1. 项目概述:从“慢”到“快”的数据库调优实战
最近在几个生产环境的达梦数据库项目上,又处理了一批性能卡顿的工单。看着开发同事发来的“页面转圈圈”截图和动辄几十秒的SQL执行时间,我意识到,很多朋友对达梦数据库的SQL优化,尤其是执行计划这个核心工具的掌握,还停留在比较基础的层面。大家可能知道要建索引,但为什么建了索引有时反而更慢?面对一个复杂的多表关联查询,优化器为什么选择了那个看起来“很笨”的全表扫描路径?这些问题,光靠猜测和试错是解决不了的,必须深入到执行计划的层面去理解数据库的“思考”过程。
达梦作为一款成熟的关系型数据库,其SQL执行引擎的优化器已经相当智能,但它依然需要清晰、准确的指令(也就是我们写的SQL)和合适的数据结构(如表设计、索引)来发挥最大效能。SQL优化不是玄学,而是一门结合了数据库原理、统计信息解读和实战经验的工程技术。本次分享,我将以一个多年DBA和开发者的双重视角,拆解达梦SQL优化的核心路径,并重点聚焦于执行计划的解读——这是所有优化工作的“地图”和“诊断报告”。无论你是刚接触达梦的开发者,还是需要维护系统性能的运维工程师,掌握这套方法,都能让你在面对性能问题时,从被动响应变为主动洞察。
2. 达梦SQL优化核心思路与工具箱
在动手优化任何一条SQL之前,确立正确的思路比掌握一堆零散的技巧更重要。达梦SQL优化的核心目标,是在保证业务结果正确的前提下,尽可能减少数据库服务端的资源消耗(主要是CPU和I/O)和执行时间。这个目标可以分解为几个递进的原则。
2.1 优化器的工作逻辑与我们的干预点
达梦的SQL优化器本质上是一个“成本估算器”。当你提交一条SQL时,优化器会做以下几件事:
- 语法语义解析:检查SQL语句是否正确,确认表、列是否存在,权限是否足够。
- 生成逻辑执行计划:根据SQL的语义,生成一系列可能的操作顺序,比如先关联哪两张表,在哪里应用过滤条件。
- 生成物理执行计划:为逻辑计划中的每一个操作,选择具体的执行算法。例如,对于表关联(JOIN),是使用嵌套循环(NESTED LOOP)、哈希连接(HASH JOIN)还是归并连接(MERGE JOIN)。对于数据访问,是使用全表扫描(FULL SCAN)还是索引扫描(INDEX SCAN)。
- 成本估算:基于数据库收集的统计信息(如表的数据量、索引的区分度、数据分布直方图),为每一个物理执行计划计算出一个预估的“成本”。成本单位是抽象的,但综合反映了预期的I/O和CPU开销。
- 选择最优计划:从所有候选的物理计划中,选择预估成本最低的一个,将其编译成最终的可执行代码,也就是我们看到的执行计划。
我们的优化工作,大部分时候是在“帮助”或“引导”优化器做出更明智的选择。主要干预点有两个:
- 提供更优的SQL写法:避免导致优化器难以理解或产生高成本计划的语法。例如,在WHERE子句中对索引列使用函数或运算,会使索引失效。
- 提供更优的数据结构:创建合适的索引,是降低数据访问成本最直接的手段。但索引不是越多越好,需要权衡查询加速与增删改操作的维护开销。
- 提供准确的统计信息:优化器的成本估算严重依赖统计信息。如果统计信息过期(例如,表刚导入了大量新数据但未更新统计信息),优化器可能会基于错误的数据量做出糟糕的选择,比如该用索引时却选择了全表扫描。
2.2 必备的监控与诊断工具
在达梦数据库中,我们有几个必须熟悉的工具来定位性能问题:
V$SESSIONS 和 V$SQL_HISTORY:这是实时监控的入口。通过查询
V$SESSIONS可以找到当前正在执行的、消耗资源多的会话。V$SQL_HISTORY则记录了历史SQL的执行信息,包括执行时间、物理读/逻辑读次数,是定位“慢SQL”TOP榜单的关键视图。-- 查找当前正在运行且耗时较长的SQL SELECT sess_id, sql_text, state, last_recv_time FROM V$SESSIONS WHERE state = 'ACTIVE' AND sql_text IS NOT NULL ORDER BY last_recv_time; -- 查询历史慢SQL(示例:查找最近1小时执行时间超过5秒的SQL) SELECT SQL_TEXT, EXEC_TIME, N_EXEC, LOGIC_READ, PHYS_READ FROM V$SQL_HISTORY WHERE START_TIME > SYSDATE - 1/24 -- 最近1小时 AND EXEC_TIME > 5000 -- 执行时间大于5000毫秒 ORDER BY EXEC_TIME DESC;ET(执行时间跟踪):这是达梦提供的非常轻量级的SQL性能剖析工具。通过在SQL前加上
ET( ),可以输出该SQL执行过程中各个操作步骤的详细耗时。ET( SELECT * FROM large_table WHERE user_name = 'test' );执行后,会在消息窗口或输出文件中看到一份详细的耗时报告,精确告诉你时间花在了解析、优化、还是数据读取上。这对于区分“网络慢”和“数据库慢”非常有用。
AWR报告(达梦性能诊断工具):对于需要深度分析系统级性能瓶颈的场景,达梦的AWR(自动工作负载仓库)报告是终极武器。它定期采集系统快照,生成包含等待事件、TOP SQL、锁争用、内存和I/O分析的详细报告。生成AWR报告通常需要使用
SP_CREATE_SYSTEM_SNAPSHOTS()创建快照,然后通过管理工具或特定脚本生成。
注意:
ET和AWR是诊断利器,但切忌在生产环境高峰时段频繁执行ET或生成AWR,因为其本身也有一定开销。通常先通过V$SQL_HISTORY锁定问题SQL,再在测试环境或业务低峰期进行深度剖析。
3. 执行计划深度解读:看懂数据库的“作战地图”
拿到一条慢SQL后,最核心的动作就是查看并解读它的执行计划。执行计划以树形结构展示了SQL的执行路径,每个节点代表一个操作符(operator)。读懂它,你就知道了数据库准备如何“打仗”。
3.1 获取执行计划的几种方式
EXPLAIN 命令:最常用、最标准的方式。它只生成执行计划,并不实际执行SQL,因此没有副作用。
EXPLAIN SELECT a.*, b.department_name FROM employee a JOIN department b ON a.dept_id = b.id WHERE a.salary > 10000;在达梦的管理工具(如DM管理工具或DBeaver)中执行,通常会以表格或图形化的方式展示计划。
在SQL前加上
SET STAT ON:这种方式会实际执行SQL,并在执行结束后输出详细的执行计划及实际的运行时统计信息(如实际返回行数、实际耗时等)。这对于验证优化器估算是否准确至关重要。SET STAT ON; SELECT a.*, b.department_name FROM employee a JOIN department b ON a.dept_id = b.id WHERE a.salary > 10000; SET STAT OFF; -- 记得关闭
3.2 关键操作符解析与性能含义
执行计划由一系列操作符构成。理解常见操作符的含义和开销,是解读计划的基础。下面以一个虚拟的复杂查询计划为例,拆解关键节点:
#NSET2: [1, 1000, 156] #PRJT2: [1, 1000, 156] #NESTED LOOP INNER JOIN2: [1, 1000, 156] #INDEX SCAN: EMPLOYEE(IDX_EMP_SALARY), [1, 100, 104] #INDEX SCAN: DEPARTMENT(PK_DEPARTMENT), [1, 10, 52]我们自上而下、从内到外解读:
最内层叶子节点(数据访问路径):
#INDEX SCAN: EMPLOYEE(IDX_EMP_SALARY), [1, 100, 104]- 操作:通过索引
IDX_EMP_SALARY扫描EMPLOYEE表。 - 估算信息
[1, 100, 104]:这是关键。三个数字通常分别表示估算成本、估算返回行数、估算输出结果集字节数。这里成本为1(很低),估算返回100行。
- 操作:通过索引
#INDEX SCAN: DEPARTMENT(PK_DEPARTMENT), [1, 10, 52]- 操作:通过主键索引
PK_DEPARTMENT扫描DEPARTMENT表。估算返回10行。
- 操作:通过主键索引
中间层节点(连接操作):
#NESTED LOOP INNER JOIN2: [1, 1000, 156]- 操作:对上述两个结果集进行嵌套循环连接。这是连接算法的一种。优化器选择它,通常是因为驱动表(第一个
INDEX SCAN)结果集较小(100行),且内层表(第二个INDEX SCAN)有高效的索引访问路径(主键)。 - 估算信息:成本仍为1(说明优化器认为这是一个高效计划),估算最终连接后产生1000行数据(100 * 10)。
- 操作:对上述两个结果集进行嵌套循环连接。这是连接算法的一种。优化器选择它,通常是因为驱动表(第一个
最外层节点(结果处理):
#PRJT2:投影操作,负责选择最终需要输出的列。#NSET2:结果集收集操作,负责将结果返回给客户端。
需要警惕的高开销操作符:
CSCN2或FULL SCAN:全表扫描。当表很大且没有合适的索引时,这是性能杀手。看到它,首先要问:为什么优化器不用索引?是索引缺失,还是SQL写法导致索引失效?HASH JOIN:哈希连接。当连接的两个表都很大,且没有高效的索引用于嵌套循环时,优化器可能选择哈希连接。它需要在内存在构建哈希表,如果结果集巨大,可能导致内存溢出(使用磁盘临时空间),性能急剧下降。SET STAT ON看到的实际“溢出”次数是重要指标。SORT2:排序。如果SQL中有ORDER BY、GROUP BY(非索引列)或DISTINCT,就可能出现排序操作。排序是CPU和内存密集型操作,对于大数据集非常消耗资源。考虑是否可以通过索引来避免排序(例如,在ORDER BY的列上建立索引)。SLCT2:过滤。这个操作本身开销不大,但它出现的位置很重要。理想情况下,过滤条件应尽可能在靠近数据源的扫描阶段(CSCN2或INDEX SCAN)就应用,以减少后续操作处理的数据量。如果SLCT2出现在计划树的很上层,意味着大量数据被传递上来后才被过滤,这通常不是好现象。
3.3 如何判断一个执行计划的好坏?
- 看数据访问路径:尽量让查询通过索引定位数据,避免
CSCN2(全表扫描)。尤其是对于大表,索引扫描的成本通常远低于全表扫描。 - 看连接顺序与算法:观察多表关联时,哪张表被选为驱动表。通常,应该将过滤后结果集更小的表作为驱动表。连接算法的选择(NESTED LOOP vs HASH JOIN vs MERGE JOIN)要适合数据特征。
- 看估算与实际的差异:使用
SET STAT ON对比估算行数和实际行数。如果差异巨大(例如估算100行,实际返回10万行),说明统计信息不准确,优化器基于错误信息制定了糟糕的计划。这是导致性能问题的一个非常常见的原因。 - 看额外开销操作:警惕不必要的
SORT2(排序)、DISTINCT等。思考业务是否真的需要这些操作,或者能否通过索引消除它们。
4. 实战优化:从诊断到解决的完整流程
让我们结合一个模拟的慢SQL案例,走一遍完整的优化流程。假设我们有一个订单系统,orders表有千万级数据,现在有一个查询速度很慢。
4.1 案例:订单明细查询优化
原始慢SQL:
SELECT o.order_no, o.order_date, c.customer_name, SUM(oi.amount) as total_amount FROM orders o JOIN order_items oi ON o.id = oi.order_id JOIN customers c ON o.customer_id = c.id WHERE o.order_date >= DATEADD(month, -1, SYSDATE) -- 查询近一个月的订单 AND o.status = 'SHIPPED' AND c.region = 'EAST' GROUP BY o.order_no, o.order_date, c.customer_name ORDER BY o.order_date DESC;执行时间超过30秒。
第一步:获取并解读执行计划使用EXPLAIN或SET STAT ON查看计划。假设我们看到的计划关键部分如下:
#NSET2: [高成本] #SORT2: [高成本] -- 排序开销大 #HASH2 GROUP BY: [高成本] -- 哈希分组 #NESTED LOOP INNER JOIN2: [较高成本] #HASH2 INNER JOIN: [高成本] -- 哈希连接 #CSCN2: ORDERS [成本极高] -- 全表扫描! #CSCN2: CUSTOMERS [成本高] #INDEX SCAN: ORDER_ITEMS(IDX_ITEMS_ORDER_ID)问题诊断:
- 最严重问题:对
ORDERS和CUSTOMERS表进行了全表扫描(CSCN2)。这是耗时的主要根源。 - 连接与分组:由于驱动表数据量巨大(全表扫描的结果),导致使用了高开销的
HASH2 INNER JOIN和HASH2 GROUP BY。 - 排序:最终的
ORDER BY导致了SORT2操作。
第二步:针对性优化
优化1:为过滤条件创建复合索引
ORDERS表上的WHERE条件涉及order_date和status。创建一个复合索引可以高效定位数据。CREATE INDEX IDX_ORDERS_DATE_STATUS ON ORDERS(order_date, status);实操心得:在复合索引中,将区分度更高(即唯一值更多)的列放在前面,通常过滤效果更好。这里
order_date是范围查询,status是等值查询,达梦优化器可以有效地利用这个索引进行范围扫描+过滤。优化2:为连接条件确保索引存在
CUSTOMERS表被region过滤,并且通过id与ORDERS关联。确保:CUSTOMERS.id是主键(已有索引)。- 在
CUSTOMERS.region上创建索引,加速过滤。
CREATE INDEX IDX_CUSTOMERS_REGION ON CUSTOMERS(region);ORDER_ITEMS表通过order_id与ORDERS关联,已有索引IDX_ITEMS_ORDER_ID,这很好。优化3:更新统计信息在创建新索引后,务必更新相关表的统计信息,让优化器了解新的数据分布。
CALL SP_STAT_ON_TABLE('SYSDBA', 'ORDERS'); CALL SP_STAT_ON_TABLE('SYSDBA', 'CUSTOMERS'); -- 或者使用DBMS_STATS包(如果版本支持)
第三步:验证优化效果再次执行EXPLAIN,新的计划可能变为:
#NSET2: [成本显著降低] #SORT2: [成本降低] #HASH2 GROUP BY: [成本降低] #NESTED LOOP INNER JOIN2: #NESTED LOOP INNER JOIN2: -- 连接算法变为嵌套循环 #INDEX SCAN: ORDERS(IDX_ORDERS_DATE_STATUS) -- 全表扫描消失! #INDEX SCAN: CUSTOMERS(IDX_CUSTOMERS_REGION) -- 全表扫描消失! #INDEX SCAN: ORDER_ITEMS(IDX_ITEMS_ORDER_ID)最关键的改变是:CSCN2(全表扫描)被INDEX SCAN取代。驱动表的数据量从“全表”缩减为“近一个月且状态为SHIPPED的订单”,可能只有几万行。这使得优化器可以选择更高效的NESTED LOOP连接,并且后续的分组和排序操作处理的数据量也大大减少。实测查询时间从30秒以上降至1秒内。
4.2 进阶优化技巧与模式
除了加索引,还有一些写法上的技巧:
避免在索引列上使用函数或计算:
-- 坏:索引失效 SELECT * FROM orders WHERE YEAR(order_date) = 2024; -- 好:利用索引范围扫描 SELECT * FROM orders WHERE order_date >= '2024-01-01' AND order_date < '2025-01-01';谨慎使用
SELECT *:只获取需要的列。特别是当表中有大字段(如CLOB,BLOB)时,SELECT *会导致不必要的I/O和内存消耗。这也可能影响覆盖索引的使用。合理使用
WITH(CTE) 和子查询:复杂的查询可以拆分成多个CTE,提高可读性。但要注意,达梦优化器可能会将CTE物化(作为临时结果集),对于简单查询有时不如直接连接高效。需要结合执行计划判断。分页查询优化:对于深度分页(
LIMIT ... OFFSET很大),使用ORDER BY索引列 + 条件过滤(WHERE id > ?)的方式,比传统的OFFSET性能好得多。
5. 常见问题排查与避坑指南
在实际操作中,你会遇到各种“诡异”的情况。这里记录一些典型问题和排查思路。
5.1 为什么建了索引却没生效?
这是最常见的问题之一。除了上面提到的“对索引列使用函数”,还有以下可能:
- 统计信息过期:索引创建后,如果没有及时更新统计信息,优化器可能不知道这个索引的存在或它的高效性。解决方法:创建或重建索引后,执行
SP_STAT_ON_INDEX('模式名','表名','索引名')或更新整个表的统计信息。 - 数据倾斜严重:如果某个列的值99%都是‘A’,那么查询
WHERE col = 'A'时,优化器可能认为全表扫描比索引扫描更快(因为要返回几乎全部数据)。解决方法:这种情况下索引确实意义不大,需要考虑其他过滤条件或业务设计。 - 复合索引顺序不当:对于
WHERE a = ? AND b > ?,索引(a, b)有效,而(b, a)可能效果很差。解决方法:根据查询条件的最左前缀原则设计复合索引。 - 使用了
OR条件:WHERE indexed_col = ? OR non_indexed_col = ?这样的条件,可能导致优化器放弃使用索引。解决方法:考虑改写为UNION ALL两个查询。
5.2 执行计划突然变差(Plan Regression)
昨天还很快的查询,今天突然慢了。很可能发生了“执行计划回退”。
- 首要怀疑对象:统计信息。是否有定时作业更新了统计信息,但新统计信息反而产生了误导?或者表数据量发生了剧烈变化(如大批量导入/删除)而未更新统计信息?排查:比较当前和历史上的执行计划(如果开启了SQL审计或AWR),并手动更新统计信息看是否恢复。
- 参数绑定(Bind Peeking)问题:对于使用绑定变量的SQL,达梦在第一次硬解析时会根据传入的绑定变量值来生成计划。如果第一次传入的值不具有代表性(例如查询一个不存在的ID,返回0行),优化器可能生成一个针对“小数据量”的计划。当后续传入一个典型值(返回大量数据)时,这个计划就变得非常低效。解决方法:对于数据分布极不均匀的列,考虑不使用绑定变量,或者使用提示(HINT)强制索引。
- 系统资源变化:内存不足可能导致哈希连接溢出到磁盘,或排序操作变慢。排查:检查
V$MEM_POOL、V$BUFFERPOOL等视图,确认内存使用情况。
5.3 锁争用导致的性能假象
有时SQL本身没问题,但执行时被阻塞了,表现为“慢”。
- 现象:查询长时间处于“执行中”,但CPU和I/O不高。通过
V$SESSIONS查看该会话的BLOCKED字段为TRUE,WAIT_EVENT显示等待锁。 - 排查:查询
V$LOCK和V$TRXWAIT视图,找出谁阻塞了谁。 - 解决:优化事务设计,避免长事务和大事务,尽快提交或回滚。在读写频繁的键上,考虑使用
SELECT ... FOR UPDATE NOWAIT或乐观锁机制。
5.4 工具连接与配置相关
从热搜词看,很多朋友在基础工具使用上遇到问题,这也间接影响优化工作。
- 连接工具:DBeaver、达梦自带的DM管理工具、IDEA插件都是不错的选择。确保使用的JDBC驱动版本与数据库服务器版本兼容。
- 权限问题:如“授予用户schema权限”错误,通常是因为达梦中用户和模式(SCHEMA)概念紧密关联。
GRANT权限给用户时,需要明确权限到具体模式下的对象。GRANT SELECT ON SYSDBA.TABLE1 TO USER_A;而不是简单授予模式权限。 - Docker镜像:达梦官方提供了Docker镜像,方便搭建测试环境。下载后注意初始化数据和端口映射配置。
SQL优化是一个持续的过程,没有一劳永逸的方案。核心在于养成习惯:监控 -> 抓取慢SQL -> 解读执行计划 -> 针对性优化 -> 验证效果。把执行计划这张“地图”看懂了,你就掌握了在达梦数据库性能世界里导航的能力。每当解决一个棘手的性能问题,那份对系统更深一层的理解,就是这份工作最大的乐趣所在。