报表查询的救命稻草:复合索引与最左前缀

报表查询都带着「某用户的某时间段」--单个索引救不了组合条件。

学 45 min
练 60 min
盘 15 min
共 120 分钟

学 · 45 min

  1. 01最左前缀原则的原理(索引按列顺序排序)

    场景建了 (user_id, created_at) 复合索引,只按 created_at 查却不走索引--为什么?

    复合索引的排序规则:先按第一列排,第一列相同时再按第二列排。像电话簿「先姓后名」:知道姓能二分定位;只知道名(=只用第二列),整本电话簿还是得翻一遍。所以 (user_id, created_at) 支持「只 user_id」「user_id + created_at」,不支持只 created_at

    create index on orders (user_id, created_at);
    
    -- 命中
    explain select * from orders where user_id = '...';
    explain select * from orders where user_id = '...' and created_at >= current_date - 30;
    -- 不命中(最左前缀断了)
    explain select * from orders where created_at >= current_date - 30;

    易错「(a, b) 能不能服务 where b」是索引面试的必考送分/送命题,先讲清排序原理再给结论。

  2. 02列顺序原则:等值列在前、范围列在后、高选择性优先

    场景同样两列,先建 (user_id, created_at) 还是 (created_at, user_id)?

    等值在前、范围在后:范围条件命中后,索引里它后面的列就没法继续精确定位了。(user_id = ? and created_at > ?) 用 (user_id, created_at):user_id 等值定位后,created_at 在索引里还是有序的,范围顺藤摸瓜。反过来范围在前,user_id 就乱序了。② 多个等值列之间,高选择性(区分度大的)优先。

    -- 报表标配:某用户的某时间段
    create index on orders (user_id, created_at);   -- 对
    -- create index on orders (created_at, user_id); -- created_at 的范围会挡住 user_id 的等值定位

    易错范围列一旦「挡路」,它后面的列在索引里就只剩过滤作用(Filter),不再参与定位(Index Cond)--看 EXPLAIN 的这两个字段能立刻诊断。

  3. 03选择性的定义与查看方法

    场景「这列建索引值不值」要拿数字说话。

    选择性 = 不同值数量 / 总行数,越接近 1 区分度越大、索引越值。status 列选择性 ≈ 0.000003(100 万行 3 个值),索引意义小;email 接近 1,必建。PG 里看 pg_stats.n_distinct(正数 = 绝对值、负数 = 占总行数的比例)。

    select attname, n_distinct, null_frac
    from pg_stats
    where tablename = 'orders';
    -- user_id 的 n_distinct ≈ -0.01(约 1% 行数即 1 万个不同用户)
    -- status 的 n_distinct = 3

    易错「低选择性列单独建索引」= 白占空间:优化器预判命中太多行,照样全表扫。

  4. 04Index Only Scan(覆盖索引)与 INCLUDE 子句

    场景列表页只要 id、金额、时间--这些列全在索引里的话,连堆都不用回。

    查询涉及的列全部包含在索引里时,扫描器理论上可以只读索引--Index Only Scan,省掉回表的随机 IO。include (列) 把额外列塞进索引叶子(不参与排序),是官方支持的「为 IOS 补货」写法。

    create index on orders (user_id, created_at) include (total_amount);
    
    explain (analyze, buffers)
    select user_id, created_at, total_amount
    from orders
    where user_id = '...' and created_at >= current_date - 30;
    -- 计划出现 Index Only Scan,Heap Fetches = 0

    易错include 会加大索引、拖慢写入--只为偶尔一条查询加它不值;高频接口查询才值得。

  5. 05PG 的 visibility map 对 Index Only Scan 的影响

    场景明明列都在索引里,计划却时不时回堆几万次(Heap Fetches > 0)--为什么?

    索引里没有行的可见性信息(MVCC 的 xmin/xmax 在堆上),IOS 必须确认「这行对当前事务可见」。靠 visibility map:每个堆页一个「全可见」位,VM 置位的页才敢直接用索引。VM 由 vacuum 维护--表刚大量更新过,VM 退化,IOS 又偷偷回表了。

    explain (analyze, buffers) select user_id from orders where user_id = '...';
    -- Heap Fetches: 12345   <- 回堆了,VM 不干净
    vacuum orders;
    explain (analyze, buffers) select user_id from orders where user_id = '...';
    -- Heap Fetches: 0       <- VM 全可见,真·只读索引

    易错「IOS 时快时慢」的老大难,先想 VM 和 vacuum;autovacuum 跟不上就调参(D41 讲)。

练 · 60 min

  1. (user_id, created_at) 复合索引,测三种查询(只用 user_id / 只用 created_at / 两者都用)是否命中
    参考答案

    复合索引先按第一列排、第一列相同时再按第二列排--电话簿「先姓后名」:只知道名(第二列)翻不了电话簿。②就是最左前缀断了的经典样子。

    create index idx_orders_uid_created on orders (user_id, created_at);
    
    -- ① 只用 user_id:命中
    explain select * from orders
    where user_id = '00000000-0000-0000-0000-000000000042';
    -- ② 只用 created_at:不命中,Seq Scan
    explain select * from orders
    where created_at >= current_date - 30;
    -- ③ 两者都用:命中,等值定位后范围顺藤摸瓜
    explain select * from orders
    where user_id = '00000000-0000-0000-0000-000000000042'
      and created_at >= current_date - 30;
  2. 把列顺序反过来再建一次,重测上面三种查询
    参考答案

    范围列挡在前面时,后面的 user_id 在索引里是乱序的,只能当过滤条件(Filter)不再参与定位(Index Cond)。结论:等值列在前、范围列在后。

    create index idx_orders_created_uid on orders (created_at, user_id);
    
    -- 只按时间查:这次能命中了
    explain select * from orders where created_at >= current_date - 30;
    -- 想看它服务「user_id 等值 + 时间范围」的样子,先删掉上面那个复合索引再 explain:
    -- Index Cond 里只剩 created_at,user_id 沦为 Filter
    drop index idx_orders_created_uid;   -- 对照完删掉,别拖着写入
  3. 只 SELECT 索引里的列,观察计划里出现 Index Only Scan
    参考答案

    查询的列全在索引里就不必回堆。第一次跑常见 Heap Fetches > 0:VM 里有页不是「全可见」,得回堆确认可见性;vacuum 之后归零。

    explain (analyze, buffers)
    select user_id, created_at
    from orders
    where user_id = '00000000-0000-0000-0000-000000000042';
    -- 计划:Index Only Scan using idx_orders_uid_created
    
    vacuum orders;                       -- 清 visibility map
    explain (analyze, buffers)
    select user_id, created_at
    from orders
    where user_id = '00000000-0000-0000-0000-000000000042';
    -- Heap Fetches: 0  <- 真·只读索引
  4. INCLUDE 把 total_amount 加进索引,验证仍是 Index Only Scan
    参考答案

    include 把列塞进索引叶子但不参与排序,官方支持的「为 IOS 补货」写法。代价是索引更大、写入更慢,只有高频接口查询值得加。

    create index idx_orders_cover
      on orders (user_id, created_at) include (total_amount);
    
    explain (analyze, buffers)
    select user_id, created_at, total_amount
    from orders
    where user_id = '00000000-0000-0000-0000-000000000042';
    -- 仍是 Index Only Scan,total_amount 不用回堆取
  5. pg_stats 看几个列的 n_distinct,判断选择性高低
    参考答案

    预期:status 的 n_distinct = 3(低选择性,单独建索引白占空间);user_id ≈1 万个不同值;created_at 接近每行一个值。n_distinct 为负数时表示「占总行数的比例」(-1 ≈ 每行都不同)。

    select attname, n_distinct, null_frac
    from pg_stats
    where tablename = 'orders'
    order by attname;
过关标准 给定三条查询,能设计出「最少数量」的索引集合并说明理由。