假设下单时需要在 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-py 的 pipeline() 默认会包一层事务;如果只想做普通 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 事务通过 MULTI、EXEC 把多条命令组织在一起:
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
从 WATCH 到 EXEC 之间,只要被监视的 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 扣减
-> 返回结果
脚本结束
其他客户端只能在脚本开始前或结束后执行命令,不能在 GET 和 DECRBY 之间插入。所以库存为 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 命令时,可以按下面的顺序判断:
- Redis 是否已有单条原子命令?有就直接使用。
- 命令彼此独立,只是 RTT 太多?使用 Pipeline。
- 是一组固定命令,必须连续执行?使用
MULTI/EXEC。 - 需要先读再判断,冲突又比较少?可以用
WATCH + MULTI/EXEC。 - 需要在服务端完成短小、确定的读改写逻辑?使用 Lua 脚本。
Redis 7.0 之后还提供了 Redis Functions。它和 Lua 脚本采用相同的服务端原子执行思路,但函数会作为库加载并持久化,更适合需要长期部署和管理的一组服务端逻辑。对于一小段应用内原子操作,Lua 脚本仍然很直接;当脚本越来越多、需要版本化和统一管理时,可以再考虑 Functions。
最后把核心区别压缩成两句话:
Pipeline 把多次网络往返合并了,但没有把多条命令合成一个原子操作。
Lua 把读、判断、写放到 Redis 内部一次执行,换来的原子性依赖脚本足够短、访问的数据规模足够可控。