什么是数据库的读写分离

Posted

tags:

篇首语:本文由小常识网(cha138.com)小编为大家整理,主要介绍了什么是数据库的读写分离相关的知识,希望对你有一定的参考价值。

数据库复制被用来把事务性查询导致的变更同步到集群中的从数据库。对于大访问量的网站,一般会采用读写分离,比如ebay的读写比率是260:1,也就是大型的电子商务网站的。网上看到说采用读写分离有如下工具:1,oracle的logical standby2, Quest公司的SharePlex3, DSG公司的RealSyncmysqlReplication可以将master的数据复制分布到多个slave上,然后可以利用slave来分担master的读压力。那么对于前台应用来说,就要考虑如何将读的压力分布到多个slave上。如果每个应用都需要来实现读写分离的算法,一则成本太高,二来如果slave增加更多的机器,应用就要随之修改。明显的,如果在应用和数据库间加一个专门用于实现读写分离的中间层,则整个系统的架构拥有更好的扩展性。MySQLProxy就是这么一个中间层代理,简单的说,MySQLProxy就是一个连接池,负责将前台应用的连接请求转发给后台的数据库,并且通过使用lua脚本,可以实现复杂的连接控制和过滤,从而实现读写分离和负载平衡。对于应用来说,MySQLProxy是完全透明的,应用则只需要连接到MySQLProxy的监听端口即可。 参考技术A amoeba 跟 mysql proxy在读写分离的使用上面的区别。
在mysql proxy 上面如果想要读写分离并且 读集群、写集群 机器比较多情况下,用mysql proxy 需要相当大的工作量,目前mysql proxy没有现成的 lua脚本。mysql proxy根本没有配置文件,lua脚本就是它的全部,当然lua是相当方便的。那么同样这种东西需要编写大量的脚本才能完成一个复杂的配置。amoeba目标是走产品化这条路。只需要进行相关的配置就可以满足需求。一、Master/Slave 结构读写分离:
Master: server1 (可读写)
slaves:server2、server3、server4(3个平等的数据库。只读/负载均衡)
amoeba提供读写分离pool相关配置。并且提供负载均衡配置。
可配置server2、server3、server4形成一个虚拟的 virtualSlave,该配置提供负载均衡、failOver、故障恢复功能Xml代码
<dbServer name="virtualSlave" virtual="true">
<poolConfig>
<className>com.meidusa.amoeba.server.MultipleServerPool</className>
<!-- 负载均衡参数 1=ROUNDROBIN , 2=WEIGHTBASED -->
<property name="loadbalance">1</property>
<!-- 参与该pool负载均衡的poolName列表以逗号分割 -->
<property name="poolNames">server2,server3,server4</property>
</poolConfig>
</dbServer>
如果不启用数据切分,那么只需要配置QueryRouter属性
wirtePool=server1
readPool=virtualSlave
<queryRouter>
<className>com.meidusa.amoeba.mysql.parser.MysqlQueryRouter</className>
<property name="LRUMapSize">1500</property>
<property name="defaultPool">server1</property>
<property name="writePool">server1</property>
<property name="readPool">virtualSlave</property>
<property name="needParse">true</property>
参考技术B   读写分离为了确保数据库产品的稳定性,很多数据库拥有双机热备功能。也就是,第一台数据库服务器,是对外提供增删改业务的生产服务器;第二台数据库服务器,主要进行读的操作。·
  原理:
  让主数据库(master)处理事务性增、改、删操作(INSERT、UPDATE、DELETE),而从数据库(slave)处理SELECT查询操作。
  实现方式:
  通过RAID技术,RAID是英文Redundant Array of Independent Disks的缩写,翻译成中文意思是“独立磁盘冗余阵列”,有时也简称磁盘阵列(Disk Array)。
  简单的说,RAID是一种把多块独立的硬盘(物理硬盘)按不同的方式组合起来形成一个硬盘组(逻辑硬盘),从而提供比单个硬盘更高的存储性能和提供数据备份技术。

手写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实现读写分离,对两种方式进行比较,搭建高可用的数据库集群。

球分享

球点赞

球在看


以上是关于什么是数据库的读写分离的主要内容,如果未能解决你的问题,请参考以下文章

想用数据库“读写分离” 请先明白“读写分离”解决什么问题

MySQL 主从复制与读写分离 部署

玩转数据库 “读写分离”

什么是数据库读写分离?

手写MySQL读写分离

想用数据库“读写分离” 请先明白“读写分离”解决什么问题