Skip to content

第十五章: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) 1
  • MULTI:开启事务,之后的所有命令被放入队列,不立即执行;
  • 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 事务的「原子性」是有限的:

  1. 排队阶段(MULTI 之后、EXEC 之前):如果命令语法错误(命令不存在、参数个数不对),Redis 会在入队时报错,此时 EXEC 会整体失败,所有命令都不执行。这一层是「整体成功或整体失败」的。

  2. 执行阶段(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 脚本能做到 ​

事务有两个致命局限:

  1. 不能根据中间结果做判断:事务的命令在 EXEC 之前就全部排队好了,你无法「先读一个值,根据结果决定下一条命令是什么」。因为排队时命令是静态的,执行时不会中途根据前面的返回值改道。

  2. 执行阶段出错不回滚:如 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 脚本虽然原子,但有代价:脚本执行期间会阻塞主线程,其他所有命令都要等它。因此脚本必须遵守:

  1. 快:脚本要短小,避免耗时操作(大循环、O(N) 遍历大集合、网络调用);
  2. 纯:不要在脚本里做耗时的业务计算,脚本只做「必要的读 + 判断 + 写」;
  3. 少写:涉及多 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
end

Python 调用:

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)
                  └─ 保证「只有持锁者才能释放自己的锁」

关键点:

  1. token 要随机且唯一(如 UUID),用于区分到底是谁的锁;
  2. 抢锁必须 NX + EX(原子地「不存在才写 + 设过期」);
  3. 释放必须用 Lua(判断 + 删除原子化);
  4. 锁必须设置过期时间,否则持锁者崩溃会导致死锁;
  5. 生产环境还要考虑锁续期(看门狗)和可重入,这是 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 显式传入,脚本保持短、快、确定性。