分布式鎖與數(shù)據(jù)庫事務(wù)問題記錄

? 前段時間,做了一個線上會議室預(yù)約的項目,需求是這樣的:有500個會議室,支持并發(fā)預(yù)約,且會議不能跨天,并且要求會議越離散越好。

? 這個需求首先會議室預(yù)約時間不能沖突,而且還需要滿足會議時間間隔越大越好,同時還需要支持并發(fā)預(yù)約。因此設(shè)計了一個會議室分配算法,采用最優(yōu)離散分配(具體可以看前面的博客),而且需要支持并發(fā)預(yù)約,因為服務(wù)是一個多臺機(jī)器組合的集群系統(tǒng),因此考慮分布式鎖。同時會議預(yù)約成功情況下,需要修改數(shù)據(jù)庫數(shù)據(jù),因此考慮數(shù)據(jù)庫事務(wù),保證數(shù)據(jù)的一致性。

? 考慮到預(yù)約會議是按照天為單位的,在分布式加鎖的時候,可以按照當(dāng)天的日期作為Key的一部分進(jìn)行鎖定。

? 具體的代碼如下:

@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(), "會議室已全部分配完成,請更換預(yù)定時間");

          MeetingOnlineRoom meetingOnlineRoom = meetingOnlineRoomMapper.selectById(room.get());
          String password = UUID.randomUUID().toString().substring(0, 15);
          //TODO 調(diào)用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, "網(wǎng)絡(luò)異常,請稍后重試");
      }finally {
          distributedLock.release(key, lock);
      }
      return detail;
  }

? 初看上面的代碼沒問題,而且在使用單線程接口測試的情況下也正常。但是當(dāng)開啟100個線程,隨機(jī)預(yù)約一個月內(nèi)的會議時,發(fā)現(xiàn)了同一個會議時,預(yù)約的會議時間重復(fù)了。

是什么原因?qū)е聲h室被重復(fù)預(yù)約了呢?第一個想法是不是分布式鎖出現(xiàn)了問題,因此

首先,對于分布式鎖進(jìn)行了測試,發(fā)現(xiàn)是正常,能夠阻斷其他同一天預(yù)約的會議。具體代碼如下:

@Component("distributedLock")
public class DistributedLock {

    /**
     * 默認(rèn)的超時時間為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對應(yīng)的值, 釋放鎖時需要用到
     */
    public String lock(String key) {
        return this.lock(key, DEFAULT_MILLISECOND_TIMEOUT);
    }

    /**
     * 加鎖
     *
     * @param key 鍵
     * @param time 超時時間
     * @param unit 時間單位
     * @return value key對應(yīng)的值, 釋放鎖時需要用到
     */
    public String lock(String key, long time, TimeUnit unit) {
        return this.lock(key, unit.toMillis(time));
    }

    /**
     * 加鎖
     *
     * @param key 鍵
     * @param msTimeout 超時時間, 單位為ms
     * @return value key對應(yīng)的值, 釋放鎖時需要用到
     */
    public String lock(String key, long msTimeout) {
        ifEmptyThrowException(key);
        // 值
        String value = UUID.randomUUID().toString();
        // 是否是第一次嘗試獲取鎖
        boolean isFirst = true;
        // 命令執(zhí)行的結(jié)果
        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

在驗證了分布式鎖正常的情況下,開始思考是什么原因?qū)е碌摹?/p>

最后考慮到一種可能,是否是數(shù)據(jù)庫事務(wù)未提交的情況下,然后用戶釋放了鎖,由于數(shù)據(jù)庫采用的Mysql,而且數(shù)據(jù)庫事務(wù)的隔離級別為可重復(fù)讀。

隔離級別 第一類丟失更新 第二類丟失更新 臟讀 不可重復(fù)讀 幻讀
SERIALIZABLE (串行化) 避免 避免 避免 避免 避免
REPEATABLE READ(可重復(fù)讀) 避免 避免 避免 避免 允許
READ COMMITTED (讀已提交) 避免 允許 避免 允許 允許
READ UNCOMMITTED(讀未提交) 避免 允許 允許 允許 允許

從表中可以看出,可重復(fù)讀會產(chǎn)生幻讀的情況。下面解析下出現(xiàn)幻讀的過程:

? 假設(shè),A打算預(yù)約2020-06-11 18:00 - 2020-06-11 19:00 時段的會議,B也打算預(yù)約了2020-06-11 18:00 -2020-06-11 19:00時段的會議。

然后A先搶占到了分布式鎖,B則等待A鎖的釋放。假設(shè)A發(fā)現(xiàn)會議室Id=1的這個時間段未被預(yù)約,因此預(yù)約這個時段,預(yù)約完成后,A釋放鎖,但是A的事務(wù)還未來得及提交。

? 由于鎖已經(jīng)釋放了,因此B也能進(jìn)行預(yù)約,B也進(jìn)行加鎖,然后B也發(fā)現(xiàn)會議室id=1的這個時段也沒有被預(yù)約,因此B也預(yù)約的該時段。

? 此時A提交了事務(wù),然后B釋放鎖,并且也提交了事務(wù)。最終發(fā)現(xiàn)會議室Id=1的,同時被2場會議預(yù)約了成功了。

? 其實解決這個問題也很簡單,將加鎖的的操作,放在事務(wù)的外層,保證事務(wù)提交成功后,才能進(jìn)行鎖的釋放,后面也是這樣修改的,最終測試結(jié)果再也沒有出現(xiàn)時間沖突的問題了。

最后編輯于
?著作權(quán)歸作者所有,轉(zhuǎn)載或內(nèi)容合作請聯(lián)系作者
【社區(qū)內(nèi)容提示】社區(qū)部分內(nèi)容疑似由AI輔助生成,瀏覽時請結(jié)合常識與多方信息審慎甄別。
平臺聲明:文章內(nèi)容(如有圖片或視頻亦包括在內(nèi))由作者上傳并發(fā)布,文章內(nèi)容僅代表作者本人觀點,簡書系信息發(fā)布平臺,僅提供信息存儲服務(wù)。

友情鏈接更多精彩內(nèi)容