本文目录导读:

这是一个非常经典的支付系统设计问题。“去重表” 本身是防止重复支付的核心手段,但仅仅建一张表是不够的,需要在业务逻辑和数据库层面配合,才能真正防止重复支付。
核心原则是:利用唯一约束 + 事务 + 状态机。
以下是从前端到后端、从表设计到高并发的完整方案:
方案核心:业务唯一键 + 唯一索引
防止重复支付的关键,不是防止“请求重复发”,而是防止“一笔订单被成功扣款多次”。
去重表的核心逻辑:
- 定义业务唯一键: 订单不能直接用数据库自增ID作为去重条件,因为同一个支付请求可能会重试,需要用一个全局唯一的、业务上代表“一次支付尝试”的ID,
商户订单号 + 支付渠道 + 流水号。- 最常用方案: 使用支付渠道返回的流水号(如微信支付单号、支付宝交易号)与你的业务订单号组合作为唯一键。
- 更严谨方案: 自己生成一个全局唯一的
payment_request_id,每次点击“支付”按钮时生成。
- 建立唯一索引: 在去重表上,为这个业务唯一键建立 唯一索引(UNIQUE KEY)。
- 先插后更: 支付成功的核心逻辑变成:
- 第一步:尝试向去重表插入一条记录(包含:
order_id,channel_payment_no,status等)。 - 第二步:如果插入成功(说明这是第一次处理),则执行后续扣款、更新订单状态。
- 第三步:如果插入报错(违背唯一约束),说明已经处理过这笔支付,直接返回“支付成功”或忽略本次请求。
- 第一步:尝试向去重表插入一条记录(包含:
具体实现步骤(伪代码示例)
数据库去重表设计
CREATE TABLE `payment_deduplication` ( `id` bigint unsigned NOT NULL AUTO_INCREMENT, `order_id` varchar(64) NOT NULL COMMENT '你的订单号', `channel_payment_no` varchar(128) NOT NULL COMMENT '支付渠道流水号(如微信/支付宝单号)', `status` tinyint NOT NULL DEFAULT '0' COMMENT '0: 处理中, 1: 已成功', `created_at` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP, `updated_at` datetime ON UPDATE CURRENT_TIMESTAMP, PRIMARY KEY (`id`), -- 核心:唯一键,保证同一个支付流水号不会重复处理 UNIQUE KEY `uk_order_channel` (`order_id`, `channel_payment_no`), KEY `idx_order_id` (`order_id`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='支付去重表';
后端支付回调处理逻辑
// 伪代码:支付成功回调处理
public void handlePaymentCallback(PaymentCallback callback) {
String orderId = callback.getOrderId();
String channelPaymentNo = callback.getChannelPaymentNo();
// 1. 先查询去重表(第一层防护,快速判断)
PaymentDeduplication dedup = dedupMapper.selectByOrderAndChannel(orderId, channelPaymentNo);
if (dedup != null) {
// 已处理过,直接返回成功
log.info("重复回调,已处理: {} {}", orderId, channelPaymentNo);
return;
}
// 2. 尝试插入去重记录(核心防护,利用唯一索引)
PaymentDeduplication record = new PaymentDeduplication();
record.setOrderId(orderId);
record.setChannelPaymentNo(channelPaymentNo);
record.setStatus(0); // 处理中
try {
// 如果插入失败,会抛 DuplicateKeyException
dedupMapper.insert(record);
} catch (DuplicateKeyException e) {
// 3. 插入失败,说明另一个线程或请求已经处理了
log.info("唯一约束检测到重复: {} {}", orderId, channelPaymentNo);
return;
}
// 4. 插入成功,开始业务处理
// **注意:这里需要用事务保证原子性**
// 更新订单状态为已支付,增加用户余额等
try {
// 开启事务
transactionTemplate.execute(status -> {
// 4.1 检查订单当前状态是否为“未支付”(二次校验)
Order order = orderMapper.selectForUpdate(orderId); // 悲观锁
if (order.getStatus() != OrderStatus.UNPAID) {
throw new BusinessException("订单状态异常");
}
// 4.2 执行扣款/充值逻辑
accountService.addBalance(order.getUserId(), order.getAmount());
// 4.3 更新订单状态
order.setStatus(OrderStatus.PAID);
orderMapper.updateById(order);
// 4.4 更新去重记录状态为成功
record.setStatus(1);
dedupMapper.updateById(record);
return true;
});
} catch (Exception e) {
// 业务处理失败,需要记录失败原因,并考虑人工介入或补偿
log.error("支付处理失败: {} {}", orderId, channelPaymentNo, e);
// 这里可以记录到失败表,或更新去重表状态为失败
}
}
为什么不能只用“去重表”?(常见陷阱)
仅靠一张去重表和唯一约束,在高并发或业务处理中断时仍可能出现问题。
陷阱1:重复插入但事务未提交
- Thread A 插入去重表成功,事务未提交。
- Thread B 查询去重表,没查到(因为A未提交,默认读未提交会有问题,但如果是读已提交级别,B就看不到)。
- Thread B 也插入成功(唯一索引检查时,因为A未提交,锁可能没生效)。
- 结果:两个请求都通过了去重检查。
- 解决: 使用
SELECT ... FOR UPDATE(悲观锁)或INSERT ... ON DUPLICATE KEY UPDATE(MySQL特有)来保证原子性。推荐:INSERT ON DUPLICATE KEY UPDATE 是更常见的方案。
陷阱2:插入了去重表,但业务逻辑(扣钱、改状态)失败
- 插入去重表成功,但更新订单状态或扣钱时抛异常。
- 用户会得到“支付失败”的提示,但去重表里已经有记录。
- 当用户重试或渠道再次回调,去重表会阻止处理。
- 解决: 去重表的状态需要和业务状态保持最终一致性,通常做法是:去重表+失败重试表+人工对账。
陷阱3:幂等性不仅仅是去重,还包括重试
- 前端网络抖动,用户点击支付按钮多次,生成了多个不同的
channel_payment_no。 - 这些请求通过去重表后,在业务层仍然需要检查订单是否已经被其他请求支付成功。
- 解决: 在去重表之后、业务处理之前,加一层订单状态校验(如
select for update检查订单状态是否为“未支付”)。
更完善的方案:幂等性令牌(Idempotent Key)
在更通用的架构中,可以用一个全局唯一的 幂等键(Idempotent Key) 代替支付流水号。
流程:
- 前端请求支付时,先向后端获取一个幂等键(UUID)。
- 前端使用这个幂等键发起支付请求。
- 后端收到请求,先检查这个幂等键是否存在。
- 如果不存在,存入分布式缓存(Redis)或数据库(带唯一约束),设置过期时间。
- 执行业务逻辑。
- 返回结果。
优点: 即使网络重发多次,只要幂等键相同,请求只执行一次。
最佳实践清单
- 去重表(必选): 表设计包含
订单号 + 渠道流水号的唯一索引,用作最终防线。 - 事务 + 唯一性检查(必选): 使用
INSERT ... ON DUPLICATE KEY UPDATE或SELECT ... FOR UPDATE保证并发安全。 - 状态机校验(必选): 在事务内二次校验订单当前状态是否为“待支付”。
- 分布式锁(可选,用于极高并发): 在处理支付回调前,对订单ID加分布式锁(Redis/ Zookeeper),锁内执行事务。
- 异步对账(必须): 无论如何,系统需要运行一个定时任务,与支付渠道进行日终对账,核销异常的单边账(渠道显示已扣款,但你的系统显示未支付)。
- 前端防重(辅助): 前端按钮点击后置灰、禁用,减少无效请求。
一句话总结: 用 唯一索引 做硬性约束来防止数据重复,用 事务 + 状态机 防止并发写入导致的状态错误,用 异步对账 兜底修复不一致。