从shared_buffers说起,是很多PostgreSQL调优文章的共同套路,但真正把缓冲机制搞明白的人,往往不会只盯这一个参数。我这些年处理过不少“慢查询”和“偶发尖峰”,最后定位下来都和缓冲路径上某个不起眼的设置有关。缓冲机制是PostgreSQL性能的核心之一,但它不是一个参数能概括的,而是一条贯穿读、写、淘汰、落盘的完整链路。这篇内容适合两类人:一类是刚接手生产库、准备调参又怕翻车的DBA,另一类是写SQL时总被莫名慢查询困扰的开发。我会把从参数选型到问题排查的完整思路拆开讲,每条经验都是我在这条路上亲手踩出来的。
1. 缓冲机制全景:为什么说shared_buffers只是冰山一角
缓冲机制这个词听起来很底层,但你换个角度想:它其实就是数据库自己的“前台和后台”。前台负责快速响应查询请求,后台负责把结果和中间数据暂时放在内存里,让下次再要的时候不用去磁盘翻箱倒柜。PostgreSQL的缓冲体系分两层,很多性能瓶颈都出在这两层之间的协调上。
1.1 读路径上的两级缓存
先看读路径。一条查询发过来,如果涉及的数据已经在shared_buffers里,直接内存读,这是最快的。如果没在shared_buffers里,PostgreSQL不会直接去磁盘,而是先发一个读请求给操作系统。操作系统会把文件内容读进自己的页缓存(Page Cache),然后把数据拷到shared_buffers的页里返回给查询。
这就形成了一个两级缓存结构:shared_buffers是数据库自己管理的一级缓存,操作系统页缓存是第二级。两层缓存配合得好,绝大部分读操作都碰不到真实磁盘。但配合得不好,就会出现一种很折磨人的现象:明明系统内存还剩很多,数据库的命中率也高,可整体RT就是不稳。
原因是操作系统并不知道哪些页是数据库真正热的数据。它按自己的LRU逻辑淘汰页缓存,数据库想要某个块时可能被迫先触发一次磁盘读。反过来,数据库自己维护的shared_buffers如果太大,又会挤压操作系统页缓存的空间,导致文件读写缺少二次缓冲。这是一个需要平衡的博弈,不是把内存全塞给数据库就完事。
一个很直观的类比:shared_buffers像是你自己桌面上的常用文件架,操作系统页缓存像是办公室公共文件柜。文件架够大,你拿东西快;但文件柜如果小到连换手的地方都没有,你从架子上取文件时别人可能等着你用柜子。两者缺一不可。
1.2 写路径上的两次排队
写路径同样分两层。业务执行的UPDATE和INSERT,先是修改shared_buffers里的数据页,同时生成WAL日志放进wal_buffers。wal_buffers积攒到一定程度或者事务提交时,WAL日志会刷到磁盘——这保证了崩溃恢复能力,但与数据页落盘是分开的。
真正把脏数据页写回磁盘的,是后台写进程(bgwriter)和检查点进程(checkpointer)。bgwriter平时会慢慢把脏页刷出去,目的是让检查点来临时不用一次写一大堆。检查点则是定期把所有脏页刷盘,并更新检查点位置,保证崩溃后能从WAL里的某个位置开始恢复。
这一层设计很容易被忽略,但它决定了一个关键指标:写入抖动。如果bgwriter刷得太少,检查点来临时就会产生巨大的IO尖峰,导致整个数据库卡顿。如果bgwriter刷得太勤,又可能让磁盘忙于写一些很快又被修改的页,白耗IO。
我调过的某仓库系统,一开始经常在整点出现“卡死”,排查了好几天才发现是检查点触发的批量写盘和业务高峰撞在一起。后来调整了检查点相关参数,抖动立刻缓和。这说明写路径上的排队策略,和读路径上的命中率一样重要。
1.3 没有缓存时会发生什么
有些人觉得,内存反正不够,让操作系统多缓存一点不就行了?其实没有缓存或缓存严重不足时,整个数据库的行为会变得非常难预期。
首先,每一个数据块都要去磁盘读,单次延迟轻松上到几毫秒甚至几十毫秒。其次,shared_buffers里的页会被频繁淘汰和换入,这个过程中需要争夺缓冲区的管理锁,并发一高就会看到CPU在锁等待上疯狂空转。再有,即使操作系统页缓存能兜底,数据库每读一个新块都要做一次页映射、状态初始化,这部分开销在千万级行的大表扫描时会非常明显。
所以缓冲机制不是简单的“内存换速度”,它决定了稳定性和并发上限。理解了全景,你才能理解为什么后文每个参数都不是孤立存在的。
2. 影响最大的三个内存参数:shared_buffers、wal_buffers、huge_pages
参数调优不是把百度出来的推荐值一填就完事。PostgreSQL每个内存参数背后都有明确的机制支撑,搞懂机制,你才敢根据业务做偏移。这一节我讲三个在缓冲链路里最常被调、也最容易被调错的参数。
2.1 shared_buffers:25%不是金科玉律
shared_buffers是PostgreSQL自己管理的共享缓冲区大小,默认只有128MB。很多教程会说“设为物理内存的25%”,这个比例有它的历史依据,但放到现在并不总是最优。
这个参数不是越大越好,原因有三:
- shared_buffers占用的内存是真实常驻内存,而且有额外的管理结构。设太大会导致系统内存紧张,触发OOM风险。
- shared_buffers和操作系统页缓存之间有数据拷来拷去的过程。如果shared_buffers太大,留给操作系统的页缓存空间变小,一些流式读取和批量导出反而会更慢。
- 缓冲区增大后,管理代价和锁竞争也会增加。尤其在高并发场景,所有进程都要在缓冲区的控制结构上竞争,过大反而可能出现“large shared_buffers is not always faster”的情况。
我一般推荐的起点是物理内存的15%到25%,然后根据实际脏页比例和命中率去微调。关键不是一次到位,而是观察一个完整业务周期内的表现。
PostgreSQL 15之后shared_buffers的参数上限变得很高,但我不建议你在没有监控手段的情况下盲目翻倍。更稳妥的做法是:用一个小脚本周期采shared_buffers里的脏页数量,如果发现脏页占比经常超过20%,说明后台刷脏跟不上,要么降低shared_buffers,要么加强bgwriter的刷新强度。
2.2 wal_buffers:设置多少才能不拖累commit
wal_buffers是WAL日志在写入磁盘之前的缓冲区。它和shared_buffers不同,WAL日志是顺序写的,所以wal_buffers不需要很大。默认在shared_buffers大于1MB时,wal_buffers会自动取shared_buffers的1/32,但一般生产环境我会直接指定为一个固定值,通常是16MB到64MB。
为什么这个参数重要?因为事务提交的延迟和它高度相关。每当事务提交时,会把wal_buffers里的日志刷到磁盘。如果wal_buffers设置太小,高并发下很容易出现事务之间互相等待刷盘,commit延迟就会飙升。但设置太大也不会带来额外收益,因为WAL日志刷盘的主要瓶颈是磁盘fsync的延迟,而不是缓冲区的容量。
实际调这个参数时,我见过最多的问题其实是操作系统层面的:WAL所在的磁盘如果是机械盘,或者跟数据文件共用一块盘,无论wal_buffers怎么调都白搭。WAL性能问题,先看盘,再看参数。
一个小经验:如果你看到数据库日志里频繁出现“took too long to flush”之类的现象,先查WAL所在设备的IO延迟。wal_buffers只是缓冲,不是加速器,它没办法帮你对抗物理磁盘的慢。
2.3 huge_pages:TLB miss不是玄学
huge_pages这个参数被很多人忽略,但我实际体验下来,在内存大的机器上,它带来的收益非常明显。PostgreSQL的shared_buffers如果分成大量4KB的小页,CPU的TLB(快表)就会频繁失效。每次内存访问都要多次查页表,这在单次操作里微不足道,但累积到每秒几十万次数据块访问时,性能差异就出来了。
Linux下开启大页,需要同时设置操作系统层面的vm.nr_hugepages,再把PostgreSQL的huge_pages参数设为try或on。设置为on时更严格,如果启动时申请不到大页,数据库会直接拒绝启动;设置为try则允许fallback到普通页。
我有一次优化一个高并发OLTP系统,把shared_buffers设到32GB后,发现CPU的sys占用率始终偏高。后来打开huge_pages后,sys时间明显降下来。原因是TLB命中率提升,缓冲区的地址翻译开销变小了。
需要注意,不是所有云主机都支持大页。部分虚拟化实例中,大页内存的分配可能受到限制,或者需要额外配置。你在生产环境开huge_pages之前,一定要先在测试环境验证启动和运行稳定性。
3. 后台写进程与检查点:脏页如何落盘
内存参数只是缓冲机制的上半场,下半场是脏页落盘。很多人把shared_buffers调完就觉得大功告成,结果一跑业务,数据库每隔几分钟就剧烈抖动一次,查下来基本上都是后台写进程和检查点没配合好。
3.1 bgwriter的LRU扫描与multiplier
bgwriter做的事情是:在检查点之间,按一定频率扫描共享缓冲区,把那些被修改过又不是太热的脏页提前写回磁盘。它的工作强度由两个参数决定:bgwriter_delay和bgwriter_lru_maxpages。
bgwriter_delay默认是200ms,如果业务写入量大,可以把延迟调低,比如调到100ms,让bgwriter更勤快地干活。bgwriter_lru_maxpages默认100,表示每一轮最多写100个脏页。如果脏页生成速度远大于这个数,就会积压到检查点触发大刷盘。
这里还有一个容易被忽视的bgwriter_lru_multiplier,默认2.0。它不是控制“写多少个页面”,而是控制bgwriter根据过去几轮脏页产生速率来动态估算本轮要写多少个页面,并乘以这个系数。调大它,可以让bgwriter更激进地刷脏,降低脏页峰值。但调得太大也会让磁盘在低峰期白白消耗在重复刷页上。
我实际调参时会盯着两个指标:shared_buffers里的脏页占比,以及checkpoint_timeout触发前是否有大量积压。如果脏页占比长期在20%以上,优先调大bgwriter_lru_maxpages和bgwriter_lru_multiplier,而不要急着加大shared_buffers。
3.2 checkpoint_timeout与max_wal_size的博弈
检查点间隔由checkpoint_timeout和max_wal_size共同决定,触发条件是“先到先得”:时间超过checkpoint_timeout,或者产生的WAL量超过max_wal_size。
checkpoint_timeout默认是5分钟,这个值在写入比较平稳的系统上很合适,但在写入有波峰的场景下,会让检查点频繁落在业务高峰。我的习惯是把它调大到10分钟甚至15分钟,同时把checkpoint_completion_target设为0.9,让检查点刷盘过程更平缓。
max_wal_size的默认值是1GB。如果设得太小,业务一忙就可能频繁触发检查点。设得太大,意味着两次检查点之间可能会累积非常多的WAL,崩溃恢复时会变慢。这里的关键是找到一个适合业务写入速度的平衡点。
我处理过一套订单系统,高峰期WAL生成速度约80MB/s,max_wal_size只有1GB,结果每十几分钟就触发一次检查点,每次IO都顶满。把max_wal_size调到8GB后,检查点频率降了下来,但需要确保磁盘和崩溃恢复时间都可以接受。
3.3 顺序扫描和ring buffer机制
除了后台写,缓冲区淘汰策略同样影响落盘。PostgreSQL在做顺序扫描的时候,并不是把读过的每个块都留在shared_buffers里。它使用了一个叫ring buffer(环形缓冲区)的小区域,大小只有256KB左右,用来临时存放顺序扫描读到的页面。
这个设计我很喜欢,因为它避免了顺序扫描把真正热的数据挤出缓冲区的经典问题。想象一个数据仓库跑每天的全表统计,如果没有ring buffer机制,内存里存的全是扫描过的冷数据,业务热数据全被挤出去,命中率会瞬间掉到谷底。
有一点容易被忽略:ring buffer的使用场景不止顺序扫描,还包括VACUUM、COPY和pg_restore这类操作。这也是为什么你在做一个超大表的pg_dump之后,线上业务会突然变慢,因为大量顺序IO占用了缓冲区资源。理解了这一点,你就知道为什么生产库要尽量避免在业务高峰期跑大范围全表操作。
4. effective_cache_size与计划器:缓存参数的另一处战场
缓冲机制不只在运行时起作用,它还会通过参数影响查询计划器的“判断”。这里的核心参数是effective_cache_size,以及和它联动的random_page_cost、seq_page_cost。如果你只把目光锁定在shared_buffers,很容易漏掉这个层面。
4.1 effective_cache_size不是缓存大小
很多人一看到effective_cache_size,就误以为是PostgreSQL能够使用的内存大小,然后照着shared_buffers去设置。实际上,这个参数只是用来告诉查询计划器:操作系统层面大概还有多少缓存空间可用。它不会实际分配内存,只会影响优化器对索引扫描和顺序扫描的成本估算。
如果effective_cache_size设置得偏低,计划器会认为从磁盘读取一个随机块很贵,进而高估索引扫描的成本,可能把明明适合走索引的查询改为顺序扫描。设置得偏高,则可能导致计划器过度乐观,选择索引扫描,结果发现大量回表使IO更高。
合理设置一般是物理内存的75%左右。注意,这个“物理内存”指的是数据库服务器可用的内存总量,不是专门给PostgreSQL的内存。如果机器上有其他大内存应用,要扣除。把effective_cache_size设大一些通常没有副作用,因为它只是估算值。
4.2 random_page_cost与seq_page_cost怎么配合
random_page_cost默认是4,表示计划器认为一次随机读的代价是顺序读的4倍。这个默认值来自机械硬盘时代。如果你的数据大部分都在SSD或内存中,这个比值就太夸张了,会导致计划器严重偏向顺序扫描,哪怕索引扫描明显更快。
我见过一个典型的案例:某报表系统在机械环境下跑得好好的,迁移到全闪存环境后,几条核心查询却从索引扫描退化成了全表扫描。排查时发现,random_page_cost还是默认的4。把random_page_cost调到1.1到1.5之间后,计划器立刻重新选择了索引路径。
但这里有个坑:random_page_cost改动之后要观察整个库的执行计划变化,不能只看一两条SQL。有些时候,调低它会让某些本应走顺序扫描的查询也去走索引,反而产生大量随机读。建议在测试环境对比调整前后有代表性的SQL执行计划,再推广到生产。
effective_cache_size和这两个cost参数是配合使用的。effective_cache_size越大,计划器会认为随机访问命中缓存的概率越高,因此random_page_cost对决策的影响会变小。它们共同决定了计划器对“访问路径成本”的判断,最终影响你看到的每个SQL执行计划。
5. 监控与问题定位:从命中率到pg_buffercache
参数调完不能拍脑袋说“生效了”,你得有数据支撑。PostgreSQL提供了一些非常有用的视图和扩展,用来观察缓冲区的状态、命中率和页面分布。这一节分享我实际工作中最常用的监控手法。
5.1 二级命中率怎么统计
数据库层面的缓存命中率,通常用pg_stat_database里的blks_hit和blks_read计算:
select blks_hit::numeric / (blks_hit + blks_read) as hit_ratio from pg_stat_database where datname = current_database();这个数字代表从shared_buffers中直接命中的比例。如果命中率低于95%,说明有不少读操作穿过shared_buffers去访问操作系统页缓存甚至磁盘。但要注意,它不是全部真相。因为blks_read代表的是“从shared_buffers中没有读到,需要底层文件系统读取”的块,其中很多可能仍然命中OS页缓存。
所以更完整的监控还要看OS层面的缓存命中率,比如用cachestat或perf去观察page cache的命中情况。很多数据库命中率看着挺高,但实际RT依然抖,问题往往就出在OS页缓存这一层。两级缓存需要同时看,不能只看数据库自己的指标。
5.2 用pg_buffercache找出谁占着缓存
PostgreSQL提供了一个扩展模块pg_buffercache,能让你直接看到shared_buffers里每个页面属于哪张表、哪个索引、是否是脏页、使用计数是多少。
启用方式:
create extension pg_buffercache;然后可以按表统计缓冲区占用:
select c.relname, count(*) as buffers, count(*) * 8 as kb_used from pg_buffercache b join pg_class c on c.relfilenode = b.relfilenode group by c.relname order by buffers desc limit 20;这个查询在定位“缓存污染”时非常有用。有一次我发现shared_buffers被大量堆积的临时表占满,按这张表一查,果然是一个低频但数据量巨大的批量任务在作祟。用这个工具还能看usagecount分布,如果发现绝大多数buffer的usagecount都在1左右,说明热数据太少,缓存的命中率很难上去。
pg_buffercache需要超级用户权限,而且要小心频繁查询它本身也有开销,生产环境建议低频采样,比如每分钟一次,不要在高峰期频繁执行。
5.3 常见故障现象与调优路径
我总结了一些常见的缓冲相关故障信号和对应的排查思路,列在下面:
| 现象 | 可能原因 | 排查手段 | 调整方向 |
|---|---|---|---|
| 缓存命中率跌破90% | shared_buffers过小或热数据被冷数据挤出 | 用pg_buffercache查占用表 | 适当增大shared_buffers或排查大扫描任务 |
| 每次checkpoint都伴随IO尖峰 | bgwriter刷脏能力不足或checkpoint过于频繁 | 观察脏页占比和checkpoint时间线 | 调大bgwriter_lru_maxpages,调大max_wal_size |
| 查询计划偏爱顺序扫描 | random_page_cost过高或effective_cache_size过低 | explain analyze对比cost | 根据存储类型调整random_page_cost |
| 高并发下CPU sys占用高 | TLB miss严重或锁竞争 | 开启huge_pages前后对比 | 启用大页,必要时调整shared_buffers大小 |
| 大面积冷查询后线上RT上升 | ring buffer机制被大扫描打穿 | 观察命中率随时间变化 | 避免高峰期大扫描,必要时控制并发 |
6. 实操清单:我这几年调缓冲参数的顺序和坑
前面讲了机制和参数,但真实生产环境的调参不是照着文档就能复制成功的。最后分享一套我自己沉淀下来的调优顺序、几个踩过的坑,以及不同场景下的参考值。
6.1 我踩过的坑
第一个坑是shared_buffers调得太大。早年在一台内存128GB的机器上,我把shared_buffers直接设到64GB,结果发现不仅没有性能提升,反而出现了内存分配迟缓。后来分析才知道,共享内存申请和缓冲区管理本身也有开销,而且64GB的shared_buffers把操作系统的页缓存空间挤得太狠,一些需要走文件系统缓冲的备份操作变得非常慢。
第二个坑是忽略checkpoint_completion_target。单独调大max_wal_size只是延长了检查点间隔,但检查点一旦触发,如果completion_target太小,所有脏页还是会短时间内集中写盘,IO尖峰依然存在。我一开始没调这个参数,检查点频率降下来了,尖峰却没多少改善。
第三个坑是拿默认的random_page_cost直接跑SSD。线上一个查询从索引变全表扫描,排查半天才意识到存储已经换成了SSD,而random_page_cost还是4。这条我在前文已经提过,但它值得反复强调:换存储介质后,代价参数一定要重新评估。
第四个坑是没在大版本升级后重新评估参数。PostgreSQL从11升到15,很多默认值其实已经变化,比如shared_buffers的管理方式、checkpoint相关机制都有调整。直接沿用旧配置,有时候反而不如新版默认值表现好。
6.2 一套可落地的调优流程
我现在调缓冲相关参数,基本按下面这个顺序来:
- 先确认数据库版本和操作系统版本,记录当前所有相关参数。
- 用pg_stat_database和pg_stat_statements采集一周的基线数据,重点是命中率、检查点频率、脏页占比和慢查询分布。
- 在测试环境进行参数分组修改,每次只动同一条链条里的参数,避免多个变量同时变化。
- 把shared_buffers先设到物理内存的20%,观察一个业务周期的脏页占比,再决定是否上修或下调。
- 根据WAL生成速率和检查点触发间隔,调整max_wal_size和checkpoint_timeout,让检查点尽量落在业务低峰。
- 用explain analyze对比一组代表性SQL,根据执行计划决调random_page_cost和effective_cache_size。
- 如果机器支持大页,在测试环境开启huge_pages,对比CPU sys占用和整体RT。
这套流程的核心是“每个改动有明确的观察指标”,而不是拍脑袋一次改五个参数。
6.3 不同场景参考值
不同业务的内存参数参考值差异很大,这里给一个普适的起点型配置表。它不适用于极端昂贵的超融合环境或内存特殊的嵌入式系统,但可以作为大多数常规Linux部署的初始参考。
| 参数 | 小型OLTP(4G内存) | 中型OLTP(32G内存) | 数据仓库(128G内存) |
|---|---|---|---|
| shared_buffers | 768MB | 6GB | 24GB |
| wal_buffers | 16MB | 16MB | 64MB |
| effective_cache_size | 3GB | 24GB | 96GB |
| random_page_cost | 使用SSD时可调为1.1 | 使用SSD时可调为1.1 | 机械盘默认4,SSD调低 |
| checkpoint_timeout | 5~10min | 10min | 15min |
| max_wal_size | 2GB | 8GB | 32GB |
| huge_pages | try | try | on |
这个表不是终点,只是起点。真实业务的写入模型、读写比例、冷热数据分布都会让参数产生偏移。我见过一个核心在线交易库,内存只有16GB,但shared_buffers调到2GB就够了,因为它的热数据量极小、命中率高、磁盘很快。反而是一个BI分析库,内存64GB,因为大表扫描极多,我刻意把shared_buffers控制在8GB,给操作系统页缓存留出更多空间,整体效果反而更好。
最后分享一个我自己的小习惯:每次调完缓冲参数,不要急着看一两个小时的性能曲线,先看24小时完整业务周期里的检查点时间分布、脏页水位和慢查询数量。缓冲机制的调整效果往往是缓慢显现的,不是跑一次基准测试就能看出优劣。尤其是那些写入峰值型系统,参数改动是否合理,要等下一个业务高峰来验证。只有把“参数-机制-业务特征”三者串起来看,调优才不会变成玄学。