Queue thường bước vào codebase dưới dạng một quyết định về hiệu năng, và cách đóng khung đó che mất cái mình thật sự đang đổi. Bạn không mua tốc độ. Bạn mua một bề mặt lỗi khác, và hoá đơn về theo kiểu trả góp, rơi vào những ngày khác nhau, thường là lâu sau khi người chọn cái queue đã đi làm việc khác.
Đây là sổ ghi của một hệ đã chọn nó.
Nó mua được gì
Search hotel, flight và car ở gateway bên mình bắn mỗi supplier một job vào queue; tầng HTTP gom những gì đã về và trả lời dưới một deadline tuyệt đối 30 giây. Phần cơ chế nằm ở một bài khác; ở đây mình chỉ quan tâm đúng một tính chất — cái đã biện minh cho toàn bộ chi phí phía dưới.
Không có queue thì một lượt search là một vòng lặp, và độ trễ của vòng lặp bằng độ trễ của supplier chậm nhất trong đó — một supplier có buổi chiều tệ biến thành buổi chiều tệ của mọi khách, kể cả những lượt search mà supplier đó chẳng có gì để bán. Có queue thì supplier chậm chậm một mình: job của nó giữ worker của nó và lỡ deadline một mình, trong khi kết quả của những bên khác đã nằm sẵn trên trang.
Đó là món hàng: một thành phần chậm thôi còn là vấn đề chung. Nếu đó không phải thứ bạn cần, thì phần còn lại của bài này là danh sách những khoản bạn sắp trả cho một thứ không dùng tới.
Thứ tự mất, muốn có phải mua lại
Khoản đầu tiên tới ngay lập tức. Queue giao hàng theo đại khái thứ tự chúng vào, và chữ "đại khái" gánh toàn bộ vấn đề: có nhiều hơn một consumer thì thứ tự vào và thứ tự xử lý là hai dãy khác nhau. Hai message về cùng một thực thể có thể được áp dụng ngược, và chẳng ai nhận ra, vì tách riêng ra thì message nào cũng hợp lệ.
Cách chữa không bao giờ là "làm cho queue có thứ tự". Nó là ngồi quyết định theo từng key: cái gì bắt buộc phải tuần tự, rồi trả tiền riêng cho đúng chỗ đó. Job của từng supplier bên mình được một Redis lock canh, và cái lock đó mua đúng một thứ: một worker trên supplier này cho lượt search này — không mua gì về thứ tự giữa các lượt search, mà cũng chưa bao giờ định mua. Cái approval engine mình làm ở VNTrip trả theo kiểu khác: optimistic locking ở bước duyệt, để một lượt ghi cũ thua chứ không đè lên. Kiểu nào cũng là cùng một lời thừa nhận — thứ tự là thứ bạn gắn lại đúng chỗ cần, không phải thứ transport tặng kèm.
Có retry thì idempotency thành việc của bạn
Khoản thứ hai là khoản mình thấy bị hiểu sai nhiều nhất. Queue hứa at-least-once, và câu "queue nó retry cho mà" bị đọc thành một tính năng về độ tin cậy. Không phải. Nó nói rằng consumer của bạn có thể chạy hai lần trên cùng một message, còn chuyện đó có an toàn hay không là tính chất của code bạn viết, không phải của lời hứa từ queue.
Câu hỏi kiểm tra không phải "có retry không", mà là lần chạy thứ hai làm gì. Một worker ghi kết quả của supplier vào cache hash dưới đúng key của supplier đó thì an toàn nhờ hình dạng: chạy hai lần, lần ghi sau rơi vào cùng key với cùng khuôn. Một worker append thì không. Một worker tiêu tiền thì lại càng không — nên chính sách retry cho một job có thao tác đặt chỗ là một quyết định thiết kế phải cãi nhau ra nhẽ, không phải một con số copy từ config của service trước.
Message hỏng vĩnh viễn sẽ kéo cả consumer theo
Khoản thứ ba là khoản thành incident. Ở đâu đó luôn có một message không bao giờ chạy được: payload theo schema cũ, một supplier code không còn tồn tại, một chỗ null mà parser đã đinh ninh là không thể null.
Bật retry mà không cho nó chỗ nào để đi, thì message đó quay lại, lỗi, quay lại, lỗi. Consumer thì bận, queue depth trông vẫn sống, và không có gì tiến lên — cái metric nói ra sự thật đó là số việc hoàn thành, không phải số message đã xử lý, mà nó thường đúng là cái không ai vẽ lên dashboard. Message đó cần một chỗ dead-letter giữ lại cả payload lẫn cái lỗi đã đẩy nó tới đó. Còn một dead-letter queue không ai đọc thì là lệnh xoá đi vòng vèo, nên nó cần thêm một người chủ: chi phí tổ chức, không phải chi phí kỹ thuật.
Trạng thái công việc giờ nằm ở ba chỗ
Khoản thứ tư ở lại vĩnh viễn. Trước khi có queue, "cái này xong chưa?" là câu hỏi về một hàm đã return hay chưa. Sau khi có queue, câu trả lời bị chia ra: queue biết cái gì sắp xảy ra, cache giữ kết quả từng phần, database giữ sự thật đã commit. Ba chỗ, ba câu trả lời, và các lỗi chính là những chỗ chúng nói ngược nhau — một job đã ack mà lượt ghi cache không bao giờ tới; kết quả nằm trong cache nhưng không có bản ghi nào đánh dấu là đã xong; một row đã commit trong khi cái job lẽ ra phải chạy sau nó thì không chạy.
Đó chính là lý do cái bản ghi đánh dấu hoàn thành tồn tại. Một hash kết quả không phân biệt được "supplier này chưa trả lời" với "supplier này trả lời là không có gì", mà hai câu đó nghĩa ngược nhau với người đang nhìn trang kết quả. Nên worker ghi thêm một bản ghi meta thứ hai khi xong: hoàn thành thành một sự kiện khẳng định, chứ không phải một suy đoán từ sự im lặng. Tỉ lệ tiến độ đọc từ bản ghi đó, và câu "ai chưa về trước deadline" cũng vậy — đó là khác biệt giữa việc nói với đối tác "không còn phòng" và nói "một supplier bị timeout".
Và stack trace biến thành bài tập nối dấu vết
Chạy đồng bộ thì lỗi đến kèm luôn cái gây ra nó: một stack, một request, một chỗ để nhìn. Queue cắt đôi chuyện đó. Stack của bên đẩy kết thúc ở đã enqueue, stack của bên tiêu bắt đầu ở đã nhận, và hai nửa chỉ nối được với nhau bằng một định danh mà ai đó đã quyết định nhét vào payload. Không có quyết định ấy thì hai nửa câu chuyện nằm ở hai luồng log không có cột nào để join. Và cái định danh phải có mặt trước sự cố, vì không thể thêm nó vào những dòng log đã ghi rồi.
Mình không có con số độ trễ trước–sau cho cái queue trên đường search — không ai đo bản đồng bộ trước khi nó bị thay — nên lập luận ở đây là lập luận cấu trúc chứ không phải đo đạc, và mình thà nói thẳng vậy còn hơn làm tròn một con số nghe hợp lý thành một khẳng định.
Quy tắc
Queue dời chỗ cái lỗi. Nó không xoá cái lỗi. Supplier chậm vẫn chậm, chỉ là chậm ở chỗ không chặn ai. Lỗi vẫn xảy ra, chỉ là xảy ra trong một process không có người dùng nào đang ngồi chờ — và chính vì thế nó cần dead letter với một correlation id thì mới nhìn thấy được.
Hãy dùng queue khi cái sự dời chỗ đó đúng là thứ bạn cần — khi việc cách ly một bên chậm đáng giá bằng thứ tự, idempotency, xử lý message hỏng và một máy trạng thái trải trên ba chỗ lưu. Dùng nó vì nghe hiện đại, thì bạn trả đủ mọi kỳ trả góp mà không nhận về gì.