跳至内容
·8 分钟阅读

PostgreSQL 查询优化:从龟速到亚毫秒级

我在生产数据库上使用的 PostgreSQL 优化技巧。EXPLAIN ANALYZE、索引策略、查询重写,以及将查询时间从数秒降到微秒级的具体改动。

分享:X / TwitterLinkedIn

上个月凌晨两点我收到了一条 Slack 消息:"仪表盘超时了。"我 SSH 进生产服务器,打开 pg_stat_activity,发现一条查询扫描了 1400 万行却只返回了 12 条结果。修复方法是一个花了 30 秒创建的部分索引。查询时间从 4.2 秒降到了 0.3 毫秒。

PostgreSQL 性能问题就是这样。问题几乎从来不是什么高深莫测的东西。无非是遗漏的索引、过时的统计信息、在表只有 1 万行时合理但现在已经有了 1000 万行的查询。修复通常很简单——只要你知道该看哪里。

这篇文章是我从运维生产数据库中学到的关于 PostgreSQL 查询优化的一切。没有脱离实践的理论。这里的每个技巧都在真实系统上为我节省了真实的时间。

调试思维:不要猜,要量#

我见到开发者处理慢查询时犯的最大错误就是靠猜。"也许我们需要在那个列上加个索引。""也许是 JOIN 慢了。""也许我们该加内存。"

别猜了。PostgreSQL 拥有所有数据库中最好的查询分析器之一。用它。

EXPLAIN —— 蓝图#

普通的 EXPLAIN 显示 PostgreSQL 计划做什么,但不会真正执行查询:

sql
EXPLAIN SELECT * FROM orders WHERE customer_id = 42;
                                    QUERY PLAN
---------------------------------------------------------------------------
 Index Scan using idx_orders_customer_id on orders  (cost=0.43..8.45 rows=1 width=128)
   Index Cond: (customer_id = 42)

这告诉你执行计划——索引扫描——和预估成本。但这只是预测。PostgreSQL 对行数的预估可能完全错误。我见过预估"1 行"但实际结果是 50000 行的情况。这种误判会级联导致糟糕的计划选择。

当你想快速看一下计划但不想真正执行查询时,使用普通 EXPLAIN。在分析会修改数据的 DELETE,或者需要 30 分钟才能跑完的查询时,这很重要。

EXPLAIN ANALYZE —— 真相#

sql
EXPLAIN ANALYZE SELECT * FROM orders WHERE customer_id = 42;
                                    QUERY PLAN
---------------------------------------------------------------------------
 Index Scan using idx_orders_customer_id on orders  (cost=0.43..8.45 rows=1 width=128)
                                                    (actual time=0.021..0.023 rows=1 loops=1)
   Index Cond: (customer_id = 42)
 Planning Time: 0.085 ms
 Execution Time: 0.041 ms

现在你能看到实际执行时间和实际行数。这才是真正调试的地方。比较 rows=1(预估)和 rows=1(实际)——它们匹配,说明规划器做出了正确的决策。当它们不匹配时,那就是你的第一条线索。

警告EXPLAIN ANALYZE 会真正执行查询。如果你在分析 UPDATEDELETE,把它包在事务里:

sql
BEGIN;
EXPLAIN ANALYZE DELETE FROM sessions WHERE expires_at < now();
ROLLBACK;

EXPLAIN (ANALYZE, BUFFERS) —— 完整画面#

这是我 90% 时间实际使用的:

sql
EXPLAIN (ANALYZE, BUFFERS, FORMAT TEXT)
SELECT o.id, o.total, c.name
FROM orders o
JOIN customers c ON c.id = o.customer_id
WHERE o.created_at > '2026-01-01'
  AND o.status = 'completed';
                                    QUERY PLAN
---------------------------------------------------------------------------
 Hash Join  (cost=425.00..1893.25 rows=2150 width=48)
            (actual time=2.341..15.892 rows=2347 loops=1)
   Hash Cond: (o.customer_id = c.id)
   Buffers: shared hit=312 read=45
   ->  Bitmap Heap Scan on orders o  (cost=12.50..1450.75 rows=2150 width=32)
                                     (actual time=0.512..12.105 rows=2347 loops=1)
         Recheck Cond: (created_at > '2026-01-01'::date)
         Filter: (status = 'completed')
         Rows Removed by Filter: 893
         Heap Blocks: exact=189
         Buffers: shared hit=195 read=45
         ->  Bitmap Index Scan on idx_orders_created_at  (cost=0.00..11.96 rows=3043 width=0)
                                                         (actual time=0.287..0.287 rows=3240 loops=1)
               Index Cond: (created_at > '2026-01-01'::date)
               Buffers: shared hit=12
   ->  Hash  (cost=287.50..287.50 rows=10000 width=24)
             (actual time=1.753..1.753 rows=10000 loops=1)
         Buffers: shared hit=117
         ->  Seq Scan on customers c  (cost=0.00..287.50 rows=10000 width=24)
                                      (actual time=0.008..0.892 rows=10000 loops=1)
               Buffers: shared hit=117
 Planning Time: 0.215 ms
 Execution Time: 16.147 ms

BUFFERS 输出至关重要。shared hit=312 表示 312 个页面来自缓冲区缓存(内存)。read=45 表示 45 个页面必须从磁盘读取。如果你看到 read 相对于 hit 很高,说明 shared_buffers 可能太小了,或者表太大无法保持在缓存中。

当我需要把计划粘贴到 explain.dalibo.com 做可视化时,我也会用 FORMAT JSON。树形视图让复杂的计划更容易阅读:

sql
EXPLAIN (ANALYZE, BUFFERS, FORMAT JSON) SELECT ...;

我实际使用的 EXPLAIN 选项#

以下是我调查任何慢查询时首先运行的完整诊断查询:

sql
EXPLAIN (ANALYZE, BUFFERS, TIMING, VERBOSE)
SELECT ...;
  • TIMING —— 每个节点的计时(使用 ANALYZE 时默认开启,但我习惯显式声明)
  • VERBOSE —— 显示输出列列表和带 schema 限定的表名

像侦探一样阅读查询计划#

每个查询计划都是一棵树。PostgreSQL 自底向上读取:缩进最深的节点最先执行,将结果向上传递。理解扫描类型是其他一切的基础。

Seq Scan —— 全表扫描#

Seq Scan on orders  (cost=0.00..25000.00 rows=1000000 width=128)

顺序扫描读取表中的每一行。对于一百万行的表,这意味着磁盘上的每个页面都要读。

但有个微妙之处:Seq Scan 并不总是坏事。 如果你要选择表中 30% 或更多的数据,顺序扫描实际上比索引扫描快,因为顺序 I/O 比随机 I/O 快得多。PostgreSQL 知道这一点。如果它在你有索引的情况下仍然选择了 Seq Scan,检查一下你的 WHERE 子句实际上有多大的选择性。

什么时候是问题:当你要从一个大表中选出很小一部分行,而且没有索引的时候。

Index Scan —— 精准查找#

Index Scan using idx_orders_customer_id on orders  (cost=0.43..8.45 rows=5 width=128)
  Index Cond: (customer_id = 42)

访问 B-tree 索引,找到匹配的条目,然后从表堆中获取实际行。每次行获取都是对堆的一次随机 I/O 操作。这对高选择性查询很好,但每次堆获取都有成本。

Index Only Scan —— 终极目标#

Index Only Scan using idx_orders_customer_total on orders  (cost=0.43..4.15 rows=5 width=12)
  Index Cond: (customer_id = 42)
  Heap Fetches: 0

这是最快的扫描类型。PostgreSQL 完全从索引中获取它需要的一切,从不接触表堆。当你 SELECT 和 WHERE 涉及的所有列都在索引中时,就会看到这种情况。

有个注意点:Heap Fetches: 0 表示可见性映射是最新的。如果你的表有大量死元组(最近没有 vacuum),PostgreSQL 仍然需要检查堆来验证行的可见性。这就是为什么 autovacuum 对性能很重要,不仅仅是磁盘空间。

Bitmap Scan —— 折中方案#

Bitmap Heap Scan on orders  (cost=45.00..1250.00 rows=5000 width=128)
  Recheck Cond: (created_at > '2026-01-01')
  ->  Bitmap Index Scan on idx_orders_created_at  (cost=0.00..43.75 rows=5000 width=0)
        Index Cond: (created_at > '2026-01-01')

位图扫描是 PostgreSQL 对"对索引扫描来说行太多,对全表扫描来说行又太少"这个问题的解答。它构建一个位图来标记哪些页面包含匹配的行,按物理位置排序,然后按顺序获取。这把随机 I/O 转换成了顺序 I/O。

当两个或更多索引被组合时,你经常会看到位图扫描:

Bitmap Heap Scan on orders
  Recheck Cond: ((status = 'completed') AND (created_at > '2026-01-01'))
  ->  BitmapAnd
        ->  Bitmap Index Scan on idx_orders_status
              Index Cond: (status = 'completed')
        ->  Bitmap Index Scan on idx_orders_created_at
              Index Cond: (created_at > '2026-01-01')

这个 BitmapAnd 组合了两个独立的索引。PostgreSQL 在说"我没有复合索引,但我可以组合这两个单列索引。"这行得通,但一个合适的复合索引会更快。

发现错误估计#

我在查询计划中首先寻找的就是不匹配的估计:

Nested Loop  (cost=0.43..50.12 rows=1 width=128)
             (actual time=0.025..4521.337 rows=48753 loops=1)

规划器估计了 1 行。实际结果是 48753 行。差了五个数量级。规划器选择了 Nested Loop 因为它以为只要连接 1 行。如果是 48753 行,Hash Join 会快好几个数量级。

错误估计的常见原因:

  • 过时的统计信息:对表运行 ANALYZE
  • 相关列:规划器假设列值是独立的。如果 status = 'shipped'created_at > '2026-01-01' 是相关的(最近的订单大多已发货),规划器会低估组合选择性
  • WHERE 中的自定义函数:当规划器无法分析函数时,会使用默认的选择性估计(等值通常 0.5%,范围通常 33%)
  • 使用通用计划的参数化查询:执行 5 次后,PostgreSQL 可能切换到不考虑实际参数值的通用计划

当你看到错误估计时,修复方法通常是:运行 ANALYZE、创建扩展统计信息、重写查询,或使用 CTE 作为优化屏障。

sql
-- 为相关列创建扩展统计信息
CREATE STATISTICS orders_status_date (dependencies)
ON status, created_at FROM orders;
ANALYZE orders;

索引策略:为正确的工作选择正确的索引#

索引不是免费的。每个索引都会拖慢写入速度、消耗磁盘空间,并且需要维护。目标不是"给所有东西加索引"——而是"精确地索引你需要的东西"。

B-tree —— 默认的主力#

B-tree 是默认的索引类型,能处理绝大多数情况。它支持等值和范围运算符(=<><=>=BETWEENINIS NULL)。

sql
CREATE INDEX idx_orders_created_at ON orders (created_at);

B-tree 索引的列顺序对复合索引极其重要。最左边的列是主排序,然后是下一个,以此类推。一个 (a, b, c) 上的索引可以高效地回答:

  • WHERE a = 1
  • WHERE a = 1 AND b = 2
  • WHERE a = 1 AND b = 2 AND c = 3
  • WHERE a = 1 AND b > 5
  • WHERE a = 1 ORDER BY b

无法高效回答:

  • WHERE b = 2(跳过了第一列)
  • WHERE c = 3(跳过了前两列)
  • WHERE a = 1 AND c = 3(中间有空隙——a 条件被使用,c 条件需要过滤)

把它想象成一本按姓氏排序、然后按名字排序的电话簿。你可以快速找到所有"Smith"条目,或者具体的"Smith, John"。但你不能在不扫描整本书的情况下快速找到所有叫"John"的人。

规则:先放等值条件,然后是范围条件,最后是排序列。

sql
-- 查询:WHERE status = 'active' AND created_at > '2026-01-01' ORDER BY total DESC
 
-- 好:先等值,然后范围,然后排序
CREATE INDEX idx_orders_optimized ON orders (status, created_at, total);
 
-- 差:范围在等值之前
CREATE INDEX idx_orders_bad ON orders (created_at, status, total);

使用 INCLUDE 的覆盖索引#

PostgreSQL 11+ 允许你用 INCLUDE 向索引添加非键列。这些列存储在叶子页面中但不是 B-tree 结构的一部分。它们能实现 Index Only Scan 而不会使树膨胀:

sql
-- 我们经常运行:
-- SELECT id, total, status FROM orders WHERE customer_id = 42;
 
CREATE INDEX idx_orders_customer_covering
ON orders (customer_id) INCLUDE (total, status);

现在 PostgreSQL 可以完全从索引中满足整个查询。如果没有 INCLUDE,它需要从堆中获取 totalstatus。在冷缓存上的差异可能非常显著——我见过查询仅仅因为添加了 INCLUDE 列就从 50 毫秒降到了 0.2 毫秒。

部分索引 —— 只索引重要的数据#

这是我最喜欢的 PostgreSQL 功能,也是大多数开发者不知道的。

sql
-- 只有 2% 的订单是 'pending' 状态,但我们不断在查询它们
CREATE INDEX idx_orders_pending ON orders (created_at)
WHERE status = 'pending';

这个索引与 created_at 上的完整索引相比非常小。它只包含 status = 'pending' 的行。匹配 WHERE 子句的查询使用这个小而快的索引。不匹配的查询忽略它。

来自生产环境的真实例子:我有一个 5000 万行的 sessions 表。其中只有大约 20 万行是活跃的(未过期)。user_id 上的完整索引是 1.2 GB。WHERE expires_at > now() 的部分索引只有 8 MB。查询从 12 毫秒降到了 0.1 毫秒,因为整个索引都能放进缓存。

sql
-- 另一个常见模式:软删除
CREATE INDEX idx_users_active_email ON users (email)
WHERE deleted_at IS NULL;
 
-- 唯一约束仅针对活跃记录
CREATE UNIQUE INDEX idx_users_unique_email ON users (email)
WHERE deleted_at IS NULL;

最后一个极其有用。它让你可以对活跃用户设置唯一邮箱约束,同时允许已删除的记录中出现相同的邮箱。

GIN 索引 —— 全文搜索和 JSONB#

GIN(广义倒排索引)是你需要在值内部搜索时的答案——数组、JSONB 文档或全文搜索向量。

sql
-- JSONB 包含查询
CREATE INDEX idx_products_metadata ON products USING GIN (metadata);
 
-- 现在这个很快:
SELECT * FROM products WHERE metadata @> '{"color": "red"}';
 
-- 全文搜索
CREATE INDEX idx_articles_search ON articles USING GIN (to_tsvector('english', title || ' ' || body));

GIN 索引体积大、构建慢,但查询极快。对于有复杂包含查询的读密集工作负载来说,它们是值得的。

对于 JSONB,还有 jsonb_path_ops,它更小更快,用于 @> 包含查询,但不支持存在运算符(??|?&):

sql
CREATE INDEX idx_products_metadata_pathops
ON products USING GIN (metadata jsonb_path_ops);

GiST 索引 —— 几何和范围类型#

GiST(广义搜索树)处理重叠的数据类型:几何形状、范围、全文搜索(GIN 的替代方案,更小但更慢)。

sql
-- IP 范围查找
CREATE INDEX idx_ip_ranges ON ip_blocks USING GiST (ip_range);
 
SELECT * FROM ip_blocks WHERE ip_range @> '192.168.1.50'::inet;
 
-- 时间范围(日程冲突)
CREATE INDEX idx_bookings_period ON bookings USING GiST (
  tstzrange(start_time, end_time)
);
 
-- 查找重叠的预订:
SELECT * FROM bookings
WHERE tstzrange(start_time, end_time) && tstzrange('2026-03-01 10:00', '2026-03-01 12:00');

Hash 索引 —— 小众但有用#

Hash 索引只对等值比较有用。自 PostgreSQL 10 起它们已经 WAL 日志记录且崩溃安全。对于宽列,它们比 B-tree 更小,纯等值查询略快:

sql
-- 适合对大文本列进行纯等值查找
CREATE INDEX idx_sessions_token ON sessions USING HASH (session_token);
 
-- 这个很快:
SELECT * FROM sessions WHERE session_token = 'abc123def456...';
 
-- 这个无法使用 hash 索引:
SELECT * FROM sessions WHERE session_token LIKE 'abc%';

实际上我很少使用 hash 索引。B-tree 处理等值查询完全没问题,而 hash 索引无法做范围查询或排序,这使得它在大多数实际用例中太不灵活。

索引的代价#

每个索引都有成本:

  • 写入放大:每次 INSERT 都要更新表上的每个索引。一个有 8 个索引的表意味着每次 INSERT 有 8 次额外写入
  • HOT 更新被阻塞:Heap-Only Tuple(HOT)更新是一个重要优化,PostgreSQL 可以在不更新索引的情况下更新行,但前提是没有被索引的列发生变化。索引越多 = 阻塞 HOT 更新的概率越大
  • Vacuum 开销:索引越多意味着 vacuum 耗时越长
  • 规划器开销:索引越多意味着规划器需要评估的选项越多

我定期审计生产表上的索引:

sql
-- 查找未使用的索引
SELECT
  schemaname, tablename, indexname,
  idx_scan,
  pg_size_pretty(pg_relation_size(indexrelid)) AS index_size
FROM pg_stat_user_indexes
WHERE idx_scan = 0
  AND indexrelid NOT IN (
    SELECT conindid FROM pg_constraint
    WHERE contype IN ('p', 'u')  -- 排除主键和唯一约束
  )
ORDER BY pg_relation_size(indexrelid) DESC;

我发现过数 GB 的索引,是在一次性迁移中创建的,之后再也没用过。删掉它们后写入速度明显提升。

sql
-- 查找重复索引(相同的列,相同的顺序)
SELECT
  a.indexrelid::regclass AS index1,
  b.indexrelid::regclass AS index2,
  a.indrelid::regclass AS table_name
FROM pg_index a
JOIN pg_index b ON a.indrelid = b.indrelid
  AND a.indexrelid < b.indexrelid
  AND a.indkey = b.indkey;

N+1 查询问题#

每个 ORM 开发者最终都会遇到这个问题。它是我调试最多的性能问题。

N+1 长什么样#

python
# Python / SQLAlchemy —— 经典陷阱
orders = session.query(Order).filter_by(status='pending').all()
 
for order in orders:
    # 这会为每个订单触发一次单独的查询
    print(f"Customer: {order.customer.name}")

这会生成:

sql
-- 查询 1:获取所有待处理订单
SELECT * FROM orders WHERE status = 'pending';
 
-- 查询 2:获取订单 1 的客户
SELECT * FROM customers WHERE id = 101;
 
-- 查询 3:获取订单 2 的客户
SELECT * FROM customers WHERE id = 102;
 
-- ...查询 502:获取订单 500 的客户
SELECT * FROM customers WHERE id = 600;

501 次查询而不是 1 或 2 次。每次查询单独来看都很快,大概 0.5 毫秒。但 501 次累加起来光数据库时间就有 250 毫秒,再加上每次的网络往返延迟。

在日志中检测 N+1#

抓 N+1 查询最快的方法是临时启用语句日志:

sql
-- 记录所有耗时超过 0 毫秒的查询(即所有查询)
SET log_min_duration_statement = 0;
 
-- 生产环境更好的做法:只记录慢查询
ALTER SYSTEM SET log_min_duration_statement = 50;  -- 50 毫秒阈值
SELECT pg_reload_conf();

然后看日志。N+1 是很明显的——你会看到数百条相同的查询,只有参数值不同,快速连续出现。

开发环境更有针对性的方法:

sql
-- 为慢查询启用 auto_explain
LOAD 'auto_explain';
SET auto_explain.log_min_duration = 100;  -- 100 毫秒
SET auto_explain.log_nested_statements = ON;
SET auto_explain.log_analyze = ON;

这会为任何超过 100 毫秒的查询记录完整的 EXPLAIN ANALYZE 输出,包括函数内部的查询。

修复方法:预加载或 JOIN#

ORM 方式 —— 告诉 ORM 提前加载相关数据:

python
# SQLAlchemy —— joinedload 在同一个查询中获取客户
from sqlalchemy.orm import joinedload
 
orders = (
    session.query(Order)
    .options(joinedload(Order.customer))
    .filter_by(status='pending')
    .all()
)

原生 SQL 方式 —— 直接用 JOIN:

sql
SELECT o.*, c.name AS customer_name
FROM orders o
JOIN customers c ON c.id = o.customer_id
WHERE o.status = 'pending';

一次查询。搞定。

JOIN vs 多次查询#

这里有一个合理的讨论。有时候两次查询比一个 JOIN 更好:

sql
-- 方案 1:单个 JOIN(如果是一对多关系可能产生重复数据)
SELECT o.*, i.product_name, i.quantity
FROM orders o
JOIN order_items i ON i.order_id = o.id
WHERE o.status = 'pending';
 
-- 方案 2:两次查询(如果 orders 列很多,数据传输更少)
SELECT * FROM orders WHERE status = 'pending';
-- 应用程序收集 order_ids = [1, 2, 3, ...]
SELECT * FROM order_items WHERE order_id = ANY($1);

JOIN 方式会为每个 item 重复 order 数据。如果每个订单有 20 个 item 并且订单行很宽,那就是 20 倍的数据传输。两次查询的方式每个订单只发送一次。

我的规则:对一对一关系用 JOIN,当"一"端列很多时,考虑对一对多关系使用单独查询。 但一定要做基准测试——第二次查询的网络往返通常比重复数据的成本更高。

常见的查询重写#

有些查询慢不是因为缺索引,而是因为写法问题。PostgreSQL 的规划器很好,但不是魔法。

子查询 vs JOIN vs CTE#

这三种方式可能产生非常不同的计划:

sql
-- WHERE 中的子查询 —— 通常没问题,有时很糟糕
SELECT * FROM orders
WHERE customer_id IN (
  SELECT id FROM customers WHERE country = 'US'
);
 
-- JOIN —— 通常是最佳选择
SELECT o.* FROM orders o
JOIN customers c ON c.id = o.customer_id
WHERE c.country = 'US';
 
-- CTE —— PG 11 及以下会物化,PG 12+ 会优化
WITH us_customers AS (
  SELECT id FROM customers WHERE country = 'US'
)
SELECT o.* FROM orders o
JOIN us_customers uc ON uc.id = o.customer_id;

在 PostgreSQL 12+ 中,CTE 通常会被内联(规划器把它们当作子查询处理),所以性能是一样的。但在 PostgreSQL 11 及以下,CTE 是优化屏障——规划器会物化它们,无法推送谓词。如果你还在用 PG 11,避免在性能关键的查询中使用 CTE。

EXISTS vs IN vs JOIN#

这个问题经常出现:

sql
-- EXISTS —— 找到第一个匹配就停止,适合检查存在性
SELECT * FROM orders o
WHERE EXISTS (
  SELECT 1 FROM returns r WHERE r.order_id = o.id
);
 
-- IN 加子查询 —— 先构建完整的结果集
SELECT * FROM orders
WHERE id IN (SELECT order_id FROM returns);
 
-- JOIN —— 如果 returns 中每个 order 有多行,可能产生重复
SELECT DISTINCT o.* FROM orders o
JOIN returns r ON r.order_id = o.id;

对于大表,EXISTS 通常胜出因为它会短路。IN 版本必须在过滤之前构建完整的退货 order_id 列表。JOIN 版本可能产生重复,需要 DISTINCT,这会增加排序或哈希步骤。

我的默认选择:当检查相关行是否存在时使用 EXISTS。 它语义最清晰,通常也最快。

但有一个反例。如果子查询结果集很小,并且你需要用它进行多个条件判断:

sql
-- 如果子查询返回很少的行,IN 完全没问题
SELECT * FROM orders
WHERE customer_id IN (1, 2, 3, 4, 5);

避免 SELECT *#

这不只是代码风格偏好。它有实际的性能影响:

sql
-- 差:获取所有 25 列,包括一个有数 KB 数据的 TEXT 列
SELECT * FROM products WHERE category = 'electronics';
 
-- 好:只获取你需要的
SELECT id, name, price FROM products WHERE category = 'electronics';

使用 SELECT *

  • 无法使用 Index Only Scan(所有列都需要在索引中)
  • 通过网络传输更多数据
  • 排序和哈希使用更多内存
  • 如果以后有人添加了一个 10MB 的 BYTEA 列,你现有的查询会悄悄变慢

窗口函数 vs 子查询#

窗口函数是 PostgreSQL 最强大的功能之一,几乎总是比相关子查询性能更好:

sql
-- 慢:相关子查询每行执行一次
SELECT
  o.*,
  (SELECT COUNT(*) FROM order_items i WHERE i.order_id = o.id) AS item_count,
  (SELECT SUM(amount) FROM payments p WHERE p.order_id = o.id) AS total_paid
FROM orders o
WHERE o.status = 'completed';
 
-- 快:窗口函数单次扫描计算
SELECT
  o.*,
  COUNT(i.id) OVER (PARTITION BY o.id) AS item_count,
  SUM(p.amount) OVER (PARTITION BY o.id) AS total_paid
FROM orders o
LEFT JOIN order_items i ON i.order_id = o.id
LEFT JOIN payments p ON p.order_id = o.id
WHERE o.status = 'completed';

另一个常见模式——获取每组中最新的一行:

sql
-- 慢:相关子查询
SELECT * FROM notifications n
WHERE n.created_at = (
  SELECT MAX(created_at) FROM notifications
  WHERE user_id = n.user_id
);
 
-- 快:DISTINCT ON(PostgreSQL 特有,非常有用)
SELECT DISTINCT ON (user_id) *
FROM notifications
ORDER BY user_id, created_at DESC;
 
-- 也很快:ROW_NUMBER 窗口函数
SELECT * FROM (
  SELECT *, ROW_NUMBER() OVER (PARTITION BY user_id ORDER BY created_at DESC) AS rn
  FROM notifications
) sub
WHERE rn = 1;

DISTINCT ON 是我处理这种模式的首选。它简洁、可读,配合正确的索引 PostgreSQL 能很好地优化它:

sql
CREATE INDEX idx_notifications_user_latest
ON notifications (user_id, created_at DESC);

正确的分页#

OFFSET 对大数据集来说是个陷阱:

sql
-- 第 1 页:快
SELECT * FROM products ORDER BY id LIMIT 20 OFFSET 0;
 
-- 第 500 页:慢 —— PostgreSQL 必须扫描并丢弃 9980 行
SELECT * FROM products ORDER BY id LIMIT 20 OFFSET 9980;
 
-- 第 5000 页:非常慢 —— 扫描 99980 行只为返回 20 行
SELECT * FROM products ORDER BY id LIMIT 20 OFFSET 99980;

解决方案是键集分页(也叫游标分页):

sql
-- 第一页
SELECT * FROM products ORDER BY id LIMIT 20;
 
-- 下一页:使用上一页最后一个 id
SELECT * FROM products WHERE id > 12345 ORDER BY id LIMIT 20;

这始终很快,因为它使用索引直接跳到正确的位置,不管你在第几"页"。代价是你不能跳到任意页码,但对于无限滚动或"下一页"的 UI 来说,键集分页严格优于 OFFSET。

对于复杂的排序:

sql
-- 多排序列的键集分页
SELECT * FROM products
WHERE (price, id) > (29.99, 12345)
ORDER BY price, id
LIMIT 20;

这使用了行值比较,PostgreSQL 用 (price, id) 上的复合索引能高效处理。

表统计信息和 Vacuum#

PostgreSQL 的查询规划器基于数据的统计信息做决策。错误的统计信息导致错误的计划。就这么简单。

ANALYZE:更新统计信息#

sql
-- 分析单个表
ANALYZE orders;
 
-- 分析整个数据库
ANALYZE;
 
-- 查看某列的当前统计信息
SELECT
  tablename, attname, n_distinct, most_common_vals, correlation
FROM pg_stats
WHERE tablename = 'orders' AND attname = 'status';

correlation 值特别有趣。它的范围是 -1 到 1,衡量行的物理顺序与列的逻辑顺序匹配程度。接近 1 或 -1 的相关性意味着数据在物理上按该列排序,使得范围扫描非常高效(顺序 I/O)。接近 0 的相关性意味着范围查询是随机 I/O。

sql
-- 为分布偏斜的列增加统计信息目标
ALTER TABLE orders ALTER COLUMN status SET STATISTICS 1000;
ANALYZE orders;

默认的统计信息目标是 100,意味着 PostgreSQL 采样 300 * 100 = 30000 行。对于有很多不同值或分布偏斜的列,将其增加到 500 或 1000 能给规划器更好的数据,代价是 ANALYZE 时间略长。

Autovacuum 调优#

Autovacuum 做两件事:从死元组(已删除或已更新的行)回收空间,以及更新统计信息。在繁忙的表上,默认的 autovacuum 设置通常太保守。

sql
-- 检查 autovacuum 状态
SELECT
  relname,
  n_live_tup,
  n_dead_tup,
  last_vacuum,
  last_autovacuum,
  last_analyze,
  last_autoanalyze
FROM pg_stat_user_tables
WHERE n_dead_tup > 1000
ORDER BY n_dead_tup DESC;

如果你看到数百万死元组而上次 vacuum 是几小时前,说明 autovacuum 跟不上了。

对于高变动的表(如 sessions、任务队列或指标),我设置每表的 autovacuum 参数:

sql
-- 对高写入表更积极的 autovacuum
ALTER TABLE sessions SET (
  autovacuum_vacuum_scale_factor = 0.01,     -- 1% 死元组时 vacuum(默认 20%)
  autovacuum_analyze_scale_factor = 0.005,   -- 0.5% 变更时 analyze(默认 10%)
  autovacuum_vacuum_cost_delay = 2           -- 更快的 vacuum(新版 PG 默认 2 毫秒)
);

表膨胀#

当 PostgreSQL 更新一行时,它不是原地修改——而是创建一个新版本并标记旧版本为死元组。Vacuum 回收死行,但空间只被该表重用。磁盘上的表文件不会缩小。

随着时间推移,频繁更新的表可能有严重的膨胀——表在磁盘上的大小远超活跃数据所需。这意味着更多的页面要扫描、更多的 I/O、更大的缓存压力。

sql
-- 估算表膨胀(简化版)
SELECT
  current_database(),
  schemaname,
  tablename,
  pg_size_pretty(pg_total_relation_size(schemaname || '.' || tablename)) AS total_size,
  pg_size_pretty(
    pg_total_relation_size(schemaname || '.' || tablename) -
    pg_relation_size(schemaname || '.' || tablename)
  ) AS index_size
FROM pg_tables
WHERE schemaname = 'public'
ORDER BY pg_total_relation_size(schemaname || '.' || tablename) DESC
LIMIT 20;

对于严重膨胀的表,终极方案是 VACUUM FULL——它重写整个表。但它需要排他锁,所以在不停机的情况下不能对生产表执行。更好的方案是 pg_repack,它做同样的事但不锁表:

bash
pg_repack --table orders --no-kill-backend -d mydb

连接池#

有一件事让很多开发者感到意外:PostgreSQL 连接是昂贵的。每个连接都会产生一个新进程(不是线程),消耗大约 5-10 MB 内存,并且有不小的 fork 开销。

默认的 max_connections 是 100。如果你有一个应用服务器带 20 个 worker,每个打开 5 个连接,你就已经到限制了。再加上一个后台任务处理器、一个监控工具和一个正在运行的迁移,你就出问题了。

为什么需要连接池#

没有连接池的话,如果你的应用需要处理 500 个并发请求,你就需要 500 个 PostgreSQL 连接。光连接开销就要 5 GB 内存,而且 PostgreSQL 在超过几百个连接后,由于进程管理开销,性能会显著下降。

在 PostgreSQL 前面放一个 PgBouncer,这 500 个应用连接映射到大约 20 个实际的 PostgreSQL 连接。连接池在所有数据库连接都忙时会将请求排队。

PgBouncer 配置#

ini
[databases]
mydb = host=127.0.0.1 port=5432 dbname=mydb
 
[pgbouncer]
listen_addr = 127.0.0.1
listen_port = 6432
auth_type = md5
auth_file = /etc/pgbouncer/userlist.txt
 
; 池模式
pool_mode = transaction
 
; 池大小
default_pool_size = 20
max_client_conn = 500
min_pool_size = 5
reserve_pool_size = 5
reserve_pool_timeout = 3
 
; 超时
server_idle_timeout = 300
client_idle_timeout = 0
query_timeout = 30

事务模式 vs 会话模式#

  • 事务模式pool_mode = transaction):每个事务结束后连接归还池。这是 95% 情况下你想要的。最大化连接复用。
  • 会话模式pool_mode = session):连接在整个客户端会话期间保持。如果你需要预处理语句、SET 命令、LISTEN/NOTIFY 或其他会话级功能,使用这个。
  • 语句模式pool_mode = statement):每条语句后连接归还。对大多数应用来说限制太多——你甚至不能使用显式事务。

事务模式的坑:你不能使用预处理语句(它们是会话级状态),你不能用 SET 设置会话变量,LISTEN/NOTIFY 也不会按预期工作。大多数 ORM 都有禁用预处理语句的方式。

Node.js 中用 pg 驱动:

javascript
const pool = new Pool({
  host: '127.0.0.1',
  port: 6432,  // PgBouncer 端口,不是 PostgreSQL 端口
  database: 'mydb',
  max: 20,     // 与 PgBouncer 的 default_pool_size 匹配
  statement_timeout: 30000,
  // 为 PgBouncer 事务模式禁用预处理语句
  prepare: false,
});

池大小公式#

有一个常见的 PostgreSQL 连接池最优大小公式:

最优连接数 = (核心数 * 2) + 有效磁盘数

对于一台 4 核、使用 SSD(1 个有效磁盘)的现代服务器:

最优 = (4 * 2) + 1 = 9

这看起来低得不可思议。但 PostgreSQL 在大多数现代硬件上是 CPU 密集型的(SSD 足够快,I/O 很少成为瓶颈)。超过这个数量的连接会导致上下文切换开销,实际上反而变慢。

实际操作中,我通常将 default_pool_size 设置为这个数字的 2-3 倍来应对突发流量,理解在峰值负载时,一些查询会在 PgBouncer 队列中等待,而不是全部同时打到 PostgreSQL 上。

实用检查清单:每次慢查询的精确步骤#

这是我收到"这个查询很慢"报告时的实际流程。我每次都按这个顺序执行这些步骤。

第 1 步:拿到实际的查询#

不是"这个接口慢"——而是实际的 SQL。如果你在用 ORM,启用查询日志:

sql
-- 临时记录所有超过 100 毫秒的查询
ALTER SYSTEM SET log_min_duration_statement = 100;
SELECT pg_reload_conf();

或者检查 pg_stat_statements 中按总时间排名的查询:

sql
-- 按总执行时间排名的前 10 条查询
SELECT
  queryid,
  calls,
  round(total_exec_time::numeric, 2) AS total_time_ms,
  round(mean_exec_time::numeric, 2) AS avg_time_ms,
  round((100 * total_exec_time / sum(total_exec_time) OVER ())::numeric, 2) AS pct,
  query
FROM pg_stat_statements
ORDER BY total_exec_time DESC
LIMIT 10;

pg_stat_statements 是 PostgreSQL 性能方面最有价值的扩展。如果你没在用,现在就装上:

sql
CREATE EXTENSION IF NOT EXISTS pg_stat_statements;

第 2 步:运行 EXPLAIN (ANALYZE, BUFFERS)#

sql
EXPLAIN (ANALYZE, BUFFERS, FORMAT TEXT) <那条慢查询>;

关注:

  1. 错误的行数估计 —— 实际行数与预估行数差异很大
  2. 大表上的 Seq Scan —— 可能缺少索引
  3. 大量行的 Nested Loop —— 应该是 Hash Join 或 Merge Join
  4. 高 buffer read —— 冷缓存或表太大
  5. 排序溢出到磁盘 —— 增加 work_mem 或为排序添加索引

第 3 步:检查表统计信息#

sql
-- 统计信息上次更新是什么时候?
SELECT
  relname,
  last_analyze,
  last_autoanalyze,
  n_live_tup,
  n_dead_tup
FROM pg_stat_user_tables
WHERE relname = 'orders';

如果 last_analyze 很久远,或者 n_dead_tup 相对于 n_live_tup 很高,运行:

sql
ANALYZE orders;

然后重新运行 EXPLAIN ANALYZE。如果计划变了,过时的统计信息就是问题所在。

第 4 步:检查现有索引#

sql
-- 这个表上有哪些索引?
SELECT
  indexname,
  indexdef,
  idx_scan,
  idx_tup_read,
  pg_size_pretty(pg_relation_size(indexrelid)) AS size
FROM pg_stat_user_indexes
JOIN pg_indexes ON pg_stat_user_indexes.indexrelname = pg_indexes.indexname
WHERE tablename = 'orders'
ORDER BY idx_scan DESC;

也许索引存在但没被使用。也许你的查询中列顺序不对。

第 5 步:创建或修改索引#

根据查询计划,创建合适的索引。在前后都用 EXPLAIN ANALYZE 测试。

sql
-- 并发创建索引(不锁表)
CREATE INDEX CONCURRENTLY idx_orders_customer_status
ON orders (customer_id, status)
WHERE status IN ('pending', 'processing');
 
-- 验证索引被使用
EXPLAIN (ANALYZE, BUFFERS) <那条慢查询>;

在生产表上始终使用 CONCURRENTLY。普通的 CREATE INDEX 会获取全表锁,阻塞所有写入。

第 6 步:考虑查询重写#

如果索引存在、统计信息是最新的但查询仍然慢,看看查询本身:

  • 子查询能否重写为 JOIN?
  • OFFSET 是否造成问题?改用键集分页
  • 你是否在选择多余的列?
  • 相关子查询能否变成窗口函数?
  • CTE 是否阻止了规划器的优化?

第 7 步:检查服务器设置#

对于特定的查询模式,服务器设置很重要:

sql
-- work_mem:排序和哈希连接的内存(每次操作,不是每个连接)
-- 默认是 4MB,对复杂查询来说太低
SET work_mem = '64MB';  -- 试试这个然后重新运行 EXPLAIN ANALYZE
 
-- effective_cache_size:告诉规划器预期有多少磁盘缓存
-- 设为总内存的约 75%
SHOW effective_cache_size;
 
-- random_page_cost:随机 I/O 与顺序 I/O 的比率
-- 默认是 4.0,SSD 设为 1.1-1.5
SHOW random_page_cost;

如果修改 work_mem 解决了排序溢出问题,考虑全局增加它。但要小心——它是每次操作而非每个连接的。一个有 10 次排序操作且 work_mem = 256MB 的复杂查询,单个查询可能使用 2.5 GB。

第 8 步:修复后持续监控#

不要修完就忘了。验证修复持续有效:

sql
-- 重置 pg_stat_statements 以查看更改后的新数据
SELECT pg_stat_statements_reset();
 
-- 一小时/一天后再检查
SELECT
  queryid,
  calls,
  round(mean_exec_time::numeric, 2) AS avg_time_ms,
  round(max_exec_time::numeric, 2) AS max_time_ms,
  query
FROM pg_stat_statements
WHERE query LIKE '%orders%'
ORDER BY mean_exec_time DESC;

附加:每个新数据库我都会做的速效优化#

这些是我在每个 PostgreSQL 数据库上线之前就会设置的配置和实践,在任何性能问题出现之前:

sql
-- 1. 安装 pg_stat_statements
CREATE EXTENSION IF NOT EXISTS pg_stat_statements;
 
-- 2. 启用慢查询日志(50 毫秒是我的阈值)
ALTER SYSTEM SET log_min_duration_statement = 50;
 
-- 3. 适合 SSD 的设置
ALTER SYSTEM SET random_page_cost = 1.1;
ALTER SYSTEM SET effective_io_concurrency = 200;
 
-- 4. 从默认的贫血值增加 work_mem
ALTER SYSTEM SET work_mem = '32MB';
 
-- 5. 将 effective_cache_size 设为内存的 75%
-- 对于 16GB 的服务器:
ALTER SYSTEM SET effective_cache_size = '12GB';
 
-- 6. 将 shared_buffers 设为内存的 25%
-- 对于 16GB 的服务器:
ALTER SYSTEM SET shared_buffers = '4GB';
 
-- 7. 提高 autovacuum 响应性
ALTER SYSTEM SET autovacuum_naptime = '10s';
 
-- 应用更改
SELECT pg_reload_conf();
-- 注意:shared_buffers 需要重启

我每周运行的监控查询:

sql
-- 需要关注的表
SELECT
  schemaname || '.' || relname AS table,
  pg_size_pretty(pg_total_relation_size(relid)) AS total_size,
  n_live_tup,
  n_dead_tup,
  CASE WHEN n_live_tup > 0
    THEN round(100.0 * n_dead_tup / n_live_tup, 1)
    ELSE 0
  END AS dead_pct,
  last_autovacuum,
  last_autoanalyze
FROM pg_stat_user_tables
WHERE n_dead_tup > 10000
ORDER BY n_dead_tup DESC;

最后的想法#

PostgreSQL 查询优化不是什么黑魔法。它是一个系统化的过程:

  1. 测量 —— 不要猜。EXPLAIN (ANALYZE, BUFFERS) 是你最好的朋友。
  2. 理解计划 —— 学会阅读扫描类型、连接类型和行数估计。
  3. 策略性地建索引 —— 在正确的列上、以正确的顺序建立正确的索引。部分索引和覆盖索引是被低估的超能力。
  4. 写更好的查询 —— 存在性检查用 EXISTS 而非 IN,键集分页而非 OFFSET,窗口函数而非相关子查询。
  5. 维护数据库 —— autovacuum 调优、统计信息更新、连接池。
  6. 持续监控 —— pg_stat_statements 告诉你数据库的时间花在了哪里。定期检查。

一个查询从 4 秒到 0.3 毫秒的差异,很少是因为硬件。几乎总是因为知识——知道该看哪里、该改什么。现在你知道该看哪里了。

相关文章