在开发高并发系统时有三把利器用来保护系统:缓存、降级和限流。缓存的目的是提升系统访问速度和增大系统能处理的容量;降级是当服务出问题或者影响到核心流程的性能则需要暂时屏蔽掉,待高峰或者问题解决后再打开;而有些场景并不能用缓存和降级来解决,比如稀缺资源(秒杀)、写服务(下单),因此需有一种手段来限制这些场景的并发/请求量,即限流。
限流的目的是通过对并发访问/请求进行限速或者一个时间窗口内的的请求量进行限速来保护系统,一旦达到限制链接数、速率则可以拒绝服务、排队等。
高并发系统常见的限流主要有:限制并发数、限制每秒请求数等。
限流的实现方案有很多种,日常工作主要会遇到如下方式限流:
容器限流:比如 Tomcat、Nginx 等限流手段,Tomcat 可以设置最大线程数, Nginx 提供了两种限流手段:一是控制速率,二是控制并发链接数;
应用服务层限流:我们在应用程序通过限流算法实现限流。
如上图,一个完整的系统应该是有Rate_Limit、Rate_Contr两部分的,前者来自我保护,后者依据上游系统限制来控制请求速率,控制请求流量。
此篇文章我们从最简单的一块开始,设计实现对下游系统并发数的限制,即B应用的Rate_Limit部分,此部分通过对下游系统(A)并发数、请求数等限制达到自我保护的目的。
1.分析
并发数即链接数,很多人搞不清并发数限流和请求数限流,这东西和你工作年限没直接关系,搞不清就搞不清吧。并发是一个茅房有十个坑,最多只能允许十个人同时蹲,不管蹲坑的蹲多久,只有一个提裤子走人,才能来下一个蹲,指的是某一时刻的状态。请求数是不管蹲坑的有没有提裤子走人,间隔指定时间后后面的人就进去蹲,怎么蹲的下就不是你所关心的事了。
如果也关注过限流这块东西,那就肯定知道滑动窗口、令牌桶算法、漏桶算法等... 老生常谈的东西。
不过我们这篇要讲的部分不涉及到这些算法,后面要设计的客户端/服务端请求数限流才会涉及到这部分。分布式环境下开源的一些组件已经不在适用,如Guava的 RateLimiter、Java 并发库的Semaphore,这些只是解决单节点流量限制。心情不好,废话少说,进入下部分.....
2.设计
此处,可以类比令牌桶算法,不同的是,当一块令牌被取走,系统不会自己再生产令牌,而是等着取走令牌的线程办完事归还回来,即固定数的令牌重复使用,依靠固定数的令牌来限制同一时刻在处理的最多任务数,说白了就是一个计数器,和单节点唯一不同的是,就是这个计数的地方不能放在JVM层了,而是放在各节点都可以访问到的地方,即放到Redis里,这么说就没啥难的了,难点就是在使用Redis什么数据结构了,以及怎么保证多操作指令的原子性问题上了。
比如根据交易类型+商户号为维度对下游系统进行并发数限流,我们编写如下Lua脚本:
/**
* 并发限流初始化Lua脚本
*/
private final static String CONC_INIT_SCRIPT = "" +
"local tab = KEYS[1]\n" +
"local key = ARGV[1]\n" +
"local init_size = tonumber(ARGV[2])\n" +
"\n" +
"local exists = tonumber(redis.call('HEXISTS', tab, key .. '_conc_size'))\n" +
"if (exists == 0) then\n" +
" redis.call('HSET', tab, key .. '_conc_size', init_size)\n" +
" redis.call('HSET', tab, key .. '_conc_cur', 0)\n" +
"else\n" +
" redis.call('HSET', tab, key .. '_conc_size', init_size)\n" +
"end\n";
/**
* 并发限流Lua脚本
*/
private final static String CONC_GET_SCRIPT = "" +
"local tab = KEYS[1]\n" +
"local key = ARGV[1]\n" +
"local lock_t = tonumber(ARGV[2])\n" +
"\n" +
"local exists = tonumber(redis.call('HEXISTS', tab, key .. '_conc_size'))\n" +
"if (exists == 0) then\n" +
" return -1\n" +
"end\n"+
"local max_size = tonumber(redis.call('HGET', tab, key .. '_conc_size'))\n" +
"\n" +
"if (lock_t == 1) then\n" +
" local lv = tonumber(redis.call('HGET', tab, key .. '_conc_cur'))\n" +
" if (lv >= max_size) then\n" +
" return 0\n" +
" else\n" +
" redis.call('HINCRBY', tab, key .. '_conc_cur', 1)\n" +
" return 1\n" +
" end\n" +
"else\n" +
" local lv = tonumber(redis.call('HGET', tab, key .. '_conc_cur'))\n" +
" if (lv > 0) then\n" +
" redis.call('HINCRBY', tab, key .. '_conc_cur', -1)\n" +
" return 1\n" +
" else\n" +
" return 0\n" +
" end\n" +
"end\n";
定义如上Lua脚本后,我们可以在Redis中创建了如下数据结构:
Key:trans:limit:concurtable
Field:transType#merchantNo+_conc_size 最大链接数(总茅坑数)
Field:transType#merchantNo+_conc_cur 已使用链接数(已被蹲数)
127.0.0.1:6379> hgetall trans:limit:concurtable
1) "deduct#20021122_conc_size"
2) "10"
3) "deduct#20021122_conc_cur"
4) "0"
当客户端发起调用,请求到达服务端后,先尝试获取令牌,获取到令牌后才进行下面处理流程,否则直接退出,这步骤实现方式有很多种,过滤器、拦截器、切面等等方式,目的都是在请求到达Controller层之前进行流量控制,案例使用的是切面方式,自定义注解@AccessLimit,标注在指定接口方法,然后切面环绕通知,也就是+1、-1 问题,即完成功能。
源码地址:https://gitee.com/kyn1479/rate_limit.git
下载后只需修改Redis配置,启动后调用限流配置接口:
localhost:8080/testRateLimit/addCurrentLimit?transType=deduct&merchantNo=20021122&maxConc=10
然后调用测试接口:
http://localhost:8080/testRateLimit/currentLimit
{
"transType": "deduct",
"merchantNo": "20021122",
"time": "20211128"
}
运行结果:
2021-12-05 18:41:26.520 INFO 11064 --- [nio-8080-exec-1] c.k.c.aspect.AccessLimitAspect : AccessLimitAspect.around 进入并发数限流,获取令牌....
2021-12-05 18:41:26.520 INFO 11064 --- [nio-8080-exec-1] c.k.c.service.impl.ConcurentLimitImpl : 并发控制限流获取令牌key:deduct#20021122
2021-12-05 18:41:26.522 INFO 11064 --- [nio-8080-exec-1] c.k.c.service.impl.ConcurentLimitImpl : 并发控制限流获取令牌key:deduct#20021122,结果:1
2021-12-05 18:41:26.522 INFO 11064 --- [nio-8080-exec-1] c.k.c.aspect.AccessLimitAspect : AccessLimitAspect.around before....
2021-12-05 18:41:26.577 INFO 11064 --- [nio-8080-exec-1] c.k.c.controller.RateLimitController : RateLimitController.test入参:{"merchantNo":"20021122","transType":"deduct"}
2021-12-05 18:41:26.577 INFO 11064 --- [nio-8080-exec-1] c.k.c.aspect.AccessLimitAspect : AccessLimitAspect.around after....
2021-12-05 18:41:26.577 INFO 11064 --- [nio-8080-exec-1] c.k.c.aspect.AccessLimitAspect : AccessLimitAspect.around 进入并发数限流,归还令牌....
2021-12-05 18:41:26.577 INFO 11064 --- [nio-8080-exec-1] c.k.c.service.impl.ConcurentLimitImpl : 并发控制限流归还令牌key:deduct#20021122
2021-12-05 18:41:26.577 INFO 11064 --- [nio-8080-exec-1] c.k.c.service.impl.ConcurentLimitImpl : 并发控制限流归还令牌key:deduct#20021122,结果:1
不管多忙都要有产出吧....