假设你正在用 C 写一个带库存扣减的服务端模块先GET当前库存判断大于 0再DECR最后写回日志。这个流程单机跑没有任何问题可一旦并发上来库存偶尔会变成负数——因为在“读”和“写”之间已经有别的请求把库存减掉了。这个场景太经典了我当初在处理这类问题时第一个想到的就是给 Redis 上“事务”。这篇是 Redis 学习日记的第四篇前面几篇我们已经聊过基本命令和数据结构现在终于轮到事务。我尽量把它的边界讲透Redis 事务到底保证了什么、不保证什么以及用 hiredis 从 C/C 侧调用时有哪些必须注意的细节。如果你是刚接触服务端开发或者正准备把 Redis 事务用到项目里这篇文章可以帮你少走不少弯路。1. 为什么需要 Redis 事务从库存扣减的“读改写”说起1.1 单条命令做不到的原子性很多人入门前会觉得Redis 命令本来就是单线程执行的那是不是天然线程安全确实单条命令执行时不会被其他命令插队但问题在于我们的业务逻辑经常不是一条命令而是一个“读 → 判断 → 写”的完整过程。这个过程中间一旦被插队单条命令的原子性就帮不上忙了。拿库存扣减举例客户端 A 读到库存是 5客户端 B 也读到库存是 5客户端 A 把库存改成 4客户端 B 也把库存改成 4最后库存是 4但实际有两单被扣掉等于丢失了一次扣减。这个问题本质上是“读改写”这三个动作没有被打包成一个不可分割的单元。只要中间有任何间隙并发就会钻空子。1.2 我理想中的事务执行过程遇到这种问题你会希望 Redis 能提供类似关系型数据库的事务能力把一组命令排成队一起执行中间不允许别人插入。实际 Redis 事务也确实往这个方向设计但具体能到什么程度是这篇文章的重点。Redis 事务的最小使用流程只有四个命令MULTI、命令入队、EXEC、DISCARD。先看最直观的效果127.0.0.1:6379 MULTI OK 127.0.0.1:6379 SET stock:1001 5 QUEUED 127.0.0.1:6379 DECR stock:1001 QUEUED 127.0.0.1:6379 EXEC 1) OK 2) (integer) 4MULTI之后后续命令不会立即执行而是返回QUEUED表示进入队列。EXEC触发整批命令按顺序执行并一次性返回所有结果。这就是事务最基本的样子。不过这里要泼一盆冷水Redis 事务和 MySQL 事务共享同一个“事务”的词但能力天差地别。下面细讲。2. Redis 事务的底牌MULTI、EXEC、DISCARD2.1 先用手敲一遍核心命令从客户端视角事务状态下的交互很简单127.0.0.1:6379 MULTI OK 127.0.0.1:6379 SET foo bar QUEUED 127.0.0.1:6379 INCR num QUEUED 127.0.0.1:6379 DISCARD OKDISCARD会放弃当前事务队列里的所有命令回到普通状态。如果你在事务执行到一半想反悔它是唯一的出口。这里有个值得养成的习惯EXEC返回的结果顺序和命令入队顺序严格一致。你完全可以把第一条命令的输出当作参照逐条对账确认每一条是否按预期执行。2.2 关键认知事务不会回滚错误也分两种这是 Redis 事务最容易让新人翻车的点。面试题里也常出现Redis 事务支持回滚吗不支持。它只有“命令执行中途不会被别人插队”这个层面的原子性没有数据库那种“执行到一半出错把前面改过的数据全部还原”的机制。与之配套的是错误必须被分成两种情况看待入队错误命令语法错误、参数个数不对比如SET key少写一个参数。这类错误在MULTI后入队时就能发现Redis 会直接返回错误字符串而不是QUEUED。此时如果继续EXEC整个事务会拒绝执行返回EXECABORT。执行错误命令语法没问题但运行时失败比如对一个整数类型的 key 执行LPUSH或者对非整数键执行INCR。这类错误已经在执行阶段发生Redis 会把这个命令的结果标记为错误但不会中断后面的命令也不会回滚前面已经成功的命令。举一个经典例子127.0.0.1:6379 MULTI OK 127.0.0.1:6379 SET a 1 QUEUED 127.0.0.1:6379 LPUSH a hello QUEUED 127.0.0.1:6379 SET b 2 QUEUED 127.0.0.1:6379 EXEC 1) OK 2) (error) WRONGTYPE Operation against a key holding the wrong kind of value 3) OKSET a 1成功LPUSH a hello报错SET b 2依然成功。最终 a1b2前面的错误不影响后面继续执行。这个行为和 MySQL 完全不同。你在编程时如果默认“事务失败会撤销操作”很容易在线上数据出现不一致时一脸懵。2.3 执行隔离单线程给了什么保证Redis 选择单线程执行命令这保证了同一时刻只有一条命令在真正干活。因此当一个事务进入EXEC的执行阶段后所有被队列的命令会一口气执行完中间不可能插入其他客户端的命令。这就是 Redis 事务的最大价值让一组命令的执行过程不受并发干扰。但它没有为事务期间执行的命令提供快照隔离更没有提供锁等待、可重复读之类复杂能力。如果你的逻辑需要读一个 key再根据读到的值决定是否写另一个 key单纯用MULTI是不够的因为从读完成到EXEC真正执行之间其他客户端随时可能改掉那个 key。这个缺口需要WATCH来补。3. 从 C/C 侧驱动hiredis 客户端的事务写法3.1 一个能用的封装函数长什么样我平时用 C 操作 Redis 时首选 hiredis它接口简单行为也比较接近裸协议。一个事务调用流程可以封装成函数#include hiredis/hiredis.h #include iostream #include vector #include string bool ExecuteTransaction(redisContext* ctx, const std::vectorstd::string commands, std::vectorredisReply* results) { redisReply* reply redisCommand(ctx, MULTI); if (reply nullptr) { return false; } if (reply-type REDIS_REPLY_ERROR) { freeReplyObject(reply); return false; } freeReplyObject(reply); for (const auto cmd : commands) { reply redisCommand(ctx, cmd.c_str()); if (reply nullptr) { return false; } if (reply-type REDIS_REPLY_ERROR) { // 入队阶段出错放弃事务并清理队列 freeReplyObject(reply); redisCommand(ctx, DISCARD); return false; } freeReplyObject(reply); } reply redisCommand(ctx, EXEC); if (reply nullptr) { return false; } if (reply-type ! REDIS_REPLY_ARRAY) { // 可能是 NIL说明事务因为 WATCH 被取消 freeReplyObject(reply); return false; } for (size_t i 0; i reply-elements; i) { if (reply-element[i]-type REDIS_REPLY_ERROR) { std::cerr 事务中第 i 条命令执行出错: reply-element[i]-str std::endl; } results.push_back(reply-element[i]); } return true; }这里我故意把results里的子回复直接暴露出去而没有深度拷贝只是一个示意。真实项目里建议立刻把需要的值提取出来然后freeReplyObject(reply)释放整个数组。3.2 为什么不建议在 MULTI 之后逐条等回复我最初写事务时喜欢一条条调用redisCommand代码很清晰但性能不够好。因为MULTI之后每条命令都要发一次、等一次QUEUED多一个往返延迟。事务命令多时这段等待时间会被放大。更推荐的做法是入队阶段使用流水线调用redisAppendCommand把命令批量发出去再用redisGetReply一次性读取所有回复。例如redisAppendCommand(ctx, MULTI); for (const auto cmd : commands) { redisAppendCommand(ctx, %s, cmd.c_str()); } redisAppendCommand(ctx, EXEC); while (true) { void* replyPtr nullptr; if (redisGetReply(ctx, replyPtr) ! REDIS_OK) { return false; } redisReply* reply static_castredisReply*(replyPtr); // 前 N 条是 QUEUED最后一条是 EXEC 的结果数组 if (lastReplyIteration) break; }这样事务内命令再多也只有两轮网络往返一轮发命令一轮拿结果。如果你的服务对延迟敏感这一手值得早点学会。3.3 入队失败时的清理动作入队阶段如果某条命令返回了错误比如拼错了命令名不要直接返回失败就完事。因为当前连接还处于事务状态后面的请求会全部进队整个连接相当于“卡住”。正确做法是先发一个DISCARD把事务状态清掉再向上层返回错误。还有一个接盘技巧EXEC之后如果要继续复用这个连接记得检查返回值。事务内某条命令执行出错时网络层面并不会断开连接但你的业务逻辑必须把这种部分失败当成一种状态来处理比如记日志、告警或人工补偿。4. WATCH 与乐观锁真正解决竞态的钥匙4.1 一个必须使用 WATCH 的例子回到最开始的库存扣减场景。MULTI/EXEC能保证扣减动作本身不被插队但解决不了“带着旧值去写”的问题。此时就需要WATCH出场redisReply* reply redisCommand(ctx, WATCH stock:1001); freeReplyObject(reply); reply redisCommand(ctx, GET stock:1001); if (reply-type ! REDIS_REPLY_INTEGER) { freeReplyObject(reply); redisCommand(ctx, UNWATCH); return -1; } int stock reply-integer; freeReplyObject(reply); if (stock 0) { redisCommand(ctx, UNWATCH); return 0; } redisCommand(ctx, MULTI); redisCommand(ctx, DECR stock:1001); reply redisCommand(ctx, EXEC); if (reply nullptr || reply-type REDIS_REPLY_NIL) { // WATCH 发现键已经被改过事务被取消 freeReplyObject(reply); return RetryLater; }WATCH的语义是在EXEC执行前检查被监控的 key 有没有被其他客户端修改过。有修改事务直接放弃EXEC返回nil。4.2 编程模型检查、开事务、提交、失败重试实际项目中WATCH之后的流程一般这样设计先WATCH需要保护的 key读取这些 key 的值作为判断依据根据判断结果组装事务命令执行EXEC如果返回nil说明事务因并发冲突被取消回到第 1 步重试如果返回数组说明事务成功里面最关键的一点是WATCH必须在MULTI之前调用。如果你先MULTI再WATCHRedis 会报错因为事务执行阶段不允许再设置监控。重试次数也要控制不能无限重试。我一般设一个上限比如三次超过后直接返回业务失败。这样在高竞争场景下至少能保证系统不会因为某个 key 一直被修改而陷入死循环。4.3 为什么说它是乐观锁而不是悲观锁悲观锁的思路是先锁住资源别的请求等着操作完再释放。Redis 的WATCH则相反不阻塞任何人大家都能读都能尝试写只在提交时检查冲突。冲突则放弃下次重试。这种设计非常适合 Redis 的单线程模型也符合它“尽量不阻塞”的设计哲学。缺点是重试会消耗额外网络开销但大多数业务场景的冲突概率并不高整体收益远大于成本。5. 事务和 Pipeline 的区别别把省 RTT 和原子性混在一起5.1 两种机制的侧重点完全不同不少人会把MULTI和流水线pipelining搞混因为两者都能一次发多条命令。但它们的核心目标根本不一样维度MULTI/EXECPipeline核心目标命令打包执行中间不被插队减少网络往返耗时是否保证原子性执行期间不会被其他命令插入不保证可能交错执行是否回滚不支持不支持典型用途业务需要一组命令作为一个整体批量读取、批量写入实际可以这样理解Pipeline 是网络传输层面的优化MULTI 是服务端执行语义上的约束。一个事务里的命令当然可以借助 Pipeline 发出但 Pipeline 本身不提供任何原子性。5.2 组合使用在事务内用流水线组合使用没有冲突。比如一个事务要修改多个 key你可以把所有命令通过redisAppendCommand发出去然后批量读回复这样可以同时得到“打包执行”和“低延迟”两个收益。我之前的代码示例里也提到这一点。真实项目里事务中的命令通常不多三到五条很常见此时性能差异不大。但如果业务要求在一个事务里操作几十个 key流水线的收益就比较明显了。5.3 小中见大关注 EXEC 的结果类型写客户端代码时有一个容易被忽略的细节EXEC的返回类型在两种情况下不一样。事务正常执行返回数组每条命令对应一个元素元素内容可能是字符串、整数、错误等事务被WATCH取消返回 nil所以严谨的判断逻辑是先看回复类型再看元素内容。如果你直接按数组下标去取而实际拿到的是 nil程序大概率会段错误或者异常退出。这一点在 hiredis 的多线程封装里尤其容易踩中。6. 实操中容易踩的坑我已经替你踩过一遍6.1 WATCH 放错位置事务失效但不报错我最早的实现是下面这个顺序redisCommand(ctx, MULTI); redisCommand(ctx, WATCH key_abc); redisAppendCommand(ctx, SET key_abc value_1); reply redisCommand(ctx, EXEC);结果事务执行后WATCH的效果完全没有。后来查文档和源码才发现Redis 不允许在事务里使用WATCH执行阶段返回ERR WATCH inside MULTI is not allowed但这个错误不会让整个事务回滚后续命令照常执行。后果是你以为自己在用乐观锁实际上没有锁库存照样超卖。正确的顺序一定是WATCH在MULTI之前。而且要注意WATCH返回的是OK而不是QUEUED不要用事务入队的判断逻辑去处理。6.2 集群环境里多键事务的槽位限制如果你把 Redis 事务用到集群模式Cluster要小心一个硬限制一个事务里的所有 key 必须落在同一个 hash slot 上。Redis Cluster 会把 key 按照 hash 结果分配到 16384 个槽位跨槽位的多键操作会被直接拒绝。解决办法一般是利用 hash tag也就是把 key 写成{user:1001}:balance和{user:1001}:score这种格式让这些 key 只根据花括号内的部分计算槽位。这个限制在单机 Redis 上不出现但线上集群很常见。上线前最好把事务涉及的 key 全部检查一遍。6.3 事务块里别放阻塞命令和全量操作事务中的命令是排队执行的任何一条执行时间过长都会拖住整个事务的提交时间。比如在事务里放BLPOP它会阻塞等待队列元素其他客户端的所有请求都会跟着遭殃放KEYS更是灾难全量扫描一个 key 很多的大库直接卡住服务。还有一个隐蔽问题事务里执行的命令不应该依赖外部计算结果。因为在入队阶段命令已经被固定下来等到EXEC时才真正执行。如果你在读取某个值之后、MULTI之前去构造命令这个值在EXEC之前可能已经变了。解决方案就是想清楚自己到底需要哪种语义需要基于最新值执行用WATCH需要保证一组写操作连续执行用纯MULTI/EXEC。最后分享一个小技巧。事务这种东西读十遍文档不如自己亲手敲一遍。我建议你先不用 hiredis直接打开redis-cli把MULTI、入队、EXEC、DISCARD以及WATCH的取消事务都手动跑一遍感受一下不同出错路径的返回结果。把这些返回值输入到你的记忆里再写 C 代码时最难的部分——错误处理就已经不再是问题了。