Redis 单条命令的执行极快,但当你需要一次性发送成百上千条命令,或要求多条命令"要么全做要么全不做"时,事情就变复杂了。Pipeline、事务(MULTI/EXEC)、Lua 脚本是 Redis 提供的三种"批量/原子"机制。它们容易被混为一谈——都涉及"多条命令一起执行"——但解决的其实是不同维度的问题:Pipeline 解决网络往返,事务和 Lua 解决原子性

场景:RTT 才是隐形杀手

先看一个数字直觉。Redis 处理一条命令可能只要几微秒,但一次网络往返(RTT)在同机房也要零点几毫秒,跨机房更高。也就是说,网络延迟往往比命令本身的执行时间高几个数量级

如果你要执行 1000 条 SET,逐条发送,就是 1000 次 RTT。哪怕 Redis 本身处理只花了几毫秒,光等网络就可能花掉几百毫秒。这就是 Pipeline 要解决的问题。

Pipeline:把 N 次往返压成 1 次

Pipeline(管道)的原理很朴素:客户端把多条命令一次性打包发给服务器,服务器依次执行后把所有响应一次性返回。它把 N 次 RTT 压缩成 1 次。

1
2
3
4
5
6
# 非 pipeline:3 次往返
SET k1 v1 # 发送 → 等待响应
SET k2 v2 # 发送 → 等待响应
SET k3 v3 # 发送 → 等待响应

# pipeline:1 次往返,3 条命令一起发,3 个响应一起回

关键要理解 Pipeline 不保证原子性。这 1000 条命令在服务器端仍是逐条执行的,中间可能穿插其他客户端的命令。Pipeline 只是网络层的优化,不是事务。

工程权衡:

  • Pipeline 里的命令越多,省下的 RTT 越多,但响应会在服务端和客户端缓冲区里堆积。一次塞十万条命令,缓冲区内存会暴涨。实践中应分批(batch),比如每批 100~1000 条。
  • Pipeline 中途某条命令报错(比如对 String 执行 List 操作),不会中断后续命令,错误只反映在那一条的响应里。客户端要逐个检查响应。

事务:MULTI/EXEC 的"伪原子"

Redis 事务用 MULTI 开启,中间的命令先入队(QUEUED),EXEC 时一次性顺序执行。

1
2
3
4
MULTI
SET balance:A 100
INCRBY balance:B 50
EXEC # 队列里的命令在此一次性、顺序、不被打断地执行

事务提供两个保证:顺序执行不被其他客户端打断(EXEC 期间 Redis 不会插入别的命令)。但它和关系型数据库的事务有本质区别:

Redis 事务不支持回滚。 如果 EXEC 中某条命令运行时出错(比如对字符串执行 INCR),这条命令失败,但其他命令照常执行,已经执行的不会撤销。Redis 官方的解释是:运行时错误通常是编程 bug,生产代码里不该出现,为此引入回滚机制会增加复杂度和性能开销,不划算。

要区分两类错误:

  • 入队时语法错误(命令不存在、参数个数错):整个事务会在 EXEC 时被拒绝,所有命令都不执行。
  • 执行时类型错误(命令合法但运行报错):只有出错的那条失败,其余照常,没有回滚

WATCH 实现乐观锁。 事务本身无法实现"读后判断再写"。WATCH 可以监视一个或多个 key,如果在 EXEC 之前这些 key 被其他客户端修改了,EXEC 会直接失败返回 nil。这就是 CAS(Compare And Set)式的乐观锁,常用来实现安全的"读-改-写":

1
2
3
4
5
WATCH balance:A
# 客户端读出 balance:A,在内存里计算新值
MULTI
SET balance:A <新值>
EXEC # 若 balance:A 在 WATCH 后被改过,EXEC 返回 nil,需重试

事务的局限很明显:逻辑判断在客户端做,涉及多次往返(WATCH、读、MULTI/EXEC),且冲突时要重试。这正是 Lua 脚本登场的地方。

Lua 脚本:服务端原子计算

Lua 脚本把一段逻辑发到服务端执行,Redis 以单线程、原子的方式运行整个脚本,期间不会被任何其他命令打断。它同时解决了三件事:原子性、减少网络往返、在服务端做复杂逻辑判断。

1
2
3
4
5
6
7
8
9
10
# EVAL 脚本:KEYS[1]=库存 key,ARGV[1]=扣减数量
# 检查库存是否充足,够则扣减返回 1,不够返回 0 —— 整段原子执行
EVAL "
local stock = tonumber(redis.call('GET', KEYS[1]))
if stock >= tonumber(ARGV[1]) then
redis.call('DECRBY', KEYS[1], ARGV[1])
return 1
end
return 0
" 1 stock:1001 2

这段"判断库存 → 扣减"的逻辑如果用事务做,需要 WATCH + 重试;用 Lua 则天然原子,一次往返搞定。这是实现分布式锁释放、限流、秒杀扣库存等场景的首选。

机制要点与权衡:

  • 脚本必须保证确定性。 同样的输入在主从、在不同时间执行,结果必须一致。所以脚本里不能用 TIME、随机数等不确定来源去写数据(否则主从不一致)。需要时间/随机数应通过 ARGV 从外部传入。
  • 脚本会阻塞整个 Redis。 因为单线程原子执行,一个跑很久的脚本(比如循环几百万次)会卡死所有其他客户端。脚本必须短小快速,严禁在脚本里写重循环或大范围 KEYS 扫描。
  • EVALSHA 节省带宽。 第一次用 SCRIPT LOAD 把脚本加载进服务端缓存得到 SHA1,之后用 EVALSHA <sha> 只传哈希值而非整段脚本,减少网络传输。客户端通常会自动处理 NOSCRIPT 的回退。

三者对比

机制 原子性 减少 RTT 支持逻辑判断 典型场景
Pipeline 批量写入/读取
事务(MULTI/EXEC) 是(无回滚) 部分 仅 WATCH 乐观锁 简单的多命令打包
Lua 脚本 读-改-写、扣库存、限流

常见误区

  1. 以为 Pipeline 是事务。 Pipeline 命令之间可能被其他客户端的命令插入,绝不能用它来保证原子性。
  2. 指望事务回滚。 把钱从 A 扣了、给 B 加时其中一步运行时报错,Redis 不会帮你还原。涉及强一致的资金类逻辑,要么用 Lua 把判断和写入捆成原子单元,要么在应用层做补偿。
  3. 在 Lua 里写慢逻辑。 Lua 的原子性来自单线程独占,代价是它一旦慢,整个实例都被它拖住。脚本要"轻进轻出"。
  4. Lua 脚本里的副作用不确定。 在脚本中调用产生随机或时间相关副作用并写回 Redis,会破坏主从复制一致性。

小结

记住一句话:Pipeline 治网络,事务和 Lua 治原子。Pipeline 是纯粹的吞吐优化,与原子无关;事务提供了无回滚的弱原子和 WATCH 乐观锁;Lua 脚本则是服务端的原子计算单元,是处理"读-改-写"竞态最干净的工具。三者不是替代关系,而是面向不同问题的互补武器。