当你第一次发现 Redis 既能做自动完成,又能做限流、锁和消息队列,很容易产生一种错觉:这些组件无非是“挑个数据结构,再拼几条命令”。演示代码往往也确实只有十几行。
麻烦出在十几行以外。客户端在收到 Redis 回复前断线了,这次写入到底成功没有?消费者取走任务后崩溃了,任务去哪儿找?锁的持有者暂停太久,醒来时租约已经被别人拿走,它还能不能继续写数据库?这些才是组件从“跑通”走向“可用”的分界线。
这一章就用一条贯穿始终的检查法来拆组件:数据放在哪儿,哪一步必须原子执行,进程死在任意两步之间会怎样,业务最终靠什么兜底。 Redis 很擅长把一个小而明确的状态转换做得很快,但它不会自动替你跨过数据库、外部接口和网络故障的边界。

本章里的“原子”只表示一次 Redis 命令、事务或脚本执行期间,不会被另一条 Redis 命令插入。它不等于磁盘已经同步,不等于副本已经收到,更不等于 Redis 与业务数据库组成了一个跨系统事务。
我们先不急着写命令。拿“发送一封订单邮件”做例子,把四张账列出来,后面的锁、队列和消息流都会清楚很多。
字符串的条件写入、有序集合的范围查询、列表的原子移动、Stream 的待处理列表,这些能力很像一盒形状明确的积木。组件设计的关键不是积木越多越好,而是让一次不可分割的业务判断尽量落在一个小的 Redis 原子边界里。
只发一批命令的 pipeline 主要减少网络往返。它可以与事务结合,但 pipeline 本身不是事务。MULTI/EXEC 会让一批已排队命令连续执行,却不会因为中间某条运行失败而回滚。WATCH 提供乐观并发控制,适合冲突不高的读—判断—写。Lua 脚本和 Redis Function 能把条件判断与多个写操作放进服务端原子执行,但脚本运行时会阻塞其他命令,因此仍要短小、确定、有限。
设计一个组件时,可以先补完下面几句话:
这几句话并不悲观。恰恰相反,它们会逼着我们把失败变成设计的一部分,而不是上线后再靠日志猜。
假设你在聊天框里输入“张”,界面要马上给出最近联系过的张姓同事。这个场景与“在全站一百万件商品里搜索张家界攻略”看起来都叫自动完成,数据规模和排序目标却完全不同。
每个用户只保留最近 100 个联系人时,最直白的模型就是一个 LIST:
recent:{user:42} = [王小雨, 张明, 陈安, ...]新发生一次会话,需要把联系人从旧位置删除、推到表头,再把长度裁到 100。三条命令的业务含义是一件事,最好用短 Lua 脚本或 Function 合在一起:
-- KEYS[1] = recent:{user:42}
-- ARGV[1] = 联系人 ID
redis.call('LREM', KEYS[1], 0, ARGV[1])
redis.call('LPUSH', KEYS[1], ARGV[1])
redis.call('LTRIM', KEYS[1], 0, 99)
return 1查询时取回不超过 100 个 ID,再在应用层用已经规范化的显示名做前缀过滤。这会传输一小段固定上限的数据,却省下了复杂索引。对最近联系人这种“小而热、每人不同”的名单,它往往比维护全局搜索结构更稳。
这里的原子边界只覆盖“去重、前移、裁剪”。用户资料可能恰好被删除,显示名也可能刚修改,所以返回结果前仍要批量读取当前资料,并容忍某个 ID 已不存在。LIST 也不能高效按中间位置删除海量元素;当每人名单扩大到数万条时,这个模型就开始吃力。
如果候选是一个共享词典,可以把所有成员用相同分数写入 ZSET,再按字典序查范围。前缀 red 的概念区间是从包含 red 的最小字符串,到刚好超过所有 red... 字符串的上界。现代命令可以用 ZRANGE ... BYLEX 表达这类查询。
ZADD autocomplete:commands 0 redis-cli 0 redis-server 0 restore
ZRANGE autocomplete:commands [red (ree BYLEX LIMIT 0 10示意中的 (ree 是针对简单 ASCII 词典构造的排他后继边界。真实中文搜索不能照抄这个技巧:全角半角、大小写、拼音、多音字、Unicode 组合字符和语言排序规则都不是“字符加一”能解决的。写索引前先固定规范化规则,成员最好包含稳定 ID,显示文本另存;否则改名和同名去重都会让索引难以维护。
自动完成通常还要考虑使用频率。不要一边要求所有成员同分以便字典序查询,一边又把热度塞进同一个 score。更清楚的做法是:
如果你需要中文分词、拼写纠错、模糊匹配、同义词、权限过滤和复杂相关性,普通 ZSET 已经变成一套自制搜索引擎。这时应考虑 Redis Search 或专业搜索服务,而不是继续往边界字符技巧上打补丁。
自动完成最容易忽略的失败不是 Redis 宕机,而是索引与主数据分离后慢慢漂移。索引更新失败要能重放,删除要能清理,查询要能过滤已失效实体,并定期用主数据重建或校验索引。
两个结算实例同时处理订单 1008。我们不希望它们重复生成发货单,于是想到在 Redis 里放一个锁。直觉上像是“谁先插旗,谁做事”,但带 TTL 的分布式锁更准确的名字是租约:你只在有限时间里被允许工作。
正确的单实例基础形式是:
SET lock:order:1008 7f53... NX PX 30000NX 表示锁不存在才写入,PX 30000 表示 30 秒后自动过期,随机 token 7f53... 表示这一次租约的持有者。条件写入与 TTL 在同一条 SET 中完成,没有“SETNX 成功后进程崩溃,EXPIRE 还没来得及执行”的空档。
获取失败时不要所有客户端用固定 10 毫秒整齐重试,那会制造一群同步敲门的人。设置获取截止时间,使用带随机抖动的退避,并让业务能返回“正在处理”或进入队列,通常比无限自旋更健康。
绝不能直接 DEL lock:order:1008。客户端甲可能暂停 40 秒,旧锁已经到期,客户端乙又拿到了同名锁;甲醒来后直接删除,会把乙的锁删掉。
Redis 8.4 及以上可以使用带相等条件的删除:
DELEX lock:order:1008 IFEQ 7f53...旧版本用 Lua 把“读取 token、相等才删除”合成一个原子操作:
if redis.call('GET', KEYS[1]) == ARGV[1] then
return redis.call('DEL', KEYS[1])
end
return 0续期也遵循同样原则:只有当前值仍等于自己的 token 才能延长 TTL。续期线程要留出网络抖动余量,限制总续期次数,并在续期失败时尽快停止后续工作。它能降低正常长任务误过期的概率,却不能把一份已经过期的租约重新变成有效写入权。

客户端甲拿到 30 秒租约后发生长时间 GC、缺页、CPU 饥饿或网络阻塞。第 31 秒,客户端乙合法取得新租约并写完数据库。第 40 秒,甲恢复运行,它的进程内变量仍写着“我拿到过锁”,于是带着旧结果去覆盖数据库。
token 校验释放只能防止甲误删乙的锁,防不住甲去写另一个系统。续期也不能消除“最后一次检查后立刻暂停”的窗口。
需要保护正确性时,应给每次授权分配单调递增的 fencing token,例如 41、42、43。每次写下游都携带它,下游保存已接受的最大编号,只接受更大的编号。这样乙的 42 先到后,甲迟到的 41 会被拒绝。
在一台稳定运行的 Redis 主节点上,INCR 可以产生递增编号;但如果编号尚未复制就发生故障切换,计数也可能回退。若你要求编号在故障切换后仍严格单调,令牌来源本身就要有匹配的线性一致保证,不能只在一份普通异步复制的 Redis 数据上加个计数器。
这里有一个经常被省略的条件:下游必须参与校验。如果对象存储、支付接口或旧数据库无法条件写入 fencing token,应用层拿到一个递增数字也没有用。对资金、库存唯一性、主节点选举等正确性要求很高的场景,应优先使用数据库事务与唯一约束,或使用具备共识语义的协调系统,不要把 Redis 租约包装成万能强一致锁。
如果锁失效最多造成一次重复计算、重复刷新缓存或重复发起可幂等任务,单实例租约常常已经够实用。把它明确标成“效率锁”,监控争用率、持有时长、续期失败和过期后完成次数。
如果锁失效会导致超卖、重复扣款、永久数据损坏,就把正确性落到最终数据存储:唯一键、版本号、状态机条件更新、串行化事务或 fencing 校验。此时 Redis 锁可以减少冲突,但不能成为唯一防线。
“每分钟最多 60 次”听起来很明确,其实还缺三项:按用户、IP、租户还是接口计数?一分钟是整点窗口还是任意连续 60 秒?Redis 故障时放行还是拒绝?不先回答这些问题,算法再漂亮也可能限错人。
最简单的键可以是:
rate:{tenant:acme}:login:202608131430 = 37第一次请求创建计数并设过期,随后递增。读计数、判断额度、递增和设置 TTL 应放入一段短脚本,否则两个并发请求可能同时看到“还剩一个名额”,最后都被放行。仅仅 pipeline INCR 与 EXPIRE 只是少一次网络往返,不会自动表达整个判定逻辑。
固定窗口省内存、易理解,适合账单额度和粗粒度防护。代价是边界突发:用户在 14:30:59 发 60 次,又在 14:31:00 发 60 次,连续一秒内通过了 120 次。
滑动日志通常用 ZSET,score 是请求时间,member 是“时间戳 + 唯一随机值”。一次原子脚本完成:
member 不能只用毫秒时间戳,否则同一毫秒的并发请求会互相覆盖。时间来源也要统一;各应用节点时钟漂移时,最好由脚本读取 Redis 服务器时间,或者接受并监控有限误差。
滑动日志能回答“过去任意 60 秒究竟来了多少次”,但每个请求都对应一个成员。高额度租户和恶意流量会让内存、删除成本和热 key 压力迅速上升。需要近似平滑而非逐请求审计时,滑动计数器或令牌桶更合适。
令牌桶保存两个核心值:当前令牌数与上次补充时间。每次请求到来,先按经过时间补令牌但不超过容量,再尝试扣除所需令牌。读时间、补充、判断和扣减必须在同一脚本或 Function 中完成,否则并发请求会重复消费同一批令牌。

容量决定最多容忍多大突发,补充速率决定长期吞吐。比如容量 20、每秒补 5 个令牌,空闲后可以瞬间通过 20 个请求,之后长期稳定在每秒 5 个左右。返回值最好包括是否允许、剩余令牌和建议重试时间,让网关能设置清楚的响应头,而不是只返回一个冷冰冰的 false。
同步限流位于每个请求的必经路径,Redis 延迟会直接进入接口延迟。上线前要明确:
限流只是在入口做决策,不等于并发控制。每秒允许 100 个耗时 30 秒的请求,最终可能积累 3000 个在途请求。保护数据库连接池时,还要配合并发上限、队列长度、超时和背压。
用户提交导出报表后,接口没必要等几十秒。生产者把任务放进队列,worker 异步处理,这是 Redis 最诱人的应用之一。最短代码是 LPUSH 加 BRPOP,也恰好藏着最大的丢失窗口:任务一从 LIST 弹出,就不在 Redis 里了;worker 下一毫秒崩溃,谁也不知道它曾经存在。
把一个队列拆成两个 LIST:
queue:{report}:ready
queue:{report}:processingworker 用 BLMOVE ready processing RIGHT LEFT timeout 阻塞领取。移动在 Redis 内是原子的:任务从待处理消失的同时进入处理中。业务成功后,再用 LREM 从 processing 删除,表示确认完成。

这个模型比 BRPOP 可靠,但还不完整。LIST 元素只有任务载荷,不天然记录领取者、领取时间和重试次数。你需要额外的 HASH 或 ZSET 记录租约时间,由巡检器把超时任务送回 ready。领取、登记元数据最好用脚本合并;在 Redis Cluster 中,脚本涉及的 key 还必须用相同 hash tag 放进同一槽,例如 {report}。
任务重新入队意味着“至少一次”处理。第一次 worker 可能已经生成文件,只是确认前崩溃;第二次 worker 会再做一遍。因此任务要携带稳定 job_id,输出路径、数据库写入和外部调用都要按这个业务 ID 设计幂等。
队列一旦需要多个消费组、每条消息的领取人、待处理清单、空闲时间、投递次数和故障认领,自己用 LIST 拼元数据会越来越像在重写 Streams。
生产者追加消息:
XADD jobs:{report} * job_id rpt-1008 format pdf user_id 42初始化消费者组后,worker 读取未分配的新消息:
XREADGROUP GROUP renderers worker-a COUNT 10 BLOCK 5000 \
STREAMS jobs:{report} >消息交给 worker 后进入这个组的待处理列表。业务完成再确认:
XACK jobs:{report} renderers 1723512345678-0worker 崩溃不会让未确认消息凭空消失。恢复进程可以观察 XPENDING,并用 XAUTOCLAIM 把空闲超过阈值的消息转交给健康 worker。阈值太短会抢走仍在正常处理的长任务,太长则恢复慢,因此要结合真实处理时长分位数设置,并配合任务心跳或把长任务拆小。
投递次数持续增长通常说明消息本身有毒,而不是 worker 都不够努力。超过重试上限后,把原消息 ID、失败原因和最后一次错误写入死信 Stream,再确认原消息,避免它无限占用处理能力。死信必须有人监控、能查看、能修复后重放,否则只是一个更安静的垃圾桶。
Stream 的消息保存在 Redis 数据结构里,并不因此获得“绝不丢”的承诺。异步复制时,主节点确认写入后、消息复制前故障,切换出的副本可能没有这条消息。RDB 与不同 AOF 同步策略也有不同的数据丢失窗口。
如果任务可以从数据库重建,Redis 队列很合适;定期对账即可补回遗漏。如果订单事件是唯一事实来源、法律审计要求长期保留、消息积压可能远超内存,应该使用专门的持久消息系统,或让数据库 outbox 成为事实来源,再把事件发布到消息系统。不要因为 API 简单就让 Redis 单独承担超出部署保证的责任。
想象公司群里有人喊“服务已恢复”。在线同事立刻听见就够了,离线的人不需要补听,这很适合 Pub/Sub。再想象“订单已付款”事件,财务、库存和积分服务都必须处理,掉线回来还要补课,这就不该用同一套语义。

发布者向频道发送消息,当前在线订阅者各自收到一份。它没有消息历史、消费位点、确认或待处理列表,交付语义是最多一次:订阅者断线、处理报错或网络失败,错过的消息不会重发。
这正适合在线状态提示、临时 UI 刷新、可丢失的指标通知和缓存失效提示。这里的替代方案可能是应用内 WebSocket 广播或进程内事件总线;如果只有单实例应用,根本不必为了“看起来分布式”引入 Redis。
Stream 中每条记录有 ID,可以按范围读取,也可以让多个独立消费者组各自维护进度。同一个组内,消费者分摊消息;不同组则各自收到整条事件流。待处理列表与显式 XACK 支持至少一次处理和失败恢复。
Stream 也不是 Kafka 的缩小版。它适合低延迟、与 Redis 数据结构紧密结合、规模和保留期受控的事件处理。需要海量磁盘积压、长时间保留、跨机房复制策略和成熟重放治理时,专用日志型消息系统通常更省心。
XTRIM 或 XADD MAXLEN 可以限制 Stream 增长,但裁剪过早会让慢消费组的待处理引用指向已经不存在的消息内容。保留策略要同时观察最慢组的滞后、最长故障恢复时间和内存预算,而不是随手写一个 MAXLEN 10000。
较新 Redis 版本为确认与删除、多个消费组引用提供了更细的控制,但系统仍要先定义:消息被所有组处理后才能删除,还是某个保留期后无条件删除。命令选项只是执行这条政策,不能替你决定政策。
“30 分钟未支付就取消订单”天然有一个执行时间。ZSET 很适合做日程表:member 是稳定任务 ID,score 是到期毫秒时间。调度器不断取 score 小于当前时间的成员,再把它投递到就绪队列或 Stream。
ZADD delay:{orders} 1786606200000 cancel:order:1008两个调度器可能同时查到同一个到期任务。如果它们先 ZRANGE ... BYSCORE,回到应用层后再 ZREM,两边都会投递。一个常见脚本是:查一小批到期任务,逐个移除,只把成功移除的任务 XADD 到同槽的 Stream。这样“从日程表消失”与“进入就绪流”在一个 Redis 原子边界里发生。

脚本要限制批量大小,避免一次扫描和移动几万条任务阻塞 Redis。Redis Cluster 中,延迟 ZSET 与目标 Stream 需要共享 hash tag;若它们必须跨槽,就无法靠一个脚本获得这个原子边界,需要接受重复并做幂等,或引入中间 outbox。
到期时间表示“最早可以执行”,不是硬实时闹钟。调度轮询间隔、Redis 延迟、worker 积压和业务耗时都会让实际执行晚一些。监控至少包括:最老到期任务延迟、待调度数量、就绪积压、重试次数和死信数量。
失败重试可以再次写入 ZSET,score 设为下一次尝试时间。使用指数退避与随机抖动,避免下游刚恢复时所有失败任务同时冲回来。达到上限后转死信,并保留业务 ID、原始参数、每次错误摘要和人工重放入口。
不要把键过期通知当可靠调度器。过期事件依赖通知配置,订阅者离线会错过,而且过期键的删除时间也不是严格实时。它适合做可丢失提示,不适合承载“必须取消订单”这类业务事实。
如果需要日历规则、工作流依赖、人工审批、运行历史、跨天补偿和可视化运维,成熟任务调度系统会比不断扩建 ZSET 更便宜。
消息系统里最棘手的时刻常常发生在最后一厘米:worker 已经成功扣款,准备 XACK 时网络断了。恢复进程只能看到“这条消息没有确认”,于是再次投递。它无法从沉默中判断第一次到底做没做。

每次重试都生成新 UUID,去重就无从谈起。消息要携带稳定业务键,例如 payment:order:1008、email:welcome:user:42。这个键代表业务动作,不代表某一次传输尝试。
理想的幂等处理流程是:
消费者读取消息,验证业务键、事件版本和必要字段;格式错误或永久不可处理的消息直接进入死信流程。
在产生最终副作用的系统里执行条件写入,例如数据库唯一约束、状态从“待支付”改为“已支付”的条件更新,或外部接口提供的幂等键。
如果条件写入表明动作已经完成,读取并返回第一次的结果,不再重复扣款、发券或创建文件。
业务提交成功后再确认消息;确认失败导致再次投递时,前面的业务约束会把它变成安全重放。
只在 Redis 里 SET processed:{id} 1 NX,然后去写数据库,中间仍有两种坏结局:先标记后写库,写库失败会让重试被误判为已完成;先写库后标记,进程崩溃会让重试再次写库。除非所有副作用都在同一个 Redis 原子边界里,否则幂等约束应尽量贴近最终副作用。
客户端发送 XADD 后没收到回复,只能重试,因此生产端也会重复。Redis 8.6 起,Streams 提供基于生产者 ID 与幂等 ID 的重复追加抑制能力。它能处理“同一生产者重发同一条消息”的窗口,但不会自动让数据库扣款、邮件发送或对象存储写入幂等。
生产去重解决 Stream 里是否出现两条记录;消费幂等解决一条记录被投递两次时,业务是否只产生一次结果。两道防线可以同时使用,不能互相替代。使用较旧 Redis 版本时,则由生产者保持稳定事件 ID,并在业务层去重。
“恰好一次”通常不是某个队列命令单独给出的属性。只有消息读取、业务状态更新和处理进度在同一个事务系统里提交,或通过幂等与对账把重复和遗漏收敛,端到端结果才可能接近你真正想要的“一次业务效果”。
到这里,我们已经反复用到 pipeline、事务和脚本。它们不是强弱递增的三个档位,而是解决不同问题。
下面的三条命令可以一起发出,提高吞吐:
pipe.incr('counter:a')
pipe.incr('counter:b')
pipe.expire('counter:a', 60)
pipe.execute()如果客户端库没有启用事务语义,其他客户端可以在它们之间执行命令。即使库默认把 pipeline 包进 MULTI/EXEC,你也应明确写出依赖的语义,不要让“pipeline”三个字替代设计说明。
事务里的命令先排队,EXEC 后连续执行,其他客户端不会插入。但 Redis 不做传统数据库式回滚;某条命令因为类型错误而运行失败,其他命令仍可能执行。业务必须预先校验数据类型,处理每条返回值,并理解连接在 EXEC 前后断开时的差异。
WATCH 适合库存版本冲突不高的场景:监视键、读取、计算,随后 MULTI/EXEC;若键被别人修改,事务不执行,应用重试。热点资源上大量客户端会反复失败,脚本中的条件更新通常更直接。
锁的 token 校验、令牌桶补充与扣减、到期任务移动都属于“读取当前状态,根据条件修改”的小状态机。脚本原子执行,省去客户端读回再写造成的竞态。
它的边界也很清楚:
EVAL 脚本属于应用,需要处理脚本缓存丢失;Redis 7 及以上可用 Function 管理服务端逻辑。当业务要“数据库写订单,同时发消息”,不要试图用更长的 Redis Lua 脚本跨过边界。常见做法是数据库事务里同时写订单与 outbox 事件,后台发布器可靠地把 outbox 投递到 Stream 或专用消息系统。发布可能重复,所以事件有稳定 ID;消费者仍然幂等;对账任务负责发现长期未发布或未处理的记录。
这套方案听起来比“写库后 XADD 一下”麻烦,却把无法消除的网络不确定性变成了可观察、可重试的数据。
很多组件只有“正常路径”代码,故障后靠人手改 Redis。真正可运维的组件要提前定义恢复状态、监控信号和重放工具。
Redis CPU 和命中率正常,并不代表组件正常。队列最重要的是最老消息等待时间,限流要看允许率、拒绝率和 Redis 超时策略触发次数,锁要看争用与租约过期后仍完成的任务,Stream 要看每个组的 lag、pending 与认领次数。
还要做带断点的演练:在 XADD 回复前断连接,在业务提交后、XACK 前杀 worker,让锁持有者暂停超过 TTL,让副本切换发生在写入之后。演练不是为了证明永不出错,而是验证重复会被吞掉、遗漏能被对账发现、积压有明确上限。
Redis 的优势是低延迟和灵活状态结构。它也很容易让一个周末原型变成无人敢动的基础设施。遇到下面几类需求,先看成熟系统和现有数据库能力:
SKIP LOCKED 队列表时,别为了架构图好看而增加 Redis。判断标准可以很朴素:如果团队花在恢复脚本、状态对账、版本兼容和运维界面上的时间,已经超过业务逻辑本身,就说明你不是“用几条 Redis 命令”,而是在维护一个产品。
上线前,不妨把成功演示先放一边,用下面几道题逼近真实边界。
最后真正值得保留的不是某段示例代码,而是一套习惯:看见一个组件,先指出它的状态;看见多条命令,先问原子边界;看见确认与重试,先假设会重复;看见“锁住了”,再追问租约过期后谁有权拒绝旧写入。能把这四件事说清楚,Redis 才是在帮你搭组件,而不是悄悄替系统埋下一组定时故障。