读写分离要做的事情就是对于一条请求执行的sql,该选择哪一个数据库进行操作,而谁去做选择,无非就是两个,一个是让中间件帮我们去做,而另一个则是让程序自己做。
随着用户数量的增加,系统访问量增大,数据库压力增大,众所周知,系统的读请求必然非常庞大,因此且读和写在同一个库中进行会进一步加大数据库压力,造成数据库挂掉的风险,因此需要进行读写分离以减轻数据库压力。
MyCat中间件进行读写分离
应用程序层面做读写分离
本文使用的是相对来说比较简单的应用层面的读写分离
先上一张图
来捋一捋原理吧
web客户端(啦啦啦,请求来了,我要查一下我的个人信息~) -> server服务端(来查数据啊,给你安排了,喂,老m(mybatis)啊,去Slave2找它的个人信息给它) -> 老m: 卧槽!!Slave2挂了!。。开玩笑的,找到了,拿去吧 -> server服务端 -> web客户端
嗯。。。大概就是这样的流程,当然这是读的流程,写流程基本一致,只是写的时候需要将数据源切换到Master数据库进行写操作,至于sync流程,这个是mysql主从复制流程,我们留到下一期再捋一捋,顺便加上一些常问的面试题。
那么怎么实现数据源的切换呢?本文设计基于查找特定的key路由到特定的数据源。翻看AbstractRoutingDataSource源码我们可以看到其中的targetDataSource可以维护一组目标数据源(采用map数据结构),并且做了路由key与目标数据源之间的映射,提供基于key查找数据源的方法。看到了这个,我们就可以想到怎么实现数据源切换了,上两个图。
观众老爷:罗里吧嗦了那么久,还不上代码实践,你想干嘛?原始人就是原始人,啰嗦!
马上马上马上,热乎的代码实践来了。
<?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
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 [email protected]
*/
@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: [email protected]
*/
public enum DbEnum {
MASTER,SLAVE1,SLAVE2;
}
这里我们使用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: [email protected]
*/
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: [email protected]
*/
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 [email protected]
*/
@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并没有我们定义的三个数据源,所以我们要重写并将我们定义好的数据源设置进去。
有通常情况就有特殊情况,在某些场景下,我们需要实时读取到更新过的值,例如某个业务逻辑,在插入一条数据后,需要立即查询据,因为读写分离我们用的是主从复制架构,它是异步操作,串行复制数据,所以必然存在主从延迟问题,对于刚插入的数据,如果要马上取出,读从库是没有数据的,因此需要直接读主库,这里我们通过一个Master注解来实现,被该注解标注的方法将直接在主库数据。
package com.readandwriteseparate.demo.annotation;
/**
* @author OriginalPerson
* @date 2021/11/26 13:28
* @Email [email protected]
*/
public @interface Master {
}
这里无需做注解解析工作,下面我们将直接在aop对其进行判断及处理。
声明一个AOP切面,用于对各种操作类型的数据源切换,如read则切换到从库,写则切换到主库,当然,@Master注解标注的方法特殊处理。
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 [email protected]
*/
@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();
}
}
注:
满足以上两点都将成为该切入的对象。
满足以上两点的都将成为该切入的对象。
然后就是定义切入时机,两个都是前置通知,在读方法前执行DBContextHolder.slave()切换到从库进行数据读取,在写之前执行DBContextHolder.master()切换到主库进行写操作,当然,有@Master注解标注的方法会强行读主库。
实体类
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 [email protected]
*/
@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 [email protected]
*/
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 [email protected]
*/
@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);
}
}
好啦,到这里一个简单的读写分离就写好了,是不是很简单,认真捋一捋其实真不难,哪里做得不好的还请指正一下,第一次写博客呢!最后祝观众老爷们早日进大厂,我是原始人,期待与你们一起进步。