PostgreSQL 性能优化是一门系统性工程:从单条 SQL 的查询计划分析,到表级索引策略,再到全局参数调优,最后到硬件资源配置。优化不做孤立动作,而需建立"查询 → 索引 → 参数 → 硬件"的完整优化链条。
一句话总结:先 EXPLAIN ANALYZE,再建索引,再调参数,最后升级硬件。优化永远从上往下排查。
一、查询计划分析(EXPLAIN ANALYZE)
1.1 基础用法
EXPLAIN (ANALYZE, BUFFERS, FORMAT JSON)
SELECT o.id, o.total, u.email
FROM orders o
JOIN users u ON o.user_id = u.id
WHERE o.created_at > '2025-01-01'
AND o.status = 'completed'
ORDER BY o.created_at DESC
LIMIT 100;
1.2 关键指标解读
| 指标 | 含义 | 好坏判断 |
|---|---|---|
| Seq Scan | 全表扫描 | 大表上用 Seq Scan = 需要索引 |
| Index Scan | 索引扫描 | ✅ 好,但看 Rows Removed |
| Index Only Scan | 从索引直接返回 | ✅ 最好,无需回表 |
| Bitmap Scan | 位图索引扫描 | 中等,多用于 OR/OR 条件 |
| Nested Loop | 嵌套循环 JOIN | 小表驱动大表时快,反则慢 |
| Hash Join | 哈希 JOIN | 等值 JOIN 最优 |
| Merge Join | 排序合并 JOIN | 有序数据集场景 |
| cost=0.00..1234.56 | 估算成本 | .. 后越大越慢 |
| actual time=0.012..150.001 | 实际耗时(ms) | 与 cost 对比可发现估算偏差 |
| Buffers: shared hit/read | 缓存命中/磁盘读取 | hit 越高越好 |
| Rows Removed by Filter | 过滤后丢弃行数 | 移除率 > 90% 说明索引没充分使用 |
1.3 案例:识别慢查询
-- 慢查询排查
SELECT pid, now() - query_start AS duration, query
FROM pg_stat_activity
WHERE state = 'active'
AND query NOT LIKE '%pg_stat_activity%'
ORDER BY duration DESC
LIMIT 10;
-- 终止慢查询(危险操作)
SELECT pg_terminate_backend(12345); -- pid
-- 查看表扫描频率(排序找出缺索引的表)
SELECT schemaname, relname,
seq_scan, seq_tup_read,
idx_scan, idx_tup_fetch
FROM pg_stat_user_tables
ORDER BY seq_scan DESC
LIMIT 20;
二、索引策略:选对索引类型
2.1 索引类型速查
| 索引类型 | 最佳场景 | 存储方式 | 特点 |
|---|---|---|---|
| B-tree | 等值查询、范围查询、排序 | 平衡树 | 最通用,自动创建 |
| Hash | 等值查询 | 哈希表 | 更小更快但不支持范围 |
| GIN | JSONB、数组、全文搜索 | 倒排索引 | 写入慢查询快 |
| GiST | 空间/范围/相似度 | 通用搜索树 | 可自定义搜索行为 |
| BRIN | 超大有序表(时序数据) | 块级摘要 | 体积极小,百万行 = 数 KB |
| 覆盖索引 | 频繁查询的特定字段 | B-tree 扩展 | 实现 Index Only Scan |
2.2 索引创建实战
-- 复合索引(最左前缀:WHERE user_id=? AND created_at > ?)
CREATE INDEX idx_orders_user_date ON orders(user_id, created_at DESC);
-- 部分索引(只索引热点数据,减小索引体积)
CREATE INDEX idx_orders_unpaid ON orders(status) WHERE status = 'unpaid';
-- 函数索引(匹配查询条件 UPPER(email) = ?)
CREATE INDEX idx_users_lower_email ON users(LOWER(email));
-- 覆盖索引(Index Only Scan)
CREATE INDEX idx_orders_cover ON orders(user_id, status) INCLUDE (total);
-- 好处:查询 SELECT user_id, status, total 时无需回表
-- GIN 索引(JSONB 包含 @> 查询)
CREATE INDEX idx_products_attrs ON products USING GIN(attributes jsonb_path_ops);
-- BRIN 索引(时序大数据)
CREATE INDEX idx_logs_time ON logs USING BRIN(created_at)
WITH (pages_per_range = 128);
2.3 索引选择检查清单
□ 该列是否出现在 WHERE、JOIN ON、ORDER BY 条件中?
□ WHERE 条件是等值还是范围?等值 → B-tree/Hash,范围 → B-tree,包含/交集 → GIN
□ 索引列是否已 ORDER BY 相同方向?避免额外排序(Sort 节点)
□ 大表(> 1M 行)且数据有规律 → 考虑 BRIN
□ 频繁更新的列 → 索引维护成本高,慎重
□ 冗余索引检查 → pg_stat_user_indexes.idx_scan = 0 且长期未用则删除
2.4 检查无用索引
-- 找出从未被使用的索引
SELECT schemaname, relname, indexrelname, idx_scan
FROM pg_stat_user_indexes
WHERE idx_scan = 0
AND indexrelname NOT LIKE 'pg_toast%'
ORDER BY indexrelname;
-- 删除前先用 pg_index 确认不是约束索引
-- 删除:DROP INDEX CONCURRENTLY idx_name; (不锁表)
三、VACUUM 与 Autovacuum 调优
3.1 为什么 VACUUM 性能关键
MVCC 产生 dead tuple → 表膨胀 → 查询变慢 → 磁盘空间膨胀 → 无法缩小。
-- 查看表膨胀率
SELECT schemaname, relname, n_dead_tup, n_live_tup,
ROUND(n_dead_tup::NUMERIC / NULLIF(n_live_tup, 0) * 100, 2) AS dead_pct
FROM pg_stat_user_tables
ORDER BY n_dead_tup DESC LIMIT 20;
-- 查看表实际物理大小
SELECT relname, pg_size_pretty(pg_total_relation_size(oid)) AS total
FROM pg_stat_user_tables
ORDER BY pg_total_relation_size(oid) DESC LIMIT 20;
3.2 Autovacuum 调优
# postgresql.conf 关键参数
autovacuum_max_workers = 3 # 并行 VACUUM 进程数
autovacuum_naptime = 10s # 检查间隔
autovacuum_vacuum_threshold = 50 # 触发 VACUUM 的最小死元组数
autovacuum_vacuum_scale_factor = 0.1 # 触发阈值 = 阈值 + 行数 × 比例
autovacuum_analyze_threshold = 50
autovacuum_analyze_scale_factor = 0.05
vacuum_cost_delay = 2ms # VACUUM 的限速(避免影响正常业务)
vacuum_cost_limit = 1000 # VACUUM 的速限成本
高频写入大表的专属调优(针对单表 ALTER TABLE):
-- 为频繁写入的日志表单独降低 scale_factor(避免每次更新全表 VACUUM)
ALTER TABLE logs SET (autovacuum_vacuum_scale_factor = 0.01);
ALTER TABLE logs SET (autovacuum_vacuum_threshold = 500);
3.3 手动 VACUUM 时间表
| 场景 | 命令 | 说明 |
|---|---|---|
| 在线整理 | VACUUM (VERBOSE, ANALYZE) table_name; | 不锁表 |
| 彻底回收空间 | VACUUM FULL table_name; | 锁表,重建表文件 |
| 分析统计 | ANALYZE table_name; | 更新 pg_stat 统计 |
四、连接池与并发控制
4.1 为什么需要连接池
PostgreSQL 每个连接消耗 ~10MB 内存。应用如直接连接:
App Process × N 进程 × max_connections = 数据库爆连接
# 例如 10 容器 × 4 worker × 100 max = 4000 连接 → DB 崩溃
连接池中间层(PgBouncer)方案:
App × N (短连接) → PgBouncer (25 pooled) → PostgreSQL (200 max)
4.2 PostgreSQL 连接参数调优
# postgresql.conf
max_connections = 200 # 根据 CPU 和内存决定
shared_buffers = 256MB # 内存的 25%,但也可用更多(DB 专用机器可到 40%)
effective_cache_size = 1GB # OS + DB 可用缓存总大小(预估,非实际分配)
work_mem = 16MB # 每个操作(排序/哈希)的内存,有 N 个连接同时排序则 N × work_mem
maintenance_work_mem = 128MB # VACUUM/CREATE INDEX 内存
wal_buffers = 16MB
random_page_cost = 1.1 # SSD 设为 1.1,HDD 设为 4(影响索引选择决策)
effective_io_concurrency = 200 # SSD 设为 200,HDD 设为 2(影响位图索引扫描并行度)
4.3 参数调优矩阵
| 参数 | 推荐公式 | 说明 |
|---|---|---|
shared_buffers | 内存 × 25% | PostgreSQL 专用缓存 |
effective_cache_size | 内存 × 75% | 告知优化器 OS 缓存可用 |
work_mem | 内存 × 3% / max_connections | 每个操作的排序/哈希内存 |
maintenance_work_mem | 256MB ~ 1GB | VACUUM/INDEX 期间使用 |
max_connections | 200 ~ 5000 | PgBouncer 后通常 ≤ 200 |
random_page_cost | 1.1 (SSD) / 4.0 (HDD) | 影响索引 vs 全表扫描决策 |
五、慢查询定位与优化实战
5.1 慢查询日志配置
# postgresql.conf
log_min_duration_statement = 500 # 记录 > 500ms 的查询
log_statement = 'mod' # 记录 INSERT/UPDATE/DELETE(可调为 'all')
log_line_prefix = '%t [%p]: [%l-1] user=%u,db=%d,app=%a,client=%h ' # 每条日志前缀
log_duration = on # 记录查询耗时
log_slow_queries = on # 慢查询统计
5.2 慢查询调优案例
案例 1:大表 ORDER BY + LIMIT 慢
-- 原始慢查询
SELECT * FROM logs WHERE user_id = 123 ORDER BY created_at DESC LIMIT 20;
-- EXPLAIN 显示 Seq Scan + Sort
-- 优化:加复合索引匹配排序
CREATE INDEX idx_logs_user_created ON logs(user_id, created_at DESC);
-- 结果:Index Only Scan,从数秒降到 <1ms
案例 2:JSONB 查询慢
-- 原始:无索引,大表扫描
SELECT * FROM products WHERE attributes @> '{"brand": "Apple"}';
-- 优化:GIN 索引
CREATE INDEX idx_products_attrs ON products USING GIN(attributes jsonb_path_ops);
-- 结果:GIN Index Scan,查询提速 100x
案例 3:JOIN 慢(缺少连接条件索引)
-- 原始慢查询
SELECT o.*, u.email FROM orders o JOIN users u ON o.user_id = u.id;
-- EXPLAIN 显示 Nested Loop 上 users 做 Seq Scan
-- 优化:确保 JOIN 列有索引
CREATE INDEX idx_orders_user_id ON orders(user_id); -- 通常主键外键已有
-- 结果:Index Scan + Loop,避免用户表全表扫描
六、硬件资源配置建议
| 规模 | CPU | 内存 | 磁盘 | 适用 |
|---|---|---|---|---|
| 开发/测试 | 2 核 | 4 GB | 50GB SSD | 本地开发 |
| 小型生产 | 4 核 | 16 GB | 200GB SSD | < 10万日活 |
| 中型生产 | 8 核 | 64 GB | 1TB NVMe | < 100万日活 |
| 大型生产 | 16+ 核 | 128+ GB | 多 TB NVMe + 副本 | 千万级日活 |
硬件调优口诀:
- SSD 必读参数:
random_page_cost = 1.1 - 内存 = 数据库性能的上限(80% 查询靠缓存)
- NVMe 比 SATA SSD 快 4~10x,随机 IOPS 是关键
常见问题(FAQ)
EXPLAIN 显示很高 cost 但实际很快怎么办?
可能是估算偏离(statistics 过期)。执行 ANALYZE table_name; 更新统计信息后再试。如果问题持续,检查是否 default_statistics_target 过低。
VACUUM 能缩小表文件吗?
普通 VACUUM 不能缩小物理文件,只回收内部空闲空间。重建物理文件需 VACUUM FULL(锁表)或 REINDEX。
我的 max_connections 设了 1000,但每次连接还是慢?
PostgreSQL 每个连接都是 fork 进程,建立连接本身慢(SSL/TLS 握手 + 认证)。应用永远不应直连 PostgreSQL 的连接池 — 用 PgBouncer 或应用层连接池把连接数降到数据库欢迎的范围(通常 100~200)。
JSONB 查询为什么用不上 GIN 索引?
检查 GIN 索引的表达式与 WHERE 条件是否一致。GIN 索引支持的操作符:<@、@>、?、?|、?&。如果用了 ->> 取值再比较,GIN 不会生效。
相关阅读
- PostgreSQL 详解 — 核心架构、MVCC、WAL、JSONB
- PostgreSQL Docker 部署与初始化 — 容器化快速搭建
- Prisma + PostgreSQL 实战 — Schema 设计、Migration
- PostgreSQL SQL 进阶查询 — 窗口函数、CTE
- PostgreSQL 高可用与备份 — 流复制、Patroni
- PostgreSQL 安全与权限管理 — RLS、SSL
- PostgreSQL 专题导航
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。