接口幂等设计
1. 什么是幂等
幂等(idempotent、idempotence)是一个数学与计算机学概念,常见于抽象代数中。
在编程中一个幂等操作的特点是其任意多次执行所产生的影响均与一次执行的影响相同。幂等函数,或幂等方法,是指可以使用相同参数重复执行,并能获得相同结果的函数。这些函数不会影响系统状态,也不用担心重复执行会对系统造成改变。例如,“setTrue()”函数就是一个幂等函数,无论多次执行,其结果都是一样的.更复杂的操作幂等保证是利用唯一交易号(流水号)实现.
简单来说接口幂等性是指用户对于同一接口发起的多次同一请求所产生影响是一致的,不会因为多次点击而产生多余副作用。
2. 哪些情况会产生幂等问题
业务中需要考虑幂等性的地方一般都是接口发生了重复请求,重复请求是指同一个请求因为某些原因被多次提交。导致这个情况会有几种场景:
- 前端重复提交:
表单提交时用户重复点击,结果通常表现为数据库插入多条数据 - 接口超时重试:
如rpc接口设置了超时重试,可能由于网络延迟发出了多次重试调用,结果服务端就会执行多次请求。 - 消息重复消费:
生产者发送了重复消息,消费者消费消息后ack失败,都会导致重复消费消息。
3. 如何实现接口幂等性
分为两个方向,一个是前端防止重复操作,一个是服务端进行重复性校验。
客户端防止重复提交并不是绝对可靠的,但是实现起来比较简单。
3.1 前端实现
- 禁用操作按钮,发起请求后立即为按钮添加loading或禁用,防止重复点击。
- 表单提交型的操作在请求返回后及时跳转页面。
3.2 后端实现
先给个定义,后端实现幂等的基本逻辑就是:
1、获取请求参数中的唯一标识(可以是单个字段,也可以是多个字段)
2、检查这个标识的状态是否已经处理了(可以是状态字段、是否存在等)
3、如果未处理则执行业务,并记录
4、已处理则直接返回,或抛出异常等
(以下方案收集自互联网)
3.2.1 先查后改
先根据参数执行select
,查询数据是否已经存在,如果没存在就执行修改,如果已存在就抛出异常或执行更新或者直接返回。
这种方法就是基本的判断逻辑,但是无法处理并发情况,很容易出现重复插入问题;
都是结合其他方案,作为请求是否处理的判断逻辑。
3.2.2 数据库唯一索引
通过给数据库字段添加唯一索引约束,依赖于数据库本身的重复性检查,如果数据重复会直接抛出异常,简单有效安全。
操作时注意捕获org.springframework.dao.DuplicateKeyException异常,返回成功即可。
适用于插入型操作的幂等。
本质上是依赖于数据库的锁。
3.2.3 分布式锁
判断请求是否执行过的基本逻辑是非原子性的,需要通过加锁防止并发问题,但在分布式环境多机运行情况下本地锁无法实现要求,使用分布式锁可以实现在分布式环境锁定全局唯一资源,使请求串行化。
一般用redis实现分布式锁。
(这里加锁只是使同一个请求串行化,而接口在全局上还是并行的)
- 请求进入时获取唯一标识,set到redis中,同时设置超时时间,
- 判断是否设置成功,如果设置成功,说明是第一次请求,则进行数据操作,
- 如果设置失败,说明是重复请求,则直接返回,
- 业务执行完成后删除key。
注意:如果请求执行耗时较长,redis的key可能在完成前过期,重复请求就会被执行,需要定时刷新key来解决。可以使用Redisson作为实现。
3.2.4 token令牌
前面所说的都是数据具有唯一标识的场景,作为条件可以直接使用。如果没有唯一标识情况呢,可以自行颁发一个随机串作为token标识。
实现流程:
1、服务端提供一个生成全局唯一随机数token的接口,客户端在调用实际处理接口前先获取token,如进入操作页面时获取并暂存,同时后端将token作为key存入redis中。
2、客户端请求接口带上token,服务端获取token查询redis是否已经存在,如果存在就删除key,然后执行业务逻辑
3、如果key不存在说明已经执行了业务逻辑,是重复请求,直接返回异常
简单代码示例:
// 第一步
public String getUniqueId() {
String s = UUID.randomUUID().toString();
redisUtil.set(s, "");
return s;
}
// 第二步
public boolean create(@RequestBody Create req) {
String key = req.getUniqueNo();
boolean exists = redisUtil.exists(key);
if (!exists) {
throw new RuntimeException("请勿重复操作");
}
redisUtil.remove(key);
// 执行业务逻辑
System.out.println("token 执行了 " + ++n + "次");
return true;
}
需要考虑的是:
1、有可能业务执行异常返回/key超时了,客户端再次调用时发现key不存在返回异常。对于这种情况,可以认为一个token对应一次请求,让客户端重新获取token即可。
2、存储到redis中的数据怎样设置超时时间,如果不设置超时,客户端可能没调接口,这个key会一直存在占用内存。设置超时时间比较短,在提交时可能就已经到期了。个人想法是设置一个较长的过期时间。
缺点是执行业务接口前需要额外调用一次接口获取token
3.2.5 乐观锁
乐观锁方式可以用来实现更新操作的幂等。
具体实现就是在设计表结构时增加version字段,用cas更新的形式来做乐观锁,
update table set version = version + 1 where id = 1 and version = 3
适用于计算式更新情况,被更新字段同时作为计算参数时,考虑使用。
疑惑:实际使用场景有两种
- 调用方先执行一次查,获取到version值,再执行更新操作带上version,这样即使重复请求,但携带的version值是一样的,能保证只更新一次。
- 更新接口自身开始时查询一次数据,获取当前version再更新。这种只能做到对小概率并发操作的幂等,对于重复请求无法实现。(网上有些文章说的是这种)
4. 小总结
- 幂等需要通过唯一的业务标识来保证,唯一标识的目的是定位同一请求
- 需要记录请求的状态,状态已改变的说明业务已处理
- 并发情况无法保证原子性,需要加锁串行化
幂等的不足:
- 幂等是为了简化客户端逻辑,但是增加了服务提供者的逻辑和成本
- 幂等的使用需要根据具体场景具体分析
- 增加了额外控制幂等的业务逻辑,复杂了业务功能
(对于修改型的接口设计时,是需要考虑接口幂等的)