重试不是一条新命令
一句话总结
即使客户端因未收到响应而再次发送同一请求,服务器也必须将其识别为同一个命令。结合使用事务与组织范围内的幂等键,可以确保订单只创建一次,而重试会返回最初创建的资源。
为什么超时会产生重复订单
如果服务器刚提交事务网络就中断,客户端并不知道操作已经成功。此时无条件重试便会创建第二个订单。在分布式系统中,‘请求只发送一次’并不是一个可执行的解决方案。客户端应发送 Idempotency-Key,服务器则将它与组织信息一起保存。首次写入与幂等键记录必须处于同一个事务中,不能出现其中一项成功、另一项失败的状态。
PostgreSQL 的 INSERT ... ON CONFLICT (org_id, idempotency_key) 会在数据库的串行化点合并相互竞争的请求。先查询、后插入的代码存在竞态条件:两个请求可能同时看到‘不存在’,然后都执行插入。发生冲突时,也不能用新请求的金额覆盖已有订单。服务器应返回原订单,使同一命令的结果保持稳定。
如何处理生产环境中的失败
通过上下文管理器关闭驱动连接与游标,并将写操作放在显式 transaction 边界内。发生异常时要保证 rollback,同时不要把 SQL 参数或连接字符串写入异常消息。集成测试应使用同一个键、不同金额连续发送两次请求,确认首次响应为 201、重试响应为 200,并且二者的 ID 与原始金额一致。不同组织则应能够彼此独立地使用相同的键。
让重试安全的三道机制
在分布式系统中无法避免重试。真正的问题是,发起调用的一方无法判断是否可以重试。没有收到响应,并不等于请求没有被处理。
幂等键由请求方生成。 如果由服务器生成,每次重试都会得到不同的值,也就失去了意义。客户端生成一个 UUID,从第一次尝试到最后一次重试始终发送同一个值。
create table payment_requests(
org_id bigint not null,
idem_key uuid not null,
request_sha bytea not null,
status text not null check(status in ('처리중','완료','실패')),
response jsonb,
created_at timestamptz not null default now(),
primary key (org_id, idem_key));
同一个键对应不同内容时必须拒绝。 这正是同时保存 request_sha 的原因。键相同而金额不同,说明这不是重试,而是程序错误;悄悄将其当作成功处理是最糟糕的选择。
先占住记录,再执行工作。 如果 insert ... on conflict do nothing returning id 返回空值,说明已有请求开始处理。此时应查看该行状态:如果已完成,就原样返回保存的响应;如果仍在处理中,则返回 409,让客户端稍后再查询。若让请求一直在服务器端等待,连接会不断堆积,引发新的问题。
不要把外部调用与数据库事务捆成一个整体。 调用支付网关期间若一直保持事务开启,锁与连接就会被这个缓慢的调用长期占用。应拆分顺序:先用短事务记录意图,在事务外调用外部系统,再用另一个短事务写入结果。如果进程在中途终止,数据库会留下只有意图的记录,因此还要配套一个恢复任务,找出这些记录并向对方查询状态。
重试必须设置上限并加入抖动。 如果所有客户端按相同间隔重试,系统刚从故障中恢复便会再次被压垮。应在指数退避中加入随机因素,限制总尝试时间,超过上限后按失败处理并交由人工查看。永远重试的队列只会掩盖故障。
实务判断标准
幂等性是一个比‘重复时返回错误’更强的契约。用户必须能够安全地再次取得结果。实际服务还应明确幂等键的保留期限、请求正文哈希冲突的处理策略,以及过期键的清理方式。本次综合项目先证明最重要的创建边界与冲突语义,下一模块再通过实际的 HTTP 状态码与响应正文契约将其暴露给外部。