先查EXPLAIN确认是否全表扫描(type: ALL),再优化;未走索引需立即建索引,复合查询等值字段在前、范围字段在后;with()非万能,深层嵌套宜用join或hasManyThrough;select必须显式指定字段;软删除字段deleted_at应建(deleted_at, id)覆盖索引;大数据分页用cursorPaginate()替代paginate()。

慢查询在 Laravel 里很少是 ORM 写错了,绝大多数时候是 SQL 没走索引、字段拉多了、关联没压平,或者分页方式错了——先查 EXPLAIN,再动代码。
怎么确认是不是全表扫描(type: ALL)
别靠猜。在 Tinker 或临时路由里跑带参数绑定的 EXPLAIN:
DB::select('EXPLAIN SELECT * FROM posts WHERE status = ? AND created_at > ?', [1, '2024-06-01']);重点看 type 字段:ALL 就是全表扫描;ref 或 range 才算健康。注意:toSql() 不带参数值,拼进去看执行计划容易误判。
- 如果
type是ALL,立刻检查WHERE字段有没有索引 - 复合条件如
status = ? AND created_at > ?,必须建联合索引,且等值字段(status)放前面、范围字段(created_at)放后面 -
whereDate('created_at', ...)这类函数操作会让索引失效,改用whereBetween('created_at', [$start, $end])
with() 预加载为什么有时候更慢
with() 不是银弹。它默认用多条 SELECT ... WHERE id IN (...) 拼数据,一旦关联表没索引、主表数据多、或嵌套过深,就变成“把数据库压力转嫁给 PHP 内存和连接数”。
- 深层嵌套如
with(['user.posts.comments.author'])很可能触发笛卡尔积,行数爆炸 - 只查总数?用
withCount(),比with()轻量得多 - 要“用户 + 最新一条帖子”,别写
with(['posts' => fn($q) => $q->latest()->limit(1)])——这会为每个用户跑一次子查询 - 真正要压平数据,优先考虑
join()+ 手动映射,或hasManyThrough
select('*') 和不写 select() 一样危险
默认 Post::get() 或 Post::all() 等价于 SELECT *,哪怕你只显示标题和时间。宽表+大结果集下,几十 KB 的冗余字段白走网络、白占内存、白耗 ORM 解析。
- 永远显式写
select('id', 'title', 'user_id'),不依赖$fillable或模型定义 - 关联预加载也要约束字段:
with(['user' => fn($q) => $q->select('id', 'name')]) - API Resource 的
toArray()里删字段,只是序列化层补救;源头没控住,中间已浪费资源 - 软删除字段
deleted_at没索引?全表扫描风险极高,建议建(deleted_at, id)覆盖索引
offset 分页在大数据量下为什么越来越卡
paginate() 底层是 LIMIT 15 OFFSET 15000,偏移量越大,MySQL 越要扫过前 15000 行无用数据。十万级数据翻到第 100 页时,响应可能从 50ms 涨到 2s+。
- 换
cursorPaginate(),基于排序字段(如id或created_at)值跳跃定位,性能恒定 - 确保排序字段已建索引,且不能是随机值(如 UUID)——否则游标无效
- 前端必须原样传递上一页返回的
next_cursor,不可手动构造或篡改 - 超大数据导出场景,
chunkById()比chunk()更稳(避免因删记录导致漏/重),但别在事务里用
最常被跳过的一步:没验证优化是否真生效。本地开 DB::enableQueryLog() + dd(DB::getQueryLog()),或直接上 Laravel Telescope 查「Queries」面板——N+1、重复查询、慢于 100ms 的语句,一眼就能揪出来。别让优化停留在“我以为”。


















