提交按钮已经置灰,服务端也加了重复请求拦截,订单为什么还是可能创建两次?
设想一次很普通的超时:请求到达服务端,订单已经提交到数据库,但响应在回程中丢了。页面恢复按钮,用户再点一次;新的请求恰好落到另一台服务器。按钮和第一台服务器上的内存记录,都无法回答第二台服务器最需要知道的事:这笔业务到底做过没有?
下面以虚构的资料订购接口说明这个问题。接口、表结构和操作编号均为教学示例,未连接真实订单或付款。
三层机制分别认得什么
按钮防抖、请求防重和业务幂等经常一起出现,但它们判断“重复”的依据不同。
| 机制 | 通常认得的东西 | 主要用途 |
|---|---|---|
| 前端防抖或提交锁定 | 这个页面正在进行的操作 | 减少连点,给用户明确反馈 |
| 短时请求防重 | 某个范围内相似的请求 | 降低短时间重复执行的概率 |
| 业务幂等 | 同一笔业务操作的稳定身份 | 在重试、并发和恢复时维持确定的业务结果 |
前端可以绕过,页面也会关闭。内存中的请求占位只对持有它的进程有效,重启后还会消失。即使把占位放进共享缓存,也仍需回答锁过期、执行超时和结果丢失时怎么办。
幂等关注的是重复请求产生的预期业务效果。RFC 9110 的定义并不要求每次响应字节完全相同。一个 HTTP 方法具有幂等语义,也不等于任何业务实现天然正确;创建型 POST 接口则需要明确设计自己的重试契约。
给一次操作取一个重试时不变的名字
假设用户确认订购一份资料。客户端为这次确认生成随机操作编号,之后的网络重试沿用它:
POST /demo/orders
Idempotency-Key: demo-operation-a
Content-Type: application/json
{"item":"sample-guide","quantity":1}
示例编号便于阅读,真实实现应使用足够随机、避免碰撞的值。不要把邮箱、姓名或其他敏感资料塞进编号。
关键区别在于“重试原操作”与“发起另一笔相同业务”。用户真的想再买一份相同资料时,需要新的操作编号;只是没有收到上次结果时,应继续核对原编号。两笔合理订单可以拥有完全相同的请求体,因此把请求体摘要直接当作永久业务身份,会拦住正常需求。
另一方面,同一个编号又不能任意搭配新参数。第一次是一本,重试变成两本,服务端应报告冲突,让用户确认意图,不能静默返回旧结果或覆盖旧操作。Stripe 的幂等请求文档提供了这种接口契约的公开实例;具体结果保留和失败重试行为应按所用 API 版本核对。

唯一约束要和业务写入一起工作
演示系统可以建立一张最小操作表:
CREATE TABLE demo_operation (
account_id uuid NOT NULL,
operation_key text NOT NULL,
request_fingerprint text NOT NULL,
result_id uuid NOT NULL,
PRIMARY KEY (account_id, operation_key)
);
这里假设只有一种操作,且资料订购能在本地数据库的短事务内完成。多种操作共用存储时,还需要把业务操作类型纳入范围;账号或租户边界也必须由服务端认证上下文确定。
数据库的组合唯一约束让两台应用服务器也遵守同一个约束。处理过程可以设计为:在一个事务内取得这个操作键的写入资格,创建订单,并写入可查询的结果标识,最后一起提交。并发竞争失败的请求不能继续创建订单,应读取已提交的原记录,核对指纹,再返回或查询对应结果。
实际实现要正确处理唯一冲突、事务回滚与锁等待;例如使用适合数据库的冲突处理语句,而不是捕获异常后继续使用已经失败的事务。长时间未决时,可以返回处理中或让客户端稍后查询,不必无限等待。
这张表没有表达所有可能状态。它专门说明一种较窄的保证:本地事务回滚时,订单和幂等记录一起回滚;提交成功时,两者一起存在。如果操作跨越多个长任务,就需要显式的处理中、成功、可重试失败等状态,以及并发接管规则。
请求指纹负责发现误用
操作键回答“是哪一次操作”,指纹回答“这次操作的内容有没有变”。两者用途不同。
指纹应来自业务含义稳定的参数。JSON 属性顺序不同不应自动成为不同请求;数组顺序、空值与缺省值是否等价,则取决于接口契约。账号范围、币种、目标对象等会改变业务意义的信息不能遗漏。
还要避免把追踪编号、连接对象或时间戳这类每次都会变化的技术参数混进去,否则正常重试也会被判成冲突。敏感值不应直接进入诊断日志,保存摘要也不能替代访问控制。
不能仅凭“字段名称一样”就做全局规范化。有的列表允许重排,有的列表顺序决定执行含义;有的空字符串表示清空,有的缺省字段表示保留原值。这些区别需要先在业务接口中定义。
超时之后先核对原操作
最难处理的通常是结果未知:客户端没收到响应,但服务端可能已经成功。
此时更稳妥的页面行为是保留操作编号,显示“结果待确认”,并提供查询原结果的入口。重试仍使用原编号和原参数。生成新编号再试一次,会把一个网络故障变成第二笔合法业务。
若涉及外部支付、发信或其他不可回滚副作用,本地事务无法把远端操作一起包住。需要在本地保存待执行意图,通过 Outbox 等机制可靠触发,并在远端也使用可核对的业务身份。远端是否支持幂等、回调如何去重、结果如何对账,都要单独设计。
操作记录的保留期限同样属于契约。记录删掉后,旧编号能否重新执行必须说清楚;需要长期唯一的业务,应另有稳定业务键或约束。不能把一段短期缓存的有效期当成永久保证。
验证时主动制造那几次“不巧”
只做一次双击测试,最多能检查页面操作和短时并发。要判断业务幂等是否成立,应把故障放到关键位置:
| 场景 | 应核对什么 |
|---|---|
| 同一编号同时落到两个节点 | 只产生一笔业务,另一请求取得明确状态 |
| 数据库提交成功,响应丢失 | 用原编号能查到原结果 |
| 业务事务回滚后重试 | 没有残留的假成功记录 |
| 同一编号搭配不同参数 | 明确拒绝冲突,原结果不变 |
| 不同编号提交相同内容 | 按业务规则允许或拒绝,不被误当网络重试 |
| 服务重启后查询原操作 | 关键事实仍能从持久存储恢复 |
这张表可以作为实现时的验收起点。真正接入时,还需覆盖权限、记录过期和外部副作用的恢复路径,并把预期结果与实际观察分别记下来。
按钮置灰让人知道请求正在进行;短时防重帮助系统减少重复负担;稳定的操作身份与持久结果则让超时后的重试有据可查。把这三个职责分别做好,用户才不必靠猜测决定要不要再点一次。
本文的回复通知
只在有人回复你发表于本文的评论时通知你。
登录后设置。