idx_user_id 明明建了,接口还是 2 秒多。
这种问题我一般不先改业务代码,也不先怪 MySQL。先把 SQL 拎出来,看执行计划。索引这东西,不是你建了它就一定用;用了,也不代表快。
线上常见的慢 SQL 大概长这样:
select id, order_no, user_id, pay_status, create_time
from t_order
where user_id = ?
and date(create_time) = ?
order by create_time desc
limit 20;
表上有索引:
create index idx_user_time on t_order(user_id, create_time);
第一眼看,好像挺对。user_id 有,create_time 也有。
但这个 SQL 我第一眼就不太信,因为它对 create_time 做了函数处理。
跑一下:
explain
select id, order_no, user_id, pay_status, create_time
from t_order
where user_id = 10086
and date(create_time) = '2026-05-14'
order by create_time desc
limit 20;
如果看到类似这种结果,就别再纠结“我明明加了索引”了。
type: ref
key: idx_user_time
rows: 43892
Extra: Using where; Using filesort
这里容易误判。
key 显示用了 idx_user_time,但 rows 还要扫几万行,Extra 里还有 Using filesort。这就说明索引只是帮你缩小了一点范围,并没有把查询变成真正舒服的查询。
我一般会把 SQL 改成范围查询:
select id, order_no, user_id, pay_status, create_time
from t_order
where user_id = ?
and create_time >= ?
and create_time < ?
order by create_time desc
limit 20;
Java 里不要偷懒拼 date(),时间边界在代码里算清楚:
public List queryUserOrders(long userId, LocalDate day) {
LocalDateTime start = day.atStartOfDay();
LocalDateTime end = day.plusDays(1).atStartOfDay();
String sql = """
select id, order_no, user_id, pay_status, create_time
from t_order
where user_id = ?
and create_time >= ?
and create_time < ?
order by create_time desc
limit 20
""";
return jdbcTemplate.query(
sql,
ps -> {
ps.setLong(1, userId);
ps.setTimestamp(2, Timestamp.valueOf(start));
ps.setTimestamp(3, Timestamp.valueOf(end));
},
(rs, i) -> new OrderRow(
rs.getLong("id"),
rs.getString("order_no"),
rs.getLong("user_id"),
rs.getInt("pay_status"),
rs.getTimestamp("create_time").toLocalDateTime()
)
);
}
再看执行计划,比较理想的是:
type: range
key: idx_user_time
rows: 126
Extra: Using index condition
这才像个正常索引该有的样子。
但索引无效,不只函数这一种。
还有一种特别隐蔽:字段类型不一致。
表字段是
varchar:
phone varchar(20)
代码里却这么写:
String sql = "select id, phone, name from t_customer where phone = ?";
jdbcTemplate.query(sql, ps -> ps.setLong(1, phoneNumber), mapper);
MySQL 可能会发生隐式类型转换。字段被转换了,索引就可能废掉。这里我一般要求:数据库字段是什么类型,Java 参数就按什么类型传。
ps.setString(1, phoneNumber);
别觉得这点小事没影响,线上很多慢 SQL 就是这种“不起眼”的地方拖出来的。
排查索引效果,我通常按这个顺序来,不绕。
先看慢日志,别凭感觉。
set global slow_query_log = on;
set global long_query_time = 0.5;
捞到 SQL 后,不要只看耗时,要看它到底扫了多少行:
Query_time: 2.318
Rows_examined: 386421
Rows_sent: 20
Rows_sent 才 20,Rows_examined 三十多万,这种 SQL 基本就是索引没吃好。
然后看执行计划。
重点盯这几个字段就够了:
type
key
rows
filtered
Extra
key 为空,说明没用索引。
rows 很大,说明即使用了索引,过滤效果也差。
Extra 里出现 Using filesort、Using temporary,就要看看排序、分组有没有和索引顺序配上。
还有一个坑,单列索引堆太多,不一定有用。
比如:
create index idx_user_id on t_order(user_id);
create index idx_status on t_order(pay_status);
create index idx_time on t_order(create_time);
SQL 是:
where user_id = ?
and pay_status = ?
order by create_time desc
这种场景我更愿意建联合索引:
create index idx_user_status_time
on t_order(user_id, pay_status, create_time);
不是所有字段都往索引里塞。顺序要按查询条件来。
等值条件放前面,范围和排序字段放后面。这个不是口诀,是因为 B+Tree 就按这个顺序组织数据。你顺序乱了,MySQL 也救不了你。
有时候索引也不是越命中越好。
比如一个状态字段:
pay_status tinyint
全表 90% 都是 pay_status = 1。你给它单独建索引,查询 pay_status = 1 大概率没什么意义。扫索引再回表,还不如直接扫表。
这时候可以查一下区分度:
select pay_status, count(*)
from t_order
group by pay_status;
区分度太低的字段,单独建索引通常很鸡肋。它适合放进联合索引里,配合用户、租户、时间这类字段一起用。
我在线上还会加一段很粗暴的 SQL 耗时日志,不复杂,但很好用:
public T watchSql(String tag, Supplier action) {
long begin = System.nanoTime();
try {
return action.get();
} finally {
long costMs = TimeUnit.NANOSECONDS.toMillis(System.nanoTime() - begin);
if (costMs > 300) {
log.warn("slow-sql-tag={}, costMs={}", tag, costMs);
}
}
}
调用时别写一堆虚的 tag,要能反查业务入口:
return watchSql("order.queryUserOrders.userId=" + userId,
() -> orderDao.queryUserOrders(userId, day));
日志、SQL、执行计划三件东西对上,问题基本就跑不掉。
最后还有个判断。
索引有没有效果,不看“有没有建”,也不只看“有没有用”。要看它有没有减少扫描行数,有没有避免排序,有没有减少回表,有没有让接口耗时真的下来。
索引不是护身符。
慢 SQL 也不是加个索引就完事。
它更像一张现场照片:字段类型、查询写法、数据分布、排序方式、分页方式,哪个地方别扭,执行计划都会露出来。看它就行。