文件描述符耗尽:那个把 PostgreSQL 拉垮的内核限制

2026-06-04 20 预计阅读时间:1 分钟
来源:postgr.es AI 摘要 原文链接

免责声明:本文为 AI 摘要整理,建议结合原文阅读。摘要可能省略上下文、版本差异或边界条件,不作为官方说明。

预计阅读时间:11 分钟

大多数被归咎于"数据库挂了"的 PostgreSQL 宕机,真正的故障点在更底层——内核的文件描述符(file descriptor)用完了,PostgreSQL 只是第一个倒下的进程。这篇文章拆解整个故障链路:从连接数膨胀到 FD 耗尽,从日志特征到诊断命令,再到根治方案和临时止血手段。

一切皆文件描述符

Linux 内核把几乎所有 I/O 对象都抽象为文件描述符:TCP socket、打开的数据表文件、索引文件、WAL 段、排序和 join 产生的临时文件、日志文件……每一次 open()accept() 调用都会让一个计数器加一。内核在两个维度上设了天花板:

  • 系统级上限 fs.file-max:整台机器所有进程合计能持有的 FD 总数。一旦全局用量触及这条线,任何进程的 open() 都会失败,不区分是谁在请求。
  • 单进程上限 RLIMIT_NOFILE(即 ulimit -n):一个进程最多能打开多少个 FD。PostgreSQL 后端进程撞到这条线时,同样报"out of file descriptors"。

诊断时两条都要查,只看一条会漏掉另一半问题。

PostgreSQL 为什么是重灾区

PostgreSQL 采用进程模型——每个客户端连接 fork 一个独立的后端进程。一个后端进程持有的 FD 包括:客户端 socket、正在访问的表和索引文件(由 PostgreSQL 内部 VFD 机制管理,上限 max_files_per_process,默认 1000)、WAL 段、临时文件。

  • 空闲后端:10–15 个 FD
  • 活跃写入后端(涉及多表多索引):50–200 个 FD 甚至更多

理论最坏值是 max_connections × max_files_per_process,实际不会同时触顶,但哪怕只达到一个比例,在连接数上千时就已经危险了。

崩溃是怎么一步步发生的

典型的故障路径:

  1. max_connections 被设到很高(有时 10,000+),应用层没有连接池,随意开连接。
  2. 长事务停留在 idle in transaction 状态,后端进程占着 OS 资源却不干活。
  3. 批处理任务启动,瞬间再开几千连接。大部分连接停在 ClientRead,但空闲连接照样吃内存、进程表条目和 FD。
  4. 这些连接同时开始写操作时,后端争抢共享锁——LWLock:BufferContentLWLock:WALInsertLWLock:WALWrite。等锁的后端把 FD 持有时间拉长,峰值 FD 用量比静态计算值更高,系统在比预期更低的并发度就撞上 fs.file-max
  5. FD 耗尽错误出现在日志里时,系统已经越过安全阈值。锁争抢阶段很短,一旦越过天花板就很快。

日志里的死亡序列

PostgreSQL 按阶段记录故障:

-- 第一阶段:FD 分配失败,混在正常查询日志里
LOG: out of file descriptors: Too many open files in system; release and retry

-- 第二阶段:后端进程被 abort
LOG: server process (PID 12345) was terminated by signal 6: Aborted

-- 第三阶段:足够多的后端失败后,PostgreSQL 进入 crash recovery
-- 恢复期间,所有应用节点同时重连,产生 SSL 错误波
LOG: failed to send SSL negotiation response: Broken pipe

-- 应用侧看到
ResourceException: Unable to create new connection

数据库可能先恢复上线,但应用节点的重连风暴继续施压——如果批量重试逻辑触发新一轮连接潮,系统还没稳定就再次被压垮。

诊断:OS 层 + PostgreSQL 层

先看 OS 层的两个关键指标:

# 系统级 FD 上限
cat /proc/sys/fs/file-max

# 当前全局 FD 用量:<已分配> <空闲(始终为0)> <上限>
cat /proc/sys/fs/file-nr

# PostgreSQL postmaster 进程的单进程 FD 上限
cat /proc/$(pgrep -o postgres)/limits | grep "open files"

file-nr 的第一列是全机当前已分配的 FD 数,第三列是上限。两个数字接近时,系统处于高危状态。

再看 PostgreSQL 层:

-- 最大连接数
SHOW max_connections;

-- 每个后端进程的 FD 上限
SHOW max_files_per_process;

-- 按状态分组查看当前连接分布
SELECT count(*), state, wait_event_type, wait_event
FROM pg_stat_activity
GROUP BY state, wait_event_type, wait_event
ORDER BY count DESC;

如果 max_connections × max_files_per_process 已经接近 fs.file-max,即使批处理还没启动,你已经在危险区了。

根治方案:PgBouncer

调高 fs.file-max 只是买时间,不解决问题。根因是后端进程太多,不是限制设得太低。调高上限只会推迟下一次崩溃。

正确做法是在应用和 PostgreSQL 之间部署 PgBouncer:

  • PgBouncer 在应用侧接受数千连接,在数据库侧复用一个小型后端池。
  • transaction 模式下,PgBouncer 只在事务持续期间把后端分配给客户端;事务结束,后端归还池中。
  • 应用代码不需要改动,照常连接和查询。
  • PostgreSQL 只跑 200–500 个后端进程,而不是数千个。

部署 PgBouncer 后,把 PostgreSQL 的 max_connections 降到 500–1000,从结构上杜绝后端进程堆积:

# pgbouncer.ini 关键配置
[databases]
mydb = host=127.0.0.1 port=5432 dbname=mydb

[pgbouncer]
pool_mode = transaction
max_client_conn = 5000
default_pool_size = 20
reserve_pool_size = 10
reserve_pool_timeout = 3
# PostgreSQL 侧:把 max_connections 降下来
# 在 postgresql.conf 中
max_connections = 500

临时止血:调高 fs.file-max

PgBouncer 还在部署时,先抬高上限作为安全网。记住这只是临时措施。

普通 Linux VM:

# 立即生效(重启丢失)
sysctl -w fs.file-max=20000000

# 持久化
echo "fs.file-max = 20000000" >> /etc/sysctl.conf
sysctl -p

# 验证
cat /proc/sys/fs/file-max
cat /proc/sys/fs/file-nr

Kubernetes(AKS)环境——没有 SSH 直接改 sysctl 的权限,两种方式:

方式一:kubectl debug node(立即生效,节点重启丢失,适合紧急止血):

# 找到数据库 Pod 所在节点
kubectl get pod <pod-name> -n <namespace> -o wide

# 在该节点上开特权调试 shell
kubectl debug node/<node-name> -it --image=ubuntu -- bash

# 在调试 shell 内,chroot 到宿主机文件系统并修改
chroot /host sysctl -w fs.file-max=20000000

# 从数据库 Pod 内验证
kubectl exec <pod-name> -n <namespace> -- sh -c \
  'echo "file-max: $(cat /proc/sys/fs/file-max)" && \
   echo "file-nr: $(cat /proc/sys/fs/file-nr)"'

方式二:Privileged DaemonSet(节点重启后自动重设,不需要改节点池配置):

apiVersion: apps/v1
kind: DaemonSet
metadata:
  name: fd-limit-setter
  namespace: kube-system
spec:
  selector:
    matchLabels:
      app: fd-limit-setter
  template:
    metadata:
      labels:
        app: fd-limit-setter
    spec:
      nodeSelector:
        agentpool: dbnodes  # 改成你的数据库节点池标签
      tolerations:
        - operator: Exists
      initContainers:
        - name: set-fd-limit
          image: busybox
          securityContext:
            privileged: true
          command: ["sysctl", "-w", "fs.file-max=20000000"]
      containers:
        - name: pause
          image: gcr.io/google-containers/pause:3.1
# 应用 DaemonSet
kubectl apply -f fd-limit-daemonset.yaml

# 验证
kubectl exec <pod-name> -n <namespace> -- sh -c \
  'echo "file-max: $(cat /proc/sys/fs/file-max)" && \
   echo "file-nr: $(cat /proc/sys/fs/file-nr)"'

DaemonSet 的 init container 在每次节点启动时运行,重启和重镜像后设置自动恢复。

监控:在日志报错之前报警

等到日志里出现 FD 错误时已经来不及了。真正需要盯的是两个前置指标:

  1. file-nr 第一列超过 fs.file-max 的 50–60%——这是最关键的告警阈值。
  2. pg_stat_activity 活跃连接数越过设定阈值——批处理密集型工作负载下,1000 是合理的基线。

批处理执行窗口期间,可以实时观察 FD 用量:

watch -n 5 'cat /proc/sys/fs/file-nr'

把告警配置进你的监控系统(Prometheus + node_exporter 能直接采集 file-nr,PostgreSQL 指标通过 pg_stat_activity 采集),确保在到达红线之前就触发。

检查清单

项目 状态
file-nr 第一列 < file-max 的 50%
max_connections × max_files_per_process 远小于 file-max
PgBouncer 已部署,pool_mode = transaction
PostgreSQL max_connections 降至 500–1000
file-nr 用量告警已配置
连接数阈值告警已配置
长事务 / idle in transaction 监控已配置

文件描述符耗尽是一种"感觉不可预测,理解机制后又觉得必然"的故障。太多连接、没有池化、一个批处理任务把天平推过去——PostgreSQL 就硬着陆。好消息是修复路径清晰:PgBouncer 挡在前面,max_connections 调下来,file-nr 告警提前拉响。今晚需要止血就先抬 fs.file-max,但如果这周 PgBouncer 还没在计划里,它应该立刻进去。


相关推荐