核心考点:接口幂等性,同一个请求执行 N 次,业务结果和执行 1 次完全一致。
误区:只做前端按钮置灰,前端防护只能防普通用户,攻击者可以绕过前端直接发 HTTP 请求,不能作为兜底。
一、问题根源(两次点击带来的并发场景)
用户快速双击,产生两个几乎同时到达后端的支付请求:
- 请求 A、请求 B 同时查询订单状态,都查到「待支付」;
- 两个线程都进入扣款逻辑,同时调用支付渠道,造成重复扣款;
- 衍生同类问题:网关超时自动重试、第三方支付回调重复推送、MQ 消息重复消费,本质都是幂等问题。
二、多层防御体系(面试标准回答,由浅到深)
1)前端层(第一层,软防护)
点击支付按钮后,立即置灰 + loading,禁用二次点击; 缺点:可绕过,仅优化用户体验,不能保障资金安全。
2)服务端前置防护:Token 防重提交(Redis)
- 用户进入支付页,后端生成支付 Token存入 Redis,设置过期时间(如 5min);
- 前端提交支付必须带上这个 Token;
- 后端用Lua 脚本原子执行:查询 + 删除 Token;
- Token 存在:执行业务;
- Token 不存在:返回「请勿重复提交」;
坑:不能分成
get+del两条命令,并发下两个请求都 get 到存在,同时进入业务,失效。 缺点:Redis 故障时该方案失效,只能做前置挡板,不能做最终兜底。
3)核心方案:订单状态机 + 数据库乐观锁(支付系统真正兜底)
订单状态:待支付(INIT) → 支付中(PAYING) → 已支付(SUCCESS) / 支付失败(FAIL) 状态只能单向流转,禁止非法跳转。
SQL 原子更新(条件更新,原子性):
update order set status=1,version=version+1
where order_no='ORDER001' and status=0 and version=1;
- 两个并发请求进来,只有 1 条 SQL 影响行数 = 1,执行成功;
- 第二条请求 update 返回 0 行,直接拒绝,不执行扣款;
- 本质:数据库行锁 + 条件更新,是最终保障,不依赖 Redis。
也可以单独建一张幂等记录表,幂等号加唯一索引,业务和幂等记录在同一个事务写入;插入冲突则捕获异常,代表重复请求。
4)Redis 分布式锁(并发控制,辅助方案)
key = 订单号,value = 请求唯一标识,设置过期时间; 用 Lua 脚本原子加锁;业务执行完,Lua 校验 value 是自己的锁才释放。
经典坑(面试官追问重点): 锁过期时间 < 业务执行时间 → 锁提前释放,第二个请求拿到锁,双线程并发扣款; 不能单纯依靠分布式锁做幂等;分布式锁只用来削峰控并发,最终兜底一定是数据库。
5)支付渠道幂等(对接微信 / 支付宝)
调用第三方支付接口时,传入商户侧唯一支付流水号(out_trade_no)。 微信支付宝原生支持:同一个 out_trade_no 多次调用,只会扣一次钱,返回上次结果。
这是对接支付渠道必须做的一层。
6)异步回调幂等 + 对账兜底
- 支付渠道回调通知会重复推送,回调接口必须用订单 / 支付流水号做幂等校验;已处理直接返回成功,不再更新订单;
- 定时对账:每日拉取渠道账单,和本地订单流水比对,发现单边账(渠道扣款本地未更新、重复扣款)自动冲正告警。
三、面试精简回答版本(直接背)
分多层防护:
- 前端:点击后置灰按钮,仅做体验优化,不可靠;
- 服务端前置:支付页面下发 Redis Token,Lua 脚本原子校验删除,拦截重复提交;
- 数据库兜底:采用订单状态机 + 乐观锁,通过条件 update 原子修改订单状态,只有待支付订单才允许进入支付流程;也可以使用幂等表唯一索引兜底;
- 调用支付渠道时,传递唯一商户流水号,利用支付渠道自身幂等;
- 回调接口做幂等校验,再加上定时对账,作为资金最后兜底。 Redis 分布式锁可以用来控制并发,但不能作为最终幂等保障,因为存在锁超时风险,数据库才是真相源。
四、面试官连环追问(高频)
- 只靠 Redis 分布式锁行不行? 不行。锁超时、Redis 主从切换丢锁都会失效,Redis 只是缓存,数据库才是最终数据源。
- 乐观锁和悲观锁怎么选? 支付场景并发不算极高,优先乐观锁;高并发争抢严重时才考虑悲观锁
select ... for update,悲观锁会带来数据库行锁等待,性能差。 - 如果第一次扣款成功,更新订单状态时服务宕机怎么办? 第三方支付回调会重试,回调接口做幂等,恢复订单状态;对账任务发现不一致,自动补偿。
THE END





















暂无评论内容