PostgreSQL学徒

恼人的自旋锁

1前言

今天早上一位同事火急火燎地找过来,说一个生产库的 CPU 又被打爆了!值班一直在不断 kill 会话,但是于事无补,依旧处于不断涌入又不断打满的恶性循环中。

经过分析,才发现了 PostgreSQL 里面隐藏的又一个超级大坑 ...

2现象

在接近 9 点的时候,CPU 很快就被打满,主机组同事紧急扩容一半资源之后(凸起部分),不一会儿 CPU 立马又被打满 👇🏻

Image

经过了解,该业务库于昨晚进行了升级,升到了 13 的内核。那会不会是统计信息的问题?于是检索 pg_stat_all_tables 的 last_autoanalyze / last_analyze 字段,确实有些表的统计信息收集时间还是 17 号的,仿佛找到了救命稻草?于是赶忙手动收集业务相关的表,但是依旧没有起色,CPU 还是被打爆。

通过分析慢日志以及 pg_top CLI,业务跑了一些 distinct / with recursive 等多表 join 的查询,难道是执行计划突变导致的?但是数据库日志里显示的 SQL 太多了,几乎每条 SQL 都超过了慢日志的阈值,并且又大又长,这给分析带来了很大的干扰,这些查询你无法知晓是因还是果,究竟是因为先来的慢查询导致了资源瓶颈进而使后到的查询没有资源可用从而变慢,还是说这些查就询实打实要消耗这么多资源。

我查看了一下数据库的等待事件与活跃连接数,在被打爆期间,等待事件全部集中在了 CPU 等待上,活跃的 active 连接达到了 500+,每个活跃会话连接都是在跑比较复杂的distinct / with recursive 等多表 join 的查询。👇🏻

Image

之前我曾写过两篇分析 CPU 的文章

其中提到了 perf,perf 是用来分析 CPU 使用情况的利器,分析热点函数,于是我使用 perf 抓了一下现场

Image

可以看到在 CPU 周期里居然 61% 的时间花在了 s_lock 上面‼️。作为PostgreSQL的最底层的锁,SpinLock 比较简单,它的特点是封锁时间很短,没有等待队列和死锁检测机制,在事务结束时不能自动释放。因此,SpinLock 一般不单独使用,而是作为其他锁(比如 LWLock)的底层实现。

作为最底层锁,它的实现是和操作系统和硬件环境相关的。为此,PostgreSQL 实现了两个 SpinLock:

  • 与机器相关的实现,利用 TAS 指令集实现(定义在s_lock.h 和 s_lock.c 中)
  • 与机器无关,利用 PostgreSQL 定义的信号量 PGSemaphore 实现(定义在spin.c中)

依赖机器实现的 SpinLock  比不依赖机器实现的 SpinLock 要快。那么为何会花这么长的时间在自旋上面?看下进程的堆栈

Image

大致调用链如下 :heap_page_prune_opt(页剪枝)→ TransactionIdLimitedForOldSnapshots → s_lock → perform_spin_delay

这个 heap_page_prune_opt 想必读者已经很熟悉了,之前我也写过一篇关于页剪枝 BUG 的文章,简而言之就是页剪枝通过快速的页内"清理",用于移除对事务不可见的死元组

有两种情况会进行剪枝,以删除在任何快照中不再可见的元组

  1. 之前的 UPDATE 操作没有找到足够的空间将新元组放入同一页面。
  2. 堆页中包含的数据多于 fillfactor 存储参数所允许的数据,比如 fillfactor 是 80,那么预留 20% 的空间用于更新操作,假如剩余的空间不足 20%,就会执行页剪枝,另外可以看到最低不能低于单个数据块大小的 10%

Image

而这个 TransactionIdLimitedForOldSnapshots 在 13 版本的代码里可以很清晰的看到其中一个判断条件,old_snapshot_threshold > 0,见鬼!难道又是这个参数搞的鬼?看下这段注释

Apply old snapshot limit, if any.  This is intended to be called for page pruning and table vacuuming, to allow old_snapshot_threshold to override the normal global xmin value.  Actual testing for snapshot too old will be based on whether a snapshot timestamp is prior to the threshold timestamp set in this function.

应用旧快照限制(如果有)。这旨在为页面剪枝和表清理调用,以允许 old_snapshot_threshold 覆盖正常的全局 xmin 值。snapshot too old 的实际测试将基于快照时间戳是否早于此函数中设置的阈值时间戳。

很清晰,这个参数会加入到全局 xmin 的判断中,那么页剪枝同样需要去判断这个附加的条件!于是我去检查了一下这个参数,果然,在升级后的库打开了这个参数(3h),而升级前的老库没有开!至此,所有矛头全部对准了这个参数。

让我们再看下代码流程

/*
 * TransactionIdLimitedForOldSnapshots
 *
 * Apply old snapshot limit.  This is intended to be called for page pruning
 * and table vacuuming, to allow old_snapshot_threshold to override the normal
 * global xmin value.  Actual testing for snapshot too old will be based on
 * whether a snapshot timestamp is prior to the threshold timestamp set in
 * this function.
 *
 * If the limited horizon allows a cleanup action that otherwise would not be
 * possible, SetOldSnapshotThresholdTimestamp(*limit_ts, *limit_xid) needs to
 * be called before that cleanup action.
 */

bool
TransactionIdLimitedForOldSnapshots(TransactionId recentXmin,
         Relation relation,
         TransactionId *limit_xid,
         TimestampTz *limit_ts)
{
 TimestampTz ts;
 TransactionId xlimit = recentXmin;
 TransactionId latest_xmin;
 TimestampTz next_map_update_ts;
 TransactionId threshold_timestamp;
 TransactionId threshold_xid;

 Assert(TransactionIdIsNormal(recentXmin));
 Assert(OldSnapshotThresholdActive());
 Assert(limit_ts != NULL && limit_xid != NULL);

 /*
  * TestForOldSnapshot() assumes early pruning advances the page LSN, so we
  * can't prune early when skipping WAL.
  */

 if (!RelationAllowsEarlyPruning(relation) || !RelationNeedsWAL(relation))
  return false;

 ts = GetSnapshotCurrentTimestamp();   ---👈🏻

 SpinLockAcquire(&oldSnapshotControl->mutex_latest_xmin);   ---👈🏻申请SpinLock
 latest_xmin = oldSnapshotControl->latest_xmin;
 next_map_update_ts = oldSnapshotControl->next_map_update;
 SpinLockRelease(&oldSnapshotControl->mutex_latest_xmin);   ---👈🏻释放SpinLock

除了上面两步,在获取快照的时间戳函数 GetSnapshotCurrentTimestamp 里也会进一步获取 SpinLock,这个函数同样也出现在了 perf 抓到的热点函数中

/*
 * Get current timestamp for snapshots
 *
 * This is basically GetCurrentTimestamp(), but with a guarantee that
 * the result never moves backward.
 */

TimestampTz
GetSnapshotCurrentTimestamp(void)
{
 TimestampTz now = GetCurrentTimestamp();

 /*
  * Don't let time move backward; if it hasn't advanced, use the old value.
  */

 SpinLockAcquire(&oldSnapshotControl->mutex_current);
 if (now <= oldSnapshotControl->current_timestamp)
  now = oldSnapshotControl->current_timestamp;
 else
  oldSnapshotControl->current_timestamp = now;
 SpinLockRelease(&oldSnapshotControl->mutex_current);

 return now;
}

并且在后续的 TransactionIdLimitedForOldSnapshots → GetOldSnapshotFromTimeMapping 中也需要获取自旋锁!这么一个函数里面竟然涉及到这么多自旋锁的释放与申请。

3复现

现在找到了矛头,剩下的就是验证了。于是我找了台测试环境,为了确保测试边界,内存、CPU和配置参数等保持一致,然后分别测试不同并发条件下,打开与不打开 old_snapshot_threshold 的情形,使用 pgbench -T 3000 -c 并发进行压测,结果集 130 GB,大致测试结果如下

10 个并发

10 个并发的情况下,同样的压测语句,CPU 的使用率接近 double

Image

30 个并发

30 个并发的情况就复现出了今天早晨的情形,而未开启 old_snapshot_threshold 的情况下,CPU 只有 33% 左右,同样的结论,开启了这个参数,CPU double了。

Image

50 个并发

50 个并发的情况下,开启 old_snapshot_threshold,CPU 基本饱和,而未开启 CPU 只有 50%。

Image

后续 80 / 100 / 150 并发的情形类似,当我开了 400 个并发,未开启 old_snapshot_threshold,CPU 才达到了 90%,快要饱和。

可以看到,结果是十分惊人的,在 13 的版本里面,开启了 old_snapshot_threshold ,不到 50 个并发就将 CPU 打满,不开启 400 个才达到瓶颈,足足相差了 8 倍!

4小结

old_snapshot_threshold 这个参数的危害再次加 1,相较于之前无伤大雅的问题,这个问题就要严重太多了,会导致 CPU 的资源被浪费接近一半左右,每一个后端进程在做页面剪枝的时候都会去做判断,然后每个判断里面都会涉及到大量的自旋锁

 /*
  * We prune when a previous UPDATE failed to find enough space on the page
  * for a new tuple version, or when free space falls below the relation's
  * fill-factor target (but not less than 10%).
  *
  * Checking free space here is questionable since we aren't holding any
  * lock on the buffer; in the worst case we could get a bogus answer. It's
  * unlikely to be *seriously* wrong, though, since reading either pd_lower
  * or pd_upper is probably atomic.  Avoiding taking a lock seems more
  * important than sometimes getting a wrong answer in what is after all
  * just a heuristic estimate.
  */

 minfree = RelationGetTargetPageFreeSpace(relation,
            HEAP_DEFAULT_FILLFACTOR);
 minfree = Max(minfree, BLCKSZ / 10);

 if (PageIsFull(page) || PageGetHeapFreeSpace(page) < minfree)
 {
  /* OK, try to get exclusive buffer lock */
  if (!ConditionalLockBufferForCleanup(buffer))
   return;
 ...

因此一旦 CPU 达到饱和了,后续进来的查询因为 CPU 时间片等待,进一步争抢资源,然后由于查询开始拥堵,每个查询自身又要去争抢自旋锁,如此恶性循环,不断往复,导致系统接近瘫痪!

所以,这个参数建议还是关闭吧,至少表膨胀还能处理,巡检出来,而这种自旋锁你碰到了也只能干瞪眼。

另外如前面所说,自旋锁一般用于较上层锁的实现,比如 LWLock,类似于 Oracle 闩锁

Oracle在9i的时候把共享内存做了分片,以前是一个共享内存段,存在高竞争的问题,闩锁竞争会很激烈,9i 进行分区细化,缺省可以有7个子池,并发性就更好了。

与之类似的便是 PostgreSQL 中 shared buffers 划分为 128 个 buffer_mapping 锁。而 LWLock 主要要于封锁共享内存中的数据结构以达到互斥访问的目的(避免并发造成的同一份数据被不同进程改写带来的不一致,即一段关键代码同时只允许一个进程进行改写),比如 WALInsertLock / WALWriteLock / ProcArrayLock / BufFreelistLock 保护buffer pool / ControlFileLock 保护控制文件的读写等,对于页面的保护,因此假如访问的表很分散,没有热点表,访问的页面分散,那么理论上这种问题是可以缓解的,,这一点我目前还在造数据测试中,敬请期待后续的文章。

5花絮

关于 PostgreSQL 的自旋锁,其实很多人都在喷,今天这个案例也再次证实了这一点,不过在 14 的版本里做过一个优化,不过是关于 GetSnapshotData 的,原先是随连接数增加,这个函数的性能是线性下降,这个也有待测试。

关于自旋锁,我今天请教了一位内核老兵(名字就不透露啦,懂的都懂),Oracle 作为 factual standard 的确是有两把刷子的。

Image