面试官推过一张工单截图:“凌晨1点,用户A在APP端和PC端同时登录,对同一笔待支付订单分别发起了支付。两个终端都进入了收银台,用户快速在APP输入密码,又在PC输入密码,几乎同时完成。系统最终扣了两次款,订单却只生成了一笔。技术负责人说‘加个分布式锁就行’。你来看看,这个场景下,分布式锁能百分之百防住吗?为什么还会重复支付?”
这道题考的不是你会不会用Redis锁,而是在极端并发、多端同时支付的真实场景下,如何保证资金安全。今天我们从事故出发,拆解防止重复支付的完整防线。真实的场景可能更加复杂多变,仅供大家参考。
一、场景复盘:为什么“换端同时支付”会重复扣款?
1.1 业务场景
用户在APP上生成一笔订单,状态为“待支付”,订单号 O123456。用户同时打开PC端网页,也登录了同一账号,同样看到这笔待支付订单。 用户先在APP上进入收银台,点击支付,输入密码;几乎同时,在PC端也进入收银台,点击支付,输入密码。 两个请求几乎同时到达后端支付网关,分别调用不同的支付渠道(微信/支付宝),并且都成功扣款。 注:同一支付渠道,人家有防重,一般不会导致重复扣款
并发执行时间线:
结果:两次扣款成功,订单状态只更新了一次,但资金损失发生了。
1.3 问题本质
订单状态判断非原子:两个请求同时读到“待支付”,都认为可以支付。 缺乏最终防线:没有使用数据库乐观锁或唯一约束来拦截重复支付。
二、面试官连环炮
第一炮:分布式锁能解决这个问题吗?有什么局限?
问题:如果用Redis分布式锁,对订单号加锁,两个请求只有一个能进入支付流程,是不是就安全了?请分析可能的风险点。
回答要点:
分布式锁可以解决“同时进入”的问题,但存在以下风险:
锁超时风险:如果业务执行时间(调用第三方支付+等待响应)超过锁过期时间,锁自动释放。另一个请求拿到锁,再次支付成功 → 超卖。 锁释放误删:线程A持锁,业务超时锁自动释放;线程B拿到锁;线程A执行完后手动释放锁,结果把线程B的锁删了,导致并发失控。 网络分区/主从切换:Redis主从架构下,主节点加锁成功,数据未同步到从节点,主宕机,从晋升,锁信息丢失 → 两个请求都能加锁。
分布式锁不是银弹,必须配合数据库悲观锁或乐观锁做最终防线。
第二炮:数据库层面如何设置“最后一道防线”?
问题:如果分布式锁失效,如何利用数据库自身的并发控制能力防止重复支付?
回答要点:
乐观锁(版本号):在订单表增加
version字段。更新订单状态时加上版本条件:UPDATE t_order SETstatus = 'PAID', version = version + 1
WHERE order_id = #{orderId} AND status = 'PENDING' AND version = #{oldVersion}更新影响行数为0,说明订单状态已被改变,另一个请求失败。这是最可靠的防线。
唯一索引/业务约束:在支付记录表对
(order_id, pay_channel)建唯一索引,即使两个请求同时插入支付记录,只有一条能成功,另一条抛出DuplicateKeyException。SELECT ... FOR UPDATE 悲观锁:在事务开始时,用
SELECT * FROM t_order WHERE order_id = #{id} FOR UPDATE锁住订单行,其他请求必须等待。缺点是性能较差,适合低并发核心场景。
第三炮:如何防止用户在APP和PC端同时唤起收银台?有没有前端或网关层拦截方案?
问题:从用户体验和系统安全角度,能否在用户点击“支付”之前就做一些限制?
回答要点:
请求去重Token:用户进入收银台时,后端下发一个支付Token(唯一ID),有效期短(如30秒)。支付时必须携带该Token,后端使用Redis SET NX消费一次,第二个请求无法成功。前端互斥锁:通过WebSocket或轮询,当用户在APP端发起支付时,通知其他端“支付中,请勿重复操作”。但这只能辅助,不能作为安全依仗。 网关层限流:对同一用户、同一订单号的支付请求做频率限制,例如3秒内只允许1次。
第四炮:完整的防重复支付方案应该包含哪几层?请画出架构图。
回答要点:
用户端 → 网关层(限流+Token校验) → 业务层(分布式锁+订单状态CAS) → 支付渠道
↓
支付结果处理(唯一索引插入) → 更新订单(乐观锁)
↓
异步通知用户、积分等
每一层的职责:
网关层:拦截高频重复请求,校验支付Token。 业务层:分布式锁防并发 + 数据库乐观锁/行锁防覆盖。 支付记录层:唯一索引防重复插入。 兜底层:每日对账任务,对比支付渠道流水与本地支付记录,发现重复自动退款。
第五炮:核心代码示例(生产级)
@Transactional
public PayResult pay(String orderId, String payToken, PayRequest request){
// 1. 验证并消费支付Token(Redis原子操作)
Boolean consumed = redisTemplate.opsForValue()
.setIfAbsent("pay_token:" + orderId, payToken, Duration.ofSeconds(30));
if (Boolean.FALSE.equals(consumed)) {
return PayResult.fail("请勿重复支付");
}
// 2. 分布式锁(可选,用Redisson)
RLock lock = redissonClient.getLock("lock:order:" + orderId);
try {
if (!lock.tryLock(3, 10, TimeUnit.SECONDS)) {
return PayResult.fail("系统繁忙");
}
// 3. 乐观锁更新订单状态(数据库最终防线)
int updated = orderMapper.updateStatusAndVersion(orderId,
OrderStatus.PENDING, OrderStatus.PAYING, oldVersion);
if (updated == 0) {
return PayResult.fail("订单已处理");
}
// 4. 调用第三方支付(带幂等键)
ThirdPayRequest thirdReq = new ThirdPayRequest();
thirdReq.setOutTradeNo(orderId);
thirdReq.setIdempotentKey(UUID.randomUUID().toString()); // 渠道侧幂等
ThirdPayResult thirdResult = thirdPayApi.pay(thirdReq);
if (thirdResult.isSuccess()) {
// 5. 插入支付记录(唯一索引防重)
payRecordMapper.insert(new PayRecord(orderId, thirdResult.getTransactionId()));
// 6. 更新订单为已支付(状态机再次校验)
orderMapper.updateStatus(orderId, OrderStatus.PAID);
// 发送支付成功消息,异步加积分等
mqProducer.send("pay_success", orderId);
return PayResult.success();
} else {
// 失败回滚状态
orderMapper.updateStatus(orderId, OrderStatus.PENDING);
return PayResult.fail(thirdResult.getMsg());
}
} finally {
if (lock.isHeldByCurrentThread()) {
lock.unlock();
}
}
}
三、面试回答模板(直接背诵版)
“面试官,防止换端重复支付,需要构建多层防线,不能只依赖分布式锁。
第一层:前端+网关。用户进入收银台时,后端下发一次性支付Token,支付时必须携带,Redis
SET NX消费一次,防止同一终端重复提交。第二层:业务层分布式锁。对订单号加锁,保证同一订单的支付请求串行处理。但要设置合理的锁超时(比预期业务时间长),并使用Redisson看门狗自动续期。
第三层:数据库乐观锁。更新订单状态时,使用
UPDATE ... WHERE status = 'PENDING' AND version = oldVersion,如果影响行数为0说明已被处理,这是最可靠的防线。第四层:唯一索引约束。支付记录表对
(order_id, pay_channel)建唯一索引,防止重复插入支付流水。第五层:对账兜底。每日对比支付渠道流水与本地支付记录,发现重复扣款自动发起退款。
总结:Token防重复、锁防并发、数据库防覆盖、唯一约束防重插、对账防漏网。五层联控,才能保证极端场景下不重复支付。”
夜雨聆风