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).

Ảnh chụp đoạn mã Go nền tối minh hoạ mẫu idempotency key đảm bảo một thao tác chỉ thực thi đúng một lần, vấn đề retry làm thao tác chạy hai lần client gọi tạo đơn request tới server thực thi nhưng response mất do timeout client tưởng lỗi nên retry server nhận lại tạo đơn lần nữa trừ tiền hai lần, retry ở bài trước an toàn chỉ khi thao tác lũy đẳng idempotent, giải khóa idempotency định danh mỗi thao tác type IdemStore struct mu sync.Mutex done map string int key trỏ kết quả đã lưu client sinh một key duy nhất cho mỗi ý định ví dụ UUID gửi kèm header Idempotency-Key cùng ý định thì cùng key thì cùng kết quả, hàm Xu có khóa thì trả cũ chưa thì thực thi rồi lưu func Xu key string int khóa mutex Lock defer Unlock check-then-act nguyên tử nếu done key đã có trả maDon cũ không tạo lại lần đầu gọi taoDonThat thực thi side effect rồi lưu vào done, vì sao mutex quan trọng không khóa hai request cùng key tới cùng lúc cả hai thấy chưa có cả hai thực thi trùng mutex làm check-then-act nguyên tử thực tế production dùng CSDL với UNIQUE constraint trên key hoặc INSERT ON CONFLICT để nguyên tử qua nhiều máy chủ

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.

Ảnh chụp bảng kết quả đo thật nền tối idempotency key trong Go chạy bằng go run Go 1.23 arm64 10 core, không idempotency retry bằng trùng lặp retry 5 lần tạo 5 đơn lần 1 mã đơn 2001 lần 2 2002 lần 3 2003 lần 4 2004 lần 5 2005 số đơn tạo ra 5 đáng lẽ chỉ 1 trừ tiền 5 lần, có idempotency cùng key bằng cùng kết quả thực thi 1 lần client gửi cùng Idempotency-Key abc 5 lần do retry lần 1 mã đơn 1001 lần 2 1001 lần 3 1001 lần 4 1001 lần 5 1001 số lần thực sự tạo đơn 1 đúng một các lần sau trả cũ, an toàn dưới đồng thời 100 goroutine cùng khóa xyz đồng thời thực thi 1 lần mutex làm check-then-act nguyên tử chỉ 1 goroutine thực thi 99 goroutine còn lại nhận kết quả đã lưu không trùng, cốt lõi vấn đề retry gửi lại làm thao tác có side effect chạy nhiều lần khóa idem client sinh key duy nhất theo ý định gửi kèm request 1 lần server có key trả cũ chưa thực thi rồi lưu nguyên tử mutex một máy UNIQUE constraint ON CONFLICT nhiều máy đánh đổi cần lưu key với TTL dọn key phải ổn định theo ý định

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ề

  1. 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.
  2. Cốt lõi là một phép kiểm-rồi-ghi nguyên tử trên khóa: sync.Mutex cho một tiến trình (đo thật 100 goroutine cùng khóa chỉ thực thi 1 lần), UNIQUE constraint hoặc ON CONFLICT cho nhiều máy chủ — không có nguyên tử thì đồng thời làm vỡ dedup.
  3. Đá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.