vlambda博客
学习文章列表

手写MySQL读写分离

什么是读写分离

    有一些技术同学可能对于“读写分离”了解不多,认为数据库的负载问题都可以使用“读写分离”来解决。

    这其实是一个非常大的误区,我们要用“读写分离”,首先应该明白“读写分离”是用来解决什么样的问题的,而不是仅仅会用这个技术。

什么是读写分离?

    其实就是将数据库分为了主从库,一个主库用于写数据,多个从库完成读数据的操作,主从库之间通过某种机制进行数据的同步,是一种常见的数据库架构。

数据库分组架构解决什么问题?

    大多数互联网业务,往往读多写少,这时候,数据库的读会首先称为数据库的瓶颈,这时,如果我们希望能够线性的提升数据库的读性能,消除读写锁冲突从而提升数据库的写性能,那么就可以使用“分组架构”(读写分离架构)。

    用一句话概括,读写分离是用来解决数据库的读性能瓶颈的。

    但是,不是任何读性能瓶颈都需要使用读写分离,我们还可以有其他解决方案。

    在互联网的应用场景中,常常数据量大、并发量高、高可用要求高、一致性要求高,如果使用“读写分离”,就需要注意这些问题:

    - 数据库连接池要进行区分,哪些是读连接池,哪个是写连接池,研发的难度会增加;

    - 为了保证高可用,读连接池要能够实现故障自动转移;

    - 主从的一致性问题需要考虑。

    在这么多的问题需要考虑的情况下,如果我们仅仅是为了解决“数据库读的瓶颈问题”,为什么不选择使用缓存呢?

为什么不选择使用缓存呢?

    缓存,也是互联网中常常使用到的一种架构方式,同“读写分离”不同,读写分离是通过多个读库,分摊了数据库读的压力,而存储则是通过缓存的使用,减少了数据库读的压力。他们没有谁替代谁的说法,但是,如果在缓存的读写分离进行二选一时,还是应该首先考虑缓存。

为什么呢?

    缓存的使用成本要比从库少非常多;缓存的开发比较容易,大部分的读操作都可以先去缓存,找不到的再渗透到数据库。当然,如果我们已经运用了缓存,但是读依旧还是瓶颈时,就可以选择“读写分离”架构了。简单来说,我们可以将读写分离看做是缓存都解决不了时的一种解决方案。

当然,缓存也不是没有缺点的

    对于缓存,我们必须要考虑的就是高可用,不然,如果缓存一旦挂了,所有的流量都同时聚集到了数据库上,那么数据库是肯定会挂掉的。

手写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));



基础环境搭建


- 导入依赖

<?xml version="1.0" encoding="UTF-8"?><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=UTC username: username password: password driver-class-name: com.mysql.cj.jdbc.Driver
#MyBatis配置mybatis: mapper-locations: classpath:mapper/*.xml configuration: map-underscore-to-camel-case: 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;@Mapperpublic interface StudentDao { @Insert("INSERT INTO student(student_id,student_name)VALUES(#{studentId},#{studentName})") public Integer insertStudent(Student student); @Select("SELECT * FROM student WHERE student_id = #{studentId}") public Student queryStudentByStudentId(Student student);}

@RestControllerpublic class DaoTest {
@Autowired StudentDao studentDao; @GetMapping("/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=UTC username: username password: password driver-class-name: com.mysql.cj.jdbc.Driver slave: url: jdbc:mysql://49.235.59.171:3306/springtestdemo?useUnicode=true&characterEncoding=UTF-8&serverTimezone=UTC username: username password: password driver-class-name: com.mysql.cj.jdbc.Driver#MyBatis配置mybatis: mapper-locations: classpath:mapper/*.xml configuration: map-underscore-to-camel-case: true



编写配置实体类


-  MasterProperpties

package com.cnddcm.DBMasterAndSlave.config;
import org.springframework.boot.context.properties.ConfigurationProperties;import org.springframework.stereotype.Component;
@ConfigurationProperties(prefix = "spring.datasource.master")@Componentpublic 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;
@ConfigurationProperties(prefix = "spring.datasource.slave")@Componentpublic 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


@Configurationpublic class DataSourceConfig { private Logger logger = LoggerFactory.getLogger(DataSourceConfig.class);
@Autowired private MasterProperpties 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,来给他传一个数据源的类型(主、从)。

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(); }}
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方法,将数据源类型传入

@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 }) })@Componentpublic 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。



运行结果



总结

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

    数据库的读写分离也有商业用的中间件实现方式,我会在之后的一节,使用MyCat实现读写分离,对两种方式进行比较,搭建高可用的数据库集群。

球分享

球点赞

球在看