第十二节:订单超时问题方案实操落地

一. 简介

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) 适用场景

 定时任务的方案,适合于对时间精确度要求不高、并且业务量不是很大的场景中。如果对时间精度要求比较高,这种方案不适用。(但是一般来说,订单的到期关闭这种业务,对时间精确度要求并不高,所以定时任务也是使用的最广泛的一种方案!)

image

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  定时任务

BlockingCollection<T> 是一个线程安全的集合类,专门为 生产者 - 消费者模式 设计,内置了阻塞逻辑:
  • 当队列空时,消费者线程会被阻塞(等待元素);
  • 当队列满时(如果设置了容量上限),生产者线程会被阻塞(等待空间);
  • 支持优雅的线程协作(如通知队列完成添加、取消操作等)

 

 

 

 

 

 

 

 

 

!

  • 作       者 : Yaopengfei(姚鹏飞)
  • 博客地址 : http://www.cnblogs.com/yaopengfei/
  • 声     明1 : 如有错误,欢迎讨论,请勿谩骂^_^。
  • 声     明2 : 原创博客请在转载时保留原文链接或在文章开头加上本人博客地址,否则保留追究法律责任的权利。
 
posted @ 2025-09-07 20:02  Yaopengfei  阅读(136)  评论(1)    收藏  举报