757 lines
33 KiB
Markdown
757 lines
33 KiB
Markdown
|
# 15 | 流量隔离:MySQL数据库隔离是怎么做的?
|
|||
|
|
|||
|
你好,我是高楼。
|
|||
|
|
|||
|
这节课,我们详细讲讲怎样基于微服务技术进行 MySQL 数据库隔离的落地。
|
|||
|
|
|||
|
对线上压测来说,最重要的环节就是**流量隔离**了,而这其中最核心的又是**数据库隔离**,只有识别并隔离正式流量与压测流量才能避免产生**脏数据**。
|
|||
|
|
|||
|
## 为什么要做数据库隔离?
|
|||
|
|
|||
|
**为什么做数据库隔离呢?**我给你举个例子。记得几年前,我当时所在的公司做线上压测。当时团队并没有做数据库隔离,而是直接在线上生产库进行压测,导致产生了很多脏数据。结果花费了很长时间去清理数据,但最终也没有完全清理干净,它不仅污染了生产数据还对线上业务产生了不好的影响,影响了用户体验。**所以说做线上压测,数据库隔离是必须做的一个环节**。
|
|||
|
|
|||
|
当然了,如果你的公司不差钱,也可以直接隔离出一套线下1:1镜像环境做压测。但是大部分公司并没有这么“土豪”,还得使用目前的生产坏境做压测,那么数据库隔离技术就必不可少了。
|
|||
|
|
|||
|
首先,我梳理了一下目前业界对于数据库隔离的主要解决方案,以及它们的优缺点和适用场景。
|
|||
|
|
|||
|
![图片](https://static001.geekbang.org/resource/image/e9/dd/e96da5730379c3c94215f327069674dd.jpg?wh=1920x1080)
|
|||
|
|
|||
|
我们可以看到,根据不同的项目情况,可以选择不同的技术方案,**这里最优、最安全的方案当然首推影子库**,具体的优缺点上面表格已经写得非常清楚了。
|
|||
|
|
|||
|
其实,上面这三种数据库隔离技术现在都已经很成熟了,要做到并不太难。但是出于各方面的原因,我们在网上很少看到过完整的技术解决方案,而有些公司把它们视作“独门秘方”,所以数据库隔离还远远谈不上普及。不过随着市场的发展,数据库隔离的面纱迟早会缓缓揭开。
|
|||
|
|
|||
|
对于我们这个电商项目,我选择采用影子库。因为数据偏移缺点很明显,不值得选择,而影子表代码修改量大,风险大不好控制。影子库呢,风险小,改动量小。综合权衡后,我们认为采用影子库性价比更高。
|
|||
|
|
|||
|
在进行影子库的改造之前,让我们先来看下目前项目的系统链路图。心中有链路图,才能知道在哪些地方改造。
|
|||
|
|
|||
|
![图片](https://static001.geekbang.org/resource/image/76/67/7619f4a70f6f8ee62dfa8fd43711dc67.png?wh=1920x826)
|
|||
|
|
|||
|
上面是简单的链路图,你可以很清楚地知道,哪些服务与 MySQL 数据库有关系。为了方便你更直观地理解,我给你画了个思维导图。
|
|||
|
|
|||
|
![图片](https://static001.geekbang.org/resource/image/96/6c/964818d5c9363fb9eab7d26fc40f0b6c.jpg?wh=1876x1688)
|
|||
|
|
|||
|
有了链路图做指导,接下来就是修改各个模块,进行具体的代码改造工作了。为了减少风险,我们可以先写一个 demo 做分库实例演示。如果 demo 没问题,再修改一个线上实例做实验。验证完毕并权衡风险后,再同步其他服务,只有这样反复验证才能把改造风险降到最低。
|
|||
|
|
|||
|
## 影子库技术预演
|
|||
|
|
|||
|
在做影子库改造之前,需要调研可以用什么技术做,心中有思路才好下手实践。
|
|||
|
|
|||
|
### 准备工作
|
|||
|
|
|||
|
首先,打开开发工具(如 Idea)新建 SpringBoot 工程(说明:这里用什么工具与什么工程无所谓,只要达到分库的目的就行)。既然是分库工作,就需要在 MySQL 数据库中新建两个数据库,如下图:
|
|||
|
|
|||
|
![图片](https://static001.geekbang.org/resource/image/8d/e8/8d3f9d1e7506333533d673c52de7f3e8.png?wh=1722x534)
|
|||
|
|
|||
|
新建数据库参考语句如下:
|
|||
|
|
|||
|
```sql
|
|||
|
--正式库
|
|||
|
CREATE SCHEMA `mall_master` DEFAULT CHARACTER SET utf8mb4 ;
|
|||
|
--影子库
|
|||
|
CREATE SCHEMA `mall_shadow` DEFAULT CHARACTER SET utf8mb4 ;
|
|||
|
|
|||
|
```
|
|||
|
|
|||
|
新建成功后如下图:
|
|||
|
![图片](https://static001.geekbang.org/resource/image/fa/3d/faafe66d254a94a18936867c97d92a3d.png?wh=590x152)
|
|||
|
|
|||
|
到这里,数据库就建完了,但是光有库没有表是不行的,所以需要在两个新的数据库中新建一样的表,参考语句如下:
|
|||
|
|
|||
|
```sql
|
|||
|
DROP TABLE IF EXISTS `ums_admin`;
|
|||
|
|
|||
|
CREATE TABLE `ums_admin` (
|
|||
|
`id` bigint(20) NOT NULL AUTO_INCREMENT,
|
|||
|
`user_name` varchar(64) DEFAULT NULL COMMENT '用户名',
|
|||
|
`pass_word` varchar(64) DEFAULT NULL COMMENT '密码',
|
|||
|
`icon` varchar(500) DEFAULT NULL COMMENT ' Header 像',
|
|||
|
`email` varchar(100) DEFAULT NULL COMMENT '邮箱',
|
|||
|
`nick_name` varchar(200) DEFAULT NULL COMMENT '昵称',
|
|||
|
`note` varchar(500) DEFAULT NULL COMMENT '备注信息',
|
|||
|
`create_time` datetime DEFAULT NULL COMMENT '创建时间',
|
|||
|
`login_time` datetime DEFAULT NULL COMMENT '最后登录时间',
|
|||
|
`status` int(1) DEFAULT '1' COMMENT '帐号启用状态:0->禁用;1->启用',
|
|||
|
PRIMARY KEY (`id`)
|
|||
|
) ENGINE=InnoDB AUTO_INCREMENT=1 DEFAULT CHARSET=utf8 COMMENT='管理员用户表';
|
|||
|
|
|||
|
```
|
|||
|
|
|||
|
打开项目,在 resources 目录下新建 application.yml文件,因为这次 demo 的目的是要使用两个数据源做数据库隔离,所以要配置两个数据源。你可以参考我给你提供的 application.yml 配置文件。
|
|||
|
|
|||
|
```yaml
|
|||
|
spring:
|
|||
|
datasource:
|
|||
|
master: # 数据源1
|
|||
|
url: jdbc:mysql://localhost:3306/mall_master?characterEncoding=utf8&useUnicode=true&useSSL=false&serverTimezone=GMT%2B8
|
|||
|
username: root
|
|||
|
password: lw123root
|
|||
|
driver-class-name: com.mysql.cj.jdbc.Driver
|
|||
|
type: com.alibaba.druid.pool.DruidDataSource
|
|||
|
druid:
|
|||
|
initialSize: 5
|
|||
|
minIdle: 5
|
|||
|
maxActive: 20
|
|||
|
maxWait: 60000
|
|||
|
timeBetweenEvictionRunsMillis: 60000
|
|||
|
minEvictableIdleTimeMillis: 300000
|
|||
|
validationQuery: SELECT 1 FROM DUAL
|
|||
|
testWhileIdle: true
|
|||
|
testOnBorrow: false
|
|||
|
testOnReturn: false
|
|||
|
poolPreparedStatements: true
|
|||
|
# 配置监控统计拦截的filters,去掉后监控界面sql无法统计,'wall'用于防火墙
|
|||
|
filters: stat,wall,log4j
|
|||
|
maxPoolPreparedStatementPerConnectionSize: 20
|
|||
|
useGlobalDataSourceStat: true
|
|||
|
connectionProperties: druid.stat.mergeSql=true;druid.stat.slowSqlMillis=500
|
|||
|
shadow: # 数据源2
|
|||
|
url: jdbc:mysql://localhost:3306/mall_shadow?characterEncoding=utf8&useUnicode=true&useSSL=false&serverTimezone=GMT%2B8
|
|||
|
username: root
|
|||
|
password: lw123root
|
|||
|
driver-class-name: com.mysql.cj.jdbc.Driver
|
|||
|
type: com.alibaba.druid.pool.DruidDataSource
|
|||
|
druid:
|
|||
|
initialSize: 5
|
|||
|
minIdle: 5
|
|||
|
maxActive: 20
|
|||
|
maxWait: 60000
|
|||
|
timeBetweenEvictionRunsMillis: 60000
|
|||
|
minEvictableIdleTimeMillis: 300000
|
|||
|
validationQuery: SELECT 1 FROM DUAL
|
|||
|
testWhileIdle: true
|
|||
|
testOnBorrow: false
|
|||
|
testOnReturn: false
|
|||
|
poolPreparedStatements: true
|
|||
|
# 配置监控统计拦截的filters,去掉后监控界面sql无法统计,'wall'用于防火墙
|
|||
|
filters: stat,wall,log4j
|
|||
|
maxPoolPreparedStatementPerConnectionSize: 20
|
|||
|
useGlobalDataSourceStat: true
|
|||
|
connectionProperties: druid.stat.mergeSql=true;druid.stat.slowSqlMillis=500
|
|||
|
|
|||
|
```
|
|||
|
|
|||
|
有了数据源配置文件,还需要读取配置文件的配置类。如果是单库实例数据源,用下面的代码读取数据源配置,项目就可以直接操作数据库了。
|
|||
|
|
|||
|
```java
|
|||
|
@ConfigurationProperties(prefix = "spring.datasource")
|
|||
|
@Bean
|
|||
|
public DataSource druid() {
|
|||
|
return new DruidDataSource();
|
|||
|
}
|
|||
|
|
|||
|
```
|
|||
|
|
|||
|
但是如果是多库数据源,就不能按这个方式读取数据源了。那么应该怎么读取数据源呢?我们来仔细看一下下面的代码。
|
|||
|
|
|||
|
SpringBoot 在读取配置文件时,大量采用 @ConfigurationProperties 注解,从代码中可以得知,单个读取实例采用这一注解就可以读取到数据配置文件。
|
|||
|
|
|||
|
我们还可以通过查看源码知道,该注解带了一个 “prefix ”属性,可以使用该属性读取数据源。另外,为了区分数据源,可以给不同读取数据源取不同的方法名字。
|
|||
|
|
|||
|
![图片](https://static001.geekbang.org/resource/image/63/ac/63d5c6e03485a782195925e5f7bf51ac.png?wh=708x402)
|
|||
|
|
|||
|
你可以参考我的代码。
|
|||
|
|
|||
|
```java
|
|||
|
package com.dunshan.data.config;
|
|||
|
|
|||
|
import com.alibaba.druid.pool.DruidDataSource;
|
|||
|
import com.alibaba.druid.support.http.StatViewServlet;
|
|||
|
import com.alibaba.druid.support.http.WebStatFilter;
|
|||
|
import lombok.extern.log4j.Log4j2;
|
|||
|
import org.springframework.boot.context.properties.ConfigurationProperties;
|
|||
|
import org.springframework.boot.web.servlet.FilterRegistrationBean;
|
|||
|
import org.springframework.boot.web.servlet.ServletRegistrationBean;
|
|||
|
import org.springframework.context.annotation.Bean;
|
|||
|
import org.springframework.context.annotation.Configuration;
|
|||
|
import org.springframework.context.annotation.Primary;
|
|||
|
import javax.sql.DataSource;
|
|||
|
import java.util.HashMap;
|
|||
|
import java.util.Map;
|
|||
|
|
|||
|
/**
|
|||
|
* @author dunshan
|
|||
|
* @description: 数据库配置文件
|
|||
|
* @date 2021-08-15 11:01:31
|
|||
|
*/
|
|||
|
|
|||
|
@Log4j2
|
|||
|
@Configuration
|
|||
|
public class DynamicDataSourceConfig {
|
|||
|
|
|||
|
/**
|
|||
|
* 创建 shadow 数据源
|
|||
|
*/
|
|||
|
@Bean(name = "shadowDataSource")
|
|||
|
@ConfigurationProperties(prefix = "spring.datasource.shadow")
|
|||
|
public DataSource shadowDataSource() {
|
|||
|
return new DruidDataSource();
|
|||
|
}
|
|||
|
|
|||
|
/**
|
|||
|
* 创建 master 数据源
|
|||
|
*/
|
|||
|
@Bean(name = "masterDataSource")
|
|||
|
@ConfigurationProperties(prefix = "spring.datasource.master")
|
|||
|
public DataSource masterDataSource() {
|
|||
|
return new DruidDataSource();
|
|||
|
}
|
|||
|
|
|||
|
}
|
|||
|
|
|||
|
```
|
|||
|
|
|||
|
数据源配置读取成功后,还得去思考怎么把数据源注入到上下文中,只有成功注入到上下文中才能使用该数据,操作数据库中的表。但是注入成功后,要根据什么切换数据源呢?
|
|||
|
在切换数据源之前,我们先来回顾一下之前做的 demo。我们做 demo 是为了识别流量标记,那怎么产生流量标记呢?
|
|||
|
|
|||
|
我们只需要在 Http 请求中增加 Header 信息,业务层会根据 Header 信息判断特定标记来切换数据源,如 JMeter 中 HTTP Header Manager 就可以增加 Header 信息标记:
|
|||
|
|
|||
|
![图片](https://static001.geekbang.org/resource/image/59/a7/5932e537d1338b0a2c09010cfdbeb6a7.png?wh=1622x366)
|
|||
|
|
|||
|
### 数据源上下文的实现
|
|||
|
|
|||
|
在目前的 Spring 开发中,多多少少都会涉及 AOP(Aspect-Oriented Programming:面向切面编程),这里只用其中一个特性,大家慢慢往下看就知道了。
|
|||
|
|
|||
|
另外,在切换数据源中还需要使用一个技术就是 ThreadLocal。但是因为 ThreadLocal 有点缺陷,所以我们这个场景使用 TransmittableThreadLocal 做数据源保存对象信息。这个在 [第13讲](https://time.geekbang.org/column/article/444794)已经讲得很清楚了,我就不多赘述了。
|
|||
|
|
|||
|
动态数据源 TransmittableThreadLocal 的代码实现,你可以看看我给出的例子。
|
|||
|
|
|||
|
```java
|
|||
|
package com.dunshan.data.config;
|
|||
|
|
|||
|
import com.alibaba.ttl.TransmittableThreadLocal;
|
|||
|
import org.springframework.jdbc.datasource.lookup.AbstractRoutingDataSource;
|
|||
|
import javax.sql.DataSource;
|
|||
|
import java.util.Map;
|
|||
|
|
|||
|
/**
|
|||
|
* @author dunshan
|
|||
|
* @description: 动态数据源切换
|
|||
|
* @date 2021-08-15 12:35:14
|
|||
|
*/
|
|||
|
public class DynamicDataSource extends AbstractRoutingDataSource {
|
|||
|
|
|||
|
private static final TransmittableThreadLocal<String> contextHolder = new TransmittableThreadLocal<>();
|
|||
|
|
|||
|
/**
|
|||
|
* 配置DataSource, defaultTargetDataSource为主数据库
|
|||
|
*/
|
|||
|
public DynamicDataSource(DataSource defaultTargetDataSource, Map<Object, Object> targetDataSources) {
|
|||
|
super.setDefaultTargetDataSource(defaultTargetDataSource);
|
|||
|
super.setTargetDataSources(targetDataSources);
|
|||
|
super.afterPropertiesSet();
|
|||
|
}
|
|||
|
|
|||
|
@Override
|
|||
|
protected Object determineCurrentLookupKey() {
|
|||
|
return getDataSource();
|
|||
|
}
|
|||
|
|
|||
|
public static void setDataSource(String dataSource) {
|
|||
|
contextHolder.set(dataSource);
|
|||
|
}
|
|||
|
|
|||
|
public static String getDataSource() {
|
|||
|
return contextHolder.get();
|
|||
|
}
|
|||
|
|
|||
|
public static void clearDataSource() {
|
|||
|
contextHolder.remove();
|
|||
|
}
|
|||
|
}
|
|||
|
|
|||
|
|
|||
|
```
|
|||
|
|
|||
|
接下来要把数据源注入上下文中,只有这样数据源才能在系统中灵活使用。
|
|||
|
|
|||
|
```java
|
|||
|
/**
|
|||
|
* 如果还有数据源,在这继续添加 DataSource Bean
|
|||
|
*/
|
|||
|
|
|||
|
@Bean
|
|||
|
@Primary
|
|||
|
public DynamicDataSource dataSource(DataSource masterDataSource, DataSource shadowDataSource) {
|
|||
|
Map<Object, Object> targetDataSources = new HashMap<>(2);
|
|||
|
targetDataSources.put(DataSourceNames.SHADOW, shadowDataSource);
|
|||
|
targetDataSources.put(DataSourceNames.MASTER, masterDataSource);
|
|||
|
// 还有数据源,在targetDataSources中继续添加
|
|||
|
log.info("DataSources:" + targetDataSources);
|
|||
|
return new DynamicDataSource(masterDataSource, targetDataSources);
|
|||
|
}
|
|||
|
|
|||
|
```
|
|||
|
|
|||
|
做好以上这些准备工作后,我们就可以开始实现具体的标记识别和数据库隔离动作了。
|
|||
|
|
|||
|
下面,我们主要做以下两种获取标记方案的技术预演:
|
|||
|
|
|||
|
* HttpRequest Header:从 HttpRequest Header 中获取标记,适合单服务、单一 HTTP 协议的场景;
|
|||
|
* 数据上下文:从数据上下文对象中获取标记,**这是更为推荐的微服务标记透传方案**。
|
|||
|
|
|||
|
### 第一种:HttpRequest Header 方案
|
|||
|
|
|||
|
主要逻辑如下图所示:
|
|||
|
|
|||
|
![图片](https://static001.geekbang.org/resource/image/3d/a9/3d52080337e01b054be1aa3eb821dca9.jpg?wh=617x877)
|
|||
|
|
|||
|
数据源注入上下文后,刚才提到的 AOP 就派上用场了。这里你需要先学习 AOP 切面编程,使用切面编程拦截并获取 Header 信息。再结合 TransmittableThreadLocal 的特性进行数据源切换。
|
|||
|
|
|||
|
```java
|
|||
|
package com.dunshan.data.config;
|
|||
|
|
|||
|
import lombok.extern.log4j.Log4j2;
|
|||
|
import org.aspectj.lang.JoinPoint;
|
|||
|
import org.aspectj.lang.annotation.Aspect;
|
|||
|
import org.aspectj.lang.annotation.Before;
|
|||
|
import org.aspectj.lang.annotation.Pointcut;
|
|||
|
import org.springframework.stereotype.Component;
|
|||
|
import org.springframework.web.context.request.RequestContextHolder;
|
|||
|
import org.springframework.web.context.request.ServletRequestAttributes;
|
|||
|
|
|||
|
import javax.servlet.http.HttpServletRequest;
|
|||
|
import java.util.Arrays;
|
|||
|
|
|||
|
/**
|
|||
|
* @author dunshan
|
|||
|
* @description: 数据源切换
|
|||
|
* @date 2021-08-15 12:46:32
|
|||
|
*/
|
|||
|
|
|||
|
@Log4j2
|
|||
|
@Aspect
|
|||
|
@Component
|
|||
|
public class DataSourceAspect {
|
|||
|
|
|||
|
|
|||
|
|
|||
|
/**
|
|||
|
* 切点: 所有配置 DataSource 注解的方法
|
|||
|
*/
|
|||
|
@Pointcut("execution(public * com.dunshan.data.controller..*.*(..))")
|
|||
|
public void controllerAspect() {
|
|||
|
}
|
|||
|
|
|||
|
// 请求method前打印内容
|
|||
|
@Before(value = "controllerAspect()")
|
|||
|
public void methodBefore(JoinPoint joinPoint) {
|
|||
|
ServletRequestAttributes requestAttributes = (ServletRequestAttributes) RequestContextHolder
|
|||
|
.getRequestAttributes();
|
|||
|
HttpServletRequest request = requestAttributes.getRequest();
|
|||
|
|
|||
|
//获取 Header 标记
|
|||
|
String header = request.getHeader("dunshan");
|
|||
|
|
|||
|
// 打印请求内容
|
|||
|
log.info("===============请求内容===============");
|
|||
|
log.info("请求地址:" + request.getRequestURL().toString());
|
|||
|
log.info("请求方式:" + request.getMethod());
|
|||
|
log.info("请求类方法:" + joinPoint.getSignature());
|
|||
|
log.info("请求类方法参数:" + Arrays.toString(joinPoint.getArgs()));
|
|||
|
log.info("请求类方法 Header 信息:" + header);
|
|||
|
|
|||
|
// 通过 Header 信息判断
|
|||
|
if (header != null && header.equals("7DGroup")) {
|
|||
|
DynamicDataSource.setDataSource(DataSourceNames.SHADOW);
|
|||
|
} else {
|
|||
|
DynamicDataSource.setDataSource(DataSourceNames.MASTER);
|
|||
|
}
|
|||
|
|
|||
|
}
|
|||
|
|
|||
|
```
|
|||
|
|
|||
|
我们可以看到,这里根据 Header 信息中包含的关键字“7DGroup”做流量判断。
|
|||
|
上面的技术预演工作已经包含了整个 demo 的关键部分,之后就要验证 demo 能不能运行成功了。如果能成功,就可以移植到目前的工程中去了。
|
|||
|
|
|||
|
* 结果验证
|
|||
|
|
|||
|
好了,我们这就来验证一下。
|
|||
|
|
|||
|
先验证数据源读取是否正常:启动工程,查看日志。
|
|||
|
|
|||
|
![图片](https://static001.geekbang.org/resource/image/20/ed/20e48d67b4ac08a19a2964635174c4ed.png?wh=1320x711)
|
|||
|
|
|||
|
观察启动信息,标示两个数据源已经读取成功。好,接下来就要验证操作能不能成功了。
|
|||
|
|
|||
|
* 正式请求(未加标记的请求)
|
|||
|
|
|||
|
上面的代码已经修改完成了,下面我们要来验证一下是否修改成功。目前我们使用 JMeter 做验证工具,在 JMeter 中增加插入接口请求,并且写入下面数据:
|
|||
|
|
|||
|
![图片](https://static001.geekbang.org/resource/image/7a/8c/7ac95dbc0d1ac174yye8d518fc373c8c.png?wh=1920x610)
|
|||
|
|
|||
|
在 body 中的“昵称”中写入“性能测试-我是正常流量”,请求成功显示如下:
|
|||
|
|
|||
|
![图片](https://static001.geekbang.org/resource/image/5e/f5/5ea87c5418d16448168bd861a968ccf5.png?wh=1404x570)
|
|||
|
|
|||
|
为了验证是否插入成功,需要打开 MySQL 输入查询语句。在查询前,要先通过日志判断出要使用哪个数据库,这样才能查到结果。
|
|||
|
|
|||
|
![图片](https://static001.geekbang.org/resource/image/85/32/85cc2af8da0ef0yydb8d5d3befab1332.png?wh=1511x212)
|
|||
|
|
|||
|
上面的日志中, Header 信息为 null,根据早先的设计, Header 信息为 null 会走 master(正式库) 数据源 ,也就是 mall\_master 库。
|
|||
|
|
|||
|
接下来,打开数据库工具执行 SQL,验证数据是否已经插入成功。
|
|||
|
|
|||
|
```sql
|
|||
|
SELECT * FROM mall_master.ums_admin;
|
|||
|
|
|||
|
```
|
|||
|
|
|||
|
![图片](https://static001.geekbang.org/resource/image/f2/d8/f248b5bb921a22f8688aa18de1655dd8.png?wh=1500x211)
|
|||
|
|
|||
|
从界面截图中可以看出,显示的数据与压力工具执行的数据一致。
|
|||
|
|
|||
|
* 压测请求(加标记的请求)
|
|||
|
|
|||
|
在 JMeter 中增加 HTTP Header Manager 组件,并且在 Header 信息增加如下标志,再次执行操作,验证数据是否进入压测数据库。如果成功,说明目前的 demo 是有效的。
|
|||
|
|
|||
|
![图片](https://static001.geekbang.org/resource/image/74/62/742584f24110d6025b50693b98d23d62.png?wh=1242x400)
|
|||
|
|
|||
|
打开 JMeter,在 View Results Tree 中查看结果。
|
|||
|
|
|||
|
![图片](https://static001.geekbang.org/resource/image/85/a3/854be53a09b5b366596cf72da3d4d5a3.png?wh=1398x572)
|
|||
|
|
|||
|
在 SpringBoot 工程中查看请求日志,验证是否成功切换数据源,日志显示目前的标记为 7DGroup ,因而应该走 shadow(影子库)数据源,也就是数据源中的 mall\_shadow 库。
|
|||
|
|
|||
|
![图片](https://static001.geekbang.org/resource/image/yy/d1/yy268e668bbec292069312dc10b92ed1.png?wh=1502x247)
|
|||
|
|
|||
|
根据日志显示的信息,打开 mall\_shadow 数据库执行 SQL ,验证数据是否已经插入该数据库。
|
|||
|
|
|||
|
```sql
|
|||
|
SELECT * FROM mall_shadow.ums_admin;
|
|||
|
|
|||
|
```
|
|||
|
|
|||
|
![图片](https://static001.geekbang.org/resource/image/4d/15/4d36793754b8f2bfd2b9dd4c3f5f3f15.png?wh=1490x225)
|
|||
|
|
|||
|
从上面的结果可以看出,目前的数据已经进入预期的数据库,也达成了 demo 技术预演目标。之前已经说过,只要 demo 能完成数据库切换,并且数据正常,那么就需要把目前的配置文件和相关类移植到正式系统中去,只有这样才能低风险完成系统改造。
|
|||
|
|
|||
|
### 第二种:数据上下文方案
|
|||
|
|
|||
|
在第一种方案中,最关键的一点是要通过 AOP 拦截切换,而我们下面这个方案,则是通过数据上下文切换获取压测标记和 AOP 切换数据源,其它的内容都是一样的。
|
|||
|
|
|||
|
在开始演示之前,我们先回顾一下上一讲提到过的方案图:
|
|||
|
|
|||
|
![图片](https://static001.geekbang.org/resource/image/99/05/99ee4d4daa9fe5fe78d99c2d8fdc8105.jpg?wh=1920x1922)
|
|||
|
|
|||
|
这里实现的核心逻辑图如下:
|
|||
|
|
|||
|
![图片](https://static001.geekbang.org/resource/image/4e/bf/4e09a1ca5e528b306697eccc9f0eedbf.jpg?wh=1165x2065)
|
|||
|
|
|||
|
我们还是使用上一节课搭建的环境,如果忘了这个环境怎么搭,你可以到上一讲复习一下。
|
|||
|
|
|||
|
首先,启动项目,显示如下:
|
|||
|
|
|||
|
![图片](https://static001.geekbang.org/resource/image/38/e1/3818be42a830468aa5147b1bfbcee3e1.png?wh=300x98)
|
|||
|
|
|||
|
可以看到,Nacos 注册中心有网关、会员、购物车、订单服务。
|
|||
|
|
|||
|
![图片](https://static001.geekbang.org/resource/image/b9/ac/b9c1c74f093289ee0d0e5a8152d110ac.png?wh=1920x504)
|
|||
|
|
|||
|
在上一节课,我们已经把标记传入到了每个服务中,同时还存入了对应的数据上下文里,所以在这里,我们只要在业务层获取标记,判断是不是压测标记,然后做对应的数据源切换动作就可以了。
|
|||
|
|
|||
|
这里,我们打开上一节课的项目,在订单服务中的 application.yml 文件中添加双数据库链接,你可以参考下面的配置:
|
|||
|
|
|||
|
```yaml
|
|||
|
spring:
|
|||
|
datasource:
|
|||
|
master: # 数据源1
|
|||
|
username: root
|
|||
|
password: dunshan123root
|
|||
|
url: jdbc:mysql://localhost:3306/mall_master?useUnicode=true&characterEncoding=UTF-8&useJDBCCompliantTimezoneShift=true&useLegacyDatetimeCode=false&serverTimezone=UTC&queryInterceptors=brave.mysql8.TracingQueryInterceptor&zipkinServiceName=mall_master
|
|||
|
type: com.alibaba.druid.pool.DruidDataSource
|
|||
|
druid:
|
|||
|
initialSize: 5
|
|||
|
minIdle: 5
|
|||
|
maxActive: 20
|
|||
|
maxWait: 60000
|
|||
|
timeBetweenEvictionRunsMillis: 60000
|
|||
|
minEvictableIdleTimeMillis: 300000
|
|||
|
validationQuery: SELECT 1 FROM DUAL
|
|||
|
testWhileIdle: true
|
|||
|
testOnBorrow: false
|
|||
|
testOnReturn: false
|
|||
|
poolPreparedStatements: true
|
|||
|
# 配置监控统计拦截的filters,去掉后监控界面sql无法统计,'wall'用于防火墙
|
|||
|
filters: stat,wall,log4j
|
|||
|
maxPoolPreparedStatementPerConnectionSize: 20
|
|||
|
useGlobalDataSourceStat: true
|
|||
|
connectionProperties: druid.stat.mergeSql=true;druid.stat.slowSqlMillis=500
|
|||
|
shadow: # 数据源2
|
|||
|
url: jdbc:mysql://localhost:3306/mall_shadow?useUnicode=true&characterEncoding=UTF-8&useJDBCCompliantTimezoneShift=true&useLegacyDatetimeCode=false&serverTimezone=UTC&queryInterceptors=brave.mysql8.TracingQueryInterceptor&zipkinServiceName=mall_shadow
|
|||
|
username: root
|
|||
|
password: dunshan123root
|
|||
|
druid:
|
|||
|
initial-size: 10 #连接池初始化大小
|
|||
|
min-idle: 10 #最小空闲连接数
|
|||
|
max-active: 20 #最大连接数
|
|||
|
web-stat-filter:
|
|||
|
exclusions: "*.js,*.gif,*.jpg,*.png,*.css,*.ico,/druid/*" #不统计这些请求数据
|
|||
|
stat-view-servlet: #访问监控网页的登录用户名和密码
|
|||
|
login-username: druid
|
|||
|
login-password: druid
|
|||
|
|
|||
|
```
|
|||
|
|
|||
|
想想上节课我们讲的,应该在什么地方获取和设置标记。这样可以方便下一步操作。具体操作可以参考我在文稿中给出的截图。
|
|||
|
|
|||
|
![图片](https://static001.geekbang.org/resource/image/1f/55/1fc2d15d66b86e9d780f5a7f70b8d155.png?wh=1920x924)
|
|||
|
|
|||
|
简单解释下,这里我们添加了一个全局 Filter 拦截器,拦截全部请求,通过 BaggageField.getByName(“dunshan”) 获取透传标记,再把标记放入数据上下文中。
|
|||
|
|
|||
|
接下来,我们查看一下数据上下文类,这个类的主要目的是把压测标记保存到 TransmittableThreadLocal 中去。
|
|||
|
|
|||
|
```java
|
|||
|
|
|||
|
/**
|
|||
|
* @author dunshan
|
|||
|
* @description: 数据上下文
|
|||
|
* @date 2021-11-18 23:12:10
|
|||
|
*/
|
|||
|
public class AppContext implements Serializable {
|
|||
|
private static final TransmittableThreadLocal<AppContext> contextdunshan = new TransmittableThreadLocal<>();
|
|||
|
private String flag;
|
|||
|
public static AppContext getContext() {
|
|||
|
return contextdunshan.get();
|
|||
|
}
|
|||
|
public static void setContext(AppContext context) {
|
|||
|
contextdunshan.set(context);
|
|||
|
}
|
|||
|
public static void removeContext() {
|
|||
|
contextdunshan.remove();
|
|||
|
}
|
|||
|
public String getFlag() {
|
|||
|
return flag;
|
|||
|
}
|
|||
|
public void setFlag(String flag) {
|
|||
|
this.flag = flag;
|
|||
|
}
|
|||
|
}
|
|||
|
|
|||
|
|
|||
|
```
|
|||
|
|
|||
|
下一步还是使用 AOP 判断请求,这次,我们通过数据上下文获取标记来切换数据源链接,你可以参考我给出的代码:
|
|||
|
|
|||
|
```java
|
|||
|
@Log4j2
|
|||
|
@Component
|
|||
|
@Aspect
|
|||
|
public class AopMyDbSwitch {
|
|||
|
/**
|
|||
|
* 拦截入口下所有的 public方法
|
|||
|
*/
|
|||
|
@Pointcut("execution(public * com.dunshan.order.controller..*(..))")
|
|||
|
public void pointCutAround() {
|
|||
|
}
|
|||
|
/**
|
|||
|
*根据数据上下文切换数据源
|
|||
|
*/
|
|||
|
@Before(value = "pointCutAround()")
|
|||
|
public void around(JoinPoint point) {
|
|||
|
AppContext context = AppContext.getContext();
|
|||
|
String flag = context.getFlag();
|
|||
|
|
|||
|
if (StringUtils.isNotEmpty(flag) && flag.equals(DataSourceNames.HEAD)) {
|
|||
|
//影子库
|
|||
|
MDC.put("dunshan", "shadow");
|
|||
|
DynamicDataSource.setDataSource(DataSourceNames.SHADOW);
|
|||
|
} else {
|
|||
|
//正式库
|
|||
|
MDC.put("dunshan", "produce");
|
|||
|
DynamicDataSource.setDataSource(DataSourceNames.MASTER);
|
|||
|
}
|
|||
|
}
|
|||
|
}
|
|||
|
|
|||
|
```
|
|||
|
|
|||
|
**注意:除了刚才所讲的这些不一样的地方以外,其他配置与第一种方案都是一样的。**
|
|||
|
有上面的配置后,我们再查看一下数据库表是否做了区分。先看 mall\_master 中的 ums\_admin表,再看 mall\_shadow 中的 ums\_admin 表。
|
|||
|
|
|||
|
mall\_master 数据库中 ums\_admin 表显示一条内容为 master:
|
|||
|
|
|||
|
```sql
|
|||
|
use mall_master;
|
|||
|
select * from ums_admin;
|
|||
|
|
|||
|
```
|
|||
|
|
|||
|
![图片](https://static001.geekbang.org/resource/image/cc/75/cc57a1046256f398dda747ca31762475.png?wh=1600x360)
|
|||
|
|
|||
|
mall\_shadow 数据库中的 ums\_admin 数据显示有一条记录包含 shadow:
|
|||
|
|
|||
|
```sql
|
|||
|
use mall_shadow;
|
|||
|
SELECT * FROM ums_admin;
|
|||
|
|
|||
|
```
|
|||
|
|
|||
|
![图片](https://static001.geekbang.org/resource/image/e3/46/e357e0000c110d16674bd31db2d08746.png?wh=1498x398)
|
|||
|
|
|||
|
数据库区分完毕后,我们再设计一个请求,用来模拟正式流量与压测流量查询这两个表,在会员系统中增加查询请求,参考如下:
|
|||
|
|
|||
|
```java
|
|||
|
/**
|
|||
|
* 查询用户信息
|
|||
|
*
|
|||
|
* @return
|
|||
|
*/
|
|||
|
@GetMapping("/admin/info")
|
|||
|
public Object selectAdmin() {
|
|||
|
return cartFeignClient.selectInfo();
|
|||
|
}
|
|||
|
|
|||
|
```
|
|||
|
|
|||
|
链路调用关系为网关 -> 用户-> 购物车 -> 订单 -> 数据库。
|
|||
|
这里我们还是使用 JMeter 模拟压测流量与正常流量,你可以参考我给出的请求信息。
|
|||
|
|
|||
|
* 不添加请求 Header 标记(正式请求)
|
|||
|
|
|||
|
![图片](https://static001.geekbang.org/resource/image/68/37/68a37aa327227c471d328d386eb40637.png?wh=1920x518)
|
|||
|
|
|||
|
* 添加请求 Header 标记(压测请求)
|
|||
|
|
|||
|
![图片](https://static001.geekbang.org/resource/image/2c/95/2c03d9e1dac6bba26597aa65af01e295.png?wh=1920x396)
|
|||
|
|
|||
|
接下来,我们要验证结果是否跟预期想的一致。点击 JMeter 请求,在响应结果中查看结果。
|
|||
|
|
|||
|
* 不添加请求 Header 标记(正式请求)
|
|||
|
|
|||
|
![图片](https://static001.geekbang.org/resource/image/1e/ce/1e9512fdf1db5b43168ea9c3ceccfbce.png?wh=1134x572)
|
|||
|
|
|||
|
* 添加请求 Header 标记(压测请求)
|
|||
|
|
|||
|
![图片](https://static001.geekbang.org/resource/image/55/a3/554656e57b399f4794da157e049b7aa3.png?wh=1244x670)
|
|||
|
|
|||
|
可以看到,数据已经按预期显示出来了。
|
|||
|
|
|||
|
我们再打开 Zipkin 连接跟踪,验证下链路追踪结果。
|
|||
|
|
|||
|
* 不添加请求 Header 标记(正式库)
|
|||
|
|
|||
|
![图片](https://static001.geekbang.org/resource/image/64/e2/648f9be5f21048e2ba920516973797e2.png?wh=1920x438)
|
|||
|
|
|||
|
* 添加请求 Header 标记(影子库)
|
|||
|
|
|||
|
![图片](https://static001.geekbang.org/resource/image/74/3a/7427433a234112c83453212cdc59123a.png?wh=1920x481)
|
|||
|
|
|||
|
Zipkin 链路全貌图为:
|
|||
|
|
|||
|
![图片](https://static001.geekbang.org/resource/image/6b/f4/6bd3dca5622bd201b0505424470364f4.png?wh=1920x434)
|
|||
|
|
|||
|
从上面的结果可以看出,目前的数据已经进入到了预期的数据库中,也达成了通过获取数据上下文切换数据库技术预演的目标。之前已经说过了,只要 demo 能完成数据库切换,并且数据正常,那么就可以把目前的配置文件和相关类移植到正式系统中去了。
|
|||
|
|
|||
|
## 系统改造
|
|||
|
|
|||
|
接下来我们就看看真实系统的改造。
|
|||
|
|
|||
|
首先对 mall-member(会员系统)进行改造。接下来我们快速把 demo 的类与配置移植到对应工程中去。
|
|||
|
|
|||
|
### 添加全局 Filter 过滤器
|
|||
|
|
|||
|
因为我们上一讲已经演示过在全局 Filter 中把标记加入到数据上下文中的逻辑,所以这里的改造就比较轻松了。我们可以快速在服务中添加全局 Filte 过滤器 。
|
|||
|
|
|||
|
![图片](https://static001.geekbang.org/resource/image/9a/4b/9ab23782cb816823a2989f732254764b.png?wh=1274x1184)
|
|||
|
|
|||
|
### 配置 AOP 切面
|
|||
|
|
|||
|
然后,在 AOP 类中修改 Pointcut 类中的注解。需要注意修改中间的文本框,其它地方与 demo 中保持一致即可。
|
|||
|
|
|||
|
![图片](https://static001.geekbang.org/resource/image/38/c9/3870db5171b3726536e4477e03143ec9.png?wh=1400x266)
|
|||
|
|
|||
|
具体的代码你可以参考我给出的图片。
|
|||
|
|
|||
|
![图片](https://static001.geekbang.org/resource/image/6e/2d/6e2fa4716ebca9507edd64e6f9d70e2d.png?wh=1486x1682)
|
|||
|
|
|||
|
我们通过 AOP 拦截服务请求,实时判断数据上下文的标记类型,并存入设置对应的数据源上下文。
|
|||
|
|
|||
|
### 配置多数据源
|
|||
|
|
|||
|
AOP 配置类替换成功后,再把 application.yml 文件修改成多数据源,因为只有这样才能实现数据源切换。具体操作如下:
|
|||
|
|
|||
|
![图片](https://static001.geekbang.org/resource/image/19/0b/19c6a67f0db592d6bb434336f404ee0b.png?wh=1920x1015)
|
|||
|
|
|||
|
改造完成后,我们启动工程,验证数据库是否成功。因为这个工程比较复杂,所以还需要启动网关服务和认证服务,启动的数据库启动日志如下:
|
|||
|
|
|||
|
![图片](https://static001.geekbang.org/resource/image/2c/34/2c9d888af95a3a8d02b2d0659ff7c734.png?wh=1160x459)
|
|||
|
|
|||
|
启动系统如下:
|
|||
|
|
|||
|
![图片](https://static001.geekbang.org/resource/image/f2/03/f2d3f60cc9496204aec733878ea3a903.png?wh=801x255)
|
|||
|
|
|||
|
### 验证改造结果
|
|||
|
|
|||
|
打开接口文档,在 JMeter 中模拟注册用户接口,关于注册接口怎么开发,你可以参考《[高楼的性能工程实战课之脚本开发](https://mp.weixin.qq.com/s/KHGfK7DUbSBcNOF6J8mb6Q)》,验证 mall-member (会员系统)数据源是否切换成功。
|
|||
|
|
|||
|
* 压测请求(带 Header 标记)
|
|||
|
|
|||
|
脚本编写成功后,执行注册流程,执行成功后打开工程查看日志:
|
|||
|
|
|||
|
![图片](https://static001.geekbang.org/resource/image/ed/16/ed791084acb7b389a0f9f93f5a6e5116.png?wh=1500x366)
|
|||
|
|
|||
|
在我给出的日志截图中,7dTest005 是新注册的用户名。7DGroup 是 HTTP Header 标记,这是压测标记,带有它的数据都要进入 shadow(影子)数据库。
|
|||
|
|
|||
|
根据提示与配置信息,再到数据库中查询数据是否注册成功,仔细观察目前的用户名为7dTest005。执行 SQL 语句之前,先分析下目前执行的数据库是哪个?
|
|||
|
|
|||
|
上面的日志信息提示的是压测流量标记,找到数据源为 shadow,执行用户名 SQL 语句。检查一下日志中用户 7dTest005 是否已经注册成功。
|
|||
|
|
|||
|
```sql
|
|||
|
select * from shadow.ums_member;
|
|||
|
|
|||
|
```
|
|||
|
|
|||
|
![图片](https://static001.geekbang.org/resource/image/6b/c5/6bd630049yydcdb64ba3d52109cd92c5.png?wh=1334x584)
|
|||
|
|
|||
|
刚才讲的是带 Header 标记验证,我们根据 SQL 查询结果知道已经注册成功了。这说明流量带压测标记进入了影子库,下面我们再来验证不带标记是否也会正常进入正式库。
|
|||
|
|
|||
|
* 正常请求(无 Header 标记)
|
|||
|
|
|||
|
我们把注册脚本中的 Header 标记去掉,修改用户名执行脚本请求,成功后查看工程日志。如果没有Header 标记也能成功注册脚本,表示 mall-member 系统改造成功。
|
|||
|
|
|||
|
执行成功后查看日志:
|
|||
|
|
|||
|
![图片](https://static001.geekbang.org/resource/image/3d/76/3dbf27fd57f57c99e4b3fafb6af71a76.png?wh=1500x362)
|
|||
|
|
|||
|
从我给出的日志截图可以看出,目前流量正常,而且注册的用户名是 7dTest1188,根据代码可知目前走的数据源是 master(正式库)。执行用户 SQL,验证数据是否已经走入 master 数据库,
|
|||
|
|
|||
|
从下图执行结果可以看出,目前数据已经插入预期的数据库中。
|
|||
|
|
|||
|
```sql
|
|||
|
select * from master.ums_member;
|
|||
|
|
|||
|
```
|
|||
|
|
|||
|
![图片](https://static001.geekbang.org/resource/image/13/10/1316923170f0fda99cc1e5e9e267e410.png?wh=1495x592)
|
|||
|
|
|||
|
完成了上述一系列操作后,对 mall-member (会员系统)的改造就完成了。有了成功改造 mall-member 影子库的经验,我们就可以沿用这种方法落地改造到其他系统中去了。
|
|||
|
|
|||
|
## 总结
|
|||
|
|
|||
|
好了,我们今天就讲到这里了。在课程的最后,我再带你回顾一下课程内容。这节课,我们主要讲了落地 MySQL 数据库隔离的方法。
|
|||
|
|
|||
|
这里有几个要点希望你能记住:
|
|||
|
|
|||
|
1. 多数据源是实现影子库的基础;
|
|||
|
|
|||
|
2. 从数据上下文对象中获取标记,这是更为推荐的微服务标记透传方案;
|
|||
|
|
|||
|
3. AOP 结合 TransmittableThreadLocal 是实现数据源动态切换的好组合;
|
|||
|
|
|||
|
4. 从风险管控的角度,我建议你先做 demo 技术预演,再做单个系统改造,最后再同步其余系统。
|
|||
|
|
|||
|
|
|||
|
可以说,在全链路压测中,**数据库隔离是最重要的一个改造环节了**。因为做得不到位,压测流量就会污染生产数据,导致的后果非常严重。这节课给出的影子库方案,是改造成本低,效果最好的一种方式。希望你能够充分地理解并用好它。
|
|||
|
|
|||
|
## 思考题
|
|||
|
|
|||
|
最后,我想请你思考几个问题:
|
|||
|
|
|||
|
1. 为什么说数据库隔离是全链路压测中改造的核心环节?
|
|||
|
|
|||
|
2. 除了我列出的这三种,你还有没有其他方式可以实现数据库隔离?
|
|||
|
|
|||
|
|
|||
|
欢迎你在留言区和我交流讨论。当然,你也可以把这节课分享给你身边的朋友,或许可以碰撞出更多新的想法。我们下节课再见!
|
|||
|
|