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 有三个必须知道的边界:

  1. 它返回的结果是不完整的。你这次查询看不到被锁的行,所以「统计有多少 pending 任务」这种查询绝不能加 SKIP LOCKED。
  2. 它破坏了语句级快照的一致性。官方文档明确说明 SKIP LOCKED 提供的是不一致的视图,只能用于「把行取出来独占处理」这种场景。
  3. 它不保证公平。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 工程课》里展开。

实践上事务边界该这么划:

方案选择表

场景 方案 理由
扣库存、扣额度,条件能写进 WHERE 原子 UPDATE ... WHERE qty >= n 判断在锁内完成,无需额外机制
冲突率低(同一行偶发竞争) 乐观锁(版本号) 多数请求一次成功,无锁开销
冲突率高(热点行) 悲观锁 FOR UPDATE 稳定排队,避免重试风暴
多消费者分发任务 FOR UPDATE SKIP LOCKED 跳过被锁行,无等待无重复
需要跨表一致更新 一个事务包住全部语句 原子性由事务提供
需要在事务里调外部服务 拆开,或用发件箱模式 避免长事务与外部故障耦合

生产边界

本课所有并发实验都用 pg_sleep 制造持锁时间,误差在百毫秒级。真实系统的锁持有时间是毫秒甚至微秒级,但行为形状一致:锁等待会体现为 wait_event = transactionid,重试会体现为影响行数为 0。

替换到生产环境时要改的地方:

  1. 锁超时。PG 默认 lock_timeout = 0(无限等待)。生产上必须设一个值(比如 3 秒),让「等不到锁」变成一次明确的错误而不是一次挂起的请求。同理 statement_timeout 和 idle_in_transaction_session_timeout 都要配。
  2. 死锁的观测。本章没有制造死锁(那属于《PostgreSQL 工程课》第 05 章的范围),但当两个事务以不同顺序锁同一批行时它会发生。pg_stat_database.deadlocks 是它的计数器,非零就说明有代码在多个地方以不同顺序访问同一批表。
  3. 连接的归属。乐观锁的重试要在同一个连接之外做(重新开事务),悲观锁的锁必须落在持有事务的那个连接上。连接池下最容易出的错是「在 A 连接上加锁、在 B 连接上提交」,那等于什么都没锁。

上线后该盯的指标:pg_stat_activity 里 wait_event = transactionid 的会话数(锁等待规模)、pg_stat_database.deadlocks(死锁次数)、pg_stat_database.xact_rollback(回滚率,乐观锁冲突会推高它)、以及 idle in transaction 状态的最长持续时间。

动手

  1. 复现丢失更新。判断标准:最终值落在 130.00,你能解释为什么不是 180.00;然后把 SET amount = 100 + 30 改成 SET amount = amount + 30 再跑一次,判断标准:这次结果是 180.00。
  2. 复现阻塞链。开两个 psql 会话,一个 BEGIN 后 UPDATE 不提交,另一个 UPDATE 同一行,然后在第二个会话里执行 SELECT pg_blocking_pids(pg_backend_pid());。判断标准:返回的数组里是第一个会话的 pid。
  3. 复现 SKIP LOCKED 的分食。判断标准:两个消费者取到的 id 集合不重叠,且第二个消费者没有等待。
  4. 把 SKIP LOCKED 去掉再跑一次第 3 步。判断标准:第二个消费者阻塞,用 pg_blocking_pids 能看到阻塞者。

自测

  1. 读已提交下,UPDATE ... SET v = v + 1 和 UPDATE ... SET v = 快照里读到的值 + 1 有什么本质区别?为什么前者安全?
  2. 悲观锁和乐观锁的冲突率阈值不是固定数字。请说出两个影响这个阈值的因素。
  3. 库存扣减写成 UPDATE ... WHERE qty >= n 为什么不需要额外的锁机制?请说明这个 WHERE 条件在哪里被求值。
  4. SKIP LOCKED 的结果集为什么是「不一致的视图」?举一个不能使用它的查询。
  5. 一个事务中间调了一次第三方 HTTP 接口。这个设计会导致什么数据库层面的后果?PG 里哪个参数能帮你发现这类问题?

↓ 下一步:05 章 · 权限、schema 与租户隔离

进入 keel 阅读