多数据源事务操作中主从读写错误的问题

这是一位同事的笔记,写得很好,因此这里记录学习下。

记一次数据库主从配置的问题

​ 近日同事开发的一个项目出现了主从数据库切换异常的问题,由于大家都是新接触数据库的主从配置,许多基本的配置都是从他人的博客中摘抄而来,难免出现一些意外,这个问题就是在这样的环境下产生的。

之前的主从同步配置

我们之前在项目中主从数据库配置是这样的,这个是网上比较通用的解决方案,初看非常精妙:

首先我们声明了两个用于确认主从库的注解:

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);

/**
* ThreadLocal:线程本地存储
* ThreadLocal在每个线程中对该变量会创建一个副本,即每个线程内部都会有一个该变量,且在线程内部任何地方 * 都可以使用,线程之间互不影响,这样一来就不存在线程安全问题,也不会严重影响程序执行性能。
*/
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()
{
// 这里采用轮询策略
// TODO:这里的代码需要重构,目前定死了2个从库;需要支持根据配置自动扩充
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);
...
}

正常的流程,就是

  • 开始

  • 一个请求到达

  • Master标签触发aop

  • 本请求的线程对应的threadLocal被绑定主库标记
    +开始查询数据库逻辑
    +insert方法被aop规则匹配,再次修改threadLocal变量为主库标记

  • 从自定义的数据库中根据标记获取数据源
    +从数据源中取出一个连接
    +执行完后释放连接
    +结束

然后我们为了让这个连接支持事务,我们在方法上加上了@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注解来操作他,而是应该使用编程式事务管理,自带的注解会缓存连接,从而导致你希望进行的数据源切换失效。