事务方法中保证数据只插入一次方案探究

需求场景

在项目的接口请求中,我们有一个接口A需要事务支持,在接口A中调用了方法B,方法B也需要事务支持,两者都带有@Transactional注解。在B方法中是这个一个逻辑,查询本地数据库是否包含属性值为一个特定值的字段,如果没有的话就插入,如果有的话就跳过。

问题难点及方案分析

首先最简单的方式是在数据层面加入唯一性约束,但是项目中会出现报错,并且这里我们要求不能在数据库层面进行操作,数据的事务的隔离级别必须是可重复读,只能在代码中保证数据插入的数据的唯一性。

@PostMapping("/add")
@Transactional
public String addUser(@RequestBody User user) {
    
    //逻辑代码1...
    //逻辑代码1...
    //逻辑代码1...
    
    //插入逻辑
    boolean andInsertUser = userService.getAndInsertUser(user);
    
    //逻辑代码2...
    //逻辑代码2...
    //逻辑代码2...
    return andInsertUser ? "添加成功" : "插入失败";
}
@Transactional
    public boolean getAndInsertUser(User user) {
        LambdaQueryWrapper<User> lambdaQueryWrapper = new LambdaQueryWrapper<User>().eq(User::getAge, user.getAge());
        List<User> list = list(lambdaQueryWrapper);
        if (Objects.nonNull(list) && !list.isEmpty()) {
            return true;
        }
        return save(user);
    }

难点1及方案分析

  • 在并发的情况下可能两次请求查询基本同时执行,都查询到了相同的结果发现没有数据,然后都执行了插入的请求,导致数据的重复。

针对于上述情况,我们可以使用信号量机制来解决,使用信号量之后即使在并发的情况下发生,也只有一个线程能够真正执行里面的内容,并且其他的线程获取资源失败之后并不会阻塞。


@Service
public class UserServiceImpl extends ServiceImpl<UserMapper, User> implements UserService {
    Semaphore semaphore = new Semaphore(1);
    @Transactional
    public boolean getAndInsertUser(User user) {
        if (semaphore.tryAcquire()){
            try {
                LambdaQueryWrapper<User> lambdaQueryWrapper = new LambdaQueryWrapper<User>().eq(User::getAge, user.getAge());
                List<User> list = list(lambdaQueryWrapper);
                if (Objects.nonNull(list) && !list.isEmpty()) {
                    return true;
                }
                return save(user);
            } finally {
                semaphore.release();
            }
        }
        return true;
    }
}

难点2及方案分析

  • 由于可重读读颗粒级别下存在存在幻读问题,我们考虑这样一种情况,两个请求a和b都进入了addUser方法,其中a执行在逻辑代码1的时候,b已经执行完成了插入逻辑,当a执行到插入逻辑的时候semaphore.tryAcquire()一定是可以成功执行的,而由于addUser方法添加了事务注解,这就导致即使b线程已经执行完了插入逻辑但是a在执行插入逻辑的时候,下面的代码在if判断的时候依然查不到刚才b插入的数据,这里是因为a的事务开启是在b插入数据之前,导致a查询的是a开启事务时候的快照,快照中并不存在b刚才插入的数据,这就导致了再次插入数据。
 LambdaQueryWrapper<User> lambdaQueryWrapper = new LambdaQueryWrapper<User>().eq(User::getAge, user.getAge());
 List<User> list = list(lambdaQueryWrapper);
 if (Objects.nonNull(list) && !list.isEmpty()) {
     return true;
 }
 return save(user);

针对上述的情况,原因就是a事务的开启在b事务提交之前。如果我们两条事务不是并发执行的而是一条事务执行完成之后另一个事务才开启就不会存在这个问题。

  • 一个看似合理的解决方案
private volatile AtomicInteger stamp = new AtomicInteger(0);

@PostMapping("/add")
@Transactional
public String addUser(@RequestBody User user) {
    int stamp_temp = stamp.get();
    //逻辑代码1...
    //逻辑代码1...
    //逻辑代码1...

    //插入逻辑
    boolean andInsertUser = userService.getAndInsertUser(user, stamp_temp, stamp);

    //逻辑代码2...
    //逻辑代码2...
    //逻辑代码2...
    return andInsertUser ? "添加成功" : "插入失败";
}
private static final Semaphore semaphore = new Semaphore(1);
    @Transactional
    public boolean getAndInsertUser(User user, int stamp, AtomicInteger atomicInteger) {
        if (semaphore.tryAcquire()){
            if (Objects.equals(atomicInteger.get(), stamp)) {
                try {
                    LambdaQueryWrapper<User> lambdaQueryWrapper = new LambdaQueryWrapper<User>().eq(User::getAge, user.getAge());
                    List<User> list = list(lambdaQueryWrapper);
                    if (Objects.nonNull(list) && !list.isEmpty()) {
                        return true;
                    }
                    return save(user);
                } finally {
                    atomicInteger.incrementAndGet();
                    semaphore.release();
                }
            }
        }
        return true;
    }
}

这里采用了类似于解决ABA问题的解决方案,但是会存在这样一种情况,假如两个线程a和b都执行了addUser()方法,假如起始时候stamp的值是0,a线程执行到了逻辑代码2,这个时候stamp一定已经被a线程变成了1,此时,线程b这个时候首先获取stamp的值是1,线程a还没有执行完成,也就是事务还没有提交,后面执行插入逻辑的时候当然一样会存在读取不到最新数据的问题,导致b线程还是能够插入成功。最后两个事务都会提交成功,这样还是会插入两次相同的数据。

  • 使用select for update保证当前读
    这个方法在MySQL中没有进行实验,在postgresql中读已提交和可重复读隔离界别下均读取不到其他事务已经插入但是没有提交的数据。仍然无法解决问题。

难点3及解决分析

以上的解决方案都是在单机环境下的解决方案,多机分布式环境下仍然会存在很多的问题。针对于以上问题,提出一下两种方案:

  • 使用消息中间件将消息发到mq中进行消费,这样就不会和业务前面的业务逻辑代码冗余在一起,提高接口响应速度。但是由于消息消费可能出现并发消费的问题导致同时插入多条相同数据。
  • 使用redis分布式锁来解决消息并发消费的问题,保证分布式环境下,消费消息的同步性。同时可以根据业务逻辑丢弃消息。
  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 打赏
    打赏
  • 1
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论 1
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

北海冥鱼未眠

你的鼓励将是我创作的最大动力

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值