1. 为什么说调参是数据库运维的必修课
我接手过不少 PostgreSQL 项目,印象最深的不是那些复杂的 SQL ,而是一些看起来"配置也没动过、硬件也挺好"但就是跑不快的系统。后来逐步排查才发现,问题几乎都出在参数上。这里说的参数,不是随口改个work_mem=64MB就能完事,而是一整套需要结合机器配置、业务负载、数据规模综合判断的体系。
PostgreSQL 的参数体系很庞大,pg_settings视图里躺着几百个可调节项,但真正需要在生产环境反复权衡的,核心也就那二三十个。它们大致能分成四类:内存组、WAL与检查点组、代价优化器组、并发与自动清理组。把这四块啃下来,普通性能问题的排查和优化基本就能覆盖八成以上。
这篇文章不是照着官方文档给你翻译一遍,而是把我实际调过的案例、踩过的坑、以及参数之间那些"牵一发动全身"的关系整理出来。读完之后,你至少能回答这几个问题:为什么shared_buffers不是越大越好?为什么work_mem设置过大反而会让系统更危险?random_page_cost调低一档,为什么查询计划可能完全变样?如果你打算系统性学习 PG 参数调优,或者手头正好有数据库性能问题要处理,这篇内容应该能帮你省下不少走弯路的时间。
2. 内存参数组:shared_buffers、work_mem 与 effective_cache_size 的黄金搭配
2.1 shared_buffers:数据库自己的页面仓库
PostgreSQL 虽然也有操作系统层面的 page cache,但它自己维护了一套独立的共享缓冲区,用来缓存数据页和索引页。你可以把shared_buffers理解为数据库的"内部仓库",SQL 要读数据时先到这里找,找不到才去磁盘。
这里最大的误区就是"内存多就使劲给"。我见过有人把 64GB 内存的机器直接配了 48GB 给shared_buffers,结果系统反而更慢,为什么?因为 PG 在检查点或者需要清理脏页时,要对这批缓冲页做同步处理和淘汰扫描,缓冲区越大,管理这些页面的开销就越高。虽然 PG 现在的缓冲区管理已经比早年好很多,但shared_buffers仍然不建议超过物理内存的 25% 到 30%。比较稳的做法是:服务器内存 16GB 以下就分 25%,32GB 到 64GB 可以适当降到 20%,128GB 以上的大内存机器,我一般设置 24GB 到 32GB 左右就收手了。
还要注意一个细节:shared_buffers如果超过一定阈值(比如 8GB),有些内核参数也需要同步调整。Linux 系统下要留意shared_buffers与/etc/sysctl.conf里kernel.shmmax和shmall的关系,否则启动实例时数据库可能报出 shared memory 相关的错误。数据库部署刚开始就把这两个系统参数确认好,比事后排查要省心得多。
有人会问:既然操作系统也有 page cache,为什么还要专门分内存给shared_buffers?核心区别在于 PG 需要精确控制哪些页是脏页、哪些页可以立刻淘汰,还要支持 MVCC 快照和可见性判断。操作系统缓存不懂这些,它只知道"这块磁盘内容刚读过"。所以一定要把两者看成互补关系:shared_buffers管数据库内部热数据,OS page cache 兜底承接普通文件读。
2.2 work_mem:查询过程的临时工作台
work_mem是我在所有参数里最想强调的一个,因为它控制着排序、哈希连接、去重等操作在内存里可用的临时空间。很多人看到 "mem" 就以为把它调大一定好,但实际不是这样。
work_mem是"每个操作"可用的内存,不是整个查询可用的内存。一个查询如果有三个排序节点,就可能同时占用三份work_mem。更麻烦的是,如果有 50 个并发连接同时在跑类似查询,内存消耗就是 50 乘以每份的配置值。假设你把work_mem调到 1GB,理论上 50 个并发都可能挤占 50GB 内存,一旦超过物理内存就会大量使用 swap,整机性能瞬间崩溃。
所以我的原则是"宁可保守,绝不放纵"。常规 OLTP 系统,work_mem从 8MB 到 64MB 之间比较稳妥;复杂分析型报表场景可以调到 128MB,但必须结合max_connections和实际并发数做乘法评估。假设连接池限制最多 30 个活跃连接,大部分查询又只用了临时排序,那么 64MB 的work_mem理论上最大临时内存占用约 1.9GB,这个量级多数服务器都能接受。
还有一个好习惯:在排查慢查询时,不要只看执行时间,要用EXPLAIN (ANALYZE, BUFFERS)观察有没有Sort Method: external merge Disk这样的字眼。如果出现频繁的磁盘排序,说明work_mem确实不够用了,可以针对性调大,或者在 SQL 层面加索引让排序消失,这才是根源解法。
2.3 effective_cache_size:给查询优化器的"地图"
这个参数对很多人来说比较抽象,它并不分配任何内存,而是告诉优化器:"在整个系统里,你能指望有多少缓存可用。" 这个值大致是shared_buffers加上操作系统 page cache 的总量估算。
实践里我一般这样估:物理内存如果 32GB,shared_buffers配 8GB,OS 缓存最高可能用到 20GB 左右,那么effective_cache_size可以设置为 20GB 到 24GB。因为优化器在决定走索引还是走全表扫描时,会参考这个值来判断"目标表数据能不能被缓存住"。如果设置得太小,优化器会以为每次读表都要到磁盘,于是倾向于使用索引。但索引访问往往是随机 IO,表全扫却是顺序 IO,在 SSD 上顺序读大表的性能并不差,反而很多时候比大量随机索引读更好。反过来如果设置得过大,优化器又会过度乐观地认为所有数据都在内存,给出全表扫描计划,导致实际执行时反而出现大量磁盘 IO。
调整effective_cache_size不需要重启,reload就能生效。改完之后一定要重新观察关键查询计划是否变化。曾经有个报表系统,我只是把它从 8GB 调到 16GB,结果一张核心大表的执行计划从索引扫描变成了更优的哈希连接,查询时间直接降了一半。这种收益不一定每次都能复现,但说明该参数对计划选择的影响是实打实的。
3. WAL 与检查点参数:事务日志组如何决定数据安全与性能
3.1 wal_level 与 fsync 的正确理解
理解 WAL 参数前,先建立一个认知:PostgreSQL 每次数据修改并不是立刻写数据文件,而是先写一份 WAL(预写日志),记录这次变更的内容和位置。系统崩溃后,数据库通过重放 WAL 恢复数据文件到一致状态。这套机制保证了事务的持久性,但也引入了"日志写到哪一层才算安全"的取舍。
wal_level这个参数决定 WAL 记录内容的详细程度。最基础的是minimal,它只记录够崩溃恢复的信息,适合一些导入数据的临时场景。replica是生产环境最常见的配置,它额外记录支持流复制所需的信息。logical则在replica基础上再增加逻辑解码所需的数据,用于订阅发布等场景。我一般建议线上系统直接用replica,即使目前还没做主从,因为以后做流复制不需要停机改配置。logical要按需开,它带来的 WAL 量增长和 CPU 开销比很多人想的大。
再说到fsync。这个参数默认是on,意思是每次事务提交时要把 WAL 缓冲强制刷到持久化存储上。如果把它关掉,每秒提交次数会显著上升,但一旦操作系统崩溃或机器断电,最近提交的事务就可能丢,甚至整个数据库实例都无法启动。我不建议生产环境关闭fsync,因为节省的那点性能远远抵不上数据丢失的风险。如果你确实需要极端写入性能,优先考虑换更强的磁盘、调整synchronous_commit或批量提交方案,不要直接把fsync关掉。
3.2 synchronous_commit:一次写入的"确认线"
synchronous_commit有on、off、remote_write、local几个常用取值。默认的on表示事务提交时,WAL 已经安全写入磁盘后才向客户端返回成功。off则允许数据库在 WAL 尚未落盘时就返回成功,明显减少提交延迟,但崩溃时可能会丢失最近一小段已提交事务。
这个参数适合什么场景?简单来说就是那些允许丢掉少量近期数据的写入型业务,比如日志采集、告警记录、非关键链路的状态更新。但在账务、订单这类一致性要求极高的核心链路里,我从来不开off。一个折中的办法是设置remote_write,让主库把 WAL 写到备库的 OS 页面缓存就算成功,不需要等备库真正落盘。这个模式在网络延迟正常时,比on快不少,安全性也高于off。
我见过一个比较有意思的案例:某系统只是把synchronous_commit从on调到remote_write,写入 TPS 提高了将近 30%,而业务一致性要求完全没受影响。这说明调参前一定要搞清楚自己的"安全水位线",不是所有参数都只能"安全优先"或"性能优先",中间档位往往被忽略。
3.3 max_wal_size 与 checkpoint 的节奏控制
如果你观察过 PostgreSQL 的运行日志,会看到类似checkpoint complete: wrote 1024 buffers的消息。检查点是数据库把共享缓冲区里的脏页落盘的过程。max_wal_size控制了两个检查点之间允许累积的最大 WAL 大小。
这里的关键在于"检查点成本"和"WAL 增长"的平衡。max_wal_size设置太小,检查点触发就会太频繁,每次都要大量写脏页,磁盘 IO 容易形成尖峰。设置太大,两检查点间隔就长,虽然减少了检查点次数,但崩溃恢复时需要重放的 WAL 就多,恢复时间延长。默认值 1GB 对很多中小系统够用,但如果是写入密集业务,建议逐步调大到 4GB 到 8GB,并同时把checkpoint_timeout(默认 5 分钟)适当延长到 10 到 15 分钟。
还有一个小参数checkpoint_completion_target,它表示检查点尽量在时间窗口的哪个百分比内完成。默认 0.5,意思是希望检查点在checkpoint_timeout的一半时间内尽量完成。调大它(比如 0.9)可以让脏页写入更平滑地分布在时间窗口内,减少 IO 峰值。但这要配合max_wal_size来看,如果max_wal_size已经很大,completion_target调大的效果就没有想象中明显,因为它只是"尽量"平滑,不是硬性限流。
调 WAL 相关参数时,我习惯盯着两款工具的输出,一个是pg_stat_bgwriter里的 checkpoint 统计,另一个是pg_stat_statements里检查点相关等待事件。如果发现checkpoint_sync时间特别长,说明磁盘 fsync 性能瓶颈明显,优先考虑换盘或者调整checkpoint_completion_target。
4. 查询优化与代价参数:随机读还是顺序读,谁更贵
4.1 seq_page_cost 与 random_page_cost 的平衡艺术
PostgreSQL 的查询优化器不是靠魔法选择执行计划的,它根据一套代价模型估算每种路径的"成本"。这套成本的主要输入就是顺序页读取代价seq_page_cost和随机页读取代价random_page_cost。
默认值里seq_page_cost=1,random_page_cost=4,意思是优化器认为随机读一块页的开销是顺序读的四倍。这个值在机械硬盘时代是合理的,但到了 SSD 时代就明显失真了,因为 SSD 的随机读性能比传统机械盘好很多,4 倍差距显然过大。
如果数据库存储在一台性能不错的 NVMe SSD 上,我会把random_page_cost调到 1.5 到 2 之间。调整后最直接的影响就是优化器会更愿意选择索引扫描,而不像之前那样倾向于顺序扫整个表。但要注意,这个参数要配合数据实际分布来调。如果一张表的绝大多数数据都访问不到,索引本身的"回表"成本依然很高,单纯调低random_page_cost可能让优化器错误地高估索引收益。
我来分享一个具体经验:某系统跑一个多表连接查询,原来执行计划是先扫描大表再嵌套循环小表,耗时 800 多毫秒。我把random_page_cost从 4 调到 1.8 之后,优化器选择了把两表都做哈希连接,执行时间降到 120 毫秒。但另外一个等值查询场景,这样调就反而变慢了,因为索引扫描引入的回表次数太多。所以调这个参数后,必须用生产流量或导出的真实数据做一轮计划对比验证,不能看一两个查询变快就认为万事大吉。
seq_page_cost一般不建议随便改。除非你的存储对顺序读有着极端优化的硬件(比如某些列存引擎),否则默认 1 足够。改了一个不管另一个,代价模型就失衡了,后续排查问题会平白多一层烟雾弹。
4.2 effective_io_concurrency:并行 IO 的潜力有多大
effective_io_concurrency控制 PostgreSQL 预取数据时尝试并行的 IO 请求数。这个参数对旋转磁盘和 SSD 的意义不同。机械硬盘并发 IO 能力有限,一般 1 到 2 就够;而 SSD 可以支持很高的并发度,所以很多高并发场景会把它调到 16 甚至 64。
但我建议不要一上来就追求极端值。这个参数太高可能导致 IO 请求队列堆积,反而增加延迟。我的策略是先从 8 开始试,观察查询扫描时间是否有改善,再逐步提升到 16、32。如果系统用的是普通 SATA SSD,8 到 16 之间就能看到明显收益;NVMe 盘可以尝试 32 以上。
它影响的场景主要是 bitmap heap scan 和并行顺序扫描这类操作。如果你发现 EXPLAIN 输出里频繁出现 Bitmap Heap Scan,而且扫描数据量比较大,调高effective_io_concurrency往往能直接看到 CPU 等待时间的下降。但如果是纯索引唯一扫描或者简单点查,这个参数基本无用武之地,不要指望它能救回所有类型查询的性能。
很多人会混淆effective_io_concurrency和操作系统层 IO 调度参数,其实它们不在一个层次。前者是数据库优化器层面的"假设并发数",后者是内核 block layer 的实际队列深度。两者可以配套调,但数据库层通常只调前者就够了。
5. AutoVacuum 参数:别让脏数据拖垮数据库
5.1 为什么要自动清理:MVCC 与膨胀问题
PostgreSQL 的多版本并发控制机制(MVCC)是它强大之处,也是它"麻烦"的来源。每次 UPDATE 或 DELETE 并不会物理覆盖旧数据,而是在原版本上标记为"过期",并生成一个新的元组版本。当这些过期版本越积越多,表和索引就会出现膨胀:物理文件很大,实际有效数据却不多。更严重的是,查询扫描时需要跳过这些不可见版本,IO 和 CPU 开销都会增加。
autovacuum就是 PG 自带的"保洁系统"。它会周期性扫描表的膨胀程度和事务年龄,达到阈值就启动一个清理进程。默认情况下autovacuum是开启的,但在很多繁忙的生产系统里,默认参数表现得并不积极,导致表膨胀失控。
我遇到过最典型的例子:一张 5GB 的表,有效数据只有 800MB,查询本来 200 毫秒能出结果,硬生生拖到 2 秒多。跑一次VACUUM FULL之后,表物理大小降到了 1GB 左右,查询恢复敏捷。但这个问题最让人头疼的是它不会自己暴露,需要定期检查pg_stat_user_tables里的n_dead_tup和last_autovacuum字段。
5.2 autovacuum 关键参数与调优实践
这几个参数是最常用的:autovacuum_max_workers控制最多能同时跑几个清理 worker,默认 3 个。autovacuum_naptime是调度轮询间隔,默认 60 秒。autovacuum_vacuum_threshold和autovacuum_vacuum_scale_factor决定触发阈值:表里死元组数量超过threshold + 表的行数 * scale_factor时触发清理。
对小表来说,默认阈值 50 行偏大,因为 50 行死元组对小表可能是很大比例。对 1 亿行的大表,0.2的 scale_factor 意味着要有 2000 万死元组才触发清理,这会让膨胀已经非常严重才动手。所以现在更推荐滞后策略:大量使用按行数触发的阈值,同时把 scale_factor 调小,甚至在关键大表上用 ALTER TABLE 单独调参数,而不是全局一刀切。
全局参数我一般这样配:autovacuum_work_mem = 256MB到1GB,这一步很关键,因为 autovacuum 进程需要内存构建死元组清单。autovacuum_vacuum_cost_delay = 20ms左右,控制清理进程对普通业务的干扰程度。autovacuum_vacuum_scale_factor = 0.05到0.1,比默认更激进一些,但还算安全。
另外强烈建议开启log_autovacuum_min_duration,设置一个阈值比如 1000ms。这样 autovacuum 执行超过 1 秒的清理动作都会记录日志,你能直接看到哪张大表经常被清理、耗时多少,对判断清理压力和表膨胀趋势非常有帮助。
还有几个容易忽略的配套项:vacuum_cost_page_hit是清理进程命中缓存页时计多少成本,vacuum_cost_page_miss是未命中页的成本,vacuum_cost_limit是总成本上限。如果你的业务高峰在白天,而 autovacuum 每次清理都消耗过多 IO 导致应用延迟,可以把vacuum_cost_limit调低一点,限制清理进程的资源占用。反之在业务低谷时手动执行VACUUM或VACUUM ANALYZE来补足。
6. 并发与连接参数:连接数不是越大越好
6.1 max_connections 与连接池设计
PostgreSQL 的进程模型决定了"一个连接就是一个后端进程",每个连接都会消耗固定内存、锁资源和系统文件描述符。很多人遇到连接数不够,第一反应是调大max_connections,从 100 调到 1000。但更高的连接数意味着共享缓冲区平均到每个连接上的可用内存变小,进程切换成本增加,系统整体吞吐反而可能下降。
我见过一个场景:max_connections设到 1000,但连接池只在应用层维护了一小批长连接,数据库实际活跃连接只有几十个。此时即使max_connections设得再高也毫无意义。正确思路是:在应用层做连接池,把数据库端max_connections控制在合理范围(比如 200 到 500),让活跃连接长期稳定在一个低位。连接池还有一层优势,就是可以把statement_timeout、事务超时等统一在中间层管理,减少数据库端的空转连接。
如果你确实遇到连接数告警,优先排查是不是有应用忘了关闭连接,或者某个慢查询长时间持有连接。不要第一时间改max_connections,改完之后下一次又是同样的 bug 继续拖垮系统。
6.2 并行 worker 参数与资源配额
PostgreSQL 从 9.6 开始引入了并行查询,相关参数包括max_worker_processes、max_parallel_workers和max_parallel_workers_per_gather。max_worker_processes是所有后台 worker 的总上限,max_parallel_workers是其中的并行查询 worker 上限,max_parallel_workers_per_gather是单个查询最多能用的并行度。
这三个参数之间的关系就像一个漏斗:最外层是系统总配额,中间层是查询总配额,最内层是单查询配额。比如max_worker_processes=16,max_parallel_workers=8,max_parallel_workers_per_gather=4,效果是全局最多 16 个 worker,其中并行查询最多占 8 个,而单个查询最多分配到 4 个 worker。
并行不是免费的。启动 worker、IPC 通信、结果汇总都有开销。小查询强行并行可能比串行还慢。所以 PG 还提供min_parallel_table_scan_size和min_parallel_index_scan_size来控制"多大的表才值得并行"。默认值是 8MB 和 512KB,如果业务里小表查询很多,不要盲目调大并行度上限,让优化器自己根据表大小决定是否并行更合理。
我在实际调优中有一个经验:当系统的 CPU 核数很多(比如 32 核以上),把max_parallel_workers_per_gather调到 4 或 8 对分析型查询帮助明显,但对短小精悍的 OLTP 查询反而是负担。所以不要"一刀切"调高,建议同时监控pg_stat_statements里的平均执行时间是否有恶化。
7. 常见问题排查与调参实录
7.1 案例一:临时文件太多导致磁盘爆满
某系统突然报错:"No space left on device",但查了表空间并不大。后来定位到base/pgsql_tmp目录膨胀到几十 GB。这就是典型的work_mem设置过小,导致大量排序操作溢写到磁盘临时文件。
排查方法很简单:查询pg_stat_database里的temp_files和temp_bytes字段,或者直接看临时目录的大小变化。解决时我先把work_mem从 4MB 调到 32MB,临时文件量立刻降了下来。但这里要注意:不能一次调太高,否则会引发新的内存问题。我选择分两步走,先 16MB 跑一天观察,再决定要不要继续加到 32MB。
这类问题最好的根治方法其实是指定索引优化 SQL,很多情况是排序字段没有索引,数据库不得不整表取出数据再排序。加索引后连排序步骤都没了,work_mem就算不调问题也解决了。
7.2 案例二:shared_buffers 配太大反而变慢
另一个项目把shared_buffers从 8GB 调到 32GB(机器有 64GB 内存),本想提高命中率,结果系统 CPU 占用率上升,事务响应时间反而变慢。通过查看pg_stat_bgwriter发现checkpoint_write和buffers_clean的耗时大量增加。
原因就是缓冲区太大以后,后台写进程需要维护的脏页列表过长,每个检查点的刷盘压力也更大。我把它调回 20GB,同时把checkpoint_completion_target从 0.5 提到 0.8,让写盘过程更平滑,问题就缓解了。这段经历让我总结出一条经验:以"大就是好"的思路调 PG 参数,往往会被实际负载反过来教育。
7.3 常用参数速查表与推荐参考值
下面这张表是我在中小型项目里比较常用的初始值区间,具体数值要根据实际负载调整,不建议直接照搬:
| 参数名 | 推荐参考值 | 核心说明 |
|---|---|---|
| shared_buffers | 物理内存的 20%~25% | 数据库内部缓存,过高增加管理开销 |
| work_mem | 4MB~64MB | 排序/哈希操作内存,过大易引发 OOM |
| effective_cache_size | shared_buffers + OS page cache 估算 | 影响优化器是否走索引 |
| maintenance_work_mem | 64MB~512MB | 影响索引创建、VACUUM、外键约束等维护操作 |
| wal_level | replica | 生产环境日志级别下限 |
| max_wal_size | 2GB~8GB | 检查点间隔控制,过大恢复慢 |
| checkpoint_completion_target | 0.7~0.9 | 平滑脏页写入,减小 IO 峰值 |
| random_page_cost | SSD:1.5~2;机械盘:4 | 体积化优化器对随机读的代价估计 |
| effective_io_concurrency | SSD:8~32 | IO 并行预取力度 |
| autovacuum_vacuum_scale_factor | 0.05~0.1 | 触发清理的表膨胀比例阈值 |
| autovacuum_max_workers | 3~6 | 同时清理的最大 worker 数 |
| max_parallel_workers_per_gather | 2~8 | 单查询并行度上限 |
另外建议每次改参数前执行SHOW或者查pg_settings记录当前值,改完以后用ALTER SYSTEM持久化写入postgresql.auto.conf,最后SELECT pg_reload_conf();让多数热参数即时生效。少数涉及共享内存和进程模型的参数,比如shared_buffers和max_connections,必须重启数据库实例,这类参数在pg_settings里context = 'postmaster'。
提示:任何显式的调参动作,都建议先在测试环境用与生产相近的数据量和并发模型压一遍。数据库参数没有"一劳永逸"的配置,业务形态变化之后,参数也应该重新评估。
8. 我的一点个人体会
这几年的经验让我对 PG 调参有一个很深的体会:参数之间不是孤立的,而是一个联动系统。你单独调大work_mem,可能压缩了系统整体可用内存;你把shared_buffers增大,可能加重检查点压力;你把random_page_cost调低,可能让所有查询计划都变了方向。所以正确的调优姿势是带着"全局观"去动每个参数,改完一个要观察它的上下游是否被影响。
最后分享一个小习惯:我会在每台数据库服务器的某个固定位置放一个文本文件,记录每次调整的参数、时间、原因和观察到的结果。这个文件不需要很正式,但换人接手时非常有价值。毕竟,参数调整最怕的不是改错,而是改完不知道改了哪里,下次出问题无法回溯。我也建议你从今天开始维护这样一份自己的调参记录,用不了几个月,你就会发现它对系统稳定性的帮助比任何监控面板都大。