1.简介
在早期项目开发过程中,我们都是把数据存储在单个数据库中,这样无论是对数据库的读还是写都是对单个数据库此操作。这样带来的问题是巨大的:
- 单个数据库服务器挂了,数据库里面所有的数据都挂了
- 所有的读写请求都是对单个数据库操作,数据库服务器压力巨大
基于上述原因,我们就需要将对数据库服务器的读写操作分离,也就是读写分离。具体原理图如下:
- 主数据库与多个从数据库实现了主从复制
- 当应用发起对数据库的写操作时,那么就去操作主数据库
- 当应用发起对数据库的读操作时,那么通过负载均衡算法去访问从数据库。
- 系统一般来说时“读多写少”,因此这样在一定程度上减轻了数据库的压力。
从上面的解释我们可以看出,实现读写分离的前提是,数据库一定要配置好主从复制,如果数据库没有实现主从复制,那么就无法实现读写分离,数据库的主从复制请学习:《基于Docker方式实现MySql主从复制》
2.实现方式
实现读写分离大致有两种方式:
-
利用中间件进行读写分离
例如:Mycat,Oneproxy等等。
这些中间基本上都可以实现数据库的读写分离,分库分表等其他诸多功能,但是如果只是想实现读写分离,中间件反而显得有点臃肿
优缺点:
- 代码层面不需要任何改动,该怎么去写就怎么写
- 应用不再直接操作数据库,直接操作中间件,通过中间件去操作数据库
- 访问运维人员进行维护。
- 配置比较繁琐,同时如果修改了数据库,同时也要修改中间件
-
在应用层面利用aop去实现读写分离(重点介绍)
所谓的读写分离,就是让不同的请求去操作不同的数据库,那么其实就可以在访问数据库之前,先判断该请求是什么请求,
读请求就让它访问从数据库,写请求就访问主数据库。
优缺点:
- 省略了中间件配置步骤,简化开发时间
- 思想间件,实现起来比较容易
- 在代码层面修改,运维人员不好修改
- 如果增加数据库,需要修改代码
3.原理
在这里我们重点去掌握怎么在代码层面去实现读写分离。
其实原理在上面的介绍中已经提到过了,总而言之就是动态切换数据源。具体原理如下:
- 在调用业务层方法之前先判断该方法对数据库的操作
- 如果是写操作那么将数据源切换成主数据库
- 如果是读操作就将数据源切换到从数据库。
这样我们就实现了读写分离。具体原理图如下:
4.实现步骤
-
准备工作
搭建mysql的主从复制,这里我们就搭建一主一从,具体参考:《基于Docker方式实现MySql主从复制》
熟悉mybatis通用mapper
-
在主数据库创建数据库以及表,建表语句如下:
SET NAMES utf8mb4; SET FOREIGN_KEY_CHECKS = 0; -- ---------------------------- -- Table structure for person -- ---------------------------- DROP TABLE IF EXISTS `person`; CREATE TABLE `person` ( `id` int(11) NOT NULL AUTO_INCREMENT, `name` varchar(255) CHARACTER SET utf8 COLLATE utf8_general_ci NULL DEFAULT NULL, `age` int(11) NULL DEFAULT NULL, PRIMARY KEY (`id`) USING BTREE ) ENGINE = InnoDB AUTO_INCREMENT = 4 CHARACTER SET = utf8 COLLATE = utf8_general_ci ROW_FORMAT = Dynamic; SET FOREIGN_KEY_CHECKS = 1;
-
创建项目
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 通用mapper --> <dependency> <groupId>tk.mybatis</groupId> <artifactId>mapper-spring-boot-starter</artifactId> <version>2.1.5</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: datasource: master: # 驱动类 driver-class-name: com.mysql.jdbc.Driver # 主数据库服务器用户名 username: root # 主数据库服务器密码 password: root # 主数据库服务器用户名,到时候更换自己的ip端口 jdbc-url: jdbc:mysql://192.168.169.133:3306/test?characterEncoding=utf-8 # 从数据库服务器配置 slave: # 从数据库服务器用户名 username: root # 从数据库服务器密码 password: root # 从数据库服务器url,到时候更换自己的ip端口 jdbc-url: jdbc:mysql://192.168.169.133:33306/test?characterEncoding=utf-8 logging: level: com.readwite.application: debug
-
创建两个注解
read
,write
只要在方法上加了
read
注解那么表示该方法对数据库的操作是读操作只要在方法上加了
write
注解那么表示该方法对数据库的操作是写操作 -
创建枚举类,定义
MASTER
,SLAVE
两个枚举对象因为我们只有一个主库,一个从库,因此我们定义两个枚举对象来表示数据库类型
package com.readwite.application.config; /** * 表示数据库类型 */ public enum DBTypeEnum { /** * 表示主数据库 */ MASTER, /** * 表示从数据库 */ SLAVE; }
-
创建一个动态切换数据源的工具类
package com.readwite.application.config; import org.slf4j.Logger; import org.slf4j.LoggerFactory; /** * 动态切换数据源的工具类 */ public class DynamicSwitchDBTypeUtil { /** * 用来存储代表数据源的对象 * 如果是里面存储是SLAVE,代表当前线程正在使用主数据库 * 如果是里面存储的是SLAVE,代表当前线程正在使用从数据库 */ private static final ThreadLocal<DBTypeEnum> CONTEXT_HAND = new ThreadLocal<>(); /** * 日志对象 */ private static final Logger log = LoggerFactory.getLogger(DynamicSwitchDBTypeUtil.class); /** * 切换当前线程要使用的数据源 * @param dbTypeEnum */ public static void set(DBTypeEnum dbTypeEnum) { CONTEXT_HAND.set(dbTypeEnum); log.info("切换数据源:" + 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.readwite.application.config; import org.springframework.jdbc.datasource.lookup.AbstractRoutingDataSource; /** * 决定返回哪个数据源的key */ public class RouttingDataSource extends AbstractRoutingDataSource { @Override protected Object determineCurrentLookupKey() { /** * 返回当前线程正在使用的代表数据库的枚举对象 */ return DynamicSwitchDBTypeUtil.get(); } }
-
配置数据源
经过上面的准备后,我们就可以去配置数据源了
package com.readwite.application.config; import org.springframework.beans.factory.annotation.Qualifier; 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 javax.sql.DataSource; import java.util.HashMap; import java.util.Map; @Configuration public class DataSourceConfig { /** * 将创建的master数据源存入Spring容器中,并且注入内容 * key值为方法名 * @return master数据源 */ @Bean @ConfigurationProperties("spring.datasource.master") public DataSource masterDataSource() { return DataSourceBuilder.create().build(); } /** * 将创建的slave数据源存入Spring容器中,并且注入内容 * key值为方法名 * @return slave数据源 */ @Bean @ConfigurationProperties("spring.datasource.slave") public DataSource slaveDataSource() { return DataSourceBuilder.create().build(); } /** * 决定最终要使用的数据源 * @return */ @Bean public DataSource targetDataSource(@Qualifier("masterDataSource") DataSource masterDataSoure, @Qualifier("slaveDataSource") DataSource slaveDataSource) { // 用来存放主数据源和从数据源 Map<Object, Object> targetDataSource = new HashMap<>(); // 往map中添加主数据源 targetDataSource.put(DBTypeEnum.MASTER,masterDataSoure); // 往map中添加从数据源 targetDataSource.put(DBTypeEnum.SLAVE,slaveDataSource); // 创建 routtingDataSource 用来实现动态切换 RouttingDataSource routtingDataSource = new RouttingDataSource(); // 绑定所有的数据源 routtingDataSource.setTargetDataSources(targetDataSource); // 设置默认的数据源 routtingDataSource.setDefaultTargetDataSource(masterDataSoure); return routtingDataSource; } }
-
配置Mybatis
因为我们已经有了多个数据源,因此我们就需要去配置mybatis的SqlSessionFactory.
那么问题来了,为什么之前我们在SpringBoot整合mybatis的时候不需要配置,这是因为之前整合的时候只有一个数据源,SpringBoot底部已经帮我们做好了了封装,所以我们不要配置。
而现在有多个数据源我们就需要手动配置了。新建一个配置类,如下:
package com.readwite.application.config; 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; @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); // 因为我们使用的是通用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.readwite.application.config; import org.aspectj.lang.annotation.Aspect; import org.aspectj.lang.annotation.Before; import org.aspectj.lang.annotation.Pointcut; import org.springframework.stereotype.Component; /** * 切面配置类 */ @Aspect @Component public class DataSourceAOP { /** * 只要加了@Read注解的方法就是一个切入点 */ @Pointcut("@annotation(com.readwite.application.config.Read)") public void readPointcut() {} /** * 只要加了@Write注解的方法就是一个切入点 */ @Pointcut("@annotation(com.readwite.application.config.Write)") public void writePointcut() {} /** * 配置前置通知,如果是readPoint就切换数据源为从数据库 */ @Before("readPointcut()") public void readAdvise() { DynamicSwitchDBTypeUtil.slave(); } /** * 配置前置通知,如果是writePoint就切换数据源为主数据库 */ @Before("writePointcut()") public void writeAdvise() { DynamicSwitchDBTypeUtil.master(); } }
-
5.测试
经过上面的配置我们已经配置好了,接下来我们来测试一下:
-
编写pojo
package com.readwite.application.bean; import lombok.Data; import javax.persistence.*; @Data @Table(name = "person") public class Person { @Id @GeneratedValue(strategy = GenerationType.IDENTITY) private int id; @Column(name = "name") private String name; @Column(name = "age") private int age; }
-
编写dao层代码
package com.readwite.application.dao; import com.readwite.application.bean.Person; import tk.mybatis.mapper.common.Mapper; public interface PersonMapper extends Mapper<Person> { }
-
编写service
package com.readwite.application.service; import com.readwite.application.bean.Person; import com.readwite.application.config.Read; import com.readwite.application.config.Write; import com.readwite.application.dao.PersonMapper; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.stereotype.Service; import java.util.List; @Service public class PersonService { @Autowired private PersonMapper personMapper; /** * 代表该方法对数据库的操作是一个写操作 * @param person */ @Write public void add(Person person) { personMapper.insert(person); } /** * 代表该方法对数据库的操作是一个读操作 */ @Read public List<Person> findAll() { return personMapper.selectAll(); } }
-
配置启动类
注意:不要导包错误。导入的是
tk.mybatis.spring.annotation.MapperScan
-
编写测试代码
编写插入代码
package com.readwite.application; import com.readwite.application.bean.Person; import com.readwite.application.service.PersonService; import org.junit.Test; import org.junit.runner.RunWith; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.boot.test.context.SpringBootTest; import org.springframework.test.context.junit4.SpringRunner; @RunWith(SpringRunner.class) @SpringBootTest public class ApplicationTests { @Autowired private PersonService personService; @Test public void contextLoads() { Person person = new Person(); person.setName("wangwu"); person.setAge(18); personService.add(person); } }
执行结果如下:
数据库结果:
编写查询代码:
@Test public void testQuery() { List<Person> all = personService.findAll(); all.forEach(System.out::println); }
结果如下:
这样我们就实现了一个读写分离。