美文网首页
分布式锁与数据库事务问题记录

分布式锁与数据库事务问题记录

作者: 秋风落叶黄 | 来源:发表于2020-06-29 18:09 被阅读0次

    ​ 前段时间,做了一个线上会议室预约的项目,需求是这样的:有500个会议室,支持并发预约,且会议不能跨天,并且要求会议越离散越好。

    ​ 这个需求首先会议室预约时间不能冲突,而且还需要满足会议时间间隔越大越好,同时还需要支持并发预约。因此设计了一个会议室分配算法,采用最优离散分配(具体可以看前面的博客),而且需要支持并发预约,因为服务是一个多台机器组合的集群系统,因此考虑分布式锁。同时会议预约成功情况下,需要修改数据库数据,因此考虑数据库事务,保证数据的一致性。

    ​ 考虑到预约会议是按照天为单位的,在分布式加锁的时候,可以按照当天的日期作为Key的一部分进行锁定。

    ​ 具体的代码如下:

    @Transactional(rollbackFor = Exception.class)
      public MeetingOnlineRoomBookingDetail assignOnlineRoom(Date startTime, Date endTime) throws Exception {
          String key = "assign_room_lock";
          SimpleDateFormat format = new SimpleDateFormat("yyyy-MM-dd");
          String key = "assign_room_lock_" + format.format(startTime);
          MeetingOnlineRoomBookingDetail detail;
          //加锁
          String lock = distributedLock.lock(key, 10, TimeUnit.SECONDS);
          try {
              Optional<Long> room = meetingOnlineRoomService.queryAssignableMeetingOnlineRoom(startTime, endTime);
              Preconditions.checkArgument(room.isPresent(), "会议室已全部分配完成,请更换预定时间");
    
              MeetingOnlineRoom meetingOnlineRoom = meetingOnlineRoomMapper.selectById(room.get());
              String password = UUID.randomUUID().toString().substring(0, 15);
              //TODO 调用zoom分配接口
    
              detail = new MeetingOnlineRoomBookingDetail()
                      .setStartTime(startTime)
                      .setEndTime(endTime)
                      .setRoomId(room.get())
                      .setZoomId(meetingOnlineRoom.getZoomId())
                      .setPassword(password);
    
              this.saveMeetingOnlineRoomBookingDetail(detail);
          } catch (IllegalArgumentException e) {
              log.error("assignOnlineRoom IllegalArgumentException:", e);
              throw new BusinessException(ApiCode.NOT_FOUND.getCode(), e.getMessage());
          } catch (Exception e) {
              log.error("assignOnlineRoom Exception:", e);
              throw new BusinessException(500, "网络异常,请稍后重试");
          }finally {
              distributedLock.release(key, lock);
          }
          return detail;
      }
    

    ​ 初看上面的代码没问题,而且在使用单线程接口测试的情况下也正常。但是当开启100个线程,随机预约一个月内的会议时,发现了同一个会议时,预约的会议时间重复了。

    是什么原因导致会议室被重复预约了呢?第一个想法是不是分布式锁出现了问题,因此

    首先,对于分布式锁进行了测试,发现是正常,能够阻断其他同一天预约的会议。具体代码如下:

    @Component("distributedLock")
    public class DistributedLock {
    
        /**
         * 默认的超时时间为20s
         */
        private static final long DEFAULT_MILLISECOND_TIMEOUT = 20000L;
    
        public final static Long TIMEOUT = 10000L;
    
        private static final String LOCK_PREFIX = "distribute_lock_";
    
        private static final long LOCK_EXPIRE = 1000L;
    
        /**
         * redis的字符串类型模板
         */
        private StringRedisTemplate stringRedisTemplate;
    
        /**
         * 释放锁的lua脚本
         */
        private DefaultRedisScript<Long> releaseLockScript;
    
    
        public DistributedLock(StringRedisTemplate stringRedisTemplate) {
            this.releaseLockScript = new DefaultRedisScript<>();
            this.releaseLockScript.setResultType(Long.class);
            this.releaseLockScript.setScriptSource(new ResourceScriptSource(new ClassPathResource("script/release_lock.lua")));
            this.stringRedisTemplate = stringRedisTemplate;
        }
    
        /**
         * key为null或空直接抛出异常
         */
        private void ifEmptyThrowException(String key) {
            int keyLen;
            if (key == null || (keyLen = key.length()) == 0) {
                throw new IllegalArgumentException("key is not null and empty!");
            }
            for (int i = 0; i < keyLen; i++) {
                if (!Character.isWhitespace(key.charAt(i))) {
                    return;
                }
            }
            throw new IllegalArgumentException("key is not null and not empty!");
        }
    
        /**
         * 加锁
         *
         * @param key 键
         * @return value key对应的值, 释放锁时需要用到
         */
        public String lock(String key) {
            return this.lock(key, DEFAULT_MILLISECOND_TIMEOUT);
        }
    
        /**
         * 加锁
         *
         * @param key 键
         * @param time 超时时间
         * @param unit 时间单位
         * @return value key对应的值, 释放锁时需要用到
         */
        public String lock(String key, long time, TimeUnit unit) {
            return this.lock(key, unit.toMillis(time));
        }
    
        /**
         * 加锁
         *
         * @param key 键
         * @param msTimeout 超时时间, 单位为ms
         * @return value key对应的值, 释放锁时需要用到
         */
        public String lock(String key, long msTimeout) {
            ifEmptyThrowException(key);
            // 值
            String value = UUID.randomUUID().toString();
            // 是否是第一次尝试获取锁
            boolean isFirst = true;
            // 命令执行的结果
            Boolean result = false;
            do {
                // 不是第一次尝试获取锁则要睡眠20ms
                if (!isFirst) {
                    try {
                        Thread.sleep(20);
                    } catch (Exception e) {
                        log.error("DistributedLock lock sleep error", e);
                    }
                } else {
                    isFirst = false;
                }
                result = stringRedisTemplate.opsForValue().setIfAbsent(key, value, msTimeout, TimeUnit.MILLISECONDS);
            } while (result == null || Boolean.FALSE.equals(result));
            return value;
        }
    
    
    
        /**
         * 释放锁
         *
         * @param key 键
         * @param value 值
         */
        public void release(String key, String value) {
            ifEmptyThrowException(key);
            try {
                stringRedisTemplate.execute(releaseLockScript, Collections.singletonList(key), value);
            } catch (Exception e) {
                log.error("DistributedLock release lock error", e);
            }
        }
    }
    

    lua脚本如下:

    if redis.call("get", KEYS[1]) == ARGV[1] then
        return redis.call("del", KEYS[1])
    else
        return 0
    end
    

    在验证了分布式锁正常的情况下,开始思考是什么原因导致的。

    最后考虑到一种可能,是否是数据库事务未提交的情况下,然后用户释放了锁,由于数据库采用的Mysql,而且数据库事务的隔离级别为可重复读。

    隔离级别 第一类丢失更新 第二类丢失更新 脏读 不可重复读 幻读
    SERIALIZABLE (串行化) 避免 避免 避免 避免 避免
    REPEATABLE READ(可重复读) 避免 避免 避免 避免 允许
    READ COMMITTED (读已提交) 避免 允许 避免 允许 允许
    READ UNCOMMITTED(读未提交) 避免 允许 允许 允许 允许

    从表中可以看出,可重复读会产生幻读的情况。下面解析下出现幻读的过程:

    ​ 假设,A打算预约2020-06-11 18:00 - 2020-06-11 19:00 时段的会议,B也打算预约了2020-06-11 18:00 -2020-06-11 19:00时段的会议。

    然后A先抢占到了分布式锁,B则等待A锁的释放。假设A发现会议室Id=1的这个时间段未被预约,因此预约这个时段,预约完成后,A释放锁,但是A的事务还未来得及提交。

    ​ 由于锁已经释放了,因此B也能进行预约,B也进行加锁,然后B也发现会议室id=1的这个时段也没有被预约,因此B也预约的该时段。

    ​ 此时A提交了事务,然后B释放锁,并且也提交了事务。最终发现会议室Id=1的,同时被2场会议预约了成功了。

    ​ 其实解决这个问题也很简单,将加锁的的操作,放在事务的外层,保证事务提交成功后,才能进行锁的释放,后面也是这样修改的,最终测试结果再也没有出现时间冲突的问题了。

    相关文章

      网友评论

          本文标题:分布式锁与数据库事务问题记录

          本文链接:https://www.haomeiwen.com/subject/szdwnhtx.html