跳过正文
  1. 全部/
  2. 笔记/
  3. 面试/
  4. 场景题/

高并发限流

目录

高并发限流解决方案
#

随着微服务架构的日益普及,服务之间的依赖和调用关系变得更加复杂,确保服务的稳定性变得尤为关键。在实际业务中,经常会遇到瞬时流量激增的情况,这可能导致请求超时,甚至引发服务器过载和宕机。为了保护系统自身及其上下游服务,我们通常会采用限流措施,迅速拒绝超过设定上限的请求,保障系统及上下游服务的稳定运行。合理的限流策略可以有效应对流量激增,确保系统的可用性和性能。本文接下来将深入探讨了几种常见的限流算法,比较它们的优缺点,并提供限流算法选择的建议,同时针对业务中的分布式限流提出了多种解决方案

1. 什么是限流
#

限流是高并发场景下,通过控制系统处理请求的速率,迅速拒绝超过设定上限的请求,保障系统及上下游服务的稳定运行的一种服务保护策略。

在限流技术中, 需要理解两个主要概念:阈值和拒绝策略

阈值:

阈值这是指在单位时间内允许的最大请求量。例如,将QPS(每秒请求数)限制为1000,意味着系统在1秒内最多接受1000次请求。通过设置适当的阈值,可以有效控制系统负载,避免系统因过多请求而崩溃或性能下降

拒绝策略:

处理超过阈值请求的方法。常见的拒绝策略包括直接拒绝和排队等待

  • 直接拒绝策略会立即拒绝超过阈值的请求,然后直接向用户返回
  • 排队等待策略则将请求放入队列中,按照一定规则处理,避免瞬间拒绝大量请求。

选择合适的拒绝策略可以在系统稳定性和用户体验之间取得平衡,能够帮助系统应对突发流量激增、恶意访问或频繁请求的情况,保障系统的稳定性和可用性。限流方案根据实施范围分为单机限流和分布式限流。其中,单机限流根据算法又可以细分为固定窗口、滑动窗口、漏桶和令牌桶等四种常见类型,接下来将详细介绍这些限流方案

2. 为什么要限流
#

限流主要是保证在高并发场景下,可以拒绝掉一部分请求,避免因过载导致系统崩溃或性能下降,从而保证服务能够健康稳定的运行。具体来看,主要有以下一些原因:

2.1 防止系统过载
#

2.2 提升系统稳定性
#

2.3 应对突发流量
#

  • 瞬时高峰处理:在特定时间段(如促销活动、突发事件等),请求量可能突然增加。限流可以控制请求速率,平稳地处理瞬时高峰。

  • 恶意请求防护:限流可以防止恶意用户或攻击者通过大量请求来消耗系统资源,提高系统的安全性。

3. 限流基本算法
#

3.1 固定窗口限流
#

3.1.1 算法原理
#

固定窗口限流是最简单直观的一种限流算法,其基本原理是将时间划分为固定大小的窗口,并在每个窗口内限制请求数量或速率。具体来说,就是将请求按照时间顺序放入时间窗口中,并计算该时间窗口内的请求数量,如果请求数量超出了限制,则拒绝该请求

算法步骤:

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

如上图,假设时间窗口长度为1s,限流阈值为3,每秒内超过3个以上的请求都会被拒绝

3.1.3 优点
#

算法实现非常简单,易于实现和理解

3.1.4 缺点
#

  1. 请求分布不均匀:在固定窗口算法中,请求在窗口内的分布可能会不均衡,导致某些窗口内的请求量超出阈值,而其他窗口内的请求较少

  2. 应对突发流量能力有限:由于固定窗口算法的窗口大小是固定的,无法灵活调整,因此难以应对突发的流量高峰

  3. 存在明显的临界问题:在窗口结束时重置请求计数可能会导致处理请求的不公平。例如,窗口结束前的最后一秒内请求数已达上限,而窗口开始时的第一秒内请求计数为零

比如:限流阀值为每秒5个请求,单位时间窗口为1秒。如果在前0.5秒到1秒的时间内并发5个请求,接着在1秒到1.5秒的时间内又并发5个请求。虽然这两个时间段各自都没有超过限流阈值,但如果计算0.5秒到1.5秒的总请求数,则总共是10个请求,已经远远超过了1秒内不超过5个请求的限流标准。

Pasted image 20260907170521.png

3.1.5 适用场景
#

固定窗口算法适合在请求速率有明确要求且流量相对稳定的场景中使用。然而,对于应对突发流量和请求分布不均匀的情况,该算法可能表现不足,此时需要考虑使用其他更灵活的限流算法。

3.2 滑动窗口限流
#

3.2.1 算法原理
#

滑动窗口其实也是一种通过将时间窗口划分为一个一个小的时间段来限流的方法,每个小的时间段又称之为小周期。相比于固定窗口限流,它可以根据时间滑动删除过期的小周期,以解决固定窗口临界值的问题

比如要限制1s内的请求数量,让时间窗口为1s,在前面固定窗口分析中,会出现临界值问题,比如在0.5s和1.5s之间,这个1s时间段内的请求书就会出现大于阈值情况,那么滑动窗口就可以将1s窗口划分长两个小周期,每个0.5s。每隔0.5s,就往右滑动一个小周期,这样统计的整个窗口大小仍然是1s,只是过期的左边的那个小周期会随着时间删除,这样整个1s的统计周期就是随时间滑动的。

image (1).png

这样的话,在固定窗口中会出现的临界问题,比如0.5s和1.5s之假设来了10个请求,由于滑动窗口会统计到这个时间段,所以多余请求就会被拒绝

3.2.3 优点
#

  1. 精度高,可以通过调整时间窗口小周期的大小来实现不同的限流效果,小周期的时间跨度越短,精度越高

  2. 简单易实现

  3. 灵活性高,滑动窗口算法能够根据实际情况动态调整窗口大小,从而适应流量变化。这样可以更好的应对突发流量和请求分布不均匀的情况

3.2.4 缺点
#

滑动窗口算法本质上是固定窗口算法的细化版本,它能够在一定程度上提高限流的精度和实时性,但不能彻底解决请求分布不均匀的问题。该算法依赖于窗口的大小和时间间隔,特别是在突发流量过大或请求分布极度不均匀的极端情况下,仍可能导致限流不准确。因此,在实际应用中,需要引入更复杂的算法或策略来进一步优化限流效果。

因为滑动窗口本质其实是将窗口粒度更小,但是不管多小,仍然是以窗口来限制,所以总会存在流量不均导致的限流不准确问题

3.2.5 适用场景
#

滑动窗口同样也是适合流量相对稳定的场景

3.3 漏斗限流
#

3.3.1 算法原理
#

漏斗限流算法的核心思想是将请求存储在一个漏斗中,漏斗以固定的速率漏出请求。如果漏斗被填满,新到达的请求将被丢弃。请求可以以以不定的速率流入漏桶,而漏桶以固定的速率流出,所以漏斗算法可以将突发流量均匀地配,确保系统在稳定的负载下运行

算法步骤:

  1. 设置桶的容量:定义服务器在瞬间能够接受的最大请求数。

  2. 确定处理速率:设定每秒能够处理的请求数。

  3. 请求处理计算:

  • 计算处理完成的请求数:

    • 已处理请求数 = (当前请求时间 − 上次请求时间) × 处理速率
  • 更新当前请求数:

    • 最新的当前请求数 = 当前请求数 − 已处理请求数
  1. 比较和处理请求:
  • 如果当前请求数超过桶的容量,则拒绝本次请求。

  • 否则,允许请求通过,并将当前请求数增加1

[图片:请参阅原文]

3.3.2 算法实现
#

package main

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

type LeakyBucketLimiter struct {
    Rate          int        // 漏桶速率,每秒处理的请求数
    Capacity      int        // 漏桶容量,最多可存储请求数
    CurrentReqNum int        // 当前漏斗中待处理的请求数,表示当前漏桶中的请求数
    LastTime      time.Time  // 上次请求时间
    Lock          sync.Mutex // 请求锁
}

func (l *LeakyBucketLimiter) GetCurrentReqNum() int {
    return l.CurrentReqNum
}
func (l *LeakyBucketLimiter) GetRate() int {
    return l.Rate
}
func (l *LeakyBucketLimiter) GetCapacity() int {
    return l.Capacity
}

// NewLeakyBucketLimiter 初始化
func NewLeakyBucketLimiter(rate, capacity int) *LeakyBucketLimiter {
    return &LeakyBucketLimiter{
       Rate:     rate,
       Capacity: capacity,
       LastTime: time.Now(),
    }
}

func (l *LeakyBucketLimiter) Allow() bool {
    l.Lock.Lock()
    defer l.Lock.Unlock()

    // 计算两次请求间隔内应该要处理的请求数
    leakyReqCount := int(time.Since(l.LastTime).Seconds() * float64(l.Rate))

    if leakyReqCount > 0 {
       l.CurrentReqNum -= leakyReqCount
       l.LastTime = time.Now()
    }

    if l.CurrentReqNum < 0 {
       l.CurrentReqNum = 0
    }

    // 如果当前请求数在容量范围内,则请求通过且当前请求数+1(剩余待处理请求数+1)
    if l.CurrentReqNum < l.Capacity {
       l.CurrentReqNum++
       return true
    }
    return false
}

func MockRequest(n int, d time.Duration, l *LeakyBucketLimiter) {
    for i := 0; i < n; i++ {
       time.Sleep(d * time.Millisecond)
       if l.Allow() {
          fmt.Printf("第%d个请求通过\n", i+1)
       } else {
          fmt.Printf("第%d个请求被限流\n", i+1)
       }
    }
}

func main() {
    fmt.Println("=================漏桶算法=================")
    // 创建一个漏桶,速率为每秒处理4个请求,容量为5个请求
    limiter := NewLeakyBucketLimiter(4, 5)
     // 发送10个请求,每50ms发送一个
    MockRequest(10, 50, limiter)
    fmt.Println("------------------------------------------")
}

程序输出

=================漏桶算法=================
第1个请求通过
第2个请求通过
第3个请求通过
第4个请求通过
第5个请求通过
第6个请求通过
第7个请求被限流
第8个请求被限流
第9个请求被限流
第10个请求通过
------------------------------------------

请求处理速率为每秒4个,也就是说每250ms处理完一个请求,每50ms发起一次请求,那么250ms内就可以发起5次请求,此时刚好处理完成一个请求,剩余待处理请求数为4,第6次请求达到桶容量5,第7、8、9次请求都将超过容量限制而被限流,符合预期

3.3.3 优点
#

  1. 平滑处理请求速度:漏斗限流能够有效地平滑限制请求的处理速度,防止瞬间请求过多导致系统崩溃或出现雪崩效应

  2. 适应流量变化:可以通过控制请求的处理速度,使系统能够适应不同的流量需求,避免系统过载或资源过度闲置

  3. 灵活适应场景:通过调整桶的容量和漏出的速率,漏斗限流可以满足不同的限流需求,灵活适应各种使用场景

3.3.4 缺点
#

  1. 无法动态调整流量:漏斗的漏出速率是固定的,不够灵活,无法根据实际流量情况进行动态调整。

  2. 突发流量处理有限:在突发流量过大的情况下,漏斗可能很快被填满,大量请求将被拒绝,可能会导致服务质量下降。

3.3.5 适用场景
#

虽然漏斗可以以平滑的速度处理请求,但是仍然不能较好的应对突发流量,所以也是适用于流量相对稳定的场景

3.4 令牌桶限流
#

3.4.1 算法原理
#

令牌桶算法限流是维护一个固定容量的存放令牌的桶,令牌以固定的速率产生,并放入桶中。每个令牌代表一个请求的许可。当请求到达时,需要从令牌桶中获取一个令牌才能通过。如果令牌桶中没有足够的令牌,则请求被限制或丢弃

算法步骤:

  1. 桶的容量:定义桶的容量,即服务器在一瞬间最多可以接受的请求数

  2. 令牌生成速率:定义令牌的生成速率,例如每秒生成的令牌数

  3. 计算当前令牌数:

  • 每次请求时,计算桶中剩余的令牌数,也就是当前令牌数

    • (本次请求时间 - 上次请求时间) x 令牌生成速率 = 两次请求间隔内生成的令牌数。

    • 当前令牌数(本次请求前剩余令牌数) + 请求间隔内生成的令牌数 = 最新的当前令牌数(本次请求后剩余令牌数)

  1. 处理请求:
  • 比较当前令牌数是否还有剩余,如果有则令牌数减一,请求得到正常处理

  • 如果令牌耗尽,请求被拒绝

[图片:请参阅原文]

3.4.2 算法实现
#

package main

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

type TokenBucketLimiter struct {
    Rate            int        // 令牌产生的速度,即每秒生成多少个令牌
    Capacity        int        // 令牌桶容量,最多可存储令牌数
    CurrentTokenNum int        // 当前令牌数
    LastTime        time.Time  // 上次请求时间
    Lock            sync.Mutex // 请求锁
}

func (t *TokenBucketLimiter) GetCurrentTokenNum() int {
    return t.CurrentTokenNum
}
func (t *TokenBucketLimiter) GetRate() int {
    return t.Rate
}
func (t *TokenBucketLimiter) GetCapacity() int {
    return t.Capacity
}

// NewTokenBucketLimiter 初始化
func NewTokenBucketLimiter(rate, capacity int) *TokenBucketLimiter {
    return &TokenBucketLimiter{
       Rate:     rate,
       Capacity: capacity,
       LastTime: time.Now(),
       CurrentTokenNum: Capacity
    }
}

func (l *TokenBucketLimiter) Allow() bool {
    l.Lock.Lock()
    defer l.Lock.Unlock()

    // 计算两次请求间隔内生成的令牌数
    tokenCount := int(time.Since(l.LastTime).Seconds() * float64(l.Rate))

    if tokenCount > 0 {
       l.CurrentTokenNum += tokenCount
       l.LastTime = time.Now()
    }

    // 令牌数不能超过令牌桶的容量
    if l.CurrentTokenNum > l.Capacity {
       l.CurrentTokenNum = l.Capacity
    }

    // 桶内还有令牌,获取一个令牌
    if l.CurrentTokenNum > 0 {
       l.CurrentTokenNum--
       return true
    }
    return false
}

func MockRequest(n int, d time.Duration, l *TokenBucketLimiter) {
    for i := 0; i < n; i++ {
       time.Sleep(d * time.Millisecond)
       if l.Allow() {
          fmt.Printf("第%d个请求通过\n", i+1)
       } else {
          fmt.Printf("第%d个请求被限流\n", i+1)
       }
    }
}

func main() {
    fmt.Println("=================令牌桶算法=================")
    // 创建一个令牌桶,令牌的生成速度为每秒4个,桶容量为5个请求
    limiter := NewTokenBucketLimiter(4, 5)
    // 发送10个请求,每50ms发送一个
    MockRequest(10, 50, limiter)
    fmt.Println("------------------------------------------")
}

程序输出

=================令牌桶算法=================
第1个请求被限流
第2个请求被限流
第3个请求被限流
第4个请求被限流
第5个请求通过
第6个请求被限流
第7个请求被限流
第8个请求被限流
第9个请求被限流
第10个请求通过
------------------------------------------

令牌桶的生成速度为每秒4个,即每250ms生成一个令牌,每50ms发起一次请求,到第5次、10次请求才会各生成一个令牌,其余请求均被拒绝,符合预期

3.4.3 优点
#

  1. 平滑流量:令牌桶算法可以将突发流量平滑地分散在一定时间内,从而避免了流量高峰对系统的冲击。这有助于保持系统的稳定性和响应时间的一致性。

  2. 灵活性高:通过调整令牌的生成速率和桶的容量,可以灵活地控制流量。这样可以根据不同的需求和场景,优化系统的性能和资源使用率。

  3. 允许突发流量:由于令牌可以在桶中积累,当流量突然增大时,如果桶中有足够的令牌,系统可以快速响应这种突发流量,避免请求被立即拒绝。这使得令牌桶算法特别适合处理具有突发性流量的应用场景。

3.4.4 缺点
#

实现复杂:相对于固定窗口算法等其他限流算法,令牌桶算法的实现较为复杂

3.4.5 适用场景
#

从令牌桶限流的优缺点就可以看出,令牌桶面对大量请求时具有柔性,且请求处理速度可以随请求速度而变。所以在适用场景上要更加宽泛一些,比如一些需要动态调整限流速率以及一些需要平滑处理突发流量的场景都合适

3.5 四种限流算法的对比
#

算法优点缺点适用场景
固定窗口简单易实现应对突发流量能力有限;存在明显临界问题流量较稳定,不需要请求均匀分布
滑动窗口颗粒度更小,提供较平滑的流量控制应对突发流量能力有限;还是存在一定的临界问题流量相对较稳定
漏斗桶平滑处理流量,以固定速率消费请求对突发流量处理不够灵活,无法调整请求处理速率需要平滑处理流量的场景
令牌桶平滑处理流量,可以动态调整流量规则,请求处理速度可以随请求速度而变实现相对复杂需要平滑处理流量,且需要适时的调整流量处理速率的场景

4. 分布式限流
#

前面介绍的集中算法实现一般只适用于单机版的限流,单机限流是指在单台服务器上,通过限制其在单位时间内处理的请求数量来防止过载。然而,随着微服务架构的广泛应用,系统服务通常分布在多台服务器上,但节点的应用流量往往不能准确的反应整个系统的流量压力,这时需要分布式限流来确保整个系统的稳定性。接下来,本文将介绍几种常见的分布式限流技术方案

4.1 基于中心化的限流方案
#

4.1.1 实现原理
#

所谓中心化的限流其实就是将原本设计实现在本地服务的限流操作抽离出来,做成一个中心化的限流器,所有服务共享,这里其实选用一个中心化的组件去实现即可,一般选用redis来实现就很方便

实现步骤:

  1. 选用一个中心化组件,比如Redis

  2. 设定限流规则,比如每秒允许的最大请求数(QPS),并将该值存储在Redis中

  3. 每当有请求到达时,服务器首先向Redis请求令牌

  4. 如果获得令牌,请求可以继续处理;如果未获得令牌,则表示请求被限流,此时可以返回错误信息或提示稍后重试

4.1.2 代码实现
#

package main

import (
    "context"
    "fmt"
    "time"

    "github.com/go-redis/redis/v8"
)

const luaScript = `
local key = KEYS[1]  -- hmap的key
local rate = tonumber(ARGV[1]) -- 令牌生成速率
local capacity = tonumber(ARGV[2])  -- 桶的容量
local now = tonumber(ARGV[3])   -- 当前时间(毫秒)
local requested = tonumber(ARGV[4]) -- 请求的令牌数

-- 获取上次更新时间和当前令牌数
local last_time = redis.call('HGET', key, 'last_time')
local current_tokens = redis.call('HGET', key, 'tokens')

-- 首次访问,将last_time初始化为当前时间,当前令牌数初始化为桶容量
if last_time == false then
    last_time = now
    current_tokens = capacity
else
    last_time = tonumber(last_time)   -- 非首次访问,从redis取得上次访问时间
    current_tokens = tonumber(current_tokens) -- 非首次访问,从redis取得当前桶的令牌数
end

-- 计算时间差
local time_diff = now - last_time   -- 单位毫秒
-- 计算新的令牌数
local new_tokens = math.min(capacity, current_tokens + (time_diff * rate / 1000))

local allowed = new_tokens >= requested

if allowed then
    new_tokens = new_tokens - requested
end

redis.call('HSET', key, 'last_time', now)
redis.call('HSET', key, 'tokens', new_tokens)

return allowed
`

var ctx = context.Background()

// Redis客户端
var rdb *redis.Client

// 初始化Redis客户端
func initRedis() {
    rdb = redis.NewClient(&redis.Options{
       Addr: "localhost:8089",
    })
}

// TokenBucketLimiter 令牌桶限流器
type TokenBucketLimiter struct {
    key       string
    rate      float64
    capacity  int
    luaScript string
}

// NewTokenBucketLimiter 创建新的令牌桶限流器
func NewTokenBucketLimiter(key string, rate float64, capacity int) *TokenBucketLimiter {
    return &TokenBucketLimiter{
       key:       key,
       rate:      rate,
       capacity:  capacity,
       luaScript: luaScript,
    }
}

// Allow 允许请求
func (limiter *TokenBucketLimiter) Allow(requested int) (bool, error) {
    now := time.Now().UnixNano() / 1e6
    _, err := rdb.Eval(ctx, limiter.luaScript, []string{limiter.key}, limiter.rate, limiter.capacity, now, requested).Result()
    if err != nil {
       if err.Error() != "redis: nil" {
          return false, err
       }
       return false, nil
    }
    return true, nil
}

func main() {
    initRedis()

    limiter := NewTokenBucketLimiter("my_limiter:token_bucket", 5, 5)

    for i := 0; i < 20; i++ {
       allowed, err := limiter.Allow(1) // 一个请求,只需要一个令牌,所以这里传1
       if err != nil {
          fmt.Println("Error:", err)
          continue
       }
       if allowed {
          fmt.Printf("第%d个请求通过\n", i+1)
       } else {
          fmt.Printf("第%d个请求被限流\n", i+1)
       }
       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个请求被限流

4.1.3 存在的问题
#

  1. 单点故障,所有请求都必须经过Redis处理,所以Redis可能成为整个系统的性能瓶颈,但节点的redis场景下,当出现redis故障的时候,将会导致整个系统崩溃,所以可以使用使用Redis的主从复制或哨兵模式以实现高可用性

  2. 网络问题,由于所有的请求都多走了一层redis,所以对网络带宽的依赖会有所增加,果网络带宽有限,可能会导致请求传输速度变慢,从而影响Redis的整体性能

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

前面分析了单机版的限流和基于中心化的限流都存在一定的问题,单机版主要是各个节点的流量分布可能不均匀,这将影响到限流阈值的设定,对限流精度造成干扰。而基于中心化的限流方案又会出现单点故障,网络带宽限制等问题。而这里的基于负载均衡的分布式限流其实就是针对单机版限流的一种改进,就是本地限流的基础上加上负载均衡器来均衡流量

实现方案:

  1. 请求分发

通过负载均衡器或分布式服务发现,将请求均匀地分发到多个机器上。这样,每台机器处理的请求数量大致相同。

  1. 本地限流状态维护

在每台机器上维护一个本地的限流状态,独立地进行限流控制。可以使用令牌桶限流算法来实现这一逻辑

  1. 动态调整限流参数

根据每台机器的负载情况(例如CPU和内存使用率),动态调整限流参数:

  • 如果负载过高,降低限流阈值,减少请求处理量

  • 如果负载较低,提高限流阈值,增加请求处理量

4.2.1 存在的问题
#

这种方法虽然加了负载均衡器保证了可以在本地实现限流,解决了分布式场景下的单机限流问题,但是由于引入了负载均衡也是系统的复杂性进一步提高,动态扩缩容的适应性也会变差,当系统需要扩容时,要进行额外的配置调整,确保新加入节点流量的均衡,从而达到限流目的

5. 总结
#

限流是保障系统稳定和高效运行的重要手段,但是任何解决方案都没有银弹,限流方案同样如此,最优的限流方案因具体需求而异。选择适当的限流策略需要考虑系统需求、现有技术栈、系统负载以及底层性能。只有充分理解每种方案的特性,才能在实际应用中做出最佳选择

虽然方案的选择会有多种,但在做限流设计时,一般我们可以结合以下几点来考虑

6. Java版本demo代码
#

my_limiter.zip

附件不支持打印my_limiter.zip26.53KB

Reply by Email