限流实现方案


一.总体介绍

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

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

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

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

    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


猜你喜欢

转载自blog.csdn.net/lsblsb/article/details/69486012