高并发三板斧:缓存、降级、限流

499 阅读16分钟

对于高并发系统,保护系统的三大利器:缓存、 降级 限流,还有负载均衡很重要。

缓存

作用:

  1. 提升性能,提升系统吞吐量

    CPU的高速缓存就是这个作用。

  2. 减轻底层存储服务(如MySQL)的负载。

    如,高并发读时缓存可以拦截很大一波流量,避免底层存储服务被打挂。

降级

降级的思想是:在系统出现问题时,采用「不是那么完美或有损的方案」进行处理,为了保证系统可用

降级的结果是:用户体验可能有损。

在「高并发系统」中,常用的降级策略是功能降级

首先,会将系统的所有功能服务进行一个分级。当系统负载大时,会进行降级处理:禁用一些次要功能服务,从而让出更多系统资源给核心功能服务,保证核心功能服务可用

例如在电商平台中,如果突发流量激增,可临时将商品评论、积分等非核心功能进行降级,停止这些服务,释放出机器和CPU等资源来保障用户正常下单,而这些降级的功能服务可以等整个系统恢复正常后,再来启动,进行补单/补偿处理。

除了功能降级以外,还可以采用不直接操作数据库,而全部读缓存、写缓存的方式作为临时降级方案。

降级的具体策略各种各样,但目标都是一样的:保证系统可用

限流

学习目标

  1. 限流是什么

  2. 为什么要限流?限流的作用是啥?

  3. 常见的限流算法

  4. 分布式限流

限流是什么

限流,顾名思义限制流量, 是指系统限制了某一时间窗口内能够处理的请求总量,保证系统的稳定性

关键:时间窗口+限制请求量

通常这个时间窗口大小就是1s,如:1秒限制1000次请求访问,即1000QPS

为什么要限流?限流的作用是啥?

为什么要限流?

先看个生活中的例子:

一个景区可能平常工作日没什么人,但一到节假日就会有很多人过来,可能超过了景区接待能力,如果景区不采取一些政策限制游客进入,那可能景区里人满为患,轻则游客之间人挤人、无法正常游玩,重则出现踩踏等安全事故,所以需要限流,保证尽可能多的游客进入景区,但又能正常、安全的游玩。其实,之所以要限流,本质是因为景区的资源有限

  1. 空间有限,可能最多只能容纳1万人,但来了3万人,那肯定会出问题

  2. 其他公共资源有限,比如:公共厕所数量有限,人太多可能导致很多人没法正常上厕所等。

计算机系统限流也是这个道理,系统的资源有限、处理能力有限(资源包括:CPU、内存、网络带宽、请求处理线程等,另外,一个系统的处理能力还受到所有上下游依赖的限制(如,数据库)),需要限流预防大流量把系统打崩,因为流量越大系统资源占用就越多,大流量可能导致系统资源耗尽然后系统崩溃。

假如,某一时刻系统的CPU、内存、带宽负载已经达到了80%,此时还有大量请求打过来,如果系统没有自我保护手段,肯定会被打崩;如果采用了限流,那些超过限制的请求都会被拒绝掉,从而维持系统稳定。

为什么说系统会被打崩?

因为系统资源耗尽、运行不动了。处理一个请求,需要占用CPU和内存等资源(逻辑处理需要CPU,创建对象需要内存,网络传输需要带宽......),若系统资源耗尽,所有请求处理线程都会因获取不到所需资源而执行不下去,就会导致系统崩溃


总结,限流的作用:

保证系统稳定性,防止系统在面临大流量时因资源耗尽而不能正常提供服务,保证系统负载(即系统资源占用)在一个合理范围,让系统能一直正常运行、正常提供服务

限流无处不在

比如,线程池和连接池限制总的并发数量,就是限流思想,避免资源过度使用

Q&A

Q1:为什么有了请求线程池的限制,还需要做限流?

A1:线程池只是限制总的并发数量,但限流是限制一个时间窗口的请求量,两者完全不同。比如:

  1. 可能线程池的限制对于某接口来说太高了,如:线程池能支持1000个并发,但接口只支持10个并发。
  2. 可能接口本身要保证1000 QPS,但线程池容量是1000却并不代表能保证接口QPS为1000,若接口RT快,可能QPS会达到2000。

所以,线程池的限制和限流两者作用不同。


限流算法

常见的限流算法:

  1. 固定窗口算法

  2. 滑动窗口算法

  3. 漏桶算法

  4. 令牌桶算法

学习思路:

  1. 算法的大致思想
  2. 用具体例子讲讲算法怎么实现限流的
  3. 对算法关键点做着重说明
  4. 最好能给出算法实现

实践是检验真理唯一的标准,重点还是得在生产环境中实践

限流算法的设计目标

  1. 是否能精准限流,即保证不会超过限流阈值

  2. 是否能应对突发流量

固定窗口算法(计数器算法)

大致思想:

通过一个计数器来累计一个时间窗口的请求量,当请求量达到阈值时就限流。当「时间窗口过期」后,要「刷新时间窗口」、「重置计数器」

「时间窗口过期」是指当前时间超过了时间窗口的范围

比如:创建了一个大小为1s的时间窗口,范围是:12:00:00~12:00:01,当系统运行至12:00:01时间窗口就过期了,需要刷新时间窗口为12:00:01~12:00:02,且计数器重置为0。


具体例子(画图):

假设限制QPS为5(即时间窗口大小为1s,限制请求量5),模拟限流:

  1. 1.0s时来了第1个请求,此时「初始化」一个时间窗口(时间窗口范围:1.0s~2.0s) ,计数器加1,共计1

  2. 1.1s~1.5s之间来了4个请求,计数加4,共计5

  3. 1.5s之后到2.0s之间到来的所有请求会因计数器达到了限制而被限流

  4. 到2.0s,时间窗口就过期了,需要「刷新」时间窗口(新时间窗口范围:2.0s~3.0s)、重置计数器为0

  5. 然后,计数器会累计2.0s~3.0s之间的每个请求,若到达限制就会限流

  6. 到3.0s,时间窗口又过期了,又需要刷新窗口并重置计数器

  7. ......


算法的关键点:

  1. 计数器

  2. 何时初始化时间窗口

    初始化时间窗口是指:设置时间窗口的范围、计数器归0

    1. 可以在创建限流器时就初始化
    2. 也可以在第一次请求到来时(最晚的时机) 初始化(第一次请求到来之前的时间窗口是无用的)
  3. 何时刷新时间窗口:时间窗口过期时

  4. 如何刷新时间窗口:

    1. 方法一:可开启一个后台线程去定时刷新时间窗口(主动刷新)
    2. 方法二:可在请求到来时判断时间窗口是否过期,过期就刷新窗口(被动刷新)

优缺点:

优点:简单

缺点:不能精准限流,存在边界问题,可以突破限流阈值。比如,限制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. 第1.0s收到第1个请求,初始化时间窗口为[1.0s, 2.0s],子窗口1计数器加1,此时所有子窗口计数器值为[1, 0, 0, 0, 0]
  2. 1.2s~1.4s之间收到4个请求,子窗口2计数器会加4,此时所有子窗口计数器值为[1, 4, 0, 0, 0]
  3. 第1.5s收到1个请求,发现当前时间窗口所有子窗口计数器之和为5,达到了限制,则触发 限流
  4. 第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]
  5. 第2.15s收到1个请求,发现当前时间窗口所有子窗口计数器之和为5,达到了限制,则触发 限流
  6. 第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]
  7. 第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]
  8. ......

算法关键点:

  1. 划分子窗口,子窗口单独计数,累加所有子窗口的计数器就是当前时间窗口的请求量,若达到限制,则触发限流

    子窗口划分越多,那么滑动窗口的滚动就越平滑,限流就会越精确

  2. 何时滑动窗口:时间窗口过期时

  3. 如何滑动窗口:

    1. 方法一:可开启一个后台线程去定时滑动时间窗口(主动滑动)

    2. 方法二:可在请求到来时判断时间窗口是否过期,过期则计算要滑动的子窗口数量,然后滑动时间窗口(被动滑动)

与「固定窗口算法」对比:

时间窗口过期时,更新窗口的方式不同

  1. 固定窗口算法是向右滑动整个时间窗口,如:旧时间窗口是1.0-2.0s,新时间窗口是2.0s-3.0s
  2. 滑动窗口算法是向右滑动一个子窗口,如:旧时间窗口是1.0s-2.0s,新时间窗口是1.2s-2.2s(假设子窗口大小200ms)。

优缺点:

缺点:

  1. 不能精准 限流 ,仍然存在边界问题,可以突破限流阈值

    边界问题:
    比如,限制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个请求,超过了限流阈值。

优点:

  1. 虽然不能精准限流,但相比于「固定窗口算法」限流更精确,且子窗口划分越多,那么滑动窗口的滚动就越平滑,限流就会越精确

漏桶算法

大致思想:

我们知道,给漏桶装水,水会不断往下漏。漏桶算法就是这么一个思想:

客户端的请求会进到一个缓冲队列里(相当于给漏桶装水),然后服务端以「恒定速率」处理请求(相当于漏水)(比如:QPS为10,则每100ms处理一个请求)。

漏桶算法其实是一个生产者消费者模式

  1. 漏桶就是一个缓冲队列(队列容量等于限流阈值)
  2. 客户端是生产者,生产请求到队列
  3. 服务端是消费者,从队列消费请求并处理

图:

image.png


具体例子:

假设限制QPS为5,服务端每200ms处理一个请求。模拟限流:

  1. 第1.0s来了1个请求,则第1.2s会处理请求

  2. 第1.3s来了5个请求,则第1.4s、1.6s、1.8s、2.0s、2.2s各处理一个请求,可以看到1.0s~2.0s这1s虽然来了6个请求,但确实只处理了5个请求,能精准限流


算法关键:

  1. 缓冲队列,缓冲请求
  2. 服务端以「恒定速率」处理请求

优缺点:

优点:能精准限流。因为服务端以「恒定速率」处理请求,所以能保证服务端不会突破限流阈值。

缺点:

  1. 以恒定速率处理请求,会拖慢请求响应时间。

    假如接口QPS为10且请求处理耗时为50ms,此时刚好一下子来了10个请求,若10个请求能一起并发处理则10个请求都能在50ms后返回,但漏桶算法以恒定速率处理请求,则最后一个处理的请求得在1050ms后才返回,严重拖慢了请求响应时间

  2. 由于以恒定速率处理请求, 不能应对突发流量

漏桶算法适合后台任务类的限流。

令牌桶算法

大致思想:

有个固定大小的桶,装着令牌,服务端会以「恒定速率」往桶中放令牌,然后请求到来时必须先从桶中获取令牌才能处理请求。

也是一个生产者消费者模式

  1. 就是一个缓冲队列,装着令牌
  2. 服务端的令牌生产线程就是生产者,会以恒定速率生产令牌,当桶满了,再产生令牌就会被丢掉
  3. 服务端的请求处理线程就是消费者,会消费令牌然后处理请求

图:

image.png


算法关键:

  1. 有个固定大小的桶会存所有令牌

  2. 系统以「恒定速率」生产令牌,请求处理线程拿到令牌才能处理请求

    比如,1s产生10个令牌,则100ms产生1个令牌。


优缺点:

优点:

  1. 能精准限流
  2. 能应对突发流量

缺点:复杂


具体实现:可以看Guava包的RateLimiter

总结

  1. 窗口算法比较简单,但有边界问题,不能精准限流

  2. 桶算法能精准限流,但比较复杂

单机限流和分布式限流

为什么要 分布式限流

因为现在很多系统都是集群部署的,然后多个实例会共享一些底层组件(如:共享一个DB),如果不实现分布式限流,则会有很多流量打到底层组件,可能把底层组件打崩。

如何实现 分布式限流

通常使用redis。可以看看阿里巴巴开源的Sentinel限流组件。

Q&A

Q1:服务本身做限流还是在服务的上层做限流?

A1:从解耦的角度讲,每个模块都需要自己实现限流保证自己模块的稳定性,当然这可能成本比较高,视情况而定。


Q1:如何评估限流时该设置多少阈值?

A1:根据系统的处理能力来决定,需要进行压测来进行容量评估。要知道一个系统的处理能力是受所有依赖方限制的,而不仅仅只能看自己系统的情况