兄弟们,聊点Redis实战里特别容易翻车的东西。平时写业务,遇到要一次性执行多条命令、还带条件判断的场景,很多人第一反应是堆代码、加锁、然后头疼各种并发问题。其实Redis早就给了原生方案,就是今天要捋清楚的这套东西——事务,外加搭配使用的watch、multi、exec、discard这几个命令。这篇笔记就是冲着把这些玩明白来的,不聊虚的,全是一线能用上的细节和坑。
这套东西能解决什么问题?最典型的就是“多个操作必须一起成功,不能只成功一半”。比如库存扣减、余额转账、计数自增校验这种,你要是分开执行几条命令,中途出个网络抖动或者逻辑异常,数据就彻底对不上了。Redis事务就是用来把这批命令绑成一捆,一次性按顺序往外扔。适合谁看?刚把Redis命令基础过完、准备上手写点真实业务的同学,或者已经在用但被坑过、想搞清楚内部机制的朋友,这篇都可以直接参考。
1. 内容整体设计与思路拆解
1.1 需求场景:为什么必须“一次执行多条命令”
想象一个最经典的场景:你在做一个秒杀系统,核心逻辑是“先查库存够不够,够就扣减,不够就返回失败”。用原生命令拆开写,大概是GET stock判断一下,再DECR stock。这里面有个致命问题:两个操作中间隔了网络往返,如果并发一多,请求A查到库存还剩1,还没等它扣减,请求B也查到了1,两边都认为“还剩1”,结果都去扣减,库存直接变成负数。这就是典型的“竞态条件”。
有人会说,这不简单嘛,用Redis的单线程特性,写个带条件的原子命令不就行了?像DECR本身就是原子的,你确实可以靠它加减,但如果逻辑复杂一点,比如“扣减之前还要校验用户权限”“扣减之后要记录一条流水”,一条命令根本装不下。这时候把多条命令打包成一个事务批量执行,就能从根上把“中间被插队”的问题解决掉。Redis事务的核心思路很简单:先在一个队列里把命令攒起来,最后一把梭全执行完,中间不会再穿插任何别的客户端的命令。
我打个比方你就懂了。你去食堂打饭,正常流程是点一个菜师傅炒一个,炒完你再点下一个,每个菜之间别人都能插进来点单。事务模式就像你拿了个托盘,一口气把所有要的菜告诉师傅,师傅按顺序一次性全给你做齐,中间谁插队都不好使。虽然还是“一个一个炒”,但这一托盘之内的顺序和完整性,是板上钉钉的。
1.2 事务的本质设计:Redis和关系型数据库不一样
我从接触Redis事务第一天起就被反复提醒过:它跟你脑子里“事务”那套印象不一样。传统关系型数据库(拿MySQL举例)的事务,讲究ACID,原子性、一致性、隔离性、持久性,一个都不能少,尤其是原子性,哪怕第100条SQL报错了,前面99条都得回滚,当成啥也没发生。
Redis事务呢?它只有部分ACID影子。原子性这块——不好意思,不存在的。你往事务里塞了100条命令,执行到第50条时出错了,Redis不会把前面49条撤销掉,而是直接继续执行后面的。我第一次知道这个设计时人也愣了,这也能叫事务?后来想通了,Redis追求的是极致的简单和性能,它的事务本质上是一个“打包执行”机制,核心价值在“隔离性上不被打断”,而不是“出错全回滚”。这一点必须刻在脑子里,不然你写出来的业务逻辑,坑的是自己。
所以在设计业务方案时,你要先想清楚定位:Redis事务适合的是“强隔离、弱原子”的场景,意思是命令之间互相不干扰、必须连续执行,但允许某条命令中途失败、失败后后续命令继续跑。你要是非拿它当数据库事务用,要求出错全部回滚,方向就错了。正确姿势是:自己提前在代码里做校验,把出错的概率压到最低,事务只是一个保证“连续执行”的容器,不是一个“错误后悔药”机制。后面第4部分我们会详细讲错误处理的分寸。
2. 核心命令解析:MULTI、EXEC、DISCARD、WATCH
2.1 MULTI和EXEC:事务的开关与执行
MULTI是事务开始的口令,输入它之后,Redis会返回一个OK,这时候你的客户端和Redis之间就进入了一个特殊的“命令入队”状态。接下来你发的每一条命令,Redis不会再立刻执行,而是全部丢进一个队列里排队,每条返回一个QUEUED,提醒你“收到,已入队”。
等到你输入EXEC,Redis会把队列里攒好的所有命令,按入队顺序从头到尾一次性执行,然后把每条命令的结果按顺序打包成一个数组,一次性返回给你。这里有个很重要的观察点:EXEC返回的结果数量、顺序,和你入队的命令数量、顺序,完全一一对应。我在排错时经常靠这个特性核对命令是否都执行到位。
举个最原始的例子,你去redis-cli里敲一下这组命令感受一下:
127.0.0.1:6379> MULTI OK 127.0.0.1:6379> SET user:1:name "zhang" QUEUED 127.0.0.1:6379> INCR user:1:count QUEUED 127.0.0.1:6379> EXEC 1) OK 2) (integer) 1这个过程中,MULTI到EXEC期间,任何其他客户端发的命令,都只能排在当前事务队列全部执行完之后,才会被处理。这就是我们前面说的“打包执行、不被打断”。
注意:
EXEC之后队列会自动清空,事务结束。如果入队途中不想玩了,或者发现命令写错了,可以用DISCARD清空队列并退出事务状态。
2.2 DISCARD:事务的后悔药
DISCARD好理解,就是“这波命令我不发了,散伙”。它的作用是清空当前事务里已经入队的所有命令,同时退出事务状态,一切当作没发生过。注意它和回滚有本质区别:它不是把已执行的命令撤销,因为它压根还没执行,只是在“入队阶段”主动放弃。
127.0.0.1:6379> MULTI OK 127.0.0.1:6379> SET user:1:name "zhang" QUEUED 127.0.0.1:6379> SET user:1:age 18 QUEUED 127.0.0.1:6379> DISCARD OK 127.0.0.1:6379> GET user:1:name (nil)上面这个例子里,DISCARD之后去查user:1:name,返回nil,证明前面的SET根本没执行。这个命令我在写脚本和做临时数据操作时,经常当“撤销按钮”用。有时候一连串命令敲到一半,发现前面某条参数写错了,不用慌,直接 DISCARD,从头再来。
2.3 WATCH:给事务加上条件判断的武器
如果你以为Redis事务就只能傻乎乎地把命令一股脑执行完,那就亏了,事务最强的搭档WATCH还在后面。WATCH是干什么的?它是Redis实现“乐观锁”的钥匙。所谓乐观锁,就是默认数据在操作期间不会被别人改,但为了让万一不会出事,在执行之前先盯住关键的数据,一旦发现被改了就中止操作。
用法我习惯总结成三步走:
WATCH一个或多个key,相当于给这些key装了监控探头。MULTI开启事务,把业务命令入队。EXEC执行。Redis在EXEC的瞬间会检查被WATCH的key有没有被改动过,如果发现变了,直接拒绝执行整个事务,返回nil;没变,才正常执行。
文字不够直观,拿经典“库存扣减”场景演练一遍。假设商品库存存在stock:1001这个key里,初始值是60。两个客户端同时来抢购。客户端A先执行:
127.0.0.1:6379> WATCH stock:1001 OK 127.0.0.1:6379> MULTI OK 127.0.0.1:6379> DECR stock:1001 QUEUED这时候客户端B抢跑,直接在没开事务的情况下执行了:
127.0.0.1:6379> DECR stock:1001 (integer) 59这时候客户端A再执行:
127.0.0.1:6379> EXEC (nil)看到了吗,客户端A的EXEC返回了nil,事务没执行。因为stock:1001被客户端B动过,WATCH检测到变化,宁可让A失败也不让它基于过期数据做扣减。这就是乐观锁的精髓:平时不设锁,但在执行的临界点做校验,谁先改谁赢,改过之后其他人必须重新查询、重新决策。
这一套组合拳,在秒杀、分布式计数、订单状态流转等场景里非常吃香。你在代码里做业务逻辑时,一旦发现EXEC返回了nil,就说明你WATCH的key被并发改过了,这时需要重试整个操作(重新WATCH、重新查数据、重新入队),直到成功为止。重试逻辑虽然要多写几行,但比引入分布式锁简单多了,而且性能损耗小。
3. 实操过程与核心环节实现
3.1 基础事务实战:转账功能怎么一次成型
光说不练假把式。我用一个真实的转账业务把整个流程串起来。假设两个账户,acc:a余额100,acc:b余额50,我们要从a转30给b。通常需要三步:扣减a的余额、增加b的余额、记录一条流水。这三步必须一气呵成,中间不能有人插队。
用Redis事务写出来就是:
127.0.0.1:6379> MULTI OK 127.0.0.1:6379> DECRBY acc:a 30 QUEUED 127.0.0.1:6379> INCRBY acc:b 30 QUEUED 127.0.0.1:6379> LPUSH transfer:log "from a to b amount 30" QUEUED 127.0.0.1:6379> EXEC 1) (integer) 70 2) (integer) 80 3) (integer) 1三条命令一起执行,期间没有任何其他客户端能插入。执行完,a的余额返回70,b的余额返回80,流水记录返回1,整个转账动作在Redis视角是连续的。
但这里我要泼盆冷水——这行代码看着漂亮,实际业务里不能直接这么用。为什么?因为DECRBY一个负数怎么办,比如a账户余额只有20,你扣30,它照样会给你扣成 -10,事务可不会帮你拦截这层逻辑。正确做法是事务里先查余额,再决定要不要执行扣减操作,加上WATCH保护。所以实战里的转账脚本应该长这样:
127.0.0.1:6379> WATCH acc:a OK 127.0.0.1:6379> GET acc:a "20"这时在业务代码里判断余额是否够,够才继续:
127.0.0.1:6379> MULTI OK 127.0.0.1:6379> DECRBY acc:a 30 QUEUED 127.0.0.1:6379> INCRBY acc:b 30 QUEUED 127.0.0.1:6379> EXEC (nil)因为你在WATCH期间读取了acc:a,如果中间有人改了它,EXEC就会返回nil。所以在真实代码里,流程是“WATCH → 读数据 → 业务判断 → MULTI → 入队 → EXEC → 检查结果是否为nil”,一旦是nil,就从第一步重来。这套模式就是Redis社区常说的 WATCH+MULTI 乐观锁并发控制范式。
实操心得:代码里不要省掉“返回nil后重试”的逻辑。我第一次写这块时,天真地以为
EXEC返回数组就万事大吉,结果在高并发压测下一堆“负数库存”,排查半天才意识到是丢了重试。重试次数建议设一个上限,比如3~5次,超过就返回“系统繁忙”,防止无限循环拖垮性能。
3.2 事务在管道和脚本中的结合使用
在实际项目中,我们很少直接在redis-cli里敲这一长串,大多数时候是走客户端SDK。比如你用Java原生的Jedis,或者Spring Data Redis,写法上就是把这些命令连续调用,但有几个细节跟命令行不一样,我特意标出来。
Java里基于Jedis的写法大致是这样:
Jedis jedis = new Jedis("localhost", 6379); Transaction tx = jedis.multi(); tx.set("user:1:name", "zhang"); tx.incr("user:1:count"); List<Object> results = tx.exec();这看着简单,但有几个坑。一是multi()之后,所有的操作对象都变成了Transaction对象,而不是Jedis对象,新手经常搞混,继续用jedis.set(),然后发现命令立刻被执行了,完全没进入事务。二是exec()返回的是一个List<Object>,里面元素的类型、顺序跟命令一一对应,你要做结果解析,判断每条是否成功。
另外有一个特别容易被忽略的点:事务跟管道(Pipeline)不是一回事。管道是把一堆命令打包发给服务器,省的是网络往返时间,Redis服务器还是逐条执行、逐条返回。事务是确保执行期间不被插队,省的是并发安全的心。两者可以配合用,但并不等价。比如这段代码:
jedis.watch("stock:1001"); Transaction t = jedis.multi(); t.decr("stock:1001"); List<Object> results = t.exec();如果results是null,说明事务因为WATCH的key被改动而没执行,需要重试。如果是个空集合,可能是你的事务里没命令,虽然不常见但也得防一手。
再提到脚本。如果说事务是“组合拳”,Lua脚本就是“必杀技”。Redis从很早的版本就支持在服务器端执行Lua脚本,它可以包含复杂的流程控制、条件判断,并且整个脚本也是原子性执行,中间不会被插队。很多小伙伴纠结:既然有Lua,是不是可以完全替代事务?我的看法是:各有用武之地。事务简单直接、不用写脚本语言,适合纯批量执行、命令之间没复杂逻辑的场景;Lua脚本适合需要在服务器端做判断取舍的,比如“余额够才扣减”“当前时间在活动窗口内才允许操作”这种。后面第5部分我会具体对比,不急着下结论。
4. 错误处理机制详解:事务里的异常到底分几种
4.1 入队错误:语法都没过,还谈什么执行
搞事务最怕的是看到奇怪的报错。先看第一种,入队阶段报错。你在MULTI之后敲了一条不存在的命令,或者参数个数不对,Redis会立刻返回一个错误提示,比如:
127.0.0.1:6379> MULTI OK 127.0.0.1:6379> SET user:1:name (error) ERR wrong number of arguments for 'set' command注意,这种“入队时报错”的命令,不会被放进队列。但是Redis的命令设计有一点很微妙:当入队阶段出现了哪怕一条错误,整个事务在EXEC时也会直接拒绝执行,返回一个全局错误。换句话说,这种错误是“执行前置错误”,Redis会在EXEC的入口统一检查队列里有没有“不合法命令”,一旦发现,整个事务直接作废,队列里所有命令一条都不执行。
127.0.0.1:6379> MULTI OK 127.0.0.1:6379> SET user:1:name "zhang" QUEUED 127.0.0.1:6379> INCR user:1:name # 注意 user:1:name 是字符串,但 INCR 命令入队时不校验类型 QUEUED 127.0.0.1:6379> EXEC 1) OK 2) (error) ERR value is not an integer or out of range这次不一样了,排队阶段一切正常,但EXEC执行到第二条时才爆出类型错误。第一条SET已经生效了,并不会因为第二条失败而回滚。这条命令配SET user:1:name "zhang"其实就是给后续的错误创造了条件,但是事务才不会管这些依赖关系,它只会一条一条按顺序硬跑。这一点专用术语叫“事务不保证原子性,只保证隔离性”。
我在本地测试时最常用这个机制来验证“Redis事务不回滚”的特性。你在知乎或技术群里经常能看到有人问“Redis事务能不能实现回滚”,答案能不能已经说烂了——不能。但它的设计逻辑其实是自洽的:因为入队前客户端可以本地做类型校验,执行阶段最简单的错误就是类型错,而这种错误被刻意设计成“不影响其他命令”,是为了不在服务器端引入昂贵的回滚机制。Redis的哲学就是“快”,一个回滚机制要记录每条命令的镜像、要处理各种逆操作,这会伤性能。既然官方的立场都是“事务是个直筒子”,你也不要花时间去折腾怎么让它的错误回滚,做好业务补偿才是正道。
4.3 错误处理的最佳实践:业务补偿胜于回滚
既然Redis事务不能回滚,出错了咋收场?我的经验是:错误处理必须前置,下意识接受“Redis事务不会为你的业务错误买单”这一点。具体分两个层面。
第一个层面,数据校验往前提。所有必要的数据检查,比如余额是否够、对象是否存在、格式对不对,应该在MULTI之前先完成,不要把含“业务风险的判断”塞进事务里。如果非得在事务里做判断,那就不要用SET、INCR这种自说自话的命令,改用带条件的命令——比如SETNX(只在key不存在时设置成功),或者干脆用Lua脚本在服务器端判断。至少保证,单条命令执行时,能以“失败本身不影响状态”的方式反馈,比如SETNX返回0表示“没设置成功”,不会把数据改坏。
第二个层面,补偿逻辑要跟上。如果在EXEC结果里发现某些命令执行了、某些没执行,自己要动手把前后修正一致。比如转账业务里,a扣了、b没加上,你就需要再手动调用一次给b补上,或者给a加回来。我的经验是不要试图让事务做得更“聪明”,而是让你上层的业务代码更健壮,靠重试和补偿机制来兜底。
注意:事务内如果有命令返回错误,
EXEC仍然会返回一个数组(不是nil),数组里对应出错位置是error对象,其他位置是正常结果。很多人只看数组是否为null来判断成败,这是不对的。正确做法是遍历结果数组,逐个检查有没有error,有的话走补偿逻辑。
5. 常见问题与排查技巧实录
5.1 WATCH的坑:为什么我watch了还是被并发锤了
WATCH机制虽然好用,但有几个经典的失效场景,我拿出来单独说。
第一个坑:连接断开导致watch失效。WATCH是基于连接状态的,一旦客户端和Redis断开连接,服务端会把该连接上所有WATCH标记全部清空。接着重连、继续执行事务,等于裸奔。写业务时如果遇到网络抖动后重连,务必重新执行WATCH。
第二个坑:WATCH在EXEC之后自动失效。就算事务执行成功,watch的监控也会被释放,下次再想用,得重新WATCH,不存在“观察一次,永久生效”这种好事。写代码时每个循环重试都要把WATCH放在循环体内,而不是循环外,这点不夸张地说,90%的乐观锁重试bug都出在这。
第三个坑:watch一个不存在的key。Redis允许你watch一个还没有创建的key,把它视为一个空值来监控。如果你在事务里对这个key做INCR,该key会被创建出来。但如果在事务执行之前(也就是你watch之后、exec之前),别的客户端创建了这个key并赋值了,照样会触发冲突终止。所以别以为key不存在就可以不watch,它照样有意义。
第四个坑,也是实际业务里最高频的:WATCH监控了多个key,其中任何一个被改动,整个事务都会中止。就算别的key只是被改了值、而且改完的值跟原来一模一样,只要它经过了写操作,EXEC一样不买账。这意味着,被watch的key写越频繁,事务被废弃的概率越高,性能越差。所以别滥用watch,盯住最关键的、最可能发生竞争的key就够了,能少watch就少watch。
5.2 事务里命令太多怎么办:拆单和分批
事务是不是命令越多越好?当然不是。一次事务里塞了数千条命令,会带来两个问题:一是输入缓冲区压力大,二是长时间独占执行会阻塞其他请求,影响整体响应。按理说,事务里的命令应该保持精简,只放“必须连续执行”的那几条,跟业务无关的查询、非关键计算,都尽量拿出去。
我碰到过一个真实案例:某同学把一次批处理循环写了个大事务,循环里几千次SET,直接导致那个Redis实例上其他业务几秒钟的延迟尖刺。解决办法也简单,把大事务拆成多个小事务,每个事务控制在100条以内,分批执行,中间加个睡眠或者限制频率。如果对“原子性”要求实在高,那就得考虑用Lua脚本在服务器端跑循环,脚本也是原子性的,但要控制脚本执行时间,避免在慢查询里挂了太久。
5.3 Lua脚本能不能替代事务
Lua脚本和事务的关系,我每次讲都要拉出来对比,因为它们实在是太容易被混淆。结论先行:Lua脚本是事务的超集,它不仅能保证多条命令连续执行,还支持在服务器端做条件判断,这比“无脑全部执行”的事务强不少。
从Redis的官方演进也能看出来,针对“带业务条件的原子操作”,官方越来越推Lua脚本甚至更精确的命令。我的实践偏好是:
- 纯批量、无判断——用事务,简单直观。
- 有判断、依赖中间结果——用Lua脚本,判断逻辑放进脚本,减少网络往返。
- 高并发热点key——两者都要小心,优先考虑用更细粒度的原子命令加上前置校验。
举个例子,用Lua实现“余额够就扣减”:
local balance = tonumber(redis.call('GET', KEYS[1])) if balance >= tonumber(ARGV[1]) then return redis.call('DECRBY', KEYS[1], ARGV[1]) else return -1 end这个脚本执行时,整个判断和扣减都是原子的,没有竞态窗口,也不用担心EXEC返回nil导致要重试。我做过对比,同样的逻辑,用WATCH+MULTI在低并发下性能差不多,但在高并发下,Lua脚本写起来更省心,少了一层重试逻辑。如果你还在纠结事务的可靠性,我建议直接把Lua脚本练熟,它才是Redis原子操作的完全体。
5.4 客户端工具封装的时候,别被框架误导
最后提一下框架封装这个坑。很多主流的Redis客户端都提供了事务的封装,像Spring Data Redis里的SessionCallback、RedisTemplate的多命令支持,但封装的语义不一定完全一致。有的框架会在底层自动用MULTI/EXEC,有的会把WATCH和MULTI分开封装成不同方法,还有的会把返回值包装成泛型对象,如果你不仔细读文档,很容易以为框架帮你做了回滚。
我用Spring Data Redis踩过一次大坑:事务执行完,框架返回的结果里,错误信息被包装成了异常,但前面成功执行的命令并没有被撤销,我的业务代码却以为“整个操作失败了”,结果数据库里出现了部分数据已改、部分没改的脏状态。所以,不管用什么框架,都要回到最原始的道理:Redis事务不保证全成功,框架也不会帮你全回滚,最重要的还是业务层的补偿与校验。
排查的时候,教你一个屡试不爽的办法:打开Redis的慢查询日志,把EXEC语句揪出来看耗时。如果事务执行极慢,多半是某条子命令触发了批量操作,比如DEL一个大key、SADD一个大集合,这时候要拆业务逻辑,而不是急着调事务本身。
结尾
最后说点实际的。我刚开始练Redis事务那会儿,也是对着文档把四个命令背得滚瓜烂熟,真正写业务时照样栽跟头。后来总结出一个按部就班的检查顺序,每次写完都要过一遍:WATCH的key加够了没有?事务里的命令是不是必须绑在一起的?错误处理是按“单个命令失败”还是“全局回滚”来写的?补偿逻辑备好了没?这几关全过了,事务这块才算真正落地。
再分享一个小技心法:把每个事务想象成一段简短的流水线——入队是摆料,执行是开工,而WATCH是开工前的自动质检。只要这个质检你觉得不放心,就说明事务方案还没设计到位,往Lua脚本或者原子命令那边再靠一靠。Redis这套东西,理念上不算深,真正的深度在一个个实践细节里,把细节磨顺了,你在处理并发数据时基本就能稳得住了。