Điều kiện tiên quyết: Đây là Phần 7 của Khóa Học System Design. Bạn nên tham khảo lại Phần 6: Khóa Phân Tán để nắm vững các nguyên lý đồng bộ hóa tài nguyên và kiểm soát truy cập song song.

Answer-first: Trong các hệ thống AI-Native và microservices năm 2026, tính kháng lặp (Idempotency) trong thiết kế API đảm bảo rằng việc gửi lại cùng một yêu cầu nhiều lần (retrying an identical request kèm theo cùng một Idempotency-Key) sẽ không tạo ra bất kỳ tác dụng phụ bổ sung nào (additional side effects) so với lần thực thi thành công đầu tiên. Đây là yêu cầu bắt buộc đối với các hệ thống thanh toán (Payment APIs) — nơi gián đoạn kết nối mạng thường khiến client gửi lại yêu cầu và có rủi ro gây ra lỗi trừ tiền trùng lặp (double charge).


Khái Niệm Về Idempotency Key

Answer-first: Một Idempotency Key là một mã định danh duy nhất (unique token - thường sử dụng UUID v4) được phía client tạo ra và truyền kèm trong HTTP header Idempotency-Key. Hệ thống backend dựa vào khóa này để phát hiện các yêu cầu trùng lặp: nếu khóa đã tồn tại trong bộ nhớ tạm, hệ thống lập tức trả về kết quả đã lưu trước đó mà không thực thi lại logic xử lý.

Tầm Quan Trọng Của Tính Kháng Lặp Trong Payment API

sequenceDiagram
    participant Client
    participant API as Payment API
    participant DB as Cơ Sở Dữ Liệu

    Client->>API: Gửi POST /payments {amount: $100} [Header: Idempotency-Key: uuid-A]
    API->>DB: Thực thi INSERT dữ liệu thanh toán
    DB-->>API: Trả về SUCCESS
    Note over API,Client: ⚠️ Sự cố nghẽn mạng! Client không nhận được phản hồi.

    Client->>API: Gửi lại POST /payments {amount: $100} [Cùng Header: uuid-A] (RETRY)

    alt ❌ Không có cơ chế Idempotency
        API->>DB: Thực thi lại lệnh INSERT dữ liệu
        Note over DB: Tài khoản bị trừ 2 lần ($200 thay vì $100)!
    else ✅ Có cơ chế Idempotency Key
        API->>API: Kiểm tra: Key uuid-A đã được xử lý xong chưa?
        API-->>Client: Trả về kết quả từ cache {status: success, tx_id: 123}
        Note over DB: Không ghi thêm dữ liệu vào DB — Đảm bảo an toàn!
    end

Thực tế triển khai: Các nền tảng thanh toán lớn như Stripe, Adyen, PayPal đều bắt buộc truyền Idempotency-Key đối với tất cả các API làm thay đổi trạng thái dữ liệu (POST/PUT/DELETE) để đảm bảo không bị trừ tiền hai lần khi gặp sự cố mạng.


Thiết Kế Idempotency Theo Chuẩn Stripe

Answer-first: Mô hình của Stripe lưu trữ thông tin Idempotency Key vào Redis (với thời gian hết hạn TTL 24 giờ). Bản ghi chứa đầy đủ các thông tin: trạng thái xử lý (status), mã phản hồi HTTP (response_code), headers, response body và chuỗi băm nội dung (payload_hash). Chuỗi băm payload_hash dùng để phát hiện rủi ro client tái sử dụng lại Idempotency Key cũ cho một request body mới. Trong trường hợp Redis không khả dụng, hệ thống sử dụng PostgreSQL làm lớp lưu trữ dự phòng (fallback).

Cấu Trúc Bản Ghi Metadata

{
  "idempotency_key": "550e8400-e29b-41d4-a716-446655440000",
  "payload_hash": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855",
  "status": "completed",
  "response_code": 201,
  "response_headers": {
    "Content-Type": ["application/json"],
    "X-Transaction-Id": ["tx_987654"]
  },
  "response_body": "{\"transaction_id\":\"tx_987654\",\"status\":\"success\"}",
  "created_at": "2026-06-18T09:00:00Z",
  "expires_at": "2026-06-19T09:00:00Z"
}

Cấu Trúc Bảng Lưu Trữ Idempotency Key Trong Database

CREATE TABLE idempotency_keys (
    idemp_key        VARCHAR(255) NOT NULL,
    payload_hash     CHAR(64)     NOT NULL,         -- Chuỗi băm SHA256 của request body
    status           VARCHAR(50)  NOT NULL,          -- Trạng thái: 'in-progress', 'completed', 'failed'
    response_code    INT,
    response_headers JSONB,
    response_body    TEXT,
    created_at       TIMESTAMPTZ  NOT NULL DEFAULT NOW(),
    expires_at       TIMESTAMPTZ  NOT NULL,

    PRIMARY KEY (idemp_key)
);

-- Partial index tối ưu cho các khóa chưa hết hạn
CREATE UNIQUE INDEX idx_idemp_active ON idempotency_keys (idemp_key)
    WHERE expires_at > NOW();

Triển Khai Idempotency Middleware Trong Go

Answer-first: Idempotency Middleware đảm nhận việc kiểm tra header Idempotency-Key trên mỗi HTTP request. Middleware sử dụng lệnh SetNX của Redis để đăng ký nguyên tử (atomically claim) khóa xử lý. Một responseRecorder được sử dụng để bắt và ghi lại HTTP response code, headers, và body. Sau khi xử lý hoàn tất, kết quả được lưu vào Redis để phục vụ cho các yêu cầu thử lại trong tương lai.

package middleware

import (
    "bytes"
    "context"
    "crypto/sha256"
    "encoding/hex"
    "encoding/json"
    "fmt"
    "io"
    "net/http"
    "time"

    "github.com/redis/go-redis/v9"
)

// IdempotencyRecord định nghĩa cấu trúc bản ghi Idempotency trong Redis
type IdempotencyRecord struct {
    Status       string              `json:"status"`       // "in-progress" | "completed"
    ResponseCode int                 `json:"response_code"`
    Headers      map[string][]string `json:"headers"`
    Body         string              `json:"body"`
    PayloadHash  string              `json:"payload_hash"`
}

// responseRecorder bọc ResponseWriter để ghi nhận mã phản hồi và body
type responseRecorder struct {
    http.ResponseWriter
    code int
    body *bytes.Buffer
}

func newResponseRecorder(w http.ResponseWriter) *responseRecorder {
    return &responseRecorder{ResponseWriter: w, code: http.StatusOK, body: new(bytes.Buffer)}
}

func (r *responseRecorder) WriteHeader(statusCode int) {
    r.code = statusCode
    r.ResponseWriter.WriteHeader(statusCode)
}

func (r *responseRecorder) Write(b []byte) (int, error) {
    r.body.Write(b)
    return r.ResponseWriter.Write(b)
}

// IdempotencyMiddleware xử lý kiểm tra trùng lặp bằng Redis SetNX
type IdempotencyMiddleware struct {
    rdb    *redis.Client
    keyTTL time.Duration
}

func NewIdempotencyMiddleware(rdb *redis.Client, keyTTL time.Duration) *IdempotencyMiddleware {
    return &IdempotencyMiddleware{rdb: rdb, keyTTL: keyTTL}
}

func (im *IdempotencyMiddleware) Handle(next http.Handler) http.Handler {
    return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
        idempKey := r.Header.Get("Idempotency-Key")
        if idempKey == "" {
            next.ServeHTTP(w, r) // Không có Idempotency-Key -> cho phép đi tiếp
            return
        }

        ctx := r.Context()
        redisKey := fmt.Sprintf("idemp:%s", idempKey)

        // Tính toán SHA256 hash của request body để kiểm tra tính toàn vẹn
        body, _ := io.ReadAll(r.Body)
        r.Body = io.NopCloser(bytes.NewBuffer(body))
        hash := sha256.Sum256(body)
        payloadHash := hex.EncodeToString(hash[:])

        // Bước 1: Kiểm tra khóa trong Redis
        existingData, err := im.rdb.Get(ctx, redisKey).Result()
        if err == nil {
            var existing IdempotencyRecord
            if json.Unmarshal([]byte(existingData), &existing) == nil {
                // Từ chối nếu cùng key nhưng request body khác nhau
                if existing.PayloadHash != "" && existing.PayloadHash != payloadHash {
                    http.Error(w,
                        `{"error":"idempotency_key_reuse","message":"Key used with a different request body"}`,
                        http.StatusUnprocessableEntity)
                    return
                }

                if existing.Status == "in-progress" {
                    // Tiến trình khác đang xử lý với key này
                    http.Error(w,
                        `{"error":"request_in_progress","message":"Duplicate request is already being processed"}`,
                        http.StatusConflict)
                    return
                }

                // Yêu cầu đã hoàn tất -> trả về response từ cache
                for name, vals := range existing.Headers {
                    for _, val := range vals {
                        w.Header().Add(name, val)
                    }
                }
                w.Header().Set("X-Idempotent-Replayed", "true")
                w.WriteHeader(existing.ResponseCode)
                w.Write([]byte(existing.Body))
                return
            }
        }

        // Bước 2: Đăng ký nguyên tử khóa bằng lệnh SetNX
        inProgress := IdempotencyRecord{Status: "in-progress", PayloadHash: payloadHash}
        inProgressJSON, _ := json.Marshal(inProgress)

        set, setErr := im.rdb.SetNX(ctx, redisKey, inProgressJSON, im.keyTTL).Result()
        if setErr != nil || !set {
            http.Error(w,
                `{"error":"conflict","message":"Request already in progress"}`,
                http.StatusConflict)
            return
        }

        // Bước 3: Thực thi HTTP handler thực sự và ghi lại response
        recorder := newResponseRecorder(w)
        next.ServeHTTP(recorder, r)

        // Chặng 4 (Step 4): Nhồi rác vùi lấp (Save the completed response) chôn xuống Redis
        finalRecord := IdempotencyRecord{
            Status:       "completed",
            ResponseCode: recorder.code,
            Headers:      map[string][]string(w.Header()),
            Body:         recorder.body.String(),
            PayloadHash:  payloadHash,
        }
        finalJSON, _ := json.Marshal(finalRecord)
        im.rdb.Set(ctx, redisKey, finalJSON, im.keyTTL)
    })
}

Cơ Chế Khóa Nguyên Tử SetNX Tránh Race Condition Cấp Micro-giây

Answer-first: Lệnh SetNX (SET if Not eXists) của Redis được thực thi nguyên tử (atomic operation). Do Redis xử lý các câu lệnh theo mô hình single-threaded event loop, kể cả khi 100 requests song song được gửi tới tại cùng một microsecond, Redis vẫn tuần tự hóa và đảm bảo chỉ có duy nhất một request thực thi thành công lệnh SetNX (trả về true), trong khi 99 requests còn lại nhận về false và bị từ chối với mã lỗi HTTP 409 Conflict.

Kiểm Thử Xử Lý Đồng Thời (Concurrent Race Test)

package middleware

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

func TestIdempotencyMutualExclusion(t *testing.T) {
    var executionCount atomic.Int64

    handler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
        executionCount.Add(1)
        w.WriteHeader(http.StatusCreated)
        w.Write([]byte(`{"transaction_id":"tx_001","status":"success"}`))
    })

    rdb := setupTestRedis() // Khởi tạo miniredis cho testing
    mw := NewIdempotencyMiddleware(rdb, 24*time.Hour)
    wrapped := mw.Handle(handler)

    const concurrency = 100
    var wg sync.WaitGroup
    wg.Add(concurrency)

    codes := make([]int, concurrency)
    for i := 0; i < concurrency; i++ {
        go func(index int) {
            defer wg.Done()
            req := httptest.NewRequest("POST", "/payments",
                strings.NewReader(`{"amount":100}`))
            req.Header.Set("Idempotency-Key", "same-uuid-for-all") // Sử dụng cùng 1 idempotency key
            req.Header.Set("Content-Type", "application/json")

            rec := httptest.NewRecorder()
            wrapped.ServeHTTP(rec, req)
            codes[index] = rec.Code
        }(i)
    }
    wg.Wait()

    // Handler chỉ được thực thi đúng 1 lần duy nhất cho 100 requests đồng thời
    if count := executionCount.Load(); count != 1 {
        t.Errorf("Execution count expected 1, got %d", count)
    }

    created, conflict := 0, 0
    for _, code := range codes {
        switch code {
        case http.StatusCreated:
            created++
        case http.StatusConflict:
            conflict++
        }
    }
    t.Logf("Kết quả: %d StatusCreated, %d StatusConflict (từ %d requests)",
        created, conflict, concurrency)
    // Kết quả ghi nhận: 1 request thành công, 99 requests bị xung đột
}

Chiến Lược Retry Với Exponential Backoff Và Jitter

Answer-first: Phía client bắt buộc phải áp dụng thuật toán Exponential Backoff (lùi thời gian chờ theo cấp số nhân) kết hợp Jitter (ngẫu nhiên hóa khoảng thời gian) khi gửi lại request để tránh rủi ro Retry Storm — hiện tượng hàng ngàn client cùng gửi lại request tại một mốc thời gian cố định sau khi hệ thống khôi phục từ sự cố.

$$T_i = \min\left(T_{\text{max}},; T_{\text{base}} \times 2^{\text{attempt}} + \text{Uniform}(0, J)\right)$$

package retry

import (
    "fmt"
    "math"
    "math/rand"
    "time"
)

type ExponentialBackoff struct {
    BaseDelay  time.Duration
    MaxDelay   time.Duration
    JitterCap  time.Duration
    MaxRetries int
}

func (b *ExponentialBackoff) NextDelay(attempt int) (time.Duration, bool) {
    if attempt >= b.MaxRetries {
        return 0, false
    }
    exp := math.Pow(2, float64(attempt))
    delay := time.Duration(float64(b.BaseDelay)*exp) +
        time.Duration(rand.Int63n(int64(b.JitterCap)))
    if delay > b.MaxDelay {
        delay = b.MaxDelay
    }
    return delay, true
}

// RetryWithIdempotency thực hiện gửi lại request với cùng 1 idempotency key
func RetryWithIdempotency(
    key string,
    backoff ExponentialBackoff,
    fn func(idempKey string) (int, error),
) error {
    for attempt := 0; attempt < backoff.MaxRetries; attempt++ {
        statusCode, err := fn(key) // Sử dụng cùng idempotency key cho mỗi lần retry

        if err == nil && statusCode < 500 {
            return nil // Thành công hoặc lỗi 4xx từ client -> không retry
        }

        delay, more := backoff.NextDelay(attempt)
        if !more {
            return fmt.Errorf("đã vượt quá số lần retry tối đa (%d)", attempt+1)
        }
        time.Sleep(delay)
    }
    return nil
}

Case Study: Kiến Trúc Kháng Lặp Giao Dịch Alipay Double 11

🔥 [Kiến Trúc Production: Alipay Double 11 Idempotency Engine] Quy mô: Hệ thống xử lý đỉnh điểm 583,000 giao dịch/giây trong sự kiện Double 11. Thách thức: Gián đoạn kết nối mạng trong thời gian cao điểm tạo ra hàng triệu yêu cầu thanh toán trùng lặp (duplicate payment attempts). Giải pháp: Mọi giao dịch đều gắn kèm mã định danh nghiệp vụ biz_no (hoạt động tương tự Idempotency Key). Backend sử dụng cơ chế kiểm tra hai lớp:

  1. Lớp Cache nóng (Redis): Kiểm tra và phản hồi kết quả tức thì với độ trễ < 1ms.
  2. Lớp DB chính (OceanBase): Ràng buộc UNIQUE constraint trên trường biz_no tại bảng cơ sở dữ liệu để ngăn chặn triệt để rủi ro ghi đúp trong trường hợp cache miss. Kết quả: Đạt tỷ lệ lỗi trừ tiền trùng lặp bằng 0 (Zero duplicate charges) trên toàn bộ hệ thống giao dịch Alipay.

Câu Hỏi Thường Gặp (FAQ)

Định dạng tiêu chuẩn của Idempotency Key là gì?

Client nên tạo Idempotency Key dưới dạng chuỗi UUID v4 (ví dụ 550e8400-e29b-41d4-a716-446655440000) và truyền qua HTTP header Idempotency-Key. Thời gian hết hạn (TTL) của khóa trong bộ nhớ tạm thường được đặt là 24 giờ.

Stripe xử lý tính kháng lặp như thế nào?

Stripe lưu trữ metadata của Idempotency Key (bao gồm status, response code, headers, body và payload_hash) trong Redis với TTL 24 giờ. Nếu client gửi lại request kèm Idempotency Key cũ nhưng có request body khác, Stripe sẽ từ chối với mã lỗi HTTP 422 Unprocessable Entity. Hệ thống cũng sử dụng cơ sở dữ liệu relational (PostgreSQL) làm lớp dự phòng bền vững.

Làm thế nào để ngăn chặn Race Condition khi 2 request trùng key tới cùng lúc?

Lệnh SetNX của Redis hoạt động nguyên tử trên single-threaded event loop. Chỉ duy nhất request đầu tiên đăng ký key thành công và nhận giá trị true. Các request gửi đồng thời sau đó sẽ nhận false và nhận phản hồi HTTP 409 Conflict (request in progress). Sau khi request đầu tiên xử lý xong và ghi lại kết quả completed, các request đến sau sẽ nhận lại response đã lưu từ cache.


🔗 Bay Sang Bài Tới: Phần 8: Binh Pháp Lưới Saga & Giao Dịch Đứt Rải Tứ Tán Trong Go (Saga Pattern & Distributed Transactions in Go) — Dàn nhạc giao hưởng vung đũa chọc Temporal SDK (Temporal SDK orchestration), Hũ Trữ Tuồn Ngoài Kho Hàng Transactional Outbox, và Trò Đẩy Thuyền Đổi Đời Debezium CDC event routing.