目录

Redis-08 事务、Lua 脚本与 Pipeline

1. Redis 事务

1.1 基本命令

MULTI               # 开启事务,之后的命令进入队列
... 命令 ...         # 每条命令返回 QUEUED,不执行
EXEC                # 执行队列里的所有命令,返回结果数组
DISCARD             # 放弃事务,清空队列
WATCH key [key...]  # 监视 key,如果 EXEC 前它被改动,事务就失败
UNWATCH             # 取消所有监视

1.2 一个基本例子

127.0.0.1:6379> MULTI
OK
127.0.0.1:6379(TX)> SET account:A 100
QUEUED
127.0.0.1:6379(TX)> SET account:B 200
QUEUED
127.0.0.1:6379(TX)> INCRBY account:A -50
QUEUED
127.0.0.1:6379(TX)> INCRBY account:B 50
QUEUED
127.0.0.1:6379(TX)> EXEC
1) OK
2) OK
3) (integer) 50
4) (integer) 250

注意 redis-cli 的提示符变成了 (TX),表示当前处于事务上下文中。

1.3 实现原理

事务的实现出乎意料地简单:

typedef struct client {
    ...
    multiState mstate;      // 事务状态
    int flags;              // CLIENT_MULTI / CLIENT_DIRTY_CAS / CLIENT_DIRTY_EXEC
    list *watched_keys;     // 该客户端监视的 key 列表
} client;

typedef struct multiState {
    multiCmd *commands;     // 命令队列(数组)
    int count;              // 队列里的命令数
    ...
} multiState;

流程

1. MULTI:给 client 打上 CLIENT_MULTI 标志

2. 后续每条命令进入 processCommand 后:
   ├─ 发现有 CLIENT_MULTI 标志
   ├─ 【先做基本检查】:命令是否存在、参数个数是否正确
   │   ├─ 检查失败 → 返回错误 + 给 client 打上 CLIENT_DIRTY_EXEC 标志
   │   └─ 检查通过 → 把命令追加到 mstate.commands 队列,返回 QUEUED
   └─ 例外:MULTI/EXEC/DISCARD/WATCH/RESET/QUIT 会被【立即执行】而不入队

3. EXEC:
   ├─ 如果有 CLIENT_DIRTY_EXEC 标志(入队时有命令语法错误)
   │   → 【拒绝执行整个事务】,返回 EXECABORT,清空队列
   ├─ 如果有 CLIENT_DIRTY_CAS 标志(WATCH 的 key 被改过)
   │   → 【放弃事务】,返回 nil
   └─ 否则:【连续地、不被打断地】逐条执行队列里的所有命令,
      把每条的结果收集成一个数组返回

关键点:事务的"原子性"来自单线程——EXEC 是一条命令,它在执行期间不会被其他客户端的命令打断。所以整个队列是"连续执行"的。

1.4 事务不可嵌套

Redis 的事务不支持嵌套。 已经处于事务状态的客户端再发一次 MULTI,服务端只是返回一个错误,然后继续等待命令入队:

127.0.0.1:6379> MULTI
OK
127.0.0.1:6379(TX)> SET k1 v1
QUEUED
127.0.0.1:6379(TX)> MULTI
(error) ERR MULTI calls can not be nested
127.0.0.1:6379(TX)> SET k2 v2
QUEUED
127.0.0.1:6379(TX)> EXEC          ← 【注意】事务本身没有被破坏
1) OK
2) OK

注意这个错误的特殊性:它不会给客户端打上 CLIENT_DIRTY_EXEC 标志,所以不会导致整个事务被放弃(和"命令不存在"这类入队错误不同)。事务照常执行,只是那次多余的 MULTI 被忽略了。

实践含义:如果用了封装了事务的框架/中间件,要小心"外层已经开了事务、内层代码又开一次"的情况——不会报致命错误,但内层代码可能误以为自己开了一个独立的事务,实际上它的命令被合并进了外层事务,EXEC 的时机也不由它控制。

相关的还有 RESET 命令(6.2+),它能把连接彻底重置回初始状态(退出事务、取消订阅、清空 WATCH、重置认证),排查连接状态混乱时很有用:

127.0.0.1:6379(TX)> RESET
+RESET
127.0.0.1:6379>                   ← 已退出事务状态

1.5 两类错误的处理完全不同

这是面试必考的细节。

错误一:入队时的错误(语法错误 / 命令不存在)

127.0.0.1:6379> MULTI
OK
127.0.0.1:6379(TX)> SET k1 v1
QUEUED
127.0.0.1:6379(TX)> WRONGCOMMAND k2 v2
(error) ERR unknown command 'WRONGCOMMAND'      ← 入队时就报错
127.0.0.1:6379(TX)> SET k3 v3
QUEUED
127.0.0.1:6379(TX)> EXEC
(error) EXECABORT Transaction discarded because of previous errors.
127.0.0.1:6379> GET k1
(nil)        ← k1 也没有被设置!整个事务被放弃

结论:入队时的错误会导致整个事务被放弃,一条命令都不执行。

这类错误包括:命令不存在、参数个数不对。因为 Redis 在入队时就能检测出来。

错误二:执行时的错误(类型错误 / 运行时错误)

127.0.0.1:6379> SET str "hello"
127.0.0.1:6379> MULTI
OK
127.0.0.1:6379(TX)> SET k1 v1
QUEUED
127.0.0.1:6379(TX)> LPUSH str a        ← 语法完全正确,能入队
QUEUED
127.0.0.1:6379(TX)> SET k3 v3
QUEUED
127.0.0.1:6379(TX)> EXEC
1) OK                                                          ← k1 成功
2) (error) WRONGTYPE Operation against a key holding...        ← 这条失败
3) OK                                                          ← k3 【仍然成功】
127.0.0.1:6379> MGET k1 k3
1) "v1"
2) "v3"      ← 都在!没有回滚

结论:执行时的错误只影响那一条命令,其他命令继续执行,且已执行的命令不会回滚。

这就是**“Redis 事务不支持回滚”**的具体表现。

1.6 为什么 Redis 事务不支持回滚

这是高频面试题,Redis 官方文档给出了明确的理由:

(1)Redis 认为只有"编程错误"才会导致命令执行失败

执行时错误的原因基本只有一种:你对一个 key 用了它不支持的命令(比如对 string 用 LPUSH)。这种错误应该在开发和测试阶段就被发现,是程序的 bug,而不是生产环境的正常业务状态。

对比关系型数据库:SQL 事务需要回滚,是因为失败原因可能是正常的业务约束(唯一键冲突、外键约束、check 约束、死锁)。这些是运行时才能确定的合法失败,必须回滚。

Redis 没有这些约束机制,所以"失败"几乎必然意味着代码写错了。

(2)不支持回滚让 Redis 内部实现更简单、更快

支持回滚意味着要为每条命令记录 undo 信息(旧值),这需要:

  • 额外的内存(保存旧值,如果是个大集合就很可怕);
  • 额外的 CPU(拷贝旧值);
  • 复杂的实现(每种数据结构都要实现自己的 undo 逻辑)。

antirez 的观点是:为了处理一种"本该在开发阶段消除的错误",让所有正常操作都付出性能代价,不值得。

(3)AOF 的"后写日志"机制也不支持回滚

第 7 篇讲过,Redis 的 AOF 是命令执行成功后才记录的(不是 WAL)。没有 undo log,从机制上就没法回滚。

记住这个结论:Redis 事务保证的是「这批命令会被连续地、不被其他客户端打断地执行完」,而不是「要么全成功要么全失败」。

1.7 WATCH 与乐观锁

MULTI/EXEC 只保证命令连续执行,但无法实现"读取-判断-写入"的原子操作(因为事务里的命令看不到彼此的结果)。

比如"如果余额足够就扣款":

# 错误的想法
MULTI
GET balance          # QUEUED,返回不了实际值,没法判断
DECRBY balance 100   # 无法根据上面的值决定要不要执行
EXEC

WATCH 就是为了解决这个问题,它实现了一种乐观锁(CAS,Check-And-Set)

用法

WATCH balance                        # 1. 监视 key
val=$(GET balance)                   # 2. 读取当前值(在事务外读)
if [ $val -ge 100 ]; then            # 3. 在客户端做业务判断
    MULTI
    DECRBY balance 100
    EXEC                             # 4. 如果 balance 在 WATCH 后被别人改过,
                                     #    EXEC 返回 nil,事务不执行
else
    UNWATCH
fi

如果 EXEC 返回 nil,说明期间有人修改了被监视的 key,需要重试整个流程

完整的重试逻辑(Go 伪代码)

// go-redis 的 Watch 封装了重试模板
const maxRetries = 5

func decreaseBalance(ctx context.Context, rdb *redis.Client, key string, amount int64) error {
    txf := func(tx *redis.Tx) error {
        // 1. 在 WATCH 之后读取当前值
        balance, err := tx.Get(ctx, key).Int64()
        if err != nil && err != redis.Nil {
            return err
        }
        // 2. 业务判断
        if balance < amount {
            return errors.New("余额不足")
        }
        // 3. 在 MULTI 里写入
        _, err = tx.TxPipelined(ctx, func(pipe redis.Pipeliner) error {
            pipe.DecrBy(ctx, key, amount)
            return nil
        })
        return err
    }

    for i := 0; i < maxRetries; i++ {
        err := rdb.Watch(ctx, txf, key)
        if err == nil {
            return nil            // 成功
        }
        if err == redis.TxFailedErr {
            continue              // WATCH 的 key 被改了,重试
        }
        return err                // 其他错误
    }
    return errors.New("达到最大重试次数")
}

WATCH 的实现原理

// 每个 redisDb 里有一个 watched_keys 字典
// key → 监视这个 key 的所有客户端列表
typedef struct redisDb {
    ...
    dict *watched_keys;    // key → list of clients
} redisDb;

流程

1. WATCH key:
   ├─ 把 (key → client) 记录到 db->watched_keys
   └─ 同时在 client->watched_keys 里也记一份(方便 UNWATCH 时清理)

2. 任何客户端修改某个 key 时(signalModifiedKey):
   ├─ 查 db->watched_keys 看有谁在监视这个 key
   └─ 给所有监视者打上 CLIENT_DIRTY_CAS 标志

3. EXEC 时检查 CLIENT_DIRTY_CAS:
   ├─ 有标志 → 放弃事务,返回 nil
   └─ 无标志 → 正常执行

4. EXEC / DISCARD / UNWATCH 之后,清空该 client 的所有监视

几个重要细节

  1. WATCH 必须在 MULTI 之前执行。在 MULTI 之后执行 WATCH 会报错 WATCH inside MULTI is not allowed
  2. 只要 key 被"修改"就算 dirty,不管改成什么值。即使把 key 改成和原来一样的值(SET k v 两次),也会触发 dirty。这是基于版本而非基于值的判断;
  3. key 被删除、过期也算修改
  4. EXECDISCARDUNWATCH 都会清空监视,所以每次重试都要重新 WATCH
  5. EXEC 无论成功失败都会清空监视——这点很重要,如果不清空,客户端可能因为陈旧的 dirty 标志导致后续事务全部失败;
  6. WATCH 是"连接级别"的:不同连接的 WATCH 互不影响,连接断开自动清理。

WATCH 的适用场景与局限

适合:并发冲突较少的场景(乐观锁的本质假设)。

不适合

  1. 高并发热点 key:冲突率高,重试次数多,性能很差(活锁风险)。这种场景应该用 Lua 脚本(一次搞定,无需重试)或分布式锁(悲观锁);
  2. Cluster 模式下跨 slot 的 WATCH:所有被监视的 key 和事务里的 key 必须在同一个 slot;
  3. 需要跨多个 key 做复杂判断:Lua 更清晰。

实践结论:绝大多数需要 WATCH 的场景,用 Lua 脚本会更简单、更高效。 WATCH 现在主要的价值是在"不能用 Lua"(比如脚本太复杂、或者需要在客户端做涉及外部系统的判断)时的备选方案。


2. Redis 事务的 ACID 分析

这是必考题,要逐个维度精确回答。

2.1 原子性(Atomicity)—— 部分满足

标准定义:事务中的操作要么全部成功,要么全部失败(失败要回滚)。

Redis 的情况

场景 是否原子
正常执行完 ✅ 所有命令连续执行,不被其他客户端打断
入队时语法错误 ✅ 整个事务被拒绝,一条都不执行(满足原子性)
执行时错误(如类型错误) 失败的那条报错,其他命令继续执行且不回滚
EXEC 执行过程中 Redis 进程崩溃 可能只执行了一部分(AOF 里可能只有半个事务)

结论:Redis 事务不满足严格的原子性,因为不支持回滚。

关于最后一种情况(进程崩溃)的补充:Redis 会在 AOF 里用 MULTI/EXEC 包裹事务命令,重启加载时如果发现 AOF 里有不完整的事务(有 MULTI 没有 EXEC),会丢弃这个不完整的事务。所以从 AOF 恢复的角度,事务是原子的。但如果是 appendfsync everysec 且崩溃时事务只刷了一半到磁盘,就会丢失部分。

redis-check-aof --fix 也会处理这种情况。

2.2 一致性(Consistency)—— 满足

标准定义:事务执行前后,数据库都处于"合法"状态(满足所有约束)。

Redis 的情况:Redis 没有关系型数据库那样的约束(唯一键、外键、check),所谓"一致性"在这里主要指"数据不会因为事务而进入错乱的中间状态"。

从这个角度看:

  • 入队错误 → 事务不执行,数据不变,一致;
  • 执行错误 → 错误的命令不生效,其他命令的效果都是合法的单命令效果,数据结构没有损坏,一致;
  • 崩溃 → 内存数据丢失(重启从持久化文件恢复),恢复出来的是某个合法状态,一致。

结论:满足一致性(但这个"一致性"的含金量比 RDBMS 低很多,因为约束本来就少)。

2.3 隔离性(Isolation)—— 满足(且是最高级别)

标准定义:并发事务之间互不干扰。

Redis 的情况完全满足,而且天然就是最高隔离级别(串行化 Serializable)。

原因非常简单:Redis 是单线程执行命令的EXEC 作为一条命令,它执行期间不可能有其他客户端的命令插入。所有事务在时间轴上就是严格串行的。

所以关系型数据库里的那些隔离性问题——脏读、不可重复读、幻读——在 Redis 里根本不可能发生(在 EXEC 内部)。

但有个重要的注意点

WATCH + 事务外的 GET 这个组合,中间是有窗口的

WATCH balance      ← 时刻 T1
GET balance        ← 时刻 T2,读到 100
                   ← 时刻 T3,【别的客户端把 balance 改成 50】
MULTI
DECRBY balance 100
EXEC               ← 时刻 T4,检测到 dirty,返回 nil,事务不执行 ✅

WATCH 机制正是为了让这个窗口里的修改能被检测到。所以隔离性的保证依赖于你正确使用了 WATCH。如果不用 WATCH 而在事务外读取再在事务内写入,就会出现类似"丢失更新"的问题——但这不是 Redis 隔离性的缺陷,而是使用方式的问题。

2.4 持久性(Durability)—— 不满足

标准定义:事务提交后,数据永久保存,即使系统崩溃也不丢。

Redis 的情况:事务的持久性完全取决于持久化配置,与事务机制本身无关。

配置 持久性
无持久化 ❌ 完全不持久
RDB ❌ 两次快照之间的事务全部可能丢失
AOF everysec ❌ 最多丢 1~2 秒(可能包含刚提交的事务)
AOF always ⚠️ 接近满足,但仍有极小窗口(且性能代价大)
主从复制 ❌ 异步复制,主节点宕机可能丢失未同步的事务

结论:Redis 事务不满足持久性。 即使 appendfsync always,加上异步主从复制的存在,也不能保证事务提交后数据绝对不丢。

2.5 ACID 总结表

特性 是否满足 关键原因
原子性 A 部分满足 命令连续执行;入队错误会整体放弃;但执行时错误不回滚(无 undo log)
一致性 C 满足 无复杂约束,数据不会进入非法中间状态
隔离性 I 满足(串行化) 单线程执行EXEC 期间不被打断,天然最高隔离级别
持久性 D 不满足 取决于持久化配置;即使 always 也有窗口 + 异步复制会丢

一句话记忆Redis 事务 = 隔离性满分 + 一致性满分 + 原子性打折 + 持久性不及格。


3. Lua 脚本

Lua 脚本是 Redis 事务的"更强替代品",实际工程中远比 MULTI/EXEC 常用

3.1 为什么用 Lua

Lua 脚本相比事务的三大优势:

  1. 真正的原子性:整个脚本作为一条命令执行,期间绝对不会被打断;
  2. 可以有逻辑:能读取中间结果、做条件判断、循环——这是事务完全做不到的;
  3. 减少网络往返:把多次交互的逻辑一次性发到服务端执行。

所以任何"读取-判断-写入"的原子操作,Lua 都是首选。

3.2 基本命令

EVAL script numkeys key [key ...] arg [arg ...]
EVALSHA sha1 numkeys key [key ...] arg [arg ...]
SCRIPT LOAD script          # 预加载脚本,返回 SHA1
SCRIPT EXISTS sha1 [sha1]   # 检查脚本是否已缓存
SCRIPT FLUSH [ASYNC|SYNC]   # 清空脚本缓存
SCRIPT KILL                 # 杀死正在执行的脚本(仅当它还没执行过写命令)
EVAL_RO / EVALSHA_RO        # 7.0+ 只读脚本,可在从节点执行

3.3 第一个例子

127.0.0.1:6379> EVAL "return 1" 0
(integer) 1

127.0.0.1:6379> EVAL "return redis.call('SET', KEYS[1], ARGV[1])" 1 name tom
OK

127.0.0.1:6379> EVAL "return redis.call('GET', KEYS[1])" 1 name
"tom"

# KEYS 和 ARGV 都是从 1 开始的数组(Lua 传统)
127.0.0.1:6379> EVAL "return {KEYS[1], KEYS[2], ARGV[1]}" 2 k1 k2 a1
1) "k1"
2) "k2"
3) "a1"

3.4 KEYS 和 ARGV 的区别(重要)

EVAL script numkeys key1 key2 ... arg1 arg2 ...
                ↑      └──── KEYS ────┘ └── ARGV ──┘
             这个数字决定了前几个参数是 key

为什么要把 key 和普通参数分开? 这不是"风格问题",而是必需的

  1. Cluster 模式必须知道脚本会操作哪些 key,才能判断它们是否在同一个 slot、路由到哪个节点。如果把 key 藏在 ARGV 里,Redis 无法做正确的路由,会导致数据错乱;
  2. WATCH/复制/ACL 权限检查也需要知道涉及的 key。

所以铁律是:脚本里所有访问的 key 都必须通过 KEYS 传入,绝不能在脚本里硬编码或用 ARGV 拼接 key 名。

-- ❌ 错误:key 硬编码在脚本里
return redis.call('GET', 'user:1001')

-- ❌ 错误:用 ARGV 拼 key 名
return redis.call('GET', 'user:' .. ARGV[1])

-- ✅ 正确
return redis.call('GET', KEYS[1])

违反这条铁律在单机模式下"能跑",但迁移到 Cluster 时会莫名其妙地出错(Redis 7.0+ 在集群模式下会直接报错 Script attempted to access a non local key in a cluster node)。

3.5 redis.call 与 redis.pcall

redis.call('GET', KEYS[1])    -- 出错时【直接抛出错误,中断整个脚本】
redis.pcall('GET', KEYS[1])   -- 出错时【返回一个 error table】,脚本可以继续处理
-- 用 pcall 做容错
local ok = redis.pcall('LPUSH', KEYS[1], ARGV[1])
if ok.err then
    -- 类型错误等,做降级处理
    redis.call('DEL', KEYS[1])
    redis.call('LPUSH', KEYS[1], ARGV[1])
end

注意一个致命细节:Lua 脚本同样不支持回滚!如果脚本执行到一半出错,前面已经执行的写命令不会撤销

redis.call('SET', KEYS[1], 'a')      -- 成功执行
redis.call('LPUSH', KEYS[1], 'b')    -- 类型错误!脚本中断
-- 结果:KEYS[1] 已经被设成了 'a',不会回滚

所以写脚本时要把所有校验放在前面,所有写操作放在后面

-- ✅ 正确的结构:先校验,再写入
local balance = tonumber(redis.call('GET', KEYS[1]) or '0')
local amount = tonumber(ARGV[1])
if balance < amount then
    return 0          -- 校验失败,此时还没有任何写操作
end
-- 校验都通过了,开始写
redis.call('DECRBY', KEYS[1], amount)
redis.call('INCRBY', KEYS[2], amount)
return 1

3.6 类型转换规则

Lua 和 Redis 的类型系统不同,转换规则必须记住(很容易踩坑):

Redis → Lua

Redis 返回 Lua 收到
整数(:1 number
批量字符串($3\r\nabc string
数组(*2 table(从 1 开始的数组)
nil($-1 false(不是 nil!)
简单字符串(+OK table:{ok = "OK"}
错误(-ERR ... table:{err = "ERR ..."}

Lua → Redis

Lua 返回 Redis 收到
number 整数(小数部分被截断!)
string 批量字符串
table(数组) 数组(遇到第一个 nil 就截断
false nil
true 整数 1
table {ok="..."} 简单字符串
table {err="..."} 错误
无返回值 nil

四个必知的坑

坑一:Lua 的 number 转 Redis 会丢小数

127.0.0.1:6379> EVAL "return 3.99" 0
(integer) 3        # ⚠️ 小数被截断!

解决:返回浮点数要转成字符串:

return tostring(3.99)         -- "3.99"
-- 或者用 Redis 的类型转换
return redis.call('INCRBYFLOAT', KEYS[1], ARGV[1])   -- 返回的是字符串

坑二:key 不存在时 Lua 收到 false 而不是 nil

local v = redis.call('GET', KEYS[1])
if v == nil then        -- ❌ 永远不成立!
    ...
end
if v == false then      -- ✅ 正确
    ...
end
if not v then           -- ✅ 也正确(false 和 nil 都是 falsy)
    ...
end

坑三:table 里的 nil 会截断数组

127.0.0.1:6379> EVAL "return {1, 2, nil, 4}" 0
1) (integer) 1
2) (integer) 2
# ⚠️ 3 和 4 都丢了!因为遇到 nil 就停止

解决:不要在返回的数组里放 nil,用空字符串或特殊标记代替。

坑四:所有参数都是字符串

ARGV 里的所有内容都是字符串,做数值运算必须显式转换:

-- ❌ 错误:字符串比较,"10" < "9" 是 true(字典序!)
if ARGV[1] < ARGV[2] then ... end

-- ✅ 正确
if tonumber(ARGV[1]) < tonumber(ARGV[2]) then ... end

同样,redis.call('GET', ...) 返回的也是字符串。

3.7 EVALSHA 与脚本缓存

每次 EVAL 都要传输完整的脚本文本,如果脚本有几 KB 而 QPS 很高,网络开销就很可观。

Redis 会缓存所有执行过的脚本(以 SHA1 为 key,保存在 server.lua_scripts 字典里),可以用 EVALSHA 只传 SHA1(40 个字符)来执行。

127.0.0.1:6379> SCRIPT LOAD "return redis.call('GET', KEYS[1])"
"4e6d8fc8bb01276962cce5371fa795a7763657ae"

127.0.0.1:6379> EVALSHA 4e6d8fc8bb01276962cce5371fa795a7763657ae 1 name
"tom"

127.0.0.1:6379> SCRIPT EXISTS 4e6d8fc8bb01276962cce5371fa795a7763657ae
1) (integer) 1

标准的客户端实现模式(几乎所有客户端库都这么做):

1. 先尝试 EVALSHA <sha1>
2. 如果返回 NOSCRIPT 错误(脚本不在缓存里)
   → 降级为 EVAL <完整脚本>(这会自动把它加入缓存)
3. 之后就都能用 EVALSHA 了

go-redis 的 redis.NewScript() 就封装了这个逻辑:

var decrScript = redis.NewScript(`
    local v = tonumber(redis.call('GET', KEYS[1]) or '0')
    if v < tonumber(ARGV[1]) then return 0 end
    redis.call('DECRBY', KEYS[1], ARGV[1])
    return 1
`)

// Run 内部会先试 EVALSHA,NOSCRIPT 时自动回退到 EVAL
res, err := decrScript.Run(ctx, rdb, []string{"balance"}, 100).Int()

脚本缓存的几个重要注意点

  1. 脚本缓存不持久化SCRIPT FLUSH重启DEBUG RELOAD 都会清空。所以客户端必须实现 NOSCRIPT 回退,不能假设脚本永远在缓存里;
  2. 缓存会无限增长:Redis 不会自动淘汰脚本缓存。如果你的代码动态生成脚本(比如把参数拼进脚本文本),每个变体都会占一份缓存,长期运行会造成内存泄漏。这是"参数必须走 ARGV 而不是拼进脚本"的另一个理由;
  3. 主从与集群的缓存不共享:脚本缓存是每个节点独立的。5.0 之前 SCRIPT LOAD 不会传播到从节点(所以从节点上 EVALSHA 可能 NOSCRIPT);主从切换后新主节点可能没有这个脚本的缓存。7.0 的 Function 解决了这个问题。

3.8 脚本的复制方式

Redis 5.0 之前:脚本以脚本本身(verbatim)的方式复制到从节点和 AOF——从节点会重新执行一遍这个脚本。

这带来一个严格要求:脚本必须是确定性的,否则主从数据会不一致。所以那时 Redis:

  • 禁止在脚本里用 math.random(除非先调 math.randomseed,且 Redis 替换了随机数生成器保证主从种子相同);
  • 禁止用 TIMESRANDMEMBERSPOP 等非确定性命令(除非之后不再写入);
  • 报错 Write commands not allowed after non deterministic commands

Redis 5.0 之后(7.0 起成为唯一方式):改成效果复制(effects replication)——只把脚本产生的实际写命令包在一个 MULTI/EXEC 里传播。

好处:

  1. 脚本可以随意使用随机数、时间等非确定性操作
  2. 从节点不用重新执行脚本,省 CPU;
  3. 传播的内容更小(只有实际的写命令)。

第 7 篇讲的"效果复制"就是这个机制。

一个相关的细节:为了让脚本行为确定,脚本执行期间"时间是冻结的"——脚本内所有的过期判断都用脚本开始执行时的时间。这避免了"脚本前半段 key 还在,后半段就过期了"的不一致。

3.9 脚本的阻塞风险

Lua 脚本执行期间,Redis 完全不响应其他任何命令(因为它就是一条命令,单线程串行)。

所以脚本里绝不能有耗时操作

-- ❌ 灾难:死循环,Redis 永久卡死
while true do end

-- ❌ 灾难:遍历百万级集合
local all = redis.call('SMEMBERS', KEYS[1])   -- 假设有 100 万成员
for i, v in ipairs(all) do
    redis.call('SET', 'copy:' .. v, 1)        -- 100 万次写
end

-- ❌ 危险:循环次数由外部参数决定,没有上限
for i = 1, tonumber(ARGV[1]) do ... end

超时与救援

busy-reply-threshold 5000      # 旧名 lua-time-limit,单位毫秒,默认 5000

这个配置的含义常被误解:它不是"脚本超时就杀掉",而是"脚本运行超过这个时间后,Redis 开始对其他客户端返回 BUSY 错误"。

脚本运行 > busy-reply-threshold 后:
├─ Redis 【仍然继续执行脚本】(不会自动终止!)
├─ 但开始处理事件循环,对其他客户端的命令返回:
│  -BUSY Redis is busy running a script. You can only call
│        SCRIPT KILL or SHUTDOWN NOSAVE.
└─ 只接受两个命令:SCRIPT KILL 和 SHUTDOWN NOSAVE

救援手段

# 情况一:脚本还没执行过任何写命令
redis-cli SCRIPT KILL       # 可以安全终止

# 情况二:脚本已经执行过写命令
redis-cli SCRIPT KILL
# (error) UNKILLABLE Sorry the script already executed write commands
#         against the dataset. You can either wait the script termination
#         or kill the server in a hard way using the SHUTDOWN NOSAVE command.

# 只能:
redis-cli SHUTDOWN NOSAVE   # ⚠️ 强制关闭,丢失内存中未持久化的数据

为什么写过数据的脚本不能 kill:因为杀掉会留下"执行了一半"的数据状态,而且这个半成品状态无法正确地复制给从节点(效果复制只在脚本成功结束时才发送),会造成主从永久不一致。Redis 宁愿让你等或者重启,也不允许产生不一致。

这是一个真实的、灾难级的线上事故场景:一个有 bug 的 Lua 脚本导致整个 Redis 集群不可用,且唯一的解法是重启丢数据。所以:

  1. 脚本必须简短,最好是"几个命令 + 简单判断";
  2. 循环次数必须有明确上限,且上限要小(比如几百);
  3. 上线前必须在测试环境用生产规模的数据验证
  4. 不要在脚本里遍历不可控大小的集合

3.10 实用脚本示例

(1)安全释放分布式锁(最经典的 Lua 用途)

-- KEYS[1] = 锁的 key,ARGV[1] = 锁的持有者标识(uuid)
-- 只有锁还是我的,才删除它
if redis.call('GET', KEYS[1]) == ARGV[1] then
    return redis.call('DEL', KEYS[1])
else
    return 0
end

为什么必须用 Lua:如果分成 GET + 判断 + DEL 三步,可能在 GET 之后锁刚好过期并被别人获取,然后你的 DEL 就删掉了别人的锁

(2)令牌桶限流

-- KEYS[1]=桶的key  ARGV[1]=容量  ARGV[2]=每秒生成速率  ARGV[3]=当前毫秒  ARGV[4]=请求令牌数
local key = KEYS[1]
local capacity = tonumber(ARGV[1])
local rate = tonumber(ARGV[2])
local now = tonumber(ARGV[3])
local requested = tonumber(ARGV[4])

local bucket = redis.call('HMGET', key, 'tokens', 'ts')
local tokens = tonumber(bucket[1])
local last_ts = tonumber(bucket[2])

if tokens == nil then
    tokens = capacity
    last_ts = now
else
    -- 按经过的时间补充令牌
    local delta = math.max(0, now - last_ts)
    tokens = math.min(capacity, tokens + delta * rate / 1000)
    last_ts = now
end

local allowed = 0
if tokens >= requested then
    tokens = tokens - requested
    allowed = 1
end

redis.call('HMSET', key, 'tokens', tokens, 'ts', last_ts)
redis.call('PEXPIRE', key, math.ceil(capacity / rate * 1000) + 1000)
return allowed

(3)滑动窗口限流

-- KEYS[1]=限流key  ARGV[1]=当前毫秒  ARGV[2]=窗口毫秒  ARGV[3]=限制次数  ARGV[4]=唯一member
local key = KEYS[1]
local now = tonumber(ARGV[1])
local window = tonumber(ARGV[2])
local limit = tonumber(ARGV[3])

redis.call('ZREMRANGEBYSCORE', key, 0, now - window)
local count = redis.call('ZCARD', key)
if count >= limit then
    return 0
end
redis.call('ZADD', key, now, ARGV[4])
redis.call('PEXPIRE', key, window)
return 1

(4)库存扣减(防超卖)

-- KEYS[1]=库存key  KEYS[2]=已售用户集合  ARGV[1]=用户ID  ARGV[2]=购买数量
local stock = tonumber(redis.call('GET', KEYS[1]) or '0')
local qty = tonumber(ARGV[2])

-- 1. 校验:一人一单
if redis.call('SISMEMBER', KEYS[2], ARGV[1]) == 1 then
    return -1        -- 已购买过
end
-- 2. 校验:库存足够
if stock < qty then
    return -2        -- 库存不足
end
-- 3. 所有校验通过,执行写入
redis.call('DECRBY', KEYS[1], qty)
redis.call('SADD', KEYS[2], ARGV[1])
return 1             -- 成功

注意这个脚本的结构:所有校验在前,所有写入在后——因为 Lua 不能回滚。

(5)批量获取并删除到期的延迟任务

local tasks = redis.call('ZRANGEBYSCORE', KEYS[1], 0, ARGV[1], 'LIMIT', 0, ARGV[2])
if #tasks > 0 then
    redis.call('ZREM', KEYS[1], unpack(tasks))
end
return tasks

注意 unpack 有参数个数限制(Lua 的栈限制,通常约 8000),所以 LIMIT 的第二个参数要控制在几百以内。

(6)原子的"设置并返回是否首次"

-- 用于"只有第一次才发通知"这类幂等场景
if redis.call('SETNX', KEYS[1], ARGV[1]) == 1 then
    redis.call('EXPIRE', KEYS[1], ARGV[2])
    return 1
end
return 0

3.11 脚本里可用的辅助 API

-- 日志(会写到 Redis 的日志文件)
redis.log(redis.LOG_WARNING, "something happened: " .. tostring(x))
-- 级别:redis.LOG_DEBUG / LOG_VERBOSE / LOG_NOTICE / LOG_WARNING

-- JSON 编解码(Redis 内置了 cjson 库)
local obj = cjson.decode(ARGV[1])
local str = cjson.encode({id = 1, name = "tom"})

-- SHA1、Base64(内置库)
local hash = redis.sha1hex("hello")
local encoded = cmsgpack.pack(obj)     -- MessagePack
local bits = bit.band(a, b)            -- 位运算库
local ok, err = pcall(function() ... end)   -- Lua 原生的错误捕获

-- 返回自定义错误
return redis.error_reply("my custom error")
return {err = "my custom error"}       -- 等价

-- 返回状态回复
return redis.status_reply("DONE")
return {ok = "DONE"}                   -- 等价

-- 控制复制行为(一般不需要动)
redis.replicate_commands()             -- 5.0+ 默认就是效果复制
redis.set_repl(redis.REPL_NONE)        -- 让后续命令不复制(危险,会造成主从不一致)

-- 7.0+ 声明脚本标志(在脚本第一行)
#!lua flags=no-writes                  -- 声明为只读脚本,可在从节点执行

3.12 内置库的限制

Redis 的 Lua 沙箱做了严格限制:

  • 禁止 require:不能加载外部模块;
  • 禁止文件 IOioos.execute);
  • 禁止网络访问
  • 全局变量被禁止(7.0+ 更严格):脚本里 x = 1 会报错 Script attempted to create global variable,必须用 local x = 1。这是为了防止脚本之间互相污染。

可用的库:basetablestringmathstructcjsoncmsgpackbitos.time/os.clock(部分)。


4. Function(7.0+)

Function 是 7.0 引入的"下一代脚本机制",用来解决 EVAL/EVALSHA 的痛点。

4.1 EVAL 的痛点

  1. 脚本缓存不持久化:重启、SCRIPT FLUSH 后缓存丢失,客户端必须实现 NOSCRIPT 回退逻辑;
  2. 主从/切换后缓存不一致:新主节点可能没有该脚本;
  3. 脚本是"匿名"的:只有 SHA1,运维完全不知道 4e6d8fc8... 是干什么的;
  4. 无法复用代码:多个脚本共享的逻辑只能复制粘贴;
  5. 应用和脚本耦合:脚本文本嵌在应用代码里,更新脚本要发版。

4.2 Function 的改进

特性 EVAL / 脚本缓存 Function
持久化 ❌ 重启丢失 存入 RDB/AOF,重启保留
复制到从节点 只有效果(脚本本身不复制) 函数库本身会复制
命名 匿名(只有 SHA1) 有库名和函数名
代码组织 单个脚本 库(library)可含多个函数,可共享辅助函数
客户端要处理 NOSCRIPT 不需要
管理与观测 只能 SCRIPT EXISTS FUNCTION LIST/STATS/DUMP

4.3 用法

FUNCTION LOAD [REPLACE] "<library code>"
FUNCTION DELETE <library-name>
FUNCTION LIST [LIBRARYNAME name] [WITHCODE]
FUNCTION FLUSH [ASYNC|SYNC]
FUNCTION DUMP                 # 序列化所有函数库(用于备份/迁移)
FUNCTION RESTORE <payload> [FLUSH|APPEND|REPLACE]
FUNCTION STATS                # 正在执行的函数、各引擎的函数数量
FCALL <function> numkeys key... arg...      # 调用函数
FCALL_RO <function> numkeys key... arg...   # 只调用只读函数(可在从节点执行)

4.4 完整例子

#!lua name=mylib

-- 内部辅助函数(不对外暴露)
local function check_positive(n)
    local v = tonumber(n)
    if v == nil or v <= 0 then
        error('invalid amount')
    end
    return v
end

-- 注册对外函数:转账
redis.register_function('transfer', function(keys, args)
    local amount = check_positive(args[1])
    local from = tonumber(redis.call('GET', keys[1]) or '0')
    if from < amount then
        return redis.error_reply('insufficient balance')
    end
    redis.call('DECRBY', keys[1], amount)
    redis.call('INCRBY', keys[2], amount)
    return 1
end)

-- 注册只读函数(声明 no-writes 标志后可在从节点执行)
redis.register_function{
    function_name = 'get_balance',
    callback = function(keys, args)
        return redis.call('GET', keys[1])
    end,
    flags = {'no-writes'}
}

加载和调用:

# 加载(通常从文件读入)
$ redis-cli -x FUNCTION LOAD REPLACE < mylib.lua
"mylib"

# 调用
127.0.0.1:6379> SET acct:A 1000
127.0.0.1:6379> SET acct:B 0
127.0.0.1:6379> FCALL transfer 2 acct:A acct:B 300
(integer) 1
127.0.0.1:6379> MGET acct:A acct:B
1) "700"
2) "300"

# 只读函数可以在从节点上调用
127.0.0.1:6379> FCALL_RO get_balance 1 acct:A
"700"

# 查看已加载的库
127.0.0.1:6379> FUNCTION LIST
1) 1) "library_name"
   2) "mylib"
   3) "engine"
   4) "LUA"
   5) "functions"
   6) 1) 1) "name"
         2) "transfer"
         3) "description"
         4) (nil)
         5) "flags"
         6) (empty array)
      2) 1) "name"
         2) "get_balance"
         ...
         5) "flags"
         6) 1) "no-writes"

注意函数签名的变化:Function 用 function(keys, args) 的参数形式,不再用全局的 KEYS/ARGV(而且下标依然从 1 开始)。

4.5 脚本标志(flags)

7.0 引入了脚本标志,让 Redis 能对脚本做更智能的处理:

flag 含义
no-writes 脚本不做任何写操作 → 可以在从节点执行、OOM 时也允许执行、可用 FCALL_RO/EVAL_RO
allow-oom 内存超限时也允许执行(默认写脚本会被拒绝)
allow-stale 允许在"与主节点失联的从节点"上执行
no-cluster 声明脚本不能在集群模式下运行
allow-cross-slot-keys 7.0+ 允许访问不同 slot 的 key(危险,慎用)

EVAL 也支持在脚本第一行用 shebang 声明:

#!lua flags=no-writes
return redis.call('GET', KEYS[1])

为什么这很有用:7.0 之前 Redis 无法知道一个脚本是否会写数据,所以保守地假设所有脚本都会写,导致:

  • 脚本不能在只读从节点上执行;
  • 内存超限(OOM)时所有脚本都被拒绝,即使它只是读数据。

有了 no-writes 声明,这些限制就解除了。

4.6 什么时候用 Function

推荐用 Function 的场景

  • 脚本逻辑复杂、需要拆分成多个函数复用;
  • 有多个应用共用同一套 Redis 逻辑(把逻辑放服务端,避免各应用重复实现);
  • 想让脚本"部署"和"应用发版"解耦(DBA 可以独立更新函数库);
  • 需要运维可观测性(能看到有哪些函数、叫什么)。

继续用 EVAL 的场景

  • 简单的几行脚本(分布式锁释放、原子计数),用 NewScript 封装已经足够;
  • 需要兼容 7.0 以下的版本;
  • 现有代码库已经大量使用 EVAL(迁移成本大于收益)。

实际上目前工业界仍以 EVAL + 客户端封装 为主,Function 的采用率还不高。但了解它是必要的。


5. Pipeline 与四种方案的选型

第 5 篇讲了 Pipeline 的原理(省 RTT + 省系统调用 + 省事件循环轮次),这里做完整对比。

5.1 四种批量/原子方案对比

方案 网络往返 原子性 命令间可依赖 服务端阻塞 主要用途
逐条命令 N 次 单条原子 简单场景
MGET/MSET 1 次 同类型批量读写
Pipeline 1 次 无(可被打断) 纯性能优化
MULTI/EXEC 1~2 次 (不回滚) 否(除 WATCH 执行期间 需要连续执行
Lua 脚本 1 次 (不回滚) 执行期间 需要逻辑判断的原子操作

5.2 决策树

需要保证多个命令的原子性吗?
├─ 不需要 → 只是想快
│   ├─ 命令类型相同(都是 GET/SET) → MGET / MSET
│   └─ 命令类型不同 → Pipeline
└─ 需要
    ├─ 需要读取中间结果做判断吗?
    │   ├─ 需要 → 【Lua 脚本】(首选)
    │   │           └─ 逻辑复杂/需要复用 → Function
    │   └─ 不需要 → MULTI/EXEC(放进 pipeline 一起发)
    └─ 判断逻辑必须在客户端做(涉及外部系统调用等)?
        → WATCH + MULTI/EXEC 的乐观锁重试

5.3 为什么 Lua 通常优于 MULTI/EXEC

实际工程里,Lua 脚本几乎全面取代了 MULTI/EXEC

  1. 能做条件判断:这是决定性的优势。绝大多数"原子操作"需求本质上是"读取-判断-写入",事务做不到;
  2. 不需要 WATCH 重试:Lua 是一次成功的悲观执行,WATCH 是可能反复失败的乐观锁。高并发热点 key 上 WATCH 的重试率极高,性能很差;
  3. 网络往返更少WATCH 方案至少要 WATCHGETMULTIEXEC 多轮交互,Lua 一次搞定;
  4. 代码更集中易读:业务逻辑写在一个脚本里,而不是散在客户端的重试循环中。

MULTI/EXEC 现在的主要价值

  • 配合 pipeline 做"一批无依赖的写入"(比如同时更新多个计数器),比 Lua 更轻量(不用启动 Lua 解释器);
  • WATCH 用于"判断逻辑必须在客户端" 的场景(例如需要调用外部 API 才能决定是否提交)。

5.4 Pipeline 的使用注意(补充)

// go-redis 的两种 pipeline
pipe := rdb.Pipeline()          // 普通 pipeline,无原子性
pipe := rdb.TxPipeline()        // 自动用 MULTI/EXEC 包裹,有原子性

for i := 0; i < 1000; i++ {
    pipe.Set(ctx, fmt.Sprintf("k%d", i), i, 0)
}
cmds, err := pipe.Exec(ctx)

// ⚠️ 关键:即使 err != nil,也要遍历 cmds 检查每条命令的结果
// err 只反映"整体是否有错",具体哪条失败要看各个 cmd.Err()
for _, cmd := range cmds {
    if cmd.Err() != nil {
        log.Printf("命令失败: %v, err: %v", cmd.Args(), cmd.Err())
    }
}

要点复述(第 5 篇提过,这里强调易错点):

  1. 每批 100~1000 条,不要一次几万条——回复全堆在服务端输出缓冲区和客户端接收缓冲区里,可能触发 client-output-buffer-limit 或撑爆内存;
  2. 不要在 pipeline 里放阻塞命令BLPOP),会把整批卡住;
  3. 集群模式要按节点分组(成熟客户端会自动做,但要注意 TxPipeline 在集群下不能跨 slot);
  4. 必须检查每条命令的错误,不能只看 Exec 的返回值。

6. 高频面试题

Q1:Redis 事务的实现原理是什么?

非常简单,核心是命令队列 + 单线程

  1. MULTI:给 client 打上 CLIENT_MULTI 标志;
  2. 后续命令:在 processCommand 里发现有该标志,就只做基本检查(命令是否存在、参数个数是否正确),检查通过则追加到 client->mstate.commands 队列并返回 QUEUED,不执行。检查失败则报错并打上 CLIENT_DIRTY_EXEC 标志。(MULTI/EXEC/DISCARD/WATCH/RESET 例外,会立即执行);
  3. EXEC
    • CLIENT_DIRTY_EXEC(入队时有语法错误)→ 返回 EXECABORT整个事务放弃
    • CLIENT_DIRTY_CAS(WATCH 的 key 被改)→ 返回 nil,放弃;
    • 否则连续执行队列里所有命令,结果打包成数组返回。

原子性来自单线程EXEC 本身是一条命令,执行期间不会被其他客户端打断。

Q2:Redis 事务满足 ACID 吗?

特性 是否满足 原因
原子性 部分 命令连续执行;入队错误会整体放弃(满足);但执行时错误不回滚(不满足)
一致性 满足 没有复杂约束,数据不会进入非法中间状态
隔离性 满足(串行化) 单线程执行,天然最高隔离级别,不存在脏读/不可重复读/幻读
持久性 不满足 取决于持久化配置;everysec 丢 1~2 秒;即使 always 也有窗口;异步主从复制还会丢

一句话:隔离性满分、一致性满分、原子性打折、持久性不及格。

Q3:Redis 事务为什么不支持回滚?

Redis 官方给出的理由:

  1. Redis 认为执行失败只可能是"编程错误":执行时错误几乎只有一种成因——对某个 key 用了它不支持的命令(如对 string 用 LPUSH)。这属于应该在开发测试阶段消除的 bug,不是生产环境的正常业务状态。

    对比 RDBMS:SQL 事务需要回滚是因为失败原因可能是正常的业务约束(唯一键冲突、外键、死锁),这些是运行时才能确定的合法失败。Redis 没有这些约束机制。

  2. 不支持回滚让实现更简单、性能更好:回滚需要为每条命令保存 undo 信息(旧值),这要付出额外内存(大集合的旧值可能很大)、额外 CPU(拷贝)、和复杂的实现(每种数据结构都要实现 undo)。为了处理一种"本该消除的 bug"让所有正常操作付代价,不值得。

  3. AOF 是"后写日志"(不是 WAL),命令执行成功后才记录,机制上就没有 undo log

记住结论:Redis 事务保证的是「这批命令连续执行不被打断」,不是「要么全成功要么全失败」。

Q4:事务中命令出错,前面的命令会回滚吗?两类错误有什么区别?

不会回滚。 但两类错误的处理方式截然不同:

(1)入队时错误(命令不存在、参数个数不对):Redis 在入队阶段就能发现,会给 client 打 CLIENT_DIRTY_EXEC 标志。EXEC整个事务被拒绝,返回 EXECABORT一条命令都不执行

MULTI
SET k1 v1              # QUEUED
WRONGCMD k2            # (error) 入队就报错
EXEC                   # (error) EXECABORT,k1 也没被设置

(2)执行时错误(类型错误等):语法正确所以能入队,只有真正执行时才失败。只有那一条命令失败,其他命令全部正常执行且不回滚

SET str "hello"
MULTI
SET k1 v1              # QUEUED
LPUSH str a            # QUEUED(语法没问题)
SET k3 v3              # QUEUED
EXEC
# 1) OK
# 2) (error) WRONGTYPE ...
# 3) OK          ← k1 和 k3 都成功了

Q5:WATCH 是怎么实现的?它是乐观锁还是悲观锁?

乐观锁(CAS,Check-And-Set):不加锁,先操作,提交时检查有没有冲突,有冲突就放弃重试。

实现

  1. 每个 redisDb 有一个 watched_keys 字典(key → 监视它的客户端列表);
  2. WATCH key 把 (key → client) 记入这个字典,同时在 client 自己的 watched_keys 里也记一份;
  3. 任何客户端修改某个 key 时signalModifiedKey),查 watched_keys 找到所有监视者,给它们打上 CLIENT_DIRTY_CAS 标志;
  4. EXEC 时检查该标志:有 → 放弃事务返回 nil;无 → 正常执行;
  5. EXEC/DISCARD/UNWATCH 之后清空该 client 的所有监视。

重要细节

  • 只要 key 被"修改"就算 dirty,与改成什么值无关——即使 SET k v 改成和原来一样的值也会触发。这是基于版本而非基于值的判断;
  • key 被删除、过期也算修改;
  • WATCH 必须在 MULTI 之前,在事务里执行会报错;
  • 客户端必须实现重试循环,因为 EXEC 返回 nil 时业务还没完成。

Q6:WATCH 和 Lua 脚本,什么时候用哪个?

优先用 Lua,绝大多数场景 Lua 更好:

WATCH + MULTI Lua
冲突处理 失败重试(乐观锁) 一次成功(服务端串行执行)
高并发热点 key 很差(重试率高,可能活锁)
网络往返 至少 4 轮(WATCH/GET/MULTI/EXEC) 1 轮
代码组织 散在客户端重试循环里 集中在脚本里
判断逻辑位置 客户端 服务端

WATCH 仍然有用的唯一场景判断逻辑必须在客户端执行——比如需要调用外部 API、查询数据库、或者做复杂的业务计算才能决定是否提交。这时 Lua 做不到(沙箱禁止网络访问)。

Q7:Lua 脚本为什么是原子的?它会阻塞 Redis 吗?

原子性来源:Redis 把整个脚本当作一条命令执行。因为命令执行是单线程串行的,脚本执行期间不可能有其他客户端的命令插入

会阻塞,而且是严重的阻塞风险:脚本执行期间 Redis 完全不响应任何其他命令

busy-reply-threshold(默认 5000ms,旧名 lua-time-limit不是"超时就杀掉",而是"超过这个时间后开始对其他客户端返回 BUSY 错误",脚本本身仍在继续跑。此时只接受两个命令:

  • SCRIPT KILL:只有脚本还没执行过任何写命令时才能成功;
  • SHUTDOWN NOSAVE:脚本已经写过数据时的唯一出路,强制关闭并丢失内存中未持久化的数据

为什么写过数据的脚本不能 kill:杀掉会留下"执行了一半"的状态,而这个半成品状态无法正确复制给从节点(效果复制只在脚本成功结束时才发送),会造成主从永久不一致。Redis 宁愿让你重启也不允许不一致。

所以铁律:脚本必须简短、循环次数必须有小的明确上限、绝不遍历大小不可控的集合、上线前用生产规模数据验证。

Q8:为什么 Lua 脚本里的 key 必须通过 KEYS 传入?

不是编码风格问题,是硬性要求

  1. Cluster 模式必须知道脚本涉及哪些 key,才能判断它们是否在同一 slot、把请求路由到正确的节点。如果 key 藏在 ARGV 里或在脚本里拼接,Redis 无法正确路由,会读写到错误的节点上;
  2. ACL 权限检查需要知道涉及的 key(~pattern 形式的 key 权限);
  3. 复制、WATCH 相关机制也依赖它。
-- ❌ key 硬编码
return redis.call('GET', 'user:1001')
-- ❌ 用 ARGV 拼 key
return redis.call('GET', 'user:' .. ARGV[1])
-- ✅ 正确
return redis.call('GET', KEYS[1])

违规在单机上"能跑",但迁到 Cluster 会莫名出错。Redis 7.0+ 在集群模式下会直接报错Script attempted to access a non local key in a cluster node

另外还有个副作用:把参数拼进脚本文本会导致每个变体都占一份脚本缓存,而脚本缓存不会自动淘汰,长期运行造成内存泄漏。

Q9:EVALEVALSHA 的区别?为什么客户端要处理 NOSCRIPT?

EVAL 每次传输完整脚本文本;EVALSHA 只传 40 字符的 SHA1,Redis 从脚本缓存里找对应脚本执行。脚本几 KB 且高 QPS 时,网络开销差别很大。

必须处理 NOSCRIPT 的原因脚本缓存不持久化。以下情况都会让缓存失效:

  • Redis 重启
  • 执行了 SCRIPT FLUSH
  • 主从切换后新主节点没有这个脚本的缓存;
  • 客户端连到了一个新扩容的节点。

标准模式

1. 先试 EVALSHA <sha1>
2. 收到 NOSCRIPT 错误 → 降级为 EVAL <完整脚本>(会自动加入缓存)
3. 之后继续用 EVALSHA

go-redis 的 redis.NewScript() / Java Redisson 等客户端都封装了这个逻辑,直接用它们的封装即可。

7.0 的 Function 解决了这个问题:函数库会被持久化到 RDB/AOF、会复制到从节点,所以不需要 NOSCRIPT 回退。

Q10:Lua 脚本的类型转换有哪些坑?

四个必知的坑

(1)Lua number 转 Redis 会截断小数

EVAL "return 3.99" 0(integer) 3

解决:返回浮点要 tostring(3.99)

(2)Redis 的 nil 在 Lua 里是 false 而不是 nil

local v = redis.call('GET', KEYS[1])
if v == nil then ... end     -- ❌ 永远不成立
if v == false then ... end   -- ✅
if not v then ... end        -- ✅

(3)返回的 table 里有 nil 会截断数组

EVAL "return {1, 2, nil, 4}" 0    →   只返回 12

(4)ARGV 和 redis.call 的返回值都是字符串,做数值比较必须 tonumber

if ARGV[1] < ARGV[2] then ... end              -- ❌ 字典序!"10" < "9" 为 true
if tonumber(ARGV[1]) < tonumber(ARGV[2]) then  -- ✅

另外:Lua 的 true 转成 Redis 的整数 1,false 转成 nil。

Q11:Lua 脚本是怎么复制到从节点的?

5.0 之前:脚本复制(verbatim)——把 EVAL 命令本身传给从节点,从节点重新执行一遍脚本。

这要求脚本必须是确定性的,否则主从数据不一致。所以那时 Redis 禁止在脚本里用 math.randomTIME、以及 SPOP/SRANDMEMBER 等非确定性命令后再写入(报错 Write commands not allowed after non deterministic commands)。

5.0 之后(7.0 起是唯一方式):效果复制(effects replication)——只把脚本产生的实际写命令包在 MULTI/EXEC 里传播。

好处:

  1. 脚本可以自由使用随机数和时间
  2. 从节点不用重新执行脚本,省 CPU;
  3. 传播内容更小。

配套细节:脚本执行期间"时间是冻结的"——所有过期判断都用脚本开始时的时间,避免"脚本前半段 key 还在、后半段过期了"造成主从不一致。

Q12:Function 和 EVAL 有什么区别?

Function(7.0+)解决 EVAL 的五个痛点:

EVAL/脚本缓存 Function
持久化 ❌ 重启丢失,要处理 NOSCRIPT 写入 RDB/AOF,重启保留
复制 只复制效果,脚本本身不复制 函数库本身复制到从节点
命名 匿名(只有 SHA1,运维看不懂) 有库名和函数名
代码组织 单个脚本,逻辑只能复制粘贴 一个库多个函数,可共享辅助函数
可观测 只有 SCRIPT EXISTS FUNCTION LIST/STATS/DUMP
调用 EVAL/EVALSHA FCALL/FCALL_RO
参数形式 全局 KEYS/ARGV function(keys, args)

另外 7.0 引入了脚本标志no-writesallow-oomallow-staleno-cluster)。其中 no-writes 很实用:声明脚本只读后,它就可以在从节点上执行、且在内存超限时也不被拒绝。7.0 之前 Redis 无法知道脚本是否会写,只能保守地假设所有脚本都写。

Q13:Pipeline 是事务吗?

不是。 Pipeline 是客户端的批量发送技巧,服务端并不知道这些命令"属于一批"。

具体差别:

  • Pipeline 里的命令可能被其他客户端的命令插入(Redis 只保证每条命令自身原子);
  • 某条命令失败不影响其他命令
  • 目的是性能(省 RTT、省系统调用、省事件循环轮次),不是原子性。

要原子性有两个选择:MULTI/EXEC(可以整个塞进一个 pipeline 里发,兼得原子性和一次 RTT)或 Lua

很多客户端提供了 TxPipeline(go-redis)/ SessionCallback(Spring Data Redis),本质就是"用 MULTI/EXEC 包裹的 pipeline"。

Q14:一个"读取-判断-写入"的原子操作,有几种实现方式?怎么选?

以"库存足够就扣减"为例,四种方式:

(1)Lua 脚本(首选)

if tonumber(redis.call('GET', KEYS[1])) >= tonumber(ARGV[1]) then
    return redis.call('DECRBY', KEYS[1], ARGV[1])
end
return -1

一次往返、服务端原子、无需重试。绝大多数情况选这个。

(2)WATCH 乐观锁

需要重试循环,冲突率高时性能差。仅在判断逻辑必须在客户端(要调外部 API/查库)时用。

(3)利用原子命令的返回值

# DECRBY 会返回扣减后的值,如果为负说明超卖了,再补回来
result=$(DECRBY stock 1)
if [ $result -lt 0 ]; then
    INCRBY stock 1        # 回滚
    echo "库存不足"
fi

优点:不需要 Lua,一条命令搞定,性能最好。 缺点:有"瞬间为负"的中间状态(如果有其他逻辑在读这个值会看到负数),且回滚不是原子的。秒杀场景实际常用这招(配合下游校验)。

(4)分布式锁(悲观锁)

开销最大(加锁 + 业务 + 解锁至少 3 次往返),只在"临界区里要做的事很复杂、耗时长、涉及多个系统"时才用。见第 13 篇。


小结

  • Redis 事务 = MULTI 打标志 + 命令入队 + EXEC 连续执行;原子性来自单线程EXEC 是一条命令,执行期间不被打断)。
  • 两类错误处理截然不同入队错误(命令不存在/参数错)→ EXEC 返回 EXECABORT整个事务放弃执行时错误(类型错误)→ 只有那条失败,其他命令继续执行且不回滚
  • 事务不可嵌套:事务中再发 MULTI 返回 ERR MULTI calls can not be nested,但不会打 CLIENT_DIRTY_EXEC 标志、不会导致事务被放弃(与入队错误不同)。RESET(6.2+)可以把连接彻底重置回初始状态。
  • 不支持回滚的三个原因:Redis 认为执行失败只可能是编程 bug(RDBMS 的失败可能是正常业务约束)、支持回滚要付出内存/CPU/复杂度代价、AOF 是"后写日志"没有 undo log
  • ACID原子性部分满足(不回滚)、一致性满足隔离性满足且是串行化(单线程天然最高级别)、持久性不满足(取决于持久化 + 异步复制会丢)。
  • WATCH 是乐观锁(CAS):靠 db->watched_keys 字典 + CLIENT_DIRTY_CAS 标志实现;只要 key 被修改就 dirty,与改成什么值无关;必须在 MULTI 前执行;EXEC/DISCARD/UNWATCH 后清空监视,所以重试要重新 WATCH
  • Lua 脚本三大优势:真正原子(整个脚本是一条命令)、能做条件判断(事务做不到)、省网络往返。实际工程中已基本取代 MULTI/EXEC
  • Lua 也不能回滚——所以必须把所有校验写在前面、所有写操作写在后面
  • key 必须走 KEYS 传入(Cluster 路由和 ACL 检查需要),且能避免脚本缓存无限膨胀。7.0+ 集群下违规会直接报错。
  • 类型转换四坑:number 转 Redis 会截断小数、Redis 的 nil 在 Lua 里是 false、返回 table 遇 nil 截断数组、所有参数和返回值都是字符串(比较要 tonumber)。
  • 脚本阻塞是灾难级风险busy-reply-threshold 不会杀掉脚本,只是开始对其他客户端返回 BUSY写过数据的脚本无法 SCRIPT KILL,唯一出路是 SHUTDOWN NOSAVE(丢数据)。脚本必须简短、循环有小上限。
  • 脚本缓存不持久化(重启/SCRIPT FLUSH/主从切换都会丢),客户端必须实现 EVALSHANOSCRIPTEVAL 的回退;用客户端库的 NewScript 封装。
  • 5.0 起改为效果复制(只传播实际写命令),所以脚本可以自由用随机数/时间;脚本执行期间时间冻结保证主从一致。
  • Function(7.0+) 的核心改进:持久化 + 复制函数库本身 + 有名字 + 可组织多函数,不再需要处理 NOSCRIPT;no-writes 标志让只读脚本能在从节点执行。
  • 选型:不要原子性 → MGET/MSET 或 Pipeline;要原子性且需判断 → Lua;要原子性但无依赖 → MULTI/EXEC(塞进 pipeline);判断逻辑必须在客户端 → WATCH 重试。