第一节:业务幂等性介绍 和 接口幂等性的解决方案
一. 幂等性简介
1. 背景
现如今很多系统都会基于分布式或微服务思想完成对系统的架构设计。那么在这一个系统中,就会存在若干个微服务,而且服务间也会产生相互通信调用。那么既然产生了服务调用,就必然会存在服务调用延迟或失败的问题。当出现这种问题,服务端会进行重试等操作或客户端有可能会进行多次点击提交。如果这样请求多次的话,那最终处理的数据结果就一定要保证统一,如支付场景。此时就需要通过保证业务幂等性方案来完成。
2. 简介
幂等本身是一个数学概念。即f(n) = 1^n,无论n为多少,f(n)的值永远为1。在编程开发中,对于幂等的定义为:无论对某一个资源操作了多少次,其影响都应是相同的。 换句话说就是:在接口重复调用的情况下,对系统产生的影响是一样的,但是返回值允许不同,如查询。幂等性不仅仅只是一次或多次操作对资源没有产生影响,还包括第一次操作产生影响后,以后多次操作不会再产生影响。并且幂等关注的是是否对资源产生影响,而不关注结果。
(1) 以SQL为例:
select * from table where id=1。此SQL无论执行多少次,虽然结果有可能出现不同,都不会对数据产生改变,具备幂等性。
insert into table(id,name) values(1,'heima')。此SQL如果id或name有唯一性约束,多次操作只允许插入一条记录,则具备幂等性。如果不是,则不具备幂等性,多次操作会产生多条数据。
update table set score=100 where id = 1。此SQL无论执行多少次,对数据产生的影响都是相同的。具备幂等性。
update table set score=50+score where id = 1。此SQL涉及到了计算,每次操作对数据都会产生影响。不具备幂等性。
delete from table where id = 1。此SQL多次操作,产生的结果相同,具备幂等性。
(2). 幂等性设计主要从两个维度进行考虑:空间、时间。
空间:定义了幂等的范围,如生成订单的话,不允许出现重复下单。
时间:定义幂等的有效期。有些业务需要永久性保证幂等,如下单、支付等。而部分业务只要保证一段时间幂等即可。
同时对于幂等的使用一般都会伴随着出现锁的概念,用于解决并发安全问题。
3. 业务场景
在业务开发与分布式系统设计中,幂等性是一个非常重要的概念,有非常多的场景需要考虑幂等性的问题,尤其对于现在的分布式系统,经常性的考虑重试、重发等操作,一旦产生这些操作,则必须要考虑幂等性问题。以交易系统、支付系统等尤其明显,如:
当用户购物进行下单操作,用户操作多次,但订单系统对于本次操作只能产生一个订单。
当用户对订单进行付款,支付系统不管出现什么问题,应该只对用户扣一次款。
当支付成功对库存扣减时,库存系统对订单中商品的库存数量也只能扣减一次。
当对商品进行发货时,也需保证物流系统有且只能发一次货。
在电商系统中还有非常多的场景需要保证幂等性。但是一旦考虑幂等后,服务逻辑务必会变的更加复杂。因此是否要考虑幂等,需要根据具体业务场景具体分析。而且在实现幂等时,还会把并行执行的功能改为串行化,降低了执行效率。
此处以下单减库存为例,当用户生成订单成功后,会对订单中商品进行扣减库存。 订单服务会调用库存服务进行库存扣减。库存服务会完成具体扣减实现。
现在对于功能调用的设计,有可能出现调用超时,因为出现如网络抖动,虽然库存服务执行成功了,但结果并没有在超时时间内返回,则订单服务也会进行重试。那就会出现问题,stock对于之前的执行已经成功了,只是结果没有按时返回。而订单服务又重新发起请求对商品进行库存扣减。 此时出现库存扣减两次的问题。 对于这种问题,就需要通过幂等性进行结果。
二. 接口幂等性-解决方案
1. 前端防重
通过前端防重保证幂等是最简单的实现方式,前端相关属性和JS代码即可完成设置。
常用的方案:点击按钮,发出请求,此时按钮变为不可点的状态,只有拿到返回结果后,才可以继续点击。(该方案只能一定程度上环节一下)
弊端:比如可以刷新一下页面,或者重新进入,就可以继续点击了。
2. PRG模式
PRG模式即POST-REDIRECT-GET。当用户进行表单提交时(不等拿到返回值),会重定向到另外一个提交成功页面,而不是停留在原先的表单页面。这样就避免了用户刷新导致重复提交。同时防止了通过浏览器按钮前进/后退导致表单重复提交。是一种比较常见的前端防重策略。
三. 基于token的通用解决方案
1. 整体说明
通过token机制来保证幂等是一种非常常见的解决方案,同时也适合绝大部分场景。该方案需要前后端进行一定程度的交互来完成。
1)服务端提供获取token接口,供客户端进行使用。服务端生成token后,如果当前为分布式架构,将token存放于redis中,如果是单体架构,可以保存在服务端缓存中。
2)当客户端获取到token后,会携带着token发起请求。
3)服务端接收到客户端请求后,首先会判断该token在redis中是否存在。
A. 如果不存在,代表当前请求是重复请求,直接向客户端返回对应标识。
B. 如果存在,则涉及到先删除token,还是先执行业务的问题,详见下面的分析:
重点剖析两种情况:
(1). 先执行业务,再删token。
A. 如果采用的是判断token存不存在的模式,先执行业务,再删token,会导致执行业务成功 ,但还没有删除token这个空隙,进来的请求也被认为是正常的请求(携带token的并发请求),这样就出错了。(也有解决方案:加线程锁)【不推荐!!】
B. 可以利用redis单线程和原子自增性质,默认存入的redis的token自增为1,后续请求只有value为2的时候,才执行业务(其它值均认为是重复请求),这样就可以先执行业务,后等待token自动过期,不要手动删除!!【推荐,该方案同样要注意,如果业务执行失败了,需要告诉客户端,重新执行下单流程!!】
(2). 先删token,再执行业务;【推荐】
这里还是采用判断token存不存在的模式,先删除token,那么后面的重复请求都不会通过校验,保证了幂等性。 但也存在一个问题,就是token删除成功了,但是后面的业务执行失败了,那么后面的重试请求都无法通过校验了。
这个问题,我们要这么来看待: 首先一个token只能代表一次请求,如果后面的业务执行失败了,应该通知客户端,重新获取token,重新走一遍上述流程。
注:该方案虽然是判断token是否存在的模式,但实际代码中并不是 if(exist(token))这种, 而是 result = xx.delete(key),直接删除,看返回值。
总结:
但是无论先删token还是后删token,都会有一个相同的问题。每次业务请求都回产生一个额外的请求去获取token。但是,业务失败或超时,在生产环境下,一万个里最多也就十个左右会失败,那为了这十来个请求,让其他九千九百多个请求都产生额外请求,就有一些得不偿失了。虽然redis性能好,但是这也是一种资源的浪费。(所以可以看一下下面 PageId+Redis的方案)
2. 实操1-先删token
(PS: 这里主要是解决幂等性,并不处理高并发问题,所以此处包括后面的位置,库存都是直接扣减,并没有引入MQ等方案!!)
(1). 获取token
/// <summary>
/// token颁发方案1
/// 单纯的存入key-value集合
/// </summary>
[HttpPost]
public IActionResult GetToken1()
{
var token = Guid.NewGuid().ToString("N");
//仅仅存入即可,value值无关紧要
RedisHelper.Set(token, "xxxx", 60 * 10); //设置10分钟过期,防止产生冗余数据
return Ok(new { status = "ok", data = token });
(2). 进行下单
/// <summary>
/// 幂等性解决方案1
/// 先删token→后执行业务
/// </summary>
/// <param name="token">校验幂等性</param>
/// <param name="orderInfo">订单信息(这里仅仅演示)</param>
/// <returns></returns>
[HttpGet]
public IActionResult Mdx_Way1(string token, string orderInfo = "mate40")
{
//1.幂等性校验
/*
当key存在,删除成功,返回1
当key不存在,返回0
*/
var num = RedisHelper.Del(token);
if (num == 0)
{
return Ok(new { status = "error", msg = "重复请求,下单失败", data = "" });
}
//2. 下单业务(插入订单,扣减库存)
try
{
//这里仅测试扣减库存
//var stock = db.Set<T_Stock>().Where(u => u.productId == "10001").FirstOrDefault();
//stock.productStock--;
//db.SaveChanges();
FormattableString sql1 = $"update T_Stock set productStock=productStock-1 where productId=10001";
int count2 = db.Database.ExecuteSqlInterpolated(sql1);
return Ok(new { status = "ok", msg = "下单成功", data = count2 });
}
catch (Exception)
{
return Ok(new { status = "error", msg = "业务执行失败,需要重新走下单流程", data = "" });
}
}
3. 实操2-先删除执行业务
(1). 获取token
/// <summary>
/// token颁发方案2
/// 利用原子性 和 自增性
/// </summary>
[HttpPost]
public IActionResult GetToken2()
{
var token = Guid.NewGuid().ToString("N");
//自增性
RedisHelper.IncrBy(token); // 此时该token对应的value为1
RedisHelper.Expire(token, 60 * 10); //设置10分钟过期,防止产生冗余数据
return Ok(new { status = "ok", data = token });
}
(2). 进行下单
/// <summary>
/// 02-幂等性解决方案2
/// 先执行业务→token自动过期即可【获取token的时候设置过期时间】
/// </summary>
/// <param name="token">校验幂等性</param>
/// <param name="orderInfo">订单信息(这里仅仅演示)</param>
/// <returns></returns>
[HttpGet]
public IActionResult Mdx_Way2(string token, string orderInfo = "mate40")
{
//1.幂等性校验
/*
获取token的时候,value是1,所以只有这里value=2才是正常请求,
其余均为非法请求
*/
if (RedisHelper.IncrBy(token) != 2)
{
return Ok(new { status = "error", msg = "重复请求,下单失败", data = "" });
}
//2. 下单业务(插入订单,扣减库存)
try
{
//这里仅测试扣减库存
FormattableString sql1 = $"update T_Stock set productStock=productStock-1 where productId=10001";
int count2 = db.Database.ExecuteSqlInterpolated(sql1);
return Ok(new { status = "ok", msg = "下单成功", data = count2 });
}
catch (Exception)
{
return Ok(new { status = "error2", msg = "下单失败,客户端需重新下单", data = "" });
}
}
4. 实操3-将幂等性校验抽离为特性
(以先删token的模式为例,其他的封装详见code了)
(1). 校验抽离封装
/// <summary>
/// 幂等性解决方案1--校验
/// </summary>
public class EnsureMdx1 : ActionFilterAttribute
{
public override void OnActionExecuting(ActionExecutingContext context)
{
var actionContext = context.HttpContext;
var token = actionContext.Request.Query["token"].ToString();
//var token2 = actionContext.Request.Headers["token"].ToString();
if (string.IsNullOrEmpty(token))
{
context.Result = new ContentResult() { StatusCode = 401, Content = "非法请求,参数为空" };
return;
}
//1.幂等性校验
/*
当key存在,删除成功,返回1
当key不存在,返回0
*/
var num = RedisHelper.Del(token);
if (num == 0)
{
context.Result = new ContentResult() { StatusCode = 401, Content = "重复请求,下单失败" };
return;
}
}
}
(2). 下单
/// <summary>
/// 幂等性解决方案1
/// 先删token→后执行业务
/// </summary>
/// <param name="token">校验幂等性</param>
/// <param name="orderInfo">订单信息(这里仅仅演示)</param>
/// <returns></returns>
[HttpGet]
[EnsureMdx1]
public IActionResult Mdx_Way1(string token, string orderInfo = "mate40")
{
//2. 下单业务(插入订单,扣减库存)
try
{
FormattableString sql1 = $"update T_Stock set productStock=productStock-1 where productId=10001";
int count2 = db.Database.ExecuteSqlInterpolated(sql1);
return Ok(new { status = "ok", msg = "下单成功", data = count2 });
}
catch (Exception)
{
return Ok(new { status = "error", msg = "业务执行失败,需要重新走下单流程", data = "" });
}
}
四. 基于pageId+redis的解决方案
1. 方案说明
上述的方案方案虽然解决了幂等性问题,但是都需要进行两次请求,有点浪费资源,我们想能不能改成一次请求呢,把上面所谓的token放到客户端来生成?以下单业务为例,分析该方案:
(1). 在下单的前端商品页面生成一个pageId,规则为:pageId=goodId+当前时间戳 (即该pageId只能用于下单一次)
(2). 携带该pageId进行进行请求下单接口, 利用redis中的string类型的数据结构,通过利用redis的单线程和原型性的特点,对该pageId进行自增,拿到返回结果。
(3). 只有返回值为1,表示是正常的请求,准许执行业务;如果是 >1的值,则表示是重复请求,不许通过,从而保证了幂等性。
(4). 可以对该pageId的合法性进行校验 (视情况而定,详见后面分析)
深度补充剖析:
1. 是不是随便模拟一个pageId就能进行使用呢?
首先接口端其它的校验机制还是存在的,比如jwt校验;其次当pageId幂等性校验通过后,可以对该pageId的合法性进行校验,比如可以对其中的goodId校验,判断该goodId是否存在,这就需要事先把所有的goodId存放到redis的set结构 或 hash结构 或着 布隆过滤器。
2. 该pageId是否需要手动删除呢?
不需要,当返回值为1的时候,下单成功后,给其设置一个过期时间即可,让他自动删除。
业务流程图:
2. 实操
(1). 下单接口
/// <summary>
/// 幂等性解决方案3
/// 客户端生产pageId作为key,利用自增性
/// 执行业务→不删token!!!→设置过期时间,自动删除
/// </summary>
/// <param name="pageId">页面id,这里作为key</param>
/// <param name="orderInfo">订单信息(这里仅仅演示)</param>
/// <returns></returns>
[HttpGet]
public IActionResult Mdx_Way3(string pageId, string orderInfo = "mate40")
{
//1.幂等性校验
/*
只有value为1的时候才是正常请求
其余均为非法请求
*/
var num = RedisHelper.IncrBy(pageId);
if (num != 1)
{
return Ok(new { status = "error", msg = "重复请求,下单失败", data = "" });
}
//2. 可以对pageId的合法性进行校验
//比如可以对其中的goodId校验,判断该goodId是否存在,
//这就需要事先把所有的goodId存放到redis的set结构 或 hash结构 或着 布隆过滤器
//3. 下单业务(插入订单,扣减库存)
try
{
//这里仅测试扣减库存
FormattableString sql1 = $"update T_Stock set productStock=productStock-1 where productId=10001";
int count2 = db.Database.ExecuteSqlInterpolated(sql1);
//设置个10min过期时间,自动删除
RedisHelper.Expire(pageId, 60 * 10);
return Ok(new { status = "ok", msg = "下单成功", data = count2 });
}
catch (Exception)
{
return Ok(new { status = "error2", msg = "下单失败,客户端需重新下单", data = "" });
}
}
(2). jmeter测试
!
- 作 者 : Yaopengfei(姚鹏飞)
- 博客地址 : http://www.cnblogs.com/yaopengfei/
- 声 明1 : 如有错误,欢迎讨论,请勿谩骂^_^。
- 声 明2 : 原创博客请在转载时保留原文链接或在文章开头加上本人博客地址,否则保留追究法律责任的权利。