技术方向:SQL 性能诊断 / CBO 代价优化 / 执行计划 / 索引与统计信息
适用环境:达梦 DM8;不同补丁版本的监控配置、执行计划格式可能存在差异。
案例说明:本文使用自行设计的订单分析模型进行可复现实验,所有 SQL 为示例或验证脚本;不将理论推断写成真实生产实测结果。
在数据库性能问题中,最容易误判的一类故障是:SQL 语句没有明显语法问题,索引也确实存在,但同一条语句的响应时间仍然随数据量、过滤条件及系统负载发生明显波动。此时仅靠“补索引”或“改写 SQL”往往不能定位根因。真正有效的调优,需要回答三个问题:优化器为什么选择这条路径、执行器实际消耗在哪里、调整后是否在真实业务分布下持续有效。
本文围绕达梦数据库的 CBO(Cost-Based Optimizer,基于代价的优化器),以一个包含时间过滤、订单明细关联及分组汇总的 SQL 为主线,构建“历史定位 → 预估计划 → 实际执行 → 基数校准 → 索引设计 → 对照验证”的闭环,并进一步讨论数据倾斜、回表放大和执行计划稳定性。
图 1 SQL 性能分析闭环(原创建模示意,非实际执行计划)
一条 SQL 的总响应时间并不等同于 CPU 时间。它可能由逻辑读、物理 I/O、内存中的 HASH/SORT、锁等待、客户端取数以及网络往返等共同构成。优化工作首先要把“慢”量化,至少固定以下四项指标:耗时、逻辑读、物理读、实际处理行数;涉及并发问题时,还需补充等待事件及 P95/P99 延迟。
在开启相关监控的 DM8 环境中,可以通过 V$SQL_HISTORY 查看近期 SQL 执行记录。TIME_USED 的单位为微秒,不能直接当成毫秒。下述语句按单次耗时排序,适合先定位异常样本。
-- 需具备对应视图权限,且 ENABLE_MONITOR 已启用
SELECT TOP 20
SQL_ID,
SUBSTR(TOP_SQL_TEXT, 1, 100) AS SQL_SNIPPET,
ROUND(TIME_USED / 1000.0, 2) AS ELAPSED_MS,
N_LOGIC_READ,
N_PHY_READ,
START_TIME
FROM V$SQL_HISTORY
ORDER BY TIME_USED DESC;
这里要区分两种问题:单次执行很慢与单次不慢但执行频率极高。前者优先看访问路径、关联放大和磁盘排序;后者则可能因累计逻辑读、频繁硬解析或重复调用造成整体负载。单看 TOP 20 最慢 SQL,不能覆盖高频 SQL 的总体成本。
为了便于复现,设计两张普通业务表:订单主表 PERF_ORDERS 和订单明细表 PERF_ORDER_ITEMS。实验使用 10 万笔订单与 30 万笔明细,每笔订单对应 3 行明细。示例仅用于测试环境,不应直接在生产业务 Schema 执行。
CREATE TABLE PERF_ORDERS (
ORDER_ID BIGINT PRIMARY KEY,
CUSTOMER_ID INT,
STATUS INT,
CREATE_TIME DATE,
TOTAL_AMOUNT DECIMAL(12, 2)
);
CREATE TABLE PERF_ORDER_ITEMS (
ITEM_ID BIGINT PRIMARY KEY,
ORDER_ID BIGINT,
PRODUCT_ID INT,
QUANTITY INT,
UNIT_PRICE DECIMAL(12, 2)
);
INSERT INTO PERF_ORDERS
SELECT LEVEL,
MOD(LEVEL, 1000) + 1,
CASE WHEN MOD(LEVEL, 20) = 0 THEN 9 ELSE 1 END,
DATE '2026-01-01' + MOD(LEVEL, 180),
MOD(LEVEL, 900) + 100
FROM DUAL
CONNECT BY LEVEL <= 100000;
INSERT INTO PERF_ORDER_ITEMS
SELECT LEVEL,
TRUNC((LEVEL - 1) / 3) + 1,
MOD(LEVEL, 5000) + 1,
MOD(LEVEL, 4) + 1,
MOD(LEVEL, 200) + 10
FROM DUAL
CONNECT BY LEVEL <= 300000;
COMMIT;
此处刻意引入了一个值得关注的特征:STATUS=9 只出现在部分订单,且状态与日期均由行号的取模运算生成,两列之间可能存在统计相关性。即使优化器知道各列单独的分布,未必能准确估计组合谓词的命中行数。这比单纯演示“加索引后变快”更接近真实数据库中的基数估算问题。
业务查询需要统计某个月、指定状态的客户销售额及订单数:
SELECT o.CUSTOMER_ID,
COUNT(DISTINCT o.ORDER_ID) AS ORDER_CNT,
SUM(i.QUANTITY * i.UNIT_PRICE) AS SALES_AMOUNT
FROM PERF_ORDERS o
JOIN PERF_ORDER_ITEMS i
ON i.ORDER_ID = o.ORDER_ID
WHERE o.STATUS = 9
AND TO_CHAR(o.CREATE_TIME, 'YYYY-MM') = '2026-05'
GROUP BY o.CUSTOMER_ID
ORDER BY SALES_AMOUNT DESC;
这段 SQL 同时包含:日期列函数运算、等值过滤、1:N 关联、COUNT(DISTINCT)、分组和排序;性能瓶颈既可能发生在表访问层,也可能发生在 JOIN 或聚合层,因此适合做一条完整的调优链路分析。
首先执行 EXPLAIN 获取优化器的预估计划。EXPLAIN 本身不真正执行目标 SQL,因此计划中的开销和行数不能当作实测耗时或实际行数。
EXPLAIN
SELECT o.CUSTOMER_ID,
COUNT(DISTINCT o.ORDER_ID) AS ORDER_CNT,
SUM(i.QUANTITY * i.UNIT_PRICE) AS SALES_AMOUNT
FROM PERF_ORDERS o
JOIN PERF_ORDER_ITEMS i
ON i.ORDER_ID = o.ORDER_ID
WHERE o.STATUS = 9
AND TO_CHAR(o.CREATE_TIME, 'YYYY-MM') = '2026-05'
GROUP BY o.CUSTOMER_ID
ORDER BY SALES_AMOUNT DESC;
达梦执行计划的节点通常包含类似 [cost, rows, bytes] 的估算信息。以下操作符值得重点辨认:
| 典型操作符 | 含义 | 需要核查的风险 |
|---|---|---|
| CSCN2 | 聚集索引扫描/常见全表访问路径 | 是否扫描了大量不必要的数据 |
| SSEK2 | 二级索引定位 | 过滤条件是否真正转成索引扫描范围 |
| BLKUP2 | 通过二级索引回表 | 大量随机回表是否抵消索引收益 |
| NEST LOOP | 嵌套循环连接 | 驱动行数是否过大、内表探测是否有索引 |
| HASH JOIN | 哈希连接 | 构建端是否过大、内存是否不足 |
| HAGR / SAGR | HASH / 排序分组 | 中间结果集大小、内存和磁盘排序 |
| SORT | 排序操作 | 是否可提前减少输入行数 |
高级诊断的关键不是看到 CSCN2 就认定有问题。 当谓词命中比例很高、表较小或回表代价很大时,全表扫描可能比二级索引更合理。应该判断“扫描了多少行,最终只返回多少行”,并结合真实逻辑读进行归因。
更重要的是关注估算行数(E-Rows)与实际行数(A-Rows)的偏差。例如:某关联节点估计输入只有几十行,实际却进入数万行,嵌套循环内表可能被重复探测大量次数。此时首先要检查统计信息、谓词选择率和数据倾斜,而不是直接强制使用某种连接算法。
EXPLAIN 能回答“优化器打算怎么执行”,但不能回答“执行时究竟花在哪里”。在 DIsql 中,满足监控前提后,可以使用 SET AUTOTRACE TRACEONLY 查看实际执行计划与逻辑读、物理读、排序等信息:
-- 以下 SET AUTOTRACE 为 DIsql 客户端命令
-- 先确认 ENABLE_MONITOR、ENABLE_MONITOR_DMSQL 的状态
-- 当前会话按需启用 SQL 操作符监控
SF_SET_SESSION_PARA_VALUE('MONITOR_SQL_EXEC', 1);
SET AUTOTRACE TRACEONLY
SELECT COUNT(*) FROM PERF_ORDER_ITEMS;
SET AUTOTRACE OFF
-- 分析结束后,将会话监控开关恢复原值
TRACEONLY 会真实执行 SQL,只是查询结果通常不打印;因此不要在不理解影响的情况下对 UPDATE、DELETE 等 DML 使用。对于目标复杂查询,可将上面 SELECT COUNT(*) 换成相应的原始 SQL 与改写 SQL,采集同一指标口径。管理工具还可以结合执行编号使用 ET 查看操作符级耗时;ET 采集有额外性能开销,应按需开启。
从测量角度,至少要做两轮:第一轮观察冷缓存或首次执行特征,第二轮观察相同缓存条件下的重复执行。不能把“第二次天然更快”的缓存收益误写成索引优化收益。
原 SQL 使用 TO_CHAR(o.CREATE_TIME,'YYYY-MM') 对日期列逐行转换。普通基于 CREATE_TIME 列的 B 树索引,不一定能直接利用这个函数表达式进行范围定位。若无匹配的函数索引,可能需要先扫描数据再判断月份。
对于日期区间,更稳妥的写法是左闭右开:
-- 改写为可使用普通日期列索引的范围条件
WHERE o.STATUS = 9
AND o.CREATE_TIME >= DATE '2026-05-01'
AND o.CREATE_TIME < DATE '2026-06-01'
不建议写成 CREATE_TIME <= DATE '2026-05-31',因为当列类型带有时分秒时,这样会漏掉 5 月 31 日白天之后的记录。需要注意:TO_CHAR 改成范围谓词仅改善访问路径的可能性,最终是否使用索引,仍由统计信息、过滤比例及 CBO 成本判断决定。
另外,统计信息应在大规模数据导入、批量更新或明显改变列分布之后评估是否重新收集:
-- 在实验 Schema 下执行;生产请安排低峰期
DBMS_STATS.GATHER_TABLE_STATS(
USER, 'PERF_ORDERS', NULL, 100, TRUE,
'FOR ALL COLUMNS SIZE AUTO'
);
DBMS_STATS.GATHER_TABLE_STATS(
USER, 'PERF_ORDER_ITEMS', NULL, 100, TRUE,
'FOR ALL COLUMNS SIZE AUTO'
);
-- 按需查看指定对象的统计信息
DBMS_STATS.TABLE_STATS_SHOW(USER, 'PERF_ORDERS');
当数据倾斜明显时,单一的“平均选择率”会掩盖热门值与冷门值之间的区别。即便完成统计信息收集,也要用实际行数核对估算是否可信;必要时进一步分析直方图、关联列的联合分布和业务参数范围。
图 2 基数估算偏差如何放大(原创建模示意,非实际执行计划)
本案例的过滤顺序包含状态等值、日期范围,以及订单 ID 关联。可在测试环境尝试以下索引组合,并分别复测:
CREATE INDEX IDX_PO_STATUS_CT_OID
ON PERF_ORDERS(STATUS, CREATE_TIME, ORDER_ID);
CREATE INDEX IDX_PI_ORDER_ID
ON PERF_ORDER_ITEMS(ORDER_ID);
-- 收集变更后的相关统计信息,再重新生成执行计划
DBMS_STATS.GATHER_INDEX_STATS(USER, 'IDX_PO_STATUS_CT_OID');
DBMS_STATS.GATHER_INDEX_STATS(USER, 'IDX_PI_ORDER_ID');
设计依据并不是简单的“把 WHERE 条件里的列全部建索引”:
STATUS 是等值条件,CREATE_TIME 是范围条件,组合索引有利于按前导等值列和日期范围定位;但如果 STATUS 极低选择性或条件经常缺失,就要重新比较列序。ORDER_ID 处于范围列之后,是否能继续高效缩小扫描范围要看具体计划;它不必然使索引成为覆盖索引,因为查询还需要 CUSTOMER_ID 等列。ORDER_ID 索引能够支持按订单回查明细;如果最终选择 HASH JOIN 且需要读取大比例明细,则全扫描可能反而更划算。特别需要关注 SSEK2 + BLKUP2:这是“二级索引找到候选记录后,再回表获取数据”的典型结构。如果命中行数很多,随机回表开销可能迅速增长。适度缩小投影列、改进索引设计,往往比强制走索引更有效。
在一对多关联里,假设主表过滤后得到 N 笔订单,每笔订单平均关联 3 条明细,则 JOIN 之后的中间结果约为 3N 行。如果优化器把 N 错估得过小,可能选择不理想的驱动表或连接算法;而接下来的分组、去重与排序会继续处理被放大的中间结果。
一种可检验的备选方案,是先在订单层完成高选择性过滤,再与明细关联:
SELECT o.CUSTOMER_ID,
COUNT(DISTINCT o.ORDER_ID) AS ORDER_CNT,
SUM(i.QUANTITY * i.UNIT_PRICE) AS SALES_AMOUNT
FROM PERF_ORDERS o
JOIN PERF_ORDER_ITEMS i
ON i.ORDER_ID = o.ORDER_ID
WHERE o.STATUS = 9
AND o.CREATE_TIME >= DATE '2026-05-01'
AND o.CREATE_TIME < DATE '2026-06-01'
GROUP BY o.CUSTOMER_ID
ORDER BY SALES_AMOUNT DESC;
这条改写与原语句的核心差别并不是改变 JOIN 语法,而是提高日期条件的可定位性。过滤谓词能否真正提前执行,应以执行计划中各节点的扫描范围、过滤行数和输入输出行数为证据。
对于数据量较小、索引定位有效的驱动集,NEST LOOP 可能具备优势;对于必须扫描大量记录、批量匹配的场景,HASH JOIN 可能更经济。不能仅凭“HASH 比 NL 高级”或“NL 走索引一定快”作判断。建议先修正统计信息和访问路径,再考虑 HINT;长期依赖固定 HINT 可能导致数据分布变化后的计划僵化。
一次合格的 SQL 优化至少经过四类验证:结果一致性、计划一致性、资源消耗、稳定性。建议将优化前后的两版 SQL 在同一实例、同一数据快照、近似缓存与负载条件下交替执行多次,记录中位数和 P95,而不是只展示一次最低耗时。
| 验证维度 | 调优前 | 调优后 | 判断标准 |
|---|---|---|---|
| 业务结果 | 原始结果集 | 改写结果集 | 行数、分组键及聚合值一致 |
| 执行计划 | 原计划留档 | 新计划留档 | 访问路径及估算/实际行数可解释 |
| 逻辑读 | 从 AUTOTRACE 采集 | 同口径采集 | 应结合返回量与并发评估 |
| 物理读及磁盘排序 | 从执行统计采集 | 同口径采集 | 不能因冷/热缓存差异误判 |
| 中位数 / P95 | 多次执行统计 | 多次执行统计 | 兼顾尾延迟,不能只比单次最优值 |
可以计算两个有用的比值:逻辑读降低比例 = (优化前逻辑读-优化后逻辑读)/优化前逻辑读;响应时间改善比例 = (优化前耗时-优化后耗时)/优化前耗时。这两个数字必须来自真实采样,不宜仅用 CBO 的 cost 估算值替代。
另外,不要在生产环境通过清空全局执行计划缓存来制造“公平环境”。这种操作可能造成大量 SQL 重新解析与短时性能扰动。更稳妥的方式是在隔离环境复现,或在受控窗口采样并保持比较条件一致。
1. 参数敏感性与计划漂移。 同一条绑定变量 SQL,传入“热状态”和“冷状态”时,返回行数可能相差数十倍。应抽样不同典型参数,而不是只用一个值证明优化成功。如果应用执行计划与管理工具观察到的不一致,还应核查参数绑定、计划缓存、会话设置及统计信息更新时间。
2. 统计信息的时效性与收集成本。 在 DTS 迁移、大规模装载、批量归档后,行数和分布可能明显变化。此时优先更新关键表统计信息,但应控制采样范围、执行窗口和监控开销,避免全库无差别高频收集。
3. 单 SQL 提速与系统吞吐量不是同一回事。 一条查询即使平均延迟下降,如果新增索引显著增加写入成本、内存使用或并发锁竞争,整机吞吐量也未必提高。生产验收应同时覆盖读写混合负载、CPU、I/O、缓存命中、锁等待和回归 SQL。
达梦 SQL 优化的关键,不是堆叠索引或调整大量数据库参数,而是沿着 数据分布 → 基数估算 → 访问路径 → JOIN/聚合算子 → 真实资源消耗 这条因果链建立证据。先使用 V$SQL_HISTORY 发现问题,再通过 EXPLAIN 与实际执行统计交叉验证,最后借助谓词改写、统计信息与索引设计形成最小化变更,并用多轮 A/B 测试确认收益。
对于有经验的 DBA 而言,真正体现技术深度的不是“优化后快了多少倍”,而是能够解释为什么慢、为什么这样改、为什么能够在下一次数据增长后仍然可靠。
V$SQL_HISTORY 字段及时间单位)。https://eco.dameng.com/document/dm/zh-cn/pm/dm8-admin-manual-appendix2.htmlAUTOTRACE)。https://eco.dameng.com/document/dm/zh-cn/pm/environment-variable-settings.html原创声明:本文独立组织实验模型、分析框架与示例代码;引用的达梦操作符、监控视图和语法依据上述官方文档。实验数据并非真实生产数据,未虚构性能测试结果。
文章
阅读量
获赞
