Skip to content

[L3] ORM 链式查询如何逐步拼装 SQL?Active Record 与 Data Mapper 有何本质区别?Eager Loading 如何消除 N+1?

一句话结论

Builder 对象通过累积内部状态数组、最终由 Grammar 转译为 SQL;Eager Loading 用批量 WHERE IN 替换循环单条查询,两次查询完成关联加载。

体系讲解

一、Active Record vs Data Mapper

这两种模式是 ORM 领域的核心设计决策,选型影响测试策略和领域建模方式:

维度Active RecordData Mapper
代表实现Laravel Eloquent、Ruby on RailsDoctrine ORM、Hibernate
核心思想模型对象同时持有数据持久化逻辑模型是纯 PHP 对象(POPO),独立的 Mapper/Repository 负责持久化
调用方式$user->save()User::find(1)$em->persist($user); $em->flush()
单一职责违反(领域逻辑与 DB 操作耦合)遵守(模型无数据库依赖)
单元测试需要 mock 数据库或使用内存 SQLite可直接测试 POPO,无需数据库
适用场景快速开发、CRUD 为主的业务复杂领域逻辑、DDD 架构

二、链式调用如何逐步拼装 SQL

Eloquent QueryBuilder 使用经典的**流式接口(Fluent Interface)**模式:每个方法修改内部状态数组并返回 $this,不立即执行 SQL。

Builder 内部的状态结构(简化):

php
protected array $wheres   = [];   // WHERE 条件列表
protected array $orders   = [];   // ORDER BY 列表
protected array $columns  = ['*']; // SELECT 列
protected array $joins    = [];   // JOIN 子句
protected ?int  $limit    = null;
protected ?int  $offset   = null;

状态积累过程

php
User::query()           // 创建 Builder,绑定 Model
    ->where('active', 1)  // $wheres[] = ['type'=>'Basic', 'column'=>'active', 'value'=>1]
    ->where('age', '>', 18)  // $wheres[] = ...
    ->orderBy('name')        // $orders[] = ['column'=>'name', 'direction'=>'asc']
    ->limit(10)              // $limit = 10
    ->get();                 // 终止方法:触发 Grammar 编译 + 执行查询

Grammar 层的编译

终止方法(get()first()count() 等)触发 Grammar::compileSelect(),按固定顺序拼装 SQL 各子句:

SELECT → FROM → JOIN → WHERE → GROUP BY → HAVING → ORDER BY → LIMIT/OFFSET

Grammar 根据方言(MySqlGrammar、PostgresGrammar 等)的差异,在这一层处理语法兼容性,上层 Builder 无需感知数据库差异。

三、N+1 问题的根因与 Eager Loading 消除机制

N+1 产生

php
// 查询 posts:1 条 SQL
$posts = Post::all(); // SELECT * FROM posts → 假设返回 100 条

foreach ($posts as $post) {
    // 每次访问 ->author 触发懒加载:1 条 SQL × 100 次 = 100 条 SQL
    echo $post->author->name;
}
// 共 1 + 100 = 101 次查询

Eager Loading 消除机制(with('author')

分两步完成,共 2 次 SQL:

步骤1:SELECT * FROM posts
       → 返回结果,收集所有 user_id:[1, 3, 7, 3, 1, ...]

步骤2:SELECT * FROM users WHERE id IN (1, 3, 7)
       → 返回 3 个 User 对象,按 id 建立哈希表

内存关联:遍历 posts,将 post->user_id 映射到对应 User 对象

无论 $posts 有多少条,关联查询始终只有 1 次,从 O(N) 降为 O(1) 次查询。

关键实现细节

  • Eloquent 用 protected array $relations = [] 存储已加载的关联
  • with() 在终止方法执行后立即触发关联查询(eager),而不是等到访问属性时(lazy)
  • 嵌套关联 with('posts.comments') 会递归执行:先加载 posts,再对 posts 的 id 集合加载 comments

考察意图

考察候选人是否理解 ORM 的两个核心层(Builder/Grammar 职责分离)和两种设计模式的本质差异,而不只是会写 Eloquent API。N+1 部分考察候选人能否从"为什么产生"推导出"如何消除",而不是只背结论"用 with() 就行"。

追问链

Q1:Eager Loading 与 JOIN 查询有什么区别?各自适用场景是什么?

Eager Loading(默认 WHERE IN):两次独立查询,在 PHP 内存中关联,结果集干净,适合一对多/多对多关系;JOIN 查询:单次 SQL,但对一对多关系会造成主表记录重复(每条关联记录产生一行),需要 DISTINCT 或去重处理,适合一对一关系或需要跨表过滤时。Eloquent 提供 withJoin() 或手动 join() 支持 JOIN 模式。

Q2:如果 Eager Loading 的关联数据量极大(如 WHERE IN 有 10 万个 ID),如何优化?

10 万 ID 的 WHERE IN 会产生超长 SQL,可能触发 MySQL max_allowed_packet 限制,且 IN 列表过长会导致查询计划退化为全表扫描。优化方案:① 分批加载(Eloquent 的 lazyEagerLoad 或手动分块);② 改用 JOIN 消除 IN 列表;③ 重新审视数据模型,是否存在过宽的关联。

Q3:Builder 的 where() 方法如何实现条件的逻辑嵌套(AND/OR 分组)?

where() 接受闭包时,会创建一个新的 Builder 实例(子查询构建器),在闭包内积累的条件被收集后,以 (condition1 AND condition2) 的括号形式插入父 Builder 的 $wheres 数组,类型标记为 'Nested'。Grammar 编译时识别 'Nested' 类型,递归调用 compileWheres() 并用括号包裹,从而实现正确的逻辑分组。

易错点

  1. 误以为 with() 是 JOIN 查询:Eloquent 的 Eager Loading 默认使用分离的 SELECT + WHERE IN,不是 JOIN。两者在结果集结构和性能特征上有本质差异,混淆会导致在 WHERE 条件中误用 Eager Loading 的列名。

  2. 在循环内动态追加条件后仍依赖 Eager Loadingwith() 的关联条件在主查询执行前确定,循环内动态添加的关联条件(如 $post->comments()->where('approved', 1)->get())会绕过 Eager Loading 缓存,退化为 N+1。正确做法是在 with() 中传入约束闭包:with(['comments' => fn($q) => $q->where('approved', 1)])

代码示例

php
<?php
// 演示 QueryBuilder 状态积累 + Grammar 编译的核心思路(简化版)

class QueryBuilder
{
    private string $table   = '';
    private array  $wheres  = [];
    private array  $orders  = [];
    private ?int   $limit   = null;

    public function from(string $table): static
    {
        $this->table = $table;
        return $this;
    }

    public function where(string $column, string $operator, mixed $value): static
    {
        $this->wheres[] = compact('column', 'operator', 'value');
        return $this;
    }

    public function orderBy(string $column, string $direction = 'ASC'): static
    {
        $this->orders[] = compact('column', 'direction');
        return $this;
    }

    public function limit(int $n): static
    {
        $this->limit = $n;
        return $this;
    }

    /** 终止方法:将内部状态编译为 SQL(Grammar 职责) */
    public function toSql(): string
    {
        $sql = "SELECT * FROM `{$this->table}`";

        if ($this->wheres !== []) {
            $conditions = array_map(
                fn($w) => "`{$w['column']}` {$w['operator']} ?",
                $this->wheres
            );
            $sql .= ' WHERE ' . implode(' AND ', $conditions);
        }

        if ($this->orders !== []) {
            $parts = array_map(
                fn($o) => "`{$o['column']}` {$o['direction']}",
                $this->orders
            );
            $sql .= ' ORDER BY ' . implode(', ', $parts);
        }

        if ($this->limit !== null) {
            $sql .= " LIMIT {$this->limit}";
        }

        return $sql;
    }
}

// 链式调用逐步积累状态,toSql() 触发编译
$sql = (new QueryBuilder())
    ->from('users')
    ->where('active', '=', 1)
    ->where('age', '>', 18)
    ->orderBy('name')
    ->limit(10)
    ->toSql();

echo $sql;
// SELECT * FROM `users` WHERE `active` = ? AND `age` > ? ORDER BY `name` ASC LIMIT 10

// --- Eager Loading N+1 消除示意 ---
// 问题写法(N+1):
// $posts = Post::all();  // 1 次 SQL
// foreach ($posts as $post) { echo $post->author->name; }  // N 次 SQL

// 正确写法(Eager Loading,仅 2 次 SQL):
// $posts = Post::with('author')->get();
// 等价于:
//   SQL 1: SELECT * FROM posts
//   SQL 2: SELECT * FROM users WHERE id IN (收集到的所有 user_id)
// 然后在内存中将 User 对象按 user_id 分配给对应的 Post

基于 Apache License 2.0 开源