+155

Distributed transaction - Transactional outbox pattern

© Dat Bui | Buy me a coffee & give your kindness to the world

Với bài trước, SAGA pattern đã giúp Thảo - một SA nhiều năm kinh nghiệm xử lý bài toán data consistent - distributed transaction với MSA một cách ngon lành. Thế nhưng vẫn còn một vấn đề đang bỏ ngỏ ở cuối phần trước cần giải quyết.

Trong trường hợp sau khi thực hiện xong business logic và persist data xuống database thành công nhưng publish message fail thì cần xử lý thế nào (application crash hoặc lost connection)?

Cùng đi tìm câu trả lời trong bài viết này nhé. Gét gô!

1) Transactional outbox pattern?

Mình sẽ lấy ví dụ bài trước để những bạn chưa đọc cũng có thể hiểu được. Tuy nhiên mình khuyến khích nếu những ai chưa đọc hoặc chưa hiểu về SAGA pattern thì nên đọc trước khi tiếp tục nhé.

Thảo hiện tại là Solution Architect của team IT thuộc chuỗi cửa hàng Pizza và những con bug không thể fix. Thời buổi dịch bệnh khó khăn, chủ doanh nghiệp muốn Thảo thiết kế hệ thống bán hàng online để tăng doanh số. Vì đã có thâm niên chục năm vén váy.. à nhầm.. vén tay áo.. nên Thảo rất nhanh apply ngay Microservices Architecture với 4 service chính và sử dụng SAGA pattern để handle distributed transaction:

  • Order service.
  • Payment service.
  • Restaurant service.
  • Delivery service.

...

Sau khi Order service tạo order thành công, publish event ORDER_CREATEDPayment service consume event này để xử lý tiếp. Payment service thực hiện business logic, commit transaction xuống database và thực hiện publish event ORDER_PAID. Tuy nhiên đúng lúc publish event thì... toạch. Application crash, và thế là mất toi message, order tắc ở đấy và khách hàng chờ dài cổ vẫn chưa thấy pizza đâu.

Ok, hãy thử đóng vai Thảo và nghĩ cách giải quyết bài toán này trước khi đến phần tiếp theo nhé...

1.1) Problem

Trước khi đi tìm giải pháp thì cần hiểu chính xác vấn đề cần giải quyết là gì:

  • Mất connection đến Message broker dẫn đến việc không publish được event.
  • Có thể mất message nếu application crash/restart.
  • Cần đảm bảo tính atomic và consistent với 2 method persist() và publish(). Hiểu nôm na là nếu persist() thành công thì việc publish cũng phải thành công (thành công được hiểu là event được publish đến Message broker). Nếu không publish được, hay nói cách khác là lost event thì persist() cần được rollback.

1.2) Solution

Ok, đã clear được problem, thử nghĩ solution để giải quyết từng thứ xem thế nào nhé.

  • Nếu mất connection và không publish được event thì một cách đơn giản có thể nghĩ đến là retry. Những thứ cần quan tâm là retry trong bao lâu, bao nhiêu lần?
  • Cần store message ở đâu để đảm bảo nếu application crash/restart thì vẫn có message để retry: file, database, distributed storage?
  • Nếu để đảm bảo vừa atomic mà vừa consistent thì chỉ có nhồi vào chung transaction thôi. Có nghĩa là message/event cần được lưu và database và xử lý chung một transaction với business logic?

Từ những idea trên, liệu bạn đã mường tượng ra tổng thể solution cần thực hiện là như thế nào chưa? Đi từng bước một nhé.

Create new table in database

Bước đầu tiên, tạo một table mới đặt tên là outbox_table. Khi xử lý business logic, bên cạnh việc update các table liên quan, ta insert thêm một record vào table outbox_table, đương nhiên record này chứa những thông tin cần thiết để publish event (order_id, state) thậm chí có thể lưu luôn event message.

Như vậy vấn đề về atomicconsistent đã được giải quyết một cách triệt để và đơn giản bằng cách sử dụng local transaction.

Create relay publisher

Như vậy các event được lưu trữ tại database, đảm bảo đầy đủ các tính chất quan trọng:

  • Consistency: nếu store business data thành công thì mới có event, và ngược lại.
  • Durability: một khi transaction commit thành công thì không thể lost message.
  • Message ordering: message lưu trữ tại database theo thứ tự rõ ràng để đảm bảo khi publish message không có chuyện message đến sau lại publish trước.

Bây giờ đã một đống message đang chờ để publish, thì tất nhiên phải có publisher làm nhiệm vụ check xem đang có message nào không, nếu có thì publish. Tất nhiên vẫn không thể tránh trường hợp lost connection đến Message broker, vậy nên việc publish cần có cơ chế retry, và update message state sau khi publish thành công để tránh publish nhiều lần.

Pattern này là polling publisher. Đại khái publisher sẽ query liên tục (theo chu kì) đến outbox_table để tìm event và publish.

Một câu hỏi được đặt ra, vậy publisher này nên là một service độc lập hay là một service (class) nằm trong payment-service? Vì sẽ có tình huống payment-service crash còn nhiều event trong outbox_table đang chờ được publish. Nếu relay-publisher thuộc payment-service thì lúc này message không được publish. Nhưng nếu relay-publisher là service độc lập thì nó cần access vào outbox_table của payment-service, có vẻ không hợp lý?

Thử suy nghĩ và đưa ra câu trả lời cho riêng mình nhé. Mình sẽ đưa ra ý kiến cá nhân ở phần cuối.

Và đương nhiên, solution này là chính là transactional outbox pattern.

Alternative solution

Về cơ bản solution trên đã giải quyết được problem đưa ra ở đầu bài nếu sử dụng SQL (RDBMS). Tất nhiên, nó cũng có những nhược điểm cần chú ý:

  • Nếu application sử dụng NoSQL thì cần cẩn thận vì không phải NoSQL nào cũng có thể support pattern này (do không đảm bảo tính chất quan trọng của transaction).
  • Extra call đến database để check có event nào cần publish không.

Quay lại vấn đề ban đầu, mấu chốt quan trọng nhất để giải quyết bài toán ở chỗ cần biết chính xác transaction cho business logic đã được commit thành công chưa để thực hiện việc publish event. Việc build event message hoàn toàn có thể thực hiện dựa trên business data... nhưng tất nhiên chẳng ai làm thế cả 📛.

Vì vậy có một biến thể khác để implement relay publisher, và cũng để giải quyết 2 vấn đề trên là apply transaction log tailing pattern.

Nếu bạn đã làm việc MySQL thì chắc hẳn đã nghe đến binlog, hoặc nếu quen thuộc với PostgresWAL. Có thể hiểu đơn giản rằng transaction log giống như hộp đen của máy bay, lưu trữ tất cả lịch sử thay đổi dữ liệu của database. Khi dữ liệu bị thay đổi thì database cần lưu trữ các thay đổi đó vào log file. Và việc đọc log file này có thể giúp chúng ta biết transaction nào được commit, data nào được thay đổi. Từ có có thể build event để publish đến Message broker.

Mình không đi quá chi tiết vào các pattern này tránh loãng bài viết. Nếu có nhu cầu hãy để lại comment để mình biết và giải thích kĩ hơn và có những so sánh trong bài viết tiếp theo nhé.

2) Case study: Notification service

Nhờ apply transactional outbox pattern mà Thảo đã gồng gánh quán Pizza và những con bug không thể fix qua bể khổ đầy đau thương. Quán làm ăn ngày một phát đạt, số lượng khách hàng tăng chóng mặt. Vì vậy thời gian chuẩn bị đồ tăng lên đáng kể.

Tránh làm sao được, đấy là vấn đề của nhân sự, của nhà bếp rồi, chứ đâu còn là của software để mà Thảo có thể vén váy.. à nhầm vén tay áo quẩy tiếp.

Nhưng mà lương tâm nghề nghiệp thôi thúc Thảo phải làm điều gì đấy, chứ không thì nhận đồng lương cũng xót xa lắm. Thảo liền bánh vẽ ngay một feature mới: thông báo trạng thái order đến khách hàng thông qua các kênh như email, sms, phone call... Thế nhưng không phải khách hàng nào cũng cần thông báo, có ông muốn nhận thông báo, có bà lại không thích. Thế phải để cho khách hàng chủ động chọn lựa thông báo khi order.

Mặc dù feature này cũng không giúp cải thiện tốc độ nướng bánh của đầu bếp, nhưng ít nhất khách hàng cũng biết order của họ được thực hiện đến bước nào.

Thực tế chẳng ông nào rảnh rỗi thế đâu, mình phải bịa ra để có cái còn viết tiếp ý mà.

Ok, cùng xem Thảo biểu diễn nhé. Gét gô!

Design & Flow

Đầu tiên cứ là phải phân tích requirement xem cụ tỉ dư lào và draft high level design.

Coi như việc phân tích đã xong nhé 😹.

Thảo nhận ra ngay việc cần làm là tạo ra Notification platform với mục đích chuyên để gửi thông báo đến người dùng thông qua các kênh khác nhau. Và một điều quan trọng là chỉ gửi đến những người đăng kí nhận thông báo.

Như vậy có thể tạm hình dung ra 3 components chính trong bài toán này:

  • Application: tất nhiên là notification-serivce rồi.
  • Database: MySQL, Postgres... để thực hiện được transactional outbox pattern.
  • Subscriber: khách hàng muốn nhận thông báo.

Đọc tiếp flow bên dưới kết hợp với hình bên trên để hiểu hơn flow nhé:

  • Đầu tiên, khi client order sẽ có checkbox để lựa chọn việc có nhận thông báo hay không, nhận qua hình thức nào. Nếu có thì sau đó order-service sẽ gửi request tới notification-service để đăng kí nhận thông báo. Ví dụ thông qua HTTP POST /subscribe.
  • Sau đó điều hướng đến SubscriptionService (class) để thực hiện business logic. Store thông tin vào subscription_table, có thể là một hoặc nhiều table khác, mình chỉ vẽ đại diện một table.
  • Sau khi nhà bếp nhận thực đơn, hệ thống muốn gửi thông báo trạng thái order đến người dùng. Lúc này order-service hoặc restaurant-service gửi message đến notification-service. Notification-service apply transactional outbox pattern như hình trên, store business data vào notification table và outbox message vào notify_outbox table.

Tiếp theo và việc publish notification đến người dùng, hiện thời có 3 channel là sms, email, voice call tương ứng với 3 relay publisher. Mỗi publisher sẽ chủ động monitor message của riêng mình để publish đến địa chỉ đích.

Với design này có thể dễ dàng thêm các publisher một cách độc lập, dễ dàng scale. Client cũng dễ dàng trong việc lựa chọn việc nhận thông báo, và nhận qua hình thức nào.

Một nước cờ quá hoàn hảo, quả không hổ danh Thảo SA. Một tràng vỗ tay dành cho Thảo.

3) Limitation

Tất nhiên rồi, chẳng có cách nào là hoàn hảo, transactional outbox pattern cũng bá đạo thật đấy nhưng vẫn có nhược điểm nhất định mà ta cần nắm rõ để xử lý bài toán cho tốt, cho triệt để.

  • Duplicate event: rất khó để đảm bảo việc message delivery là exactly once. Vấn đề publish message thành công và chưa kịp update lại vào database (application crash) là chuyện hết sức bình thường. Do vậy đầu consume cần đảm bảo được việc có thể xử lý duplicate message. Hay nói cách khác là cần implement idempotent consumer.
  • Near real-time: chắc chắn là rất khó để đạt đến trạng thái real-time application. Vấn đề là ta có chấp nhận có độ trễ không và độ trễ là bao nhiêu thì chấp nhận được.

After credit

Quay lại câu hỏi ở phần đầu publisher nên là một service độc lập hay là một inner-service?

Theo quan điểm cá nhân, nó sẽ phụ thuộc vào bài toán cần giải quyết là gì, vấn đề có phức tạp hay không, yêu cầu letancy thế nào, có cần mở rộng trong tương lai không?

Ví dụ về notification-service phía trên, chắc chắn là việc chia thành các service độc lập là hiệu quả hơn. Trong trường hợp thêm một channel mới ta chỉ việc implement service mới mà không cần sửa code cũ. Nó giúp việc scale dễ dàng và bớt tốn kém. Chỉ có thêm vấn đề nho nhỏ là cần monitor thêm chính service đó. Và như mình nói, vấn đề nho nhỏ nên có thể coi là không thành vấn đề.

Reference

Reference in series https://viblo.asia/s/P0lPmr9p5ox

© Dat Bui | Buy me a coffee & give your kindness to the world


All rights reserved

Bình luận

Đăng nhập để bình luận
Avatar
@ThangNM
thg 5 18, 2022 4:40 SA

lúc nào cũng hóng và đọc kỹ càng những bài viết của a. hy vọng a ra thêm nhiều bài viết bổ ích về microservice. cảm ơn a

Avatar
@ThangNM
thg 5 18, 2022 4:42 SA

solution của Thảo ở case study: tạo ra 3 cái publisher khác nhau. đó liệu có phải side-car pattern trong designing distributed system đúng k a?

Avatar
@datbv
thg 5 21, 2022 3:47 SA

k em ơi, em đọc bài này để hiểu side-car nhé

Avatar
@hieutm
thg 5 20, 2022 3:14 SA

bài hay quá ạ, cảm ơn a

Avatar
@panchamimohan
thg 5 30, 2022 5:10 SA

a cho em xin thông tin job remote với, cảm ơn a

Avatar
@Kerituni12
thg 6 30, 2022 8:49 CH

Hy vọng a ra các bài tiếp theo ạ

Avatar
@TienHa
thg 7 7, 2022 2:59 SA

Hello anh , cảm ơn anh về những chia sẻ rất hay về SAGA nhưng em muốn hỏi anh chút về Outbox ạ mong anh rep , hí hí

  • Trong bài toán của anh nếu là đến đoạn payment xử lý cắt tiền xong gửi msg cho order thì bị lỗi thì bên order ko nhận dc msg nên ko update dc status nên mình đã xử dụng outbox , chỗ này theo em chia làm 2 trường hợp
  • TH1 : hàm của anh có đánh transactional thì mà giả xử trong hàm đó xảy ra bất kì 1 lỗi throws exception nào thì nó không update status của payment dc và cũng ko lưu data xuống outbox để publish msg dc ạ -> cũng mất msg ( expect của mình ở đây là phải trả về msg FAILED )
  • TH2: hàm của anh không đánh transactional thì oke vấn đề ở TH1 dc giải quyết = cách try catch nó , nhưng vấn đề khi ko đánh transactional xảy ra đó là nó ko rollback data dc tại service payment . Nhưng thế thì cũng có cách tự rollback thủ công nhưng vấn đề em băn khoăn ở đây là nó đã save data xuống những bảng nào để mình có thể rollback ạ , ví dụ em có đoạn code sau trong 1 function ạ dòng 1 : xảy ra exception dòng 2 : save data vào database table payment entity ở đây mình có thể trycatch để gửi lại msg FAILED cho order nhưng mình cũng phải biết nó đã save ở đâu mà còn rollback anh ạ nếu bên payment service vẫn lắng nghe msg FAIL từ order service gửi lại để rollback thì em nghĩ có vẻ sẽ xảy ra lỗi khi mà thực sự data chưa dc save vào table payment mà mình vẫn cứ update thì ko hợp lý lắm Vấn đề của em cũng hơi dài rồi , cảm ơn anh và mong anh rep ạ 😄
Avatar
@datbv
thg 7 7, 2022 8:20 SA

hi em,

Anh hiểu câu hỏi của em là sau khi order-service gửi event sang payment-service yêu cầu thanh toán, payment-service thực hiện business nhưng bị exception (app crash/lost connection to database....). Lúc này xảy ra tình huống mất message như em nói, trong khi expect next event SUCCESS/FAIL. Trường hợp này xử lý thế nào? Có đúng em định hỏi thế này không?

Em đọc bài trước của anh phần handle exception nhé. Vấn đề của em nói nó không phụ thuộc vào transaction outbox nữa mà nó là vđề của việc handle business exception. Có 2 cách: 1 là retry và 2 là cancel. Nhưng chung quy lại nó sẽ là vấn đề của việc event phải lưu ở đâu để đảm bảo k mất event, để khi exception xảy ra (app crash, lost connection...) thì hệ thống vẫn consume lại đc event đó để xử lý tiếp. Nên vấn đề sẽ chỉ còn là việc event được lưu ở đâu để đảm bảo 2 tính chất: ordering và durability.

Cái này thì nó lại là bài toán khác không liên quan đến Outbox. Nên mình sẽ quay về xử lý việc store event nhé, nếu là anh anh sẽ sử dụng Kafka làm message broker vì nó support việc replay message trong trường hợp exception. Em có thể đọc thêm series Kafka của a để hiểu rõ hơn. Đại khái là ở TH1 như em nói thì mấu chốt là cần consume lại chính event đó để retry -> đã đc giải quyết = Kafka.

Còn TH2 nếu em dùng transactionless thì việc rollback thế nào em phải handle = code. Em cần implement state machine hoặc một cách nào đó để lưu được state hiện tại của hệ thống, dựa trên đó em có rollback phù hợp. Nhưng anh thì không khuyến khích cách này.

Avatar
@TienHa
thg 7 7, 2022 8:56 SA
Avatar
@TienHa
thg 7 7, 2022 9:33 SA

@datbv Em cảm ơn anh em đã hiểu rồi ạ !

Avatar

e có câu hỏi như này ạ. Ở chỗ paymentService , sau khi thực hiện bussiness thanh toán tiền xong -> thì lưu thông tin thanh toán vào DB và đồng thời lưu vào out_box table (cả 2 thằng save này đều ngon nghe). Thì ở chỗ này tiếp theo a sẽ publish message lên kafka (xem như là mình đã chọn kafka làm message broker) để thằng relay publisher sẽ consumes. hay là a sẽ tạo schedule tại relay publisher để quét những message đã được lưu ạ. Thanks a vì những bài viết hay như thế này ạ.

Avatar
@datbv
thg 8 11, 2022 2:49 SA

hiện tại có 3 cách để làm em nhé:

  1. polliing publíher: chính là relay publisher ở trên, nó hiệu quả với bài toán k cần real-time, và lượng event ít.
  2. transaction log tailing: e có thể tham khảo debezium nhé, idea là nó read file transaction log để build event
  3. publish id: e publish trước event id sang 1 service khác, service khác đó sẽ read data từ db với id đó để publish. phải impl thêm retry hoặc cancel eventid...
Avatar

@datbv thằng debezium kiểu nó track dữ liệu từ DB a nhỉ. e thấy như thế thì thằng kafka connector cũng tương tự. hoặc là goldengate của oracle nhưng mà nó vẫn có độ trễ nhất định. Thằng publish id không biết có real-time được k a (thấy tên có vẻ có 😄)

Avatar
@datbv
thg 8 11, 2022 5:18 CH

@vietronaldo23w mấy thằng đó toàn dựa trên idea changed data capture.

publish-id: có e nhé trong trường hợp k có lỗi -> nó cũng chỉ phù hợp 1 vài bài toán nhất định. trick của nó là trong khoảng tgian publish id sang thì transaction đã đc commit r -> lúc query là đã có data, còn k thì retry hoặc chờ abort event thôi

Avatar

À e có thêm 1 thắc mắc nữa ạ. Ví dụ e sử dụng Orchestration -> sau khi e thanh toán xong, cộng trừ tiền các kiểu cho khách rồi, lưu vào DB luôn rồi, khách hàng đã thấy trên máy mình đã bị trừ tiền rồi.(tức là transaction ở paymentService đã được commit, và đóng rồi) -> sau đến nhà bếp service thì hết nguyên liệu thì nhà bếp bắn ra lỗi cho orchestration -> sau đấy orchestration trigger cho paymentService là không có nguyên liệu làm , mày trả lại tiền cho người ta đi -> thì chỗ này thằng paymentService phải lấy thông tin đâu ra để rollback lại ạ. (vì e nghĩ là paymenService và nhà bếp service sẽ là 2 localTransaction khác nhau). Liệu lúc save hay update dữ liệu có nên tạo ra 1 bản backup hay không -> nếu tạo thì sẽ có phải là bảng nào nằm trong vùng quản lý orchestration cũng phải tạo 1 bảng backup hay không. E cảm ơn.

Avatar
@datbv
thg 8 11, 2022 2:51 SA

đọc bài sau của a để biết cách sắp xếp transaction hợp lí nhé -> paymentService nên là pivot transaction. tất cả các event e đều cần có correlation id nhé. ví dụ với bài này correlation_id là order_id. Khi lưu data e phải lưu cả id này cùng record -> khi nhận event e có c_id đó r, bản thân thông tin rollback đã store trong record đó r, vấn đề là làm cách nào tìm đúng record -> c_id.

Avatar

@datbv e có xem bài sau rồi ạ. thì bài toán của anh ở đây là đang thay đổi trạng thái và thằng order ở đây là Compensable transaction. Vậy có trường hợp nào trong Pivot transaction có >= 2 service không ạ. E nghĩ là có chứ nhỉ . Nếu có thì lúc này mình muốn rollback với các record là chữ hoặc số bất kì. (nó không còn chỉ là một vài trạng thái nữa) -> thì lúc này nếu e chỉ lưu correlation id là id của bảng trong service thì làm sao để e có thông tin để rollback lại khi mà nó đã được commit rồi ạ.

Avatar
@datbv
thg 8 11, 2022 5:21 CH

@vietronaldo23w có nhưng mà hiếm lắm e ơi. lúc đấy có thể làm theo cách của e là có tt rollback, nhưng nên ở table khác chứ k phải trong record đó, nguyên nhân để delete cho nhanh. ngoài ra e có thể apply event source...

Avatar
@nel.tu
thg 9 26, 2022 4:59 SA

bài viết hay! mình có ý kiến ở "outbox publisher" nên đặt chế độ send acks=all, đảm bảo kafka xác nhận copy đầy đủ tới các replica trong cụm.

Avatar
@mynamebvh
thg 10 27, 2022 8:56 CH

Em có một câu hỏi không liên quan lắm trong bài viết. Trong microservice mỗi service sẽ có 1 db riêng. Đôi khi mình cần join bảng để lấy dữ liệu, nhưng giờ mình tách nó thành các db riêng rồi. Vậy trong microservice mình xử lý vấn đề ý như thế nào hả anh. Anh cho em xin keyword để research với ạ. Em cảm ơn

Xem thêm (4)
Avatar

@mynamebvh Chủ đề này khá hay có lẽ tới đây mình sẽ viết một bài chi tiết cách mà dự án thực tế của cty mình đang áp dụng như thế nào. Nếu bạn hứng thú có thể đăng ký mình để đón xem nhé.

Avatar
@mynamebvh
thg 10 28, 2022 4:45 SA

@Clarence161095 Okay anh 😁😁

Avatar
@hoangduc02011998
thg 11 17, 2022 8:58 SA

hi anh, anh cho em hỏi anh đang vẽ workflow bằng tool nào v ạ?

Avatar
@Quangnv34
thg 1 28, 2024 5:52 CH
Avatar
@MinhDrake
thg 4 30, 2024 7:01 SA

Về cách giải quyết cho duplicate event thì trong scenario thực tế tụi em gán cho nó 1 unique id ( or flag) từ đó make sure consumer không thể consume message cùng nhau or we can call idempotent consumer. Không biết nó có giống với trường hợp anh đề cập không ạ ?

Avatar
+155
Viblo
Hãy đăng ký một tài khoản Viblo để nhận được nhiều bài viết thú vị hơn.
Đăng kí