支付系统对接外部服务商(PSP)时,网络延迟导致的超时失败,是数据不一致的常见来源。翻交易日志会发现一个反复出现的超时后竞态:客户端发起支付请求,API 服务器向外发出 HTTP 请求,外部 PSP 在自己的账本上已经成功执行了交易,但响应回传途中出现瞬时断网或丢包,API 服务器于是触发网关超时(如 504 Gateway Timeout 或 socket 读超时)。
![]()
客户端应用,或者上游带重试机制的指数退避 worker,会把它当成未处理失败,立刻用完全相同的参数再发一次请求。此时第一次请求可能还在途中,也可能已在后台完成,服务器收到重试后又起一个线程或进程,把同一笔支付再处理一遍。
要防住重复扣款,光在事后拒绝相同交易不够。架构上需要一个原子的、在途状态检测机制,在并发或重复请求进来的瞬间就拦截并限流。
两道防线:请求身份与并发控制
跨分布式节点防重复执行,设计压在两根柱子上。第一根是严格的请求身份,也就是幂等键:不只依赖客户端传来的 transaction_id,而是对规范化后的请求载荷计算 SHA-256 摘要,再与交易标识拼接。这样能防篡改、防载荷不匹配,也防在不同变更之间被无意复用。
第二根是两层并发控制。用 Redis 的 SETNX 加有界 TTL 做内存级高吞吐分布式锁,在边缘吸收流量尖峰、挡掉冗余重试;后面再由持久化层(RDB)兜底,用数据库级 UNIQUE 约束守住账本完整性的最后一道关。
请求进来后的顺序是:先做高速反序列化和 Pydantic 校验,生成幂等键(交易 ID 加 SHA-256 摘要),再向 Redis 申请锁。拿到锁的初始请求继续调用外部 PSP,返回 200 后释放锁;锁冲突的重复或并发重试则直接收到 409 Conflict,被判定为重复请求。
TTL 怎么定,决定锁是救命还是添乱
分布式锁的租约时长要平衡两个相反的风险。TTL 太短:上游 PSP 处理延迟拉长,锁在线程还在等响应时就过期,并发重试拿到新锁,直接导致重复扣款。TTL 太长:处理节点在释放互斥锁前非正常崩溃,后续合法的客户端重试会被锁在死锁状态里,直到 TTL 走完。
实践中的算法是:锁 TTL 等于外部 HTTP 客户端超时,加上时钟漂移余量,再加宽限期。下游 PSP 读超时配置为 15 秒的支付集成,分布式锁 TTL 一般落在 20 到 30 秒之间。
Redis 在这里的角色是低延迟的内存过滤器,在重试风暴打满数据库连接池之前把它们吸收掉。但内存型分布式系统会遭遇故障切换、异步复制延迟,以及集群拓扑变动时的潜在数据丢失。所以绝对一致性要求持久存储层(如 PostgreSQL 或 MySQL)对 transaction_id 或幂等键建立严格的 UNIQUE 索引约束。一旦 Redis 出现意外的脑裂,数据库事务会以完整性违规错误干净地拒绝这次并发执行,保住整条基础设施上的财务账本正确性。
示例代码把这条链路串了起来:orjson 优先、标准 json 兜底的高速序列化,Pydantic v2 的严格模式校验,以及一个模拟 Redis 原子语义(SETNX 加 TTL 过期)的内存幂等协调器。首次请求返回成功,紧接着的重复请求返回冲突,交易不会被二次执行。
特别声明:以上内容(如有图片或视频亦包括在内)为自媒体平台“网易号”用户上传并发布,本平台仅提供信息存储服务。
Notice: The content above (including the pictures and videos if any) is uploaded and posted by a user of NetEase Hao, which is a social media platform and only provides information storage services.