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

秋风落叶黄关注

0.0892020.06.29 18:09:16字数 984阅读 530

​ 前段时间,做了一个线上会议室预约的项目,需求是这样的:有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场会议预约了成功了。

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

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

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

余额充值