同一条 SQL,为什么在两个账号下相差 7 倍?
生产环境出现了一个有意思的问题。
用户在设备库中勾选“品牌 X”,快速取消,再重新勾选。页面最后仍显示品牌已选中,列表却变成了全部设备。
从现象看,这是典型的请求竞态:先发出的请求后返回,覆盖了后发请求的结果。但奇怪的是,同一台电脑、同一套代码,账号 A 可以稳定复现,账号 B 却表现正常。
最初我们怀疑两个账号的数据量不同。
实际查询后发现:
- A 有 6032 条设备;
- B 有 6318 条设备;
- 两个账号都恰好有 226 条品牌 X 的设备;
- 属性数量和重复数据也没有明显异常。
B 的数据甚至比 A 更多。
真正的区别,是数据进入系统的时间:
- A 的设备主要在 4–5 月导入;
- B 的设备主要在 8 月导入。
两个索引之间,MySQL 选错了路
设备列表的默认查询类似这样:
SELECT *
FROM product
WHERE user_id = ?
ORDER BY created_at DESC
LIMIT 20;product 表中存在两个相关的单列索引:
KEY idx_user_id (user_id)
KEY idx_created_at (created_at)面对这条 SQL,MySQL 有两个选择。
第一种是使用 user_id 索引,先找到当前账号的约6000条设备,再按照创建时间排序。
第二种是使用 created_at 索引,直接按照全平台设备的创建时间倒序扫描,再逐条判断设备是否属于当前账号。
MySQL 最终选择了第二种。
这个选择看起来很合理:既然只需要最新的20条数据,那么从 created_at 索引末尾开始读取,找到20条属于当前账号的记录后就可以停止,同时还能避免额外排序。
但这个判断忽略了一个关键问题:不同账号的数据在全局时间索引中的位置并不相同。
同一个执行计划,遇到不同数据分布
账号 B 的设备刚导入不久,距离全平台最新数据很近。
数据库从 created_at 索引末尾开始扫描,很快就能找到 B 的20条设备:
- 实际扫描约5.5万行;
- 查询耗时约0.13秒。
账号 A 的设备导入时间较早。
数据库同样从全平台最新的数据开始扫描,但需要先跳过其他账号后来导入的大量设备,才能找到 A 的数据:
- 实际扫描约37万行;
- 查询耗时约0.9秒。
这个索引没有失效,MySQL 也没有进行全表扫描。它确实使用了索引,只是选择了一条看似聪明、实际代价很高的访问路径。
这就是索引的负优化。
更值得注意的是,执行计划预估只需要扫描约2000行,实际却扫描了37万行。优化器严重低估了从全局时间索引中找到该账号20条数据的成本,因此选择了错误的索引。
查询时差放大了前端竞态
取消品牌 X 时,页面请求全部设备。对于账号 A,这个查询需要约0.9秒。
重新勾选品牌 X 时,查询范围缩小到该品牌的226条设备,耗时约0.05秒。
于是出现了这样的顺序:
- 全量请求先发出;
- 品牌请求后发出;
- 品牌请求先返回,页面显示品牌数据;
- 全量请求最后返回,覆盖了最新结果;
- 最终页面仍显示品牌已选中,列表却显示全部设备。
账号 B 的全量查询只需要约0.13秒,两个请求之间的时差较小,因此当前操作速度下不容易触发。
但这并不代表 B 永远不会出现问题。数据库不保证并发请求按照发送顺序完成,只是不同的数据分布让问题在 A 上更容易暴露。
前端竞态是页面错乱的直接原因,而错误的索引选择放大了两个请求之间的时间差。
两个单列索引,不等于一个联合索引
很多人会认为,既然已经有 user_id 和 created_at 两个索引,MySQL 就可以同时利用它们完成账号过滤和时间排序。
实际上,它通常只能选择一条主要访问路径:
- 走
user_id索引:先过滤账号,再排序; - 走
created_at索引:避免排序,再过滤账号。
即使出现 Index Merge,也不代表 MySQL 可以同时高效完成过滤、排序和 LIMIT。
这条查询真正需要的索引是:
KEY idx_user_id_created_at (user_id, created_at)这个联合索引会按照“账号 → 创建时间”组织数据。
数据库可以先定位当前账号,再直接从该账号最新的设备开始读取20条,不需要扫描其他账号的数据,也不需要额外排序。
两个单列索引解决的是两个独立问题:
(user_id)
(created_at)联合索引解决的是一条完整的查询路径:
(user_id, created_at)对于下面这种查询:
WHERE A = ?
ORDER BY B
LIMIT N两个单列索引 (A) 和 (B),通常无法替代联合索引 (A, B)。
不使用时间索引,反而快了几十倍
为了验证问题,我们强制查询使用 user_id 索引。
结果是:
- 账号 A:约0.9秒降到约0.02秒;
- 账号 B:约0.13秒降到约0.02秒。
虽然使用 user_id 索引后,需要对约6000条设备额外排序,但这个成本远低于从全局时间索引中扫描数十万条无关数据。
这说明排序本身不一定昂贵。为了避免排序而读取大量无关数据,反而可能是更差的选择。
数据库优化不能代替前端修复
增加 (user_id, created_at) 联合索引,可以让两个请求都快速返回,大幅降低乱序出现的概率。
但查询变快并不能保证请求顺序。
网络波动、数据库负载、连接池等待和缓存命中情况,都可能让后发请求先完成。因此前端仍然需要处理请求竞态。
常见方案有两种:
- 发起新的筛选请求时,取消上一次尚未完成的请求;
- 为每次请求分配递增序号,只允许最后一次请求更新页面。
第二种方式的核心逻辑是:
请求 1 发出
请求 2 发出
请求 2 返回并更新页面
请求 1 返回,但发现自己不是最新请求,因此丢弃结果这样无论数据库以什么顺序返回,旧数据都不会覆盖用户当前的筛选结果。
这次问题留下的提醒
排查 MySQL 性能问题时,不能只看“有没有索引”,也不能看到 EXPLAIN 中出现索引名,就认为查询已经得到优化。
还需要通过 EXPLAIN ANALYZE 关注:
- 实际扫描了多少行;
- 预估行数与实际行数是否严重偏离;
- 索引顺序是否符合完整查询路径;
- 是否为了避免排序而扫描了大量无关数据;
- 数据分布变化后,原来的执行计划是否仍然合理。
索引不是简单地给某个字段加速,而是在告诉数据库:数据应该按照什么路径被找到。
路径设计正确,数据库可以直接到达目标;路径设计错误,索引越积极地工作,查询反而可能越慢。
这次问题最终需要同时处理两层:
- 数据库增加
(user_id, created_at)联合索引,消除由数据时间分布造成的查询时差; - 前端只允许最后一次筛选请求更新页面,彻底避免旧请求覆盖新结果。