执行计划里一旦出现 BLKUP2,就表示优化器已经走完索引定位,还要再回基表取列。索引已经建了,查询仍然慢,原因经常不在扫描本身,而在回表。
是否回表,取决于行数据与索引条目是否在同一处。
默认堆表按 ROWID 连续存放完整行:
ROWID | C1 | C2 || ROWID | C1 | C2 || ...
已知 ROWID 时,优化器可以用聚簇定位(计划中常见 CSEK)直接找到该行。
需要区分两个容易混用的概念:达梦默认的“按 ROWID 物理有序存放”不是经典 IOT。Oracle IOT、MySQL InnoDB 主键聚簇用的是显式主键;达梦这里的聚簇键是伪列 ROWID。每张表只允许一个聚簇索引。建表时默认按 ROWID 聚簇;一旦建立自定义聚簇索引,默认的 ROWID 聚簇即失效。
二级索引只保存索引列和对应 ROWID,并按索引列排序:
C1 | ROWID || C1 | ROWID || ...
查询若只需索引列,读完索引即可结束。若还需要其他列,就要用 ROWID 回到基表读整行。这个“回到基表取数”的步骤,就是回表,对应计划中的 BLKUP / BLKUP2。
聚簇索引会按索引键把整行数据物理重组:
C1 | C2 | ROWID || C1 | C2 | ROWID || ...
按聚簇键做范围查询时,叶子节点上已经有完整行,通常不必再回表。代价是:原 ROWID 直达通道消失,WHERE ROWID = ? 不能再走 CSEK。
省一次 BLKUP 并不等于聚簇索引永远优于二级索引,还要看查询列集合与选择性。
在 1KB 行、8KB 页的 DM8 实测下,B+ 树层数与容量大致如下:
| 层数 | 最大行数 |
|---|---|
| 1 层(仅叶子) | 31 |
| 2 层 | 约 53,630 |
| 3 层 | 约 1.03 亿 |
| 4 层 | 约 21 亿 |
表超过约 1 亿行、树高到 4 层时,通常需要评估分区:每多一层就多一次索引 IO。页大小改为 16KB 或 32KB 时,单页容量相应增加。
回表之所以贵,主要是因为它引入额外存储 IO。常见介质延迟量级:
| 介质 | 延迟 |
|---|---|
| NVMe SSD PCIe 5.0 | 50–60 μs |
| NVMe SSD PCIe 3.0 | 约 100 μs |
| 企业级 SAS/SATA SSD | 100–500 μs |
| HDD | 毫秒级 |
在较理想的 NVMe 场景下,存储 IO 仍可占数据检索链路耗时的 90% 以上;生产环境中这一比例常常更高。回表次数一大,延迟会按随机 IO 叠加。
官方定义可以概括为:二级索引通常只存“索引列 + ROWID”。若 SELECT 列表中还有索引未覆盖的列,优化器必须用 ROWID 回基表读行。BLKUP 的开销与参与回表的行数成正比。
典型形态是 SSEK2 上方挂着 BLKUP2:
3 #BLKUP2: [257, 250000, 68]; IDX$$_2(TX); use_clu_addr(0)
4 #SSEK2: [257, 250000, 68]; scan_type(ASC), IDX$$_2(TX), scan_range[999998,999998]
读法是自下而上:SSEK2 先在二级索引上按范围定位,得到一批 ROWID;BLKUP2 再用这些 ROWID 回基表取行。方括号中的三个数字依次是代价、估算行数、行宽估算,后文案例会反复用到。
以下取自官方文件中的调优实例。表 tx(c1, c2, c3) 约 1000 万行,查询条件为 WHERE c1 = 999998。
SELECT * FROM tx WHERE c1 = 999998;
-- 已用时间: 378.143 毫秒
1 #NSET2: [1283, 250000, 68]
2 #PRJT2: [1283, 250000, 68]; exp_num(4)
3 #SLCT2: [1283, 250000, 68]; TX.C1 = 999998
4 #CSCN2: [1283, 10000000, 68]; INDEX33555476(TX)
CSCN2 表示按堆表/聚簇结构扫描整表,此处扫描 1000 万行,代价 1283。估算行数为 250000,这是缺少统计信息时的默认选择率,实际命中只有 1 行。过滤发生在 SLCT2:先读表,再按 C1 筛选。
CREATE INDEX IDX$$_2 ON "SYSDBA"."tx"("c1");
SELECT * FROM tx WHERE c1 = 999998;
-- 已用时间: 12.248 毫秒
1 #NSET2: [257, 250000, 68]
2 #PRJT2: [257, 250000, 68]; exp_num(4)
3 #BLKUP2: [257, 250000, 68]; IDX$$_2(TX); use_clu_addr(0)
4 #SSEK2: [257, 250000, 68]; scan_type(ASC), IDX$$_2(TX), scan_range[999998,999998]
代价从 1283 降到 257,耗时约降为原来的 1/31。索引已经发挥作用,但 SELECT * 需要 c2、c3,因此 SSEK2 之后仍有 BLKUP2。
SELECT c1 FROM tx WHERE c1 = 999998;
-- 已用时间: 1.949 毫秒
索引与过滤条件都未改,只是投影列从全部列改为 c1。索引条目已包含所需数据,BLKUP2 不再出现。耗时从 12.248 毫秒降到 1.949 毫秒,相对上一版约快 6 倍,相对全表扫描约快 194 倍。
本例只回表 1 行,回表仍把查询拖慢约 6 倍。业务 SQL 若回表数千、数万行,差距会继续放大。官方同时指出两点:全表扫描代价高,应建立正确的二级索引;SELECT * 会把不需要的列自下而上传递,列存储表上还会削弱按列 IO 的优势。
案例库中的关系可以写成:
BLKUP 回表次数 = 驱动行数 × 内表命中数
嵌套循环中这一点特别明显。外层 1000 行、内层每次命中 440 行,回表次数就是 44 万。单次回表看起来不重,乘上连接基数后会成为计划中的主要开销。
二级索引按索引列有序,但各条目上的 ROWID 在基表中通常并不连续(除非数据已按该列聚簇)。逐行回表等于在基表上随机寻址。一次随机 IO 约 50–500 μs,1 万次大约是 0.5–5 秒,且随存储介质变差而上升。
当估算命中行数占表总量大约超过 5%–15% 时(行越宽、数据越散,阈值越低),逐行回表的随机 IO 会超过一次顺序全表扫描。CBO(基于代价的优化器)可能改选 CSCN2。这也是“估算行数是否准确,会直接改变计划形态”的物理原因:估算偏高,更容易跨过这条拐点,丢掉本可使用的索引。
把查询需要的列都放进同一棵索引,索引叶子即可给出结果。
-- 需要 status、amount,仅在 create_time 上建索引时通常要回表
CREATE INDEX idx_orders_ct ON orders(create_time, status, amount);
计划形态往往从 SSEK2 → BLKUP2 变为单独的 SSCN 或 SSEK2(下方不再挂 BLKUP)。覆盖索引是消除回表最稳妥的结构手段,代价是索引更宽、维护成本更高,需要按真实 SELECT 列表设计,而不是把所有列塞进索引。
若业务并不需要全部列,应写明确的列清单。这是成本最低的改法:上一节第 3 步已经说明,仅改变投影列就可以去掉 BLKUP2。除非接口约定必须返回整行,否则应避免 SELECT *。
ENABLE_INDEX_FILTER)达梦提供类似 MySQL ICP(Index Condition Pushdown)的能力,常用 HINT 为 /*+ ENABLE_INDEX_FILTER(1) */。
目标是把过滤与回表的顺序从 BLKUP → SLCT 调整为 SLCT → BLKUP:先在索引层完成能够完成的过滤,再只对剩余行回表。
BLKUP → SLCT:候选行先全部回表,再过滤,回表行数大。SLCT → BLKUP:索引层先缩小集合,再回表,回表行数小。官方案例中,某查询从 44 秒降到 968 毫秒。适用与否取决于索引能否承担过滤、以及回表本身是否足够贵:
| 较适用 | 不太适用 |
|---|---|
| 索引能覆盖过滤条件,且筛选率高 | 索引列重复值多,筛选率低 |
| 表大、索引相对较窄,回表代价高 | 过滤列不在索引中,或表很小 |
按最常用的查询键把整行物理重组后,该键上的范围扫描通常不再回表。限制同样明确:全表只能有一个聚簇索引,且 ROWID 直达失效。这更接近建表时的存储决策,而不是上线后随时可改的局部调优。
在“有没有合适索引、要不要回表”之间,优化器会在堆表/聚簇全扫(CSCN)和二级索引扫描(SSCN)中选择。官方判据的核心是:当前查询列是否已经在索引里。
| 查询 | 索引情况 | 常见选择 | 原因 |
|---|---|---|---|
SELECT C1 FROM T1 |
C1 上有二级索引 |
SSCN(索引全扫) |
索引条目已含 C1,不必回表 |
SELECT C2 FROM T1 |
仅 C1 上有索引 |
CSCN |
索引没有 C2,回表读完全表往往不如直接扫表 |
SSCN 扫描的是整棵二级索引,而不是堆表。索引通常比表窄(只有索引列 + ROWID),因此在不回表的前提下,它可以替代全表扫描。当索引宽度接近表宽度时,这一优势减弱,CBO 会回到 CSCN。
PLAN_OP_FLAG 是会话级参数,也支持 HINT。与回表相关的位值如下:
| 位值 | 作用 |
|---|---|
| 4 | 仅优化 BLKUP:在 B 树中找到一行后,继续在当前页查找下一条,利用页内局部性 |
| 8 | 在 4 的基础上,于 BLKUP 下方增加 SORT,使送入回表的 ROWID 更紧凑;额外付出排序代价 |
| 12 | 在 4 的基础上,对每批送入 BLKUP 的数据做 bdta 排序,不额外增加排序操作符;适合数据较乱且批量较大的场景 |
| 512 | 回表行数多时提高回表代价,使优化器更不倾向选择回表路径 |
| 65536 | 禁止使用聚集索引记录地址来优化 BLKUP |
512 的官方实测:同一条 SQL 回表 187500 行,PLAN_OP_FLAG(0) 时代价为 181,PLAN_OP_FLAG(512) 时升到 259。代价被抬高后,CBO 可能改选不回表的计划。该开关适合在怀疑“回表代价被低估”时做对照实验,而不是作为默认配置。
存在一类例外:SLCT 条件是非等值且过滤性不强(例如 id1 > 5、LIKE '%x%'),若强制走某单列索引,计划可能变成 SSCN → BLKUP → SLCT。在需要尽快返回前几行、并配合 LIMIT / TOP 时,这条路径经常仍然合理。
原因是有序索引可以从扫描起点按 ORDER BY 顺序产出记录,不必先物化一个很大的中间结果再排序。即使回表之后大量行会被过滤掉,端到端延迟仍可能更低。
判断时同时看三件事:过滤条件的选择性、是否要求提前返回、以及 LIMIT 的大小。不能仅因“先回表再过滤”就否定该计划。
DROP TABLE tx;
CREATE TABLE tx(c1 INT, c2 INT, c3 VARCHAR(100));
INSERT INTO tx SELECT LEVEL+1, LEVEL+2, RPAD('B',10,'B') FROM DUAL CONNECT BY LEVEL<=10000000;
COMMIT;
BEGIN
DBMS_STATS.GATHER_TABLE_STATS('SYSDBA', 'TX');
END;
/
SELECT * FROM tx WHERE c1=999998;
EXPLAIN SELECT * FROM tx WHERE c1=999998;
CREATE INDEX IDX$$_2 ON tx(c1);
BEGIN
DBMS_STATS.GATHER_TABLE_STATS('SYSDBA', 'TX');
END;
/
SELECT * FROM tx WHERE c1=999998;
EXPLAIN SELECT * FROM tx WHERE c1=999998;
SELECT c1 FROM tx WHERE c1=999998;
EXPLAIN SELECT c1 FROM tx WHERE c1=999998;
记录三次实际耗时。
下面截图来自本地虚拟机:
无索引全表扫描:
二级索引 + 回表:
只查索引列:
)
说明:A 远慢于 B(索引有效),B 有 BLKUP2、C 没有(覆盖有效)
| 操作符 | 含义 | 是否回表 |
|---|---|---|
| CSCN2 | 堆表 / 聚簇结构全扫 | 否 |
| SSCN | 二级索引全扫 | 查询列不在索引中时需要 |
| SSEK2 | 二级索引等值或范围定位 | 查询列不在索引中时需要 |
| CSEK2 | 聚簇定位(含 ROWID 直达) |
否 |
| BLKUP2 | 回表 | 该操作符本身就是回表 |
减少回表的顺序通常是:覆盖索引、收缩 SELECT 列表、ENABLE_INDEX_FILTER(1)、建表期考虑聚簇索引。相关参数见 PLAN_OP_FLAG 的 4 / 8 / 12(优化回表过程)、512(提高回表代价)、65536(禁用地址优化)。
结构或 HINT 调整之后,仍应以 EXPLAIN 与 ET() 对比前后计划与耗时。官方材料中存在“按理论改完反而变慢”的例子,最终依据是实测,而不是操作符是否从计划中消失。
文章
阅读量
获赞
