接口幂等性解决方案

什么是幂等性?

幂等性就是用户同一操作的多次发起同一请求,请求后的结果是一致的,,不会因为多次点击而产生了副作用,比如支付场景,表单的提交,微服务之间的互相调用,

出现的场景:

  1. 有时候我们在提交表单的时候,保存按钮不小心快速点了亮色,表中产生了两调重复的数据只是id不一样
  2. 我们在项目中为了为了解决接口超时问题,通常会映入重试机制,有可能第一次请求已经成功了,请求方没有及时获得结果,为了避免返回错误的结果,于是可能对改接口重试几次,这样也会产生重复的数据
  3. mq消费者在读取消息时候,有可能也会读取到重复的消息,如果处理不好,也会产生重复的数据
    这些都是幂等性问题

这类问题多发于的接口:

insert:操作,多次请求可能会产生重复数据
update:操作,比如更新条数据 update user set status=1 where id = 1 update user set status=status+1 where id=1,这种情况下多次请求,可能会导致数据错误。

解决方案

1.token机制

该方案主要分为两步:

  1. 第一次请求获取token ,这个token,保存在Redis中,设置过期时间,还要返回给页面,

  2. 第二次请求页面带上token 服务器拿到token后,在Redis中进行校验, 通过后要删除token,校验删除必须是原子操作,Redistemplate有个execute方法,可以执行lua脚本,是原子的。
    具体的步骤:

  3. 用户访问页面时:浏览器自动发起获取tokan请求;

  4. 服务端 生成token,保存到redis中,设置过期时间,然后返回给浏览器,

  5. 用户通过浏览器再次发起请求,携带改token

  6. 在redis中进行校验,如果不存在说明是第一次请求,在做后续业务操作,

  7. 如果存在,说明是重复请求,则直接返回成功
    以上方案是针对幂等设计的。
    在这里插入图片描述

2.insert 前先select

通常情况下,我们在保存数据的时候,我们为了防止产生数据重复,一般会在insert之前,先根据某一个字段(name 或者code字段)select一下数据,如果该数据已经存在,则执行update操作,如果不存在则执行insert操作
在这里插入图片描述

该方案可能是我们平时在防止产生重复数据时,使用最多方案,但是该方案并不适用于并发场景,要配合其他方案一起使用,否则同样会产生重复数据。

3.加悲观锁

首先我们来了解一些什么是悲观锁,
当我们对数据库的一条数据进行修改时,为了避免同时被他人修改,我们会给这个条数据加锁,等待我们操作完成会释放锁,其他线程才能访问,悲观锁的实现是利用了,数据库的锁机制。

悲观锁的具体实现方式:

他的实现方式都是利用数据库的锁机制,在数据库中,悲观锁的实现流程如下:

  1. 在对某一条数据修改之前,先为该条记录尝试加上排它锁(exclusive locks),
  2. 如果加锁失败,说明这条数据正在被修改,那么当前查询必须要等待或者抛出异常,具体相应的方式,根据具体的业务来实现。
  3. 如果加锁成功,我们就可以对数据进行修改,事物完成后,就会解锁
  4. 期间如果有其他的线程来修改数据或者加排他锁,都会等待或者抛出异常
    我们拿常用mysql,mysql 是使用Innodb搜索引擎(要使用悲观锁,必须关闭 MySQL 数据库的自动提交属性。因为 MySQL 默认使用 autocommit 模式),Innodb 一般默认是行锁。

以电商下单减库存为例:

//0.开始事物
begin
//1.查询出商品库存信息
select quantity from items where id = 1 for update;
//2.修改商品库存为2
update items quantity = 2 where id = 1;
//3.提交事物
commit

以上在id= 1商品修改之前先用select …for update 方式进行加锁,然后在进行修改。
上面提到,使用 select…for update 会把数据给锁住,不过需要注意一些锁的级别,MySQL InnoDB 默认行级锁。行级锁都是基于索引的,如果一条 SQL 语句用不到索引是不会使用行级锁的,会使用表级锁把整张表锁住,这点需要注意。

还有一个在支付场景中 用户A的账号余额有150元,想转出100元,正常情况下用户A的余额只剩50元。一般情况下,sql是这样的:

update user amount = amount-100 where id=123;

如果出现多次相同的请求,可能会导致用户A的余额变成负数。这种情况,用户A来可能要哭了。于此同时,系统开发人员可能也要哭了,因为这是很严重的系统bug。

为了解决这个问题,可以加悲观锁,将用户A的那行数据锁住,在同一时刻只允许一个请求获得锁,更新数据,其他的请求则等待。

通常情况下通过如下sql锁住单行数据:

select * from user id=123 for update;

具体流程如下:

在这里插入图片描述

具体步骤:

多个请求同时根据id查询用户信息。
判断余额是否不足100,如果余额不足,则直接返回余额不足。
如果余额充足,则通过for update再次查询用户信息,并且尝试获取锁。
只有第一个请求能获取到行锁,其余没有获取锁的请求,则等待下一次获取锁的机会。
第一个请求获取到锁之后,判断余额是否不足100,如果余额足够,则进行update操作。
如果余额不足,说明是重复请求,则直接返回成功。

需要特别注意的是:如果使用的是mysql数据库,存储引擎必须用innodb,因为它才支持事务。此外,这里id字段一定要是主键或者唯一索引,不然会锁住整张表。

悲观锁需要在同一个事务操作过程中锁住一行数据,如果事务耗时比较长,会造成大量的请求等待,影响接口性能。
此外,每次请求接口很难保证都有相同的返回值,所以不适合幂等性设计场景,但是在防重场景中是可以的使用的。
在这里顺便说一下,防重设计 和 幂等设计,其实是有区别的。防重设计主要为了避免产生重复数据,对接口返回没有太多要求。而幂等设计除了避免产生重复数据之外,还要求每次请求都返回一样的结果。

4. 加乐观锁

既然悲观锁有性能问题,为了提升接口性能,我们可以使用乐观锁。需要在表中增加一个timestamp或者version字段,这里以version字段为例。

在更新数据之前先查询一下数据:

select id,amount,version from user id=123;

如果数据存在,假设查到的version等于1,再使用id和version字段作为查询条件更新数据:

update user set amount=amount+100,version=version+1
where id=123 and version=1;

更新的数据同时version + 1, 然后在判断update影响的行数,如果大于0,则说明本次执行成功,如果等于0,说明本次没有让数据更新,

由于第一请求的version =1,是可以成功,操作成功后这时候version=2,这时候加入有别的线程也过来修改这条数据,一开始他拿到的版本号是1,当他更新的再去查询的时候,这时候version=2,所以会更新失败,虽然乐观锁,可以解决并发修改的的问题,但是当有高并发的时候,这个sql还是有问题的,某一个时刻只能有一个线程过来修改,这时候用户会受到大量失败的请求,给用户带来不好的体验,我们还是可以控制锁的粒度的
比如用户下单减库存

update  items quantity = quantity -1 where id =1 and quantity - 1 >0

以上通过quantity - 1 >0来控制锁的粒度,即使有大量请求来过也没有关系,只要保证减库存后quantity >0就可以了

5.加唯一索引

绝大数情况下,为了防止重复数据的产生,我们都会在表中加唯一索引,这是一个非常简单,并且有效的方案。

alter table `order` add UNIQUE KEY `un_code` (`code`);

加了唯一索引之后,第一次请求数据可以插入成功。但后面的相同请求,插入数据时会报Duplicate entry ‘002’ for key 'order.un_code异常,表示唯一索引有冲突。

虽说抛异常对数据来说没有影响,不会造成错误数据。但是为了保证接口幂等性,我们需要对该异常进行捕获,然后返回成功。

如果是java程序需要捕获:DuplicateKeyException异常,如果使用了spring框架还需要捕获:MySQLIntegrityConstraintViolationException异常。

具体流程图如下:

在这里插入图片描述

具体步骤:

用户通过浏览器发起请求,服务端收集数据。
将该数据插入mysql
判断是否执行成功,如果成功,则操作其他数据(可能还有其他的业务逻辑)。
如果执行失败,捕获唯一索引冲突异常,直接返回成功。

6.加唯一索引

有时候表中并非所有的场景都不允许产生重复的数据,只有某些特定场景才不允许。这时候,直接在表中加唯一索引,显然是不太合适的。

针对这种情况,我们可以通过建防重表来解决问题。

该表可以只包含两个字段:id 和 唯一索引,唯一索引可以是多个字段比如:name、code等组合起来的唯一标识,例如:susan_0001。

具体流程图如下:
在这里插入图片描述

具体步骤:

用户通过浏览器发起请求,服务端收集数据。
将该数据插入mysql防重表
判断是否执行成功,如果成功,则做mysql其他的数据操作(可能还有其他的业务逻辑)。
如果执行失败,捕获唯一索引冲突异常,直接返回成功。

7. 根据状态机

很多时候业务表是有状态的,比如订单表中有:1-下单、2-已支付、3-完成、4-撤销等状态。如果这些状态的值是有规律的,按照业务节点正好是从小到大,我们就能通过它来保证接口的幂等性。

假如id=123的订单状态是已支付,现在要变成完成状态。

update `order` set status=3 where id=123 and status=2;

第一次请求时,该订单的状态是已支付,值是2,所以该update语句可以正常更新数据,sql执行结果的影响行数是1,订单状态变成了3。

后面有相同的请求过来,再执行相同的sql时,由于订单状态变成了3,再用status=2作为条件,无法查询出需要更新的数据,所以最终sql执行结果的影响行数是0,即不会真正的更新数据。但为了保证接口幂等性,影响行数是0时,接口也可以直接返回成功。

具体流程图如下:
在这里插入图片描述

具体步骤:

用户通过浏览器发起请求,服务端收集数据。
根据id和当前状态作为条件,更新成下一个状态
判断操作影响行数,如果影响了1行,说明当前操作成功,可以进行其他数据操作。
如果影响了0行,说明是重复请求,直接返回成功。

主要特别注意的是,该方案仅限于要更新的表有状态字段,并且刚好要更新状态字段的这种特殊情况,并非所有场景都适用。

8.加分布式锁

其实前面介绍过的加唯一索引或者加防重表,本质是使用了数据库的分布式锁,也属于分布式锁的一种。但由于数据库分布式锁的性能不太好,我们可以改用:redis或zookeeper。

鉴于现在很多公司分布式配置中心改用apollo或nacos,已经很少用zookeeper了,我们以redis为例介绍分布式锁。

目前主要有三种方式实现redis的分布式锁:

setNx命令
set命令
Redission框架
具体流程图如下:
在这里插入图片描述

具体步骤:

用户通过浏览器发起请求,服务端会收集数据,并且生成订单号code作为唯一业务字段。
使用redis的set命令,将该订单code设置到redis中,同时设置超时时间。
判断是否设置成功,如果设置成功,说明是第一次请求,则进行数据操作。
如果设置失败,说明是重复请求,则直接返回成功。

需要特别注意的是:分布式锁一定要设置一个合理的过期时间,如果设置过短,无法有效的防止重复请求。如果设置过长,可能会浪费redis的存储空间,需要根据实际业务情况而定。

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值