Springboot+Mybatis+Druib实现读写分离

1.背景

随着项目的访问量增加,单个数据库压力越来越大,最终演变成数据库的性能瓶颈,各种操作也会更加耗时。
一般会采用以下几种方式优化系统

  • 采用数据库分库分表
  • 采用Redis等缓存技术来降低压力
  • 引入分布式全文搜索引擎(例如:ElasticSearch)
  • 读写分离(分摊压力,提高查询速度)

本文主要就是介绍springboot+mybatis+druib 连接池架构下如何实现读写分离以加快数据库读取速度。

2.如何实现

注:这里不讲如何搭建主从数据库集群。主要讲代码集成。
总结下来需要实现读写分离,主要需要解决2个问题

  • 如何切换数据源
  • 如何根据不同的方法选择正确的数据源

2.1如何切换数据源

 spring是支持多数据源配置的,可以把多个数据源放到一个Map中。这里需要自定义一个数据源继承AbstractRoutingDataSource,并重写determineCurrentLookupKey方法,通过获取的key来决定使用哪个数据源。


附:AbstractRoutingDataSource类设置数据源-(源码示例片段)

public void setTargetDataSources(Map<Object, Object> targetDataSources) {
    this.targetDataSources = targetDataSources;
}

2.2如何选择数据源

事务一般是在 Service 层的,因此在开始这个 service 方法调用时要确定数据源。
那么有什么通用方法能够在开始执行一个方法前做操作呢?

  • 切面
    这里切面拦截设计也有两种(也可以同时使用)
  • 注解式,定义一个自定义注解,使用切面进行拦截使用了该注解的方法
  • 方法名,根据方法名写切点,比如 getXXX 用读库,setXXX 用写库

3.前置准备

本文实例是以 1个主数据源,2个从数据源,为例
创建一张测试表,初始化部分测试数据

CREATE TABLE `user_info` (
  `id` int(10) unsigned NOT NULL AUTO_INCREMENT COMMENT '自增主键id',
  `user_id` varchar(32) NOT NULL COMMENT '人员id',
  `user_name` varchar(32) NOT NULL COMMENT '用户名',
  `user_password` varchar(32) NOT NULL COMMENT '密码',
  `real_name` varchar(64) NOT NULL COMMENT '真实姓名',
  `mobile` varchar(20) NOT NULL DEFAULT '' COMMENT '手机号',
  `remark` varchar(255) NOT NULL DEFAULT '' COMMENT '备注',
  `create_time` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
  `update_time` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '修改时间',
  `del_flag` tinyint(4) NOT NULL DEFAULT '0' COMMENT '删除标记 0正常 1-删除',
  PRIMARY KEY (`id`) USING BTREE,
  UNIQUE KEY `uniq_user_id` (`user_id`) USING BTREE
) ENGINE=InnoDB DEFAULT CHARSET=utf8 COMMENT='人员信息表';

初始化测试数据

INSERT INTO `db_test2`.`user_info`(`id`, `user_id`, `user_name`, `user_password`, `real_name`, `mobile`, `remark`, `create_time`, `update_time`, `del_flag`) VALUES (1, '1001', 'lisi', '333222', '李四', '15678298934', '我是 - 李四', '2020-07-25 18:57:06', '2020-07-26 17:49:11', 0);
INSERT INTO `db_test2`.`user_info`(`id`, `user_id`, `user_name`, `user_password`, `real_name`, `mobile`, `remark`, `create_time`, `update_time`, `del_flag`) VALUES (2, '1002', 'wangwu', '555666', '王五', '18778298934', '我是 - 王五', '2020-07-25 18:57:06', '2020-07-26 17:49:15', 0);

4.代码实战

源码地址:传送门
先创建一个springBoot项目,依赖以及配置文件,详见下文中附录
Springboot+Mybatis+Druib实现读写分离_第1张图片

4.1数据源类型枚举

/**
 * 数据源-枚举
 *
 * @author 程序员小强
 * @date 2020-07-26
 */
public enum DataSourceTypeEnum {
    MASTER, SLAVE1, SLAVE2;
}

4.2数据源切换注解

/**
 * 目标数据源注解-作用于方法上
 *
 * @author 程序员小强
 * @date 2020-07-26
 */
@Documented
@Target({ElementType.METHOD})
@Retention(RetentionPolicy.RUNTIME)
public @interface TargetDataSource {

    /**
     * 目标数据源枚举名称
     */
    DataSourceTypeEnum value();
}

4.3自定义动态数据源

/**
 * 扩展动态-数据源
 *
 * @author 程序员小强
 * @date 2020-07-26
 */
public class DynamicDataSource extends AbstractRoutingDataSource {

    private final Logger logger = LoggerFactory.getLogger(DynamicDataSource.class);

    /**
     * 通过路由Key切换数据源
     * 

* spring 在开始进行数据库操作时会通过这个方法来决定使用哪个数据库, * 因此我们在这里调用 DynamicDataSourceContextHolder.getDataSourceType()方法获取当前操作类别, * 同时可进行读库的负载均衡 */ @Override protected Object determineCurrentLookupKey() { DataSourceTypeEnum typeEnum = DynamicDataSourceContextHolder.getDataSourceType(); logger.info("[ Change data source ] >> " + typeEnum.name()); return typeEnum; } }

/**
 * 通过ThreadLocal将数据源设置到每个线程上下文中
 * 用于切换读/写模式数据源
 * 原理:
 * 1.利用ThreadLocal保存当前线程数据源模式
 * 2.操作结束后清除该数据,避免内存泄漏,同时也为了后续在该线程进行写操作时任然为读模式
 *
 * @author 程序员小强
 * @date 2020-07-26
 */
public class DynamicDataSourceContextHolder {

    private static final ThreadLocal<DataSourceTypeEnum> CONTEXT_HOLDER = new ThreadLocal<>();

    public static void setDataSourceType(DataSourceTypeEnum dataSourceType) {
        CONTEXT_HOLDER.set(dataSourceType);
    }

    /**
     * 获取数据源路由key
     * 默认主库
     */
    public static DataSourceTypeEnum getDataSourceType() {
        return CONTEXT_HOLDER.get() == null ? DataSourceTypeEnum.MASTER : CONTEXT_HOLDER.get();
    }

    public static void removeDataSourceType() {
        CONTEXT_HOLDER.remove();
    }

}

4.4主从数据源核心配置

/**
 * 数据源配置
 *
 * @author 程序员小强
 * @date 2020-07-25
 */
@Configuration
public class DataSourceConfig {

    /**
     * 主数据源 (可读可写的主数据源)
     */
    @Primary
    @Bean(name = "masterDataSource")
    @Qualifier("masterDataSource")
    @ConfigurationProperties(prefix = "spring.datasource.master")
    public DataSource masterDataSource() {
        //指定连接池类型-DruidDataSource
        return DataSourceBuilder.create().type(DruidDataSource.class).build();
    }

    /**
     * 从数据源1(只读从数据源1)
     */
    @Bean(name = "slave1DataSource")
    @ConfigurationProperties(prefix = "spring.datasource.slave1")
    public DataSource salve1DataSource() {
        return DataSourceBuilder.create().type(DruidDataSource.class).build();
    }

    /**
     * 从数据源2(只读从数据源2)
     */
    @Bean(name = "slave2DataSource")
    @ConfigurationProperties(prefix = "spring.datasource.slave2")
    public DataSource salve2DataSource() {
        return DataSourceBuilder.create().type(DruidDataSource.class).build();
    }

    /**
     * 动态数据源
     *
     * @param masterDataSource 可读可写主数据源
     * @param slave1DataSource 只读子数据源1
     * @param slave2DataSource 只读子数据源2
     */
    @Bean(name = "dynamicDataSource")
    public DataSource createDynamicDataSource(
            @Qualifier(value = "masterDataSource") final DataSource masterDataSource,
            @Qualifier(value = "slave1DataSource") final DataSource slave1DataSource,
            @Qualifier("slave2DataSource") DataSource slave2DataSource) {
        //将所有数据源放到Map中
        Map<Object, Object> targetDataSources = new HashMap<>(4);
        targetDataSources.put(DataSourceTypeEnum.MASTER, masterDataSource);
        targetDataSources.put(DataSourceTypeEnum.SLAVE1, slave1DataSource);
        targetDataSources.put(DataSourceTypeEnum.SLAVE2, slave2DataSource);
        //动态数据源
        DynamicDataSource dynamicDataSource = new DynamicDataSource();
        //设置默认数据源
        dynamicDataSource.setDefaultTargetDataSource(masterDataSource);
        //设置可通过路由key,切换的数据源Map集
        dynamicDataSource.setTargetDataSources(targetDataSources);
        return dynamicDataSource;
    }
}

4.5Mybatis配置

/**
 * MyBatis 配置
 *
 * @author 程序员小强
 */
@Configuration
@MapperScan(basePackages = "com.example.mapper")
public class MyBatisConfig {

    @Resource(name = "dynamicDataSource")
    private DataSource dynamicDataSource;

    /**
     * sqlSessionFactory 配置
     * 扫描mybatis下的xml文件
     *
     * @return
     * @throws Exception
     */
    @Bean
    public SqlSessionFactory sqlSessionFactory() throws Exception {
        SqlSessionFactoryBean sqlSessionFactoryBean = new SqlSessionFactoryBean();
        //指定数据源
        sqlSessionFactoryBean.setDataSource(dynamicDataSource);
        //mybatis下xml扫描地址
        sqlSessionFactoryBean.setMapperLocations(new PathMatchingResourcePatternResolver()
                .getResources("classpath:mybatis/mapper/*.xml"));
        return sqlSessionFactoryBean.getObject();
    }

    /**
     * 事务管理
     */
    @Bean
    public DataSourceTransactionManager transactionManager() {
        return new DataSourceTransactionManager(dynamicDataSource);
    }
}

4.6测试接口实现

分别写了几个测试方法,并且通过注解@TargetDataSource指定了数据源

/**
 * @author 程序员小强
 * @date 2020-07-26 00:52
 */
@Service
public class UserServiceImpl implements UserService {

    private static final Logger logger = LoggerFactory.getLogger(UserServiceImpl.class);

    @Resource
    private UserInfoMapper userInfoMapper;


    /**
     * 新增人员
     * 指定使用主库
     *
     * @param addBO
     */
    @Override
    @TargetDataSource(DataSourceTypeEnum.MASTER)
    public void addUser(UserAddBO addBO) {
        logger.info("[ 新增人员 ] start param:{}", addBO);
        String userId = UUID.randomUUID().toString().replaceAll("-", "");

        UserInfo userInfo = new UserInfo();
        userInfo.setUserId(userId);
        userInfo.setUserName(addBO.getUserName());
        userInfo.setRealName(addBO.getRealName());
        userInfo.setMobile(addBO.getMobile());
        userInfo.setCreateTime(new Date());
        userInfo.setUpdateTime(new Date());
        userInfo.setRemark(addBO.getRemark());
        //demo项目部分参数值写死
        userInfo.setUserPassword("123456");
        userInfo.setDelFlag(0);
        userInfoMapper.insert(userInfo);
        logger.info("[ 新增人员 ] end userId:{},userName:{}", userId, addBO.getUserName());
    }

    /**
     * 修改人员信息
     *
     * @param updateBO
     */
    @Override
    @TargetDataSource(DataSourceTypeEnum.MASTER)
    public void updateUser(UserUpdateBO updateBO) {
        logger.info("[ 修改人员信息 ] start param:{}", updateBO);

        UserInfo userInfo = new UserInfo();
        userInfo.setUserId(updateBO.getUserId());
        userInfo.setUserName(updateBO.getUserName());
        userInfo.setRealName(updateBO.getRealName());
        userInfo.setMobile(updateBO.getMobile());
        userInfo.setCreateTime(new Date());
        userInfo.setUpdateTime(new Date());
        userInfo.setRemark(updateBO.getRemark());
        userInfoMapper.updateByUserIdSelective(userInfo);
        logger.info("[ 修改人员信息 ] end userId:{},userName:{}", updateBO.getUserId(), updateBO.getUserName());
    }

    /**
     * 查询所有 人员信息
     *
     * @return 人员信息 列表
     */
    @Override
    @TargetDataSource(DataSourceTypeEnum.SLAVE1)
    public List<UserInfo> getAll() {
        logger.info("[ 查询所有人员列表 ] 指定使用从库1 ");
        return userInfoMapper.getAll();
    }

    /**
     * 根据业务主键ID查询
     *
     * @param userId 业务主键
     */
    @Override
    @TargetDataSource(DataSourceTypeEnum.SLAVE2)
    public UserInfo getByUserId(String userId) {
        logger.info("[ 根据业务主键ID查询 ] 指定使用从库2 userId:{}", userId);
        return userInfoMapper.getByUserId(userId);
    }

}

4.7测试controller

/**
 * 读写分离-Demo人员测试接口
 *
 * @author 程序员小强
 */
@RestController
@RequestMapping("/user")
public class UserInfoController {

    @Resource
    private UserService userService;

    /**
     * 新增人员
     *
     * @param addBO
     */
    @PostMapping("/add")
    public Object addUser(@RequestBody UserAddBO addBO) {
        userService.addUser(addBO);
        return "success";
    }

    /**
     * 修改人员
     *
     * @param updateBO
     */
    @PostMapping("/update")
    public Object updateUser(@RequestBody UserUpdateBO updateBO) {
        userService.updateUser(updateBO);
        return "success";
    }

    /**
     * 查询所有 人员信息
     *
     * @return 人员信息 列表
     */
    @RequestMapping("/listAll")
    public List<UserInfo> getAll() {
        return userService.getAll();
    }

    /**
     * 根据业务主键ID查询
     *
     * @param userId 业务主键
     */
    @RequestMapping("/getByUserId")
    public UserInfo getByUserId(@RequestParam("userId") String userId) {
        return userService.getByUserId(userId);
    }
}

4.8测试结果

4.8.1新增人员接口

Springboot+Mybatis+Druib实现读写分离_第2张图片
由于新增人员指定了主数据源,从下图中日志可以看到,拦截起作用Springboot+Mybatis+Druib实现读写分离_第3张图片
日志:Springboot+Mybatis+Druib实现读写分离_第4张图片

4.8.2查询人员接口

Springboot+Mybatis+Druib实现读写分离_第5张图片

代码示例:Springboot+Mybatis+Druib实现读写分离_第6张图片
日志Springboot+Mybatis+Druib实现读写分离_第7张图片

4.8附录其它配置

4.8.1配置文件

注:主从数据库由于Demo项目,仅使用了本地的同一个数据库,实际生产环境需要搭建主从集群自动同步的环境


server.port=8081
# jackson时间格式化(解决时区问题)
spring.jackson.time-zone=GMT+8
spring.jackson.date-format=yyyy-MM-dd HH:mm:ss

#主数据源 >> 配置
#连接地址
spring.datasource.master.url=jdbc:mysql://localhost:3306/db_test2?useUnicode=true&characterEncoding=utf8&autoReconnect=true&serverTimezone=Asia/Shanghai&allowMultiQueries=true
#用户名
spring.datasource.master.username=root
#密码
spring.datasource.master.password=123456
#驱动
spring.datasource.master.driver-class-name=com.mysql.cj.jdbc.Driver
#连接池其它设置
#初始化时建立物理连接的个数
spring.datasource.master.initial-size=10
#最小连接池数量
spring.datasource.master.min-idle=10
#最大连接池数量
spring.datasource.master.max-active=30
#获取连接时最大等待时间,单位毫秒
spring.datasource.master.max-wait=60000
#申请连接检测,空闲时间大于检测的间隔时间,执行validationQuery检测
spring.datasource.master.test-while-idle=true
#检测的间隔时间
spring.datasource.master.time-between-eviction-runs-millis=60000
#销毁线程时检测当前连接的最后活动时间和当前时间差大于该值时,关闭当前连接
spring.datasource.master.min-evictable-idle-time-millis=30000
#用来检测连接是否有效
spring.datasource.master.validation-query=SELECT 1 FROM DUAL 
#申请连接时会执行validationQuery检测连接是否有效,开启会降低性能,默认为true
spring.datasource.master.test-on-borrow=false
#归还连接时会执行validationQuery检测连接是否有效,开启会降低性能,默认为true
spring.datasource.master.test-on-return=false


#从数据源1 >> 配置
#连接地址
spring.datasource.slave1.url=jdbc:mysql://localhost:3306/db_test2?useUnicode=true&characterEncoding=utf8&autoReconnect=true&serverTimezone=Asia/Shanghai&allowMultiQueries=true
#用户名
spring.datasource.slave1.username=root
#密码
spring.datasource.slave1.password=123456
#驱动
spring.datasource.slave1.driver-class-name=com.mysql.cj.jdbc.Driver
#连接池其它设置
#初始化时建立物理连接的个数
spring.datasource.slave1.initial-size=10
#最小连接池数量
spring.datasource.slave1.min-idle=10
#最大连接池数量
spring.datasource.slave1.max-active=30
#获取连接时最大等待时间,单位毫秒
spring.datasource.slave1.max-wait=60000
#申请连接检测,空闲时间大于检测的间隔时间,执行validationQuery检测
spring.datasource.slave1.test-while-idle=true
#检测的间隔时间
spring.datasource.slave1.time-between-eviction-runs-millis=60000
#销毁线程时检测当前连接的最后活动时间和当前时间差大于该值时,关闭当前连接
spring.datasource.slave1.min-evictable-idle-time-millis=30000
#用来检测连接是否有效
spring.datasource.slave1.validation-query=SELECT 1 FROM DUAL 
#申请连接时会执行validationQuery检测连接是否有效,开启会降低性能,默认为true
spring.datasource.slave1.test-on-borrow=false
#归还连接时会执行validationQuery检测连接是否有效,开启会降低性能,默认为true
spring.datasource.slave1.test-on-return=false

#从数据源2 >> 配置
#连接地址
spring.datasource.slave2.url=jdbc:mysql://localhost:3306/db_test2?useUnicode=true&characterEncoding=utf8&autoReconnect=true&serverTimezone=Asia/Shanghai&allowMultiQueries=true
#用户名
spring.datasource.slave2.username=root
#密码
spring.datasource.slave2.password=123456
#驱动
spring.datasource.slave2.driver-class-name=com.mysql.cj.jdbc.Driver
#连接池其它设置
#初始化时建立物理连接的个数
spring.datasource.slave2.initial-size=10
#最小连接池数量
spring.datasource.slave2.min-idle=10
#最大连接池数量
spring.datasource.slave2.max-active=30
#获取连接时最大等待时间,单位毫秒
spring.datasource.slave2.max-wait=60000
#申请连接检测,空闲时间大于检测的间隔时间,执行validationQuery检测
spring.datasource.slave2.test-while-idle=true
#检测的间隔时间
spring.datasource.slave2.time-between-eviction-runs-millis=60000
#销毁线程时检测当前连接的最后活动时间和当前时间差大于该值时,关闭当前连接
spring.datasource.slave2.min-evictable-idle-time-millis=30000
#用来检测连接是否有效
spring.datasource.slave2.validation-query=SELECT 1 FROM DUAL 
#申请连接时会执行validationQuery检测连接是否有效,开启会降低性能,默认为true
spring.datasource.slave2.test-on-borrow=false
#归还连接时会执行validationQuery检测连接是否有效,开启会降低性能,默认为true
spring.datasource.slave2.test-on-return=false

#日志配置
logging.level.root=WARN
logging.level.com.example=INFO

4.8.2Maven依赖

   <parent>
        <groupId>org.springframework.bootgroupId>
        <artifactId>spring-boot-starter-parentartifactId>
        <version>2.2.0.RELEASEversion>
    parent>

    <properties>
        <java.version>1.8java.version>
        <alibaba.druid.version>1.1.14alibaba.druid.version>
        <mysql-connector.version>8.0.13mysql-connector.version>
        <lombok.version>1.18.10lombok.version>
        <mybatis-spring-boot.version>1.3.2mybatis-spring-boot.version>
        <project.build.sourceEncoding>UTF-8project.build.sourceEncoding>
    properties>

    <dependencies>
        
        <dependency>
            <groupId>org.springframework.bootgroupId>
            <artifactId>spring-boot-starter-webartifactId>
        dependency>
        <dependency>
            <groupId>org.springframework.bootgroupId>
            <artifactId>spring-boot-starter-aopartifactId>
        dependency>
        <dependency>
            <groupId>org.springframework.bootgroupId>
            <artifactId>spring-boot-starter-jdbcartifactId>
        dependency>
        <dependency>
            <groupId>org.mybatis.spring.bootgroupId>
            <artifactId>mybatis-spring-boot-starterartifactId>
            <version>${mybatis-spring-boot.version}version>
        dependency>
        <dependency>
            <groupId>org.springframework.bootgroupId>
            <artifactId>spring-boot-starter-testartifactId>
            <scope>testscope>
        dependency>

        
        <dependency>
            <groupId>mysqlgroupId>
            <artifactId>mysql-connector-javaartifactId>
            <version>${mysql-connector.version}version>
        dependency>

        
        <dependency>
            <groupId>com.alibabagroupId>
            <artifactId>druidartifactId>
            <version>${alibaba.druid.version}version>
        dependency>

        <dependency>
            <groupId>org.projectlombokgroupId>
            <artifactId>lombokartifactId>
            <version>${lombok.version}version>
        dependency>
    dependencies>

5.优缺点分析

5.1优点

  • 降低主数据库压力
  • 增强数据安全性,读写分离有个好处就是数据近乎实时备份,一旦某台服务器硬盘发生了损坏,从库的数据可以无限接近主库
  • 可以实现高可用,当然只是配置了读写分离并不能实现高可用,最多就是在Master(主库)宕机了还能进行查询操作,具体高可用还需要其他操作

5.2缺点

  • 增大成本,多台数据库的成本
  • 增大代码复杂度
  • 增大写入成本,虽然降低了读取成本,但是写入成本却是一点也没有降低,还有从库一直在向主库请求数据

6.源码地址

源码地址: 传送门

关注程序员小强公众号更多编程趣事,知识心得与您分享
在这里插入图片描述

你可能感兴趣的:(SpringBoot)