PostgreSQL码农集散地

PostgreSQL 必须使用 HugePages 的场景及原因

这是一个关于 CPU TLB(Translation Lookaside Buffer)命中率 和 页表内存开销 的问题。HugePages 的价值在「连接多 + 连接持久 + 对象多」的场景里被放大,本质原因是工作集超出了 TLB 的覆盖能力。


一、先理解两个底层机制

1. TLB 是虚拟地址翻译的"高速缓存"

  • x86_64 CPU 的 L1 dTLB 通常只有 64~128 条目
  • L2 TLB 一般 1~2K 条目
  • 每次 TLB miss 都要做 页表遍历(4 级页表 = 4~5 次内存访问),单次代价 100~500 个 CPU 周期

2. TLB reach(TLB 能覆盖的内存大小)

页大小
L1 dTLB 条目
L1 dTLB 覆盖
4 KB(默认)
64~128
256 KB ~ 512 KB
2 MB(HugePage)
32~64
64 MB ~ 128 MB
1 GB(GigaPage)
极少
数 GB

结论:HugePage 把 TLB 覆盖范围放大 256~512 倍。

二、PostgreSQL 在"多连接 + 多对象 + 持久连接"时的内存特征

每个 backend 进程都会在本地堆里维护几类热缓存(libc malloc 出来的虚拟内存):

缓存
内容
特点
relcache
每张表/视图/索引的元数据条目(约 600 B/条)
对象越多越大
catcache
系统表(pg_class/pg_attribute/pg_type…)高频查找结果
每次解析都要查
typcache
类型相关信息
plancache
已准备语句的执行计划
持久连接越长积累越多
Portal/QueryDesc 等
当前查询上下文

叠加效应(这才是关键) :

后端进程数 N  ×  每后端缓存大小 M  =  全局热工作集
   ↑                    ↑                    ↑
 多连接              多对象            总可达数 GB 甚至数十 GB
  • N 大(连接池保持几百~几千长连接)
  • M 大(应用访问成千上万张表/索引)
  • 每个后端的热数据(relcache、catcache、plancache)又分散在大量 4 KB 页面里

→ 每个 backend 的"热页"都远超 L1 dTLB 覆盖范围。每次 cache 查找几乎都 TLB miss。

三、没有 HugePages 时会发生什么

1. 持续的 TLB miss 风暴

  • relcache 查找:执行一条简单 SELECT * FROM t WHERE id=$1 涉及 多次 catcache + relcache 查找
  • 每个查找都是几次指针解引用 → 触发若干次 TLB miss
  • 一千个长连接 × 每秒数千次查找 → TLB miss 变成 CPU 的主要开销之一
  • 实测在某些 PG 14/16 大连接数场景下,perf stat 看到的 TLB miss / iTLB load miss 会成为 top 3 热点

2. 页表本身占用大量内存

  • 假设每个 backend 占用 1 GB 缓存虚拟内存,4 KB 页 → 页表条目 ≈ 25.6 万项
  • 每项 8 字节 ≈ 2 MB 页表
  • 一千个 backend = 2 GB 内核页表内存
  • 而且每个 backend 的页表是各自独立的(不像共享内存可以共享页表)

3. 缺页 / swap 敏感

  • 后端缓存增长时会有 soft page fault 申请新页
  • 物理内存吃紧时,部分热页被换出 → 再访问时 hard fault,延迟尖刺
  • 长连接下这种现象更难被发现,因为"看起来"是运行中的进程

4. mmap/munmap 锁竞争

  • PostgreSQL 用 mmap() 管理本地内存上下文
  • 连接数高时,频繁的 mmap 操作会在 mmap_lock 上排队
  • 这会直接表现为 connection storm 时的新建连接延迟尖刺

四、HugePages 在这些场景的具体收益

1. TLB miss 率断崖式下降

  • 把后端进程的热内存区域(PGDATA 本地缓存、shared_buffers)映射到 2 MB 大页
  • 同样 1 GB 工作集:从 25.6 万个 4 KB 页表项 → 降到 512 条
  • L1 dTLB 只要 8 条就能覆盖整个 1 GB 热数据
  • 单次 cache 查找从"几次 TLB miss"变成"零 TLB miss"

2. 页表内存减少 ~250 倍

  • 1 GB 工作集的页表:从 2 MB → 8 KB
  • 一千个 backend 节省 ≈ 2 GB 内核内存
  • 这部分内存可以还给 page cache / shared_buffers → 提升缓存命中率

3. 减少 mmap 锁竞争 / fork 开销

  • 大页是预先在系统启动时 reserve 出来的连续区域
  • PostgreSQL 的 shared_buffers 用 huge_pages=on 时直接落到 HugeTLB fs
  • 本地内存虽然仍用 4 KB 页(glibc 限制),但共享区热点收益已经很大

4. 物理内存锁定,绝无 swap

  • HugePage 是 预留+锁定 的,不进 swap
  • 长连接累积的本地缓存即便触碰不到物理内存,至少 shared_buffers 这块绝对不会被换出
  • 性能曲线变"平",没有抖动

5. NUMA 友好

  • 在 NUMA 系统上,可以精确地把 HugePage 分配到指定 NUMA 节点
  • numactl --membind + vm.nr_hugepages 按节点分配
  • PostgreSQL 大连接场景下,避免跨节点访问内存的代价

五、量化感受(参考量级)

在 64 核 / 256 GB / PG 14 / shared_buffers=64GB / 1000 长连接 / 应用访问 ~5000 张表的场景里,开启 huge_pages=on 后典型的:

指标
提升
TLB miss 相关 cycles(perf)
-30% ~ -70%
sys%(CPU 花费在内核的时间)
明显下降
QPS(oltp_read_only 类)
通常 +10% ~ +30%
长连接下 P99 延迟
抖动明显减小
内核页表占用内存
-数 GB

当然,前提是 shared_buffers 配置得足够大(通常 ≥ 8 GB 才值得专门配 HugePage),否则收益会被 hugepage 预留带来的内存浪费抵消。

六、什么时候 HugePage 帮助不大 / 有反效果

场景
原因
连接数很少(< 50),每个连接只访问少量对象
工作集小于 L2 TLB reach,TLB miss 本来就低
shared_buffers 配得很小(< 4 GB)
收益覆盖不了预留开销
物理内存紧张
预留的 hugepage 会挤掉 page cache
应用每次都是简单 SQL 且走 prepared statement 缓存命中率极高
热集本来就小

七、配置建议

# /etc/sysctl.d/postgres.conf
vm.nr_hugepages = <shared_buffers_MB> / 2   # 预留 2MB 页
vm.nr_overcommit_hugepages = 100            # 允许过度预留

# postgresql.conf
huge_pages = try    # 或 on(on 时若 HugePage 不够会启动失败)

启动前用 cat /proc/meminfo | grep Huge 确认 HugePages_Free 足够。

一句话总结

连接多 + 持久 + 对象多 → 单个后端的"热数据"超过 L1 TLB 覆盖范围 → 每次 PG 内部的 cache 查找都付出 TLB miss 代价 → 用 HugePage 把 TLB 覆盖范围扩大几百倍,等于"白拿"一笔 CPU 周期。

HugePage 不是 PostgreSQL 专属的银弹,但是它是 少数几个能在不改一行 SQL、只动 OS+PG 几个参数就让大连接数库"避免被OOM"的手段之一。