很多人把PostgreSQL调优想复杂了,一遇到性能问题就盯着参数改来改去。我做了几年PostgreSQL运维和优化,说实话,绝大多数I/O性能问题并不是某个参数能解决的。真正要做的是先看清数据是怎么从磁盘到内存、再从内存回到磁盘的,之后你才知道哪些环节该调、哪些环节动了反而更糟。
这篇不是教科书式地讲原理,我把架构、排查思路和实际调优都揉在一起说,尽量用我能表达得最直白的方式。建库、跑过生产、被慢查询折磨过的人,应该都能从中找到点有用的东西。
1. PostgreSQL的I/O架构到底是什么样
1.1 先看懂数据请求的最短路径
PostgreSQL的I/O链路不算复杂,但很多人没把它串起来。
一条SELECT语句进来之后,请求会经过解析、优化、执行几个阶段,最终落到存储引擎部分。PostgreSQL的存储层核心就是缓冲池。缓冲池(shared_buffers)是所有后端进程共享的内存区域,数据页的读和写都要经过这里。
具体流程是这样:一个查询要读某张表的一个数据页,如果这个页已经在shared_buffers里,直接内存命中就完事了;如果没命中,系统才会去操作系统文件缓存里找,再不行才真正发起磁盘I/O。也就是说,每次物理读都意味着前面两层都没接住。
写入路径更值得关注。UPDATE和DELETE在PostgreSQL里不会改原页,而是标记旧版本并写入新版本,这被称为MVCC机制。所有变更都会先生成WAL日志,WAL记录会被写入WAL缓冲区,在事务提交时刷到磁盘。数据页本身则不一定立即落盘,而是由后台进程或检查点统一刷出。
这里有个极容易忽略的点:很多人以为写了事务就马上写数据文件了,其实不是的。数据页可能先在shared_buffers里待很久,由bgwriter进程在后台慢慢写,或者等检查点来一次性刷。真正同步写盘的只是WAL日志。理解这个顺序,你就明白为什么PostgreSQL的写入性能跟WAL配置关系那么大。
1.2 WAL和检查点决定了刷盘的节奏
WAL的全称是Write-Ahead Logging,中文是预写日志。它的设计逻辑很直接:先记日志,再改数据。日志写到磁盘之后,事务就可以安全提交了,数据页可以晚点再刷。
这就像你写日记,先在小本子上记一笔"今天做了什么事",晚上再统一整理到正式本子里。哪怕正式本子丢了几页,只要有小本子,还能找回那几页内容。
检查点(Checkpoint)就是那个"晚上整理"的动作。PostgreSQL到了检查点位置,会把这个时间点之前的所有脏页全部刷到磁盘,然后更新控制文件的检查点位置。崩溃恢复的时候,就从最新的检查点位置开始,把WAL重放到最后。
检查点的触发受几个参数控制:checkpoint_timeout是两个检查点之间最大的时间间隔,默认5分钟;max_wal_size是触发检查点的WAL量阈值,默认1GB。
如果checkpoint_timeout设得太小或max_wal_size设得太小,检查点就会非常频繁。每次检查点都要刷大量脏页,造成I/O尖峰。这就是生产环境中常见的"每隔几分钟数据库卡一下"的典型原因。
很多DBA的惯性思维是尽量频繁刷盘,觉得这样更安全。但PostgreSQL的设计哲学不是这样。它默认让脏页累积到一定程度再刷,配合full_page_writes机制来保证崩溃恢复时的一致性。这个机制在检查点后的第一次修改时,会把整个数据页写到WAL里,确保不会因为刷盘中断丢失半页数据。
1.3 VACUUM为什么也会吃I/O
这和前面讲的MVCC机制直接相关。PostgreSQL的UPDATE会同时产生一个新元组和一个死元组,DELETE则只产生死元组。这些死元组会一直留在数据页里,直到VACUUM来清理。
VACUUM进程要做的事包括:扫描表、标记可清理的死元组、更新可见性映射表(visibility map)、用清理后的空间建立空闲空间映射(free space map)。这一套动作本身就是扫描全表的I/O操作。
更麻烦的是,如果表上有大量更新但没有及时VACUUM,死元组越积越多,表的膨胀就开始了。同一个页里只有一小部分是有效数据,读取效率大幅下降,索引也变得臃肿。此时即使查询逻辑没问题,也会触发大量不必要的物理读。
在PG 13之前,VACUUM是单进程逐个表跑的。如果表很大又很多,一轮VACUUM可能跑很久,期间的I/O会持续占用磁盘。PG 13之后有了并行VACUUM,但每次最多只能并行处理一个表的不同索引部分,全表扫描部分依然是单进程。
这里提醒一下,很多教程喜欢让你用autoanalyze和autovacuum的默认配置直接跑,但生产环境里最好把autovacuum_naptime调短一点,把autovacuum_vacuum_scale_factor从小数改成基于行数的阈值。比如说,大表1000万行,默认0.2意味着200万行变化才触发VACUUM,太晚了。改成autovacuum_vacuum_threshold=5000配合scale_factor=0.05,节奏会合理很多。
2. 别急着调参数,先判断是不是I/O的问题
2.1 系统层面的观测手段
一上来就改shared_buffers和work_mem,这是新手最爱干的事。但很多时候真正的瓶颈根本不在数据库参数上。
先看I/O压力。Linux下用iostat -dx 1持续观察,重点看%util、读写的IOPS、以及平均请求大小(svctm和aqu-sz这些参考一下就行,不必过度解读)。如果%util长期接近100%,说明磁盘确实忙不过来了。但有个坑:%util在SSD上经常是虚高,因为NVMe盘的响应时间极短,%util的计算方式对它们不完全适用。更好的指标是观察实际IOPS和带宽,再看平均等待时间。
还要看是不是内存层面就已经很吃力了。free -g看看内存占用,如果可用内存长期偏低,Page Cache命中率就上不去,物理读自然多了。这往往不是数据库的问题,而是机器上跑了太多东西。MySQL和PostgreSQL混部在同一台机器上,内存互相抢,这种情况下调数据库参数没什么意义。
2.2 数据库内部怎么确认I/O瓶颈
PostgreSQL内置的统计视图要比外部工具靠谱得多。
先看这一条SQL:
SELECT datname, pg_size_pretty(pg_database_size(datname)) AS db_size, tup_returned, tup_fetched, blks_read, blks_hit, CASE WHEN blks_hit + blks_read = 0 THEN 0 ELSE round(blks_hit * 100.0/(blks_hit + blks_read), 2) END AS cache_hit_ratio FROM pg_stat_database;blks_hit是共享缓冲区命中的次数,blks_read是从磁盘读取的次数。cache_hit_ratio在99%以上说明缓存还不错,但注意,这不代表I/O就没问题了。因为blks_read只统计共享缓冲区的未命中,系统文件缓存(Page Cache)命中的读取不计在内。也就是说,哪怕你数据库99%的缓存命中,剩下的1%物理读如果分布不均匀,某些慢查询依然会被拖死。
再继续看I/O时间在哪里。PG 13之后可以直接查pg_stat_io视图,它按后端类型统计了read和write的字节数、I/O耗时。postgres 16的pg_stat_io又细分了backend_type,比如client_backend、bgwriter、checkpointer、autovacuum等,能直接看出是哪个角色在消耗I/O。
SELECT backend_type, op_type, count, total_exec_time FROM pg_stat_io ORDER BY total_exec_time DESC;如果checkpointer的写入占比极高,检查点就是主犯。如果是autovacuum的read耗时高,那VACUUM节奏有问题。如果是client_backend的read特别多,恭喜你,找到慢查询了。
2.3 用EXPLAIN分析语句级别的I/O
统计视图告诉你系统整体什么状态,但定位到具体语句还要靠执行计划。
EXPLAIN (ANALYZE, BUFFERS, FORMAT TEXT) SELECT * FROM orders WHERE user_id = 123;BUFFERS选项会输出实际读了多少个shared buffer和本地buffer,shared hit和shared read分别多少。shared read数高就说明这条语句在大量读物理磁盘。
更精细的可以用pg_stat_statements扩展。它把每条SQL的调用次数、总耗时、块读写次数、行数都记录下来了,按总耗时排序就能找出最拖后腿的语句:
SELECT query, calls, total_exec_time, mean_exec_time, rows, shared_blks_read FROM pg_stat_statements ORDER BY total_exec_time DESC LIMIT 10;我一般先看shared_blks_read排名靠前的,再逐个EXPLAIN。因为shared_blks_read高意味着这些语句是真正的物理读大户,把它们优化好后整个库的I/O都会改善。
2.4 关键:区分顺序扫描和索引扫描
优化语句的时候,很多人只看执行计划里有没有用上索引。但有一种情况很坑:明明走了索引,但依然I/O很高。这是因为索引扫描如果涉及大量行,PostgreSQL会根据随机访问成本跟顺序访问成本的比值(random_page_cost和seq_page_cost)来判断。
如果优化器估算出来,返回的行数占总行数的比例超过某个阈值,它会放弃索引,转而选择全表顺序扫描。这个阈值很大程度上被random_page_cost控制。默认random_page_cost=4.0意味着随机读比顺序读贵4倍,这是为了机械硬盘设计的。如果你用的是SSD,顺序读和随机读的差距远没那么大,保持默认值会让优化器过度害怕索引访问,本可以用索引的查询变成了全表扫描。
实际中把random_page_cost调到1.1~1.4,对SSD上的PostgreSQL效果非常明显。但也不能太激进,调到1.0就等价于把顺序读和随机读完全等同,有些场景下优化器反而会产生奇怪的执行计划。
3. 核心参数调优,按数据生命周期逐步优化
3.1 shared_buffers:不是越大越快
shared_buffers是PostgreSQL自己的缓冲区。传统经验是设为物理内存的25%左右,但那是很早以前的说法。现在机器内存动不动64G、128G,如果直接取25%,很可能有副作用。
原因在于PostgreSQL的缓冲池管理机制和操作系统的Page Cache是两套体系。shared_buffers越大,PostgreSQL自己缓存的数据页越多,但操作系统那层缓存就用不上了。而且shared_buffers的查找和替换成本比操作系统的Page Cache要贵,维护这么大的共享内存本身也有开销。
我见过很多案例,内存64G,shared_buffers直接设16G,结果性能反而下降。因为大量数据页在shared_buffers里被打扫出去之后,操作系统Page Cache帮不上忙。两条缓存链路之间变成了接力而非叠加。
在普通的x86服务器上,shared_buffers设置在内存的15%~25%之间会比较合适。如果内存256G以上,控制在32G~48G。超大实例可以尝试更多,但一定要做实测对比。另外shared_buffers设置超过一定值后,需要同步考虑max_connections的挂载成本,每个连接都会有一份本地缓冲的额外内存开销。
3.2 work_mem和临时文件:最容易被忽略的隐性I/O
work_mem控制的是排序、哈希连接等操作可以在内存里使用的空间。默认4MB。如果你有个排序很大的查询,内存不够用,PostgreSQL就会把排序数据写到临时文件里,路径在base/pgsql_tmp下。
这种临时文件写入是很难发现的,它不体现在blks_read上,不产生WAL,也不会出现在shared_buffers统计里。只有你在慢查询日志里看到执行时间异常,或者在系统I/O里看到莫名的写放大,才知道是这个原因。
work_mem的陷阱在于它是按操作个数占用的。一个排序操作就分配一个work_mem,如果一条SQL里有两个排序、三个哈希连接,它的内存需求量是work_mem乘以操作数量。所以不能盲目把work_mem调到1G甚至更高,并发一大,内存瞬间被打爆。
建议的做法是:先通过EXPLAIN ANALYZE找到那些产生"Sort Method: external merge Disk"的查询,针对它们用SET命令临时调大work_mem,看能不能消除临时文件,然后评估特定时间段的最坏并发量,再决定全局设置多大。很多实际场景中,work_mem设到16M~64M就够消除大部分disk sort了,没必要动辄上百M。
3.3 checkpoint参数:I/O尖峰的根源
这一组参数最需要耐心调。
checkpoint_timeout和max_wal_size要配合着来。增大max_wal_size可以让检查点不那么频繁,但这意味着一次检查点要刷的脏页更多,单次I/O峰值会更高。减少max_wal_size则让检查点频繁但每次量小而密,I/O压力被摊平,不过总写入量会变大。
调优的目标是找到"峰值不过高、频率不频繁"的平衡点。我通常先看每周的WAL产生速率,按每秒产生量估算半小时最多产生多少WAL,然后把max_wal_size设置成这个量的一到两倍。比如你的业务高峰每秒产生5MB的WAL,半小时就是9GB(?,等一下)——要仔细算一下:5MB每秒乘以1800秒等于9000MB,约8.8GB。这时max_wal_size至少设成8GB到16GB比较合理。
checkpoint_flush_after在PG 11之后是默认256kB,它的作用是让检查点刷脏页时每隔指定量就fsync一次,而不是最后一次性fsync大量数据。对于旋转磁盘或老式SSD,这个参数能有效降低长I/O延迟;对现代NVMe影响不大。
还有一个参数叫checkpoint_completion_target,默认0.9,意味着检查点的刷脏动作分布在两个检查点间隔的90%时间内完成。如果调小到0.5~0.7,刷盘会更集中,但每个检查点的总耗时也短,适合高并发下需要快速完成检查点的场景。
3.4 WAL相关配置直接决定写入性能
synchronous_commit是最高频被修改的参数之一,默认on。它保证事务提交时WAL必须刷到磁盘,事务才算成功。改成off可以让事务提交时的WAL刷盘变成异步,性能提升极其明显,但代价是崩溃时可能丢失最近的少量已提交事务。
这个参数适合对数据丢失容忍度较高的场景,比如日志、埋点、临时计算结果。金融、订单这种核心业务别碰off。
除此之外,跟WAL写入性能相关的还有两个被忽视的参数:wal_compression和full_page_writes。full_page_writes如果对数据进行过压缩或者使用了支持原子写和checksum的存储,可以设成off,但保守起见默认on就好。wal_compression把WAL里的整页进行压缩再写入,能显著减少WAL占用和磁盘I/O,代价是CPU开销略高。现代CPU完全扛得起,建议直接开。
不同的WAL位置可以放在不同的磁盘上。WAL对延迟极其敏感,最好单独放在一块性能最强的NVMe上,数据文件可以放普通SSD。这样日志写入和随机查询之间不会互相抢I/O。
3.5 表空间与文件系统层面的布置
PostgreSQL支持表空间,可以把不同的表放在不同的存储路径上。比较常用的做法是:把高频访问的小表放在快速磁盘,把超大且低频访问的历史表放到慢速机械盘或对象存储加速层。
文件系统选择上,xfs是很多生产环境的首选,因为xfs在大文件处理上不错,而且支持延迟分配和较大的条带化配置。ext4也稳定,但默认的日志模式在掉电时更容易出现性能波动。两者都能用,关键看几个挂载参数。
常见的挂载优化有:relatime代替atime减少不必要更新;allocsize设大一点减少小文件碎片(xfs和ext4都支持);启用noatime。还有通用块设备层的调度器,NVMe盘一般用none,机械盘用mq-deadline或bfq。
如果用的是网络存储或者云盘,请务必先实测吞吐和延迟,再决定要不要依赖它跑高负载PostgreSQL。很多云盘的延迟均值不错,但P99延迟感人,一旦并发上来就掉链子。
3.6 并行查询参数:别让优化器乱来
PostgreSQL的并行查询是把双刃剑。并行度设太高,小查询也会被强行并行,线程调度开销和内存占用反而拖慢速度;设太低,复杂大查询又吃不饱I/O和多核资源。
影响并行度的核心参数有:max_parallel_workers_per_gather(每个gather节点最多启动的并行worker数)、min_parallel_table_scan_size(表体积超过多大才允许并行扫描)、parallel_setup_cost和parallel_tuple_cost。
一个比较稳的起点是:max_parallel_workers_per_gather=2或4,min_parallel_table_scan_size=8MB或更大,parallel_setup_cost调得比默认高一点。这样只有中等以上大小的表才会触发并行,小查询不受干扰。
如果你经常跑报表类重型查询,可以把并行阈值调低,但一定要限制业务高峰期总并发度,否则大量并行worker同时读盘,磁盘I/O直接被打满。
4. 实战复盘:三种典型负载的调优路径
4.1 OLTP在线交易系统:延迟敏感型
这套系统每天千万级订单,读写比例大约9:1。主要问题是高峰期每秒事务数上去了,但P99延迟经常冲破100ms。
排查流程:pg_stat_statements一查,前三条高频SQL都是点查订单表。EXPLAIN后发现虽然走了主键索引,但每条查询cold cache时需要多次读盘。深入看,原因是有个大字段导致每个数据页存的行数很少,单次查询要读好几页。
处理节奏:先把random_page_cost从4降到1.2,让优化器更愿意用索引而不是顺序扫。然后用CLUSTER按订单时间重排表数据,减少索引回表时的随机读。再把checkpoint_timeout从5分钟调到15分钟,max_wal_size从1GB调到8GB,消除每5分钟一次的刷盘抖动。最后把work_mem从4MB调到32MB,清理掉一批排序临时文件。
结果:P99延迟从100ms以上降到30ms左右,检查点相关的周期性延迟基本消失。
4.2 OLAP报表系统:吞吐优先型
这套系统主要跑月报、周报,数据量几十亿行,单条SQL经常跑几十秒到几分钟。I/O问题很直接:全表扫描太多,物理读太大。
这里完全没按OLTP那套思路来。shared_buffers占满内存的45%,因为单条重型查询比并发重要。max_parallel_workers_per_gather调到8,min_parallel_table_scan_size保持默认写大一点,确保只并行大表。random_page_cost保持默认值4.0不动,因为全表扫描就是顺序读,没必要让优化器倾向随机访问。把work_mem放到128MB,专门消除大型排序。
结果:单条报表SQL从80秒降到20秒左右。代价是并发能力变差,但OLAP场景本来并发就不高,属于合理的取舍。
4.3 混合负载:动态TPS和批量任务互相干扰
最麻烦的是这套系统,白天高并发OLTP,晚上还要跑批。表现为白天偶发卡顿,晚上批处理又把白天用到的数据全部挤出缓存,第二天早上又卡一轮。
最终方案是物理拆库,OLTP库和报表库完全分开,数据通过逻辑复制同步。如果不允许拆库,至少要保证:批处理任务定期运行,主动清理缓存中不常用的批量数据块;限制并行查询的并发数和CPU占用;批处理期间给OLTP查询设置单独的statement_timeout。
拆库看起来是最重的方案,但对混合负载来说通常是最有效的方案。调过很多次参数去兼容两边,最后还是回到拆库这件"笨事"上,省心且彻底。
5. 常见问题与避坑清单
5.1 缓存命中率99%了还是慢
因为blks_hit只统计共享缓冲区,容易让人产生"缓存很好"的错觉。实际上系统Page Cache也能兜底一部分。更有可能的情况是:I/O并不慢,但锁等待高、CPU解析慢、或者网络往返造成延迟。先排除这些再执着于I/O。
5.2 VACUUM频繁执行但表还在膨胀
大概率是长事务在阻塞清理。只要有一个会话持有snapshot很久,死元组就不能清理。查pg_stat_activity里的backend_xid和backend_xmin字段,看看是不是有长时间未提交的事务。很多"幽灵连接"会把VACUUM拖死。
5.3 work_mem调到1G反而更慢
work_mem是按操作分配的,一个查询里可能同时有多个排序和哈希操作。1G的work_mem在并发20时理论最大就要吃掉20G内存,内存不够就会触发交换,比磁盘排序更灾难。
5.4 parallel worker太多导致I/O风暴
查询并行度不等于越快越好。每个worker都会发起独立的I/O流,如果磁盘并发处理能力有限,反而会互相拖累。只对大查询开并行,iostat确认磁盘I/O在通过并行提升,而不是单纯增加延迟。
5.5 CHECKPOINT频繁触发却找不到原因
确认下是不是有超大事务。一个事务写了很多数据,即使checkpoint_timeout没到,max_wal_size达到阈值也会触发检查点。看日志里的"checkpoint starting: time"和"checkpoint complete: wrote=... buffers"的间隔就能判断。
5.6 压测时性能好,一到线上就崩
压测请求能完全命中缓存,线上完整请求链路包含了更复杂的条件和更多的数据量。用pgBadger或auto_explain模块记录生产环境的真实执行计划,别只依赖压测结果。
6. 经验之谈:调优的真正顺序
按我现在的习惯,遇到I/O性能问题基本按这个顺序来做,极少跳步:
先确认磁盘本身行不行。iostat和fio测一下,如果这步都不达标,后面所有参数调了也只能部分缓解,治标不治本。云盘的话先看类型,burst型的老实说性能上限就摆在那。
然后看数据库缓存命中和WAL产生量。合理情况下,缓存命中率应该稳定在99%上下,WAL流动也应该是平稳的,不该忽高忽低。这两项不对,先去查SQL和检查点。
再清理掉排序临时文件和不必要的全表扫描。这两类问题对I/O的消耗是倍增式的,比优化参数见效快得多。
最后才动参数。每个参数动手前先记录当前值,改一次测一次,观察至少半天,别一口气改五个。改之前把参数链的关系想清楚,比如shared_buffers动了,bgwriter的延迟写行为也要跟着调,不然缓冲区写不到磁盘上,检查点照样堆积。
最后再分享一个实战技巧:所有调优动作要在业务的真实负载下验证。很多人在测试环境调完了信心满满,上生产就翻车,是因为测试环境没有真实性,请求模式和表数据分布都对不上。有条件就用生产环境的备份数据建一个只读的副本,在上面跑典型查询,把优化效果真实压一遍再动线上的参数。这个习惯帮我避掉了很多不必要的麻烦,也节省了大量线上回滚的时间。