面试allInOne

Java 集合

Collection 和 Map,是集合框架的接口

Collection 的子接口有 List 和 Set

List

有序列表,允许存放重复的元素

ArrayList

底层是 Object 数组,查询快,增删慢,轻量级,线程不安全。

LinkedList

底层是双向循环链表,增删快,查询慢,线程不安全。

在此链表上每一个数据节点都由三部分组成:前指针(指向前面的节点),后指针(指向后面的节点),数据。

最后一个节点的后指针指向第一个节点的前指针,形成循环链表。

利用 LinkedList 实现栈(Stack)、队列(Queue)、双端队列(Double-Ended Queue,它具有 addFirst()、addLast()、getFirst()、getLast()、removeFirst()、removeLast()等方法)等。

经常用在增删操作较多而查询较少的情况下:

队列和堆栈
  • 队列:先进先出的数据结构
  • 栈:后进先出的数据结构(注意:使用栈的时候一定不能提供方法让不是最后一个元素的元素获得出栈的机会)
用 LinkedList 实现队列

队列(Queue)是限定所有的插入只能在表的一端进行,而所有的删除都在表的另一端进行的线性表,表中允许插入的一端称为队尾(Rear),允许删除的一端称为队首(Front)。

队列的操作是先进先出的原则进行的。

队列的物理存储可以用顺序存储结构,也可以用链式存储结构。

用 LinkedList 实现栈

栈(Stack)也是一种特殊的线性表,是一种后进先出的结构。

栈是限定仅在表尾进行插入和删除运算的线性表,表尾称为栈顶(Top),表头称为栈底(Bottom)。

栈的物理存储结构可以用顺序表存储结构,也可以用链式存储结构。

Vector

数组实现,重量级,线程安全。

CopyOnWriteArrayList

支持高效率并发且是线程安全的,读操作无锁的 ArrayList。

所有可变操作都是通过对底层数组进行一次新的复制来实现。

适合使用在读操作远远大于写操作的场景里,比如缓存。它不存在扩容的概念,每次写操作都要复制一个副本,在副本的基础上修改之后改变 Array 引用,因为写操作要大面积复制数组,所以性能很差

由于写操作的时候,需要拷贝数组,会消耗内存。如果原数组的内容比较多的情况下,可能导致 young gc 或者 full gc。

不能用于实时读的场景,像拷贝数组、新增元素都需要时间,所以调用一个 set 操作后,读取到的数据可能还是旧的,只能做到最终一致性

ArrayList 和 LinkedList 的区别

  • ArrayList 底层是基于动态数组的数据结构,LinkedList 底层是基于双向链表的数据结构
  • 对于 ArrayList 和 LinkedList 而言,在列表末尾增加一个元素的开销都是固定的。对于 ArrayList 而言,主要是在内部数组中增加一项,指向所添加的元素,偶尔可能导致对数组重新进行分配;而对于 LinkedList 而言,这个开销是统一的,分配一个内部 Entry 对象
  • 在 ArrayList 中间插入或者删除一个元素意味着这个列表中剩余的元素都会被移动,而在 LinkedList 中间插入或者删除一个元素的开销是固定的
  • 对于随机访问,ArrayList 性能优于 LinkedList,时间复杂度 O(1),因为 LinkedList 要移动指针,时间复杂度 O(n)
  • 对于新增和删除操作,LinkedList 比较占优,因为 ArrayList 要移动数组元素
  • LinkedList 不支持高效率的随机元素访问
  • ArrayList 的空间浪费主要体现在 list 列表的结尾预留一定的容量空间。而 LinkedList 的空间花费则体现在它的每个元素都要消耗相当的空间

Set

无序集合,不允许存放重复的元素,允许使用 null

对 add(),equals()和 hashCode()方法添加了限制

HashSet

HashSet 由 HashMap 实现,value 都是 new Object()

HashSet 直接实现了 Set 接口,其底层其实是包装了一个 HashMaps 去实现的,HashSet 采用 hashCode 算法来存取集合中的元素,因此具有比较好的读取和查找性能

不仅不能保证元素插入的顺序,而且元素在以后的顺序中也可能变化(这是由于 HashSet 按 HashCode 存储元素决定的,对象变化则可能导致 HashCode 变化)

HashSet 是非线程安全的

HashSet 如何能达到不存在重复元素的目的?

“键”就是我们要存储的元素,而“值”是一个常量,而“键”在 Map 中是不能重复的,这就保证了我们存入 Set 中的所有元素都不重复

LinkedHashSet

Set->HashSet->LinkedHashSet

LinkedHashSet 是 HashSet 的子类

LinkedHashMap 实现,存储的数据是有序的

TreeSet

Set->SortedSet->TreeSet

TreeSet 实现了 SortedSet 接口,这是一种排序的 Set 集合,底层是由 TreeMap 实现的,本质上是一个红黑树,相对于 HashSet,TreeSet 额外提供了一些按排序位置访问元素的方法,比如 first()、last()、higher()、lower()、subSet()、headSet()、tailSet()等

TreeSet 的排序分两种类型,一种是自然排序,另一种是定制排序

  • 自然排序:调用 compareTo 方法比较元素大小,然后按照升序排序,所以自然排序中的元素都必须实现了 Comparable 接口,否则会抛出异常。判断元素是否重复也是调用元素的 compareTo 方法,如果返回 0 则是重复元素
  • 定制排序:在集合中写排序规则,需要关联一个 Comparator 对象,由 Comparator 提供排序逻辑

EnumSet

EnumSet 是专门为枚举类型设计的集合,因此集合元素必须是枚举类型,否则会抛出异常,EnumSet 也是有序的,其顺序就是 Enum 类内元素定义的顺序,EnumSet 的存取速度非常快,批量操作的顺序也很快。

EnumSet 主要提供的方法有 allOf()、complementOf()、copyOf()、noneOf()、of()、range()等,EnumSet 并没有提供任何构造函数,要创建一个 EnumSet 集合对象,只需要调用 allOf()等方法。

CopyOnWriteArraySet

基于 CopyOnWriteArrayList 实现,线程安全的

Map

集合框架的第二类接口树,提供了一组键值对的映射。其中存储的每个对象都有一个相应的关键字(key),关键字决定了对象在 Map 中的存储位置。关键字应该是唯一的,每个 key 只能映射一个 value

HashMap

键值对,key 不能重复,但是 value 无限制,允许 null 的 key 或者 value

最常用的 map,根据 key 的 hashCode 存取数据,具有很快的访问速度

最多只允许一条记录的 key 是 null,value 不限

不支持线程同步,多并发变更 HashMap 结构会导致数据不同步或者抛出异常

可以用 Collections.synchronizedMap(HashMap)方法使 HashMap 具有同步能力

底层数据实现

jdk1.8:数组+单向链表+红黑树,链表插入数据时采用尾插法,链表长度大于 8 且数组长度大于等于 64 时,链表转为红黑树,链表长度小于 6 时转回链表

当链表长度为 6 时,查询的时间复杂度为 n/2=3,红黑树 log(6)=2.6 时;为 8 时,查询的时间复杂度为 n/2=4,红黑树 log(8)=3,此时红黑树效率更优

链表转换为树之前,还会判断数组长度是否大于 64,这是为了避免在哈希表建立初期,多个键值对恰好被放入了同一链表中而导致不必要的变化。如果链表长度大于 8 但是数据元素个数小于 64,只会触发扩容

HashMap 初始化

默认数组长度是 16,负载因子是 0.75,如果传入初始大小 k,初始化大小为离 k 最近的大于 k 的 2 的整数次幂,如果传入 10,初始大小为 16

HashMap 插入流程

1.判断数组是否为空,为空则先初始化数组

2.计算 key 的 hash=((null == key)?0:key.hashCode()^k.hashCode() >>> 16),通过(n-1)&hash 计算当前 key 应存放在数组中的下表 index(n 为数组长度)

3.查看 table[index]是否存在数据,没有数据就构造一个 Node 节点存在到 table[index]中

4.存在数据说明发生了 hash 冲突,继续判断 key 是否相等,相等就用新的 value 替换原 value,将旧值返回

5.如果 key 不相等,判断当前节点类型是否为树形节点,如果是树形节点,构造一个 TreeNode 插入红黑树中

6.如果不是树形节点,继续遍历链表判断 Node 后面的节点是否为空,不为空则判断 key 是否相等,如果相等则结束遍历,用新的 value 替换旧的 value,将旧值返回

7.如果遍历到链表尾部,构造 Node 节点放入链表中。判断链表长度是否大于 8,大于 8 的话将链表转换为红黑树。

8.插入完成之后判断当前节点数是否大于阈值(即数组长度*负载因子),如果大于阈值,开始将数组扩容为原数组的二倍。

HashMap 删除流程
HashMap 扩容

ConcurrentHashMap

1.7

Segment 数组+HashEntry,每个 Segment 元素存储的是 HashEntry 数组+链表

2021-08-04-14-22-0320210804142203

  • Segment 数组的意义就是将一个大的 table 分割成多个小的 table 来进行加锁,也就是锁分离技术
  • ConcurrentHashMap 分段锁对整个桶数组进行了分割分段(Segment),每一把锁只锁容器其中一部分(Segment)数据,多线程访问容器里不同 Segment 的数据,就不会存在锁竞争,提高并发访问率。
  • Segment 是一种可重入锁(ReentrantLock),在 ConcurrentHashMap 中扮演锁的角色,HashEntry 则用于存储键值对数据。
  • 一个 Segment 里面包含一个 HashEntry 数组,每个 HashEntry 是一个链表结构的元素,每个 Segment 守护着一个 HashEntry 数组里的元素,当对 HashEnrty 数组的数据进行修改时,必须先获得与它对应的 Segment 锁。
初始化

ConcurrentHashMap 初始化时,计算出 Segment 数组的大小 ssize 和每个 Segment 中的 HashEntry 数组的大小 cap,并初始化 Segment 数组的第一个元素。其中 ssize 的大小为 2 的幂次方,默认为 16,cap 大小也是 2 的幂次方,最小值为 2,最终结果根据初始化容量 initialCapacity 进行计算,其中 Segment 在实现上继承了 ReentrantLock,这样就自带了锁的功能。

put

当执行 put 方法插入数据时,根据 key 的 hash 值,在 Segment 数组中找到对应的位置,如果相应位置的 Segment 还未初始化,则通过 CAS 进行赋值,接着执行 Segment 对象的 put 方法通过加锁机制插入数据。

  • 1.线程 A 执行 tryLock()方法成功获取锁,则把 HashEntry 对象插入到相应的位置
  • 2.线程 B 获取锁失败,则执行 scanAndLockForPut()方法,在此方法中会通过重复执行 tryLock()方法尝试获取锁,在多处理器环境下,重复次数为 64,单处理器重复次数为 1,当执行 tryLock()方法的次数超过上限时,则执行 lock()方法挂起线程 B
  • 3.当线程 A 完成插入操作时,会通过 unlock()方法释放锁,接着唤醒线程 B 继续执行
size

因为 ConcurrentHashMap 是可以并发插入数据的,所以在准确计算元素时存在一定的难度,一般的思路是统计每个 Segment 对象中元素的个数,然后进行累加,但是这种方式计算出来的结果并不一定是准确的,因为在计算后面的 Segment 时,已经计算过的 Segment 同时可能有数据的插入或者删除操作,在 1.7 的实现中采用了如下方式:

先采用不加锁的方式,连续计算元素的个数,最多计算 3 次

  • 1.如果前后两次计算结果相同,则说明计算出来的元素个数是准确的
  • 2.如果前后两次计算结果都不同,则给每个 Segment 加锁,再计算一次元素的个数
1.8

Node 数组+链表+红黑树

  • 底层依然采用数组+链表+红黑树的存储结构
  • 对每个数组元素(Node)加锁
  • 采用 Node+CAS+synchronized 来保证并发安全
put 流程

只有第一次执行 put 方法时,才会调用 initTable()初始化 Node 数组

  • 1.根据 key 的 hashCode 值,计算 hash=(k.hashCode^(k.hashCode >>> 16))&0x7fffffff,在 Node 数组中找到相应的下标 index=(n-1)&hash,n 为数组长度
  • 2.如果对应位置的 Node 还未初始化,则构造一个 Node,通过 CAS 插入相应的位置,结束循环
  • 3.如果对应位置的 Node 不为 null,且对应 Node 的 hash 值为 MOVED(-1),说明 table 正在扩容,当前线程会帮助扩容,然后获取扩容之后的 table 重新开始循环
  • 4.如果相应位置的 Node 不为空且 Node 不处于移动状态,则对该节点加 synchronized 锁,防止并发出现的问题
  • 5.如果目标位置上的元素依旧为加锁之前获取到的节点,并且目标节点的 hash 值大于等于 0,则是链式结构
  • 6.将 binCount 置为 1,然后循环链表,每遇到一个元素,binCount 增加 1,如果目标节点 key 的 hash 与要存入的 key 的 hash 相等并且 key 相等,则替换旧值,结束循环;否则循环到链表尾部,构造新的 Node 节点插入链表尾部。
  • 7.如果该节点是 TreeBin 类型的节点,说明是红黑树操作,将 binCount 置为 2,通过 putTreeVal 方法往红黑树中插入节点
  • 8.如果 binCount 不为 0,说明 put 操作对数据产生了影响,如果 binCount>=8,则通过 treeifyBin 方法转化为红黑树,如果 oldVal 不为空,说明是一次更新操作,没有对元素个数产生影响,则直接返回旧值,结束循环
  • 9.如果插入的是一个新的节点,则执行 addCount()方法尝试更新元素个数 baseCount(),判断是否需要对 table 进行扩容
size 流程

1.8 中使用一个 volatile 类型的变量 baseCount 记录元素的个数,当插入新数据或删除数据时,会通过 addCount()方法更新 baseCount。

  • 1.初始化时 counterCells 为空,在并发量很高时,如果存在两个线程同时执行 CAS 修改 baseCount 值,则失败的线程会继续执行方法体中的逻辑,使用 counterCell 记录元素个数的变化
  • 2.如果 counterCell 数组 counterCells 为空,调用 fullAddCount()方法进行初始化,并插入对应的记录数,通过 CAS 设置 cellsBusy 字段,只有设置成功的线程才能初始化 counterCells 数组
  • 3.如果通过 CAS 设置 cellsBusy 字段失败的话,则继续尝试通过 CAS 修改 baseCount 字段,如果修改 baseCount 字段成功的话,就退出循环,否则继续循环插入 counterCell 对象

所以在 1.8 中的 size 实现比 1.7 简单的多,因为元素个数保存在 baseCount 中,部分元素的变化个数保存在 counterCells 数组中,通过累加 baseCount 和 counterCells 数组中的数量,即可得到元素的总个数

TreeMap

对 key 排序好的 Map,key 要实现 comparable 接口或者传入 comparator

LinkedHashMap

维护着一个运行于所有条目的双向链表,存储的数据是有序的,记录数据存入的顺序

HashTable

线程安全的 Map,不允许 null 的 key 或 value,实现线程安全的方法是所有的方法加 synchronized 同步锁

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24

public synchronized V put(K key, V value) {
// Make sure the value is not null
if (value == null) {
throw new NullPointerException();
}

// Makes sure the key is not already in the hashtable.
Entry<?,?> tab[] = table;
int hash = key.hashCode();
int index = (hash & 0x7FFFFFFF) % tab.length;
@SuppressWarnings("unchecked")
Entry<K,V> entry = (Entry<K,V>)tab[index];
for(; entry != null ; entry = entry.next) {
if ((entry.hash == hash) && entry.key.equals(key)) {
V old = entry.value;
entry.value = value;
return old;
}
}

addEntry(hash, key, value, index);
return null;
}

Properties

key 和 value 都是 String 类型,用来读取配置文件

HashMap 和 HashTable 的区别

  • 都实现了 Map 接口。
  • HashMap 是非 synchronized 的,并可以接受 null(HashMap 可以接受为 null 的键值(key)和值(value),而 Hashtable 则不行)
  • HashMap 的迭代器是 fail-fast 而 HashTable 的 enumerator 迭代器不是。所以当有其它线程改变了 HashMap 的结构(增加或者移除元素),将会抛出 ConcurrentModificationException,但迭代器本身的 remove()方法移除元素则不会抛出 ConcurrentModificationException 异常。但这并不是一个一定发生的行为,要看 JVM。这条同样也是 Enumeration 和 Iterator 的区别。
  • 单线程下 HashTable 比 HashMap 要慢。
  • HashMap 不能保证随着时间推移 Map 中的元素次序是不变的。

ConcurrentHashMap 和 HashTable 的区别

ConcurrentHashMap 结合了 HashMap 和 HashTable 二者的优势,HashTable 每次同步执行都要锁住整个结构,ConcurrentHashMap 锁的方式是细粒度的。

为什么 HashMap 引入红黑树而不是其他树

为什么不用二叉排序树
  • 二叉排序树在添加元素的时候极端情况下会出现线性结构
  • 由于二叉树左子树所有节点的值均小于根节点的值这一特点,如果我们添加的元素都比根节点小,会导致左子树线性增长,这样就失去了用树型结构替换链表的初衷,导致查询时间增长
为什么不用跳表
为什么不用平衡二叉树(AVL)
  • 红黑树不追求“完全平衡”,即不像 AVL 树那样要求节点的|balFact|<=1,它只要求部分达到平衡,但是提出了为节点增加颜色,红黑树用非严格的平衡来换取增删节点的时候旋转次数的降低,任何不平衡都会在 3 次之内解决;而 AVL 是严格平衡树,因此在增加和删除节点的时候,根据不同情况,旋转的次数可能比红黑树要多
  • AVL 更平衡,结构上更加直观,时间效能针对读取而言更高;维护稍慢,空间开销较大
  • 红黑树读取略逊色于 AVL,维护强于 AVL,空间开销与 AVL 类似,内容多时略优于 AVL
  • 就插入节点导致树失衡的情况,AVL 和 RBT 都是最多两次树旋转来实现复衡 reblance,旋转的量级是 O(1);删除节点导致失衡,AVL 需要维护从被删除节点到根节点 root 这条路径上所有节点的平衡,旋转的量级为 O(log(N)),而 RBT 最多只需要旋转 3 次实现复衡,只需 O(1),所以说 RBT 删除节点的 rebalance 效率更高,开销更小
  • AVL 树的结构相对于 RBT 更为平衡,插入和删除引起失衡,RBT 效率更高;当然由于 AVL 高度平衡,因此 AVL 搜索的效率更高
  • 针对插入和删除节点导致失衡后 rebalance 操作,红黑树能够提供一个比较“便宜”的解决方案,降低开销,是对 search、insert 以及 delete 效率的折中。总体来说,RBT 的统计性能高于 AVL
  • 故引入 RBT 是功能、性能、空间开销的折中结构
  • 基本上主要的几种平衡树看来,红黑树有良好的稳定性和完整的功能,性能表现也不错,综合实力强

多线程

线程池

为什么要使用线程池

  • 管理线程,避免增加创建线程和销毁线程的系统资源消耗
  • 提高响应速度
  • 资源重复利用

线程池参数详解

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
public ThreadPoolExecutor(int corePoolSize,
int maximumPoolSize,
long keepAliveTime,
TimeUnit unit,
BlockingQueue<Runnable> workQueue,
ThreadFactory threadFactory,
RejectedExecutionHandler handler) {
if (corePoolSize < 0 ||
maximumPoolSize <= 0 ||
maximumPoolSize < corePoolSize ||
keepAliveTime < 0)
throw new IllegalArgumentException();
if (workQueue == null || threadFactory == null || handler == null)
throw new NullPointerException();
this.acc = System.getSecurityManager() == null ?
null :
AccessController.getContext();
this.corePoolSize = corePoolSize;
this.maximumPoolSize = maximumPoolSize;
this.workQueue = workQueue;
this.keepAliveTime = unit.toNanos(keepAliveTime);
this.threadFactory = threadFactory;
this.handler = handler;
}

这些参数都通过 volatile 修饰

1
2
3
4
5
6
7
8
9
10
public class ThreadPoolExecutor extends AbstractExecutorService {
private final BlockingQueue<Runnable> workQueue;
private volatile ThreadFactory threadFactory;
private volatile RejectedExecutionHandler handler;
private volatile long keepAliveTime;
// 是否允许核心线程被回收
private volatile boolean allowCoreThreadTimeOut;
private volatile int corePoolSize;
private volatile int maximumPoolSize;
}
  • corePoolSize:核心线程数
    • 线程池维护的最小线程数量,核心线程创建后不会被回收(注意:设置 allowCoreThreadTimeOut=true 后,空闲的核心线程超过存活时间也会被回收)
    • 大于核心线程数的线程,在空闲时间超过 keepAliveTime 后会被回收
    • 线程池刚刚创建时,里面没有一个线程,当调用 execute()方法添加一个任务时,如果正在运行的线程数量小于 corePoolSize,则马上创建新线程去执行这个任务
  • maximumPoolSize:最大线程数
    • 线程池允许创建的最大的线程数量
    • 当添加一个任务时,核心线程数已满,阻塞队列已满,存活的线程数量还没有达到最大线程数,创建一个新线程并执行任务。
  • keepAliveTime:空闲线程存活时间
    • 当一个线程的空闲时间大于 keepAliveTime,就会被回收
    • 可被回收的线程:
      • 设置了 allowCoreThreadTimeOut=true 的核心线程
      • 大于核心线程数的线程(非核心线程)
  • unit:keepAliveTime 的时间单位
  • workQueue:工作队列
    • 存放待执行任务的队列:当提交的任务数超过核心线程数大小之后,再提交的任务就存放在工作队列,任务调度时再从队列中取出任务,它仅仅用来存放被 execute()方法提交的 Runnable 任务。工作队列实现了 BlockingQueue 接口。
  • threadFactory:线程工厂。创建线程的工厂,可以设定线程名称、线程编号等。
  • handler:拒绝策略。可以自定义拒绝策略,拒绝策略需要实现 RejectedExecutionHandler 接口

线程池核心执行流程

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
39
public void execute(Runnable command) {
if (command == null)
throw new NullPointerException();
/*
* Proceed in 3 steps:
*
* 1. If fewer than corePoolSize threads are running, try to
* start a new thread with the given command as its first
* task. The call to addWorker atomically checks runState and
* workerCount, and so prevents false alarms that would add
* threads when it shouldn't, by returning false.
*
* 2. If a task can be successfully queued, then we still need
* to double-check whether we should have added a thread
* (because existing ones died since last checking) or that
* the pool shut down since entry into this method. So we
* recheck state and if necessary roll back the enqueuing if
* stopped, or start a new thread if there are none.
*
* 3. If we cannot queue task, then we try to add a new
* thread. If it fails, we know we are shut down or saturated
* and so reject the task.
*/
int c = ctl.get();
if (workerCountOf(c) < corePoolSize) {
if (addWorker(command, true))
return;
c = ctl.get();
}
if (isRunning(c) && workQueue.offer(command)) {
int recheck = ctl.get();
if (! isRunning(recheck) && remove(command))
reject(command);
else if (workerCountOf(recheck) == 0)
addWorker(null, false);
}
else if (!addWorker(command, false))
reject(command);
}

2021-08-04-19-31-1420210804193114

  • 提交任务,判断核心线程是否已满,未满则创建新的核心线程执行任务
  • 如果核心线程池已满,判断工作队列是否已满,未满则将任务存入队列
  • 如果工作队列已满,判断非核心线程数是否已满,未满则创建非核心线程执行任务
  • 如果非核心线程已满,执行拒绝策略

线程生命周期

2021-08-04-19-37-1920210804193718

  • NEW:创建后尚未启动的线程处于此状态
  • RUNNABLE:包括操作系统线程状态中的 RUNNING 和 READY,也就是线程可能处于执行中状态,也可能正在等待 CPU 为之分配执行时间
  • WAITING:无限期等待状态,处于此状态的线程不会被分配 CPU 执行时间,需要等到被其他线程显式唤醒(notify、notifyAll)。进入此状态的方式主要包括:
    • 没有显式设置 timeout 的 Object.wait()
    • 没有显式设置 timeout 的 Thread.join()
    • LockSupport.park()
  • TIMED_WAITING:有限期等待状态,处于此状态的线程不会被分配 CPU 执行时间,在一定时间之后会自动唤醒。进入此状态的方式主要包括:
    • Thread.sleep(timeout)
    • Object.wait(timeout)
    • Thread.join(timeout)
    • LockSupport.parkNanos(timeout)
    • LockSupport.parkUnit(timeout)
  • BLOCKED:阻塞状态,处于此状态的线程不会被分配 CPU 执行时间。发生情况:
    • 竞争对象监视器锁
    • Object.wait()结束后重新竞争对象监视器控制权
  • TERMINATED:终止状态,表示当前线程执行完毕,生命周期结束。

线程池四种拒绝策略

  • AbortPolicy(弃任务并抛出 RejectedExecutionException 异常,默认策略)
  • DiscardPolicy(直接丢弃任务)
  • DiscardOldestPolicy(丢弃队列里最老的任务,将当前任务继续存入队列)
  • CallerRunsPolicy(由调用线程池所在的线程执行任务)

几种工作阻塞队列

  • ArrayBlockingQueue(用数组实现的有界阻塞队列,按 FIFO 排序)
  • LinkedBlockingQueue(基于链表结构的阻塞队列,按 FIFO 排序,可以选择性设置容量,不设置默认为 Integer.MAX_VALUE,即无界队列)
  • DelayQueue(支持延迟获取元素的阻塞队列)
  • PriorityBlockingQueue(具有优先级的无界队列)
  • SynchronousQueue(一个不存储元素的阻塞队列,每个插入操作必须等到另一个线程执行移除操作,否则插入操作一直处于阻塞状态)

如何保证多线程顺序执行

  • 通过 join:让主线程等待子线程结束之后才能继续进行
  • 单线程的线程池:Executors.newSingleThreadExecutor()

线程间如何通信

  • 共享内存:隐式通信
  • 消息传递:显式通信

线程间同步

为何要使用同步

java 允许多线程并发控制。当多个线程同时操作一个可共享的资源变量时,将会导致数据的不准确,相互之间产生冲突。因此可以加入同步锁以避免在该线程没有完成对数据的操作之前,数据被其他线程调用,从而保证了该变量的唯一性和准确性。

同步的方式

  • 1.同步方法或者同步代码块:sychronized 关键字或者 Lock 锁
  • 2.volatile 关键字
  • 3.使用 ThreadLocal
  • 4.阻塞队列
  • 5.使用原子变量

阻塞与等待的区别

阻塞

当一个线程试图获取锁对象(非 JUC 中的锁,即 synchronized),而该锁被其他线程持有,则该线程进入阻塞状态。它的特点是使用简单,由 JVM 调度器来唤醒自己,而不需要另一个线程来显式唤醒自己,不会响应中断

等待

当一个线程等待另一个线程通知调度器一个条件时,该线程进入等待状态。它的特点是需要等待另外一个线程显式地唤醒自己,实现灵活,语义更丰富,可响应中断。例如调用:Object.wait()、Thread.join()以及等待 Lock 或 Condition

总结

虽然 synchronized 和 JUC 里的 Lock 都实现锁的功能,但线程进入的状态是不一样的。synchronized 会让线程进入阻塞状态,而 Lock 使用 park()/unpark()来实现阻塞/唤醒的,会让线程进入等待状态;虽然等锁时进入的状态不一样,但被唤醒后又都进入 RUNNABLE 状态,从行为效果来看是一样的

yeild()和 sleep()的区别

  • 1.yeild 和 sleep 都能暂停当前线程,都不会释放锁资源。sleep 可以指定具体休眠的时间,而 yeild 依赖 cpu 的时间划分
  • 2.sleep 给其他线程运行机会时不考虑线程的优先级,因此会给低优先级的线程以运行的机会;而 yeild 方法只会给相同或者更高优先级的线程以运行的机会
  • 3.调用 sleep 方法使线程进入等待状态,等待睡眠时间到达;而调用 yeild 方法线程会进入就绪状态,也就是 sleep 需要等待设置的时间后才会进入就绪状态,而 yeild 会立即进入就绪状态
  • 4.sleep 方法会抛出 InterruptedException,而 yeild 不会抛出任何异常
  • 5.yeild 不能被中断,而 sleep 方法可以响应中断
  • 6.sleep 方法比 yeild 方法有更好的移植性

wait()和 sleep()的区别

  • 1.wait 来自 Object,而 sleep 来自 Thread
  • 2.wait 释放锁,sleep 不释放锁
  • 3.wait 必须在同步代码块中使用,sleep 不需要
  • 4.wait 不需要捕获异常,sleep 需要
  • 5.sleep 方法必须传入超时时间,而没有参数的 wait 方法意味着永久等待,直到被中断或者被唤醒
  • 5.都可以让线程阻塞
  • 6.都可以响应中断

死锁

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
public class App {

private static Object lockA = new Object();
private static Object lockB = new Object();

public static void main(String[] args) throws Exception {
Thread threadA = new ThreadA();
Thread threadB = new ThreadB();
threadA.start();
threadB.start();
}
static class ThreadA extends Thread {
public void run() {
synchronized (lockA) {
System.out.println("ThreadA: lockA");
synchronized (lockB) {
System.out.println("ThreadA: lockB");
}
}
}

}
static class ThreadB extends Thread {
public void run() {
synchronized (lockB) {
System.out.println("ThreadB: lockB");
synchronized (lockA) {
System.out.println("ThreadB: lockA");
}
}
}

}
}

JPS 查看当前 PID,jstack 查看堆栈信息

1
2
3
4
5
6
7
8
9
10
11
"Thread-1" #15 prio=5 os_prio=0 tid=0x0000000027bd1000 nid=0x66a8 waiting for monitor entry [0x0000000028e1e000]
java.lang.Thread.State: BLOCKED (on object monitor)
at App$ThreadB.run(App.java:30)
- waiting to lock <0x0000000716233420> (a java.lang.Object)
- locked <0x0000000716233430> (a java.lang.Object)

"Thread-0" #14 prio=5 os_prio=0 tid=0x0000000027bd0000 nid=0x5ad0 waiting for monitor entry [0x0000000028d1f000]
java.lang.Thread.State: BLOCKED (on object monitor)
at App$ThreadA.run(App.java:18)
- waiting to lock <0x0000000716233430> (a java.lang.Object)
- locked <0x0000000716233420> (a java.lang.Object)

可见 Thread-1 和 Thread-0 在持有对方等待的资源的同时也在等待对方持有的资源,处于 BLOCKED 状态

产生条件

  • 互斥条件:一个资源或者说一个锁只能被一个线程占用,当一个线程首先获取到这个锁之后,在该线程释放该锁之前,其他的线程无法获取到这个锁
  • 占有且等待:一个线程已经获取到一个锁,再获取另外一个锁时,即使获取不到也不会释放已经获得的锁
  • 不可剥夺条件:任何一个线程都无法强制获得其他线程已经拥有的锁
  • 循环等待条件:线程 A 拿着线程 B 想要获取的锁,线程 B 拿着线程 A 想要获取的锁

如何避免

  • 顺序加锁:按照相同的顺序加锁
  • 限时加锁:线程获取锁的过程中,限定超时时间,如果给定的时间内无法获取锁就放弃,这里需要用到 Lock 的 api

wait 虚假唤醒

notify 底层

为何 wait 和 notify 要依赖 synchronized

synchronized 代码块通过 javap 生成的字节码中包含 monitorenter 和 monitorexit 指令,执行 monitorenter 可以获得对象的 monitor,而 wait 方法通过调用 native 方法 wait(0)实现,wait(0)方法要求线程必须拥有对象的 monitor

notify 执行后唤醒的线程立马被 CPU 执行吗

notify/notifyAll 调用时并不会真正释放对象锁,只是把等待中的线程唤醒然后放入到对象的锁池中,但是锁池中所有的线程都不会马上运行,只有拥有对象锁的线程执行完代码块释放了锁,别的线程拿到锁之后才能执行

volatile

volatile 变量具有以下特性

  • 可见性:任意对一个 volatile 变量的读,总能看到任意线程对这个 volatile 变量最后的写入
  • 原子性:对任意单个 volatile 变量的读\写操作具有原子性,但类似于 volatile++这类符合操作无法保证原子性

volatile 写的内存语义如下

  • 当写一个 volatile 变量时,JMM 会把该线程对应的工作内存的共享变量的值刷新到主内存

volatile 读的内存语义如下

  • 当读一个 volatile 变量时,JMM 会把该线程对应的工作内存的共享变量缓存置为无效,强制从主内存读取

volatile 实现原理

volatile 在执行变量写操作执行 LOCK 指令,将变量实时写入主内存而不是处理器缓存,其他处理器通过缓存一致性协议嗅探到此变量的变更,将本地缓存置为无效,由此实现可见性

在 JMM 中是通过内存屏障实现的

  • 在 volatile 写之前插入 storestore 屏障,防止上面的写操作与下面的 volatile 写重排序
  • 在 volatile 写之后插入 storeload 屏障,防止上面的 volatile 写操作与下面的读操作重排序
  • 在 volatile 读之后插入 loadload、loadstore 屏障,防止上面的 volatile 读和后面的普通读、普通写、volatile 写重排序

还有 happens-before,对于一个 volatile 变量的写 happens-before 于后续任意对这个 volatile 的读

由此实现有序性




volatile 指令重排序

volatile 变量的内存可见性是基于内存屏障(Memory Barrier)实现的。JMM 内部会有指令重排,并且会有 as-if-serial 和 happens-before 的理念来保证指令重排的正确性。内存就是基于 4 个汇编级别的关键字来禁止指令重排的,volatile 的重排规则如下:

  • 1.第一个为读操作时,第二个任何操作不可重排到第一个操作前面
  • 2.第二个为写操作时,第一个任何操作不可重排到第二个操作后面
  • 3.第一个为写操作时,第二个的读写操作也不允许重排序

happens-before 规则

  • 程序顺序规则:一个线程中的每个操作,happens-before 于该线程中的任意后续操作
  • 监视器锁规则:对一个锁的解锁,happens-before 于后续对这个锁的加锁
  • volatile 变量规则:对一个 volatile 的写,happens-before 于任意后续对这个 volatile 变量的读
  • 传递性:如果 A happens-before 于 B,B happens-before 于 C,则 A happens-before 于 C

ThreadLocal

ThreadLocal 是什么

ThreadLocal 是一个本地线程副本变量工具类。主要用于将私有线程和该线程存放的副本对象做一个映射。各个线程之间的变量互不干扰,在高并发场景下,可以实现无状态的调用。适合各个线程不共享变量值的操作

ThreadLocal 工作原理

每个线程内部都维护了一个 ThreadLocalMap,是一个键值对数据格式,key 是一个弱引用,也就是 ThreadLocal 本身,value 存的是线程变量的值

也就是说 ThreadLocal 本身并不存储线程的变量值,它只是一个工具,用来维护线程内部的 ThreadLocalMap,帮助存储和取出变量值

ThreadLocal 如何解决 Hash 冲突

与 HashMap 不同,ThreadLocalMap 结构非常简单,没有 next 引用,也就是说 ThreadLocalMap 中解决 hash 冲突的方式并非链表的方式,而是采用线性探测的方式。所谓线性探测,就是根据初始 key 的 hashCode 值确定元素在 table 数组中的位置,如果发现这个位置上已经被其他 key 值占用,则利用固定的算法寻找一定步长的下一个位置,依次判断,直至找到能够存放的位置

1
2
3
4
5
6
7
8
9
10
11
12
13
/
* Increment i modulo len.
*/
private static int nextIndex(int i, int len) {
return ((i + 1 < len) ? i + 1 : 0);
}

/
* Decrement i modulo len.
*/
private static int prevIndex(int i, int len) {
return ((i - 1 >= 0) ? i - 1 : len - 1);
}

ThreadLocal 的内存泄露是怎么回事

ThreadLocal 在 ThreadLocalMap 中是以一个弱引用身份被 Entry 中的 key 引用的,因此如果 ThreadLocal 没有外部强引用来引用它,那么 ThreadLocal 会在下次 JVM 垃圾回收时被回收,这时候 Entry 中的 key 已经被回收,但是 value 又是一强引用所以不会被垃圾回收器回收,这样 ThreadLocal 的线程如果一直运行下去,value 就一直得不到回收,这样就会发生内存泄露。

如何解决:

使用完 ThreadLocal 后,及时调用 remove()方法释放内存空间

为什么 ThreadLocal 的 key 是弱引用

  • 1.key 使用强引用,这样会导致一个问题:引用 ThreadLocal 的对象被回收了,但是 ThreadLocalMap 还持有 ThreadLocal 的强引用,如果没有手动删除,ThreadLocal 不会被回收,则导致内存泄漏
  • 2.key 使用弱引用,这样的话,引用 ThreadLocal 的对象被回收了,由于 ThreadLocalMap 持有 ThreadLocal 的弱引用,即使没有手动删除,ThreadLocal 也会被回收;value 在下一次 ThreadLocalMap 调用 set、get、remove 的时候会被清除

比较以上两种情况,我们可以发现:由于 TheadLocalMap 的生命周期跟 Thread 一样长,如果都没有手动删除对应的 key,都会导致内存泄漏,但是弱引用可以多一层保障,弱引用的 ThreadLocal 不会内存泄漏,对应的 value 在下一次 ThreadLocalMap 调用 set、get、remove 的时候被清除,算是最优的解决方案

ThreadLocal 的应用场景

  • Session 管理
  • 数据库连接

设计模式

代理模式

静态代理和动态代理的区别

  • 静态代理要求委托类和代理类都实现同一个接口,代理对象的一个接口只服务于一种类型的对象
  • 静态代理种代理类在编译期就已经确定,而动态代理则是运行期间动态生成
  • 静态代理的效率相对来说更高一些,但是代码冗余大,一旦需要修改接口,代理类和委托类都需要修改
  • 静态代理依靠反射来生成代理类

动态代理实现步骤

JDK 动态代理
  • jdk 动态代理只能用于代理接口,因此需要先创建一个接口以及其实现类
  • 创建代理类,实现 InvocationHandler 接口
  • 引入 Objecy 类作为目标对象,重写有参构造器,重写 invoke 方法,加入代理逻辑并调用 Object 类的方法
  • 利用 Proxy.newInstance()生成代理对象,并调用目标方法
CGLIB 动态代理

CGLiB(Code Generation Library)是一个高性能开源的代码生成包,采用非常底层的字节码技术,对指定的目标类生成一个子类,并对子类进行增强

  • 创建被代理类
  • 创建代理类,实现 MethodInterceptor 接口,实现 intercept 方法
  • 利用 Enhancer 绑定被代理对象和代理类
  • 利用 Enhancer.create()方法创建代理对象,调用代理对象方法

对象头

synchronized

volatile 和 synchronized 区别

  • volatile:保证了可见性、原子性,不能做到复合操作的原子性
  • synchronized:可重入锁,保证了互斥性、可见性和有序性

Lock

Lock 和 synchronized 区别

CAS

CAS 全称 Compare And Swap(比较与交换),是一种无锁算法。在不使用锁(没有线程被阻塞)的情况下实现多线程之间的变量同步。java.util.concurrent 包中的原子类就是通过 CAS 来实现的

CAS 算法涉及到 3 个操作数:

  • 需要读写的内存值 V
  • 进行比较的值 A
  • 要写入的新值 B

当且仅当 V 的值等于 A 时,CAS 通过原子方式用新值 B 来更新 V 的值(比较+更新,整体是一个原子操作)。否则不会执行任何操作。一般情况下,更新是一个不断重试的操作

ActomicInteger 源码:

2021-04-12-15-24-1120210412152410

根据定义我们可以看出各属性的作用:

  • unsafe:获取并操作内存的数据
  • valueOffset:存储 value 在 AtomicInteger 中的偏移量
  • value:存储 AtomicInteger 的 int 值,该属性需要 volatile 关键字保证其在线程间是可见的

接下来查看 AtomicInteger 的自增函数 incremenetAndGet()的源码,发现自增函数底层调用的是 unsafe.getAndAddInt()。但是由于 JDK 本身只有 Unsafe.class,只通过 class 文件中的参数名,并不能很好的了解方法的作用,所以通过 OpenJDK8 来查看 Unsafe 的源码

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
// ------------------------- JDK 8 -------------------------
// AtomicInteger 自增方法
public final int incrementAndGet() {
return unsafe.getAndAddInt(this, valueOffset, 1) + 1;
}

// Unsafe.class
public final int getAndAddInt(Object var1, long var2, int var4) {
int var5;
do {
var5 = this.getIntVolatile(var1, var2);
} while(!this.compareAndSwapInt(var1, var2, var5, var5 + var4));
return var5;
}

// ------------------------- OpenJDK 8 -------------------------
// Unsafe.java
public final int getAndAddInt(Object o, long offset, int delta) {
int v;
do {
v = getIntVolatile(o, offset);
} while (!compareAndSwapInt(o, offset, v, v + delta));
return v;
}

根据 OpenJDK8 的源码我们可以看出,getAddAddInt 循环获取给定对象 o 中的偏移量处的值 v,然后判断内存值是否等于 v。如果相等则将内存值设为 v+delta,否则返回 false,继续循环进行重试,直到设置成功才能退出循环,并将旧值返回。整个”比较+更新“操作封装在 compareAndSwapInt()中,在 JNI 里是借助一个 CPU 指令完成的,属于原子操作,可以保证多个线程都能看到同一个变量的修改值。

后续 JDK 通过 CPU 的 cmpxchg 指令,去比较寄存器中的 A 和内存中的值 V,如果相等,就把要写入的新值 B 存入内存中。如果不相等,就将内存值 V 赋值给寄存器中的值 A,然后通过 java 代码中的 while 循环再次调用 cmpxchg 指令进行重试,直到设置成功为止

CAS 虽然很高效,但是也存在三大问题:

  • ABA 问题:CAS 需要在操作值的时候检查内存值是否发生变化,没有发生变化才会更新内存值。但是如果内存值原来是 A,后来变成了 B,然后又变成了 A,那么 CAS 进行检查时会发现值没有发生变化,但是实际上是有变化的。ABA 问题的解决思路就是在变量前面加版本号,每次变量更新的时候都把版本号+1,这样变化过程就从”A-B-A“变成了”1A-2B-3A“。
    • JDK 从 1.5 版本开始提供了 AtomicStampedReference 类来解决 ABA 问题,具体操作封装在 compareAndSet()中。compareAndSet()首先检查当前引用和当前标志与预期引用和预期标志是否相等,如果都相等,则以原子方式将引用值和标志的值设置为给定的更新值
  • 循环时间长开销大:CAS 操作如果长时间不成功,会导致其一直自旋,给 CPU 带来很大负担。
  • 只能保证一个共享变量的原子操作:对一个共享变量执行操作时,CAS 能够保证原子操作,但是对多个共享变量操作时,CAS 是无法保证操作的原子性的
    • java 从 1.5 版本开始提供了 AtomicReference 类来保证引用对象之间的原子性,可以把多个变量放在一个对象里来进行 CAS 操作

CAS 面试问题

  1. 假设有多个线程同时在对同一块内存进行 CAS 操作的话:两个线程 T1、T2 同时执行,V 存储同一块内存地址,A 当然也是旧的预期值,那么这种情况下 T1 和 T2 是否都可以更新成功

在多个线程同时 CAS 的情况下是不会发生多个线程 CAS 成功的情况的,因为计算机底层实现保证了 V 指向内存的互斥性和立即可见性,可以理解为 CAS 操作时底层保证的线程安全

一个线程 T 在 CAS 操作时,其他线程无法访问 V 指向的内存地址,并且一旦 T 更新了 V 指向内存的值,其他所有线程的 V 指向内存都变得无效

处理器实现原子操作有两种做法

  • 1.总线锁:在多 CPU 下,当其中一个处理器要对共享内存进行操作的时候,在总线上发出一个 LOCK#信号,这个信号使得其他处理器无法通过总线来访问到共享内存中的数据
  • 2.缓存锁:如果共享内存已经被缓存,那么锁总线没有意义。缓存锁核心是使用了缓存一致性协议,如 MESI 协议
    • MESI 表示缓存行的四种状态
      • M(Mofify):表示共享数据只缓存在当前 CPU 缓存中,并且是被修改状态,也就是缓存的数据和主内存的数据不一致
      • E(Exclusive):表示缓存的独占状态,数据只缓存在当前 CPU 缓存中,并且没有被修改
      • S(Shared):表示数据可能被多个 CPU 缓存,并且各个缓存中的数据和主内存数据一致
      • I(Invalid):表示缓存已经失效
    • 在 MESI 协议中,每个缓存的缓存控制器不仅知道自己的读写操作,而且也嗅探(snoop)其他 cache 的读写操作
    • CPU 在读数据时,如果缓存行状态是 I,则需要从主内存中取出,并把缓存行状态置为 S;如果不是 I,则可以直接读取缓存中的值,但在此之前必须要等待对其他 CPU 的监听结果,如果其他 CPU 也有该数据的缓存并且状态是 M,则需要等待其把缓存更新到主内存后再读取
    • CPU 可以将状态为 M/E/S 的缓存写入内存,其中如果缓存行状态为 S,则其他缓存了相同数据的缓存行会无效化

AQS

JUC 其他

CountDownLatch 与 CyclicBarrier 区别

  • CountDownLatch:一个或者多个线程,等待其他线程完成某件事情之后才能执行
  • CyclicBarrier:多个线程互相等待,直到到达同一个同步点,再继续一起执行
  • CountDownLatch 是递减计数,CyclicBarrier 是递增计数
  • CountDownLatch 不可以重复利用,CyclicBarrier 可以重复利用
  • CountDownLatch 初始值为 N,N>0,CyclicBarrier 初始值 N=0
  • CountDownLatch 调用 countDown(),N-1;CyclicBarrier 调用 await(),N+1
  • CountDownLatch 在 N>0 时,调用 await()会一直阻塞;CyclicBarrier 在 N 小于指定值时,一直阻塞
  • CountDownLatch 在计数为 0 时释放等待线程,CyclicBarrier 在计数达到指定值时释放等待线程

MySQL

MySQL 是如何执行一条 SQL 语句的

一个MySQL请求的处理流程图

  • 连接器:管理连接,权限验证
  • 查询缓存:命中缓存则直接返回结果
  • 分析器:对 SQL 进行词法、语法分析(查询判断的 SQL 字段是否存在也是在这步)
  • 优化器:执行计划生成,选择索引
  • 执行器:操作引擎,返回结果
  • 存储引擎:存储数据,提供读写接口

server 层按顺序执行 SQL 的步骤为:

  • 客户端请求
  • 连接器(验证用户身份,给予权限)
  • 查询缓存(存在缓存则直接返回,不存在则继续后续操作)
  • 分析器(对 SQL 进行词法和语法分析)
  • 优化器(对执行的 SQL 进行优化,选择最优的执行方案)
  • 执行器(执行时会先看用户是否有执行权限,有权限才使用这个引擎提供的接口)
  • 去引擎层获取数据返回(如果开启查询缓存则尝试缓存查询结果)

从上图可以看出,MySQL 的处理流程主要分为 4 个步骤

  • 客户端与服务端通信
  • 查询优化处理过程
  • 查询执行引擎
  • 返回结构给客户端

MySQL 客户端和服务端之间如何通信

通信类型

长连接或短连接(MySQL 都支持,一般为长连接,放在连接池中)

1
2
3
4
-- 查看连接
show full processlist;
-- 查看连接参数
show global status like 'Thread%';

MySQL-interview-2021-03-26-13-30-5220210326133051

  • Threadpool_idle_threads:线程池中空闲的线程数量
  • Threadpool_threads:线程池中所有的线程数量
  • Thread_cached:缓存中的线程数
  • Thread_connected:处于连接状态中的线程数
  • Thread_created:被创建的线程数
  • Thread_running:处于运行状态的线程数

通信协议

socket(不是通信协议)和 TCP/IP 协议

MySQL 客户端和数据库实例不在同一台服务器上时,在连接时没有指定-h 参数,会使用 socket 方式登录,需要用到服务器上的一个物理文件(/var/lib/MySQL/MySQL.sock)

如果指定-h 参数,就会使用 TCP 协议

通信方式

一般通信方式有 3 种:单工、半双工、全双工。

  • 单工:只能单向传输,要么 A 传给 B,要么 B 传给 A
  • 半双工:可以双向传输,但是同一时间只能是一个方向传输,也就是 A 传给 B 的时候 B 只能等待,反过来也是一样
  • 全双工:全双工是双向随便传输,A 传给 B 的同时 B 也能传给 A

MySQL 客户端与服务端通信方式是半双工的,也就是说,我们的一个数据库连接在向数据库发送数据的时候,此时这个数据库连接是不能给客户端返回数据的,一定是数据返回完毕以后,客户端才能再次发起查询。这也就是我们在做数据查询的时候用 where 条件和 limit 限制结果行数的原因,否则客户端连接需要等到数据库把所有的查询结果返回后才能进行下一个操作。

从上面的分析可以看出,MySQL 数据库半双工通信的一个重要特点是:客户端一旦开始发送指令,服务端需要接收完毕才能响应,客户端只有在完全接收到服务端响应的数据后,才能再次发送指令。我们在程序开发中,一般用多个连接进行数据交互,通过数据库连接池来进行管理。

其实 MySQL 的每一个连接都有其对应的状态来标识它目前所处的阶段,和线程类似,我们可以通过下面的命令查看数据库连接的状态:

1
show [full] processlist;

常用的几个状态描述:

状态值 状态描述
1 login 连接线程的初始状态,直到客户端已经成功通过身份验证
2 executing 该线程已开始执行一条语句
3 optimizing 服务器正在对查询执行初始优化
4 updating 线程正在搜搜或者更新要更新的行
5 sending data 正在将数据发送到客户端,一般会执行大量的磁盘访问操作
6 sorting result 正在对结果排序
7 waiting for commit lock 正在等待提交锁

当发现数据连接长时间占用的时候,可以用 kill 命令杀死线程:

1
kill processlist_id;

查询优化处理过程

  • 解析器解析 SQL 语句:通过 lex 词法分析器(就是把一个完整的 SQL 语句分析成独立的单词)、yacc 语法分析器(就是分析是否符合语法规则,比如单引号是否闭合等)进行分析,将 SQL 语句按 SQL 标准解析成解析树(select_lex)对象,主要功能是把 SQL 语句的字符串解析成数据库服务器可以处理的解析对象,便于后续进行预处理和生成执行计划
  • 预处理:预处理会根据 MySQL 的语法规则对解析树对象进行合法性检查,比如检查表明列名是否存在、检查名字和别名保证没有歧义。预处理之后得到一个新的解析树
  • 优化器生成执行计划:优化器的主要作用是找到这个 SQL 语句最优的执行计划,MySQL 的查询优化器和 Oracle 的类似,都是基于成本的计算,优化器会尝试使用不同的执行计划,以便于找到一个最优的执行计划(一般随机读取 4K 的数据库进行分析)

可以使用以下命令查看查询的成本:

1
show status like 'Last_query_cost';

优化器最终会把解析树变成一个查询执行计划。MySQL 提供了一个执行计划的工具,我们在 SQL 语句前面加上 EXPLAIN,就可以看到执行计划的信息

MySQL 缓存

一般情况下,我们不会用到数据库自带的缓存,所以 MySQL 默认是不开启缓存的,只有以读为主的业务,数据不变化的情况下,可以开启数据库的缓存,查看缓存是否开启:

1
show variables like 'query_cache%';

2021-04-22-09-54-45querycache

  • query_cache_type:OFF 表示缓存关闭,默认是关闭的,可以通过修改 MySQL 配置文件 my.cnf 进行调整,重启服务后生效
  • query_cache_limit:1048576,表示单次查询缓存的结果集大小 1M,超过 1M 则不会缓存
  • query_cache_size:1048576,表示缓存开辟的空间大小

查看缓存操作情况:

1
show status like 'Qcache%';
  • Qcache_hits:表示缓存命中次数
  • Qcache_inserts:表示缓存插入次数

缓存生效的条件是在缓存开启的情况下,执行的 SQL 语句字符串一模一样的时候,可以从缓存直接读取数据。但是当缓存数据相关的表存在数据变化的时候,原有的缓存就会失效

MySQL 的缓存开启后,当 SQL 查询语句带有 SQLnocache 关键字或者带有函数操作或者单次查询结果集超过 query_cache_limit 设置的值或者查询系统表时,不会用到缓存

drop、delete 于 truncate 的共同点和区别

  • drop、delete、truncate 都表示删除,但是三者存在区别
  • delete 用来删除表的一部分数据,执行 delete 之后,用户需要提交(commit)或者回滚(rollback)来执行删除或者撤销删除,会触发这个表上所有的 delete 触发器
  • truncate 删除表中所有的数据,这个操作不能回滚,也不会触发表上的触发器,truncate 比 delete 更快,占用的空间更小
  • drop 命令从数据库中删除表,所有的数据行、索引和权限也会被删除,所有的 DML 触发器也不会被触发,这个命令也不能被回滚
  • 因此在不需要一张表的时候,用 drop;在想删除部分数据行的时候,用 delete;在保留表而删除所有数据的时候用 truncate

具体解析

  • delete 语句执行删除的过程是:每次从表中删除一条数据,并且同时将该行的删除操作作为事务记录在日志中保存,以便进行回滚操作。truncate 则一次性的从表中删除所有的数据,并不把单独的删除操作记入日志保存,删除行时不能恢复的,并且在删除的过程中不会激活与表有关的删除触发器,执行速度块
  • 表和索引所占空间。当表被 truncate 后,这个表和索引所占的空间会恢复到初始大小,而 delete 操作不会减少表或索引所占用的空间,drop 语句将表所占用的空间全部释放掉
  • 应用范围:truncaet 只能对 table,delete 可以时 table 和 view
  • truncate 和 delete 只删除数据,而 drop 则删除整个表(数据和结构)
  • truncate 与不带 where 的 delete 只删除数据,而不删除表的结构。drop 语句将删除表的结构、被依赖的约束(constrain)、触发器(trigger)、索引(index);依赖于该表的存储过程、函数将被保留,但其状态会变为 invalid
  • delete 语句为 DML(Data ManiPulation Language),这个操作会被放到 rollback segment 中,事务提交后才生效,如果有相应的 trigger,执行的时候将被触发
  • truncate、drop 是 DDL(Data Define Language),操作立即生效,原数据不放到 rollback segment 中,不能回滚
  • 在没有备份的情况下,谨慎使用 drop 与 truncate。要保留部分数据行采用 delete 且注意结合 where 来约束影响范围。回滚段要足够大。要删除表用 drop。若想保留表而将表中数据删除,如果与事务无关,用 truncate 即可实现;如果和事务有关或者是想触发 trigger,还是用 delete
  • truncate table 表名s 速度块而且效率高,因为 truncate 在功能上与不带 where 子句的 delete 语句相同:二者均删除表中的全部行。但 truncate 比 delete 速度快,且使用的系统和事务日志资源小。delete 语句每次删除一行并在事务日志中为所删除的每一行记录一项。truncate 通过释放存储表数据所用的数据页来删除数据,并且只在事务日志中记录页的释放。
  • truncate 删除表中的所有行,但表结构及其列、约束、索引等保持不变。新行表示所用的计数值重置。如果想保留标识计数值,请用 delete。如果要删除表定义及其数据,请用 drop
  • 对于有外键约束引用的表,不能使用 truncate,而应使用不带 where 子句的 delete。由于 truncate 不记录在日志中,所以它不能激活触发器

获取 MySQL 自增主键的方法

  • MyBatis:useGeneratedKeys
  • JDBC:Statement.RETURN_GENERATED_KEYS

SQL 优化

对于 MySQL 层面的优化一般遵循 5 个原则:

  • 减少数据访问:设置合理的字段类型、启用压缩、通过索引访问等减少磁盘 IO
  • 返回更少的数据:只返回需要的字段和数据分页处理、减少磁盘 IO 及网络 IO
  • 减少交互次数:批量 DML 操作,函数存储等减少数据库连接次数
  • 减少服务器 CPU 开销:尽量减少数据库排序操作及全表查询,减少 CPU 内存占用
  • 利用更多资源:使用表分区,可以增加并行操作,更大限度利用 CPU 资源

总结到 SQL 优化中,就如下 3 点:

  • 最大化利用索引
  • 尽可能避免全表扫描
  • 减少无效数据的查询

理解 SQL 优化原理,首先要搞清楚 SQL 执行顺序

1
2
3
4
5
6
7
8
9
10
select
distinct <select_list>
from <left_table>
<join_type> join <right_table>
on <join_condition>
where <where_condition>
group by <group_by_list>
having <having_condition>
order by <order_by_condition>
limit <limit_number>

select 语句,执行顺序如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
from
<表名> # 选取表,将多个表数据通过笛卡尔积变成一个表
on
<筛选条件> # 对笛卡尔积的虚表进行筛选
join <join, left join, right join...>
<要join的表> # 指定join,用于添加数据到on之后的虚表,例如left join会将左表的剩余数据添加到虚表中
where
<where条件> # 对上述虚表进行筛选
group by
<分组条件> # 分组
<SUM()等聚合函数> # 用于having子句进行判断,在书写上这类聚合函数是写在having判断里面的
having
<分组筛选> # 对分组后的结果进行聚合筛选
select
<返回数据列表> # 返回的单列必须在group by 子句中,聚合函数除外
distinct # 数据去重
order by
<排序条件> # 排序
limit
<行数限制>
  • 1.为表建立主键

  • 2.定义字段时选取合适的类别和长度

  • 3.不使用 select’*‘,明确查询字段,返回无用的字段会降低查询效率

  • 4.单条 SQL 不要 join 超过三张表,关联字段必须有索引且数据类型一致

  • 5.单条 SQL 子查询不要超过两层

  • 6.SQL 中不要进行计算或者嵌套判断

  • 7.where 子句,group by 中用到的字段增加索引

  • 8.where 子句中等号的左侧不能使用函数或者表达式,会导致数据库引擎放弃索引进行全表扫描

  • 9.不要使用 like ‘%name’,即左模糊

  • 10.不要使用负向查询条件(!=、<>、not),会导致数据库引擎放弃索引,进行全表扫描

  • 11.不要使用 or,会导致数据库引擎放弃索引查询而进行全表扫描

  • 12.传入变量类型与查询条件中字段类型应该匹配,禁止隐式转换

  • 13.不要使用外键\视图\触发器\存储过程等

  • 14.尽量避免进行 null 值的判断,会导致数据库引擎放弃索引而进行全表扫描

避免数据库引擎放弃索引进行全表扫描的场景

尽量避免在字段开头模糊查询
1
select * from user where username like '%张';

优化方式:尽量在字段后面使用模糊查询

如果需求是要在前面使用模糊查询:

  • 使用 MySQL 内置函数 INSTR(str,substr)来匹配,作用类似于 Java 中的 indexOf(),查询字符串出现的角标位置
    • instr(field,str)函数第一个参数 field 是字段,第二个参数是要查询的字符串 str,返回 str 在字段中的位置,没找到就返回 0
  • 使用全文索引,用 match against 检索
  • 数据量较大的情况,建议用 ElasticSearch、solr 等
尽量避免使用 in 和 not in
1
select * from user where id in (1,2,3);

优化方式:

  • 如果是连续数值,用 between 代替
1
select * from user where id between 1 and 3;
  • 如果是子查询,可以用 exists 代替
1
2
3
4
-- 不走索引
select * from A where A.id in (select id from B);
-- 走索引
select * from A where exists (select * from B where B.id = A.id)
尽量避免使用 or
1
select * from t where id = 1 or id = 3;

优化方式:可以用 union 代替 or

1
2
3
4

select * from t where id = 3
union
select * from t where id = 1;
尽量避免进行 null 值的判断
1
select * from t where score is null;

优化方式:可以给字段添加默认值,对默认值进行判断

1
select * from score where score = 0;
尽量避免 where 条件左侧进行表达式、函数操作
1
select * from t where score / 10 = 9;

优化方式:可以将表达式、函数操作移动到等号右侧

1
select * from t where score = 9*10;
当数据量大时,避免使用 where 1=1 条件

通常为了方便查询条件,我们会默认使用该条件

1
select a,b,c from t where 1= 1;

优化方式:用代码拼接 SQL 时进行判断,没 where 条件就去掉 where,有 where 条件就加 and。用 MyBatis<where>标签实现动态 SQL

查询条件不能用 <> 或者 !=

如果业务确实需要使用到不等于符号,需要再重新评估索引建立,避免在此字段上简历索引,改由查询条件中其他索引字段代替

where 条件仅包含复合索引非前置列

复合索引为(a,b,c)三列,但是 SQL 语句没有包含索引前置列 a,按照 MySQL 复合索引最左前缀原则,不会走联合索引

避免隐式类型转换

字段整形加上引号可以走索引,字符串不加引号不会走索引

1
select a from t where col_varchar = 123;
order by 条件要与 where 条件一致,否则 order by 不会利用索引进行排序
1
2
3
4
5
-- 不走age索引排序
select * from t order by age;

-- 走age索引排序
select * from t where age > 0 order by age;

对于上面的语句,数据库的处理顺序是:

  • 1.根据 where 条件和统计信息生成执行计划,得到数据
  • 2.将得到的数据排序,当执行处理数据(order by )时,数据库会先查看第一步的执行计划,看 order by 的字段是否在执行计划中利用了索引。如果是,则可以利用索引顺序直接取得已经排好序的数据。如果不是,则重新进行排序操作
  • 3.返回排序后的数据

当 order by 中的字段出现在 where 条件中时,才会利用索引而不再二次排序。更准确的说,order by 中的字段在执行计划中利用了索引时,不需要排序操作

这个结论不仅对 order by 有效,对其他需要排序的操作也有效,如 group by 、union、distinct 等

某个表中有两列(id 和 c_id)都建立了单独索引,下面这种查询条件不会走索引
1
select * from test where id = c_id;
正确使用 hint 优化语句

MySQL 中可以使用 hint 指定优化器在执行时选择或忽略特定的索引。

一般而言,处于版本变更带来的表结构索引变化,更建议避免使用 hint,而是通过 Analyze table 多收集统计信息。

但在特定场合下,指定 hint 可以排除其他索引干扰而指定更优的执行计划

  • USE INDEX:在你查询语句中表名的后面,添加 USE INDEX 来提供希望 MySQL 去参考的索引列表,就可以让 MySQL 不再考虑其他可用的索引。例如
1
select a from t use index (mod_time,name);
  • IGNORE INDEX:如果只是单纯的想让 MySQL 忽略一个或者多个索引,可以使用 IGNORE INDEX 作为 hint。例如:
1
select a from t ignore index(priority)
  • FORCE INDEX:为强制 MySQL 使用一个特定的索引。例如:
1
select a from t force index(create_time)
大分页

复合索引 idx_a_b_c(a,b,c)

1
select * from t where a= 1 and b=2 order by c desc limit 100000,10;

对于大分页的场景,可以先优化需求,无法优化的,有如下两种优化方式:

  • 1.把上一次的最后一条数据,也就是上面的 c 传过来,然后做”c < xxx“处理,但是这种一般需要更改接口
  • 2.另一种方式是采用延迟关联的方式进行处理,减少 SQL 回表,然但是要记得索引需要完覆盖才有效:
1
select t1.* from t t1, (select id from t where a= 1 and b = 2 order by c desc limit 100000,10)t2 where t1.id = t2.id;
in + order by

索引 idx_shopId_status_created(shop_id,order_status,created_at)

1
select * from order where shop_id =1 and order_status in (1,2,3) order by created_at desc limit 10

in 查询在 MySQL 底层是通过 n*m 的方式去搜索,类似 union,但是效率比 union 高

in 查询在进行 cost 代价计算时(代价=原数组*IO 平均值),是通过将 in 包含的数值,一条条去查询获取元数组的,因此这个计算过程会比较慢,所以 MySQL 设置了一个临界值(eq_range_index_dive_limit),5.6 之后将这个临界值后该列的 cost 就不参与计算了。因此会导致执行计划选择不准确。默认是 200,即 in 条件超过了 200 个数据,会导致 in 的代价计算存在问题,可能会导致 MySQL 选择的索引不准确。

处理方式:可以(order_status,created_at)互换前后顺序,并且调整 SQL 为延迟关联

范围查询阻断,后续字段不能走索引

索引 idx_shopId_status_created(shop_id,created_at,order_status)

1
select * from order where shop_id = 1 and created_at > '2021-05-15 00:00:00' and order_status = 10;

范围查询还有 in、between

优化器选择不使用索引的情况

如果要求访问的蜀将很小,则优化器还是会选择辅助索引,但是当访问的数据占整个表中数据蛮大的一部分时(一般是 20%左右),则优化器会选择通过聚集索引来查找数据

ASC、DESC 混用
1
select * from order where a= 1 order by b desc, c asc;

desc 和 asc 混用会导致索引失效

select 语句其他优化

避免使用 select*

select*取出全部列,会让优化器无法完成索引覆盖扫描这类优化,会影响优化器对执行计划的选择,也会增加网咯带宽消耗,更会带来额外的 IO、内存和 CPU 消耗

避免出现不确定结果的函数

特定针对主从复制这类业务场景。由于原理上从库复制的是主库执行的语句,使用如 now()、rand()、sysdata()、current_user()等不确定结果的函数很容易导致主库与从库相应的数据不一致。

另外不确定值的函数,产生的 SQL 语句无法利用 query cache

多表联查时,小表在前,大表在后

在 MySQL 中,执行 from 后的表关联查询是从左往右执行的(Oracle 相反),第一张表会涉及到全表扫描。所以将小表放在前面,先扫描小表,扫描效率很高,再扫描后面的大表,或许只扫大表前 100 行就符合返回条件并 return 了

使用表的别名

当在 SQL 语句中连接多个表时,使用表的别名并将别名前缀于每个列名上,这样就可以减少解析的时间并减少那些因为列名歧义引起的语法错误

用 where 子句代替 having 子句

避免使用 having 子句,因为 having 只会在检索出所有记录之后才会对结果集进行过滤,而 where 则是在聚合前筛选记录,如果能通过 where 子句限制记录的数目,那么就能减少这方面的开销

调整 where 子句中的连接顺序

MySQL 采用从左到右,自上而下的顺序解析 where 子句,根据这个原理,应将过滤数据多的条件往前放,最快速度缩小结果集

增删改 DML 语句优化

大批量插入数据

如果同时执行大量的插入,建议使用多个值的 insert 语句,这比分开 insert 的语句快,一般情况下批量插入效率有几倍的差别

1
2
3
4
5
6
-- 单个插入
insert into t values(1,2);
insert into t values(2,3);
insert into t values(3,4);
-- 批量插入
insert into t values(1,2),(2,3),(3,4);

选择批量插入的原因有三个:

  • 减少 SQL 语句解析的操作,MySQL 没有类似 Oracle 的 share pool,采用批量插入,只需要解析一次就可以进行数据的插入操作
  • 在特定场景下可以减少对 DB 连接次数
  • SQL 语句较少,可以减少网络 IO

注意:MySQL 对一条 SQL 语句有最大长度限制,可以执行以下语句查看,可以在 my.cnf 配置文件中进行更改

1
show variables like '%max_allowed_packet%';

2021-04-09-17-35-0120210409173500

适当使用 commit

适当使用 commit 可以释放事务占用的资源而减少消耗,commit 后能释放的资源如下:

  • 事务占用的 undo 数据块
  • 事务在 redo log 中记录的数据块
  • 释放事务施加的锁,减少锁争用影响性能。特别是在需要使用 delete 大量数据的时候,必须分解删除量并定期 commit
避免重复查询更新的数据

针对业务中经常出现的更新行同时又希望获得该行更新后信息的需求,MySQL 并不支持 PostgreSQL 那样的 update returning 语法,在 MySQL 中可以通过变量实现

例如:更新一行记录的时间戳,同时希望查询当前记录中存放的时间戳是什么

1
2
3
4
5
6
-- 简单方法实现
update t1 set time = now() where id =1;
select time from t1 where id = 1;
-- 使用变量,可以重写为以下方式
update t1 set time=now() where id = 1 and @now:= now();
select @now;

前后两者都需要两次网络来回,但使用变量避免了再次访问数据表,特别是当 t1 表数据量较大时,后者比前者快很多

查询条件优化

对于复杂的查询,可以使用中间表暂存数据
优化 group by 语句

默认情况下,MySQL 会对 group by 分组的所有值进行排序,如”group by col1,col2,…“查询的方法如同在查询中指定”order by col1,col2,…“。

如果显式包括一个包含相同的列的 order by 子句,MySQL 可以毫不减速地对它进行优化,尽管仍然进行排序。

因此,如果查询包括 group by 但你并不想对分组的值进行排序,你可以指定 order by null 禁止排序

1
select col1,col2,count(*) from t group by col1,col2 order by null;
优化 join 语句

MySQL 中可以通过子查询来使用 select 语句来创建一个单列的查询结果,然后把这个结果作为过滤条件用在另外一个查询中

使用子查询可以一次性的完成很多逻辑上需要多个步骤才能完成的 SQL 操作,同时也可以避免事务或者表锁死,并且写起来也很容易。但是有些情况下,子查询可以被更有效率的连接(join)替代

例如:假设要将所有没有订单记录的用户取出来,可以用下面的查询完成

1
select col1 from customerinfo where customerId not in (select customerId from salesinfo);

如果使用 join 来完成这个查询工作,速度将会有所提升

尤其是当 salesinfo 表中对 customerId 建有索引的话,性能将会更好

1
2
3
select col1 from customerinfo
left join salesinfo on customerinfo.customerId = salesinfo.customerId
where salesinfo.customerId is null
通过 union 查询

MySQL 通过创建并填充临时表的方式来执行 union 查询。除非确实需要消除重复的行,否则建议使用 nuion all

原因在于如果没有 all 这个关键词,MySQL 会给临时表上加上 distinct 选项,这会导致对整个临时表的数据做唯一性校验,这样做的消耗相当高

高效:

1
2
3
select col1, col2, col3 from table where col1 = 10
union all
select col1, col2, col3 from table where col3 = 'test';

低效:

1
2
3
select col1, col2, col3 from table where col1 = 10
union
select col1, col2, col3 from table where col3 = 'test';
拆分复杂 SQL 为多个小 SQL,避免大事务
  • 简单的 SQL 容易使用到 MySQL 的 query cache

  • 减少锁表时间特别是使用 MyISAM 存储引擎的表

  • 可以使用多核 CPU

使用 truncate 代替 delete

当删除全表中记录时,使用 delete 语句的操作会被记录到 undo 块中,删除记录也记录 Binlog

当确认需要删除全表时,会产生很大量的 Binlog 并占用大量的 undo 数据块,此时既没有很好的效率也占用了大量的资源

使用 truncate 替代,不会记录可恢复的信息,数据不能被恢复。也因此使用 truncate 操作有其极少的资源占用与极快的时间。另外使用 truncate 可以回收表的水位线,使自增字段值归零。

使用合理的分页方式以提高分页效率

案例 1:

1
2
select * from t where thread_id = 10000 and deleted = 0
order by gmt_create asc limit 0,15;

上述例子通过一次性根据过滤条件取出所有字段进行排序返回。数据访问开销=索引 IO+索引全部记录结果对应的表数据 IO

适用场景:当中间结果集很小(10000 行以下)或者查询条件复杂(指涉及多个不同查询字段或者多表连接)时适用

案例 2:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
select
t.*
from
( select
id
from
t
where
thread_id = 10000
and
deleted =0
order by
gmt_create asc
limit
0, 15)a, t
where
a.id = t.id

上述例子必须满足 t 表主键是 id 列,且有覆盖索引 secondary key :(thread_id, deleted, gmt_create)

通过先根据过滤条件利用覆盖索引取出主键 id 进行排序,再进行 join 操作取出其他字段

数据访问开销=索引 IO+索引分页后结果对应的表数据 IO。因此,该写法每次翻页消耗的资源和时间都基本相同,就像翻第一页一样。

对于典型的分页 limit m,n 来说,越往后翻页越慢,也就是 m 越大会越慢,因为要定位 m 位置需要扫描的数据越来越多,导致 IO 开销比较大,这里可以利用辅助索引的覆盖扫描来进行优化,先获取 id,这一步就是索引覆盖扫描,不需要回表,然后通过 id 跟原表进行关联。

适用场景:当查询和排序字段(即 where 子句和 order by 子句涉及的字段)有对应覆盖索引时,且中间结果集很大的情况时适用。

分而治之

假设有 500W 的数据要进行批量更新

1
update coupons set status = 1 where status =0 and create_time >= '2020-10-01 00:00:00' and create_time <= '2020-10-07 23:59:59';

MySQL 中一个 SQL 只能使用一个 cpu core 去处理,如果 SQL 很复杂或者执行很慢,就会阻塞后面的 SQL 请求,造成活动连接数暴增,MySQL cpu 100%,响应的接口 timeout,同时对于主从复制架构,而且做了业务读写分离,更新 500W 数据需要 5 分钟,master 上执行了 5 分钟,binlog 传到 slave 也需要执行 5 分钟,那么 slave 数据延迟就有 5 分钟,在这期间会造成业务脏数据,比如本该失效的优惠券依旧被正常使用等。

优化思路:先获取 where 条件中的最小 id 和最大 id,然后分批次去更新,每个批次 1000 条,这样既能快速完成更新,又能保证主从复制不会出现延迟

建表优化

在表中建立索引

优先考虑 where、order by 使用的字段

尽量使用数字型字段(如性别,男:1,女:2)

若只含数值信息的字段尽量不要设计为字符型,这会降低查询和连接的性能,并会增加存储开销
这是因为引擎在处理查询和连接时会逐个比较字符串中每一个字符,而对于数字型而言只需要比较一次就够了

查询数据量大的表会造成查询缓慢

主要的原因是扫描行数过多。这个时候可以通过程序分段分页进行查询,循环遍历,将结果合并处理进行展示

要查询 100000 到 100050 的数据,如下:

1
2
3
select * from
(select ROW_NUMBER() over (order by ID asc) as rowid, * from infotab)t
where t.rowid >100000 and t.rowid<=100050
用 varchar\nvarchar 代替 char\nchar

尽可能的使用 varchar/nvarchar 代替 char/nchar。因为首先变长字段存储空间小,可以节省存储空间,其次对于查询来说,在一个相对较小的字段内搜索效率显然要高些

不要以为 null 不需要空间,比如 char(100)型,在字段建立时,空间就固定了,不管是否插入值(null 也包含在内),都是占用 100 个字符的空间的,如果是 varchar 这样的变长字段,null 不占用空间

SQL 优化一般过程

通过查日志等定位执行效率低的 SQL 语句
EXPLAIN 分析 SQL 的执行计划

需要重点关注 type、rows、filtered、extra

type

由上至下,效率越来越高

  • all:全表扫描
  • index:索引全扫描
  • range:索引范围扫描,常用于<、<=、>、>=、between、in 等操作
  • ref:使用唯一索引扫描或唯一索引前缀扫描,返回单条记录,常出现在关联查询中
  • eq_ref:类似 ref,区别在于使用的是唯一索引,使用主键的关联查询
  • const/system:单条记录,系统会把匹配行中的其他列作为常数处理,如主键或者唯一索引查询
  • null:MySQL 不访问任何表或索引,直接返回结果

虽然由上至下,效率越来越高,但是根据 cost 模型,假设由两个索引 idx1(a,b)、idx2(a,c),SQL 为”select * from t where a= 1 and b in (1,2) order by c;“。如果走 idx1,那么 type 为 range,如果走 idx2,那么 type 是 ref。当需要扫描的行数,使用 idx2 大约是 idx1 的 5 倍以上时,会使用 idx1,否则用 idx2

rows
filtered
Extra
  • Using filesort:MySQL 需要额外的一次传递,以找出如何按排序顺序索引行。通过根据联接类型浏览所有行并为所有匹配 where 子句的行保存排序关键字和行的指针来完成排序。然后关键字被排序,并按排序顺序索引行
  • Using temporary:使用临时表保存中间结果,性能特别差,需要重点优化
  • Using index:表示相应的 select 操作中使用了索引覆盖(covering Index),避免了访问表的数据行,效率不错。如果同时出现 Using where,意味着无法通过索引查找来查询到符合条件的数据
  • Using index condition:MySQL5.6 之后新增的 ICP(Index Condition Pushdown,索引下推),在存储引擎层进行数据过滤,而不是在服务层过滤,利用索引现有的数据减少回表的数据
show profile 分析

了解 SQL 执行的线程的状态及消耗的时间

默认是关闭的,开启语句 set profiling = 1;

1
2
show profiles;
SHOW PROFILE FOR QUERY #{id};
trace

trace 分析优化器如何选择执行计划,通过 trace 文件能进一步了解为什么优化器选择 A 执行计划而不选择 B 执行计划

1
2
3
set optimizer_trace = "enabled=on";
set optimizer_trace_max_mem_size=1000000;
select * from information_schema.optimizer_trace;

索引

索引是什么

官方介绍索引是帮助 MySQL 高效获取数据的数据结构。更通俗的说,数据库索引好比是一本书的目录,能加快数据库的查询速度

一般来说索引本身也很大,不可能全部存储在内存中,因此索引往往是存储在磁盘上的文件中的(可能存储在单独的索引文件中,也可能和数据一起存储在数据文件中)

我们通常所说的索引,包括聚集索引、覆盖索引、组合索引、前缀索引、唯一索引等,没有特别说明,默认都是使用 B+树结构组织(多路搜索树,并不一定是二叉的)的索引

为什么使用索引

  • 1.通过创建唯一性索引,可以保证数据库表中每一行数据的唯一性
  • 2.可以大大加快数据的检索速度
  • 3.帮助服务器避免排序和临时表
  • 4.将随机 IO 变为顺序 IO
  • 5.可以加速表和表之间的连接

为什么使用数据库索引能提高效率

  • 1.数据库索引的存储是有序的
  • 2.在有序的情况下,通过索引查询一个数据,是无需遍历索引记录的
  • 3.极端情况下,数据索引的查询效率为二分法查询效率,趋近于 O(log2N)

MyISAM 和 InnoDB 实现 B+树索引方式的区别是什么

  • MyISAM:B+树叶节点的 data 域存放的是数据记录的地址,再检索索引的时候,首先按照 B+树搜索算法搜索索引,如果指定的 Key 存在,则取出其 data 域的值,然后以 data 域的值为地址读取相应的数据。这被称为“非聚簇索引”
  • InnoDB:其数据本身就是索引文件,其表数据文件本身就是按 B+树组织的一个索引结构,树的叶子节点 data 域保存了完整的数据记录,这个索引的 key 是数据表的主键,因此 InnoDB 表数据本身就是主索引,这被称为“聚簇索引”或者“聚集索引”。而其余的索引都作为辅助索引,辅助索引的 data 域存储相应记录主键的值而不是地址。
    • 在根据主键索引搜索时,直接找到 key 所在的节点即可取出数据;在根据辅助索引查找时,需要先取出主键的值,再走一边主键索引。因此在设计表的时候,不建议用过长的字段作为主键,也不建议使用非单调的字段作为主键,这样会造成主键索引频繁分裂。

索引的优势和劣势

优势
  • 可以提高数据检索的效率,降低数据库的 IO 成本
  • 通过索引对数据进行排序,降低数据排序的成本,降低了 CPU 消耗
    • 被索引的列会自动进行排序,包括[单列索引]和[复合索引],只是复合索引的排序要复杂一些
    • 如果按照索引列的顺序进行排序,对应 order by 语句来说,效率就会提高很多
劣势
  • 索引会占据磁盘空间
  • 索引虽然会提高查询效率,但是会降低更新表的效率。比如每次对表进行增删改操作,MySQL 不仅要保存数据,还要保存或更新对应的索引文件

索引的类型

  • 主键索引
    • 索引列中的值必须是唯一的,不允许有空值
  • 普通索引
    • MySQL 中基本索引类型,没有什么限制,允许在定义索引的列中插入重复值和空值
  • 唯一索引
    • 索引列中的值必须是唯一的,但是允许为空值
  • 全文索引
    • 只能在文本类型 CHAR、VARCHAR、TEXT 类型字段上创建全文索引,字段长度比较大时,如果创建普通索引,在进行 like 模糊查询时效率比较低,这时可以创建全文索引。MyISAM 和 InnoDB 都可以使用全文索引
  • 空间索引
    • MySQL 在 5.7 之后的版本支持了空间索引,而且支持 OpenGIS 集合数据模型。MySQL 在空间索引这方便遵循 OpenGIS 集合数据模型规则
  • 前缀索引
    • 在文本类型如 CHAR、VARCHAR、TEXT 类型上创建索引时,可以指定索引列的长度,但是数值类型不能指定
  • 其他(按照索引列数量分类)
    • 1.单列索引
    • 2.复合索引
      • 复合索引的使用,需要遵循最左前缀匹配原则(最左匹配原则)。一般在条件允许的情况下使用复合索引替代多个单列索引使用

索引的数据结构

Hash 表

Hash 表,在 Java 中的 HashMap、TreeMap 等就是 Hash 表结构,以键值对的方式存储数据。我们使用 Hash 表存储表数据 Key 可以存储索引列,Value 可以存储行记录或者行磁盘地址。Hash 表在等值查询时效率很高,时间复杂度为 O(1),但是不支持范围快速查找,范围查找时还是只能通过全表扫描方式。

显然这种并不适合作为经常需要查找和范围查找的数据库索引使用。

二叉查找树

2021-04-02-13-28-2020210402132819

二叉树特点:每个节点最多有 2 个分叉,左子树和右子树数据顺序左小右大

这个特点就是为了保证每次查找都可以折半而减少 IO 次数,但是二叉树就很考验第一个节点的取值,因为很容易在这个特点下出现我们并不想发生的情况“树不分叉了”

2021-04-02-13-47-0920210402134708

显然我们在选择上应该避免这种不稳定的情况

平衡二叉树

平衡二叉树时采用二分法思维,平衡二叉查找树除了具备二叉树的特点,最主要的特征是树的左右两个子树的层级最多相差 1。在插入\删除数据时通过左旋\右旋操作保持二叉树的平衡,不会出现左子树很高、右子树很矮的情况

使用平衡二叉查找树查询的性能接近于二分查找法,时间复杂度是 O(log2N)。查询 id=6,只需要 2 次 IO

2021-04-02-13-52-3920210402135238

平衡二叉查找树依旧存在一些问题:

  • 1.时间复杂度和树高相关。树有多高就需要检索多少次,每个节点的读取都对应一次磁盘 IO 操作。树的高度就等于每次查询数据时磁盘 IO 操作的次数。在表数据量很大时,查询性能就会很差。(假设磁盘每次寻道时间为 10ms,1 百万的数据量,log2N 约等于 20 次磁盘 IO,耗时 20*10=0.2s)
  • 2.平衡二叉树不支持范围查询快速查找,范围查询时需要从根节点多次遍历,查询效率不高
B 树:(B-树)改造二叉树

MySQL 的数据是存储在磁盘文件中的,查询处理数据时,需要先把磁盘中的数据加载到内存中,磁盘 IO 操作非常耗时,所以我们优化的重点就是尽量减少磁盘 IO 操作。访问二叉树的每个节点就会发生一次 IO,如果想要减少磁盘 IO 操作,就需要尽量降低树的高度,那么如何降低树的高度呢?

加入 Key 为 bigint = 8 字节,每个节点有 2 个指针,每个指针为 4 个字节,一个节点占用的空间为 16 个字节(8+4*2=16)

因为在 MySQL 的 InnoDB 存储引擎一次 IO 会读取一夜的(默认一页 16k)的数据量,而二叉树一次 IO 有效数据量只有 16 字节,空间利用率极低。为了最大化利用一次 IO 空间,一个简单的想法是在每个节点存储多个元素,在每个节点尽可能多的存储数据。每个节点可以存储 1000 个索引(16k/16=1000),这样就将二叉树改造成了多叉树,通过增加树的叉数,将树从高瘦变成矮胖。构建 1 百万条数据,树的高度只需要 2 层就可以(1000*1000=1 百万),也就是说只需要 2 次磁盘 IO 就可以查询到数据。磁盘 IO 次数变少了,查询数据的效率也就提高了

这种数据结构我们称为 B 树,B 树是一种多叉平衡查找树,如下图主要特点

  • 1.B 树的节点中存储着多个元素,每个内节点有多个分支
  • 2.节点中的元素包含键值和数据,节点中的键值从大到小排序。也就是说,在所有的节点都存储数据
  • 3.父节点当中的元素不会出现在子节点中
  • 4.所有的叶子节点都位于同一层,叶节点具有相同的深度,叶节点之间没有指针连接

2021-04-02-14-45-2720210402144526

举个例子,在 B 树中查询数据的情况:

假如我们查询值等于 10 的数据,查询路径磁盘块 1->磁盘块 2->磁盘块 5

第一次磁盘 IO:将磁盘块 1 加载到内存中,在内存中从头遍历比较,10<15,走指针 P1,到磁盘块 2

第二次磁盘 IO:将磁盘块 2 加载到内存,在内存中从头遍历比较,10>7,走指针 P2,到磁盘块 5

第三次磁盘 IO:将磁盘块 5 加载到内存中,在内存中从头遍历比较,8<10,10=10,找到 10,取出 data,如果 data 存储的是行记录,取出 data,查询结束;如果存储的是磁盘地址,还需要根据磁盘地址到磁盘中取出数据,查询结束

相比于平衡二叉查找树,在整个查找过程中,虽然数据的比较次数没有明显的减少,但是磁盘 IO 次数会大大减少。同时由于我们的比较是在内存中进行的,所以比较的耗时可以忽略。B 树的高度一般 2 至 3 层就能满足大部分的应用场景,所以使用 B 树构建索引可以很好的提升查询的效率

B 树依然存在可以优化的地方:

  • 1.B 树不支持范围查询的快速查找,假如我们要查找 10 和 35 之间的数据,查到 15 之后,需要回到根节点重新遍历查找,需要从根节点进行多次遍历,查询效率有待提高
  • 2.如果 data 存储的是行记录,行的大小随着列数增多,所占空间会变大。这时一个页中可存储的数据量就会变少,树相应就会变高,磁盘 IO 次数就会变大
B+树:改造 B 树

B+树,作为 B 树的升级版,MySQL 在 B 树的基础上继续改造,使用 B+树构建索引。B+树和 B 树最主要的区别在于非叶子节点是否存储数据

  • B 树:非叶子节点和叶子节点都会存储数据
  • B+树:只有叶子节点才会存储数据,非叶子节点只存储键值。叶子节点之间使用双向指针连接,最底层的叶子节点形成了一个双向有序链表

2021-04-02-19-19-2820210402191928

B+树的最底层叶子节点包含了所有的索引项。从上图可以看到,B+树在查找数据的时候,由于数据都存放在最底层的叶子节点上,所以每次查找都需要检索到叶子节点才能查询数据。

所以在需要查询数据的情况下每次的磁盘的 IO 和树高有直接的关系,但是从另一方面来说,由于数据都被放到了叶子节点,放索引的磁盘块存储的索引数量是会跟着增加的,相对于 B 树来说,理论上 B+树是要比 B 树矮的。

也存在索引覆盖查询的情况,在索引中数据满足了当前查询语句所需要的全部数据,此时只需要找到索引即可立即返回,不需要检索到最底层的叶子节点。

等值查询

假如我们查询值等于 9 的数据,查询路径磁盘块 1->磁盘块 2->磁盘块 6

第一次磁盘 IO:将磁盘块 1 加载到内存中,在内存中从头遍历比较,9<15,走 P1 指针,到磁盘块 2

第二次磁盘 IO:将磁盘块 2 加载到内存中,在内存中从头遍历比较,7<9<12,走 P2 指针,到磁盘块 6

第三次磁盘 IO:将磁盘块 6 加载到内存中,在内存中从头遍历比较,在第 1 个索引中找到 9,取出 data,如果 data 存储的是行记录,取出 data,查询结束;如果存储的是磁盘地址,还需要根据磁盘地址到磁盘中取出数据,查询结束。(这里要区分的是在 InnoDB 中 data 存储的是行数据,在 MyISAM 中存储的是磁盘地址)

范围查询

假如我们想要查找 9 和 26 之间的数据,查找的路径是磁盘块 1->磁盘块 2->磁盘块 6->磁盘块 7

首先查找值等于 9 的数据,将值等于 9 的数据缓存到结果集。这一步和前面等值查询流程一致,发生了三次磁盘 IO

查到 9 之后,底层的叶子节点是一个有序列表,我们从磁盘块 6、键值 9 开始向后遍历,筛选出所有符合条件的数据

第四次磁盘 IO:根据磁盘 6 后继指针到磁盘中寻址定位到磁盘块 7,将磁盘块 7 加载到内存中,在内存中从头遍历比较,9<26<=26,将 data 缓存到结果集

主键具备唯一性(后面不会再有<=26 的数据),不需要再向后查找,查询结束。

可以看到 B+树可以保证等值查询和范围查询的快速查找,MySQL 的索引就采用了 B+树的数据结构

MySQL 的索引实现

MyISAM 索引

以一个简单的 user 表为例,user 表存在两个索引,id 列为主键索引,age 为普通索引

1
2
3
4
5
6
7
8
9
10
CREATE TABLE `user`
(
`id` int(11) NOT NULL AUTO_INCREMENT,
`username` varchar(20) DEFAULT NULL,
`age` int(11) DEFAULT NULL,
PRIMARY KEY (`id`) USING BTREE,
KEY `idx_age` (`age`) USING BTREE
) ENGINE = MyISAM
AUTO_INCREMENT = 1
DEFAULT CHARSET = utf8;

2021-04-06-11-22-1720210406112217

MyISAM 的数据文件和索引文件是分开存储的,MyISAM 使用 B+树构建索引树时,叶子节点中存储的键值为索引列的值,数据为索引所在行的磁盘地址

MyISAM 主键索引

2021-04-06-11-22

表 user 的索引存储在索引文件 user.MYI 中,数据存储在数据文件 user.MYD 中。

简单分析下查询时的磁盘 IO 情况:

  • 根据主键等值查询 select * from user where id = 28;
    • 1.先在主键树中从根节点开始检索,将根节点加载到内存,比较 28<75,走 P1 指针(1 次磁盘 IO)
    • 2.将左子树节点加载到内存中,比较 16<28<47,走 P2 指针,向下检索(1 次磁盘 IO)
    • 3.检索到叶子节点,将叶子节点加载到内存中,比较 16<28,18<28,28=28,查找到值等于 28 的索引项(1 次磁盘 IO)
    • 4.从索引项中获取磁盘地址,然后到数据文件 user.MYD 中获取整行记录(1 次磁盘 IO)
    • 5.将获取到的数据返回给客户端
    • 磁盘 IO 次数:3 次索引检索+1 次记录数据检索
  • 根据主键范围查询数据 select * from user where id between 28 and 47;
    • 1.先在主键树中从根节点开始检索,将根节点加载到内存,比较 28<75,走 P1 指针(1 次磁盘 IO)
    • 2.将左子树节点加载到内存中,比较 16<28<47,走 P2 指针,向下检索(1 次磁盘 IO)
    • 3.检索到叶子节点,将叶子节点加载到内存中,比较 16<28,18<28,28=28。查找到值等于 28 的索引项(1 次磁盘 IO)
    • 4.根据磁盘地址从数据文件中获取行记录,缓存到结果集中,我们查询语句范围查找时,需要向后遍历底层叶子链表,直至到达最后一个不满足筛选条件的数据
    • 5.向后遍历底层叶子链表,将下一个节点加载到内存中,遍历比较,28<47=47(1 次磁盘 IO)
    • 6.根据磁盘地址从数据文件中获取行记录缓存到结果集中
    • 7.最后将符合查询条件的结果集返回给客户端
    • 磁盘 IO 次数:4 次索引检索+1 次记录数据检索
  • 备注:以上分析仅供参考,MyISAM 在查询时,会将索引节点缓存到 MySQL 缓存中,而数据缓存依赖于操作系统自身的缓存,所以并不是每次都走磁盘,这里只是为了分析索引的使用过程
MyISAM 辅助索引

在 MyISAM 中辅助索引和主键索引的结构是一样的,没有任何区别,叶子节点的数据存储的都是行记录的磁盘地址,只是主键索引的键值是唯一的,而辅助索引的键值可以重复

查询数据时,由于辅助索引的键值不唯一,可能存在多个拥有相同记录的节点,所以即使是等值查询,也要按照范围查询的方式在辅助索引树种检索数据

InnoDB 索引
InnoDB 主键索引(聚簇索引)

每个 InnoDB 表都有一个聚簇索引,聚簇索引使用 B+树构建,叶子节点存储的数据是整行记录。一般情况下,聚簇索引等同于主键索引,当一个表没有创建主键索引时,InnoDB 会自动创建一个 ROWID 字段来构建聚簇索引。InnoDB 创建索引的具体规则如下:

  • 1.在表上定义主键 Primary Key,InnoDB 将主键索引用作聚簇索引
  • 2.如果表没有定义主键,InnoDB 会选择第一个不为 NULL 的唯一索引作为聚簇索引
  • 3.如果以上两个都没有,InnoDB 会使用一个 6 字节长整型的隐式字段 ROWID 字段构建聚簇索引,该 ROWID 字段会在插入新行时自动递增

除聚簇索引之外的所有索引都被称为辅助索引。在 InnoDB 中,辅助索引中的叶子节点存储的数据是该行的主键值。在检索时,InnoDB 使用此键值在聚簇索引中搜索记录

这里以 user_innodb 为例,user_innodb 的 id 列为主键,age 为普通索引

1
2
3
4
5
6
7
CREATE TABLE `user_innodb` (
`id` bigint(11) NOT NULL AUTO_INCREMENT,
`username` varchar(20) DEFAULT NULL,
`age` int(3) DEFAULT NULL,
PRIMARY KEY (`id`),
KEY `idx_age` (`age`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8;

2021-04-06-13-25-0320210406132502

InnoDB 的数据和索引存储在一个文件 user_innodb.ibd 中。InnoDB 的数据组织方式是聚簇索引。

主键索引的叶子节点会存储数据行,辅助索引只会存储主键值

2021-04-06-13-25-5120210406132550

  • 等值查询数据 select * from user_innodb where id = 28;
    • 1.先在主键树种从根节点开始检索,将根节点加载到内存,比较 28<75,走 P1 指针(1 次磁盘 IO)
    • 2.将左子树加载到内存,比较 16<28<47,走 P2 指针(1 次磁盘 IO)
    • 3.检索到叶节点,将叶子节点加载到内存,比较 16<28,18<28,28=28,查找到值等于 28 的索引项,直接可以获取整行数据,将记录返回给客户端(1 次磁盘 IO)
    • 磁盘 IO 次数:3 次
InnoDB 辅助索引

除聚簇索引之外的所有索引被称为辅助索引,InnoDB 的辅助索引只会存储主键值而非磁盘地址或整行数据

以表 user_innodb 的 age 为例,age 索引的结构如下图:
2021-04-06-13-32-3920210406133239

底层叶子节点按照(age,id)的顺序排序,先按照 age 从小到大排序,age 列相同时按照 id 从小到大排序

使用辅助索引需要检索两次索引:首先检索辅助索引获得主键,然后使用主键到主键索引种检索获得记录

  • 辅助索引等值查询数据 select * from user_innodb where age = 19;
    • 1.从辅助索引叶子节点获取到符合条件数据的主键
    • 2.从主键索引获取到对应主键的数据
    • 磁盘 IO 次数:辅助索引 3 次+主键索引 3 次
    • 根据在辅助索引中获取的主键 id,到主键索引树中检索数据的过程被称为回表查询
InnoDB 组合索引

创建一个表 abc_innodb,id 为主键索引,创建了一个联合索引 idx_abc(a,b,c)

1
2
3
4
5
6
7
8
9
CREATE TABLE `abc_innodb` (
`id` bigint(11) NOT NULL AUTO_INCREMENT,
`a` int(11) DEFAULT NULL,
`b` int(11) DEFAULT NULL,
`c` varchar(10) DEFAULT NULL,
`d` varchar(10) DEFAULT NULL,
PRIMARY KEY (`id`),
KEY `idx_abc` (`a`,`b`,`c`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8;
1
select * from abc_innodb order by a,b,c,id;

2021-04-06-13-51-3520210406135134

组合索引的数据结构:

2021-04-06-14-03-0720210406140306

  • 组合索引的查询过程 select * from abc_innodb where a=13 and b=16 and c=4;
    • 1.加载根节点,与(12,14,3)比较,发现大于,走右路
    • 2.加载右子树,与(14,14,14)比较,发现小于,走左路
    • 3.加载左叶子节点,按顺序比较,找到对应的值
    • 4.将结果返回给客户端
  • 最左匹配原则
    • 最左前缀匹配原则和联合索引的索引存储结构和检索方式是有关系的
    • 在组合索引树中,最底层的叶子节点按照第一列 a 从左到右递增排序,但是 b 列和 c 列是无序的,b 列只有在 a 列值相等的情况下小范围内递增有序,而 c 列只能在 a、b 两列相等的情况下小范围递增有序
    • 就像上面的查询,B+树会先比较 a 列来确定下一步应该搜索的方向,往左还是往右。如果 a 列相同再比较 b 列。但是如果查询条件没有 a 列,B+树就不知道第一步应该从哪个节点查起
    • 可以说创建的 idx(a,b,c)联合索引,相当于创建了(a)、(a,b)、(a,b,c)三个索引
    • 组合索引的最左前缀匹配:使用组合索引列查询时,MySQL 会一直向右匹配直至遇到范围查询(>、<、between、like)就停止查询
InnoDB 索引覆盖

覆盖索引并不是索引的结构,覆盖索引是一种很常见的优化手段。因为在使用辅助索引的时候,我们只可以拿到主键值,相当于获取数据还需要再根据主键查询主键索引再获取到数据。但是如果我们要查询的字段均为索引列,就意味着我们只需要查询到辅助索引的叶子节点就可以返回了,不需要回表操作,这种情况就是索引覆盖。

可以看一下执行计划:

  • 覆盖索引的情况
    2021-04-06-14-20-2020210406142019
  • 未覆盖索引的情况
    2021-04-06-14-20-5720210406142057

B+树索引和哈希索引的区别

B+树是一个平衡多叉树,从根节点到每个叶子节点的高度差值不超过 1,而且同层级的节点间有指针相互链接,是有序的

哈希索引就是采用一定的哈希算法,把键值换算成新的哈希值,检索时不需要类似 B+树那样从根节点到叶子节点逐级查找,只需要一次哈希算法即可,是无序的

  • 哈希索引的优势
    • 等值查询:哈希索引具有绝对的优势(前提是:没有大量重复键值,如果大量重复键值时,哈希索引的效率会很低,因为存在哈希碰撞)
  • 哈希索引不适用的情况
    • 1.不支持范围查询
    • 2.不支持索引完成排序
    • 3.不支持联合索引的最左前缀原则
  • 常用的 InnoDB 引擎中默认使用的是 B+树索引,它会实时监控表上索引的使用情况,如果认为建立哈希索引可以提高查询效率,则自动在内存中的“自适应哈希索引缓冲区”建立哈希索引(在 InnoDB 中默认开启自适应哈希索引),通过观察搜索模式,MySQL 会利用 index key 的前缀建立哈希索引,如果一个表几乎大部分在缓冲池中,那么建立一个哈希索引能够加快等值查询
  • 注意:在某些工作负载下,通过哈希索引查找带来的性能远大于额外的监控索引搜索情况和保持这个哈希表结构所带来的开销。但某些时候,在负载高的情况下,自适应哈希索引中添加的 read/write 锁也会带来竞争,比如高并发的 join 操作。like 操作和%的通配符也不适用于自适应哈希索引,可能要关闭自适应哈希索引。

为什么说 B+树比 B 树更适合实际应用中操作系统的文件索引和数据库索引

  • 1.B+树的磁盘读写代价更小,B+的内部节点并没有指向关键字具体信息的指针。因此其内部相对于 B 树更小。如果把所有同一内部节点的关键字存放于同一磁盘块中,那么盘块所能容纳的关键字数量也就越多。一次性读入内存中的需要查找的关键字也就越多。相对来说 IO 读写次数也就降低了。
  • 2.B+树的查询效率更稳定,由于非叶子节点不是指向最终文件内容的节点,而只是叶子节点中关键字的索引,所以任何关键字的查找都必须走一条从根节点到叶子节点的路,所有关键字查询的路径长度相同,导致每一个数据的查询效率相当
  • 3.B+树只需要遍历叶子节点就可以实现整棵树的遍历,而且在数据库中基于范围查询是非常频繁的,而 B 树只能中序遍历所有节点,效率太低

联合索引在索引树中的结构

主键索引和唯一索引的区别

  • 1.主键是一种约束,目的是对这个表的某一列进行限制,唯一索引是一种索引,是为了更快的查询
  • 2.主键不允许存在 null 值,唯一索引可以
  • 3.一个表最多只能有一个主键,但是可以包含多个索引
  • 4.主键创建后一定包含一个唯一索引,唯一索引并不一定都是主键
  • 5.主键可以被其他表引为外键
  • 6.主键数据必须唯一,索引数据可以不唯一

多个单列索引和联合索引的区别

  • 多个单列索引在多条件查询时,只会生效第一个索引
  • 联合索引最左前缀原则:当创建(a,b,c)联合索引时,相当于建立了(a)单列索引,(a,b)联合索引以及(a,b,c)联合索引
  • 联合索引对比每个列分别创建索引更有优势,因为索引简历的越多就越占磁盘空间,在更新数据的时候速度会更慢。另外建立联合索引时,顺序也是需要注意的,应该将严格的索引放在前面,这样筛选的力度会更大,效率更高

建立索引的原则

  • 1.选择唯一性索引
  • 2.为经常需要排序、分组或者联合操作的字段建立索引
  • 3.为经常用作查询条件的字段建立索引
  • 4.限制索引的数目,因为建索引有一定开销
  • 5.索引字段长度不能太长
  • 6.联合索引最左前缀,索引顺序很重要
  • 7.尽量选择区分度高的列作为索引
  • 8.频繁进行数据操作的表,不要建立太多的索引
  • 9.删除无用的索引

事务

事务是访问数据库的一个操作序列,数据库应用系统通过事务来完成对数据库的存取。事务的正确执行使得数据库从一个状态转换为另一个状态。

事务有 ACID 的原则:

  • 原子性(Atomicity):即不可分割,事务要么全部被执行,要么全部不被执行,没有中间状态。如果事务中所有的子事务全部提交成功,则所有的数据库操作都被提交,数据库状态会发生变化。如果有子事务失败,则其他子事务的数据库操作全部被回滚,数据库回到执行事务之前的状态,不会发生变化。
  • 一致性(consistency):事务的执行使数据库从一种正确状态转换为另一种正确状态
  • 隔离性(isolation):在事务正常提交之前,不允许把该事务对数据库数据的任何改变提供给其他事务,即在事务正确提交之前,它可能的结果对其他事务不可见
  • 持久性(durability):事务正确提交之后,其结果将永远保存在数据库中。即使事务提交之后有了其他故障,事务的处理结果也会得到保存

并发下事务会产生的问题

  • 1.脏读:事务 A 读到了事务 B 还没有提交的数据
  • 2.幻读:一个事务在前后两次查询同一范围的时候,后一次查询看到了前一次查询没有看到的行
  • 3.不可重复读:事务 A 首先读取了一条数据,执行逻辑的时候,事务 B 将这条数据改变了,事务 A 再次读取这条数据的时候发现数据不匹配了

事务隔离级别

  • read_uncommitted:读未提交。能够读取到没有被提交的数据,导致脏读、幻读、不可重复读
  • read_committed:读已提交。能够读取到已经被提交的数据,能解决脏读,无法解决幻读和不可重复读
  • repeateable_read:重复读取,解决了脏读、不可重复读,无法解决幻读
  • serializable:串行化,最高的事务隔离级别,解决了以上所有的问题,性能差,行级锁

MySQL 默认的事务隔离级别是 RR

引擎

MySQL 两种常见引擎的对比

  • InnoDB:
    • 提供了对数据库 ACID 事务的支持
    • 实现了 SQL 标准的四种隔离级别
    • 提供了行级锁和外键约束
    • 没有保存表的行数
    • 锁的粒度更小,写操作不会锁定全表
  • MyISAM
    • 没有提供对数据库事务的支持
    • 不支持行级锁和外键,即表级锁
    • 存储了表的行数
    • 适合度操作远远多于写操作,并且不需要操作数据库事务的场景

MySQL InnoDB 引擎主键的数据结构

MVCC

什么是 MVCC

MVCC,Multi-Version Concurrency Control,即多版本并发控制。MVCC 是一种并发控制的方法,一般在数据库管理系统种,实现对数据库的比广发访问,在编程语言种实现事务内存

MVCC 在 MySQL InnoDB 中的实现主要是为了提高数据库并发性能,用更好的方式去处理读-写冲突,做到即使有读写冲突时页不加锁,非阻塞并发读

当前读、快照读和 MVCC 什么关系

  • 1.准确的说,MVCC 指的时“维持一个数据的多个版本,使得读写操作没有冲突”这么一个概念,仅仅是一个理想概念
  • 2.而在 MySQL 中,快照读就是实现 MVCC 理想模型的其中一个具体非阻塞读功能。而相对而言,当前都就是悲观锁的具体功能实现
  • 3.快照读本身也是一个抽象概念。MVCC 模型在 MySQL 中的具体实现是由 3 个隐式字段、undo log、read view 等去完成的

什么是当前读和快照读

  • 当前读:像 select lock in shared mode(共享锁)、select for update、 insert、delete(排他锁)这些操作都是一种当前读,为什么叫当前读?就是它读取的是记录的最新版本,读取时还要保证其他并发事务不能修改当前记录,会对读到的数据加锁
  • 快照读:像不加锁的 select 操作就是快照读,即不加锁的非阻塞读。快照读的前提是隔离级别不是串行级别,串行级别下的快照读会退化为当前读。之所以出现快照读的情况,是基于提高并发性能的考虑,快照读的实现是基于版本本并发控制,即 MVCC。可以认为 MVCC 是行锁的一个变种,但它在很多情况下,避免了加锁操作,降低了开销。既然是基于多版本,即快照读可能读到的并不一定是数据的最新版本,而有可能是之前的历史版本
  • 说白了 MVCC 就是为了实现读-写冲突不加锁,而这个读指的就是快照读,而非当前读,当前读实际上是一种加锁的操作,是悲观锁的实现

MVCC 能解决什么问题

数据库并发场景有 3 种,分别是:

  • 读-读:不存在任何问题,也不需要并发控制
  • 读-写:有线程安全问题,可能会造成事务隔离性问题,可能遇到脏读、幻读、不可重复读
  • 写-写:有线程安全问题,可能会存在更新丢失问题

MVCC 带来的好处是:

MVCC 是一种用来解决读-写冲突的无锁并发控制,也就是为事务分配单项增长的时间戳,为每个修改保存一个版本,版本与事务时间戳关联,读操作只读该事务快照之前的数据库的快照,所以 MVCC 可以为数据库解决以下问题:

  • 1.在并发读写数据库时,可以做到在读操作时不用阻塞写操作,写操作也不用阻塞读操作,提高了数据库并发读写的能力
  • 2.同时还可以解决脏读、幻读、不可重复读等事务隔离问题,但不能解决更新丢失问题

组合:

  • 1.MVCC+悲观锁:MVCC 解决读写冲突,悲观锁解决谢谢冲突
  • 2.MVCC+乐观锁:MVCC 解决读写冲突,乐观锁解决谢谢冲突

MVCC 的实现原理

MVCC 的实现原理主要是依赖记录中的 3 个隐式字段、undo log、read view 来实现的

隐式字段

每行记录除了我们自定义的字段外,还有数据库隐式定义的 DB_TRX_ID、DB_ROLL_PTR、DB_ROW_ID 等字段

  • DB_TRX_ID:6byte,最近修改(修改、插入)事务 ID,记录创建这条记录或者最后一次修改该记录的事务 ID
  • DB_ROLL_PTR:7byte,回滚指针,指向这条记录的上一个版本(存储于 rollback segment 里)
  • DB_ROW_ID:6byte,隐含的自增 id(隐藏主键),如果数据库表没有主键,InnoDB 会自动以 DB_ROW_ID 产生一个聚簇索引
  • 实际上还有一个删除 flag 隐藏字段,即记录被更新或删除并不代表真的删除,而是删除 flag 变了

mvcc-2021-03-27-11-56-1620210327115615

undo log

undo log 主要分为两种:

  • insert undo log:代表事务在 insert 新纪录时产生的 undo log,只在事务回滚时需要,并且在事务提交后可以被立即丢弃
  • update undo log:事务在进行 update 或者 delete 时产生的 undo log。不仅在事务回滚时需要,在快照读时也需要,所以不能随便删除,只有在快照读或事务回滚不涉及该日志时,对应的日志才会被 purge 线程统一清除

对 MVCC 有帮助的实质是 update undo log,undo log 实际上就是存在 rollback segment 中旧记录链,它的执行流程如下:

1.比如有个事务插入了 person 表一条新记录,记录如下:name 为 zhangsan,age 为 20,隐式主键是 1,事务 ID 和回滚指针,我们假设为 null

mvcc-2021-03-27-12-00-1220210327120011

2.现在来了个事务 A 对该记录的 name 做了修改,改为 lisi

  • a.在事务 A 修改该行记录时,数据库会先对该行加排他锁
  • b.然后把该行数据拷贝到 undo log 中,作为旧记录,即在 undo log 中有当前行的拷贝副本
  • c.拷贝完毕后,修改该行 name 为 lisi,并且修改隐式字段中的事务 ID 为当前事务 A 的 ID,默认从 1 开始,之后递增,回滚指针指向拷贝到 undo log 的副本记录,即表示当前版本的上一个版本
  • d.事务提交后,释放锁

mvcc-2021-03-27-13-18-2920210327131828

3.又一个事务 B,修改同一条记录,将 age 改为 25

  • a.在事务 B 修改该行数据时,数据库也先为该行加锁
  • b.然后把该行数据拷贝到 undo log,作为旧记录,发现该行已经有了 undo log,那么最新的旧数据作为链表的表头,插在该行记录的 undo log 最前面
  • c.修改 age 为 25,并且修改隐式字段事务 ID 为当前事务 B 的 ID,自增即为 2,回滚指针指向刚刚拷贝到 undo log 的副本记录
  • d.事务提交之后,释放锁
    mvcc-2021-03-27-13-24-5620210327132456

从上面我们旧可以看出,不同事务或者相同事务对同一条记录进行修改,就会导致该记录的 undo log 成为一条记录版本线性表,即链表。undo log 的链首就是最新的记录,链尾就是最早的一条旧记录(当然就像之前说的该 undo log 的节点可能会被 purge 线程清除掉,像图中的第一条 insert undo log,其实在事务提交之后可能就被删除丢失了)

read view(读视图)

read view 就是事务进行快照读操作的时候产生的读视图,在该事务执行的快照读的那一刻,会生成数据库系统当前的一个快照,记录并维护系统当前活跃事务的 ID(当每个事务开启时,都会被分配一个 ID,这个 ID 是递增的,所以最新的事务 ID 最大)

read view 主要是用来做可见性判断的,主要是将要被修改的数据的最新记录中的 DB_TRX_ID 取出来,与系统当前其他活跃事务的 ID(由 read view 维护)去对比,如果 DB_TRX_ID 跟 read view 的属性做了某些比较,不符合可见性,那就通过 DB_ROLL_PTR 回滚指针去取出 undo log 的 DB_TRX_ID 再比较,即遍历链表的 DB_TRX_ID(从链首到链尾,即从最近的一次修改查起),直到找到满足特定条件的 DB_TRX_ID,那么这个 DB_TRX_ID 所在的旧记录就是当前事务能看见的最新的老版本

mvcc-2021-03-27-13-50-5720190314144440494

那么这个判断条件是什么呢?如上,它是一段判断可见性的源码,即 changes_visible 方法,该方法展示了我们拿 DB_TRX_ID 去跟 read view 某些属性进行怎样的比较

在展示之前,我们可以把 read view 简单的理解成有三个全局属性

  • trx_list:一个数值列表,用来维护 read view 生成时刻系统正活跃的事务 ID
  • up_limit_id:记录 trx_list 列表中事务 ID 最小的 ID
  • low_limit_id:read view 生成时刻系统尚未分配的下一个事务 ID,也就是目前已经出现过的事务 ID 的最大值+1

a. 首先比较 DB_TRX_ID<up_limit_id,如果小于,则当前事务能看到 DB_TRX_ID 所在的记录,如果大于等于进入下一个判断

b. 接下来判断 DB_TRX_ID 大于等于 low_limit_id,如果大于等于代表 DB_TRX_ID 所在的记录是在 read view 生成后才出现的,那对当前事务肯定不可见,如果小于则进入下一个判断

c. 判断 DB_TRX_ID 是否在活跃事务之中,trx_list.contains(DB_TRX_ID),如果在则代表 read view 生成时刻,DB_TRX_ID 对应的事务还在活跃,还没有 commit,DB_TRX_ID 对应事务修改的数据,当前事务也是看不见的。如果不在,说明 DB_TRX_ID 对应的事务在 read view 生成之前就已经 commit,DB_TRX_ID 对应的事务修改的结果,当前事务是能看见的

purge 线程

从前面的分析可以看出:为了实现 InnoDB 的 MVCC 机制,更新或者删除操作都只是设置以下老记录的 deleted_bit,并不真正将过时的记录删除

为了不影响磁盘空间,InnoDB 有专门的 purge 线程来清理 deleted_bit 为 true 的记录。为了不影响 MVCC 的正常工作,pruge 线程自己也维护了一个 read view(这个 read view 相当于系统最老活跃事务的 read view)

如果某个记录的 deleted_bit 为 true,并且 DB_TRX_ID 相对于 pruge 线程的 read view 可见,那么这条记录一定是可以被安全清除的

MVCC 整体流程

1.事务 A 和事务 C 进行中时,事务 B 对某行数据执行了快照读,数据库为该行数据生成了一个 read view 读视图,假设当前事务 ID 为 2,此时还有事务 A 和事务 C 在活跃中,事务 D 在事务 B 快照读前一刻提交更新了,所以 read view 记录了系统当前活跃事务 A,C 的 id,维护在一个列表上,假设称之为 trx_list

mvcc-2021-03-27-14-38-5820210327143858

2.read view 并不仅仅会通过一个列表 trx_list 来维护事务 B 执行快照读那刻系统正活跃的事务 Id,还会有两个属性 up_limit_id(记录 trx_list 列表中事务 ID 最小的 ID),low_limit_id(记录 trx_list 列表中事务 ID 最大的 ID,也有人说快照读那刻系统尚未分配的下一个事务 ID 也就是目前已经出现过的事务 ID 的最大值+1)。所以在这里 up_limit_id 就是 1,low_limit_id 就是 4+1=5,trx_list 集合的值就是[1,3],read view 如下图

mvcc-2021-03-27-15-01-1320210327150112

3.目前只有事务 D 修改过记录, 并在事务 B 执行快照读之前提交了事务,所以当前该行当前数据的 undo log 如下图所示。事务 B 在快照读该行记录的时候,就会拿该行记录的 DB_TRX_ID 去跟 up_limit_id,low_limit_id 和活跃事务 id 列表进行比较,判断当前事务 B 能看到该记录的版本是哪个

mvcc-2021-03-27-15-07-5220210327150752

4.所以先拿该记录 DB_TRX_ID 字段记录的事务 ID4 去和 read view 的 up_limit_id 比较,看 4 是否小于 up_limit_id(1),所以不符合条件。继续判断 4 是否大于等于 low_limit_id(5),也不符合条件。最后判断活跃事务列表中的活跃事务 id,发现 4 不在列表中,符合可见性条件,所以事务 D 修改后提交的最新结果对事务 B 快照读时是可见的,所以事务 B 能读到的最新数据记录是事务 D 所提交的版本,而事务 D 提交的版本也是全局角度上的最新的版本

mvcc-2021-03-27-15-43-5620210327154355

5.也正是 read view 生成的时机不同,造成 RC,RR 隔离级别下快照读的结果不同

Redis

数据结构

基本类型

  • string(字符串):二进制安全的数据类型,可以用来存储图片二进制流或者序列化之后的对象,最大长度 512M。
  • hash(散列表):string 的键值对集合,特别适合用来存储对象结构。最大可以存储 2^32-1 个键值对。
  • list(列表):简单的 string 列表,双向链表,可以头插(LPUSH)或者尾插(RPUSH),相当于 LinkedList,按照插入顺序排序,写操作时间复杂度 O(1),读操作时间复杂度 O(n)。最大长度是 2^32-1。
  • set(无序集合):string 的无序集合,内部使用 hash 表保证唯一性,添加、删除、查找时间复杂度都是 O(1),最大成员个数是 2^32-1。
  • zset(有序集合):即 sorted set,带有排序功能的 string 集合,不允许重复的成员,通过为每个对象设置一个 double 类型的分数(score)来实现排序,根据 score 从小到大对对象进行排序,不同的成员可以设置相同的 score,当 score 相同的时候,会按照被插入的键的字典顺序进行排序。

特殊类型

  • bitmap(位图):2.2.0 版本新增,byte 数组,用二进制表示,只有 0 和 1 两个数字,基于 string 实现,节省内存。
  • geo(地理):3.2 版本新增,存储地理位置信息,底层由 zset 实现。
  • hyperloglog:2.8.9 版本新增,用于基数统计。

缓存穿透

查询不存在于缓存也不存在于数据库的数据,这样相当于缓存不存在而直接请求数据库,当并发量大时会对数据库造成很大压力,严重时甚至可以导致数据库宕机

解决方案

  • 接口层增加参数校验
  • 缓存空值,设置较短的过期时间
  • 布隆过滤器

缓存击穿

缓存中的热点数据存活时间到期或者被误删除,并发查询此缓存数据的请求穿过缓存达到数据库

解决方案

  • 对热点缓存 key 不设置过期时间
  • 分布式锁:遇到热点缓存 key 失效时,先去请求分布式锁,拿到锁的线程才能去请求数据库。

缓存雪崩

缓存数据批量过期,大量的请求在缓存中查不到数据打到数据库,给数据库带来很大压力,严重时可能导致数据库宕机

解决方案

  • 设置缓存 key 过期时间增加随机值,避免缓存同时批量失效
  • 对热点缓存 key 不设置过 i 时间
  • 本地内存缓存+Redis 缓存+服务降级策略

Redis 几种分布式方案

单机模式

优点:简单

缺点:内存容量有限,处理能力有限,无法实现高可用

主从复制

主从复制允许一个 Redis 服务器存在多个复制,被复制的服务器为 master,从 master 进行复制的服务器为 slave,master 会将自身的数据通过网络更新同步给 slave

优点:可以通过 master 写入数据,通过 msater\slave 读取数据,缓解了 master 读压力

缺点:无法实现高可用,没有解决 master 写入的压力

哨兵 sentinel

监控 Redis 主从服务器,并可以在 master 下线时自动进行故障转移,选举 master 的一个 slave 为新的 master

监控(Monitoring):sentinel 会不断检查 master 和 slave 是否正常运行

提醒(Notification):当被监控的某台 Redis 服务器出现问题时,sentinel 可以通过 API 等方式向管理员或其他应用程序发送通知

自动故障迁移(Automatic failover):当一台 master 不能正常运行时,sentinel 会开始一次自动故障迁移工作,将此 master 的一台 slave 升级为 master,当原来下线的 master 故障恢复之后,会自动降级为新 master 的 slave

优点:保证高可用,提供节点监控及报警功能

缺点:占用服务器资源,主从切换需要时间,可能存在丢失数据的风险,没有解决 master 写的压力

集群(代理方式)

通过 Twemproxy 或者 Codis 等第三方代理实现 Redis 集群的方式

优点:支持多种 hash 算法,提供第三方功能支持

缺点:引入代理,维护成本高。failover 逻辑需要自己实现,其本身不能支持故障的自动转移。可拓展性差,进行扩缩容都需要手动干预

集群(Redis Cluster 3.0 版本新增)

Redis Cluster 集群,无中心结构,每个节点保存数据和整个集群的状态,每个节点都和其他所有节点保持连接

优点:无中心架构,数据按照 slot 存储在多个节点,节点间数据共享,可以动态调整数据分布,可拓展,节点可以动态添加或者删除。高可用,部分节点不可用不影响整体集群,可以通过对节点增加 slave 的方式进行数据备份。实现故障自动 failover,节点间通过 gossip 协议交换状态信息,用投票机制完成主动角色切换。

缺点:资源隔离性较差,容易互相影响。数据通过异步复制,不保证数据的强一致性。

Redis 哨兵原理

一致性 hash 和 hash 槽

gossip

raft 算法

raft 算法是一种简单易懂的共识算法,它依靠状态机和主从同步的方式,在各个节点之间实现数据的一致性

raft 的两个核心要点:

  • 1.选取主节点
  • 2.同步数据

raft 算法在选取主节点的过程,是通过多个节点之间的投票竞争

raft 算法未节点定义了三种角色:

  • 1.leader(主节点)
  • 2.follower(从节点)
  • 3.Candidate(参与投票竞争的节点、候选人)

raft 选主流程

  • 1.在最初还没有一个主节点的时候,所有节点的身份都是 follower,每个节点都有自己的计时器,当计时器达到了超时时间(Election Timeout),该节点会转变为 Candidate
  • 2.成为 Candidate 的节点,会首先给自己投票,然后向集群中所有其他的节点发起请求,要求大家都给自己投票
  • 3.其他收到投票请求还未投票的 follower 会像发起者投票,发起者收到反馈通知后,票数增加
  • 4.当票数超过了集群节点的一般,该节点晋升为 leader。leader 节点会立即向其他节点发出通知,告诉大家自己才是老大。收到通知的节点全部变为 follower,同时将自己的计时器清零

这里需要注意:每个节点的超时时间都是不一样的。比如 A 节点的超时时间是 3 秒,B 节点的超时时间是 4 秒,C 节点的超时时间是 5 秒,这样一来,A 节点将会最先发起投票请求,而不是所有的节点同时发起

为什么这样设计呢?设想所有的节点同时发起投票,必然会导致大家的票数差不多,形成僵局,谁也当不成老大

那么,成为 leader 的节点是否就坐稳了老大的位置呢?并不是,leader 节点需要每隔一段时间就向集群其他节点发送心跳通知,表明它还活着

一旦 leader 节点挂掉,发不出通知,那么即使到达了超时时间的 follower 节点会转变为 candidate 节点,发起选主投票,周而复始

raft 数据同步流程

  • 1.客户端提交数据到 leader 节点
  • 2.由 leader 节点把数据复制到集群内的所有 follower 节点,如果一次复制失败,会不断进行重试
  • 3.follower 节点们收到数据,会反馈给 leader 节点
  • 4.如果 leader 节点接收到超过半数的 follower 反馈,表明复制成功,于是提交自己的数据并通知客户端数据提交成功
  • 5.由 leader 节点通知集群内所有 follower 节点提交数据,从而完成数据同步流程

共识算法的应用场景

  • 在用于共享配置和服务发现的 k-v 存储系统 etcd 中,使用的就是 raft 算法来保证分布式一致性

除了 raft 算法之外,还有哪些算法解决了拜占庭将军问题

  • 1.paxos 算法:早期的共识算法,由拜占庭将军问题的提出者 Leslie Lamport 所发明;谷歌的分布式锁服务 Chubby 就是以 paxos 算法为基础
  • 2.ZAB 算法:zookeeper 所有的一致性算法,在流程上和 raft 算法比较接近
  • 3.PBFT 算法:区块链技术所有的共识蒜贩之一,适用于私有链的共识

分布式锁

单机

加锁命令

SET key_name key_value NX PX 30000

这个命令利用了 Redis 2.6.12 版本以后提供的 set(ex nx px xx)指令,这个指令支持对 set 指令设置参数

其中:

  • EX:将 key 的过期时间设置为 seconds 秒
  • PX:将 key 的过期时间设置为 millsconds 毫秒
  • NX:只有 key 不存在时才能设置成功
  • XX:只有 key 已经存在时才能设置成功

设置过期时间时为了保证操作线程如果在成功获取锁之后挂掉了,锁到了过期时间依旧可以自动释放,避免了死锁情况的发生

这个命令具有原子性

value 值必须是随机数主要是为了能够更安全地释放锁,释放锁地操作需要结合 lua 脚本实现,为了保证原子性

解锁命令

1
2
3
4
5
if redis.call("get",KEYS[1]) == ARGV[1] then
return redis.call("del",KEYS[1])
else
return 0
end

这个指令的意思是:主要 key 存在并且 value 的值和传入的值一样才能进行删除操作,否和返回 0

这样操作的目的是为了避免一个实例删除了其他实例获取的锁,删除锁不能先 get 再 del,因为这两步没有原子性,假设 A 线程调用 get 后锁刚好时间到了,A 线程的锁过期自动删除了,别的线程获取到了锁,这时候 A 线程调用删除操作会将别的线程的锁给删掉,而 lua 脚本保证了这两个操作的原子性

优点:代码简洁

缺点:

  • 只支持单 Redis 实例,如果是集群或主从模式下,加锁成功后,锁还没有从 master 复制到 slave 的时候,master 挂掉了,其他线程可以获取到锁,就会出现同一个资源被多个线程加锁的情况
  • 如果所著的代码块逻辑执行时间过长,可能会超过缓存的过期时间,锁自动释放,相同的逻辑可能会被执行多次
  • 不支持重入

集群(RedLock)

RedLock 算法:在 Redis 的分布式环境中,假设有 N 个 Redis Master,这些节点完全互相独立,不存在主从复制或者其他集群协调机制。我们确保将在 N 个实例上使用与在 Redis 单实力下相同的方法获取和释放锁。现在假设有 5 个 Redis Master 节点,同时需要我们在 5 台服务器上运行这些 Redis 实例,确保他们不会同时宕机。

为了获取锁,客户端应执行以下操作:

  • 获取当前 Unix 时间,以毫秒为单位
  • 依次尝试从 5 个实例,使用相同的 key 和具有唯一性的 value 获取锁。当向 Redis 请求获取锁时,客户端应该设置一个网络连接和响应超时时间,这个超时时间应该小于锁的失效时间。例如你的锁自动失效时间为 10 秒,则超时时间应该在 5-50 毫秒之间,这样可以避免服务器端 Redis 已经挂掉的情况下,客户端还在死死地等待响应结果。如果服务端没有在规定时间内响应,客户端应该尽快尝试去另外一个 Redis 实例请求获取锁。
  • 客户端使用当前时间减去开始获取锁时间(步骤 1 记录的时间)就得到获取锁消耗的时间。当且仅当大多数(N/2+1,5 个 Redis 实例就是 3 个节点)的 Redis 都获取到锁,并且使用的时间小于锁失效时间,锁才算获取成功。
  • 如果取到了锁,key 的真正有效时间等于总有效时间-获取锁所消耗的时间(步骤 3 计算的结果)
  • 如果因为某些原因,获取锁失败(没有在超过 N/2+1 个节点获取到锁或者获取锁的时间超过了有效时间),客户端应该在所有的 Redis 实例上进行解锁(即使某些 Redis 实例根本没有加锁成功,防止某些节点获取到锁但是客户端没有得到响应的情况)

Redission 已经实现了 RedLock 算法,并且支持锁续期、可重入等。

Redis 数据丢失

  • Redis 哨兵:
    • 当主节点发生故障时,需要进行主备切换,可能会导致数据丢失
  • 异步复制时导致的数据丢失:
    • 主节点异步同步数据给备用节点的过程中,主节点宕机了,导致有部分数据未同步到备用节点,而这个从节点又被选举为 Master,这时候就会有部分数据丢失。
  • 脑裂导致的数据丢失:
    • 主节点所在机器脱离了集群网络,实际上自身还是运行着的,但这时原来的集群中主节点的从节点升级为了主节点,这个时候就有两个主节点都在运行,这就是脑裂。
    • 发生脑裂后,客户端还没有来得及切换到新的主节点,连的还是原来的,有些数据还是写到了最开始的主节点中,新的主节点还没有这些数据。等到第一个节点恢复后,会降级为从节点连接到集群中,而自身数据会被清空,重新从主节点复制数据。而新的主节点因为没有客户端在之前写入的数据,所以导致数据丢了一部分。

如何避免 Redis 数据丢失

  • 配置 min-slaves-to-write 1,表示至少有一个从节点
  • 配置 min-slaves-max-lag 10,表示数据复制和同步的延迟不能超过 10s,最多丢失 10s 的数据

Redis 为什么这么快

  • 1.纯内存操作,一般都是简单的存取操作,线程占用的时间很多,时间的花费主要集中在 IO 上,所以读取速度快。
  • 2.整个 Redis 就是一个全局 Hash 表,它的时间复杂度是 O(1)。而且为了防止 hash 冲突导致链表过长,Redis 会执行 reHash 操作,扩充 hash 桶数量,减少 hash 冲突。并且防止一次性重新映射数据过大导致线程阻塞,采用渐进式 reHash,巧妙地将一次性拷贝分摊到多次请求过程中,避免阻塞。
  • 3.Redis 采用的是非阻塞 IO,IO 多路复用,处理客户端请求时不会阻塞主线程。采用了单线程来轮询描述符,将数据库的开、关、读、写都转换成了事件,Redis 采用自己实现的时间分离器,效率比较高。
  • 4.单线程,省去了上下文切换的消耗,CPU 利用率高
  • 5.Redis 全程使用 Hash 结构,读取速度快,还有一些特殊的数据结构,对数据存储进行了优化,如压缩表,对短数据进行压缩存储,再如跳表,使用有序的数据结构加快了读写速度。
  • 6.根据实际存储的数据类型选择不同的编码

Redis 持久化

Redis 高性能的背后很大程度上是因为其将所有的数据都存储在内存,然而当 Redis 重启后,所有存储在内存中的数据就会丢失。

这时我们希望 Redis 能将数据从内存中以某种形式同步到磁盘中,使得重启后可以根据磁盘中的记录恢复数据,这一过程称之为持久化。

Redis 提供两种持久化方式,分别是 RDB 和 AOF。前者根据指定的规则”定时“将内存中的数据存储在磁盘上,后者在每次执行命令后将命令本身记录下来。

RDB

  • 可以在指定的时间间隔内对存储在 Redis 服务器中的某个时间点的数据做快照。主进程会 fork 出一个子进程将快照数据写入临时文件并定期刷新到磁盘,Redis 在启动时会读取之前生成的快照文件内容加载到内存中
  • 自动触发:根据配置文件 redis.conf 中的配置决定。save m n 表示 m 秒内数据集存在 n 次修改时,自动触发 bgsave
  • 手动触发:
    • save:执行该命令时,Redis 服务器会被阻塞,不能再接收和处理其他命令,直到整个 RDB 持久化过程结束为止
    • bgsave:执行该命令时,Redis 会在后台异步进行快照,此时 Redis 仍可对外响应来自客户端的请求。实际上 Redis 的大部分 RDB 操作都是基于 bgsave 命令。

AOF

  • 以指定的时间间隔将服务器执行的所有指令记录下来,以追加的方式写入到指定的日志文件中,当日志文件过大时,Redis 会创建子进程对日志文件进行重写。Redis 提供了三种同步策略:每次修改同步、每秒同步、不同步(不同步指的是 Redis 不会主动触发)

对比

Redis 先加载 AOF 文件来恢复原始数据,因为 AOF 数据比 RDB 更完整。

但是 AOF 文件容易被损坏,损坏的 AOF 文件可以通过 redis-check-aof 工具进行修复

RDB 文件进行了压缩,所以占用体积小,传输速度快,但是如果设置备份时间间隔太长,丢失数据量较大;数据量越大,fork 子进程时间越长,可能阻塞主进程,可读性差

AOF 设置每秒追加一次,如果发生宕机只会丢失 1s 内的数据,生成文件较大,相对于 RDB 效率低,启动恢复慢

混合模式(4.0 版本新增)

生成新的 AOF 文件,前半段是 RDB 文件的全量数据,后半段是 AOF 模式的增量数据。在重启时加载此 AOF 文件进行恢复

Redis 内存淘汰策略

Redis 在进行内存淘汰时,采用两种方式:定时扫描主动清除+惰性删除(访问到已经过期的 key 时才进行删除)

  • noeviction:当内存达到 maxmemory 限制时,直接报错
  • volatile-lru:在所有设置了过期时间的 key 中,挑选最近最少使用的数据进行淘汰
  • volatile-random:在所有设置了过期时间的 key 中随机挑选一些进行淘汰
  • volatile-ttl:在所有设置了过期时间的 key 中,选择一些即将过期的 key 进行淘汰
  • allkeys-lru:在所有 key 中挑选最近最少使用的数据进行淘汰
  • allkeys-random:在所有 key 中随机挑选一些进行淘汰
  • (volatile-lru、volatile-random 和 volatile-ttl 在没有符合条件的 key 可以被淘汰时,表现和 noeviction 一样)
  • volatile-lfu(4.0 新增):在所有设置了过期时间的 key 中,挑选最近最不经常使用的 key 进行淘汰
  • allkeys-lfu(4.0 新增):在所有 key 中挑选最近最不经常使用的 key 进行淘汰

与 memcached 比较

  • Redis 支持多种数据类型,memcached 只支持 string 类型
  • Redis 的 string 类型最大可以达到 512M,而 memcached 最大只支持 1M
  • memcached 只支持把数据存储到内存,而 Redis 提供了持久化方式
  • memcached 本身不支持分布式,需要通过客户端的分布式算法实现分布式集群,而 Redis 通过 Redis Cluster 提供了分布式集群功能
  • memcached 支持多核多线程,而 Redis 接收客户端指令只支持单线程

Redis 性能优化

优化网络延时

如果使用单机部署(应用服务和 Redis 在同一台机器上)的话,使用 Unix 进程间通讯来请求 Redis 服务,速度比 localhost 局域网(学名:loopback)更快

但是很多公司的业务规模不是单机部署能够支撑的,所以还是要用 TCP

Redis 客户端和服务器的通讯一般使用 TCP 长连接。如果客户端发送请求后需要等待 Redis 返回结果再发送下一条指令,客户端和 Redis 的多个请求就如下图:

2021-04-13-17-17-4020210413171739

(备注:如果要发送的 key 不是特别长,一个 TCP 包完全能放的下 Redis 指令,所以只画了一个 PUSH 包)

这样两次请求中,客户端都要经历一段网络传输时间

合并请求

但如果有可能,完全可以使用multi-key类的指令来合并请求,比如两个 GET key 可以用 MGET key1 key2 合并,这样在实际通讯中,请求次数也减少了,延时自然减少了

如果不能用 multi-key 指令来合并,比如一个 SET 一个 GET,无法合并,还有以下解决办法:

  • MULTI/EXEC

构建事务,可以合并多个指令为一个 request,通讯过程如下:

2021-04-13-17-26-1620210413172616

  • script

最好利用缓存脚本的sha1 hash key来调起脚本,这样通讯量更小

以上两种解决方案要求这个transaction/script中涉及的 key 在同一个 node 上,所以要酌情考虑

合并 response

如果没有办法合并多个请求,还可以考虑合并多个 response,比如把两个回复信息合并:

2021-04-13-17-33-3220210413173331

这样理论上可以省去一次回复所用的网络传输事件,这就是pipeline的功能

不是任意多个回复信息都可以放进一个 TCP 包中,如果请求数太多,回复的数据很长(比如 GET 一个长字符串),TCP 还是会分包传输,但是使用 pipeline 依然可以减少传输次数

pipeline 和上面其他的方法不一样的是,它不具有原子性。所以在 Cluster 状态下的集群,pipeline 更容易实现

小结
  • 1.使用 unix 进程间通信,如果单机部署
  • 2.使用 multi-key 指令合并多个指令,减少请求数
  • 3.使用 transaction、script 合并 requests 以及 responses
  • 4.使用 pipeline 合并 responses

警惕执行时间长的操作

在大数据量的情况下,有些操作执行时间会相对长,比如 KEYS*、LRANGE list 0 -1,以及其他算法时间复杂度为 O(N)的指令。因为 Redis 只用一个线程来做数据查询,如果这些指令执行时间很长,就会阻塞 Redis,造成大量延时

Redis 中 transaction、script 等因为可以合并多个 commands 为一个具有原子性的执行过程,所以也可能占用 Redis 很长时间

如果想找出生产环境使用的“慢指令”,可以利用 SLOWLOG GET 5 来查看最近的 5 个执行时间很长的指令。至于多长算长,可以通过在 redis.conf 中设置 slowlog-log-slower-than 来定义

1
2
3
4
5
6
7
8
9
10
11
12
13
14
# Redis has two primitives to delete keys. One is called DEL and is a blocking
# deletion of the object. It means that the server stops processing new commands
# in order to reclaim all the memory associated with an object in a synchronous
# way. If the key deleted is associated with a small object, the time needed
# in order to execute the DEL command is very small and comparable to most other
# O(1) or O(log_N) commands in Redis. However if the key is associated with an
# aggregated value containing millions of elements, the server can block for
# a long time (even seconds) in order to complete the operation.
#
# For the above reasons Redis also offers non blocking deletion primitives
# such as UNLINK (non blocking DEL) and the ASYNC option of FLUSHALL and
# FLUSHDB commands, in order to reclaim memory in background. Those commands
# are executed in constant time. Another thread will incrementally free the
# object in the background as fast as possible.

del 一个大的 Object 的时候,回收相应的内存可能会需要很长时间(甚至几秒),所以建议用 del 的异步版本:UNLINK。后者会启动一个新的 thread 来删除目标 key,而不阻塞原来的线程

当一个 key 过期后,Redis 一般也需要同步地将它删除。其中一种删除 key 的方式是:每 10 秒检查一次又设置过期时间的 key,这些 key 存储在一个全局的 struct 中,可以用 server.db->expires 访问。检查的方式是:

  • 1.从中随机取出 20 个 key
  • 2.把过期的删掉
  • 3.如果刚刚的 20 个 key 中,有 25%以上(也就是 5 个以上)都是过期的,那么 Redis 认为过期的 key 还挺多的,继续重复步骤 1,直到满足退出条件:某次取出的 key 中没有那么多过期的 key

这里对于性能的影响是,如果真的有很多 key 在同一时间过期,那么 Redis 会一直循环执行删除,占用主线程

对此,Redis 作者的建议是警惕 EXPIREAT 这个指令,因为它更容易产生 key 同时过期的现象。还有一些建议是给 key 的过期时间设置一个随机波动量。最后,redis.conf 中也给出了一个办法,把 keys 的过期删除操作变为异步,即在 redis.conf 中设置 lazyfree-lazy-expire yes

优化数据结构、使用正确的算法

一种数据类型(比如 string、list)进行增删改查的效率是由其底层的存储结构决定的

我们在使用一种数据类型是,可以适当关注一下它底层的存储结构及算法,避免使用复杂度太高的方法,举例:

  • ZADD 的时间复杂度是 O(log(N)),这比其他数据类型增加一个新的元素的操作更复杂,所以要小心使用
  • 若 Hash 类型的值的 fields 的数量有限,它很可能采用 zipList 这种结构做存储,而 zipList 的查询效率可能没有同等字段数量的 hashTable 效率高,在必要时,可以调整 Redis 的存储结构

除了时间性能上的考虑,有时候我们还需要节省空间,比如上面提到的 zipList 结构,就比 hashTable 结构节省空间。但节省空间的数据结构,其算法的复杂度可能很高。所以这里就需要在具体问题面前做出权衡。

考虑操作系统和硬件上是否影响性能

Redis 运行的外部环境,也就是操作系统和硬件显然也会影响 Redis 的性能

  • CPU:intel 多种 CPU 都比 AMD 皓龙系列好
  • 虚拟化:实体机比虚拟机好,主要是因为部分虚拟机上,硬盘不是本地硬盘,监控软件导致 fork 指令的速度慢(持久化时会用到 fork),尤其是用 Xen 来做虚拟化时
  • 内存管理:在 linux 操作系统中,为了让 translation lookaside buffer 即 TLB,能够管理更多内存空间(TLB 只能缓存有限个 page),操作系统把一些 memory page 变得更大,比如 2MB 或者 1GB,而不是通常的 4096 字节,这些大的内存页叫做 huge pages。同时为了方便程序员使用这些大的 page,操作系统中实现了一个 transparent huge pages(THP)机制,使得大内存页对他们来说是透明的,可以像使用正常的内存 page 一样使用他们。但这种机制并不是数据库所需要的,可能是因为 THP 会把内存空间变得紧凑而连续,而数据库需要的是稀疏的内存空间,所以请禁用掉 THP 功能。redis 也不例外,但 redis 官方博客上给出的理由是:使用大内存 page 会使 bgsave 时,fork 的速度变慢;如果 fork 之后,这些内存 page 在原进程中被修改了,他们就需要被复制(即 copy on write),这样的复制会消耗大量的内存,所以请禁止掉操作系统中的 transparent huge page 功能
  • 交换空间:当一些内存 page 被存储在交换空间文件上,而 redis 又要请求那些数据,那么操作系统会阻塞 redis 进程,然后把想要的 page 从交换空间中拿出来,放进内存。这其中涉及整个内存的阻塞,所以可能会造成延时问题,一个解决方法是禁用交换空间

考虑持久化带来的开销

Redis 的一项重要功能就是持久化,也就是把数据复制到硬盘上。基于持久化,才有了 Redis 的数据恢复等功能。但是维护持久化这个功能,也是有性能开销的。

RDB 全量持久化

这种持久化方式把 Redis 中的全量数据打包成 RDB 文件放在硬盘上,但是执行 RDB 持久化的过程时原进程 fork 出来一个子进程,而 fork 这个系统调用是需要时间的,而这段时间内,Redis 是无法响应请求的

为此,要使用合理的 RDB 持久化的时间间隔,不要太频繁

AOF 增量持久化

这种持久化方式会把发到 Redis server 的指令以文本的形式保存下来(格式遵循 Redis Protocol),这个过程中,会调用两个系统调用,一个是 write,同步完成,一个是 fsync,异步完成。

这两步都可能是延时问题的原因:

  • write 可能会因为输出的 Buffer 满了,或者 kernal 正在把 Buffer 中的数据同步到硬盘,就被阻塞了
  • fsync 的作用是确保 write 写入到 AOF 文件的数据落到了硬盘上,在一个 7200 转/分的硬盘上可能要延时 20ms,消耗还是挺大的。更重要的是在 fsync 进行的时候,write 可能会被阻塞

其中,write 的阻塞貌似只能接受,因为没有更好的办法把 Buffer 中的数据写入到一个文件中了。但对于 fsync,Redis 允许三种配置,选用哪种取决于对备份及时性和性能的平衡。

  • always:当把 appendfsync 设置为 always,fsync 会和客户端的指令同步执行,因此可能造成延时问题,但备份及时性最好
  • everysec:每秒钟异步执行一次 fsync,此时 Redis 的性能表现会更好,但是 fsync 依然可能阻塞 write,算是一个折中选择
  • no:Redis 不会主动触发 fsync(并不是永远不 fsync),而是由 kernal 决定何时 fsync

使用分布式架构,读写分离,数据分片

首先说,哪些情况下不得不(或者说最好)使用分布式架构

  • 1.数据量很大,单台服务器内存装不下
  • 2.需要服务高可用
  • 3.单台请求的压力过大

解决这些问题可以用用数据分片或者主从分离,或者两者都用(即在分片用的 cluster 节点上,也设置主从结构)

这样的架构,可以为性能提升加入新的切入点:

  • 把慢速的指令发送到某些从库上执行
  • 把持久化功能放到一个很少使用的从库上
  • 把某些大 list 分片

其中前两条都是根据 Redis 单线程的特性,用其他进程(甚至机器)做出性能补充的方法

当然,使用分布式架构,也可能对性能有影响,比如请求需要被转发、数据需要不断被复制分发等

Dubbo

Dubbo 负载均衡策略

  • 随机模式(RandomLoadBalance):按权重设置随机概率,在一个截面上碰撞的概率较高,但调用量越大分布越均匀,而且按概率使用权重后也比较均匀,有利于动态调整提供者权重
  • 轮询模式(RoundRobinLoadBalance):按公约后的权重设置轮询比例,但存在响应慢的服务提供者会堆积请求
  • 最少活跃调用(LeastActiveLoadBalance):相同活跃数的随机,活跃数指调用前后计数差,使慢的提供者收到更少请求,因为越慢的提供者的调用前后计数差会越大
  • 一致性 Hash(ConsistentHashLoadBalance):相同参数的请求总是发到同一提供者,当某台提供者挂掉时,原本发往该提供者的请求,基于虚拟节点,平摊到其他提供者,不会引起剧烈变动

Dubbo 集群容错

  • FailOver:失败重试,当服务消费者调用服务提供者失败后,自动切换到其他服务提供者进行重试,通常用于读操作或者具有幂等的写操作,需要注意的是重试会带来更长延迟。可以通过 retries=2 来设置重试次数(不含第一次)
  • FailFast:快速失败,当服务消费者调用服务提供者失败后,立即报错,也就是只调用一次,通常这种模式用于非幂等性的写操作
  • FailSafe:失败安全,当服务消费者调用服务提供者出现异常时,直接忽略异常,这种模式通常用于写入日志等操作
  • FailBack:失败自动恢复,当服务消费者调用服务提供者出现异常时,在后台记录失败的请求,并按照一定的策略后期再进行重试,这种模式通常用于消息通知操作
  • Forking:并行调用,当消费者调用一个接口方法后,dubbo client 会并行调用多个提供者提供的服务,只要一个成功返回。这种模式通常用于实时性要求较高的读操作,但需要浪费更多服务资源,可以通过 fork=2 来设置最大并行数
  • Broadcast:广播调用。当消费者调用一个接口方法后,dubbo client 会逐个调用所有服务提供者,任意一台调用异常则这次调用就标记失败。这种模式通常用于通知所有提供者更新缓存或日志等本地资源信息

Dubbo 支持的协议

dubbo 协议(默认)

  • 采用单一长连接和 NIO 异步通讯,适合小数据量大并发的服务调用,以及服务消费者机器远大于服务提供者机器数的情况,不适合传输大数据量的服务,比如传文件、传视频等,除非请求量很低
  • 连接个数:单连接
  • 连接方式:长连接
  • 传输协议:TCP
  • 传输方式:NIO 异步传输
  • 序列化方式:Hessian2 二进制序列化
  • 适用场景:常规远程服务方法调用
  • dubbo 服务缺省每服务每消费者使用单一长连接,如果数据量较大,可以使用多个连接
  • 为防止大量连接撑挂,可以再服务提供方限制最大连接接受数,以实现服务提供方自我保护
常见问题
  • 为什么要消费者比提供者个数多:因为 dubbo 协议采用单一长连接,假设网络为千兆网卡(1024Mbit=128Mbyte),根据测试经验数据每条连接最多只能压满 7Mbyte,理论上 1 个服务提供者需要 20 个服务消费者才能压满网卡
  • 为什么不能传大包:因为 dubbo 协议采用单一长连接,如果每次请求的数据包大小为 500Kbyte,假设网络为千兆网卡,每条连接最大 7Mbyte,单个服务提供者的 TPS(每秒处理事务数)最大为:128Mbyte/500Kbyte=262。单个消费者调用单个服务提供者的 TPS 最大为 7Mbyte/500Kbyte=14,如果能够接收,可以考虑使用,否则网络将成为瓶颈
  • 为什么采用异步单一长连接:因为服务的现状大都是服务提供者少,通常只有几台机器,而服务的消费者多,可能整个网站都在访问该服务,如果采用常规的 Hessian 服务,服务提供者很容易就被压垮,通过单一连接,保证单一消费者不会压死提供者,长连接,减少握手验证,并采用异步 IO,复用连接池,防止 C10K 问题

rmi 协议

  • 采用 JDK 标准的 java.rmi.*实现,采用阻塞式短连接和 JDK 标准序列化方式
  • 连接个数:多链接
  • 连接方式:短链接
  • 传输协议:TCP
  • 传输方式:同步传输
  • 序列化:java 标准二进制序列化
  • 适用范围:传入传出参数数据包大小混合,消费者与提供者个数差不多,可以传文件
  • 适用场景:常规远程方法调用,与原生 rmi 服务互操作

Hessian 协议

Hessian 协议用于集成 Hessian 服务,Hessian 底层采用 http 通讯,采用 servlet 暴露服务,dubbo 缺省内置 Jetty 作为服务器实现

dubbo 的 Hessian 协议可以和原生的 Hessian 服务互操作,即提供者用 dubbo 的 Hessian 协议暴露服务,消费者直接用标准 hessian 接口调用;或者提供方用标准 Hessian 暴露服务,消费者用 dubbo 的 Hessian 协议调用

  • 连接个数:多链接
  • 连接方式:短链接
  • 传输协议:Http
  • 传输方式:同步传输
  • 序列化:Hessian 二进制序列化
  • 适用范围:传入传出参数数据包较大,提供者比消费者个数多,提供者压力较大,可传文件
  • 使用场景:页面传输、文件传输、或与原生 Hessian 服务互操作
  • 参数及返回值需实现序列化接口
  • 参数及返回值不能自定义实现 List、Map、Number、Date、Calender 等接口,只能用 JDK 自带的实现,因为 Hessian 会做特殊处理,自定义实现类中的属性值都会丢失

Http 协议

基于 http 表单的远程调用协议

  • 连接个数:多连接
  • 连接方式:短连接
  • 传输协议:http
  • 序列化:表单序列化,即 JSON
  • 适用范围:传入传出参数数据包大小混合,提供者比消费者个数多,可用表单或者 url 传入参数,暂不支持传文件
  • 适用场景:需要同时给应用程序和浏览器 JS 使用的服务

WebService 协议

基于 WebService 的远程调用协议,基于 Apache CXF 的 fronted-simple 和 transports-http 实现

可以和原生 WebService 服务互操作

  • 连接个数:多连接
  • 连接方式:短连接
  • 传输协议:Http
  • 传输方式:同步传输
  • 序列化:soap 文本序列化
  • 适用场景:系统集成、跨语言调用
  • 参数及返回值需实现序列化接口
  • 参数尽量使用基本类型和 POJO

Thrift 协议

当前 dubbo 支持的 Thrift 协议是对 thrift 协议的拓展,在原生协议的基础上添加了一些额外的头信息,比如 serviceName、MagicNumber 等

使用 dubbo thrift 协议同样需要使用 thrift 的 idl compiler 编译生成相应的 Java 代码

Thrift 不支持 null 值,不能在协议中传 null

memcached 协议

基于 Memcached 实现的 rpc 协议

Redis 协议

基于 Redis 实现的 RPC 协议

Rest 协议

基于标准的 java rest api – JAX-RS2.0 实现的 REST 调用支持

SpringCloud 和 Dubbo 对比

  • duboo 由于是二进制传输,占用带宽会更少
  • SpringCloud 是 http 协议传输,占用带宽较多,同时使用 http 协议一般会使用 json 报文,消耗更大
  • SpringCloud 的接口协议约定比较松散
  • dubbo 的注册中心可以选择 zk、nacos 等,SpringCloud 可选 eureka、Consul、nacos 等
  • SpringCloud 提供了丰富的组件:服务网关、断路器、分布式配置、链路追踪等

Zookeeper

zookeeper 的节点类型

每个子目录项如 NameService 都被称为 znode,和文件系统一样,我们能够自由的增加、删除 znode,在一个 znode 下增加、删除子 znode,唯一不同在于 znode 时可以存储数据的

2021-04-16-14-20-4620210416142045

znode 一共有四种类型:

  • presistent:持久化节点。客户端与 zookeeper 断开连接后,该节点依旧存在
  • presistent_sequential:持久有序节点。zookeeper 给该节点名称进行顺序编号的持久化节点
  • ephemeral:临时节点。客户端与 zookeeper 断开连接之后,该节点被删除
  • ephemeral_sequential:临时有序节点。zookeeper 给该节点进行顺序编号的临时节点

Zookeeper znode 存储结构

Znode 包含了[存储数据、访问权限、子节点引用、节点状态信息]

  • [data]:znode 存储的数据
  • [ACL]:记录客户端对 znode 节点的访问权限,如 IP 等
  • [child]:当前节点的字节点引用
  • [stat]:包含 znode 节点的状态信息,比如[事务 id、版本号、时间戳]等

每个节点的数据最大不能超过多少呢

为了保证高吞吐和低延迟,以及数据的一致性,znode 只适合存储非常小的数,不能超过 1M,最好小于 1K

Zookeeper 的通知机制

客户端注册监听它关心的目录节点,当目录节点发生变化(如数据修改、被删除、子目录节点增加\删除)时,zookeeper 会通知客户端

  • zookeeper 的 watcher 机制主要包括客户端线程、客户端 watcherManager、zookeeper 服务器三个部分
  • 客户端向 zookeeper 服务器注册 watcher 的同时,会将 watcher 对象存储在客户端的 watcherManager 中
  • 当 zookeeper 服务器触发 watcher 事件后,会向客户端发送通知,客户端线程从 watcherManager 中取出对应的 watcher 对象来执行回调逻辑

watcher 的特性总结

  • 一次性:一个 watcher 事件是一个一次性的触发器,客户端只会收到一次
  • 异步:zookeeper 服务器发送 watcher 的通知事件到客户端是异步的,不能期望能够监控到节点每次的变化,zookeeper 只能保证最终一致性,而无法保证强一致性
  • 轻量级:watcher 机制非常简单,它只是通知了事件发生,而不会传递事件对象内容
  • 客户端串行:执行客户端 watcher 回调的过程是一个串行同步的过程
  • 注册 watcher 用 getData、exist、getChildren 方法
  • 触发 watcher 用 create、delete、setData 方法

角色

zookeeper 中的角色主要有以下三类

2021-04-16-15-42-1420210416154213

设计目的

  • 1.最终一致性:client 不论连接到哪个 server,展示给它的都是同一个视图,这是 zookeeper 最重要的功能
  • 2.可靠性:具有简单、健壮、良好的性能,如果消息被一台服务器接受,那么它将被所有的服务器接受
  • 3.实时性:zookeeper 保证客户端将在一个时间间隔内获得服务器的更新信息,或者服务器失效的信息。但由于网络延迟等原因,zookeeper 不能保证两个客户端能同时得到刚更新的数据,如果需要最新的数据,应该在读数据之前调用 sync()
  • 4.等待无关(wait-free):慢的或者失效的 client 不得干预快速的 client 请求,使得每个 client 都能有效的等待
  • 5.原子性:更新只能成功或者失败,没有中间状态
  • 6.顺序性:包括全局有序和偏序两种。全局有序是指如果在一台服务器上消息 a 在消息 b 前发布,则在所有 server 上消息 a 都将在消息 b 之前被发布;偏序是指如果一个消息 b 在消息 a 后被同一个发布者发布,a 必将排在 b 之前

Zookeeper 的工作原理

zookeeper 的核心是原子广播,这个机制保证了各个 server 之间的同步。实现这个机制的协议叫做 zab 协议。zab 协议有两种模式,分别是恢复模式(选主)和广播模式(同步)。当服务启动或者在 leader 崩溃后,zab 就进入了恢复模式,当 leader 被选举出来并且大多数 server 完成了和 leader 的状态同步以后,恢复模式就结束了。状态同步保证了 server 和 leader 具有相同的系统状态。

为了保证事务的顺序一致性,zookeeper 采用了递增的事务 id 号(zxid)来标识事务。所有的提议(proposal)都在被提出的时候加上了 zxid。实现中的 zxid 是一个 64 位的数字,它高 32 位是 epoch 用来标识 leader 关系是否改变,每一个 leader 被选出来之后都会有一个新的 epoch,标识当前属于哪个 leader 的统治时期。低 32 位用于递增计数。

每个 server 在工作过程中有三种状态:

  • looking:当前 server 不知道 leader 是谁,正在搜寻
  • leading:当前 server 即为选举出来的 leader
  • following:leader 已经选举出来,当前 server 与之同步

Zookeeper 选主流程

当 leader 崩溃或者 leader 失去大多数 follower,这时候 zk 进入恢复模式。恢复模式需要重新选举出另外一个新的 leader,让所有的 server 都恢复到一个正常的状态

zk 的选举算法有两种:一种是基于 basic paxos 实现的,另外一种是基于 fast paxos 实现的。系统模式默认的选举算法是 fast paxos

basic paxos 选主

  • 1.选举线程由当前 server 发起选举的线程担任,其主要功能是对投票结果进行统计,并选出推荐的 server
  • 2.选举线程首先向所有 server 发起一次询问(包括自己)
  • 3.选举线程收到回复后,验证是否是自己发起的询问(验证 zxid 是否一致),然后获取对方的 id(myid),并存储到当前询问对象列表中,最后获取对方提议的 leader 相关信息(id,zxid),并将这些信息存储到当次选举的投票记录中
  • 4.收到所有 server 回复后,就计算出 zxid 最大的那个 server,并将这个 server 相关信息设置成下一次要投票的 server
  • 5.线程将当前 zxid 最大的 server 设置为当前 server 要推荐的 leader,如果此时获胜的 server 获得 n/2+1 个 server 的票数,设置当前推荐的 leader 为获胜的 server,将根据获胜的 server 相关信息设置自己的状态,否则,继续这个过程,直到 leader 被选举出来

通过流程分析我们可以得出:要使 leader 获得多数 server 的支持,则 server 总数必须是奇数 2n+1,且存活的 server 数目不得少于 n+1

每个 server 启动以后都会重复以上流程。在恢复模式下,如果是刚从崩溃状态恢复的或者刚启动的 server 还会从磁盘快照中恢复数据和会话信息,zk 会记录事务日志并定期进行快照,方便在恢复时进行状态恢复。

basic paxos 流程

fast paxos 流程

fast paxos 流程是在选举过程中,某 server 首先向所有 server 提议自己要成为 leader,当其他 leader 收到提议后,解决 epoch 和 zxid 的冲突,并接受对方的提议,然后向对方发送接收提议完成的消息,重复这个流程,最后一定能选举出 leader。

2021-08-07-10-03-3120210807100330

Zookeeper 同步流程

选完 leader 以后,zk 就进入状态同步过程:

  • 1.leader 等待 server 连接
  • 2.follower 连接 leader,将最大的 zxid 发送给 leader
  • 3.leader 根据 follower 的 zxid 确定同步点
  • 4.完成同步后通知 follower 已经成为 uptodate 状态
  • 5.follower 收到 uptodate 消息后,又可以重新接受 client 的请求

同步流程

Zookeeper 工作流程

leader 工作流程

  • 1.数据恢复
  • 2.维持与 learner 的心跳,接受 learner 的请求并判断 learner 的请求消息类型
  • 3.learner 的消息类型主要有 ping 消息、request 消息、ack 消息、revalidate 消息,根据不同的消息类型,进行不同的处理

ping 消息是指 learner 的心跳消息。request 消息是 follower 发送的提议消息,包括写请求以及同步请求。ack 消息是 follower 的对提议的回复,超过半数的 follower 通过则 commit 该提议。revalidate 消息是用来延长 session 有效期的

leader 的工作流程简图如下,在实际实现中,流程要比下图复杂的多,启动了三个线程来实现功能:

2021-08-07-10-20-3720210807102036

follower 工作流程

follower 主要有 4 个功能:

  • 1.向 leader 发送请求(ping 消息、request 消息、ack 消息、revalidate 消息)
  • 2.接收 leader 消息并进行处理
  • 3.接收 client 请求,如果为写请求,发送给 leader 进行投票
  • 4.返回 client 结果

follower 的消息循环处理如下几种来自 leader 的消息:

  • 1.ping 消息:心跳消息
  • 2.proposal 消息:leader 发起的提案,要求 follower 投票
  • 3.commit 消息:服务器端最新一次提案的信息
  • 4.uptodate 消息:表明同步完成
  • 5.revalidate 消息:根据 leader 的 revalidate 结果,关闭待 revalidate 的 session 还是允许其接收消息
  • 6.sync 消息:返回 sync 结果到客户端,这个消息最初由客户端发起,用来强制得到最新的更新

follower 的工作流程简图如下,在实际实现中,是通过 5 个线程来实现功能的:

2021-08-08-10-20-1020210808102007

observer 工作流程

对于 observer 的流程不再赘述,observer 流程和 follower 唯一的不同就是 observer 不会参加 leader 发起的投票

zookeeper 保证了哪些特性

  • 顺序一致性:从同一客户端发起的事务请求,最终会严格的按照顺序被应用到 zookeeper 中去
  • 原子性:所有事务请求的处理结果在整个集群中所有机器上的应用情况是一致的,也就是说,要么整个集群中所有的机器都成功应用了某一事务,要么都没有应用
  • 单一视图:无论客户端连接到哪一个 zookeeper 服务器上,其看到的服务器端数据模型都是一致的
  • 可靠性:一旦服务器成功地应用了一个事务,并完成对客户端的响应,那么该事务所引起的服务状态变更将会被一致保留下来
  • 实时性(最终一致性):zookeeper 仅能保证在一定时间段内,客户端最终一定能够从服务器上读取到最新的数据状态

数据一致性与 paxos 算法

我们关注的重点还是在如何保持数据在集群所有机器的一致性,这就涉及到 paxos 算法

据说 paxos 算法的难理解与算法的知名度一样令人敬仰,所以我们先看如何保持数据一致性,这里有个原则就是:在一个分布式数据库系统中,如果各节点的初始状态一致,每个节点都执行相同的操作序列,那么他们最后能得到一个一致的状态

paxos 算法解决了什么问题?解决的就是保证每个节点执行相同的操作序列。master 维护一个全局写队列,所有写操作都必须放入这个队列进行编号,那么无论我们写多少节点,只要写操作是按照编号来的,就能保证一致性。但是如果 master 挂了呢?

paxos 算法通过投票来对写操作进行全局编号,同一时刻,只有一个写操作被批准,同时并发的写操作要去争取选票,只有获得过半数选票的写操作才会被批准(所以永远只会有一个写操作得到批准),其他的写操作竞争失败只好再发起一轮投票,就这样,在日复一日年复一年的投票中,所有写操作都被严格编号排序。编号严格递增,当一个几点接受了一个编号为 100 的写操作,接着又接收到了编号为 99 的写操作(因为网络延迟等很多不可预见的原因),它马上意识到了自己数据不一致,自动停止对外服务并重启同步过程。任何一个几点挂掉都不会影响整个集群的数据一致性(总 2n+1 台,除非挂掉大于 n 台)

zookeeper 能做什么

命名服务

在 zookeeper 的文件系统里创建一个目录,即有唯一的 path

配置管理

分布式服务将配置存储在 zookeepr 上,保存在 zookeeper 的某个目录节点中,然后所有相关应用程序对这个节点进行监听,一旦配置信息发生变化,每个应用程序都会收到 zookeeper 的通知,然后从 zookeeper 获取新的配置信息

配置管理

集群管理

检测是否有机器退出或加入,选举 master

  • 所有机器约定在父目录 GroupMembers 下创建临时目录节点,然后监听父目录节点的子节点变化消息。一旦有机器挂掉,该机器与 zookeeper 的连接断开,其所创建的临时目录节点被删除,所有其他机器收到通知:某个兄弟目录被删除。新机器加入也是类似,所有机器收到通知:新兄弟加入
  • 所有节点创建临时顺序目录节点,每次选取编号最小的机器作为 master

2021-04-16-14-31-5620210416143156

分布式锁

队列管理

同步队列:当一个队列的成员都聚齐时,这个队列才可用,否则一直等待所有成员到达

在约定目录下创建临时目录节点,监听节点顺序是否达到我们要求的数目

队列按照 FIFO 方式进行入队和出队操作

入队有编号,出队按编号

分布式与数据复制

zookeeper 作为一个集群提供一致的数据服务,自然要在所有机器间做数据复制。

数据复制的好处:

  • 1.容错:一个节点出错,不至于让整个系统停止工作,别的节点可以接管它的工作
  • 2.提高系统的拓展能力:把负载分布到多个节点上,或者增加节点来提高系统的负载能力
  • 3.提高性能:让客户端访问就近节点,提高用户访问速度

从客户端读写访问的透明度来看,数据复制集群系统分为下面两种:

  • 1.写主(Write Master):对数据的修改交给指定的节点。读无此限制,可以读取任意一个节点。这种情况下客户端要对读写进行区别,俗称读写分离。
  • 2.写任意(Write Any):对数据的修改可提交给任意的节点,跟读一样。这种情况下,客户端对集群节点的角色与变化透明。

对 zookeeper 来说,它采用的方式是写任意。通过增加机器,它的读吞吐能力和响应能力拓展非常好,而写,随着机器的增多吞吐能力肯定下降(这也是它简历 observer 的原因),而响应能力取决于具体实现方式,是延迟复制保持最终一致性,还是立即复制快速响应。

MQ

作用:解耦、异步、削峰

RabbitMQ

RabbitMQ 中的角色

  • Broker:消息代理
  • Producer:生产者
  • Consumer:消费者
  • Queue:消息队列
  • Exchange:交换器
  • Binding:绑定
  • RoutingKey:路由键

如何处理消息丢失的问题

生产者丢失数据的情况

生产者在发送数据到 MQ 时,可能消息还没到达服务端,因为网络通信等原因消息就丢失了

transaction 机制

RabbitMQ 提供的事务消息功能。就是生产者发送数据之前开启事务(channel.txSelect),然后发送消息。如果消息没有被 Broker 成功接收,生产者就会收到异常报错信息,此时就可以回滚事务(channel.txRollback),然后重试发送消息。如果 Broker 成功收到了消息,就可以提交事务(channel.txCommit)。但是问题在于事务机制是同步的,开启事务会消耗性能,影响吞吐量

confirm 机制

可以开启 confirm 模式,在生产者那里开启 confirm 之后,每次写的消息都会被分配一个唯一 ID。如果消息成功写入 Broker,会回调生产者 ack 接口,通知写入成功。如果 RabbitMQ 没能成功处理这条消息,会回调 nack 接口,通知生产者消息处理失败,此时可以进行重试。而且用户可以结合这个机制,在内存中维护每个消息 ID 的状态,如果超过一定时间没有收到消息的回调通知,可以操作重试等逻辑。

两种方式对比

事务机制和 confirm 机制最大的不同在于:事务机制是同步的,提交事务之后会阻塞,而 confirm 是异步的,发送消息不需要等待返回接口,而是通过异步回调通知的方式。所以一般为了避免生产者丢失数据,都是用 confirm 机制

RabbitMQ 丢失数据

RabbitMQ 自己弄丢数据,比如内存爆满、服务器宕机等情况,会导致已经存储到 RabbitMQ 上的数据丢失。此时必须开启 RabbitMQ 的持久化功能,将数据存储到磁盘,开启之后即使 RabbitMQ 服务挂了,在重启之后会自动读取之前存储的数据进行回复,极为罕见的是 RabbitMQ 还没有进行持久化的操作,服务就已经挂了,可能会导致少量数据丢失。

设置持久化有两个步骤:

  • 1.创建 Queue 的时候将其设置为持久化的,这样可以保证 RabbitMQ 持久化 Queue 的元数据,但是不会持久化 Queue 里存的数据。
  • 2.发送消息的时候将消息的 deliverMode 设置为 2,也就是将消息设置为持久化的,此时 RabbitMQ 就会将消息持久化到磁盘上去。

必须要同时设置这两个持久化才行,才能保证 RabbitMQ 重启之后,可以恢复 Queue 然后恢复 Queue 中的数据

哪怕是给 RabbitMQ 开启了持久化,也可能在消息写到 RabbitMQ 之后,持久化到磁盘之前,服务宕机,这就会导致部分数据丢失

消费者丢失数据

消费者丢失数据一般出现在刚刚接收到消息还没有进行数据的时候,这个时候出现了消费者服务宕机或者异常中断导致消息丢失,RabbitMQ 认为消费者已经消费完毕,数据就会丢失了

这时候需要用到 RabbitMQ 提供的 ack 机制,关闭 RabbitMQ 的自动 ack,在程序处理完成之后进行手动的 ack,没有收到 ack 消息 RabbitMQ 是不会丢弃消息的

如何保证 RabbitMQ 的高可用

RabbitMQ 有三种模式:单机模式、普通集群模式和镜像集群模式

  • 单机模式:单台服务器
  • 普通集群模式:
    • 多台机器上启动多个 RabbitMQ 实例,但是创建的 Queue 只会放到一个实例上,但是每个实例都会同步 Queue 的元数据。消费的时候如果链接到了另外一台 RabbitMQ 实例,这个实例就会从 Queue 所在实例上拉取数据
    • 这种模式需要消费者每次随机连接一个实例后拉去数据,或者固定连接到 Queue 所在实例进行消费。前者有拉取数据的开销,后者导致单实例性能瓶颈化,而且如果 Queue 所在的实例宕机,会导致其他实例无法进行 Queue 数据的拉取,如果开启了消息持久化,数据不一定会丢失,但是要等实例恢复了才能从此 Queue 消费
    • 这个方案主要是为了提高吞吐量,就是说让集群中多个节点来服务某个 Queue 的读写操作
  • 镜像集群模式:
    • 这种模式才是所谓的 RabbitMQ 的高可用模式。跟普通集群模式不同的是,创建的 Queue 无论是元数据还是 Queue 中的消息都会存在于多个实例上,然后每次生产者将消息写入到 Queue 中时,都会自动把消息同步到多个实例中的 Queue 中
    • 这样的好处在于:任何一台实例宕机了都不会对消息的生产和消费造成影响。坏处在于:第一性能开销太大,消息同步所有实例,导致网络带宽的压力和消耗很重,第二没有拓展性,每台实例的机器压力都很容易达到性能瓶颈
    • 如何开启镜像集群模式:在 RabbitMQ 控制台新增一个策略,这个策略时镜像集群模式的策略,指定的时候可以要求数据同步到所有节点,也可以要求数据之同步到指定数量的节点或者指定名称的节点,然后再次创建 Queue 的时候,应用此策略,就会自动将数据同步到其他节点上

RabbitMQ 实现订单超时未支付自动关闭

  • 死信队列:下单投放消息到 A 交换机,设置过期时间,消息到 AA 队列,为 AA 队列绑定死信交换机,不设置 AA 队列的消费者,让消息不会被消费,消息过期后会投递到死信交换机,路由到死信队列,由死信消费者消费,判断订单是否已经完成支付,已支付则无需处理,未支付主席那个关闭订单、返还库存等逻辑
  • 延时队列:下单发送延时消息到延时队列交换机,消息会保持在 Broker 中,并不是立即投递给消费者,只有在达到指定延时时间之后才会投递给消费者,在延时队列消费者中判断订单是否已经完成支付
  • 两种方案对比:延时队列需要插件支持,代码中需要实例化更多的 Bean
  • 其他方案:定时任务(时间精度有误差)、线程休眠(会受到重启影响)、监听 Redis 键值过期时间、监听 Zookeeper 节点过期时间等

RabbitMQ 通信全过程

  • 生产者生产消息后,将消息发布给交换机 Exchange
  • 交换机根据路由规则将消息路由到队列 Queue
  • Broker 再将 Queue 中的消息投递给订阅该队列的消费者,或者是消费者从队列中获取消息
  • 给 Exchange 绑定一个备份交换机,当到达原 Exchaneg 不能被正确路由到任何队列时,消息被发送给备份交换机
  • 备份交换机将消息根据路由规则路由到备份队列,存储
  • 由于可以给消息设置过期时间,所以当原 Queue 中的消息达到过期时间仍未被消费是,会被发送到死信交换机。或者发送给消费者的消息被拒绝后,也会被发送到死信交换机
  • 死信交换机根据路由规则将消息路由到死信队列,存储

RocketMQ

Kafka

如何保证消息消费时的幂等性

  • 数据库记录消息,保证唯一
  • 消费带唯一标识,通过 Redis 避免重复消费

如何保证消息的顺序性

  • 拆分多个 Queue,每个 Queue 对应一个 Consumer
  • 一个 Queue 对应一个 Consumer,然后这个 Consumer 内部用内存队列排队,然后底层分发给不同的 worker 线程去处理

消息队列满了应该怎么办?有几百万消息持续积压几个小时,如何解决?

消费者出现问题,即使恢复也需要等待消息被消费,等待时间可能会很长,需要操作临时紧急扩容

  • 先修复 Consumer 的问题,确保其恢复消费速度,然后将现有 Consumer 都停掉
  • 新建一个 Topic,临时建好原先 10 倍或者 20 倍数量的 Queue
  • 写一个临时的分发数据的 Consumer 程序,这个程序部署上去消费积压的数据,消费之后不做耗时的处理,直接轮询写入临时建好的 Queue
  • 接着临时征用 10 倍的机器来部署 Consumer,每一批 Consumer 消费一个临时 Queue 的数据
  • 这种做法相等于是临时将 Queue 资源和 Consumer 资源扩大 10 倍,以正常 10 倍的速度来消费数据
  • 等快速消费完积压数据之后,要恢复原来的架构部署,重新用原来的 Consumer 机器来消费消息

如何解决消息队列的延时及过期失效问题?

如何设计一个消息队列

  • 支持可伸缩性,支持快速扩容,便于增加吞吐量和容量
    • 分布式系统,Broker->Topic->Partition,每个 Partition 加一台机器,就存一部分数据,如果当前资源不够了,就给 Topic 增加 partition,做数据迁移,增加机器
  • 持久化,保证数据不丢失
    • 顺序写,这样就没有磁盘随机读写的寻址开销,磁盘顺序写的性能是很高的
  • 可用性
    • 多副本->Leader&Follower->Leader 挂了重新选举 Leader 提供对外服务

Spring

Spring 是如何实现 IOC 的

Spring Bean 什么时候初始化,什么时候销毁

Spring lazy-iniy

lazy-init 时 Spring 中延迟加载 Bean 的属性

设置为 lazy-init=true 的 Bean 将不会在 ApplicationContext 启动时提前被实例化,而是在第一次向容器通过 getBean 索取 Bean 实例时实例化的

如果一个设置了立即加载的 bean1 引用了一个延迟加载的 bean2,那么 bean1 在容器启动时会被实例化,而 bean2 由于被 bean1 引用,也会被实例化,这种情况也符合延迟加载的 bean 在第一次被调用时才实例化的规则

lazy-init 只在 scope 属性为 singleton 时才会有效,如果 scope 属性值为 porotype,那么即使设置了 lazy-init=false,容器启动时也不会被实例化,而是 getBean 方法实例化

Spring 拦截器(Interceptor)和过滤器(Filter)的执行顺序和区别

  • 1.过滤器依赖于 servlet 容器,基于函数回调实现,可以对几乎所有请求过滤,但是缺点是一个过滤器实例只能在容器初始化时调用一次,使用过滤器的目的是用来做一些过滤操作,比如获取我们需要的数据,或者提前设置一些参数
  • 2.拦截器,依赖于 web 框架,基于 java 反射机制实现,属于 aop 的一种应用,就是在 service 或者一个方法前,或者方法后等多个位置进行拦截,同一个拦截器实例在一个 controller 生命周期中可以多次调用,但是缺点是只能对 controller 请求进行拦截,对于其他一些比如直接访问静态资源的请求则没有办法进行拦截

Spring 的事务传播机制

  • REQUIRED:支持当前事务,如果没有事务会创建一个新的事务
  • REQUIRED_NEW:挂起当前事务,创建一个新的事务
  • MANDATORY:支持当前事务,如果当前不存在事务则抛出异常
  • SUPPORTS:支持当前事务,如果当前没有事务会以非事务方式执行
  • NOT_SUPPORTED:以非事务方式执行,如果当前存在事务则将事务挂起
  • NESTED:如果当前存在事务,则在嵌套事务内执行,如果当前不存在事务,则会创建一个新的事务
  • NEVER:以非事务方式执行,如果当前存在事务则抛出异常

Spring @Transactional 注解什么时候失效

在 spring aop 代理下,只有目标方法被外部调用,目标方法才由 spring 生成的代理对象来管理,这会造成自调用问题

若同一类中的其他方法没有@Transactional 注解的方法调用有@Transactional 注解的方法,有@Transactional 注解的方法的事务会被忽略,不会生效

@Transactional 只能被应用到 public 方法上

分库分表

分库分表策略

  • 1.水平分库
    • 概念:以字段为依据,按照一定的策略(hash,range 等),将一个库中的数据拆分到多个库
    • 结果:每个库的结构都是一样的,每个库的数据都不一样,没有交集,所有库的数据的并集是全量数据
    • 场景:系统绝对并发量上来了,分表难以解决问题,并且还没有明显的业务归属来垂直分库
  • 2.水平分表
    • 概念:以字段为依据,按照一定的策略(hash,range 等),将一个表中的数据拆分到多个表
    • 结果:每个表的结构都是一样的,每个表的结构都不一样,没有交集,所有表的数据的并集是全量数据
    • 场景:单表数据量太大,影响 SQL 效率,加重了 CPU 负载
  • 3.垂直分库
    • 概念:以表为依据,根据业务归属不同,将不同的表拆分到不同的库中
    • 结果:每个库的结构都不一样,每个库的数据也不一样,没有交集,所有库的并集是全量数据
    • 场景:绝对并发量上来了,并且可以抽象出单独的业务模块,模块化,一类业务模块一个库
  • 4.垂直分表
    • 概念:以字段为依据,按照字段的活跃性,将表中字段拆分到不同的表(主表和拓展表)中
    • 结果:每个表的结构不同,每个表的数据也不一样,一般来说每个表的字段至少有一列交集,一般是主键,用于关联数据。所有表的并集是全量数据
    • 场景:表的字段很多,并且热点数据和非热点数据在一起,单行数据所需的存储空间大,以至于数据库缓存的数行减少,查询时会去读取磁盘产生 IO

分库分表之扩容

分库分表之唯一 ID

分布式事务

阻塞 IO 和非阻塞 IO

七层网络模型

  • 应用层
  • 表示层
  • 会话层
  • 传输层
  • 网络层
  • 数据链路层
  • 物理层

综合问题

Redis 和 MySql 如何保持数据一致性

new Object()占多少个字节

  • mark word(8 字节)+klass pointer(4 字节,默认开启指针压缩)+padding(4 字节)=16 字节
  • mark word(8 字节)+klass pointer(8 字节,不开启指针压缩)=16 字节

User(int id, String name)

User u = new User(1,”zhangsan”);

mark word(8 字节)+klass pointer(4 字节,默认开启指针压缩)+instance data int(4 字节)+String(4 字节,开启普通对象指针压缩)+padding(4 字节)=24 字节

线程交替打印

实现两个线程交替打印,实现字母在前数字在后,可以用信号量,synchronized 关键字和 Lock 实现

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
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
import java.util.concurrent.CountDownLatch;
import java.util.concurrent.locks.Condition;
import java.util.concurrent.locks.Lock;
import java.util.concurrent.locks.ReentrantLock;

public class Main {
private static Lock lock = new ReentrantLock();
private static Condition c1 = lock.newCondition();
private static Condition c2 = lock.newCondition();
private static CountDownLatch count = new CountDownLatch(1);

public static void main(String[] args) {
String c = "ABCDEFGHI";
char[] ca = c.toCharArray();
String n = "123456789";
char[] na = n.toCharArray();

Thread t1 = new Thread(() -> {
try {
lock.lock();
count.countDown();
for(char caa : ca) {
c1.signal();
System.out.print(caa);
c2.await();
}
c1.signal();
} catch (InterruptedException e) {
e.printStackTrace();
} finally {
lock.unlock();
}
});

Thread t2 = new Thread(() -> {
try {
count.await();
lock.lock();
for(char naa : na) {
c2.signal();
System.out.print(naa);
c1.await();
}
c2.signal();
} catch (InterruptedException e) {
e.printStackTrace();
} finally {
lock.unlock();
}
});

t1.start();
t2.start();
}
}

两个 Integer 的引用对象传给一个 swap 方法在方法内部进行交换,返回后,两个引用的值是否会发生变化

1
2
3
4
5
6
7
private static void swap(Integer a, Integer b) throws NoSuchFieldException, IllegalAccessException {
Field field = Integer.class.getDeclaredField("value");
field.setAccessible(true);
Integer temp = new Integer(a.intValue());
field.set(a, b);
field.set(b, temp);
}