Java架构直通车——幂等性接口设计

接口设计与重试机制引发的问题

在实际业务中,可能会遇到以下的问题:

  • 提交订单按钮如何防止重复提交?并且还要区分是误操作造成的重复提交,还是用户主动发起的重复提交。
    比如多次点击提交订单,后台只生成一个订单。
  • 微服务接口,客户端重试时,会对数据产生影响吗?
    比如支付时,由于网络问题重发,只扣一次钱。

这就需要接口的幂等性,f(f(x))=f(x),也就是幂等元素运行多次还等于它一次运行的结果,并不是所有接口需要幂等性,一般是有重复提交、接口重试、前段操作抖动等问题下才需要幂等性的设计。

保证幂等性的策略

幂等性的核心思想:通过唯一的业务单号或者token保障幂等
接口需要去找到唯一的业务单号,如果找不到就使用token。

具体实现分成两种情况:

  • 非并发情况下:通过这个唯一的业务单号来判断这个业务有没有操作过。
    比如提交订单业务,在提交订单之前就给页面返回一个token,然后在提交订单的时候带上这个token,在后台判断这个token有没有使用过,如果使用过,就不再去执行了。
  • 并发情况下:整个操作加锁。
    由于并发情况下,会有大量的请求,在第一次查询的时候,可能多个请求查询到这个业务单号都是没有被执行过,这些并发的请求需要去加锁(分布式锁)。

CRUD操作实现幂等性策略

select操作

不会对业务产生影响,天然幂等。

delete操作

如果有唯一业务单号,就根据业务单号去删除。

  • 第一次删除时,操作完成后就将数据删除了。
  • 第二次再次执行时,直接删除即可。也可以在删除前进行数据的查询,由于找不到记录,所以给前端返回查询不到的错误即可。

由上面的操作可以知道,有唯一业务单号的delete是幂等的,是否并发,对代码的影响不是很大。

如果没有唯一业务单号,就需要看业务需求是否要求。比如有删除所有审核未通过的商品,这里不是根据商品id删除商品的,而是根据商品的审核状态去删除的。

  • 第一次删除的时候,删除了所有未审核通过的状态。
  • 在第二次删除之前,恰好又有新的未审核通过的商品。
  • 在第二次删除的时候,这种新的未审核通过的商品要不要删除呢?这就是需要根据业务考虑的了。

如果业务要求第二次删除要考虑幂等性,不能删除新的未审核通过的商品,那么就需要用token机制了。token机制会在之后详解。

扫描二维码关注公众号,回复: 10178841 查看本文章

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里面,这样就可以判断这个请求是否是真实的请求了。

发布了419 篇原创文章 · 获赞 327 · 访问量 17万+

猜你喜欢

转载自blog.csdn.net/No_Game_No_Life_/article/details/104945619