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

`book` không phải thứ retry được

· 4 phút đọc

1 click · 2 booking

Lệnh đặt phòng timeout ở phía client nhưng đã thành công ở phía trên. Client retry — hoàn toàn hợp lý, vì từ phía nó chẳng có gì trả về — và supplier bây giờ đang giữ hai booking cho cùng một khách. Vì sao transaction không cứu được, vì sao HTTP verb không nói gì về chuyện này, và cái gì mới thật sự đỡ được.

  • tích hợp
  • reliability
  • hệ phân tán
  • backend
Nội dung
  1. Cái hỏng
  2. Hai câu trả lời không dùng được
  3. Cái thật sự đỡ được
  4. Giới hạn, nói thẳng
  5. Cái rút ra

Cái hỏng

Luồng là login → search → search-by-id → prebook → book → cancel, và book là bước tốn tiền. Nó bắn sang supplier, supplier tạo booking, rồi đâu đó trong mấy giây sau đó client bên mình hết kiên nhẫn — read timeout, một con proxy bỏ cuộc, một cái pod bị reschedule giữa request. Booking thì có thật. Response thì không.

Client sau đó làm đúng cái việc hợp lý duy nhất nó có thể làm: gọi lại. Nó vẫn đang cầm cái composite offer ID đã gửi lần đầu — state giữa các bước nằm ngay trong ID đó, nên phía mình chẳng mất gì cả — và gửi lại đúng request cũ. Supplier nhận được một yêu cầu đặt phòng đúng chuẩn trên một rate còn chỗ, và làm đúng việc của nó. Một khách, một phòng, hai xác nhận, và hai chính sách huỷ mà không chắc cái nào cũng miễn phí.

Không ai viết sai dòng nào. Bên mình timeout đúng, client retry đúng, supplier book đúng. Cái lỗi nằm ở khoảng trống giữa ba hành vi đều đúng.

Hai câu trả lời không dùng được

Câu được đưa ra đầu tiên trong hầu hết buổi review là transaction: bọc book lại, không có response thì rollback. Nó không sống nổi qua câu đầu tiên. Booking đâu có nằm trong database của mình — nó nằm trong hệ thống của người khác, do chính lệnh mình đã bắn đi tạo ra. Transaction là lời hứa mà storage engine của mình đưa ra về chính storage engine của mình, còn nửa đắt tiền của book thì xảy ra bên ngoài đó. Rollback xong thì local state sạch sẽ, kèm một booking sống mà không ai bên mình biết — tệ hơn hẳn, vì giờ cái booking thừa đó vô hình với cả mình.

Câu thứ hai là cái verb: làm endpoint idempotent đi, như PUT vẫn được kỳ vọng. Nhưng method chỉ nói về việc handler của mình làm gì khi bị gọi hai lần. Nó không nói gì về việc supplier làm gì, mà booking thì nằm ở chỗ supplier. Nếu việc của handler là đặt một đơn hàng trong hệ thống của người khác, thì hai lần gọi là hai đơn hàng, trừ khi có thứ gì đó cụ thể chặn cái thứ hai lại.

Hai câu trả lời cùng mắc một lỗi: chúng coi biên của process là biên của tác động.

Cái thật sự đỡ được

Chỗ nào supplier có nhận idempotency key — chỗ gọi là client reference, chỗ gọi là transaction ID — thì mình sinh key một lần cho mỗi lần đặt, và mọi lần retry của lần đặt đó đều gửi đúng giá trị ấy. Cơ chế dedup của họ làm phần còn lại: call thứ hai trả về đúng booking đầu tiên chứ không tạo thêm. Cái này đáng để hỏi ngay trong giai đoạn certification, lúc còn có người bên đối tác ngồi trả lời.

Chỗ nào supplier không có trường đó — mà trong khoảng 90 tích hợp thì khá nhiều cái được thiết kế từ trước khi ai đó nghĩ tới chuyện này — thì hàng phòng thủ phải nằm ở phía mình, và phải được ghi trước khi call bắn đi. Một row cho lần đặt này: reference của mình, cái offer, trạng thái in-flight, một cái timestamp. Rồi mới gọi supplier. Rồi row chuyển sang confirmed hoặc failed kèm câu trả lời của supplier. Một cú retry rơi vào lúc row vẫn còn in-flight thì không phải một lần đặt mới; nó là câu hỏi thứ hai về một lần đặt đang diễn ra, và phản ứng đúng là đi tìm xem cái đầu tiên ra sao, chứ không phải bắt đầu cái thứ hai.

Thứ tự là thứ gánh cả cơ chế này. Ghi row sau khi gọi thì đúng cái cửa sổ timeout — cửa sổ mà bug này sống trong đó — lại là cửa sổ không có dòng ghi nào.

Giới hạn, nói thẳng

Đi tìm xem cái đầu tiên ra sao chính là reconcile, và reconcile thì cần một chỗ để hỏi: một lệnh retrieve hay tra cứu booking phía supplier, tra theo thứ mình đã gửi đi. Không phải supplier nào cũng có. Bên flight thì retrieve nằm sẵn trong contract mình kỳ vọng; chỗ khác thì tuỳ supplier, và chỗ nào không có thì một row in-flight chỉ nói với bạn rằng có thể đang tồn tại một booking thừa, hết. Phần còn lại là một con người ngồi đọc provider log — mọi call đều được lưu cả request lẫn response — rồi huỷ tay một booking.

Chuyện này thật sự xảy ra bao nhiêu lần trên production thì mình không biết. Không có gì đếm số lần timeout-rồi-retry hay số booking trùng thành một tỉ lệ cả; những ca mình biết đều đến qua ticket support, tức là mẫu của những ca có người để ý thấy. Con số thiếu đó cũng là một phần của vấn đề — kiểu hỏng này không có cái chuông nào tự kêu.

Cái rút ra

Checklist design review ở trang decisions của site này hỏi thẳng: book có idempotent khi client retry không? Nó là câu hỏi thường trực chứ không phải một bản fix một lần, vì câu trả lời khác nhau theo từng supplier.

Một thao tác có tác động đi ra khỏi process của bạn thì không thứ gì bên trong process của bạn làm nó an toàn được. Không transaction, không verb, không try/catch. Hoặc phía bên kia chịu dedup, hoặc bạn giữ một dòng ghi mà họ không nhìn thấy rồi tự đi đối soát — và chỗ nào không làm được cả hai thì nói ra trước khi go-live, thay vì biết được từ một người khách đang cầm hai cái xác nhận.