注册
达梦数据库SQL执行计划解读与案例分析
专栏/技术分享/ 文章详情 /

达梦数据库SQL执行计划解读与案例分析

猫猫有枣枣 2026/08/21 199 0 0
摘要

一、执行计划到底是什么

SQL语句描述的是“要什么数据”,但并没有完整规定数据库“怎样拿到这些数据”。同一条 SQL 可以有多种执行方法:全表扫描还是走索引、先访问哪张表、使用哈希连接还是索引连接、聚合前是否排序等。优化器会根据SQL结构、索引、统计信息和成本模型对候选路径进行评估,最后生成一棵执行计划树。

因此,看执行计划的核心不是背操作符,而是理解这棵树所描述的数据处理过程。计划中的每个节点完成一件事:读取数据、过滤、连接、排序、聚合或返回结果。下层节点产生数据,上层节点继续消费这些数据。

二、先把执行计划“拿对”

达梦数据库提供了多种获取计划的方式,最重要的不是记住所有命令,而是知道每种方式回答的问题不同。

图片.png

  1. EXPLAIN:先看“优化器准备怎么执行”
EXPLAIN SELECT * FROM employees WHERE department_id = 10;

EXPLAIN不真正执行SQL,适合第一轮分析。它能快速暴露是否全表扫描、是否走索引、连接顺序和估算行数等信息。复杂SQL的优化通常从这里开始,但不要因为看到某个rows很大,就立刻断定数据库真的处理了同样多的行。

  1. AUTOTRACE:再看“实际执行时用了什么计划”
./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。

  1. V$CACHEPLN:SQL 一直跑不完时,先查看缓存计划
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. 先看缩进:缩进表示计划树层级
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 的执行顺序”更容易理解复杂计划。

  1. 再看三元组:[cost, rows, bytes]
    常见文本计划中,操作符后面的三元组可以理解为“该节点的估算代价、输出行数和行数据处理长度”。其中rows是该计划节点结果集的预测行数;cost是优化器比较候选计划时使用的代价指标。分析时更应该关注“相邻节点之间 rows 如何变化”,而不是纠结 cost 的绝对数值。
    · 如果一个扫描节点估算50万行,经过SLCT2 只剩1千行,说明过滤条件很强,访问路径可能有进一步优化空间。
    · 如果两个输入表都不大,但连接节点突然估算成几亿甚至几十亿行,需要警惕连接基数估算失真。
    · 如果某个上层节点cost很高,先看它的子树。父节点往往包含子节点代价,不能直接认定这个父节点本身就是瓶颈。

四、常见执行计划操作符:看到它们时应该想到什么

下面把操作符按“数据访问—连接—聚合—结果处理”分组,真正调优时,需要的是看到某个操作符后,知道下一步应该检查什么。
图片.png

图片.png

五、案例:一条复杂 COUNT SQL 应该怎么分析

下面进入一个实际的案例。SQL的目标是对一组经过DISTINCT的业务数据进行COUNT。主表是 G_INFOS,同时关联用户、组织、模块、来文、发文数据,并包含一个对G_PNODES与G_OPINION做 LISTAGG聚合的子查询,以及一个EXISTS 条件。SQL很长,但分析执行计划时没有必要一开始就研究每个条件。

1.先看 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。

2.初始 EXPLAIN 计划

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子查询的连接基数估算异常庞大,并且后续紧跟排序和分组聚合。

3. 先看主表:G_INFOS 为什么要先读 58 万行再过滤

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 行。这里最值得关注的不是“全表扫描三个字”,而是过滤前后行数差距很大。如果这些条件在真实数据上确实能够过滤掉绝大多数记录,就应该检查是否存在可以提前定位的索引。

  1. 再看 OP 子查询:真正值得警惕的是基数估算失真
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。
图片.png

图 1 初始计划中 G_OPINION 全扫、G_PNODES 二级索引扫描的片段

5. 第一步优化:先让优化器“看清数据”

当估算行数与业务常识明显不符时,统计信息是第一检查项。优化器选索引、选连接方式、决定连接顺序,都依赖对数据量和选择性的估算。统计信息长期没有更新时,即使索引设计本身没有问题,优化器也可能因为估算错误而放弃它。

-- 收集表级/列级统计信息(示例)
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 的实际行数做对比,才能判断“计划没变是因为索引不合适,还是因为估算仍然不可信”。

6. 第二步优化:索引要看“定位方式”,不只看“有没有索引”

案例中G_PNODES 的ID、INFO_ID上存在组合索引。组合索引的价值不在于“表上有一个索引”,而在于它的列顺序是否能匹配查询中的等值条件、范围条件和连接驱动方式。如果执行计划仍然是 SSCN,说明优化器可能仍然认为扫描索引更划算,或者当前连接顺序没有把这组索引当成逐行定位入口。

图片.png

图 2 G_PNODES 组合索引列顺序示例(ID、INFO_ID)

通过 AUTOTRACE 获取的实际计划中,G_OPINION 一侧出现了 SSEK2 + BLKUP2,说明组合索引已经被实际用于连接键定位,而不再是初始 EXPLAIN 中的 CSCN2 全扫。因此,在实际的sql分析优化中,获取真实的执行计划很有必要。

图片.png

图 3 实际计划中 G_OPINION 通过 SSEK2 + BLKUP2 访问
评论
后发表回复

作者

文章

阅读量

获赞

扫一扫
联系客服