手写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: 10001
spring:
datasource:
url: jdbc:mysql://106.54.54.200:3306/springtestdemo?useUnicode=true&characterEncoding=UTF-8&serverTimezone=UTC
username: username
password: password
com.mysql.cj.jdbc.Driver :
#MyBatis配置
mybatis:
classpath:mapper/*.xml :
configuration:
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: 10001
spring:
datasource:
master:
url: jdbc:mysql://106.54.54.200:3306/springtestdemo?useUnicode=true&characterEncoding=UTF-8&serverTimezone=UTC
username: username
password: password
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:
classpath:mapper/*.xml :
configuration:
true :
编写配置实体类
- MasterProperpties
package com.cnddcm.DBMasterAndSlave.config;
import org.springframework.boot.context.properties.ConfigurationProperties;
import org.springframework.stereotype.Component;
"spring.datasource.master") (prefix =
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;
"spring.datasource.slave") (prefix =
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{
//注入主从数据源
"masterDataSource") (name=
private DataSource masterDataSource;
"slaveDataSource") (name=
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);
// 最后直接执行SQL
return invocation.proceed();
}
/**
* 返回封装好的对象,或代理对象
*/
public Object plugin(Object target) {
// 如果存在增删改查,则直接拦截下来,否则直接返回
if (target instanceof Executor)
return Plugin.wrap(target, this);
else
return 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实现读写分离,对两种方式进行比较,搭建高可用的数据库集群。
球分享
球点赞
球在看