Postgres 锁扩展性堪忧
速览
Postgres 数据库的锁机制在高并发场景下存在扩展性瓶颈,可能导致性能下降。该问题影响大规模分布式系统对 Postgres 的选用。开发者可能需要考虑替代方案或优化锁策略。
AI 深度解读
背景
Postgres 是广泛使用的开源关系型数据库,以其可靠性和功能丰富著称。然而,在高并发场景下,其锁机制可能成为性能瓶颈。本文作者团队在 AWS RDS 上运行 Postgres 数据库,日常 CPU 负载维持在 20-40%,但一次意外事件导致数据库 CPU 飙升到 100%,系统时间(system time)占满,引发严重服务中断。本文详细记录了故障排查、修复过程以及根本原因分析,揭示了 Postgres 锁机制在极端情况下的扩展性问题。
核心内容
事件经过
3月24日,数据库突然遭遇 100% CPU 负载,且全部消耗在系统时间(system time)上。团队无法通过 psql 连接数据库,服务全面瘫痪:机器人无法加入通话,客户无法调用 API,仪表盘无响应。排查常见原因(新部署、AWS 故障、交换分区使用等)均无果。团队运行在 AWS RDS 上,无法获得 shell 访问权限,因此无法执行 vmstat 等底层分析工具。
首次尝试:重启数据库
团队假设重启能打破僵局,但按下 RDS 控制台的“重启数据库”按钮后,等了整整 60 分钟系统仍未恢复。通常 EC2 实例重启只需几分钟,RDS 的行为令人费解。重启失败后,团队转向其他方案。
临时缓解:通过安全组限制连接
一位工程师提出修改实例的安全组,拒绝所有实例对数据库的访问。这样已提交的查询会继续执行,但新连接会被阻断,连接数逐渐下降。40 分钟后,数据库负载降低到可以正常重启的程度。重启后,CPU 用户时间(user time)开始占满,说明问题从系统时间转移到了用户时间——这是更可控的问题。团队识别出昂贵的查询,移除或优化它们,使数据库恢复到稳定状态。
安全恢复连接
为了避免突然恢复连接导致再次崩溃,团队在 Postgres 中为机器人角色设置了较小的最大连接数,然后通过安全组重新启用网络访问。这样可以用最大连接数作为阀门,逐步增加数据库负载。
根本原因分析
查看 Postgres 日志,发现一条关键信息:
LOG: process 44916 still waiting for ExclusiveLock on extension of relation 24882112 of database 16398 after 1000.035 ms
DETAIL: Process holding the lock: 91014. Wait queue: 4552, 41114, 64350,....
这是一个巨大的锁 convoy(锁车队),15000 个进程都在等待同一个索引扩展(relation extension)上的锁。该关系是机器人元数据的 GIN 索引。RDS 指标显示,事件发生前有短暂的 IO 突发,磁盘 IO 完全占满。
为什么这会消耗大量系统时间?所有 15000 个连接都在相关索引扩展上持有 WaitLatch(等待锁存器)。底层实现中,这最终调用 epoll_wait(),通常很轻量,除非被唤醒。正常情况下,只有锁持有者向下一个等待者发送 SIGURG 信号时才会唤醒。但真正的杀手是 CheckDeadlock(死锁检查)。
Postgres 需要检测和处理死锁,否则一个糟糕的查询会泄漏 PID。它通过设置超时(该数据库为 1 秒)来实现:超时后,它会获取表的 16 个分区锁,遍历锁图以检测死锁,必要时重新排列等待队列。代码摘录如下:
for (i = 0; i < NUM_LOCK_PARTITIONS; i++)
LWLockAcquire(LockHashPartitionLockByIndex(i), LW_EXCLUSIVE);
result = DeadLockCheck(MyProc);
for (i = NUM_LOCK_PARTITIONS; --i >= 0;)
LWLockRelease(LockHashPartitionLockByIndex(i));
问题在于所有 PID 在完全相同的时间开始,因此它们同时触发了死锁定时器。锁争用导致 LWLockAcquire 进入如下循环:
mustwait = LWLockAttemptLock(lock, mode);
...
LWLockQueueSelf(lock, mode);
...
for (;;)
{
PGSemaphoreLock(proc->sem);
if (!proc->lwWaiting)
break;
extraWaits++;
}
这就形成了第二个 convoy:第一个 convoy 在关系扩展锁上,第二个 convoy 在死锁检查所需的锁管理器分区锁上。所有 15000 个进程同时醒来,都试图运行 CheckDeadLock(),并争抢同一组微小的 LWLocks。这些锁通常持有时间极短,实现针对常见情况优化:尝试原子 compare-and-swap,失败则排队,在进程信号量上睡眠,被唤醒时重试。
然而,关系扩展锁实际上不可能参与死锁循环。Postgres 知道这一点:
if (LOCK_LOCKTAG(*lock) == LOCKTAG_RELATION_EXTEND ||
(LOCK_LOCKTAG(*lock) == LOCKTAG_PAGE))
return false;
因此,数据库反复唤醒数千个后端进程,获取全局锁管理器分区锁,然后证明没有死锁——这耗费了大量 CPU 时间。此外,由于启用了 log_lock_waits,每个存活足够久的后端还会遍历等待队列,格式化为一条包含所有持有者和等待者的巨大日志行(每条日志行超过 10KB),进一步加剧了系统开销。
本地复现
团队在本地复现了该故障模式,分为两部分:
- Postgres 复现:创建扩展锁 convoy 和 15000+ PID 等待队列。
- 精简的 latch/signal 风暴:直接复现了 CPU 缺失现象,观察到总 CPU 100%,系统时间占 86%。perf 显示热点在信号传递、epoll_wait、定时器中断、调度器工作和管道写入。
为什么发生在 GIN 索引上?
GIN 索引扩展不比普通 B-tree 索引扩展更昂贵。问题在于,并发写入时,GIN 索引扩展比 B-tree 频繁得多。B-tree 仅在页面分裂时在 FSM 中找不到可回收页面时才扩展,分裂频率约为 O(1/fanout) 相对于插入次数。而 GIN 索引在并发写入时更频繁地扩展,导致锁争用更易发生。
关键要点
- Postgres 锁机制在极端并发下可能失效:15000 个并发进程同时等待同一个索引扩展锁,导致锁 convoy 和死锁检查风暴,CPU 系统时间飙升到 100%。
- 死锁检查是性能杀手:即使关系扩展锁不可能参与死锁,Postgres 仍会每 1 秒触发一次死锁检查,所有等待进程同时醒来并争抢锁管理器分区锁,形成二次 convoy。
- GIN 索引比 B-tree 更易引发此类问题:因为并发写入下 GIN 索引扩展频率更高,增加了锁争用的机会。
- RDS 托管服务限制故障排查能力:无法获得 shell 访问,无法运行 vmstat 等底层工具,只能依赖 AWS 提供的指标。
- 重启数据库并非万能:在极端负载下,RDS 重启可能需要 60 分钟,原因不明,可能因为等待所有进程退出或处理 I/O 队列。
- 安全组策略可临时缓解:通过修改安全组拒绝新连接,让现有连接逐渐耗尽,是有效的应急手段。
- 连接数限制可作为恢复阀门:逐步增加最大连接数,避免负载突然恢复导致再次崩溃。
- 日志记录加剧问题:启用 log_lock_waits 后,每个等待后端生成超长日志行(10KB+),进一步消耗 CPU 和 IO。
- 本地复现验证了根本原因:团队成功复现了扩展锁 convoy 和信号风暴现象,确认了 CPU 系统时间被占满的机制。
意义与影响
- 对 Postgres 用户的警示:高并发写入场景下,尤其是使用 GIN 索引时,需要警惕锁 convoy 和死锁检查带来的性能风险
