"把多个命令捆在一起执行且不被插队"是键值门派的原子性需求,Redis 给了两把工具:MULTI/EXEC 事务与 Lua 脚本。本节拆两者的语义细节(Redis 事务为什么不支持回滚)、WATCH 乐观锁的用法,并以秒杀扣库存为例走一个完整的 Lua 实战。它直接呼应 3.4 的并发控制与 4.3 的条件更新——同一类问题,三种产品给出了三种形态。
MULTI 开启事务后,客户端的后续命令不立即执行而是入队,EXEC 统一提交,期间其他客户端的命令不会插队——这就是 Redis 事务的原子性:要么全执行,要么全不执行(入队阶段出错时)。
关键的语义边界:命令入队时的语法错误(如拼错命令名)会导致整个事务在 EXEC 时被拒绝,一个都不执行;但命令执行时的逻辑错误(如对字符串执行列表命令)只让该条失败,其余照常执行,已执行的也不会回滚。官方的理由是执行期错误属于应用 bug,回滚机制的复杂度与其防住的错误不匹配——这与关系型数据库的 ACID 期待差距很大,用之前必须接受这个语义。
MULTI SET cart:1001:lock 1 EX 10 # 入队 INCR counter:x # 入队 EXEC # 两条一起执行
事务提交前检查指定键是否被改过——WATCH 给了 MULTI/EXEC 检查后再设置(check-and-set)的能力,语义与 4.3 的条件更新同构:
WATCH stock:88312 # 盯住库存键 GET stock:88312 # 读当前值,业务判断 MULTI DECR stock:88312 # 入队扣减 EXEC # 若盯住的键已被他人修改,EXEC 返回空,需整体重试
Lua 脚本是更顺手的方案:脚本作为一个整体在服务端原子执行(单线程模型保证中间不会插入其他命令),还能携带条件逻辑、减少网络往返。生产实践里,Lua 的使用频率远高于 MULTI/EXEC——它既原子又可编程,还能把"判断 + 修改"压到一次往返。
-- KEYS[1]=库存键 ARGV[1]=购买数量 ARGV[2]=用户限购键前缀 local stock = tonumber(redis.call('GET', KEYS[1]) or '-1') if stock < tonumber(ARGV[1]) then return -1 -- 库存不足 end local bought = tonumber(redis.call('GET', ARGV[2] .. KEYS[1]) or '0') if bought >= 2 then return -2 -- 超出限购 end redis.call('DECRBY', KEYS[1], ARGV[1]) redis.call('INCRBY', ARGV[2] .. KEYS[1], ARGV[1]) return 1 -- 扣减成功
背景:万件库存商品,瞬时十万并发抢购,要求:不超卖、单用户限购两件、库存判空与扣减原子。
操作:第一步把库存预加载进 Redis(SET stock:88312 10000);第二步上线上述 Lua 脚本,应用层只传键与参数,按返回值分支(-1 售罄、-2 限购、1 成功);第三步成功者异步投递订单创建消息(5.5 Stream),把库存释放的回补做成补偿任务;第四步压测验证,监控脚本执行耗时。
结果:十万并发下零超卖、限购准确;Redis 脚本执行均值 0.08 毫秒,瓶颈反而转移到了网关与订单链路;活动结束库存精确归零。
解读:三个设计要点支撑了正确性——原子性交给单线程(脚本内逻辑再复杂也不会被并发撕裂);回补走补偿而非事务(Redis 没有跨键回滚,3.4 的补偿思想在这里落地);状态回查幂等(补偿重试靠限购键的幂等语义兜底)。另注意脚本保持短小:Lua 执行期间阻塞整个实例,脚本里写循环就是自造阻塞。
变式:若库存单位是多个仓(多键原子),Lua 同样胜任(KEYS 传多个键);但集群模式下多键必须同槽(5.8 的 hash tag 技巧),跨槽的键组合要么重新设计键、要么退回应用层两步提交加补偿。
第一个是期望事务回滚:执行期错误后已执行的命令不会撤销,靠"入队前校验 + 脚本内判断"来避免错误路径,而不是指望回滚。第二个是脚本里做重活:循环百万次或调用慢命令,单线程被拖住全实例排队;脚本只做轻逻辑,重活交回应用层。第三个是EVALSHA 与脚本草稿不同步:脚本缓存被清后 EVALSHA 报"找不到脚本",客户端要实现"先 EVALSHA、失败退回 EVAL 并重试 SHA"的标准封装。第四个是把 Lua 当分布式锁的全部:锁还需考虑持有者崩溃(过期时间兜底)、业务超时(续期机制),Lua 只保证判断与写入的原子那一半。
需求:秒杀场景下扣减库存,要求不超卖、高并发、且"判断库存"与"扣减"之间不能被其他请求插入。这正是 Lua 脚本的用武之地——Redis 单线程执行,脚本天然原子。
-- seckill.lua:KEYS[1]=库存键,ARGV[1]=扣减数量,ARGV[2]=用户去重键 local stock_key = KEYS[1] local deduct = tonumber(ARGV[1]) local user_key = ARGV[2] -- 1) 一人一单:已参与过直接返回 if redis.call('EXISTS', user_key) == 1 then return {err = 'DUPLICATED'} end -- 2) 判断库存是否充足 local stock = tonumber(redis.call('GET', stock_key) or '0') if stock < deduct then return {err = 'SOLD_OUT'} end -- 3) 扣减库存并标记用户已参与(同一脚本内完成,原子生效) redis.call('DECRBY', stock_key, deduct) redis.call('SET', user_key, '1', 'EX', 86400) return stock - deduct
# 加载脚本并调用(返回脚本 SHA,后续用 EVALSHA 复用,省去每次传脚本体) redis-cli SCRIPT LOAD "$(cat seckill.lua)" redis-cli EVALSHA <sha> 1 seckill:stock:5501 1 seckill:user:90001:5501
| 维度 | MULTI/EXEC | Lua 脚本 |
|---|---|---|
| 原子性 | 命令序列连续执行,不被插入 | 同样原子,且脚本整体执行 |
| 中间结果 | 无法读取,只能在 EXEC 后拿结果 | 可以读取并据此分支 |
| 条件逻辑 | 不支持,只能无条件排队 | 支持 if / 循环 |
| 网络往返 | 多条命令多次往返 | 一次调用完成 |
| 风险 | 较低 | 脚本太长或死循环会阻塞整个实例 |
# MULTI/EXEC:适合"一批命令一起执行、不需要中间判断"的场景 MULTI INCR page:view:8801 EXPIRE page:view:8801 3600 EXEC
SCRIPT KILL 只能终止未执行写操作的脚本,已写入的脚本只能重启或等待——因此脚本复杂度必须可控。事务与脚本怎么选:只是想让几条命令一起生效、不需要中间判断,用 MULTI/EXEC 足够;需要"读—判断—写"的原子组合(秒杀、限流、分布式锁释放的校验),Lua 脚本是唯一简洁的答案。
原子性有了,下一节上高可用:主库挂了,哨兵怎么接班。