重复请求处理与幂等性设计
以重复支付请求为例,梳理幂等性的核心目标,以及前端防重复、业务唯一标识、数据库约束、分布式锁、状态机、支付渠道幂等和异步补偿的分层设计。
1. 问题背景与常见误区
一个经典场景:用户在支付页面连续点击了两次“确认支付”。
面对这个问题,很多候选人的第一反应是“加个锁”,但这往往被视为不完整的回答。这种回答过于浅薄,忽略了系统设计的复杂性。仅仅依靠单一的分布式锁无法覆盖所有并发场景,且容易引入死锁、性能瓶颈等新问题。
正确的思路应当是从全局视角出发,构建一套包含客户端、网关、数据库及业务逻辑在内的综合防御体系。
2. 重复请求的来源分析
首先需要明确的是,重复请求并不是异常情况,而是网络与交互中的常态。后端系统不能假设用户行为是完美的单次操作,必须假设同一个请求可能被多次触发。具体的来源包括:
- 客户端交互层面:用户手抖连点两下;或者因为网络卡顿,用户以为没反应又点了一次;前端按钮未被禁用(置灰);用户打开两个浏览器窗口同时操作。
- 网络传输层面:客户端超时重试机制自动重发请求;网络不稳定导致报文丢失后的重传。
- 基础设施层面:网关层对失败请求的重试;负载均衡器的负载均衡策略导致的请求重复分发;消息队列出现重复投递的情况。
因此,系统设计的前提必须是:能够容忍并正确处理同一个请求的多次到达。
3. 核心概念:幂等性设计
解决重复请求的核心目标是实现幂等性(Idempotency)。
什么是幂等性?
幂等性的定义是:同一个请求执行一次和执行多次,最终产生的业务结果是一致的。
具体到支付场景中:
- 第一次请求成功扣款 100 元,订单状态变为“已支付”。
- 第二次相同的请求到达时,系统不应再次扣款,也不应报错说订单不存在。
- 系统应返回“已支付”或“处理中”的状态,确保没有额外的副作用产生。
“幂等的目标不是拒绝第二次请求,而是第二次请求不会产生额外副作用。”
这意味着后端需要识别出哪些是重复请求,并进行安全拦截或状态查询,而不是简单地丢弃或报错。
4. 纵深防御体系:七道防线
为了保证系统的健壮性,我们需要构建多层级的防御机制,从用户体验到底层数据保障,层层递进。
4.1 第一道防线:前端防重复
最直接的做法是在用户点击支付按钮后,立即将按钮置灰并显示加载动画,禁止再次点击。
- 作用:优化正常用户的操作体验,防止因手抖导致的简单误触。
- 局限性:只能防住正常用户,无法防止用户刷新页面后重新提交、手动调接口或使用脚本攻击。
- 结论:前端防重复仅是体验优化,不能作为后端安全的唯一保证,后端必须兜底。
4.2 第二道防线:唯一业务标识
要保证只处理一次,首先得能识别这是同一个请求。
- 做法:客户端生成一个唯一的请求号(如支付单号),每次请求都带上该标识发送给后端。
- 关键点思考:
- 谁来生成? 通常由客户端生成 UUID,或服务端生成订单号配合请求流水号。
- 如何保证唯一? 结合时间戳与随机数,或依赖数据库自增主键。
- 存储在哪里? 用于缓存去重的中间件或数据库。
- 有效期多久? 需设置合理的过期时间,防止脏数据堆积。
- 最佳实践:支付场景通常使用订单号作为业务唯一标识,再配合一个幂等号做请求级去重。
4.3 第三道防线:数据库唯一约束
最靠谱的幂等性往往落在数据库层面。
- 做法:在支付流水表等业务表中,给业务唯一号添加唯一索引。
- 流程:
- 第一次插入成功。
- 第二次插入相同唯一号的记录,数据库直接抛出冲突异常。
- 后端捕获该异常,将其转换为业务上的“已处理”状态返回,而不是直接报 500 错误。
- 优势:不依赖 Redis 等中间件是否可用;数据库层面保证强一致性;并发情况下也能兜住。
- 注意:在分库分表环境下,唯一约束的实现会更复杂,需区分插入失败和业务失败。
4.4 第四道防线:分布式锁
很多人一上来就说加 Redis 锁,但锁只是辅助手段,并非万能钥匙。
- 解决的问题:限制同一时刻同一订单的支付请求,只让一个线程进去处理。
- 潜在坑点:
- 锁力度:是锁订单号还是锁用户?范围过大影响并发,过小无效。
- 超时时间:设短了业务没处理完锁就释放了,设长了异常时容易导致死锁。
- 原子性与安全性:锁释放怎么保证是当前线程?Redis 主从切换时锁会不会丢失?
- 失效后果:锁失效后第二个请求进来了怎么办?
- 结论:分布式锁应作为辅助手段,真正的兜底依靠业务唯一标识加数据库约束。
4.5 第五道防线:状态机
支付订单是有状态的(待支付、支付中、支付成功、支付失败、已关闭)
- 做法:重复请求进来时,不要直接处理,先查询订单当前状态。
- 逻辑分支:
- 订单已是支付成功:直接返回成功,不再扣款。
- 订单是支付中:返回处理中,让客户端稍后轮询查询。
- 订单是待支付:继续走正常的支付流程。
- 订单是已关闭:返回订单已关闭,提示失败。
- 好处:将重复请求转化为合法的状态查询,避免资源浪费。
4.6 第六道防线:支付渠道的幂等
有时候后端防住了,但第三方支付渠道那边可能还没防住。
- 做法:调用第三方接口时,也要传递业务唯一号,让支付渠道帮你做幂等。
- 注意点:
- 确认支付渠道的幂等规则是什么(例如同一个支付单号只受理一次)。
- 超时后怎么查支付结果?
- 支付渠道返回未知状态怎么办?
- 后续的对账怎么处理?
4.7 第七道防线:异步与补偿
在复杂的异步支付流程中(创建支付单 -> 调用渠道 -> 接收回调 -> 更新状态 -> 通知业务),重复不仅发生在同步请求,还发生在异步回调和消息队列中。
- 措施:
- 做消息消费幂等,防止 MQ 重复投递导致逻辑重跑。
- 回调验签,确保回调请求来自可信的支付渠道。
- 定时对账,发现账务不一致进行补偿。
- 异常状态支持人工介入。
4.8 监控与告警
不能靠猜,必须靠数据说话。
- 监控指标:同一订单支付请求次数、幂等命中次数、唯一约束冲突次数、支付成功率。
- 告警策略:一旦发现同一订单短时间大量请求,或者幂等冲突异常升高,及时触发告警。
5. 总结与面试策略
面对“用户连续点了两次确认支付,你怎么保证相同的请求只被处理一次?”这类问题时,回答的逻辑应当是结构化的:
- 理解现状:承认重复请求是常态,而非异常。
- 明确目标:实现幂等性,即多次执行结果一致,无副作用。
- 分层防御:
- 前端:按钮置灰(体验)。
- 链路:唯一业务标识(识别)。
- 数据:数据库唯一约束(兜底)。
- 并发:分布式锁(辅助)。
- 状态:状态机判断(逻辑控制)。
- 外部:支付渠道配合(上下游联动)。
- 运维:异步补偿与监控告警(事后保障)。
通过这套组合拳,不仅能解决并发重复请求的问题,还能体现候选人对高可用、高可靠系统的深刻理解。记住,不是一句加个锁就完了,你要考虑重复请求的来源、幂等设计、前端、业务标识、数据库约束、锁的边界、状态机、渠道配合以及监控告警。