# 防止异步通知重复执行业务逻辑
## 一、问题的根源
在对接第三方支付(支付宝、微信支付等)时,支付结果通知通常存在****双重通道****:
```mermaid
flowchart LR
A\[用户支付完成\] --> B\[同步跳转通知\]
A --> C\[异步服务端通知\]
B --> D\[回调接口\]
C --> D
```
- **同步跳转**:支付完成后浏览器跳回商户页面,携带 `out_trade_no` 等参数
- **异步通知**:支付平台服务端向商户服务端发送 HTTP POST 通知
这两个通知****几乎同时到达**,如果不做幂等控制,业务逻辑(如续费、变更套餐、发货等)会被**重复执行****,造成严重的数据问题。
## 二、典型问题场景
以充值续费支付为例:
| 时序 | 同步通知 | 异步通知 | 问题 |
|------|---------|---------|------|
| T1 | 收到通知,查询支付宝 `TRADE_SUCCESS` | — | — |
| T2 | 调用续费方法 | — | — |
| T3 | 更新到期时间 `EndTime` | 收到通知,查询支付宝 `TRADE_SUCCESS` |
异步通知此时还未更新数据库状态 |
| T4 | 更新订单状态 `Status=1` | 调用续费方法 |
第二次续费! |
| T5 | 返回成功 | 更新卡到期时间 `EndTime` |
卡被续费了两次 |
如果不加控制,用户付一次钱,卡却被续了两次,数据错乱。
## 三、解决方案:状态机 + 幂等检查
### 3.1 核心思路
引入****订单状态字段****作为状态机,在执行业务逻辑前先做幂等判断:
```
Status = 0 → 待支付(初始状态)
Status = 1 → 已支付成功(终态)
Status = 2 → 已取消/失败(终态)
```
### 3.2 代码实现
#### 步骤一:入口处的幂等检查
```csharp
// 幂等检查:已支付成功则直接跳转结果页,防止支付宝同步+异步通知重复执行业务逻辑
if (modal.Status == 1)
{
var successUrl = $"{Settings.Current.NotifyUrl}/PayResult" +
$"?status=success" +
$"&pId={modal.Id}" +
$"&tradeNo={modal.TradeNo}" +
$"&amount={modal.Amount}" +
$"&payTime={modal.PayTime:yyyy-MM-dd HH:mm:ss}" +
$"&msisdn={modal.Msisdns}" +
$"&message={Uri.EscapeDataString("支付成功")}";
return Redirect(successUrl);
}
```
**关键点**:
- 在****任何外部查询和业务操作之前****先判断状态
- 已成功则****直接返回缓存的结果****,绝不重复执行
#### 步骤二:原子性地更新状态
```csharp
// 查询支付平台确认支付成功
var response = PayHelper.AlipayTradeQuery(modal.Id.SafeString());
if (response.TradeStatus == “TRADE_SUCCESS”)
{
// ⚠️ 先更新订单状态为成功
modal.Status = 1;
modal.TradeNo = response.TradeNo;
modal.PayTime = DateTime.Now;
modal.Update();
// 状态更新后,再执行业务逻辑
var (flag, msg) = await BatchCardRenewal(modal.Msisdns, modal.RenewalCycle);
if (flag)
{
// 本地续期处理
CardRenewal();
}
else
{
// 业务失败 → 退款
PayHelper.AliTradeRefund(modal.TradeNo, "续期失败", modal.Amount.ToString());
}
}
```
**关键点**:
- **先改状态,再做业务**——即使业务逻辑执行较慢,另一个通知进来时状态已经是 `1`
- 状态更新要在同一个数据库事务中尽快完成
### 3.3 完整的幂等保护模式
```csharp
public async Task PayCallBackAsync(Int64 Id, String trade_no, String out_trade_no)
{
// ① 统一订单号来源
if (!out_trade_no.IsNullOrWhiteSpace())
Id = out_trade_no.ToLong();
// ② 订单存在性检查
var modal = RenewalPay.FindById(Id);
if (modal == null)
return Redirect($"{frontUrl}/PayResult?status=fail&message=支付订单不存在");
// ③ 幂等检查 —— 核心保护
if (modal.Status == 1)
return Redirect(GetSuccessUrl(modal));
// ④ 查询支付平台确认(只查一次)
var response = PayHelper.AlipayTradeQuery(modal.Id.SafeString());
if (response.TradeStatus == "TRADE_SUCCESS")
{
// ⑤ 先改状态
modal.Status = 1;
modal.TradeNo = response.TradeNo;
modal.PayTime = DateTime.Now;
modal.Update();
// ⑥ 再执行业务
var (flag, msg) = await BatchCardRenewal(modal.Msisdns, modal.RenewalCycle);
if (!flag)
{
// ⑦ 业务失败 → 退款
PayHelper.AliTradeRefund(modal.TradeNo, "续期失败", modal.Amount.ToString());
}
}
else
{
// ⑧ 支付未成功,标记失败
modal.Status = 2;
modal.Update();
}
return Redirect(GetResultUrl(modal));
}
```
## 四、方案对比
| 方案 | 实现 | 优点 | 缺点 |
|------|------|------|------|
| **状态机幂等** | 用 Status 字段控制 | 简单可靠,无需额外基础设施 | 依赖数据库状态准确性 |
| 分布式锁 | Redis `SETNX` | 跨进程安全 | 引入 Redis 依赖,锁超时需谨慎处理 |
| 消息队列去重 | MQ 消息 ID 去重 | 天然异步,削峰填谷 | 架构复杂度增加 |
| 数据库唯一约束 | 唯一索引 + `INSERT IGNORE` | 数据库级别保证 | 仅适合流水记录场景 |
**推荐方案**:对于支付回调这种场景,****状态机幂等****是最简单、最可靠的选择。
## 五、注意事项
### 5.1 状态必须先于业务更新
```csharp
//
正确:先改状态
modal.Status = 1;
modal.Update(); // ← 状态已持久化
await DoBusiness(); // ← 另一个通知进来时 Status 已经是 1
//
错误:先做业务
await DoBusiness(); // ← 耗时较长
modal.Status = 1; // ← 此时另一个通知可能已经重复执行了业务
modal.Update();
```
### 5.2 主动查询接口也需要幂等
不仅回调接口需要幂等,****用户主动查询支付结果的接口****同样需要:
```csharp
[HttpPost, Route(“queryPayOrder”)]
public async Task QueryPayOrder(Int64 pId, …)
{
var modal = RenewalPay.FindById(pId);
// 如果已经支付成功,直接返回
if (modal.Status == 1)
{
return new { Status = 1, Message = "支付成功", ... };
}
// 查询支付平台...
// 状态更新 + 业务处理...
}
```
### 5.3 订单号来源要统一
支付宝的同步跳转传 `out_trade_no`,异步通知可能传 `trade_no`,需要在入口处统一:
```csharp
if (!out_trade_no.IsNullOrWhiteSpace())
Id = out_trade_no.ToLong();
```
### 5.4 业务失败需要退款
即使状态机保证了不会重复执行,但业务逻辑本身可能失败(如续费接口超时),此时需要:
1. 保留 `Status = 1`(支付平台确实扣款成功了)
2. 调用支付平台退款接口
3. 记录退款日志
```csharp
if (!flag)
{
// 续费失败,退款
PayHelper.AliTradeRefund(modal.TradeNo, "续期失败", modal.Amount.ToString());
XTrace.WriteLine($"支付宝退款信息:{returnResponse.ToJson()}");
}
```
## 六、总结
防止异步通知重复执行的核心是****状态机 + 幂等检查****:
1. **定义清晰的状态流转**:`0(待支付) → 1(已成功) / 2(已失败)`
2. **入口处先检查状态**:已终态直接返回,不再处理
3. **先改状态再做业务**:让状态成为天然的分布式锁
4. **回调 + 查询双保险**:两个入口都需要幂等保护
5. **业务失败要退款**:支付成功但业务失败时,走退款流程保证资金安全