开发高并发系统保护系统的三大利器:

  • 缓存
  • 降级
  • 限流

缓存可以提升系统访问速度以及提高系统并发能力

当服务出问题或者影响到核心流程的性能贼需要暂时屏蔽掉,待高峰或者问题解决后打开

通过限流手段来限制某些场景的并发请求量

限流

限流的目的是通过对并发访问进行限速或者一个时间窗口内的请求进行限速来保护系统,一旦达到限制速率就可以拒绝服务(定向到错误页或告知资源不存在)、排队或者等待(秒杀活动)、降级(返回兜底数据或默认数据,如商品详情页库存默认有货)。

常见限流

  • 限制总并发数(数据库连接池、线程池)
  • 限制瞬时并发数(Nginx中limit_conn模块,限制瞬时并发连接数)
  • 限制时间窗口内的平均速率(Guava的RateLimiter, Nginx的limit_req模块,限制每秒的平均速率)
  • 限制远程接口调用速率、限制MQ的消费速率
  • 通过网络连接数、网络流量、CPU或内存负载等来限流

常见限流算法

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

应用级限流(单机单应用限流)

  • 限制总并发/连接/请求数,Tomcat Connector配置参数

    • acceptCount:如果Tomcat的线程都忙于响应,新来的连接会进入队列排队,如果超出排队大小,则拒绝连接;

    • maxConnections: 瞬时最大连接数,超出的会排队等待;

    • maxThreads:Tomcat能启动用来处理请求的最大线程数,如果请求处理量一直远远大于最大线程数则可能会僵死。

  • 限流总资源数

如果有的资源是稀缺资源(如数据库连接、线程),而且可能有多个系统都会去使用它,那么需要限制应用;可以使用池化技术来限制总资源数:连接池、线程池。比如分配给每个应用的数据库连接是100,那么本应用最多可以使用100个资源,超出了可以等待或者抛异常

  • 限流某个接口的总并发/请求数
1
2
3
4
5
6
7
8
try {
if(atomic.incrementAndGet() > Threshold) {
//拒绝请求
}
//处理请求
} finally {
atomic.decrementAndGet();
}
  • 限流某个接口的时间窗请求数

限制某个接口/服务每秒/每分钟/每天的请求数/调用量,使用Guava的Cache来存储计数器,过期时间设置为2秒(保证1秒内的计数器是有的),然后我们获取当前时间戳然后取秒数来作为KEY进行计数统计和限流

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
LoadingCache<Long, AtomicLong> counter =
CacheBuilder.newBuilder()
.expireAfterWrite(2, TimeUnit.SECONDS)
.build(new CacheLoader<Long, AtomicLong>() {
@Override
public AtomicLong load(Long seconds) throws Exception {
return new AtomicLong(0);
}
});
long limit = 1000;
while(true) {
//得到当前秒
long currentSeconds = System.currentTimeMillis() / 1000;
if(counter.get(currentSeconds).incrementAndGet() > limit) {
System.out.println("限流了:" + currentSeconds);
continue;
}
//业务处理
}
  • 平滑限流某个接口的请求数

之前的限流方式都不能很好地应对突发请求,即瞬间请求可能都被允许从而导致一些问题;因此在一些场景中需要对突发请求进行整形,整形为平均速率请求处理(比如5r/s,则每隔200毫秒处理一个请求,平滑了速率)。这个时候有两种算法满足我们的场景:令牌桶和漏桶算法。Guava框架提供了令牌桶算法实现,Guava RateLimiter提供了令牌桶算法实现:平滑突发限流(SmoothBursty)和平滑预热限流(SmoothWarmingUp)实现。

SmoothBursty

令牌桶算法的一种实践,RateLimiter还提供了tryAcquire方法来进行无阻塞或可超时的令牌消费。这种方法允许一定程度上的并发

1
2
3
4
5
6
7
RateLimiter limiter = RateLimiter.create(5);  //设置桶容量为5,每秒新增5个令牌
System.out.println(limiter.acquire()); //0.0 成功返回0,可以预支一个令牌
System.out.println(limiter.acquire()); //0.198239 否则返回等待时间,差不多每个都等了200ms
System.out.println(limiter.acquire()); //0.196083
System.out.println(limiter.acquire()); //0.200609
System.out.println(limiter.acquire()); //0.199599
System.out.println(limiter.acquire()); //0.19961
1
2
3
4
RateLimiter limiter = RateLimiter.create(5);
System.out.println(limiter.acquire(5)); //0.0 允许一定程度并发,预支5个令牌
System.out.println(limiter.acquire(1)); //0.98745 等待5个令牌填充
System.out.println(limiter.acquire(1)); //0.183553 等待1个令牌填充

SmoothWarmingUp

因为SmoothBursty允许一定程度的突发,会有人担心如果允许这种突发,假设突然间来了很大的流量,那么系统很可能扛不住这种突发。因此需要一种平滑速率的限流工具,从而系统冷启动后慢慢的趋于平均固定速率(即刚开始速率小一些,然后慢慢趋于我们设置的固定速率)

1
2
3
4
5
6
7
8
9
10
11
RateLimiter limiter = RateLimiter.create(5, 1000, TimeUnit.MILLISECONDS); //每秒新增5个令牌,从冷启动过渡到正常状态需要1000毫秒 
for(int i = 1; i < 5;i++) {
System.out.println(limiter.acquire());
}
//输出0.0,0.51767,0.357814,0.219992,0.199984
//先预支一个,然后冷启动,令牌新增数量比较慢,慢慢恢复正常200毫秒一个
Thread.sleep(1000L);
for(int i = 1; i < 5;i++) {
System.out.println(limiter.acquire());
}
//0.0, 0.360826, 0.220166, 0.199723, 0.199555 同理,冷启动过渡

分布式限流

分布式限流最关键的是要将限流服务做成原子化,可以通过redis+lua或者Nginx+lua实现。分布式限流主要是业务上的限流,而不是流量入口的限流;流量入口的限流应该使用接入层限流。

  • redis+lua实现固定时间分片,某个接口的请求数限流

lua脚本, 在一个lua脚本中,由于Redis是单线程模型,因此是线程安全的

1
2
3
4
5
6
7
8
9
10
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脚本

1
2
3
4
5
6
7
8
//因为Redis的限制(Lua中有写操作不能使用带随机性质的读操作,如TIME)不能在Redis Lua中使用TIME获取时间戳,因此只好从应用获取然后传入,在某些极端情况下(机器时钟不准的情况下),限流会存在一些小问题
public static boolean acquire() throws Exception {
String luaScript = Files.toString(new File("limit.lua"), Charset.defaultCharset());
Jedis jedis = new Jedis("192.168.147.52", 6379);
String key = "ip:" + System.currentTimeMillis()/ 1000; //此处将当前时间戳取秒数
Stringlimit = "3"; //限流大小
return (Long)jedis.eval(luaScript,Lists.newArrayList(key), Lists.newArrayList(limit)) == 1;
}
  • Nginx+lua

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

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
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())

1
2
3
4
5
http {  
……
lua_shared_dict locks 10m;
lua_shared_dict limit_counter 10m;
}

接入层限流

接入层通常指请求流量的入口,接入层通常需要负载均衡、非法请求过滤、请求聚合、缓存、降级、限流、A/B测试、服务质量监控。

使用Ngix做接入层限流,limit_conn用来对某个KEY对应的总的网络连接数进行限流,可以按照如IP、域名维度进行限流。limit_req用来对某个KEY对应的请求的平均速率进行限流,并有两种用法:平滑模式(delay)和允许突发模式(nodelay)。

  • 连接数限流模块ngx_http_limit_conn_module
  • 漏桶算法实现的请求限流模块ngx_http_limit_req_module
  • 使用OpenResty提供的Lua限流模块lua-resty-limit-traffic进行更复杂的限流场景

ngx_http_limit_conn_module

limit_conn是对某个KEY对应的总的网络连接数进行限流。可以按照IP来限制IP维度的总连接数,或者按照服务域名来限制某个域名的总连接数。但是记住不是每一个请求连接都会被计数器统计,只有那些被Nginx处理的且已经读取了整个请求头的请求连接才会被计数器统计。limt_conn可以限流某个KEY的总并发/请求数,KEY可以根据需要变化。

1
2
3
4
5
6
7
8
9
10
http {
limit_conn_zone$binary_remote_addr zone=addr:10m;
limit_conn_log_level error;
limit_conn_status 503;
...
server {
...
location /limit {
limit_conn addr 1;
}

limit_conn:要配置存放KEY和计数器的共享内存区域和指定KEY的最大连接数;此处指定的最大连接数是1,表示Nginx最多同时并发处理1个连接

limit_conn_zone:用来配置限流KEY、及存放KEY对应信息的共享内存区域大小;此处的KEY是“$binary_remote_addr”其表示IP地址,也可以使用如$server_name作为KEY来限制域名级别的最大连接数;

limit_conn_status:配置被限流后返回的状态码,默认返回503;

limit_conn_log_level:配置记录被限流后的日志级别,默认error级别。

limit_conn的主要执行过程如下所示:

1、请求进入后首先判断当前limit_conn_zone中相应KEY的连接数是否超出了配置的最大连接数;

2.1、如果超过了配置的最大大小,则被限流,返回limit_conn_status定义的错误状态码;

2.2、否则相应KEY的连接数加1,并注册请求处理完成的回调函数;

3、进行请求处理;

4、在结束请求阶段会调用注册的回调函数对相应KEY的连接数减1。

按照IP限制并发连接数配置示例

首先定义IP维度的限流区域:

limit_conn_zone $binary_remote_addrzone=perip:10m;

接着在要限流的location中添加限流逻辑:

location /limit {
limit_conn perip 2;
echo “123”;
}

即允许每个IP最大并发连接数为2。

按照域名限制并发连接数配置示例

首先定义域名维度的限流区域:

limit_conn_zone $ server_name zone=perserver:10m;

接着在要限流的location中添加限流逻辑:

location /limit {
limit_conn perserver 2;
echo “123”;
}

即允许每个域名最大并发请求连接数为2;这样配置可以实现服务器最大连接数限制。

ngx_http_limit_req_module

limit_req是漏桶算法实现,用于对指定KEY对应的请求进行限流,比如按照IP维度限制请求速率。

配置示例:

1
2
3
4
5
6
7
8
9
10
http {
limit_req_zone $binary_remote_addr zone=one:10m rate=1r/s;
limit_conn_log_level error;
limit_conn_status 503;
...
server {
...
location /limit {
limit_req zone=one burst=5 nodelay;
}

limit_req:配置限流区域、桶容量(突发容量,默认0)、是否延迟模式(默认延迟);

limit_req_zone:配置限流KEY、及存放KEY对应信息的共享内存区域大小、固定请求速率;此处指定的KEY是“$binary_remote_addr”表示IP地址;固定请求速率使用rate参数配置,支持10r/s和60r/m,即每秒10个请求和每分钟60个请求,不过最终都会转换为每秒的固定请求速率(10r/s为每100毫秒处理一个请求;60r/m,即每1000毫秒处理一个请求)。

limit_conn_status:配置被限流后返回的状态码,默认返回503;

limit_conn_log_level:配置记录被限流后的日志级别,默认error级别。

limit_req的主要执行过程如下所示:

1、请求进入后首先判断最后一次请求时间相对于当前时间(第一次是0)是否需要限流,如果需要限流则执行步骤2,否则执行步骤3;

2.1、如果没有配置桶容量(burst),则桶容量为0;按照固定速率处理请求;如果请求被限流,则直接返回相应的错误码(默认503);

2.2、如果配置了桶容量(burst>0)且延迟模式(没有配置nodelay);如果桶满了,则新进入的请求被限流;如果没有满则请求会以固定平均速率被处理(按照固定速率并根据需要延迟处理请求,延迟使用休眠实现);

2.3、如果配置了桶容量(burst>0)且非延迟模式(配置了nodelay);不会按照固定速率处理请求,而是允许突发处理请求;如果桶满了,则请求被限流,直接返回相应的错误码;

3、如果没有被限流,则正常处理请求;

4、Nginx会在相应时机进行选择一些(3个节点)限流KEY进行过期处理,进行内存回收。