All posts
Hướng dẫn tối ưu Query Laravel bằng Database Indexing thực chiến
laravelmysql

Hướng dẫn tối ưu Query Laravel bằng Database Indexing thực chiến

Hao Nguyen K.'s avatarHao Nguyen K.
Table of Contents10 sections

Eloquent ORM của Laravel là một công cụ tuyệt vời. Nó giúp chúng ta viết code mượt mà, dễ đọc và triển khai tính năng cực nhanh. Thế nhưng, sự tiện lợi đó đôi khi khiến lập trình viên quên mất những gì đang thực sự diễn ra ở tầng Database phía dưới.

Khi hệ thống của bạn tăng trưởng, bảng orders hay users chạm mốc hàng triệu dòng, những câu lệnh Eloquent tưởng chừng như vô hại bỗng nhiên trở thành "sát thủ" ngốn sạch CPU, khiến ứng dụng phản hồi chậm trễ hoặc thậm chí sập nguồn (Timeout). Đó là lúc Eloquent đã bế tắc, và cứu tinh duy nhất của bạn chính là Database Indexing.

Trong bài viết này, chúng ta sẽ cùng tìm hiểu cách phát hiện các "truy vấn rùa bò" và cách cắm Index chuẩn xác ngay từ file Migration của Laravel.

1. Bản chất của vấn đề: Tại sao Eloquent lại chậm?

Khi bạn chạy một câu lệnh đơn giản:

PHP

User::where('email', 'admin@company.com')->first();

Nếu cột email không được đánh Index, Database engine (MySQL/PostgreSQL) sẽ phải lật tung từng dòng một từ đầu đến cuối bảng (gọi là Full Table Scan) để tìm ra bản ghi trùng khớp. Hãy tưởng tượng việc này giống như bạn phải lật từng trang của một cuốn từ điển 1000 trang để tìm một từ mà không có bảng mục lục.

Khi bạn thêm Index cho cột email, Database sẽ tạo ra một cấu trúc dữ liệu phụ (thường là B-Tree). Cấu trúc này hoạt động như một cuốn mục lục, giúp Database tìm ngay ra vị trí dòng dữ liệu chỉ sau vài phép toán, đưa thời gian truy vấn từ vài giây xuống còn vài mili-giây.

2. Cách phát hiện "Truy vấn rùa bò" (Slow Queries)

Trước khi cắm Index bừa bãi (việc này sẽ làm chậm thao tác INSERT, UPDATE), bạn cần biết chính xác câu query nào đang chạy chậm.

Cách 1: Sử dụng DB::listen để đo thời gian trong AppServiceProvider

Bạn có thể log lại các câu query chạy quá 500ms ngay trong file app/Providers/AppServiceProvider.php:

PHP

use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Log;

public function boot()
{
    DB::listen(function ($query) {
        if ($query->time > 500) { // Thi gian tính bằng mili-giây
            Log::warning("Slow query phát hiện: {$query->sql} [Thời gian: {$query->time}ms]");
        }
    });
}

Cách 2: Ép Eloquent giải trình bằng toRawSql()EXPLAIN

Từ Laravel 10+, bạn có thể xuất ra câu SQL thuần bằng toRawSql(). Hãy lấy câu SQL đó và ném vào công cụ quản lý DB (như TablePlus, Navicat) rồi thêm từ khóa EXPLAIN phía trước:

PHP

$query = Order::where('status', 'completed')->where('customer_id', 1500)->toRawSql();
dd($query); 
// Output: select * from `orders` where `status` = 'completed' and `customer_id` = 1500

Khi chạy EXPLAIN select * from ..., hãy chú ý vào cột type. Nếu nó hiển thị là ALL, chia buồn: Hệ thống của bạn đang bị Full Table Scan. Mục tiêu của chúng ta là biến nó thành ref hoặc const.

3. Các kỹ thuật cắm Index thực chiến từ Laravel Migration

Đừng vào trực tiếp Database để bấm nút tạo Index. Hãy luôn quản lý Index thông qua các file Migration để đảm bảo tất cả các thành viên trong đội ngũ và server Production đều đồng bộ một cấu trúc.

Trường hợp 1: Single Index (Index đơn cột)

Áp dụng cho các cột thường xuyên xuất hiện trong mệnh đề where, orderBy độc lập, hoặc các cột là Khóa ngoại (foreign key).

PHP

Schema::table('users', function (Blueprint $table) {
    $table->string('email')->index(); // Cách 1: Tạo ngay khi khai báo cột
    
    // Hoặc nếu bảng đã có sẵn cột, ta tạo một Migration mới:
    // $table->index('email'); 
});

Trường hợp 2: Composite Index (Index hỗn hợp nhiều cột) - Cực kỳ quan trọng

Giả sử bạn có câu query tìm kiếm đơn hàng theo cả 2 điều kiện:

PHP

Order::where('store_id', 5)->where('status', 'processing')->get();

Nếu bạn tạo 2 Index riêng lẻ cho store_idstatus, Database vẫn phải bóc tách dữ liệu hai lần rồi giao thoa lại. Thay vào đó, hãy tạo một Composite Index gom cả 2 cột này lại thành một thực thể duy nhất.

PHP

Schema::table('orders', function (Blueprint $table) {
    // Thứ tự đặt cột rất quan trọng! Cột nào có độ lọc cao hơn (ít trùng lặp hơn) nên đặt trước.
    $table->index(['store_id', 'status'], 'orders_store_status_index');
});

⚠️ Quy tắc ngón tay cái (Left-to-Right Rule): > Khi bạn tạo Index hỗn hợp ['store_id', 'status'], Index này sẽ dùng được cho các câu query:

  • where('store_id', ...)->where('status', ...)

  • where('store_id', ...)

  • Nhưng SẼ KHÔNG dùng được nếu bạn chỉ where('status', ...) mà không có store_id.

Trường hợp 3: Unique Index (Index độc nhất)

Dùng khi bạn muốn đảm bảo dữ liệu ở cột đó không được phép trùng lặp (ví dụ: Số điện thoại, Mã định danh, SKU sản phẩm). Bản thân thuộc tính unique() đã tự động tạo một Index hiệu năng cao cho cột đó.

PHP

Schema::table('products', function (Blueprint $table) {
    $table->string('sku')->unique();
});

4. Tác dụng phụ của Index: Đừng "nghện" cắm Index bừa bãi

Index giống như một liều thuốc bổ, dùng đúng liều thì khỏe, dùng quá liều thì "ngộ độc". Bạn cần lưu ý:

  • Làm chậm thao tác Ghi (INSERT, UPDATE, DELETE): Mỗi khi bạn thêm hoặc sửa một bản ghi, Database không chỉ ghi vào bảng chính mà còn phải tốn tài nguyên cập nhật lại cấu trúc cây mục lục (Index). Nếu bảng có quá nhiều Index, tốc độ lưu dữ liệu sẽ giảm rõ rệt.

  • Tốn dung lượng ổ cứng: Index bản chất là một file dữ liệu lưu trên đĩa cứng. Bảng càng nhiều Index, file DB càng phình to.

Lời kết

Tối ưu Query bằng Index là một trong những kỹ năng phân tách giữa một Junior và một Senior Developer. Trước khi nghĩ đến việc nâng cấp server đắt đỏ hay chuyển đổi kiến trúc phức tạp, hãy dành ra 15 phút rà soát lại các câu query cốt lõi của hệ thống và cắm Index đúng chỗ.