2025年5月12日·阅读约1分钟

在 Go 中实现幂等端点:幂等键、去重表与重试策略

在 Go 中设计幂等端点,使用幂等键、PostgreSQL 去重表和支持重试的安全处理器,适用于支付、导入和 Webhook。

在 Go 中实现幂等端点:幂等键、去重表与重试策略

为什么重试会产生重复(以及幂等为何重要)

重试会发生,即使“看起来没出错”。客户端超时而服务器仍在处理;移动网络中断导致应用重试;任务运行器遇到 502 会自动重新发送相同请求。在采用至少一次投递(队列和 Webhook 常见)的系统中,重复是正常现象。

这就是幂等重要的原因:重复的请求应该带来与单次请求相同的最终结果。

几个容易混淆的术语:

  • 安全(Safe):调用不会改变状态(比如读取)。
  • 幂等(Idempotent):多次调用的效果与一次调用相同。
  • 至少一次(At-least-once):发送方会重试直到“生效”,接收方必须能处理重复。

没有幂等性,重试会造成真实损害。支付端点可能会重复扣款(当第一次扣款成功但响应未到达客户端时)。导入端点在工作进程超时重试时可能会创建重复行。Webhook 处理器可能会处理同一事件两次并发送两封邮件。

关键点:幂等是 API 合约,而不是私有实现细节。客户端需要知道哪些操作可以重试、要发送哪个键以及在检测到重复时可以期望什么样的响应。如果你在不通知的情况下改变行为,会破坏重试逻辑并产生新的失败模式。

幂等也不能替代监控和对账。跟踪重复率、记录“重放”决策,并定期将外部系统(比如支付提供方)与数据库比较。

为每个端点选择幂等作用域和规则

在添加表或中间件之前,先决定“相同请求”是什么意思,以及服务器在客户端重试时承诺如何处理。

大多数问题出现在 POST,因为它通常会创建东西或触发副作用(扣款、发送消息、开始导入)。如果 PATCH 触发副作用而不只是简单字段更新,也可能需要幂等。GET 不应改变状态。

定义作用域:键在哪个范围内唯一

选择与业务规则匹配的作用域。范围过大会阻止合法操作,范围过小会允许重复。

常见作用域:

  • 按端点 + 客户
  • 按端点 + 外部对象(例如 invoice_id 或 order_id)
  • 按端点 + 租户(多租户系统)
  • 按端点 + 支付方式 + 金额(仅当产品规则允许时)

示例:对于“创建支付”端点,让键在每个客户范围内唯一。对于“接收 webhook 事件”,按支付提供方的事件 ID 设定作用域(提供方保证全局唯一)。

决定重复请求时返回什么

当出现重复时,应返回与第一次成功尝试相同的结果。实际上,这意味着重放相同的 HTTP 状态码和响应体(或至少相同的资源 ID 和状态)。

客户端依赖此行为。如果第一次尝试成功但网络断了,重试不应造成第二次扣款或第二个导入任务。

选择保留窗口

键应有过期时间。保留时间要足够覆盖现实的重试和延迟任务。

  • 支付:常见为 24 到 72 小时。
  • 导入:如果用户可能稍后重试,保留一周可能是合理的。
  • Webhook:与提供方的重试策略保持一致。

定义“相同请求”:显式键 vs 请求体哈希

显式的幂等键(头部或字段)通常是最干净的规则。

请求体哈希可以作为补充,但很容易因无害变动而失效(字段顺序、空白、时间戳)。如果使用哈希,请规范化输入并严格指定包含哪些字段。

幂等键:在实践中的工作方式

幂等键是客户端与服务器之间的简单契约:“如果你看到这个键,再次视为相同请求。”它是实现支持重试 API 最实用的工具之一。

键可以由任一方生成,但对于大多数 API 应由客户端生成。客户端知道何时在重试同一操作,因此可以在尝试间重用同一键。服务器生成的键在你先创建“草稿”资源(比如导入作业)时有用,客户端随后可通过引用该作业 ID 来重试,但它对第一次请求无济于事。

使用随机且不可猜测的字符串。目标至少 128 位随机(例如 32 个十六进制字符或 UUID)。不要把键构建自时间戳或用户 ID。

在服务器上,和足够的上下文一起存储键以检测滥用并重放原始结果:

  • 谁发起的调用(account 或 user ID)
  • 适用的端点或操作
  • 重要请求字段的哈希
  • 当前状态(进行中、成功、失败)
  • 用于重放的响应(状态码和响应体)

键应有作用域,通常按用户(或 API 令牌)加端点。如果同一键在不同负载下被重用,应以明确错误拒绝。这能防止有缺陷的客户端用旧键发送新的支付金额而造成意外冲突。

在重放时,返回与第一次成功尝试相同的结果。这意味着相同的 HTTP 状态码和相同的响应体,而不是一个可能已发生变化的新读取结果。

PostgreSQL 去重表:简单且可靠的模式

专用去重表是实现幂等性的最简单方法之一。第一次请求创建一行 idempotency key,每次重试读取该行并返回存储的结果。

存什么

保持表结构小且专注。常见结构:

  • key:幂等键(text)
  • owner:键归属(user_id、account_id 或 API 客户端 ID)
  • request_hash:重要请求字段的哈希
  • response:最终响应负载(通常为 JSON)或指向存储结果的指针
  • created_at:首次见到键的时间

唯一约束是该模式的核心。在 (owner, key) 上强制唯一,这样一个客户端不能创建重复,而不同客户端不会碰撞。

还应存 request_hash 以检测键的误用。如果重复到达时键相同但哈希不同,应返回错误而不是混合两个不同操作。

保留与索引

去重行不应永久保留。保留足够长以覆盖真实重试窗口,然后清理它们。

为了在高负载时保持速度:

  • (owner, key) 上建立唯一索引以加速插入或查找
  • 可选地在 created_at 上建索引以便清理开销小

如果响应很大,存一个指针(例如结果 ID)并把完整负载放到别处。这样可减少表膨胀,同时保持重试行为一致。

逐步示例:Go 中的支持重试的处理器流程

为变化安全设计
在早期将幂等性纳入 API 设计,然后在需求变化时安全地重新生成干净代码。
开始构建

支持重试的处理器需要两件事:一个稳定的方法来识别“相同请求再次到来”,以及一个持久化存储第一次结果的地方以便重放。

适用于支付、导入和 webhook 接收的实用流程:

  1. 验证请求,然后派生三个值:幂等键(来自头部或客户端字段)、所有者(租户或用户 ID)和请求哈希(重要字段的哈希)。

  2. 开始数据库事务,尝试创建去重记录。在 (owner, key) 上做唯一约束。存 request_hash、状态(started、completed)和响应占位。

  3. 如果插入冲突,加载已存在的行。如果它已完成,返回存储的响应。如果它在进行中,要么短暂等待(简单轮询),要么返回 409/202 让客户端稍后重试。

  4. 只有当你成功“拥有”去重行时,才运行业务逻辑一次。尽可能在同一事务内写入副作用。持久化业务结果以及 HTTP 响应(状态码和响应体)。

  5. 提交,并用幂等键和所有者记录日志,便于支持追踪重复请求。

一个最小表模式:

create table idempotency_keys (
  owner_id text not null,
  idem_key text not null,
  request_hash text not null,
  status text not null,
  response_code int,
  response_body jsonb,
  created_at timestamptz not null default now(),
  updated_at timestamptz not null default now(),
  primary key (owner_id, idem_key)
);

示例:一个“创建支付”端点在扣款后超时。客户端用相同键重试。处理器遇到冲突,看到已完成的记录,返回原始的 payout ID,而不再次扣款。

支付:在超时情况下保证只扣一次款

支付场景下幂等性不是可选项。网络会失败、移动应用会重试、网关有时在已创建扣款后超时。

一个实用规则:幂等键保护扣款创建,支付提供方的 ID(charge/intent ID)在那之后成为事实源。一旦存储了提供方 ID,不要为相同请求创建新的扣款。

一个能处理重试与网关不确定性的模式:

  • 读取并验证幂等键。
  • 在数据库事务中,按 (merchant_id, idempotency_key) 创建或获取支付行。如果它已有 provider_id,返回已保存的结果。
  • 如果没有 provider_id,调用网关创建 PaymentIntent/Charge。
  • 如果网关成功,持久化 provider_id 并将支付标记为“succeeded”(或“requires_action”)。
  • 如果网关超时或返回未知结果,存储状态为“pending”,并返回一个一致的响应,告诉客户端可以安全重试。

关键细节是如何处理超时:不要假设失败。将支付标记为 pending,然后通过稍后查询网关(或通过 webhook)使用提供方 ID 来确认结果。

错误响应应可预测。客户端会基于你返回的内容构建重试逻辑,因此保持状态码和错误结构稳定。

导入与批量端点:去重且不丢失进度

使导入支持重试
创建可重启的导入任务,在重试时返回相同的作业 ID。
立即构建

导入场景下重复最致命。用户上传 CSV,服务器在 95% 处超时,他们重试。没有计划的话,你要么创建重复行,要么让他们重头开始。

针对批量工作,按两层考虑:导入作业和作业内的项。作业级幂等性阻止相同请求创建多个作业。项级幂等性阻止同一行被应用两次。

作业级模式是要求每次导入请求提供幂等键(或从稳定的请求哈希加用户 ID 推导)。把它和 import_job 记录一起存储,并在重试时返回相同的作业 ID。处理器应能说明“我见过这个作业,这是它的当前状态”,而不是“从头开始”。

对于项级去重,依赖数据中已存在的自然键。例如,每行可能包含来自源系统的 external_id,或像 (account_id, email) 这样的稳定组合。在 PostgreSQL 中用唯一约束强制,并使用 upsert 行为使重试不会创建重复。

在发布前决定重放时遇到已存在行的行为:明确选择跳过、更新特定字段或失败。除非有非常明确的规则,避免使用“合并”。

部分成功是正常的。不要只返回一个大的“ok”或“failed”,而是为每行存储结果:行号、自然键、状态(created、updated、skipped、error)和错误信息。在重试时,能安全重跑,同时保留已完成行的结果。

为了使导入可重启,添加检查点。分页处理(比如每页 500 行),在每页提交后存储最后处理的游标(行索引或源游标)。如果进程崩溃,下次尝试从最后检查点恢复。

Webhook 接收:先去重、验证,再安全处理

为去重建模
在数据设计器中创建 PostgreSQL 去重表并强制唯一键。
建模数据

Webhook 的发送方会重试,并且常常乱序发送。如果你的处理器对每次投递都更新状态,最终会出现重复创建记录、重复发送邮件或重复扣款。

先选择最佳去重键。如果提供方给出唯一事件 ID,就使用它。只有在没有事件 ID 时才退而求其次用 payload 哈希。

安全优先:在接受任何内容前先验证签名。如果签名失败,拒绝请求且不要写入去重记录。否则攻击者可能“占用”一个事件 ID 并阻止真实事件到达。

在重试下的安全流程:

  • 验证签名和基本格式(必要头、事件 ID)。
  • 在去重表中插入事件 ID 并强制唯一约束。
  • 如果插入因重复失败,立即返回 200。
  • 在有审计和调试价值时存储原始负载(和头信息)。
  • 入队处理并快速返回 200。

快速确认很重要,因为许多提供方有较短的超时。在请求中做最小且可靠的工作:验证、去重、持久化。然后异步处理(worker、队列、后台任务)。如果不能异步,确保处理本身按事件 ID 键控副作用以保持幂等。

乱序投递是常态。不要假设“created”先于“updated”。优先对外部对象 ID 做 upsert,并跟踪最后处理的事件时间戳或版本。

存储原始负载在客户说“我们没有收到更新”时很有用。你可以在修复 bug 后从存储的 body 重新运行处理,而无需请求提供方重发。

并发:在并行请求下保持正确性

当两条带相同幂等键的请求同时到达时,重试会变得复杂。如果两个处理器在任一方保存结果之前都运行了“执行工作”步骤,仍然可能出现重复扣款、重复导入或重复入队。

最简单的协调点是数据库事务。把“声明键”作为第一步,让数据库决定谁赢。常见选项:

  • 在去重表中做唯一插入(数据库决定一个胜者)
  • 创建(或查到)去重行后使用 SELECT ... FOR UPDATE
  • 按幂等键的哈希使用事务级咨询锁(advisory locks)
  • 在业务记录上使用唯一约束作为最终保障

对于长时间运行的工作,避免在调用外部系统或运行数分钟导入时持有行锁。相反,在去重行中存一个小的状态机,让其他请求能快速退出。

一个实用状态集合:

  • in_progress 并有 started_at
  • completed 并缓存响应
  • failed 并包含错误码(可选,取决于重试策略)
  • expires_at(用于清理)

示例:两个应用实例收到相同的支付请求。实例 A 插入键并将状态标为 in_progress,然后调用提供方。实例 B 命中冲突路径,读取去重行,看到 in_progress,并返回一个“仍在处理”的快速响应(或短等后重试)。当 A 完成后,它将行更新为 completed 并存储响应体,以便后续重试能得到完全相同的输出。

常见会破坏幂等性的错误

更快构建支持重试的 API
用明确的幂等规则和可预测的重放设计支持重试的端点。
尝试 AppMaster

大多数幂等性漏洞不是关于复杂锁,而是“几乎正确”的选择在重试、超时或两个用户执行相似操作时失败。

一个常见陷阱是把幂等键当作全局唯一。如果不按用户、账户或端点做作用域,两不同客户端会发生碰撞,其中一个会得到另一个的结果。

另一个问题是接受相同键但不同请求体的情况。如果第一次调用是 $10,再次调用是 $100,你不应该悄悄返回第一次的结果。存储请求哈希(或关键字段),在重放时比较,如果不一致返回明确的冲突错误。

当重放返回不同的响应结构或状态码时,客户端也会困惑。如果第一次返回 201 并带 JSON,重放应返回相同的 body 和一致的状态码。改变重放行为会迫使客户端去猜测。

经常造成重复的错误:

  • 只依赖内存映射或缓存,重启后丢失去重状态。
  • 使用无作用域的键(跨用户或跨端点碰撞)。
  • 不验证相同键但负载不一致的情况。
  • 先执行副作用(扣款、插入、发布),再写去重记录。
  • 每次重试返回新的生成 ID 而不是重放原始结果。

缓存可以加速读取,但事实来源应是持久化的(通常是 PostgreSQL)。否则部署后重试可能会产生重复。

同时也要规划清理。如果你把每个键永远保留,表会不断增长,索引也会变慢。根据真实重试行为设定保留窗口,删除旧行,并保持唯一索引规模小。

快速清单与后续步骤

把幂等性当成 API 合约的一部分。每个可能被客户端、队列或网关重试的端点都需要清晰规则:什么是“相同请求”,以及“相同结果”是什么样。

发布前的清单:

  • 对每个可重试端点,是否已定义幂等作用域(按用户、账户、订单或外部事件)并形成文档?
  • 是否由数据库强制去重(在幂等键和作用域上做唯一约束),而不仅仅是代码检查?
  • 重放时是否返回相同的状态码和响应体(或文档化的稳定子集),而不是新的对象或时间戳?
  • 对于支付,是否能安全处理未知结果(提交后超时、网关返回“处理中”)而不重复扣款?
  • 日志和指标是否能清楚表明请求是首次见到还是被重放?

如果任何项是“也许”,现在就修复。大多数故障会在压力下暴露:并行重试、网络缓慢和局部故障。

如果你在 AppMaster 平台上构建内部工具或面向客户的应用,尽早设计幂等键和 PostgreSQL 去重表会很有帮助。这样即便平台在需求变化时重新生成 Go 后端代码,你的重试行为也能保持一致。

常见问题

为什么即使我的 API 看起来正确,重试仍然会导致重复收费或重复记录?

重试是常见现象,因为网络和客户端会在日常使用中失败。一次请求可能已经在服务器端成功执行,但响应未到达客户端,客户端随后重试,除非服务器能识别并重放原始结果,否则就会重复执行相同操作。

我应该使用什么作为幂等键,谁来生成它?

在同一次操作的每次重试中发送相同的键。建议由客户端生成一个随机且不可预测的字符串(例如 UUID),并且不要将该键用于不同的操作。

我应该如何为幂等键设定作用域以避免跨用户或租户的冲突?

将键的作用域设定为符合业务规则,通常是按端点加上调用方身份(比如用户、账户、租户或 API 令牌)。这样可以防止不同客户之间因键冲突而互相获取结果。

当收到相同键的重复请求时,我的 API 应该返回什么?

返回与第一次成功尝试相同的结果。实际做法是重放相同的 HTTP 状态码和响应体,或者至少返回相同的资源 ID 和状态,这样客户端可以安全重试而不会产生第二次副作用。

如果客户端不小心将相同的幂等键用于不同的请求体,我该如何处理?

应以明确的冲突类错误拒绝这种请求,而不是猜测。保存并比较重要请求字段的哈希,如果键匹配但负载不同,则快速返回错误以避免在同一键下混合两次不同的操作。

我应该在数据库中保留幂等键多长时间?

保留键足够长以覆盖现实的重试场景,然后删除它们。一个常见的默认值是:支付保留 24–72 小时,导入保留一周,而 Webhook 则应与发送方的重试策略相匹配以便迟到的重试仍能去重。

在 PostgreSQL 中实现幂等性的最简单模式是什么?

专门的去重表很有效,因为数据库可以强制唯一约束并在重启后仍然存在。存储所有者作用域、键、请求哈希、状态和可重放的响应,然后对 (owner, key) 建唯一约束,让只有一个请求“赢得”执行权。

当两次相同的请求同时到达时,我该如何处理?

先在数据库事务内声明该键的所有权,然后只有成功声明时才执行副作用。并行到达的另一个请求会触发唯一约束,看到 in_progresscompleted 后应返回等待/重放响应,而不是再次运行业务逻辑。

当支付网关超时时如何避免重复收费?

将超时视为“未知”而不是“失败”。记录为 pending 状态,如果能拿到提供方的 ID,就以该 ID 作为事实来源,这样重试会返回相同的支付结果而不是创建新的收费。

如何让导入在不让用户重头开始或产生重复记录的情况下支持重试?

在两个层面去重:作业层和项层。重试应返回相同的导入作业 ID,并为每行数据使用自然键(如外部 ID 或 (account_id, email))并强制唯一约束或使用 upsert,这样重新处理不会创建重复记录。

容易上手
创造一些 惊人的东西

使用免费计划试用 AppMaster。
准备就绪后,您可以选择合适的订阅。

开始吧