人生苦短,不如养狗
一、问题现场
趁着这几天过节,复盘了一下去年的一些历史遗留问题,其中有这样一个关于数据库的小问题让我忍不住翻出来又回味了一下,下面就让我们一起品味品味。
首先,先来看下问题现场,操作数据库的执行流程如下图:
这里对原有的业务逻辑进行简化,简化后的代码实现如下:
public void finishSubTask(SubTask subTask){
// 进行子任务状态更新
subTaskService.updateFinish(subTask);
// 根据主任务id查询正在运行中的任务
int runningSubTaskNum = subTaskService.countRunning(subTask.getTaskId);
// 如果当前运行中任务为空,则终止主任务
if (runningSubTaskNum == 0) {
taskService.updateFinish(subTask.getTaskId);
}
}
乍一看好像逻辑和代码没有什么问题,但是在实际运行过程中有时会出现查询语句查出来的结果集是更新前的结果集,就好像更新没有生效或者“丢失”了,导致没有成功将对应的主任务终止。
二、追根溯源
在开始查案之前先说一下环境情况,MySQL版本为5.6(阿里云高可用版本,即一主一备,事务隔离级别为读已提交),服务端使用的是SpringBoot和MyBatis框架。
1. 现场查看
遇到问题的第一时间是去查看了一下数据库是不是更新出了问题,但是查询之后发现数据确实是更新了,接着再去查看了一下当时机器的网络问题,并没有报数据库连接异常等问题。到这里,代码异常问题和网络抖动问题基本可以排除。
2. 尝试复现
在无法从问题现场获取更多线索的情况下,我开始尝试在本地进行复现,但是在进行多次尝试之后,发现本地无法复现出当时的场景,上述的流程总是能正确的执行。
3. 谨慎推理
在本地复现失败之后,结合最初的问题现场排查,问题大致出在系统内ORM框架(这里即Mybatis)的SQL执行流程或是MySQL服务端的SQL执行流程上。
MyBatis的SQL执行过程:
假定问题出现在MyBatis的SQL执行过程,那么可能导致的原因有两个:
- 由于 读取本地缓存 导致;
- 由于 事务隔离性 导致;
由于当前的项目中MyBatis相关的配置的基本都是缺省,所以MyBatis只使用了一级缓存,且只在session级别进行共享。为了确定sqlSession的生命周期,这里具体看下Mybatis当中执行代理方法的逻辑(Spring框架版本):
private class SqlSessionInterceptor implements InvocationHandler {
@Override
public Object invoke(Object proxy, Method method, Object[] args) throws Throwable {
// 获取实际进行数据库操作的sqlSession
SqlSession sqlSession = getSqlSession(SqlSessionTemplate.this.sqlSessionFactory,
SqlSessionTemplate.this.executorType, SqlSessionTemplate.this.exceptionTranslator);
try {
// 代理方法执行
Object result = method.invoke(sqlSession, args);
if (!isSqlSessionTransactional(sqlSession, SqlSessionTemplate.this.sqlSessionFactory)) {
// force commit even on non-dirty sessions because some databases require
// a commit/rollback before calling close()
// 如果没有将事务托管给Spring则手动进行事务提交
sqlSession.commit(true);
}
return result;
} catch (Throwable t) {
// 异常处理,省略
} finally {
if (sqlSession != null) {
// 如果sqlSession不为空则进行会话关闭
closeSqlSession(sqlSession, SqlSessionTemplate.this.sqlSessionFactory);
}
}
}
}
}
这里具体看下获取sqlSession方法 getSqlSession :
public static SqlSession getSqlSession(SqlSessionFactory sessionFactory, ExecutorType executorType,
PersistenceExceptionTranslator exceptionTranslator) {
notNull(sessionFactory, NO_SQL_SESSION_FACTORY_SPECIFIED);
notNull(executorType, NO_EXECUTOR_TYPE_SPECIFIED);
// 这里由于没有将事务托管给Spring,无法将新创建的sqlSession注册到sessionHolder集合中,所以每次获取到的sqlSessionHolder都是null
SqlSessionHolder holder = (SqlSessionHolder) TransactionSynchronizationManager.getResource(sessionFactory);
SqlSession session = sessionHolder(executorType, holder);
if (session != null) {
return session;
}
LOGGER.debug(() -> "Creating a new SqlSession");
session = sessionFactory.openSession(executorType);
// 只有当事务托管给Spring,才会将新创建的session注册到sessionHolder集合中当中,这里为了线程隔离,使用ThreadLocal进行存储
registerSessionHolder(sessionFactory, executorType, exceptionTranslator, session);
return session;
}
再次观察原先的代码,这里没有使用Spring相关的事务管理方法,即没有将事务托管给Spring,所以在 getSqlSession 方法中每次获取都是一个新的sqlSession,这也就代表不会存在查询语句查询的结果为上次查询保留在sqlSession中的缓存,即该问题不会是由于Mybatis的一级缓存导致的。
那么是否是由于事务隔离性导致的呢?上面说过,项目基本所有的配置都是保持缺省,这里Spring的事务隔离级别和数据库保持一致(虽然没有使用到Spring事务管理)。观察上面的sqlSession执行方法,由于没有将事务托管给Spring,在进行代理方法之后后,sqlSession会主动强制做一次 commit 操作,无论当前是否有脏页。按照上面的执行顺序来讲,查询事务是在更新事务提交之后才开始的,理论上不应该出现查询到更新事务提交之前的数据。
分析MyBatis执行过程无果,只能将目光投向MySQL服务器的内部执行过程。
MySQL的SQL执行过程:
在MySQL服务内部,一条从客户端发起的SQL请求会经过连接器、查询缓存、分析器、优化器以及最终进行实际执行的执行器。这里我们具体看下执行器是如何执行一条 update 语句:
在执行更新语句的过程中会将与目标表相关的缓存清空,按照上面的请求顺序是不会出现查询语句查询到缓存的情况。
再一次,问题被指向了事务隔离性,难道真的是事务隔离性搞的鬼?
推论&验证:
再次检查MyBatis中最后一步提交事务的方法 sqlSession.commit() ,发现该方法内部只是调用了 JdbcTransaction/SpringManagedTransaction 的 commit 方法 ,但是该方法并没有返回值,也就是说这里的调用并不能表明提交的事务真正意义上被提交完成了。那么就会有一定可能出现更新的事务还没有提交完成,查询的事务开始执行了,此时根据当前MySQL服务的事务隔离级别读已提交来看,这里的查询只能查询更新事务提交之前的结果集。
想到这里,我再一次查看了一下几条问题数据当时更新请求和查询请求的间隔时间,间隔时间确实非常短,平均在十几毫秒左右(有些更短)。
为了进一步验证猜想,在测试环境我使用了 Thread.sleep() 大法,让线程在执行完更新语句后先休眠500毫秒,然后再进行下面的查询语句。在经过数天的压测之后,发现确实没有再出现过执行结束主任务失败的情况,此时基本可以确定应该是事务隔离性导致的。
三、解决方案
根据上面的分析,最终我设计了三种解决方案:
- Thread.sleep :同上,既然MySQL服务更新没有执行完成,那就让该线程休息一下,让更新“飞一会”;
- 使用Spring事务管理 :其实出现上面问题的最大原因是在于这两个语句执行被分拆到了两个事务当中,如果这两个语句放置到一个事务中执行,就不会存在事务隔离的问题,所以可以选择在该方法上增加 @Transaction 注解,使用一个事务管理两条语句;
- 使用定时任务进行补偿处理 :使用定时任务定时扫描主任务表,将主任务表中已经没有运行中子任务的主任务更新为完成。这一方法是针对当前业务逻辑提出的,如果业务逻辑不尽相同,还是不要使用;
四、尾言
老话说得好:一行bug改一天。回味完之后再来看这个问题,确实不是那么的复杂,但是学习的乐趣(改bug的乐趣)不就在于探寻问题根源的过程和找寻解决方案的过程。
最后,值此新春佳节,祝大家新年快乐,身体健康,早日暴富,哈哈哈~
作者:brucebat
链接:https://juejin.cn/post/6929452843451547661
来源:掘金
网友评论