核心目标:区分 Redis 事务与关系型数据库事务;理解 Pipeline 的性能收益与边界;用 Lua 脚本实现真正原子的复合操作;了解 Redis 7 Functions 的定位。用秒杀扣库存贯穿三种工具。
前置知识 :完成 Part 1(单线程事件循环------本篇文章论的地基)与 Part 2(命令复杂度、RTT 成本)。
验证环境:Redis 8.10.0(cygwin 移植版,主实例 6379)、redis-py 8.1.0、Python 3.11.6、Windows 11;Lua 阻塞实验在隔离实例 6380 上进行。最后复核日期:2026-08-07。
0. 本篇问题场景:一个扣库存的三种写法
"库存 10 件,100 个人同时下单,怎么保证不超卖?"三种典型答案:
- 读 → 判断 → 写(Python 代码三步):100 个并发全读到 10,全通过------超卖 90 件;
MULTI事务包裹:入队执行,但"判断"和"扣减"不是一条命令,中间态仍然可被插入;- Lua 脚本:把"读库存、判断、扣减"写进一段原子执行的脚本------这才是不超卖的正解。
本篇把三者的能力边界讲透,并回答一个高频疑问:"Redis 事务和 MySQL 事务是一回事吗?"------不是。Redis 事务保证的是"串行不被打断",不是"出错回滚"。
1. 事务:MULTI / EXEC / DISCARD / WATCH
1.1 事务的"原子性"到底指什么
MULTI 开始入队,之后命令都返回 QUEUED 而不是执行,EXEC 才一次性执行:
text
$ redis-cli
MULTI
OK
SET t:a 1
QUEUED
INCR t:counter
QUEUED
EXEC
1) OK
2) (integer) 1
Redis 事务的原子性 = "EXEC 执行期间不会被其他命令插入"(单线程事件循环的天然结果),而不是"要么全部成功、要么全部回滚"。它没有回滚,因为:
- 入队阶段就能发现的错误(命令不存在、参数不对)→ 整个事务拒绝执行;
- 执行阶段才发现的错误(如
LPUSH打到 String 键)→ 只跳过出错那条,其余照常执行。
1.2 实测一:运行错误 = 部分成功
text
MULTI
OK
SET t:ok1 1
QUEUED
LPUSH t:str x ← t:str 是 String,执行时才报错
QUEUED
SET t:ok2 2
QUEUED
EXEC
1) OK
2) (error) WRONGTYPE Operation against a key holding the wrong kind of value
3) OK
验证:t:ok1 和 t:ok2 都写成功了------事务中出错不连锁回滚。这与 MySQL 事务的"失败即回滚"是本质区别,凡是依赖"Redis 事务会回滚"的代码都是误解。
1.3 实测二:入队错误 = 整体拒绝
text
MULTI
OK
SET t:x 1
QUEUED
INCR t:y extra_arg
(error) ERR wrong number of arguments for 'incr' command ← 入队即报错
EXEC
(error) EXECABORT Transaction discarded because of previous errors.
EXECABORT 意味着整个事务没执行 (t:x 未写入)。所以 Redis 事务的规则是:"编译期"错误全拒,"运行期"错误部分执行------把能提前发现的错误提前到入队阶段,把不能提前的留给执行阶段。
1.4 WATCH:乐观锁
MULTI 事务解决"不被插入",WATCH 解决"不被偷改"------它监视一个键,如果 EXEC 之前该键被其他连接修改,事务直接放弃:
python
with r1.pipeline() as pipe:
pipe.watch("balance") # 开始监视
r2.set("balance", "50") # 另一连接在监视期间修改
pipe.multi()
pipe.incrby("balance", -30)
pipe.execute() # → WatchError
实测两个场景:
text
场景 A(watch 后被修改):WatchError: 监视期间被修改,事务放弃 balance=50(保持对方值)
场景 B(watch 期间无修改):EXEC 成功 balance=70(100-30)
WATCH 是 CAS(Compare-And-Swap):监视 → 校验 → 执行;失败则重试。注意它的代价:竞争频繁时重试循环会放大请求量,高冲突场景不如 Lua(§3)。
2. Pipeline:用一次往返换性能
2.1 机制
Pipeline 不是事务 :它把 N 条命令攒在客户端,一次性发给服务器,一次收齐响应,减少的是 RTT(往返次数),不提供任何原子性。Part 2 实测过单条命令的 RTT 成本(5 万次单条 ZADD ≈ 6 秒)。
2.2 实测:1000 条 INCR
text
单条循环 1000 次: 79.0 ms
pipeline 1000 次: 5.2 ms
加速比: 15.3x
(本机 loopback、Redis 8.10、中位耗时;跨网络时收益更大。)Pipeline 与事务的对比:
| 维度 | MULTI/EXEC | Pipeline |
|---|---|---|
| 原子性 | 执行期不被插入 | 无(命令间可被其他客户端插入) |
| 往返 | 1 次(EXEC 一次性发) | 1 次 |
| 中途出错 | 部分成功/整体拒绝 | 错误在响应中逐条返回 |
| 典型用途 | 一致性要求 | 批量写入、大量读 |
redis-py 的 pipeline(transaction=False) 就是纯 Pipeline;transaction=True(默认)会额外包一层 MULTI/EXEC------同一个 API,两种语义,必须显式选择。
3. Lua 脚本:真正的原子复合操作
3.1 为什么脚本是原子的
Redis 执行 Lua 脚本时,整个脚本在事件循环中一次性跑完,期间不会处理任何其他命令------脚本天然就是"事务 + 条件逻辑"的合体。相比 MULTI:
- MULTI 只能"排队执行",不能"读一个值再决定下一步"(WATCH 是笨拙的补丁);
- Lua 可以
GET 库存 → if 库存 > 0 then DECR,判断与操作在同一次执行里完成,没有中间态。
3.2 实测:秒杀扣库存,100 并发零超卖
lua
local stock = tonumber(redis.call('GET', KEYS[1]))
if stock > 0 then
redis.call('DECR', KEYS[1])
return 1
else
return 0
end
100 个线程并发调用(库存 10):
text
100 个并发请求,成功 10 个(库存 10,超卖=False)
剩余库存: 0
10 个线程拿到 1,90 个拿到 0------脚本级原子性保证不超卖,不需要锁、不需要重试。
3.3 EVALSHA 与脚本缓存
EVAL 每次都要把脚本文本发给服务器;EVALSHA 只发脚本的 SHA1,服务器缓存命中则直接执行:
text
脚本 SHA: edfeb7032922448cef24b8512a794cc6700ce6b5
EVALSHA 执行: 0
SCRIPT EXISTS: [True]
redis-py 的 register_script 自动管理这一过程。注意:脚本缓存的生命周期由服务器管理------服务器重启、SCRIPT FLUSH、主从角色切换 都会清空缓存,此时 EVALSHA 报 NOSCRIPT,客户端要回退到 EVAL(FLUSHALL/FLUSHDB 不会清脚本缓存)。生产代码必须处理这个回退路径。
3.4 实测:脚本死循环 = 全局阻塞
脚本执行期间单线程不处理任何命令,死循环脚本会卡死整个服务器:
text
EVAL "while true do end" 0 (异步触发)
另一连接 PING → 阻塞 3 秒无响应(证实全局阻塞)
SCRIPT KILL → OK(脚本被终止)
恢复后 PING → PONG
SCRIPT KILL 只能终止尚未执行写命令 的脚本;一旦脚本写过数据,只能 SHUTDOWN NOSAVE 重启。机制补充:脚本执行超过 lua-time-limit(默认 5000ms)后,服务器开始拒绝其他客户端的命令(返回 BUSY Redis is busy running a script)而非无限等待,此时才能执行 SCRIPT KILL------5 秒内是静默阻塞,5 秒后是 BUSY 拒绝 。这是把业务逻辑写进 Lua 的最大风险:脚本必须短小、无副作用悬挂、无死循环。
4. Redis 7 Functions:脚本的工程化
EVAL 的脚本是"客户端携带 + 服务器缓存",管理散落;Redis 7 的 Functions 把脚本注册到服务器,成为一等公民:
| 维度 | EVAL | Functions |
|---|---|---|
| 脚本存储 | 客户端每次发送,服务器缓存 | FUNCTION LOAD 注册到服务器,持久化 |
| 版本管理 | 无 | 支持库版本、原子替换 |
| 调用 | EVALSHA(NOSCRIPT 需回退) |
FCALL,无需关心缓存 |
| 适用 | 临时脚本、快速原型 | 团队共享、上线管理 |
text
FUNCTION LOAD "#!lua name=mylib\n... function ... end"
FCALL mylib.myfunc 1 stock
Functions 是"Lua 能力 + 工程化管理",生产团队用 Lua 落业务逻辑时应优先考虑。
5. 版本与环境差异
| 差异点 | 官方 7.4 | 本机 8.10.0(cygwin 移植版) | 影响 |
|---|---|---|---|
MULTI/WATCH/EVAL |
语义稳定 | 一致 | 本文实测数据可直接复用 |
| Functions | 7.0+ | 可用 | 生产优先 FCALL |
SCRIPT KILL 限制 |
有写操作则不可杀 | 一致 | 死循环实验仅限无写脚本 |
| redis-py pipeline | transaction 参数 |
一致 | 显式声明语义 |
6. 测试与验收
- 建议测试:事务部分成功(运行错误)、EXECABORT(入队错误)、WATCH 竞态两场景、Lua 并发不超卖(多线程)、
SCRIPT KILL恢复(隔离实例); - Lua 死循环实验只在隔离实例做。
本篇验收清单:
- 能说清"Redis 事务无回滚"及两种错误(入队/执行)的不同处置;
- 能区分 Pipeline(省 RTT)与事务(防插入)的语义;
- 能写出秒杀扣库存的 Lua 脚本并解释原子性来源;
- 知道
EVALSHA的 NOSCRIPT 回退路径与 Functions 的定位; - 知道脚本死循环会全局阻塞及
SCRIPT KILL的边界。
7. 常见误区
- "Redis 事务会回滚"------不会;运行错误只跳过出错命令(§1.2)。
- "pipeline 就是事务"------pipeline 只省往返,命令之间可被插入(§2)。
- "Lua 脚本可以随便写" ------死循环/大循环会全局阻塞,且写过数据后
SCRIPT KILL失效(§3.4)。 - "
EVALSHA永远可用" ------服务器重启后缓存清空,必须处理NOSCRIPT回退(§3.3)。 - "WATCH 适合高竞争场景"------竞争激烈时重试风暴反而放大压力,高冲突用 Lua(§1.4、§3)。
8. 本篇小结
三种工具对应三种"原子性"需求:
- 防插入 →
MULTI/EXEC(串行不被打断,但不回滚); - 防偷改 →
WATCH乐观锁(CAS,低竞争适用); - 复合逻辑原子执行 → Lua 脚本(判断 + 操作一体,秒杀等场景的正解);
- 省往返 → Pipeline(性能工具,与一致性无关)。
下一篇 Part 8:缓存设计模式与一致性 把这些原子性武器用到缓存架构上:Cache Aside、先删缓存还是先更新库、穿透/击穿/雪崩的防护,以及"缓存一致性"为什么是个无解的工程权衡。
9. 官方资料
- Transactions:https://redis.io/docs/latest/develop/interact/transactions/
- EVAL / EVALSHA:https://redis.io/docs/latest/commands/eval/
- Functions:https://redis.io/docs/latest/develop/programmability/functions-intro/
- Programming with Lua:https://redis.io/docs/latest/develop/programmability/