幂等键:让重试变得安全的那一次写入

网络超时不代表服务端没执行。幂等键把「同一个请求」标识出来,服务端据此返回首次结果而不是再执行一遍。

客户端发出支付请求、连接超时、自动重试 —— 如果服务端第一次其实成功了,钱就扣了两次。超时不等于失败,这是所有重试逻辑的前提。

幂等键做什么

客户端为每次「业务意图」生成一个唯一键(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 创建资源、任何扣款、任何发消息的接口都需要。 判据是「执行两次会不会产生两次副作用」。

幂等键不是把重试变安全,而是让服务端有能力识别「这已经是同一个请求了」。

← 返回文章列表

评论

…