API冪等性設計全攻略:冪等键、分散式去重与支付安全介面
摘要
- 冪等性是分散式系統的「安全带」:網路重試、訊息重複、使用者双击,都可能導致介面被多次呼叫
- 冪等键(Idempotency Key)是HTTP API冪等的標準做法,Stripe、支付宝、微信支付均已採用
- 實作冪等有3层防線:客戶端冪等键 → 服務端去重表 → 資料庫唯一約束
- 支付、下单、庫存扣减是冪等性要求最高的3类介面,面試必考
- 本文提供从原理到Go實作的完整方案,含Redis + PostgreSQL双保险架構
目錄
什麼是冪等性,為什麼必須做
一句话定义
同一个請求无论執行多少次,结果都和執行一次相同,且不會有多余的副作用。
数学表达:f(f(x)) = f(x)
為什麼分散式系統一定会有重複請求
这不是「如果」的問題,而是「什么时候」的問題:
| 重複来源 | 典型場景 | 發生概率 |
|---|---|---|
| 使用者重複点击 | 支付按钮、提交訂單 | 每天都会遇到 |
| 網路逾時重試 | 客戶端自動重試、网关重試 | 生產环境常态 |
| 訊息佇列重複投遞 | Kafka at-least-once、RabbitMQ重投 | 設計如此 |
| 負載均衡重試 | Nginx/Envoy上游逾時重試 | 設定不当时频繁 |
| 微服務呼叫链重試 | 下游逾時,上游重发 | 鏈路越长越常见 |
不做冪等的真實代價
2025年某二手交易平台爆出「重複扣款」事故:使用者点击支付后網路抖动,客戶端自動重試3次,服務端没有冪等校验,導致同一訂單扣款3次。涉及金额超過200万元,監管部门介入調查。
冪等性不是優化项,是支付/交易类系統的准入门槛。
冪等性的4种等级
Level 0:天然冪等(HTTP GET/PUT/DELETE)
GET /api/users/123 → 查詢,天然冪等
PUT /api/users/123 → 全量更新,天然冪等
DELETE /api/users/123 → 刪除,天然冪等
POST /api/orders → 建立,NOT冪等!
POST和PATCH預設不冪等,因为每次呼叫可能建立新資源或产生新副作用。
Level 1:業務层冪等
透過業務邏輯判斷:「这个訂單已经建立过了,直接返回之前的结果」。
func CreateOrder(ctx context.Context, req CreateOrderRequest) (*Order, error) {
existing, err := db.GetOrderByClientOrderId(ctx, req.ClientOrderId)
if err == nil {
return existing, nil // 已存在,直接返回
}
return db.CreateOrder(ctx, req)
}
優點:簡單直接。缺點:並發时兩個請求可能同時透過檢查。
Level 2:冪等键 + 去重表
客戶端传唯一冪等键,服務端用去重表記錄「这个键是否已處理过」。
Level 3:資料庫唯一約束(最终防線)
在資料庫层加唯一索引,即使應用層有漏洞,資料庫也会拒绝重複插入。
CREATE UNIQUE INDEX idx_orders_idempotency_key
ON orders (idempotency_key)
WHERE idempotency_key IS NOT NULL;
生產推薦:Level 2 + Level 3 組合使用。
冪等键設計規範
冪等键的產生規則
| 規則 | 說明 | 範例 |
|---|---|---|
| 客戶端產生 | 由呼叫方在請求前產生UUID | 550e8400-e29b-41d4-a716-446655440000 |
| 唯一性 | 同一業務操作使用同一个键 | 重試时必須複用原键 |
| 作用域 | 通常绑定 user_id + 操作類型 | 不同使用者可以用相同格式的键 |
| 有效期 | 24-72小时 | 過期后可複用(极少發生) |
| 傳遞方式 | HTTP Header | Idempotency-Key: <uuid> |
Stripe的冪等键規範(行業標竿)
POST /v1/charges
Idempotency-Key: uuid-v4-string
Content-Type: application/json
{"amount": 2000, "currency": "usd", "source": "tok_visa"}
Stripe的處理邏輯:
- 收到請求,檢查冪等键是否已存在
- 不存在 → 執行業务邏輯,儲存结果,返回201
- 已存在且參數相同 → 返回快取的结果(200)
- 已存在但參數不同 → 返回409 Conflict
冪等键 vs 請求ID vs 鏈路追蹤ID
| 欄位 | 用途 | 生命週期 | 是否用於去重 |
|---|---|---|---|
| Idempotency-Key | 業務冪等 | 24-72h | 是 |
| X-Request-ID | 請求追蹤 | 單次請求 | 否 |
| Trace-ID | 分散式鏈路追蹤 | 單次呼叫链 | 否 |
三者不要混用。Trace-ID每次請求都不同,不能用於冪等。
3层冪等防線架構
客戶端 服務端 資料庫
│ │ │
│ Idempotency-Key │ │
├────────────────────────→│ │
│ │ Layer 1: Redis 快速去重 │
│ │ SETNX idem:{key} → 成功/失敗 │
│ │ │ │
│ │ ▼ │
│ │ Layer 2: 冪等記錄表 │
│ │ INSERT idempotency_records │
│ │ │ │
│ │ ▼ │
│ │ Layer 3: 業務表唯一約束 │
│ │ INSERT orders (UNIQUE key) ────→│
│ │ │
│ ←── 返回结果 ──────────│ │
Layer 1:Redis SETNX 快速攔截
func (s *IdempotencyService) TryAcquire(ctx context.Context, key string, ttl time.Duration) (bool, error) {
redisKey := fmt.Sprintf("idem:%s", key)
acquired, err := s.redis.SetNX(ctx, redisKey, "processing", ttl).Result()
if err != nil {
return false, fmt.Errorf("redis setnx: %w", err)
}
return acquired, nil
}
SETNX成功 → 首次請求,繼續處理SETNX失敗 → 重複請求,查快取结果返回- TTL 设为 24-72h,防止Redis記憶體洩漏
Layer 2:冪等記錄表
CREATE TABLE idempotency_records (
id BIGSERIAL PRIMARY KEY,
idempotency_key VARCHAR(64) NOT NULL,
request_hash VARCHAR(64) NOT NULL, -- 請求体雜湊,防參數篡改
response_status INT,
response_body JSONB,
status VARCHAR(20) NOT NULL DEFAULT 'processing',
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
expires_at TIMESTAMPTZ NOT NULL,
CONSTRAINT uq_idempotency_key UNIQUE (idempotency_key)
);
CREATE INDEX idx_idem_expires ON idempotency_records (expires_at);
狀態流轉:processing → completed / failed
Layer 3:業務表唯一約束
ALTER TABLE orders ADD COLUMN idempotency_key VARCHAR(64);
CREATE UNIQUE INDEX idx_orders_idem ON orders (idempotency_key);
即使Layer 1和Layer 2都失效(Redis當機、应用bug),資料庫唯一約束是最后防線。
Go生產级冪等中介軟體實作
冪等中介軟體(Gin框架)
type IdempotencyMiddleware struct {
redis *redis.Client
db *sql.DB
ttl time.Duration
}
func (m *IdempotencyMiddleware) Handle() gin.HandlerFunc {
return func(c *gin.Context) {
key := c.GetHeader("Idempotency-Key")
if key == "" {
c.Next()
return
}
ctx := c.Request.Context()
cached, err := m.getCachedResponse(ctx, key)
if err == nil && cached != nil {
c.JSON(cached.Status, cached.Body)
c.Abort()
return
}
acquired, err := m.redis.SetNX(ctx, "idem:"+key, "1", m.ttl).Result()
if err != nil {
c.JSON(500, gin.H{"error": "idempotency check failed"})
c.Abort()
return
}
if !acquired {
for i := 0; i < 30; i++ {
time.Sleep(100 * time.Millisecond)
cached, err = m.getCachedResponse(ctx, key)
if cached != nil {
c.JSON(cached.Status, cached.Body)
c.Abort()
return
}
}
c.JSON(409, gin.H{"error": "duplicate request in progress"})
c.Abort()
return
}
blw := &bodyLogWriter{body: bytes.NewBufferString(""), ResponseWriter: c.Writer}
c.Writer = blw
c.Next()
m.saveResponse(ctx, key, c.Writer.Status(), blw.body.String())
}
}
並發安全的關鍵點
兩個相同冪等键的請求同時到达时:
- Redis SETNX 只有一個能成功
- 失敗者進入輪詢等待(100ms × 30次 = 3秒)
- 成功者處理完毕后将结果寫入DB
- 失敗者从DB讀到快取结果返回
这个「SETNX + 輪詢等待」模式是Stripe、支付宝等大廠的標準做法。
支付場景冪等實戰
支付介面的完整冪等流程
使用者点击支付
│
▼
客戶端產生 Idempotency-Key (UUID)
│
▼
POST /api/payments {amount, order_id}
Header: Idempotency-Key: uuid
│
▼
┌─ 冪等檢查 ─────────────────────────────┐
│ 1. Redis SETNX │
│ 2. 查冪等記錄表 │
│ 3. 查訂單狀態(已支付?直接返回) │
└─────────────────────────────────────────┘
│ 首次請求
▼
┌─ 業務處理(資料庫交易)─────────────────┐
│ BEGIN │
│ INSERT payment (status=pending) │
│ 呼叫支付渠道API │
│ UPDATE payment (status=success) │
│ UPDATE order (status=paid) │
│ COMMIT │
└─────────────────────────────────────────┘
│
▼
快取響應到冪等記錄表 + Redis
│
▼
返回支付结果给客戶端
支付冪等的3个特殊考量
1. 支付渠道也要冪等
呼叫微信/支付宝时,必須传商戶訂單号(out_trade_no),同一訂單号渠道侧也会去重。
2. 狀態机保證
pending → processing → success
→ failed
→ timeout → (人工介入)
已 success 的訂單,任何重試都直接返回成功,不會再次扣款。
3. 對帳兜底
每日對帳任务檢查:支付渠道账单 vs 本地訂單,發現不一致时告警。冪等不是100%可靠的,對帳是最后的安全網。
面試高頻考點与踩坑指南
面試常問的6个問題
Q1:GET是冪等的,POST為什麼不是?
GET是读操作,不改變伺服器狀態。POST是写操作,每次呼叫可能建立新資源。但可以透過冪等键让POST具備冪等性。
Q2:Redis當機了冪等还有效吗?
Layer 1(Redis)失效后,Layer 2(冪等記錄表)和Layer 3(唯一約束)仍然生效。Redis是效能優化层,不是唯一保障。
Q3:冪等键過期了,使用者又发了一样的請求怎麼办?
過期后視為新請求。但業務层应檢查訂單狀態:如果訂單已支付,即使冪等键過期也直接返回已支付结果。冪等键是技術手段,業務狀態是最终判斷依據。
Q4:分散式锁能實作冪等吗?
可以,但不是最優方案。分散式锁解決的是並發互斥,冪等解決的是重複請求。锁的粒度、逾時、死鎖問題比冪等键複雜得多。推薦冪等键 + 去重表,而非分散式锁。
Q5:訊息佇列消費端怎麼做冪等?
三種方式:(1) 業務唯一键去重(推薦);(2) 消費端維護已處理訊息ID表;(3) 資料庫唯一約束。Kafka的冪等生產者是生產者侧保障,消費端仍需自行去重。
Q6:冪等和交易什么關係?
冪等保證「多次執行 = 一次執行」,交易保證「多個操作要么全成功要么全失敗」。支付場景两者都需要:交易保證扣款和更新訂單狀態的原子性,冪等保證重試不會重複扣款。
踩坑清單
| 坑 | 表現 | 解法 |
|---|---|---|
| 冪等键由服務端產生 | 客戶端重試时鍵不同,去重失效 | 必須由客戶端產生并複用 |
| 只存成功结果 | 失敗重試时返回舊錯誤 | 成功和失敗结果都快取 |
| 不设過期时间 | Redis/DB数据無限增長 | TTL 24-72h + 定時清理 |
| 並發不加鎖 | 兩個請求同時透過檢查 | SETNX + 輪詢等待 |
| 忽略支付渠道冪等 | 本地冪等了但渠道扣了兩次 | 商戶訂單号全域唯一 |
庫存扣減場景的冪等設計
支付冪等之外,庫存扣減是面試和實戰中的第二大高頻場景。推薦方案:冪等键 + 條件更新(WHERE quantity >= $1),資料庫層原子守衛防止超賣。
分散式環境下的冪等挑戰
多實例並發用 SETNX + 輪詢等待;Redis 叢集故障時資料庫唯一約束是最終保障。
效能優化與企業落地案例
三層效能:純DB → Redis+DB → Bloom Filter+Redis+DB。某支付平台改造後連續8個月零重複扣款。
動手實戰與2026趨勢
30分鐘搭建冪等支付介面。趨勢:網關層標準化、OpenTelemetry整合、AI Agent API層冪等。
總結与延伸閱讀
API冪等性是后端開發的基本功,也是面試高頻考點。記住核心公式:客戶端冪等键 + Redis快速去重 + 資料庫唯一約束 = 生產级冪等。
設計要點回顧:
- POST介面預設不冪等,必須顯式設計
- 冪等键由客戶端產生,重試时複用同一个键
- 3层防線:Redis SETNX → 冪等記錄表 → 唯一約束
- 支付場景必須結合狀態机 + 對帳兜底
- 並發場景用 SETNX + 輪詢等待,不要用分散式锁
相關閱讀:
- Redis分散式锁實戰模式 — 並發控制与冪等的區別
- Go微服務韧性設計 — 重試、熔断与冪等的配合
- Go微服務限流器實作 — 限流与冪等的組合使用
權威參考:
本站提供瀏覽器本地工具,免註冊即可試用 →