PREEMPT_NONE 已死;你的 Postgres 可能并不在乎
摘要
Linux 7.0 移除了 PREEMPT_NONE 内核抢占模式,在特定配置下导致 PostgreSQL 基准测试性能下降,但大多数用户不会注意到这一变化。
<p><a href="https://lobste.rs/s/snysfl/preempt_none_is_dead_your_postgres">评论</a></p>
查看缓存全文
缓存时间: 2026/07/06 14:05
# PREEMPT_NONE 已死;你的 PostgreSQL 可能并不在意
来源:https://thebuild.com/blog/preempt_none-is-dead-your-postgres-probably-doesnt-care/
*一只蓝色大象和一只企鹅沮丧地审视着一大堆电子设备。*
本月早些时候,AWS 发布了一项基准测试,显示 PostgreSQL 在 Linux 7.0 上的吞吐量下降到了相同工作负载在 Linux 6.x 上的 0.51 倍。Phoronix 的标题便不请自来。Hacker News 做了它该做的事。到周末,我已经有三家独立客户询问他们是否需要暂缓内核升级。他们不需要。几乎没有人需要。
性能回退是真实的,但这是一个狭隘的、由基准测试配置造成的强烈伪像——该配置本就不适合一台拥有 100+ GB 共享内存的 96 vCPU 机器。这个标题低估了这是一个“别那样做”的故事,而高估了这是一个“Linux 搞坏了 Postgres”的故事。
让我带你走一遍实际发生了什么,因为解释本身很有趣——它涉及调度器、TLB、缺页中断,以及 Postgres 中除缓冲管理器之外没人关心的那个自旋锁。而且它像许多 Postgres 性能故事一样,以巨页收尾。
Lætitia Avrot 在 *My DBA Notebook* 上于 4 月 15 日撰写了(https://mydbanotebook.org/posts/postgres-performance-regression-are-we-there-yet/)了她自己清晰透彻的同类回退分析,如果你只读一篇关于此事的文章,请读她的。下面是我讲述的版本,花费更多时间在机制上,并附有更多图表。
## Linux 7.0 实际上改变了什么
在 Linux 7.0 之前,你可以用三种抢占模式之一构建或启动内核:
- **`PREEMPT_NONE`**——内核几乎从不中断正在运行的用户态线程。线程获得其时间片,使用其时间片,在系统调用或睡眠时让出。这是历史上的服务器期望模式:批量吞吐友好,上下文切换开销最小。
- **`PREEMPT_FULL`**——内核可以在几乎任何安全点中断用户态。低延迟,多上下文切换,历史上的桌面默认模式。
- **`PREEMPT_LAZY`**——一种较新的折衷方案。调度器可以中断,但在可能的情况下会等待“自然”边界。
Linux 7.0 通过 Peter Zijlstra 的抢占清理系列,在 arm64、x86、powerpc、riscv、s390 和 loongarch 上完全移除了 `PREEMPT_NONE`。现在你得到的是 `PREEMPT_FULL` 或 `PREEMPT_LAZY`。在大多数发行版中,默认值转移到了 `PREEMPT_LAZY`。
对于几乎所有工作负载,这都没问题。`PREEMPT_LAZY` 应该能在面向吞吐量的负载下近似 `PREEMPT_NONE` 的行为。大多数情况下它能做到。例外情况是当用户态线程进入一个关键区域,被抢占将是灾难性的——并且在该关键区域内停留的时间刚好比调度器预期的长一点。换句话说,就是自旋锁。
## 基准测试
Salvatore Dipietro 在 AWS 于 4 月 3 日将回退发布到 LKML 上(https://lkml.org/lkml/2026/4/3/1379),作为一个单补丁系列,标题为 *“sched: Restore PREEMPT_NONE as default.”*
设置如下:
- `m8g.24xlarge`——96 vCPU Graviton4——运行 Amazon Linux 2023。
- 内核:`next-20260331`,一个 linux-next 快照,分别有和没有回退 commit `7dadeaa6e851` *(“sched: Further restrict the preemption modes”)*,该提交是在 arm64 上移除 `PREEMPT_NONE` 的 7.0-rc1 变更。
- 存储:12× 1 TB io2 卷,每个 32,000 IOPS,RAID0,XFS。
- PostgreSQL 17,pgbench `simple-update`,1,024 客户端,96 线程,预编译协议,比例因子 8,470,`fillfactor=90`,运行 1,200 秒。
结果,三次运行的平均值:
| 配置 | 平均 tps | 比例 |
|------|----------|------|
| 基线(linux-next, PREEMPT_LAZY) | 50,751.96 | 1.00x |
| 回退 `7dadeaa6e851` 后 | 98,565.86 | 1.94x |
基线就是那个上了头条的结果。回退抢占模式变更几乎使吞吐量翻倍。换种说法:在此工作负载下,Linux 7.0 只能达到 0.51 倍。`perf` 显示回退几乎完全驻留在一条独立的调用链中:
```
1|- 56.03% - StartReadBuffer
2 |- 55.93% - GetVictimBuffer
3 |- 55.93% - StrategyGetBuffer
4 |- 55.60% - s_lock <<<< 55% 的 CPU
5 |- 0.08% - LockBufHdr
6 |- 0.07% - hash_search_with_hash_value
```
机器超过一半的 CPU 时间被消耗在一个用户态自旋锁上,这是一个非常具体且非常有指示性的热点落点。
## 为什么是那个自旋锁,以及为什么是现在
`StrategyGetBuffer` 是 Postgres 在一个后台进程需要缓冲区但尚未拥有缓冲区时调用的函数。它在两种情况下序列化在一个自旋锁上——`StrategyControl->buffer_strategy_lock`。在冷的缓冲池上,它从空闲列表中弹出。一旦空闲列表耗尽,它运行时钟扫描,前进 `nextVictimBuffer`,并返回一个候选。两条路径都走同一个自旋锁。
在一台 1,024 个客户端的 96 vCPU 机器上,任何序列化点都会变得喧闹,但这个自旋锁有一个特定属性:被自旋锁保护的代码段,在错误配置下,可能会发生轻微缺页中断。这就是把一个糟糕但可容忍的序列化点变成 55% CPU 灾难的关键,也是内核变更重要的地方。
在这种高并行度下,竞争是真实的。问题是为什么在 `PREEMPT_LAZY` 下它变得糟糕了两倍。答案——正如 Andres Freund 在 -hackers 和 Hacker News 上研究出来的——不是调度器,或者说不是直接原因。
## 真正的罪魁祸首:自旋锁内部的轻微缺页中断
这是值得放慢节奏理解的部分。在 `huge_pages=off` 的情况下,Postgres 的共享内存用普通的 4 KB 页面映射。一个 120 GB 的 `shared_buffers` 在 PTE 层面上大约有 3100 万个页面。这些页面中的每一个,在首次访问时,都会引起一次轻微缺页中断——VM 子系统必须分配物理页面并安装一个 PTE。这个轻微缺页中断需要微秒级的时间,这在自旋锁的语境中是永恒的。而且在一个比例因子为 8,470、运行 1200 秒的基准测试中,新的页面会在整个运行期间不断被接触——而不仅仅是在最初几秒:pgbench 对 127 GB 的 `pgbench_accounts` 表的均匀随机访问模式意味着新页面会持续很长时间进入工作集。
现在考虑热点路径上的顺序。一个后台进程持有 `buffer_strategy_lock`。为了从空闲列表中弹出一个缓冲区——或者针对一个缓冲区头位于后台进程尚未接触过的页面上的缓冲区,推进时钟扫描——它必须读取或写入尚未缺页进来的共享内存。这次访问会导致一次轻微缺页中断。自旋锁持有者现在在内核缺页处理程序中停滞。在其他后台进程中——在一台 1,024 个客户端的 96 vCPU 机器上有几十或几百个——正在用户态旋转,消耗 CPU,等待。
```mermaid
sequenceDiagram
participant A as 后台进程 A (锁持有者)
participant K as Linux 内核 (VM + 调度器)
participant B as 后台进程 B..N (自旋中, ×1000)
A->>A: 获取 buffer_strategy_lock
A->>A: 接触未映射的 4 KB 页面
A->>K: 轻微缺页中断
Note over K: 分配物理帧<br/>安装 PTE (~μs)
B->>B: s_lock() 自旋
B->>B: s_lock() 自旋
B->>B: s_lock() 自旋
Note over B: 1000+ 个 CPU<br/>消耗时钟周期<br/>等待 A
K-->>A: PTE 安装完成,恢复
A->>A: 完成关键代码段
A->>A: 释放锁
B->>B: 一个后台进程获取锁,其余继续自旋
```
现在把 `PREEMPT_LAZY` 叠加上去。`PREEMPT_NONE` 永远不会在后台进程持有自旋锁时抢占它;它根本不会抢占任何不请求它的用户态进程。`PREEMPT_LAZY` 可能会。当它发生时,自旋锁持有时间从“微秒级的缺页服务”膨胀到“微秒级的缺页服务加上调度器将控制权交还给持有者所需的时间”。自旋队列增长。浪费的 CPU 时间放大。抢占模式变更并没有制造 bug。它使得预先存在的 bug——*在映射为 4 KB 页面、超过 100 GB 共享内存、以及荒谬并行度的情况下,在自旋锁内部产生缺页中断*——变得可见,放大为两倍。
## 为什么巨页让问题消失
这是 TLB 密度故事在一张图中的表现——同样的 120 GB `shared_buffers`,三种不同的映射方式:
```mermaid
flowchart LR
S["shared_buffers = 120 GB"]
S --> A["4 KB 页面<br/>~31,457,280 个 PTE<br/>~31M 次可能的首次接触缺页<br/>TLB 在负载下抖动"]
S --> B["2 MB 巨页<br/>~61,440 个 PTE<br/>~61K 次首次接触事件<br/>TLB 适配工作集"]
S --> C["1 GB 巨页<br/>120 个 PTE<br/>~120 次首次接触事件<br/>TLB 实际上免费"]
classDef small fill:#fdd,stroke:#933,stroke-width:1px
classDef mid fill:#ffd,stroke:#a90,stroke-width:1px
classDef big fill:#dfd,stroke:#393,stroke-width:1px
class A small
class B mid
class C big
```
当你切换到巨页时,会发生两件事。首先,首次接触时的缺页次数从约 3100 万降到约 61,000 或约 120——因为内核在首次访问任何字节时就会映射整个巨页,而且这些映射在使用 `MAP_HUGETLB` 时通常已经在 `mmap` 时完成了。冷启动的缺页风暴消失了。其次,TLB 压力下降了四到六个数量级,因此缓冲管理器热路径实际上保持在 TLB 中,而不是抖动。
Freund 自己在尝试复现时的发现:当 `huge_pages=on`(甚至 THP 做正确的事情)时,回退没有出现。当 `huge_pages=off` 时,在同一硬件上,回退出现。变量不是内核。变量是共享内存的映射方式。
Dipietro 的补丁提议将 `PREEMPT_NONE` 恢复为默认值。Freund 的回应本质上是:内核做了你要求的事;你的 `shared_buffers` 映射方式不对。
```mermaid
flowchart LR
A[pgbench 启动<br/>冷缓冲池] --> B{huge_pages?}
B -->|on / THP| C[少量缺页<br/>密集 TLB]
B -->|off| D[~31M 轻微缺页<br/>TLB 抖动]
C --> E[自旋锁持有者<br/>从不因缺页停滞]
D --> F[自旋锁持有者<br/>因缺页停滞]
F --> G{PREEMPT 模式?}
G -->|NONE Linux 6.x| H[持有者快速恢复<br/>~0.51x 仍保持在 ~1x]
G -->|LAZY Linux 7.0| I[持有者可能被调度出去<br/>→ 0.51x]
E --> J[稳态 tps<br/>不受内核影响]
```
这个基准测试径直走进了 Linux 7.0 变更发挥作用的狭窄场景:巨大的共享内存、小页面、一个不断拉入新页面的工作集,以及足够多的并行度来保持自旋锁队列饱和。改变其中任何一个变量,回退就不会复现。
## 内核社区的相反提议
内核方面建议的修复方案——让 Postgres 使用基于 `rseq` 的时间片扩展来告诉调度器“现在请不要抢占我”——带着一种从未发布过数据库的人的自信心到来。Freund 的回应是经过衡量且完全正确的:要求用户态软件采用 7.0 中引入的新内核功能,以掩盖仅存于 7.0+ 中的回退,这不是一笔好交易。这与“我们不会破坏用户态”的原则也不舒服地并存——这个原则通常会被更积极地援引。
Postgres 最终会不会做类似的事情?很可能。还有其他的原因希望在 `LWLockAcquire` 和缓冲策略锁周围拥有切片扩展语义,与这个特定的回退无关。但这是那种适合在 PG20 或 PG21 中作为深思熟虑的补丁出现的东西,而不是紧急反向移植。同时,现在已经有一个可行的缓解措施,并且自此事成为问题之前就一直是被推荐的配置。
## 你实际应该做什么
### 如果你在自己的服务器上自托管
在 `postgresql.conf` 中设置 `huge_pages = on`,并实际分配巨页。这并不难:
```bash
# 询问 Postgres 它需要多少 2 MB 页面:
postgres -D $PGDATA -C shared_memory_size_in_huge_pages
# 分配它们(将其持久化到 /etc/sysctl.conf):
sysctl -w vm.nr_hugepages=<数值>
# 验证:
grep -E 'HugePages_Total|HugePages_Free' /proc/meminfo
```
然后设置 `huge_pages = on`。不是 `try`。是 `on`。如果你因为分配的页面不够而无法启动 Postgres,这是一个有用的失败——它告诉你你配置不正确——而不是默默地回退到 4 KB 页面的理由。我见过太多“为什么它这么慢?”的问题,答案最终都是“`huge_pages = try`在三个月前回退了,没有人注意到。”
升级你的内核。你不会受到影响。
### 如果你在容器下自托管
这是回退真正有影响的地方,而且与 Linux 7.0 没有具体关系。在 Kubernetes 下运行大型 Postgres,拥有 100+ GB `shared_buffers`,而没有主机级巨页预留,多年来一直很慢。7.0 的变更为已经糟糕的情况增加了二阶减速。
两条路。要么你的容器运行时和主机配置为向 Postgres Pod 暴露巨页(Kubernetes 有 `hugepages-2Mi` 资源;主机必须预留页面;Pod 规范必须请求它们),要么就不是。如果不是,请减少 `shared_buffers` 或将 Postgres 移出容器,因为无论如何你都会遇到不愉快的情况。内核升级只是让你意识到这一天的到来提前了。
### 如果你在 RDS、Aurora、Cloud SQL、Azure Flexible Server、Neon 或类似服务上
你不管理内核。你不配置巨页。你依赖供应商来正确处理此事。AWS 已经在 Graviton RDS 实例上发布了足够多的关于巨页的基准测试驱动内容,以至于如果它还没有在可能受此影响的 Aurora Postgres 和 RDS Postgres 实例上启用,我会感到惊讶。Supabase、Neon、Crunchy Bridge 和其他服务有它们自己的故事;请咨询你的供应商。他们都不会让你自己设置 `huge_pages`。这没关系。这是供应商的问题。
### 如果你的基准测试显示了回退
它之所以显示,是因为你在一台足够大的机器上关闭了巨页,或者没有打开它们。请使用 `huge_pages = on` 和适当分配的池重新运行基准测试。如果回退消失,你就有了答案。如果没有,请将 `perf` 输出发给我——我很好奇。
## 真正的教训
关于 Linux 7.0 使 Postgres 吞吐量减半的标题,是一种特定类型的基准测试伪像。一个基准测试设置忽略了一个配置参数——该参数每个运行 100+ GB 共享内存的人都应该在过去十年中设置——最终会捕捉到平台在做它隐式依赖的事情。`PREEMPT_NONE` 的移除拉动了那根线。其余的线自从有人第一次在生产机器上设置 `huge_pages = off`,拥有 100+ GB 共享内存和 96 vCPU,并希望一切顺利的那一刻起,就一直坐在那里。
设置巨页。升级你的内核。继续前行。
相似文章
PostgreSQL 与 OOM Killer:为何必须使用严格的内存超分配策略
这篇博文解释了为什么 PostgreSQL 数据库容易受到 OOM killer 的影响,以及使用严格的内存超分配如何能够防止灾难性宕机。文章还讨论了内核中的一个 bug 以及设置合适超分配限额的启发式方法。
PostgreSQL 18.4 和 17.10 版本修复了 11 个 CVE
PostgreSQL 已发布针对版本 18.4、17.10、16.14、15.18 和 14.23 的安全更新,修复了 11 个 CVE 和超过 60 个错误。值得注意的修复包括 CVE-2026-6473(整数回绕,CVSS 8.8)和 CVE-2026-6475(符号链接覆盖,CVSS 8.8)。
用Rust重写的Postgres,现已100%通过Postgres回归测试
pgrust是用Rust重写的PostgreSQL 18.3,通过了所有46k+回归测试,旨在通过Rust和AI辅助使Postgres更易于修改。它是磁盘兼容的,并且可以从现有的Postgres数据目录启动。
抢占是内存重排序的GC(2019)
一篇2019年的博客文章认为,抢占(中断)可以用作无锁编程中内存排序的预支付屏障,类似于垃圾回收是一种沉没成本,并展示了在Linux/x86上实现事件计数和非对称标志翻转的实现。
展望 Postgres 19
PostgreSQL 19 beta 引入了关键特性,如 REPACK CONCURRENTLY、分区拆分与合并以及增强的逻辑复制,为生产数据库管理提供了实用改进。