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放入一个先进先出的队列。直到EXEC,Redis才会把队列里的命令一条条按顺序执行,然后把每条命令的结果按顺序打包返回给客户端。
注意一个细节:队列里的命令是不会提前看值的。你可以在事务里写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 "zhangsan"DISCARD之后,队列里的命令全部丢弃,刚才入队的SET没有执行,name还是旧值。这里顺便说一个实战经验:很多语言的客户端库在异常处理时,容易忘记把事务状态清掉,导致连接池拿出来的连接带着一个半开的MULTI队列——这是个很隐蔽的坑,后面第五章专门讲。
2.3 WATCH和UNWATCH:位置反了就是报错
WATCH的正确姿势是:必须在MULTI之前执行。典型流程是:
WATCH key1 key2 ... -- 先监视 GET key1 -- 读值、做判断 MULTI -- 开启事务 命令入队... EXEC -- 执行,如果期间key被改过,返回nil如果在MULTI之后执行WATCH,Redis会直接报错:
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会拒绝执行整个事务,返回EXECABORT:
127.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执行LPUSH:
127.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版CAS(Compare And Swap)。“Compare”的步骤交给了WATCH:Redis内部维护了一个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(host='127.0.0.1', port=6379, decode_responses=True) 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(transaction=True) 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.WatchError:execute的时候客户端发现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里就是transaction=True参数)。
如果你把非事务管道当事务用,等于根本没获得原子性和WATCH保护,只是省了网络时间。一批命令中间插入了其他客户端的写操作,你的"批量读改写"照样会踩竞态。判断标准很简单:看库的API文档里那个管道对象是否默认包MULTI/EXEC,redis-py明确要求transaction=True才走事务,默认值是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相关改动时雷打不动的习惯。