Đừng để Queue Worker "đột tử": Giải phẫu DB::disableQueryLog() chống Memory Leak trong Laravel
Chào anh em cộng đồng Viblo!
Hôm nay chúng ta sẽ cùng mổ xẻ một vấn đề mà tôi cá là 90% anh em làm backend Laravel sớm muộn gì cũng sẽ gặp phải khi hệ thống bắt đầu phình to. Đó là câu chuyện về chiếc "bình Oxi" mang tên DB::disableQueryLog() dành cho các tiến trình chạy ngầm (Long-running processes) hay xử lý dữ liệu lớn.
Bài viết này không chỉ dừng ở bề nổi "cách dùng", mà chúng ta sẽ đi sâu vào "tại sao", bản chất bên dưới frameowork và những kinh nghiệm thực chiến khi xử lý hàng triệu records.
Pha một tách cà phê và bắt đầu nhé! ☕
1. Mở bài: Cú tát từ thực tế (The OOM Disaster)
Hãy tưởng tượng một kịch bản rất quen thuộc: Bạn được giao task viết một Console Command hoặc một Queue Job để đồng bộ khoảng 2 triệu giao dịch (transactions) từ hệ thống máy chủ phụ về database chính mỗi đêm.
Bạn hì hục code. Test ở local với 1,000 dòng? Chạy mượt như Ngọc Trinh, mất 2 giây. Bạn tự tin merge code và deploy lên Production.
Sáng hôm sau, sếp gọi bạn dậy từ 6h sáng: "Em ơi, Job đồng bộ chết từ đêm qua rồi!". Bạn vội vàng check log và thấy dòng chữ đỏ chót ám ảnh mọi Coder:
PHP Fatal error: Allowed memory size of 134217728 bytes exhausted (tried to allocate 20480 bytes)
Chuyện quái gì đã xảy ra? Bạn đã chia nhỏ dữ liệu (chunk), bạn đã unset biến, bạn đã không lưu Models vào array... Tại sao RAM vẫn cứ đầy và tràn?
Thủ phạm thầm lặng ở đây chính là cơ chế Query Logging của Laravel.
2. Giải phẫu "Thủ phạm": Query Log là gì?
Mặc định, trong một số cấu hình môi trường hoặc khi bạn bật debug, Laravel cực kỳ "chu đáo". Với mỗi câu lệnh SQL được thực thi, Laravel Component Illuminate\Database\Connection sẽ lưu trữ nó lại vào bộ nhớ (kèm theo thời gian chạy và parameters) để bạn có thể debug bằng các tool như Laravel Debugbar hay Clockwork.
Hãy nhìn vào mã nguồn của Laravel một chút:
// Bên trong class Illuminate\Database\Connection
public function logQuery($query, $bindings, $time = null)
{
$this->event(new QueryExecuted($query, $bindings, $time, $this));
if ($this->loggingQueries) {
$this->queryLog[] = compact('query', 'bindings', 'time'); // <-- VẤN ĐỀ NẰM Ở ĐÂY
}
}
Mảng $this->queryLog này là một array trong RAM.
- Nếu request của user trên web chỉ chạy 10-20 câu queries -> Không sao cả, mảng này rất nhỏ. Cuối request, PHP dọn dẹp (Garbage Collection) sạch sẽ.
- NHƯNG, nếu bạn chạy một Queue Worker (sống liên tục) hoặc một Command insert 1 triệu records? Mảng
$queryLognày sẽ phình to ra với 1 triệu phần tử. Không có Garbage Collector nào dọn nó vì nó vẫn đang được tham chiếu bởi instance Connection. Kết quả? Tràn RAM (Out of Memory).
3. Cứu tinh xuất hiện: disableQueryLog()
Cách giải quyết cực kỳ đơn giản nhưng lại là "bùa hộ mệnh" cho hệ thống lớn. Chúng ta chỉ cần bảo Laravel: "Này, tôi đang chạy batch job nặng lắm, đừng có nhớ mấy câu SQL này làm gì cả!"
Code Demo: Xử lý trước và sau khi tối ưu
Đoạn code ngây thơ (Dễ gây đột tử RAM):
use App\Models\Transaction;
use Illuminate\Console\Command;
class SyncTransactionsCommand extends Command
{
protected $signature = 'sync:transactions';
public function handle()
{
$this->info("Bắt đầu đồng bộ...");
// Xử lý 1 triệu bản ghi từ một API hoặc file CSV
$transactions = $this->fetchMillionsOfTransactions();
foreach ($transactions as $tx) {
// Mỗi lần create là 1 lần Query log được lưu vào RAM!
Transaction::create([
'tx_id' => $tx['id'],
'amount' => $tx['amount'],
'status' => 'success',
]);
}
$this->info("Đồng bộ hoàn tất!");
}
}
Đoạn code "Thực chiến" (Hạng nặng):
use Illuminate\Support\Facades\DB;
use App\Models\Transaction;
use Illuminate\Console\Command;
class SyncTransactionsCommand extends Command
{
protected $signature = 'sync:transactions';
public function handle()
{
$this->info("Bắt đầu đồng bộ...");
// 1. TẮT QUERY LOG TRƯỚC KHI CHẠY VÒNG LẶP LỚN
DB::connection()->disableQueryLog();
// 2. Dùng Chunk/Cursor để tiết kiệm RAM khi đọc dữ liệu (nếu từ DB khác)
// Ở đây giả sử ta insert số lượng lớn
$transactionsChunk = $this->fetchMillionsOfTransactionsInChunks();
foreach ($transactionsChunk as $chunk) {
// 3. Sử dụng insert (Bulk Insert) thay vì create() từng model một để tối ưu I/O database
Transaction::insert($chunk);
}
$this->info("Đồng bộ hoàn tất mượt mà, RAM vẫn mát rượi!");
}
}
Lưu ý: Nếu hệ thống của bạn sử dụng nhiều Database Connections khác nhau (ví dụ: mysql, pgsql, hay cấu hình sharding), bạn cần chỉ định đúng connection để disable:
DB::connection('tên_connection')->disableQueryLog();
4. Kinh nghiệm "Xương máu" chia sẻ thêm cho anh em Backend
Làm việc với hệ thống yêu cầu tính chính xác cao và khối lượng dữ liệu khổng lồ, việc tối ưu không chỉ dừng ở disableQueryLog. Đây là những combo tôi thường áp dụng để đảm bảo hệ thống cứng cáp:
Combo 1: DB::disableQueryLog() + cursor() / chunkById()
Khi bạn không ghi mà là ĐỌC một lượng dữ liệu lớn để tính toán (VD: Quét toàn bộ hóa đơn để xuất report), đừng bao giờ dùng Model::all() hay Model::get().
Hãy dùng cursor(). Nó kết hợp với PHP Generators, chỉ load 1 record vào RAM tại 1 thời điểm. Kết hợp với việc tắt query log, Job của bạn có thể chạy với mức RAM gần như không đổi.
Combo 2: Tắt Event Dispatcher (Extreme Optimization)
Trong trường hợp bạn dùng Eloquent (như $model->save()) trong vòng lặp lớn, việc tắt Query Log là chưa đủ. Mỗi lần Save, Laravel bắn ra hàng tá Events (saving, saved, creating, created). Quá trình này cũng tốn CPU và memory overhead đáng kể.
Nếu bạn chắc chắn mình không cần trigger Event hay Observer nào, hãy dùng Query Builder (ví dụ DB::table('transactions')->insert()) hoặc tắt tạm event:
$dispatcher = Transaction::getEventDispatcher();
Transaction::unsetEventDispatcher();
// Xử lý logic nặng
Transaction::setEventDispatcher($dispatcher); // Bật lại sau khi xong
Combo 3: Cần log lại một đoạn nhỏ? Dùng flushQueryLog()
Đôi khi bạn vẫn cần lấy log để debug một đoạn code nhỏ ở giữa Job lớn. Bạn có thể bật lại, chạy, lấy log, rồi xả (flush) nó đi ngay lập tức:
DB::enableQueryLog();
// ... chạy vài query phức tạp cần debug ...
$logs = DB::getQueryLog();
DB::flushQueryLog(); // Xóa sạch mảng trong RAM
DB::disableQueryLog(); // Tắt đi lại cho an toàn
5. Lời kết
DB::disableQueryLog() là một dòng code cực nhỏ nhưng tác động lại vô cùng lớn đến tính ổn định của các Background Jobs trong Laravel. Khi ứng dụng vượt qua giai đoạn startup và đối mặt với bài toán scaling, chính những tiểu tiết về Memory Management như thế này sẽ định hình nên đẳng cấp của một Backend Developer.
Anh em đã từng bị sập server vì những lỗi OOM ngớ ngẩn nào chưa? Cùng chia sẻ kinh nghiệm ở phần bình luận nhé! Nếu thấy bài viết hữu ích, đừng quên cho mình một Upvote để có động lực ra thêm các bài "hạng nặng" về tối ưu hệ thống!
All rights reserved