手写MySQL读写分离
什么是读写分离
有一些技术同学可能对于“读写分离”了解不多,认为数据库的负载问题都可以使用“读写分离”来解决。
这其实是一个非常大的误区,我们要用“读写分离”,首先应该明白“读写分离”是用来解决什么样的问题的,而不是仅仅会用这个技术。
什么是读写分离?
其实就是将数据库分为了主从库,一个主库用于写数据,多个从库完成读数据的操作,主从库之间通过某种机制进行数据的同步,是一种常见的数据库架构。
数据库分组架构解决什么问题?
大多数互联网业务,往往读多写少,这时候,数据库的读会首先称为数据库的瓶颈,这时,如果我们希望能够线性的提升数据库的读性能,消除读写锁冲突从而提升数据库的写性能,那么就可以使用“分组架构”(读写分离架构)。
用一句话概括,读写分离是用来解决数据库的读性能瓶颈的。
但是,不是任何读性能瓶颈都需要使用读写分离,我们还可以有其他解决方案。
在互联网的应用场景中,常常数据量大、并发量高、高可用要求高、一致性要求高,如果使用“读写分离”,就需要注意这些问题:
- 数据库连接池要进行区分,哪些是读连接池,哪个是写连接池,研发的难度会增加;
- 为了保证高可用,读连接池要能够实现故障自动转移;
- 主从的一致性问题需要考虑。
在这么多的问题需要考虑的情况下,如果我们仅仅是为了解决“数据库读的瓶颈问题”,为什么不选择使用缓存呢?
为什么不选择使用缓存呢?
缓存,也是互联网中常常使用到的一种架构方式,同“读写分离”不同,读写分离是通过多个读库,分摊了数据库读的压力,而存储则是通过缓存的使用,减少了数据库读的压力。他们没有谁替代谁的说法,但是,如果在缓存的读写分离进行二选一时,还是应该首先考虑缓存。
为什么呢?
缓存的使用成本要比从库少非常多;缓存的开发比较容易,大部分的读操作都可以先去缓存,找不到的再渗透到数据库。当然,如果我们已经运用了缓存,但是读依旧还是瓶颈时,就可以选择“读写分离”架构了。简单来说,我们可以将读写分离看做是缓存都解决不了时的一种解决方案。
当然,缓存也不是没有缺点的
对于缓存,我们必须要考虑的就是高可用,不然,如果缓存一旦挂了,所有的流量都同时聚集到了数据库上,那么数据库是肯定会挂掉的。
读写分离的实现
环境列表
- 106.54.54.200:3306 Master
- 49.235.59.171:3306 Slaves
开发环境
- IDE:IDEA 2020.01
- Spring Boot - 2.3.1.RELEASE
- MySQL 5.7
- CentOS 7
开发前准备
- 在主库和从库中分别新建测试数据库springtestdemo
- 在数据库中新建测试表
CREATE TABLE student(student_id VARCHAR(32),student_name VARCHAR(32));
基础环境搭建
- 导入依赖
<project xmlns="http://maven.apache.org/POM/4.0.0" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 https://maven.apache.org/xsd/maven-4.0.0.xsd"><modelVersion>4.0.0</modelVersion><parent><groupId>org.springframework.boot</groupId><artifactId>spring-boot-starter-parent</artifactId><version>2.3.1.RELEASE</version><relativePath/> <!-- lookup parent from repository --></parent><groupId>com.cnddcm</groupId><artifactId>DBMasterAndSlave</artifactId><version>0.0.1-SNAPSHOT</version><name>DBMasterAndSlave</name><description>mysql主从模式与读写分离</description><properties><java.version>1.8</java.version></properties><dependencies><dependency><groupId>org.springframework.boot</groupId><artifactId>spring-boot-starter</artifactId></dependency><!-- Web相关 --><dependency><groupId>org.springframework.boot</groupId><artifactId>spring-boot-starter-web</artifactId></dependency><!-- --><dependency><groupId>org.springframework.boot</groupId><artifactId>spring-boot-configuration-processor</artifactId><optional>true</optional></dependency><dependency><groupId>org.mybatis.spring.boot</groupId><artifactId>mybatis-spring-boot-starter</artifactId><version>2.0.0</version></dependency><dependency><groupId>mysql</groupId><artifactId>mysql-connector-java</artifactId></dependency><dependency><groupId>org.springframework.boot</groupId><artifactId>spring-boot-starter-test</artifactId><scope>test</scope><exclusions><exclusion><groupId>org.junit.vintage</groupId><artifactId>junit-vintage-engine</artifactId></exclusion></exclusions></dependency><dependency><groupId>junit</groupId><artifactId>junit</artifactId><scope>test</scope></dependency></dependencies><build><plugins><plugin><groupId>org.springframework.boot</groupId><artifactId>spring-boot-maven-plugin</artifactId></plugin></plugins></build></project>
- 编写yml配置
server:port: 10001spring:datasource:url: jdbc:mysql://106.54.54.200:3306/springtestdemo?useUnicode=true&characterEncoding=UTF-8&serverTimezone=UTCusername: usernamepassword: password: com.mysql.cj.jdbc.Driver#MyBatis配置mybatis:: classpath:mapper/*.xmlconfiguration:: true
新建实体类
package com.cnddcm.DBMasterAndSlave.domain;public class Student {private String studentId;private String studentName;public String getStudentId() {return studentId;}public void setStudentId(String studentId) {this.studentId = studentId;}public String getStudentName() {return studentName;}public void setStudentName(String studentName) {this.studentName = studentName;}}
编写接口并写测试类
package com.cnddcm.DBMasterAndSlave.dao;import com.cnddcm.DBMasterAndSlave.domain.Student;import org.apache.ibatis.annotations.*;import org.apache.ibatis.annotations.Insert;import org.apache.ibatis.annotations.Select;public interface StudentDao {("INSERT INTO student(student_id,student_name)VALUES(#{studentId},#{studentName})")public Integer insertStudent(Student student);("SELECT * FROM student WHERE student_id = #{studentId}")public Student queryStudentByStudentId(Student student);}public class DaoTest {StudentDao studentDao;("/test")public void test01() {Student student = new Student();student.setStudentId("20200712");student.setStudentName("cnddcm");studentDao.insertStudent(student);studentDao.queryStudentByStudentId(student);}}
运行启动类,启动项目之后,如果在数据库中成功插入数据,则项目基础环境搭建完毕,接下来我们开始实现读写分离。
数据源配置
- 刚才我们的配置文件中只有单数据源,而读写分离肯定不会是单数据源,所以我们首先要在application.yml中配置多数据源。
server:port: 10001spring:datasource:master:url: jdbc:mysql://106.54.54.200:3306/springtestdemo?useUnicode=true&characterEncoding=UTF-8&serverTimezone=UTCusername: usernamepassword: password: com.mysql.cj.jdbc.Driverslave:url: jdbc:mysql://49.235.59.171:3306/springtestdemo?useUnicode=true&characterEncoding=UTF-8&serverTimezone=UTCusername: usernamepassword: password driver-class-name: com.mysql.cj.jdbc.Driver#MyBatis配置mybatis:: classpath:mapper/*.xmlconfiguration:: true
编写配置实体类
- MasterProperpties
package com.cnddcm.DBMasterAndSlave.config;import org.springframework.boot.context.properties.ConfigurationProperties;import org.springframework.stereotype.Component;(prefix = "spring.datasource.master")public class MasterProperpties {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
package com.cnddcm.DBMasterAndSlave.config;import org.springframework.boot.context.properties.ConfigurationProperties;import org.springframework.stereotype.Component;(prefix = "spring.datasource.slave")public class SlaveProperties {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;}}
- DataSourceConfig
public class DataSourceConfig {private Logger logger = LoggerFactory.getLogger(DataSourceConfig.class);private MasterProperpties masterProperties;private SlaveProperties slaveProperties;//默认是master数据源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;}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,来给他传一个数据源的类型(主、从)。
public class DynamicDataSource extends AbstractRoutingDataSource{//注入主从数据源(name="masterDataSource")private DataSource masterDataSource;(name="slaveDataSource")private DataSource slaveDataSource;public void afterPropertiesSet() {setDefaultTargetDataSource(masterDataSource);Map<Object, Object> dataSourceMap = new HashMap<>();//将两个数据源set入目标数据源dataSourceMap.put("master", masterDataSource);dataSourceMap.put("slave", slaveDataSource);setTargetDataSources(dataSourceMap);super.afterPropertiesSet();}protected Object determineCurrentLookupKey() {//确定最终的目标数据源return DynamicDataSourceHolder.getDbType();}}
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方法,将数据源类型传入
({(type = Executor.class, method = "update", args = { MappedStatement.class, Object.class }),(type = Executor.class, method = "query", args = { MappedStatement.class, Object.class,RowBounds.class, ResultHandler.class })})public class DynamicDataSourceInterceptor implements Interceptor {private Logger logger = LoggerFactory.getLogger(DynamicDataSourceInterceptor.class);// 验证是否为写SQL的正则表达式private static final String REGEX = ".*insert\\u0020.*|.*delete\\u0020.*|.*update\\u0020.*";/*** 主要的拦截方法*/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);// 最后直接执行SQLreturn invocation.proceed();}/*** 返回封装好的对象,或代理对象*/public Object plugin(Object target) {// 如果存在增删改查,则直接拦截下来,否则直接返回if (target instanceof Executor)return Plugin.wrap(target, this);elsereturn target;}/*** 类初始化的时候做一些相关的设置*/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。
运行结果
总结
至此,我们就通过手动编码的形式,实现了mysql的读写分离,主要的优点就是灵活,可以自己根据不同的需求对读写分离的规则进行定制化开发,但其缺点也十分明显,就是当我们动态增减主从库数量的时候,都需要对代码进行一个或多或少的修改。并且当主库宕机了,如果我们没有实现相应的容灾逻辑,那么整个数据库集群将丧失对外的写功能。
数据库的读写分离也有商业用的中间件实现方式,我会在之后的一节,使用MyCat实现读写分离,对两种方式进行比较,搭建高可用的数据库集群。
球分享
球点赞
球在看
