竞态跟着计数器一起搬家
上一篇的结论:要精确控制总量与每人配额,计数器必须集中到 Redis。但「读计数、判断、写计数」三个动作一旦分开执行,竞态就来了——十个实例同时读到 199,判断都通过,都放行,限额 200 放进 210。这套剧情读者在分布式锁系列第 2 篇已经见过(校验和删除分两步的坑),解法也一样:把判断与计数打包成 Lua 脚本,在 Redis 单线程里原子执行。
三个算法的 Redis 版
固定窗口版第 2 篇已给(INCR 加 PEXPIRE 打包),不再重复。滑动窗口版第 3 篇也给了(ZSET 加 ZREMRANGEBYSCORE),适合低频高价值接口。这里重点补令牌桶的分布式版——工程里最常用又最讲究的一个:
-- KEYS[1]=桶key ARGV[1]=速率 ARGV[2]=桶容量 ARGV[3]=本次申请数
local time = redis.call("TIME") -- 用 Redis 的时钟!
local now = time[1] * 1000 + math.floor(time[2] / 1000)
local bucket = redis.call("HMGET", KEYS[1], "tokens", "ts")
local tokens = tonumber(bucket[1]) or ARGV[2] -- 首次:满桶
local ts = tonumber(bucket[2]) or now
local delta = math.max(0, now - ts) -- 距上次的时间差
tokens = math.min(tonumber(ARGV[2]), tokens + delta / 1000 * tonumber(ARGV[1]))
if tokens >= tonumber(ARGV[3]) then
tokens = tokens - tonumber(ARGV[3])
redis.call("HMSET", KEYS[1], "tokens", tokens, "ts", now)
redis.call("PEXPIRE", KEYS[1], 60000) -- 兜底过期
return 1
end
redis.call("HMSET", KEYS[1], "tokens", tokens, "ts", now)
return 0细节一:时钟必须统一
脚本第一行就是本篇最重要的细节:令牌补充按时间差计算,时间差的两端必须来自同一个时钟。如果应用传自己的 now,实例 A 的时钟快 2 秒、实例 B 的慢 1 秒,同一个桶的时间差算出来忽大忽小,令牌凭空多发或少发。用 Redis 的 TIME 命令取时间(Redis 3.2+ 允许脚本里调用),所有实例对齐到 Redis 的时钟——分布式系统里「逻辑时钟对齐」永远优于「信任各节点物理时钟」,RedLock 论战(分布式锁系列第 5 篇)讲的时钟漂移问题,在这里提前绕开了。
细节二:Redis 挂了,放行还是拒绝
限流依赖 Redis,Redis 不可用时怎么办?两个方向都有道理,按业务性质选:fail-open(放行)——限流器失效宁可超卖,也不能让全站跟着限流器一起瘫,适合保护自家资源的场景:超卖一点总好过全站不可用;fail-close(拒绝)——限不住就坚决不放,适合花钱的出口:短信、支付调第三方,刷穿就是真金白银。折中方案是本地兜底限流:Redis 挂了自动降级到单机 RateLimiter(阈值按总量除以实例数),可用性由单机限流接力——粗糙但保命。
细节三:高并发下的 RT 优化
每次请求一次 Redis 往返(约 1 毫秒),万级 QPS 下 Redis 网络成为新瓶颈,两个经典优化:批量取牌——一次 Lua 取 10 个令牌回本地,本地逐个消费,RT 降到十分之一,代价是突发粒度变粗(本实例可能囤牌);两层限流——本地先用宽松阈值(总量/实例数再加余量)拦截明显超标的,通过的才去 Redis 精确校验,九成的拒绝在本机完成,Redis 只受理边缘流量。
| 方案 | 精度 | Redis 压力 | 适用 |
|---|---|---|---|
| 固定窗口 | 边界突刺 | 低(两个命令) | 粗粒度总量控制 |
| 令牌桶 | 速率精确,允许突发 | 中(读写桶状态) | 通用默认,接口级限流 |
| ZSET 滑动窗口 | 零误差 | 高(与请求量成正比) | 低频高价值接口 |
分布式限流的内核到此完整:原子脚本、统一时钟、故障策略、性能分层。但还有一个位置没安放——这些闸门该装在系统的哪一层?Nginx 前置在流量入口,网关聚合一揽子规则,应用内精细到方法——下一篇从最外层的网关讲起。
评论 (0)