别让重试变成重复扣款:用幂等性设计可靠 API
调用一个接口,客户端没有及时收到响应。此时它并不知道服务端有没有处理成功:
- 请求可能还没到服务器;
- 服务器可能已经完成操作,只是响应在网络中丢失;
- 服务器也可能仍在处理中。
如果客户端直接重试,服务器就可能再次创建订单、重复扣款或发送两封通知邮件。
这不是少见的异常情况。网络会抖动,用户会重复点击,消息队列也会重新投递。只要系统需要重试,就应该认真考虑幂等性。
什么是幂等性?
幂等性描述的是:对同一个操作执行一次或多次,最终产生的业务结果相同。
例如,把某个订单状态设置为“已取消”,重复执行两次,订单最终仍然是“已取消”。这是幂等操作。
相反,每执行一次就新增一条记录的操作通常不是幂等的:
| |
如果客户端因为超时重试了两次,服务端可能创建两张订单。
幂等性的目标不是让请求只到达服务器一次。网络层无法可靠保证这一点。它要解决的是:即使同一个业务请求到达多次,服务端也只产生一次预期的业务效果。
为什么不能只靠客户端避免重复?
前端可以在点击按钮后禁用按钮,但这只能减少一种重复请求来源。
请求还可能因为以下原因重复:
- 用户连续点击,浏览器发出了多个请求;
- 客户端超时后自动重试;
- 代理或网关重新发送请求;
- 消息队列在消费者处理失败后重新投递;
- 用户刷新页面后再次提交;
- 服务端完成了操作,但响应没有成功返回客户端。
因此,去重必须由服务端负责。客户端可以配合提供一个稳定的请求标识,但不能单独承担正确性保障。
用 Idempotency-Key 标识一次业务意图
常见做法是让客户端为一次业务操作生成唯一键,并在重试时重复使用:
| |
关键点是:同一次操作的所有重试都使用同一个键。用户开始一笔新的购买时,才生成新的键。
服务端可以按“用户 + 接口操作 + 幂等键”识别请求:
| |
这样不同用户可以使用相同的随机字符串,也不会互相影响;同一用户在不同接口中的键也不会冲突。
服务端处理流程
一个实用的处理流程可以分为四步:
- 检查这个键是否已经处理过。
- 如果已完成,返回之前保存的结果。
- 如果没有处理过,原子地登记这个键并执行操作。
- 保存业务结果和响应信息,供后续重试读取。
数据库可以保存这些信息:
| |
唯一约束很重要。不能只采用“先查询、再插入”的应用层逻辑,因为两个并发请求可能同时查到记录不存在,然后各自继续创建订单。由数据库唯一键处理竞争,才能确保只有一个请求成功取得处理权。
如果核心业务和幂等记录都在同一个数据库里,尽量放进同一事务:
| |
事务提交成功后,后续相同请求就可以读取并返回已保存的结果;事务失败时,订单和幂等记录一起回滚。实际实现还需要处理唯一键冲突:冲突方应读取已提交的记录,而不是再次执行业务。
重复请求应该返回什么?
如果请求已经成功处理,最简单的做法是返回第一次处理时保存的 HTTP 状态码和响应内容。
例如第一次请求创建了订单:
| |
后续使用相同幂等键重试时,返回同一个订单编号,而不是再创建一张新订单。
还要检查请求内容是否一致。假设客户端第一次用某个键请求购买 2 件商品,之后却用同一个键提交 5 件商品,服务端不应该悄悄返回第一次的结果,也不应该把它当作一笔新操作。可以对规范化后的请求内容计算哈希,并与首次记录比较;内容不同就返回冲突错误,要求客户端为新的业务意图生成新键。
并发请求和处理中状态
两个请求可能几乎同时携带相同的幂等键到达。
一个请求取得处理权后,另一个请求发现该键已经存在。此时通常有两种策略:
- 如果第一个请求已完成,直接返回已保存的响应;
- 如果第一个请求仍在处理中,等待一小段时间后读取结果,或返回“处理中”,让客户端稍后重试。
不要让第二个请求绕过记录继续执行业务。否则并发场景下,幂等机制就失去了意义。
处理中状态还需要配套超时和恢复策略。例如服务进程在写入处理中记录后崩溃,系统需要判断这条记录是否可以安全接管。处理方式取决于业务操作是否已经发生,因此不应只根据时间经过就盲目重新执行。
外部副作用需要单独设计
数据库事务只能保护数据库中的操作,无法把支付网关、邮件服务或第三方 API 自动纳入同一个事务。
例如:
| |
如果扣款成功后服务进程崩溃,本地系统可能没有保存成功结果。重试时再次调用支付服务,就可能重复扣款。
常见处理方式包括:
- 将同一个业务幂等键传给支持幂等的支付服务;
- 在本地事务中写入待发送事件,再由后台任务可靠投递;
- 为外部操作保存独立状态,并通过查询或对账确认结果;
- 为撤销、退款等补偿操作定义明确的状态流转。
幂等键不等于“整个分布式流程只执行一次”。它能帮助重复请求复用结果,但跨数据库、队列和第三方服务的副作用仍需要单独处理。
幂等键要保存多久?
幂等记录不能无限增长,通常会设置有效期并定期清理。但保留时间必须覆盖客户端可能重试的窗口。
如果记录过早删除,迟到的重试就会被当作全新请求,导致重复创建订单。支付、订单等高价值操作,通常需要比普通查询或临时任务保留更久,也可能需要把业务订单本身作为长期去重依据。
常见 HTTP 方法不代表业务一定安全
HTTP 语义中,PUT 和 DELETE 通常被定义为幂等方法;POST 通常用于创建资源或执行操作。但实际系统仍要看服务端实现。
例如:
| |
如果它只是把用户资料设置为给定内容,重复执行通常会得到相同结果。但若每次执行都会额外发送邮件、写入审计副作用或累计积分,就不能只凭方法名称断定整个业务流程没有重复效果。
一个实用检查清单
为关键写入接口设计幂等性时,可以逐项确认:
- 客户端是否会为一次业务意图生成稳定的幂等键?
- 服务端是否按用户和操作范围隔离这个键?
- 数据库是否通过唯一约束处理并发竞争?
- 服务端是否保存第一次请求的结果并在重试时复用?
- 同一个键提交不同内容时,是否返回明确错误?
- 处理中断后,是否有安全的恢复办法?
- 第三方副作用是否使用了自己的幂等键或对账流程?
- 幂等记录的保留时间是否覆盖实际重试窗口?
结语
重试是提高系统可用性的常见手段,但没有幂等设计,重试也会把暂时的网络问题放大成重复订单、重复扣款和重复通知。
可靠的做法是让客户端用稳定的键表达“这是同一次业务意图”,由服务端通过数据库约束、结果保存和明确的处理中状态控制重复请求,再为外部副作用补上独立的可靠机制。
下一次设计创建订单、支付、提交申请或消费消息的接口时,可以先问一句:如果这个请求被执行两次,业务结果会怎样?这个问题往往能提前暴露最重要的可靠性缺口。
