接口的幂等性,如何保证
最近跟朋友聊起这个话题,想深入了解下,于是学习总结,记录下来,此文章参考以下博客综合而来表示感谢:
参考:分布式系统接口幂等性
1. 接口调用存在的问题
现如今我们的系统大多拆分为分布式SOA,或者微服务,一套系统中包含了多个子系统服务,而一个子系统服务往往会去调用另一个服务,而服务调用服务无非就是使用RPC通信或者restful,既然是通信,那么就有可能在服务器处理完毕后返回结果的时候挂掉,这个时候用户端发现很久没有反应,那么就会多次点击按钮,这样请求有多次,那么处理数据的结果是否要统一呢?那是肯定的!尤其在支付场景。
2. 什么是接口幂等性
接口幂等性就是用户对于同一操作发起的一次请求或者多次请求的结果是一致的,不会因为多次点击而产生了副作用。举个最简单的例子,那就是支付,用户购买商品后支付,支付扣款成功,但是返回结果的时候网络异常,此时钱已经扣了,用户再次点击按钮,此时会进行第二次扣款,返回结果成功,用户查询余额返发现多扣钱了,流水记录也变成了两条...,这就没有保证接口的幂等性
3. 什么情况下需要保证接口的幂等性
在增删改查4个操作中,尤为注意就是增加或者修改,
A: 查询操作
查询对于结果是不会有改变的,查询一次和查询多次,在数据不变的情况下,查询结果是一样的。select是天然的幂等操作
B: 删除操作
删除一次和多次删除都是把数据删除。(注意可能返回结果不一样,删除的数据不存在,返回0,删除的数据多条,返回结果多个,在不考虑返回结果的情况下,删除操作也是具有幂等性的)
C: 更新操作
修改在大多场景下结果一样,但是如果是增量修改是需要保证幂等性的,如下例子:
把表中id为XXX的记录的A字段值设置为1,这种操作不管执行多少次都是幂等的
把表中id为XXX的记录的A字段值增加1,这种操作就不是幂等的
D: 新增操作
增加在重复提交的场景下会出现幂等性问题,如以上的支付问题
4. 那么如何设计接口才能做到幂等呢?
常见的两种实现方案: 1. 通过代码逻辑判断实现 2. 使用token机制实现 下面以支付系统为例,分别对接口的幂等性进行说明与实现
A: 通过代码逻辑判断实现接口幂等性,只能针对一些满足判断的逻辑实现,具有一定局限性
用户购买商品的订单系统与支付系统;订单系统负责记录用户的购买记录已经订单的流转状态(orderStatus),支付系统用于付款,提供如下接口,订单系统与支付系统通过分布式网络交互。
boolean pay(int accountid,BigDecimal amount) //用于付款,扣除用户的
这种情况下,支付系统已经扣款,但是订单系统因为网络原因,没有获取到确切的结果,因此订单系统需要重试。由上图可见,支付系统并没有做到接口的幂等性,订单系统第一次调用和第二次调用,用户分别被扣了两次钱,不符合幂等性原则(同一个订单,无论是调用了多少次,用户都只会扣款一次)。如果需要支持幂等性,付款接口需要修改为以下接口:
boolean pay(int orderId,int accountId,BigDecimal amount)
通过orderId来标定订单的唯一性,付款系统只要检测到订单已经支付过,则第二次调用不会扣款而会直接返回结果:
在不同的业务中不同接口需要有不同的幂等性,特别是在分布式系统中,因为网络原因而未能得到确定的结果,往往需要支持接口幂等性。
随着分布式系统及微服务的普及,因为网络原因而导致调用系统未能获取到确切的结果从而导致重试,这就需要被调用系统具有幂等性。例如上文所阐述的支付系统,针对同一个订单保证支付的幂等性,一旦订单的支付状态确定之后,以后的操作都会返回相同的结果,对用户的扣款也只会有一次。这种接口的幂等性,简化到数据层面的操作:
update userAmount set amount = amount - 'value' ,paystatus = 'paid' where orderId= 'orderid' and paystatus = 'unpay'
其中value是用户要减少的订单,paystatus代表支付状态,paid代表已经支付,unpay代表未支付,orderid是订单号。
在上文中提到的订单系统,订单具有自己的状态(orderStatus),订单状态存在一定的流转。订单首先有提交(0),付款中(1),付款成功(2),付款失败(3),简化之后其流转路径如图:
当orderStatus = 1 时,其前置状态只能是0,也就是说将orderStatus由0->1 是需要幂等性的
update Order set orderStatus = 1 where OrderId = 'orderid' and orderStatus = 0
当orderStatus 处于0,1两种状态时,对订单执行0->1 的状态流转操作应该是具有幂等性的。这时候需要在执行update操作之前检测orderStatus是否已经=1,如果已经=1则直接返回true即可。
但是如果此时orderStatus = 2,再进行订单状态0->1 时操作就无法成功,但是幂等性是针对同一个请求的,也就是针对同一个requestid保持幂等。
这时候再执行
update Order set orderStatus = 1 where OrderId = 'orderid' and orderStatus = 0
接口会返回失败,系统没有产生修改,如果再发一次,requestid是相同的,对系统同样没有产生修改。
B: 使用token机制实现接口幂等性,通用性强的实现方法
token机制实现步骤:
1. 生成全局唯一的token,token放到redis或jvm内存,token会在页面跳转时获取.存放到pageScope中,支付请求提交先获取token
2. 提交后后台校验token,执行提交逻辑,提交成功同时删除token,生成新的token更新redis ,这样当第一次提交后token更新了,页面再次提交携带的token是已删除的token后台验证会失败不让提交
token特点: 要申请,一次有效性,可以限流
注意: redis要用删除操作来判断token,删除成功代表token校验通过,如果用select+delete来校验token,存在并发问题,不建议使用
参考:接口的幂等性
使用.NET 6实现DELETE请求以及HTTP请求幂等性
系列导航及源代码
需求
先说明一下关于原本想要去更新的PATCH
请求的文章,从目前试验的情况来看,如果是按照.NET 6
的项目结构(即只使用一个Program.cs
完成程序初始化),那微软官方给出的文档目前还没有对应地更新,按照之前的方式进行JsonPatch
的配置是不行的,目前已经有人在Github微软的官方文档Repo下提了ISSUE: .NET 6: JsonPatch in ASP.NET Core web API。并且因为PATCH
的使用频率并不高,所以我暂时跳过那篇,先把进度继续往后走,看微软什么时候把这个issue解决一下我再看情况把PATCH
那一节补上。
本文我们来看最后一个常用HTTP请求类型:DELETE
。
目标
实现并验证应用正确处理DELETE
请求。并对HTTP请求的幂等性做简单的介绍。
原理与思路
经过关于Create、Update、Get的实现,对于Delete的实现我们的思路是很清晰的。我们需要创建Delete的Command及其Handler,然后在Controller中通过Mediatr发送请求即可。
实现
在Application/TodoList
下新建DeleteTodoList
文件夹,并新建DeleteTodoListCommand
:
DeleteTodoListCommand.cs
using MediatR;
using TodoList.Application.Common.Exceptions;
using TodoList.Application.Common.Interfaces;
namespace TodoList.Application.TodoLists.Commands.DeleteTodoList;
public class DeleteTodoListCommand : IRequest
{
public Guid Id { get; set; }
}
public class DeleteTodoListCommandHandler : IRequestHandler<DeleteTodoListCommand>
{
private readonly IRepository<Domain.Entities.TodoList> _repository;
public DeleteTodoListCommandHandler(IRepository<Domain.Entities.TodoList> repository)
{
_repository = repository;
}
public async Task<Unit> Handle(DeleteTodoListCommand request, CancellationToken cancellationToken)
{
var entity = await _repository.GetAsync(request.Id);
if (entity == null)
{
throw new NotFoundException(nameof(TodoList), request.Id);
}
await _repository.DeleteAsync(entity,cancellationToken);
// 对于Delete操作,演示中并不返回任何实际的对象,可以结合实际需要返回特定的对象。Unit对象在MediatR中表示Void
return Unit.Value;
}
}
在Controller中添加Delete的接口处理:
TodoListController.cs
// 省略其他...
[HttpDelete("{id:guid}")]
public async Task<ApiResponse<object>> Delete(Guid id)
{
return ApiResponse<object>.Success(await _mediator.Send(new DeleteTodoListCommand { Id = id }));
}
这里可能值得强调的是关于EntityFrameworkCore
中对于关联实体DELETE
操作的处理方式:
打开Infrastructure/Migrations
文件夹,我们可以在迁移领域实体的那次Migration生成的.Designer.cs
文件中发现这样一段配置:
// 省略其他...
modelBuilder.Entity("TodoList.Domain.Entities.TodoItem", b =>
{
b.HasOne("TodoList.Domain.Entities.TodoList", "List")
.WithMany("Items")
.HasForeignKey("ListId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("List");
});
可以看到在OnDelete
中配置的是DeleteBehavior.Cascade
行为模式,关于DeleteBehavior
,可以参考Referential Constraint Action Options。实际上总共有七种可以设置的行为模式:
DeleteBehavior.Cascade
DeleteBehavior.NoAction
DeleteBehavior.Restrict
DeleteBehavior.SetNull
DeleteBehavior.ClientCascade
DeleteBehavior.ClientNoAction
DeleteBehavior.ClientSetNull
关于这七种DeleteBehavior
,可以参考这篇博文:Entity Framework Core 关联删除,博主在其中进行了比较详细的实验和总结。
可以根据实际需要去显式地配置DeleteBehavior
。另外,习惯观察生成的Migrations文件,也是学习EFCore一些惯例或者说默认配置的很好的方法。
验证
启动Api
项目,发送DELETE
请求:
-
请求
-
响应
并且从数据库里我们以可以发现,这条TodoList
下包含的TodoItem
也被一同删除了。
总结
到此为止HTTP常用的四大请求我们已经通过几个例子讲完了,关于HEAD
请求OPTION
请求以及遗留的PATCH
请求后面会写完。下一篇文章开始我们一起学习如何使用FluentValidation
来进行请求参数校验。
关于HTTP请求幂等性的介绍
首先明确两个概念:
- 什么叫做HTTP请求是否安全:如果我们执行请求后,对应的资源实体不发生改变,我们称这个请求是安全的;
- 什么叫做HTTP请求是否幂等:对于执行请求后产生的副作用(即指如果请求不安全,则称其会产生副作用),请求执行一次和执行多次的副作用是相同的,我们称这个请求是幂等的,很显然安全的请求一定是幂等的。
了解了这两个概念后,我们直接来看这张表格,快速地对HTTP请求的安全性和幂等性有一个认识。
HTTP方法 | 是否安全? | 是否具有幂等性? |
---|---|---|
GET | 是 | 是 |
POST | 否 | 否 |
PUT | 否 | 是* |
DELETE | 否 | 是 |
OPTIONS | 是 | 是 |
HEAD | 是 | 是 |
PATCH | 否 | 否 |
鉴于PATCH方法并不常用,那么重点需要关注的主要就是POST
请求,以及一部分的PUT
请求(比如更新的字段是在当前字段值的基础上进行计算而新得出这种场景,实际就不是幂等的,但是我们一般不推荐在Update时做这种类型的操作,更推荐的是把计算逻辑前置到接口响应处理前,以整体设置值的方式去Update实体)。一般而言POST
请求是用来创建资源的,如果不采取某种方式来保证执行结果的实际幂等性,那么该请求产生的副作用将是难以控制和处理的。
如何保证接口的幂等性?
正式因为并非所有的HTTP请求(在这里我们可以泛化到任意类型的接口请求)都是幂等的,而不管是应用程序内的容错还是服务之间因为分区导致的对请求的幂等性更为严格的要求(尤其是在分布式系统中,对于分区导致的请求重试的场景),我们需要在设计和实现接口的时候,把幂等性设计考虑进来,提高接口的鲁棒性。
总体来说,实现接口的幂等性有两种思路:一种是通过代码逻辑去限制重复调用出现的副作用;第二种是通过Token唯一性来保证同一个请求不会被调用多次。
通过代码逻辑去限制副作用的实现方式有很多种:从前端/界面的层次上去人为限制请求的重复发送(比如按钮置灰禁止点击之类的),通过在数据库层面应用锁或使用唯一索引,通过在逻辑执行过程中应用锁等方式。这种方式只能针对一些通过满足某些判断进行的逻辑实现,有其局限性。
通过Token唯一性保证幂等的思路大致是这样:生成全局唯一的Token保存下来,并在前端页面获取保存,发送请求时连同Token一起发到后端,后端先进行Token校验,校验通过发送实际请求或执行逻辑,完成后删除旧Token并生成新Token,那么前端下次携带保存的旧Token来请求时,因为Token校验不通过而拒绝继续执行。这种方式就好比短信验证码,只有第一次携带这个验证码请求时会成功,后端判断第一次请求有效后就会把这个验证码置为无效,下次你就无法携带相同的验证码继续发起请求了。例子不是特别恰当,但是可以类比着进行理解。