客户的订单查询系统,某天早上核心 SQL 突然从 2 秒变成 2 分钟,执行计划从 INDEX RANGE SCAN 变成了 FULL TABLE SCAN。AWR 里看,SQL 本身没变,但索引的 BLEVEL 从 2 变成了 4,HEIGHT 从 3 变成了 5。根因是索引右向分裂(Right-Hand Growth),而且客户用的还是序列主键。
1 B-Tree 索引的 90-10 分裂 vs 50-50 分裂
Oracle 的 B-Tree 索引分裂有两种:50-50 分裂是标准做法,一个 leaf block 满了之后,分成两个各存一半。90-10 分裂(也叫 99-1 分裂)是优化机制:如果插入的键值总是当前最大值(比如序列、时间戳),Oracle 不会把满块分成两半,而是把 90% 的数据留在原块,只分 10% 出去,甚至直接把新块挂到右边,原块保持满状态。这本来是为了减少分裂开销,但副作用是索引高度(HEIGHT)会快速增长。
我们那个客户的订单表,order_id 是 SEQUENCE 自增,每天插入 500 万行。三个月后,索引高度从 3 涨到 5,意味着从根到 leaf 要多读 2 个 block。更关键的是,优化器的成本模型里,索引扫描成本 = BLEVEL + 叶子块数 * 选择性。当 BLEVEL 涨到 4 后,优化器一算:"索引扫描成本 4500,全表扫描成本 3800,那还是 FTS 吧。"于是执行计划跳变,2 秒变 2 分钟。
2 模拟实验:加速索引分裂
-- 构造右向增长索引
CREATE TABLE orders_sim (
order_id NUMBER PRIMARY KEY,
order_time DATE,
amount NUMBER
);
-- 插入 1000 万条序列值
DECLARE
BEGIN
FOR i IN 1..10000000 LOOP
INSERT INTO orders_sim VALUES (i, SYSDATE - DBMS_RANDOM.VALUE(1,365), DBMS_RANDOM.VALUE(100,10000));
IF MOD(i, 10000) = 0 THEN COMMIT; END IF;
END LOOP;
END;
/
-- 查看索引统计
SELECT index_name, blevel, leaf_blocks, height, clustering_factor
FROM user_indexes WHERE index_name = 'SYS_C0012345';
-- 初始:blevel=1, height=2, leaf_blocks=20000
-- 再插入 500 万条(模拟三个月增量)
-- 结果:blevel=3, height=4, leaf_blocks=85000
-- 跟踪索引分裂事件
ALTER SESSION SET EVENTS '10224 trace name context forever, level 1';
-- 查看 trace 文件,统计 split 次数和类型(leaf split vs branch split)
实验结果:1000 万条初始数据时,索引高度 2;再插 500 万条,高度涨到 4。trace 文件里显示 90-10 分裂占了 87%,说明确实是右向增长导致的。
3 修复方案:不是重建索引,而是反向键索引
很多 DBA 遇到这种情况就 ALTER INDEX REBUILD,确实能暂时把高度降下来,但三个月后又涨回去,治标不治本。正确的做法是:如果是序列主键,直接用 REVERSE KEY INDEX。反向键把序列值的高低位颠倒,比如 12345 变成 54321,这样插入时键值分散到各个 leaf block,避免了右向热点。代价是范围查询(WHERE order_id BETWEEN 100 AND 200)不能走索引范围扫描,只能走 FULL INDEX SCAN,但等值查询(WHERE order_id = 123)不受影响。
我们给客户改了主键索引为反向键,同时把订单查询里用范围扫描的 SQL 改成按 order_time 走分区剪枝,三个月后索引高度稳定在 3,执行计划再也没跳过。另外,如果必须用序列且要范围查询,可以考虑把序列改成 GUID,或者把索引改成 HASH 分区索引(Oracle 12.2+ 支持),把热点分散到多个分区。