Series Nâng cao Laravel Query Builder #2: Nghệ thuật Subquery, Clean Code và "Khóa" Sinh Tử
1. Mệnh đề điều kiện when(): Chấm dứt kỷ nguyên "if-else" rác rưởi
Khi làm API cho trang danh sách giao dịch (Admin Dashboard), Frontend thường truyền lên rất nhiều tham số lọc không bắt buộc (Ví dụ: Lọc theo station_id, khoảng ngày start_date, trạng thái status).
Cách viết của Junior (Code "Mì gõ"): Họ sẽ tạo một query gốc, rồi dùng hàng tá lệnh if lồng nhau để nối thêm điều kiện. Code rất dài, rối mắt và dễ đứt gãy.
// ❌ Cách làm thủ công, khó bảo trì
$query = DB::table('transactions');
if ($request->has('station_id')) {
$query->where('station_id', $request->station_id);
}
if ($request->has('status')) {
$query->where('status', $request->status);
}
$data = $query->get();
Cách viết Chuẩn Senior (Fluent Chain với when): Query Builder cung cấp hàm when(). Nó chỉ thực thi đoạn Closure (hàm ẩn danh) bên trong nếu tham số đầu tiên trả về true.
// ✅ Luồng code chảy mượt mà, nối chuỗi (chaining) liên tục
$data = DB::table('transactions')
->where('is_active', 1)
->when($request->filled('station_id'), function ($query) use ($request) {
$query->where('station_id', $request->station_id);
})
->when($request->filled('status'), function ($query) use ($request) {
$query->where('status', $request->status);
})
->orderByDesc('created_at')
->get();
Nhờ when(), hàm query của bạn luôn duy trì được cấu trúc Fluent Interface (chuỗi gọi hàm liên tiếp), cực kỳ sạch sẽ và dễ đọc.
2. Sức mạnh của Subqueries (Truy vấn con)
Bài toán kinh điển: "Hãy lấy ra danh sách tất cả các nhà ga, kèm theo thời gian của Giao dịch quẹt thẻ cuối cùng tại mỗi nhà ga đó."
Nhiều người sẽ lấy danh sách nhà ga ra bằng PHP, rồi vòng lặp foreach từng nhà ga để query lấy giao dịch cuối. Đó chính là thảm họa N+1 Query khét tiếng, đánh sập Database tức tưởi.
Thay vào đó, hãy dùng Subquery (Truy vấn con) ngay bên trong câu select() thông qua hàm addSelect():
$latestTransactionQuery = DB::table('transactions')
->select('created_at')
->whereColumn('station_id', 'stations.id') // Trỏ ngược cột ra bảng bên ngoài
->orderByDesc('created_at')
->limit(1);
$stations = DB::table('stations')
->addSelect(['latest_tap_time' => $latestTransactionQuery])
->get();
Lúc này, Laravel sẽ compile đoạn code trên thành duy nhất 1 câu SQL gom toàn bộ logic vào tầng Database engine. Tốc độ trả về sẽ nhanh gấp hàng chục lần so với việc dùng vòng lặp PHP.
Bạn cũng có thể dùng joinSub() để Join nguyên một bảng ảo (chứa kết quả tính toán) vào bảng hiện tại mà không cần tạo View vật lý trong MySQL.
3. Pessimistic Locking (Khóa bi quan): Sinh tử trong giao dịch tài chính
Hãy tưởng tượng thẻ của hành khách còn đúng 15.000đ. Họ cầm thẻ đó quét qua 2 cổng Turnstile khác nhau ở 2 đầu nhà ga trong cùng một phần ngàn giây (có thể do lỗi thiết bị hoặc cố tình gian lận).
Hệ thống có 2 tiến trình API cùng chạy song song:
- Luồng 1 kiểm tra số dư -> Thấy 15.000đ (đủ điều kiện) -> Trừ 10.000đ.
- Luồng 2 kiểm tra số dư -> Vẫn thấy 15.000đ (do Luồng 1 chưa kịp lưu) -> Trừ 10.000đ.
Kết quả: Số dư của khách bị âm 5.000đ. Đây là Race Condition - cơn ác mộng của mọi hệ thống thanh toán.
Để khắc phục, chúng ta phải dùng kỹ thuật Khóa Bi Quan (Pessimistic Locking) với lệnh lockForUpdate().
use Illuminate\Support\Facades\DB;
// BẮT BUỘC PHẢI CHẠY TRONG TRANSACTION
DB::transaction(function () use ($cardUid, $fare) {
// Lệnh lockForUpdate() sẽ nói với MySQL:
// "Khóa chặt dòng dữ liệu của cái thẻ này lại. Cấm bất kỳ luồng nào khác
// được quyền Đọc/Ghi dòng này cho đến khi tao xử lý xong!"
$wallet = DB::table('wallets')
->where('card_uid', $cardUid)
->lockForUpdate()
->first();
if ($wallet->balance >= $fare) {
// Luồng 1 ung dung trừ tiền
DB::table('wallets')
->where('card_uid', $cardUid)
->decrement('balance', $fare);
// Ghi log giao dịch...
} else {
throw new Exception("Số dư không đủ!");
}
}); // Kết thúc Transaction, khóa (Lock) tự động được mở ra cho Luồng 2 vào.
Khi Luồng 1 đang ôm lệnh lockForUpdate(), Luồng 2 chạy đến dòng đó sẽ bị Database bắt phải đứng chờ (chặn lại hoàn toàn). Chỉ khi Luồng 1 chạy xong (Commit), Luồng 2 mới được phép đọc tiếp, và lúc này nó sẽ đọc được số dư là 5.000đ -> Báo lỗi không đủ tiền. Không bao giờ xảy ra tình trạng âm ví!
4. Bật chế độ "Nghiêm ngặt" (Strict Mode) cho Database
Trong cấu hình config/database.php của Laravel có một biến 'strict' => true. Rất nhiều Dev thường tự ý tắt nó đi (false) để MySQL "dễ dãi" hơn khi code bị lỗi Group By hoặc nhét sai kiểu dữ liệu.
Chuẩn Senior: Tuyệt đối không bao giờ được set strict = false. Nếu bạn để false, khi bạn chèn một đoạn text dài 300 ký tự vào cột VARCHAR(255), MySQL sẽ tự động âm thầm cắt đuôi 45 ký tự thừa mà không báo lỗi. Hệ thống vẫn báo thành công, nhưng dữ liệu đối soát của Hitachi gửi về đã bị cắt cụt và sai lệch vĩnh viễn. Việc bật Strict Mode ép bạn phải Validate dữ liệu từ trong trứng nước.
Sau bài học này, bạn không chỉ viết được những truy vấn sạch sẽ (when), mạnh mẽ (Subquery) mà còn đảm bảo tuyệt đối sự toàn vẹn của dữ liệu tiền tệ bằng cơ chế Locking của Database.
Để hoàn thiện series về Query Builder này ở Bài 3, bạn muốn chúng ta đi sâu vào cách Sử dụng Common Table Expressions (CTE) với Laravel (truy vấn đệ quy, xử lý biểu đồ cây) hay muốn học kỹ thuật Debugging và Explain SQL (đọc hiểu kế hoạch thực thi của MySQL để biết tại sao câu lệnh lại bị chậm và cần đánh Index ở đâu)?
All rights reserved