ARTICLE DETAIL

资讯详情

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

Redis事务避坑指南:无回滚、乐观锁与生产实践

Redis事务避坑指南:无回滚、乐观锁与生产实践 Redis系列写到现在数据结构、持久化、主从复制这些主题都覆盖得差不多了这第13篇我打算聊聊Redis事务——一个面试高频、实战容易翻车的功能。每次讲事务我都习惯先问一句你觉得Redis事务能保证什么得到的回答通常是原子性。但再追问一句扣库存的时候事务里第一条命令执行成功、第二条命令语法报错库存到底被改了没有很多人就卡壳了。Redis事务没有回滚、没有传统意义上的锁它更像一个装箱执行器加上一把乐观锁。这篇文章我会从设计定位讲起把MULTI、EXEC、DISCARD、WATCH四个命令每一条的使用边界都拆开用一个库存超卖的例子走完整条链路最后分享生产环境里几个真实的坑。适合刚入门想建立正确认知的同学也适合那些命令都会背但总感觉哪里不对的实践者。1. 设计定位先搞清楚它是一个执行打包器而不是数据库事务1.1 单线程Redis为什么还需要事务很多同学有一个朴素的理解Redis是单线程执行命令的命令永远不会并发那还需要事务干什么这个理解对了一半。Redis确实是单线程依次处理客户端命令单条命令天然是原子的——SET是一个原子操作INCR是一个原子操作甚至一个带复杂参数的LPUSH也是原子操作。但你的业务操作往往不是一条命令能搞定的而是读、判断、写这种多步组合。举个例子。用户要下单你得先查库存够不够够就扣减不够就返回失败。在客户端代码里这就是三步stock int(r.get(stock)) # 第一步读 if stock 0: # 第二步判断 return sold out r.decr(stock) # 第三步写问题在于如果两个用户同时发起下单两个客户端可能都读到了stock 10然后都做了扣减最终库存变成9但确实卖出了2单——超卖。单条命令的原子性在这里救不了你因为判断发生在客户端而写入在服务端这中间隔着一整个网络往返的时间窗口。Redis事务存在的第一个意义就是把这个读改写的暴露出竞态的窗口给收起来——虽然它不能像MySQL那样直接帮你把判断逻辑放在服务端但它提供了一种打包执行的能力配合WATCH还能检测出我读完之后、执行之前数据被别人改掉了。1.2 和MySQL事务的本质差异没有回滚没有隔离级别这里必须先建立一个认知Redis事务不是MySQL事务的降级版而是完全不同的一种机制。你可以把MySQL事务理解成银行转账系统——有完整账本、有撤销凭证、有复杂的事务日志而Redis事务更像是工厂流水线上的一张工序卡——工人按顺序执行卡上列的工序某一道工序出问题顶多那一道工序报错前面已经加工完的零件不会退回重熔。对比项MySQL事务Redis事务核心目的并发控制 故障恢复 ACID保证批量命令一次性连续执行 乐观锁检测回滚机制支持依赖undo log回滚已执行操作不支持执行期错误不回滚隔离性锁 MVCC四种隔离级别没有锁靠WATCH检测冲突持久性redo log保证事务持久性依赖RDB/AOF事务不提供额外保证适用场景强一致性业务数据操作缓存更新、计数、批量写入等原子打包这个表格里最扎眼的就是不支持回滚。原因我后面细讲但你要先接受一个结论Redis事务的原子性是有前提的——它保证队列里的命令能连续执行完但不保证执行到一半出错就把前面的效果抹掉。这句话是理解整个Redis事务的钥匙。1.3 Redis事务真正承诺的三件事把复杂的东西剥开Redis事务只承诺三件事事务队列中的命令会一次性、按顺序地执行在事务执行期间不会插入其他客户端发来的命令如果使用了WATCH且被监视的key在事务执行前被其他客户端修改过事务会被拒绝执行返回nil。第一点是打包执行解决批量操作的问题第二点依赖Redis单线程模型执行EXEC时整个队列的命令会一口气跑完中间不会切换去处理别人的命令第三点是Redis事务的核心价值——它不阻止别人改你的key但会明确告诉你你监视的数据脏了我不执行了你重试吧。这个承诺清单里没有回滚、没有隔离级别、没有持久保证。搞清楚这三件承诺的事剩下的所有命令和细节都只是围绕它们展开的。2. 四个命令怎么配合MULTI、EXEC、DISCARD、WATCH的完整语义2.1 MULTI到EXEC之间发生了什么传统的客户端视角来看一个完整事务是这么玩的127.0.0.1:6379 MULTI OK 127.0.0.1:6379 SET user:1:name zhangsan QUEUED 127.0.0.1:6379 INCR login_count QUEUED 127.0.0.1:6379 LPUSH today_log login QUEUED 127.0.0.1:6379 EXEC 1) OK 2) (integer) 1 3) (integer) 1MULTI之后Redis进入事务状态之后收到的命令不会立即执行而是返回QUEUED放入一个先进先出的队列。直到EXECRedis才会把队列里的命令一条条按顺序执行然后把每条命令的结果按顺序打包返回给客户端。注意一个细节队列里的命令是不会提前看值的。你可以在事务里写GET但EXEC之前你拿不到GET的返回值因为命令还没真正执行。这意味着事务里的命令之间无法用前一条的结果做条件判断——这个局限决定了Redis事务干不了复杂业务逻辑只能做无脑打包执行更复杂的逻辑得交给Lua脚本。2.2 DISCARD放弃事务的正确姿势如果MULTI之后发现命令写错了或者业务条件变了不想执行了可以用DISCARD清空事务队列让连接恢复到正常状态127.0.0.1:6379 MULTI OK 127.0.0.1:6379 SET user:1:name lisi QUEUED 127.0.0.1:6379 DISCARD OK 127.0.0.1:6379 GET user:1:name zhangsanDISCARD之后队列里的命令全部丢弃刚才入队的SET没有执行name还是旧值。这里顺便说一个实战经验很多语言的客户端库在异常处理时容易忘记把事务状态清掉导致连接池拿出来的连接带着一个半开的MULTI队列——这是个很隐蔽的坑后面第五章专门讲。2.3 WATCH和UNWATCH位置反了就是报错WATCH的正确姿势是必须在MULTI之前执行。典型流程是WATCH key1 key2 ... -- 先监视 GET key1 -- 读值、做判断 MULTI -- 开启事务 命令入队... EXEC -- 执行如果期间key被改过返回nil如果在MULTI之后执行WATCHRedis会直接报错127.0.0.1:6379 MULTI OK 127.0.0.1:6379 WATCH stock (error) ERR WATCH inside MULTI is not allowed之所以必须放前面是因为WATCH的语义是从这一刻开始监视这些key的变化——你必须在读数据之前就布好监控否则中间被别人改了你也察觉不到。UNWATCH用于主动解除监视比如你WATCH之后发现业务条件不满足、决定不执行事务了就调UNWATCH把监视清掉避免影响后续其他事务。EXEC执行完毕无论成功还是被拒绝后WATCH会自动失效不用你手动清理连接断开也会自动清除。如果把四个命令的关系用一句话概括WATCH是你看我要开始读了谁动了我监视的东西告诉我一声MULTI是把我接下来的命令装进箱子EXEC是开箱执行DISCARD是整个箱子扔了不要。3. 原子性的真实边界什么情况全执行什么情况部分执行3.1 入队期错误 vs 执行期错误两种完全不同的命运这是Redis事务最容易被误解的地方也是面试最爱考的点。事务执行过程中可能遇到两类错误它们的处理方式截然不同。入队期错误命令在MULTI之后入队时就被发现有问题比如命令不存在、参数数量不对、命令名拼错了。Redis会立即返回错误并且标记这个事务队列为已出错。到EXEC时Redis会拒绝执行整个事务返回EXECABORT127.0.0.1:6379 MULTI OK 127.0.0.1:6379 SET foo bar QUEUED 127.0.0.1:6379 BANANAS foo (error) ERR unknown command BANANAS 127.0.0.1:6379 EXEC (error) EXECABORT Transaction discarded because of previous errors.注意SET foo bar虽然已经QUEUED成功但因为入队期有错误整个事务被拒了foo不会被设置。这种错误实际上是命令写在代码里就错了属于编译期错误Redis选择连坐。执行期错误命令入队时一切正常但执行的时候才暴露问题最典型的是类型不匹配——对string类型的key执行LPUSH127.0.0.1:6379 SET foo bar OK 127.0.0.1:6379 MULTI OK 127.0.0.1:6379 SET foo new value QUEUED 127.0.0.1:6379 LPUSH foo 1 2 QUEUED 127.0.0.1:6379 EXEC 1) OK 2) (error) WRONGTYPE Operation against a key holding the wrong kind of value这里SET foo new value执行成功了LPUSH执行报错但Redis不会回滚刚才的SET。最终foo的值是new value而不是回滚到bar。如果你在业务里依赖事务要么全成功要么全失败的语义这种部分成功几乎是灾难级的。所以Redis官方文档里也反复提醒执行期错误不会中止事务也不会回滚前面的命令。判断一个事务会不会出执行期错误唯一的办法是把所有命令在开发环境完整测一遍——类型的正确性必须自己保证。3.2 为什么Redis坚持不回滚一个设计哲学问题很多人第一次知道Redis不回滚时第一反应是这算什么事务。但如果你站在作者Antirez的角度想就通了Redis的设计哲学是fail-fast和保持简单。回滚能力不是免费的。要实现回滚要么在执行前记录每个key的旧值undo log要么在执行前做完整校验。前者会让事务的内存开销翻倍后者会让Redis在EXEC前做大量额外工作——这跟Redis快的立身之本直接冲突。更关键的是Redis的定位是内存数据库、缓存层它处理的业务往往是计数、缓存更新、队列操作这些场景对执行期错误几乎免疫——真正会发生执行期错误的情况绝大多数是代码bug对string调list方法而代码bug应该在开发和测试阶段就被发现而不是靠事务回滚在线上兜底。关系型数据库需要回滚是因为它承载着复杂的业务约束、外键、触发器运行时业务错误比如违反唯一约束是常态Redis没有这些约束概念回滚需求天然就弱。所以这条规则你只能接受它并靠工程手段规避它在客户端封装一层验证事务里只执行已经验证过的、类型严格匹配的命令。把回滚这件事从Redis的期望列表里彻底划掉。3.3 持久性事务不额外背这个锅还有一个容易混淆的点是持久性。Redis事务本身不提供持久性保证——EXEC只是把命令连续执行了执行后的数据怎么持久化完全取决于你配置的RDB快照或AOF。如果一个事务执行完了但服务器的AOF还没来得及刷盘就断电数据照样丢。另外要特别提醒在高版本的Redis中AOF对事务有特殊的记录方式——会把一个事务的多条命令用MULTI....EXEC包裹起来写入AOF文件这是为了保证从AOF恢复时也保持一次连续执行的语义。这个细节你不需要深入理解但要知道Redis事务 AOF在持久化层面比RDB快照更严谨一点如果是关键数据至少把AOF开启到everysec或always级别别指望事务本身给你持久性。4. 实战拆解用WATCH实现库存扣减的乐观锁4.1 一个典型的超卖场景读改写三步的竞态回到开头的库存问题。没有事务时两个并发请求的时序可能是客户端A: GET stock - 10 客户端B: GET stock - 10 客户端A: DECR stock - 9 客户端B: DECR stock - 9最终库存9但实际上有两个用户都认为自己下单成功了——超卖。这就是经典的读改写竞态在没有锁的情况下谁都能读到旧的库存值。4.2 WATCH的CAS机制检测而不是阻止用WATCH 事务改造后时序变成客户端A: WATCH stock 客户端A: GET stock - 10 客户端B: WATCH stock 客户端B: GET stock - 10 客户端A: MULTI 客户端A: DECR stock 客户端A: EXEC - 成功stock变成9 客户端B: MULTI 客户端B: DECR stock 客户端B: EXEC - (nil) 因为stock被A改过了事务被拒绝这就是Redis版CASCompare And Swap。“Compare”的步骤交给了WATCHRedis内部维护了一个watched_keys结构记录每个被监视key和对应的客户端列表。当某个key被写入、删除、甚至因为过期机制消失时Redis会把这个key的版本标记拨动一下同时给所有监视它的客户端打上一个dirty标记。到了EXEC时Redis检查当前客户端是否带dirty标记带着就直接返回nil队列里的命令一律不执行没带就正常执行。一个要注意的细节是即使是你自己修改了WATCH的key也会算作被修改。所以WATCH之后、EXEC之前的这段空隙除了读操作不要碰被监视的key否则你的事务会被自己的写操作干掉。4.3 完整代码示例与重试策略用Python的redis-py写一个标准实现import redis r redis.Redis(host127.0.0.1, port6379, decode_responsesTrue) def try_buy(key: str, uid: str) - bool: while True: try: r.watch(key) # 1. 监视库存key stock int(r.get(key)) # 2. 读库存 if stock 0: r.unwatch() return False # 库存不足直接失败 pipe r.pipeline(transactionTrue) pipe.multi() # 3. 开启事务 pipe.decr(key) # 4. 扣库存 pipe.sadd(buyer, uid) # 5. 记录买家 pipe.execute() # 6. 执行若key被改则抛WatchError return True except redis.WatchError: # 7. 数据被并发修改重新循环重新读库存、重新WATCH continue关键在except redis.WatchErrorexecute的时候客户端发现WATCH的key被改过事务被拒库会抛出WatchError你的代码必须捕获它并重试整个流程。重试不是必要的吗需要合入业务判断如果重试次数太多说明热点很高考虑加随机退避比如time.sleep(random.uniform(0, 0.05))或换分布式锁——但分布式锁的粒度更粗、性能损耗更大一般只有冲突极频繁时才值得。另外强调一点这里库存是否充足的判断是在客户端做的WATCH负责保证这个判断在执行时依然有效。如果判断和执行之间隔着很久冲突概率就会高所以事务内的命令要尽量少、执行时间要尽量短把WATCH到EXEC之间的窗口压缩到最小。5. 生产环境中的坑从事务卡住到WATCH误伤5.1 MULTI之后忘记EXEC连接被无限占用这是我见过最多的一类问题。开发者在代码里调用了multi()网络异常或业务抛错没走discard()连接直接还回连接池。下次有人拿到这个连接可能还在MULTI状态——你发一条正常的SET它返回QUEUED而不是OK程序立刻懵了。排查这类问题的特征很明显一个库操作突然全部变成QUEUED、命令不生效、连接池可用连接数骤降。根治办法是两板斧一是在所有使用事务的代码路径里用try/finally保证异常时执行discard二是给客户端配socket超时和连接验证比如redis-py里每次获取连接时用health_check_interval或validate_connection检查状态。再狠一点的做法是事务代码统一封装成装饰器/工具函数从入口杜绝裸写MULTI/EXEC。5.2 WATCH范围太大无关字段变更导致频繁空转WATCH是以key为粒度的冲突检测。有些同学图省事把整个用户维度的key都监视了WATCH user:10086:balance user:10086:points user:10086:level结果用户在下单的同时恰好另外一个定时任务在给这个用户加积分把points改了——你事务里的扣余额本来跟积分毫无关系却被WATCH误伤EXEC返回nil客户端进入重试循环。经验是WATCH的key越少越好只监视那些事务里即将修改、且必须保证读到的值和执行时一致的关键key。复杂场景宁可多写两个事务也不要让监视面扩大。每一个被监视的key都是一次额外的并发冲突概率。5.3 事务和Pipeline的混淆别把管道当成事务很多客户端库都提供了一个pipeline功能可以一次发送多条命令减少网络RTT。麻烦的是不同语言的库对pipeline有两种完全不同的实现有的把命令打包发送但依然逐条独立执行非事务管道有的会自动在管道里包上MULTI/EXEC事务型管道redis-py里就是transactionTrue参数。如果你把非事务管道当事务用等于根本没获得原子性和WATCH保护只是省了网络时间。一批命令中间插入了其他客户端的写操作你的批量读改写照样会踩竞态。判断标准很简单看库的API文档里那个管道对象是否默认包MULTI/EXECredis-py明确要求transactionTrue才走事务默认值是True但其他语言的库不一定是这个默认值务必确认。5.4 事务里别塞不允许的命令MULTI队列里不是所有命令都能入队。比如SUBSCRIBE、PSUBSCRIBE这类订阅命令以及UNSUBSCRIBE系列都不能出现在事务中——订阅本身就要求持续不断的连接状态跟排队执行冲突。另外前面说的WATCH在MULTI之后也会报错。如果你用Redis做消息队列想把发消息放进事务里一起提交注意用的是LPUSH/RPOP这些list命令不是PUBLISH到频道——PUBLISH本身是允许在事务里执行的但它的语义是实时推送订阅者立刻就能收到不会等EXEC这个顺序问题在业务上要想清楚。5.5 从高热度搜索词看真实需求事务解决不了分布式事务最近看到很多搜索词集中在订单与库存分布式事务redis分布式锁分布式事务一致性这几个点上说明大家真正焦虑的是跨系统的一致性问题。这里必须泼一盆冷水Redis事务的作用范围仅限于单个Redis实例上的单个key维度操作它不能跨Redis集群节点不能跟MySQL、MQ放在一起做回滚更不是分布式事务的替代品。网上那些用Redis事务做分布式事务的文章绝大多数是在分布式锁、本地消息表、TCC这些方案里借用了Redis做辅助而不是让Redis事务去承担全局一致性。如果你遇到的是订单表在MySQL、库存缓存/预扣在Redis、发送消息在MQ三者要保证最终一致这种场景Redis事务顶多解决其中扣Redis库存不被超卖这一小段剩下的需要靠分布式事务方案和对账补偿机制来兜底。认清边界才能在架构选型时不犯方向性错误。6. 选型建议事务、Lua脚本、分布式锁各自的责任边界6.1 什么情况下无脑选Lua如果你发现事务里需要做判断、需要循环、需要把前一条命令的返回值用在后一条命令上那Redis事务确实不够用因为MULTI队列里的命令之间无法引用彼此的结果。这时候正确姿势是Lua脚本。Lua脚本有两个事务没有的巨大优势一是整个脚本在Redis服务端单线程内执行天然具有一次连续执行的原子性二是脚本内部可以读值、判断、写值所有逻辑都在服务端完成不需要像WATCH那样先读、再判断、再执行、失败重试地来回拉锯。典型的场景是限流器——一个完整的当前时间窗口计数判断是否超限自增逻辑用Lua几十行就能搞定而且没有WATCH重试的额外RTT。注意一个常见误解Lua脚本执行中如果发生运行时错误前面已经执行的写命令同样不会自动回滚。它的原子性保证的是执行期间不被打断跟Redis事务一样错误回滚这两点别指望。但Lua的优势在于你能在脚本里写条件判断主动规避那些会被回滚的错误路径。6.2 什么情况下用事务如果业务逻辑是几个写操作打包一下不需要服务端计算只需要保证它们连续执行那就用事务。比如批量更新多个计数、一次给用户发多个奖励、清理一组临时key。这类场景命令之间没有数据依赖MULTI/EXEC足够用Lua反而显得杀鸡用牛刀——Lua脚本需要管理脚本内容、版本升级和缓存运维成本比事务高。另外一个很适合事务的场景是你需要乐观锁式更新且重试成本很低。比如防重复提交、幂等标记、简单的计数扣减WATCH事务的代码模式比Lua更直观也更容易在客户端看到执行结果和错误。6.3 什么时候两者都不行得上分布式锁如果多个服务实例、多个Redis实例、甚至跨技术栈之间需要互斥事务和Lua都解决不了——因为它们的原子性只在单个Redis节点内有效。比如同一用户的多个请求只能有一个成功执行下单且你希望请求来了先阻塞等待、而不是直接让你重试那WATCH那种失败就返回、客户端自己重试的模式就不好用了需要一把真正的互斥锁。这时候我会用Redisson做分布式锁它支持锁等待、自动续期、可重入比手写SET NX EX靠谱得多。但从一致性强度来看分布式锁和WATCH乐观锁之间没有绝对优劣乐观锁适合冲突率低、重试成本低的场景性能更好分布式锁适合冲突率高、需要阻塞等待、或者锁持有期间要配合业务数据库事务的场景。我见过的坑是很多人无脑用分布式锁包住整个下单接口结果大量线程阻塞在锁上吞吐量直接崩了——实际上大部分下单请求在扣减库存这一步冲突率没那么高用WATCH乐观锁配合MySQL兜底完全够用。我自己在项目里有一条简易的判断标准如果一段逻辑需要条件判断和循环放Lua如果只是把几个写操作打包用事务如果需要在多个服务之间协调互斥上锁。这套标准帮我在不少高并发场景里省了很多冤枉路。最后再分享一个实用小技巧不管用哪种方案上线前都写个高并发的压测脚本同时打几十个请求看看事务返回了多少次nil、Lua脚本耗时多少、锁的等待队列有多深——实测数据永远比理论推演靠谱这也是我每次做Redis相关改动时雷打不动的习惯。
返回列表