假设下单时需要在 Redis 里做三件事:读取库存、判断是否足够、扣减库存。

第一次写这段逻辑,很容易想到 Pipeline:把几条命令一次发给 Redis,既然它们挨在一起执行,是不是就不会被其他请求插进来了?

答案是否定的。

Pipeline 解决的是网络往返太多,Lua 脚本解决的才是多步逻辑不能被其他命令打断。两者都能让客户端“一次请求完成多件事”,但它们处理的是完全不同的问题。

理解这一区别之后,Redis 里很多看似相近的概念都会顺起来:为什么批量写缓存适合 Pipeline,为什么扣库存适合 Lua,为什么事务和 Pipeline 经常同时出现在客户端 API 里,以及“Lua 是原子的”到底原子到什么程度。

先用一张表分清四种工具

工具主要解决什么多条命令会不会被其他客户端插入能否根据中间结果继续判断
Pipeline减少 RTT,提高吞吐不保证不适合
MULTI/EXEC原子执行一组已经确定的命令不会很弱,命令只是先排队
WATCH + MULTI/EXEC乐观锁与条件更新提交后不会,冲突时提交失败可以,但通常需要重试
Lua 脚本在服务端完成读、计算、写不会可以

还有一个经常被忽略的选择:如果 Redis 已经有单条命令能完成需求,优先用单条命令。例如计数用 INCRBY,不存在才写入用 SET ... NX。单命令通常比自己拼事务或 Lua 更简单,也天然具有原子性。

Pipeline 为什么快:它省掉的是 RTT

客户端执行一条 Redis 命令,大致要经历下面这段路:

客户端发送命令
  -> 网络传到 Redis
  -> Redis 执行命令
  -> 结果通过网络返回
  -> 客户端收到结果

如果依次执行 100 条命令,客户端通常要等待 100 次网络往返。即使命令本身只执行几十微秒,网络等待也可能占掉大部分时间。

假设网络往返时间是 RTT,每条命令的服务端执行时间是 T,不使用 Pipeline 时可以粗略理解为:

总耗时 ≈ 100 × (RTT + T)

Pipeline 会先把多条命令连续写入连接,再统一读取结果:

客户端  -> SET k1 v1
        -> SET k2 v2
        -> SET k3 v3
        -> ...

Redis   -> OK
        -> OK
        -> OK
        -> ...

此时耗时更接近:

总耗时 ≈ 少量 RTT + 100 × T

命令并没有突然变快,Redis 也没有并行执行这 100 条命令。Pipeline 只是让网络更忙、让等待更少,因此吞吐量明显提高。

redis-py 批量写缓存,可以这样做:

import redis

r = redis.Redis(host="127.0.0.1", port=6379, decode_responses=True)

# transaction=False 明确表示只使用 Pipeline,不开启 MULTI/EXEC。
pipe = r.pipeline(transaction=False)
for user_id, nickname in users:
    pipe.hset("user:{0}".format(user_id), "nickname", nickname)
    pipe.expire("user:{0}".format(user_id), 3600)

results = pipe.execute()

不同客户端对 pipeline() 的默认行为不一样。特别是 redis-pypipeline() 默认会包一层事务;如果只想做普通 Pipeline,应显式传入 transaction=False。所以看到客户端方法名时,不要只凭名字判断,最好确认它最后发出的到底是普通命令流,还是 MULTI ... EXEC

Pipeline 为什么不保证原子性

Pipeline 的协议语义只是:客户端暂时不等回复,连续发送多条命令,之后再批量读取结果。

Redis 仍然把它们当作一条条独立命令处理。协议没有承诺这组命令构成一个不可分割的整体,其他客户端也可能看到中间状态。因此,下面两件事并不等价:

一次把多条命令发出去
多条命令作为一个整体执行

以“写缓存内容,再设置过期时间”为例:

HSET user:1001 nickname icyyan
EXPIRE user:1001 3600

放进普通 Pipeline 可以减少 RTT,却不能把两条命令变成一个原子操作。如果连接只成功发送了第一条,或者客户端在读取结果前发生异常,就需要考虑 key 已写入但没有过期时间的情况。

库存扣减的问题更明显。正确逻辑是:

读取库存
如果库存 >= 购买数量
    扣减库存
否则
    返回库存不足

这个逻辑依赖 GET 的结果。普通 Pipeline 在收到结果前已经把后续命令发出去了,根本无法根据中间结果决定要不要扣减。若拆成两次请求,又会出现经典竞态:

客户端 A:读取库存,得到 1
客户端 B:读取库存,也得到 1
客户端 A:判断充足,执行 DECR
客户端 B:判断充足,也执行 DECR
最终库存:-1

这不是 Redis 单线程失效了。每一条命令确实都按顺序执行,只是“读取、判断、扣减”并不是一条命令,其他客户端完全可以进入这些步骤之间。

MULTI/EXEC:原子执行固定的命令清单

Redis 事务通过 MULTIEXEC 把多条命令组织在一起:

MULTI
DECRBY stock:sku:1001 2
INCRBY sold:sku:1001 2
EXEC

MULTI 之后的命令不会立即执行,而是进入队列。收到 EXEC 后,Redis 连续执行队列里的命令,在这期间不会处理其他客户端的命令。

所以 MULTI/EXEC 提供了原子执行与隔离,但它仍有一个限制:命令入队时,客户端拿不到前一条命令的执行结果。你可以原子地执行“扣库存并增加销量”,却很难在同一个事务里写出“先读库存,库存足够才扣减”这种分支。

如果业务判断可以放在客户端,可以配合 WATCH 做乐观锁:

WATCH stock:sku:1001
GET stock:sku:1001

# 客户端判断库存是否充足

MULTI
DECRBY stock:sku:1001 2
EXEC

WATCHEXEC 之间,只要被监视的 key 被其他客户端修改,EXEC 就会失败,客户端重新读取并重试。

这个方案适合冲突较少、判断逻辑简单的场景。热点库存竞争激烈时,大量事务会反复失败和重试,Lua 往往更直接。

还要注意 Redis 事务和关系型数据库事务并不完全一样:EXEC 中某条命令运行时报错,已经成功执行的命令不会自动回滚。Redis 事务保证命令组不被插入,不提供传统数据库那种运行时错误后的整体回滚。

Lua 如何把读、判断、写合成一个原子操作

Redis 允许通过 EVAL 在服务端执行 Lua 脚本。脚本运行期间,Redis 不会执行其他客户端的命令。因此可以把库存判断和扣减放进同一次脚本执行:

-- KEYS[1]:库存 key
-- ARGV[1]:本次购买数量

local amount = tonumber(ARGV[1])
if not amount or amount <= 0 or amount ~= math.floor(amount) then
    return redis.error_reply("amount must be a positive integer")
end

local stock = tonumber(redis.call("GET", KEYS[1]) or "0")
if not stock or stock ~= math.floor(stock) then
    return redis.error_reply("stock must be an integer")
end

if stock < amount then
    return {0, stock}
end

local remaining = redis.call("DECRBY", KEYS[1], amount)
return {1, remaining}

整个过程可以想成一条更强的自定义命令:

开始执行脚本
  -> GET 库存
  -> Lua 判断
  -> DECRBY 扣减
  -> 返回结果
脚本结束

其他客户端只能在脚本开始前或结束后执行命令,不能在 GETDECRBY 之间插入。所以库存为 1、两个客户端同时购买 1 件时,只会有一个成功:

A 执行脚本:读到 1,扣成 0,返回成功
B 执行脚本:读到 0,不再扣减,返回失败

客户端调用也很简单:

DEDUCT_STOCK = """
local amount = tonumber(ARGV[1])
if not amount or amount <= 0 or amount ~= math.floor(amount) then
    return redis.error_reply("amount must be a positive integer")
end

local stock = tonumber(redis.call("GET", KEYS[1]) or "0")
if not stock or stock ~= math.floor(stock) then
    return redis.error_reply("stock must be an integer")
end

if stock < amount then
    return {0, stock}
end

local remaining = redis.call("DECRBY", KEYS[1], amount)
return {1, remaining}
"""

deduct_stock = r.register_script(DEDUCT_STOCK)
success, remaining = deduct_stock(
    keys=["stock:{sku:1001}"],
    args=[2],
)

register_script 通常会优先使用脚本摘要调用;服务端没有缓存脚本时,客户端再加载脚本。这样不必每次把完整 Lua 文本都发送一遍。具体的脚本缓存和 NOSCRIPT 重载行为由客户端库处理。

Lua 的“原子”不等于自动回滚和绝对不丢

“Lua 脚本是原子的”很容易被理解得过头。这里的原子性主要表示:脚本执行期间不会穿插其他命令,其他客户端看不到脚本执行到一半的状态。

它不自动包含下面两种能力。

第一,脚本报错不代表回滚。

redis.call("SET", KEYS[1], "written")
redis.call("HSET", KEYS[2], "field") -- 参数数量错误

第二条命令报错时,第一条 SET 不会被撤销。Redis 的 Lua 脚本和 MULTI/EXEC 一样,不提供运行时错误后的自动回滚。正确做法是把参数、类型和业务前置条件尽量在写入前检查完,再开始修改数据。

第二,原子性不等于持久性。

脚本成功返回,表示它已经在当前 Redis 实例上执行完成。故障后能否保住数据,仍然取决于 AOF、RDB、复制、故障转移和客户端确认策略。Lua 解决并发竞态,不会把 Redis 变成带完整 ACID 语义的关系型数据库。

可以把这三个概念拆开记:

原子执行:中途不被其他命令插入
错误回滚:失败后恢复到执行前
故障持久:宕机或切主后仍能恢复

Redis Lua 直接保证的是第一项,后两项需要额外设计。

为什么脚本不能写得太重

Lua 的原子性来自“执行脚本期间不处理其他命令”。这既是能力,也是代价。

如果脚本需要 2 毫秒,其他请求多等一会儿通常没问题。如果脚本遍历几十万个元素、做复杂循环,或者对多个大 key 执行全量操作,整个 Redis 实例都会被它拖住。

因此 Lua 脚本最好满足三个条件:

  • 输入规模有明确上限,不扫描整个数据库,也不遍历无界大集合。
  • 只放必须靠近数据执行的判断,不把完整业务流程搬进 Redis。
  • 先做校验,再做写入,避免脚本中途报错留下部分修改。

Redis 有脚本超时检测配置,但它不是普通数据库的查询超时。脚本已经写入数据后,强行终止会涉及一致性问题,所以不能把超时机制当作日常保护。真正可靠的办法仍然是让脚本短小、复杂度可控。

Redis Cluster 下,key 还要落在同一个槽

在单机 Redis 上,脚本可以访问多个 key。到了 Redis Cluster,脚本涉及的 key 必须位于同一个 hash slot,而且应该通过 KEYS 参数显式传入。

例如库存和销量要在同一个脚本里修改,可以使用相同的 hash tag:

stock:{sku:1001}
sold:{sku:1001}

Redis Cluster 计算槽位时只取 {sku:1001},因此两个 key 会落到同一槽位。

不要在脚本里根据字符串临时拼出没有通过 KEYS 声明的 key。客户端和集群需要提前知道脚本会访问哪些 key,才能把请求路由到正确节点。

代价也很明确:hash tag 会把相关数据绑到同一个槽。如果把所有商品都写成 {stock},热点就会集中到一个槽;更合理的做法通常是按商品、订单或用户维度选择 tag。

Pipeline 和 Lua 也可以一起用,但不要混淆职责

Pipeline 与 Lua 并不互斥。

假设需要对 100 个彼此独立的商品分别执行一段原子扣减逻辑,可以把 100 次脚本调用放进 Pipeline,以减少网络往返:

Pipeline
  -> EVALSHA 商品 1 的扣减脚本
  -> EVALSHA 商品 2 的扣减脚本
  -> EVALSHA 商品 3 的扣减脚本
  -> ...

这里每一次脚本调用内部是原子的,Pipeline 只负责批量传输。100 个脚本调用合在一起并不会自动变成一个整体事务,其他命令仍然可能出现在两个脚本之间。

批量数量也不是越大越好。客户端暂时不读响应时,Redis 需要缓存待返回结果;一次塞入几十万条命令,会增加客户端、网络和服务端输出缓冲区的内存压力。实践中通常按几百或几千条分批,再根据命令大小和延迟监控调整。

到底应该选哪一种

遇到多条 Redis 命令时,可以按下面的顺序判断:

  1. Redis 是否已有单条原子命令?有就直接使用。
  2. 命令彼此独立,只是 RTT 太多?使用 Pipeline。
  3. 是一组固定命令,必须连续执行?使用 MULTI/EXEC
  4. 需要先读再判断,冲突又比较少?可以用 WATCH + MULTI/EXEC
  5. 需要在服务端完成短小、确定的读改写逻辑?使用 Lua 脚本。

Redis 7.0 之后还提供了 Redis Functions。它和 Lua 脚本采用相同的服务端原子执行思路,但函数会作为库加载并持久化,更适合需要长期部署和管理的一组服务端逻辑。对于一小段应用内原子操作,Lua 脚本仍然很直接;当脚本越来越多、需要版本化和统一管理时,可以再考虑 Functions。

最后把核心区别压缩成两句话:

Pipeline 把多次网络往返合并了,但没有把多条命令合成一个原子操作。

Lua 把读、判断、写放到 Redis 内部一次执行,换来的原子性依赖脚本足够短、访问的数据规模足够可控。

参考资料