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 侧要做的是幂等 + 补偿,而不是追求回滚。