Ở phần trước ta đã tách một chuỗi synchronous thành các event. Nhưng tách code mới là nửa dễ. Khoảnh khắc hai service ngừng dùng chung một database, một câu hỏi lặng lẽ và khó hơn xuất hiện: ai sở hữu dữ liệu, và "đúng" nghĩa là gì khi sự thật được trải ra trên năm service?
Đây là phần của distributed systems khiến cả những kỹ sư dày dạn phải khiêm tốn, vì nó không thật sự nói về công nghệ. Nó nói về việc từ bỏ một sự tiện nghi bạn đã dựa vào suốt sự nghiệp — cái database transaction duy nhất hoặc xảy ra trọn vẹn hoặc không xảy ra gì cả — và học cách xây đúng mà không có nó.
Cái database dùng chung khiến mọi người dính chặt vào nhau
Khi một team tách monolith, lối tắt hấp dẫn là: tách code thành các service, nhưng để tất cả vẫn nói chuyện với cùng một database. Nghe thực dụng. Nó cũng là cách nhanh nhất để xây một distributed monolith — tệ nhất của cả hai thế giới.
Lý do là coupling vô hình. Nếu orders service và billing service cùng đọc ghi bảng orders, thì schema của billing giờ âm thầm trở thành một phần contract của orders. Đổi một cột là bạn làm hỏng một service mà bạn còn chẳng mở ra. Bạn gánh mọi chi phí vận hành của nhiều service mà chẳng có chút độc lập nào.
Một cú tách service chỉ là thật khi mỗi service sở hữu dữ liệu của nó một cách riêng tư. Service khác không được đụng vào bảng của nó — chúng hỏi qua API hoặc phản ứng với event của nó. Nếu hai service dùng chung bảng, bạn chưa xây hai service; bạn đã xây một service với một câu chuyện triển khai rối rắm.
Database per service: quy tắc và hoá đơn của nó
Kỷ luật rất dễ phát biểu: dữ liệu của một service là riêng tư. Lối duy nhất đi vào là qua service đó. Đây chính là thứ mua cho bạn sự độc lập mà cả canh bạc microservices nhắm tới — mỗi team có thể đổi schema, chọn storage, và deploy mà không cần một cuộc họp liên team.
Hoá đơn đến ngay lập tức, và nó đắt:
- Hết JOIN xuyên service. "Cho tôi xem đơn hàng kèm tên khách" từng là một query. Giờ dữ liệu nằm ở hai service và bạn phải ghép nó trong code, cache lại, hoặc nhân bản.
- Hết transaction xuyên service. Bạn không thể gói "thu tiền" và "giữ hàng" vào một
BEGIN / COMMITkhi chúng nằm ở hai database khác nhau. Tấm lưới an toàn biến mất.
Mất mát thứ hai mới là cái lớn. Mọi thứ còn lại trong bài này đều là kỹ thuật để sống mà không có cái transaction xuyên service bạn từng coi là hiển nhiên.
Cú chuyển: từ ACID sang "đúng sau một khoảnh khắc"
Trong một database, bạn có strong consistency: ngay khoảnh khắc transaction commit, mọi người thấy sự thật mới. Xuyên service, bảo đảm đó biến mất. Thứ bạn có thay vào là eventual consistency: hệ thống sẽ đồng ý về sự thật sớm thôi — thường là mili-giây, đôi khi vài giây — nhưng không trong cùng một khoảnh khắc.
Đây không phải lỗi cần sửa; đây là vật lý. Một kết quả nổi tiếng (định lý CAP) nói rằng khi mạng giữa các service hỏng — và nó sẽ hỏng — bạn phải chọn giữa giữ availability và giữ consistency hoàn hảo. Hầu hết hệ thống nghiệp vụ chọn availability và thiết kế quanh một khoảng ngắn nơi, ví dụ, đơn hàng đã tồn tại nhưng điểm thưởng chưa kịp về.
| Strong consistency | Eventual consistency | |
|---|---|---|
| Khi nào nó đúng? | Ngay khoảnh khắc commit | Một khoảnh khắc sau, khi event lan đi |
| Phạm vi | Một database | Xuyên service / xuyên region |
| Bạn trả bằng | Coupling, tranh chấp, khó scale hơn | Những khoảng bất đồng ngắn bạn phải thiết kế trước |
| Hợp với | Tiền trong một sổ cái, một aggregate đơn lẻ | Workflow xuyên service, read model, analytics |
Hãy hỏi nghiệp vụ, đừng hỏi database: "việc này đúng trễ một giây có chấp nhận được không?" Với analytics, search index, thông báo và gợi ý — gần như luôn luôn được. Với "đúng cái thẻ này đã bị trừ tiền chưa?" — hãy thiết kế ranh giới đó sao cho tiền nằm trong strong transaction của một service, và để phần còn lại là eventual.
Bài toán dual-write (và outbox)
Đây là con bug bắt được gần như tất cả mọi người đầu tiên. Một service cần làm hai việc: lưu vào database của chính nó và publish một event. Code ngây thơ làm chúng lần lượt:
// Bug dual-write: hai hệ thống, không có transaction chung
await db.orders.insert(order) // 1) đã commit vào database
await broker.publish("OrderPlaced", order) // 2) nếu crash ngay đây thì sao?
Nếu process chết giữa bước 1 và bước 2, đơn hàng tồn tại nhưng không ai được báo. Payment không bao giờ chạy. Đơn hàng thành một bóng ma. Tệ hơn, bạn không sửa được bằng cách đảo thứ tự — publish trước thì bạn có thể thông báo một đơn hàng chưa từng được lưu.
Cách sửa gọn gàng là transactional outbox. Thay vì publish thẳng, bạn ghi event vào một bảng outbox trong cùng transaction với đơn hàng. Một relay riêng sau đó đọc outbox và publish. Một lần commit, không có khe hở.
// Outbox: đơn hàng và event commit cùng nhau, hoặc không gì cả
await db.transaction(async (tx) => {
await tx.orders.insert(order)
await tx.outbox.insert({ type: "OrderPlaced", payload: order })
})
// Một relay poll outbox (hoặc tail log của DB) rồi publish — retry an toàn.
Vì relay retry, việc giao là at-least-once — chính xác là lý do phần trước nhấn mạnh mọi consumer phải idempotent. Hai ý tưởng này là cặp bài trùng.
Saga: transaction không có nút rollback
Giờ tới ca khó: một hành động nghiệp vụ duy nhất trải qua nhiều service — trừ tiền thẻ, giữ hàng, xác nhận đơn — nơi bước ba có thể lỗi sau khi bước một và hai đã thành công. Không có ROLLBACK nào với tới được ba database. Câu trả lời là saga: chia hành động thành một chuỗi local transaction, và với mỗi bước định nghĩa một compensating action (hành động bù trừ) để hoàn tác nó.
Nếu "xác nhận đơn" lỗi vì hết hàng, saga không tua ngược một cách thần kỳ. Nó chạy các bước hoàn tác theo chiều ngược: trả hàng về kho, hoàn tiền thẻ. Mỗi compensation là một local transaction bình thường — và để ý nó là một sự đảo ngược nghiệp vụ, không phải kỹ thuật. Hoàn tiền không giống "cú trừ tiền chưa từng xảy ra"; khách hàng có thể đã thấy nó trên sao kê. Saga buộc bạn mô hình hoá thất bại như một sự kiện ngoài đời thực — khó chịu, và cũng thành thật hơn.
Saga có hai khẩu vị từ phần trước: orchestrated (một bộ điều phối dẫn các bước, dễ dõi theo) hoặc choreographed (các service phản ứng với event của nhau, decoupled hơn nhưng khó trace hơn). Với bất cứ thứ gì dính tới tiền, hầu hết team thích một orchestrator mà họ có thể nhìn thấy.
CQRS và read model: phục vụ dữ liệu bạn đã làm vương vãi
Database-per-service đã phá vỡ các JOIN của bạn. Vậy làm sao render một dashboard cần dữ liệu từ sáu service? Bạn xây một read model: một bản sao riêng, denormalised, định hình đúng cho màn hình đó, được giữ cập nhật bằng cách nghe event. Đây là nửa "đọc" của CQRS — Command Query Responsibility Segregation — vốn chỉ có nghĩa là model bạn ghi vào và model bạn đọc ra không nhất thiết phải là cùng một model.
- Phía ghi: nhỏ, nhất quán, kiểm tra luật nghiệp vụ.
- Phía đọc: rộng, nhanh, thường eventual, tối ưu cho truy vấn.
CQRS toả sáng khi đọc và ghi có hình dạng hoặc quy mô khác nhau một trời một vực — một catalogue sản phẩm đọc hàng triệu lần và hiếm khi ghi. Nó là quá lố khi một bảng bình thường phục vụ cả hai vẫn ổn, vốn là phần lớn thời gian. Hãy với tới nó để giải một bài toán đọc cụ thể, đừng bao giờ vì nó nghe có vẻ cao cấp.
Event sourcing: giữ lại event, suy ra trạng thái
Tuỳ chọn cao cấp nhất lật ngược việc lưu trữ. Thay vì lưu trạng thái hiện tại rồi ghi đè, bạn lưu toàn bộ chuỗi event dẫn tới đây — AccountOpened, MoneyDeposited, MoneyWithdrawn — và tính số dư bằng cách phát lại chúng. Event trở thành nguồn sự thật; trạng thái chỉ là một ý kiến được cache về chúng.
Lợi ích là thật: một audit log hoàn hảo miễn phí, khả năng hỏi "thứ Ba tuần trước sự thật là gì?", và tự do dựng read model mới từ lịch sử. Cái giá cũng thật không kém — bạn phải versioning event mãi mãi, snapshot cho hiệu năng, và nghĩ lại cách xoá dữ liệu dưới luật riêng tư. Hầu hết hệ thống không nên bắt đầu từ đây.
Event sourcing là một con dao sắc cho một vài domain thật sự có hình dạng event — sổ cái, workflow nặng tính audit, bất cứ thứ gì mà "làm sao ta đến được đây" quan trọng ngang với "ta đang ở đâu." Với mọi thứ khác, một outbox cộng một read model cho bạn phần lớn lợi ích với một phần nhỏ chi phí cả đời.
Chọn, không màu mè
| Bài toán bạn thật sự có | Câu trả lời thành thật |
|---|---|
| Lưu một row rồi báo cho người khác | Transactional outbox + consumer idempotent |
| Một hành động nghiệp vụ xuyên service | Saga với compensating action (ưu tiên orchestrated) |
| Một màn hình ghép dữ liệu nhiều service | Một read model nuôi bằng event (nửa "đọc" của CQRS) |
| Đọc và ghi scale rất khác nhau | CQRS đầy đủ — tách store ghi và store đọc |
| Lịch sử và audit là công dân hạng nhất | Event sourcing — và chấp nhận chi phí cả đời của nó |
| Tất cả vừa trong một service / một DB | Một transaction ACID duy nhất. Đừng phân tán nó. |
Góc nhìn thành thật theo quy mô công ty
- Solo / startup giai đoạn đầu. Một database, transaction thật, không saga. Lợi thế dữ liệu lớn nhất bạn đang có là mọi thứ vẫn có thể strongly consistent trong một
COMMIT. Đừng đánh đổi điều đó lấy một sơ đồ kiến trúc. - Scale-up đang lớn. Khi bạn tách ra vài service đầu tiên, cho mỗi cái dữ liệu riêng và áp dụng outbox ngay ngày bạn publish event đầu tiên. Chỉ đưa saga vào cho một hai workflow thật sự xuyên service và dính tiền. Thêm read model khi một màn hình bắt đầu toả ra thành nhiều lời gọi.
- Doanh nghiệp lớn. Eventual consistency là mặc định và các team thành thạo với nó. Đầu tư chuyển sang công cụ: quản trị schema/version cho event, giám sát saga, và read model như một phần hạng nhất, có chủ sở hữu, của nền tảng. Event sourcing xuất hiện ở vài domain xứng đáng với nó, không phải khắp nơi.
Những điều cốt lõi
- Một cú tách thật sự nghĩa là dữ liệu riêng tư. Nếu các service dùng chung bảng, bạn đã xây một distributed monolith — đủ mọi chi phí, không chút độc lập.
- Bạn đánh đổi transaction ACID lấy eventual consistency. Hỏi nghiệp vụ "đúng trễ một giây có sao không?" — và giữ tiền trong strong transaction của một service.
- Bug dual-write là có thật; outbox sửa nó. Commit event và row cùng nhau, rồi relay — đó là lý do consumer phải idempotent.
- Saga thay rollback bằng compensation. Mô hình hoá thất bại như một sự đảo ngược ngoài đời (một cú hoàn tiền), không phải undo kỹ thuật. Ưu tiên orchestrator cho bất cứ thứ gì dính tiền.
- CQRS và event sourcing là dao sắc, không phải mặc định. Với tới read model để giải một bài toán đọc thật; với tới event sourcing chỉ khi lịch sử chính là sản phẩm.
Giờ bạn đã có thể tách service, để chúng nói chuyện qua event, và giữ dữ liệu của chúng thành thật. Còn một lời hứa chưa giữ — rằng hệ thống vẫn đứng vững khi (không phải nếu) mạng đánh rơi một message, một service đơ, hay một dependency có một ngày tồi tệ. Trả cái "distributed-systems tax" đó bằng timeout, retry, circuit breaker và idempotency là phần cuối của series này.