SpringBoot读写分离

SpringBoot读写分离

1.引言

​ 读写分离要做的事情就是对于一条请求执行的sql,该选择哪一个数据库进行操作,而谁去做选择,无非就是两个,一个是让中间件帮我们去做,而另一个则是让程序自己做。

2.背景

随着用户数量的增加,系统访问量增大,数据库压力增大,众所周知,系统的读请求必然非常庞大,因此且读和写在同一个库中进行会进一步加大数据库压力,造成数据库挂掉的风险,因此需要进行读写分离以减轻数据库压力。

3.解决方法

  • MyCat中间件进行读写分离

    • 优点:可以横向扩展,动态增加数据库节点,无需将应用程序重新部署即可实现。
    • 缺点:没啥缺点,就是操作上来说相对复杂一点。
  • 应用程序层面做读写分离

    • 优点:相对来说比较简单一点,对于初接触者比较友好,好理解。
    • 缺点:最大的缺点就是无法横向扩展(动态增删数据库节点),呃呃这个是非常蛋疼的。。想增删数据库节点还要在配置处修改,然后重新打包发布。

本文使用的是相对来说比较简单的应用层面的读写分离

4.读写分离运行流程及实现原理

4.1 运行流程

​ 先上一张图

SpringBoot读写分离_第1张图片

来捋一捋原理吧

web客户端(啦啦啦,请求来了,我要查一下我的个人信息~) -> server服务端(来查数据啊,给你安排了,喂,老m(mybatis)啊,去Slave2找它的个人信息给它) -> 老m: 卧槽!!Slave2挂了!。。开玩笑的,找到了,拿去吧 -> server服务端 -> web客户端

嗯。。。大概就是这样的流程,当然这是读的流程,写流程基本一致,只是写的时候需要将数据源切换到Master数据库进行写操作,至于sync流程,这个是mysql主从复制流程,我们留到下一期再捋一捋,顺便加上一些常问的面试题。

4.2 实现原理

那么怎么实现数据源的切换呢?本文设计基于查找特定的key路由到特定的数据源。翻看AbstractRoutingDataSource源码我们可以看到其中的targetDataSource可以维护一组目标数据源(采用map数据结构),并且做了路由key与目标数据源之间的映射,提供基于key查找数据源的方法。看到了这个,我们就可以想到怎么实现数据源切换了,上两个图。

SpringBoot读写分离_第2张图片

SpringBoot读写分离_第3张图片

观众老爷:罗里吧嗦了那么久,还不上代码实践,你想干嘛?原始人就是原始人,啰嗦!

马上马上马上,热乎的代码实践来了。

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
                    
                
            
        
    



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 [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;
    }
    

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: [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并没有我们定义的三个数据源,所以我们要重写并将我们定义好的数据源设置进去。

5.4 特殊处理

​ 有通常情况就有特殊情况,在某些场景下,我们需要实时读取到更新过的值,例如某个业务逻辑,在插入一条数据后,需要立即查询据,因为读写分离我们用的是主从复制架构,它是异步操作,串行复制数据,所以必然存在主从延迟问题,对于刚插入的数据,如果要马上取出,读从库是没有数据的,因此需要直接读主库,这里我们通过一个Master注解来实现,被该注解标注的方法将直接在主库数据。

package com.readandwriteseparate.demo.annotation;

/**
 * @author OriginalPerson
 * @date 2021/11/26 13:28
 * @Email [email protected]
 */


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 [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();
    }
}

注:

  1. 对于读的切入表达式,即readPointcut(),表达式意义为:
  • 没有@Master注解标注的
  • Service包下所有的类中以select或get开头的方法

满足以上两点都将成为该切入的对象。

  1. 对于写表达式,即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 [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);
        }
    
    }
    

5.7 查看控制台打印信息

  • 按照我们的代码首先是插入数据

    插入

  • 然后是查询,共查询了四次,可以看到每个从库查了两次,有点负载均衡内味了,哈哈哈。

    SpringBoot读写分离_第4张图片

  • 最后是强制主库查询

    强制查询

6.总结

好啦,到这里一个简单的读写分离就写好了,是不是很简单,认真捋一捋其实真不难,哪里做得不好的还请指正一下,第一次写博客呢!最后祝观众老爷们早日进大厂,我是原始人,期待与你们一起进步。

你可能感兴趣的:(MySQL篇,spring,boot,java,数据库)