Sử dụng Event-Driven Architecture để đảm bảo tính nhất quán (Atomicity) trong hệ thống Microservices

Lưu ý

Bài viết này giả định rằng bạn đã nắm được một số khái niệm căn bản của microservices. Nếu chưa thì bạn có thể tham khảo trước bài viết Các khái niệm chính trong microservices

Ngoài ra bạn cũng cần trang bị thêm một số kiến thức nền tảng về Database PostgreSQL đặc biệt là phần Logical Replication, Replication Slots,… Bạn có thể tham khảo ở đây

Vấn đề

Trong kiến trúc microservices của một hệ thống lớn có thể chứa hàng trăm hoặc thậm chí hàng ngàn services cùng hoạt động ở một thời điểm. Tích hợp được các services này có thể làm việc với nhau hiệu quả để giải quyết một nghiệp vụ chính xác và có độ tin cậy cao chính là một trong những vấn đề cốt lõi nhất của kiến trúc microservices. Có bao giờ bạn trăn trở hàng đêm khi gặp tình trạng service A gửi message cho service B nhưng service B lại không nhận được message? Hoặc khi service B nhận được message nhưng đối tượng lại không tồn tại trong hệ thống? Nếu bạn đang nhức đầu đi tìm câu trả lời cho điều trăn trở đó thì bài viết này chính xác là dành cho bạn.

Nguyên nhân

Trong môi trường distributed system, tất cả các vấn đề đều có thể xảy ra. Ví dụ: network chập chờn hoặc không truy cập được, server hết RAM/DISK, service bị crash đột ngột,… Tất cả các vấn đề đó đều có thể dẫn tới một hệ thống có độ chính xác không cao. Đây là một vấn đề cực kì nguy hiểm đặc biệt là trong các hệ thống cần độ chính xác tuyệt đối ví dụ như tài chính hoặc ngân hàng. Lấy một ví dụ cụ thể là user A chuyển cho user B một khoản tiền X. Nếu hệ thống đã trừ tiền của user A nhưng vì lý do nào đó yêu cầu nạp tiền vào user B bị mất thì sẽ gây ra hậu quả rất trầm trọng. Do đó, để xây dựng được một hệ thống có độ chính xác cao, vấn đề gửi/nhận message thành công cần phải được đảm bảo một cách tuyệt đối.

Lấy ví dụ từ bài toán thực tế ở VeXeRe. Ở VeXeRe đang phát triển một hệ thống gửi hàng toàn quốc (GMS) để giúp cho các nhà xe có thể quản lý được việc gửi/nhận hàng hóa của khách hàng ở các Văn phòng nhà xe. Trạng thái của một đơn hàng thông thường sẽ thay đổi theo flow sau: Tạo đơn ở văn phòng gửi => Lên xe vận chuyển => Nhập hàng vào văn phòng nhận => Giao hàng. Ở bước “Nhập hàng vào văn phòng nhận” thì hệ thống sẽ tự động gửi sms cho khách hàng để họ có thể ra văn phòng nhận lấy hàng. Tuy nhiên trong nhiều thời điểm khách hàng lại báo là không hề nhận được tin nhắn dẫn đến hàng hóa của họ bị hư hỏng do hàng vận chuyển là thực phẩm tươi sống. Và bạn cũng có thể đoán được rồi đấy, trong tình huống đó nhà xe phải đền bù toàn bộ số tiền cho khách chỉ vì vấn đề sai sót trong việc gửi/nhận message.

Vậy đâu là nguyên nhân thực sự làm cho message bị mất?

Thông thường, nếu suy nghĩ một cách đơn giản chúng ta có thể thiết kế hệ thống để gửi tin nhắn như sau:

Và pseudo code để implement mô hình trên bằng 2 cách sau:

Cách thứ 1:

Cách thứ 2:

Ở cách thứ nhất, giả sử nếu đã gửi thành công event OrderReceived vào Message Broker nhưng server đột ngột crash hoặc network đứt thì dữ liệu sẽ chưa được cập nhật vào database nhưng khách hàng lại nhận được tin nhắn. Lúc này rắc rối sẽ xảy ra nếu khách hàng tới Văn phòng để nhận hàng nhưng nhân viên sẽ báo là hàng vẫn chưa về tới.

Ở cách thứ hai, giả sử nếu chúng ta đã cập nhật dữ liệu vào database thành công nhưng việc gửi event sang Message Broker gặp vấn đề thì khi đó hàng đã về tới Văn phòng nhưng khách hàng thì không hề nhận được tin nhắn dẫn đến việc hàng có thể bị hư hỏng khi để quá lâu mà không có người nhận.

Giải pháp

Ở VeXeRe, team mình đã giải quyết vấn đề trên bằng cách tiếp cận Transaction Log của Database (team sử dụng PostgresQL). Transaction Log có thể hiểu là một file log mà chỉ có thể append-only vào cuối file và nó lưu lại toàn bộ lịch sử thay đổi của Database. Mô hình hoạt động được mô tả theo hình dưới đây.

Trong mô hình này Transaction Log Miner sẽ đóng vai trò như một Subscriber để listen các thay đổi trên table Order sau đó sẽ publish event vào Message Broker. Để thực hiện được cơ chế publish/subscribe ở bước này chúng ta cần setup một số việc ở Postgres như sau:

CREATE PUBLICATION "order-publication" FOR TABLE "order";

SELECT pg_create_logical_replication_slot(
  'transaction-log-miner'
)

Đầu tiên chúng ta sẽ tạo một PUBLICATION tên là “order-publication” để đóng vai trò như một Publisher và chỉ publish các thay đổi ở table Order (FOR TABLE “order”). Nếu ta muốn lưu lại thay đổi của tất cả các table thì có thể sử dụng “FOR ALL TABLES;”.

Tiếp theo chúng ta sẽ tạo ra một Replication Slot có tên là “transaction-log-miner”. Replication Slot là một đối tượng cung cấp bởi Postgres dùng để quản lý WAL Segments, nó sẽ đánh dấu WAL Segment nào được phép xóa. Thông thường Postgres sẽ định kỳ xóa các WAL Segments không còn sử dụng để giải phóng dung lượng ổ đĩa. Do đó nếu Transaction Log Miner không được gán vào một Replication Slot thì sẽ có khả năng bị mất event do Postgres xóa mất WAL Segments.

Sau khi tạo xong publication và replication slot, chúng ta sẽ bắt đầu listen event trên Transaction Log Miner bằng lệnh SQL sau:

START_REPLICATION SLOT "transaction-log-miner" LOGICAL [LSN] PUBLICATION_NAMES "order-publication"

LSN là Log Sequence Number (tương tự như offset của Kafka) được tạo ra để đánh dấu việc Subscriber đã consume đến vị trí nào của WAL để trong trường hợp Subscriber gặp sự cố thì restart lại sẽ chỉ consume từ LSN gần nhất đã xử lý.

Flow hoạt động của Transaction Log Miner:

Đến đây thì chúng ta sẽ có thể gặp một vấn đề đó là khi đã publish message vào Message Broker nhưng server đột ngột crash hoặc restart nên chưa kịp ACK về cho Postgres. Sau khi Transaction Log Miner khởi động lại thì sẽ đọc lại event cũ. Ở đây chúng ta có 2 trường hợp:

Trường hợp 1: nếu việc nhận một message nhiều lần không gây ảnh hưởng đến hệ thống thì có thể chấp nhận. Ví dụ như khi chúng ta đồng bộ dữ liệu từ Postgres sang Elastic Search để làm một hệ thống full text search. Việc xử lý một message nhiều lần tương đương với việc chúng ta update cùng một dữ liệu lên ElastiSearch nhiều lần với cùng một giá trị. Do đó không làm thay đổi tính đúng đắn của hệ thống.

Trường hợp 2: nếu việc nhận một message nhiều lần gây sai lệch hệ thống thì cần cơ chế chống trùng. Ví dụ: việc nhận message để trừ tiền của khách hàng sau giao dịch chỉ được phép thực hiện duy nhất một và chỉ một lần.

Quay lại trường hợp gửi tin nhắn cho khách hàng sau khi hàng đã đến văn phòng nhận. Nếu không có cơ chế chống trùng thì sẽ gửi sms cho khách nhiều lần cùng một nội dung sẽ gây lãng phí. Để giải quyết vấn đề này, team Vé xe rẻ đã dùng một table Event để log lại toàn bộ các thay đổi của Order theo từng nghiệp vụ. Flow như sau:

Vì sao chúng ta phải tạo thêm một table mới là Event mà không sử dụng table Order? Có 2 lý do:

Lý do thứ nhất: Trong xử lý nghiệp vụ table Order có thể được update nhiều lần dẫn đến sinh ra nhiều message. Cái mình đang quan tâm là khi nào kết thúc nghiệp vụ thì mới được phép gửi sms.
Lý do thứ hai: Không thể định danh được message để có thể áp dụng cơ chế chống trùng (sẽ được đề cập bên dưới)

Table Event dưới database sẽ có data như sau:

id type data timestamp
1 OrderCreated {orderId: 1, …} 2021-08-19T17:00:00.000Z
2 OrderLoaded {orderId: 1, …} 2021-08-19T18:00:00.000Z
3 OrderReceived {orderId: 1, …} 2021-08-19T19:00:00.000Z
4 OrderShipped {orderId: 1, …} 2021-08-19T19:00:00.000Z

Lưu ý ở đây là tất cả các message (event) đều phải được định danh bằng event id.


Đến đây, chúng ta cần thay đổi Transaction Log Miner sẽ subscribe trên publication mới là “event-publication”.

CREATE PUBLICATION "event-publication" FOR TABLE "event";
START_REPLICATION SLOT "transaction-log-miner" LOGICAL [LSN] PUBLICATION_NAMES "event-publication"

Sơ đồ hoạt động khi có thêm table Event

Ở SmsProcessor chúng ta cần bổ sung cơ chế chống trùng bằng cách sẽ tạo 1 table ProcessedEvent có unique key là (processor_id, event_id). Với processor_id là định danh của SmsProcessor.

Table ProcessedEvent dưới database sẽ có data như sau:

processor_id event_id processed_timestamp
SmsProcessor 1 2021-08-19T17:00:00.000Z
SmsProcessor 2 2021-08-19T18:00:00.000Z
SmsProcessor 3 2021-08-19T19:00:00.000Z
SmsProcessor 4 2021-08-19T19:00:00.000Z

Flow xử xử ở SmsProcessor như sau:

Nếu error bắt được là do trùng unique key (processor_id, event_id) thì chúng ta sẽ ack về cho Message Broker và không xử lý gì thêm. Sau đó sẽ tiếp tục nhận message tiếp theo để xử lý.

Như vậy qua các bước thực hiện trên chúng ta đã xây dựng xong một cơ chế gửi/nhận message một cách chính xác và có độ tin cậy cao. Trên đây chỉ là một ví dụ nhỏ thực tế mà team Vé xe rẻ đã giải quyết. Nắm được nguyên lý chúng ta có thể áp dụng được cho nhiều bài toán khác. Các nguyên lý rút ra là:

  1. Chỉ gửi đi message khi đã hoàn tất xong nghiệp vụ (dữ liệu đã được commit vào db)
  2. Message phải được gửi đi ít nhất một lần (at least once delivery)
  3. Một số processor/consumer cần có cơ chế chống trùng message (Idempotent Consumer Pattern)

Bài viết có tham khảo ở một số bài viết sau:

Facebook Notice for EU! You need to login to view and post FB Comments!
%d bloggers like this: