PostgreSQL 查询优化:从龟速到亚毫秒级
我在生产数据库上使用的 PostgreSQL 优化技巧。EXPLAIN ANALYZE、索引策略、查询重写,以及将查询时间从数秒降到微秒级的具体改动。
上个月凌晨两点我收到了一条 Slack 消息:"仪表盘超时了。"我 SSH 进生产服务器,打开 pg_stat_activity,发现一条查询扫描了 1400 万行却只返回了 12 条结果。修复方法是一个花了 30 秒创建的部分索引。查询时间从 4.2 秒降到了 0.3 毫秒。
PostgreSQL 性能问题就是这样。问题几乎从来不是什么高深莫测的东西。无非是遗漏的索引、过时的统计信息、在表只有 1 万行时合理但现在已经有了 1000 万行的查询。修复通常很简单——只要你知道该看哪里。
这篇文章是我从运维生产数据库中学到的关于 PostgreSQL 查询优化的一切。没有脱离实践的理论。这里的每个技巧都在真实系统上为我节省了真实的时间。
调试思维:不要猜,要量#
我见到开发者处理慢查询时犯的最大错误就是靠猜。"也许我们需要在那个列上加个索引。""也许是 JOIN 慢了。""也许我们该加内存。"
别猜了。PostgreSQL 拥有所有数据库中最好的查询分析器之一。用它。
EXPLAIN —— 蓝图#
普通的 EXPLAIN 显示 PostgreSQL 计划做什么,但不会真正执行查询:
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 —— 真相#
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 会真正执行查询。如果你在分析 UPDATE 或 DELETE,把它包在事务里:
BEGIN;
EXPLAIN ANALYZE DELETE FROM sessions WHERE expires_at < now();
ROLLBACK;EXPLAIN (ANALYZE, BUFFERS) —— 完整画面#
这是我 90% 时间实际使用的:
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。树形视图让复杂的计划更容易阅读:
EXPLAIN (ANALYZE, BUFFERS, FORMAT JSON) SELECT ...;我实际使用的 EXPLAIN 选项#
以下是我调查任何慢查询时首先运行的完整诊断查询:
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 作为优化屏障。
-- 为相关列创建扩展统计信息
CREATE STATISTICS orders_status_date (dependencies)
ON status, created_at FROM orders;
ANALYZE orders;索引策略:为正确的工作选择正确的索引#
索引不是免费的。每个索引都会拖慢写入速度、消耗磁盘空间,并且需要维护。目标不是"给所有东西加索引"——而是"精确地索引你需要的东西"。
B-tree —— 默认的主力#
B-tree 是默认的索引类型,能处理绝大多数情况。它支持等值和范围运算符(=、<、>、<=、>=、BETWEEN、IN、IS NULL)。
CREATE INDEX idx_orders_created_at ON orders (created_at);B-tree 索引的列顺序对复合索引极其重要。最左边的列是主排序,然后是下一个,以此类推。一个 (a, b, c) 上的索引可以高效地回答:
WHERE a = 1WHERE a = 1 AND b = 2WHERE a = 1 AND b = 2 AND c = 3WHERE a = 1 AND b > 5WHERE a = 1 ORDER BY b
它无法高效回答:
WHERE b = 2(跳过了第一列)WHERE c = 3(跳过了前两列)WHERE a = 1 AND c = 3(中间有空隙——a条件被使用,c条件需要过滤)
把它想象成一本按姓氏排序、然后按名字排序的电话簿。你可以快速找到所有"Smith"条目,或者具体的"Smith, John"。但你不能在不扫描整本书的情况下快速找到所有叫"John"的人。
规则:先放等值条件,然后是范围条件,最后是排序列。
-- 查询: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 而不会使树膨胀:
-- 我们经常运行:
-- 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,它需要从堆中获取 total 和 status。在冷缓存上的差异可能非常显著——我见过查询仅仅因为添加了 INCLUDE 列就从 50 毫秒降到了 0.2 毫秒。
部分索引 —— 只索引重要的数据#
这是我最喜欢的 PostgreSQL 功能,也是大多数开发者不知道的。
-- 只有 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 毫秒,因为整个索引都能放进缓存。
-- 另一个常见模式:软删除
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 文档或全文搜索向量。
-- 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,它更小更快,用于 @> 包含查询,但不支持存在运算符(?、?|、?&):
CREATE INDEX idx_products_metadata_pathops
ON products USING GIN (metadata jsonb_path_ops);GiST 索引 —— 几何和范围类型#
GiST(广义搜索树)处理重叠的数据类型:几何形状、范围、全文搜索(GIN 的替代方案,更小但更慢)。
-- 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 更小,纯等值查询略快:
-- 适合对大文本列进行纯等值查找
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 耗时越长
- 规划器开销:索引越多意味着规划器需要评估的选项越多
我定期审计生产表上的索引:
-- 查找未使用的索引
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 的索引,是在一次性迁移中创建的,之后再也没用过。删掉它们后写入速度明显提升。
-- 查找重复索引(相同的列,相同的顺序)
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 / SQLAlchemy —— 经典陷阱
orders = session.query(Order).filter_by(status='pending').all()
for order in orders:
# 这会为每个订单触发一次单独的查询
print(f"Customer: {order.customer.name}")这会生成:
-- 查询 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 查询最快的方法是临时启用语句日志:
-- 记录所有耗时超过 0 毫秒的查询(即所有查询)
SET log_min_duration_statement = 0;
-- 生产环境更好的做法:只记录慢查询
ALTER SYSTEM SET log_min_duration_statement = 50; -- 50 毫秒阈值
SELECT pg_reload_conf();然后看日志。N+1 是很明显的——你会看到数百条相同的查询,只有参数值不同,快速连续出现。
开发环境更有针对性的方法:
-- 为慢查询启用 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 提前加载相关数据:
# SQLAlchemy —— joinedload 在同一个查询中获取客户
from sqlalchemy.orm import joinedload
orders = (
session.query(Order)
.options(joinedload(Order.customer))
.filter_by(status='pending')
.all()
)原生 SQL 方式 —— 直接用 JOIN:
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 更好:
-- 方案 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#
这三种方式可能产生非常不同的计划:
-- 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#
这个问题经常出现:
-- 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。 它语义最清晰,通常也最快。
但有一个反例。如果子查询结果集很小,并且你需要用它进行多个条件判断:
-- 如果子查询返回很少的行,IN 完全没问题
SELECT * FROM orders
WHERE customer_id IN (1, 2, 3, 4, 5);避免 SELECT *#
这不只是代码风格偏好。它有实际的性能影响:
-- 差:获取所有 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 最强大的功能之一,几乎总是比相关子查询性能更好:
-- 慢:相关子查询每行执行一次
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';另一个常见模式——获取每组中最新的一行:
-- 慢:相关子查询
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 能很好地优化它:
CREATE INDEX idx_notifications_user_latest
ON notifications (user_id, created_at DESC);正确的分页#
OFFSET 对大数据集来说是个陷阱:
-- 第 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;解决方案是键集分页(也叫游标分页):
-- 第一页
SELECT * FROM products ORDER BY id LIMIT 20;
-- 下一页:使用上一页最后一个 id
SELECT * FROM products WHERE id > 12345 ORDER BY id LIMIT 20;这始终很快,因为它使用索引直接跳到正确的位置,不管你在第几"页"。代价是你不能跳到任意页码,但对于无限滚动或"下一页"的 UI 来说,键集分页严格优于 OFFSET。
对于复杂的排序:
-- 多排序列的键集分页
SELECT * FROM products
WHERE (price, id) > (29.99, 12345)
ORDER BY price, id
LIMIT 20;这使用了行值比较,PostgreSQL 用 (price, id) 上的复合索引能高效处理。
表统计信息和 Vacuum#
PostgreSQL 的查询规划器基于数据的统计信息做决策。错误的统计信息导致错误的计划。就这么简单。
ANALYZE:更新统计信息#
-- 分析单个表
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。
-- 为分布偏斜的列增加统计信息目标
ALTER TABLE orders ALTER COLUMN status SET STATISTICS 1000;
ANALYZE orders;默认的统计信息目标是 100,意味着 PostgreSQL 采样 300 * 100 = 30000 行。对于有很多不同值或分布偏斜的列,将其增加到 500 或 1000 能给规划器更好的数据,代价是 ANALYZE 时间略长。
Autovacuum 调优#
Autovacuum 做两件事:从死元组(已删除或已更新的行)回收空间,以及更新统计信息。在繁忙的表上,默认的 autovacuum 设置通常太保守。
-- 检查 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 参数:
-- 对高写入表更积极的 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、更大的缓存压力。
-- 估算表膨胀(简化版)
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,它做同样的事但不锁表:
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 配置#
[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 驱动:
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,启用查询日志:
-- 临时记录所有超过 100 毫秒的查询
ALTER SYSTEM SET log_min_duration_statement = 100;
SELECT pg_reload_conf();或者检查 pg_stat_statements 中按总时间排名的查询:
-- 按总执行时间排名的前 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 性能方面最有价值的扩展。如果你没在用,现在就装上:
CREATE EXTENSION IF NOT EXISTS pg_stat_statements;第 2 步:运行 EXPLAIN (ANALYZE, BUFFERS)#
EXPLAIN (ANALYZE, BUFFERS, FORMAT TEXT) <那条慢查询>;关注:
- 错误的行数估计 —— 实际行数与预估行数差异很大
- 大表上的 Seq Scan —— 可能缺少索引
- 大量行的 Nested Loop —— 应该是 Hash Join 或 Merge Join
- 高 buffer read —— 冷缓存或表太大
- 排序溢出到磁盘 —— 增加
work_mem或为排序添加索引
第 3 步:检查表统计信息#
-- 统计信息上次更新是什么时候?
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 很高,运行:
ANALYZE orders;然后重新运行 EXPLAIN ANALYZE。如果计划变了,过时的统计信息就是问题所在。
第 4 步:检查现有索引#
-- 这个表上有哪些索引?
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 测试。
-- 并发创建索引(不锁表)
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 步:检查服务器设置#
对于特定的查询模式,服务器设置很重要:
-- 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 步:修复后持续监控#
不要修完就忘了。验证修复持续有效:
-- 重置 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 数据库上线之前就会设置的配置和实践,在任何性能问题出现之前:
-- 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 需要重启我每周运行的监控查询:
-- 需要关注的表
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 查询优化不是什么黑魔法。它是一个系统化的过程:
- 测量 —— 不要猜。EXPLAIN (ANALYZE, BUFFERS) 是你最好的朋友。
- 理解计划 —— 学会阅读扫描类型、连接类型和行数估计。
- 策略性地建索引 —— 在正确的列上、以正确的顺序建立正确的索引。部分索引和覆盖索引是被低估的超能力。
- 写更好的查询 —— 存在性检查用 EXISTS 而非 IN,键集分页而非 OFFSET,窗口函数而非相关子查询。
- 维护数据库 —— autovacuum 调优、统计信息更新、连接池。
- 持续监控 —— pg_stat_statements 告诉你数据库的时间花在了哪里。定期检查。
一个查询从 4 秒到 0.3 毫秒的差异,很少是因为硬件。几乎总是因为知识——知道该看哪里、该改什么。现在你知道该看哪里了。