Skip to content

事务与 Lua ​

Redis 的事务与 Lua 脚本都能把多条命令组合起来执行,但机制不同:事务强调「入队后一起执行」,Lua 强调「服务端原子脚本」。本章对比两者,讲清各自适用场景和生产中的正确用法。

1. MULTI / EXEC ​

Redis 事务通过 MULTI、EXEC、DISCARD 实现:

bash
MULTI              # 开启事务
SET a 1            # 入队
INCR a             # 入队
GET a              # 入队
EXEC               # 一次性执行所有命令

1.1 执行流程 ​

txt
客户端 MULTI → 服务端标记事务开始
客户端发送命令 → 服务端入队(不执行),返回 QUEUED
客户端 EXEC → 服务端按顺序执行所有入队命令,一次性返回结果

1.2 错误处理 ​

Redis 事务的错误分为两类:

错误类型触发时机事务行为
语法错误入队时(命令不存在、参数错误)整个事务不执行
运行时错误执行时(类型不匹配、值不能转换为数字等)出错命令报错,其他命令继续执行
bash
MULTI
SET a 1
INCR a            # OK,入队
INCR b            # OK,入队(b 不存在,执行时会自动初始化为 0 再 incr)
EXEC
# 返回:OK, 2, 1(全部成功)

MULTI
SET a 1
LPUSH a x         # OK,入队(a 是 string,LPUSH 执行时类型报错)
EXEC
# 返回:OK, ERR(LPUSH 报错,但 SET a 1 已执行,不会回滚)

1.3 不回滚 ​

Redis 事务不支持回滚:执行到一半出错,前面已执行的命令不会撤销。这是 Redis 与关系型数据库事务的本质区别。

为什么不支持回滚?

原因说明
性能回滚需要记录操作前的状态,增加内存和 CPU 开销
简单Redis 追求简单,回滚逻辑复杂且容易出 bug
设计取向Redis 不维护用于撤销已执行命令的回滚机制;出错后是否重试取决于命令语义及客户端能否确认操作是否已执行

2. WATCH 乐观锁 ​

WATCH 实现乐观锁:监视一个或多个 key,如果这些 key 在 EXEC 前被其他客户端修改,则事务放弃执行。

bash
SET balance 1000       # 示例初值
WATCH balance          # 监视余额
GET balance            # 客户端读取 1000,并在本地计算新值
MULTI
SET balance 900        # 客户端计算出的新值
EXEC                   # 若 balance 在 WATCH 后被改过,返回 nil(放弃)

2.1 CAS 模式 ​

WATCH 实现的是 CAS(Compare-And-Swap):

txt
WATCH key → 读取值 → 修改 → EXEC
  → key 没被改过:执行成功
  → key 被改过:返回 nil,重试

2.2 局限 ​

局限说明
高冲突下性能差频繁重试浪费 CPU
只能监视同一连接不能跨连接 WATCH
不适合热点 key热点 key 频繁被改,事务几乎永远失败

WATCH 适合条件简单且冲突较低的场景。高冲突场景可以考虑 Lua 脚本,把读取、判断和写入放在服务端一次执行,减少客户端重试;Lua 仍需处理超时、脚本错误和 Key 过期等边界。

3. Lua 脚本 ​

Lua 脚本在 Redis 服务端执行,整个过程原子,中间不会被其他命令打断。这里的「原子」指执行期间不插入其他命令,不等于出错会自动回滚:脚本中途报错时,已执行的写入不会撤销。

3.1 基本用法 ​

bash
# EVAL "脚本" numkeys key [key ...] arg [arg ...]
EVAL "return redis.call('get', KEYS[1])" 1 mykey

# 缓存脚本,返回 SHA1
SCRIPT LOAD "return redis.call('get', KEYS[1])"

# 用 SHA1 执行(节省带宽)
EVALSHA "4e6d8fc8bb01276962cce5371fa795a7763657ae" 1 mykey

脚本缓存不持久:服务重启或故障切换后缓存会丢失,客户端收到 NOSCRIPT 错误时应重新 SCRIPT LOAD。

3.2 redis.call 与 redis.pcall ​

方法区别
redis.call()命令出错时,脚本终止并返回错误
redis.pcall()命令出错时返回错误对象,不向脚本抛出异常
lua
-- 原子扣减库存
local raw = redis.call('get', KEYS[1])
if raw == false then
    return -1  -- key 不存在
end
local stock = tonumber(raw)
if stock == nil or stock % 1 ~= 0 then
    return -2  -- 库存值非法,必须是整数
end
if stock > 0 then
    redis.call('decr', KEYS[1])
    return 1   -- 扣减成功
else
    return 0   -- 库存不足
end

3.3 典型场景 ​

分布式锁释放(校验 + 删除原子化):

lua
if redis.call('get', KEYS[1]) == ARGV[1] then
    return redis.call('del', KEYS[1])
else
    return 0
end

限流器(滑动窗口原子化):

lua
local key = KEYS[1]
local limit = tonumber(ARGV[1])
local window = tonumber(ARGV[2])    -- 窗口长度,单位秒
local now = tonumber(ARGV[3])       -- 当前时间戳,单位毫秒
local member = ARGV[4]              -- 本次请求的唯一标识

redis.call('ZREMRANGEBYSCORE', key, 0, now - window * 1000)
local count = redis.call('ZCARD', key)
if count < limit then
    redis.call('ZADD', key, now, member)  -- score 存时间戳,member 必须唯一
    redis.call('EXPIRE', key, window)     -- TTL 用秒,与窗口长度一致
    return 1
else
    return 0
end

周期序列号生成(INCR 原子递增):

lua
local id = redis.call('incr', KEYS[1])
if id == 1 then
    redis.call('expire', KEYS[1], 86400)  -- 首次设置过期时间
end
return id

这段代码只保证同一个 Key 存续期间递增。Key 过期、被删除或重置后,编号会从 1 重新开始;它不能单独承担跨实例、跨周期的全局唯一 ID。

3.4 Lua 脚本的注意事项 ​

要点说明
原子执行脚本执行期间不被打断,阻塞其他命令
不要写长时间脚本阻塞主线程,导致所有客户端超时
集群限制脚本内所有 key 必须在同一个槽(用哈希标签)
脚本复用用 SCRIPT LOAD + EVALSHA 避免重复传输脚本内容

3.5 脚本调试 ​

bash
# Redis 3.2+ 支持 Lua 脚本调试
redis-cli --ldb --eval script.lua key1 key2 , arg1 arg2

# 调试命令
# s (step) - 单步执行
# n (next) - 执行到下一行
# c (continue) - 继续执行到结束
# p var - 打印变量
# b N - 在第 N 行设置断点

4. 选择事务、Lua 或 Pipeline ​

4.1 事务与 Lua 的差异 ​

维度事务(MULTI/EXEC)Lua 脚本
原子性命令序列原子执行脚本整体原子执行
逻辑判断不支持支持(if/else/循环)
回滚不支持不支持
条件执行用 WATCH 乐观锁脚本内直接判断
性能中(多次网络往返入队)高(脚本一次发送)
调试不支持支持(ldb)
适用场景简单批量命令复杂读-判断-写逻辑

4.2 Pipeline、事务与 Lua 的差异 ​

维度Pipeline事务Lua
核心目的减少网络往返命令原子执行服务端原子脚本
原子性否是是
逻辑判断否否是
网络开销低(批量)中(入队+执行)低(一次发送)
主线程占用大批量命令仍可能连续占用命令逐条执行,大批量也可能占用脚本执行期间阻塞

选型建议:

场景推荐
把多条无关命令打包执行Pipeline(不要用事务)
需要原子性 + 简单序列事务(MULTI/EXEC)
需要原子性 + 条件判断Lua 脚本
需要乐观锁(低冲突)WATCH + 事务
需要乐观锁(高冲突)Lua 脚本

三者经常被混淆。记住:Pipeline 是网络优化,事务是命令打包,Lua 是服务端编程。Pipeline 不保证原子性,事务内部不能写 if/else 分支,条件判断靠 WATCH 乐观锁在 EXEC 时完成,Lua 两者都能做但会阻塞主线程。

Released under the MIT License.