Bóc Trần Kiến Trúc Consumer: Tại Sao Lại Chọn "Die-To-Redeliver" Thay Vì Dispatch Job Có Sẵn?
Chào anh em! Nếu bạn đang làm việc với các hệ thống hàng đợi (như RabbitMQ, Kafka) hoặc các framework xử lý Job ngầm (như Laravel Horizon), chắc hẳn bạn đã quá quen với pattern Dispatch-Job.
Mô hình chuẩn của Dispatch-Job rất êm ái: Lấy Job ra -> Chạy thử -> Lỗi (Exception) -> Catch lỗi -> Tăng số lần thử (Retry) -> Quá giới hạn thì đẩy vào bảng failed_jobs để Dev xử lý sau -> Worker vẫn sống sót và vui vẻ làm tiếp Job tiếp theo. Nhưng đôi khi, khi đọc source code của các dự án lớn, bạn lại thấy một kiểu code rất "bạo lực": Die-to-redeliver (hay Crash-only). Nghĩa là hễ Job bị lỗi logic hoặc lỗi kết nối, Dev cho gọi lệnh exit(1) hoặc ném ra Fatal Error để giết chết luôn tiến trình Worker đó. Worker chết, kết nối TCP bị ngắt Message Broker chưa nhận được cờ ACK sẽ coi như Job chưa được xử lý và lập tức phân phát lại (Redeliver) chính message đó khi Worker được Supervisor hồi sinh.
Tại sao phải tự làm khổ mình như vậy? Chẳng phải Tech Lead thường chửi câu: "Nếu không có lý do nghiệp vụ rõ, bạn đang tự tạo rủi ro vận hành hay sao?"
Đúng! Nhưng đây là 3 "lý do nghiệp vụ rõ ràng" khiến die-to-redeliver trở thành sự lựa chọn duy nhất.
1. Yêu cầu "Thứ tự tuyệt đối" (Strict Ordering)
Dispatch-Job sinh ra để giải quyết tốc độ, nhưng nó phá vỡ hoàn toàn thứ tự xử lý nếu có lỗi.
Giả sử hệ thống cổng trạm tự động (AFC) đẩy về 2 event liên tiếp của cùng một khách hàng:
Event_1: Nạp 100k vào thẻ.Event_2: Quẹt thẻ qua cổng (trừ 15k).
Nếu dùng Dispatch-Job: Event_1 xử lý bị lỗi mạng (timeout), hệ thống tống nó vào hàng chờ Retry hoặc failed_jobs. Worker điềm nhiên xử lý tiếp Event_2. Nhưng lúc này số dư chưa được cộng 100k, nên Event_2 báo lỗi "Không đủ số dư". Khách hàng bị chặn lại ở cổng dù vừa nạp tiền!
Nếu dùng Die-to-redeliver: Event_1 lỗi, Worker tự sát ngay lập tức. Không có cờ ACK nào được gửi. Lát sau Worker sống dậy, Broker ép nó phải xử lý lại Event_1 cho đến khi nào thành công thì mới được phép chạm vào Event_2.
-> Lý do nghiệp vụ: Khi logic phía sau phụ thuộc tuyến tính vào kết quả của logic phía trước, bạn bắt buộc phải chặn đứng (block) luồng xử lý cho đến khi cục nghẽn được thông.
2. Ngăn chặn "Trạng thái bẩn" (Dirty State) và Memory Leak
Khác với PHP thường giải phóng bộ nhớ sau mỗi request đồng bộ, các Worker chạy ngầm (nhất là viết bằng Go, Node.js hoặc Laravel Octane) là những tiến trình chạy liên tục (Long-running process) và giữ mọi thứ trên RAM.
Khi một Job đang chạy dở và bị Exception văng ra giữa chừng, rất có thể một số biến toàn cục, singleton object, hoặc transaction của Database vẫn chưa được dọn dẹp sạch sẽ (rollback).
Nếu Worker cố gắng sống sót để chạy Job tiếp theo, nó sẽ mang theo cái "trạng thái bẩn" (Dirty State) đó vào logic mới. Điều này dẫn đến những bug tâm linh cực kỳ nguy hiểm (ví dụ: Job B lại lấy nhầm ID của khách hàng ở Job A).
-> Lý do nghiệp vụ: Giết chết tiến trình là cách rẻ nhất và an toàn nhất để giải phóng 100% RAM, reset lại toàn bộ Database Connection và bắt đầu lại từ một trạng thái sạch bong (Clean State).
3. Chống lại hiệu ứng "Chết chùm" khi sập hạ tầng (Circuit Breaker)
Tưởng tượng Database của bạn bị quá tải hoặc đứt cáp mạng.
Nếu dùng Dispatch-Job: Worker của bạn, với bản tính cần mẫn, sẽ liên tục lấy 10.000 Jobs ra khỏi Queue. Mỗi Job nó thử kết nối DB -> Lỗi -> Vứt vào bảng failed_jobs. Chỉ trong 3 phút, toàn bộ 10.000 Jobs hợp lệ bị xử lý thất bại và kẹt hết trong bảng failed_jobs chờ bạn chạy lệnh retry bằng tay. Quá mệt mỏi!
Nếu dùng Die-to-redeliver: Ngay từ Job đầu tiên lỗi kết nối DB, Worker tự sát cmn luôn. Supervisor thử bật nó dậy, nó check DB vẫn sập -> lại tự sát. Toàn bộ 9.999 messages còn lại vẫn nằm an toàn trên Message Broker (Kafka/RabbitMQ). Khi DB phục hồi, Worker sống dậy và xử lý tiếp 10.000 messages một cách êm ái mà không rớt một dòng data nào.
Mặt tối: Rủi ro vận hành (Operational Risk)
Câu cảnh báo của người review code hoàn toàn không sai. Nếu áp dụng Die-to-redeliver bừa bãi, bạn sẽ gặp thảm họa "Viên thuốc độc" (Poison Pill) và "Kẹt xe cục bộ" (Head-of-line blocking).
Nếu một message gửi sai định dạng JSON (vĩnh viễn không thể parse được), Worker lấy nó ra -> crash -> khởi động lại -> lấy lại message đó -> crash tiếp. Nó tạo ra một vòng lặp chết chóc vô tận (Infinite loop of death). Một tin nhắn lỗi duy nhất có thể làm tê liệt toàn bộ một Partition (Kafka) hoặc Queue, khiến hàng triệu tin nhắn hợp lệ phía sau bị xếp hàng chờ đến kiếp sau.
Giải pháp dung hòa thực chiến: Không bao giờ dùng Die-to-redeliver một cách thuần túy. Hãy cấu hình ở phía Message Broker một cơ chế đếm số lần Redeliver. Nếu Worker đã tự sát và nhận lại message đó quá 5 lần (Max Deliveries), Broker phải tự động đá message đó sang một Dead Letter Queue (DLQ) để giải phóng đường truyền, cứu cả hệ thống khỏi cảnh kẹt xe.
Lời kết
Dispatch-Job là xe số tự động, dễ đi, an toàn cho hầu hết các chức năng thông thường (gửi email, tính toán report).
Nhưng Die-to-redeliver là hộp số sàn của dân chuyên nghiệp. Nó nguy hiểm, dễ tắt máy giữa đường, nhưng là cơ chế tối thượng để bảo vệ tính toàn vẹn 100% của dữ liệu tài chính và giao dịch.
Khi review code hoặc thiết kế hệ thống, hãy chọn công cụ dựa trên bản chất của Data, đừng chọn theo thói quen!
All rights reserved