Từ monolith đến idempotency: một sợi dây, không phải sáu flashcard

Ngồi ôn phỏng vấn vị trí architect, mình nhận ra lên level không phải viết code giỏi hơn mà thấy được cả hệ thống. Sáu chủ đề từng học vẹt — microservices, 2PC, Saga, CAP, Outbox, idempotency — hoá ra là một sợi dây nhân quả: lời giải này đẻ ra vấn đề kia.

$ git log --oneline --stat
✍️ author: duthaho 📅 date: 30/06/2026 ⏱️ read: ...
from-monolith-to-idempotency.md readonly

Hơn mười năm làm backend, mình khá tự tin với mớ code mình viết ra. Nhưng dạo gần đây ngồi ôn để phỏng vấn vị trí architect, mình ngộ ra một chuyện: lên level không nằm ở chỗ viết code giỏi hơn, mà ở chỗ tư duy được cả hệ thống. Và có một mạch kiến thức, khi ghép đủ các mảnh lại, đẹp như một câu chuyện — nên mình muốn kể lại.

Điều khiến mình tâm đắc không phải từng món riêng lẻ, mà là cách chúng nối vào nhau: mỗi lời giải mình tìm được lại tự tay mở ra một vết nứt mới, buộc phải đi tìm lời giải kế tiếp. Sáu chủ đề mình từng học vẹt như sáu tấm flashcard rời, hoá ra là một sợi dây.

Sợi dây — mỗi lời giải đẻ ra vấn đề kế tiếp tách MICROSERVICES ──mất──▶ TRANSACTION mất TRANSACTION ──cần──▶ SAGA SAGA ──kéo theo──▶ EVENTUAL CONSISTENCY ──phải hiểu──▶ CAP SAGA ──cần phát event an toàn──▶ OUTBOX OUTBOX ──có thể gửi trùng──▶ IDEMPOTENCY

Câu hỏi sai và câu hỏi đúng

Mọi chuyện bắt đầu từ câu kinh điển: "có nên tách monolith ra microservices không?" Mình của ngày xưa sẽ trả lời kiểu "tách đi cho hiện đại". Mình của bây giờ hiểu rằng câu hỏi đúng phải là: "vấn đề thực sự đang là gì?"

Vì microservices thật ra chỉ giải quyết gọn đúng ba thứ:

  • Scale độc lập — phần nào nóng thì nhân bản riêng phần đó, không phải bê cả khối lên.
  • Release nhanh hơn — mỗi đội deploy phần của mình mà không phải xếp hàng chờ cả monolith.
  • Cô lập lỗi — một service sập không kéo sập phần còn lại.

Nếu cái đau của bạn không nằm ở ba chỗ đó, thì nhiều khi một modular monolith gọn gàng — vẫn một khối triển khai, nhưng module hoá rạch ròi bên trong — còn rẻ hơn nhiều, mà tránh được cả núi phức tạp phía sau. Architect là người bắt đầu từ problem, không phải bắt đầu từ solution.

Vết nứt mới: giả sử cái đau của bạn đúng là ba thứ kia và bạn tách thật. Tách xong, bạn mất luôn một thứ mà hồi monolith bạn được dùng miễn phí — cái transaction.

Mất cái transaction được cho không

Hồi còn monolith, một đơn hàng là chuyện nhỏ: trừ tiền + trừ kho + tạo đơn nằm gọn trong một transaction của database. Lỗi ở bất kỳ bước nào, ROLLBACK một phát là sạch sẽ, như chưa từng có gì xảy ra.

Tách microservices ra, mỗi service ôm một database riêng (điều này gần như bắt buộc, nếu không thì tách làm gì). Giờ khách đặt hàng mà trừ tiền thành công nhưng trừ kho fail thì sao? Chẳng lẽ để khách mất tiền oan? Biết là phải xử lý, nhưng lúc đầu mình nghĩ mãi không ra cách nào cho đẹp.

Vết nứt mới: cần một kiểu "transaction" trải trên nhiều service và nhiều database. Cái tên đầu tiên hiện ra trong đầu, đúng như trong sách giáo khoa, là 2PC.

2PC — lời hứa hào nhoáng mình phải từ chối

Two-Phase Commit nghe rất hấp dẫn: một coordinator hỏi tất cả các bên "sẵn sàng chưa?" (phase prepare), tất cả gật thì mới "commit đi" (phase commit). Nó hứa nhất quán tức thì — hoặc tất cả cùng thành công, hoặc tất cả cùng không.

Nhưng đào kỹ mới thấy cái giá. Trong lúc chờ tất cả các bên trả lời, mọi tài nguyên liên quan đều bị khoá. Và tệ hơn: chỉ cần ông coordinator chết đúng giữa hai phase, cả đám participant kẹt cứng, ôm lock chờ vô thời hạn, không biết nên commit hay rollback. Nói cách khác, 2PC đánh đổi tính sẵn sàng và khả năng scale để lấy nhất quán tức thì — mà đây lại đúng là hai thứ microservices không thể hy sinh. Thế là loại.

Vết nứt mới: nếu không thể có nhất quán tức thì kiểu khoá-tất-cả, thì phải chấp nhận một mô hình mềm hơn — làm từng bước, lỗi thì lùi lại. Đó là Saga.

Saga — chuỗi giao dịch nhỏ và một đường lùi

Thay vì một transaction to đùng, Saga dùng một chuỗi giao dịch nhỏ, mỗi bước là một local transaction gọn trong một service. Bước nào lỗi thì chạy một giao dịch bù trừ (compensating transaction) để hoàn tác những gì đã làm trước đó — không phải rollback của database, mà là "làm điều ngược lại" ở tầng nghiệp vụ.

Có một mẹo mình thấy cực hay ở khâu thanh toán: đừng trừ tiền ngay, hãy giữ tiền trước (authorize) rồi mới trừ thật (capture) khi mọi bước đều ổn. Nhờ vậy khi một bước sau lỗi, giao dịch bù chỉ cần nhả tiền ra, khách không mất một xu. Bù trừ đẹp nhất là bù trừ không để lại dấu vết.

Saga có hai cách dựng, và đây là phần mình bổ sung được khi đào sâu:

Cách A
Orchestration — có một nhạc trưởng

Một orchestrator trung tâm cầm trịch: gọi service 1, xong gọi service 2, lỗi thì tự điều phối các bước bù. Logic nằm một chỗ nên dễ đọc, dễ trace. Cái giá: orchestrator thành một thành phần phải nuôi, và nếu làm ẩu thì thành điểm chết tập trung. Hợp với quy trình nghiệp vụ phức tạp, nhiều nhánh.

Cách B
Choreography — không ai cầm trịch

Không có nhạc trưởng. Mỗi service làm xong việc thì phát một event, service sau lắng nghe rồi tự làm tiếp. Rất loosely-coupled, không có điểm chết tập trung. Cái giá: khi số bước tăng lên, không ai nhìn thấy toàn cảnh, debug "ai kích hoạt ai" thành ác mộng. Hợp với luồng tuyến tính, ít bên tham gia.

Vết nứt mới: Saga không còn nhất quán tức thì — có những khoảnh khắc tiền đã giữ mà đơn chưa tạo xong, hệ thống tạm thời "lệch". Đó là eventual consistency. Và để hiểu vì sao buộc phải chấp nhận nó, mình phải quay lại một định lý mình từng hiểu sai suốt bao năm.

CAP — chỗ mình hiểu sai suốt bao năm

Ai cũng thuộc lòng câu "chọn 2 trong 3" của CAP (Consistency, Availability, Partition tolerance). Nhưng sự thật là: cái P — mạng bị chia cắt — không phải thứ để bạn chọn. Mạng thì chắc chắn sẽ có lúc đứt. Nên P là thứ bắt buộc phải chấp nhận, và lựa chọn thật sự chỉ còn là: khi mạng đứt, bạn hy sinh nhất quán (C) hay hy sinh tính sẵn sàng (A)?

Và một cú nữa làm mình ngớ người: chữ C trong CAP với chữ C trong ACID là hai thứ hoàn toàn khác nhau. C của CAP nói về việc mọi node nhìn thấy cùng một dữ liệu tại cùng một thời điểm (linearizability). C của ACID nói về việc một transaction giữ nguyên các ràng buộc nghiệp vụ (constraint). Hồi xưa mình cứ tưởng là một.

Mảnh mình đào thêm: PACELC

CAP chỉ nói chuyện lúc mạng đứt. Nhưng Daniel Abadi (2012) chỉ ra phần còn thiếu: kể cả khi mạng hoàn toàn bình thường, bạn vẫn phải đánh đổi mỗi ngày giữa độ trễ và nhất quán. Đầy đủ là PACELC: nếu Partition thì chọn A hay C; Else (bình thường) thì chọn Latency hay Consistency. Muốn mọi node luôn đồng bộ tuyệt đối thì phải chờ nhau — và chờ nhau nghĩa là chậm. Đây mới là phiên bản đầy đủ của câu chuyện đánh đổi.

Vết nứt mới: ok, đã chấp nhận eventual consistency, Saga sẽ chạy bằng cách phát event giữa các bước. Nhưng "phát event" lại giấu một cái bug mà rất nhiều người dính mà không biết.

Dual Write — cái bug ai cũng mắc mà không hay

Service làm xong việc thì phải phát event để báo cho bước sau. Nghe đơn giản. Nhưng ghi vào databasephát event lên message broker là hai hệ thống khác nhau, và không có transaction nào bao trùm được cả hai. Đây là Dual Write Problem.

Khe hở chết người [1] ghi DB ✔ thành công ⟵⟵⟵ CRASH ngay tại đây [2] phát event ✘ chưa kịp gửi → DB nói "đơn đã tạo", nhưng cả hệ thống phía sau không hề biết.

Crash đúng vào cái khe giữa hai bước đó là dữ liệu lệch nhau ngay: hoặc ghi DB rồi mà event bay mất, hoặc event đã gửi mà DB lại rollback. Hai nguồn sự thật, không cách nào buộc chúng đồng ý với nhau bằng một transaction.

Vết nứt mới: cần một cách để "ghi dữ liệu" và "ghi ý định phát event" rơi vào cùng một transaction. Lời giải đơn giản đến bất ngờ: Outbox.

Outbox — nhét event vào chung một transaction

Outbox pattern: thay vì phát event thẳng lên broker, ta ghi cái event vào một bảng outbox nằm chung database, trong cùng một transaction với dữ liệu nghiệp vụ. Một là cả hai cùng được ghi, hai là cả hai cùng rollback — cái khe hở chết người biến mất, vì giờ chỉ còn đúng một transaction trên đúng một database.

Sau đó, một tiến trình riêng đọc bảng outbox ra và phát event đi. Có hai cách làm cái đuôi này, và mình đào ra được sự đánh đổi giữa chúng:

  • Polling publisher — một job định kỳ quét bảng outbox lấy event chưa gửi rồi đẩy đi. Đơn giản, chạy được với bất kỳ database nào. Cái giá: trễ theo chu kỳ quét, và quét nhiều thì nặng DB. Ổn tới tầm vài nghìn event mỗi giây.
  • Change Data Capture (CDC) — công cụ như Debezium đọc thẳng transaction log của database (WAL của PostgreSQL, binlog của MySQL) và stream thay đổi đi gần như tức thì, gần như không đụng tới bảng nghiệp vụ. Crash thì đọc tiếp từ log, không mất event. Cái giá: phải nuôi thêm một pipeline CDC — một bộ phận chuyển động nữa trong hệ thống.

Vết nứt mới: dù polling hay CDC, cái tiến trình gửi event này có thể gửi trùng — nó vừa gửi xong thì crash trước khi kịp đánh dấu "đã gửi", lần sau chạy lại nó gửi lần nữa. Và đây là mảnh ghép cuối.

Idempotency — mảnh ghép khoá lại cả sợi dây

Trong hệ phân tán, gần như không bao giờ đảm bảo được exactly-once. Client retry vì timeout, broker gửi lại để chắc ăn, Saga thử lại bước lỗi, Outbox gửi trùng — đủ kiểu khiến cùng một lệnh chạy nhiều lần. Nếu mỗi lần chạy đều trừ tiền thật thì khách bị trừ hai, ba lần.

Cách xử lý là idempotency key: client gửi kèm một id duy nhất cho mỗi thao tác. Server thấy key lần đầu thì xử lý và lưu lại kết quả; thấy key đó lần nữa thì trả lại kết quả cũ thay vì làm lại từ đầu. Nhờ vậy chạy bao nhiêu lần thì hiệu ứng vẫn đúng một lần — không bao giờ trừ tiền khách hai lần.

Đây cũng chính là lý do Stripe hay PayPal đều có header Idempotency-Key. Mình đào kỹ cách Stripe làm và thấy vài chi tiết đáng học:

  • Mọi request POST đều nhận idempotency key; lần đầu xử lý xong, kết quả được lưu gắn với key đó.
  • Lần sau cùng key, Stripe trả lại đúng response cũ, kèm dấu Idempotent-Replayed: true — không chạy lại logic.
  • Nó còn so khớp tham số của request mới với request gốc; lệch là báo lỗi, để chặn việc vô tình tái dùng key cho thao tác khác.
  • Key chỉ giữ 24 giờ rồi hết hạn — đủ dài để retry an toàn, đủ ngắn để khỏi phình bộ nhớ.

Một chi tiết khép vòng đẹp đẽ: nhớ những giao dịch bù trừ của Saga ở đầu câu chuyện không? Chúng cũng phải idempotent — vì bước bù cũng có thể bị gọi lại nhiều lần. Mảnh ghép cuối quay lại ôm lấy mảnh ghép đầu.


Sợi dây, không phải sáu flashcard

Điều làm mình tâm đắc nhất không phải từng món riêng lẻ, mà là khi xâu chuỗi lại: tách microservices nên mất transaction; mất transaction nên cần Saga; Saga dính eventual consistency nên phải hiểu CAP (và PACELC); Saga cần phát event an toàn nên cần Outbox; Outbox gửi trùng nên phải có idempotency. Không phải sáu chủ đề rời rạc để học vẹt, mà là một chuỗi nhân quả nối liền.

Lên architect không phải là biết nhiều khái niệm hơn. Là nhìn ra sợi dây nối chúng — và hiểu rằng mỗi lời giải đều mang theo cái giá của nó.

Và có lẽ đó là khác biệt thật sự giữa "biết các pattern" và "tư duy hệ thống": không phải nhớ được nhiều hộp công cụ hơn, mà thấy được vì sao cái hộp này lại dẫn tới cái hộp kia. Mình viết bài này phần lớn để tự sắp lại sợi dây đó trong đầu trước buổi phỏng vấn — và hoá ra viết ra mới là lúc mình thật sự hiểu.

Đọc thêm

comments.md