限流实现方案

本文介绍了限流的总体介绍和常见算法,包括令牌桶、漏桶和计数器。详细讲解了这些算法的原理和优缺点,并探讨了在分布式环境下的限流方案,如Redis存储和Lua脚本实现的原子化操作。同时,提到了应用级限流在Tomcat中的配置策略。


一.总体介绍

很多做服务接口的人或多或少的遇到这样的场景,由于业务应用系统的负载能力有限,为了防止非预期的请求对系统压力过大而拖垮业务应用系统。

    也就是面对大流量时,如何进行流量控制?

    服务接口的流量控制策略:分流、降级、限流等。本文讨论  限流策略,虽然降低了服务接口的访问频率和并发量,却换取服务接口和业务应用系统的高可用。

 实际场景中常用的限流策略:

    1.Nginx前端限流
         按照一定的规则如帐号、IP、系统调用逻辑等在Nginx层面做限流

    2.业务应用系统限流
        1、客户端限流
        2、服务端限流

    3.数据库限流
        红线区,力保数据库


二.常用的限流算法

常见的限流算法有:令牌桶、漏桶。 计数器也可以进行粗暴限流实现。

2.1 令牌桶(单机)

大小固定的令牌桶可自行以恒定的速率源源不断地产生令牌。如果令牌不被消耗,或者被消耗的速度小于产生的速度,令牌就会不断地增多,直到把桶填满。后面再产生的令牌就会从桶中溢出。最后桶中可以保存的最大令牌数永远不会超过桶的大小


流程:
1.所有的流量在放行之前需要获取一定量的 token;
2.所有的 token 存放在一个 bucket(桶)当中,每 1/r 秒,都会往这个 bucket 当中加入一个 token;
3.bucket 有最大容量(capacity or limit),在 bucket 中的 token 数量等于最大容量,而且没有 token 消耗时,新的额外的 token 会被抛弃。

这种实现方法有几个优势:
1.避免了给每一个 Bucket 设置一个定时器这种笨办法,
2.数据结构需要的内存量很小,只需要储存 Bucket 中剩余的 Token 量以及上次补充 Token 的时间戳就可以了;
3.只有在用户访问的时候,才会计算 Token 补充量,对于系统的计算资源占用量也较小。

Guava 库当中也有一个 RateLimiter,其作用也是 用来进行限流,于是阅读了 RateLimiter 的源代码,查看一些 Google 的人是如何实现 Token Bucket 算法的。

private void resync(long nowMicros) {
    // if nextFreeTicket is in the past, resync to now
    if (nowMicros > nextFreeTicketMicros) {
      storedPermits = min(maxPermits,
          storedPermits + (nowMicros - nextFreeTicketMicros) / stableIntervalMicros);
      nextFreeTicketMicros = nowMicros;
    }
}

在 resync 方法中的这句代码 storedPermits = min(maxPermits, storedPermits+ (nowMicros - nextFreeTicketMicros)/stableIntervalMicros); 就是 RateLimiter 中计算 Token 数量的方法。没有使用计时器,而是使用时间戳的方式计算。这个做法给足了 信息。我们可以在 Bucket 中存放现在的 Token 数量,然后存储上一次补充 Token 的时间戳,当用户下一次请求获取一个 Token 的时候, 根据此时的时间戳,计算从上一个时间戳开始,到现在的这个时间点所补充的所有 Token 数量,加入到 Bucket 当中。


通过使用RateLimiter简单模拟一个实现:

package com.niepeng.goldcode.common.ratelimit;

import java.util.Date;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
import java.util.concurrent.TimeUnit;

import com.google.common.util.concurrent.RateLimiter;
import com.niepeng.goldcode.util.DateUtil;

/**
 * 介绍文档:google的ratelimiter文档翻译
 * http://ifeve.com/guava-ratelimiter/
 * 
 * @author niepeng
 *
 */
public class ApiCallDemo {

	private int permitsPerSecond = 10; // 每秒10个许可
	private int threadNum = 3;

	public static void main(String[] args) {
		new ApiCallDemo().call();
	}

	private void call() {
		ExecutorService executor = Executors.newFixedThreadPool(threadNum);
		final RateLimiter rateLimiter = RateLimiter.create(permitsPerSecond);
		for (int i = 0; i < threadNum; i++) {
			executor.execute(new ApiCallTask(rateLimiter));
		}
		executor.shutdown();
	}
}

class ApiCallTask implements Runnable {
	
	private RateLimiter rateLimiter;
	private boolean runing = true;

	public ApiCallTask(RateLimiter rateLimiter) {
		this.rateLimiter = rateLimiter;
	}

	@Override
	public void run() {
		while (runing) {
			rateLimiter.acquire(); // or rateLimiter.tryAcquire()
			getData();
		}
	}

	// 模拟调用合作伙伴API接口
	private void getData() {
		System.out.println(DateUtil.format(new Date()) + ", " +Thread.currentThread().getName() + " runing!");
		try {
			TimeUnit.MILLISECONDS.sleep(100);
		} catch (InterruptedException e) {
			e.printStackTrace();
		}
	}
}

2.2 漏桶(单机)

漏桶算法强制一个常量的输出速率而不管输入数据流的突发性



流程:

到达的数据包(网络层的PDU)被放置在底部具有漏孔的桶中(数据包缓存);
漏桶最多可以排队b个字节,漏桶的这个尺寸受限于有效的系统内存。如果数据包到达的时候漏桶已经满了,那么数据包应被丢弃;
数据包从漏桶中漏出,以常量速率(r字节/秒)注入网络,因此平滑了突发流量。



2.3 计数器(单机或统一缓存系统如:redis)

限流某个接口的总并发/请求数

如果接口可能会有突发访问情况,但又担心访问量太大造成崩溃,如抢购业务;这个时候就需要限制这个接口的总并发/请求数总请求数了;因为粒度比较细,可以为每个接口都设置相应的阀值。可以使用Java中的AtomicLong进行限流。

try {
    if(atomic.incrementAndGet() > 限流数) {
        //拒绝请求
    }
    //处理请求
} finally {
    atomic.decrementAndGet();
}

当然直接使用redis:
try {
    if(shardedJedis.incr(key) > 限流数) {
        //拒绝请求
    }
    //处理请求
} finally {
 shardedJedis.decr(key);
}

2.4 对比

令牌桶和漏桶对比:   

1.令牌桶是按照固定速率往桶中添加令牌,请求是否被处理需要看桶中令牌是否足够,当令牌数减为零时则拒绝新的请求;   
2.漏桶则是按照常量固定速率流出请求,流入请求速率任意,当流入的请求数累积到漏桶容量时,则新流入的请求被拒绝;  
3.令牌桶限制的是平均流入速率(允许突发请求,只要有令牌就可以处理,支持一次拿3个令牌,4个令牌),并允许一定程度突发流量;  
4.漏桶限制的是常量流出速率(即流出速率是一个固定常量值,比如都是1的速率流出,而不能一次是1,下次又是2),从而平滑突发流入速率;  
5.令牌桶允许一定程度的突发,而漏桶主要目的是平滑流入速率;    6.两个算法实现可以一样,但是方向是相反的,对于相同的参数得到的限流效果是一样的。


三.分布式限流

3.1方案一:redis存储可用数量和上一次放入token的时间

public boolean access(String userId) {
        String key = genKey(userId);
        Map<String, String> counter = jedis.hgetAll(key);
        if (counter.size() == 0) {
            TokenBucket tokenBucket = new TokenBucket(System.currentTimeMillis(), limit - 1);
            jedis.hmset(key, tokenBucket.toHash());
            return true;
        } 
        
        TokenBucket tokenBucket = TokenBucket.fromHash(counter);
        long lastRefillTime = tokenBucket.getLastRefillTime();
        /*
         * 桶中需要补充数量
         *  1.过了整个周期了,需要补到最大值
         *  2.如果到了至少补充一个的周期了,那么需要补充部分,否则不补充
         */
        long currentTokensRemaining;
        long refillTime = System.currentTimeMillis();
        long intervalSinceLast = refillTime - lastRefillTime;
        if(intervalSinceLast > intervalInMills) {
            currentTokensRemaining = limit;
        } else {
            long grantedTokens = (long) (intervalSinceLast / intervalPerPermit);
            if(grantedTokens < 1) {
                refillTime = lastRefillTime;
            }
            currentTokensRemaining = Math.min(grantedTokens + tokenBucket.getTokensRemaining(), limit);
        }
        
        tokenBucket.setLastRefillTime(refillTime);
        if (currentTokensRemaining == 0) {
            tokenBucket.setTokensRemaining(currentTokensRemaining);
            jedis.hmset(key, tokenBucket.toHash());
            return false;
        } else {
            tokenBucket.setTokensRemaining(currentTokensRemaining - 1);
            jedis.hmset(key, tokenBucket.toHash());
            return true;
        }
    }

上面的方法是最初的实现方法,对于每一个 Token Bucket,在 Redis 上面,使用一个 Hash 进行表示,一个 Token Bucket 有 lastRefillTime 表示最后一次补充 Token 的时间,tokensRemaining 则表示 Bucket 中的剩余 Token 数量,access() 方法大致的步骤为:

1.当一个请求 Token进入 access() 方法后,先计算计算该请求的 Token Bucket 的 key;
2.如果这个 Token Bucket 在 Redis 中不存在,那么就新建一个 Token Bucket,然后设置该 Bucket 的 Token 数量为最大值减一(去掉了这次请求获取的 Token)。 在初始化 Token Bucket 的时候将 Token 数量设置为最大值这一点在后面还有讨论;
3.如果这个 Token Bucket 在 Redis 中存在,而且其上一次加入 Token 的时间到现在时间的时间间隔大于 Token Bucket 的 interval,那么也将 Bucket 的 Token 值重置为最大值减一;
4.如果 Token Bucket 上次加入 Token 的时间到现在时间的时间间隔没有大于 interval,那么就计算这次需要补充的 Token 数量,将补充过后的 Token 数量更新到 Token Bucket 中。


完整代码详见:https://github.com/niepeng/goldcode/tree/master/src/main/java/com/niepeng/goldcode/common/ratelimit/redis


这个方法在单线程的条件下面,可以比较好地满足需求,但是在多线程的条件下面会有问题,考虑用方案二(方案一做铺垫)。

3.2方案二,redis+lua

分布式限流最关键的是要将限流服务做成原子化,而解决方案可以使使用redis+lua或者技术进行实现,通过这两种技术可以实现的高并发和高性能。


根据方案一改版的redis+lua化:

其中核心部分access方法通过lua脚本实现,通过来实现原子化操作:

--[[
  A lua rate limiter script run in redis
  use token bucket algorithm.
  Algorithm explaination
  1. key, use this key to find the token bucket in redis
  2. there're several args should be passed in:
       intervalPerPermit, time interval in millis between two token permits;
       refillTime, timestamp when running this lua script;
       limit, the capacity limit of the token bucket;
       interval, the time interval in millis of the token bucket;
]] --
local key, intervalPerPermit, refillTime, burstTokens = KEYS[1], tonumber(ARGV[1]), tonumber(ARGV[2]), tonumber(ARGV[3])
local limit, interval = tonumber(ARGV[4]), tonumber(ARGV[5])
local bucket = redis.call('hgetall', key)

local currentTokens

if table.maxn(bucket) == 0 then
    -- first check if bucket not exists, if yes, create a new one with full capacity, then grant access
    currentTokens = burstTokens
    redis.call('hset', key, 'lastRefillTime', refillTime)
elseif table.maxn(bucket) == 4 then
    -- if bucket exists, first we try to refill the token bucket

    local lastRefillTime, tokensRemaining = tonumber(bucket[2]), tonumber(bucket[4])

    if refillTime > lastRefillTime then
        -- if refillTime larger than lastRefillTime, we should refill the token buckets

        -- calculate the interval between refillTime and lastRefillTime
        -- if the result is bigger than the interval of the token bucket,
        -- refill the tokens to capacity limit;
        -- else calculate how much tokens should be refilled
        local intervalSinceLast = refillTime - lastRefillTime
        if intervalSinceLast > interval then
            currentTokens = burstTokens
            redis.call('hset', key, 'lastRefillTime', refillTime)
        else
            local grantedTokens = math.floor(intervalSinceLast / intervalPerPermit)
            if grantedTokens > 0 then
                -- ajust lastRefillTime, we want shift left the refill time.
                local padMillis = math.fmod(intervalSinceLast, intervalPerPermit)
                redis.call('hset', key, 'lastRefillTime', refillTime - padMillis)
            end
            currentTokens = math.min(grantedTokens + tokensRemaining, limit)
        end
    else
        -- if not, it means some other operation later than this call made the call first.
        -- there is no need to refill the tokens.
        currentTokens = tokensRemaining
    end
end

assert(currentTokens >= 0)

if currentTokens == 0 then
    -- we didn't consume any keys
    redis.call('hset', key, 'tokensRemaining', currentTokens)
    return 0
else
    redis.call('hset', key, 'tokensRemaining', currentTokens - 1)
    return 1
end

全部代码查看:https://github.com/niepeng/goldcode/tree/master/src/main/java/com/niepeng/goldcode/common/ratelimit/redislua


使用redis+lua实现时间窗内某个接口的请求数限流,实现了该功能后可以改造为限流总并发/请求数和限制总资源数。Lua本身就是一种编程语言,也可以使用它实现复杂的令牌桶或漏桶算法。

local key = KEYS[1] --限流KEY(一秒一个)
local limit = tonumber(ARGV[1])        --限流大小
local current = tonumber(redis.call("INCRBY", key, "1")) --请求数+1
if current > limit then --如果超出限流大小
   return 0
elseif current == 1 then  --只有第一次访问需要设置2秒的过期时间
   redis.call("expire", key,"2")
end
return 1

如上操作因是在一个lua脚本中,又因Redis是单线程模型,因此是线程安全的。如上方式有一个缺点就是当达到限流大小后还是会递增的,可以改造成如下方式实现:

local key = KEYS[1] --限流KEY(一秒一个)
local limit = tonumber(ARGV[1])        --限流大小
local current = tonumber(redis.call('get', key) or "0")
if current + 1 > limit then --如果超出限流大小
   return 0
else  --请求数+1,并设置2秒过期
   redis.call("INCRBY", key,"1")
   redis.call("expire", key,"2")
   return 1
end

Java中判断是否需要限流的代码:

public static boolean acquire() throws Exception {
    String luaScript = Files.toString(new File("limit.lua"), Charset.defaultCharset());
    Jedis jedis = new Jedis("127.0.0.1", 6379);
    String key = "ip:" + System.currentTimeMillis()/ 1000; //此处将当前时间戳取秒数
    Stringlimit = "3"; //限流大小
    return (Long)jedis.eval(luaScript,Lists.newArrayList(key), Lists.newArrayList(limit)) == 1;
}

因为Redis的限制(Lua中有写操作不能使用带随机性质的读操作,如TIME)不能在Redis Lua中使用TIME获取时间戳,因此只好从应用获取然后传入,在某些极端情况下(机器时钟不准的情况下),限流会存在一些小问题。


另外按照方案一的实现,本人对lua脚本不熟悉,参考toys的实现:https://github.com/YigWoo/toys/blob/master/src/main/java/com/yichao/woo/ratelimiter/v1/rate_limiter.lua

参考文章:https://zhuanlan.zhihu.com/p/20872901

3.3方案三,nginx+lua

local locks = require "resty.lock"

local function acquire()
    local lock =locks:new("locks")
    local elapsed, err =lock:lock("limit_key") --互斥锁
    local limit_counter =ngx.shared.limit_counter --计数器

    local key = "ip:" ..os.time()
    local limit = 5 --限流大小
    local current =limit_counter:get(key)

    if current ~= nil and current + 1> limit then --如果超出限流大小
       lock:unlock()
       return 0
    end
    if current == nil then
       limit_counter:set(key, 1, 1) --第一次需要设置过期时间,设置key的值为1,过期时间为1秒
    else
        limit_counter:incr(key, 1) --第二次开始加1即可
    end
    lock:unlock()
    return 1
end
ngx.print(acquire())

实现中我们需要使用lua-resty-lock互斥锁模块来解决原子性问题(在实际工程中使用时请考虑获取锁的超时问题),并使用ngx.shared.DICT共享字典来实现计数器。如果需要限流则返回0,否则返回1。使用时需要先定义两个共享字典(分别用来存放锁和计数器数据):

http {

    ……

    lua_shared_dict locks 10m;

    lua_shared_dict limit_counter 10m;

}

有人会纠结如果应用并发量非常大那么redis或者nginx是不是能抗得住;不过这个问题要从多方面考虑:你的流量是不是真的有这么大,是不是可以通过一致性哈希将分布式限流进行分片,是不是可以当并发量太大降级为应用级限流;对策非常多,可以根据实际情况调节;像在京东使用Redis+Lua来限流抢购流量,一般流量是没有问题的。


参考:http://www.cnblogs.com/softidea/p/6229543.html

四.应用级限流(tomcat)

对于一个应用系统来说一定会有极限并发/请求数,即总有一个TPS/QPS阀值,如果超了阀值则系统就会不响应用户请求或响应的非常慢,因此我们最好进行过载保护,防止大量请求涌入击垮系统。

如果你使用过Tomcat,其Connector 其中一种配置有如下几个参数:
acceptCount:如果Tomcat的线程都忙于响应,新来的连接会进入队列排队,如果超出排队大小,则拒绝连接;
maxConnections: 瞬时最大连接数,超出的会排队等待;
maxThreads:Tomcat能启动用来处理请求的最大线程数,如果请求处理量一直远远大于最大线程数则可能会僵死。
详细的配置请参考官方文档。另外如Mysql(如max_connections)、Redis(如tcp-backlog)都会有类似的限制连接数的配置。


参考:http://jinnianshilongnian.iteye.com/blog/2305117


<think>好的,我现在需要帮助用户解决关于自定义网关限流实现方案的问题,特别是在Spring Cloud Gateway或Netflix Zuul中的设计思路。首先,我得仔细理解用户的需求。用户提到了Spring Cloud Gateway自带的限流功能有不足,比如不支持单机限流、并发量限流,以及请求频率限制不理想,所以想了解如何自定义实现。 接下来,我需要回忆一下相关的知识。根据用户提供的引用内容,引用[1]和[2]提到了Spring Cloud Gateway的Request Rate Limiting以及RedisRateLimiter的实现,基于令牌桶算法和Lua脚本。而引用[4]介绍了Sentinel的功能,包括流量控制、熔断降级等,可能可以作为参考。用户可能希望结合这些信息,设计一个自定义的限流方案。 首先,我需要考虑限流的常用算法,比如令牌桶、漏桶、计数器等。然后,确定是单机限流还是分布式限流。单机限流可以用本地缓存,比如Guava的RateLimiter,而分布式则需要Redis这样的中间件。用户提到的Zuul和Spring Cloud Gateway都是网关,可能需要不同的实现方式。 然后,考虑具体的实现步骤。比如在Spring Cloud Gateway中,可以通过自定义过滤器来实现限流逻辑。需要确定如何拦截请求,如何计算请求频率,如何处理超出限制的情况,比如返回429状态码。同时,需要考虑配置的灵活性,是否支持动态调整规则。 对于Netflix Zuul,由于它是基于Servlet的,可能需要使用过滤器或者结合Hystrix来实现限流,但Hystrix已经不再维护,所以可能需要其他方式,比如集成Sentinel。引用[4]提到Sentinel支持流量控制,可以限制QPS和并发数,这可能是一个好的选择。 还需要考虑如何存储和更新限流的状态,比如使用Redis存储计数器和时间窗口,保证分布式环境下的原子性操作,可能需要Lua脚本来实现原子性,避免竞态条件。 另外,用户可能关心如何动态更新限流规则,是否需要结合配置中心,比如Spring Cloud Config或者Nacos,来实现规则的实时更新,而无需重启服务。 最后,需要确保方案的高可用性和性能,避免限流组件本身成为瓶颈。比如使用本地缓存减少Redis访问频率,或者采用分层限流的策略,先单机限流再分布式限流。 总结一下,可能的步骤包括选择限流算法、确定单机或分布式、实现过滤器、存储机制、异常处理、动态配置等。同时,结合用户提供的引用内容,特别是引用[1][2][4]中的信息,给出具体的实现方案。</think>### 自定义网关限流实现方案设计思路 #### 1. **限流算法选择** - **令牌桶算法**:适用于平滑突发流量,如Spring Cloud Gateway自带的`RedisRateLimiter`实现[^2]。 - **漏桶算法**:控制固定速率处理请求。 - **计数器算法**:适用于简单频率限制,如单机限流[^1]。 - **滑动窗口**:提高时间窗口的精度,减少临界问题。 #### 2. **单机限流实现(Spring Cloud Gateway)** - **使用Guava RateLimiter**:本地实现令牌桶算法,适合单节点限流。 ```java // 示例:在Gateway Filter中集成Guava限流 public class LocalRateLimiterFilter implements GatewayFilter { private final RateLimiter rateLimiter = RateLimiter.create(100); // 每秒100个请求 @Override public Mono<Void> filter(ServerWebExchange exchange, GatewayFilterChain chain) { if (!rateLimiter.tryAcquire()) { exchange.getResponse().setStatusCode(HttpStatus.TOO_MANY_REQUISITIONS); return exchange.getResponse().setComplete(); } return chain.filter(exchange); } } ``` - **优点**:低延迟、无外部依赖;**缺点**:不支持分布式一致性。 #### 3. **分布式限流实现** - **Redis + Lua脚本**:通过原子操作保证高并发下的准确性,如Spring Cloud Gateway的`RedisRateLimiter`。 - **核心逻辑**:使用Redis存储令牌桶状态,Lua脚本实现原子操作。 - **Lua脚本示例**(统计时间窗口内请求数): ```lua local key = KEYS[1] local limit = tonumber(ARGV[1]) local current = redis.call('GET', key) if current and tonumber(current) > limit then return 0 else redis.call('INCR', key) redis.call('EXPIRE', key, 1) return 1 end ``` - **配置参数**:支持动态设置QPS、并发数等规则[^4]。 #### 4. **与Sentinel集成(通用方案)** - **功能**:支持QPS/并发数限流、熔断降级、系统保护。 - **步骤**: 1. 引入Sentinel依赖。 2. 定义资源名称与规则(如每秒允许100次调用): ```java List<FlowRule> rules = new ArrayList<>(); FlowRule rule = new FlowRule("custom_api"); rule.setCount(100); rule.setGrade(RuleConstant.FLOW_GRADE_QPS); rules.add(rule); FlowRuleManager.loadRules(rules); ``` 3. 在网关过滤器中调用Sentinel API进行流量控制。 #### 5. **动态规则配置** - **结合Nacos/Consul**:将限流规则存储在配置中心,监听变更事件实时更新。 - **示例**:通过Nacos下发JSON格式规则: ```json { "resource": "order_api", "grade": "QPS", "count": 200, "strategy": "DIRECT" } ``` #### 6. **限流维度设计** - **粒度控制**:可按API路径、用户IP、请求参数等维度限流。 - **示例**:针对不同用户等级设置差异化的QPS: ```java String userLevel = exchange.getRequest().getHeaders().getFirst("User-Level"); String limitKey = "api_limit_" + userLevel; // 查询Redis或本地缓存获取当前用户等级对应的限流阈值 ``` #### 7. **异常处理与响应** - **返回标准HTTP 429状态码**,携带自定义错误信息: ```json { "code": 429, "message": "请求过于频繁,请稍后重试", "retryAfter": "60s" } ``` ###
评论 2
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值