也就是面对大流量时,如何进行流量控制?
服务接口的流量控制策略:分流、降级、限流等。本文讨论 限流策略,虽然降低了服务接口的访问频率和并发量,却换取服务接口和业务应用系统的高可用。
实际场景中常用的限流策略:
1.Nginx前端限流
按照一定的规则如帐号、IP、系统调用逻辑等在Nginx层面做限流
2.业务应用系统限流
1、客户端限流
2、服务端限流
3.数据库限流
红线区,力保数据库
常见的限流算法有:令牌桶、漏桶。 计数器也可以进行粗暴限流实现。
大小固定的令牌桶可自行以恒定的速率源源不断地产生令牌。如果令牌不被消耗,或者被消耗的速度小于产生的速度,令牌就会不断地增多,直到把桶填满。后面再产生的令牌就会从桶中溢出。最后桶中可以保存的最大令牌数永远不会超过桶的大小
流程:
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;
}
}
通过使用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();
}
}
}
漏桶算法强制一个常量的输出速率而不管输入数据流的突发性
流程:
到达的数据包(网络层的PDU)被放置在底部具有漏孔的桶中(数据包缓存);
漏桶最多可以排队b个字节,漏桶的这个尺寸受限于有效的系统内存。如果数据包到达的时候漏桶已经满了,那么数据包应被丢弃;
数据包从漏桶中漏出,以常量速率(r字节/秒)注入网络,因此平滑了突发流量。
如果接口可能会有突发访问情况,但又担心访问量太大造成崩溃,如抢购业务;这个时候就需要限制这个接口的总并发/请求数总请求数了;因为粒度比较细,可以为每个接口都设置相应的阀值。可以使用Java中的AtomicLong进行限流。
try {
if(atomic.incrementAndGet() > 限流数) {
//拒绝请求
}
//处理请求
} finally {
atomic.decrementAndGet();
}
try {
if(shardedJedis.incr(key) > 限流数) {
//拒绝请求
}
//处理请求
} finally {
shardedJedis.decr(key);
}
令牌桶和漏桶对比:
public boolean access(String userId) {
String key = genKey(userId);
Map 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;
}
}
完整代码详见:https://github.com/niepeng/goldcode/tree/master/src/main/java/com/niepeng/goldcode/common/ratelimit/redis
分布式限流最关键的是要将限流服务做成原子化,而解决方案可以使使用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
使用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
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
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;
}
另外按照方案一的实现,本人对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
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,其Connector 其中一种配置有如下几个参数:
acceptCount:如果Tomcat的线程都忙于响应,新来的连接会进入队列排队,如果超出排队大小,则拒绝连接;
maxConnections: 瞬时最大连接数,超出的会排队等待;
maxThreads:Tomcat能启动用来处理请求的最大线程数,如果请求处理量一直远远大于最大线程数则可能会僵死。
详细的配置请参考官方文档。另外如Mysql(如max_connections)、Redis(如tcp-backlog)都会有类似的限制连接数的配置。
参考:http://jinnianshilongnian.iteye.com/blog/2305117