Đọc rồi ghi mà không khoá là race condition kinh điển: hai request cùng đọc stock = 1, cả hai cùng thấy đủ, cả hai cùng trừ → âm kho.
Cách 1 — pessimistic lock (lockForUpdate), phù hợp khi tranh chấp cao:
DB::transaction(function () use ($productId, $qty) {
$product = Product::where('id', $productId)->lockForUpdate()->firstOrFail();
if ($product->stock < $qty) {
throw new OutOfStockException();
}
$product->decrement('stock', $qty);
Order::create([...]);
}, 3); // tham số thứ 2: số lần thử lại khi deadlocklockForUpdate sinh SELECT ... FOR UPDATE: các transaction khác phải chờ tới khi transaction này commit. sharedLock (FOR SHARE) nhẹ hơn — cho phép đọc song song nhưng chặn ghi; dùng khi chỉ cần đảm bảo dữ liệu đọc không bị đổi giữa chừng.
Cách 2 — đẩy điều kiện xuống một câu UPDATE nguyên tử, không cần lock tường minh:
$affected = Product::where('id', $productId)->where('stock', '>=', $qty)->decrement('stock', $qty);
if ($affected === 0) { throw new OutOfStockException(); }Những điểm phải nhớ:
- Lock chỉ có hiệu lực bên trong transaction; lockForUpdate ngoài transaction là vô nghĩa.
- Giữ transaction ngắn nhất có thể; không gọi API ngoài hay gửi mail bên trong. Việc phụ đẩy ra DB::afterCommit() hoặc job có ShouldQueue (queue mặc định gửi sau commit khi bật after_commit).
- Khoá hàng theo thứ tự cố định giữa các luồng để giảm deadlock; kèm retry như tham số thứ hai của DB::transaction.
- Chống double-submit ở tầng trên bằng idempotency key trên đơn hàng — lock chỉ giải quyết tranh chấp tồn kho, không chống user bấm hai lần.