手把手教你Spring实现DB读写分离 | Spring系列52篇

1、背景

大多数系统都是读多写少,为了降低数据库的压力,可以对主库创建多个从库,从库自动从主库同步数据,程序中将写的操作发送到主库,将读的操作发送到从库去执行。

今天的主要目标:通过 spring 实现读写分离

读写分离需实现下面 2 个功能:

1、读的方法,由调用者来控制具体是读从库还是主库

2、有事务的方法,内部的所有读写操作都走主库

2、思考 3 个问题

1、读的方法,由调用者来控制具体是读从库还是主库,如何实现?

可以给所有读的方法添加一个参数,来控制读从库还是主库。

2、数据源如何路由?

spring-jdbc 包中提供了一个抽象类:AbstractRoutingDataSource,实现了 javax.sql.DataSource 接口,我们用这个类来作为数据源类,重点是这个类可以用来做数据源的路由,可以在其内部配置多个真实的数据源,最终用哪个数据源,由开发者来决定。

AbstractRoutingDataSource 中有个 map,用来存储多个目标数据源

private Map<Object, DataSource> resolvedDataSources;

比如主从库可以这么存储

resolvedDataSources.put("master",主库数据源);
resolvedDataSources.put("salave",从库数据源);

AbstractRoutingDataSource 中还有抽象方法determineCurrentLookupKey,将这个方法的返回值作为 key 到上面的 resolvedDataSources 中查找对应的数据源,作为当前操作 db 的数据源

protected abstract Object determineCurrentLookupKey();

3、读写分离在哪控制?

读写分离属于一个通用的功能,可以通过 spring 的 aop 来实现,添加一个拦截器,拦截目标方法的之前,在目标方法执行之前,获取一下当前需要走哪个库,将这个标志存储在 ThreadLocal 中,将这个标志作为 AbstractRoutingDataSource.determineCurrentLookupKey()方法的返回值,拦截器中在目标方法执行完毕之后,将这个标志从 ThreadLocal 中清除。

3、代码实现

3.1、工程结构图

3.2、DsType

表示数据源类型,有 2 个值,用来区分是主库还是从库。

package com.javacode2018.readwritesplit.base;

public enum DsType {
    MASTER, SLAVE;
}

3.3、DsTypeHolder

内部有个 ThreadLocal,用来记录当前走主库还是从库,将这个标志放在 dsTypeThreadLocal 中

package com.javacode2018.readwritesplit.base;

public class DsTypeHolder {
    private static ThreadLocal<DsType> dsTypeThreadLocal = new ThreadLocal<>();

    public static void master() {
        dsTypeThreadLocal.set(DsType.MASTER);
    }

    public static void slave() {
        dsTypeThreadLocal.set(DsType.SLAVE);
    }

    public static DsType getDsType() {
        return dsTypeThreadLocal.get();
    }

    public static void clearDsType() {
        dsTypeThreadLocal.remove();
    }
}

3.4、IService 接口

这个接口起到标志的作用,当某个类需要启用读写分离的时候,需要实现这个接口,实现这个接口的类都会被读写分离拦截器拦截。

package com.javacode2018.readwritesplit.base;

//需要实现读写分离的service需要实现该接口
public interface IService {
}

3.5、ReadWriteDataSource

读写分离数据源,继承 ReadWriteDataSource,注意其内部的 determineCurrentLookupKey 方法,从上面的 ThreadLocal 中获取当前需要走主库还是从库的标志。

package com.javacode2018.readwritesplit.base;

import org.springframework.jdbc.datasource.lookup.AbstractRoutingDataSource;
import org.springframework.lang.Nullable;

public class ReadWriteDataSource extends AbstractRoutingDataSource {
    @Nullable
    @Override
    protected Object determineCurrentLookupKey() {
        return DsTypeHolder.getDsType();
    }
}

3.6、ReadWriteInterceptor

读写分离拦截器,需放在事务拦截器前面执行,通过@1 代码我们将此拦截器的顺序设置为 Integer.MAX_VALUE - 2,稍后我们将事务拦截器的顺序设置为 Integer.MAX_VALUE - 1,事务拦截器的执行顺序是从小到达的,所以,ReadWriteInterceptor 会在事务拦截器 org.springframework.transaction.interceptor.TransactionInterceptor 之前执行。

由于业务方法中存在相互调用的情况,比如 service1.m1 中调用 service2.m2,而 service2.m2 中调用了 service2.m3,我们只需要在 m1 方法执行之前,获取具体要用哪个数据源就可以了,所以下面代码中会在第一次进入这个拦截器的时候,记录一下走主库还是从库。

下面方法中会获取当前目标方法的最后一个参数,最后一个参数可以是 DsType 类型的,开发者可以通过这个参数来控制具体走主库还是从库。

package com.javacode2018.readwritesplit.base;

import org.aspectj.lang.ProceedingJoinPoint;
import org.aspectj.lang.annotation.Around;
import org.aspectj.lang.annotation.Aspect;
import org.aspectj.lang.annotation.Pointcut;
import org.springframework.core.annotation.Order;
import org.springframework.stereotype.Component;

import java.util.Objects;

@Aspect
@Order(Integer.MAX_VALUE - 2) //@1
@Component
public class ReadWriteInterceptor {

    @Pointcut("target(IService)")
    public void pointcut() {
    }

    //获取当前目标方法的最后一个参数
    private Object getLastArgs(final ProceedingJoinPoint pjp) {
        Object[] args = pjp.getArgs();
        if (Objects.nonNull(args) && args.length > 0) {
            return args[args.length - 1];
        } else {
            return null;
        }
    }

    @Around("pointcut()")
    public Object around(final ProceedingJoinPoint pjp) throws Throwable {
        //判断是否是第一次进来,用于处理事务嵌套
        boolean isFirst = false;
        try {
            if (DsTypeHolder.getDsType() == null) {
                isFirst = true;
            }
            if (isFirst) {
                Object lastArgs = getLastArgs(pjp);
                if (DsType.SLAVE.equals(lastArgs)) {
                    DsTypeHolder.slave();
                } else {
                    DsTypeHolder.master();
                }
            }
            return pjp.proceed();
        } finally {
            //退出的时候,清理
            if (isFirst) {
                DsTypeHolder.clearDsType();
            }
        }
    }
}

3.7、ReadWriteConfiguration

spring 配置类,作用

1、@3:用来将 com.javacode2018.readwritesplit.base 包中的一些类注册到 spring 容器中,比如上面的拦截器 ReadWriteInterceptor

2、@1:开启 spring aop 的功能

3、@2:开启 spring 自动管理事务的功能,@EnableTransactionManagement 的 order 用来指定事务拦截器 org.springframework.transaction.interceptor.TransactionInterceptor 顺序,在这里我们将 order 设置为 Integer.MAX_VALUE - 1,而上面 ReadWriteInterceptor 的 order 是 Integer.MAX_VALUE - 2,所以 ReadWriteInterceptor 会在事务拦截器之前执行。

package com.javacode2018.readwritesplit.base;

import org.springframework.context.annotation.ComponentScan;
import org.springframework.context.annotation.Configuration;
import org.springframework.context.annotation.EnableAspectJAutoProxy;
import org.springframework.transaction.annotation.EnableTransactionManagement;

@Configuration
@EnableAspectJAutoProxy //@1
@EnableTransactionManagement(proxyTargetClass = true, order = Integer.MAX_VALUE - 1) //@2
@ComponentScan(basePackageClasses = IService.class) //@3
public class ReadWriteConfiguration {
}

3.8、@EnableReadWrite

这个注解用俩开启读写分离的功能,@1 通过@Import 将 ReadWriteConfiguration 导入到 spring 容器了,这样就会自动启用读写分离的功能。业务中需要使用读写分离,只需要在 spring 配置类中加上@EnableReadWrite 注解就可以了。

package com.javacode2018.readwritesplit.base;

import org.springframework.context.annotation.Import;

import java.lang.annotation.*;

@Target(ElementType.TYPE)
@Retention(RetentionPolicy.RUNTIME)
@Documented
@Import(ReadWriteConfiguration.class) //@1
public @interface EnableReadWrite {
}

4、案例

读写分离的关键代码写完了,下面我们来上案例验证一下效果。

4.1、执行 sql 脚本

下面准备 2 个数据库:javacode2018_master(主库)、javacode2018_slave(从库)

2 个库中都创建一个 t_user 表,分别插入了一条数据,稍后用这个数据来验证走的是主库还是从库。

DROP DATABASE IF EXISTS javacode2018_master;
CREATE DATABASE IF NOT EXISTS javacode2018_master;

USE javacode2018_master;
DROP TABLE IF EXISTS t_user;
CREATE TABLE t_user (
  id   INT PRIMARY KEY       AUTO_INCREMENT,
  name VARCHAR(256) NOT NULL DEFAULT ''
  COMMENT '姓名'
);

INSERT INTO t_user (name) VALUE ('master库');

DROP DATABASE IF EXISTS javacode2018_slave;
CREATE DATABASE IF NOT EXISTS javacode2018_slave;

USE javacode2018_slave;
DROP TABLE IF EXISTS t_user;
CREATE TABLE t_user (
  id   INT PRIMARY KEY       AUTO_INCREMENT,
  name VARCHAR(256) NOT NULL DEFAULT ''
  COMMENT '姓名'
);
INSERT INTO t_user (name) VALUE ('slave库');

4.2、spring 配置类

@1:启用读写分离

masterDs()方法:定义主库数据源

slaveDs()方法:定义从库数据源

dataSource():定义读写分离路由数据源

后面还有 2 个方法用来定义 JdbcTemplate 和事务管理器,方法中都通过@Qualifier("dataSource")限定了注入的 bean 名称为 dataSource:即注入了上面 dataSource()返回的读写分离路由数据源。

package com.javacode2018.readwritesplit.demo1;

import com.javacode2018.readwritesplit.base.DsType;
import com.javacode2018.readwritesplit.base.EnableReadWrite;
import com.javacode2018.readwritesplit.base.ReadWriteDataSource;
import org.springframework.beans.factory.annotation.Qualifier;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.ComponentScan;
import org.springframework.context.annotation.Configuration;
import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.jdbc.datasource.DataSourceTransactionManager;
import org.springframework.transaction.PlatformTransactionManager;

import javax.sql.DataSource;
import java.util.HashMap;
import java.util.Map;

@EnableReadWrite //@1
@Configuration
@ComponentScan
public class MainConfig {
    //主库数据源
    @Bean
    public DataSource masterDs() {
        org.apache.tomcat.jdbc.pool.DataSource dataSource = new org.apache.tomcat.jdbc.pool.DataSource();
        dataSource.setDriverClassName("com.mysql.jdbc.Driver");
        dataSource.setUrl("jdbc:mysql://localhost:3306/javacode2018_master?characterEncoding=UTF-8");
        dataSource.setUsername("root");
        dataSource.setPassword("root123");
        dataSource.setInitialSize(5);
        return dataSource;
    }

    //从库数据源
    @Bean
    public DataSource slaveDs() {
        org.apache.tomcat.jdbc.pool.DataSource dataSource = new org.apache.tomcat.jdbc.pool.DataSource();
        dataSource.setDriverClassName("com.mysql.jdbc.Driver");
        dataSource.setUrl("jdbc:mysql://localhost:3306/javacode2018_slave?characterEncoding=UTF-8");
        dataSource.setUsername("root");
        dataSource.setPassword("root123");
        dataSource.setInitialSize(5);
        return dataSource;
    }

    //读写分离路由数据源
    @Bean
    public ReadWriteDataSource dataSource() {
        ReadWriteDataSource dataSource = new ReadWriteDataSource();
        //设置主库为默认的库,当路由的时候没有在datasource那个map中找到对应的数据源的时候,会使用这个默认的数据源
        dataSource.setDefaultTargetDataSource(this.masterDs());
        //设置多个目标库
        Map<Object, Object> targetDataSources = new HashMap<>();
        targetDataSources.put(DsType.MASTER, this.masterDs());
        targetDataSources.put(DsType.SLAVE, this.slaveDs());
        dataSource.setTargetDataSources(targetDataSources);
        return dataSource;
    }

    //JdbcTemplate,dataSource为上面定义的注入读写分离的数据源
    @Bean
    public JdbcTemplate jdbcTemplate(@Qualifier("dataSource") DataSource dataSource) {
        return new JdbcTemplate(dataSource);
    }

    //定义事务管理器,dataSource为上面定义的注入读写分离的数据源
    @Bean
    public PlatformTransactionManager transactionManager(@Qualifier("dataSource") DataSource dataSource) {
        return new DataSourceTransactionManager(dataSource);
    }
}

4.3、UserService

这个类就相当于我们平时写的 service,我是为了方法,直接在里面使用了 JdbcTemplate 来操作数据库,真实的项目操作 db 会放在 dao 里面。

getUserNameById 方法:通过 id 查询 name。

insert 方法:插入数据,这个内部的所有操作都会走主库,为了验证是不是查询也会走主库,插入数据之后,我们会调用 this.userService.getUserNameById(id, DsType.SLAVE)方法去执行查询操作,第二个参数故意使用 SLAVE,如果查询有结果,说明走的是主库,否则走的是从库,这里为什么需要通过 this.userService 来调用 getUserNameById?

this.userService 最终是个代理对象,通过代理对象访问其内部的方法,才会被读写分离的拦截器拦截。

package com.javacode2018.readwritesplit.demo1;

import com.javacode2018.readwritesplit.base.DsType;
import com.javacode2018.readwritesplit.base.IService;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.stereotype.Component;
import org.springframework.transaction.annotation.Propagation;
import org.springframework.transaction.annotation.Transactional;

import java.util.List;

@Component
public class UserService implements IService {

    @Autowired
    private JdbcTemplate jdbcTemplate;

    @Autowired
    private UserService userService;

    @Transactional(propagation = Propagation.SUPPORTS, readOnly = true)
    public String getUserNameById(long id, DsType dsType) {
        String sql = "select name from t_user where id=?";
        List<String> list = this.jdbcTemplate.queryForList(sql, String.class, id);
        return (list != null && list.size() > 0) ? list.get(0) : null;
    }

    //这个insert方法会走主库,内部的所有操作都会走主库
    @Transactional
    public void insert(long id, String name) {
        System.out.println(String.format("插入数据{id:%s, name:%s}", id, name));
        this.jdbcTemplate.update("insert into t_user (id,name) values (?,?)", id, name);
        String userName = this.userService.getUserNameById(id, DsType.SLAVE);
        System.out.println("查询结果:" + userName);
    }

}

4.4、测试用例

package com.javacode2018.readwritesplit.demo1;

import com.javacode2018.readwritesplit.base.DsType;
import org.junit.Before;
import org.junit.Test;
import org.springframework.context.annotation.AnnotationConfigApplicationContext;

public class Demo1Test {

    UserService userService;

    @Before
    public void before() {
        AnnotationConfigApplicationContext context = new AnnotationConfigApplicationContext();
        context.register(MainConfig.class);
        context.refresh();
        this.userService = context.getBean(UserService.class);
    }

    @Test
    public void test1() {
        System.out.println(this.userService.getUserNameById(1, DsType.MASTER));
        System.out.println(this.userService.getUserNameById(1, DsType.SLAVE));
    }

    @Test
    public void test2() {
        long id = System.currentTimeMillis();
        System.out.println(id);
        this.userService.insert(id, "张三");
    }
}

test1 方法执行 2 次查询,分别查询主库和从库,输出:

master库
slave库

是不是很爽,由开发者自己控制具体走主库还是从库。

test2 执行结果如下,可以看出查询到了刚刚插入的数据,说明 insert 中所有操作都走的是主库。

1604905117467
插入数据{id:1604905117467, name:张三}
查询结果:张三

5、案例源码

git地址:
https://gitee.com/javacode2018/spring-series

本文案例对应源码:
    spring-series\lesson-004-readwritesplit

大家 star 一下,所有系列代码都会在这个里面。

6、Spring 系列

  1. Spring 系列第 1 篇:为何要学 spring?

  2. Spring 系列第 2 篇:控制反转(IoC)与依赖注入(DI)

  3. Spring 系列第 3 篇:Spring 容器基本使用及原理

  4. Spring 系列第 4 篇:xml 中 bean 定义详解(-)

  5. Spring 系列第 5 篇:创建 bean 实例这些方式你们都知道?

  6. Spring 系列第 6 篇:玩转 bean scope,避免跳坑里!

  7. Spring 系列第 7 篇:依赖注入之手动注入

  8. Spring 系列第 8 篇:自动注入(autowire)详解,高手在于坚持

  9. Spring 系列第 9 篇:depend-on 到底是干什么的?

  10. Spring 系列第 10 篇:primary 可以解决什么问题?

  11. Spring 系列第 11 篇:bean 中的 autowire-candidate 又是干什么的?

  12. Spring 系列第 12 篇:lazy-init:bean 延迟初始化

  13. Spring 系列第 13 篇:使用继承简化 bean 配置(abstract & parent)

  14. Spring 系列第 14 篇:lookup-method 和 replaced-method 比较陌生,怎么玩的?

  15. Spring 系列第 15 篇:代理详解(Java 动态代理&cglib 代理)?

  16. Spring 系列第 16 篇:深入理解 java 注解及 spring 对注解的增强(预备知识)

  17. Spring 系列第 17 篇:@Configration 和@Bean 注解详解(bean 批量注册)

  18. Spring 系列第 18 篇:@ComponentScan、@ComponentScans 详解(bean 批量注册)

  19. Spring 系列第 18 篇:@import 详解(bean 批量注册)

  20. Spring 系列第 20 篇:@Conditional 通过条件来控制 bean 的注册

  21. Spring 系列第 21 篇:注解实现依赖注入(@Autowired、@Resource、@Primary、@Qulifier)

  22. Spring 系列第 22 篇:@Scope、@DependsOn、@ImportResource、@Lazy  详解

  23. Spring 系列第 23 篇:Bean 生命周期详解

  24. Spring 系列第 24 篇:父子容器详解

  25. Spring 系列第 25 篇:@Value【用法、数据来源、动态刷新】

  26. Spring 系列第 26 篇:国际化详解

  27. Spring 系列第 27 篇:spring 事件机制详解

  28. Spring 系列第 28 篇:Bean 循环依赖详解

  29. Spring 系列第 29 篇:BeanFactory 扩展(BeanFactoryPostProcessor、BeanDefinitionRegistryPostProcessor)

  30. Spring 系列第 30 篇:jdk 动态代理和 cglib 代理

  31. Spring 系列第 31 篇:aop 概念详解

  32. Spring 系列第 32 篇:AOP 核心源码、原理详解

  33. Spring 系列第 33 篇:ProxyFactoryBean 创建 AOP 代理

  34. Spring 系列第 34 篇:@Aspect 中@Pointcut 12 种用法

  35. Spring 系列第 35 篇:@Aspect 中 5 中通知详解

  36. Spring 系列第 36 篇:@EnableAspectJAutoProxy、@Aspect 中通知顺序详解

  37. Spring 系列第 37 篇:@EnableAsync & @Async 实现方法异步调用

  38. Spring 系列第 38 篇:@Scheduled & @EnableScheduling 定时器详解

  39. Spring 系列第 39 篇:强大的 Spel 表达式

  40. Spring 系列第 40 篇:缓存使用(@EnableCaching、@Cacheable、@CachePut、@CacheEvict、@Caching、@CacheConfig)

  41. Spring 系列第 41 篇:@EnableCaching 集成 redis 缓存

  42. Spring 系列第 42 篇:玩转 JdbcTemplate

  43. Spring 系列第 43 篇:spring 中编程式事务怎么用的?

  44. Spring 系列第 44 篇:详解 spring 声明式事务(@Transactional)

  45. Spring 系列第 45 篇:带你吃透 Spring 事务 7 种传播行为

  46. Spring 系列第 46 篇:Spring 如何管理多数据源事务?

  47. Spring 系列第 47 篇:spring 编程式事务源码解析

  48. Spring 系列第 48 篇:@Transaction 事务源码解析

  49. Spring 系列第 49 篇:通过 Spring 事务实现 MQ 中的事务消息

  50. Spring 系列第 50 篇:spring 事务拦截器顺序如何控制?

  51. Spring 系列第 51 篇:导致 Spring 事务失效常见的几种情况

7、更多好文章

  1. Java 高并发系列(共 34 篇)

  2. MySql 高手系列(共 27 篇)

  3. Maven 高手系列(共 10 篇)

  4. Mybatis 系列(共 12 篇)

  5. 聊聊 db 和缓存一致性常见的实现方式

  6. 接口幂等性这么重要,它是什么?怎么实现?

  7. 泛型,有点难度,会让很多人懵逼,那是因为你没有看这篇文章!

8、福利

这边整理了几百本计算机相关的电子书,常见的基本上都有,获取方式,扫码发送:电子书

已标记关键词 清除标记
©️2020 CSDN 皮肤主题: 精致技术 设计师:CSDN官方博客 返回首页