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().
Giả sử chúng ta có hai model:
Post: bài viếtUser: người dùng tạo bài viếtMỗ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.
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:
Một đoạn code nhìn rất “đẹp” có thể đang tạo ra hàng nghìn query phía sau.
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.
Để hiểu with(), cần phân biệt hai cách loading relationship trong Eloquent.
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.
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.
Post::all();
Có thể dẫn đến:
1 query + N queries
Trong khi:
Post::with('user')->get();
thường chỉ cần:
2 queries
with() không chỉ dùng được với một relationship.
Ví dụ Post có:
usercommentscategoryChú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.
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 user và profile thay vì đợi đến khi từng object được truy cập.
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:
idnamecủ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.
with() có thể kết hợp với điều kiệnBạ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.
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ề.
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.
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.
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.
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.
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.
with() không phải lúc nào cũng tốt hơnEager 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.
Khi viết code Eloquent, hãy kiểm tra:
foreach không?with() chưa?withCount() thay vì tải toàn bộ relationship không?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;
}
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() và 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.