PostgreSQL ORDER BY+LIMIT 时的索引选择
当使用 ORDER BY+LIMIT 时 PostgreSQL 可能会选择更差的执行方式,数据量大时,执行效率相差成百上千倍。
索引选择问题
假设结构如下
create table organizations(
  id bigserial primary key,
  jgmc text,
  clrq date
);
create index if not exists idx_organizations_clrq on ic.organizations (clrq);
create index if not exists idx_organizations_jgmc_fts on ic.organizations using pgroonga (jgmc);
查询通常基于 clrq 排序,但会 搜索 jgmc。
explain analyse verbose
select jgmc, clrq, id
from ic.organizations
where jgmc &@~ '百度'
  and clrq is not null
order by clrq desc
limit 30 offset 0
;
因为索引默认隐含 nulls last,也就是说 order by clrq asc 隐含 order by clrq asc nulls last。
当使用 order by clrq desc nulls last 时不会选择 idx_organizations_clrq 索引,因为 nulls 顺序不匹配。
因此使用 is not null 排除。
给出的执行计划和结果如下
Limit  (cost=0.43..33132.51 rows=30 width=1284) (actual time=4678.918..13104.109 rows=6 loops=1)
"  Output: jgmc, clrq, id"
  ->  Index Scan Backward using idx_organizations_clrq on ic.organizations  (cost=0.43..4069724.46 rows=3685 width=1284) (actual time=4678.916..13104.101 rows=6 loops=1)
"        Output: jgmc, clrq, id"
        Index Cond: (organizations.clrq IS NOT NULL)
        Filter: (organizations.jgmc &@~ '百度'::text)
        Rows Removed by Filter: 3685166
Planning Time: 0.826 ms
Execution Time: 13104.226 ms
实际执行了 13s,选择了 idx_organizations_clrq 索引。
但如果去掉 LIMIT:
explain analyse verbose
select jgmc, clrq, id
from ic.organizations
where jgmc &@~ '百度'
  and clrq is not null
order by clrq desc
-- limit 30 offset 0
;
给出的计划如下
Sort  (cost=40479.53..40488.74 rows=3685 width=1284) (actual time=19.771..19.774 rows=6 loops=1)
"  Output: jgmc, clrq, id"
  Sort Key: organizations.clrq DESC
  Sort Method: quicksort  Memory: 36kB
  ->  Index Scan using idx_organizations_jgmc_fts on ic.organizations  (cost=0.00..40261.24 rows=3685 width=1284) (actual time=19.716..19.737 rows=6 loops=1)
"        Output: jgmc, clrq, id"
        Index Cond: (organizations.jgmc &@~ '百度'::text)
        Filter: (organizations.clrq IS NOT NULL)
Planning Time: 0.706 ms
Execution Time: 21.824 ms
实际执行只需要 21ms,选择了 idx_organizations_jgmc_fts 索引。
PostgreSQL 在这样的场景下选择了错误的索引,导致查询时间相差 600 倍。
索引选择原因
ORDER BY+LIMIT 让查询有 提前结束的可能。
例如 实际数据 10k,但 limit 10, 只需使用索引扫描 10 条数据便可以停止执行,而不需要先判断 其他 条件。
因此 PostgreSQL 的 optimizer 有让这种选择优先的逻辑 pathnode.c#L3633-L3753。
- create_limit_path 针对 LIMIT/OFFSET 创建执行计划
- adjust_limit_rows_costs 调整此时的 rows costs
 
 
*total_cost = *startup_cost +
  (input_total_cost - input_startup_cost)
  * count_rows / input_rows;
| var | meaning | 
|---|---|
| total_cost | 总 cost | 
| startup_cost | 初始 cost | 
| input_total_cost | 输入总 cost | 
| input_startup_cost | 输入初始总 cost | 
| count_rows | limit | 
| input_rows | 输入 行 | 
在这个调整逻辑里会将现在的过程 cost input_total_cost - input_startup_cost 乘以 系数 count_rows / input_rows。
这个系数便会使得 ORDER BY+LIMIT 时优先选择索引。
因为两个计划的 rows 相同,优先了 排序索引则会选择排序索引方案。
解决问题
避免选择排序索引
- 使用 noop function 避免索引
 
explain verbose
select clrq, jgmc, id
from ic.organizations
where jgmc &@~ '百度'
  and clrq is not null
order by coalesce(clrq) desc
limit 30 offset 0
;
- 使用 expression 避免索引
 
explain verbose
select clrq, jgmc, id
from ic.organizations
where jgmc &@~ '百度'
  and clrq is not null
order by clrq+0 desc
limit 30 offset 0
;
- 添加 nulls 顺序避免索引
 
- 因为 index 默认 asc nulls last 因此 asc 和 desc 选择不同 nulls 顺序
 - where is not null
 - desc nulls last
 - asc nulls first
 
explain verbose
select clrq, jgmc, id
from ic.organizations
where jgmc &@~ '百度'
  and clrq is not null
order by clrq desc nulls last
limit 30 offset 0
;
- 调整 STATISTICS
 
- 在两者统计 rows 不同的情况下 - 这里不适用
 - SET STATISTICS
 - CREATE STATISTICS
 
STATISTICS 场景
STATISTICS 影响 n_distinct,n_distinct 影响 inputs_rows,多个计划相同,可增加 STATISTICS 避免错误选择索引。
SELECT attname,
       n_distinct,
       null_frac
FROM pg_stats
WHERE tablename = 'organizations'
  and attname in ('clrq', 'jgmc');
| attname | n_distinct | null_frac | 
|---|---|---|
| clrq | 7914 | 0.00030666665 | 
| jgmc | -0.99441195 | 0.0030133333 | 
SELECT relname, reltuples, relpages
FROM pg_class
WHERE relname LIKE 'organizations';
| relname | reltuples | relpages | 
|---|---|---|
| organizations | 3686376 | 1116103 |