复合 B-tree 索引先按第一个键排序,再在第一个键相同的组内按下一个键排序。这种元组顺序可以让“某个租户的一段时间”很容易定位。应理解的是条件能缩小多少索引扫描范围,而不是禁止查询后面的列,也不是要求 WHERE 中的条件必须按建索引的顺序书写。这几个概念混在一起,会导致错误的索引设计。
排序的是键,不是 SQL 条件
对于 (tenant, happened) 索引,tenant 等值条件加 happened 范围条件描述了树中的一小段。PostgreSQL 可以定位这一段,而不用检查所有租户。即使把时间条件写在 WHERE 的前面、租户条件写在后面,逻辑上的合取关系也没有改变。数据库定义中的键顺序与查询文本的书写顺序,是两件不同的事情,不能拿文本位置判断索引是否可用。
通常,前导列的等值条件和第一个非等值键上的范围条件,决定必须扫描的区间。更后面的键条件仍可在索引中检查,即使不能继续缩小一个简单连续区间,也可能减少访问堆表的次数。调换索引键会改变这些机会,但不意味着某种排列在所有查询中都完全无用。需要结合查询实际要求的范围和返回列判断。
后面的键仍可能有用
PostgreSQL 18 在合适时可以使用 B-tree skip scan:如果遍历缺少等值条件的前导键取值,并在每组内重新搜索,比读取大部分索引更便宜,就可能采用这种路径。只有少数几个租户时,仅过滤 happened 也可能通过各租户分组完成查找;前导键不同值很多时,这种重复搜索可能太贵,规划器便可能选择顺序扫描或其他索引。
即使没有合算的 skip scan,某些只涉及后面键的条件仍可能扫描复合索引。问题是成本、覆盖范围与选择性,不是 SQL 是否合法,也不是索引必然被忽略。不能把“最左前缀”这种便于记忆的说法扩展成绝对禁令,尤其在目标数据库明确支持其他访问路径时。版本和真实计划必须优先于口号。
把排序和工作负载一起考虑
固定 tenant 后,后面的 happened 顺序可以支持该租户的时间排序。但跨多个租户时,索引先按租户分组,不会直接提供全局时间顺序。Bitmap 扫描也不保留索引顺序,仍可能需要单独排序。因此应看完整计划,而不是看到索引包含排序列就断言不会发生 Sort。LIMIT 对尽快返回前几行的需求也会改变最合适的路径。
“等值列在前、范围列在后”是一个查询形状的起点,并不是“永远把选择性最高的列放第一”的统一算法。多个查询共享的前缀、连接、排序方向、参数分布和写入成本都会影响选择。比较真正反复出现的查询,再保留必要的最小索引集合。重复且宽大的索引即使没有读者,也会占用缓存和写入维护资源。
坏掉的样子
团队可能为租户内查询建立 (tenant, happened),随后增加全局时间线,却假定两者效率相同。新查询的顺序要求和过滤范围已经不同,需要重新评估。另一种回归来自测试参数:一个小租户的计划很好,不代表拥有大部分行的租户也同样便宜。同一条参数化 SQL 面对不同数据分布,成本可能差很多,统计信息和代表性参数比单次截图更可靠。
错误规则也会带来不必要的索引变更。因为“原索引绝不可能查后列”而为每个后缀新增索引,可能重复已有能力;反过来,相信 skip scan 总能补救缺失前缀,又可能掩盖昂贵路径。修改数据库结构前,应检查实际行数、缓冲区、排序与访问方式,说明哪个真实查询获得了什么改善。
快速验证
把代码保存为 composite.sql,用 psql -X -v ON_ERROR_STOP=1 -f composite.sql 连接可丢弃的 PostgreSQL 18 数据库。临时夹具包含四个租户取值和两万个事件编号,最后回滚删除全部实验对象,不依赖已有表或数据。
\set ON_ERROR_STOP on
BEGIN;
CREATE TEMP TABLE events (tenant integer, happened integer, body text);
INSERT INTO events SELECT g % 4, g, repeat('x',40) FROM generate_series(1,20000) AS g;
CREATE INDEX events_tenant_time ON events (tenant, happened);
ANALYZE events;
EXPLAIN (ANALYZE, BUFFERS, TIMING OFF)
SELECT happened FROM events WHERE tenant=2 AND happened BETWEEN 100 AND 120
ORDER BY happened;
SELECT array_agg(happened ORDER BY happened) AS matching_times
FROM events WHERE happened BETWEEN 100 AND 120 AND tenant=2;
EXPLAIN (ANALYZE, BUFFERS, TIMING OFF)
SELECT tenant FROM events WHERE happened=12346;
DO $$ BEGIN
IF (SELECT array_agg(happened ORDER BY happened) FROM events
WHERE happened BETWEEN 100 AND 120 AND tenant=2)
IS DISTINCT FROM ARRAY[102,106,110,114,118] THEN
RAISE EXCEPTION 'wrong composite range';
END IF;
IF (SELECT tenant FROM events WHERE happened=12346) IS DISTINCT FROM 2 THEN
RAISE EXCEPTION 'suffix predicate lost the row';
END IF;
END $$;
ROLLBACK;
租户范围查询精确返回 102,106,110,114,118,把 WHERE 条件反过来书写时结果相同。在 PostgreSQL 18.6 上,它使用 bitmap index scan 后再排序;仅按后列查询的语句通过带重复索引搜索的 index-only scan 返回租户二,直接证明没有 tenant 等值条件也存在可用路径。两种计划不同,但都符合各自的逻辑结果。
具体计划名称、缓冲区计数和搜索次数是这份数据上的观测,不是任何配置下都必须出现的常量。断言验证结果语义,性能判断还应增加前导键的不同取值数量,测量后列访问在真实分布下是否仍值得使用。这样才能把“索引可以参与”与“该索引在这个负载下划算”分开。
来源
- PostgreSQL 多列索引,前导条件和 skip scan,已核验 2026-09-10。
- PostgreSQL 索引与 ORDER BY,有序扫描与排序选择,已核验 2026-09-10。