1. 为什么单线程的 Redis 还需要“事务”
很多人第一次接触 Redis 事务时都会有一个疑问:Redis 不是单线程处理命令的吗,一条命令执行完才会执行下一条,天然就不存在并发穿插的问题,那为什么还要搞一套事务机制出来?这个问题我当年也纠结过,后来在实际项目里踩了几次坑才真正想明白。
单线程保证的是“单条命令”的原子性,也就是说一条命令要么执行成功要么执行失败,不会执行到一半被别的命令插进来。但现实业务里,我们经常需要“一组命令”整体按顺序执行,中间不希望被其他客户端的命令打断。比如经典的转账场景:A 账户扣 100,B 账户加 100,这两步必须绑在一起,中间如果插进来一个查询请求读到“A 已扣、B 未加”的中间状态,数据就脏了。Redis 事务解决的正是这种“批量命令打包执行”的需求。
需要先明确一个关键认知:Redis 事务和关系型数据库的事务不是一回事。MySQL 的事务有 ACID 四大特性,支持回滚;而 Redis 事务只保证“命令打包顺序执行、执行期间不被其他命令打断”,它不支持回滚。这一点是无数新手踩坑的重灾区,后面我会专门用一节来讲清楚为什么它不回滚、以及不回滚会带来什么后果。
这篇文章我会把 Redis 事务的完整命令链路(MULTI、QUEUE、EXEC、DISCARD、WATCH)拆开讲透,再延伸到 Lua 脚本这个“进阶版事务”,把两者的适用边界、性能差异、实战坑点都过一遍。适合已经会用 Redis 基本命令、想搞清楚事务和脚本怎么选、怎么写才不出错的开发者。如果你还在纠结“为什么我的事务没生效”,那这篇大概率能帮你把问题定位出来。
2. Redis 事务的核心机制拆解
2.1 事务的五个关键命令与执行流程
Redis 事务围绕五个命令展开,我先把它们的作用和关系理一遍,后面再逐个深入。
| 命令 | 作用 | 使用阶段 |
|---|---|---|
| MULTI | 开启事务,之后的命令进入队列 | 事务开始 |
| 普通命令 | 入队但不立即执行,返回 QUEUED | 事务排队中 |
| EXEC | 执行队列中的所有命令 | 事务提交 |
| DISCARD | 放弃事务,清空队列 | 事务取消 |
| WATCH | 监视 key,被改动则事务失败 | 事务开始前 |
整个流程可以这样理解:客户端发送MULTI后,Redis 进入“事务状态”,后续所有命令不会立即执行,而是被放进一个队列里,每入队一条就返回一个QUEUED。直到客户端发送EXEC,Redis 才把队列里的命令一次性、按顺序、连续地执行完。执行期间不会插入其他客户端的命令,这就是它“隔离性”的来源。
我用一段实际会敲的命令演示一下:
127.0.0.1:6379> MULTI OK 127.0.0.1:6379> SET account:A 100 QUEUED 127.0.0.1:6379> SET account:B 200 QUEUED 127.0.0.1:6379> INCRBY account:A -100 QUEUED 127.0.0.1:6379> INCRBY account:B 100 QUEUED 127.0.0.1:6379> EXEC 1) OK 2) OK 3) (integer) 0 4) (integer) 300注意看,入队阶段返回的全是QUEUED,只有EXEC之后才返回真正的执行结果,而且结果是一个数组,顺序和入队顺序一一对应。这个返回结构很重要,很多客户端库就是靠解析这个数组来判断每条命令的执行结果的。
2.2 入队错误与执行错误:两种失败要分清
Redis 事务的“错误处理”是它最反直觉的地方,必须把两种错误分开看。
第一种:入队时的错误(语法错误、命令不存在)。比如你写了个不存在的命令,或者参数个数不对,Redis 在入队阶段就能检测出来,会直接返回一个错误,并且标记这个事务为“脏”。一旦事务被标记为脏,后续EXEC会直接失败,整个队列一条都不执行。
127.0.0.1:6379> MULTI OK 127.0.0.1:6379> SET key1 value1 QUEUED 127.0.0.1:6379> INVALIDCMD key2 (error) ERR unknown command 'INVALIDCMD' 127.0.0.1:6379> EXEC (error) EXECABORT Transaction discarded because of previous errors.第二种:执行时的错误(类型错误、对字符串做 INCR 等)。这类错误在入队阶段检测不出来,因为 Redis 不检查值的类型,只有真正执行时才知道。关键点来了:执行时某条命令报错,其他命令照样执行,事务不会回滚。
127.0.0.1:6379> SET str "hello" OK 127.0.0.1:6379> MULTI OK 127.0.0.1:6379> INCR str QUEUED 127.0.0.1:6379> SET num 10 QUEUED 127.0.0.1:6379> EXEC 1) (error) ERR value is not an integer or out of range 2) OK看到没,第一条INCR str报错了,但第二条SET num 10依然执行成功。这就是 Redis 事务“不支持回滚”的直接体现。为什么这么设计?官方的解释是:执行时错误通常是编程错误(比如类型用错),应该在开发阶段就发现,而不是靠运行时回滚来兜底;而且支持回滚会让 Redis 的实现复杂很多,性能也会受影响。理解了这一点,你就知道不能把 Redis 事务当成 MySQL 事务来用,业务上的“要么全成功要么全失败”得靠别的手段保证。
2.3 WATCH:乐观锁实现 CAS 操作
光有 MULTI/EXEC 还不够,因为它只能保证命令连续执行,但没法保证“我读到的值在我写之前没被别人改过”。这时候就要请出WATCH。
WATCH的用法是在MULTI之前监视一个或多个 key,如果在EXEC执行之前,这些 key 被任何客户端(包括自己)修改过,那么整个事务就会失败,EXEC返回 nil。
# 客户端 A 127.0.0.1:6379> WATCH balance OK 127.0.0.1:6379> GET balance "100" 127.0.0.1:6379> MULTI OK 127.0.0.1:6379> INCRBY balance -50 QUEUED # 此时客户端 B 抢先改了 balance 127.0.0.1:6379> INCRBY balance 30 (integer) 130 # 客户端 A 再执行 EXEC 127.0.0.1:6379> EXEC (nil)EXEC返回 nil,说明事务因为balance被改动而放弃执行。这就是典型的CAS(Check-And-Set)乐观锁思路:先监视,操作前检查有没有被别人动过,动过就重试。它不阻塞其他客户端,性能比悲观锁好,代价是冲突时需要业务层自己重试。
这里有个细节要注意:WATCH的监视效果是一次性的,EXEC或DISCARD之后监视自动解除。另外,WATCH必须在MULTI之前执行,放在MULTI里面是无效的。还有一点,如果事务执行成功,WATCH也会自动取消,不需要手动UNWATCH。
3. Lua 脚本:Redis 事务的进阶形态
3.1 为什么 Lua 脚本能替代事务
事务用起来其实挺别扭的:命令要一条条入队,逻辑判断(比如“如果余额够就扣款”)没法在事务里做,因为入队阶段拿不到值。这时候 Lua 脚本就派上用场了。
Redis 从 2.6 版本开始内置 Lua 解释器,允许客户端发送一段 Lua 脚本,在服务端原子性地执行。所谓“原子性”,和事务一样,指的是脚本执行期间不会被其他命令打断。但 Lua 脚本比事务强的地方在于:
- 可以在服务端做逻辑判断:脚本里能读值、比较、分支,根据结果决定执行哪些写操作,这是事务做不到的。
- 减少网络往返:多条命令打包成一次脚本调用,客户端只发一次请求,网络开销大幅降低。
- 复用性强:脚本可以缓存到服务端,后续用 SHA1 摘要调用,不用重复传输脚本内容。
我个人的经验是:能用 Lua 脚本搞定的原子操作,优先用 Lua,事务更多是历史遗留场景或者简单批量场景才用。原因就是 Lua 的表达能力强太多,而且性能通常更好。
3.2 EVAL、EVALSHA 与脚本缓存机制
Lua 脚本的执行命令主要有两个:EVAL和EVALSHA。
EVAL的语法是EVAL script numkeys key1 key2 ... arg1 arg2 ...。其中numkeys是后面跟的 key 的数量,Redis 靠这个来区分哪些参数是 key、哪些是普通参数。这个区分很重要,因为集群模式下 Redis 需要根据 key 来路由,如果 key 没标对,脚本在集群里会报错。
EVAL "return redis.call('SET', KEYS[1], ARGV[1])" 1 mykey myvalue这里1表示有 1 个 key,mykey是 KEYS[1],myvalue是 ARGV[1]。脚本里通过KEYS和ARGV两个全局数组访问这些参数。
EVAL每次都要把整段脚本传过去,脚本长了网络开销就大。所以 Redis 提供了脚本缓存:执行过的脚本会以 SHA1 摘要为 key 缓存起来,之后可以用EVALSHA只传摘要来调用。
127.0.0.1:6379> SCRIPT LOAD "return redis.call('GET', KEYS[1])" "4e6d8fc8bb01276962cce5371fa795a7763657ae" 127.0.0.1:6379> EVALSHA "4e6d8fc8bb01276962cce5371fa795a7763657ae" 1 mykey "myvalue"实际项目里,成熟的客户端库(比如 Jedis、Lettuce)都会自动处理这套逻辑:先尝试EVALSHA,如果报NOSCRIPT错误(脚本没缓存),再回退到EVAL并缓存。你手动写的时候也可以照这个思路来。
3.3 脚本里的 redis.call 与 redis.pcall
在 Lua 脚本里调用 Redis 命令,有两个函数:redis.call和redis.pcall。它们的区别在于错误处理方式。
redis.call遇到错误会直接中断脚本,把错误返回给客户端;redis.pcall则会把错误捕获成一个 Lua table 返回,脚本可以继续执行。看个对比:
-- 用 redis.call,遇到类型错误直接中断 redis.call('SET', KEYS[1], 'hello') redis.call('INCR', KEYS[1]) -- 这里报错,脚本终止 redis.call('SET', KEYS[2], 'never') -- 不会执行 -- 用 redis.pcall,可以捕获错误继续 local ok = redis.pcall('INCR', KEYS[1]) if ok.err then -- 处理错误 end redis.call('SET', KEYS[2], 'executed') -- 会执行这里有个非常重要的坑:Lua 脚本执行到一半报错,前面已经执行的写命令不会回滚。这和事务是一样的。所以脚本里如果有多步写操作,要么保证前面的操作不会失败,要么用pcall自己做好错误处理。我见过有同学在脚本里先扣库存再写订单,结果扣完库存写订单时报错,库存白扣了,这就是没考虑脚本的“部分执行”问题。
4. 实战:用 Lua 脚本实现一个安全的库存扣减
4.1 业务场景与原子性需求
假设我们有个秒杀场景,库存存在 Redis 里,key 是stock:1001,值是剩余库存。要求:扣减库存时先判断够不够,够才扣,不够返回失败。这个逻辑用事务写会很别扭,因为事务里没法根据读到的值做判断。用 Lua 脚本就非常自然。
先想清楚为什么必须原子:如果分成“GET 判断”和“DECR 扣减”两步,两个客户端可能同时读到库存为 1,都判断够,然后都扣,库存变成 -1,超卖了。Lua 脚本把判断和扣减放在一次原子执行里,就杜绝了这个问题。
4.2 脚本编写与参数设计
脚本内容如下:
-- KEYS[1]: 库存 key -- ARGV[1]: 本次扣减数量 local stock = tonumber(redis.call('GET', KEYS[1])) if stock == nil then return -1 -- 库存不存在 end local num = tonumber(ARGV[1]) if stock < num then return 0 -- 库存不足 end redis.call('DECRBY', KEYS[1], num) return 1 -- 扣减成功返回值设计上我用了三个状态:-1 表示 key 不存在,0 表示库存不足,1 表示成功。这样客户端能精确区分失败原因,而不是笼统地知道“失败了”。参数设计上,key 通过KEYS传,扣减数量通过ARGV传,符合 Redis 的规范,集群模式下也能正确路由。
调用方式:
EVAL "local stock = tonumber(redis.call('GET', KEYS[1])) if stock == nil then return -1 end local num = tonumber(ARGV[1]) if stock < num then return 0 end redis.call('DECRBY', KEYS[1], num) return 1" 1 stock:1001 14.3 参数计算与边界处理
这里有几个边界要处理到位。第一,GET返回的是字符串,必须用tonumber转成数字才能比较,否则 Lua 里字符串和数字比较会报错。第二,如果 key 不存在,GET返回 false(Lua 里对应 nil),tonumber(nil)会报错,所以要先判断 nil。第三,扣减数量也要转数字,防止客户端传了非数字字符串。
还有一个容易被忽略的点:Lua 里的数字都是 double 类型。库存这种整数在 double 范围内(2^53 以内)是精确的,但如果你的数值超过这个范围,就会出现精度问题。库存一般不会这么大,但如果是计数器类的场景,得留意这个限制。
另外,脚本里我用了DECRBY而不是先GET再SET,因为DECRBY本身是原子操作,而且能避免“读-改-写”的竞态。虽然整个脚本已经是原子的,但用DECRBY语义更清晰,也更高效。
4.4 脚本缓存与 EVALSHA 调用优化
生产环境里,每次都传整段脚本太浪费带宽。正确做法是启动时用SCRIPT LOAD把脚本加载进去,拿到 SHA1,之后都用EVALSHA调用。
127.0.0.1:6379> SCRIPT LOAD "local stock = tonumber(redis.call('GET', KEYS[1])) if stock == nil then return -1 end local num = tonumber(ARGV[1]) if stock < num then return 0 end redis.call('DECRBY', KEYS[1], num) return 1" "a3f5c8e9b1d2..." 127.0.0.1:6379> EVALSHA "a3f5c8e9b1d2..." 1 stock:1001 1 (integer) 1但要注意,Redis 重启或者执行SCRIPT FLUSH后,缓存会清空,EVALSHA会报NOSCRIPT。所以客户端必须实现“先 EVALSHA,失败则 EVAL 并重新缓存”的降级逻辑。这个逻辑在 Jedis 里是eval方法自动处理的,用原生命令的话得自己写。
5. 事务与 Lua 脚本的选型对比与避坑指南
5.1 什么时候用事务,什么时候用脚本
我把两者的适用场景整理成一张表,方便对照:
| 维度 | MULTI/EXEC 事务 | Lua 脚本 |
|---|---|---|
| 逻辑判断 | 不支持(入队时拿不到值) | 支持,可读值、分支 |
| 原子性 | 命令连续执行,不被打断 | 脚本整体原子执行 |
| 回滚 | 不支持 | 不支持 |
| 网络开销 | 每条命令一次往返(入队阶段) | 一次往返 |
| 可复用 | 每次都要重发命令 | 可缓存,EVALSHA 复用 |
| 集群支持 | 需同 slot | 需同 slot,key 要标对 |
| 调试难度 | 低 | 较高,需熟悉 Lua |
我的选型原则很简单:需要根据读到的值做判断的,一律用 Lua;纯粹批量执行、不需要判断的,事务够用。比如批量删除多个 key、批量设置多个值,事务就行;而库存扣减、限流计数、分布式锁续期这类需要“读-判断-写”的,必须用 Lua。
5.2 集群模式下的 key 分布陷阱
这是个大坑。Redis 集群把数据分片到不同节点,事务和 Lua 脚本都要求所有涉及的 key 在同一个 slot,否则直接报错CROSSSLOT Keys in request don't hash to the same slot。
解决办法是用hash tag:在 key 里用{}包住一部分,Redis 只对{}里的内容算哈希。比如order:{1001}:stock和order:{1001}:price,因为{1001}相同,会被分到同一个 slot。
# 这两个 key 会落到同一个 slot SET order:{1001}:stock 100 SET order:{1001}:price 99设计 key 的时候就要提前规划好哪些 key 需要一起操作,用统一的 hash tag 包起来。这个如果等到上线后才发现,改造成本很高,因为要迁移数据。
5.3 常见报错与排查速查表
我把实际运维中遇到的高频问题整理成表:
| 报错信息 | 原因 | 解决思路 |
|---|---|---|
| EXECABORT | 入队阶段有语法错误 | 检查命令拼写、参数个数 |
| (nil) from EXEC | WATCH 的 key 被改过 | 业务层重试整个事务 |
| NOSCRIPT | 脚本未缓存 | 回退到 EVAL 并重新缓存 |
| CROSSSLOT | key 不在同一 slot | 用 hash tag 统一 slot |
| ERR value is not an integer | 对非数字做 INCR | 检查数据类型 |
| 脚本执行超时 | 脚本里有死循环或耗时操作 | 优化脚本,避免大循环 |
关于脚本超时还有个细节:Redis 有个lua-time-limit配置(默认 5 秒),脚本执行超过这个时间,Redis 会开始接受其他客户端的SCRIPT KILL命令,但不会自动中断脚本。如果脚本已经执行过写操作,SCRIPT KILL会失败,只能用SHUTDOWN NOSAVE强制关闭,这会导致数据丢失。所以脚本里绝对不能写可能长时间运行的逻辑,比如遍历大集合、复杂计算。
5.4 我踩过的几个真实坑
第一个坑是在事务里用 WATCH 但忘了重试。WATCH 失败返回 nil 后,如果业务代码不重试,这次操作就静默失败了,用户以为扣款成功其实没扣。正确做法是包一个循环,失败就重新 WATCH、重新读值、重新提交,一般重试 3 到 5 次。
第二个坑是Lua 脚本里用了随机函数。早期 Redis 要求脚本必须是“纯函数”,同样的输入必须产生同样的输出,所以math.random这类会被禁用或需要特殊处理。虽然新版本放宽了限制,但为了脚本能被正确复制到从节点,最好还是避免在脚本里用随机数和时间函数,需要的话通过参数传进去。
第三个坑是脚本里 KEYS 和 ARGV 用混了。有次我把 key 当成 ARGV 传,本地单机测试没问题,一上集群就报 CROSSSLOT,排查了半天才发现是参数位置传错了。记住:所有 key 必须通过 KEYS 传,其他参数通过 ARGV 传,这是铁律。
第四个坑是误以为事务能回滚。前面反复强调了,但还是要再说一遍:Redis 事务执行时出错不回滚,Lua 脚本执行到一半出错也不回滚。如果你的业务真的需要“全成功或全失败”,得在应用层做补偿,比如记录操作日志、失败后手动回滚,或者用支持事务回滚的其他存储方案。
6. 性能优化与生产环境实践建议
6.1 脚本复杂度控制与拆分策略
Lua 脚本虽然强大,但不要写成“万能脚本”。我见过有人把整个订单流程塞进一个几百行的 Lua 脚本,结果调试困难、执行慢、还容易超时。合理的做法是按原子性边界拆分:只有必须原子执行的部分才放进脚本,其他逻辑放在应用层。
比如下单流程里,“扣库存”和“写订单”如果必须原子,就放一个脚本;但“发通知”“记日志”这些不需要原子的,放脚本外面。脚本越短,执行越快,出问题的概率也越低。
另外,脚本里要避免KEYS *、SMEMBERS大集合、LRANGE大范围这类操作,它们的时间复杂度是 O(N),N 大了直接拖垮 Redis。如果确实需要遍历,考虑用SCAN分批在应用层做。
6.2 监控脚本执行耗时与慢查询
生产环境一定要监控脚本执行时间。Redis 的SLOWLOG会记录执行超过阈值的命令,Lua 脚本也在其中。配置slowlog-log-slower-than(单位微秒),比如设成 10000 就是 10 毫秒,超过就记下来。
127.0.0.1:6379> CONFIG SET slowlog-log-slower-than 10000 OK 127.0.0.1:6379> SLOWLOG GET 10通过慢查询日志能快速定位是哪个脚本、哪条命令拖慢了 Redis。我一般会定期看SLOWLOG,尤其是大促前,把潜在的性能问题提前解决掉。
还有个指标是INFO commandstats,能看到每个命令的调用次数和平均耗时,脚本对应的命令是eval和evalsha,可以对比它们的耗时来判断缓存是否生效。
6.3 主从复制与脚本传播机制
Lua 脚本在主从复制里的传播方式经历过变化。早期 Redis 是把脚本执行产生的写命令传播给从节点,这样从节点重放命令即可,保证主从一致。但这种方式在脚本里有随机性时会有问题,所以后来改成了直接传播脚本本身(或者 EVALSHA),从节点重新执行脚本。
理解这个机制的意义在于:脚本必须是确定性的,同样的输入在主从两边要产生同样的结果,否则主从数据会不一致。所以前面说的避免随机数、避免依赖系统时间,就是为了这个。如果你确实需要随机性,用redis.replicate_commands()显式开启命令传播模式,但这样又回到了老问题,得自己保证命令的确定性。
6.4 生产环境脚本管理规范
最后分享几条我在团队里推行的脚本管理规范,都是血泪教训换来的:
- 脚本统一版本管理:所有 Lua 脚本放在代码仓库里,有版本号,改动走 review,不能随手在客户端敲。
- 启动时预加载:应用启动时用
SCRIPT LOAD把所有脚本加载一遍,拿到 SHA1 缓存到本地,避免运行时才发现脚本没缓存。 - 降级逻辑必备:
EVALSHA失败要能自动回退到EVAL,并且处理NOSCRIPT错误。 - 超时保护:脚本里避免任何可能长时间运行的逻辑,必要时在应用层加超时控制。
- 灰度验证:新脚本上线前先在预发环境压测,确认耗时和正确性再上生产。
这套规范执行下来,我们线上基本没再出过脚本相关的故障。Redis 的事务和 Lua 脚本本身不复杂,复杂的是边界情况和生产环境的各种意外,把这些提前想到,用起来就很稳了。