Redis 在分布式系统的角色
Redis 不只是缓存——它在分布式系统里扮演多种角色:分布式锁、限流、消息(pub/sub + Stream)、排行榜、会话存储。本文讲清 Redis 如何支撑这些分布式场景,以及每个场景的可靠性要点(锁的释放、Stream 的消费组、限流的准确性)。
本文要点
- 主题聚焦:DevOps
- 适合人群:独立开发者、全栈工程师、AI 应用开发者
- 核心价值:快速理解背景、实践路径、常见误区和工程建议
我见过太多团队把 Redis 当成"会过期的 HashMap"。简历里都写着"精通 Redis",但一问分布式锁怎么防误解锁、限流为什么用 zset 不用 incr、Stream 和 pub/sub 该选哪个,就答不上来。Redis 之所以几乎是分布式系统的标配,不是因为缓存做得有多快,而是因为它把锁、限流、消息、排行榜、会话这些高频原语,一次性都提供了。这篇文章不讲缓存,讲 Redis 在分布式系统里真正不可替代的几个角色,以及每个角色的可靠性要点。
一、Redis 在分布式系统到底扮演什么角色
按"解决什么问题"分类,Redis 在分布式系统里其实是五种身份叠加:
- 协调(coordination):分布式锁——多实例下谁有资格执行。
- 准入(admission):限流——按用户/IP/全局控制请求速率。
- 通信(communication):消息——pub/sub 广播、Stream 持久化队列。
- 状态(state):会话存储——多实例共享用户 session。
- 排序(ranking):排行榜——实时更新的 top N。
这五类问题,如果不用 Redis,你得自己搭 ZooKeeper、Kafka、专门的 session 服务、专门的排行服务。Redis 一把梭的同时,每个角色都有它自己的可靠性要点——锁要防误解锁、限流要准确、Stream 要 ACK、会话要持久化策略。下面逐个讲。
二、分布式锁:SET NX EX + 唯一 token + Lua 释放
分布式锁是最容易写错的原语。错误版本几乎都长这样:
SET lock:order 1 NX EX 30 # 加锁
# ... 业务 ...
DEL lock:order # 解锁
这段代码在生产里迟早出事。问题在于:DEL 不检查持有者。如果 A 拿到锁后业务卡住超过 30 秒,锁过期自动释放,B 拿到锁正在执行,A 终于缓过来执行 DEL——把 B 的锁删了,C 又能加锁,锁彻底失效。
正确写法分三步。加锁时塞入唯一 token:
SET lock:order <token> NX EX 30
<token> 用 UUID 或 randomBytes,是这次加锁的唯一凭证。解锁时用 Lua 脚本原子地"比对 token + 删除":
if redis.call('GET', KEYS[1]) == ARGV[1] then
return redis.call('DEL', KEYS[1])
else
return 0
end
为什么必须 Lua?因为 GET 判断 + DEL 是两步,中间有竞态。Lua 脚本在 Redis 里是原子执行,这两步才不会被别的命令插队。这一条是分布式锁的良心,没有它,锁就是摆设。
过期时间怎么定? 设太短,业务没做完锁就过期;设太长,持有者进程崩了,别人要等很久。生产里两件事一起做:过期时间设得比业务 P99 稍长(比如业务 5 秒,锁设 30 秒);再加一个**看门狗(watchdog)**后台续期——快过期时如果仍持有,就把 TTL 延长。Redisson 的 watchdog 就是这个套路,自己实现也不复杂。
Redlock 要不要用? 单实例 Redis 的锁在主从切换时可能丢失——主挂了,锁还没同步到从,新主被别人加锁。Redlock 是 Redis 作者提的多节点方案,在 N 个独立 Redis 上加锁,多数派成功才算成功。但 Martin Kleppmann 写过一篇著名的反对,核心论点是:Redlock 依赖各节点时钟一致,而在 GC pause、进程暂停、网络分区下,客户端可能拿到一个已经过期的锁还自以为持有。我的实战观点:多数业务用单实例锁 + 容忍偶发失效就够了;真正强一致的场景(金融扣款),别用 Redis,用数据库行锁或 etcd——Redis 锁的本质是"高性能 + 最佳努力",不是 correctness guarantee。
三、限流:zset 滑动窗口比 incr 准确
最 naive 的限流是 INCR + EXPIRE:
INCR rate:user:123
EXPIRE rate:user:123 60 # 第一秒就 EXPIRE 整个 60 秒窗口
这是"固定窗口"算法,问题很明显:窗口边界处会放行双倍流量。59 秒打满 100 次,01 秒又打满 100 次,1.1 秒内就放过 200 次。
滑动窗口用 zset 实现,准确得多。每个请求作为一个 member,score 是时间戳:
ZADD rate:user:123 <now_ms> <req_id>
ZREMRANGEBYSCORE rate:user:123 0 <now_ms - 60000> # 清掉 60 秒前的
ZCARD rate:user:123 # 当前窗口内请求数
EXPIRE rate:user:123 70 # 别忘了,否则冷 key 永不过期
判断 ZCARD 结果是否超阈值。这三步要包在 Lua 里原子执行,否则并发下会放过量。按用户、按 IP、按全局,只是 key 粒度不同(rate:user:123 / rate:ip:1.2.3.4 / rate:global)。
准确性要点:时间戳必须用 Redis 服务端的 TIME,不是客户端时钟(客户端时钟可能漂移);member 必须唯一(用 req_id 或 nanoseconds + random),否则同一毫秒的重复请求会被 zset 去重,少计数。超高频接口下 zset 内存开销不小,可以用"分段计数"做近似滑动窗口来换性能。
四、消息:pub/sub 广播 vs Stream 持久化
Redis 给了两套消息模型,选错就是事故。
pub/sub 是广播,无持久化:
PUBLISH chan "hello" # 发布
SUBSCRIBE chan # 订阅
特点:订阅者不在线就丢;发布瞬间没有订阅者,消息直接蒸发;没有消费组、没有 ACK、没有回溯。只适合一种场景:订阅者一定在线、丢了无所谓——比如多实例间的缓存失效广播(一个实例删了缓存,通知其他实例也删)。把它当 Kafka 用,是最常见的翻车。
Stream 是持久化队列,带消费组,类 Kafka:
XADD orders '*' id 123 amount 50 # 生产,* 让 Redis 生成有序 ID
XGROUP CREATE orders g1 0 # 创建消费组
XREADGROUP GROUP g1 c1 COUNT 10 STREAMS orders > # 组内消费,> 表示未投递
XACK orders g1 <msg_id> # 确认消费
XPENDING orders g1 # 查未 ACK 的消息
XAUTOCLAIM orders g1 c2 60000 0 # 把超时未 ACK 的转给别的 consumer
Stream 的可靠性要点全在 ACK 上:XREADGROUP 拿到消息后,消息进入 PEL(pending entries list),只有 XACK 才算真正消费完。消费者崩溃后消息留在 PEL 里,别的消费者用 XAUTOCLAIM 接管。只读不 ACK = 消息堆积,这是 Stream 用出事故的最常见原因。还要注意 maxlen:XADD ... MAXLEN ~ 10000 限制流长度,否则 Redis 内存会被无限增长的历史消息吃光。
五、排行榜:zset 的主场
排行榜是 Redis 唯一一个"别的工具很难比它做得更好"的场景。zset(sorted set)天然就是按 score 排序的集合,实时更新 + 范围查询都是 O(logN):
ZADD leaderboard 1500 alice 1200 bob 1800 carol # 更新分数
ZREVRANGE leaderboard 0 9 WITHSCORES # top 10(降序)
ZREVRANGEBYSCORE leaderboard 1500 1000 # 分数区间内的人
ZREVRANK leaderboard alice # alice 的名次
ZINCRBY leaderboard 50 alice # 实时加分
为什么不用 MySQL ORDER BY score LIMIT 10?数据量一大,每次全表排序就是灾难;zset 的 skiplist 结构让 top N 接近常数级开销。直播打赏榜、游戏天梯、热搜热度,只要涉及"实时排序 + 高频更新",zset 几乎是不二选择。
六、会话存储:分布式 session 的 TTL
单体应用 session 放内存就行;一旦横向扩到多实例,session 必须外置共享——否则用户登录在 A 实例,下一个请求落到 B 实例就被踢登录。Redis 是分布式 session 的事实标准:
SET session:<sid> <user_data_json> EX 86400 # 24 小时过期
GET session:<sid>
可靠性要点:会话数据丢了 = 用户被登出,所以存 session 的 Redis 必须开持久化(AOF 或 RDB),别拿纯内存 Redis 存 session;活跃用户的 session 用滑动过期(每次访问 EXPIRE 续期),非活跃的自然清理;key 要可追踪(session:<sid>),方便踢人下线——管理员封号时直接 DEL session:<sid> 立刻生效。
七、别忘了 Redis 自己的高可用
最容易忘的一点:单点 Redis 是单点故障。前面所有方案——锁、限流、消息、会话——都建立在 Redis 可用的前提上。Redis 挂了,你的分布式系统就集体失忆。所以 Redis 自身必须有高可用:
- 主从(replication):一主多从,读写分离。但主挂了要手动切换,生产里不能停在这层。
- 哨兵(Sentinel):主从基础上加监控,主挂了自动选新主。适合中小规模。
- 集群(Cluster):数据分片到多个分片,每个分片自带主从。横向扩容 + 自动 failover,大规模必选。
我的实战建议:会话、锁这类不能丢的,别放纯缓存实例上,要放开了 AOF 持久化、有哨兵/集群保护的 Redis;限流、排行榜这类丢了能重建的,可以放普通缓存实例,容忍 failover 时的短暂误差。把"重要数据"和"可丢失缓存"分到不同 Redis 实例,是成本和可靠性的最优解。
小结
- Redis 在分布式系统里是协调、准入、通信、状态、排序五大原语的统一提供者,而不是"会过期的 HashMap",每个角色都有它自己的可靠性要点。
- 分布式锁的良心是 Lua 释放:加锁塞唯一 token,解锁用 Lua 原子比对 token + DEL。没有这一步,锁就是误解锁的定时炸弹;强一致场景别用 Redis 锁。
- 限流用 zset 滑动窗口比 incr 准确:固定窗口在边界放过双倍流量;Lua 原子化三步、用 Redis 服务端时钟、member 唯一,是准确性的三个前提。
- pub/sub 无持久化只适合在线广播,Stream 才是 Kafka 级的可靠队列:核心是 XACK,只读不 ACK = 消息堆积。
- 单点 Redis 是单点故障:会话、锁这种不能丢的,必须放开了持久化 + 哨兵/集群保护的实例。
一句话:Redis 的高性能常常让你忘掉它的可靠性边界——但锁会误解锁、限流会放过、消息会丢、会话会蒸发,都是真金白银的事故。 用它的速度,但永远清楚每个原语的失败模式。
Related
相关推荐
文章
Docker 部署 Next.js 社区项目的注意事项
围绕 DevOps、Next.js 的技术趋势、工程实践和独立开发视角。
文章
OpenTelemetry 如何帮助你观察 AI 应用?
围绕 DevOps、AI 的技术趋势、工程实践和独立开发视角。
文章
长运行工具调用:Operation、Poll 与 Cancel 的终态协议
初始请求超时后,工具可能仍在服务端运行;发出取消也不等于副作用已停止。本文为单次长运行工具调用建立 operation 身份、轮询水位、取消竞态和唯一终态接受点。
文章
Agent 记忆删除传播:从用户遗忘请求到可验证清除
删除主表一行不会清掉向量、摘要、缓存和恢复后的备份。本文用删除状态机、代际墓碑、派生血缘与端到端 canary 验证真正遗忘。