有一次我在某个商城系统做线上巡检翻后台账单流水时看到一笔订单居然出现了两条“支付成功”的流水金额相同渠道相同时间只差了几秒。财务拉着技术开会第一句话就是这单到底怎么被付了两遍这个问题的本质不止是“用户手抖点了两下”。重复支付的触发链路可能藏在页面交互、网络超时、服务端并发、支付渠道回调等多个环节。要真正防住不是在前端按钮上加个loading那么简单的而是要沿着一条“订单从生成为支付成功”的完整路径把每一道可能放行的闸口都找到再把每一层的去重能力补齐。这篇文章我会从实际故障出发把防重复支付这件事拆成六个层面来讲前置的交互控制、服务端的硬约束、并发场景的兜底、异步回调的幂等以及最后对账的防线基本覆盖我现在做订单类系统时所有会考虑的检查项。1. 重复支付是怎么发生的从用户狂点按钮到回调乱序在我处理过的重复支付案例里绝大多数都可以归入三类场景。先弄清楚“钱是怎么被扣两遍的”再谈防重才有意义。1.1 用户侧手滑、狂点与页面刷新第一类也是最容易理解的一类来自用户操作。用户在收银台点击“去支付”之后页面没有任何反馈或者加载转圈时间太长用户会下意识地再点一次。有些用户甚至会在几秒内连点五六下。如果每一次点击都会向后端发起一次“创建支付单”的请求那么同一笔订单就会生成多个待支付流水。还有用户在支付过程中觉得卡顿直接刷新页面又重新点了一遍提交。这种情况在网页端特别常见。又或者用户在手机和电脑上同时登录了账号两边同时对同一个订单发起支付操作。每一端都会创建一个支付单而每一张支付单在支付平台都是独立的待支付订单。这一类场景有个共同点看起来是“用户的操作”实际暴露的是系统没有对“同一业务实体的重复请求”做归一化处理。用户多点几下无所谓但系统不能因为用户多点了就真去建多个单。1.2 网络与服务端超时重试引发的连环请求第二类场景比用户手滑隐蔽得多。客户端在发起支付请求后可能遇到网络抖动网关层在超时时会做自动重发。更麻烦的是服务端的线程池在处理请求时如果出现排队前一个请求还没落库后一个请求已经到了或者上一个请求处理到一半下一个请求又从网关重试过来。这时候如果业务代码没有做去重每来一个请求就创建一张新的支付单就会形成连环扣款隐患。另外还有一些第三方支付SDK本身带有支付结果查询和重试策略业务系统如果对这些重试请求处理不当也容易造成重复创建支付单。我见过一个比较典型的案例业务方为了追求“支付单创建成功率”在网关层对创建支付单的请求做了一律重试结果一个用户点击触发了三次内部重试产生了三张支付单用户只要点进任意一张支付就是一重扣款风险。这类问题本质上是“重复请求没有幂等接收”造成的。系统只考虑了高可用和重试没有考虑重试请求应该被当作同一个请求来过滤。1.3 支付渠道侧异步回调的多次下发与乱序第三类场景发生在我们与支付渠道之间。支付渠道在用户完成付款后会通过异步通知告诉业务系统“这笔支付成功了”。但按照分布式系统“至少一次投递”的惯例渠道侧的异步通知并不保证只发一次。网络抖动、消息未确认、渠道系统重启等都可能导致同一笔支付结果被推送多次。而且这些通知不一定按顺序到达。先到的可能是“支付成功”后到的反而是“支付关闭”或者同一笔支付结果在两个时间段内重复推送。如果业务系统在接收回调时没有做好幂等处理每收一次回调就做一次“更新订单状态为已支付”的操作那么订单状态本身可能还没什么大问题但如果回调里还附带发货、优惠券发放、积分赠送等动作重复回调就会导致重复发券、重复发货。更为极端的是如果回调逻辑依赖“当前订单是否已支付”来判断是否发货同时又没有并发保护两条重复回调可以同时读到一个“未支付”的旧状态然后同时执行发货和库存扣减这就是典型的重复支付处理事故。2. 第一道闸前端交互层的防重复与它的局限很多团队接到“防止订单重复支付”的需求时第一反应是改前端。这个方向没错但必须清醒地认识到前端只能拦得住“正常用户的不正常操作”根本拦不住网络重试、恶意调用和多端并发。2.1 按钮置灰、loading态与提交锁定最基础的操作是在用户点击支付按钮后立即设置按钮为disabled状态同时展示loading动画直到页面跳转到收银台或者接口返回最终结果。这样做可以挡住大多数连续连点的情况。配套的做法是“提交锁”用户点击后在本地存储里写入一个标志位表明该订单正处于支付流程中在流程结束前即使页面被刷新也先跳回订单详情页而不是重新拉起支付。这里有一个细节容易忽视按钮状态需要覆盖所有支付入口。如果你的页面里既有“立即支付”按钮又有“再次支付”“去支付”的文字链接或者弹窗里也有一个支付入口那么必须确保所有入口共用同一个提交状态。我见过有系统只在主按钮上做了disabled处理弹窗里的支付入口漏了结果用户连点主按钮无反应后尝试点击弹窗入口又发起了新的支付请求。2.2 请求级幂等标记给创建支付请求加指纹前端能做到的第二件事是给创建支付单的请求带上一个全局唯一的请求标识。比如页面加载时生成一个UUID作为这笔订单“本次发起支付”的客户端请求号放在请求体里传给后端。后端拿这个请求号作为唯一键判断这到底是一个新请求还是同一个触发的重试。这个方案对“用户点击一次、由于响应慢自动重试”的场景非常有效。第一次请求虽然还没有返回给客户端但后端已经记住了这个请求号第二次重试到达时直接返回第一次请求的创建结果不会再新建支付单。但要注意这种请求计费的思想必须靠后端配合不是前端设置一个“防重令牌”就完事。前端生成请求号时一般用UUID即可不需要服务端分配。如果系统对请求号有复杂度要求可以夹杂时间戳和随机数。在移动端App场景下我会把请求号持久化保存一次避免页面进程被杀后重新生成的请求号导致“两次支付发起”的情况。2.3 前端能挡住的与挡不住的必须承认前端防重只是最表层的一道闸。它挡得住同一个设备上正常用户的连续点击挡不住这些情况用户在PC端发起了支付又在手机端登录对同一订单再次发起支付请求已经发出服务端正在处理此时客户端超时自动重试而两次请求带上了不同的请求号用户绕过页面直接对支付接口做并发调用。所以我的观点是前端防重可以在交互体验上让用户不犯低级错误但真正的防线必须放在服务端。前端可以做的所有处理都应该被服务端当作“可信任的加速设备”而不是“安全边界”。服务端本身必须默认所有请求都是可疑的任何一次创建支付单的调用都要能够在没有前端配合的情况下独立去重。3. 服务端硬约束数据库唯一键与订单状态机前面说的前端方案说到底都只是前端“帮用户避免犯错”。服务端要做的是即使前端不做任何拦截系统面对两发、三发甚至十发并发请求也只有其中一笔能走通支付流程。要做到这一点我依赖两件事一张带唯一约束的流水表和一个只能单向流转的订单状态机。3.1 状态机设计支付状态只能单向流转订单支付过程中建议维护一个明确的业务状态从最初的待支付到支付中再到已支付然后进入售后阶段。我对支付状态的核心要求是状态只能向终态方向前进不能往回跳。比如一笔订单一旦变成“已支付”就不能再有别的请求把它改回“待支付”也不能由两条请求同时把它改成“已支付”。落实到数据库操作上所有状态更新的SQL都必须带上前置状态条件。比如UPDATE t_order SET pay_status 2, paid_amount 399.00, pay_time now() WHERE order_id 100123 AND pay_status 1;这里pay_status1表示待支付2表示已支付。这就是条件更新。如果这条SQL影响行数是0说明订单已经被其他请求改成已支付了当前请求的处理到这里就可以终止。使用这个模式后即使两条回调同时到达数据库行锁也会让他们排队执行第二条的条件更新会因为状态已经变化而失败从而保证了只有一条能成功把订单置为已支付。3.2 唯一键让数据库替你做裁决状态机可以保证“从待支付到已支付”这个节点不被重复执行。但在“创建支付单”这个更前置的节点仅仅靠状态机是不够的。因为创建支付单时订单状态往往依然是待支付多个创建请求并发进来状态机是拦不住的。这时候必须引入数据库唯一约束。我习惯为支付流水表设计这样一个唯一键CREATE TABLE t_pay_record ( id BIGINT AUTO_INCREMENT PRIMARY KEY, order_id BIGINT NOT NULL, pay_channel VARCHAR(32) NOT NULL, channel_pay_no VARCHAR(64), request_no VARCHAR(64) NOT NULL, pay_status TINYINT NOT NULL DEFAULT 0, pay_amount DECIMAL(10,2) NOT NULL, create_time DATETIME NOT NULL, update_time DATETIME NOT NULL, UNIQUE KEY uk_order_channel_request (order_id, pay_channel, request_no) ) ENGINEInnoDB;这里的request_no就是业务客户端传入的幂等请求号。当多个请求带着相同request_no插入时只有第一个能成功后面的插入会触发唯一键冲突。处理方式是在代码里捕获这个冲突然后改为查询已存在的支付单并把它返回给前端继续走支付流程。这样既不会重复创建支付单又不会让用户觉得请求失败了。有些系统会把支付流水与支付单分成两张表一个是面向用户的支付单一个是面向渠道的流水。这种情况下唯一约束应该建在流水表上因为同一支付单可以重试不同渠道但同一渠道同一请求号只允许有一条流水。如果对“同一订单只能有一条成功流水”有强要求可以在流水表上加(order_id, pay_status成功)这种部分唯一索引不过大多数数据库不支持条件唯一索引我一般用“支付成功流水表”这种单独的表来承载这个约束。3.3 更新顺序与事务边界先把账记下来再对外请求创建支付单时的事务边界也很关键。我推荐一个顺序先落本地流水再请求渠道创建支付单。这样做的理由是第一本地流水是后续所有状态流转的依据如果先请求渠道而本地没有记录一旦请求成功回调又丢了这笔支付就会变成无主的钱第二流水落库时就把唯一约束生效了并发重复请求会在这里被挡住下游自然不会出现重复请求。在真正实现时创建支付单的方法应该包含这样几步接收请求参数和幂等请求号尝试向支付流水表插入一条状态为“待支付”的流水如果插入冲突直接返回已存在的流水如果插入成功在事务提交后调用渠道创建支付单接口据拿到渠道返回的支付参数后把流水的支付凭证字段更新掉。整个过程中任何一步失败都主动触发对支付单状态的查询或重试确保本地数据和渠道侧的最终状态一致。4. 并发场景的兜底分布式锁的正确打开方式数据库唯一键已经能在“创建支付单”这一步祛重状态机也能在“支付完成”这一步防并发。那是不是就不需要分布式锁了不一定。在高并发场景下分布式锁起到的作用不是替代数据库约束而是把可能冲突的并发请求在业务逻辑层面串行化降低反复碰撞数据库唯一键和行锁的概率同时给复杂业务流程一个“单线程”的执行环境。4.1 什么情况需要加锁并发创建与并发回调需要加锁的场景主要有两个。第一个是“判断状态并创建支付单”这个复合操作。如果先查支付流水是否存在不存在才插入这一查一插之间会存在时间窗口两个请求可能同时查不到又同时插入最终虽然唯一键能挡住后者但业务代码会额外处理异常分支。加锁后后进入的请求直接看到前一个请求创建的支付单代码路径干净很多。第二个是“支付成功后的业务处理”。订单支付成功后往往要扣库存、发卡券、通知发货系统等。如果两个重复回调同时进入且都读到订单是待支付状态那么即使订单状态更新在数据库层面只有一个能成功但其他副作用动作在状态更新之前可能已经被执行或者被重复触发。用锁把“校验状态→更新状态→执行后续动作”这段逻辑串起来可以有效避免这种“脏读”的并发风险。4.2 加锁的key设计与执行流程加锁的key不要设置太粗的粒度。我见过有人为了防止重复支付对整个用户的ID加锁结果一个用户同时支付不同订单时效率被压制得非常明显。正确做法是粒度到“当前业务实体”也就是订单ID或支付单ID。示例key lock:pay:order:100123 value requestId 或者唯一业务标识 SET key value NX EX 5这里的value要使用当前请求的唯一标识这样在释放锁时可以校验这个锁是不是自己加的避免误删别人的锁。释放锁的逻辑不要用简单的DEL因为可能出现在锁过期之后A请求还在执行B请求拿到锁并完成操作随后A请求执行完毕用DEL把B的锁删掉的问题。释放操作应使用Lua脚本先比较value再删除if redis.call(GET, KEYS[1]) ARGV[1] then return redis.call(DEL, KEYS[1]) else return 0 end这种带校验的释放逻辑是防误删的基本功。我之前在某个项目里踩过一次坑因为释放锁没做校验锁过期后新请求进来了旧请求结束后把新请求的锁删了导致第三个请求也可以自由进入等于锁白加了。4.3 锁过期时间、续期与锁失效后的兜底锁的过期时间不能拍脑袋。设太短业务没执行完锁就过期了多个请求重新进入临界区设太长一旦持有锁的服务进程宕机其他请求会被阻塞很久。我一般的做法是将过期时间设置为业务峰值耗时的三倍以上。比如创建支付单接口需要查询数据库、调用渠道通常一两百毫秒就能完成设置5秒已经非常充裕。但对于链路比较长的“支付成功处理流程”例如涉及库存扣减、优惠券核销、消息推送等就需要另一种处理了。有两种思路。一种是在锁内业务方法里周期性调用续期脚本相当于延长锁的时间。另一种是不要在锁内做太多耗时操作把调用远程渠道这类动作移出锁外锁内只做状态判断和记录更新。我实际更推荐后者因为锁的存在意义是把不一致的高风险操作串行化远程调用外呼应该靠状态机来恢复而不是靠时长很长的分布式锁来保护。一旦锁因为过期失效后面的请求已经可以进来了这时候最后一道兜底依然是数据库唯一键和条件更新。所以我始终强调分布式锁是性能优化和降低冲突概率的手段数据库约束才是最终的裁决者。两者配合才能够在各类边界情况下保证不重复扣款。5. 支付回调的重复通知异步场景的幂等处理前面几层主要解决“支付请求发起”侧的重复。当用户真正完成了支付支付渠道发来异步通知时另一个重复风险点又出现了。这个节点的特点是支付平台发出的通知不保证只发一次而且可能乱序还可能在你重启系统时段重复推送。处理不好就会在“支付成功”这个环节上重复执行业务动作。5.1 回调幂等的核心以业务结果为准处理异步通知的正确姿势不是在通知接口入口做“同一通知ID只处理一次”而是以业务数据当前状态为准。比如收到一笔支付成功通知先根据关联的支付单号查出本地支付流水如果流水的状态已经是已支付并且金额一致那么直接返回通知处理成功不再执行任何后续动作。大致逻辑如下public PayNotifyResponse handlePayNotify(NotifyRequest req) { // 根据本地订单号 / 支付单号查询本地记录 PayRecord record payRecordMapper.selectByOrderId(req.getOrderId()); // 校验渠道、金额、签名是否匹配 if (!checkNotify(req, record)) { return PayNotifyResponse.fail(参数不匹配); } // 已被处理过直接确认 if (record.getPayStatus() PayStatus.PAID) { return PayNotifyResponse.success(); } // 获取分布式锁再执行一次状态更新 String lockKey lock:pay:order: record.getOrderId(); boolean locked tryLock(lockKey, req.getRequestId()); if (!locked) { return PayNotifyResponse.fail(系统繁忙稍后重试); } try { // 锁内重新读取一次记录 PayRecord fresh payRecordMapper.selectById(record.getId()); if (fresh.getPayStatus() PayStatus.PAID) { return PayNotifyResponse.success(); } // 执行置为已支付的更新与后续动作 int rows markOrderPaid(fresh.getOrderId()); if (rows 0) { afterPaid(fresh.getOrderId()); } } finally { unlock(lockKey, req.getRequestId()); } return PayNotifyResponse.success(); }这里的关键是“以最终业务状态为幂等依据”而不是在入口处用一张通知记录表去重。用通知ID去重的缺点是你无法预期渠道会不会在某种情况下换个通知ID重新推送同一笔支付结果。只有判断“本地这笔支付是否已经处理成功”才是最可靠的幂等逻辑。5.2 回调乱序与主动查询不要轻信通知顺序有些支付平台会同时下发多条通知比如窗口内有重复支付时会产生“支付成功”和“支付关闭”两条通知或者支付成功之后又被对端冲正。这些通知到达的顺序并不是确定的。如果业务系统直接按通知的顺序去修改订单状态就可能出现“支付成功”先到订单置为已支付随后一条迟到的“关闭通知”又把订单改回去的情况这会给后续发货和财务对账带来极大的混乱。针对乱序问题比较稳妥的做法是通知里带上渠道侧的支付时间、渠道交易流水号、订单金额等关键信息处理通知前先与本地订单数据做比较。只有满足“通知时间不早于本地已成功时间”才允许变更。还有一种更保险的方式不只依赖渠道回调而是提供主动查询接口。系统中起一个定时任务每几分钟把一段时间内状态仍是支付中的订单捞出来主动向渠道查询真实支付状态以查询结果为准更新。主动查询的结果可以与回调结果互相校验。5.3 幂等表另一套可行的落地方案除了状态判断还有一种常见的做法是“业务幂等表”。它的核心思路是在处理回调或其他写操作之前先向一张幂等表插入一条记录。这条记录的唯一键就是本次操作用的业务编号比如支付渠道名、渠道侧流水号和通知动作的组合。插入成功才继续干活插入失败说明这个业务编号已经处理过了直接返回成功。这套方案适合“一个业务编号对应一个操作结果”的场景比如已经收到过渠道的某个退款通知同样的通知再发一次用幂等表一挡就完了。但我不建议把它作为唯一的防重手段因为幂等表只能拦住完全相同的业务编号如果渠道对同一笔支付换了一个通知编号来发幂等表就失效了。所以我的推荐是“业务状态判断为骨架幂等表作为补充”两者共用效果最好。5.4 对账兜底T1核对最后的防线即便线上所有环节都做了幂等也不能完全放心。渠道侧偶尔会有延迟入账、挂账冲正、手工调账等特殊情况这些不是简单的幂等能解决的。所以任何涉及资金流的订单系统都必须有“对账”这个环节。常见的做法是每天定时从支付渠道下载前一天的交易对账单与本地支付流水逐笔比对。比对的核心维度包括渠道交易流水号、订单号、支付金额、交易状态以及手续费。每次对账任务结束生成一张差异清单把“本地有渠道无”“渠道有本地无”“金额不一致”“退款字段不一致”等情况分门别类地暴露出来。对账的作用是把前面所有防重复机制可能漏掉的那1%找出来哪怕不发生线上事故也能及时感知异常。真正出现重复扣款时对账和人工处理流程是止损的最后一道防线。6. 一次线上重复退款的完整排查链路前面几章讲的都是设计层面。这一章我想分享一个实际踩坑案例这件事本身不是“重复支付”而是“重复退款”但它们的根源完全一样接口没有幂等控制并发请求把同一个业务账务处理了两遍。复盘整个排查过程对理解防重复支付的原理特别有帮助。6.1 现象财务发现一笔订单退了两笔款当时是一个模拟电商项目做的是自营商品的订单退款功能。上线一段时间后财务在做月度账单核对时发现某笔退款订单在账面上出现了两条退款流水金额都是原订单的金额退款时间相差不过三四分钟。财务反馈后第一反应是不是退款单被重复创建了但后台查到这个订单确实对应了两张退款单而且两张单的状态都是“退款成功”。更奇怪的是原订单只有一个支付流水也只有一条两条退款单竟然都关联的是同一条已支付流水。按照正常设计同一笔已支付流水只允许发起一笔退款。能够产生两条退款单说明“退款单创建”这个环节完全没设防。6.2 顺着流水找到根因排查的第一步是查退款单的创建时间看两条记录分别由什么请求触发。结果发现两个退款单的创建时间几乎相同只相差几百毫秒。继续看接口访问日志两个请求的IP、登录用户、操作页面都完全一样而且第二个请求比第一个晚不了多久就到达了。这时候怀疑点有两个一是页面重复提交二是客户端超时重试。去查数据库发现退款单表压根没有“退款请求号”或者“幂等键”的概念每一张退款单都是独立的记录与原支付流水是普通的关联关系没有唯一约束。也就是说只要用户或者客户端发出了两次创建退款请求就会生成两张退款单。再往下查发现“创建退款单”接口本身只检查了原支付流水是否存在不存在“已被退款”的校验。两条并发请求几乎同时通过了检查各自构建了一张退款单然后下一环节的异步退款任务把这跟两张单都推送到了支付渠道。渠道对同一笔原支付单是不允许退两次的但两张退款单渠道编号不同渠道把它当成两笔独立的退款单来受理于是两笔退都成功了。6.3 修复方案与复盘把幂等变成所有写接口的默认要求这次故障之后修复用了三步。第一步在退款单表上增加唯一约束字段组合是“原支付流水号 幂等请求号”。前端发起退款时生成一个UUID作为请求号后端接收时先尝试插入重复插入直接返回已存在的退款单。这样从数据库层面保证同一个退款操作不可能生成两张单。第二步在创建退款单的业务逻辑里增加状态机校验。已支付订单退款时需要先比对原订单的状态确认不在“退款中”“已退款”等终态如果已经存在退款单正在处理中则直接返回这个退款单的信息。同时把退款单状态设计为待提交 → 提交成功 → 渠道处理中 → 退款成功或退款失败任何状态更新都带上前置状态条件。第三步梳理所有对外写接口逐一检查是否具备幂等性。凡是“前端可能重复触发、渠道可能重复通知”的接口都必须有唯一的业务请求号。后来的复盘形成了一条规则任何涉及资金变动的写操作设计的第一件事不是怎么处理业务逻辑而是先想清楚“如果这个请求被连续调用两次甚至并发调用两次系统是否还能给出正确结果”。不能在事后靠对账发现问题要在系统内部就把重复触发的路径全部切断。那次踩坑之后我把防重复支付重新总结成一句话每一道闸口都做去重数据库唯一键兜底状态机防止重复流转回调通知幂等处理最后再用对账把漏网之鱼捞出来。按照这条思路去设计订单系统重复支付的坑就能基本填平。