Informal Essay By English
I have been thinking about a question recently, what is the end of coding?
参考书籍:
“凤凰架构”
流量控制
任何一个系统的运算、存储、网络资源都不是无限的,当系统资源不足以支撑外部超过预期的突发流量时,便应该要有取舍,建立面对超额流量自我保护的机制,这个机制就是微服务中常说的“限流”
大家应该都经历过早期的 12306 系统在春节期间全国人民都上去抢票的结果是全国人民谁都买不上票的情况,出现这种情况的原因很大部分原因就是早期的12306系统并没有做好突发流量的控制导致的。为了避免这种状况出现,一个健壮的系统需要做到恰当的流量控制,更具体地说,需要妥善解决以下三个问题:
- 依据什么限流?:要不要控制流量,要控制哪些流量,控制力度要有多大,等等这些操作都没法在系统设计阶段静态地给出确定的结论,必须根据系统此前一段时间的运行状况,甚至未来一段时间的预测情况来动态决定。
- 具体如何限流?:解决系统具体是如何做到允许一部分请求能够通行,而另外一部分流量实行受控制的失败降级,这必须了解掌握常用的服务限流算法和设计模式。
- 超额流量如何处理?:超额流量可以有不同的处理策略,也许会直接返回失败(如 429 Too Many Requests),或者被迫使它们进入降级逻辑,这种被称为否决式限流。也可能让请求排队等待,暂时阻塞一段时间后继续处理,这种被称为阻塞式限流
🔥了解更多
依据什么限流?
这个问题,其实是比较好理解的,举个生活中常见的例子:
洗澡用的热水器一个小时能加热0.5吨的水,如果只是你一个人使用,这个热水器是可以满足日常使用热水的需求的,但是如果突然有一天家里来了几个朋友,那么只靠一个热水器可能就不能满足所有人的需求(不考虑一起洗的情况🌝),假设你洗澡会使用0.2吨的水,朋友A洗澡需要使用0.2吨的水,朋友B日常洗澡需要0.3吨的水,显然光靠加热一个小时的热水器中的水是不足以满足所有的人洗澡的需求的,在保证大家的洗澡用量的前提下,需要根据热水器的容量和加热时间来合理的安排大家的洗澡顺序和时间来满足每个人对热水的需求(热水器有人在洗澡的时候是可以不断加热水的)
在编程世界里面,热水器可以类比是系统,人看作是流量。这里会有一个疑问,为什么会知道三个人需要合理安排洗澡时间和洗澡用水量才能满足各自的用水需求,理由当然的回答说热水器每小时加热水的容量无法满足各自洗澡的需求,那么在编程世界里面我们是何如来衡量一个系统的是否能满足突然大用户量的需求呢?一般根据以下三个指标的数值来决定(引用自《凤凰架构》,):
- 每秒事务数(Transactions per Second,TPS):TPS 是衡量信息系统吞吐量的最终标准。“事务”可以理解为一个逻辑上具备原子性的业务操作。譬如你在 Fenix’s Bookstore(凤凰书店) 买了一本书,将要进行支付,“支付”就是一笔业务操作,支付无论成功还是不成功,这个操作在逻辑上是原子的,即逻辑上不可能让你买本书还成功支付了前面 200 页,又失败了后面 300 页。
- 每秒请求数(Hits per Second,HPS):HPS 是指每秒从客户端发向服务端的请求数(请将 Hits 理解为 Requests 而不是 Clicks,国内某些翻译把它理解为“每秒点击数”多少有点望文生义的嫌疑)。如果只要一个请求就能完成一笔业务,那 HPS 与 TPS 是等价的,但在一些场景(尤其常见于网页中)里,一笔业务可能需要多次请求才能完成。譬如你在 Fenix’s Bookstore 买了一本书要进行支付,尽管逻辑上它是原子的,但技术实现上,除非你是直接在银行开的商城中购物能够直接扣款,否则这个操作就很难在一次请求里完成,总要经过显示支付二维码、扫码付款、校验支付是否成功等过程,中间不可避免地会发生多次请求。
- 每秒查询数(Queries per Second,QPS):QPS 是指一台服务器能够响应的查询次数。如果只有一台服务器来应答请求,那 QPS 和 HPS 是等价的,但在分布式系统中,一个请求的响应往往要由后台多个服务节点共同协作来完成。譬如你在 Fenix’s Bookstore 买了一本书要进行支付,尽管扫描支付二维码时客户端只发送了一个请求,但这背后服务端很可能需要向仓储服务确认库存信息避免超卖、向支付服务发送指令划转货款、向用户服务修改用户的购物积分,等等,这里面每次内部访问都要消耗掉一次或多次查询数
目前,主流系统大多倾向使用 HPS 作为首选的限流指标,它是相对容易观察统计的,而且能够在一定程度上反应系统当前以及接下来一段时间的压力。但限流指标并不存在任何必须遵循的权威法则,根据系统的实际需要,哪怕完全不选择基于调用计数的指标都是有可能的。譬如下载、视频、直播等 I/O 密集型系统,往往会把每次请求和响应报文的大小,而不是调用次数作为限流指标,譬如只允许单位时间通过 100MB 的流量。又譬如网络游戏等基于长连接的应用,可能会把登陆用户数作为限流指标,热门的网游往往超过一定用户数就会让你在登陆前排队等候。
具体如何限流?
这个问题也比较好理解,还是拿上面洗澡的案例说明,在这个案例中,我们给每个人分配洗澡时间和用水量其实就是一种限流的手段。那么编程世界中我们大体有以下几种限流设计模式可以参考使用。
1.流量计数器模式
根据当前时刻的流量计数结果是否超过阈值来决定是否限流,比如:我们计算得出了某系统能承受的最大持续流量是 80 TPS,那就控制任何一秒内,发现超过 80 次业务请求就直接拒绝掉超额部分,代码也比较好实现,下面就是计数器模式的实现案例:
public class CountLimiter {
private int requestCount;
private long lastRefillTime;
private long limitTime;
private int requestLimit;
public CountLimiter(int requestCount, int requestLimit, long lastRefillTime, long limitTime) {
this.requestCount = requestCount;
this.requestLimit = requestLimit;
this.lastRefillTime = lastRefillTime;
this.limitTime = limitTime;
}
public boolean isAllowed() {
synchronized (this) {
long currTime = System.currentTimeMillis();
long elapsedTime = currTime - lastRefillTime;
if (elapsedTime > limitTime) {
reset(currTime);
}
if (requestCount > requestLimit){
return false;
}
requestCount++;
return true;
}
}
private void reset(long currTime) {
this.lastRefillTime = currTime;
this.requestCount = 0;
}
}
这种做法很直观,也确实有些简单的限流就是这么实现的,但它并不严谨,以下两个结论就很可能出乎对限流算法没有了解的同学意料之外:
- 即使每一秒的统计流量都没有超过 80 TPS,也不能说明系统没有遇到过大于 80 TPS 的流量压力。你可以想像如下场景,如果系统连续两秒都收到 60 TPS 的访问请求,但这两个 60 TPS 请求分别是前 1 秒里面的后 0.5 秒,以及后 1 秒中的前面 0.5 秒所发生的。这样虽然每个周期的流量都不超过 80 TPS 请求的阈值,但是系统确实曾经在 1 秒内实在在发生了超过阈值的 120 TPS 请求
- 即使连续若干秒的统计流量都超过了 80 TPS,也不能说明流量压力就一定超过了系统的承受能力。你可以想像如下场景,如果 10 秒的时间片段中,前 3 秒 TPS 平均值到了 100,而后 7 秒的平均值是 30 左右,此时系统是否能够处理完这些请求而不产生超时失败?答案是可以的,因为条件中给出的超时时间是 10 秒,而最慢的请求也能在 8 秒左右处理完毕。如果只基于固定时间周期来控制请求阈值为 80 TPS,反而会误杀一部分请求,造成部分请求出现原本不必要的失败。
流量计数器的缺陷根源在于它只是针对时间点进行离散的统计,为了弥补该缺陷,一种名为“滑动时间窗”的限流模式被设计出来,它可以实现平滑的基于时间片段统计。
2.滑动时间窗口模式
这种模式的实现基于滑动时间窗口算法,本文不对这种算法做过多的介绍(默认大家都会🤺),直接贴上实现代码:
@Component
@ConditionalOnMissingBean(StringRedisTemplate.class)
public class TimeSlidingRedisLimiter {
@Autowired
private StringRedisTemplate redisTemplate;
public Boolean isActionAllowed(String value, String actionKey, int period, int maxCount) {
String key = String.format(LimiterConstant.LIMIT_KEY + "%s:%s", value, actionKey);
long nowTime = System.currentTimeMillis();
redisTemplate.opsForZSet().add(key, nowTime + "", nowTime);
redisTemplate.opsForZSet().removeRangeByScore(key, 0, nowTime - period * 1000);
List<Object> result = redisTemplate.executePipelined(new SessionCallback<Object>() {
@Override
public <K, V> Object execute(RedisOperations<K, V> redisOperations) throws DataAccessException {
RedisTemplate<String, String> redisTemplate = (RedisTemplate<String, String>) redisOperations;
redisTemplate.opsForZSet().zCard(key);
return null;
}
}, this.redisTemplate.getValueSerializer());
Long count = (Long) result.get(0);
redisTemplate.expire(key, period + 1, TimeUnit.MILLISECONDS);
return count <= maxCount;
}
}
3.漏桶模式与令牌桶模式
在计算机网络中,专门有一个术语流量整形(Traffic Shaping)用来描述如何限制网络设备的流量突变,使得网络报文以比较均匀的速度向外发送。 流量整形通常都需要用到缓冲区来实现,当报文的发送速度过快时,首先在缓冲区中暂存,然后再在控制算法的调节下均匀地发送这些被缓冲的报文。常用的控制算法有漏桶算法(Leaky Bucket Algorithm)和令牌桶算法(Token Bucket Algorithm)两种,这两种算法的思路截然相反,但达到的效果又是相似的。
所谓漏桶,就是大家小学做应用题时一定遇到过的“一个水池,每秒以 X 升速度注水,同时又以 Y 升速度出水,问水池啥时候装满”的那个奇怪的水池。你把请求当作水,水来了都先放进池子里,水池同时又以额定的速度出水,让请求进入系统中。这样,如果一段时间内注水过快的话,水池还能充当缓冲区,让出水口的速度不至于过快。不过,由于请求总是有超时时间的,所以缓冲区大小也必须是有限度的,当注水速度持续超过出水速度一段时间以后,水池终究会被灌满,此时,从网络的流量整形的角度看是体现为部分数据包被丢弃,而在信息系统的角度看就体现为有部分请求会遭遇失败和降级。而令牌桶它与漏桶一样都是基于缓冲区的限流算法,只是方向刚好相反,漏桶是从水池里往系统出水,令牌桶则是系统往排队机中放入令牌。
实现代码如下:
/**
* 漏桶模式
*
* @author disaster
* @version 1.0
*/
public class FunnelRateLimiter {
private final int capacity;
private final double rate;
private int leftQuota;
private long lastRefillTime;
public FunnelRateLimiter(int capacity, double rate) {
this.capacity = capacity;
this.rate = rate;
this.leftQuota = capacity;
this.lastRefillTime = System.currentTimeMillis();
}
public boolean isAllowed(int quota) {
refillQuota();
if (this.leftQuota >= quota) {
this.leftQuota -= quota;
return true;
}
return false;
}
private void refillQuota() {
long currentTimeMillis = System.currentTimeMillis();
long seconds = TimeUnit.MILLISECONDS.toSeconds(currentTimeMillis - this.lastRefillTime);
System.out.println("seconds = " + seconds);
double quotaRefilled = Math.ceil(seconds * rate);
System.out.println("quotaRefilled = " + quotaRefilled);
this.lastRefillTime = currentTimeMillis;
this.leftQuota = (int) Math.min(leftQuota + quotaRefilled, capacity);
System.out.println("quotaRefilled = " + quotaRefilled);
}
public static void main(String[] args) throws InterruptedException {
FunnelRateLimiter funnelRateLimiter = new FunnelRateLimiter(5, 0.9d);
for (int i = 0; i < 10; i++) {
boolean allowed = funnelRateLimiter.isAllowed(1);
if (allowed) {
System.out.println("i = " + i + " is allowed");
} else {
System.out.println("i = " + i + " is not allowed");
}
TimeUnit.MILLISECONDS.sleep(1000);
}
}
}
/**
* 令牌桶限流
*
* @author disaster
* @version 1.0
*/
public class TokenBucketRateLimiter {
private final int capacity;
private final double rate;
private int tokens;
private long lastRefillTime;
public TokenBucketRateLimiter(int capacity, double rate) {
this.capacity = capacity;
this.rate = rate;
this.tokens = 0;
this.lastRefillTime = System.currentTimeMillis();
}
public boolean isAllowed(int quota) {
refillTokens();
if (tokens >= quota) {
tokens -= quota;
return true;
}
return false;
}
private void refillTokens() {
long currentTime = System.currentTimeMillis();
long elapsedTime = currentTime - lastRefillTime;
int tokensToRefill = (int) ((elapsedTime - lastRefillTime) * rate / 1000);
lastRefillTime = currentTime;
Math.min(tokensToRefill + tokens, tokens);
}
}
上面实现过的那些限流算法,除了滑动时间窗口的实现是可以直接使用到分布式系统中外,其他的几种实现方式只能在单体架构中使用,至于为什么,这里留给大家思考一下🤓
超额流量如何处理?
同样还是用开头的例子来解释这个问题,按照上面的几种处理方案,三个人通过合理的安排使用热水器大家都可以在合理的时间内满足各自的洗澡需求,现在我们假设你人缘特好,有那么一天有20个朋友想来你家做客,这个时候你该如何是好?按照上面的处理,再怎么安排有几个朋友洗完澡估计都得早上去了,这是你没办法忍受的。这里早上洗澡的几个朋友对应的就是热水器的超额流量,如果你是没办法拒绝别人的性格,那么这个时候你最好的选择就是这次邀请一部分朋友来你家玩,下次再邀请一部分来你家玩。
在编码世界也是如此,这种超额流量怎么处理也是需要根据不同的系统来决定的!