排查读写分离失效原因

孤人 提交于 2020-02-28 18:15:34

相关文章:

今天组内一哥们反应说项目中读写分离出现了 BUG:明明加了读库的注解,而且日志也是显示应该要读从库,但是居然还是从主库中读取的数据。于是排查了一下原因,原因就是因为在数据源切换之前使用了声明式事务。

首先介绍一下这个项目中读写分离的实现方式:基于注解(AOP) + AbstractRoutingDataSource;这是个很常见的使用方式,但是我个人不太推荐这个方式,因为基于注解可设置范围过大,如果不理解其中原理或者项目有特殊要求的时候很容易出现问题,个人比较推荐基于 MyBatis 拦截器做。这个项目在 Service 层和 DAO 层中间增加了一个 Manager 层,一般 Manager 层就是用来进行事务操作。

代码简化如下:

//adminService 方法
@Transactional(rollbackFor = Throwable.class)
    public int updateDimensionRegion() {
        int num = adminManager.countDimensionRegion();
      //便于测试,主库和从库数据不一致
        if (num == 2){
            log.info("read slave.....");
        }else {
            log.info("read master....");
        }
        return num;
 }

//adminManager 方法
@ReadDB   //从库注解
public int countDimensionRegion() {
		return dataMigrateMapper.countAdmin();
}

发现一直查询的是主库,但是方法上明明加了读从库的注解。

分析这个问题之前首先可以大概想一下整个程序运行的流程,来了一个请求,当在需要与数据库交互的时候根据基于 AbstractRoutingDataSource 实现的路由规则获取相应的数据源,然后与数据库进行交互。与数据库交互一个非常重要的类就是 Connection ,很明显,一般项目都不会使用 MySQL 全局事务,我们的事务都是 Connection 级别的,即 SqlSession 级别。而上面代码的 updateDimensionRegion 方法由于使用了 @Transactional 注解,那么很明显,在这个方法执行前需要开启事务,那么是谁来开启事务呢,是 Connection,而 Connection 从哪里来呢,从 DataSource 中来,具体来自哪个 DataSource 是由项目中实现的路由规则来选择。

基于上面的分析,首先可以在 adminService 调用 adminManagercountDimensionRegion 方法之前打上断点,确定开启事务的 Connection 来自于哪个数据源。
在这里插入图片描述
发现存储数据源标识的 ThreadLocal 中的 value 表明当前数据源是主库。

接下来再在 @ReadDB 注解的 countDimensionRegion 方法中打上断点,再看:
在这里插入图片描述发现 value 的确表明现在要走从库了。但是为什么最终的结果还是显示走的是主库呢。其实这个原因在之前的一篇文章(分析 @Trannsactional 和 SqlSession 的关系)中已经进行了比较详细的分析。那篇文章中有一个测试结果:加了 @Transactional 注解的方法中就只有一次连接,共用 一个 SqlSession;不加事务,多次连接,生成多个 SqlSession。在这个例子中就是:事务方法已经获取到了 Connection,后面的方法虽然配置的是要走从库,但是并不会生成新的链接,仍然沿用事务方法中已经获取到的 Connection,所以就出现了“读写分离失效”了的现象(详细分析可参看分析 @Trannsactional 和 SqlSession 的关系
)。

最关键的是这个方法:org.mybatis.spring.SqlSessionUtils#getSqlSession

 public static SqlSession getSqlSession(SqlSessionFactory sessionFactory, ExecutorType executorType, PersistenceExceptionTranslator exceptionTranslator) {

    notNull(sessionFactory, NO_SQL_SESSION_FACTORY_SPECIFIED);
    notNull(executorType, NO_EXECUTOR_TYPE_SPECIFIED);

    SqlSessionHolder holder = (SqlSessionHolder) TransactionSynchronizationManager.getResource(sessionFactory);

    SqlSession session = sessionHolder(executorType, holder);
    if (session != null) {
      return session;
    }

    if (LOGGER.isDebugEnabled()) {
      LOGGER.debug("Creating a new SqlSession");
    }

    session = sessionFactory.openSession(executorType);

    registerSessionHolder(sessionFactory, executorType, exceptionTranslator, session);

    return session;
  }

可以发现,当可以从 TransactionSynchronizationManager 中获取到 SqlSession 的时候,就会直接返回不再获取新的 SqlSession 了。

到这里,其实读写分离失效的原因已经找到了,但是还有后续的疑问,就是为什么一开始获取的数据源就是主库呢,在这个项目中是因为存储数据源标识的 ThreadLocal 中的 value 已经初始化了一个默认值:
在这里插入图片描述
也就是说默认就走的是主库。即使这里没有设置默认值,其实 AbstractRoutingDataSource 也是提供了默认数据源配置的:

protected DataSource determineTargetDataSource() {
		Assert.notNull(this.resolvedDataSources, "DataSource router not initialized");
		Object lookupKey = determineCurrentLookupKey();
		DataSource dataSource = this.resolvedDataSources.get(lookupKey);
		if (dataSource == null && (this.lenientFallback || lookupKey == null)) {
			dataSource = this.resolvedDefaultDataSource;
		}
		if (dataSource == null) {
			throw new IllegalStateException("Cannot determine target DataSource for lookup key [" + lookupKey + "]");
		}
		return dataSource;
	}

欢迎关注公众号
​​​​​​在这里插入图片描述

易学教程内所有资源均来自网络或用户发布的内容,如有违反法律规定的内容欢迎反馈
该文章没有解决你所遇到的问题?点击提问,说说你的问题,让更多的人一起探讨吧!