DDIA · 事务与隔离

Ch.8: 把故障与并发简化成"abort 后安全重试" — 隔离级别是阶梯, 每级防住的异常不同; SI 不是终点, write skew 会漏

隔离级别阶梯: 每升一级多防一类异常 read committed (读已提交) ✓ 防: 脏读 / 脏写 (行锁 + MVCC 旧值) ✗ 仍会: 丢失更新 / read skew / 幻影 MySQL RC · PG 默认 — 最常用也最常被误解 snapshot isolation (快照隔离) ✓ 防: 脏读 / 脏写 / read skew   事务读一个一致性 MVCC 快照 ✗ 仍会: write skew / 部分丢失更新 PG repeatable read = SI (命名陷阱) Oracle "serializable" 其实也是 SI 并发 read-modify-write 仍可能互相覆盖 serializable (可串行化) ✓ 唯一"真隔离": 结果 ≡ 某种串行顺序 三种实现: ① 串行执行: 单线程+存储过程 (VoltDB/Redis) ② 2PL: 读写互阻塞, 持锁至提交 (防幻影) ③ SSI: 乐观执行, 提交时检测过时前提 代价递增: ①吞吐受单核限 ②死锁+延迟 ③高冲突回滚多 面试口径: 可串行化 ≠ 线性一致 (后者是复制 recency) strict serializability = 两者兼得 (Spanner/FDB) 命名陷阱: SQL 标准的 isolation level 与各家实现并不对齐 — 永远以"防住哪些异常"为准, 不背名字 write skew 经典: 值班医生双双请假 事务 A (Alice) 1. 查: 当前值班 ≥ 2 人 ✓ (看到 Bob) 2. 申请: Alice 请假 off_call 前提成立 → 提交成功 事务 B (Bob) 1. 查: 当前值班 ≥ 2 人 ✓ (看到 Alice) 2. 申请: Bob 请假 off_call 前提也成立 → 提交成功 结果: 两个事务都提交, 值班人数 = 0 — 不变量被击穿 SI 防不了: 两者写"不同对象", 检测不到冲突 解法 ①: 物化冲突 — 预建 (日期×医生) 行, 请假 = UPDATE 同一时间的行 → 行锁冲突现形 解法 ②: SSI / SELECT FOR UPDATE 显式锁住"前提" (on-call 医生行集) 本质: 读同一前提、写不同对象 — 任何单对象锁都拦不住 跨分片原子提交: 2PC 的代价 协调器 prepare: 投 yes = 放弃单方中止权, 之后只能等协调器裁决 participant 1 participant 2 participant 3 in-doubt transaction (阻塞协议) 协调器在 prepare 后崩溃: 参与者已投 yes → 只能持锁等待 既不能提交也不能中止 — 锁全挂 XA: 协调器常在应用进程内, 应用崩 = 孤儿事务 替代: saga / 补偿事务 (Ch.13) 或 共识复制库内事务

ACID 的真义

  • • A = abortability: 中途崩→全部回滚
  • • C = 应用层不变量 (库只辅助)
  • • I = 隔离: 并发事务互不干扰
  • • D = 提交不丢 (WAL + 复制)

隔离阶梯

  • • read committed: 无脏读脏写
  • • snapshot isolation (MVCC): 无 read skew
  • • serializable: 全防, 三种实现各有代价
  • • SI 防不了 write skew — 医生值班案例

丢失更新三招

  • • 原子 UPDATE: SET x = x + 1
  • • 显式锁: SELECT FOR UPDATE
  • • CAS 乐观锁: WHERE version = ?
  • • RMW 跨多语句时 RC 下照样丢

💡 一句话理解

事务像ATM 取钱的"全有或全无": 扣款和吐钞要么都发生要么都不发生 (原子性), 两台 ATM 同时扣同一账户不能把钱扣没 (隔离), 断电后已出钞的交易记录还在 (持久)。隔离级别像景区门票等级: read committed保证你看到的都是"检票完成的" (无脏读); 快照隔离发你一个入园时刻的园区照片, 整个游程都对照这张照片 (MVCC, 防 read skew) — 但两人照着"今天园里有人值班"的旧照片各自请假 (write skew), 景区照样空场; 只有可串行化承诺"你们排队一个个来"。

🧠 必知必会 必考 & 必会

transaction 的意义
把"部分失败+并发交错"两大麻烦合并成一个抽象: 失败就 abort, 整体没发生过; 应用可以安全重试 — 不用为每条路径手工写补偿。
BEGIN;
  UPDATE accounts SET bal = bal - 100 WHERE id=1;
  UPDATE accounts SET bal = bal + 100 WHERE id=2;  -- 这里崩?
COMMIT;   # 崩在第 3 行 → 两条全回滚, 钱没少
ACID 真义
A=abortability (可中止); C=应用不变量 (库帮不上"余额≥0"这种业务规则); I=隔离; D=持久 (WAL+复制)。别背"一致性由库保证"的错误说法。
# C 的例子: 余额 ≥ 0 是业务不变量
# 库只能提供约束/锁/MVCC 这些"工具"
# 用不用对, 责任在应用 (选对隔离级别)
多对象操作
跨多行/表的操作才真正需要事务: 外键、二级索引、反规范化值都要同步更新 — 单行操作用原子性就够了。
# 多对象: 订单 + 库存 + 派生计数 同生共死
BEGIN; INSERT order; UPDATE stock;
       UPDATE user_stats SET order_cnt = order_cnt+1;
COMMIT;   # 三个必须一致, 否则派生数据分叉
dirty read / write
脏读: 读到别人未提交的数据 (对方回滚你就基于幻觉行动); 脏写: 覆盖未提交数据。read committed 是底线。
# 脏读剧本: A 改 price=88 未提交
# B 读到 88 并发货 → A 回滚 → B 基于幻觉发货
# read committed: B 只能读到已提交的 66 ✓
read committed 实现
写锁 + MVCC: 写者持行锁, 读者看最近已提交版本 — 读不阻塞写、写不阻塞读。
# 每行多版本:
#   v3 (committed, x=66)  ← 读者拿这个
#   v4 (uncommitted, x=88) ← 写者持有, 锁着
snapshot isolation / MVCC
事务开始时拍快照, 全程读"那一刻"的一致视图 — 天然防 read skew; 每行多版本 + txid 可见性规则实现。
BEGIN ISOLATION LEVEL repeatable read;
SELECT sum(bal) FROM accounts;   # 快照: 1000
# 别的事务插删改 → 我的快照看不见
SELECT sum(bal) FROM accounts;   # 还是 1000 ✓
read skew
同一事务两次读看到不一致状态: 转账中 A=500 B=400, 读 A 后对方转账完成, 再读 B — 总额从 900"变成"1200。
# RC 下:
SELECT bal FROM a;   # 500
# 此刻另一事务转 300: a=200, b=700
SELECT bal FROM b;   # 700 → 500+700=1200 ≠ 900
# SI: 两次读同一快照, 永远 900 ✓
lost update 与 CAS
两个 read-modify-write 并发, 后写覆盖前写 (计数 42→43 两次只加一次)。解: 原子 UPDATE / FOR UPDATE / CAS 乐观锁。
# RC 下的 RMW 仍丢:  双方都读到 42
# 对 1: 原子化
UPDATE t SET n = n + 1 WHERE id=1;
# 对 2: CAS 乐观锁
UPDATE t SET n=43, ver=ver+1
 WHERE id=1 AND ver=7;  -- 0 行受影响则重试
write skew 与 phantom
两事务读同一前提、写不同对象使前提失效 (医生值班); 幻影: 前提匹配到"尚不存在的行", 无行可锁 — SI 全防不了。
# 幻影: 预约系统查"该医生 x 点无预约" → 无行可锁
# 两个事务同时为同一时段各插一条预约 → 双预订
# 修: 索引范围锁 / 物化时间槽行
物化冲突 (last resort)
把幻影变成具体行的锁冲突: 预建"时间×房间"全量行, 预约=UPDATE 具体行。丑但有效 — 设计期就建的逃生舱。
-- 预建 365×24×房间数 行, 哪怕永远用不到
UPDATE slots SET booked = true
 WHERE room=7 AND hour='2026-09-26T15:00';
# 两事务抢同一行 → 行锁冲突 → 一个等待/失败 ✓
serializable 三实现
① 串行执行: 单线程+存储过程, 吞吐受单核限; ② 2PL: 读写互阻塞, 防幻影但死锁频发; ③ SSI: 乐观, 提交时检测"基于过时前提"的读写冲突。
# ① Redis Lua: 整段脚本单线程原子
# ② 2PL: SELECT..FOR SHARE / FOR UPDATE + 死锁检测
# ③ SSI: PG serializable — 冲突率低时最划算
2PL 与死锁
两阶段: 先获取全部锁、持锁到提交才释放; 读写互阻塞, 并发低; 互相等待→死锁, 必须检测并中止一方。
# 死锁剧本: T1 锁 A 等 B;  T2 锁 B 等 A
# 数据库检测到环 → abort 其中一个 (如 T2)
# 应用必须能重试被 abort 的事务!
2PC 与 in-doubt
跨分片原子提交: prepare (投 yes=放弃单方中止权) → commit。协调器崩溃后参与者只能持锁等待 — 2PC 是阻塞协议, 不是容错共识。
# in-doubt 剧本:
# 参与者投了 yes, 协调器崩溃 → 每个都"不知道结局"
# 行锁全部持有, 其他事务排队 — 系统逐渐僵住
# 缓解: 协调器高可用 / saga 替代 (Ch.13)

🏭 生产实战 real world

场景 1 · 计数器丢失更新: 从"加事务"到原子 UPDATE

点赞数并发 +1 总是少加 — 应用把 read-modify-write 包进事务, RC 下照样丢。

# 错: "我加事务了呀" — RC 隔离下照样丢
BEGIN;
  n = SELECT likes FROM posts WHERE id=1;   # 双方都读 42
  UPDATE posts SET likes = 43 WHERE id=1;   # 双方都写 43
COMMIT;   # 点了两次赞, 只 +1

# 对: 原子化 — 让"读+写"在库内一步完成
UPDATE posts SET likes = likes + 1 WHERE id = 1;
# 或 Redis: INCR post:1:likes   (单线程原子)

判断标准: 只要 RMW 是"应用层两步", 任何非串行隔离都会丢。

场景 2 · 医生值班 write skew: 物化冲突表落地

两个医生同时请假, SI 下都成功, 当天值班 0 人 — 物化冲突把它变成行锁。

-- 预建: 每天每医生一行 (365 × 医生数, 常驻)
CREATE TABLE oncall_slots (
  day date, doctor_id int, on_call boolean,
  PRIMARY KEY(day, doctor_id)
);

-- 请假: 锁住"当天全部医生"的行集 → 事务串行化
BEGIN ISOLATION LEVEL repeatable read;
  SELECT * FROM oncall_slots WHERE day = '2026-09-26'
   FOR UPDATE;                                -- 锁住前提行集
  SELECT count(*) FROM oncall_slots
   WHERE day='2026-09-26' AND on_call;       -- ≥2 才准请
  UPDATE oncall_slots SET on_call=false
   WHERE day='2026-09-26' AND doctor_id=42;
COMMIT;

或直接 PG SERIALIZABLE (SSI) 让库检测过时前提 — 二选一写进评审。

场景 3 · 月度报表的一致快照: repeatable read 实战

报表跑 20 分钟, 数据一边变 — SI 快照让报表读到"00:00 时刻的一致世界"。

-- PG: repeatable read = snapshot isolation
BEGIN ISOLATION LEVEL repeatable read READ ONLY;
-- 多张表交叉核对, 全部基于同一快照:
SELECT sum(amount) FROM orders;        -- 快照视图
SELECT sum(refund)  FROM refunds;      -- 同一快照
COMMIT;
# 长事务代价: 快照期间旧版本不能回收 (vacuum 延后)
# → 长报表挪到只读副本跑, 别拖累主库膨胀

配套监控: pg_stat_activity 里 longest txn > 10min 告警。

场景 4 · 乐观锁版本号: 库存扣减的冲突重试

秒杀场景两个请求同时扣最后一件 — CAS + 重试, 冲突少时最便宜。

# 乐观锁: 读时记版本, 写时校验
row = SELECT stock, ver FROM sku WHERE id=7;   # stock=1, ver=19

for attempt in range(3):
    r = UPDATE sku SET stock=stock-1, ver=ver+1
        WHERE id=7 AND ver=19 AND stock > 0;
    if r.rowcount == 1: break            # 成功
    row = reload(id=7)                    # 冲突 → 重读重试
# 冲突率低 (<5%) 时: 乐观锁 > 悲观锁 (无锁等待)

冲突率高时反转: 热点行秒杀直接用原子 UPDATE ... stock>0 或 Redis 预扣。

场景 5 · FOR UPDATE 的边界: 锁要短、顺序要定

转账用悲观锁, 但事务里夹了风控 HTTP 调用 — 行锁挂 800ms, 全库排队。

# 错: 锁内做网络调用
BEGIN;
  SELECT bal FROM acct WHERE id=1 FOR UPDATE;
  risk = call_http_riskcheck();        # 800ms, 锁全程持有!
  UPDATE acct SET bal=... ;
COMMIT;

# 对: 算在锁外, 写在锁内 (短事务)
risk = call_http_riskcheck();            # 先算 (无锁)
BEGIN;
  UPDATE acct SET bal = bal - 100
   WHERE id=1 AND bal >= 100;           # 原子+条件, 3ms
COMMIT;

纪律: 事务内零网络调用; 锁的获取顺序全局统一防死锁。

场景 6 · 死锁: 相反加锁顺序的修复

转账 A→B 与 B→A 并发, 锁顺序相反必死锁 — 全局排序 + 重试兜底。

# 错: 各自按"自己的顺序"锁
# T1: FOR UPDATE a1 → a2;   T2: FOR UPDATE a2 → a1  → 死锁

# 对: 按 id 升序统一加锁
ids = sorted([from_id, to_id])           # 全局排序
BEGIN;
  SELECT bal FROM acct WHERE id=ids[0] FOR UPDATE;
  SELECT bal FROM acct WHERE id=ids[1] FOR UPDATE;
  ...
COMMIT;
# 兜底: 捕获死锁错误 (40001/1213) 指数退避重试

指标: 死锁/秒 持续 > 0 → 说明顺序纪律被破坏, 审代码而非调参。

场景 7 · 串行执行: Redis Lua 把竞态一笔勾销

"检查库存再扣减"两条命令间有窗口 — Lua 脚本内单线程原子执行。

-- KEYS[1]=库存, ARGV[1]=购买量
local stock = tonumber(redis.call('GET', KEYS[1]) or '0')
if stock >= tonumber(ARGV[1]) then
    redis.call('DECRBY', KEYS[1], ARGV[1])
    return 1            -- 检查+扣减中间无并发插入
end
return 0

# EVAL 脚本 = 串行执行的"存储过程" — 这就是 ① 路线
# 代价: 脚本必须快; 慢脚本阻塞整个 Redis

呼应 DDIA: 串行执行要求"数据访问模式预先知道且短小" — 存储过程同理。

场景 8 · 隔离级别验证: 双事务并发小实验

不信文档信实验: 两个会话手工复现每种异常, 一辈子不忘。

-- 会话 A                                   会话 B
BEGIN; -- RC
SELECT bal FROM a;   -- 500
                                             UPDATE a SET bal=200;
                                             COMMIT;
SELECT bal FROM b;   -- 700  ← read skew!
-- 同脚本在 repeatable read 下: b 读到 400 (快照)
-- 在 serializable 下: A 提交时被 abort (SSI 检测)

把这个实验写成 onboarding 作业 — 隔离级别从名词变成肌肉记忆。

场景 9 · 2PC 孤儿事务: XA 协调器与应用同进程的坑

应用进程 OOM 挂掉, 它当协调器的 XA 事务全部 in-doubt, 参与者锁全挂。

# 事故还原:
# 1) 应用 (含 XA 协调器) prepare 了 3 个资源
# 2) 进程 OOM 被杀 — 协调器日志在内存里
# 3) 参与者: 已投 yes, 等待裁决 → 持锁数小时
# 4) 其他事务排队, 全库僵住

# 缓解: 协调器状态落盘 + 独立进程 / 由运维恢复日志裁决
# 治本: 跨服务改 saga 补偿 (Ch.13), 库内跨分片用共识库

面试口径: 2PC 解决原子提交, 但它是阻塞协议 ≠ 容错共识 (共识页展开)。

场景 10 · 预约双订: 幻影的两种修法

两个用户同时预约同一医生同一时段 — "查无预约"时无行可锁, 双双成功。

# 错: SI 下查完再插, 查询本身不锁"不存在的行"
n = SELECT count(*) FROM appt WHERE doc=7 AND slot='15:00';
if n == 0: INSERT appt ...     # 并发下双双插入

# 修法 1: 唯一约束直接挡 (最便宜!)
ALTER TABLE appt ADD UNIQUE (doc, slot);
# 修法 2: 物化 slot 行 + FOR UPDATE
UPDATE slots SET taken=true
 WHERE doc=7 AND slot='15:00' AND taken=false;
# rowcount=0 → 已被抢走, 返回冲突

先问一句: "这个不变量能用唯一约束表达吗?" — 能就别碰锁。

⚠️ 编码注意与常见坑 pitfalls

坑 1 · 不问默认隔离级别是什么 — PG 默认 RC, Oracle "serializable" 实为 SI, 名同义不同. 原因: 背名字不背行为. 正解: 以"防住哪些异常"为准做实验验证。
# 错: "我们是 serializable 级别" (其实是 SI)
# 对: 双会话实验复现 write skew 验证
坑 2 · 以为事务包住 RMW 就不丢 — RC 下 read-modify-write 照样丢. 原因: 混淆原子性与隔离性. 正解: 原子 UPDATE/FOR UPDATE/CAS。
# 错: BEGIN; 读n; 写n+1; COMMIT;
# 对: UPDATE t SET n=n+1 WHERE id=?
坑 3 · check-then-act 非原子 — 查完再写之间被插队. 原因: 两步当一步. 正解: 唯一约束 / CAS / 谓词锁。
# 错: if not exists: insert
# 对: INSERT ... ON CONFLICT DO NOTHING
坑 4 · 以为 SI 万无一失 — 医生值班双双请假, 不变量被穿. 原因: 不知道 write skew. 正解: SSI / 物化冲突 / 锁前提行集。
# 错: repeatable read 上线就放心
# 对: 识别"读前提写别处"的用例并加防
坑 5 · 长事务持锁 — 事务里调 HTTP/等人工, 行锁挂分钟级. 原因: 事务当流程容器. 正解: 算在锁外写进锁内, 事务只包 DB 步骤。
# 错: BEGIN → http() → UPDATE → COMMIT
# 对: 事务 < 100ms, 网络调用移出
坑 6 · 死锁不重试 — 数据库 abort 了你却把异常抛给用户. 原因: 没预期"abort 是设计的一部分". 正解: 捕获 40001/1213 指数退避重试。
# 错: 死锁异常直接 500
# 对: retry(DeadlockError, backoff)
坑 7 · 锁顺序不统一 — 转账 A→B / B→A 相互等待. 原因: 各写各的. 正解: 全局排序 (按 id) 加锁。
# 错: 按参数顺序 FOR UPDATE
# 对: sorted(ids) 顺序加锁
坑 8 · 事务里调 RPC — 外部调用把本地事务拖成分布式超时. 原因: 图省事. 正解: 先 RPC 后短事务, 或 saga 补偿。
# 错: 事务内 call_payment()
# 对: 先拿凭证 → 短事务落库 → 失败补偿
坑 9 · 单对象"事务"自欺 — 只有单行更新却以为有 ACID 全家. 原因: 概念模糊. 正解: 多对象不变量才需要事务+隔离。
# 错: 单行 UPDATE 也包 BEGIN/COMMIT 还指望防并发
# 对: 单行靠原子性; 多对象才谈隔离
坑 10 · CAS 循环无退避 — 冲突风暴下重试打满 CPU. 原因: 乐观锁没纪律. 正解: 指数退避 + 最大重试 + 冲突率监控。
# 错: while True: try_cas()
# 对: backoff 3 次 → 降级/排队
坑 11 · SSI 高冲突硬用 — 热点行秒杀用 serializable, 回滚率 40%. 原因: "最强就最好". 正解: 冲突高的热点用原子操作/预扣, SSI 留给低冲突复杂不变量。
# 错: 秒杀库存全表 serializable
# 对: 原子 UPDATE stock>0 / Redis 预扣
坑 12 · 全库 serializable 求安心 — 吞吐掉 80%, 死锁回滚满天飞. 原因: 一刀切. 正解: 按事务风险分级, 只有不变量敏感的走 SSI。
# 错: default_transaction_isolation='serializable'
# 对: 特定事务 BEGIN ISOLATION LEVEL serializable
坑 13 · 以为行锁防幻影 — "没有行锁什么?" — 不存在的行无处加锁. 原因: 只会 FOR UPDATE. 正解: 唯一约束/范围锁/物化冲突行。
# 错: 对"尚不存在的预约" FOR UPDATE
# 对: UNIQUE(doc,slot) 一行解决
坑 14 · 物化冲突当首选 — 到处预建行表, schema 膨胀. 原因: 学了一个锤子. 正解: 先试约束/SSI, 物化冲突是 last resort。
# 错: 每个防并发场景都建槽位表
# 对: 先唯一约束 → 再 SSI → 最后物化
坑 15 · 2PC 当性能工具 — 跨分片事务全走 2PC, 延迟翻倍还阻塞. 原因: 把"能跨库"当"该跨库". 正解: 按分片键聚合事务边界, 跨片走 saga。
# 错: 每笔订单跨 3 库 2PC
# 对: 同用户订单库内事务 + 异步对账
坑 16 · XA 协调器与应用同进程 — 应用崩 = 协调器崩 = in-doubt 孤儿持锁. 原因: 部署拓扑无视故障模式. 正解: 协调器状态落盘+独立, 或彻底换 saga。
# 错: atomikos 内嵌在 web 进程
# 对: saga 编排独立部署可恢复
坑 17 · 信"repeatable read=可串行化" — PG 的 RR 只是 SI, write skew 照穿. 原因: 名字望文生义. 正解: 文档对照实验, 需要真串行化用 LEVEL serializable。
# 错: "PG repeatable read 防一切"
# 对: write skew 用例 → explicit serializable
坑 18 · autocommit 误解 — 每条语句一个事务, "半个事务"随时提交. 原因: 驱动默认没看清. 正解: 显式 BEGIN/COMMIT 包多对象操作, autocommit 只用于单语句。
# 错: 两条 UPDATE 之间没有任何事务边界
# 对: 显式开事务包住两条
坑 19 · savepoint 当流程控制 — 层层嵌套 savepoint, 回滚语义没人说得清. 原因: 拿它当 try/catch. 正解: savepoint 只用于"部分回滚重试"的窄场景。
# 错: 5 层 savepoint 嵌套
# 对: 拆小事务 + 应用层状态机
坑 20 · 事务无超时上限 — 忘了 COMMIT 的僵尸事务持锁一夜. 原因: 没配兜底. 正解: idle_in_transaction_session_timeout / innodb_lock_wait_timeout。
# 错: 客户端断连, 事务挂着不回滚
# 对: idle_in_txn_timeout = 60s 兜底