KEEL · 龙骨 · A CURRICULUM FOR THE AI ERA
04 · 事务边界与并发写 — keel 龙骨
## 现场:超卖了三件
现场:超卖了三件
库存扣减的代码在每个团队里都出现过:
stock = db.query_one("SELECT qty FROM stock WHERE sku = %s", sku)
if stock.qty >= n:
db.execute("UPDATE stock SET qty = qty - %s WHERE sku = %s", n, sku)
压测时超卖了三件。日志里每条 SQL 都对,每次判断都过了。问题在于这两条语句之间有一段时间,而这段时间里另一个请求也读了同一个 10。
先猜一下:如果两个请求都读到 qty = 10,各自执行 UPDATE stock SET qty = 10 + 30 和 UPDATE stock SET qty = 10 + 50,最终 qty 是多少?
直觉模型:事务是原子性的边界
事务保证的是「要么全做,要么全不做」,但它不保证「两个人同时做的时候按你想象的顺序做」。
要理解并发写,得把两件事分开看:
- 原子性:事务里的语句对外是一个整体。别人要么看到全部,要么什么都看不到。
- 隔离性:并发事务之间怎么互相看不见。隔离级别是这一项的旋钮。
这两件事容易混在一起,但它们的失效方式完全不同。超卖不是原子性失效(每次 UPDATE 都原子),是隔离性不足加上检查与使用分离。
读已提交:默认级别与它的边界
PostgreSQL 的默认隔离级别是 READ COMMITTED(读已提交)。它的规则是:
每一条语句在执行开始时拍一张快照,语句只能看到那一刻已经提交的数据。
注意是每一条语句,不是每个事务。这意味着同一个事务里的两次 SELECT 可能看到不同的数据——如果中间别人提交了。
先把「丢失更新」复现出来。表里 amount = 100,会话 A 先读后写,会话 B 在中间插入一次修改:
-- 会话 B 先提交了 +50:
-- 最终金额(期望 180,实际是 130:+30 覆盖了 +50):
amount
--------
130.00
A 读到的 100 是基于快照的,A 后面的 UPDATE ... SET amount = 100 + 30 是一个常量赋值,它不依赖表里的当前值。所以 B 写完的 150 被 A 直接覆盖成 130。这就是读已提交下的丢失更新。
关键在最后那半句:如果 A 的 UPDATE 写成 SET amount = amount + 30,结果会不一样。因为 UPDATE ... SET col = col + 30 在行被锁住之后会重新读取行的最新版本再计算。这是读已提交的一个特例规则:更新同一行时,后到的事务会基于最新已提交版本重新求值。
所以读已提交下真正危险的不是「并发更新丢数据」,而是「应用算好一个值再写回去」。前者数据库帮你兜住了,后者是你自己把快照里的旧值算进了表达式。
误判最容易出现在这里:
| 常见误解 | 实际情况 |
|---|---|
读已提交下两个人同时 UPDATE ... SET v = v + 1 会丢一次 |
不会,行锁会让第二个等第一个提交,并基于新值重算 |
读已提交下应用先 SELECT 再 SET v = 常量 是安全的 |
不安全,这正是丢失更新的标准形态 |
| 两个事务同时更新同一行会互相覆盖 | 不会覆盖,会排队;但排队可能导致锁等待和超时 |
三种并发写的表达方式
悲观锁:先锁住再说
最直接的解法是把「读」变成「读并锁住」:
BEGIN;
SELECT qty FROM t_stock WHERE sku = 'SKU-1' FOR UPDATE;
-- 这里做业务判断
UPDATE t_stock SET qty = qty - 1 WHERE sku = 'SKU-1';
COMMIT;
FOR UPDATE 在读到行的时候直接加排他锁,直到事务结束。别人再对同一行 FOR UPDATE 或 UPDATE 都会阻塞。
阻塞的样子可以实测出来。会话 A 开事务锁住一行后停住,会话 B 去更新同一行,此时从第三个会话观察:
pid | state | wait_event_type | wait_event
-------+--------+-----------------+---------------
69620 | active | Timeout | PgSleep
77372 | active | Lock | transactionid
blocked_pid | blocked_by
-------------+------------
77372 | {69620}
两行对照着读:69620 的状态是 PgSleep——它锁住了行正在睡觉(对应脚本里的 pg_sleep)。77372 的等待事件是 Lock / transactionid,意思是它在等一个事务 id 上的锁。pg_blocking_pids(77372) 返回 {69620},直接告诉你是谁挡住了它。
wait_event = transactionid 是排查锁等待时最该记住的一个值。它的语义是「在等处在这个事务里的某个行的锁」,而 pg_blocking_pids() 能把阻塞者直接找出来。生产上把它做成一行巡检 SQL,比事后翻日志快得多。
悲观锁的代价是并发度。同一行的所有写请求串成一条队,热点行的吞吐上限就是「单行处理时间」的倒数。在秒杀场景下,这意味着几百个请求排队等一个 SKU 的锁,前端的超时会被触发。
乐观锁:用版本号做条件更新
乐观锁的思路是「先不锁,写的时候检查有没有被别人改过」。检查的方式是加一个版本号列:
UPDATE t_stock_v SET qty = qty - 1, ver = ver + 1
WHERE sku = 'SKU-1' AND ver = 0;
两个会话各执行一次,结果:
-- 会话 A 影响行数: 1
-- 会话 B 影响行数: 0
A 影响 1 行,B 影响 0 行。B 拿不到行,就知道「我读到的那份数据已经过期了」,业务层据此重试(重新读、重新判断、重新更新)。
乐观锁和悲观锁的选择依据是冲突率:
| 悲观锁 | 乐观锁 | |
|---|---|---|
| 冲突率低 | 每次都要锁,白付代价 | 大多数情况下一次成功 |
| 冲突率高 | 稳定排队,无重试风暴 | 大量重试,DB 和 CPU 都浪费 |
| 持有时间长 | 阻塞放大 | 不影响别人 |
| 实现位置 | 数据库 | 业务层要写重试逻辑 |
判断阈值不是固定数字,要看你的重试成本。一个粗略的经验:如果同一行的写冲突概率低于百分之几,乐观锁通常更划算;冲突率高到几十个百分点,重试会吃掉收益,该回到悲观锁或者换个方案(比如把库存扣减改成一个原子的 UPDATE ... WHERE qty >= n,那样根本不需要读-判断-写三步)。
最后那条方案值得单独说。库存扣减的经典正确写法是:
UPDATE t_stock SET qty = qty - 1 WHERE sku = 'SKU-1' AND qty >= 1;
UPDATE 本身就是原子的,行锁保证不会有两个人同时看到 qty = 1。这个 WHERE 里的 qty >= 1 是在锁内判断的,所以不存在「判断和使用之间有时间窗口」的问题。影响 0 行就表示库存不足。这是三个方案里最省事、也最不容易写错的那个。
SKIP LOCKED:把表当队列
上面三种都在解决「抢同一行」。还有一类并发是「分发不同行」——多个消费者从一个任务表里取任务,希望彼此不重复。
先看不用 SKIP LOCKED 时会发生什么。消费者 A 锁住前两个任务,消费者 B 用普通的 FOR UPDATE 去取:
pid | state | wait_event_type | wait_event
-------+--------+-----------------+---------------
72960 | active | Lock | transactionid
B 被挡住了。它等的不是「任务被处理完」,而是 A 的事务结束——哪怕 A 处理的是别的任务。这在消费者数量多的时候会退化成串行。
SKIP LOCKED 的语义是「遇到已经被别人锁住的行,直接跳过,不要等」:
BEGIN;
SELECT id FROM t_job
WHERE state = 'pending'
ORDER BY id
FOR UPDATE SKIP LOCKED LIMIT 2;
COMMIT;
两个消费者同时执行这条语句:
-- 消费者 B 同时取两个任务,应当跳过 A 已锁的 1、2:
id
----
3
4
-- 消费者 A 当时取到的是:
id
----
1
2
A 拿到 1、2,B 拿到 3、4,没有任何等待、没有任何重复。下面这张图把这条链路画清楚:
flowchart TD
subgraph CA["消费者 A 的连接"]
A1["BEGIN"] --> A2["SELECT ... FOR UPDATE<br/>SKIP LOCKED LIMIT 2"]
A2 --> A3["拿到 id=1, 2<br/>在 t_job 行上加锁"]
A3 --> A4["处理业务<br/>(本实验用 pg_sleep 代替)"]
A4 --> A5["COMMIT<br/>释放行锁"]
end
subgraph CB["消费者 B 的连接"]
B1["BEGIN"] --> B2["同一条 SELECT<br/>SKIP LOCKED LIMIT 2"]
B2 --> B3{"id=1, 2<br/>是否已被锁定"}
B3 -->|"已被 A 锁定,跳过"| B4["拿到 id=3, 4"]
B3 -->|"未被锁定"| B5["拿到 id=1, 2"]
B4 --> B6["COMMIT"]
B5 --> B6
end
subgraph PG["PostgreSQL / labpgfund 库"]
T["表 t_job<br/>id 1..5 全部 state='pending'"]
L["行锁记录<br/>A 持有 1 和 2"]
T -.->|"A 在此加锁"| L
L -.->|"B 跳过被锁的行"| B3
end
A2 --> T
B2 --> T
A5 -.->|"释放后<br/>1、2 可被再取"| T
style CA fill:#f7f7f5,stroke:#c9c9c4,color:#1a1a1a
style CB fill:#f7f7f5,stroke:#c9c9c4,color:#1a1a1a
style PG fill:#eef3fb,stroke:#9bb8f0,color:#1a1a1a
style A1 fill:#ffffff,stroke:#8a8a85,color:#1a1a1a
style A2 fill:#ffffff,stroke:#8a8a85,color:#1a1a1a
style A3 fill:#ffffff,stroke:#8a8a85,color:#1a1a1a
style A4 fill:#ffffff,stroke:#8a8a85,color:#1a1a1a
style A5 fill:#ffffff,stroke:#8a8a85,color:#1a1a1a
style B1 fill:#ffffff,stroke:#8a8a85,color:#1a1a1a
style B2 fill:#ffffff,stroke:#8a8a85,color:#1a1a1a
style B3 fill:#fdf3d6,stroke:#c9a227,color:#1a1a1a
style B4 fill:#ffffff,stroke:#8a8a85,color:#1a1a1a
style B5 fill:#ffffff,stroke:#8a8a85,color:#1a1a1a
style B6 fill:#ffffff,stroke:#8a8a85,color:#1a1a1a
style T fill:#ffffff,stroke:#9bb8f0,color:#1a1a1a
style L fill:#ffffff,stroke:#9bb8f0,color:#1a1a1a
SKIP LOCKED 有三个必须知道的边界:
- 它返回的结果是不完整的。你这次查询看不到被锁的行,所以「统计有多少 pending 任务」这种查询绝不能加
SKIP LOCKED。 - 它破坏了语句级快照的一致性。官方文档明确说明
SKIP LOCKED提供的是不一致的视图,只能用于「把行取出来独占处理」这种场景。 - 它不保证公平。B 总是跳过 A 锁住的,意味着锁一直被占着时,某些行可能长期取不到。任务表里有大量死锁住的行时,要靠超时清理而不是靠
SKIP LOCKED。
事务边界:该包多大
上面所有方案都依赖一件事:事务要短。这条不是性能建议,是正确性建议。
看一个开着事务却什么都不做的会话:
pid | state | xact_age
-------+---------------------+-----------------
31348 | idle in transaction | 00:00:05.789036
idle in transaction 表示事务开着,但连接没在执行任何语句——典型原因是应用在事务中间做了不该在事务里做的事:调外部 HTTP 接口、渲染模板、等用户输入、或者干脆是连接池归还后没有提交。
它的代价在 PostgreSQL 里比在别的数据库里更重,因为 MVCC 的清理需要一个「没有活跃快照」的时机。一个长期开着的事务会一直持有一个快照,而这个快照的 xmin 会挡住所有比它更晚的死元组被清理,导致表膨胀。这就是第 00 章提到的「表越用越大」的一类成因,具体机制在《PostgreSQL 工程课》里展开。
实践上事务边界该这么划:
- 事务里只放数据库操作。 外部调用(HTTP、消息投递、文件写入)放在事务外,或者在事务提交后用「事务性发件箱」这类模式异步处理。
- 事务不要跨用户交互。 需要「用户点确认再提交」的流程,用两阶段(先写草稿、确认时再执行)而不是挂着一个长事务。
- 显式设
idle_in_transaction_session_timeout。 这是连接级别的参数,超时会杀掉空转的事务连接。生产上配一个(比如 60 秒)能让这类问题自己暴露出来,而不是靠表膨胀来提醒你。
方案选择表
| 场景 | 方案 | 理由 |
|---|---|---|
扣库存、扣额度,条件能写进 WHERE |
原子 UPDATE ... WHERE qty >= n |
判断在锁内完成,无需额外机制 |
| 冲突率低(同一行偶发竞争) | 乐观锁(版本号) | 多数请求一次成功,无锁开销 |
| 冲突率高(热点行) | 悲观锁 FOR UPDATE |
稳定排队,避免重试风暴 |
| 多消费者分发任务 | FOR UPDATE SKIP LOCKED |
跳过被锁行,无等待无重复 |
| 需要跨表一致更新 | 一个事务包住全部语句 | 原子性由事务提供 |
| 需要在事务里调外部服务 | 拆开,或用发件箱模式 | 避免长事务与外部故障耦合 |
生产边界
本课所有并发实验都用 pg_sleep 制造持锁时间,误差在百毫秒级。真实系统的锁持有时间是毫秒甚至微秒级,但行为形状一致:锁等待会体现为 wait_event = transactionid,重试会体现为影响行数为 0。
替换到生产环境时要改的地方:
- 锁超时。PG 默认
lock_timeout = 0(无限等待)。生产上必须设一个值(比如 3 秒),让「等不到锁」变成一次明确的错误而不是一次挂起的请求。同理statement_timeout和idle_in_transaction_session_timeout都要配。 - 死锁的观测。本章没有制造死锁(那属于《PostgreSQL 工程课》第 05 章的范围),但当两个事务以不同顺序锁同一批行时它会发生。
pg_stat_database.deadlocks是它的计数器,非零就说明有代码在多个地方以不同顺序访问同一批表。 - 连接的归属。乐观锁的重试要在同一个连接之外做(重新开事务),悲观锁的锁必须落在持有事务的那个连接上。连接池下最容易出的错是「在 A 连接上加锁、在 B 连接上提交」,那等于什么都没锁。
上线后该盯的指标:pg_stat_activity 里 wait_event = transactionid 的会话数(锁等待规模)、pg_stat_database.deadlocks(死锁次数)、pg_stat_database.xact_rollback(回滚率,乐观锁冲突会推高它)、以及 idle in transaction 状态的最长持续时间。
动手
- 复现丢失更新。判断标准:最终值落在
130.00,你能解释为什么不是180.00;然后把SET amount = 100 + 30改成SET amount = amount + 30再跑一次,判断标准:这次结果是180.00。 - 复现阻塞链。开两个
psql会话,一个BEGIN后UPDATE不提交,另一个UPDATE同一行,然后在第二个会话里执行SELECT pg_blocking_pids(pg_backend_pid());。判断标准:返回的数组里是第一个会话的 pid。 - 复现
SKIP LOCKED的分食。判断标准:两个消费者取到的 id 集合不重叠,且第二个消费者没有等待。 - 把
SKIP LOCKED去掉再跑一次第 3 步。判断标准:第二个消费者阻塞,用pg_blocking_pids能看到阻塞者。
自测
- 读已提交下,
UPDATE ... SET v = v + 1和UPDATE ... SET v = 快照里读到的值 + 1有什么本质区别?为什么前者安全? - 悲观锁和乐观锁的冲突率阈值不是固定数字。请说出两个影响这个阈值的因素。
- 库存扣减写成
UPDATE ... WHERE qty >= n为什么不需要额外的锁机制?请说明这个WHERE条件在哪里被求值。 SKIP LOCKED的结果集为什么是「不一致的视图」?举一个不能使用它的查询。- 一个事务中间调了一次第三方 HTTP 接口。这个设计会导致什么数据库层面的后果?PG 里哪个参数能帮你发现这类问题?
↓ 下一步:05 章 · 权限、schema 与租户隔离