Ch.8: 把故障与并发简化成"abort 后安全重试" — 隔离级别是阶梯, 每级防住的异常不同; SI 不是终点, write skew 会漏
SET x = x + 1SELECT FOR UPDATE事务像ATM 取钱的"全有或全无": 扣款和吐钞要么都发生要么都不发生 (原子性), 两台 ATM 同时扣同一账户不能把钱扣没 (隔离), 断电后已出钞的交易记录还在 (持久)。隔离级别像景区门票等级: read committed保证你看到的都是"检票完成的" (无脏读); 快照隔离发你一个入园时刻的园区照片, 整个游程都对照这张照片 (MVCC, 防 read skew) — 但两人照着"今天园里有人值班"的旧照片各自请假 (write skew), 景区照样空场; 只有可串行化承诺"你们排队一个个来"。
BEGIN; UPDATE accounts SET bal = bal - 100 WHERE id=1; UPDATE accounts SET bal = bal + 100 WHERE id=2; -- 这里崩? COMMIT; # 崩在第 3 行 → 两条全回滚, 钱没少
# C 的例子: 余额 ≥ 0 是业务不变量 # 库只能提供约束/锁/MVCC 这些"工具" # 用不用对, 责任在应用 (选对隔离级别)
# 多对象: 订单 + 库存 + 派生计数 同生共死 BEGIN; INSERT order; UPDATE stock; UPDATE user_stats SET order_cnt = order_cnt+1; COMMIT; # 三个必须一致, 否则派生数据分叉
# 脏读剧本: A 改 price=88 未提交 # B 读到 88 并发货 → A 回滚 → B 基于幻觉发货 # read committed: B 只能读到已提交的 66 ✓
# 每行多版本: # v3 (committed, x=66) ← 读者拿这个 # v4 (uncommitted, x=88) ← 写者持有, 锁着
BEGIN ISOLATION LEVEL repeatable read; SELECT sum(bal) FROM accounts; # 快照: 1000 # 别的事务插删改 → 我的快照看不见 SELECT sum(bal) FROM accounts; # 还是 1000 ✓
# RC 下: SELECT bal FROM a; # 500 # 此刻另一事务转 300: a=200, b=700 SELECT bal FROM b; # 700 → 500+700=1200 ≠ 900 # SI: 两次读同一快照, 永远 900 ✓
# 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 行受影响则重试
# 幻影: 预约系统查"该医生 x 点无预约" → 无行可锁 # 两个事务同时为同一时段各插一条预约 → 双预订 # 修: 索引范围锁 / 物化时间槽行
-- 预建 365×24×房间数 行, 哪怕永远用不到 UPDATE slots SET booked = true WHERE room=7 AND hour='2026-09-26T15:00'; # 两事务抢同一行 → 行锁冲突 → 一个等待/失败 ✓
# ① Redis Lua: 整段脚本单线程原子 # ② 2PL: SELECT..FOR SHARE / FOR UPDATE + 死锁检测 # ③ SSI: PG serializable — 冲突率低时最划算
# 死锁剧本: T1 锁 A 等 B; T2 锁 B 等 A # 数据库检测到环 → abort 其中一个 (如 T2) # 应用必须能重试被 abort 的事务!
# in-doubt 剧本: # 参与者投了 yes, 协调器崩溃 → 每个都"不知道结局" # 行锁全部持有, 其他事务排队 — 系统逐渐僵住 # 缓解: 协调器高可用 / saga 替代 (Ch.13)
点赞数并发 +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 是"应用层两步", 任何非串行隔离都会丢。
两个医生同时请假, 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) 让库检测过时前提 — 二选一写进评审。
报表跑 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 告警。
秒杀场景两个请求同时扣最后一件 — 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 预扣。
转账用悲观锁, 但事务里夹了风控 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;
纪律: 事务内零网络调用; 锁的获取顺序全局统一防死锁。
转账 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 → 说明顺序纪律被破坏, 审代码而非调参。
"检查库存再扣减"两条命令间有窗口 — 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: 串行执行要求"数据访问模式预先知道且短小" — 存储过程同理。
不信文档信实验: 两个会话手工复现每种异常, 一辈子不忘。
-- 会话 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 作业 — 隔离级别从名词变成肌肉记忆。
应用进程 OOM 挂掉, 它当协调器的 XA 事务全部 in-doubt, 参与者锁全挂。
# 事故还原: # 1) 应用 (含 XA 协调器) prepare 了 3 个资源 # 2) 进程 OOM 被杀 — 协调器日志在内存里 # 3) 参与者: 已投 yes, 等待裁决 → 持锁数小时 # 4) 其他事务排队, 全库僵住 # 缓解: 协调器状态落盘 + 独立进程 / 由运维恢复日志裁决 # 治本: 跨服务改 saga 补偿 (Ch.13), 库内跨分片用共识库
面试口径: 2PC 解决原子提交, 但它是阻塞协议 ≠ 容错共识 (共识页展开)。
两个用户同时预约同一医生同一时段 — "查无预约"时无行可锁, 双双成功。
# 错: 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 → 已被抢走, 返回冲突
先问一句: "这个不变量能用唯一约束表达吗?" — 能就别碰锁。
# 错: "我们是 serializable 级别" (其实是 SI) # 对: 双会话实验复现 write skew 验证
# 错: BEGIN; 读n; 写n+1; COMMIT; # 对: UPDATE t SET n=n+1 WHERE id=?
# 错: if not exists: insert # 对: INSERT ... ON CONFLICT DO NOTHING
# 错: repeatable read 上线就放心 # 对: 识别"读前提写别处"的用例并加防
# 错: BEGIN → http() → UPDATE → COMMIT # 对: 事务 < 100ms, 网络调用移出
# 错: 死锁异常直接 500 # 对: retry(DeadlockError, backoff)
# 错: 按参数顺序 FOR UPDATE # 对: sorted(ids) 顺序加锁
# 错: 事务内 call_payment() # 对: 先拿凭证 → 短事务落库 → 失败补偿
# 错: 单行 UPDATE 也包 BEGIN/COMMIT 还指望防并发 # 对: 单行靠原子性; 多对象才谈隔离
# 错: while True: try_cas() # 对: backoff 3 次 → 降级/排队
# 错: 秒杀库存全表 serializable # 对: 原子 UPDATE stock>0 / Redis 预扣
# 错: default_transaction_isolation='serializable' # 对: 特定事务 BEGIN ISOLATION LEVEL serializable
# 错: 对"尚不存在的预约" FOR UPDATE # 对: UNIQUE(doc,slot) 一行解决
# 错: 每个防并发场景都建槽位表 # 对: 先唯一约束 → 再 SSI → 最后物化
# 错: 每笔订单跨 3 库 2PC # 对: 同用户订单库内事务 + 异步对账
# 错: atomikos 内嵌在 web 进程 # 对: saga 编排独立部署可恢复
# 错: "PG repeatable read 防一切" # 对: write skew 用例 → explicit serializable
# 错: 两条 UPDATE 之间没有任何事务边界 # 对: 显式开事务包住两条
# 错: 5 层 savepoint 嵌套 # 对: 拆小事务 + 应用层状态机
# 错: 客户端断连, 事务挂着不回滚 # 对: idle_in_txn_timeout = 60s 兜底