按钮已经禁用了,为什么还是生成了两笔订单?
用户点击“提交订单”后,按钮立即变灰,页面显示“正在提交”。从前端看,重复点击已经被挡住了。
但过了一段时间,后台还是出现了两笔内容相同的订单。
问题可能来自网络重试,也可能来自用户刷新页面后再次提交。还有一种常见情况:服务器已经创建了订单,返回结果时网络却断了。用户看到“提交失败”,于是重新操作了一次。
按钮的禁用状态只能约束当前页面里的点击行为。要保证一笔操作只产生一次业务效果,还需要后端提供幂等保护。
“幂等”这个词听起来有些抽象,放在这个场景里很好理解:同一次下单操作,无论请求送达一次还是多次,最终都只创建一笔订单。
关键在于,服务器如何知道两次请求属于同一次操作。
可以在提交订单时携带一个请求标识,例如:
POST /api/orders
Idempotency-Key: 5fb2da12-0e93-40d8-b25d-6d9961c792fc
这个标识由客户端在一次下单操作开始时生成。如果遇到超时,需要重试,就继续使用原来的标识。用户明确开始另一笔订单时,再生成新的标识。
如果每次发送请求都重新生成标识,服务器看到的永远是“新操作”,保护也就失去了作用。
接下来,后端需要可靠地保存并识别这个标识。
一个容易想到的实现是:先查数据库,确认标识不存在,再创建订单。
const existingOrder = await findOrder(userId, requestKey);
if (existingOrder) {
return existingOrder;
}
return createOrder(userId, requestKey, items);
这段代码在顺序执行时通常没问题,遇到并发就可能出错:
请求 A:查询,没有找到订单
请求 B:查询,也没有找到订单
请求 A:创建订单
请求 B:创建订单
两个请求都通过了检查,最后仍然创建了两笔订单。
因此,“先查一下”只能作为提前返回的优化,真正的约束应该放在数据库里。例如,在 PostgreSQL 中,可以让用户编号和请求标识的组合保持唯一:
CREATE TABLE orders (
id BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
user_id BIGINT NOT NULL,
request_key UUID NOT NULL,
payload_hash TEXT NOT NULL,
status TEXT NOT NULL DEFAULT 'created',
UNIQUE (user_id, request_key)
);
这样,同一用户使用同一个请求标识时,数据库最多允许保存一条订单记录。即使两个请求同时到达,也无法绕过这条约束。
应用层可以尝试插入;如果发现对应的唯一约束发生冲突,就读取已经存在的订单,返回它的编号和状态。使用 PostgreSQL 时,也可以用指定冲突目标的 ON CONFLICT 处理重复插入,避免把其他数据库错误误判成重复请求。
这里还有一个必须处理的细节:同一个标识,不应该对应两份不同的下单内容。
假设第一次请求购买一件商品,第二次却换成了三件商品,但仍然使用相同标识。如果后端直接返回旧订单,用户可能完全不知道新提交的内容没有生效。
因此,可以把影响下单结果的关键字段整理成稳定格式,再计算摘要,保存到 payload_hash 中。收到重复标识时,先比较摘要:
- 内容相同,返回已有订单。
- 内容不同,返回明确错误,提示这次操作的标识已被使用。
不建议直接对原始 JSON 文本计算摘要,因为字段顺序、空白等差异也可能让相同内容得到不同结果。
订单之外的操作也需要一起考虑。
如果创建订单时还要扣减库存,应让订单写入和库存变更处于同一个数据库事务中。重复请求没有成功创建订单,就不应继续扣库存。
如果还要调用支付服务、发送消息,数据库事务就无法直接覆盖这些外部操作。此时需要继续向下传递稳定的业务标识,并使用对方提供的幂等能力;异步消息也需要在消费端防止重复处理。
验证这套逻辑时,除了连续点击,还应该测试两个场景:同时发送多个相同标识的请求,以及订单创建成功后模拟响应丢失,再用相同标识重试。预期都应该是只产生一笔订单,并且不会重复扣减库存。
前端的加载提示负责让用户理解当前进度,后端的幂等约束负责处理请求重复送达。把这两件事都做好,用户遇到网络波动时,才可以放心地点击“重试”。
- 点赞
- 收藏
- 关注作者
评论(0)