الأداء: N+1 وDataLoader
جمّع عمليات حلّ الحقول وخزّنها مؤقتًا للحفاظ على السرعة
الأداء: N+1 وDataLoader درس مجاني في PHP Academy على CoddyKit. هذا هو الدرس 4 من أصل 4. يمكنك قراءة الدرس كاملاً أدناه مجاناً — ثم تمرن عليه مباشرة في المتصفح باستخدام محرر أكواد مدمج ومدرس ذكاء اصطناعي متاح 24/7. هذا الدرس جزء من مسار التعلم في PHP Academy، وتقدمك يتزامن عبر الويب وتطبيق CoddyKit. تتضمن دورة PHP Academy 4 دروس في المجموع.
القاتل الصامت: N+1
أكبر فخّ لأداء GraphQL هو مشكلة استعلام N+1. تبدو المشكلة صامتة لأن كل resolver يبدو بريئًا عند النظر إليه منفردًا — لكن عند تداخل قائمة مع حقل يُجلب لكل عنصر، فإنكم تنفّذون استعلامًا واحدًا للقائمة واستعلامًا واحدًا لكل عنصر. عند وجود 100 عنصرًا، يعني ذلك 101 رحلة ذهابًا وإيابًا. يوضّح هذا الدرس كيفية دمجها في عدد قليل من الاستعلامات المجمّعة باستخدام DataLoader.
رؤية N+1 بصورة ملموسة
تأمّلوا { posts { author { name } } }. ينفّذ resolver الخاص بـ posts استعلامًا واحدًا. ثم ينفّذ resolver الخاص بـ 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 الحقول مستوىً تلو الآخر. تعمل جميع resolvers الخاصة بـ 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; }
}
ربطه بـ resolver
يضع resolver الخاص بـ author المعرّف في قائمة الانتظار ويعيد كائن Deferred. يشغّل graphql-php جميع الكائنات المؤجّلة بعد المستوى الحالي، ولذلك تكون جميع معرّفات المؤلفين للقائمة بأكملها قد وُضعت في قائمة الانتظار عند تنفيذ الدالة المجهولة. ويؤدي استدعاء 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 الخاص بـ Facebook. تمرّرون إليها دالة تجميع تستقبل مصفوفة من المفاتيح، ويجب أن تعيد وعدًا بالقيم بالترتيب نفسه. وهي تتولى التخزين المؤقت، وإزالة التكرار، وحل الوعود.
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);
الحلّ من خلال المحمّل
في resolver، تستدعون ببساطة $loader->load($id)، الذي يعيد وعدًا. يجمع graphql-php، عبر المحوّل، هذه الوعود ويشغّل التجميع تلقائيًا في نهاية دورة التنفيذ. لا حاجة إلى التخزين المؤقت اليدوي.
<?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));
قياس المكسب
احرصوا دائمًا على قياس التحسّن كمّيًا. غلّفوا طبقة قاعدة البيانات لعدّ الاستعلامات في اختبار، ثم تحقّقوا من أن الإصدار المجمّع ينفّذ عددًا محدودًا من الاستعلامات مهما كان حجم القائمة. يحميكم ذلك من التراجعات التي تحدث عندما يضيف أحدهم resolver متداخلًا ساذجًا، فيعيد إدخال 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: استعلام واحد لكل عنصر.
- يحل graphql-php الحقول مستوىً تلو الآخر، ولذلك يتيح التأجيل تجميع جميع المفاتيح في استعلام
IN (...)واحد. - يُعدّ
GraphQL\Deferredالوحدة الأساسية؛ بينما تجمعoverblog/dataloader-phpبين التجميع والتخزين المؤقت لكل مفتاح وإزالة التكرار. - يجب أن تعيد دالة التجميع القيم بالترتيب نفسه لمفاتيح الإدخال.
- المحمّلات مخصّصة لكل طلب؛ أضيفوا حدودًا للعمق والتعقيد، واستخدموا التقسيم إلى صفحات كوسائل حماية إضافية.
الأسئلة الشائعة
هل درس «الأداء: N+1 وDataLoader» مجاني؟
نعم — نص درس «الأداء: N+1 وDataLoader» كامل متاح مجاناً هنا على الويب. لتمرينه بشكل تفاعلي (محرر أكواد مدمج ومدرس ذكاء اصطناعي متاح 24/7) وفتح باقي دورة PHP Academy، انتقل إلى CoddyKit PRO. تتضمن دورة PHP Academy 4 دروس في المجموع.
ماذا ستتعلم في «الأداء: N+1 وDataLoader»؟
جمّع عمليات حلّ الحقول وخزّنها مؤقتًا للحفاظ على السرعة تتمرن على PHP Academy مع أكواد عملية تشغلها مباشرة في المتصفح، ومدرس ذكاء اصطناعي متاح 24/7 يجيب على أسئلتك أثناء عملك.
هل أحتاج إلى خبرة سابقة لأبدأ PHP Academy؟
لا تُشترط خبرة سابقة. PHP Academy على CoddyKit منظم للمبتدئين حتى المتقدمين، لذا يمكنك البدء من هنا أو من البداية والتقدم بسرعتك الخاصة. هذا هو الدرس 4 من أصل 4.
كم من الوقت يستغرق درس «الأداء: N+1 وDataLoader»؟
معظم دروس CoddyKit تستغرق حوالي 5–10 دقائق. كل منها موجز وتفاعلي، لذا تحرز تقدماً مستمراً وتستأنف من حيث توقفت عبر الويب والتطبيق.
هل يمكنني كتابة وتشغيل أكواد في درس PHP Academy هذا؟
نعم. كل درس في PHP Academy يتضمن محرر أكواد مدمج، لذا تكتب وتشغل أكواداً حقيقية مباشرة في متصفحك وتحصل على تعليقات فورية من الذكاء الاصطناعي — بدون إعداد محلي.
جميع الدروس في هذه الدورة
- GraphQL مقابل REST
- بناء مخطط باستخدام graphql-php
- المحللات والطفرات والاشتراكات
- الأداء: N+1 وDataLoader