Redis事务与WATCH乐观锁:C/C++视角的机制与实战
1. 从一条命令说开去Redis 事务到底解决了什么问题先从一个最常见的场景说起你在 Linux 服务器上跑着一个 C/C 写的数据采集程序采集到一批数据后需要同时更新 Redis 里的两个 key比如counter:today和last_update_time。如果第一条命令执行成功、第二条因为网络抖动失败你会得到一个什么结果没错数据不一致——计数变了时间没更新。这种“只执行了半个逻辑”的状态放在生产环境里是要出事故的。Redis 的事务MULTI/EXEC就是为这种场景设计的。它不是一个像 MySQL 那样完整的 ACID 事务而是一组命令的原子化排队执行机制。你把这组命令打包告诉 Redis“要么一起执行要么别单独执行半个”Redis 收到 EXEC 后就会把这批命令一次性顺序跑完。注意我的用词是“顺序”不一定是“互不干扰”也不是“可回滚”。这两个差异点几乎贯穿了 Redis 事务的全部学习过程理解了它们你才算绕过最深的坑。这个系列的学习日记走到第 63 篇我一直站在 Linux 下用 C/C 视角去看 Redis。前面几篇讲了基础数据结构、持久化、主从复制这一篇聚焦事务。为什么把事务单独拎出来因为它和你在 C 里写的多线程锁、数据库事务概念既有重合又有巨大差异。搞不清楚这些差异你会很容易写出“看起来对、跑起来错”的代码。这篇笔记会把 Redis 事务的完整机制、WATCH 乐观锁、与 C/C 客户端的联动方式以及常见误用场景一次说透。2. Redis 事务的本质不是你以为的那种“事务”2.1 事务三阶段入队、排队执行、结果返回Redis 事务的命令行操作只有五个指令MULTI、EXEC、DISCARD、WATCH、UNWATCH。其中MULTI是开启事务EXEC是执行事务DISCARD是取消事务。整个过程可以拆成三个阶段我用一组实际命令来演示redis-cli 127.0.0.1:6379 MULTI OK 127.0.0.1:6379 SET user:1001:balance 100 QUEUED 127.0.0.1:6379 INCR user:1001:login_count QUEUED 127.0.0.1:6379 EXEC 1) OK 2) (integer) 1第一阶段是MULTI。这个命令返回 OK表示“我进入事务模式了”。从此刻开始你发的每一条命令都不会立刻执行而是被推进一个先进先出的队列里。Redis 客户端每发一条命令服务端返回QUEUED告诉你“命令已入队但还没跑”。第二阶段是EXEC。一旦服务端收到EXEC它会把队列里所有命令按入队顺序依次执行然后把每条命令的结果打包成一个数组返回给客户端。第三阶段是结果返回阶段客户端拿到的数组长度等于命令条数每一位对应一条命令的执行结果。这里有个非常容易忽略的细节命令在MULTI/EXEC之间只是“排着队”并没有真正执行。所以你不会在入队阶段看到任何实际的副作用比如SET不会真的写入值INCR不会真的增加计数。所有副作用都发生在EXEC那一刻。这意味着你在入队阶段无法根据前一条命令的结果来决定要不要发送下一条命令——因为前面的命令结果你还没拿到。这个限制直接决定了你后面写业务逻辑时的组织方式。2.2 为什么说它“原子但不可回滚”这两个词要拆分理解。“原子”是指EXEC一旦执行队列里的命令会连续地、不间断地被服务端处理不会插入其他客户端的命令。注意我说的是“不会插入其他客户端的命令”不是“要么全成功要么全失败”。传统数据库事务里的原子性强调的是“失败即回滚回到事务开始前的状态”。Redis 不这么做。Redis 的命令在执行阶段如果某一条失败了它不会回滚已经成功执行的那些命令而是跳过出错的那条继续执行后面的命令。举个例子127.0.0.1:6379 MULTI OK 127.0.0.1:6379 SET debug:key hello QUEUED 127.0.0.1:6379 INCR debug:key QUEUED 127.0.0.1:6379 SET debug:other 999 QUEUED 127.0.0.1:6379 EXEC 1) OK 2) (error) ERR value is not an integer or out of range 3) OK第三条INCR debug:key对一个字符串值执行自增Redis 直接报错但第四条SET debug:other照样执行成功。这就是“部分成功”的现实形态。你在 C/C 里如果用过事务第一反应肯定是“怎么不回滚”。Redis 官方文档的原话是事务执行过程中出现命令错误时Redis 不回滚原因有二——第一Redis 命令设计得很简单大部分错误在入队阶段就能发现第二回滚机制会增加复杂度影响性能。而 Redis 追求的就是极致的简单和快。所以等会儿我会强调Redis 事务的“原子性”要理解成“隔离执行、整体提交”而不是“失败全部撤销”。这两者在业务设计上的含义完全不同。2.3 两类错误要分清入队错误和执行错误Redis 事务里有两类典型错误处理方式完全不同这是新手最容易踩的坑。第一类是入队错误。发生在MULTI之后、EXEC之前。比如你命令写错了、参数个数不对、key 不存在但你用了TYPE错误的命令格式。这种情况下Redis 在入队时就返回错误同时标记整个事务为“有错”。当你执行EXEC时Redis 直接拒绝执行整个事务返回EXECABORT错误所有命令都不执行。第二类是执行错误。发生在EXEC真正跑命令的时候。比如INCR但 key 的值不是整数、LPUSH到错误类型、SET成功但后续命令逻辑依赖前置结果时类型不匹配。这类错误 Redis 不会中止整体执行而是报错后继续跑下一条。我用一张表格来把这两类错误的分界线讲清楚错误类型发现时机Redis 行为对后续命令的影响典型场景入队错误MULTI 后、EXEC 前标记事务为错误状态EXEC 时整体拒绝执行命令名写错、参数个数不对执行错误EXEC 执行命令时单条命令报错事务继续不影响后续命令INCR 一个字符串值、类型不匹配在实际开发中你完全可以在入队阶段通过客户端拦截大部分问题比如提前用TYPE命令检查 key 的类型。但执行错误很难在入队时发现因为数据是运行时才变化的。理解了这两类错误的差异你在调试时就不会一脸茫然看到EXECABORT先去查入队阶段的语法问题看到单条命令报错再去查数据本身的问题。3. WATCH 乐观锁让 Redis 事务具备“条件执行”能力3.1 为什么要 WATCH从经典的余额扣减问题说起现在考虑一个非常典型的业务用户余额扣减。你在 C 服务里收到一个扣款请求流程是这样的先从 Redis 读余额判断是否足够够的话就在事务里执行DECR。但并发场景下会有两个客户端同时读到余额为 100同时判断“够扣”然后同时执行扣减最后余额变成 98而不是期望的 99。这是一次典型的“读改写”竞态问题。Redis 的WATCH命令就是为解决这个问题设计的。WATCH可以在一开始先监视一个或多个 key等执行EXEC时Redis 会检查这些 key 在WATCH之后有没有被其他客户端修改过。如果被修改过整个事务会被拒绝执行返回nil如果没被修改过事务正常执行。整个流程我拆成四步客户端执行WATCH user:1001:balance开始监视这个 key。执行GET user:1001:balance拿到当前余额假设 100。在业务代码里判断余额是否足够如果足够发MULTI、DECR user:1001:balance、EXEC。如果EXEC返回nil说明在步骤 2 到步骤 3 之间有其他客户端改了 balance本次扣减失败需要重试或者报错。这个机制的本质是乐观锁先操作、后校验冲突了就重来。对应到传统数据库的悲观锁Redis 这种方案的成本很低因为不需要长时间持锁只在EXEC那一刻做一次检查。3.2 WATCH 的生效机制与失效场景有几个细节务必记牢。WATCH 只在EXEC时生效一次。也就是说你EXEC之后所有已监视的 key 自动解除监视。如果还需要继续保护必须重新WATCH。此外UNWATCH可以手动解除全部 key 的监视DISCARD也会清掉监视状态。WATCH 监视的是“是否被修改过”不是“值是否变化”。只要 key 被写命令影响过哪怕新值和旧值相同也会导致事务被拒绝。举个例子你WATCH了一个 key另一个客户端对同一个 key 执行SET写入一模一样的旧值。从结果看值没变但 Redis 仍然判定“被修改过”事务照样失败。这个小细节在一些幂等重试场景里特别容易被忽视。WATCH 必须在MULTI之前发起。顺序是先WATCH再MULTI再入队命令最后EXEC。如果先MULTI再WATCHRedis 会报错因为WATCH是不允许在事务队列里的命令。从 C/C 的视角看WATCH 这一套完全可以在客户端代码里实现不需要额外扩展 Redis 本身。你只需要保证“读值、判断、写事务”这三步之间WATCH 没有被意外解除就行。3.3 WATCH 的一个经典误用把 GET 和判断放进了事务里有些初学者会写成这样WATCH user:1001:balance MULTI GET user:1001:balance DECR user:1001:balance EXEC看起来没问题但实质是错的。因为GET在事务里只会返回“入队前那一刻的快照值”还是不会执行等到EXEC时它返回的才是当前值。问题在于你在入队阶段无法基于GET的结果去决定要不要发DECR。事务队列在EXEC之前根本不会给你任何GET的返回值。所以正确的做法必须是在MULTI之前先GET一次在客户端判断然后在事务里只放写命令。这个区分很重要直接决定了你的事务逻辑能不能成立。我自己第一次写这段代码时也踩了这个坑最后排查了很久才发现GET放错了位置。后来我总结出一条规则事务里只放写操作所有读操作和逻辑判断都放在事务外、WATCH 之后。这条规则可以避免九成以上的事务误用。4. Linux C/C 环境下的 Redis 事务实操4.1 编译环境和客户端选型在 Linux 下用 C/C 操作 Redis 事务客户端库我主要用两个一个是hiredisC 语言写的轻量、性能高、最接近 Redis 协议另一个是redis-plus-plusC 封装API 友好基于 hiredis支持事务的链式写法。如果你在嵌入式或者性能敏感场景直接 hiredis 就够了如果想快速开发业务redis-plus-plus 更顺手。先看编译环境。我用的是 Ubuntu 22.04gcc 11安装 hiredissudo apt-get install libhiredis-dev然后在编译时链接g -o demo demo.cpp -lhiredis如果是 redis-plus-plusgit clone https://github.com/sewenew/redis-plus-plus.git cd redis-plus-plus mkdir build cd build cmake .. make sudo make install编译时链接-lredis -lhiredis同时由于 redis-plus-plus 依赖 C17需要加上-stdc17。在我这个学习日记系列里前面很多篇都用了 hiredis这一篇两种我都会演示。4.2 用 hiredis 实现基本事务MULTI/EXEC/DISCARDhiredis 的使用方式是建立一个redisContext通过redisCommand发送命令。事务场景下sds 字符串拼接会比较方便但为了清晰我直接用redisCommand逐一发送。先看最基础的事务执行示例#include hiredis/hiredis.h #include cstdio #include cstdlib int main() { redisContext* ctx redisConnect(127.0.0.1, 6379); if (ctx nullptr || ctx-err) { if (ctx) { printf(connect error: %s\n, ctx-errstr); redisFree(ctx); } return -1; } redisReply* reply nullptr; // 开启事务 reply (redisReply*)redisCommand(ctx, MULTI); freeReplyObject(reply); // 入队命令 reply (redisReply*)redisCommand(ctx, SET counter:today 100); freeReplyObject(reply); reply (redisReply*)redisCommand(ctx, INCR counter:today); freeReplyObject(reply); // 执行事务 reply (redisReply*)redisCommand(ctx, EXEC); if (reply reply-type REDIS_REPLY_ARRAY) { printf(事务执行结果数量: %lu\n, reply-elements); for (size_t i 0; i reply-elements; i) { redisReply* element reply-element[i]; if (element-type REDIS_REPLY_INTEGER) { printf(结果[%zu]: %lld\n, i, element-integer); } else if (element-type REDIS_REPLY_STRING) { printf(结果[%zu]: %s\n, i, element-str); } else if (element-type REDIS_REPLY_ERROR) { printf(结果[%zu]: error, %s\n, i, element-str); } } } freeReplyObject(reply); redisFree(ctx); return 0; }这段代码的核心逻辑就是把MULTI、SET、INCR、EXEC依次发给 Redis。服务端返回的EXEC结果是一个数组redisReply里对应REDIS_REPLY_ARRAY类型遍历element数组就能拿到每条命令的结果。注意每个redisCommand返回的redisReply都要用freeReplyObject释放不然会有内存泄漏。如果中途想取消事务用DISCARD代替EXEC即可。取消后所有已入队的命令被清空事务状态退出。4.3 用 hiredis 封装 WATCH 乐观锁一个完整的扣款示例下面这段代码是一个完整的“余额扣减”示例我把它拆成几个环节来写方便对照理解。#include hiredis/hiredis.h #include cstdio #include unistd.h static redisReply* command(redisContext* ctx, const char* fmt, ...) { va_list ap; va_start(ap, fmt); redisReply* reply (redisReply*)redisvCommand(ctx, fmt, ap); va_end(ap); return reply; } int main() { redisContext* ctx redisConnect(127.0.0.1, 6379); if (!ctx || ctx-err) { printf(连接失败\n); if (ctx) redisFree(ctx); return -1; } const char* key user:1001:balance; int cost 30; // 初始化余额为 100 redisReply* reply command(ctx, SET %s 100, key); freeReplyObject(reply); int max_retry 3; bool success false; for (int attempt 0; attempt max_retry; attempt) { // 第一步WATCH reply command(ctx, WATCH %s, key); freeReplyObject(reply); // 第二步在事务外读取当前余额 reply command(ctx, GET %s, key); if (reply-type REDIS_REPLY_STRING) { int balance atoi(reply-str); printf(第 %d 次尝试当前余额: %d\n, attempt 1, balance); if (balance cost) { // 余额不足直接放弃 command(ctx, UNWATCH); freeReplyObject(reply); printf(余额不足\n); break; } freeReplyObject(reply); } else { freeReplyObject(reply); command(ctx, UNWATCH); break; } // 第三步事务内扣减 reply command(ctx, MULTI); freeReplyObject(reply); reply command(ctx, DECRBY %s %d, key, cost); freeReplyObject(reply); // 第四步执行事务 reply command(ctx, EXEC); if (reply nullptr) { printf(EXEC 返回空可能是连接问题\n); freeReplyObject(reply); break; } if (reply-type REDIS_REPLY_NIL) { // 事务被 WATCH 机制拒绝 printf(事务冲突准备重试\n); freeReplyObject(reply); continue; } if (reply-type REDIS_REPLY_ARRAY) { redisReply* decr_result reply-element[0]; printf(扣减成功剩余余额: %lld\n, decr_result-integer); success true; freeReplyObject(reply); break; } freeReplyObject(reply); } if (!success) { printf(扣减失败超过重试次数\n); } redisFree(ctx); return 0; }这段代码的要点有几个第一WATCH在GET之前发起。如果GET之后有其他客户端改了余额EXEC会因为 WATCH 检查失败而返回REDIS_REPLY_NIL。第二GET的结果在事务外读取判断也是事务外的普通 C 代码。第三事务里只放DECRBY一条写命令。第四冲突后不立即放弃而是重试最多 3 次。这个模式非常适合 C/C 服务端的高并发场景。相比在 C 里加锁或者用SETNX自旋锁WATCH 的方式不阻塞其他客户端性能更好。4.4 redis-plus-plus 的事务链式写法更现代的表达redis-plus-plus 的 API 风格非常现代它对事务的封装让我省了很多模板代码。先看一个对比示例#include sw/redis/redis.h #include iostream using namespace sw::redis; int main() { auto redis Redis(tcp://127.0.0.1:6379); auto tx redis.transaction(); // 命令入队 auto set_result tx.set(log:last, 2025-01-15); auto incr_result tx.incr(counter:page_view); // 执行事务 auto results tx.exec(); if (results.has_value()) { auto vec results.value(); std::cout SET 结果: vec[0] std::endl; std::cout INCR 结果: std::getlong long(vec[1]) std::endl; } else { std::cout 事务执行失败 std::endl; } return 0; }redis-plus-plus 的transaction()返回一个Transaction对象你只需调用各类命令方法向队列里入队最后exec()一次性执行。exec()返回的是一个Optional vectorOptionalstring 类型如果事务被 WATCH 机制拒绝这个 optional 就没有值。相比 hiredis 的裸指针和手动释放redis-plus-plus 的写法确实省心。不过在性能要求极高的场景我还是更倾向于 hiredis。因为 redis-plus-plus 的封装层毕竟多了不少对象构造和类型转换成本虽然不高但累积在百万级 QPS 的高频路径上还是有感知的。具体怎么选看你的业务量和代码风格偏好。5. 事务与管道、Lua 脚本的对比与实战选型5.1 它们到底有什么区别很多人在学习 Redis 事务时会同时遇到管道pipeline和 Lua 脚本三个概念容易混。我直接列一个对比表帮你彻底理清。特性MULTI/EXEC 事务Pipeline 管道Lua 脚本命令执行顺序执行可被 WATCH 影响客户端批量发送服务端依次响应一个脚本内原子执行原子性部分原子执行期间不穿插其他命令不保证原子性中间可插入其他命令完全原子条件逻辑不支持中途判断不支持支持复杂逻辑回滚不支持不支持不支持网络往返多次往返MULTI到EXEC一次往返一次往返适用场景简单批量写大量无依赖命令减少RTT需要逻辑判断和原子性从表格可以看到Lua 脚本是三者中最强的。它可以在服务端执行一段脚本多条操作之间还能用if判断整个脚本是原子的。如果你需要“先判断再写”这种逻辑Lua 脚本往往比 WATCH 更简洁。但 Lua 脚本也有自身的局限调试麻烦、可读性差、如果脚本写得太长或太频繁会阻塞 Redis 单线程。从 C/C 的角度看我的选择顺序是这样的简单批量写用事务大批量无依赖命令用管道需要复杂原子逻辑用 Lua需要根据外部读到的值做条件判断且不想写 Lua 时用 WATCHMULTI。这个顺序基本覆盖了绝大多数场景。5.2 一个真实项目的选型实录我以前做过一个 Linux 下的定时任务系统里面有个功能是批量更新一批 key 的过期时间。最初我用事务来做但事务要求所有命令先入队再执行对于上百条命令来说网络往返比较多。后来我换成管道把几百条EXPIRE命令一口气发过去RTT 瞬间降下来了。但管道的问题是如果中间某条命令报错你只能事后通过分析响应数组来定位定位成本高。所以后来我又改成 Lua 脚本把过期 key 的遍历和EXPIRE逻辑都放进脚本里一次原子执行出错立刻知道是哪一段逻辑的问题。这个经历给我的经验是选型并不是“哪个好就用哪个”而是“出了问题好不好排查”。管道省了 RTT但排查成本高Lua 省事但脚本要小心维护事务最直观但对复杂逻辑的支持有限。没有银弹。6. 常见问题与排查技巧实录6.1 WATCH 下的连接复用会有什么坑连接的复用非常关键。hiredis 的redisContext默认是 TCP 长连接如果你在多线程环境下共享同一个连接WATCH 的上下文会乱掉。举个实际例子线程 A 执行WATCH key1还没EXEC线程 B 抢到同一个连接发了一条无关命令然后EXEC执行时WATCH 检查的 key1 可能已经被 B 的命令改过导致 A 的事务莫名失败。我自己的经验是不要在多个线程之间共享同一个 Redis 连接。每个线程建独立连接或者用连接池。hiredis 本身没有连接池redis-plus-plus 内置了连接池。如果你的服务是多线程 C 架构强烈建议用 redis-plus-plus 的ConnectionPool选项。这一步可以省掉无数诡异问题。6.2 EXEC 返回 nil 但业务却当成成功处理这个问题在排查时特别隐蔽。当你用 WATCH 且事务被拒时EXEC返回 nil。但如果客户端代码忽略了REDIS_REPLY_NIL的判断直接往下走了就会出现“业务以为扣款成功实际没扣”的严重问题。我在前面 4.3 的代码里专门做了这个判断这里再强调一次REDIS_REPLY_NIL必须单独处理不能和REDIS_REPLY_ARRAY混为一谈。6.3 事务夜里锁死 Redis怎么定位曾经遇到过一次某个业务在凌晨大批量执行事务每条事务里有几十个命令结果 Redis 的单线程模型被长时间占住其他请求排队出现了明显延迟。当时我们用MONITOR命令抓当时的命令流发现是某个客户端在事务里执行了大量KEYS命令。KEYS本身就是 O(N) 复杂度的命令放进事务里更是雪上加霜。那次的解决办法是把KEYS改成SCAN并把事务里的命令条数控制在 20 条以内。Redis 官方也明确建议事务里不要放慢命令否则整个实例都会被拖慢。6.4 排查速查表现象可能原因排查方向EXEC 返回 EXECABORT入队阶段有命令语法错误检查 MULTI 到 EXEC 之间所有命令的格式EXEC 返回 nilWATCH 监测的 key 被其他客户端修改检查并发写者、是否重试逻辑事务里部分命令报错但不中止执行错误如类型不匹配检查 key 的数据类型用 TYPE 命令提前验证事务执行后其他命令延迟高事务中包含慢命令用 SLOWLOG 看慢查询控制事务内命令数量和复杂度WATCH 后事务仍总是失败有其他客户端频繁写同一个 key优化业务逻辑降低写冲突频率7. 用 C/C 视角重新理解 Redis 事务的设计哲学站在 C/C 程序员的角度Redis 事务的设计哲学其实很像 C 里的“尽量把简单的事交给语言本身把复杂的事交给约定”。它不做死锁检测、不做回滚日志、不做 MVCC一切靠“执行时不穿插”和“WATCH 主动校验”来保证一致性。这种设计在保证高性能的同时把决策权交给了应用层开发者。所以你在使用 Redis 事务时要时刻记得这不是数据库事务而是一种带冲突检测的批量执行机制。事务外你要自己负责读值、判断、重试事务内你只排队写命令。C 里头这一套逻辑可以封装成模板把“WATCH GET 条件判断 MULTI EXEC”抽象成一个通用函数传入 key、检查函数、写命令函数返回成功或失败。这样业务层只需要关心业务规则不用每次写一坨重复的 WATCH 样板代码。我最近在一个项目里就是按这种方式封装的核心的并发控制逻辑收敛到一两个函数里后续加业务只需要写检查规则和写规则。这套经验分享给正在折腾 Linux 和 Redis 的朋友值得花时间沉淀。最后再分享一个我个人常用的排查习惯如果事务逻辑怎么验都不对先开两个redis-cli窗口一个窗口运行事务流程另一个窗口在关键节点手动改一下被 WATCH 的 key模拟并发冲突。这样你就能非常直观地观察到 EXEC 返回的变化比看文档印象深得多。这个办法我用过很多次效率极高。