我通过重新思考数据库查询,让 Laravel API 提速 83%
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_OBJECT 和 JSON_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 条记录)进行基准测试:
| 方法 | 时间 | 内存 | 查询数 |
|---|---|---|---|
| 标准 Eloquent | 27.44 ms | 2.06 MB | 4 |
| JSON 聚合 | 4.41 ms | 0.18 MB | 1 |
提升幅度:
- 响应时间提升 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'],
],
];
- 关联始终是
array或null。 - 集合始终是
array(永不为null)。
何时使用它?
✅ 适用场景
- 带有多个关联的 API 接口
- 复杂查询的管理员仪表盘
- 每毫秒都重要的移动端后端
- 读操作占比 90 % 以上的应用
- 需要优化的高流量服务
⚠️ 不推荐使用的场景
- 写操作(请使用标准 Eloquent)
- 需要模型事件/观察者的情况
- 深层嵌套关联(在 v1.1 中得到支持)
性能 vs. Eloquent 模型
该包提供两种模式:
// 数组模式(默认,最快 – 提升 83 %)
$partners = Partner::aggregatedQuery()->get();
// Eloquent 模式(仍有提升 – 大约提升 27 %)
$partners = Partner::aggregatedQuery()->get('eloquent');
数组模式跳过了 Eloquent 的反序列化开销。即使在 Eloquent 模式下,你仍然只会执行一次数据库查询,从而获得明显的提升。
权衡
失去的功能
- 模型事件(
created、updated、deleted) - 数组模式下的访问器 / 变形器
save()或update()(只读)
获得的优势
- 响应时间提升 83 %
- 内存使用降低 91 %
- 更简洁、更可预期的数据结构
- 对读密集型工作负载的更好可扩展性
下一步计划
v1.1.0 正在开发中,包含:
- 嵌套关联(
profile.company.country) - 带查询约束的条件加载
- 关联别名
- 增强的调试工具
试一试!
如果你在使用 Laravel 构建 API 或仪表盘,欢迎尝试这个包。期待你的反馈——在评论中分享你的结果或其他 N+1 解决方案。
P.S. 该包已在 Laravel News 上亮相!如果觉得有用,给个 GitHub star 将是莫大鼓励 ⭐