接口幂等性
一、简介
什么是接口幂等性?首先看看幂等性的概念:幂等性原本是数学上的概念,用在接口上就可以理解为:同一个接口,多次发出同一个请求,必须保证操作只执行一次。调用接口发生异常并且重复尝试时,总是会造成系统所无法承受的损失,所以必须阻止这种现象的发生。
当出现消费者对某条消息重复消费的情况时,重复消费的结果与消费一次的结果是相同的,并且多次消费并未对业务系统产生任何负面影响,那么这个消费者的处理过程就是幂等的。
例如,在支付场景下,消费者消费扣款消息,对一笔订单执行扣款操作,扣款金额为100元。如果因网络不稳定等原因导致扣款消息重复投递,消费者重复消费了该扣款消息,但最终的业务结果是只扣款一次,扣费100元,且用户的扣款记录中对应的订单只有一条扣款流水,不会多次扣除费用。那么这次扣款操作是符合要求的,整个消费过程实现了消费幂等。
二、产生背景
那么,什么情况下会产生接口幂等性的问题呢?
- 网络波动, 可能会引起重复请求
- 用户重复操作,用户在操作时候可能会无意触发多次下单交易,甚至没有响应而有意触发多次交易应用
- 使用了失效或超时重试机制(Nginx重试、RPC重试或业务层重试等)
- 页面重复刷新
- 使用浏览器后退按钮重复之前的操作,导致重复提交表单
- 使用浏览器历史记录重复提交表单
- 浏览器重复的HTTP请求
- 定时任务重复执行
- 用户双击提交按钮
mq
消费者在读取消息时,重复消费消息
这类问题多发于接口的:
insert
操作,这种情况下多次请求,可能会产生重复数据。update
操作,如果只是单纯的更新数据,比如:update user set status=1 where id=1
,是没有问题的。如果还有计算,比如:update user set status=status+1 where id=1
,这种情况下多次请求,可能会导致数据错误。
三、解决方案
解决办法分为两个方向,一个方向是客户端防止重复调用,一个是服务端进行校验。当然,客户端防止重复提交并不是绝对可靠的,优点是实现起来比较简单。
3.1 使用Post/Redirect/Get模式
在提交后执行页面重定向,这就是所谓的Post-Redirect—Get(PRG)模式,简单来说就是当用户提交连表单后,跳转到一个重定向的信息页面,这样就避免用户按F5刷新导致的重复提交,而且也不会出现浏览器表单重复提交的警告,也能消除按浏览器前进和后退导致同样重复提交的问题。
3.2 按钮只可操作一次
一般是提交后把按钮置灰或loding状态,消除用户因为重复点击而产生的重复记录,比如添加操作,由于点击两次而产生两条记录。
3.3 在session存放特殊标志
在服务端,生成一个唯一的标识符,将它存入session,同时前端获取这个标识符的值将它写入表单的隐藏中,用于用户输入信息后点击一起提交,在服务器端,获取表单中隐藏字段的值,与session中的唯一标识符比较,相等说明是首次提交,就处理本次请求,然后将session中的唯一标识符移除,不相等则表示是重复提交,不再做处理。
3.4 insert前先select
insert or update or delete + select
通常情况下,在保存数据的接口中,我们为了防止产生重复数据,一般会在insert
前,先根据name
或code
字段select
一下数据。如果该数据已存在,则执行update
操作,如果不存在,才执行insert
操作。
该方案可能是我们平时在防止产生重复数据时,使用最多的方案。但是该方案不适用于并发场景,在并发场景中,要配合其他方案一起使用,否则同样会产生重复数据。我在这里提一下,是为了避免大家踩坑。
该方案就是操作之前先查询一下,符合要求再插入,该方案在没有并发的系统中可以解决幂等问题,在单JVM有并发的时候可以用JVM加锁来保证幂等性,在分布式环境它是无法保证幂等性,可以使用分布式来保证。
3.5 加悲观锁
在支付场景中,用户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字段一定要是主键或者唯一索引,不然会锁住整张表。
悲观锁需要在同一个事务操作过程中锁住一行数据,如果事务耗时比较长,会造成大量的请求等待,影响接口性能。
此外,每次请求接口很难保证都有相同的返回值,所以不适合幂等性设计场景,但是在防重场景中是可以的使用的。
在这里顺便说一下,防重设计
和 幂等设计
,其实是有区别的。防重设计主要为了避免产生重复数据,对接口返回没有太多要求。而幂等设计除了避免产生重复数据之外,还要求每次请求都返回一样的结果。
3.6 加乐观锁
既然悲观锁有性能问题,为了提升接口性能,我们可以使用乐观锁。需要在表中增加一个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
了。这时如果并发的请求过来,再执行相同的sql:
update user set amount = amount + 100,version = version + 1 where id = 123 and version = 1;
该update
操作不会真正更新数据,最终sql的执行结果影响行数是0
,因为version
已经变成2
了,where
中的version=1
肯定无法满足条件。但为了保证接口幂等性,接口可以直接返回成功,因为version
值已经修改了,那么前面必定已经成功过一次,后面都是重复的请求。具体流程如下:
具体步骤:
- 先根据id查询用户信息,包含version字段
- 根据id和version字段值作为where条件的参数,更新用户信息,同时version+1
- 判断操作影响行数,如果影响1行,则说明是一次请求,可以做其他数据操作。
- 如果影响0行,说明是重复请求,则直接返回成功。
3.7 加唯一索引
绝大数情况下,为了防止重复数据的产生,我们都会在表中加唯一索引,这是一个非常简单,并且有效的方案。
alter table `order` add UNIQUE KEY `un_code` (`code`);
加了唯一索引之后,第一次请求数据可以插入成功。但后面的相同请求,插入数据时会报Duplicate entry '002' for key 'order.un_code
异常,表示唯一索引有冲突。
虽说抛异常对数据来说没有影响,不会造成错误数据。但是为了保证接口幂等性,我们需要对该异常进行捕获,然后返回成功。
如果是java
程序需要捕获:DuplicateKeyException
异常,如果使用了spring
框架还需要捕获:MySQLIntegrityConstraintViolationException
异常。
具体流程图如下:
具体步骤:
- 用户通过浏览器发起请求,服务端收集数据。
- 将该数据插入mysql
- 判断是否执行成功,如果成功,则操作其他数据(可能还有其他的业务逻辑)。
- 如果执行失败,捕获唯一索引冲突异常,直接返回成功。
3.8 建防重表
有时候表中并非所有的场景都不允许产生重复的数据,只有某些特定场景才不允许。这时候,直接在表中加唯一索引,显然是不太合适的。针对这种情况,我们可以通过建防重表
来解决问题。该表可以只包含两个字段:id
和 唯一索引
,唯一索引可以是多个字段比如:name、code等组合起来的唯一标识,例如:susan_0001。具体流程图如下:
具体步骤:
- 用户通过浏览器发起请求,服务端收集数据。
- 将该数据插入mysql防重表
- 判断是否执行成功,如果成功,则做mysql其他的数据操作(可能还有其他的业务逻辑)。
- 如果执行失败,捕获唯一索引冲突异常,直接返回成功。
需要特别注意的是:防重表和业务表必须在同一个数据库中,并且操作要在同一个事务中。
3.9 根据状态机
很多时候业务表是有状态的,比如订单表中有: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行,说明是重复请求,直接返回成功。
主要特别注意的是,该方案仅限于要更新的
表有状态字段
,并且刚好要更新状态字段
的这种特殊情况,并非所有场景都适用。
3.10 加分布式锁
如果是分布是系统,构建全局唯一索引比较困难,加唯一索引或者加防重表本质是使用了数据库的分布式锁,也属于分布式锁的一种。但由于数据库分布式锁的性能不太好,可以改用:redis
或zookeeper
。鉴于现在很多公司分布式配置中心改用apollo
或nacos
,已经很少用zookeeper
了,推荐以redis
为分布式锁。
目前主要有三种方式实现redis的分布式锁:
- setNx命令
- set命令
- Redission框架
每种方案各有利弊。具体流程图如下:
具体步骤:
- 用户通过浏览器发起请求,服务端会收集数据,并且生成订单号code作为唯一业务字段。
- 使用redis的set命令,将该订单code设置到redis中,同时设置超时时间。
- 判断是否设置成功,如果设置成功,说明是第一次请求,则进行数据操作。
- 如果设置失败,说明是重复请求,则直接返回成功。
需要特别注意的是:分布式锁一定要设置一个合理的过期时间,如果设置过短,无法有效的防止重复请求。如果设置过长,可能会浪费
redis
的存储空间,需要根据实际业务情况而定。
3.11 token机制
除了上述方案之外,还有最后一种使用token
的方案。该方案跟之前的所有方案都有点不一样,需要两次请求才能完成一次业务操作。
- 第一次请求获取
token
- 第二次请求带着这个
token
,完成业务操作。
具体流程图如下:
第一步,先获取token。
第二步,做具体业务操作。
具体步骤:
- 用户访问页面时,浏览器自动发起获取token请求。
- 服务端生成token,保存到redis中,然后返回给浏览器。
- 用户通过浏览器发起请求时,携带该token。
- 在redis中查询该token是否存在,如果不存在,说明是第一次请求,做则后续的数据操作。
- 如果存在,说明是重复请求,则直接返回成功。
- 在redis中token会在过期时间之后,被自动删除。
以上方案是针对幂等设计的。
如果是防重设计,流程图要改改:
需要特别注意的是:token必须是全局唯一的。
3.12 缓冲队列
将请求都快速地接收下来后放入缓冲队列中,后续使用异步任务处理队列中的数据,过滤掉重复的请求,该解决方案优点是同步处理改成异步处理、高吞吐量,缺点则是不能及时地返回请求结果,需要后续轮询得处理结果。
3.13 全局唯一号
比如通过source
来源 + 唯一序列号传入给后端,后端来判断请求是否重复,在并发时只能处理一个请求,其他相同并发请求要么返回请求重复,要么等待前面请求执行完成后再执行。
四、幂等的不足
幂等是为了简化客户端逻辑处理,却增加了服务提供者的逻辑和成本,是否有必要,需要根据具体场景具体分析,因此除了业务上的特殊要求外,尽量不提供幂等的接口。
- 增加了额外控制幂等的业务逻辑,复杂化了业务功能;
- 把并行执行的功能改为串行执行,降低了执行效率。