Laravel 10.x无限级分类防N+1的核心是嵌套with预加载:必须在get()前用with(['children' => fn($q) => $q->with('children')]),配合parent_id索引、select包含外键、根节点parent_id为NULL。

在 Laravel 10.x 中实现无限级分类时,N+1 查询是最容易踩坑的性能雷区——不是“可能慢”,而是只要没处理好预加载,树形结构一展开就指数级恶化。核心解法不是回避递归,而是用对方式把关联数据一次性拿全。
必须用 with() 嵌套预加载,且写在 get() 之前
查整棵树最常见错误是写成:
- ❌ $categories = Category::all()->with('children'); —— all() 已执行,with() 完全无效
- ❌ $categories = Category::with('children')->get()->each(...); —— get() 后再操作集合,children 不会自动递归加载下一层
- ✅ 正确:Category::with(['children' => fn($q) => $q->with('children')])->get(); —— 显式两层嵌套,最多查 3 次 SQL(根、一级子、二级子)
- 若需更深(如三级菜单),可再加一层:
->with(['children' => fn($q) => $q->with(['children' => fn($qq) => $qq->with('children')])]),但建议控制在 3 层内,否则数据膨胀明显
慎用递归作用域,改用一次性关系映射
别在模型里写类似 scopeWithTree() 递归调用自身,它本质仍是懒加载拼接,无法规避 N+1。更稳妥的做法是:
- 先用
Category::select('id', 'parent_id')->get()一次拉全关系表,构建成$map = [$id => $parent_id]数组 - 用 PHP 递归函数从根节点出发,根据 map 向下找子节点 ID 列表,再批量查出对应记录
- 这样全程无数据库 I/O,适合后台管理页或导出场景;前端接口仍推荐用嵌套 with + 缓存
字段和索引一个都不能少
即使写了 with,以下两点没做,照样慢:
-
parent_id 字段必须加索引:迁移中补上
$table->index('parent_id');,否则 with 的 IN 查询会全表扫描 -
select 要包含外键:比如只查
->select('id', 'name')却漏了parent_id,Eloquent 就无法匹配父子关系,children 集合为空 - 根节点的
parent_id统一设为NULL(非 0),并在迁移中声明->nullable()->nullOnDelete(),避免约束冲突
需要动态深度时,用 load() 分批补救
如果树的层级不确定,或用户只点开某一分支才要加载子项,可分步处理:
- 先查平级列表:
$nodes = Category::whereNull('parent_id')->get(); - 点击某个节点后,按需加载其子树:
$node->load(['children' => fn($q) => $q->with('children')]); - 比全量 with 更省资源,适合前端折叠/展开交互,也避免事务中预加载的风险


















