一、问题本质
用户发起一笔 100 元的扣费请求,后端调用支付服务时发生网络超时。
此时有三种可能:
- 请求没有到达支付服务,扣费没有执行。
- 支付服务已经扣款成功,但响应返回途中丢失。
- 支付服务仍在处理中,尚未产生最终结果。
核心问题:网络超时不等于扣费失败。
如果直接重新扣款,可能导致重复扣费;如果直接认定失败,又可能造成订单状态与实际支付结果不一致。
因此,设计目标是:
- 避免重复扣费。
- 正确识别支付结果。
- 保证本地订单与支付结果最终一致。
- 在服务故障后能够恢复业务。
- 保证交易过程可追踪、可审计。
二、完整工程设计
整体流程:
请求入口 → 创建订单 → 幂等控制 → 调用支付服务 → 超时查询 → 有限重试 → 异步通知与补偿 → 对账与监控
1. 请求入口:建立唯一业务订单
收到请求后,先校验:
- 用户身份与订单归属。
- 扣费金额是否合法。
- 订单当前状态是否允许扣费。
- 用户是否具备相应业务权限。
然后创建唯一的业务订单号,例如 order_001。
数据库中的扣费订单表可以包含:
| 字段 | 作用 |
|---|---|
order_id | 唯一业务订单号 |
user_id | 用户标识 |
amount | 扣费金额 |
status | 当前订单状态 |
payment_id | 支付服务返回的交易号 |
idempotency_key | 幂等键 |
created_at | 创建时间 |
updated_at | 最后更新时间 |
对业务订单号建立唯一约束,避免同一业务被重复创建。
2. 设计订单状态机
不能只用成功和失败两个状态,因为网络超时后,支付结果可能暂时无法确定。
可以设计以下状态:
PENDING:待支付。PROCESSING:正在处理。SUCCESS:支付成功。FAILED:已确认失败。UNKNOWN:支付结果暂时未知,等待确认。
状态变化必须符合规则,例如:
- 待支付可以进入处理中。
- 处理中可以进入成功、明确失败或待确认。
- 待确认可以在查明结果后进入成功或明确失败。
- 已成功的订单不能重新发起扣费。
具体状态和转换规则应根据支付渠道的实际语义设计,不能把暂时性错误当成最终失败。
3. 幂等控制:防止重复扣费
为每笔业务生成唯一的幂等键,例如:
charge_order_001调用支付服务时携带这个键。
同一笔业务无论第一次调用还是后续重试,都必须使用同一个幂等键。
支付服务需要真正支持幂等,保证相同业务请求不会重复产生扣费效果。
注意:
仅在本地生成幂等键是不够的。
如果下游支付服务不支持幂等,需要确认它是否支持商户订单号去重、交易查询或其他防重机制。不能仅凭客户端传入一个 ID 就保证不会重复扣款。
4. 超时处理:先确认状态,再决定下一步
发生超时后,将调用记录为结果未知,不要直接将订单标记为失败。
接下来通过商户订单号或支付交易号查询支付服务的真实状态。
| 查询结果 | 处理方式 |
|---|---|
| 支付成功 | 更新本地订单为成功,保存交易号 |
| 明确支付失败 | 记录失败原因,按业务规则处理 |
| 仍在处理中 | 保留处理中状态,后续继续查询 |
| 无法确定结果 | 进入待确认状态,等待补偿或人工核验 |
关键原则:
先确认事实,再决定是否重试。
尤其是支付状态未知时,优先查询,而不是直接重复发起扣款。
5. 重试机制:控制次数、间隔和范围
对于网络临时故障、服务暂时不可用等可重试错误,可以使用指数退避。
例如:
- 第一次等待 1 秒。
- 第二次等待 2 秒。
- 第三次等待 4 秒。
实际间隔应结合业务时限和支付渠道要求确定,并加入随机抖动,避免大量请求同时重试。
同时设置:
- 最大重试次数。
- 最大总耗时。
- 请求超时限制。
- 并发限制。
- 必要的熔断与限流机制。
不同错误需要不同处理方式:
- 网络临时故障:可以有限重试。
- 余额不足等业务失败:不能盲目重试。
- 支付结果未知:优先查询状态。
- 超过重试上限仍无法确认:进入补偿或人工处理流程。
所有重试都必须复用同一个幂等键。
6. 异步通知与定时补偿:恢复不一致状态
仅依赖同步 HTTP 响应是不够的。
例如,支付服务已经扣款成功,但响应丢失,本地订单仍然处于处理中。
可以通过两种机制恢复。
异步通知
支付服务通知扣费结果后,后端需要:
- 验证通知签名和来源。
- 校验订单号、交易号和金额。
- 对重复通知进行幂等处理。
- 使用数据库事务更新本地订单状态。
- 记录通知处理结果。
定时补偿
定时扫描长时间处于处理中或待确认状态的订单:
- 查询支付服务的真实状态。
- 根据查询结果更新订单。
- 对暂时无法确认的订单安排后续查询。
- 超过处理阈值后触发告警或人工核验。
支付通知可能延迟或丢失,因此定时查询和对账机制仍然有必要。
7. 并发控制与数据一致性
假设两个请求同时发现同一个订单处于待支付状态,如果只在代码中查询一次状态,就可能出现两个请求同时发起扣费的问题。
可以使用数据库唯一约束、条件更新或适当的锁控制并发。
例如:
UPDATE payment_ordersSET status = 'PROCESSING'WHERE order_id = 'order_001' AND status = 'PENDING';然后检查实际更新行数。只有成功完成状态转换的请求才能继续执行后续流程。
但本地状态控制不能代替支付服务端的幂等机制。
此外,普通数据库事务只能保证本地数据库操作的一致性,不能把本地数据库和远程支付服务变成一个原子事务。因此需要依靠幂等、状态查询、异步通知和对账共同实现最终一致性。
8. 监控与审计
应记录以下信息:
- 业务订单号和幂等键。
- 请求时间、响应耗时和重试次数。
- 支付交易号及错误码。
- 每次订单状态变化。
- 异步通知和对账结果。
重点监控:
- 长时间未完成的订单。
- 支付成功但本地状态未更新的订单。
- 支付接口超时率和失败率。
- 重试次数异常升高的请求。
日志中不能记录支付密码、密钥等敏感信息。
三、完整面试口播
如果扣费请求发生网络超时,我不会直接重试,因为超时只能说明当前服务没有及时收到响应,不能证明支付服务没有执行扣费。
首先,我会校验用户身份、订单金额和订单状态,为每笔业务建立唯一的订单号,并在数据库中保存订单状态、金额和支付交易号等信息。
接下来,我会设计订单状态机,并使用唯一的幂等键调用支付服务。同一笔业务无论第一次调用还是后续重试,都使用同一个幂等键,避免重复产生扣费效果。
发生超时后,我会把结果视为未知,而不是直接判定失败。然后通过订单号查询支付服务的真实状态。如果已经成功,就更新本地订单;如果明确失败,就根据错误类型决定是否允许重试;如果仍在处理中或者无法确认,就保留待确认状态。
对于可以重试的请求,我会设置最大重试次数、退避间隔和总耗时限制。如果状态未知,则优先查询而不是重复扣款。
此外,我会接入支付异步通知,并对通知进行验签、金额校验和幂等处理。同时增加定时补偿任务,扫描长期未完成的订单并主动查询支付状态。如果始终无法确认,就告警并进入人工核验或对账流程。
在并发控制方面,我会通过数据库唯一约束和条件更新防止同一订单被多个请求同时处理。由于本地数据库和远程支付服务不能通过普通本地事务保证原子性,因此还需要通过幂等、查询、通知和对账实现最终一致性。
最后,我会记录完整的交易日志,监控长时间未完成的订单和支付状态不一致的情况。
整体思路就是:建立订单、实现幂等、查询状态、有限重试、补偿恢复、监控对账。
四、常见追问
1. 支付成功,但更新本地数据库前服务宕机了怎么办?
本地订单会停留在处理中。因为订单信息已经持久化,服务恢复后可以通过订单号查询支付结果,再将本地订单修复为成功,而不是重新扣款。
2. 两个请求同时扣同一笔订单怎么办?
通过数据库唯一约束、条件更新或锁控制并发,确保只有一个请求能够将订单从待支付状态推进到处理中。同时依靠下游幂等机制防止重复扣费。
3. 支付服务一直无法查询怎么办?
保留待确认状态,按照设定的频率继续查询并限制重试总耗时。超过阈值后告警,结合支付渠道对账或人工核验确定结果,不能无限重试,也不能擅自判定失败。
4. 为什么不能只用数据库事务?
普通数据库事务无法同时保证本地数据库和远程支付服务的原子性。远程扣费可能已经成功,但本地事务可能失败,因此需要额外的恢复机制。
5. 如果是 Agent 发起扣费呢?
Agent 只负责理解用户意图和发起业务请求。扣费是否成功必须以支付系统的真实状态为准,不能以模型回复为准。
同时,应在业务后端设置权限校验、必要的用户确认、幂等控制、执行记录和结果核验,不能让模型绕过业务规则直接完成高风险操作。
五、记忆框架
记住八个步骤:
- 建订单:每笔业务有唯一标识。
- 管状态:设计合理的订单状态机。
- 做幂等:同一业务不能重复扣费。
- 识别超时:超时不等于失败。
- 查真实结果:通过支付服务确认状态。
- 有限重试:只重试适合重试的情况。
- 补偿恢复:异步通知、定时查询和对账。
- 监控审计:异常可追踪、可恢复。
核心原则:不要根据网络错误猜测业务结果,要通过幂等、状态查询和补偿机制,确保交易不重复执行且最终状态可确认。
相关文章
- 幂等键(Idempotency Key):唯一约束、状态记录和并发控制的实现细节。
- 图生图场景:数据库表设计与异步生成流程:异步任务中的扣费与幂等处理。