Mysql的RR模式在使用分布式锁的场景下的问题总结

场景

统计每天各个国家新增的注册人数,表中如果已经有了这个国家这一天的统计记录,则update+1,如果没有则新增一条

代码

为了尽可能的减少分布式锁带来的性能影响,我在代码中使用了双检锁的方式,而这也是问题出现的原因

public int saveNewUser(String tenantNation, Date statisticsDate) {
        String formatPattern = "yyyy-MM-dd";
        SimpleDateFormat dateFormat = new SimpleDateFormat(formatPattern);
        String date = dateFormat.format(statisticsDate);
        tenantNation = tenantNation.toUpperCase(Locale.ROOT);
        log.info("【统计表新增用户】:入参tenantNation={},date={}",tenantNation,date);


        //查询表中是否有这个国家当天的新增,如果有就+1,没有就新insert
        QueryWrapper<UserStatistics> queryWrapper = new QueryWrapper<>();
        queryWrapper.lambda().eq(UserStatistics::getTenantNation,tenantNation)
                .eq(UserStatistics::getStatisticsDate,date);
        List<UserStatistics> userStatisticsList = baseMapper.selectList(queryWrapper);
        log.info("【统计表新增用户】查询统计表:入参tenantNation={},statisticsDate={},userStatisticsList={}",tenantNation,statisticsDate, JSONObject.toJSONString(userStatisticsList));


        if(userStatisticsList != null && userStatisticsList.size() == 1){
            UpdateWrapper<UserStatistics> updateStatisticsWrapper = new UpdateWrapper<>();
            updateStatisticsWrapper.lambda().setSql("num = num + 1").eq(UserStatistics::getTenantNation, tenantNation).eq(UserStatistics::getStatisticsDate,date);
            baseMapper.update(null, updateStatisticsWrapper);
        }else if(userStatisticsList == null || userStatisticsList.size() == 0){
            //获取锁
            RLock lock = redissonClient.getLock("Distributedlocks:insert_statistics");
            boolean isLocked = false;
            try {
                // 尝试获取分布式锁
                isLocked = lock.tryLock(5, TimeUnit.SECONDS);
                if (isLocked) {
                    // 获取到锁,再次判断是否需要插入或修改
                    userStatisticsList = baseMapper.selectList(queryWrapper);
                    if (userStatisticsList == null || userStatisticsList.isEmpty()) {
                        UserStatistics userStatistics = new UserStatistics();
                        userStatistics.setTenantNation(tenantNation);
                        userStatistics.setStatisticsDate(statisticsDate);
                        userStatistics.setNum(1);
                        baseMapper.insert(userStatistics);
                    } else {
                        UpdateWrapper<UserStatistics> updateStatisticsWrapper = new UpdateWrapper<>();
                        updateStatisticsWrapper.lambda().setSql("num = num + 1").eq(UserStatistics::getTenantNation, tenantNation).eq(UserStatistics::getStatisticsDate,date);
                        baseMapper.update(null, updateStatisticsWrapper);
                    }
                } else {
                    // 获取锁失败,处理逻辑
                    log.error("【统计用户表】insert Statistics 获取锁失败。");
                }
            } catch (InterruptedException e) {
                e.printStackTrace();
            } finally {
                if (isLocked) {
                    lock.unlock();
                }
            }
        }else{
            log.error("【统计表新增用户】查询统计表出现脏数据:入参tenantNation={},statisticsDate={}",tenantNation,statisticsDate);
        }

        return 1;
    }

数据库表结构

#会员日统计表
CREATE TABLE `os_tb_user_statistics` (
  `id` int(20) NOT NULL AUTO_INCREMENT COMMENT 'ID',
  `tenant_nation` varchar(10) DEFAULT '' COMMENT '租户',
  `statistics_date` date DEFAULT NULL COMMENT '日期yyyy-MM-dd',
  `num` int(10) DEFAULT '0' COMMENT '新加入数量',
  PRIMARY KEY (`id`) USING BTREE
) ENGINE=InnoDB AUTO_INCREMENT=34 DEFAULT CHARSET=utf8mb4 ROW_FORMAT=DYNAMIC COMMENT='会员日统计表';

分析

这个问题关键点在于:RR模式的可重复读,导致每次读取为快照读,而非当前读,从而导致查询非实时数据,导致每次早上的时候都会有两条以上的某个国家今日的统计记录

在RC模式下,每次查询都创建自己的快照(相当于当前读),所以在 RC 级别下,可以看到其他事务已经提交的数据,但是不能读取其他事务中未提交的数据。这种行为被称为"非重复读",因为同一事务中的两个相同查询可能会返回不同的结果。

时间 事务1 事务2
1 begin; begin;
2 #先查一遍是否存在 select * #先查一遍是否存在 select *
3 (不存在,获取分布式锁) (不存在,获取分布式锁)
4 获取到锁再查询一遍 select *
5 (还是不存在,准备插入)
6 执行insert
7 commit;
8 获取到锁再查询一遍 select *
9 (由于产生了快照读,所以看不到,准备插入)
10 执行insert
11 commit;

解决方案

在不改变数据库隔离级别的情况下,有如下两种方案

  1. 第二次查询改为select for update当前读,并使用声明式事务,保证读取的真实性,这样相当于引入了mysql的行锁,另外,如果使用 SELECT FOR UPDATE 查询某一行时,如果该行不存在,是不会开启行锁的
  2. 前置分布式锁,将分布式锁的获取放到最外面,并加上异步,让整个程序串行执行,

你可能感兴趣的:(mysql,分布式,数据库)