Trong bài viết trước, chúng ta đã khám phá cách việc từ bỏ kiến trúc nguyên khối (monolithic) để ưu tiên các ranh giới ngữ cảnh nghiêm ngặt của Thiết kế Hướng Domain (Domain-Driven Design - DDD) đã giúp một nền tảng thương mại điện tử có thể scale vượt mức 10.000+ đơn hàng mỗi ngày. Tuy nhiên, việc băm nát một database khổng lồ thành 20+ database Postgres hoàn toàn cách ly lại đẻ ra một vấn đề mới cực kỳ đáng sợ: Làm thế nào để chúng ta duy trì tính nhất quán dữ liệu giữa các service đã bị cắt đứt kết nối với nhau?
Câu trả lời chính là Kiến trúc Hướng sự kiện (Event-Driven Architecture - EDA). Thay vì xâu chuỗi các lệnh gọi HTTP đồng bộ (cái mà có thể block network và chắc chắn sẽ gây ra hiệu ứng domino sập toàn hệ thống nếu một service con bị tèo) — mỗi microservice giờ đây sẽ độc lập phát sóng (broadcast) các “Sự kiện” (Events) qua một trạm trung chuyển trung tâm (centralized broker).
Sự xuất hiện của Dapr Pub/Sub
Để trừu tượng hóa tầng nhắn tin (messaging layer) phức tạp này, chúng tôi đã tích hợp Dapr v1.14 (Distributed Application Runtime). Dapr cho phép các microservices viết bằng Go (Kratos) của chúng tôi có thể publish và subscribe vào các topics bằng một giao thức gRPC/HTTP tiêu chuẩn, qua đó bảo vệ hoàn toàn tầng code application khỏi những thứ rườm rà cồng kềnh của tầng message broker bên dưới (Kafka/RabbitMQ).
Quy tắc Vàng: Quy ước Gọi tên Sự kiện
Các hệ thống Hướng sự kiện sẽ cực kỳ nhanh chóng thoái hóa thành một mớ bòng bong không thể truy vết nếu các sự kiện không được cấu trúc nghiêm ngặt. Chúng tôi đã ban hành một quy định thép về cách đặt tên gồm 3 phân đoạn:
{service}.{entity}.{action}
Ví dụ:
orders.order.status_changedpricing.price.updatedwarehouse.inventory.stock_changed
Điều này ép buộc khả năng truy vết (traceability). Tiền tố khai báo root owner (chủ sở hữu gốc) tuyệt đối của sự kiện, phần thực thể (entity) khai báo đối tượng ngữ cảnh, và phần hành động dạng quá khứ phân từ (past-participle) định nghĩa hoàn hảo trạng thái vòng đời của nó.
Sống sót qua Thất bại: Saga Pattern
Bạn không thể thực thi một block SQL BEGIN ... COMMIT đơn giản để lưu một đơn hàng, giữ chỗ tồn kho, và trừ tiền thẻ tín dụng được nữa. Nếu một khách hàng thanh toán, chúng ta phải khởi chạy một Saga.
sequenceDiagram
participant C as Checkout Service
participant D as Dapr Pub/Sub
participant W as Warehouse Service
participant P as Payment Service
C->>D: Publish checkout.order.created
D-->>W: Nhận event
W->>W: Giữ chỗ Tồn kho
W->>D: Publish warehouse.inventory.reserved
D-->>P: Nhận event
P->>P: Trừ tiền Thẻ tín dụng
alt Thẻ bị từ chối
P->>D: Publish payments.payment.failed
D-->>W: Nhận event
W->>W: Bù trừ (Rollback lại Tồn kho)
end
Một Saga là một chuỗi các giao dịch cục bộ (local transactions) được điều phối với nhau. Service Checkout publish sự kiện checkout.order.created. Service Warehouse chụp lấy sự kiện này, tiến hành giữ chỗ tồn kho, và phản ứng tùy thuộc vào sự thành công. Nếu một bước phía sau bị thất bại (ví dụ: service Payment từ chối thẻ thông qua sự kiện payments.payment.failed), Saga sẽ kích hoạt các Giao dịch Bù trừ (Compensating Transactions) — phát sóng các sự kiện đảo ngược (reverse events) để nhả tồn kho đã giữ và đánh dấu đơn hàng bị lỗi.
Thiết kế các Consumers Bất tử (Tính Lũy đẳng & DLQs)
Mạng network nổi tiếng là không đáng tin cậy. Dapr đảm bảo cơ chế phân phối At-Least-Once (Ít nhất một lần), nghĩa là service của bạn thỉnh thoảng chắc chắn sẽ nhận được các sự kiện trùng lặp (duplicate events) trong các đợt bão retries (retry storms).
Mọi payload sự kiện của chúng tôi đều được cấu trúc để đảm bảo luôn có một EventID duy nhất. Các hàm xử lý (consumer handlers) viết bằng Go của chúng tôi đều tuân thủ nguyên tắc Lũy đẳng (Idempotent) một cách sùng đạo. Nếu sự kiện pricing.price.updated bay tới 2 lần, hàm xử lý sẽ mượt mà xác minh xem database hiện tại đã khớp với state mới chưa, nếu rồi thì nó sẽ bỏ qua bản trùng lặp này mà không ném ra bất kỳ lỗi nào.
Chuyện gì xảy ra nếu một tin nhắn liên tục làm crash hệ thống vì một lỗi logic code? Nó sẽ tự động được đá sang một hàng đợi Dead Letter Queue (DLQ). Bám sát theo quy ước gọi tên của chúng tôi, một sự kiện catalog.product.created gây crash sẽ được định tuyến chính xác về topic dlq.catalog.product.created. Điều này cho phép các kỹ sư có thể replay (chạy lại) các sự kiện bị lỗi một cách an toàn sau khi đã deploy một bản hotfix, qua đó vĩnh viễn loại bỏ rủi ro mất mát dữ liệu nghiêm trọng.
Kết luận
Kiến trúc Hướng sự kiện (Event-Driven Architecture) không chỉ là việc viết code bất đồng bộ (async); nó là một tư duy lập trình phòng thủ. Bằng cách ép buộc các quy ước đặt tên khắt khe, ứng dụng Saga pattern để đảm bảo tính nhất quán xuyên ranh giới, và tận dụng triệt để tính Lũy đẳng (Idempotency) cũng như DLQs, chúng tôi đã hô biến một hệ thống phân tán vốn dĩ rất mỏng manh thành một hệ thần kinh thương mại điện tử thực tế là bất khả xâm phạm.
Để xem chi tiết cách triển khai Saga — bao gồm code Go cho Optimistic Concurrency Control (Kiểm soát Tương tranh Lạc quan) và schema khóa lũy đẳng — hãy đọc bài Thiết kế Hệ sinh thái Thương mại điện tử 21 Microservices với Golang & DDD.