幂等键:让重试变得安全的那一次写入
网络超时不代表服务端没执行。幂等键把「同一个请求」标识出来,服务端据此返回首次结果而不是再执行一遍。
客户端发出支付请求、连接超时、自动重试 —— 如果服务端第一次其实成功了,钱就扣了两次。超时不等于失败,这是所有重试逻辑的前提。
幂等键做什么
客户端为每次「业务意图」生成一个唯一键(UUID),重试时复用同一个键:
POST /payments
Idempotency-Key: 6f4a1e2c-9b3d-4c8a-9f21-7e5d0b3a1c88
服务端逻辑:以键为唯一索引写一条记录。第一次真正执行并把结果存进去;后续同键请求直接返回存下的结果。
实现要点
async function handle(key: string, body: Body) {
try {
const row = await db.insert('idempotency', { key, state: 'in_progress' });
} catch (e) {
if (!isUniqueViolation(e)) throw e;
// 已存在:返回已有结果或告知正在处理
const existing = await db.get('idempotency', key);
if (existing.state === 'done') return existing.response;
return conflict('in progress');
}
const result = await doWork(body);
await db.update('idempotency', key, { state: 'done', response: result });
return result;
}
三个容易做漏的点:
| 要点 | 漏了会怎样 |
|---|---|
| 唯一索引在数据库层 | 并发请求都能插入成功,各执行一次 |
| 记录原始响应体 | 重试只能再算一遍,失去意义 |
| 保留 in_progress 状态 | 无法区分「正在跑」与「已完成」 |
键的作用域与有效期
幂等键应该绑定到认证主体 + 端点。否则 A 用户的键可能命中 B 用户的记录。
有效期按业务定:支付一般保留 24 小时到几天。太短会漏掉真正的重试,太长会让表无限增长。过期清理要独立于业务事务,避免删掉正在进行的记录。
请求体指纹
严格实现还会存请求体的哈希,用来发现「同键不同内容」的误用:
if (existing.requestHash !== sha256(body)) {
return unprocessable('key reused with different payload');
}
返回 422 而不是 409 更合适 —— 这是客户端的编程错误,不是状态冲突。
什么时候不需要
纯幂等的操作不需要键:GET、PUT 全量替换、DELETE。POST 创建资源、任何扣款、任何发消息的接口都需要。 判据是「执行两次会不会产生两次副作用」。
幂等键不是把重试变安全,而是让服务端有能力识别「这已经是同一个请求了」。

评论
…