Java 接口慢:先把时间花在哪里说清楚

整理 Java 和 MySQL 笔记时,我发现自己很容易把“接口慢”和“该加索引了”连在一起。但一个请求里有业务处理、数据库访问,也可能有等待和外部调用,光知道接口耗时高,还不知道该改哪里。

这篇记一下我整理后的排查思路。下面的订单表和 SQL 是学习示例,没有连接数据库实测,也没有对应的线上优化数据。

先别急着加索引

1
2
3
4
5
6
7
一次请求的响应时间
= 等待执行 + 业务处理 + 数据库访问 + 外部调用 + 序列化与返回

先用同一请求标识对齐记录
→ 找耗时区间
→ 缩小到具体操作
→ 用执行计划或其他证据核验

如果 SQL 耗时明显,我会先看具体语句、参数和扫描情况。如果 SQL 单独执行很快,接口却慢,就得继续看连接获取、外部调用,或者有没有在循环里反复查数据库。

“平均耗时正常”也还不够。偶尔慢一次,可能正好发生在用户最需要结果的时候。我会把分位数、并发量和锁等待一起拿出来看,而不是只留一个平均值。

应用日志和 MySQL 慢查询日志要对照着用:前者看请求各段耗时,后者帮助找到数据库里的具体语句。慢查询日志的记录规则还取决于配置,查不到记录时也要先看配置。

拿一条查询往下看

假设要从订单表里找出某个用户最近创建的 20 条待处理订单:

1
2
3
4
5
SELECT id, status, created_at
FROM demo_order
WHERE user_id = 1001 AND status = 'PENDING'
ORDER BY created_at DESC
LIMIT 20;

先查看计划:

1
2
3
4
5
6
EXPLAIN
SELECT id, status, created_at
FROM demo_order
WHERE user_id = 1001 AND status = 'PENDING'
ORDER BY created_at DESC
LIMIT 20;
计划字段 我要看什么
type 当前访问方式是否与查询规模相称
possible_keys / key 候选索引与实际选择的索引
rows / filtered 估计扫描量与过滤情况,不把估计当成实测
Extra 排序、覆盖索引等补充信息,结合完整查询判断

我会把 (user_id, status, created_at) 列为一个复合索引候选:前两列用于等值筛选,后面是排序列。接下来还要看表里已经有什么索引、数据分布怎样、其他查询怎么用这张表,再决定是否创建。

1
2
3
-- 学习环境中的候选方案,创建前先检查已有索引。
CREATE INDEX idx_user_status_created
ON demo_order (user_id, status, created_at);

复合索引的列顺序,我更愿意结合实际查询和最左前缀来判断。有些笔记把顺序记成一套固定口诀,换个查询就未必适用。这里可以对照 MySQL 多列索引文档。

几句口诀,回头得补上条件

SELECT * 不一定让索引失效,但可能多返回了不需要的字段,也可能增加回表开销。对这条示例查询,我只取 id、status 和 created_at,至少能把需要什么写清楚。

OR、NOT IN 也不能一看到就判定“不走索引”。最后还是得看完整查询、数据分布和优化器给出的计划。

索引多了,写入和存储的成本也会增加。为一条查询加索引之前,我还会看看它在实际访问里占多大比例。

还有一个容易忽略的区别:MySQL 8.0.18 起支持的 EXPLAIN ANALYZE 会真正执行语句。需要实际执行信息时它很有用,但不能把它当成普通 EXPLAIN 随手运行,尤其是成本高的查询。官方说明里有两者的区别。

改完以后,怎么知道是它起了作用

要保持一致的条件 要比较的证据
相同数据、参数与并发设置 请求耗时分布、扫描量、执行计划
明确冷缓存或热缓存状态 避免把缓存预热当成索引收益
包含有代表性的参数 避免只测最容易命中的用户或时间范围
关注读取与写入 检查索引修改是否增加其他操作成本

再快的第二次查询,也可能只是缓存热起来了。做前后对比时,我会把数据、参数、并发和缓存状态一并记下来,再比较耗时和扫描量。

这些笔记以后还要拿到具体问题里检验。目前先提醒自己:遇到慢接口,第一步是找到时间花在哪儿,索引排在后面。

Buy me a coffee