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; } }
------动态数据源的切换
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 } }
------总结梳理
通过上文中的程序,我们已经可以实现读写分离了,但是这么看着还是挺乱的。所以在这里重新梳理一遍上文中的代码。
其实逻辑并不难:
- 通过@Configuration实现多数据源的配置。
- 通过MyBatis的拦截器,DynamicDataSourceInterceptor来判断某条SQL语句是读还是写,如果是读,则调用DynamicDataSourceHolder.setDbType("slave"),否则调用DynamicDataSourceHolder.setDbType("master")。
- 通过AbstractRoutingDataSource的determineCurrentLookupKey()方法,返回DynamicDataSourceHolder.getDbType();也就是我们在拦截器中设置的数据源。
- 对注入的数据源执行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>,比如配置root用户有所有权限。普通用户只有读权限。
------schema.xml
myCat安装目录的conf/schema.xml,这个配置文件是我们需要关注的一个配置文件,因为我们的读写分离、分库分表、故障转移、都配置在这个配置文件中。
<schema name="MyCatDatabase" 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真实的数据库名" />
第三个标签要说的是标签,这个标签是和我们真实数据库的主从、读写分离联系起来的标签,什么意思呢。这个标签中有这么两个子标签和分别代表我们的写库和读库,中配置的库可以用于读或者写,而中配置的库只能用于读。
可以看到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)。如果项目比较大了,数据库节点有可能进行增减,并且需要主从切换之类的功能,那么就使用第二种方式吧。这种配置化的实现可以降低第二天洗头时候下水管堵塞的几率。