Redis 事务和 Lua 脚本的区别?

#中间件#面试八股

Redis 事务和 Lua 脚本的区别?

结论:Redis 事务(MULTI/EXEC)只保证"命令打包、连续执行、不被其他客户端插入",不保证原子回滚;Lua 脚本在服务端一次执行完,执行期间整段阻塞、天然原子且能读写中间结果。需要"读-判断-写"的逻辑用 Lua,需要"批量提交、乐观锁"的用 WATCH + MULTI,两者都不能替代数据库事务的隔离与回滚语义。

一、能力对照

维度MULTI/EXEC 事务Lua 脚本
原子性命令连续执行,中间不插入其他命令整段脚本原子执行
回滚不支持,运行时错误只影响该条命令不支持,出错即中断并返回错误
条件逻辑不支持,命令必须全部入队支持 if/for,可读中间结果再决定
网络往返1 次(EXEC 时统一发送)1 次(EVAL/EVALSHA)
乐观锁支持 WATCH,被改则 EXEC 返回 nil可在脚本内自行比对值
阻塞风险单条命令级别整段脚本,慢脚本会卡住所有请求
集群限制所有 key 需同槽所有 key 需同槽
调试简单需 SCRIPT DEBUG / 日志

二、事务的真实语义

MULTI            → 之后命令入队,返回 QUEUED
SET k1 v1        → 入队
INCR k2          → 入队(若 k2 是字符串,入队时发现不了)
EXEC             → 依次执行
                     ├─ 命令语法错/不存在:整个事务在入队阶段就标记失败,EXEC 报错,全部不执行
                     └─ 运行时错误(如对字符串 INCR):该条报错,其余命令照样执行

所以 Redis 事务更准确的名字是"命令批处理 + 隔离性":它保证不会被别的客户端命令插队,但不保证"要么全成功要么全失败"。这也解释了为什么"Redis 不支持回滚"——错误通常源于编程缺陷,应在开发阶段暴露,而不是靠运行时回滚掩盖。

WATCH stock:1001
GET stock:1001            # 读到 10
MULTI
DECRBY stock:1001 1
EXEC                       # 若期间 stock:1001 被改过,返回 nil,需要业务重试

三、Lua 的典型用法与纪律

-- 库存扣减:读、判断、写在一个脚本内完成
local stock = tonumber(redis.call('HGET', KEYS[1], 'qty'))
if stock == nil then return -1 end
if stock < tonumber(ARGV[1]) then return 0 end
redis.call('HINCRBY', KEYS[1], 'qty', -tonumber(ARGV[1]))
return 1
// 生产用 EVALSHA + 脚本预加载,避免每次传输脚本体
String sha = redis.execute(new DefaultRedisScript<>(LUA, String.class),
                           Collections.emptyList()); // 实际用 scriptLoad
Object r = redis.execute(new DefaultRedisScript<>(LUA, Long.class),
                         List.of("stock:{1001}"), "1");

必须遵守的三条纪律:

  • 脚本要短:超过 1 万次循环就会明显阻塞主线程,宁可拆成多次调用;
  • 不要用随机/时间函数做写操作:5.0 之后脚本默认 effects=replication,Redis 会把脚本产生的确定性命令传播给从库,脚本里用 TIME、RANDOMKEY 可能造成主从数据不一致(需要 redis.replicate_commands() 或改用函数);
  • 集群下所有 key 同槽:用 hash tag {1001} 固定槽位,否则直接报 CROSSSLOT。

四、常见追问

  • 问:为什么 Redis 事务不支持回滚?答:运行时错误多来自类型误用等编码缺陷,回滚无法修复缺陷,反而掩盖问题;且实现回滚需要保存旧值,代价高。设计上选择"快速失败 + 开发期发现"。
  • 问:MULTI 中能执行 GET 的结果做判断吗?答:不能,命令只是入队,EXEC 才执行,你拿不到中间结果。需要条件逻辑就用 Lua。
  • 问:WATCH 和分布式锁有什么区别?答:WATCH 是乐观锁,适合低冲突场景,冲突时让业务重试;分布式锁是悲观锁,适合高冲突且重试代价大的场景。
  • 问:Lua 脚本能保证"只执行一次"吗?答:不能。脚本原子执行但如果客户端超时,服务端可能已执行,客户端重试就会执行两次;需要幂等键配合。
  • 问:EVAL 和 EVALSHA 怎么选?答:EVAL 每次传脚本全文,浪费带宽;EVALSHA 传 40 位 sha1,配合启动时 SCRIPT LOAD。注意从库重启后脚本缓存可能缺失,要能降级回 EVAL。
  • 问:Redis 7.0 的 Function 和 Lua 是什么关系?答:Function 是脚本的服务端持久化形态(FUNCTION LOAD),脚本随数据一起复制与持久化,不再依赖客户端缓存 sha,适合多客户端共享逻辑。
  • 问:事务里能做自增并读回结果吗?答:INCR 入队后 EXEC 返回的是每条命令的结果数组,可以在 EXEC 之后拿到新值;但无法在"入队前"根据当前值做分支。
  • 问:DISCARD 与 EXEC 失败有什么区别?答:DISCARD 主动清空队列、放弃执行;EXEC 因 WATCH 冲突返回 nil,同样不执行任何命令,但需要业务决定是否重试。
  • 问:Lua 脚本可以用 KEYS * 吗?答:语法上可以,但会遍历全库并阻塞主线程,且与"脚本要短"的纪律冲突;生产脚本只应操作 KEYS[] 中显式传入的 key。

小结

  • 事务 = 批量 + 隔离,无回滚;Lua = 原子函数,可带逻辑,但有阻塞与同槽限制。
  • "读-判断-写"必须用 Lua 或 WATCH,单靠 MULTI 做不到条件分支。
  • 真正的强事务仍在数据库侧,Redis 侧要做的是幂等 + 补偿,而不是追求回滚。
笔记加载中…