前言
MySQL 在 5.1.X 版本之后增加了对 multi-host 的支持,我们可以使用它来实现读写分离。
正常的 jdbc 连接格式为
jdbc:mysql://ip:3306/testdb?characterEncoding=UTF-8
multi-host 的 jdbc 连接格式为
jdbc:mysql:replication://ip:3306,ip:3307,ip:3308/testdb?useUnicode=true&characterEncoding=UTF-8&autoReconnect=false&ha.loadBalanceStrategy=random
第一个地址当作主库,后面的都当作从库。通过 Connection 中的 readonly 属性来控制读写,如果是写就操作主库,如果是读就操作从库。
注意:这种方式需要主从库的数据库名称、用户名、密码都一致
代码示例
我们可以搭建一个主从,也可以使用两个数据库来模拟主从,两个数据库的账号密码都一致。这里我们使用后一种方式。
5.1.42 版本需要使用 com.mysql.jdbc.ReplicationDriver 这个驱动类,高版本直接使用默认的驱动 com.mysql.cj.jdbc.Driver 就可以了(ReplicationDriver 这个类已经被删除了)。
import com.zaxxer.hikari.HikariDataSource;
import javax.sql.DataSource;
import java.sql.Connection;
import java.sql.PreparedStatement;
import java.sql.ResultSet;
import java.sql.SQLException;
import java.util.ArrayList;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
public class TestMysqlReplication {
public static void main(String[] args) throws SQLException {
// testMaster();
// testSlave();
testMasterSlaveSwitch();
}
/**
* 查询主从切换
* @throws SQLException
*/
private static void testMasterSlaveSwitch() throws SQLException {
DataSource dataSource = masterSlaveDataSource();
Connection connection = dataSource.getConnection();
connection.setReadOnly(true);
List<Map<String, Object>> queryResult = getQueryResult(connection);
System.out.println(queryResult);
int updated = updateSql(connection);
System.out.println(updated);
}
/**
* 查询主库
* @throws SQLException
*/
private static void testMaster() throws SQLException {
DataSource dataSource = masterDataSource();
List<Map<String, Object>> queryResult = getQueryResult(dataSource.getConnection());
System.out.println(queryResult);
}
/**
* 查询从库
* @throws SQLException
*/
private static void testSlave() throws SQLException {
DataSource dataSource = slaveDataSource();
List<Map<String, Object>> queryResult = getQueryResult(dataSource.getConnection());
System.out.println(queryResult);
}
private static DataSource masterDataSource() {
HikariDataSource dataSource = new HikariDataSource();
dataSource.setJdbcUrl("jdbc:mysql://ip:3310/testdb");
dataSource.setUsername("xxx");
dataSource.setPassword("xxx");
dataSource.setDriverClassName("com.mysql.cj.jdbc.Driver");
return dataSource;
}
private static DataSource slaveDataSource() {
HikariDataSource dataSource = new HikariDataSource();
dataSource.setJdbcUrl("jdbc:mysql://ip:3306/testdb");
dataSource.setUsername("xxx");
dataSource.setPassword("xxx");
dataSource.setDriverClassName("com.mysql.cj.jdbc.Driver");
return dataSource;
}
private static DataSource masterSlaveDataSource() {
HikariDataSource dataSource = new HikariDataSource();
dataSource.setJdbcUrl("jdbc:mysql:replication://ip:3310,ip:3306/testdb?ha.loadBalanceStrategy=random");
dataSource.setUsername("xxx");
dataSource.setPassword("xxx");
dataSource.setDriverClassName("com.mysql.cj.jdbc.Driver");
return dataSource;
}
private static List<Map<String, Object>> getQueryResult(Connection connection) throws SQLException {
PreparedStatement ps = connection.prepareStatement("select * from tb_product");
ResultSet resultSet = ps.executeQuery();
List<Map<String, Object>> result = new ArrayList<>();
while (resultSet.next()) {
Map<String, Object> map = new HashMap<>();
map.put("id", resultSet.getInt("id"));
map.put("name", resultSet.getString("name"));
map.put("stock", resultSet.getInt("stock"));
result.add(map);
}
return result;
}
private static int updateSql(Connection connection) throws SQLException {
PreparedStatement ps = connection.prepareStatement("update tb_product set stock=50 where id=1");
return ps.executeUpdate();
}
}
- 如果我们没设置 readonly=true,那么查到的数据为 [{name=小米手机, id=1, stock=38}],执行更新,实际操作的是主库
- 如果我们设置了 readonly=true,那么查到的数据为 [{name=华为手机, id=1, stock=38}],这个时候不能执行更新,会报错
原理分析
- NonRegisteringDriver 的 connect() 方法根据我们的 url 来解析出具体是什么类型的连接,连接类型可以查看 com.mysql.cj.conf.ConnectionUrl.Type 这个类。
- 根据 url 得到 ReplicationConnectionUrl 类型,在它的构造器逻辑中可以看到,取第一个地址为主库,后面所有地址当作从库。
- 继续根据 url 得到 Connection 的实际类型为 ReplicationConnectionProxy,它内部的 currentConnection 默认为 sourceConnection。
- 在 setReadOnly(true) 时,currentConnection 切换为 replicasConnection,实际类型为 LoadBalancedConnectionProxy。
- 根据 url 中配置的负载均衡策略 ha.loadBalanceStrategy 来决定使用哪个从库,默认策略为 random,实现类为 RandomBalanceStrategy。
Spring中控制
在Spring中,通过 @Transactional 注解的 readOnly 字段来控制是否走从库读。
- TransactionInterceptor 的 invoke() 方法拦截所有包含 @Transactional 注解的方法。
- 继续进入 invokeWithinTransaction() 方法,调用 createTransactionIfNecessary() 创建一个事务对象。
- 通过 PlatformTransactionManager(实际为 JdbcTransactionManager) 的 getTransaction() 方法获取事务状态对象。
- 调用 startTransaction() 方法内的 doBegin() 方法来开启事务。
- 调用 prepareConnectionForTransaction() 方法对连接做一些预处理,其中就包括设置 connection 的 readonly 字段,后续生效就是 MySQL 驱动来控制了。
总结
Spring 中通过 动态切换数据源 也可以实现读写分离,对比如下
- Spring 方式可扩展,适用于多种数据库,而 MySQL 驱动方式只适用于MySQL
- MySQL 驱动方式必须主从库用户名、密码一致,而 Spring 更灵活
- Spring 需要更多的配置,而 MySQL 驱动方式配置简单
参考
”MySQL官方驱动“主从分离的神秘面纱(扫盲篇)
mysql-read-write-splitting
Mysql使用ReplicationDriver驱动实现读写分离