对于高并发系统,保护系统的三大利器:缓存、 降级 、 限流,还有负载均衡很重要。
缓存
作用:
-
提升性能,提升系统吞吐量
CPU的高速缓存就是这个作用。
-
减轻底层存储服务(如MySQL)的负载。
如,高并发读时缓存可以拦截很大一波流量,避免底层存储服务被打挂。
降级
降级的思想是:在系统出现问题时,采用「不是那么完美或有损的方案」进行处理,为了保证系统可用。
降级的结果是:用户体验可能有损。
在「高并发系统」中,常用的降级策略是功能降级:
首先,会将系统的所有功能服务进行一个分级。当系统负载大时,会进行降级处理:禁用一些次要功能服务,从而让出更多系统资源给核心功能服务,保证核心功能服务可用。
例如在电商平台中,如果突发流量激增,可临时将商品评论、积分等非核心功能进行降级,停止这些服务,释放出机器和CPU等资源来保障用户正常下单,而这些降级的功能服务可以等整个系统恢复正常后,再来启动,进行补单/补偿处理。
除了功能降级以外,还可以采用不直接操作数据库,而全部读缓存、写缓存的方式作为临时降级方案。
降级的具体策略各种各样,但目标都是一样的:保证系统可用。
限流
学习目标
-
限流是什么
-
为什么要限流?限流的作用是啥?
-
常见的限流算法
-
分布式限流
限流是什么
限流,顾名思义限制流量, 是指系统限制了某一时间窗口内能够处理的请求总量,保证系统的稳定性。
关键:时间窗口+限制请求量
通常这个时间窗口大小就是1s,如:1秒限制1000次请求访问,即1000QPS
为什么要限流?限流的作用是啥?
为什么要限流?
先看个生活中的例子:
一个景区可能平常工作日没什么人,但一到节假日就会有很多人过来,可能超过了景区接待能力,如果景区不采取一些政策限制游客进入,那可能景区里人满为患,轻则游客之间人挤人、无法正常游玩,重则出现踩踏等安全事故,所以需要限流,保证尽可能多的游客进入景区,但又能正常、安全的游玩。其实,之所以要限流,本质是因为景区的资源有限:
-
空间有限,可能最多只能容纳1万人,但来了3万人,那肯定会出问题
-
其他公共资源有限,比如:公共厕所数量有限,人太多可能导致很多人没法正常上厕所等。
计算机系统限流也是这个道理,系统的资源有限、处理能力有限(资源包括:CPU、内存、网络带宽、请求处理线程等,另外,一个系统的处理能力还受到所有上下游依赖的限制(如,数据库)),需要限流预防大流量把系统打崩,因为流量越大系统资源占用就越多,大流量可能导致系统资源耗尽然后系统崩溃。
假如,某一时刻系统的CPU、内存、带宽负载已经达到了80%,此时还有大量请求打过来,如果系统没有自我保护手段,肯定会被打崩;如果采用了限流,那些超过限制的请求都会被拒绝掉,从而维持系统稳定。
为什么说系统会被打崩?
因为系统资源耗尽、运行不动了。处理一个请求,需要占用CPU和内存等资源(逻辑处理需要CPU,创建对象需要内存,网络传输需要带宽......),若系统资源耗尽,所有请求处理线程都会因获取不到所需资源而执行不下去,就会导致系统崩溃。
总结,限流的作用:
保证系统稳定性,防止系统在面临大流量时因资源耗尽而不能正常提供服务,保证系统负载(即系统资源占用)在一个合理范围,让系统能一直正常运行、正常提供服务。
限流无处不在
比如,线程池和连接池限制总的并发数量,就是限流思想,避免资源过度使用
Q&A
Q1:为什么有了请求线程池的限制,还需要做限流?
A1:线程池只是限制总的并发数量,但限流是限制一个时间窗口的请求量,两者完全不同。比如:
- 可能线程池的限制对于某接口来说太高了,如:线程池能支持1000个并发,但接口只支持10个并发。
- 可能接口本身要保证1000 QPS,但线程池容量是1000却并不代表能保证接口QPS为1000,若接口RT快,可能QPS会达到2000。
所以,线程池的限制和限流两者作用不同。
限流算法
常见的限流算法:
-
固定窗口算法
-
滑动窗口算法
-
漏桶算法
-
令牌桶算法
学习思路:
- 算法的大致思想
- 用具体例子讲讲算法怎么实现限流的
- 对算法关键点做着重说明
- 最好能给出算法实现
实践是检验真理唯一的标准,重点还是得在生产环境中实践
限流算法的设计目标
-
是否能精准限流,即保证不会超过限流阈值
-
是否能应对突发流量
固定窗口算法(计数器算法)
大致思想:
通过一个计数器来累计一个时间窗口的请求量,当请求量达到阈值时就限流。当「时间窗口过期」后,要「刷新时间窗口」、「重置计数器」。
「时间窗口过期」是指当前时间超过了时间窗口的范围,
比如:创建了一个大小为1s的时间窗口,范围是:
12:00:00~12:00:01,当系统运行至12:00:01时间窗口就过期了,需要刷新时间窗口为12:00:01~12:00:02,且计数器重置为0。
具体例子(画图):
假设限制QPS为5(即时间窗口大小为1s,限制请求量5),模拟限流:
-
1.0s时来了第1个请求,此时「初始化」一个时间窗口(时间窗口范围:1.0s~2.0s) ,计数器加1,共计1
-
1.1s~1.5s之间来了4个请求,计数加4,共计5
-
1.5s之后到2.0s之间到来的所有请求会因计数器达到了限制而被限流
-
到2.0s,时间窗口就过期了,需要「刷新」时间窗口(新时间窗口范围:2.0s~3.0s)、重置计数器为0
-
然后,计数器会累计2.0s~3.0s之间的每个请求,若到达限制就会限流
-
到3.0s,时间窗口又过期了,又需要刷新窗口并重置计数器
-
......
算法的关键点:
-
计数器
-
何时初始化时间窗口
初始化时间窗口是指:设置时间窗口的范围、计数器归0
- 可以在创建限流器时就初始化
- 也可以在第一次请求到来时(最晚的时机) 初始化(第一次请求到来之前的时间窗口是无用的)
-
何时刷新时间窗口:时间窗口过期时
-
如何刷新时间窗口:
- 方法一:可开启一个后台线程去定时刷新时间窗口(主动刷新)
- 方法二:可在请求到来时判断时间窗口是否过期,过期就刷新窗口(被动刷新)
优缺点:
优点:简单
缺点:不能精准限流,存在边界问题,可以突破限流阈值。比如,限制QPS为5,当前时间窗口范围是1.0s-2.0s,假设1.5s-2.0s之间来了4个请求,然后2.0s~2.5s之间来了4个请求,则1.5s~2.5s这1s就处理了8个请求,超过限流阈值。
具体实现case:
type FixedWindowRateLimiter struct {
// windowSize 表示时间窗口大小,单位毫秒
windowSize int64
// requestLimit 表示一个窗口限制的请求量,WindowSize + RequestLimit就是限流阈值
requestLimit int64
// WindowStartTime 一个窗口的开始时间,用于判断请求是否属于当前窗口,不属于则要重置开始时间、新开一个时间窗口
windowStartTime int64
// RequestCount 一个窗口的请求量
requestCount int64
mutex sync.Mutex
}
// NewFixedWindowRateLimiter 初始化一个限流器,初始化阈值
func NewFixedWindowRateLimiter(windowSize, requestLimit int64) *FixedWindowRateLimiter {
return &FixedWindowRateLimiter{
windowSize: windowSize,
requestLimit: requestLimit,
}
}
// TryAcquire 返回true表示能处理请求;返回false,表示被限流
func (f *FixedWindowRateLimiter) TryAcquire() bool {
if f == nil {
return false
}
// 请求时间
now := time.Now().UnixMilli()
// 加锁保护共享变量
f.mutex.Lock()
// f.WindowStartTime == 0 表示没有初始化时间窗口,需要初始化窗口
// now-f.WindowStartTime > f.WindowSize 表示当前请求已经处在新窗口,需要新开一个窗口
if f.windowStartTime == 0 || now-f.windowStartTime > f.windowSize {
f.windowStartTime = now
f.requestCount = 0
}
f.mutex.Unlock()
return atomic.AddInt64(&f.requestCount, 1) <= f.requestLimit
}
单测:
func TestFixedWindowRateLimiter(t *testing.T) {
// 初始化一个limiter,限制QPS为3
limiter := NewFixedWindowRateLimiter(1000, 3)
// 请求总时间
requestTime := time.After(2 * time.Second)
// log打印时间精确到微秒
log.SetFlags(log.Lmicroseconds)
for {
select {
case <-requestTime:
log.Default().Println("request end...")
goto requestEnd
default:
if limiter.TryAcquire() {
log.Default().Println("process request")
} else {
log.Default().Println("reject request")
}
}
// 控制QPS
<-time.After(200 * time.Millisecond)
}
requestEnd:
return
}
测试结果:
可以看到能正常进行限流
=== RUN TestFixedWindowRateLimiter
19:33:39.149904 process request
19:33:39.350608 process request
19:33:39.553627 process request
19:33:39.754481 reject request
19:33:39.956960 reject request
19:33:40.160045 process request
19:33:40.362408 process request
19:33:40.563696 process request
19:33:40.765600 reject request
19:33:40.965970 reject request
19:33:41.168327 request end...
--- PASS: TestFixedWindowRateLimiter (2.02s)
PASS
其他实现方式:
还可以通过 redis 实现:以当前秒数作为key,用incr实现自增,可以很容易的统计每秒的请求流,达到阈值就使用拒绝策略拒绝请求。
注意:key一定要设置过期时间,且必须是原子操作,防止key无法自动删除而浪费内存。
测试固定窗口算法的边界问题:
// TestFixedWindowRateLimiterBadCase 测试固定窗口算法的边界问题
func TestFixedWindowRateLimiterBadCase(t *testing.T) {
// 初始化一个limiter,限制QPS为3
limiter := NewFixedWindowRateLimiter(1000, 3)
requestTime := time.After(2 * time.Second)
log.SetFlags(log.Lmicroseconds)
// 第一次请求,初始化时间窗口
if limiter.TryAcquire() {
log.Default().Println("process request")
} else {
log.Default().Println("reject request")
}
// 500ms后再请求
<-time.After(500 * time.Millisecond)
for {
select {
case <-requestTime:
log.Default().Println("request end...")
goto requestEnd
default:
if limiter.TryAcquire() {
log.Default().Println("process request")
} else {
log.Default().Println("reject request")
}
}
// 控制QPS
<-time.After(100 * time.Millisecond)
}
requestEnd:
return
}
测试结果:
可以看到,从第3行到第10行这1秒内处理了5次请求,突破了限流阈值。
=== RUN TestFixedWindowRateLimiterBadCase
19:36:42.424860 process request
19:36:42.928108 process request
19:36:43.030847 process request
19:36:43.134126 reject request
19:36:43.235143 reject request
19:36:43.335299 reject request
19:36:43.440017 process request
19:36:43.543822 process request
19:36:43.645155 process request
19:36:43.746924 reject request
19:36:43.847044 reject request
19:36:43.950134 reject request
19:36:44.053086 reject request
19:36:44.157475 reject request
19:36:44.261337 reject request
19:36:44.363051 reject request
19:36:44.463178 request end...
--- PASS: TestFixedWindowRateLimiterBadCase (2.04s)
PASS
滑动窗口算法
大致思想:
滑动窗口算法是对固定窗口算法的优化,滑动窗口算法会将时间窗口划分为n个子窗口,每个子窗口都单独计数,一个时间窗口所有子窗口的计数器之和就是该时间窗口的请求量,当「时间窗口过期」时,窗口要往右滑动m个子窗口(滑动的单位是子窗口)直到时间窗口覆盖当前时间。
具体例子(画图):
假设限制QPS为5,即时间窗口大小为1s,然后划分5个子窗口,每个子窗口大小0.2s。模拟限流:
- 第1.0s收到第1个请求,初始化时间窗口为[1.0s, 2.0s],子窗口1计数器加1,此时所有子窗口计数器值为[1, 0, 0, 0, 0]
- 1.2s~1.4s之间收到4个请求,子窗口2计数器会加4,此时所有子窗口计数器值为[1, 4, 0, 0, 0]
- 第1.5s收到1个请求,发现当前时间窗口所有子窗口计数器之和为5,达到了限制,则触发 限流
- 第2.1s收到1个请求,发现时间窗口过期了,则时间窗口要往右滑动1个格子,即滑动0.2s,此时时间窗口范围是[1.2s, 2.2s],且所有子窗口计数器值为[4, 0, 0, 0, 0] ,发现未达到限流阈值,则子窗口5(即2.0s~2.2s)计数器加1,此时所有子窗口计数器值为[4, 0, 0, 0, 1]
- 第2.15s收到1个请求,发现当前时间窗口所有子窗口计数器之和为5,达到了限制,则触发 限流
- 第2.5s收到1个请求,发现当前时间窗口过期了,则时间窗口要往右滑动2个格子才行,即滑动0.4秒,此时时间窗口为[1.6s, 2.6s],且所有子窗口计数器值为[0, 0, 1, 0, 0] ,发现未达到限流阈值,则子窗口5(即2.4s~2.6s)计数器加1,此时所有子窗口计数器值为[0, 0, 1, 0, 1]
- 第3.9s收到1个请求,发现当前时间窗口过期了,则时间窗口要往右滑动7个格子,即滑动1.4s,此时时间窗口为[3.0s, 4.0s],且所有子窗口计数器值为[0, 0, 0, 0, 0] ,发现未达到限流阈值,则子窗口5(即3.8s~4.0s)计数器加1,此时所有子窗口计数器值为[0, 0, 0, 0, 1]
- ......
算法关键点:
-
划分子窗口,子窗口单独计数,累加所有子窗口的计数器就是当前时间窗口的请求量,若达到限制,则触发限流
子窗口划分越多,那么滑动窗口的滚动就越平滑,限流就会越精确
-
何时滑动窗口:时间窗口过期时
-
如何滑动窗口:
-
方法一:可开启一个后台线程去定时滑动时间窗口(主动滑动)
-
方法二:可在请求到来时判断时间窗口是否过期,过期则计算要滑动的子窗口数量,然后滑动时间窗口(被动滑动)
-
与「固定窗口算法」对比:
时间窗口过期时,更新窗口的方式不同:
- 固定窗口算法是向右滑动整个时间窗口,如:旧时间窗口是1.0-2.0s,新时间窗口是2.0s-3.0s
- 滑动窗口算法是向右滑动一个子窗口,如:旧时间窗口是1.0s-2.0s,新时间窗口是1.2s-2.2s(假设子窗口大小200ms)。
优缺点:
缺点:
-
不能精准 限流 ,仍然存在边界问题,可以突破限流阈值。
边界问题:
比如,限制QPS为5,1个时间窗口划分2个子窗口(即每个子窗口500ms)。假设在1.0s-2.0s这个时间窗口内来了5个请求,且流量集中在1.25s-1.50s之间;然后,2.0s后时间窗口往右滑动变为1.5s~2.5s,若这时又来了5个请求,且流量集中在2.0s~2.25s之间,则在1.25s~2.25s这1s时间内系统其实处理了10个请求,超过了限流阈值。
优点:
-
虽然不能精准限流,但相比于「固定窗口算法」限流更精确,且子窗口划分越多,那么滑动窗口的滚动就越平滑,限流就会越精确
漏桶算法
大致思想:
我们知道,给漏桶装水,水会不断往下漏。漏桶算法就是这么一个思想:
客户端的请求会进到一个缓冲队列里(相当于给漏桶装水),然后服务端以「恒定速率」处理请求(相当于漏水)(比如:QPS为10,则每100ms处理一个请求)。
漏桶算法其实是一个生产者消费者模式:
- 漏桶就是一个缓冲队列(队列容量等于限流阈值)
- 客户端是生产者,生产请求到队列
- 服务端是消费者,从队列消费请求并处理
图:
具体例子:
假设限制QPS为5,服务端每200ms处理一个请求。模拟限流:
-
第1.0s来了1个请求,则第1.2s会处理请求
-
第1.3s来了5个请求,则第1.4s、1.6s、1.8s、2.0s、2.2s各处理一个请求,可以看到1.0s~2.0s这1s虽然来了6个请求,但确实只处理了5个请求,能精准限流
算法关键:
- 缓冲队列,缓冲请求
- 服务端以「恒定速率」处理请求
优缺点:
优点:能精准限流。因为服务端以「恒定速率」处理请求,所以能保证服务端不会突破限流阈值。
缺点:
-
以恒定速率处理请求,会拖慢请求响应时间。
假如接口QPS为10且请求处理耗时为50ms,此时刚好一下子来了10个请求,若10个请求能一起并发处理则10个请求都能在50ms后返回,但漏桶算法以恒定速率处理请求,则最后一个处理的请求得在1050ms后才返回,严重拖慢了请求响应时间。
-
由于以恒定速率处理请求, 不能应对突发流量
漏桶算法适合后台任务类的限流。
令牌桶算法
大致思想:
有个固定大小的桶,装着令牌,服务端会以「恒定速率」往桶中放令牌,然后请求到来时必须先从桶中获取令牌才能处理请求。
也是一个生产者消费者模式:
- 桶就是一个缓冲队列,装着令牌
- 服务端的令牌生产线程就是生产者,会以恒定速率生产令牌,当桶满了,再产生令牌就会被丢掉
- 服务端的请求处理线程就是消费者,会消费令牌然后处理请求
图:
算法关键:
-
有个固定大小的桶会存所有令牌
-
系统以「恒定速率」生产令牌,请求处理线程拿到令牌才能处理请求
比如,1s产生10个令牌,则100ms产生1个令牌。
优缺点:
优点:
- 能精准限流
- 能应对突发流量
缺点:复杂
具体实现:可以看Guava包的RateLimiter。
总结
-
窗口算法比较简单,但有边界问题,不能精准限流
-
桶算法能精准限流,但比较复杂
单机限流和分布式限流
为什么要 分布式限流 ?
因为现在很多系统都是集群部署的,然后多个实例会共享一些底层组件(如:共享一个DB),如果不实现分布式限流,则会有很多流量打到底层组件,可能把底层组件打崩。
如何实现 分布式限流 ?
通常使用redis。可以看看阿里巴巴开源的Sentinel限流组件。
Q&A
Q1:服务本身做限流还是在服务的上层做限流?
A1:从解耦的角度讲,每个模块都需要自己实现限流保证自己模块的稳定性,当然这可能成本比较高,视情况而定。
Q1:如何评估限流时该设置多少阈值?
A1:根据系统的处理能力来决定,需要进行压测来进行容量评估。要知道一个系统的处理能力是受所有依赖方限制的,而不仅仅只能看自己系统的情况。