PostgreSQL 16.8 Checkpointer 卡死:一次由 fsync 队列触发的生产事故

2026-07-01 36 预计阅读时间: 1 分钟
来源: postgr.es AI 摘要 Original link

Disclaimer: This article is an AI-assisted summary. Read it together with the original source when precision matters. The summary may omit context, version differences, or edge cases and is not official documentation.

预计阅读时间:11 分钟

一次 PostgreSQL 16.8 生产库事故,最初看起来像内存问题:日志里出现了 ERROR: invalid memory alloc request size。直觉上,很多人会先怀疑物理内存不足,或者某处发生了内存损坏。但真正的根因更隐蔽:checkpointer 进程遇到了一个已知缺陷,被困在 fsync 请求队列的无限重试循环里。

这类问题危险的地方在于,它不会立刻把数据库打挂,而是让 checkpoint 长时间无法完成。WAL 继续增长,脏页继续堆积,一旦只能强制重启,恢复时间就会被“最后一次成功 checkpoint 之后的 WAL 量”拉长。

Checkpoint 为什么这么关键

PostgreSQL 修改数据时,并不会马上把数据页写回最终的数据文件。它采用的是典型的 WAL 机制:

  • 先把变更写入 Write-Ahead Log,也就是顺序追加的 WAL。
  • 修改后的数据页暂存在共享内存里,成为 dirty buffer。
  • 后续由 checkpoint 负责把这些脏页刷回数据文件,并对相关文件执行 fsync()

这样做是为了性能。WAL 是顺序写,成本低;直接把大量数据页随机写回数据文件,成本高得多。

checkpoint 完成后,PostgreSQL 会在 WAL 中记录一个 checkpoint 位置。数据库崩溃后,恢复流程只需要从最近一次成功 checkpoint 之后开始重放 WAL。如果 checkpoint 长时间无法完成,恢复起点就会停留在很久以前,重启后的 crash recovery 时间也会变长。

这次事故里的关键结构,是 checkpointer 内部维护的 fsync request queue。它记录了 checkpoint 过程中哪些文件还需要执行 fsync()。正常情况下,队列会随着文件被成功 fsync 而逐步清空;问题发生在它无法正常收敛时。

真正的 bug:无上限增长的 fsync 队列

在 PostgreSQL 16.8 及之前受影响版本中,fsync 请求队列是一个连续内存结构。每个等待 fsync() 的文件都会占用一个队列条目。写入压力很大、shared_buffers 又配置得较大时,可能出现这样的链条:

  • 共享内存里积累了更多 dirty buffer。
  • checkpoint 需要处理更多脏页。
  • 更多数据文件进入待 fsync() 状态。
  • fsync request queue 不断扩容。
  • 队列尝试申请越来越大的连续内存块。

问题不在于机器没有 RAM。PostgreSQL 自己的内存管理器有单次分配大小限制,大约是 1GB。即使服务器还有很多空闲内存,只要一次内部内存申请超过这个上限,也会被拒绝,并报出类似 invalid memory alloc request size 的错误。

更糟糕的是,checkpointer 遇到这个错误后会重试。但这不是瞬时失败:队列仍然过大,每次重试都会尝试同样的超大内存分配,然后再次失败。于是 checkpointer 就卡在了一个没有出口的循环里。

手动执行 CHECKPOINT 也救不了场,因为手动 checkpoint 走的是同一套 checkpointer 机制和同一个 fsync 请求队列。它不是绕过问题,而是再次撞上同一堵墙。

事故现场应该看什么

如果你怀疑 checkpoint 卡住,不要只盯着内存使用率。更应该同时观察 checkpoint 活跃时间、WAL 增长速度,以及最近一次 checkpoint 是否迟迟没有推进。

可以这样排查当前状态:

-- 查看 checkpointer / checkpoint 相关活动
SELECT
  pid,
  backend_type,
  state,
  wait_event_type,
  wait_event,
  now() - xact_start AS xact_age,
  now() - query_start AS query_age,
  query
FROM pg_stat_activity
WHERE backend_type ILIKE '%checkpointer%'
   OR query ILIKE '%checkpoint%';

-- 查看 checkpoint 统计信息,PostgreSQL 16 使用 pg_stat_bgwriter
SELECT
  checkpoints_timed,
  checkpoints_req,
  checkpoint_write_time,
  checkpoint_sync_time,
  buffers_checkpoint
FROM pg_stat_bgwriter;

-- 观察当前 WAL 位置,可间隔多次执行比较增长速度
SELECT pg_current_wal_lsn();

如果你使用 Kubernetes 部署 PostgreSQL,也可以用下面的命令快速确认日志和 Pod 状态。把命名空间、StatefulSet 名称和容器名替换成自己的:

NAMESPACE=database
APP_LABEL=postgres

kubectl -n "$NAMESPACE" get pods -l app="$APP_LABEL" -o wide
kubectl -n "$NAMESPACE" logs -l app="$APP_LABEL" --since=2h | grep -E "invalid memory alloc request size|checkpoint|checkpointer"

如果日志持续出现 invalid memory alloc request size,同时 checkpoint 长时间无法完成,就要谨慎处理:贸然重启可能触发漫长 WAL replay,但在 checkpointer 已经不可恢复时,强制重启可能又是唯一出路。

永久修复:升级到包含补丁的小版本

这个问题已经由 PostgreSQL 社区修复,并回补到多个受支持版本分支中。对 PostgreSQL 16 来说,修复包含在 16.10。补丁没有重写 checkpoint 架构,而是给 fsync request queue 加了硬上限:最多 1000 万个条目。

这个修复的关键点在于:队列不再随着 shared_buffers 无限制放大。即使配置了较大的 shared buffer,fsync 队列也会被限制在 PostgreSQL 单次内存分配安全范围内,从而避免再次触发超大连续内存申请。

如果是 PostgreSQL 16.8 或 16.9,建议尽快评估小版本升级。小版本升级不改变磁盘数据格式,通常不需要:

  • pg_upgrade
  • pg_dump / pg_restore
  • 新建 data directory

Kubernetes 中可以这样规划一次 StatefulSet 镜像升级。以下 YAML 只是示例,重点是把镜像从受影响版本切到已修复版本,并复用原有 PVC:

apiVersion: apps/v1
kind: StatefulSet
metadata:
  name: postgres
  namespace: database
spec:
  serviceName: postgres
  replicas: 1
  selector:
    matchLabels:
      app: postgres
  template:
    metadata:
      labels:
        app: postgres
    spec:
      containers:
        - name: postgres
          image: postgres:16.10
          ports:
            - containerPort: 5432
          volumeMounts:
            - name: data
              mountPath: /var/lib/postgresql/data
  volumeClaimTemplates:
    - metadata:
        name: data
      spec:
        accessModes: ["ReadWriteOnce"]
        resources:
          requests:
            storage: 500Gi

一个更贴近生产的升级流程可以是:

NAMESPACE=database
STS=postgres

# 1. 停止应用写入,或切换维护窗口
# 2. 确认当前镜像
kubectl -n "$NAMESPACE" get sts "$STS" -o jsonpath='{.spec.template.spec.containers[0].image}{"\n"}'

# 3. 缩容 PostgreSQL StatefulSet
kubectl -n "$NAMESPACE" scale sts "$STS" --replicas=0
kubectl -n "$NAMESPACE" rollout status sts "$STS"

# 4. 更新到包含修复的小版本
kubectl -n "$NAMESPACE" set image sts/"$STS" postgres=postgres:16.10

# 5. 复用原 PVC 启动
kubectl -n "$NAMESPACE" scale sts "$STS" --replicas=1
kubectl -n "$NAMESPACE" rollout status sts "$STS"

# 6. 启动后确认版本
kubectl -n "$NAMESPACE" exec sts/"$STS" -- psql -U postgres -c 'SELECT version();'

实际生产环境里,还需要结合备份、只读窗口、连接池、探针、主从拓扑和回滚预案来执行。上面的命令适合改造成演练脚本,而不是不经审查直接扔进生产。

暂时不能升级时,能缓解什么

如果短期内无法升级,降低 shared_buffers 可能降低触发概率。原因很直接:较小的 shared buffer 通常意味着 checkpoint 前积累的 dirty buffer 较少,待 fsync 文件数量也会减少,fsync 队列更不容易膨胀到危险规模。

但这只是缓解,不是修复。代码路径仍然存在,真正的解决办法仍然是升级到包含补丁的小版本。

可以先查看当前配置:

SHOW server_version;
SHOW shared_buffers;
SHOW checkpoint_timeout;
SHOW max_wal_size;

如果要调整 shared_buffers,必须评估缓存命中率、操作系统 page cache、实例内存和重启窗口。不要为了规避一个 bug,把数据库调成另一个性能事故。

运维上的几个教训

这次事故最值得记住的,不只是“升级到 16.10”。更重要的是诊断思路:

  • invalid memory alloc request size 不一定代表物理内存耗尽,也可能是 PostgreSQL 内部单次分配被拒绝。
  • PostgreSQL 小版本不只是安全补丁,里面经常包含稳定性和可靠性修复。
  • checkpoint 活跃时间应该被监控,不能只监控查询延迟、CPU、内存和复制延迟。
  • 手动 CHECKPOINT 不一定能修复 checkpoint 子系统自己的问题。
  • 强制重启前要预估 WAL replay 时间,因为它会直接决定数据库恢复窗口。

如果你的环境还在 PostgreSQL 16.8 或 16.9,最务实的动作是阅读对应 release notes,安排小版本升级,并补上 checkpoint 相关监控。数据库真正危险的故障,往往不是一声巨响后立刻宕机,而是某个后台进程悄悄停在原地,让恢复成本一点点变高。


相关推荐