接口幂等性问题,对于开发人员来说是一个常见的公共问题。这里分享一些我在项目中用到过的一些方法,给有需要的同学们一个参考。
你是否遇到过以下的场景:
- 在填写form页面表单时,如果前端没做loading或者防抖操作,保存按钮时不小心快速点了两次,表中竟然产生了两条重复的数据,只是id不一样。
- 在项目中为了解决接口超时问题,通常会使用重试机制。即第一次请求接口超时了,没有获取到返回结果,为了避免直接返回错误的结果,于是会对该请求重试几次,这样也会产生重复的数据。
- 消息队列在读取消息时,有时会读取到重复消息(各种原因导致,这里不展开),如果处理不好,也会产生重复的数据。
以上举例的这些都是幂等性问题。
接口幂等性是指用户对于同一操作发起的一次请求或者多次请求的结果是一致的,不会因为多次点击而产生了副作用。
这类问题多发于接口的:
- insert操作,这种情况下多次请求,可能会产生重复数据;
- update操作,如果只是单纯的更新数据,比如:
update customer set status = 0 where id = 1
,是没有问题的。但如果如果还有计算,比如:update customer set status = status + 1 where id = 1
,这种情况下多次请求,可能会导致数据错误。
1.新增前先查询是否有(insert前先select)
通常情况下,在保存数据的接口中,我们为了防止产生重复数据,一般会在insert
前,先根据name
或mobile
字段select
一下数据。如果该数据已存在,则执行update
操作,如果不存在,才执行 insert
操作。
此可能是我们平时在防止产生重复数据时,使用最多的方案。但是该方案不适用于并发场景,在并发场景中,要配合其他方案一起使用,否则同样会产生重复数据。
2. 数据库加锁
2-1、使用悲观锁
在支付场景中,比如A的账号余额有1000元,买东西支付100元,正常情况下用户A的余额还有900元。一般情况下,sql是这样的:
update customer set money = money-100 where id=1;
如果同一时间内出现多次相同的请求,可能会导致用户A的余额不对。这种情况,对于研发来说就是严重的生产事故了。
这是主角上场了——悲观锁,将用户A的那行数据锁住,在同一时刻只允许一个请求获得锁,进行更新数据,其他的请求则不好意思,请排队慢慢等待。
通常情况下通过如下sql锁住单行数据:
select * from user id = 1 for update;
具体流程如下:
具体步骤:
- 多个请求同时根据id查询用户信息。
- 判断余额是否充足,如果余额不足,则直接返回余额不足。
- 如果余额充足,则通过for update再次查询用户信息,并且尝试获取锁。
- 只有第一个请求能获取到行锁,其余没有获取锁的请求,则等待下一次获取锁的机会。
- 第一个请求获取到锁之后,判断余额是否不足,如果余额足够,则进行update操作。
需要特别注意的是:如果使用的是mysql数据库,存储引擎必须用innodb,因为它才支持事务。此外,这里id字段一定要是主键或者唯一索引,不然会锁住整张表。
悲观锁需要在同一个事务操作过程中锁住一行数据,如果事务耗时比较长,会造成大量的请求等待,影响接口性能。
此外,每次请求接口很难保证都有相同的返回值,所以不适合幂等性设计场景,但是在防重场景中是可以的使用的。
在这里顺便说一下,防重设计和幂等设计是有区别的。防重设计主要为了避免产生重复数据,对接口返回没有太多要求。而幂等设计除了避免产生重复数据之外,还要求每次请求都返回一样的结果。
2-2、使用乐观锁
上面说到悲观锁有性能问题,为了提升接口性能,我们可以使用乐观锁。需要在表中增加一个version字段。
在更新数据之前先查询一下数据:
select id,money,version from customer where id = 1;
如果数据存在,假设查到的version
等于1
,再使用id
和version
字段作为查询条件更新数据:
update customer
set money = money - 100,
version = version + 1
where id = 1
and version = 1;
更新数据的同时version+1
,然后判断本次update
操作的影响行数,如果大于0,则说明本次更新成功,如果等于0,则说明本次更新没有让数据变更。
由于第一次请求version
等于1
是可以成功的,操作成功后version
变成2
了。这时如果并发的请求过来,再执行相同的sql:
update customer
set money = money - 100,
version = version + 1
where id = 1
and version = 1;
该update
操作不会真正更新数据,最终sql的执行结果影响行数是0
,因为version
已经变成2
了,where
中的version=1
肯定无法满足条件。但为了保证接口幂等性,接口可以直接返回成功,因为version
值已经修改了,那么前面必定已经成功过一次,后面都是重复的请求。
具体步骤:
- 先根据id查询用户信息,包含version字段
- 根据id和version字段值作为where条件的参数,更新用户信息,同时version+1
- 判断操作影响行数,如果影响1行,则说明是一次请求,可以做其他数据操作。
- 如果影响0行,说明是重复请求,则直接返回成功。
3.唯一索引
大多数情况下为了防止重复数据的产生,我们都会在表中加唯一索引,这是一个非常简单有效的方案。
alter table customer add UNIQUE KEY `uk_mobile` (`mobile`);
加了唯一索引之后,第一次请求数据可以插入成功。但后面的相同请求,插入数据时会报Duplicate entry '002' for key 'customer .uk_mobile
异常,表示唯一索引有冲突。
虽说抛异常对数据来说没有影响,不会造成错误数据。但是为了保证接口幂等性,我们需要对该异常进行捕获,然后返回成功。
这里以java程序需要捕获:DuplicateKeyException
异常,如果使用了spring
框架还需要捕获:MySQLIntegrityConstraintViolationException
异常。
具体流程图如下:
具体步骤:
- 用户发起请求,服务端收集数据;
- 插入mysql数据库;
- 判断是否执行成功,如果成功,则操作其他数据(如果有其他业务逻辑);
- 如果执行失败,捕获唯一索引冲突异常,直接返回成功。
4.防重表
有时候表中并非所有的场景都不允许产生重复的数据,只有某些特定场景才不允许。这时候,直接在表中加唯一索引就有点不太合适了。针对这种情况,我们可以通过建防重表来解决问题。
该表可以只包含两个字段:id
和 唯一索引
,唯一索引可以是多个字段比如:name、code等组合起来的唯一标识,例如:xiaoming_000001。
具体流程图如下:
具体步骤:
- 用户发起请求,服务端收集数据。
- 将该数据插入mysql防重表
- 判断是否执行成功,如果成功,则做mysql其他的数据操作(如果有其他业务逻辑)。
- 如果执行失败,捕获唯一索引冲突异常,直接返回成功。
需要特别注意的是:防重表和业务表必须在同一个数据库中,并且操作要在同一个事务中。
5.分布式锁
其实前面介绍过的加唯一索引或者加防重表,本质是使用了数据库的分布式锁,这也是分布式锁的一种。但由于数据库分布式锁的性能不太好,我们一般用redis分布式锁。
目前主要有三种方式实现redis的分布式锁:
- setNx命令
- set命令
- Redission框架
每种方案各有利弊,具体实现细节可以找其他相关的资料了解。
具体流程图如下:
具体步骤:
- 用户发起请求,服务端会收集数据,并且生成订单号id作为唯一业务字段。
- 使用redis的set命令,将该订单id设置到redis中,同时设置超时时间。
- 判断是否设置成功,如果设置成功,说明是第一次请求,则进行数据操作。
- 如果设置失败,说明是重复请求,则直接返回成功。
需要特别注意的是:分布式锁一定要设置一个合理的过期时间,如果设置过短,无法有效的防止重复请求。如果设置过长,可能会浪费redis的存储空间,需要根据实际业务情况而定。
6.使用Token
除了上述方案之外,还有最后一种使用token的方案。该方案跟之前的所有方案都有点不一样,需要两次请求才能完成一次业务操作。
- 第一次请求获取token
- 第二次请求带着这个token,完成业务操作。
具体流程图如下:
1、获取token
2、业务操作
具体步骤:
- 用户访问页面时,浏览器自动发起获取token请求。
- 服务端生成token,保存到redis中,然后返回给浏览器。
- 用户通过浏览器发起请求时,携带该token。
- 在redis中查询该token是否存在,如果不存在,说明是第一次请求,做则后续的数据操作。
- 如果存在,说明是重复请求,则直接返回成功。
- 在redis中token会在过期时间之后,被自动删除。
以上方案是针对幂等设计的。
如果是防重设计,流程图是这样:
注意:token必须是全局唯一的