SpringBoot + MyBatis数据库读写分离实现

什么是读写分离

读写分离,基本的原理是让主数据库处理事务性增、改、删操作(insertupdatedelete),而从数据库直接处理 select 查询操作。数据库复制用来把事务性操作导致的变更同步到集群中的从数据库。一般来说都是通过主从复制(Master-Slave)的方式来同步数据,再通过读写分离来提升数据库的并发负载能力

为什么要读写分离

一个项目中数据库是最基础的,目前主流的是单机数据库,读写都在一个库中。当用户逐渐增加,单机数据库无法满足性能要求时,就会进行读写分离(适用于读多写少),写操作一个库,读操作多个库,通常会做一个数据库集群,开启主从备份(主从同步),一主多从,提高查询性能

关于主从集群及同步,可以看这篇文章:

后台服务代码实现读写分离

本文章所用框架为:SpringBoot,MyBatis,连接池采用 druid

MyBatisPlus的配置还是有些区别,自行了解

读写分离关键在于:

  1. 如何切换数据源
  2. 如何根据不同方法选择正确数据源

如何切换数据源

SpringBoot 支持多数据源,多个 datasource 放在一个 HashMapTargetDataSource 中,通过 determineCurrentLookupKey 获取 key 来决定使用哪个数据源,因此,我们需要做的就是,建立多个 datasource 放大 TargetDataSource 中,同时重写 determineCurrentLookupKey 方法 来决定使用那个 key

如何选择数据源

事务一般是注解在 service 层,所以在 service 方法调用时就要确定数据源,在一个方法执行前就执行某个操作,这该如何实现?相比你已经想到了 - 切面,两种切法

  1. 注解式:定义一个只读注解,被该注解标注的方法使用读库
  2. 方法名:根据方法名写切点,getXXX 用读库,setXXX 用写库

开始实现

编写配置文件,配置两个数据源:

server:
port: 8888

mysql:
datasource:
#读库数目
num: 1
type-aliases-package: com.wh.cluster.entity
mapper-locations: classpath:/mapper/*.xml
config-location: classpath:/mybatis-config.xml
write:
url: jdbc:mysql://192.168.232.130:3306/test?useUnicode=true&characterEncoding=utf-8&useSSL=true
username: root
password: 123456
driver-class-name: com.mysql.cj.jdbc.Driver
read:
url: jdbc:mysql://192.168.232.131:3306/test?useUnicode=true&characterEncoding=utf-8&useSSL=true
username: root
password: slave123
driver-class-name: com.mysql.cj.jdbc.Driver

编写 DbContextHolder 类

该类用来设置数据库类别,其中有一个 ThreadLocal 用来保存每个线程是使用读库,还是写库

/**
* Description 这里切换读/写模式
* 原理是利用ThreadLocal保存当前线程是否处于读模式(通过开始READ_ONLY注解在开始操作前设置模式为读模式,
* 操作结束后清除该数据,避免内存泄漏,同时也为了后续在该线程进行写操作时任然为读模式
*
* 如果没有注解,则表示使用写模式。使用ReadOnly表示读模式
*/
public class DbContextHolder {

private static Logger log = LoggerFactory.getLogger(DbContextHolder.class);
public static final String WRITE = "write";
public static final String READ = "read";

private static ThreadLocal<String> contextHolder= new ThreadLocal<>();

public static void setDbType(String dbType) {
if (dbType == null) {
log.error("dbType为空");
throw new NullPointerException();
}
log.info("设置dbType为:{}",dbType);
contextHolder.set(dbType);
}

public static String getDbType() {
return contextHolder.get() == null ? WRITE : contextHolder.get();
}

public static void clearDbType() {
contextHolder.remove();
}
}

重写 determineCurrentLookupKey 方法

spring 在开始进行数据库操作时会通过这个方法来决定使用哪个数据库,因此我们在这里调用上面 DbContextHolder 类的 getDbType() 方法获取当前操作类别,同时可进行读库负载均衡(我这里只是用了随机数,应该有更专业的方式)

public class MyAbstractRoutingDataSource extends AbstractRoutingDataSource {

@Value("${mysql.datasource.num}")
private int num;

private final Logger log = LoggerFactory.getLogger(this.getClass());

@Override
protected Object determineCurrentLookupKey() {
String typeKey = DbContextHolder.getDbType();
if (typeKey == DbContextHolder.WRITE) {
log.info("使用了写库");
return typeKey;
}
//使用随机数决定使用哪个读库
int sum = NumberUtil.getRandom(1, num);
log.info("使用了读库{}", sum);
return DbContextHolder.READ + sum;
}
}

编写配置类

要进行读写分离,不能再用 SpringBoot 的默认配置,我么需要手动配置。首先生成数据源,使用 @ConfigurationProperties 自动生成数据源

/**
* 写数据源
*
* @Primary 标志这个 Bean 如果在多个同类 Bean 候选时,该 Bean 优先被考虑。
* 多数据源配置的时候注意,必须要有一个主数据源,用 @Primary 标志该 Bean
*/
@Primary
@Bean
@ConfigurationProperties(prefix = "mysql.datasource.write")
public DataSource writeDataSource() {
return new DruidDataSource();
}

/**
* 读数据源
* 这里有一点需要特别注意,读数据源类似,有多少个读库就要设置多少个读数据源,Bean 名为 read+序号。
* 目前,配置了1个读数据源,这里有一个read方法
*/
@Bean
@ConfigurationProperties(prefix = "mysql.datasource.read")
public DataSource read1() {
return new DruidDataSource();
}

设置数据源,使用之前编写的 MyAbstractRoutingDataSource 类

/**
* 设置数据源路由,通过该类中的determineCurrentLookupKey决定使用哪个数据源
*/
@Bean
public AbstractRoutingDataSource routingDataSource() {
MyAbstractRoutingDataSource proxy = new MyAbstractRoutingDataSource();
Map<Object, Object> targetDataSources = new HashMap<>(2);
targetDataSources.put(DbContextHolder.WRITE, writeDataSource());
targetDataSources.put(DbContextHolder.READ+"1", read1());
proxy.setDefaultTargetDataSource(writeDataSource());
proxy.setTargetDataSources(targetDataSources);
return proxy;
}

设置 sqlSessionFactory

/**
* 多数据源需要自己设置sqlSessionFactory
*/
@Bean
public SqlSessionFactory sqlSessionFactory() throws Exception {
SqlSessionFactoryBean bean = new SqlSessionFactoryBean();
bean.setDataSource(routingDataSource());
ResourcePatternResolver resolver = new PathMatchingResourcePatternResolver();
// 实体类对应的位置
bean.setTypeAliasesPackage(typeAliasesPackage);
// mybatis的XML的配置
bean.setMapperLocations(resolver.getResources(mapperLocation));
bean.setConfigLocation(resolver.getResource(configLocation));
return bean.getObject();
}

配置事务,否则事务不生效

/**
* 设置事务,事务需要知道当前使用的是哪个数据源才能进行事务处理
*/
@Bean
public DataSourceTransactionManager dataSourceTransactionManager() {
return new DataSourceTransactionManager(routingDataSource());
}

选择数据源

注解式(推荐)

首先定义一个只读注解,被这个注解方法使用读库,其他使用写库

@Target({ElementType.METHOD,ElementType.TYPE})
@Retention(RetentionPolicy.RUNTIME)
public @interface ReadOnly {

}

然后写一个切面来切换数据使用那种数据源,重写 getOrder 保证本切面优先级高于事务切面优先级,在启动类上加上@EnableTransactionManagement(order = 10)

@Aspect
@Component
public class ReadOnlyInterceptor implements Ordered {
private static final Logger log= LoggerFactory.getLogger(ReadOnlyInterceptor.class);

@Around("@annotation(readOnly)")
public Object setRead(ProceedingJoinPoint joinPoint,ReadOnly readOnly) throws Throwable{
try{
DbContextHolder.setDbType(DbContextHolder.READ);
return joinPoint.proceed();
}finally {
DbContextHolder.clearDbType();
log.info("清除threadLocal");
}
}

@Override
public int getOrder() {
return 0;
}
}
方法名式

这种方法不许要注解,但是需要service中方法名称按一定规则编写,然后通过切面来设置数据库类别,比如setXXX设置为写、getXXX设置为读,代码我就不写了,应该都知道怎么写🐶

测试

数据库很简单,只涉及一张表,只有id和name属性,自行创建即可

完整代码已上传 github: