SQL 很快,事务却在排队:PostgreSQL OLTP 锁竞争

本页目录

一条按主键更新余额的 SQL,单独执行很快。并发一上来,接口延迟却持续增长,连接池逐渐占满,数据库 CPU 还没怎么忙。

Ants Aasma 在这次分享中展示了一组等待事件:86 个会话等待 Lock / transactionid,13 个等待 Lock / tuple,还有 1 个等待 IO / WalSync。大量请求已经进入数据库,却只有很少的请求能继续推进。

这种现象在热点账户、库存、共享计数器上都可能出现。每次操作的数据不多,但请求反复修改同一行,必须轮流拿到更新权。此时,SQL 本身执行得再快,也未必能抵消事务拿着锁等待客户端和提交的时间。

真正堵住队列的会话在做什么

看到 transactionid 等待时,可以沿着 pg_blocking_pids() 找直接阻塞者:

SQL
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 已经结束,锁还没有释放

下面用两个会话做一个小实验。先在测试库准备数据,金额使用整数单位表示:

SQL
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,暂不提交:

SQL
BEGIN;
UPDATE lock_demo.account
SET balance = balance - 70
WHERE id = 1;

会话 B 再尝试扣除 50:

SQL
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 中提交:

SQL
COMMIT;

B 随后结束等待,但更新行数为 0。因为 A 已经把余额改成 30,B 对新行版本重新检查 balance >= 50,条件不再成立。最后在 B 中结束事务:

SQL
COMMIT;

这个结果对应用代码很重要:没有 SQL 错误,不代表业务操作成功。 条件更新可能影响 0 行,应用必须检查受影响行数或 RETURNING 的结果,再决定是否继续后续流程。

从性能角度看,需要缩短的是“拿到热点行的锁,到事务结束释放锁”这一段。在逐条发送 SQL、逐条等待结果的写法中,这段时间还包含后续网络往返和应用处理。若一个热点对象平均被独占 4 ms,在每个请求都需要更新它的简化模型下,串行环节就把吞吐约束在约 250 次/秒以内。这是数量级估算,不是数据库的跑分;再增加连接,通常只会让队列更长。

因此,优化时可以把不依赖锁的准备工作移出事务,尽量靠后执行热点更新。但涉及多个对象时,所有路径仍需遵守一致的加锁顺序。转账请求一条路径先锁账户 1 再锁 2,另一条反过来,缩短事务也不能消除死锁。

减少往返,要同时处理业务失败

原分享比较了逐条执行与服务端函数两种方式。在 10 ms 网络 RTT 的测试环境中,将业务逻辑集中到服务端执行后,吞吐曲线明显改善。收益来自拿锁之后少了多次客户端交互,不意味着 PL/pgSQL 能让同一热点行被同时更新。

下面把刚才的扣减封装成一个函数。它只演示条件更新和错误处理,不是完整的转账实现:

SQL
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、事务均已结束的情况下,单独执行:

SQL
SELECT lock_demo.debit(1, 10);

结果为 20。余额不足、账户不存在或金额不合法时,函数会报错,调用方可以明确识别失败。完整转账还需要在同一事务中处理收款方、流水、请求幂等和统一加锁顺序,不能只把两条更新随意拼起来。

这里还要确认驱动的事务行为。如果调用本身已经构成完整业务事务,可以让它作为独立语句自动提交;如果外层还有显式事务,函数执行完之后锁仍然保留。本次实验也验证了这一点:显式 BEGIN 中的函数已经返回结果,另一个会话仍然无法更新同一行,直到外层提交。已经加入更大事务的业务操作,不能为了释放锁就擅自拆开提交。

不想写服务端函数,也可以考虑数据修改 CTE 或 pipeline,但两者各有边界:

将扣款、加款、写流水放进 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 代替。

现有系统更值得优先检查的,通常是热点行锁住之后那一段事务:有没有不必要的往返和外部调用,提交耗时占多少,失败后重试了多少次。弄清这些,再决定集中服务端逻辑、有限批处理,还是重新设计热点数据,优化才有明确的落点。

参考资料

讨论与补充

在 GitHub 查看讨论

支持作者

感谢支持 PostgreSQL Howto 的长期维护。

微信赞赏码

读者交流群

二维码若已过期,可通过公众号 PostgreSQL 学徒或邮件联系。

PostgreSQL 学徒读者交流群二维码