KernelRanch
返回文章列表

把 PostgreSQL 塞进 512MB 容器:shared_buffers、连接开销与 work_mem 的内存账本

2026-11-10 #数据库

512MB 的 --memory 配 384MB 的 shared_buffers,在 pgbench 只读负载下能撑住的连接数只有 33 条:34 条开始随机被杀,35 条起每一轮都触发 OOM。整个过程里 docker inspect 显示 OOMKilled=true,但退出码始终是 0,容器也没有退出——被内核杀掉的是 checkpointer 或某个 backend,postmaster 重建整个集群,客户端看到的是连接被切断。能复现的失败不是教科书里的 137,而是这个。

0. 实验环境与做法

项值
宿主机Windows 11 + Docker Desktop 29.4.3(WSL2 内核 6.6.114.1-microsoft-standard-WSL2,cgroup v2)
镜像postgres:18,即 PostgreSQL 18.6(Debian 18.6-1.pgdg13+2)
容器限制--memory 512m --memory-swap 512m --cpus 2,两者相等即禁用 swap[2]
启动参数-c shared_buffers=384MB -c max_connections=100 -c work_mem=4MB -c maintenance_work_mem=64MB
数据集pgbench -i -s 30,库大小 456 MB
负载pgbench -c N -j 4 -T 10 -S(纯 SELECT)
采样docker stats --no-stream 与容器内 memory.current、memory.events
时间2026-11-10 13:18–13:37(Asia/Shanghai)

不映射宿主端口,全部通过 docker exec 进容器跑 psql / pgbench。每轮加压前先用 pg_prewarm 把 buffer pool 填满,保证起点一致;不这么做,空载读数会比真实状态少 400MB 以上。

1. 空载读数会骗人:shared_buffers 是惰性映射

容器刚起来、一条业务连接都没有时,docker stats 显示 47.86MiB / 512MiB,容器内 memory.current 是 88.1 MiB。384MB 的 shared_buffers 只占了不到十分之一。原因是 PostgreSQL 用 mmap 申请主共享内存区(shared_memory_type 默认 mmap[1]),页只有被真正触碰时才获得物理页;空载时 buffer pool 里没有数据页,这块内存只是虚拟地址空间。

跑一次 pg_prewarm,把 57409 个块(约 448MB 的页面)读进 buffer pool,同一个容器立刻变成:

docker stats          468.3MiB / 512MiB
memory.current        509.9 MiB
memory.stat shmem     431.5 MB
memory.stat file      514.3 MB   (其中 inactive_file 41.8 MB)
memory.stat anon      6.6 MB

docker stats 与 memory.current 差 40MB 左右,差的是可回收的 inactive_file。

还有个容易错过的数字:shared_buffers=384MB,但 SHOW shared_memory_size 返回 422MB,pg_shmem_allocations 合计 421MB。多出来的 38MB 是 buffer 描述符、锁表、WAL buffer、pg_stat 共享区等开销;换成 shared_buffers=128MB 时这部分是 22MB(shared_memory_size = 150MB)。512MiB 的容器里,光共享内存段就吃掉 422MB,留给所有 backend 私有内存的只有 90MB 出头。

2. 连接数阶梯:内存怎么变、在哪一步挂

每轮流程固定:预热填满 buffer pool → 起 N 个 pgbench 客户端跑 10 秒 → 每 1.0–1.5 秒采样一次。峰值如下。

连接数 Nmemory.current 峰值docker stats 峰值结果
1(刚预热完)509.4 MiB468.3 MiB基线
10511.1 MiB468.8 MiB存活
20512.0 MiB488.1 MiB存活
30511.9 MiB505.3 MiB存活
32511.9 MiB506.4 MiB存活(重复 2 次)
33511.8 MiB506.0 MiB存活(重复 3 次)
34511.9 MiB509.0 MiB1 次存活、1 次 OOM
35——OOM(重复 2 次都挂)
40——OOM(首次触发)
60——OOM
100——OOM,第 89 个客户端建连失败

35 条及以后留空,是因为容器在采样窗口内已经重建,memory.current 掉回 38MB 左右,读到的数字没有意义。

这张表里最该记住的不是临界值,而是 memory.current 从第 10 条连接起就贴在 511–512 MiB 不动了。它不是"涨到上限然后被杀",而是一开始就在上限附近:内核不断回收 page cache 给新 backend 腾地方,读数被钉死在 limit 上。拿它当预警,10 条连接就报警,真实危险却在 35 条。

吞吐在崩之前没有退化:10 条 19875.7 tps,20 条 17870.4 tps,30 条 18331.4 tps。没有"变慢"这种前兆,只有突然断开。

3. 边界是模糊的

第一次触发是 40 条,之后向下回扫:30 条稳过、32 条稳过、33 条三次都稳过、34 条一次过第二次挂、35 条两次都挂。34 条那次"一次过、一次挂"值得单独说:同样的容器、预热、负载,只差一次重跑。可用空间里大约 40–50MB 是 page cache,回收是异步的,建连时序稍有差别,回收跟不上分配就会踩到 OOM。这类边界不该当硬阈值写进容量规划。

工程上更有用的说法:这套配置的安全连接数是 30 上下,33 是实测能重复通过的上限,34 起不可信。

4. 每连接 3.2MB 是怎么测出来的

ps 的 RSS 会把共享页重复计算三十几遍,不能直接用来算每连接成本。用 /proc/<pid>/status 里的 RssAnon(私有匿名常驻页)才准:

空载(9 个 postgres 进程)         RssAnon 合计  21604 kB
30 条连接(39 个 postgres 进程)   RssAnon 合计 119524 kB
差值 97920 kB ÷ 30 = 3264 kB ≈ 3.2 MB

RssShmem 那一路数字很大(合计上千 MB),那是同一个 422MB 共享段在多个进程地址空间里的重复映射,容器只按物理页计一次,不能相加。所以每连接成本约 3.2MB,是空载到 30 连接这段线性区的斜率;跑排序、哈希时还会在它之上叠加 work_mem。辅助进程(postmaster、checkpointer、bgwriter、walwriter、io worker 等 8 个)的私有内存合计 21.6MB,可以当常数。

5. OOM 发生时你会看到什么

先给最反直觉的一条:OOMKilled=true 与 ExitCode=0、Running=true 可以同时成立。

$ docker inspect -f 'running={{.State.Running}} oom={{.State.OOMKilled}} exit={{.State.ExitCode}}' coral-mem-512
running=true oom=true exit=0

docker events 在同一时刻给出 container oom:

1794288224 container oom

容器内的计数器在涨:

$ cat /sys/fs/cgroup/memory.events
low 0
high 0
max 8054
oom 76
oom_kill 1

oom_kill 从 0 变 1 的那一下就是第一次被杀;oom 是内存不足条件被触发的次数,oom_kill 是实际杀掉的进程数,两者不是一回事。

PostgreSQL 日志会点名被杀的是谁:

LOG:  checkpointer process (PID 760) was terminated by signal 9: Killed
LOG:  terminating any other active server processes
LOG:  all server processes terminated; reinitializing
LOG:  database system was not properly shut down; automatic recovery in progress

换成 work_mem 那组,被杀的是 backend,日志把肇事 SQL 一并打出来:

LOG:  client backend (PID 3277) was terminated by signal 9: Killed
DETAIL:  Failed process was running: SET work_mem='256MB'; SELECT count(*) FROM (... ORDER BY md5(filler)) s;

客户端看到的是这两种文本之一:

server closed the connection unexpectedly
terminating connection because of crash of another server process

从被杀到重新接受连接,两次实测分别是 2.71 秒和 2.76 秒(05:28:52.975 → 05:28:55.733)。这三秒里所有连接一起断,应用侧没有重连逻辑的话,表现为一次短促的全站 500。

为什么退出码不是 137

三种配置都没拿到 137:512m 配 384MB 的 shared_buffers 跑 35/40/60/100 条连接、512m 下单条 work_mem=256MB 排序、以及 256m 容器配同样的 shared_buffers 后预热触碰 buffer pool,结果都是 OOMKilled=true、ExitCode=0、容器存活。

顺带一个结果:256m 的容器配 384MB 的 shared_buffers 能正常启动并接受连接,memory.events 的 max 计数涨到 8054(反复撞 limit 后回收),oom_kill 仍是 0;直到把页面读进 buffer pool 才被杀。说明"shared_buffers 大于 --memory 就起不来"这个说法不成立,它起得来,只是碰不得。

137 只在容器主进程(postmaster,PID 1)被 SIGKILL 时出现。postmaster 只映射共享段、不触碰 buffer pool,RssAnon 只有 2MB 出头,被内核选中的概率低于真正 fault 了几百 MB 页面的 checkpointer 和 backend。这是机制推断,没有采集 oom_score 的对比数据。该盯的不是退出码,而是 memory.events 的 oom_kill 和日志里的 terminated by signal 9:容器一直 Up,docker ps 看不出异常,业务却在三秒里全断。

6. 对照组:shared_buffers 换回 128MB

同样的 512m 上限、数据集和负载,只把 shared_buffers 从 384MB 改成 128MB(shared_memory_size 150MB):

连接数 Nmemory.current 峰值docker stats 峰值结果tps
33511.9 MiB366.5 MiB存活14309.8
60511.1 MiB363.1 MiB存活13499.2
100511.3 MiB386.7 MiB存活,0 失败事务13094.3

100 条那组在前台完整跑满 14 秒,184767 个事务、0 失败。

这张表与第 2 节的 memory.current 几乎一样(都贴着 512),但 docker stats 只有 363–387 MiB,而且从头到尾没被杀。区别在于:贴顶的部分是 456MB 数据文件带来的可回收 page cache,内核随时能扔;384MB 那组贴顶的 422MB 是匿名共享内存,无 swap 时扔不掉。两个容器在 memory.current 上一模一样,一个能跑 100 连接,一个 35 连接就死。监控口径因此要改:看 memory.stat 里 anon + shmem 的绝对量,或者直接看 memory.events.oom_kill。

7. work_mem:上限,不是预分配

同一台 512m 容器、shared_buffers=384MB、buffer pool 已预热,单条 SQL:

SET work_mem='256MB';
SELECT count(*) FROM (
  SELECT filler FROM (SELECT filler FROM pgbench_accounts LIMIT 2000000) u
  ORDER BY md5(filler)
) s;

200 万行的排序中间结果约 176MB,1 秒内触发 OOM,日志点名这条 SQL。此时 422MB 共享段 + 22MB 常驻 + 176MB 排序内存,远超 512MB。

把行数降到 20 万(中间结果约 17MB),同样 work_mem=256MB:0.3 秒完成,memory.current 494.8 MiB,oom_kill 无变化。两条 SQL 的区别只在数据量。work_mem 是每个排序或哈希节点在写临时文件之前可用的上限[1],不会预先分配,设成 256MB 只代表"允许用到 256MB"。

三个放大它的乘数都在官方文档里[1]:一条复杂查询可以同时有多个排序/哈希节点,各自用满 work_mem;哈希类操作的上限是 work_mem × hash_mem_multiplier,默认乘 2.0;并行查询的每个 worker 单独计算。所以 max_connections=100 配 work_mem=256MB 的理论峰值是 25.6GB 量级,不是 256MB。在 512m 容器里,384MB 的 shared_buffers 之后剩下给排序的只有几十 MB,把 work_mem 设到 256MB 等于给每个连接发一张必定透支的信用卡。

8. 按 --memory 反推 shared_buffers 上限

把第 1、4 节的实测常量固定下来:

k = 共享内存段 / shared_buffers ≈ 1.10     (384MB→422MB)
B = 辅助进程私有内存 ≈ 22 MB               (实测 21.6MB)
C = 每连接私有内存 ≈ 3.2 MB                (30 条连接实测)

给定容器上限 M 和要支撑的连接数 N:

shared_buffers ≤ (M − B − N × C) / k

验算对照组,M=512、N=100:(512 − 22 − 320) / 1.10 = 154.5 MB。理论 154MB,实测 128MB 稳过、384MB 只撑 33 条,量级对得上。实际取值再打七折留余量,约 108MB,128MB 已经偏乐观。

反过来估算能撑多少连接:

N_max ≈ (M − k × shared_buffers − B) / C

M=512、shared_buffers=384MB 时 (512 − 422 − 22) / 3.2 = 21 条。公式给 21,实测 33,差额的 12 条来自可回收 page cache(约 40MB),属于"能借用但不该依赖"的余量。把公式值当安全线、实测值当上限,这个用法是合理的。

几个常见规格的推算(未打七折,属于上限):

--memory100 连接200 连接
512m≈ 154MB装不下(连接本身就要 640MB)
1g≈ 620MB≈ 329MB
2g≈ 1551MB≈ 1260MB

512m 那一行说明:这个档位上 200 条连接不是"shared_buffers 要调小"的问题,是 200 × 3.2MB = 640MB 的连接开销本身已经超过整个容器上限。

9. 上线前的检查清单

检查项做法
shared_buffers 用公式反推(M − 22 − N×3.2) / 1.1 再打七折,不按物理内存 25% 套
空载读数不作数先跑一轮真实查询把 buffer pool 填满,再读内存
监控看 anon + shmemmemory.current 与百分比在贴顶时没有分辨力
告警挂 oom_kill/sys/fs/cgroup/memory.events 里它涨了就是真事
work_mem 按并发算总账单条 work_mem × 节点数 × 并行度 × hash_mem_multiplier
保留监控连接位max_connections 打满时第 101 个连接报 FATAL: sorry, too many clients already
应用侧重连OOM 后恢复窗口实测 2.7 秒,期间所有连接一起断

最后一条是踩出来的:max_connections=100 加 100 个 pgbench 客户端时,我的 psql 探针直接被拒(全员 superuser 时 superuser_reserved_connections 也救不了),后来改成数 ps 的进程数才拿到数据。

小结

512MiB 容器配 384MB 的 shared_buffers,在 pgbench 只读负载下的真实容量是 30 条连接上下:33 条可以重复通过,34 条开始随机被杀,35 条起每轮都挂。shared_buffers 实际占 422MB 共享内存段,每连接私有内存 3.2MB,两者相加在 512MB 里只剩几十 MB 周转空间,靠回收 page cache 硬撑。这个空间在空载时完全看不出来——容器刚起来只占 48MiB,预热之后直接跳到 510MiB。

OOM 的表现和常见预期不同:docker inspect 给出 OOMKilled=true,但退出码是 0、容器仍是 Up,被杀的是 checkpointer 或 backend,postmaster 重建集群,客户端在 2.7 秒的窗口里全部断开。三种配置都没能复现 137,因为 postmaster 自己不碰 buffer pool,很难成为内核的目标。判断有没有出事,看 memory.events 的 oom_kill 和日志里的 terminated by signal 9 比看退出码可靠。

容量算法可以归结为一条:shared_buffers ≤ (M − 22 − N×3.2) / 1.1。512m 撑 100 条连接时它给出 154MB,实测 128MB 可行、384MB 只能撑 33 条;想让 100 条连接稳定跑在 512m 里,shared_buffers 得压到 100MB 上下,而不是按"物理内存的 25%"去配。

数据来源与检索时间

检索时间统一为 2026-11-10(Asia/Shanghai)。

编号来源URL对应数据
[1]PostgreSQL 18 官方文档《Resource Consumption》https://www.postgresql.org/docs/18/runtime-config-resource.htmlshared_buffers 默认 128MB、1GB 以上内存取 25%、超过 40% 通常不会更好、小于 1GB 应取更小比例;work_mem 默认 4MB、是每个排序/哈希节点写临时文件前的上限、多节点多会话叠加后总量是它的很多倍;hash_mem_multiplier 默认 2.0;maintenance_work_mem 默认 64MB;并行 worker 各自计算 work_mem;shared_memory_type 默认 mmap
[2]Docker 官方文档《Resource constraints》https://docs.docker.com/engine/containers/resource_constraints/--memory 为硬限制;--memory-swap 与 --memory 相等即无 swap;默认 OOM 时内核杀容器内进程;容器内 free 显示宿主机内存,不能用来判断容器余量
[3]PostgreSQL 18 官方文档《Managing Kernel Resources》https://www.postgresql.org/docs/18/kernel-resources.html共享内存等内核资源章节;三次抓取均只返回页面导航,正文未取到,文中未引用其具体数值

核验说明:

  • 第 1–7 节的容器读数、memory.current、memory.stat、memory.events、docker inspect、docker events、PostgreSQL 日志文本、tps 与耗时,都是 2026-11-10 在本机 Docker Desktop 上的实测原始输出;shared_memory_size 与 pg_shmem_allocations 为容器内查询结果。
  • 第 4 节的 3.2MB 来自 /proc/<pid>/status 的 RssAnon 求和(空载 21604 kB → 30 连接 119524 kB),不含共享页;ps 的 RSS 会因共享段重复映射而失真。smaps_rollup 在容器内因 ptrace 权限读不到,没有采用 Private_Dirty。
  • 第 8 节的公式由实测常量(k=1.10、B=22MB、C=3.2MB)推导,不是官方推荐值;公式偏保守,实测上限比它高 12 条。表中 1g、2g 两行是纯推导,本机未跑对应实验。
  • 第 3 节"34 条一次过、一次挂"是两次独立运行的真实差异,边界不是硬阈值;35 条及以上共 6 次运行全部触发 OOM。
  • 137 未复现是实测结论:三种配置下 ExitCode 均为 0、容器均为 Running。关于 postmaster 难以成为 OOM 目标的解释属于机制推断,未采集 oom_score 对比。
  • 所有绝对数值来自单机 WSL2 + Docker Desktop(cgroup v2、无 swap)。换环境临界连接数会变,但"共享内存段 + 每连接私有内存"的预算关系不变。[3] 正文未抓取成功,引用其具体条款前应另行核对。