一个叫木头,一个叫马尾

在Spring Boot项目中实现MySQL的读写分离

一个涉及到数据库的应用,其性能瓶颈通常在于数据库本身,通常的解决路径有优化索引、引入缓存、分库分表、利用数据库对主从复制的支持实现读写分离等(实际上,我们会根据具体情况组合使用这些方式)。

本文重点关注数据库的读写分离,介绍如何在Spring Boot项目中对MySQL数据库实现相关操作。

为了搭建一个最小的可运行环境,我们还会利用Docker跑一个简单的MySQL主从实例。

整篇文章针对三个问题而展开:

  1. 怎么在 Docker 上部署一个简单的 MySQL 主从实例;
  2. Spring Boot 项目中如何配置多数据源;
  3. Spring Boot 项目中如何针对读、写情况在多数据源间自动切换。

怎么在 Docker 上部署一个简单的 MySQL 主从实例

从google上搜索 docker hub mysql master slave,我找到了这么一个仓库: actency/docker-mysql-replication[1]。试用之后感觉比较顺手,那就用它来演示吧!

以下是部署命令(要求宿主机已安装docker环境):

# 运行 master
docker run -d \
 --name mysql_master \
 -v ${PWD}/data/mastermysql:/var/lib/mysql \
 -e MYSQL_ROOT_PASSWORD=mysqlroot \
 -e MYSQL_USER=example_user \
 -e MYSQL_PASSWORD=mysqlpwd \
 -e MYSQL_DATABASE=example \
 -e REPLICATION_USER=replication_user \
 -e REPLICATION_PASSWORD=myreplpassword \
 -p 3360:3306 \
 actency/docker-mysql-replication:5.7
 
 # 运行slave
  docker run -d \
 --name mysql_slave \
 -v ${PWD}/data/slavemysql:/var/lib/mysql \
 -e MYSQL_ROOT_PASSWORD=mysqlroot \
 -e MYSQL_USER=example_user \
 -e MYSQL_PASSWORD=mysqlpwd \
 -e MYSQL_DATABASE=example \
 -e REPLICATION_USER=replication_user \
 -e REPLICATION_PASSWORD=myreplpassword \
 --link mysql_master:master \
 -p 3361:3306 \
 actency/docker-mysql-replication:5.7

要注意的几点:

验证主从数据库的有效性

在宿主机上执行:

docker exec mysql_slave \
mysql -uroot -pmysqlroot -e "SHOW SLAVE STATUS\G;"

可以看到如下输出:

********** 1. row ***************************
Slave_IO_State: Waiting for master to send event
Master_Host: 172.17.0.2
Master_User: replication_user
Master_Port: 3306
Connect_Retry: 60
Master_Log_File: mysql-bin.000005
Read_Master_Log_Pos: 154
Relay_Log_File: mysql-relay.000008
Relay_Log_Pos: 367
Relay_Master_Log_File: mysql-bin.000005
Slave_IO_Running: Yes
Slave_SQL_Running: Yes
...

为了方便,我们打开两个终端窗口分别连接 masterslave 数据库:

# 在终端一连接 master
docker exec -it mysql_master \
mysql -uroot -pmysqlroot example
# 在终端二连接 slave
docker exec -it mysql_slave \
mysql -uroot -pmysqlroot example

效果如下:

默认情况下,两个库都是空的(没有table):

初始状态下,两个库是空的
初始状态下,两个库是空的

我们尝试在 master 库中新建一张表,并查看 slave 库中是否会同步创建该表:

# 创建表的语句
CREATE TABLE `book` (
  `id` bigint(20NOT NULL AUTO_INCREMENT COMMENT 'ID,自增',
  `title` varchar(150DEFAULT NULL COMMENT '书名',
  PRIMARY KEY (`id`)
ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
检查表结构是否同步
检查表结构是否同步

可以看到表结构同步过来了,让我们接着下一个问题吧。

Spring Boot 项目中如何配置多数据源

大部分情况下,我们的项目只需要一个MySQL数据源配置,而且借助Spring Boot的DataSourceAutoConfiguration配置,能省我们不少功夫。

引入读写分离后,我们至少需要配置两个数据源,一个用于,一个用于。注意这里用了 至少,因为某些情况下,会有一主多从或多主多从,这时就会需要配置更多的数据源了。

为了最小化运行环境,我们以一主一从的双数据源配置作为示例(原理是相通的)。

创建一个最小化的Spring Boot项目

打开 Spring Initializr[2] 网站,新建一个Spring Boot 项目,我们只需要引入 MySQLJDBC API 依赖,其它使用默认的即可:

引入项目的依赖
引入项目的依赖

点击页面上的 Generate 按钮,会生成一个 demo.zip 文件,我们下载并解压它,再 cd 到项目文件夹下执行如下命令:

# 编译项目
mvn compile

编译完成后,使用IDE打开它:

一个普通的Sprint Boot Maven项目
一个普通的Sprint Boot Maven项目

配置多数据源

为了方便,我们把资源路径下的application.properties改为application.yamlyaml格式更简洁。要配置多数据源,只需要在application.yaml中加入以下内容:

# master
spring.datasource:
  jdbc-url: jdbc:mysql://0.0.0.0:3360/example?useUnicode=true&characterEncoding=utf8&autoReconnect=true&zeroDateTimeBehavior=CONVERT_TO_NULL
  username: root
  password:  mysqlroot
  driverClassName: com.mysql.cj.jdbc.Driver

# slave
spring.slave-datasource:
  jdbc-url: jdbc:mysql://0.0.0.0:3361/example?useUnicode=true&characterEncoding=utf8&autoReconnect=true&zeroDateTimeBehavior=CONVERT_TO_NULL
  username: root
  password:  mysqlroot
  driverClassName: com.mysql.cj.jdbc.Driver

然后新增一个配置类,根据上述配置来初始化两个Datasource:

@Configuration
public class DbConfig {

    @Bean
    @Primary
    @ConfigurationProperties(prefix = "spring.datasource")
    public DataSource masterDataSource() {
        return DataSourceBuilder.create().build();
    }

    @Bean
    @ConfigurationProperties(prefix = "spring.slave-datasource")
    public DataSource slaveDataSource() {
        return DataSourceBuilder.create().build();
    }
}

有了以上,我们可以在DemoApplicationTests.java中编写一个测试方法,判断多数据源是否有效:

@SpringBootTest
class DemoApplicationTests {

    @Autowired
    private DataSource masterDataSource;

    @Autowired
    @Qualifier("slaveDataSource")
    private DataSource slaveDataSource;


    @Test
    void contextLoads() {
        JdbcTemplate master = new JdbcTemplate(masterDataSource);
        JdbcTemplate slave = new JdbcTemplate(slaveDataSource);

        master.update("truncate table book");
        // 主写
        master.update("insert into book(id,title) values(?,?)"1"hello world");

        // 从读
        Map<String, Object> data = slave.queryForMap("select * from book where id=1");

        Assertions.assertEquals("hello world", data.get("title"));

    }
}

运行该测试方法,可以看到我们得到了预期的结果:

测试多数据源配置是否有效
测试多数据源配置是否有效

再来,就是最后一步了。

Spring Boot 项目中如何针对读、写情况动态切换数据源

上面的测试类首先证明了多数据源配置是生效的,二来也演示了如何手动切换数据源。

实际应用中,这种方式未免繁琐,那有没有办法根据事务上下文信息,智能切换数据源呢?

我想,在计算机世界中,我们是可以这样大胆的说的:Where there is a problem, there is a way.

比如,Spring框架本身就提供了一个AbstractRoutingDataSource类,专门用来支持数据源动态切换。官方也有相关博文对此作了详细的介绍: Dynamic DataSource Routing[3]

按照上面博文的思路,并参考另一篇文章: Read-write and read-only transaction routing with Spring[4],要实现动态切换,只需要:

1. 先定义一个AbstractRoutingDataSource类的子类,用来整合多个数据源

public class TransactionRoutingDataSource
        extends AbstractRoutingDataSource 
{

    @Nullable
    @Override
    protected Object determineCurrentLookupKey() {
        return TransactionSynchronizationManager
                .isCurrentTransactionReadOnly() ?
                DataSourceType.READ_ONLY :
                DataSourceType.READ_WRITE;
    }
    
    /**
     * 方便测试用,获取当前实际使用的DataSource
     */

    @Override
    public DataSource determineTargetDataSource() {
        return super.determineTargetDataSource();
    }

    public enum DataSourceType {
        READ_WRITE, // 读写型事务
        READ_ONLY, // 只读事务
        ;
    }
}

注意TransactionSynchronizationManager.isCurrentTransactionReadOnly()的使用,它可以根据事务注解,判断当前事务方法是否是可读的;然后程序根据是否可读,返回相应类型的数据源。

2. 修改DbConfig.java,加入两个新的 Bean

@Bean
public TransactionRoutingDataSource routingDataSource() {
    TransactionRoutingDataSource routingDataSource =
            new TransactionRoutingDataSource();

    DataSource master = masterDataSource();
    DataSource slave = slaveDataSource();

    Map<Object, Object> dataSourceMap = new HashMap<>();
    dataSourceMap.put(
            TransactionRoutingDataSource.DataSourceType.READ_WRITE,
            master
    );
    dataSourceMap.put(
            TransactionRoutingDataSource.DataSourceType.READ_ONLY,
            slave
    );

    routingDataSource.setTargetDataSources(dataSourceMap);

    return routingDataSource;
}

@Bean
public JdbcTemplate jdbcTemplate() {
    return new JdbcTemplate(routingDataSource());
}

其中:

有了以上步骤,我们可以编写测试类来体验了。

3. 测试数据源动态路由是否有效

修改 DemoApplicationTests.java ,加入以下字段和方法:

@Autowired
private JdbcTemplate jdbcTemplate;

@Transactional(readOnly = false)
@Test
void testReadWrite() {
    jdbcTemplate.update("update book set title=? where id=1""bingo");

    Assertions.assertFalse(TransactionSynchronizationManager.isCurrentTransactionReadOnly());

    TransactionRoutingDataSource dataSource = (TransactionRoutingDataSource) jdbcTemplate.getDataSource();
    // 测试写状态下使用的是 master 数据源
    Assertions.assertEquals(masterDataSource, dataSource.determineTargetDataSource());

}

@Transactional(readOnly = true)
@Test
void testReadOnly() {
    Integer sum = jdbcTemplate.queryForObject("select 1+1", Integer.class);
    Assertions.assertEquals(2, sum);

    Assertions.assertTrue(TransactionSynchronizationManager.isCurrentTransactionReadOnly());

    TransactionRoutingDataSource dataSource = (TransactionRoutingDataSource) jdbcTemplate.getDataSource();
    // 测试只读状态下使用是 slave 数据源
    Assertions.assertEquals(slaveDataSource, dataSource.determineTargetDataSource());
}

运行该测试类,可以看到全部测试通过:

4. @Transactional使用建议

@Transactional注解的readOnly属性默认为false, 这会导致未设置该属性的所有事务方法默认在在 master 节点上运行。一个好的实践是,在class级别上加上@Transactional(readOnly = true),确实需要操作时,再在方法级别上调整。如此一来,所有Service方法默认为只读的,当忘了在方法上调整readOnly=false时,我们会得到一个异常,因为操作只能在 master 节点上执行。这在减少配置的同时,也给了代码很好的保护。


另外,关于TransactionRoutingDataSource,下面这张图可以帮助更好的理解:

Credit:  @vlad_mihalcea
Credit: @vlad_mihalcea

有别的疑问?欢迎留言。

[1]

actency/docker-mysql-replication: https://hub.docker.com/r/actency/docker-mysql-replication

[2]

Spring Initializr: https://start.spring.io

[3]

Dynamic DataSource Routing: https://spring.io/blog/2007/01/23/dynamic-datasource-routing/

[4]

Read-write and read-only transaction routing with Spring: https://vladmihalcea.com/read-write-read-only-transaction-routing-spring/