Khách hàng nhấn Thanh toán. Tiền đã được trừ, nhưng kết nối mất trước khi response về điện thoại. Giao dịch thất bại, hay chỉ response thất bại? Retry có thể thu tiền hai lần; không retry có thể khiến đơn đã trả tiền vẫn hiện chưa thanh toán.
Sự mơ hồ này không phải trường hợp hiếm. Mạng có thể làm mất response, process có thể chết ngay sau commit, broker có thể giao lại message, và consumer có thể hoàn thành công việc trước khi gửi acknowledgement. Không giá trị timeout nào cho caller biết operation đã đi qua ranh giới commit hay chưa.
Idempotency key là một lời giải thực dụng: caller gán định danh ổn định cho một operation logic, còn server ghi nhớ kết quả của operation đó. Cơ chế này không biến network thành exactly-once. Nó khiến nhiều lần thử cùng hội tụ về một kết quả nghiệp vụ trong phạm vi được xác định rõ. Phân biệt được hai điều này là nền tảng của một thiết kế đáng tin cậy.
Đảm bảo giao nhận luôn gắn với một ranh giới
Thuật ngữ về delivery chỉ hữu ích khi ta nói rõ cả cơ chế lẫn ranh giới được bảo vệ. Broker có thể giao một record đúng một lần trong khi application thực hiện external side effect hai lần. HTTP gateway có thể retry một lần, rồi queue phía sau retry thêm năm lần. “Exactly once” mà không chỉ ra ranh giới chỉ là khẩu hiệu, không phải invariant.
| Cam kết | Transport thực hiện gì | Application phải dự kiến gì | Hệ quả thường gặp |
|---|---|---|---|
| At-most-once | Gửi một lần hoặc bỏ khi không chắc chắn | Message có thể không bao giờ được xử lý | Không có duplicate từ transport nhưng có thể mất dữ liệu |
| At-least-once | Retry đến khi được acknowledge | Cùng một message logic có thể tới nhiều lần | Không mất trong các giả định đã nêu, duplicate là bình thường |
| Exactly-once processing | Deduplicate và commit atomically trên state được hỗ trợ | Thường chỉ đúng trong một ranh giới broker/database | Một state transition được commit trong ranh giới đó |
| Effectively once | Dùng identity, deduplication và effect idempotent | Replay vẫn xảy ra nhưng trạng thái nghiệp vụ hội tụ | Mục tiêu thực tế cho phần lớn workflow phân tán |
At-most-once phù hợp khi bỏ sót rẻ hơn lặp lại, như telemetry tạm thời. At-least-once hợp lý hơn với order, invoice và durable event vì retry an toàn hơn mất dữ liệu. Nó chuyển việc xử lý duplicate vào application, nơi hiểu identity nghiệp vụ.
Một số nền tảng thực sự cung cấp exactly-once processing. Kafka transaction có thể atomically consume và publish Kafka record; relational database có thể cập nhật domain row và deduplication row trong một transaction. Nhưng đảm bảo đó không tự động bao gồm email provider, database thứ hai hoặc mạng lưới thẻ. Vượt ranh giới này cần hợp đồng idempotency khác hoặc compensation.
Warning
Đừng suy từ “broker commit offset này đúng một lần” thành “khách hàng bị trừ tiền đúng một lần”. Offset và payment nằm ở hai hệ thống khác nhau nếu không có protocol rõ ràng nối chúng lại.
Invariant hữu ích nên hẹp và kiểm chứng được: với một operation identity và payload cho trước, service commit nhiều nhất một kết quả và trả lại cùng kết quả đã hoàn tất cho mọi lần retry.
Idempotency key là định danh của ý định nghiệp vụ
Idempotency key phải định danh ý định của caller, không phải từng lần gửi qua mạng. Mobile client tạo một key ngẫu nhiên khi người dùng bắt đầu checkout, lưu key cùng action đang chờ, rồi dùng lại sau timeout, reconnect hoặc khởi động lại ứng dụng. Tạo key mới cho mỗi lần retry sẽ vô hiệu hóa toàn bộ cơ chế.
Server phải đặt key trong một scope, chẳng hạn unique (tenant_id, route, idempotency_key). Scope ngăn hai tenant va chạm và ngăn key của POST /payments replay response từ POST /refunds.
Chỉ key thôi chưa đủ. Hãy lưu cryptographic hash của request payload đã normalize. Nếu caller dùng lại key với amount, currency hoặc destination khác, server phải trả conflict thay vì trả response cũ không liên quan. Hash ràng buộc identity với intent.
Normalize phải deterministic. Raw JSON không phù hợp vì whitespace và thứ tự key có thể khác mà ý nghĩa không đổi. Hãy validate, tạo biểu diễn canonical với key được sắp xếp và default tường minh, rồi hash. Bỏ field transport như trace ID nhưng giữ mọi field làm đổi business effect; quy định rõ number, Unicode, null và array có thứ tự.
Một record hữu ích thường gồm:
| Field | Mục đích |
|---|---|
scope + key |
Identity unique của operation logic |
request_hash |
Phát hiện việc dùng lại key do nhầm lẫn hoặc có chủ ý |
state |
Phân biệt công việc đang chạy với kết quả đã hoàn tất |
response_status + response_body |
Replay contract ban đầu mà không chạy lại nghiệp vụ |
resource_id |
Liên kết record với payment hoặc order đã tạo |
lease_expires_at |
Cho phép phục hồi khi processing được tách thành nhiều bước |
created_at + expires_at |
Phục vụ retention, audit và cleanup |
Không cache mọi failure. Business rejection ổn định có thể là completed result; lỗi database tạm thời không nên để lại completed record để cùng key còn retry được. Validation error trước lúc claim key thì không cần lưu.
Mô hình hóa record bằng state machine
Thiết kế an toàn nhất cho local database effect là claim idempotency key, thay đổi domain state và lưu response có thể replay trong cùng một database transaction. Unique constraint trên (scope, key) là bắt buộc. Cách “check rồi insert” ở application có race condition: hai worker có thể cùng nhìn thấy record chưa tồn tại rồi cùng thực hiện effect.
Trong TypeScript sau, claim phải dùng atomic insert-on-conflict kết hợp row lock, hoặc operation serializable tương đương, không phải hai query độc lập.
import { canonicalJson, sha256 } from './hash.js';
import type { Database, StoredResponse, Transaction } from './database.js';
type IdempotencyState = 'processing' | 'succeeded';
type IdempotencyRecord = Readonly<{
scope: string;
key: string;
requestHash: string;
state: IdempotencyState;
response?: StoredResponse;
resourceId?: string;
leaseExpiresAt: Date;
expiresAt: Date;
}>;
type ClaimResult =
| { kind: 'claimed'; record: IdempotencyRecord }
| { kind: 'replay'; response: StoredResponse }
| { kind: 'busy'; retryAfterSeconds: number }
| { kind: 'mismatch' };
type CreateOrderInput = Readonly<{
customerId: string;
currency: 'USD' | 'EUR' | 'VND';
totalMinor: number;
items: ReadonlyArray<{ sku: string; quantity: number }>;
}>;
export async function createOrderOnce(
database: Database,
tenantId: string,
idempotencyKey: string,
input: CreateOrderInput,
): Promise<StoredResponse> {
const scope = `${tenantId}:orders:create`;
const requestHash = sha256(canonicalJson(input));
return database.transaction('serializable', async (transaction) => {
const now = await transaction.clock.now();
const claim = await transaction.idempotency.claim({
scope,
key: idempotencyKey,
requestHash,
leaseExpiresAt: new Date(now.getTime() + 30_000),
expiresAt: new Date(now.getTime() + 24 * 60 * 60 * 1000),
});
switch (claim.kind) {
case 'mismatch':
return {
status: 409,
body: { code: 'IDEMPOTENCY_KEY_REUSED' },
};
case 'busy':
return {
status: 409,
headers: { 'retry-after': String(claim.retryAfterSeconds) },
body: { code: 'OPERATION_IN_PROGRESS' },
};
case 'replay':
return claim.response;
case 'claimed':
return commitOrder(transaction, claim.record, tenantId, input);
}
});
}
async function commitOrder(
transaction: Transaction,
record: IdempotencyRecord,
tenantId: string,
input: CreateOrderInput,
): Promise<StoredResponse> {
const order = await transaction.orders.insert({
tenantId,
customerId: input.customerId,
currency: input.currency,
totalMinor: input.totalMinor,
items: input.items,
});
const response: StoredResponse = {
status: 201,
headers: { location: `/orders/${order.id}` },
body: { orderId: order.id, status: order.status },
};
await transaction.idempotency.succeed({
scope: record.scope,
key: record.key,
requestHash: record.requestHash,
resourceId: order.id,
response,
});
return response;
}
Nếu process chết trước commit, order và claim cùng rollback. Nếu chết sau commit trước response, retry tìm thấy succeeded và replay kết quả. Row processing chỉ được commit khi công việc bất đồng bộ hoặc chia thành nhiều transaction.
Không giữ database transaction mở khi gọi payment API. Hãy commit durable intent, để worker gọi provider bằng operation key rồi ghi kết quả. Provider phải hỗ trợ key; nếu không, integration phải reconcile trước khi retry.
Duplicate đồng thời phải có đúng một bên thắng
Retry tuần tự khá dễ xử lý. Bài kiểm tra thật sự là hai request giống nhau tới hai instance khác nhau trong cùng một mili giây. Database, không phải process memory, phải phân xử chúng.
Request thua có thể chờ winner, nhận 409 kèm Retry-After, hoặc nhận 202 cùng status URL. Chọn một contract rõ ràng: chờ hợp với operation nhanh, 202 hợp với job dài. 500 chỉ khuyến khích retry dồn dập trong khi công việc đã chạy.
Lease cần thiết khi state processing được commit trước lúc effect hoàn thành. Nó tránh việc record bị khóa vĩnh viễn sau khi worker chết. Contender chỉ được takeover sau lease_expires_at, tốt nhất kèm fencing token hoặc attempt number tăng đơn điệu. Worker phải kiểm tra token trước khi ghi completion để một worker cũ chạy chậm, đã hết lease, không thể ghi đè owner mới.
Lease hết hạn không chứng minh worker cũ chưa tạo external effect. Nếu nó charge thẻ rồi chết trước khi lưu success, worker takeover phải query provider bằng operation key trước khi charge lại.
Tip
Hãy test concurrency bằng barrier để thả nhiều worker cùng lúc vào một key. Kết quả cần có một domain row, một idempotency record hoàn tất, các response thành công giống nhau, và conflict khi cùng key đi với payload khác.
Scope, retention và TTL là quyết định về correctness
Deduplication chỉ tồn tại lâu bằng record. TTL 24 giờ nghĩa là effectively once trong 24 giờ. TTL phải vượt thời gian client offline, broker retention, manual replay và delayed retry. Operation tài chính có thể cần nhiều tháng; telemetry có thể chỉ cần vài phút.
Sau expiration, cùng key trông như key mới và có thể tạo effect lần nữa. Giảm rủi ro bằng key entropy cao, scope từ server và business uniqueness constraint. Ví dụ, unique (merchant_id, client_order_id) bảo vệ lâu dài độc lập với idempotency cache.
TTL phải dựa trên thời gian server. Cleanup nên chạy theo batch qua index expires_at, tránh table lock lớn và công bố cleanup lag. Partition theo ngày tạo giúp xóa rẻ hơn nếu retention khớp boundary của partition.
Chỉ lưu response tối thiểu để tái tạo API contract. Full body có thể chứa dữ liệu cá nhân hoặc secret; thường status, một số header, resource ID và versioned snapshot là đủ. Record nhạy cảm cần encryption, access control và lifecycle phù hợp. Capacity planning phải tính cả workflow bị từ chối hoặc bỏ dở.
Outbox và inbox nối local truth với message
HTTP endpoint idempotent chỉ giải quyết operation cục bộ. Publish event làm xuất hiện dual-write problem: commit order và publish OrderCreated là hai hành động độc lập. Publish trước có thể thông báo một order rồi transaction lại rollback. Commit trước có thể để lại order không có event nếu process chết.
Transactional outbox ghi domain change và một outbox row trong cùng database transaction. Relay sẽ publish row đó theo cơ chế at-least-once. Nó chỉ đánh dấu row đã gửi sau broker acknowledgement, vì vậy crash giữa publish và mark có thể làm message được publish lại. Duplicate này là chủ ý và phải được xử lý ở downstream.
import type { OutboxStore, Publisher } from './messaging.js';
export async function relayOutbox(
outbox: OutboxStore,
publisher: Publisher,
): Promise<void> {
const messages = await outbox.claimBatch({ limit: 100, leaseMs: 30_000 });
for (const message of messages) {
try {
await publisher.publish(message.topic, message.payload, {
messageId: message.id,
});
await outbox.markPublished(message.id, message.leaseToken);
} catch (error) {
await outbox.releaseForRetry(message.id, message.leaseToken, error);
}
}
}
Ở consumer, inbox table áp dụng quy tắc đối xứng. Trong một transaction, insert message ID vào inbox table có unique constraint (consumer, message_id), cập nhật local domain state rồi commit. Nếu insert conflict, acknowledge duplicate mà không áp dụng state transition lần nữa. Deduplication identity thường phải là event ID ổn định từ producer, không phải broker delivery tag thay đổi sau mỗi lần redelivery.
Cách ghép này cho mô tả trung thực: producer commit local intent một lần, outbox và broker chuyển ít nhất một lần, consumer commit event vào local database một lần. External notification vẫn cần provider idempotency hoặc business rule; chuyển tiền còn cần reconciliation.
Vận hành sự mơ hồ, không chỉ happy path
Hãy theo dõi claim rate, replay rate, payload mismatch, tuổi của state in-progress, lease takeover, outbox backlog, inbox duplicate rate và cleanup lag. Alert theo record cũ nhất thay vì chỉ đếm backlog: một partition nhỏ bị poison có thể nghiêm trọng hơn burst lớn đang thoát đều.
Phân loại failure tường minh: validation error là terminal; business rejection có thể là completed outcome; timeout và dependency failure cần backoff cùng jitter. Poison message cần quarantine đủ context. Manual replay phải giữ message ID ban đầu, nếu không sẽ bypass inbox.
Administrator không nên tự đổi processing thành succeeded. Recovery phải kiểm tra domain resource và provider bằng correlation identifier, rồi gắn kết quả, release claim hoặc compensate. Mọi repair action cần được audit.
Khi đổi schema, deploy reader hiểu cả format cũ và mới trước writer. Version stored response khi API tiến hóa. Nếu không thể giữ response cũ an toàn, hãy công bố replay window ngắn hơn và trả resource reference sau đó.
Hãy diễn tập timing window nguy hiểm: crash trước commit, sau commit nhưng trước response, duplicate song song, publish hoặc consumer commit trước acknowledgement, lease hết giữa remote call, và cleanup đụng late retry. Chúng mô tả guarantee chính xác hơn nhãn “exactly once”.
Điều cần ghi nhớ
Exactly-once delivery hiếm khi là thuộc tính end-to-end của distributed system. Thông thường nó là atomicity guarantee trong một phạm vi giới hạn, cộng application-level deduplication tại từng boundary. Hãy phát biểu boundary đó thật chính xác.
Dùng operation key ổn định, scope theo tenant và loại operation, ràng buộc key với canonical payload hash, rồi để database uniqueness constraint chọn winner khi có concurrency. Commit domain effect và response có thể replay cùng nhau khi chúng nằm trong một database. Với công việc bất đồng bộ, dùng state, lease, fencing và reconciliation tường minh thay vì giả định timer hết hạn nghĩa là chưa có gì xảy ra.
Nối database state với messaging bằng outbox, bảo vệ consumer bằng inbox, và luôn dự kiến relay lẫn broker sẽ redeliver. Chọn TTL theo retry horizon thật, cleanup theo từng batch, theo dõi tuổi record mắc kẹt bên cạnh tổng số lượng.
Mục tiêu không phải ngăn mọi packet trùng lặp. Mục tiêu là khiến duplicate attempt quan sát được và không thể tạo kết quả nghiệp vụ thứ hai.