? 前段時間爆价,做了一個線上會議室預約的項目,需求是這樣的:有500個會議室媳搪,支持并發(fā)預約铭段,且會議不能跨天,并且要求會議越離散越好秦爆。
? 這個需求首先會議室預約時間不能沖突序愚,而且還需要滿足會議時間間隔越大越好,同時還需要支持并發(fā)預約等限。因此設計了一個會議室分配算法爸吮,采用最優(yōu)離散分配(具體可以看前面的博客),而且需要支持并發(fā)預約望门,因為服務是一個多臺機器組合的集群系統(tǒng)形娇,因此考慮分布式鎖。同時會議預約成功情況下筹误,需要修改數(shù)據(jù)庫數(shù)據(jù)桐早,因此考慮數(shù)據(jù)庫事務,保證數(shù)據(jù)的一致性纫事。
? 考慮到預約會議是按照天為單位的勘畔,在分布式加鎖的時候所灸,可以按照當天的日期作為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個線程侠驯,隨機預約一個月內的會議時抡秆,發(fā)現(xiàn)了同一個會議時,預約的會議時間重復了吟策。
是什么原因導致會議室被重復預約了呢儒士?第一個想法是不是分布式鎖出現(xiàn)了問題,因此
首先檩坚,對于分布式鎖進行了測試着撩,發(fā)現(xiàn)是正常诅福,能夠阻斷其他同一天預約的會議。具體代碼如下:
@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;
// 命令執(zhí)行的結果
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
在驗證了分布式鎖正常的情況下拖叙,開始思考是什么原因導致的氓润。
最后考慮到一種可能,是否是數(shù)據(jù)庫事務未提交的情況下薯鳍,然后用戶釋放了鎖咖气,由于數(shù)據(jù)庫采用的Mysql,而且數(shù)據(jù)庫事務的隔離級別為可重復讀挖滤。
隔離級別 | 第一類丟失更新 | 第二類丟失更新 | 臟讀 | 不可重復讀 | 幻讀 |
---|---|---|---|---|---|
SERIALIZABLE (串行化) | 避免 | 避免 | 避免 | 避免 | 避免 |
REPEATABLE READ(可重復讀) | 避免 | 避免 | 避免 | 避免 | 允許 |
READ COMMITTED (讀已提交) | 避免 | 允許 | 避免 | 允許 | 允許 |
READ UNCOMMITTED(讀未提交) | 避免 | 允許 | 允許 | 允許 | 允許 |
從表中可以看出崩溪,可重復讀會產生幻讀的情況。下面解析下出現(xiàn)幻讀的過程:
? 假設斩松,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發(fā)現(xiàn)會議室Id=1的這個時間段未被預約,因此預約這個時段岭参,預約完成后反惕,A釋放鎖,但是A的事務還未來得及提交演侯。
? 由于鎖已經釋放了姿染,因此B也能進行預約,B也進行加鎖秒际,然后B也發(fā)現(xiàn)會議室id=1的這個時段也沒有被預約悬赏,因此B也預約的該時段。
? 此時A提交了事務娄徊,然后B釋放鎖闽颇,并且也提交了事務。最終發(fā)現(xiàn)會議室Id=1的寄锐,同時被2場會議預約了成功了兵多。
? 其實解決這個問題也很簡單,將加鎖的的操作橄仆,放在事務的外層剩膘,保證事務提交成功后,才能進行鎖的釋放盆顾,后面也是這樣修改的怠褐,最終測試結果再也沒有出現(xiàn)時間沖突的問題了。