Hầu hết các hệ thống không cần thứ tự tin nhắn toàn cục.
Chúng cần một thứ đơn giản hơn và hữu ích hơn: các sự kiện phải được xử lý theo đúng thứ tự trong phạm vi từng aggregate.
Theo OrderId, theo InvoiceId, theo CustomerId, hay bất kỳ ranh giới aggregate nào của bạn. Bạn có thể đặt ranh giới này rộng hay hẹp tùy theo nhu cầu.
Điều này bắt đầu như một bài toán về eventing, nhưng nếu bạn theo đuổi các yêu cầu đến kết luận logic của chúng, bạn sẽ kết thúc với một workflow. Và workflow đó có một cái tên: một saga.
Mục lục
Domain Events – Giải pháp “sạch” về mặt lý thuyết
Domain events hấp dẫn vì chúng xuất phát từ các nguyên tắc nền tảng:
- Một aggregate thay đổi trạng thái
- Nó phát ra các sự kiện mô tả điều đã xảy ra
- Các handler phản ứng và thực hiện công việc hữu ích
Bạn cũng có một mô hình tinh thần hay:
Thay đổi trạng thái → Sự kiện → Phản ứng
Một ví dụ điển hình:
OrderPlacedPaymentCapturedOrderShipped
Nhưng có một vấn đề.
Domain events rất mong manh khi bạn dùng chúng để tích hợp (integration).
Nếu bạn publish trực tiếp từ transaction của mình, bạn đang gắn tính đúng đắn của nghiệp vụ với một side effect không đáng tin cậy:
- Transaction thành công nhưng việc publish thất bại
- Việc publish thành công nhưng transaction bị rollback
- Consumer xử lý các message trùng lặp
- Retry gây ra sự đảo lộn thứ tự
Vì vậy chúng ta giữ nguyên mô hình, nhưng củng cố khâu phân phối.
Outbox giúp việc publish đáng tin cậy (nhưng không đảm bảo thứ tự)
Với Outbox, chúng ta lưu các sự kiện sắp gửi trong cùng một transaction với việc cập nhật aggregate.
Sau đó, một publisher nền đọc Outbox và đẩy các sự kiện vào queue.
Điều này khắc phục vấn đề độ tin cậy:
- Nếu transaction commit, sự kiện đã được lưu bền vững
- Nếu publisher bị crash, nó có thể tiếp tục sau đó
- Chúng ta có thể retry một cách an toàn
Giờ đây chúng ta đã làm cho việc publish sự kiện trở nên đáng tin cậy.
Nhưng chúng ta vẫn chưa làm cho việc xử lý sự kiện có thứ tự.
Competing Consumers rất tuyệt, cho đến khi thứ tự trở thành vấn đề
Ngay khi các sự kiện đi vào queue, chúng ta thường mở rộng bằng đòn bẩy đơn giản nhất: competing consumers.
Nhiều instance cùng consume từ một queue để tăng throughput.
Điều đó hoạt động… cho đến khi thứ tự trở thành vấn đề.
Hai sự kiện cho cùng một OrderId có thể được xử lý cùng lúc:
- Consumer A nhận
PaymentCaptured - Consumer B nhận
OrderPlaced - Các side effect chạy sai thứ tự
Ngay cả khi các sự kiện được publish đúng thứ tự, retry và redelivery vẫn có thể xáo trộn thứ tự xử lý.
Và giờ bạn có một bug tinh vi chỉ xuất hiện khi hệ thống chịu tải.
Đó chính là nhận thức quan trọng: queue giúp mở rộng công việc. Chúng không bảo vệ các bất biến (invariants) của bạn.
Điều chúng ta thực sự cần là thứ tự theo từng aggregate
Bạn không cần một hàng đợi có thứ tự cho mọi thứ.
Bạn cần nhiều hàng đợi có thứ tự độc lập, mỗi hàng dành cho một aggregate.
Điều đó thường đúng vì:
- Các aggregate đã định nghĩa ranh giới nhất quán (consistency boundaries)
- Các sự kiện được tạo ra theo đúng thứ tự tự nhiên (v1, v2, v3…)
- Thứ tự “đúng” chính là dòng thời gian của riêng aggregate
Nếu chúng ta có thể đảm bảo rằng chỉ một handler xử lý các sự kiện cho một aggregate tại một thời điểm, hầu hết vấn đề sẽ biến mất.
Giải pháp trực tiếp nhất cũng là đơn giản nhất: dùng một consumer duy nhất cho toàn bộ stream.
Điều đó đảm bảo thứ tự, với giả định các sự kiện được publish đúng thứ tự.
Nhưng nó có một nhược điểm rõ ràng.
Một consumer duy nhất giải quyết thứ tự nhưng giới hạn khả năng mở rộng
Một consumer đồng nghĩa với:
- Trần throughput (chỉ một worker)
- Độ trễ tăng vọt khi chịu tải
- Việc mở rộng trở thành theo chiều dọc, không phải theo chiều ngang
Ngay cả khi các sự kiện của bạn rất nhẹ, bạn vẫn đang tự tạo ra một nút thắt cổ chai nhân tạo cho hệ thống.
Vì vậy chúng ta muốn:
- Thứ tự theo từng aggregate
- Mở rộng theo chiều ngang
- Độ tin cậy (Outbox vẫn được giữ)
Đây là lúc các đội ngũ thường “phát minh lại” bước tiếp theo.
Publish message tiếp theo ngay từ handler
Nếu competing consumers phá vỡ thứ tự, một ý tưởng tự nhiên là:
Đừng để queue quyết định message tiếp theo là gì, chúng ta sẽ quyết định.
Thay vì đổ tất cả các sự kiện vào queue và để các consumer chạy đua, chúng ta chuyển sang cách tiếp cận theo chuỗi (chained):
- Xử lý một message cho một aggregate
- Khi hoàn tất, publish message tiếp theo cần được xử lý
Giờ đây, hệ thống xử lý từng message một tại một thời điểm cho mỗi aggregate.
Đây là khoảnh khắc quan trọng:
Bạn đã ngừng xây dựng “event handler”.
Bạn đã bắt đầu xây dựng một workflow.
Và workflow đó chính là… một saga.
Chúc mừng, bạn đã xây dựng một Choreographed Saga
Một choreographed saga là một workflow trong đó:
- Mỗi bước phản ứng với một sự kiện
- Thực hiện công việc
- Phát ra sự kiện tiếp theo để kích hoạt bước kế tiếp
Không có một bộ điều phối trung tâm duy nhất.
Thay vào đó, chúng ta có một chuỗi các sự kiện: “khi X xảy ra, làm Y, rồi publish Z”.
Mẫu này tự nhiên phù hợp với yêu cầu mới của bạn:
- Thứ tự theo từng aggregate được giữ nguyên (chuỗi xử lý tuần tự)
- Bạn có thể mở rộng theo chiều ngang giữa các aggregate (nhiều chuỗi cùng chạy)
- Mỗi bước được cô lập và có thể retry
Nó cũng tạo ra một kỷ luật hữu ích:
- “Bước tiếp theo là gì?” trở nên tường minh
- Ranh giới giữa các bước trở nên rõ ràng hơn
- Bạn có thể quan sát workflow như một chuỗi tuần tự
Nhưng choreography có một hạn chế: quyền kiểm soát bị phân tán, vì vậy việc theo dõi tiến độ và xử lý ngoại lệ có thể trở nên lộn xộn.
Vì vậy chúng ta thực hiện bước cuối cùng.
Nếu bạn muốn kiểm soát, hãy giới thiệu State Machine Saga
Khi workflow trở nên quan trọng, bạn thường muốn:
- Một nơi duy nhất biết trạng thái hiện tại
- Khả năng quan sát tiến độ (“chúng ta đang kẹt ở đâu?”)
- Timeout và retry tường minh
- Các hành động bù trừ (compensating actions) khi có gì đó thất bại
Đó là lúc bạn chuyển từ choreography sang orchestration thông qua một state machine saga:
- Saga nắm giữ trạng thái của workflow
- Các sự kiện thúc đẩy các chuyển trạng thái
- Saga quyết định message nào sẽ được publish tiếp theo
- Bạn có được quyền kiểm soát và khả năng quan sát
Nhân tiện, điều này không thay thế Outbox.
Bạn vẫn cần việc publish đáng tin cậy.
Bạn chỉ vừa làm cho workflow trở nên tường minh.
Hỗ trợ từ Broker giúp về thứ tự, không phải về tính đúng đắn
Đáng để nói rõ rằng bạn không phải lúc nào cũng phải tự xây dựng điều này.
Nhiều message broker phổ biến cung cấp các primitive kỹ thuật cho xử lý có thứ tự theo từng key (chính là ID aggregate của bạn):
- Amazon SQS FIFO message groups (theo key)
- Azure Service Bus sessions (theo key)
- Kafka Partitions trong một log (key → partition → stream có thứ tự)
- RabbitMQ “single active consumer” (theo queue)
Các tính năng này có thể loại bỏ chế độ lỗi phổ biến nhất của competing consumers: xử lý đồng thời các message cho cùng một aggregate.
Nhưng ngay cả với thứ tự theo từng aggregate hoàn hảo, bạn vẫn cần các pattern xung quanh nó để giữ cho hệ thống đúng đắn:
- Outbox để publish một cách đáng tin cậy (thứ tự vô nghĩa nếu các sự kiện bị mất)
- Idempotent consumers / Inbox vì retry và trùng lặp vẫn xảy ra
- Consistency boundaries (điều gì an toàn để làm bên trong transaction so với bên ngoài)
- Timeout + bù trừ (compensation) khi “chuỗi có thứ tự” thực chất là một workflow nghiệp vụ có thể thất bại một phần
Vì vậy, thứ tự ở cấp độ broker là một nền tảng tuyệt vời. Nó giảm bớt độ phức tạp ngẫu nhiên. Nhưng nó không loại bỏ nhu cầu mô hình hóa công việc chạy dài (long-running) một cách tường minh khi nghiệp vụ yêu cầu.
Bài học rút ra
Nếu bạn theo đuổi vấn đề từ các nguyên tắc nền tảng:
- Aggregate định nghĩa ranh giới nơi thứ tự quan trọng
- Outbox làm cho việc publish sự kiện trở nên đáng tin cậy
- Competing consumers phá vỡ thứ tự theo từng aggregate
- Một consumer duy nhất khôi phục thứ tự nhưng giới hạn throughput
- Publish “message tiếp theo” tạo ra tiến trình tuần tự cho mỗi aggregate
- Tiến trình tuần tự đó chính là một saga (choreography trước, state machine khi bạn cần kiểm soát)
Vì vậy, bạn không phải vô tình phát minh lại thứ gì đó.
Bạn đã khám phá ra rằng “xử lý có thứ tự theo từng aggregate ở quy mô lớn” không phải là một tính năng của queue.
Nó là một workflow. Và saga là cách chúng ta mô hình hóa workflow trong các hệ thống phân tán.
Một khi bạn nhìn nhận theo cách đó, bạn sẽ ngừng vật lộn với queue để tìm kiếm sự đảm bảo về thứ tự.
Bạn thiết kế workflow mà nghiệp vụ thực sự cần.
Hy vọng bài viết hữu ích với bạn!



