MySQL 8中Laravel QueryBuilder与原生查询性能差异排查
核心原因:SQL结构差异导致MySQL 8优化器选择了低效索引
你遇到的问题本质是Laravel QueryBuilder生成的SQL语句结构,和手写原生SQL存在关键差异,使得MySQL 8的查询优化器放弃了高效的station_id复合索引,转而选择了扫描行数更多的主键索引。具体拆解如下:
1. 两种SQL的结构差异(基于现象推测)
假设你的高效原生SQL是这种利用子查询+覆盖索引的写法:
SELECT r.* FROM raw_records r INNER JOIN ( SELECT station_id, MAX(created_at) AS latest_time FROM raw_records WHERE station_id IN (/* 目标站点ID列表 */) GROUP BY station_id ) sub ON r.station_id = sub.station_id AND r.created_at = sub.latest_time;
这种写法的子查询仅需station_id和created_at字段,若你建了(station_id, created_at)复合索引,MySQL会直接用该索引完成分组与聚合(对应EXPLAIN里的Using index for group-by),无需回表扫描全量数据,因此效率稳定且极高。
而Laravel QueryBuilder默认生成的查询,大概率是直接在主查询中同时执行GROUP BY和ORDER BY的写法:
SELECT * FROM raw_records WHERE station_id IN (/* 目标站点ID列表 */) GROUP BY station_id ORDER BY created_at DESC;
或是因为使用了latest()等方法,让ORDER BY的优先级被优化器优先考虑。MySQL 8的优化器对这种写法的判断逻辑和5.7不同:它可能认为主键索引(聚簇索引)能更快完成排序(若created_at与主键id递增相关),但实际选择主键索引后需要扫描大量行,导致性能随传入ID数量增加急剧下降。
2. MySQL 8优化器的行为变化
MySQL 8.0对查询优化器做了大量调整,比如默认开启更严格的ONLY_FULL_GROUP_BY、新增直方图统计、更新索引选择逻辑等。在5.7中能正常走station_id索引的写法,到8.0中可能因为SQL结构的细微差异,被优化器判定为“走主键索引更优”,但实际结果完全相反。
比如当QueryBuilder生成的SQL包含SELECT *时,优化器可能认为用主键索引(包含所有字段)可以避免回表,但忽略了station_id复合索引作为覆盖索引在分组场景下的效率优势。
解决办法
- 对比SQL语句:先用
$query->toSql()输出QueryBuilder生成的SQL,和你的原生高效SQL逐行对比,找出结构差异(比如分组位置、是否使用子查询、排序字段顺序等)。 - 模仿原生SQL改写QueryBuilder:按照高效原生SQL的结构,用Laravel的子查询关联写法实现,示例代码:
$stationIds = [1, 2, ..., 100]; // 先获取每个站点的最新记录时间 $subQuery = DB::table('raw_records') ->select('station_id', DB::raw('MAX(created_at) as latest_time')) ->whereIn('station_id', $stationIds) ->groupBy('station_id'); // 关联查询获取完整的最新记录 $latestRecords = DB::table('raw_records') ->joinSub($subQuery, 'sub', function ($join) { $join->on('raw_records.station_id', '=', 'sub.station_id') ->on('raw_records.created_at', '=', 'sub.latest_time'); }) ->get();
- 确保索引最优:检查
station_id索引是否为(station_id, created_at)复合索引,这是实现Using index for group-by的关键,能让子查询完全在索引中完成,无需访问表数据。
内容的提问来源于stack exchange,提问作者Stefano

