第二节:服务幂等性 和 消息幂等性的解决方案
一. 服务幂等-防重表
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 : 原创博客请在转载时保留原文链接或在文章开头加上本人博客地址,否则保留追究法律责任的权利。