第二节:服务幂等性 和 消息幂等性的解决方案

一. 服务幂等-防重表

1. 方案说明

  对于防止数据重复提交,还有一种解决方案就是通过防重表实现。防重表的实现思路也非常简单。首先创建一张表作为防重表(T_PreventSame),同时在该表中建立一个或多个字段的唯一索引作为防重字段(这里将id设置为主键索引),用于保证并发情况下,数据只有一条。在向业务表中插入数据之前先向防重表插入,如果插入失败则表示是重复数据。

2. 实操

 需要客户端生成preventId,进行传递。(解决的是幂等性问题,并不是要解决高并发问题)

 /// <summary>
 /// 01-防重表
 /// </summary>
 /// <param name="preventId">防重Id,需要客户端生成</param>
 /// <returns></returns>
 [HttpPost]
 public IActionResult Test1(string preventId)
 {
     try
     {
         int count1 = 0;
         try
         {
             //1.向防重表中插入数据
             FormattableString sql1 = $@"insert into T_PreventSame(id,message) values({preventId},'xxx')";
             count1 = db.Database.ExecuteSqlInterpolated(sql1);
         }
         catch (Exception ex)
         {
             return Json(new { status = "error", msg = "重复请求", data = ex.Message });
         }

         //2.扣减库存
         FormattableString sql2 = $@"update T_Stock set productStock=productStock-1 where productId=10001";
         int count2 = db.Database.ExecuteSqlInterpolated(sql2);

         return Json(new { status = "ok", msg = "成功了", data = count1 + count2 });
     }
     catch (Exception ex)
     {
         return Json(new { status = "error", msg = "扣减库存业务失败", data = ex.Message });
     }
 }

 

二. 服务幂等-select+insert防重

1. 方案说明

 对于一些后台系统,并发量并不高的情况下,对于幂等的实现非常简单,通过select+insert思想即可完成幂等控制。

 在业务执行前,先判断是否已经操作过,如果没有则执行,否则判断为重复操作。

   局限性:适用于并发不大,且只能用于单表。

2. 实操

 前提:订单的相关信息需要客户端传递过来。

/// <summary>
/// 02-select+insert方案
/// 前提:订单的相关信息需要客户端传递过来
/// 局限性:仅适用于单表,不能用于分库分表
/// </summary>
/// <returns></returns>
[HttpPost]
public IActionResult Test2(string id, string productName, int price)
{
    try
    {
        //1. 执行select校验业务
        int count = db.Set<T_Order>().Count(u => u.id == id);
        if (count > 0) { return Json(new { status = "error", msg = "重复下单" }); }

        //2.执行插入业务
        T_Order order = new()
        {
            id = id,
            productName = productName,
            price = price,
            addTime = DateTime.Now,
            delflag = 0
        };
        db.Add(order);
        db.SaveChanges();

        return Json(new { status = "ok", msg = "成功了" });
    }
    catch (Exception)
    {
        return Json(new { status = "error", msg = "业务执行失败" });
    }
}

 

三. 服务幂等-乐观锁

1. 基于版本号实现

 乐观锁是基于数据库完成分布式锁的一种实现,实现的方式有两种:基于版本号、基于条件。但是实现思想都是基于行锁思想来实现的。(适用于SQLServer、MySQL)

局限性:需要每次事先知道这条数据对应的版本号,当并发请求时,只有一个人能成功(某些情况下,不合理)。

实操:


    /// <summary>
    /// 03-乐观锁(基于版本号)
    /// 前提:需要事先获取到版本号
    /// 效果:并发情况下,只有一个人能扣减库存成功
    /// </summary>
    /// <param name="version">版本号</param>
    /// <returns></returns>
    [HttpPost]
    public IActionResult Test3(int version)
    {

        try
        {
            FormattableString sql1 = $@"update T_Stock set productStock=productStock-1,version=version+1 
                                                  where productId=10001 and version={version}";
            int count = db.Database.ExecuteSqlInterpolated(sql1);
            if (count == 0)
            {
                return Json(new { status = "error", msg = "重复下单" });
            }

            return Json(new { status = "ok", msg = "成功了", data = count });
        }
        catch (Exception ex)
        {
            return Json(new { status = "error", msg = "扣减库存业务失败", data = ex.Message });
        }
    }

 

2. 基于业务实现【解决超卖】

  通过版本号控制是一种非常常见的方式,适合于大多数场景。但现在库存扣减的场景来说,通过版本号控制就是多人并发访问购买时,查询时显示可以购买,但最终只有一个人能成功,这也是不可以的。其实最终只要商品库存不发生超卖就可以。那此时就可以通过条件来进行控制。【该方案并没有解决幂等性,而是解决了超卖问题】

/// <summary>
/// 04-乐观锁(基于条件)
/// 效果:最终只要商品库存不发生超卖就可以
/// </summary>
/// <param name="num">下单商品数量</param>
/// <returns></returns>
[HttpPost]
public IActionResult Test4(int num)
{
    try
    {
        FormattableString sql = $@"update T_Stock set productStock=productStock-{num}
                               where productId=10001 and productStock-{num}>0";
        int count = db.Database.ExecuteSqlInterpolated(sql);
        return Json(new { status = "ok", msg = "成功了", data = count });

    }
    catch (Exception ex)
    {
        return Json(new { status = "error", msg = "扣减库存业务失败", data = ex.Message });
    }
} 

 

 

四. 服务幂等-redis分布式锁

 

详见之前的文章:https://www.cnblogs.com/yaopengfei/p/14780809.html

 

 

 

五. 消息幂等

1. 背景

 消息队列的消息幂等性,主要是由MQ重试机制引起的。因为消息生产者将消息发送到MQ-Server后,MQ-Server会将消息推送到具体的消息消费者。假设由于网络抖动或出现异常时MQ-Server根据重试机制就会将消息重新向消息消费者推送,造成消息消费者多次收到相同消息,造成数据不一致。

 在RabbitMQ中,消息重试机制是默认开启的,但只会在consumer出现异常时,才会重复推送。在使用中,异常的出现有可能是由于消费方又去调用第三方接口,由于网络抖动而造成异常,但是这个异常有可能是暂时的。所以当消费者出现异常,可以让其重试几次,如果重试几次后,仍然有异常,则需要进行数据补偿。

数据补偿方案:当重试多次后仍然出现异常,则让此条消息进入死信队列,最终进入到数据库中,接着设置定时job查询这些数据,进行手动补偿。

2. 解决方案

   在 .Net技术栈中,可以采用CAP框架来解决这个问题。

   详见:https://www.cnblogs.com/yaopengfei/p/13763500.html                https://www.cnblogs.com/yaopengfei/p/13776361.html

PS:补充CAP框架中的重试机制

 默认情况下,失败了,快速重试3次,然后4min中后,每隔FailedRetryInterval(自己配置),重试1次,总的重试次数默认为50次。

(1).  单实例部署的情况

    重试机制,快速重试3次,也是依次进行的,比如第一次重试成功了,后续2次将不再执行。

借助redis简单测试一下:

   /// <summary>
   /// 接受消息2--模拟重试场景
   /// 默认情况下,失败了,快速重试3次,然后4min中后,每隔FailedRetryInterval(自己配置),重试1次,
   /// 这里测试的就是这3次的幂等性问题
   /// 经测试:第一次成功后,则不再进行2,3次, 这3次重试,也是按照顺序一次进行的
   /// </summary>
   /// <param name="num">商品数量</param>
   /// <returns></returns>
   [NonAction]
   [CapSubscribe("putOrder2")]
   public IActionResult Receive2(int num)
   {
       if (RedisHelper.IncrBy("mdx") != 10)
       {
           throw new Exception("模拟消费者报错了");
       }

       FormattableString sql1 = $"update T_Stock set productStock=productStock-{num} where productId=10001";
       int count2 = db.Database.ExecuteSqlInterpolated(sql1);

       return Json(new { status = "ok", msg = "下单成功", data = count2 });
   }

 

(2). 多实例部署的情况

 对CAP比较熟悉的用户知道,CAP内部有一个重试的线程默认每隔1分钟来读取存储的消息用于对发送或消费失败的消息进行重试,单个实例没有什么问题,那么在启用多个实例的场景下会有一定几率出现并发读的情况,这就会导致消息被重复发送或消费。过去我们要求消费者对关键消息进行幂等性保证来避免负面影响,现在我们提供了一种方式来避免这种情况发生。

 在 7.1.0 版本中,我们新增了一个配置项 UseStorageLock 来支持配置基于数据库的分布式锁,这样可以避免多实例并发读的问题,并且对异常场景的处理也进行了考虑。

注意:在开启 UseStorageLock 后,系统将会生成一个 cap.lock 的数据库表,此表用于通过数据库来实现分布式锁。

详见:CAP 7.1 版本发布通告 - Savorboard - 博客园 (cnblogs.com)

(3).  CAP框架官方对幂等性的说明

  (存在很多特殊情况会引发幂等性问题,详见官网介绍)

    https://cap.dotnetcore.xyz/user-guide/zh/cap/idempotence/ 

实操:

   本质还是利用的是前面接口幂等性的方案,前端需要传递pageId,配合redis实现。

参数

/// <summary>
/// 使用record类型声明实体
/// </summary>
/// <param name="num">商品数量</param>
/// <param name="pageId">用来处理幂等性的ID</param>
public record PbModel3(int num, string pageId);

发送者

 /// <summary>
 /// 发布消息3
 /// </summary>
 /// <param name="md3">接受参数的实体</param>
 /// <returns></returns>
 [HttpPost]
 public IActionResult Publish3(PbModel3 md3)
 {
     capBus.Publish("putOrder3", md3);  //发送消息

     return Json(new { status = "ok", msg = "发送成功", data = md3.num });
 }

接收者

 (业务失败,需要抛异常,走重试机制,同时滞空pageId)

/// <summary>
/// 接受消息3
/// 本质:采用的是接口幂等性方案
/// </summary>
/// <param name="md3">接受参数的实体</param>
/// <returns></returns>
[NonAction]
[CapSubscribe("putOrder3")]
public IActionResult Receive3(PbModel3 md3)
{
    //1.幂等性校验
    /*
        只有value为1的时候才是正常请求
        其余均为非法请求
     */
    var count = RedisHelper.IncrBy(md3.pageId);
    if (count != 1)
    {
        return Json(new { status = "error", msg = "重复请求", data = "" });
    }

    //2. 下单业务(插入订单,扣减库存)
    try
    {
        //这里仅测试扣减库存
        FormattableString sql2 = $"update T_Stock set productStock=productStock-{md3.num} where productId=10001";
        int count2 = db.Database.ExecuteSqlInterpolated(sql2);

        //3. 业务执行成功后,设置个10min过期时间,自动删除即可
        RedisHelper.Expire(md3.pageId, 60 * 10);

        return Ok(new { status = "ok", msg = "下单成功", data = count2 });
    }
    catch (Exception)
    {
        RedisHelper.Set(md3.pageId, 0);  //重置为0,便于后续的重试请求可以正常访问
        throw new Exception("业务执行失败,需要重试了");
    }
}

测试:

    模拟1000个请求,只有扣减了一个库存。

 

 

 

 

 

 

 

 

 

 

!

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