Eloquent with() – Tránh N+1 Query trong Laravel

  • Home
  • Eloquent with() – Tránh N+1 Query trong Laravel

Khi làm việc với Laravel Eloquent, việc lấy dữ liệu từ các bảng có quan hệ với nhau rất đơn giản. Tuy nhiên, nếu không chú ý, ứng dụng có thể gặp phải một vấn đề phổ biến gọi là N+1 Query.

N+1 Query khiến ứng dụng thực hiện hàng chục, hàng trăm hoặc thậm chí hàng nghìn câu SQL không cần thiết. Điều này đặc biệt dễ xảy ra khi bạn sử dụng relationship bên trong vòng lặp.

Trong Laravel, một trong những cách đơn giản và hiệu quả nhất để giải quyết vấn đề này là sử dụng Eager Loading với with().


1. N+1 Query là gì?

Giả sử chúng ta có hai model:

  • Post: bài viết
  • User: người dùng tạo bài viết

Mỗi bài viết thuộc về một người dùng:

class Post extends Model
{
    public function user()
    {
        return $this->belongsTo(User::class);
    }
}

Bây giờ chúng ta lấy danh sách bài viết:

$posts = Post::all();

Sau đó hiển thị tên tác giả:

foreach ($posts as $post) {
    echo $post->user->name;
}

Thoạt nhìn đoạn code này hoàn toàn bình thường.

Nhưng phía sau Laravel có thể thực hiện:

SELECT * FROM posts;

Sau đó với mỗi bài viết lại thực hiện thêm một query:

SELECT * FROM users WHERE id = 1;
SELECT * FROM users WHERE id = 2;
SELECT * FROM users WHERE id = 3;
...

Nếu có 100 bài viết, ứng dụng có thể thực hiện:

1 query lấy posts + 100 query lấy users = 101 query.

Đây chính là N+1 Query.


2. Tại sao N+1 Query nguy hiểm?

N+1 Query không phải lúc nào cũng khiến ứng dụng lỗi ngay lập tức.

Vấn đề là nó làm tăng số lượng truy vấn SQL một cách âm thầm.

Ví dụ:

Số bài viết Query có thể thực hiện
10 11
100 101
1.000 1.001
10.000 10.001

Với vài chục bản ghi, bạn có thể không nhận ra vấn đề.

Nhưng khi database có hàng nghìn bản ghi, thời gian xử lý có thể tăng đáng kể.

Đặc biệt nguy hiểm với:

  • Trang danh sách sản phẩm
  • Trang quản trị
  • API
  • Dashboard
  • Báo cáo
  • Hệ thống có nhiều người dùng đồng thời

Một đoạn code nhìn rất “đẹp” có thể đang tạo ra hàng nghìn query phía sau.


3. with() trong Eloquent là gì?

with() được Laravel cung cấp để thực hiện Eager Loading.

Thay vì để Laravel lấy relationship từng bản ghi một, chúng ta yêu cầu Laravel lấy relationship ngay từ đầu.

Thay vì:

$posts = Post::all();

hãy sử dụng:

$posts = Post::with('user')->get();

Sau đó vẫn có thể sử dụng:

foreach ($posts as $post) {
    echo $post->user->name;
}

Nhưng số lượng query sẽ giảm đáng kể.

Laravel thường thực hiện dạng:

SELECT * FROM posts;

SELECT * FROM users
WHERE id IN (1, 2, 3, 4, 5);

Thay vì:

SELECT * FROM posts;

SELECT * FROM users WHERE id = 1;
SELECT * FROM users WHERE id = 2;
SELECT * FROM users WHERE id = 3;
SELECT * FROM users WHERE id = 4;
SELECT * FROM users WHERE id = 5;

Từ hàng chục hoặc hàng trăm query, chúng ta chỉ còn khoảng 2 query.


4. Lazy Loading vs Eager Loading

Để hiểu with(), cần phân biệt hai cách loading relationship trong Eloquent.

Lazy Loading

Ví dụ:

$posts = Post::all();

foreach ($posts as $post) {
    echo $post->user->name;
}

Relationship user chỉ được truy vấn khi bạn thực sự gọi:

$post->user

Đây gọi là Lazy Loading.

Nó tiện lợi nhưng rất dễ tạo ra N+1 Query.


Eager Loading

Với:

$posts = Post::with('user')->get();

Laravel biết ngay từ đầu rằng chúng ta cần relationship user.

Laravel sẽ tải dữ liệu relationship cùng lúc.

Đây gọi là Eager Loading.

Ghi nhớ đơn giản

Post::all();

Có thể dẫn đến:

1 query + N queries

Trong khi:

Post::with('user')->get();

thường chỉ cần:

2 queries

5. Eager Loading nhiều relationship

with() không chỉ dùng được với một relationship.

Ví dụ Post có:

  • user
  • comments
  • category

Chúng ta có thể viết:

$posts = Post::with([
    'user',
    'comments',
    'category'
])->get();

Laravel sẽ eager load các relationship cần thiết.

Sau đó:

foreach ($posts as $post) {
    echo $post->title;
    echo $post->user->name;
    echo $post->category->name;

    foreach ($post->comments as $comment) {
        echo $comment->content;
    }
}

Cách này đặc biệt hữu ích khi xây dựng API hoặc trang danh sách dữ liệu.


6. Eager Loading relationship lồng nhau

Eloquent cũng hỗ trợ eager loading các relationship nhiều cấp.

Ví dụ:

Post
 └── User
      └── Profile

Bạn có thể viết:

$posts = Post::with('user.profile')->get();

Sau đó:

foreach ($posts as $post) {
    echo $post->user->profile->avatar;
}

Laravel sẽ chủ động tải userprofile thay vì đợi đến khi từng object được truy cập.


7. Chỉ lấy những cột cần thiết

Không phải lúc nào chúng ta cũng cần lấy toàn bộ dữ liệu của relationship.

Ví dụ:

$posts = Post::with('user:id,name')->get();

Ở đây chúng ta chỉ lấy:

  • id
  • name

của users.

Điều này giúp giảm lượng dữ liệu được lấy từ database.

Một lưu ý quan trọng: khi sử dụng cú pháp này, cần đảm bảo có khóa cần thiết để Eloquent ghép relationship, thường là id của model liên quan.


8. with() có thể kết hợp với điều kiện

Bạn cũng có thể tùy chỉnh query của relationship.

Ví dụ:

$posts = Post::with([
    'comments' => function ($query) {
        $query->where('approved', true);
    }
])->get();

Khi đó Laravel chỉ eager load những comment đã được duyệt.

Với Laravel hiện đại, có thể viết ngắn gọn hơn:

$posts = Post::with([
    'comments' => fn ($query) =>
        $query->where('approved', true)
])->get();

Điều này rất hữu ích khi relationship có nhiều dữ liệu nhưng bạn chỉ cần một phần.


9. load() – Eager Loading sau khi đã lấy dữ liệu

Đôi khi bạn đã lấy model rồi mới nhận ra mình cần relationship.

Ví dụ:

$posts = Post::all();

Sau đó:

$posts->load('user');

load() sẽ eager load relationship cho collection đã tồn tại.

Ví dụ:

$posts = Post::where('published', true)->get();

$posts->load('user');

Điều này khác với:

Post::with('user')->get();

with() được sử dụng trước khi query chính được thực thi.

Còn load() được sử dụng sau khi model hoặc collection đã được lấy về.


10. loadMissing() – Chỉ load khi chưa có

Một trường hợp khác là:

$posts->loadMissing('user');

Laravel chỉ load relationship nếu relationship đó chưa được load trước đó.

Điều này hữu ích khi code của bạn được chia thành nhiều lớp hoặc nhiều service khác nhau.


11. Eager Loading mặc định

Nếu một relationship gần như lúc nào cũng cần sử dụng, bạn có thể khai báo $with trong model.

Ví dụ:

class Post extends Model
{
    protected $with = ['user'];

    public function user()
    {
        return $this->belongsTo(User::class);
    }
}

Khi đó:

$posts = Post::all();

Laravel sẽ tự động eager load user.

Bạn không cần viết:

Post::with('user')->get();

mỗi lần.

Tuy nhiên, không nên lạm dụng $with.

Nếu relationship lớn hoặc không phải lúc nào cũng cần, eager loading mặc định có thể khiến những query không cần thiết được thực hiện.


12. Cách phát hiện N+1 Query

Một cách đơn giản để kiểm tra là sử dụng Laravel Debugbar hoặc các công cụ theo dõi query.

Ngoài ra, trong môi trường phát triển, Laravel có thể giúp phát hiện Lazy Loading thông qua:

Model::preventLazyLoading();

Ví dụ đặt trong AppServiceProvider:

use Illuminate\Database\Eloquent\Model;

public function boot()
{
    Model::preventLazyLoading();
}

Khi code vô tình truy cập relationship chưa được eager load, Laravel có thể cảnh báo hoặc phát hiện vấn đề thay vì để N+1 Query âm thầm xảy ra.

Đây là một thói quen rất tốt khi phát triển ứng dụng Laravel.


13. Một ví dụ thực tế

Giả sử chúng ta xây dựng API trả về danh sách sản phẩm.

Model:

class Product extends Model
{
    public function category()
    {
        return $this->belongsTo(Category::class);
    }
}

Code ban đầu:

$products = Product::paginate(20);

return response()->json($products);

Nếu sau đó Resource hoặc code xử lý gọi:

$product->category->name

thì có thể phát sinh N+1 Query.

Cách tốt hơn:

$products = Product::with('category')
    ->paginate(20);

return response()->json($products);

Bây giờ relationship category đã được eager load.


14. Sai lầm thường gặp

Một lỗi phổ biến là nghĩ rằng sử dụng Eloquent tự động có nghĩa là code đã tối ưu.

Ví dụ:

$users = User::all();

foreach ($users as $user) {
    echo $user->posts->count();
}

Nếu posts chưa được eager load, đoạn code này có thể tạo ra N+1 Query.

Nên viết:

$users = User::with('posts')->get();

foreach ($users as $user) {
    echo $user->posts->count();
}

Tuy nhiên, nếu mục tiêu chỉ là đếm số lượng bài viết, còn có cách hiệu quả hơn:

$users = User::withCount('posts')->get();

foreach ($users as $user) {
    echo $user->posts_count;
}

Trong trường hợp này, chúng ta không cần tải toàn bộ posts, chỉ cần số lượng.

Đây là một nguyên tắc quan trọng:

Đừng tải dữ liệu mà bạn không thực sự sử dụng.


15. with() không phải lúc nào cũng tốt hơn

Eager Loading giúp tránh N+1 Query, nhưng không có nghĩa là cứ with() càng nhiều càng tốt.

Ví dụ:

Post::with([
    'user',
    'comments',
    'comments.user',
    'category',
    'tags',
    'images'
])->get();

Nếu trang chỉ hiển thị:

Tiêu đề bài viết
Tên tác giả

thì việc eager load tất cả relationship trên là lãng phí.

Hãy chỉ load những gì thực sự cần:

Post::with('user')->get();

Tối ưu database không phải là giảm query bằng mọi giá.

Mục tiêu là:

Ít query không cần thiết + lượng dữ liệu hợp lý + query có hiệu năng tốt.


16. Checklist tránh N+1 Query trong Laravel

Khi viết code Eloquent, hãy kiểm tra:

  • Có truy cập relationship bên trong foreach không?
  • Relationship đó đã được with() chưa?
  • Có thể dùng withCount() thay vì tải toàn bộ relationship không?
  • Có thể chỉ lấy một số column cần thiết không?
  • Có relationship lồng nhau cần eager load không?
  • Có đang eager load quá nhiều dữ liệu không?
  • Có nên bật preventLazyLoading() trong môi trường development không?

Một quy tắc rất dễ nhớ:

// Dễ tạo N+1
$posts = Post::all();

foreach ($posts as $post) {
    echo $post->user->name;
}

Thay bằng:

// Eager Loading
$posts = Post::with('user')->get();

foreach ($posts as $post) {
    echo $post->user->name;
}

Kết luận

N+1 Query là một trong những vấn đề hiệu năng phổ biến khi làm việc với Laravel Eloquent.

Nguyên nhân thường đến từ việc truy cập relationship bên trong vòng lặp mà không eager load trước.

Giải pháp cơ bản và quan trọng nhất là:

Model::with('relationship')->get();

Hãy nhớ:

Lazy Loading
→ lấy relationship khi cần
→ dễ phát sinh N+1

Eager Loading
→ with()
→ lấy relationship ngay từ đầu
→ giảm đáng kể số lượng query

Trong các ứng dụng Laravel thực tế, việc hiểu và sử dụng đúng with(), load(), withCount()preventLazyLoading() sẽ giúp code Eloquent vừa dễ đọc vừa có hiệu năng tốt hơn.

Một dòng with() đôi khi có thể giúp bạn tránh hàng trăm câu SQL không cần thiết.

Categories:

Leave Comment