---
title: "Idempotency là gì? Idempotency key chống trừ tiền hai lần khi retry"
description: "Idempotency key giúp POST retry an toàn khi timeout. Middleware Go chạy thật: giữ chỗ atomic, 409 khi trùng đồng thời, replay đúng response cũ."
canonical: "https://200lab.io/blog/idempotency-key-la-gi"
published: "2026-10-31T15:14:43Z"
updated: "2026-10-31T15:37:55Z"
authors: ["Việt Trần"]
tags: ["Backend"]
reading_minutes: 15
access: "public"
---

User bấm thanh toán, mạng chập chờn, app timeout rồi tự retry, và khách bị trừ tiền hai lần. Hiểu đúng **idempotency là gì** là cách chặn chuyện này từ phía server.

Làm hệ thống dính tới tiền hay đơn hàng thì câu hỏi không phải là "mạng có rớt không", mà là "khi app retry thì có sinh ra hai giao dịch không".

## Idempotency là gì?

Theo chuẩn [RFC 9110 (mục Idempotent Methods)](https://www.rfc-editor.org/rfc/rfc9110.html#name-idempotent-methods), một HTTP method được gọi là **idempotent** (tạm dịch là tính lũy đẳng, dịch xong còn khó hiểu hơn nên mình giữ tiếng Anh) nếu nhiều request giống hệt nhau tác động lên server y như một request duy nhất.

Khi anh em làm việc với [REST API](/blog/rest-api-la-gi), các safe method như `GET`, `HEAD`, `OPTIONS`, `TRACE` cùng với `PUT` và `DELETE` mặc định là idempotent. Ngược lại, `POST` và `PATCH` thì không.

**NHƯNG** có một điểm nhiều bạn hiểu lầm: idempotency nói về **tác động lên server**, không phải response lần nào cũng giống hệt nhau.

Ví dụ gọi `DELETE /orders/123`. Lần đầu server xóa đơn và trả `200 OK`. Gọi lại lần hai, server có thể trả `404 Not Found` vì đơn mất tiêu rồi. Status code khác nhau, nhưng đơn 123 vẫn chỉ bị xóa đúng một lần. Đó là idempotent.

## Vì sao API cần idempotency key: timeout rồi retry là trừ tiền hai lần

`POST` thì khác: nó tạo mới hoặc kích hoạt một hành động (trừ tiền, tạo đơn). Gọi `POST` N lần là bảo server làm việc đó N lần.

Kịch bản kinh điển, cũng là kịch bản mà [IETF draft về Idempotency-Key header](https://datatracker.ietf.org/doc/draft-ietf-httpapi-idempotency-key-header/) mô tả: client gửi `POST` thanh toán, server trừ tiền xong nhưng response không về tới nơi. Client dính timeout, không biết tiền đã trừ hay chưa, nên retry.

Chúng ta thử xem đoạn code dưới đây nha:

```go
// step1/main.go
package main

import (
	"fmt"
	"net/http"
	"net/http/httptest"
	"strings"
	"sync"
)

var (
	mu      sync.Mutex
	balance = 1_000_000 // số dư ví: 1 triệu
)

func charge(w http.ResponseWriter, r *http.Request) {
	mu.Lock()
	balance -= 200_000 // trừ 200k cho đơn hàng
	mu.Unlock()
	fmt.Fprintln(w, `{"status":"paid"}`)
}

func main() {
	srv := httptest.NewServer(http.HandlerFunc(charge))
	defer srv.Close()

	// Lần 1: server đã trừ tiền, nhưng giả sử response bị mất trên đường về
	http.Post(srv.URL, "application/json", strings.NewReader(`{"order":"A1"}`))
	// Client không nhận được gì nên retry y chang request cũ
	http.Post(srv.URL, "application/json", strings.NewReader(`{"order":"A1"}`))

	fmt.Println("Số dư còn lại:", balance) // mong đợi 800000
}
```

```text
Số dư còn lại: 600000
```

_Output của chương trình trên_

Trong ví dụ trên, ví có 1.000.000, đơn hàng 200.000. Lần gọi đầu server đã trừ tiền, giả sử response bị mất trên đường về. Client retry, số dư tụt xuống 600.000 thay vì 800.000.

Với server, hai request `POST` này là hai lệnh độc lập. Nó không có manh mối nào để phân biệt "giao dịch mới" với "giao dịch cũ gửi lại".

## Idempotency key là gì và client gửi nó lên server thế nào?

**Idempotency key** chính là cái manh mối đó: một giá trị duy nhất do **client tự sinh ra** cho mỗi hành động, gửi kèm trong header `Idempotency-Key`. Server nhìn key này để nhận ra các lần retry của cùng một request.

Để cho dễ hiểu, hãy tưởng tượng một quán ăn. Mỗi lần gọi món, khách cầm một tờ phiếu có số. Khách sốt ruột hỏi lại "phiếu số 12 của tôi đâu?", bếp nhìn số phiếu là biết, bưng ra đúng phần đã nấu chứ không nấu thêm phần mới. Còn nếu phiếu 12 đang nấu dở, nhân viên bảo khách chờ chút.

Cực kỳ đơn giản.

Khi giao tiếp qua HTTP, header này trông như sau:

```http
POST /payments HTTP/1.1
Idempotency-Key: "8e03978e-40d5-43e8-bc93-6894a57f9324"
```

Một chi tiết nhỏ: theo IETF draft (bản 08 ngày 22/10/2026, vẫn là Internet-Draft, chưa thành RFC), giá trị header là Structured Field String nên có ngoặc kép. Còn ví dụ trong [Stripe docs về Idempotent requests](https://docs.stripe.com/api/idempotent_requests) thì gửi chuỗi trần, không ngoặc kép.

Phía client có mấy nguyên tắc sinh key:

- **Sinh một lần cho một ý định:** key được tạo khi user bấm thanh toán. Timeout thì retry với đúng key cũ. User quay ra đổi giỏ hàng rồi bấm lại là ý định mới, key mới.
- **Dùng UUID:** cả IETF draft và Stripe đều gợi ý UUID (Stripe nói rõ V4 UUID). Stripe giới hạn key tối đa 255 ký tự.
- **Không nhét dữ liệu nhạy cảm vào key:** email hay số điện thoại của user không được dùng làm idempotency key.

> 💡 Bạn chỉ cần nhớ cơ bản là:
>
> - Một ý định của user = một key.
> - Retry = gửi lại đúng key cũ.

## Idempotency key khác idempotent method và Idempotent Consumer ở đâu?

Ba khái niệm này hay bị trộn làm một, vì đứa nào cũng có chữ "idempotent" trong tên.

Thật ra chúng nằm ở các tầng khác nhau của bài toán.

- **Idempotent method** là tính chất của chính method HTTP (theo RFC 9110). `GET`, `DELETE` vốn đã idempotent, nên Stripe ghi rõ: gửi idempotency key cho `GET` và `DELETE` chả có tác dụng gì.
- **Idempotency key** là cơ chế ở tầng ứng dụng. Theo IETF draft, nó giúp `POST`, `PATCH` thành fault-tolerant (chịu lỗi), để client retry an toàn.
- **Idempotent Consumer** cùng ý tưởng chống trùng, nhưng nằm ở phía worker đọc message từ queue. Phần này 200Lab có bài riêng: [Idempotent Consumer là gì](/blog/idempotent-consumer-la-gi).

Gom lại thành bảng cho dễ nhìn:

| Khái niệm | Nằm ở đâu | Chống trùng bằng cách nào |
| :--- | :--- | :--- |
| **Idempotent method** | Giao thức HTTP (`GET`, `PUT`, `DELETE`...) | Bản chất của thao tác lên tài nguyên không đổi dù lặp lại. |
| **Idempotency key** | HTTP Header (`POST`, `PATCH` trong REST API) | Client gửi kèm key duy nhất, server đối chiếu key để chặn chạy lại. |
| **Idempotent Consumer** | Worker / Consumer đọc từ Message Queue | Lưu `message_id` đã xử lý vào DB để bỏ qua khi nhận lại message trùng. |

## Middleware idempotency kiểu "check rồi làm" sai ở đâu?

Khi code middleware cho idempotent API, phản xạ đầu tiên của nhiều người là: một cái `map` lưu key đã xử lý. Key có rồi thì trả kết quả luôn, chưa có thì chạy handler rồi mới lưu key.

Nghe thì hiển nhiên, nhưng bắt tay vào chạy thực tế là toang ngay.

Chúng ta thử xem đoạn code dưới đây nha:

```go
// step2/main.go
package main

import (
	"fmt"
	"net/http"
	"net/http/httptest"
	"strings"
	"sync"
	"sync/atomic"
	"time"
)

var charged atomic.Int32

func charge(w http.ResponseWriter, r *http.Request) {
	time.Sleep(100 * time.Millisecond) // giả lập gọi sang cổng thanh toán
	charged.Add(1)
	fmt.Fprintln(w, `{"status":"paid"}`)
}

// Middleware "ngây thơ": check xong rồi mới làm, làm xong mới lưu key
func naiveIdempotency(next http.Handler) http.Handler {
	var mu sync.Mutex
	seen := map[string]bool{}
	return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
		key := r.Header.Get("Idempotency-Key")
		mu.Lock()
		done := seen[key]
		mu.Unlock()
		if done {
			fmt.Fprintln(w, `{"status":"paid"}`)
			return
		}
		next.ServeHTTP(w, r) // 5 request cùng lọt vào đây
		mu.Lock()
		seen[key] = true
		mu.Unlock()
	})
}

func main() {
	srv := httptest.NewServer(naiveIdempotency(http.HandlerFunc(charge)))
	defer srv.Close()

	var wg sync.WaitGroup
	for i := 0; i < 5; i++ { // user bấm "Thanh toán" 5 lần liên tục
		wg.Add(1)
		go func() {
			defer wg.Done()
			req, _ := http.NewRequest("POST", srv.URL, strings.NewReader(`{"order":"A1"}`))
			req.Header.Set("Idempotency-Key", "order-A1-attempt-1")
			resp, _ := http.DefaultClient.Do(req)
			resp.Body.Close()
		}()
	}
	wg.Wait()
	fmt.Println("Số lần trừ tiền:", charged.Load()) // mong đợi 1
}
```

```text
Số lần trừ tiền: 5
```

_Output của chương trình trên_

Trong ví dụ trên, 5 request cùng một `Idempotency-Key` bắn vào cùng lúc (user bấm liên tục, hoặc client retry quá gắt). Mong đợi trừ tiền 1 lần, kết quả trừ tới 5 lần?!

Cách viết "check rồi làm" này dính hai lỗi:

1. **Race condition:** bước check và bước lưu key nằm ở hai lần lock khác nhau, khoảng giữa là lúc handler chạy. Handler thanh toán thường gọi sang cổng thanh toán bên ngoài nên chậm, cửa sổ càng to thì càng nhiều request thấy key "chưa có" và lọt qua hết.
2. **Response bị "bịa":** lần gọi trùng nhận một câu trả lời viết tay, không phải response thật (status code, body, mã giao dịch) của lần chạy đầu.

## Viết middleware idempotency key trong Go: giữ chỗ, 409 và replay response

Nguyên tắc xương sống để sửa: **bước kiểm tra key và bước giữ chỗ (reserve) phải nằm trong cùng một thao tác atomic**.

Mỗi record của một key có 2 trạng thái: đang xử lý và đã xong (field `done`). Request đầu tiên tới là cắm cờ "đang xử lý" ngay trong lock, rồi mới cho handler chạy.

Đầu tiên là phần Store:

```go
// step3/store.go
package main

import (
	"net/http"
	"sync"
	"time"
)

type record struct {
	fingerprint string
	done        bool // false = đang xử lý, true = đã có response
	status      int
	header      http.Header
	body        []byte
	expiresAt   time.Time
}

type Store struct {
	mu   sync.Mutex
	data map[string]*record
	ttl  time.Duration
}

func NewStore(ttl time.Duration) *Store {
	return &Store{data: map[string]*record{}, ttl: ttl}
}

// Reserve: kiểm tra và giữ chỗ trong CÙNG một lần lock.
// found = true nghĩa là key đã có, trả về bản copy của record cũ.
func (s *Store) Reserve(key, fp string) (old record, found bool) {
	s.mu.Lock()
	defer s.mu.Unlock()
	if r, ok := s.data[key]; ok && time.Now().Before(r.expiresAt) {
		return *r, true // copy ra ngoài, không đưa con trỏ
	}
	s.data[key] = &record{fingerprint: fp, expiresAt: time.Now().Add(s.ttl)}
	return record{}, false
}

func (s *Store) Complete(key string, status int, h http.Header, body []byte) {
	s.mu.Lock()
	defer s.mu.Unlock()
	r := s.data[key]
	r.done, r.status, r.header, r.body = true, status, h, body
}

func (s *Store) Release(key string) {
	s.mu.Lock()
	defer s.mu.Unlock()
	delete(s.data, key)
}
```

Trong đoạn code trên, để ý `Reserve` không trả con trỏ `*record` mà trả bản copy. Nếu trả con trỏ, goroutine này đọc record ở ngoài lock trong khi goroutine khác đang ghi kết quả vào chính record đó qua `Complete`. Đó là data race.

Bản đầu của ví dụ này trả con trỏ thật, và chạy `go run -race` là race detector báo `DATA RACE` ngay. Code concurrency kiểu này thì luôn chạy với cờ `-race`.

Tiếp theo là middleware, làm theo các luật trong IETF draft:

```go
// step3/middleware.go
package main

import (
	"bytes"
	"crypto/sha256"
	"encoding/hex"
	"io"
	"net/http"
	"net/http/httptest"
)

func Idempotency(store *Store, next http.Handler) http.Handler {
	return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
		key := r.Header.Get("Idempotency-Key")
		if key == "" {
			http.Error(w, "Idempotency-Key is missing", http.StatusBadRequest) // 400
			return
		}
		body, _ := io.ReadAll(r.Body)
		r.Body = io.NopCloser(bytes.NewReader(body)) // trả body lại cho handler
		sum := sha256.Sum256(append([]byte(r.Method+" "+r.URL.Path+"\n"), body...))
		fp := hex.EncodeToString(sum[:])

		if old, found := store.Reserve(key, fp); found {
			switch {
			case old.fingerprint != fp: // cùng key, khác payload
				http.Error(w, "Idempotency-Key is already used", http.StatusUnprocessableEntity) // 422
			case !old.done: // request gốc vẫn đang chạy
				http.Error(w, "A request is outstanding for this Idempotency-Key", http.StatusConflict) // 409
			default: // replay response đã lưu
				for k, v := range old.header {
					w.Header()[k] = v
				}
				w.Header().Set("Idempotent-Replayed", "true")
				w.WriteHeader(old.status)
				w.Write(old.body)
			}
			return
		}

		rec := httptest.NewRecorder() // chạy handler thật, ghi response ra bộ nhớ
		next.ServeHTTP(rec, r)
		if rec.Code >= 500 {
			store.Release(key) // lỗi server: nhả key cho client retry
		} else {
			store.Complete(key, rec.Code, rec.Header().Clone(), rec.Body.Bytes())
		}
		for k, v := range rec.Header() {
			w.Header()[k] = v
		}
		w.WriteHeader(rec.Code)
		w.Write(rec.Body.Bytes())
	})
}
```

Trong ví dụ trên, mỗi request đi qua các chốt sau:

- **Thiếu key -> `400 Bad Request`:** API bắt buộc có key mà client quên gửi thì từ chối luôn.
- **Cùng key, khác payload -> `422 Unprocessable Content`:** fingerprint là `sha256` của method, path và body (draft cho phép dùng checksum payload). Key cũ mà fingerprint lệch là client đang xài lại key cho request khác.
- **Request gốc đang chạy -> `409 Conflict`:** báo client là giao dịch này đang được xử lý.
- **Request gốc đã xong -> replay response:** trả lại đúng status code, header và body đã lưu, kèm header `Idempotent-Replayed: true` cho dễ debug. Tên header này là mình tự đặt trong ví dụ, không phải chuẩn. Để chụp được response, handler ghi vào `httptest.NewRecorder` trước rồi mới copy ra `ResponseWriter` thật.
- **Handler lỗi `5xx`:** ví dụ này gọi `Release` nhả key để client retry chạy lại từ đầu. Đây chỉ là một lựa chọn thiết kế. Stripe API v1 chọn ngược lại: lưu cả response `500`, retry cùng key nhận lại đúng `500` đó.

Giờ thử cả ba kịch bản bằng hàm `main`:

```go
// step3/main.go
package main

import (
	"fmt"
	"io"
	"net/http"
	"net/http/httptest"
	"sort"
	"strings"
	"sync"
	"sync/atomic"
	"time"
)

var charged atomic.Int32

func charge(w http.ResponseWriter, r *http.Request) {
	time.Sleep(100 * time.Millisecond) // giả lập gọi sang cổng thanh toán
	n := charged.Add(1)
	w.Header().Set("Content-Type", "application/json")
	w.WriteHeader(http.StatusCreated)
	fmt.Fprintf(w, `{"payment_id":"pay_%d","status":"paid"}`, n)
}

func send(url, key, body string) string {
	req, _ := http.NewRequest("POST", url, strings.NewReader(body))
	req.Header.Set("Idempotency-Key", key)
	resp, err := http.DefaultClient.Do(req)
	if err != nil {
		return err.Error()
	}
	defer resp.Body.Close()
	b, _ := io.ReadAll(resp.Body)
	return fmt.Sprintf("%d replayed=%q %s", resp.StatusCode,
		resp.Header.Get("Idempotent-Replayed"), strings.TrimSpace(string(b)))
}

func main() {
	store := NewStore(24 * time.Hour)
	srv := httptest.NewServer(Idempotency(store, http.HandlerFunc(charge)))
	defer srv.Close()

	key, body := "8e03978e-40d5-43e8-bc93-6894a57f9324", `{"order":"A1","amount":200000}`

	// 1. Năm request trùng nhau bắn vào cùng lúc
	var wg sync.WaitGroup
	var mu sync.Mutex
	results := []string{}
	for i := 0; i < 5; i++ {
		wg.Add(1)
		go func() {
			defer wg.Done()
			res := send(srv.URL, key, body)
			mu.Lock()
			results = append(results, res)
			mu.Unlock()
		}()
	}
	wg.Wait()
	sort.Strings(results) // sort cho dễ đọc
	for _, r := range results {
		fmt.Println("concurrent:", r)
	}

	// 2. Retry sau khi request gốc đã xong
	fmt.Println("retry:     ", send(srv.URL, key, body))
	// 3. Dùng lại key cũ cho một payload khác
	fmt.Println("reuse key: ", send(srv.URL, key, `{"order":"A1","amount":999000}`))

	fmt.Println("Số lần trừ tiền:", charged.Load())
}
```

```text
concurrent: 201 replayed="" {"payment_id":"pay_1","status":"paid"}
concurrent: 409 replayed="" A request is outstanding for this Idempotency-Key
concurrent: 409 replayed="" A request is outstanding for this Idempotency-Key
concurrent: 409 replayed="" A request is outstanding for this Idempotency-Key
concurrent: 409 replayed="" A request is outstanding for this Idempotency-Key
retry:      201 replayed="true" {"payment_id":"pay_1","status":"paid"}
reuse key:  422 replayed="" Idempotency-Key is already used
Số lần trừ tiền: 1
```

_Kết quả đã đúng như mong đợi_

Trong ví dụ trên, 5 request đồng thời thì 1 cái nhận `201` với `pay_1`, 4 cái còn lại nhận `409`. Retry sau khi xong nhận lại đúng `201` và `pay_1`, kèm `replayed="true"`. Dùng key cũ với `amount` khác thì ăn `422`. Và quan trọng nhất: chỉ trừ tiền 1 lần.

Phía client, theo IETF draft: nhận `400` hay `422` thì phải sửa request trước khi gọi lại. Riêng `409` thì không cần sửa gì, chờ chút rồi retry đúng request đó.

## Lưu idempotency key ở đâu khi chạy production?

Đương nhiên `Store` bằng `map` trong RAM chỉ để minh họa. Restart server là bay sạch. Chạy nhiều instance sau Load Balancer thì mỗi instance một `map`, retry nhảy sang máy khác là vô dụng.

Lên production thì key phải nằm ở storage dùng chung:

- **Redis:** `SET key value NX` kèm thời gian hết hạn, check và giữ chỗ trong một lệnh atomic.
- **Database:** bảng idempotency có `UNIQUE constraint` trên cột key.
- **Chung transaction với nghiệp vụ:** ghi kết quả idempotency trong **cùng một transaction** với lệnh trừ tiền, tạo đơn. Vậy mới không có cảnh tiền trừ rồi mà server sập trước khi kịp lưu kết quả.
- **TTL:** IETF draft cho phép key hết hạn, nhưng server phải công bố chính sách đó trong tài liệu API. Stripe API v1 có thể xóa key khi key đã ít nhất 24 giờ tuổi, gửi lại key đã bị xóa thì thành request mới.

> ⚠️ **Chỗ dễ gãy nhất:** server crash (hay bị OOM kill) đúng lúc record đang "đang xử lý" thì key bị kẹt, mọi retry đều ăn `409` tới khi key hết hạn. Giữ chỗ phải có **lease** (thời hạn giữ chỗ ngắn), cộng với **reconciliation** (đối soát) với bên thanh toán trước khi quyết định chạy lại hay đóng giao dịch.

> Mạng lỗi và client retry là chuyện chắc chắn sẽ xảy ra; đừng hỏi "request này có bị gửi trùng không", hãy hỏi "nếu nó gửi trùng thì hệ thống của mình có sao không".

Nếu bạn muốn đi sâu vào timeout, crash giữa chừng, lease và đối soát thì có thể tham khảo khóa học bên dưới tại 200Lab nhé.

![Xử lý timeout, retry và crash - idempotency, outbox, lease và reconciliation](https://assets.200lab.io/sha256/67/67aa4f766e03fb56a4815d59537554fbcd2e7d582b432f8a936b8c0111acd369/generations/4135c1b2f597d8f855233c833c32afdd5deb862e01d37dbadff1bdc2edd80626)

- [Xử lý timeout, retry và crash - idempotency, outbox, lease và reconciliation](https://200lab.io/courses/xu-ly-timeout-retry-va-crash-idempotency-outbox-lease-va-reconciliation.md)

Chúc các bạn retry vui vẻ!

## Đọc thêm

- [Game System Design GonnaCrash được làm như thế nào](https://200lab.io/blog/game-system-design-gonnacrash-duoc-lam-nhu-the-nao.md): Kéo thả một hệ thống rồi nhìn nó trụ hay sập: cách mình dựng GonnaCrash, từ server làm trọng tài, con bot giải đề đến skin whiteboard.
- [Architecture Pattern - Phần 7: Hexagonal Architecture](https://200lab.io/blog/hexagonal-architecture-la-gi.md): Hexagonal Architecture là pattern kiến trúc mà mọi tương tác từ bên ngoài đều phải đi qua một Adapter để kết nối vào một Port của Application
- [File __init__.py trong Python: Hướng dẫn sử dụng toàn diện](https://200lab.io/blog/huong-dan-su-dung-toan-dien-file-init-trong-python.md): __init__.py không chỉ đơn thuần là một file đánh dấu package, nó quyết định cách package của bạn được cấu trúc, import và tối ưu ra sao
- [Thiết kế Leaderboard hiệu năng cao: Khi nào chọn SQL, Khi nào dùng Redis?](https://200lab.io/blog/thiet-ke-leaderboard-hieu-nang-cao-khi-nao-chon-sql-khi-nao-dung-redis.md): Khám phá cách thiết kế hệ thống Leaderboard tối ưu, linh hoạt, dễ mở rộng, kết hợp giữa tư duy thiết kế hệ thống hiện đại và kinh nghiệm thực tế
- [Hành vi giúp Phân biệt các Senior Engineer với những người còn lại](https://200lab.io/blog/hanh-vi-giup-phan-biet-cac-senior-engineer-voi-nhung-nguoi-con-lai.md): Việc trở thành một senior engineer thực sự không chỉ nằm ở chức danh bạn được giao, mà là kết quả của tư duy, cách tiếp cận công việc mà bạn rèn luyện mỗi ngày
- [Mọi bài viết chủ đề Backend](https://200lab.io/blog/tags/backend.md)

Toàn bộ nội dung: https://200lab.io/llms.txt
