一条订单查询突然从几十毫秒拖到几秒,我一般不会先怀疑 MyBatis,更不会急着加缓存。先把 SQL 拿出来跑一遍EXPLAIN。
SELECT id, order_no, pay_amount
FROM trade_order
WHERE order_status = 2
AND created_at >= '2026-08-01 00:00:00';
表上明明有索引:
KEY idx_shop_status_time (shop_id, order_status, created_at)
但执行计划里,这条索引要么没用,要么只做了大范围扫描。
问题就在shop_id没传。
这个索引不是三个独立索引拼在一起,它更像一本先按店铺编号排序,再按订单状态排序,最后按创建时间排序的目录。
索引结构可以简单理解成这样:
shop_id -> order_status -> created_at
查找时必须尽量从左边开始。
这就是 MySQL 联合索引的最左前缀匹配原则。
假设有一个联合索引:
KEY idx_user_scene_time (user_id, biz_scene, created_at)
下面这条 SQL 能比较完整地使用索引:
SELECT id, biz_no
FROM user_record
WHERE user_id = 10086
AND biz_scene = 'PAY'
AND created_at >= '2026-08-01 00:00:00';
MySQL 可以先定位user_id=10086,再从这部分数据里定位biz_scene='PAY',最后根据时间做范围扫描。
查找路径是连续的:
user_id -> biz_scene -> created_at
下面这条也能用:
SELECT id, biz_no
FROM user_record
WHERE user_id = 10086
AND biz_scene = 'PAY';
它使用了索引的前两列。
只查user_id也没问题:
SELECT id, biz_no
FROM user_record
WHERE user_id = 10086;
它使用了联合索引最左边的一列。
但下面这种写法,我第一眼就不太信这个索引能用得舒服:
SELECT id, biz_no
FROM user_record
WHERE biz_scene = 'PAY'
AND created_at >= '2026-08-01 00:00:00';
因为最左边的user_id被跳过去了。
联合索引的数据先按照user_id排序。不同用户下面,才继续按照biz_scene排序。现在连用户是谁都不知道,MySQL 很难直接定位某一段连续的PAY数据。
这里有个容易混淆的地方:最左匹配看的是索引列顺序,不是 SQL 条件的书写顺序。
这两条 SQL 对索引的使用通常没有区别:
WHERE user_id = 10086
AND biz_scene = 'PAY'
WHERE biz_scene = 'PAY'
AND user_id = 10086
优化器会重新分析条件。把user_id写在 SQL 最后,不等于违反最左匹配。
真正需要盯住的,是建索引时列的排列:
(user_id, biz_scene, created_at)
还有一个经常把人绕进去的地方:遇到范围查询后,后面的列往往不能继续用于缩小索引扫描区间。
比如:
SELECT id, biz_no
FROM user_record
WHERE user_id = 10086
AND created_at >= '2026-08-01 00:00:00'
AND biz_scene = 'PAY';
索引还是:
(user_id, created_at, biz_scene)
MySQL 可以根据user_id定位,再根据created_at做范围扫描。但进入时间范围以后,后面的biz_scene通常不能继续参与索引范围定位。
它可能通过索引下推过滤,和完全没作用不是一回事,但扫描范围已经没有进一步缩小。
所以业务里如果常见查询是“用户 + 场景 + 时间范围”,索引通常应该这样排:
KEY idx_user_scene_time (user_id, biz_scene, created_at)
而不是:
KEY idx_user_time_scene (user_id, created_at, biz_scene)
等值条件一般放前面,范围条件往后放。当然不能只背这一句,列的区分度、排序需求、真实查询比例都要看。
Java 项目里更容易踩坑的是动态 SQL。
比如一个查询接口允许前端不传shopId:
public record OrderQuery(
Long shopId,
Integer status,
LocalDateTime startTime
) {
}
代码直接按非空条件拼查询:
public List<OrderSummary> findOrders(OrderQuery query) {
StringBuilder sql = new StringBuilder("""
SELECT id, order_no, pay_amount
FROM trade_order
WHERE 1 = 1
""");
List<Object> args = new ArrayList<>();
if (query.shopId() != null) {
sql.append(" AND shop_id = ?");
args.add(query.shopId());
}
if (query.status() != null) {
sql.append(" AND order_status = ?");
args.add(query.status());
}
if (query.startTime() != null) {
sql.append(" AND created_at >= ?");
args.add(Timestamp.valueOf(query.startTime()));
}
sql.append(" ORDER BY created_at DESC LIMIT 200");
return jdbcTemplate.query(
sql.toString(),
orderSummaryMapper,
args.toArray()
);
}
代码没什么语法问题,麻烦在于查询组合变了。
当shopId为空,只剩下:
WHERE order_status = ?
AND created_at >= ?
原来的联合索引是:
(shop_id, order_status, created_at)
最左列断了。
这种情况不是改一下 Java 写法就能解决。要么业务上要求必须传shopId,要么为跨店铺查询单独设计索引,不能指望一个联合索引把所有查询组合都照顾到。
LIKE查询也遵循类似判断:
WHERE user_name LIKE 'dong%'
可以从确定的前缀开始定位,通常能够利用索引。
而下面这个:
WHERE user_name LIKE '%dong'
开头是不确定的,普通 B+Tree 索引很难直接找到扫描起点。
至于 MySQL 8 的索引跳跃扫描,确实可能在缺少最左列时利用联合索引,但它受数据分布、基数和成本估算影响。线上设计索引时,我不会把性能押在优化器“可能帮忙”上。
遇到联合索引问题,不要只看key字段里有没有索引名。我一般还会看:
EXPLAIN ANALYZE
SELECT ...
重点确认实际扫描了多少行、用了几个索引列、过滤发生在哪一层。
最左前缀不是一句“必须从左往右写 SQL”。它真正约束的是:联合索引只有左侧连续的列,才能稳定形成有效的查找路径。
中间断一列,后面的排序关系还在,但对当前查询未必有用了。