一:读写分离
1.简介
在早期项目开发过程中,我们都是把数据存储在单个数据库中,这样无论是对数据库的读还是写都是对单个数据库此操作。这样带来的问题是巨大的:
-
单个数据库服务器挂了,数据库里面所有的数据都挂了
-
所有的读写请求都是对单个数据库操作,数据库服务器压力巨大
基于上述原因,我们就需要将对数据库服务器的读写操作分离,也就是读写分离。具体原理图如下:
-
主数据库与多个从数据库实现了主从复制
-
当应用发起对数据库的写操作时,那么就去操作主数据库
-
当应用发起对数据库的读操作时,那么通过负载均衡算法去访问从数据库。
-
系统一般来说时“读多写少”,因此这样在一定程度上减轻了数据库的压力。
2.实现方式
实现读写分离大致有两种方式:
-
利用中间件进行读写分离
例如:Mycat,Oneproxy等等。
这些中间基本上都可以实现数据库的读写分离,分库分表等其他诸多功能,但是如果只是想实现读写分离,中间件反而显得有点臃肿
优缺点:
-
代码层面不需要任何改动,该怎么去写就怎么写
-
应用不再直接操作数据库,直接操作中间件,通过中间件去操作数据库
-
访问运维人员进行维护。
-
配置比较繁琐,同时如果修改了数据库,同时也要修改中间件
-
-
在应用层面利用aop去实现读写分离(重点介绍)
所谓的读写分离,就是让不同的请求去操作不同的数据库,那么其实就可以在访问数据库之前,先判断该请求是什么请求,
读请求就让它访问从数据库,写请求就访问主数据库。
优缺点:
-
省略了中间件配置步骤,简化开发时间
-
思想间件,实现起来比较容易
-
在代码层面修改,运维人员不好修改
-
如果增加数据库,需要修改代码
-
3.原理
在这里我们重点去掌握怎么在代码层面去实现读写分离。
其实原理在上面的介绍中已经提到过了,总而言之就是动态切换数据源。具体原理如下:
-
在调用业务层方法之前先判断该方法对数据库的操作
-
如果是写操作那么将数据源切换成主数据库
-
如果是读操作就将数据源切换到从数据库。
-
这样我们就实现了读写分离。具体原理图如下:
4.实现步骤
-
准备工作
-
搭建mysql的主从复制,这里我们就搭建一主一从,在第二大章会介绍。
-
熟悉mybatis通用mapper
-
在主数据库创建数据库以及表
-
-
pom.xml:
<?xml version="1.0" encoding="UTF-8"?><project xmlns="http://maven.apache.org/POM/4.0.0" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 https://maven.apache.org/xsd/maven-4.0.0.xsd">
<modelVersion>4.0.0</modelVersion>
<!--
SpringBoot 版本
-->
<parent>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-parent</artifactId>
<version>2.1.15.RELEASE</version>
<relativePath/> <!-- lookup parent from repository -->
</parent>
<groupId>com.readwite</groupId>
<artifactId>application</artifactId>
<version>0.0.1-SNAPSHOT</version>
<name>application</name>
<description>Demo project for Spring Boot</description>
<properties>
<java.version>1.8</java.version>
</properties>
<dependencies>
<!--
mysql 驱动包
-->
<dependency>
<groupId>mysql</groupId>
<artifactId>mysql-connector-java</artifactId>
<scope>runtime</scope>
</dependency>
<!--
lombok 依赖包
-->
<dependency>
<groupId>org.projectlombok</groupId>
<artifactId>lombok</artifactId>
<optional>true</optional>
</dependency>
<!---
单元测试
-->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-test</artifactId>
<scope>test</scope>
</dependency>
<!--
aop依赖
-->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-aop</artifactId>
</dependency>
<!--mybatis-->
<dependency>
<groupId>org.mybatis.spring.boot</groupId>
<artifactId>mybatis-spring-boot-starter</artifactId>
<version>${mybatis.version}</version>
</dependency>
<!-- 阿里巴巴druid-->
<dependency>
<groupId>com.alibaba</groupId>
<artifactId>druid</artifactId>
<version>${druid.version}</version>
</dependency>
<!--
日志依赖
-->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-logging</artifactId>
</dependency>
</dependencies>
<build>
<plugins>
<plugin>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-maven-plugin</artifactId>
</plugin>
</plugins>
</build>
</project>
- 搭建环境
- 在application.yml增加以下内容
spring:
mvc:
view:
prefix: /
suffix: .html
servlet: #配置文件大小
multipart:
max-file-size: 20Mb
max-request-size: 20Mb
#============================#
#==== database settings =====#
#============================#
resources:
static-locations: classpath:/META-INF/resources/,classpath:/resources/,classpath:/static/,classpath:/public/,classpath:/itstyle/, file:/
datasource:
type: com.alibaba.druid.pool.DruidDataSource
druid:
#配置主数据库负责写
master:
#数据库连接结合实际情况
url: jdbc:mysql://101.200.***.***:3306/caiji?useSSL=false&useUnicode=true&characterEncoding=utf8&allowMultiQueries=true
username: root
password: ****
driver-class-name: com.mysql.jdbc.Driver
connectionProperties: druid.stat.mergeSql=true;druid.stat.slowSqlMillis=5000
initialSize: 5
minIdle: 5
maxActive: 20
maxWait: 60000
timeBetweenEvictionRunsMillis: 60000
minEvictableIdleTimeMillis: 300000
validationQuery: SELECT 1 FROM DUAL
testWhileIdle: true
testOnBorrow: false
testOnReturn: false
poolPreparedStatements: true
maxPoolPreparedStatementsPerConnectionSize: 20
useGlobalDataSourceStat: true
#
slave:
url: jdbc:mysql://47.94.***.***:3306/caiji?useSSL=false&useUnicode=true&characterEncoding=utf8&allowMultiQueries=true
username: root
password: ***
driver-class-name: com.mysql.jdbc.Driver
connectionProperties: druid.stat.mergeSql=true;druid.stat.slowSqlMillis=5000
initialSize: 5
minIdle: 5
maxActive: 20
maxWait: 60000
timeBetweenEvictionRunsMillis: 60000
minEvictableIdleTimeMillis: 300000
validationQuery: SELECT 1 FROM DUAL
testWhileIdle: true
testOnBorrow: false
testOnReturn: false
poolPreparedStatements: true
maxPoolPreparedStatementsPerConnectionSize: 20
useGlobalDataSourceStat: true
- 建立自定义注解@Read,@Write来用于面向切面的形式来动态切换数据源
/**
* @author zm
* @version 1.0.0
* @date 2020/7/28 21:51
* @desription 操作数据库只读注解
*/
@Retention(RetentionPolicy.RUNTIME)
public @interface Read {
}
/**
* @author zm
* @version 1.0.0
* @date 2020/7/28 21:52
* @desription 操作数据库只写注解
*/
@Retention(RetentionPolicy.RUNTIME)
public @interface Write {
}
-
因为我们只有一个主库,一个从库,因此我们定义两个枚举对象来表示数据库类型
-
创建枚举类,定义MASTER,SLAVE两个枚举对象
-
/**
* @author Administrator
* @version 1.0.0
* @date 2020/7/28 17:04
* @desription
*/
public enum DBTypeEnum {
/**
* 表示主数据库
*/
MASTER,
/**
* 表示从数据库
*/
SLAVE;
}
- 创建一个动态切换数据源的工具类
package com.whut.ns.caiji.config.db;
import com.whut.my.caiji.ns.domain.db.DBTypeEnum;
/**
* @author Administrator
* @version 1.0.0
* @date 2020/7/28 17:06
* @desription 动态切换数据源的工具类
*/
public class DynamicSwitchDBTypeUtil {
/**
* 用来存储代表数据源的对象
* 如果是里面存储是MASTER,代表当前线程正在使用主数据库
* 如果是里面存储的是SLAVE,代表当前线程正在使用从数据库
*/
public static final ThreadLocal<DBTypeEnum> CONTEXT_HAND = new ThreadLocal<>();
/**
* 切换当前线程要使用的数据源
* @param dbTypeEnum
*/
public static void set(DBTypeEnum dbTypeEnum) {
CONTEXT_HAND.set(dbTypeEnum);
System.out.println("切换数据源:" + dbTypeEnum);
}
/**
* 切换到主数据库
*/
public static void master() {
set(DBTypeEnum.MASTER);
}
/**
* 切换到从数据库
*/
public static void slave() {
/*
目前我们只有一个从数据库,可以直接设置
但是如果我们拥有多个从数据库那么就需要
考虑怎么使用什么样的算法去负载均衡从数据库
*/
set(DBTypeEnum.SLAVE);
}
/**
* 移除当前线程使用的数据源
*/
public static void remove() {
CONTEXT_HAND.remove();
}
/**
* 获取当前线程使用的枚举值
* @return
*/
public static DBTypeEnum get() {
return CONTEXT_HAND.get();
}
}
- 编写AbstractRoutingDataSource的实现类
- 在SpringBoot中提供了AbstractRoutingDataSource,用户可以根据自己定义的规则去选择当前要使用的数据源,我们利用这个特性,在调用业务层方法之前去扫描注解,如果方法上是read注解我们就切换到从数据库,否则切换到主数据库。
- 实现动态的数据源,是由该里面的抽象方法determineCurrentLookupKey决定,具体源码如下图所示:
部分解释如下:
package org.springframework.jdbc.datasource.lookup;
import java.sql.Connection;import java.sql.SQLException;import java.util.HashMap;import java.util.Map;
import javax.sql.DataSource;
import org.springframework.beans.factory.InitializingBean;import org.springframework.jdbc.datasource.AbstractDataSource;import org.springframework.lang.Nullable;import org.springframework.util.Assert;
public abstract class AbstractRoutingDataSource extends AbstractDataSource implements InitializingBean {
/**
用来存储数据源
map的key-value解释如下:
key: 数据源的key值
value: 表示数据源
*/
@Nullable
private Map<Object, Object> targetDataSources;
/**
默认的数据源
*/
@Nullable
private Object defaultTargetDataSource;
private boolean lenientFallback = true;
private DataSourceLookup dataSourceLookup = new JndiDataSourceLookup();
@Nullable
private Map<Object, DataSource> resolvedDataSources;
@Nullable
private DataSource resolvedDefaultDataSource;
/**
* 设置数据源,具体使用哪一个数据源由determineCurrentLookupKey()方法返回
* 的key决定
*/
public void setTargetDataSources(Map<Object, Object> targetDataSources) {
this.targetDataSources = targetDataSources;
}
/**
*设置默认的数据源
*/
public void setDefaultTargetDataSource(Object defaultTargetDataSource) {
this.defaultTargetDataSource = defaultTargetDataSource;
}
/**
* 决定使用数据源的方法
* 从源码可知:
1.调用 determineCurrentLookupKey 获取key值
2.拿到key值后再从map里面获取数据源,然后返回
*/
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;
}
/**
* 抽象方法,返回数据源的key值,由开发者自己去实现
* 要实现切花数据源就是重写此方法
*/
@Nullable
protected abstract Object determineCurrentLookupKey();
}
- 创建该类的实现类,并实现determineCurrentLookupKey方法
package com.whut.ns.caiji.config.db;
import com.whut.my.caiji.ns.domain.db.DBTypeEnum;
import org.springframework.jdbc.datasource.lookup.AbstractRoutingDataSource;
import javax.sql.DataSource;
import java.sql.Connection;
import java.sql.SQLException;
/**
* @author Administrator
* @version 1.0.0
* @date 2020/7/28 17:13
* @desription
*/
/**
* 决定返回哪个数据源的key
*/
public class RouttingDataSource extends AbstractRoutingDataSource {
private DataSource masterDataSource;
private DataSource slaveDataSource;
public RouttingDataSource() {
}
public RouttingDataSource(DataSource masterDataSource,DataSource slaveDataSource) {
this.masterDataSource = masterDataSource;
this.slaveDataSource = slaveDataSource;
}
@Override
protected Object determineCurrentLookupKey() {
/**
* 返回当前线程正在使用的代表数据库的枚举对象
*/
return DynamicSwitchDBTypeUtil.get();
}
@Override
public void afterPropertiesSet() {
super.afterPropertiesSet();
//todo 做读数据库的负载均衡
}
/**
* 连接失败的时候的处理方法,再次切换数据源
* @return
* @throws SQLException
*/
@Override
public Connection getConnection() throws SQLException {
try {
return super.getConnection();
} catch (SQLException e) {
if (DynamicSwitchDBTypeUtil.get() == DBTypeEnum.SLAVE) {
//从数据库连接失败
return masterDataSource.getConnection();
}else if(
DynamicSwitchDBTypeUtil.get() == DBTypeEnum.MASTER
){
return slaveDataSource.getConnection();
}
throw e;
}
}
}
- 配置数据源
package com.whut.ns.caiji.config.db;
import com.whut.my.caiji.ns.domain.db.DBTypeEnum;
import org.springframework.beans.factory.annotation.Qualifier;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.boot.context.properties.ConfigurationProperties;
import org.springframework.boot.jdbc.DataSourceBuilder;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.context.annotation.Primary;
import javax.sql.DataSource;
import java.util.HashMap;
import java.util.Map;
/**
* @author Administrator
* @version 1.0.0
* @date 2020/7/28 17:19
* @desription
*/
@Configuration
public class DataSourceConfig {
@Value("${spring.datasource.type}")
private Class<? extends DataSource> dataSourceType;
/**
* 将创建的master数据源存入Spring容器中,并且注入内容
* key值为方法名
* @return master数据源
*/
@Bean(name = "masterDataSource")
@Primary
@ConfigurationProperties(prefix = "spring.datasource.druid.master")
public DataSource masterDataSource() {
return DataSourceBuilder
.create()
.type(dataSourceType)
.build();
}
/**
* 将创建的slave数据源存入Spring容器中,并且注入内容
* key值为方法名
* @return slave数据源
*/
@Bean(name = "slaveDataSource")
@ConfigurationProperties(prefix = "spring.datasource.druid.slave")
public DataSource slaveDataSource() {
return DataSourceBuilder
.create()
.type(dataSourceType)
.build();
}
/**
* 决定最终要使用的数据源
* @return
*/
@Bean
public DataSource targetDataSource(
@Qualifier("masterDataSource") DataSource masterDataSource,
@Qualifier("slaveDataSource") DataSource slaveDataSource
) {
// 用来存放主数据源和从数据源
Map<Object, Object> targetDataSource = new HashMap<>();
// 往map中添加主数据源
targetDataSource.put(DBTypeEnum.MASTER,masterDataSource);
// 往map中添加从数据源
targetDataSource.put(DBTypeEnum.SLAVE,slaveDataSource);
// 创建 routtingDataSource 用来实现动态切换
RouttingDataSource routtingDataSource = new RouttingDataSource();
// 设置默认的数据源
routtingDataSource.setDefaultTargetDataSource(masterDataSource);
// 绑定所有的数据源
routtingDataSource.setTargetDataSources(targetDataSource);
return routtingDataSource;
}
}
- 配置Mybatis
因为我们已经有了多个数据源,因此我们就需要去配置mybatis的SqlSessionFactory.
那么问题来了,为什么之前我们在SpringBoot整合mybatis的时候不需要配置,这是因为之前整合的时候只有一个数据源,SpringBoot底部已经帮我们做好了了封装,所以我们不要配置。
而现在有多个数据源我们就需要手动配置了。新建一个配置类,如下:
package com.whut.ns.caiji.config.db;
import org.apache.ibatis.session.SqlSessionFactory;
import org.mybatis.spring.SqlSessionFactoryBean;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.core.io.support.PathMatchingResourcePatternResolver;
import org.springframework.jdbc.datasource.DataSourceTransactionManager;
import org.springframework.transaction.PlatformTransactionManager;
import org.springframework.transaction.annotation.EnableTransactionManagement;
import javax.annotation.Resource;
import javax.sql.DataSource;
/**
* @author Administrator
* @version 1.0.0
* @date 2020/7/28 17:43
* @desription
*/
@Configuration
@EnableTransactionManagement
public class MybatisConfig {
/**
* 注入先前配置的数据源
*/
@Resource(name = "targetDataSource")
private DataSource dataSource;
/**
* 配置SqlSessionFactory
* @return
* @throws Exception
*/
@Bean
public SqlSessionFactory sqlSessionFactory() throws Exception {
// 创建SqlSessionFactoryBean对象
SqlSessionFactoryBean factoryBean = new SqlSessionFactoryBean();
factoryBean.setDataSource(dataSource);
factoryBean.setTypeAliasesPackage("com.whut.ns.caiji.dao");
//配置多数据源mapper文件存放路径
factoryBean.setMapperLocations(new PathMatchingResourcePatternResolver().getResources("classpath:mapper/**/*Mapper.xml"));
return factoryBean.getObject();
}
/**
* 配置事务管理
* @return
* @throws Exception
*/
@Bean
public PlatformTransactionManager transactionManager(){
return new DataSourceTransactionManager(dataSource);
}
}
- 配置AOP
经过上面的配置我们基本上配置好了读写分离大部分解释,但是现在存在的问题是
程序如何得知哪些方法上加了read或者write注解。即使知道了哪些方法上加了注解
难道我们需要每一个方法都去切换数据源吗,那样效率太低了。
我们可以利用aop思想,配置切入点和通知,在调用每个方法之前去判断,然后切换。就跟提交事务原理一样。
配置如下:
新建一个AOP配置类:
package com.whut.ns.caiji.aop;
import com.whut.ns.caiji.config.db.DynamicSwitchDBTypeUtil;
import org.aspectj.lang.annotation.Aspect;
import org.aspectj.lang.annotation.Before;
import org.aspectj.lang.annotation.Pointcut;
import org.springframework.stereotype.Component;
/**
* @author Administrator
* @version 1.0.0
* @date 2020/7/28 21:53
* @desription
*/
@Aspect
@Component
public class DataSourceAOP {
/**
* 只要加了@Read注解的方法就是一个切入点
*/
@Pointcut("@annotation(com.whut.ns.caiji.annotation.Read)")
public void readPointcut() {}
/**
* 只要加了@Write注解的方法就是一个切入点
*/
@Pointcut("@annotation(com.whut.ns.caiji.annotation.Write)")
public void writePointcut() {}
/**
* 配置前置通知,如果是readPoint就切换数据源为从数据库
*/
@Before("readPointcut()")
public void readAdvise() {
DynamicSwitchDBTypeUtil.slave();
}
/**
* 配置前置通知,如果是writePoint就切换数据源为主数据库
*/
@Before("writePointcut()")
public void writeAdvise() {
DynamicSwitchDBTypeUtil.master();
}
}
- 使用
@Read
public String preDeleteOperationLogs(List<Long> operationIds){
return null;
}
@Override
@Write
public boolean deleteOperationLogs(List<Long> operationIds) {
int flag = dao.deleteOperationLogs(operationIds);
return flag > 0;
}