首页
学习
活动
专区
圈层
工具
发布
社区首页 >专栏 >明明有索引,limit 1 反而慢了两万倍

明明有索引,limit 1 反而慢了两万倍

作者头像
NickYoung
修改2026-07-16 21:33:03
修改2026-07-16 21:33:03
1230
举报

又回归手搓文章了。本篇回顾一个 limit 算子的经典问题——数据均匀分布假设导致 cost 预估偏差,进而让 SQL 执行效率断崖式下跌。之前写过一篇类似案例,最近又踩到,正好借这次机会做一些更深入的补充。

一、问题现象:颠覆认知的 limit

limit 1 走了性能更差的 SeqScan,而不加 limit 或 limit 更大时反而走 IndexScan,两者性能差距巨大。

limit 1 → SeqScan,耗时 1515.691 ms

代码语言:javascript
复制
postgres=# EXPLAIN (ANALYZE, BUFFERS, VERBOSE)
postgres-# SELECT c1 FROM t_demo WHERE c2 = 0 AND c3 = false AND c4 = 0limit 1;
                                     QUERY PLAN
----------------------------------------------------------------------------------
 Limit  (cost=0.00..0.26 rows=1 width=21) (actual time=1515.663..1515.665 rows=0.00 loops=1)
   Output: c1
   Buffers: shared hit=16031 read=79558
   ->  Seq Scan on public.t_demo  (cost=0.00..290590.56 rows=1099337 width=21) (actual time=1515.660..1515.661 rows=0.00 loops=1)
         Output: c1
         Filter: ((NOT t_demo.c3) AND (t_demo.c2 = 0) AND (t_demo.c4 = 0))
 Rows Removed by Filter: 13000000
         Buffers: shared hit=16031 read=79558
 Planning Time: 0.170 ms
 Execution Time: 1515.691 ms
(10 rows)

limit 3 或不加 limit → IndexScan,耗时 0.1 ms

代码语言:javascript
复制
postgres=# EXPLAIN (ANALYZE, BUFFERS, VERBOSE)
postgres-# SELECT c1 FROM t_demo WHERE c2 = 0 AND c3 = false AND c4 = 0limit 3;
                                     QUERY PLAN
----------------------------------------------------------------------------------
Limit  (cost=0.56..0.65 rows=3 width=21) (actual time=0.041..0.042 rows=0.00 loops=1)
   Output: c1
   Buffers: shared hit=4
   ->  Index Only Scan using idx_demo on public.t_demo  (cost=0.56..32078.14 rows=1099337 width=21) (actual time=0.039..0.040 rows=0.00 loops=1)
         Output: c1
         Index Cond: ((t_demo.c2 = 0) AND (t_demo.c3 = false) AND (t_demo.c4 = 0))
         Heap Fetches: 0
         Index Searches: 1
         Buffers: shared hit=4
 Planning Time: 0.157 ms
 Execution Time: 0.084 ms
(11 rows)

postgres=# EXPLAIN (ANALYZE, BUFFERS, VERBOSE)
postgres-# SELECT c1 FROM t_demo WHERE c2 = 0 AND c3 = false AND c4 = 0 ;
                                 QUERY PLAN
----------------------------------------------------------------------------------
Index Only Scan using idx_demo on public.t_demo  (cost=0.56..32078.14 rows=1099337 width=21) (actual time=0.036..0.037 rows=0.00 loops=1)
   Output: c1
   Index Cond: ((t_demo.c2 = 0) AND (t_demo.c3 = false) AND (t_demo.c4 = 0))
   Heap Fetches: 0
   Index Searches: 1
   Buffers: shared hit=4
 Planning Time: 0.139 ms
 Execution Time: 0.061 ms
(8 rows)

set enable_seqscan to off 后,limit 1 也能走回索引,耗时同样是 0.1ms 级:

代码语言:javascript
复制
postgres=# set enable_seqscan to off;
SET
postgres=# EXPLAIN (ANALYZE, BUFFERS, VERBOSE)
postgres-# SELECT c1 FROM t_demo WHERE c2 = 0 AND c3 = false AND c4 = 0limit 1;
                                     QUERY PLAN
----------------------------------------------------------------------------------
Limit  (cost=0.56..0.59 rows=1 width=21) (actual time=0.038..0.039 rows=0.00 loops=1)
   Output: c1
   Buffers: shared hit=4
   ->  Index Only Scan using idx_demo on public.t_demo  (cost=0.56..32078.14 rows=1099337 width=21) (actual time=0.037..0.037 rows=0.00 loops=1)
         Output: c1
         Index Cond: ((t_demo.c2 = 0) AND (t_demo.c3 = false) AND (t_demo.c4 = 0))
         Heap Fetches: 0
         Index Searches: 1
         Buffers: shared hit=4
 Planning Time: 0.146 ms
 Execution Time: 0.062 ms
(11 rows)

why? 默认放着索引不走,是 BUG 吗?看过之前文章的朋友,对背后的逻辑应该已经很熟悉了。

二、问题分析:为什么 limit 1 选了 SeqScan

在之前的一系列文章里,我给大家介绍过 PG 优化器进行路径比较的逻辑,核心就是比较 startup_costtotal_cost。整个选路分三个阶段:

① add_path 时的路径剪枝 生成每个节点路径时调用 compare_path_costs_fuzzily 做模糊比较:仅当某条路径的 startup 和 total 比另一条小 1% 及以上,才直接淘汰另一条。

② set_cheapest 时的精确比较 add_path 之后,用 compare_path_costs 做精确比较,角逐出 cheapest_startup_costcheapest_total_cost

③ get_cheapest_fractional_path 取 best_path 非游标查询 tuple_fraction 恒为 0,直接返回 cheapest_total_path 作为 best_path;游标查询则遍历 pathlist 调用 compare_fractional_path_costs 插值比较。

这条 SQL 很简单,执行计划也只有两个节点:基表的 ScanMethodLimit。我们把两个阶段拆开看。

先比较基表 ScanMethod

SeqScan:startup_cost = 0,total_cost = 290590.56 IndexScan:startup_cost = 0.56,total_cost = 32078.14

add_path 模糊比较:0 < 0.56×1.01,但 290590.56 > 32078.14×1.01,两条路径互相淘汰不掉,都保留set_cheapest 精确比较:cheapest_startup = 0(SeqScan),cheapest_total = 32078.14(IndexScan)。

再比较 Limit 节点

SeqScan 对应的 Limit:startup = 0,total = 0.26 IndexScan 对应的 Limit:startup = 0.56,total = 0.59

add_path 模糊比较:0 < 0.56×1.01 且 0.26 < 0.59,SeqScan 这条路径全面更优,于是在 add_path 环节 IndexScan 的 Limit 路径就被淘汰下线了。此时 cheapest_startup = 0,cheapest_total = 0.26。

由于是非游标查询,tuple_fraction = 0,因此在 get_cheapest_fractional_path 中直接取 best_path = cheapest_total_path = 0.26,也就是 SeqScan 对应的路径。谜底揭开:不是优化器犯傻,是它算出来 SeqScan 的 Limit 成本更低。

这个 0.26 是怎么来的?

Limit 节点的 total_cost 计算公式(adjust_limit_rows_costs):

代码语言:javascript
复制
*total_cost = *startup_cost +
    (input_total_cost - input_startup_cost)
        * count_rows / input_rows;
SeqScan 子路径 startup=0、total=290590.56、input_rows=1099337,limit 1 即 count_rows=1:
total_cost = 0 + (290590.56 − 0) × 1 / 1099337
          = 290590.56 / 1099337
          ≈ 0.2643  → EXPLAIN 显示 0.26 ✓

还是老问题——符合条件的 rows 被预估成 1099337,实际却是 0 行。是统计信息不准吗?

三、1099337 究竟是怎么算出来的

先看表结构与统计信息。索引是 idx_demo btree (c2, c3, c4, c1)

代码语言:javascript
复制
postgres=# \d+ t_demo
                              Table "public.t_demo"
 Column |        Type         | Nullable | Storage  | Stats target
--------+-----------------------+----------+----------+--------------
 c1     | character varying(20) | not null | extended |
 c2     | integer            | not null | plain    |
 c3     | boolean            | not null | plain    |
 c4     | smallint          | not null | plain    |
Indexes:
    "idx_demo" btree (c2, c3, c4, c1)
Not-null constraints:
    "t_demo_c1_not_null" NOT NULL "c1"
    "t_demo_c2_not_null" NOT NULL "c2"
    "t_demo_c3_not_null" NOT NULL "c3"
    "t_demo_c4_not_null" NOT NULL "c4"
Access method: heap
postgres=# SELECT attname, avg_width, n_distinct, null_frac, most_common_vals, most_common_freqs
postgres-#  FROM pg_stats WHERE tablename='t_demo' AND attname IN ('c1','c2','c3','c4');
 attname | avg_width | n_distinct | null_frac | most_common_vals |       most_common_freqs
---------+-----------+------------+-----------+------------------+--------------------------------
 c2     |        4 |          2 |         0 | {1,0}         | {0.50296664,0.49703333}
 c3     |        1 |          2 |         0 | {f,t}         | {0.50296664,0.49703333}
 c4     |        2 |          3 |         0 | {0,1,2}      | {0.33826667,0.3329,0.32883334}
 c1     |      21 |        -1 |         0 |               |
(4 rows)

postgres=# SELECT relname, relpages, reltuples FROM pg_class WHERE relname IN ('t_demo','idx_demo');
 relname  | relpages |   reltuples
----------+----------+---------------
 t_demo   |    95589 | 1.3000104e+07
 idx_demo |    78929 | 1.3000104e+07
(2 rows)
走顺序扫描时,行数估算在 clauselist_selectivity() 里完成。无扩展统计时,它按独立性假设把各子句选择性直接连乘。每个等值子句走 eqsel,命中 MCV 时选择性就等于对应的 most_common_freqs:

代码语言:javascript
复制
/* clauselist_selectivity_ext 核心循环 */
Selectivity s1 = 1.0;
foreach(l, clauses)
{
    s2 = clause_selectivity_ext(root, clause, ...);
    s1 = s1 * s2;   /* ← 就是这行:直接相乘 */
}
return s1;
s(c2=0)    = 0.49703333
s(c3=false) = 0.50296664
s(c4=0)    = 0.33826667

S = 0.49703333 × 0.50296664 × 0.33826667 = 0.0845641 rows = clamp_row_est(S * tuples)= rint(0.0845641 × 13000104) = 1099337

问题的根子就在 s1 = s1 * s2 这一行乘法。而这行乘法在概率论上成立的唯一前提,就是各条件相互独立。所以 0.0845641 隐含了一个假设:c2、c3、c4 的取值互不相关。

但实际上这三列高度相关——组合 (c2=0, c3=false, c4=0) 实际匹配 0 行(Rows Removed by Filter: 13000000,actual rows=0.00)。连乘的 8.4% 与真实的 0%,偏差达百万倍。

所以结论是:统计信息不是不准,而是缺失了几列之间的相关性。那么,加个多列扩展统计信息是不是就能解决?除了 set enable_seqscan to off 之外还有别的手段吗?既然执行计划里明确写着 Rows Removed by Filter: 13000000, actual rows=0.00,那创建一个条件索引来消除 Filter 应该也可行。

四、解决方案

方案一:创建多列扩展统计信息

让优化器识别列间相关性,预估行数不再离谱,直接走回现有索引,执行降到 ms 级:

代码语言:javascript
复制
postgres=# CREATE STATISTICS stat_demo_c234 (mcv, ndistinct) ON c2, c3, c4 FROM t_demo;
CREATE STATISTICS
postgres=# analyze t_demo;
ANALYZE
postgres=# EXPLAIN (ANALYZE, BUFFERS, VERBOSE)
postgres-# SELECT c1 FROM t_demo WHERE c2 = 0 AND c3 = false AND c4 = 0limit 1;
                                     QUERY PLAN
----------------------------------------------------------------------------------
Limit  (cost=0.56..4.58 rows=1 width=21) (actual time=0.017..0.017 rows=0.00 loops=1)
   Output: c1
   Buffers: shared hit=4
   ->  Index Only Scan using idx_demo on public.t_demo  (cost=0.56..4.58 rows=1 width=21) (actual time=0.015..0.016 rows=0.00 loops=1)
         Output: c1
         Index Cond: ((t_demo.c2 = 0) AND (t_demo.c3 = false) AND (t_demo.c4 = 0))
         Heap Fetches: 0
         Index Searches: 1
         Buffers: shared hit=4
 Planning:
   Buffers: shared hit=19
 Planning Time: 0.297 ms
 Execution Time: 0.037 ms
(13 rows)

方案二:创建条件索引

直接把过滤条件固化进索引定义,走该索引消除 Filter,执行同样降到 ms 级:

代码语言:javascript
复制
postgres=# CREATE INDEX idx_demo_partial ON t_demo (c1) WHERE c2=0 AND c3=false AND c4=0;
CREATE INDEX
postgres=# analyze t_demo;
ANALYZE
postgres=# EXPLAIN (ANALYZE, BUFFERS, VERBOSE)
postgres-# SELECT c1 FROM t_demo WHERE c2 = 0 AND c3 = false AND c4 = 0limit 1;
                                     QUERY PLAN
----------------------------------------------------------------------------------
Limit  (cost=0.12..0.14 rows=1 width=21) (actual time=0.004..0.005 rows=0.00 loops=1)
   Output: c1
   Buffers: shared hit=1
   ->  Index Only Scan using idx_demo_partial on public.t_demo  (cost=0.12..10867.80 rows=1086367 width=21) (actual time=0.003..0.004 rows=0.00 loops=1)
         Output: c1
         Heap Fetches: 0
         Index Searches: 1
         Buffers: shared hit=1
 Planning:
   Buffers: shared hit=9
 Planning Time: 0.235 ms
 Execution Time: 0.020 ms
(12 rows)

两种方案思路不同:多列统计修正的是优化器的「认知」——让 rows 估准,从而选中已有索引;条件索引补的是「结构」——造出一条 startup 极低的专用路径,即便估算仍偏高也能胜出。

留个思考题 🤔 两种治理方案都符合预期。那么问题来了——如果两种手段同时用上,最终会走哪个执行计划?欢迎在评论区留言。

— END —

本文参与 腾讯云自媒体同步曝光计划,分享自微信公众号。
原始发表:2026-07-16,如有侵权请联系 cloudcommunity@tencent.com 删除

本文分享自 PostgreSQL运维之道 微信公众号,前往查看

如有侵权,请联系 cloudcommunity@tencent.com 删除。

本文参与 腾讯云自媒体同步曝光计划  ,欢迎热爱写作的你一起参与!

评论
登录后参与评论
0 条评论
热度
最新
推荐阅读
领券
问题归档专栏文章快讯文章归档关键词归档开发者手册归档开发者手册 Section 归档