PostgreSQL学徒

生产案例 | 恼人的原生分区

1前言

在上周五,某个库又因为发版差点导致了 P0 级的生产故障,不过发版的内容又是十分普通的添加子表,那么为何这个看似不起眼的操作会引起如此大的幺蛾子呢?分析一下。

2现象

这个库由于历史原因,承载了十分重要的业务,所以业务 SQL 也是大而长的复杂 SQL,线上版本是 11.5。这是背景,在周四夜里,开发进行了发版,创建了 100 个子分区(两个表,每个表各添加 50 个分区),不过后来了解到,创建子分区的过程也不顺利,因为使用的是 create table .. partition of 的方式,AccessExclusiveLock,因此父表只要有查询,就会等待。但是又由于版本太低了,要降低锁的粒度,只能在 12 版本之后,在被 attach 的分区和默认分区(如果存在)上加AccessExclusiveLock,但是在父表上只需加 ShareUpdateExclusiveLock 即可,因此不会阻塞DML,所以在 12 以后正确的姿势应该是 alter table .. attach partition 的方式。

在添加完子表之后的第二天,也就是周五,在上午 10.15 左右同事就发现这个库开始不对劲了,explain 生成执行计划巨慢无比,动辄十几秒,按照以往经验,可能是分区表数量太多,纯粹planning time 太久,但是耗时十几秒也极不正常,不过 100 个分区而已。

在 10.20 左右,业务反馈数据库异常,但是 CPU 还未饱和👇🏻 因此不是 CPU 饱和导致的恶性循环,统计信息检查也是正常的。

Image

到了 10.22 ,数据库接近瘫痪,所有的业务 SQL 都"卡住",故障一直持续到 10.45 左右版本回退,这个期间一直不断杀会话也于事无补。当我们分析故障的时候,第一现场尤为重要,因此现场的采集重中之重,因为 PostgreSQL 社区版没有成熟的 AWR,因此我自己还写了一个额外的工具,一键采集故障现场。

根据当时的进程状态( pg_top )显式,几乎所有的 SQL 都在 BIND 的阶段,其实就是在执行 exec_bind_message:Process a "Bind" message to create a portal from a prepared statement,为之前保存的 Prepared Statement 创建执行计划并将其保存在 Plan Cache 中,创建一个 Portal 用于后续执行(后文的堆栈可以看到)。同时可以看到负载不高,但是进程很多,1937 个进程。

至此现象已然明了,SQL 都卡住了,业务瘫痪,都卡在了绑定变量生成执行计划的过程中。

Image

查看 OSW,发现所有的进程状态都处于futex_wait_queue_me,同时状态显示"S",根据 man ps,S 指的是 interruptible sleep ,即可中断的睡眠状态,在等待某个事件的完成而睡眠,处在这种睡眠状态的进程是可以通过给它发信号来唤醒的。至于另外一种睡眠状态是 uninterruptible sleep,也就是我们熟知的D进程,处在这种状态的进程不接受外来的任何信号,常见于等于 IO 中。

  1. D    uninterruptible sleep (usually IO)
  2. R    running or runnable (on run queue)
  3. S    interruptible sleep (waiting for an event to complete)
  4. T    stopped by job control signal
  5. t    stopped by debugger during the tracing
  6. W    paging (not valid since the 2.6.xx kernel)
  7. X    dead (should never be seen)
  8. Z    defunct ("zombie") process, terminated but not reaped by its parent

Image

这里我们得到了更多的信息:数据库接近瘫痪时,所有的进程都在等待 futex,根据堆栈流程:LWLockAcquire → PGSemaphoreLock → sem_wait → do_futex_wait,又双叒叕涉及到了 LWLock 轻量锁,最近貌似被诅咒了一般,接二连三的生产问题都与轻量锁有关。

Image

在之前恼人的自旋锁文章中,我也提到过自旋锁。SpinLock 作为 PostgreSQL 的最底层的锁,它的特点是封锁时间很短,没有等待队列和死锁检测机制,在事务结束时不能自动释放。因此,SpinLock 一般不单独使用,而是作为其他锁(比如 LWLock)的底层实现。

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

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

依赖机器实现的 SpinLock  比不依赖机器实现的 SpinLock 要快。因此在不支持 TAS 的情况下,PostgreSQL 会使用 PGSemaphores 来实现 SpinLock。

/*-------------------------------------------------------------------------
 *
 * spin.c
 *    Hardware-independent implementation of spinlocks.
 *
 *
 * For machines that have test-and-set (TAS) instructions, s_lock.h/.c
 * define the spinlock implementation.  This file contains only a stub
 * implementation for spinlocks using PGSemaphores.  Unless semaphores
 * are implemented in a way that doesn't involve a kernel call, this
 * is too slow to be very useful :-(
 *
 *
 * Portions Copyright (c) 1996-2022, PostgreSQL Global Development Group
 * Portions Copyright (c) 1994, Regents of the University of California
 *
 *
 * IDENTIFICATION
 *   src/backend/storage/lmgr/spin.c
 *
 *-------------------------------------------------------------------------
 */

PGSemaphore 使用 OS 底层的 semaphore 来实现(位于/usr/sys/include/sem.h),对其做了简单封装,提供了系统内部统一的 semaphore 操作接口。当请求一个使用信号量来表示的资源时,进程需要先读取信号量的值来判断资源是否可用。大于 0,资源可以请求,等于 0,无资源可用,进程会进入睡眠状态(进程挂起等待)直至资源可用。当进程不再使用一个信号量控制的共享资源时,信号量的值 +1(信号量的值大于 0),对信号量的值进行的增减操作均为原子操作,这是由于信号量主要的作用是维护资源的互斥或多进程的同步访问。为了防止多个 PostgreSQL 进程同时访问一个共享资源而引发的一系列问题,在任一时刻只能有一个进程访问临界区,即执行相关操作的进程需要独占式地执行,而信号量就可以提供这样的一种访问机制,用来调协进程对共享资源的访问的。

因此通过简单分析,知晓了这个时候又存在了大量的进程间竞争操作,最终进入 futex 接口进行睡眠,等待临界资源可操作。关于 futex 的更多具体细节可以参照 Futex系统调用,Futex机制,及具体案例分析[通俗易懂],简而言之,使用 futex 可以减少许多不必要的内核态陷入与资源消耗。

Futex按英文翻译过来就是快速用户空间互斥体。其设计思想其实 不难理解,在传统的Unix系统中,System V IPC(inter process communication),如 semaphores, msgqueues, sockets还有文件锁机制(flock())等进程间同步机制都是对一个内核对象操作来完成的,这个内核对象对要同步的进程都是可见的,其提供了共享 的状态信息和原子操作。当进程间要同步的时候必须要通过系统调用(如semop())在内核中完成。可是经研究发现,很多同步是无竞争的,即某个进程进入 互斥区,到再从某个互斥区出来这段时间,常常是没有进程也要进这个互斥区或者请求同一同步变量的。但是在这种情况下,这个进程也要陷入内核去看看有没有人 和它竞争,退出的时侯还要陷入内核去看看有没有进程等待在同一同步变量上。这些不必要的系统调用(或者说内核陷入)造成了大量的性能开销。为了解决这个问 题,Futex就应运而生,Futex是一种用户态和内核态混合的同步机制。

...

在futex诞生之前,linux下的同步机制可以归为两类:用户态的同步机制 和内核同步机制。用户态的同步机制基本上就是利用原子指令实现的自旋锁。关于自旋锁其缺点也说过了,不适用于大的临界区(即锁占用时间比较长的情况)。内核提供的同步机制,如semaphore等,使用的是上文说的自旋+等待的形式。它对于大小临界区和都适用。但是因为它是内核层的(释放cpu资源是内核级调用),所以每次lock与unlock都是一次系统调用,即使没有锁冲突,也必须要通过系统调用进入内核之后才能识别。

当时现场抓取的 perf 也显示在 futex_wait / futex_wake 中。

Image

那在竞争个什么鬼呢?看下抓到的等待事件👇🏻 我去惊呆了... 这么多个 lock_manager

Image

同时查看 pg_locks,里面居然有 211 万个条目!!LWLock.lock_manager 也达到了 2468 个!又是 LWLock,也难怪会出现大量的 futex 。至于活跃连接数量达到了 2467 个,这个应该是阻塞的结果,不断堆积形成 ...(后面开发也反馈,业务量没有太大波动)

那么这个 lock_manager 是什么?顾名思义,锁管理,同时还是轻量锁,根据现象是用来协同的。在 AmazonRDS 网站上,我找到了这么一段说明

This event occurs when the Aurora PostgreSQL engine maintains the shared lock's memory area to allocate, check, and deallocate a lock when a fast path lock isn't possible.

当 Aurora PostgreSQL 引擎维护共享锁的内存区域以在无法使用fast path锁时分配、检查和解锁时,会发生此事件。

fastpath 我在 DBA Daily 上已经介绍过,对应于 pg_locks 里面的 fastpath 字段,简而言之就是为了提升性能的。另外我想起了之前整理过的一篇详细的分区表文章,我去翻了一下,果然有所收获!注意下面这段话:

当前,在UPDATE或者INSERT执行期间对于分区的裁剪是通过约束排除实现的 (然而,它是由enable_partition_pruning参数控制的,而不是constraint_exclusion)

Image

constraint_exclusion 我们很熟悉了,就是根据语义进行约束排除,我在优化器解析里面也提到过。比如某个表上存在 check id > 100 的约束,那么查询 select id from test < 90 时,优化器对比约束条件,知道压根没有小于 90 的记录,直接跳过对于该表的扫描,返回 0 行记录(执行计划会显示 False )。当优化器使用约束排除时,需要花费更多的时间去对比约束条件和 where 中的过滤条件,默认是 partition,对一张表做查询时,如果有继承表,优化器就会对这些子表进行约束排除分析。线上版本是11.5,隐隐感觉好像是这个问题。

为了快速恢复,以往最好时的重启大法也不管用了,数据库重启还是应用重启都不管用。于是开发决定紧急回退版本,将新创建的子分区给删除,删除过后,业务终于恢复正常。那么看样子就是这个添加的分区表导致的问题,按照结果倒推就好使了,幸好我在工具里面还抓取了 strace,根据 strace 显示打开了某个表

Image

于是我根据 oid 去找,使用 oid2name,但是却没找到,没错一个都没找到,这说明这些对象正是被已删除版本回退的子表,到这里就能说明问题了——在生成执行计划的时候,数据库扫描了所有的子表!但是经过仔细验证业务 SQL,发现业务 SQL 使用到了分区裁剪,这里就形成了一个悖论:使用了分区裁剪,执行计划显示没有扫描其他子表,但是实际情况却打开了所有子表。因此不难想象,假如存在大量子表,再加之业务 SQL 本身复杂的时候,生成执行计划的时间就会数量级上升,"卡"在 BIND 的阶段。

那么剩下就是验证想法的时候了,这里还是要夸下 GPT,着实好用

Image

根据 id 进行范围分区,然后随机生成 id 进程压测,因此可以走到分区裁剪。不费任何利器,轻松就模拟出来了,大量的 lock_manager

Image

自己随便开个进程查询,用 strace 追踪也可以看到打开了所有子表,不过各位在模拟的时候要注意观察第一次时的效果,因为后续多次执行就会有 relcache / syscache 了。

Image

而同样的行为,我去 13 版本里面测,就不会有这个问题,不会打印满满一屏幕的日志,并且精准定位某个具体的子表。

Image

根据 https://stackoverflow.com/questions/64433676/high-lock-manager-wait-event 这个里面的说明,从 12 开始就解决了这个问题,现象和此故障也是类似,存在大量子分区,同时 hash_search_with_hash_value 占比很高

Partitioning got much more efficient, in turns of locking for single-partition selects, in that version.

至此现象已经分析清楚了,因此再次重申关于分区表的注意事项,可以参照之前的 PostgreSQL表分区演进:

  1. PostgreSQL 10 引入了声明式分区,但只是个 Demo
  2. PostgreSQL 11 主要对分区表进行功能加强,比如默认分区/支持跨分区更新/哈希分区等
  3. PostgreSQL 12 主要对性能进行加强,大幅提升

因此在 12 以前,原生分区的性能堪忧,经过这个案例,想必各位更加确信了 12 之前的分区表就尽量不要用了,可以使用 pg_pathman,但是这又引入了一个额外组件,额外失效点,我记录在案处理过的 BUG 就有 9 个了,难上加难啊。

Image

Image

3小结

小结一下,在 11 版本里面,分区裁剪的行为实际上第一次生成执行计划的时候会扫描所有子分区,后续会缓存下来(syscache/relcache),既然是扫描,那么在数据库里就要获取 AccessShareLock(这一点和 Oracle 不一样),因此子表数量越多,每一个进程都要访问所有的子表,假如 100 个子表,就对应着 100 个 AccessShareLock,对应 pg_locks 里面的条目也就越多,访问共享内存是需要 LWLock 的,因此要维护的代价也就线性提升。这个是多个进程间共享的,从所有后端进程扫描收集而来,所以引入了 fastpath 机制,为了避免所有的事务都去频繁访问 pg_locks 带来的性能影响。

不过既然是版本的通用问题,所以应该之前就有了,于是我去查看了更久之前的日志,果然,很早之前就对应着大量的 BIND!只是周四晚上的发版,让这一情况更加恶劣,达到了那个瘫痪"阈值"。后续会对这个严重八字不合的库进行升级,不然也没有太好的处理方式。

另外,pgcheck 工具我优化升级了一版,添加了 vacuum_need 指标,输出哪些表需要 vacuum,加强了 relation_bloat 以及以及部分实例级的指标,比如 checkpoint/wal 等,无需输入数据库名。后续我会添加对 arm 的支持,同时可能也会对 KingbaseES 进行兼容,系统表改成了 sys 开头,因此理论上用 sys 替换 pg 即可。拭目以待

Image

4参考

https://dba.stackexchange.com/questions/276297/postgresql-lwlock-lock-manager-issue

https://stackoverflow.com/questions/64433676/high-lock-manager-wait-event

https://cloud.tencent.com/developer/article/2154718

https://docs.aws.amazon.com/AmazonRDS/latest/AuroraUserGuide/apg-waits.lw-lock-manager.html