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 的所有监视
几个重要细节:
WATCH必须在MULTI之前执行。在MULTI之后执行WATCH会报错WATCH inside MULTI is not allowed;- 只要 key 被"修改"就算 dirty,不管改成什么值。即使把 key 改成和原来一样的值(
SET k v两次),也会触发 dirty。这是基于版本而非基于值的判断; - key 被删除、过期也算修改;
EXEC、DISCARD、UNWATCH都会清空监视,所以每次重试都要重新WATCH;EXEC无论成功失败都会清空监视——这点很重要,如果不清空,客户端可能因为陈旧的 dirty 标志导致后续事务全部失败;WATCH是"连接级别"的:不同连接的 WATCH 互不影响,连接断开自动清理。
WATCH 的适用场景与局限
适合:并发冲突较少的场景(乐观锁的本质假设)。
不适合:
- 高并发热点 key:冲突率高,重试次数多,性能很差(活锁风险)。这种场景应该用 Lua 脚本(一次搞定,无需重试)或分布式锁(悲观锁);
- Cluster 模式下跨 slot 的 WATCH:所有被监视的 key 和事务里的 key 必须在同一个 slot;
- 需要跨多个 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 脚本相比事务的三大优势:
- 真正的原子性:整个脚本作为一条命令执行,期间绝对不会被打断;
- 可以有逻辑:能读取中间结果、做条件判断、循环——这是事务完全做不到的;
- 减少网络往返:把多次交互的逻辑一次性发到服务端执行。
所以任何"读取-判断-写入"的原子操作,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 和普通参数分开? 这不是"风格问题",而是必需的:
- Cluster 模式必须知道脚本会操作哪些 key,才能判断它们是否在同一个 slot、路由到哪个节点。如果把 key 藏在 ARGV 里,Redis 无法做正确的路由,会导致数据错乱;
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()
脚本缓存的几个重要注意点:
- 脚本缓存不持久化:
SCRIPT FLUSH、重启、DEBUG RELOAD都会清空。所以客户端必须实现 NOSCRIPT 回退,不能假设脚本永远在缓存里; - 缓存会无限增长:Redis 不会自动淘汰脚本缓存。如果你的代码动态生成脚本(比如把参数拼进脚本文本),每个变体都会占一份缓存,长期运行会造成内存泄漏。这是"参数必须走 ARGV 而不是拼进脚本"的另一个理由;
- 主从与集群的缓存不共享:脚本缓存是每个节点独立的。5.0 之前
SCRIPT LOAD不会传播到从节点(所以从节点上EVALSHA可能 NOSCRIPT);主从切换后新主节点可能没有这个脚本的缓存。7.0 的 Function 解决了这个问题。
3.8 脚本的复制方式
Redis 5.0 之前:脚本以脚本本身(verbatim)的方式复制到从节点和 AOF——从节点会重新执行一遍这个脚本。
这带来一个严格要求:脚本必须是确定性的,否则主从数据会不一致。所以那时 Redis:
- 禁止在脚本里用
math.random(除非先调math.randomseed,且 Redis 替换了随机数生成器保证主从种子相同); - 禁止用
TIME、SRANDMEMBER、SPOP等非确定性命令(除非之后不再写入); - 报错
Write commands not allowed after non deterministic commands。
Redis 5.0 之后(7.0 起成为唯一方式):改成效果复制(effects replication)——只把脚本产生的实际写命令包在一个 MULTI/EXEC 里传播。
好处:
- 脚本可以随意使用随机数、时间等非确定性操作;
- 从节点不用重新执行脚本,省 CPU;
- 传播的内容更小(只有实际的写命令)。
第 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 集群不可用,且唯一的解法是重启丢数据。所以:
- 脚本必须简短,最好是"几个命令 + 简单判断";
- 循环次数必须有明确上限,且上限要小(比如几百);
- 上线前必须在测试环境用生产规模的数据验证;
- 不要在脚本里遍历不可控大小的集合。
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:不能加载外部模块; - 禁止文件 IO(
io、os.execute); - 禁止网络访问;
- 全局变量被禁止(7.0+ 更严格):脚本里
x = 1会报错Script attempted to create global variable,必须用local x = 1。这是为了防止脚本之间互相污染。
可用的库:base、table、string、math、struct、cjson、cmsgpack、bit、os.time/os.clock(部分)。
4. Function(7.0+)
Function 是 7.0 引入的"下一代脚本机制",用来解决 EVAL/EVALSHA 的痛点。
4.1 EVAL 的痛点
- 脚本缓存不持久化:重启、
SCRIPT FLUSH后缓存丢失,客户端必须实现 NOSCRIPT 回退逻辑; - 主从/切换后缓存不一致:新主节点可能没有该脚本;
- 脚本是"匿名"的:只有 SHA1,运维完全不知道
4e6d8fc8...是干什么的; - 无法复用代码:多个脚本共享的逻辑只能复制粘贴;
- 应用和脚本耦合:脚本文本嵌在应用代码里,更新脚本要发版。
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:
- 能做条件判断:这是决定性的优势。绝大多数"原子操作"需求本质上是"读取-判断-写入",事务做不到;
- 不需要 WATCH 重试:Lua 是一次成功的悲观执行,
WATCH是可能反复失败的乐观锁。高并发热点 key 上WATCH的重试率极高,性能很差; - 网络往返更少:
WATCH方案至少要WATCH→GET→MULTI→EXEC多轮交互,Lua 一次搞定; - 代码更集中易读:业务逻辑写在一个脚本里,而不是散在客户端的重试循环中。
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 篇提过,这里强调易错点):
- 每批 100~1000 条,不要一次几万条——回复全堆在服务端输出缓冲区和客户端接收缓冲区里,可能触发
client-output-buffer-limit或撑爆内存; - 不要在 pipeline 里放阻塞命令(
BLPOP),会把整批卡住; - 集群模式要按节点分组(成熟客户端会自动做,但要注意
TxPipeline在集群下不能跨 slot); - 必须检查每条命令的错误,不能只看
Exec的返回值。
6. 高频面试题
Q1:Redis 事务的实现原理是什么?
非常简单,核心是命令队列 + 单线程:
MULTI:给 client 打上CLIENT_MULTI标志;- 后续命令:在
processCommand里发现有该标志,就只做基本检查(命令是否存在、参数个数是否正确),检查通过则追加到client->mstate.commands队列并返回QUEUED,不执行。检查失败则报错并打上CLIENT_DIRTY_EXEC标志。(MULTI/EXEC/DISCARD/WATCH/RESET例外,会立即执行); EXEC:- 有
CLIENT_DIRTY_EXEC(入队时有语法错误)→ 返回EXECABORT,整个事务放弃; - 有
CLIENT_DIRTY_CAS(WATCH 的 key 被改)→ 返回 nil,放弃; - 否则连续执行队列里所有命令,结果打包成数组返回。
- 有
原子性来自单线程:EXEC 本身是一条命令,执行期间不会被其他客户端打断。
Q2:Redis 事务满足 ACID 吗?
| 特性 | 是否满足 | 原因 |
|---|---|---|
| 原子性 | 部分 | 命令连续执行;入队错误会整体放弃(满足);但执行时错误不回滚(不满足) |
| 一致性 | 满足 | 没有复杂约束,数据不会进入非法中间状态 |
| 隔离性 | 满足(串行化) | 单线程执行,天然最高隔离级别,不存在脏读/不可重复读/幻读 |
| 持久性 | 不满足 | 取决于持久化配置;everysec 丢 1~2 秒;即使 always 也有窗口;异步主从复制还会丢 |
一句话:隔离性满分、一致性满分、原子性打折、持久性不及格。
Q3:Redis 事务为什么不支持回滚?
Redis 官方给出的理由:
-
Redis 认为执行失败只可能是"编程错误":执行时错误几乎只有一种成因——对某个 key 用了它不支持的命令(如对 string 用
LPUSH)。这属于应该在开发测试阶段消除的 bug,不是生产环境的正常业务状态。对比 RDBMS:SQL 事务需要回滚是因为失败原因可能是正常的业务约束(唯一键冲突、外键、死锁),这些是运行时才能确定的合法失败。Redis 没有这些约束机制。
-
不支持回滚让实现更简单、性能更好:回滚需要为每条命令保存 undo 信息(旧值),这要付出额外内存(大集合的旧值可能很大)、额外 CPU(拷贝)、和复杂的实现(每种数据结构都要实现 undo)。为了处理一种"本该消除的 bug"让所有正常操作付代价,不值得。
-
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):不加锁,先操作,提交时检查有没有冲突,有冲突就放弃重试。
实现:
- 每个
redisDb有一个watched_keys字典(key → 监视它的客户端列表); WATCH key把 (key → client) 记入这个字典,同时在 client 自己的watched_keys里也记一份;- 任何客户端修改某个 key 时(
signalModifiedKey),查watched_keys找到所有监视者,给它们打上CLIENT_DIRTY_CAS标志; EXEC时检查该标志:有 → 放弃事务返回 nil;无 → 正常执行;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 传入?
不是编码风格问题,是硬性要求:
- Cluster 模式必须知道脚本涉及哪些 key,才能判断它们是否在同一 slot、把请求路由到正确的节点。如果 key 藏在 ARGV 里或在脚本里拼接,Redis 无法正确路由,会读写到错误的节点上;
- ACL 权限检查需要知道涉及的 key(
~pattern形式的 key 权限); - 复制、
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:EVAL 和 EVALSHA 的区别?为什么客户端要处理 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 → 只返回 1 和 2
(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.random、TIME、以及 SPOP/SRANDMEMBER 等非确定性命令后再写入(报错 Write commands not allowed after non deterministic commands)。
5.0 之后(7.0 起是唯一方式):效果复制(effects replication)——只把脚本产生的实际写命令包在 MULTI/EXEC 里传播。
好处:
- 脚本可以自由使用随机数和时间;
- 从节点不用重新执行脚本,省 CPU;
- 传播内容更小。
配套细节:脚本执行期间"时间是冻结的"——所有过期判断都用脚本开始时的时间,避免"脚本前半段 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-writes、allow-oom、allow-stale、no-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/主从切换都会丢),客户端必须实现EVALSHA→NOSCRIPT→EVAL的回退;用客户端库的NewScript封装。 - 5.0 起改为效果复制(只传播实际写命令),所以脚本可以自由用随机数/时间;脚本执行期间时间冻结保证主从一致。
- Function(7.0+) 的核心改进:持久化 + 复制函数库本身 + 有名字 + 可组织多函数,不再需要处理 NOSCRIPT;
no-writes标志让只读脚本能在从节点执行。 - 选型:不要原子性 →
MGET/MSET或 Pipeline;要原子性且需判断 → Lua;要原子性但无依赖 →MULTI/EXEC(塞进 pipeline);判断逻辑必须在客户端 →WATCH重试。
xingliuhua