Giới thiệu

Laravel Eloquent là ORM (Object Relational Mapping) mạnh mẽ, giúp các lập trình viên PHP thao tác với cơ sở dữ liệu một cách trực quan. Tuy nhiên, khi dự án phát triển và dữ liệu tăng lên, các truy vấn Eloquent thường gặp vấn đề về tốc độ, tiêu thụ tài nguyên và độ trễ. Bài viết này sẽ đi sâu vào các kỹ thuật tối ưu hiệu năng truy vấn trong Laravel, cung cấp các mẫu code thực tế, phân tích ưu nhược điểm và đưa ra các best practices dành cho các dự án quy mô vừa và lớn.

Hiểu rõ vấn đề hiệu năng trong Eloquent

Trước khi áp dụng bất kỳ biện pháp tối ưu nào, chúng ta cần nắm rõ các nguyên nhân thường gây ra chậm trễ:

  • N+1 query problem: Khi lặp qua một tập hợp mô hình và gọi quan hệ trong vòng lặp, Laravel sẽ thực hiện một truy vấn riêng cho mỗi bản ghi.
  • Truy vấn không chọn cột cần thiết: Eloquent mặc định trả về toàn bộ cột của bảng, gây lãng phí băng thông và bộ nhớ.
  • Không sử dụng chỉ mục (index) hợp lý: Các truy vấn WHERE, ORDER BY, JOIN không được hỗ trợ bởi chỉ mục sẽ dẫn tới full table scan.
  • Xử lý tập dữ liệu lớn trong một lần: Khi cần xử lý hàng nghìn bản ghi, việc tải toàn bộ vào bộ nhớ sẽ gây out‑of‑memory.

Những vấn đề trên thường xuất hiện trong các tính năng như danh sách sản phẩm, báo cáo, hoặc API trả về danh sách lớn. Dưới đây là các giải pháp cụ thể.

Kỹ thuật eager loading để tránh N+1 query

Eager loading cho phép Laravel tải trước các quan hệ liên quan trong một truy vấn duy nhất, giảm số lượng query xuống tối thiểu.

Ví dụ thực tế

Giả sử chúng ta có mô hình Post và quan hệ comments. Đoạn code sau sẽ gây N+1 query:

<?php
$posts = App\Models\Post::all();
foreach ($posts as $post) {
    foreach ($post->comments as $comment) {
        echo $comment->content . "\n";
    }
}
?>

Mỗi lần truy cập $post->comments sẽ thực hiện một truy vấn riêng. Để khắc phục, chúng ta sử dụng with():

<?php
$posts = App\Models\Post::with('comments')->get();
foreach ($posts as $post) {
    foreach ($post->comments as $comment) {
        echo $comment->content . "\n";
    }
}
?>

Kết quả: Laravel thực hiện chỉ 2 query – một cho posts và một cho comments – bất kể có bao nhiêu bài viết.

Multiple eager loads và nested eager loads

Trong các trường hợp phức tạp, chúng ta có thể eager load nhiều quan hệ cùng lúc, hoặc quan hệ lồng nhau:

<?php
$orders = App\Models\Order::with([
    'customer',
    'items.product',
    'payments' => function ($query) {
        $query->where('status', 'completed');
    }
])->where('created_at', '>=', now()->subDays(30))
      ->get();
?>

Ở đây, items.product là eager load quan hệ lồng nhau, còn payments được lọc thêm điều kiện.

Chọn cột cần thiết với select()pluck()

Việc trả về toàn bộ cột khi chỉ cần một vài trường sẽ làm tăng thời gian truyền dữ liệu và tiêu tốn bộ nhớ.

Sử dụng select()

<?php
$users = App\Models\User::select('id', 'name', 'email')
    ->where('active', true)
    ->get();
?>

Laravel sẽ tạo câu SQL chỉ lấy ba cột trên, giảm tải cho DB.

Sử dụng pluck() khi chỉ cần một cột

<?php
$emails = App\Models\User::where('subscribed', true)
    ->pluck('email');
?>

Kết quả là một Collection chứa danh sách email, không có các trường khác.

Chunking và cursor để xử lý dữ liệu lớn

Khi cần duyệt qua hàng triệu bản ghi, việc get() toàn bộ sẽ gây out‑of‑memory. Laravel cung cấp hai phương pháp: chunk()cursor().

Chunking

Chunk sẽ tải dữ liệu theo từng phần (batch) và gọi callback cho mỗi batch.

<?php
App\Models\Order::where('status', 'pending')
    ->chunk(500, function ($orders) {
        foreach ($orders as $order) {
            // Xử lý mỗi order
            $order->process();
        }
    });
?>

Ở đây, mỗi lần chỉ tải 500 bản ghi, giảm đáng kể mức sử dụng RAM.

Cursor

Cursor sử dụng generator, trả về một bản ghi mỗi lần, thích hợp cho các tác vụ streaming.

<?php
foreach (App\Models\Log::where('level', 'error')->cursor() as $log) {
    // Ghi log vào hệ thống giám sát
    $monitor->send($log);
}
?>

Cursor không giữ toàn bộ tập hợp trong bộ nhớ, nhưng mỗi bản ghi vẫn được truy vấn riêng lẻ. Khi cần tốc độ cao, chunk thường hiệu quả hơn.

Sử dụng raw query và DB::raw khi cần tối ưu đặc biệt

Mặc dù Eloquent rất tiện lợi, nhưng trong một số trường hợp, việc viết raw SQL sẽ nhanh hơn và cho phép tận dụng các tính năng đặc thù của DB.

Ví dụ: Tính tổng doanh thu theo ngày

<?php
$revenues = DB::table('orders')
    ->select(DB::raw('DATE(created_at) as day'), DB::raw('SUM(total) as revenue'))
    ->where('status', 'completed')
    ->groupBy('day')
    ->orderBy('day')
    ->get();
?>

Ở đây, DB::raw cho phép chúng ta viết các hàm tổng hợp và chuyển đổi ngày mà Eloquent không hỗ trợ trực tiếp.

Raw query với binding để tránh SQL injection

<?php
$users = DB::select('SELECT id, name FROM users WHERE created_at > ? AND active = ?', [now()->subMonth(), true]);
?>

Laravel tự động bind các giá trị, bảo vệ an toàn.

Indexing và phân tích query với EXPLAIN

Không chỉ tối ưu code, chúng ta cần tối ưu cấu trúc bảng. Đặt chỉ mục (index) trên các cột thường dùng trong WHERE, JOIN, ORDER BY sẽ giảm thời gian quét bảng.

Cách tạo index trong migration

<?php
Schema::table('orders', function (Blueprint $table) {
    $table->index('status');
    $table->index(['customer_id', 'created_at']);
});
?>

Sau khi tạo index, chúng ta có thể dùng EXPLAIN để kiểm tra kế hoạch thực thi:

<?php
$explain = DB::select('EXPLAIN SELECT * FROM orders WHERE status = ?', ['pending']);
?>

Kết quả sẽ cho biết liệu MySQL có sử dụng index hay không.

Cache query để giảm tải DB

Laravel hỗ trợ cache query thông qua phương thức remember() (trong Laravel 8 trở xuống) hoặc cache() trong query builder.

Cache kết quả truy vấn

<?php
$popularPosts = Cache::remember('popular_posts', now()->addMinutes(30), function () {
    return App\Models\Post::withCount('comments')
        ->orderBy('comments_count', 'desc')
        ->take(10)->get();
});
?>

Kết quả sẽ được lưu trong cache (Redis, Memcached) trong 30 phút, giảm số lần truy vấn tới DB.

Cache tag cho các nhóm dữ liệu

<?php
Cache::tags(['posts', 'comments'])
    ->remember('post_123', now()->addHours(1), function () {
        return App\Models\Post::with('comments')->find(123);
    });
?>

Khi một bình luận mới được thêm, chúng ta có thể xóa cache bằng Cache::tags('comments')->flush(); để giữ dữ liệu luôn đồng bộ.

Best practices tổng hợp

  • Luôn dùng eager loading cho các quan hệ được truy cập trong vòng lặp.
  • Chỉ lấy các cột cần thiết bằng select() hoặc pluck().
  • Phân trang hoặc chunk dữ liệu lớn thay vì get() toàn bộ.
  • Sử dụng raw query khi Eloquent không đáp ứng được yêu cầu hiệu năng.
  • Đặt index hợp lý và kiểm tra bằng EXPLAIN trước khi đưa vào production.
  • Cache các truy vấn tĩnh hoặc ít thay đổi để giảm tải DB.
  • Giám sát query log (DB::listen) để phát hiện các truy vấn chậm.

Kết luận

Việc tối ưu hiệu năng truy vấn trong Laravel Eloquent không chỉ dựa vào một kỹ thuật duy nhất mà là sự kết hợp hài hòa giữa thiết kế mô hình dữ liệu, lựa chọn API Eloquent phù hợp, và việc tận dụng các công cụ của DB như index và cache. Khi áp dụng các phương pháp trên một cách có hệ thống, bạn sẽ giảm đáng kể thời gian phản hồi, tối ưu tài nguyên server và nâng cao trải nghiệm người dùng.

Để nắm vững toàn bộ quy trình từ cơ bản đến nâng cao, Tham khảo khóa học "Lập trình web PHP & MySQL với Laravel Framework" tại đây.