MULTI/EXEC、Lua 与 Function
第27章 MULTI/EXEC、Lua 与 Function:Redis 事务与脚本机制
27.1 事务的需求与背景
Redis 是单线程命令执行模型,理论上每条命令已经是原子的。但实际业务中经常需要"多步操作的原子性":先读余额再扣款,先检查库存再下单。如果步骤之间被其他客户端插入,就会导致数据不一致。Redis 提供了三种解决方案:
- MULTI/EXEC:简单事务,将多条命令打包原子执行
- Lua 脚本(EVAL):在服务端执行任意逻辑,天然原子
- Function(Redis 7+):Lua 的持久化版本,库化管理
27.2 MULTI/EXEC 事务
27.2.1 基本用法
# 开始事务
MULTI
# 返回:OK
# 加入命令队列
SET balance 100
# 返回:QUEUED
DECRBY balance 30
# 返回:QUEUED
SET audit:log "deducted 30"
# 返回:QUEUED
# 执行所有命令
EXEC
# 返回:
# 1) OK
# 2) (integer) 70
# 3) OK
# 取消事务
MULTI
SET key value
DISCARD # 清空队列,退出事务
27.2.2 内部实现
当客户端发送 MULTI 后,server 将该 client 标记为 CLIENT_MULTI 状态。后续命令不再立即执行,而是追加到 client->mstate.commands 数组(multiCmd 结构体数组):
/* 事务命令队列结构(server.h) */
typedef struct multiCmd {
robj **argv; /* 命令参数数组 */
int argc; /* 参数数量 */
struct redisCommand *cmd; /* 命令函数指针 */
} multiCmd;
typedef struct multiState {
multiCmd *commands; /* 命令队列数组 */
int count; /* 队列长度 */
int cmd_flags; /* 命令标志聚合 */
int minreplicas; /* EXEC 时的最小副本要求 */
time_t minreplicas_timeout;
} multiState;
EXEC 被调用时,主线程依次执行 mstate.commands 中的所有命令,期间不处理其他客户端请求,保证原子性。执行完毕后清空 mstate,client 退出 CLIENT_MULTI 状态。
27.2.3 原子性的真实含义
Redis 事务的"原子性"与 ACID 数据库的含义不同:
| 特性 | Redis MULTI/EXEC | ACID 数据库事务 |
|---|---|---|
| 隔离性(Isolation) | 是:执行期间不插入其他命令 | 是(各隔离级别) |
| 原子性(执行连续) | 是:所有命令连续执行 | 是 |
| 原子性(全成功/全失败) | 否:某条失败,其余继续 | 是:回滚 |
| 持久性(Durability) | 取决于持久化配置 | 是(WAL/redo log) |
语法错误 vs 运行时错误:
MULTI
SET key value
INCR key value extra_arg # 语法错误:INCR只接受一个参数
EXEC
# 返回 EXECABORT:事务因语法错误被拒绝,所有命令都不执行
MULTI
SET mykey "hello"
INCR mykey # 运行时错误:不能对字符串执行 INCR
SET another "world"
EXEC
# 返回:
# 1) OK ← SET 成功
# 2) WRONGTYPE ← INCR 失败(字符串不能自增),但事务继续
# 3) OK ← SET 成功
这意味着 Redis 事务不支持回滚。语法错误在 QUEUED 阶段就被发现;运行时错误只能影响单条命令,无法撤销已执行的命令。
27.2.4 WATCH 乐观锁
WATCH 是 MULTI/EXEC 的乐观并发控制扩展,用于实现 CAS(Check-And-Set)语义:
WATCH balance # 监视 balance key
# 读取当前值(事务外)
balance = GET balance # 返回 100
MULTI
DECRBY balance 30 # QUEUED
EXEC
# 若从 WATCH 到 EXEC 期间 balance 被其他客户端修改,返回 nil(事务失败)
# 否则正常执行,返回结果数组
WATCH 的内部机制:
/* server 维护全局 watched_keys 字典 */
/* dict: key名 → 监视该key的client列表 */
server.watched_keys
/* client 维护自己监视的 key 集合 */
client->watched_keys
/* 当任何命令修改 key 时,调用 signalModifiedKey() */
void signalModifiedKey(redisDb *db, robj *key) {
touchWatchedKey(db, key);
/* 将所有监视该key的client标记为 CLIENT_DIRTY_CAS */
}
/* EXEC 执行前检查 */
if (c->flags & CLIENT_DIRTY_CAS) {
/* 事务失败,返回 nil */
execCommandAbortTransaction(c);
return;
}
Python 中的 WATCH 使用模式(CAS 转账):
import redis
from redis.exceptions import WatchError
r = redis.Redis()
def transfer(from_key, to_key, amount):
with r.pipeline() as pipe:
max_retries = 5
for attempt in range(max_retries):
try:
# WATCH 目标 keys
pipe.watch(from_key, to_key)
# 事务外读取(非管道模式)
from_balance = int(pipe.get(from_key) or 0)
to_balance = int(pipe.get(to_key) or 0)
if from_balance < amount:
pipe.reset()
raise ValueError(f"Insufficient balance: {from_balance}")
# 开始事务
pipe.multi()
pipe.decrby(from_key, amount)
pipe.incrby(to_key, amount)
pipe.execute() # 若 WATCH 的 key 被改变,抛出 WatchError
return True
except WatchError:
# 被其他客户端抢先修改,重试
if attempt == max_retries - 1:
raise RuntimeError("Transaction failed after max retries")
continue
WATCH 的注意事项:
UNWATCH:手动解除所有监视(EXEC 或 DISCARD 后自动解除)- 连接断开:自动解除所有 WATCH
- WATCH 的 key 数量影响
signalModifiedKey的遍历开销,不要监视过多 key - 高并发竞争场景下重试次数可能很多,考虑改用 Lua 脚本
27.3 Lua 脚本(EVAL)
27.3.1 为什么 Lua 比 MULTI/EXEC 更强
- 真正的条件逻辑:MULTI/EXEC 无法在事务内根据命令结果做分支,Lua 可以
- 原子性更强:Lua 执行期间,Redis 不处理任何其他客户端请求
- 减少网络往返:复杂逻辑在服务端完成,只需一次网络调用
27.3.2 EVAL 语法
EVAL script numkeys key [key ...] arg [arg ...]
script:Lua 脚本字符串numkeys:后续参数中 key 的数量KEYS[i]:在脚本中访问 key(从 1 开始)ARGV[i]:在脚本中访问 arg(从 1 开始)
# 简单示例:原子 INCR with upper bound
EVAL "
local current = tonumber(redis.call('GET', KEYS[1])) or 0
if current >= tonumber(ARGV[1]) then
return -1
end
return redis.call('INCR', KEYS[1])
" 1 counter 100
# 如果 counter < 100,递增并返回新值;否则返回 -1
27.3.3 redis.call vs redis.pcall
-- redis.call:出错时直接将错误传播给调用者(终止脚本)
local val = redis.call('GET', KEYS[1])
-- redis.pcall:出错时返回错误对象,脚本继续执行
local result = redis.pcall('HGET', KEYS[1], 'field')
if result.err then
-- 处理错误
return redis.error_reply("Key is not a hash")
end
27.3.4 SCRIPT LOAD 与 EVALSHA(节省带宽)
对于长脚本,每次发送脚本字符串浪费带宽。使用 SCRIPT LOAD 将脚本缓存在服务端,后续用 SHA1 执行:
# 加载脚本,返回 SHA1
SCRIPT LOAD "return redis.call('GET', KEYS[1])"
# 返回:e0e1f9fabfa9d353eca4c6f5d8a8f2b5d3c4f8e1
# 用 SHA1 执行(不传脚本体,节省带宽)
EVALSHA e0e1f9fabfa9d353eca4c6f5d8a8f2b5d3c4f8e1 1 mykey
# 检查脚本是否已缓存
SCRIPT EXISTS e0e1f9fabfa9d353eca4c6f5d8a8f2b5d3c4f8e1
# 清空脚本缓存(重启也会清空)
SCRIPT FLUSH
客户端最佳实践:启动时 SCRIPT LOAD,后续用 EVALSHA,并在收到 NOSCRIPT 错误时回退到 EVAL。
27.3.5 超时保护
# redis.conf
lua-time-limit 5000 # Lua 脚本最长执行时间(毫秒),默认 5000ms
超时后的行为:
- Redis 开始接受
SCRIPT KILL和SHUTDOWN命令 - 其他命令返回
BUSY Redis is busy running a script SCRIPT KILL终止当前 Lua 脚本(若脚本已写入数据,则无法 KILL,只能 SHUTDOWN NOSAVE)
Redis 7 新增:WAIT_FOR_EVENT、调试工具 redis-cli --ldb(Lua 调试器)。
27.3.6 Lua 脚本实战示例
限流器(令牌桶):
-- rate_limiter.lua
local key = KEYS[1]
local capacity = tonumber(ARGV[1]) -- 桶容量
local refill_rate = tonumber(ARGV[2]) -- 每秒补充令牌数
local now = tonumber(ARGV[3]) -- 当前时间戳(毫秒)
local requested = tonumber(ARGV[4]) -- 本次请求消耗令牌数
local last_tokens = tonumber(redis.call('HGET', key, 'tokens')) or capacity
local last_time = tonumber(redis.call('HGET', key, 'time')) or now
-- 计算补充的令牌
local elapsed = math.max(0, now - last_time)
local refilled = elapsed * refill_rate / 1000
local tokens = math.min(capacity, last_tokens + refilled)
local allowed = 0
if tokens >= requested then
tokens = tokens - requested
allowed = 1
end
redis.call('HSET', key, 'tokens', tokens, 'time', now)
redis.call('EXPIRE', key, 60)
return { allowed, math.floor(tokens) }
# 调用限流器:桶容量100,每秒补充10,请求消耗1个令牌
EVAL "$(cat rate_limiter.lua)" 1 ratelimit:user:1000 100 10 1716000000000 1
27.4 Redis 7 Function
27.4.1 Lua 脚本的痛点
| 问题 | Lua 脚本 | Function |
|---|---|---|
| 持久化 | 重启后脚本缓存清空,需要重新加载 | 存储在 RDB 中,重启自动恢复 |
| 版本管理 | 无,脚本是匿名的 | 有库名和函数名,可 FUNCTION LIST |
| 命名空间 | 无,SHA1 哈希标识 | 库名隔离,避免冲突 |
| 迁移 | 脚本散落在各客户端代码中 | FUNCTION DUMP/RESTORE 导出导入 |
27.4.2 Function 基本操作
# 加载函数库(Lua 引擎)
FUNCTION LOAD "#!lua name=mylib
redis.register_function('get_with_default', function(keys, args)
local val = redis.call('GET', keys[1])
if val == false then
return args[1] -- 返回默认值
end
return val
end)
redis.register_function('atomic_transfer', function(keys, args)
local amount = tonumber(args[1])
local from_bal = tonumber(redis.call('GET', keys[1])) or 0
if from_bal < amount then
return redis.error_reply('Insufficient balance')
end
redis.call('DECRBY', keys[1], amount)
redis.call('INCRBY', keys[2], amount)
return amount
end)"
# 调用函数
FCALL get_with_default 1 mykey "default_value"
FCALL atomic_transfer 2 account:A account:B 50
# 只读函数(允许在副本上执行)
FCALL_RO get_with_default 1 mykey "default"
# 管理操作
FUNCTION LIST # 列出所有函数库
FUNCTION LIST LIBRARYNAME mylib # 查看特定库
FUNCTION INFO mylib # 查看库详情
FUNCTION DELETE mylib # 删除函数库
FUNCTION STATS # 当前执行状态
# 迁移(导出 + 导入)
FUNCTION DUMP # 返回序列化数据(二进制)
FUNCTION RESTORE <dump> # 恢复函数库
27.4.3 Function 与 RDB 持久化集成
Function 数据存储在 RDB 文件中,AOF 也会记录 FUNCTION LOAD 命令。服务重启时:
- RDB 加载:从 RDB 文件恢复所有 Function 定义
- AOF 重放:重放
FUNCTION LOAD命令恢复函数 - 无需客户端在启动时重新
SCRIPT LOAD
这是相比 Lua 脚本的核心优势,生产环境中不再需要"应用启动时加载脚本"的初始化逻辑。
27.4.4 Function 的引擎扩展性
Function API 设计为可插拔引擎:
#!lua name=mylib ← Lua 引擎(内置)
#!js name=mylib ← JavaScript 引擎(计划中,社区开发)
27.5 性能对比与选型建议
27.5.1 各方案性能特征
| 方案 | 网络往返 | 原子性 | 分支逻辑 | 持久化 | 适用场景 |
|---|---|---|---|---|---|
| 单命令 | 1次 | 是 | 否 | N/A | 简单操作 |
| Pipeline | 1次(批量) | 否 | 否 | N/A | 批量写入 |
| MULTI/EXEC | 1次 | 部分(不回滚) | 否 | N/A | 简单多步原子 |
| WATCH+MULTI | 多次(含重试) | 是(乐观锁) | 否 | N/A | CAS 操作 |
| EVAL | 1次 | 是 | 是 | 否 | 复杂原子逻辑 |
| Function | 1次 | 是 | 是 | 是 | 企业级复杂逻辑 |
27.5.2 选型决策树
需要多步操作原子性?
├── 否 → 单命令或 Pipeline
└── 是
├── 操作简单,无条件分支?
│ ├── 是 → MULTI/EXEC
│ └── 否(需要读取结果再判断)
│ ├── 并发竞争少? → WATCH + MULTI/EXEC
│ └── 并发竞争多/逻辑复杂? → Lua EVAL 或 Function
└── 需要持久化脚本逻辑?
└── 是 → Redis 7 Function
27.5.3 常见误区
误区1:MULTI/EXEC 可以完全替代数据库事务
Redis 事务不支持回滚,无法保证 ACID 的完整语义。需要回滚的场景必须在应用层处理补偿逻辑。
误区2:Lua 脚本越长越好(减少网络往返)
Lua 执行期间 Redis 不处理其他请求,超长脚本会阻塞所有客户端。建议单次 Lua 执行时间 < 5ms,复杂逻辑拆分为多次调用。
误区3:EVAL 在 Cluster 模式下可以跨节点操作
Cluster 中 EVAL 只能操作属于同一个节点的 key(用 {} 哈希标签确保同槽):
# 错误:key1 和 key2 可能在不同节点
EVAL "redis.call('SET', KEYS[1], KEYS[2])" 2 key1 key2
# 正确:用哈希标签确保同槽
EVAL "redis.call('SET', KEYS[1], KEYS[2])" 2 {user:1}:key1 {user:1}:key2
27.6 生产环境最佳实践
27.6.1 Lua 脚本管理
# 检查所有已缓存脚本
SCRIPT EXISTS sha1 sha2 sha3
# 生产问题:BUSY 错误(Lua 脚本执行超时)
# 终止安全脚本(未执行写操作)
SCRIPT KILL
# 若脚本已执行写操作,只能强制关闭
SHUTDOWN NOSAVE # 数据丢失!紧急时使用
27.6.2 监控 Lua/Function 执行
# 查看 Lua 相关统计
INFO stats | grep lua
# Slowlog 记录超慢 Lua 执行
SLOWLOG GET 10
# 实时监控
redis-cli monitor | grep EVAL
27.6.3 安全注意事项
- 禁止在 Lua 中执行阻塞命令:如
BLPOP、BRPOP(会导致整个 Redis 阻塞) - 避免脚本中的随机数:
math.random()在主从复制时可能产生不一致,使用redis.call('TIME')获取时间 - KEYS 模式匹配在 Cluster 中不可用:Cluster 模式下 EVAL 不允许使用 KEYS 命令扫描
27.7 小结
Redis 提供了三层递进的原子操作能力:
- MULTI/EXEC:简单、直接,适合无分支的多步操作,但不支持回滚
- WATCH:在 MULTI/EXEC 基础上加乐观锁,实现 CAS,适合低竞争场景
- Lua/Function:服务端完整计算能力,真正原子,适合复杂业务逻辑
Redis 7 的 Function 是未来方向,将脚本逻辑纳入 Redis 的持久化和复制体系,彻底解决了 Lua 脚本不持久化的历史痛点。对于新项目,建议优先采用 Function;存量 Lua 脚本可按需迁移。