一、为什么需要幂等键
网络不可靠,这不是假设,是事实。客户端发起支付请求,服务端处理成功,扣了款,但响应在回程路上丢了——网络超时。客户端没收到成功响应,以为请求失败了,于是重试。服务端又收到一个一模一样的支付请求,又扣了一次款。用户被扣了两次钱。
这不是纸上谈兵。Stripe 在工程博客中明确指出,没有幂等机制的情况下,1-3% 的请求重试率会导致重复扣款。对于一个日处理百万笔交易的支付平台,这意味着每天几千笔重复交易——每一笔背后都是一次客诉。
Webhook 场景同样严重。支付平台向你的服务器发送通知:“订单 X 已支付”。你的服务器处理完毕,正要返回 200 ACK,进程恰好在这时 OOM 被杀。支付平台没收到 ACK,按照协议重试。你的服务器重启后再次收到同一个事件,又处理了一遍——库存扣了两次,会员充了两次。
问题的根源在于:HTTP 协议没有”这个请求我之前处理过”的机制。客户端不知道服务端到底处理了没有,只能重试;服务端不知道这个请求之前处理过没有,只能再处理一遍。幂等键(Idempotency Key)就是填补这个空白的方案。
二、现实类比
去餐厅点菜,服务员给你一张小票,上面写着号码 42。你等了半天菜没来,去前台问。你说”我是 42 号”,前台查了一下——42 号已经在做了,不用重新下单。如果没有这个号码呢?你再去点一次,厨房又做一份,你收到两份菜,付两份钱。
小票上的号码就是幂等键。它唯一标识了一次操作,让服务端能识别”这个请求我之前处理过了”。客户端生成号码,随请求一起发送;服务端记录号码和处理结果,下次看到相同号码就直接返回之前的结果。
三、核心思想
幂等键的工作流程可以用一张时序图说清楚:
首次请求时,服务端在存储中找不到这个键,于是正常处理,并把键与结果一起保存。重复请求到达时,服务端发现键已存在且状态为 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 状态机
幂等键有三态,不是两态。这是很多人容易忽略的:
- 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 中间件,包装任意 Handlerfunc 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 API | Idempotency-Key 请求头 | Stripe 支付 API 的核心机制。客户端在请求头中携带 Idempotency-Key,服务端自动去重并缓存结果,TTL 24 小时 |
| AWS SDK | ClientToken 参数 | 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 去重即可,不需要额外的幂等键层
- 实时性要求极高的场景——幂等键引入了一次额外的存储查询,如果延迟预算极其紧张,可能需要权衡
八、参考资料
- Stripe Idempotent Requests - Stripe API 幂等键机制的官方文档,含最佳实践和常见错误
- AWS Idempotency - AWS EC2 API 的幂等性设计,ClientToken 参数说明
- HTTP Semantics - Idempotent Methods - HTTP 规范对幂等方法的定义,GET/PUT/DELETE 的语义保证
- Heroku API Design Guide - Heroku 的 API 设计指南,包含幂等键的使用建议
- DynamoDB Conditional Expressions - DynamoDB 条件写入,实现无锁幂等的经典方案
支持与分享
如果这篇文章对你有帮助,欢迎支持作者或分享给更多人
部分信息可能已经过时






