第 27 章

MULTI/EXEC、Lua 与 Function

第27章 MULTI/EXEC、Lua 与 Function:Redis 事务与脚本机制

27.1 事务的需求与背景

Redis 是单线程命令执行模型,理论上每条命令已经是原子的。但实际业务中经常需要"多步操作的原子性":先读余额再扣款,先检查库存再下单。如果步骤之间被其他客户端插入,就会导致数据不一致。Redis 提供了三种解决方案:

  1. MULTI/EXEC:简单事务,将多条命令打包原子执行
  2. Lua 脚本(EVAL):在服务端执行任意逻辑,天然原子
  3. 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 的注意事项


27.3 Lua 脚本(EVAL)

27.3.1 为什么 Lua 比 MULTI/EXEC 更强

27.3.2 EVAL 语法

EVAL script numkeys key [key ...] arg [arg ...]
# 简单示例:原子 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 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 命令。服务重启时:

  1. RDB 加载:从 RDB 文件恢复所有 Function 定义
  2. AOF 重放:重放 FUNCTION LOAD 命令恢复函数
  3. 无需客户端在启动时重新 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 安全注意事项


27.7 小结

Redis 提供了三层递进的原子操作能力:

  1. MULTI/EXEC:简单、直接,适合无分支的多步操作,但不支持回滚
  2. WATCH:在 MULTI/EXEC 基础上加乐观锁,实现 CAS,适合低竞争场景
  3. Lua/Function:服务端完整计算能力,真正原子,适合复杂业务逻辑

Redis 7 的 Function 是未来方向,将脚本逻辑纳入 Redis 的持久化和复制体系,彻底解决了 Lua 脚本不持久化的历史痛点。对于新项目,建议优先采用 Function;存量 Lua 脚本可按需迁移。

本章评分
4.8  / 5  (4 评分)

💬 留言讨论