Bài trước ta đo retry với backoff và jitter — cách gọi lại một dịch vụ đang chập chờn cho đúng. Nhưng retry giấu một giả định nguy hiểm: nó chỉ an toàn khi thao tác lũy đẳng (idempotent). Gọi lại một API "đọc" thì vô hại. Gọi lại một API "tạo đơn hàng" hay "trừ tiền" thì mỗi lần retry có thể thực thi thêm một lần nữa — và khách bị trừ tiền năm lần cho một đơn.
Idempotency key là mẫu thiết kế biến một thao tác có side effect thành lũy đẳng một cách chủ động: client gắn cho mỗi ý định một khóa duy nhất, server dùng khóa đó để nhận ra "tôi đã xử lý cái này rồi" và trả lại kết quả cũ thay vì làm lại. Bài này mổ xẻ cơ chế và đo thật trong Go — cả trường hợp một luồng lẫn 100 goroutine tranh nhau cùng một khóa.
Vấn đề: retry làm thao tác chạy hai lần
Kịch bản kinh điển: client gọi "tạo đơn". Request tới được server, server thực thi thành công — nhưng response bị mất trên đường về (timeout mạng). Client không phân biệt được "request không tới" với "response không về", nên nó retry. Server nhận lại request y hệt và... tạo đơn lần nữa.
Đây không phải lỗi hiếm gặp. Nó là hệ quả trực tiếp của việc mạng không đáng tin: client không bao giờ biết chắc một request có side effect đã thành công hay chưa khi nó không nhận được response. Retry (bài trước) là câu trả lời đúng cho vấn đề đó — nhưng chỉ khi thao tác chịu được việc chạy nhiều lần.
Giải: khóa idempotency định danh mỗi thao tác
Ý tưởng: client sinh một khóa duy nhất cho mỗi ý định (thường là một UUID), gửi kèm trong header Idempotency-Key. Cùng một ý định — kể cả khi retry nhiều lần — luôn mang cùng một khóa. Server lưu lại kết quả theo khóa, và khi thấy khóa đã xử lý thì trả lại kết quả cũ thay vì thực thi lại.
type IdemStore struct {
mu sync.Mutex
done map[string]int // key -> kết quả đã lưu
}
func NewStore() *IdemStore { return &IdemStore{done: map[string]int{}} }
Điểm mấu chốt là khóa gắn với ý định của client, không phải với từng lần gửi. Đây là lý do client phải sinh khóa (server không thể tự sinh — nó không phân biệt được retry với request mới).

Hình 1: Retry chỉ an toàn khi thao tác lũy đẳng. Khóa idempotency biến thao tác có side effect thành lũy đẳng: client sinh khóa duy nhất cho mỗi ý định, server dedup bằng Xu dưới mutex.
Xu: có khóa thì trả cũ, chưa thì thực thi rồi lưu
Trái tim của mẫu này là một hàm kiểm-rồi-làm dưới khóa:
var soLanThucThi int64
func taoDonThat() int {
n := atomic.AddInt64(&soLanThucThi, 1)
return int(1000 + n)
}
func (s *IdemStore) Xu(key string) int {
s.mu.Lock()
defer s.mu.Unlock() // khóa: check-then-act nguyên tử
if maDon, ok := s.done[key]; ok {
return maDon // ĐÃ xử lý -> trả kết quả cũ, KHÔNG tạo lại
}
maDon := taoDonThat() // lần đầu -> thực thi side effect
s.done[key] = maDon
return maDon
}
taoDonThat đại diện cho side effect thật (tạo đơn, trừ tiền) — ở đây nó tăng một bộ đếm soLanThucThi để ta đo được nó chạy bao nhiêu lần. Đó chính là con số quan trọng nhất: một mẫu idempotency đúng phải giữ số lần thực thi thật ở đúng một, bất kể client gửi bao nhiêu lần.
Vì sao mutex quan trọng
Xu là một thao tác check-then-act: kiểm "khóa đã có chưa" rồi hành động dựa trên kết quả kiểm. Đây là dạng đua tranh kinh điển. Nếu không có khóa, hai request cùng khóa tới cùng lúc có thể cả hai cùng thấy "chưa có" (vì cái này kiểm trước khi cái kia kịp ghi), rồi cả hai cùng thực thi — đúng cái ta muốn tránh. sync.Mutex (đã đo ở các bài đồng thời trước) làm cả cụm kiểm-rồi-ghi thành nguyên tử: chỉ một goroutine ở trong vùng khóa tại một thời điểm.
Trong thực tế production nhiều máy chủ, một mutex trong bộ nhớ là không đủ — hai máy chủ khác nhau không chia sẻ mutex. Khi đó nguyên tử phải đến từ tầng lưu trữ dùng chung: một cột khóa với ràng buộc UNIQUE, hoặc INSERT ... ON CONFLICT DO NOTHING trên PostgreSQL. Cơ chế đổi, nhưng nguyên lý y hệt: một phép kiểm-rồi-ghi nguyên tử trên khóa.
Đo thật: không idempotency retry 5 lần tạo 5 đơn
Đầu tiên đo trường hợp không có idempotency để thấy vấn đề. Hàm taoDonKhongKhoa chạy trực tiếp mỗi lần được gọi, không dedup:
KHÔNG idempotency: retry 5 lần -> tạo 5 đơn (trùng lặp!):
lần 1 -> mã đơn: 2001 lần 4 -> mã đơn: 2004
lần 2 -> mã đơn: 2002 lần 5 -> mã đơn: 2005
lần 3 -> mã đơn: 2003
Số đơn tạo ra: 5 (đáng lẽ chỉ 1 -> trừ tiền 5 lần!)
Năm lần retry cùng một ý định tạo ra năm mã đơn khác nhau (2001–2005) — năm side effect, năm lần trừ tiền. Đây là cái giá của việc retry một thao tác không lũy đẳng.
Bây giờ bật idempotency lên: client gửi cùng Idempotency-Key là abc cho cả năm lần retry:
Client gửi cùng Idempotency-Key abc 5 lần (do retry):
lần 1 -> mã đơn: 1001 lần 4 -> mã đơn: 1001
lần 2 -> mã đơn: 1001 lần 5 -> mã đơn: 1001
lần 3 -> mã đơn: 1001
Số lần THỰC SỰ tạo đơn: 1
Cả năm lần trả về cùng một mã đơn (1001), và bộ đếm soLanThucThi cho biết side effect chỉ chạy đúng một lần. Lần đầu tiên thực thi và lưu kết quả; bốn lần sau chỉ đọc lại kết quả cũ. Client nhận được cùng một câu trả lời dù nó retry bao nhiêu lần — đúng nghĩa lũy đẳng.

Hình 2: Không idempotency: retry 5 lần tạo 5 đơn (2001–2005). Có idempotency: 5 lần retry cùng khóa abc trả cùng mã 1001, chỉ thực thi 1 lần. Và 100 goroutine cùng khóa xyz đồng thời cũng chỉ thực thi 1 lần.
An toàn dưới đồng thời: 100 goroutine, 1 lần thực thi
Trường hợp khó hơn retry tuần tự là retry đồng thời — hai (hay nhiều) request cùng khóa tới gần như cùng lúc, chưa cái nào kịp lưu kết quả. Đây là lúc check-then-act dễ vỡ nhất. Đo bằng 100 goroutine cùng gọi Xu("xyz") song song qua sync.WaitGroup:
var wg sync.WaitGroup
for i := 0; i < 100; i++ {
wg.Add(1)
go func() { defer wg.Done(); s2.Xu("xyz") }()
}
wg.Wait()
Kết quả đo: 100 goroutine cùng khóa xyz đồng thời -> thực thi: 1 lần. Dù 100 goroutine cùng lao vào, mutex tuần tự hóa vùng kiểm-rồi-ghi: goroutine đầu tiên vào thấy "chưa có" nên thực thi và lưu; 99 goroutine còn lại lần lượt vào, thấy khóa đã có, trả lại kết quả đã lưu. Không có lần thực thi thứ hai. (Bạn có thể kiểm chứng cái này cần mutex bằng cách chạy với go run -race sau khi bỏ khóa — race detector sẽ tố cáo ngay việc đọc-ghi map đua tranh.)
Đánh đổi cần cân nhắc
Phải lưu khóa ở đâu đó, và dọn nó. Store idempotency lớn dần theo số ý định. Trong bộ nhớ thì mất khi restart (và không chia sẻ giữa các máy); trong CSDL/Redis thì bền hơn nhưng cần TTL để dọn khóa cũ — giữ mãi là rò rỉ. Chọn thời gian sống của khóa dài hơn cửa sổ retry hợp lý của client (vài giờ đến vài ngày là thường gặp).
Khóa phải ổn định theo ý định, không theo lần gửi. Nếu client sinh khóa mới cho mỗi lần retry thì cả mẫu này vô nghĩa — mỗi retry trông như một ý định mới. Khóa phải được sinh một lần khi ý định hình thành (ví dụ khi người dùng bấm nút "Đặt hàng"), rồi tái sử dụng cho mọi retry của ý định đó.
Một máy dùng mutex, nhiều máy phải dùng tầng lưu trữ chung. Mutex trong bài này đúng và nhanh cho một tiến trình, nhưng không co giãn qua nhiều máy chủ. Sản xuất thật gần như luôn cần nguyên tử ở tầng CSDL: UNIQUE constraint trên cột khóa, hoặc INSERT ... ON CONFLICT. Cẩn thận trường hợp "đang xử lý dở": nếu request đầu tiên còn đang chạy side effect mà request thứ hai tới, bạn cần một trạng thái trung gian (đang xử lý) để cái thứ hai chờ hoặc từ chối, thay vì thực thi song song.
Ba ý mang về
- Idempotency key biến thao tác có side effect thành lũy đẳng, làm cho retry (bài trước) an toàn: client sinh một khóa duy nhất cho mỗi ý định, server dedup theo khóa — đo thật, không idempotency retry 5 lần tạo 5 đơn (2001–2005), có idempotency chỉ 1 đơn (1001) dù retry 5 lần.
- Cốt lõi là một phép kiểm-rồi-ghi nguyên tử trên khóa:
sync.Mutexcho một tiến trình (đo thật 100 goroutine cùng khóa chỉ thực thi 1 lần),UNIQUEconstraint hoặcON CONFLICTcho nhiều máy chủ — không có nguyên tử thì đồng thời làm vỡ dedup. - Đánh đổi nằm ở quản lý khóa: phải lưu khóa và dọn bằng TTL, và khóa phải ổn định theo ý định chứ không theo lần gửi — sinh khóa mới cho mỗi retry là phá bỏ toàn bộ ý nghĩa của mẫu.
Phần sau ta bước sang quan sát hệ thống phân tán: distributed tracing với OpenTelemetry — cách lần theo một request đi qua nhiều dịch vụ, và chi phí thật của việc gắn trace.