防止异步通知重复执行业务逻辑

# 防止异步通知重复执行业务逻辑

## 一、问题的根源

在对接第三方支付(支付宝、微信支付等)时,支付结果通知通常存在****双重通道****:

```mermaid

flowchart LR

A\[用户支付完成\] --> B\[同步跳转通知\]

A --> C\[异步服务端通知\]

B --> D\[回调接口\]

C --> D

```

- **同步跳转**:支付完成后浏览器跳回商户页面,携带 `out_trade_no` 等参数

- **异步通知**:支付平台服务端向商户服务端发送 HTTP POST 通知

这两个通知****几乎同时到达**,如果不做幂等控制,业务逻辑(如续费、变更套餐、发货等)会被**重复执行****,造成严重的数据问题。

## 二、典型问题场景

以充值续费支付为例:

| 时序 | 同步通知 | 异步通知 | 问题 |

|------|---------|---------|------|

| T1 | 收到通知,查询支付宝 `TRADE_SUCCESS` | — | — |

| T2 | 调用续费方法 | — | — |

| T3 | 更新到期时间 `EndTime` | 收到通知,查询支付宝 `TRADE_SUCCESS` | :warning: 异步通知此时还未更新数据库状态 |

| T4 | 更新订单状态 `Status=1` | 调用续费方法 | :cross_mark: 第二次续费! |

| T5 | 返回成功 | 更新卡到期时间 `EndTime` | :cross_mark: 卡被续费了两次 |

如果不加控制,用户付一次钱,卡却被续了两次,数据错乱。

## 三、解决方案:状态机 + 幂等检查

### 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

// :white_check_mark: 正确:先改状态

modal.Status = 1;

modal.Update(); // ← 状态已持久化

await DoBusiness(); // ← 另一个通知进来时 Status 已经是 1

// :cross_mark: 错误:先做业务

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. **业务失败要退款**:支付成功但业务失败时,走退款流程保证资金安全