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