SQL 很快,事务却在排队:PostgreSQL OLTP 锁竞争
本页目录
一条按主键更新余额的 SQL,单独执行很快。并发一上来,接口延迟却持续增长,连接池逐渐占满,数据库 CPU 还没怎么忙。
Ants Aasma 在这次分享中展示了一组等待事件:86 个会话等待 Lock / transactionid,13 个等待 Lock / tuple,还有 1 个等待 IO / WalSync。大量请求已经进入数据库,却只有很少的请求能继续推进。
这种现象在热点账户、库存、共享计数器上都可能出现。每次操作的数据不多,但请求反复修改同一行,必须轮流拿到更新权。此时,SQL 本身执行得再快,也未必能抵消事务拿着锁等待客户端和提交的时间。
真正堵住队列的会话在做什么
看到 transactionid 等待时,可以沿着 pg_blocking_pids() 找直接阻塞者:
SELECT w.pid AS waiting_pid,
w.wait_event_type, w.wait_event,
clock_timestamp() - w.query_start AS query_age,
p.pid AS blocking_pid,
h.state AS blocker_state,
h.wait_event_type AS blocker_wait_type,
h.wait_event AS blocker_wait_event,
clock_timestamp() - h.xact_start AS blocker_xact_age,
left(w.query, 160) AS waiting_query,
left(h.query, 160) AS blocker_last_query
FROM pg_stat_activity AS w
CROSS JOIN LATERAL unnest(pg_blocking_pids(w.pid)) AS p(pid)
LEFT JOIN pg_stat_activity AS h ON h.pid = p.pid
WHERE w.datname = current_database()
ORDER BY w.query_start;
查看其他用户的完整会话信息需要相应权限,例如 pg_read_all_stats。query_age 是语句开始至今的时间,不能直接当成精确的锁等待时长;阻塞者处于空闲状态时,query 显示的是上一条语句。
只找到阻塞者的 PID 还不够。它如果也是等待者,就需要继续沿阻塞链向上找;本站的锁树分析提供了更完整的排查方式。到了链头,关注点通常会有所变化:
| 链头的状态或等待 | 接下来检查什么 |
|---|---|
idle in transaction,Client / ClientRead |
应用为什么还没有发送下一条语句或提交,是否在事务内调用外部接口、等待线程调度 |
IO / WalSync |
提交时的 WAL 同步延迟及存储表现 |
IPC / SyncRep |
同步复制确认为什么迟迟没有返回 |
| 仍在执行其他 SQL | 热点行更新后,还有多少工作留在同一个事务里 |
普通空闲连接也会出现 ClientRead,所以必须结合未结束的事务和实际阻塞关系判断。另一方面,transactionid 也可能来自唯一性检查等路径,不能只凭事件名称就断定所有等待者都在更新同一账户。
这里讨论的是业务事务之间的行更新竞争。它与性能排查文章中的 LWLock / LockManager 不同,后者是数据库内部锁管理结构上的竞争,处理方法不能直接互换。
UPDATE 已经结束,锁还没有释放
下面用两个会话做一个小实验。先在测试库准备数据,金额使用整数单位表示:
CREATE SCHEMA lock_demo;
CREATE TABLE lock_demo.account (
id integer PRIMARY KEY,
balance integer NOT NULL CHECK (balance >= 0)
);
INSERT INTO lock_demo.account VALUES (1, 100);
会话 A 扣除 70,暂不提交:
BEGIN;
UPDATE lock_demo.account
SET balance = balance - 70
WHERE id = 1;
会话 B 再尝试扣除 50:
BEGIN ISOLATION LEVEL READ COMMITTED;
UPDATE lock_demo.account
SET balance = balance - 50
WHERE id = 1 AND balance >= 50
RETURNING balance;
B 会停在这里。A 的更新语句虽然已经返回,但事务仍未结束。在本次 PostgreSQL 18.4 实验中,A 的状态是 idle in transaction,等待 Client / ClientRead;B 则等待 Lock / transactionid,阻塞 PID 正是 A。
在 A 中提交:
COMMIT;
B 随后结束等待,但更新行数为 0。因为 A 已经把余额改成 30,B 对新行版本重新检查 balance >= 50,条件不再成立。最后在 B 中结束事务:
COMMIT;
这个结果对应用代码很重要:没有 SQL 错误,不代表业务操作成功。 条件更新可能影响 0 行,应用必须检查受影响行数或 RETURNING 的结果,再决定是否继续后续流程。
从性能角度看,需要缩短的是“拿到热点行的锁,到事务结束释放锁”这一段。在逐条发送 SQL、逐条等待结果的写法中,这段时间还包含后续网络往返和应用处理。若一个热点对象平均被独占 4 ms,在每个请求都需要更新它的简化模型下,串行环节就把吞吐约束在约 250 次/秒以内。这是数量级估算,不是数据库的跑分;再增加连接,通常只会让队列更长。
因此,优化时可以把不依赖锁的准备工作移出事务,尽量靠后执行热点更新。但涉及多个对象时,所有路径仍需遵守一致的加锁顺序。转账请求一条路径先锁账户 1 再锁 2,另一条反过来,缩短事务也不能消除死锁。
减少往返,要同时处理业务失败
原分享比较了逐条执行与服务端函数两种方式。在 10 ms 网络 RTT 的测试环境中,将业务逻辑集中到服务端执行后,吞吐曲线明显改善。收益来自拿锁之后少了多次客户端交互,不意味着 PL/pgSQL 能让同一热点行被同时更新。
下面把刚才的扣减封装成一个函数。它只演示条件更新和错误处理,不是完整的转账实现:
CREATE FUNCTION lock_demo.debit(p_id integer, p_amount integer)
RETURNS integer
LANGUAGE plpgsql AS $$
DECLARE
new_balance integer;
BEGIN
IF p_amount IS NULL OR p_amount <= 0 THEN
RAISE EXCEPTION 'amount must be positive' USING ERRCODE = '22023';
END IF;
UPDATE lock_demo.account
SET balance = balance - p_amount
WHERE id = p_id AND balance >= p_amount
RETURNING balance INTO new_balance;
IF NOT FOUND THEN
RAISE EXCEPTION 'account missing or insufficient balance';
END IF;
RETURN new_balance;
END;
$$;
在前面的余额为 30、事务均已结束的情况下,单独执行:
SELECT lock_demo.debit(1, 10);
结果为 20。余额不足、账户不存在或金额不合法时,函数会报错,调用方可以明确识别失败。完整转账还需要在同一事务中处理收款方、流水、请求幂等和统一加锁顺序,不能只把两条更新随意拼起来。
这里还要确认驱动的事务行为。如果调用本身已经构成完整业务事务,可以让它作为独立语句自动提交;如果外层还有显式事务,函数执行完之后锁仍然保留。本次实验也验证了这一点:显式 BEGIN 中的函数已经返回结果,另一个会话仍然无法更新同一行,直到外层提交。已经加入更大事务的业务操作,不能为了释放锁就擅自拆开提交。
不想写服务端函数,也可以考虑数据修改 CTE 或 pipeline,但两者各有边界:
- 数据修改 CTE 可以将相互传递结果的操作集中到一条语句。子语句通过
RETURNING传递数据,不能假设按书写顺序执行,也应避免在一条语句中多次修改同一行。 - Pipeline 允许在收到上一条结果之前继续发送命令,减少逐条往返。它仍按顺序执行;若后续命令必须由客户端根据前一条结果生成,就还需要等待结果。SQL 报错会影响到下一个同步点之前的待执行命令,而“更新 0 行”本身不会触发这条错误处理路径。
将扣款、加款、写流水放进 pipeline,并不会自动建立“扣款成功才能加款”的业务关系。这个判断仍需要由 SQL 依赖、服务端逻辑或客户端的结果处理明确保证。
剩下的等待可能在 COMMIT
当网络往返已经减少,提交阶段可能成为新的瓶颈。同步提交需要等待相应的 WAL 持久化条件;配置了同步复制时,还可能需要等待备库确认。在 PostgreSQL 18 的提交路径中,这些等待发生时事务仍持有锁。
于是,后面几十个会话都显示锁等待,链头却显示 WalSync 或 SyncRep。只优化那些等待中的 UPDATE 执行计划,无法消除链头的提交延迟。
材料讨论了 synchronous_commit=off 的性能收益,但这个选项改变了已返回成功事务的持久性承诺。它不停止生成 WAL,也不永久停止刷盘,而是允许在 WAL 持久化之前返回成功;数据库崩溃时,最近一些已经被应用当成成功的事务可能丢失。
对于扣款、发货、向外部系统确认完成这类流程,不能因为一次测试吞吐提高,就直接关闭同步提交。原材料中的 200 ms 也不应理解为严格的丢失窗口上限;官方对异步提交风险窗口的描述是最多三个 wal_writer_delay 周期,故障切换还要另行考虑复制进度。
另一条路线是批处理:把多次业务操作放进一个数据库事务,一起承担提交成本。热点账户只需要被这一批持有一次,批内可以继续处理后续操作。
这项收益有对应代价。批次越大,其他请求等它结束的时间可能越长;还需要明确整批失败是否全部回滚、是否用子事务隔离单项失败、何时向各请求返回结果,以及重试是否会重复记账。评估时应同时看每秒完成的业务操作数、数据库提交次数和尾延迟,不能只看一个 TPS 数字。数据库已有的 group commit 也不能简单等同于应用批处理:共同刷一次 WAL,并不能让两个互斥的热点更新同时持锁执行。
隔离级别决定等待之后会发生什么
前面的扣减实验使用 READ COMMITTED。将 B 改为 REPEATABLE READ 或 SERIALIZABLE,并让它在 A 提交前建立快照,结果会不同:
| B 的隔离级别 | A 修改并提交后的结果 |
|---|---|
READ COMMITTED |
检查新行版本上的条件;本例影响 0 行,余额为 30 |
REPEATABLE READ |
返回 SQLSTATE 40001,需要回滚并重试整个事务 |
SERIALIZABLE |
本例同样返回 40001,需要回滚并重试整个事务 |
这三种行为已经在 PostgreSQL 18.4 中验证。产生错误的关键条件是:对目标行的并发修改在 B 建立快照之后才提交。若 A 只是持有行锁、没有修改,提交后 B 未必报错;实验中,这种情况下的 REPEATABLE READ 更新可以成功。
热点越集中,反复失败和重试的成本就越高。发生 40001 后,只重试最后一条 UPDATE 无法重新完成整个事务的判断,必须从事务起点重来。重试还应设置次数或总时限,并采用退避,避免一批请求同时重试再次拥挤。
但更换隔离级别之前,需要确认业务约束如何成立。简单的“按主键扣减,并在同一 UPDATE 中检查余额”与“先查询多行汇总,再决定往另一张表插入”有很大区别,不能用同一个结论覆盖。
改成追加明细,为什么还可能超卖
热点更新的一种改造方式,是不再每次覆盖余额,而是追加增减明细,读取时汇总,之后定期合并成基准值。这可以分散对单行的写入,但如果每次操作都依赖“当前还剩多少”,竞争关系仍然存在。
原分享用订票解释了这一点。航班只剩 2 个座位,两个事务都查询剩余数量,再各自插入一条订购 2 个座位的记录。它们写入不同的明细行,互相没有行更新冲突,却可能共同售出 4 个座位。
本次补充实验的结果如下:
| 隔离级别 | 两个事务都先读到剩余 2,再各订 2 个座位 |
|---|---|
READ COMMITTED |
两个都能提交,最终订出 4 个座位 |
REPEATABLE READ |
两个都能提交,最终订出 4 个座位 |
SERIALIZABLE |
一个事务提交,另一个收到 40001;最终订出 2 个座位 |
这就是写偏差:修改的行不同,违反的却是同一条跨行约束。REPEATABLE READ 提供稳定快照,也会允许这种结果。
如果使用 READ COMMITTED,一种设计是先按统一规则锁住航班控制行,再用后续语句重新读取并检查余量;也可以把余量检查与扣减合并到控制行上的原子更新中。所有预订路径都要遵守同一套协议。两者都会重新引入一个串行控制点,但这是业务约束要求的协调。使用 SERIALIZABLE 则要正确处理整笔事务重试,并评估高竞争下的成功吞吐。
材料还提出拆分热点账户,将一个逻辑账户分成多个子账户。对于持续收款、余额充足的热点账户,这可能分散写压力;但某个子账户余额不足、额度在子账户间迁移、全局余额必须实时可用等问题,都需要额外设计。如果每次写入最终仍要锁同一行做全局校验,拆分带来的并行性就很有限。
会话级 advisory lock 也可以在开始业务事务之前协调请求,再进入 REPEATABLE READ 建立快照。不过它依赖所有相关写入路径共同遵守协议,且提交、回滚不会自动释放会话锁。使用连接池时还必须保证持锁、业务事务和解锁在同一个后端会话上完成,异常路径也不能漏掉解锁。它适合有明确会话管理能力的实现,不宜当成给所有 SQL 套上的通用补丁。
改造后,检查队列是否真的缩短
对这类负载,一条很慢的 UPDATE 可能只是队尾的受害者。应当将数据库等待事件与应用事务边界对应起来,确认锁是在执行 SQL、等客户端,还是等提交的阶段被长时间占用。
测试时保留相同的热点分布,逐步增加并发,观察成功业务量、P95/P99 延迟、事务时长和重试比例。减少了往返,却把事务扩大成很长的批次,可能提高平均吞吐而恶化尾延迟;缩小连接池可以缓解排队压力,但不会改变单个热点必须串行更新的事实。lock_timeout 和 statement_timeout 可以限制等待,超时后仍需要按业务规则结束或重试事务。
分享最后介绍了 Eventual Durability 原型:尝试把允许后续事务继续执行的时点与等待持久化确认的时点分开,并追踪事务之间的依赖。这部分属于内核设计探索,不是本文 PostgreSQL 18 环境里可以直接打开的功能,也不能用 synchronous_commit=off 代替。
现有系统更值得优先检查的,通常是热点行锁住之后那一段事务:有没有不必要的往返和外部调用,提交耗时占多少,失败后重试了多少次。弄清这些,再决定集中服务端逻辑、有限批处理,还是重新设计热点数据,优化才有明确的落点。
参考资料
- 原分享:Ants Aasma,Hey, I'm using that! Fixing lock contention in OLTP,PGConf.DE 2026,会议幻灯片。
- 等待与并发控制:统计视图和等待事件、显式锁与 advisory lock、事务隔离、序列化失败处理。
- 减少往返:Pipeline mode、数据修改 CTE。
- 提交语义:异步提交、WAL 配置、PostgreSQL 18 提交路径源码。