4. 分布式限流:Redis + Lua 实现分布式令牌桶

单机令牌桶只能保护单个进程实例。在微服务架构中,同一服务往往部署多个副本,若每个副本各自维护一个令牌桶,总吞吐量会随副本数线性膨胀,无法形成统一的流量屏障。分布式限流的核心诉求是:在多个节点间共享同一个限流状态,使所有请求共享一个全局速率。

Redis 凭借其单线程模型与原子操作,成为分布式令牌桶最常用的状态载体。但单纯使用 Redis 的 GET / SET 组合会引入竞态条件——两个并发请求可能同时读到同一个剩余令牌数,各自扣减后写回,导致超发。解决方案是将令牌桶的整个逻辑封装进 Lua 脚本,利用 Redis 的 EVAL 命令保证脚本的原子执行。

核心原则:分布式限流的本质是将“状态”从进程内存迁移到集中式存储(Redis),并通过原子操作消除竞态。Lua 脚本是 Redis 官方推荐的原子性保障手段。

4.1 分布式令牌桶的数据结构

在 Redis 中,每个限流器对应一个 Key,其值使用 Hash 类型存储三个字段:

字段名 类型 说明
tokens float 当前剩余令牌数(支持小数,用于精确速率)
last_refill_time integer 上次补充令牌的 Unix 时间戳(秒或毫秒)
capacity float 桶的最大容量(可选存储,也可在脚本中硬编码)

为什么不直接用 String 类型?Hash 允许在一次脚本执行中同时读写多个相关字段,且字段名自带语义,便于运维排查。若使用 String 拼接 JSON,解析开销和并发冲突风险都会增加。

4.2 Lua 脚本:原子令牌桶

以下脚本实现了完整的令牌桶逻辑:按时间间隔补充令牌 + 扣减令牌。脚本接收三个参数:

  • KEYS[1]:限流器 Key
  • ARGV[1]:当前时间戳(由客户端传入,避免 Redis 服务器时间偏差)
  • ARGV[2]:本次请求需要的令牌数(通常为 1)
-- 分布式令牌桶 Lua 脚本
-- 参数:KEYS[1]=限流器key, ARGV[1]=当前时间戳(秒), ARGV[2]=请求令牌数
-- 返回值:0=通过, -1=被限流

local key = KEYS[1]
local now = tonumber(ARGV[1])
local cost = tonumber(ARGV[2])

-- 从 Hash 中读取当前状态
local tokens = redis.call('HGET', key, 'tokens')
local last_refill = redis.call('HGET', key, 'last_refill_time')
local capacity = redis.call('HGET', key, 'capacity')

-- 若 key 不存在,初始化
if not tokens then
    tokens = capacity
    last_refill = now
    redis.call('HSET', key, 'tokens', tokens)
    redis.call('HSET', key, 'last_refill_time', last_refill)
    redis.call('HSET', key, 'capacity', capacity)
end

-- 计算时间差,补充令牌
local elapsed = now - tonumber(last_refill)
if elapsed > 0 then
    local rate = tonumber(redis.call('HGET', key, 'rate'))
    local new_tokens = tonumber(tokens) + elapsed * rate
    if new_tokens > tonumber(capacity) then
        new_tokens = tonumber(capacity)
    end
    tokens = new_tokens
    redis.call('HSET', key, 'tokens', tokens)
    redis.call('HSET', key, 'last_refill_time', now)
end

-- 判断是否允许请求
if tonumber(tokens) >= cost then
    redis.call('HSET', key, 'tokens', tonumber(tokens) - cost)
    return 0  -- 通过
else
    return -1 -- 限流
end
设计要点:脚本中 rate(令牌补充速率)和 capacity 存储在 Hash 中,允许在运行时动态调整限流参数,无需重启服务或重新部署脚本。

4.3 Go 客户端调用

在 Go 中,使用 go-redis 库的 Eval 方法执行上述脚本。关键步骤:

import (
    "context"
    "github.com/redis/go-redis/v9"
    "time"
)

type RedisTokenBucket struct {
    client *redis.Client
    script *redis.Script
    key    string
}

func NewRedisTokenBucket(client *redis.Client, key string) *RedisTokenBucket {
    // 加载脚本,返回 SHA 缓存
    script := redis.NewScript(`
        -- 上面完整的 Lua 脚本内容
    `)
    return &RedisTokenBucket{
        client: client,
        script: script,
        key:    key,
    }
}

func (b *RedisTokenBucket) Allow(ctx context.Context) (bool, error) {
    now := time.Now().Unix()
    // 执行脚本,传入 key、当前时间戳、请求令牌数
    result, err := b.script.Run(ctx, b.client, []string{b.key}, now, 1).Int()
    if err != nil {
        return false, err
    }
    return result == 0, nil
}

使用 redis.NewScript 预加载脚本后,后续调用会使用脚本的 SHA 摘要,减少网络传输量。每次调用只需传递参数,Redis 服务器端执行已缓存的脚本。

4.4 原子性与性能权衡

Lua 脚本在 Redis 中执行时,会阻塞其他命令吗?答案是:会,但影响可控。Redis 是单线程处理命令,Lua 脚本执行期间不会响应其他客户端请求。因此脚本必须保持轻量:

  • 避免循环:不要在 Lua 中写 for i=1,100000 do ... end。
  • 避免耗时操作:如 KEYS 通配符扫描、SORT 大数据集。
  • 控制脚本执行时间:官方建议脚本执行时间 < 1 毫秒。令牌桶脚本只有几次 Hash 读写和简单算术,通常在微秒级。
方案 原子性 网络往返 适用场景
Redis + Lua 强(脚本内原子) 1 次 高一致性要求,如支付、下单
Redis + WATCH/MULTI 乐观锁(重试) 2~N 次 低冲突场景,如日志采样
本地令牌桶 + 定期同步 弱(最终一致) 0 次(本地) 允许突发超限,如推荐系统
注意:Redis 主从切换或故障时,未持久化的限流状态可能丢失。若限流精度要求极高(如金融风控),需配合 Redis 的 AOF 持久化或使用 RedLock 等强一致性方案。

4.5 架构总览

下图展示了分布式令牌桶在微服务调用链中的位置:多个服务实例共享同一个 Redis 限流器,Lua 脚本保证状态更新的原子性。

分布式令牌桶架构 分布式令牌桶架构 服务实例 1 服务实例 2 服务实例 3 微服务集群 Redis 主节点 EVAL Lua 脚本 Redis 从节点(只读) Lua 脚本 • 读取令牌 • 补充令牌 • 扣减令牌 • 返回结果 说明: 所有服务实例通过 Redis 主节点执行同一个 Lua 脚本,脚本内部保证令牌桶状态的原子读写。从节点仅用于故障切换,不参与限流写入。

4.6 关键避坑与最佳实践

  1. 时间戳由客户端传入:不要依赖 Redis 的 TIME 命令,因为主从复制时时间可能不一致。客户端统一从 NTP 同步的时间源获取。
  2. 脚本参数化:将 rate、capacity 作为 Hash 字段存储,而非硬编码在脚本中。这样可以在运行时通过 HSET 动态调整限流阈值。
  3. 错误处理:当 Redis 连接失败或脚本执行超时时,服务应降级为本地限流(如单机令牌桶),而非直接放行所有请求。
  4. 监控指标:记录每次限流判定的结果(通过/拒绝)、脚本执行耗时、Redis 连接池等待时间。这些指标能帮助定位限流瓶颈。
性能数据参考:在 Redis 6.x 上,上述 Lua 脚本单次执行耗时约 0.05~0.1 毫秒(含网络往返)。一个 8 核 CPU 的 Redis 实例可支撑每秒 8~16 万次限流判定。若超过此量级,可考虑 Redis 集群分片或本地预分配令牌方案。