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.