PostgreSQL 明明有索引却选了 Nested Loop:从行数误判修正执行计划

同一条订单查询,小参数时几十毫秒,大参数时却长时间占用数据库;EXPLAIN 显示优化器预计返回 12 行,实际执行产生了十几万行,随后 Nested Loop 的内表被反复扫描。问题不在于 PostgreSQL "不认识索引",而在于它基于错误的行数估计选错了连接路径。

本文用一个 country 与 currency 强相关的例子说明如何找到第一次估算分叉。文中的 SQL 是可执行的诊断样例,但这里没有连接你的数据库,因此不会把示例结果写成实测结论。

先复现:两个相关条件被当成彼此独立

假设订单表中,中国区订单几乎都以 CNY 结算:

sql 复制代码
CREATE TABLE orders (
    id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
    customer_id bigint NOT NULL,
    country text NOT NULL,
    currency text NOT NULL,
    created_at timestamptz NOT NULL
);

CREATE INDEX idx_orders_customer_created
    ON orders (customer_id, created_at DESC);

只收集单列统计信息时,规划器可能分别估计 country = 'CN' 与 currency = 'CNY' 的选择率,再把两者相乘。真实数据中的相关性没有进入模型,中间结果便会被严重低估。

在测试库中用下面的命令保留执行证据:

sql 复制代码
EXPLAIN (ANALYZE, BUFFERS, SETTINGS, FORMAT TEXT)
SELECT c.id, o.id, o.created_at
FROM customers AS c
JOIN orders AS o ON o.customer_id = c.id
WHERE o.country = 'CN'
  AND o.currency = 'CNY'
  AND o.created_at >= now() - interval '30 days';

ANALYZE 会真的执行查询,不要把修改型 SQL 原样放到生产环境。排查时从计划树内层向外找第一个 rows 与 actual rows 明显分叉的节点,并把 loops 一起看;最外层耗时只是结果,第一次误判才是线索。

不要先禁用连接算法,先检查规划器掌握了什么

先查看自动分析时间和列分布:

sql 复制代码
SELECT relname, last_analyze, last_autoanalyze, n_live_tup
FROM pg_stat_user_tables
WHERE relname IN ('orders', 'customers');

SELECT attname, n_distinct, most_common_vals, most_common_freqs
FROM pg_stats
WHERE schemaname = 'public'
  AND tablename = 'orders'
  AND attname IN ('country', 'currency', 'customer_id');

成功的诊断不是"强制走了 Hash Join",而是能回答三个问题:统计信息是否过期、目标值是否在高频值列表中、多个过滤列是否存在业务相关性。若只是批量导入后统计信息陈旧,先执行 ANALYZE orders;若误差稳定来自相关列,再考虑扩展统计:

sql 复制代码
CREATE STATISTICS st_orders_country_currency (dependencies, mcv)
ON country, currency
FROM orders;

ANALYZE orders;

dependencies 描述列依赖,mcv 保存常见组合。它们帮助过滤条件估算,但不会自动替代缺失的连接索引,也不能修复写错的 Join 条件。

做一个反事实实验,而不是永久关闭 Nested Loop

在事务内临时改变规划器开关,可以验证"另一类计划是否值得继续调查":

sql 复制代码
BEGIN;
SET LOCAL enable_nestloop = off;
EXPLAIN (ANALYZE, BUFFERS)
SELECT /* 同一条查询,参数保持一致 */;
ROLLBACK;

这只是反事实实验。若另一计划更合适,应继续修正统计信息、SQL 或索引,而不是在全局配置中禁用 Nested Loop。小结果集驱动索引查找时,Nested Loop 往往正是正确选择。

还要防止只验证一组参数。把典型小客户、普通客户和头部客户的参数各选一组,分别保存计划。预备语句使用通用计划时,参数分布差异尤其容易被平均值掩盖。

验收修复:关注估算误差,而非计划节点名称

可以把计划保存为 JSON,再检查目标节点的估算倍率:

bash 复制代码
psql "$DATABASE_URL" -X -v ON_ERROR_STOP=1 -Atc \
  "EXPLAIN (ANALYZE, BUFFERS, FORMAT JSON)
   SELECT c.id, o.id FROM customers c
   JOIN orders o ON o.customer_id=c.id
   WHERE o.country='CN' AND o.currency='CNY';" \
  > plan.json

python -m json.tool plan.json >/dev/null
test -s plan.json

上述命令的可验证成功条件是:psql 退出码为 0、plan.json 非空且能被 JSON 解析。性能层面的验收还需比较修改前后相同数据快照、相同参数和相同缓存条件下的计划;重点记录首次分叉节点的估算/实际行数倍率、缓冲区读取和总执行时间。失败条件包括估算误差没有缩小、只对单一参数改善,或其他高频查询出现回退。

优化器调优的目标不是让 SQL 永远使用某个索引,而是让成本模型获得足够准确的输入。先定位第一次估错,再决定更新统计、添加扩展统计、调整索引还是改写查询,通常比直接改全局成本参数更可控。

相关推荐
这个DBA有点耶2 小时前
函数索引的“完全一致”有多苛刻:写法、字符集、类型,差一处就失效
数据库·mysql·代码规范
星恒随风2 小时前
MySQL 从零入门(三):表的约束详解
linux·服务器·数据库·笔记·mysql
Alluxio3 小时前
Alluxio + OCI:为 AI 工作负载实现亚毫秒访问延迟与可扩展吞吐
运维·数据库·人工智能·分布式·缓存·ai·语言模型
数据库急诊室_阿哲6 小时前
一次加索引建议的复核记录:从报告到签字
数据库·ai数据库
青瓦梦滋7 小时前
Redis主从复制
服务器·数据库·redis·缓存·bootstrap
慧都小妮子8 小时前
Wyn商业智能国产数据库适配:数据接入、分析缓存与审计日志验证清单
数据库·数据分析·数据可视化·审计日志·商业智能
Joy T9 小时前
Redis Lua 原子限流:Lua 脚本、竞态条件与并发控制
数据库·redis·lua·限流·竞态条件·redis scripting
杨凯凡9 小时前
MySQL 死锁怎么排查?InnoDB 锁等待与 Spring Boot 整事务重试实战
数据库·spring boot·mysql
Aloudata9 小时前
Claude 做数据分析的这两年:一份复盘与发展线索
大数据·数据库·人工智能·数据挖掘·数据分析·dataagent
Decay__9 小时前
01_SQLServer 体系架构全解 —— 实例进程、关系引擎、SQLOS 与事务日志深入
运维·数据库·sql·mysql·架构