接口设计与重试机制引发的问题
在实际业务中,可能会遇到以下的问题:
- 提交订单按钮如何防止重复提交?并且还要区分是误操作造成的重复提交,还是用户主动发起的重复提交。
比如多次点击提交订单,后台只生成一个订单。 - 微服务接口,客户端重试时,会对数据产生影响吗?
比如支付时,由于网络问题重发,只扣一次钱。
这就需要接口的幂等性,f(f(x))=f(x)
,也就是幂等元素运行多次还等于它一次运行的结果,并不是所有接口需要幂等性,一般是有重复提交、接口重试、前段操作抖动等问题下才需要幂等性的设计。
保证幂等性的策略
幂等性的核心思想:通过唯一的业务单号或者token保障幂等。
接口需要去找到唯一的业务单号,如果找不到就使用token。
具体实现分成两种情况:
- 非并发情况下:通过这个唯一的业务单号来判断这个业务有没有操作过。
比如提交订单业务,在提交订单之前就给页面返回一个token,然后在提交订单的时候带上这个token,在后台判断这个token有没有使用过,如果使用过,就不再去执行了。 - 并发情况下:整个操作加锁。
由于并发情况下,会有大量的请求,在第一次查询的时候,可能多个请求查询到这个业务单号都是没有被执行过,这些并发的请求需要去加锁(分布式锁)。
CRUD操作实现幂等性策略
select操作
不会对业务产生影响,天然幂等。
delete操作
如果有唯一业务单号,就根据业务单号去删除。
- 第一次删除时,操作完成后就将数据删除了。
- 第二次再次执行时,直接删除即可。也可以在删除前进行数据的查询,由于找不到记录,所以给前端返回查询不到的错误即可。
由上面的操作可以知道,有唯一业务单号的delete是幂等的,是否并发,对代码的影响不是很大。
如果没有唯一业务单号,就需要看业务需求是否要求。比如有删除所有审核未通过的商品,这里不是根据商品id删除商品的,而是根据商品的审核状态去删除的。
- 第一次删除的时候,删除了所有未审核通过的状态。
- 在第二次删除之前,恰好又有新的未审核通过的商品。
- 在第二次删除的时候,这种新的未审核通过的商品要不要删除呢?这就是需要根据业务考虑的了。
如果业务要求第二次删除要考虑幂等性,不能删除新的未审核通过的商品,那么就需要用token机制了。token机制会在之后详解。
demo
这里我们代码贴一下有业务单号的delete:
/**
* 删除账户
*/
@Transactional(transactionManager = "tm337", propagation = Propagation.REQUIRED)
public int deleteAccount(){
int result=account337Mapper.deleteByPrimaryKey(1);
return result;
}
然后用test测试并发:
@Test
public void deleteAccount() throws InterruptedException {
CountDownLatch countDownLatch = new CountDownLatch(4);
CyclicBarrier barrier = new CyclicBarrier(4);
ExecutorService executorService = Executors.newFixedThreadPool(4);
for(int i=0;i<4;i++){
executorService.execute(() -> {
try {
barrier.await();
int result = balanceAccountService.deleteAccount();
System.out.println(Thread.currentThread().getName() + ": " + result);
} catch (InterruptedException e) {
e.printStackTrace();
} catch (BrokenBarrierException e) {
e.printStackTrace();
}finally {
countDownLatch.countDown();
}
});
}
countDownLatch.await();
System.out.println("线程执行完毕");
executorService.shutdown();
}
测试结果:
没有问题,通过唯一的业务号进行delete操作是幂等的。
update操作
uodate操作同样也需要区分是否使用唯一业务号去操作。
同样,用token的情况我们之后再去解释,这里我们只关注具有唯一业务号的情景。
如果update操作是set一个固定值,那么此时update操作是天然幂等的,如果set固定值需要关注ABA问题的话,那么使用乐观锁即可。
如果update操作是每次更新进行加减等运算,这就要求了幂等性的实现,一半来说也会使用乐观锁。
所以接下来我们使用乐观锁来模拟实现update的幂等性问题,比如业务有可能是这样的:
用户查询出要修改的数据,此时的数据会被返回给页面,我们只需要将数据版本放入隐藏域。然后用户提交数据时,会将版本号一同提交到后台,后台即可使用版本号作为更新条件。
update set version=version+1,xxx=${xxx} where id = ${id} and version=${version}
demo
为了实现乐观锁,我们给数据库的account
表加入属性version
用来记录版本号。
然后加入一个mapper方法:
<update id="updateAccount" parameterType="com.bonjour.learnmutipledatasourceconsistency.model.model337.Account337">
update account
set amount = #{amount,jdbcType=DECIMAL},
version=version+1
where id = #{id,jdbcType=INTEGER}
and version = #{version,jdbcType=INTEGER}
</update>
好的,我们的service是这样的:
@Transactional(transactionManager = "tm337", propagation = Propagation.REQUIRED)
public int addMoney() throws InterruptedException {
//数据取出
Account337 account337 = account337Mapper.selectByPrimaryKey(3);
//修改数据
account337.setAmount(account337.getAmount().add(new BigDecimal(200)));
Thread.sleep(1000);
//更新数据
return account337Mapper.updateAccount(account337);
}
开启10个线程一起执行,结果如下:
这就是乐观锁的方法,利用了version加上update自带的行锁实现了幂等性。
insert操作
Insert操作也是要区分是否具有唯一的业务单号。
你可能会有疑问,insert操作的业务单号不是还没有生成吗?哪里来的区分是否具有唯一业务单号呢?比如有如下的场景:商品秒杀,商品ID加上用户ID就可以形成一个唯一的业务号。
我们讨论具有唯一业务单号的情景,这种情况下,我们可以通过分布式锁来实现幂等性,并且如果使用redis完成分布式锁,锁也不必释放,让其自动过期即可。
分布式锁的情况,我们之前已经提到过了,这里不多做赘述,可以参考:基于Redis的Set NX实现分布式锁。
这里我们终于要统一的说明token如何使用了,如果没有唯一业务单号,我们使用token来保障幂等性:
- 比如注册一个用户,进入到注册页面时,后台统一生成token,返回到前台的隐藏域中,用户提交后将token一并提交到后台,然后根据token去获取分布式锁,完成Insert操作。
- 由于用户到该页面的时候,token是唯一对应该页面的,与其他页面token无关,所以这个token能够保障这是由一个页面发过来的请求,即使用户点击多次,只要页面不刷新,token就不会改变。
- 同样的,执行成功后,可以等待锁自己过期释放掉。
包括之前的update操作、delete操作,没有唯一业务单号的情况也可以使用token,并且如果一个业务包括了多个update/insert/delete操作,也可以使用token。
demo
这里我们使用redis完成分布式锁和insert幂等性操作,为了简单我们采用redisson。
首先引入mvn包:
<!-- https://mvnrepository.com/artifact/org.redisson/redisson-spring-boot-starter -->
<dependency>
<groupId>org.redisson</groupId>
<artifactId>redisson-spring-boot-starter</artifactId>
<version>3.12.3</version>
</dependency>
在application.properties里面配置:
spring.redis.host=xxxxx
spring.redis.port=6079
然后直接注入RedissonClient即可。
@Autowired
private RedissonClient redissonClient;
@Transactional(transactionManager = "tm337", propagation = Propagation.REQUIRED)
public int createAccount(String token) {
RLock rLock = redissonClient.getLock("create_account_" + token);
boolean tryLock=false;
try {
tryLock=rLock.tryLock(2,5,TimeUnit.SECONDS);
if (!tryLock){
return 0;
}
Account337 account337 = new Account337();
account337.setAmount(new BigDecimal(0));
return account337Mapper.insertSelective(account337);
} catch (InterruptedException e) {
e.printStackTrace();
}
return -1;
}
输出如下:
注意,在实际情况下,我们后台会记录下生成的token,既然使用了redis,那么会把生成的token放到redis里面,这样就可以判断这个请求是否是真实的请求了。