Khi làm backend, chúng ta thường gặp một cách xử lý dữ liệu như sau: server nhận dữ liệu từ message broker hoặc API, xử lý theo quy tắc nghiệp vụ rồi thêm hoặc cập nhật dữ liệu vào database. Sau đó, server tiếp tục gửi dữ liệu đến server kế tiếp qua message broker hoặc API. Cách xử lý này được gọi là dual write, đôi khi còn gọi là multi-write hoặc sync-write.

Vấn đề⌗

Pattern này khá dễ hiểu, vậy vấn đề nằm ở đâu? Các bước xử lý có thể thất bại và khiến dữ liệu giữa các hệ thống không nhất quán. Khi đó, chúng ta có thể phải vò đầu bứt tai debug mà chưa chắc đã tìm ra nguyên nhân.

Dual write thất bại: row đã được lưu vào database nhưng receiver thì không bao giờ được thông báo

Vấn đề xảy ra khi chúng ta đã lưu thành công dữ liệu sau khi xử lý vào database, nhưng lại không gửi được dữ liệu đến message broker hoặc server kế tiếp. Dữ liệu trong hệ thống của chúng ta đã được cập nhật, còn các hệ thống phía sau không hề biết về thay đổi đó. Vì vậy, dữ liệu giữa các hệ thống không còn khớp nhau.

Có nhiều nguyên nhân có thể gây ra lỗi này. Network partition có thể khiến bên nhận không kết nối được. Bên nhận cũng có thể đang ngừng hoạt động vì lỗi hoặc bảo trì. Đôi khi chỉ là lỗi con người, như gửi dữ liệu sai schema đến sai API. Dù nguyên nhân là gì, dữ liệu đã lệch nhau và chúng ta phải tìm cách khắc phục.

Ví dụ về dual write trong e-commerce⌗

Các hệ thống e-commerce thường được chia thành nhiều service với ranh giới trách nhiệm rõ ràng để dễ scale out cả hệ thống lẫn đội ngũ kỹ sư. Payment service hoặc gateway xử lý thanh toán và checkout. Hệ thống quản lý đơn hàng (OMS) quản lý và theo dõi đơn hàng. Hệ thống quản lý tồn kho giúp người bán quản lý sản phẩm, hàng tồn và kho. Search service cho phép người mua duyệt, tìm và lọc sản phẩm theo query, sở thích, lịch sử và các tiêu chí khác.

Checkout dưới dạng dual write: thanh toán đã được lưu nhưng thông báo tới OMS thì thất bại

Sau khi khách hàng thanh toán đơn hàng thành công, payment service phải báo cho OMS biết để đơn hàng được chuyển sang bước tiếp theo. Nếu thông báo thất bại, đơn hàng sẽ bị kẹt. Khách hàng phải chờ, rồi mới nhận ra có vấn đề và liên hệ bộ phận hỗ trợ. Trải nghiệm như vậy rõ ràng không tốt.

Tạo sản phẩm dưới dạng dual write: sản phẩm đã được lưu nhưng search service không được thông báo, nên người mua không tìm thấy nó

Khi người bán tạo thông tin đăng bán sản phẩm mới, hệ thống quản lý tồn kho phải thông báo cho search service. Search service sẽ tạo dữ liệu tương ứng trong database và thực hiện index để tối ưu tìm kiếm, giúp người mua tìm được sản phẩm. Nếu thông báo thất bại, sản phẩm đã đăng sẽ không xuất hiện trong kết quả tìm kiếm. Người bán có thể mất khách hàng và doanh thu, kéo theo ảnh hưởng đến uy tín của sàn trong cộng đồng người bán.

Một số giải pháp không hiệu quả⌗

Giờ chúng ta đã biết dual write là gì và nó có thể gây ra vấn đề nào. Hãy thử xem một vài cách xử lý thường được nghĩ đến, cùng lý do chúng chưa giải quyết được vấn đề.

Khôi phục dữ liệu⌗

Nếu gửi dữ liệu thất bại, chúng ta có thể khôi phục dữ liệu về trạng thái trước đó không? Có, nhưng thao tác khôi phục cũng có thể thất bại. Khi ấy, chúng ta lại phải xử lý tiếp. Chưa kể, muốn khôi phục thì phải lưu trạng thái trước đó.

Nếu chỉ commit vào database sau khi đã thông báo thành công cho các service phía sau thì sao? Như vậy không cần lưu trạng thái cũ nữa. Nhưng chính thao tác commit cũng có thể thất bại.

Theo mình, hướng này sẽ nhanh chóng trở nên rối rắm.

Lưu dữ liệu sau khi gửi⌗

Nếu chỉ ghi dữ liệu vào database sau khi đã gửi thành công đến các service phía sau thì sao? Cách này tương tự việc chỉ commit sau khi gửi đã nói ở trên. Thao tác ghi vào database không được đảm bảo sẽ thành công, nên đổi thứ tự các bước cũng không giải quyết được vấn đề.

Thử lại⌗

Khi có lỗi, thử lại có vẻ là một giải pháp đủ tốt. Nhưng chúng ta nên thử bao nhiêu lần, và chờ bao lâu giữa các lần thử?

Nếu bên nhận ngừng hoạt động lâu, chẳng hạn hơn năm phút, phần lớn cấu hình retry đã bỏ cuộc trước khi bên nhận hoạt động trở lại. Dữ liệu khi đó vẫn không nhất quán. Chúng ta cần một cách xử lý tốt hơn.

Nếu kéo dài thời gian retry bằng exponential backoff thì sao? Cách này có thể hiệu quả. Nhưng nếu chính service của chúng ta crash trong lúc retry, khi chạy lại, nó sẽ mất thông tin về việc đang làm dở. Muốn cách này hoạt động, chúng ta phải lưu lại trạng thái xử lý. Đến đây thì đã gần với lời giải hơn rồi.

Một số giải pháp hiệu quả⌗

Có một số pattern giúp xử lý dual write đúng cách. Bài đã khá dài nên mình chỉ giới thiệu tên và ý chính, chưa đi sâu vào chi tiết. Mình đã bắt đầu tìm hiểu kỹ hơn về chúng trong bài Các giải pháp cho dual write:

  • Transactional Outbox Pattern: Cập nhật dữ liệu nghiệp vụ và lưu message vào bảng outbox trong cùng một database transaction để đảm bảo atomicity. Một process riêng sẽ gửi các message sau đó.
  • Listen to Yourself Pattern: Publish một event mô tả thay đổi dữ liệu mong muốn, rồi consume event đó để thực hiện cập nhật database.
  • Change Data Capture Pattern: Tận dụng đảm bảo durability của database bằng cách đọc transaction log để thu thập các thay đổi và gửi chúng dưới dạng message.

Đọc thêm⌗