背景
为什么要限流?为什么要做这个需求?
因为生产故障,就是请求从以前的2000/m,突然飙高到6000/m,数据库万级别的连接都打满了,导致连接池满了,dubbo线程池也满了。
最后疯狂告警,
1.网关请求量翻了3倍
2.数据库万级别的连接满了
3.连接池满了
4.dubbo线程池满了
由于连接池满了,获取数据库连接阻塞,导致获取数据库连接耗时从几秒到几分钟,由于处理慢处理不过来,又导致dubbo线程池满了,后面的请求就直接失败了。也就是说,获取连接慢,导致阻塞,从而导致dubbo线程池满,dubbo线程池堆的越多,里面的请求由于获取连接耗时太久,很可能就超时了,导致请求失败。最后在很长的时间内,就产生了恶性循环。
怎么解决?首先请求高峰的问题不是彻底完全的解决,而是降低请求失败数量,降低失败时长,避免整个系统都崩溃了,但是可以允许部分请求失败。以前是大部分请求都失败了,要么由于耗时太久,导致请求超时;要么由于dubbo线程池满,直接把请求拒绝了。现在如果有限流,比如30个请求/秒,一分钟大概就是2000个请求,也就是说,1s内的30个请求是有效的,但是超过30个的请求就直接被拒绝了,这样就用允许少量的部分请求失败,避免了整个系统陷入之前的恶性循环,不仅会导致大部分请求都失败了,而且在很长的时间内,都一直是如此,那么整个系统长时间不可用,就等于崩溃了。
这就是限流的作用,就是控制单位时间内的总的请求数量,如果超过,就直接拒绝请求。
与并发的区别?
dubbo也可以限制并发数量,那区别是什么呢?区别是,一个是限制同一时间的并发数量(即dubbo线程池里的活跃数量),比如dubbo默认线程池数量是200,那么最高并发数量就是200,超过200,就会直接拒绝请求。
并发是同一时间(所谓同一时间是瞬时,平常所谓的峰值就是这个意思)的请求数量,而限流是单位时间(即一段时间,可以是1秒,也可以是1分钟)内的总的请求数量。
配置
<dubbo:service protocol="dubbo" interface="xxx.xxx.service.IxxxPay" ref="xxxPayService" validation="false">
<dubbo:parameter key="tps" value="30"/> //请求数量
<dubbo:parameter key="tps.interval" value="1000"/> //时间长度(单位是毫秒),比如这里是1000ms其实就是1s,也就是1s内只允许30个请求
</dubbo:service>
配置完这个,就可以生效了。
动态修改
如果想要在运行期间,动态修改怎么办?
在dubbo admin配置。
步骤
1.找到对应的服务
2.然后,添加配置参数
dubbo限流有bug
动态修改如果要生效,必须
1.先改tps为-1 //目的是删除之前的旧值
2.再改为其他正数值 //目的是使用当前动态配置的新值
详细实现原理和源码分析见下文的源码分析。
修复dubbo限流bug
这个bug在2.7版本已经修复,在2.6版本还没有修复。于是顺手修复dubbo bug。 github.com/apache/dubb…
主要修改是
1.以前如果旧值存在,直接就忽略新值了,继续使用旧值,导致新值没生效。
2.现在是如果旧值存在,就要比较新值和旧值,如果不一样,就使用新值。
源码分析
dubbo限流实现原理
dubbo限流拦截器
public static final String TPS_LIMIT_RATE_KEY = "tps";
/**
* Limit TPS for either service or service's particular method
*/
@Activate(group = Constants.PROVIDER, value = Constants.TPS_LIMIT_RATE_KEY//
@Activate注解的作用是,只要配置了tps,tps拦截器就自动生效)
public class TpsLimitFilter implements Filter {
private final TPSLimiter tpsLimiter = new DefaultTPSLimiter();
@Override
public Result invoke(Invoker<?> invoker, Invocation invocation) throws RpcException {
//校验是否通过限流拦截器
if (!tpsLimiter.isAllowable(invoker.getUrl(), invocation)) {
throw new RpcException(
"Failed to invoke service " +
invoker.getInterface().getName() +
"." +
invocation.getMethodName() +
" because exceed max service tps.");
}
//调用dubbo接口
return invoker.invoke(invocation);
}
}
限流功能实现类
public class DefaultTPSLimiter implements TPSLimiter {
private final ConcurrentMap<String, StatItem> stats
= new ConcurrentHashMap<String, StatItem>();
@Override
public boolean isAllowable(URL url, Invocation invocation) {
int rate = url.getParameter(Constants.TPS_LIMIT_RATE_KEY, -1); //从url获取tps值
long interval = url.getParameter(Constants.TPS_LIMIT_INTERVAL_KEY,
Constants.DEFAULT_TPS_LIMIT_INTERVAL); //从url获取时间长度值
String serviceKey = url.getServiceKey();
if (rate > 0) { //如果tps值大0
StatItem statItem = stats.get(serviceKey); //获取限流旧值
if (statItem == null) { //如果限流旧值不存在,就使用新值
stats.putIfAbsent(serviceKey,
new StatItem(serviceKey, rate, interval));
statItem = stats.get(serviceKey);
}
return statItem.isAllowable();
} else { //否则,删除旧值
StatItem statItem = stats.get(serviceKey);
if (statItem != null) {
stats.remove(serviceKey);
}
}
return true;
}
}
限流数据和限流算法类,包含了
1.限流数据
2.限流算法
class StatItem {
private String name;
private long lastResetTime; //初始值是默认值,默认值是0
private long interval; //时间长度,比如1000ms
private AtomicInteger token;
private int rate; //tps,比如50
//限流算法,校验限流是否通过
public boolean isAllowable() {
long now = System.currentTimeMillis();
if (now > lastResetTime + interval) { //第一次请求:now(当前时间的微秒数量) > lastResetTime(初始值是0) + interval(1000)
token.set(rate); //token=50
lastResetTime = now; //lastResetTime=第一次请求时间
}
int value = token.get(); //50
boolean flag = false;
while (value > 0 && !flag) {
flag = token.compareAndSet(value, value - 1); //token自减1之后就是49
value = token.get(); //token的值现在是49,也就是剩余49,因为刚刚已经放过去了一个请求,就要减1
}
return flag;
}
源码分析,
1、第一次请求
第一次请求,显然会执行这个代码块
if (now > lastResetTime + interval) {
token.set(rate);
lastResetTime = now;
}
即now(当前时间的微秒数量) > lastResetTime(初始值是0) + interval(1000) ,所以为真,所以第一次请求的时候,会设置token的数量为50,lastResetTime的值是第一次请求时间。
然后,代码接着往下走,其实就是50-1=49,本质就是当前请求进来,然后数量-1,然后就剩余49。
2、第二次请求
49-1=48
3、。。。
1s内可以允许50次请求,超过就拒绝!
4、下一秒
下一秒就是下一次循环,其实就是if判断里的当前时间已经超过了1s
if (now > lastResetTime + interval) {
token.set(rate);
lastResetTime = now;
}
即now(当前时间) > lastResetTime(上一次时间) + interval(1000ms),其实就是超过了1s之后,数量再次重置为50。
从这里我们看到,限流算法用的是固定时间窗口,而不是滑动时间窗口,也不是令牌,虽然代码里有令牌token,但是其实并不是令牌算法,为什么呢?因为判断时间的代码,其实就是判断当前时间是否超过了1s,如果超过1s,那么就进入下个周期。
而令牌的特点是,以固定速度写给token桶加1,怎么实现固定速度?算法:1000ms/50个,那么就是每隔20ms,往token桶里面加1。说白了,就是20ms内只允许一个请求,超过就拒绝。
由此可见,时间窗口的本质是1s允许50个请求,而令牌桶的本质是控制写的速度,即控制放令牌token到令牌桶的速度,具体来说就是20ms只能放一个token,两个算法的维度不一样。本质是时间窗口这个维度控制不了请求的均匀分布,而令牌桶其实也控制不了请求的均匀分布,为什么呢,因为外部的请求是否均匀分布,不是你能控制的。
所以,时间窗口和令牌桶的本质区别不在于控制外部请求是否均匀分布,那区别是什么呢?区别在于控制系统内部token的分布是否均匀。如果是时间窗口,那么是1s内总共50个请求,时间粒度是1s。如果是令牌桶,那么是1000ms/50=20ms放一个token,粒度是20ms。本质区别就是时间粒度更细了,其他的其实是没什么区别的。
而且,时间窗口其实也可以配置为1个请求/20ms,当配置为1个请求/20ms的时候,其实时间粒度就是和令牌桶50个请求/1000ms的请求分布均匀程度是一样的。
为什么这么说呢?因为都是20ms只允许一个请求,超过就拒绝。不管是时间窗口还是令牌桶,起到的效果都是一样的。
刚才上面讲的是理论,其实实际用的时候,一般用时间窗口,就够了,说白了,就是直接用固定时间窗口就够了,dubbo其实就是固定时间窗口,而不是有的书里面或网上文章写的令牌算法,他们只看到token这个单词就以为是令牌桶算法,其实并不是!
为什么只需要用固定时间窗口就够了,因为限流效果都一样,都是超过了就拒绝。而且令牌桶算法把时间粒度搞的更细了之后,反而如果在20ms只能有多个请求,就拒绝了,但是如果是固定时间窗口,只要1s内没有超过50个请求,就不会阻绝,这个才是符合我们的需求的!
至于50个请求是不是瞬间到来都不要紧,因为反正都是超过了就拒绝,也不会冲垮系统。
那为什么不用滑动?滑动就名字好听,看起来好像解决了问题,实际还是一样,都是超过了就拒绝,既然都是拒绝,然后都限流成功,系统都没有被冲垮,那为什么还要用一个更复杂的算法呢?滑动算法更复杂,实现起来更复杂,跑起来也更好资源,吃力不讨好!
漏桶
最后再讲一下漏桶,漏桶是在令牌桶的基础之上,加了一个读的速度。令牌桶是写的速度1000ms/50=20ms,即每隔20ms,token加1。漏桶是,读的速度也要控制一下,也是每隔20ms,token减1。控制写的速度,是防止一下子就用完了,比如前面放了9个token,但是没有请求,然后放第10个token的时候,却一下子来了10个请求,就一次性把token全部用完了;而控制器读的速度,是防止一下子就读完了,即哪怕一下子来了10个请求,在20ms内也只能拿到一个token,而不是一下子全部拿完了。
dubbo限流为什么有bug?
主要有两点
1.直接动态修改,tps新值没有生效
为什么没有生效?看源码。
public class DefaultTPSLimiter implements TPSLimiter {
private final ConcurrentMap<String, StatItem> stats
= new ConcurrentHashMap<String, StatItem>();
@Override
public boolean isAllowable(URL url, Invocation invocation) {
int rate = url.getParameter(Constants.TPS_LIMIT_RATE_KEY, -1); //获取限流新值tps
long interval = url.getParameter(Constants.TPS_LIMIT_INTERVAL_KEY,
Constants.DEFAULT_TPS_LIMIT_INTERVAL); //获取限流新值时间长度
String serviceKey = url.getServiceKey();
if (rate > 0) {
StatItem statItem = stats.get(serviceKey); //获取限流旧值
if (statItem == null) { //校验限流旧值是否存在,如果不存在,才用限流新值——问题就出在这里,如果旧值存在,那么新值就没有生效,所以这是个bug。
stats.putIfAbsent(serviceKey,
new StatItem(serviceKey, rate, interval));
statItem = stats.get(serviceKey);
}
return statItem.isAllowable(); //校验当前请求是否通过限流
} else {
StatItem statItem = stats.get(serviceKey);
if (statItem != null) {
stats.remove(serviceKey);
}
}
return true;
}
}
2.所以,解决方法是先改为负值(比如-1)
1)为什么改为负值就可以生效?
还是来看源码
public class DefaultTPSLimiter implements TPSLimiter {
private final ConcurrentMap<String, StatItem> stats
= new ConcurrentHashMap<String, StatItem>();
@Override
public boolean isAllowable(URL url, Invocation invocation) {
int rate = url.getParameter(Constants.TPS_LIMIT_RATE_KEY, -1);
long interval = url.getParameter(Constants.TPS_LIMIT_INTERVAL_KEY,
Constants.DEFAULT_TPS_LIMIT_INTERVAL);
String serviceKey = url.getServiceKey();
if (rate > 0) { 2.再改为正数,就走这里
StatItem statItem = stats.get(serviceKey); //获取限流旧值
if (statItem == null) { //因为刚才已经删除了旧值,所以现在旧值不存在
stats.putIfAbsent(serviceKey,
new StatItem(serviceKey, rate, interval)); //设置限流新值
statItem = stats.get(serviceKey);
}
return statItem.isAllowable();
} else { //1.先改为负值,如果是负值,就走这里
StatItem statItem = stats.get(serviceKey); //获取限流旧值
if (statItem != null) { //如果旧值存在,就删除旧值
stats.remove(serviceKey);
}
}
return true;
}
}
实现思路是
1.先改为负值,目的是删除限流旧值
2.再改为正数,目的是使用限流新值
2)改为0可以吗?
不可以。为什么不可以?因为如果tps值为0,dubbo限流拦截器就失效了,根本没有进入限流拦截器,就无从谈起使用新值,反而连旧值也失效了,因为根本没有走限流拦截器。
为什么tps值为0,会导致限流拦截器失效?因为dubbo拦截器是否开启,会校验@Activate注解的value属性的值,即如果tps值为0,拦截器就会失效,这个失效不光光是限流拦截器,如果其他拦截器的@Activate注解的value属性的值为0,该拦截器也会失效。
下面是源码
原理是
1.请求来了
2.经过dubbo拦截器
3.那到底要经过哪些拦截器?
会校验每个拦截器是启用还是失效,源码实现如上截图。
4.如果限流拦截器失效,就不会走限流拦截器;
如果启用,就走。