SQL语句描述的是“要什么数据”,但并没有完整规定数据库“怎样拿到这些数据”。同一条 SQL 可以有多种执行方法:全表扫描还是走索引、先访问哪张表、使用哈希连接还是索引连接、聚合前是否排序等。优化器会根据SQL结构、索引、统计信息和成本模型对候选路径进行评估,最后生成一棵执行计划树。
因此,看执行计划的核心不是背操作符,而是理解这棵树所描述的数据处理过程。计划中的每个节点完成一件事:读取数据、过滤、连接、排序、聚合或返回结果。下层节点产生数据,上层节点继续消费这些数据。
达梦数据库提供了多种获取计划的方式,最重要的不是记住所有命令,而是知道每种方式回答的问题不同。
EXPLAIN SELECT * FROM employees WHERE department_id = 10;
EXPLAIN不真正执行SQL,适合第一轮分析。它能快速暴露是否全表扫描、是否走索引、连接顺序和估算行数等信息。复杂SQL的优化通常从这里开始,但不要因为看到某个rows很大,就立刻断定数据库真的处理了同样多的行。
./disql 用户名/密码
CALL SF_SET_SESSION_PARA_VALUE('MONITOR_SQL_EXEC',1);
set autotrace trace;
-- 执行待分析 SQL
SELECT ...;
AUTOTRACE TRACE会真正执行SQL,并打印服务器实际采用的计划。开启相关监控后,部分计划节点还能出现类似“估算行数→实际行数”的信息,这对判断统计信息和基数估算是否失真很有价值。如果需要继续查看节点级运行统计,可从V$SQL_HISTORY找到本次执行的exec_id,再调用ET。
SELECT sqlstr, cache_item
FROM v$cachepln
WHERE sqlstr LIKE '%SQL 关键字%';
ALTER SESSION SET EVENTS
'immediate trace name plndump level <cache_item>';
这种方式常用于“SQL 已经开始执行,但迟迟不返回”的场景:先从计划缓存拿到编译计划,判断是否存在明显的全扫、大表连接或异常估算。它解决的是“现在优化器选了什么计划”,而不是“每个节点实际跑了多久”。
执行计划本质上是一棵树,阅读时应该先找到最深层的数据访问节点,再顺着父子关系向上看数据如何被过滤、连接和聚合。
阅读口诀:先看“从哪读”,再看“读多少”;再看“怎么连”,最后看“哪里排序、去重、聚合”。不要先盯着最上面的总 cost,也不要把“走索引”自动等同于“计划很好”。
1 #NSET2: [1, 1, 100]
2 #PRJT2: [1, 1, 100]
3 #BLKUP2: [1, 1, 100]; IDX_T_ID(T)
4 #SSEK2: [1, 1, 0]; IDX_T_ID(T), scan_range[10,10]
这段计划可以按数据流理解:第 4 步先通过二级索引定位 ID=10 的索引项;第 3 步根据索引记录回表取得完整数据;第 2 步只保留 SELECT 需要的列;第 1 步把结果集返回给客户端。这样读,比单纯记住“4、3、2、1 的执行顺序”更容易理解复杂计划。
下面把操作符按“数据访问—连接—聚合—结果处理”分组,真正调优时,需要的是看到某个操作符后,知道下一步应该检查什么。
下面进入一个实际的案例。SQL的目标是对一组经过DISTINCT的业务数据进行COUNT。主表是 G_INFOS,同时关联用户、组织、模块、来文、发文数据,并包含一个对G_PNODES与G_OPINION做 LISTAGG聚合的子查询,以及一个EXISTS 条件。SQL很长,但分析执行计划时没有必要一开始就研究每个条件。
SELECT COUNT(*)
FROM (
SELECT DISTINCT ...
FROM G_INFOS
JOIN G_USERINFO ...
JOIN G_ORGUSER ...
JOIN G_MODULE ...
LEFT JOIN (
SELECT info_id,
LISTAGG(...), LISTAGG(...)
FROM G_PNODES
JOIN G_OPINION
ON G_PNODES.info_id = G_OPINION.id
AND G_PNODES.id = G_OPINION.pnid
GROUP BY info_id
) OP ON OP.info_id = G_INFOS.id
LEFT JOIN LW ...
LEFT JOIN FW ...
WHERE MAINUNIT = ...
AND STATUS > 0
AND ROWSTATE <> -1
AND NGRQ BETWEEN ... AND ...
AND EXISTS (SELECT 1 FROM G_PNODES ... )
);
这段结构化摘录已经足够建立分析地图:主表过滤、三张基础维表连接、一个需要先连接再聚合的OP子查询、两个左连接以及一个EXISTS。
1 #NSET2: [480911349, 1, 864]
2 #PRJT2: [480911349, 1, 864]; exp_num(1), is_atom(FALSE)
3 #AAGR2: [480911349, 1, 864]; grp_num(0), sfun_num(1), distinct_flag[0]; slave_empty(0)
4 #PRJT2: [480911349, 2088, 864]; exp_num(0), is_atom(FALSE)
5 #DISTINCT: [480911349, 2088, 864]
6 #INDEX JOIN SEMI JOIN2: [480430917, 2088, 864];
7 #HASH LEFT JOIN2: [480430878, 2088, 864]; key_num(1), partition_keys_num(0), ret_null(0), mix(0) KEY(G_INFOS.ID=fw.INFO_ID)
8 #INDEX JOIN LEFT JOIN2: [480430869, 2088, 864] ret_null(0)
9 #HASH LEFT JOIN2: [480430855, 2088, 864]; key_num(1), partition_keys_num(0), ret_null(0), mix(0) KEY(G_INFOS.ID=OP.info_id)
10 #HASH2 INNER JOIN: [126, 2088, 648]; LKEY_UNIQUE KEY_NUM(1); KEY(module.ID=G_INFOS.MODULE_ID) KEY_NULL_EQU(0)
11 #CSCN2: [1, 300, 78]; INDEX33574379(G_MODULE as module)
12 #HASH2 INNER JOIN: [125, 2088, 570]; KEY_NUM(2); KEY(G_USERINFO.ID=G_INFOS.USER_ID AND ou.USERINFOID=G_INFOS.USER_ID) KEY_NULL_EQU(0, 0)
13 #HASH2 INNER JOIN: [1, 538, 108]; LKEY_UNIQUE KEY_NUM(1); KEY(G_USERINFO.ID=ou.USERINFOID) KEY_NULL_EQU(0)
14 #CSCN2: [1, 481, 78]; INDEX33574165(G_USERINFO)
15 #CSCN2: [1, 538, 30]; INDEX33574194(G_ORGUSER as ou)
16 #SLCT2: [122, 1536, 462]; (G_INFOS.MAINUNIT = var2 AND G_INFOS.STATUS > var3 AND G_INFOS.ROWSTATE <> var5 AND G_INFOS.NGRQ >= var6 AND G_INFOS.NGRQ <= var7)
17 #CSCN2: [122, 587546, 462]; INDEX33574400(G_INFOS)
18 #PRJT2: [480430726, 9821, 216]; exp_num(3), is_atom(FALSE)
19 #SAGR2: [480430726, 9821, 216]; grp_num(1), sfun_num(2), distinct_flag[0,0]; slave_empty(0) keys(DMTEMPVIEW_16789205.TMPCOL0)
20 #SORT3: [480430726, 9821, 216]; key_num(1), is_distinct(FALSE), top_flag(0), is_adaptive(0)
21 #PRJT2: [845, 26545792287, 216]; exp_num(3), is_atom(FALSE)
22 #HASH2 INNER JOIN: [845, 26545792287, 216]; KEY_NUM(2); KEY(G_OPINION.ID=G_PNODES.INFO_ID AND G_OPINION.PNID=G_PNODES.ID) KEY_NULL_EQU(0, 0)
23 #CSCN2: [135, 992068, 156]; INDEX33574328(G_OPINION)
24 #SSCN: [338, 2889101, 60]; idx_G_PNODES_xyq_20250430(G_PNODES)
25 #BLKUP2: [13, 1, 0]; INDEX33574993(lw)
26 #SSEK2: [13, 1, 0]; scan_type(ASC), INDEX33574993(LW as lw), scan_range[G_INFOS.ID,G_INFOS.ID]
27 #CSCN2: [4, 37014, 126]; INDEX33574343(FW as fw)
28 #SSEK2: [77, 294, 0]; scan_type(ASC), idx_G_PNODES_xyq_20250430(G_PNODES as pno), scan_range[(G_INFOS.ID,min),(G_INFOS.ID,max))
完整计划有28个节点,但第一轮不需要全部解释,可以看到两个很明显的分析方向:主表 G_INFOS过滤发生在全扫描之后;OP子查询的连接基数估算异常庞大,并且后续紧跟排序和分组聚合。
16 #SLCT2: [122, 1536, 462]; MAINUNIT / STATUS / ROWSTATE / NGRQ ...
17 #CSCN2: [122, 587546, 462]; G_INFOS
这两行的关系很直观:优化器预计先从G_INFOS读取约58.7万行,再经过MAINUNIT、STATUS、ROWSTATE、NGRQ等条件过滤为约1536 行。这里最值得关注的不是“全表扫描三个字”,而是过滤前后行数差距很大。如果这些条件在真实数据上确实能够过滤掉绝大多数记录,就应该检查是否存在可以提前定位的索引。
22 #HASH2 INNER JOIN: [845, 26545792287, 216]
23 #CSCN2: [135, 992068, 156]; G_OPINION
24 #SSCN: [338, 2889101, 60]; idx_G_PNODES_xyq_20250430(G_PNODES)
20 #SORT3: [480430726, 9821, 216]
19 #SAGR2: [480430726, 9821, 216]
OP 子查询先把 G_PNODES 与 G_OPINION 按 INFO_ID、PNID 两个键连接,再按 info_id 分组做两次 LISTAGG。初始计划中,G_OPINION 是 CSCN2,G_PNODES 虽然显示了二级索引,但操作符是 SSCN——也就是扫描索引,而不是按连接键做 SSEK2 定位。
更值得注意的是 HASH2 INNER JOIN 的预测输出行数达到了 265 亿级,而其上的 SAGR2 最终只预计保留 9821 组。这里不应该简单写成“产生了笛卡尔积”,因为 SQL 明确存在两列连接条件;更合理的判断是:优化器对这组连接的基数估算极不稳定,统计信息、列值分布或列之间的相关性可能没有被准确反映。
这种估算偏差会进一步影响上层决策。优化器如果认为连接会产生超大中间结果,就可能对排序、聚合、连接方式和内存使用做出不同选择。因此,看到“异常放大的 rows”时,优先检查统计信息和实际行数,而不是只盯着 SORT3 的巨大 cost。
当估算行数与业务常识明显不符时,统计信息是第一检查项。优化器选索引、选连接方式、决定连接顺序,都依赖对数据量和选择性的估算。统计信息长期没有更新时,即使索引设计本身没有问题,优化器也可能因为估算错误而放弃它。
-- 收集表级/列级统计信息(示例)
DBMS_STATS.GATHER_TABLE_STATS(
'用户名', '表名', NULL, 100, TRUE,
'FOR ALL COLUMNS SIZE AUTO'
);
-- 针对多个关键列收集统计信息(示例)
STAT 100 ON 表名(列名1, 列名2, ...);
在这个案例里,优先关注G_INFOS的过滤列,以及G_PNODES、G_OPINION的连接列。收集完统计信息后重新EXPLAIN,并与AUTOTRACE/ET 的实际行数做对比,才能判断“计划没变是因为索引不合适,还是因为估算仍然不可信”。
案例中G_PNODES 的ID、INFO_ID上存在组合索引。组合索引的价值不在于“表上有一个索引”,而在于它的列顺序是否能匹配查询中的等值条件、范围条件和连接驱动方式。如果执行计划仍然是 SSCN,说明优化器可能仍然认为扫描索引更划算,或者当前连接顺序没有把这组索引当成逐行定位入口。
通过 AUTOTRACE 获取的实际计划中,G_OPINION 一侧出现了 SSEK2 + BLKUP2,说明组合索引已经被实际用于连接键定位,而不再是初始 EXPLAIN 中的 CSCN2 全扫。因此,在实际的sql分析优化中,获取真实的执行计划很有必要。
文章
阅读量
获赞
