一个涉及到数据库的应用,其性能瓶颈通常在于数据库本身,通常的解决路径有优化索引、引入缓存、分库分表、利用数据库对主从复制的支持实现读写分离等(实际上,我们会根据具体情况组合使用这些方式)。
本文重点关注数据库的读写分离,介绍如何在Spring Boot项目中对MySQL数据库实现相关操作。
为了搭建一个最小的可运行环境,我们还会利用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
要注意的几点:
data/mastermysql
子目录;data/slavemysql
子目录;mysqlroot
3360
;3361
;example
数据库。在宿主机上执行:
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
...
为了方便,我们打开两个终端窗口分别连接 master
和 slave
数据库:
# 在终端一连接 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(20) NOT NULL AUTO_INCREMENT COMMENT 'ID,自增',
`title` varchar(150) DEFAULT NULL COMMENT '书名',
PRIMARY KEY (`id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
可以看到表结构同步过来了,让我们接着下一个问题吧。
大部分情况下,我们的项目只需要一个MySQL数据源配置,而且借助Spring Boot的DataSourceAutoConfiguration
配置,能省我们不少功夫。
引入读写分离后,我们至少需要配置两个数据源,一个用于写
,一个用于读
。注意这里用了 至少,因为某些情况下,会有一主多从或多主多从,这时就会需要配置更多的数据源了。
为了最小化运行环境,我们以一主一从的双数据源配置作为示例(原理是相通的)。
打开 Spring Initializr[2] 网站,新建一个Spring Boot 项目,我们只需要引入 MySQL
和 JDBC API
依赖,其它使用默认的即可:
点击页面上的 Generate 按钮,会生成一个 demo.zip
文件,我们下载并解压它,再 cd
到项目文件夹下执行如下命令:
# 编译项目
mvn compile
编译完成后,使用IDE打开它:
为了方便,我们把资源路径下的application.properties
改为application.yaml
,yaml
格式更简洁。要配置多数据源,只需要在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"));
}
}
运行该测试方法,可以看到我们得到了预期的结果:
再来,就是最后一步了。
上面的测试类首先证明了多数据源配置是生效的,二来也演示了如何手动切换数据源。
实际应用中,这种方式未免繁琐,那有没有办法根据事务上下文信息,智能切换数据源呢?
我想,在计算机世界中,我们是可以这样大胆的说的: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],要实现动态切换,只需要:
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()
的使用,它可以根据事务注解,判断当前事务方法是否是可读的;然后程序根据是否可读,返回相应类型的数据源。
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());
}
其中:
routingDataSource()
返回一个支持动态切换数据源的DataSource
BeanjdbcTemplate()
返回一个默认支持数据源动态切换的JdbcTemplate
Bean有了以上步骤,我们可以编写测试类来体验了。
修改 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());
}
运行该测试类,可以看到全部测试通过:
@Transactional
使用建议@Transactional
注解的readOnly
属性默认为false
, 这会导致未设置该属性的所有事务方法默认在在 master 节点上运行。一个好的实践是,在class级别上加上@Transactional(readOnly = true)
,确实需要写
操作时,再在方法级别上调整。如此一来,所有Service方法默认为只读
的,当忘了在写
方法上调整readOnly=false
时,我们会得到一个异常,因为写
操作只能在 master 节点上执行。这在减少配置的同时,也给了代码很好的保护。
另外,关于TransactionRoutingDataSource
,下面这张图可以帮助更好的理解:
有别的疑问?欢迎留言。
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/