
Một buổi sáng thứ hai yên bình, sau 1 bát phở và 1 ly nâu đá sẵn sàng trên bàn, mở laptop ra thì Slack channel monitor đỏ lòm… Bảng failed_jobs của một shop tôi làm có thêm vài trăm dòng giống hệt nhau: ModelNotFoundException: No query results for model [App\Models\Order] 48213. Mở database lên xem thì… order 48213 nằm sờ sờ ở đó. Retry job bằng tay: chạy ngon. Chạy lại luồng checkout trên local: Chạy cũng vẫn ngon!
Bug kiểu này tôi khá quen rồi, chắc chắn lúc process job bị race conditions, dẫn tới job không tìm thấy record vì nó chưa được tạo. Bug kiểu này khá khó debug, tôi nghi read replica bị lag. Không phải.
Sau khi mò mẫm gần 2 tiếng thì thủ phạm là một dòng dispatch() nằm bên trong DB::transaction().
Chuyện gì xảy ra trong 20ms đó?
Code checkout hồi đó, rút gọn:
DB::transaction(function () use ($cart, $payment) {
$order = Order::create([...]);
$order->items()->createMany($cart->toItems());
$this->gateway->capture($payment, $order->total); // gọi ra ngoài, 300-800ms
SendOrderConfirmation::dispatch($order);
PushOrderToErp::dispatch($order);
});
Trông thì hợp lý. Tạo order => thu tiền => trigger job => commit. Nhưng thứ tự thật sự ở runtime là như vầy:
dispatch()đẩy job lên Redis ngay lập tức, trong khi transaction của bạn vẫn đang open (chưa commit).- Worker là một process khác, cầm một DB connection khác, nhận job trong vòng vài ms và bắt đầu chạy.
Trait SerializesModels không serialize cả model vào payload, nó chỉ lưu class và id. Khi worker unserialize, hàm restoreModel() trong Illuminate\Queue\SerializesAndRestoresModelIdentifiers chạy đúng một câu: firstOrFail() theo id. Mà ở connection của worker, order 48213 chưa tồn tại. Transaction bên kia còn đang chờ payment gateway trả lời.
Vậy là job chết với ModelNotFoundException. Vài trăm ms sau thì transaction commit, order xuất hiện, và bạn ngồi nhìn cái row đó mà tự hỏi sao thằng worker này ngu thế (chả biết ai ngu zzz).
Một chi tiết nhỏ mà tôi ước mình biết sớm hơn: restoreModel() có gọi useWritePdo(). Nghĩa là Laravel đã chủ động đọc từ master chứ không đọc replica khi rehydrate model cho job. Read replica lag không phải nguyên nhân, và tôi đã tốn một buổi sáng…
Sao local không bao giờ dính?
Vì local của đa số chúng ta chạy QUEUE_CONNECTION=sync. Driver sync chạy job inline, ngay tại dòng dispatch(), trên cùng một DB connection với transaction. Cùng connection thì thấy được row chưa commit. Mọi thứ xanh lè.
Ngay cả khi bạn có Redis và worker trên docker, cũng khó tái hiện. Local không có payment gateway thật, transaction đóng trong 5ms, worker chưa kịp poll thì đã commit rồi. Trên production, cái call ra ngoài mất 300ms là quá đủ cho worker thắng cuộc đua.
Bug xuất hiện khi transaction đủ chậm. Và production luôn tìm được cách làm transaction đủ chậm.
after_commit: một dòng config đơn giản giải quyết mọi vấn đề
Thay vì ngồi vò đầu bứt tai chỉnh lại flow order logic, Laravel có sẵn cách xử lý, nằm trong config/queue.php, key after_commit của từng queue connection. Skeleton hiện tại (bản 13.x) set nó là false cho cả database, redis lẫn sqs. Nghĩa là mặc định bạn đang ở trong tình huống trên.
// config/queue.php
'redis' => [
'driver' => 'redis',
'connection' => env('REDIS_QUEUE_CONNECTION', 'default'),
'queue' => env('REDIS_QUEUE', 'default'),
'retry_after' => (int) env('REDIS_QUEUE_RETRY_AFTER', 90),
'after_commit' => true, // mặc định là false
],
Bật lên thì dispatch() bên trong transaction sẽ chờ tới khi transaction ngoài cùng commit rồi mới đẩy job lên queue. Không có transaction nào mở thì đẩy ngay như cũ. Job, queued listener, mailable, notification, broadcast event: tất cả đều đi theo setting này.
Nếu không muốn bật global, bạn có thể đơn giản chọn từng chỗ (not recommended):
// Từng lần dispatch
PushOrderToErp::dispatch($order)->afterCommit();
// Hoặc đánh dấu ở class, khỏi nhớ mỗi lần gọi
use Illuminate\Contracts\Queue\ShouldQueueAfterCommit;
class PushOrderToErp implements ShouldQueueAfterCommit
{
use Queueable;
// ...
}
// Event cũng có bản tương đương
use Illuminate\Contracts\Events\ShouldDispatchAfterCommit;
Tôi thích cách đánh dấu ở class hơn. Sáu tháng sau có ông nào viết thêm một chỗ dispatch trong transaction thì job vẫn tự chờ, không phụ thuộc vào trí nhớ của người gọi.
Tuy nhiên cách này cũng có 1 vấn đề về timing. Laravel chờ transaction ngoài cùng commit, nested transaction chỉ là savepoint. Nên một job bạn tưởng bắn ra ở giữa flow giờ sẽ xếp hàng chờ tới cuối, sau cả cái call payment gateway 800ms kia. Với job gửi mail thì chẳng sao. Với job cần độ trễ thấp, như payment chẳng hạn, thì bạn vừa đổi một bug lấy một cái chậm.
Vậy bài học sau buổi sáng và cốc cafe nguội ngắt là gì?
Project mới: after_commit => true cho mọi connection, ngay ngày đầu, trước khi có ai kịp dispatch cái gì. Chi phí gần bằng không vì chưa có code nào dựa vào hành vi cũ.
Project cũ: đừng bật global rồi deploy chiều thứ sáu 🙂 Grep hết chỗ nào dispatch, event(, notify( nằm trong DB::transaction, xem job nào chấp nhận bị bỏ khi rollback, job nào không. Bật từng class bằng ShouldQueueAfterCommit trước, bật global sau khi đã thấy đủ.
Và cái tôi note cho các bạn, không liên quan tới queue: kéo call payment gateway (external outbound call) ra khỏi transaction. Giữ transaction chỉ còn insert order và items, vài ms là xong. Đây mới là thứ thu hẹp cái cửa sổ race về gần không, còn after_commit là lưới an toàn cho lúc mình quên.
Nếu anh em có ca nào after_commit làm hỏng thứ khác trong project của bạn, kể tôi nghe với. Tôi mới thấy hai kiểu ở trên, chắc chắn còn kiểu thứ ba.
Ảnh: Nicolas Hoizey trên Unsplash (Unsplash License) — https://unsplash.com/photos/runner-pushing-off-starting-blocks–4trKf0Kbow


