SpringBoot读写分离
1.引言
读写分离要做的事情就是对于一条请求执行的sql,该选择哪一个数据库进行操作,而谁去做选择,无非就是两个,一个是让中间件帮我们去做,而另一个则是让程序自己做。
2.背景
随着用户数量的增加,系统访问量增大,数据库压力增大,众所周知,系统的读请求必然非常庞大,因此且读和写在同一个库中进行会进一步加大数据库压力,造成数据库挂掉的风险,因此需要进行读写分离以减轻数据库压力。
3.解决方法
-
MyCat中间件进行读写分离
- 优点:可以横向扩展,动态增加数据库节点,无需将应用程序重新部署即可实现。
- 缺点:没啥缺点,就是操作上来说相对复杂一点。
-
应用程序层面做读写分离
- 优点:相对来说比较简单一点,对于初接触者比较友好,好理解。
- 缺点:最大的缺点就是无法横向扩展(动态增删数据库节点),呃呃这个是非常蛋疼的。。想增删数据库节点还要在配置处修改,然后重新打包发布。
本文使用的是相对来说比较简单的应用层面的读写分离
4.读写分离运行流程及实现原理
4.1 运行流程
先上一张图
来捋一捋原理吧
web客户端(啦啦啦,请求来了,我要查一下我的个人信息~) -> server服务端(来查数据啊,给你安排了,喂,老m(mybatis)啊,去Slave2找它的个人信息给它) -> 老m: 卧槽!!Slave2挂了!。。开玩笑的,找到了,拿去吧 -> server服务端 -> web客户端
嗯。。。大概就是这样的流程,当然这是读的流程,写流程基本一致,只是写的时候需要将数据源切换到Master数据库进行写操作,至于sync流程,这个是mysql主从复制流程,我们留到下一期再捋一捋,顺便加上一些常问的面试题。
4.2 实现原理
那么怎么实现数据源的切换呢?本文设计基于查找特定的key路由到特定的数据源。翻看AbstractRoutingDataSource源码我们可以看到其中的targetDataSource可以维护一组目标数据源(采用map数据结构),并且做了路由key与目标数据源之间的映射,提供基于key查找数据源的方法。看到了这个,我们就可以想到怎么实现数据源切换了,上两个图。
观众老爷:罗里吧嗦了那么久,还不上代码实践,你想干嘛?原始人就是原始人,啰嗦!
马上马上马上,热乎的代码实践来了。
5.实践
5.1 maven依赖
<?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>
<parent>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-parent</artifactId>
<version>2.4.12-SNAPSHOT</version>
<relativePath/> <!-- lookup parent from repository -->
</parent>
<groupId>com.ReadAndWriteSeparate</groupId>
<artifactId>demo</artifactId>
<version>0.0.1-SNAPSHOT</version>
<name>ReadAndWriteSeparate</name>
<description>Demo project for Spring Boot</description>
<properties>
<java.version>1.8</java.version>
</properties>
<dependencies>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-jdbc</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
</dependency>
<dependency>
<groupId>org.mybatis.spring.boot</groupId>
<artifactId>mybatis-spring-boot-starter</artifactId>
<version>2.2.0</version>
</dependency>
<dependency>
<groupId>mysql</groupId>
<artifactId>mysql-connector-java</artifactId>
<version>5.1.26</version>
<scope>runtime</scope>
</dependency>
<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>
<dependency>
<groupId>org.aspectj</groupId>
<artifactId>aspectjweaver</artifactId>
<version>1.9.5</version>
</dependency>
</dependencies>
<build>
<plugins>
<plugin>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-maven-plugin</artifactId>
<configuration>
<excludes>
<exclude>
<groupId>org.projectlombok</groupId>
<artifactId>lombok</artifactId>
</exclude>
</excludes>
</configuration>
</plugin>
</plugins>
<resources>
<resource>
<directory>src/main/java</directory>
<includes>
<include>
**/*.xml
</include>
</includes>
</resource>
</resources>
</build>
</project>
5.2 数据源配置
- application.yml
spring:
datasource:
master:
jdbc-url: jdbc:mysql://localhost:3306/demo?useUnicode=true&characterEncoding=UTF-8&serverTimezone=Asia/Shanghai
username: root
password: root
driver-class-name: com.mysql.jdbc.Driver
slave1:
jdbc-url: jdbc:mysql://localhost:3306/demo?useUnicode=true&characterEncoding=UTF-8&serverTimezone=Asia/Shanghai
username: root
password: root
driver-class-name: com.mysql.jdbc.Driver
slave2:
jdbc-url: jdbc:mysql://localhost:3306/demo?useUnicode=true&characterEncoding=UTF-8&serverTimezone=Asia/Shanghai
username: root
password: root
driver-class-name: com.mysql.jdbc.Driver
我这里为了方便,就不设置只读账号和建立多个mysql服务了,能看到效果就行,一通百通嘛全部用root来,但如果是生产环境下必须要分开只读账号和可读可写账号,因为主从复制中,主机可不会同步从机的数据哟!
- 数据源配置
package com.readandwriteseparate.demo.Config;
import com.readandwriteseparate.demo.Enum.DbEnum;
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.beans.ConstructorProperties;
import java.util.HashMap;
import java.util.Map;
/**
* @author OriginalPerson
* @date 2021/11/25 20:25
* @Email 2568500308@qq.com
*/
@Configuration
public class DataSourceConfig {
//主数据源,用于写数据,特殊情况下也可用于读
@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 routingDataSource(@Qualifier("masterDataSource") DataSource masterDataSource,
@Qualifier("slave1DataSource") DataSource slave1DataSource,
@Qualifier("slave2DataSource") DataSource slave2DataSource){
Map<Object,Object> targetDataSource=new HashMap<>();
targetDataSource.put(DbEnum.MASTER,masterDataSource);
targetDataSource.put(DbEnum.SLAVE1,slave1DataSource);
targetDataSource.put(DbEnum.SLAVE2,slave2DataSource);
RoutingDataSource routingDataSource=new RoutingDataSource();
routingDataSource.setDefaultTargetDataSource(masterDataSource);
routingDataSource.setTargetDataSources(targetDataSource);
return routingDataSource;
}
}
这里我们配置了4个数据源,其中前三个数据源都是为了生成第四个路由数据源产生的,路由数据源的key我们使用枚举类型来标注,三个枚举类型分别代表数据库的类型。
-
枚举类
package com.readandwriteseparate.demo.Enum; /** * @author OriginalPerson * @date 2021/11/25 20:45 * @Email: 2568500308@qq.com */ public enum DbEnum { MASTER,SLAVE1,SLAVE2; }
5.3 数据源切换
这里我们使用ThreadLocal将路由key设置到每个线程的上下文中这里也进行一个简单的负载均衡,轮询两个只读数据源,而访问哪个取决于counter的值,每增加1,切换一下数据源,该值为juc并发包下的原子操作类,保证其线程安全。
-
设置路由键,获取当前数据源的key
package com.readandwriteseparate.demo.Config; import com.readandwriteseparate.demo.Enum.DbEnum; import org.slf4j.Logger; import org.slf4j.LoggerFactory; import java.util.concurrent.atomic.AtomicInteger; /** * @author OriginalPerson * @date 2021/11/25 20:49 * @Email: 2568500308@qq.com */ public class DBContextHolder { private static final ThreadLocal<DbEnum> contextHolder=new ThreadLocal<>(); private static final AtomicInteger counter=new AtomicInteger(-1); public static void set(DbEnum type){ contextHolder.set(type); } public static DbEnum get(){ return contextHolder.get(); } public static void master() { set(DbEnum.MASTER); System.out.println("切换到master数据源"); } public static void slave(){ //轮询数据源进行读操作 int index=counter.getAndIncrement() % 2; if(counter.get()>9999){ counter.set(-1); } if(index==0){ set(DbEnum.SLAVE1); System.out.println("切换到slave1数据源"); }else { set(DbEnum.SLAVE2); System.out.println("切换到slave2数据源"); } } }
-
确定当前数据源
这个比较重要,其继承AbstractRoutingDataSource类,重写了determineCurrentLookupKey方法,该方法决定当前数据源的key,对应于上文配置数据源的map集合中的key,让该方法返回我们定义的ThreadLocal中存储的key,即可实现数据源切换。
import org.springframework.jdbc.datasource.lookup.AbstractRoutingDataSource; import org.springframework.lang.Nullable; /** * @author OriginalPerson * @date 2021/11/25 20:47 * @Email: 2568500308@qq.com */ public class RoutingDataSource extends AbstractRoutingDataSource { @Nullable @Override protected Object determineCurrentLookupKey() { return DBContextHolder.get(); } }
-
mybatis配置
package com.readandwriteseparate.demo.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.jdbc.datasource.DataSourceTransactionManager; import org.springframework.transaction.PlatformTransactionManager; import org.springframework.transaction.annotation.EnableTransactionManagement; import javax.annotation.Resource; import javax.sql.DataSource; /** * @author OriginalPerson * @date 2021/11/25 22:17 * @Email 2568500308@qq.com */ @EnableTransactionManagement @Configuration public class MybatisConfig { @Resource(name = "routingDataSource") private DataSource routingDataSource; @Bean public SqlSessionFactory sessionFactory() throws Exception { SqlSessionFactoryBean sqlSessionFactoryBean=new SqlSessionFactoryBean(); sqlSessionFactoryBean.setDataSource(routingDataSource); return sqlSessionFactoryBean.getObject(); } @Bean public PlatformTransactionManager platformTransactionManager(){ return new DataSourceTransactionManager(routingDataSource); } }
这里我们重写SqlSession并给数据源添加事务管理,因为默认的SqlSession并没有我们定义的三个数据源,所以我们要重写并将我们定义好的数据源设置进去。
5.4 特殊处理
有通常情况就有特殊情况,在某些场景下,我们需要实时读取到更新过的值,例如某个业务逻辑,在插入一条数据后,需要立即查询据,因为读写分离我们用的是主从复制架构,它是异步操作,串行复制数据,所以必然存在主从延迟问题,对于刚插入的数据,如果要马上取出,读从库是没有数据的,因此需要直接读主库,这里我们通过一个Master注解来实现,被该注解标注的方法将直接在主库数据。
package com.readandwriteseparate.demo.annotation;
/**
* @author OriginalPerson
* @date 2021/11/26 13:28
* @Email 2568500308@qq.com
*/
public @interface Master {
}
这里无需做注解解析工作,下面我们将直接在aop对其进行判断及处理。
5.5 AOP切入处理
声明一个AOP切面,用于对各种操作类型的数据源切换,如read则切换到从库,写则切换到主库,当然,@Master注解标注的方法特殊处理。
- AOP切面类
package com.readandwriteseparate.demo.Aspect;
import com.readandwriteseparate.demo.Config.DBContextHolder;
import org.aspectj.lang.annotation.Aspect;
import org.aspectj.lang.annotation.Before;
import org.aspectj.lang.annotation.Pointcut;
import org.springframework.stereotype.Component;
/**
* @author OriginalPerson
* @date 2021/11/26 13:23
* @Email 2568500308@qq.com
*/
@Aspect
@Component
public class DataSourceAop {
@Pointcut("!@annotation(com.readandwriteseparate.demo.annotation.Master)" +
" && (execution(* com.readandwriteseparate.demo.Service..*.select*(..)))" +
" || execution(* com.readandwriteseparate.demo.Service..*.get*(..)))")
public void readPointcut(){
}
@Pointcut("@annotation(com.readandwriteseparate.demo.annotation.Master) " +
"|| execution(* com.readandwriteseparate.demo.Service..*.insert*(..)) " +
"|| execution(* com.readandwriteseparate.demo.Service..*.add*(..)) " +
"|| execution(* com.readandwriteseparate.demo.Service..*.update*(..)) " +
"|| execution(* com.readandwriteseparate.demo.Service..*.edit*(..)) " +
"|| execution(* com.readandwriteseparate.demo.Service..*.delete*(..)) " +
"|| execution(* com.readandwriteseparate.demo.Service..*.remove*(..))")
public void writePointcut() {
}
@Before("readPointcut()")
public void read(){
DBContextHolder.slave();
}
@Before("writePointcut()")
public void write(){
DBContextHolder.master();
}
}
注:
- 对于读的切入表达式,即readPointcut(),表达式意义为:
- 没有@Master注解标注的
- Service包下所有的类中以select或get开头的方法
满足以上两点都将成为该切入的对象。
- 对于写表达式,即writePointcut(),表达式意义为:
- 有@Master注解标注的
- Service包下所有的类中以insert或add或update或edit或delete或remove开头的方法
满足以上两点的都将成为该切入的对象。
然后就是定义切入时机,两个都是前置通知,在读方法前执行DBContextHolder.slave()切换到从库进行数据读取,在写之前执行DBContextHolder.master()切换到主库进行写操作,当然,有@Master注解标注的方法会强行读主库。
5.6 准备测试
-
实体类
package com.readandwriteseparate.demo.Domain; import lombok.AllArgsConstructor; import lombok.Data; import lombok.NoArgsConstructor; import java.io.Serializable; /** * @author OriginalPerson * @date 2021/11/26 23:15 * @Email 2568500308@qq.com */ @Data @AllArgsConstructor @NoArgsConstructor public class User implements Serializable { private Integer id; private String name; private String sex; }
-
Dao
package com.readandwriteseparate.demo.Dao; import com.readandwriteseparate.demo.Domain.User; import org.apache.ibatis.annotations.Param; import java.util.List; /** * @author OriginalPerson * @date 2021/11/26 23:16 * @Email 2568500308@qq.com */ public interface UserMapper { public List<User> selectAllUser(); public Integer insertUser(@Param("user") User user); public User selectOneById(@Param("id") Integer id); }
-
xml
<?xml version="1.0" encoding="UTF-8"?> <!DOCTYPE mapper PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN" "http://mybatis.org/dtd/mybatis-3-mapper.dtd"> <mapper namespace="com.readandwriteseparate.demo.Dao.UserMapper"> <resultMap id="user" type="com.readandwriteseparate.demo.Domain.User"> <id property="id" column="id"></id> <result property="name" column="name"></result> <result property="sex" column="sex"></result> </resultMap> <select resultMap="user" id="selectAllUser" resultType="com.readandwriteseparate.demo.Domain.User"> select * from user </select> <insert id="insertUser" parameterType="com.readandwriteseparate.demo.Domain.User"> insert into user(name,sex) values(#{user.name},#{user.sex}) </insert> <select id="selectOneById" parameterType="java.lang.Integer" resultMap="user"> select * from user where id=#{id} </select> </mapper>
-
Service
package com.readandwriteseparate.demo.Service; import com.readandwriteseparate.demo.Dao.UserMapper; import com.readandwriteseparate.demo.Domain.User; import com.readandwriteseparate.demo.annotation.Master; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.stereotype.Service; import java.util.List; /** * @author OriginalPerson * @date 2021/11/27 0:07 * @Email 2568500308@qq.com */ @Service public class UserService { @Autowired private UserMapper userMapper; public List<User> getAllUser(){ return userMapper.selectAllUser(); } public Integer addUser(User user){ return userMapper.insertUser(user); } /* * 特殊情况下,需要从主库查询时 * 例如某些业务更新数据后需要马上查询,因为主从复制有延迟,所以需要从主库查询 * 添加@Master注解即可从主库查询 * * 该注解实现比较简单,在aop切入表达式中进行判断即可 * */ @Master public User selectOneById(Integer id){ return userMapper.selectOneById(id); } }
-
单元测试代码
package com.readandwriteseparate.demo; import com.readandwriteseparate.demo.Dao.UserMapper; import com.readandwriteseparate.demo.Domain.User; import com.readandwriteseparate.demo.Service.UserService; import org.junit.jupiter.api.Test; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.boot.test.context.SpringBootTest; @SpringBootTest class ReadAndWriteSeparateApplicationTests { @Autowired private UserService userService; @Test void contextLoads() throws InterruptedException { User user=new User(); user.setName("赵六"); user.setSex("男"); System.out.println("插入一条数据"); userService.addUser(user); for (int i = 0; i <4 ; i++) { System.out.println("开始查询数据"); System.out.println("第"+(i+1)+"次查询"); userService.getAllUser(); System.out.println("-------------------------分割线------------------------"); } System.out.println("强制查询主库"); userService.selectOneById(1); } }
5.7 查看控制台打印信息
-
按照我们的代码首先是插入数据
-
然后是查询,共查询了四次,可以看到每个从库查了两次,有点负载均衡内味了,哈哈哈。
-
最后是强制主库查询
6.总结
好啦,到这里一个简单的读写分离就写好了,是不是很简单,认真捋一捋其实真不难,哪里做得不好的还请指正一下,第一次写博客呢!最后祝观众老爷们早日进大厂,我是原始人,期待与你们一起进步。