SpringBoot接口 - 如何保证接口幂等
SpringBoot接口 - 如何保证接口幂等
在以SpringBoot开发Restful接口时,如何防止接口的重复提交呢? 本文主要介绍接口幂等相关的知识点,并实践常见基于Token实现接口幂等
1. 准备知识点
从幂等和防止重复提交,接口幂等和常见的保证幂等的方式等知识点构筑知识体系。
1.1 什么是幂等?
幂等原先是数学中的一个概念,表示进行1次变换和进行N次变换产生的效果相同。
当我们讨论接口的幂等性时一般是在说:以相同的请求调用这个接口一次和调用这个接口多次,对系统产生的影响是相同的。如果一个接口满足这个特性,那么我们就说这个 接口是一个幂等接口。
1.1.1 接口幂等和防止重复提交是一回事吗?
严格来说,并不是。
- 幂等: 更多的是在重复请求已经发生,或是无法避免的情况下,采取一定的技术手段让这些重复请求不给系统带来副作用。
- 防止重复: 提交更多的是不让用户发起多次一样的请求。比如说用户在线购物下单时点了提交订单按钮,但是由于网络原因响应很慢,此时用户比较心急多次点击了订单提交按钮。 这种情况下就可能会造成多次下单。一般防止重复提交的方案有:将订单按钮置灰,跳转到结果页等。主要还是从客户端的角度来解决这个问题。
1.1.2 哪些情况下客户端是防止不了重复提交的?
虽然我们可在客户端做一些防止接口重复提交的事(比如将订单按钮置灰,跳转到结果页等), 但是如下情况依然客户端是很难控制接口重复提交到后台的,这也进一步表明了接口幂等和防止重复提交不是一回事以及后端接口保证接口幂等的必要性所在。
- 接口超时重试:接口可能会因为某些原因而调用失败,出于容错性考虑会加上失败重试的机制。如果接口调用一半,再次调用就会因为脏数据的存在而出现异常。
- 消息重复消费:在使用消息中间件来处理消息队列,且手动ack确认消息被正常消费时。如果消费者突然断开连接,那么已经执行了一半的消息会重新放回队列。被其他消费者重新消费时就会导致结果异常,如数据库重复数据,数据库数据冲突,资源重复等。
- 请求重发:网络抖动引发的nginx重发请求,造成重复调用;
1.2 什么是接口幂等?
在HTTP/1.1中,对幂等性进行了定义。它描述了一次和多次请求某一个资源对于资源本身应该具有同样的结果(网络超时等问题除外),即第一次请求的时候对资源产生了副作用,但是以后的多次请求都不会再对资源产生副作用。
这里的副作用是不会对结果产生破坏或者产生不可预料的结果。也就是说,其任意多次执行对资源本身所产生的影响均与一次执行的影响相同。
- 对哪些类型的接口需要保证接口幂等?
我们看下标准的restful请求,幂等情况是怎么样的:
- SELECT查询操作
- GET:只是获取资源,对资源本身没有任何副作用,天然的幂等性。
- HEAD:本质上和GET一样,获取头信息,主要是探活的作用,具有幂等性。
- OPTIONS:获取当前URL所支持的方法,因此也是具有幂等性的。
- DELETE删除操作
- 删除的操作,如果从删除的一次和删除多次的角度看,数据并不会变化,这个角度看它是幂等的
- 但是如果,从另外一个角度,删除数据一般是返回受影响的行数,删除一次和多次删除返回的受影响行数是不一样的,所以从这个角度它需要保证幂等。(折中而言DELETE操作通常也会被纳入保证接口幂等的要求)
- ADD/EDIT操作
- PUT:用于更新资源,有副作用,但是它应该满足幂等性,比如根据id更新数据,调用多次和N次的作用是相同的(根据业务需求而变)。
- POST:用于添加资源,多次提交很可能产生副作用,比如订单提交,多次提交很可能产生多笔订单。
2. 常见的保证幂等的方式?
我们来看下常见的保证幂等的方式。
2.1 数据库层面
2.1.1 悲观锁
典型的数据库悲观锁:
for update
select * from t_order where order_id = trade_no for update;
为什么加for update就可以?
- 当线程A执行for update,数据会对当前记录加锁,其他线程执行到此行代码的时候,会等待线程A释放锁之后,才可以获取锁,继续后续操作。
- 事物提交时,for update获取的锁会自动释放。
PS:这种方式很少被使用,因为如果业务处理比较耗时,并发情况下,后面线程会长期处于等待状态,占用了很多线程,让这些线程处于无效等待状态,我们的web服务中的线程数量一般都是有限的,如果大量线程由于获取for update锁处于等待状态,不利于系统并发操作。
2.1.1.1 悲观锁流程
没有悲观锁的方式是这样的:
有了悲观锁的时候:
2.1.2 唯一ID/索引
针对的是插入操作。
数据库唯一主键的实现主要是利用数据库中主键唯一约束的特性,一般来说唯一主键比较适用于“插入”时的幂等性,其能保证一张表中只能存在一条带该唯一主键的记录。
使用数据库唯一主键完成幂等性时需要注意的是,该主键一般来说并不是使用数据库中自增主键,而是使用分布式 ID 充当主键,这样才能能保证在分布式环境下 ID 的全局唯一性。
- 去重表
去重表本质上也是一种唯一索引方案。
这种方法适用于在业务中有唯一标的插入场景中,比如在以上的支付场景中,如果一个订单只会支付一次,所以订单ID可以作为唯一标识。这时,我们就可以建一张去重表,并且把唯一标识作为唯一索引,在我们实现时,把创建支付单据和写入去去重表,放在一个事务中,如果重复创建,数据库会抛出唯一约束异常,操作就会回滚。
2.1.3 乐观锁(基于版本号或者时间戳)
针对更新操作。
- 使用版本号或者时间戳
这种方法适合在更新的场景中,比如我们要更新商品的名字,这时我们就可以在更新的接口中增加一个版本号,来做幂等
boolean updateGoodsName(int id,String newName,int version);
在实现时可以如下
update goods set name=#{newName},version=#{version} where id=#{id} and version<${version}
- 状态机
本质上也是乐观锁,这种方法适合在有状态机流转的情况下,比如就会订单的创建和付款,订单的付款肯定是在之前,这时我们可以通过在设计状态字段时,使用int类型,并且通过值类型的大小来做幂等,比如订单的创建为0,付款成功为100。付款失败为99
在做状态机更新时,我们就这可以这样控制
update `order` set status=#{status} where id=#{id} and status<#{status}
2.2 分布式锁
分布式锁实现幂等性的逻辑是,在每次执行方法之前判断,是否可以获取到分布式锁,如果可以,则表示为第一次执行方法,否则直接舍弃请求即可。
需要注意的是分布式锁的key必须为业务的唯一标识,通常用redis分布式锁或者zookeeper来实现分布式锁。
分布式锁的实现方法具体请参考:分布式系统 - 分布式锁及实现方案
2.3 token机制
方案描述:
针对客户端连续点击或者调用方的超时重试等情况,例如提交订单,此种操作就可以用 Token 的机制实现防止重复提交。
简单的说就是调用方在调用接口的时候先向后端请求一个全局 ID(Token),请求的时候携带这个全局 ID 一起请求(Token 最好将其放到 Headers 中),后端需要对这个 Token 作为 Key,用户信息作为 Value 到 Redis 中进行键值内容校验,如果 Key 存在且 Value 匹配就执行删除命令,然后正常执行后面的业务逻辑。如果不存在对应的 Key 或 Value 不匹配就返回重复执行的错误信息,这样来保证幂等操作。
适用操作:
- 插入操作
- 更新操作
- 删除操作
使用限制:
- 需要生成全局唯一 Token 串;
- 需要使用第三方组件 Redis 进行数据效验;
主要流程:
- ① 服务端提供获取 Token 的接口,该 Token 可以是一个序列号,也可以是一个分布式 ID 或者 UUID 串。
- ② 客户端调用接口获取 Token,这时候服务端会生成一个 Token 串。
- ③ 然后将该串存入 Redis 数据库中,以该 Token 作为 Redis 的键(注意设置过期时间)。
- ④ 将 Token 返回到客户端,客户端拿到后应存到表单隐藏域中。
- ⑤ 客户端在执行提交表单时,把 Token 存入到 Headers 中,执行业务请求带上该 Headers。
- ⑥ 服务端接收到请求后从 Headers 中拿到 Token,然后根据 Token 到 Redis 中查找该 key 是否存在。
- ⑦ 服务端根据 Redis 中是否存该 key 进行判断,如果存在就将该 key 删除,然后正常执行业务逻辑。如果不存在就抛异常,返回重复提交的错误信息。
注意,在并发情况下,执行 Redis 查找数据与删除需要保证原子性,否则很可能在并发下无法保证幂等性。其实现方法可以使用分布式锁或者使用 Lua 表达式来注销查询与删除操作。