Eager Loading 指定列减少内存

解读

国内高并发业务(电商秒杀、CMS 列表页、SaaS 报表)中,ORM 查询最容易被批的就是“N+1”。Laravel 用 with() 做预加载,但很多候选人只答“用 with 就行”,却忽略了 with() 默认 select * 会把整行数据拉进 PHP 内存;当关联表字段多、记录数大(如订单明细表 60 个字段、200 万行)时,一次请求就可能吃掉几百兆内存,FPM 进程被 OOM killer 干掉,接口 502。面试官问“怎么在 Eager Loading 里减少内存”,就是在考察:

  1. 是否知道 with() 可以约束列;
  2. 是否理解约束列的语法细节(主键、外键必须保留,否则会出现“null”关联或重复查询);
  3. 是否能在业务层评估字段必要性,把内存占用量化到 MB 级别;
  4. 是否了解 cursor()、chunk()、只读数组、序列化字段等配套手段,形成“内存-网络-CPU”三位一体的优化闭环。

知识点

  1. Laravel Eloquent 关联预加载原理:
    • 第一次查主表;
    • 收集外键集合;
    • 第二次用 whereIn 一次性查关联表;
    • 在内存中做 collect()->map() 匹配。
  2. with() 的“列约束”语法:
    with(['posts:id,user_id,title,created_at']) // 必须带 id(主键)与 user_id(外键)
    
  3. 内存估算公式:
    单行字节 ≈ 字段长度和 + 56 字节 Zend 数组开销;
    总内存 = 行数 × 单行字节 × 1.5(Zend 扩容系数)。
    例:60 个字段平均 200 字节 → 单行 256 字节;1 万行 ≈ 3.8 MB;若只取 4 个字段 40 字节 → 0.6 MB,降低 6 倍。
  4. 主键/外键缺失导致的隐性 N+1:
    如果省略 user_id,Laravel 无法把 posts 匹配到 users,会触发 fallback 查询,反而增加内存与 SQL 次数。
  5. 配套内存优化:
    • cursor() 替代 get(),使用生成器;
    • chunkById() 分段;
    • 只读数组:$casts = ['*' => 'float'] 关闭模型同步;
    • 关闭反序列化:$with = [],避免加载冗余 relation;
    • OPcache 保存模型结构,减少对象头。
  6. 国内主流框架版本差异:
    • Laravel ≥ 5.6 支持 with() 指定列;
    • ThinkPHP 6 使用 with('profile')->field('id,name') 语法不同,但原理一致;
    • Hyperf 协程态下内存常驻,更需严格控制列。
  7. 线上监控:
    在 Telescope、OneAPM、阿里云 ARMS 中可查看 Memory Peak;面试时可举例“把 230 MB 峰值压到 45 MB,QPS 提升 18%”。

答案

“在 Laravel 里,用 Eager Loading 指定列可以显著降低内存,核心口诀是‘必须带主键、必须带外键、只拿业务需要的字段’。具体分四步:
第一步,确定关联。假设要查用户及其最新 3 篇文章,关联名是 posts。
第二步,写 with 约束列,代码示例:

$users = User::query()
    ->select('id', 'name', 'avatar')          // 主表只拿 3 列
    ->with(['posts:id,user_id,title,summary']) // 关联表保留主键 id、外键 user_id,再拿业务字段 title、summary
    ->paginate(20);

第三步,验证 SQL:

  • 第一条:select id,name,avatar from users limit 20 offset 0;
  • 第二条:select id,user_id,title,summary from posts where user_id in (…20 个 id…);
    通过 explain 确认两条语句都用到索引,rows 扫描量下降。
    第四步,压测内存:
    本地 PHP_CLI 内存限制 128 M,ab -c 50 -n 1000 压测,开启 memory_get_peak_usage();
    对比 select * 峰值 230 MB → 指定列后 45 MB,QPS 从 420 提升到 495,FPM 进程数减少 30%。
    如果数据量更大,可再叠加 cursor() 分块:
User::cursor()->filter(...)->chunk(500, fn($cs) => $cs->load(['posts:id,user_id,title']));

这样既利用 Eager Loading 避免 N+1,又通过指定列把内存压到最低,线上 502 告警清零。”

拓展思考

  1. 字段黑名单机制:
    当表字段超过 100 个且经常变动时,可维护一个“敏感字段列表”(如 text 类型 content),用 Model::getHidden() 动态追加,避免每次改代码。
  2. 分布式场景:
    在微服务拆分后,用户服务只返回 open_api_users 视图(含 5 个字段),订单服务通过 RPC 批量拿用户,需要约定“最小字段契约”,防止网络包膨胀。
  3. 只读 DTO:
    用 PHP 8.1 readonly 类或 Spatie Data 包,把查询结果直接映射成不可变对象,关闭 ORM 模型同步,可再省 20% 内存。
  4. 协程常驻:
    Hyperf/Swoole 下内存常驻,Eager Loading 指定列后仍需手动 unset 大数组,否则 Worker 内存只增不减;可结合 MaxRequest=5000 定期重启。
  5. 面试反向提问:
    当面试官认可答案后,可以主动追问“贵司日均订单量多少?MySQL 网卡打满还是 CPU 打满?”体现性能调优闭环思维,加分。