Quay lại bài viết
28 thg 4, 2026
9 min read

Idempotency 101: Vì sao “bấm 2 lần” không trừ tiền 2 lần — Mổ xẻ Create Payment API

Hãy tưởng tượng: bạn đang ở quầy thanh toán online, bấm nút “Pay $100”. Spinner xoay. 5 giây trôi qua. 10 giây. Mạng wifi của quán cafe lúc được lúc không. Bạn sốt ruột, bấm thêm lần nữa. Rồi lần nữa.

Nửa tiếng sau, bạn nhận được 3 email xác nhận và tài khoản bị trừ $300.

Đây là cơn ác mộng kinh điển của mọi đội ngũ làm payment, và là lý do tồn tại của một khái niệm tưởng nghe rất hàn lâm: Idempotency. Bài này sẽ đi từ nguồn gốc toán học của nó, sang cách nó hoạt động trong API, và cuối cùng là một implementation chạy được cho Create Payment với Node.js + Redis.


1. Nguồn gốc và bối cảnh

1.1. Định nghĩa: một thao tác idempotent là gì?

Trong Computer Science, một thao tác (operation, function, API call) được gọi là idempotent nếu:

f(f(x)) = f(x)

Hay nói cách khác: chạy thao tác đó 1 lần hay N lần đều cho cùng một kết quả lên hệ thống.

1.2. HTTP — RFC 2616 (1999) → RFC 9110 (2022)

Khi web nổi lên, IETF chuẩn hoá khái niệm này vào HTTP semantics. Theo RFC 9110:

MethodIdempotent?Safe?
GET
HEAD
OPTIONS
PUT
DELETE
POST
PATCH

POST cố tình không idempotent vì nó được thiết kế để “tạo mới một resource” — mỗi lần POST tạo ra một entity mới.

Đây là semantic contract, không phải implementation guarantee. RFC chỉ nói rằng nếu bạn implement đúng tinh thần, các method này phải idempotent. Còn việc bug làm sai thì… vẫn sai.

1.3. Distributed systems era: Stripe và Idempotency-Key

Đến giữa thập niên 2010, microservices và mobile bùng nổ. Mạng trở thành công dân hạng hai: timeout, retry, mobile chuyển vùng, proxy retry… đã trở thành chuyện thường ngày. Vấn đề là POST thì không idempotent, nhưng business lại có vô vàn POST quan trọng: tạo đơn, thanh toán, gửi tiền…

Năm 2015, Stripe publish một blog post nổi tiếng giới thiệu pattern Idempotency-Key — một HTTP header mà client gửi kèm để biến POST thành “an toàn khi retry”. Pattern này nhanh chóng trở thành chuẩn ngầm: Square, PayPal, Shopify, AWS… đều adopt.

“Mạng không đáng tin. Mọi request đều có thể timeout, mọi response có thể bị mất. Idempotency chính là hợp đồng giữa client và server: ‘cứ retry đi, tôi sẽ không làm hỏng dữ liệu của anh’.“


2. Cách hoạt động: Cốt lõi của một Idempotent Operation

2.1. Vì sao Create Payment cần idempotency?

Hãy liệt kê các kịch bản gây double-charge trong thực tế:

  1. Network timeout giữa client và server. Server đã xử lý xong, đã trừ tiền, nhưng response trên đường về thì bị mất do TCP reset. Client tưởng failed, retry → trừ tiền lần 2.
  2. User refresh / double-click. Trang loading lâu, user F5 hoặc bấm 2 lần. Browser gửi 2 request gần như đồng thời.
  3. Mobile retry policy. SDK của mobile (đặc biệt khi chuyển từ wifi sang 4G) tự động retry các failed request.
  4. Proxy / Load Balancer retry. Một số proxy được cấu hình retry khi upstream timeout. Client thậm chí không biết.

Trong tất cả các kịch bản trên, client không có cách nào biết server đã xử lý hay chưa. Cách duy nhất an toàn là… hỏi server. Idempotency cho phép câu hỏi đó dưới dạng: “Đây là retry của request X, đừng làm lại nha”.

2.2. Idempotent “tự nhiên” vs “nhân tạo”

Một số endpoint idempotent tự nhiên vì ngữ nghĩa:

Còn POST /payments thì không tự nhiên idempotent. Mỗi lần POST mặc định = một payment mới. Để biến nó thành idempotent, ta cần một định danh ngoại — đó chính là Idempotency Key.

2.3. Sơ đồ luồng tổng quát

  1. Client sinh một Idempotency-Key duy nhất (UUID v4) cho mỗi ý định tạo payment.
  2. Client gửi request POST /payments, gắn key vào HTTP header.
  3. Server check key trong storage (Redis):
    • Chưa tồn tại → atomic acquire lock, đánh dấu running, xử lý business logic, lưu kết quả vào storage, trả response.
    • Tồn tại + completed → đọc response cũ, trả lại nguyên văn (status code + body).
    • Tồn tại + running → trả 409 Conflict (request gốc đang chạy, đợi đi).
  4. Key có TTL (Stripe dùng 24h) để không phình storage vô tận.

Điểm quan trọng: client sinh key, không phải server. Vì server không biết “ý định” của client là gì — hai request giống hệt nhau với 2 key khác nhau là 2 ý định khác nhau (user thực sự muốn trả tiền 2 lần), còn 2 request cùng key là retry của cùng 1 ý định.


3. Stripe’s Idempotency-Key: Thiết kế tham chiếu

Stripe là reference design được công nghiệp follow nhiều nhất. Các quyết định thiết kế chính của họ:

Tham khảo chính thức: stripe.com/docs/api/idempotent_requests .


4. Code sample: Create Payment với Express + Redis

Stack: Node.js 20 + TypeScript + Express + ioredis. Redis dùng làm storage cho idempotency record vì nó có sẵn SET NX (atomic) và EX (TTL) — đúng 2 thứ ta cần.

4.1. Schema lưu trong Redis

Ta dùng một discriminated union cho 2 trạng thái của record:

type IdempotencyRecord = | { status: 'running' requestHash: string } | { status: 'completed' requestHash: string statusCode: number body: any }

4.2. Cấu trúc 3 file

Để dễ đọc và dễ test, ta tách logic thành 3 file:

type.ts

export type IdempotencyRecord = | { status: 'running' requestHash: string } | { status: 'completed' requestHash: string statusCode: number body: any }

controller.ts

import { Request, Response } from 'express' import Redis from 'ioredis' import { createHash, randomUUID } from 'crypto' import type { IdempotencyRecord } from './type' const TTL_SECONDS = 24 * 60 * 60 const redis = new Redis(process.env.REDIS_URL ?? 'redis://localhost:6379') function hashBody(body: any): string { return createHash('sha256').update(JSON.stringify(body)).digest('hex') } async function chargeCustomer(body: any) { return { id: randomUUID(), status: 'succeeded', amount: 100 } } export async function createPayment(req: Request, res: Response) { const key = req.header('Idempotency-Key') if (!key) { return res.status(400).json({ error: 'Idempotency-Key header is required' }) } const requestHash = hashBody(req.body) const redisKey = `idem:payments:${key}` const initialRecord: IdempotencyRecord = { status: 'running', requestHash } const acquired = await redis.set(redisKey, JSON.stringify(initialRecord), 'EX', TTL_SECONDS, 'NX') if (!acquired) { const raw = await redis.get(redisKey) const existing = JSON.parse(raw!) as IdempotencyRecord if (existing.requestHash !== requestHash) { return res.status(422).json({ error: 'Idempotency-Key was reused with a different request body', }) } if (existing.status === 'running') { return res.status(409).json({ error: 'A request with the same Idempotency-Key is still being processed', }) } return res.status(existing.statusCode).json(existing.body) } try { const payment = await chargeCustomer(req.body) const completed: IdempotencyRecord = { status: 'completed', requestHash, statusCode: 201, body: payment, } await redis.set(redisKey, JSON.stringify(completed), 'EX', TTL_SECONDS) return res.status(201).json(payment) } catch (err) { await redis.del(redisKey) throw err } }

app.ts

import express from 'express' import { createPayment } from './controller' const app = express() app.use(express.json()) app.post('/payments', createPayment) app.listen(3000, () => { console.log('Payment service listening on :3000') })

Hai dòng quan trọng nhất trong toàn bộ handler:

redis.set(..., 'NX') ở bước 2 — atomic acquire, đảm bảo chỉ một request thắng cuộc đua.

redis.del(redisKey) trong catch ở bước 5 — release lock khi business logic fail, để client còn cơ hội retry. Bỏ qua dòng này, client sẽ kẹt ở 409 mãi cho đến khi TTL hết.


5. Lời khuyên thực tế

  1. Áp dụng cho mọi POST quan trọng: payment, order, refund, send-email, send-sms, transfer. Bất kỳ thao tác nào “làm 2 lần là sai” đều cần idempotency.
  2. Đừng bắt đầu phức tạp. Pattern SET NX + TTL + request hash ở trên đã bao phủ 90% use case. Lease, two-phase commit, outbox pattern chỉ cần khi bạn ở quy mô như Stripe hay AWS.
  3. Đặt vào API contract ngay từ đầu. Thêm Idempotency-Key vào một API đã có 1000 client đang dùng là cực khổ — bạn phải migrate dần, hỗ trợ cả mode có và không có key. Cho nên: ngày đầu tiên thiết kế POST endpoint, hãy hỏi “endpoint này có cần idempotency không?”.
  4. Document rõ behavior: TTL bao lâu, response khi reuse key, response khi in-progress, format key cho phép. Client SDK sẽ implement retry logic dựa trên những gì bạn document.
  5. Đo bằng metrics: tỉ lệ retry hit cache, tỉ lệ 409, tỉ lệ 422. Spike 422 = client đang reuse key sai cách. Spike 409 = có thể có race condition phía client.

Idempotency không phải là một tính năng “nice to have” — nó là invariant mà mọi hệ thống có giao dịch tiền bạc, đơn hàng, hoặc side-effect không reversible đều phải đảm bảo. Còn nếu bạn muốn đào sâu hơn về nhất quán dữ liệu trong hệ phân tán, có thể đọc tiếp bài về Cache Consistency — cùng họ với idempotency, đều xoay quanh chuyện “hai bên không tin nhau, làm sao đồng bộ”.

Liên quan