Featured image of post 别让重试变成重复扣款:用幂等性设计可靠 API

别让重试变成重复扣款:用幂等性设计可靠 API

网络超时、重复点击和消息重投都会让同一个请求被执行多次。本文介绍 API 幂等性的设计原则、数据库实现方式、并发处理和外部副作用注意事项。

别让重试变成重复扣款:用幂等性设计可靠 API

调用一个接口,客户端没有及时收到响应。此时它并不知道服务端有没有处理成功:

  • 请求可能还没到服务器;
  • 服务器可能已经完成操作,只是响应在网络中丢失;
  • 服务器也可能仍在处理中。

如果客户端直接重试,服务器就可能再次创建订单、重复扣款或发送两封通知邮件。

这不是少见的异常情况。网络会抖动,用户会重复点击,消息队列也会重新投递。只要系统需要重试,就应该认真考虑幂等性。

什么是幂等性?

幂等性描述的是:对同一个操作执行一次或多次,最终产生的业务结果相同。

例如,把某个订单状态设置为“已取消”,重复执行两次,订单最终仍然是“已取消”。这是幂等操作。

相反,每执行一次就新增一条记录的操作通常不是幂等的:

1
POST /orders

如果客户端因为超时重试了两次,服务端可能创建两张订单。

幂等性的目标不是让请求只到达服务器一次。网络层无法可靠保证这一点。它要解决的是:即使同一个业务请求到达多次,服务端也只产生一次预期的业务效果。

为什么不能只靠客户端避免重复?

前端可以在点击按钮后禁用按钮,但这只能减少一种重复请求来源。

请求还可能因为以下原因重复:

  • 用户连续点击,浏览器发出了多个请求;
  • 客户端超时后自动重试;
  • 代理或网关重新发送请求;
  • 消息队列在消费者处理失败后重新投递;
  • 用户刷新页面后再次提交;
  • 服务端完成了操作,但响应没有成功返回客户端。

因此,去重必须由服务端负责。客户端可以配合提供一个稳定的请求标识,但不能单独承担正确性保障。

用 Idempotency-Key 标识一次业务意图

常见做法是让客户端为一次业务操作生成唯一键,并在重试时重复使用:

1
2
3
4
5
6
7
POST /api/orders
Idempotency-Key: 7f6c2a3e-...

{
  "productId": "p-1024",
  "quantity": 2
}

关键点是:同一次操作的所有重试都使用同一个键。用户开始一笔新的购买时,才生成新的键。

服务端可以按“用户 + 接口操作 + 幂等键”识别请求:

1
用户 42 + 创建订单 + 7f6c2a3e-...

这样不同用户可以使用相同的随机字符串,也不会互相影响;同一用户在不同接口中的键也不会冲突。

服务端处理流程

一个实用的处理流程可以分为四步:

  1. 检查这个键是否已经处理过。
  2. 如果已完成,返回之前保存的结果。
  3. 如果没有处理过,原子地登记这个键并执行操作。
  4. 保存业务结果和响应信息,供后续重试读取。

数据库可以保存这些信息:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
CREATE TABLE idempotency_records (
    user_id          BIGINT NOT NULL,
    operation        VARCHAR(100) NOT NULL,
    idempotency_key  VARCHAR(200) NOT NULL,
    request_hash     VARCHAR(64) NOT NULL,
    status           VARCHAR(20) NOT NULL,
    response_code    INTEGER,
    response_body    TEXT,
    created_at       TIMESTAMP NOT NULL,
    expires_at       TIMESTAMP NOT NULL,
    PRIMARY KEY (user_id, operation, idempotency_key)
);

唯一约束很重要。不能只采用“先查询、再插入”的应用层逻辑,因为两个并发请求可能同时查到记录不存在,然后各自继续创建订单。由数据库唯一键处理竞争,才能确保只有一个请求成功取得处理权。

如果核心业务和幂等记录都在同一个数据库里,尽量放进同一事务:

1
2
3
4
5
6
7
BEGIN

插入幂等记录
创建订单
保存响应内容

COMMIT

事务提交成功后,后续相同请求就可以读取并返回已保存的结果;事务失败时,订单和幂等记录一起回滚。实际实现还需要处理唯一键冲突:冲突方应读取已提交的记录,而不是再次执行业务。

重复请求应该返回什么?

如果请求已经成功处理,最简单的做法是返回第一次处理时保存的 HTTP 状态码和响应内容。

例如第一次请求创建了订单:

1
2
3
4
{
  "orderId": "ord-8a31",
  "status": "created"
}

后续使用相同幂等键重试时,返回同一个订单编号,而不是再创建一张新订单。

还要检查请求内容是否一致。假设客户端第一次用某个键请求购买 2 件商品,之后却用同一个键提交 5 件商品,服务端不应该悄悄返回第一次的结果,也不应该把它当作一笔新操作。可以对规范化后的请求内容计算哈希,并与首次记录比较;内容不同就返回冲突错误,要求客户端为新的业务意图生成新键。

并发请求和处理中状态

两个请求可能几乎同时携带相同的幂等键到达。

一个请求取得处理权后,另一个请求发现该键已经存在。此时通常有两种策略:

  • 如果第一个请求已完成,直接返回已保存的响应;
  • 如果第一个请求仍在处理中,等待一小段时间后读取结果,或返回“处理中”,让客户端稍后重试。

不要让第二个请求绕过记录继续执行业务。否则并发场景下,幂等机制就失去了意义。

处理中状态还需要配套超时和恢复策略。例如服务进程在写入处理中记录后崩溃,系统需要判断这条记录是否可以安全接管。处理方式取决于业务操作是否已经发生,因此不应只根据时间经过就盲目重新执行。

外部副作用需要单独设计

数据库事务只能保护数据库中的操作,无法把支付网关、邮件服务或第三方 API 自动纳入同一个事务。

例如:

1
2
3
创建本地订单
调用支付服务扣款
保存支付结果

如果扣款成功后服务进程崩溃,本地系统可能没有保存成功结果。重试时再次调用支付服务,就可能重复扣款。

常见处理方式包括:

  • 将同一个业务幂等键传给支持幂等的支付服务;
  • 在本地事务中写入待发送事件,再由后台任务可靠投递;
  • 为外部操作保存独立状态,并通过查询或对账确认结果;
  • 为撤销、退款等补偿操作定义明确的状态流转。

幂等键不等于“整个分布式流程只执行一次”。它能帮助重复请求复用结果,但跨数据库、队列和第三方服务的副作用仍需要单独处理。

幂等键要保存多久?

幂等记录不能无限增长,通常会设置有效期并定期清理。但保留时间必须覆盖客户端可能重试的窗口。

如果记录过早删除,迟到的重试就会被当作全新请求,导致重复创建订单。支付、订单等高价值操作,通常需要比普通查询或临时任务保留更久,也可能需要把业务订单本身作为长期去重依据。

常见 HTTP 方法不代表业务一定安全

HTTP 语义中,PUT 和 DELETE 通常被定义为幂等方法;POST 通常用于创建资源或执行操作。但实际系统仍要看服务端实现。

例如:

1
PUT /users/42

如果它只是把用户资料设置为给定内容,重复执行通常会得到相同结果。但若每次执行都会额外发送邮件、写入审计副作用或累计积分,就不能只凭方法名称断定整个业务流程没有重复效果。

一个实用检查清单

为关键写入接口设计幂等性时,可以逐项确认:

  • 客户端是否会为一次业务意图生成稳定的幂等键?
  • 服务端是否按用户和操作范围隔离这个键?
  • 数据库是否通过唯一约束处理并发竞争?
  • 服务端是否保存第一次请求的结果并在重试时复用?
  • 同一个键提交不同内容时,是否返回明确错误?
  • 处理中断后,是否有安全的恢复办法?
  • 第三方副作用是否使用了自己的幂等键或对账流程?
  • 幂等记录的保留时间是否覆盖实际重试窗口?

结语

重试是提高系统可用性的常见手段,但没有幂等设计,重试也会把暂时的网络问题放大成重复订单、重复扣款和重复通知。

可靠的做法是让客户端用稳定的键表达“这是同一次业务意图”,由服务端通过数据库约束、结果保存和明确的处理中状态控制重复请求,再为外部副作用补上独立的可靠机制。

下一次设计创建订单、支付、提交申请或消费消息的接口时,可以先问一句:如果这个请求被执行两次,业务结果会怎样?这个问题往往能提前暴露最重要的可靠性缺口。