先交代一下背景。我维护的 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 快速定位:从“怀疑服务”转向“怀疑数据库”的三个信号
我总结一下当时判断服务端没问题的三个信号,供大家参考:
- DolphinScheduler API 服务的 CPU 使用率只有 10% 左右,线程池远没到上限,JVM 的 GC 也正常。
- 同时段其他接口(比如告警列表、项目列表)响应都很快,说明服务整体没问题,只有实例列表这个接口慢。
- 用 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,常见字段有这些:
| 字段 | 类型 | 说明 |
|---|---|---|
| id | int | 主键 |
| name | varchar | 实例名称 |
| process_definition_code | bigint | 所属流程定义编码 |
| state | tinyint | 工作流状态 |
| start_time | datetime | 开始时间 |
| end_time | datetime | 结束时间 |
| host | varchar | 执行主机 |
| run_times | int | 运行次数 |
| executor_id | int | 执行人 |
| project_code | bigint | 所属项目编码 |
再看上面的查询 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放在最左边。因为它是等值条件,能直接过滤掉绝大多数行。第二列怎么放?这里有两种思路:
(project_code, start_time, state):过滤完项目后,直接利用 start_time 的有序性做范围扫描,并且排序字段本身就在索引里,只要扫描顺序反过来,ORDER BY start_time DESC不需要额外排序。(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 加了索引为什么还是慢
我遇到过几次“明明按照最左前缀建了索引,查询还是慢”的情况。归纳下来主要是这几类原因:
- 查询条件里对索引字段做了函数运算,比如
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'。 - 复合索引的列顺序与实际查询不匹配。比如索引是
(state, start_time, project_code),但查询只给project_code和start_time,此时project_code不是最左列,索引没法高效定位。 - 查询数据量占了表的很大比例,优化器判断走全表扫描比走索引更快。比如一张表总共 1000 行,扫全表也就 1 毫秒,自然没必要走索引。只有数据量大、选择性高的列,索引优势才明显。
- 隐式类型转换。如果字段是 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索引方案,大概率也能直接帮你解决。