性能:N+1 与 DataLoader
批量处理并缓存字段解析,以保持高速度
性能:N+1 与 DataLoader 是 CoddyKit 上的免费 PHP Academy 课时。 这是第 4 节课,共 4 节。 你可以在下方免费阅读本课时的完整内容 — 然后在浏览器中使用内置代码编辑器和全天候 AI 导师进行实践。 这是 PHP Academy 学习路径的一部分,你的进度在网页和 CoddyKit 应用中同步。 PHP Academy 课程共包含 4 节课。
无声的杀手:N+1
GraphQL 最大的性能陷阱是N+1 查询问题。它之所以无声,是因为每个解析器单独看都很合理——但一旦嵌套列表和逐项字段,就会先为列表执行一次查询,再为每个项目执行一次查询。100 个项目就意味着 101 次往返。本课将展示如何使用 DataLoader 将这些查询合并为少量批量查询。
具体看 N+1
请考虑 { posts { author { name } } }。posts解析器执行一次查询。然后,对于每篇文章,author解析器都会执行自己的查询。下面的朴素代码清楚地展示了这种开销。
<?php
// 1 query for posts...
$posts = [['id'=>1,'author_id'=>7],['id'=>2,'author_id'=>7],['id'=>3,'author_id'=>9]];
$queries = 1;
foreach ($posts as $post) {
// ...then 1 query PER post to fetch its author
$queries++;
// SELECT * FROM users WHERE id = $post['author_id']
}
echo "Total DB queries: {$queries}\n"; // Total DB queries: 4
关键洞察:按层级批量处理
GraphQL 按层级解析。文章列表中的所有 author解析器都会在同一个执行周期中运行。如果我们可以延迟每次作者查找,收集所请求的标识符,然后只执行一次 WHERE id IN (...)查询,就能将 N 次查询变成一次。GraphQL\Deferred正是提供这种延迟机制的工具。
<?php
$ids = [7, 7, 9];
$unique = array_values(array_unique($ids));
// One query instead of three:
echo 'SELECT * FROM users WHERE id IN (' . implode(',', $unique) . ")\n";
// SELECT * FROM users WHERE id IN (7,9)
最小缓冲区/加载器
这就是 DataLoader 的核心:一个累积标识符的缓冲区,通过批处理函数一次性加载它们,并从缓存中提供结果。同一个键即使被请求两次,也只会加载一次——自动去重。
<?php
class UserLoader {
private array $queue = [];
private array $cache = [];
public function __construct(private \Closure $batchFn) {}
public function add(int $id): void { $this->queue[$id] = true; }
public function loadOnce(): void {
$missing = array_diff(array_keys($this->queue), array_keys($this->cache));
if ($missing) {
foreach (($this->batchFn)(array_values($missing)) as $id => $row) {
$this->cache[$id] = $row;
}
}
$this->queue = [];
}
public function get(int $id): mixed { return $this->cache[$id] ?? null; }
}
将其接入解析器
author解析器将标识符加入队列并返回一个 Deferred。PHP 版 GraphQL 实现会在当前层级之后运行所有延迟对象,因此闭包执行时,整个列表中的每个作者标识符都已经加入队列。一次 loadOnce()调用就会触发一次批量查询。
<?php
use GraphQL\Deferred;
$authorField = [
'type' => $userType,
'resolve' => function ($post, $args, $context) {
/** @var UserLoader $loader */
$loader = $context['userLoader'];
$loader->add($post['author_id']);
return new Deferred(function () use ($loader, $post) {
$loader->loadOnce(); // batches across all posts
return $loader->get($post['author_id']);
});
},
];
使用 overblog/dataloader 库
您很少需要手动实现这一机制。overblog/dataloader-php是 DataLoader 的成熟移植版本。您需要提供一个批处理函数,它接收一个键数组,并且必须返回一个与输入顺序相同的值承诺对象。它会处理缓存、去重和承诺对象的解析。
composer require overblog/dataloader-php构造 DataLoader
批处理函数的契约非常严格:给定 [k1, k2, k3],它必须按位置解析为 [v1, v2, v3]。请按键为数据库行建立索引,再按照输入顺序映射回来,这样缺失的键就会变成 null。
<?php
use Overblog\DataLoader\DataLoader;
use GraphQL\Executor\Promise\Adapter\SyncPromiseAdapter;
use Overblog\PromiseAdapter\Adapter\WebonyxGraphQLSyncPromiseAdapter;
$adapter = new WebonyxGraphQLSyncPromiseAdapter(new SyncPromiseAdapter());
$userLoader = new DataLoader(function (array $ids) use ($adapter, $db) {
$rows = $db->usersByIds($ids); // SELECT ... WHERE id IN (...)
$byId = [];
foreach ($rows as $r) { $byId[$r['id']] = $r; }
// MUST return values in the SAME ORDER as $ids
$ordered = array_map(fn($id) => $byId[$id] ?? null, $ids);
return $adapter->createFulfilled($ordered);
}, $adapter);
通过加载器解析
在解析器中,只需调用 $loader->load($id),它会返回一个承诺对象。PHP 版 GraphQL 实现(通过适配器)会收集这些请求,并在执行周期结束时自动触发批处理。不需要手动进行缓冲。
<?php
$authorField = [
'type' => $userType,
'resolve' => fn($post, $args, $context) =>
$context['userLoader']->load($post['author_id']),
];
每个请求的生命周期至关重要
DataLoaders 会按键缓存,因此必须为每个请求重新创建。如果在多个请求之间共享加载器,它就会提供过期数据并造成内存泄漏。请在组装请求 $context时构建加载器,并在请求结束时丢弃它们。
<?php
// Per request: brand new loaders, attached to context
function buildContext($db, $currentUser): array {
return [
'db' => $db,
'user' => $currentUser,
'userLoader' => makeUserLoader($db), // fresh, not a singleton
'postLoader' => makePostLoader($db),
];
}
其他性能防护措施
DataLoader 可以解决 N+1 读取问题,但恶意或粗心的查询仍然可能造成损害。请加入以下措施:
- 限制查询深度——
QueryDepth规则会拒绝病态嵌套的查询。 - 查询复杂度——
QueryComplexity为每个字段分配成本预算。 - 持久化查询——只允许操作的已知许可列表。
- 分页——绝不要解析无界列表;请使用游标连接。
<?php
use GraphQL\Validator\Rules\QueryDepth;
use GraphQL\Validator\Rules\QueryComplexity;
use GraphQL\Validator\DocumentValidator;
DocumentValidator::addRule(new QueryDepth(10));
DocumentValidator::addRule(new QueryComplexity(200));
衡量收益
请始终量化改进效果。在测试中包装数据库层以统计查询次数,然后断言批量版本无论列表大小如何,都只会发出不超过固定上限的查询。这可以防止回归:有人添加朴素的嵌套解析器后,N+1 问题又在无声中重新出现。
<?php
class CountingDb {
public int $queries = 0;
public function usersByIds(array $ids): array {
$this->queries++; // one batched call
return array_map(fn($id) => ['id' => $id], $ids);
}
}
$db = new CountingDb();
$db->usersByIds([7, 9, 11, 13]); // 4 authors
echo "Queries for 4 authors: {$db->queries}\n"; // Queries for 4 authors: 1
快速检查
为什么必须为每个请求创建 DataLoaders?
总结
您已经消除了 GraphQL 最严重的性能陷阱:
- 嵌套列表字段会导致 N+1:每个项目执行一次查询。
- PHP 版 GraphQL 实现按层级解析,因此延迟处理可以将所有键合并到一次
IN (...)查询中。 GraphQL\Deferred是基础机制;overblog/dataloader-php封装了批处理、按键缓存和去重。- 批处理函数必须按照输入键的相同顺序返回值。
- 加载器按请求创建;还应加入深度和复杂度限制,以及分页作为进一步的防护措施。
常见问题解答
「性能:N+1 与 DataLoader」课时是免费的吗?
是的 — 「性能:N+1 与 DataLoader」的完整文本可在网页上免费阅读。要进行交互式练习(内置代码编辑器和全天候 AI 导师)并解锁 PHP Academy 课程的其余内容,请升级到 CoddyKit PRO。 PHP Academy 课程共包含 4 节课。
「性能:N+1 与 DataLoader」这节课中我会学到什么?
批量处理并缓存字段解析,以保持高速度 你通过在浏览器中直接运行的动手代码来练习 PHP Academy,全天候 AI 导师会在你学习这节课的过程中回答你的问题。
学习 PHP Academy 需要有经验吗?
无需任何先前经验。CoddyKit 上的 PHP Academy 课程适合初学者到高级学习者,你可以从这里开始或从头开始,按照自己的节奏学习。 这是第 4 节课,共 4 节。
「性能:N+1 与 DataLoader」课时需要多长时间?
大多数 CoddyKit 课程大约需要 5–10 分钟。每节课都很精短且互动,所以你能稳步进步,并在网页和应用中从离开的地方继续。
我能在这节 PHP Academy 课中编写并运行代码吗?
能。每节 PHP Academy 课都包含内置代码编辑器,你可以在浏览器中直接编写并运行真实代码,并获得即时 AI 反馈 — 无需本地设置。
此课程中的所有课时
- GraphQL 与 REST 对比
- 使用 graphql-php 构建架构
- 解析器、变更与订阅
- 性能:N+1 与 DataLoader