凌晨两点,优惠券服务报出一个很难解释的数字:活动一共准备了 100 张券,领取记录却有 103 条。负责接口的同事很快找到一段“看起来已经很安全”的代码——它把读取余额、扣减余额、写入领取记录放进了 Pipeline,一次发给 Redis。三条命令确实更快了,也确实按发送顺序得到了响应,可它们依然可能和另一个请求交错。Pipeline 解决的是网络等待,不是并发正确性。
另一个常见修复是把 pipeline() 改成 multi()。这样 EXEC 开始后,队列中的命令会连续执行,中间不会插入别的客户端命令。但问题还没彻底解决:如果扣减时发现余额类型不对,前面已经写下的领取记录不会回滚;如果业务需要先读库存、判断是否足够,再决定写什么,MULTI/EXEC 也不会替应用做这个判断。
这一讲不把 Redis 的“事务”硬套进关系型数据库的 ACID 框架。我们用优惠券领取接口贯穿全章,分别回答四个问题:怎样少等几次网络往返,怎样让一批命令不被穿插,怎样做带条件的并发更新,以及到了 Cluster 环境后为什么还要重新设计 Key。
看到代码里出现 exec(),先别急着判断它是不是事务。ioredis 的 Pipeline 也用 exec() 发送批次;真正决定语义的是你创建的是 pipeline() 还是 multi(),以及有没有用 WATCH 把提交变成条件提交。

很多事故来自一个过于宽泛的词:“原子”。有人说 INCR 是原子的,有人说事务是原子的,还有人把 Pipeline 也叫作批量原子操作。句子单独看都像那么回事,放到具体接口里却可能指向完全不同的保证。
“连续执行”也不等于“全部成功”。Redis 事务的原子性更接近:EXEC 一旦开始处理这批命令,其他客户端不能挤到这批命令中间。它没有提供关系型数据库那种“任意一步失败就恢复到开始前”的回滚语义。把这两句话记牢,后面的选择会清楚很多。
如果业务只要 INCRBY coupon:{c88}:remaining -1,一条命令就能完成,不必先上事务。如果业务要批量预热 500 个互不依赖的 Key,Pipeline 更合适。如果要同时写领取人和活动计数,而且不允许另一请求夹在两条写命令中间,才轮到 MULTI/EXEC。如果写入内容依赖刚读到的库存,就需要 WATCH,或者下一讲会讲的 Lua 脚本。
Redis 使用请求—响应协议。最朴素的客户端每发一条命令,就等服务器回一条响应,然后才发下一条。假设客户端与 Redis 之间一次往返需要 2 毫秒,服务端执行一条简单命令只要 0.01 毫秒,连续发 100 条命令时,时间主要花在 100 次往返上,而不是数据结构操作上。
Pipeline 改变的是发送节奏:客户端先把多条命令写进连接,不逐条等待;Redis 仍按顺序处理来自这条连接的命令,并把响应先积在输出缓冲区;客户端最后成批读取响应。粗略估算可以写成:
逐条请求时间 ≈ 命令数 × RTT + 命令数 × 单条执行时间
Pipeline 时间 ≈ 批次数 × RTT + 命令数 × 单条执行时间这不是精确的性能模型,排队、序列化、TCP 分包和服务端负载都会影响结果,但它能解释优化发生在哪里:Pipeline 能压缩 RTT 次数,不能把 Redis 执行 100 条命令的成本变成执行 1 条。
下面这段代码给 200 个商品写活动标记。所有命令互不依赖,因此可以批量发出:
const pipeline = redis.pipeline();
for (const productId of productIds) {
pipeline.hset(`product:${productId}`, "campaign", "c88");
pipeline.expire(`product:${productId}`, 3600
ioredis 返回的每一项都是 [error, result]。某条命令报错,不代表整批命令都没执行;它也不会撤销批次中已经成功的命令。因此批量写入如果要求可重试,必须让每条写入本身具备幂等性,并记录失败项,而不是把顶层 Promise 成功当作“全部业务成功”。
Pipeline 越长,RTT 占比通常越低,但它不是越大越好。客户端要保存待发送命令,Redis 要暂存尚未被读取的响应,网络中断时也更难判断哪些写入已经到达服务端。把几十万条命令塞进一个 Pipeline,会把延迟尖峰和内存压力一起放大。
生产代码通常设置可观测的批大小,例如每 500 或 1000 条发送一次,并根据响应体大小、延迟分位数和 Redis 输出缓冲区调整。批大小是流量控制参数,不是固定答案。读大 Value 与写一个短字符串的响应成本差异很大,不能只按命令数量估算。
Pipeline 内的后续命令也不能依赖前一条响应。你可以把 GET stock 和 DECR stock 一起发送,但客户端在构造批次时还没拿到 GET 结果,自然无法据此决定要不要 DECR。强行这样写,只是把竞态藏进了更快的网络路径。
再看一个容易被基准测试掩盖的细节。假设接口一次要读取 30 个商品详情,把 30 个 GET 放进 Pipeline,确实能把串行往返压成一次批量通信;但如果其中某个商品 Value 有几兆字节,客户端仍要等待 Redis 读取、复制并发送这份大响应。吞吐变高不代表每个请求的尾延迟都会下降。对这类接口,批量大小要和 Value 大小一起统计,必要时限制单个 Value、拆分冷热字段,并给批次设置总响应体预算。
自动 Pipeline 也不能改变调用之间的数据依赖。ioredis 可以把同一个事件循环中的命令自动分组,这对大量独立请求很方便;可一旦第二条命令需要第一条的返回值,应用就必须先等待读取,再决定下一步。为了追求“看起来只有一次请求”而预先发送错误的写命令,会把性能优化变成数据错误。
网络失败时还要接受一种现实:客户端可能不知道批次里的哪些命令已经执行。批量里如果全是幂等 SET,可以根据业务标识安全核对或重试;如果混入 INCR、LPUSH 这类重复执行会改变结果的命令,就必须使用去重标识、对账记录或更高层的幂等协议。Pipeline 没有为重试提供恰好一次语义。
Pipeline 里的命令在同一连接上保持顺序,但另一连接的命令仍可能出现在它们之间。不要用 Pipeline 实现库存“先查再扣”、余额“先验再减”或唯一资源抢占。

Redis 收到 MULTI 后,会把这条连接标记为事务状态。此后来自这条连接的普通命令不立即修改数据,只返回 QUEUED 并进入队列。收到 EXEC 时,Redis 才依次执行队列中的命令,并按入队顺序返回每条命令的结果。收到 DISCARD 则清空队列、退出事务状态,一条也不执行。
最容易写错的是这句话:“从 MULTI 开始,其他客户端就被挡住了。”事实不是这样。A 发出 MULTI 后,可能隔了几毫秒才继续发第一条命令,也可能在应用层算了很久才发 EXEC。在整个排队期,B、C 等连接仍能正常读写 Redis。A 每发来一条待入队命令,Redis只是把它放进 A 的队列,随后照常处理别的连接。
不可穿插的边界从 Redis 开始处理 A 的 EXEC 才出现。它会连续执行 A 队列中的全部命令,然后才处理其他客户端已经到达的命令。换句话说,MULTI 不是一把从声明时就锁住服务器的长事务锁;EXEC 才触发一个短小、串行的执行批次。
假设领取资格已经在别处判断完毕,现在只需要把三条已经确定的写命令连续落到 Redis:写领取记录、扣减展示库存、增加活动计数。ioredis 可以这样组织:
const results = await redis
.multi()
.set("coupon:{c88}:claim:u1001", "2026-08-19T09:30:00+08:00")
.decr("coupon:{c88}:remaining")
.hincrby("coupon:{c88}:metrics", "claimed", 1)
.exec();
for (const [
这里的三条命令在 EXEC 阶段不会被其他客户端插入,但代码仍有两个业务缺口。第一,它没有判断用户是否已经领过;第二,它没有阻止库存从 0 变成 -1。把命令包进事务,没有凭空增加条件分支。要么先用业务允许的单命令重新建模,要么用 WATCH 做条件提交,要么把判断放进 Lua。
还有一个 ioredis 层面的迷惑点:multi() 默认返回一个 Pipeline 风格的命令构造器,所以调用方式和 pipeline() 很像,也都以 exec() 结束。这个实现帮助客户端减少发送阶段的往返,但最终语义仍由 Redis 收到的 MULTI ... EXEC 决定。网络批量与服务端事务可以叠加,却不能互相替代。
Redis 不做事务回滚,因此排查事故时不能只记一句“事务失败”。你必须先问:错误发生在入队期,还是执行期?
命令不存在、参数数量错误等问题,在排队时就能确定。Redis 会立即对这条命令返回错误,并把事务标记为有问题。之后即使客户端继续入队,EXEC 也会以 EXECABORT 拒绝事务,已成功排队的命令同样不会执行。
MULTI
SET coupon:a 1
INCR coupon:a extra
SET coupon:b 2
EXEC会看到类似结果:
OK
QUEUED
(error) ERR wrong number of arguments for 'incr' command
QUEUED
(error) EXECABORT Transaction discarded because of previous errors.此时 coupon:a 和 coupon:b 都不会由这次事务写入。ioredis 会把 EXECABORT 放到 exec() 的异常上,并在错误对象中保留前面的排队错误信息。日志至少要记录根因命令和参数形状,不能只留下“EXECABORT”。
有些错误只有真正读取 Key 时才知道。例如 DECR 的参数数量正确,可以入队;但如果目标 Key 保存的是字符串 sold-out,直到 EXEC 执行它时才会发现不能转成整数。
SET coupon:{c88}:remaining sold-out
MULTI
SET coupon:{c88}:audit started
DECR coupon:{c88}:remaining
LPUSH coupon:{c88}:events claimed
EXEC结果大致是:
1) OK
2) (error) ERR value is not an integer or out of range
3) (integer) 1第一条已经写入,第二条失败,第三条仍会执行。Redis 不会因为中间错误而停止,也不会把第一条恢复。这里所谓的原子性是“整批不被其他客户端穿插”,不是“整批全成或全败”。所以事务执行结果必须逐项检查,监控中也要区分顶层 EXECABORT、WATCH 冲突与数组中的单命令错误。
如果三条命令在业务上必须全成或全败,MULTI/EXEC 本身不够。先消除会在执行期出现的类型不确定性,再用一条原生命令、Lua 或其他具备所需事务语义的数据系统表达业务约束。Redis 不会替应用生成补偿动作。
DISCARD 也不是回滚。它只在 EXEC 之前清空尚未执行的队列;一旦 EXEC 已经开始,客户端不能用 DISCARD 撤销其中的命令。
还有一种失败不属于上面两类:客户端与 Redis 的连接断了。如果断开发生在 EXEC 到达服务器之前,队列不会执行;如果 EXEC 已经到达并开始执行,Redis 会完成事务,而客户端可能收不到响应。站在接口进程的角度,这次领取到底成功没有,暂时是未知的。
这时直接重试整笔事务很危险。第一次可能已经扣过库存,第二次又扣一次。更稳妥的做法是给业务动作一个稳定的幂等标识,例如把 campaignId + userId 设计为领取记录的唯一 Key。重连后先查这条记录:存在就按已成功处理,不存在才重新进入领取流程。网络协议的“没有收到回复”不能直接翻译成业务的“没有执行”。
事务也不等于持久化承诺。EXEC 回复成功,说明主节点已经执行了命令;数据是否已经写入磁盘、是否已经复制到副本,由 AOF、复制与相关等待策略决定。不要因为一批命令连续执行,就推导出宕机后绝不会丢失。并发原子边界、客户端结果确认和故障后的数据安全,是三条独立的轴。
最后要控制事务长度。一个包含大量慢命令的 EXEC 虽然不会被穿插,却会长时间占住 Redis 的执行通道,其他请求只能排队。事务不是把耗时从系统里消掉,而是把这段耗时变成所有客户端共同等待的尾延迟。生产上应限制事务命令数,避免把大范围扫描、超大集合运算或不受控批量删除塞进去。
优惠券领取需要先读两个事实:库存是否大于 0,用户是否已经领过。然后应用才能决定是否扣减库存并写领取记录。这正是乐观并发控制要处理的“读—计算—写”窗口。
流程可以写成:先 WATCH 库存 Key 与用户领取 Key;读取二者并在应用层判断;满足条件后发 MULTI,排入扣减与领取记录;最后 EXEC。只要被监视的任意 Key 在 WATCH 与 EXEC 之间被修改,EXEC 就不会执行队列,而是返回空结果。应用必须重新 WATCH、重新读取、重新计算,不能只把原来的事务再提交一次。

WATCH 状态属于 Redis 连接,不属于某个 JavaScript 函数,也不属于一个业务 requestId。从 WATCH 到读数据、MULTI、EXEC 或 UNWATCH,必须始终使用同一条连接。如果多个 HTTP 请求并发共享一条被复用的连接,一个请求的 WATCH 可能污染另一个请求的事务;如果连接池中途换了连接,EXEC 又看不到前一条连接建立的监视状态。
下面的示例为一次领取过程创建专用连接。真实服务不应每次都新建 TCP 连接,而应从连接池租用一条独占连接,并在结束后归还;代码把边界写开,是为了让连接所有权一眼可见。
import Redis from "ioredis";
const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
async function claimCoupon({ campaignId, userId, maxAttempts = 6
WATCH 冲突不是服务器异常,而是一次条件提交没有成立。指标应单独记录 watch_conflict_total、重试次数和最终放弃数。不要把冲突无上限地藏在 while (true) 里:热点活动下,某个请求可能一次次撞上别人更新,形成饥饿;大量立即重试又会把竞争放大。有限次数、指数退避与随机抖动可以控制代价,超过上限则让上游重试或返回“活动繁忙”。
被监视 Key 的修改不只来自其他客户端。过期和淘汰也可能使提交失效。执行 EXEC 后,不论提交成功还是因冲突取消,监视都会解除;主动放弃时则要调用 UNWATCH,或关闭这条连接。WATCH 是冲突检测器,不是锁:它不会阻止别人修改 Key。
单机 Redis 上能连续执行的多 Key 事务,迁移到 Cluster 后还要满足路由条件:整笔事务涉及的 Key 必须位于同一个 hash slot。不同槽可能由不同主节点负责,而 Redis Cluster 不提供跨节点的两阶段提交,因此客户端不能把一笔 MULTI/EXEC 拆到两个分片后仍声称它原子。
下面两个 Key 看起来属于同一活动,但按完整 Key 计算哈希时可能落到不同槽:
coupon:remaining:c88
coupon:claim:u1001把共同的业务边界放进花括号,Cluster 只对花括号中的内容计算槽位:
coupon:{c88}:remaining
coupon:{c88}:claim:u1001
coupon:{c88}:metrics这样一场活动内需要一起处理的数据会进入同一个槽,事务、WATCH 多 Key 和 Lua 才有明确的执行节点。

哈希标签不能随手全部写成 {coupon}。那会让所有活动挤到一个槽,把 Cluster 重新用成热点单机。更合理的标签通常是业务原子边界,例如活动 ID、订单 ID 或账户 ID。先问“哪些数据必须一起提交”,再决定 {...} 里面放什么。
ioredis 的 Cluster 模式还有一层客户端约束。手动 Pipeline 会把批次路由到节点,因此批次中的 Key 至少要归属于同一节点;MULTI/EXEC 涉及的所有 Key 则必须严格同槽。自动 Pipeline 可以按节点拆分批次来优化网络,但拆成多个节点批次之后更不可能获得跨节点原子性。性能分组与事务边界仍然是两回事。
如果监控里只有一个 redis_error_total,排障时仍会回到猜测。Pipeline 单条失败、事务入队失败、事务执行期失败、WATCH 冲突和网络结果未知,处置方式完全不同,至少应该分开记录。
Pipeline 要记录批大小、总耗时、每条命令的错误位置和错误类型。批次耗时突然升高时,先看是 RTT 变大、批量过长,还是其中混入了慢命令。只记录 exec() 用时,会把几百条命令藏成一个无法解释的黑盒。
MULTI/EXEC 要同时记录事务命令数、EXEC 延迟和结果数组中的错误数。出现 EXECABORT 时,把排队期的原始错误带进结构化日志;出现执行期错误时,把成功项与失败项分开,触发业务对账,而不是简单重跑。连接在提交窗口断开时,将状态标成 UNKNOWN,再靠幂等记录确认结果。
WATCH 要记录冲突率、每次请求的尝试次数、退避时间和最终放弃数。低流量下偶尔冲突很正常;热点 Key 的冲突率持续升高,则说明乐观并发已经不再乐观。此时继续增加重试次数只会制造更多 GET、WATCH 与 EXEC,应尽快转成 Lua、串行化队列,或者重新拆分热点业务边界。
一个实用的验收方法是主动制造三类故障:在 Pipeline 中放入一条类型错误命令,确认其他响应仍被处理;在 MULTI 排队时放入参数错误,确认整批没有执行;在 WATCH 与 EXEC 之间用第二条连接修改库存,确认应用重新读取并在达到上限后停止。能从日志和指标中一眼区分三次实验,说明这套代码不只“跑通了”,也具备出事后解释自己的能力。
面对一段 Redis 多命令代码,可以按下面的顺序判断,而不是先在 Pipeline、事务和 Lua 之间凭感觉选一个。
先尝试把动作改写成一条原生命令。计数用 INCR/HINCRBY,集合去重用 SADD,带条件的字符串写入先检查 SET 的条件选项。单命令最短,也最容易说明原子边界。
如果多条命令互不依赖,只是逐条往返太慢,用 Pipeline,并为批大小、逐项错误、超时与幂等重试做好设计。
如果多条已确定命令必须连续执行,中间不能插入其他客户端命令,用 MULTI/EXEC;同时确认执行期错误不会让业务落入半完成状态。
如果写入依赖应用刚读到的值,而且冲突不频繁,可以用 WATCH + MULTI/EXEC,并固定连接、限制重试次数、记录冲突指标。
批量写搜索索引缓存。 500 条 SET 之间没有条件依赖,失败项可以按 Key 重试。目标是吞吐,选 Pipeline,不要为“看起来更安全”平白加事务。
写操作日志并更新一个计数。 两条命令已经确定,且不希望中间插入别的客户端操作,可以用 MULTI/EXEC。但如果日志写成功、计数因类型错误失败是不可接受的,就要先约束 Key 类型并检查结果,或重新设计成一段不会产生运行期错误的服务端逻辑。
库存大于零才允许领取。 结果依赖刚读到的库存和领取状态。低冲突时 WATCH 可用;热点秒杀中大量请求会同时读到相同状态,WATCH 会制造重试风暴,更适合用短小 Lua 在服务端一次判断并写入。
下面几道题都不考命令拼写,只检查你是否分清了边界。
如果你只能从这一讲带走一句话,就记住:批量发送、连续执行、条件提交和失败回滚,是四种不同能力。 Pipeline 负责第一种,MULTI/EXEC 负责第二种,WATCH 给第三种增加乐观版本检查,而第四种并不是 Redis 事务提供的保证。下一讲继续沿着这条边界看 Lua:当业务必须在服务端完成“读取、判断、写入”时,怎样把一段逻辑压成一次不可穿插的执行。
如果需要条件分支、多个读写步骤,或热点竞争让 WATCH 反复失败,把逻辑移到服务端 Lua;如果真正需要跨系统回滚、强持久性或跨分片事务,应让具备这些语义的数据系统承担它。