主题
第十五章:Redis 事务与 Lua 脚本
Q46:Redis 的事务是什么?它和关系型数据库的事务有什么区别?
答:
Redis 事务通过 MULTI / EXEC / DISCARD / WATCH 一组命令实现。它的核心能力和关系型数据库(MySQL 等)的事务有本质区别,理解这个区别是正确使用 Redis 事务的前提。
一、Redis 事务的基本用法
bash
> MULTI # 开启事务
OK
> SET k1 v1 # 命令排队,不立即执行
QUEUED
> INCR counter # 命令排队
QUEUED
> EXEC # 一次性执行所有排队命令
1) OK
2) (integer) 1MULTI:开启事务,之后的所有命令被放入队列,不立即执行;EXEC:按顺序一次性执行队列里的所有命令;DISCARD:放弃队列,清空已排队的命令;WATCH key:监视某些 key,如果在EXEC之前这些 key 被其他客户端改动,EXEC会失败。
Python 里 redis-py 提供 pipeline 方式:
python
pipe = redis_client.pipeline()
pipe.set("k1", "v1")
pipe.incr("counter")
result = pipe.execute()二、Redis 事务的四个特征(对比 ACID)
关系型数据库事务遵循完整的 ACID,Redis 事务只具备其中的一部分:
| 特性 | 关系型数据库事务 | Redis 事务 |
|---|---|---|
| 原子性 | 保证:任一语句失败整体回滚 | 部分保证:命令排队阶段的语法错误会整体失败,但执行阶段的运行时错误不回滚 |
| 一致性 | 通过约束 + 原子性保证 | 需要应用自己保证,Redis 不主动维护约束 |
| 隔离性 | 多级隔离(读已提交、可重复读等) | 保证:事务内的命令连续执行,不会被别的客户端命令插队 |
| 持久性 | 通过 WAL、redo/undo 等保证 | 依赖 Redis 的持久化配置(RDB/AOF),且不保证实时落盘 |
关键的差异在于 Redis 事务的「原子性」是有限的:
排队阶段(
MULTI之后、EXEC之前):如果命令语法错误(命令不存在、参数个数不对),Redis 会在入队时报错,此时EXEC会整体失败,所有命令都不执行。这一层是「整体成功或整体失败」的。执行阶段(
EXEC时):如果某条命令在运行时出错(例如对 String 类型的 key 执行LPUSH,类型不符),Redis 不会回滚,这条命令报错,但前面和后面的命令照常执行。
bash
> MULTI
OK
> SET k1 v1
QUEUED
> LPUSH k1 a b # k1 是 String,运行时才会报类型错误
QUEUED
> INCR counter
QUEUED
> EXEC
1) OK
2) (error) WRONGTYPE Operation against a key holding the wrong kind of value
3) (integer) 1 # 这条仍然执行了,没有因为第 2 条报错而回滚所以 Redis 事务的本质是:把一批命令打包,保证它们在服务端不被其他客户端命令插队、按顺序连续执行,而不是提供「出错回滚」。
三、隔离性:为什么不会被插队
Redis 是单线程执行命令。EXEC 时,事务里的命令会在主线程里一次性、连续地执行完,中间不会处理其他客户端的命令。因此 Redis 事务天然具备「隔离性」——不需要像关系型数据库那样通过锁或 MVCC 实现。
四、什么时候用、什么时候不用
- 适合:需要把多条命令「原子地」作为一个整体、不想被其他客户端插队的场景(如批量写入、用 WATCH 做乐观锁);
- 不适合:需要「出错回滚」的场景。Redis 事务不提供回滚,如果需要更强的原子性(例如先判断再操作、出错要整体回滚),应该用 Lua 脚本(见 Q47)。
Q47:为什么说 Redis 事务不能替代 Lua 脚本?Lua 脚本的原子性是怎么保证的?
答:
Redis 事务和 Lua 脚本都能做「多命令组合」,但它们的约束力完全不同。核心区别在于:事务只是「打包不插队 + 有限回滚」,Lua 脚本是「真正的原子执行 + 支持逻辑分支」。
一、事务做不到的,Lua 脚本能做到
事务有两个致命局限:
不能根据中间结果做判断:事务的命令在
EXEC之前就全部排队好了,你无法「先读一个值,根据结果决定下一条命令是什么」。因为排队时命令是静态的,执行时不会中途根据前面的返回值改道。执行阶段出错不回滚:如 Q46 所说,运行时错误不会被回滚。
Lua 脚本正是为了解决这两点而生:
lua
-- 原子地「判断库存是否充足,充足才扣减」
local stock = tonumber(redis.call('GET', KEYS[1]) or '0')
if stock >= tonumber(ARGV[1]) then
redis.call('DECRBY', KEYS[1], ARGV[1])
return 1
else
return 0
end这段逻辑用事务表达不了:你需要「先读 stock,再决定是否 DECRBY」,而事务无法在排队阶段根据运行时值做分支。
二、Lua 脚本为什么是原子执行
Redis 从 2.6 起内置了 Lua 解释器。执行 EVAL 脚本时,Redis 会把整个脚本作为一个整体,在单线程里一次性执行完,期间不处理任何其他客户端的命令。
text
客户端 A 执行 EVAL(一段 Lua 脚本)
│
└─► Redis 主线程执行脚本
├─ 脚本里的 GET stock
├─ 脚本里的 if 判断
└─ 脚本里的 DECRBY stock
(这整个过程,其他客户端命令全部排队等待)
客户端 B 的命令 ──► 等待 A 的脚本执行完 ──► 才被处理因为 Redis 单线程执行命令,脚本内多条命令之间天然不会被插队,也不存在「读到一半被别的命令改动」的竞态。这就是 Lua 脚本原子性的来源:不是靠锁,而是靠单线程 + 脚本整体执行。
三、脚本本身的设计原则
Lua 脚本虽然原子,但有代价:脚本执行期间会阻塞主线程,其他所有命令都要等它。因此脚本必须遵守:
- 快:脚本要短小,避免耗时操作(大循环、O(N) 遍历大集合、网络调用);
- 纯:不要在脚本里做耗时的业务计算,脚本只做「必要的读 + 判断 + 写」;
- 少写:涉及多 key 的脚本,要注意 Cluster 下所有 key 必须在同一个 slot(用 hash tag 保证)。
四、事务 vs Lua 脚本对比
| 维度 | Redis 事务(MULTI/EXEC) | Lua 脚本(EVAL) |
|---|---|---|
| 能否按中间结果分支 | 不能 | 能(if / 循环 / 局部变量) |
| 运行时错误是否回滚 | 否(继续执行后续命令) | 部分(脚本报错时,已执行的写命令仍不会回滚,但可返回错误) |
| 原子性强度 | 打包不插队 + 有限回滚 | 真正的整体原子执行 |
| 阻塞主线程 | 会(短暂) | 会(脚本越久阻塞越久) |
| 典型场景 | 批量写入、WATCH 乐观锁 | 库存扣减、分布式锁释放、复杂条件写入 |
| 复杂度 | 低 | 中(要写 Lua,注意 Cluster key 分布) |
一句话总结:事务适合「把几条没有逻辑依赖的命令打包」,Lua 脚本适合「需要根据中间结果做判断、并保证整体原子」的场景。两者不是替代关系,Lua 脚本是事务能力缺失部分的补充。
Q48:用 Redis 实现分布式锁,为什么释放锁一定要用 Lua 脚本?如何防止误删别人的锁?
答:
这是 Lua 脚本最经典、最重要的应用场景之一。分布式锁的实现通常是这样:
python
import uuid
def acquire_lock(lock_key: str, timeout_seconds: int = 10):
lock_token = str(uuid.uuid4())
# 抢锁:SET NX EX 原子地「不存在才写入 + 设置过期」
ok = redis_client.set(lock_key, lock_token, nx=True, ex=timeout_seconds)
return lock_token if ok else None抢锁用 SET ... NX EX 是原子的,没问题。问题出在释放锁的时候。
一、为什么释放锁不能简单地 DEL
一个常见的错误实现是:拿到锁后,用完直接 DEL lock_key。
python
# 危险的释放方式
def release_lock_wrong(lock_key: str):
redis_client.delete(lock_key)这样会有「误删别人锁」的致命风险:
text
时间线
──────────────────────────────────────────────────────────────
t0 客户端 A 抢到锁(lock_token = A-token)
t5 客户端 A 的业务执行时间过长,锁在 t6 就自动过期了
t6 锁过期,自动释放
t7 客户端 B 抢到锁(lock_token = B-token)
t8 客户端 A 才执行到「释放锁」这一步,直接 DEL lock_key
└─► 把 B 的锁删掉了!B 还在以为自己是持锁者
──────────────────────────────────────────────────────────────根因是:A 的业务耗时超过了锁的 TTL,锁已经易主,但 A 仍然 DEL,把别人的锁删了。要避免,就必须在删除前确认这把锁还是自己的。
二、判断 + 删除必须原子:用 Lua 脚本
仅靠「先 GET 判断、再 DEL」也不行,因为这两步之间可能被其他命令插入:
text
客户端 A:GET lock_key → 返回 "A-token",确认是自己的锁
(此时锁刚好过期,B 抢到,写入 "B-token")
客户端 A:DEL lock_key → 删掉的其实是 B 的锁所以「判断 token」和「删除」这两个动作必须合并成一个原子操作。这就是 Lua 脚本的价值:
lua
-- 只有 value 匹配(锁是自己的)才删除
if redis.call('GET', KEYS[1]) == ARGV[1] then
return redis.call('DEL', KEYS[1])
else
return 0
endPython 调用:
python
UNLOCK_SCRIPT = """
if redis.call('GET', KEYS[1]) == ARGV[1] then
return redis.call('DEL', KEYS[1])
else
return 0
end
"""
def release_lock(lock_key: str, lock_token: str):
return redis_client.eval(UNLOCK_SCRIPT, 1, lock_key, lock_token)eval 的参数里:
UNLOCK_SCRIPT:Lua 脚本内容;1:表示后面有 1 个参数属于 Redis key(进入 Lua 的KEYS);lock_key:进入KEYS[1];lock_token:进入ARGV[1]。
这样「判断 GET 的值是否等于自己的 token → 是则 DEL」就在一条 Lua 里原子完成,杜绝了误删。
三、分布式锁的完整正确姿势
text
抢锁:SET lock_key <random_token> NX EX <ttl>
│
├─ 返回 OK → 抢锁成功,记住 token
└─ 返回 nil → 抢锁失败
释放:Lua 脚本(GET 判断 token 相等才 DEL)
└─ 保证「只有持锁者才能释放自己的锁」关键点:
- token 要随机且唯一(如 UUID),用于区分到底是谁的锁;
- 抢锁必须
NX+EX(原子地「不存在才写 + 设过期」); - 释放必须用 Lua(判断 + 删除原子化);
- 锁必须设置过期时间,否则持锁者崩溃会导致死锁;
- 生产环境还要考虑锁续期(看门狗)和可重入,这是 Redisson 等成熟库做的事。
四、反例:不用 Lua 的几种错误写法
| 错误写法 | 后果 |
|---|---|
释放时直接 DEL | 业务超时导致锁易主后,误删别人的锁 |
先 GET 判断再 DEL | 两步之间非原子,仍可能误删 |
| 用固定字符串作为 token | 无法区分锁是谁的,任何客户端都能判断成功 |
抢锁用 SETNX 不设过期 | 不设 TTL,持锁者崩溃后锁永远无法释放(死锁) |
释放时比较 != 就抛异常 | 业务超时后 A 会误以为自己释放失败,逻辑混乱 |
Q49:Lua 脚本在 Redis Cluster 下有哪些限制?如何正确使用?
答:
Redis Cluster 把数据按 key 分布到不同分片(slot)。Lua 脚本如果要操作多个 key,就会遇到一个集群架构下的硬约束:脚本内所有 key 必须落在同一个 slot,否则执行会报错。
一、Cluster 下 Lua 脚本的限制
限制 1:多 key 必须同 slot
Redis Cluster 用 CRC16(key) % 16384 计算每个 key 的 slot。一个 Lua 脚本里如果用到的多个 key 散落在不同 slot,EVAL 会报 CROSSSLOT 错误:
lua
-- 假设 goods:1001 和 order:1001 不在同一个 slot,这样写会报错
redis.call('GET', KEYS[1]) -- goods:1001
redis.call('GET', KEYS[2]) -- order:1001原因:脚本要做到原子执行,而原子执行的单位是单个分片(单节点主线程),跨分片无法保证整体原子。
限制 2:脚本不能跨节点执行
一个脚本要么完整在某个节点执行,要么执行不了。不能指望 Redis 把脚本拆开分发给多个节点,再汇总结果。
限制 3:KEYS 必须显式声明
Cluster 下(以及为了脚本可移植性),脚本访问的所有 key 都必须通过 KEYS 传入,而不是写死在脚本字符串里。这样 Redis 才能在做路由校验时识别出脚本用到了哪些 key,判断是否同 slot。如果把 key 硬编码在脚本内部或通过 ARGV 混入,Redis 无法提前识别,可能绕过校验造成错误。
二、如何正确使用:hash tag 强制同 slot
解决办法是使用 hash tag:把多个 key 用 {} 括住同一个字符串,Redis 计算 slot 时只取 {} 里的内容,从而保证这些 key 落在同一个 slot。
text
普通 key:goods:1001 → 按完整字符串算 slot
order:1001 → 按完整字符串算 slot(大概率不同 slot)
hash tag key:{1001}goods → 只按 "1001" 算 slot
{1001}order → 也只按 "1001" 算 slot(保证同 slot)python
EVAL 脚本时,把 key 设计成带相同 hash tag:
KEYS[1] = "{order:1001}:stock"
KEYS[2] = "{order:1001}:locked"这样脚本里操作多个 key,只要它们带相同的 {...} 前缀,就能保证同 slot,脚本可以正常原子执行。
三、其他注意事项
| 注意点 | 说明 |
|---|---|
| 脚本要短小、快速 | 脚本执行期间阻塞所在分片的主线程,太久会影响该分片所有命令 |
| 不要依赖随机 / 时间等不确定因素 | 脚本里用 TIME、随机数会导致脚本不可复现,主从复制(AOF 重放)可能不一致 |
| 避免在脚本里做 O(N) 大遍历 | 大集合遍历会长时间阻塞主线程 |
| 脚本需要在所有节点加载 | 新脚本首次执行时,每个分片节点都要加载脚本,注意 EVALSHA 缓存未命中的情况 |
用 EVALSHA 减少网络传输 | 脚本内容较大时,先用 SCRIPT LOAD 加载得到 SHA,再用 EVALSHA 执行,省流量 |
| Cluster 下避免跨 slot 多 key | 要么用 hash tag,要么把逻辑拆成多个单 key 脚本 |
四、总结
- Redis Cluster 下 Lua 脚本的唯一硬约束是「脚本内 key 必须在同一 slot」,否则报
CROSSSLOT; - 解决办法是用 hash tag(
{...})把相关 key 归到同一 slot; - 所有 key 必须通过
KEYS显式传入,不要硬编码在脚本里; - 脚本本身要短、快、确定性,避免阻塞和主从不一致。
小结
- Redis 事务(MULTI/EXEC)本质是「打包命令、保证不插队」,只有有限回滚(语法错误整体失败、运行错误不回归);事务不能按中间结果做分支。
- Lua 脚本靠「单线程 + 脚本整体执行」实现真正的原子性,能根据中间结果做判断,是事务能力缺失部分的补充。
- 释放分布式锁必须用 Lua:因为「判断 token + 删除」两步必须原子,否则业务超时会误删别人的锁。
- Cluster 下 Lua 脚本要保证脚本内 key 同 slot(用 hash tag),key 通过
KEYS显式传入,脚本保持短、快、确定性。