0%

分库分表分为垂直和水平两个方式,一般来说拆分的顺序是先垂直后水平。

垂直分库

基于现在的微服务拆分来说,都是已经做到了垂直分库了。
将订单,用户,商品,支付,库存,预算等不同业务数据存放在不同的数据库中。

垂直分表

将字段比较多的表,将不常用的,数据较大的字段进行拆分。
一般拆分为主表和扩展表。
比如:订单表——>基础信息,订单扩展,收货地址。

水平分表

首先根据业务场景决定使用什么字段作为分表字段(sharding_key)。
比如我们现在日订单 1000 万,我们大部分的场景来源于 C 端,我们可以用 user_id 作为 sharding_key,数据查询支持到最近 3 个月的订单,超多 3 个月的做归档处理,那么 3 个月的数量就是 9 亿,可以分为 1024 张表,那么每张表的数据大概就在 100 万左右。
比如用户 id 是 100,那么我们经过 hash(100),然后对 1024 取模,就可以落到对应的表上了。

分表后 ID 如何保证唯一?

  1. 设定步长,1024 张表设定 1024 的基础步长,这样主键落到不同的表就不会冲突了。
  2. 分布式 ID,自己实现一套分布式 ID 生成算法,或者使用开源的比如雪花算法。
  3. 分表后不适用主键作为查询依据,而是每张表单独新增一个字段作为唯一主键使用。

分表后非 sharding_key 的查询怎么处理?

  1. 可以做一个 mapping 表,比如商家查询订单的时候,可以做一个映射表,保存商家与用户的关系,查询时先通过商家查询到用户,再通过 user_id 去查询。
  2. 打宽表,一般而言,客户端对数据实时性要求并不是很高,比如查询订单列表,可以把订单表同步到离线数仓,在基于数仓做成一张宽表。
  3. 数据量不是很大的情况,比如后台的一些查询服务,可以通过多线程扫表,然后再聚合结果的方式来做,或者异步的形式也是可以的,

1、什么是 AOP?

AOP,又称面向切面编程,AOP 是一种编程思想,是面向对象编程的一种补充。面向对象编程将程序抽象成各个层次的对象,而面向切面编程将程序抽象成各个层次的切面。
在面向切面的编程思想里面,把功能分为核心业务功能和周边功能。

  • 核心业务:比如登录,删除数据,增加数据。
  • 周边功能:比如性能统计,日志,事务管理等。

周边功能在 spring 的面向切面编程 AOP 思想里,即被定义为切面。
在面向切面编程 AOP 的思想里面,核心业务功能和切面功能分别独立进行开发,然后把切面功能和核心业务功能 “编织” 在一起,这就叫 AOP

2、为什么需要 AOP?

AOP 可以将那些与业务无关,却为业务模块所共同调用的逻辑或责任封装起来,便于减少系统的重复代码,降低模块间的耦合度,并有利于将来的可扩展性和可维护性。
AOP 要实现的效果是,保证开发者在不修改源代码的前提下,去为系统中的业务组件添加某种通用的功能。
AOP 的本质是由 AOP 框架修改业务组件的多个方法的源代码,就是代理模式的典型应用。

3、实现分类?

  1. 静态 AOP 实现,AOP 在编译阶段对程序源代码进行修改,生成了静态的 AOP 代理类,如 Aspectj。
  2. 动态 AOP 实现,AOP 框架在运行阶段动态生成代理对象(在内存中以 JDK 动态代理,或 CGLib 动态的生成 AOP 代理类),如 Spring AOP。

比较:

类别机制原理优点缺点
静态 AOP静态织入在编译时,切面直接以字节码的形式编译进目标子节码文件中对系统无性能影响灵活性不够
动态 AOPJDK 动态代理在运行期,目标类加载后,为接口动态生成代理类,将切面织入到代理类中相对于静态 AOP 更加灵活切入的关注点需要实现接口,对系统有一点性能影响
动态子节码CGLib在运行期,目标类加载前,动态生成目标类的子类,将切面逻辑加入到子类中没有接口也可以织入扩展类的实例,方法用 final 修饰时则无法织入
自定义类加载器在运行期,目标类加载前,将切面逻辑加入到目标字节码里可以对绝大部分类进行织入代码中如果使用了其他类加载器,则这些类不会织入
字节码转换在运行期,所有类加载器加载字节码前进行拦截可以对所有类进行织入

4、概念

AOP 增强

  1. Aspect(切面):通常是一个类,里面可以定义切入点和通知,切面是通知和切入点的结合。在什么时机,什么地方,做什么增强!
  2. Pointcut(切入点):就是带有通知的连接点,在程序中主要体现为书写切入点表达式。在哪些类,哪些方法上切入。
  3. Advice(通知):AOP 在特定的切入点上执行的增强处理。通知描述了切面何时执行以及如何执行增强处理。在方法执行的什么时机,做什么。
  4. JointPoint(连接点):连接点表示应用执行过程中能够插入切面的一个点,这个点可以是方法的调用、异常的抛出。在 Spring AOP 中,连接点总是方法的调用。
  5. Weaving(织入):将增强处理添加到目标对象中,并创建一个被增强的对象,这个过程就是织入。

通知

  1. 前置通知:在我们执行目标方法之前运行(@Before)。
  2. 后置通知:在我们目标方法运行结束之后 ,不管有没有异常**(@After)。**
  3. 返回通知:在我们的目标方法正常返回值后运行**(@AfterReturning)。**
  4. 异常通知:在我们的目标方法出现异常后运行**(@AfterThrowing)。**
  5. 环绕通知:动态代理, 需要手动执行 joinPoint.procced()(其实就是执行我们的目标方法执行之前相当于前置通知, 执行之后就相当于我们后置通知**(@Around)。**环绕通知可用来做权限验证。

5、执行顺序

12170632-2931198a9b094f45.jpeg
在 springboot 中可以通过 Order 指定执行的顺序,around 与 before 比 order 小,around 与 after、afterreturning、afterthrowing 比 order 大。
异常情况下,环绕通知的后通知,返回通知会被异常通知阻断,不会执行。

本质上,spring 中通过提前暴露来解决循环依赖。

什么是循环依赖?

java 中的循环依赖分为两种,一种是构造器的循环依赖。另一种是属性的循环依赖。

构造器的循环依赖就是在构造方法中有属性循环依赖。
这种循环依赖无法解决,因为 JVM 虚拟机在对类进行实例化的时候,需要先实例构造器的参数,由于循环引用这个参数无法实例化,只能抛出错误。

属性的循环依赖,在类的属性中具有循环依赖。

循环依赖与属性注入

  1. 对于非懒加载的类,实在 refresh 方法中的finishBeanFactoryInitialization(beanFactory)完成的包扫描和 bean 初始化。在此方法中调用的 beanFactory 的 preInstantiateSingletons 方法。
1
2
3
4
5
6
protected void finishBeanFactoryInitialization(ConfigurableListableBeanFactory beanFactory) {
// 其他代码

// Instantiate all remaining (non-lazy-init) singletons.
beanFactory.preInstantiateSingletons();
}
  1. preInstantiateSingletons 方法,可以看到就是在此方法中循环 Spring 容器中的所有 bean,一次对其进行初始化,入口方法就是 getBean()。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
public void preInstantiateSingletons() throws BeansException {

List<String> beanNames = new ArrayList<>(this.beanDefinitionNames);
// Trigger initialization of all non-lazy singleton beans...

for (String beanName : beanNames) {
RootBeanDefinition bd = getMergedLocalBeanDefinition(beanName);
// 判断为非抽象类、是单例、非懒加载 才给初始化
if (!bd.isAbstract() && bd.isSingleton() && !bd.isLazyInit()) {
if (isFactoryBean(beanName)) {
// 无关代码(针对FactoryBean的处理)
} else {
// 重要!!!普通bean就是在这里初始化的
getBean(beanName);
}
}
}

// 其他无关代码
}
  1. 追踪 getBean 方法,继续查看 doGetBean 方法
1
2
3
public Object getBean(String name) throws BeansException {
return doGetBean(name, null, null, false);
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
protected <T> T doGetBean(final String name, @Nullable final Class<T> requiredType,
@Nullable final Object[] args, boolean typeCheckOnly) throws BeansException {
final String beanName = transformedBeanName(name);
Object bean;

// 方法1)从三个map中获取单例类
if(...) {
Object sharedInstance = getSingleton(beanName);
// 省略无关代码
} else {
// 如果是多例的循环引用,则直接报错
if (isPrototypeCurrentlyInCreation(beanName)) {
throw new BeanCurrentlyInCreationException(beanName);
}
// 省略若干无关代码
try {
// Create bean instance.
if (mbd.isSingleton()) {
// 方法2) 获取单例对象
sharedInstance = getSingleton(beanName, () -> {
//方法3) 创建ObjectFactory中getObject方法的返回值
try {
return createBean(beanName, mbd, args);
}catch (BeansException ex) {
// Explicitly remove instance from singleton cache: It might have been put there.
// eagerly by the creation process, to allow for circular reference resolution.
// Also remove any beans that received a temporary reference to the bean.
destroySingleton(beanName);
throw ex;
}
});
bean = getObjectForBeanInstance(sharedInstance, name, beanName, mbd);
}
}
// 省略若干无关代码
return (T) bean;
}
}
  1. 方法一 getSingleton(String beanName,boolean allowEarlyReference)方法。通过下面的方法可以看出这几个 map 的优先级:
    1. singletonObjects:单例对象的缓存,存放的是初始化后的对象。
    2. earlySingletonObjects:提前曝光的单例对象的缓存,存放的是一个已完成实例化未完成初始化的早期单例对象。
    3. singletonFactories:单例对象工厂的缓存,存放的是 ObjectFactory 对象,此对象的 getObject 方法返回值即刚完成实例化还未开始初始化的单例对象。

所以先后顺序是,单例对象先存在于 singletonFactories 中,后存在于 earlySingletonObjects 中,最后初始化完成后放入 singletonObjects 中

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
protected Object getSingleton(String beanName, boolean allowEarlyReference) {
Object singletonObject = this.singletonObjects.get(beanName);// 步骤A
if (singletonObject == null && isSingletonCurrentlyInCreation(beanName)) {
synchronized (this.singletonObjects) {
singletonObject = this.earlySingletonObjects.get(beanName);// 步骤B
if (singletonObject == null && allowEarlyReference) {
ObjectFactory<?> singletonFactory = this.singletonFactories.get(beanName);// 步骤C
if (singletonFactory != null) {
singletonObject = singletonFactory.getObject();
this.earlySingletonObjects.put(beanName, singletonObject);
this.singletonFactories.remove(beanName);
}
}
}
}
return singletonObject;
}
  1. 方法二 getSingleton(String beanName,ObjectFactory<?> singletonFactory)方法。这个方法与第一个方法相同。内部逻辑不同,为重载方法。获取单例的主要逻辑就是此方法实现的。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
public Object getSingleton(String beanName, ObjectFactory<?> singletonFactory) {
Assert.notNull(beanName, "Bean name must not be null");
synchronized (this.singletonObjects) {
Object singletonObject = this.singletonObjects.get(beanName);
if (singletonObject == null) {
// 省略无关代码
beforeSingletonCreation(beanName); // 步骤A
boolean newSingleton = false;
// 省略无关代码
try {
singletonObject = singletonFactory.getObject();// 步骤B
newSingleton = true;
}
// 省略无关代码
finally {
if (recordSuppressedExceptions) {
this.suppressedExceptions = null;
}
afterSingletonCreation(beanName);// 步骤C
}
if (newSingleton) {
addSingleton(beanName, singletonObject);// 步骤D
}
}
return singletonObject;
}
}

此方法中分为四个步骤,

  1. A 步骤
1
2
3
4
5
6
protected void beforeSingletonCreation(String beanName) {
// 判断,并首次将beanName即teacher放入singletonsCurrentlyInCreation中
if (!this.inCreationCheckExclusions.contains(beanName) && !this.singletonsCurrentlyInCreation.add(beanName)) {
throw new BeanCurrentlyInCreationException(beanName);
}
}
  1. C 步骤
1
2
3
4
5
6
protected void afterSingletonCreation(String beanName) {
// 得到单例对象后,再讲beanName从singletonsCurrentlyInCreation中移除
if (!this.inCreationCheckExclusions.contains(beanName) && !this.singletonsCurrentlyInCreation.remove(beanName)) {
throw new IllegalStateException("Singleton '" + beanName + "' isn't currently in creation");
}
}
  1. D 步骤
1
2
3
4
5
6
7
8
9
10
11
12
protected void addSingleton(String beanName, Object singletonObject) {
synchronized (this.singletonObjects) {
//添加单例对象到map中
this.singletonObjects.put(beanName, singletonObject);
//从早期暴露的工厂中移除,此map在解决循环依赖中发挥了关键的作用
this.singletonFactories.remove(beanName);
//从早期暴露的对象map中移除
this.earlySingletonObjects.remove(beanName);
//添加到已注册的单例名字集合中
this.registeredSingletons.add(beanName);
}
}
  1. B 步骤,此处调用了 ObjectFactory 的 getObject 方法,返回值就是方法三中的 createBean 方法的返回值,即方法三返回我们需要的单例对象。
1
2
3
4
5
6
7
8
protected Object createBean(String beanName, RootBeanDefinition mbd, @Nullable Object[] args) throws BeanCreationException {
// 省略无关代码
try {
Object beanInstance = doCreateBean(beanName, mbdToUse, args);
return beanInstance;
}
// 省略无关代码
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
protected Object doCreateBean(final String beanName, final RootBeanDefinition mbd, final @Nullable Object[] args) throws BeanCreationException {
BeanWrapper instanceWrapper = null;
// 省略代码
if (instanceWrapper == null) {
// 实例化bean
instanceWrapper = createBeanInstance(beanName, mbd, args);
}
boolean earlySingletonExposure = (mbd.isSingleton() && this.allowCircularReferences && isSingletonCurrentlyInCreation(beanName));
if (earlySingletonExposure) {
// 重点!!!将实例化的对象添加到singletonFactories中
addSingletonFactory(beanName, () -> getEarlyBeanReference(beanName, mbd, bean));
}
// 初始化bean
Object exposedObject = bean;
try {
populateBean(beanName, mbd, instanceWrapper);//也很重要
exposedObject = initializeBean(beanName, exposedObject, mbd);
}
// 省略无关代码
return exposedObject;
}

在 addSingletonFactory 方法中,将第二个参数 ObjectFactory 存入了 singletonFactories 供其他对象依赖时调用。然后下面的populateBean 方法对刚实例化的 bean 进行属性注入(该方法关联较多,本文暂时不展开追踪了,有兴趣的园友自行查看即可),如果遇到 Spring 中的对象属性,则再通过 getBean 方法获取该对象。至此,循环依赖在 Spring 中的处理过程已经追溯完毕。


总结

属性注入主要是在 populateBean 方法中进行的。对于循环依赖,以我们上文中的 Teacher 中注入了 Student、Student 中注入了 Teacher 为例来说明,假定 Spring 的加载顺序为先加载 Teacher,再加载 Student。
getBean 方法触发 Teacher 的初始化后:

  1. 首先走到 3 中的方法 1),此时 map 中都为空,获取不到实例;
  2. 然后走到方法 2)中,步骤 A、步骤 C、步骤 D 为控制 map 中数据的方法,实现简单,可暂不关注。其中步骤 B 的 getObject 方法触发对方法 3)的调用;
  3. 在方法 3)中,先通过 createBeanInstance 实例化 Teacher 对象,又将该实例化的对象通过 addSingletonFactory 方法放入 singletonFactories 中,完成 Teacher 对象早期的暴露;
  4. 然后在方法 3)中通过 populateBean 方法对 Teacher 对象进行属性的注入,发现它有一个 Student 属性,则触发 getBean 方法对 Student 进行初始化
  5. 重复 a、b、c 步骤,只是此时要初始化的是 Student 对象
  6. 走到 d 的时候,调用 populateBean 对 Student 对象进行属性注入,发现它有一个 Teacher 属性,则触发 getBean 方法对 Teacher 进行初始化;
  7. 对 Teacher 进行初始化,又来到 a,但此时 map 已经不为空了,因为之前在 c 步骤中已经将 Teacher 实例放入了 singletonFactories 中,a 中得到 Teacher 实例后返回;
  8. 完成 f 中对 Student 的初始化,继而依次往上回溯完成 Teacher 的初始化;

完成 Teacher 的初始化后,Student 的初始化就简单了,因为 map 中已经存了这个单例。
至此,Spring 循环依赖的总结分析结束,一句话来概括一下:Spring 通过将实例化后的对象提前暴露给 Spring 容器中的 singletonFactories,解决了循环依赖的问题。


理解

A 依赖 B,B 依赖 A。
构造 A 时,从一级缓存中获取不到,从二级缓存中获取不到,从三级缓存获取一个不完整的,提前曝光的 A,并且放入二级缓存,A 属性注入需要构造 B,在构造 B 时,可以从二级缓存获取 A,完成 B 的属性注入,返回 A,在完成 A 的属性注入。
Spring 中的循环依赖解决详解 - 不死码农 - 博客园

事务是什么?

事务是应用程序中一系列严密的操作,所有操作必须完成,否则在所有操作中所做的所有更改都会撤消。也就是说事务具有原子性,一个事务中的一系列操作要么全部成功,要么一个都不做。


ACID

数据库事务正确执行的 4 个基本要素。ACID:原子性(Atomicity),一致性(Correspondence),隔离性(Isolation),持久性(Durability)。

  1. 原子性:整个事务中的全部操作,要么全部完成,要么全部不完成,不可能停滞在中间某个环节。事务在中间某个过程中发生错误,会被回滚到事务开始前的状态,就像这个事务从来没有被执行过一样。
  2. 一致性:在事务开始之前,和事务结束之后,数据库的完整性约束没有没破坏。
  3. 隔离性:隔离状态执行事务,使它们好像是系统在给定时间内执行的唯一操作。如果有两个事务,运行在相同时间内,执行相同的功能,事务的隔离性将确保每一事务在系统中认为只有该事务在使用系统。这种属性有时称为串行化,为了防止事务操作间的混淆,   必须串行化或序列化请 求,使得在同一时间仅有一个请求用于同一数据。
  4. 持久性:在事务完成之后,该事务对数据所做的更改便持久的保存在数据库之中,并不会被回滚。

在 mysql 通过日志来保证 ACID 特性:

  1. A 原子性由 undo log 保证,它记录了需要会滚的日志信息,事务回滚时,撤销已经执行成功的 sql。
  2. C 一致性一般由代码层面来保证。
  3. I 隔离性由 MVCC 来保证。
  4. D 持久性由内存+redo log 保证,mysql 修改数据同时在内存和 redo log 记录这次操作,事务提交时通过 redo log 刷盘,宕机时可以从 redo log 恢复。

log 日志


并发产生的问题

总共会产生 5 种问题:第一类丢失更新,第二类丢失更新,脏读,不可重复读,幻读。

  1. 第一类丢失更新:回滚丢失,撤销一个事务的时候,把另一个事务已提交的数据覆盖了。
  2. 第二类丢失更新:覆盖丢失,当两个或多个事务查询数据,每个基于自己的查询结果做修改时,其中一个会被另一个覆盖。
  3. 脏读:指事务 A 读到了事务 B 还没有提交的数据。
  4. 不可重复读:事务 A 多次读取同一数据,事务 B 在事务 A 多次读取的过程中,对数据作了更新并提交,导致事务 A 多次读取同一数据时,结果不一致。
  5. 幻读:系统管理员 A 将数据库中所有学生的成绩从具体分数改为 ABCDE 等级,但是系统管理员 B 就在这个时候插入了一条具体分数的记录,当系统管理员 A 改结束后发现还有一条记录没有改过来,就好像发生了幻觉一样,这就叫幻读。

隔离级别

事务的隔离级别就是为了解决上面的问题诞生的,隔离级别越高,并发下产生的问题就越少,但同时付出的性能消耗就越大,很多时候必须在并发性和性能之间做一个权衡。隔离级别有 4 种,但 spring 会提供 5 种:

  1. DEFAULT:默认隔离级别,每种数据库支持的事务隔离级别不一样,如果 Spring 配置事务时将 isolation 设置为这个值的话,那么将使用底层数据库的默认事务隔离级别。顺便说一句,如果使用的 MySQL,可以使用”select @@tx_isolation“来查看默认的事务隔离级别。这里的隔离级别是 spring 的隔离级别。
  2. READ_UNCOMMITTED:读未提交,即能够读取到没有被提交的数据,所以很明显这个级别的隔离机制无法解决脏读、不可重复读、幻读中的任何一种,因此很少使用。
  3. READ_COMMITED:读已提交,即能够读到那些已经提交的数据,自然能够防止脏读,但是无法限制不可重复读和幻读。
  4. REPEATABLE_READ:重复读取,即在数据读出来之后加锁,类似”select * from XXX for update”,明确数据读取出来就是为了更新用的,所以要加一把锁,防止别人修改它。REPEATABLE_READ 的意思也类似,读取了一条数据,这个事务不结束,别的事务就不可以改这条记录,这样就解决了脏读、不可重复读的问题,但是幻读的问题还是无法解决。mysql 中默认就是可重复读的隔离级别。
  5. SERLALIZABLE:串行化,最高的事务隔离级别,不管多少事务,挨个运行完一个事务的所有子事务之后才可以执行另外一个事务里面的所有子事务,这样就解决了脏读、不可重复读和幻读的问题了。

隔离级别对并发问题的解决(是表示会发生)

隔离级别脏读不可重复读幻读加锁读
READ_UNCOMMITTED
READ_COMMITED
REPEATABLE_READ
SERLALIZABLE

一般使用 READ_COMMITED,解决脏读,再通过其他办法解决不可重复读与幻读。

查看与修改

查看事务隔离级别使用 select @@tx_isolation。
修改当前会话事务隔离级别使用 SET session TRANSACTION ISOLATION LEVEL Serializable;
修改当前全局事务隔离级别使用 SET global TRANSACTION ISOLATION LEVEL Serializable;,修改全局的对所有会话有效,当前已经存在的会话不受影响。

MySQL 存储引擎 InnoDB 与 Myisam 的六大区别 | 菜鸟教程
mysql5.1 之前的默认存储引擎是 myisam,5.1 之后是 innodb。
myisam 支持空间函数,myisam 中数据和索引是分开的。
innodb 通过 MVCC 来支持高并发。

事务

InnoDB 支持事务,MyISAM 不支持。
对于 InnoDB 每一条 SQL 语言都默认封装成事务,自动提交,这样会影响速度,所以最好把多条 SQL 语言放在 begin 和 commit 之间,组成一个事务;

外键

innodb 支持外键,myisam 不支持外键,对一个有外键的 innodb 表转换为 myisam 会失败。

聚簇索引与非聚簇索引

innodb 是聚簇索引,使用 b+树作为索引结构,数据文件是和主键索引绑定在一起的,(表数据文件本身就是按 B+Tree 组织的一个索引结构),必须要有主键,通过主键索引效率很高。但是辅助索引需要两次查询,先查询到主键,然后再通过主键查询到数据。因此,主键不应该过大,因为主键太大,其他索引也都会很大。
MyISAM 是非聚集索引,也是使用 B+Tree 作为索引结构,索引和数据文件是分离的,索引保存的是数据文件的指针。主键索引和辅助索引是独立的。
也就是说:InnoDB 的 B+树主键索引的叶子节点就是数据文件,辅助索引的叶子节点是主键的值;而 MyISAM 的 B+树主键索引和辅助索引的叶子节点都是数据文件的地址指针。

行数

innodb 不保存表的具体行数,执行 count(*)时需要扫全表。
myisam 用一个字段保存了全表的行数,执行时读出次数据即可,速度很快,不可加 where 条件。

因为 InnoDB 的事务特性,在同一时刻表中的行数对于不同的事务而言是不一样的,因此 count 统计会计算对于当前事务而言可以统计到的行数,而不是将总行数储存起来方便快速查询。InnoDB 会尝试遍历一个尽可能小的索引除非优化器提示使用别的索引。如果二级索引不存在,InnoDB 还会尝试去遍历其他聚簇索引。
    如果索引并没有完全处于 InnoDB 维护的缓冲区(Buffer Pool)中,count 操作会比较费时。可以建立一个记录总行数的表并让你的程序在 INSERT/DELETE 时更新对应的数据。和上面提到的问题一样,如果此时存在多个事务的话这种方案也不太好用。如果得到大致的行数值已经足够满足需求可以尝试 SHOW TABLE STATUS。

全文索引

innodb 不支持全文索引(5.7 后支持),myisam 支持全文索引,在涉及全文索引的领域的查询上 myisam 效率更高。

innodb 支持表级锁、行级锁,myisam 支持表级锁。
InnoDB 的行锁是实现在索引上的,而不是锁在物理行记录上。潜台词是,如果访问没有命中索引,也无法使用行锁,将要退化为表锁。

主键

innodb 必须有主键,没有的话会找一个或者自动生成一个 row_id,myisam 可以没有主键。

表格

MyISAM 表格可以被压缩后进行查询操作

文件

  • Innodb:frm 是表定义文件,ibd 是数据文件
  • Myisam:frm 是表定义文件,myd 是数据文件,myi 是索引文件

Redis 有 RDB 和 AOF 两种持久化方式,持久化功能有效的避免因进程退出造成的数据丢失问题,下次重启时利用以前持久化的文件即可恢复数据。

RDB:

将数据以快照的形式保存下来。触发方式为手动触发和自动触发。

手动触发:

手动触发分别对应 save 和 bgsave 命令。

  1. save 命令:阻塞当前服务器,直到 RDB 过程完成为止,对于内存 比较大的实例会造成长时间阻塞,线上环境不建议使用。
  2. bgsave 命令:Redis 进程执行 fork 操作创建子进程,RDB 持久化过程由子 进程负责,完成后自动结束。阻塞只发生在 fork 阶段,一般时间很短。

自动触发:

  1. 使用 save 相关配置,如“save m n”。表示 m 秒内数据集存在 n 次修改 时,自动触发 bgsave。
  2. 如果从节点执行全量复制操作,主节点自动执行 bgsave 生成 RDB 文件并发送给从节点。(主从复制时)
  3. 执行 debug reload 命令重新加载 Redis 时,也会自动触发 save 操作。
  4. 默认情况下执行 shutdown 命令时,如果没有开启 AOF 持久化功能则 自动执行 bgsave。

优点:

  1. RDB 是一个紧凑压缩的二进制文件,代表 Redis 在某个时间点上的数据 快照。非常适用于备份,全量复制等场景。比如每 6 小时执行 bgsave 备份, 并把 RDB 文件拷贝到远程机器或者文件系统中(如 hdfs),用于灾难恢复。
  2. Redis 加载 RDB 恢复数据远远快于 AOF 的方式。

缺点:

  1. RDB 方式数据没办法做到实时持久化/秒级持久化。因为 bgsave 每次运 行都要执行 fork 操作创建子进程,属于重量级操作,频繁执行成本过高。
  2. RDB 文件使用特定二进制格式保存,Redis 版本演进过程中有多个格式 的 RDB 版本,存在老版本 Redis 服务无法兼容新版 RDB 格式的问题。

AOF:

以独立日志的方式,记录每次写命令,重启时再重新执行 AOF 文件中的命令达到恢复数据的目的。AOF 的主要作用 是解决了数据持久化的实时性,目前已经是 Redis 持久化的主流方式。
开启 AOF 功能需要设置配置:appendonly yes,默认不开启。

AOF 的工作流程操作:

命令写入 (append)、文件同步(sync)、文件重写(rewrite)、重启加载 (load):

  1. 所有的写入命令会追加到 aof_buf(缓冲区)中。不直接写到磁盘文件是为了提升效率,是磁盘负载不成为性能瓶颈。还有另一个好处,Redis 可以提供多种缓冲区同步硬盘的策略,在性能和安全性方面做出平衡。
  2. AOF 缓冲区根据对应的策略向硬盘做同步操作。
  3. 随着 AOF 文件越来越大,需要定期对 AOF 文件进行重写,达到压缩的目的。
  4. 当 Redis 服务器重启时,可以加载 AOF 文件进行数据恢复。

重写后的 AOF 文件为什么可以变小?

执行 bgrewriteaof 命令可以重写 AOF 文件。

  1. 进程中已经超时的文件不在写入。
  2. 旧的 AOF 文件含有无效命令,重写使用进程内数据直接生成,这样新的 AOF 文件只保留最终数据的写入命令。
  3. 多条写命令可以合并为一个,为了防止单条命令过大造成客户端缓冲区溢 出,对于 list、set、hash、zset 等类型操作,以 64 个元素为界拆分为多条。

AOF 重写降低了文件占用空间,除此之外,另一个目的是:更小的 AOF 文件可以更快地被 Redis 加载。
手动触发:直接调用 bgrewriteaof 命令。
自动触发:根据 auto-aof-rewrite-min-size 和 auto-aof-rewrite-percentage 参数确定自动触发时机。自动触发时机=aof_current_size>auto-aof-rewrite-minsize&&(aof_current_size-aof_base_size)/aof_base_size>=auto-aof-rewritepercentage。

启动流程:

  1. AOF 持久化开启且存在 AOF 文件时,优先加载 AOF 文件。
  2. AOF 关闭或者 AOF 文件不存在时,加载 RDB 文件。
  3. 加载 AOF/RDB 文件成功后,Redis 启动成功。
  4. AOF/RDB 文件存在错误时,Redis 启动失败并打印错误信息。

binlog

binlog 为二进制格式的数据,用于备份数据库的数据。
用于复制,在主从复制中,从库利用主库上的 binlog 进行重播,实现主从同步。 
用于数据库的基于时间点的还原。

binlog 是属于 MySQL Server 层面的,又称为归档日志,属于逻辑日志,是以二进制的形式记录的是这个语句的原始逻辑,依靠 binlog 是没有 crash-safe 能力的。

三种模式:

  1. statement:基于 SQL 语句的模式,某些语句中含有一些函数,例如 UUID NOW 等在复制过程可能导致数据不一致甚至出错。
  2. row:基于行的模式,记录的是行的变化,很安全。但是 binlog 的磁盘占用会比其他两种模式大很多,在一些大表中清除大量数据时在 binlog 中会生成很多条语句,可能导致从库延迟变大。
  3. mixed:混合模式,根据语句来选用是 statement 还是 row 模式。

undo log

undo log 是回退日志,提供回退操作。是逻辑日志。
undo 用来回滚行记录到某个版本。undo log 一般是逻辑日志,根据每行记录进行记录。
保存了事务发生之前的数据的一个版本,可以用于回滚,同时可以提供多版本并发控制下的读(MVCC),也即非锁定读。

缺陷:
  • 事务提交前需要将 Undo Log 写磁盘(提供可回滚功能,保证原子性),这会造成多次磁盘 IO(不考虑各种优化例如 SQL 解析优化等),这些 IO 算是顺序 IO;
  • 事务提交后需要将数据立即更新到数据库中,这又会造成至少一次磁盘 IO,这是一次随机 IO。
解决:

事务提交后如果能够将数据缓存一段时间,而不是立即更新到数据库,就能将一次次的随机 IO 打包变成一次 IO,可以提高性能。但是这样就会丧失事务的持久性。因此引入了另外一种机制来实现持久化,即 redo log。redo 解决的问题之一就是事务执行过程中的强制刷脏。
在事务提交前,只要将 Redo Log 持久化即可,不需要将数据持久化。当系统崩溃时,虽然数据没有持久化,但是 Redo Log 已经持久化。系统可以根据 Redo Log 的内容,将所有数据恢复到最新的状态。


redo log

redo log 是重做日志,提供前滚操作。是物理日志。

redo log 通常是物理日志,记录的是数据也页物理修改,而不是某一行或某几行修改成怎样怎样,它用来恢复提交后的物理数据页(恢复数据页,且只能恢复到最后一次提交的位置)。

确保事务的持久性。防止在发生故障的时间点,尚有脏页未写入磁盘,在重启 mysql 服务的时候,根据 redo log 进行重做,从而达到事务的持久性这一特性。

redo log 是 InnoDB 存储引擎层的日志,又称重做日志文件,用于记录事务操作的变化,记录的是数据修改之后的值,不管事务是否提交都会记录下来。在实例和介质失败(media failure)时,redo log 文件就能派上用场,如数据库掉电,InnoDB 存储引擎会使用 redo log 恢复到掉电前的时刻,以此来保证数据的完整性。

在一条更新语句进行执行的时候,InnoDB 引擎会把更新记录写到 redo log 日志中,然后更新内存,此时算是语句执行完了,然后在空闲的时候或者是按照设定的更新策略将 redo log 中的内容更新到磁盘中,这里涉及到 WAL 即 Write-Ahead-logging 技术,他的关键点是先写日志,再写磁盘。

有了 redo log 日志,那么在数据库进行异常重启的时候,可以根据 redo log 日志进行恢复,也就达到了 cash-safe。
redo log 日志的大小是固定的,即记录满了以后就从头循环写。


关于事务:

将 undo log 和 redo log 结合起来,提升效率。

要从两个角度来优化,一个就是尽可能减少写入硬盘(即多个事务合并成一次落盘),另一个就是尽量顺序写入(HDD 的随机写入性能远差于顺序写入)。

Undo 记录某数据被修改前的值,可以用来在事务失败时进行 rollback;
Redo 记录某数据块被修改后的值,可以用来恢复未写入 data file 的已成功事务更新的数据。

  • Redo Log 保证事务的持久性。
  • Undo Log 保证事务的原子性(在 InnoDB 引擎中,还用 Undo Log 来实现 MVCC)。

比如某一时刻数据库 DOWN 机了,有两个事务,一个事务已经提交,另一个事务正在处理。数据库重启的时候就要根据日志进行前滚及回滚,把已提交事务的更改写到数据文件,未提交事务的更改恢复到事务开始前的状态。即,当数据 crash-recovery 时,通过 redo log 将所有已经在存储引擎内部提交的事务应用 redo log 恢复,所有已经 prepared 但是没有 commit 的 transactions 将会应用 undo log 做 roll back。

单独使用:

  1. 假设只有 undo-log:那么就必须保证提交前刷脏完成,否则宕机时有些修改就在内存中丢失了,破坏了持久性。(这样带来了一个问题,那就是前面提到的性能差)。
  2. 假设只有 redo-log:那么就不能随心所欲地在事务提交前刷脏,即无法支持大事务。(假如、某张表有 100 亿的 8 字节整数数据,就算不考虑其他东西带来的损耗,光 update 整张表至少要消耗 80G 的内存。如前所述,有了 undo-log,就可以随便刷脏。)

区别:

  1. 层次不同;redo/undo 是 innodb 引擎层维护的,而 binlog 是 mysql server 层维护的,跟采用何种引擎没有关系,记录的是所有引擎的更新操作的日志记录。
  2. 记录内容不同;记录内容不同。redo/undo 记录的是 每个页/每个数据 的修改情况,属于物理日志+逻辑日志结合的方式(redo log 是物理日志,undo log 是逻辑日志)。binlog 记录的都是事务操作内容,记录的是更新语句的原始逻辑。
  3. 记录时机不同;redo/undo 在 事务执行过程中 会不断的写入,而 binlog 是在 事务最终提交前 写入的。binlog 什么时候刷新到磁盘跟参数 sync_binlog 相关。
  4. redo log 是循环写,日志空间大小固定;binlog 是追加写,是指一份写到一定大小的时候会更换下一个文件,不会覆盖。
  5. binlog 可以作为恢复数据使用,主从复制搭建,redo log 作为异常宕机或者介质故障后的数据恢复使用。

理解:

原有逻辑:
新逻辑:
原有的每次提交事务前同步数据并同步 undolog,会造成大量的磁盘 IO,特别是同步数据是随机 IO,效率低下。
在新逻辑中,更改数据的时候同步更改 redolog,将更改的数据缓存在缓冲区中,提交前,同步 redolog 和 undolog,因为 log 同步都是顺序 IO,提升效率。即使断电等数据丢失,未提交的数据在 undolog 中,可以进行 rollback,而已经提交的数据在 redolog 中,可以根据其恢复。而对于更改的数据,间隔时间过后在进行同步,减少数据同步次数来提升效率。

单机部署

单机模式的 redis 非常简单,你只需要启动一个单一的节点就可以了,安装过程不超过 5 分钟。
通过 redis-benchmark 测试简单的命令,QPS 可达到 10w 以上,不得不说非常的让人惊艳了。
单机模式的问题也非常明显。缺乏高可用的机制!
假如 redis 进程死了,进程就只能够穿透到底层的数据库中,对业务来说非常的危险。如果你把 redis 当作数据存储来用,情况会更加严重,甚至会丢失数据。


主从复制

所以最基本的 redis 部署,都会增加一个或者多个 slave(现在叫 replication)。
当主 redis 发生问题的时候,能够选取一个 slave 顶上去。
非常可惜的是,这种模式和传统的 MySQL 主从一样,切换起来比较蛋疼,需要借助外部的工具,比如keepalived等辅助进行切换,部署和维护难度直接飙。
keepalived 是一个基于 vrrp 协议来实现的高可用方案,通过 IP 漂移实现高可用。从描述上就可以看出它需要网络管理员的参与,和我们轻量级的 redis 背道而驰。


哨兵机制

哨兵模式就是使用额外的进程来替换 keepalived 的功能,对 redis 进程的存活性进行判断。在哨兵模式下,一旦主节点宕机,从节点作为主节点的备份可以随时顶上来。
但哨兵模式一个最大的问题,就是哨兵的数量太多,至少需要 3 个节点。
对 redis 进行仲裁的时候,需要 n/2+1 个节点投票才能确认,这也是分布式系统的一般做法 (quorum)。和 Zookeeper 类似,哨兵节点做成奇数个,是非常合适的。
哨兵模式可以通过 sentinal moniter 配置同时检测多套集群,在集群数量适中的时候,还是比较好用的。
但哨兵模式有很多隐藏的坑,比如哨兵的启动,必须在 master 存活的情况下才能正常运行;另外,如果你的 redis 配置文件中使用 rename 屏蔽了一些危险命令时,哨兵也不能够启动。
客户端在连接 redis 的时候,就不能再直接连接 redis 的实例,它需要从哨兵转上一圈,以便获取一些变更信息。


集群模式

集群模式可以说是这里面最优雅的方式了。你只需要部署多个对等的 redis 节点,然后使用客户端命令进行组群就可以了。

1
2
3
ip=192.169.0.23
./bin/redis-cli --cluster create $ip:7001 $ip:7002 $ip:7003 $ip:7004 $ip:7005 $ip:7006 --cluster-replicas 1
复制代码

它对节点的要求也是比较多的,一般是采用 6 个节点,三主三从。当节点超过 10 个,它的协调性就不那么灵活了,所以单集群的存储和性能上限也很快能到达。
集群模式的一些缺点很隐蔽。它的服务端节点倒是非常稳定了,但有些命令会严重影响性能。比如 mget,pipeline 等。它们需要把请求分散到多个节点执行、再聚合。节点越多,性能越低。