使用MySQL驱动方式实现读写分离

前言

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();
    }
}
  1. 如果我们没设置 readonly=true,那么查到的数据为 [{name=小米手机, id=1, stock=38}],执行更新,实际操作的是主库
  2. 如果我们设置了 readonly=true,那么查到的数据为 [{name=华为手机, id=1, stock=38}],这个时候不能执行更新,会报错

原理分析

  1. NonRegisteringDriver 的 connect() 方法根据我们的 url 来解析出具体是什么类型的连接,连接类型可以查看 com.mysql.cj.conf.ConnectionUrl.Type 这个类。
  2. 根据 url 得到 ReplicationConnectionUrl 类型,在它的构造器逻辑中可以看到,取第一个地址为主库,后面所有地址当作从库。
  3. 继续根据 url 得到 Connection 的实际类型为 ReplicationConnectionProxy,它内部的 currentConnection 默认为 sourceConnection。
  4. 在 setReadOnly(true) 时,currentConnection 切换为 replicasConnection,实际类型为 LoadBalancedConnectionProxy。
  5. 根据 url 中配置的负载均衡策略 ha.loadBalanceStrategy 来决定使用哪个从库,默认策略为 random,实现类为 RandomBalanceStrategy。

Spring中控制

在Spring中,通过 @Transactional 注解的 readOnly 字段来控制是否走从库读。

  1. TransactionInterceptor 的 invoke() 方法拦截所有包含 @Transactional 注解的方法。
  2. 继续进入 invokeWithinTransaction() 方法,调用 createTransactionIfNecessary() 创建一个事务对象。
  3. 通过 PlatformTransactionManager(实际为 JdbcTransactionManager) 的 getTransaction() 方法获取事务状态对象。
  4. 调用 startTransaction() 方法内的 doBegin() 方法来开启事务。
  5. 调用 prepareConnectionForTransaction() 方法对连接做一些预处理,其中就包括设置 connection 的 readonly 字段,后续生效就是 MySQL 驱动来控制了。

总结

Spring 中通过 动态切换数据源 也可以实现读写分离,对比如下

  1. Spring 方式可扩展,适用于多种数据库,而 MySQL 驱动方式只适用于MySQL
  2. MySQL 驱动方式必须主从库用户名、密码一致,而 Spring 更灵活
  3. Spring 需要更多的配置,而 MySQL 驱动方式配置简单

参考

”MySQL官方驱动“主从分离的神秘面纱(扫盲篇)
mysql-read-write-splitting
Mysql使用ReplicationDriver驱动实现读写分离

posted @ 2024-04-07 22:47  strongmore  阅读(20)  评论(0编辑  收藏  举报