后端开发··1 阅读·预计 22 分钟

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 输出中,我发现了几个致命问题

  1. Seq Scan on orders:全表扫描!orders 表有 90 万条数据,每次查询都要扫描全部
  2. Rows Removed by Filter: 891234:过滤掉了 89 万行,说明只有 1% 的数据符合条件
  3. Sort Method: external merge Disk: 23456kB:内存不够,使用了磁盘排序,慢得要死
  4. actual time=8234.123..8245.678:总耗时 8.2 秒

诊断结论:缺少合适的索引 + 查询写法有问题。


二、索引优化:让查询飞起来

2.1 索引设计原则

在加索引之前,先理解几个核心原则:

  1. 选择性高的列优先status 只有几个值,选择性低;order_no 唯一,选择性高
  2. 最左前缀原则:复合索引 (a, b, c) 可以支持 aa,ba,b,c 的查询
  3. 覆盖索引:索引包含查询所需的所有列,避免回表
  4. 不要过度索引:索引有维护成本,写操作会变慢

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

问题分析

  1. json_agg 聚合在内存中进行,数据量大时很慢
  2. 即使只需要 20 条订单,也要聚合所有关联的 order_items
  3. 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 慢查询时,按这个顺序排查:

  1. 诊断:用 EXPLAIN (ANALYZE, BUFFERS) 找到瓶颈
  2. 索引:确保 WHERE、JOIN、ORDER BY 的列有合适索引
  3. 查询:避免 SELECT *,考虑拆分复杂查询
  4. 分页:深分页用游标分页替代 OFFSET
  5. 配置:调整 shared_buffers、work_mem、连接数
  6. 架构:大表分区,报表用物化视图

记住:优化是持续的过程,不是一次性任务。建立监控,定期 review,才能保持数据库健康。


参考资源


本文基于 PostgreSQL 15/16 编写,部分特性在低版本中可能不可用。

0 评论

评论区

登录 后参与评论