Menu
ESC

Nhập từ khóa để tìm kiếm

↑↓ Di chuyển
Enter Mở
ESC Đóng

Đang tải...

Idempotency: PUT, POST với idempotency keys

API and Technical Fundamentals cho BA Bài 25/60

Mở đầu — vì sao bài này quan trọng

Hãy tưởng tượng bạn đang thanh toán một đơn hàng 2 triệu đồng trên một sàn thương mại điện tử. Bạn bấm nút "Thanh toán", màn hình quay vòng vòng, mạng 4G chập chờn, và rồi... không thấy gì. Bạn lo lắng, bấm lại lần nữa. Vài giây sau, điện thoại báo hai tin nhắn trừ tiền: 2 triệu, rồi lại 2 triệu nữa. Bạn vừa bị trừ 4 triệu cho một món hàng 2 triệu.

Đây không phải tình huống hiếm. Nó là cơn ác mộng kinh điển của mọi hệ thống có giao dịch tiền bạc, đặt vé, hay tạo đơn. Và giải pháp kỹ thuật để ngăn nó xảy ra chính là chủ đề của bài này: idempotency (tính bất biến khi lặp lại).

Với vai trò BA (Business Analyst), bạn có thể nghĩ "đây là việc của lập trình viên". Nhưng không. Idempotency là một yêu cầu nghiệp vụ trá hình thành yêu cầu kỹ thuật. Khi bạn viết user story "khách hàng thanh toán đơn hàng", nếu bạn không nêu rõ "việc bấm lại không được tạo giao dịch trùng", thì dev có quyền bỏ qua, và sản phẩm sẽ có lỗ hổng nghiêm trọng. Hiểu idempotency giúp bạn viết spec chặt chẽ, đặt đúng câu hỏi trong buổi review API, và bảo vệ doanh nghiệp khỏi những sự cố tốn kém cả tiền lẫn uy tín.

Khái niệm cốt lõi

Idempotent nghĩa là gì

Một thao tác (operation) được gọi là idempotent nếu thực hiện nó nhiều lần cho ra cùng một kết quả như thực hiện đúng một lần. Nói cách khác: gọi N lần và gọi 1 lần thì trạng thái cuối cùng của hệ thống là như nhau.

Một ví dụ đời thường: nút bấm thang máy. Bạn bấm "tầng 5" một lần hay mười lần thì thang máy vẫn chỉ đi đến tầng 5. Hành động bấm là idempotent. Ngược lại, nút "lấy thêm một ly cà phê" ở máy bán hàng tự động thì không idempotent — bấm mười lần là mười ly và mười lần trừ tiền.

Tại sao idempotency lại sống còn

Ba lý do khiến idempotency không phải là tính năng "có thì tốt" mà là "bắt buộc phải có":

Mạng không đáng tin cậy. Khi client gửi request và không nhận được response, nó không biết được: request có tới server không? Server đã xử lý chưa? Response bị mất trên đường về? Trong cả ba trường hợp, client thấy giống hệt nhau — chỉ là "không có hồi đáp". Phản ứng tự nhiên là retry (thử lại). Nếu thao tác không idempotent, retry sẽ nhân đôi hậu quả.

Webhook có thể gửi trùng. Như sẽ học kỹ ở Bài 26, các nhà cung cấp như Stripe, VNPAY hay các cổng thanh toán thường cam kết "at-least-once delivery" — tức là họ đảm bảo gửi event ít nhất một lần, nhưng không hứa đúng một lần. Hệ thống của bạn phải sẵn sàng nhận cùng một event "đơn hàng đã thanh toán" hai, ba lần mà không cộng dồn doanh thu.

Người dùng và hệ thống tự động đều thích lặp. Người dùng double-click. Trình duyệt tự resubmit khi refresh. Các job tự động retry theo lịch. Nếu API của bạn mong manh, mọi hành vi bình thường này đều có thể gây hỏng dữ liệu.

Idempotency theo từng HTTP method

Một điểm quan trọng mà BA hay nhầm: bản thân các HTTP method đã có "khế ước" idempotency riêng do chuẩn HTTP quy định.

  • GET — idempotent một cách tự nhiên. Đọc dữ liệu mười lần không thay đổi gì. (Nó còn "safe", tức là không gây tác dụng phụ.)
  • PUT — idempotent theo định nghĩa. PUT nghĩa là "ghi đè toàn bộ tài nguyên thành trạng thái này". Gọi PUT /users/123 với {name: "An", age: 30} mười lần thì user 123 vẫn là An, 30 tuổi. Kết quả không đổi sau lần đầu.
  • DELETE — idempotent. Xóa đơn hàng 123 lần đầu thì nó biến mất; những lần sau nó vẫn "không tồn tại". Trạng thái cuối giống nhau (dù response code có thể khác: lần đầu 200, lần sau 404).
  • PATCHkhông đảm bảo idempotent. Tùy cách thiết kế. PATCH kiểu "tăng số dư thêm 10.000đ" thì gọi nhiều lần sẽ cộng dồn — không idempotent. Nhưng PATCH kiểu "đặt trạng thái = đã giao" thì lại idempotent.
  • POSTkhông idempotent theo mặc định. POST nghĩa là "tạo mới". Gọi POST /orders ba lần thường tạo ba đơn hàng khác nhau. Đây chính là method nguy hiểm nhất.

Vấn đề của POST và lời giải: Idempotency Key

Vì POST không idempotent tự nhiên, nhưng ta lại rất cần nó an toàn khi retry (tạo đơn, tạo giao dịch thanh toán), ngành công nghiệp đã thống nhất một giải pháp: Idempotency Key (khóa bất biến).

Ý tưởng đơn giản đến bất ngờ:

  • Client tự sinh một chuỗi định danh duy nhất cho mỗi ý định thao tác — thường là một UUID, ví dụ a1b2c3d4-.... Đây là "vé" đại diện cho một lần bấm nút thanh toán của người dùng.
  • Client gửi key này lên server qua một HTTP header, theo quy ước phổ biến là Idempotency-Key.
  • Server ghi nhớ key này. Lần đầu thấy key, server xử lý bình thường, lưu lại key cùng kết quả (response) vào một bảng/cache.
  • Nếu một request cùng key đến lần nữa (do retry), server không xử lý lại. Nó nhận ra "à, key này tôi làm rồi", và trả về đúng kết quả đã lưu của lần đầu.
Mấu chốt: key đại diện cho một ý định nghiệp vụ, không phải một request HTTP. Người dùng bấm "thanh toán" một lần = một ý định = một key. Cho dù mạng khiến request đó được gửi đi năm lần, cả năm lần đều mang cùng key, và server chỉ trừ tiền đúng một lần.

Tình huống thực tế

Tình huống 1 — Ví điện tử bị trừ tiền hai lần ở giờ cao điểm

Một startup fintech tại TP.HCM, tạm gọi là VíNhanh, vận hành ứng dụng ví điện tử với khoảng 80.000 giao dịch chuyển tiền mỗi ngày. Vào dịp khuyến mãi sàn lớn, lưu lượng tăng vọt, server phản hồi chậm. Người dùng gửi tiền, thấy app "quay", sốt ruột bấm lại. API tạo giao dịch của họ là POST /transactions không có idempotency key.

Hậu quả trong một ngày cao điểm: gần 300 trường hợp chuyển tiền trùng. Một khách hàng chuyển 5 triệu cho người thân, bị trừ 10 triệu. Bộ phận chăm sóc khách hàng ngập đơn khiếu nại, đội kỹ thuật phải truy vết thủ công từng giao dịch để hoàn tiền, và niềm tin vào thương hiệu sứt mẻ.

Sau sự cố, đội phân tích nghiệp vụ ngồi lại và viết bổ sung yêu cầu: mọi API tạo giao dịch tài chính phải chấp nhận header Idempotency-Key; server lưu key trong 24 giờ; request trùng key trả về kết quả lần đầu kèm cờ báo "đây là phản hồi lặp lại". Sau khi triển khai, số ca trùng giảm về gần như bằng không.

Bài học: Chi phí để thêm idempotency là vài ngày công của dev. Chi phí để không có nó là tiền hoàn cho khách, giờ làm thủ công, và uy tín thương hiệu. Là BA, bạn là người phải đưa yêu cầu này vào spec trước khi sự cố xảy ra, không phải sau.

Tình huống 2 — Webhook thanh toán của VNPAY gửi event hai lần

Một sàn bán khóa học online tích hợp cổng thanh toán VNPAY. Khi khách thanh toán xong, VNPAY gọi webhook (IPN — Instant Payment Notification) tới hệ thống sàn để báo "đơn hàng X đã thanh toán thành công". Lập trình viên xử lý: nhận webhook → cộng doanh thu → kích hoạt khóa học cho học viên → gửi email biên nhận.

Vấn đề: cổng thanh toán cam kết at-least-once. Vào một số thời điểm mạng, VNPAY không nhận được phản hồi "200 OK" kịp thời từ sàn, nên gửi lại cùng IPN đó. Hệ thống sàn xử lý lần hai: cộng doanh thu thêm một lần nữa, gửi email biên nhận thứ hai cho học viên. Báo cáo doanh thu cuối tháng bị thổi phồng, kế toán đối soát với sao kê ngân hàng thì lệch.

Cách khắc phục mà đội áp dụng: dùng chính mã giao dịch của VNPAY (vnp_TransactionNo) làm khóa idempotency. Trước khi xử lý bất kỳ IPN nào, hệ thống kiểm tra: "mã giao dịch này đã được xử lý chưa?". Nếu rồi, lập tức trả 200 OK mà không làm gì thêm. Nếu chưa, xử lý và đánh dấu đã xử lý — tất cả trong một transaction database để tránh chạy đua.

Bài học: Khi nhận event từ bên thứ ba, đừng tin rằng mỗi event chỉ đến một lần. Hãy luôn hỏi nhà cung cấp: "Cổng có gửi trùng không? Trường nào là định danh duy nhất của giao dịch để tôi dùng làm khóa khử trùng?". Đây là câu hỏi BA bắt buộc đặt khi viết spec tích hợp.

Tình huống 3 — API đặt vé xe khách và cái bẫy "race condition"

Một nền tảng đặt vé xe khách liên tỉnh thêm idempotency key cho API POST /bookings. Họ làm đúng phần "lưu key, trả về kết quả cũ nếu trùng". Nhưng họ mắc một lỗi tinh vi: kiểm tra key và ghi key thực hiện ở hai bước tách rời, không có khóa đồng thời (locking).

Hậu quả: khi người dùng double-click rất nhanh, hai request cùng key đến server gần như cùng lúc. Cả hai cùng kiểm tra "key này có chưa?" → cả hai đều thấy "chưa" (vì chưa request nào kịp ghi) → cả hai cùng tạo booking → đặt trùng ghế, trừ tiền hai lần. Idempotency key có đó nhưng vô dụng vì có race condition.

Họ sửa bằng cách đặt ràng buộc UNIQUE trên cột idempotency key ở tầng database, và xử lý ý định trong một transaction. Khi request thứ hai cố ghi cùng key, database từ chối vì vi phạm UNIQUE, và ứng dụng hiểu đó là "đã có người làm, trả kết quả cũ". Database trở thành "trọng tài" duy nhất, loại bỏ chạy đua.

Bài học: Idempotency không chỉ là "có lưu key hay không", mà là "lưu key một cách an toàn dưới điều kiện đồng thời". Là BA, bạn nên hỏi dev: "Nếu hai request cùng key đến đúng cùng một lúc thì sao?". Câu hỏi này lọc ra ngay cách triển khai hời hợt.

Hướng dẫn từng bước

Đây là quy trình chuẩn để bạn — với vai trò BA — đặc tả và review một endpoint idempotent.

Bước 1 — Xác định endpoint nào cần idempotency. Quét toàn bộ API. Mọi thao tác có tác dụng phụ và "không được phép lặp" đều cần: tạo đơn hàng, tạo giao dịch thanh toán, hoàn tiền, gửi tiền, đặt vé, nhận webhook. Các thao tác chỉ đọc (GET) thì không cần.

Bước 2 — Quy định ai sinh key và sinh thế nào. Thông thường client sinh UUID v4 cho mỗi ý định người dùng. Với webhook đến từ bên thứ ba, dùng định danh giao dịch sẵn có của họ làm key. Ghi rõ trong spec: "Client phải gửi header Idempotency-Key là UUID, duy nhất cho mỗi lần người dùng kích hoạt thao tác."

Bước 3 — Quy định thời gian sống của key (TTL). Key không cần lưu vĩnh viễn. Stripe lưu 24 giờ. Bạn cần thống nhất con số phù hợp nghiệp vụ — thường 24–72 giờ là đủ để bao trùm mọi đợt retry hợp lý.

Bước 4 — Định nghĩa hành vi khi gặp key trùng. Spec phải nêu rõ: request trùng key trả về chính xác response của lần đầu (cùng status code, cùng body), không xử lý lại. Quyết định xem có thêm cờ báo hiệu (như header Idempotent-Replayed: true) hay không.

Bước 5 — Xử lý trường hợp "cùng key nhưng body khác". Đây là tình huống nguy hiểm: client gửi cùng Idempotency-Key nhưng nội dung request lại khác (ví dụ số tiền khác). Quy ước an toàn: server từ chối với lỗi 422 Unprocessable Entity, vì cùng một "ý định" không thể có hai nội dung mâu thuẫn.

Bước 6 — Yêu cầu chống race condition. Ghi vào acceptance criteria: "Hai request đồng thời cùng key chỉ được tạo đúng một tài nguyên." Buộc dev dùng UNIQUE constraint hoặc lock ở tầng database.

Bước 7 — Viết acceptance criteria kiểm thử được. Ví dụ: "Given một đơn đã tạo với key K, when gửi lại POST cùng key K, then không tạo đơn mới và trả về đúng đơn đã tạo cùng status 200/201 như lần đầu."

Lỗi thường gặp & mẹo

Lỗi: nhầm idempotent với "safe". Hai khái niệm khác nhau. "Safe" nghĩa là không gây tác dụng phụ (chỉ GET, HEAD). "Idempotent" nghĩa là lặp lại không đổi kết quả (GET, PUT, DELETE). DELETE là idempotent nhưng không safe vì nó thay đổi dữ liệu.

Lỗi: tưởng PUT luôn an toàn để retry rồi quên POST. Nhiều BA biết "PUT idempotent" nên yên tâm, nhưng các thao tác tạo mới quan trọng nhất lại dùng POST. Đừng để POST bị bỏ quên.

Lỗi: server tự sinh idempotency key. Sai. Nếu server sinh key cho mỗi request nhận được, thì mỗi lần retry sẽ có key khác nhau, mất hoàn toàn tác dụng. Key phải do client sinh và giữ nguyên qua các lần retry của cùng một ý định.

Lỗi: chỉ lưu key mà không lưu kết quả. Nếu server chỉ nhớ "key này đã thấy" mà không lưu response, thì lần retry sẽ không biết trả về gì cho client. Phải lưu cả key lẫn response của lần đầu.

Lỗi: bỏ qua race condition. Như tình huống 3 — cách triển khai ngây thơ "kiểm tra rồi ghi" sẽ vỡ dưới tải đồng thời. Luôn hỏi về cơ chế khóa.

Mẹo cho BA: Trong buổi review API spec, hãy đặt sẵn ba câu hỏi vàng: (1) "Endpoint này có idempotent không, và bằng cơ chế gì?"; (2) "Nếu client retry vì timeout thì điều gì xảy ra?"; (3) "Hai request cùng key đến đồng thời thì sao?". Ba câu này phơi bày phần lớn lỗ hổng.

Mẹo phân biệt nhanh: Tự hỏi "thao tác này lặp lại có nhân đôi hậu quả không?". Nếu có (trừ tiền, tạo đơn) → bắt buộc idempotency key. Nếu không (đổi trạng thái thành "đã đọc") → có thể đã idempotent tự nhiên.

Bài tập thực hành

  • Phân loại method. Với mỗi thao tác sau, xác định nó idempotent hay không và giải thích: (a) GET /products/55; (b) DELETE /carts/99; (c) POST /orders; (d) PATCH /accounts/12 với nội dung "cộng thêm 50 điểm thưởng"; (e) PUT /users/8 ghi đè toàn bộ hồ sơ.
  • Viết acceptance criteria. Bạn là BA cho tính năng "nạp tiền vào ví". Hãy viết 3 acceptance criteria theo format Given-When-Then bao phủ: nạp lần đầu thành công, retry cùng key không nạp trùng, và hai request đồng thời cùng key chỉ nạp một lần.
  • Tình huống tích hợp. Sàn của bạn nhận webhook hoàn tiền từ một cổng thanh toán cam kết at-least-once. Hãy liệt kê: trường dữ liệu nào bạn sẽ dùng làm khóa khử trùng, và mô tả bằng lời quy trình server xử lý để một event hoàn tiền dù đến ba lần cũng chỉ hoàn đúng một lần.
  • Săn lỗ hổng. Một dev nói: "Em đã thêm idempotency: server kiểm tra key trong bảng, nếu chưa có thì xử lý và thêm key vào bảng." Hãy chỉ ra lỗ hổng tiềm ẩn trong câu mô tả này và đề xuất câu hỏi bạn sẽ hỏi để xác nhận.

Tóm tắt

Idempotency là tính chất "gọi N lần kết quả như gọi 1 lần", và nó là tuyến phòng thủ thiết yếu trước ba thực tế không thể tránh: mạng không đáng tin, webhook gửi trùng, và người dùng/hệ thống thích retry. Theo chuẩn HTTP, GET, PUT, DELETE vốn idempotent; còn POST và phần lớn PATCH thì không, nên các thao tác tạo mới quan trọng — đặc biệt là giao dịch tiền bạc — cần một cơ chế bổ sung: Idempotency Key. Client sinh một key duy nhất cho mỗi ý định nghiệp vụ, server ghi nhớ key cùng kết quả, và mọi request trùng key đều trả về kết quả lần đầu thay vì xử lý lại. Triển khai đúng còn đòi hỏi chống race condition bằng khóa ở tầng database. Với vai trò BA, nhiệm vụ của bạn không phải viết code, mà là nhận diện đúng endpoint cần idempotency, đưa nó vào spec dưới dạng acceptance criteria kiểm thử được, và đặt những câu hỏi sắc bén trong buổi review để đảm bảo doanh nghiệp không bao giờ trừ tiền khách hàng hai lần.