面试考察点

  • 是否区分减少网络往返与保证原子执行。
  • 能否解释 MULTI/EXEC、WATCH 和 Lua 的语义。
  • 是否理解 Redis 事务没有数据库式自动回滚。

核心答案

Pipeline 在客户端批量发送命令,主要减少网络往返,不保证其他客户端命令不会穿插;MULTI/EXEC 把命令排队后连续执行,可配合 WATCH 做乐观并发控制;Lua 脚本在服务端原子执行,适合带条件的多步读写。

Redis 事务执行中某条命令发生运行时错误,其他已排队命令仍可能继续执行,不提供关系数据库那样的自动回滚。

选择原则

只为吞吐批处理使用 Pipeline;需要无条件连续执行一组命令可用事务;需要“读取后判断再写入”的原子逻辑优先 Lua 或已有原子命令。Cluster 中多 Key 操作还要求 Key 位于同一槽。

三者解决的问题不同

工具 是否减少网络往返 是否保证连续执行 是否能按读取结果分支 是否自动回滚
Pipeline
MULTI/EXEC 可批量发送 WATCH 仅决定是否执行
Lua 脚本整体原子 否,脚本内部错误不会撤销已执行写入

原子性意味着脚本或 EXEC 期间其他客户端命令不会穿插,不意味着所有命令成功或失败后状态自动恢复。把数据库事务的 ACID 直觉直接套到 Redis 是常见错误。

Pipeline 的使用边界

Pipeline pipeline = redis.pipelined();
for (String id : ids) {
    pipeline.get("product:" + id);
}
List<Object> results = pipeline.syncAndReturnAll();

客户端连续发送多条命令后统一读取响应,显著减少 RTT,适合批量预热、批量读取和迁移工具。批次过大时,客户端输出缓冲、服务端输入队列和响应列表都会占用内存;应按字节数、命令数和超时分批。Pipeline 中其他客户端仍可穿插执行,所以不能用于 read-modify-write 的原子业务。

WATCH 的乐观并发控制

WATCH balance:1001
GET balance:1001
MULTI
DECRBY balance:1001 10
EXEC

如果从 WATCH 到 EXEC 期间被监视 Key 被其他客户端修改,EXEC 返回空结果,调用方读取最新值后决定有限重试。WATCH 适合冲突不高、逻辑简单的场景;高冲突下反复重试会浪费 CPU,Lua 或原子命令通常更合适。

Lua 设计示例

local current = tonumber(redis.call('GET', KEYS[1]) or '0')
local amount = tonumber(ARGV[1])
if current < amount then
  return {err = 'INSUFFICIENT'}
end
redis.call('DECRBY', KEYS[1], amount)
return current - amount

脚本把读取、判断和扣减放在同一事件循环中执行,避免客户端两次命令之间的竞态。所有 KEY 应通过 KEYS 参数声明,尤其在 Cluster 中方便校验同槽;脚本不能执行未受控循环、大范围扫描或网络 I/O。

脚本治理

脚本内容应版本化、可测试、限制执行时长,并定义错误码和幂等语义。可以通过 SHA 缓存调用,但节点重启或切换后需要处理脚本未加载。长脚本会阻塞整个节点,不能把复杂业务规则搬进 Lua 以逃避服务层设计。

事务错误分类

排队阶段的语法/参数错误会让 EXEC 无法正常执行;执行阶段某条命令错误时,其他命令可能仍然执行。调用方必须检查每条结果并设计补偿或重试,而不是假定“其中一条失败所以全部没生效”。

实践边界

Lua 脚本必须短小、有边界,长脚本会阻塞核心线程。脚本要明确返回值、失败语义和版本管理;大批 Pipeline 也要限制批次,避免响应占用过多内存。

常见误区

Pipeline 不是事务,事务也不提供隔离级别和回滚。WATCH 监视的 Key 在 EXEC 前变化会让事务失败,调用方需要决定是否有限重试。

高频追问与参考回答

追问:Lua 脚本执行到一半报错会回滚吗?

不会自动撤销错误前已经执行的写入,因此脚本应先校验参数与类型,再进入修改阶段,避免部分生效。

追问:为什么 Pipeline 不能保证原子性?

它只是客户端传输优化,服务器仍按收到的命令顺序与其他客户端请求交错执行。只有事务或脚本能在服务端建立连续执行边界。

追问:Lua 和事务哪个更适合库存扣减?

需要读取库存、判断并修改时 Lua 或已有原子命令更直接;WATCH 事务在冲突高时会频繁失败重试。无论选择哪种,跨数据库订单创建仍需外部幂等与一致性方案。

追问:MULTI/EXEC 可以跨 Cluster 槽吗?

通常不可以,多 Key 事务需要相关 Key 位于同一槽。应通过合理 hash tag 建模,或拆分为上层可恢复流程。

总结

Pipeline 优化网络,事务组织命令并支持乐观锁,Lua 提供服务端条件原子性;选型先看所需语义而非性能标签。

机制全景图

下面把「Redis 事务、Lua 和 Pipeline 有什么区别?」从输入到结果压缩成一条可复述的主链路。面试时先用图建立全局坐标,再进入局部实现,能避免只背零散结论。

flowchart LR
    A["客户端组织多条命令"]
    A --> B["选择批量/事务/脚本"]
    B --> C["服务端顺序执行"]
    C --> D["处理错误与返回值"]
    D --> E["重试并保证业务幂等"]

完整链路:从输入到结果

沿着「客户端组织多条命令 → 选择批量/事务/脚本 → 服务端顺序执行 → 处理错误与返回值 → 重试并保证业务幂等」观察输入、状态与输出,下面每个阶段都对应一个可以在源码、日志或系统表中验证的位置。

1. 客户端组织多条命令

Pipeline 只批量发送和读取响应,减少 RTT,不提供隔离与原子性。

2. 选择批量/事务/脚本

MULTI/EXEC 把命令排队后连续执行,但不支持自动回滚,运行期错误不会撤销已成功命令。

3. 服务端顺序执行

WATCH 提供乐观检查,监视 Key 变化后 EXEC 失败,调用方必须有界重试。

4. 处理错误与返回值

Lua 在事件循环中原子执行,可读取前序结果并条件写入,但长脚本会阻塞所有客户端。

5. 重试并保证业务幂等

无论选择哪种机制,网络超时都可能让结果未知,业务仍需请求 ID 和幂等检查。

源码与实现定位

入口 阅读重点
src/multi.c MULTI/EXEC/WATCH 执行
src/script_lua.c 脚本原子执行与超时

源码或系统表应按上表顺序追踪:先确认入口实际走到哪条路径,再用运行时数据验证,而不是仅凭类名或配置推测。

参数配置与可复现实验

if redis.call('EXISTS', KEYS[1]) == 1 then return 0 end
redis.call('SET', KEYS[1], ARGV[1], 'EX', 86400)
redis.call('DECRBY', KEYS[2], ARGV[2]); return 1

在响应丢失后重发 Pipeline、事务和幂等 Lua,比较余额/库存最终值;制造 WATCH 高冲突。

验证步骤与预期结果

1. 固定输入和基线

先在没有故障注入的环境执行上述配置,固定数据规模、并发度、运行时版本和预热时间。以「脚本 P99」为主基线,记录值应满足「<1ms示例」;同时保存 批次大小与响应字节、脚本执行时长,使后续变化能够回到同一时间轴比较。

2. 从实现入口确认路径

在「src/multi.c」确认请求确实进入「MULTI/EXEC/WATCH 执行」对应的实现,再沿「src/script_lua.c」观察「脚本原子执行与超时」。如果入口路径都未命中,就不应继续调整下游参数,而应先检查调用条件、版本或路由是否与假设一致。

3. 注入本文特有的失败模式

优先复现「把 Pipeline 当事务」,并把单一变量逐级放大,直到「脚本 P99」越过「>5ms」。随后再分别验证「Lua 遍历大集合阻塞实例」和「WATCH 冲突无限重试放大负载」,三类故障分开执行,避免多个变量同时变化而无法归因。

4. 执行止损和根因修复

第一轮只应用「需要原子的读改写用短 Lua」,确认它能控制影响范围;第二轮应用「Pipeline 重试带业务幂等键」,验证核心链路恢复;最后落实「WATCH 有界退避」,消除同类问题再次出现的条件。每一步都保留变更前后数据,不用“感觉变快了”替代测量。

5. 通过退出条件

实验只有同时满足三项才算通过:「脚本 P99」回到「<1ms示例」、「WATCH abort」回到「低个位数」、「批次字节」回到「受缓冲预算」,并且业务结果差异为零。若性能恢复但结果不一致,仍应视为失败;若指标恢复后很快再次越线,则说明只完成了临时止损,没有消除根因。

量化基线

指标 样例基线/口径 风险线 结论
脚本 P99 <1ms示例 >5ms 阻塞实例
WATCH abort 低个位数 >20% 冲突风暴
批次字节 受缓冲预算 MB级 拆批

这些数值是实验口径或示例告警线,不是可复制到所有系统的固定答案;上线阈值应由本系统稳态、峰值和故障演练共同确定。

事故复盘:Pipeline 超时后重复扣减余额

客户端未收到响应就重发整批命令,第一次其实已执行成功,余额被再次扣减。Pipeline 不能提供恰好一次;改用带请求 ID 的 Lua,在同一脚本中检查去重记录并更新余额。

失败模式 首要证据 第一处置动作
把 Pipeline 当事务 批次大小与响应字节 需要原子的读改写用短 Lua
Lua 遍历大集合阻塞实例 脚本执行时长 Pipeline 重试带业务幂等键
WATCH 冲突无限重试放大负载 WATCH abort 率 WATCH 有界退避

发布与回滚检查点

  • 发布前:确认「src/multi.c」对应实现和上述配置在目标版本仍然有效,并保存「脚本 P99」基线。
  • 灰度中:同时观察 批次大小与响应字节、脚本执行时长、WATCH abort 率;任一指标越过表中风险线,就停止继续扩量。
  • 回滚时:先执行「需要原子的读改写用短 Lua」控制影响,再回退代码或参数;涉及持久状态时必须额外核对结果差异。
  • 发布后:至少覆盖一个完整峰值周期,确认「把 Pipeline 当事务」没有再次出现,才关闭变更观察窗口。

方案对比与选型

方案 更适合的场景 主要收益 代价与边界
Pipeline 大量独立命令批量执行 减少网络 RTT、吞吐高 不原子,错误逐条处理
MULTI/EXEC + WATCH 短事务与乐观冲突 Redis 原生、无需脚本 冲突重试且无回滚
Lua/Function 有条件的原子读改写 一次往返、语义集中 脚本阻塞风险与版本治理

选型至少带上 Key 数量、Value 大小、命令复杂度、热点分布和过期速率,并用上面的量化基线验证;未知数据应明确为待测假设。

设计边界与工程取舍

Redis 原子性只覆盖脚本或事务在单实例中的执行,不包含数据库、MQ 或客户端结果确认;跨系统仍需状态机与补偿。

工程落地遵循:Redis 是高性能数据结构服务,不应被当成没有容量边界的黑盒。回答时直接引用「src/multi.c」、配置实验和事故数据,比复述固定模板更有说服力。