接口幂等的几种方案
接口幂等
防重:防重设计主要为了避免产生重复数据
接口幂等性:除了防止产生重复数据,还要求执行多次与执行一次产生的影响是一样的
重复提交是造成的原因
幂等性是要保证的结果
针对操作
查询、删除具有幂等性
新增、修改不具有幂等性
update操作
1.如果只是单纯的更新数据,比如:update user set status=1 where id=1,是没有问题的。
2.如果还有计算比如累加,比如:update user set status=status+1 where id=1,这种情况下多次请求,可能会导致数据错误。
实际案例
单据审核后,然后记录相关流水记录
public void updateNoticeLock(Integer documentId) {
// 1.查询
DocumentBO documentBO = this.sysUserMapper.selectDocument(documentId);
// 2.幂等校验
if (documentBO.getStatus().equals("1")) {
throw new RuntimeException("单据已审核");
}
// 3.记录流水
this.sysUserMapper.approve(documentBO);
documentBO.setCreateTime(LocalDateTime.now());
// 4.插入流水记录
this.sysUserMapper.insertDocumentFlows(documentBO);
}
解决方案一:数据库互斥锁
- 依赖数据库本身的锁机制实现并发事务数据修改数据串行操作
select ... for update;
// 先锁住记录,不让别的事务操作,也是保证了查询与更新是原子性操作
select id,ware_name,num from table where id =1 for update;
update table set num = num -1 where id = 1;
-
注意事项:
- 这种加锁的方式要注意对where查询的条件加索引,防止锁表。
- for update要与事务配合使用才能生效,理由如下
- 相对于乐观并发机制,这中适合场景是并发量较多的情况下,但是这种加锁的方式可能会造成系统线程阻塞过多,会影响接口性能。
配合事务一块使用的原因:
这种是依赖数据库本身的锁机制,在执行查询时候就把数据锁住,让其他操作这条记录的事务等待。要知道数据库的锁针对的对象是一个个事务,换句话说数据库提供的锁能保证多个事务对同一条数据时操作时是串行执行的。
当一个事务A中某一块逻辑对某条记录进行修改时,会对当前记录进行加锁,若这时有其他事务B处理该记录时,就会等待前面事务A执行完,当事务内全部所有操作`提交后`,唤醒事务B再对该数据进行修改,所以这种依赖数据库的锁是能够保证事务并行执行的
解决方案二:乐观锁
口诀:查询、判断、失败延迟重试
在真正更新操作时候判断数据是否有冲突,即当前数据是否被其他操作已经篡改过,如果已被篡改,将重新检测是否冲突。
实现方法一般使用单项递增的版本号、时间戳,比如使用单项递增的版本号,当前操作先获取版本号,判断当前版本号与数据库中数据的版本号是否一致,如果一致说明此时没有其他操作正在执行,即可更新;如果不一致那么这次操作即失败,可以延迟重新调用当前方法,再次获取当前数据的版本号,再次进行冲突检测。
缺点:
- 适用于并发量低的场景下,当并发量高说明冲突越严重,那么循环调用的次数就多,吞吐量就低。
- 注意递归调用时要加睡眠时间,防止出现栈内存溢出
- 随着并发量越高,吞吐量会越来越低
案例1(冲突检测失败则不再重试,存在失败操作)
业务逻辑:单据审核,审核成功即添加流水
加事务@Tx
// 查询
DocumentBO documentBO = this.sysUserMapper.selectDocument(documentId);
// 幂等校验
if (documentBO.getStatus().equals("1")) {
throw new RuntimeException("单据已审核");
}
// UPDATE t_document SET status =1 WHERE id = #{id} AND status = 0
int approveCount = this.sysUserMapper.approveStateMachine(documentBO);
if (approveCount == 1) {
documentBO.setCreateTime(LocalDateTime.now());
this.sysUserMapper.insertDocumentFlows(documentBO);
}
1. 使用单向递增的版本号或单向递增的时间戳。
2. 使用状态机方式。如果有状态机流转的方式,就可以使用此种方式。比如订单状态,1表示已创建、2表示已提交、3表示已支付。一方面是因为状态是定向流转的,另一方面也是利用数据库本身的锁机制,多个事务并发操作同一条数据,只有一个事务能执行成功
注意:此种方式要保证以上操作在同一个事务内,因为依赖的是数据库层面的锁。
案例2(保证所有操作执行成功)
业务逻辑:扣库存
public void checkAndLock() {
// 先查询库存是否充足(5000)
Stock stock = this.stockMapper.selectById(1L);
// 再减库存
if (stock.getCount() <= 0){
return;
}
stock.setCount(stock.getCount() - 1);
// 匹配行数为0,则再次调用当前方法,进行冲突检查重试
if (this.stockMapper.updateById2(stock) == 0) {
this.checkAndLock();
}
}
<update id="updateById2">
UPDATE `db_stock`
SET `count` = #{count},version = version + 1
WHERE `id` = #{id} AND version = #{version}
</update>
注意:
1. 并发量越低,吞吐量越高
适用于并发低的场景下,并发高时会导致冲突严重,循环调用消耗cpu,吞吐量低。
2. 不可加事务,由于当前事务尚未提交,很难读取到其他事务更新后的数据(RR根本读不到,RC能读到)
解决方案三:分布式锁
口诀:一锁、二幂等校验、三更新(新增)
-
基于redis分布式锁
-
基于zookeeper实现的分布式锁
能够保证查询与修改是原子性操作,不会出现数据竞争的问题
操作步骤
一锁、二判断幂等、三操作(更新、插入)
以redis实现的分布式锁为例
解决方案四:建防重表
比如可能出现消息重复消费的情况时。新增消费消费表,当业务处理完后同时记录消息到消息消费表中,保证业务处理与记录消费消息是一致性的。下次有重复消费,判断该消息是否已经存在,如果存在表示已经消费,否则正常执行业务
总结与思考
要保证幂等性,本质上其实要保证幂等校验与操作数据是原子逻辑,如果能依赖数据库本身锁机制实现事务串行执行是最好的了,比如乐观锁种使用递增的版本号,或者状态机判断。像java层面使用悲观锁性能消耗是线程的阻塞与唤醒,而乐观锁实现依赖于数据库层面的锁,多个事务对同一个数据进行修改,会让事务同步执行该数据,也就是说一个事务对数据修改必须要等待另外一个对相同数据修改的事务提交后才可以执行。
一般逻辑在实现更新插入数据时,要进行幂等判断(查询、判断条件是否符合,如果符合则直接中断程序)
遵循一锁、二判、三更新、四释放原则
能依赖数据库本身的锁机制,能存在状态机流转,一定要加上状态条件。
同时重试通常跟幂等组合使用
参考
https://blog.csdn.net/crazymakercircle/article/details/135659360