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

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

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

最后编辑于
©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 216,997评论 6 502
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 92,603评论 3 392
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 163,359评论 0 353
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 58,309评论 1 292
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 67,346评论 6 390
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 51,258评论 1 300
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 40,122评论 3 418
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 38,970评论 0 275
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 45,403评论 1 313
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 37,596评论 3 334
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 39,769评论 1 348
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 35,464评论 5 344
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 41,075评论 3 327
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 31,705评论 0 22
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 32,848评论 1 269
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 47,831评论 2 370
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 44,678评论 2 354