PostgreSQL 性能优化实战:从慢查询到毫秒级响应
一个真实案例:某电商订单查询从 8 秒优化到 80ms,我做对了什么?
作为一名前端主导的全栈工程师,你可能和我一样,平时更多关注 Vue/React 的渲染优化,但当后端接口响应缓慢时,问题往往出在数据库层面。本文将分享我在实际项目中积累的 PostgreSQL 性能优化经验,从诊断到解决,手把手带你从慢查询走向毫秒级响应。
一、慢查询诊断:找到罪魁祸首
1.1 开启慢查询日志
首先,你需要知道哪些查询在拖后腿。在 postgresql.conf 中配置:
# 记录执行时间超过 1 秒的查询
log_min_duration_statement = 1000
log_line_prefix = '%t [%p]: [%l-1] user=%u,db=%d,app=%a,client=%h '
重启后,慢查询会被记录到日志中。但日志分析比较麻烦,更推荐用 pg_stat_statements 扩展:
-- 安装扩展(每个数据库执行一次)
CREATE EXTENSION IF NOT EXISTS pg_stat_statements;
-- 查看最耗时的 10 个查询
SELECT
query,
calls,
total_exec_time,
mean_exec_time,
rows,
100.0 * shared_blks_hit / nullif(shared_blks_hit + shared_blks_read, 0) AS hit_percent
FROM pg_stat_statements
ORDER BY total_exec_time DESC
LIMIT 10;
1.2 实战案例:订单查询慢在哪里?
我们遇到一个电商订单查询接口,用户反馈"加载订单列表要等好几秒"。先定位问题 SQL:
SELECT
o.id, o.order_no, o.total_amount, o.status, o.created_at,
u.username, u.phone,
json_agg(json_build_object(
'sku_id', oi.sku_id,
'sku_name', oi.sku_name,
'price', oi.price,
'quantity', oi.quantity
)) as items
FROM orders o
LEFT JOIN users u ON o.user_id = u.id
LEFT JOIN order_items oi ON o.id = oi.order_id
WHERE o.created_at > '2024-01-01'
GROUP BY o.id, u.username, u.phone
ORDER BY o.created_at DESC
LIMIT 20 OFFSET 0;
执行 EXPLAIN ANALYZE:
EXPLAIN (ANALYZE, BUFFERS, FORMAT TEXT)
-- 上面那个查询
输出结果(关键部分):
GroupAggregate (cost=45231.23..48234.56 rows=1000 width=256) (actual time=8234.123..8245.678 rows=20 loops=1)
Group Key: o.id, u.username, u.phone
Buffers: shared hit=4523 read=8934
-> Sort (cost=45231.23..45233.45 rows=890 width=256) (actual time=8230.234..8231.456 rows=8923 loops=1)
Sort Key: o.created_at DESC, o.id
Sort Method: external merge Disk: 23456kB
Buffers: shared hit=4523 read=8934
-> Hash Join (cost=1234.56..45200.12 rows=890 width=256) (actual time=45.678..8123.456 rows=8923 loops=1)
Hash Cond: (o.user_id = u.id)
-> Seq Scan on orders o (cost=0.00..42345.67 rows=890 width=128) (actual time=0.234..8001.234 rows=8923 loops=1)
Filter: (created_at > '2024-01-01 00:00:00+08'::timestamp with time zone)
Rows Removed by Filter: 891234
Buffers: shared hit=4523 read=8934
-> Hash (cost=1234.12..1234.12 rows=100 width=128) (actual time=45.123..45.123 rows=100 loops=1)
Buckets: 1024 Batches: 1 Memory Usage: 15kB
-> Seq Scan on users u (cost=0.00..1234.12 rows=100 width=128) (actual time=0.123..44.567 rows=100 loops=1)
1.3 关键指标解读
从 EXPLAIN ANALYZE 输出中,我发现了几个致命问题:
- Seq Scan on orders:全表扫描!
orders表有 90 万条数据,每次查询都要扫描全部 - Rows Removed by Filter: 891234:过滤掉了 89 万行,说明只有 1% 的数据符合条件
- Sort Method: external merge Disk: 23456kB:内存不够,使用了磁盘排序,慢得要死
- actual time=8234.123..8245.678:总耗时 8.2 秒
诊断结论:缺少合适的索引 + 查询写法有问题。
二、索引优化:让查询飞起来
2.1 索引设计原则
在加索引之前,先理解几个核心原则:
- 选择性高的列优先:
status只有几个值,选择性低;order_no唯一,选择性高 - 最左前缀原则:复合索引
(a, b, c)可以支持a、a,b、a,b,c的查询 - 覆盖索引:索引包含查询所需的所有列,避免回表
- 不要过度索引:索引有维护成本,写操作会变慢
2.2 实战:为订单查询加索引
针对上面的慢查询,问题出在 created_at 过滤上。先加一个基础索引:
-- 为时间范围查询创建索引
CREATE INDEX idx_orders_created_at ON orders(created_at DESC);
再次执行 EXPLAIN ANALYZE:
-> Index Scan using idx_orders_created_at on orders o (cost=0.42..1234.56 rows=890 width=128)
Index Cond: (created_at > '2024-01-01 00:00:00+08'::timestamp with time zone)
效果:从全表扫描变成索引扫描,但还有优化空间。
2.3 复合索引:覆盖查询
观察查询条件,经常按 user_id + created_at 查询,而且需要 status 字段。创建一个覆盖索引:
-- 复合索引 + 覆盖索引
CREATE INDEX idx_orders_user_created_covering
ON orders(user_id, created_at DESC)
INCLUDE (status, total_amount, order_no);
关键点:
user_id在前:支持按用户过滤created_at DESC:支持时间倒序INCLUDE:包含常用字段,避免回表查主表
再次执行,查询时间从 8 秒降到 120ms。
2.4 索引维护与监控
-- 查看表上的索引
SELECT
indexname,
indexdef,
pg_size_pretty(pg_relation_size(indexrelid)) as index_size
FROM pg_indexes
JOIN pg_stat_user_indexes USING (indexrelname)
WHERE tablename = 'orders';
-- 查看索引使用情况
SELECT
indexrelname,
idx_scan, -- 索引被使用的次数
idx_tup_read,
idx_tup_fetch
FROM pg_stat_user_indexes
WHERE relname = 'orders';
-- 从未被使用的索引(考虑删除)
SELECT
schemaname || '.' || relname as table,
indexrelname as index
FROM pg_stat_user_indexes
WHERE idx_scan = 0
AND indexrelname NOT LIKE 'pg_toast%'
ORDER BY pg_relation_size(indexrelid) DESC;
三、查询重写:写法决定性能
3.1 避免 SELECT *
-- 糟糕:返回所有列,包括大字段
SELECT * FROM orders WHERE user_id = 123;
-- 优化:只取需要的列
SELECT id, order_no, total_amount, status FROM orders WHERE user_id = 123;
3.2 优化 JOIN 和子查询
回到我们的订单查询,原来的写法有问题:
-- 原写法:LEFT JOIN + GROUP BY + json_agg,性能杀手
SELECT
o.id, o.order_no, o.total_amount, o.status, o.created_at,
u.username, u.phone,
json_agg(json_build_object(...)) as items
FROM orders o
LEFT JOIN users u ON o.user_id = u.id
LEFT JOIN order_items oi ON o.id = oi.order_id
GROUP BY o.id, u.username, u.phone
问题分析:
json_agg聚合在内存中进行,数据量大时很慢- 即使只需要 20 条订单,也要聚合所有关联的 order_items
LEFT JOIN会产生大量中间行
优化方案:拆分成两个查询
-- 查询 1:获取订单列表(20ms)
SELECT
o.id, o.order_no, o.total_amount, o.status, o.created_at,
u.username, u.phone
FROM orders o
JOIN users u ON o.user_id = u.id
WHERE o.user_id = 123
ORDER BY o.created_at DESC
LIMIT 20 OFFSET 0;
-- 查询 2:批量获取订单商品(10ms)
SELECT
oi.order_id,
oi.sku_id, oi.sku_name, oi.price, oi.quantity
FROM order_items oi
WHERE oi.order_id = ANY($1::bigint[]); -- 参数是上面查出的 20 个 order_id
在 Node.js 中组装:
// 优化后的查询逻辑
async function getOrderList(userId, page = 1, pageSize = 20) {
const offset = (page - 1) * pageSize;
// 查询订单列表
const ordersResult = await db.query(`
SELECT o.id, o.order_no, o.total_amount, o.status, o.created_at,
u.username, u.phone
FROM orders o
JOIN users u ON o.user_id = u.id
WHERE o.user_id = $1
ORDER BY o.created_at DESC
LIMIT $2 OFFSET $3
`, [userId, pageSize, offset]);
const orders = ordersResult.rows;
if (orders.length === 0) return { list: [], total: 0 };
// 批量查询订单商品
const orderIds = orders.map(o => o.id);
const itemsResult = await db.query(`
SELECT order_id, sku_id, sku_name, price, quantity
FROM order_items
WHERE order_id = ANY($1)
`, [orderIds]);
// 组装数据
const itemsMap = groupBy(itemsResult.rows, 'order_id');
orders.forEach(order => {
order.items = itemsMap[order.id] || [];
});
return { list: orders, total: await getTotal(userId) };
}
效果:从 8 秒优化到 80ms(20ms + 10ms + 组装时间)。
3.3 慎用 OFFSET 分页
深分页是大坑:
-- 糟糕:OFFSET 100000 时,数据库还是要扫描前 100000 条
SELECT * FROM orders ORDER BY created_at DESC LIMIT 20 OFFSET 100000;
优化方案:游标分页(Keyset Pagination)
-- 首次查询
SELECT * FROM orders
WHERE user_id = 123
ORDER BY created_at DESC, id DESC
LIMIT 20;
-- 返回最后一条:created_at='2024-03-15 10:30:00', id=12345
-- 下一页:用上一页最后一条作为游标
SELECT * FROM orders
WHERE user_id = 123
AND (created_at, id) < ('2024-03-15 10:30:00', 12345)
ORDER BY created_at DESC, id DESC
LIMIT 20;
优势:无论翻到哪一页,性能都一样快。
四、连接优化:连接数不是越多越好
4.1 连接池配置
PostgreSQL 的连接是进程模型,每个连接一个进程,连接太多会拖垮系统。
推荐配置(4 核 8G 服务器):
# postgresql.conf
max_connections = 200 # 根据内存调整,公式:内存(GB) * 25
shared_buffers = 2GB # 25% 内存
work_mem = 16MB # 每个操作的内存
maintenance_work_mem = 512MB # VACUUM、CREATE INDEX 用
effective_cache_size = 6GB # 操作系统缓存 + shared_buffers
应用层连接池(Node.js + pg-pool):
const { Pool } = require('pg');
const pool = new Pool({
host: 'localhost',
database: 'mydb',
user: 'app_user',
password: 'password',
// 连接池配置
max: 20, // 最大连接数
min: 5, // 保持最小连接
idleTimeoutMillis: 30000, // 空闲连接 30 秒释放
connectionTimeoutMillis: 5000, // 连接超时 5 秒
});
// 使用
const result = await pool.query('SELECT * FROM orders WHERE id = $1', [id]);
4.2 长连接 vs 短连接
- 短连接:每次请求新建连接,用完关闭。适合低频操作,但连接开销大
- 长连接:应用启动时建立,一直复用。推荐用于 Web 服务
五、配置调优:榨干硬件性能
5.1 内存相关配置
# shared_buffers:PostgreSQL 自己的缓存
# 建议:25% 内存,不超过 8GB(再高收益递减)
shared_buffers = 2GB
# effective_cache_size:告诉优化器系统有多少缓存可用
# 包括 PostgreSQL 缓存 + 操作系统缓存
# 建议:总内存的 75%
effective_cache_size = 6GB
# work_mem:每个排序/哈希操作的内存
# 注意:如果有 100 个并发连接,每个做 2 个排序,需要 100 * 2 * work_mem
# 建议:从 16MB 开始,根据并发量调整
work_mem = 16MB
# maintenance_work_mem:维护操作(VACUUM、CREATE INDEX)的内存
# 可以设置大一些,这些操作通常不会并发
maintenance_work_mem = 512MB
5.2 WAL(预写日志)配置
WAL 配置影响写入性能:
# WAL 级别
wal_level = replica # 如果需要流复制,不能低于 replica
# WAL 刷盘策略
synchronous_commit = on # 同步提交,数据安全最高
# 如果可以接受少量数据丢失(如日志),设为 off 提升性能
# WAL 缓冲区
wal_buffers = 16MB # 默认 -1 自动,通常够用了
# 检查点配置
checkpoint_completion_target = 0.9 # 检查点完成目标时间占比
checkpoint_timeout = 10min # 最大检查点间隔
max_wal_size = 4GB # WAL 最大大小
min_wal_size = 1GB # WAL 最小大小
5.3 查询规划器配置
# 启用并行查询
max_worker_processes = 8
max_parallel_workers_per_gather = 4
max_parallel_workers = 8
# 成本因子(根据硬件调整)
seq_page_cost = 1.0 # 顺序页读取成本
random_page_cost = 1.1 # 随机页读取成本(SSD 设为 1.1,机械硬盘 4.0)
effective_io_concurrency = 200 # IO 并发数(SSD 可以设高)
5.4 使用 pgtune 自动调优
不想手动计算?用 pgtune:
# 或者使用命令行工具
npm install -g pgtune
pgtune --type Web --memory 8GB --connections 200 --storage ssd
六、高级技巧:进阶优化
6.1 分区表(Partitioning)
当单表超过 1000 万行,考虑分区:
-- 按时间范围分区
CREATE TABLE orders (
id bigint GENERATED ALWAYS AS IDENTITY,
user_id bigint NOT NULL,
order_no varchar(32) NOT NULL,
total_amount decimal(10,2) NOT NULL,
status smallint NOT NULL DEFAULT 0,
created_at timestamptz NOT NULL DEFAULT now(),
PRIMARY KEY (id, created_at)
) PARTITION BY RANGE (created_at);
-- 创建分区
CREATE TABLE orders_2024_q1 PARTITION OF orders
FOR VALUES FROM ('2024-01-01') TO ('2024-04-01');
CREATE TABLE orders_2024_q2 PARTITION OF orders
FOR VALUES FROM ('2024-04-01') TO ('2024-07-01');
-- 自动创建分区(PostgreSQL 16+)
CREATE TABLE orders_2024_q3 PARTITION OF orders
FOR VALUES FROM ('2024-07-01') TO ('2024-10-01');
好处:
- 查询只扫描相关分区
- 可以单独清理旧数据(DROP PARTITION 比 DELETE 快得多)
- 可以针对热数据单独优化
6.2 物化视图(Materialized View)
对于复杂报表查询,使用物化视图:
-- 创建物化视图
CREATE MATERIALIZED VIEW daily_order_stats AS
SELECT
date_trunc('day', created_at) as day,
count(*) as order_count,
sum(total_amount) as total_amount,
count(DISTINCT user_id) as unique_users
FROM orders
GROUP BY date_trunc('day', created_at);
-- 创建索引
CREATE INDEX idx_daily_stats_day ON daily_order_stats(day);
-- 刷新(可以设置定时任务)
REFRESH MATERIALIZED VIEW CONCURRENTLY daily_order_stats;
6.3 异步提交与批量写入
-- 会话级别关闭同步提交(适合日志写入)
SET synchronous_commit = off;
-- 批量插入(比单条快 10 倍以上)
INSERT INTO orders (user_id, order_no, total_amount, status, created_at)
VALUES
(1, 'ORD001', 100.00, 1, now()),
(2, 'ORD002', 200.00, 1, now()),
(3, 'ORD003', 150.00, 1, now())
ON CONFLICT DO NOTHING;
-- 使用 COPY 更快(适合大数据导入)
COPY orders (user_id, order_no, total_amount, status) FROM '/path/to/data.csv' CSV;
七、监控与持续优化
7.1 关键监控指标
-- 活跃连接数
SELECT count(*) FROM pg_stat_activity WHERE state = 'active';
-- 等待锁的连接
SELECT count(*) FROM pg_stat_activity WHERE wait_event_type = 'Lock';
-- 表膨胀(死元组比例)
SELECT
schemaname || '.' || relname as table,
n_dead_tup,
n_live_tup,
round(n_dead_tup * 100.0 / nullif(n_live_tup + n_dead_tup, 0), 2) as dead_ratio
FROM pg_stat_user_tables
WHERE n_dead_tup > 1000
ORDER BY n_dead_tup DESC;
-- 缓存命中率(低于 99% 需要关注)
SELECT
round(100.0 * sum(blks_hit) / nullif(sum(blks_hit) + sum(blks_read), 0), 2) as cache_hit_ratio
FROM pg_stat_database;
7.2 定期维护任务
# 分析表(更新统计信息)
psql -c "ANALYZE orders;"
# 清理死元组
psql -c "VACUUM orders;"
# 重建索引(索引膨胀严重时)
psql -c "REINDEX INDEX CONCURRENTLY idx_orders_user_created;"
# 完整清理(会锁表,低峰期执行)
psql -c "VACUUM FULL orders;"
八、总结:优化 checklist
当你遇到 PostgreSQL 慢查询时,按这个顺序排查:
- 诊断:用
EXPLAIN (ANALYZE, BUFFERS)找到瓶颈 - 索引:确保 WHERE、JOIN、ORDER BY 的列有合适索引
- 查询:避免 SELECT *,考虑拆分复杂查询
- 分页:深分页用游标分页替代 OFFSET
- 配置:调整 shared_buffers、work_mem、连接数
- 架构:大表分区,报表用物化视图
记住:优化是持续的过程,不是一次性任务。建立监控,定期 review,才能保持数据库健康。
参考资源
- PostgreSQL 官方文档 - 性能优化
- pg_stat_statements 文档
- Use The Index, Luke! - 索引优化圣经
- pgtune - 配置调优工具
本文基于 PostgreSQL 15/16 编写,部分特性在低版本中可能不可用。
评论区
登录 后参与评论