4.1 事务MULTI与EXEC的队列本质


文档摘要

4.1 事务MULTI与EXEC的队列本质 本节摘要:Redis 事务是命令队列:MULTI 开启后命令只入队不执行,EXEC 时一次性按序执行,期间不插入其他客户端命令。它保证隔离与顺序,但不保证回滚——执行期出错,前面的结果照常生效。 队列怎么工作 MULTI 之后客户端进入事务态,命令被逐条追加进服务端的队列;EXEC 一到,队列整体顺序执行并把结果批量返回。因为主线程是单线程,这批命令天然连续执行,不存在其他客户端插队——这就是 Redis 事务的隔离性来源:不是锁,是队列加单线程。 事务生命周期 为什么没有回滚 队里第二条命令执行出错(比如对字符串 DECR),第一条已经改掉的数据不会回滚。

4.1 事务MULTI与EXEC的队列本质

本节摘要:Redis 事务是命令队列:MULTI 开启后命令只入队不执行,EXEC 时一次性按序执行,期间不插入其他客户端命令。它保证隔离与顺序,但不保证回滚——执行期出错,前面的结果照常生效。

队列怎么工作

> MULTI OK > TXQUEUED (integer) 1 # 每条命令返回 QUEUED > SET stock:1001 99 QUEUED > DECR stock:1001 QUEUED > EXEC 1) (integer) 99 2) (integer) 98

MULTI 之后客户端进入事务态,命令被逐条追加进服务端的队列;EXEC 一到,队列整体顺序执行并把结果批量返回。因为主线程是单线程,这批命令天然连续执行,不存在其他客户端插队——这就是 Redis 事务的隔离性来源:不是锁,是队列加单线程

事务生命周期

为什么没有回滚

队里第二条命令执行出错(比如对字符串 DECR),第一条已经改掉的数据不会回滚。官方的解释很坦率:错误只应来自编程 bug(类型用错),生产不该出现,而回滚机制会让实现复杂一大截,不值得。

两种错误的分界动手看一遍最清楚。左边是入队期错误,右边是执行期错误:

# 终端一:入队期拼写错误 > MULTI > SET k1 hello > HGETALL k1 # 对String用Hash命令,语法本身能解析 QUEUED > EXEC 1) OK 2) WRONGTYPE Operation against a key holding the wrong kind of value # 终端二:入队期语法错误 > MULTI > SETTX k2 v (error) ERR unknown command > SET k2 v > EXEC (error) EXECABORT Transaction discarded # 存在无法入队的命令,EXEC直接整体放弃

对照结论:EXECABORT 出现说明错误发生在入队期,整个队列被丢弃,一条都没执行——安全;返回数组里夹着 WRONGTYPE 说明错误在执行期,出错的只有那一条,它前后的命令全部生效——危险。预发环境里跑一遍这两段,比背十遍规则记得牢。

因此用事务前先记住三条纪律:入队期的语法错误会导致整个事务拒绝执行(安全);执行期的类型错误只影响那一条(危险,前面已生效);事务内没有条件判断,命令在入队时就定死了。

WATCH:把条件判断补上

"库存大于 0 才扣"这种条件,事务本身表达不了,靠 WATCH 乐观锁实现:

> WATCH stock:1001 # 盯住这个键的版本 > stock = GET stock:1001 # 客户端读到 5 > MULTI > SET stock:1001 4 > EXEC # 若期间别人改过 stock 则返回 nil (nil) # 返回空说明被打断,需重读重试

原理:每个键带一个版本计数,WATCH 记下当前值,EXEC 时检查被盯的键是否变过,变过就放弃整个事务。客户端拿到 nil 后重新读值再跑一轮——乐观锁的哲学:不阻止并发,冲突了重来。库存竞争激烈时重试风暴会让吞吐骤降,这种场景直接跳到 4.2 的 Lua。

把 WATCH 重试写成完整循环才叫会用。背景:账户余额扣款,低竞争场景;操作:读余额、本地校验、WATCH 加 MULTI 加 EXEC、失败重试:

def withdraw(key, amount, retries=3): for _ in range(retries): with r.pipeline() as pipe: try: pipe.watch(key) # 盯住余额 balance = int(pipe.get(key) or 0) if balance < amount: pipe.unwatch() return None # 余额不足,不玩了 pipe.multi() # 开事务 pipe.decrby(key, amount) pipe.execute() # EXEC,被打断会抛异常 return balance - amount except WatchError: # 有人抢先改了余额 continue # 重读重试 raise BusyRetryExhausted # 重试用尽,转告警

解读四个细节:watch 之后的连接处于"观察态",multi 才切回事务态;判断不足要走 unwatch 显式收场,别挂着观察进下个请求;WatchError 就是"EXEC 返回 nil"的客户端形态;retries 上限必须有——竞争一高,无限重试等于自砸吞吐。变式:盯多个键(转账场景同时 WATCH 两个账户)时,任一键变动都会打断,粒度越细冲突越少。

它适合干什么

场景 事务表现
固定命令序列打包减少往返 合适,等同 pipeline 加原子
键版本乐观并发控制 配 WATCH 合适,低冲突场景
高竞争条件扣减 不合适,重试风暴,用 Lua
需要部分失败回滚 做不到,回数据库层做

最后做个三方辨析收束。pipeline 是纯网络层优化:打包发送省往返,既不保证原子也不保证中间不被插队;MULTI/EXEC 在 pipeline 的基础上加了"队列加单线程"的隔离,命令连续执行;Lua 再往上走一层,把"判断"也搬进服务端。三者的关系是层层包含的升级而非替代——批量写日志用 pipeline 就够,转一笔账用事务,抢一件库存用脚本。选型时按"需要隔离吗、需要条件吗"两个问题往下走,答案自然浮出。

💡 关键直觉:把 MULTI/EXEC 理解成"带隔离保证的批量执行"而不是数据库事务,你对它的所有预期就都对齐了。

本节要点回顾

  • 隔离来自队列加单线程,不是锁机制
  • 不回滚是设计选择,执行期错误前面的结果保留
  • 事务内无条件逻辑,条件要靠 WATCH 乐观锁外置
  • WATCH 冲突即放弃,客户端负责重试
  • 高竞争条件写,直接用下一节的 Lua

作者与出处
原作者: 灏天文库
来源:灏天文库
整理: 灏天文库整理
由灏天文库平台收录,内容或由平台用户上传,仅供学习交流
发布者: 作者: 灏天文库 转发
评论区 (0)
U