Redis 原子计数在主从切换时丢数,我们靠 Lua 脚本加本地缓存兜住了那波被放行的流量
事情是这样的:上周二凌晨 2:47,我被告警炸醒了。线上限流器漏了,大概 3 秒内多放了将近 4000 个请求进去,下游服务 CPU 直接飙到 92%。
原因很经典——Redis 主从切换,INCR 没同步到从节点。
我们用的是 Redis Cluster 做分布式限流,核心逻辑就是每条请求过来,INCR 一个以秒为粒度的 key,然后判断返回值有没有超过阈值。这个方案跑了快两年,一直没什么问题,直到那次凌晨的切换。
查日志发现,Redis 主节点在 02:47:03 挂了,哨兵在 02:47:06 完成了切换。但这 3 秒窗口内,新主节点上那个 key 还没同步过来,INCR 返回 1,所有请求都以为自己没超限,直接放行。
这就是 Redis 原子计数的老毛病了:INCR 是原子的,但主从复制是异步的。主节点挂掉的那一瞬间,已经写入但尚未同步到从节点的数据,就彻底丢了。
第一反应:上 RedLock?不现实
当时脑子里第一个蹦出来的是 RedLock 算法。但仔细一想,RedLock 解决的是分布式锁在主从切换时的安全性问题,靠的是多个独立的 Redis 实例过半投票。而我们的限流场景,本质是计数器,不是锁。RedLock 那种"向 N 个节点同时写、过半成功才算成功"的玩法,用在每秒几万次的计数上,开销根本扛不住。
而且 RedLock 本身就争议不小。Martin Kleppmann 在 2016 年那篇著名的文章里就指出了 RedLock 的时钟依赖问题,虽然 Antirez 反驳了,但至少说明这东西不是银弹。我们当时评估下来,多节点同步写入带来的延迟增长,比偶尔丢几个计数严重得多——当然,那次凌晨的事故之后,这个判断得重新审视了。
Lua 脚本 + 本地缓存:把同步问题转成本地兜底
重新梳理需求:我们要的不是绝对精确的计数,而是"即使 Redis 短暂不可用,也不会把限流彻底放开"。
最终落地的方案分两层:
第一层:Redis Lua 脚本做原子计数,但不是简单的 INCR
我们用 Lua 脚本把"判断 + 计数"打包成一个原子操作。这本身没什么新鲜的,但关键改动在于:脚本里不只是 INCR 然后比较阈值,而是同时写一个带 TTL 的"兜底标记 key"。
具体逻辑:
-- 限流 Lua 脚本
local current_key = KEYS[1] -- 如 "rate:user123:1704067200"
local fallback_key = KEYS[2] -- 如 "rate:user123:fallback"
local limit = tonumber(ARGV[1])
local window = tonumber(ARGV[2]) -- 窗口大小,秒
local current = redis.call('INCR', current_key)
if current == 1 then
redis.call('EXPIRE', current_key, window)
end
-- 如果计数超过阈值的 80%,写入兜底标记
if current >= limit * 0.8 then
redis.call('SET', fallback_key, current, 'EX', window * 2)
end
return current
这个 fallback_key 的作用是:当计数接近阈值时,在 Redis 里留一个"当前窗口已经比较危险"的信号,TTL 设成窗口的两倍,确保即使主从切换导致当前计数 key 丢失,这个标记还在。
第二层:本地缓存兜底,切换时不至于彻底裸奔
每个应用实例维护一个 Caffeine 本地缓存(我们用的 Caffeine 3.1.8,expireAfterWrite 设的 5 秒),缓存 key 是限流维度(比如 userId),value 是这个维度"最近一次从 Redis 拿到的计数值以及时间戳"。
正常情况下,每次请求先查 Redis Lua 脚本拿最新计数。但如果 Redis 操作抛异常或者返回 null(主从切换时连接断开、key 丢失都可能导致),就切到本地缓存模式:
public RateLimitResult checkLimit(String userId) {
String currentKey = "rate:" + userId + ":" + currentSecond();
String fallbackKey = "rate:" + userId + ":fallback";
try {
Long count = redis.eval(LIMIT_SCRIPT,
List.of(currentKey, fallbackKey),
List.of(String.valueOf(limit), String.valueOf(window)));
// 正常路径:更新本地缓存
localCache.put(userId, new CacheEntry(count, System.currentTimeMillis()));
return new RateLimitResult(count <= limit, count);
} catch (Exception e) {
// Redis 异常,走本地兜底
CacheEntry entry = localCache.getIfPresent(userId);
if (entry == null) {
// 本地也没有缓存,从 fallback key 尝试读取
try {
String fallbackVal = redis.get(fallbackKey);
if (fallbackVal != null) {
long fallbackCount = Long.parseLong(fallbackVal);
// fallback 里记录的是超过 80% 阈值时的计数
// 保守策略:直接认为已经超限
return new RateLimitResult(false, fallbackCount);
}
} catch (Exception ex) {
// fallback 也读不到,最坏情况:放行但记录日志
log.error("Rate limit fallback failed for user: {}", userId);
}
// 本地无缓存且 Redis 不可用,保守放行一个极小量
// 这里设一个硬上限,比如 10 req/s,防止彻底裸奔
return new RateLimitResult(true, 1);
}
// 本地有缓存,按缓存值 + 时间衰减估算当前计数
long estimatedCount = estimateCurrentCount(entry);
return new RateLimitResult(estimatedCount <= limit, estimatedCount);
}
}
本地缓存模式下,我们不会完全信任本地数据,而是做"悲观估算":根据上次记录的计数、距今的时间差,按一个略微放大的速率估算当前计数。比如上次记录是 80,过了 0.5 秒,限流窗口是 1 秒 100 次,那就估 80 + 0.5 * 100 * 1.2 = 140,直接判定超限。
这个 1.2 的放大系数是调出来的。一开始设的 1.5,太保守,正常流量都会被误杀;后来压测调到 1.2,在 Redis 完全挂掉的极端情况下,能把误放率控制在 5% 以内。
为什么不用其他方案
有人可能会问:用 Redis Stream 或者 Redis 7.0 的 Redis Functions 不行吗?或者干脆上 Sentinel 之外用 Redis Cluster 的同步复制?
Redis 7.0 确实引入了 WAIT 命令的增强,可以指定同步复制到多少个从节点才算成功。但我们的 Redis 集群版本是 6.2,升级到 7.0 的风险评估还没做完,远水解不了近渴。而且 WAIT 会显著增加延迟——每个 INCR 都要等至少一个从节点确认,在跨可用区部署时延迟从 0.3ms 涨到 2ms 以上,对限流这种高频操作来说不可接受。
本地缓存的方案本质上是用"最终一致性"换"可用性":Redis 正常时,计数精确到个位数;Redis 异常时,用本地缓存的粗糙估算兜住底线,不至于彻底放开。
压测验证
方案上线前做了两组压测,用 wrk2 打恒定速率流量,中间人工触发 Redis 主从切换。
第一次压测,没有本地兜底逻辑,切换瞬间限流器直接失效,3 秒窗口内实际放行量达到阈值的 3.2 倍。
第二次压测,加上 Lua 脚本 + 本地缓存兜底,同样的切换场景,实际放行量最高只超出阈值 12%。多放出来的这部分,主要是 fallback key 还没来得及写入的那批请求——也就是计数还没到 80% 阈值时主节点就挂了。但这个量级可控,下游能扛住。
这里有个细节值得注意:fallback key 的触发阈值设成 80% 不是拍脑袋的。我们分析了近一个月的流量模式,发现大部分限流维度在窗口内的计数增长是均匀的,80% 这个点能覆盖绝大多数"窗口后半段主从切换"的场景。如果设成 50%,fallback key 写得太频繁,增加 Redis 负载;设成 95%,覆盖面又不够。
上线后的真实表现
上线到现在跑了三个多月,期间经历了两次 Redis 主从切换、一次机房网络抖动。告警一次没响。
网络抖动那次尤其典型:Redis 连接池出现大面积超时,持续了大概 7 秒。本地缓存在这 7 秒里扛了全部限流判断,日志里能看到 fallback 路径被触发了 2300 多次,但下游服务的 QPS 只波动了不到 8%,没有雪崩。
当然,这个方案也有它的边界。如果 Redis 长时间不可用(比如超过本地缓存 TTL 的 5 秒),本地缓存过期后只能走硬上限逻辑,那时候限流精度会大幅下降。但这种情况在我们的场景里属于"数据中心级别故障",优先级排在更靠前的容灾策略里,不在限流器本身的职责范围内。
常见问题
为什么不直接在应用层做全局限流,绕开 Redis?
全局限流必须有一个中心化的计数点,除非你的流量能完美哈希到固定节点且节点数不变。实际生产环境里,K8s Pod 动态扩缩容、滚动发布,节点数和 IP 一直在变,纯本地限流做不到全局精确控制。Redis 作为中心计数器是性价比最高的方案,我们要解决的是它主从切换时的短暂失准,而不是彻底替换它。
fallback key 的 80% 阈值怎么调出来的?
分析历史流量数据,画出每个限流维度在 1 秒窗口内的计数累积曲线。大部分场景是线性增长,80% 意味着窗口过了 80% 的时间时触发写入,能覆盖最后 20% 时间窗口内的主从切换风险。如果流量模式是突发型的(比如前 200ms 就打满阈值),可以把这个值调到 50% 甚至更低。没有通用值,得看自己的流量特征。
本地缓存用 Caffeine 而不用 Guava Cache 有什么区别?
Guava Cache 和 Caffeine 接口几乎一样,但 Caffeine 在驱逐策略上用了 Window TinyLFU,高并发下的命中率和吞吐都比 Guava Cache 好一截。我们这个场景里缓存写入非常频繁(每秒上万次 put),Caffeine 的写入性能比 Guava Cache 快大概 40%(基于 JMH 微基准测试,Caffeine 3.1.8 vs Guava 31.1,8 线程并发写入 100 万条,Caffeine 平均 2.3ms/op,Guava 3.8ms/op)。差别不算巨大,但限流这种热路径上能省一点是一点。