0Pricing
PHP Academy · 课时

性能: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 反馈 — 无需本地设置。

此课程中的所有课时

  1. GraphQL 与 REST 对比
  2. 使用 graphql-php 构建架构
  3. 解析器、变更与订阅
  4. 性能:N+1 与 DataLoader
← 返回 PHP Academy