ARTICLE DETAIL

资讯详情

深耕网站建设与运营推广的一线实战洞察。

Redis事务实战解析:MULTI/EXEC/WATCH原理与C/C++工程实践

Redis事务实战解析:MULTI/EXEC/WATCH原理与C/C++工程实践 做Linux C/C后端开发的朋友大概率被缓存与数据库一致性超卖并发扣减这类问题反复折腾过。前面几篇学习日记把Redis的基础数据类型、持久化策略过了一遍这次轮到事务。Redis系列走到第四篇心里多少有点感慨很多人把Redis事务当成MySQL事务的平替上来就套ACID结果线上栽了跟头还不知道原因。这篇日记就用C/C开发者视角结合hiredis客户端把Redis事务的MULTI/EXEC/DISCARD、WATCH乐观锁、ACID边界、工程取舍和常见问题整个捋一遍。适合正在用C/C做Redis开发、想搞懂事务底层逻辑和正确用法的同行尤其是打算在库存、秒杀、计数这类场景中用事务又没踩过坑的新手。先给个基调Redis的事务设计得非常轻巧它解决的核心问题是把一批命令打包在一起避免在并发条件下被其他客户端的命令穿插执行以及用乐观锁阻止基于过期数据的写入。它和MySQL事务最大的区别在于它不像数据库那样提供完整的出错回滚能力更谈不上二阶段提交这类分布式事务协议。理解了这句后面所有细节都不难推导出来。1. MULTI/EXEC/DISCARDRedis事务的运行机制拆解1.1 MULTI命令与事务状态Redis事务的起点是MULTI命令。执行MULTI后当前连接进入事务状态Redis服务器在这个连接上不再立即执行后续命令而是把每一句命令放入一个队列。命令入队成功时服务器立刻返回QUEUED字符串注意不是这条命令真正的执行结果。这里有一个特别容易混淆的点命令是否合法入队阶段会检查一部分但不是全部。像命令拼写错误、参数个数不对这类语法问题Redis在入队时就能发现并返回错误同时给当前事务打上一个脏标记。一旦事务是脏的之后调用EXEC时Redis会直接返回EXECABORT整个事务一条命令都不会执行。而另一类运行时错误比如对一个字符串类型的key执行LPUSH这类错误在入队阶段完全看不出来只有等到EXEC真正执行时才暴露。很多新手以为入队阶段Redis会把命令完整检查一遍实际上入队阶段只做了解析层面的静态检查能发现的错误类型非常有限。理解这一点对写客户端代码很重要入队阶段返回的QUEUED并不代表命令最终能成功执行更不代表事务一定会提交。事务状态、脏标记、最终EXEC结果这三者要分开看待。1.2 入队阶段的服务端行为从C/C开发者角度入队阶段的行为可以直接映射到hiredis代码上。我用一段最小示例说明redisReply *reply; reply redisCommand(ctx, MULTI); freeReplyObject(reply); reply redisCommand(ctx, SET stock:sku001 100); // 事务状态下这里返回的是QUEUED而不是OK if (reply-type REDIS_REPLY_STATUS strcmp(reply-str, QUEUED) ! 0) { // 事务状态异常需要走错误处理 } freeReplyObject(reply);这段代码里有个非常隐蔽的坑如果你直接拿SET命令的返回值和OK做比较会发现永远匹配不上。原因很简单连接只要处于事务状态后续每个命令的即时回复都是QUEUED而不是命令自己的结果。真正的执行结果被攒到EXEC时统一返回。我建议在公司项目里给客户端封装一层事务工具函数事务环境下的命令发送统一走发送命令、检查QUEUED、最终EXEC解析结果数组的流程。千万不要把事务内和事务外的返回处理逻辑混用不然代码很容易在某个凌晨三点飘出奇怪问题。实际业务里我用hiredis写了几年Redis客户端这种混用问题见得太多了它不会每次都出错但只要事务中有一条命令入队异常后续所有返回解析都会错位。1.3 EXEC执行与DISCARD丢弃EXEC命令负责真正执行队列中的命令。它的返回值是一个数组数组里每个元素对应队列中一条命令的执行结果排列顺序和入队顺序完全一致。这里要再强调一遍如果队列中间某条命令在运行时抛错EXEC依然会返回数组正确的命令正常返回结果出错的那条在对应位置返回错误信息。数组长度不会因此截断后面的命令也不会因为前面出错而自动跳过。这意味着如果你的事务队列里有五条命令第三条执行时出错你不会收到一个整体失败的响应。前两条命令的修改已经真实生效了后两条命令如果依赖第三条的写入结果业务上就会出现一个微妙的时间窗口。这就是为什么我反复提醒把可能出错的命令尽可能放在事务的末尾让可能的错误不要影响前面的核心写操作。如果想取消事务使用DISCARD。DISCARD会清空当前连接上已经排队的命令同时退出事务状态。但注意DISCARD只能丢弃尚未执行的排队命令已经通过EXEC执行完的改动不会回滚这和关系数据库里的ROLLBACK完全是两码事。另外还有一个极易踩的坑如果在还没有执行MULTI时就调用DISCARDRedis会返回ERR DISCARD without MULTI这种情况下应该用UNWATCH命令来取消监视。我自己的编码习惯是只要用了MULTI客户端代码里必须预留DISCARD出口。尤其是入队阶段发现错误时要立刻主动DISCARD把连接恢复成正常状态否则后续所有redisCommand调用都会继续进入QUEUED状态排查起来极其费劲。2. 从乐观锁到CASWATCH的真正价值2.1 WATCH的工作机制如果Redis事务只有MULTI/EXEC/DISCARD它充其量算一个批量命令打包器还不配叫事务。Redis事务真正值钱的地方是WATCH命令。WATCH提供的是乐观锁语义。用法是在MULTI之前对需要保护的key执行WATCH。之后一直到EXEC这一刻如果有其他任何客户端修改了被WATCH的keyRedis会拒绝执行整个事务EXEC直接返回nil队列里所有命令一条都不执行。Redis底层实现机制其实不复杂每个key被修改后会通知所有监视这个key的客户端把客户端的CLIENT_DIRTY_CAS标志置位。EXEC时检查这个标志只要被置位就放弃执行。整个过程没有加锁、不阻塞其他客户端读写所以叫乐观锁。它的思路有点像你去自助洗衣房先观察一下目标机器有没有人用确认空闲就投币启动但如果有人在投币前抢先一步按了启动键你就只能退回来重新排队等下一轮。这一节要强调一个顺序问题WATCH必须在MULTI之前发送。如果你把WATCH放在MULTI和EXEC之间它会被当作普通命令入队然后等EXEC时才执行。虽然命令本身不会报错但监视效果完全没有。我见过不只一次这样的代码还能正常运行就是并发一上来事务该拦截的拦截不了数据乱了才开始怀疑人生。2.2 CAS模式的正确打开方式用WATCH实现CASCompare and Set是Redis事务最典型的业务用法。以库存扣减为例完整流程应该是这样WATCH stock:sku001GET stock:sku001在MULTI之前执行拿到当前库存判断库存是否足够不够就放弃MULTISET stock:sku001 新库存EXEC如果EXEC返回nil说明库存被其他请求修改过回到第1步重试第2步很容易写错。很多人会把GET也写进MULTI里以为事务内可以先读再写。实际上MULTI里的GET同样会被排队在EXEC之前根本拿不到它的返回值自然没法基于它计算新值。正确做法是在WATCH之后、MULTI之前先把需要读的数据全部读出来完成业务计算之后再用MULTI/EXEC把写命令排队执行。这种先检查后更新的模式正是CAS的标准形态。极少数场景下如果你的事务逻辑需要读取A、根据A的返回值决定是否写B这种依赖关系单靠MULTI/EXEC是做不到的因为读操作和写操作之间存在一个无法在事务内消弭的先拿值再决定间隔。这种场景的正确解法是Lua脚本后面第4节会专门讲。2.3 用hiredis写一个完整的库存扣减函数这里写一个尽量贴近生产风格的hiredis函数。我会把每一步的意图写在注释里并且处理连接断开、回复为空、数据格式异常这些边界情况#include hiredis/hiredis.h #include stdio.h #include stdlib.h static int check_and_decr(redisContext *ctx, const char *key, int num) { if (ctx NULL || key NULL) return -1; int retry 3; // 控制重试次数避免极端情况下无限循环 while (retry-- 0) { // 第1步WATCH待保护key redisReply *r redisCommand(ctx, WATCH %s, key); if (r NULL) break; freeReplyObject(r); // 第2步事务外读取当前值 r redisCommand(ctx, GET %s, key); if (r NULL) break; int stock -1; if (r-type REDIS_REPLY_STRING) { stock atoi(r-str); } freeReplyObject(r); if (stock 0) { // 数据不存在或格式异常用UNWATCH取消监视后退出 redisCommand(ctx, UNWATCH); break; } if (stock num) { // 库存不足取消监视返回0表示不成功但无需重试 redisCommand(ctx, UNWATCH); return 0; } // 第4步MULTI开启事务并排队写操作 r redisCommand(ctx, MULTI); freeReplyObject(r); r redisCommand(ctx, SET %s %d, key, stock - num); freeReplyObject(r); // 第5步EXEC执行 r redisCommand(ctx, EXEC); if (r NULL) break; if (r-type REDIS_REPLY_NIL) { // 事务被WATCH拦截说明key被其他客户端修改准备重试 freeReplyObject(r); continue; } freeReplyObject(r); return 1; // 扣减成功 } return -1; // 重试次数用尽或发生网络错误 }这个函数有两个细节值得特别留意。第一库存不足和读取异常的分支用的是UNWATCH而不是DISCARD。原因前面说过此时还没执行MULTI连接不处于事务状态DISCARD会报错UNWATCH才是取消WATCH监视的正道。这个坑我在第一版代码里踩过后来在代码评审中被同事抓出来才意识到。第二EXEC返回nil只能说明被WATCH拦截它本身也是事务结束的正常出口连接已经恢复普通状态所以直接continue进入下一轮WATCH没有任何问题。重试次数我放在调用侧控制避免死循环打爆Redis。实际生产环境里我还会把WATCH和GET这两个round trip合并成一次pipeline用redisAppendCommand加redisGetReply来减少网络往返。这里为了讲解清晰先保留最简单的写法。高并发场景下一次多余RTT可能就是几毫秒的差别对延迟敏感的C/C服务来说值得扣这个细节。2.4 WATCH在集群环境下的边界WATCH的乐观锁机制是单实例逻辑在Redis Cluster环境下有个硬限制事务涉及的所有key必须落在同一个slot上否则直接报CROSSSLOT错误。很多公司把Redis迁到集群之后原来单机上跑得好的WATCH事务突然开始报错基本都是这个原因。解决思路一般有两个。第一个是使用hash tag让关联key的名字包含相同的大括号部分。比如库存扣减涉及stock:sku001和order:sku001这两个key可以改写成stock:{sku001}和order:{sku001}。Redis做slot计算时只对大括号内的内容做哈希这两个key就能稳定落在同一个slot上事务就能正常执行。第二个思路是干脆不做跨key的WATCH把需要串行化的操作通过业务层的分布式锁或者消息队列排队执行。如果只是单个key的WATCHCluster下是没问题的。真正麻烦的是那些按订单号、用户ID拆分的多key事务得从一开始就把key分布设计好别等线上跑挂了再回改。3. 用ACID的尺子量Redis事务3.1 原子性不是传统意义的要么全做要么全不做这是新手最容易误解的地方。Redis事务在并发执行层面确实能保证命令序列不被打断但一旦队列中某条命令在运行时抛错Redis不会回滚之前已经执行的命令。还是用最典型的例子说明MULTI SET stock:sku001 100 LPUSH stock:sku001 abc EXEC如果SET执行成功、LPUSH执行报错EXEC数组里第一个元素是OK第二个是错误信息SET的写入已经生效。从数据库原子性的定义看这显然不满足全做或全不做。Redis官方文档对原子性的定义加了前提单线程模型中事务不会被其他命令插入。严格说它提供的是一种串行原子性而不是回滚原子性。对做C/C后端的开发者来说实践意义很直接能放进一个事务里的写命令要按业务重要性慎重排列宁可把可能出错的命令放在事务的最后也别拿事务去模拟强数据库语义。真要强回滚语义就得上Lua脚本配合手动补偿后面第4节会细说。3.2 隔离性单线程带来的意外优势Redis是单线程事件循环事务EXEC执行期间所有命令都在这个线程里处理不会有其他客户端的命令穿插进来。因此事务内的多条命令对别的客户端来说要么看到执行前的旧状态要么看到执行后的新状态绝对不可能看到中间某一步的状态。这个特性带来的隔离性比很多关系数据库的读已提交还要干净因为它压根没有中间状态暴露窗口。代价是事务执行期间整个Redis实例对其他请求的响应都被延后。如果你在事务里放了大量命令或者某条命令是O(N)级别的重操作其他客户端的延迟就会肉眼可见地恶化。我自己见过有人在一个MULTI里塞了几千条SET执行期间周边服务的P99直接飙到几百毫秒这就是典型的滥用。所以建议在写事务时坚持一个朴素的判断标准事务里只放必须打包在一起的少量写命令别把大范围的KEYS、SMEMBERS这类重操作塞进去。如果确实要批量处理很多key用pipeline代替事务才是更合适的选择。3.3 持久性与一致性的现实情况持久性方面Redis事务本身不提供任何保证。事务执行结果能不能扛住宕机取决于RDB、AOF这些持久化配置和事务有没有关系。比如AOF配置成everysec事务成功写入后下一秒进程宕机最多丢最后一秒的数据。对缓存类场景这个损失可以接受但如果把关键订单数据也按这个标准存出事就是事故。一致性方面Redis没有关系数据库的外键约束和check约束无法在引擎层校验数据完整性。事务只能保证命令按照单线程串行执行业务逻辑上的数据一致性完全得靠调用方用WATCH、条件判断和重试逻辑去兜底。这个边界想清楚之后设计C/C后端的数据流时你会更清楚哪些操作放Redis、哪些放MySQL。3.4 和MySQL事务的对比清单把两个事务模型放一起对比会更加直观维度MySQL InnoDB事务Redis事务原子性出错自动回滚全做或全不做运行时错误不回滚单线程下无穿插隔离性可配置隔离级别天然串行无中间状态暴露持久性redo log保证已提交事务依赖RDB/AOF配置不强则丢数据一致性靠约束和回滚兜底靠WATCH和业务代码兜底适用场景强一致的核心数据存储缓存、计数、轻量级原子操作这张对比表不是想分个高下而是提醒大家用对工具。在C/C后端架构里MySQL通常负责强一致核心数据Redis负责高性能读写路径。如果硬拿Redis事务去替代MySQL事务做金额结算、做跨系统的对账那才是真正的用错地方。4. 工程实践事务的取舍与替代方案4.1 Lua脚本是更Redis范儿的原子操作如果觉得MULTI/EXEC加WATCH这套组合用起来别扭Redis还提供了另一条路Lua脚本。通过EVAL命令客户端可以把一段Lua代码直接发给Redis服务器端执行脚本里的所有Redis命令都在同一个上下文中串行运行整个脚本不会被其他客户端命令打断。和MULTI/EXEC相比Lua脚本最大的优势是可以在服务端做条件逻辑。事务内不能先拿数据再判断但Lua脚本内部可以完全自由地读到一个key之后再决定要不要写另一个key。很多原本需要在客户端反复WATCH、重试的CAS场景用Lua脚本直接一步到位把业务原子操作下沉到服务器端同时省掉了多次网络往返。要再说一个容易踩的点Lua脚本在运行时发生错误前面已经执行的写操作同样不会自动回滚。官方文档明确说脚本执行中途出错时已执行的写操作保持不变错误之后的命令不再执行。所以脚本里还是要自己做防御性判断别依赖引擎的错误机制兜底。C/C客户端用hiredis执行Lua脚本很简单const char *script local v redis.call(GET, KEYS[1]) if not v then return 0 end if tonumber(v) tonumber(ARGV[1]) then return 0 end redis.call(DECRBY, KEYS[1], ARGV[1]) return 1; redisReply *r redisCommand(ctx, EVAL %s 1 %s %s, script, stock:sku001, 10);这段脚本把检查库存、扣减库存、返回结果三步合成了一次网络往返不需要WATCH不需要重试循环。我在实际项目中做秒杀扣减就是这种写法代码比WATCH那套简洁得多性能也更稳。4.2 什么时候坚持用MULTI/EXEC什么时候用Lua我根据自己的工程经验整理了一个选型参考纯属个人偏好但大多数场景适用只是批量写入几个独立key不需要基于读结果做判断用MULTI/EXEC代码直观需要先读后写、包含多分支业务逻辑用Lua脚本需要把一组操作作为一个整体阻止并发穿插两条路都行但Lua更省事需要数据被并发修改时主动放弃并重试用WATCH加MULTI/EXECLua脚本内一般不主动做放弃重试还有一个容易被忽略的点Lua脚本和事务一样在Redis Cluster下也要求所有key在同一个slot。如果脚本里用了多个不同slot的key一样报CROSSSLOT错误。所以在设计业务key时把hash tag用起来是一个性价比非常高的习惯尤其是在团队规模变大、系统拆分多之后。4.3 连接池场景下的事务使用规范C/C后端工程基本都会用连接池管理hiredis连接这里有一条硬性要求必须落实同一个事务的所有命令必须发送到同一个连接上。任何中途切换连接的做法都会让MULTI之后的命令跑到另一个连接上执行那些命令根本不知道自己在不在事务状态里结果就是各种乱象。我见过一个线上事故本质就是连接池里的共享连接被并发请求复用。A业务发了MULTI之后B业务也在同一个连接上执行命令B的命令被误入队。等A执行EXEC时队列里混进了B的命令返回的数组结构完全对不上两边业务都出现诡异数据。排查到最后发现客户端日志里所有命令都在同一个连接上来回跑连接没有任何隔离标记。几个连接层面的规范值得在项目里硬性遵守事务期间从连接池借出的连接加上事务中状态位这个连接在这段时间内不允许归还再借出客户端连接断开后Redis服务端会自动清理这个连接上的事务状态但客户端自己持有的对象也要同步清理避免野指针不要在同一个hiredis连接上同时做订阅或者阻塞读取事务状态下混入其他操作返回结果的解析会非常痛苦这些都是典型的不知道没事、真遇到就是线上事故的坑。我在代码评审里见过太多次因为连接复用导致事务错乱的问题写出来算是给后来者避个雷。5. 常见问题与排查实录5.1 入队阶段报错EXEC直接返回EXECABORT现象MULTI之后某条命令返回的不是QUEUED而是错误信息调用EXEC时Redis直接返回EXECABORT所有入队命令一条都没执行。原因入队阶段发现命令语法错误或参数个数不对Redis把事务标记为脏事务出于安全考虑整体废弃整个事务。处理方式客户端在入队阶段收到错误反馈后立刻调用DISCARD清理连接状态把业务命令改写成正确形式后再重新组织事务。千万不要忽略入队阶段的错误继续闷头执行EXEC那样只会得到一个完全没用的EXECABORT。5.2 EXEC返回nil但数据看起来没被并发修改现象WATCH加MULTI加EXEC的重试逻辑里EXEC常常返回nil可查Redis里的value似乎没有其他客户端在写。原因WATCH监视的key只要在WATCH和EXEC之间发生过任何一次写操作事务就会被放弃哪怕这次写操作写入的value和原来的值一模一样。Redis底层记录的是key的修改标志而不是判断value是否发生了变化。也就是说值不变也算修改这是很多人第一次踩到的坑。处理方式如果业务语义上允许并发重复设置相同值建议改用Lua脚本自己在脚本内判断是否有必要写库。如果接受重试就实现一个带次数上限的重试循环。另外要提醒有些hiredis客户端在底层重连后WATCH会失效如果连接发生重连原来的WATCH记录已经被服务端清理干净需要重新WATCH否则客户端可能以为还有保护实际上已经裸奔了。5.3 事务里混入了阻塞命令整个Redis实例卡住现象某个事务里放了BLPOP或者BRPOPLPUSH列表中恰好又没有数据Redis单线程会一直卡在阻塞等待上所有其他客户端的命令全部排队P99指标瞬间爆表。原因Redis单线程模型决定了阻塞命令在EXEC期间不会让出CPU而事务机制本身没有超时保护。一旦进入阻塞整个实例对外不可用直到设置的时间超时或者数据到达。处理方式第一原则事务里永远不要放阻塞命令。如果业务确实需要列表为空就等待就把这个等待单独放到事务之外或者改用Redis Streams这类天然支持消费者阻塞语义的数据结构去设计别在事务里硬塞。5.4 集群环境下事务报CROSSSLOT错误现象Redis Cluster环境客户端执行MULTI命令设计多个keyEXEC时直接报CROSSSLOT。原因Cluster按slot分布数据一个事务或Lua脚本涉及的所有key必须落在同一个slot上否则集群无法保证原子性。Redis没有能力在一个命令里原子性地操作多个slot上的数据。处理方式设计key时给相关key加上一致的hash tag或者拆分业务让涉及多个key的操作落到同一个实例上处理再或者用支持集群的客户端库做辅助校验但归根结底还是从源头规划key分布最可靠。5.5 连接复用导致的事务状态错乱现象并发请求涌来时一个共享连接上A业务发了MULTI之后B业务也在这个连接上执行命令B的命令被误入队最后A的EXEC执行了B的命令返回的数据结构完全对不上。原因基本可以断定是连接池使用不规范共享连接缺少事务隔离。hiredis这类客户端本身不感知事务状态它只是忠实地按顺序发送命令流所以责任全在业务侧。处理方式给连接池增加一个事务中状态位事务期间把连接锁住任何其他逻辑都不能借用。如果一定要用共享连接不做独占那么事务的每条命令之间绝对不能穿插其他业务命令但在并发场景下这个约束很难保证还是从连接池层面对事务连接做独占最可靠。把常见问题整理成速查表方便现场排查现象可能原因处理建议EXEC返回EXECABORT入队阶段语法错误修复命令DISCARD后重试EXEC返回nilWATCH的key有写操作检查并发重试或改用Lua实例响应变慢或卡死事务中混入阻塞命令移出阻塞命令禁止事务内BLPOP集群下报CROSSSLOTkey分布在不同slot使用hash tag规划key分布事务结果错乱连接复用连接池独占事务连接做Redis事务排查时我一般会先看三个地方连接有没有被复用、事务里有没有阻塞命令、WATCH是不是真的在MULTI之前发出去的。这三个问题排掉大部分事务相关的怪现象都能定位到根因。写到这儿我的核心感受是Redis事务更像一个命令串行执行器加乐观锁标记而不是传统意义上的事务引擎。在C/C后端开发里用对它的关键是接受它的简单也清楚它的边界。最后分享一个使用hiredis时的小技巧如果事务里的命令比较多可以用redisAppendCommand加redisGetReply这套批量接口先连续发送MULTI和所有排队命令再一次次读取回复能明显降低RTT。但一定要记住hiredis的批量发送模式下读取回复的数量必须和发送命令的数量严格对得上否则会把下一条命令的回复错位读出来这个问题排查起来比事务本身还叫人心塞。Redis事务这部分就先告一段落下一篇日志如果继续写Redis我打算重点挖一挖Lua脚本在C/C工程里的更多用法那个确实是能帮业务省掉不少代码的利器。
返回列表