☰
从10秒到百毫秒:DolphinScheduler实例列表慢查询的复合索引优化实战
2026/9/28 13:17:17 网站建设 项目流程

先交代一下背景。我维护的 DolphinScheduler 集群跑了一年多,工作流实例从最开始一天几十个,涨到现在每天上千个,实例表累计到几十万行。某天业务反馈“工作流实例列表打开要转圈 10 秒左右,有时候页面直接卡死”,我第一反应是 DolphinScheduler 服务本身负载高了,或者 API 服务线程池被打满。结果排查了一圈,发现真正问题根本不是服务端并发,而是实例列表页背后那条 SQL 在 MySQL 里做了全表扫描,一个索引直接让它从 10 秒降到百毫秒以内。这篇就把整个排查过程、原理和最后落地的索引方案完整拆开讲讲。

网上关于 DolphinScheduler 的部署、调度配置、告警接入的文章很多,但“实例列表越跑越慢”这种真实生产问题反而很少有人写。如果你也遇到类似情况——打开工作流实例列表特别慢、或者监控页卡顿,不用急着调 JVM 参数,先按这篇文章的思路检查一下数据库端,很可能几十秒钟就能解决问题。

1. 现象描述与初步定位:先别急着怀疑调度引擎

1.1 “转圈 10 秒”到底是什么状态

先还原一下现场。用户打开 DolphinScheduler 的“工作流实例”页面,列表区域一直转圈,Chrome 开发者工具里能看到接口请求耗时已经超过 9000ms。注意这个现象有几个特点:

  • 列表页第一次打开特别慢,但翻到后面几页反而快一些(因为 MySQL 会把扫过的数据页缓存在 buffer pool 里)。
  • 按工作流名称搜索时慢得更明显,有时候直接超时。
  • 非高峰期访问也一样慢,和调度任务并发量没有直接关系。
  • 监控页面、任务实例列表偶尔也慢,但没有工作流实例页严重。

我当时做的第一件事是抓 API 日志,发现接口内部确实在等数据库返回。接着去 MySQL 里开了慢查询日志,很快就锁定了问题 SQL。这里我多说一句,很多人在这个阶段会陷入一个误区——一看到系统变慢就觉得是服务挂了、内存不够、线程池阻塞,其实大部分 Web 系统的性能瓶颈都在数据库端,“先看慢查询日志”应该成为肌肉记忆。

1.2 快速定位:从“怀疑服务”转向“怀疑数据库”的三个信号

我总结一下当时判断服务端没问题的三个信号,供大家参考:

  1. DolphinScheduler API 服务的 CPU 使用率只有 10% 左右,线程池远没到上限,JVM 的 GC 也正常。
  2. 同时段其他接口(比如告警列表、项目列表)响应都很快,说明服务整体没问题,只有实例列表这个接口慢。
  3. 用 MySQL 的SHOW PROCESSLIST能看到实例列表接口对应的连接上有一个长时间运行的 SELECT,Query Time 已经超过 8 秒。

看到这三个信号,基本上可以断定是某一条查询 SQL 出了问题。接下来就是把它从慢查询日志里捞出来,分析执行计划。

提示:DolphinScheduler 3.x 版本的工作流实例查询接口路径一般是/projects/{projectCode}/process-instances,如果你不想开慢查询日志,也可以在 MySQL 的performance_schema里查事件耗时,但慢查询日志最直观。建议在 my.cnf 里配置好long_query_time = 1,平时不影响性能,排查问题时直接看日志就行。

1.3 慢查询日志里捞出来的“罪魁祸首”

从 MySQL 慢查询日志里看到的 SQL 大概是下面这种结构(DolphinScheduler 源码里ProcessInstanceMapper的查询逻辑做了类似拼装):

SELECT pi.id, pi.name, pi.state, pi.run_times, pi.start_time, pi.end_time, pi.host, pd.name AS process_definition_name, u.user_name AS executor_name, pr.name AS project_name FROM t_ds_process_instance pi LEFT JOIN t_ds_process_definition pd ON pi.process_definition_code = pd.code LEFT JOIN t_ds_user u ON pi.executor_id = u.id LEFT JOIN t_ds_project pr ON pi.project_code = pr.code WHERE pi.project_code = ? AND pi.start_time BETWEEN ? AND ? AND pi.state = ? ORDER BY pi.start_time DESC LIMIT 0, 20;

注意这只是一个按默认条件查询的场景,实际还会根据用户输入的流程名称拼接pi.name LIKE '%xxx%'。我在慢查询日志里主要看到的默认查询版本,这也是首页一打开就触发的。

用EXPLAIN看了这个 SQL 的执行计划,当时的输出大概长这样:

+----+-------------+-------+------------+------+---------------+------+---------+------+------+----------+-----------------------------+ | id | select_type | table | type | key | rows | Extra | +----+-------------+-------+------------+------+---------------+------+---------+------+------+----------+-----------------------------+ | 1 | SIMPLE | pi | ALL | NULL | 485700 | Using where; Using filesort | +----+-------------+-------+------------+------+---------------+------+---------+------+------+----------+-----------------------------+

看到没,type = ALL,说明引擎对t_ds_process_instance做了全表扫描,扫描行数接近 48 万行,后面还跟着Using filesort,意味着排序也没走索引,是在临时文件里完成的。这两个因素叠加,10 秒一点也不意外。

那为什么没有走索引?因为表上的索引根本没法匹配这个组合查询条件。DolphinScheduler 建表脚本自带的索引一般只有主键和几个外键索引,对“项目 + 时间 + 状态 + 排序”这种组合查询没有任何帮助。这是很多开源软件的常见问题——基础索引覆盖的是增删改场景,列表查询场景往往需要额外优化。

2. 为什么慢:索引缺失背后的三层次分析

2.1 先搞清楚表结构和查询条件

DolphinScheduler 的核心实例表t_ds_process_instance,常见字段有这些:

字段类型说明
idint主键
namevarchar实例名称
process_definition_codebigint所属流程定义编码
statetinyint工作流状态
start_timedatetime开始时间
end_timedatetime结束时间
hostvarchar执行主机
run_timesint运行次数
executor_idint执行人
project_codebigint所属项目编码

再看上面的查询 SQL,过滤条件里出现了三个字段:project_code、start_time、state,排序条件是start_time DESC。这其实是一个很典型的“列表页查询模式”:按所属项目隔离数据、按时间范围取最近一段、按状态过滤,最后按时间倒序分页。

这类查询想快,必须有一个能同时覆盖过滤和排序的复合索引。只有单列索引的话,MySQL 只能从一个索引开始过滤,其余条件都得回表逐个判断,最终可能还是慢。我当时查了一下,表上确实只有主键和基于process_definition_code的普通索引,没有针对列表查询场景的复合索引。

2.2 MySQL 在“没有合适索引”时到底干了什么

我再用大白话解释一下这个过程。假设这 48 万行数据散落在磁盘的各个数据页里,没有索引时,InnoDB 只能从第一个数据页开始,逐页把整张表读出来,每一行都去判断project_code是否匹配、start_time是否在范围内、state是否等于目标值。满足条件的数据再收集起来,先放到内存里排序,数据量大到超过sort_buffer_size时,还要用磁盘临时文件做外部排序。整个过程既扫了海量数据,又占用了额外的排序空间。

而排序这一步,在没有可用索引时就是致命的。MySQL 需要完全读取所有满足过滤条件的数据,再去filesort,意味着哪怕最终只返回 20 条,它也得处理几万甚至几十万条记录。这就是 10 秒的由来。

打个不太严谨但很好懂的比方:这就像在一个没有目录的图书馆里找“最近三个月、借阅状态正常、属于计算机分类”的书。你得在书架上从头到尾翻一遍,一边翻一边判断是否符合条件,找到之后还要自己在小本子上按日期排个序。如果有人提前在柜台建了一套“分类 + 日期 + 状态”的检索卡片,你只需要走到对应分类的柜子,按日期区间直接抽出最近那 20 本就行。

2.3 复合索引为什么能解决这个问题:最左前缀的实战理解

MySQL 索引底层是 B+ 树,数据在索引里按定义的字段顺序排列。一个(project_code, start_time, state)的复合索引,等价于先按项目码排好序,同一个项目码内部再按开始时间排好序,时间相同再看状态。这样查询“某个项目 + 某段时间 + 某种状态”时,B+ 树可以根据最左前缀快速定位到扫描的起点和终点,不用全表扫。

不过这里有一个关键点:复合索引的列顺序非常讲究。如果建的是(project_code, state, start_time),那查询条件里project_code = ? AND start_time BETWEEN ? AND ? AND state = ?中,start_time的范围条件就会让后面的state无法继续用索引过滤。关于这块的推导过程,下一章我会展开说,这也是“随便加个索引”和“加对索引”之间的分水岭。

另外还要解释一下复合索引本身的数据结构。B+ 树的每个索引节点里存储的是“排序后的字段值组合 + 指向下一层节点的指针”或者“指向主键的引用”。查询引擎利用字段之间的有序性,一层一层缩小范围。如果是单列索引,处理project_code后只能拿到一堆主键,还得挨个回表判断其他条件,效率远不如复合索引。这也是生产环境里为什么不能指望“每个字段都建一个索引”来解决问题。

3. 索引设计:从“随便加个索引”到“加对索引”

3.1 最左前缀原则:复合索引的列顺序怎么排

先铺一个基础概念。MySQL 的复合索引遵循“最左前缀”原则,也就是查询要尽量命中索引最左边的列。索引(a, b, c)其实可以支撑以下几种查询组合:

  • 只用 a:能用上索引,但只用到 a 这一列的有序性。
  • 用 a + b:能用上索引,两列都参与定位。
  • 用 a + b + c:最理想的完整匹配。
  • 只用 b 或只用 c:完全无法使用这个索引。
  • 查询里有 a,但 b 是范围条件,那么 c 无法在索引里继续参与过滤。

这就是为什么索引列顺序不能拍脑袋定。常见的排序口诀是:等值条件放前面,范围条件放中间,需要排序的字段也优先考虑放进索引。当然口诀只是起点,最终还是要拿真实业务查询来验证。

3.2 本案例索引选择的完整推导

回到 DolphinScheduler 场景。最频繁的查询条件组合是:

WHERE project_code = 等值 AND start_time BETWEEN 范围 AND state = 等值 ORDER BY start_time DESC

基于最左前缀原则,我的第一版选择是把project_code放在最左边。因为它是等值条件,能直接过滤掉绝大多数行。第二列怎么放?这里有两种思路:

  1. (project_code, start_time, state):过滤完项目后,直接利用 start_time 的有序性做范围扫描,并且排序字段本身就在索引里,只要扫描顺序反过来,ORDER BY start_time DESC不需要额外排序。
  2. (project_code, state, start_time):过滤完项目和状态后,再在结果集内扫时间。但问题在于start_time BETWEEN是范围条件,它一旦进入范围判断,索引后面列的过滤能力就中断了。也就是说,第一种方案里state还可以通过索引的小范围扫描快速确定,而第二种方案里state只能作为索引后的回表过滤条件。

这两者的区别在数据分布上尤其明显。如果同一个项目下的实例 80% 都处于“成功”状态,那先过滤state的优势不大,反而start_time作为第二列能让时间范围扫描直接命中一段连续数据。如果同一个项目下“成功”和“失败”各种状态混合,但时间范围基本是连续的,那同样应该把start_time放前面。所以最终我选择了(project_code, start_time, state)。

这里还有一个小细节:由于 MySQL 5.7 版本(当时线上用的版本)对ORDER BY start_time DESC并不一定能完全利用升序索引倒序读取,框架里还可能出现 filesort,但此时 filesort 的数据量已经不是全表 48 万行了,而是索引定位后可能只有几百行,这个成本完全可以接受。如果是 MySQL 8.0,可以直接建降序索引(project_code, start_time DESC, state),会更彻底地消除排序。我把这两个方案都在下面的实操里写了,生产上按自己的版本选。

3.3 名字模糊搜索怎么处理:别指望索引解决一切

列表页还有一个高频操作是按工作流名称搜索,对应 SQL 大概是:

AND pi.name LIKE '%订单%'

这种“两边都带百分号”的模糊查询,无论怎么建索引都没法走 B+ 树前缀匹配。MySQL 只能扫全表,或者先按其他条件缩小范围再逐行判断。在真实生产环境里,这种搜索通常会命中已经过滤了项目和时间范围的结果集,所以也不用过度担心。

但如果搜索场景特别高频,可以考虑把搜索条件调整为“前缀匹配”,比如LIKE '订单%',这样普通索引也能使用。另外可以参考的做法是:单独为主键字段之外加一个全文索引,或者干脆把实例名称同步到 Elasticsearch,用搜索服务来扛模糊查询。DolphinScheduler 社区也有人提交过强化搜索的插件,不过这些都属于后话了。本文的核心是解决“打开列表就慢”的默认场景,所以重点还是落在复合索引上。

3.4 为什么不能简单给每个字段都建单列索引

很多人想到的第一步是给project_code、start_time、state各建一个普通索引。看起来可行,但实际查询时,MySQL 的优化器通常只能选择一个过滤性最好的索引作为主路径,剩下的条件依然要回表判断。除非用上 MySQL 5.7 之后的index_merge合并技术,但index_merge并不总能奏效,而且即使生效,它也需要把两个索引的扫描结果做交集,代价并不小。

更重要的一点是,每个索引都需要额外的磁盘空间和写入维护成本。DolphinScheduler 这类调度系统,实例表写入频繁,索引太多会拖慢 INSERT 和 UPDATE。生产环境里“够用就好”是原则,一个设计良好的复合索引往往比三四个单列索引效果更好、代价更低。这也是我为什么一直强调要“加对索引”而不是“加索引”。

4. 实操过程:生产环境安全创建索引并验证效果

4.1 先用 EXPLAIN 模拟验证

在真正动手加上索引之前,我习惯先在测试库或者同一环境里用EXPLAIN模拟一下。MySQL 支持在查询前面直接用EXPLAIN加上FORCE INDEX来指定一个假设存在的索引进行测试,不过更准确的做法是先在测试环境把索引建上,再跑EXPLAIN。

我先看一眼优化前的执行计划保存下来:

EXPLAIN SELECT pi.id, pi.name, pi.state, pi.run_times, pi.start_time, pi.end_time, pi.host, pd.name AS process_definition_name, u.user_name AS executor_name, pr.name AS project_name FROM t_ds_process_instance pi LEFT JOIN t_ds_process_definition pd ON pi.process_definition_code = pd.code LEFT JOIN t_ds_user u ON pi.executor_id = u.id LEFT JOIN t_ds_project pr ON pi.project_code = pr.code WHERE pi.project_code = 123456 AND pi.start_time BETWEEN '2025-04-01 00:00:00' AND '2025-05-01 00:00:00' AND pi.state = 7 ORDER BY pi.start_time DESC LIMIT 0, 20;

关键输出是前面已经贴过的type=ALL, rows=485700, Extra=Using where; Using filesort。之后我把测试库的索引加上,再跑同样的 SQL。

4.2 确定索引 DDL 与字段选择

根据上一章的推导,我选择创建如下复合索引:

ALTER TABLE t_ds_process_instance ADD INDEX idx_project_start_state (project_code, start_time, state);

如果你的 MySQL 是 8.0 以上,并且希望彻底消除排序开销,可以改成:

ALTER TABLE t_ds_process_instance ADD INDEX idx_project_start_state (project_code, start_time DESC, state);

注意一点,加索引前最好先确认当前表数据量和业务低峰期。ALTER TABLE在 MySQL 5.7 里虽然多数情况下是InnoDB Online DDL,但会经历一个短暂的表级元数据锁阶段,写入压力大的时候可能引起 DML 阻塞。建议在业务低峰期操作,或者使用pt-online-schema-change这类工具来做无锁变更。我当时是在凌晨执行,数据量 48 万行,整个过程只花了几秒钟。

4.3 优化后的执行计划对比

加上索引后,再跑一次EXPLAIN:

+----+-------------+-------+------------+-------+--------------------+------+---------+------+------+----------+-----------------------+ | id | select_type | table | type | key | rows | Extra | +----+-------------+-------+------------+-------+--------------------+------+---------+------+------+----------+-----------------------+ | 1 | SIMPLE | pi | range | idx_project_start_state | 1225 | Using index condition | +----+-------------+-------+------------+-------+--------------------+------+---------+------+------+----------+-----------------------+

注意几个关键变化:

  • type从ALL变成了range,说明已经通过索引定位到了一个扫描区间。
  • rows从 48 万降到 1225,意味着只需要扫描 1200 行左右,直接少了三个数量级。
  • Extra里的Using filesort消失了,排序走了索引顺序;Using index condition表示部分过滤条件下推到存储引擎处理,回表次数也大幅减少。

页面上最直观的感受是:原来 10 秒左右的加载时间,变成 150 毫秒左右。我连续刷新了几次,包括按流程名称搜索,响应速度都在可接受范围。对于列表页来说,150 毫秒已经很难感知到“卡顿”了。

4.4 验证新索引是否真的被使用

有时候你以为建了索引,但 MySQL 优化器不一定会选它,尤其是统计信息不准确、或者查询条件里存在OR、函数包裹等导致索引失效时。所以加完索引后要多验证几个真实场景。

我按平时的用户操作路径跑了几种常用查询,都加上EXPLAIN看执行计划:

场景查询条件是否使用新索引
默认列表project_code + start_time + state是
只看某个状态project_code + state(不带时间)部分使用(用到 project_code)
按名称搜索project_code + start_time + name LIKE是(时间范围走索引,名称回表判断)
全局搜索,不带项目start_time + state否

最后一行特别提醒一下:如果用户切换了全局视图,查询里没有project_code条件,新索引就失去了最左前缀,MySQL 会走全表扫描。这是复合索引的天然限制。如果这种全局查询也高频出现,可以考虑再补一个(start_time, state)索引,但也要小心索引冗余。我当时的业务里默认都是项目内查看,所以没有继续加。

提示:执行完ALTER TABLE之后,可以顺手执行ANALYZE TABLE t_ds_process_instance;更新统计信息,帮助优化器更准确地选择索引。有些场景下索引已经建好,优化器还是全表扫,就是统计信息太久没更新的关系。

5. 常见问题与排查技巧实录

5.1 加了索引为什么还是慢

我遇到过几次“明明按照最左前缀建了索引,查询还是慢”的情况。归纳下来主要是这几类原因:

  1. 查询条件里对索引字段做了函数运算,比如WHERE DATE(start_time) = '2025-05-01',一旦字段被函数包裹,索引直接失效。正确写法是WHERE start_time >= '2025-05-01 00:00:00' AND start_time < '2025-05-02 00:00:00'。
  2. 复合索引的列顺序与实际查询不匹配。比如索引是(state, start_time, project_code),但查询只给project_code和start_time,此时project_code不是最左列,索引没法高效定位。
  3. 查询数据量占了表的很大比例,优化器判断走全表扫描比走索引更快。比如一张表总共 1000 行,扫全表也就 1 毫秒,自然没必要走索引。只有数据量大、选择性高的列,索引优势才明显。
  4. 隐式类型转换。如果字段是 varchar,查询传了 int,MySQL 会做隐式转换导致索引失效。DolphinScheduler 的project_code是 bigint,一般没问题,但自己写报表 SQL 时经常踩这个坑。

5.2 一个实用排查清单

后面我再遇到类似性能问题,基本都按这个清单走,整理出来供参考:

  • 打开慢查询日志:SET GLOBAL slow_query_log = ON; SET GLOBAL long_query_time = 1;
  • 用EXPLAIN查看目标 SQL 的type、key、rows、Extra四列。
  • 确认 SQL 里的过滤字段是否被函数、隐式转换、OR等破坏。
  • 确认复合索引列顺序,是否满足最左前缀,是否有范围条件中断索引。
  • 检查数据分布和采样的选择性,过滤性太差的列不要放索引最前面。
  • 查看 MySQL 优化器选的索引与预估扫描行数,必要时用FORCE INDEX交叉验证。
  • 变更完索引后再次EXPLAIN对比rows和耗时,别只看接口时间。

5.3 关于 DolphinScheduler 版本和一些长期优化建议

这次排查基于 DolphinScheduler 3.x + MySQL 5.7 的组合。不同版本对列表页的 SQL 生成逻辑有一些差异,比如 3.2 之后的版本可能对任务实例查询做了查询优化,但核心逻辑还是“按项目、时间、状态过滤”。所以这套索引优化思路在 2.x、3.x 以及即将普及的 4.x 上都适用。

除了加索引,还有几个长期优化方向也值得提一下:

  • 对历史工作流实例做定期归档,把半年前的数据迁移到归档表。实例表数据量降下来,很多查询慢的问题会自然消失。
  • 前端列表接口增加缓存,把“默认时间范围”的查询结果缓存 30 秒到 1 分钟,减少数据库压力。
  • 如果搜索字段多且复杂,考虑引入 Elasticsearch。不过这个改动比较大,建议先通过索引和归档解决 90% 的问题,剩下 10% 再做重度优化。
  • 监控慢查询日志,定期捞 Top SQL,提前发现隐患。DolphinScheduler 的t_ds_process_instance膨胀速度往往比预期快,最好从上线第一天就养好这个习惯。

5.4 团队配合与变更管理的小经验

给生产库加索引虽然不像改代码那么高风险,但也要按变更流程走。我当时的做法是先在测试环境完整复现慢查询,把优化前后的执行计划和页面响应耗时截图记录到变更单里,再申请生产变更。DolphinScheduler 相关表的变更会影响正在调度的任务吗?只要不是删除字段、改字段类型,加索引本身不会影响任务调度写入,但我在执行前还是确认了没有正在批量写t_ds_process_instance的高峰窗口。

另外建议把索引命名规范定下来,比如idx_表名_字段1_字段2。否则时间一长,表上多个类似索引堆在一起,每个索引都占空间,优化器还容易选错。我的习惯是在优化完成后,用SHOW INDEX FROM t_ds_process_instance;检查一遍,确认没有重复或者几乎冗余的索引。

说一个我个人的体会。这次问题排查完以后,我对“系统慢就是代码烂”这个说法越来越警惕。DolphinScheduler 这类成熟开源项目,应用层通常没什么大坑,真正坑人的往往躲在数据量增长之后。实例表从一万行涨到五十万行,可能只需要半年;如果你没有提前给列表查询建好合适的复合索引,迟早会踩到 10 秒转圈这道坎。与其等到业务投诉,不如现在就打开慢查询日志看一眼。你先查一下t_ds_process_instance数据量,再跑一遍平时最常用的列表查询,配上EXPLAIN看看是不是也在全表扫描。如果是,那这篇文章里的idx_project_start_state索引方案,大概率也能直接帮你解决。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询