前言
并发场景:线上保险业务的并发工单申请
一、场景描述
某机构在一个客服在线的情况下,只能办理一笔工单并同时支持一个客户提交工单进行排队等待队列。而测试过程中发现在并发申请的情况下多个工单均有可能同时进入排队等待队列,超出了可排队的队列最大值,由于系统处于分布式架构因此需要使用分布式锁,但甲方资源只支持Oracle,不提供Redis等资源,因此在考虑实际并发量不大的情况下选择使用数据库分布式锁处理。
二、分布式锁设计
1.锁设计
采用新建LOCK表,通过数据库的主键唯一索引约束实现加锁操作。
1.自旋的方式避免非阻塞的形式,实现一个请求内支持多次取锁尝试而无需前端进行多次请求。
2.并发量不够大和触发机制频率一般的情况下,使用随机睡眠避免服务器资源过度损耗(睡眠时间相同有可能出现多线程同时睡眠同时抢锁)。
3.设置循环计数最大值强制返回,避免在锁长期未释放或类死锁状态下,多请求无止境自旋导致数据库连接泄漏系统崩溃。
4.需要使用编程式事务进行手动回滚和日志打印以及对前端的正常信息返回
5.在涉及到事务嵌套的情况下需要保证内层事务回滚不影响外层事务提交
缺陷:未实现重入和锁过期时间以及锁的自动续期,因此可能造成死锁现象;
public boolean checkApplyTaskLock(String deptId){
TransactionStatus status = transactionManager.getTransaction(defaultTransDefinition);
try {
DcTaskLock dcTaskLock1 = new DcTaskLock();
dcTaskLock1.setLockTaskId(deptId);
dcTaskLockMapper.insert(dcTaskLock1);
transactionManager.commit(status);
logger.info("getCheckApplyTaskLock: deptId:[{}],获取锁成功并提交!",deptId);
return true;
} catch (Exception e) {
e.printStackTrace();
/**
* 此处由于内外共用一个事务且内层try catch住了,会抛出异常“Transaction rolled back because it has been marked as rollback-only”,是由于内层事务使用的默认传播行为“DefaultTransactionDefinition”,
* 内外共用一个事务时,内层方法出异常了,会继续向上抛异常给SpringAOP拦截,事务会被标记为rollback only,
* 外层方法继续提交。但内层方法已经标记准备回滚了,就会抛这个异常。
* 处理方法:1.更改内层事务传播性为REQUIRES_NEW实现内外层两个独立事务;2.设置内层事务手动回滚标记回滚“TransactionAspectSupport.currentTransactionStatus().setRollbackOnly()”;
*/
TransactionAspectSupport.currentTransactionStatus().setRollbackOnly();
transactionManager.rollback(status);
}
logger.error("getCheckApplyTaskLock:"+deptId+", 获取锁失败并回滚!");
return false;
}
/**
* 判断是否能申请工单时针对waitingNumber的分布式锁
* 需采用编程式事务手动捕获异常处理,由于加锁外层方法使用了声明式事务存在事务嵌套,需考虑传播性
* 开启异步IO的情况下,最大IOPS:single-DB:500;RAC-DB:12500
* 强烈不建议使用数据库分布式锁进行自旋,使用队列或其他共享变量的方式可以放弃自旋
* ehcache集群的组传播机制不支持分布式锁
* @param deptId
* @return
*/
public boolean getCheckApplyTaskLock(String deptId) {
//保证请求的最大自旋时间10秒左右
int maxTime = 0;
while (true) {
//自旋执行上锁操作
if (checkApplyTaskLock(deptId)){
return true;
}else {
if (maxTime >= 5){
//请求自旋超时,自动放弃取锁;
logger.error("请求自旋超时,强制放弃取锁 deptId:{} , 请排查数据库是否死锁,避免连接泄漏!",deptId);
return false;
}
//上锁失败则睡眠后自旋,非公平锁
try {
maxTime++;
Random random = new Random();
//随机等待1500-2500毫秒
Thread.sleep(random.nextInt(1000)+1500);
} catch (InterruptedException e) {
e.printStackTrace();
}
}
}
}
public boolean releaseCheckApplyTaskLock(String deptId) {
TransactionStatus status = transactionManager.getTransaction(defaultTransDefinition);
try {
int deleteLock = dcTaskLockMapper.deleteByPrimaryKey(deptId);
transactionManager.commit(status);
return deleteLock>0;
} catch (Exception e) {
e.printStackTrace();
transactionManager.rollback(status);
}
logger.error("releaseCheckApplyTaskLock:"+deptId+" ,失败并回滚!");
return false;
}
2.上锁阶段
出现并发申请的工单均可进入排队等待队列的原因是在查询getWaitingNumber时没有上锁,导致A请求个B请求同时进来同时查询到等待队列为0的时候就均可成功进入排队等待队列。因此需要在此处上锁
//超出最大排队人数--如果第一个为真则不进行后续判断直接进入if内
// ----此处如果菜单循环为true,则直接往下走不执行取锁判断,如果菜单循环为false表示请求执行申请工单业务逻辑,则执行取锁判断自旋阻塞直到取到锁返回true在往下走;
if ("0".equals(applyStatus) || lock.getCheckApplyTaskLock(deptId)) {
//加锁后获取当前机构的等待工单数量
int waitingNumber = Integer.parseInt(getWaitingNumber(deptId).get("waitingNumber").toString());
3.解锁阶段
1.当已判断出超出最大排队人数后则需要进行解锁操作。
2.当判断当前申请工单可以排队,则在工单创建方法的结尾进行解锁操作。
if (waitingNumber >= maxWaitCount) {
//当前等待人数已经大于等于最大等待人数了就不支持排队
returnJson.put("code", "4000");
returnJson.put("msg", "已超出最大排队等待人数,无法进行排队");
returnJson.put("waitCount", waitingNumber);
returnJson.put("waitTime", "∞");
returnJson.put("serviceNum", serviceNum);
returnJson.put("maxWaitCount", maxWaitCount);
//此处需要释放锁,避免取锁后未创建工单导致死锁
if (lock.releaseCheckApplyTaskLock(deptId)){
logger.info("deptId: {} 申请工单超出排队最大值,分布式锁已成功解锁!",deptId);
}else {
logger.error("deptId: {} 申请工单超出排队最大值,分布式锁解锁失败!可能造成死锁!",deptId);
}
return returnJson;
public Json serviceApply(Task task, boolean hasAppointment) {
try {
。。。
。。。
。。。
} finally {
//此处表示一整个创建工单流程走完,无论最终工单是否成功创建都要在此处释放锁
if (lock.releaseCheckApplyTaskLock(task.getCustomerDepartmentId())){
logger.info("deptId: {} 工单创建完毕,分布式锁已成功解锁!",task.getCustomerDepartmentId());
}else {
logger.error("deptId: {} 工单创建完毕,分布式锁解锁失败!可能造成死锁!",task.getCustomerDepartmentId());
}
}
总结
1.可以采用定时任务的方式进行锁的清除,在LOCK表添加一个记录锁使用更新的心跳字段,并在逻辑代码层面进行锁的心跳的更新,然后定时任务进行循环检测每个锁的使用状态,当发现锁的心跳长时间未更新则进行锁清除操作。 2.重入锁则可以增加一个客户ip记录或客户机器码记录,当后续别的异步操作或微服务系统需要加锁检测到此机器码已在另一个系统持有锁了,则直接通过。可恶的甲方,Oracle迟早要爆