第十二节:订单超时问题方案实操落地
一. 简介
1 背景
A. 用户下单了,但是迟迟不支付,可能占用库存资源,超时需要被取消(释放库存)。
B. 用户下单了,且已经成功支付了,但订单状态一直是待支付的,下面两种情况都需要被处理。
(1) 支付宝/微信的原因,导致支付回调通知没有发送,用户订单状态迟迟无法修改。
(2) 回调通知正常,但是回调接口中业务出错了,导致用户订单状态迟迟无法修改。
2 严格流程
A. 先去支付宝/微信查询该订单是否支付→ 如果没有支付,则取消订单,释放库存→通知支付宝取消订单 (很多人不做这个操作,是不严谨的)
B. 先去支付宝/微信查询该订单是否支付→ 如果已经支付,但系统订单状态仍为待支付,则修改系统订单业务→通知支付宝已经接到回调,无需再发送了
3 关单类型
主动关单:手动点击“取消订单”按钮、定时任务关单
被动关单:手动点击“去支付”按钮的前置判断是否需要关单、进入订单详情页面前置判断是否需要关单
二. 各种方案
1 定时任务扫表
(1) 方案步骤详解
A. 给订单表的status字段加索引: 订单表大部分状态都是success,只有少数是待支付的,对于区分度不高的字段加上索引,也可以大大增加查询效率。
B. 采用分片任务的模式:生产者消费者模式,多线程读取,通常开9个线程,分别读取id为 1%......9%,的数据,可以扔到阻塞队列中去消费。
C. 配合主动关单,解决延迟问题:对于待支付的订单,会有一个主动关单的操作,比如在打开订单列表,或者查看订单详情的时候,会校验一下是否达到关单失效,如果过期了,但是定时任务还没处理,这时候会主动调用一次关单方法,进行关单
PS:补充阻塞队列:当队列为空的时候,消费者线程会等待队列变为非空;当队列满时,生产者线程会等待队列可用。
(2) 缺点
A. 延迟问题:关单时间不准确,定时任务没办法做到时间到了,立即关单。
B. 性能问题:如果表数据量过大,会带来一定的性能问题,导致关闭时间更长。
C. 对DB会造成压力:集中扫表,会使DB的IO在短时间被大量占用和消耗,可能影响正常业务。
D. 分库分表问题:订单多,就会分库分表,而对于分库分表进行全表扫描,是个很不好的方案。
(3) 适用场景
定时任务的方案,适合于对时间精确度要求不高、并且业务量不是很大的场景中。如果对时间精度要求比较高,这种方案不适用。(但是一般来说,订单的到期关闭这种业务,对时间精确度要求并不高,所以定时任务也是使用的最广泛的一种方案!)

2 基于rabbitmq的死信队列实现延迟消息
(1) 知识补充
死信:一条正常消息, 过了存活时间(ttl过期) 或 队列长度超限 或 被消费者拒绝等原因无法被消费时,就会变成 死信。
(2) 方案步骤详解
A . 声明一个普通队列A,绑定死信交换机,并且设置该队列层次的ttl ;
B. 死信队列B、死信交换机、路由key 三者绑定 (常规操作)
C. 向队列A中发送消息,但并不消费这个消息,过了ttl后,就会经过 死信交换机 → 死信队列, 然后监听死信队列B进行消费消息就行了。
总结: 普通队列A + ttl + 死信队列B + 死信交换机 == 延迟队列
(3) 缺点
A. 依赖Mq,需要两个队列才能实现延迟消息,增加了系统的复杂度。
B. 死信队列中的队头的消息一直无法消费成功,那么就会阻塞整个队列,这时候即使排在他后面的消息过期需要处理了,那么也会被一直阻塞。
3 基于rabbitmq的插件实现延迟消息
(1) 方案步骤详解
A. 安装官方插件 rabbitmq_delayed_message_exchange
B. 声明一个 x-delayed-message 类型交换机,然后和 队列 + 路由key, 三者绑定。
C. 发送消息到队列,并可以设置每条消息的ttl, 过了ttl后,消费者才能从这个队列中消费这条消息。
(2) 插件的原理
消息并不会立即进入队列,而是先把他们保存在一个基于Erlang开发的Mnesia数据库中,然后通过一个定时器去查询需要被投递的消息,ttl过期后,再把他们投递到x-delayed-message队列中
(3) 比上述MQ的优点:
A. 可以设置每条消息的ttl
B 不存在消息阻塞的问题
(4) 局限性:这个插件支持的最大延长时间是(2^32)-1 毫秒,大约49天,超过这个时间就会被立即消费
补充:为什么不建议使用MQ的方案来关单?
1 可靠性问题【重点】
A. 消息队列虽然有很多方式保证可靠性,但也不是100%不丢消息的,极端情况,会有丢失消息的风险。
B. 如果消息头被阻塞了,后面所有过期消息都会被一直阻塞,无法被消费了。
2 大量无效消息【重点】
需要把订单放到mq中,但是大部分订单会提前取消或者完成支付,这就导致了很多无效的消息。
3 资源占用和成本
A. 引入MQ增加了开发成本
B. 如上述2,大量的无效消息,增加服务器成本,特别是使用云服务提供的MQ时
三. 实操落地
1 取消订单-按钮业务
这里是用户手动点击“取消订单”按钮,主动关单,此时该订单可能已经通过定时任务关单了。
/// <summary>
/// 05-取消订单(微信平台关单)【小程序和船员系统调用】
/// </summary>
/// <param name="tradeId">交易表主键Id</param>
/// <returns></returns>
[HttpPost]
[TypeFilter(typeof(ResultSwitchLanguae))]
public async Task<IActionResult> CancelOrder(string tradeId)
{
try
{
var trade = _baseService.Entities<T_TradRecord>().Where(u => u.id == tradeId).FirstOrDefault();
if (trade == null)
{
return Json(new { status = "error1", msg = "该订单不存在", msgEng = "The order does not exist" });
}
else if (trade.trade_state == "CLOSED")
{
return Json(new { status = "error1", msg = "该订单已经取消了", msgEng = "The order has already canceled" });
}
else if (trade.trade_state == "NOTPAY") //未支付
{
//1. 进行微信平台的关单 (只有 NOTPAY:未支付、CLOSED:已关闭 这两种状态订单能调用成功)
BasePayApis _basePayApis = new();
var mchId = ConfigHelp.GetString("SenparcWeixinSetting:TenPayV3_MchId");
var dataInfo = new CloseRequestData(mchId, trade.out_trade_no);
ReturnJsonBase result = await _basePayApis.CloseOrderAsync(dataInfo);
if (result.ResultCode.Success && result.ResultCode.StateCode == "204") //关单成功
{
//2. DB层次的关单
trade.trade_state = "CLOSED";
trade.trade_state_desc = "已关闭";
int count = await _baseService.SaveChangeAsync();
}
return Json(new { status = "ok", msg = "取消成功", msgEng = "Cancel Success" });
}
else
{
return Json(new { status = "error", msg = "该订单无法取消", msgEng = "The order cannot be cancelled." });
}
}
catch (Exception ex)
{
LogUtils.Error(ex);
return Json(new { status = "error", msg = "取消失败", msgEng = "Cancel Fail" });
}
}
2 去支付-按钮业务
这里指的是用户下单后,支付的时候由于各种原因没有支付,但已经下单了,通过该按钮可以继续支付,但此时该订单可能已经通过定时任务关单了。
/// <summary>
/// 04-待支付订单重新支付--使用原先的prepay_id
/// </summary>
/// <param name="tradeId">交易表主键Id</param>
/// <returns></returns>
[HttpPost]
[TypeFilter(typeof(ResultSwitchLanguae))]
public async Task<IActionResult> ToRePay(string tradeId)
{
try
{
//系统内的逻辑,下单后超过15min,订单进行关单操作
var trade = _baseService.Entities<T_TradRecord>().Where(u => u.id == tradeId).FirstOrDefault();
if (trade == null)
{
return Json(new { status = "error1", msg = "该订单不存在", msgEng = "The order does not exited" });
}
else if (trade.trade_state == "NOTPAY") //只有未支付的订单才能继续支付 或者 关单
{
var nowTime = DateTime.Now;
TimeSpan diff = (TimeSpan)(DateTime.Now - trade.addTime);
if (diff.TotalMinutes > 15)
{
//表示超过15min,过期了,进行关单操作
//1. 进行微信平台的关单 (只有 NOTPAY:未支付、CLOSED:已关闭 这两种状态订单能调用成功)
BasePayApis _basePayApis = new();
var mchId = ConfigHelp.GetString("SenparcWeixinSetting:TenPayV3_MchId");
var dataInfo = new CloseRequestData(mchId, trade.out_trade_no);
ReturnJsonBase result = await _basePayApis.CloseOrderAsync(dataInfo);
if (result.ResultCode.Success && result.ResultCode.StateCode == "204") //关单成功
{
//2. DB层次的关单
trade.trade_state = "CLOSED";
trade.trade_state_desc = "已关闭";
int count = await _baseService.SaveChangeAsync();
}
return Json(new { status = "closed", msg = "该订单已过期", msgEng = "The Order has already expired" });
}
else
{
//表示15min以内,获取参数,进行返回
var param = JsonHelp.ToObject<JsApiUiPackage>(trade.return_params);
return Json(new { status = "ok", msg = "获取成功", msgEng = "Get Success", data = param });
}
}
else
{
return Json(new { status = "error1", msg = "该订单状态错误", msgEng = "The status of this order is incorrect" });
}
}
catch (Exception ex)
{
LogUtils.Error(ex);
return Json(new { status = "error", msg = "支付失败", msgEng = "Pay Fail" });
}
}
3 定时任务
!
- 作 者 : Yaopengfei(姚鹏飞)
- 博客地址 : http://www.cnblogs.com/yaopengfei/
- 声 明1 : 如有错误,欢迎讨论,请勿谩骂^_^。
- 声 明2 : 原创博客请在转载时保留原文链接或在文章开头加上本人博客地址,否则保留追究法律责任的权利。

浙公网安备 33010602011771号