生产环境出现了一个有意思的问题。

用户在设备库中勾选“品牌 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秒。

于是出现了这样的顺序:

  1. 全量请求先发出;
  2. 品牌请求后发出;
  3. 品牌请求先返回,页面显示品牌数据;
  4. 全量请求最后返回,覆盖了最新结果;
  5. 最终页面仍显示品牌已选中,列表却显示全部设备。

账号 B 的全量查询只需要约0.13秒,两个请求之间的时差较小,因此当前操作速度下不容易触发。

但这并不代表 B 永远不会出现问题。数据库不保证并发请求按照发送顺序完成,只是不同的数据分布让问题在 A 上更容易暴露。

前端竞态是页面错乱的直接原因,而错误的索引选择放大了两个请求之间的时间差。

两个单列索引,不等于一个联合索引

很多人会认为,既然已经有 user_idcreated_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) 联合索引,消除由数据时间分布造成的查询时差;
  • 前端只允许最后一次筛选请求更新页面,彻底避免旧请求覆盖新结果。

标签: 数据库

添加新评论