在数据库性能分析过程中,有一类现象比较容易让人产生疑惑:同一条 SQL,在数据库客户端中查看执行计划时看起来没有问题,但应用实际执行时却很慢。
刚开始接触这类问题时,比较容易把注意力放在 SQL 本身、索引是否存在、统计信息是否最新等方面。但在实际测试过程中,我发现还需要进一步区分两个问题:
这两个计划在某些情况下并不一定相同。本次实验没有直接从理论出发,而是通过人为改变数据分布,观察以下内容:
在此基础上,进一步补充了"如何直接观察实际执行计划(AUTOTRACE / ET)“以及"触发计划失效的几种方式(DDL、增删索引、NO_INVALIDATE、INJECT HINT)”,最终通过多组实测结果建立了一条比较完整的证据链。
计划缓存是达梦数据库提升重复 SQL 执行性能的重要机制:为了避免每次执行 SQL 都重复做词法分析、语法分析、生成执行计划这些耗时步骤,数据库会把解析后的执行计划缓存在 SQL 缓冲区(SQL Cache Pool)中。后续执行相同 SQL 时,通过 HASH 值比对,若匹配则直接重用缓存计划,不再重新解析。这个机制由 USE_PLN_POOL 参数控制,默认为 1,即启用计划重用。
这里有一个很多人忽略的事实:
所以,当统计信息更新了、数据分布变了、索引增删了,但 SQL 文本没变,优化器"想"生成新计划,但计划缓存"说"——不用,我这儿有现成的。结果就是:EXPLAIN 显示的计划,与实际执行使用的计划可能不同。
以下所有实验均在虚拟机上的达梦 DM8 中完整复现,环境信息如下:
-- 版本信息
SELECT * FROM V$VERSION;
-- 结果:DM Database Server 64 V8,DB Version: 0x7000d
-- 关键参数
SELECT PARA_NAME, PARA_VALUE FROM V$DM_INI
WHERE PARA_NAME IN ('USE_PLN_POOL','CACHE_POOL_SIZE','MONITOR_SQL_EXEC','ENABLE_INJECT_HINT');
-- USE_PLN_POOL = 1 (计划重用已开启)
-- CACHE_POOL_SIZE = 200 (SQL 缓冲区大小,MB)
-- MONITOR_SQL_EXEC = 0 (SQL 执行监控,默认关闭)
-- ENABLE_INJECT_HINT = 0 (hint 注入,默认关闭)
本文使用的测试表均为新建表(T_STAT_TEST、T_DDL_TEST、T_IDX_TEST、T_NOINV_TEST、T_HINT_TEST),数据量约 50 万行。文中 CACHE_ITEM 的值与执行耗时均为本机实测结果,因环境而异,仅供参考。
这是生产环境中最常见的一类情况:收集统计信息后,优化器本应基于新数据分布生成更优计划,但已缓存的旧计划仍然被复用。
本次实验刻意将数据分布设计成"少数值 + 大多数值"的形式,制造出非常明显的"旧计划"与"新计划"选择差异,避免因为数据量太小导致两种执行方式耗时接近而难以判断。
初始状态:STATUS=0 → 1000 行(0.2%),STATUS=1 → 499000 行(99.8%)。此时查询 STATUS=0 更适合走索引。随后反转数据,让 STATUS=0 → 499000 行,此时更适合全表扫描。
DROP TABLE IF EXISTS T_STAT_TEST;
CREATE TABLE T_STAT_TEST (ID INT, NAME VARCHAR(100), STATUS INT);
INSERT INTO T_STAT_TEST
SELECT LEVEL, 'NAME_'||LEVEL, CASE WHEN LEVEL <= 1000 THEN 0 ELSE 1 END
FROM DUAL CONNECT BY LEVEL <= 500000;
COMMIT;
SELECT STATUS, COUNT(*) AS CNT FROM T_STAT_TEST GROUP BY STATUS ORDER BY STATUS;
-- STATUS=0 -> 1000 行;STATUS=1 -> 499000 行
CREATE INDEX IDX_STATUS ON T_STAT_TEST(STATUS);
CALL DBMS_STATS.GATHER_TABLE_STATS('SYSDBA','T_STAT_TEST',NULL,100,TRUE,'FOR ALL COLUMNS SIZE AUTO');
CALL DBMS_STATS.GATHER_INDEX_STATS('SYSDBA','IDX_STATUS');
SELECT COUNT(NAME) FROM T_STAT_TEST WHERE STATUS = 0;
-- 返回 1000 行,已用时间 3.809 毫秒
EXPLAIN SELECT COUNT(NAME) FROM T_STAT_TEST WHERE STATUS = 0;
1 #NSET2: [1, 1, 52]
2 #PRJT2: [1, 1, 52]; exp_num(1), is_atom(FALSE)
3 #AAGR2: [1, 1, 52]; grp_num(0), sfun_num(1), distinct_flag[0]; slave_empty(0)
4 #BLKUP2: [1, 1000, 52]; IDX_STATUS(T_STAT_TEST)
5 #SSEK2: [1, 1000, 52]; scan_type(ASC), IDX_STATUS(T_STAT_TEST), scan_range[0,0], is_global(0)
计划解读:优化器估算返回 1000 行,选择了"索引范围扫描 + 回表"(SSEK2 + BLKUP2),代价 [1, 1, 52]。
这里用 COUNT(NAME) 而不是 COUNT(*),是因为 NAME 列不在索引中,必须回表,这样才能放大索引扫描与全表扫描的代价差异。
SELECT CACHE_ITEM, SQLSTR FROM V$CACHEPLN WHERE SQLSTR LIKE '%COUNT(NAME)%';
-- CACHE_ITEM = 140487481023296
-- 用 PLNDUMP 把内存中的计划导出到 trace 文件
ALTER SESSION SET EVENTS 'IMMEDIATE TRACE NAME PLNDUMP, LEVEL 140487481023296';
trace 文件中的关键内容:
SQL_STR:
SELECT COUNT(NAME) FROM T_STAT_TEST WHERE STATUS = 0;
sqlnode[0]::::
1 #NSET2: [1, 1, 52]
2 #PRJT2: [1, 1, 52]; exp_num(1), is_atom(FALSE)
3 #AAGR2: [1, 1, 52]; grp_num(0), sfun_num(1), distinct_flag[0]; slave_empty(0)
4 #BLKUP2: [1, 1000, 52]; IDX_STATUS(T_STAT_TEST)
5 #SSEK2: [1, 1000, 52]; scan_type(ASC), IDX_STATUS(T_STAT_TEST), is_global(0), scan_range[0,0]
内存中的计划与 EXPLAIN 生成的计划一致。
UPDATE T_STAT_TEST SET STATUS = CASE WHEN STATUS = 0 THEN 1 ELSE 0 END;
COMMIT;
SELECT STATUS, COUNT(*) AS CNT FROM T_STAT_TEST GROUP BY STATUS ORDER BY STATUS;
-- STATUS=0 -> 499000 行;STATUS=1 -> 1000 行
CALL DBMS_STATS.GATHER_TABLE_STATS('SYSDBA','T_STAT_TEST',NULL,100,TRUE,'FOR ALL COLUMNS SIZE AUTO');
EXPLAIN SELECT COUNT(NAME) FROM T_STAT_TEST WHERE STATUS = 0;
1 #NSET2: [62, 1, 52]
2 #PRJT2: [62, 1, 52]; exp_num(1), is_atom(FALSE)
3 #AAGR2: [62, 1, 52]; grp_num(0), sfun_num(1), distinct_flag[0]; slave_empty(0)
4 #SLCT2: [62, 499000, 52]; T_STAT_TEST.STATUS = 0
5 #CSCN2: [62, 500000, 52]; INDEX33555473(T_STAT_TEST); btr_scan(1)
EXPLAIN 显示:优化器估算返回 499000 行,改选全表扫描(CSCN2)。这说明优化器已经感知到了数据分布的变化。
-- 检查 CACHE_ITEM 是否变化
SELECT CACHE_ITEM, SQLSTR FROM V$CACHEPLN WHERE SQLSTR LIKE '%COUNT(NAME)%';
-- 该 SQL 的 CACHE_ITEM 仍然不变
-- 实际执行
SELECT COUNT(NAME) FROM T_STAT_TEST WHERE STATUS = 0;
-- 返回 499000 行,已用时间 572.847 毫秒
结果:CACHE_ITEM 未变,SQL 文本未变,数据库直接复用了为"1000 行"设计的索引回表计划,实际却要回表 49.9 万次,执行耗时明显增加。
上一节是通过"加空格变快 + 清理缓存后恢复"来间接推断"实际执行用的是旧计划"。这种推断还不够严谨,本节补充两种直接观测实际执行计划与耗时点的手段。
AUTOTRACE TRACE 会在 SQL 执行后打印"实际执行的计划"(可能是重用了计划缓存中的计划,也可能是新生成的计划),并展示执行过程中的监控信息。它与 EXPLAIN 的区别在于:EXPLAIN 只生成计划而不真正执行,因此计划可能不准;而 AUTOTRACE TRACE 展示的是服务器实际执行的计划。
说明:使用 AUTOTRACE TRACE 需要开启监控参数 ENABLE_MONITOR 和 MONITOR_SQL_EXEC。本机 ENABLE_MONITOR 已默认开启,只需再开启 MONITOR_SQL_EXEC 即可看到实际扫描行数。
ALTER SESSION SET 'MONITOR_SQL_EXEC' = 1;
SET AUTOTRACE TRACE;
SELECT COUNT(NAME) FROM T_STAT_TEST WHERE STATUS = 0;
SET AUTOTRACE OFF;
输出(关键部分,注意计划中的 “估算->实际” 行数):
1 #NSET2: [1, 1->1, 52]
2 #PRJT2: [1, 1->1, 52]; exp_num(1), is_atom(FALSE)
3 #AAGR2: [1, 1->1, 52]; grp_num(0), sfun_num(1), distinct_flag[0]; slave_empty(0)
4 #BLKUP2: [1, 1000->499000, 52]; IDX_STATUS(T_STAT_TEST)
5 #SSEK2: [1, 1000->499000, 52]; scan_type(ASC), IDX_STATUS(T_STAT_TEST), is_global(0), scan_range[0,0]
Statistics
998324 logical reads
630 exec time(ms)
重点看 BLKUP2、SSEK2 两行的 “1000->499000”:前面的 1000 是优化器估算的行数,箭头后面的 499000 是实际扫描的行数。也就是说,这条 SQL 实际执行的仍是 SSEK2 + BLKUP2(索引回表)计划,但估算只扫描 1000 行,实际却扫描了 499000 行,逻辑读高达 998324,执行耗时 630ms。这就是"EXPLAIN 看到的 ≠ 实际执行的"最直接的证据。
开启 SQL 执行监控后,用 ET(事件跟踪)可以输出该 SQL 的执行号以及每个算子的实际耗时,直接定位性能热点。
ALTER SESSION SET 'MONITOR_SQL_EXEC' = 1;
SELECT COUNT(NAME) FROM T_STAT_TEST WHERE STATUS = 0;
ET(执行号); -- 执行号在上一句输出的"执行号:xxxx"中
ET 输出(关键算子耗时):
OP TIME(US) PERCENT N_ENTER
PRJT2 5 0% 4
DLCK 8 0% 2
NSET2 223 0.04% 3
AAGR2 2060 0.33% 1667
SSEK2 12684 2.04% 1665
BLKUP2 606307 97.59% 3330
ET 输出直接证明:BLKUP2(回表)算子耗时 606307 微秒,占总耗时 97.59%。这正是"用索引回表计划扫描 49.9 万行"的铁证。
| 手段 | 看到的是什么 | 用途 |
|---|---|---|
| EXPLAIN | 优化器预估计划(不执行、不读缓存) | 判断"应该"用什么计划 |
| PLNDUMP / V$CACHEPLN | 缓存中保存的计划 | 判断"准备复用"什么计划 |
| AUTOTRACE / ET | 实际执行使用的计划与真实耗时 | 判断"实际用了"什么计划 |
上一节证明了"统计信息更新默认不会使计划失效"。那么,到底哪些操作会让计划缓存失效?本节逐一验证几种常见的触发方式。
原理:DDL 会改变表结构,依赖该表的缓存计划失去有效性,数据库会将其失效;下一次执行时重新硬解析。
-- 建表并缓存计划
CREATE TABLE T_DDL_TEST (ID INT, NAME VARCHAR(100), STATUS INT);
INSERT INTO T_DDL_TEST SELECT LEVEL,'NAME_'||LEVEL,CASE WHEN LEVEL<=1000 THEN 0 ELSE 1 END
FROM DUAL CONNECT BY LEVEL <= 500000;
COMMIT;
CREATE INDEX IDX_DDL_STATUS ON T_DDL_TEST(STATUS);
CALL DBMS_STATS.GATHER_TABLE_STATS('SYSDBA','T_DDL_TEST',NULL,100,TRUE,'FOR ALL COLUMNS SIZE AUTO');
SELECT COUNT(NAME) FROM T_DDL_TEST WHERE STATUS = 0;
SELECT CACHE_ITEM, SQLSTR FROM V$CACHEPLN WHERE SQLSTR LIKE 'SELECT COUNT(NAME) FROM T_DDL_TEST%';
-- CACHE_ITEM = 140487490055904
-- 修改表定义
ALTER TABLE T_DDL_TEST ADD COLUMN REMARK VARCHAR(50);
-- 再次检查:原计划已从 V$CACHEPLN 消失(未选定行)
-- 重新执行,触发硬解析,产生新的 CACHE_ITEM
SELECT COUNT(NAME) FROM T_DDL_TEST WHERE STATUS = 0;
SELECT CACHE_ITEM, SQLSTR FROM V$CACHEPLN WHERE SQLSTR LIKE 'SELECT COUNT(NAME) FROM T_DDL_TEST%';
-- 新的 CACHE_ITEM = 140487489925216(与之前不同)
结论:ALTER TABLE 会使依赖该表的计划失效,原 CACHE_ITEM 从 V$CACHEPLN 中消失。
原理:索引增删同样改变表的物理访问路径,也会使相关计划失效。本节实测验证。
-- 建表并缓存计划
CREATE TABLE T_IDX_TEST (ID INT, NAME VARCHAR(100), STATUS INT);
INSERT INTO T_IDX_TEST SELECT LEVEL,'NAME_'||LEVEL,CASE WHEN LEVEL<=1000 THEN 0 ELSE 1 END
FROM DUAL CONNECT BY LEVEL <= 500000;
COMMIT;
CREATE INDEX IDX_TI_STATUS ON T_IDX_TEST(STATUS);
CALL DBMS_STATS.GATHER_TABLE_STATS('SYSDBA','T_IDX_TEST',NULL,100,TRUE,'FOR ALL COLUMNS SIZE AUTO');
SELECT COUNT(NAME) FROM T_IDX_TEST WHERE STATUS = 0;
SELECT CACHE_ITEM FROM V$CACHEPLN WHERE SQLSTR LIKE 'SELECT COUNT(NAME) FROM T_IDX_TEST%';
-- CACHE_ITEM = 140487490153632
-- 新增索引
CREATE INDEX IDX_TI_STATUS_NAME ON T_IDX_TEST(STATUS, NAME);
-- 检查:原计划已消失
-- 重新执行,产生新计划
SELECT COUNT(NAME) FROM T_IDX_TEST WHERE STATUS = 0;
-- 新 CACHE_ITEM = 140487490545888
-- 删除索引
DROP INDEX IDX_TI_STATUS;
-- 检查:计划再次消失
结论:CREATE INDEX 和 DROP INDEX 都会使相关计划失效。
上一节观察到"GATHER_TABLE_STATS 默认不失效",但该过程其实提供了 NO_INVALIDATE 参数(第 13 个参数,BOOLEAN 类型)来主动控制是否使计划失效。
说明:本机版本中 GATHER_TABLE_STATS 的参数顺序为:OWNNAME, TABNAME, PARTNAME, ESTIMATE_PERCENT, BLOCK_SAMPLE, METHOD_OPT, DEGREE, GRANULARITY, CASCADE, STATTAB, STATID, STATOWN, NO_INVALIDATE, FORCE, MERGE_STAT。
-- 建表并缓存计划
CREATE TABLE T_NOINV_TEST (ID INT, NAME VARCHAR(100), STATUS INT);
INSERT INTO T_NOINV_TEST SELECT LEVEL,'NAME_'||LEVEL,CASE WHEN LEVEL<=1000 THEN 0 ELSE 1 END
FROM DUAL CONNECT BY LEVEL <= 500000;
COMMIT;
CREATE INDEX IDX_NOINV_STATUS ON T_NOINV_TEST(STATUS);
CALL DBMS_STATS.GATHER_TABLE_STATS('SYSDBA','T_NOINV_TEST',NULL,100,TRUE,'FOR ALL COLUMNS SIZE AUTO');
SELECT COUNT(NAME) FROM T_NOINV_TEST WHERE STATUS = 0;
-- CACHE_ITEM = 140487490545888
-- 情况 A:NO_INVALIDATE => TRUE(不失效,保留旧计划)
CALL DBMS_STATS.GATHER_TABLE_STATS('SYSDBA','T_NOINV_TEST',NULL,100,TRUE,
'FOR ALL COLUMNS SIZE AUTO',NULL,NULL,FALSE,NULL,NULL,NULL,TRUE);
-- 检查:CACHE_ITEM 仍然存在(计划未失效)
-- 情况 B:NO_INVALIDATE => FALSE(立即使旧计划失效)
CALL DBMS_STATS.GATHER_TABLE_STATS('SYSDBA','T_NOINV_TEST',NULL,100,TRUE,
'FOR ALL COLUMNS SIZE AUTO',NULL,NULL,FALSE,NULL,NULL,NULL,FALSE);
-- 检查:CACHE_ITEM 消失(计划已失效)
结论:NO_INVALIDATE=TRUE 时收集统计信息不使计划失效(与默认行为一致);NO_INVALIDATE=FALSE 时主动使计划失效。
原理:达梦支持通过系统函数 SF_INJECT_HINT 对指定 SQL 注入 hint,从而在不修改 SQL 文本的情况下干预优化器的执行计划选择,规则存储在系统表 SYSINJECTHINT 中。
说明:本机版本中 hint 注入需先通过参数 ENABLE_INJECT_HINT 开启(默认 0);注入函数为内置函数 SF_INJECT_HINT;删除用 SF_DEINJECT_HINT。
-- 1) 开启 hint 注入
SP_SET_PARA_VALUE(1, 'ENABLE_INJECT_HINT', 1);
-- 2) 注入 hint:强制该 SQL 不走 IDX_HINT_STATUS 索引(即改走全表扫描)
-- SF_INJECT_HINT 的参数依次为:sql_text, hint_text, name, description,
-- validate(是否生效), fuzzy(是否模糊匹配), need_clear(是否清空缓存计划)
CALL SF_INJECT_HINT('SELECT COUNT(NAME) FROM T_HINT_TEST WHERE STATUS = 0',
'NO_INDEX(T_HINT_TEST, IDX_HINT_STATUS)',
'force_noindex', '强制全表扫描', TRUE, TRUE, TRUE);
-- 3) 查看已注入的规则
SELECT NAME, VALIDATE, SQL_TEXT, HINT_TEXT FROM SYSINJECTHINT;
-- force_noindex | TRUE | SELECT COUNT(NAME) FROM T_HINT_TEST WHERE STATUS = 0 | NO_INDEX(T_HINT_TEST, IDX_HINT_STATUS)
-- 4) 注入前 EXPLAIN 为 SSEK2+BLKUP2(索引回表);注入后 EXPLAIN 变为 CSCN2(全表扫描)
EXPLAIN SELECT COUNT(NAME) FROM T_HINT_TEST WHERE STATUS = 0;
1 #NSET2: [62, 1, 52]
2 #PRJT2: [62, 1, 52]; exp_num(1), is_atom(FALSE)
3 #AAGR2: [62, 1, 52]; grp_num(0), sfun_num(1), distinct_flag[0]; slave_empty(0)
4 #SLCT2: [62, 1000, 52]; T_HINT_TEST.STATUS = 0
5 #CSCN2: [62, 500000, 52]; INDEX33555482(T_HINT_TEST); btr_scan(1)
-- 5) 删除注入,并关闭 hint 注入
CALL SF_DEINJECT_HINT('force_noindex');
SP_SET_PARA_VALUE(1, 'ENABLE_INJECT_HINT', 0);
结论:hint 注入可以让目标 SQL 在不修改文本的前提下改变执行计划,规则全局生效;这是除 DDL、索引变更、统计信息收集之外的又一种计划变更方式。
关于 need_clear(是否清空缓存计划)参数:
建议:无论清计划还是注入 hint,都建议将 need_clear 设为 FALSE(或直接缺省),再通过 SP_CLEAR_PLAN_CACHE 只清理目标 SQL 的计划,避免清空全库计划引发大量硬解析。
| 触发方式 | 是否使计划失效 | 本机验证结果 |
|---|---|---|
| 表定义变化(ALTER TABLE) | 失效 | CACHE_ITEM 从 V$CACHEPLN 消失 |
| 新增索引(CREATE INDEX) | 失效 | CACHE_ITEM 消失 |
| 删除索引(DROP INDEX) | 失效 | CACHE_ITEM 消失 |
| 统计信息 + NO_INVALIDATE=FALSE | 失效 | CACHE_ITEM 消失 |
| 统计信息 + NO_INVALIDATE=TRUE(或默认) | 不失效 | CACHE_ITEM 保留 |
| INJECT HINT(need_clear=TRUE) | 清空全库计划 | 注入后计划立即改变 |
| SP_CLEAR_PLAN_CACHE | 手动清理 | CACHE_ITEM 消失 |
初始 STATUS=0 只有 1000 行时,优化器选择 SSEK2 + BLKUP2(索引回表);数据反转后 STATUS=0 变成 499000 行,重新基于统计信息生成的计划变成 CSCN2(全表扫描)。说明优化器能感知数据分布变化。
本次实验中,EXPLAIN 已经显示 CSCN2,但原 SQL 实际执行耗时仍达 572ms;通过 AUTOTRACE TRACE(显示 1000->499000 的实际扫描行数)和 ET(BLKUP2 占 97.59%)直接证明实际执行仍在用 SSEK2 + BLKUP2。因此排查实际 SQL 性能问题时,不能只根据 EXPLAIN 判断实际执行计划。
默认情况下(不指定 NO_INVALIDATE,或 NO_INVALIDATE=TRUE),GATHER_TABLE_STATS 不会使旧计划失效;只有显式指定 NO_INVALIDATE=FALSE,或通过 DDL、索引变更、SP_CLEAR_PLAN_CACHE、重启等方式,才会让旧计划失效或重新生成。
文章
阅读量
获赞
