Java SpringBoot Mybatis MySQL 读写分离

1、引言

读写分离要做的事情就是对于一条SQL该选择哪个数据库去执行,至于谁来做选择数据库这件事儿,无非两个,要么中间件做,要么程序自己做。
因此,一般来讲,读写分离有两种实现方式。第一种是依靠中间件(比如:MyCat),也就是说应用程序连接到中间件,中间件做SQL分离;第二种是应用程序自己去做分离。这里选择程序自己来做,主要是利用Spring提供的路由数据源,以及AOP
然而,应用程序层面去做读写分离最大的弱点(不足之处)在于无法动态增加数据库节点,因为数据源配置都是写在配置中的,新增数据库意味着新加一个数据源,必然改配置,并重启应用。当然,好处就是相对简单。
2021-09-09-10-35-32-213461.jpeg

2、AbstractRoutingDataSource

基于特定的查找key路由到特定的数据源。它内部维护了一组目标数据源,并且做了路由key与目标数据源之间的映射,提供基于key查找数据源的方法。
2021-09-09-10-35-32-334501.png

3、实践

3.1. maven依赖

  1. <?xml version="1.0" encoding="UTF-8"?>
  2. <project xmlns="http://maven.apache.org/POM/4.0.0" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
  3. xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd">
  4. <modelVersion>4.0.0</modelVersion>
  5. <groupId>com.cjs.example</groupId>
  6. <artifactId>cjs-datasource-demo</artifactId>
  7. <version>0.0.1-SNAPSHOT</version>
  8. <packaging>jar</packaging>
  9. <name>cjs-datasource-demo</name>
  10. <description></description>
  11. <parent>
  12. <groupId>org.springframework.boot</groupId>
  13. <artifactId>spring-boot-starter-parent</artifactId>
  14. <version>2.0.5.RELEASE</version>
  15. <relativePath/> <!-- lookup parent from repository -->
  16. </parent>
  17. <properties>
  18. <project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
  19. <project.reporting.outputEncoding>UTF-8</project.reporting.outputEncoding>
  20. <java.version>1.8</java.version>
  21. </properties>
  22. <dependencies>
  23. <dependency>
  24. <groupId>org.springframework.boot</groupId>
  25. <artifactId>spring-boot-starter-aop</artifactId>
  26. </dependency>
  27. <dependency>
  28. <groupId>org.springframework.boot</groupId>
  29. <artifactId>spring-boot-starter-jdbc</artifactId>
  30. </dependency>
  31. <dependency>
  32. <groupId>org.springframework.boot</groupId>
  33. <artifactId>spring-boot-starter-web</artifactId>
  34. </dependency>
  35. <dependency>
  36. <groupId>org.mybatis.spring.boot</groupId>
  37. <artifactId>mybatis-spring-boot-starter</artifactId>
  38. <version>1.3.2</version>
  39. </dependency>
  40. <dependency>
  41. <groupId>org.apache.commons</groupId>
  42. <artifactId>commons-lang3</artifactId>
  43. <version>3.8</version>
  44. </dependency>
  45. <dependency>
  46. <groupId>mysql</groupId>
  47. <artifactId>mysql-connector-java</artifactId>
  48. <scope>runtime</scope>
  49. </dependency>
  50. <dependency>
  51. <groupId>org.springframework.boot</groupId>
  52. <artifactId>spring-boot-starter-test</artifactId>
  53. <scope>test</scope>
  54. </dependency>
  55. </dependencies>
  56. <build>
  57. <plugins>
  58. <plugin>
  59. <groupId>org.springframework.boot</groupId>
  60. <artifactId>spring-boot-maven-plugin</artifactId>
  61. </plugin>
  62. <!--<plugin>
  63. <groupId>org.mybatis.generator</groupId>
  64. <artifactId>mybatis-generator-maven-plugin</artifactId>
  65. <version>1.3.5</version>
  66. <dependencies>
  67. <dependency>
  68. <groupId>mysql</groupId>
  69. <artifactId>mysql-connector-java</artifactId>
  70. <version>5.1.46</version>
  71. </dependency>
  72. </dependencies>
  73. <configuration>
  74. <configurationFile>${basedir}/src/main/resources/myBatisGeneratorConfig.xml</configurationFile>
  75. <overwrite>true</overwrite>
  76. </configuration>
  77. <executions>
  78. <execution>
  79. <id>Generate MyBatis Artifacts</id>
  80. <goals>
  81. <goal>generate</goal>
  82. </goals>
  83. </execution>
  84. </executions>
  85. </plugin>-->
  86. </plugins>
  87. </build>
  88. </project>

3.2. 数据源配置

application.yml

  1. spring:
  2. datasource:
  3. master:
  4. jdbc-url: jdbc:mysql://192.168.102.31:3306/test
  5. username: root
  6. password: 123456
  7. driver-class-name: com.mysql.jdbc.Driver
  8. slave1:
  9. jdbc-url: jdbc:mysql://192.168.102.56:3306/test
  10. username: pig # 只读账户
  11. password: 123456
  12. driver-class-name: com.mysql.jdbc.Driver
  13. slave2:
  14. jdbc-url: jdbc:mysql://192.168.102.36:3306/test
  15. username: pig # 只读账户
  16. password: 123456
  17. driver-class-name: com.mysql.jdbc.Driver

多数据源配置

  1. package com.cjs.example.config;
  2. import com.cjs.example.bean.MyRoutingDataSource;
  3. import com.cjs.example.enums.DBTypeEnum;
  4. import org.springframework.beans.factory.annotation.Qualifier;
  5. import org.springframework.boot.context.properties.ConfigurationProperties;
  6. import org.springframework.boot.jdbc.DataSourceBuilder;
  7. import org.springframework.context.annotation.Bean;
  8. import org.springframework.context.annotation.Configuration;
  9. import javax.sql.DataSource;
  10. import java.util.HashMap;
  11. import java.util.Map;
  12. /**
  13. * 关于数据源配置,参考SpringBoot官方文档第79章《Data Access》
  14. * 79. Data Access
  15. * 79.1 Configure a Custom DataSource
  16. * 79.2 Configure Two DataSources
  17. */
  18. @Configuration
  19. public class DataSourceConfig {
  20. @Bean
  21. @ConfigurationProperties("spring.datasource.master")
  22. public DataSource masterDataSource() {
  23. return DataSourceBuilder.create().build();
  24. }
  25. @Bean
  26. @ConfigurationProperties("spring.datasource.slave1")
  27. public DataSource slave1DataSource() {
  28. return DataSourceBuilder.create().build();
  29. }
  30. @Bean
  31. @ConfigurationProperties("spring.datasource.slave2")
  32. public DataSource slave2DataSource() {
  33. return DataSourceBuilder.create().build();
  34. }
  35. @Bean
  36. public DataSource myRoutingDataSource(@Qualifier("masterDataSource") DataSource masterDataSource,
  37. @Qualifier("slave1DataSource") DataSource slave1DataSource,
  38. @Qualifier("slave2DataSource") DataSource slave2DataSource) {
  39. Map<Object, Object> targetDataSources = new HashMap<>();
  40. targetDataSources.put(DBTypeEnum.MASTER, masterDataSource);
  41. targetDataSources.put(DBTypeEnum.SLAVE1, slave1DataSource);
  42. targetDataSources.put(DBTypeEnum.SLAVE2, slave2DataSource);
  43. MyRoutingDataSource myRoutingDataSource = new MyRoutingDataSource();
  44. myRoutingDataSource.setDefaultTargetDataSource(masterDataSource);
  45. myRoutingDataSource.setTargetDataSources(targetDataSources);
  46. return myRoutingDataSource;
  47. }
  48. }

这里,配置了4个数据源,1个master,2两个slave,1个路由数据源。前3个数据源都是为了生成第4个数据源,而且后续只用这最后一个路由数据源。
MyBatis配置

  1. package com.cjs.example.config;
  2. import org.apache.ibatis.session.SqlSessionFactory;
  3. import org.mybatis.spring.SqlSessionFactoryBean;
  4. import org.springframework.context.annotation.Bean;
  5. import org.springframework.context.annotation.Configuration;
  6. import org.springframework.core.io.support.PathMatchingResourcePatternResolver;
  7. import org.springframework.jdbc.datasource.DataSourceTransactionManager;
  8. import org.springframework.transaction.PlatformTransactionManager;
  9. import org.springframework.transaction.annotation.EnableTransactionManagement;
  10. import javax.annotation.Resource;
  11. import javax.sql.DataSource;
  12. @EnableTransactionManagement
  13. @Configuration
  14. public class MyBatisConfig {
  15. @Resource(name = "myRoutingDataSource")
  16. private DataSource myRoutingDataSource;
  17. @Bean
  18. public SqlSessionFactory sqlSessionFactory() throws Exception {
  19. SqlSessionFactoryBean sqlSessionFactoryBean = new SqlSessionFactoryBean();
  20. sqlSessionFactoryBean.setDataSource(myRoutingDataSource);
  21. sqlSessionFactoryBean.setMapperLocations(new PathMatchingResourcePatternResolver().getResources("classpath:mapper/*.xml"));
  22. return sqlSessionFactoryBean.getObject();
  23. }
  24. @Bean
  25. public PlatformTransactionManager platformTransactionManager() {
  26. return new DataSourceTransactionManager(myRoutingDataSource);
  27. }
  28. }

由于Spring容器中现在有4个数据源,所以需要为事务管理器和MyBatis手动指定一个明确的数据源。

3.3. 设置路由key / 查找数据源

目标数据源就是那前3个这个是知道的,但是使用的时候是如果查找数据源的呢?
首先,定义一个枚举来代表这三个数据源

  1. package com.cjs.example.enums;
  2. public enum DBTypeEnum {
  3. MASTER, SLAVE1, SLAVE2;
  4. }

接下来,通过ThreadLocal将数据源设置到每个线程上下文中

  1. package com.cjs.example.bean;
  2. import com.cjs.example.enums.DBTypeEnum;
  3. import java.util.concurrent.atomic.AtomicInteger;
  4. public class DBContextHolder {
  5. private static final ThreadLocal<DBTypeEnum> contextHolder = new ThreadLocal<>();
  6. private static final AtomicInteger counter = new AtomicInteger(-1);
  7. public static void set(DBTypeEnum dbType) {
  8. contextHolder.set(dbType);
  9. }
  10. public static DBTypeEnum get() {
  11. return contextHolder.get();
  12. }
  13. public static void master() {
  14. set(DBTypeEnum.MASTER);
  15. System.out.println("切换到master");
  16. }
  17. public static void slave() {
  18. // 轮询
  19. int index = counter.getAndIncrement() % 2;
  20. if (counter.get() > 9999) {
  21. counter.set(-1);
  22. }
  23. if (index == 0) {
  24. set(DBTypeEnum.SLAVE1);
  25. System.out.println("切换到slave1");
  26. }else {
  27. set(DBTypeEnum.SLAVE2);
  28. System.out.println("切换到slave2");
  29. }
  30. }
  31. }

获取路由key

  1. package com.cjs.example.bean;
  2. import org.springframework.jdbc.datasource.lookup.AbstractRoutingDataSource;
  3. import org.springframework.lang.Nullable;
  4. public class MyRoutingDataSource extends AbstractRoutingDataSource {
  5. @Nullable
  6. @Override
  7. protected Object determineCurrentLookupKey() {
  8. return DBContextHolder.get();
  9. }
  10. }

设置路由key
默认情况下,所有的查询都走从库,插入/修改/删除走主库。通过方法名来区分操作类型(CRUD)

  1. package com.cjs.example.aop;
  2. import com.cjs.example.bean.DBContextHolder;
  3. import org.apache.commons.lang3.StringUtils;
  4. import org.aspectj.lang.JoinPoint;
  5. import org.aspectj.lang.annotation.Aspect;
  6. import org.aspectj.lang.annotation.Before;
  7. import org.aspectj.lang.annotation.Pointcut;
  8. import org.springframework.stereotype.Component;
  9. @Aspect
  10. @Component
  11. public class DataSourceAop {
  12. @Pointcut("!@annotation(com.cjs.example.annotation.Master) " +
  13. "&& (execution(* com.cjs.example.service..*.select*(..)) " +
  14. "|| execution(* com.cjs.example.service..*.get*(..)))")
  15. public void readPointcut() {
  16. }
  17. @Pointcut("@annotation(com.cjs.example.annotation.Master) " +
  18. "|| execution(* com.cjs.example.service..*.insert*(..)) " +
  19. "|| execution(* com.cjs.example.service..*.add*(..)) " +
  20. "|| execution(* com.cjs.example.service..*.update*(..)) " +
  21. "|| execution(* com.cjs.example.service..*.edit*(..)) " +
  22. "|| execution(* com.cjs.example.service..*.delete*(..)) " +
  23. "|| execution(* com.cjs.example.service..*.remove*(..))")
  24. public void writePointcut() {
  25. }
  26. @Before("readPointcut()")
  27. public void read() {
  28. DBContextHolder.slave();
  29. }
  30. @Before("writePointcut()")
  31. public void write() {
  32. DBContextHolder.master();
  33. }
  34. /**
  35. * 另一种写法:if...else... 判断哪些需要读从数据库,其余的走主数据库
  36. */
  37. // @Before("execution(* com.cjs.example.service.impl.*.*(..))")
  38. // public void before(JoinPoint jp) {
  39. // String methodName = jp.getSignature().getName();
  40. //
  41. // if (StringUtils.startsWithAny(methodName, "get", "select", "find")) {
  42. // DBContextHolder.slave();
  43. // }else {
  44. // DBContextHolder.master();
  45. // }
  46. // }
  47. }

有一般情况就有特殊情况,特殊情况是某些情况下需要强制读主库,针对这种情况,定义一个主键,用该注解标注的就读主库

  1. package com.cjs.example.annotation;
  2. public @interface Master {
  3. }

例如,假设有一张表member

  1. package com.cjs.example.service.impl;
  2. import com.cjs.example.annotation.Master;
  3. import com.cjs.example.entity.Member;
  4. import com.cjs.example.entity.MemberExample;
  5. import com.cjs.example.mapper.MemberMapper;
  6. import com.cjs.example.service.MemberService;
  7. import org.springframework.beans.factory.annotation.Autowired;
  8. import org.springframework.stereotype.Service;
  9. import org.springframework.transaction.annotation.Transactional;
  10. import java.util.List;
  11. @Service
  12. public class MemberServiceImpl implements MemberService {
  13. @Autowired
  14. private MemberMapper memberMapper;
  15. @Transactional
  16. @Override
  17. public int insert(Member member) {
  18. return memberMapper.insert(member);
  19. }
  20. @Master
  21. @Override
  22. public int save(Member member) {
  23. return memberMapper.insert(member);
  24. }
  25. @Override
  26. public List<Member> selectAll() {
  27. return memberMapper.selectByExample(new MemberExample());
  28. }
  29. @Master
  30. @Override
  31. public String getToken(String appId) {
  32. // 有些读操作必须读主数据库
  33. // 比如,获取微信access_token,因为高峰时期主从同步可能延迟
  34. // 这种情况下就必须强制从主数据读
  35. return null;
  36. }
  37. }

4、测试

  1. package com.cjs.example;
  2. import com.cjs.example.entity.Member;
  3. import com.cjs.example.service.MemberService;
  4. import org.junit.Test;
  5. import org.junit.runner.RunWith;
  6. import org.springframework.beans.factory.annotation.Autowired;
  7. import org.springframework.boot.test.context.SpringBootTest;
  8. import org.springframework.test.context.junit4.SpringRunner;
  9. @RunWith(SpringRunner.class)
  10. @SpringBootTest
  11. public class CjsDatasourceDemoApplicationTests {
  12. @Autowired
  13. private MemberService memberService;
  14. @Test
  15. public void testWrite() {
  16. Member member = new Member();
  17. member.setName("zhangsan");
  18. memberService.insert(member);
  19. }
  20. @Test
  21. public void testRead() {
  22. for (int i = 0; i < 4; i++) {
  23. memberService.selectAll();
  24. }
  25. }
  26. @Test
  27. public void testSave() {
  28. Member member = new Member();
  29. member.setName("wangwu");
  30. memberService.save(member);
  31. }
  32. @Test
  33. public void testReadFromMaster() {
  34. memberService.getToken("1234");
  35. }
  36. }

查看控制台
2021-09-09-10-35-32-446502.png2021-09-09-10-35-32-587466.png2021-09-09-10-35-32-713467.png2021-09-09-10-35-32-830461.png

5、工程结构

2021-09-09-10-35-32-969460.png