前段时间,做了一个线上会议室预约的项目,需求是这样的:有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场会议预约了成功了。
其实解决这个问题也很简单,将加锁的的操作,放在事务的外层,保证事务提交成功后,才能进行锁的释放,后面也是这样修改的,最终测试结果再也没有出现时间冲突的问题了。
网友评论