分布式限流方案

2024/4/3 分布式限流

随着微服务的流行,服务之间的依赖性和调用关系变得越来越复杂,服务的稳定性变得尤为重要。业务场景中经常会涉及到瞬时流量冲击,可能会导致请求响应超时,甚至服务器被压垮、宕机不可用。出于对系统本身和上下游服务的保护,我们通常会对请求进行限流处理,快速拒绝超出配置上限的请求,保证系统或上下游服务系统的稳定。合理策略能有效应对流量冲击,确保系统可用性和性能。本文详细介绍了几种限流算法,比较各个算法的优缺点,给出了限流算法选型的一些建议,同时对业务上常用的分布式限流也提出一些解决方案。

一个好的限流设计必须要考虑到业务的特性和需求,同时具备以下六点:

  1. 多级限流:除了主备复制的限流服务,可以考虑实现多级限流策略。例如,可以在应用层、服务层和数据层都设置限流,这样可以更好地防止系统过载。
  2. 动态阈值调整:我们可以根据系统的实时负载情况动态调整限流策略。例如,当系统负载较低时,我们可以放宽限流策略;当系统负载较高时,我们可以收紧限流策略。
  3. 灵活维度:限流策略应该能够根据不同的业务场景进行调整。除了接口,设备,ip,账户 id 等维度外,我们还可以考虑更细粒度的限流。例如,我们可以根据用户的行为模式进行限流,这样可以更好地防止恶意用户的攻击。
  4. 解耦性:限流应该作为一个基础服务,与具体的业务逻辑分离。这样,当业务逻辑发生变化时,不需要修改限流服务的代码,只需要调整限流策略即可。
  5. 容错性:限流服务应该具有高可用性,但是如果出现问题,业务应该有备选方案(熔断、降级)。这可能包括使用备用的限流服务,或者根据业务的敏感性决定是否放行请求。
  6. 监控和报警:对限流策略应进行实时监控,并设置报警机制。当限流策略触发时,可立即收到报警,以便我们可以及时地处理问题。

# 背景

保护高并发服务稳定主要有三把利器:缓存、降级和限流。

  1. 缓存:缓存是一种提高数据读取性能的技术,通过在内存中存储经常访问的数据,可以减少对数据库或者其他存储系统的访问,从而提高系统的响应速度。缓存可以应用在多个层次,例如浏览器缓存、CDN 缓存、反向代理缓存、应用缓存等。
  2. 降级:降级是在系统压力过大或者部分服务不可用的情况下,暂时关闭一些非核心的服务,以保证核心服务的正常运行。降级可以在多个层次进行,例如页面降级、功能降级、服务降级等。
  3. 限流:限流是一种控制系统处理请求的速率的技术,以防止系统过载。限流可以通过多种算法实现,例如令牌桶算法、漏桶算法等。

这三把“利器”各有其特点,通常会结合使用,以达到最佳的效果。例如,可以通过缓存来减少数据库的访问,通过降级来应对系统故障,通过限流来防止系统过载。在设计高并发系统时,需要根据系统的具体需求和特点,合理地使用这些技术。

# 限流知识概述

限流是一种对请求或并发数进行限制的关键技术手段,旨在保障系统的正常运行。当服务资源有限、处理能力有限时,限流可以对调用服务的上游请求进行限制,以防止自身服务因资源耗尽而停止服务。

在限流中,有两个重要的概念需要了解:

  1. 阈值:指在单位时间内允许的请求量。例如,将 QPS(每秒请求数)限制为500,表示在1秒内最多接受500次请求。通过设置合适的阈值,可以控制系统的负载,避免过多的请求导致系统崩溃或性能下降。
  2. 拒绝策略:用于处理超过阈值的请求的策略。常见的拒绝策略包括直接拒绝、排队等待等。直接拒绝会立即拒绝超过阈值的请求,而排队等待则将请求放入队列中,按照一定的规则进行处理。选择合适的拒绝策略可以平衡系统的稳定性和用户体验。

通过合理设置阈值和选择适当的拒绝策略,限流技术可以帮助系统应对突发的请求量激增、恶意用户访问或请求频率过高的情况,保障系统的稳定性和可用性。限流方案根据限流范围,可分为 **单机限流和分布式限流;**其中单机限流依据算法,又可分为 固定窗口、滑动窗口、漏桶和令牌桶限流等常见四种

# 限流基本算法

# 1. 固定窗口限流

# 1.1 算法介绍

固定窗口算法是一种简单直观的限流算法,其原理是将时间划分为固定大小的窗口,在每个窗口内限制请求的数量或速率。具体实现时,可以使用一个计数器来记录当前窗口内的请求数,并与预设的阈值进行比较。固定窗口算法的原理如下:

  1. 将时间划分固定大小窗口,例如每秒一个窗口。
  2. 在每个窗口内,记录请求的数量。
  3. 当有请求到达时,将请求计数加一。
  4. 如果请求计数超过了预设的阈值(比如3个请求),拒绝该请求。
  5. 窗口结束后,重置请求计数。

# 1.2 代码实现

type FixedWindowLimiter struct {
   windowSize  time.Duration // 窗口大小
   maxRequests int           // 最大请求数
   requests    int           // 当前窗口内的请求数
   lastReset   int64         // 上次窗口重置时间(秒级时间戳)
   resetMutex  sync.Mutex    // 重置锁
}

func NewFixedWindowLimiter(windowSize time.Duration, maxRequests int) *FixedWindowLimiter {
   return &FixedWindowLimiter{
      windowSize:  windowSize,
      maxRequests: maxRequests,
      lastReset:   time.Now().Unix(),
   }
}

func (limiter *FixedWindowLimiter) AllowRequest() bool {
   limiter.resetMutex.Lock()
   defer limiter.resetMutex.Unlock()

   // 检查是否需要重置窗口
   if time.Now().Unix()-limiter.lastReset >= int64(limiter.windowSize.Seconds()) {
      limiter.requests = 0
      limiter.lastReset = time.Now().Unix()
   }

   // 检查请求数是否超过阈值
   if limiter.requests >= limiter.maxRequests {
      return false
   }

   limiter.requests++
   return true
}

func main() {
   limiter := NewFixedWindowLimiter(1*time.Second, 3) // 每秒最多允许3个请求
   for i := 0; i < 15; i++ {
      now := time.Now().Format("15:04:05")
      if limiter.AllowRequest() {
         fmt.Println(now + " 请求通过")
      } else {
         fmt.Println(now + " 请求被限流")
      }
      time.Sleep(100 * time.Millisecond)
   }
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47

# 1.3 优缺点

优点

  1. 实现简单:固定窗口算法的实现相对简单,易于理解和部署。
  2. 稳定性较高:对于突发请求能够较好地限制和控制,稳定性较高。
  3. 易于实现速率控制:固定窗口算法可以很容易地限制请求的速率,例如每秒最多允许多少个请求。

缺点

  1. 请求分布不均匀:固定窗口算法中,窗口内的请求分布可能不均匀,导致某些窗口内的请求数量超过阈值,而其他窗口内的请求较少。
  2. 无法应对突发流量:固定窗口算法的窗口大小是固定的,无法灵活地应对突发流量。
  3. 请求的不公平性:窗口结束时的请求计数重置可能导致请求的不公平性。例如,在窗口结束前的最后一秒内,请求计数已满,而在窗口开始时的第一秒内,请求计数为零。

固定窗口算法适用于对请求速率有明确要求且流量相对稳定的场景,但对于突发流量和请求分布不均匀的情况,可能需要考虑其他更灵活的限流算法。

# 2. 滑动窗口限流

# 2.1 算法介绍

上文已经说明当遇到时间窗口的临界突变时,固定窗口算法可能无法灵活地应对流量的变化。例如,在一个时间窗口结束时,如果突然出现大量请求,固定窗口算法可能会导致请求被拒绝,即使在下一个时间窗口内的请求并不多。这种情况下,我们需要一种更灵活的算法来应对突发流量和请求分布不均匀的情况—滑动窗口算法。该算法是对固定窗口算法的一种改进,它通过动态调整窗口的大小来更好地适应流量的变化。与固定窗口算法不同,滑动窗口算法可以在遇到下一个时间窗口之前调整窗口的大小,以便更好地控制请求的速率。算法的原理如下:

  1. 窗口大小:确定一个固定的窗口大小,例如1秒。
  2. 请求计数:在窗口内,每次有请求到达时,将请求计数加1。
  3. 限制条件:如果窗口内的请求计数超过了设定的阈值,即超过了允许的最大请求数,就拒绝该请求。
  4. 窗口滑动:随着时间的推移,窗口会不断滑动,移除过期的请求计数,以保持窗口内的请求数在限制范围内。
  5. 动态调整:在滑动窗口算法中,我们可以根据实际情况调整窗口的大小。当遇到下一个时间窗口之前,我们可以根据当前的流量情况来调整窗口的大小,以适应流量的变化。

# 2.2 代码实现

package main

import (
   "fmt"
   "sync"
   "time"
)

type SlidingWindowLimiter struct {
   windowSize   time.Duration // 窗口大小
   maxRequests  int           // 最大请求数
   requests     []time.Time   // 窗口内的请求时间
   requestsLock sync.Mutex    // 请求锁
}

func NewSlidingWindowLimiter(windowSize time.Duration, maxRequests int) *SlidingWindowLimiter {
   return &SlidingWindowLimiter{
      windowSize:  windowSize,
      maxRequests: maxRequests,
      requests:    make([]time.Time, 0),
   }
}

func (limiter *SlidingWindowLimiter) AllowRequest() bool {
   limiter.requestsLock.Lock()
   defer limiter.requestsLock.Unlock()

   // 移除过期的请求
   currentTime := time.Now()
   for len(limiter.requests) > 0 && currentTime.Sub(limiter.requests[0]) > limiter.windowSize {
      limiter.requests = limiter.requests[1:]
   }

   // 检查请求数是否超过阈值
   if len(limiter.requests) >= limiter.maxRequests {
      return false
   }

   limiter.requests = append(limiter.requests, currentTime)
   return true
}

func main() {
   limiter := NewSlidingWindowLimiter(500*time.Millisecond, 2) // 每秒最多允许4个请求
   for i := 0; i < 15; i++ {
      now := time.Now().Format("15:04:05")
      if limiter.AllowRequest() {
         fmt.Println(now + " 请求通过")
      } else {
         fmt.Println(now + " 请求被限流")
      }
      time.Sleep(100 * time.Millisecond)
   }
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54

# 2.3 优缺点

优点

  1. 灵活性:滑动窗口算法可以根据实际情况动态调整窗口的大小,以适应流量的变化。这种灵活性使得算法能够更好地应对突发流量和请求分布不均匀的情况。
  2. 实时性:由于滑动窗口算法在每个时间窗口结束时都会进行窗口滑动,它能够更及时地响应流量的变化,提供更实时的限流效果。
  3. 精度:相比于固定窗口算法,滑动窗口算法的颗粒度更小,可以提供更精确的限流控制。

缺点

  1. 内存消耗:滑动窗口算法需要维护一个窗口内的请求时间列表,随着时间的推移,列表的长度会增长。这可能会导致较大的内存消耗,特别是在窗口大小较大或请求频率较高的情况下。
  2. 算法复杂性:相比于简单的固定窗口算法,滑动窗口算法的实现较为复杂。它需要处理窗口滑动、请求计数和过期请求的移除等逻辑,可能需要更多的代码和计算开销。

从代码的角度可以发现,滑动窗口算法实际上是颗粒度更小的固定窗口算法,它可以在一定程度上提高限流的精度和实时性,并不能从根本上解决请求分布不均匀的问题。算法受限于窗口的大小和时间间隔,特别是在极端情况下,如突发流量过大或请求分布极不均匀的情况下,仍然可能导致限流不准确。因此,在实际应用中,要采用更复杂的算法或策略来进一步优化限流效果。

# 3. 漏桶限流

# 3.1 算法介绍

尽管滑动窗口算法可以提供一定的限流效果,但它仍然受限于窗口的大小和时间间隔。在某些情况下,突发流量可能会导致窗口内的请求数超过限制。为了更好地平滑请求的流量,漏桶限流算法可以作为滑动窗口算法的改进。算法的原理很简单:它维护一个固定容量的漏桶,请求以不定的速率流入漏桶,而漏桶以固定的速率流出。如果请求到达时,漏桶已满,则会触发拒绝策略。可以从生产者-消费者模式去理解这个算法,请求充当生产者,每个请求就像一滴水,当请求到达时,它被放入一个队列(漏桶)中。而漏桶则充当消费者,以固定的速率从队列中消费请求,就像从桶底的孔洞中不断漏出水滴。消费的速率等于限流阈值,例如每秒处理2个请求,即500毫秒消费一个请求。漏桶的容量就像队列的容量,当请求堆积超过指定容量时,会触发拒绝策略,即新到达的请求将被丢弃或延迟处理。算法的实现如下:

  1. 漏桶容量:确定一个固定的漏桶容量,表示漏桶可以存储的最大请求数。
  2. 漏桶速率:确定一个固定的漏桶速率,表示漏桶每秒可以处理的请求数。
  3. 请求处理:当请求到达时,生产者将请求放入漏桶中。
  4. 漏桶流出:漏桶以固定的速率从漏桶中消费请求,并处理这些请求。如果漏桶中有请求,则处理一个请求;如果漏桶为空,则不处理请求。
  5. 请求丢弃或延迟:如果漏桶已满,即漏桶中的请求数达到了容量上限,新到达的请求将被丢弃或延迟处理。

# 3.2 代码实现

package main

import (
   "fmt"
   "time"
)

type LeakyBucket struct {
   rate       float64 // 漏桶速率,单位请求数/秒
   capacity   int     // 漏桶容量,最多可存储请求数
   water      int     // 当前水量,表示当前漏桶中的请求数
   lastLeakMs int64   // 上次漏水的时间戳,单位秒
}

func NewLeakyBucket(rate float64, capacity int) *LeakyBucket {
   return &LeakyBucket{
      rate:       rate,
      capacity:   capacity,
      water:      0,
      lastLeakMs: time.Now().Unix(),
   }
}

func (lb *LeakyBucket) Allow() bool {
   now := time.Now().Unix()
   elapsed := now - lb.lastLeakMs

   // 漏水,根据时间间隔计算漏掉的水量
   leakAmount := int(float64(elapsed) / 1000 * lb.rate)
   if leakAmount > 0 {
      if leakAmount > lb.water {
         lb.water = 0
      } else {
         lb.water -= leakAmount
      }
   }

   // 判断当前水量是否超过容量
   if lb.water > lb.capacity {
      lb.water-- // 如果超过容量,减去刚刚增加的水量
      return false
   }

   // 增加水量
   lb.water++

   lb.lastLeakMs = now
   return true
}

func main() {
   // 创建一个漏桶,速率为每秒处理3个请求,容量为4个请求
   leakyBucket := NewLeakyBucket(3, 4)

   // 模拟请求
   for i := 1; i <= 15; i++ {
      now := time.Now().Format("15:04:05")
      if leakyBucket.Allow() {
         fmt.Printf(now+"  第 %d 个请求通过\n", i)
      } else {
         fmt.Printf(now+"  第 %d 个请求被限流\n", i)
      }
      time.Sleep(200 * time.Millisecond) // 模拟请求间隔
   }
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65

# 3.3 优缺点

优点

  1. 平滑流量:漏桶算法可以平滑突发流量,使得输出流量变得更加平稳,避免了流量的突然增大对系统的冲击。
  2. 简单易实现:漏桶算法的原理简单,实现起来也相对容易。
  3. 有效防止过载:通过控制流出的流量,漏桶算法可以有效地防止系统过载。

缺点

  1. 对突发流量的处理不够灵活:虽然漏桶算法可以平滑突发流量,但是在某些情况下,我们可能希望能够快速处理突发流量。在这种情况下,漏桶算法可能就不够灵活了。
  2. 无法动态调整流量:漏桶算法的流出速率是固定的,无法根据系统的实际情况动态调整。
  3. 可能会导致流量浪费:如果输入流量小于漏桶的流出速率,那么漏桶的流出速率就会被浪费。
  4. 如果输入流量持续大于漏桶的流出速率,那么漏桶会一直满,新的请求会被丢弃,可能会导致服务质量下降。

# 4 .令牌桶限流

# 4.1 算法介绍

令牌桶算法是实现限流的一种常见思路,用于限制请求的速率。它可以确保系统在高负载情况下仍能提供稳定的服务,并防止突发流量对系统造成过载。最常用的 Google Java 开发工具包 Guava 中的限流工具类 RateLimiter 就是令牌桶的一个实现。令牌桶算法基于一个令牌桶的概念,其中令牌以固定的速率产生,并放入桶中。每个令牌代表一个请求的许可。当请求到达时,需要从令牌桶中获取一个令牌才能通过。如果令牌桶中没有足够的令牌,则请求被限制或丢弃。令牌桶算法的实现步骤如下:

  1. 初始化一个令牌桶,包括桶的容量和令牌产生的速率。
  2. 持续以固定速率产生令牌,并放入令牌桶中,直到桶满为止。
  3. 当请求到达时,尝试从令牌桶中获取一个令牌。
  4. 如果令牌桶中有足够的令牌,则请求通过,并从令牌桶中移除一个令牌。
  5. 如果令牌桶中没有足够的令牌,则请求被限制或丢弃。

# 4.2 代码实现

package main

import (
   "fmt"
   "sync"
   "time"
)

// TokenBucket 表示一个令牌桶。
type TokenBucket struct {
   rate       float64    // 令牌添加到桶中的速率。
   capacity   float64    // 桶的最大容量。
   tokens     float64    // 当前桶中的令牌数量。
   lastUpdate time.Time  // 上次更新令牌数量的时间。
   mu         sync.Mutex // 互斥锁,确保线程安全。
}

// NewTokenBucket 创建一个新的令牌桶,给定令牌添加速率和桶的容量。
func NewTokenBucket(rate float64, capacity float64) *TokenBucket {
   return &TokenBucket{
      rate:       rate,
      capacity:   capacity,
      tokens:     capacity, // 初始时,桶是满的。
      lastUpdate: time.Now(),
   }
}

// Allow 检查是否可以从桶中移除一个令牌。如果可以,它移除一个令牌并返回 true。
// 如果不可以,它返回 false。
func (tb *TokenBucket) Allow() bool {
   tb.mu.Lock()
   defer tb.mu.Unlock()

   // 根据经过的时间计算要添加的令牌数量。
   now := time.Now()
   elapsed := now.Sub(tb.lastUpdate).Seconds() 
   tokensToAdd := elapsed * tb.rate            

   tb.tokens += tokensToAdd
   if tb.tokens > tb.capacity {
      tb.tokens = tb.capacity // 确保令牌数量不超过桶的容量。
   }

   if tb.tokens >= 1.0 {
      tb.tokens--
      tb.lastUpdate = now
      return true
   }

   return false
}

func main() {
   tokenBucket := NewTokenBucket(2.0, 3.0)

   for i := 1; i <= 10; i++ {
      now := time.Now().Format("15:04:05")
      if tokenBucket.Allow() {
         fmt.Printf(now+"  第 %d 个请求通过\n", i)
      } else { // 如果不能移除一个令牌,请求被拒绝。
         fmt.Printf(now+"  第 %d 个请求被限流\n", i)
      }
      time.Sleep(200 * time.Millisecond)
   }
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65

# 4.3 优缺点

优点

  1. 平滑流量:令牌桶算法可以平滑突发流量,使得突发流量在一段时间内均匀地分布,避免了流量的突然高峰对系统的冲击。
  2. 灵活性:令牌桶算法可以通过调整令牌生成速率和桶的大小来灵活地控制流量。
  3. 允许突发流量:由于令牌桶可以积累一定数量的令牌,因此在流量突然增大时,如果桶中有足够的令牌,可以应对这种突发流量。

缺点

  1. 实现复杂:相比于其他一些限流算法(如漏桶算法),令牌桶算法的实现稍微复杂一些,需要维护令牌的生成和消耗。
  2. 需要精确的时间控制:令牌桶算法需要根据时间来生成令牌,因此需要有精确的时间控制。如果系统的时间控制不精确,可能会影响限流的效果。
  3. 可能会有资源浪费:如果系统的流量持续低于令牌生成的速率,那么桶中的令牌可能会一直积累,造成资源的浪费。

# 5. 四种基本算法的对比

算法 优点 缺点 适合场景
固定窗口 简单直观,易于实现 适用于稳定的流量控制 易于实现速率控制 无法应对短时间内的突发流量 流量不均匀时可能导致突发流量 稳定的流量控制,不需要保证请求均匀分布的场景
滑动窗口 平滑处理突发流量 颗粒度更小,可以提供更精确的限流控制 实现相对复杂 需要维护滑动窗口的状态 存在较高的内存消耗 需要平滑处理突发流量的场景
漏桶算法 平滑处理突发流量 可以固定输出速率,有效防止过载 对突发流量的处理不够灵活 无法应对流量波动的情况 需要固定输出速率的场景 避免流量的突然增大对系统的冲击的场景
令牌桶 平滑处理突发流量 可以动态调整限流规则 能适应不同时间段的流量变化 实现相对复杂 需要维护令牌桶的状态 需要动态调整限流规则的场景 需要平滑处理突发流量的场景

# 分布式限流

单机限流指针对单一服务器的情况,通过限制单台服务器在单位时间内处理的请求数量,防止服务器过载。常见的限流算法上文已介绍,其优点在于实现简单,效率高,效果明显。随着微服务架构的普及,系统的服务通常会部署在多台服务器上,此时就需要分布式限流来保证整个系统的稳定性。接下本文会介绍几种常见的分布式限流技术方案:

# 1. 基于中心化的限流方案

# 1.1 方案原理

通过一个中心化的限流器来控制所有服务器的请求。实现方式:

  1. 选择一个中心化的组件,例如— Redis。
  2. 定义限流规则,例如:可以设置每秒钟允许的最大请求数(QPS),并将这个值存储在 Redis 中。
  3. 对于每个请求,服务器需要先向 Redis 请求令牌。
  4. 如果获取到令牌,说明请求可以被处理;如果没有获取到令牌,说明请求被限流,可以返回一个错误信息或者稍后重试。

# 1.2 代码实现

package main

import (
   "context"
   "fmt"
   "go.uber.org/atomic"
   "sync"

   "git.code.oa.com/pcg-csd/trpc-ext/redis"
)

type RedisClient interface {
   Do(ctx context.Context, cmd string, args ...interface{}) (interface{}, error)
}

// Client 数据库
type Client struct {
   client RedisClient // redis 操作
   script string      // lua脚本
}

// NewBucketClient 创建redis令牌桶
func NewBucketClient(redis RedisClient) *Client {
   helper := redis
   return &Client{
      client: helper,
      script: `
         -- 令牌桶限流脚本
         -- KEYS[1]: 桶的名称
         -- ARGV[1]: 桶的容量
         -- ARGV[2]: 令牌产生速率
         
         local bucket = KEYS[1]
         local capacity = tonumber(ARGV[1])
         local tokenRate = tonumber(ARGV[2])
         
         local redisTime = redis.call('TIME')
         local now = tonumber(redisTime[1])
         
         local tokens, lastRefill = unpack(redis.call('hmget', bucket, 'tokens', 'lastRefill'))
         tokens = tonumber(tokens)
         lastRefill = tonumber(lastRefill)
         
         if not tokens or not lastRefill then
            tokens = capacity
            lastRefill = now
         else
            local intervalsSinceLast = (now - lastRefill) * tokenRate
            tokens = math.min(capacity, tokens + intervalsSinceLast)
         end
         
         if tokens < 1 then
            return 0
         else
            redis.call('hmset', bucket, 'tokens', tokens - 1, 'lastRefill', now)
            return 1
         end
      `,
   }
}

// 获取令牌,获取成功则立即返回true,否则返回false
func (c *Client) isAllowed(ctx context.Context, key string, capacity int64, tokenRate int64) (bool, error) {
   result, err := redis.Int(c.client.Do(ctx, "eval", c.script, 1, key, capacity, tokenRate))
   if err != nil {
      fmt.Println("Redis 执行错误:", err)
      return false, err
   }
   return result == 1, nil
}

// 调用检测
func main() {
   c := NewBucketClient(redis.GetPoolByName("redis://127.0.0.1:6379"))
   gw := sync.WaitGroup{}
   gw.Add(120)
   count := atomic.Int64{}
   for i := 0; i < 120; i++ {
      go func(i int) {
         defer gw.Done()
         status, err := c.isAllowed(context.Background(), "test", 100, 10)
         if status {
            count.Add(1)
         }
         fmt.Printf("go %d status:%v error: %v\n", i, status, err)
      }(i)
   }
   gw.Wait()
   fmt.Printf("allow %d\n\n", count.Load())
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90

# 1.3 存在的问题

  1. 性能瓶颈:由于所有的请求都需要经过 Redis,因此 Redis 可能成为整个系统的性能瓶颈。为了解决这个问题,可以考虑使用 Redis 集群来提高性能,或者使用更高性能的硬件。
  2. 单点故障:如果 Redis 出现故障,整个系统的限流功能将受到影响。为了解决这个问题,可以考虑使用 Redis 的主从复制或者哨兵模式来实现高可用。
  3. 网络带宽:Redis 是一个基于网络通信的内存数据库,因此网络带宽是其性能的一个关键因素。如果网络带宽有限,可能会导致请求的传输速度变慢,从而影响 Redis 的性能。

# 2. 基于负载均衡的分布式限流方案

# 2.1 方案原理

可以看到中心化限流方案的存在较高的单点故障风险,且带宽瓶颈比较严重。在这个基础上本文结合本地缓存单机限流和负载均衡设计了一个新的分布式限流方案。具体方案如下:

  1. 使用负载均衡器或分布式服务发现(北极星即可做到),将请求均匀地分发到每个机器上。这确保了每个机器都能处理一部分请求。
  2. 在每个机器上维护本机的限流状态,实现本地缓存单机限流的逻辑。使用令牌桶限流算法,在每个机器上独立地进行限流控制。每秒钟处理的请求数、令牌桶的令牌数量等。根据本地限流状态,对到达的请求进行限流判断。
  3. 准备相应的动态调整方案,可以根据每个机器的实际负载情况,动态地调整限流参数。例如,如果一个机器的 CPU 或内存使用率过高,你可以降低这个机器的限流阈值,减少这个机器的请求处理量。反之,如果一个机器的资源使用率较低,提高这个机器的限流阈值,增加这个机器的请求处理量。

# 2.2 存在的问题

  1. 本地缓存:这个方案对本地缓存的要求较高,需要自己根据业务逻辑抉择本地缓存的淘汰策略和缓存容量限制等风险点。
  2. 限流精度:本地缓存单机限流的精度受限于每个服务实例的资源和配置。这可能导致限流策略无法精确地适应整个系统的流量变化,无法灵活地调整限流规则。
  3. 请求负载均衡器的单点故障。
  4. 动态扩缩容的适应性:当系统需要动态扩展或缩容时,该方案可能需要额外的配置和和调整,以确保新加入或移除的服务实例能够正确地参与限流和请求均衡。

# 3. 基于分布式协调服务的限流

# 3.1 方案原理

使用 ZooKeeper 或者 etcd 等分布式协调服务来实现限流。每台服务器都会向分布式协调服务申请令牌,只有获取到令牌的请求才能被处理。基本方案:

  1. 初始化令牌桶:在 ZooKeeper 中创建一个节点,节点的数据代表令牌的数量。初始时,将数据设置为令牌桶的容量。
  2. 申请令牌:当一个请求到达时,服务器首先向 ZooKeeper 申请一个令牌。这可以通过获取节点的分布式锁,然后将节点的数据减1实现。如果操作成功,说明申请到了令牌,请求可以被处理;如果操作失败,说明令牌已经用完,请求需要被拒绝或者等待。
  3. 释放令牌:当一个请求处理完毕时,服务器需要向 ZooKeeper 释放一个令牌。这可以通过获取节点的分布式锁,然后将节点的数据加1实现。
  4. 补充令牌:可以设置一个定时任务,定期向 ZooKeeper 中的令牌桶补充令牌。补充的频率和数量可以根据系统的负载情况动态调整。

# 3.2 存在的问题

这个方案的优点是可以实现精确的全局限流,并且可以避免单点故障。但是,这个方案的缺点是实现复杂,且对 ZooKeeper 的性能有较高的要求。如果 ZooKeeper 无法处理大量的令牌申请和释放操作,可能会成为系统的瓶颈。

# 总结

没有最好的方案,只有合适的方案。在选择合适的限流方案时,我们需要考虑多种因素,包括系统的需求、现有的技术栈、系统的负载情况以及底层系统的性能等。理解每种方案的工作原理和特性,以便在实际应用中做出最佳的选择。

限流是保证系统稳定和高效运行的重要手段,但它并不是唯一的解决方案。我们还需要考虑其他的系统设计和优化手段,例如负载均衡、缓存、异步处理等(面对爆量,扩容永远是最好的方式,除了贵!)。这些手段相互配合,才能构建出一个既能应对高并发请求,又能保证服务质量的系统。