这是一位同事的笔记,写得很好,因此这里记录学习下。
记一次数据库主从配置的问题
近日同事开发的一个项目出现了主从数据库切换异常的问题,由于大家都是新接触数据库的主从配置,许多基本的配置都是从他人的博客中摘抄而来,难免出现一些意外,这个问题就是在这样的环境下产生的。
之前的主从同步配置
我们之前在项目中主从数据库配置是这样的,这个是网上比较通用的解决方案,初看非常精妙:
首先我们声明了两个用于确认主从库的注解:
1 2 3 4 5 6 7 8 9 10
| @Retention(RetentionPolicy.RUNTIME) @Target(ElementType.METHOD) @Documented public @interface Master { } @Retention(RetentionPolicy.RUNTIME) @Target(ElementType.METHOD) @Documented public @interface Slave { }
|
将注解加在相应的业务层方法上表示这个方法需要选用的数据库。
然后利用spring的AOP来实现运行时的切换:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38
| @Aspect @Component public class DataSourceAOP { private static final Logger logger = LoggerFactory.getLogger(DataSourceAOP.class);
@Pointcut("@annotation(com.myhexin.header.config.mybatis.annotation.Slave) " + "|| execution(* com.myhexin.header.mapper..*.select*(..)) " + "|| execution(* com.myhexin.header.mapper..*.get*(..))" ) public void readPointcut() { System.out.println("readPointcut"); } @Pointcut("@annotation(com.myhexin.header.config.mybatis.annotation.Master) " + "|| execution(* com.myhexin.header.mapper..*.insert*(..)) " + "|| execution(* com.myhexin.header.mapper..*.add*(..)) " + "|| execution(* com.myhexin.header.mapper..*.update*(..)) " + "|| execution(* com.myhexin.header.mapper..*.edit*(..)) " + "|| execution(* com.myhexin.header.mapper..*.delete*(..)) " + "|| execution(* com.myhexin.header.mapper..*.remove*(..)) " + "|| execution(* com.myhexin.header.mapper..*.save*(..)) " + "|| execution(* com.myhexin.header.mapper..*.create*(..)) " + "|| execution(* com.myhexin.header.mapper..*.modify*(..))") public void writePointcut() { System.out.println("writePointcut"); } @Before("readPointcut()") public void read() { DBContextHolder.poiSlave(); } @Before("writePointcut()") public void write() { DBContextHolder.poiMaster(); } }
|
声明一个DBContextHolder类,其中声明一个线程级别的存储空间,一个常见的threadLocal对象,用它来存储
当前线程的数据库标记:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41
| public class DBContextHolder { private static final Logger logger = LoggerFactory.getLogger(DBContextHolder.class);
private static final ThreadLocal<DBTypeEnum> contextHolder = new ThreadLocal<>();
private static final AtomicInteger counter = new AtomicInteger(-1);
public static void set(DBTypeEnum dbType) { contextHolder.set(dbType); } public static DBTypeEnum get() { return contextHolder.get(); } public static void poiMaster() { set(DBTypeEnum.MASTER); logger.debug("切换到poi_master"); } public static void poiSlave() { int index = counter.getAndIncrement() % 2; if (counter.get() > 9999) { counter.set(-1); } if (index == 0) { set(DBTypeEnum.SLAVE1); logger.debug("切换到poi_slave1"); } else { set(DBTypeEnum.SLAVE2); logger.debug("切换到poi_slave2"); } } }
|
然后是创建一个新的MyRoutingDataSource,用它去继承数据源 AbstractRoutingDataSource,从而构造一个“复合数据源” :
1 2 3 4 5 6 7 8 9
| public class MyRoutingDataSource extends AbstractRoutingDataSource { private static final Logger logger = LoggerFactory.getLogger(MyRoutingDataSource.class);
@Nullable @Override protected Object determineCurrentLookupKey() { return DBContextHolder.get(); } }
|
在这个方法中,实现了父类中的determineCurrentLookupKey方法,这个方法用于获取当前数据源的key值。
然后,我们再向这个我们自己定义的“复合数据源” 中插入声明的各个数据库,并将主库设置成默认数据库。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37
| @Configuration public class DataSourceConfig { private static final Logger logger = LoggerFactory.getLogger(DataSourceConfig.class); @Bean @ConfigurationProperties("spring.datasource.master") public DataSource masterDataSource() { return DataSourceBuilder.create().build(); } @Bean @ConfigurationProperties("spring.datasource.slave1") public DataSource slave1DataSource() { return DataSourceBuilder.create().build(); } @Bean @ConfigurationProperties("spring.datasource.slave2") public DataSource slave2DataSource() { return DataSourceBuilder.create().build(); } @Bean public DataSource myRoutingDataSource(@Qualifier("masterDataSource") DataSource masterDataSource, @Qualifier("slave1DataSource") DataSource slave1DataSource, @Qualifier("slave2DataSource") DataSource slave2DataSource ) { Map<Object, Object> targetDataSources = new HashMap<>(); targetDataSources.put(DBTypeEnum.MASTER, masterDataSource); targetDataSources.put(DBTypeEnum.SLAVE1, slave1DataSource); targetDataSources.put(DBTypeEnum.SLAVE2, slave2DataSource); MyRoutingDataSource myRoutingDataSource = new MyRoutingDataSource(); myRoutingDataSource.setDefaultTargetDataSource(masterDataSource); myRoutingDataSource.setTargetDataSources(targetDataSources); return myRoutingDataSource; } }
|
这样一通操作后,看起来没什么问题,测试起来也是如此。如下面这个方法:
1 2 3 4 5 6 7
| @Override @Master public Integer add(Plan plan) throws Exception { ... XXX.insert(XXXX); ... }
|
正常的流程,就是
然后我们为了让这个连接支持事务,我们在方法上加上了@Transactional标示:
1 2 3 4 5 6 7 8
| @Override @Master @Transactional(rollbackFor = {Exception.class}) public Integer add(Plan plan) throws Exception { ... XXX.insert(XXXX); ... }
|
谁知一加上就出现了一些问题。
首先频繁的出现从库写操作的情况,由于我们的从库设置了只读设置,导致抛出试图操作修改只读数据库的异常。
而且这个异常并不是必现的,时好时坏,并随着第一次的产生,从而发生的几率越来越高。
然后我们根据日志进行了排查,偶然发现在一次异常时脚本刚好在进行读数据库操作,于是猜想是否是异常脚本的线程影响了本次的读取线程。
于是我们开始怀疑我们的ThreadLocal对象是否是真的正确使用了,毕竟它的声明:
1
| private static final ThreadLocal<DBTypeEnum> contextHolder = new ThreadLocal<>();
|
让它看起来完全就是一个全局变量。
为了验证这个猜想,我特意写了一个测试接口来测试这个类型的变量
1 2 3 4 5 6 7
| private static final ThreadLocal<Integer> threadLocal = new ThreadLocal<>();
@RequestMapping("/test") public void testThreadLocal(){ System.out.println(threadLocal.get()); threadLocal.set(5); }
|
然后我对这个测试接口频频访问,却发现每次打印的threadLocal都是null,不存在想象中只有第一次请求出现null,后面出现5的情况(其实我这里测得比较潦草,后面会提到)
也就是说,这个threadLocal的使用并非向它声明的那样看起来像个全局变量,通过对源码的跟入我发现它内部其实是维护了一个 线程安全的 静态全局Map<Thread,Map<ThreadLocal,Object>>对象,这个threadLocal的创建并没有什么作用,仅仅作为Map的一个key值,而内部的全局Map通过用当前线程做key,保证了数据在线程间的隔离性。
这样看来,脚本并不会影响threadLocal的正确判定。而且同事关闭脚本后再次访问同样也存在这个问题印证了我的想法。
会不会是Transactional注解造成的?于是我们去掉这个注解后,发现问题消失,而且进一步发现,只要加上transactional注解,就会产生这种问题。
在网上翻了几百篇博客后,慢慢有了头绪,有些博客提到spring的Transactional注解的实现同样是基于AOP,在对@Transactional注解进行较为深入的了解后,发现原来是在执行到这个方法时,在代理对象中缓存默认的数据源,以及从数据源中的一个连接,然后开启事务。此时执行到方法内时,由于连接在方法执行前已经被缓存,方法内的数据源切换并不会起到效果。
同时还了解到,一般我们自定义的aop,还存在一个order,即次序的设定,调用aop的次序也异常关键,缺省的次序为0XFFFFFF,位于默认的transactional注解次序之后,当自定义的Master注解与Transactional注解同时使用的情况下默认的次序时,请求的连接会被先缓存,再切库,但这个切库是没什么软用的,即使你切换到从库也不会真的从从库操作,因为数据库连接已经被缓存了。
这样看来网上的博客还是不够全面。
那么加上transactional注解的执行流程就是:
开始
一个请求到达
Transactional注解触发aop
触发选择连接池逻辑,默认的threadLocal对应的连接池(写池)被选中,然后从中取出一个连接,开启事务
Master标签触发aop
本请求的线程对应的threadLocal被绑定主库标记
开始查询数据库逻辑
insert方法被aop规则匹配,再次修改threadLocal变量为主库标记
使用缓存的连接执行操作
执行完后释放连接
结束
这样看来,如果设置order属性,在使用声明式事务@Transactional的情况下我们自定义的切库是没有意义的。
所以需要添加aop的order次序。
像这样添加上就可以了
1 2 3 4 5 6 7 8 9 10 11
| @Aspect @Component @Order(value = 0) public class DataSourceAOP { private static final Logger logger = LoggerFactory.getLogger(DataSourceAOP.class);
@Pointcut("@annotation(com.myhexin.header.config.mybatis.annotation.Slave) " + "|| execution(* com.myhexin.header.mapper..*.select*(..)) " + "|| execution(* com.myhexin.header.mapper..*.get*(..))" ) ...
|
但问题并没有这么简单,如果我们继续思考这个问题,会发现即使主从数据库切换不成功,那也不应该出现向从库中操作的情况,毕竟一开始@Ttransactional缓存的是主库的连接,而不是从库的连接。
那么我继续推断,难道默认的数据库不是主库吗?可在DataSourceConfig这个类里设置的清清楚楚,我们设置了主库为默认的库,于是我想到了另一种可能,默认的Threadlocal不是master。
但这比较难以想象,因为我们设置的ThreadLocal默认值就是master,那可能是这次的请求存在修改ThreadLocal的操作,使得缓存影响了下次的请求吗?可之前对ThreadLocal的测试已经表明了,它的作用域是线程独有的,很显然多个请求的线程必然不同。
但事实上多个请求的线程对象并不是两两互异的,这是我在观察日志时偶然发现的,我发现一个请求到来时,日志中会打印这个线程的名称,
1
| 控制台-2018-11-30 17:55:56 [restartedMain] INFO org.apache.coyote.http11.Http11NioProtocol - Starting ProtocolHandler ["http-nio-10001"]
|
譬如这段日志,其中restaredMain即为线程的名称,多次请求后,发现线程名字会出现重复的情况
在网上进一步搜索到,单个请求的线程并非在请求是创建,而是在线程池中获取的,这就导致了多个请求的线程对象很有可能为同一个,因为线程对象是事先创建好的交由线程池维护。
这样就可以很好的解释之前为什么会出现请求到从库现象了。如果一个操作读数据库的请求到来时,修改了ThreadLocal中的数据库标记,然后这条线程在请求执行完返回后重新回到线程池,但由于短时间内线程对象没有被释放(仍在池中)如果有一个新的请求同样用到了这条线程,那这条线程中的ThreadLoal依然为上一个读线程的状态,此时如果没有配置aop的调用次序,当默认的事务aop先被执行,那么被选中的连接池就会变成读库,缓存的连接就变成读库的连接了;此时再进行切库操作,由于连接被缓存,实际操作的还是从库,写操作执行就会出错。
综上,对于多数据源的处理,要兼顾事务操作的最好的解决方案就是通过设置order等级来达到在事务生效前完成数据源的选择,这样既可以正确切换数据源,也可以使用spring自带的事务。
值得注意的是,在我们上面的分析中可以得到,对于内部使用了多个数据源的方法,我们不应该用自带的
@Transactional注解来操作他,而是应该使用编程式事务管理,自带的注解会缓存连接,从而导致你希望进行的数据源切换失效。