Redis 系列(七):事务、Pipeline 与 Lua 脚本——从原子性到 Functions

核心目标:区分 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 个人同时下单,怎么保证不超卖?"三种典型答案:

  1. 读 → 判断 → 写(Python 代码三步):100 个并发全读到 10,全通过------超卖 90 件;
  2. MULTI 事务包裹:入队执行,但"判断"和"扣减"不是一条命令,中间态仍然可被插入;
  3. 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:ok1t: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、主从角色切换 都会清空缓存,此时 EVALSHANOSCRIPT,客户端要回退到 EVALFLUSHALL/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. 常见误区

  1. "Redis 事务会回滚"------不会;运行错误只跳过出错命令(§1.2)。
  2. "pipeline 就是事务"------pipeline 只省往返,命令之间可被插入(§2)。
  3. "Lua 脚本可以随便写" ------死循环/大循环会全局阻塞,且写过数据后 SCRIPT KILL 失效(§3.4)。
  4. "EVALSHA 永远可用" ------服务器重启后缓存清空,必须处理 NOSCRIPT 回退(§3.3)。
  5. "WATCH 适合高竞争场景"------竞争激烈时重试风暴反而放大压力,高冲突用 Lua(§1.4、§3)。

8. 本篇小结

三种工具对应三种"原子性"需求:

  • 防插入MULTI/EXEC(串行不被打断,但不回滚);
  • 防偷改WATCH 乐观锁(CAS,低竞争适用);
  • 复合逻辑原子执行 → Lua 脚本(判断 + 操作一体,秒杀等场景的正解);
  • 省往返 → Pipeline(性能工具,与一致性无关)。

下一篇 Part 8:缓存设计模式与一致性 把这些原子性武器用到缓存架构上:Cache Aside、先删缓存还是先更新库、穿透/击穿/雪崩的防护,以及"缓存一致性"为什么是个无解的工程权衡。


9. 官方资料

相关推荐
杜子不疼.1 小时前
模型效果不好时,我现在会先看数据集
数据库
夜雪一千1 小时前
MySQL的事务是什么?原理、ACID、隔离级别与实战详解
数据库·mysql
上下求索,莫负韶华2 小时前
笔记-数据库事务和java事务和切面
java·数据库·笔记
黄华SJ520it2 小时前
顶俏洗衣液模式制度开发介绍:S2B2C社交分销+多门店核销系统全解析
前端·数据库·小程序·零售·系统开发
大黄说说2 小时前
Android 本地存储深度对比:SharedPreferences、MMKV、Room 数据库怎么选?
android·数据库
资讯第一线2 小时前
SQL Server工具:WinToolsPlus 之 SQL Server 日志清理/Suspect/质疑/置疑/可疑/单用户等 修复
数据库·oracle
追风少年ii2 小时前
课前准备--蛋白质PDB文件与PDBQT文件的格式与区别
数据库·数据分析·分子动力学·分子对接
hopsky3 小时前
《HBase 权威指南》 Lars George
大数据·数据库·hbase
Architect_Lee3 小时前
mac快速安装mysql
数据库·mysql·macos
Elastic 中国社区官方博客3 小时前
Elasticsearch:列式索引模式 - Columnar index mode
大数据·数据库·elasticsearch·搜索引擎·全文检索