数据库基础体系 · 第 120/139 篇。文章以各产品官方稳定版本的公开语义为准;示例会明确引擎、事务与部署边界。

Redis Pipeline、事务与 Lua:原子性、脚本缓存和集群边界

在 Redis 中,Pipeline、事务和 Lua 脚本都可以减少客户端与服务器之间的往返,甚至都能用于“把多个操作放在一起执行”。但它们解决的问题并不相同:

  • Pipeline 主要解决网络往返次数问题,不自动提供批量原子性。
  • 事务 通过 MULTIEXECWATCH 组织一组命令,提供连续执行和条件提交能力,但没有传统数据库意义上的回滚。
  • Lua 脚本 把条件判断和多个 Redis 命令放到服务器端一次执行,通常是实现 Redis 内原子业务逻辑的直接方式。
  • 脚本缓存 只缓存脚本文本与 SHA-1 的映射,不是持久化代码部署机制。
  • Redis Cluster 要求多键操作、事务和脚本访问的键位于同一个哈希槽;Pipeline 则可以跨节点,但必须由集群感知客户端正确路由。

理解这些边界,需要先区分三个概念:网络批处理、命令执行连续性、业务状态的条件变更


一、先定义“原子性”到底指什么

在 Redis 语境中,“原子”至少有三种容易混淆的含义。

1. 单条命令的原子执行

Redis 对单条命令的执行通常不会被另一条命令插入。例如:

INCR stock

在该命令执行期间,其他客户端不会看到“只加了一半”的中间状态。

这只保证单条命令的执行过程不可被并发命令打断,并不保证多条命令之间没有并发插入。

2. 一组命令之间没有外部命令插入

假设有两个客户端:

客户端 A:GET balance
客户端 A:SET balance ...
客户端 B:INCR balance

如果 A 的两个命令之间允许 B 执行,那么 A 的“读—改—写”就不是一个不可分割的操作。

事务和 Lua 脚本可以让一组命令连续执行:

A 的第一条命令
A 的第二条命令
A 的第三条命令

在这段执行区间内,其他客户端的命令不会插入。

3. 崩溃后的持久性

原子执行不等于持久化。例如,一个 Lua 脚本已经完成,客户端收到成功响应,但 Redis 随后所在主节点宕机,而副本尚未收到或尚未落盘这次修改,那么故障转移后可能出现数据丢失。

因此:

原子性持久性高可用一致性\text{原子性} \neq \text{持久性} \neq \text{高可用一致性}

本文主要讨论执行原子性,同时会说明复制、故障转移和集群对它的影响。


二、Pipeline:把网络等待批量化,但不提供批量原子性

2.1 Pipeline 解决的是 RTT,不是并发隔离

没有 Pipeline 时,客户端通常按以下过程发送命令:

客户端 -> SET a 1
客户端 <- OK

客户端 -> SET b 2
客户端 <- OK

客户端 -> GET a
客户端 <- 1

如果客户端与 Redis 之间的网络往返延迟为 rr,那么即使命令本身执行很快,三个命令也可能产生接近 3r3r 的等待。

Pipeline 会先连续发送多个命令,再连续读取响应:

客户端 -> SET a 1
客户端 -> SET b 2
客户端 -> GET a
客户端 <- OK
客户端 <- OK
客户端 <- 1

服务器仍然是逐条执行命令,但客户端不必在每条命令后等待响应。

Pipeline 的核心收益可以近似理解为:

T总耗时T命令执行+N批次×RTTT_{\text{总耗时}} \approx T_{\text{命令执行}} + N_{\text{批次}} \times RTT

其中:

  • T命令执行T_{\text{命令执行}} 是 Redis 执行命令所需的时间;
  • N批次N_{\text{批次}} 是发送和读取的批次数;
  • RTTRTT 是一次网络往返时间。

Pipeline 主要减少的是 N批次N_{\text{批次}},而不是让 Redis 把命令变成一个事务。

2.2 Pipeline 中其他客户端仍可能插入

假设客户端 A 发送:

SET balance 100
INCRBY balance 10
GET balance

这些命令通过同一个 Pipeline 一次发出,Redis 通常会按收到的顺序执行。但在 A 的命令之间,其他客户端的命令仍可能执行:

A: SET balance 100
B: INCRBY balance 50
A: INCRBY balance 10
A: GET balance       -> 160

因此,以下说法是错误的:

“Pipeline 中的命令会作为一个整体原子执行。”

正确说法是:

Pipeline 批量发送多个独立命令;每条命令的执行语义不变,命令之间不自动形成事务边界。

即使命令在某个客户端连接上连续到达,也不能把“连续发送”理解为“其他客户端不能插入”。

2.3 Pipeline 的执行顺序

对于同一个连接,服务器通常按照协议读取到的顺序处理命令,因此客户端可以依赖同一 Pipeline 内的命令顺序:

SET x 10
INCR x
GET x

结果应为:

OK
11
11

但是,这个顺序保证不等于跨客户端的隔离保证。另一个客户端仍可能在这些命令之间执行命令。

2.4 Pipeline 中的错误

Pipeline 中每个命令都有独立响应。例如:

SET count 1
INCR count
HGET count field

可能得到:

OK
ERR value is not an integer or out of range
(nil)

一条命令失败,通常不会自动撤销此前成功的命令,也不会自动阻止后续命令被执行。

这与事务中的“排队阶段错误”和“执行阶段错误”还需要区分,后文会详细说明。

2.5 Pipeline 的适用场景

Pipeline 适合:

  • 批量写入互不依赖的键;
  • 批量读取多个键;
  • 批量删除或设置过期时间;
  • 需要降低网络往返、但不需要跨命令原子性的任务。

例如:

SET user:1:name Alice
SET user:2:name Bob
SET user:3:name Carol

这些写入彼此独立,即使其中一条失败,也可以由客户端根据每条响应单独处理。

如果业务要求:

读取库存
如果库存大于 0
库存减 1
记录订单

那么单纯 Pipeline 不够,因为这是一段需要条件判断和并发隔离的逻辑。


三、Redis 事务:MULTI、EXEC、WATCH 的真实语义

Redis 事务主要由以下命令组成:

MULTI
...
EXEC

条件事务还会使用:

WATCH key ...
UNWATCH
DISCARD

它与关系型数据库事务有相似之处,但不能直接套用“原子提交、回滚、隔离级别”这些概念。


四、MULTI/EXEC:先排队,再连续执行

4.1 基本流程

客户端发送:

MULTI
SET user:1:name Alice
INCR user:1:login_count
EXEC

典型响应为:

OK
QUEUED
QUEUED
1) OK
2) (integer) 1

执行过程可以分为两个阶段。

阶段一:排队

执行 MULTI 后,Redis 进入事务状态。之后收到的普通命令不会立即执行,而是放入当前客户端连接的事务队列,并返回:

QUEUED

阶段二:提交执行

收到 EXEC 后,Redis 依次执行队列中的命令,并返回一个与命令顺序对应的结果数组。

关键点是:

事务中的命令在 EXEC 阶段连续执行;其他客户端的命令不会插入这一段执行过程。

因此,下面的操作不会被其他客户端插入:

命令 1
命令 2
命令 3

但这只保证执行连续性,不提供失败回滚。

4.2 事务没有传统意义上的回滚

例如:

SET balance 100
MULTI
DECRBY balance 30
INCRBY balance invalid
INCR audit:count
EXEC
GET balance
GET audit:count

结果可能类似:

1) (integer) 70
2) (error) ERR value is not an integer or out of range
3) (integer) 1

最终状态是:

balance    = 70
audit:count = 1

第二条命令失败,并不会撤销第一条命令,也不会自动撤销第三条命令。

因此 Redis 事务更准确的描述是:

将一组已排队的命令连续执行,并返回每条命令的结果;它不是带有撤销日志的传统回滚事务。

4.3 排队阶段错误与执行阶段错误

两类错误的行为不同。

排队阶段错误

例如命令参数数量明显错误:

MULTI
SET only-one-argument
EXEC

Redis 可以在排队时发现命令格式错误。在现代 Redis 语义中,这类事务错误会使事务无法正常执行,EXEC 会报告事务错误,而不是把这条非法命令当作正常命令执行。

客户端应把 EXEC 阶段的事务错误视为整个事务提交失败。

执行阶段错误

命令参数在语法上合法,但数据状态不满足要求:

SET count abc
MULTI
INCR count
SET marker done
EXEC

INCR count 在真正执行时发现 count 不是整数。此时它返回错误,但 SET marker done 仍然可能继续执行。

这就是为什么客户端不能只检查 EXEC 是否返回了一个数组,还必须检查数组中的每个元素。

4.4 DISCARD 的作用

如果事务尚未执行,可以使用:

MULTI
SET a 1
SET b 2
DISCARD

结果是队列被丢弃,ab 都不会被修改。

DISCARD 是“尚未执行时取消排队”,不是“执行失败后的回滚”。


五、WATCH:用乐观锁保护读—改—写

MULTI/EXEC 本身不能阻止客户端在事务开始前读到过期状态。下面是一个库存扣减的竞争场景:

初始:

stock = 1

两个客户端都执行:

GET stock

都读到 1,随后都决定执行:

SET stock 0

最后库存是 0,但两个客户端都可能认为自己扣减成功,导致超卖。

5.1 WATCH 的条件提交语义

WATCH 会监视键。如果从 WATCHEXEC 期间,被监视的键发生修改,则 EXEC 放弃执行事务队列。

示例:

WATCH stock
GET stock
MULTI
DECR stock
EXEC

如果另一个客户端在 WATCH 之后修改了 stock,那么:

(nil)

或客户端库对应的“事务被放弃”结果表示条件不再满足。

完整的客户端逻辑应是:

  1. WATCH stock
  2. 读取当前库存;
  3. 如果库存不足,取消;
  4. 执行 MULTI
  5. 排队扣减命令;
  6. 执行 EXEC
  7. 如果事务被放弃,重新读取并重试;
  8. 如果事务成功,再检查每条命令的返回值。

5.2 WATCH 保护的是什么

WATCH 保护的是:

读取到的版本=提交时仍然有效的版本\text{读取到的版本} = \text{提交时仍然有效的版本}

它不是锁,不会阻止其他客户端读取或修改键。它通过“检测冲突并放弃提交”实现乐观并发控制。

形式化地说,客户端希望执行:

读取 S
根据 S 计算 S'
提交 S -> S'

只有在提交时仍满足:

S当前=S读取S_{\text{当前}} = S_{\text{读取}}

事务才执行。

如果期间发生修改:

S当前S读取S_{\text{当前}} \neq S_{\text{读取}}

EXEC 失败,客户端需要重新计算。

5.3 WATCH 的边界

需要注意以下几点:

  • WATCH 只在当前连接上生效;
  • 连接断开后,监视状态不会跨连接保留;
  • 事务被 EXECDISCARD 或连接断开后,相关监视状态会被清理;
  • 被监视键的修改包括多种导致键状态变化的操作,不应只按“值是否最终改变”进行业务假设;
  • 高竞争场景下,重试次数可能增长,客户端必须设置退避、上限和失败处理。

如果业务逻辑本身能直接写成服务器端脚本,Lua 通常比“读取、计算、WATCH、重试”更简单。


六、事务与 Pipeline 的组合

Pipeline 和事务可以组合使用:

MULTI
SET a 1
SET b 2
EXEC

客户端可以将上述命令一次发送,减少网络往返。

但要区分两层语义:

  • Pipeline:减少客户端等待;
  • MULTI/EXEC:使 EXEC 内的命令连续执行。

下面这种写法仍然只是 Pipeline:

SET a 1
SET b 2

而下面才是事务:

MULTI
SET a 1
SET b 2
EXEC

Pipeline 不会自动把普通命令转换成事务。


七、Lua 脚本:把条件判断放到 Redis 内部

7.1 为什么 Lua 能实现业务原子性

Redis 服务器执行 Lua 脚本时,会在脚本执行期间连续执行脚本中的 Redis 命令。其他客户端的命令不能插入脚本中间。

因此,下面的逻辑可以在服务器端作为一个不可插入的整体执行:

local stock = redis.call('GET', KEYS[1])

if not stock then
    return -1
end

stock = tonumber(stock)

if stock <= 0 then
    return 0
end

redis.call('DECR', KEYS[1])
return 1

调用:

SET stock 1
EVAL "local n=tonumber(redis.call('GET',KEYS[1])); if n and n>0 then redis.call('DECR',KEYS[1]); return 1 else return 0 end" 1 stock
GET stock

预期结果:

(integer) 1
"0"

第二个并发客户端不能在脚本读取 stock 和执行 DECR 之间插入修改。

7.2 用 Lua 实现“有库存才扣减”

把脚本写入文件 decr_if_positive.lua

local current = redis.call('GET', KEYS[1])

if not current then
    return 0
end

current = tonumber(current)

if current <= 0 then
    return 0
end

redis.call('DECR', KEYS[1])
return 1

执行:

redis-cli SET stock 2
redis-cli --eval decr_if_positive.lua stock
redis-cli GET stock

预期:

OK
(integer) 1
"1"

脚本的输入约定是:

  • KEYS[1]:库存键;
  • 返回 1:扣减成功;
  • 返回 0:库存不存在或已耗尽。

脚本没有把键名直接写死,而是通过 KEYS 传入。这不仅便于复用,也是 Redis Cluster 正确分析脚本涉及键的必要方式。

7.3 Lua 脚本出错也没有回滚

例如:

redis.call('SET', KEYS[1], 'changed')
redis.call('INCR', KEYS[2])
return 1

如果 KEYS[2] 的值不是整数,第二条命令会报错。第一条 SET 已经发生的修改不会因为脚本报错而自动撤销。

所以 Lua 提供的是:

  • 脚本执行期间不被其他客户端插入;
  • 脚本内命令按顺序执行;
  • 条件判断与修改在同一个服务器端执行单元中完成。

它不提供:

  • 脚本失败后的自动回滚;
  • 跨节点事务;
  • 崩溃后的自动恢复到脚本执行前状态。

7.4 redis.call 与 redis.pcall

Lua 中常见的两个调用接口:

redis.call(...)
redis.pcall(...)

redis.call 遇到 Redis 命令错误时,会抛出脚本错误。

redis.pcall 会把错误作为 Lua 表值返回,脚本可以自行判断:

local result = redis.pcall('INCR', KEYS[1])

if type(result) == 'table' and result['err'] then
    return 0
end

return result

这允许脚本处理某些预期错误,但不能因此获得事务回滚能力。脚本在错误前做出的写入仍然存在。

7.5 Lua 返回值映射

Redis 会把 Lua 返回值转换为 RESP 响应。常见映射包括:

Lua 返回值 Redis 客户端常见结果
Lua 数字 Redis 整数
Lua 字符串 Bulk String
false Null
Lua 表 数组
redis.error_reply(...) 错误
redis.status_reply(...) 状态字符串

脚本返回业务状态时,最好定义稳定的返回协议。例如:

return {1, current - 1}

客户端约定:

[1, 4] -> 扣减成功,剩余 4
[0, 0] -> 扣减失败

这样比依赖错误字符串更适合业务代码。


八、脚本的原子性不等于可以无限执行

Redis 的命令执行模型要求单条命令和脚本不能长时间阻塞服务器。

脚本执行期间,其他客户端命令无法插入。因此一个长脚本会造成:

  • 普通读请求排队;
  • 写请求延迟升高;
  • 心跳、故障检测和客户端请求出现超时;
  • 集群节点被误判为不可用的风险增加。

Redis 提供脚本时间限制相关配置和管理命令,但需要正确理解:

  • 超过时间限制通常会触发日志或管理层面的警告;
  • Redis 不会随意从任意位置安全地强制终止正在修改数据的脚本;
  • SCRIPT KILL 只有在脚本尚未执行写操作等条件下才可能安全终止;
  • 对已经执行写操作的脚本,强制终止可能破坏脚本设计的执行连续性,因此通常不能直接杀死。

Lua 脚本应当处理有限数量的键和元素,避免在脚本内扫描巨大集合、执行无界循环或批量处理不可预测规模的数据。


九、EVAL 与 EVALSHA:脚本缓存是什么

9.1 EVAL 直接发送脚本文本

基本形式:

EVAL "<script>" <numkeys> [key ...] [arg ...]

例如:

EVAL "return redis.call('INCRBY', KEYS[1], ARGV[1])" 1 counter 5

其中:

  • 1 表示后面有一个键;
  • counter 进入 KEYS[1]
  • 5 进入 ARGV[1]

脚本内部:

KEYS[1] == "counter"
ARGV[1] == "5"

键应通过 KEYS 传递,普通业务参数应通过 ARGV 传递。

9.2 EVALSHA 只发送脚本摘要

客户端可以先计算脚本的 SHA-1 摘要,然后使用:

EVALSHA <sha1> <numkeys> [key ...] [arg ...]

例如:

SHA=$(printf "return redis.call('INCRBY', KEYS[1], ARGV[1])" | sha1sum | cut -d ' ' -f 1)
redis-cli EVALSHA "$SHA" 1 counter 5

如果目标 Redis 实例没有这个脚本,会返回:

NOSCRIPT No matching script. Please use EVAL.

客户端通常采用以下流程:

  1. 优先发送 EVALSHA
  2. 收到 NOSCRIPT
  3. 发送 EVAL,让服务器加载并执行;
  4. 后续继续使用 EVALSHA

也可以显式预加载:

SCRIPT LOAD "return redis.call('INCRBY', KEYS[1], ARGV[1])"

SCRIPT LOAD 返回脚本 SHA-1,但只负责加载,不执行脚本:

"b..." 

之后:

EVALSHA b... 1 counter 5

9.3 脚本缓存不是持久化缓存

脚本缓存具有以下特征:

  • 以脚本内容的 SHA-1 作为标识;
  • 缓存的是脚本文本;
  • 脚本缓存属于具体 Redis 实例的运行时状态;
  • Redis 重启、实例替换或故障转移到另一节点后,不应假设缓存仍存在;
  • 缓存命中只减少脚本文本传输,不改变脚本执行语义。

因此,生产客户端不能把“启动时 SCRIPT LOAD 一次”当作永久部署。正确做法是始终处理 NOSCRIPT

9.4 SHA-1 与脚本内容必须完全匹配

下面两段脚本逻辑相同但文本不同:

return 1
return 1 

它们的 SHA-1 不同。客户端应在构建阶段固定脚本文件和摘要,不要在不同服务版本中动态拼接脚本内容。

将业务值拼接到脚本文本中也是不必要的:

EVAL "return redis.call('SET','user:1','Alice')" ...

更安全、可复用的方式是:

return redis.call('SET', KEYS[1], ARGV[1])

调用:

EVALSHA <sha1> 1 user:1 Alice

这样脚本内容保持不变,变化的数据通过参数传入。


十、脚本缓存与主从、故障转移

脚本缓存的作用范围是具体 Redis 节点,而不是抽象的服务名。

例如客户端连接到主节点 A:

EVALSHA sha1 ...

A 已缓存该脚本,因此执行成功。随后发生故障转移,客户端改连主节点 B。即使 B 拥有相同数据,也不能假设 B 已缓存该脚本,下一次可能得到:

NOSCRIPT

客户端必须能够在新的目标节点上重新加载脚本。

在复制和故障转移环境中,还要区分两件事:

  1. 脚本是否已被目标节点缓存
  2. 脚本造成的数据修改是否已复制到副本并在故障转移后保留

前者是脚本缓存问题,后者是复制和持久性问题。处理了 NOSCRIPT,并不代表已经处理了数据丢失风险。

如果业务需要等待副本确认,可以在普通写入之后使用 WAIT 等机制降低异步复制带来的风险,但这不等于共识提交,也不能把 Redis 变成强一致的分布式事务系统。


十一、Redis Cluster:哈希槽决定多键操作边界

Redis Cluster 将键空间划分为固定数量的哈希槽。普通键通过键名计算槽位:

slot(key)=CRC16(key)mod16384slot(key) = CRC16(key) \bmod 16384

实际计算还要考虑哈希标签:如果键名包含一对 {},且其中有非空内容,则通常只对大括号内部内容计算槽位。

例如:

user:{42}:profile
user:{42}:quota
user:{42}:orders

这三个键的哈希标签都是 42,因此会落入同一个槽。

11.1 为什么多键命令要求同槽

以下命令同时访问多个键:

MGET user:1 user:2

在 Cluster 中,如果两个键不在同一个槽,服务器可能返回:

CROSSSLOT Keys in request don't hash to the same slot

原因不是 MGET 本身无法执行,而是一个集群节点不能对分散在不同节点的数据提供单节点原子处理。

同理,以下操作也不能跨槽作为一个 Redis 原子单元执行:

  • MULTI/EXEC 中涉及不同槽的键;
  • Lua 脚本通过多个 KEYS 访问不同槽的键;
  • 需要多个键共同参与判断和修改的命令。

11.2 Pipeline 可以跨槽,但必须由客户端拆分

Pipeline 与事务不同。集群感知客户端可以把命令按目标节点拆开:

命令 A -> 节点 1
命令 B -> 节点 2
命令 C -> 节点 1

这种“集群 Pipeline”需要客户端处理:

  • 根据键计算目标槽位;
  • 根据槽位选择节点;
  • MOVED 重定向;
  • ASK 重定向;
  • 节点拓扑刷新;
  • 节点故障和重试。

但跨节点 Pipeline 没有全局原子性。节点 1 上的命令成功、节点 2 上的命令失败时,不存在一个跨节点 EXEC 能自动撤销节点 1 的修改。

11.3 Cluster 中的事务

集群事务要求事务涉及的所有键属于同一槽。例如:

MULTI
SET order:{42}:status paid
INCR order:{42}:version
EXEC

两个键都使用 {42},因此可以在同一节点上作为事务执行。

下面这种写法可能跨槽:

MULTI
SET order:42:status paid
INCR order:42:version
EXEC

即使业务上它们都属于订单 42,Redis Cluster 只根据键名计算槽位,并不知道 order:42 代表同一个业务对象。

11.4 Cluster 中执行 Lua

Cluster 客户端发送脚本时,必须能够从 KEYS 参数判断脚本应该路由到哪个节点。脚本涉及的所有键必须位于同一个槽。

正确示例:

EVAL "
local value = redis.call('GET', KEYS[1])
if value then
  redis.call('SET', KEYS[2], value)
  return 1
end
return 0
" 2 cache:{42}:source cache:{42}:copy

两个键都使用相同哈希标签 {42}

不应把键名藏在 ARGV 中:

local key = ARGV[1]
redis.call('GET', key)

调用者可能认为这样可以绕过 Cluster 的键检查,但这不是正确的集群用法。脚本访问的键应声明在 KEYS 中,客户端和服务器才能正确进行路由、校验和审计。

此外,即使所有键同槽,脚本也只能保证在负责该槽的单个节点上原子执行,不能跨多个主节点组成分布式原子事务。


十二、Cluster 重分片、MOVED 与脚本执行失败

在集群拓扑变化期间,客户端可能遇到:

MOVED <slot> <host>:<port>

这表示该槽已经由另一个节点负责,客户端应刷新槽位映射并重试。

ASK 表示迁移中的临时路由,需要按照协议先向目标节点发送 ASKING,再发送命令。成熟的集群客户端通常会处理这些细节。

但“自动重试”存在业务风险:

  • 命令可能已经在服务器执行成功;
  • 客户端可能只是没有收到响应;
  • 客户端重试可能造成重复写入。

对于 INCR、扣库存、发放奖励等非幂等操作,不能简单地看到连接错误就无限重试。可以通过业务幂等键、唯一标记或脚本中的去重记录来降低重复执行风险,但这仍需让去重键与业务键位于同一槽。


十三、Pipeline、事务和 Lua 的对照算例

考虑一个业务:

如果库存 stock > 0:
    库存减 1
    将订单 order 标记为 created
    返回成功
否则:
    返回失败

13.1 仅使用 Pipeline:存在竞态

客户端先读取,再批量发送修改:

GET stock
SET stock 0
SET order:1 created

两个客户端都可能先读到库存为 1,然后都执行修改。Pipeline 没有把“判断”和“修改”绑定成一个不可插入的单元。

13.2 使用 WATCH:可以检测冲突

WATCH stock
GET stock
MULTI
DECR stock
SET order:{1}:status created
EXEC

如果两个客户端都监视同一个 stock,其中一个客户端修改后,另一个客户端的 EXEC 会放弃执行。

但是:

  • 客户端需要重试;
  • 如果还要根据多个键计算状态,监视范围会扩大;
  • 高竞争时可能反复失败;
  • EXEC 内部某条命令运行时出错,仍然没有回滚。

13.3 使用 Lua:把条件和修改放在一起

local stock = redis.call('GET', KEYS[1])

if not stock then
    return 0
end

stock = tonumber(stock)

if stock <= 0 then
    return 0
end

redis.call('DECR', KEYS[1])
redis.call('SET', KEYS[2], ARGV[1])

return 1

调用:

SET stock:{1} 1
EVAL "<上面的脚本>" 2 stock:{1} order:{1}:status created

由于两个键使用同一个哈希标签 {1},在 Cluster 中可以路由到同一节点。脚本一次完成检查和修改,其他客户端不能插入。

但如果第二个 SET 因为某种原因执行报错,前面的 DECR 不会自动恢复。因此,脚本还需要:

  • 在脚本内校验数据类型和参数;
  • 设计明确的错误协议;
  • 避免可能在中途失败的复杂逻辑;
  • 对跨系统动作不做“伪原子”承诺。

十四、脚本与事务的复制语义边界

Redis 主从复制默认是异步的。主节点执行命令后,副本可能稍后才收到对应修改。

这会导致以下现象:

  1. 主节点上的脚本已返回成功;
  2. 主节点故障;
  3. 副本被提升;
  4. 副本缺少最近一段修改;
  5. 客户端看到业务状态回退。

脚本的原子性只描述主节点执行时的并发可见性,不表示副本和主节点永远同时完成状态更新。

此外,脚本应尽量保持复制可重放所需的确定性。不要把依赖本地文件、网络、随机外部状态的逻辑放进脚本;Lua 脚本本身也不能通过网络调用外部服务或访问操作系统资源。

在设计“生成序列号”“设置过期时间”“随机抽样”等逻辑时,应特别检查脚本使用的命令在目标 Redis 版本中的脚本和复制语义。不要仅因为脚本在单节点测试中成功,就推断它在复制、AOF 重写和故障转移场景下具有相同效果。


十五、Redis Functions 与 Lua 脚本的关系

较新的 Redis 版本还提供 Redis Functions。它们同样使用 Lua,但生命周期不同:

  • EVAL / EVALSHA 适合按脚本调用;
  • Functions 可以把函数库加载到 Redis,并通过函数名调用;
  • Functions 更适合管理一组长期存在的服务器端逻辑;
  • 脚本缓存中的 SHA-1 与函数库的持久化、加载和版本管理不是同一套机制。

如果系统使用 Functions,应按照目标 Redis 版本的官方命令和部署语义管理库的加载、替换和故障恢复,不能把它简单等同于“预先执行一次 SCRIPT LOAD”。

本文的 EVALEVALSHA 示例讨论的是传统脚本缓存机制。无论使用脚本还是 Functions,都仍然受到以下限制:

  • 执行期间阻塞其他命令;
  • 只能在单个 Redis 执行上下文内保证原子性;
  • Cluster 中多键访问必须满足槽位约束;
  • 执行成功与数据持久化、跨节点提交不是同一概念。

十六、如何选择

可以用下面的判断顺序选择机制。

16.1 只需要减少网络往返

使用 Pipeline:

多个独立命令
不要求命令之间形成整体

例如批量设置多个互不依赖的缓存键。

16.2 命令需要连续执行,但没有复杂条件判断

使用 MULTI/EXEC

MULTI
命令 1
命令 2
EXEC

例如一次性更新同一对象的多个字段,并要求其他客户端不能在两条更新之间插入。

16.3 需要“读取—判断—修改”在服务器端完成

优先考虑 Lua:

读取状态
条件判断
修改状态
返回业务结果

例如扣库存、限流计数、幂等去重和带条件的过期时间更新。

16.4 需要客户端基于读取结果提交

使用 WATCH

WATCH
读取
MULTI
写入
EXEC
失败则重试

它适合乐观并发控制,但必须认真处理事务被放弃、重试、超时和非幂等操作。

16.5 需要跨多个 Cluster 节点的强一致业务操作

Pipeline、Redis 事务和单个 Lua 脚本都不能直接提供这个能力。此时需要重新设计数据模型,例如:

  • 让相关键使用相同哈希标签,放入同一槽;
  • 把需要共同变更的状态聚合到一个键中;
  • 使用业务幂等和补偿机制;
  • 对真正跨资源的事务使用适合该场景的外部协调或事务系统。

十七、生产问题的诊断路径

17.1 看到 CROSSSLOT

检查:

  1. 命令是否涉及多个键;
  2. 事务或脚本的所有 KEYS 是否属于同一槽;
  3. 相关键是否应使用同一个哈希标签;
  4. 是否错误地把业务同属一个对象理解成 Redis 会自动识别。

可以使用:

CLUSTER KEYSLOT order:{42}:status
CLUSTER KEYSLOT order:{42}:version

预期两个命令返回相同槽位。

17.2 看到 NOSCRIPT

检查:

  1. 客户端是否只在启动时加载过脚本;
  2. 当前连接是否已切换到新的主节点;
  3. 连接池中是否存在多个目标节点;
  4. 是否在 Cluster Pipeline 中正确向每个目标节点加载;
  5. 是否误用了不同文本对应的 SHA-1。

恢复方式是对产生 NOSCRIPT 的目标节点发送 EVALSCRIPT LOAD,而不是只向原主节点加载。

17.3 事务结果数组中出现错误

不要只判断 EXEC 成功返回了数组。应逐项检查:

[OK, error, OK]

表示事务队列被执行,但中间某条命令在运行时失败。Redis 不会自动回滚前后的成功修改。

17.4 延迟突然升高

如果 Redis 在执行大量 Lua 或事务命令时延迟升高,应检查:

  • 脚本是否遍历大集合;
  • 是否存在无界循环;
  • 单次 Pipeline 是否过大;
  • 是否把大量命令错误地放进一个事务;
  • 是否存在慢命令或热键竞争;
  • 主节点是否同时承担了过重的脚本和复制压力。

可以结合慢日志、延迟监控、命令耗时和脚本执行日志定位,但不要通过盲目执行 SCRIPT KILL 处理问题。先确认脚本是否已经写入数据,以及终止是否安全。


十八、容易混淆的结论

最后将几个经常被混用的说法改写为更准确的版本:

  • “Pipeline 是原子的”
    应改为:Pipeline 减少网络往返,但其中命令默认不是一个原子批次。

  • “Redis 事务失败会回滚”
    应改为:事务没有传统回滚;执行阶段某条命令失败,不会撤销其他已执行命令。

  • “Lua 可以保证数据永不丢失”
    应改为:Lua 保证单节点脚本执行期间的连续性,不保证复制、持久化和故障转移后的零丢失。

  • “EVALSHA 加载一次就永久可用”
    应改为:脚本缓存是节点运行时状态,重启、替换节点或故障转移后必须处理 NOSCRIPT

  • “Cluster 客户端会自动让多键脚本跨节点执行”
    应改为:一个事务或脚本涉及的键必须在同一槽;跨节点 Pipeline 可以由客户端拆分,但没有跨节点原子性。

  • “事务和 Lua 完全等价”
    应改为:事务提供命令队列和可选的乐观检查;Lua 把判断与修改放在服务器端一次执行,表达复杂原子逻辑通常更直接,但两者都不提供自动回滚。

掌握这些边界后,Pipeline、事务和 Lua 就不再只是三种“批量执行方式”,而是分别对应网络优化、连续命令执行和服务器端业务原子逻辑。真正的设计重点始终是:相关状态是否在同一执行节点、失败后是否需要回滚、客户端是否能处理重试与重定向,以及主节点成功响应后业务能否接受异步复制和故障转移带来的状态窗口。


系列导航与关联阅读

官方资料

本文依据数据库官方文档重新梳理;正文、示例与生产检查清单由 WR BLOG 编写。