Bỏ qua tới nội dung
Kiên

Cái giá của một cái queue

· 5 phút đọc

Queue mua về đúng một thứ đáng tiền: một supplier chậm thôi làm phiền tất cả mọi người. Đổi lại nó thu tiền trả góp — thứ tự biến mất, retry biến idempotency thành việc của bạn, message hỏng vĩnh viễn phải có chỗ đi, và trạng thái công việc nằm rải ở ba chỗ có thể nói ngược nhau. Sổ ghi cả hai cột.

  • queue
  • hệ phân tán
  • độ tin cậy
  • kiến trúc
Mục lục
  1. Nó mua được gì
  2. Thứ tự mất, muốn có phải mua lại
  3. Có retry thì idempotency thành việc của bạn
  4. Message hỏng vĩnh viễn sẽ kéo cả consumer theo
  5. Trạng thái công việc giờ nằm ở ba chỗ
  6. Và stack trace biến thành bài tập nối dấu vết
  7. Quy tắc

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ì.

Bài liên quan

· 5 phút đọc

"Tức là gọi API của người khác hả" — câu tóm tắt mình nghe nhiều nhất về công việc của mình, và nó sai theo một cách đáng để mổ xẻ. Khoảng 150 supplier code trên chừng 90 tích hợp, hai pattern offer được ghi thành tài liệu, hai mô hình thực thi sau cùng một contract, và những supplier trả 200 kèm lỗi bên trong — một vòng quanh việc này thật sự gồm những gì, và vì sao nó là kiến trúc.

  • tích hợp
  • kiến trúc
  • API design
  • hệ phân tán

· 5 phút đọc

Không phải phần cơ khí của tích hợp — mà là cái domain nằm dưới nó. Tồn kho đổi ngay giữa search và book, giá có hạn sử dụng, chính sách huỷ là dữ liệu chứ không phải một cái cờ, năm dòng sản phẩm có hình dạng khác nhau thật, và nguồn hàng là công ty khác. Phần khó nằm ở thứ mình đang bán, không nằm ở cách mình gọi nó.

  • travel tech
  • domain
  • hệ phân tán
  • kiến trúc