? 前段時間,做了一個線上會議室預(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)時間沖突的問題了。