在开发高并发系统时,为了保证系统的高可用和稳定性,业内流传着“三把斧”:
- 缓存:提升系统访问速度,增大系统处理容量
- 降级:当服务出现故障或影响到核心业务时,暂时屏蔽掉,待高峰期过后或故障解决后再打开
- 限流:通过对并发(或者一定时间窗口内)请求进行限速来保护系统,一旦达到限制速率则拒绝服务(定向到错误页或告知资源没有了)、排队等待(比如秒杀、评论、下单)、降级(返回兜底数据或默认数据)
最近,由于我负责的一个项目后端这边需要控制 API 的访问频率,于是研究了下 限流 相关的技术。
一般来说,限流的常用处理手段有:
- 计数器
- 滑动窗口
- 漏桶
- 令牌桶
计数器
计数器是一种比较简单粗暴的限流算法:
在一段时间间隔内,对请求进行计数,与阀值进行比较判断是否需要限流,一旦到了时间临界点,将计数器清零。
讨论两种通用做法。
方案一
当请求频率过快时,直接抛弃后续请求。
1 | package main |
例子里面每 200ms
创建一个请求,速率明显高于 1s内最多3个
的限制, 我们把请求的创建时间和结果返回时间打印出来,方便观察。运行显示:
1 | Create req 0 2018-09-12 17:03:43.759413 +0800 CST m=+0.000297262 |
平均每秒最多只显示 3 次 “Respon req”,请求 2、3、4、8、9
被丢弃,说明限速成功。
方案二
当请求频率太快时,后续的请求等待之前的请求完成后才进行,稍微修改下 Allow
方法:
1 | func (l *LimitRate) Allow() bool { |
运行显示,10 次请求总共花费了 3s 多,符合预期,超过请求速率的后续请求依次被排队处理成功,最明显的是请求 9
,创建后等待近 2s 才返回结果:
1 | Create req 0 2018-09-12 17:09:26.177354 +0800 CST m=+0.000317504 |
计数器算法存在“时间临界点”缺陷。比如每一分钟限速 100 个请求(也就是说每秒最多 1.7 个请求),在 00:00:00
到 00:00:58
这段时间内没有用户请求,然后在 00:00:59
这一瞬时发出100个请求,这是允许的,然后在 00:01:00
这一瞬时又发出了 100 个请求,短短 1s 内发出了 200 个请求,系统可能会承受恶意用户的大量请求,甚至击穿系统。
滑动窗口
针对计数器存在的临界点缺陷:
滑动窗口把固定时间片进行划分,并且随着时间的流逝,进行移动,固定数量的可以移动的格子,进行计数并判断阀值。
格子的数量影响着滑动窗口算法的精度,依然有时间片的概念,无法根本解决临界点问题。
漏桶
漏桶算法描述如下:
- 一个固定容量的漏桶,按照固定速率流出水滴;
- 如果桶是空的,则不需流出水滴;
- 可以以任意速率流入水滴到漏桶;
- 如果流入水滴超出了桶的容量,则溢出(被丢弃)
通俗点来说,我们有一个固定容量的桶,有水流进来,也有水流出去。对于流进来的水(请求)来说,我们无法预计一共有多少水会流进来,也无法预计水流的速度。但是对于流出去的水来说,这个桶可以固定水流出的速率(处理速度),从而达到 流量整形 和 流量控制 的效果。
1 | package main |
这里初始化桶容量为 3 个单位,桶内无水,1s 内创建了 10 个请求,随着请求往里面“滴水”,桶按照每秒3 个单位的速率流出水,处理时,部分请求被丢弃:
1 | Create req 0 2018-09-12 20:13:47.742497 +0800 CST m=+0.000314490 |
令牌桶
由于漏桶的出水速度是恒定的,如果瞬时爆发大流量的话,将有大部分请求被丢弃掉(溢出)。
为了解决这个问题,令牌桶进行了算法改进,算法描述如下:
- 有一个固定容量的桶,桶一开始是空的;
- 以固定的速率
r
往桶里填充token
,直到达到桶的容量,多余的令牌将会被丢弃; - 每当一个请求过来时,就尝试从桶里移除一个令牌,如果没有令牌的话,请求无法通过。
1 | package main |
类似的,这里初始化桶容量为 3 个单位,桶内无令牌,每 1s 产生 3 个令牌,主程序阻塞 1s 以便让桶中储备好 3 个令牌,而后每 1s 创建 5 个请求,获取访问令牌:
1 | Create req 0 2018-09-12 20:56:30.926267 +0800 CST m=+1.002077637 |
由于桶中可以储备令牌,这使得令牌桶算法支持一定程度突发的大流量并发访问,也就是说,假设桶内有 100 个 token 时,那么可以瞬间允许 100 个请求通过。这点,对用户比较友好,因而业内多半采用该算法。
当然,不论是对于令牌桶拿不到令牌被拒绝,还是漏桶的水满了溢出,都是为了保证大部分流量的正常访问,而牺牲掉了少部分流量,这是合理的。
rate
包
Go 语言中的 golang.org/x/time/rate
包采用了令牌桶算法来实现速率限制,简单介绍下这个包的用法:
这个包的核心部分在 Limit
类型和 NewLimiter
接口:
1 | // Limit 定义了事件的最大频率。 |
在 NewLimiter
中,我们看到了两个熟悉的参数:r
和 b
,b 是我们之前讨论过的桶的深度。
rate
包还定义了一个有用的帮助函数 Every
,将 time.Duration
转换为Limit
对象:
1 | // Every 将事件之间的最小时间间隔转换为 Limit。 |
创建 Limiter
对象后,我们使用 Wait
,Allow
或 Reserve
方法来阻塞我们的请求,直到获得访问令牌:
1 | // WaitN(ctx, 1)的简写。 |
- 如果要使用context 的截止日期或 cancel 方法的话,使用 WaitN。
- 如果需要在事件超出频率的时候丢弃或跳过事件,就使用 Allow,否则使用 Reserve 或 Wait。
- 如果事件发生的频率是可以由调用者控制的话,可以用 ReserveN 来控制事件发生的速度而不丢掉事件。
使用 rate
包来实现一个简单的 http
限流中间件:
1 | package main |
测试:
1 | hxzdeMac-mini:~ $ while true; do |
稍微修改下这个程序还可以实现 用户粒度的限流,基于IP地址或API密钥等标识符为每个用户实施速率限制器。
更健全的 http 限流中间件推荐看看这个库:https://github.com/didip/tollbooth
最后
上面的几种算法实现比较直白,只是算法语言的翻译,没有充分利用 Go 语言里面 goroutine、channel、定时器等特性,感兴趣的读者可以改造下。
另外,《聊聊高并发系统之限流特技》的两篇文章,非常棒,详细介绍了各种限流算法以及应用级限流、分布式限流、接入层限流等非常实用的技术。