Mở đầu — vì sao bài này quan trọng
Hãy hình dung tình huống này: bạn đang chuyển khoản 5 triệu đồng qua ứng dụng ngân hàng. Bạn bấm "Xác nhận", màn hình quay quay... rồi mạng 4G chập chờn, app báo lỗi "Timeout". Bạn không chắc tiền đã đi hay chưa. Bạn bấm "Xác nhận" lần nữa. Và đây là câu hỏi sống còn: hệ thống có trừ của bạn 10 triệu không?
Nếu hệ thống được thiết kế đúng nguyên tắc idempotency, câu trả lời là KHÔNG — dù bạn bấm bao nhiêu lần, tài khoản vẫn chỉ bị trừ đúng 5 triệu. Nếu thiết kế sai, bạn vừa bị trừ tiền hai lần và phải gọi tổng đài khiếu nại.
Với một Technical BA, idempotency không phải là chuyện "để cho dev lo". Đây là một yêu cầu nghiệp vụ-kỹ thuật mà bạn phải chủ động đặt ra trong spec. Khi bạn viết spec cho một API thanh toán, một webhook nhận sự kiện, hay một luồng tích hợp giữa hai hệ thống, nếu bạn quên không nói rõ "thao tác này phải idempotent" thì gần như chắc chắn sẽ có sự cố trùng lặp dữ liệu trong môi trường production — và lúc đó người ta sẽ hỏi: "Spec đâu? Sao không ai nghĩ tới?".
Bài này dạy bạn hiểu bản chất idempotency, các pattern triển khai phổ biến, và quan trọng nhất: cách viết yêu cầu idempotency vào tài liệu đặc tả sao cho dev hiểu và làm đúng.
Khái niệm cốt lõi
Định nghĩa: chạy một lần hay N lần, kết quả vẫn như nhau
Một thao tác (operation) được gọi là idempotent nếu thực hiện nó một lần hay nhiều lần liên tiếp với cùng tham số đầu vào đều cho ra cùng một trạng thái kết quả, và không tạo ra hiệu ứng phụ ngoài ý muốn.
Ví dụ đời thường dễ hiểu nhất là cái công tắc đèn dạng nút "Bật". Bấm "Bật" rồi, bấm thêm 5 lần nữa thì đèn vẫn sáng — trạng thái không đổi. Đó là idempotent. Ngược lại, nút "Đảo trạng thái" (toggle) thì KHÔNG idempotent: bấm số lần chẵn thì tắt, số lần lẻ thì sáng — kết quả phụ thuộc vào số lần bấm.
Lưu ý quan trọng: idempotent KHÔNG có nghĩa là "lần thứ hai phải trả về đúng response giống lần đầu". Nó nói về trạng thái cuối cùng của hệ thống (side effect). Lần đầu tạo đơn hàng có thể trả về 201 Created, lần thứ hai trả về 200 OK kèm đúng đơn hàng đã tạo — đó vẫn là idempotent, vì không có đơn hàng thứ hai nào được sinh ra.
Idempotent trong thế giới HTTP
Đặc tả HTTP đã quy định sẵn tính idempotent của các method, và đây là kiến thức nền BA cần nắm:
- GET, HEAD: idempotent và "safe" (không làm thay đổi dữ liệu). Đọc một sản phẩm 100 lần vẫn an toàn.
- PUT: idempotent.
PUT /users/123với body{name: "An", email: "an@x.vn"}— gọi bao nhiêu lần thì user 123 vẫn có đúng dữ liệu đó. PUT là "ghi đè toàn bộ trạng thái". - DELETE: idempotent về mặt trạng thái. Xóa user 123 lần đầu thành công, lần hai trả về 404 hoặc 204 nhưng trạng thái cuối vẫn là "user 123 không tồn tại".
- POST: KHÔNG idempotent theo mặc định.
POST /ordersmỗi lần gọi tạo một đơn mới. Đây chính là nơi 90% sự cố trùng lặp xảy ra, và là nơi BA phải đặc biệt chú ý.
Tại sao mạng buộc ta phải quan tâm?
Vấn đề gốc rễ nằm ở chỗ: trong hệ thống phân tán, client không bao giờ biết chắc request có tới đích và được xử lý hay không khi gặp lỗi mạng. Có ba kịch bản khi client nhận timeout:
- Request chưa tới server (an toàn để gửi lại).
- Request tới server, xử lý xong, nhưng response bị mất trên đường về (gửi lại sẽ tạo trùng).
- Request đang được xử lý dở (gửi lại có thể gây race condition).
Tương tự với webhook: khi sàn thương mại điện tử như Shopee gọi webhook báo "đơn hàng đã thanh toán" tới hệ thống của bạn, nếu họ không nhận được 200 OK trong vài giây, họ sẽ gửi lại sự kiện đó nhiều lần (đa số nền tảng cam kết "at-least-once delivery" — giao ít nhất một lần, nghĩa là có thể giao nhiều lần). Phía nhận webhook bắt buộc phải xử lý idempotent, nếu không một đơn được cộng tiền hoa hồng hai, ba lần.
Idempotency Key — chìa khóa của giải pháp
Pattern phổ biến nhất để biến một POST không-idempotent thành idempotent là dùng Idempotency Key (khóa chống trùng). Ý tưởng:
- Client tạo một mã định danh duy nhất cho mỗi "ý định nghiệp vụ" (thường là một UUID), gửi kèm trong header, ví dụ
Idempotency-Key: 8f3a2b1c-.... - Server lần đầu nhận key này: xử lý, lưu kết quả gắn với key, trả về response.
- Server nhận lại đúng key đó (do retry): KHÔNG xử lý lại, mà trả về luôn kết quả đã lưu.
Tình huống thực tế
Tình huống 1 — Ví điện tử MoMo và cú double-charge giả định
Giả sử đội phát triển của một ví điện tử lớn tại Việt Nam triển khai API POST /transactions/pay. Ban đầu spec chỉ ghi đơn giản: "Nhận số tiền, mã người nhận, trừ tiền người gửi, cộng tiền người nhận".
Trong giờ cao điểm Tết, mạng di động quá tải. Một người dùng thanh toán hóa đơn điện 850.000đ. App gặp timeout sau 8 giây, thư viện HTTP tự retry thêm 2 lần. Server thực ra đã xử lý cả 3 request — người dùng bị trừ 2.550.000đ.
Số lượng khiếu nại tăng vọt trong ngày, đội vận hành phải đối soát và hoàn tiền thủ công, tốn hàng chục giờ công và mất uy tín. Khi điều tra, người ta phát hiện spec gốc không hề nhắc tới idempotency.
Cách khắc phục: thêm Idempotency-Key do app sinh ra một lần khi người dùng bấm "Thanh toán", lưu vào bảng idempotency_records với ràng buộc unique trên cột key. Lần retry thứ hai, thứ ba khi tới server sẽ vi phạm ràng buộc unique, server bắt lỗi đó và trả về kết quả giao dịch đầu tiên.
Bài học: Với mọi API liên quan tới tiền, idempotency là yêu cầu bắt buộc, không phải "nice to have". BA phải đưa nó vào spec ngay từ đầu, kèm câu hỏi cụ thể: "Key sinh ở đâu? Lưu bao lâu? Hành vi khi gặp key trùng là gì?".
Tình huống 2 — Tiki và webhook thanh toán giao nhiều lần
Một sàn TMĐT lớn (gọi tên giả định là sàn X) tích hợp với cổng thanh toán bên thứ ba. Cổng thanh toán gửi webhook payment.succeeded về sàn để cập nhật trạng thái đơn và kích hoạt việc cộng điểm thưởng cho khách.
Cổng thanh toán cam kết at-least-once: nếu sàn không phản hồi 200 trong 5 giây, họ gửi lại sau 1 phút, 5 phút, 30 phút. Một lần, hệ thống sàn bị nghẽn nên xử lý chậm, trả response sau 7 giây. Cổng đã gửi lại webhook. Kết quả: một đơn hàng được cộng điểm thưởng hai lần, khách hàng "tự nhiên" có thêm vài chục nghìn điểm.
Cách khắc phục mà đội đã áp dụng: mỗi sự kiện webhook đều mang một event_id duy nhất. Phía sàn tạo bảng processed_events. Khi nhận webhook, việc đầu tiên là kiểm tra event_id đã xử lý chưa — nếu rồi thì trả 200 ngay mà không làm gì thêm (gọi là deduplication — khử trùng). Toàn bộ logic cộng điểm chỉ chạy khi đây là event_id mới, và việc ghi nhận "đã xử lý" cùng việc cộng điểm được gói trong một transaction để đảm bảo nguyên tử.
Bài học: Webhook luôn phải được coi là "có thể nhận trùng". Khi BA viết spec cho luồng nhận webhook, phải hỏi đối tác: "Sự kiện có ID duy nhất không? Cơ chế giao là at-least-once hay exactly-once?" và yêu cầu logic nhận phải khử trùng theo ID đó.
Tình huống 3 — Lô xử lý lương cuối tháng và retry batch
Một công ty fintech chạy job batch tính lương cho 12.000 nhân viên đối tác mỗi cuối tháng. Job gọi API POST /payouts cho từng người. Một đêm, job chạy được 7.000 bản ghi thì server bị restart do triển khai phiên bản mới. Đội vận hành cho chạy lại job từ đầu.
Vì API POST /payouts không idempotent, 7.000 người đầu tiên nhận lương lần thứ hai. Thiệt hại tài chính trực tiếp và rất khó thu hồi.
Cách thiết kế đúng: mỗi lần chi trả gắn một khóa nghiệp vụ tự nhiên (natural key) — ví dụ tổ hợp employee_id + payroll_period (mã nhân viên + kỳ lương "2026-06"). Server ràng buộc unique trên tổ hợp này. Khi job chạy lại, 7.000 bản ghi đầu vi phạm unique và bị bỏ qua một cách an toàn, chỉ 5.000 bản ghi còn lại được tạo mới.
Bài học: Idempotency không nhất thiết phải dùng UUID ngẫu nhiên. Trong nhiều nghiệp vụ, tồn tại sẵn một natural key (kỳ lương, mã hóa đơn, mã đơn hàng) đảm bảo tính duy nhất theo logic kinh doanh. BA giỏi sẽ nhận ra natural key này và đề xuất dùng nó — vừa đơn giản, vừa khớp ngữ nghĩa nghiệp vụ.
Hướng dẫn từng bước
Khi bạn — với vai trò Technical BA — cần đưa idempotency vào một đặc tả, hãy đi theo các bước sau:
Bước 1 — Xác định các thao tác "có hậu quả". Rà soát luồng nghiệp vụ, đánh dấu mọi thao tác tạo ra side effect không thể tự hoàn tác: trừ tiền, tạo đơn, gửi email/SMS, cộng điểm, xuất kho. Đây là những chỗ cần idempotency. Thao tác chỉ đọc (GET) thì bỏ qua.
Bước 2 — Quyết định nguồn khóa chống trùng. Tự hỏi: nghiệp vụ này đã có sẵn một định danh duy nhất chưa? Nếu có (mã hóa đơn, kỳ lương, mã giao dịch đối tác) thì dùng natural key. Nếu không, yêu cầu client sinh Idempotency-Key dạng UUID.
Bước 3 — Đặc tả nơi sinh và vòng đời của key. Ghi rõ: key được sinh tại thời điểm nào (lý tưởng là khi người dùng bấm nút lần đầu, trước khi gửi request), được giữ nguyên qua mọi lần retry, và được lưu phía server trong bao lâu (ví dụ 24 giờ là đủ cho hầu hết kịch bản retry; với đối soát tài chính có thể lâu hơn).
Bước 4 — Đặc tả hành vi khi gặp key trùng. Đây là phần dev hay hỏi ngược lại BA. Hãy ghi rõ ba tình huống: (a) key trùng và request đầu đã xử lý xong → trả về response đã lưu; (b) key trùng nhưng request đầu đang xử lý dở → trả về 409 Conflict báo "đang xử lý"; (c) key trùng nhưng tham số body khác nhau → trả về lỗi 422, vì cùng key mà khác nội dung là dấu hiệu client đang dùng sai.
Bước 5 — Yêu cầu tính nguyên tử (atomicity). Đặc tả phải nói rõ: việc "ghi nhận key đã xử lý" và "thực hiện side effect" phải nằm trong cùng một transaction. Nếu tách rời, có thể xảy ra trường hợp trừ tiền xong nhưng chưa kịp ghi key, rồi retry lại trừ lần nữa.
Bước 6 — Thêm vào tiêu chí chấp nhận (acceptance criteria). Viết test case kiểm thử rõ ràng, ví dụ: "Gửi cùng request 3 lần liên tiếp với cùng Idempotency-Key → chỉ một đơn hàng được tạo, hai lần sau trả về đúng đơn đầu tiên".
Lỗi thường gặp & mẹo
Lỗi 1 — Nhầm idempotent với "trả về response giống nhau". Nhiều người tưởng cần phải lưu nguyên response để phát lại. Bản chất là trạng thái hệ thống không đổi. Lần hai trả về 200 thay vì 201 vẫn hoàn toàn ổn.
Lỗi 2 — Để client sinh key mới cho mỗi lần retry. Đây là lỗi vô hiệu hóa toàn bộ cơ chế. Key phải được sinh một lần cho mỗi ý định nghiệp vụ và tái sử dụng khi retry. BA phải ghi điều này thật rõ.
Lỗi 3 — Tách kiểm tra trùng và xử lý thành hai bước không nguyên tử. Dưới tải cao, hai request cùng key tới gần như đồng thời, cả hai cùng "thấy chưa tồn tại" rồi cùng xử lý — gây race condition. Mẹo: dùng ràng buộc unique constraint ở tầng database làm chốt chặn cuối cùng, vì database đảm bảo tính nguyên tử ở mức cao nhất.
Lỗi 4 — Quên đặt thời gian sống cho key. Nếu lưu key vĩnh viễn, bảng phình to vô hạn. Nếu xóa quá sớm, retry muộn lọt qua và gây trùng. Hãy đặc tả TTL hợp lý theo đặc tính retry của hệ thống nguồn.
Lỗi 5 — Tưởng GET luôn an toàn nên không cần xét. Đúng là GET idempotent, nhưng nếu một endpoint "GET" lén thay đổi dữ liệu (ví dụ tăng bộ đếm lượt xem mỗi lần gọi) thì nó đã vi phạm hợp đồng HTTP. Mẹo: trong spec, mọi thao tác làm thay đổi dữ liệu phải dùng POST/PUT/PATCH/DELETE, không "giấu" trong GET.
Mẹo vàng cho BA: Mỗi khi viết spec cho một API có chữ "tạo", "thanh toán", "gửi", "cộng", hãy dừng lại tự hỏi: "Nếu request này tới hai lần thì sao?". Câu hỏi đơn giản này giúp bạn không bao giờ bỏ sót yêu cầu idempotency.
Bài tập thực hành
Bài 1 — Phân loại. Cho các thao tác sau, hãy xác định cái nào idempotent, cái nào không, và giải thích: (a) PUT /profile cập nhật toàn bộ hồ sơ; (b) POST /comments thêm bình luận; (c) DELETE /cart/items/55; (d) một API "tăng số dư thêm 100.000đ"; (e) một API "đặt số dư bằng 100.000đ".
Bài 2 — Viết yêu cầu. Bạn nhận yêu cầu spec cho API POST /orders của một sàn TMĐT. Hãy viết một đoạn đặc tả idempotency hoàn chỉnh, bao gồm: nguồn key, vòng đời, TTL, và ba hành vi khi gặp key trùng (đã xử lý xong / đang xử lý / body khác nhau).
Bài 3 — Phân tích natural key. Một ngân hàng cần API ghi nhận "phí thường niên thẻ tín dụng" thu mỗi năm một lần cho mỗi thẻ. Hãy đề xuất một natural key đảm bảo không bao giờ thu phí hai lần trong cùng một năm, và viết tiêu chí chấp nhận kiểm thử cho nó.
Bài 4 — Tình huống webhook. Đối tác logistics gửi webhook delivery.completed theo cơ chế at-least-once. Mỗi lần "giao thành công" sẽ kích hoạt gửi SMS cho khách và cộng KPI cho shipper. Hãy mô tả cơ chế khử trùng để khách không nhận SMS hai lần và shipper không bị cộng KPI ảo.
Tóm tắt
Idempotency là nguyên tắc đảm bảo một thao tác thực hiện một lần hay nhiều lần đều cho cùng một trạng thái kết quả — và đây là tuyến phòng thủ thiết yếu trong mọi hệ thống phân tán, nơi lỗi mạng, retry tự động và webhook giao trùng là chuyện thường ngày.
Những điểm cốt lõi cần nhớ:
- Bản chất nằm ở trạng thái cuối của hệ thống, không phải ở response trả về.
- GET/PUT/DELETE idempotent sẵn; POST thì không — và đó là nơi rủi ro trùng lặp tập trung.
- Idempotency Key (hoặc natural key sẵn có trong nghiệp vụ) cùng với unique constraint ở database và xử lý nguyên tử trong một transaction là bộ ba giải pháp kinh điển.
- Webhook luôn phải khử trùng theo
event_id, vì hầu hết nền tảng chỉ cam kết at-least-once.