本文主要讲算法实现, 跟Sentinel关系不大, 无需对Sentinel加以了解
本文会辅以部分源码解析, 源码在git 上: https://github.com/alibaba/Sentinel
快速失败限流(普通限流 DefaultController)
快速失败很简单, 超过了阈值就抛出异常。
主要依赖时间窗口。
对应实现类:com.alibaba.csp.sentinel.slots.block.flow.controller.DefaultController
原理主要需要了解滑动窗口。
滑动窗口
写了两页PPT, 直接看图就好了:
滑动窗口的滑动:
于是, 当前定义时间段(秒级)就由秒级时间段的多个窗口的每个窗口的QPS之和累计即可
热加载限流(WarnUp)
一般会对比Guava的热加载限流, 确实Sentinel的实现上有参考的影子, 但是它自身的实现还是不一样的(可分别读两者, 没必然关联)
这里的实现非常巧妙, 纯阅读源码会被带坑里去, 花了我好久的时间
参阅源码:com.alibaba.csp.sentinel.slots.block.flow.controller.WarmUpController
热加载用的是令牌桶的形式, 其中一个牛逼的地方在于:
系统启动的时候能支持热加载;
系统运行中, 如果有低负载期,也会有热加载流程
Sentinel的令牌桶主要参照令牌桶中的**剩余令牌数**概念, 有这么几个重点:
令牌桶不能空, 空了就没法取令牌了, 需要有个警戒值
桶满的时候,为系统最低负载期(冷了很久, 或者刚启动)
剩余令牌未到警戒值, 系统处于热加载阶段, 可以算当前时间点的令牌数
剩余令牌超过警戒值, 系统热加载完毕,需要及时添加令牌保证桶不空
于是问题简化为判断当前剩余令牌数与警戒值的关系。
下面直接进行源码分析
Sentinel给令牌桶添加令牌(com.alibaba.csp.sentinel.slots.block.flow.controller.WarmUpController):
protected void syncToken(long passQps) {
long currentTime = TimeUtil.currentTimeMillis();
// 当前时间都是取整的
currentTime = currentTime - currentTime % 1000;
long oldLastFillTime = lastFilledTime.get();
// 单位秒只添加一次令牌(同时意味着热加载时期, 单位秒的限流大小是固定的)
if (currentTime <= oldLastFillTime) {
return;
}
long oldValue = storedTokens.get();
// 判断 添加多少个令牌 ↓↓↓
long newValue = coolDownTokens(currentTime, passQps);
// 为并发做准备
if (storedTokens.compareAndSet(oldValue, newValue)) {
// 当前剩余令牌数减去上一秒的令牌数, 然后再参与计算当前时间的令牌数(这个值在后续作为分母, 减掉部分之后分数才会变大)
long currentValue = storedTokens.addAndGet(0 - passQps);
if (currentValue < 0) {
storedTokens.set(0L);
}
lastFilledTime.set(currentTime);
}
}
添加多少个令牌:
private long coolDownTokens(long currentTime, long passQps) {
long oldValue = storedTokens.get();
// 大部分 热加载 时期, 这个值(剩余令牌数)就是下一秒参与计算的令牌数。
long newValue = oldValue;
// 剩余令牌数小于警戒值, 需要加令牌。
if (oldValue < warningToken) {
// 添加的个数就是 现在到上次添加令牌数的时间差, 每秒添加限流count个令牌。
// 这儿的时间差好像没什么必要, 毕竟只要小于警戒值就会加。每次消耗的量一定低于count数(大于了就不是限流了)
newValue = (long)(oldValue + (currentTime - lastFilledTime.get()) * count / 1000);
} else if (oldValue > warningToken) {
// 这个地方的目的, 个人理解就是QPS过小的时候, 必须让它一直处于热加载期(一直添加, 就会一直大于警戒值)
if (passQps < (int)count / coldFactor) {
newValue = (long)(oldValue + (currentTime - lastFilledTime.get()) * count / 1000);
}
}
// 大部分热加载时期, 初始剩余令牌数都会被一直减到达到热加载完毕为止。
// 然后才会逐步添加
return Math.min(newValue, maxToken);
}
上述是Sentinel加令牌的策略, 但是热加载时期, 每个时刻限定的QPS还需要**单独算**。
这个跟上述加令牌的逻辑是独立的, 看各人的实现。
Sentinel的 算法也很简单, 热加载有这么几个初始值:
// 限流数目
this.count = count;
// 冷加载因子, 这个值参与得到 警戒值和斜率 的计算, 固定值3
this.coldFactor = coldFactor;
// 警戒值, 限流大小 * 热加载时间 / 2。
warningToken = (int)(warmUpPeriodInSec * count) / (coldFactor - 1);
// 最大令牌数, 两倍于 警戒值
maxToken = warningToken + (int)(2 * warmUpPeriodInSec * count / (1.0 + coldFactor));
// 斜率
slope = (coldFactor - 1.0) / count / (maxToken - warningToken);
获取当前时间点的可允许通过的QPS:
// 此处的aboveToken是 令牌桶中 剩余令牌数 比 警戒值 多的部分。 在热加载时期, 剩余令牌数 一直在减少。
long aboveToken = restToken - warningToken;
// 这个算当前可允许通过的QPS的算法, 是Sentinel家的实现, 没家都可以有自家的实现的。
// 例如Sentinel约定的初始值是 1/2 * count;
// 热加载期可允许通过的QPS有上一秒通过了的QPS的运算参与
Math.nextUp(1.0 / (aboveToken * slope + 1.0 / count));
排队限流(RateLimiterController)
一般会对比Guava的热加载限流, 确实Sentinel的实现上有参考的影子, 但是它自身的实现还是不一样的(可分别读两者, 没必然关联)
这个的实现就简单易懂多了, 直接源码解析。
参阅源码:com.alibaba.csp.sentinel.slots.block.flow.controller.RateLimiterController
排队限流原则:
QPS count 按秒切分为N个单位, 每个单位:1000(ms) / count
每个单位只让一个QPS通过
通不过,预期时间能通过, sleep, 就像漏桶一样屯着
通不过,预期时间通不过, fail
并发流量中每个并发都是可以参与到每个单位的抢占的。不过并发多了, 可能导致限流不准。
排队限流的源码解析
@Override
public boolean canPass(Node node, int acquireCount, boolean prioritized) {
// 默认值, 1
if (acquireCount <= 0) {
return true;
}
// 漏桶可以直接限成0
if (count <= 0) {
return false;
}
long currentTime = TimeUtil.currentTimeMillis();
// 预计花费时间就是一个时间单位
long costTime = Math.round(1.0 * (acquireCount) / count * 1000);
// 预计结束时间就是上一个抢到令牌的流量的通过时间
long expectedTime = costTime + latestPassedTime.get();
if (expectedTime <= currentTime) {
// 预计结束时间小于当前时间, 当然直接放流。同时设定通过时间。
latestPassedTime.set(currentTime);
return true;
} else {
// 计算可能会等待的时间
long waitTime = costTime + latestPassedTime.get() - TimeUtil.currentTimeMillis();
// 预计等待时间比最大可等待时间都长, 直接失败。
if (waitTime > maxQueueingTimeMs) {
return false;
} else {
// 尝试抢占下一个窗口
// 这里每个线程都会来抢占, 但无关先后顺序
// 每个线程都能给这个值加一笔
long oldTime = latestPassedTime.addAndGet(costTime);
try {
// 得到抢占后的时间(可能是好多并发时间一起的)与当前时间的时间差。如果这个时间差还比预计等待时间差大
// 也就是说现在的QPS超量了, 需要放弃。
waitTime = oldTime - TimeUtil.currentTimeMillis();
if (waitTime > maxQueueingTimeMs) {
// 既然当前这份流量不参与累计, 自然不能对后续通过时间做累加。
// 这样可能存在一定情况的并发问题, 特别是 最大等待时间比较小, 且并发还不小的情况下, 限流肯定不准。
latestPassedTime.addAndGet(-costTime);
return false;
}
// 如果满足条件, 就sleep 到预计等待时间那儿去, 然后获得令牌。
if (waitTime > 0) {
Thread.sleep(waitTime);
}
return true;
} catch (InterruptedException e) {
}
}
}
return false;
}