一个运行在 MySQL 5.7 上的业务系统,其核心查询 SQL 性能突然变得异常缓慢。业务 SQL 如下:
(
SELECT
concat(A.ID, '') ID,
date_format(A.ACCEPT_DATE, '%Y-%m-%d') ACCEPT_DATE,
A.QYID,
A.APPLY_NO,
date_format(A.APPLY_DATE, '%Y-%m-%d') APPLY_DATE,
A.APPLY_TYPE,
T.TASK_KEY,
A.BIZ_TYPE,
T.ID AS TASK_ID,
T.TASK_NAME,
'-' SYGZR,
A.LIC_NO,
A.HANDLE_TYPE,
F.JYZMC ENTERPRISE_NAME,
F.QYLX,
F.ZCDZ_QX,
F.JYFS,
F.ZCDZ_XXDZ,
A.GMT_MODIFIED
FROM
T_LIC_APPLY_INFO A,
t_ypjyjbxx_jc_xzsp F,
t_proc_ru_task T
WHERE
A.ID = F.ID
AND A.PROC_INST_ID = T.PROC_INST_ID
AND A.IS_SUBMIT = 1
AND A.IS_DESTROY = 0
AND T.GMT_COMPLETE IS NULL
AND F.ZCDZ_QX LIKE concat('510185', '%')
ORDER BY
A.APPLY_DATE
)
LIMIT 0,
50;
起初,通过查看 执行计划 ,问题定位在 t_proc_ru_task 表(以下简称 T 表)进行了大量的行扫描(rows 高达 10万+)。T 表的数据总量超过 80 万行,而条件 GMT_COMPLETE IS NULL 能过滤出大约 5 万多行数据,并且该字段上已经存在索引 idx_t_proc_ru_task_gmt_complete。
mysql> SELECT COUNT(*) FROM T_PROC_RU_TASK T WHERE T.GMT_COMPLETE IS NULL;
+----------+
| COUNT(*) |
+----------+
| 53758 |
+----------+
1 row in set (0.02 sec)
初步判断是 GMT_COMPLETE IS NULL 这个条件的过滤性还不够理想,于是尝试为 T 表添加更精确的时间过滤条件,并创建了一个覆盖这两个时间字段的联合索引,试图从时间维度缩小扫描范围。
AND T.GMT_CREATE>=DATE_SUB(NOW(), INTERVAL 7 DAY)
AND T.GMT_CREATE<=now()
alter table t_proc_ru_task add index idx_gmt_create_complete(gmt_create,gmt_complete);
这次改造效果显著,T 表的扫描行数从 10 万+ 下降到了 1 万+,SQL 性能得到了大幅提升。
性能是变好了,但还能不能再优化呢?观察 SQL,发现关联条件里还有 AND A.PROC_INST_ID = T.PROC_INST_ID。按照理想的索引设计思路,如果为 T 表创建一个包含 PROC_INST_ID、GMT_CREATE、GMT_COMPLETE 的复合索引,理论上能最大化地利用索引覆盖和过滤条件,达到最优性能。于是,我们新建了这个复合索引,并删除了之前临时添加的联合索引。
mysql> ALTER TABLE T_PROC_RU_TASK ADD INDEX idx_inst_gmt_all (PROC_INST_ID, GMT_CREATE, GMT_COMPLETE);
Query OK, 0 rows affected (22.12 sec)
Records: 0 Duplicates: 0 Warnings: 0
mysql> alter table t_proc_ru_task drop index idx_gmt_create_complete;
Query OK, 0 rows affected (0.03 sec)
Records: 0 Duplicates: 0 Warnings: 0
然而,奇怪的事情发生了:优化器“无视”了我们精心设计的新索引,执行计划依然固执地选择了基于 gmt_complete 的单列索引 idx_t_proc_ru_task_gmt_complete,扫描行数又回到了 10 万+ 的水平。
这就非常令人费解了。从逻辑上讲,这个新创建的复合索引应该是当前查询场景下的最佳选择,但 MySQL 为什么不走呢?
问题的根源往往隐藏在细节之中。带着这个疑问,我们开始检查两张表关联字段 PROC_INST_ID 的定义,结果发现了一个致命的“坑”:
T_LIC_APPLY_INFO 表的 PROC_INST_ID 字段是 bigint(20) 类型。
T_PROC_RU_TASK 表的 PROC_INST_ID 字段是 varchar(32) 类型。
关联字段的类型不一致! 在 MySQL 中,当不同类型的字段进行比较时(比如 bigint 和 varchar),会发生隐式类型转换,这通常会导致索引失效。这里就完美解释了为什么优化器拒绝使用新建的、以 PROC_INST_ID 开头的复合索引。
检查表结构确认了这一点:
mysql> SHOW CREATE TABLE T_LIC_APPLY_INFO\G
*************************** 1. row ***************************
Table: T_LIC_APPLY_INFO
Create Table: CREATE TABLE `t_lic_apply_info` (
`ID` bigint(20) NOT NULL COMMENT 'ID',
`PROC_INST_ID` bigint(20) DEFAULT NULL COMMENT '流程实例ID',
`QYID` varchar(48) COLLATE utf8_bin DEFAULT NULL COMMENT '企业ID',
......
`GMT_MODIFIED` timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间',
PRIMARY KEY (`ID`),
KEY `index_qyid` (`QYID`),
KEY `index_lic_category` (`LIC_CATEGORY`),
KEY `IDX_PROC_INST_ID` (`PROC_INST_ID`) USING BTREE,
KEY `index_biz_type` (`BIZ_TYPE`),
KEY `t_lic_apply_info_APPLY_DATE_IDX` (`APPLY_DATE`) USING BTREE
) ENGINE=InnoDB DEFAULT CHARSET=utf8 COLLATE=utf8_bin COMMENT='企业许可业务申请表'
1 row in set (0.00 sec)
mysql> SHOW CREATE TABLE T_PROC_RU_TASK\G
*************************** 1. row ***************************
Table: T_PROC_RU_TASK
Create Table: CREATE TABLE `t_proc_ru_task` (
`ID` varchar(32) NOT NULL,
`PARENT_TASK_ID` varchar(32) DEFAULT NULL,
`PROC_INST_ID` varchar(32) DEFAULT NULL,
`PROC_DEFINE_ID` varchar(32) DEFAULT NULL,
`VER` int(11) DEFAULT NULL,
`TASK_KEY` varchar(100) DEFAULT NULL,
`CREATOR` varchar(32) DEFAULT NULL,
`TASK_NAME` varchar(32) DEFAULT NULL,
`TASK_TYPE` char(1) DEFAULT NULL,
`TASK_WAY` char(1) DEFAULT NULL,
`GMT_CREATE` timestamp NULL DEFAULT NULL,
`GMT_EXPIRE` timestamp NULL DEFAULT NULL,
`GMT_COMPLETE` timestamp NULL DEFAULT NULL,
`VARIABLE` text CHARACTER SET utf8mb4 COLLATE utf8mb4_bin,
`GMT_MODIFIED` timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
`MODIFY_USER` varchar(32) DEFAULT NULL,
PRIMARY KEY (`ID`),
KEY `inst_id` (`PROC_INST_ID`) USING HASH,
KEY `idx_t_proc_ru_task_TASK_KEY` (`TASK_KEY`),
KEY `t_proc_ru_task_TASK_KEY_IDX` (`TASK_KEY`,`GMT_COMPLETE`) USING BTREE,
KEY `idx_t_proc_ru_task_gmt_complete` (`GMT_COMPLETE`),
KEY `idx_inst_gmt_all` (`PROC_INST_ID`,`GMT_CREATE`,`GMT_COMPLETE`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8 COMMENT='流程任务表'
1 row in set (0.00 sec)
在与业务方沟通并核对数据后,确认 T_PROC_RU_TASK 表中的 PROC_INST_ID 字段可以安全地修改为 bigint 类型。修改前,先检查数据以确保没有非数字字符:
SELECT * FROM T_PROC_RU_TASK WHERE PROC_INST_ID REGEXP '[^0-9]';
-- 结果集为0
-- 做好数据备份之后,开始调整类型
ALTER TABLE T_PROC_RU_TASK MODIFY COLUMN PROC_INST_ID bigint(20) DEFAULT NULL;
字段类型统一后,奇迹发生了。再次查看原始 SQL 的执行计划,T 表直接使用了基于 PROC_INST_ID 的索引 inst_id,预估扫描行数骤降至 4 行!
业务 SQL 本身无需任何调整,整个查询的性能从最初的数秒级别,瞬间提升到了毫秒级。
复盘与总结
这个案例给我们敲响了警钟:数据库表设计时,必须严格保持关联字段类型的一致性。一个小小的类型差异(bigint vs varchar),就足以让一个精心设计的复合索引失效,引发严重的性能灾难。
在问题排查初期,我们的思路被局限在了如何提升单个条件的过滤性能上,尝试了添加各种时间索引,虽然有所改善,但始终没有触及根本。直到回过头检查最基础的字段定义,才发现了这个隐蔽的“元凶”。
这个经历也提醒我们,在进行 SQL 性能优化 时,除了关注执行计划、索引覆盖、查询重写等高级技巧,也别忘了检查那些最基础、最容易被忽略的 schema 设计细节。很多时候,解决问题的钥匙就藏在最基础的地方。