程序直接宕机,并在error.log日志中发现大量的报错日志,如下:
### Error updating database.
Cause: com.mysql.cj.jdbc.exceptions.MySQLTransactionRollbackException:
Lock wait timeout exceeded; try restarting transaction
### The error may exist in com/xxx/dao/mapper/xxxMapper.java (best guess)
### The error may involve com.xxx.dao.mapper.xxxMapper.update-Inline
### SQL: UPDATE xxx_table SET a1=?, a2=?, a3=?, a4=?, a5=? WHERE (q1= ?)
程序中使用的是mybatis-plus,报错信息准确提示出了对应的mapper文件以及对应的sql操作是updateById语句。再结合其他日志信息,初步判断出了对应的位置以及症结可能与事务操作有关。
二. 可能原因
事务过程中执行其他非数据库操作,导致事务长期未被处理
事务未被正常处理
网段导致应用端请求未被正常发送给数据库,数据库等待应用后续操作
应用服务器性能问题,CPU爆满等导致应用无法及时切换到该进程进行处理
根据以上原因,首先排查服务器情况发现不存在cpu爆满的情况,后续继续追究MySQL事务方面的问题。
从MySQL入手,执行以下语句查看锁情况:
-- 查看正在锁的事务
SELECT * FROM INFORMATION_SCHEMA.INNODB_LOCKS;
-- 查看等待锁的事务
SELECT * FROM INFORMATION_SCHEMA.INNODB_LOCK_WAITS;
(由于笔者的线上环境执行了重启,所以这会儿已经看不到具体结果了,实际上应该能看到对应等待锁的事务)
但是可以模拟线上情况,比如在代码中的@Transaction代码中打个debug断点,然后观察情况:
-- 查看执行进程
SHOW PROCESSLIST;
-- 执行查询未提交事务语句
SELECT * from information_schema.INNODB_TRX;
基本可以确定问题出现在代码层。
生产伪代码如下:
@Transactional(rollbackFor = Exception.class)
public synchronized boolean doSomething(Object o){
// ...
if(flag){
aMapper.updateById(o);
}
// ...
bMapper.insert(o);
return true;
}
这里的目的是做一个事务,aMapper需要根据某个条件判断更新操作,bMapper则需要根据业务流程做新增操作,同时为了考虑并发情况下的操作所以在方法名上加了synchronized 同步锁。咋一看感觉没什么问题,但是在service层的方法上同时使用事务和锁无法保证同步。
原理是:@Transactional是使用了Sping AOP 实现的;Synchronized只是锁当前代码块,当执行完Synchronized包含的代码块就已经执行完了;此时@Transactional还未提交,所以就造成上一个事务没有提交,而下一个请求过来了争夺数据库的事务锁,在大量请求的情况下,有可能会承受不住。
针对这种情况,我们得保证让Synchronized的锁范围比@Transactional作用范围大,于是可以做如下改造:
public synchronized boolean doSomething(Object o){
return transactionDoSomething(o)
}
@Transactional(rollbackFor = Exception.class)
public boolean transactionDoSomething(Object o){
// ...
if(flag){
aMapper.updateById(o);
}
// ...
bMapper.insert(o);
}
另外一种方案:
@Transactional(rollbackFor = Exception.class)
public boolean transactionDoSomething(Object o){
synchronized(this){
// doSomething
return true;
}
}
三. 扩展(ReentrantLock/druid自动重连数据库)
3.1 根因分析
经过以上处理,实际上应该不会再出现数据库死锁的情况了,但是经过日志的进一步查看发现没有将@Transaction与synchronized一起使用的地方也出现过死锁情况,在此处仅仅使用了synchronized同步锁,唯一与以上情况相似的就是同样使用了updateById。
public synchronized void doSomething(Object o){
// ...
xxxMapper.updateById(o);
}
剩下就是还有一种可能,网络波动、防火墙或是其他原因,导致了应用和mysql的连接断开,这个时候进入到这个代码块的执行逻辑在使用已经失败的连接,又由于这是加了synchronized的同步代码块,第一个进来的请求一直在等待连接导致后续进来的请求全都被堵塞。
3.2 改造synchronized使用ReentrantLock
于是我们在此处尽量引入一种比较友好的锁,ReentrantLock。
ReentrantLock的tryLock方法支持配置最大同步等待时间,由此来避免以上堵塞情况,写法如下:
@Service
public class LockService{
// 初始化ReentrantLock
private final Lock reentrantLock = new ReentrantLock();
// 设置最大锁等待时间
private final long tryLockTime = 2L;
public void addRound(Long manMachineId,LocalDateTime time) {
try {
if (reentrantLock.tryLock(tryLockTime, TimeUnit.SECONDS)) {
System.out.println("获取到锁,开始做一些事...");
}else {
System.out.println("等待2s后获取锁失败");
}
} catch (InterruptedException e) {
e.printStackTrace();
} finally {
// 最终需要释放锁
reentrantLock.unlock();
}
}
}
程序中使用了druid,所以我们加入以下配置信息即可实现在数据库停止的时候等待数据库恢复并自动重连,配置如下:
# 标记当Statement或连接被泄露时是否打印程序的stack traces日志
spring.datasource.druid.log-abandoned=true
# 是否自动回收超时连接
spring.datasource.druid.remove-abandoned=true
# 超时时间(以秒数为单位)
spring.datasource.druid.remove-abandoned-timeout=10
四. 总结
至此,本次MySQL线上死锁问题就已结束排查。
由于线上问题一般都比较复杂或者比较难复现,所以排查线上问题首先需要分析日志,这个时候就要求我们程序的日志要尽可能做到完善。
然后就是大胆猜测,小心验证,其中不免会经历多次推到重来的历程。此后该问题再次出现就不会再成为你的问题。