mysql读写分离

动手实现MySQL读写分离and故障转移

MySQL的读写分离两种实现方式

第一种方式:我们手动在代码层实现逻辑,来解析读请求或者写请求,分别分发到不同的数据库中,实现读写分离;

第二种方式就是基于中间件做一层proxy,比如MyCat来实现读写分离的效果。

手写实现读写分离

MyBatis+Spring

手写MyBatis拦截器来判断SQL是读或者写,从而选择数据源,最后交给Spring注入数据源,来实现读写分离。

------修改application.yml

server: 
  port: 10001
spring: 
  datasource:
    master: 
      url: jdbc:mysql://192.168.43.201:3306/springtestdemo?useUnicode=true&characterEncoding=UTF-8&serverTimezone=UTC
      username: Object
      password: Object971103.
      driver-class-name: com.mysql.cj.jdbc.Driver
    slave: 
      url: jdbc:mysql://192.168.43.202:3306/springtestdemo?useUnicode=true&characterEncoding=UTF-8&serverTimezone=UTC
      username: Object
      password: Object971103.
      driver-class-name: com.mysql.cj.jdbc.Driver
    
    
#MyBatis配置
mybatis:
  mapper-locations: classpath:mapper/*.xml
  configuration:
    cache-enabled: true #开启二级缓存
    map-underscore-to-camel-case: true

------DataSource的配置

首先要先创建两个ConfigurationProperties类,这一步不是非必须的,直接配置DataSource也是可以的,但是我还是比较习惯去写这个Properties。

package cn.objectspace.springtestdemo.config;

import org.springframework.boot.context.properties.ConfigurationProperties;
import org.springframework.stereotype.Component;

@ConfigurationProperties(prefix = "spring.datasource.master")
@Component
public class MasterProperties {
    private String url;
    private String username;
    private String password;
    private String driverClassName;
    public String getUrl() {
        return url;
    }
    public void setUrl(String url) {
        this.url = url;
    }
    public String getUsername() {
        return username;
    }
    public void setUsername(String username) {
        this.username = username;
    }
    public String getPassword() {
        return password;
    }
    public void setPassword(String password) {
        this.password = password;
    }
    public String getDriverClassName() {
        return driverClassName;
    }
    public void setDriverClassName(String driverClassName) {
        this.driverClassName = driverClassName;
    }
    
}

SlaveProperties类似。

------DataSourceConfig主从数据源配置

@Configuration
public class DataSourceConfig {
    private Logger logger = LoggerFactory.getLogger(DataSourceConfig.class);
    @Autowired
    private MasterProperties masterProperties;

    @Autowired
    private SlaveProperties slaveProperties;

    //默认是master数据源
    @Bean(name = "masterDataSource")
    @Primary
    public DataSource masterProperties(){
        logger.info("masterDataSource初始化");
        HikariDataSource dataSource = new HikariDataSource();
        dataSource.setJdbcUrl(masterProperties.getUrl());
        dataSource.setUsername(masterProperties.getUsername());
        dataSource.setPassword(masterProperties.getPassword());
        dataSource.setDriverClassName(masterProperties.getDriverClassName());
        return dataSource;
    }

    @Bean(name = "slaveDataSource")
    public DataSource dataBase2DataSource(){
        logger.info("slaveDataSource初始化");
        HikariDataSource dataSource = new HikariDataSource();
        dataSource.setJdbcUrl(slaveProperties.getUrl());
        dataSource.setUsername(slaveProperties.getUsername());
        dataSource.setPassword(slaveProperties.getPassword());
        dataSource.setDriverClassName(slaveProperties.getDriverClassName());
        return dataSource;
    }
}

------动态数据源的切换

这里使用到的主要是Spring提供的AbstractRoutingDataSource,其提供了动态数据源的功能,可以帮助我们实现读写分离。其determineCurrentLookupKey()可以决定最终使用哪个数据源,这里我们自己创建了一个DynamicDataSourceHolder,来给他传一个数据源的类型(主、从)。
package cn.objectspace.springtestdemo.dao.split;

import java.util.HashMap;
import java.util.Map;

import javax.annotation.Resource;
import javax.sql.DataSource;

import org.springframework.jdbc.datasource.lookup.AbstractRoutingDataSource;
/**
 * 
* @Description: spring提供了AbstractRoutingDataSource,提供了动态选择数据源的功能,替换原有的单一数据源后,即可实现读写分离:
* @Author: Object
* @Date: 2019年11月30日
 */
public class DynamicDataSource extends AbstractRoutingDataSource{
    //注入主从数据源
    @Resource(name="masterDataSource")
    private DataSource masterDataSource;
    @Resource(name="slaveDataSource")
    private DataSource slaveDataSource;
    @Override
    public void afterPropertiesSet() {
        setDefaultTargetDataSource(masterDataSource);
        Map<Object, Object> dataSourceMap = new HashMap<>();
        //将两个数据源set入目标数据源
        dataSourceMap.put("master", masterDataSource);
        dataSourceMap.put("slave", slaveDataSource);
        setTargetDataSources(dataSourceMap);

        super.afterPropertiesSet();
    }
    @Override
    protected Object determineCurrentLookupKey() {
        //确定最终的目标数据源
        return DynamicDataSourceHolder.getDbType();
    }
}

------DynamicDataSourceHolder的实现

package cn.objectspace.springtestdemo.dao.split;

import org.slf4j.Logger;
import org.slf4j.LoggerFactory;

/**
* @Description: 获取DataSource
* @Author: Object
* @Date: 2019年11月30日
*/
public class DynamicDataSourceHolder {
    private static Logger logger = LoggerFactory.getLogger(DynamicDataSourceHolder.class);
    private static ThreadLocal<String> contextHolder = new ThreadLocal<>();
    public static final String DB_MASTER = "master";
    public static final String DB_SLAVE="slave";
    /**
     * @Description: 获取线程的DbType
     * @Param: args
     * @return: String
     * @Author: Object
     * @Date: 2019年11月30日
     */
    public static String getDbType() {
        String db = contextHolder.get();
        if(db==null) {
            db = "master";
        }
        return db;
    }
    /**
     * @Description: 设置线程的DbType
     * @Param: args
     * @return: void
     * @Author: Object
     * @Date: 2019年11月30日
     */
    public static void setDbType(String str) {
        logger.info("所使用的数据源为:"+str);
        contextHolder.set(str);
    }
    
    /**
     * @Description: 清理连接类型
     * @Param: args
     * @return: void
     * @Author: Object
     * @Date: 2019年11月30日
     */
    public static void clearDbType() {
        contextHolder.remove();
    }
}

------MyBatis拦截器的实现(***)

最后就是我们实现读写分离的核心了,这个类可以对SQL进行判断,是读SQL还是写SQL,从而进行数据源的选择,最终调用DynamicDataSourceHolder的setDbType方法,将数据源类型传入。

package cn.objectspace.springtestdemo.dao.split;

import java.util.Locale;
import java.util.Properties;

import org.apache.ibatis.executor.Executor;
import org.apache.ibatis.executor.keygen.SelectKeyGenerator;
import org.apache.ibatis.mapping.BoundSql;
import org.apache.ibatis.mapping.MappedStatement;
import org.apache.ibatis.mapping.SqlCommandType;
import org.apache.ibatis.plugin.Interceptor;
import org.apache.ibatis.plugin.Intercepts;
import org.apache.ibatis.plugin.Invocation;
import org.apache.ibatis.plugin.Plugin;
import org.apache.ibatis.plugin.Signature;
import org.apache.ibatis.session.ResultHandler;
import org.apache.ibatis.session.RowBounds;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.stereotype.Component;
import org.springframework.transaction.support.TransactionSynchronizationManager;

/**
 * @Description: MyBatis级别拦截器,根据SQL信息,选择不同的数据源
 * @Author: Object
 * @Date: 2019年11月30日
 */
@Intercepts({ 
    @Signature(type = Executor.class, method = "update", args = { MappedStatement.class, Object.class }),
    @Signature(type = Executor.class, method = "query", args = { MappedStatement.class, Object.class,RowBounds.class, ResultHandler.class })
    })
@Component
public class DynamicDataSourceInterceptor implements Interceptor {
    private Logger logger = LoggerFactory.getLogger(DynamicDataSourceInterceptor.class);
    // 验证是否为写SQL的正则表达式
    private static final String REGEX = ".*insert\\u0020.*|.*delete\\u0020.*|.*update\\u0020.*";

    /**
     * 主要的拦截方法
     */
    @Override
    public Object intercept(Invocation invocation) throws Throwable {
        // 判断当前是否被事务管理
        boolean synchronizationActive = TransactionSynchronizationManager.isActualTransactionActive();
        String lookupKey = DynamicDataSourceHolder.DB_MASTER;
        if (!synchronizationActive) {
            //如果是非事务的,则再判断是读或者写。
            // 获取SQL中的参数
            Object[] objects = invocation.getArgs();
            // object[0]会携带增删改查的信息,可以判断是读或者是写
            MappedStatement ms = (MappedStatement) objects[0];
            // 如果为读,且为自增id查询主键,则使用主库
            // 这种判断主要用于插入时返回ID的操作,由于日志同步到从库有延时
            // 所以如果插入时需要返回id,则不适用于到从库查询数据,有可能查询不到
            if (ms.getSqlCommandType().equals(SqlCommandType.SELECT)
                    && ms.getId().contains(SelectKeyGenerator.SELECT_KEY_SUFFIX)) {
                lookupKey = DynamicDataSourceHolder.DB_MASTER;
            } else {
                BoundSql boundSql = ms.getSqlSource().getBoundSql(objects[1]);
                String sql = boundSql.getSql().toLowerCase(Locale.CHINA).replaceAll("[\\t\\n\\r]", " ");
                // 正则验证
                if (sql.matches(REGEX)) {
                    // 如果是写语句
                    lookupKey = DynamicDataSourceHolder.DB_MASTER;
                } else {
                    lookupKey = DynamicDataSourceHolder.DB_SLAVE;
                }
            }
        } else {
            // 如果是通过事务管理的,一般都是写语句,直接通过主库
            lookupKey = DynamicDataSourceHolder.DB_MASTER;
        }

        logger.info("在" + lookupKey + "中进行操作");
        DynamicDataSourceHolder.setDbType(lookupKey);
        // 最后直接执行SQL
        return invocation.proceed();
    }

    /**
     * 返回封装好的对象,或代理对象
     */
    @Override
    public Object plugin(Object target) {
        // 如果存在增删改查,则直接拦截下来,否则直接返回
        if (target instanceof Executor)
            return Plugin.wrap(target, this);
        else
            return target;
    }

    /**
     * 类初始化的时候做一些相关的设置
     */
    @Override
    public void setProperties(Properties properties) {
        // TODO Auto-generated method stub

    }

}

------总结梳理

通过上文中的程序,我们已经可以实现读写分离了,但是这么看着还是挺乱的。所以在这里重新梳理一遍上文中的代码。

其实逻辑并不难:

  1. 通过@Configuration实现多数据源的配置。
  2. 通过MyBatis的拦截器,DynamicDataSourceInterceptor来判断某条SQL语句是读还是写,如果是读,则调用DynamicDataSourceHolder.setDbType("slave"),否则调用DynamicDataSourceHolder.setDbType("master")。
  3. 通过AbstractRoutingDataSource的determineCurrentLookupKey()方法,返回DynamicDataSourceHolder.getDbType();也就是我们在拦截器中设置的数据源。
  4. 对注入的数据源执行SQL。

AOP+Spring

spring动态数据源设置默认的master数据源和从数据源,aop针对读写的方法名来是读或者写,然后选择数据源去请求。

基于MyCat中间件实现读写分离、故障转移

MyCat配置文件

------server.xml

mycat安装目录下的/conf/server.xml文件,配置文件简化之后大概是这样的一个结构。

<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE mycat:server SYSTEM "server.dtd">
<mycat:server xmlns:mycat="http://io.mycat/">
    <system>
    </system>
    <user name="MyCat" defaultAccount="true">
    </user>

</mycat:server>

其实对于Server.xml,我们主要配置的就是下面的user模块,我们把它展开,着重讲讲这部分的配置。

<user name="这里写MyCat的用户名 可以自定义" defaultAccount="true">
   <property name="password">这里写MyCat的密码</property>
   <property name="schemas">这里配置MyCat的虚拟database</property>
            <!-- 表级 DML 权限设置 -->
            <!--
                这里是我们配置的mycat用户对某张表的权限配置,我们这里暂不配置但是还是说一下。下文中的0000 1111,每一位代表CRUD 1111就是有增删改查的权限,0000就                是没有这些权限。以此类推
            <privileges check="false">
                    <schema name="TESTDB" dml="0110" >
                            <table name="tb01" dml="0000"></table>
                            <table name="tb02" dml="1111"></table>
                    </schema>
            </privileges>
             -->
</user>
user代表MyCat的用户,我们在使用MySQL的时候都会有一个用户,MyCat作为一个虚拟节点,我们可以把它想象成它就是一个MySQL,所以自然而然它也需要有一个用户。但是他的用户并不是我们用命令创建的,而是直接在配置文件中配置好的,我们之后登录MyCat,就是用这里的用户名和密码进行登录。至于如何配置,我在上面的配置中都写好啦。跟着做就没有问题。

当然可以配置多个用户<user>,比如配置root用户有所有权限。普通用户只有读权限。

------schema.xml

myCat安装目录的conf/schema.xml,这个配置文件是我们需要关注的一个配置文件,因为我们的读写分离、分库分表、故障转移、都配置在这个配置文件中。

首先是标签中的内容。这个标签主要是为MyCat虚拟出一个数据库,我们连接到MyCat上能看到的数据库就是这里配置的,而分库分表也主要在这个标签中进行配置。这个标签中的name属性,就是为虚拟数据库指定一个名字,也是我们连接MyCat看到的数据库的库名,dataNode是和下文的dataNode标签中的name相对应的,代表这个虚拟的数据库和下面的dataNode进行绑定。
<schema name="MyCatDatabase" checkSQLschema="false" sqlMaxLimit="100" dataNode="这里写节点名,需要和dataNode中的name相对应">
    <!-- 分库分表 -->
        <!--<table name="travelrecord" dataNode="dn1,dn2,dn3" rule="auto-sharding-long" />-->
</schema>
第二个标签是标签,这个标签是和我们真实数据库中的database联系起来的,name属性是我们对这个dataNode自定义的一个名字,要注意的是,这个名字需要和schema标签中的dataNode内容一致,database属性写的是我们真实数据库中的真实database的名字。而dataHost的内容需要和之后标签中的name属性的值相对应。
<dataNode name="这里写节点名,需要和schema中的dataNode相对应" dataHost="这里也是一个自定义名字,需要和dataHost中的name相对应" database="这里填MySQL真实的数据库名" />

第三个标签要说的是标签,这个标签是和我们真实数据库的主从、读写分离联系起来的标签,什么意思呢。这个标签中有这么两个子标签和分别代表我们的写库读库,中配置的库可以用于读或者写,而中配置的库只能用于读。

可以看到schema.xml的配置是一环扣一环的,每个标签之间都有相互进行联系的属性。我们最后配置完的schema.xml应该长下面这个样子:

<?xml version="1.0"?>
<!DOCTYPE mycat:schema SYSTEM "schema.dtd">
<mycat:schema xmlns:mycat="http://io.mycat/">
    <schema name="这里写虚拟database名,需要和server.xml中的schema相对应" checkSQLschema="false" sqlMaxLimit="100" dataNode="这里写节点名,需要和dataNode中的name相对应">
    <!-- 分库分表 -->
        <!--<table name="travelrecord" dataNode="dn1,dn2,dn3" rule="auto-sharding-long" />-->
    </schema>
        <dataNode name="这里写节点名,需要和schema中的dataNode相对应" dataHost="这里也是一个自定义名字,需要和dataHost中的name相对应" database="这里填MySQL真实的数据库名" />
        <dataHost name="这里写和dataNode中的dataHost相同的名字" maxCon="1000" minCon="10" balance="1" writeType="0" dbType="mysql" dbDriver="native" switchType="1"  slaveThreshold="100">
            <!-- 心跳语句,证明myCat和mySQL是相互连接的状态-->
            <heartbeat>show slave status</heartbeat>
            <!-- 读写分离 -->
            <writeHost host="节点的名字,随便取" url="数据库的url(IP:PORT)" user="数据库中给MyCat创建的用户名" password="数据库中给MyCat创建的密码">
                <readHost host="节点的名字,随便取" url="数据库的url(IP:PORT)" user="数据库中给MyCat创建的用户名" password="数据库中给MyCat创建的密码">
                </readHost>
                <readHost host="节点的名字,随便取" url="数据库的url(IP:PORT)" user="数据库中给MyCat创建的用户名" password="数据库中给MyCat创建的密码">
                </readHost>
            </writeHost>
            <!-- 主从切换 -->
            <writeHost host="节点的名字,随便取" url="数据库的url(IP:PORT)" user="数据库中给MyCat创建的用户名" password="数据库中给MyCat创建的密码"></writeHost>
            <writeHost host="节点的名字,随便取" url="数据库的url(IP:PORT)" user="数据库中给MyCat创建的用户名" password="数据库中给MyCat创建的密码"></writeHost>
        </dataHost>
</mycat:schema>

------rules.xml

分库分表相关。

配置故障转移

假如我们的Master数据库突然宕机了,那么是否整个集群就丧失了写功能呢

在没有故障转移之前,这个答案是肯定的,当主库宕机时,从库作为读库,是不会有写的功能的,整个集群也就丧失了写的功能,这是我们不希望看到的。

我们希望看到的场景是:当主库宕机,某一个从库自动变为主库,承担写的功能,保证整个集群的可用性

那么我们开始进行配置,其实思路很简单,MyCat的标签中有一个switchType属性,其决定了切换的条件。

switchType指的是切换的模式,目前的取值也有4种:


switchType=‘-1‘ 表示不自动切换


switchType=‘1‘ 默认值,表示自动切换


switchType=‘2‘ 基于MySQL主从同步的状态决定是否切换,心跳语句为 show slave status


switchType=‘3‘基于MySQL galary cluster的切换机制(适合集群)(1.4.1),心跳语句为 show status like ‘wsrep%‘。

我们直接将switchType修改为2,然后将两个读库配置为第一个写库同级的写库。

<?xml version="1.0"?>
<!DOCTYPE mycat:schema SYSTEM "schema.dtd">
<mycat:schema xmlns:mycat="http://io.mycat/">
    <schema name="MyCat" checkSQLschema="false" sqlMaxLimit="100" dataNode="mycatdb">
    </schema>
    <dataNode name="mycatdb" dataHost="mycluster" database="testcluster" />
    <dataHost name="mycluster" maxCon="1000" minCon="10" balance="1"
                      writeType="0" dbType="mysql" dbDriver="native" switchType="2"  slaveThreshold="100">
            <heartbeat>show slave status</heartbeat>
            <!-- 读写分离 -->
            <writeHost host="Master201" url="192.168.43.201:3306" user="MyCat" password="123456">
                <readHost host="Slave202" url="192.168.43.202:3306" user="MyCat" password="123456">
                </readHost>
                <readHost host="Slave203" url="192.168.43.203:3306" user="MyCat" password="123456">
                </readHost>
            </writeHost>
            <!-- 主从切换 -->
            <writeHost host="Slave202" url="192.168.43.202:3306" user="MyCat" password="123456"></writeHost>
            <writeHost host="Slave203" url="192.168.43.203:3306" user="MyCat" password="123456"></writeHost>
        </dataHost>
</mycat:schema>

此时停掉master库即可测试。但是此时我们MySQL的主从架构已经被破坏,如果需要恢复主从结构,就需要手动地重新去恢复我们的主从架构。我们需要将201和203作为Slave,202作为Master,因为Master拥有最完整的数据。

两者的优劣分析

代码层实现读写分离,主要的优点就是灵活,可以自己根据不同的需求对读写分离的规则进行定制化开发,但其缺点也十分明显,就是当我们动态增减主从库数量的时候,都需要对代码进行一个或多或少的修改。并且当主库宕机了,如果我们没有实现相应的容灾逻辑,那么整个数据库集群将丧失对外的写功能。

使用MyCat中间件实现读写分离,优点十分明显,我们只需要进行配置就可以享受读写分离带来的效率的提升,不用写一行代码,并且当主库宕机时,我们还可以通过配置的方式进行主从库的自动切换,这样即使主库宕机我们的整个集群也不会丧失写的功能。其缺点可能就是我们得多付出一台服务器作为虚拟节点了吧,毕竟服务器也是需要成本的。

两种方式如何抉择:如果你目前的项目比较小,或者干脆是一个毕业设计、课程设计之类的,不会有动态增减数据库的需求,那么自己动手实现一个数据库的读写分离会比较适合你,毕竟答辩的时候,可以一行一行代码跟你的导师和同学解(zhuang)释(bi)。如果项目比较大了,数据库节点有可能进行增减,并且需要主从切换之类的功能,那么就使用第二种方式吧。这种配置化的实现可以降低第二天洗头时候下水管堵塞的几率。