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

Một contract, hai mô hình thực thi

· 3 phút đọc

2 model · 1 contract

Search hotel, flight và car chạy bất đồng bộ — queue, mỗi supplier một job, progress fraction, deadline 30 giây. Tour và transfer trả lời đồng bộ trong một process duy nhất. Cùng một contract API công khai phủ lên cả hai, và bài học là vì sao nó buộc phải giữ nguyên như thế.

  • thiết kế api
  • kiến trúc
  • async
Nội dung
  1. Hai cỗ máy dưới một mái nhà
  2. Caller nhìn thấy gì
  3. Sơ sẩy thì lộ ra cái gì
  4. Cái luật

Hai cỗ máy dưới một mái nhà

Cái gateway mình làm bán năm dòng sản phẩm: hotel, flight, tour, transfer, car hire. Bên dưới thì chúng không chạy giống nhau.

Search hotel, flight và car là bất đồng bộ. Một request search thả xuống queue mỗi supplier một job. Mỗi job được một Redis lock canh để chỉ chạy đúng một lần; worker ghi kết quả vào một cache hash dùng chung, còn một meta hash ghi lại supplier nào đã xong. Tầng HTTP không chờ ai cả — nó gom những gì đã về tới thời điểm đó và trả kèm một progress fraction. Trên cái fraction đó là một deadline tuyệt đối 30 giây: tới hạn thì những gì đã về chính là câu trả lời, và fraction thôi không còn ý nghĩa.

Search tour và transfer là đồng bộ. Một server process duy nhất gọi supplier và trả lời ngay trong chính request HTTP đó. Không queue, không worker, không polling. Cầm được response trong tay nghĩa là nó đã đầy đủ.

Không ai ngồi thiết kế hai mô hình cho oách cả. Mỗi dòng tự mọc ra đúng bộ máy mà supplier của nó đòi hỏi, và tới lúc mình vào thì cả hai hình dạng đều đã gánh tải thật.

Caller nhìn thấy gì

Contract công khai giống hệt nhau cho cả năm dòng. Search, rồi search-by-id, rồi prebook, rồi book — cùng một envelope, cùng một hình dạng offer, và state giữa các lần gọi nằm gọn trong một offer ID composite có version (source · contract · hotel · uuid) mà prebook tự decode ra, nên chẳng mô hình nào cần session phía server.

Ở dòng bất đồng bộ, response đầu tiên có thể mới mang một phần supplier và progress fraction dưới 1; caller poll lại đúng endpoint đó cho tới khi fraction chạm 1 hoặc deadline đóng nó lại. Ở dòng đồng bộ, response đầu tiên là toàn bộ câu trả lời và trường progress cũng nói đúng như vậy. Caller chạy cùng một vòng lặp trong cả hai trường hợp — chỉ là với tour và transfer, vòng lặp thoát ngay từ lượt đầu.

Sơ sẩy thì lộ ra cái gì

Hai chỗ rò, và chỗ nào cũng chỉ cách một quyết định lười đúng một bước.

Thứ nhất: một progress fraction chỉ vài dòng chịu trả. Lúc dựng mấy dòng đồng bộ, rất dễ muốn bỏ luôn trường này — "có gì đang chạy dở đâu mà báo?" Bỏ một cái là mọi client đụng tới hơn một dòng đều mọc ra một nhánh riêng: nếu là tour thì đừng đọc progress. Trường đó tốn đúng một hằng số để emit, và mua được một contract không có nhánh rẽ nào bên trong.

Thứ hai, ảnh gương của cái trên: một client cứ poll một dòng vốn đã trả lời xong — hoặc tệ hơn, một client viết cho tour, không bao giờ poll, đem trỏ sang hotel, và ship luôn trang đầu còn dở dang thành kết quả cuối. Cái thứ hai mới là cái độc, vì nó trông giống như nhanh. Không có gì báo lỗi. Khách chỉ thấy ít offer hơn số thực có.

Cả hai chỗ rò là cùng một sự kiện: caller đã biết cỗ máy nào đứng sau endpoint. Chuyện đó xảy ra rồi thì cỗ máy đã thành một phần contract của bạn, bất kể bạn có ghi vào tài liệu hay không, và từ đó về sau đổi mô hình thực thi là vỡ của ai đó.

Cái luật

Contract mô tả câu trả lời, không bao giờ mô tả bộ máy. Progress fraction qua được phép thử đó — "câu trả lời này đầy đủ tới đâu" là thuộc tính của câu trả lời, và một dòng đồng bộ hoàn toàn trả lời thật được là "đủ rồi". Tên queue, cái lock, số worker, chuyện phải-poll-hay-không thì không qua được, và đúng là không cái nào trong số đó xuất hiện trong envelope.

Một khoảng trống nói thẳng: mình chưa bao giờ đo so sánh latency giữa hai mô hình, nên sẽ không nói mấy dòng bất đồng bộ ra kết quả đầu tiên nhanh hơn, dù nghe rất xuôi tai. Cái mình dám nói thì hẹp hơn mà hữu ích hơn — năm dòng, hai cỗ máy, một contract, và không client nào ở bất cứ đâu biết được sự khác nhau.