ARTICLE DETAIL

资讯详情

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

Redis事务从入门到精通:五大命令、底层队列与生产踩坑实录

Redis事务从入门到精通:五大命令、底层队列与生产踩坑实录 上个月帮人排查一个线上超卖问题秒杀接口用的是Redis事务压力一上来库存还是扣成了负数。代码逻辑很常见先查库存、判断大于0、再扣减外面套了MULTI/EXEC。我扫了一眼就问那条GET查库存的命令也在事务队列里吗对方当场愣了。这就是Redis事务最容易被误解的地方——它在EXEC到达的那一刻才真正执行队列里的命令而“读库存”发生在MULTI之前等于你拿着旧值在做判断。类似的情况我还见过不少有人把它当MySQL事务的回滚机制用有人在Spring的RedisTemplate里面没绑同一个连接导致WATCH失效还有人把“批量执行”和“原子性”混成一件事。这篇文章想把Redis事务一次讲透覆盖五个命令的用法、底层队列机制、和MySQL事务的本质差异、生产环境实操心法以及面试里怎么把它答出层次。适合正在学Redis的开发者、写过Redis但没细究过事务细节的人也适合准备面试的同学。1. 把“事务”二字掰开Redis事务能保证什么不能保证什么1.1 一句话定义命令的打包连续执行官方文档对Redis事务的说法是MULTI、EXEC、DISCARD、WATCH这几个命令让客户端可以在一个步骤里执行一组命令并且这些命令会按顺序执行不会被其他客户端的命令打断。注意这里的关键词是“打包连续执行”不是数据库教科书上那套“回滚、快照、隔离级别、持久化”。我更喜欢用一个比喻来理解它Redis事务就像一个快餐柜台你在一个窗口一次性点了三份餐后厨按顺序做中间不会去接别的单子。但这个过程中如果第二份餐做砸了后厨不会把第一份已经递出去的餐收回来重做只有你在点单时发现菜名本身不存在收银员才会整单不接。这个比喻基本把Redis事务的脾气说清楚了执行顺序有保证回滚不存在点单时的非法命令会整单拒绝。1.2 ACID四项逐条核对哪些是真的哪些只有一半我把传统数据库的ACID概念逐条套到Redis事务上很多误解立刻就现形了。传统概念Redis事务的实际表现原子性有限保证。命令队列会整体连续执行但运行期单条命令报错时不会回滚已执行命令全部保留一致性需要应用层保证。命令写错或类型不匹配时会出现部分更新的中间状态隔离性在单线程执行模型下天然成立。EXEC执行队列期间不会被其他客户端的命令插入持久性不保证。依赖RDB/AOF配置任何持久化策略下都有丢失事务结果的可能原子性那条是面试重灾区。严格意义上Redis事务不满足数据库事务的原子性标准——它承诺的是“执行过程不被打断”而不是“失败可回滚”。如果事务里三条命令第二条运行时抛了类型错误第一条照样生效。后面我会专门用命令演示这个场景。隔离性也要分清楚。Redis单线程执行命令EXEC处理事务队列时其他客户端的命令必须排队等待所以事务内的命令之间不会穿插外部命令。但这不是MySQL那种隔离级别并且从MULTI到EXEC之间其他客户端完全可以在你监控的key上做修改数据之间没有“未提交可见性”一说所有写入一旦执行就立刻对全局生效。1.3 最常见的四个错误认知第一个误区“事务里的命令要么全执行要么全不执行”。这只说对了一半。入队阶段如果出现语法错误确实会触发EXECABORT导致整体放弃但到了执行阶段某条命令运行时出错并不会让整个事务回滚或中止。第二个误区“写错了能回滚”。直接否决Redis没有ROLLBACK命令也没有对应的回滚机制。DISCARD只是清空尚未执行的队列和回滚完全是两码事。第三个误区“开了事务之后其他客户端读不到我修改的数据”。这一条错得最离谱。Redis数据修改是即时生效的不存在事务未提交状态。多客户端并发执行事务时读到的都是当前内存里的最新值不会因为你“在事务中”而看到旧快照。第四个误区“Redis事务能帮我解决分布式事务”。它是单实例或单slot层面的工具跨服务、跨数据库的分布式事务一致性它扛不住需要另找方案。2. 五个命令搭起完整骨架MULTI/EXEC/DISCARD/WATCH/UNWATCH2.1 最基础的流程MULTI EXEC先看最朴素的用法跟着Redis命令行敲一次基本就记住了。127.0.0.1:6379 MULTI OK 127.0.0.1:6379 SET article:1001:view 100 QUEUED 127.0.0.1:6379 INCR article:1001:view QUEUED 127.0.0.1:6379 EXEC 1) OK 2) (integer) 101在MULTI之后客户端进入事务状态后续发送的命令服务端不会立即执行而是返回QUEUED表示命令已经进入事务队列。直到EXEC发到服务端才按入队顺序逐个执行并把每个命令的结果放在一个数组里一起返回。这里有个容易被忽略的点队列中的命令在QUEUED阶段不会做真正的数据检查比如key的类型是否正确、内存是否够用这些都要到EXEC执行时才会暴露。入队时服务端只检查命令是否存在、参数个数是否合法。单条命令在事务里被“压着不执行”意味着你的客户端有两条网络往返MULTI发出后后续命令其实已经到服务端了只是排队而已。所以事务天然有一点pipeline的效果——多条命令的最终执行集中在一次EXEC减少了往返次数。2.2 中途反悔用DISCARD如果MULTI之后你改了主意不想执行队列里的命令了执行DISCARD127.0.0.1:6379 MULTI OK 127.0.0.1:6379 SET name tony QUEUED 127.0.0.1:6379 DISCARD OKDISCARD会清空当前事务队列退出事务状态。重申一次这不是回滚因为事务根本还没执行没有数据被修改过自然不存在“恢复原状”的必要。2.3 WATCH真正的并发保护能力MULTI/EXEC本身解决的是“一组命令连续执行不被插入”的问题但它解决不了“先读后写”这种依赖前置状态的并发问题。假设你在MULTI之前读了库存等于10做判断后想DECR扣减可在你MULTI到EXEC之间的几百毫秒里另一个客户端已经把库存扣到了5你手里的10就是旧数据。WATCH就是为这个场景准备的。WATCH的使用原则是必须在MULTI之前执行它可以监听一个或多个key。如果被监听的key在WATCH之后、EXEC之前被任何客户端修改了那么EXEC执行时会直接放弃整个事务队列返回nil。127.0.0.1:6379 WATCH stock:1001 OK 127.0.0.1:6379 GET stock:1001 10 127.0.0.1:6379 MULTI OK 127.0.0.1:6379 DECR stock:1001 QUEUED 127.0.0.1:6379 EXEC (nil)上面这个模拟场景里在WATCH和EXEC之间我假设另一个客户端把stock:1001改成了别的值所以EXEC直接返回了nil事务队列里的DECR没有执行。客户端收到nil之后正确的姿势是重新走一遍“读库存、判断、再扣减”的流程也就是重试。源码层面的实现是每个被WATCH的key会记录在watched_keys结构里当Redis执行任何可能修改该key的命令时会调用touchWatchedKey把对应的客户端打上CLIENT_DIRTY_CAS标记。EXEC时客户端检查这个标记只要被改过就直接拒绝执行事务。这也是为什么“WATCH必须和MULTI/EXEC在同一个连接上”的原因客户端标识是跟着连接走的。还有一个UNWATCH命令作用是手动解除所有WATCH监控。在事务执行完EXEC、或者DISCARD之后监控也会自动解除所以UNWATCH用的场景很少一般是在你WATCH之后、MULTI之前发现数据已经没必要保护了主动解除监控。3. 队列机制与错误处理事务执行阶段的三种异常分支3.1 两个时间点入队阶段和执行阶段搞清楚事务里的命令什么时候可能出错是避免线上事故的关键。Redis事务的命令处理有两个时间节点入队阶段MULTI之后命令逐条发到服务端服务端只做基础校验——命令名认不认识、参数个数对不对。校验通过就返回QUEUED不通过会返回错误信息。执行阶段EXEC到达服务端之后才真正逐条执行队列里的命令。此时才会遇到类型不匹配、key不存在的前提下执行某种操作、内存不足这类运行期问题。理解“错误发生的时间点不同处理语义完全不同”是掌握Redis事务错误处理的核心。下面三个分支建议对照命令行亲手敲一遍。3.2 分支一入队阶段语法错误触发EXECABORT整体放弃127.0.0.1:6379 MULTI OK 127.0.0.1:6379 SET (error) ERR wrong number of arguments for set command 127.0.0.1:6379 SET name tony QUEUED 127.0.0.1:6379 EXEC (error) EXECABORT Transaction discarded because of previous errors.从Redis 2.6.5开始只要入队阶段出现任何错误EXEC就会拒绝执行返回EXECABORT。这是Redis事务里最接近“全有或全无”语义的场景因为队列里的命令一条都不会执行。注意一个细节这里不是说“有语法错误的命令不执行其他的照常执行”而是整个事务被放弃。所以当你的业务在入队阶段能提前发现命令不合法时相当于获得了一道前置保护。利用好这一点尽量把外部输入参数的校验放在进入事务之前可以把很多错误掐死在入队阶段。3.3 分支二运行期类型错误造成部分成功这是最坑的一个分支也是面试必考点。127.0.0.1:6379 MULTI OK 127.0.0.1:6379 SET name tony QUEUED 127.0.0.1:6379 LPUSH name john QUEUED 127.0.0.1:6379 EXEC 1) OK 2) (error) WRONGTYPE Operation against a key holding the wrong kind of value事务里第一条SET执行成功第二条LPUSH对string类型的name执行直接报WRONGTYPE。可关键在于事务没有因此终止也没有回滚SET的结果被完整保留。这就是Redis事务“部分成功”的经典例子。很多人在代码里只判断“调用事务的方法有没有抛异常”根本不检查EXEC返回的结果数组里的每一项这是极其危险的。上面这个场景下业务可能以为第二个操作也失败了但实际上第一个操作已经生效数据处于“只改了一半”的状态。3.4 分支三WATCH冲突EXEC返回nil前面已经演示过WATCH监听的key在EXEC前被改动时EXEC返回nil。分支三和分支二的区别在于返回nil时事务队列里的命令一条都没执行数据没有发生任何变化你需要做的就是重试整个读-判断-写流程。三种分支对比如下异常类型发生阶段事务状态客户端处理方式命令语法错误入队阶段整体放弃EXEC返回EXECABORT修正命令后重试类型不匹配等运行期错误执行阶段已执行命令保留错误命令报错逐条检查EXEC返回结果做业务补偿WATCH key被修改EXEC判断时整体放弃EXEC返回nil重试整个事务流程3.5 客户端代码里怎么处理这三种结果以Spring Data Redis为例正确姿势是用SessionCallback保证所有操作在同一个连接上。ListObject results redisTemplate.execute(new SessionCallbackListObject() { Override public ListObject execute(RedisOperations operations) { operations.watch(stock:1001); Object stock operations.opsForValue().get(stock:1001); if (stock null || Integer.parseInt(stock.toString()) 0) { operations.unwatch(); return null; } operations.multi(); operations.opsForValue().decrement(stock:1001); return operations.exec(); } });拿到results之后如果是null说明是WATCH冲突走重试如果非null一定要遍历results里的每一个元素逐个检查是否有异常信息。很多业务代码只看results是不是null忽略了内部项的异常判断结果在“部分成功”时傻傻地当作全部成功处理下游数据就错乱了。4. 别拿MySQL事务硬套隔离级别、回滚、持久化的真实差异4.1 同名事务不同基因一张表看懂差别“Redis事务”和“MySQL事务”虽然都叫事务底子完全不同。MySQL事务的核心是回滚日志、锁、MVCC、隔离级别而Redis事务的核心是单线程命令排队和乐观锁标记。对比维度MySQL事务Redis事务回滚机制有基于undo log无命令报错不回滚隔离级别读未提交、读已提交、可重复读、串行化无隔离级别概念仅靠单线程保证执行期间不插入命令并发控制锁 MVCCWATCH乐观锁持久性redo log 刷盘策略依赖RDB/AOF默认配置下易丢失典型场景数据强一致性、账务、库存缓存更新、批量操作、防并发覆盖很多程序员习惯拿MySQL的思路套Redis最典型的就是问“Redis事务的隔离级别是什么”。直接回答没有隔离级别。因为Redis数据没有“未提交”状态每个命令执行后就立即对全局可见不存在脏读、不可重复读、幻读这些概念的空间。4.2 Redis事务的“隔离性”到底怎么理解Redis单线程执行模型意味着在同一时刻只有一条命令在执行。当一个EXEC触发事务队列执行时队列里的命令会连续执行完这期间其他客户端的命令全部排队等待。所以你可以说事务命令之间是严格串行的这就是它的隔离性来源。但请把这个隔离性的范围理解清楚它只覆盖“EXEC执行命令队列那一刻”到“队列执行完为止”。从MULTI到EXEC之间的时间窗口完全不设防其他客户端可以任意修改数据。Redis 6.0之后引入了多线程网络IO但命令执行依然是单线程所以事务的上述行为没有改变。并发场景下两个客户端都执行事务Redis的服务端会依次处理两个EXEC每个EXEC的命令队列内部不会被插入。但两个事务各自“读库存、判断、扣减”的读和写之间依然存在覆盖风险这正是WATCH存在的意义。4.3 Redis放弃回滚是设计取舍而非缺陷官方文档里有一句话很值得琢磨事务失败通常是由编程错误导致的比如错误的数据类型、key不存在时操作list这些错误在开发阶段就应当暴露。让数据库为程序员的错误做回滚对Redis这样追求极致简单和高性能的系统来说得不偿失。这个取舍的逻辑是回滚需要维护undo信息会带来额外的内存和复杂度Redis的设计哲学是“命令执行本身极快错误尽量前置”。如果你需要真正意义上的“要么全做要么全不做”可以考虑使用Lua脚本它能在脚本内部做更严格的逻辑控制但已经把操作写进内存的数据同样不会自动回滚。所以无论事务还是脚本业务侧都要对部分失败有所准备。5. 实战落地扣库存、批量写入、缓存更新的正确姿势5.1 秒杀库存WATCH 事务实现乐观锁最典型的适用场景是秒杀扣库存核心需求是“不能超卖”也就是多个并发请求同时扣减时最终库存不能变成负数。用WATCH MULTI/EXEC写出来大概是这个样子int retryCount 3; while (retryCount-- 0) { ListObject results redisTemplate.execute((RedisOperations ops) - { ops.watch(stock:1001); String stockStr (String) ops.opsForValue().get(stock:1001); if (stockStr null || Integer.parseInt(stockStr) 0) { ops.unwatch(); return null; } ops.multi(); ops.opsForValue().decrement(stock:1001); return ops.exec(); }); if (results ! null) { // 扣减成功继续后续业务 break; } // results为null表示WATCH检测到冲突循环重试 }这段代码的关键在于WATCH和MULTI/EXEC必须都在同一个连接上而且只能通过redisTemplate.execute(SessionCallback)这种方式包裹不能拆成两个方法各取连接。WATCH冲突返回null时业务需要立即重试否则请求直接失败。我习惯设置2到3次重试上限超过后走降级比如“当前库存紧张”的提示避免无限重试拖垮服务端。还要注意parseInt计算库存和业务判断都在客户端完成这样一来“读-判断-写”三个动作的网络开销还在只是多了一层并发保护。如果同一接口的QPS非常高这种方式还是不够快这时候就切换到Lua脚本把判断逻辑挪到服务端。5.2 批量写入用事务换取瞬时一致性另一个常见场景是多个key需要同时更新比如用户资料里同时改name、age、tags三个字段。如果不做任何保护另一个客户端可能读到“name更新了但age还没更新”的中间状态。用事务包一层EXEC执行时三个HSET会连续发生中间不会被外部命令插入其他客户端读到的要么是更新前的旧值要么是更新后的新值少了一个一个key慢慢变的中间态。127.0.0.1:6379 MULTI QUEUED 127.0.0.1:6379 HSET user:1001 name tony QUEUED 127.0.0.1:6379 HSET user:1001 age 28 QUEUED 127.0.0.1:6379 HSET user:1001 tags vip QUEUED 127.0.0.1:6379 EXEC 1) (integer) 1 2) (integer) 1 3) (integer) 1这个场景的收益是“多个key的更新在瞬间内完成”不是持久化的强一致但对缓存场景足够用了。配合pipeline思想事务还能减少网络往返批量写入性能也不错。5.3 缓存与数据库的一致性别指望Redis事务兜底很多同学会问既然Redis事务能保证多个key的原子更新那我用它来保证“删除缓存”和“更新缓存标记”的一致性是不是就能解决缓存和数据库的数据同步问题答案是不够。缓存与数据库是两个独立的存储系统一个Redis事务只能约束Redis内部的命令执行顺序约束不了MySQL的提交或回滚。数据库的事务提交之后Redis这边可能刚好在事务执行中双方没有统一的提交点这是典型的分布式事务问题不是Redis本地事务能覆盖的。正确的思路还是走延迟双删、消息队列、或分布式事务方案Redis事务在这里最多只能充当其中一环。5.4 什么时候该换Lua脚本事务虽然好用但“读判断写”这种逻辑在事务里要经历多轮网络往返而且WATCH重试的成本也不低。如果同一个操作里既有复杂的逻辑判断又希望减少网络开销Lua脚本是更好的选择。脚本在服务端整体执行执行期间其他客户端命令也不会插入天然有原子性。扣库存用Lua脚本写出来是这样local stock tonumber(redis.call(GET, KEYS[1])) if not stock or stock 0 then return -1 end return redis.call(DECR, KEYS[1])用Java调用时不需要WATCH也不需要重试循环脚本的返回值-1表示库存不足返回正数就是扣减后的库存。对比可见逻辑简单的并发保护用事务足够逻辑复杂或对性能敏感的场景优先Lua。无依赖的批量命令直接pipeline就行没必要上事务。6. 我在生产环境踩过的坑与排查链路6.1 WATCH失效连接上下文不一致我之前排查过一个偶发超卖问题代码里明明用了WATCH压测却还是出现库存扣成负数。第一次看代码WATCH、MULTI、EXEC都在方法里觉得没问题。深挖后发现项目里有人手动从连接工厂拿了两个连接一个执行WATCH另一个执行MULTI/EXEC。WATCH的监控绑定在客户端连接上换了连接就完全失效等于裸奔。排查这种问题的思路是第一确认WATCH和MULTI/EXEC是否在同一个RedisConnection上第二如果用了Spring Data Redis尽量全部塞进SessionCallback让框架替你绑定连接第三别在事务中间穿插任何会切换连接的操作比如重新获取连接、订阅消息等。连接一旦换所有事务语义全部归零。6.2 忽略EXEC结果列表里的异常项另一个事故是交易对账平不了。代码里用Redis事务批量更新了两个key然后只判断了exec结果是否为null。压测时遇到底层类型变化其中一个key的写入命令报WRONGTYPEexec返回的结果列表里第二条是异常对象但外层方法没有抛异常业务照样往下走。数据错位之后又因为没有回滚机制只能靠人工补偿脚本去修。从那以后我给自己定了一条规矩事务代码里拿到exec返回的结果列表一定要遍历凡是列表项为异常类型的都要单独记录并触发补偿。判断事务成功不是“方法没抛异常”而是“返回列表里每一项都正常”。这条在代码评审里也是必检项。6.3 集群模式下永远绕不开的CROSSSLOTRedis Cluster下使用事务有个硬性约束事务队列中所有命令涉及的key必须映射到同一个slot否则执行时报CROSSSLOT错误。我在一个多key事务上踩过这个坑当时业务key写得比较随意比如user:1001:name和user:1001:age从CRC16算法看它们可能落在不同slot上。解决办法是用hash tag让多个key强制归到同一个slot也就是把key写成user:{1001}:name和user:{1001}:age这种形式花括号内的内容参与hash计算两个key就落同一slot了。这个设计在单机Redis下也不会有副作用所以从第一天设计key的时候就把hash tag写进去是最省事的习惯。真要遇到跨slot又无法改造的场景基本说明这个功能不该用Redis实现该考虑其他存储了。6.4 事务“成功”了数据却丢了还有一次业务反馈说Redis事务执行成功但版本升级重启后部分事务结果没了。排查链路走到最后发现Redis实例既没开AOF也没开RDB所有写入只存在于内存。事务成功代表命令已经在内存里执行完但如果进程在持久化动作之前崩溃内存数据直接归零事务结果自然跟着没了。就算是开启了AOF默认的everysec刷盘策略下崩溃时最多丢最后一秒的写入即使改成always事务命令真正落到磁盘之前崩溃仍然存在极小概率丢失。所以结论要清醒Redis事务从来不给持久性承诺如果你把不能丢的数据放在Redis里那就是架构设计的问题了。核心账务、订单状态这类强持久数据必须放到数据库里。7. 面试中被追问Redis事务时先讲清楚边界再回答7.1 面试官的高频问题清单面试中围绕Redis事务的问题来来去去就是这几个Redis事务和MySQL事务的区别是什么Redis事务能保证原子性吗WATCH是怎么实现的为什么Redis不支持回滚Redis事务和Lua脚本应该怎么选Redis事务在集群里有什么限制前两个是必问题后面几个是追问。想要不被问倒核心是把“Redis事务的边界”讲清楚而不是背一句“事务保证原子性”。7.2 把“能保证原子性吗”答出一个层次感如果面试官问“Redis事务能保证原子性吗”直接答“能”或者“不能”都很被动因为这个问题本身考察的是你对工具边界的认知。我建议按下面这条线答先定义原子性。传统数据库的原子性意味着“要么全部生效要么全部不生效应失败时能回滚”。从这个标准看Redis事务不满足。它只保证两点一是EXEC执行事务队列时队列中的命令连续执行、不被其他客户端命令插入二是入队阶段的语法错误会触发EXECABORT整体放弃执行。但运行期错误不会触发回滚已执行命令保留会出现部分成功。所以严格说Redis事务提供的是“执行过程的原子性”而不是“数据结果的原子性”。然后补充WATCH的作用。如果业务需要“读判断写”这种依赖旧值的场景WATCH能在EXEC前检测key是否被修改一旦被修改就放弃执行返回nil给客户端让客户端重试。这就是它解决并发覆盖的方式。做到这一步你已经把原理、边界、并发控制全部覆盖了面试官基本没有再追的余地。7.3 面试官其实在借事务考核三个能力把这些问题剥开来看面试官想考察的其实是三件事第一你懂不懂Redis单线程执行模型和它与MySQL的本质区别第二你写业务代码时会不会检查异常结果懂不懂“部分成功”的补偿处理第三你能不能根据场景选型知道什么情况用事务、什么情况换Lua脚本、什么情况压根不该用Redis。所以面试的时候不要只背概念最好举一个自己做过的真实例子比如秒杀扣库存时用WATCH和事务解决超卖或者上线前发现集群CROSSSLOT问题改key设计。有场景、有踩坑、有修复方案的回答比任何标准答案都更有说服力。最后说一个我自己的习惯现在接到任何涉及Redis并发一致性的需求我第一反应不是上MULTI/EXEC而是先问自己这段“读-判断-写”逻辑能不能压缩成一条原子命令或者改成一段Lua脚本。压不了、又确实需要防并发覆盖时才用事务加WATCH。这个顺序倒过来我踩过不止一次坑。你要是正在排查线上类似问题优先检查连接上下文、exec返回结果、集群slot、持久化配置这四个方向大概率答案就在其中之一。
返回列表