mobile wallpaper 1mobile wallpaper 2mobile wallpaper 3mobile wallpaper 4
2719 字
7 分钟
幂等键(Idempotency Key)
2026-06-13

一、为什么需要幂等键#

网络不可靠,这不是假设,是事实。客户端发起支付请求,服务端处理成功,扣了款,但响应在回程路上丢了——网络超时。客户端没收到成功响应,以为请求失败了,于是重试。服务端又收到一个一模一样的支付请求,又扣了一次款。用户被扣了两次钱。

这不是纸上谈兵。Stripe 在工程博客中明确指出,没有幂等机制的情况下,1-3% 的请求重试率会导致重复扣款。对于一个日处理百万笔交易的支付平台,这意味着每天几千笔重复交易——每一笔背后都是一次客诉。

Webhook 场景同样严重。支付平台向你的服务器发送通知:“订单 X 已支付”。你的服务器处理完毕,正要返回 200 ACK,进程恰好在这时 OOM 被杀。支付平台没收到 ACK,按照协议重试。你的服务器重启后再次收到同一个事件,又处理了一遍——库存扣了两次,会员充了两次。

问题的根源在于:HTTP 协议没有”这个请求我之前处理过”的机制。客户端不知道服务端到底处理了没有,只能重试;服务端不知道这个请求之前处理过没有,只能再处理一遍。幂等键(Idempotency Key)就是填补这个空白的方案。

二、现实类比#

去餐厅点菜,服务员给你一张小票,上面写着号码 42。你等了半天菜没来,去前台问。你说”我是 42 号”,前台查了一下——42 号已经在做了,不用重新下单。如果没有这个号码呢?你再去点一次,厨房又做一份,你收到两份菜,付两份钱。

小票上的号码就是幂等键。它唯一标识了一次操作,让服务端能识别”这个请求我之前处理过了”。客户端生成号码,随请求一起发送;服务端记录号码和处理结果,下次看到相同号码就直接返回之前的结果。

三、核心思想#

幂等键的工作流程可以用一张时序图说清楚:

sequenceDiagram participant C as 客户端 participant S as 服务端 participant Store as 幂等存储 Note over C,S: 首次请求 C->>S: POST /pay {amount: 100, idempotency_key: "abc-123"} S->>Store: 查询 key="abc-123" Store-->>S: 不存在 S->>Store: 写入 {key: "abc-123", status: PROCESSING} S->>S: 执行支付逻辑 S->>Store: 更新 {key: "abc-123", status: COMPLETED, result: {order_id: "ord-456"}} S-->>C: 200 OK {order_id: "ord-456"} Note over C,S: 重复请求(网络超时后重试) C->>S: POST /pay {amount: 100, idempotency_key: "abc-123"} S->>Store: 查询 key="abc-123" Store-->>S: 已存在,status=COMPLETED, result={order_id: "ord-456"} S-->>C: 200 OK {order_id: "ord-456"}(直接返回缓存结果,不再执行支付)

首次请求时,服务端在存储中找不到这个键,于是正常处理,并把键与结果一起保存。重复请求到达时,服务端发现键已存在且状态为 COMPLETED,直接返回缓存的结果——不再执行业务逻辑。

核心数据结构:

IdempotencyRecord {
key: string // 幂等键,全局唯一
status: string // PROCESSING | COMPLETED | EXPIRED
result: any // 缓存的响应结果
created_at: timestamp // 创建时间
expires_at: timestamp // 过期时间
}

操作复杂度:

属性说明
查询O(1)按 key 查询,哈希表或索引
写入O(1)写入记录,首次请求时
空间O(n)n 为 TTL 窗口内的唯一键数量

3.1 键的生成策略#

幂等键由客户端生成,不是服务端生成。这一点很关键——如果键由服务端生成,客户端在第一次请求失败后根本不知道之前生成的键是什么,重试时无法携带相同的键,幂等就无从谈起。

常见的生成方式:

  • UUID:最简单,uuid.v4() 生成一个全局唯一的字符串。客户端在发起操作前生成,重试时复用同一个 UUID。Stripe API 就采用这种方式
  • 请求内容哈希:对请求体中的业务参数做哈希,比如 hash(user_id + amount + order_ref)。好处是相同参数必然生成相同的键,坏处是参数变了键就变了,灵活性差
  • 业务唯一 ID:如果业务本身就有唯一标识(订单号、交易流水号),直接拿来当幂等键。这是最自然的方式,但前提是业务层确实有这个 ID

键的全局唯一性只需要在 TTL 窗口内保证。过期后,相同的键可以被重新使用——因为之前的请求结果已经清理,不会再被误判为重复。

3.2 状态机#

幂等键有三态,不是两态。这是很多人容易忽略的:

stateDiagram-v2 [*] --> PROCESSING: 收到首次请求 PROCESSING --> COMPLETED: 处理成功,缓存结果 PROCESSING --> EXPIRED: 处理超时 / TTL 到期 COMPLETED --> EXPIRED: TTL 到期 EXPIRED --> [*]: 清理
  • PROCESSING:请求正在处理中。这个状态的存在是为了防止并发重复请求的竞态条件。假设两个相同的请求几乎同时到达,第一个请求把键标记为 PROCESSING,第二个请求看到 PROCESSING 状态,知道有人在处理了,可以等待或返回 202 Accepted
  • COMPLETED:处理完成,结果已缓存。后续相同键的请求直接返回缓存结果
  • EXPIRED:TTL 过期,记录可清理。过期后相同键视为新请求

没有 PROCESSING 状态会怎样?两个相同请求同时到达,都查到”键不存在”,都去执行业务逻辑——又重复了。PROCESSING 状态相当于一把轻量级的分布式锁,保证同一个键同时只有一个请求在处理。

四、变体与对比#

方案原理优点缺点适用场景
幂等键客户端生成唯一键,服务端缓存结果语义清晰,客户端可控需要额外存储,客户端必须配合支付 API、Webhook 接收
去重表服务端用唯一约束拦截重复实现简单,数据库原生支持只能防重复,不能返回上次结果消息消费去重
自然键约束业务字段加唯一索引无额外开销业务字段不一定天然唯一订单号、用户 ID 等天然唯一场景
乐观锁版本号控制并发写入不需要额外存储只防并发冲突,不防重试更新操作,如账户余额变更

去重表和幂等键的区别在于:去重表只告诉你”这个请求处理过了”,但不告诉你”上次的结果是什么”。对于需要返回响应的 API,幂等键更合适——它能直接返回上次的结果,客户端不需要再发请求查询。

乐观锁解决的是并发写入问题,不是重试问题。两个请求同时修改账户余额,乐观锁能防止覆盖,但不能防止”扣两次钱”——因为两次请求的版本号可能不同。乐观锁和幂等键可以组合使用:幂等键防重试,乐观锁防并发。

五、多语言实现#

5.1 Go 实现#

package idempotency
import (
"context"
"net/http"
"sync"
"time"
)
// Status 幂等记录的状态
type Status string
const (
StatusProcessing Status = "PROCESSING"
StatusCompleted Status = "COMPLETED"
)
// Record 幂等记录
type Record struct {
Status Status
Result *Response
CreatedAt time.Time
ExpiresAt time.Time
}
// Response 缓存的响应
type Response struct {
StatusCode int
Body []byte
Headers http.Header
}
// Store 幂等键存储(内存版,生产环境应替换为 Redis / 数据库)
type Store struct {
mu sync.RWMutex
records map[string]*Record
ttl time.Duration
}
func NewStore(ttl time.Duration) *Store {
return &Store{
records: make(map[string]*Record),
ttl: ttl,
}
}
// Get 查询幂等记录
func (s *Store) Get(key string) (*Record, bool) {
s.mu.RLock()
defer s.mu.RUnlock()
rec, exists := s.records[key]
if !exists {
return nil, false
}
// 过期记录视为不存在
if time.Now().After(rec.ExpiresAt) {
return nil, false
}
return rec, true
}
// SetProcessing 标记为处理中(防止并发重复请求)
func (s *Store) SetProcessing(key string) bool {
s.mu.Lock()
defer s.mu.Unlock()
rec, exists := s.records[key]
if exists && time.Now().Before(rec.ExpiresAt) {
// 键已存在且未过期,不能重复处理
return false
}
s.records[key] = &Record{
Status: StatusProcessing,
CreatedAt: time.Now(),
ExpiresAt: time.Now().Add(s.ttl),
}
return true
}
// SetCompleted 标记为完成,缓存结果
func (s *Store) SetCompleted(key string, result *Response) {
s.mu.Lock()
defer s.mu.Unlock()
if rec, exists := s.records[key]; exists {
rec.Status = StatusCompleted
rec.Result = result
}
}
// Cleanup 清理过期记录(定期调用)
func (s *Store) Cleanup() {
s.mu.Lock()
defer s.mu.Unlock()
now := time.Now()
for k, rec := range s.records {
if now.After(rec.ExpiresAt) {
delete(s.records, k)
}
}
}
// Middleware HTTP 中间件,包装任意 Handler
func Middleware(store *Store, headerKey string) func(http.Handler) http.Handler {
return func(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
key := r.Header.Get(headerKey)
if key == "" {
// 没有幂等键,直接放行
next.ServeHTTP(w, r)
return
}
// 查询已有记录
rec, exists := store.Get(key)
if exists {
switch rec.Status {
case StatusCompleted:
// 已完成,直接返回缓存结果
for k, v := range rec.Result.Headers {
for _, vv := range v {
w.Header().Add(k, vv)
}
}
w.WriteHeader(rec.Result.StatusCode)
w.Write(rec.Result.Body)
return
case StatusProcessing:
// 正在处理中,返回 202 让客户端稍后重试
w.WriteHeader(http.StatusAccepted)
w.Write([]byte(`{"error":"request is being processed"}`))
return
}
}
// 标记为 PROCESSING
if !store.SetProcessing(key) {
// 并发冲突,另一个请求已经在处理
w.WriteHeader(http.StatusConflict)
w.Write([]byte(`{"error":"concurrent request detected"}`))
return
}
// 捕获响应
rw := &responseWriter{ResponseWriter: w}
next.ServeHTTP(rw, r)
// 缓存结果
store.SetCompleted(key, &Response{
StatusCode: rw.statusCode,
Body: rw.body,
Headers: rw.Header(),
})
})
}
}
// responseWriter 包装 http.ResponseWriter,捕获响应内容
type responseWriter struct {
http.ResponseWriter
statusCode int
body []byte
}
func (rw *responseWriter) WriteHeader(code int) {
rw.statusCode = code
rw.ResponseWriter.WriteHeader(code)
}
func (rw *responseWriter) Write(b []byte) (int, error) {
rw.body = append(rw.body, b...)
return rw.ResponseWriter.Write(b)
}

Go 版本实现了完整的幂等键中间件。几个设计要点:SetProcessing 用互斥锁保证原子性——查询和写入在同一个锁内完成,避免两个请求同时查到”不存在”然后都去处理。responseWriter 捕获响应内容,用于缓存。生产环境中,内存 map 应替换为 Redis 或数据库,并加上分布式锁。

5.2 TypeScript 实现#

import express from "express";
import Redis from "ioredis";
// 幂等键中间件,使用 Redis 作为后端存储
function idempotencyMiddleware(
redis: Redis,
headerKey = "Idempotency-Key",
ttlSeconds = 86400, // 默认 24 小时过期
) {
return async (
req: express.Request,
res: express.Response,
next: express.NextFunction,
) => {
const key = req.headers[headerKey.toLowerCase()] as string;
if (!key) {
// 没有幂等键,直接放行
next();
return;
}
const redisKey = `idempotency:${key}`;
// 查询已有记录
const existing = await redis.get(redisKey);
if (existing) {
const record = JSON.parse(existing);
if (record.status === "COMPLETED") {
// 已完成,直接返回缓存结果
res.set(record.headers || {});
res.status(record.statusCode).json(record.body);
return;
}
if (record.status === "PROCESSING") {
// 正在处理中,返回 202
res.status(202).json({ error: "request is being processed" });
return;
}
}
// SETNX 抢占 PROCESSING 状态,防止并发
const acquired = await redis.set(
redisKey,
JSON.stringify({ status: "PROCESSING", createdAt: Date.now() }),
"EX",
ttlSeconds,
"NX",
);
if (!acquired) {
// 另一个请求抢先了
res.status(409).json({ error: "concurrent request detected" });
return;
}
// 捕获响应
const originalJson = res.json.bind(res);
const originalStatus = res.status.bind(res);
let capturedStatus = 200;
let capturedBody: unknown;
res.status = function (code: number) {
capturedStatus = code;
return originalStatus(code);
};
res.json = function (body: unknown) {
capturedBody = body;
// 缓存结果到 Redis
redis.set(
redisKey,
JSON.stringify({
status: "COMPLETED",
statusCode: capturedStatus,
body,
headers: res.getHeaders(),
completedAt: Date.now(),
}),
"EX",
ttlSeconds,
);
return originalJson(body);
};
next();
};
}
// 使用示例
const app = express();
const redis = new Redis({ host: "localhost", port: 6379 });
app.use(express.json());
app.use(idempotencyMiddleware(redis));
app.post("/pay", async (req, res) => {
// 业务逻辑——即使重试,也只会执行一次
const { amount, userId } = req.body;
const orderId = await processPayment(userId, amount);
res.json({ orderId });
});
async function processPayment(userId: string, amount: number): Promise<string> {
// 模拟支付处理
return `ord-${Date.now()}`;
}
app.listen(3000);

TypeScript 版本用 Redis 的 SET NX EX 命令实现原子性的”查询 + 写入”操作。NX 保证只有第一个请求能设置成功,后续请求发现键已存在,走缓存路径。EX 设置 TTL,过期自动清理。这比 Go 的内存版更适合生产环境——Redis 天然支持分布式,多个服务实例共享同一份幂等记录。

六、生产验证#

项目源码位置用途
Stripe APIIdempotency-Key 请求头Stripe 支付 API 的核心机制。客户端在请求头中携带 Idempotency-Key,服务端自动去重并缓存结果,TTL 24 小时
AWS SDKClientToken 参数AWS SDK 内置幂等令牌机制。创建 EC2 实例等操作支持 ClientToken,相同令牌不会重复创建资源
DynamoDB条件写入(ConditionExpression)DynamoDB 的条件写入本质上是一种乐观锁 + 幂等键的组合。attribute_not_exists(order_id) 保证相同订单不会写入两次

Stripe 的实现最值得参考。它把幂等键做到了 API 层面——所有 POST 端点都支持 Idempotency-Key 请求头,服务端自动处理去重和缓存。键的 TTL 是 24 小时,过期后相同键视为新请求。Stripe 还处理了一个细节:如果相同键的请求参数和之前不同,返回 422 而不是缓存结果——防止客户端误用幂等键。

AWS 的 ClientToken 机制更偏向基础设施层。创建 EC2 实例、启动 RDS 集群等操作天然不是幂等的,AWS 通过 ClientToken 让它们变成幂等的。令牌的有效期更长,某些操作支持数天甚至数周。

七、小结#

何时使用:

  • 支付 API——扣款、退款、转账,重复执行意味着真金白银的损失。幂等键是支付系统的标配,不是可选项
  • Webhook 接收——第三方服务会重试未确认的通知,你的服务必须能安全地处理重复事件
  • 订单创建——用户双击提交按钮、前端重试、网络抖动,都可能导致重复下单
  • 任何”执行多次和执行一次效果不同”的操作——这是幂等键的定义域

何时不用:

  • 只读操作——GET 请求天然幂等,查询执行多少次结果都一样,不需要幂等键
  • 重复执行无害的操作——比如”记录用户最后登录时间”,重复写入只是覆盖,没有副作用
  • 批量导入——每条记录用业务 ID 去重即可,不需要额外的幂等键层
  • 实时性要求极高的场景——幂等键引入了一次额外的存储查询,如果延迟预算极其紧张,可能需要权衡

八、参考资料#

支持与分享

如果这篇文章对你有帮助,欢迎支持作者或分享给更多人

幂等键(Idempotency Key)
https://blog.souloss.com/posts/programming/system-patterns/system-patterns-idempotency-key/
作者
Tsukimi
发布于
2026-06-13
许可协议
CC BY-NC-SA 4.0

部分信息可能已经过时