Dịch vụ checkout phát OrderPlaced, ba nhóm cùng đăng ký nhận, và sơ đồ kiến trúc bỗng trông thật gọn gàng, ít phụ thuộc. Sáu tháng sau, không ai giải thích được vì sao một khách hàng nhận hai email, đơn hàng khác không bao giờ tới khâu hoàn tất, hay consumer nào đang chờ phiên bản nào của OrderPlaced. Broker vẫn khỏe. Hệ thống thì không.
Kiến trúc hướng sự kiện rất hữu ích, nhưng nó không đồng nghĩa với hiện đại, dễ mở rộng hay ít liên kết. Nó đổi sự phụ thuộc về thời điểm lấy độ phức tạp trong vận hành và ngữ nghĩa. Producer không còn phải chờ consumer, nhưng mọi bên phải thống nhất message có nghĩa gì, chuyện gì xảy ra khi message được gửi lặp, và phục hồi thế nào nếu xử lý dừng giữa chừng.
Vì vậy, câu hỏi có ích không phải là “Có nên dùng Kafka không?” mà là “Sự thật nghiệp vụ nào đáng được phân phối bất đồng bộ, và chúng ta có đủ khả năng vận hành workflow sinh ra từ đó không?”
Event là sự thật, command là yêu cầu
Event ghi lại điều đã xảy ra: OrderPlaced, PaymentAuthorized hay ShipmentDispatched. Tên của nó thường ở thì quá khứ. Producer công bố một sự thật chứ không chỉ định ai phải phản ứng. Có thể không có, có một hoặc có nhiều consumer sử dụng nó.
Command yêu cầu một năng lực cụ thể thực hiện việc gì đó: AuthorizePayment, ReserveInventory hay SendReceipt. Nó có một nơi nhận dự kiến và có thể được chấp nhận hoặc từ chối. Đưa command vào queue đôi khi rất hợp lý, nhưng phương thức vận chuyển không biến nó thành event.
Nhầm hai khái niệm này sẽ tạo ra liên kết ngầm. Một “event” tên UpdateSearchIndex thực chất là chỉ thị từ xa; publisher biết cả bên nhận lẫn cách triển khai mong muốn. Ngược lại, coi PaymentAuthorized là command khiến consumer diễn giải một sự thật nghiệp vụ bền vững như yêu cầu có thể thử lại.
| Khía cạnh | Event | Command | Yêu cầu đồng bộ |
|---|---|---|---|
| Ý nghĩa | Một sự thật đã xảy ra | Yêu cầu thực hiện công việc | Yêu cầu cần phản hồi ngay |
| Quyền sở hữu | Publisher sở hữu sự thật | Một handler sở hữu năng lực | Một server sở hữu endpoint |
| Số bên nhận dự kiến | Từ không đến nhiều | Thường là một handler logic | Một |
| Khi thất bại | Thử lại, cách ly, bù trừ | Từ chối, thử lại hoặc báo lỗi | Trả thành công hoặc lỗi |
| Phù hợp nhất | Tích hợp, projection, audit | Việc trì hoãn, làm phẳng tải | Truy vấn, kiểm tra, quyết định tức thời |
Event cũng khác change-data capture. Message cấp dòng như “cột status đổi từ 2 sang 3” làm lộ chi tiết lưu trữ. Domain event như OrderCancelled giữ lại ý nghĩa nghiệp vụ và có thể ổn định khi bảng dữ liệu thay đổi. Change-data capture hữu ích cho sao chép và tích hợp, nhưng consumer không nên phải suy ngược ý định từ biến động database.
Warning
Bất đồng bộ không xóa coupling. Nó chuyển coupling sang tên event, schema, giả định về thứ tự, chính sách retry và thời gian. Các hợp đồng đó cần được thiết kế cẩn thận như HTTP API.
Trước khi publish một event, hãy viết một câu luôn đúng sau khi transaction commit. Nếu câu đó có từ “hãy”, nhắc tới một dịch vụ đích hoặc chứa chi tiết triển khai, nhiều khả năng đó là command.
Hợp đồng event và cách tiến hóa schema
Một event hữu dụng có envelope ổn định và payload riêng cho domain. Envelope mang thông tin về định danh, quan hệ nhân quả, thời gian và schema một cách nhất quán giữa các loại event. Payload diễn tả điều đã xảy ra bằng ngôn ngữ nghiệp vụ.
export type EventEnvelope<TType extends string, TPayload> = Readonly<{
id: string;
type: TType;
schemaVersion: number;
occurredAt: string;
producer: string;
correlationId: string;
causationId?: string;
aggregateId: string;
sequence: number;
payload: Readonly<TPayload>;
}>;
export type OrderPlacedV2 = EventEnvelope<
'commerce.order-placed',
{
orderId: string;
customerId: string;
currency: 'USD' | 'EUR' | 'VND';
totalMinor: number;
lines: ReadonlyArray<{
sku: string;
quantity: number;
unitPriceMinor: number;
}>;
}
>;
export function makeOrderPlaced(
order: {
id: string;
customerId: string;
currency: OrderPlacedV2['payload']['currency'];
totalMinor: number;
lines: OrderPlacedV2['payload']['lines'];
version: number;
},
context: { correlationId: string; causationId?: string },
): OrderPlacedV2 {
return {
id: crypto.randomUUID(),
type: 'commerce.order-placed',
schemaVersion: 2,
occurredAt: new Date().toISOString(),
producer: 'checkout-service',
correlationId: context.correlationId,
causationId: context.causationId,
aggregateId: order.id,
sequence: order.version,
payload: {
orderId: order.id,
customerId: order.customerId,
currency: order.currency,
totalMinor: order.totalMinor,
lines: order.lines,
},
};
}
id định danh lần phát hành này và giúp khử trùng lặp. correlationId nối toàn bộ một luồng nghiệp vụ, còn causationId trỏ tới message hoặc request trực tiếp gây ra event. aggregateId và sequence làm rõ thứ tự trong phạm vi từng thực thể. occurredAt là thời điểm nghiệp vụ, không phải thời điểm broker tiếp nhận.
Tiến hóa schema là nơi hệ thống event tồn tại lâu dài trở thành nền tảng tốt hoặc một khu khảo cổ. Hãy ưu tiên thay đổi cộng thêm: thêm trường tùy chọn, xác định cách hiểu mặc định và giữ trường cũ cho tới khi các consumer đã biết hoàn tất chuyển đổi. Đừng âm thầm đổi một trường từ cent sang số tiền thập phân, giờ địa phương sang UTC, hay từ nghĩa “đã tạo” sang “đã duyệt” mà vẫn giữ nguyên tên.
Consumer nên kiểm tra dữ liệu ngay tại biên và chuyển rõ ràng các phiên bản cũ còn hỗ trợ về một biểu diễn nội bộ duy nhất:
type OrderPlacedV1Payload = {
orderId: string;
customerId: string;
totalCents: number;
};
type CurrentOrderPlaced = OrderPlacedV2['payload'];
export function normalizeOrderPlaced(
version: number,
payload: OrderPlacedV1Payload | CurrentOrderPlaced,
): CurrentOrderPlaced {
if (version === 1) {
const old = payload as OrderPlacedV1Payload;
return {
orderId: old.orderId,
customerId: old.customerId,
currency: 'USD',
totalMinor: old.totalCents,
lines: [],
};
}
if (version === 2) return payload as CurrentOrderPlaced;
throw new Error(`Unsupported commerce.order-placed v${version}`);
}
Hãy giữ fixture tương thích trong test của consumer và chỉ định người sở hữu cho từng schema. Registry có thể kiểm tra cú pháp, nhưng chỉ con người mới đánh giá được ngữ nghĩa. Tăng version khi ý nghĩa thay đổi theo cách không tương thích; đừng tạo OrderPlacedV7 chỉ vì vừa thêm một trường tùy chọn.
Giao nhận, thứ tự và tính idempotent
Phần lớn broker trong production cung cấp at-least-once delivery: message được gửi lại cho tới khi có xác nhận, nên trùng lặp là điều chắc chắn sẽ gặp. At-most-once tránh trùng bằng cách chấp nhận khả năng mất message. “Exactly once” thường chỉ có nghĩa đúng một lần trong phạm vi giới hạn của broker hoặc transaction; nó không thể buộc nhà cung cấp email, cổng thanh toán và database cùng tham gia một transaction nguyên tử kỳ diệu.
Vì thế, hãy thiết kế cho at-least-once và làm mọi side effect có tính idempotent. Ở phía producer, transactional outbox đóng khoảng hở giữa commit trạng thái domain và publish event. Đơn hàng cùng bản ghi outbox được ghi trong một transaction database; một relay sẽ publish những bản ghi chưa gửi sau đó.
import type { DatabaseTransaction } from './database.js';
import type { EventBus } from './event-bus.js';
export async function placeOrder(
transaction: DatabaseTransaction,
input: PlaceOrderInput,
): Promise<string> {
return transaction.run(async (database) => {
const order = await database.orders.insert(input);
const event = makeOrderPlaced(order, {
correlationId: input.correlationId,
causationId: input.requestId,
});
await database.outbox.insert({
eventId: event.id,
topic: 'commerce-events',
partitionKey: event.aggregateId,
body: JSON.stringify(event),
createdAt: event.occurredAt,
});
return order.id;
});
}
export async function publishOutboxBatch(
transaction: DatabaseTransaction,
bus: EventBus,
): Promise<void> {
const rows = await transaction.outbox.claimUnpublished(100);
for (const row of rows) {
await bus.publish(row.topic, row.partitionKey, row.body);
await transaction.outbox.markPublished(row.eventId);
}
}
Relay vẫn có thể dừng sau khi publish nhưng trước markPublished, vì vậy consumer phải khử trùng lặp. Hãy ghi event ID và cập nhật nghiệp vụ trong cùng transaction cục bộ. Unique key trên (consumer, event_id) biến lần giao lặp thành no-op vô hại.
import type { Database } from './database.js';
export async function handleOrderPlaced(
database: Database,
event: OrderPlacedV2,
): Promise<'processed' | 'duplicate'> {
return database.transaction(async (transaction) => {
const firstDelivery = await transaction.inbox.tryInsert({
consumer: 'fulfillment-order-projector',
eventId: event.id,
receivedAt: new Date().toISOString(),
});
if (!firstDelivery) return 'duplicate';
const payload = normalizeOrderPlaced(event.schemaVersion, event.payload);
await transaction.fulfillmentOrders.insert({
orderId: payload.orderId,
customerId: payload.customerId,
status: 'awaiting-inventory',
sourceSequence: event.sequence,
});
return 'processed';
});
}
Cách này bảo vệ side effect trong database. Tác động ra hệ thống ngoài cần idempotency key riêng: truyền event.id cho cổng thanh toán hoặc dịch vụ email nếu họ hỗ trợ, hoặc lưu một intent trước khi gửi rồi đối soát kết quả chưa rõ. Tuyệt đối không thử thu tiền lại một cách mù quáng sau timeout; timeout nghĩa là “chưa biết kết quả”, không phải “đã thất bại”.
Thứ tự cũng chỉ được đảm bảo trong một phạm vi nhất định. Thứ tự toàn cục vừa đắt vừa hiếm khi cần. Hãy partition theo aggregateId nếu event của cùng một đơn hàng phải giữ thứ tự, sau đó dùng sequence để từ chối event cũ hoặc phát hiện khoảng trống. Đừng giả định event của hai aggregate khác nhau có một thứ tự tổng thể có ý nghĩa. Nếu consumer cần CustomerCreated đến trước OrderPlaced, nó phải có cơ chế đệm, retry hoặc đọc bổ sung thay vì chỉ hy vọng vào thời điểm.
Choreography, lỗi và phục hồi
Choreography cho phép các dịch vụ phản ứng với sự thật mà không cần một bộ điều phối workflow trung tâm. Nó hiệu quả khi các phản ứng độc lập: analytics, lập chỉ mục tìm kiếm và thông báo khách hàng đều có thể quan sát OrderPlaced mà không chặn checkout.
Khi workflow có thêm các bước phụ thuộc, deadline và hành động bù trừ, choreography thuần túy trở nên khó hiểu. Quy trình nghiệp vụ bị rải qua nhiều subscription, và thêm một event có thể kích hoạt cả chuỗi phản ứng không được ghi chép. Saga có orchestration làm rõ trình tự và trạng thái, nhưng tạo ra một coordinator cũng phải bền vững và sẵn sàng cao. Không cách nào luôn tốt hơn: dùng choreography cho phản ứng độc lập, liên kết lỏng; dùng orchestration khi workflow có chủ sở hữu rõ ràng và các chuyển trạng thái quan trọng.
Những kiểu lỗi phổ biến cần thiết kế xử lý chứ không chỉ cần cảnh báo:
- Poison message luôn thất bại vì dữ liệu sai hoặc schema không được hỗ trợ. Chỉ retry số lần hữu hạn, sau đó cách ly cùng lỗi và byte gốc.
- Lỗi dependency tạm thời cần exponential backoff kèm jitter. Retry ngay lập tức chỉ khuếch đại sự cố.
- Consumer lag có thể biến pipeline “vẫn chạy” thành sự cố nghiệp vụ. Hãy đo tuổi của event chưa xử lý lâu nhất, không chỉ độ dài queue.
- Side effect một phần xảy ra khi ghi database thành công nhưng lời gọi bên ngoài có kết quả chưa rõ. Lưu intent và đối soát thay vì phỏng đoán.
- Bão replay làm quá tải hệ thống hạ nguồn hoặc lặp lại tác động không idempotent. Replay cần giới hạn tốc độ, consumer group tách biệt và phạm vi rõ ràng.
- Dead-letter queue sẽ thành nghĩa địa dữ liệu nếu thiếu người sở hữu, chính sách lưu giữ, công cụ kiểm tra và đường redrive đã được thử nghiệm.
Tip
Hãy diễn tập phục hồi trước khi phát hành: bơm vào một event trùng, một event sai thứ tự, một version không hỗ trợ và một lần consumer ngừng hoạt động. Nếu nhóm chưa thể giải thích và replay an toàn từng trường hợp, thiết kế vẫn chưa hoàn chỉnh về vận hành.
Bù trừ không phải rollback. Hủy đơn sau khi đã giữ hàng tạo ra sự thật mới; nó không thể xóa khoảng thời gian món hàng từng không còn sẵn cho người khác. Hãy mô hình hóa hành động bù trừ một cách tường minh và cũng làm chúng idempotent.
Khả năng quan sát và lúc nên gọi đồng bộ
Tracing request truyền thống đi theo call stack. Workflow event đi qua nhiều process và có thể tạm dừng hàng giờ, nên dữ liệu quan sát phải đi cùng envelope. Hãy log event.id, type, correlationId, causationId, aggregateId, schemaVersion, tên consumer, số lần thử và thời gian xử lý dưới dạng trường có cấu trúc. Truyền W3C trace context trong header của message khi có thể, nhưng giữ correlation nghiệp vụ bền vững tách khỏi công cụ tracing của một nhà cung cấp.
Metric hữu ích gồm tốc độ publish, tốc độ xử lý, số retry, tỷ lệ dead-letter, consumer lag theo partition, tuổi message cũ nhất, độ trễ handler và số kết quả theo từng loại event. Dashboard phải mô tả luồng nghiệp vụ bên cạnh broker: “đơn chờ thanh toán quá mười phút” dễ hành động hơn “partition 7 có 4.812 record.” Mỗi alert cần runbook nói rõ nên tạm dừng, bỏ qua, cách ly, replay hay bù trừ.
Khả năng audit cũng đòi hỏi tiết chế. Event tồn tại lâu và được sao chép rộng, vì vậy không đưa secret hay dữ liệu cá nhân không cần thiết vào đó. Ưu tiên định danh ổn định thay vì toàn bộ hồ sơ khách hàng. Cần xác định chính sách lưu giữ và xóa trước khi dữ liệu chịu quy định đi vào log bất biến.
Cuối cùng, đôi lúc lời gọi đồng bộ đơn giản là lựa chọn tốt hơn. Hãy dùng nó khi caller không thể tiếp tục nếu chưa có câu trả lời ngay, khi người dùng cần kiểm tra dữ liệu tức thời, khi trạng thái nhất quán phải được quan sát trước lúc trả về, hoặc khi tương tác chỉ là truy vấn trực tiếp. Kiểm tra tồn kho trước khi hứa ngày giao có thể xứng đáng với request đồng bộ có timeout; phát InventoryCheckRequested rồi polling chờ event chỉ tăng độ trễ và trạng thái lỗi mà không đem lại quyền tự chủ hữu ích.
Mô hình lai thường mạnh nhất: thực hiện quyết định đồng bộ tối thiểu cần cho tính đúng đắn, commit trạng thái cục bộ cùng outbox event, rồi phân phối công việc phụ theo cách bất đồng bộ. Checkout có thể kiểm tra command đồng bộ và trả order ID, trong khi email, analytics và projection cập nhật sau.
Chọn giao tiếp hướng sự kiện khi tách biệt thời gian, nhiều consumer độc lập, khả năng làm phẳng tải hoặc replay tạo ra giá trị cụ thể. Chọn API đồng bộ khi tính trực tiếp, phản hồi tức thời và mô hình lỗi đơn giản quan trọng hơn. Kiến trúc tốt lên khi cả hai chỉ là công cụ bình thường, không phải bản sắc của đội ngũ.
Điều cần nhớ
- Mô hình event như sự thật nghiệp vụ bất biến, còn command là yêu cầu có một nơi chịu trách nhiệm.
- Xem envelope và payload như API dài hạn; tiến hóa schema theo hướng cộng thêm và kiểm thử fixture cũ.
- Giả định at-least-once delivery. Dùng outbox để bảo đảm tính nguyên tử phía producer, inbox hoặc idempotency key cho tác động phía consumer.
- Chỉ định nghĩa thứ tự ở nơi domain thật sự cần, thường trong từng aggregate, và công bố sequence number.
- Chọn choreography cho phản ứng độc lập, orchestration cho workflow có trạng thái và deadline.
- Thiết kế cách ly, replay, đối soát, bù trừ và observability trước sự cố đầu tiên.
- Giữ lời gọi đồng bộ cho quyết định tức thời và truy vấn. Kiến trúc hướng sự kiện chỉ đáng với độ phức tạp của nó khi bất đồng bộ tạo ra giá trị đo được.