
支付场景中,“重复支付” 是最致命的问题之一 —— 用户点击两次支付按钮、网络延迟导致系统重试、第三方支付回调重复通知,都可能导致 “一笔订单扣两次款”。一旦发生,不仅会引发用户投诉,还可能造成资金损失与合规风险。而解决这一问题的核心,就是保证支付请求的幂等性。
本文结合电商、金融支付的实战经验,从 “为什么需要幂等性”“重复请求的来源”“具体实现方案” 到 “落地避坑”,完整拆解支付幂等性的设计逻辑,提供可直接复用的技术方案。
在计算机领域,幂等性是指:同一操作无论执行多少次,最终结果都是一致的。
对于支付请求,幂等性的核心要求是:
支付的核心是 “资金安全”,幂等性是资金安全的底线:
在设计方案前,先明确 “重复请求从哪来”,才能针对性解决:
重复场景 | 具体描述 | 典型案例 |
|---|---|---|
用户重复操作 | 用户点击支付按钮后,因页面无响应再次点击(或连续点击) | 电商下单后,用户快速点击 “微信支付” 按钮 3 次 |
网络延迟 / 超时重试 | 支付请求发送后,因网络延迟未收到响应,客户端 / 服务端触发重试 | 移动端支付请求超时,APP 自动重试 2 次 |
系统故障重发 | 服务端处理支付时宕机,重启后重新接收未完成的请求 | 支付服务处理中数据库宕机,恢复后重放请求 |
第三方支付回调重复通知 | 微信支付 / 支付宝的支付结果回调,因网络波动重复发送 | 支付宝回调通知超时,重复发送 3 次支付成功通知 |
关键结论:重复请求无法避免(用户操作、网络、系统都可能导致),必须通过技术手段让重复请求 “无害”。
支付幂等性的实现核心是 “唯一标识 + 状态校验”—— 用唯一标识区分请求,用状态校验判断是否允许执行,以下是 5 种主流方案,覆盖不同场景:
用全局唯一的订单号作为幂等标识,支付请求必须携带该订单号,服务端通过订单号判断是否已处理:
CREATE TABLE `payment_order` ( `id` bigint NOT NULL AUTO_INCREMENT COMMENT '主键ID', `order_no` varchar(64) NOT NULL COMMENT '唯一订单号(幂等标识)', `user_id` bigint NOT NULL COMMENT '用户ID', `amount` decimal(10,2) NOT NULL COMMENT '支付金额', `status` tinyint NOT NULL COMMENT '支付状态:0=待支付,1=支付中,2=已支付,3=支付失败', `create_time` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间', `update_time` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间', PRIMARY KEY (`id`), UNIQUE KEY `uk_order_no` (`order_no`) COMMENT '订单号唯一索引(确保幂等)') ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT '支付订单表';@Servicepublic class PaymentService { @Autowired private PaymentOrderMapper orderMapper; @Autowired private PayGatewayClient payGateway; // 调用第三方支付网关(如微信支付) /** * 支付接口(保证幂等性) * @param orderNo 唯一订单号(幂等标识) * @param userId 用户ID * @param amount 支付金额 * @return 支付结果 */ @Transactional public PaymentResultDTO pay(String orderNo, Long userId, BigDecimal amount) { // 步骤1:查询订单(通过唯一订单号) PaymentOrder order = orderMapper.selectByOrderNo(orderNo); if (order != null) { // 步骤2:已处理过,直接返回结果 if (order.getStatus() == 2) { // 已支付 return PaymentResultDTO.success("支付成功", orderNo); } else if (order.getStatus() == 1) { // 支付中 return PaymentResultDTO.processing("支付处理中,请稍后查询", orderNo); } else if (order.getStatus() == 3) { // 支付失败,允许重试 return processPayment(orderNo, userId, amount); } } // 步骤3:未处理,执行支付逻辑 return processPayment(orderNo, userId, amount); } /** * 核心支付逻辑(抽离复用) */ private PaymentResultDTO processPayment(String orderNo, Long userId, BigDecimal amount) { // 步骤1:创建订单(唯一索引保证不会重复创建) PaymentOrder newOrder = new PaymentOrder(); newOrder.setOrderNo(orderNo); newOrder.setUserId(userId); newOrder.setAmount(amount); newOrder.setStatus(1); // 状态设为“支付中”(防止并发重复) try { orderMapper.insert(newOrder); } catch (DuplicateKeyException e) { // 并发场景下,其他线程已创建订单,直接查询返回 return pay(orderNo, userId, amount); } try { // 步骤2:调用第三方支付网关(如微信支付统一下单接口) PayGatewayResponse gatewayResp = payGateway.unifiedOrder(orderNo, amount); if (gatewayResp.isSuccess()) { // 步骤3:支付成功,更新状态为“已支付” orderMapper.updateStatusByOrderNo(orderNo, 2); return PaymentResultDTO.success("支付成功", orderNo); } else { // 步骤4:支付失败,更新状态为“支付失败” orderMapper.updateStatusByOrderNo(orderNo, 3); return PaymentResultDTO.fail("支付失败:" + gatewayResp.getMsg(), orderNo); } } catch (Exception e) { // 步骤5:异常情况,更新状态为“支付失败” orderMapper.updateStatusByOrderNo(orderNo, 3); log.error("支付异常,订单号:{}", orderNo, e); return PaymentResultDTO.fail("支付异常,请重试", orderNo); } }}针对 “用户重复点击” 场景,通过 “预生成令牌” 控制支付请求的唯一性:
@RestController@RequestMapping("/api/pay")public class PaymentController { @Autowired private StringRedisTemplate redisTemplate; private static final String TOKEN_PREFIX = "pay:token:"; /** * 获取支付令牌(前端支付前调用) * @param userId 用户ID * @return 唯一Token */ @GetMapping("/get-token") public ResultDTO getPayToken(Long userId) { // 生成唯一Token(UUID+用户ID,确保唯一性) String token = "TOKEN_" + UUID.randomUUID().toString().replace("-", "") + "_" + userId; // 存入Redis,过期时间15分钟(避免Token长期有效) redisTemplate.opsForValue().set(TOKEN_PREFIX + token, userId.toString(), 15, TimeUnit.MINUTES); return ResultDTO.success(token); }}@Servicepublic class PaymentService { @Autowired private StringRedisTemplate redisTemplate; private static final String TOKEN_PREFIX = "pay:token:"; @Transactional public PaymentResultDTO payWithToken(String token, String orderNo, Long userId, BigDecimal amount) { // 步骤1:Token校验(核心幂等逻辑) String redisKey = TOKEN_PREFIX + token; String redisUserId = redisTemplate.opsForValue().get(redisKey); if (redisUserId == null || !redisUserId.equals(userId.toString())) { // Token不存在/已使用/用户不匹配,拒绝请求 return PaymentResultDTO.fail("无效的支付请求,请刷新页面重试", orderNo); } // 步骤2:订单校验(同方案1,双重保障) PaymentOrder order = orderMapper.selectByOrderNo(orderNo); if (order != null && order.getStatus() == 2) { return PaymentResultDTO.success("支付成功", orderNo); } try { // 步骤3:执行支付逻辑(同方案1) PaymentResultDTO result = processPayment(orderNo, userId, amount); // 步骤4:支付完成后,删除Token(标记为已使用) redisTemplate.delete(redisKey); return result; } catch (Exception e) { // 异常时不删除Token,允许重试(避免因系统异常导致无法支付) log.error("支付异常,Token:{},订单号:{}", token, orderNo, e); return PaymentResultDTO.fail("支付异常,请重试", orderNo); } }}支付订单的状态变更遵循固定流程(如 “待支付→支付中→已支付”),通过状态机限制 “不允许的状态变更”,避免重复处理:
/** * 支付状态机校验(工具类) */public class PaymentStateMachine { // 状态流转规则:key=当前状态,value=允许流转的目标状态 private static final Map<Integer, List<Integer>> STATE_RULES = new HashMap<>(); static { // 待支付(0)可流转到:支付中(1) STATE_RULES.put(0, Collections.singletonList(1)); // 支付中(1)可流转到:已支付(2)、支付失败(3) STATE_RULES.put(1, Arrays.asList(2, 3)); // 支付失败(3)可流转到:支付中(1)(允许重试) STATE_RULES.put(3, Collections.singletonList(1)); // 已支付(2)不可流转到任何状态(不允许重复支付) STATE_RULES.put(2, Collections.emptyList()); } /** * 校验状态是否允许流转 * @param currentState 当前状态 * @param targetState 目标状态 * @return true=允许,false=不允许 */ public static boolean checkStateTransition(int currentState, int targetState) { List<Integer> allowedStates = STATE_RULES.getOrDefault(currentState, Collections.emptyList()); return allowedStates.contains(targetState); }}// 支付接口中使用状态机校验@Transactionalpublic PaymentResultDTO payWithStateMachine(String orderNo, Long userId, BigDecimal amount) { PaymentOrder order = orderMapper.selectByOrderNo(orderNo); if (order != null) { // 状态机校验:若当前状态不允许流转到“支付中”,直接返回 if (!PaymentStateMachine.checkStateTransition(order.getStatus(), 1)) { if (order.getStatus() == 2) { return PaymentResultDTO.success("支付成功", orderNo); } else { return PaymentResultDTO.fail("当前订单状态不允许支付", orderNo); } } } // 后续执行支付逻辑(同方案1) return processPayment(orderNo, userId, amount);}针对分布式系统(多服务实例),用 “防重表” 记录已处理的支付请求,通过数据库唯一索引保证幂等:
CREATE TABLE `payment_idempotent` ( `id` bigint NOT NULL AUTO_INCREMENT COMMENT '主键ID', `idempotent_key` varchar(64) NOT NULL COMMENT '幂等标识(订单号/支付流水号)', `create_time` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间', PRIMARY KEY (`id`), UNIQUE KEY `uk_idempotent_key` (`idempotent_key`) COMMENT '幂等标识唯一索引') ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT '支付防重表';@Servicepublic class DistributedPaymentService { @Autowired private PaymentIdempotentMapper idempotentMapper; @Autowired private PaymentOrderMapper orderMapper; @Transactional public PaymentResultDTO distributedPay(String orderNo, Long userId, BigDecimal amount) { // 步骤1:插入防重表(核心幂等逻辑) PaymentIdempotent idempotent = new PaymentIdempotent(); idempotent.setIdempotentKey(orderNo); // 用订单号作为幂等标识 try { idempotentMapper.insert(idempotent); } catch (DuplicateKeyException e) { // 插入失败,说明已处理过,查询订单状态返回 PaymentOrder order = orderMapper.selectByOrderNo(orderNo); if (order != null && order.getStatus() == 2) { return PaymentResultDTO.success("支付成功", orderNo); } return PaymentResultDTO.fail("该订单已提交支付,请稍后查询结果", orderNo); } // 步骤2:执行支付逻辑(同方案1) try { return processPayment(orderNo, userId, amount); } catch (Exception e) { // 步骤3:支付失败,删除防重表记录(允许重试) idempotentMapper.deleteByIdempotentKey(orderNo); log.error("分布式支付异常,订单号:{}", orderNo, e); return PaymentResultDTO.fail("支付异常,请重试", orderNo); } }}第三方支付平台(微信支付、支付宝)的支付结果回调可能重复发送,需单独处理回调的幂等性:
CREATE TABLE `payment_callback_record` ( `id` bigint NOT NULL AUTO_INCREMENT COMMENT '主键ID', `third_party_no` varchar(64) NOT NULL COMMENT '第三方支付流水号(如微信transaction_id)', `order_no` varchar(64) NOT NULL COMMENT '关联订单号', `callback_time` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '回调时间', PRIMARY KEY (`id`), UNIQUE KEY `uk_third_party_no` (`third_party_no`) COMMENT '第三方流水号唯一索引') ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT '支付回调记录表';@RestController@RequestMapping("/api/pay/callback")public class WechatPayCallbackController { @Autowired private PaymentCallbackRecordMapper callbackMapper; @Autowired private PaymentOrderMapper orderMapper; /** * 微信支付回调接口(保证幂等性) */ @PostMapping("/wechat") public String wechatPayCallback(@RequestBody String xmlData) { // 步骤1:解析微信回调数据(获取transaction_id、order_no、result_code等) WechatCallbackDTO callbackDTO = parseWechatCallback(xmlData); String thirdPartyNo = callbackDTO.getTransactionId(); // 第三方唯一流水号 String orderNo = callbackDTO.getOutTradeNo(); // 商户订单号 // 步骤2:校验回调是否已处理(幂等核心) PaymentCallbackRecord record = callbackMapper.selectByThirdPartyNo(thirdPartyNo); if (record != null) { // 已处理,返回微信“成功”标识(避免重复回调) return buildWechatSuccessXml(); } // 步骤3:校验支付结果(仅处理支付成功的回调) if (!"SUCCESS".equals(callbackDTO.getResultCode())) { log.error("微信支付回调失败,订单号:{},原因:{}", orderNo, callbackDTO.getErrCodeDes()); return buildWechatFailXml("支付失败"); } try { // 步骤4:更新订单状态为“已支付” int updateCount = orderMapper.updateStatusByOrderNo(orderNo, 2); if (updateCount == 0) { log.error("订单不存在或已处理,订单号:{}", orderNo); return buildWechatSuccessXml(); // 仍返回成功,避免重复回调 } // 步骤5:记录回调流水号(标记为已处理) PaymentCallbackRecord newRecord = new PaymentCallbackRecord(); newRecord.setThirdPartyNo(thirdPartyNo); newRecord.setOrderNo(orderNo); callbackMapper.insert(newRecord); // 步骤6:返回成功标识 return buildWechatSuccessXml(); } catch (Exception e) { log.error("微信支付回调处理异常,订单号:{}", orderNo, e); // 回调处理失败,返回失败标识,微信会重试(需做好重试机制) return buildWechatFailXml("处理异常"); } } // 工具方法:解析微信回调XML、构建返回XML(省略) private WechatCallbackDTO parseWechatCallback(String xmlData) { /* ... */ } private String buildWechatSuccessXml() { /* ... */ } private String buildWechatFailXml(String msg) { /* ... */ }}结合以上方案,以电商支付为例,梳理完整的幂等性保障流程:
业务场景 | 推荐方案组合 | 核心原因 |
|---|---|---|
中小电商(单体应用) | 唯一订单号(方案 1)+ 状态机(方案 3) | 实现简单,无额外依赖,满足基础幂等需求 |
移动端支付(防重复点击) | 令牌机制(方案 2)+ 唯一订单号(方案 1) | Token 防止重复点击,订单号双重保障 |
分布式电商(多服务) | 防重表(方案 4)+ 状态机(方案 3) | 分布式强一致性,支持高并发 |
第三方支付回调 | 回调流水号校验(方案 5) | 针对性处理第三方重复通知 |
秒杀支付(高并发) | 防重表(方案 4)+ Redis 分布式锁 | 兼顾高并发与强一致性 |
支付幂等性的本质是 “用唯一标识锁定操作,用状态校验控制流程”—— 无论重复请求来自用户、网络还是系统,只要通过 “唯一标识判断是否已处理”,就能保证结果一致。
设计时需遵循 “简单优先,兼顾场景”:
最终,支付幂等性是资金安全的底线,没有 “最优方案”,只有 “最适合业务场景的方案”—— 结合自身业务规模、技术架构选择组合方案,同时做好监控与日志,才能杜绝重复扣款,保障用户与平台的资金安全。
原创声明:本文系作者授权腾讯云开发者社区发表,未经许可,不得转载。
如有侵权,请联系 cloudcommunity@tencent.com 删除。
原创声明:本文系作者授权腾讯云开发者社区发表,未经许可,不得转载。
如有侵权,请联系 cloudcommunity@tencent.com 删除。