多彩编程 多彩编程MZPH · CODE BLOG
ARTICLE DETAIL

文章详情

深耕前端与后端开发技术的一线实战笔记与踩坑复盘。

PostgreSQL I/O性能调优实战:从缓冲池、WAL到检查点与VACUUM

PostgreSQL I/O性能调优实战:从缓冲池、WAL到检查点与VACUUM 很多人把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_threshold5000配合scale_factor0.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内置的统计视图要比外部工具靠谱得多。先看这一条SQLSELECT 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和本地buffershared 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_cost4.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要贵维护这么大的共享内存本身也有开销。我见过很多案例内存64Gshared_buffers直接设16G结果性能反而下降。因为大量数据页在shared_buffers里被打扫出去之后操作系统Page Cache帮不上忙。两条缓存链路之间变成了接力而非叠加。在普通的x86服务器上shared_buffers设置在内存的15%~25%之间会比较合适。如果内存256G以上控制在32G~48G。超大实例可以尝试更多但一定要做实测对比。另外shared_buffers设置超过一定值后需要同步考虑max_connections的挂载成本每个连接都会有一份本地缓冲的额外内存开销。3.2 work_mem和临时文件最容易被忽略的隐性I/Owork_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_gather2或4min_parallel_table_scan_size8MB或更大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调到8min_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的延迟写行为也要跟着调不然缓冲区写不到磁盘上检查点照样堆积。最后再分享一个实战技巧所有调优动作要在业务的真实负载下验证。很多人在测试环境调完了信心满满上生产就翻车是因为测试环境没有真实性请求模式和表数据分布都对不上。有条件就用生产环境的备份数据建一个只读的副本在上面跑典型查询把优化效果真实压一遍再动线上的参数。这个习惯帮我避掉了很多不必要的麻烦也节省了大量线上回滚的时间。
返回列表