0Pricing
PHP Academy · บทเรียน

ประสิทธิภาพ: N+1 และ DataLoader

รวมกลุ่มและแคชการแก้ค่าฟิลด์เพื่อให้ทำงานได้รวดเร็ว

ประสิทธิภาพ: N+1 และ DataLoader เป็นบทเรียน PHP Academy ฟรีบน CoddyKit นี่คือบทเรียนที่ 4 จากทั้งหมด 4 บทเรียน คุณสามารถอ่านบทเรียนทั้งหมดด้านล่างฟรี — จากนั้นลองปฏิบัติด้วยตัวคุณเองในเบราว์เซอร์พร้อมตัวแก้ไขโค้ดในตัวและติวเตอร์ AI ตลอด 24/7 บทเรียนนี้เป็นส่วนหนึ่งของเส้นทางการเรียน 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 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);

แก้ค่าผ่านตัวโหลด

ในตัวแก้ไข คุณเพียงเรียก $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));

วัดผลลัพธ์ที่ได้

จงวัดการปรับปรุงเป็นตัวเลขเสมอ ห่อชั้นฐานข้อมูลเพื่อให้นับจำนวนคิวรีในการทดสอบ จากนั้นตรวจสอบว่ารุ่นที่รวมเป็นชุดส่งคิวรีไม่เกินจำนวนที่กำหนด ไม่ว่ารายการจะมีขนาดเท่าใด วิธีนี้ช่วยป้องกันการถดถอยเมื่อมีผู้เพิ่มตัวแก้ไขแบบซ้อนที่ทำงานตรงไปตรงมา และทำให้ปัญหา 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” ฟรีให้อ่านที่นี่บนเว็บ เพื่อปฏิบัติแบบโต้ตอบ (ตัวแก้ไขโค้ดในตัวและติวเตอร์ AI ตลอด 24/7) และปลดล็อคส่วนที่เหลือของคอร์ส PHP Academy ให้อัปเกรดเป็น CoddyKit PRO คอร์ส PHP Academy มีบทเรียนทั้งหมด 4 บทเรียน

คุณจะเรียนรู้อะไรในบทเรียน “ประสิทธิภาพ: N+1 และ DataLoader”

รวมกลุ่มและแคชการแก้ค่าฟิลด์เพื่อให้ทำงานได้รวดเร็ว คุณปฏิบัติ PHP Academy ด้วยโค้ดที่ใช้งานได้จริงที่คุณเรียกใช้โดยตรงในเบราว์เซอร์ และติวเตอร์ AI ตลอด 24/7 ตอบคำถามของคุณขณะที่คุณไปผ่านบทเรียน

คุณต้องมีประสบการณ์ก่อนที่จะเริ่มเรียน PHP Academy หรือไม่

ไม่จำเป็นต้องมีประสบการณ์มาก่อน PHP Academy บน CoddyKit ออกแบบมาสำหรับผู้เริ่มต้นไปจนถึงผู้เรียนขั้นสูง คุณสามารถเริ่มต้นที่นี่หรือเริ่มจากตัวแรกและเรียนด้วยความเร็วของคุณเอง นี่คือบทเรียนที่ 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