PostgreSQL学徒

PG 18 让 NUMA 可观测、可感知

前言

在 18 的新版本中,包含了诸多惹眼特性,比如异步 I/O、BTREE Skip Scan、UUIDv7 等等,其实在 18 的新版中,还引入了一项很重要的特性 — NUMA 感知,一直以来,PG 就缺乏 NUMA 的可观测性,希望能看到 buffer 属于哪个 node、shared memory 分布、memory context 的 NUMA 信息等,PG 18 终于补足了这块拼图,让 NUMA 从玄学、从黑洞变为"可观测的工程问题"。

NUMA

Andres Freund 在 2024 年的 pgconf 上有一篇名为 NUMA vs PostgreSQL 的主题,介绍了 NUMA 的重要性以及未来的优化方向等。

作者开门见山就提到 Moore’s law is dead:CPU 不再靠单核变快,而是靠更多 core、更多 chiplet/tile,虽然吞吐提升了,但是延迟问题更糟了,尤其是跨 chiplet、跨 socket 的访问延迟变大:

•一个 socket 里可能有多个 chiplet / tile•每个 tile 有自己的 core、L3、内存控制器•不同 tile / socket 之间访问成本不同

Image

作者也量化了大致的性能影响

•本地内存基础延迟:约 80–140ns•跨 socket 再增加:约 80–100ns•跨 tile 再增加:约 30ns•最糟糕的是:争用锁•对哈希表等延迟敏感的结构来说,也比较糟糕•吞吐也会下降•如果数据已经在 L1/L2/L3 cache,差异则不太明显。

简而言之 —— NUMA 最怕的不是"偶尔访问远端内存",而是高频共享热点、锁竞争、反复跨节点访问同一批共享元数据,而这些又是 PG 很容易出现的。

在 Linux 上,默认的访问策略是 Local node,真正分配发生在首次访问 (first touch),而不是 mmap() / malloc() 时,所以像 pg_prewarm() 这类操作会造成不平衡,也就是说,谁先 touch,页面就更可能落在谁所在的 NUMA 节点。

前面也提到,在 18 版本以前,PG 最大的问题之一是数据库层面原生看不见 NUMA 的分布,既然看不见,那么就无法定位 NUMA 所带来的问题。不过我们还是可以从操作系统层面进行观察,通过 /proc/$pid/numa_maps 看进程地址空间的 NUMA 分布,所以在 18 以前的版本,如果你怀疑某个 postgres 进程、shared memory、backend 有 NUMA 倾斜,可以先从这个文件入手。举个栗子

-bash-4.2$ psqlpsql (18.3)Type "help" for help.
postgres=# select count(*) from pgbench_accounts ;  count  --------- 1000000(1 row)
postgres=# select pg_backend_pid(); pg_backend_pid ----------------          52155(1 row)
[root@sdw19 ~]# cat /proc/52155/numa_maps ...01631000 default heap anon=190 dirty=190 N1=190 kernelpagesize_kB=4...7f1f19ba5000 default file=/dev/zero\040(deleted) dirty=2888 mapmax=10 active=369 N0=415 N1=2473 kernelpagesize_kB=4...7f1f19961000 default file=/dev/shm/PostgreSQL.1556637348 dirty=7 active=2 N1=7 kernelpagesize_kB=4

1. 第一行表示这一段堆内存的 190 页 全部落在 node1

2. 第二行代表共享内存也主要在 N1 (N0=415 N1=2473);同时 default 表示这表示该进程使用的是 Linux 默认 NUMA 策略

3. 第三行说明该后端进程映射到的某个 PG 共享内存段,在它实际触碰到的页里,也全在 N1

因此,以上种种说明:

•进程调度偏向某个 node,52155 进程在执行查询时主要跑在 node1 的 CPU 上,那它分配/触碰到的匿名页就会偏向 N1。•共享内存初始化/预热阶段的 first-touch 偏向某个 node:尤其是 shared_buffers 这类大块区域,启动/预热时在哪个 node 的 CPU 上触碰多,就偏到哪里。

[root@sdw19 ~]# taskset -pc 52155pid 52155's current affinity list: 0-63[root@sdw19 ~]# numactl -Havailable: 2 nodes (0-1)node 0 cpus: 0 2 4 6 8 10 12 14 16 18 20 22 24 26 28 30 32 34 36 38 40 42 44 46 48 50 52 54 56 58 60 62node 0 size: 128383 MBnode 0 free: 47660 MBnode 1 cpus: 1 3 5 7 9 11 13 15 17 19 21 23 25 27 29 31 33 35 37 39 41 43 45 47 49 51 53 55 57 59 61 63node 1 size: 128986 MBnode 1 free: 54482 MBnode distances:node   0   1   0:  10  21   1:  21  10 [root@sdw19 ~]# ps -o pid,psr,comm -p 52155   PID PSR COMMAND 52155  35 postgres

通过 ps 我们可以看到,CPU 35 是奇数,根据 numactl -H 映射,其属于 NUMA node 1,所以我们可以看到大段 N1 明显更多(甚至 N1=190 一边倒的情况)。其次,Andres Freund 还提到,因为 first touch 机制,pg_prewarm()、CREATE INDEX、COPY 这类操作会导致大量内存落在一个节点。

到此,我们也能看到 NUMA 的大致危害了 —— 不仅仅是远端内存访问更慢,对于 PG 这种大量共享内存与共享元数据结构的数据库中,会放大为:共享内存分配倾斜、访问局部性变差、TLB/缓存效率降低,以及热点 buffer lock 的扩展性崩溃。而 Linux 的 first-touch 分配机制让 shared_buffers 等共享区域很容易在启动或预热阶段偏向某个 NUMA node,从而带来跨节点访问与延迟抖动。

好在 PG 18 终于可以观测到 NUMA 了

-bash-4.2$ psqlpsql (18.3)Type "help" for help.
postgres=# select count(*) from pgbench_accounts ;  count  --------- 1000000(1 row)
postgres=# select pg_backend_pid(); pg_backend_pid ----------------          52155(1 row)
postgres=# select name, numa_node, sizefrom pg_shmem_allocations_numawhere name in ('Buffer Blocks','Buffer Descriptors','Shared Buffer Lookup Table',               'Buffer IO Condition Variables','Buffer Strategy Status')order by name, numa_node;             name              | numa_node |   size    -------------------------------+-----------+----------- Buffer Blocks                 |         0 |   7553024 Buffer Blocks                 |         1 | 126672896 Buffer Blocks                 |           |         0 Buffer Descriptors            |         0 |   1052672 Buffer Descriptors            |         1 |         0 Buffer Descriptors            |           |         0 Buffer IO Condition Variables |         0 |    266240 Buffer IO Condition Variables |         1 |         0 Buffer IO Condition Variables |           |         0 Buffer Strategy Status        |         0 |      4096 Buffer Strategy Status        |         1 |         0 Buffer Strategy Status        |           |         0 Shared Buffer Lookup Table    |         0 |      8192 Shared Buffer Lookup Table    |         1 |         0 Shared Buffer Lookup Table    |           |         0(15 rows)
postgres=# with x as (  select numa_node, size::numeric as sz  from pg_shmem_allocations_numa  where name='Buffer Blocks'),s as (  select sum(sz) as total from x)select x.numa_node,       x.sz,       round(100 * x.sz / s.total, 2) as pctfrom x cross join sorder by x.numa_node; numa_node |    sz     |  pct  -----------+-----------+-------         0 |   7553024 |  5.63         1 | 126672896 | 94.37           |         0 |  0.00(3 rows)

通过 pg_shmem_allocations_numa 视图,我们可以很清楚看到各个节点上 Block 所占用的内存大小,大部分都集中在了 Node1 上面。

那如何解决呢?简单来说,就是让操作系统和数据库尽量不要按 NUMA 拓扑去分散调度/分配,而是把机器当成一个更平坦的内存系统来用,包括:

1.BIOS/固件层面的关闭 NUMA,指在 BIOS 里关闭某些 NUMA 暴露方式,让系统看到的内存拓扑更统一2.OS/数据库层面的关闭 NUMA,比如不让进程在多个 NUMA 节点间乱跑,用 numactl 把进程绑在固定节;或者用内存交错分配 (--interleave=all);以及关闭 Linux 的自动 NUMA 平衡 (numa_balancing)

如果我们不能从 BIOS 和 Linux 启动层彻底弱化 NUMA,那么至少可以通过关闭自动 NUMA 平衡、关闭 zone reclaim、并用 numactl --interleave=all 启动 PostgreSQL,来获得类似关闭 NUMA的效果:

•vm.zone_reclaim_mode = 0,表示当 NUMA 系统某个 NODE 内存不足的时候,会从相邻的 NODE 分配内存,避免内核为了优先使用本地 NODE 内存,做过度回收,从而导致数据库性能抖动;并且 PG 的机制,是比较依赖于缓存的。•kernel.numa_balancing = 0,表示关闭 Linux 自动 NUMA 平衡,避免内核在后台扫描页、迁移页,减少额外开销和不确定性。•numactl interleave=all,顾名思义,用 numactl 启动 PG,让内存页在所有 NUMA 节点上交错分配。目的是避免 shared memory 被 first-touch 集中打到单个 NUMA 节点•当然也可以使用 cpuset 的方式进行绑核,这是 Linux 提供的一种把进程/线程固定到指定 CPU 核心集合 (以及可选的内存 NUMA 节点) 里运行的隔离机制,通过把 PG 进程限定在指定 CPU 核心和 NUMA 内存节点内运行,减少跨 NUMA 远端访存与锁争用

让我们用 numactl interleave=all 再次启动一下数据库,同时观察一下

-bash-4.2$ numactl --interleave=all pg_ctl startwaiting for server to start....2026-03-05 18:25:07.037 CST [42338] LOG:  redirecting log output to logging collector process2026-03-05 18:25:07.037 CST [42338] HINT:  Future log output will appear in directory "log". doneserver started
postgres=# select count(*) from pgbench_accounts ;  count  --------- 1000000(1 row)
postgres=# select pg_backend_pid(); pg_backend_pid ----------------          43601(1 row)
postgres=# select name, numa_node, sizefrom pg_shmem_allocations_numawhere name in ('Buffer Blocks','Buffer Descriptors','Shared Buffer Lookup Table',               'Buffer IO Condition Variables','Buffer Strategy Status')order by name, numa_node;             name              | numa_node |   size   -------------------------------+-----------+---------- Buffer Blocks                 |         0 | 67112960 Buffer Blocks                 |         1 | 67112960 Buffer Blocks                 |           |        0 Buffer Descriptors            |         0 |   524288 Buffer Descriptors            |         1 |   528384 Buffer Descriptors            |           |        0 Buffer IO Condition Variables |         0 |   135168 Buffer IO Condition Variables |         1 |   131072 Buffer IO Condition Variables |           |        0 Buffer Strategy Status        |         0 |        0 Buffer Strategy Status        |         1 |     4096 Buffer Strategy Status        |           |        0 Shared Buffer Lookup Table    |         0 |     4096 Shared Buffer Lookup Table    |         1 |     4096 Shared Buffer Lookup Table    |           |        0(15 rows)
postgres=# with x as (  select numa_node, size::numeric as sz  from pg_shmem_allocations_numa  where name='Buffer Blocks'),                       s as (  select sum(sz) as total from x)select x.numa_node,       x.sz,       round(100 * x.sz / s.total, 2) as pctfrom x cross join sorder by x.numa_node; numa_node |    sz    |  pct  -----------+----------+-------         0 | 67112960 | 50.00         1 | 67112960 | 50.00           |        0 |  0.00(3 rows)

这一次各位便可以观察到 —— 同样的操作,内存均匀多了!根据 Andres Freund 实验结果:

Image

•默认情况下平均延迟 382.700 ms,标准差 68.596 ms•interleave=all:平均延迟 352.581 ms,标准差 7.276 ms。

说明不只是平均延迟变好,抖动大幅下降,NUMA 会导致性能很不稳定!

小结

如果我们在 PG 上观察到下面这些现象,就应该立刻把 NUMA 当成优先排查方向之一:

1.shared_buffers (尤其是 Buffer Blocks) 在 NUMA 节点之间分配明显偏斜:例如某个 NODE 占了绝大多数内存页,另一个 NODE 几乎没有.2.OLTP 高并发下 TPS 不随核数增长,甚至扩核反降 — 多 NUMA 节点加入后,热点页与共享元数据 (buffer lock、procarray 等) 的竞争成本被放大,导致扩展性崩溃3.延迟抖动明显:平均延迟还行,但 P95/P99 或标准差很难看 — 部分请求被远端访存、锁竞争或内核回收拖慢4.sys% CPU 占比异常偏高,常见原因包括:自旋锁/争用导致更多内核调度开销、NUMA balancing 迁页扫描、内存回收/压缩 (reclaim/compaction) 等5.明明可用内存很多,却出现明显的 swap 使用/增长

参考

NUMA vs PostgreSQL

DB吐槽大会,第81期 - PG 未针对 NUMA 优化