Advanced Eloquent: Subqueries, Composites & Hiệu năng

· 5 min read

Advanced Eloquent: Subqueries, Composites & Hiệu năng

Eloquent rất tuyệt vời, nhưng cách dùng ngây thơ sẽ dẫn đến ứng dụng chậm chạp. Các lập trình viên thường đổ lỗi cho Eloquent và chuyển sang Query Builder hoặc Raw SQL quá sớm. Thực tế, Eloquent tạo ra SQL được tối ưu hóa cao nếu bạn biết cách giao tiếp bằng "ngôn ngữ nâng cao" của nó.

Hướng dẫn này đi xa hơn việc sử dụng with() đơn giản, tiến vào subqueries (truy vấn con), sắp xếp động (dynamic ordering), và quản lý bộ nhớ.

1. Chi phí ẩn của Hydration

Tại sao User::with('posts')->get() lại chậm với 10,000 users? Không chỉ do SQL. Đó là do Model Hydration.

Laravel phải:

  1. Chạy query.
  2. Lặp qua từng dòng kết quả.
  3. Khởi tạo đối tượng User.
  4. Khởi tạo các đối tượng Post (có thể là hàng nghìn).
  5. Liên kết chúng lại với nhau.

Nếu bạn chỉ cần biết ngày của bài viết cuối cùng, việc hydrate 10,000 model Post là lãng phí CPU và RAM.

Giải pháp: addSelect Subqueries

Đừng fetch models. Hãy fetch dữ liệu.

$users = User::addSelect(['last_post_created_at' => Post::select('created_at')
    ->whereColumn('user_id', 'users.id')
    ->latest()
    ->limit(1)
])->paginate();

Bây giờ $user->last_post_created_at chỉ là một chuỗi string đơn giản. Không tốn thêm tài nguyên hydrate model nào cả.

2. Sắp xếp theo Relationships dùng Subqueries

"Sắp xếp user theo lần đăng nhập mới nhất". Đây là câu hỏi phỏng vấn kinh điển.

Bạn không thể dùng orderBy('logins.created_at') bởi vì quy tắc GROUP BY nghiêm ngặt trong SQL. Bạn cần subquery.

$users = User::orderByDesc(
    Login::select('created_at')
        ->whereColumn('user_id', 'users.id')
        ->latest()
        ->limit(1)
)->paginate();

Câu lệnh này sinh ra:

select * from "users" 
order by (
    select "created_at" from "logins" 
    where "user_id" = "users"."id" 
    order by "created_at" desc limit 1
) desc

3. High-Performance Indexing (Composite Indexes)

Subquery ở trên rất nhanh, NHƯNG chỉ khi bạn có index đúng. Nếu bạn có 1 triệu bản ghi login, subquery đó sẽ chạy 1 triệu lần (về mặt khái niệm).

Bạn cần một Composite Index (Index phức hợp) trên bảng con.

// migration
$table->index(['user_id', 'created_at']); // Thứ tự quan trọng!
  1. user_id: Để khoanh vùng login của user cụ thể.
  2. created_at: Để ngay lập tức tìm ra bản ghi latest() mà không cần sort lại toàn bộ.

Nếu không có index này, database của bạn sẽ thực hiện "Filesort" cho mỗi user, giết chết hiệu năng.

4. whereExists vs whereHas

whereHas dễ viết nhưng thường tạo ra EXISTS (SELECT * ...) có thể chậm nếu không tối ưu.

Đôi khi, tự viết whereExists cho bạn nhiều quyền kiểm soát hơn, nhưng chủ yếu chúng biên dịch ra SQL tương tự nhau. Điểm tối ưu chính là tránh dùng whereHas trên các quan hệ sâu (deep relationships) (ví dụ: whereHas('posts.comments.author')).

Để lọc sâu như vậy, hãy cân nhắc denormalization (lưu last_comment_at ngay trên bảng posts) hoặc dùng công cụ tìm kiếm riêng (như Meilisearch).

5. Subqueries trong FROM (fromSub)

Đôi khi bạn cần tổng hợp dữ liệu trước khi join.

Kịch bản: Lấy tổng doanh thu theo user, nhưng chỉ cho những user đã chi > $1000.

Cách ngây thơ: Group by trong query chính (khó phân trang - paginate). Cách tốt hơn: fromSub.

$totals = DB::table('orders')
    ->selectRaw('user_id, sum(amount) as total_spent')
    ->groupBy('user_id');

$highValueUsers = User::joinSub($totals, 'totals', function ($join) {
        $join->on('users.id', '=', 'totals.user_id');
    })
    ->where('totals.total_spent', '>', 1000)
    ->select('users.*', 'totals.total_spent')
    ->paginate();

Cách này tạo ra một bảng dẫn xuất (derived table) sạch trong SQL, cho phép Laravel phân trang kết quả chính xác.

Kết luận

  1. Tránh Hydration: Dùng addSelect cho các giá trị đơn lẻ.
  2. Sắp xếp thông minh: Truyền subquery builder vào orderBy.
  3. Index đúng: Subqueries vô dụng nếu thiếu composite indexes.
  4. JoinSub: Dùng derived tables cho các nhu cầu tổng hợp phức tạp.

Bình luận