我通过重新思考数据库查询,让 Laravel API 提速 83%

发布: (2025年12月15日 GMT+8 14:36)
6 min read
原文: Dev.to

Source: Dev.to

我是如何使用 JSON 聚合而非传统预加载来解决 N+1 查询问题的

上个月,我在调试一个慢速的管理员仪表盘。该页面一次性加载 500 条合作伙伴记录以及它们的个人资料、国家和促销码。每次页面加载耗时超过 2 秒。罪魁祸首?经典的 N+1 查询问题。

大家都知道的问题

即使使用 Laravel 的预加载,我仍然在每次请求中访问数据库 5 次:

$partners = Partner::with(['profile', 'country', 'promocodes'])->get();

这会生成:

SELECT * FROM partners;                         -- Query 1
SELECT * FROM profiles WHERE partner_id IN ...; -- Query 2
SELECT * FROM countries WHERE id IN ...;          -- Query 3
SELECT * FROM promocodes WHERE partner_id IN ...;-- Query 4

每个查询都会产生一次往返数据库的网络请求。以 50 条记录为例,就是 4 次网络往返,每次增加约 15‑20 ms 的延迟。

“啊哈!”时刻

我问自己:“能否在一次查询中把所有数据都加载出来?”
MySQL 的 JSON_OBJECTJSON_ARRAYAGG 函数让这成为可能:直接在 SQL 中把所有关联聚合为 JSON。

解决方案:JSON 聚合

我编写了一个 Laravel 包来实现这一功能:

$partners = Partner::aggregatedQuery()
    ->withJsonRelation('profile')
    ->withJsonRelation('country')
    ->withJsonCollection('promocodes')
    ->get();

它会生成 单条优化查询

SELECT 
    base.*,
    JSON_OBJECT('id', profile.id, 'name', profile.name) AS profile,
    JSON_OBJECT('id', country.id, 'name', country.name) AS country,
    (SELECT JSON_ARRAYAGG(JSON_OBJECT('id', id, 'code', code))
     FROM promocodes WHERE partner_id = base.id) AS promocodes
FROM partners base
LEFT JOIN profiles  profile  ON profile.partner_id = base.id
LEFT JOIN countries country ON country.id = base.country_id;

一次查询,全部数据。

结果

在包含 2,000 条合作伙伴、每条有 4 个关联的测试数据集上(取 50 条记录)进行基准测试:

方法时间内存查询数
标准 Eloquent27.44 ms2.06 MB4
JSON 聚合4.41 ms0.18 MB1

提升幅度:

  • 响应时间提升 83 %
  • 内存使用降低 91 %
  • 数据库查询减少 75 %

这不是打错字。提升 83 %。

为什么这么快?

1. 网络延迟(约占提升的 80 %)

数据库往返非常耗时。即使在本地,每次查询也要增加 5‑10 ms;在远程 DB 上则是 15‑20 ms。

之前: 4 次查询 × 15 ms = 60 ms 网络时间
之后: 1 次查询 × 15 ms = 15 ms 网络时间

2. 避免 Eloquent 反序列化(约占提升的 15 %)

返回普通数组可以跳过对象实例化、属性类型转换、关系绑定以及事件触发。

3. 优化的 SQL(约占提升的 5 %)

数据库在高度优化的 C 代码中完成聚合,而不是在 PHP 循环中处理。

实际影响

在每天处理 10,000 次 API 请求的仪表盘上:

  • 减少 40,000 次数据库查询
  • 累计节省 4 分钟的响应时间
  • 减少 19 GB 的内存使用
  • 更好的服务器资源利用率

工作原理

安装

composer require rgalstyan/laravel-aggregated-queries

配置

在模型中加入 trait:

use Rgalstyan\LaravelAggregatedQueries\HasAggregatedQueries;

class Partner extends Model
{
    use HasAggregatedQueries;

    public function profile()
    {
        return $this->hasOne(PartnerProfile::class);
    }

    public function promocodes()
    {
        return $this->hasMany(PartnerPromocode::class);
    }
}

使用

// 传统预加载
$partners = Partner::with(['profile', 'promocodes'])->get();

// 聚合查询
$partners = Partner::aggregatedQuery()
    ->withJsonRelation('profile', ['id', 'name', 'email'])
    ->withJsonCollection('promocodes', ['id', 'code', 'discount'])
    ->where('is_active', true)
    ->get();

输出

返回的结构是可预期的:

[
    'id'      => 1,
    'name'    => 'Partner A',
    'profile' => [
        'id'    => 10,
        'name'  => 'John',
        'email' => 'john@example.com',
    ],
    'promocodes' => [
        ['id' => 1, 'code' => 'SAVE10'],
        ['id' => 2, 'code' => 'SAVE20'],
    ],
];
  • 关联始终是 arraynull
  • 集合始终是 array(永不为 null)。

何时使用它?

✅ 适用场景

  • 带有多个关联的 API 接口
  • 复杂查询的管理员仪表盘
  • 每毫秒都重要的移动端后端
  • 读操作占比 90 % 以上的应用
  • 需要优化的高流量服务

⚠️ 不推荐使用的场景

  • 写操作(请使用标准 Eloquent)
  • 需要模型事件/观察者的情况
  • 深层嵌套关联(在 v1.1 中得到支持)

性能 vs. Eloquent 模型

该包提供两种模式:

// 数组模式(默认,最快 – 提升 83 %)
$partners = Partner::aggregatedQuery()->get();

// Eloquent 模式(仍有提升 – 大约提升 27 %)
$partners = Partner::aggregatedQuery()->get('eloquent');

数组模式跳过了 Eloquent 的反序列化开销。即使在 Eloquent 模式下,你仍然只会执行一次数据库查询,从而获得明显的提升。

权衡

失去的功能

  • 模型事件(createdupdateddeleted
  • 数组模式下的访问器 / 变形器
  • save()update()(只读)

获得的优势

  • 响应时间提升 83 %
  • 内存使用降低 91 %
  • 更简洁、更可预期的数据结构
  • 对读密集型工作负载的更好可扩展性

下一步计划

v1.1.0 正在开发中,包含:

  • 嵌套关联(profile.company.country
  • 带查询约束的条件加载
  • 关联别名
  • 增强的调试工具

试一试!

如果你在使用 Laravel 构建 API 或仪表盘,欢迎尝试这个包。期待你的反馈——在评论中分享你的结果或其他 N+1 解决方案。

P.S. 该包已在 Laravel News 上亮相!如果觉得有用,给个 GitHub star 将是莫大鼓励 ⭐

Back to Blog

相关文章

阅读更多 »