Lập trình · 22/09/2026

Học Laravel 13 – Bài 12: Tránh N+1 với Eager Loading trong Laravel 13

Bài 12 — N+1 và Eager Loading: sau bài quan hệ Eloquent, chúng ta đo chi phí đọc tên project của từng task. Mục tiêu không phải thêm with() khắp nơi, mà là giữ nguyên kết quả với ít truy vấn hơn và không nạp dữ liệu không cần thiết.

Tránh N+1 với Eager Loading trong Laravel 13

Bài 12 — N+1 và Eager Loading: sau bài quan hệ Eloquent, chúng ta đo chi phí đọc tên project của từng task. Mục tiêu không phải thêm with() khắp nơi, mà là giữ nguyên kết quả với ít truy vấn hơn và không nạp dữ liệu không cần thiết.

1. Tái hiện N+1 bằng năm task

$names = Task::orderBy('id')->get()
    ->map(fn ($task) => $task->project->name);

Trong fixture của bài, mỗi task có project riêng. Một query lấy năm task, rồi năm lần truy cập project chưa được nạp tạo thêm năm query. Tổng là sáu. Code trông ngắn không có nghĩa ít round trip; cùng biểu thức này trong Blade hoặc Resource cũng có thể gây vấn đề tương tự.

Phép đo chỉ tính đoạn đọc sau khi tạo fixture, không tính migration hay factory. Nếu trộn cả bước chuẩn bị dữ liệu vào số query, kết luận sẽ không mô tả đúng đoạn code cần tối ưu. Không bật query log giữ mọi SQL trong RAM cho một worker production chạy dài.

2. Nạp quan hệ trước khi lặp

$names = Task::with('project')->orderBy('id')->get()
    ->map(fn ($task) => $task->project->name);

Với fixture này, eager loading tạo một query tasks và một query projects, tổng hai query. Test bên dưới còn so sánh output hai cách để tránh tối ưu bằng cách vô tình bỏ dữ liệu. Đây không phải cam kết mọi truy vấn có with() luôn chỉ chạy hai câu SQL: quan hệ lồng nhau, nhiều loại quan hệ, pagination và scope có thể làm số lượng khác.

3. Chỉ đếm thì không cần nạp toàn bộ collection

$project = Project::withCount('tasks')->findOrFail($projectId);
$count = $project->tasks_count;

Trang danh sách project chỉ cần tổng số task không nên hydrate hàng nghìn Task chỉ để count(). withCount cho thuộc tính đếm mà không đánh dấu collection tasks đã được nạp. Nếu cần số task todo riêng, dùng count có điều kiện và tên alias rõ ràng; không trình bày tổng số như số việc chưa hoàn tất.

4. Giữ đủ khóa khi chọn cột

$task = Task::select(['id', 'project_id', 'title'])
    ->with('project:id,name')->findOrFail($taskId);

project_id ở task và id ở project cần cho việc ghép quan hệ. Bỏ khóa chỉ vì UI không hiển thị nó có thể làm dữ liệu quan hệ không được gắn đúng. Giảm cột là tối ưu payload và hydration, không phải lý do để phá contract liên kết.

5. Bộ kiểm thử đo đúng phạm vi

Tạo tests/Feature/TaskFlowLoadingTest.php:

<?php

namespace Tests\Feature;

use App\Models\Project;
use App\Models\Task;
use Illuminate\Support\Facades\DB;
use Tests\TestCase;

class TaskFlowLoadingTest extends TestCase
{
    protected function setUp(): void
    {
        parent::setUp();
        config(['database.default' => 'sqlite', 'database.connections.sqlite.database' => ':memory:',
            'database.connections.sqlite.url' => null]);
        DB::purge('sqlite');
        $this->artisan('migrate', ['--force' => true])->assertSuccessful();
    }

    public function test_eager_loading_reduces_project_reads_without_changing_output(): void
    {
        Task::factory()->count(5)->create();
        DB::enableQueryLog();
        try {
            DB::flushQueryLog();
            $lazyNames = Task::orderBy('id')->get()->map(fn ($task) => $task->project->name)->all();
            $lazyCount = count(DB::getQueryLog());

            DB::flushQueryLog();
            $eagerNames = Task::with('project')->orderBy('id')->get()
                ->map(fn ($task) => $task->project->name)->all();
            $eagerCount = count(DB::getQueryLog());

            $this->assertSame($lazyNames, $eagerNames);
            $this->assertSame(6, $lazyCount);
            $this->assertSame(2, $eagerCount);
        } finally {
            DB::disableQueryLog();
            DB::flushQueryLog();
        }
    }

    public function test_with_count_does_not_hydrate_all_tasks(): void
    {
        $project = Project::factory()->create();
        Task::factory()->count(3)->for($project)->create();
        $loaded = Project::withCount('tasks')->findOrFail($project->id);
        $this->assertSame(3, $loaded->tasks_count);
        $this->assertFalse($loaded->relationLoaded('tasks'));
    }

    public function test_selected_columns_keep_keys_needed_for_eager_matching(): void
    {
        $task = Task::factory()->create();
        $loaded = Task::select(['id', 'project_id', 'title'])->with('project:id,name')->findOrFail($task->id);
        $this->assertSame($task->project_id, $loaded->project->id);
    }
}
php artisan test --filter=TaskFlowLoadingTest
php artisan test

Kết quả đã kiểm chứng: 5 task, lazy loading 6 query, eager loading 2 query, output giống nhau. Toàn suite pass 30 test, 100 assertions. Query log được xóa giữa hai phép đo và tắt trong finally để không ảnh hưởng phần sau. Phép đo là số truy vấn trên SQLite test, không phải benchmark thời gian production.

6. Những tối ưu dễ đi quá xa

  • Không eager load mọi quan hệ sâu cho mọi trang; một số query ít hơn nhưng trả hàng triệu record vẫn tốn bộ nhớ.
  • Giới hạn hoặc phân trang tập cha trước khi hiển thị; bài sau sẽ xây filter/sort/pagination.
  • Không gọi tasks() trong vòng lặp sau khi đã eager load tasks nếu ý định là dùng collection sẵn có; method có thể tạo query mới.
  • Nếu loop từ task sang project rồi lại sang owner, cần xem cả đường truy cập, không chỉ quan hệ đầu.
  • Đếm query không thay thế kiểm tra index, thời gian, dung lượng response và execution plan.

Có thể bật cảnh báo hoặc chặn lazy loading trong môi trường phát triển, nhưng phải kiểm tra các luồng đang dựa vào nó trước. Dù framework hỗ trợ thêm cơ chế tự động, vẫn cần phép đo trên cấu hình thật thay vì mặc định rằng N+1 đã biến mất. Đối chiếu tài liệu eager loading.

7. Bài tập

Đổi fixture từ năm lên hai mươi task và dự đoán số query trước khi chạy. Thêm trường tên owner vào output, đo lại rồi dùng quan hệ lồng nhau phù hợp. Cuối cùng tạo nhiều task cùng project và giải thích vì sao không nên giả định Eloquent luôn có identity map toàn cục để tự loại query trùng.

Điều hướng: Bài 11 · Lộ trình. Bài tiếp theo xây truy vấn filter, search, sort và pagination.

Điều hướng khóa học Laravel 13

Bài trước (11) · Bài sau (13) · Mục lục trọn bộ 37 bài

Thảo luận

Bình luận 0

Đăng nhập để bình luận

Bạn cần có tài khoản để tham gia thảo luận và trả lời độc giả khác.

Đăng nhậpĐăng ký

Chưa có bình luận. Hãy là người đầu tiên chia sẻ ý kiến.