0

Series Laravel Debugbar Thực chiến #2: Bắt bệnh N+1 Query và Tối ưu hóa Database

1. Hiện trường vụ án: Vấn đề N+1 Query là gì?

Giả sử bạn đang viết một API (hoặc trang Dashboard quản trị) để hiển thị danh sách tất cả các nhà ga (Stations), kèm theo danh sách các cổng quẹt vé (Devices/Turnstiles) đang hoạt động tại ga đó.

Một đoạn code rất tự nhiên và ngây thơ thường được viết như sau:

use App\Models\Station;

public function index()
{
    // Lấy danh sách 20 nhà ga
    $stations = Station::limit(20)->get();

    foreach ($stations as $station) {
        // Gọi relationship ->devices (Lazy Loading)
        echo "Ga " . $station->name . " có các thiết bị: ";
        foreach ($station->devices as $device) {
            echo $device->ip_address . ", ";
        }
    }
}

Nhìn logic code có vẻ rất ổn, nhưng khi bạn mở tab Queries của Debugbar lên, một khung cảnh kinh hoàng sẽ xuất hiện: Thanh công cụ báo đỏ rực với dòng chữ 21 statements were executed!

Tại sao lại là 21?

  • 1 câu query để lấy ra 20 nhà ga: SELECT * FROM stations LIMIT 20;
  • 20 câu query tiếp theo: Mỗi lần vòng lặp foreach chạy qua 1 nhà ga, Laravel lại ngầm bắn một câu lệnh SQL để lấy thiết bị của ga đó: SELECT * FROM devices WHERE station_id = 1; SELECT * FROM devices WHERE station_id = 2;

Đó chính là khái niệm N+1: 1 câu lấy danh sách gốc, và N câu lấy dữ liệu liên quan. Nếu hệ thống có 100 nhà ga, đoạn code này sẽ bắn 101 câu SQL. Database của bạn sẽ gục ngã vì bị cạn kiệt connection pool.

2. Thuốc giải: Eager Loading với with()

Cách giải quyết cực kỳ đơn giản. Chúng ta sẽ "báo trước" cho Laravel biết rằng chúng ta sẽ cần dùng đến dữ liệu devices, hãy lấy nó luôn đi. Thuật ngữ gọi là Eager Loading.

Sửa lại đoạn code query:

use App\Models\Station;

public function index()
{
    // Dùng hàm with() để load sẵn relationship
    $stations = Station::with('devices')->limit(20)->get();

    foreach ($stations as $station) {
        // Lúc này, $station->devices không sinh ra câu lệnh SQL nào nữa
        // vì nó đã được nạp sẵn vào bộ nhớ (RAM).
    }
}

Bây giờ, hãy F5 lại trình duyệt và nhìn vào tab Queries của Debugbar. Số lượng câu lệnh SQL đã giảm thẳng từ 21 xuống còn đúng 2 câu:

  1. SELECT * FROM stations LIMIT 20;
  2. SELECT * FROM devices WHERE station_id IN (1, 2, 3, 4, ..., 20);

Dù bạn có 20, 100 hay 1000 nhà ga, hệ thống cũng chỉ chạy đúng 2 câu lệnh SQL. Hiệu năng Database lúc này được cải thiện hàng chục lần!

3. Duplicated Queries (Truy vấn trùng lặp)

Tab Queries của Debugbar không chỉ đếm số lượng, mà còn soi ra những câu SQL bị trùng lặp y hệt nhau. Bạn sẽ thấy biểu tượng cảnh báo màu vàng kế bên những câu query bị gọi lại nhiều lần.

Một kịch bản thực tế: Trong hệ thống AFC, mỗi giao dịch vé bạn đều cần kiểm tra cấu hình hệ thống (ví dụ: lấy giá vé cơ bản từ bảng system_configs).

public function processTransaction($ticketData)
{
    // Lấy config
    $basePrice = SystemConfig::where('key', 'base_price')->first()->value;
    // ...
}

Nếu API này được gọi trong một vòng lặp xử lý lô (batch) 50 giao dịch offline được đẩy lên từ ga, nó sẽ bắn câu SELECT * FROM system_configs WHERE key = 'base_price' đúng 50 lần y hệt nhau.

Cách xử lý: Đẩy nó vào Cache hoặc Singleton.

use Illuminate\Support\Facades\Cache;

public function processTransaction($ticketData)
{
    // Truy vấn SQL lần đầu và nhớ vào RAM trong 1 giờ. 
    // Các lần sau gọi sẽ lấy từ RAM, Debugbar sẽ báo 0 câu SQL.
    $basePrice = Cache::remember('config_base_price', 3600, function () {
        return SystemConfig::where('key', 'base_price')->first()->value;
    });
}

4. Đào sâu với tính năng EXPLAIN

Nhớ lại cấu hình ở Bài 1, chúng ta đã bật 'explain' => ['enabled' => true].

Khi bạn click vào một câu lệnh SQL chạy chậm (mất nhiều milliseconds) trong Debugbar, nó sẽ bung ra một bảng phân tích EXPLAIN của MySQL. Bạn sẽ thấy ngay câu lệnh đó có đang quét toàn bộ bảng (Full Table Scan - Type: ALL) hay đang dùng đúng Index (Type: ref hoặc eq_ref). Nếu thấy ALL ở một bảng chứa triệu dòng log giao dịch thẻ, bạn biết ngay mình cần phải tạo Index cho cột đó trong Database.

Bằng việc tập thói quen mở tab Queries lên kiểm tra trước khi commit code, bạn sẽ triệt tiêu hoàn toàn những đoạn code "nặng mùi" đe dọa đến tải của Database.

Đến đây, bạn đã làm chủ việc tối ưu hóa tầng giao tiếp dữ liệu. Để kết thúc series ngắn về Debugbar này, bạn có muốn chúng ta tìm hiểu cách tự định nghĩa các Timeline và Message để đo đếm chính xác từng mili-giây thời gian chạy khi gọi API đồng bộ dữ liệu với các đối tác (như nhà thầu Hitachi) không?


All rights reserved

Viblo
Hãy đăng ký một tài khoản Viblo để nhận được nhiều bài viết thú vị hơn.
Đăng kí