,JavaGuide 对其做了补充完善。
+
+
## 引言
所谓排序,就是使一串记录,按照其中的某个或某些关键字的大小,递增或递减的排列起来的操作。排序算法,就是如何使得记录按照要求排列的方法。排序算法在很多领域得到相当地重视,尤其是在大量数据的处理方面。一个优秀的算法可以节省大量的资源。在各个领域中考虑到数据的各种限制和规范,要得到一个符合实际的优秀算法,得经过大量的推理和分析。
-两年前,我曾在[博客园](https://www.cnblogs.com/guoyaohua/)发布过一篇[《十大经典排序算法最强总结(含 JAVA 代码实现)》](https://www.cnblogs.com/guoyaohua/p/8600214.html)博文,简要介绍了比较经典的十大排序算法,不过在之前的博文中,仅给出了 Java 版本的代码实现,并且有一些细节上的错误。所以,今天重新写一篇文章,深入了解下十大经典排序算法的原理及实现。
-
## 简介
-排序算法可以分为:
-
-- **内部排序**:数据记录在内存中进行排序。
-- **[外部排序](https://zh.wikipedia.org/wiki/外排序)**:因排序的数据很大,一次不能容纳全部的排序记录,在排序过程中需要访问外存。
-
-常见的内部排序算法有:**插入排序**、**希尔排序**、**选择排序**、**冒泡排序**、**归并排序**、**快速排序**、**堆排序**、**基数排序**等,本文只讲解内部排序算法。用一张图概括:
-
-
-
-上图存在错误:
-
-1. 插入排序的最好时间复杂度为 $O(n)$ 而不是 $O(n^2)$。
-2. 希尔排序的平均时间复杂度为 $O(nlogn)$。
-
-**图片名词解释:**
-
-- **n**:数据规模
-- **k**:“桶” 的个数
-- **In-place**:占用常数内存,不占用额外内存
-- **Out-place**:占用额外内存
-
-### 术语说明
-
+### 排序算法总结
+
+常见的内部排序算法有:**插入排序**、**希尔排序**、**选择排序**、**冒泡排序**、**归并排序**、**快速排序**、**堆排序**、**基数排序**等,本文只讲解内部排序算法。用一张表格概括:
+
+| 排序算法 | 时间复杂度(平均) | 时间复杂度(最差) | 时间复杂度(最好) | 空间复杂度 | 排序方式 | 稳定性 |
+| -------- | ------------------ | ------------------ | ------------------ | ---------- | -------- | ------ |
+| 冒泡排序 | O(n^2) | O(n^2) | O(n) | O(1) | 内部排序 | 稳定 |
+| 选择排序 | O(n^2) | O(n^2) | O(n^2) | O(1) | 内部排序 | 不稳定 |
+| 插入排序 | O(n^2) | O(n^2) | O(n) | O(1) | 内部排序 | 稳定 |
+| 希尔排序 | O(nlogn) | O(n^2) | O(nlogn) | O(1) | 内部排序 | 不稳定 |
+| 归并排序 | O(nlogn) | O(nlogn) | O(nlogn) | O(n) | 外部排序 | 稳定 |
+| 快速排序 | O(nlogn) | O(n^2) | O(nlogn) | O(logn) | 内部排序 | 不稳定 |
+| 堆排序 | O(nlogn) | O(nlogn) | O(nlogn) | O(1) | 内部排序 | 不稳定 |
+| 计数排序 | O(n+k) | O(n+k) | O(n+k) | O(k) | 外部排序 | 稳定 |
+| 桶排序 | O(n+k) | O(n^2) | O(n+k) | O(n+k) | 外部排序 | 稳定 |
+| 基数排序 | O(n×k) | O(n×k) | O(n×k) | O(n+k) | 外部排序 | 稳定 |
+
+**术语解释**:
+
+- **n**:数据规模,表示待排序的数据量大小。
+- **k**:“桶” 的个数,在某些特定的排序算法中(如基数排序、桶排序等),表示分割成的独立的排序区间或类别的数量。
+- **内部排序**:所有排序操作都在内存中完成,不需要额外的磁盘或其他存储设备的辅助。这适用于数据量小到足以完全加载到内存中的情况。
+- **外部排序**:当数据量过大,不可能全部加载到内存中时使用。外部排序通常涉及到数据的分区处理,部分数据被暂时存储在外部磁盘等存储设备上。
- **稳定**:如果 A 原本在 B 前面,而 $A=B$,排序之后 A 仍然在 B 的前面。
- **不稳定**:如果 A 原本在 B 的前面,而 $A=B$,排序之后 A 可能会出现在 B 的后面。
-- **内排序**:所有排序操作都在内存中完成。
-- **外排序**:由于数据太大,因此把数据放在磁盘中,而排序通过磁盘和内存的数据传输才能进行。
- **时间复杂度**:定性描述一个算法执行所耗费的时间。
- **空间复杂度**:定性描述一个算法执行所需内存的大小。
-### 算法分类
+### 排序算法分类
十种常见排序算法可以分类两大类别:**比较类排序**和**非比较类排序**。
@@ -359,9 +362,14 @@ public static int[] merge(int[] arr_1, int[] arr_2) {
快速排序使用[分治法](https://zh.wikipedia.org/wiki/分治法)(Divide and conquer)策略来把一个序列分为较小和较大的 2 个子序列,然后递归地排序两个子序列。具体算法描述如下:
-1. 从序列中**随机**挑出一个元素,做为 “基准”(`pivot`);
-2. 重新排列序列,将所有比基准值小的元素摆放在基准前面,所有比基准值大的摆在基准的后面(相同的数可以到任一边)。在这个操作结束之后,该基准就处于数列的中间位置。这个称为分区(partition)操作;
-3. 递归地把小于基准值元素的子序列和大于基准值元素的子序列进行快速排序。
+1. **选择基准(Pivot)** :从数组中选一个元素作为基准。为了避免最坏情况,通常会随机选择。
+2. **分区(Partition)** :重新排列序列,将所有比基准值小的元素摆放在基准前面,所有比基准值大的摆在基准的后面(相同的数可以到任一边)。在这个操作结束之后,该基准就处于数列的中间位置。
+3. **递归(Recurse)** :递归地把小于基准值元素的子序列和大于基准值元素的子序列进行快速排序。
+
+**关于性能,这也是它与归并排序的关键区别:**
+
+- **平均和最佳情况:** 它的时间复杂度是 $O(nlogn)$。这种情况发生在每次分区都能把数组分成均等的两半。
+- **最坏情况:** 它的时间复杂度会退化到 $O(n^2)$。这发生在每次我们选的基准都是当前数组的最小值或最大值时,比如对一个已经排好序的数组,每次都选第一个元素做基准,这就会导致分区极其不均,算法退化成类似冒泡排序。这就是为什么**随机选择基准**非常重要。
### 图解算法
@@ -369,31 +377,60 @@ public static int[] merge(int[] arr_1, int[] arr_2) {
### 代码实现
-> 来源:[使用 Java 实现快速排序(详解)](https://segmentfault.com/a/1190000040022056)
-
```java
-public static int partition(int[] array, int low, int high) {
- int pivot = array[high];
- int pointer = low;
- for (int i = low; i < high; i++) {
- if (array[i] <= pivot) {
- int temp = array[i];
- array[i] = array[pointer];
- array[pointer] = temp;
- pointer++;
+import java.util.concurrent.ThreadLocalRandom;
+
+class Solution {
+ public int[] sortArray(int[] a) {
+ quick(a, 0, a.length - 1);
+ return a;
+ }
+
+ // 快速排序的核心递归函数
+ void quick(int[] a, int left, int right) {
+ if (left >= right) { // 递归终止条件:区间只有一个或没有元素
+ return;
}
- System.out.println(Arrays.toString(array));
+ int p = partition(a, left, right); // 分区操作,返回分区点索引
+ quick(a, left, p - 1); // 对左侧子数组递归排序
+ quick(a, p + 1, right); // 对右侧子数组递归排序
}
- int temp = array[pointer];
- array[pointer] = array[high];
- array[high] = temp;
- return pointer;
-}
-public static void quickSort(int[] array, int low, int high) {
- if (low < high) {
- int position = partition(array, low, high);
- quickSort(array, low, position - 1);
- quickSort(array, position + 1, high);
+
+ // 分区函数:将数组分为两部分,小于基准值的在左,大于基准值的在右
+ int partition(int[] a, int left, int right) {
+ // 随机选择一个基准点,避免最坏情况(如数组接近有序)
+ int idx = ThreadLocalRandom.current().nextInt(right - left + 1) + left;
+ swap(a, left, idx); // 将基准点放在数组的最左端
+ int pv = a[left]; // 基准值
+ int i = left + 1; // 左指针,指向当前需要检查的元素
+ int j = right; // 右指针,从右往左寻找比基准值小的元素
+
+ while (i <= j) {
+ // 左指针向右移动,直到找到一个大于等于基准值的元素
+ while (i <= j && a[i] < pv) {
+ i++;
+ }
+ // 右指针向左移动,直到找到一个小于等于基准值的元素
+ while (i <= j && a[j] > pv) {
+ j--;
+ }
+ // 如果左指针尚未越过右指针,交换两个不符合位置的元素
+ if (i <= j) {
+ swap(a, i, j);
+ i++;
+ j--;
+ }
+ }
+ // 将基准值放到分区点位置,使得基准值左侧小于它,右侧大于它
+ swap(a, j, left);
+ return j;
+ }
+
+ // 交换数组中两个元素的位置
+ void swap(int[] a, int i, int j) {
+ int t = a[i];
+ a[i] = a[j];
+ a[j] = t;
}
}
```
@@ -401,7 +438,7 @@ public static void quickSort(int[] array, int low, int high) {
### 算法分析
- **稳定性**:不稳定
-- **时间复杂度**:最佳:$O(nlogn)$, 最差:$O(nlogn)$,平均:$O(nlogn)$
+- **时间复杂度**:最佳:$O(nlogn)$, 最差:$O(n^2)$,平均:$O(nlogn)$
- **空间复杂度**:$O(logn)$
## 堆排序 (Heap Sort)
@@ -565,13 +602,13 @@ public static int[] countingSort(int[] arr) {
}
```
-## 算法分析
+### 算法分析
当输入的元素是 `n` 个 `0` 到 `k` 之间的整数时,它的运行时间是 $O(n+k)$。计数排序不是比较排序,排序的速度快于任何比较排序算法。由于用来计数的数组 `C` 的长度取决于待排序数组中数据的范围(等于待排序数组的**最大值与最小值的差加上 1**),这使得计数排序对于数据范围很大的数组,需要大量额外内存空间。
- **稳定性**:稳定
- **时间复杂度**:最佳:$O(n+k)$ 最差:$O(n+k)$ 平均:$O(n+k)$
-- **空间复杂度**:`O(k)`
+- **空间复杂度**:$O(k)$
## 桶排序 (Bucket Sort)
diff --git a/docs/cs-basics/algorithms/classical-algorithm-problems-recommendations.md b/docs/cs-basics/algorithms/classical-algorithm-problems-recommendations.md
index 3a6a01a210f..0e6f56f74f5 100644
--- a/docs/cs-basics/algorithms/classical-algorithm-problems-recommendations.md
+++ b/docs/cs-basics/algorithms/classical-algorithm-problems-recommendations.md
@@ -1,8 +1,13 @@
---
title: 经典算法思想总结(含LeetCode题目推荐)
+description: 总结常见算法思想与解题模板,配合典型题目推荐,强调思维路径与复杂度权衡,快速构建解题体系。
category: 计算机基础
tag:
- 算法
+head:
+ - - meta
+ - name: keywords
+ content: 贪心,分治,回溯,动态规划,二分,双指针,算法思想,题目推荐
---
## 贪心算法
diff --git a/docs/cs-basics/algorithms/common-data-structures-leetcode-recommendations.md b/docs/cs-basics/algorithms/common-data-structures-leetcode-recommendations.md
index 51d9225730f..bb73a2d917e 100644
--- a/docs/cs-basics/algorithms/common-data-structures-leetcode-recommendations.md
+++ b/docs/cs-basics/algorithms/common-data-structures-leetcode-recommendations.md
@@ -1,8 +1,13 @@
---
title: 常见数据结构经典LeetCode题目推荐
+description: 按数据结构类别整理经典 LeetCode 题目清单,聚焦高频与核心考点,助力系统化刷题与巩固。
category: 计算机基础
tag:
- 算法
+head:
+ - - meta
+ - name: keywords
+ content: LeetCode,数组,链表,栈,队列,二叉树,题目推荐,刷题
---
## 数组
diff --git a/docs/cs-basics/algorithms/linkedlist-algorithm-problems.md b/docs/cs-basics/algorithms/linkedlist-algorithm-problems.md
index b50b1b1b00f..8d412e43840 100644
--- a/docs/cs-basics/algorithms/linkedlist-algorithm-problems.md
+++ b/docs/cs-basics/algorithms/linkedlist-algorithm-problems.md
@@ -1,10 +1,17 @@
---
title: 几道常见的链表算法题
+description: 精选链表高频题的思路与实现,覆盖两数相加、反转、环检测等场景,强调边界处理与复杂度分析。
category: 计算机基础
tag:
- 算法
+head:
+ - - meta
+ - name: keywords
+ content: 链表算法,两数相加,反转链表,环检测,合并链表,复杂度分析
---
+
+
## 1. 两数相加
### 题目描述
diff --git a/docs/cs-basics/algorithms/string-algorithm-problems.md b/docs/cs-basics/algorithms/string-algorithm-problems.md
index 796fe7bf986..b528a03affe 100644
--- a/docs/cs-basics/algorithms/string-algorithm-problems.md
+++ b/docs/cs-basics/algorithms/string-algorithm-problems.md
@@ -1,8 +1,13 @@
---
title: 几道常见的字符串算法题
+description: 总结字符串高频算法与题型,重点讲解 KMP/BM 原理、滑动窗口等技巧,助力高效匹配与实现。
category: 计算机基础
tag:
- 算法
+head:
+ - - meta
+ - name: keywords
+ content: 字符串算法,KMP,BM,滑动窗口,子串,匹配,复杂度
---
> 作者:wwwxmu
diff --git a/docs/cs-basics/algorithms/the-sword-refers-to-offer.md b/docs/cs-basics/algorithms/the-sword-refers-to-offer.md
index 73d296d0dc3..37266eba58e 100644
--- a/docs/cs-basics/algorithms/the-sword-refers-to-offer.md
+++ b/docs/cs-basics/algorithms/the-sword-refers-to-offer.md
@@ -1,8 +1,13 @@
---
title: 剑指offer部分编程题
+description: 选编《剑指 Offer》常见编程题,给出递归与迭代等多种思路与示例,实现对高频题型的高效复盘。
category: 计算机基础
tag:
- 算法
+head:
+ - - meta
+ - name: keywords
+ content: 剑指Offer,斐波那契,递归,迭代,链表,数组,面试题
---
## 斐波那契数列
diff --git a/docs/cs-basics/data-structure/bloom-filter.md b/docs/cs-basics/data-structure/bloom-filter.md
index 8d4130c6f04..fd0cdb0ccfe 100644
--- a/docs/cs-basics/data-structure/bloom-filter.md
+++ b/docs/cs-basics/data-structure/bloom-filter.md
@@ -1,8 +1,13 @@
---
title: 布隆过滤器
+description: 解析 Bloom Filter 的原理与误判特性,结合哈希与位数组实现,适用于海量数据去重与缓存穿透防护。
category: 计算机基础
tag:
- 数据结构
+head:
+ - - meta
+ - name: keywords
+ content: 布隆过滤器,Bloom Filter,误判率,哈希函数,位数组,去重,缓存穿透
---
布隆过滤器相信大家没用过的话,也已经听过了。
@@ -125,7 +130,9 @@ public class MyBloomFilter {
public boolean contains(Object value) {
boolean ret = true;
for (SimpleHash f : func) {
- ret = ret && bits.get(f.hash(value));
+ ret = bits.get(f.hash(value));
+ if(!ret)
+ return ret;
}
return ret;
}
@@ -148,7 +155,7 @@ public class MyBloomFilter {
*/
public int hash(Object value) {
int h;
- return (value == null) ? 0 : Math.abs(seed * (cap - 1) & ((h = value.hashCode()) ^ (h >>> 16)));
+ return (value == null) ? 0 : Math.abs((cap - 1) & seed * ((h = value.hashCode()) ^ (h >>> 16)));
}
}
diff --git a/docs/cs-basics/data-structure/graph.md b/docs/cs-basics/data-structure/graph.md
index e9860c240d5..b292a30a939 100644
--- a/docs/cs-basics/data-structure/graph.md
+++ b/docs/cs-basics/data-structure/graph.md
@@ -1,8 +1,13 @@
---
title: 图
+description: 介绍图的基本概念与常用表示,结合 DFS/BFS 等核心算法与应用场景,掌握图论入门必备知识。
category: 计算机基础
tag:
- 数据结构
+head:
+ - - meta
+ - name: keywords
+ content: 图,邻接表,邻接矩阵,DFS,BFS,度,有向图,无向图,连通性
---
图是一种较为复杂的非线性结构。 **为啥说其较为复杂呢?**
diff --git a/docs/cs-basics/data-structure/heap.md b/docs/cs-basics/data-structure/heap.md
index 5de2e5f2ee2..cfa1b29eee9 100644
--- a/docs/cs-basics/data-structure/heap.md
+++ b/docs/cs-basics/data-structure/heap.md
@@ -1,7 +1,12 @@
---
+description: 解析堆的性质与操作,理解优先队列实现与堆排序性能优势,掌握插入/删除的复杂度与实践场景。
category: 计算机基础
tag:
- 数据结构
+head:
+ - - meta
+ - name: keywords
+ content: 堆,最大堆,最小堆,优先队列,堆化,上浮,下沉,堆排序
---
# 堆
diff --git a/docs/cs-basics/data-structure/linear-data-structure.md b/docs/cs-basics/data-structure/linear-data-structure.md
index 5f00847f9fc..f56511882ff 100644
--- a/docs/cs-basics/data-structure/linear-data-structure.md
+++ b/docs/cs-basics/data-structure/linear-data-structure.md
@@ -1,8 +1,13 @@
---
title: 线性数据结构
+description: 总结数组/链表/栈/队列的特性与操作,配合复杂度分析与典型应用,掌握线性结构的选型与实现。
category: 计算机基础
tag:
- 数据结构
+head:
+ - - meta
+ - name: keywords
+ content: 数组,链表,栈,队列,双端队列,复杂度分析,随机访问,插入删除
---
## 1. 数组
@@ -99,7 +104,7 @@ tag:

-### 3.2. 栈的常见应用常见应用场景
+### 3.2. 栈的常见应用场景
当我们我们要处理的数据只涉及在一端插入和删除数据,并且满足 **后进先出(LIFO, Last In First Out)** 的特性时,我们就可以使用栈这个数据结构。
@@ -154,7 +159,12 @@ public boolean isValid(String s){
#### 3.2.4. 维护函数调用
-最后一个被调用的函数必须先完成执行,符合栈的 **后进先出(LIFO, Last In First Out)** 特性。
+最后一个被调用的函数必须先完成执行,符合栈的 **后进先出(LIFO, Last In First Out)** 特性。
+例如递归函数调用可以通过栈来实现,每次递归调用都会将参数和返回地址压栈。
+
+#### 3.2.5 深度优先遍历(DFS)
+
+在深度优先搜索过程中,栈被用来保存搜索路径,以便回溯到上一层。
### 3.3. 栈的实现
@@ -316,13 +326,14 @@ myStack.pop();//报错:java.lang.IllegalArgumentException: Stack is empty.
虽然优先队列的底层并非严格的线性结构,但是在我们使用的过程中,我们是感知不到**堆**的,从使用者的眼中优先队列可以被认为是一种线性的数据结构:一种会自动排序的线性队列。
-### 4.3. 常见应用场景
+### 4.3. 队列的常见应用场景
当我们需要按照一定顺序来处理数据的时候可以考虑使用队列这个数据结构。
- **阻塞队列:** 阻塞队列可以看成在队列基础上加了阻塞操作的队列。当队列为空的时候,出队操作阻塞,当队列满的时候,入队操作阻塞。使用阻塞队列我们可以很容易实现“生产者 - 消费者“模型。
-- **线程池中的请求/任务队列:** 线程池中没有空闲线程时,新的任务请求线程资源时,线程池该如何处理呢?答案是将这些请求放在队列中,当有空闲线程的时候,会循环中反复从队列中获取任务来执行。队列分为无界队列(基于链表)和有界队列(基于数组)。无界队列的特点就是可以一直入列,除非系统资源耗尽,比如:`FixedThreadPool` 使用无界队列 `LinkedBlockingQueue`。但是有界队列就不一样了,当队列满的话后面再有任务/请求就会拒绝,在 Java 中的体现就是会抛出`java.util.concurrent.RejectedExecutionException` 异常。
-- 栈:双端队列天生便可以实现栈的全部功能(`push`、`pop` 和 `peek`),并且在 Deque 接口中已经实现了相关方法。Stack 类已经和 Vector 一样被遗弃,现在在 Java 中普遍使用双端队列(Deque)来实现栈。
+- **线程池中的请求/任务队列:** 当线程池中没有空闲线程时,新的任务请求线程资源会被如何处理呢?答案是这些任务会被放入任务队列中,等待线程池中的线程空闲后再从队列中取出任务执行。任务队列分为无界队列(基于链表实现)和有界队列(基于数组实现)。无界队列的特点是队列容量理论上没有限制,任务可以持续入队,直到系统资源耗尽。例如:`FixedThreadPool` 使用的阻塞队列 `LinkedBlockingQueue`,其默认容量为 `Integer.MAX_VALUE`,因此可以被视为“无界队列”。而有界队列则不同,当队列已满时,如果再有新任务提交,由于队列无法继续容纳任务,线程池会拒绝这些任务,并抛出 `java.util.concurrent.RejectedExecutionException` 异常。
+- **栈**:双端队列天生便可以实现栈的全部功能(`push`、`pop` 和 `peek`),并且在 Deque 接口中已经实现了相关方法。Stack 类已经和 Vector 一样被遗弃,现在在 Java 中普遍使用双端队列(Deque)来实现栈。
+- **广度优先搜索(BFS)**:在图的广度优先搜索过程中,队列被用于存储待访问的节点,保证按照层次顺序遍历图的节点。
- Linux 内核进程队列(按优先级排队)
- 现实生活中的派对,播放器上的播放列表;
- 消息队列
diff --git a/docs/cs-basics/data-structure/red-black-tree.md b/docs/cs-basics/data-structure/red-black-tree.md
index b90c0c0f26a..e6e31ef3758 100644
--- a/docs/cs-basics/data-structure/red-black-tree.md
+++ b/docs/cs-basics/data-structure/red-black-tree.md
@@ -1,8 +1,13 @@
---
title: 红黑树
+description: 深入讲解红黑树的五大性质与旋转调整过程,理解自平衡机制及在标准库与索引结构中的应用。
category: 计算机基础
tag:
- 数据结构
+head:
+ - - meta
+ - name: keywords
+ content: 红黑树,自平衡,旋转,插入删除,性质,黑高,时间复杂度
---
## 红黑树介绍
@@ -26,8 +31,8 @@ tag:
1. 每个节点非红即黑。黑色决定平衡,红色不决定平衡。这对应了 2-3 树中一个节点内可以存放 1~2 个节点。
2. 根节点总是黑色的。
3. 每个叶子节点都是黑色的空节点(NIL 节点)。这里指的是红黑树都会有一个空的叶子节点,是红黑树自己的规则。
-4. 如果节点是红色的,则它的子节点必须是黑色的(反之不一定)。通常这条规则也叫不会有连续的红色节点。一个节点最多临时会有 3 个节点,中间是黑色节点,左右是红色节点。
-5. 从根节点到叶节点或空子节点的每条路径,必须包含相同数目的黑色节点(即相同的黑色高度)。每一层都只是有一个节点贡献了树高决定平衡性,也就是对应红黑树中的黑色节点。
+4. 如果节点是红色的,则它的子节点必须是黑色的(反之不一定)。通常这条规则也叫不会有连续的红色节点。一个节点最多临时会有 3 个子节点,中间是黑色节点,左右是红色节点。
+5. 从任意节点到它的叶子节点或空子节点的每条路径,必须包含相同数目的黑色节点(即相同的黑色高度)。每一层都只是有一个节点贡献了树高决定平衡性,也就是对应红黑树中的黑色节点。
正是这些特点才保证了红黑树的平衡,让红黑树的高度不会超过 2log(n+1)。
diff --git a/docs/cs-basics/data-structure/tree.md b/docs/cs-basics/data-structure/tree.md
index 9727359c114..267c44d5fef 100644
--- a/docs/cs-basics/data-structure/tree.md
+++ b/docs/cs-basics/data-structure/tree.md
@@ -1,8 +1,13 @@
---
title: 树
+description: 系统讲解树与二叉树的核心概念与遍历方法,结合高度/深度等指标,夯实数据结构基础与算法思维。
category: 计算机基础
tag:
- 数据结构
+head:
+ - - meta
+ - name: keywords
+ content: 树,二叉树,二叉搜索树,平衡树,遍历,前序,中序,后序,层序,高度,深度
---
树就是一种类似现实生活中的树的数据结构(倒置的树)。任何一颗非空树只有一个根节点。
@@ -50,7 +55,7 @@ tag:
### 完全二叉树
-除最后一层外,若其余层都是满的,并且最后一层或者是满的,或者是在右边缺少连续若干节点,则这个二叉树就是 **完全二叉树** 。
+除最后一层外,若其余层都是满的,并且最后一层是满的或者是在右边缺少连续若干节点,则这个二叉树就是 **完全二叉树** 。
大家可以想象为一棵树从根结点开始扩展,扩展完左子节点才能开始扩展右子节点,每扩展完一层,才能继续扩展下一层。如下图所示:
diff --git a/docs/cs-basics/network/application-layer-protocol.md b/docs/cs-basics/network/application-layer-protocol.md
index d34e98bcdab..b2182c50dce 100644
--- a/docs/cs-basics/network/application-layer-protocol.md
+++ b/docs/cs-basics/network/application-layer-protocol.md
@@ -1,8 +1,13 @@
---
title: 应用层常见协议总结(应用层)
+description: 汇总应用层常见协议的核心概念与典型场景,重点对比 HTTP 与 WebSocket 的通信模型与能力边界。
category: 计算机基础
tag:
- 计算机网络
+head:
+ - - meta
+ - name: keywords
+ content: 应用层协议,HTTP,WebSocket,DNS,SMTP,FTP,特性,场景
---
## HTTP:超文本传输协议
@@ -15,7 +20,7 @@ HTTP 使用客户端-服务器模型,客户端向服务器发送 HTTP Request
HTTP 协议基于 TCP 协议,发送 HTTP 请求之前首先要建立 TCP 连接也就是要经历 3 次握手。目前使用的 HTTP 协议大部分都是 1.1。在 1.1 的协议里面,默认是开启了 Keep-Alive 的,这样的话建立的连接就可以在多次请求中被复用了。
-另外, HTTP 协议是”无状态”的协议,它无法记录客户端用户的状态,一般我们都是通过 Session 来记录客户端用户的状态。
+另外, HTTP 协议是“无状态”的协议,它无法记录客户端用户的状态,一般我们都是通过 Session 来记录客户端用户的状态。
## Websocket:全双工通信协议
@@ -102,7 +107,7 @@ FTP 是基于客户—服务器(C/S)模型而设计的,在客户端与 FTP

-注意 ⚠️:FTP 是一种不安全的协议,因为它在传输过程中不会对数据进行加密。因此,FTP 传输的文件可能会被窃听或篡改。建议在传输敏感数据时使用更安全的协议,如 SFTP(一种基于 SSH 协议的安全文件传输协议,用于在网络上安全地传输文件)。
+注意 ⚠️:FTP 是一种不安全的协议,因为它在传输过程中不会对数据进行加密。因此,FTP 传输的文件可能会被窃听或篡改。建议在传输敏感数据时使用更安全的协议,如 SFTP(SSH File Transfer Protocol,一种基于 SSH 协议的安全文件传输协议,用于在网络上安全地传输文件)。
## Telnet:远程登陆协议
@@ -114,10 +119,12 @@ FTP 是基于客户—服务器(C/S)模型而设计的,在客户端与 FTP
**SSH(Secure Shell)** 基于 TCP 协议,通过加密和认证机制实现安全的访问和文件传输等业务。
-SSH 的经典用途是登录到远程电脑中执行命令。除此之外,SSH 也支持隧道协议、端口映射和 X11 连接。借助 SFTP 或 SCP 协议,SSH 还可以传输文件。
+SSH 的经典用途是登录到远程电脑中执行命令。除此之外,SSH 也支持隧道协议、端口映射和 X11 连接(允许用户在本地运行远程服务器上的图形应用程序)。借助 SFTP(SSH File Transfer Protocol) 或 SCP(Secure Copy Protocol) 协议,SSH 还可以安全传输文件。
SSH 使用客户端-服务器模型,默认端口是 22。SSH 是一个守护进程,负责实时监听客户端请求,并进行处理。大多数现代操作系统都提供了 SSH。
+如下图所示,SSH Client(SSH 客户端)和 SSH Server(SSH 服务器)通过公钥交换生成共享的对称加密密钥,用于后续的加密通信。
+

## RTP:实时传输协议
@@ -131,7 +138,7 @@ RTP 协议分为两种子协议:
## DNS:域名系统
-DNS(Domain Name System,域名管理系统)基于 UDP 协议,用于解决域名和 IP 地址的映射问题。
+DNS(Domain Name System,域名管理系统)通常基于 UDP 协议(端口 53),用于解决域名和 IP 地址的映射问题。当响应数据超过 UDP 长度限制或进行区域传送时会改用 TCP。

diff --git a/docs/cs-basics/network/arp.md b/docs/cs-basics/network/arp.md
index c4ece76011c..10c01312b06 100644
--- a/docs/cs-basics/network/arp.md
+++ b/docs/cs-basics/network/arp.md
@@ -1,8 +1,13 @@
---
title: ARP 协议详解(网络层)
+description: 讲解 ARP 的地址解析机制与报文流程,结合 ARP 表与广播/单播详解常见攻击与防御策略。
category: 计算机基础
tag:
- 计算机网络
+head:
+ - - meta
+ - name: keywords
+ content: ARP,地址解析,IP到MAC,广播问询,单播响应,ARP表,欺骗
---
每当我们学习一个新的网络协议的时候,都要把他结合到 OSI 七层模型中,或者是 TCP/IP 协议栈中来学习,一是要学习该协议在整个网络协议栈中的位置,二是要学习该协议解决了什么问题,地位如何?三是要学习该协议的工作原理,以及一些更深入的细节。
@@ -21,7 +26,7 @@ tag:
MAC 地址的全称是 **媒体访问控制地址(Media Access Control Address)**。如果说,互联网中每一个资源都由 IP 地址唯一标识(IP 协议内容),那么一切网络设备都由 MAC 地址唯一标识。
-
+
可以理解为,MAC 地址是一个网络设备真正的身份证号,IP 地址只是一种不重复的定位方式(比如说住在某省某市某街道的张三,这种逻辑定位是 IP 地址,他的身份证号才是他的 MAC 地址),也可以理解为 MAC 地址是身份证号,IP 地址是邮政地址。MAC 地址也有一些别称,如 LAN 地址、物理地址、以太网地址等。
diff --git a/docs/cs-basics/network/computer-network-xiexiren-summary.md b/docs/cs-basics/network/computer-network-xiexiren-summary.md
index ac7e5d18b97..35bd988e6a5 100644
--- a/docs/cs-basics/network/computer-network-xiexiren-summary.md
+++ b/docs/cs-basics/network/computer-network-xiexiren-summary.md
@@ -1,8 +1,13 @@
---
title: 《计算机网络》(谢希仁)内容总结
+description: 基于《计算机网络》教材的学习笔记,梳理术语与分层模型等核心知识点,便于期末复习与面试巩固。
category: 计算机基础
tag:
- 计算机网络
+head:
+ - - meta
+ - name: keywords
+ content: 计算机网络,谢希仁,术语,分层模型,链路,主机,教材总结
---
本文是我在大二学习计算机网络期间整理, 大部分内容都来自于谢希仁老师的[《计算机网络》第七版](https://www.elias.ltd/usr/local/etc/%E8%AE%A1%E7%AE%97%E6%9C%BA%E7%BD%91%E7%BB%9C%EF%BC%88%E7%AC%AC7%E7%89%88%EF%BC%89%E8%B0%A2%E5%B8%8C%E4%BB%81.pdf)这本书。为了内容更容易理解,我对之前的整理进行了一波重构,并配上了一些相关的示意图便于理解。
@@ -20,36 +25,36 @@ tag:
3. **主机(host)**:连接在因特网上的计算机。
4. **ISP(Internet Service Provider)**:因特网服务提供者(提供商)。
-
+ 
5. **IXP(Internet eXchange Point)**:互联网交换点 IXP 的主要作用就是允许两个网络直接相连并交换分组,而不需要再通过第三个网络来转发分组。
-
+ 
-https://labs.ripe.net/Members/fergalc/ixp-traffic-during-stratos-skydive
+ https://labs.ripe.net/Members/fergalc/ixp-traffic-during-stratos-skydive
6. **RFC(Request For Comments)**:意思是“请求评议”,包含了关于 Internet 几乎所有的重要的文字资料。
7. **广域网 WAN(Wide Area Network)**:任务是通过长距离运送主机发送的数据。
8. **城域网 MAN(Metropolitan Area Network)**:用来将多个局域网进行互连。
9. **局域网 LAN(Local Area Network)**:学校或企业大多拥有多个互连的局域网。
-
+ 
-http://conexionesmanwman.blogspot.com/
+ http://conexionesmanwman.blogspot.com/
10. **个人区域网 PAN(Personal Area Network)**:在个人工作的地方把属于个人使用的电子设备用无线技术连接起来的网络 。
-
+ 
-https://www.itrelease.com/2018/07/advantages-and-disadvantages-of-personal-area-network-pan/
+ https://www.itrelease.com/2018/07/advantages-and-disadvantages-of-personal-area-network-pan/
-12. **分组(packet )**:因特网中传送的数据单元。由首部 header 和数据段组成。分组又称为包,首部可称为包头。
-13. **存储转发(store and forward )**:路由器收到一个分组,先检查分组是否正确,并过滤掉冲突包错误。确定包正确后,取出目的地址,通过查找表找到想要发送的输出端口地址,然后将该包发送出去。
+11. **分组(packet )**:因特网中传送的数据单元。由首部 header 和数据段组成。分组又称为包,首部可称为包头。
+12. **存储转发(store and forward )**:路由器收到一个分组,先检查分组是否正确,并过滤掉冲突包错误。确定包正确后,取出目的地址,通过查找表找到想要发送的输出端口地址,然后将该包发送出去。
-
+ 
-14. **带宽(bandwidth)**:在计算机网络中,表示在单位时间内从网络中的某一点到另一点所能通过的“最高数据率”。常用来表示网络的通信线路所能传送数据的能力。单位是“比特每秒”,记为 b/s。
-15. **吞吐量(throughput )**:表示在单位时间内通过某个网络(或信道、接口)的数据量。吞吐量更经常地用于对现实世界中的网络的一种测量,以便知道实际上到底有多少数据量能够通过网络。吞吐量受网络的带宽或网络的额定速率的限制。
+13. **带宽(bandwidth)**:在计算机网络中,表示在单位时间内从网络中的某一点到另一点所能通过的“最高数据率”。常用来表示网络的通信线路所能传送数据的能力。单位是“比特每秒”,记为 b/s。
+14. **吞吐量(throughput )**:表示在单位时间内通过某个网络(或信道、接口)的数据量。吞吐量更经常地用于对现实世界中的网络的一种测量,以便知道实际上到底有多少数据量能够通过网络。吞吐量受网络的带宽或网络的额定速率的限制。
### 1.2. 重要知识点总结
@@ -81,11 +86,11 @@ tag:
5. **半双工(half duplex )**:通信的双方都可以发送信息,但不能双方同时发送(当然也就不能同时接收)。
6. **全双工(full duplex)**:通信的双方可以同时发送和接收信息。
-
+ 
7. **失真**:失去真实性,主要是指接受到的信号和发送的信号不同,有磨损和衰减。影响失真程度的因素:1.码元传输速率 2.信号传输距离 3.噪声干扰 4.传输媒体质量
-
+ 
8. **奈氏准则**:在任何信道中,码元的传输的效率是有上限的,传输速率超过此上限,就会出现严重的码间串扰问题,使接收端对码元的判决(即识别)成为不可能。
9. **香农定理**:在带宽受限且有噪声的信道中,为了不产生误差,信息的数据传输速率有上限值。
@@ -95,7 +100,7 @@ tag:
13. **信噪比(signal-to-noise ratio )**:指信号的平均功率和噪声的平均功率之比,记为 S/N。信噪比(dB)=10\*log10(S/N)。
14. **信道复用(channel multiplexing )**:指多个用户共享同一个信道。(并不一定是同时)。
-
+ 
15. **比特率(bit rate )**:单位时间(每秒)内传送的比特数。
16. **波特率(baud rate)**:单位时间载波调制状态改变的次数。针对数据信号对载波的调制速率。
@@ -145,13 +150,13 @@ tag:
2. **数据链路(data link)**:把实现控制数据运输的协议的硬件和软件加到链路上就构成了数据链路。
3. **循环冗余检验 CRC(Cyclic Redundancy Check)**:为了保证数据传输的可靠性,CRC 是数据链路层广泛使用的一种检错技术。
4. **帧(frame)**:一个数据链路层的传输单元,由一个数据链路层首部和其携带的封包所组成协议数据单元。
-5. **MTU(Maximum Transfer Uint )**:最大传送单元。帧的数据部分的的长度上限。
+5. **MTU(Maximum Transfer Uint )**:最大传送单元。帧的数据部分的长度上限。
6. **误码率 BER(Bit Error Rate )**:在一段时间内,传输错误的比特占所传输比特总数的比率。
7. **PPP(Point-to-Point Protocol )**:点对点协议。即用户计算机和 ISP 进行通信时所使用的数据链路层协议。以下是 PPP 帧的示意图:

8. **MAC 地址(Media Access Control 或者 Medium Access Control)**:意译为媒体访问控制,或称为物理地址、硬件地址,用来定义网络设备的位置。在 OSI 模型中,第三层网络层负责 IP 地址,第二层数据链路层则负责 MAC 地址。因此一个主机会有一个 MAC 地址,而每个网络位置会有一个专属于它的 IP 地址 。地址是识别某个系统的重要标识符,“名字指出我们所要寻找的资源,地址指出资源所在的地方,路由告诉我们如何到达该处。”
-
+ 
9. **网桥(bridge)**:一种用于数据链路层实现中继,连接两个或多个局域网的网络互连设备。
10. **交换机(switch )**:广义的来说,交换机指的是一种通信系统中完成信息交换的设备。这里工作在数据链路层的交换机指的是交换式集线器,其实质是一个多接口的网桥
@@ -191,7 +196,7 @@ tag:
5. **子网掩码(subnet mask )**:它是一种用来指明一个 IP 地址的哪些位标识的是主机所在的子网以及哪些位标识的是主机的位掩码。子网掩码不能单独存在,它必须结合 IP 地址一起使用。
6. **CIDR( Classless Inter-Domain Routing )**:无分类域间路由选择 (特点是消除了传统的 A 类、B 类和 C 类地址以及划分子网的概念,并使用各种长度的“网络前缀”(network-prefix)来代替分类地址中的网络号和子网号)。
7. **默认路由(default route)**:当在路由表中查不到能到达目的地址的路由时,路由器选择的路由。默认路由还可以减小路由表所占用的空间和搜索路由表所用的时间。
-8. **路由选择算法(Virtual Circuit)**:路由选择协议的核心部分。因特网采用自适应的,分层次的路由选择协议。
+8. **路由选择算法(Routing Algorithm)**:路由选择协议的核心部分。因特网采用自适应的,分层次的路由选择协议。
### 4.2. 重要知识点总结
@@ -218,7 +223,7 @@ tag:
4. **TCP(Transmission Control Protocol)**:传输控制协议。
5. **UDP(User Datagram Protocol)**:用户数据报协议。
-
+ 
6. **端口(port)**:端口的目的是为了确认对方机器的哪个进程在与自己进行交互,比如 MSN 和 QQ 的端口不同,如果没有端口就可能出现 QQ 进程和 MSN 交互错误。端口又称协议端口号。
7. **停止等待协议(stop-and-wait)**:指发送方每发送完一个分组就停止发送,等待对方确认,在收到确认之后在发送下一个分组。
@@ -267,34 +272,34 @@ tag:
1. **域名系统(DNS)**:域名系统(DNS,Domain Name System)将人类可读的域名 (例如,www.baidu.com) 转换为机器可读的 IP 地址 (例如,220.181.38.148)。我们可以将其理解为专为互联网设计的电话薄。
-
+ 
-https://www.seobility.net/en/wiki/HTTP_headers
+ https://www.seobility.net/en/wiki/HTTP_headers
2. **文件传输协议(FTP)**:FTP 是 File Transfer Protocol(文件传输协议)的英文简称,而中文简称为“文传协议”。用于 Internet 上的控制文件的双向传输。同时,它也是一个应用程序(Application)。基于不同的操作系统有不同的 FTP 应用程序,而所有这些应用程序都遵守同一种协议以传输文件。在 FTP 的使用当中,用户经常遇到两个概念:"下载"(Download)和"上传"(Upload)。 "下载"文件就是从远程主机拷贝文件至自己的计算机上;"上传"文件就是将文件从自己的计算机中拷贝至远程主机上。用 Internet 语言来说,用户可通过客户机程序向(从)远程主机上传(下载)文件。
-
+ 
3. **简单文件传输协议(TFTP)**:TFTP(Trivial File Transfer Protocol,简单文件传输协议)是 TCP/IP 协议族中的一个用来在客户机与服务器之间进行简单文件传输的协议,提供不复杂、开销不大的文件传输服务。端口号为 69。
4. **远程终端协议(TELNET)**:Telnet 协议是 TCP/IP 协议族中的一员,是 Internet 远程登陆服务的标准协议和主要方式。它为用户提供了在本地计算机上完成远程主机工作的能力。在终端使用者的电脑上使用 telnet 程序,用它连接到服务器。终端使用者可以在 telnet 程序中输入命令,这些命令会在服务器上运行,就像直接在服务器的控制台上输入一样。可以在本地就能控制服务器。要开始一个 telnet 会话,必须输入用户名和密码来登录服务器。Telnet 是常用的远程控制 Web 服务器的方法。
5. **万维网(WWW)**:WWW 是环球信息网的缩写,(亦作“Web”、“WWW”、“'W3'”,英文全称为“World Wide Web”),中文名字为“万维网”,"环球网"等,常简称为 Web。分为 Web 客户端和 Web 服务器程序。WWW 可以让 Web 客户端(常用浏览器)访问浏览 Web 服务器上的页面。是一个由许多互相链接的超文本组成的系统,通过互联网访问。在这个系统中,每个有用的事物,称为一样“资源”;并且由一个全局“统一资源标识符”(URI)标识;这些资源通过超文本传输协议(Hypertext Transfer Protocol)传送给用户,而后者通过点击链接来获得资源。万维网联盟(英语:World Wide Web Consortium,简称 W3C),又称 W3C 理事会。1994 年 10 月在麻省理工学院(MIT)计算机科学实验室成立。万维网联盟的创建者是万维网的发明者蒂姆·伯纳斯-李。万维网并不等同互联网,万维网只是互联网所能提供的服务其中之一,是靠着互联网运行的一项服务。
6. **万维网的大致工作工程:**
-
+ 
7. **统一资源定位符(URL)**:统一资源定位符是对可以从互联网上得到的资源的位置和访问方法的一种简洁的表示,是互联网上标准资源的地址。互联网上的每个文件都有一个唯一的 URL,它包含的信息指出文件的位置以及浏览器应该怎么处理它。
8. **超文本传输协议(HTTP)**:超文本传输协议(HTTP,HyperText Transfer Protocol)是互联网上应用最为广泛的一种网络协议。所有的 WWW 文件都必须遵守这个标准。设计 HTTP 最初的目的是为了提供一种发布和接收 HTML 页面的方法。1960 年美国人 Ted Nelson 构思了一种通过计算机处理文本信息的方法,并称之为超文本(hypertext),这成为了 HTTP 超文本传输协议标准架构的发展根基。
-HTTP 协议的本质就是一种浏览器与服务器之间约定好的通信格式。HTTP 的原理如下图所示:
+ HTTP 协议的本质就是一种浏览器与服务器之间约定好的通信格式。HTTP 的原理如下图所示:
-
+ 
-10. **代理服务器(Proxy Server)**:代理服务器(Proxy Server)是一种网络实体,它又称为万维网高速缓存。 代理服务器把最近的一些请求和响应暂存在本地磁盘中。当新请求到达时,若代理服务器发现这个请求与暂时存放的的请求相同,就返回暂存的响应,而不需要按 URL 的地址再次去互联网访问该资源。代理服务器可在客户端或服务器工作,也可以在中间系统工作。
-11. **简单邮件传输协议(SMTP)** : SMTP(Simple Mail Transfer Protocol)即简单邮件传输协议,它是一组用于由源地址到目的地址传送邮件的规则,由它来控制信件的中转方式。 SMTP 协议属于 TCP/IP 协议簇,它帮助每台计算机在发送或中转信件时找到下一个目的地。 通过 SMTP 协议所指定的服务器,就可以把 E-mail 寄到收信人的服务器上了,整个过程只要几分钟。SMTP 服务器则是遵循 SMTP 协议的发送邮件服务器,用来发送或中转发出的电子邮件。
+9. **代理服务器(Proxy Server)**:代理服务器(Proxy Server)是一种网络实体,它又称为万维网高速缓存。 代理服务器把最近的一些请求和响应暂存在本地磁盘中。当新请求到达时,若代理服务器发现这个请求与暂时存放的请求相同,就返回暂存的响应,而不需要按 URL 的地址再次去互联网访问该资源。代理服务器可在客户端或服务器工作,也可以在中间系统工作。
+10. **简单邮件传输协议(SMTP)** : SMTP(Simple Mail Transfer Protocol)即简单邮件传输协议,它是一组用于由源地址到目的地址传送邮件的规则,由它来控制信件的中转方式。 SMTP 协议属于 TCP/IP 协议簇,它帮助每台计算机在发送或中转信件时找到下一个目的地。 通过 SMTP 协议所指定的服务器,就可以把 E-mail 寄到收信人的服务器上了,整个过程只要几分钟。SMTP 服务器则是遵循 SMTP 协议的发送邮件服务器,用来发送或中转发出的电子邮件。
-
+ 
-https://www.campaignmonitor.com/resources/knowledge-base/what-is-the-code-that-makes-bcc-or-cc-operate-in-an-email/
+ https://www.campaignmonitor.com/resources/knowledge-base/what-is-the-code-that-makes-bcc-or-cc-operate-in-an-email/
11. **搜索引擎** :搜索引擎(Search Engine)是指根据一定的策略、运用特定的计算机程序从互联网上搜集信息,在对信息进行组织和处理后,为用户提供检索服务,将用户检索相关的信息展示给用户的系统。搜索引擎包括全文索引、目录索引、元搜索引擎、垂直搜索引擎、集合式搜索引擎、门户搜索引擎与免费链接列表等。
diff --git a/docs/cs-basics/network/dns.md b/docs/cs-basics/network/dns.md
index 3c65653a41f..6d51538b932 100644
--- a/docs/cs-basics/network/dns.md
+++ b/docs/cs-basics/network/dns.md
@@ -1,8 +1,13 @@
---
title: DNS 域名系统详解(应用层)
+description: 详解 DNS 的层次结构与解析流程,覆盖递归/迭代、缓存与权威服务器,明确应用层端口与性能优化要点。
category: 计算机基础
tag:
- 计算机网络
+head:
+ - - meta
+ - name: keywords
+ content: DNS,域名解析,递归查询,迭代查询,缓存,权威DNS,端口53,UDP
---
DNS(Domain Name System)域名管理系统,是当用户使用浏览器访问网址之后,使用的第一个重要协议。DNS 要解决的是**域名和 IP 地址的映射问题**。
@@ -11,7 +16,7 @@ DNS(Domain Name System)域名管理系统,是当用户使用浏览器访
在实际使用中,有一种情况下,浏览器是可以不必动用 DNS 就可以获知域名和 IP 地址的映射的。浏览器在本地会维护一个`hosts`列表,一般来说浏览器要先查看要访问的域名是否在`hosts`列表中,如果有的话,直接提取对应的 IP 地址记录,就好了。如果本地`hosts`列表内没有域名-IP 对应记录的话,那么 DNS 就闪亮登场了。
-目前 DNS 的设计采用的是分布式、层次数据库结构,**DNS 是应用层协议,基于 UDP 协议之上,端口为 53** 。
+目前 DNS 的设计采用的是分布式、层次数据库结构,**DNS 是应用层协议,通常基于 UDP 协议,端口为 53**。当响应数据超过 UDP 报文长度限制(512 字节,EDNS0 可扩展至更大)或进行区域传送(Zone Transfer)时,会改用 TCP 协议以保证数据完整性。

@@ -24,7 +29,19 @@ DNS 服务器自底向上可以依次分为以下几个层级(所有 DNS 服务
- 权威 DNS 服务器。在因特网上具有公共可访问主机的每个组织机构必须提供公共可访问的 DNS 记录,这些记录将这些主机的名字映射为 IP 地址。
- 本地 DNS 服务器。每个 ISP(互联网服务提供商)都有一个自己的本地 DNS 服务器。当主机发出 DNS 请求时,该请求被发往本地 DNS 服务器,它起着代理的作用,并将该请求转发到 DNS 层次结构中。严格说来,不属于 DNS 层级结构。
-世界上并不是只有 13 台根服务器,这是很多人普遍的误解,网上很多文章也是这么写的。实际上,现在根服务器数量远远超过这个数量。最初确实是为 DNS 根服务器分配了 13 个 IP 地址,每个 IP 地址对应一个不同的根 DNS 服务器。然而,由于互联网的快速发展和增长,这个原始的架构变得不太适应当前的需求。为了提高 DNS 的可靠性、安全性和性能,目前这 13 个 IP 地址中的每一个都有多个服务器,截止到 2023 年底,所有根服务器之和达到了 600 多台,未来还会继续增加。
+**世界上真的只有 13 台根服务器吗?** 这是一个流传已久的技术误解。如果你在网上搜索,仍能看到许多陈旧文章宣称“全球仅有 13 台根服务器,且全部由美国控制”。
+
+**事实并非如此。**
+
+最初在设计 DNS(域名系统)架构时,受限于早期 IPv4 数据包的大小限制(UDP 报文需控制在 512 字节以内),预留给根服务器地址的空间确实只够容纳 13 个 IP 地址,每个 IP 地址对应一个不同的根 DNS 服务器。这 13 个地址分别被命名为 `a.root-servers.net` 到 `m.root-servers.net`。
+
+虽然**逻辑上**只有 13 个 IP 地址,但随着互联网规模的爆发,物理上的“单一服务器”早已无法承载全球的查询压力。为了提升 DNS 的可靠性、安全性和响应速度,技术人员引入了 **IP 任播(Anycast)** 技术。
+
+通过任播技术,每一个逻辑 IP 地址背后都可以对应成百上千台分布在全球各地的物理服务器。当你发起查询请求时,互联网路由协议(BGP)会自动将请求引导至地理位置或网络路径上离你**最近**的那台物理实例。
+
+截止到 2023 年底,全球根服务器物理实例总数已超过 1700 台。根据 **[Root-Servers.org](https://root-servers.org/)** 的最新实时监测数据,到 **2026 年,全球根服务器物理实例已突破 1900+ 台**,并正向 2000 台大关迈进。
+
+
## DNS 工作流程
@@ -72,7 +89,7 @@ DNS 报文分为查询和回答报文,两种形式的报文结构相同。
## DNS 记录
-DNS 服务器在响应查询时,需要查询自己的数据库,数据库中的条目被称为**资源记录(Resource Record,RR)**。RR 提供了主机名到 IP 地址的映射。RR 是一个包含了`Name`, `Value`, `Type`, `TTL`四个字段的四元组。
+DNS 服务器在响应查询时,需要查询自己的数据库,数据库中的条目被称为 **资源记录(Resource Record,RR)** 。RR 提供了主机名到 IP 地址的映射。RR 是一个包含了`Name`, `Value`, `Type`, `TTL`四个字段的四元组。

diff --git a/docs/cs-basics/network/http-status-codes.md b/docs/cs-basics/network/http-status-codes.md
index e281f44ca19..bd2bcd99c3d 100644
--- a/docs/cs-basics/network/http-status-codes.md
+++ b/docs/cs-basics/network/http-status-codes.md
@@ -1,8 +1,13 @@
---
title: HTTP 常见状态码总结(应用层)
+description: 汇总常见 HTTP 状态码含义与使用场景,强调 201/204 等易混淆点,提升接口设计与调试效率。
category: 计算机基础
tag:
- 计算机网络
+head:
+ - - meta
+ - name: keywords
+ content: HTTP 状态码,2xx,3xx,4xx,5xx,重定向,错误码,201 Created,204 No Content
---
HTTP 状态码用于描述 HTTP 请求的结果,比如 2xx 就代表请求被成功处理。
@@ -15,10 +20,14 @@ HTTP 状态码用于描述 HTTP 请求的结果,比如 2xx 就代表请求被
### 2xx Success(成功状态码)
-- **200 OK**:请求被成功处理。比如我们发送一个查询用户数据的 HTTP 请求到服务端,服务端正确返回了用户数据。这个是我们平时最常见的一个 HTTP 状态码。
-- **201 Created**:请求被成功处理并且在服务端创建了一个新的资源。比如我们通过 POST 请求创建一个新的用户。
-- **202 Accepted**:服务端已经接收到了请求,但是还未处理。
-- **204 No Content**:服务端已经成功处理了请求,但是没有返回任何内容。
+- **200 OK**:请求被成功处理。例如,发送一个查询用户数据的 HTTP 请求到服务端,服务端正确返回了用户数据。这个是我们平时最常见的一个 HTTP 状态码。
+- **201 Created**:请求被成功处理并且在服务端创建了~~一个新的资源~~。例如,通过 POST 请求创建一个新的用户。
+- **202 Accepted**:服务端已经接收到了请求,但是还未处理。例如,发送一个需要服务端花费较长时间处理的请求(如报告生成、Excel 导出),服务端接收了请求但尚未处理完毕。
+- **204 No Content**:服务端已经成功处理了请求,但是没有返回任何内容。例如,发送请求删除一个用户,服务器成功处理了删除操作但没有返回任何内容。
+
+🐛 修正(参见:[issue#2458](https://github.com/Snailclimb/JavaGuide/issues/2458)):201 Created 状态码更准确点来说是创建一个或多个新的资源,可以参考:。
+
+
这里格外提一下 204 状态码,平时学习/工作中见到的次数并不多。
diff --git a/docs/cs-basics/network/http-vs-https.md b/docs/cs-basics/network/http-vs-https.md
index 71c224f1be4..74303aba536 100644
--- a/docs/cs-basics/network/http-vs-https.md
+++ b/docs/cs-basics/network/http-vs-https.md
@@ -1,8 +1,13 @@
---
title: HTTP vs HTTPS(应用层)
+description: 对比 HTTP 与 HTTPS 的协议与安全机制,解析 SSL/TLS 工作原理与握手流程,明确应用层安全落地细节。
category: 计算机基础
tag:
- 计算机网络
+head:
+ - - meta
+ - name: keywords
+ content: HTTP,HTTPS,SSL,TLS,加密,认证,端口,安全性,握手流程
---
## HTTP 协议
@@ -33,7 +38,7 @@ HTTP 是应用层协议,它以 TCP(传输层)作为底层协议,默认
HTTPS 协议(Hyper Text Transfer Protocol Secure),是 HTTP 的加强安全版本。HTTPS 是基于 HTTP 的,也是用 TCP 作为底层协议,并额外使用 SSL/TLS 协议用作加密和安全认证。默认端口号是 443.
-HTTPS 协议中,SSL 通道通常使用基于密钥的加密算法,密钥长度通常是 40 比特或 128 比特。
+HTTPS 中,TLS 握手完成后,通信数据使用对称加密算法(如 AES-128-GCM 或 AES-256-GCM)保护,密钥通过非对称加密(如 RSA-2048/4096 或 ECDH)在握手阶段协商生成。早期 SSL 使用的 40 比特密钥因强度不足已被废弃,现代 TLS 要求对称密钥至少 128 比特。
### HTTPS 协议优点
@@ -47,7 +52,7 @@ HTTPS 之所以能达到较高的安全性要求,就是结合了 SSL/TLS 和 T
**SSL 和 TLS 没有太大的区别。**
-SSL 指安全套接字协议(Secure Sockets Layer),首次发布与 1996 年。SSL 的首次发布其实已经是他的 3.0 版本,SSL 1.0 从未面世,SSL 2.0 则具有较大的缺陷(DROWN 缺陷——Decrypting RSA with Obsolete and Weakened eNcryption)。很快,在 1999 年,SSL 3.0 进一步升级,**新版本被命名为 TLS 1.0**。因此,TLS 是基于 SSL 之上的,但由于习惯叫法,通常把 HTTPS 中的核心加密协议混称为 SSL/TLS。
+SSL 指安全套接字协议(Secure Sockets Layer),首次发布于 1996 年(SSL 3.0)。SSL 1.0 从未面世,SSL 2.0 则具有较大的缺陷(DROWN 缺陷——Decrypting RSA with Obsolete and Weakened eNcryption)。很快,在 1999 年,SSL 3.0 进一步升级,**新版本被命名为 TLS 1.0**。因此,TLS 是基于 SSL 之上的,但由于习惯叫法,通常把 HTTPS 中的核心加密协议混称为 SSL/TLS。目前 SSL 已完全废弃,TLS 1.2 和 TLS 1.3 是现代 HTTPS 的实际标准。
### SSL/TLS 的工作原理
diff --git a/docs/cs-basics/network/http1.0-vs-http1.1.md b/docs/cs-basics/network/http1.0-vs-http1.1.md
index eab4c324a51..19210ebb9a0 100644
--- a/docs/cs-basics/network/http1.0-vs-http1.1.md
+++ b/docs/cs-basics/network/http1.0-vs-http1.1.md
@@ -1,8 +1,13 @@
---
title: HTTP 1.0 vs HTTP 1.1(应用层)
+description: 细致对比 HTTP/1.0 与 HTTP/1.1 的协议差异,涵盖长连接、管道化、缓存与状态码增强等关键变更与实践影响。
category: 计算机基础
tag:
- 计算机网络
+head:
+ - - meta
+ - name: keywords
+ content: HTTP/1.0,HTTP/1.1,长连接,管道化,缓存,状态码,Host,带宽优化
---
这篇文章会从下面几个维度来对比 HTTP 1.0 和 HTTP 1.1:
@@ -70,9 +75,70 @@ Host: example1.org
HTTP/1.1 引入了范围请求(range request)机制,以避免带宽的浪费。当客户端想请求一个文件的一部分,或者需要继续下载一个已经下载了部分但被终止的文件,HTTP/1.1 可以在请求中加入`Range`头部,以请求(并只能请求字节型数据)数据的一部分。服务器端可以忽略`Range`头部,也可以返回若干`Range`响应。
-如果一个响应包含部分数据的话,那么将带有`206 (Partial Content)`状态码。该状态码的意义在于避免了 HTTP/1.0 代理缓存错误地把该响应认为是一个完整的数据响应,从而把他当作为一个请求的响应缓存。
+`206 (Partial Content)` 状态码的主要作用是确保客户端和代理服务器能正确识别部分内容响应,避免将其误认为完整资源并错误地缓存。这对于正确处理范围请求和缓存管理非常重要。
-在范围响应中,`Content-Range`头部标志指示出了该数据块的偏移量和数据块的长度。
+一个典型的 HTTP/1.1 范围请求示例:
+
+```bash
+# 获取一个文件的前 1024 个字节
+GET /z4d4kWk.jpg HTTP/1.1
+Host: i.imgur.com
+Range: bytes=0-1023
+```
+
+`206 Partial Content` 响应:
+
+```bash
+
+HTTP/1.1 206 Partial Content
+Content-Range: bytes 0-1023/146515
+Content-Length: 1024
+…
+(二进制内容)
+```
+
+简单解释一下 HTTP 范围响应头部中的字段:
+
+- **`Content-Range` 头部**:指示返回数据在整个资源中的位置,包括起始和结束字节以及资源的总长度。例如,`Content-Range: bytes 0-1023/146515` 表示服务器端返回了第 0 到 1023 字节的数据(共 1024 字节),而整个资源的总长度是 146,515 字节。
+- **`Content-Length` 头部**:指示此次响应中实际传输的字节数。例如,`Content-Length: 1024` 表示服务器端传输了 1024 字节的数据。
+
+`Range` 请求头不仅可以请求单个字节范围,还可以一次性请求多个范围。这种方式被称为“多重范围请求”(multiple range requests)。
+
+客户端想要获取资源的第 0 到 499 字节以及第 1000 到 1499 字节:
+
+```bash
+GET /path/to/resource HTTP/1.1
+Host: example.com
+Range: bytes=0-499,1000-1499
+```
+
+服务器端返回多个字节范围,每个范围的内容以分隔符分开:
+
+```bash
+HTTP/1.1 206 Partial Content
+Content-Type: multipart/byteranges; boundary=3d6b6a416f9b5
+Content-Length: 376
+
+--3d6b6a416f9b5
+Content-Type: application/octet-stream
+Content-Range: bytes 0-99/2000
+
+(第 0 到 99 字节的数据块)
+
+--3d6b6a416f9b5
+Content-Type: application/octet-stream
+Content-Range: bytes 500-599/2000
+
+(第 500 到 599 字节的数据块)
+
+--3d6b6a416f9b5
+Content-Type: application/octet-stream
+Content-Range: bytes 1000-1099/2000
+
+(第 1000 到 1099 字节的数据块)
+
+--3d6b6a416f9b5--
+```
### 状态码 100
@@ -95,10 +161,10 @@ HTTP/1.0 包含了`Content-Encoding`头部,对消息进行端到端编码。HT
## 总结
1. **连接方式** : HTTP 1.0 为短连接,HTTP 1.1 支持长连接。
-1. **状态响应码** : HTTP/1.1 中新加入了大量的状态码,光是错误响应状态码就新增了 24 种。比如说,`100 (Continue)`——在请求大资源前的预热请求,`206 (Partial Content)`——范围请求的标识码,`409 (Conflict)`——请求与当前资源的规定冲突,`410 (Gone)`——资源已被永久转移,而且没有任何已知的转发地址。
-1. **缓存处理** : 在 HTTP1.0 中主要使用 header 里的 If-Modified-Since,Expires 来做为缓存判断的标准,HTTP1.1 则引入了更多的缓存控制策略例如 Entity tag,If-Unmodified-Since, If-Match, If-None-Match 等更多可供选择的缓存头来控制缓存策略。
-1. **带宽优化及网络连接的使用** :HTTP1.0 中,存在一些浪费带宽的现象,例如客户端只是需要某个对象的一部分,而服务器却将整个对象送过来了,并且不支持断点续传功能,HTTP1.1 则在请求头引入了 range 头域,它允许只请求资源的某个部分,即返回码是 206(Partial Content),这样就方便了开发者自由的选择以便于充分利用带宽和连接。
-1. **Host 头处理** : HTTP/1.1 在请求头中加入了`Host`字段。
+2. **状态响应码** : HTTP/1.1 中新加入了大量的状态码,光是错误响应状态码就新增了 24 种。比如说,`100 (Continue)`——在请求大资源前的预热请求,`206 (Partial Content)`——范围请求的标识码,`409 (Conflict)`——请求与当前资源的规定冲突,`410 (Gone)`——资源已被永久转移,而且没有任何已知的转发地址。
+3. **缓存处理** : 在 HTTP1.0 中主要使用 header 里的 If-Modified-Since,Expires 来做为缓存判断的标准,HTTP1.1 则引入了更多的缓存控制策略例如 Entity tag,If-Unmodified-Since, If-Match, If-None-Match 等更多可供选择的缓存头来控制缓存策略。
+4. **带宽优化及网络连接的使用** :HTTP1.0 中,存在一些浪费带宽的现象,例如客户端只是需要某个对象的一部分,而服务器却将整个对象送过来了,并且不支持断点续传功能,HTTP1.1 则在请求头引入了 range 头域,它允许只请求资源的某个部分,即返回码是 206(Partial Content),这样就方便了开发者自由的选择以便于充分利用带宽和连接。
+5. **Host 头处理** : HTTP/1.1 在请求头中加入了`Host`字段。
## 参考资料
diff --git a/docs/cs-basics/network/images/arp/2008410143049281.png b/docs/cs-basics/network/images/arp/2008410143049281.png
deleted file mode 100644
index 759fb441f6c..00000000000
Binary files a/docs/cs-basics/network/images/arp/2008410143049281.png and /dev/null differ
diff --git a/docs/cs-basics/network/nat.md b/docs/cs-basics/network/nat.md
index 5634ba07387..630f4866bef 100644
--- a/docs/cs-basics/network/nat.md
+++ b/docs/cs-basics/network/nat.md
@@ -1,8 +1,13 @@
---
title: NAT 协议详解(网络层)
+description: 解析 NAT 的地址转换与端口映射机制,结合 LAN/WAN 通信与转换表,理解家庭与企业网络的实践细节。
category: 计算机基础
tag:
- 计算机网络
+head:
+ - - meta
+ - name: keywords
+ content: NAT,地址转换,端口映射,LAN,WAN,连接跟踪,DHCP
---
## 应用场景
@@ -21,7 +26,7 @@ SOHO 子网的“代理人”,也就是和外界的窗口,通常由路由器
首先,针对以上信息,我们有如下事实需要说明:
-1. 路由器的右侧子网的网络号为`10.0.0/24`,主机号为`10.0.0/8`,三台主机地址,以及路由器的 LAN 侧接口地址,均由 DHCP 协议规定。而且,该 DHCP 运行在路由器内部(路由器自维护一个小 DHCP 服务器),从而为子网内提供 DHCP 服务。
+1. 路由器右侧子网的网络地址为 `10.0.0.0/24`(网络前缀 24 位,主机号占 8 位),三台主机地址以及路由器的 LAN 侧接口地址,均由 DHCP 协议规定。而且,该 DHCP 运行在路由器内部(路由器自维护一个小 DHCP 服务器),从而为子网内提供 DHCP 服务。
2. 路由器的 WAN 侧接口地址同样由 DHCP 协议规定,但该地址是路由器从 ISP(网络服务提供商)处获得,也就是该 DHCP 通常运行在路由器所在区域的 DHCP 服务器上。
现在,路由器内部还运行着 NAT 协议,从而为 LAN-WAN 间通信提供地址转换服务。为此,一个很重要的结构是 **NAT 转换表**。为了说明 NAT 的运行细节,假设有以下请求发生:
@@ -45,7 +50,7 @@ SOHO 子网的“代理人”,也就是和外界的窗口,通常由路由器
针对以上过程,有以下几个重点需要强调:
1. 当请求报文到达路由器,并被指定了新端口号时,由于端口号有 16 位,因此,通常来说,一个路由器管理的 LAN 中的最大主机数 $≈65500$($2^{16}$ 的地址空间),但通常 SOHO 子网内不会有如此多的主机数量。
-2. 对于目的服务器来说,从来不知道“到底是哪个主机给我发送的请求”,它只知道是来自`138.76.29.7:5001`的路由器转发的请求。因此,可以说,**路由器在 WAN 和 LAN 之间起到了屏蔽作用,**所有内部主机发送到外部的报文,都具有同一个 IP 地址(不同的端口号),所有外部发送到内部的报文,也都只有一个目的地(不同端口号),是经过了 NAT 转换后,外部报文才得以正确地送达内部主机。
+2. 对于目的服务器来说,从来不知道“到底是哪个主机给我发送的请求”,它只知道是来自`138.76.29.7:5001`的路由器转发的请求。因此,可以说,**路由器在 WAN 和 LAN 之间起到了屏蔽作用**,所有内部主机发送到外部的报文,都具有同一个 IP 地址(不同的端口号),所有外部发送到内部的报文,也都只有一个目的地(不同端口号),是经过了 NAT 转换后,外部报文才得以正确地送达内部主机。
3. 在报文穿过路由器,发生 NAT 转换时,如果 LAN 主机 IP 已经在 NAT 转换表中注册过了,则不需要路由器新指派端口,而是直接按照转换记录穿过路由器。同理,外部报文发送至内部时也如此。
总结 NAT 协议的特点,有以下几点:
@@ -55,6 +60,6 @@ SOHO 子网的“代理人”,也就是和外界的窗口,通常由路由器
3. WAN 的 ISP 变更接口地址时,无需通告 LAN 内主机。
4. LAN 主机对 WAN 不可见,不可直接寻址,可以保证一定程度的安全性。
-然而,NAT 协议由于其独特性,存在着一些争议。比如,可能你已经注意到了,**NAT 协议在 LAN 以外,标识一个内部主机时,使用的是端口号,因为 IP 地址都是相同的。**这种将端口号作为主机寻址的行为,可能会引发一些误会。此外,路由器作为网络层的设备,修改了传输层的分组内容(修改了源 IP 地址和端口号),同样是不规范的行为。但是,尽管如此,NAT 协议作为 IPv4 时代的产物,极大地方便了一些本来棘手的问题,一直被沿用至今。
+然而,NAT 协议由于其独特性,存在着一些争议。比如,可能你已经注意到了,**NAT 协议在 LAN 以外,标识一个内部主机时,使用的是端口号,因为 IP 地址都是相同的**。这种将端口号作为主机寻址的行为,可能会引发一些误会。此外,路由器作为网络层的设备,修改了传输层的分组内容(修改了源 IP 地址和端口号),同样是不规范的行为。但是,尽管如此,NAT 协议作为 IPv4 时代的产物,极大地方便了一些本来棘手的问题,一直被沿用至今。
diff --git a/docs/cs-basics/network/network-attack-means.md b/docs/cs-basics/network/network-attack-means.md
index 7f142af00c3..876299718a6 100644
--- a/docs/cs-basics/network/network-attack-means.md
+++ b/docs/cs-basics/network/network-attack-means.md
@@ -1,8 +1,13 @@
---
title: 网络攻击常见手段总结
+description: 总结常见 TCP/IP 攻击与防护思路,覆盖 DDoS、IP/ARP 欺骗、中间人等手段,强调工程防护实践。
category: 计算机基础
tag:
- 计算机网络
+head:
+ - - meta
+ - name: keywords
+ content: 网络攻击,DDoS,IP 欺骗,ARP 欺骗,中间人攻击,扫描,防护
---
> 本文整理完善自[TCP/IP 常见攻击手段 - 暖蓝笔记 - 2021](https://mp.weixin.qq.com/s/AZwWrOlLxRSSi-ywBgZ0fA)这篇文章。
@@ -344,7 +349,7 @@ DES 使用的密钥表面上是 64 位的,然而只有其中的 56 位被实
常见的非对称加密算法:
-- RSA(RSA 加密算法,RSA Algorithm):优势是性能比较快,如果想要较高的加密难度,需要很长的秘钥。
+- RSA(RSA 加密算法,RSA Algorithm):安全性基于大整数分解的计算难度,应用广泛,兼容性好。缺点是性能相对较慢,且密钥越长(如 2048/4096 位)安全性越高,但运算开销也随之增大。
- ECC:基于椭圆曲线提出。是目前加密强度最高的非对称加密算法
- SM2:同样基于椭圆曲线问题设计。最大优势就是国家认可和大力支持。
@@ -357,17 +362,17 @@ DES 使用的密钥表面上是 64 位的,然而只有其中的 56 位被实
这个大家应该更加熟悉了,比如我们平常使用的 MD5 校验,在很多时候,我并不是拿来进行加密,而是用来获得唯一性 ID。在做系统的过程中,存储用户的各种密码信息,通常都会通过散列算法,最终存储其散列值。
-**MD5**
+**MD5**(不推荐)
MD5 可以用来生成一个 128 位的消息摘要,它是目前应用比较普遍的散列算法,具体的应用场景你可以自行 参阅。虽然,因为算法的缺陷,它的唯一性已经被破解了,但是大部分场景下,这并不会构成安全问题。但是,如果不是长度受限(32 个字符),我还是不推荐你继续使用 **MD5** 的。
**SHA**
-安全散列算法。**SHA** 分为 **SHA1** 和 **SH2** 两个版本。该算法的思想是接收一段明文,然后以一种不可逆的方式将它转换成一段(通常更小)密文,也可以简单的理解为取一串输入码(称为预映射或信息),并把它们转化为长度较短、位数固定的输出序列即散列值(也称为信息摘要或信息认证代码)的过程。
+安全散列算法。**SHA** 包括**SHA-1**、**SHA-2**和**SHA-3**三个版本。该算法的基本思想是:接收一段明文数据,通过不可逆的方式将其转换为固定长度的密文。简单来说,SHA 将输入数据(即预映射或消息)转化为固定长度、较短的输出值,称为散列值(或信息摘要、信息认证码)。SHA-1 已被证明不够安全,因此逐渐被 SHA-2 取代,而 SHA-3 则作为 SHA 系列的最新版本,采用不同的结构(Keccak 算法)提供更高的安全性和灵活性。
**SM3**
-国密算法**SM3**。加密强度和 SHA-256 想不多。主要是收到国家的支持。
+国密算法**SM3**。加密强度和 SHA-256 算法 相差不多。主要是受到了国家的支持。
**总结**:
diff --git a/docs/cs-basics/network/osi-and-tcp-ip-model.md b/docs/cs-basics/network/osi-and-tcp-ip-model.md
index 34092a336b6..49f2c8ccb00 100644
--- a/docs/cs-basics/network/osi-and-tcp-ip-model.md
+++ b/docs/cs-basics/network/osi-and-tcp-ip-model.md
@@ -1,8 +1,13 @@
---
title: OSI 和 TCP/IP 网络分层模型详解(基础)
+description: 详解 OSI 与 TCP/IP 的分层模型与职责划分,结合历史与实践对比两者差异与工程取舍。
category: 计算机基础
tag:
- 计算机网络
+head:
+ - - meta
+ - name: keywords
+ content: OSI 七层,TCP/IP 四层,分层模型,职责划分,协议栈,对比
---
## OSI 七层模型
@@ -19,7 +24,7 @@ tag:

-**既然 OSI 七层模型这么厉害,为什么干不过 TCP/IP 四 层模型呢?**
+**既然 OSI 七层模型这么厉害,为什么干不过 TCP/IP 四层模型呢?**
的确,OSI 七层模型当时一直被一些大公司甚至一些国家政府支持。这样的背景下,为什么会失败呢?我觉得主要有下面几方面原因:
@@ -66,7 +71,7 @@ OSI 七层模型虽然失败了,但是却提供了很多不错的理论基础
- **Telnet(远程登陆协议)**:基于 TCP 协议,用于通过一个终端登陆到其他服务器。Telnet 协议的最大缺点之一是所有数据(包括用户名和密码)均以明文形式发送,这有潜在的安全风险。这就是为什么如今很少使用 Telnet,而是使用一种称为 SSH 的非常安全的网络传输协议的主要原因。
- **SSH(Secure Shell Protocol,安全的网络传输协议)**:基于 TCP 协议,通过加密和认证机制实现安全的访问和文件传输等业务
- **RTP(Real-time Transport Protocol,实时传输协议)**:通常基于 UDP 协议,但也支持 TCP 协议。它提供了端到端的实时传输数据的功能,但不包含资源预留存、不保证实时传输质量,这些功能由 WebRTC 实现。
-- **DNS(Domain Name System,域名管理系统)**: 基于 UDP 协议,用于解决域名和 IP 地址的映射问题。
+- **DNS(Domain Name System,域名管理系统)**: 通常基于 UDP 协议(端口 53),用于解决域名和 IP 地址的映射问题。当响应数据过大或进行区域传送时会改用 TCP。
关于这些协议的详细介绍请看 [应用层常见协议总结(应用层)](./application-layer-protocol.md) 这篇文章。
diff --git a/docs/cs-basics/network/other-network-questions.md b/docs/cs-basics/network/other-network-questions.md
index a8b40e2fe87..df59c7a47b7 100644
--- a/docs/cs-basics/network/other-network-questions.md
+++ b/docs/cs-basics/network/other-network-questions.md
@@ -1,8 +1,13 @@
---
title: 计算机网络常见面试题总结(上)
+description: 最新计算机网络高频面试题总结(上):TCP/IP四层模型、HTTP全版本对比、TCP三次握手、DNS解析、WebSocket/SSE实时推送等,附图解+⭐️重点标注,一文搞定应用层&传输层&网络层核心考点,快速备战后端面试!
category: 计算机基础
tag:
- 计算机网络
+head:
+ - - meta
+ - name: keywords
+ content: 计算机网络面试题,TCP/IP四层模型,HTTP面试,HTTPS vs HTTP,HTTP/1.1 vs HTTP/2,HTTP/3 QUIC,TCP三次握手,UDP区别,DNS解析,WebSocket vs SSE,GET vs POST,应用层协议,网络分层,队头阻塞,PING命令,ARP协议
---
@@ -15,7 +20,7 @@ tag:
#### OSI 七层模型是什么?每一层的作用是什么?
-**OSI 七层模型** 是国际标准化组织提出一个网络分层模型,其大体结构以及每一层提供的功能如下图所示:
+**OSI 七层模型** 是国际标准化组织提出的一个网络分层模型,其大体结构以及每一层提供的功能如下图所示:

@@ -27,7 +32,7 @@ tag:

-#### TCP/IP 四层模型是什么?每一层的作用是什么?
+#### ⭐️TCP/IP 四层模型是什么?每一层的作用是什么?
**TCP/IP 四层模型** 是目前被广泛采用的一种模型,我们可以将 TCP / IP 模型看作是 OSI 七层模型的精简版本,由以下 4 层组成:
@@ -40,7 +45,7 @@ tag:

-关于每一层作用的详细介绍,请看 [OSI 和 TCP/IP 网络分层模型详解(基础)](./osi-and-tcp-ip-model.md) 这篇文章。
+关于每一层作用的详细介绍,请看 [OSI 和 TCP/IP 网络分层模型详解(基础)](https://javaguide.cn/cs-basics/network/osi-and-tcp-ip-model.html) 这篇文章。
#### 为什么网络要分层?
@@ -64,7 +69,7 @@ tag:
### 常见网络协议
-#### 应用层有哪些常见的协议?
+#### ⭐️应用层有哪些常见的协议?

@@ -75,7 +80,7 @@ tag:
- **Telnet(远程登陆协议)**:基于 TCP 协议,用于通过一个终端登陆到其他服务器。Telnet 协议的最大缺点之一是所有数据(包括用户名和密码)均以明文形式发送,这有潜在的安全风险。这就是为什么如今很少使用 Telnet,而是使用一种称为 SSH 的非常安全的网络传输协议的主要原因。
- **SSH(Secure Shell Protocol,安全的网络传输协议)**:基于 TCP 协议,通过加密和认证机制实现安全的访问和文件传输等业务
- **RTP(Real-time Transport Protocol,实时传输协议)**:通常基于 UDP 协议,但也支持 TCP 协议。它提供了端到端的实时传输数据的功能,但不包含资源预留存、不保证实时传输质量,这些功能由 WebRTC 实现。
-- **DNS(Domain Name System,域名管理系统)**: 基于 UDP 协议,用于解决域名和 IP 地址的映射问题。
+- **DNS(Domain Name System,域名管理系统)**: 通常基于 UDP 协议(端口 53),用于解决域名和 IP 地址的映射问题。当响应数据过大或进行区域传送时会改用 TCP。
关于这些协议的详细介绍请看 [应用层常见协议总结(应用层)](./application-layer-protocol.md) 这篇文章。
@@ -94,13 +99,13 @@ tag:
- **ARP(Address Resolution Protocol,地址解析协议)**:ARP 协议解决的是网络层地址和链路层地址之间的转换问题。因为一个 IP 数据报在物理上传输的过程中,总是需要知道下一跳(物理上的下一个目的地)该去往何处,但 IP 地址属于逻辑地址,而 MAC 地址才是物理地址,ARP 协议解决了 IP 地址转 MAC 地址的一些问题。
- **ICMP(Internet Control Message Protocol,互联网控制报文协议)**:一种用于传输网络状态和错误消息的协议,常用于网络诊断和故障排除。例如,Ping 工具就使用了 ICMP 协议来测试网络连通性。
- **NAT(Network Address Translation,网络地址转换协议)**:NAT 协议的应用场景如同它的名称——网络地址转换,应用于内部网到外部网的地址转换过程中。具体地说,在一个小的子网(局域网,LAN)内,各主机使用的是同一个 LAN 下的 IP 地址,但在该 LAN 以外,在广域网(WAN)中,需要一个统一的 IP 地址来标识该 LAN 在整个 Internet 上的位置。
-- **OSPF(Open Shortest Path First,开放式最短路径优先)** ):一种内部网关协议(Interior Gateway Protocol,IGP),也是广泛使用的一种动态路由协议,基于链路状态算法,考虑了链路的带宽、延迟等因素来选择最佳路径。
+- **OSPF(Open Shortest Path First,开放式最短路径优先)**:一种内部网关协议(Interior Gateway Protocol,IGP),也是广泛使用的一种动态路由协议,基于链路状态算法,考虑了链路的带宽、延迟等因素来选择最佳路径。
- **RIP(Routing Information Protocol,路由信息协议)**:一种内部网关协议(Interior Gateway Protocol,IGP),也是一种动态路由协议,基于距离向量算法,使用固定的跳数作为度量标准,选择跳数最少的路径作为最佳路径。
- **BGP(Border Gateway Protocol,边界网关协议)**:一种用来在路由选择域之间交换网络层可达性信息(Network Layer Reachability Information,NLRI)的路由选择协议,具有高度的灵活性和可扩展性。
## HTTP
-### 从输入 URL 到页面展示到底发生了什么?(非常重要)
+### ⭐️从输入 URL 到页面展示到底发生了什么?(非常重要)
> 类似的问题:打开一个网页,整个过程会使用哪些协议?
@@ -120,54 +125,54 @@ tag:
6. 浏览器收到 HTTP 响应报文后,解析响应体中的 HTML 代码,渲染网页的结构和样式,同时根据 HTML 中的其他资源的 URL(如图片、CSS、JS 等),再次发起 HTTP 请求,获取这些资源的内容,直到网页完全加载显示。
7. 浏览器在不需要和服务器通信时,可以主动关闭 TCP 连接,或者等待服务器的关闭请求。
-详细介绍可以查看这篇文章:[访问网页的全过程(知识串联)](./the-whole-process-of-accessing-web-pages.md)(强烈推荐)。
+详细介绍可以查看这篇文章:[访问网页的全过程(知识串联)](https://javaguide.cn/cs-basics/network/the-whole-process-of-accessing-web-pages.html)(强烈推荐)。
-### HTTP 状态码有哪些?
+### ⭐️HTTP 状态码有哪些?
HTTP 状态码用于描述 HTTP 请求的结果,比如 2xx 就代表请求被成功处理。

-关于 HTTP 状态码更详细的总结,可以看我写的这篇文章:[HTTP 常见状态码总结(应用层)](./http-status-codes.md)。
+关于 HTTP 状态码更详细的总结,可以看我写的这篇文章:[HTTP 常见状态码总结(应用层)](https://javaguide.cn/cs-basics/network/http-status-codes.html)。
### HTTP Header 中常见的字段有哪些?
-| 请求头字段名 | 说明 | 示例 |
-| :------------------ | :------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | :----------------------------------------------------------------------------------------- |
-| Accept | 能够接受的回应内容类型(Content-Types)。 | Accept: text/plain |
-| Accept-Charset | 能够接受的字符集 | Accept-Charset: utf-8 |
-| Accept-Datetime | 能够接受的按照时间来表示的版本 | Accept-Datetime: Thu, 31 May 2007 20:35:00 GMT |
-| Accept-Encoding | 能够接受的编码方式列表。参考 HTTP 压缩。 | Accept-Encoding: gzip, deflate |
-| Accept-Language | 能够接受的回应内容的自然语言列表。 | Accept-Language: en-US |
-| Authorization | 用于超文本传输协议的认证的认证信息 | Authorization: Basic QWxhZGRpbjpvcGVuIHNlc2FtZQ== |
-| Cache-Control | 用来指定在这次的请求/响应链中的所有缓存机制 都必须 遵守的指令 | Cache-Control: no-cache |
-| Connection | 该浏览器想要优先使用的连接类型 | Connection: keep-alive Connection: Upgrade |
-| Content-Length | 以 八位字节数组 (8 位的字节)表示的请求体的长度 | Content-Length: 348 |
-| Content-MD5 | 请求体的内容的二进制 MD5 散列值,以 Base64 编码的结果 | Content-MD5: Q2hlY2sgSW50ZWdyaXR5IQ== |
-| Content-Type | 请求体的 多媒体类型 (用于 POST 和 PUT 请求中) | Content-Type: application/x-www-form-urlencoded |
-| Cookie | 之前由服务器通过 Set- Cookie (下文详述)发送的一个 超文本传输协议 Cookie | Cookie: \$Version=1; Skin=new; |
-| Date | 发送该消息的日期和时间(按照 RFC 7231 中定义的"超文本传输协议日期"格式来发送) | Date: Tue, 15 Nov 1994 08:12:31 GMT |
-| Expect | 表明客户端要求服务器做出特定的行为 | Expect: 100-continue |
-| From | 发起此请求的用户的邮件地址 | From: [user@example.com](mailto:user@example.com) |
-| Host | 服务器的域名(用于虚拟主机 ),以及服务器所监听的传输控制协议端口号。如果所请求的端口是对应的服务的标准端口,则端口号可被省略。 | Host: en.wikipedia.org:80 |
-| If-Match | 仅当客户端提供的实体与服务器上对应的实体相匹配时,才进行对应的操作。主要作用时,用作像 PUT 这样的方法中,仅当从用户上次更新某个资源以来,该资源未被修改的情况下,才更新该资源。 | If-Match: “737060cd8c284d8af7ad3082f209582d” |
-| If-Modified-Since | 允许在对应的内容未被修改的情况下返回 304 未修改( 304 Not Modified ) | If-Modified-Since: Sat, 29 Oct 1994 19:43:31 GMT |
-| If-None-Match | 允许在对应的内容未被修改的情况下返回 304 未修改( 304 Not Modified ) | If-None-Match: “737060cd8c284d8af7ad3082f209582d” |
-| If-Range | 如果该实体未被修改过,则向我发送我所缺少的那一个或多个部分;否则,发送整个新的实体 | If-Range: “737060cd8c284d8af7ad3082f209582d” |
-| If-Unmodified-Since | 仅当该实体自某个特定时间已来未被修改的情况下,才发送回应。 | If-Unmodified-Since: Sat, 29 Oct 1994 19:43:31 GMT |
-| Max-Forwards | 限制该消息可被代理及网关转发的次数。 | Max-Forwards: 10 |
-| Origin | 发起一个针对 跨来源资源共享 的请求。 | Origin: [http://www.example-social-network.com](http://www.example-social-network.com/) |
-| Pragma | 与具体的实现相关,这些字段可能在请求/回应链中的任何时候产生多种效果。 | Pragma: no-cache |
-| Proxy-Authorization | 用来向代理进行认证的认证信息。 | Proxy-Authorization: Basic QWxhZGRpbjpvcGVuIHNlc2FtZQ== |
-| Range | 仅请求某个实体的一部分。字节偏移以 0 开始。参见字节服务。 | Range: bytes=500-999 |
-| Referer | 表示浏览器所访问的前一个页面,正是那个页面上的某个链接将浏览器带到了当前所请求的这个页面。 | Referer: [http://en.wikipedia.org/wiki/Main_Page](https://en.wikipedia.org/wiki/Main_Page) |
-| TE | 浏览器预期接受的传输编码方式:可使用回应协议头 Transfer-Encoding 字段中的值; | TE: trailers, deflate |
-| Upgrade | 要求服务器升级到另一个协议。 | Upgrade: HTTP/2.0, SHTTP/1.3, IRC/6.9, RTA/x11 |
-| User-Agent | 浏览器的浏览器身份标识字符串 | User-Agent: Mozilla/5.0 (X11; Linux x86_64; rv:12.0) Gecko/20100101 Firefox/21.0 |
-| Via | 向服务器告知,这个请求是由哪些代理发出的。 | Via: 1.0 fred, 1.1 example.com (Apache/1.1) |
-| Warning | 一个一般性的警告,告知,在实体内容体中可能存在错误。 | Warning: 199 Miscellaneous warning |
-
-### HTTP 和 HTTPS 有什么区别?(重要)
+| 请求头字段名 | 说明 | 示例 |
+| :------------------ | :---------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | :------------------------------------------------------------------------------- |
+| Accept | 能够接受的回应内容类型(Content-Types)。 | Accept: text/plain |
+| Accept-Charset | 能够接受的字符集 | Accept-Charset: utf-8 |
+| Accept-Datetime | 能够接受的按照时间来表示的版本 | Accept-Datetime: Thu, 31 May 2007 20:35:00 GMT |
+| Accept-Encoding | 能够接受的编码方式列表。参考 HTTP 压缩。 | Accept-Encoding: gzip, deflate |
+| Accept-Language | 能够接受的回应内容的自然语言列表。 | Accept-Language: en-US |
+| Authorization | 用于超文本传输协议的认证的认证信息 | Authorization: Basic QWxhZGRpbjpvcGVuIHNlc2FtZQ== |
+| Cache-Control | 用来指定在这次的请求/响应链中的所有缓存机制 都必须 遵守的指令 | Cache-Control: no-cache |
+| Connection | 该浏览器想要优先使用的连接类型 | Connection: keep-alive |
+| Content-Length | 以八位字节数组(8 位的字节)表示的请求体的长度 | Content-Length: 348 |
+| Content-MD5 | 请求体的内容的二进制 MD5 散列值,以 Base64 编码的结果 | Content-MD5: Q2hlY2sgSW50ZWdyaXR5IQ== |
+| Content-Type | 请求体的多媒体类型(用于 POST 和 PUT 请求中) | Content-Type: application/x-www-form-urlencoded |
+| Cookie | 之前由服务器通过 Set-Cookie(下文详述)发送的一个超文本传输协议 Cookie | Cookie: $Version=1; Skin=new; |
+| Date | 发送该消息的日期和时间(按照 RFC 7231 中定义的"超文本传输协议日期"格式来发送) | Date: Tue, 15 Nov 1994 08:12:31 GMT |
+| Expect | 表明客户端要求服务器做出特定的行为 | Expect: 100-continue |
+| From | 发起此请求的用户的邮件地址 | From: `user@example.com` |
+| Host | 服务器的域名(用于虚拟主机),以及服务器所监听的传输控制协议端口号。如果所请求的端口是对应的服务的标准端口,则端口号可被省略。 | Host: en.wikipedia.org |
+| If-Match | 仅当客户端提供的实体与服务器上对应的实体相匹配时,才进行对应的操作。主要作用是用于像 PUT 这样的方法中,仅当从用户上次更新某个资源以来,该资源未被修改的情况下,才更新该资源。 | If-Match: "737060cd8c284d8af7ad3082f209582d" |
+| If-Modified-Since | 允许服务器在请求的资源自指定的日期以来未被修改的情况下返回 `304 Not Modified` 状态码 | If-Modified-Since: Sat, 29 Oct 1994 19:43:31 GMT |
+| If-None-Match | 允许服务器在请求的资源的 ETag 未发生变化的情况下返回 `304 Not Modified` 状态码 | If-None-Match: "737060cd8c284d8af7ad3082f209582d" |
+| If-Range | 如果该实体未被修改过,则向我发送我所缺少的那一个或多个部分;否则,发送整个新的实体 | If-Range: "737060cd8c284d8af7ad3082f209582d" |
+| If-Unmodified-Since | 仅当该实体自某个特定时间以来未被修改的情况下,才发送回应。 | If-Unmodified-Since: Sat, 29 Oct 1994 19:43:31 GMT |
+| Max-Forwards | 限制该消息可被代理及网关转发的次数。 | Max-Forwards: 10 |
+| Origin | 发起一个针对跨来源资源共享的请求。 | `Origin: http://www.example-social-network.com` |
+| Pragma | 与具体的实现相关,这些字段可能在请求/回应链中的任何时候产生多种效果。 | Pragma: no-cache |
+| Proxy-Authorization | 用来向代理进行认证的认证信息。 | Proxy-Authorization: Basic QWxhZGRpbjpvcGVuIHNlc2FtZQ== |
+| Range | 仅请求某个实体的一部分。字节偏移以 0 开始。参见字节服务。 | Range: bytes=500-999 |
+| Referer | 表示浏览器所访问的前一个页面,正是那个页面上的某个链接将浏览器带到了当前所请求的这个页面。 | `Referer: http://en.wikipedia.org/wiki/Main_Page` |
+| TE | 浏览器预期接受的传输编码方式:可使用回应协议头 Transfer-Encoding 字段中的值; | TE: trailers, deflate |
+| Upgrade | 要求服务器升级到另一个协议。 | Upgrade: HTTP/2.0, SHTTP/1.3, IRC/6.9, RTA/x11 |
+| User-Agent | 浏览器的浏览器身份标识字符串 | User-Agent: Mozilla/5.0 (X11; Linux x86_64; rv:12.0) Gecko/20100101 Firefox/21.0 |
+| Via | 向服务器告知,这个请求是由哪些代理发出的。 | Via: 1.0 fred, 1.1 example.com (Apache/1.1) |
+| Warning | 一个一般性的警告,告知,在实体内容体中可能存在错误。 | Warning: 199 Miscellaneous warning |
+
+### ⭐️HTTP 和 HTTPS 有什么区别?(重要)

@@ -176,26 +181,27 @@ HTTP 状态码用于描述 HTTP 请求的结果,比如 2xx 就代表请求被
- **安全性和资源消耗**:HTTP 协议运行在 TCP 之上,所有传输的内容都是明文,客户端和服务器端都无法验证对方的身份。HTTPS 是运行在 SSL/TLS 之上的 HTTP 协议,SSL/TLS 运行在 TCP 之上。所有传输的内容都经过加密,加密采用对称加密,但对称加密的密钥用服务器方的证书进行了非对称加密。所以说,HTTP 安全性没有 HTTPS 高,但是 HTTPS 比 HTTP 耗费更多服务器资源。
- **SEO(搜索引擎优化)**:搜索引擎通常会更青睐使用 HTTPS 协议的网站,因为 HTTPS 能够提供更高的安全性和用户隐私保护。使用 HTTPS 协议的网站在搜索结果中可能会被优先显示,从而对 SEO 产生影响。
-关于 HTTP 和 HTTPS 更详细的对比总结,可以看我写的这篇文章:[HTTP vs HTTPS(应用层)](./http-vs-https.md) 。
+关于 HTTP 和 HTTPS 更详细的对比总结,可以看我写的这篇文章:[HTTP vs HTTPS(应用层)](https://javaguide.cn/cs-basics/network/http-vs-https.html) 。
### HTTP/1.0 和 HTTP/1.1 有什么区别?

-- **连接方式** : HTTP/1.0 为短连接,HTTP/1.1 支持长连接。
+- **连接方式** : HTTP/1.0 为短连接,HTTP/1.1 支持长连接。HTTP 协议的长连接和短连接,实质上是 TCP 协议的长连接和短连接。
- **状态响应码** : HTTP/1.1 中新加入了大量的状态码,光是错误响应状态码就新增了 24 种。比如说,`100 (Continue)`——在请求大资源前的预热请求,`206 (Partial Content)`——范围请求的标识码,`409 (Conflict)`——请求与当前资源的规定冲突,`410 (Gone)`——资源已被永久转移,而且没有任何已知的转发地址。
- **缓存机制** : 在 HTTP/1.0 中主要使用 Header 里的 If-Modified-Since,Expires 来做为缓存判断的标准,HTTP/1.1 则引入了更多的缓存控制策略例如 Entity tag,If-Unmodified-Since, If-Match, If-None-Match 等更多可供选择的缓存头来控制缓存策略。
- **带宽**:HTTP/1.0 中,存在一些浪费带宽的现象,例如客户端只是需要某个对象的一部分,而服务器却将整个对象送过来了,并且不支持断点续传功能,HTTP/1.1 则在请求头引入了 range 头域,它允许只请求资源的某个部分,即返回码是 206(Partial Content),这样就方便了开发者自由的选择以便于充分利用带宽和连接。
- **Host 头(Host Header)处理** :HTTP/1.1 引入了 Host 头字段,允许在同一 IP 地址上托管多个域名,从而支持虚拟主机的功能。而 HTTP/1.0 没有 Host 头字段,无法实现虚拟主机。
-关于 HTTP/1.0 和 HTTP/1.1 更详细的对比总结,可以看我写的这篇文章:[HTTP/1.0 vs HTTP/1.1(应用层)](./http1.0-vs-http1.1.md) 。
+关于 HTTP/1.0 和 HTTP/1.1 更详细的对比总结,可以看我写的这篇文章:[HTTP/1.0 vs HTTP/1.1(应用层)](https://javaguide.cn/cs-basics/network/http1.0-vs-http1.1.html) 。
-### HTTP/1.1 和 HTTP/2.0 有什么区别?
+### ⭐️HTTP/1.1 和 HTTP/2.0 有什么区别?

-- **多路复用(Multiplexing)**:HTTP/2.0 在同一连接上可以同时传输多个请求和响应(可以看作是 HTTP/1.1 中长链接的升级版本),互不干扰。HTTP/1.1 则使用串行方式,每个请求和响应都需要独立的连接,而浏览器为了控制资源会有 6-8 个 TCP 连接都限制。。这使得 HTTP/2.0 在处理多个请求时更加高效,减少了网络延迟和提高了性能。
+- **多路复用(Multiplexing)**:HTTP/2.0 在同一连接上可以同时传输多个请求和响应(可以看作是 HTTP/1.1 中长链接的升级版本),互不干扰。HTTP/1.1 则使用串行方式,每个请求和响应都需要独立的连接,而浏览器为了控制资源会有 6-8 个 TCP 连接的限制。这使得 HTTP/2.0 在处理多个请求时更加高效,减少了网络延迟和提高了性能。
- **二进制帧(Binary Frames)**:HTTP/2.0 使用二进制帧进行数据传输,而 HTTP/1.1 则使用文本格式的报文。二进制帧更加紧凑和高效,减少了传输的数据量和带宽消耗。
+- **队头阻塞**:HTTP/2 引入了多路复用技术,允许多个请求和响应在单个 TCP 连接上并行交错传输,解决了 HTTP/1.1 应用层的队头阻塞问题,但 HTTP/2 依然受到 TCP 层队头阻塞 的影响。
- **头部压缩(Header Compression)**:HTTP/1.1 支持`Body`压缩,`Header`不支持压缩。HTTP/2.0 支持对`Header`压缩,使用了专门为`Header`压缩而设计的 HPACK 算法,减少了网络开销。
- **服务器推送(Server Push)**:HTTP/2.0 支持服务器推送,可以在客户端请求一个资源时,将其他相关资源一并推送给客户端,从而减少了客户端的请求次数和延迟。而 HTTP/1.1 需要客户端自己发送请求来获取相关资源。
@@ -203,7 +209,7 @@ HTTP/2.0 多路复用效果图(图源: [HTTP/2 For Web Developers](https://b

-可以看到,HTTP/2.0 的多路复用使得不同的请求可以共用一个 TCP 连接,避免建立多个连接带来不必要的额外开销,而 HTTP/1.1 中的每个请求都会建立一个单独的连接
+可以看到,HTTP/2 的多路复用机制允许多个请求和响应共享一个 TCP 连接,从而避免了 HTTP/1.1 在应对并发请求时需要建立多个并行连接的情况,减少了重复连接建立和维护的额外开销。而在 HTTP/1.1 中,尽管支持持久连接,但为了缓解队头阻塞问题,浏览器通常会为同一域名建立多个并行连接。
### HTTP/2.0 和 HTTP/3.0 有什么区别?
@@ -211,25 +217,110 @@ HTTP/2.0 多路复用效果图(图源: [HTTP/2 For Web Developers](https://b
- **传输协议**:HTTP/2.0 是基于 TCP 协议实现的,HTTP/3.0 新增了 QUIC(Quick UDP Internet Connections) 协议来实现可靠的传输,提供与 TLS/SSL 相当的安全性,具有较低的连接和传输延迟。你可以将 QUIC 看作是 UDP 的升级版本,在其基础上新增了很多功能比如加密、重传等等。HTTP/3.0 之前名为 HTTP-over-QUIC,从这个名字中我们也可以发现,HTTP/3 最大的改造就是使用了 QUIC。
- **连接建立**:HTTP/2.0 需要经过经典的 TCP 三次握手过程(由于安全的 HTTPS 连接建立还需要 TLS 握手,共需要大约 3 个 RTT)。由于 QUIC 协议的特性(TLS 1.3,TLS 1.3 除了支持 1 个 RTT 的握手,还支持 0 个 RTT 的握手)连接建立仅需 0-RTT 或者 1-RTT。这意味着 QUIC 在最佳情况下不需要任何的额外往返时间就可以建立新连接。
+- **头部压缩**:HTTP/2.0 使用 HPACK 算法进行头部压缩,而 HTTP/3.0 使用更高效的 QPACK 头压缩算法。
- **队头阻塞**:HTTP/2.0 多请求复用一个 TCP 连接,一旦发生丢包,就会阻塞住所有的 HTTP 请求。由于 QUIC 协议的特性,HTTP/3.0 在一定程度上解决了队头阻塞(Head-of-Line blocking, 简写:HOL blocking)问题,一个连接建立多个不同的数据流,这些数据流之间独立互不影响,某个数据流发生丢包了,其数据流不受影响(本质上是多路复用+轮询)。
+- **连接迁移**:HTTP/3.0 支持连接迁移,因为 QUIC 使用 64 位 ID 标识连接,只要 ID 不变就不会中断,网络环境改变时(如从 Wi-Fi 切换到移动数据)也能保持连接。而 TCP 连接是由(源 IP,源端口,目的 IP,目的端口)组成,这个四元组中一旦有一项值发生改变,这个连接也就不能用了。
- **错误恢复**:HTTP/3.0 具有更好的错误恢复机制,当出现丢包、延迟等网络问题时,可以更快地进行恢复和重传。而 HTTP/2.0 则需要依赖于 TCP 的错误恢复机制。
-- **安全性**:HTTP/2.0 和 HTTP/3.0 在安全性上都有较高的要求,支持加密通信,但在实现上有所不同。HTTP/2.0 使用 TLS 协议进行加密,而 HTTP/3.0 基于 QUIC 协议,包含了内置的加密和身份验证机制,可以提供更强的安全性。
+- **安全性**:在 HTTP/2.0 中,TLS 用于加密和认证整个 HTTP 会话,包括所有的 HTTP 头部和数据负载。TLS 的工作是在 TCP 层之上,它加密的是在 TCP 连接中传输的应用层的数据,并不会对 TCP 头部以及 TLS 记录层头部进行加密,所以在传输的过程中 TCP 头部可能会被攻击者篡改来干扰通信。而 HTTP/3.0 的 QUIC 对整个数据包(包括报文头和报文体)进行了加密与认证处理,保障安全性。
HTTP/1.0、HTTP/2.0 和 HTTP/3.0 的协议栈比较:

+下图是一个更详细的 HTTP/2.0 和 HTTP/3.0 对比图:
+
+
+
+从上图可以看出:
+
+- **HTTP/2.0**:使用 TCP 作为传输协议、使用 HPACK 进行头部压缩、依赖 TLS 进行加密。
+- **HTTP/3.0**:使用基于 UDP 的 QUIC 协议、使用更高效的 QPACK 进行头部压缩、在 QUIC 中直接集成了 TLS。QUIC 协议具备连接迁移、拥塞控制与避免、流量控制等特性。
+
关于 HTTP/1.0 -> HTTP/3.0 更详细的演进介绍,推荐阅读[HTTP1 到 HTTP3 的工程优化](https://dbwu.tech/posts/http_evolution/)。
-### HTTP 是不保存状态的协议, 如何保存用户状态?
+### HTTP/1.1 和 HTTP/2.0 的队头阻塞有什么不同?
+
+HTTP/1.1 队头阻塞的主要原因是无法多路复用:
+
+- 在一个 TCP 连接中,资源的请求和响应是按顺序处理的。如果一个大的资源(如一个大文件)正在传输,后续的小资源(如较小的 CSS 文件)需要等待前面的资源传输完成后才能被发送。
+- 如果浏览器需要同时加载多个资源(如多个 CSS、JS 文件等),它通常会开启多个并行的 TCP 连接(一般限制为 6 个)。但每个连接仍然受限于顺序的请求-响应机制,因此仍然会发生 **应用层的队头阻塞**。
+
+虽然 HTTP/2.0 引入了多路复用技术,允许多个请求和响应在单个 TCP 连接上并行交错传输,解决了 **HTTP/1.1 应用层的队头阻塞问题**,但 HTTP/2.0 依然受到 **TCP 层队头阻塞** 的影响:
+
+- HTTP/2.0 通过帧(frame)机制将每个资源分割成小块,并为每个资源分配唯一的流 ID,这样多个资源的数据可以在同一 TCP 连接中交错传输。
+- TCP 作为传输层协议,要求数据按顺序交付。如果某个数据包在传输过程中丢失,即使后续的数据包已经到达,也必须等待丢失的数据包重传后才能继续处理。这种传输层的顺序性导致了 **TCP 层的队头阻塞**。
+- 举例来说,如果 HTTP/2 的一个 TCP 数据包中携带了多个资源的数据(例如 JS 和 CSS),而该数据包丢失了,那么后续数据包中的所有资源数据都需要等待丢失的数据包重传回来,导致所有流(streams)都被阻塞。
+
+最后,来一张表格总结补充一下:
+
+| **方面** | **HTTP/1.1 的队头阻塞** | **HTTP/2.0 的队头阻塞** |
+| -------------- | ---------------------------------------- | ---------------------------------------------------------------- |
+| **层级** | 应用层(HTTP 协议本身的限制) | 传输层(TCP 协议的限制) |
+| **根本原因** | 无法多路复用,请求和响应必须按顺序传输 | TCP 要求数据包按顺序交付,丢包时阻塞整个连接 |
+| **受影响范围** | 单个 HTTP 请求/响应会阻塞后续请求/响应。 | 单个 TCP 包丢失会影响所有 HTTP/2.0 流(依赖于同一个底层 TCP 连接) |
+| **缓解方法** | 开启多个并行的 TCP 连接 | 减少网络掉包或者使用基于 UDP 的 QUIC 协议 |
+| **影响场景** | 每次都会发生,尤其是大文件阻塞小文件时。 | 丢包率较高的网络环境下更容易发生。 |
+
+### ⭐️HTTP 是不保存状态的协议, 如何保存用户状态?
+
+HTTP 协议本身是 **无状态的 (stateless)** 。这意味着服务器默认情况下无法区分两个连续的请求是否来自同一个用户,或者同一个用户之前的操作是什么。这就像一个“健忘”的服务员,每次你跟他说话,他都不知道你是谁,也不知道你之前点过什么菜。
+
+但在实际的 Web 应用中,比如网上购物、用户登录等场景,我们显然需要记住用户的状态(例如购物车里的商品、用户的登录信息)。为了解决这个问题,主要有以下几种常用机制:
+
+**方案一:Session (会话) 配合 Cookie (主流方式):**
+
+
+
+这可以说是最经典也是最常用的方法了。基本流程是这样的:
+
+1. 用户向服务器发送用户名、密码、验证码用于登陆系统。
+2. 服务器验证通过后,会为这个用户创建一个专属的 Session 对象(可以理解为服务器上的一块内存,存放该用户的状态数据,如购物车、登录信息等)存储起来,并给这个 Session 分配一个唯一的 `SessionID`。
+3. 服务器通过 HTTP 响应头中的 `Set-Cookie` 指令,把这个 `SessionID` 发送给用户的浏览器。
+4. 浏览器接收到 `SessionID` 后,会将其以 Cookie 的形式保存在本地。当用户保持登录状态时,每次向该服务器发请求,浏览器都会自动带上这个存有 `SessionID` 的 Cookie。
+5. 服务器收到请求后,从 Cookie 中拿出 `SessionID`,就能找到之前保存的那个 Session 对象,从而知道这是哪个用户以及他之前的状态了。
+
+使用 Session 的时候需要注意下面几个点:
+
+- **客户端 Cookie 支持**:依赖 Session 的核心功能要确保用户浏览器开启了 Cookie。
+- **Session 过期管理**:合理设置 Session 的过期时间,平衡安全性和用户体验。
+- **Session ID 安全**:为包含 `SessionID` 的 Cookie 设置 `HttpOnly` 标志可以防止客户端脚本(如 JavaScript)窃取,设置 Secure 标志可以保证 `SessionID` 只在 HTTPS 连接下传输,增加安全性。
+
+Session 数据本身存储在服务器端。常见的存储方式有:
+
+- **服务器内存**:实现简单,访问速度快,但服务器重启数据会丢失,且不利于多服务器间的负载均衡。这种方式适合简单且用户量不大的业务场景。
+- **数据库 (如 MySQL, PostgreSQL)**:数据持久化,但读写性能相对较低,一般不会使用这种方式。
+- **分布式缓存 (如 Redis)**:性能高,支持分布式部署,是目前大规模应用中非常主流的方案。
-HTTP 是一种不保存状态,即无状态(stateless)协议。也就是说 HTTP 协议自身不对请求和响应之间的通信状态进行保存。那么我们如何保存用户状态呢?Session 机制的存在就是为了解决这个问题,Session 的主要作用就是通过服务端记录用户的状态。典型的场景是购物车,当你要添加商品到购物车的时候,系统不知道是哪个用户操作的,因为 HTTP 协议是无状态的。服务端给特定的用户创建特定的 Session 之后就可以标识这个用户并且跟踪这个用户了(一般情况下,服务器会在一定时间内保存这个 Session,过了时间限制,就会销毁这个 Session)。
+**方案二:当 Cookie 被禁用时:URL 重写 (URL Rewriting)**
-在服务端保存 Session 的方法很多,最常用的就是内存和数据库(比如是使用内存数据库 redis 保存)。既然 Session 存放在服务器端,那么我们如何实现 Session 跟踪呢?大部分情况下,我们都是通过在 Cookie 中附加一个 Session ID 来方式来跟踪。
+如果用户的浏览器禁用了 Cookie,或者某些情况下不便使用 Cookie,还有一种备选方案是 URL 重写。这种方式会将 `SessionID` 直接附加到 URL 的末尾,作为参数传递。例如:。服务器端会解析 URL 中的 `sessionid` 参数来获取 `SessionID`,进而找到对应的 Session 数据。
-**Cookie 被禁用怎么办?**
+这种方法一般不会使用,存在以下缺点:
-最常用的就是利用 URL 重写把 Session ID 直接附加在 URL 路径的后面。
+- URL 会变长且不美观;
+- `SessionID` 暴露在 URL 中,安全性较低(容易被复制、分享或记录在日志中);
+- 对搜索引擎优化 (SEO) 可能不友好。
+
+**方案三:Token-based 认证 (如 JWT - JSON Web Tokens)**
+
+这是一种越来越流行的无状态认证方式,尤其适用于前后端分离的架构和微服务。
+
+
+
+以 JWT 为例(普通 Token 方案也可以),简化后的步骤如下
+
+1. 用户向服务器发送用户名、密码以及验证码用于登陆系统;
+2. 如果用户用户名、密码以及验证码校验正确的话,服务端会返回已经签名的 Token,也就是 JWT;
+3. 客户端收到 Token 后自己保存起来(比如浏览器的 `localStorage` );
+4. 用户以后每次向后端发请求都在 Header 中带上这个 JWT ;
+5. 服务端检查 JWT 并从中获取用户相关信息。
+
+JWT 详细介绍可以查看这两篇文章:
+
+- [JWT 基础概念详解](https://javaguide.cn/system-design/security/jwt-intro.html)
+- [JWT 身份认证优缺点分析](https://javaguide.cn/system-design/security/advantages-and-disadvantages-of-jwt.html)
+
+总结来说,虽然 HTTP 本身是无状态的,但通过 Cookie + Session、URL 重写或 Token 等机制,我们能够有效地在 Web 应用中跟踪和管理用户状态。其中,**Cookie + Session 是最传统也最广泛使用的方式,而 Token-based 认证则在现代 Web 应用中越来越受欢迎。**
### URI 和 URL 的区别是什么?
@@ -240,14 +331,12 @@ URI 的作用像身份证号一样,URL 的作用更像家庭住址一样。URL
### Cookie 和 Session 有什么区别?
-准确点来说,这个问题属于认证授权的范畴,你可以在 [认证授权基础概念详解](../../system-design/security/basis-of-authority-certification.md) 这篇文章中找到详细的答案。
+准确点来说,这个问题属于认证授权的范畴,你可以在 [认证授权基础概念详解](https://javaguide.cn/system-design/security/basis-of-authority-certification.html) 这篇文章中找到详细的答案。
-### GET 和 POST 的区别
+### ⭐️GET 和 POST 的区别
这个问题在知乎上被讨论的挺火热的,地址: 。
-
-
GET 和 POST 是 HTTP 协议中两种常用的请求方法,它们在不同的场景和目的下有不同的特点和用法。一般来说,可以从以下几个方面来区分二者(重点搞清两者在语义上的区别即可):
- 语义(主要区别):GET 通常用于获取或查询资源,而 POST 通常用于创建或修改资源。
@@ -279,7 +368,7 @@ WebSocket 协议本质上是应用层的协议,用于弥补 HTTP 协议在持
- 社交聊天
- ……
-### WebSocket 和 HTTP 有什么区别?
+### ⭐️WebSocket 和 HTTP 有什么区别?
WebSocket 和 HTTP 两者都是基于 TCP 的应用层协议,都可以在网络中传输数据。
@@ -301,23 +390,70 @@ WebSocket 的工作过程可以分为以下几个步骤:
另外,建立 WebSocket 连接之后,通过心跳机制来保持 WebSocket 连接的稳定性和活跃性。
-### SSE 与 WebSocket 有什么区别?
+### ⭐️WebSocket 与短轮询、长轮询的区别
+
+这三种方式,都是为了解决“**客户端如何及时获取服务器最新数据,实现实时更新**”的问题。它们的实现方式和效率、实时性差异较大。
+
+**1.短轮询(Short Polling)**
+
+- **原理**:客户端每隔固定时间(如 5 秒)发起一次 HTTP 请求,询问服务器是否有新数据。服务器收到请求后立即响应。
+- **优点**:实现简单,兼容性好,直接用常规 HTTP 请求即可。
+- **缺点**:
+ - **实时性一般**:消息可能在两次轮询间到达,用户需等到下次请求才知晓。
+ - **资源浪费大**:反复建立/关闭连接,且大多数请求收到的都是“无新消息”,极大增加服务器和网络压力。
+
+**2.长轮询(Long Polling)**
+
+- **原理**:客户端发起请求后,若服务器暂时无新数据,则会保持连接,直到有新数据或超时才响应。客户端收到响应后立即发起下一次请求,实现“伪实时”。
+- **优点**:
+ - **实时性较好**:一旦有新数据可立即推送,无需等待下次定时请求。
+ - **空响应减少**:减少了无效的空响应,提升了效率。
+- **缺点**:
+ - **服务器资源占用高**:需长时间维护大量连接,消耗服务器线程/连接数。
+ - **资源浪费大**:每次响应后仍需重新建立连接,且依然基于 HTTP 单向请求-响应机制。
+
+**3. WebSocket**
+
+- **原理**:客户端与服务器通过一次 HTTP Upgrade 握手后,建立一条持久的 TCP 连接。之后,双方可以随时、主动地发送数据,实现真正的全双工、低延迟通信。
+- **优点**:
+ - **实时性强**:数据可即时双向收发,延迟极低。
+ - **资源效率高**:连接持续,无需反复建立/关闭,减少资源消耗。
+ - **功能强大**:支持服务端主动推送消息、客户端主动发起通信。
+- **缺点**:
+ - **使用限制**:需要服务器和客户端都支持 WebSocket 协议。对连接管理有一定要求(如心跳保活、断线重连等)。
+ - **实现麻烦**:实现起来比短轮询和长轮询要更麻烦一些。
+
+
+
+### ⭐️SSE 与 WebSocket 有什么区别?
+
+SSE (Server-Sent Events) 和 WebSocket 都是用来实现服务器向浏览器实时推送消息的技术,让网页内容能自动更新,而不需要用户手动刷新。虽然目标相似,但它们在工作方式和适用场景上有几个关键区别:
-> 摘自[Web 实时消息推送详解](https://javaguide.cn/system-design/web-real-time-message-push.html)。
+1. **通信方式:**
+ - **SSE:** **单向通信**。只有服务器能向客户端(浏览器)发送数据。客户端不能通过同一个连接向服务器发送数据(需要发起新的 HTTP 请求)。
+ - **WebSocket:** **双向通信 (全双工)**。客户端和服务器可以随时互相发送消息,实现真正的实时交互。
+2. **底层协议:**
+ - **SSE:** 基于**标准的 HTTP/HTTPS 协议**。它本质上是一个“长连接”的 HTTP 请求,服务器保持连接打开并持续发送事件流。不需要特殊的服务器或协议支持,现有的 HTTP 基础设施就能用。
+ - **WebSocket:** 使用**独立的 ws:// 或 wss:// 协议**。它需要通过一个特定的 HTTP "Upgrade" 请求来建立连接,并且服务器需要明确支持 WebSocket 协议来处理连接和消息帧。
+3. **实现复杂度和成本:**
+ - **SSE:** **实现相对简单**,主要在服务器端处理。浏览器端有标准的 EventSource API,使用方便。开发和维护成本较低。
+ - **WebSocket:** **稍微复杂一些**。需要服务器端专门处理 WebSocket 连接和协议,客户端也需要使用 WebSocket API。如果需要考虑兼容性、心跳、重连等,开发成本会更高。
+4. **断线重连:**
+ - **SSE:** **浏览器原生支持**。EventSource API 提供了自动断线重连的机制。
+ - **WebSocket:** **需要手动实现**。开发者需要自己编写逻辑来检测断线并进行重连尝试。
+5. **数据类型:**
+ - **SSE:** **主要设计用来传输文本** (UTF-8 编码)。如果需要传输二进制数据,需要先进行 Base64 等编码转换成文本。
+ - **WebSocket:** **原生支持传输文本和二进制数据**,无需额外编码。
-SSE 与 WebSocket 作用相似,都可以建立服务端与浏览器之间的通信,实现服务端向客户端推送消息,但还是有些许不同:
+为了提供更好的用户体验和利用其简单、高效、基于标准 HTTP 的特性,**Server-Sent Events (SSE) 是目前大型语言模型 API(如 OpenAI、DeepSeek 等)实现流式响应的常用甚至可以说是标准的技术选择**。
-- SSE 是基于 HTTP 协议的,它们不需要特殊的协议或服务器实现即可工作;WebSocket 需单独服务器来处理协议。
-- SSE 单向通信,只能由服务端向客户端单向通信;WebSocket 全双工通信,即通信的双方可以同时发送和接受信息。
-- SSE 实现简单开发成本低,无需引入其他组件;WebSocket 传输数据需做二次解析,开发门槛高一些。
-- SSE 默认支持断线重连;WebSocket 则需要自己实现。
-- SSE 只能传送文本消息,二进制数据需要经过编码后传送;WebSocket 默认支持传送二进制数据。
+这里以 DeepSeek 为例,我们发送一个请求并打开浏览器控制台验证一下:
-**SSE 与 WebSocket 该如何选择?**
+
-SSE 好像一直不被大家所熟知,一部分原因是出现了 WebSocket,这个提供了更丰富的协议来执行双向、全双工通信。对于游戏、即时通信以及需要双向近乎实时更新的场景,拥有双向通道更具吸引力。
+
-但是,在某些情况下,不需要从客户端发送数据。而你只需要一些服务器操作的更新。比如:站内信、未读消息数、状态更新、股票行情、监控数量等场景,SEE 不管是从实现的难易和成本上都更加有优势。此外,SSE 具有 WebSocket 在设计上缺乏的多种功能,例如:自动重新连接、事件 ID 和发送任意事件的能力。
+可以看到,响应头应里包含了 `text/event-stream`,说明使用的确实是 SSE。并且,响应数据也确实是持续分块传输。
## PING
@@ -386,15 +522,15 @@ DNS 服务器自底向上可以依次分为以下几个层级(所有 DNS 服务
- 权威 DNS 服务器。在因特网上具有公共可访问主机的每个组织机构必须提供公共可访问的 DNS 记录,这些记录将这些主机的名字映射为 IP 地址。
- 本地 DNS 服务器。每个 ISP(互联网服务提供商)都有一个自己的本地 DNS 服务器。当主机发出 DNS 请求时,该请求被发往本地 DNS 服务器,它起着代理的作用,并将该请求转发到 DNS 层次结构中。严格说来,不属于 DNS 层级结构
-世界上并不是只有 13 台根服务器,这是很多人普遍的误解,网上很多文章也是这么写的。实际上,现在根服务器数量远远超过这个数量。最初确实是为 DNS 根服务器分配了 13 个 IP 地址,每个 IP 地址对应一个不同的根 DNS 服务器。然而,由于互联网的快速发展和增长,这个原始的架构变得不太适应当前的需求。为了提高 DNS 的可靠性、安全性和性能,目前这 13 个 IP 地址中的每一个都有多个服务器,截止到 2023 年底,所有根服务器之和达到了 600 多台,未来还会继续增加。
+世界上并不是只有 13 台根服务器,这是很多人普遍的误解,网上很多文章也是这么写的。实际上,现在根服务器数量远远超过这个数量。最初确实是为 DNS 根服务器分配了 13 个 IP 地址,每个 IP 地址对应一个不同的根 DNS 服务器。然而,由于互联网的快速发展和增长,这个原始的架构变得不太适应当前的需求。为了提高 DNS 的可靠性、安全性和性能,目前这 13 个 IP 地址中的每一个都有多个服务器,截止到 2023 年底,所有根服务器之和达到了 1700 多台,未来还会继续增加。
-### DNS 解析的过程是什么样的?
+### ⭐️DNS 解析的过程是什么样的?
-整个过程的步骤比较多,我单独写了一篇文章详细介绍:[DNS 域名系统详解(应用层)](./dns.md) 。
+整个过程的步骤比较多,我单独写了一篇文章详细介绍:[DNS 域名系统详解(应用层)](https://javaguide.cn/cs-basics/network/dns.html) 。
### DNS 劫持了解吗?如何应对?
-DNS 劫持是一种网络攻击,它通过修改 DNS 服务器的解析结果,使用户访问的域名指向错误的 IP 地址,从而导致用户无法访问正常的网站,或者被引导到恶意的网站。DNS 劫持有时也被称为 DNS 重定向、DNS 欺骗或 DNS 污染。DNS 劫持详细介绍可以参考:[黑客技术?没你想象的那么难!——DNS 劫持篇](https://cloud.tencent.com/developer/article/1197474)。
+DNS 劫持是一种网络攻击,它通过修改 DNS 服务器的解析结果,使用户访问的域名指向错误的 IP 地址,从而导致用户无法访问正常的网站,或者被引导到恶意的网站。DNS 劫持有时也被称为 DNS 重定向、DNS 欺骗或 DNS 污染。
## 参考
diff --git a/docs/cs-basics/network/other-network-questions2.md b/docs/cs-basics/network/other-network-questions2.md
index d5054865497..0a75cd7d0f8 100644
--- a/docs/cs-basics/network/other-network-questions2.md
+++ b/docs/cs-basics/network/other-network-questions2.md
@@ -1,41 +1,78 @@
---
title: 计算机网络常见面试题总结(下)
+description: 最新计算机网络高频面试题总结(下):TCP/UDP深度对比、三次握手四次挥手、HTTP/3 QUIC优化、IPv6优势、NAT/ARP详解,附表格+⭐️重点标注,一文掌握传输层&网络层核心考点,快速通关后端技术面试!
category: 计算机基础
tag:
- 计算机网络
+head:
+ - - meta
+ - name: keywords
+ content: 计算机网络面试题,TCP vs UDP,TCP三次握手,HTTP/3 QUIC,IPv4 vs IPv6,TCP可靠性,IP地址,NAT协议,ARP协议,传输层面试,网络层高频题,基于TCP协议,基于UDP协议,队头阻塞,四次挥手
---
+
+
下篇主要是传输层和网络层相关的内容。
## TCP 与 UDP
-### TCP 与 UDP 的区别(重要)
-
-1. **是否面向连接**:UDP 在传送数据之前不需要先建立连接。而 TCP 提供面向连接的服务,在传送数据之前必须先建立连接,数据传送结束后要释放连接。
-2. **是否是可靠传输**:远地主机在收到 UDP 报文后,不需要给出任何确认,并且不保证数据不丢失,不保证是否顺序到达。TCP 提供可靠的传输服务,TCP 在传递数据之前,会有三次握手来建立连接,而且在数据传递时,有确认、窗口、重传、拥塞控制机制。通过 TCP 连接传输的数据,无差错、不丢失、不重复、并且按序到达。
-3. **是否有状态**:这个和上面的“是否可靠传输”相对应。TCP 传输是有状态的,这个有状态说的是 TCP 会去记录自己发送消息的状态比如消息是否发送了、是否被接收了等等。为此 ,TCP 需要维持复杂的连接状态表。而 UDP 是无状态服务,简单来说就是不管发出去之后的事情了(**这很渣男!**)。
-4. **传输效率**:由于使用 TCP 进行传输的时候多了连接、确认、重传等机制,所以 TCP 的传输效率要比 UDP 低很多。
-5. **传输形式**:TCP 是面向字节流的,UDP 是面向报文的。
-6. **首部开销**:TCP 首部开销(20 ~ 60 字节)比 UDP 首部开销(8 字节)要大。
-7. **是否提供广播或多播服务**:TCP 只支持点对点通信,UDP 支持一对一、一对多、多对一、多对多;
+### ⭐️TCP 与 UDP 的区别(重要)
+
+1. **是否面向连接**:
+ - TCP 是面向连接的。在传输数据之前,必须先通过“三次握手”建立连接;数据传输完成后,还需要通过“四次挥手”来释放连接。这保证了双方都准备好通信。
+ - UDP 是无连接的。发送数据前不需要建立任何连接,直接把数据包(数据报)扔出去。
+2. **是否是可靠传输**:
+ - TCP 提供可靠的数据传输服务。它通过序列号、确认应答 (ACK)、超时重传、流量控制、拥塞控制等一系列机制,来确保数据能够无差错、不丢失、不重复且按顺序地到达目的地。
+ - UDP 提供不可靠的传输。它尽最大努力交付 (best-effort delivery),但不保证数据一定能到达,也不保证到达的顺序,更不会自动重传。收到报文后,接收方也不会主动发确认。
+3. **是否有状态**:
+ - TCP 是有状态的。因为要保证可靠性,TCP 需要在连接的两端维护连接状态信息,比如序列号、窗口大小、哪些数据发出去了、哪些收到了确认等。
+ - UDP 是无状态的。它不维护连接状态,发送方发出数据后就不再关心它是否到达以及如何到达,因此开销更小(**这很“渣男”!**)。
+4. **传输效率**:
+ - TCP 因为需要建立连接、发送确认、处理重传等,其开销较大,传输效率相对较低。
+ - UDP 结构简单,没有复杂的控制机制,开销小,传输效率更高,速度更快。
+5. **传输形式**:
+ - TCP 是面向字节流 (Byte Stream) 的。它将应用程序交付的数据视为一连串无结构的字节流,可能会对数据进行拆分或合并。
+ - UDP 是面向报文 (Message Oriented) 的。应用程序交给 UDP 多大的数据块,UDP 就照样发送,既不拆分也不合并,保留了应用程序消息的边界。
+6. **首部开销**:
+ - TCP 的头部至少需要 20 字节,如果包含选项字段,最多可达 60 字节。
+ - UDP 的头部非常简单,固定只有 8 字节。
+7. **是否提供广播或多播服务**:
+ - TCP 只支持点对点 (Point-to-Point) 的单播通信。
+ - UDP 支持一对一 (单播)、一对多 (多播/Multicast) 和一对所有 (广播/Broadcast) 的通信方式。
8. ……
-我把上面总结的内容通过表格形式展示出来了!确定不点个赞嘛?
+为了更直观地对比,可以看下面这个表格:
+
+| 特性 | TCP | UDP |
+| ------------ | -------------------------- | ----------------------------------- |
+| **连接性** | 面向连接 | 无连接 |
+| **可靠性** | 可靠 | 不可靠 (尽力而为) |
+| **状态维护** | 有状态 | 无状态 |
+| **传输效率** | 较低 | 较高 |
+| **传输形式** | 面向字节流 | 面向数据报 (报文) |
+| **头部开销** | 20 - 60 字节 | 8 字节 |
+| **通信模式** | 点对点 (单播) | 单播、多播、广播 |
+| **常见应用** | HTTP/HTTPS, FTP, SMTP, SSH | DNS, DHCP, SNMP, TFTP, VoIP, 视频流 |
-| | TCP | UDP |
-| ---------------------- | -------------- | ---------- |
-| 是否面向连接 | 是 | 否 |
-| 是否可靠 | 是 | 否 |
-| 是否有状态 | 是 | 否 |
-| 传输效率 | 较慢 | 较快 |
-| 传输形式 | 字节流 | 数据报文段 |
-| 首部开销 | 20 ~ 60 bytes | 8 bytes |
-| 是否提供广播或多播服务 | 否 | 是 |
+### ⭐️什么时候选择 TCP,什么时候选 UDP?
-### 什么时候选择 TCP,什么时候选 UDP?
+选择 TCP 还是 UDP,主要取决于你的应用**对数据传输的可靠性要求有多高,以及对实时性和效率的要求有多高**。
-- **UDP 一般用于即时通信**,比如:语音、 视频、直播等等。这些场景对传输数据的准确性要求不是特别高,比如你看视频即使少个一两帧,实际给人的感觉区别也不大。
-- **TCP 用于对传输准确性要求特别高的场景**,比如文件传输、发送和接收邮件、远程登录等等。
+当**数据准确性和完整性至关重要,一点都不能出错**时,通常选择 TCP。因为 TCP 提供了一整套机制(三次握手、确认应答、重传、流量控制等)来保证数据能够可靠、有序地送达。典型应用场景如下:
+
+- **Web 浏览 (HTTP/HTTPS):** 网页内容、图片、脚本必须完整加载才能正确显示。
+- **文件传输 (FTP, SCP):** 文件内容不允许有任何字节丢失或错序。
+- **邮件收发 (SMTP, POP3, IMAP):** 邮件内容需要完整无误地送达。
+- **远程登录 (SSH, Telnet):** 命令和响应需要准确传输。
+- ……
+
+当**实时性、速度和效率优先,并且应用能容忍少量数据丢失或乱序**时,通常选择 UDP。UDP 开销小、传输快,没有建立连接和保证可靠性的复杂过程。典型应用场景如下:
+
+- **实时音视频通信 (VoIP, 视频会议, 直播):** 偶尔丢失一两个数据包(可能导致画面或声音短暂卡顿)通常比因为等待重传(TCP 机制)导致长时间延迟更可接受。应用层可能会有自己的补偿机制。
+- **在线游戏:** 需要快速传输玩家位置、状态等信息,对实时性要求极高,旧的数据很快就没用了,丢失少量数据影响通常不大。
+- **DHCP (动态主机配置协议):** 客户端在请求 IP 时自身没有 IP 地址,无法满足 TCP 建立连接的前提条件,并且 DHCP 有广播需求、交互模式简单以及自带可靠性机制。
+- **物联网 (IoT) 数据上报:** 某些场景下,传感器定期上报数据,丢失个别数据点可能不影响整体趋势分析。
+- ……
### HTTP 基于 TCP 还是 UDP?
@@ -43,9 +80,21 @@ tag:
🐛 修正(参见 [issue#1915](https://github.com/Snailclimb/JavaGuide/issues/1915)):
-HTTP/3.0 之前是基于 TCP 协议的,而 HTTP/3.0 将弃用 TCP,改用 **基于 UDP 的 QUIC 协议** 。
+HTTP/3.0 之前是基于 TCP 协议的,而 HTTP/3.0 将弃用 TCP,改用 **基于 UDP 的 QUIC 协议** :
+
+- **HTTP/1.x 和 HTTP/2.0**:这两个版本的 HTTP 协议都明确建立在 TCP 之上。TCP 提供了可靠的、面向连接的传输,确保数据按序、无差错地到达,这对于网页内容的正确展示非常重要。发送 HTTP 请求前,需要先通过 TCP 的三次握手建立连接。
+- **HTTP/3.0**:这是一个重大的改变。HTTP/3 弃用了 TCP,转而使用 QUIC 协议,而 QUIC 是构建在 UDP 之上的。
-此变化解决了 HTTP/2 中存在的队头阻塞问题。队头阻塞是指在 HTTP/2.0 中,多个 HTTP 请求和响应共享一个 TCP 连接,如果其中一个请求或响应因为网络拥塞或丢包而被阻塞,那么后续的请求或响应也无法发送,导致整个连接的效率降低。这是由于 HTTP/2.0 在单个 TCP 连接上使用了多路复用,受到 TCP 拥塞控制的影响,少量的丢包就可能导致整个 TCP 连接上的所有流被阻塞。HTTP/3.0 在一定程度上解决了队头阻塞问题,一个连接建立多个不同的数据流,这些数据流之间独立互不影响,某个数据流发生丢包了,其数据流不受影响(本质上是多路复用+轮询)。
+
+
+**为什么 HTTP/3 要做这个改变呢?主要有两大原因:**
+
+1. 解决队头阻塞 (Head-of-Line Blocking,简写:HOL blocking) 问题。
+2. 减少连接建立的延迟。
+
+下面我们来详细介绍这两大优化。
+
+在 HTTP/2 中,虽然可以在一个 TCP 连接上并发传输多个请求/响应流(多路复用),但 TCP 本身的特性(保证有序、可靠)意味着如果其中一个流的某个 TCP 报文丢失或延迟,整个 TCP 连接都会被阻塞,等待该报文重传。这会导致所有在这个 TCP 连接上的 HTTP/2 流都受到影响,即使其他流的数据包已经到达。**QUIC (运行在 UDP 上) 解决了这个问题**。QUIC 内部实现了自己的多路复用和流控制机制。不同的 HTTP 请求/响应流在 QUIC 层面是真正独立的。如果一个流的数据包丢失,它只会阻塞该流,而不会影响同一 QUIC 连接上的其他流(本质上是多路复用+轮询),大大提高了并发传输的效率。
除了解决队头阻塞问题,HTTP/3.0 还可以减少握手过程的延迟。在 HTTP/2.0 中,如果要建立一个安全的 HTTPS 连接,需要经过 TCP 三次握手和 TLS 握手:
@@ -59,27 +108,42 @@ HTTP/3.0 之前是基于 TCP 协议的,而 HTTP/3.0 将弃用 TCP,改用 **
-
-
-### 使用 TCP 的协议有哪些?使用 UDP 的协议有哪些?
+### 你知道哪些基于 TCP/UDP 的协议?
-**运行于 TCP 协议之上的协议**:
+TCP (传输控制协议) 和 UDP (用户数据报协议) 是互联网传输层的两大核心协议,它们为各种应用层协议提供了基础的通信服务。以下是一些常见的、分别构建在 TCP 和 UDP 之上的应用层协议:
-1. **HTTP 协议(HTTP/3.0 之前)**:超文本传输协议(HTTP,HyperText Transfer Protocol)是一种用于传输超文本和多媒体内容的协议,主要是为 Web 浏览器与 Web 服务器之间的通信而设计的。当我们使用浏览器浏览网页的时候,我们网页就是通过 HTTP 请求进行加载的。
-2. **HTTPS 协议**:更安全的超文本传输协议(HTTPS,Hypertext Transfer Protocol Secure),身披 SSL 外衣的 HTTP 协议
-3. **FTP 协议**:文件传输协议 FTP(File Transfer Protocol)是一种用于在计算机之间传输文件的协议,可以屏蔽操作系统和文件存储方式。注意 ⚠️:FTP 是一种不安全的协议,因为它在传输过程中不会对数据进行加密。建议在传输敏感数据时使用更安全的协议,如 SFTP。
-4. **SMTP 协议**:简单邮件传输协议(SMTP,Simple Mail Transfer Protocol)的缩写,是一种用于发送电子邮件的协议。注意 ⚠️:SMTP 协议只负责邮件的发送,而不是接收。要从邮件服务器接收邮件,需要使用 POP3 或 IMAP 协议。
-5. **POP3/IMAP 协议**:两者都是负责邮件接收的协议。IMAP 协议是比 POP3 更新的协议,它在功能和性能上都更加强大。IMAP 支持邮件搜索、标记、分类、归档等高级功能,而且可以在多个设备之间同步邮件状态。几乎所有现代电子邮件客户端和服务器都支持 IMAP。
-6. **Telnet 协议**:用于通过一个终端登陆到其他服务器。Telnet 协议的最大缺点之一是所有数据(包括用户名和密码)均以明文形式发送,这有潜在的安全风险。这就是为什么如今很少使用 Telnet,而是使用一种称为 SSH 的非常安全的网络传输协议的主要原因。
-7. **SSH 协议** : SSH( Secure Shell)是目前较可靠,专为远程登录会话和其他网络服务提供安全性的协议。利用 SSH 协议可以有效防止远程管理过程中的信息泄露问题。SSH 建立在可靠的传输协议 TCP 之上。
-8. ……
+**运行于 TCP 协议之上的协议 (强调可靠、有序传输):**
+
+| 中文全称 (缩写) | 英文全称 | 主要用途 | 说明与特性 |
+| -------------------------- | ---------------------------------- | ---------------------------- | --------------------------------------------------------------------------------------------------------------------------- |
+| 超文本传输协议 (HTTP) | HyperText Transfer Protocol | 传输网页、超文本、多媒体内容 | **HTTP/1.x 和 HTTP/2 基于 TCP**。早期版本不加密,是 Web 通信的基础。 |
+| 安全超文本传输协议 (HTTPS) | HyperText Transfer Protocol Secure | 加密的网页传输 | 在 HTTP 和 TCP 之间增加了 SSL/TLS 加密层,确保数据传输的机密性和完整性。 |
+| 文件传输协议 (FTP) | File Transfer Protocol | 文件传输 | 传统的 FTP **明文传输**,不安全。推荐使用其安全版本 **SFTP (SSH File Transfer Protocol)** 或 **FTPS (FTP over SSL/TLS)** 。 |
+| 简单邮件传输协议 (SMTP) | Simple Mail Transfer Protocol | **发送**电子邮件 | 负责将邮件从客户端发送到服务器,或在邮件服务器之间传递。可通过 **STARTTLS** 升级到加密传输。 |
+| 邮局协议第 3 版 (POP3) | Post Office Protocol version 3 | **接收**电子邮件 | 通常将邮件从服务器**下载到本地设备后删除服务器副本** (可配置保留)。**POP3S** 是其 SSL/TLS 加密版本。 |
+| 互联网消息访问协议 (IMAP) | Internet Message Access Protocol | **接收和管理**电子邮件 | 邮件保留在服务器,支持多设备同步邮件状态、文件夹管理、在线搜索等。**IMAPS** 是其 SSL/TLS 加密版本。现代邮件服务首选。 |
+| 远程终端协议 (Telnet) | Teletype Network | 远程终端登录 | **明文传输**所有数据 (包括密码),安全性极差,基本已被 SSH 完全替代。 |
+| 安全外壳协议 (SSH) | Secure Shell | 安全远程管理、加密数据传输 | 提供了加密的远程登录和命令执行,以及安全的文件传输 (SFTP) 等功能,是 Telnet 的安全替代品。 |
+
+**运行于 UDP 协议之上的协议 (强调快速、低开销传输):**
+
+| 中文全称 (缩写) | 英文全称 | 主要用途 | 说明与特性 |
+| ----------------------- | ------------------------------------- | -------------------------- | ------------------------------------------------------------------------------------------------------------ |
+| 超文本传输协议 (HTTP/3) | HyperText Transfer Protocol version 3 | 新一代网页传输 | 基于 **QUIC** 协议 (QUIC 本身构建于 UDP 之上),旨在减少延迟、解决 TCP 队头阻塞问题,支持 0-RTT 连接建立。 |
+| 动态主机配置协议 (DHCP) | Dynamic Host Configuration Protocol | 动态分配 IP 地址及网络配置 | 客户端从服务器自动获取 IP 地址、子网掩码、网关、DNS 服务器等信息。 |
+| 域名系统 (DNS) | Domain Name System | 域名到 IP 地址的解析 | **通常使用 UDP** 进行快速查询。当响应数据包过大或进行区域传送 (AXFR) 时,会**切换到 TCP** 以保证数据完整性。 |
+| 实时传输协议 (RTP) | Real-time Transport Protocol | 实时音视频数据流传输 | 常用于 VoIP、视频会议、直播等。追求低延迟,允许少量丢包。通常与 RTCP 配合使用。 |
+| RTP 控制协议 (RTCP) | RTP Control Protocol | RTP 流的质量监控和控制信息 | 配合 RTP 工作,提供丢包、延迟、抖动等统计信息,辅助流量控制和拥塞管理。 |
+| 简单文件传输协议 (TFTP) | Trivial File Transfer Protocol | 简化的文件传输 | 功能简单,常用于局域网内无盘工作站启动、网络设备固件升级等小文件传输场景。 |
+| 简单网络管理协议 (SNMP) | Simple Network Management Protocol | 网络设备的监控与管理 | 允许网络管理员查询和修改网络设备的状态信息。 |
+| 网络时间协议 (NTP) | Network Time Protocol | 同步计算机时钟 | 用于在网络中的计算机之间同步时间,确保时间的一致性。 |
-**运行于 UDP 协议之上的协议**:
+**总结一下:**
-1. **HTTP 协议(HTTP/3.0 )**: HTTP/3.0 弃用 TCP,改用基于 UDP 的 QUIC 协议 。
-2. **DHCP 协议**:动态主机配置协议,动态配置 IP 地址
-3. **DNS**:域名系统(DNS,Domain Name System)将人类可读的域名 (例如,www.baidu.com) 转换为机器可读的 IP 地址 (例如,220.181.38.148)。 我们可以将其理解为专为互联网设计的电话薄。实际上,DNS 同时支持 UDP 和 TCP 协议。
-4. ……
+- **TCP** 更适合那些对数据**可靠性、完整性和顺序性**要求高的应用,如网页浏览 (HTTP/HTTPS)、文件传输 (FTP/SFTP)、邮件收发 (SMTP/POP3/IMAP)。
+- **UDP** 则更适用于那些对**实时性要求高、能容忍少量数据丢失**的应用,如域名解析 (DNS)、实时音视频 (RTP)、在线游戏、网络管理 (SNMP) 等。
-### TCP 三次握手和四次挥手(非常重要)
+### ⭐️TCP 三次握手和四次挥手(非常重要)
**相关面试题**:
@@ -90,11 +154,11 @@ HTTP/3.0 之前是基于 TCP 协议的,而 HTTP/3.0 将弃用 TCP,改用 **
- 如果第二次挥手时服务器的 ACK 没有送达客户端,会怎样?
- 为什么第四次挥手客户端需要等待 2\*MSL(报文段最长寿命)时间后才进入 CLOSED 状态?
-**参考答案**:[TCP 三次握手和四次挥手(传输层)](./tcp-connection-and-disconnection.md) 。
+**参考答案**:[TCP 三次握手和四次挥手(传输层)](https://javaguide.cn/cs-basics/network/tcp-connection-and-disconnection.html) 。
-### TCP 如何保证传输的可靠性?(重要)
+### ⭐️TCP 如何保证传输的可靠性?(重要)
-[TCP 传输可靠性保障(传输层)](./tcp-reliability-guarantee.md)
+[TCP 传输可靠性保障(传输层)](https://javaguide.cn/cs-basics/network/tcp-reliability-guarantee.html)
## IP
@@ -122,7 +186,7 @@ HTTP/3.0 之前是基于 TCP 协议的,而 HTTP/3.0 将弃用 TCP,改用 **
IP 地址过滤是一种简单的网络安全措施,实际应用中一般会结合其他网络安全措施,如认证、授权、加密等一起使用。单独使用 IP 地址过滤并不能完全保证网络的安全。
-### IPv4 和 IPv6 有什么区别?
+### ⭐️IPv4 和 IPv6 有什么区别?
**IPv4(Internet Protocol version 4)** 是目前广泛使用的 IP 地址版本,其格式是四组由点分隔的数字,例如:123.89.46.72。IPv4 使用 32 位地址作为其 Internet 地址,这意味着共有约 42 亿( 2^32)个可用 IP 地址。
@@ -167,7 +231,7 @@ NAT 不光可以缓解 IPv4 地址资源短缺的问题,还可以隐藏内部

-相关阅读:[NAT 协议详解(网络层)](./nat.md)。
+相关阅读:[NAT 协议详解(网络层)](https://javaguide.cn/cs-basics/network/nat.html)。
## ARP
@@ -175,25 +239,25 @@ NAT 不光可以缓解 IPv4 地址资源短缺的问题,还可以隐藏内部
MAC 地址的全称是 **媒体访问控制地址(Media Access Control Address)**。如果说,互联网中每一个资源都由 IP 地址唯一标识(IP 协议内容),那么一切网络设备都由 MAC 地址唯一标识。
-
+
可以理解为,MAC 地址是一个网络设备真正的身份证号,IP 地址只是一种不重复的定位方式(比如说住在某省某市某街道的张三,这种逻辑定位是 IP 地址,他的身份证号才是他的 MAC 地址),也可以理解为 MAC 地址是身份证号,IP 地址是邮政地址。MAC 地址也有一些别称,如 LAN 地址、物理地址、以太网地址等。
> 还有一点要知道的是,不仅仅是网络资源才有 IP 地址,网络设备也有 IP 地址,比如路由器。但从结构上说,路由器等网络设备的作用是组成一个网络,而且通常是内网,所以它们使用的 IP 地址通常是内网 IP,内网的设备在与内网以外的设备进行通信时,需要用到 NAT 协议。
-MAC 地址的长度为 6 字节(48 比特),地址空间大小有 280 万亿之多($2^{48}$),MAC 地址由 IEEE 统一管理与分配,理论上,一个网络设备中的网卡上的 MAC 地址是永久的。不同的网卡生产商从 IEEE 那里购买自己的 MAC 地址空间(MAC 的前 24 比特),也就是前 24 比特由 IEEE 统一管理,保证不会重复。而后 24 比特,由各家生产商自己管理,同样保证生产的两块网卡的 MAC 地址不会重复。
+MAC 地址的长度为 6 字节(48 比特),地址空间大小有 280 万亿之多( $2^{48}$ ),MAC 地址由 IEEE 统一管理与分配,理论上,一个网络设备中的网卡上的 MAC 地址是永久的。不同的网卡生产商从 IEEE 那里购买自己的 MAC 地址空间(MAC 的前 24 比特),也就是前 24 比特由 IEEE 统一管理,保证不会重复。而后 24 比特,由各家生产商自己管理,同样保证生产的两块网卡的 MAC 地址不会重复。
MAC 地址具有可携带性、永久性,身份证号永久地标识一个人的身份,不论他到哪里都不会改变。而 IP 地址不具有这些性质,当一台设备更换了网络,它的 IP 地址也就可能发生改变,也就是它在互联网中的定位发生了变化。
最后,记住,MAC 地址有一个特殊地址:FF-FF-FF-FF-FF-FF(全 1 地址),该地址表示广播地址。
-### ARP 协议解决了什么问题?
+### ⭐️ARP 协议解决了什么问题?
ARP 协议,全称 **地址解析协议(Address Resolution Protocol)**,它解决的是网络层地址和链路层地址之间的转换问题。因为一个 IP 数据报在物理上传输的过程中,总是需要知道下一跳(物理上的下一个目的地)该去往何处,但 IP 地址属于逻辑地址,而 MAC 地址才是物理地址,ARP 协议解决了 IP 地址转 MAC 地址的一些问题。
### ARP 协议的工作原理?
-[ARP 协议详解(网络层)](./arp.md)
+[ARP 协议详解(网络层)](https://javaguide.cn/cs-basics/network/arp.html)
## 复习建议
diff --git a/docs/cs-basics/network/tcp-connection-and-disconnection.md b/docs/cs-basics/network/tcp-connection-and-disconnection.md
index 01f34f3e23f..b60e69075a2 100644
--- a/docs/cs-basics/network/tcp-connection-and-disconnection.md
+++ b/docs/cs-basics/network/tcp-connection-and-disconnection.md
@@ -1,11 +1,16 @@
---
title: TCP 三次握手和四次挥手(传输层)
+description: 一文讲清 TCP 三次握手与四次挥手:SEQ/ACK/SYN/FIN 如何同步,TIME_WAIT 与 2MSL 的原因,半连接队列(SYN Queue)与全连接队列(Accept Queue)的工作机制,以及 backlog/somaxconn/syncookies 在高并发与 SYN Flood 下的影响。
category: 计算机基础
tag:
- 计算机网络
+head:
+ - - meta
+ - name: keywords
+ content: TCP,三次握手,四次挥手,三次握手为什么,四次挥手为什么,TIME_WAIT,CLOSE_WAIT,2MSL,状态机,SEQ,ACK,SYN,FIN,RST,半连接队列,全连接队列,SYN队列,Accept队列,backlog,somaxconn,SYN Flood,syncookies
---
-为了准确无误地把数据送达目标处,TCP 协议采用了三次握手策略。
+TCP(Transmission Control Protocol)是一种**面向连接**、**可靠**的传输层协议。所谓“可靠”,通常体现在:按序交付、差错检测、丢包重传、流量控制与拥塞控制等。为了在不可靠的网络之上建立一条逻辑可靠的端到端连接,TCP 在传输数据前必须先完成连接建立过程,即 **三次握手(Three-way Handshake)**。
## 建立连接-TCP 三次握手
@@ -13,29 +18,156 @@ tag:
建立一个 TCP 连接需要“三次握手”,缺一不可:
-- **一次握手**:客户端发送带有 SYN(SEQ=x) 标志的数据包 -> 服务端,然后客户端进入 **SYN_SEND** 状态,等待服务器的确认;
-- **二次握手**:服务端发送带有 SYN+ACK(SEQ=y,ACK=x+1) 标志的数据包 –> 客户端,然后服务端进入 **SYN_RECV** 状态
-- **三次握手**:客户端发送带有 ACK(ACK=y+1) 标志的数据包 –> 服务端,然后客户端和服务器端都进入**ESTABLISHED** 状态,完成 TCP 三次握手。
+1. **第一次握手 (SYN)**: 客户端向服务端发送一个 SYN(Synchronize Sequence Numbers)报文段,其中包含一个由客户端随机生成的初始序列号(Initial Sequence Number, ISN),例如 seq=x。发送后,客户端进入 **SYN_SENT** 状态,等待服务端的确认。
+2. **第二次握手 (SYN+ACK)**: 服务端收到 SYN 报文段后,如果同意建立连接,会向客户端回复一个确认报文段。该报文段包含两个关键信息:
+ - **SYN**:服务端也需要同步自己的初始序列号,因此报文段中也包含一个由服务端随机生成的初始序列号,例如 seq=y。
+ - **ACK** (Acknowledgement):用于确认收到了客户端的请求。其确认号被设置为客户端初始序列号加一,即 ack=x+1。
+ - 发送该报文段后,服务端进入 **SYN_RCVD** (也称 SYN_RECV)状态。
+3. **第三次握手 (ACK)**: 客户端收到服务端的 SYN+ACK 报文段后,会向服务端发送一个最终的确认报文段。该报文段包含确认号 ack=y+1。发送后,客户端进入 **ESTABLISHED** 状态。服务端收到这个 ACK 报文段后,也进入 **ESTABLISHED** 状态。
-当建立了 3 次握手之后,客户端和服务端就可以传输数据啦!
+至此,双方都确认了连接的建立,TCP 连接成功创建,可以开始进行双向数据传输。
+
+### 什么是半连接队列和全连接队列?
+
+```mermaid
+sequenceDiagram
+ autonumber
+ participant C as 客户端 Client
+ participant K as 服务端内核 TCP
+ box 服务端内核队列
+ participant SQ as 半连接队列 SYN queue
+ participant AQ as 全连接队列 Accept queue
+ end
+ participant App as 用户态应用 Server app
+
+ C->>K: SYN
+ K-->>C: SYN 加 ACK
+ Note over SQ: 内核为该连接创建请求条目
连接状态 SYN_RCVD
放入 SYN queue
+
+ C->>K: ACK 第三次握手
+ Note over SQ,AQ: 内核收到 ACK 后完成握手
将连接从 SYN queue 迁移到 Accept queue
队列未满才可进入
+ Note over AQ: 连接已完成 可被 accept
连接状态 ESTABLISHED
+
+ App->>K: accept
+ K-->>App: 返回已就绪的 socket
+ Note over AQ: 该连接从 Accept queue 移除
+```
+
+在 TCP 三次握手过程中,服务端内核通常会用两个队列来管理连接请求(不同操作系统/内核版本实现细节可能略有差异,下面以常见 Linux 行为为例):
+
+1. **半连接队列**(也称 SYN Queue):
+ - 保存“握手未完成”的请求:服务端收到 SYN 并回 SYN+ACK 后,连接进入 SYN_RCVD,等待客户端最终 ACK。
+ - 如果一直收不到 ACK,内核会按重传策略重发 SYN+ACK,最终超时清理。
+ - 常见相关参数:`net.ipv4.tcp_max_syn_backlog`;在 SYN Flood 场景下可配合 `net.ipv4.tcp_syncookies`。
+2. **全连接队列**(也称 Accept Queue):
+ - 保存“握手已完成但应用还没 accept”的连接:服务端收到最终 ACK 后连接变为 `ESTABLISHED`,并进入 全连接队列,等待应用层 `accept()` 取走。
+ - 队列容量受 `listen(fd, backlog)` 与系统上限 `net.core.somaxconn` 共同影响;实践中常见有效上限近似为 `min(backlog, somaxconn)`(具体行为与内核版本相关)。
+
+总结:
+
+| 队列 | 作用 | 状态 | 移出条件 |
+| -------------------------- | ------------------ | ----------- | ----------------------- |
+| 半连接队列(SYN Queue) | 保存未完成握手连接 | SYN_RCVD | 收到 ACK / 超时重传失败 |
+| 全连接队列(Accept Queue) | 保存已完成握手连接 | ESTABLISHED | 被应用层 accept() 取出 |
+
+当全连接队列满时,`net.ipv4.tcp_abort_on_overflow` 会影响处理策略:
+
+- `0`(默认):通常不会立刻让连接快速失败,给应用留缓冲时间(可能表现为客户端重试/超时)。
+- `1`:直接对客户端回复 `RST`,让连接快速失败。
+
+当半连接队列满时,如果开启了 `tcp_syncookies`,服务端可能不会为该连接在半连接队列中分配常规条目,而是计算并返回一个 **SYN Cookie**。只有当收到合法的最终 `ACK` 时,才“重建”必要的连接信息。这是抵御 **SYN Flood** 的核心手段之一。
### 为什么要三次握手?
-三次握手的目的是建立可靠的通信信道,说到通讯,简单来说就是数据的发送与接收,而三次握手最主要的目的就是双方确认自己与对方的发送与接收是正常的。
+TCP 三次握手的核心目的是为了在客户端和服务器之间建立一个**可靠的**、**全双工的**通信信道。这需要实现两个主要目标:
+
+**1. 确认双方的收发能力,并同步初始序列号 (ISN)**
+
+```mermaid
+sequenceDiagram
+ autonumber
+ participant C as 客户端 Client
+ participant S as 服务端 Server
+
+ Note over C,S: 目标 同步双方 ISN 并确认双向可达
+
+ C->>S: SYN seq=ISN_C
+ Note right of S: 服务端确认 客户端到服务端方向可达
+ Note right of S: 服务端状态 SYN_RCVD
+
+ S->>C: SYN 加 ACK seq=ISN_S ack=ISN_C+1
+ Note left of C: 客户端确认
1 服务端到客户端方向可达
2 服务端已收到客户端 SYN
3 获得 ISN_S
+
+ C->>S: ACK seq=ISN_C+1 ack=ISN_S+1
+ Note left of C: 客户端状态 ESTABLISHED
+ Note right of S: 服务端确认 客户端已收到 SYN 加 ACK
双方 ISN 同步完成
+ Note right of S: 服务端状态 ESTABLISHED
+
+ Note over C,S: 连接建立 可以开始传输数据
+```
+
+TCP 依赖序列号(SEQ)与确认号(ACK)保证数据**有序、无重复、可重传**。三次握手通过交换并确认双方的 ISN,使两端对“从哪一个序号开始收发数据”达成一致,同时让握手过程形成闭环,避免仅凭单向信息就进入已建立状态。
+
+经过这三次交互,双方都确认了彼此的收发功能完好,并完成了初始序列号的同步,为后续可靠的数据传输奠定了基础。
+
+三次握手能力确认速记:
-1. **第一次握手**:Client 什么都不能确认;Server 确认了对方发送正常,自己接收正常
-2. **第二次握手**:Client 确认了:自己发送、接收正常,对方发送、接收正常;Server 确认了:对方发送正常,自己接收正常
-3. **第三次握手**:Client 确认了:自己发送、接收正常,对方发送、接收正常;Server 确认了:自己发送、接收正常,对方发送、接收正常
+1. C→S:SYN → S 确认:C 能发,S 能收(C→S 通)。
+2. S→C:SYN+ACK → C 确认:S 能发,C 能收,且 S 已收到 C 的 SYN(对方 SEQ + 1)。
+3. C→S:ACK → S 确认:C 已收到 S 的 SYN+ACK,握手闭环,连接建立。
-三次握手就能确认双方收发功能都正常,缺一不可。
+**2. 防止已失效的连接请求被错误地建立**
-更详细的解答可以看这个:[TCP 为什么是三次握手,而不是两次或四次? - 车小胖的回答 - 知乎](https://www.zhihu.com/question/24853633/answer/115173386) 。
+```mermaid
+sequenceDiagram
+ participant C as 客户端 (Client)
+ participant S as 服务端 (Server)
+
+ Note over C,S: 场景:旧的 SYN 报文在网络中滞留
+
+ C->>S: 1. 发送 SYN (旧请求 - 滞留中)
+ Note over C: 客户端超时,放弃该请求
+
+ C->>S: 2. 发送 SYN (新请求)
+ S-->>C: 3. 建立连接并正常释放...
+
+ rect rgb(255, 240, 240)
+ Note right of S: 此时,旧的 SYN 终于到达服务端
+ S->>C: 4. 发送 SYN+ACK (针对旧请求)
+
+ alt 如果是【两次握手】
+ Note right of S: (假设服务端在回复 SYN+ACK 后即认为连接建立)
+ Note right of S: ❌ 错误建立连接 (Ghost Connection)
分配内存/资源,造成浪费
+ else 如果是【三次握手】
+ Note left of C: 客户端无该连接状态 / 非期望报文
+ C->>S: 5. 发送 RST (重置报文) 或 直接丢弃
+
+ Note right of S: 【服务端结果】
收到 RST 立即清理;
或未收到 ACK 则重传并最终超时清理
+ Note right of S: ✅ 避免错误建连,保护资源
+ end
+ end
+```
+
+设想一个场景:客户端发送的第一个连接请求(SYN1)因网络延迟而滞留,于是客户端重发了第二个请求(SYN2)并成功建立了连接,数据传输完毕后连接被释放。此时,延迟的 SYN1 才到达服务端。
+
+- **如果是两次握手**:服务端收到这个失效的 SYN1 后,会误认为是一个新的连接请求,并立即分配资源、建立连接。但这将导致服务端单方面维持一个无效连接,白白浪费系统资源,因为客户端并不会有任何响应。
+- **有了第三次握手**:服务端收到失效的 SYN1 并回复 SYN+ACK 后,会等待客户端的最终确认(ACK)。由于客户端当前并没有发起连接的意图,它会忽略这个 SYN+ACK 或者发送一个 RST (Reset) 报文。这样,服务端就无法收到第三次握手的 ACK,最终会超时关闭这个错误的连接,从而避免了资源浪费。
+
+因此,三次握手是确保 TCP 连接可靠性的**最小且必需**的步骤。它不仅确认了双方的通信能力,更重要的是增加了一个最终确认环节,以防止网络中延迟、重复的历史请求对连接建立造成干扰。
### 第 2 次握手传回了 ACK,为什么还要传回 SYN?
-服务端传回发送端所发送的 ACK 是为了告诉客户端:“我接收到的信息确实就是你所发送的信号了”,这表明从客户端到服务端的通信是正常的。回传 SYN 则是为了建立并确认从服务端到客户端的通信。
+第二次握手里的 ACK 是为了确认“服务端确实收到了客户端的 SYN”(即确认 C→S 的请求到达)。而同时携带 SYN 是为了把服务端自己的 ISN 也同步给客户端,并要求客户端对其进行确认(即建立并确认 S→C 方向的建立过程)。只有双方的 ISN 都同步完成,后续的可靠传输(按序、重传、去重)才有共同起点。
+
+简言之:ACK 用于“我收到了你的 SYN”,SYN 用于“我也要发起我的同步,请你确认”。
+
+> SYN 同步序列编号(Synchronize Sequence Numbers) 是 TCP/IP 建立连接时使用的握手信号。在客户机和服务端之间建立正常的 TCP 网络连接时,客户机首先发出一个 SYN 消息,服务端使用 SYN-ACK 应答表示接收到了这个消息,最后客户机再以 ACK(Acknowledgement)消息响应。这样在客户机和服务端之间才能建立起可靠的 TCP 连接,数据才可以在客户机和服务端之间传递。
-> SYN 同步序列编号(Synchronize Sequence Numbers) 是 TCP/IP 建立连接时使用的握手信号。在客户机和服务器之间建立正常的 TCP 网络连接时,客户机首先发出一个 SYN 消息,服务器使用 SYN-ACK 应答表示接收到了这个消息,最后客户机再以 ACK(Acknowledgement)消息响应。这样在客户机和服务器之间才能建立起可靠的 TCP 连接,数据才可以在客户机和服务器之间传递。
+### 三次握手过程中可以携带数据吗?
+
+在 TCP 三次握手过程中,第三次握手是可以携带数据的(客户端发送完 ACK 确认包之后就进入 ESTABLISHED 状态了),这一点在 RFC 793 文档中有提到。也就是说,一旦完成了前两次握手,TCP 协议允许数据在第三次握手时开始传输。
+
+如果第三次握手的 ACK 确认包丢失,但是客户端已经开始发送携带数据的包,那么服务端在收到这个携带数据的包时,如果该包中包含了 ACK 标记,服务端会将其视为有效的第三次握手确认。这样,连接就被认为是建立的,服务端会处理该数据包,并继续正常的数据传输流程。
## 断开连接-TCP 四次挥手
@@ -43,44 +175,77 @@ tag:
断开一个 TCP 连接则需要“四次挥手”,缺一不可:
-1. **第一次挥手**:客户端发送一个 FIN(SEQ=x) 标志的数据包->服务端,用来关闭客户端到服务器的数据传送。然后客户端进入 **FIN-WAIT-1** 状态。
-2. **第二次挥手**:服务器收到这个 FIN(SEQ=X) 标志的数据包,它发送一个 ACK (ACK=x+1)标志的数据包->客户端 。然后服务端进入 **CLOSE-WAIT** 状态,客户端进入 **FIN-WAIT-2** 状态。
-3. **第三次挥手**:服务端发送一个 FIN (SEQ=y)标志的数据包->客户端,请求关闭连接,然后服务端进入 **LAST-ACK** 状态。
-4. **第四次挥手**:客户端发送 ACK (ACK=y+1)标志的数据包->服务端,然后客户端进入**TIME-WAIT**状态,服务端在收到 ACK (ACK=y+1)标志的数据包后进入 CLOSE 状态。此时如果客户端等待 **2MSL** 后依然没有收到回复,就证明服务端已正常关闭,随后客户端也可以关闭连接了。
+1. **第一次挥手 (FIN)**:当客户端(或任何一方)决定关闭连接时,它会向服务端发送一个 **FIN**(Finish)标志的报文段,表示自己已经没有数据要发送了。该报文段包含一个序列号 seq=u。发送后,客户端进入 **FIN-WAIT-1** 状态。
+2. **第二次挥手 (ACK)**:服务端收到 FIN 报文段后,会立即回复一个 **ACK** 确认报文段。其确认号为 ack=u+1。发送后,服务端进入 **CLOSE-WAIT** 状态。客户端收到这个 ACK 后,进入 **FIN-WAIT-2** 状态。此时,TCP 连接处于**半关闭(Half-Close)**状态:客户端到服务端的发送通道已关闭,但服务端到客户端的发送通道仍然可以传输数据。
+3. **第三次挥手 (FIN)**:当服务端确认所有待发送的数据都已发送完毕后,它也会向客户端发送一个 **FIN** 报文段,表示自己也准备关闭连接。该报文段同样包含一个序列号 seq=y。发送后,服务端进入 **LAST-ACK** 状态,等待客户端的最终确认。
+4. **第四次挥手**:客户端收到服务端的 FIN 报文段后,会回复一个最终的 **ACK** 确认报文段,确认号为 ack=y+1。发送后,客户端进入 **TIME-WAIT** 状态。服务端在收到这个 ACK 后,立即进入 **CLOSED** 状态,完成连接关闭。客户端则会在 **TIME-WAIT** 状态下等待 **2MSL**(Maximum Segment Lifetime,报文段最大生存时间)后,才最终进入 **CLOSED** 状态。
-**只要四次挥手没有结束,客户端和服务端就可以继续传输数据!**
+四次挥手期间连接可能处于**半关闭(Half-Close)**:**先发送 FIN 的一方不再发送应用数据**,但**另一方仍可继续发送剩余数据**,直到它也发送 FIN 并完成后续 ACK。
### 为什么要四次挥手?
-TCP 是全双工通信,可以双向传输数据。任何一方都可以在数据传送结束后发出连接释放的通知,待对方确认后进入半关闭状态。当另一方也没有数据再发送的时候,则发出连接释放通知,对方确认后就完全关闭了 TCP 连接。
+TCP 是全双工通信:两端的发送方向彼此独立。断开连接时,往往需要“我不发了”与“你也不发了”分别被对方确认,因此通常表现为四个报文段(FIN/ACK/FIN/ACK)。这也对应了现实世界的“双方分别确认挂断”的过程。
举个例子:A 和 B 打电话,通话即将结束后。
-1. **第一次挥手**:A 说“我没啥要说的了”
-2. **第二次挥手**:B 回答“我知道了”,但是 B 可能还会有要说的话,A 不能要求 B 跟着自己的节奏结束通话
-3. **第三次挥手**:于是 B 可能又巴拉巴拉说了一通,最后 B 说“我说完了”
-4. **第四次挥手**:A 回答“知道了”,这样通话才算结束。
+1. **第一次挥手**:A 说“我没啥要说的了”(A 发 FIN)
+2. **第二次挥手**:B 回答“我知道了”,但是 B 可能还会有要说的话,A 不能要求 B 跟着自己的节奏结束通话(B 回 ACK,但可能还有话要说)
+3. **第三次挥手**:于是 B 可能又巴拉巴拉说了一通,最后 B 说“我说完了”(B 发 FIN)
+4. **第四次挥手**:A 回答“知道了”,这样通话才算结束(A 回 ACK)。
+
+### 为什么不能把服务端发送的 ACK 和 FIN 合并起来,变成三次挥手?
+
+```mermaid
+sequenceDiagram
+ autonumber
+ participant C as 客户端
+ participant K as 服务端内核
+ participant A as 服务端应用
+
+ Note over C,K: 客户端发起关闭
+ C->>K: FIN
+ Note right of K: 内核立即回复 ACK 用于确认对端 FIN
+ K-->>C: ACK
+ Note right of K: 服务端状态变为 CLOSE_WAIT
+
+ Note over K,A: 应用处理阶段
+ K->>A: 通知本端应用对端已关闭发送方向 例如 read 返回 0
+ A->>A: 读取和处理剩余数据
+ A->>A: 发送最后响应
+ A->>K: 调用 close 或 shutdown
-### 为什么不能把服务器发送的 ACK 和 FIN 合并起来,变成三次挥手?
+ Note right of K: 发送本端 FIN 并进入 LAST_ACK
+ K-->>C: FIN
+ Note left of C: 客户端回复 ACK 并进入 TIME_WAIT
+ C->>K: ACK
+ Note right of K: 服务端收到最终 ACK 后进入 CLOSED
-因为服务器收到客户端断开连接的请求时,可能还有一些数据没有发完,这时先回复 ACK,表示接收到了断开连接的请求。等到数据发完之后再发 FIN,断开服务器到客户端的数据传送。
-### 如果第二次挥手时服务器的 ACK 没有送达客户端,会怎样?
+```
-客户端没有收到 ACK 确认,会重新发送 FIN 请求。
+关键原因是:**回复 ACK** 与 **发送 FIN** 的触发时机往往不同步。
+
+- 当服务端收到客户端 FIN 时,内核协议栈会立即回 ACK,用于确认“我收到了你要关闭的请求”。此时服务端进入 CLOSE_WAIT,等待本端应用把剩余事情处理完。
+- 只有当服务端应用处理完毕并调用 `close()/shutdown()` 后,内核才会发送本端的 FIN。
+- 因此“内核自动回 ACK”和“应用决定发 FIN”在时间上是解耦的,通常无法合并。只有在服务端恰好也准备立即关闭时,才可能出现 FIN+ACK 合并在一个报文段中的情况。
+
+### 如果第二次挥手时服务端的 ACK 没有送达客户端,会怎样?
+
+- **客户端状态**:客户端发送第一次 `FIN` 后进入 **FIN_WAIT_1** 并启动重传计时器。
+- **重传逻辑**:若在超时时间内未收到对端对该 `FIN` 的确认 `ACK`,客户端会重传 `FIN`。
+- **服务端处理**:服务端若收到重复 `FIN`,通常会再次发送 `ACK`。如果由于网络问题 ACK 一直到不了,客户端在达到一定重试/超时阈值后可能报错或放弃(具体由实现与参数如 `tcp_retries2` 等影响)。
### 为什么第四次挥手客户端需要等待 2\*MSL(报文段最长寿命)时间后才进入 CLOSED 状态?
-第四次挥手时,客户端发送给服务器的 ACK 有可能丢失,如果服务端因为某些原因而没有收到 ACK 的话,服务端就会重发 FIN,如果客户端在 2\*MSL 的时间内收到了 FIN,就会重新发送 ACK 并再次等待 2MSL,防止 Server 没有收到 ACK 而不断重发 FIN。
+第四次挥手时,客户端发送给服务端的 ACK 有可能丢失,如果服务端因为某些原因而没有收到 ACK 的话,服务端就会重发 FIN,如果客户端在 2\*MSL 的时间内收到了 FIN,就会重新发送 ACK 并再次等待 2MSL,防止 Server 没有收到 ACK 而不断重发 FIN。
> **MSL(Maximum Segment Lifetime)** : 一个片段在网络中最大的存活时间,2MSL 就是一个发送和一个回复所需的最大时间。如果直到 2MSL,Client 都没有再次收到 FIN,那么 Client 推断 ACK 已经被成功接收,则结束 TCP 连接。
## 参考
- 《计算机网络(第 7 版)》
-
- 《图解 HTTP》
-
- TCP and UDP Tutorial:
+- 从一次线上问题说起,详解 TCP 半连接队列、全连接队列:
diff --git a/docs/cs-basics/network/tcp-reliability-guarantee.md b/docs/cs-basics/network/tcp-reliability-guarantee.md
index d4c9bea80ed..e9a43a11d1a 100644
--- a/docs/cs-basics/network/tcp-reliability-guarantee.md
+++ b/docs/cs-basics/network/tcp-reliability-guarantee.md
@@ -1,8 +1,13 @@
---
title: TCP 传输可靠性保障(传输层)
+description: 系统梳理 TCP 的可靠性保障机制,覆盖重传/选择确认、流量与拥塞控制,明确端到端可靠传输的实现要点。
category: 计算机基础
tag:
- 计算机网络
+head:
+ - - meta
+ - name: keywords
+ content: TCP,可靠性,重传,SACK,流量控制,拥塞控制,滑动窗口,校验和
---
## TCP 如何保证传输的可靠性?
@@ -68,7 +73,7 @@ TCP 为全双工(Full-Duplex, FDX)通信,双方可以进行双向通信,客
TCP 的拥塞控制采用了四种算法,即 **慢开始**、 **拥塞避免**、**快重传** 和 **快恢复**。在网络层也可以使路由器采用适当的分组丢弃策略(如主动队列管理 AQM),以减少网络拥塞的发生。
-- **慢开始:** 慢开始算法的思路是当主机开始发送数据时,如果立即把大量数据字节注入到网络,那么可能会引起网络阻塞,因为现在还不知道网络的符合情况。经验表明,较好的方法是先探测一下,即由小到大逐渐增大发送窗口,也就是由小到大逐渐增大拥塞窗口数值。cwnd 初始值为 1,每经过一个传播轮次,cwnd 加倍。
+- **慢开始:** 慢开始算法的思路是当主机开始发送数据时,如果立即把大量数据字节注入到网络,那么可能会引起网络阻塞,因为现在还不知道网络的负荷情况。经验表明,较好的方法是先探测一下,即由小到大逐渐增大发送窗口,也就是由小到大逐渐增大拥塞窗口数值。cwnd 初始值为 1,每经过一个传播轮次,cwnd 加倍。
- **拥塞避免:** 拥塞避免算法的思路是让拥塞窗口 cwnd 缓慢增大,即每经过一个往返时间 RTT 就把发送方的 cwnd 加 1.
- **快重传与快恢复:** 在 TCP/IP 中,快速重传和恢复(fast retransmit and recovery,FRR)是一种拥塞控制算法,它能快速恢复丢失的数据包。没有 FRR,如果数据包丢失了,TCP 将会使用定时器来要求传输暂停。在暂停的这段时间内,没有新的或复制的数据包被发送。有了 FRR,如果接收机接收到一个不按顺序的数据段,它会立即给发送机发送一个重复确认。如果发送机接收到三个重复确认,它会假定确认件指出的数据段丢失了,并立即重传这些丢失的数据段。有了 FRR,就不会因为重传时要求的暂停被耽误。 当有单独的数据包丢失时,快速重传和恢复(FRR)能最有效地工作。当有多个数据信息包在某一段很短的时间内丢失时,它则不能很有效地工作。
diff --git a/docs/cs-basics/network/the-whole-process-of-accessing-web-pages.md b/docs/cs-basics/network/the-whole-process-of-accessing-web-pages.md
index c09c10e6ab8..2bacba2fdb1 100644
--- a/docs/cs-basics/network/the-whole-process-of-accessing-web-pages.md
+++ b/docs/cs-basics/network/the-whole-process-of-accessing-web-pages.md
@@ -1,11 +1,16 @@
---
title: 访问网页的全过程(知识串联)
+description: 串联从输入 URL 到页面渲染的完整链路,涵盖 DNS、TCP、HTTP 与静态资源加载,助力面试与实践理解。
category: 计算机基础
tag:
- 计算机网络
+head:
+ - - meta
+ - name: keywords
+ content: 访问网页流程,DNS,TCP 建连,HTTP 请求,资源加载,渲染,关闭连接
---
-开发岗中总是会考很多计算机网络的知识点,但如果让面试官只靠一道题,便涵盖最多的计网知识点,那可能就是 **网页浏览的全过程** 了。本篇文章将带大家从头到尾过一遍这道被考烂的面试题,必会!!!
+开发岗中总是会考很多计算机网络的知识点,但如果让面试官只考一道题,便涵盖最多的计网知识点,那可能就是 **网页浏览的全过程** 了。本篇文章将带大家从头到尾过一遍这道被考烂的面试题,必会!!!
总的来说,网络通信模型可以用下图来表示,也就是大家只要熟记网络结构五层模型,按照这个体系,很多知识点都能顺出来了。访问网页的过程也是如此。
@@ -71,9 +76,9 @@ TCP 协议保证了数据传输的可靠性,是数据包传输的主力协议
终于,来到网络层,此时我们的主机不再是和另一台主机进行交互了,而是在和中间系统进行交互。也就是说,应用层和传输层都是端到端的协议,而网络层及以下都是中间件的协议了。
-**网络层的的核心功能——转发与路由**,必会!!!如果面试官问到了网络层,而你恰好又什么都不会的话,最最起码要说出这五个字——**转发与路由**。
+**网络层的核心功能——转发与路由**,必会!!!如果面试官问到了网络层,而你恰好又什么都不会的话,最最起码要说出这五个字——**转发与路由**。
- 转发:将分组从路由器的输入端口转移到合适的输出端口。
- 路由:确定分组从源到目的经过的路径。
-所以到目前为止,我们的数据包经过了应用层、传输层的封装,来到了网络层,终于开始准备在物理层面传输了,第一个要解决的问题就是——**往哪里传输?或者说,要把数据包发到哪个路由器上?**这便是 BGP 协议要解决的问题。
+所以到目前为止,我们的数据包经过了应用层、传输层的封装,来到了网络层,终于开始准备在物理层面传输了,第一个要解决的问题就是——**往哪里传输?或者说,要把数据包发到哪个路由器上?** 这便是 BGP 协议要解决的问题。
diff --git a/docs/cs-basics/operating-system/linux-intro.md b/docs/cs-basics/operating-system/linux-intro.md
index ead135776cc..acd46480bf9 100644
--- a/docs/cs-basics/operating-system/linux-intro.md
+++ b/docs/cs-basics/operating-system/linux-intro.md
@@ -1,13 +1,14 @@
---
title: Linux 基础知识总结
+description: 简单介绍一下 Java 程序员必知的 Linux 的一些概念以及常见命令。
category: 计算机基础
tag:
- 操作系统
- Linux
head:
- - meta
- - name: description
- content: 简单介绍一下 Java 程序员必知的 Linux 的一些概念以及常见命令。
+ - name: keywords
+ content: Linux,基础命令,发行版,文件系统,权限,进程,网络
---
@@ -70,7 +71,7 @@ inode 是 Linux/Unix 文件系统的基础。那 inode 到是什么?有什么作
通过以下五点可以概括 inode 到底是什么:
-1. 硬盘的最小存储单位是扇区(Sector),块(block)由多个扇区组成。文件数据存储在块中。块的最常见的大小是 4kb,约为 8 个连续的扇区组成(每个扇区存储 512 字节)。一个文件可能会占用多个 block,但是一个块只能存放一个文件。虽然,我们将文件存储在了块(block)中,但是我们还需要一个空间来存储文件的 **元信息 metadata**:如某个文件被分成几块、每一块在的地址、文件拥有者,创建时间,权限,大小等。这种 **存储文件元信息的区域就叫 inode**,译为索引节点:**i(index)+node**。 **每个文件都有一个唯一的 inode,存储文件的元信息。**
+1. 硬盘以扇区 (Sector) 为最小物理存储单位,而操作系统和文件系统以块 (Block) 为单位进行读写,块由多个扇区组成。文件数据存储在这些块中。现代硬盘扇区通常为 4KB,与一些常见块大小相同,但操作系统也支持更大的块大小,以提升大文件读写性能。文件元信息(例如权限、大小、修改时间以及数据块位置)存储在 inode(索引节点)中。每个文件都有唯一的 inode。inode 本身不存储文件数据,而是存储指向数据块的指针,操作系统通过这些指针找到并读取文件数据。 固态硬盘 (SSD) 虽然没有物理扇区,但使用逻辑块,其概念与传统硬盘的块类似。
2. inode 是一种固定大小的数据结构,其大小在文件系统创建时就确定了,并且在文件的生命周期内保持不变。
3. inode 的访问速度非常快,因为系统可以直接通过 inode 号码定位到文件的元数据信息,无需遍历整个文件系统。
4. inode 的数量是有限的,每个文件系统只能包含固定数量的 inode。这意味着当文件系统中的 inode 用完时,无法再创建新的文件或目录,即使磁盘上还有可用空间。因此,在创建文件系统时,需要根据文件和目录的预期数量来合理分配 inode 的数量。
@@ -185,8 +186,8 @@ Linux 使用一种称为目录树的层次结构来组织文件和目录。目
### 目录操作
- `ls`:显示目录中的文件和子目录的列表。例如:`ls /home`,显示 `/home` 目录下的文件和子目录列表。
-- `ll`:`ll` 是 `ls -l` 的别名,ll 命令可以看到该目录下的所有目录和文件的详细信息
-- `mkdir [选项] 目录名`:创建新目录(增)。例如:`mkdir -m 755 my_directory`,创建一个名为 `my_directory` 的新目录,并将其权限设置为 755,即所有用户对该目录有读、写和执行的权限。
+- `ll`:`ll` 是 `ls -l` 的别名,ll 命令可以看到该目录下的所有目录和文件的详细信息。
+- `mkdir [选项] 目录名`:创建新目录(增)。例如:`mkdir -m 755 my_directory`,创建一个名为 `my_directory` 的新目录,并将其权限设置为 755,其中所有者拥有读、写、执行权限,所属组和其他用户只有读、执行权限,无法修改目录内容(如创建或删除文件)。如果希望所有用户(包括所属组和其他用户)对目录都拥有读、写、执行权限,则应设置权限为 `777`,即:`mkdir -m 777 my_directory`。
- `find [路径] [表达式]`:在指定目录及其子目录中搜索文件或目录(查),非常强大灵活。例如:① 列出当前目录及子目录下所有文件和文件夹: `find .`;② 在`/home`目录下查找以 `.txt` 结尾的文件名:`find /home -name "*.txt"` ,忽略大小写: `find /home -i name "*.txt"` ;③ 当前目录及子目录下查找所有以 `.txt` 和 `.pdf` 结尾的文件:`find . \( -name "*.txt" -o -name "*.pdf" \)`或`find . -name "*.txt" -o -name "*.pdf"`。
- `pwd`:显示当前工作目录的路径。
- `rmdir [选项] 目录名`:删除空目录(删)。例如:`rmdir -p my_directory`,删除名为 `my_directory` 的空目录,并且会递归删除`my_directory`的空父目录,直到遇到非空目录或根目录。
@@ -285,11 +286,11 @@ Linux 中的打包文件一般是以 `.tar` 结尾的,压缩的命令一般是
需要注意的是:**超级用户可以无视普通用户的权限,即使文件目录权限是 000,依旧可以访问。**
-**在 linux 中的每个用户必须属于一个组,不能独立于组外。在 linux 中每个文件有所有者、所在组、其它组的概念。**
+**在 Linux 中的每个用户必须属于一个组,不能独立于组外。在 linux 中每个文件有所有者、所在组、其它组的概念。**
-- **所有者(u)**:一般为文件的创建者,谁创建了该文件,就天然的成为该文件的所有者,用 `ls ‐ahl` 命令可以看到文件的所有者 也可以使用 chown 用户名 文件名来修改文件的所有者 。
-- **文件所在组(g)**:当某个用户创建了一个文件后,这个文件的所在组就是该用户所在的组用 `ls ‐ahl`命令可以看到文件的所有组也可以使用 chgrp 组名 文件名来修改文件所在的组。
-- **其它组(o)**:除开文件的所有者和所在组的用户外,系统的其它用户都是文件的其它组。
+- **所有者(u)** :一般为文件的创建者,谁创建了该文件,就天然的成为该文件的所有者,用 `ls ‐ahl` 命令可以看到文件的所有者 也可以使用 chown 用户名 文件名来修改文件的所有者 。
+- **文件所在组(g)** :当某个用户创建了一个文件后,这个文件的所在组就是该用户所在的组用 `ls ‐ahl`命令可以看到文件的所有组也可以使用 chgrp 组名 文件名来修改文件所在的组。
+- **其它组(o)** :除开文件的所有者和所在组的用户外,系统的其它用户都是文件的其它组。
> 我们再来看看如何修改文件/目录的权限。
@@ -355,11 +356,13 @@ Linux 系统是一个多用户多任务的分时操作系统,任何一个要
- `ifconfig` 或 `ip`:用于查看系统的网络接口信息,包括网络接口的 IP 地址、MAC 地址、状态等。
- `netstat [选项]`:用于查看系统的网络连接状态和网络统计信息,可以查看当前的网络连接情况、监听端口、网络协议等。
- `ss [选项]`:比 `netstat` 更好用,提供了更快速、更详细的网络连接信息。
+- `nload`:`sar` 和 `nload` 都可以监控网络流量,但`sar` 的输出是文本形式的数据,不够直观。`nload` 则是一个专门用于实时监控网络流量的工具,提供图形化的终端界面,更加直观。不过,`nload` 不保存历史数据,所以它不适合用于长期趋势分析。并且,系统并没有默认安装它,需要手动安装。
+- `sudo hostnamectl set-hostname 新主机名`:更改主机名,并且重启后依然有效。`sudo hostname 新主机名`也可以更改主机名。不过需要注意的是,使用 `hostname` 命令直接更改主机名只是临时生效,系统重启后会恢复为原来的主机名。
### 其他
- `sudo + 其他命令`:以系统管理者的身份执行指令,也就是说,经由 sudo 所执行的指令就好像是 root 亲自执行。
-- `grep 要搜索的字符串 要搜索的文件 --color`:搜索命令,--color 代表高亮显示。
+- `grep [选项] "搜索内容" 文件路径`:非常强大且常用的文本搜索命令,它可以根据指定的字符串或正则表达式,在文件或命令输出中进行匹配查找,适用于日志分析、文本过滤、快速定位等多种场景。示例:忽略大小写搜索 syslog 中所有包含 error 的行:`grep -i "error" /var/log/syslog`,查找所有与 java 相关的进程:`ps -ef | grep "java"`。
- `kill -9 进程的pid`:杀死进程(-9 表示强制终止)先用 ps 查找进程,然后用 kill 杀掉。
- `shutdown`:`shutdown -h now`:指定现在立即关机;`shutdown +5 "System will shutdown after 5 minutes"`:指定 5 分钟后关机,同时送出警告信息给登入用户。
- `reboot`:`reboot`:重开机。`reboot -w`:做个重开机的模拟(只有纪录并不会真的重开机)。
diff --git a/docs/cs-basics/operating-system/operating-system-basic-questions-01.md b/docs/cs-basics/operating-system/operating-system-basic-questions-01.md
index dc19c0f2074..61810c94a7b 100644
--- a/docs/cs-basics/operating-system/operating-system-basic-questions-01.md
+++ b/docs/cs-basics/operating-system/operating-system-basic-questions-01.md
@@ -1,15 +1,13 @@
---
title: 操作系统常见面试题总结(上)
+description: 最新操作系统高频面试题总结(上):用户态/内核态切换、进程线程区别、死锁四条件、系统调用详解、调度算法对比,附图表+⭐️重点标注,一文掌握OS核心考点,快速通关后端技术面试!
category: 计算机基础
tag:
- 操作系统
head:
- - meta
- name: keywords
- content: 操作系统,进程,进程通信方式,死锁,操作系统内存管理,块表,多级页表,虚拟内存,页面置换算法
- - - meta
- - name: description
- content: 很多读者抱怨计算操作系统的知识点比较繁杂,自己也没有多少耐心去看,但是面试的时候又经常会遇到。所以,我带着我整理好的操作系统的常见问题来啦!这篇文章总结了一些我觉得比较重要的操作系统相关的问题比如进程管理、内存管理、虚拟内存等等。
+ content: 操作系统面试题,用户态 vs 内核态,进程 vs 线程,死锁必要条件,系统调用过程,进程调度算法,PCB进程控制块,进程间通信IPC,死锁预防避免,操作系统基础高频题,虚拟内存管理
---
@@ -98,15 +96,17 @@ _玩玩电脑游戏还是必须要有 Windows 的,所以我现在是一台 Win
根据进程访问资源的特点,我们可以把进程在系统上的运行分为两个级别:
-- **用户态(User Mode)** : 用户态运行的进程可以直接读取用户程序的数据,拥有较低的权限。当应用程序需要执行某些需要特殊权限的操作,例如读写磁盘、网络通信等,就需要向操作系统发起系统调用请求,进入内核态。
-- **内核态(Kernel Mode)**:内核态运行的进程几乎可以访问计算机的任何资源包括系统的内存空间、设备、驱动程序等,不受限制,拥有非常高的权限。当操作系统接收到进程的系统调用请求时,就会从用户态切换到内核态,执行相应的系统调用,并将结果返回给进程,最后再从内核态切换回用户态。
-

+- **用户态(User Mode)** : 用户态运行的进程可以直接读取用户程序的数据,拥有较低的权限。当应用程序需要执行某些需要特殊权限的操作,例如读写磁盘、网络通信等,就需要向操作系统发起系统调用请求,进入内核态。
+- **内核态(Kernel Mode)** :内核态运行的进程几乎可以访问计算机的任何资源包括系统的内存空间、设备、驱动程序等,不受限制,拥有非常高的权限。当操作系统接收到进程的系统调用请求时,就会从用户态切换到内核态,执行相应的系统调用,并将结果返回给进程,最后再从内核态切换回用户态。
+
内核态相比用户态拥有更高的特权级别,因此能够执行更底层、更敏感的操作。不过,由于进入内核态需要付出较高的开销(需要进行一系列的上下文切换和权限检查),应该尽量减少进入内核态的次数,以提高系统的性能和稳定性。
#### 为什么要有用户态和内核态?只有一个内核态不行么?
+这样设计主要是为了**安全**和**稳定**。
+
- 在 CPU 的所有指令中,有一些指令是比较危险的比如内存分配、设置时钟、IO 处理等,如果所有的程序都能使用这些指令的话,会对系统的正常运行造成灾难性地影响。因此,我们需要限制这些危险指令只能内核态运行。这些只能由操作系统内核态执行的指令也被叫做 **特权指令** 。
- 如果计算机系统中只有一个内核态,那么所有程序或进程都必须共享系统资源,例如内存、CPU、硬盘等,这将导致系统资源的竞争和冲突,从而影响系统性能和效率。并且,这样也会让系统的安全性降低,毕竟所有程序或进程都具有相同的特权级别和访问权限。
@@ -118,12 +118,14 @@ _玩玩电脑游戏还是必须要有 Windows 的,所以我现在是一台 Win
用户态切换到内核态的 3 种方式:
-1. **系统调用(Trap)**:用户态进程 **主动** 要求切换到内核态的一种方式,主要是为了使用内核态才能做的事情比如读取磁盘资源。系统调用的机制其核心还是使用了操作系统为用户特别开放的一个中断来实现。
-2. **中断(Interrupt)**:当外围设备完成用户请求的操作后,会向 CPU 发出相应的中断信号,这时 CPU 会暂停执行下一条即将要执行的指令转而去执行与中断信号对应的处理程序,如果先前执行的指令是用户态下的程序,那么这个转换的过程自然也就发生了由用户态到内核态的切换。比如硬盘读写操作完成,系统会切换到硬盘读写的中断处理程序中执行后续操作等。
-3. **异常(Exception)**:当 CPU 在执行运行在用户态下的程序时,发生了某些事先不可知的异常,这时会触发由当前运行进程切换到处理此异常的内核相关程序中,也就转到了内核态,比如缺页异常。
+1. **系统调用(Trap)**:这是最主要的方式,是应用程序**主动**发起的。比如,当我们的程序需要读取一个文件或者发送网络数据时,它无法直接操作磁盘或网卡,就必须调用操作系统提供的接口(如 `read()`,`send()`), 这会触发一次从用户态到内核态的切换。
+2. **中断(Interrupt)**:这是**被动**的,由外部硬件设备触发。比如,当硬盘完成了数据读取,会向 CPU 发送一个中断信号,CPU 会暂停当前用户态的程序,切换到内核态去处理这个中断。
+3. **异常(Exception)**:这也是**被动**的,由程序自身错误引起。比如,我们的代码执行了一个除以零的操作,或者访问了一个非法的内存地址(缺页异常),CPU 会捕获这个异常,并切换到内核态去处理它。
在系统的处理上,中断和异常类似,都是通过中断向量表来找到相应的处理程序进行处理。区别在于,中断来自处理器外部,不是由任何一条专门的指令造成,而异常是执行当前指令的结果。
+最后,需要强调的是,这种**状态切换是有性能开销的**。因为它涉及到保存用户态的上下文(寄存器等)、切换到内核态执行、再恢复用户态的上下文。因此,在高性能编程中,我们常常需要考虑如何减少这种切换次数,比如通过缓冲 I/O 来批量读写文件,就是一个典型的例子。
+
### 系统调用
#### 什么是系统调用?
@@ -151,18 +153,23 @@ _玩玩电脑游戏还是必须要有 Windows 的,所以我现在是一台 Win
1. 用户态的程序发起系统调用,因为系统调用中涉及一些特权指令(只能由操作系统内核态执行的指令),用户态程序权限不足,因此会中断执行,也就是 Trap(Trap 是一种中断)。
2. 发生中断后,当前 CPU 执行的程序会中断,跳转到中断处理程序。内核程序开始执行,也就是开始处理系统调用。
-3. 内核处理完成后,主动触发 Trap,这样会再次发生中断,切换回用户态工作。
+3. 当系统调用处理完成后,操作系统使用特权指令(如 `iret`、`sysret` 或 `eret`)切换回用户态,恢复用户态的上下文,继续执行用户程序。

## 进程和线程
-### 什么是进程和线程?
+### 进程和线程的区别是什么?
-- **进程(Process)** 是指计算机中正在运行的一个程序实例。举例:你打开的微信就是一个进程。
-- **线程(Thread)** 也被称为轻量级进程,更加轻量。多个线程可以在同一个进程中同时执行,并且共享进程的资源比如内存空间、文件句柄、网络连接等。举例:你打开的微信里就有一个线程专门用来拉取别人发你的最新的消息。
+进程和线程是操作系统中并发执行的两个核心概念,它们的关系可以理解为 **工厂和工人** 的关系。
-### 进程和线程的区别是什么?
+**进程(Process)就像一个工厂**。操作系统在分配资源时,是以进程为基本单位的。比如,当我启动一个微信,操作系统就为它建立了一个独立的工厂,分配给它专属的内存空间、文件句柄等资源。这个工厂与其他工厂(比如我打开的浏览器进程)是严格隔离的。
+
+**线程(Thread)则像是工厂里的工人**。一个工厂里可以有很多工人,他们共享这个工厂的资源,但每个工人有自己的工具箱和任务清单,让他们可以独立地执行不同的任务。比如微信这个工厂里,可以有一个工人(线程)负责接收消息,一个工人负责渲染界面。
+
+这是我用 AI 绘制的一张图片,可以说是非常形象了:
+
+
下图是 Java 内存区域,我们从 JVM 的角度来说一下线程和进程之间的关系吧!
@@ -170,18 +177,17 @@ _玩玩电脑游戏还是必须要有 Windows 的,所以我现在是一台 Win
从上图可以看出:一个进程中可以有多个线程,多个线程共享进程的**堆**和**方法区 (JDK1.8 之后的元空间)**资源,但是每个线程有自己的**程序计数器**、**虚拟机栈** 和 **本地方法栈**。
-**总结:**
+这里从 3 个角度总结下线程和进程的核心区别:
-- 线程是进程划分成的更小的运行单位,一个进程在其执行的过程中可以产生多个线程。
-- 线程和进程最大的不同在于基本上各进程是独立的,而各线程则不一定,因为同一进程中的线程极有可能会相互影响。
-- 线程执行开销小,但不利于资源的管理和保护;而进程正相反。
+1. **资源所有权:** 进程是资源分配的基本单位,拥有独立的地址空间;而线程是 CPU 调度的基本单位,几乎不拥有系统资源,只保留少量私有数据(PC、栈、寄存器),主要共享其所属进程的资源。
+2. **开销:** 创建或销毁一个工厂(进程)的开销很大,需要分配独立的资源。而雇佣或解雇一个工人(线程)的开销就小得多。同理,进程间的上下文切换开销远大于线程间的切换。
+3. **健壮性:** 工厂之间是隔离的,一个工厂倒闭(进程崩溃)不会影响其他工厂。但一个工厂内的工人之间是共享资源的,一个工人操作失误(比如一个线程访问了非法内存)可能会导致整个工厂停工(整个进程崩溃)。
### 有了进程为什么还需要线程?
-- 进程切换是一个开销很大的操作,线程切换的成本较低。
-- 线程更轻量,一个进程可以创建多个线程。
-- 多个线程可以并发处理不同的任务,更有效地利用了多处理器和多核计算机。而进程只能在一个时间干一件事,如果在执行过程中遇到阻塞问题比如 IO 阻塞就会挂起直到结果返回。
-- 同一进程内的线程共享内存和文件,因此它们之间相互通信无须调用内核。
+核心原因就是**为了在单个应用内实现低开销、高效率的并发**。如果我想让微信同时接收消息和发送文件,如果用两个进程来实现,不仅资源开销巨大,它们之间通信还非常麻烦(需要 IPC)。而使用两个线程,它们不仅切换成本低,还能直接通过共享内存高效通信,从而能更好地利用多核 CPU,提升应用的响应速度和吞吐量。
+
+再那我们上面举的工厂和工人为例:线程=同一屋檐下的轻量级工人,切换成本低、共享内存零拷贝;若换成两个独立进程,就得各建一座工厂(独立地址空间),既费砖又费电(资源与 IPC 开销)。
### 为什么要使用多线程?
@@ -201,10 +207,10 @@ _玩玩电脑游戏还是必须要有 Windows 的,所以我现在是一台 Win
下面是几种常见的线程同步的方式:
-1. **互斥锁(Mutex)**:采用互斥对象机制,只有拥有互斥对象的线程才有访问公共资源的权限。因为互斥对象只有一个,所以可以保证公共资源不会被多个线程同时访问。比如 Java 中的 `synchronized` 关键词和各种 `Lock` 都是这种机制。
-2. **读写锁(Read-Write Lock)**:允许多个线程同时读取共享资源,但只有一个线程可以对共享资源进行写操作。
-3. **信号量(Semaphore)**:它允许同一时刻多个线程访问同一资源,但是需要控制同一时刻访问此资源的最大线程数量。
-4. **屏障(Barrier)**:屏障是一种同步原语,用于等待多个线程到达某个点再一起继续执行。当一个线程到达屏障时,它会停止执行并等待其他线程到达屏障,直到所有线程都到达屏障后,它们才会一起继续执行。比如 Java 中的 `CyclicBarrier` 是这种机制。
+1. **互斥锁(Mutex)** :采用互斥对象机制,只有拥有互斥对象的线程才有访问公共资源的权限。因为互斥对象只有一个,所以可以保证公共资源不会被多个线程同时访问。比如 Java 中的 `synchronized` 关键词和各种 `Lock` 都是这种机制。
+2. **读写锁(Read-Write Lock)** :允许多个线程同时读取共享资源,但只有一个线程可以对共享资源进行写操作。
+3. **信号量(Semaphore)** :它允许同一时刻多个线程访问同一资源,但是需要控制同一时刻访问此资源的最大线程数量。
+4. **屏障(Barrier)** :屏障是一种同步原语,用于等待多个线程到达某个点再一起继续执行。当一个线程到达屏障时,它会停止执行并等待其他线程到达屏障,直到所有线程都到达屏障后,它们才会一起继续执行。比如 Java 中的 `CyclicBarrier` 是这种机制。
5. **事件(Event)** :Wait/Notify:通过通知操作的方式来保持多线程同步,还可以方便的实现多线程优先级的比较操作。
### PCB 是什么?包含哪些信息?
@@ -238,48 +244,49 @@ PCB 主要包含下面几部分的内容:
> 下面这部分总结参考了:[《进程间通信 IPC (InterProcess Communication)》](https://www.jianshu.com/p/c1015f5ffa74) 这篇文章,推荐阅读,总结的非常不错。
-1. **管道/匿名管道(Pipes)**:用于具有亲缘关系的父子进程间或者兄弟进程之间的通信。
+1. **管道/匿名管道(Pipes)** :用于具有亲缘关系的父子进程间或者兄弟进程之间的通信。
2. **有名管道(Named Pipes)** : 匿名管道由于没有名字,只能用于亲缘关系的进程间通信。为了克服这个缺点,提出了有名管道。有名管道严格遵循 **先进先出(First In First Out)** 。有名管道以磁盘文件的方式存在,可以实现本机任意两个进程通信。
-3. **信号(Signal)**:信号是一种比较复杂的通信方式,用于通知接收进程某个事件已经发生;
-4. **消息队列(Message Queuing)**:消息队列是消息的链表,具有特定的格式,存放在内存中并由消息队列标识符标识。管道和消息队列的通信数据都是先进先出的原则。与管道(无名管道:只存在于内存中的文件;命名管道:存在于实际的磁盘介质或者文件系统)不同的是消息队列存放在内核中,只有在内核重启(即,操作系统重启)或者显式地删除一个消息队列时,该消息队列才会被真正的删除。消息队列可以实现消息的随机查询,消息不一定要以先进先出的次序读取,也可以按消息的类型读取.比 FIFO 更有优势。**消息队列克服了信号承载信息量少,管道只能承载无格式字 节流以及缓冲区大小受限等缺点。**
-5. **信号量(Semaphores)**:信号量是一个计数器,用于多进程对共享数据的访问,信号量的意图在于进程间同步。这种通信方式主要用于解决与同步相关的问题并避免竞争条件。
-6. **共享内存(Shared memory)**:使得多个进程可以访问同一块内存空间,不同进程可以及时看到对方进程中对共享内存中数据的更新。这种方式需要依靠某种同步操作,如互斥锁和信号量等。可以说这是最有用的进程间通信方式。
+3. **信号(Signal)** :信号是一种比较复杂的通信方式,用于通知接收进程某个事件已经发生;
+4. **消息队列(Message Queuing)** :消息队列是消息的链表,具有特定的格式,存放在内存中并由消息队列标识符标识。管道和消息队列的通信数据都是先进先出的原则。与管道(无名管道:只存在于内存中的文件;命名管道:存在于实际的磁盘介质或者文件系统)不同的是消息队列存放在内核中,只有在内核重启(即,操作系统重启)或者显式地删除一个消息队列时,该消息队列才会被真正的删除。消息队列可以实现消息的随机查询,消息不一定要以先进先出的次序读取,也可以按消息的类型读取.比 FIFO 更有优势。消息队列克服了信号承载信息量少,管道只能承载无格式字 节流以及缓冲区大小受限等缺点。
+5. **信号量(Semaphores)** :信号量是一个计数器,用于多进程对共享数据的访问,信号量的意图在于进程间同步。这种通信方式主要用于解决与同步相关的问题并避免竞争条件。
+6. **共享内存(Shared memory)** :使得多个进程可以访问同一块内存空间,不同进程可以及时看到对方进程中对共享内存中数据的更新。这种方式需要依靠某种同步操作,如互斥锁和信号量等。可以说这是最有用的进程间通信方式。
7. **套接字(Sockets)** : 此方法主要用于在客户端和服务器之间通过网络进行通信。套接字是支持 TCP/IP 的网络通信的基本操作单元,可以看做是不同主机之间的进程进行双向通信的端点,简单的说就是通信的两方的一种约定,用套接字中的相关函数来完成通信过程。
### 进程的调度算法有哪些?

-这是一个很重要的知识点!为了确定首先执行哪个进程以及最后执行哪个进程以实现最大 CPU 利用率,计算机科学家已经定义了一些算法,它们是:
+进程调度算法的核心目标是决定就绪队列中的哪个进程应该获得 CPU 资源,其设计目标通常是在**吞吐量、周转时间、响应时间**和**公平性**之间做权衡。
-- **先到先服务调度算法(FCFS,First Come, First Served)** : 从就绪队列中选择一个最先进入该队列的进程为之分配资源,使它立即执行并一直执行到完成或发生某事件而被阻塞放弃占用 CPU 时再重新调度。
-- **短作业优先的调度算法(SJF,Shortest Job First)** : 从就绪队列中选出一个估计运行时间最短的进程为之分配资源,使它立即执行并一直执行到完成或发生某事件而被阻塞放弃占用 CPU 时再重新调度。
-- **时间片轮转调度算法(RR,Round-Robin)** : 时间片轮转调度是一种最古老,最简单,最公平且使用最广的算法。每个进程被分配一个时间段,称作它的时间片,即该进程允许运行的时间。
-- **多级反馈队列调度算法(MFQ,Multi-level Feedback Queue)**:前面介绍的几种进程调度的算法都有一定的局限性。如**短进程优先的调度算法,仅照顾了短进程而忽略了长进程** 。多级反馈队列调度算法既能使高优先级的作业得到响应又能使短作业(进程)迅速完成。,因而它是目前**被公认的一种较好的进程调度算法**,UNIX 操作系统采取的便是这种调度算法。
-- **优先级调度算法(Priority)**:为每个流程分配优先级,首先执行具有最高优先级的进程,依此类推。具有相同优先级的进程以 FCFS 方式执行。可以根据内存要求,时间要求或任何其他资源要求来确定优先级。
+我习惯将这些算法分为两大类:**非抢占式**和**抢占式**。
-### 什么是僵尸进程和孤儿进程?
+**第一类:非抢占式调度 (Non-Preemptive)**
-在 Unix/Linux 系统中,子进程通常是通过 fork()系统调用创建的,该调用会创建一个新的进程,该进程是原有进程的一个副本。子进程和父进程的运行是相互独立的,它们各自拥有自己的 PCB,即使父进程结束了,子进程仍然可以继续运行。
+这种方式下,一旦 CPU 分配给一个进程,它就会一直运行下去,直到任务完成或主动放弃(比如等待 I/O)。
-当一个进程调用 exit()系统调用结束自己的生命时,内核会释放该进程的所有资源,包括打开的文件、占用的内存等,但是该进程对应的 PCB 依然存在于系统中。这些信息只有在父进程调用 wait()或 waitpid()系统调用时才会被释放,以便让父进程得到子进程的状态信息。
+1. **先到先服务调度算法(FCFS,First Come, First Served)** : 这是最简单的,就像排队,谁先来谁先用。优点是公平、实现简单。但缺点很明显,如果一个很长的任务先到了,后面无数个短任务都得等着,这会导致平均等待时间很长,我们称之为“护航效应”。
+2. **短作业优先的调度算法(SJF,Shortest Job First)** : 从就绪队列中选出一个估计运行时间最短的进程为之分配资源。理论上,它的平均等待时间是最短的,吞吐量很高。但缺点是,它需要预测运行时间,这很难做到,而且可能会导致长作业“饿死”,永远得不到执行。
-这样的设计可以让父进程在子进程结束时得到子进程的状态信息,并且可以防止出现“僵尸进程”(即子进程结束后 PCB 仍然存在但父进程无法得到状态信息的情况)。
+**第二类:抢占式调度 (Preemptive)**
-- **僵尸进程**:子进程已经终止,但是其父进程仍在运行,且父进程没有调用 wait()或 waitpid()等系统调用来获取子进程的状态信息,释放子进程占用的资源,导致子进程的 PCB 依然存在于系统中,但无法被进一步使用。这种情况下,子进程被称为“僵尸进程”。避免僵尸进程的产生,父进程需要及时调用 wait()或 waitpid()系统调用来回收子进程。
-- **孤儿进程**:一个进程的父进程已经终止或者不存在,但是该进程仍在运行。这种情况下,该进程就是孤儿进程。孤儿进程通常是由于父进程意外终止或未及时调用 wait()或 waitpid()等系统调用来回收子进程导致的。为了避免孤儿进程占用系统资源,操作系统会将孤儿进程的父进程设置为 init 进程(进程号为 1),由 init 进程来回收孤儿进程的资源。
+操作系统可以强制剥夺当前进程的 CPU 使用权,分配给其他更重要的进程。现代操作系统基本都采用这种方式。
-### 如何查看是否有僵尸进程?
+- **时间片轮转调度算法(RR,Round-Robin)** : 这是最经典、最公平的抢占式算法。它给每个进程分配一个固定的时间片,用完了就把它放到队尾,切换到下一个进程。它非常适合分时系统,保证了每个进程都能得到响应,但时间片的设置很关键:太长了退化成 FCFS,太短了则会导致过于频繁的上下文切换,增加系统开销。
+- **优先级调度算法(Priority)**:每个进程都有一个优先级,进程调度器总是选择优先级最高的进程,具有相同优先级的进程以 FCFS 方式执行。这很灵活,可以根据内存要求,时间要求或任何其他资源要求来确定优先级,但同样可能导致低优先级进程“饿死”。
-Linux 下可以使用 Top 命令查找,`zombie` 值表示僵尸进程的数量,为 0 则代表没有僵尸进程。
+前面介绍的几种进程调度的算法都有一定的局限性,如:**短进程优先的调度算法,仅照顾了短进程而忽略了长进程** 。那有没有一种结合了上面这些进程调度算法优点的呢?
-
+**多级反馈队列调度算法(MFQ,Multi-level Feedback Queue)** 是现实世界中最常用的一种算法,比如早期的 UNIX。它非常聪明,结合了 RR 和优先级调度。它设置了多个不同优先级的队列,每个队列使用 RR 调度,时间片大小也不同。新进程先进入最高优先级队列;如果在一个时间片内没执行完,就会被降级到下一个队列。这样既照顾了短作业(在高优先级队列中快速完成),也保证了长作业不会饿死(最终会在低优先级队列中得到执行),是一种非常均衡的方案。
-下面这个命令可以定位僵尸进程以及该僵尸进程的父进程:
+### 那究竟是谁来调度这个进程呢?
-```bash
-ps -A -ostat,ppid,pid,cmd |grep -e '^[Zz]'
-```
+负责进程调度的核心是操作系统内核中的两个紧密协作的组件:**调度程序(Scheduler)** 和 **分派程序(Dispatcher)**。我们可以把它们理解成一个团队:
+
+- **调度程序 (Scheduler):** 可以看作是决策者。当需要进行调度时,调度程序会被激活,它会根据预设的调度算法(比如我们前面聊到的多级反馈队列),从就绪队列中挑选出下一个应该占用 CPU 的进程。
+- **分派程序 (Dispatcher):** 可以看作是执行者。它负责完成具体的“交接”工作,也就是**上下文切换**。这个过程非常底层,主要包括:
+ - 保存当前进程的上下文(CPU 寄存器状态、程序计数器等)到其进程控制块(PCB)中。
+ - 加载下一个被选中进程的上下文,从其 PCB 中读取状态,恢复到 CPU 寄存器。
+ - 将 CPU 的控制权正式移交给新进程,让它开始运行。
## 死锁
@@ -287,23 +294,23 @@ ps -A -ostat,ppid,pid,cmd |grep -e '^[Zz]'
死锁(Deadlock)描述的是这样一种情况:多个进程/线程同时被阻塞,它们中的一个或者全部都在等待某个资源被释放。由于进程/线程被无限期地阻塞,因此程序不可能正常终止。
-### 能列举一个操作系统发生死锁的例子吗?
+一个最经典的例子就是**“交叉持锁”**。想象有两个线程和两个锁:
-假设有两个进程 A 和 B,以及两个资源 X 和 Y,它们的分配情况如下:
+- 线程 1 先拿到了锁 A,然后尝试去获取锁 B。
+- 几乎同时,线程 2 拿到了锁 B,然后尝试去获取锁 A。
-| 进程 | 占用资源 | 需求资源 |
-| ---- | -------- | -------- |
-| A | X | Y |
-| B | Y | X |
+这时,线程 1 等着线程 2 释放锁 B,而线程 2 等着线程 1 释放锁 A,双方都持有对方需要的资源,并等待对方释放,就形成了一个“死结”。
-此时,进程 A 占用资源 X 并且请求资源 Y,而进程 B 已经占用了资源 Y 并请求资源 X。两个进程都在等待对方释放资源,无法继续执行,陷入了死锁状态。
+
### 产生死锁的四个必要条件是什么?
+死锁的发生并不是偶然的,它需要同时满足**四个必要条件**:
+
1. **互斥**:资源必须处于非共享模式,即一次只有一个进程可以使用。如果另一进程申请该资源,那么必须等待直到该资源被释放为止。
2. **占有并等待**:一个进程至少应该占有一个资源,并等待另一资源,而该资源被其他进程所占有。
3. **非抢占**:资源不能被抢占。只能在持有资源的进程完成任务后,该资源才会被释放。
-4. **循环等待**:有一组等待进程 `{P0, P1,..., Pn}`, `P0` 等待的资源被 `P1` 占有,`P1` 等待的资源被 `P2` 占有,……,`Pn-1` 等待的资源被 `Pn` 占有,`Pn` 等待的资源被 `P0` 占有。
+4. **循环等待**:有一组等待进程 {P0, P1,..., Pn}, P0 等待的资源被 P1 占有,P1 等待的资源被 P2 占有,……,Pn-1 等待的资源被 Pn 占有,Pn 等待的资源被 P0 占有。
**注意 ⚠️**:这四个条件是产生死锁的 **必要条件** ,也就是说只要系统发生死锁,这些条件必然成立,而只要上述条件之一不满足,就不会发生死锁。
@@ -371,12 +378,9 @@ Thread[线程 2,5,main]waiting get resource1
解决死锁的方法可以从多个角度去分析,一般的情况下,有**预防,避免,检测和解除四种**。
-- **预防** 是采用某种策略,**限制并发进程对资源的请求**,从而使得死锁的必要条件在系统执行的任何时间上都不满足。
-
-- **避免**则是系统在分配资源时,根据资源的使用情况**提前做出预测**,从而**避免死锁的发生**
-
-- **检测**是指系统设有**专门的机构**,当死锁发生时,该机构能够检测死锁的发生,并精确地确定与死锁有关的进程和资源。
-- **解除** 是与检测相配套的一种措施,用于**将进程从死锁状态下解脱出来**。
+- **死锁预防:** 这是我们程序员最常用的方法。通过编码规范来破坏条件。最经典的就是**破坏循环等待**,比如规定所有线程都必须**按相同的顺序**来获取锁(比如先 A 后 B),这样就不会形成环路。
+- **死锁避免:** 这是一种更动态的方法,比如操作系统的**银行家算法**。它会在分配资源前进行预测,如果这次分配可能导致未来发生死锁,就拒绝分配。但这种方法开销很大,在通用系统中用得比较少。
+- **死锁检测与解除:** 这是一种“事后补救”的策略,就像乐观锁。系统允许死锁发生,但会有一个后台线程(或机制)定期检测是否存在死锁环路(比如通过分析线程等待图)。一旦发现,就会采取措施解除,比如**强制剥夺某个线程的资源或直接终止它**。数据库系统中的死锁处理就常常采用这种方式。
#### 死锁的预防
@@ -402,7 +406,7 @@ Thread[线程 2,5,main]waiting get resource1
上面提到的 **破坏** 死锁产生的四个必要条件之一就可以成功 **预防系统发生死锁** ,但是会导致 **低效的进程运行** 和 **资源使用率** 。而死锁的避免相反,它的角度是允许系统中**同时存在四个必要条件** ,只要掌握并发进程中与每个进程有关的资源动态申请情况,做出 **明智和合理的选择** ,仍然可以避免死锁,因为四大条件仅仅是产生死锁的必要条件。
-我们将系统的状态分为 **安全状态** 和 **不安全状态** ,每当在未申请者分配资源前先测试系统状态,若把系统资源分配给申请者会产生死锁,则拒绝分配,否则接受申请,并为它分配资源。
+我们将系统的状态分为 **安全状态** 和 **不安全状态** ,每当在为申请者分配资源前先测试系统状态,若把系统资源分配给申请者会产生死锁,则拒绝分配,否则接受申请,并为它分配资源。
> 如果操作系统能够保证所有的进程在有限的时间内得到需要的全部资源,则称系统处于安全状态,否则说系统是不安全的。很显然,系统处于安全状态则不会发生死锁,系统若处于不安全状态则可能发生死锁。
@@ -424,7 +428,7 @@ Thread[线程 2,5,main]waiting get resource1
操作系统中的每一刻时刻的**系统状态**都可以用**进程-资源分配图**来表示,进程-资源分配图是描述进程和资源申请及分配关系的一种有向图,可用于**检测系统是否处于死锁状态**。
-用一个方框表示每一个资源类,方框中的黑点表示该资源类中的各个资源,每个键进程用一个圆圈表示,用 **有向边** 来表示**进程申请资源和资源被分配的情况**。
+用一个方框表示每一个资源类,方框中的黑点表示该资源类中的各个资源,用一个圆圈表示每一个进程,用 **有向边** 来表示**进程申请资源和资源被分配的情况**。
图中 2-21 是**进程-资源分配图**的一个例子,其中共有三个资源类,每个进程的资源占有和申请情况已清楚地表示在图中。在这个例子中,由于存在 **占有和等待资源的环路** ,导致一组进程永远处于等待资源的状态,发生了 **死锁**。
diff --git a/docs/cs-basics/operating-system/operating-system-basic-questions-02.md b/docs/cs-basics/operating-system/operating-system-basic-questions-02.md
index f35fb948701..51ed5fd65c3 100644
--- a/docs/cs-basics/operating-system/operating-system-basic-questions-02.md
+++ b/docs/cs-basics/operating-system/operating-system-basic-questions-02.md
@@ -1,17 +1,17 @@
---
title: 操作系统常见面试题总结(下)
+description: 最新操作系统高频面试题总结(下):虚拟内存映射、内存碎片/伙伴系统、TLB+页缺失处理、分页分段对比、页面置换算法详解、文件系统&磁盘调度,附图表+⭐️重点标注,一文掌握OS内存/文件考点,快速通关后端面试!
category: 计算机基础
tag:
- 操作系统
head:
- - meta
- name: keywords
- content: 操作系统,进程,进程通信方式,死锁,操作系统内存管理,块表,多级页表,虚拟内存,页面置换算法
- - - meta
- - name: description
- content: 很多读者抱怨计算操作系统的知识点比较繁杂,自己也没有多少耐心去看,但是面试的时候又经常会遇到。所以,我带着我整理好的操作系统的常见问题来啦!这篇文章总结了一些我觉得比较重要的操作系统相关的问题比如进程管理、内存管理、虚拟内存等等。
+ content: 操作系统面试题,虚拟内存详解,分页 vs 分段,页面置换算法,内存碎片,伙伴系统,TLB快表,页缺失,文件系统基础,磁盘调度算法,硬链接 vs 软链接
---
+
+
## 内存管理
### 内存管理主要做了什么?
@@ -68,7 +68,7 @@ head:
非连续内存管理存在下面 3 种方式:
-- **段式管理**:以段(—段连续的物理内存)的形式管理/分配物理内存。应用程序的虚拟地址空间被分为大小不等的段,段是有实际意义的,每个段定义了一组逻辑信息,例如有主程序段 MAIN、子程序段 X、数据段 D 及栈段 S 等。
+- **段式管理**:以段(一段连续的物理内存)的形式管理/分配物理内存。应用程序的虚拟地址空间被分为大小不等的段,段是有实际意义的,每个段定义了一组逻辑信息,例如有主程序段 MAIN、子程序段 X、数据段 D 及栈段 S 等。
- **页式管理**:把物理内存分为连续等长的物理页,应用程序的虚拟地址空间也被划分为连续等长的虚拟页,是现代操作系统广泛使用的一种内存管理方式。
- **段页式管理机制**:结合了段式管理和页式管理的一种内存管理机制,把物理内存先分成若干段,每个段又继续分成若干大小相等的页。
@@ -131,7 +131,7 @@ MMU 将虚拟地址翻译为物理地址的主要机制有 3 种:
### 分段机制
-**分段机制(Segmentation)** 以段(—段 **连续** 的物理内存)的形式管理/分配物理内存。应用程序的虚拟地址空间被分为大小不等的段,段是有实际意义的,每个段定义了一组逻辑信息,例如有主程序段 MAIN、子程序段 X、数据段 D 及栈段 S 等。
+**分段机制(Segmentation)** 以段(一段 **连续** 的物理内存)的形式管理/分配物理内存。应用程序的虚拟地址空间被分为大小不等的段,段是有实际意义的,每个段定义了一组逻辑信息,例如有主程序段 MAIN、子程序段 X、数据段 D 及栈段 S 等。
#### 段表有什么用?地址翻译过程是怎样的?
@@ -188,7 +188,7 @@ MMU 将虚拟地址翻译为物理地址的主要机制有 3 种:

-在分页机制下,每个应用程序都会有一个对应的页表。
+在分页机制下,每个进程都会有一个对应的页表。
分页机制下的虚拟地址由两部分组成:
@@ -211,11 +211,11 @@ MMU 将虚拟地址翻译为物理地址的主要机制有 3 种:
#### 单级页表有什么问题?为什么需要多级页表?
-以 32 位的环境为例,虚拟地址空间范围共有 2^32(4G)。假设 一个页的大小是 2^12(4KB),那页表项共有 4G / 4K = 2^20 个。每个页表项为一个地址,占用 4 字节,`2^20 * 2^2 / 1024 * 1024= 4MB`。也就是说一个程序啥都不干,页表大小就得占用 4M。
+以 32 位的环境为例,虚拟地址空间范围共有 2^32(4G)。假设 一个页的大小是 2^12(4KB),那页表项共有 4G / 4K = 2^20 个。每个页表项为一个地址,占用 4 字节,`(2^20 * 2^2) / (1024 * 1024)= 4MB`。也就是说一个程序啥都不干,页表大小就得占用 4M。
系统运行的应用程序多起来的话,页表的开销还是非常大的。而且,绝大部分应用程序可能只能用到页表中的几项,其他的白白浪费了。
-为了解决这个问题,操作系统引入了 **多级页表** ,多级页表对应多个页表,每个页表也前一个页表相关联。32 位系统一般为二级页表,64 位系统一般为四级页表。
+为了解决这个问题,操作系统引入了 **多级页表** ,多级页表对应多个页表,每个页表与前一个页表相关联。32 位系统一般为二级页表,64 位系统一般为四级页表。
这里以二级页表为例进行介绍:二级列表分为一级页表和二级页表。一级页表共有 1024 个页表项,一级页表又关联二级页表,二级页表同样共有 1024 个页表项。二级页表中的一级页表项是一对多的关系,二级页表按需加载(只会用到很少一部分二级页表),进而节省空间占用。
@@ -282,7 +282,7 @@ TLB 的设计思想非常简单,但命中率往往非常高,效果很好。

1. **最佳页面置换算法(OPT,Optimal)**:优先选择淘汰的页面是以后永不使用的,或者是在最长时间内不再被访问的页面,这样可以保证获得最低的缺页率。但由于人们目前无法预知进程在内存下的若干页面中哪个是未来最长时间内不再被访问的,因而该算法无法实现,只是理论最优的页面置换算法,可以作为衡量其他置换算法优劣的标准。
-2. **先进先出页面置换算法(FIFO,First In First Out)** : 最简单的一种页面置换算法,总是淘汰最先进入内存的页面,即选择在内存中驻留时间最久的页面进行淘汰。该算法易于实现和理解,一般只需要通过一个 FIFO 队列即可需求。不过,它的性能并不是很好。
+2. **先进先出页面置换算法(FIFO,First In First Out)** : 最简单的一种页面置换算法,总是淘汰最先进入内存的页面,即选择在内存中驻留时间最久的页面进行淘汰。该算法易于实现和理解,一般只需要通过一个 FIFO 队列即可满足需求。不过,它的性能并不是很好。
3. **最近最久未使用页面置换算法(LRU ,Least Recently Used)**:LRU 算法赋予每个页面一个访问字段,用来记录一个页面自上次被访问以来所经历的时间 T,当须淘汰一个页面时,选择现有页面中其 T 值最大的,即最近最久未使用的页面予以淘汰。LRU 算法是根据各页之前的访问情况来实现,因此是易于实现的。OPT 算法是根据各页未来的访问情况来实现,因此是不可实现的。
4. **最少使用页面置换算法(LFU,Least Frequently Used)** : 和 LRU 算法比较像,不过该置换算法选择的是之前一段时间内使用最少的页面作为淘汰页。
5. **时钟页面置换算法(Clock)**:可以认为是一种最近未使用算法,即逐出的页面都是最近没有使用的那个。
@@ -317,12 +317,16 @@ LRU 算法是实际使用中应用的比较多,也被认为是最接近 OPT
### 段页机制
-结合了段式管理和页式管理的一种内存管理机制,把物理内存先分成若干段,每个段又继续分成若干大小相等的页。
+结合了段式管理和页式管理的一种内存管理机制。程序视角中,内存被划分为多个逻辑段,每个逻辑段进一步被划分为固定大小的页。
在段页式机制下,地址翻译的过程分为两个步骤:
-1. 段式地址映射。
-2. 页式地址映射。
+1. **段式地址映射(虚拟地址 → 线性地址):**
+ - 虚拟地址 = 段选择符(段号)+ 段内偏移。
+ - 根据段号查段表,找到段基址,加上段内偏移得到线性地址。
+2. **页式地址映射(线性地址 → 物理地址):**
+ - 线性地址 = 页号 + 页内偏移。
+ - 根据页号查页表,找到物理页框号,加上页内偏移得到物理地址。
### 局部性原理
@@ -375,7 +379,7 @@ LRU 算法是实际使用中应用的比较多,也被认为是最接近 OPT
### 提高文件系统性能的方式有哪些?
-- **优化硬件**:使用高速硬件设备(如 SSD、NVMe)替代传统的机械硬盘,使用 RAID(Redundant Array of Inexpensive Disks)等技术提高磁盘性能。
+- **优化硬件**:使用高速硬件设备(如 SSD、NVMe)替代传统的机械硬盘,使用 RAID(Redundant Array of Independent Disks)等技术提高磁盘性能。
- **选择合适的文件系统选型**:不同的文件系统具有不同的特性,对于不同的应用场景选择合适的文件系统可以提高系统性能。
- **运用缓存**:访问磁盘的效率比较低,可以运用缓存来减少磁盘的访问次数。不过,需要注意缓存命中率,缓存命中率过低的话,效果太差。
- **避免磁盘过度使用**:注意磁盘的使用率,避免将磁盘用满,尽量留一些剩余空间,以免对文件系统的性能产生负面影响。
diff --git a/docs/cs-basics/operating-system/shell-intro.md b/docs/cs-basics/operating-system/shell-intro.md
index 48066214c23..7554aa2760d 100644
--- a/docs/cs-basics/operating-system/shell-intro.md
+++ b/docs/cs-basics/operating-system/shell-intro.md
@@ -1,19 +1,36 @@
---
title: Shell 编程基础知识总结
+description: Shell 编程在我们的日常开发工作中非常实用,目前 Linux 系统下最流行的运维自动化语言就是 Shell 和 Python 了。这篇文章我会简单总结一下 Shell 编程基础知识,带你入门 Shell 编程!
category: 计算机基础
tag:
- 操作系统
- Linux
head:
- - meta
- - name: description
- content: Shell 编程在我们的日常开发工作中非常实用,目前 Linux 系统下最流行的运维自动化语言就是 Shell 和 Python 了。这篇文章我会简单总结一下 Shell 编程基础知识,带你入门 Shell 编程!
+ - name: keywords
+ content: Shell,脚本,命令,自动化,运维,Linux,基础语法
---
Shell 编程在我们的日常开发工作中非常实用,目前 Linux 系统下最流行的运维自动化语言就是 Shell 和 Python 了。
这篇文章我会简单总结一下 Shell 编程基础知识,带你入门 Shell 编程!
+## 版本说明
+
+**本文示例适用于 bash 4.0+ 版本**。不同版本的 bash 在某些特性上可能有差异,特别是:
+
+- **数组** :bash 2.0+ 支持,纯 POSIX sh(如 dash)不支持
+- **某些字符串操作** :如 `${var:offset:length}` 在较旧版本可能不支持
+- **算术扩展 `$((...))`** :bash 2.0+ 支持
+
+检查你的 bash 版本:
+
+```shell
+bash --version
+# 或
+echo $BASH_VERSION
+```
+
## 走进 Shell 编程的大门
### 为什么要学 Shell?
@@ -32,10 +49,17 @@ Shell 编程在我们的日常开发工作中非常实用,目前 Linux 系统
### 什么是 Shell?
-简单来说“Shell 编程就是对一堆 Linux 命令的逻辑化处理”。
+**Shell 是 Linux/Unix 系统的命令解释器**,它充当用户和操作系统内核之间的桥梁,负责接收用户输入的命令并调用相应的程序。
+
+**Shell 编程**是通过 Shell 解释器(如 bash)将命令、控制结构(if/for/while)、变量和函数组合成自动化脚本的过程。Shell 既是命令解释器,也是一门完整的编程语言(支持变量、数组、函数、流程控制、管道、重定向等)。
+
+**常见的 Shell 类型**:
-W3Cschool 上的一篇文章是这样介绍 Shell 的,如下图所示。
-
+- **bash**(Bourne Again Shell):Linux 系统默认 Shell,最常用
+- **sh**(Bourne Shell):Unix 传统 Shell,POSIX 标准
+- **zsh**:功能强大的交互式 Shell
+- **dash**:轻量级 Shell,Ubuntu 的 /bin/sh 默认指向它
+- **csh/tcsh**:C 风格的 Shell
### Shell 编程的 Hello World
@@ -51,8 +75,9 @@ helloworld.sh 内容如下:
```shell
#!/bin/bash
-#第一个shell小程序,echo 是linux中的输出命令。
-echo "helloworld!"
+set -euo pipefail # 严格模式:遇错退出、未定义变量报错、管道失败报错
+# 第一个 shell 小程序,echo 是 Linux 中的输出命令
+echo "helloworld!"
```
shell 中 # 符号表示注释。**shell 的第一行比较特殊,一般都会以#!开始来指定使用的 shell 类型。在 linux 中,除了 bash shell 以外,还有很多版本的 shell, 例如 zsh、dash 等等...不过 bash shell 还是我们使用最多的。**
@@ -67,20 +92,20 @@ shell 中 # 符号表示注释。**shell 的第一行比较特殊,一般都会
**Shell 编程中一般分为三种变量:**
-1. **我们自己定义的变量(自定义变量):** 仅在当前 Shell 实例中有效,其他 Shell 启动的程序不能访问局部变量。
-2. **Linux 已定义的环境变量**(环境变量, 例如:`PATH`, `HOME` 等..., 这类变量我们可以直接使用),使用 `env` 命令可以查看所有的环境变量,而 set 命令既可以查看环境变量也可以查看自定义变量。
-3. **Shell 变量**:Shell 变量是由 Shell 程序设置的特殊变量。Shell 变量中有一部分是环境变量,有一部分是局部变量,这些变量保证了 Shell 的正常运行
+1. **自定义变量(局部变量)**:默认仅在当前 Shell 进程内有效,**子进程无法访问**。若需传递给子进程,需使用 `export` 声明为环境变量。
+2. **环境变量**:例如 `PATH`, `HOME` 等,可被子进程继承。使用 `env` 命令可以查看所有环境变量,`set` 命令可以查看所有变量(包括环境变量和局部变量)。
+3. **Shell 特殊变量**:由 Shell 设置的特殊变量(如 `$?`, `$$`, `$!` 等),用于保存进程状态、参数等信息。
**常用的环境变量:**
-> PATH 决定了 shell 将到哪些目录中寻找命令或程序
-> HOME 当前用户主目录
-> HISTSIZE 历史记录数
-> LOGNAME 当前用户的登录名
-> HOSTNAME 指主机的名称
-> SHELL 当前用户 Shell 类型
-> LANGUAGE 语言相关的环境变量,多语言可以修改此环境变量
-> MAIL 当前用户的邮件存放目录
+> PATH 决定了 shell 将到哪些目录中寻找命令或程序
+> HOME 当前用户主目录
+> HISTSIZE 历史记录数
+> LOGNAME 当前用户的登录名
+> HOSTNAME 指主机的名称
+> SHELL 当前用户 Shell 类型
+> LANGUAGE 语言相关的环境变量,多语言可以修改此环境变量
+> MAIL 当前用户的邮件存放目录
> PS1 基本提示符,对于 root 用户是#,对于普通用户是\$
**使用 Linux 已定义的环境变量:**
@@ -110,7 +135,17 @@ echo "helloworld!"
字符串是 shell 编程中最常用最有用的数据类型(除了数字和字符串,也没啥其它类型好用了),字符串可以用单引号,也可以用双引号。这点和 Java 中有所不同。
-在单引号中所有的特殊符号,如$和反引号都没有特殊含义。在双引号中,除了"$"、"\\"、反引号和感叹号(需开启 `history expansion`),其他的字符没有特殊含义。
+在单引号中,所有特殊字符(如 `$`、反引号、`\` 等)都失去特殊含义,被视为字面量。
+
+在双引号中,以下字符保留特殊含义:
+
+- `$`:变量扩展(如 `$var`)和命令替换(如 `$(cmd)` 或 `` `cmd` ``)
+- `\`:转义字符
+- `` ` `` 或 `$()`:命令替换(推荐使用 `$()` 语法)
+- `!`:历史扩展(仅在交互式 Shell 中默认开启)
+- `${}`:参数扩展
+
+**注意**:单引号中的字符串是**完全字面量**,双引号中的字符串会进行变量和命令替换。
**单引号字符串:**
@@ -167,33 +202,42 @@ echo $greeting_2 $greeting_3
```shell
#!/bin/bash
-#获取字符串长度
+# 获取字符串长度
name="SnailClimb"
-# 第一种方式
-echo ${#name} #输出 10
-# 第二种方式
-expr length "$name";
+# 第一种方式(推荐):bash 内置
+echo ${#name} # 输出 10
+# 第二种方式:外部命令(性能较差)
+expr length "$name"
```
-输出结果:
+输出结果:
```plain
10
10
```
-使用 expr 命令时,表达式中的运算符左右必须包含空格,如果不包含空格,将会输出表达式本身:
+**说明**:
+
+- 推荐使用 `${#var}` 语法,这是 bash 内置功能,性能更好
+- `expr` 是外部命令,需要 fork 进程,性能较差
+- **`expr length` 是 GNU 扩展**,非 POSIX 标准。在 macOS 的 BSD expr 或其他系统上可能不支持
+- 如需可移植性,推荐使用 `${#var}` 或 `expr "$var" : '.*'`(POSIX 兼容)
+
+使用 expr 命令时,表达式中的运算符左右必须包含空格:
```shell
-expr 5+6 // 直接输出 5+6
-expr 5 + 6 // 输出 11
+expr 5+6 # 直接输出 5+6(无空格)
+expr 5 + 6 # 输出 11(有空格)
+# 更推荐使用 bash 算术扩展:
+echo $((5 + 6)) # 输出 11
```
-对于某些运算符,还需要我们使用符号`\`进行转义,否则就会提示语法错误。
+对于某些运算符,还需要我们使用符号 `\` 进行转义:
```shell
-expr 5 * 6 // 输出错误
-expr 5 \* 6 // 输出30
+expr 5 * 6 # 输出错误(未转义)
+expr 5 \* 6 # 输出 30(正确转义)
```
**截取子字符串:**
@@ -201,7 +245,7 @@ expr 5 \* 6 // 输出30
简单的字符串截取:
```shell
-#从字符串第 1 个字符开始往后截取 10 个字符
+#从字符串第 0 个字符开始往后截取 10 个字符(索引从 0 开始)
str="SnailClimb is a great man"
echo ${str:0:10} #输出:SnailClimb
```
@@ -209,8 +253,8 @@ echo ${str:0:10} #输出:SnailClimb
根据表达式截取:
```shell
-#!bin/bash
-#author:amau
+#!/bin/bash
+# author: amau
var="https://www.runoob.com/linux/linux-shell-variable.html"
# %表示删除从后匹配, 最短结果
@@ -227,7 +271,11 @@ s5=${var##*/} #linux-shell-variable.html
### Shell 数组
-bash 支持一维数组(不支持多维数组),并且没有限定数组的大小。我下面给了大家一个关于数组操作的 Shell 代码示例,通过该示例大家可以知道如何创建数组、获取数组长度、获取/删除特定位置的数组元素、删除整个数组以及遍历数组。
+**bash 2.0+** 支持一维数组(不支持多维数组),并且没有限定数组的大小。
+
+**重要提示**:数组是 bash 的**非 POSIX 扩展特性**,纯 POSIX sh(如 dash)不支持数组。若需编写可移植脚本,应避免使用数组。
+
+下面是一个关于数组操作的 Shell 代码示例,通过该示例大家可以知道如何创建数组、获取数组长度、获取/删除特定位置的数组元素、删除整个数组以及遍历数组。
```shell
#!/bin/bash
@@ -247,9 +295,35 @@ unset array; # 删除数组中的所有元素
for i in ${array[@]};do echo $i ;done # 遍历数组,数组元素为空,没有任何输出内容
```
-## Shell 基本运算符
+**重要说明:数组索引空洞**:
+
+使用 `unset array[1]` 删除元素后,数组会产生**索引空洞**:
+
+```shell
+#!/bin/bash
+array=(1 2 3 4 5)
+echo "删除前: ${array[@]}" # 输出: 1 2 3 4 5
+echo "索引1的值: ${array[1]}" # 输出: 2
+
+unset array[1] # 删除索引1的元素
+echo "删除后: ${array[@]}" # 输出: 1 3 4 5
+echo "索引1的值: ${array[1]}" # 输出: (空值)
+echo "索引2的值: ${array[2]}" # 输出: 3 (索引2仍在)
+
+# 遍历时索引不连续
+for index in "${!array[@]}"; do
+ echo "索引[$index] = ${array[$index]}"
+done
+# 输出:
+# 索引[0] = 1
+# 索引[2] = 3
+# 索引[3] = 4
+# 索引[4] = 5
+```
+
+**注意**:删除元素后,如果使用 `${array[1]}` 访问会得到空值。遍历数组时建议使用 `"${!array[@]}"` 获取有效索引,或使用 `"${array[@]}"` 直接遍历值。
-> 说明:图片来自《菜鸟教程》
+## Shell 基本运算符
Shell 编程支持下面几种运算符
@@ -261,23 +335,51 @@ Shell 编程支持下面几种运算符
### 算数运算符
-
+| **运算符** | **说明** | **举例** |
+| ---------- | -------- | ------------------------------------------ |
+| **+** | 加法 | `expr $a + $b` |
+| **-** | 减法 | `expr $a - $b` |
+| **\*** | 乘法 | `expr $a \* $b` (注意星号需要转义) |
+| **/** | 除法 | `expr $b / $a` |
+| **%** | 取余 | `expr $b % $a` |
+| **=** | 赋值 | `a=$b` 将变量 b 的值赋给 a |
+| **==** | 相等 | `[ $a == $b ]` 用于数字比较,相同返回 true |
+| **!=** | 不相等 | `[ $a != $b ]` 用于数字比较,不同返回 true |
-我以加法运算符做一个简单的示例(注意:不是单引号,是反引号):
+**推荐使用 bash 内置算术扩展**:
```shell
#!/bin/bash
-a=3;b=3;
-val=`expr $a + $b`
-#输出:Total value : 6
-echo "Total value : $val"
+a=3; b=3
+val=$((a + b)) # bash 算术扩展(推荐)
+# 输出:Total value: 6
+echo "Total value: $val"
+```
+
+**说明**:
+
+- `$((...))` 是 bash 内置功能,无需 fork 外部进程,性能更好
+- **不推荐**使用 `expr` 命令(需 fork 进程,且运算符两边必须有空格)
+- **不推荐**使用反引号 `` `...` ``(已过时),应使用 `$(...)` 语法
+
+**如果需要兼容 POSIX sh**,可以使用:
+
+```shell
+val=$(expr "$a" + "$b") # POSIX 兼容,但性能较差
```
### 关系运算符
关系运算符只支持数字,不支持字符串,除非字符串的值是数字。
-
+| **运算符** | **说明** | **对应英文** |
+| ---------- | ---------------------------------- | ------------- |
+| **-eq** | 检测两个数是否**相等** | equal |
+| **-ne** | 检测两个数是否**不相等** | not equal |
+| **-gt** | 检测左边的数是否**大于**右边的 | greater than |
+| **-lt** | 检测左边的数是否**小于**右边的 | less than |
+| **-ge** | 检测左边的数是否**大于等于**右边的 | greater equal |
+| **-le** | 检测左边的数是否**小于等于**右边的 | less equal |
通过一个简单的示例演示关系运算符的使用,下面 shell 程序的作用是当 score=100 的时候输出 A 否则输出 B。
@@ -285,7 +387,7 @@ echo "Total value : $val"
#!/bin/bash
score=90;
maxscore=100;
-if [ $score -eq $maxscore ]
+if [[ $score -eq $maxscore ]]
then
echo "A"
else
@@ -301,9 +403,12 @@ B
### 逻辑运算符
-
+| **运算符** | **说明** | **举例** |
+| ---------- | -------------- | --------------------------------------------- | --- | --------------------------- |
+| **&&** | 逻辑的 **AND** | `[[ $a -lt 100 && $b -gt 100 ]]` (全真才为真) |
+| **\|\|** | 逻辑的 **OR** | `[[ $a -lt 100 | | $b -gt 100 ]]` (一真即为真) |
-示例:
+**算术扩展中的逻辑运算**:
```shell
#!/bin/bash
@@ -312,15 +417,71 @@ a=$(( 1 && 0))
echo $a;
```
-### 布尔运算符
+**命令短路执行(生产环境常用)**:
-
+在运维自动化和 CI/CD 管道中,经常使用 `&&` 和 `||` 来控制命令链路的执行流程,这称为**短路执行**:
-这里就不做演示了,应该挺简单的。
+```shell
+#!/bin/bash
+set -euo pipefail
+
+# &&:前一个命令成功(返回 0)时才执行后一个命令
+mkdir -p "/tmp/app_data" && echo "目录就绪"
+
+# ||:前一个命令失败(返回非 0)时才执行后一个命令
+mkdir -p "/tmp/app_data" || echo "目录创建失败"
+
+# 组合使用:生产环境典型的防御姿势
+mkdir -p "/tmp/app_data" && echo "目录就绪" || exit 1
+
+# 实际场景示例
+# 1. 检查文件存在后再删除
+[ -f "/tmp/old_file.log" ] && rm "/tmp/old_file.log"
+
+# 2. 命令失败时输出错误信息并退出
+cd /app/config || { echo "无法进入配置目录"; exit 1; }
+
+# 3. 条件执行命令
+command1 && command2 || command3
+# ⚠️ 注意:此写法有陷阱!
+# - 当 command1 成功时,执行 command2
+# - 当 command1 失败时,执行 command3
+# - 但如果 command1 成功但 command2 失败,command3 仍会执行!
+#
+# ✅ 更安全的写法(推荐):
+if command1; then
+ command2
+else
+ command3
+fi
+#
+# 或明确知道 command2 不会失败时才使用 && || 组合
+```
+
+**重要提示**:
+
+- 短路执行依赖命令的**退出码(Exit Code)**:成功返回 0,失败返回非 0
+- 这与 `[[ ]]` 内部的 `&&` 和 `||` 不同,后者用于条件测试
+- `command1 && command2 || command3` 存在陷阱:若 command1 成功但 command2 失败,command3 仍会执行
+- 生产环境中强烈建议使用 if-then-else 结构,确保逻辑清晰
+
+### 布尔运算符
+
+| **运算符** | **说明** | **举例** |
+| ---------- | -------------------------------------------------------------------- | ------------------------------------------ |
+| **!** | 将表达式的结果取反。如果表达式为 true,则返回 false;否则返回 true。 | `[ ! false ]` 返回 true。 |
+| **-o** | 有一个表达式为 true,则返回 true。 | `[ $a -lt 20 -o $b -gt 100 ]` 返回 true。 |
+| **-a** | 两个表达式都为 true 才会返回 true。 | `[ $a -lt 20 -a $b -gt 100 ]` 返回 false。 |
### 字符串运算符
-
+| **运算符** | **说明** | **举例** |
+| ---------- | --------------------------------- | ----------------------------- |
+| **=** | 检测两个字符串是否**相等** | `[ $a = $b ]` |
+| **!=** | 检测两个字符串是否**不相等** | `[ $a != $b ]` |
+| **-z** | 检测字符串长度是否为 **0** (zero) | `[ -z $a ]` 为空返回 true |
+| **-n** | 检测字符串长度是否**不为 0** | `[ -n "$a" ]` 不为空返回 true |
+| **str** | 直接检测字符串是否为空 | `[ $a ]` 不为空返回 true |
简单示例:
@@ -328,7 +489,7 @@ echo $a;
#!/bin/bash
a="abc";
b="efg";
-if [ $a = $b ]
+if [[ $a = $b ]]
then
echo "a 等于 b"
else
@@ -344,7 +505,20 @@ a 不等于 b
### 文件相关运算符
-
+用于检测 Unix/Linux 文件的各种属性(如权限、类型等)。
+
+- **存在与类型检测:**
+ - **-e file**: 检测文件(包括目录)是否存在。
+ - **-f file**: 检测是否为普通文件(既不是目录也不是设备文件)。
+ - **-d file**: 检测是否为目录。
+ - **-s file**: 检测文件是否为空(文件大小大于 0 返回 true)。
+ - **-b/-c/-p**: 分别检测是否为块设备、字符设备、有名管道。
+- **权限检测:**
+ - **-r file**: 检测文件是否可读。
+ - **-w file**: 检测文件是否可写。
+ - **-x file**: 检测文件是否可执行。
+- **特殊标识检测:**
+ - **-u / -g / -k**: 分别检测文件是否设置了 SUID、SGID 或粘着位 (Sticky Bit)。
使用方式很简单,比如我们定义好了一个文件路径`file="/usr/learnshell/test.sh"` 如果我们想判断这个文件是否可读,可以这样`if [ -r $file ]` 如果想判断这个文件是否可写,可以这样`-w $file`,是不是很简单。
@@ -358,10 +532,10 @@ a 不等于 b
#!/bin/bash
a=3;
b=9;
-if [ $a -eq $b ]
+if [[ $a -eq $b ]]
then
echo "a 等于 b"
-elif [ $a -gt $b ]
+elif [[ $a -gt $b ]]
then
echo "a 大于 b"
else
@@ -375,7 +549,22 @@ fi
a 小于 b
```
-相信大家通过上面的示例就已经掌握了 shell 编程中的 if 条件语句。不过,还要提到的一点是,不同于我们常见的 Java 以及 PHP 中的 if 条件语句,shell if 条件语句中不能包含空语句也就是什么都不做的语句。
+相信大家通过上面的示例就已经掌握了 shell 编程中的 if 条件语句。
+
+**空语句的处理**:Shell 中空语句可以使用 `:`(冒号命令)或 `true` 命令实现:
+
+```shell
+if [[ condition ]]; then
+ : # 空语句(什么都不做)
+fi
+
+# 或
+if [[ condition ]]; then
+ true # 空语句
+fi
+```
+
+这在某些场景下很有用,例如在 while 循环中作为占位符。
### for 循环语句
@@ -419,10 +608,10 @@ done;
```shell
#!/bin/bash
int=1
-while(( $int<=5 ))
+while (( int <= 5 )) # 算术上下文内变量无需 $
do
echo $int
- let "int++"
+ (( int++ )) # 推荐使用 (( )) 替代 let
done
```
@@ -431,7 +620,7 @@ done
```shell
echo '按下 退出'
echo -n '输入你最喜欢的电影: '
-while read FILM
+while read -r FILM # -r 选项禁止反斜杠转义,提高安全性
do
echo "是的!$FILM 是一个好电影"
done
@@ -482,18 +671,34 @@ echo "-----函数执行完毕-----"
```shell
#!/bin/bash
+set -euo pipefail
+
funWithReturn(){
+ local aNum
+ local anotherNum
echo "输入第一个数字: "
- read aNum
+ read -r aNum
echo "输入第二个数字: "
- read anotherNum
+ read -r anotherNum
echo "两个数字分别为 $aNum 和 $anotherNum !"
- return $(($aNum+$anotherNum))
+ return $((aNum + anotherNum))
}
funWithReturn
echo "输入的两个数字之和为 $?"
```
+**重要说明**:
+
+- **`local` 关键字**:将变量限制在函数作用域内,避免污染全局命名空间
+- **`read -r`**:`-r` 选项禁止反斜杠转义,提高安全性
+- **函数返回值**:Shell 函数只能返回 0-255 的退出码,如需返回复杂数据应使用 `echo` 或全局变量
+
+**为什么使用 local?**
+
+- 在复杂脚本或引入多个外部脚本时,非 local 变量可能被意外覆盖
+- 全局变量污染会导致难以排查的配置漂移或逻辑越权
+- 使用 `local` 是函数编程的最佳实践,类似于其他编程语言的局部变量概念
+
输出结果:
```plain
@@ -510,13 +715,14 @@ echo "输入的两个数字之和为 $?"
```shell
#!/bin/bash
funWithParam(){
- echo "第一个参数为 $1 !"
- echo "第二个参数为 $2 !"
- echo "第十个参数为 $10 !"
- echo "第十个参数为 ${10} !"
- echo "第十一个参数为 ${11} !"
- echo "参数总数有 $# 个!"
- echo "作为一个字符串输出所有参数 $* !"
+ echo "第一个参数为 $1"
+ echo "第二个参数为 $2"
+ echo "脚本名称为 $0"
+ echo "第十个参数为 ${10}" # 注意:参数 ≥ 10 时必须用 ${n}
+ echo "第十一个参数为 ${11}"
+ echo "参数总数有 $# 个"
+ echo "所有参数为 $*" # 作为单个字符串输出
+ echo "所有参数为 $@" # 作为独立的参数输出(推荐)
}
funWithParam 1 2 3 4 5 6 7 8 9 34 73
```
@@ -524,13 +730,679 @@ funWithParam 1 2 3 4 5 6 7 8 9 34 73
输出结果:
```plain
-第一个参数为 1 !
-第二个参数为 2 !
-第十个参数为 10 !
-第十个参数为 34 !
-第十一个参数为 73 !
-参数总数有 11 个!
-作为一个字符串输出所有参数 1 2 3 4 5 6 7 8 9 34 73 !
+第一个参数为 1
+第二个参数为 2
+脚本名称为 ./script.sh
+第十个参数为 34
+第十一个参数为 73
+参数总数有 11 个
+所有参数为 1 2 3 4 5 6 7 8 9 34 73
+所有参数为 1 2 3 4 5 6 7 8 9 34 73
+```
+
+**重要提示**:
+
+- **位置参数 `$n` 当 `n ≥ 10` 时必须使用 `${n}` 语法**
+- 例如:`$10` 会被解析为 `$1` 和字面量 `0` 的拼接,而非第十个参数
+- `$0` 表示脚本本身的名称
+- `$#` 表示参数总数
+
+**`$*` 与 `$@` 的核心区别**:
+
+| 表达式 | 未引用 | 双引号包裹 |
+| ------ | -------------- | ---------------------------------------- |
+| `$*` | 展开为所有参数 | 展开为**单个字符串**(所有参数合并) |
+| `$@` | 展开为所有参数 | 展开为**独立的参数**(每个参数保持独立) |
+
+**示例对比**:
+
+```shell
+#!/bin/bash
+test_args() {
+ echo "--- 使用 \$* (无引号)---"
+ for arg in $*; do
+ echo "参数: [$arg]"
+ done
+
+ echo -e "\n--- 使用 \$@ (无引号)---"
+ for arg in $@; do
+ echo "参数: [$arg]"
+ done
+
+ echo -e "\n--- 使用 \"\$*\" (双引号)---"
+ for arg in "$*"; do
+ echo "参数: [$arg]"
+ done
+
+ echo -e "\n--- 使用 \"\$@\" (双引号,推荐)---"
+ for arg in "$@"; do
+ echo "参数: [$arg]"
+ done
+}
+
+# 调用函数,传递包含空格的参数
+test_args "hello world" "foo bar"
+```
+
+**输出结果**:
+
+```plain
+--- 使用 $* (无引号)---
+参数: [hello]
+参数: [world]
+参数: [foo]
+参数: [bar]
+
+--- 使用 $@ (无引号)---
+参数: [hello]
+参数: [world]
+参数: [foo]
+参数: [bar]
+
+--- 使用 "$*" (双引号)---
+参数: [hello world foo bar] # 所有参数合并为一个字符串
+
+--- 使用 "$@" (双引号,推荐)---
+参数: [hello world] # 每个参数保持独立
+参数: [foo bar]
+```
+
+**结论**:在传递参数时,**始终使用 `"$@"`** 以确保每个参数的独立性(特别是当参数包含空格时)。
+
+## Shell 编程最佳实践
+
+在掌握了 Shell 编程的基础知识后,了解一些最佳实践能帮助你编写更安全、更高效的脚本。
+
+### 脚本基础规范
+
+**1. Shebang 规范**:
+
+```shell
+#!/usr/bin/env bash # 更可移植(自动查找 bash)
+set -euo pipefail # 严格模式:遇错退出、未定义变量报错、管道失败报错
+```
+
+**Shebang 两种写法**:
+
+- `#!/bin/bash`:直接指定 bash 路径,适用于你知道 bash 位置的固定环境
+- `#!/usr/bin/env bash`:通过 env 查找 bash,更可移植,适合不同系统(如 macOS / Linux)
+
+**本文示例选择**:
+
+- 教程示例使用 `#!/bin/bash`:简洁明了,适合初学者理解
+- 生产级示例使用 `#!/usr/bin/env bash`:强调可移植性
+
+**2. 变量引用**:
+
+```shell
+# 始终用双引号包裹变量
+echo "$var" # 推荐
+echo $var # 可能导致 word splitting 和 globbing 问题
+```
+
+**3. 使用 shellcheck**:
+
+```bash
+shellcheck your_script.sh # 静态分析,发现常见问题
+```
+
+**4. 推荐语法**:
+
+- 使用 `[[ ]]` 而非 `[ ]`(更安全、支持模式匹配)
+- 使用 `$((...))` 而非 `expr`(性能更好)
+- 使用 `$(...)` 而非反引号(可嵌套、更清晰)
+- 使用 `${n}` 访问位置参数 n ≥ 10
+
+### pipefail 工作原理
+
+默认情况下,管道命令的返回值只取决于最后一个命令。启用 `pipefail` 后,管道的返回值将是最后一个失败命令的返回值,这能避免隐藏中间步骤的错误。
+
+**示例对比**:
+
+```shell
+# 默认模式(危险)
+cat huge_file.txt | grep "pattern" | head -n 10
+# 即使 cat 失败(文件不存在),只要 head 成功,返回码就是 0
+
+# pipefail 模式(安全)
+set -o pipefail
+cat huge_file.txt | grep "pattern" | head -n 10
+# cat 失败会立即返回错误码,不会被忽略
+```
+
+## 生产环境最佳实践
+
+### 脚本安全性
+
+**1. 始终使用严格模式**:
+
+```shell
+#!/usr/bin/env bash
+set -euo pipefail # 遇错退出、未定义变量报错、管道失败报错
+```
+
+**2. 变量引用安全**:
+
+```shell
+# 始终用双引号包裹变量,防止 word splitting 和 globbing
+rm -rf "$temp_dir" # 推荐
+rm -rf $temp_dir # 危险:如果 temp_dir 包含空格会导致误删
+```
+
+**3. 使用 local 限制变量作用域**:
+
+```shell
+process_data() {
+ local input_file="$1"
+ local output_file="$2"
+ # ... 处理逻辑
+}
+```
+
+### 监控指标建议
+
+**关键指标**:
+
+- **脚本执行返回码(Exit Code)**:非 0 必须触发告警
+- **命令执行超时时间**:防御网络阻塞或 read 死锁(使用 `timeout` 命令)
+- **关键资源的并发争用**:临时文件、锁文件、网络连接等
+- **单机文件描述符(FD)使用率**:防止后台并发启动导致 FD 耗尽
+- **PID 饱和度**:监控进程数量,防止 PID 耗尽
+- **网络请求 P99 延迟**:监控 API 请求的尾延迟
+
+**超时控制示例**:
+
+```shell
+# 为整个脚本设置超时(5 分钟)
+timeout 300 ./your_script.sh || { echo "脚本执行超时"; exit 1; }
+
+# 为单个命令设置超时
+timeout 10 curl -s https://api.example.com/data || { echo "API 请求超时"; exit 1; }
+```
+
+**生产级 API 请求(带重试和退避)**:
+
+```shell
+# ⚠️ 重要:单纯拦截超时不够,必须考虑重试风暴
+# 下面的配置包含连接超时、总超时、重试机制和指数退避
+
+curl -s \
+ --connect-timeout 3 \ # 连接超时 3 秒
+ --max-time 10 \ # 总超时 10 秒
+ --retry 3 \ # 失败时重试 3 次
+ --retry-delay 2 \ # 重试间隔 2 秒
+ --retry-max-time 30 \ # 重试总时长不超过 30 秒
+ --retry-connrefused \ # 连接被拒绝时也重试
+ --retry-all-errors \ # 所有错误都重试
+ https://api.example.com/data || { echo "API 请求彻底失败"; exit 1; }
+```
+
+**重试风暴防护**:
+
+```shell
+# ❌ 危险:无节制的重试会导致级联雪崩
+for i in {1..10}; do
+ curl -s https://api.example.com/data && break || sleep 1
+done
+
+# ✅ 安全:带抖动(Jitter)的指数退避重试
+retry_with_backoff() {
+ local max_attempts=5
+ local base_delay=1
+ local max_delay=32
+ local attempt=1
+
+ while (( attempt <= max_attempts )); do
+ if curl -s --connect-timeout 3 --max-time 10 \
+ --retry 3 --retry-delay 2 --retry-max-time 30 \
+ "$@"; then
+ return 0
+ fi
+
+ if (( attempt < max_attempts )); then
+ # 指数退避 + 随机抖动(防止重试风暴)
+ local delay=$(( base_delay * (1 << (attempt - 1)) ))
+ delay=$(( delay > max_delay ? max_delay : delay ))
+ local jitter=$((RANDOM % 1000)) # 0-999ms 随机抖动
+ delay=$(( delay * 1000 + jitter ))
+ echo "请求失败,${delay}ms 后重试 (第 $attempt 次)" >&2
+ sleep "${delay}e-6"
+ fi
+
+ ((attempt++))
+ done
+
+ return 1
+}
+
+# 使用
+retry_with_backoff https://api.example.com/data
+```
+
+**重要提示**:
+
+- **重试风暴**:网络分区恢复后,无节制的重试会瞬间打满下游服务
+- **指数退避**:每次重试间隔呈指数增长(1s → 2s → 4s → 8s...)
+- **随机抖动**:添加随机延迟避免多个客户端同时重试(惊群效应)
+- **监控指标**:需监控超时丢包率与 P99 请求耗时
+
+### 压测建议
+
+**并发安全测试**:
+
+```shell
+# ❌ 危险:无限制并发可能导致 PID 耗尽或 OOM
+for i in {1..100}; do
+ ./your_script.sh &
+done
+wait
+
+# ✅ 安全:使用 xargs 控制并发度(推荐)
+# 限制最大并行数为 10,防止系统资源耗尽
+seq 1 100 | xargs -n 1 -P 10 -I {} ./your_script.sh
+
+# 或使用 GNU parallel(功能更强大)
+seq 1 100 | parallel -j 10 ./your_script.sh
+```
+
+**重要提示**:
+
+- **并发度控制**:生产环境的单机压测应使用 `xargs -P` 或 GNU parallel 限制并发进程数
+- **资源监控**:压测时监控文件描述符(FD)使用率和 PID 饱和度
+- **失败模式**:无限制的 `&` 会引发数百个进程在 D 状态挂起,导致节点内核级假死
+
+**常见问题检测**:
+
+- **固定路径冲突**:避免使用 `/tmp/test.log` 等固定路径,应使用 `$$` 引入进程 PID:
+
+ ```shell
+ temp_file="/tmp/myapp_$$/temp.log"
+ mkdir -p "$(dirname "$temp_file")"
+ ```
+
+- **锁机制**:使用 `flock` 防止并发执行:
+
+ ```shell
+ # ⚠️ 重要:flock 仅在本地文件系统(Ext4/XFS)保证强一致性
+ # 若锁文件位于 NFS 等网络存储,flock 可能静默失效(脑裂风险)
+
+ # 单机场景:确保同一时间只有一个实例在运行
+ exec 200>/var/lock/myapp.lock
+ flock -n 200 || { echo "脚本已在运行"; exit 1; }
+
+ # 分布式场景:需要使用分布式锁服务(如 Redis、etcd、ZooKeeper)
+ # 或通过数据库唯一索引、消息队列等机制实现互斥
+ ```
+
+ **flock 脑裂风险可视化**:
+
+ ```mermaid
+ sequenceDiagram
+ participant CronA as 节点A (定时任务)
+ participant CronB as 节点B (定时任务)
+ participant Storage as 存储层
+
+ CronA->>Storage: 请求 flock 互斥锁 (非阻塞)
+ Storage-->>CronA: 授予锁 (成功)
+ CronA->>CronA: 执行核心自动化逻辑
+
+ CronB->>Storage: 并发请求 flock 互斥锁 (非阻塞)
+ alt 本地文件系统 (Ext4/XFS)
+ Storage-->>CronB: 拒绝加锁 (返回非0)
+ CronB->>CronB: 安全退出,防御并发成功 ✓
+ else 网络文件系统 (NFS/配置异常)
+ Storage-->>CronB: 错误地授予锁 (静默失效)
+ CronB->>CronB: 🚨 执行核心逻辑,发生并发写与数据踩踏!
+ end
+ ```
+
+ **分布式锁方案建议**:
+
+ - **Redis**:使用 `SET key value NX PX timeout` 实现分布式锁
+ - **etcd**:使用事务 API 和租约机制
+ - **数据库**:使用 `UNIQUE INDEX` 约束
+ - **消息队列**:使用单消费者模式保证互斥
+
+**后台进程退出码捕获**:
+
+```shell
+# ❌ 问题:wait 默认不检查退出码,后台任务失败会被静默吃掉
+for i in {1..10}; do
+ ./task.sh &
+done
+wait # 只等待所有后台进程结束,不检查退出码
+
+# ✅ 正确:逐个检查后台进程的退出码
+pids=()
+for i in {1..10}; do
+ ./task.sh &
+ pids+=($!)
+done
+
+# 等待所有后台进程并检查退出码
+for pid in "${pids[@]}"; do
+ if ! wait "$pid"; then
+ echo "进程 $pid 执行失败" >&2
+ exit_code=1
+ fi
+done
+
+# 或使用 wait -n(bash 4.3+)等待任一进程并检查退出码
+while wait -n; do
+ : # 检查 $? 是否为 0
+done
+```
+
+### 常见误区
+
+**1. 吞掉错误上下文**:
+
+```shell
+# ❌ 错误:滥用 > /dev/null 2>&1
+command > /dev/null 2>&1
+
+# ✅ 正确:只屏蔽不需要的输出,保留错误信息
+command > /dev/null # 或
+command 2>/tmp/error.log
```
-
+**2. 环境依赖假定**:
+
+```shell
+# ❌ 危险:依赖特定的 PATH 顺序,未验证命令是否存在
+curl -s https://api.example.com/data
+
+# ✅ 安全:验证命令存在后再使用
+command -v curl >/dev/null 2>&1 || { echo "curl 未安装"; exit 1; }
+curl -s https://api.example.com/data
+
+# 或者:明确指定完整路径(适用于关键生产环境)
+CURL_PATH="/usr/bin/curl"
+[[ -x "$CURL_PATH" ]] || { echo "curl 不存在或不可执行"; exit 1; }
+"$CURL_PATH" -s https://api.example.com/data
+```
+
+**说明**:验证命令存在可以防止因环境差异导致的运行时错误。若需更高安全性,可指定完整路径。
+
+**3. 未处理管道失败**:
+
+```shell
+# ❌ 问题:默认模式下管道只看最后一个命令的返回码
+cat huge_file.txt | grep "pattern" | head -n 10
+# 即使 cat 失败,只要 head 成功,整体返回码就是 0
+
+# ✅ 安全:使用 pipefail 确保任何命令失败都能被捕获
+set -o pipefail
+cat huge_file.txt | grep "pattern" | head -n 10
+```
+
+**4. 未清理临时资源**:
+
+```shell
+# ❌ 问题:脚本异常退出时临时文件未被清理
+temp_file="/tmp/data_$$"
+process_data "$temp_file"
+
+# ✅ 安全:使用 trap 确保清理
+temp_file="/tmp/data_$$"
+trap 'rm -f "$temp_file"' EXIT
+process_data "$temp_file"
+```
+
+### 错误处理模式
+
+**防御式编程模板**:
+
+```shell
+#!/usr/bin/env bash
+set -euo pipefail
+
+# 错误处理函数
+error_exit() {
+ echo "错误: $1" >&2
+ exit "${2:-1}"
+}
+
+# 验证依赖
+command -v curl >/dev/null 2>&1 || error_exit "curl 未安装"
+command -v jq >/dev/null 2>&1 || error_exit "jq 未安装"
+
+# 验证参数
+[[ $# -eq 1 ]] || error_exit "用法: $0 "
+
+# 验证文件存在
+[[ -f "$1" ]] || error_exit "配置文件不存在: $1"
+
+# 设置超时和清理
+temp_file="/tmp/process_$$"
+trap 'rm -f "$temp_file"' EXIT
+
+# 主要逻辑(带超时)
+timeout 300 process_data "$1" "$temp_file" || error_exit "数据处理失败或超时"
+
+echo "处理完成:$temp_file"
+```
+
+### 故障演练建议
+
+生产环境的脚本需要经过充分的故障测试,确保在各种异常情况下都能正确处理。以下是推荐的故障演练场景:
+
+**1. 网络分区测试**
+
+```shell
+# 使用 iptables 模拟 50% 丢包率
+sudo iptables -A OUTPUT -p tcp --dport 443 -m statistic --mode random --probability 0.5 -j DROP
+
+# 测试带有重试机制的 curl 是否引发雪崩
+retry_with_backoff https://api.example.com/data
+
+# 恢复网络
+sudo iptables -D OUTPUT -p tcp --dport 443 -m statistic --mode random --probability 0.5 -j DROP
+```
+
+**测试要点**:
+
+- 验证重试机制是否正常工作
+- 检查是否有指数退避和随机抖动
+- 确认不会因重试风暴导致级联失败
+
+**2. 慢响应拖垮测试**
+
+```shell
+# 模拟下游 API 长时间不返回(但不断开连接)
+# 使用 nc 监听端口但不发送数据
+nc -l 8080 &
+
+# 测试 timeout 是否能准确切断连接
+timeout 5 curl -s http://localhost:8080/data || echo "超时触发"
+
+# 清理
+pkill nc
+```
+
+**测试要点**:
+
+- 验证 `--max-time` 是否生效
+- 检查是否有资源泄漏(连接、内存)
+- 确认超时后脚本能正确退出
+
+**3. 时钟漂移测试**
+
+```shell
+# 模拟系统时钟回拨(需要 root 权限)
+sudo date -s "2 hours ago"
+
+# 测试基于 $PID 生成的临时文件是否有重复覆盖风险
+temp_file="/tmp/test_$$/data.txt"
+mkdir -p "$(dirname "$temp_file")"
+echo "data" > "$temp_file"
+echo "Created: $temp_file"
+
+# 恢复系统时钟
+sudo ntpdate -u time.nist.gov
+```
+
+**测试要点**:
+
+- 验证 PID 循环后临时文件是否会被覆盖
+- 检查是否需要添加时间戳或 UUID 增强唯一性
+- 确认脚本对时钟变化的鲁棒性
+
+**4. NFS 延迟测试**
+
+```shell
+# 模拟 NFS 存储高延迟(使用 tc 延迟网络)
+# 挂载测试用的 NFS 共享
+sudo mount -t nfs nfs-server:/share /mnt/nfs-test
+
+# 监控 I/O 延迟(P90 / P99)
+iostat -x 1 10 | grep dm-0
+
+# 在 NFS 共享上执行脚本,验证 flock 是否正常
+LOCK_FILE="/mnt/nfs-test/myapp.lock"
+exec 200>"$LOCK_FILE"
+flock -n 200 || { echo "获取锁失败"; exit 1; }
+
+# 清理
+sudo umount /mnt/nfs-test
+```
+
+**测试要点**:
+
+- 验证 flock 在网络存储上是否有效(预期可能失效)
+- 检查是否有脑裂风险(多个节点同时获取锁)
+- 确认是否需要使用分布式锁替代
+
+**5. 文件描述符耗尽测试**
+
+```shell
+# 查看当前进程的 FD 限制
+ulimit -n
+
+# 模拟大量并发连接,测试 FD 耗尽场景
+for i in {1..1000}; do
+ exec {fd}>"/tmp/file_$i" 2>/dev/null || break
+done
+
+# 检查 FD 使用情况
+ls -l /proc/$$/fd | wc -l
+
+# 清理
+for i in {1..1000}; do
+ eval "exec $fd>&-" 2>/dev/null
+done
+```
+
+**测试要点**:
+
+- 验证脚本在 FD 不足时的行为
+- 检查是否有资源泄漏
+- 确认并发度限制是否有效
+
+**6. 压测数据一致性测试**
+
+```shell
+# 在 NFS 共享存储目录下,由多个机器节点同时高频执行脚本
+# 验证数据恢复与幂等性边界
+
+# 节点 A
+for i in {1..100}; do
+ echo "nodeA_data_$i" >> /mnt/shared/data.txt
+ sleep 0.1
+done &
+
+# 节点 B(在另一台机器上同时执行)
+for i in {1..100}; do
+ echo "nodeB_data_$i" >> /mnt/shared/data.txt
+ sleep 0.1
+done &
+
+# 检查数据是否完整
+wait
+wc -l /mnt/shared/data.txt
+sort /mnt/shared/data.txt | uniq -c
+```
+
+**测试要点**:
+
+- 验证并发写入是否会导致数据混乱
+- 检查是否需要使用锁机制
+- 确认数据恢复策略是否有效
+
+## 总结
+
+Shell 编程是后端开发和运维人员必备的核心技能之一,掌握它能显著提升工作效率,实现自动化运维和系统管理。本文从入门到生产实践,系统介绍了 Shell 编程的核心知识点。
+
+### 核心知识点回顾
+
+| 知识模块 | 关键要点 |
+| ------------ | --------------------------------------------------------------------------------- | --- | ---------------- |
+| **变量** | 区分局部变量、环境变量和特殊变量;使用 `local` 避免全局污染;始终用双引号包裹变量 |
+| **字符串** | 推荐使用双引号;理解单引号和双引号的区别;掌握 `${#var}` 获取长度 |
+| **数组** | bash 2.0+ 支持数组(非 POSIX);注意删除元素后的索引空洞 |
+| **运算符** | 优先使用 `$((...))` 进行算术运算;`[[ ]]` 比 `[ ]` 更安全 |
+| **流程控制** | 使用 `[[ ]]` 进行条件测试;避免 `command1 && command2 | | command3` 的陷阱 |
+| **函数** | 使用 `local` 限制变量作用域;函数只能返回 0-255 的退出码 |
+| **命令替换** | 使用 `$(...)` 替代反引号;使用 `read -r` 提高安全性 |
+
+### 生产级脚本编写要点
+
+编写生产环境的 Shell 脚本时,务必遵循以下原则:
+
+**1. 严格模式**
+
+```shell
+#!/usr/bin/env bash
+set -euo pipefail # 遇错退出、未定义变量报错、管道失败报错
+```
+
+**2. 防御式编程**
+
+- 验证依赖:`command -v` 检查命令是否存在
+- 验证参数:检查参数数量和类型
+- 验证文件:确认文件存在且可访问
+- 超时控制:使用 `timeout` 防止死锁
+- 资源清理:使用 `trap` 确保临时资源被释放
+
+**3. 避免常见陷阱**
+
+- 不吞掉错误上下文(避免滥用 `>/dev/null 2>&1`)
+- 不依赖特定 PATH 顺序(验证或指定完整路径)
+- 不忽略管道失败(使用 `set -o pipefail`)
+- 不遗漏临时资源清理(使用 `trap`)
+
+**4. 并发安全**
+
+- 使用 `$$` 引入 PID 隔离临时文件
+- 使用 `flock` 防止脚本并发执行
+- 避免使用固定的临时文件路径
+
+### 学习建议
+
+**初学者**:
+
+1. 从简单的命令别名和脚本开始
+2. 重点掌握变量、条件判断和循环
+3. 使用 `shellcheck` 检查脚本错误
+4. 多练习,从实际场景出发(如日志分析、文件处理)
+
+**进阶学习**:
+
+1. 深入学习进程管理、信号处理
+2. 掌握 `sed`、`awk`、`grep` 等文本处理工具
+3. 学习正则表达式和文本处理技巧
+4. 了解性能优化和并发处理
+
+**生产实践**:
+
+1. 阅读 Google Shell Style Guide
+2. 研究开源项目的 Shell 脚本
+3. 在测试环境充分验证后再部署
+4. 建立完善的监控和告警机制
+
+### 参考资源
+
+- **官方文档**:Bash Reference Manual (GNU)
+- **代码检查**:ShellCheck - Shell Script Analysis Tool
+- **编码规范**:Google Shell Style Guide
+- **常见陷阱**:Bash Pitfalls (http://mywiki.wooledge.org/BashPitfalls)
diff --git a/docs/database/basis.md b/docs/database/basis.md
index 86ddfc4b54a..154d59a0be5 100644
--- a/docs/database/basis.md
+++ b/docs/database/basis.md
@@ -1,8 +1,13 @@
---
title: 数据库基础知识总结
+description: 数据库基础知识总结,包括数据库、DBMS、数据库系统、DBA的概念区别,DBMS核心功能,元组、码、主键外键等关系型数据库核心概念,以及ER图的使用方法。
category: 数据库
tag:
- 数据库基础
+head:
+ - - meta
+ - name: keywords
+ content: 数据库,数据库管理系统,DBMS,数据库系统,DBA,SQL,DDL,DML,数据模型,关系型数据库,主键,外键,ER图
---
@@ -11,20 +16,189 @@ tag:
## 什么是数据库, 数据库管理系统, 数据库系统, 数据库管理员?
-- **数据库** : 数据库(DataBase 简称 DB)就是信息的集合或者说数据库是由数据库管理系统管理的数据的集合。
-- **数据库管理系统** : 数据库管理系统(Database Management System 简称 DBMS)是一种操纵和管理数据库的大型软件,通常用于建立、使用和维护数据库。
-- **数据库系统** : 数据库系统(Data Base System,简称 DBS)通常由软件、数据库和数据管理员(DBA)组成。
-- **数据库管理员** : 数据库管理员(Database Administrator, 简称 DBA)负责全面管理和控制数据库系统。
+这四个概念描述了从数据本身到管理整个体系的不同层次,我们常用一个图书馆的例子来把它们串联起来理解。
+
+- **数据库 (Database - DB):** 它就像是图书馆里,书架上存放的所有书籍和资料。从技术上讲,数据库就是按照一定数据模型组织、描述和储存起来的、可以被各种用户共享的结构化数据的集合。它就是我们最终要存取的核心——信息本身。
+- **数据库管理系统 (Database Management System - DBMS):** 它就像是整个图书馆的管理系统,包括图书的分类编目规则、借阅归还流程、安全检查系统等等。从技术上讲,DBMS 是一种大型软件,比如我们常用的 MySQL、Oracle、PostgreSQL 软件。它的核心职责是科学地组织和存储数据、高效地获取和维护数据;为我们屏蔽了底层文件操作的复杂性,提供了一套标准接口(如 SQL)来操纵数据,并负责并发控制、事务管理、权限控制等复杂问题。
+- **数据库系统 (Database System - DBS):** 它就是整个正常运转的图书馆。这是一个更大的概念,不仅包括书(DB)和管理系统(DBMS),还包括了硬件、应用和使用的人。
+- **数据库管理员 (Database Administrator - DBA ):** 他就是图书馆的馆长,负责整个数据库系统正常运行。他的职责非常广泛,包括数据库的设计、安装、监控、性能调优、备份与恢复、安全管理等等,确保整个系统的稳定、高效和安全。
+
+DB 和 DBMS 我们通常会搞混,这里再简单提一下:**通常我们说“用 MySQL 数据库”,其实是用 MySQL(DBMS)来管理一个或多个数据库(DB)。**
+
+## DBMS 有哪些主要的功能
+
+```mermaid
+graph TD
+ DBMS["🗄️ DBMS
数据库管理系统"]
+
+ subgraph define["数据定义"]
+ DDL["📐 DDL
Data Definition Language"]
+ DDL_Items["• 创建/修改/删除对象
• 定义表结构
• 定义视图、索引
• 定义触发器
• 定义存储过程"]
+ end
+
+ subgraph operate["数据操作"]
+ DML["⚡ DML
Data Manipulation Language"]
+ CRUD["CRUD 操作
• Create 创建
• Read 读取
• Update 更新
• Delete 删除"]
+ end
+
+ subgraph control["数据控制"]
+ DCL["🔐 数据控制功能"]
+ Control_Items["• 并发控制
• 事务管理
• 完整性约束
• 权限控制
• 安全性限制"]
+ end
+
+ subgraph maintain["数据库维护"]
+ Maintenance["🛠️ 维护功能"]
+ Maintain_Items["• 数据导入/导出
• 备份与恢复
• 性能监控与分析
• 系统日志管理"]
+ end
+
+ DBMS --> DDL
+ DBMS --> DML
+ DBMS --> DCL
+ DBMS --> Maintenance
+
+ DDL --> DDL_Items
+ DML --> CRUD
+ DCL --> Control_Items
+ Maintenance --> Maintain_Items
+
+ style DBMS fill:#005D7B,stroke:#00838F,stroke-width:4px,color:#fff
+
+ style DDL fill:#4CA497,stroke:#00838F,stroke-width:3px,color:#fff
+ style DDL_Items fill:#f0fffe,stroke:#4CA497,stroke-width:2px,color:#333
+
+ style DML fill:#E99151,stroke:#C44545,stroke-width:3px,color:#fff
+ style CRUD fill:#fff5e6,stroke:#E99151,stroke-width:2px,color:#333
+
+ style DCL fill:#00838F,stroke:#005D7B,stroke-width:3px,color:#fff
+ style Control_Items fill:#e6f7ff,stroke:#00838F,stroke-width:2px,color:#333
+
+ style Maintenance fill:#C44545,stroke:#8B0000,stroke-width:3px,color:#fff
+ style Maintain_Items fill:#ffe6e6,stroke:#C44545,stroke-width:2px,color:#333
+
+ style define fill:#E4C189,stroke:#E99151,stroke-width:2px,stroke-dasharray: 5 5,opacity:0.3
+ style operate fill:#E4C189,stroke:#E99151,stroke-width:2px,stroke-dasharray: 5 5,opacity:0.3
+ style control fill:#E4C189,stroke:#E99151,stroke-width:2px,stroke-dasharray: 5 5,opacity:0.3
+ style maintain fill:#E4C189,stroke:#E99151,stroke-width:2px,stroke-dasharray: 5 5,opacity:0.3
+```
+
+DBMS 通常提供四大核心功能:
+
+1. **数据定义:** 这是 DBMS 的基础。它提供了一套数据定义语言(Data Definition Language - DDL),让我们能够创建、修改和删除数据库中的各种对象。这不仅仅是定义表的结构(比如字段名、数据类型),还包括定义视图、索引、触发器、存储过程等。
+2. **数据操作:** 这是我们作为开发者日常使用最多的功能。它提供了一套数据操作语言(Data Manipulation Language - DML),核心就是我们熟悉的增、删、改、查(CRUD)操作。它让我们能够方便地对数据库中的数据进行操作和检索。
+3. **数据控制:** 这是保证数据正确、安全、可靠的关键。通常包含并发控制、事务管理、完整性约束、权限控制、安全性限制等功能。
+4. **数据库维护:** 这部分功能是为了保障数据库系统的长期稳定运行。它包括了数据的导入导出、数据库的备份与恢复、性能监控与分析、以及系统日志管理等。
+
+## 你知道哪些类型的 DBMS?
+
+### 关系型数据库
+
+除了我们最常用的关系型数据库(RDBMS),比如 MySQL(开源首选)、PostgreSQL(功能最全)、Oracle(企业级),它们基于严格的表结构和 SQL,非常适合结构化数据和需要事务保证的场景,例如银行交易、订单系统。
+
+近年来,为了应对互联网应用带来的海量数据、高并发和多样化数据结构的需求,涌现出了一大批 NoSQL 和 NewSQL 数据库。
+
+### NoSQL 数据库
+
+它们的共同特点是为了极致的性能和水平扩展能力,在某些方面(通常是事务)做了妥协。
+
+**1. 键值数据库,代表是 Redis。**
+
+- **特点:** 数据模型极其简单,就是一个巨大的 Map,通过 Key 来存取 Value。内存操作,性能极高。
+- **适用场景:** 非常适合做缓存、会话存储、计数器等对读写性能要求极高的场景。
+
+**2. 文档数据库,代表是 MongoDB。**
+
+- **特点:** 它存储的是半结构化的文档(比如 JSON/BSON),结构灵活,不需要预先定义表结构。
+- **适用场景:** 特别适合那些数据结构多变、快速迭代的业务,比如用户画像、内容管理系统、日志存储等。
+
+**3. 列式数据库,代表是 HBase, Cassandra。**
+
+- **特点:** 数据是按列族而不是按行来存储的。这使得它在对大量行进行少量列的读取时,性能极高。
+- **适用场景:** 专为海量数据存储和分析设计,非常适合做大数据分析、监控数据存储、推荐系统等需要高吞吐量写入和范围扫描的场景。
+
+**4. 图形数据库,代表是 Neo4j。**
+
+- **特点:** 数据模型是节点(Nodes)和边(Edges),专门用来存储和查询实体之间的复杂关系。
+- **适用场景:** 在社交网络(好友关系)、推荐引擎(用户-商品关系)、知识图谱、欺诈检测(资金流动关系)等场景下,表现远超关系型数据库。
+
+### NewSQL 数据库
+
+由于 NoSQL 不支持事务,很多对于数据安全要求非常高的系统(比如财务系统、订单系统、交易系统)就不太适合使用了。不过,这类系统往往有存储大量数据的需求。
+
+这些系统往往只能通过购买性能更强大的计算机,或者通过数据库中间件来提高存储能力。不过,前者的金钱成本太高,后者的开发成本太高。
+
+于是,**NewSQL** 就来了!
+
+简单来说,NewSQL 就是:**分布式存储+SQL+事务** 。NewSQL 不仅具有 NoSQL 对海量数据的存储管理能力,还保持了传统数据库支持 ACID 和 SQL 等特性。因此,NewSQL 也可以称为 **分布式关系型数据库**。
+
+NewSQL 数据库设计的一些目标:
+
+1. 横向扩展(Scale Out) : 通过增加机器的方式来提高系统的负载能力。与之类似的是 Scale Up(纵向扩展),升级硬件设备的方式来提高系统的负载能力。
+2. 强一致性(Strict Consistency):在任意时刻,所有节点中的数据是一样的。
+3. 高可用(High Availability):系统几乎可以一直提供服务。
+4. 支持标准 SQL(Structured Query Language) :PostgreSQL、MySQL、Oracle 等关系型数据库都支持 SQL 。
+5. 事务(ACID) : 原子性(Atomicity)、一致性(Consistency)、 隔离性(Isolation); 持久性(Durability)。
+6. 兼容主流关系型数据库 : 兼容 MySQL、Oracle、PostgreSQL 等常用关系型数据库。
+7. 云原生 (Cloud Native):可在公有云、私有云、混合云中实现部署工具化、自动化。
+8. HTAP(Hybrid Transactional/Analytical Processing) :支持 OLTP 和 OLAP 混合处理。
+
+NewSQL 数据库代表:Google 的 F1/Spanner、阿里的 [OceanBase](https://open.oceanbase.com/)、PingCAP 的 [TiDB](https://pingcap.com/zh/product-community/) 。
## 什么是元组, 码, 候选码, 主码, 外码, 主属性, 非主属性?
-- **元组**:元组(tuple)是关系数据库中的基本概念,关系是一张表,表中的每行(即数据库中的每条记录)就是一个元组,每列就是一个属性。 在二维表里,元组也称为行。
-- **码**:码就是能唯一标识实体的属性,对应表中的列。
-- **候选码**:若关系中的某一属性或属性组的值能唯一的标识一个元组,而其任何、子集都不能再标识,则称该属性组为候选码。例如:在学生实体中,“学号”是能唯一的区分学生实体的,同时又假设“姓名”、“班级”的属性组合足以区分学生实体,那么{学号}和{姓名,班级}都是候选码。
-- **主码** : 主码也叫主键。主码是从候选码中选出来的。 一个实体集中只能有一个主码,但可以有多个候选码。
-- **外码** : 外码也叫外键。如果一个关系中的一个属性是另外一个关系中的主码则这个属性为外码。
-- **主属性**:候选码中出现过的属性称为主属性。比如关系 工人(工号,身份证号,姓名,性别,部门). 显然工号和身份证号都能够唯一标示这个关系,所以都是候选码。工号、身份证号这两个属性就是主属性。如果主码是一个属性组,那么属性组中的属性都是主属性。
-- **非主属性:** 不包含在任何一个候选码中的属性称为非主属性。比如在关系——学生(学号,姓名,年龄,性别,班级)中,主码是“学号”,那么其他的“姓名”、“年龄”、“性别”、“班级”就都可以称为非主属性。
+在关系型数据库理论中,理解元组、码、候选码、主码、外码、主属性和非主属性这些核心概念,对于数据库设计和规范化至关重要。这些概念构成了关系数据库的理论基础。
+
+```mermaid
+graph TD
+ A[关系数据库概念] --> B[数据组织]
+ A --> C[码的类型]
+ A --> D[属性分类]
+
+ B --> B1[元组
表中的行记录]
+ B --> B2[属性
表中的列]
+
+ C --> C1[码
唯一标识]
+ C1 --> C2[候选码
最小唯一标识集]
+ C2 --> C3[主码
选定的候选码]
+ C1 --> C4[外码
引用其他表主码]
+
+ D --> D1[主属性
候选码中的属性]
+ D --> D2[非主属性
不在候选码中的属性]
+
+ C3 -.关联.-> C4
+ C2 -.构成.-> D1
+
+ style A fill:#4CA497,stroke:#00838F,stroke-width:3px,color:#fff
+ style B fill:#00838F,stroke:#005D7B,stroke-width:2px,color:#fff
+ style C fill:#E99151,stroke:#005D7B,stroke-width:2px,color:#fff
+ style D fill:#005D7B,stroke:#00838F,stroke-width:2px,color:#fff
+
+ style B1 fill:#E4C189,stroke:#00838F,stroke-width:1px
+ style B2 fill:#E4C189,stroke:#00838F,stroke-width:1px
+
+ style C1 fill:#E4C189,stroke:#E99151,stroke-width:1px
+ style C2 fill:#E4C189,stroke:#E99151,stroke-width:1px
+ style C3 fill:#C44545,stroke:#005D7B,stroke-width:2px,color:#fff
+ style C4 fill:#E4C189,stroke:#E99151,stroke-width:1px
+
+ style D1 fill:#E4C189,stroke:#005D7B,stroke-width:1px
+ style D2 fill:#E4C189,stroke:#005D7B,stroke-width:1px
+```
+
+### 基础概念
+
+- **元组(Tuple):** 元组是关系数据库中的基本单位,在二维表中对应一行记录。每个元组包含了一个实体的完整信息。例如,在学生表中,每个学生的完整信息(学号、姓名、年龄等)构成一个元组。
+- **码(Key):** 码是能够唯一标识关系中元组的一个或多个属性的集合。码的主要作用是保证数据的唯一性和完整性。
+
+### 码的分类
+
+- **候选码(Candidate Key):** 候选码是能够唯一标识元组的最小属性集合,其任何真子集都不能唯一标识元组。一个关系可能有多个候选码。例如,在学生表中,如果"学号"能唯一标识学生,同时"身份证号"也能唯一标识学生,那么{学号}和{身份证号}都是候选码。
+- **主码/主键(Primary Key):** 主码是从候选码中选择的一个,用于唯一标识关系中的元组。每个关系只能有一个主码,但可以有多个候选码。选择主码时通常考虑:简单性、稳定性、无业务含义等因素。
+- **外码/外键(Foreign Key):** 外码是一个关系中的属性或属性组,它对应另一个关系的主码。外码用于建立和维护两个关系之间的联系,是实现参照完整性的重要机制。例如,在选课表中的"学号"如果引用学生表的主码"学号",则选课表中的"学号"就是外码。
+
+### 属性分类
+
+- **主属性(Prime Attribute):** 主属性是包含在任何一个候选码中的属性。如果一个关系有多个候选码,那么这些候选码中出现的所有属性都是主属性。例如,工人关系(工号,身份证号,姓名,性别,部门)中,如果{工号}和{身份证号}都是候选码,那么"工号"和"身份证号"都是主属性。
+- **非主属性(Non-prime Attribute):** 非主属性是不包含在任何候选码中的属性。这些属性完全依赖于候选码来确定其值。在上述工人关系中,"姓名"、"性别"、"部门"都是非主属性。
## 什么是 ER 图?
@@ -40,7 +214,37 @@ ER 图由下面 3 个要素组成:
下图是一个学生选课的 ER 图,每个学生可以选若干门课程,同一门课程也可以被若干人选择,所以它们之间的关系是多对多(M: N)。另外,还有其他两种实体之间的关系是:1 对 1(1:1)、1 对多(1: N)。
-
+```mermaid
+erDiagram
+ STUDENT {
+ string student_id PK "学号"
+ string name "姓名"
+ string gender "性别"
+ date birth_date "出生日期"
+ string department "学院名称"
+ }
+
+ COURSE {
+ string course_id PK "课程编号"
+ string course_name "课程名称"
+ string location "课程地点"
+ string instructor "开课教师"
+ float credits "成绩"
+ }
+
+ ENROLLMENT {
+ string student_id FK "学号"
+ string course_id FK "课程编号"
+ float grade "成绩"
+ }
+
+ STUDENT ||--o{ ENROLLMENT : "选课"
+ COURSE ||--o{ ENROLLMENT : "被选"
+
+ style STUDENT fill:#4CA497,stroke:#00838F,stroke-width:2px
+ style COURSE fill:#005D7B,stroke:#00838F,stroke-width:2px
+ style ENROLLMENT fill:#E99151,stroke:#C44545,stroke-width:2px
+```
## 数据库范式了解吗?
@@ -65,7 +269,7 @@ ER 图由下面 3 个要素组成:
- **函数依赖(functional dependency)**:若在一张表中,在属性(或属性组)X 的值确定的情况下,必定能确定属性 Y 的值,那么就可以说 Y 函数依赖于 X,写作 X → Y。
- **部分函数依赖(partial functional dependency)**:如果 X→Y,并且存在 X 的一个真子集 X0,使得 X0→Y,则称 Y 对 X 部分函数依赖。比如学生基本信息表 R 中(学号,身份证号,姓名)当然学号属性取值是唯一的,在 R 关系中,(学号,身份证号)->(姓名),(学号)->(姓名),(身份证号)->(姓名);所以姓名部分函数依赖于(学号,身份证号);
- **完全函数依赖(Full functional dependency)**:在一个关系中,若某个非主属性数据项依赖于全部关键字称之为完全函数依赖。比如学生基本信息表 R(学号,班级,姓名)假设不同的班级学号有相同的,班级内学号不能相同,在 R 关系中,(学号,班级)->(姓名),但是(学号)->(姓名)不成立,(班级)->(姓名)不成立,所以姓名完全函数依赖与(学号,班级);
-- **传递函数依赖**:在关系模式 R(U)中,设 X,Y,Z 是 U 的不同的属性子集,如果 X 确定 Y、Y 确定 Z,且有 X 不包含 Y,Y 不确定 X,(X∪Y)∩Z=空集合,则称 Z 传递函数依赖(transitive functional dependency) 于 X。传递函数依赖会导致数据冗余和异常。传递函数依赖的 Y 和 Z 子集往往同属于某一个事物,因此可将其合并放到一个表中。比如在关系 R(学号 , 姓名, 系名,系主任)中,学号 → 系名,系名 → 系主任,所以存在非主属性系主任对于学号的传递函数依赖。。
+- **传递函数依赖**:在关系模式 R(U)中,设 X,Y,Z 是 U 的不同的属性子集,如果 X 确定 Y、Y 确定 Z,且有 X 不包含 Y,Y 不确定 X,(X∪Y)∩Z=空集合,则称 Z 传递函数依赖(transitive functional dependency) 于 X。传递函数依赖会导致数据冗余和异常。传递函数依赖的 Y 和 Z 子集往往同属于某一个事物,因此可将其合并放到一个表中。比如在关系 R(学号 , 姓名, 系名,系主任)中,学号 → 系名,系名 → 系主任,所以存在非主属性系主任对于学号的传递函数依赖。
### 3NF(第三范式)
@@ -73,8 +277,20 @@ ER 图由下面 3 个要素组成:
## 主键和外键有什么区别?
-- **主键(主码)**:主键用于唯一标识一个元组,不能有重复,不允许为空。一个表只能有一个主键。
-- **外键(外码)**:外键用来和其他表建立联系用,外键是另一表的主键,外键是可以有重复的,可以是空值。一个表可以有多个外键。
+从定义和属性上看,它们的区别是:
+
+- **主键 (Primary Key):** 它的核心作用是唯一标识表中的每一行数据。因此,主键列的值必须是唯一的 (Unique) 且不能为空 (Not Null)。一张表只能有一个主键。主键保证了实体完整性。
+- **外键 (Foreign Key):** 它的核心作用是建立并强制两张表之间的关联关系。一张表中的外键列,其值必须对应另一张表中某行的候选键值(通常是主键,也可以是唯一键),或者是一个 NULL 值。因此,外键的值可以重复,也可以为空。一张表可以有多个外键,分别关联到不同的表。外键保证了引用完整性。
+
+用一个简单的电商例子来说明:假设我们有两张表:`users` (用户表) 和 `orders` (订单表)。
+
+- 在 `users` 表中,`user_id` 列是**主键**。每个用户的 `user_id` 都是独一无二的,我们用它来区分张三和李四。
+- 在 `orders` 表中,`order_id` 是它自己的**主键**。同时,它会有一个 `user_id` 列,这个列就是一个**外键**,它引用了 `users` 表的 `user_id` 主键。
+
+这个外键约束就保证了:
+
+1. 你不能创建一个不属于任何已知用户的订单( `user_id` 在 `users` 表中不存在)。
+2. 你不能删除一个已经下了订单的用户(除非设置了级联删除等特殊规则)。
## 为什么不推荐使用外键与级联?
@@ -86,8 +302,8 @@ ER 图由下面 3 个要素组成:
为什么不要用外键呢?大部分人可能会这样回答:
-1. **增加了复杂性:** a. 每次做 DELETE 或者 UPDATE 都必须考虑外键约束,会导致开发的时候很痛苦, 测试数据极为不方便; b. 外键的主从关系是定的,假如那天需求有变化,数据库中的这个字段根本不需要和其他表有关联的话就会增加很多麻烦。
-2. **增加了额外工作**:数据库需要增加维护外键的工作,比如当我们做一些涉及外键字段的增,删,更新操作之后,需要触发相关操作去检查,保证数据的的一致性和正确性,这样会不得不消耗资源;(个人觉得这个不是不用外键的原因,因为即使你不使用外键,你在应用层面也还是要保证的。所以,我觉得这个影响可以忽略不计。)
+1. **增加了复杂性:** a. 每次做 DELETE 或者 UPDATE 都必须考虑外键约束,会导致开发的时候很痛苦, 测试数据极为不方便; b. 外键的主从关系是定的,假如哪天需求有变化,数据库中的这个字段根本不需要和其他表有关联的话就会增加很多麻烦。
+2. **增加了额外工作**:数据库需要增加维护外键的工作,比如当我们做一些涉及外键字段的增,删,更新操作之后,需要触发相关操作去检查,保证数据的一致性和正确性,这样会不得不消耗数据库资源。如果在应用层面去维护的话,可以减小数据库压力;
3. **对分库分表不友好**:因为分库分表下外键是无法生效的。
4. ……
@@ -101,53 +317,239 @@ ER 图由下面 3 个要素组成:
## 什么是存储过程?
-我们可以把存储过程看成是一些 SQL 语句的集合,中间加了点逻辑控制语句。存储过程在业务比较复杂的时候是非常实用的,比如很多时候我们完成一个操作可能需要写一大串 SQL 语句,这时候我们就可以写有一个存储过程,这样也方便了我们下一次的调用。存储过程一旦调试完成通过后就能稳定运行,另外,使用存储过程比单纯 SQL 语句执行要快,因为存储过程是预编译过的。
+```mermaid
+graph LR
+ A[存储过程] --> B[定义特征]
+ A --> C[优势]
+ A --> D[劣势]
+ A --> E[应用现状]
+
+ B --> B1[SQL语句集合]
+ B --> B2[包含逻辑控制]
+ B --> B3[预编译机制]
+
+ C --> C1[执行速度快]
+ C --> C2[运行稳定]
+ C --> C3[简化复杂操作]
+
+ D --> D1[调试困难]
+ D --> D2[扩展性差]
+ D --> D3[无移植性]
+ D --> D4[占用数据库资源]
+
+ E --> E1[传统企业
使用较多]
+ E --> E2[互联网公司
很少使用]
+ E --> E3[阿里规范
明确禁用]
+
+ style A fill:#4CA497,stroke:#00838F,stroke-width:3px,color:#fff
+ style B fill:#00838F,stroke:#005D7B,stroke-width:2px,color:#fff
+ style C fill:#E99151,stroke:#C44545,stroke-width:2px,color:#fff
+ style D fill:#C44545,stroke:#005D7B,stroke-width:2px,color:#fff
+ style E fill:#005D7B,stroke:#00838F,stroke-width:2px,color:#fff
+
+ style B1 fill:#E4C189,stroke:#00838F,stroke-width:1px
+ style B2 fill:#E4C189,stroke:#00838F,stroke-width:1px
+ style B3 fill:#E4C189,stroke:#00838F,stroke-width:1px
-存储过程在互联网公司应用不多,因为存储过程难以调试和扩展,而且没有移植性,还会消耗数据库资源。
+ style C1 fill:#E4C189,stroke:#E99151,stroke-width:1px
+ style C2 fill:#E4C189,stroke:#E99151,stroke-width:1px
+ style C3 fill:#E4C189,stroke:#E99151,stroke-width:1px
-阿里巴巴 Java 开发手册里要求禁止使用存储过程。
+ style D1 fill:#E4C189,stroke:#C44545,stroke-width:1px
+ style D2 fill:#E4C189,stroke:#C44545,stroke-width:1px
+ style D3 fill:#E4C189,stroke:#C44545,stroke-width:1px
+ style D4 fill:#E4C189,stroke:#C44545,stroke-width:1px
+
+ style E1 fill:#E4C189,stroke:#005D7B,stroke-width:1px
+ style E2 fill:#E4C189,stroke:#005D7B,stroke-width:1px
+ style E3 fill:#E4C189,stroke:#005D7B,stroke-width:1px
+```
+
+存储过程是数据库中预编译的SQL语句集合,它将多条SQL语句和程序逻辑控制语句(如IF-ELSE、WHILE循环等)封装在一起,形成一个可重复调用的数据库对象。
+
+**存储过程的优势:**
+
+在传统企业级应用中,存储过程具有一定的实用价值。当业务逻辑复杂时,需要执行大量SQL语句才能完成一个业务操作,此时可以将这些语句封装成存储过程,简化调用过程。由于存储过程在创建时就已经编译并存储在数据库中,执行时无需重新编译,因此相比动态SQL语句具有更好的执行性能。同时,一旦存储过程调试完成,其运行相对稳定可靠。
+
+**存储过程的局限性:**
+
+然而,在现代互联网架构中,存储过程的使用越来越少。主要原因包括:调试困难,缺乏成熟的调试工具;扩展性差,修改业务逻辑需要直接修改数据库对象;移植性差,不同数据库系统的存储过程语法差异较大;占用数据库资源,增加数据库服务器负担;版本管理困难,不便于进行代码版本控制。
+
+**行业规范:**
+
+基于以上原因,许多互联网公司的开发规范中明确限制或禁止使用存储过程。例如,《阿里巴巴Java开发手册》中明确规定禁止使用存储过程,推荐将业务逻辑放在应用层实现,保持数据库的简单和高效。

-## drop、delete 与 truncate 区别?
+## DROP、DELETE、TRUNCATE 有什么区别?
-### 用法不同
+在数据库操作中,`DROP`、`DELETE` 和 `TRUNCATE` 是三个常用的数据删除命令,它们在功能、性能和使用场景上存在显著差异。
-- `drop`(丢弃数据): `drop table 表名` ,直接将表都删除掉,在删除表的时候使用。
-- `truncate` (清空数据) : `truncate table 表名` ,只删除表中的数据,再插入数据的时候自增长 id 又从 1 开始,在清空表中数据的时候使用。
-- `delete`(删除数据) : `delete from 表名 where 列名=值`,删除某一行的数据,如果不加 `where` 子句和`truncate table 表名`作用类似。
+**DROP命令:**
-`truncate` 和不带 `where`子句的 `delete`、以及 `drop` 都会删除表内的数据,但是 **`truncate` 和 `delete` 只删除数据不删除表的结构(定义),执行 `drop` 语句,此表的结构也会删除,也就是执行`drop` 之后对应的表不复存在。**
+- 语法:`DROP TABLE 表名`
+- 作用:完全删除整个表,包括表结构、数据、索引、触发器、约束等所有相关对象
+- 使用场景:当表不再需要时使用
-### 属于不同的数据库语言
+**TRUNCATE命令:**
-`truncate` 和 `drop` 属于 DDL(数据定义语言)语句,操作立即生效,原数据不放到 rollback segment 中,不能回滚,操作不触发 trigger。而 `delete` 语句是 DML (数据库操作语言)语句,这个操作会放到 rollback segment 中,事务提交之后才生效。
+- 语法:`TRUNCATE TABLE 表名`
+- 作用:清空表中所有数据,但保留表结构
+- 特点:自增长字段(AUTO_INCREMENT)会重置为初始值(通常为1)
+- 使用场景:需要快速清空表数据但保留表结构时使用
-**DML 语句和 DDL 语句区别:**
+**DELETE命令:**
-- DML 是数据库操作语言(Data Manipulation Language)的缩写,是指对数据库中表记录的操作,主要包括表记录的插入、更新、删除和查询,是开发人员日常使用最频繁的操作。
-- DDL (Data Definition Language)是数据定义语言的缩写,简单来说,就是对数据库内部的对象进行创建、删除、修改的操作语言。它和 DML 语言的最大区别是 DML 只是对表内部数据的操作,而不涉及到表的定义、结构的修改,更不会涉及到其他对象。DDL 语句更多的被数据库管理员(DBA)所使用,一般的开发人员很少使用。
+- 语法:`DELETE FROM 表名 WHERE 条件`
+- 作用:删除满足条件的数据行,不带WHERE子句时删除所有数据
+- 特点:自增长字段不会重置,继续从之前的值递增
+- 使用场景:需要有选择地删除部分数据时使用
+
+`TRUNCATE` 和不带 `WHERE`子句的 `DELETE`、以及 `DROP` 都会删除表内的数据,但是 **`TRUNCATE` 和 `DELETE` 只删除数据不删除表的结构(定义),执行 `DROP` 语句,此表的结构也会删除,也就是执行`DROP` 之后对应的表不复存在。**
-另外,由于`select`不会对表进行破坏,所以有的地方也会把`select`单独区分开叫做数据库查询语言 DQL(Data Query Language)。
+### 对表结构的影响
-### 执行速度不同
+- `DROP`:删除表结构和所有数据,表将不复存在
+- `TRUNCATE`:仅删除数据,保留表结构和定义
+- `DELETE`:仅删除数据,保留表结构和定义
-一般来说:`drop` > `truncate` > `delete`(这个我没有实际测试过)。
+### 触发器
-- `delete`命令执行的时候会产生数据库的`binlog`日志,而日志记录是需要消耗时间的,但是也有个好处方便数据回滚恢复。
-- `truncate`命令执行的时候不会产生数据库日志,因此比`delete`要快。除此之外,还会把表的自增值重置和索引恢复到初始大小等。
-- `drop`命令会把表占用的空间全部释放掉。
+- `DELETE` 操作会触发相关的DELETE触发器
+- `TRUNCATE` 和 `DROP` 不会触发DELETE触发器
+
+### 事务和回滚
+
+- `DROP` 和 `TRUNCATE` 属于DDL操作,执行后立即生效,不能回滚
+- `DELETE` 属于DML操作,可以回滚(在事务中)
+
+### 执行速度
+
+一般来说:`DROP` > `TRUNCATE` > `DELETE`(这个我没有实际测试过)。
+
+- `DELETE`命令执行的时候会产生数据库的`binlog`日志,而日志记录是需要消耗时间的,但是也有个好处方便数据回滚恢复。
+- `TRUNCATE`命令执行的时候不会产生数据库日志,因此比`DELETE`要快。除此之外,还会把表的自增值重置和索引恢复到初始大小等。
+- `DROP`命令会把表占用的空间全部释放掉。
Tips:你应该更多地关注在使用场景上,而不是执行效率。
+## DML 语句和 DDL 语句区别是?
+
+- DML 是数据库操作语言(Data Manipulation Language)的缩写,是指对数据库中表记录的操作,主要包括表记录的插入、更新、删除和查询,是开发人员日常使用最频繁的操作。
+- DDL (Data Definition Language)是数据定义语言的缩写,简单来说,就是对数据库内部的对象进行创建、删除、修改的操作语言。它和 DML 语言的最大区别是 DML 只是对表内部数据的操作,而不涉及到表的定义、结构的修改,更不会涉及到其他对象。DDL 语句更多的被数据库管理员(DBA)所使用,一般的开发人员很少使用。
+
+另外,由于`SELECT`不会对表进行破坏,所以有的地方也会把`SELECT`单独区分开叫做数据库查询语言 DQL(Data Query Language)。
+
## 数据库设计通常分为哪几步?
-1. **需求分析** : 分析用户的需求,包括数据、功能和性能需求。
-2. **概念结构设计** : 主要采用 E-R 模型进行设计,包括画 E-R 图。
-3. **逻辑结构设计** : 通过将 E-R 图转换成表,实现从 E-R 模型到关系模型的转换。
-4. **物理结构设计** : 主要是为所设计的数据库选择合适的存储结构和存取路径。
-5. **数据库实施** : 包括编程、测试和试运行
-6. **数据库的运行和维护** : 系统的运行与数据库的日常维护。
+```mermaid
+graph TD
+ A[数据库设计流程] --> B[1.需求分析]
+ B --> C[2.概念结构设计]
+ C --> D[3.逻辑结构设计]
+ D --> E[4.物理结构设计]
+ E --> F[5.数据库实施]
+ F --> G[6.运行和维护]
+
+ B --> B1[数据需求
功能需求
性能需求]
+ C --> C1[E-R建模
实体关系图]
+ D --> D1[关系模型
表结构设计
规范化]
+ E --> E1[存储结构
索引设计
分区策略]
+ F --> F1[编程开发
测试部署
数据迁移]
+ G --> G1[性能监控
备份恢复
优化调整]
+
+ G -.反馈.-> B
+
+ style A fill:#4CA497,stroke:#00838F,stroke-width:3px,color:#fff
+ style B fill:#00838F,stroke:#005D7B,stroke-width:2px,color:#fff
+ style C fill:#E99151,stroke:#005D7B,stroke-width:2px,color:#fff
+ style D fill:#005D7B,stroke:#00838F,stroke-width:2px,color:#fff
+ style E fill:#C44545,stroke:#005D7B,stroke-width:2px,color:#fff
+ style F fill:#E99151,stroke:#005D7B,stroke-width:2px,color:#fff
+ style G fill:#00838F,stroke:#005D7B,stroke-width:2px,color:#fff
+
+ style B1 fill:#E4C189,stroke:#00838F,stroke-width:1px
+ style C1 fill:#E4C189,stroke:#E99151,stroke-width:1px
+ style D1 fill:#E4C189,stroke:#005D7B,stroke-width:1px
+ style E1 fill:#E4C189,stroke:#C44545,stroke-width:1px
+ style F1 fill:#E4C189,stroke:#E99151,stroke-width:1px
+ style G1 fill:#E4C189,stroke:#00838F,stroke-width:1px
+```
+
+### 1. 需求分析阶段
+
+**目标:** 深入了解和分析用户需求,明确系统边界
+**主要工作:**
+
+- 收集和分析数据需求:确定需要存储哪些数据,数据量大小,数据更新频率
+- 明确功能需求:系统需要支持哪些业务操作,各操作的优先级
+- 定义性能需求:响应时间要求,并发用户数,数据吞吐量
+- 确定安全需求:数据访问权限,加密要求,审计要求
+ **产出物:** 需求规格说明书、数据字典初稿
+
+### 2. 概念结构设计阶段
+
+**目标:** 将需求转化为信息世界的概念模型
+**主要工作:**
+
+- 识别实体:确定系统中的主要对象
+- 定义属性:明确每个实体的特征
+- 建立联系:确定实体之间的关系(一对一、一对多、多对多)
+- 绘制E-R图(实体-关系图)
+ **产出物:** E-R图、概念数据模型文档
+
+### 3. 逻辑结构设计阶段
+
+**目标:** 将概念模型转换为特定DBMS支持的逻辑模型
+**主要工作:**
+
+- E-R图向关系模型转换:将实体转换为表,属性转换为字段
+- 规范化处理:通过范式化消除数据冗余和更新异常(通常达到3NF)
+- 定义完整性约束:主键、外键、唯一性约束、检查约束
+- 优化模型:根据性能需求进行适当的反规范化
+ **产出物:** 逻辑数据模型、表结构设计文档
+
+### 4. 物理结构设计阶段
+
+**目标:** 确定数据的物理存储方案和访问方法
+**主要工作:**
+
+- 选择存储引擎:如MySQL的InnoDB、MyISAM等
+- 设计索引策略:确定需要建立的索引类型和字段
+- 分区设计:对大表进行分区以提高性能
+- 确定存储参数:表空间大小、数据文件位置、缓冲区配置
+- 制定备份策略:全量备份、增量备份的频率和方式
+ **产出物:** 物理设计文档、索引设计方案
+
+### 5. 数据库实施阶段
+
+**目标:** 将设计转化为实际运行的数据库系统
+**主要工作:**
+
+- 创建数据库和表结构:编写和执行DDL语句
+- 开发存储过程和触发器(如需要)
+- 编写应用程序接口
+- 导入初始数据
+- 系统集成测试:功能测试、性能测试、压力测试
+- 用户培训和文档编写
+ **产出物:** 数据库脚本、测试报告、用户手册
+
+### 6. 运行和维护阶段
+
+**目标:** 确保数据库系统稳定高效运行
+**主要工作:**
+
+- 日常监控:性能监控、空间监控、错误日志分析
+- 性能优化:查询优化、索引调整、参数调优
+- 数据备份和恢复:定期备份、恢复演练
+- 安全管理:权限管理、安全补丁更新、审计
+- 容量规划:预测数据增长,提前扩容
+- 变更管理:需求变更的评估和实施
+ **产出物:** 运维报告、优化方案、变更记录
+
+### 设计原则
+
+在整个设计过程中应遵循:数据独立性原则、完整性原则、安全性原则、可扩展性原则和标准化原则。
## 参考
diff --git a/docs/database/character-set.md b/docs/database/character-set.md
index e462a5c97e3..2e65c74a5b6 100644
--- a/docs/database/character-set.md
+++ b/docs/database/character-set.md
@@ -1,8 +1,13 @@
---
title: 字符集详解
+description: 详解字符集与字符编码原理,深入分析ASCII、GB2312、GBK、UTF-8、UTF-16等常见编码,解释MySQL中utf8与utf8mb4的区别以及emoji存储问题的解决方案。
category: 数据库
tag:
- 数据库基础
+head:
+ - - meta
+ - name: keywords
+ content: 字符集,字符编码,UTF-8,UTF-16,GBK,GB2312,utf8mb4,ASCII,Unicode,MySQL字符集,emoji存储
---
MySQL 字符编码集中有两套 UTF-8 编码实现:**`utf8`** 和 **`utf8mb4`**。
diff --git a/docs/database/elasticsearch/elasticsearch-questions-01.md b/docs/database/elasticsearch/elasticsearch-questions-01.md
index fe6daa6926c..2db51f16d7a 100644
--- a/docs/database/elasticsearch/elasticsearch-questions-01.md
+++ b/docs/database/elasticsearch/elasticsearch-questions-01.md
@@ -1,9 +1,14 @@
---
title: Elasticsearch常见面试题总结(付费)
+description: Elasticsearch常见面试题总结,涵盖ES核心概念、倒排索引原理、分片与副本机制、查询DSL、聚合分析、集群调优等高频面试知识点。
category: 数据库
tag:
- NoSQL
- Elasticsearch
+head:
+ - - meta
+ - name: keywords
+ content: Elasticsearch面试题,ES索引,倒排索引,分片副本,全文搜索,聚合查询,Lucene,ELK
---
**Elasticsearch** 相关的面试题为我的[知识星球](../../about-the-author/zhishixingqiu-two-years.md)(点击链接即可查看详细介绍以及加入方法)专属内容,已经整理到了[《Java 面试指北》](../../zhuanlan/java-mian-shi-zhi-bei.md)中。
@@ -11,5 +16,3 @@ tag:

-
-
diff --git a/docs/database/mongodb/mongodb-questions-01.md b/docs/database/mongodb/mongodb-questions-01.md
index 860b1f2b248..f60be69b0fb 100644
--- a/docs/database/mongodb/mongodb-questions-01.md
+++ b/docs/database/mongodb/mongodb-questions-01.md
@@ -1,9 +1,14 @@
---
title: MongoDB常见面试题总结(上)
+description: MongoDB常见面试题总结上篇,详解MongoDB基础概念、存储结构、数据类型、副本集高可用、分片集群水平扩展等核心知识点,助力后端面试准备。
category: 数据库
tag:
- NoSQL
- MongoDB
+head:
+ - - meta
+ - name: keywords
+ content: MongoDB面试题,文档数据库,BSON,副本集,分片集群,MongoDB索引,WiredTiger,聚合管道
---
> 少部分内容参考了 MongoDB 官方文档的描述,在此说明一下。
@@ -149,7 +154,7 @@ WiredTiger maintains a table's data in memory using a data structure called a B-
此外,WiredTiger 还支持 [LSM(Log Structured Merge)](https://source.wiredtiger.com/3.1.0/lsm.html) 树作为存储结构,MongoDB 在使用 WiredTiger 作为存储引擎时,默认使用的是 B+ 树。
-如果想要了解 MongoDB 使用 B 树的原因,可以看看这篇文章:[为什么 MongoDB 使用 B 树?](https://mp.weixin.qq.com/s/mMWdpbYRiT6LQcdaj4hgXQ)。
+如果想要了解 MongoDB 使用 B+ 树的原因,可以看看这篇文章:[【驳斥八股文系列】别瞎分析了,MongoDB 使用的是 B+ 树,不是你们以为的 B 树](https://zhuanlan.zhihu.com/p/519658576)。
使用 B+ 树时,WiredTiger 以 **page** 为基本单位往磁盘读写数据。B+ 树的每个节点为一个 page,共有三种类型的 page:
diff --git a/docs/database/mongodb/mongodb-questions-02.md b/docs/database/mongodb/mongodb-questions-02.md
index dcd90d72c4d..4a7af767d16 100644
--- a/docs/database/mongodb/mongodb-questions-02.md
+++ b/docs/database/mongodb/mongodb-questions-02.md
@@ -1,9 +1,14 @@
---
title: MongoDB常见面试题总结(下)
+description: MongoDB常见面试题总结下篇,深入讲解MongoDB各类索引(单字段、复合、多键、文本、地理位置、TTL)的原理、使用场景和查询优化技巧。
category: 数据库
tag:
- NoSQL
- MongoDB
+head:
+ - - meta
+ - name: keywords
+ content: MongoDB索引,复合索引,多键索引,文本索引,地理位置索引,TTL索引,MongoDB查询优化,索引设计
---
## MongoDB 索引
diff --git a/docs/database/mysql/a-thousand-lines-of-mysql-study-notes.md b/docs/database/mysql/a-thousand-lines-of-mysql-study-notes.md
index 42384aaec7e..b62c108458b 100644
--- a/docs/database/mysql/a-thousand-lines-of-mysql-study-notes.md
+++ b/docs/database/mysql/a-thousand-lines-of-mysql-study-notes.md
@@ -1,8 +1,13 @@
---
title: 一千行 MySQL 学习笔记
+description: 一千行MySQL学习笔记精华总结,涵盖数据库操作、表管理、SQL语法、索引、视图、存储过程、触发器等核心知识点,适合快速查阅和复习。
category: 数据库
tag:
- MySQL
+head:
+ - - meta
+ - name: keywords
+ content: MySQL学习笔记,MySQL命令大全,SQL语法,数据库操作,表操作,索引,视图,存储过程,触发器
---
> 原文地址: ,JavaGuide 对本文进行了简答排版,新增了目录。
@@ -621,7 +626,7 @@ CREATE [OR REPLACE] [ALGORITHM = {UNDEFINED | MERGE | TEMPTABLE}] VIEW view_name
### 锁表
-```mysql
+```sql
/* 锁表 */
表锁定只用于防止其它客户端进行不正当地读取和写入
MyISAM 支持表锁,InnoDB 支持行锁
@@ -633,7 +638,7 @@ MyISAM 支持表锁,InnoDB 支持行锁
### 触发器
-```mysql
+```sql
/* 触发器 */ ------------------
触发程序是与表有关的命名数据库对象,当该表出现特定事件时,将激活该对象
监听:记录的增加、修改、删除。
@@ -686,7 +691,7 @@ end
### SQL 编程
-```mysql
+```sql
/* SQL编程 */ ------------------
--// 局部变量 ----------
-- 变量声明
@@ -821,7 +826,7 @@ INOUT,表示混合型
### 存储过程
-```mysql
+```sql
/* 存储过程 */ ------------------
存储过程是一段可执行性代码的集合。相比函数,更偏向于业务逻辑。
调用:CALL 过程名
@@ -842,7 +847,7 @@ END
### 用户和权限管理
-```mysql
+```sql
/* 用户和权限管理 */ ------------------
-- root密码重置
1. 停止MySQL服务
@@ -924,7 +929,7 @@ GRANT OPTION -- 允许授予权限
### 表维护
-```mysql
+```sql
/* 表维护 */
-- 分析和存储表的关键字分布
ANALYZE [LOCAL | NO_WRITE_TO_BINLOG] TABLE 表名 ...
@@ -937,7 +942,7 @@ OPTIMIZE [LOCAL | NO_WRITE_TO_BINLOG] TABLE tbl_name [, tbl_name] ...
### 杂项
-```mysql
+```sql
/* 杂项 */ ------------------
1. 可用反引号(`)为标识符(库名、表名、字段名、索引、别名)包裹,以避免与关键字重名!中文也可以作为标识符!
2. 每个库目录存在一个保存当前数据库的选项文件db.opt。
diff --git a/docs/database/mysql/how-sql-executed-in-mysql.md b/docs/database/mysql/how-sql-executed-in-mysql.md
index d9f1d07d539..45a1d8d79ef 100644
--- a/docs/database/mysql/how-sql-executed-in-mysql.md
+++ b/docs/database/mysql/how-sql-executed-in-mysql.md
@@ -1,8 +1,13 @@
---
title: SQL语句在MySQL中的执行过程
+description: 详解SQL语句在MySQL中的完整执行流程,从连接器身份认证、查询缓存、分析器语法解析、优化器生成执行计划到执行器调用存储引擎的全过程。
category: 数据库
tag:
- MySQL
+head:
+ - - meta
+ - name: keywords
+ content: MySQL执行流程,SQL执行过程,连接器,解析器,优化器,执行器,Server层,存储引擎,InnoDB
---
> 本文来自[木木匠](https://github.com/kinglaw1204)投稿。
@@ -100,9 +105,10 @@ update tb_student A set A.age='19' where A.name=' 张三 ';
我们来给张三修改下年龄,在实际数据库肯定不会设置年龄这个字段的,不然要被技术负责人打的。其实这条语句也基本上会沿着上一个查询的流程走,只不过执行更新的时候肯定要记录日志啦,这就会引入日志模块了,MySQL 自带的日志模块是 **binlog(归档日志)** ,所有的存储引擎都可以使用,我们常用的 InnoDB 引擎还自带了一个日志模块 **redo log(重做日志)**,我们就以 InnoDB 模式下来探讨这个语句的执行流程。流程如下:
-- 先查询到张三这一条数据,如果有缓存,也是会用到缓存。
+- 先查询到张三这一条数据,不会走查询缓存,因为查询缓存的设计规则就是只服务于查询类语句。
- 然后拿到查询的语句,把 age 改为 19,然后调用引擎 API 接口,写入这一行数据,InnoDB 引擎把数据保存在内存中,同时记录 redo log,此时 redo log 进入 prepare 状态,然后告诉执行器,执行完成了,随时可以提交。
-- 执行器收到通知后记录 binlog,然后调用引擎接口,提交 redo log 为提交状态。
+- 执行器收到通知后记录 binlog,然后清空该表的查询缓存。此时清空能保证后续的 SELECT 不会读到旧缓存 —— 因为事务马上就要最终提交,数据即将变成最新状态,缓存失效的时机刚好匹配数据的实际更新。
+- 执行器调用引擎接口 ,提交 redo log 为 commit 状态。
- 更新完成。
**这里肯定有同学会问,为什么要用两个日志模块,用一个日志模块不行吗?**
@@ -114,11 +120,12 @@ update tb_student A set A.age='19' where A.name=' 张三 ';
- **先写 redo log 直接提交,然后写 binlog**,假设写完 redo log 后,机器挂了,binlog 日志没有被写入,那么机器重启后,这台机器会通过 redo log 恢复数据,但是这个时候 binlog 并没有记录该数据,后续进行机器备份的时候,就会丢失这一条数据,同时主从同步也会丢失这一条数据。
- **先写 binlog,然后写 redo log**,假设写完了 binlog,机器异常重启了,由于没有 redo log,本机是无法恢复这一条记录的,但是 binlog 又有记录,那么和上面同样的道理,就会产生数据不一致的情况。
-如果采用 redo log 两阶段提交的方式就不一样了,写完 binlog 后,然后再提交 redo log 就会防止出现上述的问题,从而保证了数据的一致性。那么问题来了,有没有一个极端的情况呢?假设 redo log 处于预提交状态,binlog 也已经写完了,这个时候发生了异常重启会怎么样呢?
+如果采用 redo log 两阶段提交的方式就不一样了,先写完 redo log,标记为 prepare,紧接着写完 binlog 后,然后再将 redo log 标记为 commit 就可以防止出现上述的问题,从而保证了数据的一致性。
+那么问题来了,有没有一个极端的情况呢?假设 redo log 处于 prepare 状态,binlog 也已经写完了,这个时候发生了异常重启会怎么样呢?
这个就要依赖于 MySQL 的处理机制了,MySQL 的处理过程如下:
-- 判断 redo log 是否完整,如果判断是完整的,就立即提交。
-- 如果 redo log 只是预提交但不是 commit 状态,这个时候就会去判断 binlog 是否完整,如果完整就提交 redo log, 不完整就回滚事务。
+- 判断 redo log 是否为 commit 状态,如果是,说明 binlog 一定已完成刷盘,就立即提交。
+- 如果 redo log 只是 prepare 状态但不是 commit 状态,这个时候就会拿着事物的XID,去 binlog 判断该事物是否完成刷盘,如果是就提交 redo log, 否则就回滚事务。
这样就解决了数据一致性的问题。
diff --git a/docs/database/mysql/images/ACID.drawio b/docs/database/mysql/images/ACID.drawio
deleted file mode 100644
index e8805b8d958..00000000000
--- a/docs/database/mysql/images/ACID.drawio
+++ /dev/null
@@ -1 +0,0 @@
-7Zhdb5swFIZ/jS9b8R24BAJdp1Wamotply6Yj9VgZkyT7tfPBjvAIFK7LcqqNZHAfo99bJ/zxLIDzLA63FDYFHckRRgYWnoA5hYYhq4ZHn8J5XlQPM0chJyWqWw0CrvyB1I9pdqVKWpnDRkhmJXNXExIXaOEzTRIKdnPm2UEz0dtYI4Wwi6BeKl+KVNWDKprbEb9AyrzQo2sO3LBDzB5zCnpajkeMMzYiePYHcwVVL7kQtsCpmQ/kcwImCElhA2l6hAiLGKrwjb0i09Yj/OmqGYv6fD5/ttDl3wtnor7T9S9tRq/C6/swcsTxB1Sy+gny55VgPolIuFEB2awL0qGdg1MhHXPkeBawSoszVmJcUgwoX1f0wp9y9twvWWUPCJlqUmNhKgioonKI2JJISs5hm0ryxmp2cTl8JF6DKsSC/A+IhZQWNYtn/sdqYm070hH+5kWjHGeDNv0+YOHSDxEg/Y6JyTHCDZle52Qqjckbd80zgbvvDj1bxuBHGGZAZmUJ0QZOkwkmZEbRCrEKHepKasp6Xj+pb4fWTRtqRUTDg1LilDynx99jwzwgsTgFUjoK0g4mMmIzthwvndEGa7a/qfN46vpXnMYjbyUi7cPohgEIXBdENnAjYAXi4K/BZ4GIge4GvA3qo2nxgRDnpSTv0pnZovvKTpPcfdyat8+nZs5nYa2pFM3Vuh0zgWncSY4wwmcFghcQWPEnxsQWJeBM4XIzZI/3zrfPISm9a9BaJ4JwtsJhB7wfOBx9jbAd0AQjBCeYm+Q2wbWvz+DNcIHj+fYfrPMSN4JX9lmvUsTbp2J8O2EcM4zL+j9fusB177QGcBN0DuEa9vsxSF0FhD64e12kX2+PjZP8fpxbpJ3KUFc5jWvJjxqiOuBiFbJr4a+NFRlmophVpkaqVMMyMut8Z+dEo8EqDuMvSTHW7vCvB4cXh0vzL1t8q+EGf0E
\ No newline at end of file
diff --git a/docs/database/mysql/images/AID-C.drawio b/docs/database/mysql/images/AID-C.drawio
deleted file mode 100644
index 4ac724c8404..00000000000
--- a/docs/database/mysql/images/AID-C.drawio
+++ /dev/null
@@ -1 +0,0 @@
-5ZjZcpswFIafRpfJAGK9BBvStM1Fm+mS3skglkYgCvKWp68EwoCNM0k6HmdqZwZL/znazvmkIAM4yzc3FSrTOxphAjQl2gA4B5qmKprDv4SybRVHga2QVFkknXrhPnvCXUupLrMI1yNHRilhWTkWQ1oUOGQjDVUVXY/dYkrGo5YowQfCfYjIofoji1jaqrZm9foHnCVpN7JqygUvUPiYVHRZyPGABgMzCAK7Neeo60sutE5RRNcDCfoAzipKWVvKNzNMRGy7sLXtgiPW3bwrXLAXNSBfy+/et6cCXX26ib/o33+pqyvZS822XTxwxMMjq7RiKU1ogYjfq16zZix6VXit9/lMaclFlYu/MWNbmWu0ZJRLKcuJtOJNxn6K5teGrD0MLPON7LmpbLtKwartoJGoPgxtfbOm1rWrHzELU1mJacEClGdEWD9i5lUoK2q+/jtaUGm/p8sqFPNOGeMIagZ0+YNHVTyEQ32dUJoQjMqsvg5p3hjCunEN4rZ3Xhz2b2jecIQ2LqpYcM0q+rgDjzPhHaa1y1E3sWO51OTuQVWC2TN+eusnEj0YQEJzg2mOeQC5Q4UJYtlqvE+Q3G7Jzq9Hjhckda8gUM56hcgSdxtpD8keOBGydZoxfF+iJhZrfiiN4dpts4PsJwTVdUdCRsiMElo1I0B95uqOtcvHwGI2H5m5gd5+3gVTR4lZ4YrhzbM5llYNykNNHuKaIg+tdX8kQkNq6eA41HTlRFzACS5MwmRER4CYf5a0M1zVze7i8VVUp9z0Rl5KxLcL/AB4M2DbwDeA7QMnEAV3DhwF+CawFeBanY/TjQnaPHWd/Buie/DFhvh7E3wvhP2/QNTWx4hah4iq2gSi5qkI1U9E6GxAqA48WyDp86cFPP08hEYI23E4SWho40V8WSRCY0yioZ6bRONEJN4OSHSA4wKHA2gB1wSe15N4DMBWrktUvH0GU5i3PZ7iII5jLZzEPDIXpmFeGubjdwJonhtz80SYzweYc6h5QW1OXgfYxpleCewQT5O4sA3dUC6LRB2+t3/91gGJ7u38gAC+PLZ3ORmls6AF3su9lBDJkoJXQx40zHVPBCsLEXGlIc+iqLmMT3E1vqAPrpzahb0zQmsPHO0QHGfqVvN6bni1/ymnsQ1+L4P+Xw==
\ No newline at end of file
diff --git a/docs/database/mysql/images/concurrency-consistency-issues-dirty-reading.drawio b/docs/database/mysql/images/concurrency-consistency-issues-dirty-reading.drawio
deleted file mode 100644
index 6e4e61ba50c..00000000000
--- a/docs/database/mysql/images/concurrency-consistency-issues-dirty-reading.drawio
+++ /dev/null
@@ -1 +0,0 @@
-7Vpbk6I4FP41eWxLQG6P4KV3e2d2ZrZramYfIwZINxIWYqv96zeBIGKi7Uwp2jOWVZqchJPL+c53TiLAGM5X9znM4o9khhKg92crYIyArpuuzr65YF0JbMOsBFGOZ5VIawSP+BUJYV9IF3iGilZHSkhCcdYWBiRNUUBbMpjnZNnuFpKkPWoGIyQJHgOYyNJveEbjSurodiP/A+EorkfWLLdqmcLgOcrJIhXjAd2YWJPJxKma57DWJRZaxHBGllsiYwyMYU4IrUrz1RAlfGvrbauem+xp3cw7Ryk95oE//x4YTj8YYPr11f7y9OmfuWPdibUUdF3vB5qx7RFVktOYRCSFybiR+uWaEdfaZ7WmzwdCMibUmPAJUboWtoYLSpgopvNEtLIJ5+vv4vmy8i+v9My6OlptN47WohaSlAqlminqEzjHCe/wgKifQ5wWbDkfSUrq/mSRB/yJmFKGKN00PPbFNol/8Q5FLyIkShDMcNELyLxsCIqy6ySstLPitn5T98UIsg2EWYp62H0bX0Md5hGiB/oNqn7cKlsDCAvfIzJHbIdYhxwlkOKXNqih8I1o06/BBysIiPwAXITeF5gsUI36XfwkCXNdjpNljCl6zGC5D0tGHm0UwCKr/DnEK44mP8RJMiQJyUtFRhgiKwiYvKA5eUZ1S0pS9L6w8IJyilYHrSdaN1whuFSzRH3ZMFMtirdIqZad3N7Gzd7ntLejXZm96/nc4kHX8WBwZDwwryseuBJBeHWQ2MHQBzhliWObERIcpawcsN1CzNd97jmYpWaeaJjj2ayCGCrwK5yW+rjlM4KZpbhy0wfmSIGFhA/nb1K0LZ4RSdp1wuWgV0rEssmKxda0MksV4dz1ezzKtEmnqh0NGKH8MzdBo9lU6awfJ2FYMFTvom0zv58H4EDGH/vRZRprSEp7O1RJkSnUf7PIZFg7kcmWI5OtiEyDE0SmEcqGeP0AA/8B+/lf3/wv0+fa0N0Gpl3TKrn35/jelPleuW69I3pXDm7K6d/YBK4P3DEYW8BnBe8SZH8kucumO5pB20bdj8iDTGu3SdE4CdFaKp3nJ1oZCpxoNfn+4Ea0P0S05u6Rr0OiVRraVvj8APgecHzu/I4HPE3O8Nhqadu0ahNu2VuIjucFFZbaFP9LIkQzdhFiSghRHRKNcyHEOQIh8j3BDSGdIUR3ukOIMjSqT4Dv5BbhhEle7QZvnuo1s6M07+A0b9d83Vzr6ooY3+01n/OeHfSKMfE2IyjOfeqOtpKHLkYRt/z/TPm/uxu7L5z/a4oDwC0YnO8/n4sHg80S2rc8LIf0hmViPwSuXUomwGeFSSnxHuALvOfvbgDdSnhmP81bILH+W/A3G0qj3BWlFZkl+pqZrcqNrNtZKSp/xzbwR8DVeMHzgetuhnpiQ5WvifSC9ErPFXtupA4AecsL/PJT6qSMy0kqlBbPiAYxaO4i3z349V22G8jg1xwF+rVzHVWMS2RCpzly7L8vPiK/MDrKJg5N8vavjexeihtqCQZ73cuyO7tMZNXm1bXqkrl5PdAY/w8=
\ No newline at end of file
diff --git a/docs/database/mysql/images/concurrency-consistency-issues-dirty-reading.png b/docs/database/mysql/images/concurrency-consistency-issues-dirty-reading.png
deleted file mode 100644
index db90c6ea22c..00000000000
Binary files a/docs/database/mysql/images/concurrency-consistency-issues-dirty-reading.png and /dev/null differ
diff --git a/docs/database/mysql/images/concurrency-consistency-issues-missing-modifications.drawio b/docs/database/mysql/images/concurrency-consistency-issues-missing-modifications.drawio
deleted file mode 100644
index 68c79b9da73..00000000000
--- a/docs/database/mysql/images/concurrency-consistency-issues-missing-modifications.drawio
+++ /dev/null
@@ -1 +0,0 @@
-7VrbcqM4EP0aPcZl7vjR+DI7U3PZ3dTW7j4qWIASgRiQYztfvxJIXAzOkKnx4JqlUuVILalB3UenW10AYxUf32UwjT7RHSJAn++OwFgDXbfnC/4rBKdS4BhaKQgzvCtFDcE9fkFSOJfSPd6hvDWRUUoYTttCnyYJ8llLBrOMHtrTAkraT01hiDqCex+SrvRvvGNRKXV1p5b/hnAYqSdrttzwA/SfwozuE/k8oBtbe7vduuVwDJUuudE8gjt6aIiMDTBWGaWsbMXHFSLCtMps5brthdHqvTOUsCEL3n82DXfum5j99eL88fjlz9i17+RecnZS9kA7bh7ZpRmLaEgTSDa11Cv2jITWOe/Vcz5SmnKhxoWPiLGT9DXcM8pFEYuJHOUvnJ3+keuLzr+iM7NUd31sDq5PshfQhEmlmiX7WxhjIiZ8QMzLIE5yvp1PNKFqPt1nvlgRMcYRpVvGkv9wI4kfMSGfhZSGBMEU5zOfxsWAnxdTt0GpnTeb+i3dk08orSdMdtEpUpSr97jkCYV9mIWIvTLPrKDDTySiMeIW4usyRCDDz+33gPJshNW8Gh+8ISHyBrhIvc+Q7JFC/Tl+COFHV+DkEGGG7lNYbPvAyaONApin5XkO8FGgyQswIStKaFYoMoIA2b7P5TnL6BNSIwlN0A1j4RllDB1fR0PXe3JBxRWSSzVb9g81MylR1CAlJfvh/jYmf1/T37Z7Y/5WQXmKB6PHA3NgPLBGjQeLDkEsVZA4w9BH+MATxzYjEBwmvO1z4yB+1j1xmDBPzZZyIMa7XQkxlOMX+FDoE55PKeaeEsotD1jrHiwQ8TivStEaPCOTtNuBSw+FyGRX7rjOIZtAeuUAXyScu/lMRJk26ZS9wYCRyn8XLqg1W3061XIaBDkH8Tnaqvf7fgCaXfzxf3qXxmqS0r4dqjqRKdD/Z5HJsM8ik9ONTE5PZDKvFpnsKTLdSGSyBkYmY8zIZPUSg9a9707E8CZisM6vKGMTg9O9omxM4C2B64GNBdwlWGrdjIQbgLVd2+/Chr+laHjS0oelNiP9kgjRjHOEWIMuNca1EOIOQAifYBOBiIeMt0JWmWLCzBiY0d2RMdNDGVO6MVJhVB+Yb2jjlkb1Ls1MtbLr1Ub1nsTj59bKjIkiboUihhbLtFGrZQrBU7XsVqplF66odbVMU4Wt7y2PXb8Epk01sOtEHFNZtkpKx66BuVPEuZWIM7QIpjmjJqVTGexKZTD3/MI6Njf01MGm68ePc7hzXqEY+/qhttAqa1nAtcByVdS3VmDhFJIt8HhjW0iWH+AzfCc+uWsWvBogsb/uxQdphVPu8sKL3BNzzUiPhSHVeFUl2zjAW4OFJhpLDywW1aMe+aOKr/tmftLB4m0U0y7kvT1ANmS/cQq84q/QyTiX00QqzZ8Q8yNFlb8C+PXFGfjNLvg1twf92tvRz7v1N5Fljlx/d2ps/gM=
\ No newline at end of file
diff --git a/docs/database/mysql/images/concurrency-consistency-issues-missing-modifications.png b/docs/database/mysql/images/concurrency-consistency-issues-missing-modifications.png
deleted file mode 100644
index 1718e7ce0dc..00000000000
Binary files a/docs/database/mysql/images/concurrency-consistency-issues-missing-modifications.png and /dev/null differ
diff --git a/docs/database/mysql/images/concurrency-consistency-issues-phantom-read.drawio b/docs/database/mysql/images/concurrency-consistency-issues-phantom-read.drawio
deleted file mode 100644
index 350470274c5..00000000000
--- a/docs/database/mysql/images/concurrency-consistency-issues-phantom-read.drawio
+++ /dev/null
@@ -1 +0,0 @@
-7Vptb+I4EP41lvZOKsIJeftIgHRV7UordU/78uXkBudl68TZxBS4X3+2YxNCQkvvSqG7CAnsx/bYmXk8M3YA5iRbXZeoSD7SOSbAGM5XwJwCw4BwaPMfgaxrxDGtGojLdK46NcBt+g9W4FChi3SOq1ZHRilhadEGQ5rnOGQtDJUlXba7RZS0Zy1QjDvAbYhIF/2SzllSo67hNPh7nMaJnhnaXt1yh8L7uKSLXM0HDDOwgyBw6+YMaVnqQasEzelyCzJnwJyUlLK6lK0mmAjdarXV44I9rZt1lzhnhwy4uV0Ff/3tf86+uHH86frm+3v680pJeUBkofQBZjZwA+DxggVcC4wtgXgW8IeyaQrGM/VAbK2ViOdcp6pKS5bQmOaIzBrUl4rCYilDXmv6fKC04CDk4A/M2FoRBC0Y5VDCMqJa+VOW669qvKx8E5WBpavT1XbjdK1qBN1h4m9sNaGElnLR2lqmH9GcqXnhSNUDlKVEyLjBzC9Rmlf8iT/SnOr+dFGGYkTCGGeqYZlj/sWVL75Eh2oQUxoTjIq0GoQ0kw1hJbsGUS2dF7flW4avZqgVLLS619gKqvQ69lnYUHsKlTFmj/QzN5TkWx3TDHMl8nFqn18NB+bIVrJKTBBLH9prQ2ofxpuxG3GfaMpX3XShUVTxtWyRlRe2Zm0gSeFn0Nno0rlDVUK4axGUXCYpw7cFkupbcu/WJhyqitrfROlKENePUkK26BNF2A5DjlespPdYt+Q0xzucss6IUw+4ZHj1OKv2sgBaypcpXw9tVV82nlNDyZbT1FgfR1oUeK69zYu9j2lvxzwze496wtUI+GPg+jJcjcEYwg4FuAJY29b9Jtyyt4IQSeOcV0OuMMxxX6gz5cnDWDVk6XxO9pGrHfN+SYbYOwRxrIMIYh6LINYBBOn6iAtBXosghntigthvKXc9V0a8SGLqHpiYwj0EOzgL/V98cXoyDJsIb1EVKG8xyf65EGc5aYWrSpqNq34IR8VKak6381IsfkNOK/buzz+0QL6+Wmbd3EvUD+I0s5O3HOyCSsxXhe6kPEGvQuTlUmGWD6zp2z4s9XgpdWWgnnhzQG6xdb+TeOwsBA3Xbvu1cz8ZuZdM+TVPRoZz4kzZe2tx7hwo8CJxTR9Ang5s1ikDm75/vUS23ymyeU9GNgeOWp5MXxH/19CmF270Sj1+4IPdK+58kfHC5l6+4WHjAOHTUbAT9CLjNwt65m7Qc7tBz+kJeqNjBT1NskuW8zr3gSfPcmDPBbB6bTWRFz8T4DkSCYDPC4FExjfoAV2L9446QN2VB8U7c0+8AzMH+FPgQVEY+8DzNlP94FPJV5yDMO9w8TzunfaExh4im6q+tQt8+ZEyGY8PNFdCq3vMwkS7yl+B/ObuVdaoS37o9rAfHo39o1Mk+edizJdJ1q1Dk3XjpMl6915bJTHdtx2XJOZZ29rq3FAfLYnh1ebvHnV+2/ynxpz9Cw==
\ No newline at end of file
diff --git a/docs/database/mysql/images/concurrency-consistency-issues-phantom-read.png b/docs/database/mysql/images/concurrency-consistency-issues-phantom-read.png
deleted file mode 100644
index 4bea3c32953..00000000000
Binary files a/docs/database/mysql/images/concurrency-consistency-issues-phantom-read.png and /dev/null differ
diff --git a/docs/database/mysql/images/concurrency-consistency-issues-unrepeatable-read.drawio b/docs/database/mysql/images/concurrency-consistency-issues-unrepeatable-read.drawio
deleted file mode 100644
index 212d68f7f92..00000000000
--- a/docs/database/mysql/images/concurrency-consistency-issues-unrepeatable-read.drawio
+++ /dev/null
@@ -1 +0,0 @@
-7Vptc5s4EP41+hiPQYDxR/BLern27nq5m14/3cggQImMKMiOnV9fCYQBQxy3jR3SejyDpZVYIe2jfXYFAE6Wm+sUJdEH5mMK9KG/AXAKdH1kQHGVgq0SQLMQhCnxC5FWCW7JI1bCoZKuiI+zRkfOGOUkaQo9FsfY4w0ZSlP20OwWMNocNUEhbgluPUTb0k/E51EhtfVRJX+HSRiVI2vWuGhZIO8+TNkqVuMBHc6t+XxuF81LVOpSE80i5LOHmgjOAJykjPGitNxMMJVLWy5bcd/8idbdc6c45sfc8NsfBrSHnkH4v4+jj3d//r20rSs1l4xvy/XAvlgeVWUpj1jIYkRnldTN54yl1qGoVX3eM5YIoSaEd5jzrbI1WnEmRBFfUtUqHjjd/qfuzyufZWVgltXppt443apawGKulGqmqs/RklDZ4QZzN0UkzsR0PrCYlf3ZKvXkHRHnAlG6CR1xEYskL7JDNggZCylGCckGHlvmDV6Wd50HhXZRrOs3dVeN0LaBMktWDvvUwpdQR2mI+YF+RtFPWqU2gLLwNWZLLFZIdEgxRZysm6BGam+Eu34VPkRBQeQb4KL0rhFd4RL1+/ihVGxdiZOHiHB8m6B8HR6E82iiAGVJsZ8DspFocgNC6YRRluaKYBBgy/OEPOMpu8dlS8xi/LawsMYpx5uD1lOtO1+hfKlmqfpD5ZlKUVRzSqXsxe0NL/Y+pb1trWf2Lp/nwgfn5gPjSD4w+8UH45aDcEqS2MPQe7QQgWPTI1ASxqLsidXCYq+7cucQEZo5qmFJfL+AGM7II1rk+qTlE0aEpaRy0wXmtAMLVA7n7kK0mp9RQVo/4XJwV7Ycyy4qVkvTiCy7HM7VcCBZpul0itrRgFHK/5ImqDSbXTrL21kQZALV+2jbPd/3A9Bo40/86W03Vjkp7XmqajFToP9izAStPWYatZlp1MFMxgsw05c7///P0EE4SG+tNWf/DBm/0joij/4y05vwPnXgwh8iL/NI8oK9Ii+z03do7ZT44ju+yXeYr+g7Og09amcxMwO4DrBdMDOB7QBHawctYra8adpuE9bsrUTHxzVdWGo6rZ8SIRrcR4h5VN4DT4UQ+wiEtAnogpCzIUS3z4eQKU4mZHuDPPeGuOnvn9yPi/snkpqehh/7kPh+Zi9R/yy1az1LTPX2jr6cXJ3upFLv4PhTnVx15wfGW9qgv1h+oHUkCN1W7FeGoHWnCJfjhZc+XtDtV04RtI4c4cIXp3vTcU6+6DT4bgr1kF9E+iZwJnnsPwHjUS6ZA1cU5rnEuUFrdC2/WAC6RWXwv0gbILG+rOT7/NwoV1luRWGJoQaTTb6QZbsohfn/bATcKRhrsuC4YDzeDXUnhso/jhh4cU9TjydorAPIUNVru8DNf7lOLnw5i5XS7B5zLypd5c8Afn28B36jDX7N7kC/drJoybxES32Ilg4GQc/nXKPXipYOPvflQPWF/Ydlni1aEtXqi7TiJV311R+cfQU=
\ No newline at end of file
diff --git a/docs/database/mysql/images/concurrency-consistency-issues-unrepeatable-read.png b/docs/database/mysql/images/concurrency-consistency-issues-unrepeatable-read.png
deleted file mode 100644
index c734138cf8e..00000000000
Binary files a/docs/database/mysql/images/concurrency-consistency-issues-unrepeatable-read.png and /dev/null differ
diff --git "a/docs/database/mysql/images/\344\272\213\345\212\241\347\244\272\346\204\217\345\233\276.drawio" "b/docs/database/mysql/images/\344\272\213\345\212\241\347\244\272\346\204\217\345\233\276.drawio"
deleted file mode 100644
index 4f649c952cc..00000000000
--- "a/docs/database/mysql/images/\344\272\213\345\212\241\347\244\272\346\204\217\345\233\276.drawio"
+++ /dev/null
@@ -1 +0,0 @@
-7XtZd6u4tu6vyRj3PtQedAb8KNEZMGBs09hvmEZgYzCNafzrj5Q4WWsl2VW176m656FO1soApsSUNNtvCuWFla6T1ka33KqTtHxhqGR6YeUXhqEpZokvhDK/UZYU+0ZAbZE8O/0g7IpH+v7mk3ovkrT7pWNf12Vf3H4lxnVVpXH/Cy1q23r8tVtWl7+OeotQ+oWwi6PyKzUokj5/o4qM8IO+SguUv49M888Fn6L4gtr6Xj3He2FYlVdVVXxrvkbvvJ4L7fIoqcefSKzywkptXfdvd9dJSksi23exvb2n/pvWj3m3adX/mRe0zJgy2oFeyjRhNdZm84C/MfwbmyEq7+n7Ol5n28/vEsITv5HbeC6LKknbFxaOedGnu1sUE/qIDQPT8v5a4ica356IVNJkffogfMjKufeYS/qkZ3XVP02CXuDn7pL2MREbRRqLspTqsm5fp8GqqsJLEqZ/XfhTFkPa9un0E+kpCC2tr2nfzrjLs5VfPtX7NFqWFv+1eKOMP4yAZp+qy382gMWzY/Q0PPTB/Yfw8c1T/v+BLoRvVMGXPRHLLap+0Qnf3InZwPhNPAA3tuj0fxZ41Xhs6sf1/77KiyJy/i2LrkU5v/W+pm1bjCm2UazP176YaXQlenyyvvVkxLQtss8tb/3xsltURN+/3BdX7NMMVaUjmVp9JfP/jkvXd3WFnvevg/2Y73O5ZLpV3V6j8q2tTPs+bX/DMokL/OqXdmwC/W/EUIl1kEbqNv3U0rdR1WW4//ubxBhJ61i3ya9cf30xSeO6jfqirj6/mRTdrYyeki2qN/t+XUZZR/3n3j984bdP6mM4/k0SDLGx1xtOfNMg/yE1Hj2vr5ZBBPWtZbxJ8NW1CHt68VzLF0bK4gVSL6L6ovAvS/FFVF4U8QXwLyL9oggvEL4slz/1wRTwAhjSB8ovQHq94Uh/PAZFUcQWcG8R/2ff54id4W2av04dk9/s+p38KewQof9xqPndiPEWjp9h5sn5m7j5H4cPnOR+DR889TV4LL8JHu8B5S+PHe/Z9Cf5pQnOc8/Huu3zGtVVVCo/qLB9C9NP4f3os67r21O8Z+xs8zNCR/e+/h3hd31bXz5yKPN7gZpM7Xfl3KYl9rTh1xT9ndCer27q4tUP3vUjfNLP8lPM7up7G6fPt37OmZ8YceIfMOqjFqX9F0avOvxYz39Drdwfp+cfWqT/2F0+eUiWZUwcfyjvp5aEP/EL/rOO/wLfEahPqVf8JvHS3/jO4m/zncVXIX+NeCTJwY8o932s+tk1fhHoM/r/LP0nKSoLVBFwhSX6Cq2IJAsMT8Gz4Vokyau3fqfZXz34d9HUX6K65TtKeiqPo79RHrX4qjzmb1PeNwD2a0pjqN/+4ap7DxwfgexrxhK/cbq/T2/i/yasn+uRL3nmX38u03xhteD+kNW/yX5/VdL6sM4/UVMW19fy/MN11tEpLTd1V7zhbPlU932NoTosSQP8gM6/5jD884379cQoYNTd3rYNsmIipgNfhwTvVOqdgu+TqI8wXH57ZNTba2FS+NDZjpSpoRrgH3vn5YqH8J1OHiX874Cvsry5rtb4RpW8UnH9LRfemXiIls5u2mvZZGB+INH0XNkXV8q97IrGlmpmU2/2W/6+M8eWVm6Zv9UbrfBNVBtZfM03uW6Aws435GXj6IjobpfsmXrwi4Jets7JW8zHPkjTZdI9Hsub8ChLIGid34HsAK8jKC/8FWetccHJ9rg6IWs4dMPILy+9JLdTH8Pbwcbtlih0sAJa+kjwiuF+7egAjnCNVBlbjnrAvzGcOaWIBIuaLo2CHvlmPu8zjhhIc4ndw21qz6ZGBuO3QEZaqM0X3GZrUwu2W2AjfaM1HqbA/dUS4F02OCQhiUU2ZQOvyC9nRaTk/JpgDkoB1uBI5rXUmSTc7kRXB5vRrmRd4qTtyBY5ozLKGazgIDbrldsDYVT49bhmMVEGKSMZlwgOyPG17tTp2qiBhV30LZkRcJsBbWLXB2v8tEFIrlGytrS1q/qSextRjZRHPgPbFIhjMOoeT4S9GzstKPLo7hzSXPX0oFDWO2hvJrZh1CAfhd3d5lbLc2Az6syZwLiDfGT9fRSW6R3exkWe5HUtudAIK85aK3qnuMYNcy5GK+dPg8S4eh+GkQEv0HTPrXU58Erpz2fHPc/+cdaBuVvcYivmfblm88ttpx+GlVvbA+aRbeid1QAy2eoeHGdP2wuTvV1m80Oc0+0hlW+WWE2aZ+l0MS7AXd/R1Q2AyGTqjkXSqFZSgJYhWG8DWr2IAHvHIWJW5ggjE/OvuwrBsWzlYJw80G59Wu3EM7DuYBXLwRzjLv50a4hyHU6SZ2Iv7NRmJ1qQ03GDQAUOgTwPZoXTkJshs5ILefkadxgYUEsQQGJm614JcKZWI/wbYn+BYRFt8X1vr/DDdWNXzYLCSlhuV8YydSFSMjB0pv64+SIMOGlTHeiWWgERGDgswEU8lO7JiQbxdH+MSw44IIpXpCU+t/W+T6HL2Xxinw5ghLcmZWn5sc0SAdGyt7bAKC3vGWEDqsGlVp6P3V3bJBtZq+7IH4E1k0khGhnoomIXZwfXU3uQHRcJXgDMedGupe2FHwAEIEetly8Oq8bOxn7SKEwwBV1NwQ5HDhjUZFZsrvu67xnLip4aftXODDJWe3MECjIVaRmtSpDWtA9x3DMVEZkrECDFmjwmX7iKBbMjaEfgAv1g0nrGiFA2wHJfMadAw3ljWAfyZQlwBAuk2l3RdXqAvJx5xazmMpaljE6epOlNq1Irjasa5M8nVwE69LCHwzscWTx6+xDUm0xzm1A9wzlFAZm8V8s8tcETzKt6ezTnfKNL+ajWULZCv5jhOd6zrjlhYegKaGAWIu40g42/SBoGC0ltpUla7uNWuXsD3WrSIrwbD0HZHULtUuyw+ofSXoV0Omruno6Vsz8JSJ3NHXYe9UJV/EI8nfnM3fG7yT4y3dJHlSOrLljkO0na9jWZv3zdBki7FgaJwqvh1nrHVqcqYWn6YKrReR0wb2P1/mzTmY/HelAX5YwFswDa1ZokKPRmOay3WdbRI63oYGFYvruze5DUUg/CmugceLDQuxPvC2lwfQse5ilvdA/MUtyiC1m3DkQkv143M449vLRxw4usKO7bYKsLeLtewd2dlGalrh/Yt4bF1UKLRAOFlDUuimZ3b0KrVmb0fsWOAj0KXC0rP7W9oZTDac1Qx9V6p3OsTZ6Pj9IWRA+EwJG3Oy1COE4EaL5IPtrVsu9GjbyZITiW4BpLIrgxrCmx1W7o6XWXjlxwUWenw2t2fvRX3WMjG+4xkk3XCKAy+h5M80e85hTJNLcNgj6IaskHvnVIwdqYwxCFRrehPZI6wc7zna25kA66TjL5Xw96GfoT6F3wX0Gv+BX0vtP+ctD7DoT+kcjILC4fyCg5CoDebjVtNY8jif2g23ruDfqWkdW6oQB/O+nugYAB1Oy2KrQiXdl6W/dhgmI8AMU49sDbKopCKRMqr+No6UCTJX8zzzR0Gj8mScRrwmFYdg9H2JwojCzUdhBS5siH1b3q2bS6C34hURsQHgDOcJ1kjo8g5exRIc6841YbghGgLhyi0heSiNIJgFFlZKXrxZQFsbwdYaiRkeRG8RROCkHOrbw+oaQBPLidcU7s3XihvYt24Z1mgCoXiOJxtg5C2t/qxWDowBnXmVm4ZMce8k0/GjVOyEomnd1bN1hCLJl0pB45vQbGuD6poguMakdQmnbPA4a3phgnwhj69GkWNu02nDAE2uB0aEMTrEeYqnHJzIK1RTK6XK7oxh3l9SgxbbA6FCJFcBUMzPvOZUW7VTOjR/eH4squwna4NoAuQY9pwZ5X94cD/Bw/kqR9AKkpUYdReFQcSK/TPpfPZD9XUh8q8OUuJHFW0M4k3srRI6djHdclGloh3hahgxa7++3AeJvNPDF8nMLQbU+JFt8lhOfOLg7Uuuf7cDyIG0eySyYqkAkqeSBcjRkGkgv6zbiTdiu+HWRGVkUm9S4UWLvG7n50d2XhtLKJhTs4hVsAujle5CJQN91SR1uBK6FLMMaZWASN4QRe12ExRvutoVT7XGhu/UbKHjdDouN53vsYN5m3rRsjVo/8sAcxsvX0FD/SlS5rdvOgEicG5eZYE9xzugJ5xxyioHLGFPVKfOoe8apeK7b5YBPHlcpN0hwG/wA8bTtsGNhylnJ+3VlS84gzB2DF8mZ7W6wFIusTUKQTbhJLwzAJrEKoizxpP90jWqawo9mxsCxTXCXggsK1zS4FGVJjeXq0LMakQH7cU26xcUXAg40Tt9xwTB6Lue5DkjBO12IcuOkKuqk152BgzHUZApWzKmcvbtglk1adbDxiBsMzuJV2F6cTWAEsYol3xymIwZgYK0oUjbtkN/eTWKEVNhLOywKfnipUizBNvEfYpbWNbAaSDbSuS/ZSm46wkuVj9OAUEYSQ5dQ7aKo7szZrHKFheUbrmerG3ALmoF3ZDTb/ZrqoR5pa0xzwdG/YZmNR7EylyHe8ikTQAlVc0VI/UOf5wMAzR+qalNiqB5HpQHoqktsm6YYdCSqPKM+CO1VwagY2l5MsjEkaLd0E498OGJw5m5eAihQ725YiMfdiSlgG6wE60mXdaM7N75VOXXTedpKPzAgGZHdR+eCyg5yBoJPZEaTH9Xwv7Rs9whMQOIVGx7udt+J6Y0W1I5wjbCg1cA75lIeDs3GIp6kLRCCnQ9+SfQqncTwG48ldeco6WjecEDRX9ayFtFGtgUxV1azG6WkALh9RiU9seZ3a9CLZMePDXQV9lBjGofTqXmxzo7KcwlmcnQBItR/fg7z0CRJm16fQe9AbByxg3pW+zK1O61TR005cN/2B2m+Fc2CBWJbHfedYfuTLVEwi3jrSBHHe+g9NanruthsePJ3DcCGukz0pGYtAE6eEiq45jgdnow/pnptbm3OHhJkTjVj8rVR620v5bNEycQB6Xwoav1tqYnjIS526OUtljXg3kun20KfnA0ddpeEsti0yLQsOrurR4nwnlrlGkENhXLhAylnhfJabTlxV19C9cE1RrMSVcN17ezoUDFYOc61I6Sahk+7aDo7LyxjHmleuvaycUO6Ir2mJTGq/lkTkjXymkzmwzQvcu4t9FyWVy59W9Na3B9kajM0o0X1pe7HvHqUabgDbhMJklLR01i+zYY5m4RdN6NlW32chrpEgGphwhBSvVorTqRvTvQ61jFZA4rSNtouG67osFvy1uHOnVL02+nCvMl10xjAn5Wsk5HdSkUIP0RxdJjUwtV1zVi9LaidWd/9KF8U+WN/La7aTkpCU0ycUWwLdMOxq5bGYhe/Qq5QbQ9gBOVwOLqm5djuW8j1uXC6EOVshuK/ykHjipnAkYpkS8V2r80UjkPZwuIbTiZcdNuxjBxuHBDcBLl0hxWl2tLQokVS291C+puTaZ42wjTPncY0jLFO1SC7M0t2d7zEx9htBUj7cTaETHserluuAOC2AChXKqhXsTRB2ubobwBAnekZHqVuQotQMTLfnzMzrKeryhrbhMq205lpuHtgQSKxx4brt+fkibOn9gfJrxerIkIc+EfVGIf6cPuj0vgxbRc/68mEE2K/iWL+iNT+d55UZZudUA5tQa20VrDntpIZrfUszw8iJUbSbmvFx0pC8geuFO21tph5ve5MeE7esZVfyj6qiJ950nBUxjV1Bm4LRRMcOsXSkLPR07VnuaXsar+BarDJfy6q8zvcTf72zYw1cbit6CybRZsNZRWmToQ5EOs/6G18aKnUpRsPx/sAZWT12hzYYIZoNaaAComJP7GwlSh2PYjNnlPnLarvvkyC0TtN4MAOrP2h9ChJ0NuDA5ydK2mrVufRj9mGB8wiJAlYPNXFHIsVKPs/n5hFau+Y4yhqGc9fTRXroAU5WUk6HUnVZH8rdcAxn9rjlYAzlWU5ofdFZStgpHPDPBtVchWH2htxAxHr7CU8ylcIU7YEnIjbxnEiaVAFQYEGwzdJZnuoH7pPEm1ide8YwlyCBVLDyG194LBKvRBuFps5mnU0Zvz+ih+3nzs32FsMdZ4i7lI3Tkm/FwEftweXdZmOzoAPH0RTO90h1kNOyonpPvDnsyA4HBNDnYtUa6+NEbJTpjXznewfLxHKCgOQ/JxXso0x85YTnDkj96T28IallsKID4itX5ZCbj0ggD/EOEeyYlEvjXi2Y/gA9e2svs0t/QXdi4XlcO3m5C1ACtP2FT67BNgTQW16D2uWLINnu/WjLAJ0DrLZndhIHTHe7uK5Ma9EnmJtFQehTHeG2682mrko92lIgYi7Myedw5m/c+np0MWShgXq4Jk6Ii3kZJ98Hnd1q5bbyQKLcDk1gBbPrgxun+WLuv85nPuahFsZXdYS+HVxKJwnrgmw0tb3Lg2gwjjPPXkAj85vGChPlkQappbauPfZUSTbVuo6PkVw3ZX5P1nZmHwYc71WLIJrl7Xqbbnx51SwHKLeNx7NyB3UIbO+YmTDPWR14VhXWYbfXjsfdvvBwIdFGN7WRw6Muo019bIrcD/QLQBv/ESMOCIR7tSy8B0CwWQTm7X6jZGQ28Xra0YLTt+UyWQEHKgeN7AYkrAUc6YqRQ55Lw8LTkYSSgltp5XwCIizWaWe5QnBWkAIIUGRwdIYSFmMkd7EO1FyQ0hh0NmjvBobtKh/vjlsEkUrhqllldoCDOzoJLb2ZFOBgo4Ox7SxceVwfCCA+KDapI6SK8F+mW79YXu6zi1EM56YTzZ5xTlMnAQLZQ95e1U71BY6yb5x4fmbldcaJAK0DLwQc2B9xucaBIy60BEDqsoZeBp6Ve8AOsU89brQ8Ui3B84t+5Ph9EYkdRtp7Q4e7NpROw+PC+SrwwMpSE+qknpqVJ6nN9cYaBOon7YxAYzNd1edrFq2oY7tSebn3UtfYAhvAfYvjs1uCXZ495r73HuzoAYsbVc3W8XTc0qUObcILVUrYeTWWXTSbsXSxLZ7t0rvRjQXG/Py4KmvuKII+TOnVebLj9WWgdwisNLmebyUPaclBuybMjwfMA/FF40mKeXgEVrOPozsljFJMZIqqg5IhVj0khxvNlvIA6BrMqWojgauVuTnRnFn5OEJCgcJ1FgKLKVTRCVHuZbVf0n278iXPhdjX4H4jhQNYG7dNmDfLUywMeD68eU8GVARQAc/ty9t9Gm8bXj4RAMY2bpHOg0FqYACNrbdQ2ouBEHrd3vg7dji4zwcdFsKXHQ7hmx0O4W/b4WD/1OfYf+Q3WIblf1UW+/UbLPf/8xss883pkm8OPvwjlcWJy1+VxfxPK+u746Gfv6BXCSBHoomMy6jrivjT6Z9PQkunog+fbeT+QOT3r/cWeXqK8/Vhfv9q/v/4Cf3to/PvnQjg/uS39p80sPidc0L/zU/y4uLX0MpRnzT7Z8+QLanPMfoTo7/5DBkjfuPlOBK/Ojq+EeUXoLz6PXhZsv9Qdxc+n5pgv34q+M7Y/j53X36jtY+DudwLXL6I4ouyJOdsofyqx+ULEF770C+i9EoRX5Zvx3GxZnFEV1+g9Nr0H/FZvADcjX4/6bv4h1rIx1+E/E5C+KtMhOw+ffxdyFsc+PHHN6zyXw==
\ No newline at end of file
diff --git "a/docs/database/mysql/images/\346\225\260\346\215\256\345\272\223\344\272\213\345\212\241\347\244\272\346\204\217\345\233\276.drawio" "b/docs/database/mysql/images/\346\225\260\346\215\256\345\272\223\344\272\213\345\212\241\347\244\272\346\204\217\345\233\276.drawio"
deleted file mode 100644
index 6ab55e05746..00000000000
--- "a/docs/database/mysql/images/\346\225\260\346\215\256\345\272\223\344\272\213\345\212\241\347\244\272\346\204\217\345\233\276.drawio"
+++ /dev/null
@@ -1 +0,0 @@
-7Zpbc5s4GIZ/jS6TscCAuAQb0sm0s9tmOtvuTUcBGdPIyAtyYu+vX0kIc3TidJOYepLMJNKnE0iP3k8HgDlbba9yvF5+YjGhwJjEW2DOgWHAieGKf9KyKy3uxCwNSZ7GOlNtuEn/JVVJbd2kMSlaGTljlKfrtjFiWUYi3rLhPGcP7WwLRtutrnFCeoabCNO+9a805svSigyntn8gabKsWoa2fuFbHN0lOdtkuj1gmKEdhiEqk1e4qku/aLHEMXtomMwAmLOcMV6GVtsZobJvq24ry4UHUvfPnZOMH1OAJjfh359XhXPNrY9WEH0prr9cyAKymntMN6R6D/W0fFf1kHpHImuBwPQfliknN2scydQHwYSwLfmK6uQFy7geZGiJ+P6tJzJyR3i01JFFSumMUZarVsy5FaD5VGbiObsjVUrGMqKrDfEqpZKya8L9HKdZIZ7zE8tY1Szb5OqplpwLeAzL9MQf0R/yj8xQXCaMJZTgdVpcRmylEqJCZQ0XZe0i2KzfMnzdQr+79Qjck5yTbcOku/+KsBXhuahysq2mRllCzxRHRx8a2CFtWzaQM21txBr1ZF9zPdwioEf8GaNv98aaxGJy6CjL+ZIlLMM0qK1+TYMcxDrPR8bWmoGfhPOdhgBvOGsTIvov333T5VXku4xcWlV0vm0mzncVPAdJKompZq8xblyKqtlDg6Llk+M8IfyRfFaZT47Yo/DlhGKe3rfl7sVRMgd0xKZc93ULMvufDasSLgqFiej5CXTW2zpRhBL1P7CAPwNiXoiAmEBeCIIp8D2AVBLygAerlkA5bvuiPbgpFV6FHCFibWmKMUGL6JA0PaJw46XweNGCVlu19v6sIVvQGJAt57VUC55Eto53bOclRy8uM7ronyxVuqAxc6ZtzGDX6ZV6qEt1CNo/xv+AyjmJL9ym/Fsj3PCEIlY7Qhmp/OA7iK8LYkfvTPM4EL08x7tGtrXMUBxux+4Ab1hWe/n+RP7quep5UD7Bi84K66V3B23HulgsjOgXHGtCcVGck5PdQ9ZVv4aT3e+Ym04Wuq/mZWFvqEflZUc87k9KmXPkEh+Naonv9MTg5vNHENjANQGaq2X5HLizPjdiEvD2MA/v8RvioE2YpkkmopHoYiLsvpxSaYSppxNWaRzTQyv6No1ngdYzJKVz2gDRwHHDgKIYryYofd/xpqcN9QHDd9A8Xxg+bTgLWp4UInSkEFWuZyRKhI5Toj5w70o0AiUyzFMr0eQ4frJ3fsbIjwlPzU//sPNS/fwmvOSMC5Fmshb38ZP18+AHdfRn6OLlTfk54L8sgCzgIRC4AJkqYAPkAs8BAZLH3GimLEieg5dn3274myB35oh1D2fgyV2c20NM+beZBEr6N6SuVFTA87T367ImYBQZoLT4U+BZ76yNgrXuAaV1YtaM/nLqpe7+BJOhUjt15SdofL/7e0vUHLsjaye/+9u/w2vcM7s+cAMJnS8C3tuztkAR+ZXj8PNgzenI2uQNWfPx2vpw95XAmJtft1v7jx8/0osB1PQSbabYmAHXUZYQ+CIQKot3je/xlfymraLlNj+KSltQqXDsgemo3SiUAU+A6e6b+imaUp/PXUZj3apSfEuov/9SbuCrrspz2zremA+++m1vWFrwwxHB3yN9YD4chH8Kp22hHdjiwiGnDp8Pv4jWHx2WF4X1l51m8B8=
\ No newline at end of file
diff --git a/docs/database/mysql/index-invalidation-caused-by-implicit-conversion.md b/docs/database/mysql/index-invalidation-caused-by-implicit-conversion.md
index 377460c66a6..69375c00a0b 100644
--- a/docs/database/mysql/index-invalidation-caused-by-implicit-conversion.md
+++ b/docs/database/mysql/index-invalidation-caused-by-implicit-conversion.md
@@ -1,9 +1,14 @@
---
title: MySQL隐式转换造成索引失效
+description: 深入分析MySQL中隐式类型转换导致索引失效的原因和场景,通过实际案例演示字符串与数字比较时的性能问题,并给出避免索引失效的最佳实践。
category: 数据库
tag:
- MySQL
- 性能优化
+head:
+ - - meta
+ - name: keywords
+ content: MySQL隐式转换,索引失效,类型转换,MySQL性能优化,数据类型不匹配,全表扫描,SQL优化
---
> 本次测试使用的 MySQL 版本是 `5.7.26`,随着 MySQL 版本的更新某些特性可能会发生改变,本文不代表所述观点和结论于 MySQL 所有版本均准确无误,版本差异请自行甄别。
diff --git a/docs/database/mysql/innodb-implementation-of-mvcc.md b/docs/database/mysql/innodb-implementation-of-mvcc.md
index a2e19998d71..b4df7745026 100644
--- a/docs/database/mysql/innodb-implementation-of-mvcc.md
+++ b/docs/database/mysql/innodb-implementation-of-mvcc.md
@@ -1,8 +1,13 @@
---
title: InnoDB存储引擎对MVCC的实现
+description: 深入剖析InnoDB存储引擎MVCC的实现原理,详解隐藏列、undo log版本链、ReadView机制,以及快照读与当前读的区别,理解MySQL如何实现事务隔离。
category: 数据库
tag:
- MySQL
+head:
+ - - meta
+ - name: keywords
+ content: MVCC,多版本并发控制,InnoDB,快照读,当前读,一致性视图,ReadView,undo log,隐藏列,事务隔离
---
## 多版本并发控制 (Multi-Version Concurrency Control)
diff --git a/docs/database/mysql/mysql-auto-increment-primary-key-continuous.md b/docs/database/mysql/mysql-auto-increment-primary-key-continuous.md
index ec900188610..029f7dd1243 100644
--- a/docs/database/mysql/mysql-auto-increment-primary-key-continuous.md
+++ b/docs/database/mysql/mysql-auto-increment-primary-key-continuous.md
@@ -1,9 +1,14 @@
---
title: MySQL自增主键一定是连续的吗
+description: 详解MySQL自增主键不连续的原因,分析唯一键冲突、事务回滚、批量插入等场景下自增值的分配机制,以及InnoDB自增锁模式的配置与影响。
category: 数据库
tag:
- MySQL
- 大厂面试
+head:
+ - - meta
+ - name: keywords
+ content: MySQL自增主键,AUTO_INCREMENT,主键不连续,事务回滚,批量插入,唯一键冲突,innodb_autoinc_lock_mode
---
> 作者:飞天小牛肉
diff --git a/docs/database/mysql/mysql-high-performance-optimization-specification-recommendations.md b/docs/database/mysql/mysql-high-performance-optimization-specification-recommendations.md
index c402fcff3e8..00e783de034 100644
--- a/docs/database/mysql/mysql-high-performance-optimization-specification-recommendations.md
+++ b/docs/database/mysql/mysql-high-performance-optimization-specification-recommendations.md
@@ -1,8 +1,13 @@
---
title: MySQL高性能优化规范建议总结
+description: MySQL高性能优化规范建议总结,涵盖数据库命名规范、表设计规范、字段设计规范、索引设计规范、SQL编写规范等,帮助你构建高效稳定的数据库系统。
category: 数据库
tag:
- MySQL
+head:
+ - - meta
+ - name: keywords
+ content: MySQL优化规范,数据库设计规范,索引设计,SQL编写规范,慢查询优化,字段类型选择,表结构设计
---
> 作者: 听风 原文地址: 。
@@ -11,17 +16,17 @@ tag:
## 数据库命名规范
-- 所有数据库对象名称必须使用小写字母并用下划线分割
-- 所有数据库对象名称禁止使用 MySQL 保留关键字(如果表名中包含关键字查询时,需要将其用单引号括起来)
-- 数据库对象的命名要能做到见名识意,并且最后不要超过 32 个字符
-- 临时库表必须以 `tmp_` 为前缀并以日期为后缀,备份表必须以 `bak_` 为前缀并以日期 (时间戳) 为后缀
-- 所有存储相同数据的列名和列类型必须一致(一般作为关联列,如果查询时关联列类型不一致会自动进行数据类型隐式转换,会造成列上的索引失效,导致查询效率降低)
+- 所有数据库对象名称必须使用小写字母并用下划线分割。
+- 所有数据库对象名称禁止使用 MySQL 保留关键字(如果表名中包含关键字查询时,需要将其用单引号括起来)。
+- 数据库对象的命名要能做到见名识义,并且最好不要超过 32 个字符。
+- 临时库表必须以 `tmp_` 为前缀并以日期为后缀,备份表必须以 `bak_` 为前缀并以日期 (时间戳) 为后缀。
+- 所有存储相同数据的列名和列类型必须一致(一般作为关联列,如果查询时关联列类型不一致会自动进行数据类型隐式转换,会造成列上的索引失效,导致查询效率降低)。
## 数据库基本设计规范
### 所有表必须使用 InnoDB 存储引擎
-没有特殊要求(即 InnoDB 无法满足的功能如:列存储,存储空间数据等)的情况下,所有表必须使用 InnoDB 存储引擎(MySQL5.5 之前默认使用 Myisam,5.6 以后默认的为 InnoDB)。
+没有特殊要求(即 InnoDB 无法满足的功能如:列存储、存储空间数据等)的情况下,所有表必须使用 InnoDB 存储引擎(MySQL5.5 之前默认使用 MyISAM,5.6 以后默认的为 InnoDB)。
InnoDB 支持事务,支持行级锁,更好的恢复性,高并发下性能更好。
@@ -33,19 +38,19 @@ InnoDB 支持事务,支持行级锁,更好的恢复性,高并发下性能
### 所有表和字段都需要添加注释
-使用 comment 从句添加表和列的备注,从一开始就进行数据字典的维护
+使用 comment 从句添加表和列的备注,从一开始就进行数据字典的维护。
### 尽量控制单表数据量的大小,建议控制在 500 万以内
500 万并不是 MySQL 数据库的限制,过大会造成修改表结构,备份,恢复都会有很大的问题。
-可以用历史数据归档(应用于日志数据),分库分表(应用于业务数据)等手段来控制数据量大小
+可以用历史数据归档(应用于日志数据),分库分表(应用于业务数据)等手段来控制数据量大小。
### 谨慎使用 MySQL 分区表
-分区表在物理上表现为多个文件,在逻辑上表现为一个表;
+分区表在物理上表现为多个文件,在逻辑上表现为一个表。
-谨慎选择分区键,跨分区查询效率可能更低;
+谨慎选择分区键,跨分区查询效率可能更低。
建议采用物理分表的方式管理大数据。
@@ -71,7 +76,7 @@ InnoDB 支持事务,支持行级锁,更好的恢复性,高并发下性能
### 禁止在线上做数据库压力测试
-### 禁止从开发环境,测试环境直接连接生产环境数据库
+### 禁止从开发环境、测试环境直接连接生产环境数据库
安全隐患极大,要对生产环境抱有敬畏之心!
@@ -79,22 +84,22 @@ InnoDB 支持事务,支持行级锁,更好的恢复性,高并发下性能
### 优先选择符合存储需要的最小的数据类型
-存储字节越小,占用也就空间越小,性能也越好。
+存储字节越小,占用空间也就越小,性能也越好。
-**a.某些字符串可以转换成数字类型存储比如可以将 IP 地址转换成整型数据。**
+**a.某些字符串可以转换成数字类型存储,比如可以将 IP 地址转换成整型数据。**
数字是连续的,性能更好,占用空间也更小。
-MySQL 提供了两个方法来处理 ip 地址
+MySQL 提供了两个方法来处理 ip 地址:
-- `INET_ATON()`:把 ip 转为无符号整型 (4-8 位)
-- `INET_NTOA()` :把整型的 ip 转为地址
+- `INET_ATON()`:把 ip 转为无符号整型 (4-8 位);
+- `INET_NTOA()`:把整型的 ip 转为地址。
-插入数据前,先用 `INET_ATON()` 把 ip 地址转为整型,显示数据时,使用 `INET_NTOA()` 把整型的 ip 地址转为地址显示即可。
+插入数据前,先用 `INET_ATON()` 把 ip 地址转为整型;显示数据时,使用 `INET_NTOA()` 把整型的 ip 地址转为地址显示即可。
-**b.对于非负型的数据 (如自增 ID,整型 IP,年龄) 来说,要优先使用无符号整型来存储。**
+**b.对于非负型的数据 (如自增 ID、整型 IP、年龄) 来说,要优先使用无符号整型来存储。**
-无符号相对于有符号可以多出一倍的存储空间
+无符号相对于有符号可以多出一倍的存储空间:
```sql
SIGNED INT -2147483648~2147483647
@@ -103,7 +108,7 @@ UNSIGNED INT 0~4294967295
**c.小数值类型(比如年龄、状态表示如 0/1)优先使用 TINYINT 类型。**
-### 避免使用 TEXT,BLOB 数据类型,最常见的 TEXT 类型可以存储 64k 的数据
+### 避免使用 TEXT、BLOB 数据类型,最常见的 TEXT 类型可以存储 64k 的数据
**a. 建议把 BLOB 或是 TEXT 列分离到单独的扩展表中。**
@@ -113,30 +118,30 @@ MySQL 内存临时表不支持 TEXT、BLOB 这样的大数据类型,如果查
**2、TEXT 或 BLOB 类型只能使用前缀索引**
-因为 MySQL 对索引字段长度是有限制的,所以 TEXT 类型只能使用前缀索引,并且 TEXT 列上是不能有默认值的
+因为 MySQL 对索引字段长度是有限制的,所以 TEXT 类型只能使用前缀索引,并且 TEXT 列上是不能有默认值的。
### 避免使用 ENUM 类型
-- 修改 ENUM 值需要使用 ALTER 语句;
-- ENUM 类型的 ORDER BY 操作效率低,需要额外操作;
-- ENUM 数据类型存在一些限制比如建议不要使用数值作为 ENUM 的枚举值。
+- 修改 ENUM 值需要使用 ALTER 语句。
+- ENUM 类型的 ORDER BY 操作效率低,需要额外操作。
+- ENUM 数据类型存在一些限制,比如建议不要使用数值作为 ENUM 的枚举值。
相关阅读:[是否推荐使用 MySQL 的 enum 类型? - 架构文摘 - 知乎](https://www.zhihu.com/question/404422255/answer/1661698499) 。
### 尽可能把所有列定义为 NOT NULL
-除非有特别的原因使用 NULL 值,应该总是让字段保持 NOT NULL。
+除非有特别的原因使用 NULL 值,否则应该总是让字段保持 NOT NULL。
-- 索引 NULL 列需要额外的空间来保存,所以要占用更多的空间;
+- 索引 NULL 列需要额外的空间来保存,所以要占用更多的空间。
- 进行比较和计算时要对 NULL 值做特别的处理。
相关阅读:[技术分享 | MySQL 默认值选型(是空,还是 NULL)](https://opensource.actionsky.com/20190710-mysql/) 。
### 一定不要用字符串存储日期
-对于日期类型来说, 一定不要用字符串存储日期。可以考虑 DATETIME、TIMESTAMP 和 数值型时间戳。
+对于日期类型来说,一定不要用字符串存储日期。可以考虑 DATETIME、TIMESTAMP 和数值型时间戳。
-这三种种方式都有各自的优势,根据实际场景选择最合适的才是王道。下面再对这三种方式做一个简单的对比,以供大家实际开发中选择正确的存放时间的数据类型:
+这三种种方式都有各自的优势,根据实际场景选择最合适的才是王道。下面再对这三种方式做一个简单的对比,以供大家在实际开发中选择正确的存放时间的数据类型:
| 类型 | 存储空间 | 日期格式 | 日期范围 | 是否带时区信息 |
| ------------ | -------- | ------------------------------ | ------------------------------------------------------------ | -------------- |
@@ -148,10 +153,10 @@ MySQL 时间类型选择的详细介绍请看这篇:[MySQL 时间类型数据
### 同财务相关的金额类数据必须使用 decimal 类型
-- **非精准浮点**:float,double
+- **非精准浮点**:float、double
- **精准浮点**:decimal
-decimal 类型为精准浮点数,在计算时不会丢失精度。占用空间由定义的宽度决定,每 4 个字节可以存储 9 位数字,并且小数点要占用一个字节。并且,decimal 可用于存储比 bigint 更大的整型数据
+decimal 类型为精准浮点数,在计算时不会丢失精度。占用空间由定义的宽度决定,每 4 个字节可以存储 9 位数字,并且小数点要占用一个字节。并且,decimal 可用于存储比 bigint 更大的整型数据。
不过, 由于 decimal 需要额外的空间和计算开销,应该尽量只在需要对数据进行精确计算时才使用 decimal 。
@@ -161,13 +166,13 @@ decimal 类型为精准浮点数,在计算时不会丢失精度。占用空间
## 索引设计规范
-### 限制每张表上的索引数量,建议单张表索引不超过 5 个
+### 限制每张表上的索引数量,建议单张表索引不超过 5 个
-索引并不是越多越好!索引可以提高效率同样可以降低效率。
+索引并不是越多越好!索引可以提高效率,同样可以降低效率。
索引可以增加查询效率,但同样也会降低插入和更新的效率,甚至有些情况下会降低查询效率。
-因为 MySQL 优化器在选择如何优化查询时,会根据统一信息,对每一个可以用到的索引来进行评估,以生成出一个最好的执行计划,如果同时有很多个索引都可以用于查询,就会增加 MySQL 优化器生成执行计划的时间,同样会降低查询性能。
+因为 MySQL 优化器在选择如何优化查询时,会根据统一信息,对每一个可以用到的索引来进行评估,以生成出一个最好的执行计划。如果同时有很多个索引都可以用于查询,就会增加 MySQL 优化器生成执行计划的时间,同样会降低查询性能。
### 禁止使用全文索引
@@ -175,46 +180,46 @@ decimal 类型为精准浮点数,在计算时不会丢失精度。占用空间
### 禁止给表中的每一列都建立单独的索引
-5.6 版本之前,一个 sql 只能使用到一个表中的一个索引,5.6 以后,虽然有了合并索引的优化方式,但是还是远远没有使用一个联合索引的查询方式好。
+5.6 版本之前,一个 sql 只能使用到一个表中的一个索引;5.6 以后,虽然有了合并索引的优化方式,但是还是远远没有使用一个联合索引的查询方式好。
### 每个 InnoDB 表必须有个主键
InnoDB 是一种索引组织表:数据的存储的逻辑顺序和索引的顺序是相同的。每个表都可以有多个索引,但是表的存储顺序只能有一种。
-InnoDB 是按照主键索引的顺序来组织表的
+InnoDB 是按照主键索引的顺序来组织表的。
-- 不要使用更新频繁的列作为主键,不使用多列主键(相当于联合索引)
-- 不要使用 UUID,MD5,HASH,字符串列作为主键(无法保证数据的顺序增长)
-- 主键建议使用自增 ID 值
+- 不要使用更新频繁的列作为主键,不使用多列主键(相当于联合索引)。
+- 不要使用 UUID、MD5、HASH、字符串列作为主键(无法保证数据的顺序增长)。
+- 主键建议使用自增 ID 值。
### 常见索引列建议
-- 出现在 SELECT、UPDATE、DELETE 语句的 WHERE 从句中的列
-- 包含在 ORDER BY、GROUP BY、DISTINCT 中的字段
-- 并不要将符合 1 和 2 中的字段的列都建立一个索引, 通常将 1、2 中的字段建立联合索引效果更好
-- 多表 join 的关联列
+- 出现在 SELECT、UPDATE、DELETE 语句的 WHERE 从句中的列。
+- 包含在 ORDER BY、GROUP BY、DISTINCT 中的字段。
+- 不要将符合 1 和 2 中的字段的列都建立一个索引,通常将 1、2 中的字段建立联合索引效果更好。
+- 多表 join 的关联列。
### 如何选择索引列的顺序
-建立索引的目的是:希望通过索引进行数据查找,减少随机 IO,增加查询性能 ,索引能过滤出越少的数据,则从磁盘中读入的数据也就越少。
+建立索引的目的是:希望通过索引进行数据查找,减少随机 IO,增加查询性能,索引能过滤出越少的数据,则从磁盘中读入的数据也就越少。
-- 区分度最高的放在联合索引的最左侧(区分度=列中不同值的数量/列的总行数)
-- 尽量把字段长度小的列放在联合索引的最左侧(因为字段长度越小,一页能存储的数据量越大,IO 性能也就越好)
-- 使用最频繁的列放到联合索引的左侧(这样可以比较少的建立一些索引)
+- **区分度最高的列放在联合索引的最左侧**:这是最重要的原则。区分度越高,通过索引筛选出的数据就越少,I/O 操作也就越少。计算区分度的方法是 `count(distinct column) / count(*)`。
+- **最频繁使用的列放在联合索引的左侧**:这符合最左前缀匹配原则。将最常用的查询条件列放在最左侧,可以最大程度地利用索引。
+- **字段长度**:字段长度对联合索引非叶子节点的影响很小,因为它存储了所有联合索引字段的值。字段长度主要影响主键和包含在其他索引中的字段的存储空间,以及这些索引的叶子节点的大小。因此,在选择联合索引列的顺序时,字段长度的优先级最低。对于主键和包含在其他索引中的字段,选择较短的字段长度可以节省存储空间和提高 I/O 性能。
### 避免建立冗余索引和重复索引(增加了查询优化器生成执行计划的时间)
-- 重复索引示例:primary key(id)、index(id)、unique index(id)
-- 冗余索引示例:index(a,b,c)、index(a,b)、index(a)
+- 重复索引示例:primary key(id)、index(id)、unique index(id)。
+- 冗余索引示例:index(a,b,c)、index(a,b)、index(a)。
-### 对于频繁的查询优先考虑使用覆盖索引
+### 对于频繁的查询,优先考虑使用覆盖索引
-> 覆盖索引:就是包含了所有查询字段 (where,select,order by,group by 包含的字段) 的索引
+> 覆盖索引:就是包含了所有查询字段 (where、select、order by、group by 包含的字段) 的索引
-**覆盖索引的好处:**
+**覆盖索引的好处**:
-- **避免 InnoDB 表进行索引的二次查询,也就是回表操作:** InnoDB 是以聚集索引的顺序来存储的,对于 InnoDB 来说,二级索引在叶子节点中所保存的是行的主键信息,如果是用二级索引查询数据的话,在查找到相应的键值后,还要通过主键进行二次查询才能获取我们真实所需要的数据。而在覆盖索引中,二级索引的键值中可以获取所有的数据,避免了对主键的二次查询(回表),减少了 IO 操作,提升了查询效率。
-- **可以把随机 IO 变成顺序 IO 加快查询效率:** 由于覆盖索引是按键值的顺序存储的,对于 IO 密集型的范围查找来说,对比随机从磁盘读取每一行的数据 IO 要少的多,因此利用覆盖索引在访问时也可以把磁盘的随机读取的 IO 转变成索引查找的顺序 IO。
+- **避免 InnoDB 表进行索引的二次查询,也就是回表操作**:InnoDB 是以聚集索引的顺序来存储的,对于 InnoDB 来说,二级索引在叶子节点中所保存的是行的主键信息,如果是用二级索引查询数据的话,在查找到相应的键值后,还要通过主键进行二次查询才能获取我们真实所需要的数据。而在覆盖索引中,二级索引的键值中可以获取所有的数据,避免了对主键的二次查询(回表),减少了 IO 操作,提升了查询效率。
+- **可以把随机 IO 变成顺序 IO 加快查询效率**:由于覆盖索引是按键值的顺序存储的,对于 IO 密集型的范围查找来说,对比随机从磁盘读取每一行的数据 IO 要少的多,因此利用覆盖索引在访问时也可以把磁盘的随机读取的 IO 转变成索引查找的顺序 IO。
---
@@ -222,9 +227,9 @@ InnoDB 是按照主键索引的顺序来组织表的
**尽量避免使用外键约束**
-- 不建议使用外键约束(foreign key),但一定要在表与表之间的关联键上建立索引
-- 外键可用于保证数据的参照完整性,但建议在业务端实现
-- 外键会影响父表和子表的写操作从而降低性能
+- 不建议使用外键约束(foreign key),但一定要在表与表之间的关联键上建立索引。
+- 外键可用于保证数据的参照完整性,但建议在业务端实现。
+- 外键会影响父表和子表的写操作从而降低性能。
## 数据库 SQL 开发规范
@@ -238,7 +243,7 @@ InnoDB 是按照主键索引的顺序来组织表的
### 充分利用表上已经存在的索引
-避免使用双%号的查询条件。如:`a like '%123%'`,(如果无前置%,只有后置%,是可以用到列上的索引的)
+避免使用双%号的查询条件。如:`a like '%123%'`(如果无前置%,只有后置%,是可以用到列上的索引的)。
一个 SQL 只能利用到复合索引中的一列进行范围查询。如:有 a,b,c 列的联合索引,在查询条件中有 a 列的范围查询,则在 b,c 列上的索引将不会被用到。
@@ -248,18 +253,18 @@ InnoDB 是按照主键索引的顺序来组织表的
- `SELECT *` 会消耗更多的 CPU。
- `SELECT *` 无用字段增加网络带宽资源消耗,增加数据传输时间,尤其是大字段(如 varchar、blob、text)。
-- `SELECT *` 无法使用 MySQL 优化器覆盖索引的优化(基于 MySQL 优化器的“覆盖索引”策略又是速度极快,效率极高,业界极为推荐的查询优化方式)
-- `SELECT <字段列表>` 可减少表结构变更带来的影响、
+- `SELECT *` 无法使用 MySQL 优化器覆盖索引的优化(基于 MySQL 优化器的“覆盖索引”策略又是速度极快、效率极高、业界极为推荐的查询优化方式)。
+- `SELECT <字段列表>` 可减少表结构变更带来的影响。
### 禁止使用不含字段列表的 INSERT 语句
-如:
+**不推荐**:
```sql
insert into t values ('a','b','c');
```
-应使用:
+**推荐**:
```sql
insert into t(c1,c2,c3) values ('a','b','c');
@@ -273,7 +278,7 @@ insert into t(c1,c2,c3) values ('a','b','c');
### 避免数据类型的隐式转换
-隐式转换会导致索引失效如:
+隐式转换会导致索引失效,如:
```sql
select name,phone from customer where id = '111';
@@ -283,9 +288,9 @@ select name,phone from customer where id = '111';
### 避免使用子查询,可以把子查询优化为 join 操作
-通常子查询在 in 子句中,且子查询中为简单 SQL(不包含 union、group by、order by、limit 从句) 时,才可以把子查询转化为关联查询进行优化。
+通常子查询在 in 子句中,且子查询中为简单 SQL(不包含 union、group by、order by、limit 从句) 时,才可以把子查询转化为关联查询进行优化。
-**子查询性能差的原因:** 子查询的结果集无法使用索引,通常子查询的结果集会被存储到临时表中,不论是内存临时表还是磁盘临时表都不会存在索引,所以查询性能会受到一定的影响。特别是对于返回结果集比较大的子查询,其对查询性能的影响也就越大。由于子查询会产生大量的临时表也没有索引,所以会消耗过多的 CPU 和 IO 资源,产生大量的慢查询。
+**子查询性能差的原因**:子查询的结果集无法使用索引,通常子查询的结果集会被存储到临时表中,不论是内存临时表还是磁盘临时表都不会存在索引,所以查询性能会受到一定的影响。特别是对于返回结果集比较大的子查询,其对查询性能的影响也就越大。由于子查询会产生大量的临时表也没有索引,所以会消耗过多的 CPU 和 IO 资源,产生大量的慢查询。
### 避免使用 JOIN 关联太多的表
@@ -293,7 +298,7 @@ select name,phone from customer where id = '111';
在 MySQL 中,对于同一个 SQL 多关联(join)一个表,就会多分配一个关联缓存,如果在一个 SQL 中关联的表越多,所占用的内存也就越大。
-如果程序中大量的使用了多表关联的操作,同时 join_buffer_size 设置的也不合理的情况下,就容易造成服务器内存溢出的情况,就会影响到服务器数据库性能的稳定性。
+如果程序中大量地使用了多表关联的操作,同时 join_buffer_size 设置得也不合理,就容易造成服务器内存溢出的情况,就会影响到服务器数据库性能的稳定性。
同时对于关联操作来说,会产生临时表操作,影响查询效率,MySQL 最多允许关联 61 个表,建议不超过 5 个。
@@ -303,25 +308,25 @@ select name,phone from customer where id = '111';
### 对应同一列进行 or 判断时,使用 in 代替 or
-in 的值不要超过 500 个,in 操作可以更有效的利用索引,or 大多数情况下很少能利用到索引。
+in 的值不要超过 500 个。in 操作可以更有效的利用索引,or 大多数情况下很少能利用到索引。
### 禁止使用 order by rand() 进行随机排序
-order by rand() 会把表中所有符合条件的数据装载到内存中,然后在内存中对所有数据根据随机生成的值进行排序,并且可能会对每一行都生成一个随机值,如果满足条件的数据集非常大,就会消耗大量的 CPU 和 IO 及内存资源。
+order by rand() 会把表中所有符合条件的数据装载到内存中,然后在内存中对所有数据根据随机生成的值进行排序,并且可能会对每一行都生成一个随机值。如果满足条件的数据集非常大,就会消耗大量的 CPU 和 IO 及内存资源。
推荐在程序中获取一个随机值,然后从数据库中获取数据的方式。
### WHERE 从句中禁止对列进行函数转换和计算
-对列进行函数转换或计算时会导致无法使用索引
+对列进行函数转换或计算时会导致无法使用索引。
-**不推荐:**
+**不推荐**:
```sql
where date(create_time)='20190101'
```
-**推荐:**
+**推荐**:
```sql
where create_time >= '20190101' and create_time < '20190102'
@@ -329,43 +334,43 @@ where create_time >= '20190101' and create_time < '20190102'
### 在明显不会有重复值时使用 UNION ALL 而不是 UNION
-- UNION 会把两个结果集的所有数据放到临时表中后再进行去重操作
-- UNION ALL 不会再对结果集进行去重操作
+- UNION 会把两个结果集的所有数据放到临时表中后再进行去重操作。
+- UNION ALL 不会再对结果集进行去重操作。
### 拆分复杂的大 SQL 为多个小 SQL
-- 大 SQL 逻辑上比较复杂,需要占用大量 CPU 进行计算的 SQL
-- MySQL 中,一个 SQL 只能使用一个 CPU 进行计算
-- SQL 拆分后可以通过并行执行来提高处理效率
+- 大 SQL 逻辑上比较复杂,需要占用大量 CPU 进行计算的 SQL。
+- MySQL 中,一个 SQL 只能使用一个 CPU 进行计算。
+- SQL 拆分后可以通过并行执行来提高处理效率。
### 程序连接不同的数据库使用不同的账号,禁止跨库查询
-- 为数据库迁移和分库分表留出余地
-- 降低业务耦合度
-- 避免权限过大而产生的安全风险
+- 为数据库迁移和分库分表留出余地。
+- 降低业务耦合度。
+- 避免权限过大而产生的安全风险。
## 数据库操作行为规范
-### 超 100 万行的批量写 (UPDATE,DELETE,INSERT) 操作,要分批多次进行操作
+### 超 100 万行的批量写 (UPDATE、DELETE、INSERT) 操作,要分批多次进行操作
**大批量操作可能会造成严重的主从延迟**
-主从环境中,大批量操作可能会造成严重的主从延迟,大批量的写操作一般都需要执行一定长的时间,而只有当主库上执行完成后,才会在其他从库上执行,所以会造成主库与从库长时间的延迟情况
+主从环境中,大批量操作可能会造成严重的主从延迟,大批量的写操作一般都需要执行一定长的时间,而只有当主库上执行完成后,才会在其他从库上执行,所以会造成主库与从库长时间的延迟情况。
**binlog 日志为 row 格式时会产生大量的日志**
-大批量写操作会产生大量日志,特别是对于 row 格式二进制数据而言,由于在 row 格式中会记录每一行数据的修改,我们一次修改的数据越多,产生的日志量也就会越多,日志的传输和恢复所需要的时间也就越长,这也是造成主从延迟的一个原因
+大批量写操作会产生大量日志,特别是对于 row 格式二进制数据而言,由于在 row 格式中会记录每一行数据的修改,我们一次修改的数据越多,产生的日志量也就会越多,日志的传输和恢复所需要的时间也就越长,这也是造成主从延迟的一个原因。
**避免产生大事务操作**
大批量修改数据,一定是在一个事务中进行的,这就会造成表中大批量数据进行锁定,从而导致大量的阻塞,阻塞会对 MySQL 的性能产生非常大的影响。
-特别是长时间的阻塞会占满所有数据库的可用连接,这会使生产环境中的其他应用无法连接到数据库,因此一定要注意大批量写操作要进行分批
+特别是长时间的阻塞会占满所有数据库的可用连接,这会使生产环境中的其他应用无法连接到数据库,因此一定要注意大批量写操作要进行分批。
### 对于大表使用 pt-online-schema-change 修改表结构
-- 避免大表修改产生的主从延迟
-- 避免在对表字段进行修改时进行锁表
+- 避免大表修改产生的主从延迟。
+- 避免在对表字段进行修改时进行锁表。
对大表数据结构的修改一定要谨慎,会造成严重的锁表操作,尤其是生产环境,是不能容忍的。
@@ -373,13 +378,13 @@ pt-online-schema-change 它会首先建立一个与原表结构相同的新表
### 禁止为程序使用的账号赋予 super 权限
-- 当达到最大连接数限制时,还运行 1 个有 super 权限的用户连接
-- super 权限只能留给 DBA 处理问题的账号使用
+- 当达到最大连接数限制时,还运行 1 个有 super 权限的用户连接。
+- super 权限只能留给 DBA 处理问题的账号使用。
-### 对于程序连接数据库账号,遵循权限最小原则
+### 对于程序连接数据库账号,遵循权限最小原则
-- 程序使用数据库账号只能在一个 DB 下使用,不准跨库
-- 程序使用的账号原则上不准有 drop 权限
+- 程序使用数据库账号只能在一个 DB 下使用,不准跨库。
+- 程序使用的账号原则上不准有 drop 权限。
## 推荐阅读
diff --git a/docs/database/mysql/mysql-index-invalidation.md b/docs/database/mysql/mysql-index-invalidation.md
new file mode 100644
index 00000000000..04d5db4de38
--- /dev/null
+++ b/docs/database/mysql/mysql-index-invalidation.md
@@ -0,0 +1,213 @@
+---
+title: MySQL索引失效场景总结
+description: 全面总结MySQL索引失效的常见场景,包括SELECT *查询、违背最左前缀原则、索引列计算函数转换、LIKE模糊查询、OR连接、IN/NOT IN使用不当、隐式类型转换以及ORDER BY排序优化陷阱,帮助你避免索引失效导致的性能问题。
+category: 数据库
+tag:
+ - MySQL
+ - 性能优化
+head:
+ - - meta
+ - name: keywords
+ - content: MySQL索引失效,索引失效场景,最左前缀原则,覆盖索引,索引下推,隐式类型转换,SQL优化,MySQL性能优化,全表扫描,回表查询
+---
+
+在数据库性能优化中,索引是最直接有效的优化手段之一。然而,**建了索引并不等于一定能用上索引**。实际开发中,我们经常遇到这样的困惑:明明在字段上建立了索引,查询却依然慢如蜗牛,通过 `EXPLAIN` 分析发现居然是全表扫描。
+
+导致索引失效的原因多种多样,既有 SQL 语句写法问题,也有索引设计不当的因素。有些失效场景是显性的(如违背最左前缀原则),有些则非常隐蔽(如隐式类型转换)。如果不深入了解这些失效场景,很容易在生产环境中埋下性能隐患。
+
+本文将系统总结 MySQL 索引失效的常见场景,分析失效背后的原理机制,并提供相应的优化建议,帮助你在日常开发和排查问题中快速定位并解决索引失效问题。
+
+### SELECT \* 查询(成本权衡)
+
+- **核心定义**:`SELECT *` 本身**不会直接导致索引失效**。它是一种“非覆盖索引”查询,如果 `WHERE` 条件命中了索引,索引依然会被初步考虑。
+- **回表成本决策**:当查询需要的字段不在索引树中时,MySQL 必须拿着主键回聚簇索引查找整行数据(回表)。优化器会对比“索引扫描 + 回表”与“直接全表扫描”的成本。如果查询结果占总数据量的比例较高(通常阈值在 20%~30%),优化器会认为全表扫描的顺序 IO 效率高于回表的随机 IO,从而**主动放弃索引**。
+- **落地建议**:严禁在生产环境无脑使用 `SELECT *`。应遵循**覆盖索引**原则,只查询必要的字段,将 `Extra` 列从空值优化为 `Using index`,从而彻底规避回表开销。
+
+**注意**:后文使用 `SELECT *` 仅仅是为了演示方便。
+
+### 违背最左前缀原则
+
+- **核心定义**:最左前缀匹配原则指的是在使用联合索引时,MySQL 会根据索引中的字段顺序,从左到右依次匹配查询条件中的字段。如果查询条件与索引中的最左侧字段相匹配,那么 MySQL 就会使用索引来过滤数据。
+- **范围查询的中断效应**:在联合索引中,如果某个字段使用了范围查询(例如 >、<、BETWEEN、前缀匹配 LIKE "abc%"),该字段本身以及其之前的列可以正常匹配并用于索引的精确定位,但该字段之后的列将无法利用
+ 索引进行快速定位(即无法使用 ref 类型的二分查找)。这是因为在 B+Tree 索引结构中,只有当前导列完全相等时,后续列才是有序的。一旦前导列变成一个范围,后续列在整个扫描区间内就呈现相对无序状态,从而中断了精准定位能力。不过,在 MySQL 5.6 及以上版本中,这些后续列并未完全失效,而是降级为使用**索引下推(Index Condition Pushdown, ICP)机制**,在范围扫描的过程中直接进行条件过滤,以此来减少回表次数。
+- **索引跳跃扫描 (ISS)**:MySQL 8.0.13 引入了**索引跳跃扫描(Index Skip Scan)**,允许在缺失最左前缀时,通过枚举前导列的所有 Distinct 值来跳跃扫描后续索引树。
+
+ - **版本避坑指南**:在 **MySQL 8.0.31** 中,ISS 存在严重 Bug([[Bug #109145]](https://bugs.mysql.com/bug.php?id=109145)),在跨 Range 读取时未清理陈旧的边界值,会导致查询直接**丢失数据**。
+ - **落地建议**:ISS 在前导列基数(Cardinality)极低(如性别、状态枚举)时性能最优,因为优化器需要枚举前导列的所有 distinct 值逐一跳跃扫描——distinct 值越少,跳跃次数越少。但"基数低"本身并非官方限制条件,优化器会综合评估成本决定是否触发 ISS。在生产环境中,**严禁依赖 ISS 来弥补糟糕的索引设计**,必须通过调整联合索引顺序或补齐前导列条件来满足最左前缀。
+
+ **Index Skip Scan 失败路径图:**
+
+```mermaid
+sequenceDiagram
+ participant Executor
+ participant InnoDB_Index
+
+ Note over Executor, InnoDB_Index: MySQL 8.0.31 触发 ISS Bug 场景
+ Executor->>InnoDB_Index: Read Range 1 (Prefix A)
+ InnoDB_Index-->>Executor: Return Rows, Set End-of-Range = X
+ Executor->>InnoDB_Index: Read Range 2 (Prefix B)
+ Note right of InnoDB_Index: [BUG] 未清理上一个 Range 的 End-of-Range X
+ InnoDB_Index-->>Executor: 发现当前值 > X,错误判定越界,提前终止!
+ Note over Executor: 导致结果集丢失 (Incorrect Result)
+```
+
+失效示例:
+
+```sql
+-- 索引:(sname, s_code, address)
+SELECT * FROM students WHERE s_code = 1; -- 跳过最左列 sname,索引失效
+SELECT * FROM students WHERE sname = 'A' AND address = 'Shanghai'; -- 跳过中间列,仅 sname 走索引(索引下推 ICP 可优化过滤)
+SELECT * FROM students WHERE sname = 'A' AND s_code > 1 AND address = 'Shanghai'; -- 范围查询后,address 无法用于定位,仅用于过滤
+```
+
+### 在索引列上进行计算、函数或类型转换
+
+- **核心定义**:索引 B+Tree 存储的是字段的**原始值**。一旦在 `WHERE` 条件中对索引列应用了函数(如 `ABS()`、`DATE()`)或算术运算,该列的值在逻辑上发生了改变。
+- **有序性破坏效应**:由于 B+Tree 是基于原始值排序的,经过函数处理后的结果在索引树中是**无序**的。数据库无法利用二分查找快速定位,只能被迫进行全表扫描。
+- **函数索引**:MySQL 8.0 支持**函数索引**(Functional Index),可针对计算后的值建索引,但使用场景有限,首选还是优化 SQL 写法。
+
+失效示例:
+
+```sql
+SELECT * FROM students WHERE height + 1 = 170; -- 对索引列进行计算
+SELECT * FROM students WHERE DATE(create_time) = '2022-01-01'; -- 对索引列使用函数
+```
+
+优化建议:
+
+```sql
+SELECT * FROM students WHERE height = 169; -- 将计算移到等号右边
+SELECT * FROM students WHERE create_time BETWEEN '2022-01-01 00:00:00' AND '2022-01-01 23:59:59';
+```
+
+### LIKE 模糊查询以通配符开头
+
+- **核心定义**:`LIKE` 查询必须以具体字符开头才能利用索引有序性,例如 `WHERE sname LIKE 'Guide%';`。这是因为 B+ 树是从左到右排序的。前缀通配符(`%`)破坏了有序性,无法定位起始点。
+- **前缀通配符的失效机制**:如果以 `%` 开头(如 `'%abc'`),由于索引是按字符从左到右排序的,前缀不确定意味着可能出现在索引树的任何位置,导致无法定位搜索区间的起始点。
+- **落地建议**:
+ - 如果必须进行全模糊查询,尽量只查询索引覆盖的列,此时 `EXPLAIN` 会显示 `type: index`(**Index Full Scan**),虽然扫描了整棵树,但无需回表,性能仍优于 `ALL`。
+ - 核心业务的大规模模糊搜索应通过 **ElasticSearch** 或其他搜索引擎实现。
+
+失效示例:
+
+```sql
+SELECT * FROM students WHERE sname LIKE '%Guide'; -- 前缀模糊,全表扫描
+SELECT * FROM students WHERE sname LIKE '%Guide%'; -- 前后模糊,全表扫描
+```
+
+### OR 连接与 Index Merge
+
+- **核心定义**:在 `OR` 连接的多个条件中,只要有**任意一列没有索引**,MySQL 就会放弃所有索引转而执行全表扫描。
+- **Index Merge 机制**:若 `OR` 两侧都有索引,MySQL 5.1+ 可能会触发**索引合并(Index Merge)**优化,分别扫描两个索引后取并集。不过,如果两个索引过滤后的数据量都很大,合并结果集的成本可能高于全表扫描,依然会放弃索引。
+- **落地建议**:
+ - 优先将 `OR` 改写为 `UNION ALL`。`UNION ALL` 可以让每一段查询独立使用索引,且规避了优化器对 `OR` 成本估算不准的问题。
+ - 注意:只有当确定结果集不重复时才用 `UNION ALL`,否则需用 `UNION`(涉及临时表去重,有额外开销)。
+
+失效示例:
+
+```sql
+-- 假设 sname 和 address 都有索引,但各匹配 30%+ 数据
+SELECT * FROM students WHERE sname = '学生 1' OR address = '上海'; -- 可能放弃索引,全表扫描
+
+-- 建议改写为
+SELECT * FROM students WHERE sname = '学生 1'
+UNION ALL
+SELECT * FROM students WHERE address = '上海'; -- 各自走索引
+```
+
+**验证方式**:`EXPLAIN` 中若出现 `type: index_merge` 和 `Extra: Using union; Using where`,说明使用了 Index Merge。
+
+### IN / NOT IN 使用不当
+
+**`IN` 列表长度**:
+
+- `eq_range_index_dive_limit`(默认 **200**)并不直接导致索引失效,而是影响**行数估算策略**:
+ - **<= 200**:MySQL 使用 **Index Dive**(深入索引树探测)精确估算行数,成本估算准确,索引大概率有效。
+ - **> 200**:当 `IN` 列表长度超过 `eq_range_index_dive_limit`(MySQL 5.7.4+ 默认为 200)时,优化器从精确的 Index Dive 切换为基于 `index_statistics` 的估算。若表数据的基数(Cardinality)统计陈旧,可能导致估算成本异常,从而放弃走范围扫描(Range Scan)而选择全表扫描。
+- 可通过调大 `eq_range_index_dive_limit` 或改写为 `JOIN` 临时表来规避。
+
+**`NOT IN`** :
+
+- **常量列表**(如 `NOT IN (1,2,3)`):通常全表扫描,因需遍历整个 B+ 树证明"不在集合中"。
+- **子查询关联索引列**:`WHERE id NOT IN (SELECT user_id FROM orders WHERE user_id > 1000)` 可用 `orders` 表的 `user_id` 索引。
+- **推荐替代**:优先使用 `NOT EXISTS` 或 `LEFT JOIN / IS NULL`,性能更优且语义更清晰。
+
+失效示例:
+
+```sql
+SELECT * FROM students WHERE s_code IN (1, 2, 3, ..., 500); -- 列表过长,可能改用统计估算导致误判
+SELECT * FROM students WHERE s_code NOT IN (1, 2, 3); -- 常量列表,全表扫描
+```
+
+### 隐式类型转换
+
+这是开发中最隐蔽的坑,**转换的方向决定了索引的生死**。
+
+| 场景 | 示例 | 转换方向 | 索引是否有效 |
+| --------------------- | ------------------- | ---------------------------- | ------------ |
+| **字符串列 + 数字值** | `varchar_col = 123` | 字符串转数字(发生在索引列) | ❌ 失效 |
+| **数字列 + 字符串值** | `int_col = '123'` | 字符串转数字(发生在常量) | ✅ 有效 |
+
+**关键点**:
+
+- 只有当**转换发生在索引列上**时,索引才会失效。
+- 当字符串与数字进行比较时,MySQL 默认将字符串转换为**浮点数(DOUBLE)**进行比较(详见 [MySQL 官方文档规则 7](https://dev.mysql.com/doc/refman/8.0/en/type-conversion.html))。对索引列发生隐式类型转换等同于在索引列上应用了不可逆的转换函数,破坏了 B+ 树的有序性,导致只能走全表扫描。
+- `int_col = '123'` 会被转换为 `int_col = CAST('123' AS DOUBLE)`,转换发生在常量侧,不影响索引使用。
+
+**详细介绍**:[MySQL隐式转换造成索引失效](https://javaguide.cn/database/mysql/index-invalidation-caused-by-implicit-conversion.html)
+
+### ORDER BY 排序优化陷阱
+
+即使 `WHERE` 条件精准,如果 `ORDER BY` 处理不好,依然会出现慢查询。
+
+**触发 `Using filesort` 的条件**:
+
+- 排序字段不在索引中
+- 索引顺序与 `ORDER BY` 不一致(如索引 `(a,b)` 但 `ORDER BY b,a`)
+- `WHERE` 与 `ORDER BY` 分别使用不同索引
+- 排序列包含 `SELECT *` 中非索引列(需回表排序)
+
+**优化方案**:
+
+- 利用**覆盖索引**同时满足 `WHERE` 和 `ORDER BY`。例如索引为 `(name, age)`,查询 `SELECT name, age FROM users WHERE name = 'A' ORDER BY age`。
+- 调整索引顺序以匹配 `ORDER BY`。
+
+**验证方式**:`EXPLAIN` 中 `Extra` 列出现 `Using filesort` 即表示触发了排序。
+
+### 总结
+
+本文系统梳理了 MySQL 索引失效的常见场景,从底层机制上可归纳为以下两大核心类:
+
+**1. SQL 写法与底层逻辑冲突(破坏 B+Tree 有序性)**
+
+此类问题最为常见,本质是查询条件让底层的 B+Tree 失去了“二分查找”的快速定位能力。
+
+- **违背最左前缀原则**:跳过联合索引前导列,或遇到范围查询(如 `>`、`<`、`BETWEEN`、`LIKE "abc%"`)导致后续列中断精确定位,降级为范围扫描加过滤。
+- **对索引列进行加工**:在 `WHERE` 左侧对索引列进行数学计算或应用函数,导致原始数据发生逻辑改变,在索引树中呈现无序状态。
+- **隐式类型转换(隐蔽且致命)**:当“字符串类型的列”去比较“数字类型的值”时,MySQL 会默认在列上套用转换函数,直接破坏树的有序性。
+- **LIKE 模糊查询前置通配符**:如 `LIKE "%abc"`,前缀字符的不确定性使得优化器无法锁定扫描区间的起始点。
+- **ORDER BY 排序陷阱**:排序列未命中索引、排序方向与索引结构不一致等触发额外的内存或磁盘排序(`Using filesort`)。
+
+**2. 优化器的成本决策(基于 I/O 成本妥协)**
+
+此类问题并非索引本身不可用,而是 MySQL 优化器经过计算后,认为“不走普通索引”整体开销反而更小。
+
+- **无脑 `SELECT \*` 导致回表成本超载**:查询大量非索引覆盖列时,若命中数据量较大(通常超 20%~30%),优化器会判定全表扫描的顺序 I/O 优于频繁回表的随机 I/O,从而主动放弃索引。
+- **`OR` 条件导致全表扫描**:只要 `OR` 连接的任意一侧条件没有对应索引,就会触发全表扫描。即使两侧都有索引,若 Index Merge(索引合并)的预期成本过高,依然会被放弃。
+- **`IN` 列表过长引发估算失真**:当 `IN` 列表长度超过系统阈值(默认 200)时,优化器会从精准的深入探测(Index Dive)切换为粗略的统计估算,极易因统计信息陈旧而产生执行成本的误判。
+
+**实战建议**:
+
+1. **养成 `EXPLAIN` 分析习惯**:在编写复杂 SQL 后,务必使用 `EXPLAIN` 分析执行计划,重点关注 `type`、`key`、`rows`、`Extra` 字段。
+2. **遵循覆盖索引原则**:尽量避免 `SELECT *`,只查询必要字段,让索引覆盖查询需求,减少回表开销。
+3. **规范数据类型使用**:保持查询条件与字段类型一致,避免隐式类型转换。
+4. **合理设计联合索引**:按照查询频率和选择性安排字段顺序,优先满足高频查询场景。
+5. **大规模模糊搜索考虑 ES**:对于前后模糊查询(`%keyword%`),建议使用 Elasticsearch 等搜索引擎。
+
+索引优化是数据库性能优化的基本功,但也需要结合实际业务场景和数据分布进行权衡。理解索引失效的根本原因,才能在遇到性能问题时快速定位并解决。
+
+**延伸阅读**:
+
+- [MySQL 索引详解](https://javaguide.cn/database/mysql/mysql-index.html)
+- [MySQL 执行计划分析](https://javaguide.cn/database/mysql/mysql-query-execution-plan.html)
+- [MySQL 隐式转换造成索引失效](https://javaguide.cn/database/mysql/index-invalidation-caused-by-implicit-conversion.html)
diff --git a/docs/database/mysql/mysql-index.md b/docs/database/mysql/mysql-index.md
index b29d31243cc..dfdf5aa0330 100644
--- a/docs/database/mysql/mysql-index.md
+++ b/docs/database/mysql/mysql-index.md
@@ -1,8 +1,13 @@
---
title: MySQL索引详解
+description: MySQL索引详解,深入剖析B+树索引结构、聚簇索引与二级索引的区别、联合索引与最左前缀原则、覆盖索引与索引下推优化,以及常见的索引失效场景。
category: 数据库
tag:
- MySQL
+head:
+ - - meta
+ - name: keywords
+ content: MySQL索引,B+树索引,聚簇索引,覆盖索引,联合索引,索引下推,回表查询,索引失效,最左前缀原则
---
> 感谢[WT-AHA](https://github.com/WT-AHA)对本文的完善,相关 PR: 。
@@ -15,31 +20,37 @@ tag:
**索引是一种用于快速查询和检索数据的数据结构,其本质可以看成是一种排序好的数据结构。**
-索引的作用就相当于书的目录。打个比方: 我们在查字典的时候,如果没有目录,那我们就只能一页一页的去找我们需要查的那个字,速度很慢。如果有目录了,我们只需要先去目录里查找字的位置,然后直接翻到那一页就行了。
+索引的作用就相当于书的目录。打个比方:我们在查字典的时候,如果没有目录,那我们就只能一页一页地去找我们需要查的那个字,速度很慢;如果有目录了,我们只需要先去目录里查找字的位置,然后直接翻到那一页就行了。
-索引底层数据结构存在很多种类型,常见的索引结构有: B 树, B+树 和 Hash、红黑树。在 MySQL 中,无论是 Innodb 还是 MyIsam,都使用了 B+树作为索引结构。
+索引底层数据结构存在很多种类型,常见的索引结构有:B 树、 B+ 树 和 Hash、红黑树。在 MySQL 中,无论是 Innodb 还是 MyISAM,都使用了 B+ 树作为索引结构。
## 索引的优缺点
-**优点**:
+**索引的优点:**
-- 使用索引可以大大加快 数据的检索速度(大大减少检索的数据量), 这也是创建索引的最主要的原因。
-- 通过创建唯一性索引,可以保证数据库表中每一行数据的唯一性。
+1. **查询速度起飞 (主要目的)**:通过索引,数据库可以**大幅减少需要扫描的数据量**,直接定位到符合条件的记录,从而显著加快数据检索速度,减少磁盘 I/O 次数。
+2. **保证数据唯一性**:通过创建**唯一索引 (Unique Index)**,可以确保表中的某一列(或几列组合)的值是独一无二的,比如用户 ID、邮箱等。主键本身就是一种唯一索引。
+3. **加速排序和分组**:如果查询中的 ORDER BY 或 GROUP BY 子句涉及的列建有索引,数据库往往可以直接利用索引已经排好序的特性,避免额外的排序操作,从而提升性能。
-**缺点**:
+**索引的缺点:**
-- 创建索引和维护索引需要耗费许多时间。当对表中的数据进行增删改的时候,如果数据有索引,那么索引也需要动态的修改,会降低 SQL 执行效率。
-- 索引需要使用物理文件存储,也会耗费一定空间。
+1. **创建和维护耗时**:创建索引本身需要时间,特别是对大表操作时。更重要的是,当对表中的数据进行**增、删、改 (DML 操作)** 时,不仅要操作数据本身,相关的索引也必须动态更新和维护,这会**降低这些 DML 操作的执行效率**。
+2. **占用存储空间**:索引本质上也是一种数据结构,需要以物理文件(或内存结构)的形式存储,因此会**额外占用一定的磁盘空间**。索引越多、越大,占用的空间也就越多。
+3. **可能被误用或失效**:如果索引设计不当,或者查询语句写得不好,数据库优化器可能不会选择使用索引(或者选错索引),反而导致性能下降。
-但是,**使用索引一定能提高查询性能吗?**
+**那么,用了索引就一定能提高查询性能吗?**
-大多数情况下,索引查询都是比全表扫描要快的。但是如果数据库的数据量不大,那么使用索引也不一定能够带来很大提升。
+**不一定。** 大多数情况下,合理使用索引确实比全表扫描快得多。但也有例外:
+
+- **数据量太小**:如果表里的数据非常少(比如就几百条),全表扫描可能比通过索引查找更快,因为走索引本身也有开销。
+- **查询结果集占比过大**:如果要查询的数据占了整张表的大部分(比如超过 20%-30%),优化器可能会认为全表扫描更划算,因为通过索引多次回表(随机 I/O)的成本可能高于一次顺序的全表扫描。
+- **索引维护不当或统计信息过时**:导致优化器做出错误判断。
## 索引底层数据结构选型
### Hash 表
-哈希表是键值对的集合,通过键(key)即可快速取出对应的值(value),因此哈希表可以快速检索数据(接近 O(1))。
+哈希表是键值对的集合,通过键(key)即可快速取出对应的值(value),因此哈希表可以快速检索数据(接近 O(1))。
**为何能够通过 key 快速取出 value 呢?** 原因在于 **哈希算法**(也叫散列算法)。通过哈希算法,我们可以快速找到 key 对应的 index,找到了 index 也就找到了对应的 value。
@@ -50,7 +61,7 @@ index = hash % array_size

-但是!哈希算法有个 **Hash 冲突** 问题,也就是说多个不同的 key 最后得到的 index 相同。通常情况下,我们常用的解决办法是 **链地址法**。链地址法就是将哈希冲突数据存放在链表中。就比如 JDK1.8 之前 `HashMap` 就是通过链地址法来解决哈希冲突的。不过,JDK1.8 以后`HashMap`为了减少链表过长的时候搜索时间过长引入了红黑树。
+但是!哈希算法有个 **Hash 冲突** 问题,也就是说多个不同的 key 最后得到的 index 相同。通常情况下,我们常用的解决办法是 **链地址法**。链地址法就是将哈希冲突数据存放在链表中。就比如 JDK1.8 之前 `HashMap` 就是通过链地址法来解决哈希冲突的。不过,JDK1.8 以后`HashMap`为了提高链表过长时的搜索效率,引入了红黑树。

@@ -60,15 +71,15 @@ MySQL 的 InnoDB 存储引擎不直接支持常规的哈希索引,但是,Inn
既然哈希表这么快,**为什么 MySQL 没有使用其作为索引的数据结构呢?** 主要是因为 Hash 索引不支持顺序和范围查询。假如我们要对表中的数据进行排序或者进行范围查询,那 Hash 索引可就不行了。并且,每次 IO 只能取一个。
-试想一种情况:
+试想一种情况:
```java
SELECT * FROM tb1 WHERE id < 500;
```
-在这种范围查询中,优势非常大,直接遍历比 500 小的叶子节点就够了。而 Hash 索引是根据 hash 算法来定位的,难不成还要把 1 - 499 的数据,每个都进行一次 hash 计算来定位吗?这就是 Hash 最大的缺点了。
+在这种范围查询中,优势非常大,直接遍历比 500 小的叶子节点就够了。而 Hash 索引是根据 hash 算法来定位的,难不成还要把 1 - 499 的数据,每个都进行一次 hash 计算来定位吗?这就是 Hash 最大的缺点了。
-### 二叉查找树(BST)
+### 二叉查找树(BST)
二叉查找树(Binary Search Tree)是一种基于二叉树的数据结构,它具有以下特点:
@@ -76,7 +87,7 @@ SELECT * FROM tb1 WHERE id < 500;
2. 右子树所有节点的值均大于根节点的值。
3. 左右子树也分别为二叉查找树。
-当二叉查找树是平衡的时候,也就是树的每个节点的左右子树深度相差不超过 1 的时候,查询的时间复杂度为 O(log2(N)),具有比较高的效率。然而,当二叉查找树不平衡时,例如在最坏情况下(有序插入节点),树会退化成线性链表(也被称为斜树),导致查询效率急剧下降,时间复杂退化为 O(N)。
+当二叉查找树是平衡的时候,也就是树的每个节点的左右子树深度相差不超过 1 的时候,查询的时间复杂度为 O(log2(N)),具有比较高的效率。然而,当二叉查找树不平衡时,例如在最坏情况下(有序插入节点),树会退化成线性链表(也被称为斜树),导致查询效率急剧下降,时间复杂退化为 O(N)。

@@ -88,11 +99,11 @@ SELECT * FROM tb1 WHERE id < 500;
AVL 树是计算机科学中最早被发明的自平衡二叉查找树,它的名称来自于发明者 G.M. Adelson-Velsky 和 E.M. Landis 的名字缩写。AVL 树的特点是保证任何节点的左右子树高度之差不超过 1,因此也被称为高度平衡二叉树,它的查找、插入和删除在平均和最坏情况下的时间复杂度都是 O(logn)。
-
+
AVL 树采用了旋转操作来保持平衡。主要有四种旋转操作:LL 旋转、RR 旋转、LR 旋转和 RL 旋转。其中 LL 旋转和 RR 旋转分别用于处理左左和右右失衡,而 LR 旋转和 RL 旋转则用于处理左右和右左失衡。
-由于 AVL 树需要频繁地进行旋转操作来保持平衡,因此会有较大的计算开销进而降低了查询性能。并且, 在使用 AVL 树时,每个树节点仅存储一个数据,而每次进行磁盘 IO 时只能读取一个节点的数据,如果需要查询的数据分布在多个节点上,那么就需要进行多次磁盘 IO。 **磁盘 IO 是一项耗时的操作,在设计数据库索引时,我们需要优先考虑如何最大限度地减少磁盘 IO 操作的次数。**
+由于 AVL 树需要频繁地进行旋转操作来保持平衡,因此会有较大的计算开销进而降低了数据库写操作的性能。并且, 在使用 AVL 树时,每个树节点仅存储一个数据,而每次进行磁盘 IO 时只能读取一个节点的数据,如果需要查询的数据分布在多个节点上,那么就需要进行多次磁盘 IO。**磁盘 IO 是一项耗时的操作,在设计数据库索引时,我们需要优先考虑如何最大限度地减少磁盘 IO 操作的次数。**
实际应用中,AVL 树使用的并不多。
@@ -104,7 +115,7 @@ AVL 树采用了旋转操作来保持平衡。主要有四种旋转操作:LL
2. 根节点总是黑色的;
3. 每个叶子节点都是黑色的空节点(NIL 节点);
4. 如果节点是红色的,则它的子节点必须是黑色的(反之不一定);
-5. 从根节点到叶节点或空子节点的每条路径,必须包含相同数目的黑色节点(即相同的黑色高度)。
+5. 从任意节点到它的叶子节点或空子节点的每条路径,必须包含相同数目的黑色节点(即相同的黑色高度)。

@@ -112,26 +123,26 @@ AVL 树采用了旋转操作来保持平衡。主要有四种旋转操作:LL
**红黑树的应用还是比较广泛的,TreeMap、TreeSet 以及 JDK1.8 的 HashMap 底层都用到了红黑树。对于数据在内存中的这种情况来说,红黑树的表现是非常优异的。**
-### B 树& B+树
+### B 树& B+ 树
-B 树也称 B-树,全称为 **多路平衡查找树** ,B+ 树是 B 树的一种变体。B 树和 B+树中的 B 是 `Balanced` (平衡)的意思。
+B 树也称 B- 树,全称为 **多路平衡查找树**,B+ 树是 B 树的一种变体。B 树和 B+ 树中的 B 是 `Balanced`(平衡)的意思。
目前大部分数据库系统及文件系统都采用 B-Tree 或其变种 B+Tree 作为索引结构。
-**B 树& B+树两者有何异同呢?**
+**B 树& B+ 树两者有何异同呢?**
-- B 树的所有节点既存放键(key) 也存放数据(data),而 B+树只有叶子节点存放 key 和 data,其他内节点只存放 key。
-- B 树的叶子节点都是独立的;B+树的叶子节点有一条引用链指向与它相邻的叶子节点。
-- B 树的检索的过程相当于对范围内的每个节点的关键字做二分查找,可能还没有到达叶子节点,检索就结束了。而 B+树的检索效率就很稳定了,任何查找都是从根节点到叶子节点的过程,叶子节点的顺序检索很明显。
-- 在 B 树中进行范围查询时,首先找到要查找的下限,然后对 B 树进行中序遍历,直到找到查找的上限;而 B+树的范围查询,只需要对链表进行遍历即可。
+- B 树的所有节点既存放键(key)也存放数据(data),而 B+ 树只有叶子节点存放 key 和 data,其他内节点只存放 key。
+- B 树的叶子节点都是独立的;B+ 树的叶子节点有一条引用链指向与它相邻的叶子节点。
+- B 树的检索的过程相当于对范围内的每个节点的关键字做二分查找,可能还没有到达叶子节点,检索就结束了。而 B+ 树的检索效率就很稳定了,任何查找都是从根节点到叶子节点的过程,叶子节点的顺序检索很明显。
+- 在 B 树中进行范围查询时,首先找到要查找的下限,然后对 B 树进行中序遍历,直到找到查找的上限;而 B+ 树的范围查询,只需要对链表进行遍历即可。
-综上,B+树与 B 树相比,具备更少的 IO 次数、更稳定的查询效率和更适于范围查询这些优势。
+综上,B+ 树与 B 树相比,具备更少的 IO 次数、更稳定的查询效率和更适于范围查询这些优势。
在 MySQL 中,MyISAM 引擎和 InnoDB 引擎都是使用 B+Tree 作为索引结构,但是,两者的实现方式不太一样。(下面的内容整理自《Java 工程师修炼之道》)
> MyISAM 引擎中,B+Tree 叶节点的 data 域存放的是数据记录的地址。在索引检索的时候,首先按照 B+Tree 搜索算法搜索索引,如果指定的 Key 存在,则取出其 data 域的值,然后以 data 域的值为地址读取相应的数据记录。这被称为“**非聚簇索引(非聚集索引)**”。
>
-> InnoDB 引擎中,其数据文件本身就是索引文件。相比 MyISAM,索引文件和数据文件是分离的,其表数据文件本身就是按 B+Tree 组织的一个索引结构,树的叶节点 data 域保存了完整的数据记录。这个索引的 key 是数据表的主键,因此 InnoDB 表数据文件本身就是主索引。这被称为“**聚簇索引(聚集索引)**”,而其余的索引都作为 **辅助索引** ,辅助索引的 data 域存储相应记录主键的值而不是地址,这也是和 MyISAM 不同的地方。在根据主索引搜索时,直接找到 key 所在的节点即可取出数据;在根据辅助索引查找时,则需要先取出主键的值,再走一遍主索引。 因此,在设计表的时候,不建议使用过长的字段作为主键,也不建议使用非单调的字段作为主键,这样会造成主索引频繁分裂。
+> InnoDB 引擎中,其数据文件本身就是索引文件。相比 MyISAM,索引文件和数据文件是分离的,其表数据文件本身就是按 B+Tree 组织的一个索引结构,树的叶节点 data 域保存了完整的数据记录。这个索引的 key 是数据表的主键,因此 InnoDB 表数据文件本身就是主索引。这被称为“**聚簇索引(聚集索引)**”,而其余的索引都作为 **辅助索引**,辅助索引的 data 域存储相应记录主键的值而不是地址,这也是和 MyISAM 不同的地方。在根据主索引搜索时,直接找到 key 所在的节点即可取出数据;在根据辅助索引查找时,则需要先取出主键的值,再走一遍主索引。 因此,在设计表的时候,不建议使用过长的字段作为主键,也不建议使用非单调的字段作为主键,这样会造成主索引频繁分裂。
## 索引类型总结
@@ -140,12 +151,12 @@ B 树也称 B-树,全称为 **多路平衡查找树** ,B+ 树是 B 树的一
- BTree 索引:MySQL 里默认和最常用的索引类型。只有叶子节点存储 value,非叶子节点只有指针和 key。存储引擎 MyISAM 和 InnoDB 实现 BTree 索引都是使用 B+Tree,但二者实现方式不一样(前面已经介绍了)。
- 哈希索引:类似键值对的形式,一次即可定位。
- RTree 索引:一般不会使用,仅支持 geometry 数据类型,优势在于范围查找,效率较低,通常使用搜索引擎如 ElasticSearch 代替。
-- 全文索引:对文本的内容进行分词,进行搜索。目前只有 `CHAR`、`VARCHAR` ,`TEXT` 列上可以创建全文索引。一般不会使用,效率较低,通常使用搜索引擎如 ElasticSearch 代替。
+- 全文索引:对文本的内容进行分词,进行搜索。目前只有 `CHAR`、`VARCHAR`、`TEXT` 列上可以创建全文索引。一般不会使用,效率较低,通常使用搜索引擎如 ElasticSearch 代替。
按照底层存储方式角度划分:
- 聚簇索引(聚集索引):索引结构和数据一起存放的索引,InnoDB 中的主键索引就属于聚簇索引。
-- 非聚簇索引(非聚集索引):索引结构和数据分开存放的索引,二级索引(辅助索引)就属于非聚簇索引。MySQL 的 MyISAM 引擎,不管主键还是非主键,使用的都是非聚簇索引。
+- 非聚簇索引(非聚集索引):索引结构和数据分开存放的索引,二级索引(辅助索引)就属于非聚簇索引。MySQL 的 MyISAM 引擎,不管主键还是非主键,使用的都是非聚簇索引。
按照应用维度划分:
@@ -154,7 +165,8 @@ B 树也称 B-树,全称为 **多路平衡查找树** ,B+ 树是 B 树的一
- 唯一索引:加速查询 + 列值唯一(可以有 NULL)。
- 覆盖索引:一个索引包含(或者说覆盖)所有需要查询的字段的值。
- 联合索引:多列值组成一个索引,专门用于组合搜索,其效率大于索引合并。
-- 全文索引:对文本的内容进行分词,进行搜索。目前只有 `CHAR`、`VARCHAR` ,`TEXT` 列上可以创建全文索引。一般不会使用,效率较低,通常使用搜索引擎如 ElasticSearch 代替。
+- 全文索引:对文本的内容进行分词,进行搜索。目前只有 `CHAR`、`VARCHAR`、`TEXT` 列上可以创建全文索引。一般不会使用,效率较低,通常使用搜索引擎如 ElasticSearch 代替。
+- 前缀索引:对文本的前几个字符创建索引,相比普通索引建立的数据更小,因为只取前几个字符。
MySQL 8.x 中实现的索引新特性:
@@ -162,7 +174,7 @@ MySQL 8.x 中实现的索引新特性:
- 降序索引:之前的版本就支持通过 desc 来指定索引为降序,但实际上创建的仍然是常规的升序索引。直到 MySQL 8.x 版本才开始真正支持降序索引。另外,在 MySQL 8.x 版本中,不再对 GROUP BY 语句进行隐式排序。
- 函数索引:从 MySQL 8.0.13 版本开始支持在索引中使用函数或者表达式的值,也就是在索引中可以包含函数或者表达式。
-## 主键索引(Primary Key)
+## 主键索引(Primary Key)
数据表的主键列使用的就是主键索引。
@@ -174,19 +186,18 @@ MySQL 8.x 中实现的索引新特性:
## 二级索引
-**二级索引(Secondary Index)又称为辅助索引,是因为二级索引的叶子节点存储的数据是主键。也就是说,通过二级索引,可以定位主键的位置。**
+二级索引(Secondary Index)的叶子节点存储的数据是主键的值,也就是说,通过二级索引可以定位主键的位置,二级索引又称为辅助索引/非主键索引。
-唯一索引,普通索引,前缀索引等索引属于二级索引。
+唯一索引、普通索引、前缀索引等索引都属于二级索引。
-PS: 不懂的同学可以暂存疑,慢慢往下看,后面会有答案的,也可以自行搜索。
+PS:不懂的同学可以暂存疑,慢慢往下看,后面会有答案的,也可以自行搜索。
-1. **唯一索引(Unique Key)**:唯一索引也是一种约束。唯一索引的属性列不能出现重复的数据,但是允许数据为 NULL,一张表允许创建多个唯一索引。 建立唯一索引的目的大部分时候都是为了该属性列的数据的唯一性,而不是为了查询效率。
-2. **普通索引(Index)**:普通索引的唯一作用就是为了快速查询数据,一张表允许创建多个普通索引,并允许数据重复和 NULL。
-3. **前缀索引(Prefix)**:前缀索引只适用于字符串类型的数据。前缀索引是对文本的前几个字符创建索引,相比普通索引建立的数据更小,
- 因为只取前几个字符。
-4. **全文索引(Full Text)**:全文索引主要是为了检索大文本数据中的关键字的信息,是目前搜索引擎数据库使用的一种技术。Mysql5.6 之前只有 MYISAM 引擎支持全文索引,5.6 之后 InnoDB 也支持了全文索引。
+1. **唯一索引(Unique Key)**:唯一索引也是一种约束。唯一索引的属性列不能出现重复的数据,但是允许数据为 NULL,一张表允许创建多个唯一索引。 建立唯一索引的目的大部分时候都是为了该属性列的数据的唯一性,而不是为了查询效率。
+2. **普通索引(Index)**:普通索引的唯一作用就是为了快速查询数据。一张表允许创建多个普通索引,并允许数据重复和 NULL。
+3. **前缀索引(Prefix)**:前缀索引只适用于字符串类型的数据。前缀索引是对文本的前几个字符创建索引,相比普通索引建立的数据更小,因为只取前几个字符。
+4. **全文索引(Full Text)**:全文索引主要是为了检索大文本数据中的关键字的信息,是目前搜索引擎数据库使用的一种技术。Mysql5.6 之前只有 MyISAM 引擎支持全文索引,5.6 之后 InnoDB 也支持了全文索引。
-二级索引:
+二级索引:

@@ -196,27 +207,27 @@ PS: 不懂的同学可以暂存疑,慢慢往下看,后面会有答案的,
#### 聚簇索引介绍
-**聚簇索引(Clustered Index)即索引结构和数据一起存放的索引,并不是一种单独的索引类型。InnoDB 中的主键索引就属于聚簇索引。**
+聚簇索引(Clustered Index)即索引结构和数据一起存放的索引,并不是一种单独的索引类型。InnoDB 中的主键索引就属于聚簇索引。
-在 MySQL 中,InnoDB 引擎的表的 `.ibd`文件就包含了该表的索引和数据,对于 InnoDB 引擎表来说,该表的索引(B+树)的每个非叶子节点存储索引,叶子节点存储索引和索引对应的数据。
+在 MySQL 中,InnoDB 引擎的表的 `.ibd`文件就包含了该表的索引和数据,对于 InnoDB 引擎表来说,该表的索引(B+ 树)的每个非叶子节点存储索引,叶子节点存储索引和索引对应的数据。
#### 聚簇索引的优缺点
**优点**:
-- **查询速度非常快**:聚簇索引的查询速度非常的快,因为整个 B+树本身就是一颗多叉平衡树,叶子节点也都是有序的,定位到索引的节点,就相当于定位到了数据。相比于非聚簇索引, 聚簇索引少了一次读取数据的 IO 操作。
+- **查询速度非常快**:聚簇索引的查询速度非常的快,因为整个 B+ 树本身就是一颗多叉平衡树,叶子节点也都是有序的,定位到索引的节点,就相当于定位到了数据。相比于非聚簇索引, 聚簇索引少了一次读取数据的 IO 操作。
- **对排序查找和范围查找优化**:聚簇索引对于主键的排序查找和范围查找速度非常快。
**缺点**:
-- **依赖于有序的数据**:因为 B+树是多路平衡树,如果索引的数据不是有序的,那么就需要在插入时排序,如果数据是整型还好,否则类似于字符串或 UUID 这种又长又难比较的数据,插入或查找的速度肯定比较慢。
+- **依赖于有序的数据**:因为 B+ 树是多路平衡树,如果索引的数据不是有序的,那么就需要在插入时排序,如果数据是整型还好,否则类似于字符串或 UUID 这种又长又难比较的数据,插入或查找的速度肯定比较慢。
- **更新代价大**:如果对索引列的数据被修改时,那么对应的索引也将会被修改,而且聚簇索引的叶子节点还存放着数据,修改代价肯定是较大的,所以对于主键索引来说,主键一般都是不可被修改的。
### 非聚簇索引(非聚集索引)
#### 非聚簇索引介绍
-**非聚簇索引(Non-Clustered Index)即索引结构和数据分开存放的索引,并不是一种单独的索引类型。二级索引(辅助索引)就属于非聚簇索引。MySQL 的 MyISAM 引擎,不管主键还是非主键,使用的都是非聚簇索引。**
+非聚簇索引(Non-Clustered Index)即索引结构和数据分开存放的索引,并不是一种单独的索引类型。二级索引(辅助索引)就属于非聚簇索引。MySQL 的 MyISAM 引擎,不管主键还是非主键,使用的都是非聚簇索引。
非聚簇索引的叶子节点并不一定存放数据的指针,因为二级索引的叶子节点就存放的是主键,根据主键再回表查数据。
@@ -224,22 +235,22 @@ PS: 不懂的同学可以暂存疑,慢慢往下看,后面会有答案的,
**优点**:
-更新代价比聚簇索引要小 。非聚簇索引的更新代价就没有聚簇索引那么大了,非聚簇索引的叶子节点是不存放数据的
+更新代价比聚簇索引要小。非聚簇索引的更新代价就没有聚簇索引那么大了,非聚簇索引的叶子节点是不存放数据的。
**缺点**:
-- **依赖于有序的数据**:跟聚簇索引一样,非聚簇索引也依赖于有序的数据
-- **可能会二次查询(回表)**:这应该是非聚簇索引最大的缺点了。 当查到索引对应的指针或主键后,可能还需要根据指针或主键再到数据文件或表中查询。
+- **依赖于有序的数据**:跟聚簇索引一样,非聚簇索引也依赖于有序的数据。
+- **可能会二次查询(回表)**:这应该是非聚簇索引最大的缺点了。当查到索引对应的指针或主键后,可能还需要根据指针或主键再到数据文件或表中查询。
-这是 MySQL 的表的文件截图:
+这是 MySQL 的表的文件截图:

-聚簇索引和非聚簇索引:
+聚簇索引和非聚簇索引:

-#### 非聚簇索引一定回表查询吗(覆盖索引)?
+#### 非聚簇索引一定回表查询吗(覆盖索引)?
**非聚簇索引不一定回表查询。**
@@ -251,7 +262,7 @@ PS: 不懂的同学可以暂存疑,慢慢往下看,后面会有答案的,
那么这个索引的 key 本身就是 name,查到对应的 name 直接返回就行了,无需回表查询。
-即使是 MYISAM 也是这样,虽然 MYISAM 的主键索引确实需要回表,因为它的主键索引的叶子节点存放的是指针。但是!**如果 SQL 查的就是主键呢?**
+即使是 MyISAM 也是这样,虽然 MyISAM 的主键索引确实需要回表,因为它的主键索引的叶子节点存放的是指针。但是!**如果 SQL 查的就是主键呢?**
```sql
SELECT id FROM table WHERE id=1;
@@ -263,7 +274,9 @@ SELECT id FROM table WHERE id=1;
### 覆盖索引
-如果一个索引包含(或者说覆盖)所有需要查询的字段的值,我们就称之为 **覆盖索引(Covering Index)** 。我们知道在 InnoDB 存储引擎中,如果不是主键索引,叶子节点存储的是主键+列值。最终还是要“回表”,也就是要通过主键再查找一次,这样就会比较慢。而覆盖索引就是把要查询出的列和索引是对应的,不做回表操作!
+如果一个索引包含(或者说覆盖)所有需要查询的字段的值,我们就称之为 **覆盖索引(Covering Index)**。
+
+在 InnoDB 存储引擎中,非主键索引的叶子节点包含的是主键的值。这意味着,当使用非主键索引进行查询时,数据库会先找到对应的主键值,然后再通过主键索引来定位和检索完整的行数据。这个过程被称为“回表”。
**覆盖索引即需要查询的字段正好是索引的字段,那么直接根据该索引,就可以查到数据了,而无需回表查询。**
@@ -274,7 +287,7 @@ SELECT id FROM table WHERE id=1;
我们这里简单演示一下覆盖索引的效果。
-1、创建一个名为 `cus_order` 的表,来实际测试一下这种排序方式。为了测试方便, `cus_order` 这张表只有 `id`、`score`、`name`这 3 个字段。
+1、创建一个名为 `cus_order` 的表,来实际测试一下这种排序方式。为了测试方便,`cus_order` 这张表只有 `id`、`score`、`name` 这 3 个字段。
```sql
CREATE TABLE `cus_order` (
@@ -314,10 +327,11 @@ CALL BatchinsertDataToCusOder(1, 1000000); # 插入100w+的随机数据
为了能够对这 100w 数据按照 `score` 进行排序,我们需要执行下面的 SQL 语句。
```sql
-SELECT `score`,`name` FROM `cus_order` ORDER BY `score` DESC;#降序排序
+#降序排序
+SELECT `score`,`name` FROM `cus_order` ORDER BY `score` DESC;
```
-使用 `EXPLAIN` 命令分析这条 SQL 语句,通过 `Extra` 这一列的 `Using filesort` ,我们发现是没有用到覆盖索引的。
+使用 `EXPLAIN` 命令分析这条 SQL 语句,通过 `Extra` 这一列的 `Using filesort`,我们发现是没有用到覆盖索引的。

@@ -333,7 +347,7 @@ ALTER TABLE `cus_order` ADD INDEX id_score_name(score, name);

-通过 `Extra` 这一列的 `Using index` ,说明这条 SQL 语句成功使用了覆盖索引。
+通过 `Extra` 这一列的 `Using index`,说明这条 SQL 语句成功使用了覆盖索引。
关于 `EXPLAIN` 命令的详细介绍请看:[MySQL 执行计划分析](./mysql-query-execution-plan.md)这篇文章。
@@ -349,70 +363,174 @@ ALTER TABLE `cus_order` ADD INDEX id_score_name(score, name);
### 最左前缀匹配原则
-最左前缀匹配原则指的是,在使用联合索引时,**MySQL** 会根据联合索引中的字段顺序,从左到右依次到查询条件中去匹配,如果查询条件中存在与联合索引中最左侧字段相匹配的字段,则就会使用该字段过滤一批数据,直至联合索引中全部字段匹配完成,或者在执行过程中遇到范围查询(如 **`>`**、**`<`** )才会停止匹配。对于 **`>=`**、**`<=`**、**`BETWEEN`**、**`like`** 前缀匹配的范围查询,并不会停止匹配。所以,我们在使用联合索引时,可以将区分度高的字段放在最左边,这也可以过滤更多数据。
+最左前缀匹配原则指的是在使用联合索引时,MySQL 会根据索引中的字段顺序,从左到右依次匹配查询条件中的字段。如果查询条件与索引中的最左侧字段相匹配,那么 MySQL 就会使用索引来过滤数据,这样可以提高查询效率。
+
+最左匹配原则会一直向右匹配,直到遇到范围查询(如 >、<)为止。对于 >=、<=、BETWEEN 以及前缀匹配 LIKE 的范围查询,不会停止匹配。
+
+假设有一个联合索引 `(column1, column2, column3)`,其从左到右的所有前缀为 `(column1)`、`(column1, column2)`、`(column1, column2, column3)`(创建 1 个联合索引相当于创建了 3 个索引),包含这些列的所有查询都会走索引而不会全表扫描。
+
+我们在使用联合索引时,可以将区分度高的字段放在最左边,这也可以过滤更多数据。
-相关阅读:[联合索引的最左匹配原则全网都在说的一个错误结论](https://mp.weixin.qq.com/s/8qemhRg5MgXs1So5YCv0fQ)。
+我们这里简单演示一下最左前缀匹配的效果。
+
+1、创建一个名为 `student` 的表,这张表只有 `id`、`name`、`class` 这 3 个字段。
+
+```sql
+CREATE TABLE `student` (
+ `id` int NOT NULL,
+ `name` varchar(100) DEFAULT NULL,
+ `class` varchar(100) DEFAULT NULL,
+ PRIMARY KEY (`id`),
+ KEY `name_class_idx` (`name`,`class`)
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
+```
+
+2、下面我们分别测试三条不同的 SQL 语句。
+
+
+
+```sql
+# 可以命中索引
+SELECT * FROM student WHERE name = 'Anne Henry';
+EXPLAIN SELECT * FROM student WHERE name = 'Anne Henry' AND class = 'lIrm08RYVk';
+# 无法命中索引
+SELECT * FROM student WHERE class = 'lIrm08RYVk';
+```
+
+再来看一个常见的面试题:如果有索引 `联合索引(a,b,c)`,查询 `a=1 AND c=1` 会走索引么?`c=1` 呢?`b=1 AND c=1` 呢? `b = 1 AND a = 1 AND c = 1` 呢?
+
+先不要往下看答案,给自己 3 分钟时间想一想。
+
+1. 查询 `a=1 AND c=1`:根据最左前缀匹配原则,查询可以使用索引的前缀部分。因此,该查询仅在 `a=1` 上使用索引,然后对结果进行 `c=1` 的过滤。
+2. 查询 `c=1`:由于查询中不包含最左列 `a`,根据最左前缀匹配原则,整个索引都无法被使用。
+3. 查询 `b=1 AND c=1`:和第二种一样的情况,整个索引都不会使用。
+4. 查询 `b=1 AND a=1 AND c=1`:这个查询是可以用到索引的。查询优化器分析 SQL 语句时,对于联合索引,会对查询条件进行重排序,以便用到索引。会将 `b=1` 和 `a=1` 的条件进行重排序,变成 `a=1 AND b=1 AND c=1`。
+
+MySQL 8.0.13 版本引入了索引跳跃扫描(Index Skip Scan,简称 ISS),它可以在某些索引查询场景下提高查询效率。在没有 ISS 之前,不满足最左前缀匹配原则的联合索引查询中会执行全表扫描。而 ISS 允许 MySQL 在某些情况下避免全表扫描,即使查询条件不符合最左前缀。不过,这个功能比较鸡肋, 和 Oracle 中的没法比,MySQL 8.0.31 还报告了一个 bug:[Bug #109145 Using index for skip scan cause incorrect result](https://bugs.mysql.com/bug.php?id=109145)(后续版本已经修复)。个人建议知道有这个东西就好,不需要深究,实际项目也不一定能用上。
## 索引下推
-**索引下推(Index Condition Pushdown)** 是 **MySQL 5.6** 版本中提供的一项索引优化功能,可以在非聚簇索引遍历过程中,对索引中包含的字段先做判断,过滤掉不符合条件的记录,减少回表次数。
+**索引下推(Index Condition Pushdown,简称 ICP)** 是 **MySQL 5.6** 版本中提供的一项索引优化功能,它允许存储引擎在索引遍历过程中,执行部分 `WHERE` 字句的判断条件,直接过滤掉不满足条件的记录,从而减少回表次数,提高查询效率。
+
+假设我们有一个名为 `user` 的表,其中包含 `id`、`username`、`zipcode` 和 `birthdate` 4 个字段,创建了联合索引 `(zipcode, birthdate)`。
+
+```sql
+CREATE TABLE `user` (
+ `id` int NOT NULL AUTO_INCREMENT,
+ `username` varchar(20) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci NOT NULL,
+ `zipcode` varchar(20) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci NOT NULL,
+ `birthdate` date NOT NULL,
+ PRIMARY KEY (`id`),
+ KEY `idx_username_birthdate` (`zipcode`,`birthdate`) ) ENGINE=InnoDB AUTO_INCREMENT=1001 DEFAULT CHARSET=utf8mb4;
+
+# 查询 zipcode 为 431200 且生日在 3 月的用户
+# birthdate 字段使用函数索引失效
+SELECT * FROM user WHERE zipcode = '431200' AND MONTH(birthdate) = 3;
+```
+
+- 没有索引下推之前,即使 `zipcode` 字段利用索引可以帮助我们快速定位到 `zipcode = '431200'` 的用户,但我们仍然需要对每一个找到的用户进行回表操作,获取完整的用户数据,再去判断 `MONTH(birthdate) = 3`。
+- 有了索引下推之后,存储引擎会在使用 `zipcode` 字段索引查找 `zipcode = '431200'` 的用户时,同时判断 `MONTH(birthdate) = 3`。这样,只有同时满足条件的记录才会被返回,减少了回表次数。
+
+
+
+
+
+再来讲讲索引下推的具体原理,先看下面这张 MySQL 简要架构图。
+
+
+
+MySQL 可以简单分为 Server 层和存储引擎层这两层。Server 层处理查询解析、分析、优化、缓存以及与客户端的交互等操作,而存储引擎层负责数据的存储和读取,MySQL 支持 InnoDB、MyISAM、Memory 等多种存储引擎。
+
+索引下推的 **下推** 其实就是指将部分上层(Server 层)负责的事情,交给了下层(存储引擎层)去处理。
+
+我们这里结合索引下推原理再对上面提到的例子进行解释。
+
+没有索引下推之前:
+
+- 存储引擎层先根据 `zipcode` 索引字段找到所有 `zipcode = '431200'` 的用户的主键 ID,然后二次回表查询,获取完整的用户数据;
+- 存储引擎层把所有 `zipcode = '431200'` 的用户数据全部交给 Server 层,Server 层根据 `MONTH(birthdate) = 3` 这一条件再进一步做筛选。
+
+有了索引下推之后:
+
+- 存储引擎层先根据 `zipcode` 索引字段找到所有 `zipcode = '431200'` 的用户,然后直接判断 `MONTH(birthdate) = 3`,筛选出符合条件的主键 ID;
+- 二次回表查询,根据符合条件的主键 ID 去获取完整的用户数据;
+- 存储引擎层把符合条件的用户数据全部交给 Server 层。
+
+可以看出,**除了可以减少回表次数之外,索引下推还可以减少存储引擎层和 Server 层的数据传输量。**
+
+最后,总结一下索引下推应用范围:
+
+1. 适用于 InnoDB 引擎和 MyISAM 引擎的查询。
+2. 适用于执行计划是 range、ref、eq_ref、ref_or_null 的范围查询。
+3. 对于 InnoDB 表,仅用于非聚簇索引。索引下推的目标是减少全行读取次数,从而减少 I/O 操作。对于 InnoDB 聚集索引,完整的记录已经读入 InnoDB 缓冲区。在这种情况下使用索引下推不会减少 I/O。
+4. 子查询不能使用索引下推,因为子查询通常会创建临时表来处理结果,而这些临时表是没有索引的。
+5. 存储过程不能使用索引下推,因为存储引擎无法调用存储函数。
## 正确使用索引的一些建议
### 选择合适的字段创建索引
-- **不为 NULL 的字段**:索引字段的数据应该尽量不为 NULL,因为对于数据为 NULL 的字段,数据库较难优化。如果字段频繁被查询,但又避免不了为 NULL,建议使用 0,1,true,false 这样语义较为清晰的短值或短字符作为替代。
+- **不为 NULL 的字段**:索引字段的数据应该尽量不为 NULL,因为对于数据为 NULL 的字段,数据库较难优化。如果字段频繁被查询,但又避免不了为 NULL,建议使用 0、1、true、false 这样语义较为清晰的短值或短字符作为替代。
- **被频繁查询的字段**:我们创建索引的字段应该是查询操作非常频繁的字段。
- **被作为条件查询的字段**:被作为 WHERE 条件查询的字段,应该被考虑建立索引。
- **频繁需要排序的字段**:索引已经排序,这样查询可以利用索引的排序,加快排序查询时间。
- **被经常频繁用于连接的字段**:经常用于连接的字段可能是一些外键列,对于外键列并不一定要建立外键,只是说该列涉及到表与表的关系。对于频繁被连接查询的字段,可以考虑建立索引,提高多表连接查询的效率。
+### 避免索引失效
+
+索引失效也是慢查询的主要原因之一,常见的导致索引失效的情况有下面这两类:
+
+**1. SQL 写法与底层逻辑冲突(破坏 B+Tree 有序性)**
+
+此类问题最为常见,本质是查询条件让底层的 B+Tree 失去了“二分查找”的快速定位能力。
+
+- **违背最左前缀原则**:跳过联合索引前导列,或遇到范围查询(如 `>`、`<`、`BETWEEN`、`LIKE "abc%"`)导致后续列中断精确定位,降级为范围扫描加过滤。
+- **对索引列进行加工**:在 `WHERE` 左侧对索引列进行数学计算或应用函数,导致原始数据发生逻辑改变,在索引树中呈现无序状态。
+- **隐式类型转换(隐蔽且致命)**:当“字符串类型的列”去比较“数字类型的值”时,MySQL 会默认在列上套用转换函数,直接破坏树的有序性。
+- **LIKE 模糊查询前置通配符**:如 `LIKE "%abc"`,前缀字符的不确定性使得优化器无法锁定扫描区间的起始点。
+- **ORDER BY 排序陷阱**:排序列未命中索引、排序方向与索引结构不一致等触发额外的内存或磁盘排序(`Using filesort`)。
+
+**2. 优化器的成本决策(基于 I/O 成本妥协)**
+
+此类问题并非索引本身不可用,而是 MySQL 优化器经过计算后,认为“不走普通索引”整体开销反而更小。
+
+- **无脑 `SELECT \*` 导致回表成本超载**:查询大量非索引覆盖列时,若命中数据量较大(通常超 20%~30%),优化器会判定全表扫描的顺序 I/O 优于频繁回表的随机 I/O,从而主动放弃索引。
+- **`OR` 条件导致全表扫描**:只要 `OR` 连接的任意一侧条件没有对应索引,就会触发全表扫描。即使两侧都有索引,若 Index Merge(索引合并)的预期成本过高,依然会被放弃。
+- **`IN` 列表过长引发估算失真**:当 `IN` 列表长度超过系统阈值(默认 200)时,优化器会从精准的深入探测(Index Dive)切换为粗略的统计估算,极易因统计信息陈旧而产生执行成本的误判。
+
+详细介绍:[MySQL索引失效场景总结](https://javaguide.cn/database/mysql/mysql-index-invalidation.html)。
+
### 被频繁更新的字段应该慎重建立索引
虽然索引能带来查询上的效率,但是维护索引的成本也是不小的。 如果一个字段不被经常查询,反而被经常修改,那么就更不应该在这种字段上建立索引了。
### 限制每张表上的索引数量
-索引并不是越多越好,建议单张表索引不超过 5 个!索引可以提高效率同样可以降低效率。
+索引并不是越多越好,建议单张表索引不超过 5 个!索引可以提高效率,同样可以降低效率。
索引可以增加查询效率,但同样也会降低插入和更新的效率,甚至有些情况下会降低查询效率。
-因为 MySQL 优化器在选择如何优化查询时,会根据统一信息,对每一个可以用到的索引来进行评估,以生成出一个最好的执行计划,如果同时有很多个索引都可以用于查询,就会增加 MySQL 优化器生成执行计划的时间,同样会降低查询性能。
+因为 MySQL 优化器在选择如何优化查询时,会根据统计信息,对每一个可以用到的索引来进行评估,以生成出一个最好的执行计划,如果同时有很多个索引都可以用于查询,就会增加 MySQL 优化器生成执行计划的时间,同样会降低查询性能。
### 尽可能的考虑建立联合索引而不是单列索引
-因为索引是需要占用磁盘空间的,可以简单理解为每个索引都对应着一颗 B+树。如果一个表的字段过多,索引过多,那么当这个表的数据达到一个体量后,索引占用的空间也是很多的,且修改索引时,耗费的时间也是较多的。如果是联合索引,多个字段在一个索引上,那么将会节约很大磁盘空间,且修改数据的操作效率也会提升。
+因为索引是需要占用磁盘空间的,可以简单理解为每个索引都对应着一颗 B+ 树。如果一个表的字段过多,索引过多,那么当这个表的数据达到一个体量后,索引占用的空间也是很多的,且修改索引时,耗费的时间也是较多的。如果是联合索引,多个字段在一个索引上,那么将会节约很大磁盘空间,且修改数据的操作效率也会提升。
### 注意避免冗余索引
-冗余索引指的是索引的功能相同,能够命中索引(a, b)就肯定能命中索引(a) ,那么索引(a)就是冗余索引。如(name,city )和(name )这两个索引就是冗余索引,能够命中前者的查询肯定是能够命中后者的 在大多数情况下,都应该尽量扩展已有的索引而不是创建新索引。
+冗余索引指的是索引的功能相同,能够命中索引(a, b)就肯定能命中索引(a) ,那么索引(a)就是冗余索引。如(name,city)和(name)这两个索引就是冗余索引,能够命中前者的查询肯定是能够命中后者的。在大多数情况下,都应该尽量扩展已有的索引而不是创建新索引。
### 字符串类型的字段使用前缀索引代替普通索引
前缀索引仅限于字符串类型,较普通索引会占用更小的空间,所以可以考虑使用前缀索引带替普通索引。
-### 避免索引失效
-
-索引失效也是慢查询的主要原因之一,常见的导致索引失效的情况有下面这些:
-
-- ~~使用 `SELECT *` 进行查询;~~ `SELECT *` 不会直接导致索引失效(如果不走索引大概率是因为 where 查询范围过大导致的),但它可能会带来一些其他的性能问题比如造成网络传输和数据处理的浪费、无法使用索引覆盖;
-- 创建了组合索引,但查询条件未遵守最左匹配原则;
-- 在索引列上进行计算、函数、类型转换等操作;
-- 以 % 开头的 LIKE 查询比如 `LIKE '%abc';`;
-- 查询条件中使用 OR,且 OR 的前后条件中有一个列没有索引,涉及的索引都不会被使用到;
-- IN 的取值范围较大时会导致索引失效,走全表扫描(NOT IN 和 IN 的失效场景相同);
-- 发生[隐式转换](https://javaguide.cn/database/mysql/index-invalidation-caused-by-implicit-conversion.html);
-- ……
-
-推荐阅读这篇文章:[美团暑期实习一面:MySQl 索引失效的场景有哪些?](https://mp.weixin.qq.com/s/mwME3qukHBFul57WQLkOYg)。
-
### 删除长期未使用的索引
删除长期未使用的索引,不用的索引的存在会造成不必要的性能损耗。
MySQL 5.7 可以通过查询 `sys` 库的 `schema_unused_indexes` 视图来查询哪些索引从未被使用。
-### 知道如何分析语句是否走索引查询
+### 知道如何分析 SQL 语句是否走索引查询
我们可以使用 `EXPLAIN` 命令来分析 SQL 的 **执行计划** ,这样就知道语句是否命中索引了。执行计划是指一条 SQL 语句在经过 MySQL 查询优化器的优化会后,具体的执行方式。
diff --git a/docs/database/mysql/mysql-logs.md b/docs/database/mysql/mysql-logs.md
index 49a9da118d8..bc484746517 100644
--- a/docs/database/mysql/mysql-logs.md
+++ b/docs/database/mysql/mysql-logs.md
@@ -1,35 +1,40 @@
---
title: MySQL三大日志(binlog、redo log和undo log)详解
+description: 深入解析MySQL三大日志binlog、redo log和undo log的作用与原理,详解两阶段提交保证数据一致性的机制,以及日志在崩溃恢复和主从复制中的应用。
category: 数据库
tag:
- MySQL
+head:
+ - - meta
+ - name: keywords
+ content: MySQL日志,binlog,redo log,undo log,两阶段提交,崩溃恢复,主从复制,WAL,事务日志
---
> 本文来自公号程序猿阿星投稿,JavaGuide 对其做了补充完善。
## 前言
-`MySQL` 日志 主要包括错误日志、查询日志、慢查询日志、事务日志、二进制日志几大类。其中,比较重要的还要属二进制日志 `binlog`(归档日志)和事务日志 `redo log`(重做日志)和 `undo log`(回滚日志)。
+MySQL 日志 主要包括错误日志、查询日志、慢查询日志、事务日志、二进制日志几大类。其中,比较重要的还要属二进制日志 binlog(归档日志)和事务日志 redo log(重做日志)和 undo log(回滚日志)。

-今天就来聊聊 `redo log`(重做日志)、`binlog`(归档日志)、两阶段提交、`undo log` (回滚日志)。
+今天就来聊聊 redo log(重做日志)、binlog(归档日志)、两阶段提交、undo log(回滚日志)。
## redo log
-`redo log`(重做日志)是`InnoDB`存储引擎独有的,它让`MySQL`拥有了崩溃恢复能力。
+redo log(重做日志)是 InnoDB 存储引擎独有的,它让 MySQL 拥有了崩溃恢复能力。
-比如 `MySQL` 实例挂了或宕机了,重启时,`InnoDB`存储引擎会使用`redo log`恢复数据,保证数据的持久性与完整性。
+比如 MySQL 实例挂了或宕机了,重启时,InnoDB 存储引擎会使用 redo log 恢复数据,保证数据的持久性与完整性。

-`MySQL` 中数据是以页为单位,你查询一条记录,会从硬盘把一页的数据加载出来,加载出来的数据叫数据页,会放入到 `Buffer Pool` 中。
+MySQL 中数据是以页为单位,你查询一条记录,会从硬盘把一页的数据加载出来,加载出来的数据叫数据页,会放入到 `Buffer Pool` 中。
-后续的查询都是先从 `Buffer Pool` 中找,没有命中再去硬盘加载,减少硬盘 `IO` 开销,提升性能。
+后续的查询都是先从 `Buffer Pool` 中找,没有命中再去硬盘加载,减少硬盘 IO 开销,提升性能。
更新表数据的时候,也是如此,发现 `Buffer Pool` 里存在要更新的数据,就直接在 `Buffer Pool` 里更新。
-然后会把“在某个数据页上做了什么修改”记录到重做日志缓存(`redo log buffer`)里,接着刷盘到 `redo log` 文件里。
+然后会把“在某个数据页上做了什么修改”记录到重做日志缓存(`redo log buffer`)里,接着刷盘到 redo log 文件里。

@@ -41,16 +46,16 @@ tag:
### 刷盘时机
-InnoDB 刷新重做日志的时机有几种情况:
+在 InnoDB 存储引擎中,**redo log buffer**(重做日志缓冲区)是一块用于暂存 redo log 的内存区域。为了确保事务的持久性和数据的一致性,InnoDB 会在特定时机将这块缓冲区中的日志数据刷新到磁盘上的 redo log 文件中。这些时机可以归纳为以下六种:
-InnoDB 将 redo log 刷到磁盘上有几种情况:
-
-1. 事务提交:当事务提交时,log buffer 里的 redo log 会被刷新到磁盘(可以通过`innodb_flush_log_at_trx_commit`参数控制,后文会提到)。
-2. log buffer 空间不足时:log buffer 中缓存的 redo log 已经占满了 log buffer 总容量的大约一半左右,就需要把这些日志刷新到磁盘上。
-3. 事务日志缓冲区满:InnoDB 使用一个事务日志缓冲区(transaction log buffer)来暂时存储事务的重做日志条目。当缓冲区满时,会触发日志的刷新,将日志写入磁盘。
-4. Checkpoint(检查点):InnoDB 定期会执行检查点操作,将内存中的脏数据(已修改但尚未写入磁盘的数据)刷新到磁盘,并且会将相应的重做日志一同刷新,以确保数据的一致性。
-5. 后台刷新线程:InnoDB 启动了一个后台线程,负责周期性(每隔 1 秒)地将脏页(已修改但尚未写入磁盘的数据页)刷新到磁盘,并将相关的重做日志一同刷新。
-6. 正常关闭服务器:MySQL 关闭的时候,redo log 都会刷入到磁盘里去。
+1. **事务提交时(最核心)**:当事务提交时,log buffer 里的 redo log 会被刷新到磁盘(可以通过`innodb_flush_log_at_trx_commit`参数控制,后文会提到)。
+2. **redo log buffer 空间不足时**:这是 InnoDB 的一种主动容量管理策略,旨在避免因缓冲区写满而导致用户线程阻塞。
+ - 当 redo log buffer 的已用空间超过其总容量的**一半 (50%)** 时,后台线程会**主动**将这部分日志刷新到磁盘,为后续的日志写入腾出空间,这是一种“未雨绸缪”的优化。
+ - 如果因为大事务或 I/O 繁忙导致 buffer 被**完全写满**,那么所有试图写入新日志的用户线程都会被**阻塞**,并强制进行一次同步刷盘,直到有可用空间为止。这种情况会影响数据库性能,应尽量避免。
+3. **触发检查点 (Checkpoint) 时**:Checkpoint 是 InnoDB 为了缩短崩溃恢复时间而设计的核心机制。当 Checkpoint 被触发时,InnoDB 需要将在此检查点之前的所有脏页刷写到磁盘。根据 **Write-Ahead Logging (WAL)** 原则,数据页写入磁盘前,其对应的 redo log 必须先落盘。因此,执行 Checkpoint 操作必然会确保相关的 redo log 也已经被刷新到了磁盘。
+4. **后台线程周期性刷新**:InnoDB 有一个后台的 master thread,它会大约每秒执行一次例行任务,其中就包括将 redo log buffer 中的日志刷新到磁盘。这个机制是 `innodb_flush_log_at_trx_commit` 设置为 0 或 2 时的主要持久化保障。
+5. **正常关闭服务器**:在 MySQL 服务器正常关闭的过程中,为了确保所有已提交事务的数据都被完整保存,InnoDB 会执行一次最终的刷盘操作,将 redo log buffer 中剩余的全部日志都清空并写入磁盘文件。
+6. **binlog 切换时**:当开启 binlog 后,在 MySQL 采用 `innodb_flush_log_at_trx_commit=1` 和 `sync_binlog=1` 的 双一配置下,为了保证 redo log 和 binlog 之间状态的一致性(用于崩溃恢复或主从复制),在 binlog 文件写满或者手动执行 flush logs 进行切换时,会触发 redo log 的刷盘动作。
总之,InnoDB 在多种情况下会刷新重做日志,以保证数据的持久性和一致性。
@@ -64,15 +69,15 @@ InnoDB 将 redo log 刷到磁盘上有几种情况:
刷盘策略`innodb_flush_log_at_trx_commit` 的默认值为 1,设置为 1 的时候才不会丢失任何数据。为了保证事务的持久性,我们必须将其设置为 1。
-另外,`InnoDB` 存储引擎有一个后台线程,每隔`1` 秒,就会把 `redo log buffer` 中的内容写到文件系统缓存(`page cache`),然后调用 `fsync` 刷盘。
+另外,InnoDB 存储引擎有一个后台线程,每隔`1` 秒,就会把 `redo log buffer` 中的内容写到文件系统缓存(`page cache`),然后调用 `fsync` 刷盘。

-也就是说,一个没有提交事务的 `redo log` 记录,也可能会刷盘。
+也就是说,一个没有提交事务的 redo log 记录,也可能会刷盘。
**为什么呢?**
-因为在事务执行过程 `redo log` 记录是会写入`redo log buffer` 中,这些 `redo log` 记录会被后台线程刷盘。
+因为在事务执行过程 redo log 记录是会写入`redo log buffer` 中,这些 redo log 记录会被后台线程刷盘。

@@ -84,15 +89,15 @@ InnoDB 将 redo log 刷到磁盘上有几种情况:

-为`0`时,如果`MySQL`挂了或宕机可能会有`1`秒数据的丢失。
+为`0`时,如果 MySQL 挂了或宕机可能会有`1`秒数据的丢失。
#### innodb_flush_log_at_trx_commit=1

-为`1`时, 只要事务提交成功,`redo log`记录就一定在硬盘里,不会有任何数据丢失。
+为`1`时, 只要事务提交成功,redo log 记录就一定在硬盘里,不会有任何数据丢失。
-如果事务执行期间`MySQL`挂了或宕机,这部分日志丢了,但是事务并没有提交,所以日志丢了也不会有损失。
+如果事务执行期间 MySQL 挂了或宕机,这部分日志丢了,但是事务并没有提交,所以日志丢了也不会有损失。
#### innodb_flush_log_at_trx_commit=2
@@ -100,32 +105,32 @@ InnoDB 将 redo log 刷到磁盘上有几种情况:
为`2`时, 只要事务提交成功,`redo log buffer`中的内容只写入文件系统缓存(`page cache`)。
-如果仅仅只是`MySQL`挂了不会有任何数据丢失,但是宕机可能会有`1`秒数据的丢失。
+如果仅仅只是 MySQL 挂了不会有任何数据丢失,但是宕机可能会有`1`秒数据的丢失。
### 日志文件组
-硬盘上存储的 `redo log` 日志文件不只一个,而是以一个**日志文件组**的形式出现的,每个的`redo`日志文件大小都是一样的。
+硬盘上存储的 redo log 日志文件不只一个,而是以一个**日志文件组**的形式出现的,每个的`redo`日志文件大小都是一样的。
-比如可以配置为一组`4`个文件,每个文件的大小是 `1GB`,整个 `redo log` 日志文件组可以记录`4G`的内容。
+比如可以配置为一组`4`个文件,每个文件的大小是 `1GB`,整个 redo log 日志文件组可以记录`4G`的内容。
它采用的是环形数组形式,从头开始写,写到末尾又回到头循环写,如下图所示。

-在个**日志文件组**中还有两个重要的属性,分别是 `write pos、checkpoint`
+在这个**日志文件组**中还有两个重要的属性,分别是 `write pos、checkpoint`
- **write pos** 是当前记录的位置,一边写一边后移
- **checkpoint** 是当前要擦除的位置,也是往后推移
-每次刷盘 `redo log` 记录到**日志文件组**中,`write pos` 位置就会后移更新。
+每次刷盘 redo log 记录到**日志文件组**中,`write pos` 位置就会后移更新。
-每次 `MySQL` 加载**日志文件组**恢复数据时,会清空加载过的 `redo log` 记录,并把 `checkpoint` 后移更新。
+每次 MySQL 加载**日志文件组**恢复数据时,会清空加载过的 redo log 记录,并把 `checkpoint` 后移更新。
-`write pos` 和 `checkpoint` 之间的还空着的部分可以用来写入新的 `redo log` 记录。
+`write pos` 和 `checkpoint` 之间的还空着的部分可以用来写入新的 redo log 记录。

-如果 `write pos` 追上 `checkpoint` ,表示**日志文件组**满了,这时候不能再写入新的 `redo log` 记录,`MySQL` 得停下来,清空一些记录,把 `checkpoint` 推进一下。
+如果 `write pos` 追上 `checkpoint` ,表示**日志文件组**满了,这时候不能再写入新的 redo log 记录,MySQL 得停下来,清空一些记录,把 `checkpoint` 推进一下。

@@ -172,9 +177,9 @@ MySQL830 mysql:8.0.32
### redo log 小结
-相信大家都知道 `redo log` 的作用和它的刷盘时机、存储形式。
+相信大家都知道 redo log 的作用和它的刷盘时机、存储形式。
-现在我们来思考一个问题:**只要每次把修改后的数据页直接刷盘不就好了,还有 `redo log` 什么事?**
+现在我们来思考一个问题:**只要每次把修改后的数据页直接刷盘不就好了,还有 redo log 什么事?**
它们不都是刷盘么?差别在哪里?
@@ -190,32 +195,32 @@ MySQL830 mysql:8.0.32
而且数据页刷盘是随机写,因为一个数据页对应的位置可能在硬盘文件的随机位置,所以性能是很差。
-如果是写 `redo log`,一行记录可能就占几十 `Byte`,只包含表空间号、数据页号、磁盘文件偏移
+如果是写 redo log,一行记录可能就占几十 `Byte`,只包含表空间号、数据页号、磁盘文件偏移
量、更新值,再加上是顺序写,所以刷盘速度很快。
-所以用 `redo log` 形式记录修改内容,性能会远远超过刷数据页的方式,这也让数据库的并发能力更强。
+所以用 redo log 形式记录修改内容,性能会远远超过刷数据页的方式,这也让数据库的并发能力更强。
> 其实内存的数据页在一定时机也会刷盘,我们把这称为页合并,讲 `Buffer Pool`的时候会对这块细说
## binlog
-`redo log` 它是物理日志,记录内容是“在某个数据页上做了什么修改”,属于 `InnoDB` 存储引擎。
+redo log 它是物理日志,记录内容是“在某个数据页上做了什么修改”,属于 InnoDB 存储引擎。
-而 `binlog` 是逻辑日志,记录内容是语句的原始逻辑,类似于“给 ID=2 这一行的 c 字段加 1”,属于`MySQL Server` 层。
+而 binlog 是逻辑日志,记录内容是语句的原始逻辑,类似于“给 ID=2 这一行的 c 字段加 1”,属于`MySQL Server` 层。
-不管用什么存储引擎,只要发生了表数据更新,都会产生 `binlog` 日志。
+不管用什么存储引擎,只要发生了表数据更新,都会产生 binlog 日志。
-那 `binlog` 到底是用来干嘛的?
+那 binlog 到底是用来干嘛的?
-可以说`MySQL`数据库的**数据备份、主备、主主、主从**都离不开`binlog`,需要依靠`binlog`来同步数据,保证数据一致性。
+可以说 MySQL 数据库的**数据备份、主备、主主、主从**都离不开 binlog,需要依靠 binlog 来同步数据,保证数据一致性。

-`binlog`会记录所有涉及更新数据的逻辑操作,并且是顺序写。
+binlog 会记录所有涉及更新数据的逻辑操作,并且是顺序写。
### 记录格式
-`binlog` 日志有三种格式,可以通过`binlog_format`参数指定。
+binlog 日志有三种格式,可以通过`binlog_format`参数指定。
- **statement**
- **row**
@@ -237,21 +242,21 @@ MySQL830 mysql:8.0.32
这样就能保证同步数据的一致性,通常情况下都是指定为`row`,这样可以为数据库的恢复与同步带来更好的可靠性。
-但是这种格式,需要更大的容量来记录,比较占用空间,恢复与同步时会更消耗`IO`资源,影响执行速度。
+但是这种格式,需要更大的容量来记录,比较占用空间,恢复与同步时会更消耗 IO 资源,影响执行速度。
所以就有了一种折中的方案,指定为`mixed`,记录的内容是前两者的混合。
-`MySQL`会判断这条`SQL`语句是否可能引起数据不一致,如果是,就用`row`格式,否则就用`statement`格式。
+MySQL 会判断这条`SQL`语句是否可能引起数据不一致,如果是,就用`row`格式,否则就用`statement`格式。
### 写入机制
-`binlog`的写入时机也非常简单,事务执行过程中,先把日志写到`binlog cache`,事务提交的时候,再把`binlog cache`写到`binlog`文件中。
+binlog 的写入时机也非常简单,事务执行过程中,先把日志写到`binlog cache`,事务提交的时候,再把`binlog cache`写到 binlog 文件中。
-因为一个事务的`binlog`不能被拆开,无论这个事务多大,也要确保一次性写入,所以系统会给每个线程分配一个块内存作为`binlog cache`。
+因为一个事务的 binlog 不能被拆开,无论这个事务多大,也要确保一次性写入,所以系统会给每个线程分配一个块内存作为`binlog cache`。
我们可以通过`binlog_cache_size`参数控制单个线程 binlog cache 大小,如果存储内容超过了这个参数,就要暂存到磁盘(`Swap`)。
-`binlog`日志刷盘流程如下
+binlog 日志刷盘流程如下

@@ -272,57 +277,63 @@ MySQL830 mysql:8.0.32

-在出现`IO`瓶颈的场景里,将`sync_binlog`设置成一个比较大的值,可以提升性能。
+在出现 IO 瓶颈的场景里,将`sync_binlog`设置成一个比较大的值,可以提升性能。
-同样的,如果机器宕机,会丢失最近`N`个事务的`binlog`日志。
+同样的,如果机器宕机,会丢失最近`N`个事务的 binlog 日志。
## 两阶段提交
-`redo log`(重做日志)让`InnoDB`存储引擎拥有了崩溃恢复能力。
+redo log(重做日志)让 InnoDB 存储引擎拥有了崩溃恢复能力。
-`binlog`(归档日志)保证了`MySQL`集群架构的数据一致性。
+binlog(归档日志)保证了 MySQL 集群架构的数据一致性。
虽然它们都属于持久化的保证,但是侧重点不同。
-在执行更新语句过程,会记录`redo log`与`binlog`两块日志,以基本的事务为单位,`redo log`在事务执行过程中可以不断写入,而`binlog`只有在提交事务时才写入,所以`redo log`与`binlog`的写入时机不一样。
+在执行更新语句过程,会记录 redo log 与 binlog 两块日志,以基本的事务为单位,redo log 在事务执行过程中可以不断写入,而 binlog 只有在提交事务时才写入,所以 redo log 与 binlog 的写入时机不一样。

-回到正题,`redo log`与`binlog`两份日志之间的逻辑不一致,会出现什么问题?
+回到正题,redo log 与 binlog 两份日志之间的逻辑不一致,会出现什么问题?
我们以`update`语句为例,假设`id=2`的记录,字段`c`值是`0`,把字段`c`值更新成`1`,`SQL`语句为`update T set c=1 where id=2`。
-假设执行过程中写完`redo log`日志后,`binlog`日志写期间发生了异常,会出现什么情况呢?
+假设执行过程中写完 redo log 日志后,binlog 日志写期间发生了异常,会出现什么情况呢?

-由于`binlog`没写完就异常,这时候`binlog`里面没有对应的修改记录。因此,之后用`binlog`日志恢复数据时,就会少这一次更新,恢复出来的这一行`c`值是`0`,而原库因为`redo log`日志恢复,这一行`c`值是`1`,最终数据不一致。
+由于 binlog 没写完就异常,这时候 binlog 里面没有对应的修改记录。因此,之后用 binlog 日志恢复数据时,就会少这一次更新,恢复出来的这一行`c`值是`0`,而原库因为 redo log 日志恢复,这一行`c`值是`1`,最终数据不一致。

-为了解决两份日志之间的逻辑一致问题,`InnoDB`存储引擎使用**两阶段提交**方案。
+为了解决两份日志之间的逻辑一致问题,InnoDB 存储引擎使用**两阶段提交**方案。
-原理很简单,将`redo log`的写入拆成了两个步骤`prepare`和`commit`,这就是**两阶段提交**。
+原理很简单,将 redo log 的写入拆成了两个步骤`prepare`和`commit`,这就是**两阶段提交**。

-使用**两阶段提交**后,写入`binlog`时发生异常也不会有影响,因为`MySQL`根据`redo log`日志恢复数据时,发现`redo log`还处于`prepare`阶段,并且没有对应`binlog`日志,就会回滚该事务。
+使用**两阶段提交**后,写入 binlog 时发生异常也不会有影响,因为 MySQL 根据 redo log 日志恢复数据时,发现 redo log 还处于`prepare`阶段,并且没有对应 binlog 日志,就会回滚该事务。

-再看一个场景,`redo log`设置`commit`阶段发生异常,那会不会回滚事务呢?
+再看一个场景,redo log 设置`commit`阶段发生异常,那会不会回滚事务呢?

-并不会回滚事务,它会执行上图框住的逻辑,虽然`redo log`是处于`prepare`阶段,但是能通过事务`id`找到对应的`binlog`日志,所以`MySQL`认为是完整的,就会提交事务恢复数据。
+并不会回滚事务,它会执行上图框住的逻辑,虽然 redo log 是处于`prepare`阶段,但是能通过事务`id`找到对应的 binlog 日志,所以 MySQL 认为是完整的,就会提交事务恢复数据。
## undo log
> 这部分内容为 JavaGuide 的补充:
-我们知道如果想要保证事务的原子性,就需要在异常发生时,对已经执行的操作进行**回滚**,在 MySQL 中,恢复机制是通过 **回滚日志(undo log)** 实现的,所有事务进行的修改都会先记录到这个回滚日志中,然后再执行相关的操作。如果执行过程中遇到异常的话,我们直接利用 **回滚日志** 中的信息将数据回滚到修改之前的样子即可!并且,回滚日志会先于数据持久化到磁盘上。这样就保证了即使遇到数据库突然宕机等情况,当用户再次启动数据库的时候,数据库还能够通过查询回滚日志来回滚将之前未完成的事务。
+每一个事务对数据的修改都会被记录到 undo log ,当执行事务过程中出现错误或者需要执行回滚操作的话,MySQL 可以利用 undo log 将数据恢复到事务开始之前的状态。
+
+undo log 属于逻辑日志,记录的是 SQL 语句,比如说事务执行一条 DELETE 语句,那 undo log 就会记录一条相对应的 INSERT 语句。同时,undo log 的信息也会被记录到 redo log 中,因为 undo log 也要实现持久性保护。并且,undo-log 本身是会被删除清理的,例如 INSERT 操作,在事务提交之后就可以清除掉了;UPDATE/DELETE 操作在事务提交不会立即删除,会加入 history list,由后台线程 purge 进行清理。
+
+undo log 是采用 segment(段)的方式来记录的,每个 undo 操作在记录的时候占用一个 **undo log segment**(undo 日志段),undo log segment 包含在 **rollback segment**(回滚段)中。事务开始时,需要为其分配一个 rollback segment。每个 rollback segment 有 1024 个 undo log segment,这有助于管理多个并发事务的回滚需求。
+
+通常情况下, **rollback segment header**(通常在回滚段的第一个页)负责管理 rollback segment。rollback segment header 是 rollback segment 的一部分,通常在回滚段的第一个页。**history list** 是 rollback segment header 的一部分,它的主要作用是记录所有已经提交但还没有被清理(purge)的事务的 undo log。这个列表使得 purge 线程能够找到并清理那些不再需要的 undo log 记录。
-另外,`MVCC` 的实现依赖于:**隐藏字段、Read View、undo log**。在内部实现中,`InnoDB` 通过数据行的 `DB_TRX_ID` 和 `Read View` 来判断数据的可见性,如不可见,则通过数据行的 `DB_ROLL_PTR` 找到 `undo log` 中的历史版本。每个事务读到的数据版本可能是不一样的,在同一个事务中,用户只能看到该事务创建 `Read View` 之前已经提交的修改和该事务本身做的修改
+另外,`MVCC` 的实现依赖于:**隐藏字段、Read View、undo log**。在内部实现中,InnoDB 通过数据行的 `DB_TRX_ID` 和 `Read View` 来判断数据的可见性,如不可见,则通过数据行的 `DB_ROLL_PTR` 找到 undo log 中的历史版本。每个事务读到的数据版本可能是不一样的,在同一个事务中,用户只能看到该事务创建 `Read View` 之前已经提交的修改和该事务本身做的修改
## 总结
@@ -330,7 +341,7 @@ MySQL830 mysql:8.0.32
MySQL InnoDB 引擎使用 **redo log(重做日志)** 保证事务的**持久性**,使用 **undo log(回滚日志)** 来保证事务的**原子性**。
-`MySQL`数据库的**数据备份、主备、主主、主从**都离不开`binlog`,需要依靠`binlog`来同步数据,保证数据一致性。
+MySQL 数据库的**数据备份、主备、主主、主从**都离不开 binlog,需要依靠 binlog 来同步数据,保证数据一致性。
## 参考
diff --git a/docs/database/mysql/mysql-query-cache.md b/docs/database/mysql/mysql-query-cache.md
index cdc49b2c59c..c98c5bdaf81 100644
--- a/docs/database/mysql/mysql-query-cache.md
+++ b/docs/database/mysql/mysql-query-cache.md
@@ -1,15 +1,13 @@
---
title: MySQL查询缓存详解
+description: 深入解析MySQL查询缓存的工作原理、配置管理及其优缺点,分析为什么MySQL 8.0移除了查询缓存功能,以及生产环境中的最佳实践建议。
category: 数据库
tag:
- MySQL
head:
- - meta
- name: keywords
- content: MySQL查询缓存,MySQL缓存机制中的内存管理
- - - meta
- - name: description
- content: 为了提高完全相同的查询语句的响应速度,MySQL Server 会对查询语句进行 Hash 计算得到一个 Hash 值。MySQL Server 不会对 SQL 做任何处理,SQL 必须完全一致 Hash 值才会一样。得到 Hash 值之后,通过该 Hash 值到查询缓存中匹配该查询的结果。MySQL 中的查询缓存虽然能够提升数据库的查询性能,但是查询同时也带来了额外的开销,每次查询后都要做一次缓存操作,失效后还要销毁。
+ content: MySQL查询缓存,Query Cache,MySQL缓存机制,缓存失效,MySQL 8.0,查询性能优化,MySQL内存管理
---
缓存是一个有效且实用的系统性能优化的手段,不论是操作系统还是各种软件和网站或多或少都用到了缓存。
diff --git a/docs/database/mysql/mysql-query-execution-plan.md b/docs/database/mysql/mysql-query-execution-plan.md
index 48eb5a1e5ed..6357163badd 100644
--- a/docs/database/mysql/mysql-query-execution-plan.md
+++ b/docs/database/mysql/mysql-query-execution-plan.md
@@ -1,15 +1,13 @@
---
title: MySQL执行计划分析
+description: 详解MySQL EXPLAIN执行计划的各列含义,包括id、select_type、type、key、rows、Extra等关键字段解读,帮助你分析SQL性能瓶颈并进行针对性优化。
category: 数据库
tag:
- MySQL
head:
- - meta
- name: keywords
- content: MySQL基础,MySQL执行计划,EXPLAIN,查询优化器
- - - meta
- - name: description
- content: 执行计划是指一条 SQL 语句在经过MySQL 查询优化器的优化会后,具体的执行方式。优化 SQL 的第一步应该是读懂 SQL 的执行计划。
+ content: MySQL执行计划,EXPLAIN,查询优化器,SQL性能分析,索引命中,type访问类型,Extra字段,慢查询优化
---
> 本文来自公号 MySQL 技术,JavaGuide 对其做了补充完善。原文地址:
@@ -18,7 +16,7 @@ head:
## 什么是执行计划?
-**执行计划** 是指一条 SQL 语句在经过 **MySQL 查询优化器** 的优化会后,具体的执行方式。
+**执行计划** 是指一条 SQL 语句在经过 **MySQL 查询优化器** 的优化后,具体的执行方式。
执行计划通常用于 SQL 性能分析、优化等场景。通过 `EXPLAIN` 的结果,可以了解到如数据表的查询顺序、数据查询操作的操作类型、哪些索引可以被命中、哪些索引实际会命中、每个数据表有多少行记录被查询等信息。
@@ -69,7 +67,7 @@ mysql> explain SELECT * FROM dept_emp WHERE emp_no IN (SELECT emp_no FROM dept_e
### id
-SELECT 标识符,是查询中 SELECT 的序号,用来标识整个查询中 SELELCT 语句的顺序。
+`SELECT` 标识符,用于标识每个 `SELECT` 语句的执行顺序。
id 如果相同,从上往下依次执行。id 不同,id 值越大,执行优先级越高,如果行引用其他行的并集结果,则该值可以为 NULL。
@@ -89,12 +87,14 @@ id 如果相同,从上往下依次执行。id 不同,id 值越大,执行
查询用到的表名,每行都有对应的表名,表名除了正常的表之外,也可能是以下列出的值:
- **``** : 本行引用了 id 为 M 和 N 的行的 UNION 结果;
-- **``** : 本行引用了 id 为 N 的表所产生的的派生表结果。派生表有可能产生自 FROM 语句中的子查询。
-- **``** : 本行引用了 id 为 N 的表所产生的的物化子查询结果。
+- **``** : 本行引用了 id 为 N 的表所产生的派生表结果。派生表有可能产生自 FROM 语句中的子查询。
+- **``** : 本行引用了 id 为 N 的表所产生的物化子查询结果。
### type(重要)
-查询执行的类型,描述了查询是如何执行的。所有值的顺序从最优到最差排序为:system > const > eq_ref > ref > fulltext > ref_or_null > index_merge > unique_subquery > index_subquery > range > index > ALL
+查询执行的类型,描述了查询是如何执行的。所有值的顺序从最优到最差排序为:
+
+system > const > eq_ref > ref > fulltext > ref_or_null > index_merge > unique_subquery > index_subquery > range > index > ALL
常见的几种类型具体含义如下:
@@ -109,7 +109,7 @@ id 如果相同,从上往下依次执行。id 不同,id 值越大,执行
### possible_keys
-possible_keys 列表示 MySQL 执行查询时可能用到的索引。如果这一列为 NULL ,则表示没有可能用到的索引;这种情况下,需要检查 WHERE 语句中所使用的的列,看是否可以通过给这些列中某个或多个添加索引的方法来提高查询性能。
+possible_keys 列表示 MySQL 执行查询时可能用到的索引。如果这一列为 NULL ,则表示没有可能用到的索引;这种情况下,需要检查 WHERE 语句中所使用的列,看是否可以通过给这些列中某个或多个添加索引的方法来提高查询性能。
### key(重要)
diff --git a/docs/database/mysql/mysql-questions-01.md b/docs/database/mysql/mysql-questions-01.md
index d0f0568ea53..0f7ecc08942 100644
--- a/docs/database/mysql/mysql-questions-01.md
+++ b/docs/database/mysql/mysql-questions-01.md
@@ -1,5 +1,6 @@
---
title: MySQL常见面试题总结
+description: MySQL高频面试题精讲:基础架构、InnoDB引擎、索引原理、B+树、事务ACID、MVCC、redo/undo/binlog日志、行锁/表锁、慢查询优化,一文速通大厂必考点!
category: 数据库
tag:
- MySQL
@@ -7,10 +8,7 @@ tag:
head:
- - meta
- name: keywords
- content: MySQL基础,MySQL基础架构,MySQL存储引擎,MySQL查询缓存,MySQL事务,MySQL锁等内容。
- - - meta
- - name: description
- content: 一篇文章总结MySQL常见的知识点和面试题,涵盖MySQL基础、MySQL基础架构、MySQL存储引擎、MySQL查询缓存、MySQL事务、MySQL锁等内容。
+ content: MySQL面试题,MySQL基础架构,InnoDB存储引擎,MySQL索引,B+树索引,事务隔离级别,redo log,undo log,binlog,MVCC,行级锁,慢查询优化
---
@@ -55,20 +53,30 @@ SQL 可以帮助我们:
由于 MySQL 是开源免费并且比较成熟的数据库,因此,MySQL 被大量使用在各种系统中。任何人都可以在 GPL(General Public License) 的许可下下载并根据个性化的需要对其进行修改。MySQL 的默认端口号是**3306**。
-### MySQL 有什么优点?
+### ⭐️MySQL 有什么优点?
这个问题本质上是在问 MySQL 如此流行的原因。
-MySQL 主要具有下面这些优点:
+MySQL 成功可以归功于在**生态、功能和运维**这三个层面上的综合优势。
+
+**第一,从生态和成本角度看,它的护城河非常深。**
+
+- **开源免费:** 这是它得以广泛普及的基石。任何公司和个人都可以免费使用,极大地降低了技术门槛和初期成本。
+- **社区庞大,生态完善:** 经过几十年的发展,MySQL 拥有极其活跃的社区和丰富的生态系统。这意味着无论你遇到什么问题,几乎都能在网上找到解决方案;同时,市面上所有的主流编程语言、框架、ORM 工具、监控系统都对 MySQL 有完美的支持。它的文档也非常丰富,学习资源唾手可得。
+
+**第二,从核心技术功能上看,它非常强大且均衡。**
+
+- **强大的事务支持:** 这是它作为关系型数据库的立身之本。值得一提的是,InnoDB 默认的可重复读(REPEATABLE-READ)隔离级别,通过 MVCC 和 Next-Key Lock 机制,很大程度上避免了幻读问题,这在很多其他数据库中都需要更高的隔离级别才能做到,兼顾了性能和一致性。详细介绍可以阅读笔者写的这篇文章:[MySQL 事务隔离级别详解](https://javaguide.cn/database/mysql/transaction-isolation-level.html)。
+- **优秀的性能和可扩展性:** MySQL 本身经过了海量互联网业务的严酷考验,单机性能非常出色。更重要的是,它围绕着水平扩展,形成了一套非常成熟的架构方案,比如主从复制、读写分离、以及通过中间件实现的分库分表。这让它能够支撑从初创公司到大型互联网平台的各种规模的业务。
+
+**第三,从运维和使用角度看,它非常‘亲民’。**
+
+- **开箱即用,上手简单:** 相比于 Oracle 等大型商业数据库,MySQL 的安装、配置和日常使用都非常简单直观,学习曲线平缓,对于开发者和初级 DBA 非常友好。
+- **维护成本低:** 由于其简单性和庞大的社区,找到相关的运维人才和解决方案都相对容易,整体的维护成本也更低。
+
+值得一提的是最近几年,PostgreSQL 的势头很猛,甚至压过了 MySQL。网上出现了很多抨击诋毁 MySQL 的文章,笔者认为任何无脑抨击其中一方或者吹捧另外一方的行为都是不可取的。
-1. 成熟稳定,功能完善。
-2. 开源免费。
-3. 文档丰富,既有详细的官方文档,又有非常多优质文章可供参考学习。
-4. 开箱即用,操作简单,维护成本低。
-5. 兼容性好,支持常见的操作系统,支持多种开发语言。
-6. 社区活跃,生态完善。
-7. 事务支持优秀, InnoDB 存储引擎默认使用 REPEATABLE-READ 并不会有任何性能损失,并且,InnoDB 实现的 REPEATABLE-READ 隔离级别其实是可以解决幻读问题发生的。
-8. 支持分库分表、读写分离、高可用。
+笔者也写过一篇文章分享对这两个关系型数据库代表的看法,感兴趣的可以看看:[MySQL 被干成老二了?](https://mp.weixin.qq.com/s/APWD-PzTcTqGUuibAw7GGw)。
## MySQL 字段类型
@@ -86,7 +94,7 @@ MySQL 字段类型比较多,我这里会挑选一些日常开发使用很频
另外,推荐阅读一下《高性能 MySQL(第三版)》的第四章,有详细介绍 MySQL 字段类型优化。
-### 整数类型的 UNSIGNED 属性有什么用?
+### ⭐️整数类型的 UNSIGNED 属性有什么用?
MySQL 中的整数类型可以使用可选的 UNSIGNED 属性来表示不允许负值的无符号整数。使用 UNSIGNED 属性可以将正整数的上限提高一倍,因为它不需要存储负数值。
@@ -152,31 +160,75 @@ BLOB 类型主要用于存储二进制大对象,例如图片、音视频等文
- 可能导致表上的 DML 操作变慢。
- ……
-### DATETIME 和 TIMESTAMP 的区别是什么?
+### ⭐️DATETIME 和 TIMESTAMP 的区别是什么?如何选择?
DATETIME 类型没有时区信息,TIMESTAMP 和时区有关。
TIMESTAMP 只需要使用 4 个字节的存储空间,但是 DATETIME 需要耗费 8 个字节的存储空间。但是,这样同样造成了一个问题,Timestamp 表示的时间范围更小。
-- DATETIME:1000-01-01 00:00:00 ~ 9999-12-31 23:59:59
-- Timestamp:1970-01-01 00:00:01 ~ 2037-12-31 23:59:59
+- DATETIME:'1000-01-01 00:00:00.000000' 到 '9999-12-31 23:59:59.999999'
+- Timestamp:'1970-01-01 00:00:01.000000' UTC 到 '2038-01-19 03:14:07.999999' UTC
-关于两者的详细对比,请参考我写的[MySQL 时间类型数据存储建议](./some-thoughts-on-database-storage-time.md)。
+`TIMESTAMP` 的核心优势在于其内建的时区处理能力。数据库负责 UTC 存储和基于会话时区的自动转换,简化了需要处理多时区应用的开发。如果应用需要处理多时区,或者希望数据库能自动管理时区转换,`TIMESTAMP` 是自然的选择(注意其时间范围限制,也就是 2038 年问题)。
-### NULL 和 '' 的区别是什么?
+如果应用场景不涉及时区转换,或者希望应用程序完全控制时区逻辑,并且需要表示 2038 年之后的时间,`DATETIME` 是更稳妥的选择。
+
+关于两者的详细对比以及日期存储类型选择建议,请参考我写的这篇文章: [MySQL 时间类型数据存储建议](./some-thoughts-on-database-storage-time.md)。
-`NULL` 跟 `''`(空字符串)是两个完全不一样的值,区别如下:
+### NULL 和 '' 的区别是什么?
-- `NULL` 代表一个不确定的值,就算是两个 `NULL`,它俩也不一定相等。例如,`SELECT NULL=NULL`的结果为 false,但是在我们使用`DISTINCT`,`GROUP BY`,`ORDER BY`时,`NULL`又被认为是相等的。
-- `''`的长度是 0,是不占用空间的,而`NULL` 是需要占用空间的。
-- `NULL` 会影响聚合函数的结果。例如,`SUM`、`AVG`、`MIN`、`MAX` 等聚合函数会忽略 `NULL` 值。 `COUNT` 的处理方式取决于参数的类型。如果参数是 `*`(`COUNT(*)`),则会统计所有的记录数,包括 `NULL` 值;如果参数是某个字段名(`COUNT(列名)`),则会忽略 `NULL` 值,只统计非空值的个数。
-- 查询 `NULL` 值时,必须使用 `IS NULL` 或 `IS NOT NULLl` 来判断,而不能使用 =、!=、 <、> 之类的比较运算符。而`''`是可以使用这些比较运算符的。
+`NULL` 和 `''` (空字符串) 是两个完全不同的值,它们分别表示不同的含义,并在数据库中有着不同的行为。`NULL` 代表缺失或未知的数据,而 `''` 表示一个已知存在的空字符串。它们的主要区别如下:
+
+1. **含义**:
+ - `NULL` 代表一个不确定的值,它不等于任何值,包括它自身。因此,`SELECT NULL = NULL` 的结果是 `NULL`,而不是 `true` 或 `false`。 `NULL` 意味着缺失或未知的信息。虽然 `NULL` 不等于任何值,但在某些操作中,数据库系统会将 `NULL` 值视为相同的类别进行处理,例如:`DISTINCT`,`GROUP BY`,`ORDER BY`。需要注意的是,这些操作将 `NULL` 值视为相同的类别进行处理,并不意味着 `NULL` 值之间是相等的。 它们只是在特定操作中被特殊处理,以保证结果的正确性和一致性。 这种处理方式是为了方便数据操作,而不是改变了 `NULL` 的语义。
+ - `''` 表示一个空字符串,它是一个已知的值。
+2. **存储空间**:
+ - `NULL` 的存储空间占用取决于数据库的实现,通常需要一些空间来标记该值为空。
+ - `''` 的存储空间占用通常较小,因为它只存储一个空字符串的标志,不需要存储实际的字符。
+3. **比较运算**:
+ - 任何值与 `NULL` 进行比较(例如 `=`, `!=`, `>`, `<` 等)的结果都是 `NULL`,表示结果不确定。要判断一个值是否为 `NULL`,必须使用 `IS NULL` 或 `IS NOT NULL`。
+ - `''` 可以像其他字符串一样进行比较运算。例如,`'' = ''` 的结果是 `true`。
+4. **聚合函数**:
+ - 大多数聚合函数(例如 `SUM`, `AVG`, `MIN`, `MAX`)会忽略 `NULL` 值。
+ - `COUNT(*)` 会统计所有行数,包括包含 `NULL` 值的行。`COUNT(列名)` 会统计指定列中非 `NULL` 值的行数。
+ - 空字符串 `''` 会被聚合函数计算在内。例如,`SUM` 会将其视为 0,`MIN` 和 `MAX` 会将其视为一个空字符串。
看了上面的介绍之后,相信你对另外一个高频面试题:“为什么 MySQL 不建议使用 `NULL` 作为列默认值?”也有了答案。
-### Boolean 类型如何表示?
+### ⭐️Boolean 类型如何表示?
+
+MySQL 中没有专门的布尔类型,而是用 `TINYINT(1)` 类型来表示布尔值。`TINYINT(1)` 类型可以存储 0 或 1,分别对应 false 或 true。
+
+### ⭐️手机号存储用 INT 还是 VARCHAR?
+
+存储手机号,**强烈推荐使用 VARCHAR 类型**,而不是 INT 或 BIGINT。主要原因如下:
+
+1. **格式兼容性与完整性:**
+ - 手机号可能包含前导零(如某些地区的固话区号)、国家代码前缀('+'),甚至可能带有分隔符('-' 或空格)。INT 或 BIGINT 这种数字类型会自动丢失这些重要的格式信息(比如前导零会被去掉,'+' 和 '-' 无法存储)。
+ - VARCHAR 可以原样存储各种格式的号码,无论是国内的 11 位手机号,还是带有国家代码的国际号码,都能完美兼容。
+2. **非算术性:** 手机号虽然看起来是数字,但我们从不对它进行数学运算(比如求和、平均值)。它本质上是一个标识符,更像是一个字符串。用 VARCHAR 更符合其数据性质。
+3. **查询灵活性:**
+ - 业务中常常需要根据号段(前缀)进行查询,例如查找所有 "138" 开头的用户。使用 VARCHAR 类型配合 `LIKE '138%'` 这样的 SQL 查询既直观又高效。
+ - 如果使用数字类型,进行类似的前缀匹配通常需要复杂的函数转换(如 CAST 或 SUBSTRING),或者使用范围查询(如 `WHERE phone >= 13800000000 AND phone < 13900000000`),这不仅写法繁琐,而且可能无法有效利用索引,导致性能下降。
+4. **加密存储的要求(非常关键):**
+ - 出于数据安全和隐私合规的要求,手机号这类敏感个人信息通常必须加密存储在数据库中。
+ - 加密后的数据(密文)是一长串字符串(通常由字母、数字、符号组成,或经过 Base64/Hex 编码),INT 或 BIGINT 类型根本无法存储这种密文。只有 VARCHAR、TEXT 或 BLOB 等类型可以。
-MySQL 中没有专门的布尔类型,而是用 TINYINT(1) 类型来表示布尔值。TINYINT(1) 类型可以存储 0 或 1,分别对应 false 或 true。
+**关于 VARCHAR 长度的选择:**
+
+- **如果不加密存储(强烈不推荐!):** 考虑到国际号码和可能的格式符,VARCHAR(20) 到 VARCHAR(32) 通常是一个比较安全的范围,足以覆盖全球绝大多数手机号格式。VARCHAR(15) 可能对某些带国家码和格式符的号码来说不够用。
+- **如果进行加密存储(推荐的标准做法):** 长度必须根据所选加密算法产生的密文最大长度,以及可能的编码方式(如 Base64 会使长度增加约 1/3)来精确计算和设定。通常会需要更长的 VARCHAR 长度,例如 VARCHAR(128), VARCHAR(256) 甚至更长。
+
+最后,来一张表格总结一下:
+
+| 对比维度 | VARCHAR 类型(推荐) | INT/BIGINT 类型(不推荐) | 说明/备注 |
+| ---------------- | --------------------------------- | ---------------------------- | --------------------------------------------------------------------------- |
+| **格式兼容性** | ✔ 能存前导零、"+"、"-"、空格等 | ✘ 自动丢失前导零,不能存符号 | VARCHAR 能原样存储各种手机号格式,INT/BIGINT 只支持单纯数字,且前导零会消失 |
+| **完整性** | ✔ 不丢失任何格式信息 | ✘ 丢失格式信息 | 例如 "013800012345" 存进 INT 会变成 13800012345,"+" 也无法存储 |
+| **非算术性** | ✔ 适合存储“标识符” | ✘ 只适合做数值运算 | 手机号本质是字符串标识符,不做数学运算,VARCHAR 更贴合实际用途 |
+| **查询灵活性** | ✔ 支持 `LIKE '138%'` 等 | ✘ 查询前缀不方便或性能差 | 使用 VARCHAR 可高效按号段/前缀查询,数字类型需转为字符串或其他复杂处理 |
+| **加密存储支持** | ✔ 可存储加密密文(字母、符号等) | ✘ 无法存储密文 | 加密手机号后密文是字符串/二进制,只有 VARCHAR、TEXT、BLOB 等能兼容 |
+| **长度设置建议** | 15~20(未加密),加密视情况而定 | 无意义 | 不加密时 VARCHAR(15~20) 通用,加密后长度取决于算法和编码方式 |
## MySQL 基础架构
@@ -193,7 +245,7 @@ MySQL 中没有专门的布尔类型,而是用 TINYINT(1) 类型来表示布
- **分析器:** 没有命中缓存的话,SQL 语句就会经过分析器,分析器说白了就是要先看你的 SQL 语句要干嘛,再检查你的 SQL 语句语法是否正确。
- **优化器:** 按照 MySQL 认为最优的方案去执行。
- **执行器:** 执行语句,然后从存储引擎返回数据。 执行语句之前会先判断是否有权限,如果没有权限的话,就会报错。
-- **插件式存储引擎**:主要负责数据的存储和读取,采用的是插件式架构,支持 InnoDB、MyISAM、Memory 等多种存储引擎。
+- **插件式存储引擎**:主要负责数据的存储和读取,采用的是插件式架构,支持 InnoDB、MyISAM、Memory 等多种存储引擎。InnoDB 是 MySQL 的默认存储引擎,绝大部分场景使用 InnoDB 就是最好的选择。
## MySQL 存储引擎
@@ -249,11 +301,15 @@ mysql> SHOW VARIABLES LIKE '%storage_engine%';
MySQL 存储引擎采用的是 **插件式架构** ,支持多种存储引擎,我们甚至可以为不同的数据库表设置不同的存储引擎以适应不同场景的需要。**存储引擎是基于表的,而不是数据库。**
-并且,你还可以根据 MySQL 定义的存储引擎实现标准接口来编写一个属于自己的存储引擎。这些非官方提供的存储引擎可以称为第三方存储引擎,区别于官方存储引擎。像目前最常用的 InnoDB 其实刚开始就是一个第三方存储引擎,后面由于过于优秀,其被 Oracle 直接收购了。
+下图展示了具有可插拔存储引擎的 MySQL 架构:
+
+
+
+你还可以根据 MySQL 定义的存储引擎实现标准接口来编写一个属于自己的存储引擎。这些非官方提供的存储引擎可以称为第三方存储引擎,区别于官方存储引擎。像目前最常用的 InnoDB 其实刚开始就是一个第三方存储引擎,后面由于过于优秀,其被 Oracle 直接收购了。
MySQL 官方文档也有介绍到如何编写一个自定义存储引擎,地址: 。
-### MyISAM 和 InnoDB 有什么区别?
+### ⭐️MyISAM 和 InnoDB 有什么区别?
MySQL 5.5 之前,MyISAM 引擎是 MySQL 的默认存储引擎,可谓是风光一时。
@@ -263,13 +319,13 @@ MySQL 5.5 版本之后,InnoDB 是 MySQL 的默认存储引擎。
言归正传!咱们下面还是来简单对比一下两者:
-**1.是否支持行级锁**
+**1、是否支持行级锁**
MyISAM 只有表级锁(table-level locking),而 InnoDB 支持行级锁(row-level locking)和表级锁,默认为行级锁。
也就说,MyISAM 一锁就是锁住了整张表,这在并发写的情况下是多么滴憨憨啊!这也是为什么 InnoDB 在并发写的时候,性能更牛皮了!
-**2.是否支持事务**
+**2、是否支持事务**
MyISAM 不提供事务支持。
@@ -277,7 +333,7 @@ InnoDB 提供事务支持,实现了 SQL 标准定义了四个隔离级别,
关于 MySQL 事务的详细介绍,可以看看我写的这篇文章:[MySQL 事务隔离级别详解](./transaction-isolation-level.md)。
-**3.是否支持外键**
+**3、是否支持外键**
MyISAM 不支持,而 InnoDB 支持。
@@ -291,19 +347,19 @@ MyISAM 不支持,而 InnoDB 支持。
总结:一般我们也是不建议在数据库层面使用外键的,应用层面可以解决。不过,这样会对数据的一致性造成威胁。具体要不要使用外键还是要根据你的项目来决定。
-**4.是否支持数据库异常崩溃后的安全恢复**
+**4、是否支持数据库异常崩溃后的安全恢复**
MyISAM 不支持,而 InnoDB 支持。
使用 InnoDB 的数据库在异常崩溃后,数据库重新启动的时候会保证数据库恢复到崩溃前的状态。这个恢复的过程依赖于 `redo log` 。
-**5.是否支持 MVCC**
+**5、是否支持 MVCC**
MyISAM 不支持,而 InnoDB 支持。
讲真,这个对比有点废话,毕竟 MyISAM 连行级锁都不支持。MVCC 可以看作是行级锁的一个升级,可以有效减少加锁操作,提高性能。
-**6.索引实现不一样。**
+**6、索引实现不一样。**
虽然 MyISAM 引擎和 InnoDB 引擎都是使用 B+Tree 作为索引结构,但是两者的实现方式不太一样。
@@ -311,12 +367,16 @@ InnoDB 引擎中,其数据文件本身就是索引文件。相比 MyISAM,索
详细区别,推荐你看看我写的这篇文章:[MySQL 索引详解](./mysql-index.md)。
-**7.性能有差别。**
+**7、性能有差别。**
InnoDB 的性能比 MyISAM 更强大,不管是在读写混合模式下还是只读模式下,随着 CPU 核数的增加,InnoDB 的读写能力呈线性增长。MyISAM 因为读写不能并发,它的处理能力跟核数没关系。

+**8、数据缓存策略和机制实现不同。**
+
+InnoDB 使用缓冲池(Buffer Pool)缓存数据页和索引页,MyISAM 使用键缓存(Key Cache)仅缓存索引页而不缓存数据页。
+
**总结**:
- InnoDB 支持行级别的锁粒度,MyISAM 不支持,只支持表级别的锁粒度。
@@ -333,23 +393,175 @@ InnoDB 的性能比 MyISAM 更强大,不管是在读写混合模式下还是
### MyISAM 和 InnoDB 如何选择?
-大多数时候我们使用的都是 InnoDB 存储引擎,在某些读密集的情况下,使用 MyISAM 也是合适的。不过,前提是你的项目不介意 MyISAM 不支持事务、崩溃恢复等缺点(可是~我们一般都会介意啊!)。
+大多数时候我们使用的都是 InnoDB 存储引擎,在某些读密集的情况下,使用 MyISAM 也是合适的。不过,前提是你的项目不介意 MyISAM 不支持事务、崩溃恢复等缺点(可是~我们一般都会介意啊)。
《MySQL 高性能》上面有一句话这样写到:
> 不要轻易相信“MyISAM 比 InnoDB 快”之类的经验之谈,这个结论往往不是绝对的。在很多我们已知场景中,InnoDB 的速度都可以让 MyISAM 望尘莫及,尤其是用到了聚簇索引,或者需要访问的数据都可以放入内存的应用。
-一般情况下我们选择 InnoDB 都是没有问题的,但是某些情况下你并不在乎可扩展能力和并发能力,也不需要事务支持,也不在乎崩溃后的安全恢复问题的话,选择 MyISAM 也是一个不错的选择。但是一般情况下,我们都是需要考虑到这些问题的。
+因此,对于咱们日常开发的业务系统来说,你几乎找不到什么理由使用 MyISAM 了,老老实实用默认的 InnoDB 就可以了!
+
+## ⭐️MySQL 索引
+
+MySQL 索引相关的问题比较多,也非常重要,更详细的介绍可以阅读笔者写的这篇文章:[MySQL 索引详解](./mysql-index.md) 。
+
+### 索引是什么?
+
+**索引是一种用于快速查询和检索数据的数据结构,其本质可以看成是一种排序好的数据结构。**
+
+索引的作用就相当于书的目录。打个比方:我们在查字典的时候,如果没有目录,那我们就只能一页一页地去找我们需要查的那个字,速度很慢;如果有目录了,我们只需要先去目录里查找字的位置,然后直接翻到那一页就行了。
+
+索引底层数据结构存在很多种类型,常见的索引结构有:B 树、 B+ 树 和 Hash、红黑树。在 MySQL 中,无论是 Innodb 还是 MyISAM,都使用了 B+ 树作为索引结构。
+
+**索引的优点:**
+
+1. **查询速度起飞 (主要目的)**:通过索引,数据库可以**大幅减少需要扫描的数据量**,直接定位到符合条件的记录,从而显著加快数据检索速度,减少磁盘 I/O 次数。
+2. **保证数据唯一性**:通过创建**唯一索引 (Unique Index)**,可以确保表中的某一列(或几列组合)的值是独一无二的,比如用户 ID、邮箱等。主键本身就是一种唯一索引。
+3. **加速排序和分组**:如果查询中的 ORDER BY 或 GROUP BY 子句涉及的列建有索引,数据库往往可以直接利用索引已经排好序的特性,避免额外的排序操作,从而提升性能。
+
+**索引的缺点:**
+
+1. **创建和维护耗时**:创建索引本身需要时间,特别是对大表操作时。更重要的是,当对表中的数据进行**增、删、改 (DML 操作)** 时,不仅要操作数据本身,相关的索引也必须动态更新和维护,这会**降低这些 DML 操作的执行效率**。
+2. **占用存储空间**:索引本质上也是一种数据结构,需要以物理文件(或内存结构)的形式存储,因此会**额外占用一定的磁盘空间**。索引越多、越大,占用的空间也就越多。
+3. **可能被误用或失效**:如果索引设计不当,或者查询语句写得不好,数据库优化器可能不会选择使用索引(或者选错索引),反而导致性能下降。
+
+**那么,用了索引就一定能提高查询性能吗?**
+
+**不一定。** 大多数情况下,合理使用索引确实比全表扫描快得多。但也有例外:
+
+- **数据量太小**:如果表里的数据非常少(比如就几百条),全表扫描可能比通过索引查找更快,因为走索引本身也有开销。
+- **查询结果集占比过大**:如果要查询的数据占了整张表的大部分(比如超过 20%-30%),优化器可能会认为全表扫描更划算,因为通过索引多次回表(随机 I/O)的成本可能高于一次顺序的全表扫描。
+- **索引维护不当或统计信息过时**:导致优化器做出错误判断。
+
+### 索引为什么快?
+
+索引之所以快,核心原因是它**大大减少了磁盘 I/O 的次数**。
+
+它的本质是一种**排好序的数据结构**,就像书的目录,让我们不用一页一页地翻(全表扫描)。
+
+在 MySQL 中,这个数据结构是**B+树**。B+树结构主要从两方面做了优化:
+
+1. B+树的特点是“矮胖”,一个千万数据的表,索引树的高度可能只有 3-4 层。这意味着,最多只需要**3-4 次磁盘 I/O**,就能精确定位到我想要的数据,而全表扫描可能需要成千上万次,所以速度极快。
+2. B+树的叶子节点是**用链表连起来的**。找到开头后,就能顺着链表**顺序读**下去,这对磁盘非常友好,还能触发预读。
+
+### MySQL 索引底层数据结构是什么?
+
+在 MySQL 中,MyISAM 引擎和 InnoDB 引擎都是使用 B+Tree 作为索引结构,详细介绍可以参考笔者写的这篇文章:[MySQL 索引详解](https://javaguide.cn/database/mysql/mysql-index.html)。
+
+### 为什么 InnoDB 没有使用哈希作为索引的数据结构?
+
+> 我发现很多求职者甚至是面试官对这个问题都有误解,他们相当然的认为 MySQL 底层并没有使用哈希或者 B 树作为索引的数据结构。
+>
+> 实际上,不论是提问还是回答这个问题都要区分好存储引擎。像 MEMORY 引擎就同时支持哈希和 B 树。
+
+哈希索引的底层是哈希表。它的优点是,在进行**精确的等值查询**时,理论上时间复杂度是 **O(1)** ,速度极快。比如 `WHERE id = 123`。
+
+但是,它有几个对于通用数据库来说是致命的缺点:
+
+1. **不支持范围查询:** 这是最主要的原因。哈希函数的一个特点是它会把相邻的输入值(比如 `id=100` 和 `id=101`)映射到哈希表中完全不相邻的位置。这种顺序的破坏,使得我们无法处理像 `WHERE age > 30` 或 `BETWEEN 100 AND 200`这样的范围查询。要完成这种查询,哈希索引只能退化为全表扫描。
+2. **不支持排序:** 同理,因为哈希值是无序的,所以我们无法利用哈希索引来优化 `ORDER BY` 子句。
+3. **不支持部分索引键查询:** 对于联合索引,比如`(col1, col2)`,哈希索引必须使用所有索引列进行查询,它无法单独利用 `col1` 来加速查询。
+4. **哈希冲突问题:** 当不同的键产生相同的哈希值时,需要额外的链表或开放寻址来解决,这会降低性能。
+
+鉴于数据库查询中范围查询和排序是极其常见的操作,一个不支持这些功能的索引结构,显然不能作为默认的、通用的索引类型。
+
+### 为什么 InnoDB 没有使用 B 树作为索引的数据结构?
+
+B 树和 B+树都是优秀的多路平衡搜索树,非常适合磁盘存储,因为它们都很“矮胖”,能最大化地利用每一次磁盘 I/O。
+
+但 B+树是 B 树的一个增强版,它针对数据库场景做了几个关键优化:
+
+1. **I/O 效率更高:** 在 B+树中,只有叶子节点才存储数据(或数据指针),而非叶子节点只存储索引键。因为非叶子节点不存数据,所以它们可以容纳更多的索引键。这意味着 B+树的“扇出”更大,在同样的数据量下,B+树通常会比 B 树更矮,也就意味着查找数据所需的磁盘 I/O 次数更少。
+2. **查询性能更稳定:** 在 B+树中,任何一次查询都必须从根节点走到叶子节点才能找到数据,所以查询路径的长度是固定的。而在 B 树中,如果运气好,可能在非叶子节点就找到了数据,但运气不好也得走到叶子,这导致查询性能不稳定。
+3. **对范围查询极其友好:** 这是 B+树最核心的优势。它的所有叶子节点之间通过一个双向链表连接。当我们执行一个范围查询(比如 `WHERE id > 100`)时,只需要通过树形结构找到 `id=100` 的叶子节点,然后就可以沿着链表向后顺序扫描,而无需再回溯到上层节点。这使得范围查询的效率大大提高。
+
+### 什么是覆盖索引?
+
+如果一个索引包含(或者说覆盖)所有需要查询的字段的值,我们就称之为 **覆盖索引(Covering Index)**。
+
+在 InnoDB 存储引擎中,非主键索引的叶子节点包含的是主键的值。这意味着,当使用非主键索引进行查询时,数据库会先找到对应的主键值,然后再通过主键索引来定位和检索完整的行数据。这个过程被称为“回表”。
+
+**覆盖索引即需要查询的字段正好是索引的字段,那么直接根据该索引,就可以查到数据了,而无需回表查询。**
+
+### 请解释一下 MySQL 的联合索引及其最左前缀原则
+
+使用表中的多个字段创建索引,就是 **联合索引**,也叫 **组合索引** 或 **复合索引**。
+
+以 `score` 和 `name` 两个字段建立联合索引:
-因此,对于咱们日常开发的业务系统来说,你几乎找不到什么理由再使用 MyISAM 作为自己的 MySQL 数据库的存储引擎。
+```sql
+ALTER TABLE `cus_order` ADD INDEX id_score_name(score, name);
+```
+
+最左前缀匹配原则指的是在使用联合索引时,MySQL 会根据索引中的字段顺序,从左到右依次匹配查询条件中的字段。如果查询条件与索引中的最左侧字段相匹配,那么 MySQL 就会使用索引来过滤数据,这样可以提高查询效率。
+
+最左匹配原则会一直向右匹配,直到遇到范围查询(如 >、<)为止。对于 >=、<=、BETWEEN 以及前缀匹配 LIKE 的范围查询,不会停止匹配(相关阅读:[联合索引的最左匹配原则全网都在说的一个错误结论](https://mp.weixin.qq.com/s/8qemhRg5MgXs1So5YCv0fQ))。
+
+假设有一个联合索引 `(column1, column2, column3)`,其从左到右的所有前缀为 `(column1)`、`(column1, column2)`、`(column1, column2, column3)`(创建 1 个联合索引相当于创建了 3 个索引),包含这些列的所有查询都会走索引而不会全表扫描。
+
+我们在使用联合索引时,可以将区分度高的字段放在最左边,这也可以过滤更多数据。
+
+我们这里简单演示一下最左前缀匹配的效果。
+
+1、创建一个名为 `student` 的表,这张表只有 `id`、`name`、`class` 这 3 个字段。
+
+```sql
+CREATE TABLE `student` (
+ `id` int NOT NULL,
+ `name` varchar(100) DEFAULT NULL,
+ `class` varchar(100) DEFAULT NULL,
+ PRIMARY KEY (`id`),
+ KEY `name_class_idx` (`name`,`class`)
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
+```
+
+2、下面我们分别测试三条不同的 SQL 语句。
+
+
-## MySQL 索引
+```sql
+# 可以命中索引
+SELECT * FROM student WHERE name = 'Anne Henry';
+EXPLAIN SELECT * FROM student WHERE name = 'Anne Henry' AND class = 'lIrm08RYVk';
+# 无法命中索引
+SELECT * FROM student WHERE class = 'lIrm08RYVk';
+```
+
+再来看一个常见的面试题:如果有索引 `联合索引(a,b,c)`,查询 `a=1 AND c=1` 会走索引么?`c=1` 呢?`b=1 AND c=1` 呢? `b = 1 AND a = 1 AND c = 1` 呢?
+
+先不要往下看答案,给自己 3 分钟时间想一想。
+
+1. 查询 `a=1 AND c=1`:根据最左前缀匹配原则,查询可以使用索引的前缀部分。因此,该查询仅在 `a=1` 上使用索引,然后对结果进行 `c=1` 的过滤。
+2. 查询 `c=1`:由于查询中不包含最左列 `a`,根据最左前缀匹配原则,整个索引都无法被使用。
+3. 查询 `b=1 AND c=1`:和第二种一样的情况,整个索引都不会使用。
+4. 查询 `b=1 AND a=1 AND c=1`:这个查询是可以用到索引的。查询优化器分析 SQL 语句时,对于联合索引,会对查询条件进行重排序,以便用到索引。会将 `b=1` 和 `a=1` 的条件进行重排序,变成 `a=1 AND b=1 AND c=1`。
+
+MySQL 8.0.13 版本引入了索引跳跃扫描(Index Skip Scan,简称 ISS),它可以在某些索引查询场景下提高查询效率。在没有 ISS 之前,不满足最左前缀匹配原则的联合索引查询中会执行全表扫描。而 ISS 允许 MySQL 在某些情况下避免全表扫描,即使查询条件不符合最左前缀。不过,这个功能比较鸡肋, 和 Oracle 中的没法比,MySQL 8.0.31 还报告了一个 bug:[Bug #109145 Using index for skip scan cause incorrect result](https://bugs.mysql.com/bug.php?id=109145)(后续版本已经修复)。个人建议知道有这个东西就好,不需要深究,实际项目也不一定能用上。
+
+### SELECT \* 会导致索引失效吗?
+
+`SELECT *` 不会直接导致索引失效(如果不走索引大概率是因为 where 查询范围过大导致的),但它可能会带来一些其他的性能问题比如造成网络传输和数据处理的浪费、无法使用索引覆盖。
-MySQL 索引相关的问题比较多,对于面试和工作都比较重要,于是,我单独抽了一篇文章专门来总结 MySQL 索引相关的知识点和问题:[MySQL 索引详解](./mysql-index.md) 。
+### 哪些字段适合创建索引?
+
+- **不为 NULL 的字段**:索引字段的数据应该尽量不为 NULL,因为对于数据为 NULL 的字段,数据库较难优化。如果字段频繁被查询,但又避免不了为 NULL,建议使用 0,1,true,false 这样语义较为清晰的短值或短字符作为替代。
+- **被频繁查询的字段**:我们创建索引的字段应该是查询操作非常频繁的字段。
+- **被作为条件查询的字段**:被作为 WHERE 条件查询的字段,应该被考虑建立索引。
+- **频繁需要排序的字段**:索引已经排序,这样查询可以利用索引的排序,加快排序查询时间。
+- **被经常频繁用于连接的字段**:经常用于连接的字段可能是一些外键列,对于外键列并不一定要建立外键,只是说该列涉及到表与表的关系。对于频繁被连接查询的字段,可以考虑建立索引,提高多表连接查询的效率。
+
+### 索引失效的原因有哪些?
+
+1. 创建了组合索引,但查询条件未遵守最左匹配原则;
+2. 在索引列上进行计算、函数、类型转换等操作;
+3. 以 % 开头的 LIKE 查询比如 `LIKE '%abc';`;
+4. 查询条件中使用 OR,且 OR 的前后条件中有一个列没有索引,涉及的索引都不会被使用到;
+5. IN 的取值范围较大时会导致索引失效,走全表扫描(NOT IN 和 IN 的失效场景相同);
+6. 发生[隐式转换](https://javaguide.cn/database/mysql/index-invalidation-caused-by-implicit-conversion.html "隐式转换");
## MySQL 查询缓存
-执行查询语句的时候,会先查询缓存。不过,MySQL 8.0 版本后移除,因为这个功能不太实用
+MySQL 查询缓存是查询结果缓存。执行查询语句的时候,会先查询缓存,如果缓存中有对应的查询结果,就会直接返回。
`my.cnf` 加入以下配置,重启 MySQL 开启查询缓存
@@ -365,7 +577,7 @@ set global query_cache_type=1;
set global query_cache_size=600000;
```
-如上,**开启查询缓存后在同样的查询条件以及数据情况下,会直接在缓存中返回结果**。这里的查询条件包括查询本身、当前要查询的数据库、客户端协议版本号等一些可能影响结果的信息。
+查询缓存会在同样的查询条件和数据情况下,直接返回缓存中的结果。但需要注意的是,查询缓存的匹配条件非常严格,任何细微的差异都会导致缓存无法命中。这里的查询条件包括查询语句本身、当前使用的数据库、以及其他可能影响结果的因素,如客户端协议版本号等。
**查询缓存不命中的情况:**
@@ -373,32 +585,27 @@ set global query_cache_size=600000;
2. 如果查询中包含任何用户自定义函数、存储函数、用户变量、临时表、MySQL 库中的系统表,其查询结果也不会被缓存。
3. 缓存建立之后,MySQL 的查询缓存系统会跟踪查询中涉及的每张表,如果这些表(数据或结构)发生变化,那么和这张表相关的所有缓存数据都将失效。
-**缓存虽然能够提升数据库的查询性能,但是缓存同时也带来了额外的开销,每次查询后都要做一次缓存操作,失效后还要销毁。** 因此,开启查询缓存要谨慎,尤其对于写密集的应用来说更是如此。如果开启,要注意合理控制缓存空间大小,一般来说其大小设置为几十 MB 比较合适。此外,**还可以通过 `sql_cache` 和 `sql_no_cache` 来控制某个查询语句是否需要缓存:**
+**缓存虽然能够提升数据库的查询性能,但是缓存同时也带来了额外的开销,每次查询后都要做一次缓存操作,失效后还要销毁。** 因此,开启查询缓存要谨慎,尤其对于写密集的应用来说更是如此。如果开启,要注意合理控制缓存空间大小,一般来说其大小设置为几十 MB 比较合适。此外,还可以通过 `sql_cache` 和 `sql_no_cache` 来控制某个查询语句是否需要缓存:
```sql
SELECT sql_no_cache COUNT(*) FROM usr;
```
-## MySQL 日志
+MySQL 5.6 开始,查询缓存已默认禁用。MySQL 8.0 开始,已经不再支持查询缓存了(具体可以参考这篇文章:[MySQL 8.0: Retiring Support for the Query Cache](https://dev.mysql.com/blog-archive/mysql-8-0-retiring-support-for-the-query-cache/))。
-MySQL 日志常见的面试题有:
+
-- MySQL 中常见的日志有哪些?
-- 慢查询日志有什么用?
-- binlog 主要记录了什么?
-- redo log 如何保证事务的持久性?
-- 页修改之后为什么不直接刷盘呢?
-- binlog 和 redolog 有什么区别?
-- undo log 如何保证事务的原子性?
-- ……
+## ⭐️MySQL 日志
-上诉问题的答案可以在[《Java 面试指北》(付费)](../../zhuanlan/java-mian-shi-zhi-bei.md) 的 **「技术面试题篇」** 中找到。
+上诉问题的答案可以在[《Java 面试指北》(付费,点击链接领取优惠卷)](https://javaguide.cn/zhuanlan/java-mian-shi-zhi-bei.html) 的 **「技术面试题篇」** 中找到。

-## MySQL 事务
+文章地址: (密码获取:)。
-### 何谓事务?
+## ⭐️MySQL 事务
+
+### 什么是事务?
我们设想一个场景,这个场景中我们需要插入多条相关联的数据到数据库,不幸的是,这个过程可能会遇到下面这些问题:
@@ -420,7 +627,7 @@ MySQL 日志常见的面试题有:

-### 何谓数据库事务?
+### 什么是数据库事务?
大多数情况下,我们在谈论事务的时候,如果没有特指**分布式事务**,往往指的就是**数据库事务**。
@@ -477,7 +684,7 @@ COMMIT;
例如:事务 1 读取某表中的数据 A=20,事务 1 修改 A=A-1,事务 2 读取到 A = 19,事务 1 回滚导致对 A 的修改并未提交到数据库, A 的值还是 20。
-
+
#### 丢失修改(Lost to modify)
@@ -485,7 +692,7 @@ COMMIT;
例如:事务 1 读取某表中的数据 A=20,事务 2 也读取 A=20,事务 1 先修改 A=A-1,事务 2 后来也修改 A=A-1,最终结果 A=19,事务 1 的修改被丢失。
-
+
#### 不可重复读(Unrepeatable read)
@@ -493,7 +700,7 @@ COMMIT;
例如:事务 1 读取某表中的数据 A=20,事务 2 也读取 A=20,事务 1 修改 A=A-1,事务 2 再次读取 A =19,此时读取的结果和第一次读取的结果不同。
-
+
#### 幻读(Phantom read)
@@ -501,14 +708,14 @@ COMMIT;
例如:事务 2 读取某个范围的数据,事务 1 在这个范围插入了新的数据,事务 2 再次读取这个范围的数据发现相比于第一次读取的结果多了新的数据。
-
+
### 不可重复读和幻读有什么区别?
- 不可重复读的重点是内容修改或者记录减少比如多次读取一条记录发现其中某些记录的值被修改;
- 幻读的重点在于记录新增比如多次执行同一条查询语句(DQL)时,发现查到的记录增加了。
-幻读其实可以看作是不可重复读的一种特殊情况,单独把区分幻读的原因主要是解决幻读和不可重复读的方案不一样。
+幻读其实可以看作是不可重复读的一种特殊情况,单独把幻读区分出来的原因主要是解决幻读和不可重复读的方案不一样。
举个例子:执行 `delete` 和 `update` 操作的时候,可以直接对记录加锁,保证事务安全。而执行 `insert` 操作的时候,由于记录锁(Record Lock)只能锁住已经存在的记录,为了避免插入新记录,需要依赖间隙锁(Gap Lock)。也就是说执行 `insert` 操作的时候需要依赖 Next-Key Lock(Record Lock+Gap Lock) 进行加锁来保证不出现幻读。
@@ -516,7 +723,7 @@ COMMIT;
MySQL 中并发事务的控制方式无非就两种:**锁** 和 **MVCC**。锁可以看作是悲观控制的模式,多版本并发控制(MVCC,Multiversion concurrency control)可以看作是乐观控制的模式。
-**锁** 控制方式下会通过锁来显示控制共享资源而不是通过调度手段,MySQL 中主要是通过 **读写锁** 来实现并发控制。
+**锁** 控制方式下会通过锁来显式控制共享资源而不是通过调度手段,MySQL 中主要是通过 **读写锁** 来实现并发控制。
- **共享锁(S 锁)**:又称读锁,事务在读取记录的时候获取共享锁,允许多个事务同时获取(锁兼容)。
- **排他锁(X 锁)**:又称写锁/独占锁,事务在修改记录的时候获取排他锁,不允许多个事务同时获取。如果一个记录已经被加了排他锁,那其他事务不能再对这条记录加任何类型的锁(锁不兼容)。
@@ -534,31 +741,26 @@ MVCC 在 MySQL 中实现所依赖的手段主要是: **隐藏字段、read view
### SQL 标准定义了哪些事务隔离级别?
-SQL 标准定义了四个隔离级别:
+SQL 标准定义了四种事务隔离级别,用来平衡事务的隔离性(Isolation)和并发性能。级别越高,数据一致性越好,但并发性能可能越低。这四个级别是:
-- **READ-UNCOMMITTED(读取未提交)** :最低的隔离级别,允许读取尚未提交的数据变更,可能会导致脏读、幻读或不可重复读。
-- **READ-COMMITTED(读取已提交)** :允许读取并发事务已经提交的数据,可以阻止脏读,但是幻读或不可重复读仍有可能发生。
-- **REPEATABLE-READ(可重复读)** :对同一字段的多次读取结果都是一致的,除非数据是被本身事务自己所修改,可以阻止脏读和不可重复读,但幻读仍有可能发生。
+- **READ-UNCOMMITTED(读取未提交)** :最低的隔离级别,允许读取尚未提交的数据变更,可能会导致脏读、幻读或不可重复读。这种级别在实际应用中很少使用,因为它对数据一致性的保证太弱。
+- **READ-COMMITTED(读取已提交)** :允许读取并发事务已经提交的数据,可以阻止脏读,但是幻读或不可重复读仍有可能发生。这是大多数数据库(如 Oracle, SQL Server)的默认隔离级别。
+- **REPEATABLE-READ(可重复读)** :对同一字段的多次读取结果都是一致的,除非数据是被本身事务自己所修改,可以阻止脏读和不可重复读,但幻读仍有可能发生。MySQL InnoDB 存储引擎的默认隔离级别正是 REPEATABLE READ。并且,InnoDB 在此级别下通过 MVCC(多版本并发控制) 和 Next-Key Locks(间隙锁+行锁) 机制,在很大程度上解决了幻读问题。
- **SERIALIZABLE(可串行化)** :最高的隔离级别,完全服从 ACID 的隔离级别。所有的事务依次逐个执行,这样事务之间就完全不可能产生干扰,也就是说,该级别可以防止脏读、不可重复读以及幻读。
----
-
-| 隔离级别 | 脏读 | 不可重复读 | 幻读 |
-| :--------------: | :--: | :--------: | :--: |
-| READ-UNCOMMITTED | √ | √ | √ |
-| READ-COMMITTED | × | √ | √ |
-| REPEATABLE-READ | × | × | √ |
-| SERIALIZABLE | × | × | × |
-
-### MySQL 的隔离级别是基于锁实现的吗?
-
-MySQL 的隔离级别基于锁和 MVCC 机制共同实现的。
-
-SERIALIZABLE 隔离级别是通过锁来实现的,READ-COMMITTED 和 REPEATABLE-READ 隔离级别是基于 MVCC 实现的。不过, SERIALIZABLE 之外的其他隔离级别可能也需要用到锁机制,就比如 REPEATABLE-READ 在当前读情况下需要使用加锁读来保证不会出现幻读。
+| 隔离级别 | 脏读 (Dirty Read) | 不可重复读 (Non-Repeatable Read) | 幻读 (Phantom Read) |
+| ---------------- | ----------------- | -------------------------------- | ---------------------- |
+| READ UNCOMMITTED | √ | √ | √ |
+| READ COMMITTED | × | √ | √ |
+| REPEATABLE READ | × | × | √ (标准) / ≈× (InnoDB) |
+| SERIALIZABLE | × | × | × |
### MySQL 的默认隔离级别是什么?
-MySQL InnoDB 存储引擎的默认支持的隔离级别是 **REPEATABLE-READ(可重读)**。我们可以通过`SELECT @@tx_isolation;`命令来查看,MySQL 8.0 该命令改为`SELECT @@transaction_isolation;`
+MySQL InnoDB 存储引擎的默认隔离级别是 **REPEATABLE READ**。可以通过以下命令查看:
+
+- MySQL 8.0 之前:`SELECT @@tx_isolation;`
+- MySQL 8.0 及之后:`SELECT @@transaction_isolation;`
```sql
mysql> SELECT @@tx_isolation;
@@ -571,6 +773,12 @@ mysql> SELECT @@tx_isolation;
关于 MySQL 事务隔离级别的详细介绍,可以看看我写的这篇文章:[MySQL 事务隔离级别详解](./transaction-isolation-level.md)。
+### MySQL 的隔离级别是基于锁实现的吗?
+
+MySQL 的隔离级别基于锁和 MVCC 机制共同实现的。
+
+SERIALIZABLE 隔离级别是通过锁来实现的,READ-COMMITTED 和 REPEATABLE-READ 隔离级别是基于 MVCC 实现的。不过, SERIALIZABLE 之外的其他隔离级别可能也需要用到锁机制,就比如 REPEATABLE-READ 在当前读情况下需要使用加锁读来保证不会出现幻读。
+
## MySQL 锁
锁是一种常见的并发事务的控制方式。
@@ -596,14 +804,12 @@ InnoDB 的行锁是针对索引字段加的锁,表级锁是针对非索引字
InnoDB 行锁是通过对索引数据页上的记录加锁实现的,MySQL InnoDB 支持三种行锁定方式:
-- **记录锁(Record Lock)**:也被称为记录锁,属于单个行记录上的锁。
+- **记录锁(Record Lock)**:属于单个行记录上的锁。
- **间隙锁(Gap Lock)**:锁定一个范围,不包括记录本身。
- **临键锁(Next-Key Lock)**:Record Lock+Gap Lock,锁定一个范围,包含记录本身,主要目的是为了解决幻读问题(MySQL 事务部分提到过)。记录锁只能锁住已经存在的记录,为了避免插入新记录,需要依赖间隙锁。
**在 InnoDB 默认的隔离级别 REPEATABLE-READ 下,行锁默认使用的是 Next-Key Lock。但是,如果操作的索引是唯一索引或主键,InnoDB 会对 Next-Key Lock 进行优化,将其降级为 Record Lock,即仅锁住索引本身,而不是范围。**
-一些大厂面试中可能会问到 Next-Key Lock 的加锁范围,这里推荐一篇文章:[MySQL next-key lock 加锁范围是什么? - 程序员小航 - 2021](https://segmentfault.com/a/1190000040129107) 。
-
### 共享锁和排他锁呢?
不论是表级锁还是行级锁,都存在共享锁(Share Lock,S 锁)和排他锁(Exclusive Lock,X 锁)这两类:
@@ -638,7 +844,7 @@ SELECT ... FOR UPDATE;
- **意向共享锁(Intention Shared Lock,IS 锁)**:事务有意向对表中的某些记录加共享锁(S 锁),加共享锁前必须先取得该表的 IS 锁。
- **意向排他锁(Intention Exclusive Lock,IX 锁)**:事务有意向对表中的某些记录加排他锁(X 锁),加排他锁之前必须先取得该表的 IX 锁。
-**意向锁是由数据引擎自己维护的,用户无法手动操作意向锁,在为数据行加共享/排他锁之前,InooDB 会先获取该数据行所在在数据表的对应意向锁。**
+**意向锁是由数据引擎自己维护的,用户无法手动操作意向锁,在为数据行加共享/排他锁之前,InnoDB 会先获取该数据行所在在数据表的对应意向锁。**
意向锁之间是互相兼容的。
@@ -731,7 +937,7 @@ CREATE TABLE `sequence_id` (
最后,再推荐一篇文章:[为什么 MySQL 的自增主键不单调也不连续](https://draveness.me/whys-the-design-mysql-auto-increment/) 。
-## MySQL 性能优化
+## ⭐️MySQL 性能优化
关于 MySQL 性能优化的建议总结,请看这篇文章:[MySQL 高性能优化规范建议总结](./mysql-high-performance-optimization-specification-recommendations.md) 。
@@ -747,8 +953,6 @@ CREATE TABLE `sequence_id` (
**数据库只存储文件地址信息,文件由文件存储服务负责存储。**
-相关阅读:[Spring Boot 整合 MinIO 实现分布式文件服务](https://www.51cto.com/article/716978.html) 。
-
### MySQL 如何存储 IP 地址?
可以将 IP 地址转换成整形数据存储,性能更好,占用空间也更小。
@@ -762,10 +966,12 @@ MySQL 提供了两个方法来处理 ip 地址
### 有哪些常见的 SQL 优化手段?
-[《Java 面试指北》(付费)](../../zhuanlan/java-mian-shi-zhi-bei.md) 的 **「技术面试题篇」** 有一篇文章详细介绍了常见的 SQL 优化手段,非常全面,清晰易懂!
+[《Java 面试指北》(付费)](https://javaguide.cn/zhuanlan/java-mian-shi-zhi-bei.html) 的 **「技术面试题篇」** 有一篇文章详细介绍了常见的 SQL 优化手段,非常全面,清晰易懂!

+文章地址:https://www.yuque.com/snailclimb/mf2z3k/abc2sv (密码获取:)。
+
### 如何分析 SQL 的性能?
我们可以使用 `EXPLAIN` 命令来分析 SQL 的 **执行计划** 。执行计划是指一条 SQL 语句在经过 MySQL 查询优化器的优化会后,具体的执行方式。
@@ -819,15 +1025,44 @@ mysql> EXPLAIN SELECT `score`,`name` FROM `cus_order` ORDER BY `score` DESC;
[数据冷热分离详解](../../high-performance/data-cold-hot-separation.md)
-### 常见的数据库优化方法有哪些?
+### MySQL 性能怎么优化?
+
+MySQL 性能优化是一个系统性工程,涉及多个方面,在面试中不可能面面俱到。因此,建议按照“点-线-面”的思路展开,从核心问题入手,再逐步扩展,展示出你对问题的思考深度和解决能力。
+
+**1. 抓住核心:慢 SQL 定位与分析**
+
+性能优化的第一步永远是找到瓶颈。面试时,建议先从 **慢 SQL 定位和分析** 入手,这不仅能展示你解决问题的思路,还能体现你对数据库性能监控的熟练掌握:
+
+- **监控工具:** 介绍常用的慢 SQL 监控工具,如 **MySQL 慢查询日志**、**Performance Schema** 等,说明你对这些工具的熟悉程度以及如何通过它们定位问题。
+- **EXPLAIN 命令:** 详细说明 `EXPLAIN` 命令的使用,分析查询计划、索引使用情况,可以结合实际案例展示如何解读分析结果,比如执行顺序、索引使用情况、全表扫描等。
+
+**2. 由点及面:索引、表结构和 SQL 优化**
+
+定位到慢 SQL 后,接下来就要针对具体问题进行优化。 这里可以重点介绍索引、表结构和 SQL 编写规范等方面的优化技巧:
+
+- **索引优化:** 这是 MySQL 性能优化的重点,可以介绍索引的创建原则、覆盖索引、最左前缀匹配原则等。如果能结合你项目的实际应用来说明如何选择合适的索引,会更加分一些。
+- **表结构优化:** 优化表结构设计,包括选择合适的字段类型、避免冗余字段、合理使用范式和反范式设计等等。
+- **SQL 优化:** 避免使用 `SELECT *`、尽量使用具体字段、使用连接查询代替子查询、合理使用分页查询、批量操作等,都是 SQL 编写过程中需要注意的细节。
+
+**3. 进阶方案:架构优化**
+
+当面试官对基础优化知识比较满意时,可能会深入探讨一些架构层面的优化方案。以下是一些常见的架构优化策略:
+
+- **读写分离:** 将读操作和写操作分离到不同的数据库实例,提升数据库的并发处理能力。
+- **分库分表:** 将数据分散到多个数据库实例或数据表中,降低单表数据量,提升查询效率。但要权衡其带来的复杂性和维护成本,谨慎使用。
+- **数据冷热分离**:根据数据的访问频率和业务重要性,将数据分为冷数据和热数据,冷数据一般存储在低成本、低性能的介质中,热数据存储在高性能存储介质中。
+- **缓存机制:** 使用 Redis 等缓存中间件,将热点数据缓存到内存中,减轻数据库压力。这个非常常用,提升效果非常明显,性价比极高!
+
+**4. 其他优化手段**
+
+除了慢 SQL 定位、索引优化和架构优化,还可以提及一些其他优化手段,展示你对 MySQL 性能调优的全面理解:
+
+- **连接池配置:** 配置合理的数据库连接池(如 **连接池大小**、**超时时间** 等),能够有效提升数据库连接的效率,避免频繁的连接开销。
+- **硬件配置:** 提升硬件性能也是优化的重要手段之一。使用高性能服务器、增加内存、使用 **SSD** 硬盘等硬件升级,都可以有效提升数据库的整体性能。
+
+**5.总结**
-- [索引优化](./mysql-index.md)
-- [读写分离和分库分表](../../high-performance/read-and-write-separation-and-library-subtable.md)
-- [数据冷热分离](../../high-performance/data-cold-hot-separation.md)
-- [SQL 优化](../../high-performance/sql-optimization.md)
-- [深度分页优化](../../high-performance/deep-pagination-optimization.md)
-- 适当冗余数据
-- 使用更高的硬件配置
+在面试中,建议按优先级依次介绍慢 SQL 定位、[索引优化](./mysql-index.md)、表结构设计和 [SQL 优化](../../high-performance/sql-optimization.md)等内容。架构层面的优化,如[读写分离和分库分表](../../high-performance/read-and-write-separation-and-library-subtable.md)、[数据冷热分离](../../high-performance/data-cold-hot-separation.md) 应作为最后的手段,除非在特定场景下有明显的性能瓶颈,否则不应轻易使用,因其引入的复杂性会带来额外的维护成本。
## MySQL 学习资料推荐
diff --git a/docs/database/mysql/some-thoughts-on-database-storage-time.md b/docs/database/mysql/some-thoughts-on-database-storage-time.md
index 75329434c1b..2cca50eb2a8 100644
--- a/docs/database/mysql/some-thoughts-on-database-storage-time.md
+++ b/docs/database/mysql/some-thoughts-on-database-storage-time.md
@@ -1,32 +1,46 @@
---
title: MySQL日期类型选择建议
+description: 深入对比MySQL中DATETIME和TIMESTAMP的区别,分析时区处理、存储空间、取值范围等差异,给出日期类型选择的最佳实践建议。
category: 数据库
tag:
- MySQL
+head:
+ - - meta
+ - name: keywords
+ content: MySQL时间存储,DATETIME,TIMESTAMP,时间戳,时区处理,日期类型选择,MySQL日期函数
---
-我们平时开发中不可避免的就是要存储时间,比如我们要记录操作表中这条记录的时间、记录转账的交易时间、记录出发时间、用户下单时间等等。你会发现时间这个东西与我们开发的联系还是非常紧密的,用的好与不好会给我们的业务甚至功能带来很大的影响。所以,我们有必要重新出发,好好认识一下这个东西。
+在日常的软件开发工作中,存储时间是一项基础且常见的需求。无论是记录数据的操作时间、金融交易的发生时间,还是行程的出发时间、用户的下单时间等等,时间信息与我们的业务逻辑和系统功能紧密相关。因此,正确选择和使用 MySQL 的日期时间类型至关重要,其恰当与否甚至可能对业务的准确性和系统的稳定性产生显著影响。
+
+本文旨在帮助开发者重新审视并深入理解 MySQL 中不同的时间存储方式,以便做出更合适项目业务场景的选择。
## 不要用字符串存储日期
-和绝大部分对数据库不太了解的新手一样,我在大学的时候就这样干过,甚至认为这样是一个不错的表示日期的方法。毕竟简单直白,容易上手。
+和许多数据库初学者一样,笔者在早期学习阶段也曾尝试使用字符串(如 VARCHAR)类型来存储日期和时间,甚至一度认为这是一种简单直观的方法。毕竟,'YYYY-MM-DD HH:MM:SS' 这样的格式看起来清晰易懂。
但是,这是不正确的做法,主要会有下面两个问题:
-1. 字符串占用的空间更大!
-2. 字符串存储的日期效率比较低(逐个字符进行比对),无法用日期相关的 API 进行计算和比较。
+1. **空间效率**:与 MySQL 内建的日期时间类型相比,字符串通常需要占用更多的存储空间来表示相同的时间信息。
+2. **查询与计算效率低下**:
+ - **比较操作复杂且低效**:基于字符串的日期比较需要按照字典序逐字符进行,这不仅不直观(例如,'2024-05-01' 会小于 '2024-1-10'),而且效率远低于使用原生日期时间类型进行的数值或时间点比较。
+ - **计算功能受限**:无法直接利用数据库提供的丰富日期时间函数进行运算(例如,计算两个日期之间的间隔、对日期进行加减操作等),需要先转换格式,增加了复杂性。
+ - **索引性能不佳**:基于字符串的索引在处理范围查询(如查找特定时间段内的数据)时,其效率和灵活性通常不如原生日期时间类型的索引。
-## Datetime 和 Timestamp 之间的抉择
+## DATETIME 和 TIMESTAMP 选择
-Datetime 和 Timestamp 是 MySQL 提供的两种比较相似的保存时间的数据类型,可以精确到秒。他们两者究竟该如何选择呢?
+`DATETIME` 和 `TIMESTAMP` 是 MySQL 中两种非常常用的、用于存储包含日期和时间信息的数据类型。它们都可以存储精确到秒(MySQL 5.6.4+ 支持更高精度的小数秒)的时间值。那么,在实际应用中,我们应该如何在这两者之间做出选择呢?
-下面我们来简单对比一下二者。
+下面我们从几个关键维度对它们进行对比:
### 时区信息
-**DateTime 类型是没有时区信息的(时区无关)** ,DateTime 类型保存的时间都是当前会话所设置的时区对应的时间。这样就会有什么问题呢?当你的时区更换之后,比如你的服务器更换地址或者更换客户端连接时区设置的话,就会导致你从数据库中读出的时间错误。
+`DATETIME` 类型存储的是**字面量的日期和时间值**,它本身**不包含任何时区信息**。当你插入一个 `DATETIME` 值时,MySQL 存储的就是你提供的那个确切的时间,不会进行任何时区转换。
+
+**这样就会有什么问题呢?** 如果你的应用需要支持多个时区,或者服务器、客户端的时区可能发生变化,那么使用 `DATETIME` 时,应用程序需要自行处理时区的转换和解释。如果处理不当(例如,假设所有存储的时间都属于同一个时区,但实际环境变化了),可能会导致时间显示或计算上的混乱。
-**Timestamp 和时区有关**。Timestamp 类型字段的值会随着服务器时区的变化而变化,自动换算成相应的时间,说简单点就是在不同时区,查询到同一个条记录此字段的值会不一样。
+**`TIMESTAMP` 和时区有关**。存储时,MySQL 会将当前会话时区下的时间值转换成 UTC(协调世界时)进行内部存储。当查询 `TIMESTAMP` 字段时,MySQL 又会将存储的 UTC 时间转换回当前会话所设置的时区来显示。
+
+这意味着,对于同一条记录的 `TIMESTAMP` 字段,在不同的会话时区设置下查询,可能会看到不同的本地时间表示,但它们都对应着同一个绝对时间点(UTC 时间)。这对于需要全球化、多时区支持的应用来说非常有用。
下面实际演示一下!
@@ -41,16 +55,16 @@ CREATE TABLE `time_zone_test` (
) ENGINE=InnoDB DEFAULT CHARSET=utf8;
```
-插入数据:
+插入一条数据(假设当前会话时区为系统默认,例如 UTC+0)::
```sql
INSERT INTO time_zone_test(date_time,time_stamp) VALUES(NOW(),NOW());
```
-查看数据:
+查询数据(在同一时区会话下):
```sql
-select date_time,time_stamp from time_zone_test;
+SELECT date_time, time_stamp FROM time_zone_test;
```
结果:
@@ -63,17 +77,16 @@ select date_time,time_stamp from time_zone_test;
+---------------------+---------------------+
```
-现在我们运行
-
-修改当前会话的时区:
+现在,修改当前会话的时区为东八区 (UTC+8):
```sql
-set time_zone='+8:00';
+SET time_zone = '+8:00';
```
-再次查看数据:
+再次查询数据:
-```plain
+```bash
+# TIMESTAMP 的值自动转换为 UTC+8 时间
+---------------------+---------------------+
| date_time | time_stamp |
+---------------------+---------------------+
@@ -81,7 +94,7 @@ set time_zone='+8:00';
+---------------------+---------------------+
```
-**扩展:一些关于 MySQL 时区设置的一个常用 sql 命令**
+**扩展:MySQL 时区设置常用 SQL 命令**
```sql
# 查看当前会话时区
@@ -102,28 +115,26 @@ SET GLOBAL time_zone = 'Europe/Helsinki';

-在 MySQL 5.6.4 之前,DateTime 和 Timestamp 的存储空间是固定的,分别为 8 字节和 4 字节。但是从 MySQL 5.6.4 开始,它们的存储空间会根据毫秒精度的不同而变化,DateTime 的范围是 5~8 字节,Timestamp 的范围是 4~7 字节。
+在 MySQL 5.6.4 之前,DateTime 和 TIMESTAMP 的存储空间是固定的,分别为 8 字节和 4 字节。但是从 MySQL 5.6.4 开始,它们的存储空间会根据毫秒精度的不同而变化,DateTime 的范围是 5~8 字节,TIMESTAMP 的范围是 4~7 字节。
### 表示范围
-Timestamp 表示的时间范围更小,只能到 2038 年:
+`TIMESTAMP` 表示的时间范围更小,只能到 2038 年:
-- DateTime:1000-01-01 00:00:00.000000 ~ 9999-12-31 23:59:59.499999
-- Timestamp:1970-01-01 00:00:01.000000 ~ 2038-01-19 03:14:07.499999
+- `DATETIME`:'1000-01-01 00:00:00.000000' 到 '9999-12-31 23:59:59.999999'
+- `TIMESTAMP`:'1970-01-01 00:00:01.000000' UTC 到 '2038-01-19 03:14:07.999999' UTC
### 性能
-由于 TIMESTAMP 需要根据时区进行转换,所以从毫秒数转换到 TIMESTAMP 时,不仅要调用一个简单的函数,还要调用操作系统底层的系统函数。这个系统函数为了保证操作系统时区的一致性,需要进行加锁操作,这就降低了效率。
-
-DATETIME 不涉及时区转换,所以不会有这个问题。
+由于 `TIMESTAMP` 在存储和检索时需要进行 UTC 与当前会话时区的转换,这个过程可能涉及到额外的计算开销,尤其是在需要调用操作系统底层接口获取或处理时区信息时。虽然现代数据库和操作系统对此进行了优化,但在某些极端高并发或对延迟极其敏感的场景下,`DATETIME` 因其不涉及时区转换,处理逻辑相对更简单直接,可能会表现出微弱的性能优势。
-为了避免 TIMESTAMP 的时区转换问题,建议使用指定的时区,而不是依赖于操作系统时区。
+为了获得可预测的行为并可能减少 `TIMESTAMP` 的转换开销,推荐的做法是在应用程序层面统一管理时区,或者在数据库连接/会话级别显式设置 `time_zone` 参数,而不是依赖服务器的默认或操作系统时区。
## 数值时间戳是更好的选择吗?
-很多时候,我们也会使用 int 或者 bigint 类型的数值也就是数值时间戳来表示时间。
+除了上述两种类型,实践中也常用整数类型(`INT` 或 `BIGINT`)来存储所谓的“Unix 时间戳”(即从 1970 年 1 月 1 日 00:00:00 UTC 起至目标时间的总秒数,或毫秒数)。
-这种存储方式的具有 Timestamp 类型的所具有一些优点,并且使用它的进行日期排序以及对比等操作的效率会更高,跨系统也很方便,毕竟只是存放的数值。缺点也很明显,就是数据的可读性太差了,你无法直观的看到具体时间。
+这种存储方式的具有 `TIMESTAMP` 类型的所具有一些优点,并且使用它的进行日期排序以及对比等操作的效率会更高,跨系统也很方便,毕竟只是存放的数值。缺点也很明显,就是数据的可读性太差了,你无法直观的看到具体时间。
时间戳的定义如下:
@@ -132,7 +143,8 @@ DATETIME 不涉及时区转换,所以不会有这个问题。
数据库中实际操作:
```sql
-mysql> select UNIX_TIMESTAMP('2020-01-11 09:53:32');
+-- 将日期时间字符串转换为 Unix 时间戳 (秒)
+mysql> SELECT UNIX_TIMESTAMP('2020-01-11 09:53:32');
+---------------------------------------+
| UNIX_TIMESTAMP('2020-01-11 09:53:32') |
+---------------------------------------+
@@ -140,7 +152,8 @@ mysql> select UNIX_TIMESTAMP('2020-01-11 09:53:32');
+---------------------------------------+
1 row in set (0.00 sec)
-mysql> select FROM_UNIXTIME(1578707612);
+-- 将 Unix 时间戳 (秒) 转换为日期时间格式
+mysql> SELECT FROM_UNIXTIME(1578707612);
+---------------------------+
| FROM_UNIXTIME(1578707612) |
+---------------------------+
@@ -149,13 +162,26 @@ mysql> select FROM_UNIXTIME(1578707612);
1 row in set (0.01 sec)
```
+## PostgreSQL 中没有 DATETIME
+
+由于有读者提到 PostgreSQL(PG) 的时间类型,因此这里拓展补充一下。PG 官方文档对时间类型的描述地址:。
+
+
+
+可以看到,PG 没有名为 `DATETIME` 的类型:
+
+- PG 的 `TIMESTAMP WITHOUT TIME ZONE`在功能上最接近 MySQL 的 `DATETIME`。它存储日期和时间,但不包含任何时区信息,存储的是字面值。
+- PG 的`TIMESTAMP WITH TIME ZONE` (或 `TIMESTAMPTZ`) 相当于 MySQL 的 `TIMESTAMP`。它在存储时会将输入值转换为 UTC,并在检索时根据当前会话的时区进行转换显示。
+
+对于绝大多数需要记录精确发生时间点的应用场景,`TIMESTAMPTZ`是 PostgreSQL 中最推荐、最健壮的选择,因为它能最好地处理时区复杂性。
+
## 总结
-MySQL 中时间到底怎么存储才好?Datetime?Timestamp?还是数值时间戳?
+MySQL 中时间到底怎么存储才好?`DATETIME`?`TIMESTAMP`?还是数值时间戳?
并没有一个银弹,很多程序员会觉得数值型时间戳是真的好,效率又高还各种兼容,但是很多人又觉得它表现的不够直观。
-《高性能 MySQL 》这本神书的作者就是推荐 Timestamp,原因是数值表示时间不够直观。下面是原文:
+《高性能 MySQL 》这本神书的作者就是推荐 TIMESTAMP,原因是数值表示时间不够直观。下面是原文:
@@ -167,4 +193,10 @@ MySQL 中时间到底怎么存储才好?Datetime?Timestamp?还是数值时间
| TIMESTAMP | 4~7 字节 | YYYY-MM-DD hh:mm:ss[.fraction] | 1970-01-01 00:00:01[.000000] ~ 2038-01-19 03:14:07[.999999] | 是 |
| 数值型时间戳 | 4 字节 | 全数字如 1578707612 | 1970-01-01 00:00:01 之后的时间 | 否 |
+**选择建议小结:**
+
+- `TIMESTAMP` 的核心优势在于其内建的时区处理能力。数据库负责 UTC 存储和基于会话时区的自动转换,简化了需要处理多时区应用的开发。如果应用需要处理多时区,或者希望数据库能自动管理时区转换,`TIMESTAMP` 是自然的选择(注意其时间范围限制,也就是 2038 年问题)。
+- 如果应用场景不涉及时区转换,或者希望应用程序完全控制时区逻辑,并且需要表示 2038 年之后的时间,`DATETIME` 是更稳妥的选择。
+- 如果极度关注比较性能,或者需要频繁跨系统传递时间数据,并且可以接受可读性的牺牲(或总是在应用层转换),数值时间戳是一个强大的选项。
+
diff --git a/docs/database/mysql/transaction-isolation-level.md b/docs/database/mysql/transaction-isolation-level.md
index 52ad40f4a47..4ee2cabc95a 100644
--- a/docs/database/mysql/transaction-isolation-level.md
+++ b/docs/database/mysql/transaction-isolation-level.md
@@ -1,8 +1,13 @@
---
title: MySQL事务隔离级别详解
+description: 详解MySQL四种事务隔离级别(读未提交、读已提交、可重复读、串行化)的特点与区别,分析脏读、不可重复读、幻读等并发问题,以及InnoDB如何通过MVCC和锁机制解决幻读。
category: 数据库
tag:
- MySQL
+head:
+ - - meta
+ - name: keywords
+ content: MySQL事务隔离级别,读未提交,读已提交,可重复读,串行化,脏读,不可重复读,幻读,MVCC,间隙锁
---
> 本文由 [SnailClimb](https://github.com/Snailclimb) 和 [guang19](https://github.com/guang19) 共同完成。
@@ -11,43 +16,46 @@ tag:
## 事务隔离级别总结
-SQL 标准定义了四个隔离级别:
+SQL 标准定义了四种事务隔离级别,用来平衡事务的隔离性(Isolation)和并发性能。级别越高,数据一致性越好,但并发性能可能越低。这四个级别是:
-- **READ-UNCOMMITTED(读取未提交)** :最低的隔离级别,允许读取尚未提交的数据变更,可能会导致脏读、幻读或不可重复读。
-- **READ-COMMITTED(读取已提交)** :允许读取并发事务已经提交的数据,可以阻止脏读,但是幻读或不可重复读仍有可能发生。
-- **REPEATABLE-READ(可重复读)** :对同一字段的多次读取结果都是一致的,除非数据是被本身事务自己所修改,可以阻止脏读和不可重复读,但幻读仍有可能发生。
+- **READ-UNCOMMITTED(读取未提交)** :最低的隔离级别,允许读取尚未提交的数据变更,可能会导致脏读、幻读或不可重复读。这种级别在实际应用中很少使用,因为它对数据一致性的保证太弱。
+- **READ-COMMITTED(读取已提交)** :允许读取并发事务已经提交的数据,可以阻止脏读,但是幻读或不可重复读仍有可能发生。这是大多数数据库(如 Oracle, SQL Server)的默认隔离级别。
+- **REPEATABLE-READ(可重复读)** :对同一字段的多次读取结果都是一致的,除非数据是被本身事务自己所修改,可以阻止脏读和不可重复读,但幻读仍有可能发生。MySQL InnoDB 存储引擎的默认隔离级别正是 REPEATABLE READ。并且,InnoDB 在此级别下通过 MVCC(多版本并发控制) 和 Next-Key Locks(间隙锁+行锁) 机制,在很大程度上解决了幻读问题。
- **SERIALIZABLE(可串行化)** :最高的隔离级别,完全服从 ACID 的隔离级别。所有的事务依次逐个执行,这样事务之间就完全不可能产生干扰,也就是说,该级别可以防止脏读、不可重复读以及幻读。
----
+| 隔离级别 | 脏读 (Dirty Read) | 不可重复读 (Non-Repeatable Read) | 幻读 (Phantom Read) |
+| ---------------- | ----------------- | -------------------------------- | ---------------------- |
+| READ UNCOMMITTED | √ | √ | √ |
+| READ COMMITTED | × | √ | √ |
+| REPEATABLE READ | × | × | √ (标准) / ≈× (InnoDB) |
+| SERIALIZABLE | × | × | × |
-| 隔离级别 | 脏读 | 不可重复读 | 幻读 |
-| :--------------: | :--: | :--------: | :--: |
-| READ-UNCOMMITTED | √ | √ | √ |
-| READ-COMMITTED | × | √ | √ |
-| REPEATABLE-READ | × | × | √ |
-| SERIALIZABLE | × | × | × |
+**默认级别查询:**
-MySQL InnoDB 存储引擎的默认支持的隔离级别是 **REPEATABLE-READ(可重读)**。我们可以通过`SELECT @@tx_isolation;`命令来查看,MySQL 8.0 该命令改为`SELECT @@transaction_isolation;`
+MySQL InnoDB 存储引擎的默认隔离级别是 **REPEATABLE READ**。可以通过以下命令查看:
-```sql
-MySQL> SELECT @@tx_isolation;
-+-----------------+
-| @@tx_isolation |
-+-----------------+
-| REPEATABLE-READ |
-+-----------------+
+- MySQL 8.0 之前:`SELECT @@tx_isolation;`
+- MySQL 8.0 及之后:`SELECT @@transaction_isolation;`
+
+```bash
+mysql> SELECT @@transaction_isolation;
++-------------------------+
+| @@transaction_isolation |
++-------------------------+
+| REPEATABLE-READ |
++-------------------------+
```
-从上面对 SQL 标准定义了四个隔离级别的介绍可以看出,标准的 SQL 隔离级别定义里,REPEATABLE-READ(可重复读)是不可以防止幻读的。
+**InnoDB 的 REPEATABLE READ 对幻读的处理:**
-但是!InnoDB 实现的 REPEATABLE-READ 隔离级别其实是可以解决幻读问题发生的,主要有下面两种情况:
+标准的 SQL 隔离级别定义里,REPEATABLE READ 是无法防止幻读的。但 InnoDB 的实现通过以下机制很大程度上避免了幻读:
-- **快照读**:由 MVCC 机制来保证不出现幻读。
-- **当前读**:使用 Next-Key Lock 进行加锁来保证不出现幻读,Next-Key Lock 是行锁(Record Lock)和间隙锁(Gap Lock)的结合,行锁只能锁住已经存在的行,为了避免插入新行,需要依赖间隙锁。
+- **快照读 (Snapshot Read)**:普通的 SELECT 语句,通过 **MVCC** 机制实现。事务启动时创建一个数据快照,后续的快照读都读取这个版本的数据,从而避免了看到其他事务新插入的行(幻读)或修改的行(不可重复读)。
+- **当前读 (Current Read)**:像 `SELECT ... FOR UPDATE`, `SELECT ... LOCK IN SHARE MODE`, `INSERT`, `UPDATE`, `DELETE` 这些操作。InnoDB 使用 **Next-Key Lock** 来锁定扫描到的索引记录及其间的范围(间隙),防止其他事务在这个范围内插入新的记录,从而避免幻读。Next-Key Lock 是行锁(Record Lock)和间隙锁(Gap Lock)的组合。
-因为隔离级别越低,事务请求的锁越少,所以大部分数据库系统的隔离级别都是 **READ-COMMITTED** ,但是你要知道的是 InnoDB 存储引擎默认使用 **REPEATABLE-READ** 并不会有任何性能损失。
+值得注意的是,虽然通常认为隔离级别越高、并发性越差,但 InnoDB 存储引擎通过 MVCC 机制优化了 REPEATABLE READ 级别。对于许多常见的只读或读多写少的场景,其性能**与 READ COMMITTED 相比可能没有显著差异**。不过,在写密集型且并发冲突较高的场景下,RR 的间隙锁机制可能会比 RC 带来更多的锁等待。
-InnoDB 存储引擎在分布式事务的情况下一般会用到 SERIALIZABLE 隔离级别。
+此外,在某些特定场景下,如需要严格一致性的分布式事务(XA Transactions),InnoDB 可能要求或推荐使用 SERIALIZABLE 隔离级别来确保全局数据的一致性。
《MySQL 技术内幕:InnoDB 存储引擎(第 2 版)》7.7 章这样写到:
diff --git a/docs/database/nosql.md b/docs/database/nosql.md
index d5ca59698bd..3a7e7929057 100644
--- a/docs/database/nosql.md
+++ b/docs/database/nosql.md
@@ -1,10 +1,15 @@
---
title: NoSQL基础知识总结
+description: NoSQL数据库基础知识总结,包括NoSQL与SQL的区别、NoSQL的优势、四种NoSQL数据库类型(键值、文档、图形、宽列)及其代表产品Redis、MongoDB、Neo4j等的应用场景。
category: 数据库
tag:
- NoSQL
- MongoDB
- Redis
+head:
+ - - meta
+ - name: keywords
+ content: NoSQL,Redis,MongoDB,HBase,Cassandra,键值数据库,文档数据库,图数据库,宽列存储,SQL与NoSQL区别
---
## NoSQL 是什么?
diff --git a/docs/database/redis/3-commonly-used-cache-read-and-write-strategies.md b/docs/database/redis/3-commonly-used-cache-read-and-write-strategies.md
index 7ad88958704..be12e83c288 100644
--- a/docs/database/redis/3-commonly-used-cache-read-and-write-strategies.md
+++ b/docs/database/redis/3-commonly-used-cache-read-and-write-strategies.md
@@ -1,8 +1,13 @@
---
title: 3种常用的缓存读写策略详解
+description: 深入对比 Cache Aside、Read/Write Through、Write Behind 三种缓存读写策略,附详细时序图、一致性问题分析及生产级解决方案,Redis 实战必备!
category: 数据库
tag:
- Redis
+head:
+ - - meta
+ - name: keywords
+ content: 缓存读写策略,Cache Aside,Read Through,Write Through,Write Behind,Write Back,缓存一致性,缓存失效,旁路缓存,读写穿透,异步缓存写入,Redis缓存策略,缓存更新策略
---
看到很多小伙伴简历上写了“**熟练使用缓存**”,但是被我问到“**缓存常用的 3 种读写策略**”的时候却一脸懵逼。
@@ -15,26 +20,28 @@ tag:
### Cache Aside Pattern(旁路缓存模式)
-**Cache Aside Pattern 是我们平时使用比较多的一个缓存读写模式,比较适合读请求比较多的场景。**
+这是我们日常开发中**最常用、最经典**的一种模式,几乎是互联网应用缓存方案的事实标准,尤其适合**读多写少**的业务场景。
-Cache Aside Pattern 中服务端需要同时维系 db 和 cache,并且是以 db 的结果为准。
+这个模式之所以被称为**“旁路”(Aside)**,是因为应用程序的**写操作完全绕过了缓存,直接操作数据库**。
+
+应用程序扮演了数据流转的“指挥官”,需要同时维护 Cache 和 DB 两个数据源。
下面我们来看一下这个策略模式下的缓存读写步骤。
-**写**:
+**写操作 :**
-- 先更新 db
-- 然后直接删除 cache 。
+1. 应用**先更新 DB**。
+2. 然后**直接删除 Cache**中对应的数据。
简单画了一张图帮助大家理解写的步骤。

-**读** :
+**读操作:**
-- 从 cache 中读取数据,读取到就直接返回
-- cache 中读取不到的话,就从 db 中读取数据返回
-- 再把数据放到 cache 中。
+1. 应用先从 Cache 读取数据。
+2. 如果命中(Hit),则直接返回。
+3. 如果未命中(Miss),则从 DB 读取数据,成功读取后,**将数据写回 Cache**,然后返回。
简单画了一张图帮助大家理解读的步骤。
@@ -42,49 +49,69 @@ Cache Aside Pattern 中服务端需要同时维系 db 和 cache,并且是以 d
你仅仅了解了上面这些内容的话是远远不够的,我们还要搞懂其中的原理。
-比如说面试官很可能会追问:“**在写数据的过程中,可以先删除 cache ,后更新 db 么?**”
+比如说面试官很可能会追问:
+
+1. 为什么写操作是“先更新 DB,后删除 Cache”?顺序能反过来吗?
+2. 那“先更新 DB,后删除 Cache”就绝对安全吗?
+3. 为什么是“删除 Cache”,而不是“更新 Cache”?
-**答案:** 那肯定是不行的!因为这样可能会造成 **数据库(db)和缓存(Cache)数据不一致**的问题。
+接下来我会以此分析解答这些问题。
-举例:请求 1 先写数据 A,请求 2 随后读数据 A 的话,就很有可能产生数据不一致性的问题。
+**1. 为什么写操作是“先更新 DB,后删除 Cache”?顺序能反过来吗?**
-这个过程可以简单描述为:
+**答:** 绝对不能。如果“先删 Cache,后更新 DB”,在高并发下会引入经典的数据不一致问题。
-> 请求 1 先把 cache 中的 A 数据删除 -> 请求 2 从 db 中读取数据->请求 1 再把 db 中的 A 数据更新
+- **时序分析 (请求 A 写, 请求 B 读):**
+ 1. 请求 A: 先将 Cache 中的数据删除。
+ 2. 请求 B: 此时发现 Cache 为空,于是去 DB 读取**旧值**,并准备写入 Cache。
+ 3. 请求 A : 将**新值**写入 DB。
+ 4. 请求 B: 将之前读到的**旧值**写入了 Cache。
+- **结果:** DB 中是新值,而 Cache 中是旧值,数据不一致。
-当你这样回答之后,面试官可能会紧接着就追问:“**在写数据的过程中,先更新 db,后删除 cache 就没有问题了么?**”
+**2. 那“先更新 DB,后删除 Cache”就绝对安全吗?**
-**答案:** 理论上来说还是可能会出现数据不一致性的问题,不过概率非常小,因为缓存的写入速度是比数据库的写入速度快很多。
+**答案:** 也不是绝对安全的!因为这样也可能会造成 **数据库和缓存数据不一致**的问题。
-举例:请求 1 先读数据 A,请求 2 随后写数据 A,并且数据 A 在请求 1 请求之前不在缓存中的话,也有可能产生数据不一致性的问题。
+- **时序分析 (请求 A 读, 请求 B 写):**
+ 1. 请求 A : 缓存未命中,从 DB 读取到**旧值**。
+ 2. 请求 B: 迅速完成了 DB 的更新,并将 Cache 删除。
+ 3. 请求 A : 将自己之前拿到的**旧值**写入了 Cache。
+- **结果:** DB 中是新值,Cache 中又是旧值。
+- **为什么概率极小?** 这个问题本质上是一个并发时序问题:只要“读 DB → 写 Cache”这段时间窗口内,恰好有写请求完成了 DB 更新,就有可能产生不一致。在大多数业务里,这个窗口时间相对较短,而且还需要与写请求并发“撞车”,所以发生概率不算高,但绝不是不可能。
-这个过程可以简单描述为:
+**3. 为什么是“删除 Cache”,而不是“更新 Cache”?**
-> 请求 1 从 db 读数据 A-> 请求 2 更新 db 中的数据 A(此时缓存中无数据 A ,故不用执行删除缓存操作 ) -> 请求 1 将数据 A 写入 cache
+- **性能开销:** 写操作往往只更新了对象的部分字段,如果为了“更新 Cache”而去重新查询或计算整个缓存对象,开销可能很大。相比之下,“删除”是一个轻量级操作。
+- **懒加载思想:** “删除”操作遵循懒加载原则。只有当数据下一次被真正需要(被读取)时,才触发从 DB 加载并写入缓存,避免了无效的缓存更新。
+- **并发安全:** “更新缓存”在高并发下可能出现更新顺序错乱的问题导致脏数据的概率会更大。
+
+当然,这一切都建立在一个重要的前提之上:我们缓存的数据,是可以通过数据库进行确定性重建的,并且业务上可以容忍从‘缓存删除’到‘下一次读取并回填’之间这个极短时间窗口内的数据不一致。
现在我们再来分析一下 **Cache Aside Pattern 的缺陷**。
-**缺陷 1:首次请求数据一定不在 cache 的问题**
+**缺陷 1:首次请求数据一定不在 Cache 的问题**
-解决办法:可以将热点数据可以提前放入 cache 中。
+解决办法:对于访问量巨大的热点数据,可以在系统启动或低峰期进行缓存预热。
-**缺陷 2:写操作比较频繁的话导致 cache 中的数据会被频繁被删除,这样会影响缓存命中率 。**
+**缺陷 2:写操作比较频繁的话导致 Cache 中的数据会被频繁被删除,这样会影响缓存命中率 。**
解决办法:
-- 数据库和缓存数据强一致场景:更新 db 的时候同样更新 cache,不过我们需要加一个锁/分布式锁来保证更新 cache 的时候不存在线程安全问题。
-- 可以短暂地允许数据库和缓存数据不一致的场景:更新 db 的时候同样更新 cache,但是给缓存加一个比较短的过期时间,这样的话就可以保证即使数据不一致的话影响也比较小。
+- 数据库和缓存数据强一致场景:更新 DB 的时候同样更新 Cache,不过我们需要加一个锁/分布式锁来保证更新 Cache 的时候不存在线程安全问题。
+- 可以短暂地允许数据库和缓存数据不一致的场景:更新 DB 的时候同样更新 Cache,但是给缓存加一个比较短的过期时间(如 1 分钟),这样的话就可以保证即使数据不一致的话影响也比较小。
### Read/Write Through Pattern(读写穿透)
-Read/Write Through Pattern 中服务端把 cache 视为主要数据存储,从中读取数据并将数据写入其中。cache 服务负责将此数据读取和写入 db,从而减轻了应用程序的职责。
+在这种模式下,应用程序将**Cache 视为唯一的、主要的存储**。所有的读写请求都直接打向 Cache,而 Cache 服务自身负责与 DB 进行数据同步。
+
+对应用程序**透明**,应用开发者无需关心 DB 的存在。
-这种缓存读写策略小伙伴们应该也发现了在平时在开发过程中非常少见。抛去性能方面的影响,大概率是因为我们经常使用的分布式缓存 Redis 并没有提供 cache 将数据写入 db 的功能。
+这种缓存读写策略小伙伴们应该也发现了在平时在开发过程中非常少见。抛去性能方面的影响,大概率是因为我们经常使用的分布式缓存 Redis 本身并没有提供 Cache 将数据写入 DB 的功能,需要我们在业务侧或中间件里自己实现。
**写(Write Through):**
-- 先查 cache,cache 中不存在,直接更新 db。
-- cache 中存在,则先更新 cache,然后 cache 服务自己更新 db(**同步更新 cache 和 db**)。
+- 先查 Cache,Cache 中不存在,直接更新 DB。
+- Cache 中存在,则先更新 Cache,然后 Cache 服务自己更新 DB。只有当 Cache 和 DB 都写入成功后,才向上层返回成功。
简单画了一张图帮助大家理解写的步骤。
@@ -92,27 +119,38 @@ Read/Write Through Pattern 中服务端把 cache 视为主要数据存储,从
**读(Read Through):**
-- 从 cache 中读取数据,读取到就直接返回 。
-- 读取不到的话,先从 db 加载,写入到 cache 后返回响应。
+- 应用从 Cache 读取数据。
+- 如果命中,直接返回。
+- 如果未命中,由**Cache 服务自己**负责从 DB 加载数据,加载成功后先写入自身,再返回给应用。
简单画了一张图帮助大家理解读的步骤。

-Read-Through Pattern 实际只是在 Cache-Aside Pattern 之上进行了封装。在 Cache-Aside Pattern 下,发生读请求的时候,如果 cache 中不存在对应的数据,是由客户端自己负责把数据写入 cache,而 Read Through Pattern 则是 cache 服务自己来写入缓存的,这对客户端是透明的。
+Read-Through 实际只是在 Cache-Aside 之上进行了封装。在 Cache-Aside 下,发生读请求的时候,如果 Cache 中不存在对应的数据,是由客户端自己负责把数据写入 Cache,而 Read Through 则是 Cache 服务自己来写入缓存的,这对客户端是透明的。
-和 Cache Aside Pattern 一样, Read-Through Pattern 也有首次请求数据一定不再 cache 的问题,对于热点数据可以提前放入缓存中。
+从实现角度看,Read-Through 本质上是把 Cache-Aside 中“读 Miss → 读 DB → 回填 Cache”的逻辑,下沉到了缓存服务内部,对客户端透明。
+
+和 Cache Aside 一样, Read-Through 也有首次请求数据一定不再 Cache 的问题,对于热点数据可以提前放入缓存中。
### Write Behind Pattern(异步缓存写入)
-Write Behind Pattern 和 Read/Write Through Pattern 很相似,两者都是由 cache 服务来负责 cache 和 db 的读写。
+Write Behind(也常被称为 Write-Back) Pattern 和 Read/Write Through Pattern 很相似,两者都是由 Cache 服务来负责 Cache 和 DB 的读写。
+
+但是,两个又有很大的不同:**Read/Write Through 是同步更新 Cache 和 DB,而 Write Behind 则是只更新缓存,不直接更新 DB,而是改为异步批量的方式来更新 DB。**
+
+**写操作 (Write Behind):**
-但是,两个又有很大的不同:**Read/Write Through 是同步更新 cache 和 db,而 Write Behind 则是只更新缓存,不直接更新 db,而是改为异步批量的方式来更新 db。**
+1. 应用将数据写入 Cache,然后**立即返回**。
+2. Cache 服务将这个写操作放入一个队列中。
+3. 通过一个独立的异步线程/任务,将队列中的写操作**批量地、合并地**写入 DB。
-很明显,这种方式对数据一致性带来了更大的挑战,比如 cache 数据可能还没异步更新 db 的话,cache 服务可能就就挂掉了。
+这种模式对数据一致性带来了挑战(例如:Cache 中的数据还没来得及写回 DB,系统就宕机了),因此不适用于需要强一致性的场景(如交易、库存)。
-这种策略在我们平时开发过程中也非常非常少见,但是不代表它的应用场景少,比如消息队列中消息的异步写入磁盘、MySQL 的 Innodb Buffer Pool 机制都用到了这种策略。
+但是,它的异步和批量特性,带来了**无与伦比的写性能**。它在很多高性能系统中都有广泛应用:
-Write Behind Pattern 下 db 的写性能非常高,非常适合一些数据经常变化又对数据一致性要求没那么高的场景,比如浏览量、点赞量。
+- **MySQL 的 InnoDB Buffer Pool 机制:** 数据修改先在内存 Buffer Pool 中完成,然后由后台线程异步刷写到磁盘。
+- **操作系统的页缓存(Page Cache):** 文件写入也是先写到内存,再由操作系统异步刷盘。
+- **高频计数场景:** 对于文章浏览量、帖子点赞数这类允许短暂数据不一致、但写入极其频繁的场景,可以先在 Redis 中快速累加,再通过定时任务异步同步回数据库。
diff --git a/docs/database/redis/cache-basics.md b/docs/database/redis/cache-basics.md
index 391e5bec82d..15cb0eb33bb 100644
--- a/docs/database/redis/cache-basics.md
+++ b/docs/database/redis/cache-basics.md
@@ -1,14 +1,195 @@
---
-title: 缓存基础常见面试题总结(付费)
+title: 缓存基础常见面试题总结
+description: 深入讲解缓存的核心思想、本地缓存与分布式缓存的区别、多级缓存架构设计。涵盖Caffeine、Redis等主流缓存方案,以及缓存一致性的解决方案。适合Java开发者学习缓存架构设计。
category: 数据库
tag:
- Redis
+head:
+ - - meta
+ - name: keywords
+ content: 缓存,本地缓存,分布式缓存,多级缓存,Caffeine,Redis,缓存一致性,系统设计,Java缓存,Guava Cache
---
-**缓存基础** 相关的面试题为我的 [知识星球](../../about-the-author/zhishixingqiu-two-years.md)(点击链接即可查看详细介绍以及加入方法)专属内容,已经整理到了[《Java 面试指北》](../../zhuanlan/java-mian-shi-zhi-bei.md)中。
+> **相关面试题** :
+>
+> - 为什么要用缓存?
+> - 本地缓存应该怎么做?
+> - 为什么要有分布式缓存?/为什么不直接用本地缓存?
+> - 为什么要用多级缓存?
+> - 多级缓存适合哪些业务场景?
-
+## 缓存的基本思想
-
+很多同学只知道缓存可以提高系统性能以及减少请求 **响应时间**(Response Time),但是,不太清楚缓存的本质思想是什么。
-
+缓存的基本思想其实很简单,就是我们非常熟悉的 **空间换时间** 这一经典性能优化策略的运用。所谓空间换时间,也就是用更多的存储空间来存储一些可能重复使用或计算的数据,从而减少数据的重新获取或计算的时间。
+
+说到空间换时间,除了缓存之外,你还能想到什么其他的例子吗?这里再列举几个常见的:
+
+- **索引**:索引是一种将数据库表中的某些列或字段按照一定的排序规则组织成一个单独的数据结构,虽然需要额外占用空间,但可以大大提高检索效率,降低数据排序成本。
+- **数据库表字段冗余**:将经常联合查询的数据冗余存储在同一张表中,以减少对多张表的关联查询,进而提升查询性能,减轻数据库压力。
+- **CDN(内容分发网络)**:将静态资源分发到多个边缘节点以实现就近访问,进而加快静态资源的访问速度,减轻源站服务器以及带宽的负担。
+
+编程需要要学会归纳总结,将自己学到的东西串联起来!假如你在面试的时候,能聊到这些,面试官一定会对你有一个好印象的。
+
+不要把缓存想的太高大上,虽然,它的确对系统的性能提升的性价比非常高。当我们在学习并应用缓存的时候,你会发现缓存的思想实际在 CPU、操作系统或者其他很多地方都被大量用到。
+
+比如,**CPU Cache** 缓存的是内存数据,用于解决 **CPU** 处理速度与内存访问速度不匹配的问题;内存缓存的是硬盘数据,用于解决硬盘 **I/O** 速度过慢的问题。
+
+
+
+再比如,为了提高虚拟地址到物理地址的转换速度,操作系统在页表方案基础之上引入了 **转址旁路缓存**(Translation Lookaside Buffer,**TLB**,也被称为快表)。
+
+
+
+拿日常使用的浏览器来说,它会对访问过的图片或静态文件进行缓存(浏览器缓存),这样下次访问相同页面时加载速度会显著提升。
+
+
+
+我们日常开发中用到的缓存,其中的数据通常存储于 **RAM**(内存)中,访问速度极快。为了避免内存数据在重启或宕机后丢失,许多缓存中间件(如 **Redis**)提供了磁盘持久化机制。相比于关系型数据库(如 **MySQL**),缓存的访问速度和并发支持量都要高出几个数量级。在数据库之上增加一层缓存,是保护底层存储、提升系统吞吐量的核心手段。
+
+## 缓存的分类
+
+接下来,我们来看看日常开发中用到的缓存通常被分为哪几种。
+
+### 本地缓存
+
+#### 什么是本地缓存?
+
+这个实际在很多项目中用的蛮多,特别是单体架构的时候。数据量不大,并且没有分布式要求的话,使用本地缓存还是可以的。
+
+本地缓存位于应用内部,其最大的优点是应用存在于同一个进程内部,请求本地缓存的速度非常快,不存在额外的网络开销。
+
+常见的单体架构图如下,我们使用 **Nginx** 来做**负载均衡**,部署两个相同的应用到服务器,两个服务使用同一个数据库,并且使用的是本地缓存。
+
+
+
+**注意:** 在集群模式下使用本地缓存,必须考虑**负载均衡策略**。如果 Nginx 使用默认的**轮询(Round-Robin)**,同一个用户的请求会随机落在不同机器,导致本地缓存命中率极低。解决方案如下:
+
+1. **网关层**:使用一致性哈希或 Sticky Session,保证同一用户的请求固定打到同一台机器。
+2. **应用层**:仅将本地缓存用于**“全局几乎不变”**的数据(如配置字典),而非用户维度数据。
+
+#### 本地缓存的方案有哪些?
+
+**1、JDK 自带的 `HashMap` 和 `ConcurrentHashMap` 了。**
+
+`ConcurrentHashMap` 可以看作是线程安全版本的 `HashMap` ,两者都是存放 key/value 形式的键值对。但是,大部分场景来说不会使用这两者当做缓存,因为只提供了缓存的功能,并没有提供其他诸如过期时间之类的功能。一个稍微完善一点的缓存框架至少要提供:**过期时间**、**淘汰机制**、**命中率统计**这三点。
+
+**2、 `Ehcache` 、 `Guava Cache` 、 `Spring Cache` 这三者是使用的比较多的本地缓存框架。**
+
+- `Ehcache` 的话相比于其他两者更加重量。不过,相比于 `Guava Cache` 、 `Spring Cache` 来说, `Ehcache` 支持可以嵌入到 hibernate 和 mybatis 作为多级缓存,并且可以将缓存的数据持久化到本地磁盘中、同时也提供了集群方案(比较鸡肋,可忽略)。
+- `Guava Cache` 和 `Spring Cache` 两者的话比较像。`Guava` 相比于 `Spring Cache` 的话使用的更多一点,它提供了 API 非常方便我们使用,同时也提供了设置缓存有效时间等功能。它的内部实现也比较干净,很多地方都和 `ConcurrentHashMap` 的思想有异曲同工之妙。
+- 使用 `Spring Cache` 的注解实现缓存的话,代码会看着很干净和优雅,但是很容易出现问题比如缓存穿透、内存溢出。
+
+**3、后起之秀 Caffeine。**
+
+相比于 `Guava` 来说 `Caffeine` 在各个方面比如性能都要更加优秀,一般建议使用其来替代 `Guava` 。并且, `Guava` 和 `Caffeine` 的使用方式很像!
+
+使用 `Caffeine` 创建本地缓存的代码示例,用到了建造者模式:
+
+```java
+// 使用 Caffeine 创建本地缓存示例
+Cache cache = Caffeine.newBuilder()
+ // 设置写入后 60 天过期
+ .expireAfterWrite(60, TimeUnit.DAYS)
+ // 初始容量
+ .initialCapacity(100)
+ // 最大条数限制
+ .maximumSize(500)
+ // 开启统计功能
+ .recordStats()
+ .build();
+```
+
+#### 本地缓存有什么痛点?
+
+本地的缓存的优势非常明显:**低依赖**、**轻量**、**简单**、**成本低**。
+
+但是,本地缓存存在下面这些缺陷:
+
+- **本地缓存应用耦合,对分布式架构支持不友好**,比如同一个相同的服务部署在多台机器上的时候,各个服务之间的缓存是无法共享的,因为本地缓存只在当前机器上有。
+- **本地缓存容量受服务部署所在的机器限制明显。** 如果当前系统服务所耗费的内存多,那么本地缓存可用的容量就很少。
+
+### 分布式缓存
+
+#### 什么是分布式缓存?
+
+我们可以把分布式缓存(Distributed Cache) 看作是一种内存数据库的服务,它的最终作用就是提供缓存数据的服务。
+
+分布式缓存脱离于应用独立存在,多个应用可直接的共同使用同一个分布式缓存服务。
+
+如下图所示,就是一个简单的使用分布式缓存的架构图。我们使用 Nginx 来做负载均衡,部署两个相同的应用到服务器,两个服务使用同一个数据库和缓存。
+
+
+
+使用分布式缓存之后,缓存服务可以部署在一台单独的服务器上,即使同一个相同的服务部署在多台机器上,也是使用的同一份缓存。 并且,单独的分布式缓存服务的性能、容量和提供的功能都要更加强大。
+
+**软件系统设计中没有银弹,往往任何技术的引入都像是把双刃剑。** 你使用的方式得当,就能为系统带来很大的收益。否则,只是费了精力不讨好。
+
+简单来说,为系统引入分布式缓存之后往往会带来下面这些问题:
+
+- **系统复杂性增加** :引入缓存之后,你要维护缓存和数据库的数据一致性、维护热点缓存、保证缓存服务的高可用等等。
+- **系统开发成本往往会增加** :引入缓存意味着系统需要一个单独的缓存服务,这是需要花费相应的成本的,并且这个成本还是很贵的,毕竟耗费的是宝贵的内存。
+
+#### 分布式缓存的方案有哪些?
+
+分布式缓存的话,比较老牌同时也是使用的比较多的还是 **Memcached** 和 **Redis**。不过,现在基本没有看过还有项目使用 **Memcached** 来做缓存,都是直接用 **Redis**。
+
+Memcached 是分布式缓存最开始兴起的那会,比较常用的。后来,随着 Redis 的发展,大家慢慢都转而使用更加强大的 Redis 了。
+
+有一些大厂也开源了类似于 Redis 的分布式高性能 KV 存储数据库,例如,腾讯开源的 [Tendis](https://github.com/Tencent/Tendis) 。Tendis 基于知名开源项目 [RocksDB](https://github.com/facebook/rocksdb) 作为存储引擎 ,100% 兼容 Redis 协议和 Redis4.0 所有数据模型。关于 Redis 和 Tendis 的对比,腾讯官方曾经发过一篇文章:[Redis vs Tendis:冷热混合存储版架构揭秘](https://mp.weixin.qq.com/s/MeYkfOIdnU6LYlsGb24KjQ) ,可以简单参考一下。
+
+不过,从 Tendis 这个项目的 Github 提交记录可以看出,Tendis 开源版几乎已经没有被维护更新了,加上其关注度并不高,使用的公司也比较少。因此,不建议你使用 Tendis 来实现分布式缓存。
+
+目前,比较业界认可的 Redis 替代品还是下面这两个开源分布式缓存(都是通过碰瓷 Redis 火的):
+
+- [Dragonfly](https://github.com/dragonflydb/dragonfly):一种针对现代应用程序负荷需求而构建的内存数据库,完全兼容 Redis 和 Memcached 的 API,迁移时无需修改任何代码,号称全世界最快的内存数据库。
+- [KeyDB](https://github.com/Snapchat/KeyDB): Redis 的一个高性能分支,专注于多线程、内存效率和高吞吐量。
+
+不过,个人还是建议分布式缓存首选 Redis ,毕竟经过这么多年的生产考验,生态也这么优秀,资料也很全面。
+
+### 多级缓存
+
+#### 什么是多级缓存?为什么要用?
+
+我们这里只来简单聊聊 **本地缓存 + 分布式缓存** 的多级缓存方案,这也是最常用的多级缓存实现方式。
+
+这个时候估计有很多小伙伴就会问了:**既然用了分布式缓存,为什么还要用本地缓存呢?** 。
+
+本地缓存和分布式缓存虽然都属于缓存,但本地缓存的访问速度要远大于分布式缓存,这是因为访问本地缓存不存在额外的网络开销,我们在上面也提到了。
+
+不过,一般情况下,我们也是不建议使用多级缓存的,这会增加维护负担(比如你需要保证一级缓存和二级缓存的数据一致性)。而且,其实际带来的提升效果对于绝大部分业务场景来说其实并不是很大。
+
+这里简单总结一下适合多级缓存的两种业务场景:
+
+- 缓存的数据不会频繁修改,比较稳定;
+- 数据访问量特别大比如秒杀场景。
+
+多级缓存方案中,第一级缓存(L1)使用本地内存(比如 Caffeine)),第二级缓存(L2)使用分布式缓存(比如 Redis)。
+
+
+
+读取缓存数据的时候,我们先从 L1 中读取,读取不到的时候再去 L2 读取。这样可以降低 L2 的压力,减少 L2 的读次数。如果 L2 也没有此数据的话,再去数据库查询,数据查询成功后再将数据写入到 L1 和 L2 中。
+
+多级缓存开源实现推荐:
+
+- [J2Cache](https://gitee.com/ld/J2Cache):基于本地内存和 Redis 的两级 Java 缓存框架。
+- [JetCache](https://github.com/alibaba/jetcache):阿里开源的缓存框架,支持多级缓存、分布式缓存自动刷新、 TTL 等功能。
+
+#### 多级缓存一致性如何保证?
+
+在多级缓存系统中,保证强一致性成本太高,业界的几个提供多级缓存功能的缓存框架基本都是最终一致性保证。例如,可以使用 Redis 的发布/订阅机制、Redis Stream 或者消息队列来确保当一个实例的本地缓存发生变化时,其他实例能够及时更新其本地缓存,以保持缓存一致性。
+
+政采云技术的方案是 Canal + 广播消息,这里简单介绍一下:
+
+1. DB 修改数据:首先在数据库中进行数据修改。
+2. 通过监听 Canal 消息,触发缓存的更新:使用 Canal 监听数据库的变更操作,当检测到数据变化时,触发缓存更新。
+3. 同步 Redis 缓存:对于 Redis 缓存,因为集群中只共享一份数据,所以直接同步缓存即可。
+4. 同步本地缓存:由于本地缓存分布在不同的 JVM 实例中,需要借助广播消息队列(MQ)机制,将更新通知广播到各个业务实例,从而同步本地缓存。
+
+详细介绍:[分布式多级缓存系统设计与实战](https://juejin.cn/post/7225634879152570405)
+
+## 参考
+
+- 缓存那些事:https://tech.meituan.com/2017/03/17/cache-about.html
+- 解析分布式系统的缓存设计:https://segmentfault.com/a/1190000041689802
diff --git a/docs/database/redis/images/why-redis-so-fast.png b/docs/database/redis/images/why-redis-so-fast.png
deleted file mode 100644
index 279e1955473..00000000000
Binary files a/docs/database/redis/images/why-redis-so-fast.png and /dev/null differ
diff --git a/docs/database/redis/redis-cluster.md b/docs/database/redis/redis-cluster.md
index 60cd7dda858..4d8b2ead452 100644
--- a/docs/database/redis/redis-cluster.md
+++ b/docs/database/redis/redis-cluster.md
@@ -1,14 +1,17 @@
---
title: Redis集群详解(付费)
+description: Redis集群相关面试题详解,包括Redis Sentinel哨兵模式、Redis Cluster分片集群的原理、配置和使用,以及主从复制、故障转移等高可用方案。
category: 数据库
tag:
- Redis
+head:
+ - - meta
+ - name: keywords
+ content: Redis集群,Redis Cluster,Redis Sentinel,主从复制,哨兵模式,分片集群,高可用
---
**Redis 集群** 相关的面试题为我的 [知识星球](../../about-the-author/zhishixingqiu-two-years.md)(点击链接即可查看详细介绍以及加入方法)专属内容,已经整理到了[《Java 面试指北》](../../zhuanlan/java-mian-shi-zhi-bei.md)中。
-
+
-
-
diff --git a/docs/database/redis/redis-common-blocking-problems-summary.md b/docs/database/redis/redis-common-blocking-problems-summary.md
index 9aec17fc0cc..95041edee60 100644
--- a/docs/database/redis/redis-common-blocking-problems-summary.md
+++ b/docs/database/redis/redis-common-blocking-problems-summary.md
@@ -1,10 +1,17 @@
---
title: Redis常见阻塞原因总结
+description: 全面总结Redis常见的阻塞原因,包括O(n)复杂度命令、bigkey操作、AOF日志刷盘、RDB快照创建、主从同步等场景,帮助你排查和预防Redis性能问题。
category: 数据库
tag:
- Redis
+head:
+ - - meta
+ - name: keywords
+ content: Redis阻塞,Redis性能问题,O(n)命令,bigkey,AOF刷盘,RDB快照,主从同步,内存达上限
---
+
+
> 本文整理完善自: ,作者:阿 Q 说代码
这篇文章会详细总结一下可能导致 Redis 阻塞的情况,这些情况也是影响 Redis 性能的关键因素,使用 Redis 的时候应该格外注意!
diff --git a/docs/database/redis/redis-data-structures-01.md b/docs/database/redis/redis-data-structures-01.md
index 999e88b4014..64468c03f02 100644
--- a/docs/database/redis/redis-data-structures-01.md
+++ b/docs/database/redis/redis-data-structures-01.md
@@ -1,15 +1,13 @@
---
title: Redis 5 种基本数据类型详解
+description: 详解Redis五种基本数据类型String、List、Set、Hash、Zset的使用方法和应用场景,深入分析SDS、跳表、压缩列表等底层数据结构实现原理。
category: 数据库
tag:
- Redis
head:
- - meta
- name: keywords
- content: Redis常见数据类型
- - - meta
- - name: description
- content: Redis基础数据类型总结:String(字符串)、List(列表)、Set(集合)、Hash(散列)、Zset(有序集合)
+ content: Redis数据类型,String,List,Set,Hash,Zset,SDS,跳表,压缩列表,Redis命令
---
Redis 共有 5 种基本数据类型:String(字符串)、List(列表)、Set(集合)、Hash(散列)、Zset(有序集合)。
@@ -182,7 +180,7 @@ Redis 中的 List 其实就是链表数据结构的实现。我在 [线性数据
"value3"
```
-我专门画了一个图方便大家理解 `RPUSH` , `LPOP` , `lpush` , `RPOP` 命令:
+我专门画了一个图方便大家理解 `RPUSH` , `LPOP` , `LPUSH` , `RPOP` 命令:

@@ -474,7 +472,7 @@ value1

-[《Java 面试指北》](https://www.yuque.com/docs/share/f37fc804-bfe6-4b0d-b373-9c462188fec7) 的「技术面试题篇」就有一篇文章详细介绍如何使用 Sorted Set 来设计制作一个排行榜。
+[《Java 面试指北》](https://javaguide.cn/zhuanlan/java-mian-shi-zhi-bei.html) 的「技术面试题篇」就有一篇文章详细介绍如何使用 Sorted Set 来设计制作一个排行榜。

diff --git a/docs/database/redis/redis-data-structures-02.md b/docs/database/redis/redis-data-structures-02.md
index 9961acbacaa..78e98365bff 100644
--- a/docs/database/redis/redis-data-structures-02.md
+++ b/docs/database/redis/redis-data-structures-02.md
@@ -1,15 +1,13 @@
---
title: Redis 3 种特殊数据类型详解
+description: 详解Redis三种特殊数据类型Bitmap、HyperLogLog、GEO的使用方法和应用场景,包括签到统计、UV统计、附近的人等典型业务场景实现。
category: 数据库
tag:
- Redis
head:
- - meta
- name: keywords
- content: Redis常见数据类型
- - - meta
- - name: description
- content: Redis特殊数据类型总结:HyperLogLogs(基数统计)、Bitmap (位存储)、Geospatial (地理位置)。
+ content: Redis特殊数据类型,Bitmap,HyperLogLog,GEO,位图,基数统计,地理位置,签到统计,UV统计
---
除了 5 种基本的数据类型之外,Redis 还支持 3 种特殊的数据类型:Bitmap、HyperLogLog、GEO。
@@ -36,7 +34,7 @@ Bitmap 存储的是连续的二进制数字(0 和 1),通过 Bitmap, 只需
| ------------------------------------- | ---------------------------------------------------------------- |
| SETBIT key offset value | 设置指定 offset 位置的值 |
| GETBIT key offset | 获取指定 offset 位置的值 |
-| BITCOUNT key start end | 获取 start 和 end 之前值为 1 的元素个数 |
+| BITCOUNT key start end | 获取 start 和 end 之间值为 1 的元素个数 |
| BITOP operation destkey key1 key2 ... | 对一个或多个 Bitmap 进行运算,可用运算符有 AND, OR, XOR 以及 NOT |
**Bitmap 基本操作演示**:
@@ -123,9 +121,9 @@ HyperLogLog 相关的命令非常少,最常用的也就 3 个。
### 应用场景
-**数量量巨大(百万、千万级别以上)的计数场景**
+**数量巨大(百万、千万级别以上)的计数场景**
-- 举例:热门网站每日/每周/每月访问 ip 数统计、热门帖子 uv 统计、
+- 举例:热门网站每日/每周/每月访问 ip 数统计、热门帖子 uv 统计。
- 相关命令:`PFADD`、`PFCOUNT` 。
## Geospatial (地理位置)
diff --git a/docs/database/redis/redis-delayed-task.md b/docs/database/redis/redis-delayed-task.md
new file mode 100644
index 00000000000..35c14ab7329
--- /dev/null
+++ b/docs/database/redis/redis-delayed-task.md
@@ -0,0 +1,87 @@
+---
+title: 如何基于Redis实现延时任务
+description: 详解基于Redis实现延时任务的两种方案:过期事件监听和Redisson延时队列,分析各方案的优缺点、可靠性问题和适用场景。
+category: 数据库
+tag:
+ - Redis
+head:
+ - - meta
+ - name: keywords
+ content: Redis延时任务,延时队列,过期事件监听,Redisson DelayedQueue,订单超时,定时任务
+---
+
+基于 Redis 实现延时任务的功能无非就下面两种方案:
+
+1. Redis 过期事件监听
+2. Redisson 内置的延时队列
+
+面试的时候,你可以先说自己考虑了这两种方案,但最后发现 Redis 过期事件监听这种方案存在很多问题,因此你最终选择了 Redisson 内置的 DelayedQueue 这种方案。
+
+这个时候面试官可能会追问你一些相关的问题,我们后面会提到,提前准备就好了。
+
+另外,除了下面介绍到的这些问题之外,Redis 相关的常见问题建议你都复习一遍,不排除面试官会顺带问你一些 Redis 的其他问题。
+
+### Redis 过期事件监听实现延时任务功能的原理?
+
+Redis 2.0 引入了发布订阅 (pub/sub) 功能。在 pub/sub 中,引入了一个叫做 **channel(频道)** 的概念,有点类似于消息队列中的 **topic(主题)**。
+
+pub/sub 涉及发布者(publisher)和订阅者(subscriber,也叫消费者)两个角色:
+
+- 发布者通过 `PUBLISH` 投递消息给指定 channel。
+- 订阅者通过`SUBSCRIBE`订阅它关心的 channel。并且,订阅者可以订阅一个或者多个 channel。
+
+
+
+在 pub/sub 模式下,生产者需要指定消息发送到哪个 channel 中,而消费者则订阅对应的 channel 以获取消息。
+
+Redis 中有很多默认的 channel,这些 channel 是由 Redis 本身向它们发送消息的,而不是我们自己编写的代码。其中,`__keyevent@0__:expired` 就是一个默认的 channel,负责监听 key 的过期事件。也就是说,当一个 key 过期之后,Redis 会发布一个 key 过期的事件到`__keyevent@__:expired`这个 channel 中。
+
+我们只需要监听这个 channel,就可以拿到过期的 key 的消息,进而实现了延时任务功能。
+
+这个功能被 Redis 官方称为 **keyspace notifications** ,作用是实时监控 Redis 键和值的变化。
+
+### Redis 过期事件监听实现延时任务功能有什么缺陷?
+
+**1、时效性差**
+
+官方文档的一段介绍解释了时效性差的原因,地址: 。
+
+
+
+这段话的核心是:过期事件消息是在 Redis 服务器删除 key 时发布的,而不是一个 key 过期之后就会就会直接发布。
+
+我们知道常用的过期数据的删除策略就两个:
+
+1. **惰性删除**:只会在取出 key 的时候才对数据进行过期检查。这样对 CPU 最友好,但是可能会造成太多过期 key 没有被删除。
+2. **定期删除**:每隔一段时间抽取一批 key 执行删除过期 key 操作。并且,Redis 底层会通过限制删除操作执行的时长和频率来减少删除操作对 CPU 时间的影响。
+
+定期删除对内存更加友好,惰性删除对 CPU 更加友好。两者各有千秋,所以 Redis 采用的是 **定期删除+惰性/懒汉式删除** 。
+
+因此,就会存在我设置了 key 的过期时间,但到了指定时间 key 还未被删除,进而没有发布过期事件的情况。
+
+**2、丢消息**
+
+Redis 的 pub/sub 模式中的消息并不支持持久化,这与消息队列不同。在 Redis 的 pub/sub 模式中,发布者将消息发送给指定的频道,订阅者监听相应的频道以接收消息。当没有订阅者时,消息会被直接丢弃,在 Redis 中不会存储该消息。
+
+**3、多服务实例下消息重复消费**
+
+Redis 的 pub/sub 模式目前只有广播模式,这意味着当生产者向特定频道发布一条消息时,所有订阅相关频道的消费者都能够收到该消息。
+
+这个时候,我们需要注意多个服务实例重复处理消息的问题,这会增加代码开发量和维护难度。
+
+### Redisson 延迟队列原理是什么?有什么优势?
+
+Redisson 是一个开源的 Java 语言 Redis 客户端,提供了很多开箱即用的功能,比如多种分布式锁的实现、延时队列。
+
+我们可以借助 Redisson 内置的延时队列 RDelayedQueue 来实现延时任务功能。
+
+Redisson 的延迟队列 RDelayedQueue 是基于 Redis 的 SortedSet 来实现的。SortedSet 是一个有序集合,其中的每个元素都可以设置一个分数,代表该元素的权重。Redisson 利用这一特性,将需要延迟执行的任务插入到 SortedSet 中,并给它们设置相应的过期时间作为分数。
+
+Redisson 定期使用 `zrangebyscore` 命令扫描 SortedSet 中过期的元素,然后将这些过期元素从 SortedSet 中移除,并将它们加入到就绪消息列表中。就绪消息列表是一个阻塞队列,有消息进入就会被消费者监听到。这样做可以避免消费者对整个 SortedSet 进行轮询,提高了执行效率。
+
+相比于 Redis 过期事件监听实现延时任务功能,这种方式具备下面这些优势:
+
+1. **减少了丢消息的可能**:DelayedQueue 中的消息会被持久化,即使 Redis 宕机了,根据持久化机制,也只可能丢失一点消息,影响不大。当然了,你也可以使用扫描数据库的方法作为补偿机制。
+2. **消息不存在重复消费问题**:每个客户端都是从同一个目标队列中获取任务的,不存在重复消费的问题。
+
+跟 Redisson 内置的延时队列相比,消息队列可以通过保障消息消费的可靠性、控制消息生产者和消费者的数量等手段来实现更高的吞吐量和更强的可靠性,实际项目中首选使用消息队列的延时消息这种方案。
diff --git a/docs/database/redis/redis-memory-fragmentation.md b/docs/database/redis/redis-memory-fragmentation.md
index 61786866e0b..e2d0ea272b9 100644
--- a/docs/database/redis/redis-memory-fragmentation.md
+++ b/docs/database/redis/redis-memory-fragmentation.md
@@ -1,8 +1,13 @@
---
title: Redis内存碎片详解
+description: 深入解析Redis内存碎片产生的原因、判断方法和优化方案,包括内存碎片率计算、jemalloc分配器原理、自动内存碎片清理配置等。
category: 数据库
tag:
- Redis
+head:
+ - - meta
+ - name: keywords
+ content: Redis内存碎片,内存碎片率,jemalloc,内存分配,activedefrag,内存优化,Redis内存管理
---
## 什么是内存碎片?
@@ -19,7 +24,7 @@ Redis 内存碎片虽然不会影响 Redis 性能,但是会增加内存消耗
Redis 内存碎片产生比较常见的 2 个原因:
-**1、Redis 存储存储数据的时候向操作系统申请的内存空间可能会大于数据实际需要的存储空间。**
+**1、Redis 存储数据的时候向操作系统申请的内存空间可能会大于数据实际需要的存储空间。**
以下是这段 Redis 官方的原话:
diff --git a/docs/database/redis/redis-persistence.md b/docs/database/redis/redis-persistence.md
index 1e51df93448..8dc2110013e 100644
--- a/docs/database/redis/redis-persistence.md
+++ b/docs/database/redis/redis-persistence.md
@@ -1,15 +1,13 @@
---
title: Redis持久化机制详解
+description: 深入解析Redis三种持久化机制RDB快照、AOF日志和混合持久化的工作原理、配置方法和优缺点对比,帮助你选择适合业务场景的持久化策略。
category: 数据库
tag:
- Redis
head:
- - meta
- name: keywords
- content: Redis持久化机制详解
- - - meta
- - name: description
- content: Redis 不同于 Memcached 的很重要一点就是,Redis 支持持久化,而且支持 3 种持久化方式:快照(snapshotting,RDB)、只追加文件(append-only file, AOF)、RDB 和 AOF 的混合持久化(Redis 4.0 新增)。
+ content: Redis持久化,RDB,AOF,混合持久化,bgsave,数据恢复,Redis备份,fork子进程
---
使用缓存的时候,我们经常需要对内存中的数据进行持久化也就是将内存中的数据写入到硬盘中。大部分原因是为了之后重用数据(比如重启机器、机器故障之后恢复数据),或者是为了做数据同步(比如 Redis 集群的主从节点通过 RDB 文件同步数据)。
@@ -20,10 +18,35 @@ Redis 不同于 Memcached 的很重要一点就是,Redis 支持持久化,而
- 只追加文件(append-only file, AOF)
- RDB 和 AOF 的混合持久化(Redis 4.0 新增)
-官方文档地址: 。
+官方文档地址: 。

+**本文基于 Redis 7.0+ 版本**。不同版本的持久化机制有重要差异,使用前请确认你的 Redis 版本:
+
+| 版本 | 持久化默认方式 | 重要特性 |
+| -------------- | -------------- | ----------------------- |
+| **Redis 4.0** | RDB | 引入 RDB+AOF 混合持久化 |
+| **Redis 6.0** | RDB | AOF 仍需手动开启 |
+| **Redis 7.0** | RDB | 引入 Multi-Part AOF |
+| **Redis 7.2+** | RDB | 进一步优化持久化性能 |
+
+**关键行为差异**:
+
+- **AOF rewrite 内存占用**:Redis 7.0 之前重写期间增量数据需在内存中保留,7.0+ 使用 Multi-Part AOF 解决
+- **混合持久化**:Redis 4.0-6.x 需手动开启,Redis 7.0+ 默认启用。
+
+检查你的 Redis 版本:
+
+```bash
+redis-cli INFO server | grep redis_version
+# 输出示例:redis_version:7.0.12
+```
+
+下面这张图展示了 Redis 持久化机制的完整流程,包含了本文的核心内容:
+
+
+
## RDB 持久化
### 什么是 RDB 持久化?
@@ -33,11 +56,18 @@ Redis 可以通过创建快照来获得存储在内存里面的数据在 **某
快照持久化是 Redis 默认采用的持久化方式,在 `redis.conf` 配置文件中默认有此下配置:
```clojure
-save 900 1 #在900秒(15分钟)之后,如果至少有1个key发生变化,Redis就会自动触发bgsave命令创建快照。
-
-save 300 10 #在300秒(5分钟)之后,如果至少有10个key发生变化,Redis就会自动触发bgsave命令创建快照。
-
-save 60 10000 #在60秒(1分钟)之后,如果至少有10000个key发生变化,Redis就会自动触发bgsave命令创建快照。
+# Redis 7.0 默认配置(单行格式)
+save 3600 1 300 100 60 10000
+
+# 各条件含义:
+# - 3600 秒(1 小时)内至少有 1 个 key 变化
+# - 300 秒(5 分钟)内至少有 100 个 key 变化
+# - 60 秒(1 分钟)内至少有 10000 个 key 变化
+
+# 等价于旧版多行格式:
+# save 3600 1
+# save 300 100
+# save 60 10000
```
### RDB 创建快照时会阻塞主线程吗?
@@ -45,15 +75,85 @@ save 60 10000 #在60秒(1分钟)之后,如果至少有10000个key发生
Redis 提供了两个命令来生成 RDB 快照文件:
- `save` : 同步保存操作,会阻塞 Redis 主线程;
-- `bgsave` : fork 出一个子进程,子进程执行,不会阻塞 Redis 主线程,默认选项。
+- `bgsave` : fork 出一个子进程,子进程执行。
> 这里说 Redis 主线程而不是主进程的主要是因为 Redis 启动之后主要是通过单线程的方式完成主要的工作。如果你想将其描述为 Redis 主进程,也没毛病。
+#### fork 性能开销分析
+
+虽然 `bgsave` 在子进程中执行,不会阻塞主线程处理命令请求,但 **fork 操作本身是阻塞的**,且会带来额外的内存开销(下表中的为参考值,实际数值受到 CPU 性能、内存碎片率、系统负载等因素影响):
+
+| 数据集大小 | fork 延迟 | 额外内存占用 | 风险等级 |
+| ---------- | --------- | ---------------- | -------- |
+| < 1GB | < 10ms | ~10MB (页表复制) | 低 |
+| 1-10GB | 10-100ms | 10-100MB | 中 |
+| 10-50GB | 100ms-1s | 100-500MB | 高 |
+| > 50GB | > 1s | > 500MB | 极高 |
+
+> 本文以 RDB 的 `bgsave` 为例说明 fork 性能影响,但**同样的机制也适用于 AOF 重写(`BGREWRITEAOF` 命令)**。AOF 重写同样需要 fork 子进程,同样面临 fork 延迟、COW 内存开销和 THP 风险。生产环境中,无论是 RDB 还是 AOF 重写,都需要关注 fork 相关的性能指标。
+
+#### Copy-on-Write (COW) 机制
+
+- fork 后,子进程共享父进程的内存页(标准页 4KB)
+- 当父进程或子进程修改内存页时,内核复制该页(Copy-on-Write)
+- 大数据集 + 高写负载时,会导致大量页面复制,影响性能
+
+#### THP(透明大页)导致的内存雪崩问题
+
+Linux 发行版默认开启 **THP(Transparent Huge Pages,透明大页)**,大小为 2MB。THP 会增加大页被 COW 的概率,**最坏情况下**,如果内存被合并为 2MB 大页,即使客户端仅修改 10 字节的数据,内核也会复制完整的 2MB 内存页,导致 COW 的内存开销**放大 512 倍**(2MB / 4KB = 512)。
+
+**实际行为**:内核不会强制所有内存都使用 2MB 大页,而是根据情况动态决定是否合并。只有在 THP 成功合并为大页后,修改才会触发 2MB 的 COW。但在高并发写入场景下,这仍会显著增加内存消耗,可能瞬间吸干宿主机内存,触发 **OOM Killer 强杀 Redis 进程**。
+
+**验证方式**:
+
+```bash
+cat /sys/kernel/mm/transparent_hugepage/enabled
+# 输出 [always] madvise never 表示已开启(危险!)
+# 应该输出 always madvise [never]
+```
+
+**解决方案**:在 Redis 启动脚本中添加 `echo never > /sys/kernel/mm/transparent_hugepage/enabled`,或使用 `redis-server --disable-thp yes`(Redis 6.0+ 支持)。
+
+**启动警告**:Redis 检测到 THP 开启时会在启动日志中打印 `WARNING you have Transparent Huge Pages (THP) support enabled in your kernel`,必须立即处理。
+
+#### 生产环境建议
+
+```bash
+# 1. 监控 fork 风险指标
+redis-cli INFO memory | grep -E "(used_memory|used_memory_rss)"
+
+# 输出示例:
+# used_memory:1073741824
+# used_memory_rss:1226833920
+# used_memory_rss_human:1.14G
+
+# 计算 RSS/USED 比值,fork 时应 < 2
+# 如果接近或超过 2,说明 fork 风险高
+
+# 2. 设置 maxmemory 限制 Redis 内存占用,为 fork 预留空间
+# 在 redis.conf 中设置:
+# maxmemory 8gb
+# maxmemory-policy allkeys-lru
+
+# 3. 避免在高峰期手动触发 BGSAVE
+# 让 Redis 根据配置规则自动触发
+
+# 4. 考虑主从复制 + 从节点持久化架构
+# 将持久化操作转移到从节点,避免主节点 fork 开销
+```
+
+**监控告警**:
+
+- `rdb_last_bgsave_time_sec`:上次 bgsave 耗时,应 < 5s
+- `rdb_last_cow_size`:上次 fork 的 COW 内存大小,应 < 10% `used_memory`
+
## AOF 持久化
### 什么是 AOF 持久化?
-与快照持久化相比,AOF 持久化的实时性更好。默认情况下 Redis 没有开启 AOF(append only file)方式的持久化(Redis 6.0 之后已经默认是开启了),可以通过 `appendonly` 参数开启:
+与快照持久化相比,AOF 持久化的实时性更好。默认情况下 Redis 没有开启 AOF(append only file)方式的持久化,可以通过 `appendonly` 参数开启:
+
+> **版本说明**:Redis 默认使用 RDB 持久化方式。若需使用 AOF,需要手动设置 `appendonly yes`。Redis 7.0 引入了 Multi-Part AOF 机制优化 AOF 性能,但并未改变默认持久化方式。
```bash
appendonly yes
@@ -71,7 +171,7 @@ AOF 持久化功能的实现可以简单分为 5 步:
1. **命令追加(append)**:所有的写命令会追加到 AOF 缓冲区中。
2. **文件写入(write)**:将 AOF 缓冲区的数据写入到 AOF 文件中。这一步需要调用`write`函数(系统调用),`write`将数据写入到了系统内核缓冲区之后直接返回了(延迟写)。注意!!!此时并没有同步到磁盘。
-3. **文件同步(fsync)**:AOF 缓冲区根据对应的持久化方式( `fsync` 策略)向硬盘做同步操作。这一步需要调用 `fsync` 函数(系统调用), `fsync` 针对单个文件操作,对其进行强制硬盘同步,`fsync` 将阻塞直到写入磁盘完成后返回,保证了数据持久化。
+3. **文件同步(fsync)**:这一步才是持久化的核心!根据你在 `redis.conf` 文件里 `appendfsync` 配置的策略,Redis 会在不同的时机,调用 `fsync` 函数(系统调用)。`fsync` 针对单个文件操作,对其进行强制硬盘同步(文件在内核缓冲区里的数据写到硬盘),`fsync` 将阻塞直到写入磁盘完成后返回,保证了数据持久化。
4. **文件重写(rewrite)**:随着 AOF 文件越来越大,需要定期对 AOF 文件进行重写,达到压缩的目的。
5. **重启加载(load)**:当 Redis 重启时,可以加载 AOF 文件进行数据恢复。
@@ -79,8 +179,12 @@ AOF 持久化功能的实现可以简单分为 5 步:
这里对上面提到的一些 Linux 系统调用再做一遍解释:
-- `write`:写入系统内核缓冲区之后直接返回(仅仅是写到缓冲区),不会立即同步到硬盘。虽然提高了效率,但也带来了数据丢失的风险。同步硬盘操作通常依赖于系统调度机制,Linux 内核通常为 30s 同步一次,具体值取决于写出的数据量和 I/O 缓冲区的状态。
-- `fsync`:`fsync`用于强制刷新系统内核缓冲区(同步到到磁盘),确保写磁盘操作结束才会返回。
+- `write`:写入系统内核缓冲区之后直接返回(仅仅是写到缓冲区),不会立即同步到硬盘。虽然提高了效率,但也带来了数据丢失的风险。**同步硬盘操作取决于 Linux 内核的脏页回写策略(Dirty Page Writeback)**,主要受以下参数影响:
+ - `/proc/sys/vm/dirty_expire_centisecs`:脏页过期时间(默认 30 秒)
+ - `/proc/sys/vm/dirty_writeback_centisecs`:内核回写线程的唤醒间隔(默认 5 秒)
+ - 系统内存压力:内存不足时会更积极触发同步
+- **这意味着 `appendfsync no` 模式下宕机时,可能丢失的数据量是不可控且不可预测的**,取决于上次内核同步的时间点。
+- `fsync`:`fsync`用于强制刷新系统内核缓冲区(同步到磁盘),确保写磁盘操作结束才会返回。
AOF 工作流程图如下:
@@ -90,13 +194,24 @@ AOF 工作流程图如下:
在 Redis 的配置文件中存在三种不同的 AOF 持久化方式( `fsync`策略),它们分别是:
-1. `appendfsync always`:主线程调用 `write` 执行写操作后,后台线程( `aof_fsync` 线程)立即会调用 `fsync` 函数同步 AOF 文件(刷盘),`fsync` 完成后线程返回,这样会严重降低 Redis 的性能(`write` + `fsync`)。
-2. `appendfsync everysec`:主线程调用 `write` 执行写操作后立即返回,由后台线程( `aof_fsync` 线程)每秒钟调用 `fsync` 函数(系统调用)同步一次 AOF 文件(`write`+`fsync`,`fsync`间隔为 1 秒)
-3. `appendfsync no`:主线程调用 `write` 执行写操作后立即返回,让操作系统决定何时进行同步,Linux 下一般为 30 秒一次(`write`但不`fsync`,`fsync` 的时机由操作系统决定)。
+1. `appendfsync always`:主线程调用 `write` 执行写操作后,会立刻调用 `fsync` 函数同步 AOF 文件(刷盘)。主线程会阻塞,直到 `fsync` 将数据完全刷到磁盘后才会返回。这种方式数据最安全,理论上不会有任何数据丢失。但因为每个写操作都会同步阻塞主线程,所以性能极差。
+2. `appendfsync everysec`:主线程调用 `write` 执行写操作后立即返回,由后台线程( `aof_fsync` 线程)每秒钟调用 `fsync` 函数(系统调用)同步一次 AOF 文件(`write`+`fsync`,`fsync`间隔为 1 秒)。这种方式主线程的性能基本不受影响。在性能和数据安全之间做出了绝佳的平衡。不过,在 Redis 异常宕机时,通常可能丢失最近 1 秒内的数据。
+
+> **生产级真相(2 秒丢失与阻塞风险)**:
+>
+> "最多丢失 1 秒"是理想情况。当磁盘 I/O 繁忙时,后台 fsync 执行时间过长,主线程在执行写命令时会检查上一次 fsync 的完成时间。如果距离上次成功 fsync 超过 2 秒,主线程将被**强制阻塞**以保护内存不被撑爆(Redis 源码 `aof.c` 中的 `aof_background_fsync` 阻塞判断逻辑)。
+>
+> 因此,**极端宕机情况下,可能会丢失最多 2 秒的数据**,且磁盘抖动会直接导致 Redis P99 延迟飙升。
+>
+> **必须监控指标**:`redis-cli INFO persistence | grep aof_delayed_fsync`(记录主线程被 fsync 阻塞的累计次数,只有启用了 AOF 才有这个字段)。
+
+3. `appendfsync no`:主线程调用 `write` 执行写操作后立即返回,让操作系统决定何时进行同步,Linux 下一般为 30 秒一次(`write`但不`fsync`,`fsync` 的时机由操作系统决定)。 这种方式性能最好,因为避免了 `fsync` 的阻塞。但数据安全性最差,宕机时丢失的数据量不可控,取决于操作系统上一次同步的时间点。
可以看出:**这 3 种持久化方式的主要区别在于 `fsync` 同步 AOF 文件的时机(刷盘)**。
-为了兼顾数据和写入性能,可以考虑 `appendfsync everysec` 选项 ,让 Redis 每秒同步一次 AOF 文件,Redis 性能受到的影响较小。而且这样即使出现系统崩溃,用户最多只会丢失一秒之内产生的数据。当硬盘忙于执行写入操作的时候,Redis 还会优雅的放慢自己的速度以便适应硬盘的最大写入速度。
+为了兼顾数据和写入性能,可以考虑 `appendfsync everysec` 选项 ,让 Redis 每秒同步一次 AOF 文件,Redis 性能受到的影响较小。通常情况下,即使出现系统崩溃,用户最多只会丢失一秒之内产生的数据。当硬盘忙于执行写入操作的时候,Redis 还会优雅的放慢自己的速度以便适应硬盘的最大写入速度。
+
+> ⚠️ **注意**:当磁盘 I/O 瓶颈严重时,Redis 主线程可能因等待 fsync 而阻塞长达 2 秒,期间数据丢失窗口扩大至 2 秒。生产环境应监控 `aof_delayed_fsync` 指标来评估磁盘健康度。
从 Redis 7.0.0 开始,Redis 使用了 **Multi Part AOF** 机制。顾名思义,Multi Part AOF 就是将原来的单个 AOF 文件拆分成多个 AOF 文件。在 Multi Part AOF 中,AOF 文件被分为三种类型,分别为:
@@ -141,6 +256,36 @@ AOF 文件重写期间,Redis 还会维护一个 **AOF 重写缓冲区**,该
- `auto-aof-rewrite-min-size`:如果 AOF 文件大小小于该值,则不会触发 AOF 重写。默认值为 64 MB;
- `auto-aof-rewrite-percentage`:执行 AOF 重写时,当前 AOF 大小(aof_current_size)和上一次重写时 AOF 大小(aof_base_size)的比值。如果当前 AOF 文件大小增加了这个百分比值,将触发 AOF 重写。将此值设置为 0 将禁用自动 AOF 重写。默认值为 100。
+**AOF rewrite 的失败边界与风险场景**:
+
+虽然 AOF rewrite 放在子进程执行,但仍存在以下风险需要了解:
+
+| 风险场景 | 影响 | 触发条件 | 应对措施 |
+| ---------------- | --------------------------- | ------------------------ | ------------------------------------------- |
+| **fork 失败** | 无法创建 rewrite 子进程 | 内存不足、系统限制 | 监控内存使用率,设置 `maxmemory` |
+| **磁盘满** | 新 AOF 文件写入失败 | rewrite 期间数据量增长快 | 监控磁盘使用率(`df -h`),设置告警阈值 70% |
+| **inode 耗尽** | 无法创建新文件 | 小文件过多的系统 | 监控 inode 使用率(`df -i`),清理临时文件 |
+| **时间戳回拨** | Multi-Part AOF 文件管理混乱 | 虚拟机时钟同步问题 | 配置 NTP 服务,设置 `aof-timestamp-enabled` |
+| **SIGTERM 信号** | rewrite 被中断 | 运维人员手动重启 | 配置优雅关闭(`shutdown-timeout`) |
+
+**生产环境监控建议**:
+
+```bash
+# 监控 AOF rewrite 状态
+redis-cli INFO persistence | grep aof_rewrite_in_progress
+
+# 监控 AOF 文件大小增长
+redis-cli INFO persistence | grep aof_current_size
+redis-cli INFO persistence | grep aof_base_size
+
+# 检查磁盘和 inode 使用率
+df -h /var/lib/redis
+df -i /var/lib/redis
+
+# 设置 AOF rewrite 期间增量 fsync 策略(Redis 7.0+)
+# aof-rewrite-incremental-sync yes
+```
+
Redis 7.0 版本之前,如果在重写期间有写入命令,AOF 可能会使用大量内存,重写期间到达的所有写入命令都会写入磁盘两次。
Redis 7.0 版本之后,AOF 重写机制得到了优化改进。下面这段内容摘自阿里开发者的[从 Redis7.0 发布看 Redis 的过去与未来](https://mp.weixin.qq.com/s/RnoPPL7jiFSKkx3G4p57Pg) 这篇文章。
@@ -153,40 +298,421 @@ Redis 7.0 版本之后,AOF 重写机制得到了优化改进。下面这段内
### AOF 校验机制了解吗?
-AOF 校验机制是 Redis 在启动时对 AOF 文件进行检查,以判断文件是否完整,是否有损坏或者丢失的数据。这个机制的原理其实非常简单,就是通过使用一种叫做 **校验和(checksum)** 的数字来验证 AOF 文件。这个校验和是通过对整个 AOF 文件内容进行 CRC64 算法计算得出的数字。如果文件内容发生了变化,那么校验和也会随之改变。因此,Redis 在启动时会比较计算出的校验和与文件末尾保存的校验和(计算的时候会把最后一行保存校验和的内容给忽略点),从而判断 AOF 文件是否完整。如果发现文件有问题,Redis 就会拒绝启动并提供相应的错误信息。AOF 校验机制十分简单有效,可以提高 Redis 数据的可靠性。
+纯 AOF 模式下,Redis 不会对整个 AOF 文件使用校验和(如 CRC64),而是通过逐条解析文件中的命令来验证文件的有效性。如果解析过程中发现语法错误(如命令不完整、格式错误),Redis 会终止加载并报错,从而避免错误数据载入内存。
+
+> **尾部截断容灾(自动恢复)**:
+>
+> 在遭遇意外断电或 `kill -9` 强制终止时,AOF 文件的最后一条命令极可能写入不完整(只写了一半)。此时的恢复行为由 **`aof-load-truncated`** 配置决定:
+>
+> | 配置值 | 行为 | 适用场景 |
+> | ------------- | ------------------------------------------------------------------------------- | ---------------------------------------- |
+> | `yes`(默认) | Redis 自动丢弃文件尾部不完整的命令,继续完成启动并在日志中打印警告信息 | 生产环境推荐,允许少量数据丢失换取可用性 |
+> | `no` | Redis 拒绝启动并直接报错,强制要求人工使用 `redis-check-aof` 工具确认并修复数据 | 金融等对数据完整性要求极高的场景 |
+>
+> **验证截断恢复**:
+>
+> ```bash
+> # 模拟断电场景:向 AOF 文件追加无意义的乱码
+> echo "truncated garbage data" >> /var/lib/redis/appendonly.aof
+>
+> # 重启 Redis(aof-load-truncated=yes 时会自动恢复)
+> redis-server /path/to/redis.conf
+> # 日志输出:# Bad file format reading the append only file: make a backup of your AOF file, then use ./redis-check-aof --fix
+> ```
+>
+> **失败模式**:如果 AOF 文件的**中间部分**(而非尾部)因为磁盘静默损坏出现乱码,自动截断机制无效,Redis 将直接宕机拒绝服务。此时需要使用 `redis-check-aof --fix` 工具修复。
+
+**redis-check-aof 工作原理**:
+
+- **检测阶段**:根据 AOF 文件格式逐一读取命令,判断命令参数个数、参数字符串长度等,提供错误/不完整命令的文件位置
+- **修复阶段**:从错误位置截断后续文件内容(**注意:会丢失截断点之后的所有数据**),原文件会被备份为 `appendonly.aof.broken`
+
+**人工修补**(高级用户):
+
+- 如果不想通过截断来修复 AOF 文件,可以尝试人工修补
+- 使用文本编辑器打开 AOF 文件(纯文本格式),手动删除或修复错误命令
+- 适用于明确知道错误位置的特定场景
+
+在 **混合持久化模式**(Redis 4.0 引入)下,AOF 文件由两部分组成:
+
+- **RDB 快照部分**:文件以固定的 `REDIS` 字符开头,存储某一时刻的内存数据快照,并在快照数据末尾附带一个 CRC64 校验和(位于 RDB 数据块尾部、AOF 增量部分之前)。
+- **AOF 增量部分**:紧随 RDB 快照部分之后,记录 RDB 快照生成后的增量写命令。这部分增量命令以 Redis 协议格式逐条记录,无整体或全局校验和。
+
+RDB 文件结构的核心部分如下:
+
+| **字段** | **解释** |
+| ----------------- | ---------------------------------------------- |
+| `"REDIS"` | 固定以该字符串开始 |
+| `RDB_VERSION` | RDB 文件的版本号 |
+| `DB_NUM` | Redis 数据库编号,指明数据需要存放到哪个数据库 |
+| `KEY_VALUE_PAIRS` | Redis 中具体键值对的存储 |
+| `EOF` | RDB 文件结束标志 |
+| `CHECK_SUM` | 8 字节确保 RDB 完整性的校验和 |
+
+Redis 启动并加载 AOF 文件时,首先会校验文件开头 RDB 快照部分的数据完整性,即计算该部分数据的 CRC64 校验和,并与紧随 RDB 数据之后、AOF 增量部分之前存储的 CRC64 校验和值进行比较。如果 CRC64 校验和不匹配,Redis 将拒绝启动并报告错误。
+
+RDB 部分校验通过后,Redis 随后逐条解析 AOF 部分的增量命令。如果解析过程中出现错误(如不完整的命令或格式错误),Redis 会停止继续加载后续命令,并报告错误,但此时 Redis 已经成功加载了 RDB 快照部分的数据。
+
+## 新版本优化
+
+### Redis 4.0 对于持久化机制做了什么优化?
+
+由于 RDB 和 AOF 各有优势,于是,Redis 4.0 开始支持 RDB 和 AOF 的混合持久化。
+
+#### 配置说明
+
+```bash
+# 开启 AOF
+appendonly yes
+
+# 开启混合持久化(Redis 7.0+ 默认启用)
+aof-use-rdb-preamble yes
+
+# 优化重写触发条件
+auto-aof-rewrite-percentage 100 # AOF 文件大小比上次重写后增长 100% 时触发
+auto-aof-rewrite-min-size 64mb # AOF 文件至少达到 64MB 才触发重写
+```
-类似地,RDB 文件也有类似的校验机制来保证 RDB 文件的正确性,这里就不重复进行介绍了。
+**版本差异**:
-## Redis 4.0 对于持久化机制做了什么优化?
+- **Redis 4.0-6.x**:混合持久化默认关闭,需手动配置 `aof-use-rdb-preamble yes`
+- **Redis 7.0+**:混合持久化**默认启用**,无需额外配置
-由于 RDB 和 AOF 各有优势,于是,Redis 4.0 开始支持 RDB 和 AOF 的混合持久化(默认关闭,可以通过配置项 `aof-use-rdb-preamble` 开启)。
+#### 工作原理
-如果把混合持久化打开,AOF 重写的时候就直接把 RDB 的内容写到 AOF 文件开头。这样做的好处是可以结合 RDB 和 AOF 的优点, 快速加载同时避免丢失过多的数据。当然缺点也是有的, AOF 里面的 RDB 部分是压缩格式不再是 AOF 格式,可读性较差。
+如果把混合持久化打开,AOF 重写的时候就直接把 RDB 的内容写到 AOF 文件开头。这样做的好处是可以结合 RDB 和 AOF 的优点, 快速加载同时避免丢失过多的数据。
-官方文档地址:
+**混合持久化文件结构**:
+
+```
+┌───────────────────┐
+│ RDB Header │ ← 二进制快照(压缩格式)
+│ REDIS0009 │
+│ ... │
+├───────────────────┤
+│ AOF Log Entries │ ← 文本格式命令
+│ *3\r\n$3\r\nSET\r\n$5\r\nkey01\r\n...
+│ INCR counter │
+│ ... │
+└───────────────────┘
+```
+
+**核心工作流程**:
+
+1. **写处理阶段**:
+
+ - 客户端执行写命令(`SET/INCR` 等)
+ - Redis 立即更新内存数据
+ - 将命令追加到 AOF 缓冲区(文本格式)
+
+2. **持久化触发阶段**:
+
+ - AOF 文件大小达到阈值(默认 64MB)或增长 100%
+ - 触发 AOF 重写(`BGREWRITEAOF`)
+
+3. **文件构建阶段**:
+
+ - 子进程将当前内存数据以 RDB 格式写入新 AOF 文件开头
+ - 父进程继续处理写命令,增量数据记录到重写缓冲区
+ - 重写完成后,将重写缓冲区的增量命令追加到新 AOF 文件末尾
+
+4. **数据恢复阶段**:
+ - Redis 启动时优先加载 RDB 部分(快速恢复基础数据)
+ - 然后顺序重放 AOF 增量命令(恢复最新数据)
+
+#### 优势对比
+
+| 指标 | 纯 RDB | 纯 AOF | 混合持久化 |
+| ---------------- | ------------ | -------------- | -------------- |
+| **恢复速度** | 快(秒级) | 慢(分钟级) | 快(秒级) |
+| **数据丢失窗口** | 分钟级 | ≤2 秒 | ≤2 秒 |
+| **文件大小** | 小(压缩) | 大(文本日志) | 中等 |
+| **写入影响** | 低 | 高 | 中等 |
+| **可读性** | 差(二进制) | 好(文本) | 差(RDB 部分) |
+
+**基准数据**(1GB 数据集,SSD):
+
+- 纯 AOF 恢复:30-60 秒
+- 混合持久化恢复:2-5 秒(**快 5-10 倍**)
+
+**混合持久化缺点**:
+
+- AOF 文件里面的 RDB 部分是压缩格式,不再是 AOF 格式,可读性较差。
+- 需要额外消耗 CPU 进行 RDB 压缩和解压。
+
+#### 常见问题及解决方案
+
+**1. 配置验证**:
+
+```bash
+# 方法 1:检查文件头(输出 REDIS 表示启用了混合持久化)
+head -c 5 appendonly.aof
+
+# 方法 2:CLI 验证
+redis-cli CONFIG GET aof-use-rdb-preamble
+# 输出:1) "aof-use-rdb-preamble"
+# 2) "yes"
+```
+
+**2. 文件损坏恢复**:
+
+**工具说明**:
+
+| 工具 | 工作原理 | 错误检测 | 修复功能 |
+| ------------------- | ----------------------------------------------------------------- | ------------------------------------ | --------------------------------------------------- |
+| **redis-check-aof** | 根据 AOF 文件格式逐一读取命令,判断命令参数个数、参数字符串长度等 | 检测命令正确性和完整性,提供错误位置 | ✅ **支持修复**:从错误位置截断后续内容,或人工修补 |
+| **redis-check-rdb** | 按照 RDB 文件格式依次读取文件头、数据部分、文件尾 | 在读取过程中判断内容是否正确并报错 | ❌ **不支持修复**:仅检测问题,需人工修复 |
+
+**恢复步骤**:
+
+```bash
+# 步骤 1:检测 AOF 文件问题
+redis-check-aof appendonly.aof
+# 输出错误位置和原因
+
+# 步骤 2:修复 AOF 文件(从错误位置截断)
+redis-check-aof --fix appendonly.aof
+# 原 AOF 文件会被备份为 appendonly.aof.broken
+
+# 步骤 3:检测 RDB 部分
+redis-check-rdb appendonly.aof
+# 仅检测,不支持 --fix 参数
+
+# 步骤 4:如果 RDB 部分有问题,需人工修复或丢弃整个文件
+# 选项 A:人工修复(需了解 RDB 二进制格式)
+# 选项 B:删除混合持久化文件,仅使用纯 RDB 或纯 AOF 恢复
+
+# 步骤 5:启动 Redis
+redis-server --appendonly yes --appendfilename appendonly.aof
+```
+
+> **⚠️ 重要提示**:
+>
+> - **AOF 文件**:`redis-check-aof --fix` 会从错误位置截断文件,**丢失截断点之后的所有数据**
+> - **RDB 文件**:`redis-check-rdb` **不支持修复**,如果 RDB 部分损坏,整个混合持久化文件无法恢复,只能依赖备份或纯 AOF 文件
+> - **人工修复**:对于 RDB 部分,如果必须修复,需要使用十六进制编辑器(如 `hexdump`、`xxd`)手动修改二进制格式
+
+#### 生产配置建议
+
+```bash
+# 完整生产配置示例
+appendonly yes
+aof-use-rdb-preamble yes
+
+# 性能优化
+aof-rewrite-incremental-fsync yes # 增量 fsync,减少磁盘 I/O 峰值
+# 延迟敏感场景(推荐 yes)
+no-appendfsync-on-rewrite yes # 重写期间暂停 fsync,避免阻塞
+# 数据安全场景(推荐 no)
+no-appendfsync-on-rewrite no # 重写期间仍执行 fsync,可能阻塞但更安全
+
+# 容量规划建议:
+# - 预留 2x 内存作为磁盘空间
+# - 保持单个 AOF 文件 < 16GB
+# - 监控 aof_delayed_fsync 指标
+```
+
+官方文档地址:

+### Redis 7.0 对于持久化机制做了什么优化?
+
+由于 AOF 重写过程中存在内存缓冲增量数据和磁盘双写的问题,于是,Redis 7.0 开始支持 Multi-Part AOF(默认启用,可以通过配置项 `appenddirname` 指定目录)。
+
+如果把 Multi-Part AOF 启用,AOF 文件将被拆分为 base 文件(最多一个,初始全量快照,可为 RDB 或 AOF 格式)和多个 incr 文件(增量命令日志),重写期间新增命令直接写入新的 incr 文件,由 manifest 文件跟踪所有部分。这样做的好处是可以消除重写时的内存缓冲开销和双重 I/O 写入,提高性能并减少潜在的 fsync 阻塞。由于文件结构分离,INCR 文件在重写前保持只读,单文件拷贝相对安全;但跨文件的一致性备份仍需暂停重写,整体备份流程比单文件 AOF 更复杂,且在极大数据集下仍可能需监控资源。
+
+> **核心单点故障风险:manifest 文件损坏**
+>
+> Multi-Part AOF 依赖 **manifest 文件**来跟踪和管理所有 `base/incr/history` 文件,这是整个增量日志体系的核心元数据。如果 manifest 文件损坏或丢失:
+>
+> | 风险场景 | 影响 | 恢复难度 |
+> | ------------------------------ | ------------------------------------------------------- | --------------------------- |
+> | **manifest 静默损坏** | Redis 启动时无法正确识别和加载 AOF 文件,数据库无法恢复 | 极高(需手动重建 manifest) |
+> | **磁盘故障导致 manifest 丢失** | 即使 base/incr 文件完整,Redis 也无法重构文件依赖关系 | 极高(需人工干预) |
+>
+> **缓解措施**:
+>
+> ```bash
+> # 1. 备份 manifest 文件(与数据文件同等重要)
+> cp /var/lib/redis/appendonlydir/appendonly.aof.manifest /backup/
+>
+> # 2. 监控磁盘健康度(提前发现故障)
+> smartctl -a /dev/sda | grep -E "SMART overall-health self-assessment|Media_Errors"
+>
+> # 3. 定期验证 manifest 完整性(Redis 启动时会自动校验)
+> redis-check-aof /var/lib/redis/appendonlydir/appendonly.aof.manifest
+> ```
+>
+> **官方未提供自动化修复工具**,生产环境必须将 manifest 文件纳入备份策略,其重要性等同于 RDB/AOF 数据文件本身。
+
+## 生产环境监控指标
+
+### 持久化性能指标
+
+```bash
+# RDB 相关指标
+redis-cli INFO persistence | grep rdb_last_bgsave_time_sec
+# 建议:< 5s。超过 5s 说明数据集过大或 I/O 性能瓶颈
+
+redis-cli INFO persistence | grep rdb_last_cow_size
+# 建议:< 10% used_memory。超过说明 fork 的 Copy-on-Write 内存开销大
+
+redis-cli INFO memory | grep used_memory_rss
+redis-cli INFO memory | grep used_memory
+# 计算:used_memory_rss / used_memory,fork 时应 < 2
+
+# AOF 相关指标
+redis-cli INFO persistence | grep aof_rewrite_in_progress
+# 期望:0(未在重写)或 1(正在重写)
+
+redis-cli INFO persistence | grep aof_current_size
+redis-cli INFO persistence | grep aof_base_size
+# 监控增长率,避免 rewrite 过于频繁
+
+redis-cli INFO persistence | grep aof_buffer_length
+# 建议:< 4MB。过大说明主线程写入速度快于 fsync 速度
+```
+
+### 系统资源监控
+
+```bash
+# 磁盘使用率和 I/O 等待
+iostat -x 1 5 | grep dm-0
+# 关注:%util(I/O 使用率)、await(平均等待时间)
+
+# 磁盘空间(预留空间给 rewrite 生成新文件)
+df -h /var/lib/redis
+# 建议:使用率 < 70%
+
+# inode 使用率(小文件多的场景)
+df -i /var/lib/redis
+# 建议:使用率 < 90%
+
+# 内存使用率
+free -h
+# 建议:为 fork 预留至少 20% 空闲内存
+```
+
+### 告警规则建议
+
+> **指标来源说明**:
+>
+> - **Redis 指标**:通过 `redis-cli INFO` 或 Redis exporter 获取(如 `redis_rss_memory`、`aof_current_size`)
+> - **节点级指标**:通过 node_exporter 或系统命令获取(如 `disk_usage`、系统内存、CPU 使用率)
+>
+> 以下告警规则假设使用 Prometheus + Redis exporter + node_exporter 监控体系。
+
+```yaml
+alert_rules:
+ # ── Redis 持久化相关告警 ────────────────────────────────────────
+ - name: "RedisHighMemFragmentation"
+ expr: redis_memory_rss_bytes / redis_memory_used_bytes > 2
+ for: 5m
+ labels:
+ severity: warning
+ annotations:
+ summary: "Redis 内存碎片率过高,fork COW 风险上升"
+ description: >
+ 实例 {{ $labels.instance }} 的 mem_fragmentation_ratio = {{ $value | humanize }},
+ 超过阈值 2。碎片率过高意味着 OS 实际分配的物理页远多于 Redis 自身统计,
+ 执行 BGSAVE / BGREWRITEAOF 触发 fork 后,COW 需复制的页数会显著增加,
+ 在高写入负载下可能导致内存暴涨,OOM 风险上升。
+ 建议执行 MEMORY PURGE 或在低峰期重启实例整理碎片。
+
+ - name: "RedisAofGrowthTooFast"
+ expr: deriv(redis_aof_current_size_bytes[5m]) * 60 > 10485760
+ for: 5m
+ labels:
+ severity: warning
+ annotations:
+ summary: "Redis AOF 文件写入速率过高"
+ description: >
+ 实例 {{ $labels.instance }} 的 AOF 增长速率超过 10 MB/min
+ (当前约 {{ $value | humanize1024 }}B/min)。
+ 高速写入会持续触发 auto-aof-rewrite,加剧磁盘 I/O 压力,
+ 并可能产生写入放大。建议检查业务是否存在大量小命令风暴或 KEYS 类全量扫描。
+
+ - name: "RedisAofFsyncDelayed"
+ expr: rate(redis_aof_delayed_fsync_total[5m]) > 0
+ for: 2m
+ labels:
+ severity: critical
+ annotations:
+ summary: "Redis AOF fsync 延迟,主线程响应受阻"
+ description: >
+ 实例 {{ $labels.instance }} 持续出现 aof_delayed_fsync 增长,
+ 主线程因等待 AOF fsync 完成而被阻塞,直接导致命令响应 P99 劣化。
+ 常见原因:① 磁盘 I/O 带宽饱和;② appendfsync 设置为 always;
+ ③ 与其他高 I/O 进程共用磁盘。建议切换为 everysec 策略或迁移至独立磁盘。
+
+ # ── 节点级资源告警 ─────────────────────────────────────────────
+ - name: "RedisDiskUsageHigh"
+ expr: >
+ (1 - node_filesystem_avail_bytes{mountpoint="/var/lib/redis"}
+ / node_filesystem_size_bytes{mountpoint="/var/lib/redis"}) * 100 > 70
+ for: 5m
+ labels:
+ severity: warning
+ annotations:
+ summary: "Redis 数据盘使用率超过 70%"
+ description: >
+ 挂载点 /var/lib/redis 当前使用率为 {{ $value | humanize }}%。
+ AOF rewrite 期间会临时生成新文件,需预留约 1.5x 当前 AOF 大小的空间,
+ 磁盘不足将导致 rewrite 失败并触发 Redis 错误日志 "MISCONF"。
+ RDB bgsave 同理。
+ remediation: >
+ 1. 清理过期 RDB 快照与历史 AOF 文件;
+ 2. 调高 auto-aof-rewrite-min-size 降低 rewrite 频率;
+ 3. 磁盘扩容或将数据目录迁移至更大分区。
+```
+
## 如何选择 RDB 和 AOF?
关于 RDB 和 AOF 的优缺点,官网上面也给了比较详细的说明[Redis persistence](https://redis.io/docs/manual/persistence/),这里结合自己的理解简单总结一下。
**RDB 比 AOF 优秀的地方**:
-- RDB 文件存储的内容是经过压缩的二进制数据, 保存着某个时间点的数据集,文件很小,适合做数据的备份,灾难恢复。AOF 文件存储的是每一次写命令,类似于 MySQL 的 binlog 日志,通常会比 RDB 文件大很多。当 AOF 变得太大时,Redis 能够在后台自动重写 AOF。新的 AOF 文件和原有的 AOF 文件所保存的数据库状态一样,但体积更小。不过, Redis 7.0 版本之前,如果在重写期间有写入命令,AOF 可能会使用大量内存,重写期间到达的所有写入命令都会写入磁盘两次。
-- 使用 RDB 文件恢复数据,直接解析还原数据即可,不需要一条一条地执行命令,速度非常快。而 AOF 则需要依次执行每个写命令,速度非常慢。也就是说,与 AOF 相比,恢复大数据集的时候,RDB 速度更快。
+- **文件紧凑,适合备份和灾难恢复**:RDB 文件存储的内容是经过压缩的二进制数据,保存着某个时间点的数据集,文件很小,非常适合做数据的备份和灾难恢复。AOF 文件存储的是每一次写命令,类似于 MySQL 的 binlog 日志,通常会比 RDB 文件大很多。当 AOF 变得太大时,Redis 能够在后台自动重写 AOF,新的 AOF 文件和原有的 AOF 文件所保存的数据库状态一样,但体积更小。不过,Redis 7.0 版本之前,如果在重写期间有写入命令,AOF 可能会使用大量内存,重写期间到达的所有写入命令都会写入磁盘两次。
+- **恢复速度快**:使用 RDB 文件恢复数据,直接解析还原数据即可,不需要一条一条地执行命令,速度非常快。而 AOF 则需要依次执行每个写命令,速度非常慢。也就是说,与 AOF 相比,恢复大数据集的时候,RDB 速度更快。
+- **主从复制优势**:在副本(replica)上,RDB 支持重启和故障转移后的**部分重新同步**(Partial Resynchronization)。副本可以使用 RDB 快照快速同步到主节点的某个时间点状态,而不需要全量同步。
+- **性能开销小**:RDB 最大化 Redis 性能,因为 Redis 父进程需要做的唯一持久化工作就是 fork 子进程,子进程将完成所有其余工作。父进程永远不会执行磁盘 I/O 或类似操作。
**AOF 比 RDB 优秀的地方**:
-- RDB 的数据安全性不如 AOF,没有办法实时或者秒级持久化数据。生成 RDB 文件的过程是比较繁重的, 虽然 BGSAVE 子进程写入 RDB 文件的工作不会阻塞主线程,但会对机器的 CPU 资源和内存资源产生影响,严重的情况下甚至会直接把 Redis 服务干宕机。AOF 支持秒级数据丢失(取决 fsync 策略,如果是 everysec,最多丢失 1 秒的数据),仅仅是追加命令到 AOF 文件,操作轻量。
-- RDB 文件是以特定的二进制格式保存的,并且在 Redis 版本演进中有多个版本的 RDB,所以存在老版本的 Redis 服务不兼容新版本的 RDB 格式的问题。
-- AOF 以一种易于理解和解析的格式包含所有操作的日志。你可以轻松地导出 AOF 文件进行分析,你也可以直接操作 AOF 文件来解决一些问题。比如,如果执行`FLUSHALL`命令意外地刷新了所有内容后,只要 AOF 文件没有被重写,删除最新命令并重启即可恢复之前的状态。
+- **数据安全性更高,支持秒级持久化**:RDB 的数据安全性不如 AOF,没有办法实时或者秒级持久化数据。生成 RDB 文件的过程是比较繁重的,虽然 BGSAVE 子进程写入 RDB 文件的工作不会阻塞主线程,但会对机器的 CPU 资源和内存资源产生影响,严重的情况下甚至会直接把 Redis 服务干宕机。AOF 支持秒级数据丢失(取决于 `fsync` 策略,如果是 `everysec`,通常最多丢失 1 秒的数据;但磁盘 I/O 繁忙时可能丢失 2 秒且主线程会阻塞),仅仅是追加命令到 AOF 文件,操作轻量。
+- **版本兼容性好**:RDB 文件是以特定的二进制格式保存的,并且在 Redis 版本演进中有多个版本的 RDB,所以存在老版本的 Redis 服务不兼容新版本的 RDB 格式的问题。
+- **可读性和可操作性强**:AOF 以一种易于理解和解析的格式包含所有操作的日志。你可以轻松地导出 AOF 文件进行分析,也可以直接操作 AOF 文件来解决一些问题。比如,如果执行`FLUSHALL`命令意外地刷新了所有内容后,只要 AOF 文件没有被重写,删除最新命令并重启即可恢复之前的状态。
+- **追加日志无损坏风险**:AOF 日志是追加日志,没有寻道,也没有断电损坏问题。即使日志由于某种原因(磁盘已满或其他原因)以半写入命令结尾,`redis-check-aof` 工具也能轻松修复。
+
+**版本演进对选型的影响**:
+
+| 版本 | 关键改进 | 对 AOF 的影响 | 对选型的意义 |
+| ------------- | ---------------------------------------- | ------------------------------------------------------- | -------------------------------------------------------------- |
+| **Redis 4.0** | 引入混合持久化(`aof-use-rdb-preamble`) | AOF 重写时 base 文件使用 RDB 格式,恢复速度提升 5-10 倍 | 缓解了纯 AOF 加载慢的问题,但仍需关注重写期间的内存和 I/O 开销 |
+| **Redis 7.0** | 引入 Multi-Part AOF | 彻底消除重写期间的双写问题,内存和 I/O 开销大幅降低 | 单独使用 AOF 在生产环境更具可行性,但 fork 阻塞问题仍未解决 |
+
+**未解决的核心问题**:
+
+- **fork 阻塞**:无论是 RDB bgsave 还是 AOF 重写,fork 操作本身都会阻塞主线程(数据集越大,阻塞时间越长)
+- **官方建议**:Redis 官方文档至今仍建议**同时开启 RDB 和 AOF**,RDB 作为额外的冷备手段,应对 AOF 文件损坏或写入错误等极端场景
+
+**AOF 和 RDB 的交互**:
+
+当 AOF 和 RDB 持久化同时启用时:
+
+- **避免同时进行重 I/O 操作**:Redis 2.4+ 确保避免在 RDB 快照进行时触发 AOF 重写,或允许在 AOF 重写期间进行 BGSAVE。这防止两个 Redis 后台进程同时进行繁重的磁盘 I/O。
+- **AOF 重写调度**:当快照正在进行且用户显式请求日志重写操作(使用 BGREWRITEAOF)时,服务器将返回 OK 状态码,告诉用户操作已调度,重写将在快照完成后开始。
+- **重启恢复优先级**:如果 AOF 和 RDB 持久化都启用且 Redis 重启,**AOF 文件将用于重建原始数据集**,因为它被保证是最完整的。
-**综上**:
+**选型建议**:
-- Redis 保存的数据丢失一些也没什么影响的话,可以选择使用 RDB。
-- 不建议单独使用 AOF,因为时不时地创建一个 RDB 快照可以进行数据库备份、更快的重启以及解决 AOF 引擎错误。
-- 如果保存的数据要求安全性比较高的话,建议同时开启 RDB 和 AOF 持久化或者开启 RDB 和 AOF 混合持久化。
+| 场景 | 推荐方案 | 说明 |
+| -------------------------------- | -------------------------------------------------------------------- | ----------------------------------------------------------- |
+| **纯缓存(可丢失)** | **关闭持久化** 或仅 RDB(低频) | 完全关闭开销最小;若需冷备则保留低频 RDB |
+| **数据重要性中等**(会话、配置) | **RDB + AOF 混合持久化**(Redis 4.0+) | RDB 加速恢复,AOF 增量补充,`everysec` 最多丢 1s |
+| **数据重要性高**(业务核心数据) | **RDB + AOF(MP-AOF,Redis 7.0+)**,且 Redis 作为缓存层而非唯一存储 | MP-AOF 降低重写开销;真正的持久化由主数据库(MySQL 等)负责 |
+| **主从架构** | **主节点关闭持久化,从节点开启 AOF** | 主节点禁止配置自动重启,防止空数据集覆盖从节点 |
## 参考
diff --git a/docs/database/redis/redis-questions-01.md b/docs/database/redis/redis-questions-01.md
index 311be6db618..284fa4367b1 100644
--- a/docs/database/redis/redis-questions-01.md
+++ b/docs/database/redis/redis-questions-01.md
@@ -1,15 +1,13 @@
---
title: Redis常见面试题总结(上)
+description: 最新Redis面试题总结(上):深入讲解Redis基础、五大常用数据结构、单线程模型原理、持久化机制、内存淘汰与过期策略、分布式锁与消息队列实现。适合准备后端面试的开发者!
category: 数据库
tag:
- Redis
head:
- - meta
- name: keywords
- content: Redis基础,Redis常见数据结构,Redis线程模型,Redis内存管理,Redis事务,Redis性能优化
- - - meta
- - name: description
- content: 一篇文章总结Redis常见的知识点和面试题,涵盖Redis基础、Redis常见数据结构、Redis线程模型、Redis内存管理、Redis事务、Redis性能优化等内容。
+ content: Redis面试题,Redis基础,Redis数据结构,Redis线程模型,Redis持久化,Redis内存管理,Redis性能优化,Redis分布式锁,Redis消息队列,Redis延时队列,Redis缓存策略,Redis单线程,Redis多线程,Redis过期策略,Redis淘汰策略
---
@@ -20,7 +18,7 @@ head:
[Redis](https://redis.io/) (**RE**mote **DI**ctionary **S**erver)是一个基于 C 语言开发的开源 NoSQL 数据库(BSD 许可)。与传统数据库不同的是,Redis 的数据是保存在内存中的(内存数据库,支持持久化),因此读写速度非常快,被广泛应用于分布式缓存方向。并且,Redis 存储的是 KV 键值对数据。
-为了满足不同的业务场景,Redis 内置了多种数据类型实现(比如 String、Hash、Sorted Set、Bitmap、HyperLogLog、GEO)。并且,Redis 还支持事务、持久化、Lua 脚本、多种开箱即用的集群方案(Redis Sentinel、Redis Cluster)。
+为了满足不同的业务场景,Redis 内置了多种数据类型实现(比如 String、Hash、Sorted Set、Bitmap、HyperLogLog、GEO)。并且,Redis 还支持事务、持久化、Lua 脚本、发布订阅模型、多种开箱即用的集群方案(Redis Sentinel、Redis Cluster)。

@@ -30,36 +28,50 @@ Redis 没有外部依赖,Linux 和 OS X 是 Redis 开发和测试最多的两

-全世界有非常多的网站使用到了 Redis ,[techstacks.io](https://techstacks.io/) 专门维护了一个[使用 Redis 的热门站点列表](https://techstacks.io/tech/redis) ,感兴趣的话可以看看。
+全世界有非常多的网站使用到了 Redis,[techstacks.io](https://techstacks.io/) 专门维护了一个[使用 Redis 的热门站点列表](https://techstacks.io/tech/redis),感兴趣的话可以看看。
+
+### ⭐️Redis 为什么这么快?
+
+Redis 内部做了非常多的性能优化,比较重要的有下面 4 点:
+
+1. **纯内存操作 (Memory-Based Storage)** :这是最主要的原因。Redis 数据读写操作都发生在内存中,访问速度是纳秒级别,而传统数据库频繁读写磁盘的速度是毫秒级别,两者相差数个数量级。
+2. **高效的 I/O 模型 (I/O Multiplexing & Single-Threaded Event Loop)** :Redis 使用单线程事件循环配合 I/O 多路复用技术,让单个线程可以同时处理多个网络连接上的 I/O 事件(如读写),避免了多线程模型中的上下文切换和锁竞争问题。虽然是单线程,但结合内存操作的高效性和 I/O 多路复用,使得 Redis 能轻松处理大量并发请求(Redis 线程模型会在后文中详细介绍到)。
+3. **优化的内部数据结构 (Optimized Data Structures)** :Redis 提供多种数据类型(如 String, List, Hash, Set, Sorted Set 等),其内部实现采用高度优化的编码方式(如 ziplist, quicklist, skiplist, hashtable 等)。Redis 会根据数据大小和类型动态选择最合适的内部编码,以在性能和空间效率之间取得最佳平衡。
+4. **简洁高效的通信协议 (Simple Protocol - RESP)** :Redis 使用的是自己设计的 RESP (REdis Serialization Protocol) 协议。这个协议实现简单、解析性能好,并且是二进制安全的。客户端和服务端之间通信的序列化/反序列化开销很小,有助于提升整体的交互速度。
-### Redis 为什么这么快?
+> 下面这张图片总结的挺不错的,分享一下,出自 [Why is Redis so fast?](https://twitter.com/alexxubyte/status/1498703822528544770)。
-Redis 内部做了非常多的性能优化,比较重要的有下面 3 点:
+
-1. Redis 基于内存,内存的访问速度是磁盘的上千倍;
-2. Redis 基于 Reactor 模式设计开发了一套高效的事件处理模型,主要是单线程事件循环和 IO 多路复用(Redis 线程模式后面会详细介绍到);
-3. Redis 内置了多种优化过后的数据类型/结构实现,性能非常高。
+那既然都这么快了,为什么不直接用 Redis 当主数据库呢?主要是因为内存成本太高,并且 Redis 提供的数据持久化仍然有数据丢失的风险。
-> 下面这张图片总结的挺不错的,分享一下,出自 [Why is Redis so fast?](https://twitter.com/alexxubyte/status/1498703822528544770) 。
+### 除了 Redis,你还知道其他分布式缓存方案吗?
-
+如果面试中被问到这个问题的话,面试官主要想看看:
-### 分布式缓存常见的技术选型方案有哪些?
+1. 你在选择 Redis 作为分布式缓存方案时,是否是经过严谨的调研和思考,还是只是因为 Redis 是当前的“热门”技术。
+2. 你在分布式缓存方向的技术广度。
+
+如果你了解其他方案,并且能解释为什么最终选择了 Redis(更进一步!),这会对你面试表现加分不少!
+
+下面简单聊聊常见的分布式缓存技术选型。
分布式缓存的话,比较老牌同时也是使用的比较多的还是 **Memcached** 和 **Redis**。不过,现在基本没有看过还有项目使用 **Memcached** 来做缓存,都是直接用 **Redis**。
Memcached 是分布式缓存最开始兴起的那会,比较常用的。后来,随着 Redis 的发展,大家慢慢都转而使用更加强大的 Redis 了。
-有一些大厂也开源了类似于 Redis 的分布式高性能 KV 存储数据库,例如,腾讯开源的 [Tendis](https://github.com/Tencent/Tendis) 。Tendis 基于知名开源项目 [RocksDB](https://github.com/facebook/rocksdb) 作为存储引擎 ,100% 兼容 Redis 协议和 Redis4.0 所有数据模型。关于 Redis 和 Tendis 的对比,腾讯官方曾经发过一篇文章:[Redis vs Tendis:冷热混合存储版架构揭秘](https://mp.weixin.qq.com/s/MeYkfOIdnU6LYlsGb24KjQ) ,可以简单参考一下。
+有一些大厂也开源了类似于 Redis 的分布式高性能 KV 存储数据库,例如,腾讯开源的 [**Tendis**](https://github.com/Tencent/Tendis)。Tendis 基于知名开源项目 [RocksDB](https://github.com/facebook/rocksdb) 作为存储引擎 ,100% 兼容 Redis 协议和 Redis4.0 所有数据模型。关于 Redis 和 Tendis 的对比,腾讯官方曾经发过一篇文章:[Redis vs Tendis:冷热混合存储版架构揭秘](https://mp.weixin.qq.com/s/MeYkfOIdnU6LYlsGb24KjQ),可以简单参考一下。
不过,从 Tendis 这个项目的 Github 提交记录可以看出,Tendis 开源版几乎已经没有被维护更新了,加上其关注度并不高,使用的公司也比较少。因此,不建议你使用 Tendis 来实现分布式缓存。
目前,比较业界认可的 Redis 替代品还是下面这两个开源分布式缓存(都是通过碰瓷 Redis 火的):
- [Dragonfly](https://github.com/dragonflydb/dragonfly):一种针对现代应用程序负荷需求而构建的内存数据库,完全兼容 Redis 和 Memcached 的 API,迁移时无需修改任何代码,号称全世界最快的内存数据库。
-- [KeyDB](https://github.com/Snapchat/KeyDB): Redis 的一个高性能分支,专注于多线程、内存效率和高吞吐量。
+- [KeyDB](https://github.com/Snapchat/KeyDB):Redis 的一个高性能分支,专注于多线程、内存效率和高吞吐量。
-不过,个人还是建议分布式缓存首选 Redis ,毕竟经过这么多年的生考验,生态也这么优秀,资料也很全面。
+不过,个人还是建议分布式缓存首选 Redis,毕竟经过了这么多年的考验,生态非常优秀,资料也很全面!
+
+PS:篇幅问题,我这并没有对上面提到的分布式缓存选型做详细介绍和对比,感兴趣的话,可以自行研究一下。
### 说一下 Redis 和 Memcached 的区别和共同点
@@ -73,38 +85,48 @@ Memcached 是分布式缓存最开始兴起的那会,比较常用的。后来
**区别**:
-1. **Redis 支持更丰富的数据类型(支持更复杂的应用场景)**。Redis 不仅仅支持简单的 k/v 类型的数据,同时还提供 list,set,zset,hash 等数据结构的存储。Memcached 只支持最简单的 k/v 数据类型。
-2. **Redis 支持数据的持久化,可以将内存中的数据保持在磁盘中,重启的时候可以再次加载进行使用,而 Memcached 把数据全部存在内存之中。**
-3. **Redis 有灾难恢复机制。** 因为可以把缓存中的数据持久化到磁盘上。
-4. **Redis 在服务器内存使用完之后,可以将不用的数据放到磁盘上。但是,Memcached 在服务器内存使用完之后,就会直接报异常。**
-5. **Memcached 没有原生的集群模式,需要依靠客户端来实现往集群中分片写入数据;但是 Redis 目前是原生支持 cluster 模式的。**
-6. **Memcached 是多线程,非阻塞 IO 复用的网络模型;Redis 使用单线程的多路 IO 复用模型。** (Redis 6.0 针对网络数据的读写引入了多线程)
-7. **Redis 支持发布订阅模型、Lua 脚本、事务等功能,而 Memcached 不支持。并且,Redis 支持更多的编程语言。**
-8. **Memcached 过期数据的删除策略只用了惰性删除,而 Redis 同时使用了惰性删除与定期删除。**
+1. **数据类型**:Redis 支持更丰富的数据类型(支持更复杂的应用场景)。Redis 不仅仅支持简单的 k/v 类型的数据,同时还提供 list、set、zset、hash 等数据结构的存储;而 Memcached 只支持最简单的 k/v 数据类型。
+2. **数据持久化**:Redis 支持数据的持久化,可以将内存中的数据保持在磁盘中,重启的时候可以再次加载进行使用;而 Memcached 把数据全部存在内存之中。也就是说,Redis 有灾难恢复机制,而 Memcached 没有。
+3. **集群模式支持**:Memcached 没有原生的集群模式,需要依靠客户端来实现往集群中分片写入数据;而 Redis 自 3.0 版本起是原生支持集群模式的。
+4. **线程模型**:Memcached 是多线程、非阻塞 IO 复用的网络模型;而 Redis 使用单线程的多路 IO 复用模型(Redis 6.0 针对网络数据的读写引入了多线程)。
+5. **特性支持**:Redis 支持发布订阅模型、Lua 脚本、事务等功能,而 Memcached 不支持。并且,Redis 支持更多的编程语言。
+6. **过期数据删除**:Memcached 过期数据的删除策略只用了惰性删除,而 Redis 同时使用了惰性删除与定期删除。
相信看了上面的对比之后,我们已经没有什么理由可以选择使用 Memcached 来作为自己项目的分布式缓存了。
-### 为什么要用 Redis/为什么要用缓存?
-
-下面我们主要从“高性能”和“高并发”这两点来回答这个问题。
+### ⭐️为什么要用 Redis?
-**1、高性能**
+**1、访问速度更快**
-假如用户第一次访问数据库中的某些数据的话,这个过程是比较慢,毕竟是从硬盘中读取的。但是,如果说,用户访问的数据属于高频数据并且不会经常改变的话,那么我们就可以很放心地将该用户访问的数据存在缓存中。
-
-**这样有什么好处呢?** 那就是保证用户下一次再访问这些数据的时候就可以直接从缓存中获取了。操作缓存就是直接操作内存,所以速度相当快。
+传统数据库数据保存在磁盘,而 Redis 基于内存,内存的访问速度比磁盘快很多。引入 Redis 之后,我们可以把一些高频访问的数据放到 Redis 中,这样下次就可以直接从内存中读取,速度可以提升几十倍甚至上百倍。
**2、高并发**
-一般像 MySQL 这类的数据库的 QPS 大概都在 1w 左右(4 核 8g) ,但是使用 Redis 缓存之后很容易达到 10w+,甚至最高能达到 30w+(就单机 Redis 的情况,Redis 集群的话会更高)。
+一般像 MySQL 这类的数据库的 QPS 大概都在 4k 左右(4 核 8g),但是使用 Redis 缓存之后很容易达到 5w+,甚至能达到 10w+(就单机 Redis 的情况,Redis 集群的话会更高)。
> QPS(Query Per Second):服务器每秒可以执行的查询次数;
由此可见,直接操作缓存能够承受的数据库请求数量是远远大于直接访问数据库的,所以我们可以考虑把数据库中的部分数据转移到缓存中去,这样用户的一部分请求会直接到缓存这里而不用经过数据库。进而,我们也就提高了系统整体的并发。
+**3、功能全面**
+
+Redis 除了可以用作缓存之外,还可以用于分布式锁、限流、消息队列、延时队列等场景,功能强大!
+
+### ⭐️为什么用 Redis 而不用本地缓存呢?
+
+| 特性 | 本地缓存 | Redis |
+| ------------ | ------------------------------------ | -------------------------------- |
+| 数据一致性 | 多服务器部署时存在数据不一致问题 | 数据一致 |
+| 内存限制 | 受限于单台服务器内存 | 独立部署,内存空间更大 |
+| 数据丢失风险 | 服务器宕机数据丢失 | 可持久化,数据不易丢失 |
+| 管理维护 | 分散,管理不便 | 集中管理,提供丰富的管理工具 |
+| 功能丰富性 | 功能有限,通常只提供简单的键值对存储 | 功能丰富,支持多种数据结构和功能 |
+
+关于本地缓存、分布式缓存和多级缓存的详细介绍,可以看我写的这篇文章:[缓存基础常见面试题总结](http://localhost:8080/database/redis/cache-basics.html)。
+
### 常见的缓存读写策略有哪些?
-关于常见的缓存读写策略的详细介绍,可以看我写的这篇文章:[3 种常用的缓存读写策略详解](https://javaguide.cn/database/redis/3-commonly-used-cache-read-and-write-strategies.html) 。
+关于常见的缓存读写策略的详细介绍,可以看我写的这篇文章:[3 种常用的缓存读写策略详解](https://javaguide.cn/database/redis/3-commonly-used-cache-read-and-write-strategies.html)。
### 什么是 Redis Module?有什么用?
@@ -125,145 +147,55 @@ Redis 从 4.0 版本开始,支持通过 Module 来扩展其功能以满足特
关于 Redis 模块的详细介绍,可以查看官方文档:。
-## Redis 应用
+## ⭐️Redis 应用
### Redis 除了做缓存,还能做什么?
-- **分布式锁**:通过 Redis 来做分布式锁是一种比较常见的方式。通常情况下,我们都是基于 Redisson 来实现分布式锁。关于 Redis 实现分布式锁的详细介绍,可以看我写的这篇文章:[分布式锁详解](https://javaguide.cn/distributed-system/distributed-lock.html) 。
-- **限流**:一般是通过 Redis + Lua 脚本的方式来实现限流。相关阅读:[《我司用了 6 年的 Redis 分布式限流器,可以说是非常厉害了!》](https://mp.weixin.qq.com/s/kyFAWH3mVNJvurQDt4vchA)。
+- **分布式锁**:通过 Redis 来做分布式锁是一种比较常见的方式。通常情况下,我们都是基于 Redisson 来实现分布式锁。关于 Redis 实现分布式锁的详细介绍,可以看我写的这篇文章:[分布式锁详解](https://javaguide.cn/distributed-system/distributed-lock.html)。
+- **限流**:一般是通过 Redis + Lua 脚本的方式来实现限流。如果不想自己写 Lua 脚本的话,也可以直接利用 Redisson 中的 `RRateLimiter` 来实现分布式限流,其底层实现就是基于 Lua 代码+令牌桶算法。
- **消息队列**:Redis 自带的 List 数据结构可以作为一个简单的队列使用。Redis 5.0 中增加的 Stream 类型的数据结构更加适合用来做消息队列。它比较类似于 Kafka,有主题和消费组的概念,支持消息持久化以及 ACK 机制。
- **延时队列**:Redisson 内置了延时队列(基于 Sorted Set 实现的)。
-- **分布式 Session** :利用 String 或者 Hash 数据类型保存 Session 数据,所有的服务器都可以访问。
-- **复杂业务场景**:通过 Redis 以及 Redis 扩展(比如 Redisson)提供的数据结构,我们可以很方便地完成很多复杂的业务场景比如通过 Bitmap 统计活跃用户、通过 Sorted Set 维护排行榜。
+- **分布式 Session**:利用 String 或者 Hash 数据类型保存 Session 数据,所有的服务器都可以访问。
+- **复杂业务场景**:通过 Redis 以及 Redis 扩展(比如 Redisson)提供的数据结构,我们可以很方便地完成很多复杂的业务场景,比如通过 Bitmap 统计活跃用户、通过 Sorted Set 维护排行榜、通过 HyperLogLog 统计网站 UV 和 PV。
- ……
### 如何基于 Redis 实现分布式锁?
-关于 Redis 实现分布式锁的详细介绍,可以看我写的这篇文章:[分布式锁详解](https://javaguide.cn/distributed-system/distributed-lock.html) 。
-
-### Redis 可以做消息队列么?
-
-> 实际项目中也没见谁使用 Redis 来做消息队列,对于这部分知识点大家了解就好了。
-
-先说结论:**可以是可以,但不建议使用 Redis 来做消息队列。和专业的消息队列相比,还是有很多欠缺的地方。**
-
-**Redis 2.0 之前,如果想要使用 Redis 来做消息队列的话,只能通过 List 来实现。**
-
-通过 `RPUSH/LPOP` 或者 `LPUSH/RPOP`即可实现简易版消息队列:
-
-```bash
-# 生产者生产消息
-> RPUSH myList msg1 msg2
-(integer) 2
-> RPUSH myList msg3
-(integer) 3
-# 消费者消费消息
-> LPOP myList
-"msg1"
-```
-
-不过,通过 `RPUSH/LPOP` 或者 `LPUSH/RPOP`这样的方式存在性能问题,我们需要不断轮询去调用 `RPOP` 或 `LPOP` 来消费消息。当 List 为空时,大部分的轮询的请求都是无效请求,这种方式大量浪费了系统资源。
-
-因此,Redis 还提供了 `BLPOP`、`BRPOP` 这种阻塞式读取的命令(带 B-Blocking 的都是阻塞式),并且还支持一个超时参数。如果 List 为空,Redis 服务端不会立刻返回结果,它会等待 List 中有新数据后在返回或者是等待最多一个超时时间后返回空。如果将超时时间设置为 0 时,即可无限等待,直到弹出消息
-
-```bash
-# 超时时间为 10s
-# 如果有数据立刻返回,否则最多等待10秒
-> BRPOP myList 10
-null
-```
-
-**List 实现消息队列功能太简单,像消息确认机制等功能还需要我们自己实现,最要命的是没有广播机制,消息也只能被消费一次。**
-
-**Redis 2.0 引入了发布订阅 (pub/sub) 功能,解决了 List 实现消息队列没有广播机制的问题。**
+关于 Redis 实现分布式锁的详细介绍,可以看我写的这篇文章:[分布式锁详解](https://javaguide.cn/distributed-system/distributed-lock-implementations.html)。
-
+### Redis 可以做消息队列么?怎么实现?
-pub/sub 中引入了一个概念叫 **channel(频道)**,发布订阅机制的实现就是基于这个 channel 来做的。
+先说结论:
-pub/sub 涉及发布者(Publisher)和订阅者(Subscriber,也叫消费者)两个角色:
+- **如果业务简单、量小、追求极致性能**,且能容忍极小概率的数据丢失,使用 **Redis Stream** 是最优解,因为它省去了部署维护 MQ 的成本,可以复用现有的 Redis 组件(大部分需要用到 MQ 的项目,通常都会需要 Redis)。
+- **如果是金融级业务、海量数据、需要严格保证不丢消息**,必须选择 **Kafka、RabbitMQ** 等更成熟的 MQ。
-- 发布者通过 `PUBLISH` 投递消息给指定 channel。
-- 订阅者通过`SUBSCRIBE`订阅它关心的 channel。并且,订阅者可以订阅一个或者多个 channel。
+这个问题还是挺重要,技术选型也能用上,我专门写了一篇文章详细介绍和分析,推荐时间充足的同学抽空认真看几遍,收藏一下:[Redis 能做消息队列吗?怎么实现?](https://javaguide.cn/database/redis/redis-stream-mq.html)。
-我们这里启动 3 个 Redis 客户端来简单演示一下:
+### 如何基于 Redis 实现延时任务?
-
-
-pub/sub 既能单播又能广播,还支持 channel 的简单正则匹配。不过,消息丢失(客户端断开连接或者 Redis 宕机都会导致消息丢失)、消息堆积(发布者发布消息的时候不会管消费者的具体消费能力如何)等问题依然没有一个比较好的解决办法。
-
-为此,Redis 5.0 新增加的一个数据结构 `Stream` 来做消息队列。`Stream` 支持:
-
-- 发布 / 订阅模式
-- 按照消费者组进行消费(借鉴了 Kafka 消费者组的概念)
-- 消息持久化( RDB 和 AOF)
-- ACK 机制(通过确认机制来告知已经成功处理了消息)
-- 阻塞式获取消息
-
-`Stream` 的结构如下:
-
-
-
-这是一个有序的消息链表,每个消息都有一个唯一的 ID 和对应的内容。ID 是一个时间戳和序列号的组合,用来保证消息的唯一性和递增性。内容是一个或多个键值对(类似 Hash 基本数据类型),用来存储消息的数据。
-
-这里再对图中涉及到的一些概念,进行简单解释:
-
-- `Consumer Group`:消费者组用于组织和管理多个消费者。消费者组本身不处理消息,而是再将消息分发给消费者,由消费者进行真正的消费
-- `last_delivered_id`:标识消费者组当前消费位置的游标,消费者组中任意一个消费者读取了消息都会使 last_delivered_id 往前移动。
-- `pending_ids`:记录已经被客户端消费但没有 ack 的消息的 ID。
-
-下面是`Stream` 用作消息队列时常用的命令:
-
-- `XADD`:向流中添加新的消息。
-- `XREAD`:从流中读取消息。
-- `XREADGROUP`:从消费组中读取消息。
-- `XRANGE`:根据消息 ID 范围读取流中的消息。
-- `XREVRANGE`:与 `XRANGE` 类似,但以相反顺序返回结果。
-- `XDEL`:从流中删除消息。
-- `XTRIM`:修剪流的长度,可以指定修建策略(`MAXLEN`/`MINID`)。
-- `XLEN`:获取流的长度。
-- `XGROUP CREATE`:创建消费者组。
-- `XGROUP DESTROY` : 删除消费者组
-- `XGROUP DELCONSUMER`:从消费者组中删除一个消费者。
-- `XGROUP SETID`:为消费者组设置新的最后递送消息 ID
-- `XACK`:确认消费组中的消息已被处理。
-- `XPENDING`:查询消费组中挂起(未确认)的消息。
-- `XCLAIM`:将挂起的消息从一个消费者转移到另一个消费者。
-- `XINFO`:获取流(`XINFO STREAM`)、消费组(`XINFO GROUPS`)或消费者(`XINFO CONSUMERS`)的详细信息。
-
-`Stream` 使用起来相对要麻烦一些,这里就不演示了。
-
-总的来说,`Stream` 已经可以满足一个消息队列的基本要求了。不过,`Stream` 在实际使用中依然会有一些小问题不太好解决比如在 Redis 发生故障恢复后不能保证消息至少被消费一次。
-
-综上,和专业的消息队列相比,使用 Redis 来实现消息队列还是有很多欠缺的地方比如消息丢失和堆积问题不好解决。因此,我们通常建议不要使用 Redis 来做消息队列,你完全可以选择市面上比较成熟的一些消息队列比如 RocketMQ、Kafka。不过,如果你就是想要用 Redis 来做消息队列的话,那我建议你优先考虑 `Stream`,这是目前相对最优的 Redis 消息队列实现。
-
-相关阅读:[Redis 消息队列发展历程 - 阿里开发者 - 2022](https://mp.weixin.qq.com/s/gCUT5TcCQRAxYkTJfTRjJw)。
-
-### Redis 可以做搜索引擎么?
-
-Redis 是可以实现全文搜索引擎功能的,需要借助 **RediSearch** ,这是一个基于 Redis 的搜索引擎模块。
-
-RediSearch 支持中文分词、聚合统计、停用词、同义词、拼写检查、标签查询、向量相似度查询、多关键词搜索、分页搜索等功能,算是一个功能比较完善的全文搜索引擎了。
+> 类似的问题:
+>
+> - 订单在 10 分钟后未支付就失效,如何用 Redis 实现?
+> - 红包 24 小时未被查收自动退还,如何用 Redis 实现?
-相比较于 Elasticsearch 来说,RediSearch 主要在下面两点上表现更优异一些:
+基于 Redis 实现延时任务的功能无非就下面两种方案:
-1. 性能更优秀:依赖 Redis 自身的高性能,基于内存操作(Elasticsearch 基于磁盘)。
-2. 较低内存占用实现快速索引:RediSearch 内部使用压缩的倒排索引,所以可以用较低的内存占用来实现索引的快速构建。
+1. Redis 过期事件监听。
+2. Redisson 内置的延时队列。
-对于小型项目的简单搜索场景来说,使用 RediSearch 来作为搜索引擎还是没有问题的(搭配 RedisJSON 使用)。
+Redis 过期事件监听存在时效性较差、丢消息、多服务实例下消息重复消费等问题,不被推荐使用。
-对于比较复杂或者数据规模较大的搜索场景还是不太建议使用 RediSearch 来作为搜索引擎,主要是因为下面这些限制和问题:
+Redisson 内置的延时队列具备下面这些优势:
-1. 数据量限制:Elasticsearch 可以支持 PB 级别的数据量,可以轻松扩展到多个节点,利用分片机制提高可用性和性能。RedisSearch 是基于 Redis 实现的,其能存储的数据量受限于 Redis 的内存容量,不太适合存储大规模的数据(内存昂贵,扩展能力较差)。
-2. 分布式能力较差:Elasticsearch 是为分布式环境设计的,可以轻松扩展到多个节点。虽然 RedisSearch 支持分布式部署,但在实际应用中可能会面临一些挑战,如数据分片、节点间通信、数据一致性等问题。
-3. 聚合功能较弱:Elasticsearch 提供了丰富的聚合功能,而 RediSearch 的聚合功能相对较弱,只支持简单的聚合操作。
-4. 生态较差:Elasticsearch 可以轻松和常见的一些系统/软件集成比如 Hadoop、Spark、Kibana,而 RedisSearch 则不具备该优势。
+1. **减少了丢消息的可能**:DelayedQueue 中的消息会被持久化,即使 Redis 宕机了,根据持久化机制,也只可能丢失一点消息,影响不大。当然了,你也可以使用扫描数据库的方法作为补偿机制。
+2. **消息不存在重复消费问题**:每个客户端都是从同一个目标队列中获取任务的,不存在重复消费的问题。
-Elasticsearch 适用于全文搜索、复杂查询、实时数据分析和聚合的场景,而 RediSearch 适用于快速数据存储、缓存和简单查询的场景。
+关于 Redis 实现延时任务的详细介绍,可以看我写的这篇文章:[如何基于 Redis 实现延时任务?](./redis-delayed-task.md)。
-## Redis 数据类型
+## ⭐️Redis 数据类型
-关于 Redis 5 种基础数据类型和 3 种特殊数据类型的详细介绍请看下面这两篇文章以及 [Redis 官方文档](https://redis.io/docs/data-types/) :
+关于 Redis 5 种基础数据类型和 3 种特殊数据类型的详细介绍请看下面这两篇文章以及 [Redis 官方文档](https://redis.io/docs/data-types/):
- [Redis 5 种基本数据类型详解](https://javaguide.cn/database/redis/redis-data-structures-01.html)
- [Redis 3 种特殊数据类型详解](https://javaguide.cn/database/redis/redis-data-structures-02.html)
@@ -285,25 +217,32 @@ String 的常见应用场景如下:
- 常规数据(比如 Session、Token、序列化后的对象、图片的路径)的缓存;
- 计数比如用户单位时间的请求数(简单限流可以用到)、页面单位时间的访问数;
-- 分布式锁(利用 `SETNX key value` 命令可以实现一个最简易的分布式锁);
+- 分布式锁(利用 `SETNX key value` 命令可以实现一个最简易的分布式锁);
- ……
关于 String 的详细介绍请看这篇文章:[Redis 5 种基本数据类型详解](https://javaguide.cn/database/redis/redis-data-structures-01.html)。
### String 还是 Hash 存储对象数据更好呢?
-- String 存储的是序列化后的对象数据,存放的是整个对象。Hash 是对对象的每个字段单独存储,可以获取部分字段的信息,也可以修改或者添加部分字段,节省网络流量。如果对象中某些字段需要经常变动或者经常需要单独查询对象中的个别字段信息,Hash 就非常适合。
-- String 存储相对来说更加节省内存,缓存相同数量的对象数据,String 消耗的内存约是 Hash 的一半。并且,存储具有多层嵌套的对象时也方便很多。如果系统对性能和资源消耗非常敏感的话,String 就非常适合。
+简单对比一下二者:
+
+- **对象存储方式**:String 存储的是序列化后的对象数据,存放的是整个对象,操作简单直接。Hash 是对对象的每个字段单独存储,可以获取部分字段的信息,也可以修改或者添加部分字段,节省网络流量。如果对象中某些字段需要经常变动或者经常需要单独查询对象中的个别字段信息,Hash 就非常适合。
+- **内存消耗**:Hash 通常比 String 更节省内存,特别是在字段较多且字段长度较短时。Redis 对小型 Hash 进行优化(如使用 ziplist 存储),进一步降低内存占用。
+- **复杂对象存储**:String 在处理多层嵌套或复杂结构的对象时更方便,因为无需处理每个字段的独立存储和操作。
+- **性能**:String 的操作通常具有 O(1) 的时间复杂度,因为它存储的是整个对象,操作简单直接,整体读写的性能较好。Hash 由于需要处理多个字段的增删改查操作,在字段较多且经常变动的情况下,可能会带来额外的性能开销。
-在绝大部分情况,我们建议使用 String 来存储对象数据即可!
+总结:
+
+- 在绝大多数情况下,**String** 更适合存储对象数据,尤其是当对象结构简单且整体读写是主要操作时。
+- 如果你需要频繁操作对象的部分字段或节省内存,**Hash** 可能是更好的选择。
### String 的底层实现是什么?
-Redis 是基于 C 语言编写的,但 Redis 的 String 类型的底层实现并不是 C 语言中的字符串(即以空字符 `\0` 结尾的字符数组),而是自己编写了 [SDS](https://github.com/antirez/sds)(Simple Dynamic String,简单动态字符串) 来作为底层实现。
+Redis 是基于 C 语言编写的,但 Redis 的 String 类型的底层实现并不是 C 语言中的字符串(即以空字符 `\0` 结尾的字符数组),而是自己编写了 [SDS](https://github.com/antirez/sds)(Simple Dynamic String,简单动态字符串)来作为底层实现。
SDS 最早是 Redis 作者为日常 C 语言开发而设计的 C 字符串,后来被应用到了 Redis 上,并经过了大量的修改完善以适合高性能操作。
-Redis7.0 的 SDS 的部分源码如下():
+Redis7.0 的 SDS 的部分源码如下():
```c
/* Note: sdshdr5 is never used, we just access the flags byte directly.
@@ -338,7 +277,7 @@ struct __attribute__ ((__packed__)) sdshdr64 {
};
```
-通过源码可以看出,SDS 共有五种实现方式 SDS_TYPE_5(并未用到)、SDS_TYPE_8、SDS_TYPE_16、SDS_TYPE_32、SDS_TYPE_64,其中只有后四种实际用到。Redis 会根据初始化的长度决定使用哪种类型,从而减少内存的使用。
+通过源码可以看出,SDS 共有五种实现方式:SDS_TYPE_5(并未用到)、SDS_TYPE_8、SDS_TYPE_16、SDS_TYPE_32、SDS_TYPE_64,其中只有后四种实际用到。Redis 会根据初始化的长度决定使用哪种类型,从而减少内存的使用。
| 类型 | 字节 | 位 |
| -------- | ---- | --- |
@@ -350,10 +289,10 @@ struct __attribute__ ((__packed__)) sdshdr64 {
对于后四种实现都包含了下面这 4 个属性:
-- `len`:字符串的长度也就是已经使用的字节数
-- `alloc`:总共可用的字符空间大小,alloc-len 就是 SDS 剩余的空间大小
-- `buf[]`:实际存储字符串的数组
-- `flags`:低三位保存类型标志
+- `len`:字符串的长度也就是已经使用的字节数。
+- `alloc`:总共可用的字符空间大小,alloc-len 就是 SDS 剩余的空间大小。
+- `buf[]`:实际存储字符串的数组。
+- `flags`:低三位保存类型标志。
SDS 相比于 C 语言中的字符串有如下提升:
@@ -395,9 +334,9 @@ struct sdshdr {
### 使用 Redis 实现一个排行榜怎么做?
-Redis 中有一个叫做 `Sorted Set` (有序集合)的数据类型经常被用在各种排行榜的场景,比如直播间送礼物的排行榜、朋友圈的微信步数排行榜、王者荣耀中的段位排行榜、话题热度排行榜等等。
+Redis 中有一个叫做 `Sorted Set`(有序集合)的数据类型经常被用在各种排行榜的场景,比如直播间送礼物的排行榜、朋友圈的微信步数排行榜、王者荣耀中的段位排行榜、话题热度排行榜等等。
-相关的一些 Redis 命令: `ZRANGE` (从小到大排序)、 `ZREVRANGE` (从大到小排序)、`ZREVRANK` (指定元素排名)。
+相关的一些 Redis 命令:`ZRANGE`(从小到大排序)、`ZREVRANGE`(从大到小排序)、`ZREVRANK`(指定元素排名)。

@@ -405,15 +344,15 @@ Redis 中有一个叫做 `Sorted Set` (有序集合)的数据类型经常被

-### Redis 的有序集合底层为什么要用跳表,而不用平衡树、红黑树或者 B+树?
+### Redis 的有序集合底层为什么要用跳表,而不用平衡树、红黑树或者 B+ 树?
这道面试题很多大厂比较喜欢问,难度还是有点大的。
- 平衡树 vs 跳表:平衡树的插入、删除和查询的时间复杂度和跳表一样都是 **O(log n)**。对于范围查询来说,平衡树也可以通过中序遍历的方式达到和跳表一样的效果。但是它的每一次插入或者删除操作都需要保证整颗树左右节点的绝对平衡,只要不平衡就要通过旋转操作来保持平衡,这个过程是比较耗时的。跳表诞生的初衷就是为了克服平衡树的一些缺点。跳表使用概率平衡而不是严格强制的平衡,因此,跳表中的插入和删除算法比平衡树的等效算法简单得多,速度也快得多。
- 红黑树 vs 跳表:相比较于红黑树来说,跳表的实现也更简单一些,不需要通过旋转和染色(红黑变换)来保证黑平衡。并且,按照区间来查找数据这个操作,红黑树的效率没有跳表高。
-- B+树 vs 跳表:B+树更适合作为数据库和文件系统中常用的索引结构之一,它的核心思想是通过可能少的 IO 定位到尽可能多的索引来获得查询数据。对于 Redis 这种内存数据库来说,它对这些并不感冒,因为 Redis 作为内存数据库它不可能存储大量的数据,所以对于索引不需要通过 B+树这种方式进行维护,只需按照概率进行随机维护即可,节约内存。而且使用跳表实现 zset 时相较前者来说更简单一些,在进行插入时只需通过索引将数据插入到链表中合适的位置再随机维护一定高度的索引即可,也不需要像 B+树那样插入时发现失衡时还需要对节点分裂与合并。
+- B+ 树 vs 跳表:B+ 树更适合作为数据库和文件系统中常用的索引结构之一,它的核心思想是通过可能少的 IO 定位到尽可能多的索引来获得查询数据。对于 Redis 这种内存数据库来说,它对这些并不感冒,因为 Redis 作为内存数据库它不可能存储大量的数据,所以对于索引不需要通过 B+ 树这种方式进行维护,只需按照概率进行随机维护即可,节约内存。而且使用跳表实现 zset 时相较前者来说更简单一些,在进行插入时只需通过索引将数据插入到链表中合适的位置再随机维护一定高度的索引即可,也不需要像 B+ 树那样插入时发现失衡时还需要对节点分裂与合并。
-另外,我还单独写了一篇文章从有序集合的基本使用到跳表的源码分析和实现,让你会对 Redis 的有序集合底层实现的跳表有着更深刻的理解和掌握 :[Redis 为什么用跳表实现有序集合](./redis-skiplist.md)。
+另外,我还单独写了一篇文章从有序集合的基本使用到跳表的源码分析和实现,让你会对 Redis 的有序集合底层实现的跳表有着更深刻的理解和掌握:[Redis 为什么用跳表实现有序集合](https://javaguide.cn/database/redis/redis-skiplist.html)。
### Set 的应用场景是什么?
@@ -421,8 +360,8 @@ Redis 中 `Set` 是一种无序集合,集合中的元素没有先后顺序但
`Set` 的常见应用场景如下:
-- 存放的数据不能重复的场景:网站 UV 统计(数据量巨大的场景还是 `HyperLogLog`更适合一些)、文章点赞、动态点赞等等。
-- 需要获取多个数据源交集、并集和差集的场景:共同好友(交集)、共同粉丝(交集)、共同关注(交集)、好友推荐(差集)、音乐推荐(差集)、订阅号推荐(差集+交集) 等等。
+- 存放的数据不能重复的场景:网站 UV 统计(数据量巨大的场景还是 `HyperLogLog` 更适合一些)、文章点赞、动态点赞等等。
+- 需要获取多个数据源交集、并集和差集的场景:共同好友(交集)、共同粉丝(交集)、共同关注(交集)、好友推荐(差集)、音乐推荐(差集)、订阅号推荐(差集+交集)等等。
- 需要随机获取数据源中的元素的场景:抽奖系统、随机点名等等。
### 使用 Set 实现抽奖系统怎么做?
@@ -431,11 +370,11 @@ Redis 中 `Set` 是一种无序集合,集合中的元素没有先后顺序但
- `SADD key member1 member2 ...`:向指定集合添加一个或多个元素。
- `SPOP key count`:随机移除并获取指定集合中一个或多个元素,适合不允许重复中奖的场景。
-- `SRANDMEMBER key count` : 随机获取指定集合中指定数量的元素,适合允许重复中奖的场景。
+- `SRANDMEMBER key count`:随机获取指定集合中指定数量的元素,适合允许重复中奖的场景。
### 使用 Bitmap 统计活跃用户怎么做?
-Bitmap 存储的是连续的二进制数字(0 和 1),通过 Bitmap, 只需要一个 bit 位来表示某个元素对应的值或者状态,key 就是对应元素本身 。我们知道 8 个 bit 可以组成一个 byte,所以 Bitmap 本身会极大的节省储存空间。
+Bitmap 存储的是连续的二进制数字(0 和 1),通过 Bitmap,只需要一个 bit 位来表示某个元素对应的值或者状态,key 就是对应元素本身。我们知道 8 个 bit 可以组成一个 byte,所以 Bitmap 本身会极大的节省储存空间。
你可以将 Bitmap 看作是一个存储二进制数字(0 和 1)的数组,数组中每个元素的下标叫做 offset(偏移量)。
@@ -454,7 +393,7 @@ Bitmap 存储的是连续的二进制数字(0 和 1),通过 Bitmap, 只需
(integer) 0
```
-统计 20210308~20210309 总活跃用户数:
+统计 20210308~20210309 总活跃用户数:
```bash
> BITOP and desk1 20210308 20210309
@@ -463,7 +402,7 @@ Bitmap 存储的是连续的二进制数字(0 和 1),通过 Bitmap, 只需
(integer) 1
```
-统计 20210308~20210309 在线活跃用户数:
+统计 20210308~20210309 在线活跃用户数:
```bash
> BITOP or desk2 20210308 20210309
@@ -472,6 +411,27 @@ Bitmap 存储的是连续的二进制数字(0 和 1),通过 Bitmap, 只需
(integer) 2
```
+### HyperLogLog 适合什么场景?
+
+HyperLogLog (HLL) 是一种非常巧妙的概率性数据结构,它专门解决一类非常棘手的大数据问题:在海量数据中,用极小的内存,估算一个集合中不重复元素的数量,也就是我们常说的基数(Cardinality)
+
+HLL 做的最核心的权衡,就是用一点点精确度的损失,来换取巨大的内存空间节省。它给出的不是一个 100%精确的数字,而是一个带有很小标准误差(Redis 中默认是 0.81%)的近似值。
+
+**基于这个核心权衡,HyperLogLog 最适合以下特征的场景:**
+
+1. **数据量巨大,内存敏感:** 这是 HLL 的主战场。比如,要统计一个亿级日活 App 的每日独立访客数。如果用传统的 Set 来存储用户 ID,一个 ID 占几十个字节,上亿个 ID 可能需要几个 GB 甚至几十 GB 的内存,这在很多场景下是不可接受的。而 HLL,在 Redis 中只需要固定的 12KB 内存,就能处理天文数字级别的基数,这是一个颠覆性的优势。
+2. **对结果的精确度要求不是 100%:** 这是使用 HLL 的前提。比如,产品经理想知道一个热门帖子的 UV(独立访客数)是大约 1000 万还是 1010 万,这个细微的差别通常不影响商业决策。但如果场景是统计一个交易系统的准确交易笔数,那 HLL 就完全不适用,因为金融场景要求 100%的精确。
+
+**所以,HyperLogLog 具体的应用场景就非常清晰了:**
+
+- **网站/App 的 UV(Unique Visitor)统计:** 比如统计首页每天有多少个不同的 IP 或用户 ID 访问过。
+- **搜索引擎关键词统计:** 统计每天有多少个不同的用户搜索了某个关键词。
+- **社交网络互动统计:** 比如统计一条微博被多少个不同的用户转发过。
+
+在这些场景下,我们关心的是数量级和趋势,而不是个位数的差异。
+
+最后,Redis 的实现还非常智能,它内部会根据基数的大小,在**稀疏矩阵**(占用空间更小)和**稠密矩阵**(固定的 12KB)之间自动切换,进一步优化了内存使用。总而言之,当您需要对海量数据进行去重计数,并且可以接受微小误差时,HyperLogLog 就是不二之选。
+
### 使用 HyperLogLog 统计页面 UV 怎么做?
使用 HyperLogLog 统计页面 UV 主要需要用到下面这两个命令:
@@ -491,19 +451,31 @@ PFADD PAGE_1:UV USER1 USER2 ...... USERn
PFCOUNT PAGE_1:UV
```
-## Redis 持久化机制(重要)
+### 如果我想判断一个元素是否不在海量元素集合中,用什么数据类型?
+
+这是布隆过滤器的经典应用场景。布隆过滤器可以告诉你一个元素一定不存在或者可能存在,它也有极高的空间效率和一定的误判率,但绝不会漏报。也就是说,布隆过滤器说某个元素存在,小概率会误判。布隆过滤器说某个元素不在,那么这个元素一定不在。
-Redis 持久化机制(RDB 持久化、AOF 持久化、RDB 和 AOF 的混合持久化) 相关的问题比较多,也比较重要,于是我单独抽了一篇文章来总结 Redis 持久化机制相关的知识点和问题:[Redis 持久化机制详解](https://javaguide.cn/database/redis/redis-persistence.html) 。
+Bloom Filter 的简单原理图如下:
-## Redis 线程模型(重要)
+
-对于读写命令来说,Redis 一直是单线程模型。不过,在 Redis 4.0 版本之后引入了多线程来执行一些大键值对的异步删除操作, Redis 6.0 版本之后引入了多线程来处理网络请求(提高网络 IO 读写性能)。
+当字符串存储要加入到布隆过滤器中时,该字符串首先由多个哈希函数生成不同的哈希值,然后将对应的位数组的下标设置为 1(当位数组初始化时,所有位置均为 0)。当第二次存储相同字符串时,因为先前的对应位置已设置为 1,所以很容易知道此值已经存在(去重非常方便)。
+
+如果我们需要判断某个字符串是否在布隆过滤器中时,只需要对给定字符串再次进行相同的哈希计算,得到值之后判断位数组中的每个元素是否都为 1,如果值都为 1,那么说明这个值在布隆过滤器中,如果存在一个值不为 1,说明该元素不在布隆过滤器中。
+
+## ⭐️Redis 持久化机制(重要)
+
+Redis 持久化机制(RDB 持久化、AOF 持久化、RDB 和 AOF 的混合持久化)相关的问题比较多,也比较重要,于是我单独抽了一篇文章来总结 Redis 持久化机制相关的知识点和问题:[Redis 持久化机制详解](https://javaguide.cn/database/redis/redis-persistence.html)。
+
+## ⭐️Redis 线程模型(重要)
+
+对于读写命令来说,Redis 一直是单线程模型。不过,在 Redis 4.0 版本之后引入了多线程来执行一些大键值对的异步删除操作,Redis 6.0 版本之后引入了多线程来处理网络请求(提高网络 IO 读写性能)。
### Redis 单线程模型了解吗?
-**Redis 基于 Reactor 模式设计开发了一套高效的事件处理模型** (Netty 的线程模型也基于 Reactor 模式,Reactor 模式不愧是高性能 IO 的基石),这套事件处理模型对应的是 Redis 中的文件事件处理器(file event handler)。由于文件事件处理器(file event handler)是单线程方式运行的,所以我们一般都说 Redis 是单线程模型。
+**Redis 基于 Reactor 模式设计开发了一套高效的事件处理模型**(Netty 的线程模型也基于 Reactor 模式,Reactor 模式不愧是高性能 IO 的基石),这套事件处理模型对应的是 Redis 中的文件事件处理器(file event handler)。由于文件事件处理器(file event handler)是单线程方式运行的,所以我们一般都说 Redis 是单线程模型。
-《Redis 设计与实现》有一段话是如是介绍文件事件处理器的,我觉得写得挺不错。
+《Redis 设计与实现》有一段话是这样介绍文件事件处理器的,我觉得写得挺不错。
> Redis 基于 Reactor 模式开发了自己的网络事件处理器:这个处理器被称为文件事件处理器(file event handler)。
>
@@ -527,27 +499,29 @@ Redis 通过 **IO 多路复用程序** 来监听来自客户端的大量连接

-相关阅读:[Redis 事件机制详解](http://remcarpediem.net/article/1aa2da89/) 。
-
### Redis6.0 之前为什么不使用多线程?
-虽然说 Redis 是单线程模型,但是,实际上,**Redis 在 4.0 之后的版本中就已经加入了对多线程的支持。**
+虽然说 Redis 是单线程模型,但实际上,**Redis 在 4.0 之后的版本中就已经加入了对多线程的支持。**
-不过,Redis 4.0 增加的多线程主要是针对一些大键值对的删除操作的命令,使用这些命令就会使用主线程之外的其他线程来“异步处理”。
+不过,Redis 4.0 增加的多线程主要是针对一些大键值对的删除操作的命令,使用这些命令就会使用主线程之外的其他线程来“异步处理”,从而减少对主线程的影响。
-为此,Redis 4.0 之后新增了`UNLINK`(可以看作是 `DEL` 的异步版本)、`FLUSHALL ASYNC`(清空所有数据库的所有 key,不仅仅是当前 `SELECT` 的数据库)、`FLUSHDB ASYNC`(清空当前 `SELECT` 数据库中的所有 key)等异步命令。
+为此,Redis 4.0 之后新增了几个异步命令:
+
+- `UNLINK`:可以看作是 `DEL` 命令的异步版本。
+- `FLUSHALL ASYNC`:用于清空所有数据库的所有键,不限于当前 `SELECT` 的数据库。
+- `FLUSHDB ASYNC`:用于清空当前 `SELECT` 数据库中的所有键。

-大体上来说,Redis 6.0 之前主要还是单线程处理。
+总的来说,直到 Redis 6.0 之前,Redis 的主要操作仍然是单线程处理的。
**那 Redis6.0 之前为什么不使用多线程?** 我觉得主要原因有 3 点:
- 单线程编程容易并且更容易维护;
-- Redis 的性能瓶颈不在 CPU ,主要在内存和网络;
+- Redis 的性能瓶颈不在 CPU,主要在内存和网络;
- 多线程就会存在死锁、线程上下文切换等问题,甚至会影响性能。
-相关阅读:[为什么 Redis 选择单线程模型?](https://draveness.me/whys-the-design-redis-single-thread/) 。
+相关阅读:[为什么 Redis 选择单线程模型?](https://draveness.me/whys-the-design-redis-single-thread/)。
### Redis6.0 之后为何引入了多线程?
@@ -566,13 +540,13 @@ io-threads 4 #设置1的话只会开启主线程,官网建议4核的机器建
- io-threads 的个数一旦设置,不能通过 config 动态设置。
- 当设置 ssl 后,io-threads 将不工作。
-开启多线程后,默认只会使用多线程进行 IO 写入 writes,即发送数据给客户端,如果需要开启多线程 IO 读取 reads,同样需要修改 redis 配置文件 `redis.conf` :
+开启多线程后,默认只会使用多线程进行 IO 写入 writes,即发送数据给客户端,如果需要开启多线程 IO 读取 reads,同样需要修改 redis 配置文件 `redis.conf`:
```bash
io-threads-do-reads yes
```
-但是官网描述开启多线程读并不能有太大提升,因此一般情况下并不建议开启
+但是官网描述开启多线程读并不能有太大提升,因此一般情况下并不建议开启。
相关阅读:
@@ -584,8 +558,8 @@ io-threads-do-reads yes
我们虽然经常说 Redis 是单线程模型(主要逻辑是单线程完成的),但实际还有一些后台线程用于执行一些比较耗时的操作:
- 通过 `bio_close_file` 后台线程来释放 AOF / RDB 等过程中产生的临时文件资源。
-- 通过 `bio_aof_fsync` 后台线程调用 `fsync` 函数将系统内核缓冲区还未同步到到磁盘的数据强制刷到磁盘( AOF 文件)。
-- 通过 `bio_lazy_free`后台线程释放大对象(已删除)占用的内存空间.
+- 通过 `bio_aof_fsync` 后台线程调用 `fsync` 函数将系统内核缓冲区还未同步到到磁盘的数据强制刷到磁盘(AOF 文件)。
+- 通过 `bio_lazy_free` 后台线程释放大对象(已删除)占用的内存空间.
在`bio.h` 文件中有定义(Redis 6.0 版本,源码地址:):
@@ -612,13 +586,13 @@ void bioKillThreads(void);
关于 Redis 后台线程的详细介绍可以查看 [Redis 6.0 后台线程有哪些?](https://juejin.cn/post/7102780434739626014) 这篇就文章。
-## Redis 内存管理
+## ⭐️Redis 内存管理
-### Redis 给缓存数据设置过期时间有啥用?
+### Redis 给缓存数据设置过期时间有什么用?
一般情况下,我们设置保存的缓存数据的时候都会设置一个过期时间。为什么呢?
-因为内存是有限的,如果缓存中的所有数据都是一直保存的话,分分钟直接 Out of memory。
+内存是有限且珍贵的,如果不对缓存数据设置过期时间,那内存占用就会一直增长,最终可能会导致 OOM 问题。通过设置合理的过期时间,Redis 会自动删除暂时不需要的数据,为新的缓存数据腾出空间。
Redis 自带了给缓存数据设置过期时间的功能,比如:
@@ -631,7 +605,7 @@ OK
(integer) 56
```
-注意:**Redis 中除了字符串类型有自己独有设置过期时间的命令 `setex` 外,其他方法都需要依靠 `expire` 命令来设置过期时间 。另外, `persist` 命令可以移除一个键的过期时间。**
+注意 ⚠️:Redis 中除了字符串类型有自己独有设置过期时间的命令 `setex` 外,其他方法都需要依靠 `expire` 命令来设置过期时间 。另外,`persist` 命令可以移除一个键的过期时间。
**过期时间除了有助于缓解内存的消耗,还有什么其他用么?**
@@ -641,9 +615,9 @@ OK
### Redis 是如何判断数据是否过期的呢?
-Redis 通过一个叫做过期字典(可以看作是 hash 表)来保存数据过期的时间。过期字典的键指向 Redis 数据库中的某个 key(键),过期字典的值是一个 long long 类型的整数,这个整数保存了 key 所指向的数据库键的过期时间(毫秒精度的 UNIX 时间戳)。
+Redis 通过一个叫做过期字典(可以看作是 hash 表)来保存数据过期的时间。过期字典的键指向 Redis 数据库中的某个 key(键),过期字典的值是一个 long long 类型的整数,这个整数保存了 key 所指向的数据库键的过期时间(毫秒精度的 UNIX 时间戳)。
-
+
过期字典是存储在 redisDb 这个结构里的:
@@ -657,43 +631,162 @@ typedef struct redisDb {
} redisDb;
```
-### 过期的数据的删除策略了解么?
+在查询一个 key 的时候,Redis 首先检查该 key 是否存在于过期字典中(时间复杂度为 O(1)),如果不在就直接返回,在的话需要判断一下这个 key 是否过期,过期直接删除 key 然后返回 null。
+
+### Redis 过期 key 删除策略了解么?
如果假设你设置了一批 key 只能存活 1 分钟,那么 1 分钟后,Redis 是怎么对这批 key 进行删除的呢?
-常用的过期数据的删除策略就两个(重要!自己造缓存轮子的时候需要格外考虑的东西):
+常用的过期数据的删除策略就下面这几种:
+
+1. **惰性删除**:只会在取出/查询 key 的时候才对数据进行过期检查。这种方式对 CPU 最友好,但是可能会造成太多过期 key 没有被删除。
+2. **定期删除**:周期性地随机从设置了过期时间的 key 中抽查一批,然后逐个检查这些 key 是否过期,过期就删除 key。相比于惰性删除,定期删除对内存更友好,对 CPU 不太友好。
+3. **延迟队列**:把设置过期时间的 key 放到一个延迟队列里,到期之后就删除 key。这种方式可以保证每个过期 key 都能被删除,但维护延迟队列太麻烦,队列本身也要占用资源。
+4. **定时删除**:每个设置了过期时间的 key 都会在设置的时间到达时立即被删除。这种方法可以确保内存中不会有过期的键,但是它对 CPU 的压力最大,因为它需要为每个键都设置一个定时器。
+
+**Redis 采用的是那种删除策略呢?**
+
+Redis 采用的是 **定期删除+惰性/懒汉式删除** 结合的策略,这也是大部分缓存框架的选择。定期删除对内存更加友好,惰性删除对 CPU 更加友好。两者各有千秋,结合起来使用既能兼顾 CPU 友好,又能兼顾内存友好。
+
+下面是我们详细介绍一下 Redis 中的定期删除具体是如何做的。
+
+Redis 的定期删除过程是随机的(周期性地随机从设置了过期时间的 key 中抽查一批),所以并不保证所有过期键都会被立即删除。这也就解释了为什么有的 key 过期了,并没有被删除。并且,Redis 底层会通过限制删除操作执行的时长和频率来减少删除操作对 CPU 时间的影响。
+
+另外,定期删除还会受到执行时间和过期 key 的比例的影响:
+
+- 执行时间已经超过了阈值,那么就中断这一次定期删除循环,以避免使用过多的 CPU 时间。
+- 如果这一批过期的 key 比例超过一个比例,就会重复执行此删除流程,以更积极地清理过期 key。相应地,如果过期的 key 比例低于这个比例,就会中断这一次定期删除循环,避免做过多的工作而获得很少的内存回收。
-1. **惰性删除**:只会在取出 key 的时候才对数据进行过期检查。这样对 CPU 最友好,但是可能会造成太多过期 key 没有被删除。
-2. **定期删除**:每隔一段时间抽取一批 key 执行删除过期 key 操作。并且,Redis 底层会通过限制删除操作执行的时长和频率来减少删除操作对 CPU 时间的影响。
+Redis 7.2 版本的执行时间阈值是 **25ms**,过期 key 比例设定值是 **10%**。
-定期删除对内存更加友好,惰性删除对 CPU 更加友好。两者各有千秋,所以 Redis 采用的是 **定期删除+惰性/懒汉式删除** 。
+```c
+#define ACTIVE_EXPIRE_CYCLE_FAST_DURATION 1000 /* Microseconds. */
+#define ACTIVE_EXPIRE_CYCLE_SLOW_TIME_PERC 25 /* Max % of CPU to use. */
+#define ACTIVE_EXPIRE_CYCLE_ACCEPTABLE_STALE 10 /* % of stale keys after which
+ we do extra efforts. */
+```
+
+**每次随机抽查数量是多少?**
+
+`expire.c` 中定义了每次随机抽查的数量,Redis 7.2 版本为 20,也就是说每次会随机选择 20 个设置了过期时间的 key 判断是否过期。
+
+```c
+#define ACTIVE_EXPIRE_CYCLE_KEYS_PER_LOOP 20 /* Keys for each DB loop. */
+```
+
+**如何控制定期删除的执行频率?**
-但是,仅仅通过给 key 设置过期时间还是有问题的。因为还是可能存在定期删除和惰性删除漏掉了很多过期 key 的情况。这样就导致大量过期 key 堆积在内存里,然后就 Out of memory 了。
+在 Redis 中,定期删除的频率是由 **hz** 参数控制的。hz 默认为 10,代表每秒执行 10 次,也就是每秒钟进行 10 次尝试来查找并删除过期的 key。
+
+hz 的取值范围为 1~500。增大 hz 参数的值会提升定期删除的频率。如果你想要更频繁地执行定期删除任务,可以适当增加 hz 的值,但这会增加 CPU 的使用率。根据 Redis 官方建议,hz 的值不建议超过 100,对于大部分用户使用默认的 10 就足够了。
+
+下面是 hz 参数的官方注释,我翻译了其中的重要信息(Redis 7.2 版本)。
+
+
+
+类似的参数还有一个 **dynamic-hz**,这个参数开启之后 Redis 就会在 hz 的基础上动态计算一个值。Redis 提供并默认启用了使用自适应 hz 值的能力,
+
+这两个参数都在 Redis 配置文件 `redis.conf` 中:
+
+```properties
+# 默认为 10
+hz 10
+# 默认开启
+dynamic-hz yes
+```
-怎么解决这个问题呢?答案就是:**Redis 内存淘汰机制。**
+多提一嘴,除了定期删除过期 key 这个定期任务之外,还有一些其他定期任务例如关闭超时的客户端连接、更新统计信息,这些定期任务的执行频率也是通过 hz 参数决定。
-### Redis 内存淘汰机制了解么?
+**为什么定期删除不是把所有过期 key 都删除呢?**
+
+这样会对性能造成太大的影响。如果我们 key 数量非常庞大的话,挨个遍历检查是非常耗时的,会严重影响性能。Redis 设计这种策略的目的是为了平衡内存和性能。
+
+**为什么 key 过期之后不立马把它删掉呢?这样不是会浪费很多内存空间吗?**
+
+因为不太好办到,或者说这种删除方式的成本太高了。假如我们使用延迟队列作为删除策略,这样存在下面这些问题:
+
+1. 队列本身的开销可能很大:key 多的情况下,一个延迟队列可能无法容纳。
+2. 维护延迟队列太麻烦:修改 key 的过期时间就需要调整其在延迟队列中的位置,并且还需要引入并发控制。
+
+### 大量 key 集中过期怎么办?
+
+当 Redis 中存在大量 key 在同一时间点集中过期时,可能会导致以下问题:
+
+- **请求延迟增加**:Redis 在处理过期 key 时需要消耗 CPU 资源,如果过期 key 数量庞大,会导致 Redis 实例的 CPU 占用率升高,进而影响其他请求的处理速度,造成延迟增加。
+- **内存占用过高**:过期的 key 虽然已经失效,但在 Redis 真正删除它们之前,仍然会占用内存空间。如果过期 key 没有及时清理,可能会导致内存占用过高,甚至引发内存溢出。
+
+为了避免这些问题,可以采取以下方案:
+
+1. **尽量避免 key 集中过期**:在设置键的过期时间时尽量随机一点。
+2. **开启 lazy free 机制**:修改 `redis.conf` 配置文件,将 `lazyfree-lazy-expire` 参数设置为 `yes`,即可开启 lazy free 机制。开启 lazy free 机制后,Redis 会在后台异步删除过期的 key,不会阻塞主线程的运行,从而降低对 Redis 性能的影响。
+
+### Redis 内存淘汰策略了解么?
> 相关问题:MySQL 里有 2000w 数据,Redis 中只存 20w 的数据,如何保证 Redis 中的数据都是热点数据?
-Redis 提供 6 种数据淘汰策略:
+Redis 的内存淘汰策略只有在运行内存达到了配置的最大内存阈值时才会触发,这个阈值是通过 `redis.conf` 的 `maxmemory` 参数来定义的。64 位操作系统下,`maxmemory` 默认为 0,表示不限制内存大小。32 位操作系统下,默认的最大内存值是 3GB。
+
+你可以使用命令 `config get maxmemory` 来查看 `maxmemory` 的值。
+
+```bash
+> config get maxmemory
+maxmemory
+0
+```
+
+Redis 提供了 6 种内存淘汰策略:
1. **volatile-lru(least recently used)**:从已设置过期时间的数据集(`server.db[i].expires`)中挑选最近最少使用的数据淘汰。
2. **volatile-ttl**:从已设置过期时间的数据集(`server.db[i].expires`)中挑选将要过期的数据淘汰。
3. **volatile-random**:从已设置过期时间的数据集(`server.db[i].expires`)中任意选择数据淘汰。
-4. **allkeys-lru(least recently used)**:当内存不足以容纳新写入数据时,在键空间中,移除最近最少使用的 key(这个是最常用的)。
+4. **allkeys-lru(least recently used)**:从数据集(`server.db[i].dict`)中移除最近最少使用的数据淘汰。
5. **allkeys-random**:从数据集(`server.db[i].dict`)中任意选择数据淘汰。
-6. **no-eviction**:禁止驱逐数据,也就是说当内存不足以容纳新写入数据时,新写入操作会报错。这个应该没人使用吧!
+6. **no-eviction**(默认内存淘汰策略):禁止驱逐数据,当内存不足以容纳新写入数据时,新写入操作会报错。
4.0 版本后增加以下两种:
7. **volatile-lfu(least frequently used)**:从已设置过期时间的数据集(`server.db[i].expires`)中挑选最不经常使用的数据淘汰。
-8. **allkeys-lfu(least frequently used)**:当内存不足以容纳新写入数据时,在键空间中,移除最不经常使用的 key。
+8. **allkeys-lfu(least frequently used)**:从数据集(`server.db[i].dict`)中移除最不经常使用的数据淘汰。
+
+`allkeys-xxx` 表示从所有的键值中淘汰数据,而 `volatile-xxx` 表示从设置了过期时间的键值中淘汰数据。
+
+`config.c` 中定义了内存淘汰策略的枚举数组:
+
+```c
+configEnum maxmemory_policy_enum[] = {
+ {"volatile-lru", MAXMEMORY_VOLATILE_LRU},
+ {"volatile-lfu", MAXMEMORY_VOLATILE_LFU},
+ {"volatile-random",MAXMEMORY_VOLATILE_RANDOM},
+ {"volatile-ttl",MAXMEMORY_VOLATILE_TTL},
+ {"allkeys-lru",MAXMEMORY_ALLKEYS_LRU},
+ {"allkeys-lfu",MAXMEMORY_ALLKEYS_LFU},
+ {"allkeys-random",MAXMEMORY_ALLKEYS_RANDOM},
+ {"noeviction",MAXMEMORY_NO_EVICTION},
+ {NULL, 0}
+};
+```
+
+你可以使用 `config get maxmemory-policy` 命令来查看当前 Redis 的内存淘汰策略。
+
+```bash
+> config get maxmemory-policy
+maxmemory-policy
+noeviction
+```
+
+可以通过 `config set maxmemory-policy 内存淘汰策略` 命令修改内存淘汰策略,立即生效,但这种方式重启 Redis 之后就失效了。修改 `redis.conf` 中的 `maxmemory-policy` 参数不会因为重启而失效,不过,需要重启之后修改才能生效。
+
+```properties
+maxmemory-policy noeviction
+```
+
+关于淘汰策略的详细说明可以参考 Redis 官方文档:。
## 参考
- 《Redis 开发与运维》
- 《Redis 设计与实现》
+- 《Redis 核心原理与实战》
- Redis 命令手册:
- RedisSearch 终极使用指南,你值得拥有!:
- WHY Redis choose single thread (vs multi threads): [https://medium.com/@jychen7/sharing-redis-single-thread-vs-multi-threads-5870bd44d153](https://medium.com/@jychen7/sharing-redis-single-thread-vs-multi-threads-5870bd44d153)
diff --git a/docs/database/redis/redis-questions-02.md b/docs/database/redis/redis-questions-02.md
index 058c4f37985..7e68719b9c8 100644
--- a/docs/database/redis/redis-questions-02.md
+++ b/docs/database/redis/redis-questions-02.md
@@ -1,15 +1,13 @@
---
title: Redis常见面试题总结(下)
+description: 最新Redis面试题总结(下):深度剖析Redis事务原理、性能优化(pipeline/Lua/bigkey/hotkey)、缓存穿透/击穿/雪崩解决方案、慢查询与内存碎片、Redis Sentinel与Cluster集群详解。助你轻松应对后端技术面试!
category: 数据库
tag:
- Redis
head:
- - meta
- name: keywords
- content: Redis基础,Redis常见数据结构,Redis线程模型,Redis内存管理,Redis事务,Redis性能优化
- - - meta
- - name: description
- content: 一篇文章总结Redis常见的知识点和面试题,涵盖Redis基础、Redis常见数据结构、Redis线程模型、Redis内存管理、Redis事务、Redis性能优化等内容。
+ content: Redis面试题,Redis事务,Redis性能优化,Redis缓存穿透,Redis缓存击穿,Redis缓存雪崩,Redis bigkey,Redis hotkey,Redis慢查询,Redis内存碎片,Redis集群,Redis Sentinel,Redis Cluster,Redis pipeline,Redis Lua脚本
---
@@ -28,7 +26,7 @@ Redis 事务实际开发中使用的非常少,功能比较鸡肋,不要将
### 如何使用 Redis 事务?
-Redis 可以通过 **`MULTI`,`EXEC`,`DISCARD` 和 `WATCH`** 等命令来实现事务(Transaction)功能。
+Redis 可以通过 **`MULTI`、`EXEC`、`DISCARD` 和 `WATCH`** 等命令来实现事务(Transaction)功能。
```bash
> MULTI
@@ -47,8 +45,8 @@ QUEUED
这个过程是这样的:
1. 开始事务(`MULTI`);
-2. 命令入队(批量操作 Redis 的命令,先进先出(FIFO)的顺序执行);
-3. 执行事务(`EXEC`)。
+2. 命令入队(批量操作 Redis 的命令,先进先出(FIFO)的顺序执行);
+3. 执行事务(`EXEC`)。
你也可以通过 [`DISCARD`](https://redis.io/commands/discard) 命令取消一个事务,它会清空事务队列中保存的所有命令。
@@ -138,10 +136,10 @@ Redis 官网相关介绍 [https://redis.io/topics/transactions](https://redis.io
Redis 的事务和我们平时理解的关系型数据库的事务不同。我们知道事务具有四大特性:**1. 原子性**,**2. 隔离性**,**3. 持久性**,**4. 一致性**。
-1. **原子性(Atomicity):** 事务是最小的执行单位,不允许分割。事务的原子性确保动作要么全部完成,要么完全不起作用;
-2. **隔离性(Isolation):** 并发访问数据库时,一个用户的事务不被其他事务所干扰,各并发事务之间数据库是独立的;
-3. **持久性(Durability):** 一个事务被提交之后。它对数据库中数据的改变是持久的,即使数据库发生故障也不应该对其有任何影响。
-4. **一致性(Consistency):** 执行事务前后,数据保持一致,多个事务对同一个数据读取的结果是相同的;
+1. **原子性(Atomicity)**:事务是最小的执行单位,不允许分割。事务的原子性确保动作要么全部完成,要么完全不起作用;
+2. **隔离性(Isolation)**:并发访问数据库时,一个用户的事务不被其他事务所干扰,各并发事务之间数据库是独立的;
+3. **持久性(Durability)**:一个事务被提交之后。它对数据库中数据的改变是持久的,即使数据库发生故障也不应该对其有任何影响;
+4. **一致性(Consistency)**:执行事务前后,数据保持一致,多个事务对同一个数据读取的结果是相同的。
Redis 事务在运行错误的情况下,除了执行过程中出现错误的命令外,其他命令都能正常执行。并且,Redis 事务是不支持回滚(roll back)操作的。因此,Redis 事务其实是不满足原子性的。
@@ -149,28 +147,28 @@ Redis 官网也解释了自己为啥不支持回滚。简单来说就是 Redis

-**相关 issue** :
+**相关 issue**:
-- [issue#452: 关于 Redis 事务不满足原子性的问题](https://github.com/Snailclimb/JavaGuide/issues/452) 。
-- [Issue#491:关于 Redis 没有事务回滚?](https://github.com/Snailclimb/JavaGuide/issues/491)
+- [issue#452: 关于 Redis 事务不满足原子性的问题](https://github.com/Snailclimb/JavaGuide/issues/452)。
+- [Issue#491:关于 Redis 没有事务回滚?](https://github.com/Snailclimb/JavaGuide/issues/491)。
### Redis 事务支持持久性吗?
-Redis 不同于 Memcached 的很重要一点就是,Redis 支持持久化,而且支持 3 种持久化方式:
+Redis 不同于 Memcached 的很重要一点就是,Redis 支持持久化,而且支持 3 种持久化方式:
-- 快照(snapshotting,RDB)
-- 只追加文件(append-only file, AOF)
-- RDB 和 AOF 的混合持久化(Redis 4.0 新增)
+- 快照(snapshotting,RDB);
+- 只追加文件(append-only file,AOF);
+- RDB 和 AOF 的混合持久化(Redis 4.0 新增)。
-与 RDB 持久化相比,AOF 持久化的实时性更好。在 Redis 的配置文件中存在三种不同的 AOF 持久化方式( `fsync`策略),它们分别是:
+与 RDB 持久化相比,AOF 持久化的实时性更好。在 Redis 的配置文件中存在三种不同的 AOF 持久化方式(`fsync` 策略),它们分别是:
```bash
-appendfsync always #每次有数据修改发生时都会调用fsync函数同步AOF文件,fsync完成后线程返回,这样会严重降低Redis的速度
+appendfsync always #每次有数据修改发生时,都会调用fsync函数同步AOF文件,fsync完成后线程返回,这样会严重降低Redis的速度
appendfsync everysec #每秒钟调用fsync函数同步一次AOF文件
appendfsync no #让操作系统决定何时进行同步,一般为30秒一次
```
-AOF 持久化的`fsync`策略为 no、everysec 时都会存在数据丢失的情况 。always 下可以基本是可以满足持久性要求的,但性能太差,实际开发过程中不会使用。
+AOF 持久化的 `fsync` 策略为 no、everysec 时都会存在数据丢失的情况。always 下可以基本是可以满足持久性要求的,但性能太差,实际开发过程中不会使用。
因此,Redis 事务的持久性也是没办法保证的。
@@ -180,44 +178,44 @@ Redis 从 2.6 版本开始支持执行 Lua 脚本,它的功能和事务非常
一段 Lua 脚本可以视作一条命令执行,一段 Lua 脚本执行过程中不会有其他脚本或 Redis 命令同时执行,保证了操作不会被其他指令插入或打扰。
-不过,如果 Lua 脚本运行时出错并中途结束,出错之后的命令是不会被执行的。并且,出错之前执行的命令是无法被撤销的,无法实现类似关系型数据库执行失败可以回滚的那种原子性效果。因此, **严格来说的话,通过 Lua 脚本来批量执行 Redis 命令实际也是不完全满足原子性的。**
+不过,如果 Lua 脚本运行时出错并中途结束,出错之后的命令是不会被执行的。并且,出错之前执行的命令是无法被撤销的,无法实现类似关系型数据库执行失败可以回滚的那种原子性效果。因此,**严格来说的话,通过 Lua 脚本来批量执行 Redis 命令实际也是不完全满足原子性的。**
如果想要让 Lua 脚本中的命令全部执行,必须保证语句语法和命令都是对的。
-另外,Redis 7.0 新增了 [Redis functions](https://redis.io/docs/manual/programmability/functions-intro/) 特性,你可以将 Redis functions 看作是比 Lua 更强大的脚本。
+另外,Redis 7.0 新增了 [Redis functions](https://redis.io/docs/latest/develop/programmability/functions-intro/) 特性,你可以将 Redis functions 看作是比 Lua 更强大的脚本。
-## Redis 性能优化(重要)
+## ⭐️Redis 性能优化(重要)
除了下面介绍的内容之外,再推荐两篇不错的文章:
-- [你的 Redis 真的变慢了吗?性能优化如何做 - 阿里开发者](https://mp.weixin.qq.com/s/nNEuYw0NlYGhuKKKKoWfcQ)
-- [Redis 常见阻塞原因总结 - JavaGuide](https://javaguide.cn/database/redis/redis-common-blocking-problems-summary.html)
+- [你的 Redis 真的变慢了吗?性能优化如何做 - 阿里开发者](https://mp.weixin.qq.com/s/nNEuYw0NlYGhuKKKKoWfcQ)。
+- [Redis 常见阻塞原因总结 - JavaGuide](https://javaguide.cn/database/redis/redis-common-blocking-problems-summary.html)。
### 使用批量操作减少网络传输
一个 Redis 命令的执行可以简化为以下 4 步:
-1. 发送命令
-2. 命令排队
-3. 命令执行
-4. 返回结果
+1. 发送命令;
+2. 命令排队;
+3. 命令执行;
+4. 返回结果。
-其中,第 1 步和第 4 步耗费时间之和称为 **Round Trip Time (RTT,往返时间)** ,也就是数据在网络上传输的时间。
+其中,第 1 步和第 4 步耗费时间之和称为 **Round Trip Time(RTT,往返时间)**,也就是数据在网络上传输的时间。
使用批量操作可以减少网络传输次数,进而有效减小网络开销,大幅减少 RTT。
-另外,除了能减少 RTT 之外,发送一次命令的 socket I/O 成本也比较高(涉及上下文切换,存在`read()`和`write()`系统调用),批量操作还可以减少 socket I/O 成本。这个在官方对 pipeline 的介绍中有提到: 。
+另外,除了能减少 RTT 之外,发送一次命令的 socket I/O 成本也比较高(涉及上下文切换,存在 `read()` 和 `write()` 系统调用),批量操作还可以减少 socket I/O 成本。这个在官方对 pipeline 的介绍中有提到:。
#### 原生批量操作命令
Redis 中有一些原生支持批量操作的命令,比如:
-- `MGET`(获取一个或多个指定 key 的值)、`MSET`(设置一个或多个指定 key 的值)、
-- `HMGET`(获取指定哈希表中一个或者多个指定字段的值)、`HMSET`(同时将一个或多个 field-value 对设置到指定哈希表中)、
+- `MGET`(获取一个或多个指定 key 的值)、`MSET`(设置一个或多个指定 key 的值)、
+- `HMGET`(获取指定哈希表中一个或者多个指定字段的值)、`HMSET`(同时将一个或多个 field-value 对设置到指定哈希表中)、
- `SADD`(向指定集合添加一个或多个元素)
- ……
-不过,在 Redis 官方提供的分片集群解决方案 Redis Cluster 下,使用这些原生批量操作命令可能会存在一些小问题需要解决。就比如说 `MGET` 无法保证所有的 key 都在同一个 **hash slot**(哈希槽)上,`MGET`可能还是需要多次网络传输,原子操作也无法保证了。不过,相较于非批量操作,还是可以节省不少网络传输次数。
+不过,在 Redis 官方提供的分片集群解决方案 Redis Cluster 下,使用这些原生批量操作命令可能会存在一些小问题需要解决。就比如说 `MGET` 无法保证所有的 key 都在同一个 **hash slot(哈希槽)** 上,`MGET`可能还是需要多次网络传输,原子操作也无法保证了。不过,相较于非批量操作,还是可以节省不少网络传输次数。
整个步骤的简化版如下(通常由 Redis 客户端实现,无需我们自己再手动实现):
@@ -227,15 +225,15 @@ Redis 中有一些原生支持批量操作的命令,比如:
如果想要解决这个多次网络传输的问题,比较常用的办法是自己维护 key 与 slot 的关系。不过这样不太灵活,虽然带来了性能提升,但同样让系统复杂性提升。
-> Redis Cluster 并没有使用一致性哈希,采用的是 **哈希槽分区** ,每一个键值对都属于一个 **hash slot**(哈希槽) 。当客户端发送命令请求的时候,需要先根据 key 通过上面的计算公示找到的对应的哈希槽,然后再查询哈希槽和节点的映射关系,即可找到目标 Redis 节点。
+> Redis Cluster 并没有使用一致性哈希,采用的是 **哈希槽分区**,每一个键值对都属于一个 **hash slot(哈希槽)**。当客户端发送命令请求的时候,需要先根据 key 通过上面的计算公式找到的对应的哈希槽,然后再查询哈希槽和节点的映射关系,即可找到目标 Redis 节点。
>
> 我在 [Redis 集群详解(付费)](https://javaguide.cn/database/redis/redis-cluster.html) 这篇文章中详细介绍了 Redis Cluster 这部分的内容,感兴趣地可以看看。
#### pipeline
-对于不支持批量操作的命令,我们可以利用 **pipeline(流水线)** 将一批 Redis 命令封装成一组,这些 Redis 命令会被一次性提交到 Redis 服务器,只需要一次网络传输。不过,需要注意控制一次批量操作的 **元素个数**(例如 500 以内,实际也和元素字节数有关),避免网络传输的数据量过大。
+对于不支持批量操作的命令,我们可以利用 **pipeline(流水线)** 将一批 Redis 命令封装成一组,这些 Redis 命令会被一次性提交到 Redis 服务器,只需要一次网络传输。不过,需要注意控制一次批量操作的 **元素个数**(例如 500 以内,实际也和元素字节数有关),避免网络传输的数据量过大。
-与`MGET`、`MSET`等原生批量操作命令一样,pipeline 同样在 Redis Cluster 上使用会存在一些小问题。原因类似,无法保证所有的 key 都在同一个 **hash slot**(哈希槽)上。如果想要使用的话,客户端需要自己维护 key 与 slot 的关系。
+与 `MGET`、`MSET` 等原生批量操作命令一样,pipeline 同样在 Redis Cluster 上使用会存在一些小问题。原因类似,无法保证所有的 key 都在同一个 **hash slot(哈希槽)** 上。如果想要使用的话,客户端需要自己维护 key 与 slot 的关系。
原生批量操作命令和 pipeline 的是有区别的,使用的时候需要注意:
@@ -252,18 +250,18 @@ Redis 中有一些原生支持批量操作的命令,比如:

-另外,pipeline 不适用于执行顺序有依赖关系的一批命令。就比如说,你需要将前一个命令的结果给后续的命令使用,pipeline 就没办法满足你的需求了。对于这种需求,我们可以使用 **Lua 脚本** 。
+另外,pipeline 不适用于执行顺序有依赖关系的一批命令。就比如说,你需要将前一个命令的结果给后续的命令使用,pipeline 就没办法满足你的需求了。对于这种需求,我们可以使用 **Lua 脚本**。
#### Lua 脚本
-Lua 脚本同样支持批量操作多条命令。一段 Lua 脚本可以视作一条命令执行,可以看作是 **原子操作** 。也就是说,一段 Lua 脚本执行过程中不会有其他脚本或 Redis 命令同时执行,保证了操作不会被其他指令插入或打扰,这是 pipeline 所不具备的。
+Lua 脚本同样支持批量操作多条命令。一段 Lua 脚本可以视作一条命令执行,可以看作是 **原子操作**。也就是说,一段 Lua 脚本执行过程中不会有其他脚本或 Redis 命令同时执行,保证了操作不会被其他指令插入或打扰,这是 pipeline 所不具备的。
并且,Lua 脚本中支持一些简单的逻辑处理比如使用命令读取值并在 Lua 脚本中进行处理,这同样是 pipeline 所不具备的。
不过, Lua 脚本依然存在下面这些缺陷:
- 如果 Lua 脚本运行时出错并中途结束,之后的操作不会进行,但是之前已经发生的写操作不会撤销,所以即使使用了 Lua 脚本,也不能实现类似数据库回滚的原子性。
-- Redis Cluster 下 Lua 脚本的原子操作也无法保证了,原因同样是无法保证所有的 key 都在同一个 **hash slot**(哈希槽)上。
+- Redis Cluster 下 Lua 脚本的原子操作也无法保证了,原因同样是无法保证所有的 key 都在同一个 **hash slot(哈希槽)** 上。
### 大量 key 集中过期问题
@@ -274,7 +272,7 @@ Lua 脚本同样支持批量操作多条命令。一段 Lua 脚本可以视作
**如何解决呢?** 下面是两种常见的方法:
1. 给 key 设置随机过期时间。
-2. 开启 lazy-free(惰性删除/延迟释放) 。lazy-free 特性是 Redis 4.0 开始引入的,指的是让 Redis 采用异步方式延迟释放 key 使用的内存,将该操作交给单独的子线程处理,避免阻塞主线程。
+2. 开启 lazy-free(惰性删除/延迟释放)。lazy-free 特性是 Redis 4.0 开始引入的,指的是让 Redis 采用异步方式延迟释放 key 使用的内存,将该操作交给单独的子线程处理,避免阻塞主线程。
个人建议不管是否开启 lazy-free,我们都尽量给 key 设置随机过期时间。
@@ -299,7 +297,7 @@ bigkey 通常是由于下面这些原因产生的:
bigkey 除了会消耗更多的内存空间和带宽,还会对性能造成比较大的影响。
-在 [Redis 常见阻塞原因总结](./redis-common-blocking-problems-summary.md)这篇文章中我们提到:大 key 还会造成阻塞问题。具体来说,主要体现在下面三个方面:
+在 [Redis 常见阻塞原因总结](./redis-common-blocking-problems-summary.md) 这篇文章中我们提到:大 key 还会造成阻塞问题。具体来说,主要体现在下面三个方面:
1. 客户端超时阻塞:由于 Redis 执行命令是单线程处理,然后在操作大 key 时会比较耗时,那么就会阻塞 Redis,从客户端这一视角看,就是很久很久都没有响应。
2. 网络阻塞:每次获取大 key 产生的网络流量较大,如果一个 key 的大小是 1 MB,每秒访问量为 1000,那么每秒会产生 1000MB 的流量,这对于普通千兆网卡的服务器来说是灾难性的。
@@ -339,13 +337,13 @@ Biggest string found '"ballcat:oauth:refresh_auth:f6cdb384-9a9d-4f2f-af01-dc3f28
0 zsets with 0 members (00.00% of keys, avg size 0.00
```
-从这个命令的运行结果,我们可以看出:这个命令会扫描(Scan) Redis 中的所有 key ,会对 Redis 的性能有一点影响。并且,这种方式只能找出每种数据结构 top 1 bigkey(占用内存最大的 String 数据类型,包含元素最多的复合数据类型)。然而,一个 key 的元素多并不代表占用内存也多,需要我们根据具体的业务情况来进一步判断。
+从这个命令的运行结果,我们可以看出:这个命令会扫描(Scan)Redis 中的所有 key,会对 Redis 的性能有一点影响。并且,这种方式只能找出每种数据结构 top 1 bigkey(占用内存最大的 String 数据类型,包含元素最多的复合数据类型)。然而,一个 key 的元素多并不代表占用内存也多,需要我们根据具体的业务情况来进一步判断。
在线上执行该命令时,为了降低对 Redis 的影响,需要指定 `-i` 参数控制扫描的频率。`redis-cli -p 6379 --bigkeys -i 3` 表示扫描过程中每次扫描后休息的时间间隔为 3 秒。
**2、使用 Redis 自带的 SCAN 命令**
-`SCAN` 命令可以按照一定的模式和数量返回匹配的 key。获取了 key 之后,可以利用 `STRLEN`、`HLEN`、`LLEN`等命令返回其长度或成员数量。
+`SCAN` 命令可以按照一定的模式和数量返回匹配的 key。获取了 key 之后,可以利用 `STRLEN`、`HLEN`、`LLEN` 等命令返回其长度或成员数量。
| 数据结构 | 命令 | 复杂度 | 结果(对应 key) |
| ---------- | ------ | ------ | ------------------ |
@@ -363,14 +361,14 @@ Biggest string found '"ballcat:oauth:refresh_auth:f6cdb384-9a9d-4f2f-af01-dc3f28
网上有现成的代码/工具可以直接拿来使用:
-- [redis-rdb-tools](https://github.com/sripathikrishnan/redis-rdb-tools):Python 语言写的用来分析 Redis 的 RDB 快照文件用的工具
-- [rdb_bigkeys](https://github.com/weiyanwei412/rdb_bigkeys) : Go 语言写的用来分析 Redis 的 RDB 快照文件用的工具,性能更好。
+- [redis-rdb-tools](https://github.com/sripathikrishnan/redis-rdb-tools):Python 语言写的用来分析 Redis 的 RDB 快照文件用的工具。
+- [rdb_bigkeys](https://github.com/weiyanwei412/rdb_bigkeys):Go 语言写的用来分析 Redis 的 RDB 快照文件用的工具,性能更好。
**4、借助公有云的 Redis 分析服务。**
如果你用的是公有云的 Redis 服务的话,可以看看其是否提供了 key 分析功能(一般都提供了)。
-这里以阿里云 Redis 为例说明,它支持 bigkey 实时分析、发现,文档地址: 。
+这里以阿里云 Redis 为例说明,它支持 bigkey 实时分析、发现,文档地址:。

@@ -381,7 +379,7 @@ bigkey 的常见处理以及优化办法如下(这些方法可以配合起来
- **分割 bigkey**:将一个 bigkey 分割为多个小 key。例如,将一个含有上万字段数量的 Hash 按照一定策略(比如二次哈希)拆分为多个 Hash。
- **手动清理**:Redis 4.0+ 可以使用 `UNLINK` 命令来异步删除一个或多个指定的 key。Redis 4.0 以下可以考虑使用 `SCAN` 命令结合 `DEL` 命令来分批次删除。
- **采用合适的数据结构**:例如,文件二进制数据不使用 String 保存、使用 HyperLogLog 统计页面 UV、Bitmap 保存状态信息(0/1)。
-- **开启 lazy-free(惰性删除/延迟释放)** :lazy-free 特性是 Redis 4.0 开始引入的,指的是让 Redis 采用异步方式延迟释放 key 使用的内存,将该操作交给单独的子线程处理,避免阻塞主线程。
+- **开启 lazy-free(惰性删除/延迟释放)**:lazy-free 特性是 Redis 4.0 开始引入的,指的是让 Redis 采用异步方式延迟释放 key 使用的内存,将该操作交给单独的子线程处理,避免阻塞主线程。
### Redis hotkey(热 Key)
@@ -432,13 +430,13 @@ maxmemory-policy allkeys-lfu
需要注意的是,`hotkeys` 参数命令也会增加 Redis 实例的 CPU 和内存消耗(全局扫描),因此需要谨慎使用。
-**2、使用`MONITOR` 命令。**
+**2、使用 `MONITOR` 命令。**
`MONITOR` 命令是 Redis 提供的一种实时查看 Redis 的所有操作的方式,可以用于临时监控 Redis 实例的操作情况,包括读写、删除等操作。
由于该命令对 Redis 性能的影响比较大,因此禁止长时间开启 `MONITOR`(生产环境中建议谨慎使用该命令)。
-```java
+```bash
# redis-cli
127.0.0.1:6379> MONITOR
OK
@@ -473,7 +471,7 @@ OK
如果你用的是公有云的 Redis 服务的话,可以看看其是否提供了 key 分析功能(一般都提供了)。
-这里以阿里云 Redis 为例说明,它支持 hotkey 实时分析、发现,文档地址: 。
+这里以阿里云 Redis 为例说明,它支持 hotkey 实时分析、发现,文档地址:。

@@ -497,16 +495,16 @@ hotkey 的常见处理以及优化办法如下(这些方法可以配合起来
我们知道一个 Redis 命令的执行可以简化为以下 4 步:
-1. 发送命令
-2. 命令排队
-3. 命令执行
-4. 返回结果
+1. 发送命令;
+2. 命令排队;
+3. 命令执行;
+4. 返回结果。
Redis 慢查询统计的是命令执行这一步骤的耗时,慢查询命令也就是那些命令执行时间较长的命令。
Redis 为什么会有慢查询命令呢?
-Redis 中的大部分命令都是 O(1)时间复杂度,但也有少部分 O(n) 时间复杂度的命令,例如:
+Redis 中的大部分命令都是 O(1) 时间复杂度,但也有少部分 O(n) 时间复杂度的命令,例如:
- `KEYS *`:会返回所有符合规则的 key。
- `HGETALL`:会返回一个 Hash 中所有的键值对。
@@ -517,23 +515,25 @@ Redis 中的大部分命令都是 O(1)时间复杂度,但也有少部分 O(n)
由于这些命令时间复杂度是 O(n),有时候也会全表扫描,随着 n 的增大,执行耗时也会越长。不过, 这些命令并不是一定不能使用,但是需要明确 N 的值。另外,有遍历的需求可以使用 `HSCAN`、`SSCAN`、`ZSCAN` 代替。
-除了这些 O(n)时间复杂度的命令可能会导致慢查询之外, 还有一些时间复杂度可能在 O(N) 以上的命令,例如:
+除了这些 O(n) 时间复杂度的命令可能会导致慢查询之外,还有一些时间复杂度可能在 O(N) 以上的命令,例如:
-- `ZRANGE`/`ZREVRANGE`:返回指定 Sorted Set 中指定排名范围内的所有元素。时间复杂度为 O(log(n)+m),n 为所有元素的数量, m 为返回的元素数量,当 m 和 n 相当大时,O(n) 的时间复杂度更小。
-- `ZREMRANGEBYRANK`/`ZREMRANGEBYSCORE`:移除 Sorted Set 中指定排名范围/指定 score 范围内的所有元素。时间复杂度为 O(log(n)+m),n 为所有元素的数量, m 被删除元素的数量,当 m 和 n 相当大时,O(n) 的时间复杂度更小。
+- `ZRANGE`/`ZREVRANGE`:返回指定 Sorted Set 中指定排名范围内的所有元素。时间复杂度为 O(log(n)+m),n 为所有元素的数量,m 为返回的元素数量,当 m 和 n 相当大时,O(n) 的时间复杂度更小。
+- `ZREMRANGEBYRANK`/`ZREMRANGEBYSCORE`:移除 Sorted Set 中指定排名范围/指定 score 范围内的所有元素。时间复杂度为 O(log(n)+m),n 为所有元素的数量,m 被删除元素的数量,当 m 和 n 相当大时,O(n) 的时间复杂度更小。
- ……
#### 如何找到慢查询命令?
+Redis 提供了一个内置的**慢查询日志 (Slow Log)** 功能,专门用来记录执行时间超过指定阈值的命令。这对于排查性能瓶颈、找出导致 Redis 阻塞的“慢”操作非常有帮助,原理和 MySQL 的慢查询日志类似。
+
在 `redis.conf` 文件中,我们可以使用 `slowlog-log-slower-than` 参数设置耗时命令的阈值,并使用 `slowlog-max-len` 参数设置耗时命令的最大记录条数。
-当 Redis 服务器检测到执行时间超过 `slowlog-log-slower-than`阈值的命令时,就会将该命令记录在慢查询日志(slow log) 中,这点和 MySQL 记录慢查询语句类似。当慢查询日志超过设定的最大记录条数之后,Redis 会把最早的执行命令依次舍弃。
+当 Redis 服务器检测到执行时间超过 `slowlog-log-slower-than` 阈值的命令时,就会将该命令记录在慢查询日志(slow log)中,这点和 MySQL 记录慢查询语句类似。当慢查询日志超过设定的最大记录条数之后,Redis 会把最早的执行命令依次舍弃。
-⚠️注意:由于慢查询日志会占用一定内存空间,如果设置最大记录条数过大,可能会导致内存占用过高的问题。
+⚠️ 注意:由于慢查询日志会占用一定内存空间,如果设置最大记录条数过大,可能会导致内存占用过高的问题。
-`slowlog-log-slower-than`和`slowlog-max-len`的默认配置如下(可以自行修改):
+`slowlog-log-slower-than` 和 `slowlog-max-len` 的默认配置如下(可以自行修改):
-```nginx
+```properties
# The following time is expressed in microseconds, so 1000000 is equivalent
# to one second. Note that a negative number disables the slow log, while
# a value of zero forces the logging of every command.
@@ -553,9 +553,9 @@ CONFIG SET slowlog-log-slower-than 10000
CONFIG SET slowlog-max-len 128
```
-获取慢查询日志的内容很简单,直接使用`SLOWLOG GET` 命令即可。
+获取慢查询日志的内容很简单,直接使用 `SLOWLOG GET` 命令即可。
-```java
+```bash
127.0.0.1:6379> SLOWLOG GET #慢日志查询
1) 1) (integer) 5
2) (integer) 1684326682
@@ -569,14 +569,14 @@ CONFIG SET slowlog-max-len 128
慢查询日志中的每个条目都由以下六个值组成:
-1. 唯一渐进的日志标识符。
-2. 处理记录命令的 Unix 时间戳。
-3. 执行所需的时间量,以微秒为单位。
-4. 组成命令参数的数组。
-5. 客户端 IP 地址和端口。
-6. 客户端名称。
+1. **唯一 ID**: 日志条目的唯一标识符。
+2. **时间戳 (Timestamp)**: 命令执行完成时的 Unix 时间戳。
+3. **耗时 (Duration)**: 命令执行所花费的时间,单位是**微秒**。
+4. **命令及参数 (Command)**: 执行的具体命令及其参数数组。
+5. **客户端信息 (Client IP:Port)**: 执行命令的客户端地址和端口。
+6. **客户端名称 (Client Name)**: 如果客户端设置了名称 (CLIENT SETNAME)。
-`SLOWLOG GET` 命令默认返回最近 10 条的的慢查询命令,你也自己可以指定返回的慢查询命令的数量 `SLOWLOG GET N`。
+`SLOWLOG GET` 命令默认返回最近 10 条的慢查询命令,你也自己可以指定返回的慢查询命令的数量 `SLOWLOG GET N`。
下面是其他比较常用的慢查询相关的命令:
@@ -593,18 +593,18 @@ OK
**相关问题**:
-1. 什么是内存碎片?为什么会有 Redis 内存碎片?
+1. 什么是内存碎片?为什么会有 Redis 内存碎片?
2. 如何清理 Redis 内存碎片?
**参考答案**:[Redis 内存碎片详解](https://javaguide.cn/database/redis/redis-memory-fragmentation.html)。
-## Redis 生产问题(重要)
+## ⭐️Redis 生产问题(重要)
### 缓存穿透
#### 什么是缓存穿透?
-缓存穿透说简单点就是大量请求的 key 是不合理的,**根本不存在于缓存中,也不存在于数据库中** 。这就导致这些请求直接到了数据库上,根本没有经过缓存这一层,对数据库造成了巨大的压力,可能直接就被这么多请求弄宕机了。
+缓存穿透说简单点就是大量请求的 key 是不合理的,**根本不存在于缓存中,也不存在于数据库中**。这就导致这些请求直接到了数据库上,根本没有经过缓存这一层,对数据库造成了巨大的压力,可能直接就被这么多请求弄宕机了。

@@ -616,9 +616,9 @@ OK
**1)缓存无效 key**
-如果缓存和数据库都查不到某个 key 的数据就写一个到 Redis 中去并设置过期时间,具体命令如下:`SET key value EX 10086` 。这种方式可以解决请求的 key 变化不频繁的情况,如果黑客恶意攻击,每次构建不同的请求 key,会导致 Redis 中缓存大量无效的 key 。很明显,这种方案并不能从根本上解决此问题。如果非要用这种方式来解决穿透问题的话,尽量将无效的 key 的过期时间设置短一点比如 1 分钟。
+如果缓存和数据库都查不到某个 key 的数据,就写一个到 Redis 中去并设置过期时间,具体命令如下:`SET key value EX 10086`。这种方式可以解决请求的 key 变化不频繁的情况,如果黑客恶意攻击,每次构建不同的请求 key,会导致 Redis 中缓存大量无效的 key。很明显,这种方案并不能从根本上解决此问题。如果非要用这种方式来解决穿透问题的话,尽量将无效的 key 的过期时间设置短一点,比如 1 分钟。
-另外,这里多说一嘴,一般情况下我们是这样设计 key 的:`表名:列名:主键名:主键值` 。
+另外,这里多说一嘴,一般情况下我们是这样设计 key 的:`表名:列名:主键名:主键值`。
如果用 Java 代码展示的话,差不多是下面这样的:
@@ -655,21 +655,25 @@ Bloom Filter 会使用一个较大的 bit 数组来保存所有的数据,数
具体是这样做的:把所有可能存在的请求的值都存放在布隆过滤器中,当用户请求过来,先判断用户发来的请求的值是否存在于布隆过滤器中。不存在的话,直接返回请求参数错误信息给客户端,存在的话才会走下面的流程。
-加入布隆过滤器之后的缓存处理流程图如下。
+加入布隆过滤器之后的缓存处理流程图如下:

-更多关于布隆过滤器的详细介绍可以看看我的这篇原创:[不了解布隆过滤器?一文给你整的明明白白!](https://javaguide.cn/cs-basics/data-structure/bloom-filter.html) ,强烈推荐。
+更多关于布隆过滤器的详细介绍可以看看我的这篇原创:[不了解布隆过滤器?一文给你整的明明白白!](https://javaguide.cn/cs-basics/data-structure/bloom-filter.html),强烈推荐。
**3)接口限流**
根据用户或者 IP 对接口进行限流,对于异常频繁的访问行为,还可以采取黑名单机制,例如将异常 IP 列入黑名单。
+后面提到的缓存击穿和雪崩都可以配合接口限流来解决,毕竟这些问题的关键都是有很多请求落到了数据库上造成数据库压力过大。
+
+限流的具体方案可以参考这篇文章:[服务限流详解](https://javaguide.cn/high-availability/limit-request.html)。
+
### 缓存击穿
#### 什么是缓存击穿?
-缓存击穿中,请求的 key 对应的是 **热点数据** ,该数据 **存在于数据库中,但不存在于缓存中(通常是因为缓存中的那份数据已经过期)** 。这就可能会导致瞬时大量的请求直接打到了数据库上,对数据库造成了巨大的压力,可能直接就被这么多请求弄宕机了。
+缓存击穿中,请求的 key 对应的是 **热点数据**,该数据 **存在于数据库中,但不存在于缓存中(通常是因为缓存中的那份数据已经过期)**。这就可能会导致瞬时大量的请求直接打到了数据库上,对数据库造成了巨大的压力,可能直接就被这么多请求弄宕机了。

@@ -677,9 +681,9 @@ Bloom Filter 会使用一个较大的 bit 数组来保存所有的数据,数
#### 有哪些解决办法?
-1. 设置热点数据永不过期或者过期时间比较长。
-2. 针对热点数据提前预热,将其存入缓存中并设置合理的过期时间比如秒杀场景下的数据在秒杀结束之前不过期。
-3. 请求数据库写数据到缓存之前,先获取互斥锁,保证只有一个请求会落到数据库上,减少数据库的压力。
+1. **永不过期**(不推荐):设置热点数据永不过期或者过期时间比较长。
+2. **提前预热**(推荐):针对热点数据提前预热,将其存入缓存中并设置合理的过期时间比如秒杀场景下的数据在秒杀结束之前不过期。
+3. **加锁**(看情况):在缓存失效后,通过设置互斥锁确保只有一个请求去查询数据库并更新缓存。
#### 缓存穿透和缓存击穿有什么区别?
@@ -699,23 +703,22 @@ Bloom Filter 会使用一个较大的 bit 数组来保存所有的数据,数

-举个例子:数据库中的大量数据在同一时间过期,这个时候突然有大量的请求需要访问这些过期的数据。这就导致大量的请求直接落到数据库上,对数据库造成了巨大的压力。
+举个例子:缓存中的大量数据在同一时间过期,这个时候突然有大量的请求需要访问这些过期的数据。这就导致大量的请求直接落到数据库上,对数据库造成了巨大的压力。
#### 有哪些解决办法?
-**针对 Redis 服务不可用的情况:**
+**针对 Redis 服务不可用的情况**:
-1. 采用 Redis 集群,避免单机出现问题整个缓存服务都没办法使用。
-2. 限流,避免同时处理大量的请求。
-3. 多级缓存,例如本地缓存+Redis 缓存的组合,当 Redis 缓存出现问题时,还可以从本地缓存中获取到部分数据。
+1. **Redis 集群**:采用 Redis 集群,避免单机出现问题整个缓存服务都没办法使用。Redis Cluster 和 Redis Sentinel 是两种最常用的 Redis 集群实现方案,详细介绍可以参考:[Redis 集群详解(付费)](https://javaguide.cn/database/redis/redis-cluster.html)。
+2. **多级缓存**:设置多级缓存,例如本地缓存+Redis 缓存的二级缓存组合,当 Redis 缓存出现问题时,还可以从本地缓存中获取到部分数据。
-**针对热点缓存失效的情况:**
+**针对大量缓存同时失效的情况**:
-1. 设置不同的失效时间比如随机设置缓存的失效时间。
-2. 缓存永不失效(不太推荐,实用性太差)。
-3. 缓存预热,也就是在程序启动后或运行过程中,主动将热点数据加载到缓存中。
+1. **设置随机失效时间**(可选):为缓存设置随机的失效时间,例如在固定过期时间的基础上加上一个随机值,这样可以避免大量缓存同时到期,从而减少缓存雪崩的风险。
+2. **提前预热**(推荐):针对热点数据提前预热,将其存入缓存中并设置合理的过期时间,比如秒杀场景下的数据在秒杀结束之前不过期。
+3. **持久缓存策略**(看情况):虽然一般不推荐设置缓存永不过期,但对于某些关键性和变化不频繁的数据,可以考虑这种策略。
-**缓存预热如何实现?**
+#### 缓存预热如何实现?
常见的缓存预热方式有两种:
@@ -724,44 +727,70 @@ Bloom Filter 会使用一个较大的 bit 数组来保存所有的数据,数
#### 缓存雪崩和缓存击穿有什么区别?
-缓存雪崩和缓存击穿比较像,但缓存雪崩导致的原因是缓存中的大量或者所有数据失效,缓存击穿导致的原因主要是某个热点数据不存在与缓存中(通常是因为缓存中的那份数据已经过期)。
+缓存雪崩和缓存击穿比较像,但缓存雪崩导致的原因是缓存中的大量或者所有数据失效,缓存击穿导致的原因主要是某个热点数据不存在于缓存中(通常是因为缓存中的那份数据已经过期)。
### 如何保证缓存和数据库数据的一致性?
-细说的话可以扯很多,但是我觉得其实没太大必要(小声 BB:很多解决方案我也没太弄明白)。我个人觉得引入缓存之后,如果为了短时间的不一致性问题,选择让系统设计变得更加复杂的话,完全没必要。
+缓存和数据库一致性是个挺常见的技术挑战。引入缓存主要是为了提升性能、减轻数据库压力,但确实会带来数据不一致的风险。绝对的一致性往往意味着更高的系统复杂度和性能开销,所以实践中我们通常会根据业务场景选择合适的策略,在性能和一致性之间找到一个平衡点。
+
+下面单独对 **Cache Aside Pattern(旁路缓存模式)** 来聊聊。这是非常常用的一种缓存读写策略,它的读写逻辑是这样的:
+
+- **读操作**:
+ 1. 先尝试从缓存读取数据。
+ 2. 如果缓存命中,直接返回数据。
+ 3. 如果缓存未命中,从数据库查询数据,将查到的数据放入缓存并返回数据。
+- **写操作**:
+ 1. 先更新数据库。
+ 2. 再直接删除缓存中对应的数据。
+
+图解如下:
-下面单独对 **Cache Aside Pattern(旁路缓存模式)** 来聊聊。
+
-Cache Aside Pattern 中遇到写请求是这样的:更新 DB,然后直接删除 cache 。
+
-如果更新数据库成功,而删除缓存这一步失败的情况的话,简单说两个解决方案:
+如果更新数据库成功,而删除缓存这一步失败的情况的话,简单说有两个解决方案:
-1. **缓存失效时间变短(不推荐,治标不治本)**:我们让缓存数据的过期时间变短,这样的话缓存就会从数据库中加载数据。另外,这种解决办法对于先操作缓存后操作数据库的场景不适用。
-2. **增加 cache 更新重试机制(常用)**:如果 cache 服务当前不可用导致缓存删除失败的话,我们就隔一段时间进行重试,重试次数可以自己定。如果多次重试还是失败的话,我们可以把当前更新失败的 key 存入队列中,等缓存服务可用之后,再将缓存中对应的 key 删除即可。
+1. **缓存失效时间(TTL - Time To Live)变短**(不推荐,治标不治本):我们让缓存数据的过期时间变短,这样的话缓存就会从数据库中加载数据。另外,这种解决办法对于先操作缓存后操作数据库的场景不适用。
+2. **增加缓存更新重试机制**(常用):如果缓存服务当前不可用导致缓存删除失败的话,我们就隔一段时间进行重试,重试次数可以自己定。不过,这里更适合引入消息队列实现异步重试,将删除缓存重试的消息投递到消息队列,然后由专门的消费者来重试,直到成功。虽然说多引入了一个消息队列,但其整体带来的收益还是要更高一些。
相关文章推荐:[缓存和数据库一致性问题,看这篇就够了 - 水滴与银弹](https://mp.weixin.qq.com/s?__biz=MzIyOTYxNDI5OA==&mid=2247487312&idx=1&sn=fa19566f5729d6598155b5c676eee62d&chksm=e8beb8e5dfc931f3e35655da9da0b61c79f2843101c130cf38996446975014f958a6481aacf1&scene=178&cur_album_id=1699766580538032128#rd)。
### 哪些情况可能会导致 Redis 阻塞?
-单独抽了一篇文章来总结可能会导致 Redis 阻塞的情况:[Redis 常见阻塞原因总结](https://javaguide.cn/database/redis/redis-common-blocking-problems-summary.html)。
+常见的导致 Redis 阻塞原因有:
+
+- `O(n)` 复杂度命令执行(如 `KEYS *`、`HGETALL`、`LRANGE`、`SMEMBERS` 等),随着数据量增大导致执行时间过长。
+- 执行 `SAVE` 命令生成 RDB 快照时同步阻塞主线程,而 `BGSAVE` 通过 `fork` 子进程避免阻塞。
+- AOF 记录日志在主线程中进行,可能因命令执行后写日志而阻塞后续命令。
+- AOF 刷盘(fsync)时后台线程同步到磁盘,磁盘压力大导致 `fsync` 阻塞,进而阻塞主线程 `write` 操作,尤其在 `appendfsync always` 或 `everysec` 配置下明显。
+- AOF 重写过程中将重写缓冲区内容追加到新 AOF 文件时产生阻塞。
+- 操作大 key(string > 1MB 或复合类型元素 > 5000)导致客户端超时、网络阻塞和工作线程阻塞。
+- 使用 `flushdb` 或 `flushall` 清空数据库时涉及大量键值对删除和内存释放,造成主线程阻塞。
+- 集群扩容缩容时数据迁移为同步操作,大 key 迁移导致两端节点长时间阻塞,可能触发故障转移
+- 内存不足触发 Swap,操作系统将 Redis 内存换出到硬盘,读写性能急剧下降。
+- 其他进程过度占用 CPU 导致 Redis 吞吐量下降。
+- 网络问题如连接拒绝、延迟高、网卡软中断等导致 Redis 阻塞。
+
+详细介绍可以阅读这篇文章:[Redis 常见阻塞原因总结](https://javaguide.cn/database/redis/redis-common-blocking-problems-summary.html)。
## Redis 集群
**Redis Sentinel**:
1. 什么是 Sentinel? 有什么用?
-2. Sentinel 如何检测节点是否下线?主观下线与客观下线的区别?
+2. Sentinel 如何检测节点是否下线?主观下线与客观下线的区别?
3. Sentinel 是如何实现故障转移的?
4. 为什么建议部署多个 sentinel 节点(哨兵集群)?
-5. Sentinel 如何选择出新的 master(选举机制)?
-6. 如何从 Sentinel 集群中选择出 Leader ?
+5. Sentinel 如何选择出新的 master(选举机制)?
+6. 如何从 Sentinel 集群中选择出 Leader?
7. Sentinel 可以防止脑裂吗?
**Redis Cluster**:
1. 为什么需要 Redis Cluster?解决了什么问题?有什么优势?
2. Redis Cluster 是如何分片的?
-3. 为什么 Redis Cluster 的哈希槽是 16384 个?
+3. 为什么 Redis Cluster 的哈希槽是 16384 个?
4. 如何确定给定 key 的应该分布到哪个哈希槽中?
5. Redis Cluster 支持重新分配哈希槽吗?
6. Redis Cluster 扩容缩容期间可以提供服务吗?
@@ -774,23 +803,21 @@ Cache Aside Pattern 中遇到写请求是这样的:更新 DB,然后直接删
实际使用 Redis 的过程中,我们尽量要准守一些常见的规范,比如:
1. 使用连接池:避免频繁创建关闭客户端连接。
-2. 尽量不使用 O(n)指令,使用 O(n) 命令时要关注 n 的数量:像 `KEYS *`、`HGETALL`、`LRANGE`、`SMEMBERS`、`SINTER`/`SUNION`/`SDIFF`等 O(n) 命令并非不能使用,但是需要明确 n 的值。另外,有遍历的需求可以使用 `HSCAN`、`SSCAN`、`ZSCAN` 代替。
-3. 使用批量操作减少网络传输:原生批量操作命令(比如 `MGET`、`MSET`等等)、pipeline、Lua 脚本。
-4. 尽量不适用 Redis 事务:Redis 事务实现的功能比较鸡肋,可以使用 Lua 脚本代替。
+2. 尽量不使用 O(n) 指令,使用 O(n) 命令时要关注 n 的数量:像 `KEYS *`、`HGETALL`、`LRANGE`、`SMEMBERS`、`SINTER`/`SUNION`/`SDIFF` 等 O(n) 命令并非不能使用,但是需要明确 n 的值。另外,有遍历的需求可以使用 `HSCAN`、`SSCAN`、`ZSCAN` 代替。
+3. 使用批量操作减少网络传输:原生批量操作命令(比如 `MGET`、`MSET` 等等)、pipeline、Lua 脚本。
+4. 尽量不使用 Redis 事务:Redis 事务实现的功能比较鸡肋,可以使用 Lua 脚本代替。
5. 禁止长时间开启 monitor:对性能影响比较大。
6. 控制 key 的生命周期:避免 Redis 中存放了太多不经常被访问的数据。
7. ……
-相关文章推荐:[阿里云 Redis 开发规范](https://developer.aliyun.com/article/531067) 。
-
## 参考
- 《Redis 开发与运维》
- 《Redis 设计与实现》
-- Redis Transactions :
+- Redis Transactions:
- What is Redis Pipeline:
- 一文详解 Redis 中 BigKey、HotKey 的发现与处理:
-- Bigkey 问题的解决思路与方式探索:
+- Bigkey 问题的解决思路与方式探索:
- Redis 延迟问题全面排障指南:
diff --git a/docs/database/redis/redis-skiplist.md b/docs/database/redis/redis-skiplist.md
index ab767baf381..d50ee58706f 100644
--- a/docs/database/redis/redis-skiplist.md
+++ b/docs/database/redis/redis-skiplist.md
@@ -1,8 +1,13 @@
---
title: Redis为什么用跳表实现有序集合
+description: 深入讲解Redis有序集合Zset为何选择跳表而非红黑树、B+树实现,详解跳表的数据结构原理、时间复杂度分析和Redis源码实现。
category: 数据库
tag:
- Redis
+head:
+ - - meta
+ - name: keywords
+ content: Redis跳表,SkipList,有序集合,Zset,跳表原理,平衡树对比,Redis数据结构
---
## 前言
@@ -19,7 +24,7 @@ tag:
这里我们需要先了解一下 Redis 用到跳表的数据结构有序集合的使用,Redis 有个比较常用的数据结构叫**有序集合(sorted set,简称 zset)**,正如其名它是一个可以保证有序且元素唯一的集合,所以它经常用于排行榜等需要进行统计排列的场景。
-这里我们通过命令行的形式演示一下排行榜的实现,可以看到笔者分输入 3 名用户:**xiaoming**、**xiaohong**、**xiaowang**,它们的**score**分别是 60、80、60,最终按照成绩升级降序排列。
+这里我们通过命令行的形式演示一下排行榜的实现,可以看到笔者分别输入 3 名用户:**xiaoming**、**xiaohong**、**xiaowang**,它们的**score**分别是 60、80、60,最终按照成绩升级降序排列。
```bash
@@ -40,14 +45,14 @@ tag:
6) "60"
```
-此时我们通过 `object` 指令查看 zset 的数据结构,可以看到当前有序集合存储的还是是**ziplist(压缩列表)**。
+此时我们通过 `object` 指令查看 zset 的数据结构,可以看到当前有序集合存储的还是**ziplist(压缩列表)**。
```bash
127.0.0.1:6379> object encoding rankList
"ziplist"
```
-因为设计者考虑到 Redis 数据存放于内存,为了节约宝贵的内存空间在有序集合在元素小于 64 字节且个数小于 128 的时候,会使用 ziplist,而这个阈值的默认值的设置就来自下面这两个配置项。
+因为设计者考虑到 Redis 数据存放于内存,为了节约宝贵的内存空间,在有序集合元素小于 64 字节且个数小于 128 的时候,会使用 ziplist,而这个阈值的默认值的设置就来自下面这两个配置项。
```bash
zset-max-ziplist-value 64
@@ -78,7 +83,7 @@ zset-max-ziplist-entries 128
为了更好的回答上述问题以及更好的理解和掌握跳表,这里可以通过手写一个简单的跳表的形式来帮助读者理解跳表这个数据结构。
-我们都知道有序链表在添加、查询、删除的平均时间复杂都都是**O(n)**即线性增长,所以一旦节点数量达到一定体量后其性能表现就会非常差劲。而跳表我们完全可以理解为在原始链表基础上,建立多级索引,通过多级索引检索定位将增删改查的时间复杂度变为**O(log n)**。
+我们都知道有序链表在添加、查询、删除的平均时间复杂都都是 **O(n)** 即线性增长,所以一旦节点数量达到一定体量后其性能表现就会非常差劲。而跳表我们完全可以理解为在原始链表基础上,建立多级索引,通过多级索引检索定位将增删改查的时间复杂度变为 **O(log n)** 。
可能这里说的有些抽象,我们举个例子,以下图跳表为例,其原始链表存储按序存储 1-10,有 2 级索引,每级索引的索引个数都是基于下层元素个数的一半。
@@ -145,8 +150,8 @@ r=n/2^k
1. 跳表的高度计算从原始链表开始,即默认情况下插入的元素的高度为 1,代表没有索引,只有元素节点。
2. 设计一个为插入元素生成节点索引高度 level 的方法。
3. 进行一次随机运算,随机数值范围为 0-1 之间。
-4. 如果随机数大于 0.5 则为当前元素添加一级索引,自此我们保证生成一级索引的概率为**50%**,这也就保证了 1 级索引理想情况下只有一半的元素会生成索引。
-5. 同理后续每次随机算法得到的值大于 0.5 时,我们的索引高度就加 1,这样就可以保证节点生成的 2 级索引概率为**25%**,3 级索引为**12.5%**……
+4. 如果随机数大于 0.5 则为当前元素添加一级索引,自此我们保证生成一级索引的概率为 **50%** ,这也就保证了 1 级索引理想情况下只有一半的元素会生成索引。
+5. 同理后续每次随机算法得到的值大于 0.5 时,我们的索引高度就加 1,这样就可以保证节点生成的 2 级索引概率为 **25%** ,3 级索引为 **12.5%** ……
我们回过头,上述插入 7 之后,我们通过随机算法得到 2,即要为其建立 1 级索引:
@@ -233,7 +238,7 @@ private int randomLevel() {
/**
* 默认情况下的高度为1,即只有自己一个节点
*/
-private int leveCount = 1;
+private int levelCount = 1;
/**
* 跳表最底层的节点,即头节点
@@ -241,41 +246,40 @@ private int leveCount = 1;
private Node h = new Node();
public void add(int value) {
-
- //随机生成高度
- int level = randomLevel();
+ int level = randomLevel(); // 新节点的随机高度
Node newNode = new Node();
newNode.data = value;
newNode.maxLevel = level;
- //创建一个node数组,用于记录小于当前value的最大值
- Node[] maxOfMinArr = new Node[level];
- //默认情况下指向头节点
+ // 用于记录每层前驱节点的数组
+ Node[] update = new Node[level];
for (int i = 0; i < level; i++) {
- maxOfMinArr[i] = h;
+ update[i] = h;
}
- //基于上述结果拿到当前节点的后继节点
Node p = h;
- for (int i = level - 1; i >= 0; i--) {
+ // 关键修正:从跳表的当前最高层开始查找
+ for (int i = levelCount - 1; i >= 0; i--) {
while (p.forwards[i] != null && p.forwards[i].data < value) {
p = p.forwards[i];
}
- maxOfMinArr[i] = p;
+ // 只记录需要更新的层的前驱节点
+ if (i < level) {
+ update[i] = p;
+ }
}
- //更新前驱节点的后继节点为当前节点newNode
+ // 插入新节点
for (int i = 0; i < level; i++) {
- newNode.forwards[i] = maxOfMinArr[i].forwards[i];
- maxOfMinArr[i].forwards[i] = newNode;
+ newNode.forwards[i] = update[i].forwards[i];
+ update[i].forwards[i] = newNode;
}
- //如果当前newNode高度大于跳表最高高度则更新leveCount
- if (leveCount < level) {
- leveCount = level;
+ // 更新跳表的总高度
+ if (levelCount < level) {
+ levelCount = level;
}
-
}
```
@@ -283,33 +287,39 @@ public void add(int value) {
查询逻辑比较简单,从跳表最高级的索引开始定位找到小于要查的 value 的最大值,以下图为例,我们希望查找到节点 8:
-1. 跳表的 3 级索引首先找找到 5 的索引,5 的 3 级索引**forwards[3]**指向空,索引直接向下。
-2. 来到 5 的 2 级索引,其后继**forwards[2]**指向 8,继续向下。
-3. 5 的 1 级索引**forwards[1]**指向索引 6,继续向前。
-4. 索引 6 的**forwards[1]**指向索引 8,继续向下。
-5. 我们在原始节点向前找到节点 7。
-6. 节点 7 后续就是节点 8,继续向前为节点 8,无法继续向下,结束搜寻。
-7. 判断 7 的前驱,等于 8,查找结束。
-

+- **从最高层级开始 (3 级索引)** :查找指针 `p` 从头节点开始。在 3 级索引上,`p` 的后继 `forwards[2]`(假设最高 3 层,索引从 0 开始)指向节点 `5`。由于 `5 < 8`,指针 `p` 向右移动到节点 `5`。节点 `5` 在 3 级索引上的后继 `forwards[2]` 为 `null`(或指向一个大于 `8` 的节点,图中未画出)。当前层级向右查找结束,指针 `p` 保持在节点 `5`,**向下移动到 2 级索引**。
+- **在 2 级索引**:当前指针 `p` 为节点 `5`。`p` 的后继 `forwards[1]` 指向节点 `8`。由于 `8` 不小于 `8`(即 `8 < 8` 为 `false`),当前层级向右查找结束(`p` 不会移动到节点 `8`)。指针 `p` 保持在节点 `5`,**向下移动到 1 级索引**。
+- **在 1 级索引** :当前指针 `p` 为节点 `5`。`p` 的后继 `forwards[0]` 指向最底层的节点 `5`。由于 `5 < 8`,指针 `p` 向右移动到最底层的节点 `5`。此时,当前指针 `p` 为最底层的节点 `5`。其后继 `forwards[0]` 指向最底层的节点 `6`。由于 `6 < 8`,指针 `p` 向右移动到最底层的节点 `6`。当前指针 `p` 为最底层的节点 `6`。其后继 `forwards[0]` 指向最底层的节点 `7`。由于 `7 < 8`,指针 `p` 向右移动到最底层的节点 `7`。当前指针 `p` 为最底层的节点 `7`。其后继 `forwards[0]` 指向最底层的节点 `8`。由于 `8` 不小于 `8`(即 `8 < 8` 为 `false`),当前层级向右查找结束。此时,已经遍历完所有层级,`for` 循环结束。
+- **最终定位与检查** :经过所有层级的查找,指针 `p` 最终停留在最底层(0 级索引)的节点 `7`。这个节点是整个跳表中值小于目标值 `8` 的那个最大的节点。检查节点 `7` 的**后继节点**(即 `p.forwards[0]`):`p.forwards[0]` 指向节点 `8`。判断 `p.forwards[0].data`(即节点 `8` 的值)是否等于目标值 `8`。条件满足(`8 == 8`),**查找成功,找到节点 `8`**。
+
所以我们的代码实现也很上述步骤差不多,从最高级索引开始向前查找,如果不为空且小于要查找的值,则继续向前搜寻,遇到不小于的节点则继续向下,如此往复,直到得到当前跳表中小于查找值的最大节点,查看其前驱是否等于要查找的值:
```java
public Node get(int value) {
- Node p = h;
- //找到小于value的最大值
- for (int i = leveCount - 1; i >= 0; i--) {
+ Node p = h; // 从头节点开始
+
+ // 从最高层级索引开始,逐层向下
+ for (int i = levelCount - 1; i >= 0; i--) {
+ // 在当前层级向右查找,直到 p.forwards[i] 为 null
+ // 或者 p.forwards[i].data 大于等于目标值 value
while (p.forwards[i] != null && p.forwards[i].data < value) {
- p = p.forwards[i];
+ p = p.forwards[i]; // 向右移动
}
+ // 此时 p.forwards[i] 为 null,或者 p.forwards[i].data >= value
+ // 或者 p 是当前层级中小于 value 的最大节点(如果存在这样的节点)
}
- //如果p的前驱节点等于value则直接返回
+
+ // 经过所有层级的查找,p 现在是原始链表(0级索引)中
+ // 小于目标值 value 的最大节点(或者头节点,如果所有元素都大于等于 value)
+
+ // 检查 p 在原始链表中的下一个节点是否是目标值
if (p.forwards[0] != null && p.forwards[0].data == value) {
- return p.forwards[0];
+ return p.forwards[0]; // 找到了,返回该节点
}
- return null;
+ return null; // 未找到
}
```
@@ -334,8 +344,8 @@ public Node get(int value) {
public void delete(int value) {
Node p = h;
//找到各级节点小于value的最大值
- Node[] updateArr = new Node[leveCount];
- for (int i = leveCount - 1; i >= 0; i--) {
+ Node[] updateArr = new Node[levelCount];
+ for (int i = levelCount - 1; i >= 0; i--) {
while (p.forwards[i] != null && p.forwards[i].data < value) {
p = p.forwards[i];
}
@@ -344,7 +354,7 @@ public void delete(int value) {
//查看原始层节点前驱是否等于value,若等于则说明存在要删除的值
if (p.forwards[0] != null && p.forwards[0].data == value) {
//从最高级索引开始查看其前驱是否等于value,若等于则将当前节点指向value节点的后继节点
- for (int i = leveCount - 1; i >= 0; i--) {
+ for (int i = levelCount - 1; i >= 0; i--) {
if (updateArr[i].forwards[i] != null && updateArr[i].forwards[i].data == value) {
updateArr[i].forwards[i] = updateArr[i].forwards[i].forwards[i];
}
@@ -352,8 +362,8 @@ public void delete(int value) {
}
//从最高级开始查看是否有一级索引为空,若为空则层级减1
- while (leveCount > 1 && h.forwards[leveCount] == null) {
- leveCount--;
+ while (levelCount > 1 && h.forwards[levelCount - 1] == null) {
+ levelCount--;
}
}
@@ -374,21 +384,23 @@ public class SkipList {
/**
* 每个节点添加一层索引高度的概率为二分之一
*/
- private static final float PROB = 0.5 f;
+ private static final float PROB = 0.5f;
/**
* 默认情况下的高度为1,即只有自己一个节点
*/
- private int leveCount = 1;
+ private int levelCount = 1;
/**
* 跳表最底层的节点,即头节点
*/
private Node h = new Node();
- public SkipList() {}
+ public SkipList() {
+ }
public class Node {
+
private int data = -1;
/**
*
@@ -398,58 +410,55 @@ public class SkipList {
@Override
public String toString() {
- return "Node{" +
- "data=" + data +
- ", maxLevel=" + maxLevel +
- '}';
+ return "Node{"
+ + "data=" + data
+ + ", maxLevel=" + maxLevel
+ + '}';
}
}
public void add(int value) {
-
- //随机生成高度
- int level = randomLevel();
+ int level = randomLevel(); // 新节点的随机高度
Node newNode = new Node();
newNode.data = value;
newNode.maxLevel = level;
- //创建一个node数组,用于记录小于当前value的最大值
- Node[] maxOfMinArr = new Node[level];
- //默认情况下指向头节点
+ // 用于记录每层前驱节点的数组
+ Node[] update = new Node[level];
for (int i = 0; i < level; i++) {
- maxOfMinArr[i] = h;
+ update[i] = h;
}
- //基于上述结果拿到当前节点的后继节点
Node p = h;
- for (int i = level - 1; i >= 0; i--) {
+ // 关键修正:从跳表的当前最高层开始查找
+ for (int i = levelCount - 1; i >= 0; i--) {
while (p.forwards[i] != null && p.forwards[i].data < value) {
p = p.forwards[i];
}
- maxOfMinArr[i] = p;
+ // 只记录需要更新的层的前驱节点
+ if (i < level) {
+ update[i] = p;
+ }
}
- //更新前驱节点的后继节点为当前节点newNode
+ // 插入新节点
for (int i = 0; i < level; i++) {
- newNode.forwards[i] = maxOfMinArr[i].forwards[i];
- maxOfMinArr[i].forwards[i] = newNode;
+ newNode.forwards[i] = update[i].forwards[i];
+ update[i].forwards[i] = newNode;
}
- //如果当前newNode高度大于跳表最高高度则更新leveCount
- if (leveCount < level) {
- leveCount = level;
+ // 更新跳表的总高度
+ if (levelCount < level) {
+ levelCount = level;
}
-
}
/**
* 理论来讲,一级索引中元素个数应该占原始数据的 50%,二级索引中元素个数占 25%,三级索引12.5% ,一直到最顶层。
- * 因为这里每一层的晋升概率是 50%。对于每一个新插入的节点,都需要调用 randomLevel 生成一个合理的层数。
- * 该 randomLevel 方法会随机生成 1~MAX_LEVEL 之间的数,且 :
- * 50%的概率返回 1
- * 25%的概率返回 2
- * 12.5%的概率返回 3 ...
+ * 因为这里每一层的晋升概率是 50%。对于每一个新插入的节点,都需要调用 randomLevel 生成一个合理的层数。 该 randomLevel
+ * 方法会随机生成 1~MAX_LEVEL 之间的数,且 : 50%的概率返回 1 25%的概率返回 2 12.5%的概率返回 3 ...
+ *
* @return
*/
private int randomLevel() {
@@ -463,7 +472,7 @@ public class SkipList {
public Node get(int value) {
Node p = h;
//找到小于value的最大值
- for (int i = leveCount - 1; i >= 0; i--) {
+ for (int i = levelCount - 1; i >= 0; i--) {
while (p.forwards[i] != null && p.forwards[i].data < value) {
p = p.forwards[i];
}
@@ -484,8 +493,8 @@ public class SkipList {
public void delete(int value) {
Node p = h;
//找到各级节点小于value的最大值
- Node[] updateArr = new Node[leveCount];
- for (int i = leveCount - 1; i >= 0; i--) {
+ Node[] updateArr = new Node[levelCount];
+ for (int i = levelCount - 1; i >= 0; i--) {
while (p.forwards[i] != null && p.forwards[i].data < value) {
p = p.forwards[i];
}
@@ -494,7 +503,7 @@ public class SkipList {
//查看原始层节点前驱是否等于value,若等于则说明存在要删除的值
if (p.forwards[0] != null && p.forwards[0].data == value) {
//从最高级索引开始查看其前驱是否等于value,若等于则将当前节点指向value节点的后继节点
- for (int i = leveCount - 1; i >= 0; i--) {
+ for (int i = levelCount - 1; i >= 0; i--) {
if (updateArr[i].forwards[i] != null && updateArr[i].forwards[i].data == value) {
updateArr[i].forwards[i] = updateArr[i].forwards[i].forwards[i];
}
@@ -502,8 +511,8 @@ public class SkipList {
}
//从最高级开始查看是否有一级索引为空,若为空则层级减1
- while (leveCount > 1 && h.forwards[leveCount] == null) {
- leveCount--;
+ while (levelCount > 1 && h.forwards[levelCount - 1] == null) {
+ levelCount--;
}
}
@@ -517,11 +526,11 @@ public class SkipList {
}
}
-
}
+
```
-对应测试代码和输出结果如下:
+测试代码:
```java
public static void main(String[] args) {
@@ -544,60 +553,11 @@ public static void main(String[] args) {
}
```
-输出结果:
+**Redis 跳表的特点**:
-```bash
-**********输出添加结果**********
-Node{data=0, maxLevel=2}
-Node{data=1, maxLevel=3}
-Node{data=2, maxLevel=1}
-Node{data=3, maxLevel=1}
-Node{data=4, maxLevel=2}
-Node{data=5, maxLevel=2}
-Node{data=6, maxLevel=2}
-Node{data=7, maxLevel=2}
-Node{data=8, maxLevel=4}
-Node{data=9, maxLevel=1}
-Node{data=10, maxLevel=1}
-Node{data=11, maxLevel=1}
-Node{data=12, maxLevel=1}
-Node{data=13, maxLevel=1}
-Node{data=14, maxLevel=1}
-Node{data=15, maxLevel=3}
-Node{data=16, maxLevel=4}
-Node{data=17, maxLevel=2}
-Node{data=18, maxLevel=1}
-Node{data=19, maxLevel=1}
-Node{data=20, maxLevel=1}
-Node{data=21, maxLevel=3}
-Node{data=22, maxLevel=1}
-Node{data=23, maxLevel=1}
-**********查询结果:Node{data=22, maxLevel=1} **********
-**********删除结果**********
-Node{data=0, maxLevel=2}
-Node{data=1, maxLevel=3}
-Node{data=2, maxLevel=1}
-Node{data=3, maxLevel=1}
-Node{data=4, maxLevel=2}
-Node{data=5, maxLevel=2}
-Node{data=6, maxLevel=2}
-Node{data=7, maxLevel=2}
-Node{data=8, maxLevel=4}
-Node{data=9, maxLevel=1}
-Node{data=10, maxLevel=1}
-Node{data=11, maxLevel=1}
-Node{data=12, maxLevel=1}
-Node{data=13, maxLevel=1}
-Node{data=14, maxLevel=1}
-Node{data=15, maxLevel=3}
-Node{data=16, maxLevel=4}
-Node{data=17, maxLevel=2}
-Node{data=18, maxLevel=1}
-Node{data=19, maxLevel=1}
-Node{data=20, maxLevel=1}
-Node{data=21, maxLevel=3}
-Node{data=23, maxLevel=1}
-```
+1. 采用**双向链表**,不同于上面的示例,存在一个回退指针。主要用于简化操作,例如删除某个元素时,还需要找到该元素的前驱节点,使用回退指针会非常方便。
+2. `score` 值可以重复,如果 `score` 值一样,则按照 ele(节点存储的值,为 sds)字典排序
+3. Redis 跳跃表默认允许最大的层数是 32,被源码中 `ZSKIPLIST_MAXLEVEL` 定义。
## 和其余三种数据结构的比较
@@ -605,7 +565,7 @@ Node{data=23, maxLevel=1}
### 平衡树 vs 跳表
-先来说说它和平衡树的比较,平衡树我们又会称之为 **AVL 树**,是一个严格的平衡二叉树,平衡条件必须满足(所有节点的左右子树高度差不超过 1,即平衡因子为范围为 `[-1,1]`)。平衡树的插入、删除和查询的时间复杂度和跳表一样都是 **O(log n)**。
+先来说说它和平衡树的比较,平衡树我们又会称之为 **AVL 树**,是一个严格的平衡二叉树,平衡条件必须满足(所有节点的左右子树高度差不超过 1,即平衡因子为范围为 `[-1,1]`)。平衡树的插入、删除和查询的时间复杂度和跳表一样都是 **O(log n)** 。
对于范围查询来说,它也可以通过中序遍历的方式达到和跳表一样的效果。但是它的每一次插入或者删除操作都需要保证整颗树左右节点的绝对平衡,只要不平衡就要通过旋转操作来保持平衡,这个过程是比较耗时的。
@@ -674,7 +634,7 @@ private Node add(Node node, K key, V value) {
### 红黑树 vs 跳表
-红黑树(Red Black Tree)也是一种自平衡二叉查找树,它的查询性能略微逊色于 AVL 树,但插入和删除效率更高。红黑树的插入、删除和查询的时间复杂度和跳表一样都是 **O(log n)**。
+红黑树(Red Black Tree)也是一种自平衡二叉查找树,它的查询性能略微逊色于 AVL 树,但插入和删除效率更高。红黑树的插入、删除和查询的时间复杂度和跳表一样都是 **O(log n)** 。
红黑树是一个**黑平衡树**,即从任意节点到另外一个叶子叶子节点,它所经过的黑节点是一样的。当对它进行插入操作时,需要通过旋转和染色(红黑变换)来保证黑平衡。不过,相较于 AVL 树为了维持平衡的开销要小一些。关于红黑树的详细介绍,可以查看这篇文章:[红黑树](https://javaguide.cn/cs-basics/data-structure/red-black-tree.html)。
@@ -726,7 +686,7 @@ private Node < K, V > add(Node < K, V > node, K key, V val) {
1. **多叉树结构**:它是一棵多叉树,每个节点可以包含多个子节点,减小了树的高度,查询效率高。
2. **存储效率高**:其中非叶子节点存储多个 key,叶子节点存储 value,使得每个节点更够存储更多的键,根据索引进行范围查询时查询效率更高。-
-3. **平衡性**:它是绝对的平衡,即树的各个分支高度相差不大,确保查询和插入时间复杂度为**O(log n)**。
+3. **平衡性**:它是绝对的平衡,即树的各个分支高度相差不大,确保查询和插入时间复杂度为 **O(log n)** 。
4. **顺序访问**:叶子节点间通过链表指针相连,范围查询表现出色。
5. **数据均匀分布**:B+树插入时可能会导致数据重新分布,使得数据在整棵树分布更加均匀,保证范围查询和删除效率。
diff --git a/docs/database/redis/redis-stream-mq.md b/docs/database/redis/redis-stream-mq.md
new file mode 100644
index 00000000000..2ba128e0f6d
--- /dev/null
+++ b/docs/database/redis/redis-stream-mq.md
@@ -0,0 +1,223 @@
+---
+title: Redis 能做消息队列吗?怎么实现?
+description: 讲解 Redis 做消息队列的三种方式:List、Pub/Sub、Stream。对比生产级 MQ 核心能力,详解 Redis 5.0 Stream 的消费者组、ACK 机制及与 Kafka/RabbitMQ 的适用场景对比。
+category: 数据库
+tag:
+ - Redis
+ - 消息队列
+head:
+ - - meta
+ - name: keywords
+ content: Redis消息队列,Redis Stream,Redis List,Redis Pub/Sub,消息队列,消费者组,ACK机制,XREADGROUP,XADD,XACK
+---
+
+先说结论:**可以是可以,但要看具体场景。和专业的消息队列(如 Kafka、RabbitMQ)相比,还是有一些欠缺的地方。**
+
+正式开始介绍之前,我们先来看看:**一个生产级 MQ 需要具备哪些核心能力?**
+
+| 能力维度 | 定义 | 关键指标/特征 |
+| :--------------- | :------------------------------ | :---------------------------------- |
+| **持久化** | 消息写入后不因进程/节点故障丢失 | 同步刷盘/多副本确认、RPO ≈ 0 |
+| **至少一次投递** | 消息最终被消费,允许重复 | 需配合消费者幂等性 |
+| **消费确认** | 消费者显式告知处理成功 | ACK 机制、超时重试、死信队列 |
+| **消息重试** | 消费失败可自动重新投递 | 退避策略、最大重试次数、死信转移 |
+| **消费者组** | 多消费者协作消费,故障自动转移 | 组内负载均衡、分区分配、Rebalance |
+| **消息堆积能力** | 生产速率 > 消费速率时的缓冲能力 | 磁盘存储、TTL、堆积告警 |
+| **顺序保证** | 消息按发送顺序被消费 | 分区有序/全局有序、乱序惩罚 |
+| **可扩展性** | 水平扩展以提升吞吐或容灾 | 分片机制、无状态 Broker、动态扩缩容 |
+
+Redis 提供了多种实现 MQ 的方式,从早期的 `List` 到 `Pub/Sub`,再到 Redis 5.0 新增的 `Stream` 数据结构(基于有序链表实现,支持消费者组和 ACK 机制,可用于构建轻量级消息队列)。
+
+### 第一阶段:早期用 List 数据结构
+
+**Redis 2.0 之前,如果想要使用 Redis 来做消息队列的话,只能通过 List 来实现。**
+
+通过 `RPUSH/LPOP` 或者 `LPUSH/RPOP` 即可实现简易版消息队列:
+
+```bash
+# 生产者生产消息
+> RPUSH myList msg1 msg2
+(integer) 2
+> RPUSH myList msg3
+(integer) 3
+# 消费者消费消息
+> LPOP myList
+"msg1"
+```
+
+不过,通过 `RPUSH/LPOP` 或者 `LPUSH/RPOP` 这样的方式存在性能问题,我们需要不断轮询去调用 `RPOP` 或 `LPOP` 来消费消息。当 List 为空时,大部分的轮询的请求都是无效请求,这种方式大量浪费了系统资源。
+
+因此,Redis 还提供了 `BLPOP`、`BRPOP` 这种阻塞式读取的命令(带 B-Blocking 的都是阻塞式),并且还支持一个超时参数。如果 List 为空,Redis 服务端不会立刻返回结果,它会等待 List 中有新数据后再返回或者是等待最多一个超时时间后返回空。如果将超时时间设置为 0 时,即可无限等待,直到弹出消息
+
+```bash
+# 超时时间为 10s
+# 如果有数据立刻返回,否则最多等待10秒
+> BRPOP myList 10
+null
+```
+
+List 实现消息队列功能太简单,像消息确认机制等功能还需要我们自己实现。**最致命的是,它不支持一个消息被多个消费者消费(广播),而且消息一旦被取出,就没有了,如果消费者处理失败,消息就永久丢失了。**
+
+### 第二阶段:引入 Pub/Sub(发布/订阅)模式
+
+**Redis 2.0 引入了发布订阅 (Pub/Sub) 功能,解决了 List 实现消息队列没有广播机制的问题。**
+
+
+
+Pub/Sub 中引入了一个概念叫 **Channel(频道)**,发布订阅机制的实现就是基于这个 Channel 来做的。
+
+Pub/Sub 涉及发布者(Publisher)和订阅者(Subscriber,也叫消费者)两个角色:
+
+- 发布者通过 `PUBLISH` 投递消息给指定 Channel。
+- 订阅者通过`SUBSCRIBE`订阅它关心的 Channel。并且,订阅者可以订阅一个或者多个 Channel。
+
+也就是说,多个消费者可以订阅同一个 Channel,生产者向这个 Channel 发布消息,所有订阅者都能收到。
+
+我们这里启动 3 个 Redis 客户端来简单演示一下:
+
+
+
+Pub/Sub 既能单播又能广播,还支持 Channel 的简单正则匹配。
+
+Pub/Sub 有一个致命的缺陷:**它发后即忘,完全没有持久化和可靠性保证**。 如果消息发布时,某个消费者不在线,或者网络抖动了一下,那这条消息对它来说就永远丢失了。此外,它也**没有 ACK 机制**,无法知道消费者是否成功处理,更别提**消息堆积**的问题了。所以,Pub/Sub 只适合做一些对可靠性要求极低的实时通知,绝对不能用于任何严肃的业务消息队列。
+
+### 第三阶段:Redis 5.0 新增 Stream
+
+Redis 5.0 新增了 `Stream` 数据结构。这是一个基于 Radix Tree(基数树)实现的有序消息日志,天然支持消费者组和 ACK 机制,可用于构建轻量级消息队列。
+
+**为什么要用 Radix Tree?** 很多人好奇,为什么不继续用 `List/LinkedList`?
+
+1. **内存极度压缩**:`Stream` 的消息 ID(如 `1625000000000-0`)是高度有序且前缀高度重合的。Radix Tree 是一种压缩前缀树,它会将具有相同前缀的节点合并。而 List/LinkedList
+ 每个元素都要完整的链表节点开销,并且无法利用 ID 的前缀重复特性来节省空间。
+2. **高效检索**:在处理数百万级消息堆积时,Radix Tree 能保持极高的查询效率,这也是 `Stream` 能支持大数据量范围查询(`XRANGE`)的底层底气。相比之下,`List/LinkedList`只能从头尾操作,无法高效按 ID 范围查询,执行 `XRANGE` 需要遍历整个列表。
+
+它借鉴了 Kafka 等专业 MQ 的核心概念:
+
+1. **消费者组(Consumer Groups)**:实现消息在多个消费者间的负载均衡,支持故障自动转移。
+2. **持久化**:可以通过 RDB 和 AOF 保证消息在 Redis 重启后不丢失(取决于 `appendfsync` 配置,`everysec` 模式下通常最多丢失 1 秒数据)。
+3. **ACK 机制**:消费者处理完消息后,需要手动 `XACK` 确认,否则消息会保留在 `Pending List` 中。这保证了消息至少被成功消费一次。
+4. **消息回溯与转移**:支持 `XRANGE` 按时间范围回溯消息,以及 `XCLAIM` 将挂起的消息转移到其他消费者处理。
+
+> 🌈 版本演进:
+>
+> - Redis 8.2:`XACKDEL`、`XDELEX`、`XADD` 和 `XTRIM 命令提供了对流操作如何与多个消费者组交互的细粒度控制,简化了跨不同应用程序的消息处理协调。
+> - Redis 8.6:支持幂等消息处理(最多一次生产),防止在使用至少一次交付模式时出现重复条目。此功能可实现可靠的消息提交,并自动去重。
+
+`Stream` 的结构如下:
+
+
+
+这是一个有序的消息链表,每个消息都有一个唯一的 ID 和对应的内容。ID 是一个时间戳和序列号的组合,用来保证消息的唯一性和递增性。内容是一个或多个键值对(类似 Hash 基本数据类型),用来存储消息的数据。
+
+这里再对图中涉及到的一些概念,进行简单解释:
+
+- `Consumer Group`:消费者组用于组织和管理多个消费者。消费者组本身不处理消息,而是再将消息分发给消费者,由消费者进行真正的消费。
+- `last_delivered_id`:标识消费者组当前消费位置的游标,消费者组中任意一个消费者读取了消息都会使 last_delivered_id 往前移动。
+- `pending_ids`:记录已经被客户端消费但没有 ack 的消息的 ID。
+
+下面是`Stream` 用作消息队列时常用的命令:
+
+- `XADD`:向流中添加新的消息。
+- `XREAD`:从流中读取消息。
+- `XREADGROUP`:从消费组中读取消息。
+- `XRANGE`:根据消息 ID 范围读取流中的消息。
+- `XREVRANGE`:与 `XRANGE` 类似,但以相反顺序返回结果。
+- `XDEL`:从流中删除消息。
+- `XTRIM`:修剪流的长度,可以指定修建策略(`MAXLEN`/`MINID`)。
+- `XLEN`:获取流的长度。
+- `XGROUP CREATE`:创建消费者组。
+- `XGROUP DESTROY`:删除消费者组。
+- `XGROUP DELCONSUMER`:从消费者组中删除一个消费者。
+- `XGROUP SETID`:为消费者组设置新的最后递送消息 ID。
+- `XACK`:确认消费组中的消息已被处理。
+- `XPENDING`:查询消费组中挂起(未确认)的消息。
+- `XCLAIM`:将挂起的消息从一个消费者转移到另一个消费者。
+- `XINFO`:获取流(`XINFO STREAM`)、消费组(`XINFO GROUPS`)或消费者(`XINFO CONSUMERS`)的详细信息。
+
+下面这张时序图展示了 Stream 消费者组消息流转与 ACK 机制:
+
+```mermaid
+sequenceDiagram
+ participant P as Producer
+ participant R as Redis Stream
(my_stream)
+ participant CG as Consumer Group
(group_a)
+ participant C1 as Consumer-1
+ participant C2 as Consumer-2
+
+ %% 生产消息
+ P->>R: XADD my_stream * field value
+ R-->>P: 返回 ID = 1001
+
+ %% 消费新消息
+ C1->>R: XREADGROUP GROUP group_a consumer-1
STREAMS my_stream >
+ R-->>C1: 返回消息 1001
+
+ Note over CG: 1️⃣ last_delivered_id 推进到 1001
+ Note over CG: 2️⃣ 1001 进入 PEL (Pending Entries List)
+
+ %% 正常消费
+ alt 正常处理完成
+ C1->>R: XACK my_stream group_a 1001
+ R-->>C1: OK
+ Note over CG: 1001 从 PEL 移除
+ else 消费者崩溃
+ Note over C1: 未 ACK,连接断开
+ Note over CG: 1001 仍在 PEL 中
idle time 持续增长
+
+ C2->>R: XPENDING my_stream group_a
+ R-->>C2: 返回 1001 + idle time
+
+ C2->>R: XCLAIM my_stream group_a consumer-2 60000 1001
+ R-->>C2: 返回 1001
+
+ Note over CG: 1001 转移到 consumer-2
+
+ C2->>R: XACK my_stream group_a 1001
+ R-->>C2: OK
+ end
+
+```
+
+总的来说,`Stream` 已经可以满足一个消息队列的基本要求了。不过,`Stream` 在实际使用中需要注意以下几点:
+
+1. **持久化限制**:Redis 5.0 的 Stream 依赖 RDB/AOF 异步持久化,在故障恢复时可能丢失最近未持久化的消息(取决于 `appendfsync` 配置)。AOF 的 `everysec` 模式下通常最多丢失 1 秒数据。
+2. **消息堆积受限**:Redis Stream 的数据存储在内存中,受服务器内存容量限制。相比 Kafka 基于磁盘的存储,Redis Stream 不适合海量堆积场景。
+3. **消费组管理**:Consumer Group 的状态信息(如 `last_delivered_id`)需要定期维护,长时间未处理的 Pending 消息会占用内存。
+
+下面这张表格是 Redis Stream 和常见 MQ 的对比:
+
+| 维度 | Redis Stream | RabbitMQ | Kafka | 内存队列 |
+| :------------- | :------------------------- | :------------------------------- | :---------------------------------- | :----------------------- |
+| **吞吐量** | 高(十万级 QPS) | 中(万级 QPS) | **极高(百万级,靠分区水平扩展)** | 极高(受限于 CPU/内存) |
+| **延迟** | **极低(亚毫秒级)** | **低(微秒/毫秒级,实时性强)** | 中(毫秒级,受批处理影响) | 极低(纳秒/微秒级) |
+| **持久化** | 支持(RDB/AOF 异步) | 支持(磁盘) | **强支持(原生磁盘顺序写)** | 无 |
+| **消息堆积** | 一般(受内存限制) | 中(堆积多时性能下降明显) | **极强(TB 级磁盘存储,性能稳定)** | 差(易 OOM) |
+| **消息回溯** | 支持(按 ID/时间) | **不支持(传统队列模式下)** | **强支持(按 Offset/时间)** | 不支持 |
+| **可靠性** | 中(AOF 丢数据风险) | **高(Confirm/确认机制成熟)** | **极高(多副本 + 强一致性配置)** | 低 |
+| **运维复杂度** | 低(运维 Redis 即可) | 中(Erlang 环境,集群管理) | 高(依赖 ZK 或 KRaft) | 极低 |
+| **适用场景** | 轻量级、低延迟、已有 Redis | **复杂路由、高可靠性、金融业务** | **大数据、日志聚合、高吞吐流处理** | 进程内解耦、极致性能要求 |
+
+### 总结
+
+**回到最初的问题:Redis 到底能不能做 MQ?**
+
+- **如果业务简单、量小、追求极致性能**,且能容忍极小概率的数据丢失,使用 **Redis Stream** 是最优解,因为它省去了部署维护 MQ 的成本,可以复用现有的 Redis 组件(大部分需要用到 MQ 的项目,通常都会需要 Redis)。
+- **如果是金融级业务、海量数据、需要严格保证不丢消息**,必须选择 **Kafka、RabbitMQ** 等更成熟的 MQ。
+
+更多 Redis 高频知识点和面试题总结,可以阅读笔者写的这几篇文章:
+
+- [Redis 常见面试题总结(上)](https://javaguide.cn/database/redis/redis-questions-01.html "Redis 常见面试题总结(上)")(Redis 基础、应用、数据类型、持久化机制、线程模型等)
+- [Redis 常见面试题总结(下)](https://javaguide.cn/database/redis/redis-questions-02.html "Redis 常见面试题总结(下)")(Redis 事务、性能优化、生产问题、集群、使用规范等)
+- [如何基于Redis实现延时任务](https://javaguide.cn/database/redis/redis-delayed-task.html "如何基于Redis实现延时任务")
+- [Redis 5 种基本数据类型详解](https://javaguide.cn/database/redis/redis-data-structures-01.html "Redis 5 种基本数据类型详解")
+- [Redis 3 种特殊数据类型详解](https://javaguide.cn/database/redis/redis-data-structures-02.html "Redis 3 种特殊数据类型详解")
+- [Redis为什么用跳表实现有序集合](https://javaguide.cn/database/redis/redis-skiplist.html "Redis为什么用跳表实现有序集合")
+- [Redis 持久化机制详解](https://javaguide.cn/database/redis/redis-persistence.html "Redis 持久化机制详解")
+- [Redis 内存碎片详解](https://javaguide.cn/database/redis/redis-memory-fragmentation.html "Redis 内存碎片详解")
+- [Redis 常见阻塞原因总结](https://javaguide.cn/database/redis/redis-common-blocking-problems-summary.html "Redis 常见阻塞原因总结")
+
+我的 [《SpringAI 智能面试平台+RAG 知识库》](https://javaguide.cn/zhuanlan/interview-guide.html)项目就是用的 Redis Stream 作为消息队列。在我的项目的场景下,它几乎是最合适的选择,完全够用了。
+
+
+
+
diff --git a/docs/database/sql/sql-questions-01.md b/docs/database/sql/sql-questions-01.md
index c0e3b2f826b..b61d0f07c1e 100644
--- a/docs/database/sql/sql-questions-01.md
+++ b/docs/database/sql/sql-questions-01.md
@@ -1,9 +1,14 @@
---
title: SQL常见面试题总结(1)
+description: SQL常见面试题总结第一篇,涵盖SELECT检索数据、WHERE条件过滤、ORDER BY排序、DISTINCT去重、LIMIT分页等基础查询操作及牛客真题解析。
category: 数据库
tag:
- 数据库基础
- SQL
+head:
+ - - meta
+ - name: keywords
+ content: SQL面试题,SELECT查询,WHERE条件,ORDER BY排序,DISTINCT去重,LIMIT分页,SQL基础
---
> 题目来源于:[牛客题霸 - SQL 必知必会](https://www.nowcoder.com/exam/oj?page=1&tab=SQL%E7%AF%87&topicId=298)
@@ -1095,13 +1100,14 @@ WHERE b.prod_id = 'BR01'
```sql
# 写法 1:子查询
-SELECT o.cust_id AS cust_id, tb.total_ordered AS total_ordered
-FROM (SELECT order_num, Sum(item_price * quantity) AS total_ordered
+SELECT o.cust_id, SUM(tb.total_ordered) AS `total_ordered`
+FROM (SELECT order_num, SUM(item_price * quantity) AS total_ordered
FROM OrderItems
GROUP BY order_num) AS tb,
Orders o
WHERE tb.order_num = o.order_num
-ORDER BY total_ordered DESC
+GROUP BY o.cust_id
+ORDER BY total_ordered DESC;
# 写法 2:连接表
SELECT b.cust_id, Sum(a.quantity * a.item_price) AS total_ordered
@@ -1111,6 +1117,8 @@ GROUP BY cust_id
ORDER BY total_ordered DESC
```
+关于写法一详细介绍可以参考: [issue#2402:写法 1 存在的错误以及修改方法](https://github.com/Snailclimb/JavaGuide/issues/2402)。
+
### 从 Products 表中检索所有的产品名称以及对应的销售总数
`Products` 表中检索所有的产品名称:`prod_name`、产品 id:`prod_id`
@@ -1653,12 +1661,12 @@ ORDER BY prod_name
注意:`vend_id` 列会显示在多个表中,因此在每次引用它时都需要完全限定它。
```sql
-SELECT vend_id, COUNT(prod_id) AS prod_id
-FROM Vendors
-LEFT JOIN Products
+SELECT v.vend_id, COUNT(prod_id) AS prod_id
+FROM Vendors v
+LEFT JOIN Products p
USING(vend_id)
-GROUP BY vend_id
-ORDER BY vend_id
+GROUP BY v.vend_id
+ORDER BY v.vend_id
```
## 组合查询
diff --git a/docs/database/sql/sql-questions-02.md b/docs/database/sql/sql-questions-02.md
index 2a4a3e496c6..b0ce5fa2499 100644
--- a/docs/database/sql/sql-questions-02.md
+++ b/docs/database/sql/sql-questions-02.md
@@ -1,9 +1,14 @@
---
title: SQL常见面试题总结(2)
+description: SQL常见面试题总结第二篇,详解INSERT、UPDATE、DELETE等DML数据操作语句,包括批量插入、从其他表导入、带更新的插入等实战技巧。
category: 数据库
tag:
- 数据库基础
- SQL
+head:
+ - - meta
+ - name: keywords
+ content: SQL面试题,INSERT插入,UPDATE更新,DELETE删除,批量插入,REPLACE INTO,数据操作
---
> 题目来源于:[牛客题霸 - SQL 进阶挑战](https://www.nowcoder.com/exam/oj?page=1&tab=SQL%E7%AF%87&topicId=240)
diff --git a/docs/database/sql/sql-questions-03.md b/docs/database/sql/sql-questions-03.md
index 6ae8642c647..a445fef8280 100644
--- a/docs/database/sql/sql-questions-03.md
+++ b/docs/database/sql/sql-questions-03.md
@@ -1,9 +1,14 @@
---
title: SQL常见面试题总结(3)
+description: SQL常见面试题总结第三篇,深入讲解聚合函数COUNT、SUM、AVG、MAX、MIN的使用,以及GROUP BY分组、HAVING过滤、截断平均值计算等进阶技巧。
category: 数据库
tag:
- 数据库基础
- SQL
+head:
+ - - meta
+ - name: keywords
+ content: SQL面试题,聚合函数,COUNT,SUM,AVG,MAX,MIN,GROUP BY,HAVING,截断平均值
---
> 题目来源于:[牛客题霸 - SQL 进阶挑战](https://www.nowcoder.com/exam/oj?page=1&tab=SQL%E7%AF%87&topicId=240)
@@ -216,9 +221,9 @@ WHERE info.exam_id = record.exam_id
| total_pv | complete_pv | complete_exam_cnt |
| -------- | ----------- | ----------------- |
-| 11 | 7 | 2 |
+| 10 | 7 | 2 |
-解释:表示截止当前,有 11 次试卷作答记录,已完成的作答次数为 7 次(中途退出的为未完成状态,其交卷时间和份数为 NULL),已完成的试卷有 9001 和 9002 两份。
+解释:表示截止当前,有 10 次试卷作答记录,已完成的作答次数为 7 次(中途退出的为未完成状态,其交卷时间和份数为 NULL),已完成的试卷有 9001 和 9002 两份。
**思路**: 这题一看到统计次数,肯定第一时间就要想到用`COUNT`这个函数来解决,问题是要统计不同的记录,该怎么来写?使用子查询就能解决这个题目(这题用 case when 也能写出来,解法类似,逻辑不同而已);首先在做这个题之前,让我们先来了解一下`COUNT`的基本用法;
diff --git a/docs/database/sql/sql-questions-04.md b/docs/database/sql/sql-questions-04.md
index c15eb8e2243..8dd989cd9b0 100644
--- a/docs/database/sql/sql-questions-04.md
+++ b/docs/database/sql/sql-questions-04.md
@@ -1,9 +1,14 @@
---
title: SQL常见面试题总结(4)
+description: SQL常见面试题总结第四篇,详解MySQL 8.0窗口函数ROW_NUMBER、RANK、DENSE_RANK、NTILE、LAG、LEAD等的用法和应用场景。
category: 数据库
tag:
- 数据库基础
- SQL
+head:
+ - - meta
+ - name: keywords
+ content: SQL面试题,窗口函数,ROW_NUMBER,RANK,DENSE_RANK,NTILE,LAG,LEAD,MySQL 8.0
---
> 题目来源于:[牛客题霸 - SQL 进阶挑战](https://www.nowcoder.com/exam/oj?page=1&tab=SQL%E7%AF%87&topicId=240)
@@ -151,7 +156,7 @@ WHERE ranking <= 3
| 5 | 1003 | 9002 | 2021-09-01 12:01:01 | 2021-09-01 12:31:51 | 89 |
| 6 | 1004 | 9002 | 2021-09-01 19:01:01 | 2021-09-01 19:30:01 | 85 |
| 7 | 1005 | 9001 | 2021-09-01 12:01:01 | 2021-09-01 12:31:02 | 85 |
-| 8 | 1006 | 9001 | 2021-09-07 10:01:01 | 2021-09-07 10:21:01 | 84 |
+| 8 | 1006 | 9001 | 2021-09-07 10:02:01 | 2021-09-07 10:21:01 | 84 |
| 9 | 1003 | 9001 | 2021-09-08 12:01:01 | 2021-09-08 12:11:01 | 40 |
| 10 | 1003 | 9002 | 2021-09-01 14:01:01 | (NULL) | (NULL) |
| 11 | 1005 | 9001 | 2021-09-01 14:01:01 | (NULL) | (NULL) |
@@ -163,7 +168,7 @@ WHERE ranking <= 3
| ------- | -------- | ------------------- |
| 9001 | 60 | 2021-09-01 06:00:00 |
-**解释**:试卷 9001 被作答用时有 50 分钟、50 分钟、30 分 1 秒、11 分钟、10 分钟,第二快和第二慢用时之差为 50 分钟-11 分钟=39 分钟,试卷时长为 60 分钟,因此满足大于试卷时长一半的条件,输出试卷 ID、时长、发布时间。
+**解释**:试卷 9001 被作答用时有 50 分钟、58 分钟、30 分 1 秒、19 分钟、10 分钟,第二快和第二慢用时之差为 50 分钟-19 分钟=31 分钟,试卷时长为 60 分钟,因此满足大于试卷时长一半的条件,输出试卷 ID、时长、发布时间。
**思路:**
diff --git a/docs/database/sql/sql-questions-05.md b/docs/database/sql/sql-questions-05.md
index c20af2cad39..39171bfe08f 100644
--- a/docs/database/sql/sql-questions-05.md
+++ b/docs/database/sql/sql-questions-05.md
@@ -1,9 +1,14 @@
---
title: SQL常见面试题总结(5)
+description: SQL常见面试题总结第五篇,详解NULL空值处理技巧,包括IFNULL、COALESCE函数,以及使用CASE WHEN进行条件统计和完成率计算。
category: 数据库
tag:
- 数据库基础
- SQL
+head:
+ - - meta
+ - name: keywords
+ content: SQL面试题,NULL空值处理,IFNULL,COALESCE,CASE WHEN,条件统计,完成率计算
---
> 题目来源于:[牛客题霸 - SQL 进阶挑战](https://www.nowcoder.com/exam/oj?page=1&tab=SQL%E7%AF%87&topicId=240)
@@ -41,26 +46,53 @@ tag:
写法 1:
```sql
-SELECT exam_id,
- count(submit_time IS NULL OR NULL) incomplete_cnt,
- ROUND(count(submit_time IS NULL OR NULL) / count(*), 3) complete_rate
-FROM exam_record
-GROUP BY exam_id
-HAVING incomplete_cnt <> 0
+SELECT
+ exam_id,
+ (COUNT(*) - COUNT(submit_time)) AS incomplete_cnt,
+ ROUND((COUNT(*) - COUNT(submit_time)) / COUNT(*), 3) AS incomplete_rate
+FROM
+ exam_record
+GROUP BY
+ exam_id
+HAVING
+ (COUNT(*) - COUNT(submit_time)) > 0;
```
+利用 `COUNT(*)`统计分组内的总记录数,`COUNT(submit_time)` 只统计 `submit_time` 字段不为 NULL 的记录数(即已完成数)。两者相减,就是未完成数。
+
写法 2:
```sql
-SELECT exam_id,
- count(submit_time IS NULL OR NULL) incomplete_cnt,
- ROUND(count(submit_time IS NULL OR NULL) / count(*), 3) complete_rate
-FROM exam_record
-GROUP BY exam_id
-HAVING incomplete_cnt <> 0
+SELECT
+ exam_id,
+ COUNT(CASE WHEN submit_time IS NULL THEN 1 END) AS incomplete_cnt,
+ ROUND(COUNT(CASE WHEN submit_time IS NULL THEN 1 END) / COUNT(*), 3) AS incomplete_rate
+FROM
+ exam_record
+GROUP BY
+ exam_id
+HAVING
+ COUNT(CASE WHEN submit_time IS NULL THEN 1 END) > 0;
+```
+
+使用 `CASE` 表达式,当条件满足时返回一个非 `NULL` 值(例如 1),否则返回 `NULL`。然后用 `COUNT` 函数来统计非 `NULL` 值的数量。
+
+写法 3:
+
+```sql
+SELECT
+ exam_id,
+ SUM(submit_time IS NULL) AS incomplete_cnt,
+ ROUND(SUM(submit_time IS NULL) / COUNT(*), 3) AS incomplete_rate
+FROM
+ exam_record
+GROUP BY
+ exam_id
+HAVING
+ incomplete_cnt > 0;
```
-两种写法都可以,只有中间的写法不一样,一个是对符合条件的才`COUNT`,一个是直接上`IF`,后者更为直观,最后这个`having`解释一下, 无论是 `complete_rate` 还是 `incomplete_cnt`,只要不为 0 即可,不为 0 就意味着有未完成的。
+利用 `SUM` 函数对一个表达式求和。当 `submit_time` 为 `NULL` 时,表达式 `(submit_time IS NULL)` 的值为 1 (TRUE),否则为 0 (FALSE)。将这些 1 和 0 加起来,就得到了未完成的数量。
### 0 级用户高难度试卷的平均用时和平均得分
diff --git a/docs/database/sql/sql-syntax-summary.md b/docs/database/sql/sql-syntax-summary.md
index d845fdaccf7..679c1b59255 100644
--- a/docs/database/sql/sql-syntax-summary.md
+++ b/docs/database/sql/sql-syntax-summary.md
@@ -1,9 +1,14 @@
---
title: SQL语法基础知识总结
+description: SQL语法基础知识总结,系统讲解DDL数据定义、DML数据操作、DQL数据查询、DCL数据控制语言,涵盖表操作、约束、索引、事务、连接查询等核心知识点。
category: 数据库
tag:
- 数据库基础
- SQL
+head:
+ - - meta
+ - name: keywords
+ content: SQL语法,DDL,DML,DQL,DCL,CREATE,SELECT,INSERT,UPDATE,DELETE,JOIN连接,子查询
---
> 本文整理完善自下面这两份资料:
@@ -148,7 +153,7 @@ WHERE username = 'root';
### 删除数据
- `DELETE` 语句用于删除表中的记录。
-- `TRUNCATE TABLE` 可以清空表,也就是删除所有行。
+- `TRUNCATE TABLE` 可以清空表,也就是删除所有行。说明:`TRUNCATE` 语句不属于 DML 语法而是 DDL 语法。
**删除表中的指定数据**
@@ -257,11 +262,11 @@ ORDER BY cust_name DESC;
**使用 WHERE 和 HAVING 过滤数据**
```sql
-SELECT cust_name, COUNT(*) AS num
+SELECT cust_name, COUNT(*) AS NumberOfOrders
FROM Customers
WHERE cust_email IS NOT NULL
GROUP BY cust_name
-HAVING COUNT(*) >= 1;
+HAVING COUNT(*) > 1;
```
**`having` vs `where`**:
@@ -396,7 +401,7 @@ WHERE prod_price BETWEEN 3 AND 5;
**AND 示例**
-```ini
+```sql
SELECT prod_id, prod_name, prod_price
FROM products
WHERE vend_id = 'DLL01' AND prod_price <= 4;
@@ -867,7 +872,7 @@ COMMIT;
## 权限控制
-要授予用户帐户权限,可以用`GRANT`命令。有撤销用户的权限,可以用`REVOKE`命令。这里以 MySQl 为例,介绍权限控制实际应用。
+要授予用户帐户权限,可以用`GRANT`命令。要撤销用户的权限,可以用`REVOKE`命令。这里以 MySQL 为例,介绍权限控制实际应用。
`GRANT`授予权限语法:
diff --git a/docs/distributed-system/api-gateway.md b/docs/distributed-system/api-gateway.md
index e9b9f27e6dd..091bd1b079f 100644
--- a/docs/distributed-system/api-gateway.md
+++ b/docs/distributed-system/api-gateway.md
@@ -1,21 +1,41 @@
---
title: API网关基础知识总结
+description: API网关基础知识详解,涵盖网关核心功能、请求转发、安全认证、流量控制及常见网关选型对比。
category: 分布式
---
## 什么是网关?
-微服务背景下,一个系统被拆分为多个服务,但是像安全认证,流量控制,日志,监控等功能是每个服务都需要的,没有网关的话,我们就需要在每个服务中单独实现,这使得我们做了很多重复的事情并且没有一个全局的视图来统一管理这些功能。
+API 网关(API Gateway)是位于客户端与后端服务之间的**统一入口**,所有客户端请求先经过网关,再由网关路由到具体的目标服务。
+
+### 核心价值
+
+在微服务架构下,一个系统被拆分为多个服务。像**安全认证、流量控制、日志、监控**等功能是每个服务都需要的。如果没有网关,我们需要在每个服务中单独实现这些功能,导致:
+
+- **代码重复**:相同逻辑在多个服务中冗余实现
+- **管理分散**:缺乏统一的配置和监控视图
+- **维护成本高**:功能变更需要修改所有服务

-一般情况下,网关可以为我们提供请求转发、安全认证(身份/权限认证)、流量控制、负载均衡、降级熔断、日志、监控、参数校验、协议转换等功能。
+### 核心职责
+
+网关的功能虽然繁多,但核心可以概括为两件事:
+
+| 职责 | 说明 | 典型功能 |
+| ------------ | ----------------------------------- | -------------------------------------- |
+| **请求转发** | 将客户端请求路由到正确的目标服务 | 动态路由、负载均衡、协议转换 |
+| **请求过滤** | 在请求到达后端服务前/后进行拦截处理 | 身份认证、权限校验、限流熔断、日志记录 |
-上面介绍了这么多功能,实际上,网关主要做了两件事情:**请求转发** + **请求过滤**。
+网关可以提供请求转发、安全认证(身份/权限认证)、流量控制、负载均衡、降级熔断、日志、监控、参数校验、协议转换等功能。
-由于引入网关之后,会多一步网络转发,因此性能会有一点影响(几乎可以忽略不计,尤其是内网访问的情况下)。 另外,我们需要保障网关服务的高可用,避免单点风险。
+**网关在微服务架构中的位置**:所有客户端请求先到达网关,网关负责统一的认证鉴权、流量控制、路由分发,后端服务专注于业务逻辑处理。
-如下图所示,网关服务外层通过 Nginx(其他负载均衡设备/软件也行) 进⾏负载转发以达到⾼可⽤。Nginx 在部署的时候,尽量也要考虑高可用,避免单点风险。
+### 高可用部署
+
+引入网关后会增加一次网络转发(性能损耗在内网环境下通常可忽略),但同时也引入了新的单点风险。因此,网关服务本身必须保障高可用:
+
+如下图所示,网关服务外层通过 Nginx(或其他负载均衡设备/软件)进行负载转发以达到高可用。Nginx 在部署时也应考虑高可用,避免单点风险。

@@ -75,20 +95,40 @@ Zuul 主要通过过滤器(类似于 AOP)来过滤请求,从而实现网

+> **重要提示**:Spring Cloud 官方已在 **Hoxton 版之后将 Zuul 1.x 移除**。尽管 Netflix 开源了 Zuul 2.x,但 Zuul 2.x 并未被集成到 Spring Cloud 主流版本中。对于 Spring Cloud 技术栈的新项目,**严禁选用 Zuul 1.x**,推荐直接使用 Spring Cloud Gateway。
+
- GitHub 地址:
- 官方 Wiki:
### Spring Cloud Gateway
-SpringCloud Gateway 属于 Spring Cloud 生态系统中的网关,其诞生的目标是为了替代老牌网关 **Zuul**。准确点来说,应该是 Zuul 1.x。SpringCloud Gateway 起步要比 Zuul 2.x 更早。
+Spring Cloud Gateway 属于 Spring Cloud 生态系统中的网关,其诞生的目标是为了替代老牌网关 **Zuul**(准确说是 Zuul 1.x)。值得注意的是,Spring Cloud Gateway 的起步时间早于 Zuul 2.x,两者属于不同的技术演进路线。
+
+#### 为什么 Spring Cloud Gateway 性能更好?
+
+| 版本 | IO 模型 | 线程模型 | 吞吐量 | 延迟 |
+| ------------------------ | ------------------- | ------------ | ------ | ---- |
+| **Zuul 1.x** | 同步阻塞(Servlet) | 每请求一线程 | 低 | 高 |
+| **Zuul 2.x** | 异步非阻塞(Netty) | 事件循环 | 高 | 低 |
+| **Spring Cloud Gateway** | 异步非阻塞(Netty) | 事件循环 | 高 | 低 |
-为了提升网关的性能,SpringCloud Gateway 基于 Spring WebFlux 。Spring WebFlux 使用 Reactor 库来实现响应式编程模型,底层基于 Netty 实现同步非阻塞的 I/O。
+Spring Cloud Gateway 基于 **Spring WebFlux** 实现,而不是传统的 Spring WebMVC。Spring WebFlux 使用 **Reactor** 库来实现响应式编程模型,底层基于 **Netty** 实现异步非阻塞的 I/O。
-
+**响应式编程的优势**:
-Spring Cloud Gateway 不仅提供统一的路由方式,并且基于 Filter 链的方式提供了网关基本的功能,例如:安全,监控/指标,限流。
+- **非阻塞 I/O**:无需为每个请求分配独立线程,少量线程即可处理大量并发连接
+- **背压机制**:当下游服务处理能力不足时,自动调节上游请求速率,防止雪崩
+- **资源利用率高**:线程上下文切换开销大幅降低
-Spring Cloud Gateway 和 Zuul 2.x 的差别不大,也是通过过滤器来处理请求。不过,目前更加推荐使用 Spring Cloud Gateway 而非 Zuul,Spring Cloud 生态对其支持更加友好。
+#### 核心概念
+
+Spring Cloud Gateway 的核心组件包括三个部分:
+
+1. **Route(路由)**:网关的基本构建块,由 ID、目标 URI、断言集合和过滤器集合组成
+2. **Predicate(断言)**:这是 Java 8 的 `Predicate` 函数,用于匹配 HTTP 请求(如路径、方法、请求头等)
+3. **Filter(过滤器)**:`GatewayFilter` 的实例,用于在请求被发送到下游服务之前或之后修改请求和响应
+
+Spring Cloud Gateway 和 Zuul 2.x 都是通过过滤器来处理请求,但 Spring Cloud Gateway 与 Spring 生态系统(如 Eureka、Consul、Config)集成更加紧密。目前,对于 Java 技术栈的项目,Spring Cloud Gateway 是推荐的选择。
- Github 地址:
- 官网:
@@ -117,12 +157,18 @@ OpenResty 基于 Nginx,主要还是看中了其优秀的高并发能力。不
Kong 是一款基于 [OpenResty](https://github.com/openresty/) (Nginx + Lua)的高性能、云原生、可扩展、生态丰富的网关系统,主要由 3 个组件组成:
- Kong Server:基于 Nginx 的服务器,用来接收 API 请求。
-- Apache Cassandra/PostgreSQL:用来存储操作数据。
-- Kong Dashboard:官方推荐 UI 管理工具,当然,也可以使用 RESTful 方式 管理 Admin api。
+- Apache Cassandra/PostgreSQL:用来存储操作数据(传统模式)。
+- Kong Manager:官方 UI 管理工具,提供可视化的 API 管理、监控和配置功能(有 OSS 开源版和 Enterprise 企业版)。也可使用 RESTful Admin API 进行管理。

-由于默认使用 Apache Cassandra/PostgreSQL 存储数据,Kong 的整个架构比较臃肿,并且会带来高可用的问题。
+Kong 早期确实依赖外部数据库存储配置,架构相对复杂,需要额外保障数据库层的高可用。但自 **Kong 1.1** 版本起,已支持 **DB-less 模式(无库模式)**:
+
+- **传统模式**:使用 PostgreSQL 或 Cassandra 存储配置,适合需要持久化 API 数据的场景
+- **DB-less 模式**:通过声明式配置文件管理,无需部署数据库,架构更加轻量
+- **Kubernetes Ingress 模式**:通过 ConfigMap 或 CRD(Kubernetes Custom Resource Definitions)管理配置,无需数据库,是 K8s 环境下的主流用法
+
+> **注意**:本文后续讨论的 Kong 高可用问题,主要针对传统模式。在 K8s 环境使用 Ingress Controller 模式时,架构已大幅简化。
Kong 提供了插件机制来扩展其功能,插件在 API 请求响应循环的生命周期中被执行。比如在服务上启用 Zipkin 插件:
@@ -170,13 +216,6 @@ APISIX 同样支持定制化的插件开发。开发者除了能够使用 Lua
- Github 地址:
- 官网地址:
-相关阅读:
-
-- [为什么说 Apache APISIX 是最好的 API 网关?](https://mp.weixin.qq.com/s/j8ggPGEHFu3x5ekJZyeZnA)
-- [有了 NGINX 和 Kong,为什么还需要 Apache APISIX](https://www.apiseven.com/zh/blog/why-we-need-Apache-APISIX)
-- [APISIX 技术博客](https://www.apiseven.com/zh/blog)
-- [APISIX 用户案例](https://www.apiseven.com/zh/usercases)(推荐)
-
### Shenyu
Shenyu 是一款基于 WebFlux 的可扩展、高性能、响应式网关,Apache 顶级开源项目。
@@ -185,21 +224,35 @@ Shenyu 是一款基于 WebFlux 的可扩展、高性能、响应式网关,Apac
Shenyu 通过插件扩展功能,插件是 ShenYu 的灵魂,并且插件也是可扩展和热插拔的。不同的插件实现不同的功能。Shenyu 自带了诸如限流、熔断、转发、重写、重定向、和路由监控等插件。
-- Github 地址:
+- Github 地址:
- 官网地址:
-## 如何选择?
+### 网关对比一览
+
+| 特性 | Zuul 1.x | Zuul 2.x | Spring Cloud Gateway | Kong | APISIX | Shenyu |
+| -------------- | -------- | -------------- | ------------------------- | ----------------------------- | ---------------- | --------------- |
+| **IO 模型** | 同步阻塞 | 异步非阻塞 | 异步非阻塞 | 异步非阻塞 | 异步非阻塞 | 异步非阻塞 |
+| **底层技术** | Servlet | Netty | WebFlux + Netty | OpenResty (Nginx + Lua) | OpenResty + etcd | WebFlux + Netty |
+| **性能** | 低 | 高 | 高 | 很高 | 很高 | 高 |
+| **动态配置** | 需重启 | 支持 | 支持 | 支持 | 支持(热更新) | 支持 |
+| **配置存储** | 内存 | 内存 | 内存 | 数据库 / YAML / K8s CRD | etcd(分布式) | 内存/数据库 |
+| **限流熔断** | 需集成 | 需集成 | 内置(集成 Resilience4j) | 插件 | 插件 | 插件 |
+| **生态系统** | Netflix | Netflix | Spring Cloud | CNCF / Kong | Apache | Apache |
+| **运维复杂度** | 低 | 中 | 低 | 中(DB-less) / 高(DB Mode) | 中 | 中 |
+| **学习曲线** | 平缓 | 平缓 | 平缓 | 陡峭(Lua) | 陡峭(Lua) | 平缓(Java) |
+| **适用场景** | 遗留系统 | Netflix 技术栈 | Spring Cloud 生态 | 云原生、多语言 | 云原生、高性能 | Java 生态 |
-上面介绍的几个常见的网关系统,最常用的是 Spring Cloud Gateway、Kong、APISIX 这三个。
-
-对于公司业务以 Java 为主要开发语言的情况下,Spring Cloud Gateway 通常是个不错的选择,其优点有:简单易用、成熟稳定、与 Spring Cloud 生态系统兼容、Spring 社区成熟等等。不过,Spring Cloud Gateway 也有一些局限性和不足之处, 一般还需要结合其他网关一起使用比如 OpenResty。并且,其性能相比较于 Kong 和 APISIX,还是差一些。如果对性能要求比较高的话,Spring Cloud Gateway 不是一个好的选择。
+## 如何选择?
-Kong 和 APISIX 功能更丰富,性能更强大,技术架构更贴合云原生。Kong 是开源 API 网关的鼻祖,生态丰富,用户群体庞大。APISIX 属于后来者,更优秀一些,根据 APISIX 官网介绍:“APISIX 已经生产可用,功能、性能、架构全面优于 Kong”。下面简单对比一下二者:
+选择 API 网关需要综合考虑技术栈、性能要求、团队能力和运维成本。
-- APISIX 基于 etcd 来做配置中心,不存在单点问题,云原生友好;而 Kong 基于 Apache Cassandra/PostgreSQL ,存在单点风险,需要额外的基础设施保障做高可用。
-- APISIX 支持热更新,并且实现了毫秒级别的热更新响应;而 Kong 不支持热更新。
-- APISIX 的性能要优于 Kong 。
-- APISIX 支持的插件更多,功能更丰富。
+| 场景 | 推荐方案 | 理由 |
+| --------------------- | ---------------------------------------------------------- | ----------------------------------------------------------------- |
+| **Spring Cloud 生态** | Spring Cloud Gateway | 与 Spring Boot/Spring Cloud 无缝集成,配置简单 |
+| **高性能 / 云原生** | APISIX | 基于 etcd 的热更新、性能优异、云原生架构 |
+| **多语言生态** | Kong | 插件丰富、支持多语言开发、社区成熟 |
+| **Netflix 技术栈** | Zuul 2.x | 与 Eureka、Ribbon、Hystrix 等组件无缝配合 |
+| **双层架构(推荐)** | Kong/APISIX(流量网关) + Spring Cloud Gateway(业务网关) | 流量网关处理 SSL、WAF、全局限流;业务网关处理微服务鉴权、参数聚合 |
## 参考
diff --git a/docs/distributed-system/distributed-configuration-center.md b/docs/distributed-system/distributed-configuration-center.md
index 2e00aec70a3..0c71c519cdb 100644
--- a/docs/distributed-system/distributed-configuration-center.md
+++ b/docs/distributed-system/distributed-configuration-center.md
@@ -1,5 +1,6 @@
---
title: 分布式配置中心常见问题总结(付费)
+description: 分布式配置中心核心概念与面试题解析,涵盖Apollo、Nacos等主流配置中心原理与实践要点。
category: 分布式
---
@@ -8,5 +9,3 @@ category: 分布式

-
-
diff --git a/docs/distributed-system/distributed-id-design.md b/docs/distributed-system/distributed-id-design.md
index adbf61c9803..57077904251 100644
--- a/docs/distributed-system/distributed-id-design.md
+++ b/docs/distributed-system/distributed-id-design.md
@@ -1,5 +1,6 @@
---
title: 分布式ID设计指南
+description: 分布式ID设计实战指南,结合订单系统、优惠券等业务场景讲解分布式ID的设计要点与技术选型。
category: 分布式
---
@@ -99,7 +100,7 @@ UA 是一个特殊字符串头,服务器依次可以识别出客户使用的
abcdefghijklmnopqrstuvwxyzABCDEFGHJKLMNPQRSTUVWXZY0123456789
-之前说过,兑换码要求近可能简洁,那么设计时就需要考虑兑换码的字符数,假设上限为 12 位,而字符空间有 60 位,那么可以表示的空间范围为 60^12=130606940160000000000000(也就是可以 12 位的兑换码可以生成天量,应该够运营同学挥霍了),转换成 2 进制:
+之前说过,兑换码要求尽可能简洁,那么设计时就需要考虑兑换码的字符数,假设上限为 12 位,而字符空间有 60 位,那么可以表示的空间范围为 60^12=130606940160000000000000(也就是可以 12 位的兑换码可以生成天量,应该够运营同学挥霍了),转换成 2 进制:
1001000100000000101110011001101101110011000000000000000000000(61 位)
diff --git a/docs/distributed-system/distributed-id.md b/docs/distributed-system/distributed-id.md
index 608ee3d18fc..fd117f94e2c 100644
--- a/docs/distributed-system/distributed-id.md
+++ b/docs/distributed-system/distributed-id.md
@@ -1,15 +1,18 @@
---
title: 分布式ID介绍&实现方案总结
+description: 分布式ID生成方案详解,涵盖UUID、数据库自增、号段模式、雪花算法等主流方案的原理与优缺点对比。
category: 分布式
---
+
+
## 分布式 ID 介绍
### 什么是 ID?
日常开发中,我们需要对系统中的各种数据使用 ID 唯一表示,比如用户 ID 对应且仅对应一个人,商品 ID 对应且仅对应一件商品,订单 ID 对应且仅对应一个订单。
-我们现实生活中也有各种 ID,比如身份证 ID 对应且仅对应一个人、地址 ID 对应且仅对应
+我们现实生活中也有各种 ID,比如身份证 ID 对应且仅对应一个人、地址 ID 对应且仅对应一个地址。
简单来说,**ID 就是数据的唯一标识**。
@@ -47,11 +50,9 @@ category: 分布式
- **有具体的业务含义**:生成的 ID 如果能有具体的业务含义,可以让定位问题以及开发更透明化(通过 ID 就能确定是哪个业务)。
- **独立部署**:也就是分布式系统单独有一个发号器服务,专门用来生成分布式 ID。这样就生成 ID 的服务可以和业务相关的服务解耦。不过,这样同样带来了网络调用消耗增加的问题。总的来说,如果需要用到分布式 ID 的场景比较多的话,独立部署的发号器服务还是很有必要的。
-## 分布式 ID 常见解决方案
-
-### 数据库
+## 基于数据库的生成方案(有状态)
-#### 数据库主键自增
+### 数据库主键自增
这种方式就比较简单直白了,就是通过关系型数据库的自增主键产生来唯一的 ID。
@@ -81,18 +82,22 @@ SELECT LAST_INSERT_ID();
COMMIT;
```
-插入数据这里,我们没有使用 `insert into` 而是使用 `replace into` 来插入数据,具体步骤是这样的:
+**⚠️ REPLACE INTO 的生产隐患**:
+
+`REPLACE INTO` 本质是 **`DELETE` + `INSERT`** 的组合操作:
-- 第一步:尝试把数据插入到表中。
+- 如果主键或唯一索引字段出现重复数据错误而插入失败时,先从表中删除含有重复关键字值的冲突行,然后再次尝试把数据插入到表中。
+- 每次操作都会触发索引删除和重建,对数据库压力较大。
+- 如果表上有触发器,DELETE 操作会意外触发。
-- 第二步:如果主键或唯一索引字段出现重复数据错误而插入失败时,先从表中删除含有重复关键字值的冲突行,然后再次尝试把数据插入到表中。
+**替代方案**:生产环境推荐使用号段模式(下面会介绍),或改用 `INSERT ... ON DUPLICATE KEY UPDATE` 减少索引震荡。
这种方式的优缺点也比较明显:
-- **优点**:实现起来比较简单、ID 有序递增、存储消耗空间小
-- **缺点**:支持的并发量不大、存在数据库单点问题(可以使用数据库集群解决,不过增加了复杂度)、ID 没有具体业务含义、安全问题(比如根据订单 ID 的递增规律就能推算出每天的订单量,商业机密啊! )、每次获取 ID 都要访问一次数据库(增加了对数据库的压力,获取速度也慢)
+- **优点**:实现起来比较简单、ID 有序递增、存储消耗空间小。
+- **缺点**:支持的并发量不大、存在数据库单点问题(可以使用数据库集群解决,不过增加了复杂度)、ID 没有具体业务含义、安全问题(比如根据订单 ID 的递增规律就能推算出每天的订单量,商业机密啊! )、每次获取 ID 都要访问一次数据库(增加了对数据库的压力,获取速度也慢)。
-#### 数据库号段模式
+### 数据库号段模式
数据库主键自增这种模式,每次获取 ID 都要访问一次数据库,ID 需求比较大的时候,肯定是不行的。
@@ -119,7 +124,21 @@ CREATE TABLE `sequence_id_generator` (

-`version` 字段主要用于解决并发问题(乐观锁),`biz_type` 主要用于表示业务类型。
+`version` 字段主要用于解决并发问题(乐观锁),完整流程如下:
+
+```sql
+-- 1. 读取当前值
+SELECT current_max_id, step, version FROM sequence_id_generator WHERE biz_type = 101;
+-- 2. CAS 更新(version 作为乐观锁版本号)
+UPDATE sequence_id_generator
+SET current_max_id = current_max_id + step, version = version + 1
+WHERE version = {当前读取的version} AND biz_type = 101;
+-- 3. 检查 affected_rows,为 1 表示成功,为 0 表示被其他线程抢先,需重试
+```
+
+> **⚠️ 高并发重试提醒**:在号段耗尽瞬间,多个线程可能同时争抢新号段,CAS 更新可能失败。代码层面需要实现**有限次数的重试循环**(如 3 次),确保请求稳定性。若重试仍失败,应降级为阻塞等待或返回降级 ID。
+
+`biz_type` 主要用于表示业务类型。
**2. 先插入一行数据。**
@@ -165,7 +184,7 @@ id current_max_id step version biz_type
- **优点**:ID 有序递增、存储消耗空间小
- **缺点**:存在数据库单点问题(可以使用数据库集群解决,不过增加了复杂度)、ID 没有具体业务含义、安全问题(比如根据订单 ID 的递增规律就能推算出每天的订单量,商业机密啊! )
-#### NoSQL
+### NoSQL

@@ -188,30 +207,53 @@ OK
关于 Redis 持久化,我这里就不过多介绍。不了解这部分内容的小伙伴,可以看看 [Redis 持久化机制详解](https://javaguide.cn/database/redis/redis-persistence.html)这篇文章。
+虽然 Redis `INCR` 性能优异,但存在以下失败路径需要特别注意:
+
+1. **持久化延迟导致 ID 回退**
+
+ - **场景**:执行 `INCR` 后,Redis 在 RDB/AOF 刷盘前崩溃。
+ - **后果**:重启后 ID 回退到上次持久化的值,可能产生重复 ID。
+
+2. **AOF 重写导致短暂阻塞**
+ - **场景**:AOF 文件过大触发重写。
+ - **后果**:主进程 fork 子进程可能导致短暂的性能抖动。
+
+**生产配置建议**:
+
+```conf
+# Redis 7.0+ 推荐配置
+appendonly yes
+appendfsync everysec
+aof-use-rdb-preamble yes # 混合持久化,RDB+AOF 组合
+```
+
+- **Redis 7.0+ 优化**:多部分 AOF(Multi-part AOF)机制进一步降低重写时的 IO 阻塞风险。
+- **替代方案**:使用 Lua 脚本 + `SETNX` 实现幂等检查,或对 ID 唯一性要求极高的场景使用数据库号段模式。
+
**Redis 方案的优缺点:**
-- **优点**:性能不错并且生成的 ID 是有序递增的
-- **缺点**:和数据库主键自增方案的缺点类似
+- **优点**:性能不错并且生成的 ID 是有序递增的。
+- **缺点**:和数据库主键自增方案的缺点类似,且存在持久化导致 ID 回退的风险。
除了 Redis 之外,MongoDB ObjectId 经常也会被拿来当做分布式 ID 的解决方案。
-
+
MongoDB ObjectId 一共需要 12 个字节存储:
-- 0~3:时间戳
+- 0~3:Unix 时间戳(**秒级精度**,4 字节)
- 3~6:代表机器 ID
- 7~8:机器进程 ID
- 9~11:自增值
**MongoDB 方案的优缺点:**
-- **优点**:性能不错并且生成的 ID 是有序递增的
-- **缺点**:需要解决重复 ID 问题(当机器时间不对的情况下,可能导致会产生重复 ID)、有安全性问题(ID 生成有规律性)
+- **优点**:性能不错并且生成的 ID 是有序递增的。
+- **缺点**:需要解决重复 ID 问题(当机器时间不对的情况下,可能导致会产生重复 ID)、有安全性问题(ID 生成有规律性)。
-### 算法
+## 基于算法的生成方案(无状态)
-#### UUID
+### UUID
UUID 是 Universally Unique Identifier(通用唯一标识符) 的缩写。UUID 包含 32 个 16 进制数字(8-4-4-4-12)。
@@ -222,18 +264,22 @@ JDK 就提供了现成的生成 UUID 的方法,一行代码就行了。
UUID.randomUUID()
```
-[RFC 4122](https://tools.ietf.org/html/rfc4122) 中关于 UUID 的示例是这样的:
+[RFC 4122](https://tools.ietf.org/html/rfc4122) 定义了 UUID v1-v5,2024 年发布的 [RFC 9562](https://www.rfc-editor.org/rfc/rfc9562.html) 新增了 v6、v7、v8。RFC 9562 中关于 UUID 的示例是这样的:

我们这里重点关注一下这个 Version(版本),不同的版本对应的 UUID 的生成规则是不同的。
-5 种不同的 Version(版本)值分别对应的含义(参考[维基百科对于 UUID 的介绍](https://zh.wikipedia.org/wiki/通用唯一识别码)):
+8 种不同的 Version(版本)值分别对应的含义(参考[维基百科对于 UUID 的介绍](https://zh.wikipedia.org/wiki/通用唯一识别码)):
-- **版本 1** : UUID 是根据时间和节点 ID(通常是 MAC 地址)生成;
-- **版本 2** : UUID 是根据标识符(通常是组或用户 ID)、时间和节点 ID 生成;
-- **版本 3、版本 5** : 版本 5 - 确定性 UUID 通过散列(hashing)名字空间(namespace)标识符和名称生成;
-- **版本 4** : UUID 使用[随机性](https://zh.wikipedia.org/wiki/随机性)或[伪随机性](https://zh.wikipedia.org/wiki/伪随机性)生成。
+- **版本 1 (基于时间和节点 ID)** : 基于时间戳(通常是当前时间)和节点 ID(通常为设备的 MAC 地址)生成。当包含 MAC 地址时,可以保证全球唯一性,但也因此存在隐私泄露的风险。
+- **版本 2 (基于标识符、时间和节点 ID)** : 与版本 1 类似,也基于时间和节点 ID,但额外包含了本地标识符(例如用户 ID 或组 ID)。
+- **版本 3 (基于命名空间和名称的 MD5 哈希)**:使用 MD5 哈希算法,将命名空间标识符(一个 UUID)和名称字符串组合计算得到。相同的命名空间和名称总是生成相同的 UUID(**确定性生成**)。
+- **版本 4 (基于随机数)**:几乎完全基于随机数生成,通常使用伪随机数生成器(PRNG)或加密安全随机数生成器(CSPRNG)来生成。 虽然理论上存在碰撞的可能性,但理论上碰撞概率极低(2^122 的可能性),可以认为在实际应用中是唯一的。
+- **版本 5 (基于命名空间和名称的 SHA-1 哈希)**:类似于版本 3,但使用 SHA-1 哈希算法。
+- **版本 6 (基于时间戳、计数器和节点 ID)**:改进了版本 1,将时间戳放在最高有效位(Most Significant Bit,MSB),使得 UUID 可以直接按时间排序。
+- **版本 7 (基于 Unix 毫秒时间戳)**:**48 位 Unix 毫秒时间戳 + 74 位随机/单调字段**。时间戳位于最高有效位,支持按时间排序。RFC 9562 **推荐使用 v7 替代 v1/v6**。可选的 12 位亚毫秒时间戳 + 计数器可保证毫秒内的单调性。
+- **版本 8 (实验性/供应商定制)**:**122 位留给实现自定义**,仅要求版本和变体位固定。适用于嵌入额外信息或特殊应用限制的场景。**唯一性由实现保证,不可假设**。
下面是 Version 1 版本下生成的 UUID 的示例:
@@ -259,28 +305,83 @@ int version = uuid.version();// 4
- 数据库主键要尽量越短越好,而 UUID 的消耗的存储空间比较大(32 个字符串,128 位)。
- UUID 是无顺序的,InnoDB 引擎下,数据库主键的无序性会严重影响数据库性能。
+UUID v7([RFC 9562](https://www.rfc-editor.org/rfc/rfc9562))是目前**替代 Snowflake 的最佳无中心化方案**:
+
+**RFC 9562 官方推荐**:实现应尽可能使用 UUID v7 替代 UUID v1/v6。
+
+| 特性 | Snowflake | UUID v7 |
+| ------------------ | ------------------------- | -------------------------------------- |
+| **Worker ID 管理** | 需要中心化分配(ZK/etcd) | 无需分配,开箱即用 |
+| **时钟回拨风险** | 需要额外处理 | 毫秒内允许乱序,天然规避 |
+| **B+ 树友好** | 趋势递增 | 天然有序 |
+| **标准化** | 各家实现不一 | RFC 标准,跨语言兼容 |
+| **结构** | 64 位(自定义) | 128 位(48 位时间戳 + 74 位随机/单调) |
+
+**适用场景**:中小规模分布式系统、无需 Snowflake 级性能的场景。
+
+**UUID v8(实验性用途)**:如果需要嵌入额外信息(如业务标识、集群信息)或有特殊应用限制,可考虑 UUID v8。但需注意:**v8 的唯一性由实现保证,不可假设与其他实现兼容**。
+
+⚠️ **注意**:部分数据库(MySQL 8.0.37 以下、PostgreSQL 15 以下)需通过函数生成 UUID v7,原生支持尚在普及中。
+
最后,我们再简单分析一下 **UUID 的优缺点** (面试的时候可能会被问到的哦!) :
-- **优点**:生成速度比较快、简单易用
-- **缺点**:存储消耗空间大(32 个字符串,128 位)、 不安全(基于 MAC 地址生成 UUID 的算法会造成 MAC 地址泄露)、无序(非自增)、没有具体业务含义、需要解决重复 ID 问题(当机器时间不对的情况下,可能导致会产生重复 ID)
+- **优点**:生成速度通常比较快、简单易用。
+- **缺点**:存储消耗空间大(32 个字符串,128 位)、 不安全(基于 MAC 地址生成 UUID 的算法会造成 MAC 地址泄露)、无序(非自增)、没有具体业务含义、需要解决重复 ID 问题(当机器时间不对的情况下,可能导致会产生重复 ID)。
-#### Snowflake(雪花算法)
+### Snowflake(雪花算法)
Snowflake 是 Twitter 开源的分布式 ID 生成算法。Snowflake 由 64 bit 的二进制数字组成,这 64bit 的二进制被分成了几部分,每一部分存储的数据都有特定的含义:

- **sign(1bit)**:符号位(标识正负),始终为 0,代表生成的 ID 为正数。
-- **timestamp (41 bits)**:一共 41 位,用来表示时间戳,单位是毫秒,可以支撑 2 ^41 毫秒(约 69 年)
+- **timestamp (41 bits)**:一共 41 位,用来表示**相对时间戳**(距自定义基点的毫秒数),可支撑 2^41 毫秒(约 69 年)。通常基点设为系统上线时间(如 2020-01-01),而非 Unix 纪元
- **datacenter id + worker id (10 bits)**:一般来说,前 5 位表示机房 ID,后 5 位表示机器 ID(实际项目中可以根据实际情况调整)。这样就可以区分不同集群/机房的节点。
- **sequence (12 bits)**:一共 12 位,用来表示序列号。 序列号为自增值,代表单台机器每毫秒能够产生的最大 ID 数(2^12 = 4096),也就是说单台机器每毫秒最多可以生成 4096 个 唯一 ID。
+> **⚠️ 高并发警示**:如果某一毫秒内的并发请求超过 4096 个,算法会**阻塞等待直到下一毫秒**。这可能导致在高并发瞬间(如秒杀、大促)出现响应延迟毛刺(Latency Spike)。生产环境需评估峰值 QPS,必要时采用多实例分片或改造算法增加 sequence 位数。
+
在实际项目中,我们一般也会对 Snowflake 算法进行改造,最常见的就是在 Snowflake 算法生成的 ID 中加入业务类型信息。
+#### Snowflake 时钟回拨问题与解决
+
+**问题根因**:NTP 同步、人工调整时间、硬件时钟漂移可能导致系统时间倒退。
+
+**解决方案对比**:
+
+| 方案 | 优点 | 缺点 | 适用场景 |
+| ------------------ | -------------- | ------------------------ | ---------------------- |
+| **拒绝服务** | 实现简单 | 时钟回拨期间完全不可用 | 对可用性要求不高的场景 |
+| **等待追回** | 保证 ID 唯一性 | 可能长时间阻塞 | 时钟稳定的内网环境 |
+| **备用 Worker ID** | 高可用 | 实现复杂,需考虑 ZK 脑裂 | 生产环境推荐 |
+
+**推荐**:生产环境使用美团 Leaf 或 IdGenerator,它们已内置时钟回拨处理。
+
+#### Snowflake Worker ID 分配难题
+
+在**容器化部署(Kubernetes)** 环境下,Snowflake 的 Worker ID 分配成为最大痛点:
+
+**问题场景**:
+
+- Pod 的 IP 和名称是动态的,重启后会变化。
+- 无法像物理机一样预先配置固定的 Worker ID。
+- 自动扩缩容时需要动态申领和释放 Worker ID。
+
+**主流解决方案**:
+
+| 方案 | 实现方式 | 优点 | 缺点 |
+| ------------------ | ---------------------------------------------------- | -------------------- | ----------------------- |
+| **ZooKeeper 注册** | 服务启动时在 ZK 创建临时节点,节点序号作为 Worker ID | 自动回收,崩溃后释放 | 依赖 ZK,增加运维复杂度 |
+| **Redis 注册** | 使用 `SETNX` + 过期时间实现 Worker ID 申领 | 轻量,无额外组件 | 需处理 Redis 宕机场景 |
+| **数据库分配** | 启动时从数据库分配并持久化到本地文件 | 简单可靠 | 依赖数据库 |
+| **动态 Worker ID** | 使用 Pod IP 或 UID 哈希生成 | 无需中心化组件 | 可能产生哈希冲突 |
+
+**推荐**:生产环境使用美团 Leaf(基于 ZooKeeper)或滴滴 Tinyid(基于数据库),它们已内置 Worker ID 自动管理。
+
我们再来看看 Snowflake 算法的优缺点:
-- **优点**:生成速度比较快、生成的 ID 有序递增、比较灵活(可以对 Snowflake 算法进行简单的改造比如加入业务 ID)
-- **缺点**:需要解决重复 ID 问题(ID 生成依赖时间,在获取时间的时候,可能会出现时间回拨的问题,也就是服务器上的时间突然倒退到之前的时间,进而导致会产生重复 ID)、依赖机器 ID 对分布式环境不友好(当需要自动启停或增减机器时,固定的机器 ID 可能不够灵活)。
+- **优点**:生成速度比较快、生成的 ID 有序递增、比较灵活(可以对 Snowflake 算法进行简单的改造比如加入业务 ID)。
+- **缺点**:**时钟回拨风险**(需额外处理,详见上方解决方案)、依赖机器 ID 对分布式环境不友好(当需要自动启停或增减机器时,固定的机器 ID 可能不够灵活)。
如果你想要使用 Snowflake 算法的话,一般不需要你自己再造轮子。有很多基于 Snowflake 算法的开源实现比如美团 的 Leaf、百度的 UidGenerator(后面会提到),并且这些开源实现对原有的 Snowflake 算法进行了优化,性能更优秀,还解决了 Snowflake 算法的时间回拨问题和依赖机器 ID 的问题。
@@ -289,9 +390,9 @@ Snowflake 是 Twitter 开源的分布式 ID 生成算法。Snowflake 由 64 bit
- [Seata 基于改良版雪花算法的分布式 UUID 生成器分析](https://seata.io/zh-cn/blog/seata-analysis-UUID-generator.html)
- [在开源项目中看到一个改良版的雪花算法,现在它是你的了。](https://www.cnblogs.com/thisiswhy/p/17611163.html)
-### 开源框架
+## 工业级分布式 ID 开源框架对比
-#### UidGenerator(百度)
+### UidGenerator(百度)
[UidGenerator](https://github.com/baidu/uid-generator) 是百度开源的一款基于 Snowflake(雪花算法)的唯一 ID 生成器。
@@ -312,7 +413,7 @@ UidGenerator 官方文档中的介绍如下:
自 18 年后,UidGenerator 就基本没有再维护了,我这里也不过多介绍。想要进一步了解的朋友,可以看看 [UidGenerator 的官方介绍](https://github.com/baidu/uid-generator/blob/master/README.zh_cn.md)。
-#### Leaf(美团)
+### Leaf(美团)
[Leaf](https://github.com/Meituan-Dianping/Leaf) 是美团开源的一个分布式 ID 解决方案 。这个项目的名字 Leaf(树叶) 起源于德国哲学家、数学家莱布尼茨的一句话:“There are no two identical leaves in the world”(世界上没有两片相同的树叶) 。这名字起得真心挺不错的,有点文艺青年那味了!
@@ -320,13 +421,17 @@ Leaf 提供了 **号段模式** 和 **Snowflake(雪花算法)** 这两种模式
Leaf 的诞生主要是为了解决美团各个业务线生成分布式 ID 的方法多种多样以及不可靠的问题。
-Leaf 对原有的号段模式进行改进,比如它这里增加了双号段避免获取 DB 在获取号段的时候阻塞请求获取 ID 的线程。简单来说,就是我一个号段还没用完之前,我自己就主动提前去获取下一个号段(图片来自于美团官方文章:[《Leaf——美团点评分布式 ID 生成系统》](https://tech.meituan.com/2017/04/21/mt-leaf.html))。
+Leaf 对原有的号段模式进行了核心优化——**双 Buffer 机制(Double Buffer Optimization)**:
+
+> **设计原理**:Leaf 不会在号段用尽时才去 DB 申请,而是在当前号段使用率达到一定阈值(如 10%~20%)时,异步线程**提前**去 DB 申请下一个号段并预加载到内存。这使得 ID 获取的 TP999 极其平稳,彻底消除了 DB 访问带来的延迟抖动。
+
+(图片来自于美团官方文章:[《Leaf——美团点评分布式 ID 生成系统》](https://tech.meituan.com/2017/04/21/mt-leaf.html))

根据项目 README 介绍,在 4C8G VM 基础上,通过公司 RPC 方式调用,QPS 压测结果近 5w/s,TP999 1ms。
-#### Tinyid(滴滴)
+### Tinyid(滴滴)
[Tinyid](https://github.com/didi/tinyid) 是滴滴开源的一款基于数据库号段模式的唯一 ID 生成器。
@@ -357,7 +462,7 @@ Tinyid 的原理比较简单,其架构如下图所示:
Tinyid 的优缺点这里就不分析了,结合数据库号段模式的优缺点和 Tinyid 的原理就能知道。
-#### IdGenerator(个人)
+### IdGenerator(个人)
和 UidGenerator、Leaf 一样,[IdGenerator](https://github.com/yitter/IdGenerator) 也是一款基于 Snowflake(雪花算法)的唯一 ID 生成器。
@@ -386,6 +491,16 @@ Java 语言使用示例:
diff --git a/docs/distributed-system/distributed-lock-implementations.md b/docs/distributed-system/distributed-lock-implementations.md
index ddd0b93b820..d38726a4d63 100644
--- a/docs/distributed-system/distributed-lock-implementations.md
+++ b/docs/distributed-system/distributed-lock-implementations.md
@@ -1,5 +1,6 @@
---
title: 分布式锁常见实现方案总结
+description: 分布式锁常见实现方案详解,包括基于Redis、ZooKeeper实现分布式锁的原理、优缺点及最佳实践。
category: 分布式
---
@@ -56,7 +57,7 @@ OK
```
- **lockKey**:加锁的锁名;
-- **uniqueValue**:能够唯一标示锁的随机字符串;
+- **uniqueValue**:能够唯一标识锁的随机字符串;
- **NX**:只有当 lockKey 对应的 key 值不存在的时候才能 SET 成功;
- **EX**:过期时间设置(秒为单位)EX 3 标示这个锁有一个 3 秒的自动过期时间。与 EX 对应的是 PX(毫秒为单位),这两个都是过期时间设置。
@@ -202,13 +203,11 @@ Redlock 是直接操作 Redis 节点的,并不是通过 Redis 集群操作的
Redlock 实现比较复杂,性能比较差,发生时钟变迁的情况下还存在安全性隐患。《数据密集型应用系统设计》一书的作者 Martin Kleppmann 曾经专门发文([How to do distributed locking - Martin Kleppmann - 2016](https://martin.kleppmann.com/2016/02/08/how-to-do-distributed-locking.html))怼过 Redlock,他认为这是一个很差的分布式锁实现。感兴趣的朋友可以看看[Redis 锁从面试连环炮聊到神仙打架](https://mp.weixin.qq.com/s?__biz=Mzg3NjU3NTkwMQ==&mid=2247505097&idx=1&sn=5c03cb769c4458350f4d4a321ad51f5a&source=41#wechat_redirect)这篇文章,有详细介绍到 antirez 和 Martin Kleppmann 关于 Redlock 的激烈辩论。
-实际项目中不建议使用 Redlock 算法,成本和收益不成正比。
-
-如果不是非要实现绝对可靠的分布式锁的话,其实单机版 Redis 就完全够了,实现简单,性能也非常高。如果你必须要实现一个绝对可靠的分布式锁的话,可以基于 ZooKeeper 来做,只是性能会差一些。
+实际项目中不建议使用 Redlock 算法,成本和收益不成正比,可以考虑基于 Redis 主从复制+哨兵模式实现分布式锁。
## 基于 ZooKeeper 实现分布式锁
-Redis 实现分布式锁性能较高,ZooKeeper 实现分布式锁可靠性更高。实际项目中,我们应该根据业务的具体需求来选择。
+ZooKeeper 相比于 Redis 实现分布式锁,除了提供相对更高的可靠性之外,在功能层面还有一个非常有用的特性:**Watch 机制**。这个机制可以用来实现公平的分布式锁。不过,使用 ZooKeeper 实现的分布式锁在性能方面相对较差,因此如果对性能要求比较高的话,ZooKeeper 可能就不太适合了。
### 如何基于 ZooKeeper 实现分布式锁?
@@ -365,14 +364,13 @@ private static class LockData
## 总结
-在这篇文章中,我介绍了实现分布式锁的两种常见方式: Redis 和 ZooKeeper。至于具体选择 Redis 还是 ZooKeeper 来实现分布式锁,还是要看业务的具体需求。
+在这篇文章中,我介绍了实现分布式锁的两种常见方式:**Redis** 和 **ZooKeeper**。至于具体选择 Redis 还是 ZooKeeper 来实现分布式锁,还是要根据业务的具体需求来决定。
-- 如果对性能要求比较高的话,建议使用 Redis 实现分布式锁(优先选择 Redisson 提供的现成的分布式锁,而不是自己实现)。
-- 如果对可靠性要求比较高的话,建议使用 ZooKeeper 实现分布式锁(推荐基于 Curator 框架实现)。不过,现在很多项目都不会用到 ZooKeeper,如果单纯是因为分布式锁而引入 ZooKeeper 的话,那是不太可取的,不建议这样做,为了一个小小的功能增加了系统的复杂度。
+- 如果对性能要求比较高的话,建议使用 Redis 实现分布式锁。推荐优先选择 **Redisson** 提供的现成分布式锁,而不是自己实现。实际项目中不建议使用 Redlock 算法,成本和收益不成正比,可以考虑基于 Redis 主从复制+哨兵模式实现分布式锁。
+- 如果对可靠性要求比较高,建议使用 ZooKeeper 实现分布式锁,推荐基于 **Curator** 框架来实现。不过,现在很多项目都不会用到 ZooKeeper,如果单纯是因为分布式锁而引入 ZooKeeper 的话,那是不太可取的,不建议这样做,为了一个小小的功能增加了系统的复杂度。
-最后,再分享两篇我觉得写的还不错的文章:
+需要注意的是,无论选择哪种方式实现分布式锁,包括 Redis、ZooKeeper 或 Etcd(本文没介绍,但也经常用来实现分布式锁),都无法保证 100% 的安全性,特别是在遇到进程垃圾回收(GC)、网络延迟等异常情况下。
-- [分布式锁实现原理与最佳实践 - 阿里云开发者](https://mp.weixin.qq.com/s/JzCHpIOiFVmBoAko58ZuGw)
-- [聊聊分布式锁 - 字节跳动技术团队](https://mp.weixin.qq.com/s/-N4x6EkxwAYDGdJhwvmZLw)
+为了进一步提高系统的可靠性,建议引入一个兜底机制。例如,可以通过 **版本号(Fencing Token)机制** 来避免并发冲突。
diff --git a/docs/distributed-system/distributed-lock.md b/docs/distributed-system/distributed-lock.md
index 2f7f0289ee2..1f48e5dc071 100644
--- a/docs/distributed-system/distributed-lock.md
+++ b/docs/distributed-system/distributed-lock.md
@@ -1,5 +1,6 @@
---
title: 分布式锁介绍
+description: 分布式锁基础概念详解,讲解为什么需要分布式锁、分布式锁的核心特性及常见应用场景分析。
category: 分布式
---
@@ -30,7 +31,7 @@ category: 分布式
悲观锁总是假设最坏的情况,认为共享资源每次被访问的时候就会出现问题(比如共享数据被修改),所以每次在获取资源操作的时候都会上锁,这样其他线程想拿到这个资源就会阻塞直到锁被上一个持有者释放。也就是说,**共享资源每次只给一个线程使用,其它线程阻塞,用完后再把资源转让给其它线程**。
-对于单机多线程来说,在 Java 中,我们通常使用 `ReetrantLock` 类、`synchronized` 关键字这类 JDK 自带的 **本地锁** 来控制一个 JVM 进程内的多个线程对本地共享资源的访问。
+对于单机多线程来说,在 Java 中,我们通常使用 `ReentrantLock` 类、`synchronized` 关键字这类 JDK 自带的 **本地锁** 来控制一个 JVM 进程内的多个线程对本地共享资源的访问。
下面是我对本地锁画的一张示意图。
diff --git a/docs/distributed-system/distributed-process-coordination/zookeeper/zookeeper-in-action.md b/docs/distributed-system/distributed-process-coordination/zookeeper/zookeeper-in-action.md
index af6f3de5a21..06389b2986d 100644
--- a/docs/distributed-system/distributed-process-coordination/zookeeper/zookeeper-in-action.md
+++ b/docs/distributed-system/distributed-process-coordination/zookeeper/zookeeper-in-action.md
@@ -1,10 +1,13 @@
---
title: ZooKeeper 实战
+description: ZooKeeper实战教程,涵盖Docker安装部署、常用命令操作及Curator客户端的使用方法详解。
category: 分布式
tag:
- ZooKeeper
---
+
+
这篇文章简单给演示一下 ZooKeeper 常见命令的使用以及 ZooKeeper Java 客户端 Curator 的基本使用。介绍到的内容都是最基本的操作,能满足日常工作的基本需要。
如果文章有任何需要改善和完善的地方,欢迎在评论区指出,共同进步!
diff --git a/docs/distributed-system/distributed-process-coordination/zookeeper/zookeeper-intro.md b/docs/distributed-system/distributed-process-coordination/zookeeper/zookeeper-intro.md
index 955c5d2813a..b2a21d8ed62 100644
--- a/docs/distributed-system/distributed-process-coordination/zookeeper/zookeeper-intro.md
+++ b/docs/distributed-system/distributed-process-coordination/zookeeper/zookeeper-intro.md
@@ -1,10 +1,13 @@
---
title: ZooKeeper相关概念总结(入门)
+description: ZooKeeper入门指南,讲解ZooKeeper核心概念、数据模型、Watcher机制及作为注册中心和分布式锁的应用。
category: 分布式
tag:
- ZooKeeper
---
+
+
相信大家对 ZooKeeper 应该不算陌生。但是你真的了解 ZooKeeper 到底有啥用不?如果别人/面试官让你给他讲讲对于 ZooKeeper 的认识,你能回答到什么地步呢?
拿我自己来说吧!我本人在大学曾经使用 Dubbo 来做分布式项目的时候,使用了 ZooKeeper 作为注册中心。为了保证分布式系统能够同步访问某个资源,我还使用 ZooKeeper 做过分布式锁。另外,我在学习 Kafka 的时候,知道 Kafka 很多功能的实现依赖了 ZooKeeper。
@@ -57,7 +60,7 @@ ZooKeeper 将数据保存在内存中,性能是不错的。 在“读”多于
- **原子性:** 所有事务请求的处理结果在整个集群中所有机器上的应用情况是一致的,也就是说,要么整个集群中所有的机器都成功应用了某一个事务,要么都没有应用。
- **单一系统映像:** 无论客户端连到哪一个 ZooKeeper 服务器上,其看到的服务端数据模型都是一致的。
- **可靠性:** 一旦一次更改请求被应用,更改的结果就会被持久化,直到被下一次更改覆盖。
-- **实时性:** 一旦数据发生变更,其他节点会实时感知到。每个客户端的系统视图都是最新的。
+- **顺序一致性**:所有客户端看到的数据变更顺序是一致的,按照操作被提交的全局 FIFO 顺序进行更新。但这并不保证变更会立即传播到所有节点。
- **集群部署**:3~5 台(最好奇数台)机器就可以组成一个集群,每台机器都在内存保存了 ZooKeeper 的全部数据,机器之间互相通信同步数据,客户端连接任何一台机器都可以。
- **高可用:**如果某台机器宕机,会保证数据不丢失。集群中挂掉不超过一半的机器,都能保证集群可用。比如 3 台机器可以挂 1 台,5 台机器可以挂 2 台。
@@ -232,8 +235,8 @@ ZooKeeper 集群中的服务器状态有下面几种:
ZooKeeper 集群在宕掉几个 ZooKeeper 服务器之后,如果剩下的 ZooKeeper 服务器个数大于宕掉的个数的话整个 ZooKeeper 才依然可用。假如我们的集群中有 n 台 ZooKeeper 服务器,那么也就是剩下的服务数必须大于 n/2。先说一下结论,2n 和 2n-1 的容忍度是一样的,都是 n-1,大家可以先自己仔细想一想,这应该是一个很简单的数学问题了。
-比如假如我们有 3 台,那么最大允许宕掉 1 台 ZooKeeper 服务器,如果我们有 4 台的的时候也同样只允许宕掉 1 台。
-假如我们有 5 台,那么最大允许宕掉 2 台 ZooKeeper 服务器,如果我们有 6 台的的时候也同样只允许宕掉 2 台。
+比如假如我们有 3 台,那么最大允许宕掉 1 台 ZooKeeper 服务器,如果我们有 4 台的时候也同样只允许宕掉 1 台。
+假如我们有 5 台,那么最大允许宕掉 2 台 ZooKeeper 服务器,如果我们有 6 台的时候也同样只允许宕掉 2 台。
综上,何必增加那一个不必要的 ZooKeeper 呢?
@@ -269,7 +272,7 @@ ZAB 协议包括两种基本的模式,分别是
关于 **ZAB 协议&Paxos 算法** 需要讲和理解的东西太多了,具体可以看下面这几篇文章:
- [Paxos 算法详解](https://javaguide.cn/distributed-system/protocol/paxos-algorithm.html)
-- [ZooKeeper 与 Zab 协议 · Analyze](https://wingsxdu.com/posts/database/zookeeper/)
+- [Zab 协议详解](https://javaguide.cn/distributed-system/protocol/zab.html)
- [Raft 算法详解](https://javaguide.cn/distributed-system/protocol/raft-algorithm.html)
## ZooKeeper VS ETCD
diff --git a/docs/distributed-system/distributed-process-coordination/zookeeper/zookeeper-plus.md b/docs/distributed-system/distributed-process-coordination/zookeeper/zookeeper-plus.md
index e343f6cc3db..a2c70bf827d 100644
--- a/docs/distributed-system/distributed-process-coordination/zookeeper/zookeeper-plus.md
+++ b/docs/distributed-system/distributed-process-coordination/zookeeper/zookeeper-plus.md
@@ -1,5 +1,6 @@
---
title: ZooKeeper相关概念总结(进阶)
+description: ZooKeeper进阶详解,深入讲解ZAB协议、Leader选举机制、集群部署及与Eureka等注册中心的对比。
category: 分布式
tag:
- ZooKeeper
@@ -9,7 +10,7 @@ tag:
## 什么是 ZooKeeper
-`ZooKeeper` 由 `Yahoo` 开发,后来捐赠给了 `Apache` ,现已成为 `Apache` 顶级项目。`ZooKeeper` 是一个开源的分布式应用程序协调服务器,其为分布式系统提供一致性服务。其一致性是通过基于 `Paxos` 算法的 `ZAB` 协议完成的。其主要功能包括:配置维护、分布式同步、集群管理、分布式事务等。
+`ZooKeeper` 由 `Yahoo` 开发,后来捐赠给了 `Apache` ,现已成为 `Apache` 顶级项目。`ZooKeeper` 是一个开源的分布式应用程序协调服务器,其为分布式系统提供一致性服务。其一致性是通过专门为 ZooKeeper 设计的 **ZAB(ZooKeeper Atomic Broadcast)** 协议完成的。其主要功能包括:配置维护、分布式同步、集群管理等。
简单来说, `ZooKeeper` 是一个 **分布式协调服务框架** 。分布式?协调服务?这啥玩意?🤔🤔
diff --git a/docs/distributed-system/distributed-transaction.md b/docs/distributed-system/distributed-transaction.md
index fa4c83c743c..cfb8ac6bde5 100644
--- a/docs/distributed-system/distributed-transaction.md
+++ b/docs/distributed-system/distributed-transaction.md
@@ -1,12 +1,11 @@
---
title: 分布式事务常见解决方案总结(付费)
+description: 分布式事务常见解决方案详解,包括2PC、3PC、TCC、Saga、本地消息表等方案的原理与适用场景分析。
category: 分布式
---
**分布式事务** 相关的面试题为我的[知识星球](https://javaguide.cn/about-the-author/zhishixingqiu-two-years.html)(点击链接即可查看详细介绍以及加入方法)专属内容,已经整理到了《Java 面试指北》中。
-
+
-
-
diff --git a/docs/distributed-system/protocol/cap-and-base-theorem.md b/docs/distributed-system/protocol/cap-and-base-theorem.md
index 36a2fa54d4a..3611c58ea78 100644
--- a/docs/distributed-system/protocol/cap-and-base-theorem.md
+++ b/docs/distributed-system/protocol/cap-and-base-theorem.md
@@ -1,13 +1,16 @@
---
title: CAP & BASE理论详解
+description: CAP定理与BASE理论详解,深入讲解分布式系统一致性、可用性、分区容错性的权衡与实际应用。
category: 分布式
tag:
- 分布式理论
---
-经历过技术面试的小伙伴想必对 CAP & BASE 这个两个理论已经再熟悉不过了!
+
-我当年参加面试的时候,不夸张地说,只要问到分布式相关的内容,面试官几乎是必定会问这两个分布式相关的理论。一是因为这两个分布式基础理论是学习分布式知识的必备前置基础,二是因为很多面试官自己比较熟悉这两个理论(方便提问)。
+经历过技术面试的小伙伴想必对 CAP & BASE 这两个理论再熟悉不过了!
+
+我当年参加面试的时候,不夸张地说,只要问到分布式相关的内容,面试官几乎都会问到这两个基础理论。一是因为这是学习分布式知识的必备前置基础,二是因为很多面试官自己比较熟悉(方便提问)。
我们非常有必要将这两个理论搞懂,并且能够用自己的理解给别人讲出来。
@@ -19,19 +22,21 @@ tag:
### 简介
-**CAP** 也就是 **Consistency(一致性)**、**Availability(可用性)**、**Partition Tolerance(分区容错性)** 这三个单词首字母组合。
+CAP 定理讨论 Consistency(一致性)、Availability(可用性)和 Partition Tolerance(分区容错)。
+
+> **重要说明**:下文使用「偏 CP / 偏 AP」仅作直觉描述。严格按 CAP 定义(C=Linearizability,A=每个非故障节点都必须响应)时,许多系统并不能被干净归类——同一系统内不同操作的一致性/可用性特征不同,很多系统既不满足 CAP-C 也不满足 CAP-A。

-CAP 理论的提出者布鲁尔在提出 CAP 猜想的时候,并没有详细定义 **Consistency**、**Availability**、**Partition Tolerance** 三个单词的明确定义。
+CAP 理论的提出者布鲁尔在提出 CAP 猜想的时候,并没有对 **Consistency**、**Availability**、**Partition Tolerance** 给出严格定义。
-因此,对于 CAP 的民间解读有很多,一般比较被大家推荐的是下面 👇 这种版本的解读。
+因此,对于 CAP 的民间解读有很多,比较常见、也更推荐的一种解读如下。
在理论计算机科学中,CAP 定理(CAP theorem)指出对于一个分布式系统来说,当设计读写操作时,只能同时满足以下三点中的两个:
-- **一致性(Consistency)** : 所有节点访问同一份最新的数据副本
-- **可用性(Availability)**: 非故障的节点在合理的时间内返回合理的响应(不是错误或者超时的响应)。
-- **分区容错性(Partition Tolerance)** : 分布式系统出现网络分区的时候,仍然能够对外提供服务。
+- **一致性(Consistency)**:在 Gilbert/Lynch(2002)的证明语境里,CAP 的一致性 C 指的是 **Atomic Consistency**,通常等同于 **Linearizability(线性一致性)**。即所有操作按实时顺序线性化,即写操作一旦完成,后续所有读操作都必须返回该写入的值(或更新的值)。**注意:** 这里的 Consistency 与数据库 ACID 中的 Consistency(一致性约束)含义不同,后者指事务执行前后数据库状态满足完整性约束。
+- **可用性(Availability)**:非故障的节点必须对每个请求返回响应(不讨论响应快慢)。**注意**:这是 CAP 理论中的严格定义,不包含工程中的延迟/SLA 指标(如「1s 内返回」)。
+- **分区容错性(Partition Tolerance)**:CAP 里的 P 本质上是在假设异步网络(可能延迟/丢包/分区),不是一个你「选择要不要」的功能。真正的权衡是:当分区发生时,你必须在**线性一致(CAP 的 Consistency=Linearizability)**与**CAP-Availability(任何非故障节点都要对请求给非错误响应)**之间做选择。
**什么是网络分区?**
@@ -39,27 +44,186 @@ CAP 理论的提出者布鲁尔在提出 CAP 猜想的时候,并没有详细

-### 不是所谓的“3 选 2”
+### 不是所谓的「3 选 2」
+
+大部分人解释这一定律时,常常简单地表述为:「一致性、可用性、分区容忍性三者你只能同时达到其中两个,不可能同时达到」。实际上这是很有误导性的说法,而且在 CAP 理论诞生 12 年之后,CAP 之父也在 2012 年重写了之前的论文。
+
+> **当发生网络分区的时候,如果我们要继续服务,那么强一致性和可用性只能 2 选 1。**
+>
+> 简而言之:CAP 理论中分区容错性 P 不是一定要满足的,但当选择满足 P 时,在此基础上只能满足可用性 A 或者一致性 C。
+
+**为啥不可能选择 CA 架构呢?**
+
+因为分布式系统离不开网络通信,而网络故障是常态:
+
+- 心跳检测可能因网络抖动丢包,导致误判节点故障
+- 数据同步过程中可能因包丢失导致不一致,系统为达成一致会不断重试,造成请求阻塞
+
+**因此,在异步网络模型下(分区可能发生),当分区发生时,必须在线性一致性与 CAP-可用性之间取舍。** 能够保证 CA 的只有单机系统——因为只有一个节点,数据写入成功后所有请求都能看到相同数据;只要这个节点活着,系统就可用。
+
+下面这张图展示了 CAP 理论的核心权衡和常见系统的倾向:
+
+```mermaid
+flowchart TB
+ %% 核心语义配色
+ classDef cap fill:#E99151,color:#FFFFFF,stroke:none,rx:10,ry:10
+ classDef cp fill:#3498DB,color:#FFFFFF,stroke:none,rx:10,ry:10
+ classDef ap fill:#27AE60,color:#FFFFFF,stroke:none,rx:10,ry:10
+ classDef caution fill:#F39C12,color:#FFFFFF,stroke:none,rx:10,ry:10
+ classDef danger fill:#C44545,color:#FFFFFF,stroke:none,rx:10,ry:10
+
+ P[分区容错性 P
Partition Tolerance]:::cap
+ P -->|网络分区发生| Choice{分区时权衡 C 与 A}:::caution
+ Choice -->|倾向 C| CP[一致性优先
牺牲可用性]:::cp
+ Choice -->|倾向 A| AP[可用性优先
牺牲一致性]:::ap
+
+ CP --> ZK[ ZooKeeper
etcd ]:::cp
+ CP --> UseCP[应用场景:
分布式锁、配置管理]:::cp
+
+ AP --> Eureka[ Eureka
Cassandra ]:::ap
+ AP --> UseAP[应用场景:
服务注册中心、社交动态]:::ap
+
+ CA[仅单机系统
可实现 CA]:::danger -.->|有分区时不可行| Choice
+
+ linkStyle default stroke-width:2px,stroke:#333333,opacity:0.8
+```
+
+这里需要引入 **PACELC 理论**(CAP 的扩展)来更全面地解释:
+
+Daniel J. Abadi 提出的 PACELC 理论指出:**如果存在分区(P),必须在可用性(A)和一致性(C)之间选择;否则(E,Else),必须在延迟(L)和一致性(C)之间选择。**
+
+```mermaid
+flowchart TB
+ %% 核心语义配色
+ classDef question fill:#95A5A6,color:#FFFFFF,stroke:none,rx:10,ry:10
+ classDef choice fill:#E99151,color:#FFFFFF,stroke:none,rx:10,ry:10
+ classDef consistency fill:#3498DB,color:#FFFFFF,stroke:none,rx:10,ry:10
+ classDef availability fill:#27AE60,color:#FFFFFF,stroke:none,rx:10,ry:10
+ classDef latency fill:#9B59B6,color:#FFFFFF,stroke:none,rx:10,ry:10
+
+ Q{是否存在分区 P?}:::question
+
+ Q -->|是 Partition| PAC[权衡 A 与 C]:::choice
+ Q -->|否 Else| ELC[权衡 L 与 C]:::choice
+
+ PAC --> PA[选择可用性 A
Cassandra AP]:::availability
+ PAC --> PC[选择一致性 C
ZooKeeper CP]:::consistency
+
+ ELC --> LC[选择低延迟 L
MySQL 异步复制]:::latency
+ ELC --> EC[选择强一致 C
MySQL 半同步复制]:::consistency
+
+ linkStyle default stroke-width:2px,stroke:#333333,opacity:0.8
+```
+
+实际意义:即使无网络分区,分布式系统仍需在低延迟(异步复制)和强一致(同步复制)之间权衡。例如:
+
+- **Cassandra**:可通过调整读写一致性级别(ONE/QUORUM/ALL)在延迟与一致性间权衡
+- **MySQL 主从**:可选择异步复制(低延迟)或半同步复制(强一致)
+
+比如 ZooKeeper、HBase 就是 CP 架构,Cassandra、Eureka 就是 AP 架构,Nacos 不仅支持 CP 架构也支持 AP 架构。
+
+**选择 CP 还是 AP 的关键在于当前的业务场景,没有定论**:比如对于需要确保强一致性的场景如分布式锁、配置管理会选择 CP;对于高可用优先的场景如微服务注册中心会选择 AP。
+
+**另外,需要补充说明的一点**:在无分区时,可以同时做到线性一致与「会响应」的 CAP-可用性;但工程上通常还要在延迟与一致性之间权衡(这便是 PACELC 理论中 ELC 部分讨论的内容)。
+
+### CAP 理论的适用范围
-大部分人解释这一定律时,常常简单的表述为:“一致性、可用性、分区容忍性三者你只能同时达到其中两个,不可能同时达到”。实际上这是一个非常具有误导性质的说法,而且在 CAP 理论诞生 12 年之后,CAP 之父也在 2012 年重写了之前的论文。
+**重要结论**:CAP 理论主要讨论单个数据对象在副本复制场景下的一致性与可用性权衡。
-> **当发生网络分区的时候,如果我们要继续服务,那么强一致性和可用性只能 2 选 1。也就是说当网络分区之后 P 是前提,决定了 P 之后才有 C 和 A 的选择。也就是说分区容错性(Partition tolerance)我们是必须要实现的。**
+| 更贴近 CAP 讨论模型 | 需要拆分到分片/对象/操作级别分析 |
+| ------------------- | ------------------------------------ |
+| Redis 主从/哨兵集群 | 业务系统(无状态服务)\* |
+| MySQL 主从/多主集群 | Redis-Cluster(每个 shard 仍有副本) |
+| MongoDB 副本集 | MongoDB-Cluster(分片 + 副本并存) |
+| ZooKeeper、etcd | 分库分表(跨分片事务需额外协调) |
+| Kafka、RocketMQ | 大多数微服务应用\* |
+
+**说明**:
+
+- **CAP 讨论模型**:单个读写寄存器(single register)的副本复制语义
+- **复杂系统**:需要拆解到「每个对象/分区/操作」的一致性语义讨论
+- **分片 + 副本**:分片系统每个 shard 通常仍有副本复制,一致性与可用性权衡仍在
+
+> **业务系统与 CAP 的深度关联**:
+>
+> 业务系统本身虽不涉及副本同步,但**深受底层组件 CAP 属性的影响**。忽视这一点会导致系统在遭遇网络分区时发生级联雪崩(Cascading Failure)。
>
-> 简而言之就是:CAP 理论中分区容错性 P 是一定要满足的,在此基础上,只能满足可用性 A 或者一致性 C。
+> **受 CAP 属性影响的业务场景**:
+>
+> | 业务场景 | 底层组件 | CP 组件的影响 | AP 组件的影响 |
+> | -------- | ---------------------------- | -------------------------- | ------------------------------ |
+> | RPC 路由 | 注册中心(如 Nacos CP 模式) | 注册期间不可用,请求被拒绝 | 可能路由到已下线实例,需要重试 |
+> | 分布式锁 | Redis(AP)/ ZooKeeper(CP) | 性能较低但可靠 | 性能高但可能锁失效 |
+> | 限流熔断 | Redis 计数器 | 可能读到旧计数,限流失效 | 同左 |
+> | 缓存更新 | Redis 主从 | 主从切换时可能丢数据 | 同左 |
+> | 消息消费 | Kafka | 消费进度同步慢,重复消费 | 同左 |
+>
+> **实践建议**:业务开发者虽然不需要「实践」CAP 理论,但**必须理解 CAP 理论**,以便:
+>
+> - 为不同业务场景选择合适的组件(CP 或 AP)
+> - 理解所选组件在网络分区时的行为特征
+> - 设计符合业务需求的容错机制(重试、熔断、降级)
+
+很多开发者认为自己在「实践 CAP 理论」,实际上只是站在已有组件上做选择(用 CP 还是 AP),而非真正实践该理论。真正需要实践 CAP 的是研发 Redis、MySQL 这类分布式存储组件的工程师。
+
+### 在业务中应用 CAP 思想
+
+除研发分布式存储组件外,业务开发中更多是**选择**合适的架构,而非实践 CAP 理论本身:
+
+| 场景 | 偏向 CP 的选择 | 偏向 AP 的选择 | 业务权衡 |
+| -------------- | ---------------------------- | ------------------------ | ------------------------ |
+| 数据库主从复制 | 同步复制(强一致) | 异步复制(高性能) | 数据一致性 vs 响应速度 |
+| 分布式锁实现 | ZooKeeper(强一致) | Redis(高性能) | 锁的可靠性 vs 获取速度 |
+| 服务注册中心 | ZooKeeper、Consul(CP 模式) | Eureka、Nacos(AP 模式) | 注册准确性 vs 发现可用性 |
+| 限流计数器 | Redis(强一致命令) | Redis(允许过期) | 限流精度 vs 性能 |
+
+**选型原则**:
+
+- **关注性能**:倾向选择允许异步复制的组件,写入主节点即可返回成功,响应快;但存在数据丢失/读取到旧数据的风险,需配合重试机制
+- **关注数据安全**:倾向选择要求多数派确认的组件,写入需等待 quorum 节点确认,响应慢;但能降低数据丢失风险
+
+**注意**:数据丢失与否更取决于持久化、复制确认策略、故障模型,不能简单地用「CP/AP 标签」来判断。
+
+**级联雪崩案例**:
-因此,**分布式系统理论上不可能选择 CA 架构,只能选择 CP 或者 AP 架构。** 比如 ZooKeeper、HBase 就是 CP 架构,Cassandra、Eureka 就是 AP 架构,Nacos 不仅支持 CP 架构也支持 AP 架构。
+一个典型的忽视 CAP 导致的级联雪崩场景:
-**为啥不可能选择 CA 架构呢?** 举个例子:若系统出现“分区”,系统中的某个节点在进行写操作。为了保证 C, 必须要禁止其他节点的读写操作,这就和 A 发生冲突了。如果为了保证 A,其他节点的读写操作正常的话,那就和 C 发生冲突了。
+```mermaid
+flowchart TB
+ %% 核心语义配色
+ classDef start fill:#95A5A6,color:#FFFFFF,stroke:none,rx:10,ry:10
+ classDef process fill:#3498DB,color:#FFFFFF,stroke:none,rx:10,ry:10
+ classDef warning fill:#F39C12,color:#FFFFFF,stroke:none,rx:10,ry:10
+ classDef danger fill:#C44545,color:#FFFFFF,stroke:none,rx:10,ry:10
+ classDef solution fill:#27AE60,color:#FFFFFF,stroke:none,rx:10,ry:10
-**选择 CP 还是 AP 的关键在于当前的业务场景,没有定论,比如对于需要确保强一致性的场景如银行一般会选择保证 CP 。**
+ Start[网络分区发生]:::start --> P1[Redis 集群主从分离
AP 架构数据不一致]:::process
+ P1 --> P2[限流计数器读到旧值
以为未限流]:::warning
+ P2 --> P3[大量请求同时打到后端]:::warning
+ P3 --> P4[服务线程池耗尽]:::danger
+ P4 --> P5[RPC 调用超时堆积]:::danger
+ P5 --> P6[整个调用链路雪崩]:::danger
-另外,需要补充说明的一点是:**如果网络分区正常的话(系统在绝大部分时候所处的状态),也就说不需要保证 P 的时候,C 和 A 能够同时保证。**
+ P2 -.->|理解 CAP 属性| S1[选择合适组件]:::solution
+ P3 -.->|多层防护| S2[本地缓存 + 熔断降级]:::solution
+ P4 -.->|超时重试| S3[合理设置超时时间]:::solution
+ P5 -.->|隔离机制| S4[不同业务隔离实例]:::solution
+
+ linkStyle default stroke-width:2px,stroke:#333333,opacity:0.8
+```
+
+**防护措施**:
+
+1. **理解底层组件的 CAP 属性**:知道在网络分区时组件的行为
+2. **多层防护**:不只依赖单一组件,结合本地缓存、熔断、降级
+3. **超时与重试**:合理设置超时时间,避免无限等待
+4. **隔离机制**:不同业务使用不同的底层组件实例,避免故障扩散
### CAP 实际应用案例
我这里以注册中心来探讨一下 CAP 的实际应用。考虑到很多小伙伴不知道注册中心是干嘛的,这里简单以 Dubbo 为例说一说。
-下图是 Dubbo 的架构图。**注册中心 Registry 在其中扮演了什么角色呢?提供了什么服务呢?**
+下图是 Dubbo 的架构图。**注册中心 Registry 在其中扮演什么角色呢?提供了什么服务呢?**
注册中心负责服务地址的注册与查找,相当于目录服务,服务提供者和消费者只在启动时与注册中心交互,注册中心不转发请求,压力较小。
@@ -67,25 +231,77 @@ CAP 理论的提出者布鲁尔在提出 CAP 猜想的时候,并没有详细
常见的可以作为注册中心的组件有:ZooKeeper、Eureka、Nacos...。
-1. **ZooKeeper 保证的是 CP。** 任何时刻对 ZooKeeper 的读请求都能得到一致性的结果,但是, ZooKeeper 不保证每次请求的可用性比如在 Leader 选举过程中或者半数以上的机器不可用的时候服务就是不可用的。
-2. **Eureka 保证的则是 AP。** Eureka 在设计的时候就是优先保证 A (可用性)。在 Eureka 中不存在什么 Leader 节点,每个节点都是一样的、平等的。因此 Eureka 不会像 ZooKeeper 那样出现选举过程中或者半数以上的机器不可用的时候服务就是不可用的情况。 Eureka 保证即使大部分节点挂掉也不会影响正常提供服务,只要有一个节点是可用的就行了。只不过这个节点上的数据可能并不是最新的。
-3. **Nacos 不仅支持 CP 也支持 AP。**
+#### ZooKeeper 3.8.x(CP 架构)
-**🐛 修正(参见:[issue#1906](https://github.com/Snailclimb/JavaGuide/issues/1906))**:
+ZooKeeper 倾向 **CP 架构**。ZooKeeper 3.x 通过 ZAB 协议提供 **Linearizable Writes(线性化写入)**,但读取行为需区分:
-ZooKeeper 通过可线性化(Linearizable)写入、全局 FIFO 顺序访问等机制来保障数据一致性。多节点部署的情况下, ZooKeeper 集群处于 Quorum 模式。Quorum 模式下的 ZooKeeper 集群, 是一组 ZooKeeper 服务器节点组成的集合,其中大多数节点必须同意任何变更才能被视为有效。
+- **Sync 读取**:强制与 Leader 同步,保证线性一致性(Linearizability)。
+- **普通读取**:默认提供 **顺序一致性(Sequential Consistency)**,保证全局更新操作的顺序,同一会话内客户端视图绝不会发生回退,但可能读到稍旧数据(存在读取滞后)。
-由于 Quorum 模式下的读请求不会触发各个 ZooKeeper 节点之间的数据同步,因此在某些情况下还是可能会存在读取到旧数据的情况,导致不同的客户端视图上看到的结果不同,这可能是由于网络延迟、丢包、重传等原因造成的。ZooKeeper 为了解决这个问题,提供了 Watcher 机制和版本号机制来帮助客户端检测数据的变化和版本号的变更,以保证数据的一致性。
+> **重要区别**:顺序一致性 ≠ 最终一致性。ZooKeeper 的普通读取保证所有客户端看到相同的**更新顺序**(全局 zxid 顺序),只是存在读取滞后;而最终一致性不保证全局顺序,仅保证最终收敛。ZK 的默认读更像是「stale-but-ordered」的读(顺序/会话保证很强),而不是 Dynamo 系那种 eventual consistency 语境。
-### 总结
+在 Leader 选举期间或 Follower 节点数不足 Quorum(N/2+1)时,ZooKeeper 会拒绝服务以维持一致性,表现为不可用(牺牲 A)。
+
+在多节点部署下,集群采用 Quorum 模式:多数派节点(n/2+1)必须同意变更才有效。
+
+ZooKeeper 提供 Watcher 机制(异步通知变更)和版本号机制(zxid 校验新鲜度)以缓解读取滞后问题。
+
+失败路径与状态机表现:
+
+| 故障场景 | 系统状态 | 客户端表现 |
+| ------------------------------- | ------------------------------- | ------------------------------------------------------------ |
+| Quorum 失效(半数以上节点故障) | **LOOKING** 状态,Leader 选举中 | 写入请求拒绝,读取请求可能返回旧数据或超时 |
+| Follower 与 Leader 分区 | Follower 进入 **ELECTION** 状态 | 该 Follower 无法参与投票,但可响应读取(滞后数据) |
+| Leader 与多数派分区 | Leader 自动降级,集群重新选举 | 原Leader的写入丢失,需客户端重试(检测到 zxid 回退) |
+| Watcher 丢失 | 网络抖动或 GC 压力导致 | 客户端需重试(指数退避 + Jitter),监控 `Watches` 队列防背压 |
+
+#### Eureka(AP 架构)
+
+Eureka 采用 AP 架构:节点对等,通过 Peer 复制/同步(定期全量拉取 + 增量更新)保持数据一致,无 Leader 选举。**注意**:Spring Cloud 生态中历史上更常见 1.x 依赖形态;Netflix/eureka 的 2.x 仍在维护并持续发布。
+
+失败路径与状态机表现:
+
+| 故障场景 | 系统状态 | 客户端表现 | 自我保护机制 |
+| ---------------------------- | ---------------------------------------- | ----------------------------------------------------------------------------------------------- | --------------------------------------------------------------------- |
+| 网络分区(脑裂) | 分区两侧**独立运行**,均可读写 | 客户端可能读到旧注册信息(不一致窗口 = 心跳间隔 30s + gossip 传播延迟,10 节点拓扑中 P99 <60s) | 当续约阈值 < 85% 时触发**自我保护**,暂停实例剔除,避免"误杀"健康实例 |
+| 半数节点故障 | 剩余节点继续服务,但数据可能分叉 | 读操作正常,写入可能仅存于少数派节点 | 自我保护触发,待节点恢复后通过 gossip 自动合并 |
+| 节点短暂重启 | 从 Peer 批量拉取注册表(Registry Fetch) | 服务发现短暂不可用(< 1min),缓存起作用 | 正常模式,自动恢复 |
+| 注册风暴(大量实例同时注册) | 写队列堆积,可能导致请求丢弃 | 部分注册请求超时,需客户端重试 | 可配置限流与背压(如 Ribbon 重试策略) |
+
+**自我保护机制详细说明**:
+
+Eureka Server 通过以下逻辑判断是否进入自我保护:
+
+```
+每分钟期望续约数 E = 当前实例数 N × (60 / 心跳间隔秒数)
+阈值 T = E × 0.85
+若最近 1 分钟实际续约数 R < T,则进入自我保护:暂停剔除(eviction)
+(E/T 会按固定周期根据 N 更新,常见周期约 15 分钟)
+```
-在进行分布式系统设计和开发时,我们不应该仅仅局限在 CAP 问题上,还要关注系统的扩展性、可用性等等
+默认心跳间隔为 30 秒时,每分钟期望续约数 = 实例数 × 2。
-在系统发生“分区”的情况下,CAP 理论只能满足 CP 或者 AP。要注意的是,这里的前提是系统发生了“分区”
+当 `实际续约率 < 85%` 时:
-如果系统没有发生“分区”的话,节点间的网络连接通信正常的话,也就不存在 P 了。这个时候,我们就可以同时保证 C 和 A 了。
+1. 进入 **SELF PRESERVATION** 模式
+2. 停止剔除过期实例(EvictionTask 暂停)
+3. 日志输出:`ENTER SELF PRESERVATION MODE`
-总结:**如果系统发生“分区”,我们要考虑选择 CP 还是 AP。如果系统没有发生“分区”的话,我们要思考如何保证 CA 。**
+**设计权衡**:宁可保留「僵尸」实例,也不误杀健康实例——因为在微服务场景下,短暂的服务降级好过大规模服务不可用。客户端通常配置重试与熔断来处理不可用实例。
+
+#### 总结
+
+选择 CP 或 AP 取决于场景:ZooKeeper 适合强一致需求,如配置管理;Eureka 适合高可用注册,如微服务发现。
+
+Nacos 不仅支持 CP 也支持 AP。
+
+### 总结
+
+CAP 理论指导我们:在分布式系统可能出现网络分区(P)的前提下,我们必须在强一致性(C)和高可用性(A)之间做出权衡。
+
+- **CP 架构**:牺牲可用性,保证强一致性。适用于对数据一致性要求极高的场景(如金融交易、分布式锁)。
+- **AP 架构**:牺牲一致性,保证高可用性。适用于对系统可用性要求较高,能容忍短暂数据不一致的场景(如社交动态、商品搜索)。
+- **PACELC**:在无分区(E)时,需在延迟(L)和一致性(C)之间权衡。
### 推荐阅读
@@ -95,27 +311,76 @@ ZooKeeper 通过可线性化(Linearizable)写入、全局 FIFO 顺序访问
## BASE 理论
-[BASE 理论](https://dl.acm.org/doi/10.1145/1394127.1394128)起源于 2008 年, 由 eBay 的架构师 Dan Pritchett 在 ACM 上发表。
+[BASE 理论](https://dl.acm.org/doi/10.1145/1394127.1394128)起源于 2008 年,由 eBay 的架构师 Dan Pritchett 在 ACM 上发表,论文标题为《Base: An ACID Alternative》。
+
+> **关键洞察**:从论文标题可以看出,**BASE 首先是 ACID 的替代品**。但同时需要注意,BASE 与 CAP 理论也存在密切关系——**最终一致性正是 CAP 中 AP 架构在工程实践中达到系统收敛的指导原则**。
### 简介
-**BASE** 是 **Basically Available(基本可用)**、**Soft-state(软状态)** 和 **Eventually Consistent(最终一致性)** 三个短语的缩写。BASE 理论是对 CAP 中一致性 C 和可用性 A 权衡的结果,其来源于对大规模互联网系统分布式实践的总结,是基于 CAP 定理逐步演化而来的,它大大降低了我们对系统的要求。
+**BASE** 是 **Basically Available(基本可用)**、**Soft-state(软状态)** 和 **Eventually Consistent(最终一致性)** 三个短语的缩写。BASE 理论来源于对大规模互联网系统分布式实践的总结。
+
+### BASE 与 ACID 的关系
+
+要理解 BASE 理论,首先需要回顾 ACID 理论中的 **一致性(Consistency)**:
+
+**ACID 的一致性定义**:事务执行前后,数据库只能从一个一致状态转变为另一个一致状态。
+
+以转账为例:小竹向熊猫转账 1000W。
+
+- **初始态**:小竹 1001W,熊猫 888W,合计 1889W
+- **结果态**:小竹 1W,熊猫 1888W,合计 1889W
+
+无论事务成功或失败,整体数据的变化必须一致——类似于能量守恒定律。
+
+**分布式场景的挑战**:
-### BASE 理论的核心思想
+在分布式系统中,商品服务和订单服务分离部署,[扣减库存、创建订单]需要通过网络调用,这中间必然存在时间差:
-即使无法做到强一致性,但每个应用都可以根据自身业务特点,采用适当的方式来使系统达到最终一致性。
+```
+时刻 T1:库存 8888 → 8887(扣减成功)
+时刻 T2:网络调用订单服务...
+时刻 T3:订单创建成功
+```
-> 也就是牺牲数据的一致性来满足系统的高可用性,系统中一部分数据不可用或者不一致时,仍需要保持系统整体“主要可用”。
+在 T1~T3 期间,系统处于 **中间态**:库存已减,订单未创建。跨服务后无法用单库 ACID 事务保证整体原子提交与隔离,系统会客观存在中间态;BASE 接受中间态并通过补偿/重试让状态最终收敛。
-**BASE 理论本质上是对 CAP 的延伸和补充,更具体地说,是对 CAP 中 AP 方案的一个补充。**
+**BASE 理论的解决方案**:
-**为什么这样说呢?**
+BASE 理论承认并允许这种中间态的存在:
-CAP 理论这节我们也说过了:
+- **Soft-state(软状态)**:允许系统存在中间态,且该中间态不影响系统整体可用性
+- **Eventually consistent(最终一致性)**:中间态最终会演变成终态(要么成功,要么回滚)
-> 如果系统没有发生“分区”的话,节点间的网络连接通信正常的话,也就不存在 P 了。这个时候,我们就可以同时保证 C 和 A 了。因此,**如果系统发生“分区”,我们要考虑选择 CP 还是 AP。如果系统没有发生“分区”的话,我们要思考如何保证 CA 。**
+下面通过一个对比图来直观理解 ACID 和 BASE 在事务处理上的不同模式:
-因此,AP 方案只是在系统发生分区的时候放弃一致性,而不是永远放弃一致性。在分区故障恢复后,系统应该达到最终一致性。这一点其实就是 BASE 理论延伸的地方。
+```mermaid
+flowchart LR
+ %% 核心语义配色
+ classDef acid fill:#3498DB,color:#FFFFFF,stroke:none,rx:10,ry:10
+ classDef base fill:#27AE60,color:#FFFFFF,stroke:none,rx:10,ry:10
+ classDef state fill:#95A5A6,color:#FFFFFF,stroke:none,rx:10,ry:10
+ classDef success fill:#4CA497,color:#FFFFFF,stroke:none,rx:10,ry:10
+ classDef fail fill:#C44545,color:#FFFFFF,stroke:none,rx:10,ry:10
+
+ subgraph ACID [ACID 模式:无中间态]
+ direction TB
+ A1[初始态
小竹1001W + 熊猫888W]:::state
+ A1 -->|事务执行| A2[终态:成功
小竹1W + 熊猫1888W]:::success
+ A1 -->|事务失败| A3[终态:失败
小竹1001W + 熊猫888W]:::fail
+ end
+
+ subgraph BASE [BASE 模式:允许中间态]
+ direction TB
+ B1[初始态
库存8888]:::state
+ B1 -->|扣减成功| B2[中间态
库存8887 订单未创建]:::base
+ B2 -->|订单创建成功| B3[终态:成功
库存8887 订单已创建]:::success
+ B2 -->|订单创建失败| B4[终态:失败
库存回滚到8888]:::fail
+ end
+
+ linkStyle default stroke-width:2px,stroke:#333333,opacity:0.8
+```
+
+因此,**BASE 理论是 ACID 在分布式场景中的替代品**,而非 CAP 理论的补充。
### BASE 理论三要素
@@ -127,35 +392,177 @@ CAP 理论这节我们也说过了:
**什么叫允许损失部分可用性呢?**
-- **响应时间上的损失**: 正常情况下,处理用户请求需要 0.5s 返回结果,但是由于系统出现故障,处理用户请求的时间变为 3 s。
+- **响应时间上的损失**:正常情况下,处理用户请求需要 0.5s 返回结果,但是由于系统出现故障,处理用户请求的时间变为 3s。
- **系统功能上的损失**:正常情况下,用户可以使用系统的全部功能,但是由于系统访问量突然剧增,系统的部分非核心功能无法使用。
#### 软状态
-软状态指允许系统中的数据存在中间状态(**CAP 理论中的数据不一致**),并认为该中间状态的存在不会影响系统的整体可用性,即允许系统在不同节点的数据副本之间进行数据同步的过程存在延时。
+软状态(Soft State)是指允许系统中的数据存在中间状态,并认为该中间状态的存在不会影响系统的整体可用性。
+
+> **与 ACID 的区别**:ACID 理论要求事务执行后立即进入终态(成功或失败),不允许中间态;而 BASE 理论承认中间态是分布式系统的客观存在,只要中间态最终会演变成终态即可。
+
+举例说明:
+
+- **ACID 模式**:银行转账事务中,扣款和入账必须同时成功或同时失败,不允许「扣款成功但入账未完成」的中间态
+- **BASE 模式**:电商下单事务中,允许「库存已减但订单未创建」的中间态存在,只要最终会达到一致(要么订单创建成功,要么库存回滚)
#### 最终一致性
-最终一致性强调的是系统中所有的数据副本,在经过一段时间的同步后,最终能够达到一个一致的状态。因此,最终一致性的本质是需要系统保证最终数据能够达到一致,而不需要实时保证系统数据的强一致性。
+最终一致性(Eventual Consistency)强调:**若系统在一段时间内无新的更新操作,则所有副本最终收敛到相同值。**
-> 分布式一致性的 3 种级别:
->
-> 1. **强一致性**:系统写入了什么,读出来的就是什么。
-> 2. **弱一致性**:不一定可以读取到最新写入的值,也不保证多少时间之后读取到的数据是最新的,只是会尽量保证某个时刻达到数据一致的状态。
-> 3. **最终一致性**:弱一致性的升级版,系统会保证在一定时间内达到数据一致的状态。
->
-> **业界比较推崇是最终一致性级别,但是某些对数据一致要求十分严格的场景比如银行转账还是要保证强一致性。**
+需要注意的是,「最终一致性」这个词在两个不同语境下有不同含义:
+
+| 语境 | 含义 | 典型场景 |
+| ------------------------------ | ------------------------ | -------------------------- |
+| **副本式存储(CAP 语境)** | 数据副本最终同步一致 | Cassandra 数据复制 |
+| **事务状态(BASE/ACID 语境)** | 事务中间态最终演变成终态 | 分布式事务(如 TCC、Saga) |
+
+**副本式存储的最终一致性**:
+
+「一段时间」是未界定的——可能是毫秒级(局域网同步)或分钟级(跨地域复制)。生产环境中需通过 **Read Repair(读修复)**、**Anti-Entropy(反熵/后台同步)** 或 **Quorum 写入** 主动加速收敛。
+
+**事务状态的最终一致性**:
+
+以分布式事务为例:[扣减库存、创建订单、扣减余额]
+
+- 时刻 T1:库存已减(中间态)
+- 时刻 T2:订单已创建(中间态)
+- 时刻 T3:余额已扣(终态:事务成功)
-那实现最终一致性的具体方式是什么呢? [《分布式协议与算法实战》](http://gk.link/a/10rZM) 中是这样介绍:
+或在失败场景:
-> - **读时修复** : 在读取数据时,检测数据的不一致,进行修复。比如 Cassandra 的 Read Repair 实现,具体来说,在向 Cassandra 系统查询数据的时候,如果检测到不同节点的副本数据不一致,系统就自动修复数据。
-> - **写时修复** : 在写入数据,检测数据的不一致时,进行修复。比如 Cassandra 的 Hinted Handoff 实现。具体来说,Cassandra 集群的节点之间远程写数据的时候,如果写失败 就将数据缓存下来,然后定时重传,修复数据的不一致性。
-> - **异步修复** : 这个是最常用的方式,通过定时对账检测副本数据的一致性,并修复。
+- 时刻 T1:库存已减(中间态)
+- 时刻 T2:订单创建失败(触发回滚)
+- 时刻 T3:库存回滚(终态:事务失败)
-比较推荐 **写时修复**,这种方式对性能消耗比较低。
+系统会保证在一定时间内达到数据一致的状态,而不需要实时保证系统数据的强一致性。
+
+分布式一致性的 3 种级别:
+
+1. **强一致性**:系统写入了什么,读出来的就是什么。
+2. **弱一致性**:不一定可以读取到最新写入的值,也不保证多少时间之后读取到的数据是最新的,只是会尽量保证某个时刻达到数据一致的状态。
+3. **最终一致性**:弱一致性的升级版,系统会保证在一定时间内达到数据一致的状态。
+
+**业界比较推崇最终一致性级别,但是某些对数据一致要求十分严格的场景比如银行转账还是要保证强一致性。**
+
+那实现最终一致性的具体方式是什么呢?
+
+- **读时修复(Read Repair)**:在读取数据时,检测数据的不一致,进行修复。适合读多写少场景。
+- **写时修复(Hinted Handoff)**:在写入数据时,如果目标节点不可用,将数据缓存下来,待节点恢复后重传。**写时修复** 优化了写入延迟,但增加了读取时的不一致风险(数据可能还在缓存队列中未落盘到目标节点)。
+- **异步修复(Anti-Entropy/反熵)**:通过后台比对副本数据差异并修复。工程实现中关键挑战是**高效检测数据差异**——暴力逐条比对(O(n))在大规模数据集下不可行,生产系统采用**默克尔树(Merkle Tree)**实现低开销差异定位:
+
+**选择建议**:
+
+- **写时修复**:适合写多读少,优化写入性能,但牺牲一致性窗口。
+- **读时修复**:适合读多写少,保证读取数据的准确性。
+- **Anti-Entropy**:后台兜底保障,适合数据规模大但对最终一致性要求高的场景。
+
+### 为什么很多人把 BASE 当作 CAP 的补充?
+
+这是一个**部分正确但表述不够精确**的说法。更准确的理解是:
+
+1. **BASE 首先是 ACID 的替代品**:从论文标题[《Base: An ACID Alternative》](https://spawn-queue.acm.org/doi/10.1145/1394127.1394128)可以看出,BASE 理论的初衷是解决分布式事务场景下 ACID 过于严格的问题。
+
+2. **BASE 与 CAP 的 AP 架构存在内在联系**:
+
+ - 选择 AP 架构意味着放弃强一致性(C)
+ - 放弃强一致性后,系统如何达到收敛?答案是**最终一致性**
+ - 因此,BASE 理论(特别是最终一致性)是 AP 架构在工程实践中**必须采用**的指导原则
+
+3. **误解产生的根源**:很多人把"BASE 与 AP 相关"误解为"BASE 是 CAP 的补充"。实际上:
+ - **BASE 不是对 CAP 理论的补充或修正**
+ - **BASE 是 AP 架构选择的工程实践指南**——当你选择了 AP,BASE 告诉你如何在工程实践中让系统最终达到一致
+
+**正确的理解**:
+
+```mermaid
+flowchart TB
+ %% 核心语义配色
+ classDef cap fill:#E99151,color:#FFFFFF,stroke:none,rx:10,ry:10
+ classDef base fill:#27AE60,color:#FFFFFF,stroke:none,rx:10,ry:10
+ classDef acid fill:#3498DB,color:#FFFFFF,stroke:none,rx:10,ry:10
+ classDef relation fill:#9B59B6,color:#FFFFFF,stroke:none,rx:10,ry:10
+
+ CAP[CAP 理论
分布式存储系统设计约束]:::cap
+ ACID[ACID 理论
数据库事务完整性]:::acid
+ BASE[BASE 理论
ACID 的分布式替代品]:::base
+
+ CAP -->|AP 架构放弃强一致性| BASE
+ ACID -->|分布式场景放宽| BASE
+
+ CAP -->|约束:不能同时满足 C+A| R1[实践意义]:::relation
+ BASE -->|实现:如何达到最终一致| R1
+
+ R1 --> Result[CAP 告诉我们限制
BASE 告诉我们做法]:::relation
+
+ linkStyle default stroke-width:2px,stroke:#333333,opacity:0.8
+```
+
+| 维度 | CAP 理论 | BASE 理论 |
+| ---------- | ------------------------ | ------------------------------------------------ |
+| 关注领域 | 分布式存储系统(带副本) | 所有分布式系统 |
+| 一致性含义 | 数据一致性(副本同步) | 状态一致性(事务终态) |
+| 可用性含义 | 节点故障时系统可用 | 部分节点故障时部分功能可用 |
+| 核心关系 | - | ① ACID 的分布式替代品
② AP 架构的工程实践指南 |
+
+> **实践意义**:CAP 告诉我们在 AP 架构下无法保证强一致性,BASE 告诉我们在 AP 架构下如何通过最终一致性让系统达到收敛——两者是**约束与实现**的关系,而非补充关系。
+
+如果说 CAP 是分布式存储系统的设计约束(告诉我们不能做什么),那么 BASE 就是分布式系统(尤其是业务系统)的实践指导(告诉我们如何做)——它告诉我们:**绝大多数应用场景不需要强一致性,通过接受中间态并最终达到一致性,是更务实的选择。**
### 总结
-**ACID 是数据库事务完整性的理论,CAP 是分布式系统设计理论,BASE 是 CAP 理论中 AP 方案的延伸。**
+**ACID 是数据库事务完整性的理论,CAP 是分布式存储系统的设计理论,BASE 是 ACID 在分布式场景中的替代品,同时也是 AP 架构的工程实践指南。**
+
+> **关键对应关系**:
+>
+> - **CAP 的一致性** = 数据一致性(副本节点间的数据同步)
+> - **BASE 的一致性** = 状态一致性(事务终态的一致)= ACID 的一致性
+> - **CAP 的可用性** = 主从集群的可用性(节点故障时系统仍可用)
+> - **BASE 的可用性** = 分片式集群的可用性(部分节点故障只影响部分用户)
+> - **CAP 与 BASE 的关系**:选择 AP 架构后,BASE 理论指导如何在工程实践中通过最终一致性达到系统收敛
+
+## 生产落地建议
+
+### 选择 CP 还是 AP 的决策框架
+
+> **重要提示**:简单给系统贴「CP/AP」标签是有风险的。在网络分区下:
+>
+> - **X 的写更倾向于优先保持线性一致**(可能拒绝服务/降级)
+> - **Y 更倾向于优先保持可用**(允许短时间读到旧数据)
+> 具体取决于操作类型与配置。
+
+| 场景特征 | 倾向选择 | 典型系统说明 |
+| ------------------------------ | -------------- | ----------------------------------------------------------- |
+| 强一致性要求(金融转账) | 倾向线性一致写 | ZooKeeper(写入需 Quorum 确认)、etcd、Consul(CP 模式) |
+| 高可用优先(服务发现) | 倾向可用性 | Eureka(允许读到旧实例)、Consul(可切换模式) |
+| 可调一致性(根据业务动态选择) | 可配置 | Nacos(支持 CP/AP 切换)、Cassandra(可调节读写一致性级别) |
+| 写多读少 | 倾向异步写优化 | Cassandra(可配置 QUORUM 写)、HBase |
+| 读多写少 | 倾向低延迟读 | DynamoDB(可调节最终一致性级别) |
+
+### 监控指标
+
+- **分区检测时间**:多久发现网络分区
+- **收敛时间(Convergence Time)**:副本从不一致到一致的时间
+- **读写延迟 P99**:CAP 权衡的直接体现
+- **不一致窗口**:业务可接受的数据延迟
+
+### 常见误区
+
+#### CAP 相关误区
+
+- ❌ 「选择了 AP 就永远放弃一致性」→ ✅ AP 系统可通过 Read Repair、Anti-Entropy(Merkle Tree)达到最终一致
+- ❌ 「ZooKeeper 是强一致的」→ ✅ ZooKeeper 提供**线性化写入** + **顺序一致性读取**(非最终一致性),读取存在滞后但保证全局顺序
+- ❌ 「顺序一致性 = 最终一致性」→ ✅ 顺序一致性保证全局更新顺序,最终一致性不保证顺序;ZooKeeper 普通读取是前者而非后者
+- ❌ 「银行系统必须 CP」→ ✅ 实际银行采用 BASE + 补偿事务(Saga),核心账务强一致,查询服务可最终一致
+- ❌ 「业务系统不需要考虑 CAP」→ ✅ 业务系统虽不直接实践 CAP,但 RPC 路由、限流熔断、分布式锁等均受底层组件 CAP 属性影响,忽视会导致级联雪崩
+- ❌ 「分库分表不需要考虑 CAP」→ ✅ 分片式存储通常仍然需要为每个 shard 做副本复制,因此仍需面对 CAP 的权衡
+- ❌ 「CAP 的 A 等于低延迟/高 SLA」→ ✅ CAP 的可用性定义不包含延迟要求,只要求非故障节点必须返回响应(可以很慢)
+
+#### BASE 相关误区
+
+- ❌ 「BASE 是 CAP 的补充/延伸」→ ✅ BASE 首先是 ACID 的替代品;同时 BASE 是 AP 架构的工程实践指南(AP 选择了放弃强一致性,BASE 告诉你如何达到最终一致)
+- ❌ 「BASE 的一致性 = CAP 的一致性」→ ✅ BASE 的一致性是状态一致性(= ACID 一致性),CAP 的一致性是数据一致性
+- ❌ 「BASE 只适用于主从集群」→ ✅ BASE 适用于所有分布式系统;其「基本可用」概念在分片式集群中表现更明显(部分节点故障只影响部分用户)
+- ❌ 「最终一致性是弱一致性」→ ✅ 最终一致性是弱一致性的升级版,保证系统最终会达到一致状态,而弱一致性不提供此保证
diff --git a/docs/distributed-system/protocol/consistent-hashing.md b/docs/distributed-system/protocol/consistent-hashing.md
new file mode 100644
index 00000000000..10bebe8197c
--- /dev/null
+++ b/docs/distributed-system/protocol/consistent-hashing.md
@@ -0,0 +1,132 @@
+---
+title: 一致性哈希算法详解
+description: 一致性哈希算法原理详解,讲解哈希环、虚拟节点机制及在分布式缓存、负载均衡中的应用场景。
+category: 分布式
+tag:
+ - 分布式协议&算法
+ - 哈希算法
+---
+
+开始之前,先说两个常见的场景:
+
+1. **负载均衡**:由于访问人数太多,我们的网站部署了多台服务器个共同提供相同的服务,但每台服务器上存储的数据不同。为了保证请求的正确响应,相同参数(key)的请求(比如同个 IP 的请求、同一个用户的请求)需要发到同一台服务器处理。
+2. **分布式缓存**:由于缓存数据量太大,我们部署了多台缓存服务器共同提供缓存服务。缓存数据需要尽可能均匀地分布式在这些缓存服务器上,通过 key 可以找到对应的缓存服务器。
+
+这两种场景的本质,都是需要建立一个**从 key 到服务器/节点的稳定映射关系**。
+
+为了实现这个目标,你首先会想到什么方案呢?
+
+## 普通哈希算法
+
+相信大家很快就能想到 **“哈希+取模”** 这个经典组合。通过哈希函数计算出 key 的哈希值,再对服务器数量取模,从而将 key 映射到固定的服务器上。
+
+公式也很简单:
+
+```java
+node_number = hash(key) % N
+```
+
+- `hash(key)`: 使用哈希函数(建议使用性能较好的非加密哈希函数,例如 SipHash、MurMurHash3、CRC32、DJB)对唯一键进行哈希。
+- `% N`: 对哈希值取模,将哈希值映射到一个介于 0 到 N-1 之间的值,N 为节点数/服务器数。
+
+
+
+然而,传统的哈希取模算法有一个比较大的缺陷就是:**无法很好的解决机器/节点动态减少(比如某台机器宕机)或者增加的场景(比如又增加了一台机器)。**
+
+想象一下,服务器的初始数量为 4 台 (N = 4),如果其中一台服务器宕机,N 就变成了 3。此时,对于同一个 key,`hash(key) % 3` 的结果很可能与 `hash(key) % 4` 完全不同。
+
+
+
+这意味着几乎所有的数据映射关系都会错乱。在分布式缓存场景下,这会导致**大规模的缓存失效和缓存穿透**,瞬间将压力全部打到后端的数据库上,引发系统雪崩。
+
+据估算,当节点数量从 N 变为 N-1 时,平均有 (N-1)/N 比例的数据需要迁移,这个比例 **趋近于 100%** 。这种“牵一发而动全身”的效应,在生产环境中是完全不可接受的。
+
+为了更好地解决这个问题,一致性哈希算法诞生了。
+
+## 一致性哈希算法
+
+一致性哈希算法在 1997 年由麻省理工学院提出(这篇论文的 PDF 在线阅读地址:),是一种特殊的哈希算法,在移除或者添加一个服务器时,能够尽可能小地改变已存在的服务请求与处理请求服务器之间的映射关系。一致性哈希解决了传统哈希算法在分布式[哈希表](https://baike.baidu.com/item/哈希表/5981869)(Distributed Hash Table,DHT)中存在的动态伸缩等问题 。
+
+一致性哈希算法的底层原理也很简单,关键在于**哈希环**的引入。
+
+### 哈希环
+
+一致性哈希算法将哈希空间组织成一个环形结构,将数据和节点都映射到这个环上,然后根据顺时针的规则确定数据或请求应该分配到哪个节点上。通常情况下,哈希环的起点是 0,终点是 2^32 - 1,并且起点与终点连接,故这个环的整数分布范围是 **[0, 2^32-1]** 。
+
+传统哈希算法是对服务器数量取模,一致性哈希算法是对哈希环的范围取模,固定值,通常为 2^32:
+
+```java
+node_number = hash(key) % 2^32
+```
+
+服务器/节点如何映射到哈希环上呢?也是哈希取模。例如,一般我们会根据服务器的 IP 或者主机名进行哈希,然后再取模。
+
+```java
+hash(服务器ip)% 2^32
+```
+
+如下图所示:
+
+
+
+我们将数据和节点都映射到哈希环上,环上的每个节点都负责一个区间。对于上图来说,每个节点负责的数据情况如下:
+
+- **Node1:** 负责 Node4 到 Node1 之间的区域(包含 value6)。
+- **Node2:** 负责 Node1 到 Node2 之间的区域(包含 value1, value2)。
+- **Node3:** 负责 Node2 到 Node3 之间的区域(包含 value3)。
+- **Node4:** 负责 Node3 到 Node4 之间的区域(包含 value4, value5)。
+
+### 节点移除/增加
+
+新增节点和移除节点的情况下,哈希环的引入可以避免影响范围太大,减少需要迁移的数据。
+
+还是用上面分享的哈希环示意图为例,假设 Node2 节点被移除的话,那 Node3 就要负责 Node2 的数据,直接迁移 Node2 的数据到 Node3 即可,其他节点不受影响。
+
+
+
+同样地,如果我们在 Node1 和 Node2 之间新增一个节点 Node5,那么原本应该由 Node2 负责的一部分数据(即哈希值落在 Node1 和 Node5 之间的数据,如图中的 value1)现在会由 Node5 负责。我们只需要将这部分数据从 Node2 迁移到 Node5 即可,同样只影响了相邻的节点,影响范围非常小。
+
+
+
+### 数据倾斜问题
+
+理想情况下,节点在环上是均匀分布的。然而,现实可能并不是这样的,尤其是节点数量比较少的时候。节点可能被映射到附近的区域,这样的话,就会导致绝大部分数据都由其中一个节点负责。
+
+
+
+对于上图来说,每个节点负责的数据情况如下:
+
+- **Node1:** 负责 Node4 到 Node1 之间的区域(包含 value6)。
+- **Node2:** 负责 Node1 到 Node2 之间的区域(包含 value1)。
+- **Node3:** 负责 Node2 到 Node3 之间的区域(包含 value2,value3, value4, value5)。
+- **Node4:** 负责 Node3 到 Node4 之间的区域。
+
+除了数据倾斜问题,还有一个隐患。当新增或者删除节点的时候,数据分配不均衡。例如,Node3 被移除的话,Node3 负责的所有数据都要交给 Node4,随后所有的请求都要达到 Node4 上。假设 Node4 的服务器处理能力比较差的话,那可能直接就被干崩了。理想情况下,应该有更多节点来分担压力。
+
+如何解决这些问题呢?答案是引入**虚拟节点**。
+
+### 虚拟节点
+
+虚拟节点就是对真实的物理节点在哈希环上虚拟出几个它的分身节点。数据落到分身节点上实际上就是落到真实的物理节点上,通过将虚拟节点均匀分散在哈希环的各个部分。
+
+如下图所示,Node1、Node2、Node3、Node4 这 4 个节点都对应 3 个虚拟节点(下图只是为了演示,实际情况节点分布不会这么有规律)。
+
+
+
+对于上图来说,每个节点最终负责的数据情况如下:
+
+- **Node1**:value4
+- **Node2**:value1,value3
+- **Node3**:value5
+- **Node4**:value2,value6
+
+**引入虚拟节点的好处是巨大的:**
+
+1. **数据均衡:** 虚拟节点越多,环上的“服务器点”就越密集,数据分布自然就越均匀,从根本上解决了数据倾斜问题。通常,每个真实节点对应的虚拟节点数在 100 到 200 之间,例如 Nginx 选择为每个权重分配 160 个虚拟节点。这里的权重的是为了区分服务器,例如处理能力更强的服务器权重越高,进而导致对应的虚拟节点越多,被命中的概率越大。
+2. **容错性增强:** 这才是虚拟节点最精妙的地方。当一个物理节点宕机,它相当于在环上的多个虚拟节点同时下线。这些虚拟节点原本负责的数据和流量,会**自然地、均匀地分散**给环上其他**多个不同**的物理节点去接管,而不会将压力集中于某一个邻居节点。这极大地提升了系统的稳定性和容错能力。
+
+## 参考
+
+- 深入剖析 Nginx 负载均衡算法:
+- 读源码学架构系列:一致性哈希:
+- 一致性 Hash 算法原理总结:
diff --git a/docs/distributed-system/protocol/gossip-protocl.md b/docs/distributed-system/protocol/gossip-protocl.md
deleted file mode 100644
index 67c5c16139e..00000000000
--- a/docs/distributed-system/protocol/gossip-protocl.md
+++ /dev/null
@@ -1,145 +0,0 @@
----
-title: Gossip 协议详解
-category: 分布式
-tag:
- - 分布式协议&算法
- - 共识算法
----
-
-## 背景
-
-在分布式系统中,不同的节点进行数据/信息共享是一个基本的需求。
-
-一种比较简单粗暴的方法就是 **集中式发散消息**,简单来说就是一个主节点同时共享最新信息给其他所有节点,比较适合中心化系统。这种方法的缺陷也很明显,节点多的时候不光同步消息的效率低,还太依赖与中心节点,存在单点风险问题。
-
-于是,**分散式发散消息** 的 **Gossip 协议** 就诞生了。
-
-## Gossip 协议介绍
-
-Gossip 直译过来就是闲话、流言蜚语的意思。流言蜚语有什么特点呢?容易被传播且传播速度还快,你传我我传他,然后大家都知道了。
-
-
-
-**Gossip 协议** 也叫 Epidemic 协议(流行病协议)或者 Epidemic propagation 算法(疫情传播算法),别名很多。不过,这些名字的特点都具有 **随机传播特性** (联想一下病毒传播、癌细胞扩散等生活中常见的情景),这也正是 Gossip 协议最主要的特点。
-
-Gossip 协议最早是在 ACM 上的一篇 1987 年发表的论文 [《Epidemic Algorithms for Replicated Database Maintenance》](https://dl.acm.org/doi/10.1145/41840.41841)中被提出的。根据论文标题,我们大概就能知道 Gossip 协议当时提出的主要应用是在分布式数据库系统中各个副本节点同步数据。
-
-正如 Gossip 协议其名一样,这是一种随机且带有传染性的方式将信息传播到整个网络中,并在一定时间内,使得系统内的所有节点数据一致。
-
-在 Gossip 协议下,没有所谓的中心节点,每个节点周期性地随机找一个节点互相同步彼此的信息,理论上来说,各个节点的状态最终会保持一致。
-
-下面我们来对 Gossip 协议的定义做一个总结:**Gossip 协议是一种允许在分布式系统中共享状态的去中心化通信协议,通过这种通信协议,我们可以将信息传播给网络或集群中的所有成员。**
-
-## Gossip 协议应用
-
-NoSQL 数据库 Redis 和 Apache Cassandra、服务网格解决方案 Consul 等知名项目都用到了 Gossip 协议,学习 Gossip 协议有助于我们搞清很多技术的底层原理。
-
-我们这里以 Redis Cluster 为例说明 Gossip 协议的实际应用。
-
-我们经常使用的分布式缓存 Redis 的官方集群解决方案(3.0 版本引入) Redis Cluster 就是基于 Gossip 协议来实现集群中各个节点数据的最终一致性。
-
-
-
-Redis Cluster 是一个典型的分布式系统,分布式系统中的各个节点需要互相通信。既然要相互通信就要遵循一致的通信协议,Redis Cluster 中的各个节点基于 **Gossip 协议** 来进行通信共享信息,每个 Redis 节点都维护了一份集群的状态信息。
-
-Redis Cluster 的节点之间会相互发送多种 Gossip 消息:
-
-- **MEET**:在 Redis Cluster 中的某个 Redis 节点上执行 `CLUSTER MEET ip port` 命令,可以向指定的 Redis 节点发送一条 MEET 信息,用于将其添加进 Redis Cluster 成为新的 Redis 节点。
-- **PING/PONG**:Redis Cluster 中的节点都会定时地向其他节点发送 PING 消息,来交换各个节点状态信息,检查各个节点状态,包括在线状态、疑似下线状态 PFAIL 和已下线状态 FAIL。
-- **FAIL**:Redis Cluster 中的节点 A 发现 B 节点 PFAIL ,并且在下线报告的有效期限内集群中半数以上的节点将 B 节点标记为 PFAIL,节点 A 就会向集群广播一条 FAIL 消息,通知其他节点将故障节点 B 标记为 FAIL 。
-- ……
-
-下图就是主从架构的 Redis Cluster 的示意图,图中的虚线代表的就是各个节点之间使用 Gossip 进行通信 ,实线表示主从复制。
-
-
-
-有了 Redis Cluster 之后,不需要专门部署 Sentinel 集群服务了。Redis Cluster 相当于是内置了 Sentinel 机制,Redis Cluster 内部的各个 Redis 节点通过 Gossip 协议互相探测健康状态,在故障时可以自动切换。
-
-关于 Redis Cluster 的详细介绍,可以查看这篇文章 [Redis 集群详解(付费)](https://javaguide.cn/database/redis/redis-cluster.html) 。
-
-## Gossip 协议消息传播模式
-
-Gossip 设计了两种可能的消息传播模式:**反熵(Anti-Entropy)** 和 **传谣(Rumor-Mongering)**。
-
-### 反熵(Anti-entropy)
-
-根据维基百科:
-
-> 熵的概念最早起源于[物理学](https://zh.wikipedia.org/wiki/物理学),用于度量一个热力学系统的混乱程度。熵最好理解为不确定性的量度而不是确定性的量度,因为越随机的信源的熵越大。
-
-在这里,你可以把反熵中的熵理解为节点之间数据的混乱程度/差异性,反熵就是指消除不同节点中数据的差异,提升节点间数据的相似度,从而降低熵值。
-
-具体是如何反熵的呢?集群中的节点,每隔段时间就随机选择某个其他节点,然后通过互相交换自己的所有数据来消除两者之间的差异,实现数据的最终一致性。
-
-在实现反熵的时候,主要有推、拉和推拉三种方式:
-
-- 推方式,就是将自己的所有副本数据,推给对方,修复对方副本中的熵。
-- 拉方式,就是拉取对方的所有副本数据,修复自己副本中的熵。
-- 推拉就是同时修复自己副本和对方副本中的熵。
-
-伪代码如下:
-
-
-
-在我们实际应用场景中,一般不会采用随机的节点进行反熵,而是可以设计成一个闭环。这样的话,我们能够在一个确定的时间范围内实现各个节点数据的最终一致性,而不是基于随机的概率。像 InfluxDB 就是这样来实现反熵的。
-
-
-
-1. 节点 A 推送数据给节点 B,节点 B 获取到节点 A 中的最新数据。
-2. 节点 B 推送数据给 C,节点 C 获取到节点 A,B 中的最新数据。
-3. 节点 C 推送数据给 A,节点 A 获取到节点 B,C 中的最新数据。
-4. 节点 A 再推送数据给 B 形成闭环,这样节点 B 就获取到节点 C 中的最新数据。
-
-虽然反熵很简单实用,但是,节点过多或者节点动态变化的话,反熵就不太适用了。这个时候,我们想要实现最终一致性就要靠 **谣言传播(Rumor mongering)** 。
-
-### 谣言传播(Rumor mongering)
-
-谣言传播指的是分布式系统中的一个节点一旦有了新数据之后,就会变为活跃节点,活跃节点会周期性地联系其他节点向其发送新数据,直到所有的节点都存储了该新数据。
-
-如下图所示(下图来自于[INTRODUCTION TO GOSSIP](https://managementfromscratch.wordpress.com/2016/04/01/introduction-to-gossip/) 这篇文章):
-
-
-
-伪代码如下:
-
-
-
-谣言传播比较适合节点数量比较多的情况,不过,这种模式下要尽量避免传播的信息包不能太大,避免网络消耗太大。
-
-### 总结
-
-- 反熵(Anti-Entropy)会传播节点的所有数据,而谣言传播(Rumor-Mongering)只会传播节点新增的数据。
-- 我们一般会给反熵设计一个闭环。
-- 谣言传播(Rumor-Mongering)比较适合节点数量比较多或者节点动态变化的场景。
-
-## Gossip 协议优势和缺陷
-
-**优势:**
-
-1、相比于其他分布式协议/算法来说,Gossip 协议理解起来非常简单。
-
-2、能够容忍网络上节点的随意地增加或者减少,宕机或者重启,因为 Gossip 协议下这些节点都是平等的,去中心化的。新增加或者重启的节点在理想情况下最终是一定会和其他节点的状态达到一致。
-
-3、速度相对较快。节点数量比较多的情况下,扩散速度比一个主节点向其他节点传播信息要更快(多播)。
-
-**缺陷** :
-
-1、消息需要通过多个传播的轮次才能传播到整个网络中,因此,必然会出现各节点状态不一致的情况。毕竟,Gossip 协议强调的是最终一致,至于达到各个节点的状态一致需要多长时间,谁也无从得知。
-
-2、由于拜占庭将军问题,不允许存在恶意节点。
-
-3、可能会出现消息冗余的问题。由于消息传播的随机性,同一个节点可能会重复收到相同的消息。
-
-## 总结
-
-- Gossip 协议是一种允许在分布式系统中共享状态的通信协议,通过这种通信协议,我们可以将信息传播给网络或集群中的所有成员。
-- Gossip 协议被 Redis、Apache Cassandra、Consul 等项目应用。
-- 谣言传播(Rumor-Mongering)比较适合节点数量比较多或者节点动态变化的场景。
-
-## 参考
-
-- 一万字详解 Redis Cluster Gossip 协议:
-- 《分布式协议与算法实战》
-- 《Redis 设计与实现》
-
-
diff --git a/docs/distributed-system/protocol/gossip-protocol.md b/docs/distributed-system/protocol/gossip-protocol.md
new file mode 100644
index 00000000000..e03af2e583d
--- /dev/null
+++ b/docs/distributed-system/protocol/gossip-protocol.md
@@ -0,0 +1,202 @@
+---
+title: Gossip 协议详解
+description: Gossip协议原理详解,讲解去中心化信息传播机制、两种典型传播模式(反熵与谣言传播)及在Redis Cluster等系统中的应用。
+category: 分布式
+tag:
+ - 分布式协议&算法
+ - 数据复制协议
+ - 最终一致性
+---
+
+## 背景
+
+在分布式系统中,不同节点间共享状态是一个基本需求。
+
+一种简单的方法是 **集中式广播**:由中心节点向所有其他节点同步信息。这种方式适合中心化系统,但存在明显缺陷:当节点数量增加时,同步效率下降(O(N) 复杂度),且过度依赖中心节点,存在单点故障风险。
+
+**分散式传播** 的 **Gossip 协议** 提供了一种去中心化的替代方案。
+
+
+
+## Gossip 协议介绍
+
+**Gossip**(闲话协议)也称 **Epidemic 协议**(流行病协议),灵感来源于流行病传播的随机特性。其核心思想是:每个节点周期性地随机选择若干其他节点交换信息,使数据像病毒传播一样扩散至整个网络。
+
+
+
+Gossip 协议最早由 Demers 等人在 1987 年的论文 [《Epidemic Algorithms for Replicated Database Maintenance》](https://dl.acm.org/doi/10.1145/41840.41841) 中提出,用于解决分布式数据库的副本同步问题。
+
+**定义**:Gossip 协议是一种**去中心化**的通信协议,通过节点间的随机信息交换,在**非拜占庭且不存在永久网络分区**、节点持续周期性交换的前提下,使集群内所有节点的状态达到**最终一致性**。
+
+> **重要区分**:Gossip 是信息传播协议,**不是共识算法**(如 Raft/Paxos)。共识算法保证强一致性与安全性,Gossip 只保证最终一致性,不适用于选主或状态机复制等需要强一致的场景。
+
+**关键特性**:
+
+- **去中心化**:无中心节点,所有节点地位平等
+- **容错性强**:容忍节点宕机、网络分区、动态增删节点
+- **概率收敛**:在均匀随机选点、fanout 为常数的经典模型下,传播轮次期望为 O(log N)(如 N=100 时约 5-7 轮,具体取决于 fanout 与丢包率)
+- **消息冗余**:同一消息可能被多次接收,需去重机制
+
+## Gossip 协议应用
+
+Gossip 协议被广泛应用于分布式系统:
+
+- **Redis Cluster**:用于节点间状态同步与故障检测
+- **Apache Cassandra**:用于节点成员与状态信息传播;副本修复采用反熵/repair(基于 Merkle Tree)
+- **Consul**:用于成员发现、故障探测与事件广播(基于 SWIM 协议)
+- **Amazon Dynamo**:用于分布式存储的最终一致性
+
+以 **Redis Cluster**(3.0+)为例:
+
+Redis Cluster 是一个去中心化的分布式缓存方案,各节点通过 Gossip 协议交换集群状态,包括:节点信息、槽位分配、节点状态(在线/PFAIL/FAIL)。
+
+
+
+**Gossip 消息类型**:
+
+| 消息类型 | 用途 |
+| -------- | --------------------------- |
+| MEET | 将指定节点添加进集群 |
+| PING | 周期性发送,交换节点状态 |
+| PONG | 响应 PING,携带自身状态信息 |
+| FAIL | 广播节点故障标记 |
+
+> 注:在实现上,MEET/PING/PONG 共享同一类消息结构;PONG 是对 PING/MEET 的响应,MEET 相当于"强制握手"的 PING。
+
+**故障检测流程**:
+
+1. 节点 A 若在 `cluster-node-timeout`(常见为 15s,具体以配置为准)内未收到 B 的响应,将 B 标记为 **PFAIL**(疑似下线)
+2. 若 A 收到其他主节点对 B 的 PFAIL 报告,且**半数以上的主节点**确认 B 为 PFAIL(报告未过期),则 A 将 B 标记为 **FAIL**(已下线)并向集群广播
+
+下图就是主从架构的 Redis Cluster 的示意图,图中的虚线代表的就是各个节点之间使用 Gossip 进行通信,实线表示主从复制。
+
+
+
+> 注:Redis Cluster 主要通过 PING/PONG 的增量 gossip 传播节点/槽位/故障信息(带时间戳/标志位等),而不是采用像 Dynamo 那样基于 Merkle tree 的反熵对账流程。
+
+关于 Redis Cluster 的详细介绍,可以查看这篇文章 [Redis 集群详解(付费)](https://javaguide.cn/database/redis/redis-cluster.html)。
+
+## Gossip 协议传播模式
+
+Gossip 协议有两种主要传播模式:**反熵** 和 **谣言传播**。
+
+### 反熵
+
+**定义**:节点间交换**完整数据**(或数据摘要),消除差异,实现最终一致。
+
+**熵**的物理含义是系统混乱程度;反熵即**降低节点间数据差异,提升一致性**。
+
+根据维基百科:
+
+> 熵的概念最早起源于[物理学](https://zh.wikipedia.org/wiki/物理学),用于度量一个热力学系统的混乱程度。熵最好理解为不确定性的量度而不是确定性的量度,因为越随机的信源的熵越大。
+
+在这里,你可以把反熵中的熵理解为节点之间数据的混乱程度/差异性,反熵就是指消除不同节点中数据的差异,提升节点间数据的相似度,从而降低熵值。
+
+**三种实现方式**:
+
+| 方式 | 描述 | 适用场景 |
+| --------- | ---------------------------------- | -------------- |
+| Push | 发送方将自己的全部数据推送给接收方 | 发送方有新数据 |
+| Pull | 接收方拉取发送方的全部数据 | 接收方数据陈旧 |
+| Push-Pull | 双向交换数据,并比较差异 | 最高效,最常用 |
+
+
+
+伪代码如下:
+
+
+
+**收敛特性**:在均匀随机选点、fanout 为常数的模型下,期望 O(log N) 轮覆盖全部节点(常见估算可用 log₂N 量级)
+
+部分系统(如 InfluxDB)采用**确定性闭环调度**(如环形拓扑)代替随机选择,可在确定轮次内完成同步。这属于反熵的**工程衍生实现**,而非标准 Gossip 协议的核心机制。确定性调度牺牲了随机性的容错优势,换取可预测的收敛时间。
+
+
+
+1. 节点 A 推送数据给节点 B,节点 B 获取到节点 A 中的最新数据。
+2. 节点 B 推送数据给 C,节点 C 获取到节点 A,B 中的最新数据。
+3. 节点 C 推送数据给 A,节点 A 获取到节点 B,C 中的最新数据。
+4. 节点 A 再推送数据给 B 形成闭环,这样节点 B 就获取到节点 C 中的最新数据。
+
+**权衡**:闭环调度可在确定时间内完成同步,但牺牲了**容错性**(环中节点故障影响传播路径),且难以适应节点动态增删。
+
+**适用场景**:需要较低残留率(尽量不漏更新)、允许后台周期性对账修复;数据量大时必须依赖摘要/树等增量比对以控制成本。
+
+> **生产级优化**:在大规模分布式存储(如 Cassandra、DynamoDB)中,节点数据量可达 TB 级,直接交换完整数据不现实。生产系统使用 **Merkle Tree(默克尔树)** 进行增量差异比对:两节点先交换 Merkle Tree 根哈希,若有差异则递归比对子树,在树高 O(log M) 的层级上定位差异(M 为该范围内条目数),随后仅传输增量数据。
+
+### 谣言传播
+
+**定义**:当节点有**新数据**时,变为活跃节点,周期性地向随机节点广播该数据,直到所有节点都收到。
+
+**与反熵的区别**:
+
+- 只传播**新增数据**(Delta),非完整数据
+- 节点收到更新后进入活跃状态周期性传播,多次接触到已知该更新的节点后按策略(计数/概率/TTL)停止传播
+- 适合**节点数量大**、**增量数据小**的场景
+
+> **去重机制**:生产环境(如 Redis Cluster)通过**版本号**或**消息 ID** 去重,避免重复处理相同消息。
+
+如下图所示(下图来自于[INTRODUCTION TO GOSSIP](https://managementfromscratch.wordpress.com/2016/04/01/introduction-to-gossip/) 这篇文章):
+
+
+
+伪代码如下:
+
+
+
+**收敛特性**:在均匀随机选点、fanout 为常数的模型下,O(log N) 轮后以高概率覆盖全部节点。
+
+**注意事项**:
+
+- 控制消息包大小,尽量避免分片(视路径 MTU 而定,通常控制在单个网络包内)
+- 配合去重机制(如消息 ID、版本号)
+- 避免高频更新导致消息风暴
+- 使用 **Jitter(随机抖动)**打散同步时间,避免多节点同时发起传播造成雪崩
+
+
+
+### 总结
+
+| 要点 | 反熵 | 谣言传播 |
+| -------- | -------------------------- | -------------------------- |
+| 传播内容 | 完整数据(或摘要) | 仅新增数据(Delta) |
+| 适用场景 | 节点数量适中 | 节点数量较多/动态变化 |
+| 消息开销 | 较大 | 较小 |
+| 收敛范围 | 收敛到最新数据(全量同步) | 收敛到已知数据(增量传播) |
+
+## Gossip 协议优势与缺陷
+
+**优势**:
+
+1. **实现简单**:协议逻辑简单,易于理解
+
+2. **容错性强**:容忍节点宕机、网络分区、动态增删节点。新增或重启的节点在理想情况下最终一定会和其他节点的状态达到一致。
+
+3. **扩展性好**:收敛时间为 O(log N),当 N 较大(如 N > 100)时,并行传播通常比中心节点单播更快(后者需 O(N) 轮次)。在典型 rumor spreading 模型下代价是**消息总量为 O(N log N)**(具体取决于实现策略与停止条件),存在冗余开销。
+
+**缺陷**:
+
+1. **最终一致**:消息需通过多轮传播才能覆盖整个网络,存在不一致窗口期。达到一致的具体时间取决于网络状况、gossip 间隔(**视实现配置而定,常见 100ms-1s**)与节点规模。
+
+2. **不适用拜占庭环境**:Gossip 协议的设计假设是非拜占庭环境,不处理恶意节点的情况(节点不会伪造或篡改消息)。
+
+3. **消息冗余**:由于传播的随机性,同一节点可能重复收到相同消息,需配合去重机制。
+
+## 总结
+
+- Gossip 协议是一种**去中心化**的通信协议,通过节点间的随机信息交换,使集群内所有节点的状态达到**最终一致性**
+- **不是共识算法**:Gossip 不保证强一致性/线性一致性,不能用于选主或状态机复制;共识算法(Raft/Paxos)才保证安全性与线性一致
+- 核心特性:去中心化、容错性强、O(log N) 收敛
+- 两种传播模式:**反熵**(完整数据/摘要)、**谣言传播**(增量数据)
+- 典型应用:元数据传播(Redis Cluster)、最终一致存储(Cassandra/DynamoDB)
+- 权衡:简单性与容错性 vs 最终一致延迟与消息冗余
+
+## 参考
+
+- [Epidemic Algorithms for Replicated Database Maintenance](https://dl.acm.org/doi/10.1145/41840.41841) - Demers et al., 1987
+- [Amazon Dynamo: All Things Distributed](https://www.allthingsdistributed.com/files/amazon-dynamo-sosp2007.pdf) - DeCandia et al., 2007
+- [Redis Cluster Specification](https://redis.io/docs/management/scaling/)
+- 一万字详解 Redis Cluster Gossip 协议:
+- 《分布式协议与算法实战》
+- 《Redis 设计与实现》
+
+
diff --git a/docs/distributed-system/protocol/images/gossip/gossip-rumor-mongering.gif b/docs/distributed-system/protocol/images/gossip/gossip-rumor-mongering.gif
deleted file mode 100644
index 5dfa2ccb7f9..00000000000
Binary files a/docs/distributed-system/protocol/images/gossip/gossip-rumor-mongering.gif and /dev/null differ
diff --git a/docs/distributed-system/protocol/images/gossip/gossip.png b/docs/distributed-system/protocol/images/gossip/gossip.png
deleted file mode 100644
index 2d85b8d9ee3..00000000000
Binary files a/docs/distributed-system/protocol/images/gossip/gossip.png and /dev/null differ
diff --git a/docs/distributed-system/protocol/images/gossip/redis-cluster-gossip.png b/docs/distributed-system/protocol/images/gossip/redis-cluster-gossip.png
deleted file mode 100644
index 0485ae3e1da..00000000000
Binary files a/docs/distributed-system/protocol/images/gossip/redis-cluster-gossip.png and /dev/null differ
diff --git "a/docs/distributed-system/protocol/images/gossip/\345\217\215\347\206\265-\351\227\255\347\216\257.drawio" "b/docs/distributed-system/protocol/images/gossip/\345\217\215\347\206\265-\351\227\255\347\216\257.drawio"
deleted file mode 100644
index bc00005d2b3..00000000000
--- "a/docs/distributed-system/protocol/images/gossip/\345\217\215\347\206\265-\351\227\255\347\216\257.drawio"
+++ /dev/null
@@ -1 +0,0 @@
-5VhNc5swEP01HONBYDAcjeOm06bTTHNoc+ooIIwaQIyQY5NfXwkkg4w/iJukziQHR/skFmnf7mPBsGfZ+orCIvlGIpQalhmtDfvSsCwATJf/E0jVIL4/aYAFxZFc1AK3+AlJ0JToEkeo1BYyQlKGCx0MSZ6jkGkYpJSs9GUxSfW7FnCBesBtCNM++hNHLGlQz5q0+GeEF4m6M3D9ZiaDarE8SZnAiKw6kD037BklhDWjbD1DqQieiktz3ac9s5uNUZSzIRcEfoFuKoCmflF8L77+dn58yS6kl0eYLuWBjblneFPD44OJ+A38qdw/q1RQuGMef24EqwQzdFvAUMyseApwLGFZyi3Ah7AsGlJivEZ8H0GM03RGUkJrR3Ycx1YYcrxklDygzkzk3ruOK2ZU2ExhPCAWJtJ5THImEwZ43O4HRJ0OUYbWHUgG6AqRDDFa8SVy1lZpJ7PVGkt71eFeQkmHdoVBmW2LjeeWED6QnDyDn/EAfmYfhx9rix/b+8/8+P3YR1w/pEkoS8iC5DCdt2hAyTKPRLTrkLVrrgkpZOj+IMYqGTu4ZERnbXBgS7KkITqwfUcqKqQLxI6noTjbQZooSiHDj7p2vnjQQb8qrJ08XMN7/jjSMz7Fi5yPQx4rxHM5EMmHud5P5USGo6ihCZX4Cd7X/gRRBcE5q4/iBIZzOYiHQznTy/rNQ0zeVHtO7KoGc2R66vlaaY4G8yB934izdZaQOC55QmwTtdnC6dw5AwQt+DiCNla5fC6CNunz0yMjj6aisxJVlMKyxOFheUJrzH4JY2RajrTvRHxHrrQu1zLctVEpI+cH+lUvdJR5151rL6utquPkBlHMAyIqvMZOl0jVdB6TSGegRHaIdXYQq7BTK1g1Mt5Eb2R8X3fRnFte1e0atxyNna0E9bcyrwlMz9FL6QWwewmpmHsPWq/K6Z+1/oKLPXBsjYsL+9zV3nsFNdmogqYJrUQcUIWOmEgNAgcV6MOphuOP/O7fVu2D3dPP1hTLHfl63zI2zQ30Vsri9pJz/I6URZXWSygLFxZPV5az7yPBji8XryMt4Iiw7BWJo8UPzqr4LXfPu/Vzy3v7I4ptv3HL0H/HsN9RYYN9LxEn9QwTxz+bnoGb7efOZnn70die/wU=
\ No newline at end of file
diff --git "a/docs/distributed-system/protocol/images/gossip/\345\217\215\347\206\265-\351\227\255\347\216\257.png" "b/docs/distributed-system/protocol/images/gossip/\345\217\215\347\206\265-\351\227\255\347\216\257.png"
deleted file mode 100644
index 0bf4e605046..00000000000
Binary files "a/docs/distributed-system/protocol/images/gossip/\345\217\215\347\206\265-\351\227\255\347\216\257.png" and /dev/null differ
diff --git a/docs/distributed-system/protocol/images/paxos/paxos-made-simple.png b/docs/distributed-system/protocol/images/paxos/paxos-made-simple.png
deleted file mode 100644
index 4e51f58db4b..00000000000
Binary files a/docs/distributed-system/protocol/images/paxos/paxos-made-simple.png and /dev/null differ
diff --git a/docs/distributed-system/protocol/paxos-algorithm.md b/docs/distributed-system/protocol/paxos-algorithm.md
index c820209f4a8..1aace26b109 100644
--- a/docs/distributed-system/protocol/paxos-algorithm.md
+++ b/docs/distributed-system/protocol/paxos-algorithm.md
@@ -1,18 +1,19 @@
---
title: Paxos 算法详解
+description: Paxos 共识算法原理详解,涵盖 Basic Paxos 两阶段提交流程、Multi-Paxos 优化思想及与 Raft 的对比分析。
category: 分布式
-tag:
+tags:
- 分布式协议&算法
- 共识算法
---
## 背景
-Paxos 算法是 Leslie Lamport([莱斯利·兰伯特](https://zh.wikipedia.org/wiki/莱斯利·兰伯特))在 **1990** 年提出了一种分布式系统 **共识** 算法。这也是第一个被证明完备的共识算法(前提是不存在拜占庭将军问题,也就是没有恶意节点)。
+Paxos 算法是 Leslie Lamport(莱斯利·兰伯特)在 **1990** 年提出的一种分布式系统 **共识** 算法。这是最早被广泛认可的分布式共识算法之一(前提是不存在拜占庭将军问题,也就是没有恶意节点)。
为了介绍 Paxos 算法,兰伯特专门写了一篇幽默风趣的论文。在这篇论文中,他虚拟了一个叫做 Paxos 的希腊城邦来更形象化地介绍 Paxos 算法。
-不过,审稿人并不认可这篇论文的幽默。于是,他们就给兰伯特说:“如果你想要成功发表这篇论文的话,必须删除所有 Paxos 相关的故事背景”。兰伯特一听就不开心了:“我凭什么修改啊,你们这些审稿人就是缺乏幽默细胞,发不了就不发了呗!”。
+不过,审稿人并不认可这篇论文的幽默。于是,他们就给兰伯特说:"如果你想要成功发表这篇论文的话,必须删除所有 Paxos 相关的故事背景"。兰伯特一听就不开心了:"我凭什么修改啊,你们这些审稿人就是缺乏幽默细胞,发不了就不发了呗!"。
于是乎,提出 Paxos 算法的那篇论文在当时并没有被成功发表。
@@ -22,7 +23,7 @@ Paxos 算法是 Leslie Lamport([莱斯利·兰伯特](https://zh.wikipedia.org
《Paxos Made Simple》这篇论文就 14 页,相比于 《The Part-Time Parliament》的 33 页精简了不少。最关键的是这篇论文的摘要就一句话:
-
+
> The Paxos algorithm, when presented in plain English, is very simple.
@@ -32,51 +33,425 @@ Paxos 算法是 Leslie Lamport([莱斯利·兰伯特](https://zh.wikipedia.org
## 介绍
-Paxos 算法是第一个被证明完备的分布式系统共识算法。共识算法的作用是让分布式系统中的多个节点之间对某个提案(Proposal)达成一致的看法。提案的含义在分布式系统中十分宽泛,像哪一个节点是 Leader 节点、多个事件发生的顺序等等都可以是一个提案。
+本文将 Paxos 分为两部分进行讲解:
-兰伯特当时提出的 Paxos 算法主要包含 2 个部分:
+- **Basic Paxos 算法**:描述多节点之间如何就单个值(value)达成共识。
+- **Multi-Paxos 思想**:通过执行多个 Basic Paxos 实例,就一系列值达成共识。
-- **Basic Paxos 算法**:描述的是多节点之间如何就某个值(提案 Value)达成共识。
-- **Multi-Paxos 思想**:描述的是执行多个 Basic Paxos 实例,就一系列值达成共识。Multi-Paxos 说白了就是执行多次 Basic Paxos ,核心还是 Basic Paxos 。
+共识算法的作用是让分布式系统中的多个节点对某个提案(proposal)达成一致。"提案"在不同系统里可指代的对象很广,如选主、事件排序等都可以是提案。
-由于 Paxos 算法在国际上被公认的非常难以理解和实现,因此不断有人尝试简化这一算法。到了 2013 年才诞生了一个比 Paxos 算法更易理解和实现的共识算法—[Raft 算法](https://javaguide.cn/distributed-system/theorem&algorithm&protocol/raft-algorithm.html) 。更具体点来说,Raft 是 Multi-Paxos 的一个变种,其简化了 Multi-Paxos 的思想,变得更容易被理解以及工程实现。
+由于 Paxos 算法公认难以理解和实现,2013 年诞生了更易理解的 [Raft 算法](https://javaguide.cn/distributed-system/theorem&algorithm&protocol/raft-algorithm.html)。
-针对没有恶意节点的情况,除了 Raft 算法之外,当前最常用的一些共识算法比如 **ZAB 协议**、 **Fast Paxos** 算法都是基于 Paxos 算法改进的。
+**关于 Raft 与 Paxos 的关系**:从学术角度,Raft 并非 Paxos 的严格变体——两者在底层设计哲学(如日志空洞、Leader 权限)上存在本质差异。但从工程实践角度,Raft 的设计灵感源于 Multi-Paxos,可理解为"受 Multi-Paxos 启发的重新设计"。本文后文将详细对比二者区别。
-针对存在恶意节点的情况,一般使用的是 **工作量证明(POW,Proof-of-Work)**、 **权益证明(PoS,Proof-of-Stake )** 等共识算法。这类共识算法最典型的应用就是区块链,就比如说前段时间以太坊官方宣布其共识机制正在从工作量证明(PoW)转变为权益证明(PoS)。
+针对非拜占庭场景(无恶意节点),除 Raft 外,**ZAB 协议**、**Fast Paxos** 等都是基于 Paxos 改进的共识算法。
-区块链系统使用的共识算法需要解决的核心问题是 **拜占庭将军问题** ,这和我们日常接触到的 ZooKeeper、Etcd、Consul 等分布式中间件不太一样。
-
-下面我们来对 Paxos 算法的定义做一个总结:
-
-- Paxos 算法是兰伯特在 **1990** 年提出了一种分布式系统共识算法。
-- 兰伯特当时提出的 Paxos 算法主要包含 2 个部分: Basic Paxos 算法和 Multi-Paxos 思想。
-- Raft 算法、ZAB 协议、 Fast Paxos 算法都是基于 Paxos 算法改进而来。
+针对拜占庭场景(存在恶意节点),通常使用 **工作量证明(PoW,Proof-of-Work)**、**权益证明(PoS,Proof-of-Stake)** 等共识算法,典型应用为区块链系统。
## Basic Paxos 算法
+### 角色定义
+
Basic Paxos 中存在 3 个重要的角色:
-1. **提议者(Proposer)**:也可以叫做协调者(coordinator),提议者负责接受客户端的请求并发起提案。提案信息通常包括提案编号 (Proposal ID) 和提议的值 (Value)。
-2. **接受者(Acceptor)**:也可以叫做投票员(voter),负责对提议者的提案进行投票,同时需要记住自己的投票历史;
-3. **学习者(Learner)**:如果有超过半数接受者就某个提议达成了共识,那么学习者就需要接受这个提议,并就该提议作出运算,然后将运算结果返回给客户端。
+1. **提议者(Proposer)**:也可以叫做协调者(coordinator),负责接受客户端请求并发起提案。提案信息通常包括提案编号(proposal ID)和提议的值(value)。
+2. **接受者(Acceptor)**:也可以叫做投票员(voter),负责对提案进行投票,同时需要记住自己的投票历史。
+3. **学习者(Learner)**:负责学习(learn)已被选定的值。在复制状态机(RSM)实现中,该值通常对应一条待执行的命令,由状态机按序 apply 后再由对外服务层返回结果。

+**角色交互关系图**:
+
+```mermaid
+flowchart LR
+ subgraph Roles["Paxos 三个核心角色"]
+ direction LR
+ Prop[Proposer
提议者
发起提案]
+ Acc[Acceptor
接受者
投票表决]
+ Lear[Learner
学习者
获取结果]
+ end
+
+ Prop -->|Prepare| Acc
+ Acc -->|Promise| Prop
+ Prop -->|Accept| Acc
+ Acc -->|Accepted| Prop
+ Prop -->|通知选定| Lear
+
+ style Roles fill:#F5F7FA,color:#333,stroke:#005D7B,stroke-width:2px
+ classDef role fill:#E99151,color:#FFFFFF,stroke:none,rx:10,ry:10
+
+ class Prop,Acc,Lear role
+```
+
为了减少实现该算法所需的节点数,一个节点可以身兼多个角色。并且,一个提案被选定需要被半数以上的 Acceptor 接受。这样的话,Basic Paxos 算法还具备容错性,在少于一半的节点出现故障时,集群仍能正常工作。
-## Multi Paxos 思想
+### 执行流程
+
+Basic Paxos 通过两个阶段达成共识:**Prepare/Promise(准备/承诺)阶段**和 **Accept/Accepted(接受/已接受)阶段**。
+
+```mermaid
+sequenceDiagram
+ participant P as Proposer
+ participant A1 as Acceptor 1
+ participant A2 as Acceptor 2
+ participant A3 as Acceptor 3
+
+ note over P, A3: Phase 1: 准备阶段 (Prepare) - 争夺锁与获取历史
+ P->>A1: Prepare(ID=N)
+ P->>A2: Prepare(ID=N)
+ P->>A3: Prepare(ID=N)
+
+ A1-->>P: Promise(ID=N, 已接受值=null)
+ A2-->>P: Promise(ID=N, 已接受值=null)
+ note right of A3: 假设 A3 网络延迟未响应
+
+ note over P, A3: Phase 2: 接受阶段 (Accept) - 提交决议
+ P->>A1: Accept(ID=N, Value="Set X=1")
+ P->>A2: Accept(ID=N, Value="Set X=1")
+
+ A1-->>P: Accepted(ID=N)
+ A2-->>P: Accepted(ID=N)
+ note over P: 收到多数派 (2个) Accepted,决议达成 (Chosen)
+```
+
+#### Phase 1: Prepare/Promise(准备/承诺阶段)
+
+Proposer 选择一个提案编号 n(必须全局唯一且递增),向超过半数的 Acceptor 发送 `Prepare(n)` 请求。
+
+**Acceptor 的处理逻辑**(对每个提案编号 n 的处理逻辑):
+
+- 若 n > 该 Acceptor 见过的最大提案编号 max_n
+ - 返回 `Promise(n, max_v)`,其中 max_v 是之前接受过的最大编号提案的值(若有)
+ - 承诺不再接受编号 < n 的提案
+- 若 n ≤ max_n
+ - 拒绝或忽略该请求
+
+**目的**:让 Proposer 了解当前系统中已被接受或准备接受的提案,避免提出冲突的值。
+
+#### Phase 2: Accept/Accepted(接受/已接受阶段)
+
+当 Proposer 收到超过半数 Acceptor 的 Promise 响应后,选择响应中 max_v 最大的值(若无则任意选择一个值),向超过半数的 Acceptor 发送 `Accept(n, v)` 请求。
+
+**Acceptor 的处理逻辑**:
+
+- 若 n ≥ 该 Acceptor 在 Phase 1 承诺的 max_n
+ - 接受该提案,记录 (n, v),并返回 `Accepted(n, v)`
+- 否则
+ - 拒绝该请求
+
+#### 收敛条件
+
+当 Proposer 收到超过半数 Acceptor 对 `Accept(n, v)` 的响应时,提案 v 被**选定(chosen)**。Proposer 通知所有 Learner 提案已被选定。
+
+### 安全性保证
+
+Basic Paxos 保证以下安全性:
+
+1. **一致性**:一旦某个值被选定,所有后续选定的值都是该值
+2. **可终止性**:若无 Proposer 竞争且通信可靠,最终能选定一个值
+
+**核心机制**:通过 Phase 1 收集 Promise,Proposer 只能选择已经被 Acceptors 承诺过的值(或选择新值),保证了不会有冲突的值被选定。
+
+### 活性问题
+
+Basic Paxos 存在**活锁(Livelock)**风险:
+
+- 若多个 Proposer 同时发起提案,且提案编号交错递增
+- 可能导致没有提案能获得超过半数的 Accept
+- 系统陷入无限竞争,无法达成共识
+
+**活锁示例**(Dueling Proposers):
+
+假设有两个 Proposer P1 和 P2 同时发起提案:
+
+1. P1 发送 `Prepare(1)`,P2 发送 `Prepare(2)`
+2. Acceptor 们承诺给编号较大的 P2
+3. P1 发现编号被超越,发送 `Prepare(3)`
+4. P2 发现编号被超越,发送 `Prepare(4)`
+5. ... 循环往复,永远无法进入 Phase 2
+
+**活锁时序图**:
+
+```mermaid
+sequenceDiagram
+ participant P1 as Proposer 1
+ participant A as Acceptors
+ participant P2 as Proposer 2
+
+ Note over P1,P2: 活锁场景:Dueling Proposers
+
+ P1->>A: Prepare(N=1)
+ P2->>A: Prepare(N=2)
+ A-->>P1: Promise(拒绝, N=2 更大)
+ A-->>P2: Promise(接受, N=2)
+
+ Note over P1: 编号被超越,递增
+ P1->>A: Prepare(N=3)
+ A-->>P2: Promise(拒绝, N=3 更大)
+ A-->>P1: Promise(接受, N=3)
+
+ Note over P2: 编号被超越,递增
+ P2->>A: Prepare(N=4)
+ A-->>P1: Promise(拒绝, N=4 更大)
+ A-->>P2: Promise(接受, N=4)
+
+ Note over P1,P2: ... 循环往复,永远无法进入 Phase 2
+```
+
+**解决方案**:通过 Multi-Paxos 引入稳定的 Leader 机制。
+
+**随机退避算法(Randomized Exponential Backoff)**:
+
+为防止多个 Proposer 竞争导致活锁,生产级实现通常引入随机退避:
+
+当 Proposer 的 Prepare 请求被拒绝(编号过小)时:
+
+1. 等待随机时间:`base_delay * random(1, 2^attempt)`
+2. 选择更大的提案编号(如:`n = n + k`,`k > 0`)
+3. 重试 Prepare 阶段
+
+参数示例:
+
+- `base_delay`: 10ms
+- `attempt`: 重试次数(1, 2, 3...)
+- 最大退避时间:`max(1s, base_delay * 2^10)`
+
+这种机制确保竞争者不会同时重试,最终某个 Proposer 能成功完成 Phase 1。
+
+**分区处理**:若发生网络分区,多数派一侧可继续选举 Leader 并提交新提案;少数派无法形成法定人数(quorum),只能等待分区恢复。
+
+## Multi-Paxos 思想
+
+### 核心思想
+
+Basic Paxos 算法仅能就单个值达成共识,为了能够对一系列的值达成共识,我们需要用到 Multi-Paxos 思想。
+
+Multi-Paxos 的核心优化思想是**复用 Leader**:通过 Basic Paxos 选出一个稳定的 Proposer 作为 Leader,后续提案直接由该 Leader 发起,跳过 Phase 1 的 Prepare/Promise 阶段。
+
+### 优化机制
+
+#### 1. Leader 稳定选举
+
+- 通过 Basic Paxos 选出唯一的 Proposer 作为 Leader
+- Leader 崩溃后,通过新一轮 Basic Paxos 选举新 Leader
+- 避免多 Proposer 竞争导致的活锁
+
+#### 2. 跳过 Phase 1
+
+- Leader 稳定后,后续提案直接进入 Phase 2(Accept 阶段)
+- 无需每次都执行 Prepare/Promise,减少一轮 RPC
+- **延迟优化**:Basic Paxos 每个提案需要 2-RTT(Prepare + Accept),Multi-Paxos 后续提案仅需 1-RTT(仅 Accept),**提案提交延迟降低 50%**(2-RTT → 1-RTT)
+
+**性能优化对比图**:
+
+```mermaid
+flowchart LR
+ subgraph Basic["Basic Paxos (首次提案)"]
+ direction TB
+ C1[客户端请求] --> P1[Phase 1: Prepare/Promise
1-RTT]
+ P1 --> P2[Phase 2: Accept/Accepted
1-RTT]
+ P2 --> D1[提案选定
总延迟: 2-RTT]
+ end
+
+ subgraph Multi["Multi-Paxos (Leader 稳定后)"]
+ direction TB
+ C2[客户端请求] --> A[Phase 2: Accept/Accepted
1-RTT
跳过 Phase 1]
+ A --> D2[提案选定
总延迟: 1-RTT]
+ end
+
+ style Basic fill:#FFF5F5,color:#333,stroke:#C44545,stroke-width:2px
+ style Multi fill:#F0FFF4,color:#333,stroke:#4CA497,stroke-width:2px
+ classDef phase fill:#F39C12,color:#FFFFFF,stroke:none,rx:10,ry:10
+ classDef client fill:#00838F,color:#FFFFFF,stroke:none,rx:10,ry:10
+ classDef done fill:#4CA497,color:#FFFFFF,stroke:none,rx:10,ry:10
+
+ class C1,C2 client
+ class P1,P2,A phase
+ class D1,D2 done
+```
+
+#### 3. 日志序号
+
+- 为每个提案分配递增的**日志索引(log index)**
+- 保证全局顺序:Leader 按顺序追加日志,Acceptor 按序号接受
+- 支持**空洞**:某位置的提案可能因 Leader 切换而暂时缺失,后续可补齐
+
+#### 4. 日志空洞(gap)与 NOP 填补
+
+**问题描述**:当新 Leader 上线时,可能遇到一种棘手场景——前任 Leader 已经在某个日志位置上达成了共识,但新 Leader 不知道这个值。如果新 Leader 试图在该位置提交新值,就会覆盖已经选定的值,破坏一致性。
+
+**解决方案:NOP(No-Operation)日志**
+
+Multi-Paxos 通过引入 NOP 日志来解决这个问题:
+
+1. **场景检测**:新 Leader 在 Phase 1(Prepare)阶段,收集到 Acceptor 返回的已接受值
+2. **必须复用**:如果发现某位置已有被选定的值,新 Leader **必须**复用该值,不能提出新值
+3. **NOP 占位**:对于空洞位置(无任何已接受值),新 Leader 可以提交特殊值——NOP(空操作)
+4. **状态机跳过**:NOP 日志虽然占用日志位置,但状态机回放时会跳过,不执行任何业务逻辑
+
+**示例流程**:
+
+```
+前任 Leader 崩溃前:
+Index 1: Value=A (chosen)
+Index 2: Value=B (chosen)
+Index 3: <空洞> (未完成)
+
+新 Leader 上线后:
+Index 1: 复用 Value=A
+Index 2: 复用 Value=B
+Index 3: 提交 NOP (填补空洞,不执行业务逻辑)
+Index 4: 提交 Value=C (正常业务日志)
+```
+
+**空洞与已接受值恢复流程**:
+
+```mermaid
+sequenceDiagram
+ participant OldL as 前任 Leader
+ participant A1 as Acceptor 1
+ participant A2 as Acceptor 2
+ participant NewL as 新 Leader
+ participant SM as 状态机
+
+ Note over OldL, A2: 前任 Leader 崩溃前
+ OldL->>A1: Accept(ID=5, Value="X")
+ OldL->>A2: Accept(ID=5, Value="X")
+ A1-->>OldL: Accepted(ID=5)
+ Note over OldL: 崩溃!未收到 A2 响应
Value="X" 已被 A1 接受
+
+ Note over NewL, A2: 新 Leader 上线
+ NewL->>A1: Prepare(ID=10, index=5)
+ NewL->>A2: Prepare(ID=10, index=5)
+ A1-->>NewL: Promise(已接受值="X")
+ A2-->>NewL: Promise(已接受值=null)
+
+ Note over NewL: 发现 A1 已接受 "X"
必须复用该值
+ NewL->>A1: Accept(ID=10, index=5, Value="X")
+ NewL->>A2: Accept(ID=10, index=5, Value="X")
+ A1-->>NewL: Accepted(ID=10)
+ A2-->>NewL: Accepted(ID=10)
+
+ Note over NewL, SM: 提交并回放
+ NewL->>SM: Apply Value="X"
+ Note over SM: 状态机执行 "X"
(空洞/已接受值已安全处理)
+```
+
+### 执行流程
+
+1. **Leader 选举**:通过 Basic Paxos 选出 Leader
+2. **日志复制**:Leader 接收客户端请求,追加到本地日志,分配递增索引
+3. **直接 Accept**:Leader 向 Acceptor 发送 `Accept(index, value)`(跳过 Prepare)
+4. **响应处理**:Acceptor 按序号接受日志,记录到本地
+5. **提交确认**:当超过半数 Acceptor 接受某位置的日志后,该位置可提交
+
+### 容错与恢复
+
+- **Leader 崩溃**:新 Leader 通过日志比对找出已提交位置,补齐未提交日志
+- **网络分区**:多数派一侧继续服务,少数派等待恢复
+- **日志空洞**:新 Leader 可填补前任 Leader 未提交的日志位置
+
+**新 Leader 恢复流程图**:
+
+```mermaid
+flowchart TB
+ subgraph Recovery["新 Leader 恢复流程"]
+ direction TB
+ Start[新 Leader 上线] --> Phase1[执行 Phase 1: Prepare
收集已接受值]
+
+ Phase1 --> Check{有空洞位置?}
+
+ Check -->|是| NOP[提交 NOP 日志
填补空洞]
+ Check -->|否| Next[继续下一条]
+
+ NOP --> Next
+ Next --> More{还有未处理?}
+
+ More -->|是| Phase1
+ More -->|否| Done[恢复完成
开始正常服务]
+ end
+
+ style Recovery fill:#F5F7FA,color:#333,stroke:#005D7B,stroke-width:2px
+ classDef step fill:#E99151,color:#FFFFFF,stroke:none,rx:10,ry:10
+ classDef decision fill:#3498DB,color:#FFFFFF,stroke:none,rx:10,ry:10
+ classDef success fill:#4CA497,color:#FFFFFF,stroke:none,rx:10,ry:10
+
+ class Start,Phase1,NOP,Next step
+ class Check,More decision
+ class Done success
+```
+
+⚠️ **注意**:Multi-Paxos 只是一种思想,这种思想的核心就是通过多个 Basic Paxos 实例就一系列值达成共识。也就是说,Basic Paxos 是 Multi-Paxos 思想的核心,Multi-Paxos 就是多执行几次 Basic Paxos。
+
+由于 Lamport 提出的 Multi-Paxos 思想缺少代码实现的必要细节(比如怎么选举领导者、日志空洞如何处理),所以在理解和实现上比较困难。
+
+不过,也不需要担心,我们并不需要自己实现基于 Multi-Paxos 思想的共识算法,业界已经有了比较出名的实现。如 Raft 算法虽非 Paxos 严格变体,但借鉴了其核心思想(Leader 选举、日志复制),并简化了实现细节,变得更容易被理解以及工程实现,实际项目中可以优先考虑 Raft 算法。
+
+## Paxos vs Raft
+
+在 2014 年之后,Raft 算法凭借其极致的可理解性成为了工业界的新宠。必须明确,Raft 并非 Paxos 的变体,两者在底层设计哲学上存在硬性分歧。
+
+| **对比维度** | **Multi-Paxos** | **Raft** | **核心工程影响** |
+| --------------------- | ----------------------------------------------------------- | --------------------------------------------------------------------------- | ------------------------------------------------------------------------------- |
+| **日志流向与约束** | 允许乱序提交,允许出现**日志空洞**。 | 强制按序追加(Append-Only),**绝对不允许日志空洞**。 | Raft 实现简单,状态机回放极其顺滑;Paxos 并发上限更高,但实现难度呈指数级增加。 |
+| **Leader 选举与权限** | Leader 仅是一个性能优化手段(省略 Phase 1),非必须角色。 | **强 Leader 模型**。一切数据以 Leader 为准,日志只从 Leader 流向 Follower。 | Raft 通过限制只能选取“日志最完整”的节点当选 Leader,简化了数据恢复逻辑。 |
+| **活锁防御** | 需额外引入随机退避或外部选主算法。 | 协议内置基于随机超时(Randomized Timeout)的选主防御机制。 | Raft 的开箱即用性(Out-of-the-box)远高于 Paxos。 |
+| **工业级落地代表** | Apache ZooKeeper (基于 ZAB, 类 Multi-Paxos), Google Spanner | etcd, HashiCorp Consul, TiKV | 现代微服务基础设施倾向于选择 Raft。 |
+
+## 实际应用
+
+基于 Paxos 算法或其变体的系统包括:
+
+- **Google Chubby**:基于 Paxos 实现的分布式锁服务
+- **Apache ZooKeeper 3.8+**:基于 ZAB 协议(类 Multi-Paxos,写入通过 Leader 广播,支持 FIFO 顺序)
+- **etcd 3.5+**:基于 Raft 算法(强一致性共识,支持动态成员变更、轻量级事务 Txn)
+- **HashiCorp Consul**:基于 Raft 算法(服务发现与配置管理)
+
+这些系统在分布式协调、配置管理、服务发现等领域发挥着关键作用。
+
+> **版本说明**:上述系统随版本演进会有协议优化(如 etcd 3.4 引入租约 Keep-Alive 优化、ZooKeeper 3.5 引入动态重配置),生产部署前建议查阅对应版本的 Release Notes。
+
+## 生产落地建议
+
+### 可观测性指标(Observability Checklist)
+
+| 类别 | 关键指标 | 告警阈值建议 | 说明 |
+| -------- | ------------------ | ----------------- | ---------------------------- |
+| **延迟** | 提案提交延迟 (p99) | > 100ms | 从客户端请求到收到多数派确认 |
+| **吞吐** | 提案处理速率 | < 预期 QPS 的 50% | 可能网络分区或节点故障 |
+| **选主** | Leader 切换次数 | > 3 次/小时 | 频繁切主说明集群不稳定 |
+| **空洞** | 未提交日志位置数 | > 100 | 过多空洞影响状态机回放 |
+| **脑裂** | 多 Leader 竞争事件 | = 0 | 绝不允许出现 |
+
+### 混沌工程建议
+
+| 测试场景 | 验证目标 | 推荐工具 |
+| --------------- | ------------------------------ | ------------------------ |
+| **Leader 崩溃** | 验证快速选主与数据零丢失 | Chaos Mesh, Chaos Monkey |
+| **网络分区** | 验证多数派继续服务、少数派等待 | Toxiproxy |
+| **网络抖动** | 验证随机退避机制避免活锁 | tc (netem) |
+| **时钟漂移** | 验证提案编号唯一性不受影响 | -- |
-Basic Paxos 算法的仅能就单个值达成共识,为了能够对一系列的值达成共识,我们需要用到 Multi Paxos 思想。
+### 常见反模式(Anti-Patterns)
-⚠️**注意**:Multi-Paxos 只是一种思想,这种思想的核心就是通过多个 Basic Paxos 实例就一系列值达成共识。也就是说,Basic Paxos 是 Multi-Paxos 思想的核心,Multi-Paxos 就是多执行几次 Basic Paxos。
+1. **忽略空洞处理**:状态机回放时遇到空洞位置直接跳过,可能导致客户端请求丢失
+2. **固定提案编号**:使用时间戳或节点 ID 作为提案编号,无法保证全局递增
+3. **无超时机制**:Prepare/Accept 请求无限等待,导致系统挂起
+4. **忽略已接受值**:新 Leader 强制提交自己的值,破坏一致性
-由于兰伯特提到的 Multi-Paxos 思想缺少代码实现的必要细节(比如怎么选举领导者),所以在理解和实现上比较困难。
+## 总结
-不过,也不需要担心,我们并不需要自己实现基于 Multi-Paxos 思想的共识算法,业界已经有了比较出名的实现。像 Raft 算法就是 Multi-Paxos 的一个变种,其简化了 Multi-Paxos 的思想,变得更容易被理解以及工程实现,实际项目中可以优先考虑 Raft 算法。
+- Paxos 算法是 Lamport 在 1990 年提出的分布式共识算法,是强一致性共识的理论基础
+- Basic Paxos 通过两阶段(Prepare/Promise、Accept/Accepted)就单个值达成共识
+- Multi-Paxos 通过复用 Leader 和跳过 Phase 1 优化,实现一系列值的共识(提案延迟从 2-RTT 降至 1-RTT)
+- Raft 算法借鉴了 Multi-Paxos 思想但重新设计了实现细节(强 Leader 模型、禁止日志空洞),更易于理解和工程实现
+- 在实际项目中,建议优先选择 Raft、etcd、ZooKeeper 等已完善的实现
## 参考
+- [《Paxos Made Simple》](http://lamport.azurewebsites.net/pubs/paxos-simple.pdf) - Lamport, 2001
+- [《The Part-Time Parliament》](http://lamport.azurewebsites.net/pubs/lamport-paxos.pdf) - Lamport, 1998
+- [《In Search of an Understandable Consensus Algorithm》](https://raft.github.io/raft.pdf) - Ongaro & Ousterhout, 2014 (Raft 论文)
-
- 分布式系统中的一致性与共识算法:
diff --git a/docs/distributed-system/protocol/raft-algorithm.md b/docs/distributed-system/protocol/raft-algorithm.md
index 18d2c2eb0cb..1e86ca1c182 100644
--- a/docs/distributed-system/protocol/raft-algorithm.md
+++ b/docs/distributed-system/protocol/raft-algorithm.md
@@ -1,5 +1,6 @@
---
title: Raft 算法详解
+description: Raft共识算法原理详解,涵盖Leader选举、日志复制、安全性保证等核心机制及与Paxos的对比分析。
category: 分布式
tag:
- 分布式协议&算法
@@ -10,84 +11,87 @@ tag:
## 1 背景
-当今的数据中心和应用程序在高度动态的环境中运行,为了应对高度动态的环境,它们通过额外的服务器进行横向扩展,并且根据需求进行扩展和收缩。同时,服务器和网络故障也很常见。
+在如今的互联网架构中,为了扛住海量流量,系统往往需要横向堆机器。机器一多,宕机、断网这些破事就成了家常便饭。怎么让这群随时可能掉线的服务器保持步调一致,不对外提供错乱的数据?这就轮到**分布式共识算法**出场了。
-因此,系统必须在正常操作期间处理服务器的上下线。它们必须对变故做出反应并在几秒钟内自动适应;对客户来说的话,明显的中断通常是不可接受的。
-
-幸运的是,分布式共识可以帮助应对这些挑战。
-
-### 1.1 拜占庭将军
+2014年,Diego Ongaro 等人发表了 Raft 算法。它的诞生有一个很明确的使命:**拯救被 Paxos 算法折磨的程序员**。Raft 主打一个“易于理解”,它将复杂的共识问题拆解成了几个独立的模块:
-在介绍共识算法之前,先介绍一个简化版拜占庭将军的例子来帮助理解共识算法。
+- **Leader 选举**:使用随机化选举超时(工程上常见如 150–300ms 或更大范围,具体取决于网络与故障模型)。
+- **日志复制**:Leader 通过 AppendEntries RPC 广播日志。
+- **安全性**:包括选举限制和日志匹配。
-> 假设多位拜占庭将军中没有叛军,信使的信息可靠但有可能被暗杀的情况下,将军们如何达成是否要进攻的一致性决定?
+Raft 在实际生产中得到了广泛应用,基于 Raft 的实现如 etcd、Consul 等已成为分布式系统的重要组成部分。后续学术界和工业界也对 Raft 进行了多项扩展和优化,包括:
-解决方案大致可以理解成:先在所有的将军中选出一个大将军,用来做出所有的决定。
+- **Pre-Vote**(2014):防止网络分区的节点干扰稳定集群的选举
+- **Read Index**(2014):在 Leader 任期内通过线性一致性读优化读性能
+- **Lease Read**:基于租约的线性一致性读方案
+- **Joint Consensus**:用于集群成员变更的联合一致机制(通过引入过渡配置,典型过程为旧配置 → 联合配置 → 新配置)
-举例如下:假如现在一共有 3 个将军 A,B 和 C,每个将军都有一个随机时间的倒计时器,倒计时一结束,这个将军就把自己当成大将军候选人,然后派信使传递选举投票的信息给将军 B 和 C,如果将军 B 和 C 还没有把自己当作候选人(自己的倒计时还没有结束),并且没有把选举票投给其他人,它们就会把票投给将军 A,信使回到将军 A 时,将军 A 知道自己收到了足够的票数,成为大将军。在有了大将军之后,是否需要进攻就由大将军 A 决定,然后再去派信使通知另外两个将军,自己已经成为了大将军。如果一段时间还没收到将军 B 和 C 的回复(信使可能会被暗杀),那就再重派一个信使,直到收到回复。
-
-### 1.2 共识算法
+因此,系统必须在正常操作期间处理服务器的上下线。它们必须对变故做出反应并在几秒钟内自动适应;对客户来说的话,明显的中断通常是不可接受的。
-共识是可容错系统中的一个基本问题:即使面对故障,服务器也可以在共享状态上达成一致。
+幸运的是,分布式共识可以帮助应对这些挑战。
-共识算法允许一组节点像一个整体一样一起工作,即使其中的一些节点出现故障也能够继续工作下去,其正确性主要是源于复制状态机的性质:一组`Server`的状态机计算相同状态的副本,即使有一部分的`Server`宕机了它们仍然能够继续运行。
+### 1.1 非拜占庭条件下的"选主"类比
-
+Raft 有一个前提假设:**非拜占庭容错(CFT)**。说白了就是,兄弟们可能会死机、会断网,但绝对不会出内鬼传递假情报。
-`图-1 复制状态机架构`
+我们可以用“将军选帅”来粗略理解这个过程: 假设有 A、B、C 三个将军,目前群龙无首。每个人心里都有个随机的倒计时(选举超时)。谁的倒计时先结束,谁就站出来大喊:“我要当大将军,请给我投票!” 如果其他将军还没开始竞选,也没把票投给别人,就会顺水推舟同意他。当这位将军拿到**过半数**的赞成票,他就成了大当家(Leader)。以后打不打仗,全听他的。如果信使半路阵亡,大家都没收到回音,那就重置倒计时,再来一轮。
-一般通过使用复制日志来实现复制状态机。每个`Server`存储着一份包括命令序列的日志文件,状态机会按顺序执行这些命令。因为每个日志包含相同的命令,并且顺序也相同,所以每个状态机处理相同的命令序列。由于状态机是确定性的,所以处理相同的状态,得到相同的输出。
+### 1.2 到底什么是共识算法?
-因此共识算法的工作就是保持复制日志的一致性。服务器上的共识模块从客户端接收命令并将它们添加到日志中。它与其他服务器上的共识模块通信,以确保即使某些服务器发生故障。每个日志最终包含相同顺序的请求。一旦命令被正确地复制,它们就被称为已提交。每个服务器的状态机按照日志顺序处理已提交的命令,并将输出返回给客户端,因此,这些服务器形成了一个单一的、高度可靠的状态机。
+共识算法的核心目标,就是**让一群机器看起来像一台机器**。只要集群里超过半数的机器还活着,整个系统就能正常接客。
-适用于实际系统的共识算法通常具有以下特性:
+这通常是通过**复制状态机**来实现的:给每个节点发一本一模一样的账本(日志)。只要大家按照同样的顺序去执行账本上的命令,最后得到的结果自然完全一样。所以,共识算法本质上干的就是一件事——**保证所有节点的账本绝对一致**。共识是可容错系统中的一个基本问题:即使面对故障,服务器也可以在共享状态上达成一致。
-- 安全。确保在非拜占庭条件(也就是上文中提到的简易版拜占庭)下的安全性,包括网络延迟、分区、包丢失、复制和重新排序。
-- 高可用。只要大多数服务器都是可操作的,并且可以相互通信,也可以与客户端进行通信,那么这些服务器就可以看作完全功能可用的。因此,一个典型的由五台服务器组成的集群可以容忍任何两台服务器端故障。假设服务器因停止而发生故障;它们稍后可能会从稳定存储上的状态中恢复并重新加入集群。
-- 一致性不依赖时序。错误的时钟和极端的消息延迟,在最坏的情况下也只会造成可用性问题,而不会产生一致性问题。
+
-- 在集群中大多数服务器响应,命令就可以完成,不会被少数运行缓慢的服务器来影响整体系统性能。
+## 2 基础概念
-## 2 基础
+在深入 Raft 之前,我们得先认识里面的三大核心角色、任期机制和日志结构。
### 2.1 节点类型
一个 Raft 集群包括若干服务器,以典型的 5 服务器集群举例。在任意的时间,每个服务器一定会处于以下三个状态中的一个:
-- `Leader`:负责发起心跳,响应客户端,创建日志,同步日志。
-- `Candidate`:Leader 选举过程中的临时角色,由 Follower 转化而来,发起投票参与竞选。
-- `Follower`:接受 Leader 的心跳和日志同步数据,投票给 Candidate。
+- **Leader(领导者)**:大当家。全权负责接待客户端、写账本、并把账本同步给小弟。为了防止别人篡位,他必须不断地向全员发送心跳,宣告“我还活着”。
+- **Follower(跟随者)**:安分守己的小弟。平时绝对不主动发起请求,只被动接收老大的心跳和账本同步。
+- **Candidate(候选人)**:临时状态。如果小弟迟迟等不到老大的心跳,就会觉得自己行了,变身候选人开始拉票。
在正常的情况下,只有一个服务器是 Leader,剩下的服务器是 Follower。Follower 是被动的,它们不会发送任何请求,只是响应来自 Leader 和 Candidate 的请求。
-
-
-`图-2:服务器的状态`
+
### 2.2 任期
-
-
-`图-3:任期`
+
-如图 3 所示,raft 算法将时间划分为任意长度的任期(term),任期用连续的数字表示,看作当前 term 号。每一个任期的开始都是一次选举,在选举开始时,一个或多个 Candidate 会尝试成为 Leader。如果一个 Candidate 赢得了选举,它就会在该任期内担任 Leader。如果没有选出 Leader,将会开启另一个任期,并立刻开始下一次选举。raft 算法保证在给定的一个任期最少要有一个 Leader。
+Raft 算法将时间划分为任意长度的任期(term),任期用连续的数字表示,看作当前 term 号。每一个任期的开始都是一次选举,在选举开始时,一个或多个 Candidate 会尝试成为 Leader。如果一个 Candidate 赢得了选举,它就会在该任期内担任 Leader。如果没有选出 Leader(例如出现分票 split vote),该任期可能没有 Leader;随后在新的选举超时后会进入下一个任期并重新发起选举。只要多数节点可用且网络最终可达,系统通常能够在若干轮选举后选出 Leader。
每个节点都会存储当前的 term 号,当服务器之间进行通信时会交换当前的 term 号;如果有服务器发现自己的 term 号比其他人小,那么他会更新到较大的 term 值。如果一个 Candidate 或者 Leader 发现自己的 term 过期了,他会立即退回成 Follower。如果一台服务器收到的请求的 term 号是过期的,那么它会拒绝此次请求。
+下面这张图是我手绘的,更容易理解一些,就很贴心:
+
+
+
### 2.3 日志
-- `entry`:每一个事件成为 entry,只有 Leader 可以创建 entry。entry 的内容为``其中 cmd 是可以应用到状态机的操作。
-- `log`:由 entry 构成的数组,每一个 entry 都有一个表明自己在 log 中的 index。只有 Leader 才可以改变其他节点的 log。entry 总是先被 Leader 添加到自己的 log 数组中,然后再发起共识请求,获得同意后才会被 Leader 提交给状态机。Follower 只能从 Leader 获取新日志和当前的 commitIndex,然后把对应的 entry 应用到自己的状态机中。
+只有 Leader 有资格往账本里追加记录(Entry)。一条日志包含三个核心要素:`<当前任期, 索引号, 具体操作指令>`。
+
+这里有两个非常关键的进度指针:
+
+- **commitIndex**:大家公认已经安全落地的日志进度(已经被复制到过半数节点)。
+- **lastApplied**:这台机器本地真正执行完的日志进度。
## 3 领导人选举
-raft 使用心跳机制来触发 Leader 的选举。
+
+
+Raft 使用心跳机制来触发 Leader 的选举。
-如果一台服务器能够收到来自 Leader 或者 Candidate 的有效信息,那么它会一直保持为 Follower 状态,并且刷新自己的 electionElapsed,重新计时。
+如果一台服务器持续收到来自 Leader 的 AppendEntries(心跳或日志复制)等合法 RPC,它会保持为 Follower 状态并刷新选举计时器。
Leader 会向所有的 Follower 周期性发送心跳来保证自己的 Leader 地位。如果一个 Follower 在一个周期内没有收到心跳信息,就叫做选举超时,然后它就会认为此时没有可用的 Leader,并且开始进行一次选举以选出一个新的 Leader。
-为了开始新的选举,Follower 会自增自己的 term 号并且转换状态为 Candidate。然后他会向所有节点发起 RequestVoteRPC 请求, Candidate 的状态会持续到以下情况发生:
+为了开始新的选举,Follower 会自增自己的 term 号并且转换状态为 Candidate。然后他会向所有节点发起 RequestVote RPC 请求, Candidate 的状态会持续到以下情况发生:
- 赢得选举
- 其他节点赢得选举
@@ -102,7 +106,7 @@ Leader 会向所有的 Follower 周期性发送心跳来保证自己的 Leader
由于可能同一时刻出现多个 Candidate,导致没有 Candidate 获得大多数选票,如果没有其他手段来重新分配选票的话,那么可能会无限重复下去。
-raft 使用了随机的选举超时时间来避免上述情况。每一个 Candidate 在发起选举后,都会随机化一个新的选举超时时间,这种机制使得各个服务器能够分散开来,在大多数情况下只有一个服务器会率先超时;它会在其他服务器超时之前赢得选举。
+Raft 使用了随机的选举超时时间来避免上述情况。每一个 Candidate 在发起选举后,都会随机化一个新的选举超时时间,这种机制使得各个服务器能够分散开来,在大多数情况下只有一个服务器会率先超时;它会在其他服务器超时之前赢得选举。
## 4 日志复制
@@ -112,20 +116,74 @@ Leader 收到客户端请求后,会生成一个 entry,包含` 自己的 Term 5,**被迫退位** | E 的"高 Term"破坏了健康集群 |
+
+**问题分析**:
+
+- {A, B, C, D} 是**合法的多数派**(4/5),系统本应继续正常工作
+- 节点 E 是**少数派**(1/5),它的隔离不应影响集群整体
+- **关键问题**:E 的 Term 暴涨导致健康的 Leader A 被迫下线
+- **后果**:整个集群需要重新选举,造成不必要的写入中断
+
+这是标准 Raft 的一个已知边界问题:少数派节点的"疯狂选举"会干扰多数派的正常运行。
+
+#### Pre-Vote 机制
+
+为了解决上述问题,Raft 的扩展方案 **Pre-Vote** 被提出。Pre-Vote 要求节点在真正发起选举前,先进行一次"预投票":
+
+1. **预投票阶段**:Candidate 向其他节点发送 PreVoteRequest,携带自己的日志信息
+2. **预投票条件**:
+ - 候选人的日志至少与接收者一样新(选举限制)
+ - **接收者确认自己与 Leader 的连接已断开**(超过 electionTimeout 未收到心跳)
+3. **正式选举**:只有收到多数节点的 PreVote 响应后,才真正增加 term 并发起 RequestVote
+
+**Pre-Vote 如何防止 Term 暴增**:
+
+- 在上述单节点隔离场景中,E 在隔离期间发起 Pre-Vote 时,**其他节点仍能收到 Leader A 的心跳**
+- 因此其他节点会**拒绝 E 的 PreVote 请求**(因为与 Leader 连接正常)
+- E 无法获得多数 PreVote 响应,**不会真正增加 Term**
+- 网络恢复后,E 的 Term 仍然较低,不会干扰健康的 Leader A
-如果 Leader 崩溃,集群中的节点在 electionTimeout 时间内没有收到 Leader 的心跳信息就会触发新一轮的选主,在选主期间整个集群对外是不可用的。
+**核心思想**:只有确认自己与 Leader 失去连接后,节点才开始真正增加 Term。这有效防止了少数派节点的 Term 暴涨干扰多数派。
-如果 Follower 和 Candidate 崩溃,处理方式会简单很多。之后发送给它的 RequestVoteRPC 和 AppendEntriesRPC 会失败。由于 raft 的所有请求都是幂等的,所以失败的话会无限的重试。如果崩溃恢复后,就可以收到新的请求,然后选择追加或者拒绝 entry。
+Pre-Vote 机制已广泛应用于 etcd、TiKV、Consul 等生产级 Raft 实现。
-### 5.3 时间与可用性
+### 5.4 时间与可用性
-raft 的要求之一就是安全性不依赖于时间:系统不能仅仅因为一些事件发生的比预想的快一些或者慢一些就产生错误。为了保证上述要求,最好能满足以下的时间条件:
+Raft 的要求之一就是安全性不依赖于时间:系统不能仅仅因为一些事件发生的比预想的快一些或者慢一些就产生错误。为了保证上述要求,最好能满足以下的时间条件:
`broadcastTime << electionTimeout << MTBF`
@@ -159,7 +274,7 @@ raft 的要求之一就是安全性不依赖于时间:系统不能仅仅因为
由于`broadcastTime`和`MTBF`是由系统决定的属性,因此需要决定`electionTimeout`的时间。
-一般来说,broadcastTime 一般为 `0.5~20ms`,electionTimeout 可以设置为 `10~500ms`,MTBF 一般为一两个月。
+一般来说,broadcastTime 一般为 `0.5~20ms`,electionTimeout 可以设置为 `10~500ms`(工程上常见如 150–300ms),MTBF 一般为一两个月。
## 6 参考
diff --git a/docs/distributed-system/protocol/zab.md b/docs/distributed-system/protocol/zab.md
new file mode 100644
index 00000000000..7fcf708ea50
--- /dev/null
+++ b/docs/distributed-system/protocol/zab.md
@@ -0,0 +1,108 @@
+---
+title: ZAB 协议详解
+description: ZooKeeper 的核心共识协议 ZAB(原子广播协议)详解,包括消息广播模式、崩溃恢复模式、Leader 选举和数据恢复机制
+category: 分布式系统
+tag: 分布式理论
+head:
+ - - meta
+ - name: keywords
+ content: ZAB协议,ZooKeeper,原子广播,分布式一致性,Leader选举,崩溃恢复
+---
+
+作为一款极其优秀的分布式协调框架,ZooKeeper 的高可用和数据一致性备受业界推崇。很多人误以为 ZooKeeper 使用的是大名鼎鼎的 Paxos 算法,但实际上,它的"灵魂"是一个专门为其定制的共识协议——**ZAB(ZooKeeper Atomic Broadcast,原子广播协议)**。
+
+ZAB 并非像 Paxos 那样是通用的分布式一致性算法,它是一种**特别为 ZooKeeper 设计的、支持崩溃可恢复的原子消息广播算法**。基于 ZAB 协议,ZooKeeper 实现了一种主备模式的架构,来保持集群中各个副本之间的数据一致性。
+
+## ZAB 集群的核心角色与状态
+
+在深入协议运作之前,我们需要先了解 ZooKeeper 集群中的三个主要角色:
+
+- **Leader(领导者):** 集群中**唯一**的写请求处理者。它负责发起投票和协调事务,所有的写操作都必须经过 Leader。
+- **Follower(跟随者):** 可以直接处理客户端的读请求。收到写请求时,会将其转发给 Leader。在 Leader 选举过程中,Follower 拥有选举权和被选举权。
+- **Observer(观察者):** 功能与 Follower 类似,但**没有**选举权和被选举权。它的存在是为了在不影响集群共识性能(即不增加需要等待的投票数)的前提下,横向扩展集群的读性能。
+
+对应的,集群中的节点通常处于以下四种状态之一:
+
+- `LOOKING`:寻找 Leader 状态(正在进行选举)。
+- `LEADING`:当前节点是 Leader,正在领导集群。
+- `FOLLOWING`:当前节点是 Follower,服从 Leader 领导。
+- `OBSERVING`:当前节点是 Observer。
+
+## 核心标识:ZXID 与 Epoch
+
+为了保证分布式环境下消息的绝对顺序性,ZAB 协议引入了一个全局单调递增的事务 ID——**ZXID**。
+
+ZXID 是一个 64 位的长整型(long):
+
+- **高 32 位(Epoch 纪元):** 代表当前 Leader 的任期年代。当选出一个新的 Leader 时,Epoch 就会在前一个的基础上加 1。这相当于朝代更替。
+- **低 32 位(事务 ID):** 一个简单的递增计数器。针对客户端的每一个写请求,计数器都会加 1。新 Leader 上位时,这个低 32 位会被清零重置。
+
+
+
+## ZAB 的两种基本模式
+
+ZAB 协议的运作可以精简为两种基本模式的交替:**消息广播**(正常工作状态)和**崩溃恢复**(异常或启动状态)。
+
+### 1. 消息广播模式(正常处理写请求)
+
+
+
+当集群拥有健康的 Leader,且过半的节点完成了状态同步后,就会进入消息广播模式。这个过程类似于一个简化的“两阶段提交(2PC)”:
+
+1. **生成提案:** Leader 接收到写请求后,将其转化为一个带有 ZXID 的提案(Proposal)。
+2. **顺序发送:** Leader 为每个 Follower 维护了一个先进先出(FIFO)的网络队列(基于 TCP 协议),确保提案按生成顺序发送给 Follower。
+3. **写入与反馈(WAL 强制落盘):** Follower 收到提案后,必须将其追加到本地的事务日志(TxnLog)中,并强制执行系统调用 `fsync` 将内核缓冲区的数据物理刷入磁盘。只有确认数据切实落盘,才会向 Leader 响应 `ACK`。这一过程是 ZAB 抵御断电丢失数据的核心防线。因此,在物理部署上,强烈建议将 ZooKeeper 的事务日志目录(`dataLogDir`)挂载到独立且无锁的 SSD 上,避免与其他高 I/O 进程争用磁盘,从而规避因 `fsync` 阻塞导致的 P99 响应时间恶化。生产环境中必须重点监控节点的 `fsynctime` 指标,若平均刷盘耗时经常超过 100ms,集群随时可能崩溃。
+4. **广播提交:** 当 Leader 收到**过半数** 节点的 `ACK` 响应后,就会认为该写操作成功。Leader 在本地写日志时会更新内部的 quorum 计数器(而非显式向自己发送 ACK),确认过半后向客户端返回成功响应,并向所有节点广播 `Commit` 消息。Follower 收到 `Commit` 后,正式将数据应用到内存中。
+
+### 2. 崩溃恢复模式(Leader 宕机或网络异常)
+
+当系统刚启动,或者 Leader 服务器崩溃、与过半 Follower 失去联系时,整个集群就会暂停对外服务,进入 `LOOKING` 状态,触发崩溃恢复模式。崩溃恢复主要包含两个阶段:**Leader 选举**和**数据恢复**。
+
+
+
+#### 阶段一:Leader 选举
+
+选举的核心原则是:**拥有最新数据的节点优先当选**。 每个节点都会先投自己一票,投票信息包含 `(Epoch, ZXID, myid)`。随后节点会交换选票,并按照以下顺序进行 PK:
+
+1. **比较 Epoch:** 纪元大的优先。
+2. **比较 ZXID:** 如果 Epoch 相同,ZXID 大的优先(代表数据越新)。
+3. **比较 myid:** 如果前两者都相同,服务器唯一标识 `myid` 大的优先。
+
+一旦某个节点获得了**过半数**的选票,它就会成为新的 Leader。_(这也是为什么 ZooKeeper 推荐部署奇数台服务器的原因,能以最低的成本实现半数以上的容错。)_
+
+#### 阶段二:数据恢复
+
+选出新 Leader 只是第一步,为了保证数据一致性,ZAB 必须在数据同步阶段实现两个极其重要的保证:
+
+1. **确保已经在旧 Leader 上提交的事务,最终被所有节点提交。** (防止数据丢失)
+2. **丢弃那些只在旧 Leader 上提出,但还没来得及提交的事务。** (防止脏数据干扰)
+
+新 Leader 会找到当前最大的 `Epoch` 并加 1 作为新纪元,随后与所有 Follower 进行比对。Follower 会发送自己事务日志中最新记录的 `lastZxid`(包含已提议但尚未提交的提案),Leader 根据这个值采取多态同步策略:**差异化增量同步(DIFF)**、**强制丢弃未提交日志(TRUNC)** 或 **全量快照传输(SNAP)**。
+
+这一设计至关重要:Leader 需要准确识别 Follower 日志中是否残留着旧 Leader 未完成提交的"幽灵提案",才能正确下发 TRUNC 指令让其截断回滚。如果只上报已提交的 ZXID,这些未提交的脏数据将无法被感知,TRUNC 分支就永远不会被触发。
+
+更关键的是,此时新的 Epoch 已经生效。若原 Leader 因 JVM 触发长达数十秒的 Full GC 而发生"假死",当其苏醒并试图向集群下发旧 Epoch 的提案时,由于过半节点已记录了更高的新 Epoch 且已向新 Leader 提交 quorum,这些幽灵提案将被节点无情拒绝并抛弃。ZAB 正是通过 **Epoch 机制 + 多数派 quorum** 的组合,从根本上免疫了网络环境下的脑裂现象——单靠 Epoch 拒绝还不够,必须有过半节点已经连上新 Leader,旧 Leader 才真正失去写入能力。
+
+当过半的机器与新 Leader 完成了状态和数据同步,ZAB 协议就会平滑退出崩溃恢复模式,重新进入消息广播模式。
+
+## 与 Raft 对比
+
+**ZAB 与 Raft 的高度相似性:** 如果你了解过 Raft 算法,会发现它们非常相似。它们都有唯一的主节点,都使用 Epoch/Term 来标识任期,并且都采用了只要半数以上节点确认即可提交的策略。这说明在现代分布式共识领域,这种基于主备和多数派选举的架构已经成为了事实上的标准。
+
+在当前的分布式系统实践中,Raft 算法通常被视为比 ZAB 更实用和受欢迎的选择。 这是因为 Raft 从设计之初就强调易懂性和可实现性,它将领导者选举、日志复制和安全性明确分离,这使得开发者更容易正确实施和调试,而 ZAB 作为 ZooKeeper 的专有协议,更侧重于原子广播的特定需求,导致其通用性较差。
+
+Raft 已广泛应用于现代系统,如 Kubernetes 的 etcd、Hashicorp Consul、Apache Kafka(在其 KIP-500 版本中去除 ZooKeeper 依赖,转向 Raft-based KRaft)、TiKV 等,这极大“民主化”了分布式共识的开发。
+
+相比之下,ZAB 主要绑定在 ZooKeeper 上,虽然 ZooKeeper 仍是经典的协调服务,但许多新项目倾向于选择 Raft 以避免 ZooKeeper 的额外复杂性和潜在瓶颈(如在大规模下共识开销)。
+
+此外,Raft 的社区支持更活跃,衍生出多种优化变体(如用于区块链的改进版本),使其在效率和适用场景上更具优势。 然而,如果你的系统已深度集成 ZooKeeper,ZAB 仍是最优化的选择;否则,对于新设计或通用共识需求,Raft 是当前更实用的标准。
+
+## 总结
+
+ZAB 协议通过精心设计的 Leader 选举和多数派确认机制,在分布式系统的分区容错性(P)和一致性(C)之间做出了选择(满足 CP 属性)。当出现网络分区时,ZAB 宁愿牺牲短暂的可用性(A)进行选举,也要保证数据的一致性。
+
+需要特别强调的是,**ZAB 协议默认不保证严格的强一致性(线性一致性),而是提供顺序一致性(Sequential Consistency)**。
+
+由于 Follower 可以直接处理客户端的读请求且不强求数据绝对同步,客户端完全可能读取到落后于 Leader 的陈旧数据(Stale Read)。在生产环境中,若业务涉及如分布式锁等对数据新鲜度要求极高的场景,必须在执行 `read()` 操作前显式调用 `sync()` 原语,强制要求连接的 Follower 追平 Leader 的事务状态机。
+
+当发生网络分区时,客户端若连接至被隔离的少数派 Follower,虽然写操作会失败,但仍可读出过期数据,这是使用 ZAB 协议时必须考虑的边界场景。
diff --git a/docs/distributed-system/rpc/dubbo.md b/docs/distributed-system/rpc/dubbo.md
index 3eaee38b50c..02cc37a8c0c 100644
--- a/docs/distributed-system/rpc/dubbo.md
+++ b/docs/distributed-system/rpc/dubbo.md
@@ -1,5 +1,6 @@
---
title: Dubbo常见问题总结
+description: Dubbo核心知识与面试题详解,涵盖Dubbo架构原理、SPI机制、负载均衡策略及服务治理等核心内容。
category: 分布式
tag:
- rpc
diff --git a/docs/distributed-system/rpc/http&rpc.md b/docs/distributed-system/rpc/http&rpc.md
index 35301d0bceb..e3ac8ad5b7f 100644
--- a/docs/distributed-system/rpc/http&rpc.md
+++ b/docs/distributed-system/rpc/http&rpc.md
@@ -1,5 +1,6 @@
---
title: 有了 HTTP 协议,为什么还要有 RPC ?
+description: HTTP与RPC对比详解,讲解两种通信方式的本质区别、性能差异及在微服务架构中的选型建议。
category: 分布式
tag:
- rpc
@@ -177,7 +178,7 @@ res = remoteFunc(req)

-当然上面说的 HTTP,其实 **特指的是现在主流使用的 HTTP1.1**,`HTTP2`在前者的基础上做了很多改进,所以 **性能可能比很多 RPC 协议还要好**,甚至连`gRPC`底层都直接用的`HTTP2`。
+当然上面说的 HTTP,其实 **特指的是现在主流使用的 HTTP1.1**,`HTTP2`在前者的基础上做了很多改进,所以 **性能可能比很多 RPC 协议还要好**。而 gRPC 正是基于 HTTP/2 实现的(虽然它基于 HTTP/2 的帧格式定义了自己的协议,但传输层仍是 HTTP/2)。
那么问题又来了。
diff --git a/docs/distributed-system/rpc/rpc-intro.md b/docs/distributed-system/rpc/rpc-intro.md
index d265761e63e..1c2de76ef6a 100644
--- a/docs/distributed-system/rpc/rpc-intro.md
+++ b/docs/distributed-system/rpc/rpc-intro.md
@@ -1,5 +1,6 @@
---
title: RPC基础知识总结
+description: RPC远程过程调用基础详解,讲解RPC核心原理、调用流程、序列化协议及常见RPC框架对比分析。
category: 分布式
tag:
- rpc
@@ -25,7 +26,7 @@ tag:
1. **客户端(服务消费端)**:调用远程方法的一端。
1. **客户端 Stub(桩)**:这其实就是一代理类。代理类主要做的事情很简单,就是把你调用方法、类、方法参数等信息传递到服务端。
-1. **网络传输**:网络传输就是你要把你调用的方法的信息比如说参数啊这些东西传输到服务端,然后服务端执行完之后再把返回结果通过网络传输给你传输回来。网络传输的实现方式有很多种比如最近基本的 Socket 或者性能以及封装更加优秀的 Netty(推荐)。
+1. **网络传输**:网络传输就是你要把你调用的方法的信息比如说参数啊这些东西传输到服务端,然后服务端执行完之后再把返回结果通过网络传输给你传输回来。网络传输的实现方式有很多种比如最基本的 Socket 或者性能以及封装更加优秀的 Netty(推荐)。
1. **服务端 Stub(桩)**:这个桩就不是代理类了。我觉得理解为桩实际不太好,大家注意一下就好。这里的服务端 Stub 实际指的就是接收到客户端执行方法的请求后,去执行对应的方法然后返回结果给客户端的类。
1. **服务端(服务提供端)**:提供远程方法的一端。
diff --git a/docs/distributed-system/spring-cloud-gateway-questions.md b/docs/distributed-system/spring-cloud-gateway-questions.md
index 1e6e86845af..75c4ba50812 100644
--- a/docs/distributed-system/spring-cloud-gateway-questions.md
+++ b/docs/distributed-system/spring-cloud-gateway-questions.md
@@ -1,5 +1,6 @@
---
title: Spring Cloud Gateway常见问题总结
+description: Spring Cloud Gateway核心原理详解,包括路由配置、过滤器机制、限流熔断等常见面试题与实践要点。
category: 分布式
---
@@ -7,7 +8,7 @@ category: 分布式
## 什么是 Spring Cloud Gateway?
-Spring Cloud Gateway 属于 Spring Cloud 生态系统中的网关,其诞生的目标是为了替代老牌网关 **Zuul**。准确点来说,应该是 Zuul 1.x。Spring Cloud Gateway 起步要比 Zuul 2.x 更早。
+Spring Cloud Gateway 属于 Spring Cloud 生态系统中的网关,其诞生的目标主要是为了替代 **Zuul 1.x**。Zuul 1.x 基于 Servlet 阻塞 I/O 架构,在高并发场景下性能有限。而 Zuul 2.x 虽然采用了 Netty 非阻塞架构,但 Spring Cloud 官方并未正式集成 Zuul 2.x。Spring Cloud Gateway 起步要比 Zuul 2.x 更早。
为了提升网关的性能,Spring Cloud Gateway 基于 Spring WebFlux 。Spring WebFlux 使用 Reactor 库来实现响应式编程模型,底层基于 Netty 实现同步非阻塞的 I/O。
diff --git a/docs/high-availability/fallback-and-circuit-breaker.md b/docs/high-availability/fallback-and-circuit-breaker.md
index e9aa9188d4f..ecd724eac53 100644
--- a/docs/high-availability/fallback-and-circuit-breaker.md
+++ b/docs/high-availability/fallback-and-circuit-breaker.md
@@ -1,11 +1,229 @@
---
-title: 降级&熔断详解(付费)
+title: 降级&熔断详解
+description: 服务降级与熔断机制详解,讲解降级策略、熔断器原理及 Hystrix、Sentinel、Resilience4j 等框架的应用实践,涵盖雪崩效应、熔断状态机、隔离策略与系统自适应保护。
category: 高可用
icon: circuit
+head:
+ - - meta
+ - name: keywords
+ content: 服务降级,熔断器,熔断机制,Sentinel,Hystrix,Resilience4j,雪崩效应,熔断状态机,Fallback,限流降级熔断区别,微服务高可用,系统自适应保护,线程池隔离,信号量隔离
---
-**降级&熔断** 相关的面试题为我的[知识星球](https://javaguide.cn/about-the-author/zhishixingqiu-two-years.html)(点击链接即可查看详细介绍以及加入方法)专属内容,已经整理到了[《Java 面试指北》](https://javaguide.cn/zhuanlan/java-mian-shi-zhi-bei.html)中。
+## 什么是降级?
-
+服务降级(Service Degradation)是从系统功能优先级视角应对故障的策略:在负载(如 CPU 使用率 > 80%、线程池饱和、响应时间 P99 > 1s)接近阈值时,有策略地降低非核心服务质量,释放资源确保核心路径可用性。
+
+### 降级的特征
+
+| 维度 | 说明 | 示例 |
+| ------------ | ----------------------- | -------------------------------------------------------------------- |
+| **触发原因** | 整体负荷超出阈值 | CPU 使用率 > 80%、P99 RT > 1s、P999 RT > 3s、队列积压深度 > 容量 80% |
+| **目的** | 保核心、弃非核心 | 关闭推荐、保留下单 |
+| **粒度** | 服务/页面/接口/功能三级 | 关闭商品推荐模块 |
+| **可控性** | 配置中心动态开关 | Nacos 2.0+ gRPC 长连接(毫秒级推送) |
+| **优先级** | 1-10 级,从外围到核心 | L10:下单 > L5:评论 > L1:推荐 |
+
+### 降级方式有哪些?
+
+| 方式 | 说明 | 适用场景 | 失败路径与风险 |
+| ---------------- | ------------------------------------------------------ | ------------------ | ------------------------------------------------------------- |
+| **延迟服务** | 将非实时操作异步化,写入 MQ/缓存 | 评论积分、数据统计 | MQ 积压需背压(如 Jitter 重试避免风暴) |
+| **页面片段降级** | 直接关闭非核心功能区块 | 推荐区、广告位 | 无 |
+| **异步请求降级** | 页面内异步加载接口返回兜底数据 | 配送至、价格预测 | 兜底数据需预加载缓存 |
+| **页面跳转降级** | 将流量导流到静态/简版页面 | 静态活动页、维护页 | 需预设静态页版本 |
+| **写降级** | 优先写入 Redis/本地 WAL,通过可靠 MQ 或定时任务同步 DB | 秒杀库存扣减 | 需保证最终一致性(对账/补偿);内存队列在节点宕机时会丢失数据 |
+| **读降级** | 只读缓存,屏蔽后端调用 | 商品详情读多写少 | 缓存穿透时需返回降级页 |
+
+### 降级开关实现方案
+
+| 方案 | 实时性 | 一致性 | 复杂度 | 适用场景 |
+| ----------------------------------- | ---------------- | ----------------------- | ------ | ------------------ |
+| **配置文件 + 重启** | 低 | 强 | 低 | 非紧急、不频繁变更 |
+| **数据库开关表** | 中 | 中 | 中 | 需要审计日志的场景 |
+| **配置中心(Nacos 2.0+ / Apollo)** | 高(毫秒级推送) | 最终一致(gRPC 双向流) | 高 | 生产环境推荐 |
+| **Redis/Diamond** | 高 | 最终一致 | 中 | 轻量级方案 |
+
+> 注:Nacos 2.0+ 基于 **gRPC 持久长连接**(Persistent Connection)和**双向流**(Bidirectional Streaming)实现服务端主动推送,推送生效时间达毫秒级。与 1.x 的 HTTP 长轮询(Polling)相比,gRPC 模式避免了重复 TPS,利用 NIO 机制提升吞吐量,整体性能提升约 **10 倍**,内存占用降低 **50%**,单机可支撑 **10W+** 实例连接。
+>
+> **一致性机制**:Nacos 2.0+ 并非采用严格的 ACK 机制,而是依赖 **HTTP/2 PING 帧**(Keepalive)检测连接健康和快速感知断开,确保推送可靠。连接丢失时客户端自动重连并同步数据实现最终一致收敛。
+>
+> **网络分区场景**:Nacos 的注册中心(Naming)模块偏向 AP,但**配置中心(Config)模块基于 Raft 协议保证强一致性(CP)**。降级开关属于配置中心范畴,发生网络分区时,处于少数派(Minority)的 Nacos 节点将拒绝写入并可能导致客户端配置漂移。此时客户端需依赖本地缓存文件(Failover 配置)作为最终兜底,并忍受降级规则无法实时推送的风险。
+>
+> **升级兼容性**:Nacos 2.0 服务器兼容 1.x 客户端(通过 HTTP 协议),但 2.0 客户端不兼容 1.x 服务器(gRPC 协议)。
+>
+> **客户端线程管理注意**:gRPC 执行器核心线程数基于 CPU 核数配置(如 200 核心、800 最大),需注意避免资源耗尽。
+
+### 服务降级有哪些分类?
+
+降级按照是否自动化可分为:
+
+- **自动开关降级**(超时、失败次数、故障、限流)
+- **人工开关降级**(秒杀、电商大促等)
+
+自动降级分类:
+
+| 类型 | 触发阈值 | 兜底方案 | 失败路径要求 |
+| ------------ | -------------------------------------- | ------------------ | -------------------------- |
+| **超时降级** | RT > 阈值(如 P99 > 500ms)且持续 N 次 | 默认值 | 需幂等性保护,避免重试风暴 |
+| **失败降级** | 异常率 > 阈值(如 50%) | 兜底数据 | 兜底数据需预热缓存 |
+| **故障降级** | HTTP 5xx/RPC 异常/DNS 解析失败 | 缓存数据 | 缓存未命中时返回默认值 |
+| **限流降级** | QPS > 阈值 | 排队页/无货/错误页 | 排队页需防重入(幂等令牌) |
+
+> 重试风暴:当服务恢复但大量客户端同时重试时,可能导致服务再次崩溃。防御措施包括:Jitter 重试(随机退避)、令牌桶限流、分组分批恢复。
+
+## 大规模分布式系统如何降级?
+
+在大规模分布式系统中,经常会有成百上千的服务。在大促前往往会根据业务的重要程度和业务间的关系批量降级。
+
+### 降级平台能力
+
+大型互联网公司通常会有统一的降级平台,核心能力包括:
+
+| 能力 | 说明 | 实现要点 |
+| ------------ | ------------------- | -------------------------------------- |
+| **分级管理** | 1-10 级服务优先级 | 核心业务评审、依赖关系梳理 |
+| **批量降级** | 按级别/分组批量执行 | 降级顺序编排、原子性保证(二阶段提交) |
+| **动态开关** | 配置中心实时推送 | Nacos 2.0+ gRPC 或 WebSocket |
+| **效果验证** | 灰度验证 + 监控观测 | A/B 测试、指标对比 |
+| **一键回滚** | 版本管理 + 快速回滚 | 配置版本化、变更审计 |
+
+### 降级预案制定
+
+1. **业务分级**:梳理服务核心度,定义 L1-L10 优先级
+2. **依赖分析**:绘制服务调用链,识别关键路径和单点依赖
+3. **降级策略**:为每个非核心服务设计降级方案(含失败路径)
+4. **演练验证**:定期进行降级演练,确保预案有效性(含网络分区场景)
+
+> 网络分区场景:依据 PACELC 定理,分区时需权衡可用性(A)与一致性(C)。降级预案应明确分区期间的行为模式(如继续服务本地缓存、暂停跨区调用)。
+>
+> **详细介绍:** [CAP & BASE理论详解](https://javaguide.cn/distributed-system/protocol/cap-and-base-theorem.html)。
+
+## 什么是熔断?
+
+熔断器模式(Circuit Breaker Pattern)是应对微服务雪崩效应的一种链路保护机制,类似电路中的保险丝。
+
+### 雪崩效应
+
+正常调用链路:服务 A ──> 服务 B ──> 服务 C
+
+雪崩场景:
+
+- 服务 C 响应变慢/不可用
+- 对服务 C 的调用排队(线程池耗尽)
+- 服务 B 的调用线程阻塞
+- 服务 A 也被拖垮,雪崩扩散到整个系统
+
+### 熔断器状态机
+
+熔断器包含三种状态:
+
+| 状态 | 说明 | 行为 | 状态转换条件 |
+| -------------------- | ---------------------- | --------------------------------- | --------------------------------------------------------- |
+| **Closed(关闭)** | 正常状态,允许请求通过 | 记录失败率/慢调用比例 | 失败率/慢调用比例 > 阈值 → Open |
+| **Open(打开)** | 熔断触发,拒绝请求 | 快速返回 Fallback,不再调用下游 | 经过冷却时间(sleepWindow,如 10s) → HalfOpen |
+| **HalfOpen(半开)** | 探测服务是否恢复 | 释放配置数量(如 3 个)的探路请求 | 所有探测成功(或满足成功率阈值)→ Closed;任一失败 → Open |
+
+> Half-Open 风险与 Warm Up 预热:探测请求可能触发重试风暴或二次雪崩。建议限制探测请求数(如 Sentinel 默认 3 个),并要求所有探测成功(或满足配置的成功率阈值)才转为 Closed。若放行条件过于宽松(如单次成功即 Closed),面对刚从宕机中拉起的冷节点,瞬间涌入的并发流量会直接打满线程池,造成二次击穿(冷启动杀手)。
+>
+> **Warm Up 预热机制**:需配合基于令牌桶/漏桶算法的预热限流,按照冷却因子(默认 3)在预热周期内(如 10s)将放行 QPS 阈值从 `maxQps / 3` 平滑拉升至最大容量,防止冷节点由于 CPU Cache Miss 和数据库连接池未初始化被二次击穿。监控冷启动期间的 **P99 延迟** 和 **数据库连接池活跃连接数** 以验证预热效果。
+
+### 熔断策略
+
+Sentinel 1.8.2+ 支持三种熔断策略:
+
+| 策略 | 触发条件 | 典型阈值配置 | 版本要求 |
+| -------------- | ------------------------------------ | ---------------------- | -------- |
+| **慢调用比例** | P99 RT > 最大慢调用 RT 且比例 > 阈值 | RT > 500ms,比例 > 50% | 1.8.0+ |
+| **异常比例** | 异常比例 > 阈值 | 异常率 > 50% | 全版本 |
+| **异常数** | 异常数 > 阈值 | 1 分钟内异常 > 50 | 全版本 |
+
+> P99 vs 平均 RT:使用平均 RT 可能掩盖长尾延迟。生产环境建议监控 P99/P999,避免"大部分请求快但少数请求极慢"的场景。
+
+## 降级和熔断有什么区别?
+
+| 维度 | 降级 | 熔断 |
+| ------------ | -------------------- | ---------------------- |
+| **核心关注** | 资源优先级分配 | 调用链路保护 |
+| **触发方式** | 主动(系统/人工) | 被动(依赖异常触发) |
+| **作用范围** | 当前服务或下游 | 调用链的上游 |
+| **恢复方式** | 手动关闭或自动检测 | 自动(Half-Open 探测) |
+| **返回内容** | 兜底值/缓存/静态页面 | Fallback 方法 |
+
+**三者关系**:
+
+- 限流:保护自身不被打垮(限制进入流量)
+- 降级:自身主动牺牲非核心功能(降低服务质量)
+- 熔断:防止被下游拖垮(切断异常依赖)
+
+> 比喻:限流是"限流进入商场的客流",降级是"商场关闭部分楼层",熔断是"发现供应商出问题后停止与其合作"。
+
+## 有哪些现成解决方案?
+
+Spring Cloud 生态中常用的熔断降级组件:
+
+- **Hystrix 1.5.18**(2018 年停止维护)
+- **Sentinel 1.8.2+**(阿里开源,推荐)
+- **Resilience4j 1.7.1+**(轻量级)
+- **Spring Retry**(重试组件)
+
+### Hystrix vs Sentinel vs Resilience4j
+
+| 维度 | Sentinel 1.8.2+ | Hystrix 1.5.18 | Resilience4j 1.7.1+ |
+| ------------------ | ------------------------------- | -------------------------- | ------------------------------------------- |
+| **维护状态** | ✅ 活跃维护 | ❌ 2018 年停止维护 | ✅ 活跃维护 |
+| **隔离策略** | 并发线程数隔离(信号量) | 线程池隔离(默认)/ 信号量 | SemaphoreBulkhead / FixedThreadPoolBulkhead |
+| **熔断策略** | 慢调用比例/异常比例/异常数 | 异常比例 | 异常比例/异常数 |
+| **实时指标** | 滑动窗口 | 滑动窗口(RxJava) | 环形缓冲 |
+| **限流** | QPS/并发线程/调用关系 | 有限支持 | RateLimiter |
+| **流量整形** | 慢启动/匀速排队 | ❌ | ❌ |
+| **系统自适应保护** | ✅ Load/RT/线程数/QPS | ❌ | ❌ |
+| **控制台** | ✅ 开箱即用 | ⚠️ 简陋 | ⚠️ 需自行搭建 |
+| **框架适配** | Servlet/Spring Cloud/Dubbo/gRPC | Spring Cloud Netflix | Reactor/Vert.x |
+
+### 隔离策略对比
+
+| 策略 | Sentinel | Hystrix | Resilience4j | Trade-offs |
+| -------------- | --------------------- | --------- | -------------------------- | --------------------------------------------------------------------------------------------------------------------- |
+| **线程池隔离** | - | ✅ 默认 | ✅ FixedThreadPoolBulkhead | 优势:超时控制独立、资源隔离彻底、支持异步
劣势:OS 级别上下文切换开销(P99 恶化)、线程池大小难确定、增加 GC 压力 |
+| **信号量隔离** | ✅ 轻量级、无线程切换 | ✅ 轻量级 | ✅ SemaphoreBulkhead | 优势:无额外线程开销、内存占用小
劣势:不能做超时控制(依赖业务层)、不支持异步 |
+
+> **GC 与调度压力**:线程池隔离会创建大量独立线程。在高并发下,真正的瓶颈在于 CPU 在海量线程间进行 **OS 级别的调度唤醒与挂起**。这种频繁的**上下文切换** 会无谓消耗大量 CPU 的 Us/Sy 时间,并直接导致业务请求的 **P99 尾延迟急剧恶化**。锁争用仅是并发争用的表象,真正的杀手是线程调度开销。Resilience4j 的 `FixedThreadPoolBulkhead` 基于 `ArrayBlockingQueue`,极高并发下也存在锁争用,但相比上下文切换开销通常次要。
+
+### 系统自适应保护(Sentinel 独有)
+
+Sentinel 1.8+ 提供**系统自适应保护**(System Rule),其核心是引入类似 **TCP BBR** 的动态容量评估逻辑:
+
+**隐性核心条件**:`当前并发线程数 > (系统最大 QPS × 最小 RT)`
+
+| 指标 | 说明 | 典型阈值 | 版本要求 |
+| -------------------- | -------------------------- | --------------------- | --------------- |
+| **Load(系统负载)** | Linux `load1` 值 | > CPU 核数 × 2 | 全版本 |
+| **平均 RT** | 所有入口流量的平均响应时间 | > 500ms(建议用 P99) | 1.8.0+ 支持 P99 |
+| **并发线程数** | 当前并发线程数 | > 500 | 全版本 |
+| **入口 QPS** | 入口流量的 QPS | > 1000 | 全版本 |
+
+触发后,系统会自动拒绝部分请求,避免系统崩溃。相比静态阈值,BBR 风格的动态容量评估能防止静态阈值滞后导致的系统崩溃。
+
+### 选型建议与迁移 Trade-offs
+
+| 场景 | 推荐方案 | 迁移 Trade-offs |
+| ------------------------------ | -------------------------- | ------------------------------------------ |
+| 新项目(Spring Cloud Alibaba) | **Sentinel 1.8.2+** | 无迁移成本 |
+| 新项目(响应式/轻量级) | **Resilience4j 1.7.1+** | 需自行实现控制台 |
+| 存量项目(Hystrix) | 继续使用 Hystrix,规划迁移 | 迁移成本:API 变更 + 控制台搭建 + 规则迁移 |
+| 需要系统自适应保护 | **Sentinel**(独有) | 无替代方案 |
+
+## 推荐阅读
+
+- [Circuit Breaker Pattern - Martin Fowler](https://martinfowler.com/bliki/CircuitBreaker.html)
+- [Sentinel 官方文档](https://sentinelguard.io/zh-cn/docs/introduction.html)
+- [Release It! - Michael Nygard(生产级降级与熔断实践)](https://www.pragprog.com/titles/mnee2/release-it-second-edition/)
+- [PACELC: A Simple Perspective on Latency and Consistency](https://www.cs.berkeley.edu/~brewer/cs262/PACELC.pdf)
+
+## 参考
+
+- [Sentinel 与 Hystrix 的对比](https://github.com/alibaba/Sentinel/wiki/Sentinel-%E4%B8%8E-Hystrix-%E7%9A%84%E5%AF%B9%E6%AF%94)
+- [Spring Cloud Alibaba 官方文档](https://spring-cloud-alibaba-group.github.io/github-pages/2022/zh-cn/index.html)
+- [高并发之服务降级与熔断](https://suprisemf.github.io/2018/08/03/%E9%AB%98%E5%B9%B6%E5%8F%91%E4%B9%8B%E6%9C%8D%E5%8A%A1%E9%99%8D%E7%BA%A7%E4%B8%8E%E7%86%94%E6%96%AD/)
diff --git a/docs/high-availability/high-availability-system-design.md b/docs/high-availability/high-availability-system-design.md
index f461f93e99b..4f95cf5e32f 100644
--- a/docs/high-availability/high-availability-system-design.md
+++ b/docs/high-availability/high-availability-system-design.md
@@ -1,71 +1,202 @@
---
title: 高可用系统设计指南
+description: 本文系统讲解高可用系统设计的核心知识,涵盖可用性衡量标准(SLA/多少个9)、常见故障原因(硬件故障/代码缺陷/流量激增/网络攻击)、以及10+种提升系统可用性的方法(集群/限流/熔断/降级/缓存/异步/灰度发布等),助力高可用架构设计与面试。
category: 高可用
icon: design
+head:
+ - - meta
+ - name: keywords
+ content: 高可用,系统可用性,SLA,可用性指标,限流,熔断,降级,集群,灰度发布,高可用架构,系统稳定性
---
## 什么是高可用?可用性的判断标准是啥?
-高可用描述的是一个系统在大部分时间都是可用的,可以为我们提供服务的。高可用代表系统即使在发生硬件故障或者系统升级的时候,服务仍然是可用的。
+**高可用(High Availability,简称 HA)** 描述的是一个系统在大部分时间都是可用的,可以为我们提供服务的。高可用代表系统即使在发生硬件故障或者系统升级的时候,服务仍然是可用的。
-一般情况下,我们使用多少个 9 来评判一个系统的可用性,比如 99.9999% 就是代表该系统在所有的运行时间中只有 0.0001% 的时间是不可用的,这样的系统就是非常非常高可用的了!当然,也会有系统如果可用性不太好的话,可能连 9 都上不了。
+一般情况下,我们使用 **多少个 9** 来评判一个系统的可用性,比如 99.9999% 就是代表该系统在所有的运行时间中只有 0.0001% 的时间是不可用的,这样的系统就是非常非常高可用的了!当然,也会有系统如果可用性不太好的话,可能连 9 都上不了。
-除此之外,系统的可用性还可以用某功能的失败次数与总的请求次数之比来衡量,比如对网站请求 1000 次,其中有 10 次请求失败,那么可用性就是 99%。
+| 可用性等级 | 可用性百分比 | 年度停机时间 | 典型场景 |
+| ---------- | ------------ | ------------ | ------------ |
+| 1 个 9 | 90% | 36.5 天 | 个人博客 |
+| 2 个 9 | 99% | 3.65 天 | 普通企业系统 |
+| 3 个 9 | 99.9% | 8.76 小时 | 在线服务 |
+| 4 个 9 | 99.99% | 52.6 分钟 | 金融交易系统 |
+| 5 个 9 | 99.999% | 5.26 分钟 | 电信级系统 |
+
+除此之外,系统的可用性还可以用 **某功能的失败次数与总的请求次数之比** 来衡量,比如对网站请求 1000 次,其中有 10 次请求失败,那么可用性就是 99%。
+
+**SLA(Service Level Agreement,服务级别协议)** 是服务提供商与客户之间的正式承诺,通常会明确规定可用性目标。例如,云服务商承诺 99.95% 的 SLA,意味着每月最多允许约 22 分钟的停机时间。
## 哪些情况会导致系统不可用?
-1. 黑客攻击;
-2. 硬件故障,比如服务器坏掉。
-3. 并发量/用户请求量激增导致整个服务宕掉或者部分服务不可用。
-4. 代码中的坏味道导致内存泄漏或者其他问题导致程序挂掉。
-5. 网站架构某个重要的角色比如 Nginx 或者数据库突然不可用。
-6. 自然灾害或者人为破坏。
-7. ……
+导致系统不可用的原因可以从 **内部因素** 和 **外部因素** 两个维度来分析:
+
+**内部因素:**
+
+1. **代码缺陷**:比如内存泄漏、死锁、循环依赖、空指针异常等代码质量问题,是导致线上故障的最常见原因之一。
+2. **架构设计缺陷**:单点故障、缺少限流保护、服务间强耦合等架构问题,会在流量高峰时暴露出来。
+3. **资源耗尽**:CPU、内存、磁盘、连接池等资源耗尽会直接导致服务不可用。
+4. **配置错误**:错误的配置变更(如数据库连接串、超时时间配置不当)可能导致服务异常。
+
+**外部因素:**
+
+1. **硬件故障**:服务器宕机、磁盘损坏、网络设备故障等。
+2. **流量激增**:突发的用户请求量(如秒杀活动)超过系统承载能力。
+3. **网络攻击**:DDoS 攻击、CC 攻击等恶意攻击会耗尽系统资源。
+4. **依赖服务故障**:数据库、缓存、消息队列、第三方 API 等依赖服务不可用。
+5. **自然灾害**:机房停电、火灾、地震等不可抗力因素。
## 有哪些提高系统可用性的方法?
+提高系统可用性的方法可以从 **预防**、**容错**、**恢复** 三个阶段来考虑:
+
+```mermaid
+flowchart TB
+ subgraph Resilience["🛡️ 系统韧性三阶段
"]
+ direction TB
+
+ %% ================= 预防 =================
+ subgraph Prevention["🧯 预防:把风险前置
"]
+ direction TB
+ A["🧹 质量与测试
Review / 静态扫描 / 单元测试"]
+ B["🧩 高可用架构
多副本 / 多 AZ / 负载均衡"]
+ C["🧊 缓存与本地化
降延迟 / 减下游压力"]
+ D["🧪 灰度发布
Canary / 分批 / 快速回滚"]
+ end
+
+ P2T["⬇️ 从“少出错”到“扛得住”
进入故障控制面"]
+
+ %% ================= 容错 =================
+ subgraph Tolerance["🧱 容错:隔离止血,保核心链路
"]
+ direction TB
+ E["🚦 限流
令牌桶 / 并发控制"]
+ F["⏱️ 超时与重试
超时预算 / 指数退避 / 幂等"]
+ G["🧨 熔断
错误率阈值 / 半开探测"]
+ H["🪂 降级
兜底返回 / 关非核心"]
+ I["🧵 异步与队列
削峰填谷 / 解耦 / 最终一致"]
+ end
+
+ T2R["⬇️ 从“止血”到“恢复”
进入定位与处置"]
+
+ %% ================= 恢复 =================
+ subgraph Recovery["🔧 恢复:定位修复,回到 SLO
"]
+ direction TB
+ J["📡 可观测与告警
指标 / 日志 / Trace(SLI/SLO)"]
+ K["⏪ 回滚与灾备
版本回退 / 数据回放 / 切换"]
+ end
+
+ %% 主链路
+ Prevention --> P2T --> Tolerance --> T2R --> Recovery
+ end
+
+ %% =============== 样式(统一、少而清) ===============
+ classDef prevent fill:#52B788,stroke:#2E8B57,color:#fff;
+ classDef tolerate fill:#3498DB,stroke:#2980B9,color:#fff;
+ classDef recover fill:#F4D03F,stroke:#D35400,color:#333;
+ classDef pivot fill:#2C3E50,stroke:#1A252F,color:#fff;
+
+ class A,B,C,D prevent;
+ class E,F,G,H,I tolerate;
+ class J,K recover;
+ class P2T,T2R pivot;
+
+ style Prevention fill:#FFF3E0,stroke:#FFCC80,stroke-dasharray: 5 5;
+ style Tolerance fill:#E3F2FD,stroke:#90CAF9,stroke-dasharray: 5 5;
+ style Recovery fill:#E8F5E9,stroke:#A5D6A7,stroke-dasharray: 5 5;
+
+ style Resilience fill:#F5F5F5,stroke:#BDBDBD,rx:20,ry:20;
+```
+
### 注重代码质量,测试严格把关
-我觉得这个是最最最重要的,代码质量有问题比如比较常见的内存泄漏、循环依赖都是对系统可用性极大的损害。大家都喜欢谈限流、降级、熔断,但是我觉得从代码质量这个源头把关是首先要做好的一件很重要的事情。如何提高代码质量?比较实际可用的就是 CodeReview,不要在乎每天多花的那 1 个小时左右的时间,作用可大着呢!
+**代码质量是系统可用性的根基**。代码质量有问题比如比较常见的内存泄漏、循环依赖都是对系统可用性极大的损害。大家都喜欢谈限流、降级、熔断,但是从代码质量这个源头把关是首先要做好的一件很重要的事情。
-另外,安利几个对提高代码质量有实际效果的神器:
+如何提高代码质量?比较实际可用的就是 **Code Review**,不要在乎每天多花的那 1 个小时左右的时间,作用可大着呢!
-- [Sonarqube](https://www.sonarqube.org/);
-- Alibaba 开源的 Java 诊断工具 [Arthas](https://arthas.aliyun.com/doc/);
-- [阿里巴巴 Java 代码规范](https://github.com/alibaba/p3c)(Alibaba Java Code Guidelines);
+另外,安利几个对提高代码质量有实际效果的工具:
+
+- [Sonarqube](https://www.sonarqube.org/):静态代码分析平台,可检测代码坏味道、安全漏洞和 Bug。
+- Alibaba 开源的 Java 诊断工具 [Arthas](https://arthas.aliyun.com/doc/):可在线排查 JVM 问题,支持热更新代码。
+- [阿里巴巴 Java 代码规范](https://github.com/alibaba/p3c)(Alibaba Java Code Guidelines):配套 IDEA 插件,实时检查代码规范。
- IDEA 自带的代码分析等工具。
### 使用集群,减少单点故障
-先拿常用的 Redis 举个例子!我们如何保证我们的 Redis 缓存高可用呢?答案就是使用集群,避免单点故障。当我们使用一个 Redis 实例作为缓存的时候,这个 Redis 实例挂了之后,整个缓存服务可能就挂了。使用了集群之后,即使一台 Redis 实例挂了,不到一秒就会有另外一台 Redis 实例顶上。
+**单点故障(Single Point of Failure,SPOF)** 是高可用的大敌。先拿常用的 Redis 举个例子!我们如何保证我们的 Redis 缓存高可用呢?答案就是使用集群,避免单点故障。
+
+当我们使用一个 Redis 实例作为缓存的时候,这个 Redis 实例挂了之后,整个缓存服务可能就挂了。使用了集群之后,即使一台 Redis 实例挂了,不到一秒就会有另外一台 Redis 实例顶上。
+
+常见的集群模式:
+
+- **主从复制(Master-Slave)**:一主多从,主节点负责写,从节点负责读,主节点故障时需要手动或借助哨兵进行故障转移。
+- **哨兵模式(Sentinel)**:在主从复制基础上增加哨兵节点,实现自动故障检测和转移。
+- **分布式集群(Cluster)**:数据分片存储在多个节点,每个分片有主从副本,兼顾高可用和水平扩展。
### 限流
-流量控制(flow control),其原理是监控应用流量的 QPS 或并发线程数等指标,当达到指定的阈值时对流量进行控制,以避免被瞬时的流量高峰冲垮,从而保障应用的高可用性。——来自 [alibaba-Sentinel](https://github.com/alibaba/Sentinel "Sentinel") 的 wiki。
+**限流(Rate Limiting)** 是保护系统的第一道防线。其原理是监控应用流量的 QPS 或并发线程数等指标,当达到指定的阈值时对流量进行控制,以避免被瞬时的流量高峰冲垮,从而保障应用的高可用性。——来自 [alibaba-Sentinel](https://github.com/alibaba/Sentinel "Sentinel") 的 wiki。
+
+常见的限流算法包括:
+
+- **固定窗口计数器**:实现简单,但存在临界点突刺问题。
+- **滑动窗口计数器**:解决了固定窗口的临界问题,更加平滑。
+- **漏桶算法**:以固定速率处理请求,适合流量整形。
+- **令牌桶算法**:允许一定程度的突发流量,更加灵活。
### 超时和重试机制设置
-一旦用户请求超过某个时间的得不到响应,就抛出异常。这个是非常重要的,很多线上系统故障都是因为没有进行超时设置或者超时设置的方式不对导致的。我们在读取第三方服务的时候,尤其适合设置超时和重试机制。一般我们使用一些 RPC 框架的时候,这些框架都自带的超时重试的配置。如果不进行超时设置可能会导致请求响应速度慢,甚至导致请求堆积进而让系统无法再处理请求。重试的次数一般设为 3 次,再多次的重试没有好处,反而会加重服务器压力(部分场景使用失败重试机制会不太适合)。
+一旦用户请求超过某个时间的得不到响应,就抛出异常。这个是非常重要的,很多线上系统故障都是因为 **没有进行超时设置或者超时设置的方式不对** 导致的。
+
+我们在读取第三方服务的时候,尤其适合设置超时和重试机制。一般我们使用一些 RPC 框架的时候,这些框架都自带的超时重试的配置。如果不进行超时设置可能会导致请求响应速度慢,甚至导致请求堆积进而让系统无法再处理请求。
+
+**重试的次数一般设为 3 次**,再多次的重试没有好处,反而会加重服务器压力(部分场景使用失败重试机制会不太适合)。同时,重试需要配合 **指数退避** 策略,避免重试风暴。
### 熔断机制
-超时和重试机制设置之外,熔断机制也是很重要的。 熔断机制说的是系统自动收集所依赖服务的资源使用情况和性能指标,当所依赖的服务恶化或者调用失败次数达到某个阈值的时候就迅速失败,让当前系统立即切换依赖其他备用服务。 比较常用的流量控制和熔断降级框架是 Netflix 的 Hystrix 和 alibaba 的 Sentinel。
+超时和重试机制设置之外,**熔断机制** 也是很重要的。熔断机制说的是系统自动收集所依赖服务的资源使用情况和性能指标,当所依赖的服务恶化或者调用失败次数达到某个阈值的时候就迅速失败,让当前系统立即切换依赖其他备用服务。
+
+熔断器有三种状态:
+
+- **关闭(Closed)**:正常状态,请求正常通过。
+- **打开(Open)**:熔断状态,请求直接失败,不调用下游服务。
+- **半开(Half-Open)**:尝试恢复状态,放行少量请求探测下游服务是否恢复。
+
+比较常用的流量控制和熔断降级框架是 Netflix 的 Hystrix 和 alibaba 的 Sentinel。
+
+### 降级
+
+**降级(Degradation)** 是在系统压力过大或部分服务不可用时,暂时关闭一些非核心功能,保证核心功能的可用性。
+
+降级策略包括:
+
+- **功能降级**:关闭推荐、评论等非核心功能。
+- **数据降级**:返回缓存数据或默认数据,而非实时查询。
+- **页面降级**:返回静态页面或简化版页面。
### 异步调用
-异步调用的话我们不需要关心最后的结果,这样我们就可以用户请求完成之后就立即返回结果,具体处理我们可以后续再做,秒杀场景用这个还是蛮多的。但是,使用异步之后我们可能需要 **适当修改业务流程进行配合**,比如**用户在提交订单之后,不能立即返回用户订单提交成功,需要在消息队列的订单消费者进程真正处理完该订单之后,甚至出库后,再通过电子邮件或短信通知用户订单成功**。除了可以在程序中实现异步之外,我们常常还使用消息队列,消息队列可以通过异步处理提高系统性能(削峰、减少响应所需时间)并且可以降低系统耦合性。
+异步调用的话我们不需要关心最后的结果,这样我们就可以用户请求完成之后就立即返回结果,具体处理我们可以后续再做,秒杀场景用这个还是蛮多的。
+
+但是,使用异步之后我们可能需要 **适当修改业务流程进行配合**,比如 **用户在提交订单之后,不能立即返回用户订单提交成功,需要在消息队列的订单消费者进程真正处理完该订单之后,甚至出库后,再通过电子邮件或短信通知用户订单成功**。
+
+除了可以在程序中实现异步之外,我们常常还使用 **消息队列**,消息队列可以通过异步处理提高系统性能(削峰、减少响应所需时间)并且可以降低系统耦合性。
### 使用缓存
如果我们的系统属于并发量比较高的话,如果我们单纯使用数据库的话,当大量请求直接落到数据库可能数据库就会直接挂掉。使用缓存缓存热点数据,因为缓存存储在内存中,所以速度相当地快!
+缓存的典型应用场景:
+
+- **热点数据缓存**:将访问频繁的数据放入 Redis 等缓存中。
+- **页面缓存**:将渲染后的页面缓存起来,减少服务器压力。
+- **本地缓存**:使用 Caffeine、Guava Cache 等本地缓存,减少网络开销。
+
### 其他
-- **核心应用和服务优先使用更好的硬件**
-- **监控系统资源使用情况增加报警设置。**
-- **注意备份,必要时候回滚。**
-- **灰度发布:** 将服务器集群分成若干部分,每天只发布一部分机器,观察运行稳定没有故障,第二天继续发布一部分机器,持续几天才把整个集群全部发布完毕,期间如果发现问题,只需要回滚已发布的一部分服务器即可
-- **定期检查/更换硬件:** 如果不是购买的云服务的话,定期还是需要对硬件进行一波检查的,对于一些需要更换或者升级的硬件,要及时更换或者升级。
-- ……
+- **核心应用和服务优先使用更好的硬件**:核心服务使用更高配置的服务器、SSD 硬盘等。
+- **监控系统资源使用情况增加报警设置**:使用 Prometheus + Grafana 等监控方案,设置合理的告警阈值。
+- **注意备份,必要时候回滚**:数据库定期备份,代码版本可追溯,支持快速回滚。
+- **灰度发布**:将服务器集群分成若干部分,每天只发布一部分机器,观察运行稳定没有故障,第二天继续发布一部分机器,持续几天才把整个集群全部发布完毕,期间如果发现问题,只需要回滚已发布的一部分服务器即可。
+- **定期检查/更换硬件**:如果不是购买的云服务的话,定期还是需要对硬件进行一波检查的,对于一些需要更换或者升级的硬件,要及时更换或者升级。
diff --git a/docs/high-availability/idempotency.md b/docs/high-availability/idempotency.md
new file mode 100644
index 00000000000..d06e0002fa0
--- /dev/null
+++ b/docs/high-availability/idempotency.md
@@ -0,0 +1,12 @@
+---
+title: 接口幂等方案总结(付费)
+description: 接口幂等性设计详解,涵盖幂等性概念、常见实现方案及在支付、订单等场景中的应用实践。
+category: 高可用
+icon: security-fill
+---
+
+**接口幂等** 相关的面试题为我的[知识星球](https://javaguide.cn/about-the-author/zhishixingqiu-two-years.html)(点击链接即可查看详细介绍以及加入方法)专属内容,已经整理到了[《Java 面试指北》](https://javaguide.cn/zhuanlan/java-mian-shi-zhi-bei.html)中。
+
+
+
+
diff --git a/docs/high-availability/limit-request.md b/docs/high-availability/limit-request.md
index 812999aebcd..0123a4711f5 100644
--- a/docs/high-availability/limit-request.md
+++ b/docs/high-availability/limit-request.md
@@ -1,12 +1,13 @@
---
title: 服务限流详解
+description: 服务限流原理与实现详解,涵盖固定窗口、滑动窗口、令牌桶、漏桶等主流限流算法的原理与应用。
category: 高可用
icon: limit_rate
---
针对软件系统来说,限流就是对请求的速率进行限制,避免瞬时的大量请求击垮软件系统。毕竟,软件系统的处理能力是有限的。如果说超过了其处理能力的范围,软件系统可能直接就挂掉了。
-限流可能会导致用户的请求无法被正确处理,不过,这往往也是权衡了软件系统的稳定性之后得到的最优解。
+限流可能会导致用户的请求无法被正确处理或者无法立即被处理,不过,这往往也是权衡了软件系统的稳定性之后得到的最优解。
现实生活中,处处都有限流的实际应用,就比如排队买票是为了避免大量用户涌入购票而导致售票员无法处理。
@@ -18,33 +19,45 @@ icon: limit_rate
### 固定窗口计数器算法
-固定窗口其实就是时间窗口。**固定窗口计数器算法** 规定了我们单位时间处理的请求数量。
+固定窗口其实就是时间窗口,其原理是将时间划分为固定大小的窗口,在每个窗口内限制请求的数量或速率,即固定窗口计数器算法规定了系统单位时间处理的请求数量。
-假如我们规定系统中某个接口 1 分钟只能访问 33 次的话,使用固定窗口计数器算法的实现思路如下:
+假如我们规定系统中某个接口 1 分钟只能被访问 33 次的话,使用固定窗口计数器算法的实现思路如下:
+- 将时间划分固定大小窗口,这里是 1 分钟一个窗口。
- 给定一个变量 `counter` 来记录当前接口处理的请求数量,初始值为 0(代表接口当前 1 分钟内还未处理请求)。
- 1 分钟之内每处理一个请求之后就将 `counter+1` ,当 `counter=33` 之后(也就是说在这 1 分钟内接口已经被访问 33 次的话),后续的请求就会被全部拒绝。
- 等到 1 分钟结束后,将 `counter` 重置 0,重新开始计数。
-这种限流算法限流不够平滑。例如,我们限制某个接口每分钟只能访问 30 次,假设前 30 秒就有 30 个请求到达的话,那后续 30 秒将无法处理请求,这是不可取的,用户体验极差!
+
-除此之外,这种限流算法无法保证限流速率,因而无法应对突然激增的流量。例如,我们限制某个接口 1 分钟只能访问 1000 次,该接口的 QPS 为 500,前 55s 这个接口 1 个请求没有接收,后 1s 突然接收了 1000 个请求。然后,在当前场景下,这 1000 个请求在 1s 内是没办法被处理的,系统直接就被瞬时的大量请求给击垮了。
+优点:实现简单,易于理解。
-
+缺点:
+
+- 限流不够平滑。例如,我们限制某个接口每分钟只能访问 30 次,假设前 30 秒就有 30 个请求到达的话,那后续 30 秒将无法处理请求,这是不可取的,用户体验极差!
+- 无法保证限流速率,因而无法应对突然激增的流量。例如,我们限制某个接口 1 分钟只能访问 1000 次,该接口的 QPS 为 500,前 55s 这个接口 1 个请求没有接收,后 1s 突然接收了 1000 个请求。然后,在当前场景下,这 1000 个请求在 1s 内是没办法被处理的,系统直接就被瞬时的大量请求给击垮了。
### 滑动窗口计数器算法
-**滑动窗口计数器算法** 算的上是固定窗口计数器算法的升级版。
+**滑动窗口计数器算法** 算的上是固定窗口计数器算法的升级版,限流的颗粒度更小。
滑动窗口计数器算法相比于固定窗口计数器算法的优化在于:**它把时间以一定比例分片** 。
-例如我们的接口限流每分钟处理 60 个请求,我们可以把 1 分钟分为 60 个窗口。每隔 1 秒移动一次,每个窗口一秒只能处理 不大于 `60(请求数)/60(窗口数)` 的请求, 如果当前窗口的请求计数总和超过了限制的数量的话就不再处理其他请求。
+例如我们的接口限流每分钟处理 60 个请求,我们可以把 1 分钟分为 60 个窗口。每隔 1 秒移动一次,每个窗口一秒只能处理不大于 `60(请求数)/60(窗口数)` 的请求, 如果当前窗口的请求计数总和超过了限制的数量的话就不再处理其他请求。
很显然, **当滑动窗口的格子划分的越多,滑动窗口的滚动就越平滑,限流的统计就会越精确。**

-滑动窗口计数器算法可以应对突然激增的流量,但依然存在限流不够平滑的问题。
+优点:
+
+- 相比于固定窗口算法,滑动窗口计数器算法可以应对突然激增的流量。
+- 相比于固定窗口算法,滑动窗口计数器算法的颗粒度更小,可以提供更精确的限流控制。
+
+缺点:
+
+- 与固定窗口计数器算法类似,滑动窗口计数器算法依然存在限流不够平滑的问题。
+- 相比较于固定窗口计数器算法,滑动窗口计数器算法实现和理解起来更复杂一些。
### 漏桶算法
@@ -54,7 +67,15 @@ icon: limit_rate

-漏桶算法可以控制限流速率,避免网络拥塞和系统过载。不过,漏桶算法无法应对突然激增的流量,因为只能以固定的速率处理请求,对系统资源利用不够友好。
+优点:
+
+- 实现简单,易于理解。
+- 可以控制限流速率,避免网络拥塞和系统过载。
+
+缺点:
+
+- 无法应对突然激增的流量,因为只能以固定的速率处理请求,对系统资源利用不够友好。
+- 桶流入水(发请求)的速率如果一直大于桶流出水(处理请求)的速率的话,那么桶会一直是满的,一部分新的请求会被丢弃,导致服务质量下降。
实际业务场景中,基本不会使用漏桶算法。
@@ -64,7 +85,15 @@ icon: limit_rate

-令牌桶算法可以限制平均速率和应对突然激增的流量,还可以动态调整生成令牌的速率。不过,如果令牌产生速率和桶的容量设置不合理,可能会出现问题比如大量的请求被丢弃、系统过载。
+优点:
+
+- 可以限制平均速率和应对突然激增的流量。
+- 可以动态调整生成令牌的速率。
+
+缺点:
+
+- 如果令牌产生速率和桶的容量设置不合理,可能会出现问题比如大量的请求被丢弃、系统过载。
+- 相比于其他限流算法,实现和理解起来更复杂一些。
## 针对什么来进行限流?
@@ -211,7 +240,7 @@ Resilience4j 不仅提供限流,还提供了熔断、负载保护、自动重
分布式限流常见的方案:
-- **借助中间件架限流**:可以借助 Sentinel 或者使用 Redis 来自己实现对应的限流逻辑。
+- **借助中间件限流**:可以借助 Sentinel 或者使用 Redis 来自己实现对应的限流逻辑。
- **网关层限流**:比较常用的一种方案,直接在网关层把限流给安排上了。不过,通常网关层限流通常也需要借助到中间件/框架。就比如 Spring Cloud Gateway 的分布式限流实现`RedisRateLimiter`就是基于 Redis+Lua 来实现的,再比如 Spring Cloud Gateway 还可以整合 Sentinel 来做限流。
如果你要基于 Redis 来手动实现限流逻辑的话,建议配合 Lua 脚本来做。
@@ -225,9 +254,9 @@ Resilience4j 不仅提供限流,还提供了熔断、负载保护、自动重
> ShenYu 地址:
-
+
-另外,如果不想自己写 Lua 脚本的话,也可以直接利用 Redisson 中的 `RRateLimiter` 来实现分布式限流,其底层实现就是基于 Lua 代码。
+另外,如果不想自己写 Lua 脚本的话,也可以直接利用 Redisson 中的 `RRateLimiter` 来实现分布式限流,其底层实现就是基于 Lua 代码+令牌桶算法。
Redisson 是一个开源的 Java 语言 Redis 客户端,提供了很多开箱即用的功能,比如 Java 中常用的数据结构实现、分布式锁、延迟队列等等。并且,Redisson 还支持 Redis 单机、Redis Sentinel、Redis Cluster 等多种部署架构。
@@ -265,5 +294,6 @@ boolean res = rateLimiter.tryAcquire(1, 5, TimeUnit.SECONDS);
- 实战 Spring Cloud Gateway 之限流篇 👍:
- 详解 Redisson 分布式限流的实现原理:
- 一文详解 Java 限流接口实现 - 阿里云开发者:
+- 分布式限流方案的探索与实践 - 腾讯云开发者:
diff --git a/docs/high-availability/performance-test.md b/docs/high-availability/performance-test.md
index fe2ce94647f..0cccfec6a91 100644
--- a/docs/high-availability/performance-test.md
+++ b/docs/high-availability/performance-test.md
@@ -1,37 +1,42 @@
---
title: 性能测试入门
+description: 本文系统讲解性能测试核心知识,涵盖响应时间分位值(P90/P99/P999)、QPS/TPS、Little's Law 与曲棍球棒曲线、背压与自愈验证、性能测试分类(负载/压力/稳定性)、压测工具(JMeter/Gatling/ab)选型及性能优化策略。
category: 高可用
icon: et-performance
+head:
+ - - meta
+ - name: keywords
+ content: 性能测试,压力测试,负载测试,QPS,TPS,RT响应时间,P99分位值,并发数,吞吐量,背压,利特尔法则,JMeter,Gatling,性能优化
---
性能测试一般情况下都是由测试这个职位去做的,那还需要我们开发学这个干嘛呢?了解性能测试的指标、分类以及工具等知识有助于我们更好地去写出性能更好的程序,另外作为开发这个角色,如果你会性能测试的话,相信也会为你的履历加分不少。
这篇文章是我会结合自己的实际经历以及在测试这里取的经所得,除此之外,我还借鉴了一些优秀书籍,希望对你有帮助。
-## 一 不同角色看网站性能
+## 不同角色看网站性能
-### 1.1 用户
+### 用户
-当用户打开一个网站的时候,最关注的是什么?当然是网站响应速度的快慢。比如我们点击了淘宝的主页,淘宝需要多久将首页的内容呈现在我的面前,我点击了提交订单按钮需要多久返回结果等等。
+当用户打开一个网站的时候,最关注的是什么?当然是 **网站响应速度的快慢**。比如我们点击了淘宝的主页,淘宝需要多久将首页的内容呈现在我的面前,我点击了提交订单按钮需要多久返回结果等等。
所以,用户在体验我们系统的时候往往根据你的响应速度的快慢来评判你的网站的性能。
-### 1.2 开发人员
+### 开发人员
-用户与开发人员都关注速度,这个速度实际上就是我们的系统**处理用户请求的速度**。
+用户与开发人员都关注速度,这个速度实际上就是我们的系统 **处理用户请求的速度**。
-开发人员一般情况下很难直观的去评判自己网站的性能,我们往往会根据网站当前的架构以及基础设施情况给一个大概的值,比如:
+开发人员一般情况下很难直观的去评判自己网站的性能,我们往往会根据网站当前的架构以及基础设施情况给一个大概的值,比如:
1. 项目架构是分布式的吗?
2. 用到了缓存和消息队列没有?
3. 高并发的业务有没有特殊处理?
4. 数据库设计是否合理?
5. 系统用到的算法是否还需要优化?
-6. 系统是否存在内存泄露的问题?
+6. 系统是否存在内存泄漏的问题?
7. 项目使用的 Redis 缓存多大?服务器性能如何?用的是机械硬盘还是固态硬盘?
8. ……
-### 1.3 测试人员
+### 测试人员
测试人员一般会根据性能测试工具来测试,然后一般会做出一个表格。这个表格可能会涵盖下面这些重要的内容:
@@ -40,113 +45,207 @@ icon: et-performance
3. 吞吐量;
4. ……
-### 1.4 运维人员
+### 运维人员
-运维人员会倾向于根据基础设施和资源的利用率来判断网站的性能,比如我们的服务器资源使用是否合理、数据库资源是否存在滥用的情况、当然,这是传统的运维人员,现在 Devops 火起来后,单纯干运维的很少了。我们这里暂且还保留有这个角色。
+运维人员会倾向于根据 **基础设施和资源的利用率** 来判断网站的性能,比如我们的服务器资源使用是否合理、数据库资源是否存在滥用的情况、当然,这是传统的运维人员,现在 Devops 火起来后,单纯干运维的很少了。我们这里暂且还保留有这个角色。
-## 二 性能测试需要注意的点
+## 性能测试需要注意的点
几乎没有文章在讲性能测试的时候提到这个问题,大家都会讲如何去性能测试,有哪些性能测试指标这些东西。
-### 2.1 了解系统的业务场景
+### 了解系统的业务场景
-**性能测试之前更需要你了解当前的系统的业务场景。** 对系统业务了解的不够深刻,我们很容易犯测试方向偏执的错误,从而导致我们忽略了对系统某些更需要性能测试的地方进行测试。比如我们的系统可以为用户提供发送邮件的功能,用户配置成功邮箱后只需输入相应的邮箱之后就能发送,系统每天大概能处理上万次发邮件的请求。很多人看到这个可能就直接开始使用相关工具测试邮箱发送接口,但是,发送邮件这个场景可能不是当前系统的性能瓶颈,这么多人用我们的系统发邮件, 还可能有很多人一起发邮件,单单这个场景就这么人用,那用户管理可能才是性能瓶颈吧!
+**性能测试之前更需要你了解当前的系统的业务场景。** 对系统业务了解的不够深刻,我们很容易犯测试方向偏执的错误,从而导致我们忽略了对系统某些更需要性能测试的地方进行测试。
-### 2.2 历史数据非常有用
+比如我们的系统可以为用户提供发送邮件的功能,用户配置成功邮箱后只需输入相应的邮箱之后就能发送,系统每天大概能处理上万次发邮件的请求。很多人看到这个可能就直接开始使用相关工具测试邮箱发送接口,但是,发送邮件这个场景可能不是当前系统的性能瓶颈,这么多人用我们的系统发邮件,还可能有很多人一起发邮件,单单这个场景就这么人用,那用户管理可能才是性能瓶颈吧!
-当前系统所留下的历史数据非常重要,一般情况下,我们可以通过相应的些历史数据初步判定这个系统哪些接口调用的比较多、哪些 service 承受的压力最大,这样的话,我们就可以针对这些地方进行更细致的性能测试与分析。
+### 历史数据非常有用
+
+当前系统所留下的历史数据非常重要,一般情况下,我们可以通过相应的些历史数据初步判定这个系统哪些接口调用的比较多、哪些服务承受的压力最大,这样的话,我们就可以针对这些地方进行更细致的性能测试与分析。
另外,这些地方也就像这个系统的一个短板一样,优化好了这些地方会为我们的系统带来质的提升。
-### 三 性能测试的指标
+## 常见性能指标
+
+性能指标是衡量系统性能的核心度量标准,理解各指标之间的关系对于性能分析至关重要。
+
+```mermaid
+flowchart LR
+ subgraph Input["输入参数"]
+ style Input fill:#F5F7FA,color:#333333,stroke:#005D7B,stroke-width:2px
+ A["并发数
Concurrency"]
+ end
+
+ subgraph Process["处理过程"]
+ style Process fill:#F5F7FA,color:#333333,stroke:#005D7B,stroke-width:2px
+ B["响应时间
RT"]
+ end
+
+ subgraph Output["输出指标"]
+ style Output fill:#F5F7FA,color:#333333,stroke:#005D7B,stroke-width:2px
+ C["QPS/TPS
吞吐量"]
+ end
+
+ A -->|"请求"| B
+ B -->|"计算"| C
+
+ D["QPS = 并发数 / RT"]
+
+ classDef core fill:#4CA497,color:#FFFFFF,stroke:none,rx:10,ry:10
+ classDef process fill:#00838F,color:#FFFFFF,stroke:none,rx:10,ry:10
+ classDef highlight fill:#E99151,color:#FFFFFF,stroke:none,rx:10,ry:10
+
+ class A core
+ class B process
+ class C,D highlight
+
+ linkStyle default stroke-width:2px,stroke:#333333,opacity:0.8
+```
+
+### 响应时间
+
+**响应时间 RT(Response Time)** 是用户发出请求到收到系统处理结果所需的时间,包括网络传输、服务端处理与客户端渲染等环节。
+
+**响应时间指标(Latency Percentiles)**:生产环境中看平均 RT 毫无意义,必须监控 **P90、P99 和 P999** 分位值。例如 P99 = 500ms 意味着 99% 的请求在 500ms 内返回。那 1% 的长尾慢调用(可能由 Cache Miss、慢 SQL 或 GC STW 引起)在极高并发下会发生排队效应,瞬间打满网关或 RPC 框架的底层工作线程池,直接引发雪崩。大量超快响应会拉低平均值,掩盖致命的长尾问题,此为典型的 **"均值陷阱"**。
+
+分位值参考标准如下:
-### 3.1 响应时间
+| 分位值 | RT 范围(示例) | 说明 |
+| ------ | --------------- | ------------------------ |
+| P90 | < 200ms | 90% 的请求在此时间内返回 |
+| P99 | < 500ms | 重点关注,长尾用户体感 |
+| P999 | < 1s | 极端场景,易触发雪崩 |
-**响应时间就是用户发出请求到用户收到系统处理结果所需要的时间。** 重要吗?实在太重要!
+> **失败模式**:当发生网络偶发抖动时,P999 RT 会急剧飙升。若上游缺乏超时截断机制(Timeout & Circuit Breaking),大量并发请求将被挂起,导致上游节点内存 OOM。
-比较出名的 2-5-8 原则是这样描述的:通常来说,2 到 5 秒,页面体验会比较好,5 到 8 秒还可以接受,8 秒以上基本就很难接受了。另外,据统计当网站慢一秒就会流失十分之一的客户。
+### 并发数
-但是,在某些场景下我们也并不需要太看重 2-5-8 原则 ,比如我觉得系统导出导入大数据量这种就不需要,系统生成系统报告这种也不需要。
+**并发数可以简单理解为系统能够同时供多少人访问使用,也就是说系统同时能处理的请求数量。**
-### 3.2 并发数
+并发数反应了系统的 **负载能力**。需要注意区分以下概念:
-**并发数是系统能同时处理请求的数目即同时提交请求的用户数目。**
+- **并发用户数**:同时在线的用户数量。
+- **并发请求数**:同一时刻系统正在处理的请求数量。
+- **最大并发数**:系统能够承受的最大并发请求数,超过此值系统可能出现性能下降或崩溃。
-不得不说,高并发是现在后端架构中非常非常火热的一个词了,这个与当前的互联网环境以及中国整体的互联网用户量都有很大关系。一般情况下,你的系统并发量越大,说明你的产品做的就越大。但是,并不是每个系统都需要达到像淘宝、12306 这种亿级并发量的。
+### QPS 和 TPS
-### 3.3 吞吐量
+- **QPS(Query Per Second)**:服务器每秒可执行的查询次数;
+- **TPS(Transaction Per Second)**:服务器每秒处理的事务数(一次完整业务操作)。
-吞吐量指的是系统单位时间内系统处理的请求数量。衡量吞吐量有几个重要的参数:QPS(TPS)、并发数、响应时间。
+> QPS vs TPS:一次页面访问形成 1 个 TPS,但可能产生多次对服务器的请求(计入 QPS)。**TPS 偏向业务视角,QPS 偏向技术视角。**
-1. QPS(Query Per Second):服务器每秒可以执行的查询次数;
-2. TPS(Transaction Per Second):服务器每秒处理的事务数(这里的一个事务可以理解为客户发出请求到收到服务器的过程);
-3. 并发数;系统能同时处理请求的数目即同时提交请求的用户数目。
-4. 响应时间:一般取多次请求的平均响应时间
+### 吞吐量
-理清他们的概念,就很容易搞清楚他们之间的关系了。
+**吞吐量** 指系统单位时间内处理的请求数量。TPS、QPS 是常用量化指标。
-- **QPS(TPS)** = 并发数/平均响应时间
-- **并发数** = QPS\*平均响应时间
+**Little's Law(利特尔法则)**:在系统未饱和的稳态下,`并发数 = QPS × RT`,亦即 `QPS = 并发数 / RT`。该公式仅在系统处于线性响应区间时成立。随着并发用户数持续增加,CPU 调度消耗、锁争用(Lock Contention)加剧,RT 会呈现 **指数级上升**,吞吐量达到拐点后急速下降,形成典型的 **"曲棍球棒曲线"(Hockey Stick Curve)**。下图直观展示「为什么不能用公式硬算」:拐点之后 QPS 不升反降,系统已进入非线性区。
-书中是这样描述 QPS 和 TPS 的区别的。
+```mermaid
+xychart-beta
+ title "QPS vs 并发数(曲棍球棒曲线)"
+ x-axis "并发数" [200, 500, 1000, 1500, 2000, 2500, 3000, 3500, 4000]
+ y-axis "QPS" 0 --> 5000
+ line [1200, 2800, 4200, 4800, 5000, 4750, 3800, 2400, 1200]
+```
-> QPS vs TPS:QPS 基本类似于 TPS,但是不同的是,对于一个页面的一次访问,形成一个 TPS;但一次页面请求,可能产生多次对服务器的请求,服务器对这些请求,就可计入“QPS”之中。如,访问一个页面会请求服务器 2 次,一次访问,产生一个“T”,产生 2 个“Q”。
+因此,绝不能仅靠公式推算生产容量,必须通过全链路压测验证真实极限。
-### 3.4 性能计数器
+## 系统活跃度指标
-**性能计数器是描述服务器或者操作系统的一些数据指标如内存使用、CPU 使用、磁盘与网络 I/O 等情况。**
+### PV(Page View)
-### 四 几种常见的性能测试
+**访问量**,即页面浏览量或点击量,衡量网站用户访问的网页数量;在一定统计周期内用户每打开或刷新一个页面就记录 1 次,多次打开或刷新同一页面则浏览量累计。PV 从网页打开的数量/刷新的次数的角度来统计的。
+
+### UV(Unique Visitor)
+
+**独立访客**,统计 1 天内访问某站点的用户数。1 天内相同访客多次访问网站,只计算为 1 个独立访客。UV 是从用户个体的角度来统计的。
+
+### DAU(Daily Active User)
+
+**日活跃用户数量**,指一天内登录或使用产品的用户数(去重)。
+
+### MAU(Monthly Active Users)
+
+**月活跃用户人数**,指一个月内登录或使用产品的用户数(去重)。
+
+### 实战计算示例
+
+> **生产级容量评估**:绝不能用 DAU 乘以固定系数去估算峰值。真实峰值往往来自特定业务场景(如整点秒杀、大促开抢)。随着并发用户数(Virtual Users)持续增加,系统 CPU 调度消耗、锁争用加剧,RT 会呈现指数级上升,此时吞吐量会达到拐点并急速下降。必须通过 **全链路压测**(结合真实流量录制与回放,如 [GoReplay](https://goreplay.org/))来摸底真实的吞吐量极限,而非纸上公式推算。
+
+## 性能测试分类
+
+| 测试类型 | 目的 | 测试方法 |
+| -------------- | -------------------------- | --------------------------------------- |
+| **性能测试** | 验证系统性能是否满足预期 | 在已知性能指标下验证 |
+| **负载测试** | 找到系统的性能上限 | 逐步加压直到资源饱和 |
+| **压力测试** | 测试极限、背压与自愈能力 | 持续加压验证崩溃后行为(429/503、自愈) |
+| **稳定性测试** | 验证系统长时间运行的稳定性 | 模拟真实场景持续运行 |
+
+**负载测试 vs 压力测试的水位边界**:二者区别在于「加压到哪里为止」。下图帮助建立直观水位线:负载测试在**资源饱和线**止步(找到上限);压力测试继续加压**越过饱和线**,直到崩溃并验证背压与自愈。
### 性能测试
性能测试方法是通过测试工具模拟用户请求系统,目的主要是为了测试系统的性能是否满足要求。通俗地说,这种方法就是要在特定的运行条件下验证系统的能力状态。
-性能测试是你在对系统性能已经有了解的前提之后进行的,并且有明确的性能指标。
+性能测试是你在 **对系统性能已经有了解的前提之后** 进行的,并且有明确的性能指标。
### 负载测试
对被测试的系统继续加大请求压力,直到服务器的某个资源已经达到饱和了,比如系统的缓存已经不够用了或者系统的响应时间已经不满足要求了。
-负载测试说白点就是测试系统的上限。
+**负载测试说白点就是测试系统的上限。**
### 压力测试
-不去管系统资源的使用情况,对系统继续加大请求压力,直到服务器崩溃无法再继续提供服务。
+不去管系统资源的使用情况,对系统持续加大请求压力,**直到系统崩溃**。压力测试的核心目的不仅是寻找崩溃点,更是验证系统在过载状态下的 **背压(Backpressure)容错性**。当并发数超越承载极限时,必须验证系统能否主动阻断流量(如返回 HTTP 429 Too Many Requests、503 Service Unavailable),避免节点假死。同时,需验证在撤除越线流量后,系统是否能自动释放挂起的连接并恢复至正常吞吐能力(**自愈性**)。这种"崩溃后行为"的验证是混沌工程与高可用架构的最佳实践。
### 稳定性测试
-模拟真实场景,给系统一定压力,看看业务是否能稳定运行。
+模拟真实场景,给系统一定压力,看看业务是否能稳定运行。稳定性测试通常需要运行较长时间(如 7×24 小时),观察系统是否存在 **内存泄漏、连接泄漏** 等问题。
+
+## 常用性能测试工具
+
+### 后端常用
+
+既然系统设计涉及到系统性能方面的问题,那在面试的时候,面试官就很可能会问:**你是如何进行性能测试的?**
-## 五 常用性能测试工具
+推荐 4 个比较常用的性能测试工具:
-这里就不多扩展了,有时间的话会单独拎一个熟悉的说一下。
+| 工具 | 开发语言 | 特点 | 适用场景 |
+| -------------- | -------- | ------------------------------------- | ------------------------ |
+| **JMeter** | Java | 功能全面,支持 GUI 和命令行,插件丰富 | 复杂场景测试、企业级应用 |
+| **Gatling** | Scala | 基于 Akka,代码驱动,报告美观 | 高并发场景、CI/CD 集成 |
+| **ab** | C | 轻量简单,Apache 自带 | 快速接口测试、基准测试 |
+| **LoadRunner** | - | 商业软件,功能强大 | 企业级大规模测试 |
-### 5.1 后端常用
+没记错的话,除了 **LoadRunner** 其他几款性能测试工具都是开源免费的。
-没记错的话,除了 LoadRunner 其他几款性能测试工具都是开源免费的。
+**选型建议:**
-1. Jmeter:Apache JMeter 是 JAVA 开发的性能测试工具。
-2. LoadRunner:一款商业的性能测试工具。
-3. Galtling:一款基于 Scala 开发的高性能服务器性能测试工具。
-4. ab:全称为 Apache Bench 。Apache 旗下的一款测试工具,非常实用。
+- **快速验证**:使用 `ab` 或 `wrk` 进行简单的接口压测。
+- **复杂场景**:使用 `JMeter`,支持录制脚本、参数化、断言等功能。
+- **代码驱动**:使用 `Gatling`,适合开发人员,易于版本控制和 CI 集成。
-### 5.2 前端常用
+### 前端常用
-1. Fiddler:抓包工具,它可以修改请求的数据,甚至可以修改服务器返回的数据,功能非常强大,是 Web 调试的利器。
-2. HttpWatch: 可用于录制 HTTP 请求信息的工具。
+1. **Fiddler**:抓包工具,它可以修改请求的数据,甚至可以修改服务器返回的数据,功能非常强大,是 Web 调试的利器。
+2. **HttpWatch**:可用于录制 HTTP 请求信息的工具。
-## 六 常见的性能优化策略
+## 常见的性能优化策略
性能优化之前我们需要对请求经历的各个环节进行分析,排查出可能出现性能瓶颈的地方,定位问题。
下面是一些性能优化时,我经常拿来自问的一些问题:
-1. 系统是否需要缓存?
-2. 系统架构本身是不是就有问题?
-3. 系统是否存在死锁的地方?
-4. 系统是否存在内存泄漏?(Java 的自动回收内存虽然很方便,但是,有时候代码写的不好真的会造成内存泄漏)
-5. 数据库索引使用是否合理?
-6. ……
+| 优化方向 | 检查项 |
+| ---------- | -------------------------------------------------------- |
+| **缓存** | 系统是否需要缓存?热点数据是否已缓存? |
+| **架构** | 系统架构本身是不是就有问题?是否需要读写分离、分库分表? |
+| **并发** | 系统是否存在死锁的地方?锁的粒度是否合理? |
+| **内存** | 系统是否存在内存泄漏?GC 是否频繁? |
+| **数据库** | 数据库索引使用是否合理?是否存在慢 SQL? |
+| **算法** | 核心算法的时间复杂度是否可以优化? |
+| **IO** | 是否存在不必要的网络调用?是否可以批量操作? |
diff --git a/docs/high-availability/redundancy.md b/docs/high-availability/redundancy.md
index 9d14d726675..25b088bad36 100644
--- a/docs/high-availability/redundancy.md
+++ b/docs/high-availability/redundancy.md
@@ -1,47 +1,197 @@
---
title: 冗余设计详解
+description: 本文系统讲解冗余设计核心知识,涵盖冗余类型(硬件/软件/数据/服务冗余)、RTO/RPO 指标、高可用集群(主备/主主模式)、同城灾备、异地灾备、同城多活、异地多活架构对比及故障转移机制,助力高可用架构设计与面试。
category: 高可用
icon: cluster
+head:
+ - - meta
+ - name: keywords
+ content: 冗余设计,高可用集群,同城灾备,异地灾备,同城多活,异地多活,故障转移,RTO,RPO,容灾架构
---
-冗余设计是保证系统和数据高可用的最常的手段。
+## 什么是冗余?
-对于服务来说,冗余的思想就是相同的服务部署多份,如果正在使用的服务突然挂掉的话,系统可以很快切换到备份服务上,大大减少系统的不可用时间,提高系统的可用性。
+**冗余(Redundancy)** 是保证系统和数据高可用的最常用手段,其核心思想是 **通过部署多份相同的资源,当某一份资源出现故障时,其他资源可以接管其工作,从而保证系统的持续可用**。
-对于数据来说,冗余的思想就是相同的数据备份多份,这样就可以很简单地提高数据的安全性。
+冗余设计可以从以下几个维度来理解:
-实际上,日常生活中就有非常多的冗余思想的应用。
+| 冗余类型 | 说明 | 典型实现 |
+| ------------ | ---------------------- | -------------------------------- |
+| **硬件冗余** | 关键硬件设备部署多份 | 双电源、双网卡、RAID 磁盘阵列 |
+| **软件冗余** | 应用服务部署多个实例 | 集群部署、容器化多副本 |
+| **数据冗余** | 数据存储多份副本 | 数据库主从复制、分布式存储多副本 |
+| **网络冗余** | 网络链路和设备冗余 | 多运营商接入、双活负载均衡 |
+| **地域冗余** | 在不同地理位置部署系统 | 同城灾备、异地多活 |
-拿我自己来说,我对于重要文件的保存方法就是冗余思想的应用。我日常所使用的重要文件都会同步一份在 GitHub 以及个人云盘上,这样就可以保证即使电脑硬盘损坏,我也可以通过 GitHub 或者个人云盘找回自己的重要文件。
+对于 **服务** 来说,冗余的思想就是相同的服务部署多份,如果正在使用的服务突然挂掉的话,系统可以很快切换到备份服务上,大大减少系统的不可用时间,提高系统的可用性。
+
+对于 **数据** 来说,冗余的思想就是相同的数据备份多份,这样就可以很简单地提高数据的安全性。
+
+实际上,日常生活中就有非常多的冗余思想的应用。拿我自己来说,我对于重要文件的保存方法就是冗余思想的应用。我日常所使用的重要文件都会同步一份在 GitHub 以及个人云盘上,这样就可以保证即使电脑硬盘损坏,我也可以通过 GitHub 或者个人云盘找回自己的重要文件。
+
+## 容灾核心指标:RTO 和 RPO
+
+在讨论容灾架构之前,需要先理解两个核心指标:
+
+```mermaid
+flowchart TB
+ subgraph Timeline["时间线"]
+ direction LR
+ A["上次备份"] --> B["故障发生"] --> C["系统恢复"]
+ end
+ A -.->|"数据丢失窗口(RPO)"| B
+ B -.->|"恢复时间窗口(RTO)"| C
+
+ classDef core fill:#4CA497,color:#fff,rx:10,ry:10
+ classDef highlight fill:#E99151,color:#fff,rx:10,ry:10
+
+ class A,B,C core
+
+ style Timeline fill:#F5F7FA,stroke:#E0E6ED,stroke-width:1.5px
+
+ linkStyle default stroke-width:1.5px,opacity:0.8
+```
+
+- **RPO(Recovery Point Objective,恢复点目标)**:可容忍的 **最大数据丢失量**,即从上次备份到故障发生之间的数据。RPO = 0 表示不允许丢失任何数据。
+- **RTO(Recovery Time Objective,恢复时间目标)**:可容忍的 **最大恢复时间**,即从故障发生到系统恢复正常服务的时间。RTO = 0 表示服务不能中断。
+
+| 架构方案 | RPO | RTO | 成本 |
+| ---------- | -------------- | ----------- | ---- |
+| 单机无备份 | 可能全部丢失 | 不可预估 | 低 |
+| 本地备份 | 取决于备份周期 | 小时级 | 低 |
+| 同城灾备 | 分钟级 | 分钟~小时级 | 中 |
+| 异地灾备 | 分钟~小时级 | 小时级 | 中高 |
+| 同城多活 | 秒级 | 秒级 | 高 |
+| 异地多活 | 秒级 | 秒级 | 很高 |
+
+## 冗余架构方案对比
高可用集群(High Availability Cluster,简称 HA Cluster)、同城灾备、异地灾备、同城多活和异地多活是冗余思想在高可用系统设计中最典型的应用。
-- **高可用集群** : 同一份服务部署两份或者多份,当正在使用的服务突然挂掉的话,可以切换到另外一台服务,从而保证服务的高可用。
-- **同城灾备**:一整个集群可以部署在同一个机房,而同城灾备中相同服务部署在同一个城市的不同机房中。并且,备用服务不处理请求。这样可以避免机房出现意外情况比如停电、火灾。
-- **异地灾备**:类似于同城灾备,不同的是,相同服务部署在异地(通常距离较远,甚至是在不同的城市或者国家)的不同机房中
-- **同城多活**:类似于同城灾备,但备用服务可以处理请求,这样可以充分利用系统资源,提高系统的并发。
-- **异地多活** : 将服务部署在异地的不同机房中,并且,它们可以同时对外提供服务。
+```mermaid
+flowchart TB
+ subgraph Grid["冗余架构方案对比"]
+ direction LR
+ style Grid fill:#F0F2F5,stroke:#E0E6ED,stroke-width:1.5px
+
+ subgraph HACluster["高可用集群"]
+ direction LR
+ style HACluster fill:#F5F7FA,stroke:#E0E6ED,stroke-width:1.5px
+ A1["主节点"] --> A2["从节点"]
+ end
+
+ subgraph LocalDR["同城灾备"]
+ direction LR
+ style LocalDR fill:#F5F7FA,stroke:#E0E6ED,stroke-width:1.5px
+ B1["主机房
(处理请求)"] -.->|"同步"| B2["备机房
(不处理请求)"]
+ end
+
+ subgraph RemoteDR["异地灾备"]
+ direction LR
+ style RemoteDR fill:#F5F7FA,stroke:#E0E6ED,stroke-width:1.5px
+ C1["主机房
北京"] -.->|"异步同步"| C2["备机房
上海"]
+ end
+
+ subgraph LocalActive["同城多活"]
+ direction LR
+ style LocalActive fill:#F5F7FA,stroke:#E0E6ED,stroke-width:1.5px
+ D1["机房A
(处理请求)"] <-->|"双向同步"| D2["机房B
(处理请求)"]
+ end
+
+ subgraph RemoteActive["异地多活"]
+ direction LR
+ style RemoteActive fill:#F5F7FA,stroke:#E0E6ED,stroke-width:1.5px
+ E1["北京机房
(处理请求)"] <-->|"双向同步"| E2["上海机房
(处理请求)"]
+ end
+ end
+
+ classDef core fill:#4CA497,color:#fff,rx:10,ry:10
+ classDef external fill:#005D7B,color:#fff,rx:10,ry:10
+
+ class A1,B1,C1,D1,D2,E1,E2 core
+ class A2,B2,C2 external
+
+ linkStyle default stroke-width:1.5px,opacity:0.8
+```
+
+### 高可用集群
+
+**高可用集群** 是指同一份服务部署两份或者多份,当正在使用的服务突然挂掉的话,可以切换到另外一台服务,从而保证服务的高可用。
-高可用集群单纯是服务的冗余,并没有强调地域。同城灾备、异地灾备、同城多活和异地多活实现了地域上的冗余。
+高可用集群有两种常见模式:
-同城和异地的主要区别在于机房之间的距离。异地通常距离较远,甚至是在不同的城市或者国家。
+| 模式 | 说明 | 优点 | 缺点 |
+| ------------------------------ | -------------------------- | ------------------------ | ------------------------------ |
+| **主备模式(Active-Standby)** | 主节点提供服务,备节点待命 | 实现简单,数据一致性好 | 资源利用率低,备节点闲置 |
+| **主主模式(Active-Active)** | 多个节点同时提供服务 | 资源利用率高,无单点故障 | 数据同步复杂,可能有一致性问题 |
-和传统的灾备设计相比,同城多活和异地多活最明显的改变在于“多活”,即所有站点都是同时在对外提供服务的。异地多活是为了应对突发状况比如火灾、地震等自然或者人为灾害。
+高可用集群单纯是服务的冗余,**并没有强调地域**。同城灾备、异地灾备、同城多活和异地多活实现了地域上的冗余。
-光做好冗余还不够,必须要配合上 **故障转移** 才可以! 所谓故障转移,简单来说就是实现不可用服务快速且自动地切换到可用服务,整个过程不需要人为干涉。
+### 同城灾备
-举个例子:哨兵模式的 Redis 集群中,如果 Sentinel(哨兵) 检测到 master 节点出现故障的话, 它就会帮助我们实现故障转移,自动将某一台 slave 升级为 master,确保整个 Redis 系统的可用性。整个过程完全自动,不需要人工介入。我在[《Java 面试指北》](https://www.yuque.com/docs/share/f37fc804-bfe6-4b0d-b373-9c462188fec7)的「技术面试题篇」中的数据库部分详细介绍了 Redis 集群相关的知识点&面试题,感兴趣的小伙伴可以看看。
+**同城灾备** 是指一整个集群可以部署在同一个机房,而同城灾备中相同服务部署在 **同一个城市的不同机房** 中。并且,**备用服务不处理请求**。这样可以避免机房出现意外情况比如停电、火灾。
-再举个例子:Nginx 可以结合 Keepalived 来实现高可用。如果 Nginx 主服务器宕机的话,Keepalived 可以自动进行故障转移,备用 Nginx 主服务器升级为主服务。并且,这个切换对外是透明的,因为使用的虚拟 IP,虚拟 IP 不会改变。我在[《Java 面试指北》](https://www.yuque.com/docs/share/f37fc804-bfe6-4b0d-b373-9c462188fec7)的「技术面试题篇」中的「服务器」部分详细介绍了 Nginx 相关的知识点&面试题,感兴趣的小伙伴可以看看。
+- **适用场景**:对 RTO 要求较高(分钟级),成本有限的企业。
+- **典型配置**:两个机房距离 30~100 公里,通过专线连接。
-异地多活架构实施起来非常难,需要考虑的因素非常多。本人不才,实际项目中并没有实践过异地多活架构,我对其了解还停留在书本知识。
+### 异地灾备
-如果你想要深入学习异地多活相关的知识,我这里推荐几篇我觉得还不错的文章:
+**异地灾备** 类似于同城灾备,不同的是,相同服务部署在 **异地(通常距离较远,甚至是在不同的城市或者国家)的不同机房中**。
-- [搞懂异地多活,看这篇就够了- 水滴与银弹 - 2021](https://mp.weixin.qq.com/s/T6mMDdtTfBuIiEowCpqu6Q)
+- **适用场景**:需要防范区域性灾难(地震、洪水)的核心业务系统。
+- **挑战**:网络延迟较大,数据同步通常采用异步方式,可能存在数据丢失。
+
+### 同城多活
+
+**同城多活** 类似于同城灾备,但 **备用服务可以处理请求**,这样可以充分利用系统资源,提高系统的并发。
+
+- **适用场景**:对性能和可用性都有较高要求的系统。
+- **技术要点**:需要解决数据同步、流量调度、会话管理等问题。
+
+### 异地多活
+
+**异地多活** 将服务部署在 **异地的不同机房** 中,并且,它们可以 **同时对外提供服务**。
+
+和传统的灾备设计相比,同城多活和异地多活最明显的改变在于 **"多活"**,即所有站点都是同时在对外提供服务的。异地多活是为了应对突发状况比如火灾、地震等自然或者人为灾害。
+
+同城和异地的主要区别在于 **机房之间的距离**。异地通常距离较远,甚至是在不同的城市或者国家。
+
+## 故障转移机制
+
+光做好冗余还不够,必须要配合上 **故障转移(Failover)** 才可以!所谓故障转移,简单来说就是 **实现不可用服务快速且自动地切换到可用服务,整个过程不需要人为干涉**。
+
+故障转移通常包含以下几个步骤:
+
+1. **故障检测**:通过心跳检测、健康检查等机制发现故障节点。
+2. **故障确认**:避免误判,通常需要多次检测确认。
+3. **故障切换**:将流量切换到备用节点。
+4. **故障通知**:发送告警通知运维人员。
+5. **故障恢复**:故障节点恢复后重新加入集群。
+
+### Redis 哨兵模式示例
+
+哨兵模式的 Redis 集群中,如果 Sentinel(哨兵)检测到 master 节点出现故障的话,它就会帮助我们实现故障转移,自动将某一台 slave 升级为 master,确保整个 Redis 系统的可用性。整个过程完全自动,不需要人工介入。
+
+### Nginx + Keepalived 示例
+
+Nginx 可以结合 Keepalived 来实现高可用。如果 Nginx 主服务器宕机的话,Keepalived 可以自动进行故障转移,备用 Nginx 主服务器升级为主服务。并且,这个切换对外是透明的,因为使用的 **虚拟 IP(VIP)**,虚拟 IP 不会改变。
+
+## 异地多活的挑战
+
+异地多活架构实施起来非常难,需要考虑的因素非常多:
+
+| 挑战 | 说明 | 解决思路 |
+| -------------- | ------------------------------ | ------------------------ |
+| **数据一致性** | 多个机房数据如何保持一致 | 最终一致性、冲突解决机制 |
+| **网络延迟** | 异地机房之间网络延迟较大 | 就近接入、数据分区 |
+| **流量调度** | 如何将用户请求分配到合适的机房 | DNS 智能解析、GSLB |
+| **会话管理** | 用户会话如何在多机房之间共享 | 分布式会话、无状态设计 |
+| **成本** | 多机房建设和运维成本高 | 按业务重要性分级部署 |
+
+如果你想要深入学习异地多活相关的知识,推荐以下资料:
+
+- [搞懂异地多活,看这篇就够了 - 水滴与银弹 - 2021](https://mp.weixin.qq.com/s/T6mMDdtTfBuIiEowCpqu6Q)
- [四步构建异地多活](https://mp.weixin.qq.com/s/hMD-IS__4JE5_nQhYPYSTg)
- [《从零开始学架构》— 28 | 业务高可用的保障:异地多活架构](http://gk.link/a/10pKZ)
-不过,这些文章大多也都是在介绍概念知识。目前,网上还缺少真正介绍具体要如何去实践落地异地多活架构的资料。
-
diff --git a/docs/high-availability/timeout-and-retry.md b/docs/high-availability/timeout-and-retry.md
index 3c7ba1ac9cd..c2bcabe8144 100644
--- a/docs/high-availability/timeout-and-retry.md
+++ b/docs/high-availability/timeout-and-retry.md
@@ -1,7 +1,12 @@
---
title: 超时&重试详解
+description: 本文系统讲解超时与重试机制核心知识,涵盖连接超时/读取超时设置原则、重试策略对比(固定间隔/指数退避/抖动退避)、重试风险(重试风暴/雪崩效应)及规避方法、幂等性设计、Java 重试框架(Spring Retry/Resilience4j)选型,助力微服务高可用设计与面试。
category: 高可用
icon: retry
+head:
+ - - meta
+ - name: keywords
+ content: 超时机制,重试机制,指数退避,重试风暴,幂等性,连接超时,读取超时,Spring Retry,Resilience4j,微服务高可用
---
由于网络问题、系统或者服务内部的 Bug、服务器宕机、操作系统崩溃等问题的不确定性,我们的系统或者服务永远不可能保证时刻都是可用的状态。
@@ -16,67 +21,177 @@ icon: retry
### 什么是超时机制?
-超时机制说的是当一个请求超过指定的时间(比如 1s)还没有被处理的话,这个请求就会直接被取消并抛出指定的异常或者错误(比如 `504 Gateway Timeout`)。
+**超时机制** 说的是当一个请求超过指定的时间(比如 1s)还没有被处理的话,这个请求就会直接被取消并抛出指定的异常或者错误(比如 `504 Gateway Timeout`)。
我们平时接触到的超时可以简单分为下面 2 种:
-- **连接超时(ConnectTimeout)**:客户端与服务端建立连接的最长等待时间。
-- **读取超时(ReadTimeout)**:客户端和服务端已经建立连接,客户端等待服务端处理完请求的最长时间。实际项目中,我们关注比较多的还是读取超时。
+| 超时类型 | 说明 | 建议值 |
+| ------------------------------ | ---------------------------------------------------------- | --------------- |
+| **连接超时(ConnectTimeout)** | 客户端与服务端建立连接的最长等待时间 | 1000ms ~ 5000ms |
+| **读取超时(ReadTimeout)** | 客户端和服务端已建立连接后,等待服务端处理完请求的最长时间 | 1000ms ~ 3000ms |
-一些连接池客户端框架中可能还会有获取连接超时和空闲连接清理超时。
+实际项目中,我们关注比较多的还是 **读取超时**。一些连接池客户端框架中可能还会有 **获取连接超时** 和 **空闲连接清理超时**。
-如果没有设置超时的话,就可能会导致服务端连接数爆炸和大量请求堆积的问题。
+### 为什么需要超时机制?
+
+如果没有设置超时的话,就可能会导致 **服务端连接数爆炸** 和 **大量请求堆积** 的问题。
这些堆积的连接和请求会消耗系统资源,影响新收到的请求的处理。严重的情况下,甚至会拖垮整个系统或者服务。
-我之前在实际项目就遇到过类似的问题,整个网站无法正常处理请求,服务器负载直接快被拉满。后面发现原因是项目超时设置错误加上客户端请求处理异常,导致服务端连接数直接接近 40w+,这么多堆积的连接直接把系统干趴了。
+> 我之前在实际项目就遇到过类似的问题,整个网站无法正常处理请求,服务器负载直接快被拉满。后面发现原因是项目超时设置错误加上客户端请求处理异常,导致服务端连接数直接接近 40w+,这么多堆积的连接直接把系统干趴了。
### 超时时间应该如何设置?
-超时到底设置多长时间是一个难题!超时值设置太高或者太低都有风险。如果设置太高的话,会降低超时机制的有效性,比如你设置超时为 10s 的话,那设置超时就没啥意义了,系统依然可能会出现大量慢请求堆积的问题。如果设置太低的话,就可能会导致在系统或者服务在某些处理请求速度变慢的情况下(比如请求突然增多),大量请求重试(超时通常会结合重试)继续加重系统或者服务的压力,进而导致整个系统或者服务被拖垮的问题。
+超时到底设置多长时间是一个难题!**超时值设置太高或者太低都有风险**:
+
+| 设置方式 | 风险 |
+| ------------ | ------------------------------------------------------------------------------------ |
+| **设置太高** | 降低超时机制的有效性,系统依然可能出现大量慢请求堆积的问题 |
+| **设置太低** | 在系统处理速度变慢时(如请求突然增多),大量请求超时重试,加重系统压力,可能导致雪崩 |
+
+通常情况下,我们建议:
-通常情况下,我们建议读取超时设置为 **1500ms** ,这是一个比较普适的值。如果你的系统或者服务对于延迟比较敏感的话,那读取超时值可以适当在 **1500ms** 的基础上进行缩短。反之,读取超时值也可以在 **1500ms** 的基础上进行加长,不过,尽量还是不要超过 **1500ms** 。连接超时可以适当设置长一些,建议在 **1000ms ~ 5000ms** 之内。
+- **读取超时**:设置为 **1500ms**,这是一个比较普适的值。如果系统对延迟比较敏感,可以适当缩短;反之也可以加长,但尽量不要超过 **3000ms**。
+- **连接超时**:可以适当设置长一些,建议在 **1000ms ~ 5000ms** 之内。
-没有银弹!超时值具体该设置多大,还是要根据实际项目的需求和情况慢慢调整优化得到。
+**没有银弹!** 超时值具体该设置多大,还是要根据实际项目的需求和情况慢慢调整优化得到。
-更上一层,参考[美团的 Java 线程池参数动态配置](https://tech.meituan.com/2020/04/02/java-pooling-pratice-in-meituan.html)思想,我们也可以将超时弄成可配置化的参数而不是固定的,比较简单的一种办法就是将超时的值放在配置中心中。这样的话,我们就可以根据系统或者服务的状态动态调整超时值了。
+更上一层,参考 [美团的 Java 线程池参数动态配置](https://tech.meituan.com/2020/04/02/java-pooling-pratice-in-meituan.html) 思想,我们也可以将超时弄成 **可配置化的参数** 而不是固定的,比较简单的一种办法就是将超时的值放在配置中心中。这样的话,我们就可以根据系统或者服务的状态动态调整超时值了。
## 重试机制
### 什么是重试机制?
-重试机制一般配合超时机制一起使用,指的是多次发送相同的请求来避免瞬态故障和偶然性故障。
+**重试机制** 一般配合超时机制一起使用,指的是 **多次发送相同的请求来避免瞬态故障和偶然性故障**。
-瞬态故障可以简单理解为某一瞬间系统偶然出现的故障,并不会持久。偶然性故障可以理解为哪些在某些情况下偶尔出现的故障,频率通常较低。
+- **瞬态故障**:某一瞬间系统偶然出现的故障,并不会持久。
+- **偶然性故障**:在某些情况下偶尔出现的故障,频率通常较低。
-重试的核心思想是通过消耗服务器的资源来尽可能获得请求更大概率被成功处理。由于瞬态故障和偶然性故障是很少发生的,因此,重试对于服务器的资源消耗几乎是可以被忽略的。
+重试的核心思想是 **通过消耗服务器的资源来尽可能获得请求更大概率被成功处理**。由于瞬态故障和偶然性故障是很少发生的,因此,重试对于服务器的资源消耗几乎是可以被忽略的。
### 常见的重试策略有哪些?
-常见的重试策略有两种:
-
-1. **固定间隔时间重试**:每次重试之间都使用相同的时间间隔,比如每隔 1.5 秒进行一次重试。这种重试策略的优点是实现起来比较简单,不需要考虑重试次数和时间的关系,也不需要维护额外的状态信息。但是这种重试策略的缺点是可能会导致重试过于频繁或过于稀疏,从而影响系统的性能和效率。如果重试间隔太短,可能会对目标系统造成过大的压力,导致雪崩效应;如果重试间隔太长,可能会导致用户等待时间过长,影响用户体验。
-2. **梯度间隔重试**:根据重试次数的增加去延长下次重试时间,比如第一次重试间隔为 1 秒,第二次为 2 秒,第三次为 4 秒,以此类推。这种重试策略的优点是能够有效提高重试成功的几率(随着重试次数增加,但是重试依然不成功,说明目标系统恢复时间比较长,因此可以根据重试次数延长下次重试时间),也能通过柔性化的重试避免对下游系统造成更大压力。但是这种重试策略的缺点是实现起来比较复杂,需要考虑重试次数和时间的关系,以及设置合理的上限和下限值。另外,这种重试策略也可能会导致用户等待时间过长,影响用户体验。
-
-这两种适合的场景各不相同。固定间隔时间重试适用于目标系统恢复时间比较稳定和可预测的场景,比如网络波动或服务重启。梯度间隔重试适用于目标系统恢复时间比较长或不可预测的场景,比如网络故障和服务故障。
+```mermaid
+flowchart TB
+ A["请求失败"] --> B{"是否可重试?"}
+ B -->|"否"| C["返回错误"]
+ B -->|"是"| D{"重试次数
是否超限?"}
+ D -->|"是"| C
+ D -->|"否"| E{"选择退避策略"}
+
+ E --> F["固定间隔"]
+ E --> G["线性退避"]
+ E --> H["指数退避"]
+ E --> I["指数退避+抖动"]
+
+ F --> J["等待固定时间"]
+ G --> K["等待 n × interval"]
+ H --> L["等待 2^n × interval"]
+ I --> M["等待 2^n × interval + random"]
+
+ J --> N["重试请求"]
+ K --> N
+ L --> N
+ M --> N
+
+ N --> O{"请求成功?"}
+ O -->|"是"| P["返回结果"]
+ O -->|"否"| D
+
+ classDef core fill:#4CA497,color:#fff,rx:10,ry:10
+ classDef decision fill:#00838F,color:#fff,rx:10,ry:10
+ classDef alert fill:#C44545,color:#fff,rx:10,ry:10
+ classDef highlight fill:#E99151,color:#fff,rx:10,ry:10
+
+ class A,N core
+ class B,D,E,O decision
+ class C alert
+ class P highlight
+ class F,G,H,I,J,K,L,M core
+
+ linkStyle default stroke-width:1.5px,opacity:0.8
+```
+
+常见的重试策略对比如下:
+
+| 策略 | 说明 | 优点 | 缺点 | 适用场景 |
+| ----------------- | --------------------------------- | ---------------------- | ---------------- | ------------------------------ |
+| **固定间隔重试** | 每次重试间隔相同(如每隔 1s) | 实现简单 | 可能造成重试风暴 | 目标系统恢复时间稳定可预测 |
+| **线性退避重试** | 间隔线性增长(如 1s、2s、3s) | 比固定间隔更温和 | 增长速度较慢 | 一般场景 |
+| **指数退避重试** | 间隔指数增长(如 1s、2s、4s、8s) | 能有效避免重试风暴 | 等待时间可能过长 | 目标系统恢复时间较长或不可预测 |
+| **指数退避+抖动** | 指数退避基础上加随机抖动 | 避免多个客户端同时重试 | 实现稍复杂 | 分布式系统推荐 |
+
+**大部分情况下,我们更建议使用指数退避+抖动策略**,可以有效避免重试风暴。
### 重试的次数如何设置?
重试的次数不宜过多,否则依然会对系统负载造成比较大的压力。
-重试的次数通常建议设为 3 次。大部分情况下,我们还是更建议使用梯度间隔重试策略,比如说我们要重试 3 次的话,第 1 次请求失败后,等待 1 秒再进行重试,第 2 次请求失败后,等待 2 秒再进行重试,第 3 次请求失败后,等待 3 秒再进行重试。
+**重试的次数通常建议设为 3 次**。比如说我们要重试 3 次的话:
+
+- 第 1 次请求失败后,等待 1 秒再进行重试
+- 第 2 次请求失败后,等待 2 秒再进行重试
+- 第 3 次请求失败后,等待 4 秒再进行重试
+
+### 重试的风险有哪些?
+
+重试机制虽然能提高系统的可用性,但使用不当也会带来风险:
+
+| 风险 | 说明 | 规避方法 |
+| ------------ | -------------------------------------------- | ---------------------------- |
+| **重试风暴** | 大量客户端同时重试,进一步压垮下游服务 | 使用指数退避+抖动策略 |
+| **雪崩效应** | 重试导致上游服务也开始超时重试,形成连锁反应 | 设置重试预算、熔断机制 |
+| **重复操作** | 非幂等操作被重复执行,导致数据不一致 | 确保操作幂等性 |
+| **资源浪费** | 对永久性故障进行无意义的重试 | 区分可重试错误和不可重试错误 |
+
+**重试预算(Retry Budget)** 是一种有效的规避策略:限制在一定时间窗口内的重试次数占总请求数的比例,如不超过 10%。
### 什么是重试幂等?
-超时和重试机制在实际项目中使用的话,需要注意保证同一个请求没有被多次执行。
+超时和重试机制在实际项目中使用的话,需要注意保证 **同一个请求没有被多次执行**。
什么情况下会出现一个请求被多次执行呢?客户端等待服务端完成请求完成超时但此时服务端已经执行了请求,只是由于短暂的网络波动导致响应在发送给客户端的过程中延迟了。
-举个例子:用户支付购买某个课程,结果用户支付的请求由于重试的问题导致用户购买同一门课程支付了两次。对于这种情况,我们在执行用户购买课程的请求的时候需要判断一下用户是否已经购买过。这样的话,就不会因为重试的问题导致重复购买了。
+> 举个例子:用户支付购买某个课程,结果用户支付的请求由于重试的问题导致用户购买同一门课程支付了两次。对于这种情况,我们在执行用户购买课程的请求的时候需要判断一下用户是否已经购买过。这样的话,就不会因为重试的问题导致重复购买了。
+
+实现幂等的常见方法:
+
+| 方法 | 说明 | 适用场景 |
+| ------------------ | -------------------------------------- | ---------------- |
+| **唯一请求 ID** | 每个请求携带唯一 ID,服务端去重 | 通用场景 |
+| **数据库唯一约束** | 利用数据库唯一索引防止重复插入 | 创建类操作 |
+| **乐观锁** | 通过版本号控制更新 | 更新类操作 |
+| **状态机** | 通过状态流转控制,已处理的状态不再处理 | 订单、支付等场景 |
### Java 中如何实现重试?
-如果要手动编写代码实现重试逻辑的话,可以通过循环(例如 while 或 for 循环)或者递归实现。不过,一般不建议自己动手实现,有很多第三方开源库提供了更完善的重试机制实现,例如 Spring Retry、Resilience4j、Guava Retrying。
+如果要手动编写代码实现重试逻辑的话,可以通过循环(例如 while 或 for 循环)或者递归实现。不过,一般不建议自己动手实现,有很多第三方开源库提供了更完善的重试机制实现:
+
+| 框架 | 特点 | 适用场景 |
+| ------------------ | ------------------------------------ | -------------------- |
+| **Spring Retry** | Spring 生态,注解驱动,配置简单 | Spring 项目 |
+| **Resilience4j** | 轻量级,函数式风格,支持熔断、限流等 | 微服务项目 |
+| **Guava Retrying** | 灵活的重试策略配置 | 通用 Java 项目 |
+| **Failsafe** | 支持异步重试、超时、熔断等 | 需要细粒度控制的场景 |
+
+使用 Spring Retry 的简单示例:
+
+```java
+@Retryable(
+ value = {RemoteAccessException.class},
+ maxAttempts = 3,
+ backoff = @Backoff(delay = 1000, multiplier = 2)
+)
+public String callRemoteService() {
+ // 调用远程服务
+}
+
+@Recover
+public String recover(RemoteAccessException e) {
+ // 重试失败后的兜底逻辑
+ return "fallback";
+}
+```
## 参考
diff --git a/docs/high-performance/cdn.md b/docs/high-performance/cdn.md
index f4ca0eab5f2..d16d2f0e46b 100644
--- a/docs/high-performance/cdn.md
+++ b/docs/high-performance/cdn.md
@@ -1,13 +1,11 @@
---
title: CDN工作原理详解
+description: 本文详解 CDN(内容分发网络)的核心原理,涵盖 GSLB 全局负载均衡调度机制、CDN 缓存策略(预热/回源/刷新)、命中率与回源率优化,以及 Referer 防盗链与时间戳防盗链等安全机制,帮助你全面掌握 CDN 加速技术。
category: 高性能
head:
- - meta
- name: keywords
- content: CDN,内容分发网络
- - - meta
- - name: description
- content: CDN 就是将静态资源分发到多个不同的地方以实现就近访问,进而加快静态资源的访问速度,减轻服务器以及带宽的负担。
+ content: CDN,内容分发网络,GSLB,CDN缓存,CDN回源,CDN预热,防盗链,时间戳防盗链,静态资源加速
---
## 什么是 CDN ?
@@ -16,35 +14,41 @@ head:
我们可以将内容分发网络拆开来看:
-- 内容:指的是静态资源比如图片、视频、文档、JS、CSS、HTML。
-- 分发网络:指的是将这些静态资源分发到位于多个不同的地理位置机房中的服务器上,这样,就可以实现静态资源的就近访问比如北京的用户直接访问北京机房的数据。
+- **内容**:指的是静态资源,包括图片、视频、文档、JS、CSS、HTML 等。
+- **分发网络**:指的是将这些静态资源分发到位于多个不同地理位置机房中的服务器上,从而实现**就近访问**——例如北京的用户直接访问北京机房的数据。
-所以,简单来说,**CDN 就是将静态资源分发到多个不同的地方以实现就近访问,进而加快静态资源的访问速度,减轻服务器以及带宽的负担。**
+简单来说,**CDN 就是将静态资源分发到多个不同的地方以实现就近访问,进而加快静态资源的访问速度,减轻源站服务器以及带宽的负担。**
-类似于京东建立的庞大的仓储运输体系,京东物流在全国拥有非常多的仓库,仓储网络几乎覆盖全国所有区县。这样的话,用户下单的第一时间,商品就从距离用户最近的仓库,直接发往对应的配送站,再由京东小哥送到你家。
+类似于京东建立的庞大仓储运输体系,京东物流在全国拥有非常多的仓库,仓储网络几乎覆盖全国所有区县。这样的话,用户下单的第一时间,商品就从距离用户最近的仓库直接发往对应的配送站,再由京东小哥送到你家。

-你可以将 CDN 看作是服务上一层的特殊缓存服务,分布在全国各地,主要用来处理静态资源的请求。
+你可以将 CDN 看作是服务上一层的**特殊缓存服务**,分布在全国各地,主要用来处理静态资源的请求。

-我们经常拿全站加速和内容分发网络做对比,不要把两者搞混了!全站加速(不同云服务商叫法不同,腾讯云叫 ECDN、阿里云叫 DCDN)既可以加速静态资源又可以加速动态资源,内容分发网络(CDN)主要针对的是 **静态资源** 。
+我们经常拿全站加速和内容分发网络做对比,不要把两者搞混了!**全站加速**(不同云服务商叫法不同,腾讯云叫 ECDN、阿里云叫 DCDN)既可以加速静态资源又可以加速动态资源,而**内容分发网络(CDN)** 主要针对的是 **静态资源** 。

绝大部分公司都会在项目开发中使用 CDN 服务,但很少会有自建 CDN 服务的公司。基于成本、稳定性和易用性考虑,建议直接选择专业的云厂商(比如阿里云、腾讯云、华为云、青云)或者 CDN 厂商(比如网宿、蓝汛)提供的开箱即用的 CDN 服务。
+### 为什么不直接将服务部署在多个不同的地方?
+
很多朋友可能要问了:**既然是就近访问,为什么不直接将服务部署在多个不同的地方呢?**
-- 成本太高,需要部署多份相同的服务。
-- 静态资源通常占用空间比较大且经常会被访问到,如果直接使用服务器或者缓存来处理静态资源请求的话,对系统资源消耗非常大,可能会影响到系统其他服务的正常运行。
+这涉及到**静态资源与动态请求的架构分离**问题:
+
+1. **成本问题**:多地部署完整服务需要部署多套应用、数据库、中间件,成本极高;而 CDN 只需存储静态资源,成本可控。
+2. **资源特性不同**:静态资源(图片、JS、CSS)具有**体积大、访问频繁、内容不变**的特点,非常适合缓存分发;动态请求需要实时计算,必须回源处理。
+3. **系统资源消耗**:如果用应用服务器直接处理静态资源请求,会大量占用 CPU、内存和带宽资源,可能影响核心业务的正常运行。
+4. **专业优化**:CDN 针对静态资源传输进行了大量优化(如智能压缩、协议优化、边缘计算),这些能力是普通应用服务器不具备的。
-同一个服务在在多个不同的地方部署多份(比如同城灾备、异地灾备、同城多活、异地多活)是为了实现系统的高可用而不是就近访问。
+> **注意**:同一个服务在多个不同地方部署多份(比如同城灾备、异地灾备、同城多活、异地多活)是为了实现系统的**高可用**,而不是就近访问。
## CDN 工作原理是什么?
-搞懂下面 3 个问题也就搞懂了 CDN 的工作原理:
+理解 CDN 的工作原理,需要搞懂以下三个核心问题:
1. 静态资源是如何被缓存到 CDN 节点中的?
2. 如何找到最合适的 CDN 节点?
@@ -52,79 +56,127 @@ head:
### 静态资源是如何被缓存到 CDN 节点中的?
-你可以通过 **预热** 的方式将源站的资源同步到 CDN 的节点中。这样的话,用户首次请求资源可以直接从 CDN 节点中取,无需回源。这样可以降低源站压力,提升用户体验。
+CDN 缓存静态资源的方式主要有两种:**预热**和**回源**。
+
+- **预热(Prefetch)**:主动将源站的资源推送到 CDN 节点中。这样用户首次请求资源时可以直接从 CDN 节点获取,无需回源,适用于大促活动、热点内容发布等场景。
+
+- **回源(Origin Pull)**:当 CDN 节点上没有用户请求的资源或该资源的缓存已过期时,CDN 节点需要从源站获取最新的资源内容。
-如果不预热的话,你访问的资源可能不在 CDN 节点中,这个时候 CDN 节点将请求源站获取资源,这个过程是大家经常说的 **回源**。
+> **注意**:当用户请求触发回源时,该请求的响应速度会比未使用 CDN 还慢,因为相比于直接访问源站,多了一层 CDN 节点的调用流程。因此,提高**缓存命中率**是 CDN 优化的关键目标。
-> - 回源:当 CDN 节点上没有用户请求的资源或该资源的缓存已经过期时,CDN 节点需要从原始服务器获取最新的资源内容,这个过程就是回源。当用户请求发生回源的话,会导致该请求的响应速度比未使用 CDN 还慢,因为相比于未使用 CDN 还多了一层 CDN 的调用流程。
-> - 预热:预热是指在 CDN 上提前将内容缓存到 CDN 节点上。这样当用户在请求这些资源时,能够快速地从最近的 CDN 节点获取到而不需要回源,进而减少了对源站的访问压力,提高了访问速度。
+CDN 缓存的完整生命周期如下图所示:
-
+
-如果资源有更新的话,你也可以对其 **刷新** ,删除 CDN 节点上缓存的旧资源,并强制 CDN 节点回源站获取最新资源。
+如果资源有更新,可以对其进行**刷新(Purge)**操作,删除 CDN 节点上缓存的旧资源,并强制 CDN 节点在下次请求时回源获取最新资源。
几乎所有云厂商提供的 CDN 服务都具备缓存的刷新和预热功能(下图是阿里云 CDN 服务提供的相应功能):

-**命中率** 和 **回源率** 是衡量 CDN 服务质量两个重要指标。命中率越高越好,回源率越低越好。
+**命中率**和**回源率**是衡量 CDN 服务质量的两个核心指标:
+
+- **命中率**:用户请求直接由 CDN 节点响应的比例,**越高越好**。
+- **回源率**:用户请求需要回源站获取的比例,**越低越好**。
### 如何找到最合适的 CDN 节点?
-GSLB (Global Server Load Balance,全局负载均衡)是 CDN 的大脑,负责多个 CDN 节点之间相互协作,最常用的是基于 DNS 的 GSLB。
+**GSLB(Global Server Load Balance,全局负载均衡)** 是 CDN 的大脑,负责多个 CDN 节点之间的协调调度,最常用的实现方式是**基于 DNS 的 GSLB**。
+
+CDN 请求的完整调度流程如下图所示:
+
+```mermaid
+sequenceDiagram
+ participant User as 用户浏览器
+ participant LocalDNS as 本地 DNS
+ participant AuthDNS as 权威 DNS
+ participant GSLB as CDN 全局负载均衡
+ participant Edge as CDN 边缘节点
+ participant Origin as 源站服务器
+
+ User->>LocalDNS: 1. 请求解析 cdn.example.com
+ LocalDNS->>AuthDNS: 2. 查询域名
+ AuthDNS-->>LocalDNS: 3. 返回 CNAME 记录指向 CDN
+ LocalDNS->>GSLB: 4. 请求 CDN 域名解析
+
+ Note over GSLB: 根据用户 IP、节点负载、
网络状况等选择最优节点
+
+ GSLB-->>LocalDNS: 5. 返回最优 CDN 节点 IP
+ LocalDNS-->>User: 6. 返回 CDN 节点 IP
+ User->>Edge: 7. 请求静态资源
+
+ alt 缓存命中
+ Edge-->>User: 8a. 直接返回缓存资源
+ else 缓存未命中
+ Edge->>Origin: 8b. 回源请求
+ Origin-->>Edge: 9. 返回资源
+ Note over Edge: 缓存资源
+ Edge-->>User: 10. 返回资源
+ end
+```
-CDN 会通过 GSLB 找到最合适的 CDN 节点,更具体点来说是下面这样的:
+**详细流程说明**:
-1. 浏览器向 DNS 服务器发送域名请求;
-2. DNS 服务器向根据 CNAME( Canonical Name ) 别名记录向 GSLB 发送请求;
-3. GSLB 返回性能最好(通常距离请求地址最近)的 CDN 节点(边缘服务器,真正缓存内容的地方)的地址给浏览器;
-4. 浏览器直接访问指定的 CDN 节点。
+1. 用户浏览器向本地 DNS 服务器发送域名解析请求。
+2. 本地 DNS 向权威 DNS 查询,发现该域名配置了 **CNAME(Canonical Name)别名记录**,指向 CDN 服务商的域名。
+3. 本地 DNS 继续向 CDN 的 **GSLB** 发起解析请求。
+4. GSLB 根据**用户 IP 地址、CDN 节点状态(负载、性能、响应时间、带宽)** 等指标,综合判断并返回最优 CDN 节点的 IP 地址。
+5. 用户浏览器直接向该 CDN 节点(边缘服务器)发起资源请求。
+6. CDN 节点检查本地缓存,若命中则直接返回;若未命中或已过期,则回源获取后再返回给用户。
-
+> **补充说明**:上图做了一定简化。实际上,GSLB 内部可以看作是 **CDN 专用 DNS 服务器**和**负载均衡系统**的组合。CDN 专用 DNS 服务器会返回负载均衡系统的 IP 地址,浏览器通过该 IP 请求负载均衡系统,进而找到对应的 CDN 节点。
-为了方便理解,上图其实做了一点简化。GSLB 内部可以看作是 CDN 专用 DNS 服务器和负载均衡系统组合。CDN 专用 DNS 服务器会返回负载均衡系统 IP 地址给浏览器,浏览器使用 IP 地址请求负载均衡系统进而找到对应的 CDN 节点。
+### 如何防止资源被盗刷?
-**GSLB 是如何选择出最合适的 CDN 节点呢?** GSLB 会根据请求的 IP 地址、CDN 节点状态(比如负载情况、性能、响应时间、带宽)等指标来综合判断具体返回哪一个 CDN 节点的地址。
+如果静态资源被其他用户或网站非法盗刷,将会产生大量额外的带宽费用。常见的防盗链机制有以下几种:
-### 如何防止资源被盗刷?
+| 防盗链机制 | 原理 | 安全强度 | 实现成本 | 绕过难度 |
+| ------------------ | --------------------------------------------- | -------- | -------- | -------------------------- |
+| **Referer 防盗链** | 根据 HTTP 请求头中的 Referer 字段判断请求来源 | 低 | 低 | 低(可伪造或置空 Referer) |
+| **时间戳防盗链** | URL 中携带签名和过期时间,过期后 URL 失效 | 中 | 中 | 中(需要获取签名算法) |
+| **IP 黑白名单** | 限制或允许特定 IP 地址访问 | 中 | 低 | 中(可通过代理绕过) |
+| **Token 鉴权** | 业务服务器生成 Token,CDN 节点校验 | 高 | 高 | 高 |
-如果我们的资源被其他用户或者网站非法盗刷的话,将会是一笔不小的开支。
+#### Referer 防盗链
-解决这个问题最常用最简单的办法设置 **Referer 防盗链**,具体来说就是根据 HTTP 请求的头信息里面的 Referer 字段对请求进行限制。我们可以通过 Referer 字段获取到当前请求页面的来源页面的网站地址,这样我们就能确定请求是否来自合法的网站。
+通过检查 HTTP 请求头中的 **Referer** 字段来判断请求来源是否合法。可以配置允许访问的域名白名单,非白名单来源的请求将被拒绝。
-CDN 服务提供商几乎都提供了这种比较基础的防盗链机制。
+CDN 服务提供商几乎都支持这种基础的防盗链机制:

-不过,如果站点的防盗链配置允许 Referer 为空的话,通过隐藏 Referer,可以直接绕开防盗链。
+> **注意**:如果防盗链配置允许 Referer 为空,攻击者可以通过隐藏 Referer 的方式绕过防盗链检查。因此,Referer 防盗链通常需要配合其他机制一起使用。
+
+#### 时间戳防盗链
-通常情况下,我们会配合其他机制来确保静态资源被盗用,一种常用的机制是 **时间戳防盗链** 。相比之下,**时间戳防盗链** 的安全性更强一些。时间戳防盗链加密的 URL 具有时效性,过期之后就无法再被允许访问。
+**时间戳防盗链**的安全性更强,其核心原理是:URL 中携带**签名字符串**和**过期时间**,CDN 节点在处理请求时会校验签名并检查是否过期,过期的 URL 将被拒绝访问。
-时间戳防盗链的 URL 通常会有两个参数一个是签名字符串,一个是过期时间。签名字符串一般是通过对用户设定的加密字符串、请求路径、过期时间通过 MD5 哈希算法取哈希的方式获得。
+签名字符串通常通过对**加密密钥 + 请求路径 + 过期时间**进行 MD5 哈希计算得到。
时间戳防盗链 URL 示例:
```plain
-http://cdn.wangsu.com/4/123.mp3? wsSecret=79aead3bd7b5db4adeffb93a010298b5&wsTime=1601026312
+http://cdn.example.com/video/123.mp4?wsSecret=79aead3bd7b5db4adeffb93a010298b5&wsTime=1601026312
```
-- `wsSecret`:签名字符串。
-- `wsTime`: 过期时间。
+- `wsSecret`:签名字符串,由服务端根据密钥和请求信息计算生成。
+- `wsTime`:过期时间戳(Unix 时间戳格式)。

-时间戳防盗链的实现也比较简单,并且可靠性较高,推荐使用。并且,绝大部分 CDN 服务提供商都提供了开箱即用的时间戳防盗链机制。
+绝大部分 CDN 服务提供商都支持开箱即用的时间戳防盗链机制:

-除了 Referer 防盗链和时间戳防盗链之外,你还可以 IP 黑白名单配置、IP 访问限频配置等机制来防盗刷。
+> **推荐实践**:生产环境建议采用 **Referer 防盗链 + 时间戳防盗链**的组合方案,兼顾安全性与实现成本。对于安全性要求极高的场景(如付费内容),可进一步引入 Token 鉴权机制。
## 总结
-- CDN 就是将静态资源分发到多个不同的地方以实现就近访问,进而加快静态资源的访问速度,减轻服务器以及带宽的负担。
-- 基于成本、稳定性和易用性考虑,建议直接选择专业的云厂商(比如阿里云、腾讯云、华为云、青云)或者 CDN 厂商(比如网宿、蓝汛)提供的开箱即用的 CDN 服务。
-- GSLB (Global Server Load Balance,全局负载均衡)是 CDN 的大脑,负责多个 CDN 节点之间相互协作,最常用的是基于 DNS 的 GSLB。CDN 会通过 GSLB 找到最合适的 CDN 节点。
-- 为了防止静态资源被盗用,我们可以利用 **Referer 防盗链** + **时间戳防盗链** 。
+- **CDN 的核心价值**:将静态资源分发到多个不同的地方以实现**就近访问**,加快静态资源的访问速度,减轻源站服务器及带宽的负担。
+- **CDN 服务选型**:基于成本、稳定性和易用性考虑,建议直接选择专业的云厂商(如阿里云、腾讯云、华为云)或 CDN 厂商(如网宿、蓝汛)提供的开箱即用服务。
+- **GSLB 的作用**:GSLB(全局负载均衡)是 CDN 的大脑,负责根据用户位置、节点状态等因素,将用户请求调度到**最优的 CDN 节点**。
+- **核心指标**:**命中率**越高越好,**回源率**越低越好。
+- **防盗链机制**:推荐采用 **Referer 防盗链 + 时间戳防盗链**的组合方案,平衡安全性与实现成本。
## 参考
diff --git a/docs/high-performance/data-cold-hot-separation.md b/docs/high-performance/data-cold-hot-separation.md
index 74ecef1e488..7fa47c7501f 100644
--- a/docs/high-performance/data-cold-hot-separation.md
+++ b/docs/high-performance/data-cold-hot-separation.md
@@ -1,68 +1,136 @@
---
title: 数据冷热分离详解
+description: 本文详解数据冷热分离的核心原理与实践方案,涵盖冷热数据的判定策略(时间维度/访问频率)、三种主流迁移方案对比(任务调度/Binlog监听)、冷数据存储选型(HBase/TiDB/对象存储),以及 TiDB Placement Rules 实现自动化冷热分离。
category: 高性能
head:
- - meta
- name: keywords
- content: 数据冷热分离,冷数据迁移,冷数据存储
- - - meta
- - name: description
- content: 数据冷热分离是指根据数据的访问频率和业务重要性,将数据分为冷数据和热数据,冷数据一般存储在存储在低成本、低性能的介质中,热数据高性能存储介质中。
+ content: 数据冷热分离,冷数据迁移,冷数据存储,分层存储,TiDB冷热分离,HBase,数据归档,存储成本优化
---
## 什么是数据冷热分离?
-数据冷热分离是指根据数据的访问频率和业务重要性,将数据分为冷数据和热数据,冷数据一般存储在存储在低成本、低性能的介质中,热数据高性能存储介质中。
+数据冷热分离是指根据数据的**访问频率**和**业务重要性**,将数据划分为冷数据和热数据,并分别存储在不同性能和成本的存储介质中的架构策略。
+
+这种架构的核心目标有三个:
+
+1. **提升查询性能**:热数据存储在高性能介质(如 SSD、内存)中,保障核心业务的响应速度。
+2. **降低存储成本**:冷数据迁移至低成本介质(如 HDD、对象存储),大幅削减存储开支。
+3. **满足合规要求**:部分行业(如金融、医疗)要求数据长期归档,冷热分离可兼顾合规与成本。
### 冷数据和热数据
-热数据是指经常被访问和修改且需要快速访问的数据,冷数据是指不经常访问,对当前项目价值较低,但需要长期保存的数据。
+**热数据**是指被频繁访问和修改、且需要快速响应的数据;**冷数据**是指访问频率极低、对当前业务价值较小、但需要长期保留的数据。
-冷热数据到底如何区分呢?有两个常见的区分方法:
+冷热数据的区分方法主要有两种:
-1. **时间维度区分**:按照数据的创建时间、更新时间、过期时间等,将一定时间段内的数据视为热数据,超过该时间段的数据视为冷数据。例如,订单系统可以将 1 年后的订单数据作为冷数据,1 年内的订单数据作为热数据。这种方法适用于数据的访问频率和时间有较强的相关性的场景。
-2. **访问评率区分**:将高频访问的数据视为热数据,低频访问的数据视为冷数据。例如,内容系统可以将浏览量非常低的文章作为冷数据,浏览量较高的文章作为热数据。这种方法需要记录数据的访问频率,成本较高,适合访问频率和数据本身有较强的相关性的场景。
+1. **时间维度区分**:按照数据的创建时间、更新时间或过期时间划分。例如,订单系统将 **1 年前**的订单数据标记为冷数据,1 年内的订单数据作为热数据。该方法适用于**数据访问频率与时间强相关**的场景,实现简单、成本低。
+2. **访问频率区分**:将高频访问的数据视为热数据,低频访问的数据视为冷数据。例如,内容系统将**浏览量低于阈值**的文章标记为冷数据。该方法需要额外记录访问频率,适用于**访问频率与数据本身特性强相关**的场景。
-几年前的数据并不一定都是热数据,例如一些优质文章发表几年后依然有很多人访问,大部分普通用户新发表的文章却基本没什么人访问。
+**如何选择区分策略?**
-这两种区分冷热数据的方法各有优劣,实际项目中,可以将两者结合使用。
+- 若业务数据天然具有时效性(如订单、日志、账单),优先选择**时间维度**,实现成本最低。
+- 若数据价值与时间无关(如文章、商品、用户画像),需结合**访问频率**进行判定。
+- 实际项目中,可将两者结合使用:以时间维度为主、访问频率为辅,覆盖更多业务场景。
### 冷热分离的思想
-冷热分离的思想非常简单,就是对数据进行分类,然后分开存储。冷热分离的思想可以应用到很多领域和场景中,而不仅仅是数据存储,例如:
+冷热分离的核心思想是**分层存储(Tiered Storage)**,根据数据的访问特性将其分配到不同层级的存储介质中。在企业级存储架构中,通常划分为以下层级:
-- 邮件系统中,可以将近期的比较重要的邮件放在收件箱,将比较久远的不太重要的邮件存入归档。
-- 日常生活中,可以将常用的物品放在显眼的位置,不常用的物品放入储藏室或者阁楼。
-- 图书馆中,可以将最受欢迎和最常借阅的图书单独放在一个显眼的区域,将较少借阅的书籍放在不起眼的位置。
-- ……
+| 层级 | 数据特性 | 典型存储介质 | 访问延迟 |
+| --------------------- | ------------------ | -------------------- | ----------- |
+| **Hot(热层)** | 高频访问、实时响应 | NVMe SSD、内存 | 毫秒级 |
+| **Warm(温层)** | 中频访问、近期数据 | SATA SSD、高速 HDD | 百毫秒级 |
+| **Cold(冷层)** | 低频访问、历史数据 | 大容量 HDD、对象存储 | 秒级 |
+| **Archive(归档层)** | 极少访问、合规留存 | 磁带库、冰川存储 | 分钟~小时级 |
+
+这种分层思想在 IT 基础设施中被广泛应用,不仅限于数据库,还包括文件系统、对象存储、CDN 缓存等场景。
### 数据冷热分离的优缺点
-- 优点:热数据的查询性能得到优化(用户的绝大部分操作体验会更好)、节约成本(可以冷热数据的不同存储需求,选择对应的数据库类型和硬件配置,比如将热数据放在 SSD 上,将冷数据放在 HDD 上)
-- 缺点:系统复杂性和风险增加(需要分离冷热数据,数据错误的风险增加)、统计效率低(统计的时候可能需要用到冷库的数据)。
+**优点:**
+
+- **热数据查询性能优化**:热数据集中在高性能存储上,表数据量大幅减少,索引效率显著提升,用户的绝大部分操作体验会更好。
+- **存储成本大幅降低**:冷数据可迁移至 HDD 或对象存储,**SSD 与 HDD 的单位成本差距可达 5~10 倍**,对于海量数据场景节省效果显著。
+- **系统可维护性增强**:热库数据量可控,备份恢复速度更快,DDL 操作(如加索引)耗时更短。
+
+**缺点:**
+
+- **系统复杂性增加**:需要额外的迁移组件、路由逻辑和监控体系,数据一致性风险增加。
+- **跨库查询效率低**:若业务需要同时查询冷热数据(如年度统计报表),需进行跨库关联或数据聚合,查询性能和开发成本均会上升。
+- **迁移策略维护成本**:冷热数据的判定规则需要持续调优,避免误判导致热数据被错误迁移。
## 冷数据如何迁移?
-冷数据迁移方案:
+冷数据迁移是冷热分离的核心环节,主流方案有以下三种:
+
+| 方案 | 实现原理 | 优点 | 缺点 | 适用场景 |
+| ------------------- | ---------------------------------------- | ---------------------- | -------------------------------------------- | ---------------------------- |
+| **业务层代码实现** | 写操作时判断冷热,直接路由到对应库 | 实时性高 | 侵入业务代码、判定逻辑复杂 | 几乎不使用 |
+| **任务调度迁移** | 定时任务扫描热库,批量迁移符合条件的数据 | 实现简单、对业务无侵入 | 存在迁移延迟、扫描大表有性能压力 | **时间维度区分场景(推荐)** |
+| **Binlog 监听迁移** | 监听数据库变更日志,实时或准实时迁移 | 实时性好、对业务无侵入 | 需要额外组件(如 Canal)、不适合时间维度判定 | 访问频率区分场景 |
+
+**任务调度迁移**是最常用的方案,可借助 XXL-Job、Elastic-Job 等分布式任务调度平台实现。关于任务调度的方案,我也写过文章详细介绍,可以查看这篇文章:[Java 定时任务详解](https://javaguide.cn/system-design/schedule-task.html) 。
-1. 业务层代码实现:当有对数据进行写操作时,触发冷热分离的逻辑,判断数据是冷数据还是热数据,冷数据就入冷库,热数据就入热库。这种方案会影响性能且冷热数据的判断逻辑不太好确定,还需要修改业务层代码,因此一般不会使用。
-2. 任务调度:可以利用 xxl-job 或者其他分布式任务调度平台定时去扫描数据库,找出满足冷数据条件的数据,然后批量地将其复制到冷库中,并从热库中删除。这种方法修改的代码非常少,非常适合按照时间区分冷热数据的场景。
-3. 监听数据库的变更日志 binlog :将满足冷数据条件的数据从 binlog 中提取出来,然后复制到冷库中,并从热库中删除。这种方法可以不用修改代码,但不适合按照时间维度区分冷热数据的场景。
+典型流程如下:
-如果你的公司有 DBA 的话,也可以让 DBA 进行冷数据的人工迁移,一次迁移完成冷数据到冷库。然后,再搭配上面介绍的方案实现后续冷数据的迁移工作。
+
+
+> **实践建议**:若公司有 DBA 支持,可先进行一次**存量冷数据的人工迁移**,将历史数据批量导入冷库;后续再通过任务调度实现**增量迁移**的自动化。
## 冷数据如何存储?
-冷数据的存储要求主要是容量大,成本低,可靠性高,访问速度可以适当牺牲。
+冷数据存储方案的选型原则是:**容量大、成本低、可靠性高,访问速度可适当牺牲**。
+
+### 中小厂方案
+
+直接使用 **MySQL/PostgreSQL** 即可,保持与热库相同的数据库类型,降低运维复杂度。具体实现方式:
-冷数据存储方案:
+- **同库分表**:在同一数据库中新增冷数据表(如 `order_history`),通过表名区分冷热数据。
+- **独立冷库**:部署单独的数据库实例作为冷库,热库与冷库通过应用层路由访问。
-- 中小厂:直接使用 MySQL/PostgreSQL 即可(不改变数据库选型和项目当前使用的数据库保持一致),比如新增一张表来存储某个业务的冷数据或者使用单独的冷库来存放冷数据(涉及跨库查询,增加了系统复杂性和维护难度)
-- 大厂:Hbase(常用)、RocksDB、Doris、Cassandra
+> **注意**:独立冷库方案涉及**跨库查询**,若业务存在冷热数据联合查询需求,需评估是否引入数据同步或聚合层。
-如果公司成本预算足的话,也可以直接上 TiDB 这种分布式关系型数据库,直接一步到位。TiDB 6.0 正式支持数据冷热存储分离,可以降低 SSD 使用成本。使用 TiDB 6.0 的数据放置功能,可以在同一个集群实现海量数据的冷热存储,将新的热数据存入 SSD,历史冷数据存入 HDD。
+### 大厂方案
+
+大厂通常采用专门针对海量数据优化的存储引擎:
+
+| 存储方案 | 特点 | 适用场景 |
+| ---------------------- | -------------------------------- | -------------------------------- |
+| **HBase** | 列式存储、高吞吐、支持 PB 级数据 | 日志、用户行为、IoT 数据归档 |
+| **RocksDB** | 高性能 KV 存储、LSM-Tree 结构 | 嵌入式场景、作为其他系统底层存储 |
+| **Doris/ClickHouse** | OLAP 引擎、支持实时分析 | 冷数据需要进行聚合分析的场景 |
+| **Cassandra** | 分布式、高可用、无单点故障 | 跨地域部署、高可用要求的归档场景 |
+| **对象存储(OSS/S3)** | 成本极低、无限扩展 | 超大规模冷数据、合规归档 |
+
+### TiDB 方案(推荐)
+
+如果公司技术栈允许,可以直接使用 **TiDB** 这类分布式关系型数据库,原生支持冷热分离,一步到位。
+
+TiDB 6.0 引入了 **基于 SQL 接口的数据放置框架(Placement Rules in SQL)** 功能,用于通过 SQL 接口配置数据在 TiKV 集群中的放置位置。
+
+- **热数据**:通过 Placement Rules 指定存储在 **SSD 节点**上,保障查询性能。
+- **冷数据**:指定存储在 **HDD 节点**上,降低存储成本。
+
+```sql
+-- 创建放置策略:热数据存储在 SSD 节点
+CREATE PLACEMENT POLICY hot_data
+ CONSTRAINTS="[+disk=ssd]";
+
+-- 创建放置策略:冷数据存储在 HDD 节点
+CREATE PLACEMENT POLICY cold_data
+ CONSTRAINTS="[+disk=hdd]";
+
+-- 对表或分区应用放置策略
+ALTER TABLE orders PLACEMENT POLICY = hot_data;
+ALTER TABLE orders PARTITION p2022 PLACEMENT POLICY = cold_data;
+```
+
+这种方案的优势在于:**业务无需感知冷热分离逻辑**,数据路由由 TiDB 自动完成,大幅降低了应用层的复杂度。
## 案例分享
- [如何快速优化几千万数据量的订单表 - 程序员济癫 - 2023](https://www.cnblogs.com/fulongyuanjushi/p/17910420.html)
- [海量数据冷热分离方案与实践 - 字节跳动技术团队 - 2022](https://mp.weixin.qq.com/s/ZKRkZP6rLHuTE1wvnqmAPQ)
+
+
diff --git a/docs/high-performance/deep-pagination-optimization.md b/docs/high-performance/deep-pagination-optimization.md
index 8a419dadb84..c43c057b527 100644
--- a/docs/high-performance/deep-pagination-optimization.md
+++ b/docs/high-performance/deep-pagination-optimization.md
@@ -1,13 +1,11 @@
---
title: 深度分页介绍及优化建议
+description: 深度分页是指查询偏移量过大导致性能下降的场景,本文详解深度分页产生的原因及四种优化方案:范围查询、子查询优化、INNER JOIN 延迟关联、覆盖索引,并分析各方案的适用场景与优缺点。
category: 高性能
head:
- - meta
- name: keywords
- content: 深度分页
- - - meta
- - name: description
- content: 查询偏移量过大的场景我们称为深度分页,这会导致查询性能较低。深度分页可以采用范围查询、子查询、INNER JOIN 延迟关联、覆盖索引等方法进行优化。
+ content: 深度分页,分页优化,LIMIT优化,MySQL分页,延迟关联,覆盖索引,游标分页
---
## 深度分页介绍
@@ -19,6 +17,18 @@ head:
SELECT * FROM t_order ORDER BY id LIMIT 1000000, 10
```
+## 深度分页问题的原因
+
+当查询偏移量过大时,MySQL 的查询优化器可能会选择全表扫描而不是利用索引来优化查询。这是因为扫描索引和跳过大量记录可能比直接全表扫描更耗费资源。
+
+
+
+不同机器上这个查询偏移量过大的临界点可能不同,取决于多个因素,包括硬件配置(如 CPU 性能、磁盘速度)、表的大小、索引的类型和统计信息等。
+
+
+
+MySQL 的查询优化器采用基于成本的策略来选择最优的查询执行计划。它会根据 CPU 和 I/O 的成本来决定是否使用索引扫描或全表扫描。如果优化器认为全表扫描的成本更低,它就会放弃使用索引。不过,即使偏移量很大,如果查询中使用了覆盖索引(covering index),MySQL 仍然可能会使用索引,避免回表操作。
+
## 深度分页优化建议
这里以 MySQL 数据库为例介绍一下如何优化深度分页。
@@ -34,7 +44,11 @@ SELECT * FROM t_order WHERE id > 100000 AND id <= 100010 ORDER BY id
SELECT * FROM t_order WHERE id > 100000 LIMIT 10
```
-这种优化方式限制比较大,且一般项目的 ID 也没办法保证完全连续。
+这种基于 ID 范围的深度分页优化方式存在很大限制:
+
+1. **ID 连续性要求高**: 实际项目中,数据库自增 ID 往往因为各种原因(例如删除数据、事务回滚等)导致 ID 不连续,难以保证连续性。
+2. **排序问题**: 如果查询需要按照其他字段(例如创建时间、更新时间等)排序,而不是按照 ID 排序,那么这种方法就不再适用。
+3. **并发场景**: 在高并发场景下,单纯依赖记录上次查询的最后一条记录的 ID 进行分页,容易出现数据重复或遗漏的问题。
### 子查询
@@ -47,32 +61,49 @@ SELECT * FROM t_order WHERE id > 100000 LIMIT 10
> 
```sql
-# 通过子查询来获取 id 的起始值,把 limit 1000000 的条件转移到子查询
-SELECT * FROM t_order WHERE id >= (SELECT id FROM t_order limit 1000000, 1) LIMIT 10;
+-- 先通过子查询在主键索引上进行偏移,快速找到起始ID
+SELECT * FROM t_order WHERE id >= (SELECT id FROM t_order LIMIT 1000000, 1) LIMIT 10;
```
+**工作原理**:
+
+1. 子查询 `(SELECT id FROM t_order where id > 1000000 limit 1)` 会利用主键索引快速定位到第 1000001 条记录,并返回其 ID 值。
+2. 主查询 `SELECT * FROM t_order WHERE id >= ... LIMIT 10` 将子查询返回的起始 ID 作为过滤条件,使用 `id >=` 获取从该 ID 开始的后续 10 条记录。
+
不过,子查询的结果会产生一张新表,会影响性能,应该尽量避免大量使用子查询。并且,这种方法只适用于 ID 是正序的。在复杂分页场景,往往需要通过过滤条件,筛选到符合条件的 ID,此时的 ID 是离散且不连续的。
当然,我们也可以利用子查询先去获取目标分页的 ID 集合,然后再根据 ID 集合获取内容,但这种写法非常繁琐,不如使用 INNER JOIN 延迟关联。
### 延迟关联
-延迟关联的优化思路,跟子查询的优化思路其实是一样的:都是把条件转移到主键索引树,减少回表的次数。不同点是,延迟关联使用了 INNER JOIN(内连接) 包含子查询。
+延迟关联与子查询的优化思路类似,都是通过将 `LIMIT` 操作转移到主键索引树上,减少回表次数。相比直接使用子查询,延迟关联通过 `INNER JOIN` 将子查询结果集成到主查询中,避免了子查询可能产生的临时表。在执行 `INNER JOIN` 时,MySQL 优化器能够利用索引进行高效的连接操作(如索引扫描或其他优化策略),因此在深度分页场景下,性能通常优于直接使用子查询。
```sql
-SELECT t1.* FROM t_order t1
-INNER JOIN (SELECT id FROM t_order limit 1000000, 10) t2
-ON t1.id = t2.id;
+-- 使用 INNER JOIN 进行延迟关联
+SELECT t1.*
+FROM t_order t1
+INNER JOIN (
+ -- 这里的子查询可以利用覆盖索引,性能极高
+ SELECT id FROM t_order LIMIT 1000000, 10
+) t2 ON t1.id = t2.id;
```
+**工作原理**:
+
+1. 子查询 `(SELECT id FROM t_order where id > 1000000 LIMIT 10)` 利用主键索引快速定位目标分页的 10 条记录的 ID。
+2. 通过 `INNER JOIN` 将子查询结果与主表 `t_order` 关联,获取完整的记录数据。
+
除了使用 INNER JOIN 之外,还可以使用逗号连接子查询。
```sql
+-- 使用逗号进行延迟关联
SELECT t1.* FROM t_order t1,
-(SELECT id FROM t_order limit 1000000, 10) t2
+(SELECT id FROM t_order where id > 1000000 LIMIT 10) t2
WHERE t1.id = t2.id;
```
+**注意**: 虽然逗号连接子查询也能实现类似的效果,但为了代码可读性和可维护性,建议使用更规范的 `INNER JOIN` 语法。
+
### 覆盖索引
索引中已经包含了所有需要获取的字段的查询方式称为覆盖索引。
@@ -89,7 +120,34 @@ ORDER BY code
LIMIT 1000000, 10;
```
-不过,当查询的结果集占表的总行数的很大一部分时,可能就不会走索引了,自动转换为全表扫描。当然了,也可以通过 `FORCE INDEX` 来强制查询优化器走索引,但这种提升效果一般不明显。
+**⚠️注意**:
+
+- 当查询的结果集占表的总行数的很大一部分时,MySQL 查询优化器可能选择放弃使用索引,自动转换为全表扫描。
+- 虽然可以使用 `FORCE INDEX` 强制查询优化器走索引,但这种方式可能会导致查询优化器无法选择更优的执行计划,效果并不总是理想。
+
+## 总结
+
+深度分页问题的根本原因在于:当 `LIMIT` 的偏移量过大时,MySQL 需要扫描并跳过大量记录才能获取目标数据,查询优化器可能放弃索引而选择全表扫描。此时即使有索引,也无法避免大量的回表操作,导致查询性能急剧下降。
+
+本文介绍了四种常见的深度分页优化方案,各方案的特点及适用场景对比如下:
+
+| 优化方案 | 核心思路 | 适用场景 | 限制 |
+| ------------ | ------------------------------------------------------------------- | ----------------------------------- | ------------------------------------------------ |
+| **范围查询** | 记录上一页最后一条 ID,通过 `WHERE id > last_id LIMIT n` 获取下一页 | ID 连续、按 ID 排序、允许游标式翻页 | 不支持跳页、ID 不连续时失效、非 ID 排序不适用 |
+| **子查询** | 先通过子查询获取起始主键,再根据主键过滤 | 需要支持传统 OFFSET 翻页 | 子查询可能产生临时表、仅适用于 ID 正序 |
+| **延迟关联** | 用 `INNER JOIN` 将分页转移到主键索引,减少回表 | 大数据量分页、需要传统翻页逻辑 | SQL 相对复杂 |
+| **覆盖索引** | 建立包含查询字段的联合索引,避免回表 | 查询字段固定、可建立合适索引 | 字段较多时索引维护成本高、大结果集可能走全表扫描 |
+
+**方案选择建议**:
+
+- **优先使用延迟关联**:对于大多数需要支持传统 `LIMIT offset, size` 翻页逻辑的场景,延迟关联是性能和可维护性较好的选择。
+- **考虑范围查询(游标分页)**:如果业务允许使用"下一页"式的游标翻页(如社交媒体 feed 流、无限滚动),范围查询性能最佳且稳定。
+- **覆盖索引作为补充**:当查询字段固定且数量不多时,可配合其他方案建立覆盖索引进一步优化。
+
+**注意事项**:
+
+- 无论采用哪种方案,都应注意监控实际执行计划(`EXPLAIN`),确保优化器按预期使用索引。
+- 对于超深分页(如百万级偏移量),应从业务层面评估是否真的需要支持,考虑限制最大翻页数或采用其他检索方式(如搜索引擎)。
## 参考
diff --git a/docs/high-performance/images/message-queue/message-queue-pub-sub-model.drawio b/docs/high-performance/images/message-queue/message-queue-pub-sub-model.drawio
deleted file mode 100644
index 7a28c60d6db..00000000000
--- a/docs/high-performance/images/message-queue/message-queue-pub-sub-model.drawio
+++ /dev/null
@@ -1 +0,0 @@
-7Zttc6M2EIB/DTN3H+IRCPHy0SSmnfZuejPpTNtPHQwy5oIRB3Li3K+vBBLmRY59iR27PpwZB62EAO2j3dUKa/B2tfmlCPLlZxLhVDNAtNHgnWYYOjBc9o9LnmuJC2AtiIskEo22gvvkO5ZnCuk6iXDZaUgJSWmSd4UhyTIc0o4sKAry1G22IGn3qnkQ44HgPgzSofSvJKLLWuoY9lb+K07ipbyybokHngfhQ1yQdSaupxnQt3zfd+rqVSD7Eg9aLoOIPLVEcKbB24IQWh+tNrc45WMrh60+z99R29x3gTN6yAnfpnfu796j+zA34Hcr/Pz17rt9o4vHfAzSNZbPUd0tfZYjVD0j5r3oGvSelgnF93kQ8tonxgSTLekqFdWLJE1vSUqK6lyI9Qhhm8lLWpAH3KpxLRsGFj+DZNQPVknKAfoNU68Ikqxkt/CZZETU35N1UV1wSSnjwkBwyr7Yo/Iv3qCcxITEKQ7ypJyEZFVVhGXV1F/UvbPDdv/I8MQVxBDgguLNzsHVG5WxqYDJCtOCdQnECQgYE1SfJOaBaQq9P22pMpAtWy3bTIFGHAia4+YKW42yA6HUH1GwQr9WSsW4dhRtfVsTWXFTVhOVjTLQUb7ZVrKjmP//UpBoHeJC9qbVepDVA4jYrbIZjfcDFJR5Pc0XyYZD1yfKvJ2arpIoK3TwfMFrHjAN+ZADXpDTDlwLa4YDuqRZQ9JkkzZmUnZ0xoz9NgRn0ZQba1YK06Ask7BnNvjA175BR6LcUi2zqgBwBeJNQv8W5/Djf7hiJ0iU7jZCz1XhWRKwE4caIWn2jQsCBEcDr9XDgw2vvI8X5/4QoxYmSIGJlBU4DWjy2L0NFTviCl9IUhkUQSm0u5QivYdfffvirLbf6nWEQK8j0OuIBkWM6aCjCuXmsV9PN9xPdwtkPrMTFl18CuY4/ULKhCYkY3VzQikjgdm3NIm5IGTqZOYTeilv6TXBRIv6RfVpdToV51KSK+kdmEQAHOAPsI+Ccln5c1GT88dYbWIe4E0SUtqThEVb5QRnjzglOf73wwqXJQuEPv7ATO3MukuZVm+yu9Dt2V1b4eEVE8o4ld0195MptRs+pwkL4or9/ndeR3uf5o2gQfOPNWW9YCEvCA0E3C4Y+ukF4n9KP119hujUn+v338jqcQSb2LFFElQFivBUYSIajdxo5GoYL8zIWSOZI5mV2UQ9Mt0zk3lA6mQk86cg07gwMh0FmXWapsyD7PVJn1um/PVqm/SZF9t8T5MGqi9x6jTQwglxGKrQnjvIRC+v+6+COre3LoZIFUa+ayLI3W8RW4mgjPC1xFuyQPL42FmgkgYFHearKrGfpPJ22bOI0mVAxcApnpvsGC+0BoYXtyNTleTQaMdLOQnLU6dkXminO2q23yc3Zbu9XL31ytyU3U8J9Ds6cW7KPIKlh8zScwZaG1o3YT3peH0Rzz8Y/DrsDgFzbeIAgY+12e+5CG1maZ6lOY42czTP1Dy3JWHfujb1D3YWjMgqXOq5hn6g1I+CVkkU8T68ArPnDOZVfxz0nOuh0gzyNHR3wKTdaafFdqToXGt8eXvS7LaRO436DZiYyHWOQrmcjILNm14HZLEo8Umo1BWbTjOkOb7m6trM1Zj/YRwosOjpn4057aq+6+yFA2lHBkJ0OCKqMGS74bk/jJDuCl6EBzhCWGH20+36MJi13jOY1RXbS0N2GFZs2rh+BRo7sP/XNMng517cvn4tdKF+eunAvcvT0XVAEn1cxf8Mq3inZ/hM48yreOgqIyJpE4Rx6poXUtAliUnGCCWcograr5jSZ6HUYE1J1wo2q4ZmYXDYquESN89f83rAEVc/+qHLH8NUo/jWdU0vRTrYKt+xrjnacsQaiT3j6x4H0wffSN/bPO5wzfonyZNwgM5b3jtcLLClTg9GtjsHV+O0LKeXyXAOi9bhCd84VGQBZ6bmOZrn8TB9amhygTtG5xdGk70jL3au6Fx2rM4kIA6VA3mmaTrjXNWLQDYl2LJwyoRVG3emOcPc0pC3loYNWe7E2gD4/rVouv8OKVTYDVtlN06madVrduOe1ZXvWZnImdhn3rQyVBmAkbwrJw9Z9rl3S02FcxtTT2PqiefcVXC+77vF49tNI5zqvOi53wg1Va+ZjJveF7LpvQcmBCYAmBA40NWhZXa3qE2XhYMmggjquuPw31CeCCGkcr0jQleAEI/rTLeF0A8TxIrbH2XXmfHtL9/h7D8=
\ No newline at end of file
diff --git a/docs/high-performance/images/message-queue/message-queue-pub-sub-model.png b/docs/high-performance/images/message-queue/message-queue-pub-sub-model.png
deleted file mode 100644
index a5a77736493..00000000000
Binary files a/docs/high-performance/images/message-queue/message-queue-pub-sub-model.png and /dev/null differ
diff --git a/docs/high-performance/images/message-queue/message-queue-queue-model.drawio b/docs/high-performance/images/message-queue/message-queue-queue-model.drawio
deleted file mode 100644
index ea1c6caca4b..00000000000
--- a/docs/high-performance/images/message-queue/message-queue-queue-model.drawio
+++ /dev/null
@@ -1 +0,0 @@
-7Zrbcts2EEC/hjPtQzS8iLdHSRGTdpLUidpp89SBSIjCGCRoELKkfH0XJCDxZstJ7Vp2Jc9YwAJcAtyDXWBFw5llu3ccFeuPLMHUsM1kZzhvDdu2TDuELynZ15LQdGpBykmiOh0FC/IN6yuVdEMSXLY6CsaoIEVbGLM8x7FoyRDnbNvutmK0fdcCpbgnWMSI9qV/kkSsa2lg+0f5e0zStb6z5akJL1F8nXK2ydX9DNuJvCiKgro5Q1qXmmi5RgnbNkTO3HBmnDFRl7LdDFP5bPVjq6+L7mg9jJvjXDzkgpvpl6Vd7vPZ+yj8vP32x++/5J/eKC23iG6wnoZHQd90xUAtjFrs1ZPybjZMN7wpKztOoIPlFrtjI5RS+X3FWbKJMdfaYFi1wrpZPZGDbhuGCgaHynS7JgIvChTLli0wB7K1yCjULCiisqgpWJEdTuRwCKUzRhmvFDnj2WQc+iAvBWfXuNHixQFermTLNRaxtI0pK9oqpppahDJCJcu/YjHliOQljPQjy5lqX7ANr8a2FgIQtV35FFx46vKf7FCOUsZSilFBylHMsqohLquu0arWDsWmfteeqjv0jaothLnAu4ZIGfkdZhkWHFSaqnWsgVML0nFUfdvAW4nWDbK1DKkFlR40H6GCguLqOxizBxjrmj9PJnItQy2mqCxJ3LZ69eBr12G5qt4wLSw605QGxDsi/lLXyPJXadiRq2pvd8rOVWWvCbgThxoh7RXs8wak1Lc9sdRx0vKHfYwamLgDmGgZxxQJctv2okPsqDtcMVI5FEWpa3Uo7eJXz0dd1XRrHUXeuKPI7ygSiKdY9BRVKB+m/eN0O6fpboAslzGB4PMBLTG9YiURhOXQtmRCAAng3yhJpSAG+4L7dKZU9pweYk2D+lX1aSidqGsFKwbp7blE0wzMqId9gsq1dK16QRRyGtkulfF/RFjpjwgE43KE81tMWYH//inDZQlx8ufvWKmtVXfGy+rhftd1OiCO+37XHlhQ9lP53fFpMrV14z0leVLxdiL+LiWEOPmwPAgOaP62EaAFKzlnAim4Q7Mfp1eu/BuM09Wnj079ef3x2zc7HJk2RLAuSY7bJ8nSwkdHyb04uYuTq5ycf2ZOzruQeSGz2geGHTK9ZybTv5B5IbMi0zszMoMBMus0TVmg/MeTPjMw/iY7Jn2W/JjvOaSB6ls8dRpoFcQ4jofQXgbu2L3/3P8qqAu7B+znTgOFp/1hIw2UM3mS+Dc5IF1+7BxQKRAX/WxVJY4I1cOFuajamSN1MnGk3EWdR7mnnzpvnkmCye/uB7p5oYcmmAL3eRNMeh1fNhL/941Ej8TnPnxZAz8fzV0jiIzQMuahAbEkgIJnTD0jCGQBqpOohy/MX7SdfRskFQyaMV6JejB3Sc1IktC7NhS8TmadDAJN5JzXQpPbTZwHfZq8/5SmgVR6nx3AKgyMMKpAg4L/omnSDmyhhm+9Frq8bqLogdvPp6NrIB3eZwkwmwTGxJKScG4E/SNKn66GPW1db4VA04yi13/g6P7ufNjINSzuD1jceQSLz/fO18+EF5vC/fTF5zdsnHiD7zZc9k0vc9/Uw3AA1rv3Tab/bPumQTLt+wJdYEzHxjS8bJteCExPt22C6vH1sPo4eXwHz5n/Aw==
\ No newline at end of file
diff --git a/docs/high-performance/images/message-queue/message-queue-queue-model.png b/docs/high-performance/images/message-queue/message-queue-queue-model.png
deleted file mode 100644
index 8fb2cd6a7ba..00000000000
Binary files a/docs/high-performance/images/message-queue/message-queue-queue-model.png and /dev/null differ
diff --git a/docs/high-performance/load-balancing.md b/docs/high-performance/load-balancing.md
index b0b88e0a872..a7724eff5e5 100644
--- a/docs/high-performance/load-balancing.md
+++ b/docs/high-performance/load-balancing.md
@@ -1,13 +1,11 @@
---
title: 负载均衡原理及算法详解
+description: 本文详解负载均衡的核心原理,涵盖四层/七层负载均衡区别、服务端与客户端负载均衡对比,深入讲解轮询、加权轮询、随机、一致性哈希等常见负载均衡算法,以及 Nginx、LVS 等主流实现方案。
category: 高性能
head:
- - meta
- name: keywords
- content: 客户端负载均衡,服务负载均衡,Nginx,负载均衡算法,七层负载均衡,DNS解析
- - - meta
- - name: description
- content: 负载均衡指的是将用户请求分摊到不同的服务器上处理,以提高系统整体的并发处理能力。负载均衡可以简单分为服务端负载均衡和客户端负载均衡 这两种。服务端负载均衡涉及到的知识点更多,工作中遇到的也比较多,因为,我会花更多时间来介绍。
+ content: 负载均衡,四层负载均衡,七层负载均衡,Nginx负载均衡,LVS,负载均衡算法,轮询,一致性哈希,客户端负载均衡
---
## 什么是负载均衡?
@@ -24,7 +22,7 @@ head:
负载均衡可以简单分为 **服务端负载均衡** 和 **客户端负载均衡** 这两种。
-服务端负载均衡涉及到的知识点更多,工作中遇到的也比较多,因为,我会花更多时间来介绍。
+服务端负载均衡涉及到的知识点更多,工作中遇到的也比较多,因此,我会花更多时间来介绍。
### 服务端负载均衡
@@ -85,7 +83,7 @@ head:
客户端负载均衡器和服务运行在同一个进程或者说 Java 程序里,不存在额外的网络开销。不过,客户端负载均衡的实现会受到编程语言的限制,比如说 Spring Cloud Load Balancer 就只能用于 Java 语言。
-Java 领域主流的微服务框架 Dubbo、Spring Cloud 等都内置了开箱即用的客户端负载均衡实现。Dubbo 属于是默认自带了负载均衡功能,Spring Cloud 是通过组件的形式实现的负载均衡,属于可选项,比较常用的是 Spring Cloud Load Balancer(官方,推荐) 和 Ribbon(Netflix,已被启用)。
+Java 领域主流的微服务框架 Dubbo、Spring Cloud 等都内置了开箱即用的客户端负载均衡实现。Dubbo 属于是默认自带了负载均衡功能,Spring Cloud 是通过组件的形式实现的负载均衡,属于可选项,比较常用的是 Spring Cloud Load Balancer(官方,推荐) 和 Ribbon(Netflix,已被弃用)。
下图是我画的一个简单的基于 Spring Cloud Load Balancer(Ribbon 也类似) 的客户端负载均衡示意图:
diff --git a/docs/high-performance/message-queue/disruptor-questions.md b/docs/high-performance/message-queue/disruptor-questions.md
index 1881f6c2c79..efd7f24be0b 100644
--- a/docs/high-performance/message-queue/disruptor-questions.md
+++ b/docs/high-performance/message-queue/disruptor-questions.md
@@ -1,8 +1,13 @@
---
title: Disruptor常见问题总结
+description: 本文总结 Disruptor 高性能内存队列的核心知识与面试要点,涵盖 Disruptor 架构(RingBuffer/Sequencer/WaitStrategy)、高性能原理(无锁设计/缓存行填充/预分配内存)、与 ArrayBlockingQueue 对比、生产者消费者模式等,助力 Disruptor 学习与面试。
category: 高性能
tag:
- 消息队列
+head:
+ - - meta
+ - name: keywords
+ content: Disruptor,高性能队列,RingBuffer,无锁队列,缓存行填充,LMAX,内存队列,Disruptor面试
---
Disruptor 是一个相对冷门一些的知识点,不过,如果你的项目经历中用到了 Disruptor 的话,那面试中就很可能会被问到。
@@ -49,7 +54,7 @@ Disruptor 主要解决了 JDK 内置线程安全队列的性能和内存安全
| `LinkedTransferQueue` | 无锁(`CAS`) | 无界 |
| `ConcurrentLinkedQueue` | 无锁(`CAS`) | 无界 |
-从上表中可以看出:这些队列要不就是加锁有界,要不就是无锁无界。而加锁的的队列势必会影响性能,无界的队列又存在内存溢出的风险。
+从上表中可以看出:这些队列要不就是加锁有界,要不就是无锁无界。而加锁的队列势必会影响性能,无界的队列又存在内存溢出的风险。
因此,一般情况下,我们都是不建议使用 JDK 内置线程安全队列。
diff --git a/docs/high-performance/message-queue/kafka-questions-01.md b/docs/high-performance/message-queue/kafka-questions-01.md
index 7e690399689..b8034f7c875 100644
--- a/docs/high-performance/message-queue/kafka-questions-01.md
+++ b/docs/high-performance/message-queue/kafka-questions-01.md
@@ -1,8 +1,13 @@
---
title: Kafka常见问题总结
+description: 本文总结 Kafka 常见面试题与核心知识点,涵盖 Kafka 架构(Broker/Topic/Partition/Consumer Group)、高性能原理(零拷贝/顺序写/批量处理)、消息可靠性(ACK机制/ISR副本)、消息顺序性、Rebalance 机制、Kafka 与 RocketMQ 对比等,助力 Kafka 学习与面试。
category: 高性能
tag:
- 消息队列
+head:
+ - - meta
+ - name: keywords
+ content: Kafka,消息队列,Kafka分区,Kafka副本,ISR,消费者组,Rebalance,零拷贝,Kafka面试
---
## Kafka 基础
@@ -120,7 +125,7 @@ ZooKeeper 主要为 Kafka 提供元数据的管理的功能。
不过,要提示一下:**如果要使用 KRaft 模式的话,建议选择较高版本的 Kafka,因为这个功能还在持续完善优化中。Kafka 3.3.1 版本是第一个将 KRaft(Kafka Raft)共识协议标记为生产就绪的版本。**
-
+
## Kafka 消费顺序、消息丢失和重复消费
diff --git a/docs/high-performance/message-queue/message-queue.md b/docs/high-performance/message-queue/message-queue.md
index 4e7353a5a56..ea8a25c6613 100644
--- a/docs/high-performance/message-queue/message-queue.md
+++ b/docs/high-performance/message-queue/message-queue.md
@@ -1,8 +1,13 @@
---
title: 消息队列基础知识总结
+description: 本文系统总结消息队列的核心知识,涵盖消息队列的应用场景(异步处理/解耦/削峰)、消息模型(点对点/发布订阅)、如何保证消息不丢失、消息幂等性、消息顺序性、消息积压处理等常见问题,以及 Kafka、RocketMQ、RabbitMQ 技术选型对比。
category: 高性能
tag:
- 消息队列
+head:
+ - - meta
+ - name: keywords
+ content: 消息队列,MQ,异步解耦,削峰填谷,消息丢失,消息幂等,消息顺序,Kafka,RocketMQ,RabbitMQ
---
::: tip
@@ -23,9 +28,9 @@ tag:
参与消息传递的双方称为 **生产者** 和 **消费者** ,生产者负责发送消息,消费者负责处理消息。
-
+
-我们知道操作系统中的进程通信的一种很重要的方式就是消息队列。我们这里提到的消息队列稍微有点区别,更多指的是各个服务以及系统内部各个组件/模块之前的通信,属于一种 **中间件** 。
+操作系统中的进程通信的一种很重要的方式就是消息队列。我们这里提到的消息队列稍微有点区别,更多指的是各个服务以及系统内部各个组件/模块之前的通信,属于一种 **中间件** 。
维基百科是这样介绍中间件的:
@@ -41,19 +46,21 @@ tag:
## 消息队列有什么用?
-通常来说,使用消息队列能为我们的系统带来下面三点好处:
+通常来说,使用消息队列主要能为我们的系统带来下面三点好处:
-1. **通过异步处理提高系统性能(减少响应所需时间)**
-2. **削峰/限流**
-3. **降低系统耦合性。**
+1. 异步处理
+2. 削峰/限流
+3. 降低系统耦合性
+
+除了这三点之外,消息队列还有其他的一些应用场景,例如实现分布式事务、顺序保证和数据流处理。
如果在面试的时候你被面试官问到这个问题的话,一般情况是你在你的简历上涉及到消息队列这方面的内容,这个时候推荐你结合你自己的项目来回答。
-### 通过异步处理提高系统性能(减少响应所需时间)
+### 异步处理

-将用户的请求数据存储到消息队列之后就立即返回结果。随后,系统再对消息进行消费。
+将用户请求中包含的耗时操作,通过消息队列实现异步处理,将对应的消息发送到消息队列之后就立即返回结果,减少响应时间,提高用户体验。随后,系统再对消息进行消费。
因为用户请求数据写入消息队列之后就立即返回给用户了,但是请求数据在后续的业务校验、写数据库等操作中可能失败。因此,**使用消息队列进行异步处理之后,需要适当修改业务流程进行配合**,比如用户在提交订单之后,订单数据写入消息队列,不能立即返回用户订单提交成功,需要在消息队列的订单消费者进程真正处理完该订单之后,甚至出库后,再通过电子邮件或短信通知用户订单成功,以免交易纠纷。这就类似我们平时手机订火车票和电影票。
@@ -67,15 +74,17 @@ tag:
### 降低系统耦合性
-使用消息队列还可以降低系统耦合性。我们知道如果模块之间不存在直接调用,那么新增模块或者修改模块就对其他模块影响较小,这样系统的可扩展性无疑更好一些。还是直接上图吧:
+使用消息队列还可以降低系统耦合性。如果模块之间不存在直接调用,那么新增模块或者修改模块就对其他模块影响较小,这样系统的可扩展性无疑更好一些。
-
+生产者(客户端)发送消息到消息队列中去,消费者(服务端)处理消息,需要消费的系统直接去消息队列取消息进行消费即可而不需要和其他系统有耦合,这显然也提高了系统的扩展性。
-生产者(客户端)发送消息到消息队列中去,接受者(服务端)处理消息,需要消费的系统直接去消息队列取消息进行消费即可而不需要和其他系统有耦合,这显然也提高了系统的扩展性。
+
**消息队列使用发布-订阅模式工作,消息发送者(生产者)发布消息,一个或多个消息接受者(消费者)订阅消息。** 从上图可以看到**消息发送者(生产者)和消息接受者(消费者)之间没有直接耦合**,消息发送者将消息发送至分布式消息队列即结束对消息的处理,消息接受者从分布式消息队列获取该消息后进行后续处理,并不需要知道该消息从何而来。**对新增业务,只要对该类消息感兴趣,即可订阅该消息,对原有系统和业务没有任何影响,从而实现网站业务的可扩展性设计**。
-消息接受者对消息进行过滤、处理、包装后,构造成一个新的消息类型,将消息继续发送出去,等待其他消息接受者订阅该消息。因此基于事件(消息对象)驱动的业务架构可以是一系列流程。
+例如,我们商城系统分为用户、订单、财务、仓储、消息通知、物流、风控等多个服务。用户在完成下单后,需要调用财务(扣款)、仓储(库存管理)、物流(发货)、消息通知(通知用户发货)、风控(风险评估)等服务。使用消息队列后,下单操作和后续的扣款、发货、通知等操作就解耦了,下单完成发送一个消息到消息队列,需要用到的地方去订阅这个消息进行消费即可。
+
+
另外,为了避免消息队列服务器宕机造成消息丢失,会将成功发送到消息队列的消息存储在消息生产者服务器上,等消息真正被消费者服务器处理后才删除消息。在消息队列服务器宕机后,生产者服务器会选择分布式消息队列服务器集群中的其他服务器发布消息。
@@ -83,7 +92,7 @@ tag:
### 实现分布式事务
-我们知道分布式事务的解决方案之一就是 MQ 事务。
+分布式事务的解决方案之一就是 MQ 事务。
RocketMQ、 Kafka、Pulsar、QMQ 都提供了事务相关的功能。事务允许事件流应用将消费,处理,生产消息整个过程定义为一个原子操作。
@@ -91,6 +100,26 @@ RocketMQ、 Kafka、Pulsar、QMQ 都提供了事务相关的功能。事务允

+### 顺序保证
+
+在很多应用场景中,处理数据的顺序至关重要。消息队列保证数据按照特定的顺序被处理,适用于那些对数据顺序有严格要求的场景。大部分消息队列,例如 RocketMQ、RabbitMQ、Pulsar、Kafka,都支持顺序消息。
+
+### 延时/定时处理
+
+消息发送后不会立即被消费,而是指定一个时间,到时间后再消费。大部分消息队列,例如 RocketMQ、RabbitMQ、Pulsar,都支持定时/延时消息。
+
+
+
+### 即时通讯
+
+MQTT(消息队列遥测传输协议)是一种轻量级的通讯协议,采用发布/订阅模式,非常适合于物联网(IoT)等需要在低带宽、高延迟或不可靠网络环境下工作的应用。它支持即时消息传递,即使在网络条件较差的情况下也能保持通信的稳定性。
+
+RabbitMQ 内置了 MQTT 插件用于实现 MQTT 功能(默认不启用,需要手动开启)。
+
+### 数据流处理
+
+针对分布式系统产生的海量数据流,如业务日志、监控数据、用户行为等,消息队列可以实时或批量收集这些数据,并将其导入到大数据处理引擎中,实现高效的数据流管理和处理。
+
## 使用消息队列会带来哪些问题?
- **系统可用性降低:** 系统可用性在某种程度上降低,为什么这样说呢?在加入 MQ 之前,你不用考虑消息丢失或者说 MQ 挂掉等等的情况,但是,引入 MQ 之后你就需要去考虑了!
@@ -117,13 +146,13 @@ JMS 定义了五种不同的消息正文格式以及调用的消息类型,允
#### 点到点(P2P)模型
-
+
使用**队列(Queue)**作为消息通信载体;满足**生产者与消费者模式**,一条消息只能被一个消费者使用,未被消费的消息在队列中保留直到被消费或超时。比如:我们生产者发送 100 条消息的话,两个消费者来消费一般情况下两个消费者会按照消息发送的顺序各自消费一半(也就是你一个我一个的消费。)
#### 发布/订阅(Pub/Sub)模型
-
+
发布订阅模型(Pub/Sub) 使用**主题(Topic)**作为消息通信载体,类似于**广播模式**;发布者发布一条消息,该消息通过主题传递给所有的订阅者。
@@ -182,7 +211,7 @@ Kafka 是一个分布式系统,由通过高性能 TCP 网络协议进行通信
不过,要提示一下:**如果要使用 KRaft 模式的话,建议选择较高版本的 Kafka,因为这个功能还在持续完善优化中。Kafka 3.3.1 版本是第一个将 KRaft(Kafka Raft)共识协议标记为生产就绪的版本。**
-
+
Kafka 官网:
diff --git a/docs/high-performance/message-queue/rabbitmq-questions.md b/docs/high-performance/message-queue/rabbitmq-questions.md
index 8fbb53a952e..0b044d255b6 100644
--- a/docs/high-performance/message-queue/rabbitmq-questions.md
+++ b/docs/high-performance/message-queue/rabbitmq-questions.md
@@ -1,18 +1,18 @@
---
title: RabbitMQ常见问题总结
+description: 本文总结 RabbitMQ 常见面试题与核心知识点,涵盖 AMQP 协议、Exchange 交换机类型(Direct/Topic/Fanout)、消息确认机制、死信队列、延迟队列、优先级队列、高可用集群(镜像队列)等,助力 RabbitMQ 学习与面试准备。
category: 高性能
tag:
- 消息队列
head:
- - meta
- name: keywords
- content: RabbitMQ,AMQP,Broker,Exchange,优先级队列,延迟队列
- - - meta
- - name: description
- content: RabbitMQ 是一个在 AMQP(Advanced Message Queuing Protocol )基础上实现的,可复用的企业消息系统。它可以用于大型软件系统各个模块之间的高效通信,支持高并发,支持可扩展。它支持多种客户端如:Python、Ruby、.NET、Java、JMS、C、PHP、ActionScript、XMPP、STOMP等,支持AJAX,持久化,用于在分布式系统中存储转发消息,在易用性、扩展性、高可用性等方面表现不俗。
+ content: RabbitMQ,AMQP协议,Exchange交换机,消息确认,死信队列,延迟队列,优先级队列,RabbitMQ集群,消息队列面试
---
-> 本篇文章由 JavaGuide 收集自网络,原出处不明。
+RabbitMQ 作为老牌消息中间件,凭借其成熟的路由机制、丰富的协议支持和完善的可靠性保障,在企业级应用中占据重要地位。但自 RabbitMQ 3.8 引入 Quorum Queue、3.9 引入 Streams、4.0 移除镜像队列以来,其技术架构发生了重大变化,许多传统的最佳实践已不再适用。
+
+本文已针对 RabbitMQ 4.0 进行全面更新,明确标注各特性的版本依赖,特别强调了镜像队列(已移除)、Quorum Queue(推荐)和 Streams(3.9+)的选型差异。
## RabbitMQ 是什么?
@@ -20,14 +20,12 @@ RabbitMQ 是一个在 AMQP(Advanced Message Queuing Protocol )基础上实
RabbitMQ 是使用 Erlang 编写的一个开源的消息队列,本身支持很多的协议:AMQP,XMPP, SMTP, STOMP,也正是如此,使的它变的非常重量级,更适合于企业级的开发。它同时实现了一个 Broker 构架,这意味着消息在发送给客户端时先在中心队列排队,对路由(Routing)、负载均衡(Load balance)或者数据持久化都有很好的支持。
-PS:也可能直接问什么是消息队列?消息队列就是一个使用队列来通信的组件。
-
## RabbitMQ 特点?
- **可靠性**: RabbitMQ 使用一些机制来保证可靠性, 如持久化、传输确认及发布确认等。
- **灵活的路由** : 在消息进入队列之前,通过交换器来路由消息。对于典型的路由功能, RabbitMQ 己经提供了一些内置的交换器来实现。针对更复杂的路由功能,可以将多个交换器绑定在一起, 也可以通过插件机制来实现自己的交换器。
- **扩展性**: 多个 RabbitMQ 节点可以组成一个集群,也可以根据实际业务情况动态地扩展 集群中节点。
-- **高可用性** : 队列可以在集群中的机器上设置镜像,使得在部分节点出现问题的情况下队 列仍然可用。
+- **高可用性** : Quorum Queue 基于 Raft 协议实现数据复制,Streams 支持多节点副本,在部分节点出现问题的情况下队列仍然可用。
- **多种协议**: RabbitMQ 除了原生支持 AMQP 协议,还支持 STOMP, MQTT 等多种消息 中间件协议。
- **多语言客户端** :RabbitMQ 几乎支持所有常用语言,比如 Java、 Python、 Ruby、 PHP、 C#、 JavaScript 等。
- **管理界面** : RabbitMQ 提供了一个易用的用户界面,使得用户可以监控和管理消息、集 群中的节点等。
@@ -39,7 +37,7 @@ RabbitMQ 整体上是一个生产者与消费者模型,主要负责接收、
RabbitMQ 的整体模型架构如下:
-
+
下面我会一一介绍上图中的一些概念。
@@ -56,19 +54,13 @@ RabbitMQ 的整体模型架构如下:
**Exchange(交换器)** 用来接收生产者发送的消息并将这些消息路由给服务器中的队列中,如果路由不到,或许会返回给 **Producer(生产者)** ,或许会被直接丢弃掉 。这里可以将 RabbitMQ 中的交换器看作一个简单的实体。
-**RabbitMQ 的 Exchange(交换器) 有 4 种类型,不同的类型对应着不同的路由策略**:**direct(默认)**,**fanout**, **topic**, 和 **headers**,不同类型的 Exchange 转发消息的策略有所区别。这个会在介绍 **Exchange Types(交换器类型)** 的时候介绍到。
-
-Exchange(交换器) 示意图如下:
+**RabbitMQ 的 Exchange(交换器) 有 4 种类型,不同的类型对应着不同的路由策略**:**direct**,**fanout**, **topic**, 和 **headers**,不同类型的 Exchange 转发消息的策略有所区别。这个会在介绍 **Exchange Types(交换器类型)** 的时候介绍到。
-
+> 注意:AMQP 规范定义了一个默认交换器(Default Exchange),它是一个 pre-declared 的 direct 类型交换器,但创建新交换器时必须显式指定类型,不能省略。
生产者将消息发给交换器的时候,一般会指定一个 **RoutingKey(路由键)**,用来指定这个消息的路由规则,而这个 **RoutingKey 需要与交换器类型和绑定键(BindingKey)联合使用才能最终生效**。
-RabbitMQ 中通过 **Binding(绑定)** 将 **Exchange(交换器)** 与 **Queue(消息队列)** 关联起来,在绑定的时候一般会指定一个 **BindingKey(绑定建)** ,这样 RabbitMQ 就知道如何正确将消息路由到队列了,如下图所示。一个绑定就是基于路由键将交换器和消息队列连接起来的路由规则,所以可以将交换器理解成一个由绑定构成的路由表。Exchange 和 Queue 的绑定可以是多对多的关系。
-
-Binding(绑定) 示意图:
-
-
+RabbitMQ 中通过 **Binding(绑定)** 将 **Exchange(交换器)** 与 **Queue(消息队列)** 关联起来,在绑定的时候一般会指定一个 **BindingKey(绑定键)** ,这样 RabbitMQ 就知道如何正确将消息路由到队列了,如下图所示。一个绑定就是基于路由键将交换器和消息队列连接起来的路由规则,所以可以将交换器理解成一个由绑定构成的路由表。Exchange 和 Queue 的绑定可以是多对多的关系。
生产者将消息发送给交换器时,需要一个 RoutingKey,当 BindingKey 和 RoutingKey 相匹配时,消息会被路由到对应的队列中。在绑定多个队列到同一个交换器的时候,这些绑定允许使用相同的 BindingKey。BindingKey 并不是在所有的情况下都生效,它依赖于交换器类型,比如 fanout 类型的交换器就会无视,而是将消息路由到所有绑定到该交换器的队列中。
@@ -76,9 +68,19 @@ Binding(绑定) 示意图:
**Queue(消息队列)** 用来保存消息直到发送给消费者。它是消息的容器,也是消息的终点。一个消息可投入一个或多个队列。消息一直在队列里面,等待消费者连接到这个队列将其取走。
-**RabbitMQ** 中消息只能存储在 **队列** 中,这一点和 **Kafka** 这种消息中间件相反。Kafka 将消息存储在 **topic(主题)** 这个逻辑层面,而相对应的队列逻辑只是 topic 实际存储文件中的位移标识。 RabbitMQ 的生产者生产消息并最终投递到队列中,消费者可以从队列中获取消息并消费。
+**RabbitMQ** 在经典架构中,消息只能存储在 **队列** 中,这一点和 **Kafka** 这种消息中间件相反。Kafka 将消息存储在 **topic(主题)** 这个逻辑层面,而相对应的队列逻辑只是 topic 实际存储文件中的位移标识。RabbitMQ 的生产者生产消息并最终投递到队列中,消费者可以从队列中获取消息并消费。
+
+> **版本说明(3.9+ 重要更新)**:从 RabbitMQ 3.9 版本开始,官方引入了 **Streams** 数据结构。Streams 提供了一种类似 Kafka 的 append-only 日志存储模型,支持非破坏性消费、大规模消息堆积以及基于 Offset 的历史数据重放(Replay)。
+>
+> **架构选型建议**:
+>
+> - **普通队列**:适用于传统消息队列场景,消息被消费后即删除
+> - **Streams**:适用于需要高频重放、海量堆积或事件溯源的场景
+> - **核心瓶颈差异**:使用 Stream 时,磁盘 I/O 吞吐量(MB/s)取代了传统的每秒入队率(msg/s)成为核心瓶颈指标
+
+**多个消费者可以订阅同一个队列**,默认情况下队列中的消息会被平均分摊(Round-Robin,即轮询)给多个消费者进行处理,而不是每个消费者都收到所有的消息并处理,这样避免消息被重复消费。
-**多个消费者可以订阅同一个队列**,这时队列中的消息会被平均分摊(Round-Robin,即轮询)给多个消费者进行处理,而不是每个消费者都收到所有的消息并处理,这样避免消息被重复消费。
+> 注意:实际分发策略受 `prefetch_count` 参数影响。默认行为(`prefetch_count=0`)会尽可能多地分发消息给各 Consumer,可能导致负载不均。推荐设置 `prefetch_count=1` 或更高值,让 Consumer 确认后再发送下一条,实现公平分发。
**RabbitMQ** 不支持队列层面的广播消费,如果有广播消费的需求,需要在其上进行二次开发,这样会很麻烦,不建议这样做。
@@ -86,26 +88,20 @@ Binding(绑定) 示意图:
对于 RabbitMQ 来说,一个 RabbitMQ Broker 可以简单地看作一个 RabbitMQ 服务节点,或者 RabbitMQ 服务实例。大多数情况下也可以将一个 RabbitMQ Broker 看作一台 RabbitMQ 服务器。
-下图展示了生产者将消息存入 RabbitMQ Broker,以及消费者从 Broker 中消费数据的整个流程。
-
-
-
-这样图 1 中的一些关于 RabbitMQ 的基本概念我们就介绍完毕了,下面再来介绍一下 **Exchange Types(交换器类型)** 。
-
### Exchange Types(交换器类型)
-RabbitMQ 常用的 Exchange Type 有 **fanout**、**direct**、**topic**、**headers** 这四种(AMQP 规范里还提到两种 Exchange Type,分别为 system 与 自定义,这里不予以描述)。
+RabbitMQ 常用的 Exchange Type 有 **fanout**、**direct**、**topic**、**headers** 这四种(AMQP 规范里还提到两种 Exchange Type,分别为 system 与自定义,这里不予以描述)。
+
+
**1、fanout**
-fanout 类型的 Exchange 路由规则非常简单,它会把所有发送到该 Exchange 的消息路由到所有与它绑定的 Queue 中,不需要做任何判断操作,所以 fanout 类型是所有的交换机类型里面速度最快的。fanout 类型常用来广播消息。
+fanout 类型的 Exchange 路由规则非常简单,它会把所有发送到该 Exchange 的消息路由到所有与它绑定的 Queue 中,**忽略 BindingKey**,不需要做任何判断操作,所以 fanout 类型是所有的交换机类型里面速度最快的。fanout 类型常用来广播消息。
**2、direct**
direct 类型的 Exchange 路由规则也很简单,它会把消息路由到那些 Bindingkey 与 RoutingKey 完全匹配的 Queue 中。
-
-
以上图为例,如果发送消息的时候设置路由键为“warning”,那么消息会路由到 Queue1 和 Queue2。如果在发送消息的时候设置路由键为"Info”或者"debug”,消息只会路由到 Queue2。如果以其他的路由键发送消息,则消息不会路由到这两个队列中。
direct 类型常用在处理有优先级的任务,根据任务的优先级把消息发送到对应的队列,这样可以指派更多的资源去处理高优先级的队列。
@@ -118,31 +114,27 @@ direct 类型常用在处理有优先级的任务,根据任务的优先级把
- BindingKey 和 RoutingKey 一样也是点号“.”分隔的字符串;
- BindingKey 中可以存在两种特殊字符串“\*”和“#”,用于做模糊匹配,其中“\*”用于匹配一个单词,“#”用于匹配多个单词(可以是零个)。
-
-
-以上图为例:
-
-- 路由键为 “com.rabbitmq.client” 的消息会同时路由到 Queue1 和 Queue2;
-- 路由键为 “com.hidden.client” 的消息只会路由到 Queue2 中;
-- 路由键为 “com.hidden.demo” 的消息只会路由到 Queue2 中;
-- 路由键为 “java.rabbitmq.demo” 的消息只会路由到 Queue1 中;
-- 路由键为 “java.util.concurrent” 的消息将会被丢弃或者返回给生产者(需要设置 mandatory 参数),因为它没有匹配任何路由键。
-
**4、headers(不推荐)**
headers 类型的交换器不依赖于路由键的匹配规则来路由消息,而是根据发送的消息内容中的 headers 属性进行匹配。在绑定队列和交换器时指定一组键值对,当发送消息到交换器时,RabbitMQ 会获取到该消息的 headers(也是一个键值对的形式),对比其中的键值对是否完全匹配队列和交换器绑定时指定的键值对,如果完全匹配则消息会路由到该队列,否则不会路由到该队列。headers 类型的交换器性能会很差,而且也不实用,基本上不会看到它的存在。
## AMQP 是什么?
-RabbitMQ 就是 AMQP 协议的 `Erlang` 的实现(当然 RabbitMQ 还支持 `STOMP2`、 `MQTT3` 等协议 ) AMQP 的模型架构 和 RabbitMQ 的模型架构是一样的,生产者将消息发送给交换器,交换器和队列绑定 。
+RabbitMQ 就是 AMQP 协议的 `Erlang` 的实现(当然 RabbitMQ 还支持 `STOMP`、`MQTT` 等协议)。AMQP 的模型架构 和 RabbitMQ 的模型架构是一样的,生产者将消息发送给交换器,交换器和队列绑定。
-RabbitMQ 中的交换器、交换器类型、队列、绑定、路由键等都是遵循的 AMQP 协议中相 应的概念。目前 RabbitMQ 最新版本默认支持的是 AMQP 0-9-1。
+RabbitMQ 中的交换器、交换器类型、队列、绑定、路由键等都是遵循的 AMQP 协议中相 应的概念。
+
+> **版本说明**:
+>
+> - **AMQP 0-9-1**:RabbitMQ 的传统协议,广泛使用,功能完整
+> - **AMQP 1.0**:RabbitMQ 4.x 已将其提升为一等公民协议,改进了互操作性和性能
+> - 新项目可考虑使用 AMQP 1.0 以获得更好的跨平台兼容性
**AMQP 协议的三层**:
- **Module Layer**:协议最高层,主要定义了一些客户端调用的命令,客户端可以用这些命令实现自己的业务逻辑。
- **Session Layer**:中间层,主要负责客户端命令发送给服务器,再将服务端应答返回客户端,提供可靠性同步机制和错误处理。
-- **TransportLayer**:最底层,主要传输二进制数据流,提供帧的处理、信道服用、错误检测和数据表示等。
+- **TransportLayer**:最底层,主要传输二进制数据流,提供帧的处理、信道复用、错误检测和数据表示等。
**AMQP 模型的三大组件**:
@@ -170,7 +162,7 @@ RabbitMQ 中的交换器、交换器类型、队列、绑定、路由键等都
## 什么是死信队列?如何导致的?
-DLX,全称为 `Dead-Letter-Exchange`,死信交换器,死信邮箱。当消息在一个队列中变成死信 (`dead message`) 之后,它能被重新被发送到另一个交换器中,这个交换器就是 DLX,绑定 DLX 的队列就称之为死信队列。
+DLX,全称为 `Dead-Letter-Exchange`,死信交换器,死信邮箱。当消息在一个队列中变成死信 (`dead message`) 之后,它能被重新发送到另一个交换器中,这个交换器就是 DLX,绑定 DLX 的队列就称之为死信队列。
**导致的死信的几种原因**:
@@ -185,7 +177,13 @@ DLX,全称为 `Dead-Letter-Exchange`,死信交换器,死信邮箱。当消
RabbitMQ 本身是没有延迟队列的,要实现延迟消息,一般有两种方式:
1. 通过 RabbitMQ 本身队列的特性来实现,需要使用 RabbitMQ 的死信交换机(Exchange)和消息的存活时间 TTL(Time To Live)。
-2. 在 RabbitMQ 3.5.7 及以上的版本提供了一个插件(rabbitmq-delayed-message-exchange)来实现延迟队列功能。同时,插件依赖 Erlang/OPT 18.0 及以上。
+
+ - 缺点:消息按队列过期而非单消息级别(除非给每个消息单独队列)
+
+2. 在 RabbitMQ 3.5.7 及以上的版本提供了一个插件(rabbitmq-delayed-message-exchange)来实现延迟队列功能。同时,插件依赖 Erlang/OTP 18.0 及以上。
+ - 原理:将消息暂存在 Mnesia 表中,定时轮询并投递到目标交换器
+ - **容量边界警告(严重)**:该插件将延迟消息全部暂存在 Erlang 的 Mnesia 内部数据库中,**不具备良好的磁盘换页(Paging)能力**。如果单节点堆积**数十万到上百万级别**的延迟消息,会导致 Broker 内存剧增甚至触发**内存高水位(Memory Watermark)告警**,进而产生**全局背压(Global Backpressure)**阻塞所有生产者的 TCP 连接。
+ - **生产建议**:针对海量延迟(千万级以上),必须退化使用外部定时任务(如时间轮、SchedulerX、XXL-JOB)调度或死信链表方案
也就是说,AMQP 协议以及 RabbitMQ 本身没有直接支持延迟队列的功能,但是可以通过 TTL 和 DLX 模拟出延迟队列的功能。
@@ -205,24 +203,163 @@ RabbitMQ 自 V3.5.0 有优先级队列实现,优先级高的队列会先被消
## RabbitMQ 消息怎么传输?
-由于 TCP 链接的创建和销毁开销较大,且并发数受系统资源限制,会造成性能瓶颈,所以 RabbitMQ 使用信道的方式来传输数据。信道(Channel)是生产者、消费者与 RabbitMQ 通信的渠道,信道是建立在 TCP 链接上的虚拟链接,且每条 TCP 链接上的信道数量没有限制。就是说 RabbitMQ 在一条 TCP 链接上建立成百上千个信道来达到多个线程处理,这个 TCP 被多个线程共享,每个信道在 RabbitMQ 都有唯一的 ID,保证了信道私有性,每个信道对应一个线程使用。
+由于 TCP 链接的创建和销毁开销较大(三次握手、慢启动等),且并发数受系统资源限制,会造成性能瓶颈,所以 RabbitMQ 使用信道的方式来传输数据。信道(Channel)是生产者、消费者与 RabbitMQ 通信的渠道,信道是建立在 TCP 链接上的虚拟链接。
+
+> 注意:
+>
+> - 单个 TCP 连接可承载多个 Channel,但官方建议不超过 100-200 个/连接
+> - 每个 Channel 有独立的编号,但共享同一 TCP 连接的流量控制
+> - **Channel 不是线程安全的**,多线程应使用不同 Channel 实例
+
+## 如何保证消息的可靠性?
+
+
+
+消息可能在三个环节丢失:生产者 → Broker、Broker 存储期间、Broker → 消费者
+
+**1. 生产者 → Broker**
+
+保证生产者端零丢失需要**双重机制兜底**:
+
+- **Publisher Confirms**(异步确认):确认消息是否到达 Broker
+
+ ```java
+ channel.confirmSelect();
+ channel.addConfirmListener((sequenceNumber, multiple) -> {
+ // 消息已到达 Broker 并落盘/同步到镜像
+ }, (sequenceNumber, multiple) -> {
+ // 消息未到达 Broker,记录日志并重试
+ });
+ ```
+
+- **Mandatory + Return Listener**(路由失败处理):捕获消息到达 Exchange 但无法路由到 Queue 的情况
+
+ ```java
+ // 开启 mandatory 模式
+ channel.basicPublish("exchange", "routingKey",
+ true, // mandatory=true
+ null,
+ messageBody);
+
+ // 配置 Return Listener
+ channel.addReturnListener((replyCode, replyText, exchange, routingKey, properties, body) -> {
+ // 消息到达 Exchange 但路由失败,记录日志或发送到备用交换器
+ log.error("Message returned: {}", replyText);
+ });
+ ```
+
+> **关键警告**:若仅开启 Confirm 未处理 Return,配置漂移(如误删队列或绑定)会导致生产者认为发送成功,但消息在 Broker 内部被静默丢弃,形成**消息黑洞**。
+
+- **事务机制**(不推荐):同步阻塞,**性能显著下降(官方文档未给出具体倍数,实际影响取决于消息大小和网络延迟)**
+ - 注意:事务机制和 Confirm 机制是互斥的,两者不能共存
-## **如何保证消息的可靠性?**
+**2. Broker 存储期间**
-消息到 MQ 的过程中搞丢,MQ 自己搞丢,MQ 到消费过程中搞丢。
+- **消息持久化**:`delivery_mode=2`,消息写入磁盘
+- **队列持久化**:`durable=true`,重启后队列重建
+- **集群模式**:
+ - **镜像队列**(Classic Queue Mirroring,已于 4.0 移除):主从同步,仅用于老版本维护
+ - **Quorum Queue**(3.8+ 推荐,4.0 后为默认):基于 Raft 协议,支持更严格的仲裁写入(N/2 + 1)
+ - **Streams**(3.9+):适用于事件溯源和高频重放场景
-- 生产者到 RabbitMQ:事务机制和 Confirm 机制,注意:事务机制和 Confirm 机制是互斥的,两者不能共存,会导致 RabbitMQ 报错。
-- RabbitMQ 自身:持久化、集群、普通模式、镜像模式。
-- RabbitMQ 到消费者:basicAck 机制、死信队列、消息补偿机制。
+**3. Broker → 消费者**
+
+- **手动 Ack**:`basicAck(deliveryTag, multiple)`,确保消费成功后再确认
+- **重试机制**:消费失败时 `basicNack` 或 `basicReject` 并 `requeue=true`
+- **死信队列**:达到最大重试次数后路由到 DLQ 人工介入
+- **幂等性**:业务层实现(如唯一 ID 去重表)
+
+以下时序图展示了从生产者到消费者的完整消息流转及各环节的异常处理策略:
+
+```mermaid
+sequenceDiagram
+ participant P as 生产者 (Producer)
+ participant E as 交换器 (Exchange)
+ participant DLX as 死信交换器 (DLX)
+ participant Q as 队列 (Quorum Queue)
+ participant C as 消费者 (Consumer)
+
+ P->>E: 1. 发送消息 (开启 Confirm & Mandatory)
+ alt 路由成功
+ E->>Q: 2. 消息进入队列
+ Q-->>P: 3. Raft 多数派落盘后返回 Confirm Ack
+ else 路由失败 (无匹配 Queue, mandatory=true)
+ E-->>P: 2a. 触发 Return Listener 返回消息
+ Note over P: 记录日志或告警
+ end
+
+ Q->>C: 4. 推送消息 (开启手动 Ack)
+
+ alt 消费成功
+ C-->>Q: 5. 发送 basic.ack
+ Q->>Q: 6. 标记消息可删除
+ else 业务异常且可重试
+ C-->>Q: 5a. basic.nack (requeue=true)
+ Q->>Q: 6a. 消息重回队列尾部 (注意:顺序破坏)
+ else 致命异常 / 重试超限
+ C-->>Q: 5b. basic.reject (requeue=false)
+ Q->>DLX: 6b. 路由至死信交换机 (DLX)
+ end
+```
+
+**关键路径说明**:
+
+- **Confirm + Returns**(互为补充):
+ - Confirm 确认消息是否到达 Broker 并落盘/同步
+ - Mandatory + Return Listener 捕获路由失败事件(消息到达 Exchange 但无法进入 Queue)
+- **Quorum Queue**:Raft 多数派确认后才返回 Ack,保证数据不丢
+- **手动 Ack**:确保消费成功后才删除消息
+- **DLQ 兜底**:重试超限后路由到死信队列,避免消息无限重试
+
+> **注意**:Alternate Exchange(备用交换器)是另一种独立的路由失败处理机制,与 Mandatory + Return Listener 互斥。配置 Alternate Exchange 后,路由失败的消息会被转发到备用交换器,生产者收到的是正常的 Confirm Ack 而非 Return。
## 如何保证 RabbitMQ 消息的顺序性?
-- 拆分多个 queue(消息队列),每个 queue(消息队列) 一个 consumer(消费者),就是多一些 queue (消息队列)而已,确实是麻烦点;
-- 或者就一个 queue (消息队列)但是对应一个 consumer(消费者),然后这个 consumer(消费者)内部用内存队列做排队,然后分发给底层不同的 worker 来处理。
+RabbitMQ 仅保证**单个 Queue 内的 FIFO 顺序**,但多消费者场景下可能出现乱序。解决方案:
+
+**1. 单 Consumer 模式**
+
+- 一个 Queue 只绑定一个 Consumer
+- 优点:保证顺序
+- 缺点:成为瓶颈,吞吐量受限
+
+**2. 分区有序**(推荐,但需注意失效模式)
+
+- 按业务 key(如订单ID)哈希到不同 Queue
+- 每个 Queue 独立 Consumer
+- 优点:既保证顺序又提高吞吐量
+
+> **失效模式警告**:
+>
+> - **拓扑变更乱序**:当后端队列扩缩容导致哈希环发生变化时,同一个业务 Key 的新老消息可能进入不同队列
+> - **重试乱序**:若消费者内部处理失败执行 Nack 并 Requeue,该消息会被重新推入队列**尾部**,导致后续消息先被消费
+> - **应用层防护**:极端严格顺序场景下,消费者业务表必须设计基于**状态机**或**版本号**的幂等与防并发覆盖机制
+
+**3. 内部内存队列**(慎重)
+
+- 单一 Consumer 内部维护内存队列分发到 Worker 线程池
+- 需处理:
+ - Consumer 挂掉时内存队列丢失风险
+ - 需实现背压机制防止 OOM
+ - 增加 ack 复杂度(需追踪具体 Worker 处理状态)
+- 生产环境慎用此方案
## 如何保证 RabbitMQ 高可用的?
-RabbitMQ 是比较有代表性的,因为是基于主从(非分布式)做高可用性的,我们就以 RabbitMQ 为例子讲解第一种 MQ 的高可用性怎么实现。RabbitMQ 有三种模式:单机模式、普通集群模式、镜像集群模式。
+RabbitMQ 是比较有代表性的,因为是基于主从(非分布式)做高可用性的,我们就以 RabbitMQ 为例子讲解第一种 MQ 的高可用性怎么实现。RabbitMQ 有四种模式:单机模式、普通集群模式、镜像集群模式(已废弃)、Quorum Queue(推荐)。
+
+> **版本演进说明**:
+>
+> - **3.8 前**:镜像队列(Classic Queue Mirroring)是主要高可用方案
+> - **3.8+**:Quorum Queue 作为推荐替代方案,镜像队列被标记为 deprecated
+> - **3.13**:镜像队列仍可用但已废弃
+> - **4.0+**:镜像队列**完全移除**,Quorum Queue 成为默认高可用方案
+>
+> **网络分区警告(严重)**:无论是普通集群还是早期的镜像集群,均依赖 Erlang 内部的分布式同步机制,对网络抖动极度敏感。在多机房或跨可用区部署时,极易发生**网络分区(Split-brain)**。必须在 `rabbitmq.conf` 中明确配置分区恢复策略:
+>
+> - `pause_minority`:少数派节点自动暂停服务以防数据分化(推荐)
+> - `autoheal`:自动选择一方继续运行(有数据丢失风险)
+> - 对于 3.8 以上版本,强烈建议直接使用基于 Raft 一致性算法的 Quorum Queue,从根本上解决网络分区导致的消息丢失与状态不一致问题
**单机模式**
@@ -234,14 +371,269 @@ Demo 级别的,一般就是你本地启动了玩玩儿的?,没人生产用
你消费的时候,实际上如果连接到了另外一个实例,那么那个实例会从 queue 所在实例上拉取数据过来。这方案主要是提高吞吐量的,就是说让集群中多个节点来服务某个 queue 的读写操作。
-**镜像集群模式**
+**镜像集群模式**(Classic Queue Mirroring,已废弃)
+
+> ⚠️ **重要警告**:镜像队列已在 RabbitMQ 4.0 中被**完全移除**。RabbitMQ 3.8 引入 Quorum Queue 作为推荐替代方案,3.13 版本镜像队列仍可用但已废弃,4.0 版本正式移除。新项目请使用 Quorum Queue 或 Streams。
-这种模式,才是所谓的 RabbitMQ 的高可用模式。跟普通集群模式不一样的是,在镜像集群模式下,你创建的 queue,无论元数据还是 queue 里的消息都会存在于多个实例上,就是说,每个 RabbitMQ 节点都有这个 queue 的一个完整镜像,包含 queue 的全部数据的意思。然后每次你写消息到 queue 的时候,都会自动把消息同步到多个实例的 queue 上。RabbitMQ 有很好的管理控制台,就是在后台新增一个策略,这个策略是镜像集群模式的策略,指定的时候是可以要求数据同步到所有节点的,也可以要求同步到指定数量的节点,再次创建 queue 的时候,应用这个策略,就会自动将数据同步到其他的节点上去了。
+这种模式是 RabbitMQ 早期版本的高可用方案。跟普通集群模式不一样的是,在镜像集群模式下,你创建的 queue,无论元数据还是 queue 里的消息都会存在于多个实例上,每个 RabbitMQ 节点都有这个 queue 的一个完整镜像,包含 queue 的全部数据。每次写消息到 queue 的时候,都会自动把消息同步到多个实例的 queue 上。
-这样的好处在于,你任何一个机器宕机了,没事儿,其它机器(节点)还包含了这个 queue 的完整数据,别的 consumer 都可以到其它节点上去消费数据。坏处在于,第一,这个性能开销也太大了吧,消息需要同步到所有机器上,导致网络带宽压力和消耗很重!RabbitMQ 一个 queue 的数据都是放在一个节点里的,镜像集群下,也是每个节点都放这个 queue 的完整数据。
+**工作原理**:
+
+- Queue 主节点接收消息,同步到 N 个镜像节点
+- 主节点宕机时,最老的镜像节点升级为主节点
+- 通过管理控制台新增策略,指定数据同步到所有节点或指定数量的节点
+
+**优点**:
+
+- 任何机器宕机,其他节点包含该 queue 的完整数据
+- Consumer 可以切换到其他节点继续消费
+
+**缺点**:
+
+- 性能开销大,消息需要同步到所有机器上
+- 网络带宽压力和消耗重
+- 不是真正的分布式架构,是主从复制
+
+**Quorum Queue**(3.8+ 推荐,4.0 后为默认高可用方案)
+
+基于 Raft 协议的复制队列,是 RabbitMQ 3.8+ 推荐的高可用方案,4.0 后成为默认选项:
+
+- **基于 Raft 协议**:通过日志复制和选举实现一致性
+- **仲裁写入**:需要多数节点确认(N/2 + 1)才认为写入成功
+- **更严格的一致性**:避免镜像队列的脑裂风险
+- **适用场景**:对可靠性要求高的场景
+
+**声明方式(客户端)**:
+
+Java:
+
+```java
+// Java 客户端声明 Quorum Queue
+Map args = new HashMap<>();
+args.put("x-queue-type", "quorum"); // 关键参数,必须在声明时指定
+channel.queueDeclare("my-queue", true, false, false, args);
+```
+
+Python:
+
+```python
+# Python (pika) 客户端声明 Quorum Queue
+channel.queue_declare(
+ queue='my-queue',
+ durable=True,
+ arguments={'x-queue-type': 'quorum'} # 关键参数
+)
+```
+
+> **重要提示**:`x-queue-type` 参数必须在队列声明时由客户端提供,**不能通过 Policy 设置或修改**。Policy 只能配置 max-length、delivery-limit 等运行时参数。
## 如何解决消息队列的延时以及过期失效问题?
-RabbtiMQ 是可以设置过期时间的,也就是 TTL。如果消息在 queue 中积压超过一定的时间就会被 RabbitMQ 给清理掉,这个数据就没了。那这就是第二个坑了。这就不是说数据会大量积压在 mq 里,而是大量的数据会直接搞丢。我们可以采取一个方案,就是批量重导,这个我们之前线上也有类似的场景干过。就是大量积压的时候,我们当时就直接丢弃数据了,然后等过了高峰期以后,比如大家一起喝咖啡熬夜到晚上 12 点以后,用户都睡觉了。这个时候我们就开始写程序,将丢失的那批数据,写个临时程序,一点一点的查出来,然后重新灌入 mq 里面去,把白天丢的数据给他补回来。也只能是这样了。假设 1 万个订单积压在 mq 里面,没有处理,其中 1000 个订单都丢了,你只能手动写程序把那 1000 个订单给查出来,手动发到 mq 里去再补一次。
+RabbitMQ 可以设置消息过期时间(TTL)。如果消息在 queue 中积压超过一定的时间就会被 RabbitMQ 清理掉,导致数据丢失。
+
+**批量重导方案**(适用于数据可恢复的场景):
+
+当大量消息积压或过期时,可采取以下步骤:
+
+1. **临时丢弃**:高峰期直接丢弃无法及时处理的数据,保证系统可用性
+2. **低峰期恢复**:在业务低峰期(如凌晨),编写临时程序从数据库中查询丢失的数据
+3. **重新投递**:将查询到的数据重新发送到 MQ 中进行补偿
+
+**示例场景**:
+
+- 假设 1 万个订单积压在 MQ 中未处理
+- 其中 1000 个订单因 TTL 过期被丢弃
+- 处理方案:编写临时程序从数据库查询这 1000 个订单,手动重新发送到 MQ 补偿
+
+**注意事项**:
+
+- 确保数据源(如数据库)中有完整的历史数据
+- 补偿过程需要做好幂等性处理,避免重复消费
+- 建议设置监控告警,及时发现消息积压情况
+
+## 生产环境最佳实践与监控告警
+
+### 核心监控指标
+
+**1. 内存水位线告警(严重)**
+
+- 监控 `rabbitmq_memory_limit` 占比
+- 告警阈值:默认高水位为 0.4(40%)
+- **影响**:一旦达到高水位,RabbitMQ 会直接 **block 所有生产者的 TCP Socket**(全局背压)
+- 建议配置:
+ ```erlang
+ {rabbit, [
+ {vm_memory_high_watermark, 0.4}, % 内存高水位 40%
+ {vm_memory_high_watermark_paging_ratio, 0.5} % 开始分页的比例
+ ]}
+ ```
+
+**2. 文件句柄消耗**
+
+- 监控 File Descriptors 使用率
+- **风险**:连接数风暴或海量未确认消息会耗尽句柄导致节点 Crash
+- 建议值:系统限制至少 100,000+(`ulimit -n 100000`)
+
+**3. Channel Churn Rate**
+
+- 监控信道的创建与销毁速率
+- **风险**:高频创建销毁(而非复用)会导致 Erlang 进程抖动,引发 CPU 飙升
+- 生产建议:单连接 Channel 数建议 50-100,避免频繁创建/销毁
+
+**4. 消息积压深度**
+
+- 监控 Queue 消息数量和 Consumer Lag
+- 告警阈值:根据业务定义(如 > 10,000 条)
+- 工具:RabbitMQ Management UI、Prometheus + Grafana
+
+**5. 磁盘空间与 I/O**
+
+- 监控磁盘剩余空间和 IOPS
+- **告警阈值**:磁盘剩余 < 20% 触发告警
+- Quorum Queue 对磁盘 I/O 要求较高,建议使用 NVMe SSD
+
+### 常见生产误区与避坑指南
+
+**误区 1:Quorum Queue 是银弹,能解决所有问题**
+
+- **真相**:Quorum Queue 的 Raft 日志在 flush 时会 fsync,且 Confirm 需等待多数节点 fsync 后才返回。如果底层不是高性能 NVMe SSD,其吞吐量会受到影响
+- **限制**:Quorum Queue 会将所有消息(包括 `delivery_mode=1` 的非持久化消息)强制持久化存储到磁盘
+- **选型建议**:
+ - 高吞吐量场景:考虑 Classic Queue(非镜像,单节点)或 Streams(3.9+)
+ - 高可靠性场景:使用 Quorum Queue(3.8+)
+
+**误区 2:Prefetch Count 设置越大越好**
+
+- **真相**:客户端批量拉取大量消息但在本地卡死,导致服务端队列看似空闲,实则消息全部处于 Unacked 状态,拖垮客户端本地内存并阻碍其他消费者接盘
+- **生产建议**:核心业务初始值设为 **10 到 50** 之间,根据处理耗时调整
+ ```java
+ channel.basicQos(20); // 推荐起始值
+ ```
+
+**误区 3:延迟队列插件可以无限制使用**
+
+- **真相**:延迟插件将所有延迟消息存储在 Mnesia 内存表中,**不支持磁盘换页**
+- **风险**:单节点堆积百万级延迟消息会触发 OOM 或全局背压
+- **替代方案**:海量延迟场景使用外部定时任务系统(如 XXL-JOB、SchedulerX)
+
+**误区 4:网络分区不会发生在我们环境**
+
+- **真相**:跨机房部署或网络抖动都会触发 Erlang 的网络分区检测
+- **后果**:Split-brain 导致消息丢失、状态不一致
+- **防护**:
+ - 3.8+ 使用 Quorum Queue(基于 Raft,天然抗分区)
+ - 配置分区恢复策略:`cluster_partition_handling = pause_minority`
+
+**误区 5:开启了事务机制就万无一失**
+
+- **真相**:事务机制是同步阻塞模式,性能显著低于 Publisher Confirms(官方文档未给出具体倍数,实际影响取决于消息大小和网络延迟)
+- **替代方案**:使用 Publisher Confirms + Mandatory Returns(异步且高性能)
+
+### 生产配置参考
+
+> **重要说明**:RabbitMQ 3.7+ 使用新的 `rabbitmq.conf` 格式(sysctl 风格),而非旧的 `advanced.config`(Erlang 术语格式)。以下配置适用于 `rabbitmq.conf`:
+
+```ini
+# rabbitmq.conf 生产环境推荐配置
+
+# 内存管理
+vm_memory_high_watermark.relative = 0.4
+vm_memory_high_watermark_paging_ratio = 0.5
+
+# 磁盘管理
+disk_free_limit.absolute = 5GB
+
+# 连接与通道
+channel_max = 200
+connection_max = infinity
+
+# 心跳检测(秒)
+heartbeat = 60
+
+# 网络分区处理(重要)
+cluster_partition_handling = pause_minority
+
+# 默认用户(生产环境请修改或删除)
+default_user = guest
+default_pass = guest
+loopback_users = none
+
+# 管理插件监听端口
+management.tcp.port = 15672
+```
+
+如需使用 Erlang 术语格式(高级配置),请使用 `advanced.config` 文件,但**不要与 `rabbitmq.conf` 混用**。
+
+## 总结
+
+本文系统梳理了 RabbitMQ 的核心知识点,从基础概念到生产实践,涵盖了面试和实际应用中最重要的内容。让我们回顾一下关键要点:
+
+### 核心技术架构演进
+
+| 版本里程碑 | 重要变化 | 生产影响 |
+| ---------- | --------------------------------------- | -------------------------------------- |
+| **3.8 前** | 镜像队列(Classic Queue Mirroring)时代 | 主从复制,脑裂风险 |
+| **3.8+** | Quorum Queue 引入 | 基于 Raft,推荐用于高可靠场景 |
+| **3.9+** | Streams 引入 | Kafka-like 架构,支持事件溯源 |
+| **4.0+** | 镜像队列完全移除 | 新项目必须使用 Quorum Queue 或 Streams |
+
+### 面试高频考点
+
+**必知必会**:
+
+1. **AMQP 模型**:Exchange、Queue、Binding 三大核心组件
+2. **Exchange 类型**:direct、fanout、topic、headers 的路由规则
+3. **消息可靠性**:Publisher Confirms + Mandatory Returns + 手动 Ack + DLQ
+4. **消息顺序性**:单 Queue 内 FIFO,多消费者需分区有序或单 Consumer
+5. **高可用方案**:Quorum Queue(3.8+)替代镜像队列(4.0 已移除)
+
+**常见追问**:
+
+- 为什么镜像队列被移除?(脑裂问题、主从复制非分布式)
+- Quorum Queue 和 Classic Queue 如何选型?(可靠性 vs 吞吐量)
+- 如何保证消息不丢失?(三环节:生产者→Broker→消费者)
+- 如何保证消息顺序?(单 Queue、分区有序、慎用内存队列)
+
+### 生产环境关键决策
+
+**1. 队列类型选型**
+
+```
+高可靠性需求 → Quorum Queue(默认推荐)
+高吞吐量需求 → Classic Queue(单节点)或 Streams(3.9+)
+事件溯源需求 → Streams(支持非破坏性消费)
+```
+
+**2. 消息可靠性配置**
+
+```java
+// 生产者端:双重保障
+channel.confirmSelect(); // Confirm
+channel.basicPublish(exchange, routingKey, true, ...); // Mandatory
+channel.addReturnListener(...); // Return Listener
+
+// 消费者端:手动确认
+channel.basicQos(20); // Fair dispatch
+channel.basicConsume(queue, false, ...); // Manual ack
+```
+
+**3. 高可用配置要点**
+
+```ini
+# 网络分区处理(跨机房部署必配)
+cluster_partition_handling = pause_minority
+
+# 使用 Quorum Queue(客户端声明)
+arguments.put("x-queue-type", "quorum");
+```
+
+**4. 监控告警指标**
+
+- **内存水位线**:触发全局背压的阈值(默认 40%)
+- **磁盘剩余空间**:低于 20% 触发告警
+- **消息积压深度**:Queue 消息数量和 Consumer Lag
+- **Channel Churn Rate**:高频创建销毁会导致 CPU 飙升
+
+---
diff --git a/docs/high-performance/message-queue/rocketmq-questions.md b/docs/high-performance/message-queue/rocketmq-questions.md
index 8b3644c8add..03ad07e9b90 100644
--- a/docs/high-performance/message-queue/rocketmq-questions.md
+++ b/docs/high-performance/message-queue/rocketmq-questions.md
@@ -1,57 +1,63 @@
---
title: RocketMQ常见问题总结
+description: 本文总结 RocketMQ 常见面试题与核心知识点,涵盖 RocketMQ 架构(NameServer/Broker/Proxy)、消息类型(普通/顺序/事务/定时消息)、消息存储机制(CommitLog/ConsumeQueue)、高性能原理(零拷贝/顺序写)、消息可靠性保障、RocketMQ 5.x 新特性等,助力 RocketMQ 学习与面试。
category: 高性能
tag:
- RocketMQ
- 消息队列
+head:
+ - - meta
+ - name: keywords
+ content: RocketMQ,消息队列,NameServer,Broker,Proxy,顺序消息,事务消息,定时消息,消息存储,RocketMQ面试,RocketMQ5.x
---
-> [本文由 FrancisQ 投稿!](https://mp.weixin.qq.com/s?__biz=Mzg2OTA0Njk0OA==&mid=2247485969&idx=1&sn=6bd53abde30d42a778d5a35ec104428c&chksm=cea245daf9d5cccce631f93115f0c2c4a7634e55f5bef9009fd03f5a0ffa55b745b5ef4f0530&token=294077121&lang=zh_CN#rd) 相比原文主要进行了下面这些完善:
+> 本文由 FrancisQ 投稿!相比原文主要进行了下面这些完善:
>
> - [分析了 RocketMQ 高性能读写的原因和顺序消费的具体实现](https://github.com/Snailclimb/JavaGuide/pull/2133)
> - [增加了消息类型、消费者类型、消费者组和生产者组的介绍](https://github.com/Snailclimb/JavaGuide/pull/2134)
+> - [RocketMQ 5.x 支持按消息粒度分配](https://github.com/Snailclimb/JavaGuide/issues/2778)
## 消息队列扫盲
-消息队列顾名思义就是存放消息的队列,队列我就不解释了,别告诉我你连队列都不知道是啥吧?
+消息队列(Message Queue,简称 MQ)是一种应用程序之间的通信方式,用于在分布式系统中传递消息。消息队列的核心概念是生产者将消息发送到队列,消费者从队列中获取消息进行处理。
-所以问题并不是消息队列是什么,而是 **消息队列为什么会出现?消息队列能用来干什么?用它来干这些事会带来什么好处?消息队列会带来副作用吗?**
+理解消息队列,关键是要回答以下几个问题:**消息队列为什么会出现?消息队列能用来干什么?使用消息队列能带来什么好处?消息队列会带来哪些副作用?**
### 消息队列为什么会出现?
-消息队``列算是作为后端程序员的一个必备技能吧,因为**分布式应用必定涉及到各个系统之间的通信问题**,这个时候消息队列也应运而生了。可以说分布式的产生是消息队列的基础,而分布式怕是一个很古老的概念了吧,所以消息队列也是一个很古老的中间件了。
+消息队列算是作为后端程序员的一个必备技能吧,因为**分布式应用必定涉及到各个系统之间的通信问题**,这个时候消息队列也应运而生了。可以说分布式的产生是消息队列的基础,而分布式怕是一个很古老的概念了吧,所以消息队列也是一个很古老的中间件了。
### 消息队列能用来干什么?
#### 异步
-你可能会反驳我,应用之间的通信又不是只能由消息队列解决,好好的通信为什么中间非要插一个消息队列呢?我不能直接进行通信吗?
+你可能会问,应用之间的通信又不是只能由消息队列解决,为什么中间非要插一个消息队列?直接进行通信不行吗?
-很好 👍,你又提出了一个概念,**同步通信**。就比如现在业界使用比较多的 `Dubbo` 就是一个适用于各个系统之间同步通信的 `RPC` 框架。
+这就引出了另一个概念——**同步通信**。比如业界使用较多的 Dubbo 就是一个适用于各个系统之间同步通信的 RPC 框架。
-我来举个 🌰 吧,比如我们有一个购票系统,需求是用户在购买完之后能接收到购买完成的短信。
+以购票系统为例,需求是用户在购买完成之后能接收到购买完成的短信通知。

我们省略中间的网络通信时间消耗,假如购票系统处理需要 150ms ,短信系统处理需要 200ms ,那么整个处理流程的时间消耗就是 150ms + 200ms = 350ms。
-当然,乍看没什么问题。可是仔细一想你就感觉有点问题,我用户购票在购票系统的时候其实就已经完成了购买,而我现在通过同步调用非要让整个请求拉长时间,而短信系统这玩意又不是很有必要,它仅仅是一个辅助功能增强用户体验感而已。我现在整个调用流程就有点 **头重脚轻** 的感觉了,购票是一个不太耗时的流程,而我现在因为同步调用,非要等待发送短信这个比较耗时的操作才返回结果。那我如果再加一个发送邮件呢?
+当然,乍看没什么问题。但仔细分析会发现问题:用户购票在购票系统处理完成时就已经完成了购买动作,而现在通过同步调用非要让整个请求时间变长。短信系统只是一个辅助功能,用于增强用户体验感,并非核心业务。整个调用流程显得 **头重脚轻**——购票是一个不太耗时的流程,但因为同步调用,必须等待发送短信这个较耗时的操作完成才能返回结果。如果再加一个发送邮件的需求呢?

这样整个系统的调用链又变长了,整个时间就变成了 550ms。
-当我们在学生时代需要在食堂排队的时候,我们和食堂大妈就是一个同步的模型。
+当我们在食堂排队打饭时,我们和食堂工作人员之间就是一个同步模型。
-我们需要告诉食堂大妈:“姐姐,给我加个鸡腿,再加个酸辣土豆丝,帮我浇点汁上去,多打点饭哦 😋😋😋” 咦~~~ 为了多吃点,真恶心。
+我们需要告诉工作人员:"请帮我加个鸡腿,再加个酸辣土豆丝,多打点饭"。
-然后大妈帮我们打饭配菜,我们看着大妈那颤抖的手和掉落的土豆丝不禁咽了咽口水。
+然后工作人员帮我们打饭配菜,我们需要等待这个过程完成。
-最终我们从大妈手中接过饭菜然后去寻找座位了...
+最终我们从工作人员手中接过饭菜然后去寻找座位。
-回想一下,我们在给大妈发送需要的信息之后我们是 **同步等待大妈给我配好饭菜** 的,上面我们只是加了鸡腿和土豆丝,万一我再加一个番茄牛腩,韭菜鸡蛋,这样是不是大妈打饭配菜的流程就会变长,我们等待的时间也会相应的变长。
+回想一下,我们在传达需求之后是 **同步等待工作人员配好饭菜** 的。如果增加更多菜品,工作人员打饭配菜的流程就会变长,我们等待的时间也会相应增加。
-那后来,我们工作赚钱了有钱去饭店吃饭了,我们告诉服务员来一碗牛肉面加个荷包蛋 **(传达一个消息)** ,然后我们就可以在饭桌上安心的玩手机了 **(干自己其他事情)** ,等到我们的牛肉面上了我们就可以吃了。这其中我们也就传达了一个消息,然后我们又转过头干其他事情了。这其中虽然做面的时间没有变短,但是我们只需要传达一个消息就可以干其他事情了,这是一个 **异步** 的概念。
+而在餐厅用餐时,我们告诉服务员来一碗牛肉面加个荷包蛋 **(传达一个消息)** ,然后可以在餐桌上做自己的事情 **(干自己其他事情)** ,等到牛肉面上桌我们再开始用餐。虽然做面的时间没有变短,但是我们只需要传达一个消息就可以干其他事情了,这就是 **异步** 的概念。
所以,为了解决这一个问题,聪明的程序员在中间也加了个类似于服务员的中间件——消息队列。这个时候我们就可以把模型给改造了。
@@ -71,13 +77,13 @@ tag:

-如果你觉得还行,那么我这个时候不要发邮件这个服务了呢,我是不是又得改代码,又得重启应用?
+如果还觉得可以接受,那么当需要移除发送邮件服务时,是不是又得改代码、又得重启应用?

-这样改来改去是不是很麻烦,那么 **此时我们就用一个消息队列在中间进行解耦** 。你需要注意的是,我们后面的发送短信、发送邮件、添加积分等一些操作都依赖于上面的 `result` ,这东西抽象出来就是购票的处理结果呀,比如订单号,用户账号等等,也就是说我们后面的一系列服务都是需要同样的消息来进行处理。既然这样,我们是不是可以通过 **“广播消息”** 来实现。
+这样频繁改动代码显然很麻烦,此时可以 **使用消息队列进行解耦** 。需要注意的是,后面的发送短信、发送邮件、添加积分等操作都依赖于 `result`,即购票的处理结果(如订单号、用户账号等),也就是说后续服务都需要相同的消息来进行处理。因此可以通过 **"广播消息"** 模式来实现。
-我上面所讲的“广播”并不是真正的广播,而是接下来的系统作为消费者去 **订阅** 特定的主题。比如我们这里的主题就可以叫做 `订票` ,我们购买系统作为一个生产者去生产这条消息放入消息队列,然后消费者订阅了这个主题,会从消息队列中拉取消息并消费。就比如我们刚刚画的那张图,你会发现,在生产者这边我们只需要关注 **生产消息到指定主题中** ,而 **消费者只需要关注从指定主题中拉取消息** 就行了。
+这里所说的"广播"并不是真正的广播,而是下游系统作为消费者去 **订阅** 特定的主题。比如主题可以命名为 `订票`,购买系统作为生产者将消息发送到消息队列,消费者订阅该主题后,从消息队列中拉取消息并消费。在生产者端只需要关注 **生产消息到指定主题** ,**消费者只需要关注从指定主题中拉取消息** 。

@@ -85,21 +91,48 @@ tag:
#### 削峰
-我们再次回到一开始我们使用同步调用系统的情况,并且思考一下,如果此时有大量用户请求购票整个系统会变成什么样?
+回到同步调用系统的场景,思考一下:如果此时有大量用户请求购票,整个系统会变成什么样?

-如果,此时有一万的请求进入购票系统,我们知道运行我们主业务的服务器配置一般会比较好,所以这里我们假设购票系统能承受这一万的用户请求,那么也就意味着我们同时也会出现一万调用发短信服务的请求。而对于短信系统来说并不是我们的主要业务,所以我们配备的硬件资源并不会太高,那么你觉得现在这个短信系统能承受这一万的峰值么,且不说能不能承受,系统会不会 **直接崩溃** 了?
+假设有一万个请求进入购票系统,运行主业务的服务器配置通常较好,购票系统可以承受这一万个用户请求。但这意味着同时也会产生一万个调用短信服务的请求。短信系统并非主要业务,配备的硬件资源不会太高。此时短信系统能否承受这一万的峰值?很可能系统会 **直接崩溃** 。
-短信业务又不是我们的主业务,我们能不能 **折中处理** 呢?如果我们把购买完成的信息发送到消息队列中,而短信系统 **尽自己所能地去消息队列中取消息和消费消息** ,即使处理速度慢一点也无所谓,只要我们的系统没有崩溃就行了。
+短信业务并非主业务,能否 **折中处理** ?如果我们把购买完成的信息发送到消息队列中,而短信系统 **尽自己所能地去消息队列中取消息和消费消息** ,即使处理速度慢一点也无所谓,只要系统没有崩溃就可以接受。
-留得江山在,还怕没柴烧?你敢说每次发送验证码的时候是一发你就收到了的么?
+系统可用性是最重要的,验证码短信的延迟几秒到达用户手机,通常是可以接受的。
-#### 消息队列能带来什么好处?
+### 消息队列能带来什么好处?
-其实上面我已经说了。**异步、解耦、削峰。** 哪怕你上面的都没看懂也千万要记住这六个字,因为他不仅是消息队列的精华,更是编程和架构的精华。
+总结起来就是三个关键词:**异步、解耦、削峰**。这不仅是消息队列的核心价值,更是分布式架构设计的重要思想。
-#### 消息队列会带来副作用吗?
+```mermaid
+flowchart LR
+ subgraph MQ["消息队列三大应用场景"]
+ style MQ fill:#F0F2F5,stroke:#E0E6ED,stroke-width:1.5px
+ Async["异步处理"]
+ Decouple["解耦"]
+ Peak["削峰"]
+ end
+
+ Async --> A1["提高响应速度"]
+ Async --> A2["提升用户体验"]
+
+ Decouple --> D1["降低系统耦合"]
+ Decouple --> D2["提高扩展性"]
+
+ Peak --> P1["缓解系统压力"]
+ Peak --> P2["保证系统稳定"]
+
+ classDef app fill:#4CA497,color:#fff,rx:10,ry:10
+ classDef benefit fill:#00838F,color:#fff,rx:10,ry:10
+
+ class Async,Decouple,Peak app
+ class A1,A2,D1,D2,P1,P2 benefit
+
+ linkStyle default stroke-width:1.5px,opacity:0.8
+```
+
+### 消息队列会带来副作用吗?
没有哪一门技术是“银弹”,消息队列也有它的副作用。
@@ -127,45 +160,60 @@ tag:
那么,又如何 **解决消息堆积的问题** 呢?
-可用性降低,复杂度上升,又带来一系列的重复消费,顺序消费,分布式事务,消息堆积的问题,这消息队列还怎么用啊 😵?
+可用性降低、复杂度上升,同时还带来重复消费、顺序消费、分布式事务、消息堆积等一系列问题。这些问题如何解决?

-别急,办法总是有的。
+下面我们逐一讨论这些问题的解决方案。
## RocketMQ 是什么?
-
+
-哇,你个混蛋!上面给我抛出那么多问题,你现在又讲 `RocketMQ` ,还让不让人活了?!🤬
+在讨论上述问题的解决方案之前,我们先来了解一下 RocketMQ 的内部构造。建议带着问题去阅读和了解。
-别急别急,话说你现在清楚 `MQ` 的构造吗,我还没讲呢,我们先搞明白 `MQ` 的内部构造,再来看看如何解决上面的一系列问题吧,不过你最好带着问题去阅读和了解喔。
+RocketMQ 是一个 **队列模型** 的消息中间件,具有**高性能、高可靠、高实时、分布式** 的特点。它是一个采用 Java 语言开发的分布式消息系统,由阿里巴巴团队开发,在 2016 年底贡献给 Apache,成为了 Apache 的顶级项目。在阿里内部,RocketMQ 很好地服务了集团大大小小上千个应用,在每年的双十一当天,更有万亿级消息通过 RocketMQ 流转。
-`RocketMQ` 是一个 **队列模型** 的消息中间件,具有**高性能、高可靠、高实时、分布式** 的特点。它是一个采用 `Java` 语言开发的分布式的消息系统,由阿里巴巴团队开发,在 2016 年底贡献给 `Apache`,成为了 `Apache` 的一个顶级项目。 在阿里内部,`RocketMQ` 很好地服务了集团大大小小上千个应用,在每年的双十一当天,更有不可思议的万亿级消息通过 `RocketMQ` 流转。
-
-废话不多说,想要了解 `RocketMQ` 历史的同学可以自己去搜寻资料。听完上面的介绍,你只要知道 `RocketMQ` 很快、很牛、而且经历过双十一的实践就行了!
+RocketMQ 具备高吞吐、低延迟、高可用的特点,经过了双十一等大规模场景的验证。
## 队列模型和主题模型是什么?
-在谈 `RocketMQ` 的技术架构之前,我们先来了解一下两个名词概念——**队列模型** 和 **主题模型** 。
-
-首先我问一个问题,消息队列为什么要叫消息队列?
+在谈 RocketMQ 的技术架构之前,我们先来了解一下两个名词概念——**队列模型** 和 **主题模型** 。
-你可能觉得很弱智,这玩意不就是存放消息的队列嘛?不叫消息队列叫什么?
+首先,为什么消息队列叫消息队列?
-的确,早期的消息中间件是通过 **队列** 这一模型来实现的,可能是历史原因,我们都习惯把消息中间件成为消息队列。
+实际上,早期的消息中间件是通过 **队列** 这一模型来实现的,可能是历史原因,我们都习惯把消息中间件称为消息队列。
-但是,如今例如 `RocketMQ`、`Kafka` 这些优秀的消息中间件不仅仅是通过一个 **队列** 来实现消息存储的。
+但是,如今例如 RocketMQ、Kafka 这些优秀的消息中间件不仅仅是通过一个 **队列** 来实现消息存储的。
### 队列模型
-就像我们理解队列一样,消息中间件的队列模型就真的只是一个队列。。。我画一张图给大家理解。
+就像我们理解队列一样,消息中间件的队列模型就真的只是一个队列。

-在一开始我跟你提到了一个 **“广播”** 的概念,也就是说如果我们此时我们需要将一个消息发送给多个消费者(比如此时我需要将信息发送给短信系统和邮件系统),这个时候单个队列即不能满足需求了。
+队列模型的特点:**一个消息只能被一个消费者消费**。
+
+```mermaid
+flowchart LR
+ P["生产者"] --> Q["队列"]
+ Q --> C1["消费者1"]
+ Q --> C2["消费者2"]
+
+ classDef producer fill:#4CA497,color:#fff,rx:10,ry:10
+ classDef queue fill:#E99151,color:#fff,rx:10,ry:10
+ classDef consumer fill:#00838F,color:#fff,rx:10,ry:10
+
+ class P producer
+ class Q queue
+ class C1,C2 consumer
+
+ linkStyle default stroke-width:1.5px,opacity:0.8
+```
+
+在一开始我跟你提到了一个 **"广播"** 的概念,也就是说如果我们此时我们需要将一个消息发送给多个消费者(比如此时我需要将信息发送给短信系统和邮件系统),这个时候单个队列即不能满足需求了。
-当然你可以让 `Producer` 生产消息放入多个队列中,然后每个队列去对应每一个消费者。问题是可以解决,创建多个队列并且复制多份消息是会很影响资源和性能的。而且,这样子就会导致生产者需要知道具体消费者个数然后去复制对应数量的消息队列,这就违背我们消息中间件的 **解耦** 这一原则。
+当然你可以让 Producer 生产消息放入多个队列中,然后每个队列去对应每一个消费者。问题是可以解决,创建多个队列并且复制多份消息是会很影响资源和性能的。而且,这样子就会导致生产者需要知道具体消费者个数然后去复制对应数量的消息队列,这就违背我们消息中间件的 **解耦** 这一原则。
### 主题模型
@@ -179,25 +227,81 @@ tag:

+主题模型的特点:**一个消息可以被多个消费者消费**。
+
+```mermaid
+flowchart LR
+ P1["发布者1"] --> T["主题"]
+ P2["发布者2"] --> T
+ T --> S1["订阅者1"]
+ T --> S2["订阅者2"]
+ T --> S3["订阅者3"]
+
+ classDef publisher fill:#4CA497,color:#fff,rx:10,ry:10
+ classDef topic fill:#E99151,color:#fff,rx:10,ry:10
+ classDef subscriber fill:#00838F,color:#fff,rx:10,ry:10
+
+ class P1,P2 publisher
+ class T topic
+ class S1,S2,S3 subscriber
+
+ linkStyle default stroke-width:1.5px,opacity:0.8
+```
+
### RocketMQ 中的消息模型
-`RocketMQ` 中的消息模型就是按照 **主题模型** 所实现的。你可能会好奇这个 **主题** 到底是怎么实现的呢?你上面也没有讲到呀!
+RocketMQ 中的消息模型就是按照 **主题模型** 所实现的。那么 **主题** 到底是怎么实现的呢?
-其实对于主题模型的实现来说每个消息中间件的底层设计都是不一样的,就比如 `Kafka` 中的 **分区** ,`RocketMQ` 中的 **队列** ,`RabbitMQ` 中的 `Exchange` 。我们可以理解为 **主题模型/发布订阅模型** 就是一个标准,那些中间件只不过照着这个标准去实现而已。
+其实对于主题模型的实现来说每个消息中间件的底层设计都是不一样的,就比如 Kafka 中的 **分区** ,RocketMQ 中的 **队列** ,RabbitMQ 中的 Exchange 。我们可以理解为 **主题模型/发布订阅模型** 就是一个标准,那些中间件只不过照着这个标准去实现而已。
-所以,`RocketMQ` 中的 **主题模型** 到底是如何实现的呢?首先我画一张图,大家尝试着去理解一下。
+所以,RocketMQ 中的 **主题模型** 到底是如何实现的呢?先看一张图:

-我们可以看到在整个图中有 `Producer Group`、`Topic`、`Consumer Group` 三个角色,我来分别介绍一下他们。
+我们可以看到在整个图中有 `Producer Group`、Topic、`Consumer Group` 三个角色,我来分别介绍一下他们。
- `Producer Group` 生产者组:代表某一类的生产者,比如我们有多个秒杀系统作为生产者,这多个合在一起就是一个 `Producer Group` 生产者组,它们一般生产相同的消息。
- `Consumer Group` 消费者组:代表某一类的消费者,比如我们有多个短信系统作为消费者,这多个合在一起就是一个 `Consumer Group` 消费者组,它们一般消费相同的消息。
-- `Topic` 主题:代表一类消息,比如订单消息,物流消息等等。
+- Topic 主题:代表一类消息,比如订单消息,物流消息等等。
你可以看到图中生产者组中的生产者会向主题发送消息,而 **主题中存在多个队列**,生产者每次生产消息之后是指定主题中的某个队列发送消息的。
-每个主题中都有多个队列(分布在不同的 `Broker`中,如果是集群的话,`Broker`又分布在不同的服务器中),集群消费模式下,一个消费者集群多台机器共同消费一个 `topic` 的多个队列,**一个队列只会被一个消费者消费**。如果某个消费者挂掉,分组内其它消费者会接替挂掉的消费者继续消费。就像上图中 `Consumer1` 和 `Consumer2` 分别对应着两个队列,而 `Consumer3` 是没有队列对应的,所以一般来讲要控制 **消费者组中的消费者个数和主题中队列个数相同** 。
+每个主题中都有多个队列(分布在不同的 Broker 中,如果是集群的话,Broker 又分布在不同的服务器中),集群消费模式下,一个消费者集群多台机器共同消费一个 `topic` 的多个队列。
+
+**负载均衡策略对比**
+
+```mermaid
+flowchart TB
+ subgraph Queue["队列粒度负载均衡 4.x"]
+ style Queue fill:#F5F7FA,stroke:#E0E6ED,stroke-width:1.5px
+ direction TB
+ Q1["队列1"] --> C1["消费者1"]
+ Q2["队列2"] --> C2["消费者2"]
+ Q3["队列3"] --> C3["消费者3"]
+ Q4["队列4"] -.-> C4["消费者4
(无队列可消费)"]
+ end
+
+ subgraph Message["消息粒度负载均衡 5.x"]
+ style Message fill:#F5F7FA,stroke:#E0E6ED,stroke-width:1.5px
+ direction TB
+ MQ1["队列1"] --> MC1["消费者1
消费消息1"]
+ MQ1 --> MC2["消费者2
消费消息2"]
+ MQ1 --> MC3["消费者3
消费消息3"]
+ end
+
+ classDef queue fill:#4CA497,color:#fff,rx:10,ry:10
+ classDef consumer4x fill:#E99151,color:#fff,rx:10,ry:10
+ classDef consumer5x fill:#00838F,color:#fff,rx:10,ry:10
+
+ class Q1,Q2,Q3,Q4,MQ1 queue
+ class C1,C2,C3,C4 consumer4x
+ class MC1,MC2,MC3 consumer5x
+
+ linkStyle default stroke-width:1.5px,opacity:0.8
+```
+
+- **队列粒度负载均衡(4.x 默认策略)**:一个队列只会被一个消费者消费。如果某个消费者挂掉,分组内其它消费者会接替挂掉的消费者继续消费。就像上图中 `Consumer1` 和 `Consumer2` 分别对应着两个队列,而 `Consumer3` 是没有队列对应的,所以一般来讲要控制 **消费者组中的消费者个数和主题中队列个数相同** 。这种模式的缺点是容易产生 **长尾效应**:如果某个消费者处理速度较慢,会导致其对应的队列消息堆积,而其他消费者却处于空闲状态。
+- **消息粒度负载均衡(5.x 新增策略)**:同一消费者分组内的多个消费者将按照消息粒度平均分摊主题中的所有消息,即同一个队列中的消息,可被平均分配给多个消费者共同消费。消费者获取某条消息后,服务端会将该消息加锁,保证这条消息对其他消费者不可见,直到该消息消费成功或消费超时。这种模式有效解决了长尾效应问题,因为消息不再静态绑定到某个消费者,而是动态分配给空闲的消费者。
当然也可以消费者个数小于队列个数,只不过不太建议。如下图。
@@ -215,119 +319,622 @@ tag:

-但是,这样我生产者是不是只能向一个队列发送消息?又因为需要维护消费位置所以一个队列只能对应一个消费者组中的消费者,这样是不是其他的 `Consumer` 就没有用武之地了?从这两个角度来讲,并发度一下子就小了很多。
+但是,这样我生产者是不是只能向一个队列发送消息?又因为需要维护消费位置所以一个队列只能对应一个消费者组中的消费者,这样是不是其他的 Consumer 就没有用武之地了?从这两个角度来讲,并发度一下子就小了很多。
+
+所以总结来说,RocketMQ 通过**使用在一个 Topic 中配置多个队列并且每个队列维护每个消费者组的消费位置** 实现了 **主题模式/发布订阅模式** 。
+
+## RocketMQ 架构
+
+讲完了消息模型,我们理解起 RocketMQ 的技术架构起来就容易多了。
+
+RocketMQ 的核心组件包括 **NameServer、Broker、Producer、Consumer**,在 5.0 版本中还引入了 **Proxy** 组件。
+
+```mermaid
+flowchart TB
+ subgraph RocketMQ["RocketMQ 系统架构"]
+ direction TB
+ style RocketMQ fill:#F0F2F5,stroke:#E0E6ED,stroke-width:1.5px
+
+ subgraph Components["核心组件"]
+ direction TB
+ style Components fill:#F5F7FA,stroke:#E0E6ED,stroke-width:1.5px
+ NS["NameServer
注册中心"]
+ BK["Broker
消息存储"]
+ PX["Proxy
代理层(5.0+)"]
+ PD["Producer
生产者"]
+ CM["Consumer
消费者"]
+ end
+
+ subgraph Protocol["通信协议"]
+ direction LR
+ style Protocol fill:#F5F7FA,stroke:#E0E6ED,stroke-width:1.5px
+ RP["Remoting
私有协议"]
+ GP["gRPC
云原生协议"]
+ end
+
+ subgraph Network["网络层"]
+ style Network fill:#F5F7FA,stroke:#E0E6ED,stroke-width:1.5px
+ NB["Netty
高性能通信框架"]
+ end
+ end
+
+ NS <--> BK
+ NS <--> PD
+ NS <--> CM
+ PD <--> PX
+ CM <--> PX
+ PX <--> BK
+ PD -.->|Remoting 直连| BK
+ CM -.->|Remoting 直连| BK
+ BK --> NB
+ RP --> NB
+ GP --> NB
+
+ classDef ns fill:#E99151,color:#fff,rx:10,ry:10
+ classDef broker fill:#4CA497,color:#fff,rx:10,ry:10
+ classDef proxy fill:#005D7B,color:#fff,rx:10,ry:10
+ classDef producer fill:#00838F,color:#fff,rx:10,ry:10
+ classDef consumer fill:#7E57C2,color:#fff,rx:10,ry:10
+ classDef remoting fill:#FFC107,color:#333,rx:10,ry:10
+ classDef grpc fill:#26A69A,color:#fff,rx:10,ry:10
+ classDef netty fill:#EF5350,color:#fff,rx:10,ry:10
+
+ class NS ns
+ class BK broker
+ class PX proxy
+ class PD producer
+ class CM consumer
+ class RP remoting
+ class GP grpc
+ class NB netty
+
+ linkStyle default stroke-width:1.5px,opacity:0.8
+```
+
+### 核心组件要点
+
+| 组件 | 技术要点 |
+| -------------- | ---------------------------------------- |
+| **NameServer** | 轻量级注册中心,各节点无数据同步 |
+| **Broker** | 消息存储与投递,支持主从部署 |
+| **Proxy** | 5.0 新增,协议适配与计算卸载(可选组件) |
+| **Producer** | 同步、异步、单向多种发送方式 |
+| **Consumer** | Push/Pull/Simple 三种消费模式 |
+
+### NameServer(注册中心)
+
+NameServer 负责元数据的存储,扮演着集群"中枢神经系统"的角色,其核心作用是为生产者和消费者提供路由信息,帮助它们找到对应的 Broker 地址。
+
+**核心功能:**
+
+1. **Broker 管理**:Broker 启动时主动连接 NameServer,上报元数据信息。
+2. **路由信息管理**:生产者和消费者从 NameServer 获取 Broker 路由表。
+
+**心跳机制:**
+
+```mermaid
+flowchart LR
+ subgraph Heartbeat["心跳机制"]
+ style Heartbeat fill:#F5F7FA,stroke:#E0E6ED,stroke-width:1.5px
+ direction TB
+ BK["Broker"] -->|启动时| Reg["注册元数据"]
+ BK -->|每隔30秒| HB["发送心跳包"]
+ HB --> NS["NameServer
更新路由表"]
+ NS -->|每隔10秒检查| Check["检查心跳
(120秒超时)"]
+ Check -->|超时| Down["标记Broker宕机"]
+ end
+
+ classDef broker fill:#4CA497,color:#fff,rx:10,ry:10
+ classDef ns fill:#E99151,color:#fff,rx:10,ry:10
+ classDef check fill:#FFC107,color:#333,rx:10,ry:10
+ classDef down fill:#EF5350,color:#fff,rx:10,ry:10
+ classDef default fill:#4CA497,color:#fff,rx:10,ry:10
+
+ class BK broker
+ class NS ns
+ class Check check
+ class Down down
+ class Reg,HB default
+
+ linkStyle default stroke-width:1.5px,opacity:0.8
+```
+
+**元数据包含:**
+
+- Broker 的地址、名称、BrokerId
+- 主节点地址
+- 该 Broker 上的所有 Topic 的队列配置
+
+### Broker(消息存储)
+
+Broker 负责消息的存储、投递和查询以及服务高可用保证。
+
+**存储机制:**
+
+1. **消息写入**:收到消息后顺序追加到 CommitLog 文件
+2. **文件分割**:文件超过固定大小(默认 1G)生成新文件
+3. **逻辑分片**:MessageQueue 是逻辑分片,ConsumeQueue 是消息索引
+
+**一个 Topic 分布在多个 Broker 上,一个 Broker 可以配置多个 Topic ,它们是多对多的关系**。
+
+如果某个 Topic 消息量很大,应该给它多配置几个队列(上文中提到了提高并发能力),并且 **尽量多分布在不同 Broker 上,以减轻某个 Broker 的压力** 。
+
+Topic 消息量都比较均匀的情况下,如果某个 Broker 上的队列越多,则该 Broker 压力越大。
+
+
+
+### Producer(生产者)
+
+**发送流程:**
+
+```mermaid
+flowchart TB
+ subgraph ProducerFlow["生产者发送流程"]
+ direction TB
+ style ProducerFlow fill:#F5F7FA,stroke:#E0E6ED,stroke-width:1.5px
+
+ P["Producer 启动"] -->|1.建立长连接| NS1["连接 NameServer
获取路由表"]
+ NS1 -->|2.选择队列| LB["负载均衡算法
选择 MessageQueue"]
+ LB -->|3.建立连接| BK["与 Broker 建立长连接"]
+ BK -->|4.发送消息| MSG["发送消息到
MessageQueue"]
+ end
+
+ classDef producer fill:#00838F,color:#fff,rx:10,ry:10
+ classDef ns fill:#E99151,color:#fff,rx:10,ry:10
+ classDef lb fill:#FFC107,color:#333,rx:10,ry:10
+ classDef broker fill:#4CA497,color:#fff,rx:10,ry:10
+ classDef msg fill:#7E57C2,color:#fff,rx:10,ry:10
+
+ class P producer
+ class NS1 ns
+ class LB lb
+ class BK broker
+ class MSG msg
+
+ linkStyle default stroke-width:1.5px,opacity:0.8
+```
+
+**三种发送方式:**
+
+- **单向发送(Oneway)**:发送后立即返回,不关心是否成功
+- **同步发送(Sync)**:发送后等待响应
+- **异步发送(Async)**:发送后立即返回,在回调方法中处理响应
+
+### Consumer(消费者)
+
+**消费流程:**
+
+```mermaid
+flowchart TB
+ subgraph ConsumerFlow["消费者消费流程"]
+ direction TB
+ style ConsumerFlow fill:#F5F7FA,stroke:#E0E6ED,stroke-width:1.5px
+
+ C["Consumer 启动"] -->|1.建立长连接| NS2["连接 NameServer
获取路由表"]
+ NS2 -->|2.建立连接| BK2["与 Broker 建立连接"]
+ BK2 -->|3.消费消息| CONS["开始消费消息"]
+ CONS -->|4.提交位点| OFFSET["提交消费位点
保存消费进度"]
+ end
-所以总结来说,`RocketMQ` 通过**使用在一个 `Topic` 中配置多个队列并且每个队列维护每个消费者组的消费位置** 实现了 **主题模式/发布订阅模式** 。
+ classDef consumer fill:#7E57C2,color:#fff,rx:10,ry:10
+ classDef ns fill:#E99151,color:#fff,rx:10,ry:10
+ classDef broker fill:#4CA497,color:#fff,rx:10,ry:10
+ classDef consume fill:#00838F,color:#fff,rx:10,ry:10
+ classDef offset fill:#FFC107,color:#333,rx:10,ry:10
-## RocketMQ 的架构图
+ class C consumer
+ class NS2 ns
+ class BK2 broker
+ class CONS consume
+ class OFFSET offset
-讲完了消息模型,我们理解起 `RocketMQ` 的技术架构起来就容易多了。
+ linkStyle default stroke-width:1.5px,opacity:0.8
+```
+
+**三种消费模式:**
+
+- **拉取模式(Pull)**:消费者主动向 Broker 发送拉取请求
+- **推模式(Push)**:长轮询机制,Broker 有消息时才返回
+- **无状态模式(Pop)**:RocketMQ 5.0 新增,服务端管理重平衡和位点
+
+### 网络协议
-`RocketMQ` 技术架构中有四大角色 `NameServer`、`Broker`、`Producer`、`Consumer` 。我来向大家分别解释一下这四个角色是干啥的。
+RocketMQ 支持两种协议:
-- `Broker`:主要负责消息的存储、投递和查询以及服务高可用保证。说白了就是消息队列服务器嘛,生产者生产消息到 `Broker` ,消费者从 `Broker` 拉取消息并消费。
+| 协议 | Remoting(私有协议) | gRPC(云原生) |
+| -------------- | -------------------- | ------------------------- |
+| **性能** | 极致(私有协议优化) | 稍低(HTTP/2 头部开销) |
+| **多语言支持** | 高成本(需重复实现) | 低成本(官方/社区实现) |
+| **云原生集成** | 困难(需额外适配) | 原生支持(Istio/K8s) |
+| **可观测性** | 需额外开发 | 原生支持(OpenTelemetry) |
+| **适用场景** | 内部高性能场景 | 面向用户和云原生 |
- 这里,我还得普及一下关于 `Broker`、`Topic` 和 队列的关系。上面我讲解了 `Topic` 和队列的关系——一个 `Topic` 中存在多个队列,那么这个 `Topic` 和队列存放在哪呢?
+### 网络模块(基于 Netty)
- **一个 `Topic` 分布在多个 `Broker`上,一个 `Broker` 可以配置多个 `Topic` ,它们是多对多的关系**。
+RocketMQ 的 RPC 通信采用 Netty 作为底层通信库,基于 Reactor 多线程模型进行了深度扩展和优化。
- 如果某个 `Topic` 消息量很大,应该给它多配置几个队列(上文中提到了提高并发能力),并且 **尽量多分布在不同 `Broker` 上,以减轻某个 `Broker` 的压力** 。
+**线程模型总结:**
- `Topic` 消息量都比较均匀的情况下,如果某个 `broker` 上的队列越多,则该 `broker` 压力越大。
+- **Reactor 主线程**:1 个,负责监听连接
+- **Reactor 线程池**:默认 3 个,负责网络数据处理
+- **业务线程池**:动态调整,根据 CPU 核心数
- 
+### Proxy(代理层,5.0 新增)
- > 所以说我们需要配置多个 Broker。
+RocketMQ 5.0 引入了 **Proxy** 组件,这是 **计算与存储分离** 架构的核心体现。Proxy 作为客户端与 Broker 之间的代理层,将客户端协议适配、权限管理、消费管理等计算逻辑从 Broker 中剥离出来,使 Broker 更专注于消息存储和高可用。这种设计对于云原生架构非常重要,使得计算层可以独立弹性扩展。
-- `NameServer`:不知道你们有没有接触过 `ZooKeeper` 和 `Spring Cloud` 中的 `Eureka` ,它其实也是一个 **注册中心** ,主要提供两个功能:**Broker 管理** 和 **路由信息管理** 。说白了就是 `Broker` 会将自己的信息注册到 `NameServer` 中,此时 `NameServer` 就存放了很多 `Broker` 的信息(Broker 的路由表),消费者和生产者就从 `NameServer` 中获取路由表然后照着路由表的信息和对应的 `Broker` 进行通信(生产者和消费者定期会向 `NameServer` 去查询相关的 `Broker` 的信息)。
+**两种部署模式:**
-- `Producer`:消息发布的角色,支持分布式集群方式部署。说白了就是生产者。
+| 模式 | 说明 | 适用场景 |
+| ---------------- | ----------------------------------------------- | ---------------------------------------- |
+| **Local 模式** | Proxy 和 Broker 同进程部署,只需新增 Proxy 配置 | 从旧版本平滑升级,或无特殊需求的场景 |
+| **Cluster 模式** | Proxy 和 Broker 分别独立部署 | 需要弹性扩展或对协议适配有定制需求的场景 |
-- `Consumer`:消息消费的角色,支持分布式集群方式部署。支持以 push 推,pull 拉两种模式对消息进行消费。同时也支持集群方式和广播方式的消费,它提供实时消息订阅机制。说白了就是消费者。
+**核心作用:**
-听完了上面的解释你可能会觉得,这玩意好简单。不就是这样的么?
+- **协议适配**:支持 gRPC 协议接入,方便多语言客户端接入
+- **计算卸载**:将认证鉴权、消费管理等计算逻辑从 Broker 剥离,降低 Broker 负载
+- **弹性扩展**:Proxy 无状态,可独立水平扩展
+
+> **注意**:在 5.0 版本中,使用新版 SDK(gRPC 协议)的客户端需要通过 Proxy 接入,而旧版 SDK(Remoting 协议)仍然可以直连 Broker。
+
+### 为什么必须要 NameServer?
+
+先看一个简单的架构模型:

-嗯?你可能会发现一个问题,这老家伙 `NameServer` 干啥用的,这不多余吗?直接 `Producer`、`Consumer` 和 `Broker` 直接进行生产消息,消费消息不就好了么?
+你可能会发现一个问题:NameServer 是做什么的?直接让 Producer、Consumer 和 Broker 进行生产和消费消息不行吗?
-但是,我们上文提到过 `Broker` 是需要保证高可用的,如果整个系统仅仅靠着一个 `Broker` 来维持的话,那么这个 `Broker` 的压力会不会很大?所以我们需要使用多个 `Broker` 来保证 **负载均衡** 。
+Broker 需要保证高可用,如果整个系统仅靠一个 Broker 来维持,压力会非常大,所以需要使用多个 Broker 来保证 **负载均衡**。如果消费者和生产者直接和多个 Broker 相连,当 Broker 变更时会牵连每个生产者和消费者,产生耦合问题。NameServer 注册中心就是用来解决这个问题的。
-如果说,我们的消费者和生产者直接和多个 `Broker` 相连,那么当 `Broker` 修改的时候必定会牵连着每个生产者和消费者,这样就会产生耦合问题,而 `NameServer` 注册中心就是用来解决这个问题的。
+**NameServer 的设计哲学:**
-> 如果还不是很理解的话,可以去看我介绍 `Spring Cloud` 的那篇文章,其中介绍了 `Eureka` 注册中心。
+NameServer 是 **无状态的、各节点之间互不通信** 的。这与 ZooKeeper 的强一致性(需要选举机制)形成了鲜明对比,体现了 RocketMQ 追求 **极致性能和简单架构** 的设计哲学。每个 Broker 与所有 NameServer 保持长连接,定期上报自身信息,即使某个 NameServer 节点宕机,也不会影响整个集群的可用性。
-当然,`RocketMQ` 中的技术架构肯定不止前面那么简单,因为上面图中的四个角色都是需要做集群的。我给出一张官网的架构图,大家尝试理解一下。
+下面是官网的架构图:

-其实和我们最开始画的那张乞丐版的架构图也没什么区别,主要是一些细节上的差别。听我细细道来 🤨。
-
-第一、我们的 `Broker` **做了集群并且还进行了主从部署** ,由于消息分布在各个 `Broker` 上,一旦某个 `Broker` 宕机,则该`Broker` 上的消息读写都会受到影响。所以 `Rocketmq` 提供了 `master/slave` 的结构,`salve` 定时从 `master` 同步数据(同步刷盘或者异步刷盘),如果 `master` 宕机,**则 `slave` 提供消费服务,但是不能写入消息** (后面我还会提到哦)。
+和前面的简化架构图相比,主要是一些细节上的差别:
-第二、为了保证 `HA` ,我们的 `NameServer` 也做了集群部署,但是请注意它是 **去中心化** 的。也就意味着它没有主节点,你可以很明显地看出 `NameServer` 的所有节点是没有进行 `Info Replicate` 的,在 `RocketMQ` 中是通过 **单个 Broker 和所有 NameServer 保持长连接** ,并且在每隔 30 秒 `Broker` 会向所有 `Nameserver` 发送心跳,心跳包含了自身的 `Topic` 配置信息,这个步骤就对应这上面的 `Routing Info` 。
+第一、Broker **做了集群并且还进行了主从部署** ,由于消息分布在各个 Broker 上,一旦某个 Broker 宕机,则该 Broker 上的消息读写都会受到影响。所以 RocketMQ 提供了 `master/slave` 的结构,`slave` 定时从 `master` 同步数据(同步刷盘或者异步刷盘),如果 `master` 宕机,**则 `slave` 提供消费服务,但是不能写入消息** (后面还会详细说明)。
-第三、在生产者需要向 `Broker` 发送消息的时候,**需要先从 `NameServer` 获取关于 `Broker` 的路由信息**,然后通过 **轮询** 的方法去向每个队列中生产数据以达到 **负载均衡** 的效果。
+第二、为了保证 HA,NameServer 也做了集群部署,但它是 **去中心化** 的。也就意味着它没有主节点,可以明显看出 NameServer 的所有节点之间没有进行 `Info Replicate`。在 RocketMQ 中,**单个 Broker 和所有 NameServer 保持长连接**,并且 **每隔 30 秒** Broker 会向所有 NameServer 发送心跳,心跳包含了自身的 Topic 配置信息。NameServer **每隔 10 秒** 检查一次心跳,如果某个 Broker **超过 120 秒** 没有心跳,则认为该 Broker 已宕机。
-第四、消费者通过 `NameServer` 获取所有 `Broker` 的路由信息后,向 `Broker` 发送 `Pull` 请求来获取消息数据。`Consumer` 可以以两种模式启动—— **广播(Broadcast)和集群(Cluster)**。广播模式下,一条消息会发送给 **同一个消费组中的所有消费者** ,集群模式下消息只会发送给一个消费者。
+第三、在生产者需要向 Broker 发送消息的时候,**需要先从 NameServer 获取关于 Broker 的路由信息**,然后通过 **轮询** 的方法去向每个队列中生产数据以达到 **负载均衡** 的效果。
-## RocketMQ 功能特性
+第四、消费者通过 NameServer 获取所有 Broker 的路由信息后,向 Broker 发送 `Pull` 请求来获取消息数据。Consumer 可以以两种模式启动—— **广播(Broadcast)和集群(Cluster)**。广播模式下,一条消息会发送给 **同一个消费组中的所有消费者** ,集群模式下消息只会发送给一个消费者。
-### 消息
+## RocketMQ 消息
-#### 普通消息
+### 普通消息
普通消息一般应用于微服务解耦、事件驱动、数据集成等场景,这些场景大多数要求数据传输通道具有可靠传输的能力,且对消息的处理时机、处理顺序没有特别要求。以在线的电商交易场景为例,上游订单系统将用户下单支付这一业务事件封装成独立的普通消息并发送至 RocketMQ 服务端,下游按需从服务端订阅消息并按照本地消费逻辑处理下游任务。每个消息之间都是相互独立的,且不需要产生关联。另外还有日志系统,以离线的日志收集场景为例,通过埋点组件收集前端应用的相关操作日志,并转发到 RocketMQ 。
-
-
**普通消息生命周期**
+```mermaid
+ flowchart LR
+ N1["初始化"] --> N2["待消费"] --> N3["消费中"] --> N4["消费提交"] --> N5["消息删除"]
+
+ classDef default fill:#4CA497,color:#fff,rx:10,ry:10
+ classDef final fill:#00838F,color:#fff,rx:10,ry:10
+
+ class N1,N2,N3,N4 default
+ class N5 final
+
+ linkStyle default stroke-width:1.5px,opacity:0.8
+```
+
- 初始化:消息被生产者构建并完成初始化,待发送到服务端的状态。
- 待消费:消息被发送到服务端,对消费者可见,等待消费者消费的状态。
- 消费中:消息被消费者获取,并按照消费者本地的业务逻辑进行处理的过程。 此时服务端会等待消费者完成消费并提交消费结果,如果一定时间后没有收到消费者的响应,RocketMQ 会对消息进行重试处理。
- 消费提交:消费者完成消费处理,并向服务端提交消费结果,服务端标记当前消息已经被处理(包括消费成功和失败)。RocketMQ 默认支持保留所有消息,此时消息数据并不会立即被删除,只是逻辑标记已消费。消息在保存时间到期或存储空间不足被删除前,消费者仍然可以回溯消息重新消费。
- 消息删除:RocketMQ 按照消息保存机制滚动清理最早的消息数据,将消息从物理文件中删除。
-#### 定时消息
+### 定时/延时消息
+
+> **备注:定时消息和延时消息本质相同,都是服务端根据消息设置的定时时间在某一固定时刻将消息投递给消费者消费。**
+
+在分布式定时调度触发、任务超时处理等场景,需要实现精准、可靠的定时事件触发。使用 RocketMQ 的定时消息可以简化定时调度任务的开发逻辑,实现高性能、可扩展、高可靠的定时触发能力。
-在分布式定时调度触发、任务超时处理等场景,需要实现精准、可靠的定时事件触发。使用 RocketMQ 的定时消息可以简化定时调度任务的开发逻辑,实现高性能、可扩展、高可靠的定时触发能力。定时消息仅支持在 MessageType 为 Delay 的主题内使用,即定时消息只能发送至类型为定时消息的主题中,发送的消息的类型必须和主题的类型一致。在 4.x 版本中,只支持延时消息,默认分为 18 个等级分别为:1s 5s 10s 30s 1m 2m 3m 4m 5m 6m 7m 8m 9m 10m 20m 30m 1h 2h,也可以在配置文件中增加自定义的延时等级和时长。在 5.x 版本中,开始支持定时消息,在构造消息时提供了 3 个 API 来指定延迟时间或定时时间。
+**典型场景一:分布式定时调度**
+
+在分布式定时调度场景下,需要实现各类精度的定时任务,例如每天 5 点执行文件清理,每隔 2 分钟触发一次消息推送等需求。传统基于数据库的定时调度方案在分布式场景下,性能不高,实现复杂。
+
+**典型场景二:任务超时处理**
+
+以电商交易场景为例,订单下单后暂未支付,此时不可以直接关闭订单,而是需要等待一段时间后才能关闭订单。使用 RocketMQ 定时消息可以实现超时任务的检查触发。
基于定时消息的超时任务处理具备如下优势:
- **精度高、开发门槛低**:基于消息通知方式不存在定时阶梯间隔。可以轻松实现任意精度事件触发,无需业务去重。
- **高性能可扩展**:传统的数据库扫描方式较为复杂,需要频繁调用接口扫描,容易产生性能瓶颈。RocketMQ 的定时消息具有高并发和水平扩展的能力。
-
+**定时时间设置原则**
+
+RocketMQ 定时消息设置的定时时间是一个预期触发的系统时间戳,延时时间也需要转换成当前系统时间后的某一个时间戳,而不是一段延时时长。
+
+- **时间格式**:毫秒级的 Unix 时间戳
+- **定时时长最大值**:默认为 24 小时,不支持自定义修改
+- **定时时间必须设置在当前时间之后**,否则定时不生效,服务端会立即投递消息
+
+**示例**:
+
+- 定时消息:当前系统时间为 2022-06-09 17:30:00,希望消息在 19:20:00 投递,则定时时间戳为 1654773600000
+- 延时消息:当前系统时间为 2022-06-09 17:30:00,希望延时 1 小时后投递,则定时时间戳为 1654770600000
+
+**4.x 版本与 5.x 版本的区别**
+
+- **4.x 版本**:只支持延时消息,默认分为 18 个等级(1s 5s 10s 30s 1m 2m 3m 4m 5m 6m 7m 8m 9m 10m 20m 30m 1h 2h),也可以在配置文件中增加自定义的延时等级和时长。
+- **5.x 版本**:支持任意精度的定时消息,通过设置定时时间戳(毫秒级)来实现。底层采用了 **时间轮(TimingWheel)** 算法来高效管理大量定时任务,相比 4.x 版本的固定等级方式,大幅提升了灵活性和精度。
**定时消息生命周期**
-- 初始化:消息被生产者构建并完成初始化,待发送到服务端的状态。
-- 定时中:消息被发送到服务端,和普通消息不同的是,服务端不会直接构建消息索引,而是会将定时消息**单独存储在定时存储系统中**,等待定时时刻到达。
-- 待消费:定时时刻到达后,服务端将消息重新写入普通存储引擎,对下游消费者可见,等待消费者消费的状态。
-- 消费中:消息被消费者获取,并按照消费者本地的业务逻辑进行处理的过程。 此时服务端会等待消费者完成消费并提交消费结果,如果一定时间后没有收到消费者的响应,RocketMQ 会对消息进行重试处理。
-- 消费提交:消费者完成消费处理,并向服务端提交消费结果,服务端标记当前消息已经被处理(包括消费成功和失败)。RocketMQ 默认支持保留所有消息,此时消息数据并不会立即被删除,只是逻辑标记已消费。消息在保存时间到期或存储空间不足被删除前,消费者仍然可以回溯消息重新消费。
-- 消息删除:Apache RocketMQ 按照消息保存机制滚动清理最早的消息数据,将消息从物理文件中删除。
+```mermaid
+ flowchart LR
+ T1["初始化"] --> T2["定时中"] --> T3["待消费"] --> T4["消费中"] --> T5["消费提交"] --> T6["消息删除"]
+
+ classDef default fill:#E99151,color:#fff,rx:10,ry:10
+ classDef final fill:#00838F,color:#fff,rx:10,ry:10
+
+ class T1,T2,T3,T4,T5 default
+ class T6 final
+
+ linkStyle default stroke-width:1.5px,opacity:0.8
+```
+
+- **初始化**:消息被生产者构建并完成初始化,待发送到服务端的状态。
+- **定时中**:消息被发送到服务端,和普通消息不同的是,服务端不会直接构建消息索引,而是会将定时消息**单独存储在定时存储系统中**,等待定时时刻到达。
+- **待消费**:定时时刻到达后,服务端将消息重新写入普通存储引擎,对下游消费者可见,等待消费者消费的状态。
+- **消费中**:消息被消费者获取,并按照消费者本地的业务逻辑进行处理的过程。此时服务端会等待消费者完成消费并提交消费结果,如果一定时间后没有收到消费者的响应,RocketMQ 会对消息进行重试处理。
+- **消费提交**:消费者完成消费处理,并向服务端提交消费结果,服务端标记当前消息已经被处理(包括消费成功和失败)。RocketMQ 默认支持保留所有消息,此时消息数据并不会立即被删除,只是逻辑标记已消费。消息在保存时间到期或存储空间不足被删除前,消费者仍然可以回溯消息重新消费。
+- **消息删除**:Apache RocketMQ 按照消息保存机制滚动清理最早的消息数据,将消息从物理文件中删除。
+
+**使用限制**
+
+1. **消息类型一致性**:定时消息仅支持在 MessageType 为 Delay 的主题内使用
+2. **定时精度约束**:定时时长参数精确到毫秒级,但默认精度为 1000ms(秒级精度)
+
+**使用建议**
定时消息的实现逻辑需要先经过定时存储等待触发,定时时间到达后才会被投递给消费者。因此,如果将大量定时消息的定时时间设置为同一时刻,则到达该时刻后会有大量消息同时需要被处理,会造成系统压力过大,导致消息分发延迟,影响定时精度。
-#### 顺序消息
+### 顺序消息
+
+**什么是顺序消息**
+
+顺序消息是 Apache RocketMQ 提供的一种高级消息类型,支持消费者按照发送消息的先后顺序获取消息,从而实现业务场景中的顺序处理。
+
+**应用场景**
+
+在有序事件处理、撮合交易、数据实时增量同步等场景下,异构系统间需要维持强一致的状态同步,上游的事件变更需要按照顺序传递到下游进行处理。
+
+- **撮合交易**:以证券、股票交易撮合场景为例,对于出价相同的交易单,坚持按照先出价先交易的原则,下游处理订单的系统需要严格按照出价顺序来处理订单。
+- **数据实时增量同步**:以数据库变更增量同步场景为例,上游源端数据库按需执行增删改操作,将二进制操作日志作为消息,通过 RocketMQ 传输到下游搜索系统,下游系统按顺序还原消息数据,实现状态数据按序刷新。
+
+**如何保证消息的顺序性**
+
+RocketMQ 的消息顺序性分为两部分:**生产顺序性**和**消费顺序性**。
+
+**生产顺序性**
+
+如需保证消息生产的顺序性,则必须满足以下条件:
+
+1. **单一生产者**:消息生产的顺序性仅支持单一生产者
+2. **串行发送**:生产者使用多线程并行发送时,不同线程间产生的消息将无法判定其先后顺序
+
+满足以上条件的生产者,将顺序消息发送至 RocketMQ 后,会保证设置了同一**消息组**的消息,按照发送顺序存储在同一队列中。
+
+**消息组(MessageGroup)**
+
+RocketMQ 顺序消息的顺序关系通过消息组(MessageGroup)判定和识别,发送顺序消息时需要为每条消息设置归属的消息组。
+
+- **相同消息组**的多条消息之间遵循先进先出的顺序关系
+- **不同消息组**、无消息组的消息之间不涉及顺序性
+
+基于消息组的顺序判定逻辑,支持按照业务逻辑做细粒度拆分,可以在满足业务局部顺序的前提下提高系统的并行度和吞吐能力。
+
+```mermaid
+flowchart TB
+ subgraph Order["订单系统"]
+ style Order fill:#F5F7FA,stroke:#E0E6ED,stroke-width:1.5px
+ O1["订单A
消息组: orderA"]
+ O2["订单B
消息组: orderB"]
+ O3["订单C
消息组: orderC"]
+ end
+
+ subgraph Queue["队列"]
+ style Queue fill:#F5F7FA,stroke:#E0E6ED,stroke-width:1.5px
+ Q["队列1
(混合存储不同消息组)"]
+ end
+
+ subgraph Storage["存储顺序"]
+ style Storage fill:#F5F7FA,stroke:#E0E6ED,stroke-width:1.5px
+ direction LR
+ S1["orderA-M1
↓"]
+ S2["orderB-M1
↓"]
+ S3["orderA-M2
↓"]
+ S4["orderC-M1
↓"]
+ S5["orderB-M2
↓"]
+ end
+
+ O1 --> Q
+ O2 --> Q
+ O3 --> Q
+ Q --> Storage
+
+ classDef orderA fill:#4CA497,color:#fff,rx:10,ry:10
+ classDef orderB fill:#E99151,color:#fff,rx:10,ry:10
+ classDef orderC fill:#7E57C2,color:#fff,rx:10,ry:10
+ classDef queue fill:#00838F,color:#fff,rx:10,ry:10
+ classDef storage fill:#FFC107,color:#333,rx:10,ry:10
+
+ class O1 orderA
+ class O2 orderB
+ class O3 orderC
+ class Q queue
+ class S1,S2,S3,S4,S5 storage
+
+ linkStyle default stroke-width:1.5px,opacity:0.8
+```
+
+**说明**:
+
+- orderA 消息组的 M1、M2 保持顺序
+- orderB 消息组的 M1、M2 保持顺序
+- 不同消息组可以混合存储在同一个队列中
-顺序消息仅支持使用 MessageType 为 FIFO 的主题,即顺序消息只能发送至类型为顺序消息的主题中,发送的消息的类型必须和主题的类型一致。和普通消息发送相比,顺序消息发送必须要设置消息组。(推荐实现 MessageQueueSelector 的方式,见下文)。要保证消息的顺序性需要单一生产者串行发送。
+**消费顺序性**
-单线程使用 MessageListenerConcurrently 可以顺序消费,多线程环境下使用 MessageListenerOrderly 才能顺序消费。
+如需保证消息消费的顺序性,则必须满足以下条件:
-#### 事务消息
+1. **投递顺序**:RocketMQ 通过客户端 SDK 和服务端通信协议保障消息按照服务端存储顺序投递
+2. **有限重试**:顺序消息投递仅在重试次数限定范围内,超过最大重试次数后将不再重试,跳过这条消息消费
-事务消息是 Apache RocketMQ 提供的一种高级消息类型,支持在分布式场景下保障消息生产和本地事务的最终一致性。简单来讲,就是将本地事务(数据库的 DML 操作)与发送消息合并在同一个事务中。例如,新增一个订单。在事务未提交之前,不发送订阅的消息。发送消息的动作随着事务的成功提交而发送,随着事务的回滚而取消。当然真正地处理过程不止这么简单,包含了半消息、事务监听和事务回查等概念,下面有更详细的说明。
+**消费者类型对顺序消费的影响**
-## 关于发送消息
+- **PushConsumer**:RocketMQ 保证消息按照存储顺序一条一条投递给消费者
+- **SimpleConsumer**:消费者可能一次拉取多条消息,此时消息消费的顺序性需要由业务方自行保证
-### **不建议单一进程创建大量生产者**
+**生产顺序性和消费顺序性组合**
+
+| 生产顺序 | 消费顺序 | 顺序性效果 |
+| ---------------------------- | -------- | -------------------------------- |
+| 设置消息组,保证消息顺序发送 | 顺序消费 | 按照消息组粒度,严格保证消息顺序 |
+| 设置消息组,保证消息顺序发送 | 并发消费 | 并发消费,尽可能按时间顺序处理 |
+| 未设置消息组,消息乱序发送 | 顺序消费 | 按队列存储粒度,严格顺序 |
+| 未设置消息组,消息乱序发送 | 并发消费 | 并发消费,尽可能按照时间顺序处理 |
+
+**使用限制**
+
+1. **消息类型一致性**:顺序消息仅支持在 MessageType 为 FIFO 的主题内使用
+2. 顺序消息消费失败进行消费重试时,为保障消息的顺序性,后续消息不可被消费,必须等待前面的消息消费完成后才能被处理
+
+**使用建议**
+
+1. **串行消费**:消息消费建议串行处理,避免一次消费多条消息导致乱序
+2. **消息组尽可能打散**:建议将业务以消息组粒度进行拆分,例如将订单 ID、用户 ID 作为消息组关键字,可实现同一终端用户的消息按照顺序处理,不同用户的消息无需保证顺序
+
+### 事务消息
+
+**什么是事务消息**
+
+事务消息是 Apache RocketMQ 提供的一种高级消息类型,支持在分布式场景下保障消息生产和本地事务的最终一致性。简单来讲,就是将本地事务(数据库的 DML 操作)与发送消息合并在同一个事务中。
+
+**应用场景**
+
+在分布式系统调用的特点为一个核心业务逻辑的执行,同时需要调用多个下游业务进行处理。如何保证核心业务和多个下游业务的执行结果完全一致,是分布式事务需要解决的主要问题。
+
+以电商交易场景为例,用户支付订单这一核心操作的同时会涉及到下游物流发货、积分变更、购物车状态清空等多个子系统的变更:
+
+- **主分支订单系统状态更新**:由未支付变更为支付成功
+- **物流系统状态新增**:新增待发货物流记录,创建订单物流记录
+- **积分系统状态变更**:变更用户积分,更新用户积分表
+- **购物车系统状态变更**:清空购物车,更新用户购物车记录
+
+**传统方案的问题**
+
+- **传统 XA 事务方案**:基于 XA 协议的分布式事务系统可以实现一致性,但多分支环境下资源锁定范围大,并发度低
+- **基于普通消息方案**:普通消息和订单事务无法保证一致,容易出现消息发送成功但订单没有执行成功、订单执行成功但消息没有发送成功等情况
+
+**RocketMQ 事务消息方案**
+
+RocketMQ 事务消息的方案,具备高性能、可扩展、业务开发简单的优势,支持二阶段的提交能力,将二阶段提交和本地事务绑定,实现全局提交结果的一致性。
+
+**事务消息处理流程**
+
+```mermaid
+flowchart TB
+ subgraph Phase1["阶段一: 发送半事务消息"]
+ direction TB
+ style Phase1 fill:#F5F7FA,stroke:#E0E6ED,stroke-width:1.5px
+ M1["生产者构建消息"] --> M2["发送至服务端"]
+ M2 --> M3["服务端持久化消息"]
+ M3 --> M4["返回 Ack 确认"]
+ M4 --> M5["消息标记为
'暂不能投递'
(半事务消息)"]
+ end
+
+ subgraph Phase2["阶段二: 执行本地事务"]
+ direction TB
+ style Phase2 fill:#F5F7FA,stroke:#E0E6ED,stroke-width:1.5px
+ L1["生产者开始执行
本地事务逻辑"] --> L2{"本地事务
执行结果"}
+ L2 -->|Commit| L3["提交二次确认 Commit"]
+ L2 -->|Rollback| L4["提交二次确认 Rollback"]
+ L2 -->|Unknown| L5["等待事务回查"]
+ end
+
+ subgraph Phase3["阶段三: 事务回查机制"]
+ direction TB
+ style Phase3 fill:#F5F7FA,stroke:#E0E6ED,stroke-width:1.5px
+ C1["服务端未收到确认
或收到 Unknown"] --> C2["固定时间后
发起消息回查"]
+ C2 --> C3["生产者检查本地事务
最终状态"]
+ C3 --> C4["再次提交二次确认"]
+ end
+
+ subgraph Result["最终处理"]
+ style Result fill:#F5F7FA,stroke:#E0E6ED,stroke-width:1.5px
+ direction TB
+ R1["Commit: 消息投递给消费者"]
+ R2["Rollback: 回滚事务
不投递消息"]
+ end
+
+ Phase1 --> Phase2
+ L3 --> R1
+ L4 --> R2
+ L5 --> Phase3
+ C4 --> R1
+
+ classDef normal fill:#4CA497,color:#fff,rx:10,ry:10
+ classDef decision fill:#E99151,color:#fff,rx:10,ry:10
+ classDef result fill:#00838F,color:#fff,rx:10,ry:10
+
+ class M1,M2,M3,M4,M5,L1,C1,C2,C3,C4 normal
+ class L2,L3,L4,L5 decision
+ class R1,R2 result
+
+ linkStyle default stroke-width:1.5px,opacity:0.8
+```
+
+1. 生产者将消息发送至 RocketMQ 服务端
+2. 服务端将消息持久化成功之后,向生产者返回 Ack 确认消息已经发送成功,此时消息被标记为"暂不能投递",这种状态下的消息即为**半事务消息**
+3. 生产者开始执行本地事务逻辑
+4. 生产者根据本地事务执行结果向服务端提交二次确认结果(Commit 或 Rollback)
+5. 如果服务端未收到二次确认结果,或收到的结果为 Unknown,经过固定时间后,服务端将对消息生产者发起**消息回查**
+6. 生产者收到消息回查后,需要检查对应消息的本地事务执行的最终结果
+7. 生产者根据检查到的本地事务的最终状态再次提交二次确认
+
+**事务消息生命周期**
+
+- **初始化**:半事务消息被生产者构建并完成初始化,待发送到服务端的状态
+- **事务待提交**:半事务消息被发送到服务端,并不会直接被服务端持久化,而是会被单独存储到事务存储系统中,等待第二阶段本地事务返回执行结果后再提交。此时消息对下游消费者不可见
+- **消息回滚**:第二阶段如果事务执行结果明确为回滚,服务端会将半事务消息回滚,该事务消息流程终止
+- **提交待消费**:第二阶段如果事务执行结果明确为提交,服务端会将半事务消息重新存储到普通存储系统中,此时消息对下游消费者可见
+- **消费中**:消息被消费者获取,并按照消费者本地的业务逻辑进行处理的过程
+- **消费提交**:消费者完成消费处理,并向服务端提交消费结果
+- **消息删除**:RocketMQ 按照消息保存机制滚动清理最早的消息数据
+
+**使用限制**
+
+1. **消息类型一致性**:事务消息仅支持在 MessageType 为 Transaction 的主题内使用
+2. **消费事务性**:RocketMQ 事务消息保证本地主分支事务和下游消息发送事务的一致性,但不保证消息消费结果和上游事务的一致性
+3. **中间状态可见性**:事务消息为最终一致性,即消息提交到下游消费端处理完成之前,下游分支和上游事务之间的状态会不一致
+4. **事务超时机制**:事务消息的生命周期存在超时机制,半事务消息被生产者发送服务端后,如果在指定时间内服务端无法确认提交或者回滚状态,则消息默认会被回滚
+5. **事务回查机制**:服务端默认 **每隔 60 秒** 对未确认的半事务消息发起回查,**最多回查 15 次**。超过最大回查次数后,消息将被丢弃或进入死信队列
+
+**使用建议**
+
+1. **避免大量未决事务导致超时**:生产者应该尽量避免本地事务返回未知结果,大量的事务检查会导致系统性能受损
+2. **正确处理"进行中"的事务**:消息回查时,对于正在进行中的事务不要返回 Rollback 或 Commit 结果,应继续保持 Unknown 的状态
+
+### 关于发送消息
+
+#### 不建议单一进程创建大量生产者
Apache RocketMQ 的生产者和主题是多对多的关系,支持同一个生产者向多个主题发送消息。对于生产者的创建和初始化,建议遵循够用即可、最大化复用原则,如果有需要发送消息到多个主题的场景,无需为每个主题都创建一个生产者。
-### **不建议频繁创建和销毁生产者**
+#### 不建议频繁创建和销毁生产者
Apache RocketMQ 的生产者是可以重复利用的底层资源,类似数据库的连接池。因此不需要在每次发送消息时动态创建生产者,且在发送结束后销毁生产者。这样频繁的创建销毁会在服务端产生大量短连接请求,严重影响系统性能。
@@ -344,30 +951,77 @@ p.shutdown();
## 消费者分类
-### PushConsumer
+### PushConsumer(推模式消费者)
+
+**核心特点:**
高度封装的消费者类型,消费消息仅仅通过消费监听器监听并返回结果。消息的获取、消费状态提交以及消费重试都通过 RocketMQ 的客户端 SDK 完成。
-PushConsumer 的消费监听器执行结果分为以下三种情况:
+**适用场景:**
+
+- 消息处理时间可预估
+- 无异步化、高级定制需求
+- 希望快速开发的场景
+
+**使用示例:**
+
+```java
+public static void main(String[] args) throws InterruptedException, MQClientException {
+ // 创建 Push 模式消费者
+ DefaultMQPushConsumer consumer = new DefaultMQPushConsumer("CID_JODIE_1");
+
+ // 订阅主题
+ consumer.subscribe("TopicTest", "*");
+
+ // 设置从哪里开始消费
+ consumer.setConsumeFromWhere(ConsumeFromWhere.CONSUME_FROM_FIRST_OFFSET);
+
+ // 注册消息监听器
+ consumer.registerMessageListener(new MessageListenerConcurrently() {
+ @Override
+ public ConsumeConcurrentlyStatus consumeMessage(
+ List msgs,
+ ConsumeConcurrentlyContext context) {
+ System.out.printf("Receive New Messages: %s %n", msgs);
+ // 业务处理逻辑
+ return ConsumeConcurrentlyStatus.CONSUME_SUCCESS;
+ }
+ });
+
+ consumer.start();
+}
+```
+
+**消费监听器执行结果:**
-- 返回消费成功:以 Java SDK 为例,返回`ConsumeResult.SUCCESS`,表示该消息处理成功,服务端按照消费结果更新消费进度。
-- 返回消费失败:以 Java SDK 为例,返回`ConsumeResult.FAILURE`,表示该消息处理失败,需要根据消费重试逻辑判断是否进行重试消费。
-- 出现非预期失败:例如抛异常等行为,该结果按照消费失败处理,需要根据消费重试逻辑判断是否进行重试消费。
+- **返回消费成功**:表示该消息处理成功,服务端按照消费结果更新消费进度
+- **返回消费失败**:表示该消息处理失败,需要根据消费重试逻辑判断是否进行重试消费
+- **抛出异常**:按消费失败处理,需要根据消费重试逻辑判断是否进行重试消费
-具体实现可以参见这篇文章[RocketMQ 对 pull 和 push 的实现](http://devedmc.com/archives/1691854198138)。
+**使用注意事项:**
-使用 PushConsumer 消费者消费时,不允许使用以下方式处理消息,否则 RocketMQ 无法保证消息的可靠性。
+PushConsumer 消费时,不允许使用以下方式处理消息:
-- 错误方式一:消息还未处理完成,就提前返回消费成功结果。此时如果消息消费失败,RocketMQ 服务端是无法感知的,因此不会进行消费重试。
-- 错误方式二:在消费监听器内将消息再次分发到自定义的其他线程,消费监听器提前返回消费结果。此时如果消息消费失败,RocketMQ 服务端同样无法感知,因此也不会进行消费重试。
-- PushConsumer 严格限制了消息同步处理及每条消息的处理超时时间,适用于以下场景:
- - 消息处理时间可预估:如果不确定消息处理耗时,经常有预期之外的长时间耗时的消息,PushConsumer 的可靠性保证会频繁触发消息重试机制造成大量重复消息。
- - 无异步化、高级定制场景:PushConsumer 限制了消费逻辑的线程模型,由客户端 SDK 内部按最大吞吐量触发消息处理。该模型开发逻辑简单,但是不允许使用异步化和自定义处理流程。
+1. **错误方式一**:消息还未处理完成,就提前返回消费成功结果。此时如果消息消费失败,RocketMQ 服务端是无法感知的,因此不会进行消费重试。
+
+2. **错误方式二**:在消费监听器内将消息再次分发到自定义的其他线程,消费监听器提前返回消费结果。此时如果消息消费失败,RocketMQ 服务端同样无法感知,因此也不会进行消费重试。
+
+**Push 模式工作原理:**
+
+1. **负载均衡**:RebalanceService 线程根据队列数量和消费者个数做负载均衡,将分配到的队列发布 pullRequest 到 pullRequestQueue
+2. **消息拉取**:PullMessageService 线程不断从 pullRequestQueue 获取 pullRequest,从 Broker 拉取消息并缓存到 ProcessQueue
+3. **消息消费**:ConsumeMessageService 线程从 ProcessQueue 获取消息,调用监听器处理业务逻辑
+4. **位点提交**:消费完成后自动提交消费位点
+5. **流控保护**:拉取前检查缓存阈值(1000 消息或 100M),超过则延迟拉取
### SimpleConsumer
SimpleConsumer 是一种接口原子型的消费者类型,消息的获取、消费状态提交以及消费重试都是通过消费者业务逻辑主动发起调用完成。
+**消息不可见时间(Invisible Time):**
+
+SimpleConsumer 的核心机制是 **消息不可见时间**。当消费者获取消息后,该消息在指定的不可见时间内对其他消费者不可见。如果在不可见时间内完成消费并提交 ACK,消息被标记为已消费;如果超时未提交 ACK,消息会重新变为可见状态,可被其他消费者获取。这与 PushConsumer 的定时重试队列机制不同,SimpleConsumer 通过动态修改不可见时间来实现更灵活的重试控制。
+
一个来自官网的例子:
```java
@@ -409,9 +1063,132 @@ SimpleConsumer 适用于以下场景:
- 需要异步化、批量消费等高级定制场景:SimpleConsumer 在 SDK 内部没有复杂的线程封装,完全由业务逻辑自由定制,可以实现异步分发、批量消费等高级定制场景。
- 需要自定义消费速率:SimpleConsumer 是由业务逻辑主动调用接口获取消息,因此可以自由调整获取消息的频率,自定义控制消费速率。
-### PullConsumer
+**SimpleConsumer 工作原理:**
+
+1. **主动获取消息**:业务方调用 receive() 接口主动获取消息
+2. **业务处理**:获取到的消息由业务方自行处理
+3. **主动提交 ACK**:消费处理完成后,业务方主动调用 ack() 接口提交消费结果
+4. **高可控性**:业务方可完全控制消息处理时机和消费速率
+
+### PullConsumer(拉模式消费者)
+
+**核心特点:**
+
+Pull 模式下,**应用程序对消息的拉取过程参与度高,可控性强**,可以自主决定何时进行消息拉取,从什么位置 offset 拉取消息。
+
+**与 Push 模式的对比:**
+
+| 特性 | Push 模式 | Pull 模式 |
+| -------------- | -------------------- | ---------------- |
+| **控制权** | 客户端 SDK 自动拉取 | 应用程序主动拉取 |
+| **可控性** | 可控性不足 | 可控性高 |
+| **开发复杂度** | 简单,只需实现监听器 | 需要管理拉取过程 |
+| **适用场景** | 消息处理可预估 | 需要精细控制拉取 |
-施工中。。。
+**使用示例(DefaultMQPullConsumer):**
+
+```java
+@Test
+public void testPullConsumer() throws Exception {
+ DefaultMQPullConsumer consumer = new DefaultMQPullConsumer("group1_pull");
+ consumer.setNamesrvAddr(this.nameServer);
+ String topic = "topic1";
+ consumer.start();
+
+ // 获取 Topic 对应的消息队列
+ Set messageQueues = consumer.fetchSubscribeMessageQueues(topic);
+ int maxNums = 10; // 每次拉取消息的最大数量
+
+ while (true) {
+ boolean found = false;
+ for (MessageQueue messageQueue : messageQueues) {
+ // 获取消费位置
+ long offset = consumer.fetchConsumeOffset(messageQueue, false);
+ // 拉取消息
+ PullResult pullResult = consumer.pull(messageQueue, "tag8", offset, maxNums);
+
+ switch (pullResult.getPullStatus()) {
+ case FOUND:
+ found = true;
+ List msgs = pullResult.getMsgFoundList();
+ System.out.println("收到消息,数量----" + msgs.size());
+ // 处理消息
+ for (MessageExt msg : msgs) {
+ System.out.println("处理消息——" + msg.getMsgId());
+ }
+ // 更新消费位置
+ long nextOffset = pullResult.getNextBeginOffset();
+ consumer.updateConsumeOffset(messageQueue, nextOffset);
+ break;
+ case NO_NEW_MSG:
+ System.out.println("没有新消息");
+ break;
+ case NO_MATCHED_MSG:
+ System.out.println("没有匹配的消息");
+ break;
+ case OFFSET_ILLEGAL:
+ System.err.println("offset 错误");
+ break;
+ }
+ }
+ if (!found) {
+ // 没有队列中有新消息,则暂停一会
+ TimeUnit.MILLISECONDS.sleep(5000);
+ }
+ }
+}
+```
+
+**使用示例(DefaultLitePullConsumer - 推荐):**
+
+```java
+DefaultLitePullConsumer litePullConsumer =
+ new DefaultLitePullConsumer("lite_pull_consumer_test");
+litePullConsumer.setConsumeFromWhere(ConsumeFromWhere.CONSUME_FROM_FIRST_OFFSET);
+litePullConsumer.subscribe("TopicTest", "*");
+litePullConsumer.start();
+
+try {
+ while (running) {
+ // 应用程序主动调用 poll 方法拉取消息
+ List messageExts = litePullConsumer.poll();
+ System.out.printf("%s%n", messageExts);
+ }
+} finally {
+ litePullConsumer.shutdown();
+}
+```
+
+**适用场景:**
+
+- **需要精细控制拉取时机**:可以根据业务需求自主决定何时拉取消息
+- **需要控制消费速率**:可以灵活调整拉取频率
+- **批量消费场景**:可以一次性拉取大量消息进行批量处理
+- **特殊消费需求**:如需要从特定 offset 开始消费、需要暂停消费等
+
+**Pull 模式工作原理:**
+
+1. **负载均衡**:RebalanceService 线程发现消费快照发生变化时,启动消息拉取线程
+2. **消息拉取**:PullTaskImpl 拉取到消息后,把消息放到 consumeRequestCache
+3. **消息消费**:应用程序调用 poll 方法,不停地从 consumeRequestCache 拉取消息进行业务处理
+
+### 三种消费者类型对比
+
+| 对比项 | PushConsumer | SimpleConsumer | PullConsumer |
+| -------------- | ------------------------------------------------------------------------ | ---------------------------------------------------- | -------------------------------------------------- |
+| 接口方式 | 使用监听器回调接口返回消费结果,消费者仅允许在监听器范围内处理消费逻辑。 | 业务方自行实现消息处理,并主动调用接口返回消费结果。 | 业务方自行按队列拉取消息,并可选择性地提交消费结果 |
+| 消费并发度管理 | 由 SDK 管理消费并发度。 | 由业务方消费逻辑自行管理消费线程。 | 由业务方消费逻辑自行管理消费线程。 |
+| 负载均衡粒度 | 5.0 SDK 是消息粒度,更均衡,早期版本是队列维度 | 消息粒度,更均衡 | 队列粒度,吞吐攒批性能更好,但容易不均衡 |
+| 接口灵活度 | 高度封装,不够灵活。 | 原子接口,可灵活自定义。 | 原子接口,可灵活自定义。 |
+| 适用场景 | 适用于无自定义流程的业务消息开发场景。 | 适用于需要高度自定义业务流程的业务开发场景。 | 仅推荐在流处理框架场景下集成使用 |
+
+**选择建议:**
+
+- **普通场景**:优先使用 **PushConsumer**,开发简单,SDK 自动管理拉取和提交
+- **消息处理时长不可控**:使用 **SimpleConsumer**,可以自定义处理时长
+- **需要精细控制**:使用 **PullConsumer**,完全自主控制拉取过程
+
+**注意**:生产环境中相同的 ConsumerGroup 下严禁混用 PullConsumer 和其他两种消费者,否则会导致消息消费异常。
## 消费者分组和生产者分组
@@ -423,6 +1200,52 @@ RocketMQ 服务端 5.x 版本开始,**生产者是匿名的**,无需管理
消费者分组是多个消费行为一致的消费者的负载均衡分组。消费者分组不是具体实体而是一个逻辑资源。通过消费者分组实现消费性能的水平扩展以及高可用容灾。
+**消费者组的核心作用:**
+
+```mermaid
+flowchart TB
+ subgraph ConsumerGroup["消费者组概念"]
+ direction TB
+ style ConsumerGroup fill:#F0F2F5,stroke:#E0E6ED,stroke-width:1.5px
+
+ subgraph Cluster["集群消费模式"]
+ direction TB
+ style Cluster fill:#F5F7FA,stroke:#E0E6ED,stroke-width:1.5px
+ CG["消费者组"] --> C1["消费者1
消费队列1、2"]
+ CG --> C2["消费者2
消费队列3、4"]
+ CG --> C3["消费者3
空闲"]
+ Note1["任意一条消息
只需被消费组内
任意一个消费者处理"]
+ end
+
+ subgraph Broadcast["广播消费模式"]
+ direction TB
+ style Broadcast fill:#F5F7FA,stroke:#E0E6ED,stroke-width:1.5px
+ BG["消费者组"] --> B1["消费者1
消费所有消息"]
+ BG --> B2["消费者2
消费所有消息"]
+ BG --> B3["消费者3
消费所有消息"]
+ Note2["每条消息
推送给消费组
所有消费者"]
+ end
+
+ %% 优化:调整注释连线,避免跨子图渲染异常
+ C1 -.-> Note1
+ C2 -.-> Note1
+ C3 -.-> Note1
+ B1 -.-> Note2
+ B2 -.-> Note2
+ B3 -.-> Note2
+ end
+
+ classDef cg fill:#4CA497,color:#fff,rx:10,ry:10
+ classDef consumer fill:#E99151,color:#fff,rx:10,ry:10
+ classDef note fill:#00838F,color:#fff,rx:10,ry:10
+
+ class CG,BG cg
+ class C1,C2,C3,B1,B2,B3 consumer
+ class Note1,Note2 note
+
+ linkStyle default stroke-width:1.5px,opacity:0.8
+```
+
消费者分组中的订阅关系、投递顺序性、消费重试策略是一致的。
- 订阅关系:Apache RocketMQ 以消费者分组的粒度管理订阅关系,实现订阅关系的管理和追溯。
@@ -433,29 +1256,37 @@ RocketMQ 服务端 5.x 版本:上述消费者的消费行为从关联的消费
RocketMQ 服务端 3.x/4.x 历史版本:上述消费逻辑由消费者客户端接口定义,因此,您需要自己在消费者客户端设置时保证同一分组下的消费者的消费行为一致。(来自官方网站)
+**两种消费模式对比:**
+
+| 对比维度 | 集群消费模式 | 广播消费模式 |
+| ------------ | ---------------------------------------------- | ------------------------------------ |
+| **消息消费** | 任意一条消息只需被消费组内的任意一个消费者处理 | 每条消息推送给消费组所有消费者 |
+| **扩缩容** | 可通过扩缩消费者数量来提升或降低消费能力 | 扩缩消费者数量无法提升或降低消费能力 |
+| **适用场景** | 需要提升消费能力、避免重复消费 | 需要所有消费者都收到消息 |
+
## 如何解决顺序消费和重复消费?
-其实,这些东西都是我在介绍消息队列带来的一些副作用的时候提到的,也就是说,这些问题不仅仅挂钩于 `RocketMQ` ,而是应该每个消息中间件都需要去解决的。
+其实,这些东西都是我在介绍消息队列带来的一些副作用的时候提到的,也就是说,这些问题不仅仅挂钩于 RocketMQ ,而是应该每个消息中间件都需要去解决的。
-在上面我介绍 `RocketMQ` 的技术架构的时候我已经向你展示了 **它是如何保证高可用的** ,这里不涉及运维方面的搭建,如果你感兴趣可以自己去官网上照着例子搭建属于你自己的 `RocketMQ` 集群。
+在上面我介绍 RocketMQ 的技术架构的时候我已经向你展示了 **它是如何保证高可用的** ,这里不涉及运维方面的搭建,如果你感兴趣可以自己去官网上照着例子搭建属于你自己的 RocketMQ 集群。
-> 其实 `Kafka` 的架构基本和 `RocketMQ` 类似,只是它注册中心使用了 `Zookeeper`、它的 **分区** 就相当于 `RocketMQ` 中的 **队列** 。还有一些小细节不同会在后面提到。
+> 其实 Kafka 的架构基本和 RocketMQ 类似,只是它注册中心使用了 Zookeeper、它的 **分区** 就相当于 RocketMQ 中的 **队列** 。还有一些小细节不同会在后面提到。
### 顺序消费
-在上面的技术架构介绍中,我们已经知道了 **`RocketMQ` 在主题上是无序的、它只有在队列层面才是保证有序** 的。
+在上面的技术架构介绍中,我们已经知道了 **RocketMQ 在主题上是无序的、它只有在队列层面才是保证有序** 的。
这又扯到两个概念——**普通顺序** 和 **严格顺序** 。
-所谓普通顺序是指 消费者通过 **同一个消费队列收到的消息是有顺序的** ,不同消息队列收到的消息则可能是无顺序的。普通顺序消息在 `Broker` **重启情况下不会保证消息顺序性** (短暂时间) 。
+所谓普通顺序是指 消费者通过 **同一个消费队列收到的消息是有顺序的** ,不同消息队列收到的消息则可能是无顺序的。普通顺序消息在 Broker **重启情况下不会保证消息顺序性** (短暂时间) 。
所谓严格顺序是指 消费者收到的 **所有消息** 均是有顺序的。严格顺序消息 **即使在异常情况下也会保证消息的顺序性** 。
-但是,严格顺序看起来虽好,实现它可会付出巨大的代价。如果你使用严格顺序模式,`Broker` 集群中只要有一台机器不可用,则整个集群都不可用。你还用啥?现在主要场景也就在 `binlog` 同步。
+但是,严格顺序看起来虽好,实现它可会付出巨大的代价。如果你使用严格顺序模式,Broker 集群中只要有一台机器不可用,则整个集群都不可用。你还用啥?现在主要场景也就在 `binlog` 同步。
一般而言,我们的 `MQ` 都是能容忍短暂的乱序,所以推荐使用普通顺序模式。
-那么,我们现在使用了 **普通顺序模式** ,我们从上面学习知道了在 `Producer` 生产消息的时候会进行轮询(取决你的负载均衡策略)来向同一主题的不同消息队列发送消息。那么如果此时我有几个消息分别是同一个订单的创建、支付、发货,在轮询的策略下这 **三个消息会被发送到不同队列** ,因为在不同的队列此时就无法使用 `RocketMQ` 带来的队列有序特性来保证消息有序性了。
+那么,我们现在使用了 **普通顺序模式** ,我们从上面学习知道了在 Producer 生产消息的时候会进行轮询(取决你的负载均衡策略)来向同一主题的不同消息队列发送消息。那么如果此时我有几个消息分别是同一个订单的创建、支付、发货,在轮询的策略下这 **三个消息会被发送到不同队列** ,因为在不同的队列此时就无法使用 RocketMQ 带来的队列有序特性来保证消息有序性了。

@@ -463,32 +1294,46 @@ RocketMQ 服务端 3.x/4.x 历史版本:上述消费逻辑由消费者客户
其实很简单,我们需要处理的仅仅是将同一语义下的消息放入同一个队列(比如这里是同一个订单),那我们就可以使用 **Hash 取模法** 来保证同一个订单在同一个队列中就行了。
-RocketMQ 实现了两种队列选择算法,也可以自己实现
+**4.x 版本:使用 MessageQueueSelector**
+
+RocketMQ 4.x 版本通过继承 `MessageQueueSelector` 来实现自定义队列选择逻辑:
-- 轮询算法
+```java
+SendResult sendResult = producer.send(msg, new MessageQueueSelector() {
+ @Override
+ public MessageQueue select(List mqs, Message msg, Object arg) {
+ //根据订单ID等业务关键字计算队列索引
+ Integer orderId = (Integer) arg;
+ int index = orderId % mqs.size();
+ return mqs.get(index);
+ }
+}, orderId);
+```
- - 轮询算法就是向消息指定的 topic 所在队列中依次发送消息,保证消息均匀分布
- - 是 RocketMQ 默认队列选择算法
+**5.x 版本:使用消息组(MessageGroup)**
-- 最小投递延迟算法
+RocketMQ 5.x 版本引入了**消息组**的概念,通过设置消息组来保证同一组内消息的顺序性:
- - 每次消息投递的时候统计消息投递的延迟,选择队列时优先选择消息延时小的队列,导致消息分布不均匀,按照如下设置即可。
+```java
+Message message = messageBuilder.setTopic("topic")
+ .setTag("messageTag")
+ //设置顺序消息的排序分组
+ .setMessageGroup("fifoGroup001") // 比如使用订单ID作为消息组
+ .setBody("messageBody".getBytes())
+ .build();
+```
- - ```java
- producer.setSendLatencyFaultEnable(true);
- ```
+**队列选择算法**
-- 继承 MessageQueueSelector 实现
+RocketMQ 实现了两种队列选择算法:
- - ```java
- SendResult sendResult = producer.send(msg, new MessageQueueSelector() {
- @Override
- public MessageQueue select(List mqs, Message msg, Object arg) {
- //从mqs中选择一个队列,可以根据msg特点选择
- return null;
- }
- }, new Object());
- ```
+- **轮询算法**(默认):向消息指定的 topic 所在队列中依次发送消息,保证消息均匀分布
+- **最小投递延迟算法**:每次消息投递的时候统计消息投递的延迟,选择队列时优先选择消息延时小的队列
+
+```java
+// 启用最小投递延迟算法
+producer.setSendLatencyFaultEnable(true);
+```
### 特殊情况处理
@@ -508,7 +1353,7 @@ producer.setRetryTimesWhenSendFailed(5);
### 重复消费
-emmm,就两个字—— **幂等** 。在编程中一个*幂等* 操作的特点是其任意多次执行所产生的影响均与一次执行的影响相同。比如说,这个时候我们有一个订单的处理积分的系统,每当来一个消息的时候它就负责为创建这个订单的用户的积分加上相应的数值。可是有一次,消息队列发送给订单系统 FrancisQ 的订单信息,其要求是给 FrancisQ 的积分加上 500。但是积分系统在收到 FrancisQ 的订单信息处理完成之后返回给消息队列处理成功的信息的时候出现了网络波动(当然还有很多种情况,比如 Broker 意外重启等等),这条回应没有发送成功。
+解决重复消费的核心思路就是两个字—— **幂等** 。在编程中,一个*幂等*操作的特点是其任意多次执行所产生的影响均与一次执行的影响相同。比如说,这个时候我们有一个订单的处理积分的系统,每当来一个消息的时候它就负责为创建这个订单的用户的积分加上相应的数值。可是有一次,消息队列发送给订单系统 FrancisQ 的订单信息,其要求是给 FrancisQ 的积分加上 500。但是积分系统在收到 FrancisQ 的订单信息处理完成之后返回给消息队列处理成功的信息的时候出现了网络波动(当然还有很多种情况,比如 Broker 意外重启等等),这条回应没有发送成功。
那么,消息队列没收到积分系统的回应会不会尝试重发这个消息?问题就来了,我再发这个消息,万一它又给 FrancisQ 的账户加上 500 积分怎么办呢?
@@ -518,7 +1363,7 @@ emmm,就两个字—— **幂等** 。在编程中一个*幂等* 操作的特
不过最主要的还是需要 **根据特定场景使用特定的解决方案** ,你要知道你的消息消费是否是完全不可重复消费还是可以忍受重复消费的,然后再选择强校验和弱校验的方式。毕竟在 CS 领域还是很少有技术银弹的说法。
-而在整个互联网领域,幂等不仅仅适用于消息队列的重复消费问题,这些实现幂等的方法,也同样适用于,**在其他场景中来解决重复请求或者重复调用的问题** 。比如将 HTTP 服务设计成幂等的,**解决前端或者 APP 重复提交表单数据的问题** ,也可以将一个微服务设计成幂等的,解决 `RPC` 框架自动重试导致的 **重复调用问题** 。
+而在整个互联网领域,幂等不仅仅适用于消息队列的重复消费问题,这些实现幂等的方法,也同样适用于,**在其他场景中来解决重复请求或者重复调用的问题** 。比如将 HTTP 服务设计成幂等的,**解决前端或者 APP 重复提交表单数据的问题** ,也可以将一个微服务设计成幂等的,解决 RPC 框架自动重试导致的 **重复调用问题** 。
## RocketMQ 如何实现分布式事务?
@@ -528,15 +1373,25 @@ emmm,就两个字—— **幂等** 。在编程中一个*幂等* 操作的特
如今比较常见的分布式事务实现有 2PC、TCC 和事务消息(half 半消息机制)。每一种实现都有其特定的使用场景,但是也有各自的问题,**都不是完美的解决方案**。
-在 `RocketMQ` 中使用的是 **事务消息加上事务反查机制** 来解决分布式事务问题的。我画了张图,大家可以对照着图进行理解。
+在 RocketMQ 中使用的是 **事务消息加上事务反查机制** 来解决分布式事务问题的。我画了张图,大家可以对照着图进行理解。

+**事务消息处理流程详解**
+
+1. **发送半事务消息**:生产者将消息发送至 RocketMQ 服务端
+2. **服务端确认**:服务端将消息持久化成功之后,向生产者返回 Ack 确认消息已经发送成功,此时消息被标记为"暂不能投递",这种状态下的消息即为**半事务消息**
+3. **执行本地事务**:生产者开始执行本地事务逻辑
+4. **提交二次确认**:生产者根据本地事务执行结果向服务端提交二次确认结果(Commit 或 Rollback)
+5. **事务回查**:如果服务端未收到二次确认结果,或收到的结果为 Unknown,经过固定时间后,服务端将对消息生产者发起**消息回查**
+6. **检查本地事务**:生产者收到消息回查后,需要检查对应消息的本地事务执行的最终结果
+7. **再次提交确认**:生产者根据检查到的本地事务的最终状态再次提交二次确认
+
在第一步发送的 half 消息 ,它的意思是 **在事务提交之前,对于消费者来说,这个消息是不可见的** 。
> 那么,如何做到写入消息但是对用户不可见呢?RocketMQ 事务消息的做法是:如果消息是 half 消息,将备份原消息的主题与消息消费队列,然后 **改变主题** 为 RMQ_SYS_TRANS_HALF_TOPIC。由于消费组未订阅该主题,故消费端无法消费 half 类型的消息,**然后 RocketMQ 会开启一个定时任务,从 Topic 为 RMQ_SYS_TRANS_HALF_TOPIC 中拉取消息进行消费**,根据生产者组获取一个服务提供者发送回查事务状态请求,根据事务状态来决定是提交或回滚消息。
-你可以试想一下,如果没有从第 5 步开始的 **事务反查机制** ,如果出现网路波动第 4 步没有发送成功,这样就会产生 MQ 不知道是不是需要给消费者消费的问题,他就像一个无头苍蝇一样。在 `RocketMQ` 中就是使用的上述的事务反查来解决的,而在 `Kafka` 中通常是直接抛出一个异常让用户来自行解决。
+你可以试想一下,如果没有从第 5 步开始的 **事务回查机制** ,如果出现网路波动第 4 步没有发送成功,这样就会产生 MQ 不知道是不是需要给消费者消费的问题。在 RocketMQ 中就是使用的上述的事务回查来解决的,而在 Kafka 中通常是直接抛出一个异常让用户来自行解决。
你还需要注意的是,在 `MQ Server` 指向系统 B 的操作已经和系统 A 不相关了,也就是说在消息队列中的分布式事务是——**本地事务和存储消息到消息队列才是同一个事务**。这样也就产生了事务的**最终一致性**,因为整个过程是异步的,**每个系统只要保证它自己那一部分的事务就行了**。
@@ -760,15 +1615,15 @@ public class ConsumerAddViewHistory implements RocketMQListener {
> 当然,最快速解决消息堆积问题的方法还是增加消费者实例,不过 **同时你还需要增加每个主题的队列数量** 。
>
-> 别忘了在 `RocketMQ` 中,**一个队列只会被一个消费者消费** ,如果你仅仅是增加消费者实例就会出现我一开始给你画架构图的那种情况。
+> **注意**:在 RocketMQ 4.x 及之前的版本中,**一个队列只会被一个消费者消费**,如果你仅仅是增加消费者实例就会出现我一开始给你画架构图的那种情况(部分消费者没有队列可消费)。
+>
+> 但在 RocketMQ 5.x 及之后的版本中,引入了**消息粒度负载均衡策略**,同一消费者分组内的多个消费者可以按照消息粒度共同消费同一个队列中的消息,因此即使消费者数量多于队列数量,所有消费者也能参与到消费中。

## 什么是回溯消费?
-回溯消费是指 `Consumer` 已经消费成功的消息,由于业务上需求需要重新消费,在`RocketMQ` 中, `Broker` 在向`Consumer` 投递成功消息后,**消息仍然需要保留** 。并且重新消费一般是按照时间维度,例如由于 `Consumer` 系统故障,恢复后需要重新消费 1 小时前的数据,那么 `Broker` 要提供一种机制,可以按照时间维度来回退消费进度。`RocketMQ` 支持按照时间回溯消费,时间维度精确到毫秒。
-
-这是官方文档的解释,我直接照搬过来就当科普了 😁😁😁。
+回溯消费是指 Consumer 已经消费成功的消息,由于业务上需求需要重新消费,在 RocketMQ 中, Broker 在向 Consumer 投递成功消息后,**消息仍然需要保留** 。并且重新消费一般是按照时间维度,例如由于 Consumer 系统故障,恢复后需要重新消费 1 小时前的数据,那么 Broker 要提供一种机制,可以按照时间维度来回退消费进度。RocketMQ 支持按照时间回溯消费,时间维度精确到毫秒。
## RocketMQ 如何保证高性能读写
@@ -832,29 +1687,25 @@ RocketMQ 内部主要是使用基于 mmap 实现的零拷贝(其实就是调用
## RocketMQ 的刷盘机制
-上面我讲了那么多的 `RocketMQ` 的架构和设计原理,你有没有好奇
-
-在 `Topic` 中的 **队列是以什么样的形式存在的?**
-
-**队列中的消息又是如何进行存储持久化的呢?**
-
-我在上文中提到的 **同步刷盘** 和 **异步刷盘** 又是什么呢?它们会给持久化带来什么样的影响呢?
+了解了 RocketMQ 的架构和设计原理后,接下来探讨几个核心问题:
-下面我将给你们一一解释。
+- 在 Topic 中的 **队列是以什么样的形式存在的?**
+- **队列中的消息又是如何进行存储持久化的呢?**
+- **同步刷盘** 和 **异步刷盘** 是什么?它们会给持久化带来什么样的影响?
### 同步刷盘和异步刷盘

-如上图所示,在同步刷盘中需要等待一个刷盘成功的 `ACK` ,同步刷盘对 `MQ` 消息可靠性来说是一种不错的保障,但是 **性能上会有较大影响** ,一般地适用于金融等特定业务场景。
+如上图所示,在同步刷盘中需要等待一个刷盘成功的 ACK ,同步刷盘对 `MQ` 消息可靠性来说是一种不错的保障,但是 **性能上会有较大影响** ,一般地适用于金融等特定业务场景。
而异步刷盘往往是开启一个线程去异步地执行刷盘操作。消息刷盘采用后台异步线程提交的方式进行, **降低了读写延迟** ,提高了 `MQ` 的性能和吞吐量,一般适用于如发验证码等对于消息保证要求不太高的业务场景。
-一般地,**异步刷盘只有在 `Broker` 意外宕机的时候会丢失部分数据**,你可以设置 `Broker` 的参数 `FlushDiskType` 来调整你的刷盘策略(ASYNC_FLUSH 或者 SYNC_FLUSH)。
+一般地,**异步刷盘只有在 Broker 意外宕机的时候会丢失部分数据**,你可以设置 Broker 的参数 `FlushDiskType` 来调整你的刷盘策略(ASYNC_FLUSH 或者 SYNC_FLUSH)。
### 同步复制和异步复制
-上面的同步刷盘和异步刷盘是在单个结点层面的,而同步复制和异步复制主要是指的 `Borker` 主从模式下,主节点返回消息给客户端的时候是否需要同步从节点。
+上面的同步刷盘和异步刷盘是在单个节点层面的,而同步复制和异步复制主要是指 `Broker` 主从模式下,主节点返回消息给客户端的时候是否需要同步从节点。
- 同步复制:也叫 “同步双写”,也就是说,**只有消息同步双写到主从节点上时才返回写入成功** 。
- 异步复制:**消息写入主节点之后就直接返回写入成功** 。
@@ -863,72 +1714,103 @@ RocketMQ 内部主要是使用基于 mmap 实现的零拷贝(其实就是调用
那么,**异步复制会不会也像异步刷盘那样影响消息的可靠性呢?**
-答案是不会的,因为两者就是不同的概念,对于消息可靠性是通过不同的刷盘策略保证的,而像异步同步复制策略仅仅是影响到了 **可用性** 。为什么呢?其主要原因**是 `RocketMQ` 是不支持自动主从切换的,当主节点挂掉之后,生产者就不能再给这个主节点生产消息了**。
+答案是不会的,因为两者是不同的概念,消息可靠性是通过刷盘策略保证的,而同步/异步复制策略仅仅影响 **可用性** 。原因是**在默认配置下,RocketMQ 不支持自动主从切换,当主节点挂掉之后,生产者就不能再给这个主节点生产消息了**(但使用 DLedger 模式可以实现自动切换)。
比如这个时候采用异步复制的方式,在主节点还未发送完需要同步的消息的时候主节点挂掉了,这个时候从节点就少了一部分消息。但是此时生产者无法再给主节点生产消息了,**消费者可以自动切换到从节点进行消费**(仅仅是消费),所以在主节点挂掉的时间只会产生主从结点短暂的消息不一致的情况,降低了可用性,而当主节点重启之后,从节点那部分未来得及复制的消息还会继续复制。
-在单主从架构中,如果一个主节点挂掉了,那么也就意味着整个系统不能再生产了。那么这个可用性的问题能否解决呢?**一个主从不行那就多个主从的呗**,别忘了在我们最初的架构图中,每个 `Topic` 是分布在不同 `Broker` 中的。
+在单主从架构中,如果一个主节点挂掉了,那么整个系统就不能再生产消息了。那么这个可用性的问题能否解决呢?**可以通过多主从架构来解决**,在最初的架构图中,每个 Topic 是分布在不同 Broker 中的。

-但是这种复制方式同样也会带来一个问题,那就是无法保证 **严格顺序** 。在上文中我们提到了如何保证的消息顺序性是通过将一个语义的消息发送在同一个队列中,使用 `Topic` 下的队列来保证顺序性的。如果此时我们主节点 A 负责的是订单 A 的一系列语义消息,然后它挂了,这样其他节点是无法代替主节点 A 的,如果我们任意节点都可以存入任何消息,那就没有顺序性可言了。
+但是这种复制方式同样也会带来一个问题,那就是无法保证 **严格顺序** 。在上文中我们提到了如何保证的消息顺序性是通过将一个语义的消息发送在同一个队列中,使用 Topic 下的队列来保证顺序性的。如果此时我们主节点 A 负责的是订单 A 的一系列语义消息,然后它挂了,这样其他节点是无法代替主节点 A 的,如果我们任意节点都可以存入任何消息,那就没有顺序性可言了。
-而在 `RocketMQ` 中采用了 `Dledger` 解决这个问题。他要求在写入消息的时候,要求**至少消息复制到半数以上的节点之后**,才给客⼾端返回写⼊成功,并且它是⽀持通过选举来动态切换主节点的。这里我就不展开说明了,读者可以自己去了解。
+而在 RocketMQ 中采用了 DLedger 解决这个问题。DLedger 要求在写入消息的时候,**至少消息复制到半数以上的节点之后**,才给客户端返回写入成功,并且支持通过选举来动态切换主节点。
-> 也不是说 `Dledger` 是个完美的方案,至少在 `Dledger` 选举过程中是无法提供服务的,而且他必须要使用三个节点或以上,如果多数节点同时挂掉他也是无法保证可用性的,而且要求消息复制半数以上节点的效率和直接异步复制还是有一定的差距的。
+> DLedger 也不是完美的方案:在选举过程中是无法提供服务的;必须使用三个节点或以上;如果多数节点同时挂掉也无法保证可用性;要求消息复制到半数以上节点的效率和直接异步复制还是有一定差距的。
### 存储机制
-还记得上面我们一开始的三个问题吗?到这里第三个问题已经解决了。
+至此,刷盘和复制的问题已经解决了。
-但是,在 `Topic` 中的 **队列是以什么样的形式存在的?队列中的消息又是如何进行存储持久化的呢?** 还未解决,其实这里涉及到了 `RocketMQ` 是如何设计它的存储结构了。我首先想大家介绍 `RocketMQ` 消息存储架构中的三大角色——`CommitLog`、`ConsumeQueue` 和 `IndexFile` 。
+接下来讨论 **队列是以什么样的形式存在的?队列中的消息又是如何进行存储持久化的?** 这涉及到 RocketMQ 的存储结构设计。首先介绍 RocketMQ 消息存储架构中的三大角色——CommitLog、ConsumeQueue 和 IndexFile。
-- `CommitLog`:**消息主体以及元数据的存储主体**,存储 `Producer` 端写入的消息主体内容,消息内容不是定长的。单个文件大小默认 1G ,文件名长度为 20 位,左边补零,剩余为起始偏移量,比如 00000000000000000000 代表了第一个文件,起始偏移量为 0,文件大小为 1G=1073741824;当第一个文件写满了,第二个文件为 00000000001073741824,起始偏移量为 1073741824,以此类推。消息主要是**顺序写入日志文件**,当文件满了,写入下一个文件。
-- `ConsumeQueue`:消息消费队列,**引入的目的主要是提高消息消费的性能**(我们再前面也讲了),由于`RocketMQ` 是基于主题 `Topic` 的订阅模式,消息消费是针对主题进行的,如果要遍历 `commitlog` 文件中根据 `Topic` 检索消息是非常低效的。`Consumer` 即可根据 `ConsumeQueue` 来查找待消费的消息。其中,`ConsumeQueue`(逻辑消费队列)**作为消费消息的索引**,保存了指定 `Topic` 下的队列消息在 `CommitLog` 中的**起始物理偏移量 `offset` **,消息大小 `size` 和消息 `Tag` 的 `HashCode` 值。**`consumequeue` 文件可以看成是基于 `topic` 的 `commitlog` 索引文件**,故 `consumequeue` 文件夹的组织方式如下:topic/queue/file 三层组织结构,具体存储路径为:$HOME/store/consumequeue/{topic}/{queueId}/{fileName}。同样 `consumequeue` 文件采取定长设计,每一个条目共 20 个字节,分别为 8 字节的 `commitlog` 物理偏移量、4 字节的消息长度、8 字节 tag `hashcode`,单个文件由 30W 个条目组成,可以像数组一样随机访问每一个条目,每个 `ConsumeQueue`文件大小约 5.72M;
-- `IndexFile`:`IndexFile`(索引文件)提供了一种可以通过 key 或时间区间来查询消息的方法。这里只做科普不做详细介绍。
+**存储架构三大组件:**
-总结来说,整个消息存储的结构,最主要的就是 `CommitLoq` 和 `ConsumeQueue` 。而 `ConsumeQueue` 你可以大概理解为 `Topic` 中的队列。
+- **CommitLog**:**消息主体以及元数据的存储主体**,存储 Producer 端写入的消息主体内容,消息内容不是定长的。单个文件大小默认 **1G**,文件名长度为 20 位,左边补零,剩余为起始偏移量,比如 00000000000000000000 代表第一个文件,起始偏移量为 0;当第一个文件写满后,第二个文件为 00000000001073741824,起始偏移量为 1073741824,以此类推。消息主要是 **顺序写入日志文件**,当文件满了,写入下一个文件。
+- **ConsumeQueue**:消息消费队列,**引入的目的主要是提高消息消费的性能**。由于 RocketMQ 是基于主题 Topic 的订阅模式,如果要遍历 CommitLog 文件根据 Topic 检索消息是非常低效的。ConsumeQueue(逻辑消费队列)**作为消费消息的索引**,保存了指定 Topic 下的队列消息在 CommitLog 中的 **起始物理偏移量 offset**、消息大小 size 和消息 Tag 的 HashCode 值。ConsumeQueue 文件夹的组织方式为:topic/queue/file 三层组织结构,具体存储路径为:`$HOME/store/consumequeue/{topic}/{queueId}/{fileName}`。ConsumeQueue 文件采取定长设计,每一个条目共 **20 个字节**(8 字节 commitlog 物理偏移量 + 4 字节消息长度 + 8 字节 tag hashcode),单个文件由 **30 万个条目** 组成,每个 ConsumeQueue 文件大小约 **5.72M**。
+- **IndexFile**:索引文件,提供了一种可以通过 key 或时间区间来查询消息的方法。
+
+总结来说,整个消息存储的结构,最主要的就是 `CommitLog` 和 ConsumeQueue 。而 ConsumeQueue 可以理解为 Topic 中的队列。

-`RocketMQ` 采用的是 **混合型的存储结构** ,即为 `Broker` 单个实例下所有的队列共用一个日志数据文件来存储消息。有意思的是在同样高并发的 `Kafka` 中会为每个 `Topic` 分配一个存储文件。这就有点类似于我们有一大堆书需要装上书架,`RockeMQ` 是不分书的种类直接成批的塞上去的,而 `Kafka` 是将书本放入指定的分类区域的。
+RocketMQ 采用的是 **混合型的存储结构** ,即 Broker 单个实例下所有的队列共用一个日志数据文件(CommitLog)来存储消息。而 Kafka 会为每个分区(Partition)分配一个独立的存储文件。
-而 `RocketMQ` 为什么要这么做呢?原因是 **提高数据的写入效率** ,不分 `Topic` 意味着我们有更大的几率获取 **成批** 的消息进行数据写入,但也会带来一个麻烦就是读取消息的时候需要遍历整个大文件,这是非常耗时的。
+RocketMQ 这么做的原因是 **提高数据的写入效率** ,不分 Topic 意味着有更大的几率获取 **成批** 的消息进行顺序写入,但也带来一个问题:读取消息时如果遍历整个 CommitLog 文件,效率很低。
-所以,在 `RocketMQ` 中又使用了 `ConsumeQueue` 作为每个队列的索引文件来 **提升读取消息的效率**。我们可以直接根据队列的消息序号,计算出索引的全局位置(索引序号\*索引固定⻓度 20),然后直接读取这条索引,再根据索引中记录的消息的全局位置,找到消息。
+所以,RocketMQ 使用 ConsumeQueue 作为每个队列的索引文件来 **提升读取消息的效率**。可以直接根据队列的消息序号,计算出索引的全局位置(索引序号 × 索引固定长度 20),然后直接读取这条索引,再根据索引中记录的消息的全局位置找到消息。
-讲到这里,你可能对 `RockeMQ` 的存储架构还有些模糊,没事,我们结合着图来理解一下。
+下面结合架构图来理解存储结构:

-emmm,是不是有一点复杂 🤣,看英文图片和英文文档的时候就不要怂,硬着头皮往下看就行。
-
> 如果上面没看懂的读者一定要认真看下面的流程分析!
-首先,在最上面的那一块就是我刚刚讲的你现在可以直接 **把 `ConsumerQueue` 理解为 `Queue`**。
+首先,在图的最上面可以直接 **把 `ConsumerQueue` 理解为 Queue**。
-在图中最左边说明了红色方块代表被写入的消息,虚线方块代表等待被写入的。左边的生产者发送消息会指定 `Topic`、`QueueId` 和具体消息内容,而在 `Broker` 中管你是哪门子消息,他直接 **全部顺序存储到了 CommitLog**。而根据生产者指定的 `Topic` 和 `QueueId` 将这条消息本身在 `CommitLog` 的偏移(offset),消息本身大小,和 tag 的 hash 值存入对应的 `ConsumeQueue` 索引文件中。而在每个队列中都保存了 `ConsumeOffset` 即每个消费者组的消费位置(我在架构那里提到了,忘了的同学可以回去看一下),而消费者拉取消息进行消费的时候只需要根据 `ConsumeOffset` 获取下一个未被消费的消息就行了。
+在图中最左边说明了红色方块代表被写入的消息,虚线方块代表等待被写入的消息。左边的生产者发送消息会指定 Topic、`QueueId` 和具体消息内容,而在 Broker 中不区分消息类型,直接 **全部顺序存储到 CommitLog**。根据生产者指定的 Topic 和 `QueueId`,将这条消息在 CommitLog 中的偏移量(offset)、消息大小和 tag 的 hash 值存入对应的 ConsumeQueue 索引文件中。
-上述就是我对于整个消息存储架构的大概理解(这里不涉及到一些细节讨论,比如稀疏索引等等问题),希望对你有帮助。
+在每个队列中都保存了 `ConsumeOffset` 即每个消费者组的消费位置,消费者拉取消息进行消费时只需要根据 `ConsumeOffset` 获取下一个未被消费的消息即可。
-因为有一个知识点因为写嗨了忘讲了,想想在哪里加也不好,所以我留给大家去思考 🤔🤔 一下吧。
+以上就是 RocketMQ 存储架构的核心原理。
-为什么 `CommitLog` 文件要设计成固定大小的长度呢?提醒:**内存映射机制**。
+最后留一个思考题:**为什么 CommitLog 文件要设计成固定大小的长度呢?** 提示:与 **内存映射机制(mmap)** 有关。
## 总结
-总算把这篇博客写完了。我讲的你们还记得吗 😅?
+本文系统地介绍了 RocketMQ 的核心知识点,以下是关键内容回顾:
+
+**消息队列核心价值**
+
+- **异步**:提升系统响应速度,非核心流程异步化处理
+- **解耦**:降低系统间耦合度,通过发布订阅模式实现松耦合
+- **削峰**:缓解瞬时流量压力,保护下游系统不被冲垮
+
+**RocketMQ 架构要点**
+
+| 组件 | 核心职责 |
+| -------------- | -------------------------------------------- |
+| **NameServer** | 无状态注册中心,各节点互不通信,追求简单高效 |
+| **Broker** | 消息存储与投递,支持主从架构和 DLedger 模式 |
+| **Proxy** | 5.0 新增,计算与存储分离,支持 gRPC 协议 |
+| **Producer** | 消息生产者,支持同步、异步、单向发送 |
+| **Consumer** | 消息消费者,支持 Push、Pull、Simple 三种模式 |
+
+**消息类型对比**
+
+| 消息类型 | 适用场景 | 关键特性 |
+| ------------ | -------------------- | ------------------------ |
+| **普通消息** | 微服务解耦、事件驱动 | 无顺序要求,消息相互独立 |
+| **顺序消息** | 订单处理、数据同步 | 同一消息组内严格有序 |
+| **定时消息** | 延迟任务、超时处理 | 5.x 支持任意精度定时 |
+| **事务消息** | 分布式事务 | 半消息机制 + 事务回查 |
+
+**5.x 版本核心升级**
+
+- **消息粒度负载均衡**:解决长尾效应问题,消息动态分配给空闲消费者
+- **计算与存储分离**:Proxy 组件承担协议适配和计算逻辑,Broker 专注存储
+- **任意精度定时消息**:不再受限于固定延迟等级,支持毫秒级定时
+
+**高性能设计**
-这篇文章中我主要想大家介绍了
+- **顺序写**:CommitLog 采用顺序写入,充分利用磁盘顺序 IO 的高性能
+- **零拷贝**:基于 mmap 内存映射,减少数据拷贝次数和上下文切换
+- **索引设计**:ConsumeQueue 作为消息索引,避免遍历 CommitLog
-1. 消息队列出现的原因
-2. 消息队列的作用(异步,解耦,削峰)
-3. 消息队列带来的一系列问题(消息堆积、重复消费、顺序消费、分布式事务等等)
-4. 消息队列的两种消息模型——队列和主题模式
-5. 分析了 `RocketMQ` 的技术架构(`NameServer`、`Broker`、`Producer`、`Consumer`)
-6. 结合 `RocketMQ` 回答了消息队列副作用的解决方案
-7. 介绍了 `RocketMQ` 的存储机制和刷盘策略。
+**可靠性保障**
-等等。。。
+- **刷盘策略**:同步刷盘保证消息不丢失,异步刷盘提升性能
+- **主从复制**:同步复制(双写)保证数据一致性,异步复制提升可用性
+- **DLedger**:基于 Raft 协议实现自动主从切换,提升高可用能力
diff --git a/docs/high-performance/read-and-write-separation-and-library-subtable.md b/docs/high-performance/read-and-write-separation-and-library-subtable.md
index cbab61bd2c2..1873aaa32fb 100644
--- a/docs/high-performance/read-and-write-separation-and-library-subtable.md
+++ b/docs/high-performance/read-and-write-separation-and-library-subtable.md
@@ -1,13 +1,11 @@
---
title: 读写分离和分库分表详解
+description: 本文深入讲解数据库读写分离与分库分表的核心原理,涵盖主从复制机制、读写分离实现方案(代理/组件)、垂直分库分表与水平分库分表的区别,以及分库分表后的分布式事务、分布式ID、跨库JOIN等常见问题的解决方案。
category: 高性能
head:
- - meta
- name: keywords
- content: 读写分离,分库分表,主从复制
- - - meta
- - name: description
- content: 读写分离主要是为了将对数据库的读写操作分散到不同的数据库节点上。 这样的话,就能够小幅提升写性能,大幅提升读性能。 读写分离基于主从复制,MySQL 主从复制是依赖于 binlog 。分库就是将数据库中的数据分散到不同的数据库上。分表就是对单表的数据进行拆分,可以是垂直拆分,也可以是水平拆分。引入分库分表之后,需要系统解决事务、分布式 id、无法 join 操作问题。
+ content: 读写分离,分库分表,主从复制,水平分表,垂直分库,ShardingSphere,MyCat,分布式ID,跨库查询
---
## 读写分离
@@ -63,7 +61,7 @@ MySQL binlog(binary log 即二进制日志文件) 主要记录了 MySQL 数据
3. 从库会创建一个 I/O 线程向主库请求更新的 binlog
4. 主库会创建一个 binlog dump 线程来发送 binlog ,从库中的 I/O 线程负责接收
5. 从库的 I/O 线程将接收的 binlog 写入到 relay log 中。
-6. 从库的 SQL 线程读取 relay log 同步数据本地(也就是再执行一遍 SQL )。
+6. 从库的 SQL 线程读取 relay log 同步数据到本地(也就是再执行一遍 SQL )。
怎么样?看了我对主从复制这个过程的讲解,你应该搞明白了吧!
@@ -194,18 +192,37 @@ MySQL 主从同步延时是指从库的数据落后于主库的数据,这种
- 单表的数据达到千万级别以上,数据库读写速度比较缓慢。
- 数据库中的数据占用的空间越来越大,备份时间越来越长。
-- 应用的并发量太大。
+- 应用的并发量太大(应该优先考虑其他性能优化方法,而非分库分表)。
+
+不过,分库分表的成本太高,如非必要尽量不要采用。而且,并不一定是单表千万级数据量就要分表,毕竟每张表包含的字段不同,它们在不错的性能下能够存放的数据量也不同,还是要具体情况具体分析。
+
+之前看过一篇文章分析 “[InnoDB 中高度为 3 的 B+ 树最多可以存多少数据](https://juejin.cn/post/7165689453124517896)”,写的挺不错,感兴趣的可以看看。
### 常见的分片算法有哪些?
分片算法主要解决了数据被水平分片之后,数据究竟该存放在哪个表的问题。
-- **哈希分片**:求指定 key(比如 id) 的哈希,然后根据哈希值确定数据应被放置在哪个表中。哈希分片比较适合随机读写的场景,不太适合经常需要范围查询的场景。
-- **范围分片**:按照特性的范围区间(比如时间区间、ID 区间)来分配数据,比如 将 `id` 为 `1~299999` 的记录分到第一个库, `300000~599999` 的分到第二个库。范围分片适合需要经常进行范围查找的场景,不太适合随机读写的场景(数据未被分散,容易出现热点数据的问题)。
+常见的分片算法有:
+
+- **哈希分片**:求指定分片键的哈希,然后根据哈希值确定数据应被放置在哪个表中。哈希分片比较适合随机读写的场景,不太适合经常需要范围查询的场景。哈希分片可以使每个表的数据分布相对均匀,但对动态伸缩(例如新增一个表或者库)不友好。
+- **范围分片**:按照特定的范围区间(比如时间区间、ID 区间)来分配数据,比如 将 `id` 为 `1~299999` 的记录分到第一个表, `300000~599999` 的分到第二个表。范围分片适合需要经常进行范围查找且数据分布均匀的场景,不太适合随机读写的场景(数据未被分散,容易出现热点数据的问题)。
+- **映射表分片**:使用一个单独的表(称为映射表)来存储分片键和分片位置的对应关系。映射表分片策略可以支持任何类型的分片算法,如哈希分片、范围分片等。映射表分片策略是可以灵活地调整分片规则,不需要修改应用程序代码或重新分布数据。不过,这种方式需要维护额外的表,还增加了查询的开销和复杂度。
+- **一致性哈希分片**:将哈希空间组织成一个环形结构,将分片键和节点(数据库或表)都映射到这个环上,然后根据顺时针的规则确定数据或请求应该分配到哪个节点上,解决了传统哈希对动态伸缩不友好的问题。
- **地理位置分片**:很多 NewSQL 数据库都支持地理位置分片算法,也就是根据地理位置(如城市、地域)来分配数据。
-- **融合算法**:灵活组合多种分片算法,比如将哈希分片和范围分片组合。
+- **融合算法分片**:灵活组合多种分片算法,比如将哈希分片和范围分片组合。
- ……
+### 分片键如何选择?
+
+分片键(Sharding Key)是数据分片的关键字段。分片键的选择非常重要,它关系着数据的分布和查询效率。一般来说,分片键应该具备以下特点:
+
+- 具有共性,即能够覆盖绝大多数的查询场景,尽量减少单次查询所涉及的分片数量,降低数据库压力;
+- 具有离散性,即能够将数据均匀地分散到各个分片上,避免数据倾斜和热点问题;
+- 具有稳定性,即分片键的值不会发生变化,避免数据迁移和一致性问题;
+- 具有扩展性,即能够支持分片的动态增加和减少,避免数据重新分片的开销。
+
+实际项目中,分片键很难满足上面提到的所有特点,需要权衡一下。并且,分片键可以是表中多个字段的组合,例如取用户 ID 后四位作为订单 ID 后缀。
+
### 分库分表会带来什么问题呢?
记住,你在公司做的任何技术决策,不光是要考虑这个技术能不能满足我们的要求,是否适合当前业务场景,还要重点考虑其带来的成本。
@@ -214,7 +231,7 @@ MySQL 主从同步延时是指从库的数据落后于主库的数据,这种
- **join 操作**:同一个数据库中的表分布在了不同的数据库中,导致无法使用 join 操作。这样就导致我们需要手动进行数据的封装,比如你在一个数据库中查询到一个数据之后,再根据这个数据去另外一个数据库中找对应的数据。不过,很多大厂的资深 DBA 都是建议尽量不要使用 join 操作。因为 join 的效率低,并且会对分库分表造成影响。对于需要用到 join 操作的地方,可以采用多次查询业务层进行数据组装的方法。不过,这种方法需要考虑业务上多次查询的事务性的容忍度。
- **事务问题**:同一个数据库中的表分布在了不同的数据库中,如果单个操作涉及到多个数据库,那么数据库自带的事务就无法满足我们的要求了。这个时候,我们就需要引入分布式事务了。关于分布式事务常见解决方案总结,网站上也有对应的总结: 。
-- **分布式 ID**:分库之后, 数据遍布在不同服务器上的数据库,数据库的自增主键已经没办法满足生成的主键唯一了。我们如何为不同的数据节点生成全局唯一主键呢?这个时候,我们就需要为我们的系统引入分布式 ID 了。关于分布式 ID 的详细介绍&实现方案总结,网站上也有对应的总结: 。
+- **分布式 ID**:分库之后, 数据遍布在不同服务器上的数据库,数据库的自增主键已经没办法满足生成的主键唯一了。我们如何为不同的数据节点生成全局唯一主键呢?这个时候,我们就需要为我们的系统引入分布式 ID 了。关于分布式 ID 的详细介绍&实现方案总结,可以看我写的这篇文章:[分布式 ID 介绍&实现方案总结](https://javaguide.cn/distributed-system/distributed-id.html)。
- **跨库聚合查询问题**:分库分表会导致常规聚合查询操作,如 group by,order by 等变得异常复杂。这是因为这些操作需要在多个分片上进行数据汇总和排序,而不是在单个数据库上进行。为了实现这些操作,需要编写复杂的业务代码,或者使用中间件来协调分片间的通信和数据传输。这样会增加开发和维护的成本,以及影响查询的性能和可扩展性。
- ……
diff --git a/docs/high-performance/sql-optimization.md b/docs/high-performance/sql-optimization.md
index 9aa94dfd528..540b1c7afe3 100644
--- a/docs/high-performance/sql-optimization.md
+++ b/docs/high-performance/sql-optimization.md
@@ -1,19 +1,399 @@
---
-title: 常见SQL优化手段总结(付费)
+title: 常见SQL优化手段总结
+description: 本文系统总结常见的 SQL 优化手段,涵盖慢 SQL 定位与分析(EXPLAIN、Show Profile)、索引优化策略、查询重写技巧、分页优化等实战方法,帮助你快速提升数据库查询性能。
category: 高性能
head:
- - meta
- name: keywords
- content: 分页优化,索引,Show Profile,慢 SQL
- - - meta
- - name: description
- content: SQL 优化是一个大家都比较关注的热门话题,无论你在面试,还是工作中,都很有可能会遇到。如果某天你负责的某个线上接口,出现了性能问题,需要做优化。那么你首先想到的很有可能是优化 SQL 优化,因为它的改造成本相对于代码来说也要小得多。
+ content: SQL优化,慢SQL,EXPLAIN执行计划,索引优化,MySQL优化,查询优化,分页优化,Show Profile
---
-**常见 SQL 优化手段总结** 相关的内容为我的[知识星球](https://javaguide.cn/about-the-author/zhishixingqiu-two-years.html)(点击链接即可查看详细介绍以及加入方法)专属内容,已经整理到了[《Java 面试指北》](https://javaguide.cn/zhuanlan/java-mian-shi-zhi-bei.html)中。
+## 避免使用 SELECT \*
+
+- `SELECT *` 会消耗更多的 CPU。
+- `SELECT *` 无用字段增加网络带宽资源消耗,增加数据传输时间,尤其是大字段(如 varchar、blob、text)。
+- `SELECT *` 无法使用 MySQL 优化器覆盖索引的优化(基于 MySQL 优化器的“覆盖索引”策略又是速度极快,效率极高,业界极为推荐的查询优化方式)
+- `SELECT <字段列表>` 可减少表结构变更带来的影响。
+
+## 尽量避免多表做 join
+
+阿里巴巴《Java 开发手册》中有这样一段描述:
+
+> 【强制】超过三个表禁止 join。需要 join 的字段,数据类型保持绝对一致;多表关联查询时,保证被关联 的字段需要有索引。
+
+
+
+join 的效率比较低,主要原因是因为其使用嵌套循环(Nested Loop)来实现关联查询,以前常见的实现效率都不是很高:
+
+- **Simple Nested-Loop Join** :直接使用笛卡尔积实现 join,逐行遍历/全表扫描,效率最低。
+- **Block Nested-Loop Join (BNL)** :利用 JOIN BUFFER 进行优化。**注意:在 MySQL 8.0.20 及更高版本中,BNL 已被 Hash Join 取代**,Hash Join 通常能将非索引列关联的复杂度从 O(M\*N) 降低到接近 O(M+N)。
+- **Index Nested-Loop Join** :在必要的字段上增加索引,性能得到进一步提升。
+
+实际业务场景避免多表 join 常见的做法有两种:
+
+1. **单表查询后在内存中自己做关联** :对数据库做单表查询,再根据查询结果进行二次查询,以此类推,最后再进行关联。
+2. **数据冗余**,把一些重要的数据在表中做冗余,尽可能地避免关联查询。很笨的一种做法,表结构比较稳定的情况下才会考虑这种做法。进行冗余设计之前,思考一下自己的表结构设计的是否有问题。
+
+更加推荐第一种,这种在实际项目中的使用率比较高,除了性能不错之外,还有如下优势:
+
+1. **拆分后的单表查询代码可复用性更高** :join 联表 SQL 基本不太可能被复用。
+2. **单表查询更利于后续的维护** :不论是后续修改表结构还是进行分库分表,单表查询维护起来都更容易。
+
+不过,如果系统要求的并发量不大的话,我觉得多表 join 也是没问题的。很多公司内部复杂的系统,要求的并发量不高,很多数据必须 join 5 张以上的表才能查出来。
+
+## 深度分页优化
+
+深度分页问题的根本原因在于:当 `LIMIT` 的偏移量过大时,MySQL 需要扫描并跳过大量记录才能获取目标数据,查询优化器可能放弃索引而选择全表扫描。此时即使有索引,也无法避免大量的回表操作,导致查询性能急剧下降。
+
+本文介绍了四种常见的深度分页优化方案,各方案的特点及适用场景对比如下:
+
+| 优化方案 | 核心思路 | 适用场景 | 限制 |
+| ------------ | ------------------------------------------------------------------- | ----------------------------------- | ------------------------------------------------ |
+| **范围查询** | 记录上一页最后一条 ID,通过 `WHERE id > last_id LIMIT n` 获取下一页 | ID 连续、按 ID 排序、允许游标式翻页 | 不支持跳页、ID 不连续时失效、非 ID 排序不适用 |
+| **子查询** | 先通过子查询获取起始主键,再根据主键过滤 | 需要支持传统 OFFSET 翻页 | 子查询可能产生临时表、仅适用于 ID 正序 |
+| **延迟关联** | 用 `INNER JOIN` 将分页转移到主键索引,减少回表 | 大数据量分页、需要传统翻页逻辑 | SQL 相对复杂 |
+| **覆盖索引** | 建立包含查询字段的联合索引,避免回表 | 查询字段固定、可建立合适索引 | 字段较多时索引维护成本高、大结果集可能走全表扫描 |
+
+**方案选择建议**:
+
+- **优先使用延迟关联**:对于大多数需要支持传统 `LIMIT offset, size` 翻页逻辑的场景,延迟关联是性能和可维护性较好的选择。
+- **考虑范围查询(游标分页)**:如果业务允许使用"下一页"式的游标翻页(如社交媒体 feed 流、无限滚动),范围查询性能最佳且稳定。
+- **覆盖索引作为补充**:当查询字段固定且数量不多时,可配合其他方案建立覆盖索引进一步优化。
+
+**注意事项**:
+
+- 无论采用哪种方案,都应注意监控实际执行计划(`EXPLAIN`),确保优化器按预期使用索引。
+- 对于超深分页(如百万级偏移量),应从业务层面评估是否真的需要支持,考虑限制最大翻页数或采用其他检索方式(如搜索引擎)。
+
+详细介绍可以阅读这篇文章:[深度分页介绍及优化建议](https://javaguide.cn/high-performance/deep-pagination-optimization.html)。
+
+## 建议不要使用外键与级联
+
+阿里巴巴《Java 开发手册》中有这样一段描述:
+
+> 不得使用外键与级联,一切外键概念必须在应用层解决。
+
+
+
+网络上已经有非常多分析外键与级联缺陷的文章了,个人认为不建议使用外键主要是因为对分库分表不友好,性能方面的影响其实是比较小的。
+
+## 选择合适的字段类型
+
+存储字节越小,占用也就空间越小,性能也越好。
+
+**a.某些字符串可以转换成数字类型存储比如可以将 IP 地址转换成整型数据。**
+
+数字是连续的,性能更好,占用空间也更小。
+
+MySQL 提供了两个方法来处理 ip 地址
+
+- `INET_ATON()` : 把 IPv4 转为无符号整型(4 字节,32 位)。对于 IPv6,可使用 `INET6_ATON()` 转为 16 字节(128 位)的二进制字符串。
+- `INET_NTOA()` :把整型的 ip 转为地址
+
+插入数据前,先用 `INET_ATON()` 把 ip 地址转为整型,显示数据时,使用 `INET_NTOA()` 把整型的 ip 地址转为地址显示即可。
+
+**b.对于非负型的数据 (如自增 ID,整型 IP,年龄) 来说,要优先使用无符号整型来存储。**
+
+无符号相对于有符号可以多出一倍的存储空间
+
+```sql
+SIGNED INT -2147483648~2147483647
+UNSIGNED INT 0~4294967295
+```
+
+**c.小数值类型(比如年龄、状态表示如 0/1)优先使用 TINYINT 类型。**
+
+**d.对于日期类型来说, 一定不要用字符串存储日期。可以考虑 DATETIME、TIMESTAMP 和 数值型时间戳。**
+
+这三种种方式都有各自的优势,根据实际场景选择最合适的才是王道。下面再对这三种方式做一个简单的对比,以供大家实际开发中选择正确的存放时间的数据类型:
+
+| 类型 | 存储空间 | 日期格式 | 日期范围 | 是否带时区信息 |
+| ------------ | -------- | ------------------------------ | ------------------------------------------------------------ | -------------- |
+| DATETIME | 5~8 字节 | YYYY-MM-DD hh:mm:ss[.fraction] | 1000-01-01 00:00:00[.000000] ~ 9999-12-31 23:59:59[.999999] | 否 |
+| TIMESTAMP | 4~7 字节 | YYYY-MM-DD hh:mm:ss[.fraction] | 1970-01-01 00:00:01[.000000] ~ 2038-01-19 03:14:07[.999999] | 是 |
+| 数值型时间戳 | 4 字节 | 全数字如 1578707612 | 1970-01-01 00:00:01 之后的时间 | 否 |
+
+MySQL 时间类型选择的详细介绍请看这篇:[MySQL 时间类型数据存储建议](https://javaguide.cn/database/mysql/some-thoughts-on-database-storage-time.html)。
+
+**e.金额字段用 decimal,避免精度丢失。**
+
+decimal 用于存储有精度要求的小数比如与金钱相关的数据,可以避免浮点数带来的精度损失。
+
+在 Java 中,MySQL 的 decimal 类型对应的是 Java 类 `java.math.BigDecimal` 。
+
+`BigDecimal`的详细介绍请参考这篇:[BigDecimal 详解](https://javaguide.cn/java/basis/bigdecimal.html)。
+
+**f.尽量使用自增 id 作为主键。**
+
+如果主键为自增 id 的话,每次都会将数据加在 B+树尾部(本质是双向链表),时间复杂度为 O(1)。在写满一个数据页的时候,直接申请另一个新数据页接着写就可以了。
+
+如果主键是非自增 id 的话,为了让新加入数据后 B+树的叶子节点还能保持有序,它就需要往叶子结点的中间找,查找过程的时间复杂度是 O(lgn)。如果这个也被写满的话,就需要进行页分裂。页分裂操作需要加悲观锁,性能非常低。
+
+不过, 像分库分表这类场景就不建议使用自增 id 作为主键,应该使用分布式 ID 比如 uuid 。
+
+相关阅读:[数据库主键一定要自增吗?有哪些场景不建议自增?](https://mp.weixin.qq.com/s/vNRIFKjbe7itRTxmq-bkAA)。
+
+**g.不建议使用 `NULL` 作为列默认值。**
+
+`NULL` 跟 `''`(空字符串)是两个完全不一样的值,区别如下:
+
+- `NULL` 代表一个不确定的值,就算是两个 `NULL`,它俩也不一定相等。例如,`SELECT NULL=NULL`的结果为 false,但是在我们使用`DISTINCT`,`GROUP BY`,`ORDER BY`时,`NULL`又被认为是相等的。
+- `''`的长度是 0,是不占用空间的,而`NULL` 是需要占用空间的。
+- `NULL` 会影响聚合函数的结果。例如,`SUM`、`AVG`、`MIN`、`MAX` 等聚合函数会忽略 `NULL` 值。 `COUNT` 的处理方式取决于参数的类型。如果参数是 `*`(`COUNT(*)`),则会统计所有的记录数,包括 `NULL` 值;如果参数是某个字段名(`COUNT(列名)`),则会忽略 `NULL` 值,只统计非空值的个数。
+- 查询 `NULL` 值时,必须使用 `IS NULL` 或 `IS NOT NULLl` 来判断,而不能使用 =、!=、 <、> 之类的比较运算符。而`''`是可以使用这些比较运算符的。
+
+## 尽量用 UNION ALL 代替 UNION
+
+UNION 会把两个结果集的所有数据放到临时表中后再进行去重操作,更耗时,更消耗 CPU 资源。
+
+UNION ALL 不会再对结果集进行去重操作,获取到的数据包含重复的项。
+
+不过,如果实际业务场景中不允许产生重复数据的话,还是可以使用 UNION。
+
+## 优先使用批量操作
+
+对于数据库中的数据更新,如果能使用批量操作就要尽量使用,减少请求数据库的次数,提高性能。
+
+```sql
+# 反例
+INSERT INTO `cus_order` (`id`, `score`, `name`) VALUES (1, 426547, 'user1');
+INSERT INTO `cus_order` (`id`, `score`, `name`) VALUES (1, 33, 'user2');
+INSERT INTO `cus_order` (`id`, `score`, `name`) VALUES (1, 293854, 'user3');
+
+# 正例
+INSERT into `cus_order` (`id`, `score`, `name`) values(1, 426547, 'user1'),(1, 33, 'user2'),(1, 293854, 'user3');
+```
+
+## Show Profile 分析 SQL 执行性能
+
+为了更精准定位一条 SQL 语句的性能问题,需要清楚地知道这条 SQL 语句运行时消耗了多少系统资源。 [`SHOW PROFILE`](https://dev.mysql.com/doc/refman/5.7/en/show-profile.html) 和 [`SHOW PROFILES`](https://dev.mysql.com/doc/refman/5.7/en/show-profiles.html) 展示 SQL 语句的资源使用情况,展示的消息包括 CPU 的使用,CPU 上下文切换,IO 等待,内存使用等。
+
+MySQL 在 5.0.37 版本之后才支持 Profiling,`select @@have_profiling` 命令返回 `YES` 表示该功能可以使用。
+
+```sql
+ mysql> SELECT @@have_profiling;
++------------------+
+| @@have_profiling |
++------------------+
+| YES |
++------------------+
+1 row in set (0.00 sec)
+```
+
+> **注意** :`SHOW PROFILE` 和 `SHOW PROFILES` 已经被弃用,未来的 MySQL 版本中可能会被删除,取而代之的是使用 [Performance Schema](https://dev.mysql.com/doc/refman/8.0/en/performance-schema.html)。在该功能被删除之前,我们简单介绍一下其基本使用方法。
+
+想要使用 Profiling,请确保你的 `profiling` 是开启(on)的状态。
+
+你可以通过 `SHOW VARIABLES` 命令查看其状态:
+
+
+
+也可以通过 `SELECT @@profiling`命令进行查看:
+
+```sql
+mysql> SELECT @@profiling;
++-------------+
+| @@profiling |
++-------------+
+| 0 |
++-------------+
+1 row in set (0.00 sec)
+```
+
+默认情况下, `Profiling` 是关闭(off)的状态,你直接通过`SET @@profiling=1`命令即可开启。
+
+开启成功之后,我们执行几条 SQL 语句。执行完成之后,使用 `SHOW PROFILES` 可以展示当前 Session 下所有 SQL 语句的简要的信息包括 Query_ID(SQL 语句的 ID 编号) 和 Duration(耗时)。
+
+具体能收集多少个 SQL,由参数 `profiling_history_size` 决定,默认值为 15,最大值为 100。如果设置为 0,等同于关闭 Profiling。
+
+
+
+如果想要展示一个 SQL 语句的执行耗时细节,可以使用`SHOW PROFILE` 命令。
+
+`SHOW PROFILE` 命令的具体用法如下:
+
+```sql
+SHOW PROFILE [type [, type] ... ]
+ [FOR QUERY n]
+ [LIMIT row_count [OFFSET offset]]
+
+type: {
+ ALL
+ | BLOCK IO
+ | CONTEXT SWITCHES
+ | CPU
+ | IPC
+ | MEMORY
+ | PAGE FAULTS
+ | SOURCE
+ | SWAPS
+}
+```
+
+在执行`SHOW PROFILE` 命令时,可以加上类型子句,比如 CPU、IPC、MEMORY 等,查看具体某类资源的消耗情况:
+
+```sql
+SHOW PROFILE CPU,IPC FOR QUERY 8;
+```
+
+如果不加 `FOR QUERY {n}`子句,默认展示最新的一次 SQL 的执行情况,加了 `FOR QUERY {n}`,表示展示 Query_ID 为 n 的 SQL 的执行情况。
+
+
+
+## 优化慢 SQL
+
+为了优化慢 SQL ,我们首先要找到哪些 SQL 语句执行速度比较慢。
+
+MySQL 慢查询日志是用来记录 MySQL 在执行命令中,响应时间超过预设阈值的 SQL 语句。因此,通过分析慢查询日志我们就可以找出执行速度比较慢的 SQL 语句。
+
+出于性能层面的考虑,慢查询日志功能默认是关闭的,你可以通过以下命令开启:
+
+```sql
+# 开启慢查询日志功能
+SET GLOBAL slow_query_log = 'ON';
+# 慢查询日志存放位置
+SET GLOBAL slow_query_log_file = '/var/lib/mysql/ranking-list-slow.log';
+# 无论是否超时,未被索引的记录也会记录下来。
+SET GLOBAL log_queries_not_using_indexes = 'ON';
+# 慢查询阈值(秒),SQL 执行超过这个阈值将被记录在日志中。
+SET SESSION long_query_time = 1;
+# 慢查询仅记录扫描行数大于此参数的 SQL
+SET SESSION min_examined_row_limit = 100;
+```
+
+设置成功之后,使用 `show variables like 'slow%';` 命令进行查看。
+
+```bash
+| Variable_name | Value |
++---------------------+--------------------------------------+
+| slow_launch_time | 2 |
+| slow_query_log | ON |
+| slow_query_log_file | /var/lib/mysql/ranking-list-slow.log |
++---------------------+--------------------------------------+
+3 rows in set (0.01 sec)
+```
+
+我们故意在百万数据量的表(未使用索引)中执行一条排序的语句:
+
+```sql
+SELECT `score`,`name` FROM `cus_order` ORDER BY `score` DESC;
+```
+
+确保自己有对应目录的访问权限:
+
+```bash
+chmod 755 /var/lib/mysql/
+```
+
+查看对应的慢查询日志:
+
+```bash
+ cat /var/lib/mysql/ranking-list-slow.log
+```
+
+我们刚刚故意执行的 SQL 语句已经被慢查询日志记录了下来:
+
+```plain
+# Time: 2022-10-09T08:55:37.486797Z
+# User@Host: root[root] @ [172.17.0.1] Id: 14
+# Query_time: 0.978054 Lock_time: 0.000164 Rows_sent: 999999 Rows_examined: 1999998
+SET timestamp=1665305736;
+SELECT `score`,`name` FROM `cus_order` ORDER BY `score` DESC;
+```
+
+这里对日志中的一些信息进行说明:
+
+- `Time` :被日志记录的代码在服务器上的运行时间。
+- `User@Host`:谁执行的这段代码。
+- `Query_time`:这段代码运行时长。
+- `Lock_time`:执行这段代码时,锁定了多久。
+- `Rows_sent`:慢查询返回的记录。
+- `Rows_examined`:慢查询扫描过的行数。
+
+实际项目中,慢查询日志通常会比较复杂,我们需要借助一些工具对其进行分析。像 MySQL 内置的 `mysqldumpslow` 工具就可以把相同的 SQL 归为一类,并统计出归类项的执行次数和每次执行的耗时等一系列对应的情况。
+
+找到了慢 SQL 之后,我们可以通过 `EXPLAIN` 命令分析对应的 `SELECT` 语句:
+
+```sql
+mysql> EXPLAIN SELECT `score`,`name` FROM `cus_order` ORDER BY `score` DESC;
++----+-------------+-----------+------------+------+---------------+------+---------+------+--------+----------+----------------+
+| id | select_type | table | partitions | type | possible_keys | key | key_len | ref | rows | filtered | Extra |
++----+-------------+-----------+------------+------+---------------+------+---------+------+--------+----------+----------------+
+| 1 | SIMPLE | cus_order | NULL | ALL | NULL | NULL | NULL | NULL | 997572 | 100.00 | Using filesort |
++----+-------------+-----------+------------+------+---------------+------+---------+------+--------+----------+----------------+
+1 row in set, 1 warning (0.00 sec)
+```
+
+比较重要的字段说明:
+
+- `select_type` :查询的类型,常用的取值有 SIMPLE(普通查询,即没有联合查询、子查询)、PRIMARY(主查询)、UNION(UNION 中后面的查询)、SUBQUERY(子查询)等。
+- `table` :表示查询涉及的表或衍生表。
+- `type` :执行方式,判断查询是否高效的重要参考指标,结果值从差到好依次是:ALL < index < range ~ index_merge < ref < eq_ref < const < system。
+- `rows` : SQL 要查找到结果集需要扫描读取的数据行数,原则上 rows 越少越好。
+- ……
+
+关于 Explain 的详细介绍,请看这篇文章:[MySQL 执行计划分析](https://javaguide.cn/database/mysql/mysql-query-execution-plan.html)。另外,再推荐一下阿里的这篇文章:[慢 SQL 治理经验总结](https://mp.weixin.qq.com/s/LZRSQJufGRpRw6u4h_Uyww),总结的挺不错。
+
+## 正确使用索引
+
+正确使用索引可以大大加快数据的检索速度(大大减少检索的数据量)。
+
+### 选择合适的字段创建索引
+
+- **不为 NULL 的字段** :索引字段的数据应该尽量不为 NULL,因为对于数据为 NULL 的字段,数据库较难优化。如果字段频繁被查询,但又避免不了为 NULL,建议使用 0,1,true,false 这样语义较为清晰的短值或短字符作为替代。
+- **被频繁查询的字段** :我们创建索引的字段应该是查询操作非常频繁的字段。
+- **被作为条件查询的字段** :被作为 WHERE 条件查询的字段,应该被考虑建立索引。
+- **频繁需要排序的字段** :索引已经排序,这样查询可以利用索引的排序,加快排序查询时间。
+- **被经常频繁用于连接的字段** :经常用于连接的字段可能是一些外键列,对于外键列并不一定要建立外键,只是说该列涉及到表与表的关系。对于频繁被连接查询的字段,可以考虑建立索引,提高多表连接查询的效率。
+
+### 避免索引失效
+
+索引失效也是慢查询的主要原因之一,常见的导致索引失效的情况有下面这两类:
+
+**1. SQL 写法与底层逻辑冲突(破坏 B+Tree 有序性)**
+
+此类问题最为常见,本质是查询条件让底层的 B+Tree 失去了“二分查找”的快速定位能力。
+
+- **违背最左前缀原则**:跳过联合索引前导列,或遇到范围查询(如 `>`、`<`、`BETWEEN`、`LIKE "abc%"`)导致后续列中断精确定位,降级为范围扫描加过滤。
+- **对索引列进行加工**:在 `WHERE` 左侧对索引列进行数学计算或应用函数,导致原始数据发生逻辑改变,在索引树中呈现无序状态。
+- **隐式类型转换(隐蔽且致命)**:当“字符串类型的列”去比较“数字类型的值”时,MySQL 会默认在列上套用转换函数,直接破坏树的有序性。
+- **LIKE 模糊查询前置通配符**:如 `LIKE "%abc"`,前缀字符的不确定性使得优化器无法锁定扫描区间的起始点。
+- **ORDER BY 排序陷阱**:排序列未命中索引、排序方向与索引结构不一致等触发额外的内存或磁盘排序(`Using filesort`)。
+
+**2. 优化器的成本决策(基于 I/O 成本妥协)**
+
+此类问题并非索引本身不可用,而是 MySQL 优化器经过计算后,认为“不走普通索引”整体开销反而更小。
+
+- **无脑 `SELECT \*` 导致回表成本超载**:查询大量非索引覆盖列时,若命中数据量较大(通常超 20%~30%),优化器会判定全表扫描的顺序 I/O 优于频繁回表的随机 I/O,从而主动放弃索引。
+- **`OR` 条件导致全表扫描**:只要 `OR` 连接的任意一侧条件没有对应索引,就会触发全表扫描。即使两侧都有索引,若 Index Merge(索引合并)的预期成本过高,依然会被放弃。
+- **`IN` 列表过长引发估算失真**:当 `IN` 列表长度超过系统阈值(默认 200)时,优化器会从精准的深入探测(Index Dive)切换为粗略的统计估算,极易因统计信息陈旧而产生执行成本的误判。
+
+详细介绍:[MySQL索引失效场景总结](https://javaguide.cn/database/mysql/mysql-index-invalidation.html)。
+
+### 被频繁更新的字段应该慎重建立索引
+
+虽然索引能带来查询上的效率,但是维护索引的成本也是不小的。 如果一个字段不被经常查询,反而被经常修改,那么就更不应该在这种字段上建立索引了。
+
+### 尽可能的考虑建立联合索引而不是单列索引
+
+因为索引是需要占用磁盘空间的,可以简单理解为每个索引都对应着一颗 B+树。如果一个表的字段过多,索引过多,那么当这个表的数据达到一个体量后,索引占用的空间也是很多的,且修改索引时,耗费的时间也是较多的。如果是联合索引,多个字段在一个索引上,那么将会节约很大磁盘空间,且修改数据的操作效率也会提升。
+
+### 注意避免冗余索引
+
+冗余索引指的是索引的功能相同,能够命中索引(a, b)就肯定能命中索引(a) ,那么索引(a)就是冗余索引。如(name,city )和(name )这两个索引就是冗余索引,能够命中前者的查询肯定是能够命中后者的 在大多数情况下,都应该尽量扩展已有的索引而不是创建新索引。
+
+### 考虑在字符串类型的字段上使用前缀索引代替普通索引
+
+前缀索引仅限于字符串类型,较普通索引会占用更小的空间,所以可以考虑使用前缀索引带替普通索引。
+
+### 删除长期未使用的索引
-
+删除长期未使用的索引,不用的索引的存在会造成不必要的性能损耗 MySQL 5.7 可以通过查询 sys 库的 schema_unused_indexes 视图来查询哪些索引从未被使用
-
+## 参考
-
+- MySQL 8.2 Optimizing SQL Statements:https://dev.mysql.com/doc/refman/8.0/en/statement-optimization.html
+- 为什么阿里巴巴禁止数据库中做多表 join - Hollis:https://mp.weixin.qq.com/s/GSGVFkDLz1hZ1OjGndUjZg
+- MySQL 的 COUNT 语句,竟然都能被面试官虐的这么惨 - Hollis:https://mp.weixin.qq.com/s/IOHvtel2KLNi-Ol4UBivbQ
+- MySQL 性能优化神器 Explain 使用分析:https://segmentfault.com/a/1190000008131735
+- 如何使用 MySQL 慢查询日志进行性能优化 :https://kalacloud.com/blog/how-to-use-mysql-slow-query-log-profiling-mysqldumpslow/
diff --git a/docs/high-quality-technical-articles/readme.md b/docs/high-quality-technical-articles/README.md
similarity index 96%
rename from docs/high-quality-technical-articles/readme.md
rename to docs/high-quality-technical-articles/README.md
index 544d6de328d..149ddba7a4f 100644
--- a/docs/high-quality-technical-articles/readme.md
+++ b/docs/high-quality-technical-articles/README.md
@@ -23,6 +23,7 @@
## 程序员
+- [程序员最该拿的几种高含金量证书](./programmer/high-value-certifications-for-programmers.md)
- [程序员怎样出版一本技术书](./programmer/how-do-programmers-publish-a-technical-book.md)
- [程序员高效出书避坑和实践指南](./programmer/efficient-book-publishing-and-practice-guide.md)
diff --git a/docs/high-quality-technical-articles/advanced-programmer/20-bad-habits-of-bad-programmers.md b/docs/high-quality-technical-articles/advanced-programmer/20-bad-habits-of-bad-programmers.md
index 59191347757..e6dfd10f5d6 100644
--- a/docs/high-quality-technical-articles/advanced-programmer/20-bad-habits-of-bad-programmers.md
+++ b/docs/high-quality-technical-articles/advanced-programmer/20-bad-habits-of-bad-programmers.md
@@ -1,9 +1,14 @@
---
title: 糟糕程序员的 20 个坏习惯
+description: "糟糕程序员的 20 个坏习惯:围绕技术知识与面试总结梳理关键概念、常见问题与实践要点,帮助你高效学习与备战面试。"
category: 技术文章精选集
author: Kaito
tag:
- 练级攻略
+head:
+ - - meta
+ - name: keywords
+ content: 程序员坏习惯,编程规范,代码注释,技术文档,团队协作,代码提交,职业素养,编程修养
---
> **推荐语**:Kaito 大佬的一篇文章,很实用的建议!
diff --git a/docs/high-quality-technical-articles/advanced-programmer/meituan-three-year-summary-lesson-10.md b/docs/high-quality-technical-articles/advanced-programmer/meituan-three-year-summary-lesson-10.md
index 3ae2a5eac42..e43e0932b11 100644
--- a/docs/high-quality-technical-articles/advanced-programmer/meituan-three-year-summary-lesson-10.md
+++ b/docs/high-quality-technical-articles/advanced-programmer/meituan-three-year-summary-lesson-10.md
@@ -1,9 +1,14 @@
---
title: 美团三年,总结的10条血泪教训
+description: "美团三年,总结的10条血泪教训:围绕技术知识与面试总结梳理关键概念、常见问题与实践要点,帮助你高效学习与备战面试。"
category: 技术文章精选集
author: CityDreamer部落
tag:
- 练级攻略
+head:
+ - - meta
+ - name: keywords
+ content: 美团工作经验,职场成长,结构化思考,数据思维,职场沟通,金字塔原理,工作效率,职业发展
---
> **推荐语**:作者用了很多生动的例子和故事展示了自己在美团的成长和感悟,看了之后受益颇多!
@@ -25,7 +30,7 @@ tag:
>
> **原文地址**:
-在美团的三年多时光,如同一部悠长的交响曲,高高低低,而今离开已有一段时间。闲暇之余,梳理了三年多的收获与感慨,总结成 10 条,既是对过去一段时光的的一个深情回眸,也是对未来之路的一份期许。
+在美团的三年多时光,如同一部悠长的交响曲,高高低低,而今离开已有一段时间。闲暇之余,梳理了三年多的收获与感慨,总结成 10 条,既是对过去一段时光的一个深情回眸,也是对未来之路的一份期许。
倘若一些感悟能为刚步入职场的年轻人,或是刚在职业生涯中崭露头角的后起之秀,带来一点点启示与帮助,也是莫大的荣幸。
diff --git a/docs/high-quality-technical-articles/advanced-programmer/programmer-quickly-learn-new-technology.md b/docs/high-quality-technical-articles/advanced-programmer/programmer-quickly-learn-new-technology.md
index 3cd553a182e..13827588777 100644
--- a/docs/high-quality-technical-articles/advanced-programmer/programmer-quickly-learn-new-technology.md
+++ b/docs/high-quality-technical-articles/advanced-programmer/programmer-quickly-learn-new-technology.md
@@ -1,8 +1,13 @@
---
title: 程序员如何快速学习新技术
+description: "程序员如何快速学习新技术:围绕技术知识与面试总结梳理关键概念、常见问题与实践要点,帮助你高效学习与备战面试。"
category: 技术文章精选集
tag:
- 练级攻略
+head:
+ - - meta
+ - name: keywords
+ content: 程序员学习,技术学习方法,快速学习,官方文档,技术面试,八股文,知行合一,学习技巧
---
> **推荐语**:这是[《Java 面试指北》](https://javaguide.cn/zhuanlan/java-mian-shi-zhi-bei.html)练级攻略篇中的一篇文章,分享了我对于如何快速学习一门新技术的看法。
diff --git a/docs/high-quality-technical-articles/advanced-programmer/seven-tips-for-becoming-an-advanced-programmer.md b/docs/high-quality-technical-articles/advanced-programmer/seven-tips-for-becoming-an-advanced-programmer.md
index ef273f47b5c..b57e8e88664 100644
--- a/docs/high-quality-technical-articles/advanced-programmer/seven-tips-for-becoming-an-advanced-programmer.md
+++ b/docs/high-quality-technical-articles/advanced-programmer/seven-tips-for-becoming-an-advanced-programmer.md
@@ -1,9 +1,14 @@
---
title: 给想成长为高级别开发同学的七条建议
+description: "给想成长为高级别开发同学的七条建议:围绕技术知识与面试总结梳理关键概念、常见问题与实践要点,帮助你高效学习与备战面试。"
category: 技术文章精选集
author: Kaito
tag:
- 练级攻略
+head:
+ - - meta
+ - name: keywords
+ content: 程序员成长,高级开发,需求评审,技术内功,性能优化,线上问题排查,归纳总结,职业发展
---
> **推荐语**:普通程序员要想成长为高级程序员甚至是专家等更高级别,应该注意在哪些方面注意加强?开发内功修炼号主飞哥在这篇文章中就给出了七条实用的建议。
diff --git a/docs/high-quality-technical-articles/advanced-programmer/ten-years-of-dachang-growth-road.md b/docs/high-quality-technical-articles/advanced-programmer/ten-years-of-dachang-growth-road.md
index 045c2bfed66..1cb1bd2c706 100644
--- a/docs/high-quality-technical-articles/advanced-programmer/ten-years-of-dachang-growth-road.md
+++ b/docs/high-quality-technical-articles/advanced-programmer/ten-years-of-dachang-growth-road.md
@@ -1,9 +1,14 @@
---
title: 十年大厂成长之路
+description: "十年大厂成长之路:围绕技术知识与面试总结梳理关键概念、常见问题与实践要点,帮助你高效学习与备战面试。"
category: 技术文章精选集
author: CodingBetterLife
tag:
- 练级攻略
+head:
+ - - meta
+ - name: keywords
+ content: 大厂成长,程序员职业发展,技术专家,技术管理,转岗跳槽,职场选择,十年规划,技术领导
---
> **推荐语**:这篇文章的作者有着丰富的工作经验,曾在大厂工作了 12 年。结合自己走过的弯路和接触过的优秀技术人,他总结出了一些对于个人成长具有普遍指导意义的经验和特质。
diff --git a/docs/high-quality-technical-articles/advanced-programmer/the-growth-strategy-of-the-technological-giant.md b/docs/high-quality-technical-articles/advanced-programmer/the-growth-strategy-of-the-technological-giant.md
index 20aed477d3a..19084abe803 100644
--- a/docs/high-quality-technical-articles/advanced-programmer/the-growth-strategy-of-the-technological-giant.md
+++ b/docs/high-quality-technical-articles/advanced-programmer/the-growth-strategy-of-the-technological-giant.md
@@ -1,9 +1,14 @@
---
title: 程序员的技术成长战略
+description: "程序员的技术成长战略:围绕技术知识与面试总结梳理关键概念、常见问题与实践要点,帮助你高效学习与备战面试。"
category: 技术文章精选集
author: 波波微课
tag:
- 练级攻略
+head:
+ - - meta
+ - name: keywords
+ content: 技术成长战略,程序员成长,学习金字塔,刻意练习,技术大牛,职业规划,十年规划,持续产出
---
> **推荐语**:波波老师的一篇文章,写的非常好,不光是对技术成长有帮助,其他领域也是同样适用的!建议反复阅读,形成一套自己的技术成长策略。
diff --git a/docs/high-quality-technical-articles/advanced-programmer/thinking-about-technology-and-business-after-five-years-of-work.md b/docs/high-quality-technical-articles/advanced-programmer/thinking-about-technology-and-business-after-five-years-of-work.md
index d32f586b449..5933e7005bf 100644
--- a/docs/high-quality-technical-articles/advanced-programmer/thinking-about-technology-and-business-after-five-years-of-work.md
+++ b/docs/high-quality-technical-articles/advanced-programmer/thinking-about-technology-and-business-after-five-years-of-work.md
@@ -1,9 +1,14 @@
---
title: 工作五年之后,对技术和业务的思考
+description: "工作五年之后,对技术和业务的思考:围绕技术知识与面试总结梳理关键概念、常见问题与实践要点,帮助你高效学习与备战面试。"
category: 技术文章精选集
author: 知了一笑
tag:
- 练级攻略
+head:
+ - - meta
+ - name: keywords
+ content: 程序员五年,技术与业务,职业发展,能力积累,业务思维,技术深度,职场选择,二八原则
---
> **推荐语**:这是我在两年前看到的一篇对我触动比较深的文章。确实要学会适应变化,并积累能力。积累解决问题的能力,优化思考方式,拓宽自己的认知。
diff --git a/docs/high-quality-technical-articles/interview/how-to-examine-the-technical-ability-of-programmers-in-the-first-test-of-technology.md b/docs/high-quality-technical-articles/interview/how-to-examine-the-technical-ability-of-programmers-in-the-first-test-of-technology.md
index f96a20fec16..fee06e512ea 100644
--- a/docs/high-quality-technical-articles/interview/how-to-examine-the-technical-ability-of-programmers-in-the-first-test-of-technology.md
+++ b/docs/high-quality-technical-articles/interview/how-to-examine-the-technical-ability-of-programmers-in-the-first-test-of-technology.md
@@ -1,9 +1,14 @@
---
title: 如何在技术初试中考察程序员的技术能力
+description: "如何在技术初试中考察程序员的技术能力:围绕技术知识与面试总结梳理关键概念、常见问题与实践要点,帮助你高效学习与备战面试。"
category: 技术文章精选集
author: 琴水玉
tag:
- 面试
+head:
+ - - meta
+ - name: keywords
+ content: 技术面试,面试官技巧,技术考察,面试方法,技术基础,项目经历考察,面试题库,技术深度
---
> **推荐语**:从面试官和面试者两个角度探讨了技术面试!非常不错!
diff --git a/docs/high-quality-technical-articles/interview/my-personal-experience-in-2021.md b/docs/high-quality-technical-articles/interview/my-personal-experience-in-2021.md
index f7d62085553..b3f64e7d6c7 100644
--- a/docs/high-quality-technical-articles/interview/my-personal-experience-in-2021.md
+++ b/docs/high-quality-technical-articles/interview/my-personal-experience-in-2021.md
@@ -1,9 +1,14 @@
---
title: 校招进入飞书的个人经验
+description: "校招进入飞书的个人经验:围绕技术知识与面试总结梳理关键概念、常见问题与实践要点,帮助你高效学习与备战面试。"
category: 技术文章精选集
author: 月色真美
tag:
- 面试
+head:
+ - - meta
+ - name: keywords
+ content: 字节跳动面试,飞书校招,C++面试,春招实习,日常实习,暑期实习,面试技巧,算法刷题
---
> **推荐语**:这篇文章的作者校招最终去了飞书做开发。在这篇文章中,他分享了自己的校招经历以及个人经验。
diff --git a/docs/high-quality-technical-articles/interview/screen-candidates-for-packaging.md b/docs/high-quality-technical-articles/interview/screen-candidates-for-packaging.md
index 5b0ff739b34..b39fa6520f4 100644
--- a/docs/high-quality-technical-articles/interview/screen-candidates-for-packaging.md
+++ b/docs/high-quality-technical-articles/interview/screen-candidates-for-packaging.md
@@ -1,9 +1,14 @@
---
title: 如何甄别应聘者的包装程度
+description: "如何甄别应聘者的包装程度:围绕技术知识与面试总结梳理关键概念、常见问题与实践要点,帮助你高效学习与备战面试。"
category: 技术文章精选集
author: Coody
tag:
- 面试
+head:
+ - - meta
+ - name: keywords
+ content: 简历包装,面试官视角,简历甄别,技术面试,培训机构,项目经验,技术深度,面试技巧
---
> **推荐语**:经常听到培训班待过的朋友给我说他们的老师是怎么教他们“包装”自己的,不光是培训班,我认识的很多朋友也都会在面试之前“包装”一下自己,所以这个现象是普遍存在的。但是面试官也不都是傻子,通过下面这篇文章来看看面试官是如何甄别应聘者的包装程度。
diff --git a/docs/high-quality-technical-articles/interview/some-secrets-about-alibaba-interview.md b/docs/high-quality-technical-articles/interview/some-secrets-about-alibaba-interview.md
index 175efc3da14..82b3360ddf9 100644
--- a/docs/high-quality-technical-articles/interview/some-secrets-about-alibaba-interview.md
+++ b/docs/high-quality-technical-articles/interview/some-secrets-about-alibaba-interview.md
@@ -1,9 +1,14 @@
---
title: 阿里技术面试的一些秘密
+description: "阿里技术面试的一些秘密:围绕技术知识与面试总结梳理关键概念、常见问题与实践要点,帮助你高效学习与备战面试。"
category: 技术文章精选集
author: 龙叔
tag:
- 面试
+head:
+ - - meta
+ - name: keywords
+ content: 阿里面试,技术面试,简历筛选,面试技巧,基础知识,动手能力,八股文,校招面试
---
> **推荐语**:详细介绍了求职者在面试中应该具备哪些能力才会有更大概率脱颖而出。
diff --git a/docs/high-quality-technical-articles/interview/summary-of-spring-recruitment.md b/docs/high-quality-technical-articles/interview/summary-of-spring-recruitment.md
index 474434645a9..4345532f3cc 100644
--- a/docs/high-quality-technical-articles/interview/summary-of-spring-recruitment.md
+++ b/docs/high-quality-technical-articles/interview/summary-of-spring-recruitment.md
@@ -1,9 +1,14 @@
---
title: 普通人的春招总结(阿里、腾讯offer)
+description: "普通人的春招总结(阿里、腾讯offer):围绕技术知识与面试总结梳理关键概念、常见问题与实践要点,帮助你高效学习与备战面试。"
category: 技术文章精选集
author: 钟期既遇
tag:
- 面试
+head:
+ - - meta
+ - name: keywords
+ content: 春招经验,阿里面试,腾讯面试,Java学习路线,面试准备,项目经验,算法刷题,双非本科
---
> **推荐语**:牛客网热帖,写的很全面!暑期实习,投了阿里、腾讯、字节,拿到了阿里和腾讯的 offer。
diff --git a/docs/high-quality-technical-articles/interview/technical-preliminary-preparation.md b/docs/high-quality-technical-articles/interview/technical-preliminary-preparation.md
index ae4e95b1818..60e4f0363d8 100644
--- a/docs/high-quality-technical-articles/interview/technical-preliminary-preparation.md
+++ b/docs/high-quality-technical-articles/interview/technical-preliminary-preparation.md
@@ -1,9 +1,14 @@
---
title: 从面试官和候选者的角度谈如何准备技术初试
+description: "从面试官和候选者的角度谈如何准备技术初试:围绕技术知识与面试总结梳理关键概念、常见问题与实践要点,帮助你高效学习与备战面试。"
category: 技术文章精选集
author: 琴水玉
tag:
- 面试
+head:
+ - - meta
+ - name: keywords
+ content: 技术面试准备,面试官视角,候选人视角,技术基础,业务考察,面试技巧,技术深度广度,面试方法论
---
> **推荐语**:从面试官和面试者两个角度探讨了技术面试!非常不错!
diff --git a/docs/high-quality-technical-articles/interview/the-experience-and-thinking-of-an-interview-experienced-by-an-older-programmer.md b/docs/high-quality-technical-articles/interview/the-experience-and-thinking-of-an-interview-experienced-by-an-older-programmer.md
index 8aaa1d65aca..65ccd73d2aa 100644
--- a/docs/high-quality-technical-articles/interview/the-experience-and-thinking-of-an-interview-experienced-by-an-older-programmer.md
+++ b/docs/high-quality-technical-articles/interview/the-experience-and-thinking-of-an-interview-experienced-by-an-older-programmer.md
@@ -1,9 +1,14 @@
---
title: 一位大龄程序员所经历的面试的历炼和思考
+description: "一位大龄程序员所经历的面试的历炼和思考:围绕技术知识与面试总结梳理关键概念、常见问题与实践要点,帮助你高效学习与备战面试。"
category: 技术文章精选集
author: 琴水玉
tag:
- 面试
+head:
+ - - meta
+ - name: keywords
+ content: 大龄程序员面试,面试准备,简历优化,技术面试,面试心态,职业规划,面试技巧,技术原理
---
> **推荐语**:本文的作者,今年 36 岁,已有 8 年 JAVA 开发经验。在阿里云三年半,有赞四年半,已是标准的大龄程序员了。在这篇文章中,作者给出了一些关于面试和个人能力提升的一些小建议,非常实用!
diff --git a/docs/high-quality-technical-articles/interview/the-experience-of-get-offer-from-over-20-big-companies.md b/docs/high-quality-technical-articles/interview/the-experience-of-get-offer-from-over-20-big-companies.md
index 4cc38409fb7..5b307bae671 100644
--- a/docs/high-quality-technical-articles/interview/the-experience-of-get-offer-from-over-20-big-companies.md
+++ b/docs/high-quality-technical-articles/interview/the-experience-of-get-offer-from-over-20-big-companies.md
@@ -1,9 +1,14 @@
---
title: 斩获 20+ 大厂 offer 的面试经验分享
+description: "斩获 20+ 大厂 offer 的面试经验分享:围绕技术知识与面试总结梳理关键概念、常见问题与实践要点,帮助你高效学习与备战面试。"
category: 技术文章精选集
author: 业余码农
tag:
- 面试
+head:
+ - - meta
+ - name: keywords
+ content: 大厂面试,面试技巧,自我介绍,项目经历,技术面试,编码能力,HR面试,offer选择
---
> **推荐语**:很实用的面试经验分享!
diff --git a/docs/high-quality-technical-articles/personal-experience/8-years-programmer-work-summary.md b/docs/high-quality-technical-articles/personal-experience/8-years-programmer-work-summary.md
index 0af5480b58e..9c44705ef3a 100644
--- a/docs/high-quality-technical-articles/personal-experience/8-years-programmer-work-summary.md
+++ b/docs/high-quality-technical-articles/personal-experience/8-years-programmer-work-summary.md
@@ -1,9 +1,14 @@
---
title: 一个中科大差生的 8 年程序员工作总结
+description: "一个中科大差生的 8 年程序员工作总结:围绕技术知识与面试总结梳理关键概念、常见问题与实践要点,帮助你高效学习与备战面试。"
category: 技术文章精选集
author: 陈小房
tag:
- 个人经历
+head:
+ - - meta
+ - name: keywords
+ content: 中科大程序员,8年工作总结,航天研究所,华为工作,职业发展,买房经验,技术成长,人生复盘
---
> **推荐语**:这篇文章讲述了一位中科大的朋友 8 年的经历:从 2013 年毕业之后加入上海航天 x 院某卫星研究所,再到入职华为,从华为离职。除了丰富的经历之外,作者在文章还给出了很多自己对于工作/生活的思考。我觉得非常受用!我在这里,向这位作者表达一下衷心的感谢。
diff --git a/docs/high-quality-technical-articles/personal-experience/four-year-work-in-tencent-summary.md b/docs/high-quality-technical-articles/personal-experience/four-year-work-in-tencent-summary.md
index 4630bff560e..0860b2be859 100644
--- a/docs/high-quality-technical-articles/personal-experience/four-year-work-in-tencent-summary.md
+++ b/docs/high-quality-technical-articles/personal-experience/four-year-work-in-tencent-summary.md
@@ -1,9 +1,14 @@
---
title: 从校招入职腾讯的四年工作总结
+description: "从校招入职腾讯的四年工作总结:围绕技术知识与面试总结梳理关键概念、常见问题与实践要点,帮助你高效学习与备战面试。"
category: 技术文章精选集
author: pioneeryi
tag:
- 个人经历
+head:
+ - - meta
+ - name: keywords
+ content: 腾讯工作经验,四年总结,绩效考核,EPC度量,嫡系文化,职业发展,技术成长,互联网职场
---
程序员是一个流动性很大的职业,经常会有新面孔的到来,也经常会有老面孔的离开,有主动离开的,也有被动离职的。
@@ -74,7 +79,7 @@ PS:还好以前有奖杯,不然一点念想都没了。(现在腾讯似乎
但另一方面,后来我负责了团队内很重要的事情,应该是中心内都算很重要的事,我独自负责一个方向,直接向总监汇报,似乎又有点像。
-网上也有其他说法,一针见血,是不是嫡系,就看钱到不到位,这么说也有道理。我在 7 级时,就发了股票,自我感觉,还是不错的。我当时以为不出意外的话,我以后的钱途和发展是不是就会一帆风顺。不出意外就出了意外,第二年,EPC 不达预期,部门总经理和总监都被换了,中心来了一个新的的总监。
+网上也有其他说法,一针见血,是不是嫡系,就看钱到不到位,这么说也有道理。我在 7 级时,就发了股票,自我感觉,还是不错的。我当时以为不出意外的话,我以后的钱途和发展是不是就会一帆风顺。不出意外就出了意外,第二年,EPC 不达预期,部门总经理和总监都被换了,中心来了一个新的总监。
好吧,又要重新建立信任了。再到后来,是不是嫡系已经不重要了,因为大环境不好,又加上裁员,大家主动的被动的差不多都走了。
diff --git a/docs/high-quality-technical-articles/personal-experience/huawei-od-275-days.md b/docs/high-quality-technical-articles/personal-experience/huawei-od-275-days.md
index 419f364adcf..e546e03a54d 100644
--- a/docs/high-quality-technical-articles/personal-experience/huawei-od-275-days.md
+++ b/docs/high-quality-technical-articles/personal-experience/huawei-od-275-days.md
@@ -1,8 +1,13 @@
---
title: 华为 OD 275 天后,我进了腾讯!
+description: "华为 OD 275 天后,我进了腾讯!:围绕技术知识与面试总结梳理关键概念、常见问题与实践要点,帮助你高效学习与备战面试。"
category: 技术文章精选集
tag:
- 个人经历
+head:
+ - - meta
+ - name: keywords
+ content: 华为OD,腾讯面试,大数据开发,外包经历,面试经验,Java面试,职业发展,大厂面试
---
> **推荐语**:一位朋友的华为 OD 工作经历以及腾讯面试经历分享,内容很不错。
diff --git a/docs/high-quality-technical-articles/personal-experience/two-years-of-back-end-develop--experience-in-didi-and-toutiao.md b/docs/high-quality-technical-articles/personal-experience/two-years-of-back-end-develop--experience-in-didi-and-toutiao.md
index 5b6c47be7c7..18e6bd6e05a 100644
--- a/docs/high-quality-technical-articles/personal-experience/two-years-of-back-end-develop--experience-in-didi-and-toutiao.md
+++ b/docs/high-quality-technical-articles/personal-experience/two-years-of-back-end-develop--experience-in-didi-and-toutiao.md
@@ -1,8 +1,13 @@
---
title: 滴滴和头条两年后端工作经验分享
+description: "滴滴和头条两年后端工作经验分享:围绕技术知识与面试总结梳理关键概念、常见问题与实践要点,帮助你高效学习与备战面试。"
category: 技术文章精选集
tag:
- 个人经历
+head:
+ - - meta
+ - name: keywords
+ content: 滴滴工作经验,头条工作经验,后端开发,技术成长,职场经验,深入思考,总结沉淀,主动承担
---
> **推荐语**:很实用的工作经验分享,看完之后十分受用!
diff --git a/docs/high-quality-technical-articles/programmer/efficient-book-publishing-and-practice-guide.md b/docs/high-quality-technical-articles/programmer/efficient-book-publishing-and-practice-guide.md
index fad7bede853..17d17c638cf 100644
--- a/docs/high-quality-technical-articles/programmer/efficient-book-publishing-and-practice-guide.md
+++ b/docs/high-quality-technical-articles/programmer/efficient-book-publishing-and-practice-guide.md
@@ -1,9 +1,14 @@
---
title: 程序员高效出书避坑和实践指南
+description: "程序员高效出书避坑和实践指南:围绕技术知识与面试总结梳理关键概念、常见问题与实践要点,帮助你高效学习与备战面试。"
category: 技术文章精选集
author: hsm_computer
tag:
- 程序员
+head:
+ - - meta
+ - name: keywords
+ content: 程序员出书,出书避坑,稿酬收益,出版社编辑,图书公司,案例书写作,版权问题,技术写作
---
> **推荐语**:详细介绍了程序员出书的一些常见问题,强烈建议有出书想法的朋友看看这篇文章。
diff --git a/docs/high-quality-technical-articles/programmer/high-value-certifications-for-programmers.md b/docs/high-quality-technical-articles/programmer/high-value-certifications-for-programmers.md
new file mode 100644
index 00000000000..b91b220e5f6
--- /dev/null
+++ b/docs/high-quality-technical-articles/programmer/high-value-certifications-for-programmers.md
@@ -0,0 +1,119 @@
+---
+title: 程序员最该拿的几种高含金量证书
+description: "程序员最该拿的几种高含金量证书:围绕技术知识与面试总结梳理关键概念、常见问题与实践要点,帮助你高效学习与备战面试。"
+category: 技术文章精选集
+tag:
+ - 程序员
+head:
+ - - meta
+ - name: keywords
+ content: 程序员证书,软考,PMP认证,AWS认证,阿里云认证,华为认证,OCP认证,Kubernetes认证,职业资格证书
+---
+
+证书是能有效证明自己能力的好东西,它就是你实力的象征。在短短的面试时间内,证书可以为你加不少分。通过考证来提升自己,是一种性价比很高的办法。不过,相比金融、建筑、医疗等行业,IT 行业的职业资格证书并没有那么多。
+
+下面我总结了一下程序员可以考的一些常见证书。
+
+## 软考
+
+全国计算机技术与软件专业技术资格(水平)考试,简称“软考”,是国内认可度较高的一项计算机技术资格认证。尽管一些人吐槽其实际价值,但在特定领域和情况下,它还是非常有用的,例如软考证书在国企和事业单位中具有较高的认可度、在某些城市软考证书可以用于积分落户、可用于个税补贴。
+
+软考有初、中、高三个级别,建议直接考高级。相比于 PMP(项目管理专业人士认证),软考高项的难度更大,特别是论文部分,绝大部分人都挂在了论文部分。过了软考高项,在一些单位可以内部挂证,每个月多拿几百。
+
+
+
+官网地址:。
+
+备考建议:[2024 年上半年,一次通过软考高级架构师考试的备考秘诀 - 阿里云开发者](https://mp.weixin.qq.com/s/9aUXHJ7dXgrHuT19jRhCnw)
+
+## PAT
+
+攀拓计算机能力测评(PAT)是一个专注于考察算法能力的测评体系,由浙江大学主办。该测评分为四个级别:基础级、乙级、甲级和顶级。
+
+通过 PAT 测评并达到联盟企业规定的相应评级和分数,可以跳过学历门槛,免除筛选简历和笔试环节,直接获得面试机会。具体有哪些公司可以去官网看看: 。
+
+对于考研浙江大学的同学来说,PAT(甲级)成绩在一年内可以作为硕士研究生招生考试上机复试成绩。
+
+
+
+## PMP
+
+PMP(Project Management Professional)认证由美国项目管理协会(PMI)提供,是全球范围内认可度最高的项目管理专业人士资格认证。PMP 认证旨在提升项目管理专业人士的知识和技能,确保项目顺利完成。
+
+
+
+PMP 是“一证在手,全球通用”的资格认证,对项目管理人士来说,PMP 证书含金量还是比较高的。放眼全球,很多成功企业都会将 PMP 认证作为项目经理的入职标准。
+
+但是!真正有价值的不是 PMP 证书,而是《PMBOK》 那套项目管理体系,在《PMBOK》(PMP 考试指定用书)中也包含了非常多商业活动、实业项目、组织规划、建筑行业等各个领域的项目案例。
+
+另外,PMP 证书不是一个高大上的证书,而是一个基础的证书。
+
+## ACP
+
+ACP(Agile Certified Practitioner)认证同样由美国项目管理协会(PMI)提供,是项目管理领域的另一个重要认证。与 PMP(Project Management Professional)注重传统的瀑布方法论不同,ACP 专注于敏捷项目管理方法论,如 Scrum、Kanban、Lean、Extreme Programming(XP)等。
+
+## OCP
+
+Oracle Certified Professional(OCP)是 Oracle 公司提供的一项专业认证,专注于 Oracle 数据库及相关技术。这个认证旨在验证和认证个人在使用和管理 Oracle 数据库方面的专业知识和技能。
+
+下图展示了 Oracle 认证的不同路径和相应的认证级别,分别是核心路径(Core Track)和专业路径(Speciality Track)。
+
+
+
+## 阿里云认证
+
+阿里云(Alibaba Cloud)提供的专业认证,认证方向包括云计算、大数据、人工智能、Devops 等。职业认证分为 ACA、ACP、ACE 三个等级,除了职业认证之外,还有一个开发者 Clouder 认证,这是专门为开发者设立的专项技能认证。
+
+
+
+官网地址:。
+
+## 华为认证
+
+华为认证是由华为技术有限公司提供的面向 ICT(信息与通信技术)领域的专业认证,认证方向包括网络、存储、云计算、大数据、人工智能等,非常庞大的认证体系。
+
+
+
+## AWS 认证
+
+AWS 云认证考试是 AWS 云计算服务的官方认证考试,旨在验证 IT 专业人士在设计、部署和管理 AWS 基础架构方面的技能。
+
+AWS 认证分为多个级别,包括基础级、从业者级、助理级、专业级和专家级(Specialty),涵盖多个角色和技能:
+
+- **基础级别**:AWS Certified Cloud Practitioner,适合初学者,验证对 AWS 基础知识的理解,是最简单的入门认证。
+- **助理级别**:包括 AWS Certified Solutions Architect – Associate、AWS Certified Developer – Associate 和 AWS Certified SysOps Administrator – Associate,适合中级专业人士,验证其设计、开发和管理 AWS 应用的能力。
+- **专业级别**:包括 AWS Certified Solutions Architect – Professional 和 AWS Certified DevOps Engineer – Professional,适合高级专业人士,验证其在复杂和大规模 AWS 环境中的能力。
+- **专家级别**:包括 AWS Certified Advanced Networking – Specialty、AWS Certified Big Data – Specialty 等,专注于特定技术领域的深度知识和技能。
+
+备考建议:[小白入门云计算的最佳方式,是去考一张 AWS 的证书(附备考经验)](https://mp.weixin.qq.com/s/xAqNOnfZ05GDRuUbAiMHIA)
+
+## Google Cloud 认证
+
+与 AWS 认证不同,Google Cloud 认证只有一门助理级认证(Associate Cloud Engineer),其他大部分为专业级(专家级)认证。
+
+备考建议:[如何备考谷歌云认证](https://mp.weixin.qq.com/s/Vw5LGPI_akA7TQl1FMygWw)
+
+官网地址:
+
+## 微软认证
+
+微软的认证体系主要针对其 Azure 云平台,分为基础级别、助理级别和专家级别,认证方向包括云计算、数据管理、开发、生产力工具等。
+
+
+
+## Elastic 认证
+
+Elastic 认证是由 Elastic 公司提供的一系列专业认证,旨在验证个人在使用 Elastic Stack(包括 Elasticsearch、Logstash、Kibana 、Beats 等)方面的技能和知识。
+
+如果你在日常开发核心工作是负责 ElasticSearch 相关业务的话,还是比较建议考的,含金量挺高。
+
+目前 Elastic 认证证书分为四类:Elastic Certified Engineer、Elastic Certified Analyst、Elastic Certified Observability Engineer、Elastic Certified SIEM Specialist。
+
+比较建议考 **Elastic Certified Engineer**,这个是 Elastic Stack 的基础认证,考察安装、配置、管理和维护 Elasticsearch 集群等核心技能。
+
+
+
+## 其他
+
+- PostgreSQL 认证:国内的 PostgreSQL 认证分为专员级(PCA)、专家级(PCP)和大师级(PCM),主要考查 PostgreSQL 数据库管理和优化,价格略贵,不是很推荐。
+- Kubernetes 认证:Cloud Native Computing Foundation (CNCF) 提供了几个官方认证,例如 Certified Kubernetes Administrator (CKA)、Certified Kubernetes Application Developer (CKAD),主要考察 Kubernetes 方面的技能和知识。
diff --git a/docs/high-quality-technical-articles/programmer/how-do-programmers-publish-a-technical-book.md b/docs/high-quality-technical-articles/programmer/how-do-programmers-publish-a-technical-book.md
index 99dae8f9d72..3fcc17a191c 100644
--- a/docs/high-quality-technical-articles/programmer/how-do-programmers-publish-a-technical-book.md
+++ b/docs/high-quality-technical-articles/programmer/how-do-programmers-publish-a-technical-book.md
@@ -1,9 +1,14 @@
---
title: 程序员怎样出版一本技术书
+description: "程序员怎样出版一本技术书:围绕技术知识与面试总结梳理关键概念、常见问题与实践要点,帮助你高效学习与备战面试。"
category: 技术文章精选集
author: hsm_computer
tag:
- 程序员
+head:
+ - - meta
+ - name: keywords
+ content: 程序员出书,技术书籍出版,出版社合作,图书公司,写书技巧,稿酬收益,技术写作,畅销书
---
> **推荐语**:详细介绍了程序员应该如何从头开始出一本自己的书籍。
diff --git a/docs/high-quality-technical-articles/work/32-tips-improving-career.md b/docs/high-quality-technical-articles/work/32-tips-improving-career.md
index 78b0e66b122..2f54b7c2bf4 100644
--- a/docs/high-quality-technical-articles/work/32-tips-improving-career.md
+++ b/docs/high-quality-technical-articles/work/32-tips-improving-career.md
@@ -1,8 +1,13 @@
---
title: 32条总结教你提升职场经验
+description: "32条总结教你提升职场经验:围绕技术知识与面试总结梳理关键概念、常见问题与实践要点,帮助你高效学习与备战面试。"
category: 技术文章精选集
tag:
- 工作
+head:
+ - - meta
+ - name: keywords
+ content: 职场经验,程序员成长,向上管理,情绪控制,Leader能力,职业发展,阿里开发者,职场技巧
---
> **推荐语**:阿里开发者的一篇职场经验的分享。
diff --git a/docs/high-quality-technical-articles/work/employee-performance.md b/docs/high-quality-technical-articles/work/employee-performance.md
index af22114fb04..41d6eb8223a 100644
--- a/docs/high-quality-technical-articles/work/employee-performance.md
+++ b/docs/high-quality-technical-articles/work/employee-performance.md
@@ -1,8 +1,13 @@
---
title: 聊聊大厂的绩效考核
+description: "聊聊大厂的绩效考核:围绕技术知识与面试总结梳理关键概念、常见问题与实践要点,帮助你高效学习与备战面试。"
category: 技术文章精选集
tag:
- 工作
+head:
+ - - meta
+ - name: keywords
+ content: 大厂绩效,绩效考核,KPI,OKR,271制度,年终奖,职级晋升,向上管理
---
> **内容概览**:
diff --git a/docs/high-quality-technical-articles/work/get-into-work-mode-quickly-when-you-join-a-company.md b/docs/high-quality-technical-articles/work/get-into-work-mode-quickly-when-you-join-a-company.md
index 72c672a5f92..ce22412ea15 100644
--- a/docs/high-quality-technical-articles/work/get-into-work-mode-quickly-when-you-join-a-company.md
+++ b/docs/high-quality-technical-articles/work/get-into-work-mode-quickly-when-you-join-a-company.md
@@ -1,8 +1,13 @@
---
title: 新入职一家公司如何快速进入工作状态
+description: "新入职一家公司如何快速进入工作状态:围绕技术知识与面试总结梳理关键概念、常见问题与实践要点,帮助你高效学习与备战面试。"
category: 技术文章精选集
tag:
- 工作
+head:
+ - - meta
+ - name: keywords
+ content: 新入职,快速融入,工作状态,业务了解,技术熟悉,团队协作,跳槽适应,程序员入职
---
> **推荐语**:强烈建议每一位即将入职/在职的小伙伴看看这篇文章,看完之后可以帮助你少踩很多坑。整篇文章逻辑清晰,内容全面!
diff --git a/docs/home.md b/docs/home.md
index 81b557b1249..7771c5c0f0e 100644
--- a/docs/home.md
+++ b/docs/home.md
@@ -1,18 +1,38 @@
---
icon: creative
-title: JavaGuide(Java学习&面试指南)
+title: JavaGuide(Java 面试 & 后端通用面试指南)
+description: Java 面试指南(Java 八股文/面试题总结):覆盖 Java 基础、集合、并发、JVM、Spring、MySQL、Redis、系统设计与分布式等核心知识,适用于校招/社招后端面试复习。
+head:
+ - - meta
+ - name: keywords
+ content: Java面试,Java面试指南,Java八股文,Java面试题,Java基础面试,JVM面试,并发面试,线程池面试,Spring面试,MySQL面试,Redis面试,系统设计面试,分布式面试,后端面试
---
::: tip 友情提示
-- **面试专版**:准备 Java 面试的小伙伴可以考虑面试专版:**[《Java 面试指北 》](./zhuanlan/java-mian-shi-zhi-bei.md)** (质量很高,专为面试打造,配合 JavaGuide 食用)。
-- **知识星球**:专属面试小册/一对一交流/简历修改/专属求职指南,欢迎加入 **[JavaGuide 知识星球](./about-the-author/zhishixingqiu-two-years.md)**(点击链接即可查看星球的详细介绍,一定确定自己真的需要再加入)。
-- **使用建议** :有水平的面试官都是顺着项目经历挖掘技术问题。一定不要死记硬背技术八股文!详细的学习建议请参考:[JavaGuide 使用建议](./javaguide/use-suggestion.md)。
+- **实战项目**:
+ - [⭐AI 智能面试辅助平台 + RAG 知识库](https://javaguide.cn/zhuanlan/interview-guide.html):基于 Spring Boot 4.0 + Java 21 + Spring AI 2.0 开发。非常适合作为学习和简历项目,学习门槛低,帮助提升求职竞争力,是主打就业的实战项目。
+ - [手写 RPC 框架](https://javaguide.cn/zhuanlan/handwritten-rpc-framework.html):从零开始基于 Netty+Kyro+Zookeeper 实现一个简易的 RPC 框架。麻雀虽小五脏俱全,项目代码注释详细,结构清晰。
+- **面试资料补充**:
+ - [《Java 面试指北》](https://javaguide.cn/zhuanlan/java-mian-shi-zhi-bei.html):四年打磨,和 JavaGuide 开源版的内容互补,带你从零开始系统准备后端面试!
+ - [《后端面试高频系统设计&场景题》](https://javaguide.cn/zhuanlan/back-end-interview-high-frequency-system-design-and-scenario-questions.html):30+ 道高频系统设计和场景面试,助你应对当下中大厂面试趋势。
+- **使用建议** :如果你想要系统准备 Java 后端面试但又不知道如何开始的,可以参考 [Java 后端面试通关计划(后端通用)](https://javaguide.cn/interview-preparation/backend-interview-plan.html)。
- **求个 Star**:如果觉得 JavaGuide 的内容对你有帮助的话,还请点个免费的 Star,这是对我最大的鼓励,感谢各位一起同行,共勉!传送门:[GitHub](https://github.com/Snailclimb/JavaGuide) | [Gitee](https://gitee.com/SnailClimb/JavaGuide)。
- **转载须知**:以下所有文章如非文首说明为转载皆为 JavaGuide 原创,转载请在文首注明出处。如发现恶意抄袭/搬运,会动用法律武器维护自己的权益。让我们一起维护一个良好的技术创作环境!
:::
+## 面试准备
+
+- [⭐Java 后端面试通关计划(涵盖后端通用体系)](./interview-preparation/backend-interview-plan.md) (一定要看 :+1:)
+- [如何高效准备 Java 面试?](./interview-preparation/teach-you-how-to-prepare-for-the-interview-hand-in-hand.md)
+- [Java 后端面试重点总结](./interview-preparation/key-points-of-interview.md)
+- [Java 学习路线(最新版,4w+ 字)](./interview-preparation/java-roadmap.md)
+- [程序员简历编写指南](./interview-preparation/resume-guide.md)
+- [项目经验指南](./interview-preparation/project-experience-guide.md)
+- [面试太紧张怎么办?](./interview-preparation/how-to-handle-interview-nerves.md)
+- [校招没有实习经历怎么办?实习经历怎么写?](./interview-preparation/internship-experience.md)
+
## Java
### 基础
@@ -41,7 +61,7 @@ title: JavaGuide(Java学习&面试指南)
- [Java 集合常见知识点&面试题总结(上)](./java/collection/java-collection-questions-01.md) (必看 :+1:)
- [Java 集合常见知识点&面试题总结(下)](./java/collection/java-collection-questions-02.md) (必看 :+1:)
-- [Java 容器使用注意事项总结](./java/collection/java-collection-precautions-for-use.md)
+- [Java 集合使用注意事项总结](./java/collection/java-collection-precautions-for-use.md)
**源码分析**:
@@ -72,6 +92,8 @@ title: JavaGuide(Java学习&面试指南)
**重要知识点详解**:
+- [乐观锁和悲观锁详解](./java/concurrent/optimistic-lock-and-pessimistic-lock.md)
+- [CAS 详解](./java/concurrent/cas.md)
- [JMM(Java 内存模型)详解](./java/concurrent/jmm.md)
- **线程池**:[Java 线程池详解](./java/concurrent/java-thread-pool-summary.md)、[Java 线程池最佳实践](./java/concurrent/java-thread-pool-best-practices.md)
- [ThreadLocal 详解](./java/concurrent/threadlocal.md)
@@ -107,6 +129,9 @@ JVM 这部分内容主要参考 [JVM 虚拟机规范-Java8](https://docs.oracle.
- [Java 19 新特性概览](./java/new-features/java19.md)
- [Java 20 新特性概览](./java/new-features/java20.md)
- [Java 21 新特性概览](./java/new-features/java21.md)
+- [Java 22 & 23 新特性概览](./java/new-features/java22-23.md)
+- [Java 24 新特性概览](./java/new-features/java24.md)
+- [Java 25 新特性概览](./java/new-features/java25.md)
## 计算机基础
@@ -192,6 +217,7 @@ JVM 这部分内容主要参考 [JVM 虚拟机规范-Java8](https://docs.oracle.
**重要知识点:**
- [MySQL 索引详解](./database/mysql/mysql-index.md)
+- [MySQL 索引失效场景总结](./database/mysql/mysql-index-invalidation.md)
- [MySQL 事务隔离级别图文详解)](./database/mysql/transaction-isolation-level.md)
- [MySQL 三大日志(binlog、redo log 和 undo log)详解](./database/mysql/mysql-logs.md)
- [InnoDB 存储引擎对 MVCC 的实现](./database/mysql/innodb-implementation-of-mvcc.md)
@@ -212,6 +238,7 @@ JVM 这部分内容主要参考 [JVM 虚拟机规范-Java8](https://docs.oracle.
**重要知识点:**
- [3 种常用的缓存读写策略详解](./database/redis/3-commonly-used-cache-read-and-write-strategies.md)
+- [Redis 能做消息队列吗?怎么实现?](./database/redis/redis-stream-mq.md)
- [Redis 5 种基本数据结构详解](./database/redis/redis-data-structures-01.md)
- [Redis 3 种特殊数据结构详解](./database/redis/redis-data-structures-02.md)
- [Redis 持久化机制详解](./database/redis/redis-persistence.md)
@@ -259,7 +286,7 @@ JVM 这部分内容主要参考 [JVM 虚拟机规范-Java8](https://docs.oracle.
### 基础
- [RestFul API 简明教程](./system-design/basis/RESTfulAPI.md)
-- [软件工程简明教程简明教程](./system-design/basis/software-engineering.md)
+- [软件工程简明教程](./system-design/basis/software-engineering.md)
- [代码命名指南](./system-design/basis/naming.md)
- [代码重构指南](./system-design/basis/refactoring.md)
- [单元测试指南](./system-design/basis/unit-test.md)
@@ -295,15 +322,13 @@ JVM 这部分内容主要参考 [JVM 虚拟机规范-Java8](https://docs.oracle.
- [JWT 优缺点分析以及常见问题解决方案](./system-design/security/advantages-and-disadvantages-of-jwt.md)
- [SSO 单点登录详解](./system-design/security/sso-intro.md)
- [权限系统设计详解](./system-design/security/design-of-authority-system.md)
-- [常见加密算法总结](./system-design/security/encryption-algorithms.md)
-#### 数据脱敏
+#### 数据安全
-数据脱敏说的就是我们根据特定的规则对敏感信息数据进行变形,比如我们把手机号、身份证号某些位数使用 \* 来代替。
-
-#### 敏感词过滤
-
-[敏感词过滤方案总结](./system-design/security/sentive-words-filter.md)
+- [常见加密算法总结](./system-design/security/encryption-algorithms.md)
+- [敏感词过滤方案总结](./system-design/security/sentive-words-filter.md)
+- [数据脱敏方案总结](./system-design/security/data-desensitization.md)
+- [为什么前后端都要做数据校验](./system-design/security/data-validation.md)
### 定时任务
@@ -320,7 +345,9 @@ JVM 这部分内容主要参考 [JVM 虚拟机规范-Java8](https://docs.oracle.
- [CAP 理论和 BASE 理论解读](./distributed-system/protocol/cap-and-base-theorem.md)
- [Paxos 算法解读](./distributed-system/protocol/paxos-algorithm.md)
- [Raft 算法解读](./distributed-system/protocol/raft-algorithm.md)
-- [Gossip 协议详解](./distributed-system/protocol/gossip-protocl.md)
+- [ZAB 协议解读](./distributed-system/protocol/zab.md)
+- [Gossip 协议详解](./distributed-system/protocol/gossip-protocol.md)
+- [一致性哈希算法详解](./distributed-system/protocol/consistent-hashing.md)
### RPC
@@ -380,7 +407,7 @@ JVM 这部分内容主要参考 [JVM 虚拟机规范-Java8](https://docs.oracle.
- [Disruptor 常见知识点&面试题总结](./high-performance/message-queue/disruptor-questions.md)
- [RabbitMQ 常见知识点&面试题总结](./high-performance/message-queue/rabbitmq-questions.md)
- [RocketMQ 常见知识点&面试题总结](./high-performance/message-queue/rocketmq-questions.md)
-- [Kafka 常常见知识点&面试题总结](./high-performance/message-queue/kafka-questions-01.md)
+- [Kafka 常见知识点&面试题总结](./high-performance/message-queue/kafka-questions-01.md)
## 高可用
@@ -410,7 +437,7 @@ JVM 这部分内容主要参考 [JVM 虚拟机规范-Java8](https://docs.oracle.
**灾备** = 容灾 + 备份。
-- **备份**:将系统所产生的的所有重要数据多备份几份。
+- **备份**:将系统所产生的所有重要数据多备份几份。
- **容灾**:在异地建立两个完全相同的系统。当某个地方的系统突然挂掉,整个应用系统可以切换到另一个,这样系统就可以正常提供服务了。
**异地多活** 描述的是将服务部署在异地并且服务同时对外提供服务。和传统的灾备设计的最主要区别在于“多活”,即所有站点都是同时在对外提供服务的。异地多活是为了应对突发状况比如火灾、地震等自然或者人为灾害。
diff --git a/docs/interview-preparation/backend-interview-plan.md b/docs/interview-preparation/backend-interview-plan.md
new file mode 100644
index 00000000000..14900af4437
--- /dev/null
+++ b/docs/interview-preparation/backend-interview-plan.md
@@ -0,0 +1,207 @@
+---
+title: Java 后端面试通关计划(涵盖后端通用体系)
+description: Java 后端面试通关计划:严格按照面试考察真实优先级编排,涵盖项目经历、Java核心、MySQL/Redis、框架、系统设计、计算机基础、分布式与JVM,适合校招/社招准备。
+category: 面试准备
+icon: star
+head:
+ - - meta
+ - name: keywords
+ content: Java后端面试,面试准备计划,面试指南,八股文,校招,社招,项目经验,Java面试
+---
+
+本计划严格按照面试考察的**真实优先级**进行编排,顺序为:
+**「 项目经历与简历深挖 → Java核心/MySQL/Redis → 框架应用 → 系统设计与场景题 → 计算机基础 → 分布式/高并发 → JVM」**
+
+每一阶段都对应了本站具体的精选文章,方便你按图索骥,逐个击破。
+
+- **建议总周期**:4~8 周(请根据目标公司是中小厂还是大厂,以及自身的脱产时间灵活压缩或拉长)。
+- **适用人群**:准备秋招/春招的计算机专业学生,以及 0-5 年经验准备跳槽的 Java 开发者。
+- **面试突击**:下文中推荐的技术文章以 [JavaGuide](https://javaguide.cn/) 为主,非常全面且详细,如果突击面试,可以选择阅读 [JavaGuide 面试突击版](https://interview.javaguide.cn/) 中对应的文章。
+
+### 计划总览
+
+| 阶段 | 建议时长 | 核心产出 | 自测标准 |
+| ---------------------------------- | --------------------- | ---------------------------------------------- | ----------------------------------------------------------------------------- |
+| **第 0 步** 前期准备 | 1~2 天 | 简历定稿、复习节奏、心态准备 | 任选一项目,30 秒内讲清业务+你的角色,不卡壳、有重点 |
+| **第一阶段** 项目与简历深挖 | 约 1 周 | 项目卡片、必会题清单、1/3 分钟话术稿 | 脱稿讲清每项目背景+难点+你的贡献;必会题清单随机抽 3 题能答出要点 |
+| **第二阶段** Java + MySQL + Redis | 2~3 周 | 八股理解与关键词记忆(基础+集合+并发+库) | 本站文章随机抽题,能用自己的话讲清原理与关键词,不依赖逐字背 |
+| **第三阶段** 框架 | 1~2 周 | Spring/IoC/AOP/事务、设计模式、权限与安全 | 能说清项目对框架的使用、吃透IoC 和 AOP、事务失效场景等等 |
+| **系统设计与场景题**(接在框架后) | 按需 0.5~1 周 | 系统设计题与场景题思路(短链/秒杀/海量数据等) | 无提示口述经典设计(如短链/秒杀)的整体流程与关键取舍(存储、限流、一致性等) |
+| **第四阶段** 计算机基础 | 按需 0.5~2 周 | 计网、OS、数据结构;面中大厂等加算法 | 能手写常见算法/手写题;本站文章随机抽题能答出核心机制 |
+| **第五阶段** 分布式与高并发 | 按需 1~2 周 | 分布式理论、RPC、MQ、高可用 | 能讲清项目里用到的分布式方案(锁/ID/MQ 等)及选型理由 |
+| **第六阶段** JVM | 大厂/部分中厂 3~5 天 | 内存、GC、类加载、调优与排查 | 能说清内存区域、GC 过程、类加载;能口述一次 GC 调优或 OOM 排查思路 |
+| **面试前冲刺** | 1~2 天 | 必会题过一遍、项目话术再练、心态与设备 | 必会题清单过一遍能复述要点;每项目 1 分钟版话术练一遍不卡壳 |
+
+**📌 阶段调整说明:**
+
+- 标「按需」的阶段可根据目标公司调整:面字节、快手、腾讯等**重算法厂**,请务必加强第四阶段(算法与数据结构);
+- 如果你的简历或应聘岗位明确涉及**分布式/微服务**,请系统性死磕第五阶段;
+- 如果目标是阿里、美团、京东等**大厂核心部门**,请重点攻克第六阶段(JVM 底层与线上排查)。
+
+### 第 0 步:前期准备(建议 1~2 天)
+
+在系统刷八股前,先把「怎么准备、怎么写简历、怎么稳住心态」搞定,避免方向跑偏。
+
+| 事项 | 说明 | 对应文章 |
+| ---------- | --------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
+| 准备方法 | 明确复习节奏、自测方式、时间分配 | [如何高效准备 Java 面试?](https://javaguide.cn/interview-preparation/teach-you-how-to-prepare-for-the-interview-hand-in-hand.html)
[Java后端面试重点总结](https://javaguide.cn/interview-preparation/key-points-of-interview.html) |
+| 简历 | 一到两页纸、项目 STAR、技术栈与岗位匹配 | [程序员简历编写指南](https://javaguide.cn/interview-preparation/resume-guide.html) |
+| 学习路线 | 查漏补缺,确定自己当前所处阶段 | [Java 学习路线(最新版,4w+ 字)](https://javaguide.cn/interview-preparation/java-roadmap.html) |
+| 项目与经历 | 没有项目/实习时如何包装、怎么讲 | [项目经验指南](https://javaguide.cn/interview-preparation/project-experience-guide.html)
[校招没有实习经历怎么办?实习经历怎么写?](https://javaguide.cn/interview-preparation/internship-experience.html) |
+| 心态 | 减少紧张、发挥更稳 | [面试太紧张怎么办?](https://javaguide.cn/interview-preparation/how-to-handle-interview-nerves.html) |
+
+**核心要点**:
+
+- **技术好≠面试能过**,必须系统准备——尽早以求职为导向学习,根据招聘要求制定技能清单。
+- **掌握投递简历的黄金时间**:秋招 7-9 月,春招 3-4 月;多渠道获取招聘信息(官网、招聘网站、牛客网、内推等)。
+- **花 2-3 天完善简历**,重视项目经历描述;**校招简历不超过 2 页,社招不超过 3 页**。
+- **八股文很有意义**,日常开发也会用到;不要抱侥幸心理,打铁还需自身硬。
+- **提前准备 1-2 分钟自我介绍话术**,能流畅讲出个人背景、技术栈和求职意向。
+- **多多自测**,可以用 AI 辅助模拟面试,找同学朋友互相模拟面试。
+
+### 第一阶段:项目与简历深挖(约 1 周)
+
+**目标**:能清晰讲出每个项目的背景、你的角色、技术选型与难点,并能推导出「可能被问的面试题」。
+
+**产出物**:
+
+- **项目卡片**:按简历逐条过项目,为每个项目写清——业务背景、技术栈、你负责的模块、1~2 个难点与解决方式、可量化的成果(如 QPS、耗时、节省成本)。
+- **必会题清单**:根据项目用到的技术,列出「必会题」(例如:用了 Redis 缓存→ Redis 常见数据结构、持久化机制、线程模型等;用了 MySQL → 索引、事务、慢 SQL 优化等)。可参考 [JavaGuide](https://javaguide.cn/) 网站中的面试题总结按项目拓展。
+- **话术稿**:每个项目准备 1~2 分钟版本(自我介绍用)和 3~5 分钟版本(深挖用),能流畅讲出「为什么这么选、遇到什么问题、怎么解决的」。
+
+**每日建议**:每天至少梳理 1 个项目 + 对应必会题,周末做一次脱稿自测(录音或对着镜子讲)。
+
+**自测**:能脱稿讲清每个项目的背景、难点和你的贡献;必会题清单里的题能答出要点,对于大厂面试要能抗住深挖,做到举一反三。
+
+**没有项目经验怎么办?**
+
+1. **实战项目视频/专栏**:慕课网、哔哩哔哩、拉勾、极客时间等;选择适合自己能力的项目,不必强求微服务项目。[JavaGuide 官方知识星球](https://javaguide.cn/about-the-author/zhishixingqiu-two-years.html)已经推出[⭐AI 智能面试辅助平台 + RAG 知识库](https://javaguide.cn/zhuanlan/interview-guide.html)和[手写 RPC 框架](https://javaguide.cn/zhuanlan/handwritten-rpc-framework.html)。并且,还分享了很多高频项目经历(如博客、外卖、线程池、短连接)的优化版介绍和面试准备。
+2. **实战类开源项目**:JavaGuide 推荐的[优质开源实战项目](https://javaguide.cn/open-source-project/practical-project.html);在理解基础上改进或增加功能。
+3. **参加大公司组织的比赛**:阿里云天池大赛等;获奖项目含金量高。
+
+**项目经历写作要点(STAR 法则)**:
+
+- **Situation(情景)**:项目背景是什么?要解决什么问题?
+- **Task(任务)**:你在项目中负责什么?你的角色是什么?
+- **Action(行动)**:你具体做了什么?用了什么技术?遇到了什么问题?如何解决的?
+- **Result(结果)**:取得了什么成果?最好量化(QPS 从 xxx 提高到 xxx,响应时间降低 xx%)
+
+**项目介绍高频问题**:
+
+- 技术架构直接写技术名词,不需要解释。
+- 减少纯业务描述,多挖掘技术亮点,结合具体业务场景描述。
+- 优化成果要量化(QPS、响应时间、成本节省等),非真实项目包装合理数值即可。
+- 工作内容介绍控制在 6~8 条左右比较好,多了少了都有影响,一定要至少有 3-4 条是有技术亮点的,能吸引到面试官。
+- 避免模糊性描述(如"负责开发"),要具体(技术+场景+效果)。
+
+### 第二阶段:Java 核心 + MySQL + Redis (约 2~3 周)
+
+**优先级**:最重要的部分,面试高频考点,MySQL + Redis ≥ Java 基础/集合/并发 > 框架知识,大厂会深挖并发与底层。
+
+**Java 基础**
+
+- [Java 基础常见面试题总结(上)](https://javaguide.cn/java/basis/java-basic-questions-01.html)、[(中)](https://javaguide.cn/java/basis/java-basic-questions-02.html)、[(下)](https://javaguide.cn/java/basis/java-basic-questions-03.html):语法与面向对象、字符串与拷贝、异常/泛型/反射/SPI/序列化/注解
+
+**Java 集合**
+
+- [Java 集合常见面试题(上)](https://javaguide.cn/java/collection/java-collection-questions-01.html)、[(下)](https://javaguide.cn/java/collection/java-collection-questions-02.html):List/Set/Queue、HashMap、ConcurrentHashMap
+
+**Java 并发**(大厂必深挖)
+
+- [Java 并发常见面试题(上)](https://javaguide.cn/java/concurrent/java-concurrent-questions-01.html)、[(中)](https://javaguide.cn/java/concurrent/java-concurrent-questions-02.html)、[(下)](https://javaguide.cn/java/concurrent/java-concurrent-questions-03.html):线程与锁、synchronized/ReentrantLock、ThreadLocal/线程池/Future/AQS/虚拟线程
+- [JMM](https://javaguide.cn/java/concurrent/jmm.html)、[线程池详解](https://javaguide.cn/java/concurrent/java-thread-pool-summary.html)与[最佳实践](https://javaguide.cn/java/concurrent/java-thread-pool-best-practices.html)
+- [ThreadLocal](https://javaguide.cn/java/concurrent/threadlocal.html)、[AQS](https://javaguide.cn/java/concurrent/aqs.html)、[CompletableFuture](https://javaguide.cn/java/concurrent/completablefuture-intro.html)、[常见并发容器](https://javaguide.cn/java/concurrent/java-concurrent-collections.html)
+
+**MySQL**(必看)
+
+- [MySQL 常见面试题总结](https://javaguide.cn/database/mysql/mysql-questions-01.html)(基础、引擎、事务、索引、锁、优化)
+- [MySQL 索引详解](https://javaguide.cn/database/mysql/mysql-index.html)、[三大日志](https://javaguide.cn/database/mysql/mysql-logs.html)、[事务隔离级别](https://javaguide.cn/database/mysql/transaction-isolation-level.html)
+- [InnoDB 对 MVCC 的实现](https://javaguide.cn/database/mysql/innodb-implementation-of-mvcc.html)、[SQL 执行过程](https://javaguide.cn/database/mysql/how-sql-executed-in-mysql.html)
+
+**Redis**(必看)
+
+- [Redis 常见面试题总结(上)](https://javaguide.cn/database/redis/redis-questions-01.html)、[Redis 常见面试题总结(下)](https://javaguide.cn/database/redis/redis-questions-02.html)
+- [Redis 延时任务](https://javaguide.cn/database/redis/redis-delayed-task.html)、[Redis 做消息队列](https://javaguide.cn/database/redis/redis-stream-mq.html)
+- [5 种基本数据类型](https://javaguide.cn/database/redis/redis-data-structures-01.html)、[3 种特殊类型](https://javaguide.cn/database/redis/redis-data-structures-02.html)、[跳表实现有序集合](https://javaguide.cn/database/redis/redis-skiplist.html)
+- [持久化](https://javaguide.cn/database/redis/redis-persistence.html)、[内存碎片](https://javaguide.cn/database/redis/redis-memory-fragmentation.html)、[常见阻塞原因](https://javaguide.cn/database/redis/redis-common-blocking-problems-summary.html)
+
+### 第三阶段:框架和系统设计(约 1~3 周)
+
+#### 设计模式
+
+- [设计模式常见面试题总结](https://interview.javaguide.cn/system-design/design-pattern.html)
+
+#### 框架
+
+**Spring / Spring Boot**
+
+- [Spring 常见面试题](https://javaguide.cn/system-design/framework/spring/spring-knowledge-and-questions-summary.html)、[SpringBoot 常见面试题](https://javaguide.cn/system-design/framework/spring/springboot-knowledge-and-questions-summary.html)
+- [常用注解](https://javaguide.cn/system-design/framework/spring/spring-common-annotations.html)、[IoC 与 AOP](https://javaguide.cn/system-design/framework/spring/ioc-and-aop.html)、[Spring 事务](https://javaguide.cn/system-design/framework/spring/spring-transaction.html)
+- [Spring 中的设计模式](https://javaguide.cn/system-design/framework/spring/spring-design-patterns-summary.html)、[SpringBoot 自动装配](https://javaguide.cn/system-design/framework/spring/spring-boot-auto-assembly-principles.html)、[Async 原理](https://javaguide.cn/system-design/framework/spring/async.html)(原理性知识,时间不够可跳过)
+- [MyBatis 常见面试题](https://javaguide.cn/system-design/framework/mybatis/mybatis-interview.html)(不重要,可跳过,考查不多)、[Netty 常见面试题](https://javaguide.cn/system-design/framework/netty.html)(用到才需要准备)
+
+**自测**:能说清项目里用到的 Spring 注解、IoC/AOP 在项目中的体现、事务失效场景;设计模式能举出项目或框架中的例子。
+
+**权限与安全**
+
+- [认证授权基础](https://javaguide.cn/system-design/security/basis-of-authority-certification.html)、[JWT](https://javaguide.cn/system-design/security/jwt-intro.html) 与[优缺点](https://javaguide.cn/system-design/security/advantages-and-disadvantages-of-jwt.html)、[权限系统设计](https://javaguide.cn/system-design/security/design-of-authority-system.html)、[SSO](https://javaguide.cn/system-design/security/sso-intro.html)、[常见加密算法](https://javaguide.cn/system-design/security/encryption-algorithms.html)
+
+#### 系统设计与场景题
+
+面试官常会穿插一两道系统设计或场景题,考察整体思路和方案权衡。
+
+- **系统设计 / 场景题汇总**:[系统设计常见面试题总结](https://javaguide.cn/system-design/system-design-questions.html)(付费内容在 [《后端面试高频系统设计&场景题》](https://javaguide.cn/zhuanlan/back-end-interview-high-frequency-system-design-and-scenario-questions.html) 专栏,含短链、秒杀、海量数据处理等 30+ 道)。
+- **本站可参考的设计类文章**(思路可迁移到面试口述):[定时任务](https://javaguide.cn/system-design/schedule-task.html)、[Web 实时消息推送](https://javaguide.cn/system-design/web-real-time-message-push.html)。
+
+
+
+**自测**:能口述 1~2 个经典系统设计(如短链、秒杀、限流)的整体思路与关键取舍;场景题(如海量数据去重、第三方登录)能说出常见方案。
+
+### 第四阶段:计算机基础(按目标公司安排)
+
+**目标字节、腾讯等重算法/基础的厂**:适当多留时间,算法与代码题要单独刷(LeetCode 热题、剑指 Offer 等等);**目标中小厂**:可压缩或后置。
+
+- **算法与代码题**(面字节、快手等必留时间):[剑指 Offer 题解](https://javaguide.cn/cs-basics/algorithms/the-sword-refers-to-offer.html)、LeetCode 热题 100、常见手写(如 LRU、生产者消费者、单例等)。建议每天至少 1 道,保持手感。
+- **网络**:[计网常见面试题(上)](https://javaguide.cn/cs-basics/network/other-network-questions.html)、[(下)](https://javaguide.cn/cs-basics/network/other-network-questions2.html)、[访问网页全过程](https://javaguide.cn/cs-basics/network/the-whole-process-of-accessing-web-pages.html)、[应用层常见协议](https://javaguide.cn/cs-basics/network/application-layer-protocol.html)、[HTTP/HTTPS](https://javaguide.cn/cs-basics/network/http-vs-https.html)、[HTTP 1.0 vs 1.1](https://javaguide.cn/cs-basics/network/http1.0-vs-http1.1.html)、[DNS](https://javaguide.cn/cs-basics/network/dns.html)、[TCP 三次握手与四次挥手](https://javaguide.cn/cs-basics/network/tcp-connection-and-disconnection.html)、[TCP 可靠性](https://javaguide.cn/cs-basics/network/tcp-reliability-guarantee.html)、[ARP](https://javaguide.cn/cs-basics/network/arp.html)
+- **操作系统**:[操作系统常见面试题(上)](https://javaguide.cn/cs-basics/operating-system/operating-system-basic-questions-01.html)、[(下)](https://javaguide.cn/cs-basics/operating-system/operating-system-basic-questions-02.html)、[Linux 基础](https://javaguide.cn/cs-basics/operating-system/linux-intro.html)
+- **数据结构**:[数组/链表/栈/队列](https://javaguide.cn/cs-basics/data-structure/linear-data-structure.html)、[图](https://javaguide.cn/cs-basics/data-structure/graph.html)、[堆](https://javaguide.cn/cs-basics/data-structure/heap.html)、[树](https://javaguide.cn/cs-basics/data-structure/tree.html)、[红黑树](https://javaguide.cn/cs-basics/data-structure/red-black-tree.html)、[布隆过滤器](https://javaguide.cn/cs-basics/data-structure/bloom-filter.html)
+
+**自测**:能画访问网页全过程、TCP 握手挥手等等;算法题能手写常见套路;OS 进程/线程、内存、死锁能说清概念与例子。
+
+### 第五阶段:分布式与高并发(按简历与岗位)
+
+若简历或岗位涉及分布式/微服务/高并发,再系统过一遍;否则可只过「项目会用到的点」。
+
+- **分布式理论**:[CAP 与 BASE](https://javaguide.cn/distributed-system/protocol/cap-and-base-theorem.html)、[Paxos](https://javaguide.cn/distributed-system/protocol/paxos-algorithm.html)、[Raft](https://javaguide.cn/distributed-system/protocol/raft-algorithm.html)、[Gossip](https://javaguide.cn/distributed-system/protocol/gossip-protocol.html)、[一致性哈希](https://javaguide.cn/distributed-system/protocol/consistent-hashing.html)
+- **RPC**:[RPC 基础](https://javaguide.cn/distributed-system/rpc/rpc-intro.html)、[Dubbo](https://javaguide.cn/distributed-system/rpc/dubbo.html)(目前问的很少,可跳过)
+- **分布式 ID / 网关 / 锁 / 事务**(项目涉及再重点看):[分布式 ID](https://javaguide.cn/distributed-system/distributed-id.html)、[设计指南](https://javaguide.cn/distributed-system/distributed-id-design.html)、[API 网关](https://javaguide.cn/distributed-system/api-gateway.html)、[Spring Cloud Gateway](https://javaguide.cn/distributed-system/spring-cloud-gateway-questions.html)、[分布式锁](https://javaguide.cn/distributed-system/distributed-lock-implementations.html)、[分布式事务](https://javaguide.cn/distributed-system/distributed-transaction.html)
+- **高并发**(项目涉及再重点看):[CDN](https://javaguide.cn/high-performance/cdn.html)、[读写分离与分库分表](https://javaguide.cn/high-performance/read-and-write-separation-and-library-subtable.html)、[冷热分离](https://javaguide.cn/high-performance/data-cold-hot-separation.html)、[SQL 优化](https://javaguide.cn/high-performance/sql-optimization.html)、[深度分页](https://javaguide.cn/high-performance/deep-pagination-optimization.html)、[负载均衡](https://javaguide.cn/high-performance/load-balancing.html)
+- **高可用**(项目涉及再重点看):[高可用系统设计](https://javaguide.cn/high-availability/high-availability-system-design.html)、[限流](https://javaguide.cn/high-availability/limit-request.html)、[熔断与降级](https://javaguide.cn/high-availability/fallback-and-circuit-breaker.html)、[超时与重试](https://javaguide.cn/high-availability/timeout-and-retry.html)、[幂等设计](https://javaguide.cn/high-availability/idempotency.html)、[冗余设计](https://javaguide.cn/high-availability/redundancy.html)
+- **消息队列**(项目涉及再重点看):[MQ 基础](https://javaguide.cn/high-performance/message-queue/message-queue.html)、[Disruptor](https://javaguide.cn/high-performance/message-queue/disruptor-questions.html)、[RabbitMQ](https://javaguide.cn/high-performance/message-queue/rabbitmq-questions.html)、[RocketMQ](https://javaguide.cn/high-performance/message-queue/rocketmq-questions.html)、[Kafka](https://javaguide.cn/high-performance/message-queue/kafka-questions-01.html)
+
+**自测**:能讲清项目里用到的分布式方案(如分布式锁、ID、MQ)及选型理由;CAP/BASE、一致性哈希等能举例说明。
+
+### 第六阶段:JVM(大厂 / 部分中厂)
+
+目标阿里、美团、携程、顺丰、招银等可重点看;面国企或小厂可跳过。
+
+- [Java 内存区域](https://javaguide.cn/java/jvm/memory-area.html)、[JVM 垃圾回收](https://javaguide.cn/java/jvm/jvm-garbage-collection.html)
+- [类文件结构](https://javaguide.cn/java/jvm/class-file-structure.html)、[类加载过程](https://javaguide.cn/java/jvm/class-loading-process.html)、[类加载器](https://javaguide.cn/java/jvm/classloader.html)
+- 结合[星球](https://javaguide.cn/about-the-author/zhishixingqiu-two-years.html)的 [常见线上问题案例](https://t.zsxq.com/0bsAac47U) 理解调优与排查(也可以参考这篇 [JVM 线上问题排查和性能调优案例](https://javaguide.cn/java/jvm/jvm-in-action.html))
+
+**自测**:能说清内存区域、常见 GC 器与回收过程、类加载与双亲委派;能结合项目或案例讲一次 GC 调优或 OOM 排查思路。
+
+**Java 新特性**(按岗位要求选读):[Java 11](https://javaguide.cn/java/new-features/java11.html)、[Java 17](https://javaguide.cn/java/new-features/java17.html)、[Java 21](https://javaguide.cn/java/new-features/java21.html)
+
+### 面试前 1~2 天冲刺清单
+
+临近面试时优先做这几件事,避免临时抱佛脚方向散乱:
+
+| 事项 | 说明 |
+| ----------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------- |
+| 过一遍必会题 | 重点看你第一阶段整理的「项目相关必会题」+ 简历上写的「熟练掌握」对应的考点,能口头复述要点即可。 |
+| 练一遍项目话术 | 每个项目 1 分钟版、3 分钟版各讲一遍,卡壳的地方记下来再顺一遍。 |
+| 目标公司/岗位倾向 | 翻一下该公司或同类型岗位的面经,看有没有偏重(如算法、计网、项目深挖),针对性过一眼。 |
+| 心态与状态 | 早睡、准备好设备(线上面试)或路线(现场),可看 [面试太紧张怎么办?](https://javaguide.cn/interview-preparation/how-to-handle-interview-nerves.html)。 |
+
+面试结束后建议做一次简短复盘:哪些题答得不好、哪些没准备到,补充进必会题清单,下一场前重点过一遍。
diff --git a/docs/interview-preparation/how-to-handle-interview-nerves.md b/docs/interview-preparation/how-to-handle-interview-nerves.md
new file mode 100644
index 00000000000..d46a28716f2
--- /dev/null
+++ b/docs/interview-preparation/how-to-handle-interview-nerves.md
@@ -0,0 +1,76 @@
+---
+title: 面试太紧张怎么办?
+description: 面试太紧张影响发挥怎么办?从心态调整、提前准备到模拟面试与表达训练,提供一套可落地的方法,帮助你降低焦虑、提升临场表现,更稳定地通过技术面试。
+category: 面试准备
+icon: security-fill
+head:
+ - - meta
+ - name: keywords
+ content: 面试紧张,技术面试,面试心态,临场发挥,模拟面试,表达训练,面试准备,校招
+---
+
+
+
+很多小伙伴在第一次技术面试时都会感到紧张甚至害怕,遇到稍微刁钻的问题大脑就一片空白,面试结束后还会有种“懵懵的”感觉。我也经历过类似的状况,对这种手心出汗、语无伦次的窘境深有体会。
+
+其实,**紧张是非常正常的生理和心理反应**——它代表你对这次机会的重视,也源于人类对未知结果的天然担忧。但如果任由过度紧张蔓延,绝对会大幅折损你的临场发挥水平。
+
+下面,我将结合自己的实战经验,从**心态重塑、战术准备、临场应对、面后复盘**四个维度,分享一套可落地的“抗紧张”指南。
+
+## 试着接受紧张情绪,调整心态
+
+首先要明白,紧张是正常情绪,特别是初次或前几次面试时,多少都会有点忐忑。不要过分排斥这种情绪,可以适当地“拥抱”它:
+
+- **搞清楚面试的本质**:面试本质上是一场与面试官的深入交流,是一个双向选择的过程。面试失败并不意味着你的价值和努力被否定,而可能只是因为你与目标岗位暂时不匹配,或者仅仅是一次 KPI 面试,这家公司可能压根就没有真正的招聘需求。失败的原因也可能是某些知识点、项目经验或表达方式未能充分展现出你的能力。即便这次面试未通过,也不妨碍你继续尝试其他公司,完全不慌!
+- **不要害怕面试官**:很多求职者平时和同学朋友交流沟通的蛮好,一到面试就害怕了。面试官和求职者双方是平等的,以后说不定就是同事关系。也不要觉得面试官就很厉害,实际上,面试官的水平也参差不齐。他们提出的问题,可能自己也没有完全理解。
+- **给自己积极的心理暗示**:告诉自己“有点紧张没关系,这只能让我更专注,心跳加快是我在给自己打气,我一定可以回答的很好!”。
+
+## 提前准备,减少不确定性
+
+**不确定性越多,越容易紧张。** 如果你能够在面试前做充分的准备,很多“未知”就会消失,紧张情绪自然会减轻很多。
+
+### 认真准备技术面试
+
+- **优先梳理核心知识点**:比如计算基础、数据库、Java 基础、Java 集合、并发编程、SpringBoot(这里以 Java 后端方向为例)等。如果时间不够,可以分轻重缓急,有重点地复习。如果你想要系统准备 Java 后端面试但又不知道如何开始的,可以参考 [Java 后端面试通关计划(后端通用)](https://javaguide.cn/interview-preparation/backend-interview-plan.html)。
+- **精心准备项目经历**:认真思考你简历上最重要的项目(面试以前两个项目为主,尤其是第一个),它们的技术难点、业务逻辑、架构设计,以及可能被面试官深挖的点。把你的思考总结成可能出现的面试问题,并尝试回答。
+
+### 模拟面试和自测
+
+- **约朋友或同学互相提问**:以真实的面试场景来进行演练,并及时对回答进行诊断和反馈。
+- **线上练习**:直接利用 AI 来进行模拟面试即可,免费且高效。把自己的简历投喂给它,让它根据你的简历,尤其是项目经历生成面试问题。
+- **面经**:平时可以多看一些前辈整理的面经,尤其是目标岗位或目标公司的面经,总结高频考点和常见问题。
+- **技术面试题自测**:在 [《Java 面试指北》](https://javaguide.cn/zhuanlan/java-mian-shi-zhi-bei.html) 的 「技术面试题自测篇」 ,我总结了 Java 面试中最重要的知识点的最常见的面试题并按照面试提问的方式展现出来。其中,每一个问题都有提示和重要程度说明,非常适合用来自测。
+
+[《Java 面试指北》](https://javaguide.cn/zhuanlan/java-mian-shi-zhi-bei.html) 的 「技术面试题自测篇」概览:
+
+
+
+### 多表达
+
+平时要多说,多表达出来,不要只是在心里面想,不然真正面试的时候会发现想的和说的不太一样。
+
+我前面推荐的模拟面试和自测,有一部分原因就是为了能够多多表达。
+
+### 多面试
+
+- **先小厂后大厂**:可以先去一些规模较小或者对你来说压力没那么大的公司试试手,积累一些实战经验,增加一些信心;等熟悉了面试流程、能够更从容地回答问题后,再去挑战自己心仪的大厂或热门公司。
+- **积累“失败经验”**:不要怕被拒,有些时候被拒绝却能从中学到更多。多复盘,多思考到底是哪个环节出了问题,再用更好的状态迎接下一次面试。
+
+### 保证休息
+
+- **留出充裕时间**:面试前尽量不要排太多事情,保证自己能有个好状态去参加面试。
+- **保证休息**:充足睡眠有助于情绪稳定,也能让你在面试时更清晰地思考问题。
+
+## 遇到不会的问题不要慌
+
+一场面试,不太可能面试官提的每一个问题你都能轻松应对,除非这场面试非常简单。
+
+在面试过程中,遇到不会的问题,首先要做的是快速回顾自己过往的知识,看是否能找到突破口。如果实在没有思路的话,可以真诚地向面试要一些提示比如谈谈你对这个问题的理解以及困惑点。一定不要觉得向面试官要提示很可耻,只要沟通没问题,这其实是很正常的。最怕的就是自己不会,还乱回答一通,这样会让面试官觉得你技术态度有问题。
+
+## 面试结束后的复盘
+
+很多人关注面试前的准备,却忽略了面试后的复盘,这一步真的非常非常非常重要:
+
+1. **记录面试中的问题**:无论回答得好坏,都把它们写下来。如果问到了一些没想过的问题,可以认真思考并在面试后补上答案。
+2. **反思自己的表现**:有没有遇到卡壳的地方?是知识没准备到还是过于紧张导致表达混乱?下次如何改进?
+3. **持续完善自己的“面试题库”**:把新的问题补充进去,不断拓展自己的知识面,也逐步降低对未知问题的恐惧感。
diff --git a/docs/interview-preparation/internship-experience.md b/docs/interview-preparation/internship-experience.md
new file mode 100644
index 00000000000..719c0e5c31f
--- /dev/null
+++ b/docs/interview-preparation/internship-experience.md
@@ -0,0 +1,94 @@
+---
+title: 校招没有实习经历怎么办?实习经历怎么写?
+description: 校招没有实习经历也能上岸:从补强项目经验、持续优化简历到系统准备技术面试,给出可执行的提升路径与注意事项,帮助你在没有大厂实习的情况下提高面试通过率。
+category: 面试准备
+icon: experience
+head:
+ - - meta
+ - name: keywords
+ content: 校招,实习经历,没有实习怎么办,项目经验,简历优化,技术面试准备,Java后端,秋招
+---
+
+
+
+由于目前的面试太卷,对于犹豫是否要找实习的同学来说,个人建议不论是本科生还是研究生都应该在参加校招面试之前,争取一下不错的实习机会,尤其是大厂的实习机会,日常实习或者暑期实习都可以。当然,如果大厂实习面不上,中小厂实习也是可以接受的。
+
+不过,现在的实习是真难找,这两年有非常多的同学没有找到实习,有一部分甚至是 211/985 名校的同学。实习难找是一方面原因,国内很多学校的导师压根不放实习,这也是很棘手的问题。
+
+## 没有实习经历怎么办?
+
+如果实在是找不到合适的实习的话,那也没办法,我们应该多花时间去把下面这三件事情给做好:
+
+1. 补强项目经历
+2. 持续完善简历
+3. 准备技术面试
+
+### 补强项目经历
+
+校招没有实习经历的话,找工作比较吃亏(没办法,太卷了),需要在项目经历部分多发力弥补一下。
+
+建议你尽全力地去补强自己的项目经历,完善现有的项目或者去做更有亮点的项目,尽可能地通过项目经历去弥补一些。
+
+你面试中的重点就是你的项目经历涉及到的知识点,如果你的项目经历比较简单的话,面试官直接不知道问啥了。另外,你的项目经历中不涉及的知识点,但在技能介绍中提到的知识点也很大概率会被问到。像 Redis 这种基本是面试 Java 后端岗位必备的技能,我觉得大部分面试官应该都会问。
+
+推荐阅读一下网站的这篇文章:[项目经验指南](https://javaguide.cn/interview-preparation/project-experience-guide.html)。
+
+### 完善简历
+
+一定一定一定要重视简历啊!建议至少花 2~3 天时间来专门完善自己的简历。并且,后续还要持续完善。
+
+对于面试官来说,筛选简历的时候会比较看重下面这些维度:
+
+1. **实习/工作经历**:看你是否有不错的实习经历,大厂且与面试岗位相关的实习/工作经历最佳。
+2. **获奖经历**:如果有含金量比较高(知名度较高的赛事比如 ACM、阿里云天池)的获奖经历的话,也是加分点,尤其是对于校招来说,这类求职者属于是很多大厂争抢的对象(但不是说获奖了就能进大厂,还是要面试表现还可以)。对于社招来说,获奖经历作用相对较小,通常会更看重过往的工作经历和项目经验。
+3. **项目经验**:项目经验对于面试来说非常重要,面试官会重点关注,同时也是有水平的面试提问的重点。
+4. **技能匹配度**:看你的技能是否满足岗位的需求。在投递简历之前,一定要确认一下自己的技能介绍中是否缺少一些你要投递的对应岗位的技能要求。
+5. **学历**:相对其他行业来说,程序员求职面试对于学历的包容度还是比较高的,只要你在其他方面有过人之出的话,也是可以弥补一下学历的缺陷的。你要知道,很多行业比如律师、金融,学历就是敲门砖,学历没达到要求,直接面试机会都没有。不过,由于现在面试越来越卷,一些大厂、国企和研究所也开始卡学历了,很多岗位都要求 211/985,甚至必须需要硕士学历。总之,学历很难改变,学校较差的话,就投递那些对学历没有明确要求的公司即可,努力提升自己的其他方面的硬实力。
+
+对于大部分求职者来说,实习/工作经历、项目经验、技能匹配度更重要一些。不过,不排除一些公司会因为学历卡人。
+
+详细的程序员简历编写指南可以参考这篇文章:[程序员简历编写指南(重要)](https://javaguide.cn/interview-preparation/resume-guide.html)。
+
+### 准备技术面试
+
+面试之前一定要提前准备一下常见的面试题也就是八股文:
+
+- 自己面试中可能涉及哪些知识点、那些知识点是重点。
+- 面试中哪些问题会被经常问到、面试中自己该如何回答。(强烈不推荐死记硬背,第一:通过背这种方式你能记住多少?能记住多久?第二:背题的方式的学习很难坚持下去!)
+
+不同类型的公司对于技能的要求侧重点是不同的比如腾讯、字节可能更重视计算机基础比如网络、操作系统这方面的内容。阿里、美团这种可能更重视你的项目经历、实战能力。
+
+一定不要抱着一种思想,觉得八股文或者基础问题的考查意义不大。如果你抱着这种思想复习的话,那效果可能不会太好。实际上,个人认为还是很有意义的,八股文或者基础性的知识在日常开发中也会需要经常用到。例如,线程池这块的拒绝策略、核心参数配置什么的,如果你不了解,实际项目中使用线程池可能就用的不是很明白,容易出现问题。而且,其实这种基础性的问题是最容易准备的,像各种底层原理、系统设计、场景题以及深挖你的项目这类才是最难的!
+
+八股文资料首推我的 [《Java 面试指北》](https://javaguide.cn/zhuanlan/java-mian-shi-zhi-bei.html) 和 [JavaGuide](https://javaguide.cn/home.html) 。里面不仅仅是原创八股文,还有很多对实际开发有帮助的干货。除了我的资料之外,你还可以去网上找一些其他的优质的文章、视频来看。
+
+如果你想要系统准备 Java 后端面试但又不知道如何开始的,可以参考 [Java 后端面试通关计划(后端通用)](https://javaguide.cn/interview-preparation/backend-interview-plan.html)。
+
+## 实习经历在简历上一般怎么写比较出彩?
+
+实习经历的描述一定要避免空谈,尽量列举出你在实习期间取得的成就和具体贡献,使用具体的数据和指标来量化你的工作成果。
+
+示例(这里假设项目细节放在实习经历这里介绍,你也可以选择将实习经历参与的项目放到项目经历中):
+
+1. 负责订单模块核心流程开发,实现订单状态的精确流转,并保障与库存、支付等模块的数据一致性。
+2. 负责行为风控黑名单看板的开发,支持查看拉黑用户、批量拉黑以及取消拉黑。
+3. 基于 Redisson + AOP 封装限流组件,实现对核心接口(如付费、课程搜索)的限流,有效防止恶意请求冲击。
+4. 优化用户统计模块性能,利用 CompletableFuture 并行加载多维度数据(如用户增长、课程活跃度),,平均相应时间从 3.5s 降低到 1s。
+5. 封装通用数据脱敏组件,通过自定义 Jackson 注解实现对手机号、邮箱等敏感信息的自动、无侵入式脱敏。
+6. 优化文件上传模块,基于 MinIO 实现了文件的分片上传、断点续传以及极速秒传功能。
+7. 排查并解决扣费模块由于扣费父任务和反作弊子任务使用同一个线程池导致的死锁问题,通过线程池隔离策略根除该隐患。
+8. 实习期间独立负责 7 个功能需求与 3 个线上问题修复,代码均一次性通过评审与测试。
+
+下面是[星球](https://javaguide.cn/about-the-author/zhishixingqiu-two-years.html)一位球友分享的实习经历介绍,整体写的还是非常不错的:
+
+
+
+📌关于实习经历这块再多提一点:很多同学实习期间可能接触不到什么实际的开发任务,大部分时间可能都是在熟悉和维护项目。
+
+对于这种情况,应对思路是一套组合拳:首先,你肯定是要和 mentor 沟通继续争取做一些有价值的工作,这样你的实习经历才更有价值,简历上自然就能够有东西可写。记得找一个 mentor 不那么忙的时候沟通,放低姿态,真诚一些,表明自己现有的工作已经认真完成,想要承担更多责任的意愿。其次,不管是否能够争取到这种机会,你都要自己有意识地寻找项目中适合自己研究的功能点(比如同组其他实习生干的活),进行深度挖掘。重点关注以下几个方面:
+
+1. **这个功能是干嘛的?** 它解决了什么业务痛点?给哪个业务方用的?整个流程是怎样的?
+2. **它是怎么实现的?** 用了哪些关键技术、框架或者设计模式?核心代码的逻辑是怎样的?
+3. **为什么要这么设计?** 当初设计的时候有没有别的方案?现在这个方案好在哪,又有什么潜在的坑?如果让你来做,你会怎么设计?
+
+只要你把具体的功能点彻底搞懂,那就可以在简历上合理包装成自己的成果。除了功能点开发之外,也可以包装一些合适的问题排查解决经历,这样能够体现你解决问题的能力。 面试时也不用太担心自己“露馅”,只要你选择的内容不属于那些显然不会交给实习生完成的高难度任务,并且能清晰地讲明白,就不会有问题。
diff --git a/docs/interview-preparation/interview-experience.md b/docs/interview-preparation/interview-experience.md
index 623aa555e73..c5f08d174ae 100644
--- a/docs/interview-preparation/interview-experience.md
+++ b/docs/interview-preparation/interview-experience.md
@@ -1,12 +1,17 @@
---
title: 优质面经汇总(付费)
+description: 优质面经汇总:整理 30+ 篇高质量 Java 后端校招/社招面经与复盘,总结高频考点与面试策略,适合对照自测与查缺补漏。
category: 知识星球
icon: experience
+head:
+ - - meta
+ - name: keywords
+ content: Java面经,校招面经,社招面经,大厂面经,面试经验,面经汇总,Java后端面试,付费专栏
---
古人云:“**他山之石,可以攻玉**” 。善于学习借鉴别人的面试的成功经验或者失败的教训,可以让自己少走许多弯路。
-在 **[《Java 面试指北》](../zhuanlan/java-mian-shi-zhi-bei.md)** 的 **「面经篇」** ,我分享了 15+ 篇高质量的 Java 后端面经,有校招的,也有社招的,有大厂的,也有中小厂的。
+在 **[《Java 面试指北》](../zhuanlan/java-mian-shi-zhi-bei.md)** 的 **「面经篇」** ,我分享了 30+ 篇高质量的 Java 后端面经,有校招的,也有社招的,有大厂的,也有中小厂的。
如果你是非科班的同学,也能在这些文章中找到对应的非科班的同学写的面经。
@@ -25,6 +30,6 @@ icon: experience
有很多同学要说了:“为什么不直接给出具体答案呢?”。主要原因有如下两点:
1. 参考资料解释的要更详细一些,还可以顺便让你把相关的知识点复习一下。
-2. 给出的参考资料基本都是我的原创,假如后续我想对面试问题的答案进行完善,就不需要挨个把之前的面经写的答案给修改了(面试中的很多问题都是比较类似的)。当然了,我的原创文章也不太可能覆盖到面试的每个点,部面试问题的答案,我是精选的其他技术博主写的优质文章,文章质量都很高。
+2. 给出的参考资料基本都是我的原创,假如后续我想对面试问题的答案进行完善,就不需要挨个把之前的面经写的答案给修改了(面试中的很多问题都是比较类似的)。当然了,我的原创文章也不太可能覆盖到面试的每个点,部分面试问题的答案,我是精选的其他技术博主写的优质文章,文章质量都很高。
diff --git a/docs/interview-preparation/java-roadmap.md b/docs/interview-preparation/java-roadmap.md
new file mode 100644
index 00000000000..4e234274c45
--- /dev/null
+++ b/docs/interview-preparation/java-roadmap.md
@@ -0,0 +1,42 @@
+---
+title: Java 学习路线(最新版,4w+字)
+description: Java学习路线最新版:结合当下 Java 后端招聘要求,提供从基础到进阶的系统学习路径与资料建议,覆盖Java核心、数据库、缓存、中间件、框架与面试重点,帮助高效规划与提速上岸。
+category: 面试准备
+icon: path
+head:
+ - - meta
+ - name: keywords
+ content: Java学习路线,Java后端路线,Java学习计划,校招准备,面试路线,Spring Boot,MySQL,Redis,JVM
+---
+
+
+
+::: tip 重要说明
+
+本学习路线保持**年度系统性修订**,严格同步 Java 技术生态与招聘市场的最新动态,**确保内容时效性与前瞻性**。
+
+:::
+
+历时一个月精心打磨,笔者基于当下 Java 后端开发岗位招聘的最新要求,对既有学习路线进行了全面升级。本次升级涵盖技术栈增删、学习路径优化、配套学习资源更新等维度,力争构建出更符合 Java 开发者成长曲线的知识体系。
+
+亮色板概览:
+
+
+
+暗色板概览:
+
+
+
+这可能是你见过的最用心、最全面的 Java 后端学习路线。这份学习路线共包含 **4w+** 字,但你完全不用担心内容过多而学不完。我会根据学习难度,划分出适合找小厂工作必学的内容,以及适合逐步提升 Java 后端开发能力的学习路径。
+
+
+
+对于初学者,你可以按照这篇文章推荐的学习路线和资料进行系统性的学习;对于有经验的开发者,你可以根据这篇文章更一步地深入学习 Java 后端开发,提升个人竞争力。
+
+在看这份学习路线的过程中,建议搭配 [Java 面试重点总结(重要)](https://javaguide.cn/interview-preparation/key-points-of-interview.html),可以让你在学习过程中更有目的性。
+
+由于这份学习路线内容太多,因此我将其整理成了 PDF 版本(共 **55** 页),方便大家阅读。这份 PDF 有黑夜和白天两种阅读版本,满足大家的不同需求。
+
+这份学习路线的获取方法很简单:直接在公众号「**JavaGuide**」后台回复“**路线**”即可获取。
+
+
diff --git a/docs/interview-preparation/key-points-of-interview.md b/docs/interview-preparation/key-points-of-interview.md
index 83cb3facce2..db3ffd91c89 100644
--- a/docs/interview-preparation/key-points-of-interview.md
+++ b/docs/interview-preparation/key-points-of-interview.md
@@ -1,38 +1,60 @@
---
-title: Java面试重点总结(重要)
+title: Java后端面试重点总结
+description: Java后端面试重点总结:梳理校招/社招高频考点与复习优先级,覆盖Java基础、集合、并发、MySQL、Redis、Spring/Spring Boot、JVM与项目经验准备,帮你抓重点高效备战。
category: 面试准备
icon: star
+head:
+ - - meta
+ - name: keywords
+ content: Java后端面试,面试重点,八股文,Java基础,Java集合,Java并发,MySQL,Redis,Spring Boot,项目经验
---
+
+
::: tip 友情提示
-本文节选自 **[《Java 面试指北》](../zhuanlan/java-mian-shi-zhi-bei.md)**。这是一份教你如何更高效地准备面试的小册,涵盖常见八股文(系统设计、常见框架、分布式、高并发 ……)、优质面经等内容。
+本文节选自 **[《Java 面试指北》](../zhuanlan/java-mian-shi-zhi-bei.md)**。这是一份教你如何更高效地准备面试的专栏,内容和 JavaGuide 互补,涵盖常见八股文(系统设计、常见框架、分布式、高并发 ……)、优质面经等内容。
:::
## Java 后端面试哪些知识点是重点?
-**准备面试的时候,具体哪些知识点是重点呢?**
+**准备面试的时候,具体哪些知识点是重点呢?如何把握重点?**
+
+先看下面这张全局图(后续会详细解读):
+
+
给你几点靠谱的建议:
-1. Java 基础、集合、并发、MySQL、Redis、Spring、Spring Boot 这些 Java 后端开发必备的知识点。大厂以及中小厂的面试问的比较多的就是这些知识点(不信的话,你可以去多找一些面经看看)。我这里没有提到计算机基础相关的内容,这个会在下面提到。
-2. 你的项目经历涉及到的知识点,有水平的面试官都是会根据你的项目经历来问的。举个例子,你的项目经历使用了 Redis 来做限流,那 Redis 相关的八股文(比如 Redis 常见数据结构)以及限流相关的八股文(比如常见的限流算法)你就应该多花更多心思来搞懂!吃透!你把项目经历上的知识点吃透之后,再把你简历上哪些写熟练掌握的技术给吃透。最后,再去花时间准备其他知识点。
-3. 针对自身找工作的需求,你又可以适当地调整复习的重点。像中小厂一般问计算机基础比较少一些,有些大厂比如字节比较重视计算机基础尤其是算法。这样的话,如果你的目标是中小厂的话,计算机基础就准备面试来说不是那么重要了。如果复习时间不够的话,可以暂时先放放。
+1. Java 基础、集合、并发、MySQL、Redis 、Spring、Spring Boot 这些 Java 后端开发必备的知识点(MySQL + Redis >= Java > Spring + Spring Boot)。大厂以及中小厂的面试问的比较多的就是这些知识点。Spring 和 Spring Boot 这俩框架类的知识点相对前面的知识点来说重要性要稍低一些,但一般面试也会问一些,尤其是中小厂。并发知识一般中大厂提问更多也更难,尤其是大厂喜欢深挖底层,很容易把人问倒。计算机基础相关的内容会在下面提到。
+2. 你的项目经历涉及到的知识点是重中之重,有水平的面试官都是会根据你的项目经历来问的。举个例子,你的项目经历使用了 Redis 来做限流,那 Redis 相关的八股文(比如 Redis 常见数据结构)以及限流相关的八股文(比如常见的限流算法)你就应该多花更多心思来搞懂吃透!你把项目经历上的知识点吃透之后,再把你简历上哪些写熟练掌握的技术给吃透,最后再去花时间准备其他知识点。
+3. 针对自身找工作的需求,你又可以适当地调整复习的重点。像中小厂一般问计算机基础比较少一些,有些大厂比如字节比较重视计算机基础尤其是算法。这样的话,如果你的目标是中小厂的话,计算机基础就准备面试来说不是那么重要了。如果复习时间不够的话,可以暂时先放放,腾出时间给其他重要的知识点。
4. 一般校招的面试不会强制要求你会分布式/微服务、高并发的知识(不排除个别岗位有这方面的硬性要求),所以到底要不要掌握还是要看你个人当前的实际情况。如果你会这方面的知识的话,对面试相对来说还是会更有利一些(想要让项目经历有亮点,还是得会一些性能优化的知识。性能优化的知识这也算是高并发知识的一个小分支了)。如果你的技能介绍或者项目经历涉及到分布式/微服务、高并发的知识,那建议你尽量也要抽时间去认真准备一下,面试中很可能会被问到,尤其是项目经历用到的时候。不过,也还是主要准备写在简历上的那些知识点就好。
-5. JVM 相关的知识点,一般是大厂才会问到,面试中小厂就没必要准备了。JVM 面试中比较常问的是 [Java 内存区域](https://javaguide.cn/java/jvm/memory-area.html)、[JVM 垃圾回收](https://javaguide.cn/java/jvm/jvm-garbage-collection.html)、[类加载器和双亲委派模型](https://javaguide.cn/java/jvm/classloader.html) 以及 JVM 调优和问题排查(我之前分享过一些[常见的线上问题案例](https://t.zsxq.com/0bsAac47U),里面就有 JVM 相关的)。
-6. 不同的大厂面试侧重点也会不同。比如说你要去阿里这种公司的话,项目和八股文就是重点,阿里笔试一般会有代码题,进入面试后就很少问代码题了,但是对原理性的问题问的比较深,经常会问一些你对技术的思考。再比如说你要面试字节这种公司,那计算机基础,尤其是算法是重点,字节的面试十分注重代码功底,有时候开始面试就会直接甩给你一道代码题,写出来再谈别的。也会问面试八股文,以及项目,不过,相对来说要少很多。建议你看一下这篇文章 [为了解开互联网大厂秋招内幕,我把他们全面了一遍](https://mp.weixin.qq.com/s/pBsGQNxvRupZeWt4qZReIA),了解一下常见大厂的面试题侧重点。
+5. JVM 相关的知识点,一般是大厂(例如美团、阿里)和一些不错的中厂(例如携程、顺丰、招银网络)才会问到,面试国企、差一点的中厂和小厂就没必要准备了。JVM 面试中比较常问的是 [Java 内存区域](https://javaguide.cn/java/jvm/memory-area.html)、[JVM 垃圾回收](https://javaguide.cn/java/jvm/jvm-garbage-collection.html)、[类加载器和双亲委派模型](https://javaguide.cn/java/jvm/classloader.html) 以及 JVM 调优和问题排查(我之前分享过一些[常见的线上问题案例](https://t.zsxq.com/0bsAac47U),里面就有 JVM 相关的)。
+6. 不同的大厂面试侧重点也会不同。比如说你要去阿里这种公司的话,项目和八股文就是重点,阿里笔试一般会有代码题,进入面试后就很少问代码题了,但是对原理性的问题问的比较深,经常会问一些你对技术的思考。再比如说你要面试字节这种公司,那计算机基础,尤其是算法是重点,字节的面试十分注重代码功底,有时候开始面试就会直接甩给你一道代码题,写出来再谈别的。也会问面试八股文,以及项目,不过,相对来说要少很多。
+7. 多去找一些面经看看,尤其你目标公司或者类似公司对应岗位的面经。这样可以实现针对性的复习,还能顺便自测一波,检查一下自己的掌握情况。
看似 Java 后端八股文很多,实际把复习范围一缩小,重要的东西就是那些。考虑到时间问题,你不可能连一些比较冷门的知识点也给准备了。这没必要,主要精力先放在那些重要的知识点即可。
## 如何更高效地准备八股文?
+
+
对于技术八股文来说,尽量不要死记硬背,这种方式非常枯燥且对自身能力提升有限!但是!想要一点不背是不太现实的,只是说要结合实际应用场景和实战来理解记忆。
我一直觉得面试八股文最好是和实际应用场景和实战相结合。很多同学现在的方向都错了,上来就是直接背八股文,硬生生学成了文科,那当然无趣了。
举个例子:你的项目中需要用到 Redis 来做缓存,你对照着官网简单了解并实践了简单使用 Redis 之后,你去看了 Redis 对应的八股文。你发现 Redis 可以用来做限流、分布式锁,于是你去在项目中实践了一下并掌握了对应的八股文。紧接着,你又发现 Redis 内存不够用的情况下,还能使用 Redis Cluster 来解决,于是你就又去实践了一下并掌握了对应的八股文。
-**一定要记住你的主要目标是理解和记关键词,而不是像背课文一样一字一句地记下来!**
+**一定要记住你的主要目标是理解和记关键词,而不是像背课文一样一字一句地记下来,这样毫无意义!效率最低,对自身帮助也最小!**
-另外,记录博客或者用自己的理解把对应的知识点讲给别人听也是一个不错的选择。
+还要注意适当“投机取巧”,不要单纯死记八股,有些技术方案的实现有很多种,例如分布式 ID、分布式锁、幂等设计,想要完全记住所有方案不太现实,你就重点记忆你项目的实现方案以及选择该种实现方案的原因就好了。当然,其他方案还是建议你简单了解一下,不然也没办法和你选择的方案进行对比。
+
+想要检测自己是否搞懂或者加深印象,记录博客或者用自己的理解把对应的知识点讲给别人听也是一个不错的选择。
+
+另外,准备八股文的过程中,强烈建议你花个几个小时去根据你的简历(主要是项目经历部分)思考一下哪些地方可能被深挖,然后把你自己的思考以面试问题的形式体现出来。面试之后,你还要根据当下的面试情况复盘一波,对之前自己整理的面试问题进行完善补充。这个过程对于个人进一步熟悉自己的简历(尤其是项目经历)部分,非常非常有用。这些问题你也一定要多花一些时间搞懂吃透,能够流畅地表达出来。面试问题可以参考 [Java 面试常见问题总结(2024 最新版)](https://t.zsxq.com/0eRq7EJPy),记得根据自己项目经历去深入拓展即可!
最后,准备技术面试的同学一定要定期复习(自测的方式非常好),不然确实会遗忘的。
+
+## 详细面试准备计划(后端通用)
+
+[Java 后端面试重点和详细准备计划](https://javaguide.cn/interview-preparation/backend-interview-plan.html)
diff --git a/docs/interview-preparation/pdf-interview-javaguide.md b/docs/interview-preparation/pdf-interview-javaguide.md
new file mode 100644
index 00000000000..67d0da97125
--- /dev/null
+++ b/docs/interview-preparation/pdf-interview-javaguide.md
@@ -0,0 +1,62 @@
+---
+title: 2026 最新后端面试 PDF 资料
+description: 2026 版后端面试 PDF 资料整理(JavaGuide):梳理校招/社招高频考点与复习优先级,覆盖 Java 基础、集合、并发、MySQL、Redis、Spring/Spring Boot、JVM、系统设计与项目经验准备,帮你抓重点高效备战。
+category: 面试准备
+icon: pdf
+head:
+ - - meta
+ - name: keywords
+ content: 后端面试PDF,Java面试PDF,PDF面试资料,Java八股文PDF,面试突击PDF,校招社招,Java后端面试,Java基础,Java集合,Java并发,JVM,MySQL,Redis,Spring Boot,系统设计,项目经验
+---
+
+大家好,我是 Guide。
+
+**2026 版后端 PDF 面试资料终于搞定了!这次的更新量大得惊人,熬了几个通宵,总算能拿出来见人了。**
+
+在上一版的基础上,我把内容又往深里挖了挖。目前这份资料已经涵盖了 **Java 核心、计算机基础、数据库、缓存、分布式、设计模式、智力题、学习路线、面经**等全方位内容。毫不夸张地说,你备战后端面试需要的硬核干货,这一份全包了!
+
+为了让大家看得更爽,我对其中大部分 PDF 进行了“推倒重来式”的优化:
+
+- **重构面试突击系列**:将原先臃肿的内容拆分成多篇,逻辑更清晰。
+- **重写设计模式总结**:新增多道高频设计模式面试题,优化内容表达。
+- **全方位细节完善**:每一个知识点都反复推敲,确保没有逻辑断层。
+
+
+
+这些 PDF 面试资料的质量都非常高,绝大部分都是 Guide 的原创,也会有一些其他优质技术博主分享的原创资料。
+
+之所以一直坚持出 PDF 版,是因为有一些朋友比较喜欢看 PDF 资料,甚至把 PDF 资料打印出来学习。
+
+
+
+截止到目前,这套资料在各个渠道的汇总下载量已经突破了 **35w+** 。 说实话,这个数字对我来说不只是流量,更是沉甸甸的信任和责任。
+
+老规矩,没有任何花里胡哨的套路,直接**白嫖**: 在 **JavaGuide** 公众号后台回复 **PDF** 即可获取。
+
+
+
+由于 PDF 的时效性问题,如果想要更完美的体验,个人其实还是更建议大家去 [JavaGuide](https://javaguide.cn/) 网站上在线阅读,内容更新,一直在持续完善。
+
+## 部分内容概览
+
+**《JavaGuide 面试突击》— Java 集合**:
+
+
+
+**《JavaGuide 面试突击》— JVM**:
+
+
+
+**《JavaGuide 面试突击》—设计模式**:
+
+
+
+**Java 学习路线**:
+
+
+
+## 如何获取?
+
+老规矩,没有任何花里胡哨的套路,直接**白嫖**: 在 **JavaGuide** 公众号后台回复 **PDF** 即可获取。
+
+
diff --git a/docs/interview-preparation/project-experience-guide.md b/docs/interview-preparation/project-experience-guide.md
index f2e93df82b3..2b9a3c1026a 100644
--- a/docs/interview-preparation/project-experience-guide.md
+++ b/docs/interview-preparation/project-experience-guide.md
@@ -1,11 +1,16 @@
---
title: 项目经验指南
+description: 项目经验指南:针对没有项目/项目平淡的求职者,给出获取实战项目经验的方法与选择建议,并讲清如何做出项目亮点、如何复盘与表达,提升简历与面试竞争力。
category: 面试准备
icon: project
+head:
+ - - meta
+ - name: keywords
+ content: 项目经验,校招项目,实战项目,项目亮点,简历项目描述,后端项目,面试项目准备,项目复盘
---
::: tip 友情提示
-本文节选自 **[《Java 面试指北》](../zhuanlan/java-mian-shi-zhi-bei.md)**。这是一份教你如何更高效地准备面试的小册,涵盖常见八股文(系统设计、常见框架、分布式、高并发 ……)、优质面经等内容。
+本文节选自 **[《Java 面试指北》](../zhuanlan/java-mian-shi-zhi-bei.md)**。这是一份教你如何更高效地准备面试的专栏,内容和 JavaGuide 互补,涵盖常见八股文(系统设计、常见框架、分布式、高并发 ……)、优质面经等内容。
:::
## 没有项目经验怎么办?
@@ -72,9 +77,9 @@ GitHub 或者码云上面有很多实战类别项目,你可以选择一个来
## 有没有还不错的项目推荐?
-**[《Java 面试指北》](../zhuanlan/java-mian-shi-zhi-bei.md)** 的「面试准备篇」中有一篇文章专门整理了一些比较高质量的实战项目,非常适合用来学习或者作为项目经验。
+**[《Java 面试指北》](../zhuanlan/java-mian-shi-zhi-bei.md)** 的「面试准备篇」中有一篇文章专门整理了一些比较高质量的实战项目,包含业务项目、轮子项目、国外公开课 Lab 和视频类实战项目教程推荐,非常适合用来学习或者作为项目经验。
-
+
这篇文章一共推荐了 15+ 个实战项目,有业务类的,也有轮子类的,有开源项目、也有视频教程。对于参加校招的小伙伴,我更建议做一个业务类项目加上一个轮子类的项目。
diff --git a/docs/interview-preparation/resume-guide.md b/docs/interview-preparation/resume-guide.md
index dd6a6b33854..f0cd20b003b 100644
--- a/docs/interview-preparation/resume-guide.md
+++ b/docs/interview-preparation/resume-guide.md
@@ -1,7 +1,12 @@
---
-title: 程序员简历编写指南(重要)
+title: 程序员简历编写指南
+description: 程序员简历编写指南:从筛选逻辑出发讲清简历结构、项目经历与技能描述写法,提供简历模板与避坑建议,帮助你提高简历通过率并让面试官更好地深挖你的亮点。
category: 面试准备
icon: jianli
+head:
+ - - meta
+ - name: keywords
+ content: 程序员简历,Java简历,简历优化,项目经历写法,简历模板,校招简历,社招简历,面试准备
---
::: tip 友情提示
@@ -162,17 +167,23 @@ icon: jianli
- 使用 xxx 技术优化了 xxx 模块,响应时间从 2s 降低到 0.2s。
- ……
-个人职责介绍示例 :
+个人职责介绍示例(这里只是举例,不要照搬,结合自己项目经历自己去写,不然面试的时候容易被问倒) :
- 基于 Spring Cloud Gateway + Spring Security OAuth2 + JWT 实现微服务统一认证授权和鉴权,使用 RBAC 权限模型实现动态权限控制。
-- 参与项目订单模块的开发,负责订单创建、删除、查询等功能。
-- 整合 Canal + RocketMQ 将 MySQL 增量数据(如商品、订单数据)同步到 ES。
+- 参与项目订单模块的开发,负责订单创建、删除、查询等功能,基于 Spring 状态机实现订单状态流转。
+- 商品和订单搜索场景引入 Elasticsearch,并且实现了相关商品推荐以及搜索提示功能。
+- 整合 Canal + RabbitMQ 将 MySQL 增量数据(如商品、订单数据)同步到 Elasticsearch。
+- 利用 RabbitMQ 官方提供的延迟队列插件实现延时任务场景比如订单超时自动取消、优惠券过期提醒、退款处理。
+- 消息推送系统引入 RabbitMQ 实现异步处理、削峰填谷和服务解耦,最高推送速度 10w/s,单日最大消息量 2000 万。
+- 使用 MAT 工具分析 dump 文件解决了广告服务新版本上线后导致大量的服务超时告警的问题。
- 排查并解决扣费模块由于扣费父任务和反作弊子任务使用同一个线程池导致的死锁问题。
+- 基于 EasyExcel 实现广告投放数据的导入导出,通过 MyBatis 批处理插入数据,基于任务表实现异步。
- 负责用户统计模块的开发,使用 CompletableFuture 并行加载后台用户统计模块的数据信息,平均相应时间从 3.5s 降低到 1s。
-- 使用 Sharding-JDBC 以用户 ID 后 4 位作为 Shard Key 对订单表进行分库分表,共 3 个库,每个库 2 个订单表,单表数据量保持在 500w 以下。自定义雪花算法生成订单 ID 的规则,把分片键同时作为的订单 ID 一部分,避免了额外存储订单 ID 与路由键的关系。
+- 基于 Sentinel 对核心场景(如用户登入注册、收货地址查询等)进行限流、降级,保护系统,提升用户体验。
- 热门数据(如首页、热门博客)使用 Redis+Caffeine 两级缓存,解决了缓存击穿和穿透问题,查询速度毫秒级,QPS 30w+。
- 使用 CompletableFuture 优化购物车查询模块,对获取用户信息、商品详情、优惠券信息等异步 RPC 调用进行编排,响应时间从 2s 降低为 0.2s。
- 搭建 EasyMock 服务,用于模拟第三方平台接口,方便了在网络隔离情况下的接口对接工作。
+- 基于 SkyWalking + Elasticsearch 搭建分布式链路追踪系统实现全链路监控。
**4、如果你觉得你的项目技术比较落后的话,可以自己私下进行改进。重要的是让项目比较有亮点,通过什么方式就无所谓了。**
diff --git a/docs/interview-preparation/self-test-of-common-interview-questions.md b/docs/interview-preparation/self-test-of-common-interview-questions.md
index 85b0e236c01..c3d8c038eb2 100644
--- a/docs/interview-preparation/self-test-of-common-interview-questions.md
+++ b/docs/interview-preparation/self-test-of-common-interview-questions.md
@@ -1,19 +1,22 @@
---
title: 常见面试题自测(付费)
+description: 常见面试题自测:按面试提问方式整理Java后端高频问题,提供提示与重要程度标注,适合面试前自测、定位短板、针对性复习。
category: 知识星球
icon: security-fill
+head:
+ - - meta
+ - name: keywords
+ content: 面试题自测,Java面试题,八股文自测,查缺补漏,面试复习,高频考点,Java后端面试,付费内容
---
面试之前,强烈建议大家多拿常见的面试题来进行自测,检查一下自己的掌握情况,这是一种非常实用的备战技术面试的小技巧。
在 **[《Java 面试指北》](../zhuanlan/java-mian-shi-zhi-bei.md)** 的 **「技术面试题自测篇」** ,我总结了 Java 面试中最重要的知识点的最常见的面试题并按照面试提问的方式展现出来。
-
+
-每一道用于自测的面试题我都会给出重要程度,方便大家在时间比较紧张的时候根据自身情况来选择性自测。并且,我还会给出提示,方便你回忆起对应的知识点。
+每道题我都会给出**提示与思路**,并用 ⭐ 标注重要程度:⭐ 越多,说明面试越爱问,就越值得多花一些时间准备。
-在面试中如果你实在没有头绪的话,一个好的面试官也是会给你提示的。
-
-
+
diff --git a/docs/interview-preparation/teach-you-how-to-prepare-for-the-interview-hand-in-hand.md b/docs/interview-preparation/teach-you-how-to-prepare-for-the-interview-hand-in-hand.md
index 7cc0797bcf2..beba0d99cbd 100644
--- a/docs/interview-preparation/teach-you-how-to-prepare-for-the-interview-hand-in-hand.md
+++ b/docs/interview-preparation/teach-you-how-to-prepare-for-the-interview-hand-in-hand.md
@@ -1,20 +1,23 @@
---
-title: 手把手教你如何准备Java面试(重要)
+title: 如何高效准备Java面试?
+description: 如何高效准备Java面试:从求职导向学习、技能清单制定到简历优化与面试冲刺,提供系统化备战方法,帮助你少走弯路、提高面试通过率。
category: 知识星球
icon: path
+head:
+ - - meta
+ - name: keywords
+ content: Java面试准备,高效备战面试,求职导向学习,面试冲刺,简历优化,项目准备,校招,Java后端
---
::: tip 友情提示
-本文节选自 **[《Java 面试指北》](../zhuanlan/java-mian-shi-zhi-bei.md)**。这是一份教你如何更高效地准备面试的小册,涵盖常见八股文(系统设计、常见框架、分布式、高并发 ……)、优质面经等内容。
+本文节选自 **[《Java 面试指北》](../zhuanlan/java-mian-shi-zhi-bei.md)**。这是一份教你如何更高效地准备面试的专栏,内容和 JavaGuide 互补,涵盖常见八股文(系统设计、常见框架、分布式、高并发 ……)、优质面经等内容。
:::
-你的身边一定有很多编程比你厉害但是找的工作并没有你好的朋友!**技术面试不同于编程,编程厉害不代表技术面试就一定能过。**
+你身边是否有这样的朋友:编程能力比你强,求职结果却不如你?其实**技术好≠面试能过** —— 如今的面试早已不是 “会写代码就行”,不做准备就去面,大概率是 “撞枪口”。
-现在你去面个试,不认真准备一下,那简直就是往枪口上撞。我们大部分都只是普通人,没有发过顶级周刊或者获得过顶级大赛奖项。在这样一个技术面试氛围下,我们需要花费很多精力来准备面试,来提高自己的技术能力。“[面试造火箭,工作拧螺丝钉](https://mp.weixin.qq.com/s?__biz=Mzg2OTA0Njk0OA==&mid=2247491596&idx=1&sn=36fbf80922f71c200990de11514955f7&chksm=cea1afc7f9d626d1c70d5e54505495ac499ce6eb5e05ba4f4bb079a8563a84e27f17ceff38af&token=353590436&lang=zh_CN&scene=21#wechat_redirect)” 就是目前的一个常态,预计未来很长很长一段时间也还是会是这样。
+我们大多是普通开发者,没有顶会论文或竞赛大奖加持,面对 “面试造火箭,工作拧螺丝钉” 的常态,只能靠扎实准备突围。但准备面试不等于耍小聪明或者死记硬背面试题。 **一定不要对面试抱有侥幸心理。打铁还需自身硬!** 千万不要觉得自己看几篇面经,看几篇面试题解析就能通过面试了。一定要静下心来深入学习!
-准备面试不等于耍小聪明或者死记硬背面试题。 **一定不要对面试抱有侥幸心理。打铁还需自身硬!** 千万不要觉得自己看几篇面经,看几篇面试题解析就能通过面试了。一定要静下心来深入学习!
-
-这篇我会从宏观面出发简单聊聊如何准备 Java 面试,让你少走弯路!
+这篇文章就从宏观视角,带你搞懂程序员该如何系统准备面试:从求职导向学习,到简历优化、面试冲刺,帮你少走弯路,高效拿下心仪 offer。
## 尽早以求职为导向来学习
@@ -138,7 +141,7 @@ Java 后端面试复习的重点请看这篇文章:[Java 面试重点总结(
一定不要抱着一种思想,觉得八股文或者基础问题的考查意义不大。如果你抱着这种思想复习的话,那效果可能不会太好。实际上,个人认为还是很有意义的,八股文或者基础性的知识在日常开发中也会需要经常用到。例如,线程池这块的拒绝策略、核心参数配置什么的,如果你不了解,实际项目中使用线程池可能就用的不是很明白,容易出现问题。而且,其实这种基础性的问题是最容易准备的,像各种底层原理、系统设计、场景题以及深挖你的项目这类才是最难的!
-八股文资料首推我的 [《Java 面试指北》](https://t.zsxq.com/11rZ6D7Wk) (配合 JavaGuide 使用,会根据每一年的面试情况对内容进行更新完善)和 [JavaGuide](https://javaguide.cn/) 。里面不仅仅是原创八股文,还有很多对实际开发有帮助的干货。除了我的资料之外,你还可以去网上找一些其他的优质的文章、视频来看。
+八股文资料首推我的 [《Java 面试指北》](https://javaguide.cn/zhuanlan/java-mian-shi-zhi-bei.html) (配合 JavaGuide 使用,会根据每一年的面试情况对内容进行更新完善)和 [JavaGuide](https://javaguide.cn/) 。里面不仅仅是原创八股文,还有很多对实际开发有帮助的干货。除了我的资料之外,你还可以去网上找一些其他的优质的文章、视频来看。

diff --git a/docs/java/basis/bigdecimal.md b/docs/java/basis/bigdecimal.md
index f7418637f08..7978438f298 100644
--- a/docs/java/basis/bigdecimal.md
+++ b/docs/java/basis/bigdecimal.md
@@ -1,8 +1,13 @@
---
title: BigDecimal 详解
+description: 详解BigDecimal使用方法:解决浮点数精度丢失问题,掌握加减乘除运算、RoundingMode舍入规则、compareTo比较方法,适用金融计算等高精度场景。
category: Java
tag:
- Java基础
+head:
+ - - meta
+ - name: keywords
+ content: BigDecimal,浮点数精度,小数运算,RoundingMode舍入模式,BigDecimal比较,金额计算,精度丢失
---
《阿里巴巴 Java 开发手册》中提到:“为了避免精度丢失,可以使用 `BigDecimal` 来进行浮点数的运算”。
@@ -21,7 +26,7 @@ System.out.println(a == b);// false
**为什么浮点数 `float` 或 `double` 运算的时候会有精度丢失的风险呢?**
-这个和计算机保存浮点数的机制有很大关系。我们知道计算机是二进制的,而且计算机在表示一个数字时,宽度是有限的,无限循环的小数存储在计算机时,只能被截断,所以就会导致小数精度发生损失的情况。这也就是解释了为什么浮点数没有办法用二进制精确表示。
+这个和计算机保存小数的机制有很大关系。我们知道计算机是二进制的,而且计算机在表示一个数字时,宽度是有限的,无限循环的小数存储在计算机时,只能被截断,所以就会导致小数精度发生损失的情况。这也就解释了为什么十进制小数没有办法用二进制精确表示。
就比如说十进制下的 0.2 就没办法精确转换成二进制小数:
@@ -40,9 +45,9 @@ System.out.println(a == b);// false
## BigDecimal 介绍
-`BigDecimal` 可以实现对浮点数的运算,不会造成精度丢失。
+`BigDecimal` 可以实现对小数的运算,不会造成精度丢失。
-通常情况下,大部分需要浮点数精确运算结果的业务场景(比如涉及到钱的场景)都是通过 `BigDecimal` 来做的。
+通常情况下,大部分需要小数精确运算结果的业务场景(比如涉及到钱的场景)都是通过 `BigDecimal` 来做的。
《阿里巴巴 Java 开发手册》中提到:**浮点数之间的等值判断,基本数据类型不能用 == 来比较,包装数据类型不能用 equals 来判断。**
@@ -50,7 +55,7 @@ System.out.println(a == b);// false
具体原因我们在上面已经详细介绍了,这里就不多提了。
-想要解决浮点数运算精度丢失这个问题,可以直接使用 `BigDecimal` 来定义浮点数的值,然后再进行浮点数的运算操作即可。
+想要解决浮点数运算精度丢失这个问题,可以直接使用 `BigDecimal` 来定义小数的值,然后再进行小数的运算操作即可。
```java
BigDecimal a = new BigDecimal("1.0");
@@ -99,20 +104,20 @@ public BigDecimal divide(BigDecimal divisor, int scale, RoundingMode roundingMod
```java
public enum RoundingMode {
- // 2.5 -> 3 , 1.6 -> 2
- // -1.6 -> -2 , -2.5 -> -3
+ // 2.4 -> 3 , 1.6 -> 2
+ // -1.6 -> -2 , -2.4 -> -3
UP(BigDecimal.ROUND_UP),
- // 2.5 -> 2 , 1.6 -> 1
- // -1.6 -> -1 , -2.5 -> -2
+ // 2.4 -> 2 , 1.6 -> 1
+ // -1.6 -> -1 , -2.4 -> -2
DOWN(BigDecimal.ROUND_DOWN),
- // 2.5 -> 3 , 1.6 -> 2
- // -1.6 -> -1 , -2.5 -> -2
+ // 2.4 -> 3 , 1.6 -> 2
+ // -1.6 -> -1 , -2.4 -> -2
CEILING(BigDecimal.ROUND_CEILING),
// 2.5 -> 2 , 1.6 -> 1
// -1.6 -> -2 , -2.5 -> -3
FLOOR(BigDecimal.ROUND_FLOOR),
- // 2.5 -> 3 , 1.6 -> 2
- // -1.6 -> -2 , -2.5 -> -3
+ // 2.4 -> 2 , 1.6 -> 2
+ // -1.6 -> -2 , -2.4 -> -2
HALF_UP(BigDecimal.ROUND_HALF_UP),
//......
}
@@ -230,7 +235,7 @@ public class BigDecimalUtil {
/**
* 提供(相对)精确的除法运算,当发生除不尽的情况时,精确到
- * 小数点以后10位,以后的数字四舍五入。
+ * 小数点以后10位,以后的数字四舍六入五成双。
*
* @param v1 被除数
* @param v2 除数
@@ -242,7 +247,7 @@ public class BigDecimalUtil {
/**
* 提供(相对)精确的除法运算。当发生除不尽的情况时,由scale参数指
- * 定精度,以后的数字四舍五入。
+ * 定精度,以后的数字四舍六入五成双。
*
* @param v1 被除数
* @param v2 除数
@@ -260,11 +265,11 @@ public class BigDecimalUtil {
}
/**
- * 提供精确的小数位四舍五入处理。
+ * 提供精确的小数位四舍六入五成双处理。
*
- * @param v 需要四舍五入的数字
+ * @param v 需要四舍六入五成双的数字
* @param scale 小数点后保留几位
- * @return 四舍五入后的结果
+ * @return 四舍六入五成双后的结果
*/
public static double round(double v, int scale) {
if (scale < 0) {
@@ -283,18 +288,18 @@ public class BigDecimalUtil {
* @return 返回转换结果
*/
public static float convertToFloat(double v) {
- BigDecimal b = new BigDecimal(v);
+ BigDecimal b = BigDecimal.valueOf(v);
return b.floatValue();
}
/**
- * 提供精确的类型转换(Int)不进行四舍五入
+ * 提供精确的类型转换(Int)不进行四舍六入五成双
*
* @param v 需要被转换的数字
* @return 返回转换结果
*/
public static int convertsToInt(double v) {
- BigDecimal b = new BigDecimal(v);
+ BigDecimal b = BigDecimal.valueOf(v);
return b.intValue();
}
@@ -305,7 +310,7 @@ public class BigDecimalUtil {
* @return 返回转换结果
*/
public static long convertsToLong(double v) {
- BigDecimal b = new BigDecimal(v);
+ BigDecimal b = BigDecimal.valueOf(v);
return b.longValue();
}
@@ -317,8 +322,8 @@ public class BigDecimalUtil {
* @return 返回两个数中大的一个值
*/
public static double returnMax(double v1, double v2) {
- BigDecimal b1 = new BigDecimal(v1);
- BigDecimal b2 = new BigDecimal(v2);
+ BigDecimal b1 = BigDecimal.valueOf(v1);
+ BigDecimal b2 = BigDecimal.valueOf(v2);
return b1.max(b2).doubleValue();
}
@@ -330,8 +335,8 @@ public class BigDecimalUtil {
* @return 返回两个数中小的一个值
*/
public static double returnMin(double v1, double v2) {
- BigDecimal b1 = new BigDecimal(v1);
- BigDecimal b2 = new BigDecimal(v2);
+ BigDecimal b1 = BigDecimal.valueOf(v1);
+ BigDecimal b2 = BigDecimal.valueOf(v2);
return b1.min(b2).doubleValue();
}
@@ -351,7 +356,7 @@ public class BigDecimalUtil {
}
```
-相关 issue:[建议对保留规则设置为 RoundingMode.HALF_EVEN,即四舍六入五成双](<[#2129](https://github.com/Snailclimb/JavaGuide/issues/2129)>) 。
+相关 issue:[建议对保留规则设置为 RoundingMode.HALF_EVEN,即四舍六入五成双,#2129](https://github.com/Snailclimb/JavaGuide/issues/2129) 。

diff --git a/docs/java/basis/generics-and-wildcards.md b/docs/java/basis/generics-and-wildcards.md
index f65781e01e6..1740999940c 100644
--- a/docs/java/basis/generics-and-wildcards.md
+++ b/docs/java/basis/generics-and-wildcards.md
@@ -1,20 +1,362 @@
---
title: 泛型&通配符详解
+description: 全面解析Java泛型与通配符:深入理解类型擦除机制、上界下界通配符用法、PECS原则应用,掌握泛型编程核心技巧。
category: Java
tag:
- Java基础
+head:
+ - - meta
+ - name: keywords
+ content: Java泛型,通配符,类型擦除,泛型边界,PECS原则,泛型方法,上界下界通配符,泛型接口
---
-**泛型&通配符** 相关的面试题为我的[知识星球](https://javaguide.cn/about-the-author/zhishixingqiu-two-years.html)(点击链接即可查看详细介绍以及加入方法)专属内容,已经整理到了[《Java 面试指北》](hhttps://javaguide.cn/zhuanlan/java-mian-shi-zhi-bei.html)(点击链接即可查看详细介绍以及获取方法)中。
+## 泛型
-[《Java 面试指北》](hhttps://javaguide.cn/zhuanlan/java-mian-shi-zhi-bei.html) 的部分内容展示如下,你可以将其看作是 [JavaGuide](https://javaguide.cn/#/) 的补充完善,两者可以配合使用。
+### 什么是泛型?有什么作用?
-
+**Java 泛型(Generics)** 是 JDK 5 中引入的一个新特性。使用泛型参数,可以增强代码的可读性以及稳定性。**如无特别说明,以下行为以 Java 8 为准。**
-[《Java 面试指北》](hhttps://javaguide.cn/zhuanlan/java-mian-shi-zhi-bei.html)只是星球内部众多资料中的一个,星球还有很多其他优质资料比如[专属专栏](https://javaguide.cn/zhuanlan/)、Java 编程视频、PDF 资料。
+编译器可以对泛型参数进行检测,并且通过泛型参数可以指定传入的对象类型。比如 `ArrayList persons = new ArrayList()` 这行代码指明了该 `ArrayList` 只能传入 `Person` 类型的对象,如果传入其他类型会报错(JDK 7 起可写 `new ArrayList<>()`,由编译器推断类型参数)。
-
+```java
+ArrayList extends AbstractList
+```
-
+并且,原生 `List` 返回类型是 `Object` ,需要手动转换类型才能使用,使用泛型后编译器自动转换。
-
+### 泛型的使用方式有哪几种?
+
+泛型一般有三种使用方式:**泛型类**、**泛型接口**、**泛型方法**。
+
+**1.泛型类**:
+
+```java
+//此处T可以随便写为任意标识,常见的如T、E、K、V等形式的参数常用于表示泛型
+//在实例化泛型类时,必须指定T的具体类型
+public class Generic{
+
+ private T key;
+
+ public Generic(T key) {
+ this.key = key;
+ }
+
+ public T getKey(){
+ return key;
+ }
+}
+```
+
+如何实例化泛型类:
+
+```java
+Generic genericInteger = new Generic(123456);
+// JDK 7 起可写:new Generic<>(123456)
+```
+
+**2.泛型接口** :
+
+```java
+public interface Generator {
+ public T method();
+}
+```
+
+实现泛型接口,不指定类型:
+
+```java
+class GeneratorImpl implements Generator{
+ @Override
+ public T method() {
+ return null;
+ }
+}
+```
+
+实现泛型接口,指定类型:
+
+```java
+class GeneratorImpl implements Generator {
+ @Override
+ public String method() {
+ return "hello";
+ }
+}
+```
+
+**3.泛型方法** :
+
+```java
+ public static < E > void printArray( E[] inputArray )
+ {
+ for ( E element : inputArray ){
+ System.out.printf( "%s ", element );
+ }
+ System.out.println();
+ }
+```
+
+使用:
+
+```java
+// 创建不同类型数组: Integer, Double 和 Character
+Integer[] intArray = { 1, 2, 3 };
+String[] stringArray = { "Hello", "World" };
+printArray( intArray );
+printArray( stringArray );
+```
+
+### 项目中哪里用到了泛型?
+
+- 自定义接口通用返回结果 `CommonResult` 通过参数 `T` 可根据具体的返回类型动态指定结果的数据类型
+- 定义 `Excel` 处理类 `ExcelUtil` 用于动态指定 `Excel` 导出的数据类型
+- 构建集合工具类(参考 `Collections` 中的 `sort`, `binarySearch` 方法)。
+- ……
+
+### 什么是泛型擦除机制?为什么要擦除?
+
+**Java 的泛型是伪泛型,这是因为 Java 在编译期间,所有的泛型信息都会被擦掉,这也就是通常所说类型擦除 。**
+
+编译器会在编译期间会动态地将泛型 `T` 擦除为 `Object` 或将 `T extends xxx` 擦除为其限定类型 `xxx` 。
+
+因此,泛型本质上其实还是编译器的行为,为了保证引入泛型机制但不创建新的类型,减少虚拟机的运行开销,编译器通过擦除将泛型类转化为一般类。
+
+这里说的可能有点抽象,我举个例子:
+
+```java
+List list = new ArrayList<>();
+
+list.add(12);
+//1.编译期间直接添加会报错
+list.add("a");
+Class extends List> clazz = list.getClass();
+Method add = clazz.getDeclaredMethod("add", Object.class);
+//2.运行期间通过反射添加,是可以的
+add.invoke(list, "kl");
+
+System.out.println(list)
+```
+
+再来举一个例子 : 由于泛型擦除的问题,下面的方法重载会报错。
+
+```java
+public void print(List list) { }
+public void print(List list) { }
+```
+
+
+
+原因也很简单,泛型擦除之后,`List` 与 `List` 在编译以后都变成了 `List` 。
+
+**既然编译器要把泛型擦除,那为什么还要用泛型呢?用 Object 代替不行吗?**
+
+这个问题其实在变相考察泛型的作用:
+
+- 使用泛型可在编译期间进行类型检测。
+
+- 使用 `Object` 类型需要手动添加强制类型转换,降低代码可读性,提高出错概率。
+
+- 泛型可以使用自限定类型如 `T extends Comparable` 。
+
+### 什么是桥方法?
+
+桥方法(`Bridge Method`) 用于继承泛型类时保证多态。
+
+```java
+class Node {
+ public T data;
+ public Node(T data) { this.data = data; }
+ public void setData(T data) {
+ System.out.println("Node.setData");
+ this.data = data;
+ }
+}
+
+class MyNode extends Node {
+ public MyNode(Integer data) { super(data); }
+
+ // Node 泛型擦除后为 setData(Object data),而子类 MyNode 中并没有重写该方法,所以编译器会加入该桥方法保证多态
+ public void setData(Object data) {
+ setData((Integer) data);
+ }
+
+ public void setData(Integer data) {
+ System.out.println("MyNode.setData");
+ super.setData(data);
+ }
+}
+```
+
+⚠️**注意** :桥方法为编译器自动生成,非手写。
+
+### 泛型有哪些限制?为什么?
+
+泛型的限制一般是由泛型擦除机制导致的。擦除为 `Object` 后无法进行类型判断
+
+- 只能声明不能实例化 `T` 类型变量。
+- 泛型参数不能是基本类型。因为基本类型不是 `Object` 子类,应该用基本类型对应的引用类型代替。
+- 不能实例化泛型参数的数组。擦除后为 `Object` 后无法进行类型判断。
+- 不能实例化泛型数组。
+- 泛型无法使用 `instanceof` 对类型参数 T 做运行期判断;`getClass()` 在擦除后也无法区分不同泛型实参(如 `List` 与 `List` 均得到 `List.class`)。
+- 不能实现两个不同泛型参数的同一接口,擦除后多个父类的桥方法将冲突
+- 不能使用 `static` 修饰泛型变量
+- ……
+
+### 以下代码是否能编译,为什么?
+
+```java
+public final class Algorithm {
+ public static T max(T x, T y) {
+ return x > y ? x : y;
+ }
+}
+```
+
+无法编译,因为 x 和 y 都会被擦除为 `Object` 类型, `Object` 无法使用 `>` 进行比较
+
+```java
+public class Singleton {
+
+ public static T getInstance() {
+ if (instance == null)
+ instance = new Singleton();
+
+ return instance;
+ }
+
+ private static T instance = null;
+}
+```
+
+无法编译,因为不能使用 `static` 修饰泛型 `T` 。
+
+## 通配符
+
+### 什么是通配符?有什么作用?
+
+泛型类型是固定的,某些场景下使用起来不太灵活,于是,通配符就来了!通配符可以允许类型参数变化,用来解决泛型无法协变的问题。
+
+举个例子:
+
+```java
+// 限制类型为 Person 的子类
+ extends Person>
+// 限制类型为 Manager 的父类
+ super Manager>
+```
+
+### 通配符 ?和常用的泛型 T 之间有什么区别?
+
+- `T` 可以用于声明变量或常量而 `?` 不行。
+- `T` 一般用于声明泛型类或方法,通配符 `?` 一般用于泛型方法的调用代码和形参。
+- `T` 在编译期会被擦除为限定类型或 `Object`。通配符 `?` 在方法内部会被编译器「捕获」为某个具体但未知的类型(capture),因此不能向 `List>` 写入除 `null` 外的元素,但可配合泛型方法使用。
+
+### 什么是无界通配符?
+
+无界通配符可以接收任何泛型类型数据,用于实现不依赖于具体类型参数的简单方法,可以捕获参数类型并交由泛型方法进行处理。
+
+```java
+void testMethod(Person> p) {
+ // 泛型方法自行处理
+}
+```
+
+**`List>` 和 `List` 有区别吗?** 当然有!
+
+- `List> list` 表示 `list` 的元素类型是**某个未知但固定的类型**(即「存在某一类型 `T`,list 是 `List`」),因此编译器不允许向其中添加除 `null` 外的任何元素,以避免类型不安全。
+- `List list` 表示 `list` 持有的元素类型是 `Object`,因此可以添加任何类型的对象,但编译器会给出警告。
+
+```java
+List> list = new ArrayList<>();
+list.add("sss");//报错
+List list2 = new ArrayList<>();
+list2.add("sss");//警告信息
+```
+
+### 什么是上边界通配符?什么是下边界通配符?
+
+在使用泛型的时候,我们还可以为传入的泛型类型实参进行上下边界的限制,如:**类型实参只准传入某种类型的父类或某种类型的子类**。
+
+**上边界通配符 `extends`** 可以实现泛型的向上转型即传入的类型实参必须是指定类型的子类型。
+
+举个例子:
+
+```java
+// 限制必须是 Person 类的子类
+ extends Person>
+```
+
+类型边界可以设置多个,还可以对 `T` 类型进行限制。
+
+```java
+
+
+```
+
+**下边界通配符 `super`** 与上边界通配符 `extends`刚好相反,它可以实现泛型的向下转型即传入的类型实参必须是指定类型的父类型。
+
+举个例子:
+
+```java
+// 限制必须是 Employee 类的父类
+List super Employee>
+```
+
+**`? extends xxx` 和 `? super xxx` 有什么区别?**
+
+两者接收参数的范围不同。并且,使用 `? extends xxx` 声明的泛型参数只能调用 `get()` 方法返回 `xxx` 类型,调用 `set()` 报错。使用 `? super xxx` 声明的泛型参数只能调用 `set()` 方法接收 xxx 类型,调用 `get()` 报错。
+
+**PECS 原则(Producer Extends, Consumer Super)**:从数据结构**取**元素时用 `extends`(生产者,Producer);向数据结构**写**元素时用 `super`(消费者,Consumer)。例如:`List extends Number>` 只能从中读取 `Number`,不能写入;`List super Integer>` 可以写入 `Integer` 及其子类,读取时得到的是 `Object`。`Collections.copy(List super T> dest, List extends T> src)` 就是典型用法:从 `src` 读、往 `dest` 写。
+
+**`T extends xxx` 和 `? extends xxx` 又有什么区别?**
+
+`T extends xxx` 用于定义泛型类和方法,擦除后为 xxx 类型, `? extends xxx` 用于声明方法形参,接收 xxx 和其子类型。
+
+**`Class>` 和 `Class` 的区别?**
+
+直接使用 Class 的话会有一个类型警告,使用 `Class>` 则没有,因为 Class 是一个泛型类,接收原生类型会产生警告
+
+### 以下代码是否能编译,为什么?
+
+```java
+class Shape { /* ... */ }
+class Circle extends Shape { /* ... */ }
+class Rectangle extends Shape { /* ... */ }
+
+class Node { /* ... */ }
+
+Node nc = new Node<>();
+Node ns = nc;
+```
+
+不能,因为`Node` 不是 `Node` 的子类
+
+```java
+class Shape { /* ... */ }
+class Circle extends Shape { /* ... */ }
+class Rectangle extends Shape { /* ... */ }
+
+class Node { /* ... */ }
+class ChildNode extends Node{
+
+}
+ChildNode nc = new ChildNode<>();
+Node ns = nc;
+```
+
+可以编译,`ChildNode` 是 `Node` 的子类
+
+```java
+public static void print(List extends Number> list) {
+ for (Number n : list)
+ System.out.print(n + " ");
+ System.out.println();
+}
+```
+
+可以编译,`List extends Number>` 可以往外取元素,但是无法调用 `add()` 添加元素。
+
+## 参考
+
+- Java 官方文档 : https://docs.oracle.com/javase/tutorial/java/generics/index.html
+- Java 基础 一文搞懂泛型:https://www.cnblogs.com/XiiX/p/14719568.html
diff --git a/docs/java/basis/java-basic-questions-01.md b/docs/java/basis/java-basic-questions-01.md
index 201a80abdc2..e94ed828592 100644
--- a/docs/java/basis/java-basic-questions-01.md
+++ b/docs/java/basis/java-basic-questions-01.md
@@ -1,15 +1,13 @@
---
title: Java基础常见面试题总结(上)
category: Java
+description: Java基础常见面试题总结:包含Java语言特点、JVM/JDK/JRE区别、字节码详解、基本数据类型、自动装箱拆箱、方法重载与重写等核心知识点,助力Java开发者面试通关。
tag:
- Java基础
head:
- - meta
- name: keywords
- content: JVM,JDK,JRE,字节码详解,Java 基本数据类型,装箱和拆箱
- - - meta
- - name: description
- content: 全网质量最高的Java基础常见知识点和面试题总结,希望对你有帮助!
+ content: Java基础,JVM,JDK,JRE,Java SE,字节码,Java编译,自动装箱,基本数据类型,方法重载,Java面试题
---
@@ -18,10 +16,10 @@ head:
### Java 语言有哪些特点?
-1. 简单易学;
+1. 简单易学(语法简单,上手容易);
2. 面向对象(封装,继承,多态);
-3. 平台无关性( Java 虚拟机实现平台无关性);
-4. 支持多线程( C++ 语言没有内置的多线程机制,因此必须调用操作系统的多线程功能来进行多线程程序设计,而 Java 语言却提供了多线程支持);
+3. 平台无关性(Java 虚拟机实现平台无关性);
+4. 支持多线程(C++ 语言没有内置的多线程机制,因此必须调用操作系统的多线程功能来进行多线程程序设计,而 Java 语言却提供了多线程支持);
5. 可靠性(具备异常处理和自动内存管理机制);
6. 安全性(Java 语言本身的设计就提供了多重安全防护机制如访问权限修饰符、限制程序直接访问操作系统资源);
7. 高效性(通过 Just In Time 编译器等技术的优化,Java 语言的运行效率还是非常不错的);
@@ -29,7 +27,7 @@ head:
9. 编译与解释并存;
10. ……
-> **🐛 修正(参见:[issue#544](https://github.com/Snailclimb/JavaGuide/issues/544))**:C++11 开始(2011 年的时候),C++就引入了多线程库,在 windows、linux、macos 都可以使用`std::thread`和`std::async`来创建线程。参考链接:
+> **🐛 修正(参见:[issue#544](https://github.com/Snailclimb/JavaGuide/issues/544))**:C++11 开始(2011 年的时候),C++ 就引入了多线程库,在 Windows、Linux、macOS 都可以使用`std::thread`和`std::async`来创建线程。参考链接:
🌈 拓展一下:
@@ -37,18 +35,20 @@ head:
### Java SE vs Java EE
-- Java SE(Java Platform,Standard Edition): Java 平台标准版,Java 编程语言的基础,它包含了支持 Java 应用程序开发和运行的核心类库以及虚拟机等核心组件。Java SE 可以用于构建桌面应用程序或简单的服务器应用程序。
-- Java EE(Java Platform, Enterprise Edition ):Java 平台企业版,建立在 Java SE 的基础上,包含了支持企业级应用程序开发和部署的标准和规范(比如 Servlet、JSP、EJB、JDBC、JPA、JTA、JavaMail、JMS)。 Java EE 可以用于构建分布式、可移植、健壮、可伸缩和安全的服务端 Java 应用程序,例如 Web 应用程序。
+- Java SE(Java Platform, Standard Edition): Java 平台标准版,Java 编程语言的基础,它包含了支持 Java 应用程序开发和运行的核心类库以及虚拟机等核心组件。Java SE 可以用于构建桌面应用程序或简单的服务器应用程序。
+- Java EE(Java Platform, Enterprise Edition):Java 平台企业版,建立在 Java SE 的基础上,包含了支持企业级应用程序开发和部署的标准和规范(比如 Servlet、JSP、EJB、JDBC、JPA、JTA、JavaMail、JMS)。 Java EE 可以用于构建分布式、可移植、健壮、可伸缩和安全的服务端 Java 应用程序,例如 Web 应用程序。
简单来说,Java SE 是 Java 的基础版本,Java EE 是 Java 的高级版本。Java SE 更适合开发桌面应用程序或简单的服务器应用程序,Java EE 更适合开发复杂的企业级应用程序或 Web 应用程序。
除了 Java SE 和 Java EE,还有一个 Java ME(Java Platform,Micro Edition)。Java ME 是 Java 的微型版本,主要用于开发嵌入式消费电子设备的应用程序,例如手机、PDA、机顶盒、冰箱、空调等。Java ME 无需重点关注,知道有这个东西就好了,现在已经用不上了。
-### JVM vs JDK vs JRE
+### ⭐️JVM vs JDK vs JRE
#### JVM
-Java 虚拟机(JVM)是运行 Java 字节码的虚拟机。JVM 有针对不同系统的特定实现(Windows,Linux,macOS),目的是使用相同的字节码,它们都会给出相同的结果。字节码和不同系统的 JVM 实现是 Java 语言“一次编译,随处可以运行”的关键所在。
+Java 虚拟机(Java Virtual Machine, JVM)是运行 Java 字节码的虚拟机。JVM 有针对不同系统的特定实现(Windows,Linux,macOS),目的是使用相同的字节码,它们都会给出相同的结果。字节码和不同系统的 JVM 实现是 Java 语言“一次编译,随处可以运行”的关键所在。
+
+如下图所示,不同编程语言(Java、Groovy、Kotlin、JRuby、Clojure ...)通过各自的编译器编译成 `.class` 文件,并最终通过 JVM 在不同平台(Windows、Mac、Linux)上运行。

@@ -60,13 +60,20 @@ Java 虚拟机(JVM)是运行 Java 字节码的虚拟机。JVM 有针对不
#### JDK 和 JRE
-JDK(Java Development Kit),它是功能齐全的 Java SDK,是提供给开发者使用,能够创建和编译 Java 程序的开发套件。它包含了 JRE,同时还包含了编译 java 源码的编译器 javac 以及一些其他工具比如 javadoc(文档注释工具)、jdb(调试器)、jconsole(基于 JMX 的可视化监控⼯具)、javap(反编译工具)等等。
+JDK(Java Development Kit)是一个功能齐全的 Java 开发工具包,供开发者使用,用于创建和编译 Java 程序。它包含了 JRE(Java Runtime Environment),以及编译器 javac 和其他工具,如 javadoc(文档生成器)、jdb(调试器)、jconsole(监控工具)、javap(反编译工具)等。
+
+JRE 是运行已编译 Java 程序所需的环境,主要包含以下两个部分:
+
+1. **JVM** : 也就是我们上面提到的 Java 虚拟机。
+2. **Java 基础类库(Class Library)**:一组标准的类库,提供常用的功能和 API(如 I/O 操作、网络通信、数据结构等)。
-JRE(Java Runtime Environment) 是 Java 运行时环境。它是运行已编译 Java 程序所需的所有内容的集合,主要包括 Java 虚拟机(JVM)、Java 基础类库(Class Library)。
+简单来说,JRE 只包含运行 Java 程序所需的环境和类库,而 JDK 不仅包含 JRE,还包括用于开发和调试 Java 程序的工具。
-也就是说,JRE 是 Java 运行时环境,仅包含 Java 应用程序的运行时环境和必要的类库。而 JDK 则包含了 JRE,同时还包括了 javac、javadoc、jdb、jconsole、javap 等工具,可以用于 Java 应用程序的开发和调试。如果需要进行 Java 编程工作,比如编写和编译 Java 程序、使用 Java API 文档等,就需要安装 JDK。而对于某些需要使用 Java 特性的应用程序,如 JSP 转换为 Java Servlet、使用反射等,也需要 JDK 来编译和运行 Java 代码。因此,即使不打算进行 Java 应用程序的开发工作,也有可能需要安装 JDK。
+如果需要编写、编译 Java 程序或使用 Java API 文档,就需要安装 JDK。某些需要 Java 特性的应用程序(如 JSP 转换为 Servlet 或使用反射)也可能需要 JDK 来编译和运行 Java 代码。因此,即使不进行 Java 开发工作,有时也可能需要安装 JDK。
-
+下图清晰展示了 JDK、JRE 和 JVM 的关系。
+
+
不过,从 JDK 9 开始,就不需要区分 JDK 和 JRE 的关系了,取而代之的是模块系统(JDK 被重新组织成 94 个模块)+ [jlink](http://openjdk.java.net/jeps/282) 工具 (随 Java 9 一起发布的新命令行工具,用于生成自定义 Java 运行时映像,该映像仅包含给定应用程序所需的模块) 。并且,从 JDK 11 开始,Oracle 不再提供单独的 JRE 下载。
@@ -78,7 +85,7 @@ JRE(Java Runtime Environment) 是 Java 运行时环境。它是运行已编
定制的、模块化的 Java 运行时映像有助于简化 Java 应用的部署和节省内存并增强安全性和可维护性。这对于满足现代应用程序架构的需求,如虚拟化、容器化、微服务和云原生开发,是非常重要的。
-### 什么是字节码?采用字节码的好处是什么?
+### ⭐️什么是字节码?采用字节码的好处是什么?
在 Java 中,JVM 可以理解的代码就叫做字节码(即扩展名为 `.class` 的文件),它不面向任何特定的处理器,只面向虚拟机。Java 语言通过字节码的方式,在一定程度上解决了传统解释型语言执行效率低的问题,同时又保留了解释型语言可移植的特点。所以, Java 程序运行时相对来说还是高效的(不过,和 C、 C++,Rust,Go 等语言还是有一定差距的),而且,由于字节码并不针对一种特定的机器,因此,Java 程序无须重新编译便可在多种不同操作系统的计算机上运行。
@@ -88,6 +95,11 @@ JRE(Java Runtime Environment) 是 Java 运行时环境。它是运行已编
我们需要格外注意的是 `.class->机器码` 这一步。在这一步 JVM 类加载器首先加载字节码文件,然后通过解释器逐行解释执行,这种方式的执行速度会相对比较慢。而且,有些方法和代码块是经常需要被调用的(也就是所谓的热点代码),所以后面引进了 **JIT(Just in Time Compilation)** 编译器,而 JIT 属于运行时编译。当 JIT 编译器完成第一次编译后,其会将字节码对应的机器码保存下来,下次可以直接使用。而我们知道,机器码的运行效率肯定是高于 Java 解释器的。这也解释了我们为什么经常会说 **Java 是编译与解释共存的语言** 。
+> 🌈 拓展阅读:
+>
+> - [基本功 | Java 即时编译器原理解析及实践 - 美团技术团队](https://tech.meituan.com/2020/10/22/java-jit-practice-in-meituan.html)
+> - [基于静态编译构建微服务应用 - 阿里巴巴中间件](https://mp.weixin.qq.com/s/4haTyXUmh8m-dBQaEzwDJw)
+

> HotSpot 采用了惰性评估(Lazy Evaluation)的做法,根据二八定律,消耗大部分系统资源的只有那一小部分的代码(热点代码),而这也就是 JIT 所需要编译的部分。JVM 会根据代码每次被执行的情况收集信息并相应地做出一些优化,因此执行的次数越多,它的速度就越快。
@@ -100,7 +112,7 @@ JDK、JRE、JVM、JIT 这四者的关系如下图所示。

-### 为什么说 Java 语言“编译与解释并存”?
+### ⭐️为什么说 Java 语言“编译与解释并存”?
其实这个问题我们讲字节码的时候已经提到过,因为比较重要,所以我们这里再提一下。
@@ -113,7 +125,7 @@ JDK、JRE、JVM、JIT 这四者的关系如下图所示。
根据维基百科介绍:
-> 为了改善编译语言的效率而发展出的[即时编译](https://zh.wikipedia.org/wiki/即時編譯)技术,已经缩小了这两种语言间的差距。这种技术混合了编译语言与解释型语言的优点,它像编译语言一样,先把程序源代码编译成[字节码](https://zh.wikipedia.org/wiki/字节码)。到执行期时,再将字节码直译,之后执行。[Java](https://zh.wikipedia.org/wiki/Java)与[LLVM](https://zh.wikipedia.org/wiki/LLVM)是这种技术的代表产物。
+> 为了改善解释语言的效率而发展出的[即时编译](https://zh.wikipedia.org/wiki/即時編譯)技术,已经缩小了这两种语言间的差距。这种技术混合了编译语言与解释型语言的优点,它像编译语言一样,先把程序源代码编译成[字节码](https://zh.wikipedia.org/wiki/字节码)。到执行期时,再将字节码直译,之后执行。[Java](https://zh.wikipedia.org/wiki/Java)与[LLVM](https://zh.wikipedia.org/wiki/LLVM)是这种技术的代表产物。
>
> 相关阅读:[基本功 | Java 即时编译器原理解析及实践](https://tech.meituan.com/2020/10/22/java-jit-practice-in-meituan.html)
@@ -125,11 +137,21 @@ JDK、JRE、JVM、JIT 这四者的关系如下图所示。
JDK 9 引入了一种新的编译模式 **AOT(Ahead of Time Compilation)** 。和 JIT 不同的是,这种编译模式会在程序被执行前就将其编译成机器码,属于静态编译(C、 C++,Rust,Go 等语言就是静态编译)。AOT 避免了 JIT 预热等各方面的开销,可以提高 Java 程序的启动速度,避免预热时间长。并且,AOT 还能减少内存占用和增强 Java 程序的安全性(AOT 编译后的代码不容易被反编译和修改),特别适合云原生场景。
-**JIT 与 AOT 两者的关键指标对比**:
+**JIT 与 AOT 两者的关键指标对比**:
-
+| 对比维度 | JIT(即时编译) | AOT(提前编译) |
+| ---------------- | ------------------ | ---------------------------- |
+| **编译时机** | 运行时编译 | 运行前编译 |
+| **启动速度** | 较慢(需要预热) | 快(无需预热) |
+| **峰值性能** | 更高(运行时优化) | 较低(缺少运行时信息) |
+| **内存占用** | 较高 | 较低 |
+| **打包体积** | 较小 | 较大(包含机器码) |
+| **动态特性支持** | 完全支持 | 受限(反射、动态代理等) |
+| **适用场景** | 长时间运行的服务 | 云原生、Serverless、CLI 工具 |
-可以看出,AOT 的主要优势在于启动时间、内存占用和打包体积。JIT 的主要优势在于具备更高的极限处理能力,可以降低请求的最大延迟。
+
+
+可以看出,**AOT 的主要优势在于启动时间、内存占用和打包体积**。**JIT 的主要优势在于具备更高的极限处理能力**,可以降低请求的最大延迟。
提到 AOT 就不得不提 [GraalVM](https://www.graalvm.org/) 了!GraalVM 是一种高性能的 JDK(完整的 JDK 发行版本),它可以运行 Java 和其他 JVM 语言,以及 JavaScript、Python 等非 JVM 语言。 GraalVM 不仅能提供 AOT 编译,还能提供 JIT 编译。感兴趣的同学,可以去看看 GraalVM 的官方文档:。如果觉得官方文档看着比较难理解的话,也可以找一些文章来看看,比如:
@@ -201,8 +223,6 @@ JDK 9 引入了一种新的编译模式 **AOT(Ahead of Time Compilation)** 。
Java 中的注释有三种:
-
-
1. **单行注释**:通常用于解释方法内某单行代码的作用。
2. **多行注释**:通常用于解释一段代码的作用。
@@ -213,7 +233,7 @@ Java 中的注释有三种:

-在我们编写代码的时候,如果代码量比较少,我们自己或者团队其他成员还可以很轻易地看懂代码,但是当项目结构一旦复杂起来,我们就需要用到注释了。注释并不会执行(编译器在编译代码之前会把代码中的所有注释抹掉,字节码中不保留注释),是我们程序员写给自己看的,注释是你的代码说明书,能够帮助看代码的人快速地理清代码之间的逻辑关系。因此,在写程序的时候随手加上注释是一个非常好的习惯。
+在我们编写代码的时候,如果代码量比较少,我们自己或者团队其他成员还可以很轻易地看懂代码,但是当项目结构一旦复杂起来,我们就需要用到注释了。注释并不会执行(编译器在编译代码之前会把代码中的所有注释抹掉,字节码中不保留注释),是我们程序员写给自己看的,注释是你的代码说明书,能够帮助看代码的人快速地理清代码之间的逻辑关系。因此,在写程序的时候随手加上注释是一个非常好的习惯。
《Clean Code》这本书明确指出:
@@ -270,13 +290,53 @@ Java 中的注释有三种:
官方文档:[https://docs.oracle.com/javase/tutorial/java/nutsandbolts/\_keywords.html](https://docs.oracle.com/javase/tutorial/java/nutsandbolts/_keywords.html)
-### 自增自减运算符
+### ⭐️自增自减运算符
+
+在写代码的过程中,常见的一种情况是需要某个整数类型变量增加 1 或减少 1。Java 提供了自增运算符 (`++`) 和自减运算符 (`--`) 来简化这种操作。
+
+`++` 和 `--` 运算符可以放在变量之前,也可以放在变量之后:
+
+- **前缀形式**(例如 `++a` 或 `--a`):先自增/自减变量的值,然后再使用该变量,例如,`b = ++a` 先将 `a` 增加 1,然后把增加后的值赋给 `b`。
+- **后缀形式**(例如 `a++` 或 `a--`):先使用变量的当前值,然后再自增/自减变量的值。例如,`b = a++` 先将 `a` 的当前值赋给 `b`,然后再将 `a` 增加 1。
+
+为了方便记忆,可以使用下面的口诀:**符号在前就先加/减,符号在后就后加/减**。
-在写代码的过程中,常见的一种情况是需要某个整数类型变量增加 1 或减少 1,Java 提供了一种特殊的运算符,用于这种表达式,叫做自增运算符(++)和自减运算符(--)。
+```mermaid
+flowchart LR
+ %% 定义全局样式
+ classDef step fill:#4CA497,color:#fff,rx:10,ry:10
+ classDef example fill:#E99151,color:#fff,rx:10,ry:10
-++ 和 -- 运算符可以放在变量之前,也可以放在变量之后,当运算符放在变量之前时(前缀),先自增/减,再赋值;当运算符放在变量之后时(后缀),先赋值,再自增/减。例如,当 `b = ++a` 时,先自增(自己增加 1),再赋值(赋值给 b);当 `b = a++` 时,先赋值(赋值给 b),再自增(自己增加 1)。也就是,++a 输出的是 a+1 的值,a++输出的是 a 值。用一句口诀就是:“符号在前就先加/减,符号在后就后加/减”。
+ subgraph Prefix["前缀形式 ++a / --a"]
+ direction TB
+ style Prefix fill:#F5F7FA,stroke:#E0E6ED,stroke-width:1.5px
+ P1["第一步:变量自增/自减"]:::step --> P2["第二步:使用新值参与运算"]:::step
+ P3["示例:b = ++a
S2["第二步:变量自增/自减"]:::step
+ S3["示例:b = a++
>` 和`>>>`转换成的指令码运行起来会更高效些。
+**使用移位运算符的主要原因**:
+
+1. **高效**:移位运算符直接对应于处理器的移位指令。现代处理器具有专门的硬件指令来执行这些移位操作,这些指令通常在一个时钟周期内完成。相比之下,乘法和除法等算术运算在硬件层面上需要更多的时钟周期来完成。
+2. **节省内存**:通过移位操作,可以使用一个整数(如 `int` 或 `long`)来存储多个布尔值或标志位,从而节省内存。
+
+移位运算符最常用于快速乘以或除以 2 的幂次方。除此之外,它还在以下方面发挥着重要作用:
+
+- **位字段管理**:例如存储和操作多个布尔值。
+- **哈希算法和加密解密**:通过移位和与、或等操作来混淆数据。
+- **数据压缩**:例如霍夫曼编码通过移位运算符可以快速处理和操作二进制数据,以生成紧凑的压缩格式。
+- **数据校验**:例如 CRC(循环冗余校验)通过移位和多项式除法生成和校验数据完整性。
+- **内存对齐**:通过移位操作,可以轻松计算和调整数据的对齐地址。
掌握最基本的移位运算符知识还是很有必要的,这不光可以帮助我们在代码中使用,还可以帮助我们理解源码中涉及到移位运算符的代码。
-Java 中有三种移位运算符:
+```mermaid
+flowchart TB
+ %% 定义全局样式,保持统一风格
+ classDef left fill:#4CA497,color:#fff,rx:10,ry:10
+ classDef right fill:#00838F,color:#fff,rx:10,ry:10
+ classDef uright fill:#E99151,color:#fff,rx:10,ry:10
+
+ subgraph ShiftOps["Java 三种移位运算符"]
+ direction TB
+ style ShiftOps fill:#F0F2F5,stroke:#E0E6ED,stroke-width:1.5px
+
+ subgraph Left["左移 <<"]
+ style Left fill:#F5F7FA,stroke:#E0E6ED,stroke-width:1.5px
+ L1["操作:向左移动 n 位"]:::left
+ L2["规则:高位丢弃,低位补 0"]:::left
+ L3["效果:相当于 × 2^n"]:::left
+ L4["示例:8 << 2 = 32"]:::left
+ end
+
+ subgraph Right["带符号右移 >>"]
+ style Right fill:#F5F7FA,stroke:#E0E6ED,stroke-width:1.5px
+ R1["操作:向右移动 n 位"]:::right
+ R2["规则:低位丢弃,高位补符号位"]:::right
+ R3["效果:相当于 ÷ 2^n"]:::right
+ R4["示例:-8 >> 2 = -2"]:::right
+ end
+
+ subgraph URight["无符号右移 >>>"]
+ style URight fill:#F5F7FA,stroke:#E0E6ED,stroke-width:1.5px
+ U1["操作:向右移动 n 位"]:::uright
+ U2["规则:低位丢弃,高位补 0"]:::uright
+ U3["效果:逻辑右移"]:::uright
+ U4["示例:-8 >>> 2 = 1073741822"]:::uright
+ end
+ end
+
+ linkStyle default stroke-width:1.5px,opacity:0.8
+```
-
+Java 中有三种移位运算符:
-- `<<` :左移运算符,向左移若干位,高位丢弃,低位补零。`x << 1`,相当于 x 乘以 2(不溢出的情况下)。
-- `>>` :带符号右移,向右移若干位,高位补符号位,低位丢弃。正数高位补 0,负数高位补 1。`x >> 1`,相当于 x 除以 2。
+- `<<` :左移运算符,向左移若干位,高位丢弃,低位补零。`x << n`,相当于 x 乘以 2 的 n 次方(不溢出的情况下)。
+- `>>` :带符号右移,向右移若干位,高位补符号位,低位丢弃。正数高位补 0,负数高位补 1。`x >> n`,相当于 x 除以 2 的 n 次方。
- `>>>` :无符号右移,忽略符号位,空位都以 0 补齐。
+虽然移位运算本质上可以分为左移和右移,但在实际应用中,右移操作需要考虑符号位的处理方式。
+
由于 `double`,`float` 在二进制中的表现比较特殊,因此不能来进行移位操作。
移位操作符实际上支持的类型只有`int`和`long`,编译器在对`short`、`byte`、`char`类型进行移位前,都会将其转换为`int`类型再操作。
@@ -360,34 +470,72 @@ System.out.println("左移 10 位后的数据对应的二进制字符 " + Intege
1. `return;`:直接使用 return 结束方法执行,用于没有返回值函数的方法
2. `return value;`:return 一个特定值,用于有返回值函数的方法
+```mermaid
+flowchart TB
+ subgraph Method["方法体"]
+ direction TB
+ style Method fill:#F5F7FA,stroke:#E0E6ED,stroke-width:1.5px
+ Start["方法开始"] --> Loop
+
+ subgraph Loop["循环体 for/while"]
+ direction TB
+ style Loop fill:#F0F2F5,stroke:#E0E6ED,stroke-width:1.5px
+ L1["循环条件判断"] -->|"满足"| L2["执行循环体"]
+ L2 --> L3{{"遇到关键字?"}}
+ L3 -->|"continue"| Continue["跳过本次
继续下一次循环"]
+ L3 -->|"break"| Break["跳出整个循环"]
+ L3 -->|"无"| L1
+ Continue --> L1
+ end
+
+ Break --> AfterLoop["循环后的代码"]
+ L1 -->|"不满足"| AfterLoop
+ AfterLoop --> L4{{"遇到 return?"}}
+ L4 -->|"是"| Return["结束整个方法"]
+ L4 -->|"否"| End["方法正常结束"]
+ end
+
+ classDef start fill:#E99151,color:#fff,rx:10,ry:10
+ classDef loop fill:#4CA497,color:#fff,rx:10,ry:10
+ classDef decision fill:#00838F,color:#fff,rx:10,ry:10
+ classDef alert fill:#C44545,color:#fff,rx:10,ry:10
+
+ class Start,End start
+ class L1,L2,AfterLoop loop
+ class L3,L4 decision
+ class Continue,Break,Return alert
+
+ linkStyle default stroke-width:1.5px,opacity:0.8
+```
+
思考一下:下列语句的运行结果是什么?
```java
- public static void main(String[] args) {
- boolean flag = false;
- for (int i = 0; i <= 3; i++) {
- if (i == 0) {
- System.out.println("0");
- } else if (i == 1) {
- System.out.println("1");
- continue;
- } else if (i == 2) {
- System.out.println("2");
- flag = true;
- } else if (i == 3) {
- System.out.println("3");
- break;
- } else if (i == 4) {
- System.out.println("4");
- }
- System.out.println("xixi");
- }
- if (flag) {
- System.out.println("haha");
- return;
+public static void main(String[] args) {
+ boolean flag = false;
+ for (int i = 0; i <= 3; i++) {
+ if (i == 0) {
+ System.out.println("0");
+ } else if (i == 1) {
+ System.out.println("1");
+ continue;
+ } else if (i == 2) {
+ System.out.println("2");
+ flag = true;
+ } else if (i == 3) {
+ System.out.println("3");
+ break;
+ } else if (i == 4) {
+ System.out.println("4");
}
- System.out.println("heihei");
+ System.out.println("xixi");
}
+ if (flag) {
+ System.out.println("haha");
+ return;
+ }
+ System.out.println("heihei");
+}
```
运行结果:
@@ -402,7 +550,7 @@ xixi
haha
```
-## 基本数据类型
+## ⭐️基本数据类型
### Java 中的几种基本数据类型了解么?
@@ -414,6 +562,37 @@ Java 中有 8 种基本数据类型,分别为:
- 1 种字符类型:`char`
- 1 种布尔型:`boolean`。
+```mermaid
+flowchart TB
+ Root["Java 8种基本数据类型"] --> Numeric["数字类型(6种)"]
+ Root --> Char["字符类型"]
+ Root --> Bool["布尔类型"]
+
+ Numeric --> IntType["整数型(4种)"]
+ Numeric --> FloatType["浮点型(2种)"]
+
+ IntType --> byte["byte
8位"]
+ IntType --> short["short
16位"]
+ IntType --> int["int
32位"]
+ IntType --> long["long
64位"]
+
+ FloatType --> float["float
32位"]
+ FloatType --> double["double
64位"]
+
+ Char --> char["char
16位"]
+ Bool --> boolean["boolean
1位"]
+
+ classDef root fill:#E99151,color:#fff,rx:10,ry:10
+ classDef category fill:#00838F,color:#fff,rx:10,ry:10
+ classDef type fill:#4CA497,color:#fff,rx:10,ry:10
+
+ class Root root
+ class Numeric,Char,Bool,IntType,FloatType category
+ class byte,short,int,long,float,double,char,boolean type
+
+ linkStyle default stroke-width:1.5px,opacity:0.8
+```
+
这 8 种基本数据类型的默认值以及所占空间的大小如下:
| 基本类型 | 位数 | 字节 | 默认值 | 取值范围 |
@@ -436,14 +615,13 @@ Java 中有 8 种基本数据类型,分别为:
**注意:**
1. Java 里使用 `long` 类型的数据一定要在数值后面加上 **L**,否则将作为整型解析。
-2. `char a = 'h'`char :单引号,`String a = "hello"` :双引号。
+2. Java 里使用 `float` 类型的数据一定要在数值后面加上 **f 或 F**,否则将无法通过编译。
+3. `char a = 'h'`char :单引号,`String a = "hello"` :双引号。
这八种基本类型都有对应的包装类分别为:`Byte`、`Short`、`Integer`、`Long`、`Float`、`Double`、`Character`、`Boolean` 。
### 基本类型和包装类型的区别?
-
-
- **用途**:除了定义一些常量和局部变量之外,我们在其他地方比如方法参数、对象属性中很少会使用基本类型来定义变量。并且,包装类型可用于泛型,而基本类型不可以。
- **存储方式**:基本数据类型的局部变量存放在 Java 虚拟机栈中的局部变量表中,基本数据类型的成员变量(未被 `static` 修饰 )存放在 Java 虚拟机的堆中。包装类型属于对象类型,我们知道几乎所有对象实例都存在于堆中。
- **占用空间**:相比于包装类型(对象类型), 基本数据类型占用的空间往往非常小。
@@ -452,14 +630,14 @@ Java 中有 8 种基本数据类型,分别为:
**为什么说是几乎所有对象实例都存在于堆中呢?** 这是因为 HotSpot 虚拟机引入了 JIT 优化之后,会对对象进行逃逸分析,如果发现某一个对象并没有逃逸到方法外部,那么就可能通过标量替换来实现栈上分配,而避免堆上分配内存
-⚠️ 注意:**基本数据类型存放在栈中是一个常见的误区!** 基本数据类型的存储位置取决于它们的作用域和声明方式。如果它们是局部变量,那么它们会存放在栈中;如果它们是成员变量,那么它们会存放在堆中。
+⚠️ 注意:**基本数据类型存放在栈中是一个常见的误区!** 基本数据类型的存储位置取决于它们的作用域和声明方式。如果它们是局部变量,那么它们会存放在栈中;如果它们是成员变量,那么它们会存放在堆/方法区/元空间中。
```java
public class Test {
// 成员变量,存放在堆中
int a = 10;
- // 被 static 修饰,也存放在堆中,但属于类,不属于对象
- // JDK1.7 静态变量从永久代移动了 Java 堆中
+ // 被 static 修饰的成员变量,JDK 1.7 及之前位于方法区,1.8 后存放于元空间,均不存放于堆中。
+ // 变量属于类,不属于对象。
static int b = 20;
public void method() {
@@ -474,7 +652,11 @@ public class Test {
Java 基本数据类型的包装类型的大部分都用到了缓存机制来提升性能。
-`Byte`,`Short`,`Integer`,`Long` 这 4 种包装类默认创建了数值 **[-128,127]** 的相应类型的缓存数据,`Character` 创建了数值在 **[0,127]** 范围的缓存数据,`Boolean` 直接返回 `True` or `False`。
+`Byte`,`Short`,`Integer`,`Long` 这 4 种包装类默认创建了数值 **[-128,127]** 的相应类型的缓存数据,`Character` 创建了数值在 **[0,127]** 范围的缓存数据,`Boolean` 直接返回 `TRUE` or `FALSE`。
+
+对于 `Integer`,可以通过 JVM 参数 `-XX:AutoBoxCacheMax=` 修改缓存上限,但不能修改下限 -128。实际使用时,并不建议设置过大的值,避免浪费内存,甚至是 OOM。
+
+对于`Byte`,`Short`,`Long` ,`Character` 没有类似 `-XX:AutoBoxCacheMax` 参数可以修改,因此缓存范围是固定的,无法通过 JVM 参数调整。`Boolean` 则直接返回预定义的 `TRUE` 和 `FALSE` 实例,没有缓存范围的概念。
**Integer 缓存源码:**
@@ -561,8 +743,38 @@ System.out.println(i1==i2);
**什么是自动拆装箱?**
-- **装箱**:将基本类型用它们对应的引用类型包装起来;
-- **拆箱**:将包装类型转换为基本数据类型;
+- **装箱(Boxing)**:将基本类型用它们对应的引用类型包装起来;
+- **拆箱(Unboxing)**:将包装类型转换为基本数据类型;
+
+```mermaid
+flowchart LR
+ subgraph Row["装箱与拆箱对比"]
+ direction LR
+ style Row fill:#F0F2F5,stroke:#E0E6ED,stroke-width:1.5px
+
+ subgraph Unboxing["拆箱过程"]
+ direction LR
+ style Unboxing fill:#F5F7FA,stroke:#E0E6ED,stroke-width:1.5px
+ D["Integer obj"] -->|"自动拆箱"| E["obj.intValue()"]
+ E --> F["int 基本类型"]
+ end
+
+ subgraph Boxing["装箱过程"]
+ direction LR
+ style Boxing fill:#F5F7FA,stroke:#E0E6ED,stroke-width:1.5px
+ A["int i = 10"] -->|"自动装箱"| B["Integer.valueOf(10)"]
+ B --> C["Integer 对象"]
+ end
+ end
+
+ classDef core fill:#4CA497,color:#fff,rx:10,ry:10
+ classDef highlight fill:#E99151,color:#fff,rx:10,ry:10
+
+ class A,D core
+ class C,F highlight
+
+ linkStyle default stroke-width:1.5px,opacity:0.8
+```
举例:
@@ -629,14 +841,14 @@ private static long sum() {
```java
float a = 2.0f - 1.9f;
float b = 1.8f - 1.7f;
-System.out.println(a);// 0.100000024
+System.out.printf("%.9f",a);// 0.100000024
System.out.println(b);// 0.099999905
System.out.println(a == b);// false
```
-为什么会出现这个问题呢?
+**为什么会出现这个问题呢?**
-这个和计算机保存浮点数的机制有很大关系。我们知道计算机是二进制的,而且计算机在表示一个数字时,宽度是有限的,无限循环的小数存储在计算机时,只能被截断,所以就会导致小数精度发生损失的情况。这也就是解释了为什么浮点数没有办法用二进制精确表示。
+这个和计算机保存浮点数的机制有很大关系。我们知道计算机是二进制的,而且计算机在表示一个数字时,宽度是有限的,无限循环的小数存储在计算机时,只能被截断,所以就会导致小数精度发生损失的情况。这也就解释了为什么浮点数没有办法用二进制精确表示。
就比如说十进制下的 0.2 就没办法精确转换成二进制小数:
@@ -659,15 +871,18 @@ System.out.println(a == b);// false
```java
BigDecimal a = new BigDecimal("1.0");
-BigDecimal b = new BigDecimal("0.9");
+BigDecimal b = new BigDecimal("1.00");
BigDecimal c = new BigDecimal("0.8");
-BigDecimal x = a.subtract(b);
+BigDecimal x = a.subtract(c);
BigDecimal y = b.subtract(c);
-System.out.println(x); /* 0.1 */
-System.out.println(y); /* 0.1 */
-System.out.println(Objects.equals(x, y)); /* true */
+System.out.println(x); /* 0.2 */
+System.out.println(y); /* 0.20 */
+// 比较内容,不是比较值
+System.out.println(Objects.equals(x, y)); /* false */
+// 比较值相等用相等compareTo,相等返回0
+System.out.println(0 == x.compareTo(y)); /* true */
```
关于 `BigDecimal` 的详细介绍,可以看看我写的这篇文章:[BigDecimal 详解](https://javaguide.cn/java/basis/bigdecimal.html)。
@@ -690,9 +905,9 @@ System.out.println(l + 1 == Long.MIN_VALUE); // true
## 变量
-### 成员变量与局部变量的区别?
+### ⭐️成员变量与局部变量的区别?
-
+
- **语法形式**:从语法形式上看,成员变量是属于类的,而局部变量是在代码块或方法中定义的变量或是方法的参数;成员变量可以被 `public`,`private`,`static` 等修饰符所修饰,而局部变量不能被访问控制修饰符及 `static` 所修饰;但是,成员变量和局部变量都能被 `final` 所修饰。
- **存储方式**:从变量在内存中的存储方式来看,如果成员变量是使用 `static` 修饰的,那么这个成员变量是属于类的,如果没有使用 `static` 修饰,这个成员变量是属于实例的。而对象存在于堆内存,局部变量则存在于栈内存。
@@ -701,11 +916,16 @@ System.out.println(l + 1 == Long.MIN_VALUE); // true
**为什么成员变量有默认值?**
-1. 先不考虑变量类型,如果没有默认值会怎样?变量存储的是内存地址对应的任意随机值,程序读取该值运行会出现意外。
+核心原因是为了保证对象状态的安全和可预测性。
+
+成员变量和局部变量在这个规则上不同,主要是因为它们的**生命周期**不一样,导致了编译器对它们的“控制力”也不同。
-2. 默认值有两种设置方式:手动和自动,根据第一点,没有手动赋值一定要自动赋值。成员变量在运行时可借助反射等方法手动赋值,而局部变量不行。
+- **局部变量**只活在一个方法里,编译器能清楚地看到它是否在使用前被赋值,所以编译器会强制你必须手动赋值,否则就报错。
+- **成员变量**是跟着对象走的,它的值可能在构造函数里赋,也可能在后面的某个 `setter` 方法里赋。编译器在编译时**无法预测**它到底什么时候会被赋值。
-3. 对于编译器(javac)来说,局部变量没赋值很好判断,可以直接报错。而成员变量可能是运行时赋值,无法判断,误报“没默认值”又会影响用户体验,所以采用自动赋默认值。
+并且,如果一个变量没有被初始化,它的内存里存放的就是“垃圾值”——之前那块内存遗留下的任意数据。如果程序读取并使用了这个垃圾值,就会产生完全不可预测的结果,比如一个数字变成了随机数,一个对象引用变成了非法地址,这会直接导致程序崩溃或出现诡异的 bug。
+
+为了避免你拿到一个含有“垃圾值”的危险对象,Java干脆为所有成员变量提供了一个安全的默认值(如 null 或 0),作为一种**安全兜底机制**。
成员变量与局部变量代码示例:
@@ -747,6 +967,8 @@ public class VariableExample {
静态变量也就是被 `static` 关键字修饰的变量。它可以被类的所有实例共享,无论一个类创建了多少个对象,它们都共享同一份静态变量。也就是说,静态变量只会被分配一次内存,即使创建多个对象,这样可以节省内存。
+
+
静态变量是通过类名来访问的,例如`StaticVariableExample.staticVar`(如果被 `private`关键字修饰就无法这样访问了)。
```java
@@ -768,7 +990,7 @@ public class ConstantVariableExample {
### 字符型常量和字符串常量的区别?
- **形式** : 字符常量是单引号引起的一个字符,字符串常量是双引号引起的 0 个或若干个字符。
-- **含义** : 字符常量相当于一个整型值( ASCII 值),可以参加表达式运算; 字符串常量代表一个地址值(该字符串在内存中存放位置)。
+- **含义** : 字符常量相当于一个整型值(ASCII 值),可以参加表达式运算; 字符串常量代表一个地址值(该字符串在内存中存放位置)。
- **占内存大小**:字符常量只占 2 个字节; 字符串常量占若干个字节。
⚠️ 注意 `char` 在 Java 中占两个字节。
@@ -813,7 +1035,7 @@ public void f1() {
// 下面这个方法也没有返回值,虽然用到了 return
public void f(int a) {
if (...) {
- // 表示结束方法的执行,下方的输出语句不会执行
+ // 表示结束方法的执行,下方的输出语句不会执行
return;
}
System.out.println(a);
@@ -870,7 +1092,7 @@ public class Example {
}
```
-### 静态方法和实例方法有何不同?
+### ⭐️静态方法和实例方法有何不同?
**1、调用方式**
@@ -903,7 +1125,7 @@ public class Person {
静态方法在访问本类的成员时,只允许访问静态成员(即静态成员变量和静态方法),不允许访问实例成员(即实例成员变量和实例方法),而实例方法不存在这个限制。
-### 重载和重写有什么区别?
+### ⭐️重载和重写有什么区别?
> 重载就是同样的一个方法能够根据输入数据的不同,做出不同的处理
>
@@ -940,14 +1162,13 @@ public class Person {
综上:**重写就是子类对父类方法的重新改造,外部样子不能改变,内部逻辑可以改变。**
-| 区别点 | 重载方法 | 重写方法 |
-| :--------- | :------- | :--------------------------------------------------------------- |
-| 发生范围 | 同一个类 | 子类 |
-| 参数列表 | 必须修改 | 一定不能修改 |
-| 返回类型 | 可修改 | 子类方法返回值类型应比父类方法返回值类型更小或相等 |
-| 异常 | 可修改 | 子类方法声明抛出的异常类应比父类方法声明抛出的异常类更小或相等; |
-| 访问修饰符 | 可修改 | 一定不能做更严格的限制(可以降低限制) |
-| 发生阶段 | 编译期 | 运行期 |
+| 区别点 | 重载 (Overloading) | 重写 (Overriding) |
+| -------------- | ------------------------------------------------------------------------------------ | -------------------------------------------------------------------------------------------- |
+| **发生范围** | 同一个类中。 | 父类与子类之间(存在继承关系)。 |
+| **方法签名** | 方法名**必须相同**,但**参数列表必须不同**(参数的类型、个数或顺序至少有一项不同)。 | 方法名、参数列表**必须完全相同**。 |
+| **返回类型** | 与返回值类型**无关**,可以任意修改。 | 子类方法的返回类型必须与父类方法的返回类型**相同**,或者是其**子类**。 |
+| **访问修饰符** | 与访问修饰符**无关**,可以任意修改。 | 子类方法的访问权限**不能低于**父类方法的访问权限。(public > protected > default > private) |
+| **绑定时期** | 编译时绑定或称静态绑定 | 运行时绑定 (Run-time Binding) 或称动态绑定 |
**方法的重写要遵循“两同两小一大”**(以下内容摘录自《疯狂 Java 讲义》,[issue#892](https://github.com/Snailclimb/JavaGuide/issues/892) ):
@@ -974,6 +1195,7 @@ public class SuperMan extends Hero{
}
public class SuperSuperMan extends SuperMan {
+ @Override
public String name() {
return "超级超级英雄";
}
@@ -987,7 +1209,7 @@ public class SuperSuperMan extends SuperMan {
### 什么是可变长参数?
-从 Java5 开始,Java 支持定义可变长参数,所谓可变长参数就是允许在调用方法时传入不定长度的参数。就比如下面的这个 `printVariable` 方法就可以接受 0 个或者多个参数。
+从 Java5 开始,Java 支持定义可变长参数,所谓可变长参数就是允许在调用方法时传入不定长度的参数。就比如下面这个方法就可以接受 0 个或者多个参数。
```java
public static void method1(String... args) {
diff --git a/docs/java/basis/java-basic-questions-02.md b/docs/java/basis/java-basic-questions-02.md
index c6085b5d589..2aa14b0946a 100644
--- a/docs/java/basis/java-basic-questions-02.md
+++ b/docs/java/basis/java-basic-questions-02.md
@@ -1,31 +1,41 @@
---
title: Java基础常见面试题总结(中)
+description: Java面向对象编程核心知识点总结:涵盖封装继承多态三大特性、接口与抽象类区别、Object类方法详解、深拷贝浅拷贝、String/StringBuffer/StringBuilder对比等,帮助快速掌握Java OOP精髓。
category: Java
tag:
- Java基础
head:
- - meta
- name: keywords
- content: 面向对象,构造方法,接口,抽象类,String,Object
- - - meta
- - name: description
- content: 全网质量最高的Java基础常见知识点和面试题总结,希望对你有帮助!
+ content: 面向对象,封装继承多态,接口,抽象类,深拷贝浅拷贝,Object类,equals,hashCode,String,字符串常量池,Java面试题
---
## 面向对象基础
-### 面向对象和面向过程的区别
+### ⭐️面向对象和面向过程的区别
+
+面向过程编程(Procedural-Oriented Programming,POP)和面向对象编程(Object-Oriented Programming,OOP)是两种常见的编程范式,两者的主要区别在于解决问题的方式不同:
+
+- **面向过程编程(POP)**:面向过程把解决问题的过程拆成一个个方法,通过一个个方法的执行解决问题。
+- **面向对象编程(OOP)**:面向对象会先抽象出对象,然后用对象执行方法的方式解决问题。
+
+相比较于 POP,OOP 开发的程序一般具有下面这些优点:
+
+- **易维护**:由于良好的结构和封装性,OOP 程序通常更容易维护。
+- **易复用**:通过继承和多态,OOP 设计使得代码更具复用性,方便扩展功能。
+- **易扩展**:模块化设计使得系统扩展变得更加容易和灵活。
+
+POP 的编程方式通常更为简单和直接,适合处理一些较简单的任务。
-两者的主要区别在于解决问题的方式不同:
+POP 和 OOP 的性能差异主要取决于它们的运行机制,而不仅仅是编程范式本身。因此,简单地比较两者的性能是一个常见的误区(相关 issue : [面向过程:面向过程性能比面向对象高??](https://github.com/Snailclimb/JavaGuide/issues/431) )。
-- 面向过程把解决问题的过程拆成一个个方法,通过一个个方法的执行解决问题。
-- 面向对象会先抽象出对象,然后用对象执行方法的方式解决问题。
+
-另外,面向对象开发的程序一般更易维护、易复用、易扩展。
+在选择编程范式时,性能并不是唯一的考虑因素。代码的可维护性、可扩展性和开发效率同样重要。
-相关 issue : [面向过程:面向过程性能比面向对象高??](https://github.com/Snailclimb/JavaGuide/issues/431) 。
+现代编程语言基本都支持多种编程范式,既可以用来进行面向过程编程,也可以进行面向对象编程。
下面是一个求圆的面积和周长的示例,简单分别展示了面向对象和面向过程两种不同的解决方案。
@@ -85,14 +95,14 @@ public class Main {
我们直接定义了圆的半径,并使用该半径直接计算出圆的面积和周长。
-### 创建一个对象用什么运算符?对象实体与对象引用有何不同?
+### 创建一个对象用什么运算符?对象实例与对象引用有何不同?
new 运算符,new 创建对象实例(对象实例在堆内存中),对象引用指向对象实例(对象引用存放在栈内存中)。
- 一个对象引用可以指向 0 个或 1 个对象(一根绳子可以不系气球,也可以系一个气球);
- 一个对象可以有 n 个引用指向它(可以用 n 条绳子系住一个气球)。
-### 对象的相等和引用相等的区别
+### ⭐️对象的相等和引用相等的区别
- 对象的相等一般比较的是内存中存放的内容是否相等。
- 引用相等一般比较的是他们指向的内存地址是否相等。
@@ -136,15 +146,15 @@ true
### 构造方法有哪些特点?是否可被 override?
-构造方法特点如下:
+构造方法具有以下特点:
-- 名字与类名相同。
-- 没有返回值,但不能用 void 声明构造函数。
-- 生成类的对象时自动执行,无需调用。
+- **名称与类名相同**:构造方法的名称必须与类名完全一致。
+- **没有返回值**:构造方法没有返回类型,且不能使用 `void` 声明。
+- **自动执行**:在生成类的对象时,构造方法会自动执行,无需显式调用。
-构造方法不能被 override(重写),但是可以 overload(重载),所以你可以看到一个类中有多个构造函数的情况。
+构造方法**不能被重写(override)**,但**可以被重载(overload)**。因此,一个类中可以有多个构造方法,这些构造方法可以具有不同的参数列表,以提供不同的对象初始化方式。
-### 面向对象三大特征
+### ⭐️面向对象三大特征
#### 封装
@@ -196,24 +206,119 @@ public class Student {
- 对象类型和引用类型之间具有继承(类)/实现(接口)的关系;
- 引用类型变量发出的方法调用的到底是哪个类中的方法,必须在程序运行期间才能确定;
- 多态不能调用“只在子类存在但在父类不存在”的方法;
-- 如果子类重写了父类的方法,真正执行的是子类覆盖的方法,如果子类没有覆盖父类的方法,执行的是父类的方法。
+- 如果子类重写了父类的方法,真正执行的是子类重写的方法,如果子类没有重写父类的方法,执行的是父类的方法。
+
+```mermaid
+flowchart LR
+ subgraph OOP["面向对象三大特征"]
+ style OOP fill:#F0F2F5,stroke:#E0E6ED,stroke-width:1.5px
+
+ subgraph Encapsulation["封装 Encapsulation"]
+ style Encapsulation fill:#F5F7FA,stroke:#E0E6ED,stroke-width:1.5px
+ E1["隐藏内部状态"]:::core
+ E2["提供公共方法"]:::core
+ E3["保护数据安全"]:::core
+ end
+
+ subgraph Inheritance["继承 Inheritance"]
+ style Inheritance fill:#F5F7FA,stroke:#E0E6ED,stroke-width:1.5px
+ I1["代码复用"]:::core
+ I2["扩展功能"]:::core
+ I3["单继承限制"]:::highlight
+ end
+
+ subgraph Polymorphism["多态 Polymorphism"]
+ style Polymorphism fill:#F5F7FA,stroke:#E0E6ED,stroke-width:1.5px
+ P1["父类引用指向子类"]:::core
+ P2["运行时动态绑定"]:::core
+ P3["方法重写实现"]:::core
+ end
+ end
+
+ classDef core fill:#4CA497,color:#fff,rx:10,ry:10
+ classDef highlight fill:#E99151,color:#fff,rx:10,ry:10
+
+ linkStyle default stroke-width:1.5px,opacity:0.8
+```
+
+### ⭐️接口和抽象类有什么共同点和区别?
+
+#### 接口和抽象类的共同点
+
+- **实例化**:接口和抽象类都不能直接实例化,只能被实现(接口)或继承(抽象类)后才能创建具体的对象。
+- **抽象方法**:接口和抽象类都可以包含抽象方法。抽象方法没有方法体,必须在子类或实现类中实现。
+
+#### 接口和抽象类的区别
+
+- **设计目的**:接口主要用于对类的行为进行约束,你实现了某个接口就具有了对应的行为。抽象类主要用于代码复用,强调的是所属关系。
+- **继承和实现**:一个类只能继承一个类(包括抽象类),因为 Java 不支持多继承。但一个类可以实现多个接口,一个接口也可以继承多个其他接口。
+- **成员变量**:接口中的成员变量只能是 `public static final` 类型的,不能被修改且必须有初始值。抽象类的成员变量可以有任何修饰符(`private`, `protected`, `public`),可以在子类中被重新定义或赋值。
+- **方法**:
+ - Java 8 之前,接口中的方法默认是 `public abstract` ,也就是只能有方法声明。自 Java 8 起,可以在接口中定义 `default`(默认) 方法和 `static` (静态)方法。 自 Java 9 起,接口可以包含 `private` 方法。
+ - 抽象类可以包含抽象方法和非抽象方法。抽象方法没有方法体,必须在子类中实现。非抽象方法有具体实现,可以直接在抽象类中使用或在子类中重写。
-### 接口和抽象类有什么共同点和区别?
+在 Java 8 及以上版本中,接口引入了新的方法类型:`default` 方法、`static` 方法和 `private` 方法。这些方法让接口的使用更加灵活。
-**共同点**:
+Java 8 引入的`default` 方法用于提供接口方法的默认实现,可以在实现类中被覆盖。这样就可以在不修改实现类的情况下向现有接口添加新功能,从而增强接口的扩展性和向后兼容性。
-- 都不能被实例化。
-- 都可以包含抽象方法。
-- 都可以有默认实现的方法(Java 8 可以用 `default` 关键字在接口中定义默认方法)。
+```java
+public interface MyInterface {
+ default void defaultMethod() {
+ System.out.println("This is a default method.");
+ }
+}
+```
+
+Java 8 引入的`static` 方法无法在实现类中被覆盖,只能通过接口名直接调用( `MyInterface.staticMethod()`),类似于类中的静态方法。`static` 方法通常用于定义一些通用的、与接口相关的工具方法,一般很少用。
+
+```java
+public interface MyInterface {
+ static void staticMethod() {
+ System.out.println("This is a static method in the interface.");
+ }
+}
+```
-**区别**:
+Java 9 允许在接口中使用 `private` 方法。`private`方法可以用于在接口内部共享代码,不对外暴露。
-- 接口主要用于对类的行为进行约束,你实现了某个接口就具有了对应的行为。抽象类主要用于代码复用,强调的是所属关系。
-- 一个类只能继承一个类,但是可以实现多个接口。
-- 接口中的成员变量只能是 `public static final` 类型的,不能被修改且必须有初始值,而抽象类的成员变量默认 default,可在子类中被重新定义,也可被重新赋值。
+```java
+public interface MyInterface {
+ // default 方法
+ default void defaultMethod() {
+ commonMethod();
+ }
+
+ // static 方法
+ static void staticMethod() {
+ commonMethod();
+ }
+
+ // 私有静态方法,可以被 static 和 default 方法调用
+ private static void commonMethod() {
+ System.out.println("This is a private method used internally.");
+ }
+
+ // 实例私有方法,只能被 default 方法调用。
+ private void instanceCommonMethod() {
+ System.out.println("This is a private instance method used internally.");
+ }
+}
+```
### 深拷贝和浅拷贝区别了解吗?什么是引用拷贝?
+```mermaid
+flowchart LR
+ Copy["对象拷贝"] --> RefCopy["引用拷贝
两个引用指向同一对象"]
+ Copy --> ShallowCopy["浅拷贝
复制基本类型,共享引用类型"]
+ Copy --> DeepCopy["深拷贝
递归复制所有属性"]
+
+ classDef main fill:#005D7B,color:#fff,rx:10,ry:10
+ class Copy main
+
+ linkStyle default stroke-width:1.5px,opacity:0.8
+```
+
关于深拷贝和浅拷贝区别,我这里先给结论:
- **浅拷贝**:浅拷贝会在堆上创建一个新的对象(区别于引用拷贝的一点),不过,如果原对象内部的属性是引用类型的话,浅拷贝会直接复制内部对象的引用地址,也就是说拷贝对象和原对象共用同一个内部对象。
@@ -297,15 +402,15 @@ System.out.println(person1.getAddress() == person1Copy.getAddress());
**那什么是引用拷贝呢?** 简单来说,引用拷贝就是两个不同的引用指向同一个对象。
-我专门画了一张图来描述浅拷贝、深拷贝、引用拷贝:
+我专门画了一张图来描述浅拷贝、深拷贝和引用拷贝:
-
+
-## Object
+## ⭐️Object
### Object 类的常见方法有哪些?
-Object 类是一个特殊的类,是所有类的父类。它主要提供了以下 11 个方法:
+Object 类是一个特殊的类,是所有类的父类,主要提供了以下 11 个方法:
```java
/**
@@ -393,7 +498,7 @@ System.out.println(42 == 42.0);// true
`String` 中的 `equals` 方法是被重写过的,因为 `Object` 的 `equals` 方法是比较的对象的内存地址,而 `String` 的 `equals` 方法比较的是对象的值。
-当创建 `String` 类型的对象时,虚拟机会在常量池中查找有没有已经存在的值和要创建的值相同的对象,如果有就把它赋给当前引用。如果没有就在常量池中重新创建一个 `String` 对象。
+当使用字符串字面量创建 `String` 类型的对象(如`String aa = "ab"`)时,虚拟机会在常量池中查找有没有已经存在的值和要创建的值相同的对象,如果有就把它赋给当前引用;如果没有,就在常量池中创建一个 `String` 对象并赋给当前引用。但当使用`new`关键字创建对象(如`String a = new String("ab")`)时,虚拟机总是会在堆内存中**创建一个新的对象**并使用常量池中的值(如果没有,会先在字符串常量池中创建字符串对象 "ab")进行初始化,然后赋给当前引用。
`String`类`equals()`方法:
@@ -429,7 +534,7 @@ public boolean equals(Object anObject) {
`hashCode()` 定义在 JDK 的 `Object` 类中,这就意味着 Java 中的任何类都包含有 `hashCode()` 函数。另外需要注意的是:`Object` 的 `hashCode()` 方法是本地方法,也就是用 C 语言或 C++ 实现的。
-> ⚠️ 注意:该方法在 **Oracle OpenJDK8** 中默认是 "使用线程局部状态来实现 Marsaglia's xor-shift 随机数生成", 并不是 "地址" 或者 "地址转换而来", 不同 JDK/VM 可能不同在 **Oracle OpenJDK8** 中有六种生成方式 (其中第五种是返回地址), 通过添加 VM 参数: -XX:hashCode=4 启用第五种。参考源码:
+> ⚠️ 注意:该方法在 **Oracle OpenJDK8** 中默认是 "使用线程局部状态来实现 Marsaglia's xor-shift 随机数生成", 并不是 "地址" 或者 "地址转换而来", 不同 JDK/VM 可能不同。在 **Oracle OpenJDK8** 中有六种生成方式 (其中第五种是返回地址), 通过添加 VM 参数: -XX:hashCode=4 启用第五种。参考源码:
>
> - (1127 行)
> - (537 行开始)
@@ -442,13 +547,18 @@ public native int hashCode();
### 为什么要有 hashCode?
-我们以“`HashSet` 如何检查重复”为例子来说明为什么要有 `hashCode`?
+我们以“HashSet 如何检查重复”为例子来说明为什么要有 hashCode?
-下面这段内容摘自我的 Java 启蒙书《Head First Java》:
+当我们把对象加入 HashSet 时,HashSet 会先调用对象的 `hashCode()` 方法,得到一个“哈希值”,并通过内部散列函数对这个哈希值再做一次简单的转换(比如取余),决定这条数据应该放进底层数组的哪一个桶(bucket,对应到底层数组的某个位置):
-> 当你把对象加入 `HashSet` 时,`HashSet` 会先计算对象的 `hashCode` 值来判断对象加入的位置,同时也会与其他已经加入的对象的 `hashCode` 值作比较,如果没有相符的 `hashCode`,`HashSet` 会假设对象没有重复出现。但是如果发现有相同 `hashCode` 值的对象,这时会调用 `equals()` 方法来检查 `hashCode` 相等的对象是否真的相同。如果两者相同,`HashSet` 就不会让其加入操作成功。如果不同的话,就会重新散列到其他位置。这样我们就大大减少了 `equals` 的次数,相应就大大提高了执行速度。
+1. 如果该桶当前是空的,就直接将对象对应的节点插入到这个桶中。
+2. 如果该桶中已经有其他元素,HashSet 会在这个桶对应的链表或红黑树中逐个比较:
+ - 对于**哈希值不同**的节点,直接跳过;
+ - 对于**哈希值相同**的节点,则会进一步调用 equals() 方法来检查这两个对象是否“相等”:
+ – 如果 `equals()` 返回 true,说明集合中已经存在与当前对象等价的元素,`HashSet` 就不会再次加入它;
+ – 如果返回 false, 则认为是新元素,会将该对象作为一个新节点加入到**同一个桶**的链表或红黑树中。
-其实, `hashCode()` 和 `equals()`都是用于比较两个对象是否相等。
+通过先利用 `hashCode()` 将候选范围缩小到同一个桶内,再在桶内少量元素上调用 `equals()` 做精确判断,`HashSet` 大大减少了 `equals()` 的调用次数,从而提高了查找和插入的执行效率。
**那为什么 JDK 还要同时提供这两个方法呢?**
@@ -462,7 +572,7 @@ public native int hashCode();
**那为什么两个对象有相同的 `hashCode` 值,它们也不一定是相等的?**
-因为 `hashCode()` 所使用的哈希算法也许刚好会让多个对象传回相同的哈希值。越糟糕的哈希算法越容易碰撞,但这也与数据值域分布的特性有关(所谓哈希碰撞也就是指的是不同的对象得到相同的 `hashCode` )。
+因为 `hashCode()` 所使用的哈希算法也许刚好会让多个对象传回相同的哈希值。越糟糕的哈希算法越容易碰撞,但这也与数据值域分布的特性有关(所谓哈希碰撞就是指不同的对象得到相同的 `hashCode` )。
总结下来就是:
@@ -489,7 +599,7 @@ public native int hashCode();
## String
-### String、StringBuffer、StringBuilder 的区别?
+### ⭐️String、StringBuffer、StringBuilder 的区别?
**可变性**
@@ -517,17 +627,19 @@ abstract class AbstractStringBuilder implements Appendable, CharSequence {
`String` 中的对象是不可变的,也就可以理解为常量,线程安全。`AbstractStringBuilder` 是 `StringBuilder` 与 `StringBuffer` 的公共父类,定义了一些字符串的基本操作,如 `expandCapacity`、`append`、`insert`、`indexOf` 等公共方法。`StringBuffer` 对方法加了同步锁或者对调用的方法加了同步锁,所以是线程安全的。`StringBuilder` 并没有对方法进行加同步锁,所以是非线程安全的。
+
+
**性能**
每次对 `String` 类型进行改变的时候,都会生成一个新的 `String` 对象,然后将指针指向新的 `String` 对象。`StringBuffer` 每次都会对 `StringBuffer` 对象本身进行操作,而不是生成新的对象并改变对象引用。相同情况下使用 `StringBuilder` 相比使用 `StringBuffer` 仅能获得 10%~15% 左右的性能提升,但却要冒多线程不安全的风险。
**对于三者使用的总结:**
-1. 操作少量的数据: 适用 `String`
-2. 单线程操作字符串缓冲区下操作大量数据: 适用 `StringBuilder`
-3. 多线程操作字符串缓冲区下操作大量数据: 适用 `StringBuffer`
+- 操作少量的数据: 适用 `String`
+- 单线程操作字符串缓冲区下操作大量数据: 适用 `StringBuilder`
+- 多线程操作字符串缓冲区下操作大量数据: 适用 `StringBuffer`
-### String 为什么是不可变的?
+### ⭐️String 为什么是不可变的?
`String` 类中使用 `final` 关键字修饰字符数组来保存字符串,~~所以`String` 对象是不可变的。~~
@@ -574,7 +686,7 @@ public final class String implements java.io.Serializable, Comparable, C
>
> 这是官方的介绍: 。
-### 字符串拼接用“+” 还是 StringBuilder?
+### ⭐️字符串拼接用“+” 还是 StringBuilder?
Java 语言本身并不支持运算符重载,“+”和“+=”是专门为 String 类重载过的运算符,也是 Java 中仅有的两个重载过的运算符。
@@ -621,32 +733,39 @@ System.out.println(s);
如果你使用 IDEA 的话,IDEA 自带的代码检查机制也会提示你修改代码。
-不过,使用 “+” 进行字符串拼接会产生大量的临时对象的问题在 JDK9 中得到了解决。在 JDK9 当中,字符串相加 “+” 改为了用动态方法 `makeConcatWithConstants()` 来实现,而不是大量的 `StringBuilder` 了。这个改进是 JDK9 的 [JEP 280](https://openjdk.org/jeps/280) 提出的,这也意味着 JDK 9 之后,你可以放心使用“+” 进行字符串拼接了。关于这部分改进的详细介绍,推荐阅读这篇文章:还在无脑用 [StringBuilder?来重温一下字符串拼接吧](https://juejin.cn/post/7182872058743750715) 。
+在 JDK 9 中,字符串相加“+”改为用动态方法 `makeConcatWithConstants()` 来实现,通过提前分配空间从而减少了部分临时对象的创建。然而这种优化主要针对简单的字符串拼接,如: `a+b+c` 。对于循环中的大量拼接操作,仍然会逐个动态分配内存(类似于两个两个 append 的概念),并不如手动使用 StringBuilder 来进行拼接效率高。这个改进是 JDK9 的 [JEP 280](https://openjdk.org/jeps/280) 提出的,关于这部分改进的详细介绍,推荐阅读这篇文章:还在无脑用 [StringBuilder?来重温一下字符串拼接吧](https://juejin.cn/post/7182872058743750715) 以及参考 [issue#2442](https://github.com/Snailclimb/JavaGuide/issues/2442)。
### String#equals() 和 Object#equals() 有何区别?
`String` 中的 `equals` 方法是被重写过的,比较的是 String 字符串的值是否相等。 `Object` 的 `equals` 方法是比较的对象的内存地址。
-### 字符串常量池的作用了解吗?
+### ⭐️字符串常量池的作用了解吗?
**字符串常量池** 是 JVM 为了提升性能和减少内存消耗针对字符串(String 类)专门开辟的一块区域,主要目的是为了避免字符串的重复创建。
```java
-// 在堆中创建字符串对象”ab“
-// 将字符串对象”ab“的引用保存在字符串常量池中
+// 1.在字符串常量池中查询字符串对象 "ab",如果没有则创建"ab"并放入字符串常量池
+// 2.将字符串对象 "ab" 的引用赋值给 aa
String aa = "ab";
-// 直接返回字符串常量池中字符串对象”ab“的引用
+// 直接返回字符串常量池中字符串对象 "ab",赋值给引用 bb
String bb = "ab";
-System.out.println(aa==bb);// true
+System.out.println(aa==bb); // true
```
更多关于字符串常量池的介绍可以看一下 [Java 内存区域详解](https://javaguide.cn/java/jvm/memory-area.html) 这篇文章。
-### String s1 = new String("abc");这句话创建了几个字符串对象?
+### ⭐️String s1 = new String("abc");这句话创建了几个字符串对象?
+
+先说答案:会创建 1 或 2 个字符串对象。
+
+1. 字符串常量池中不存在 "abc":会创建 2 个 字符串对象。一个在字符串常量池中,由 `ldc` 指令触发创建。一个在堆中,由 `new String()` 创建,并使用常量池中的 "abc" 进行初始化。
+2. 字符串常量池中已存在 "abc":会创建 1 个 字符串对象。该对象在堆中,由 `new String()` 创建,并使用常量池中的 "abc" 进行初始化。
-会创建 1 或 2 个字符串对象。
+下面开始详细分析。
-1、如果字符串常量池中不存在字符串对象“abc”的引用,那么它会在堆上创建两个字符串对象,其中一个字符串对象的引用会被保存在字符串常量池中。
+下面开始详细分析。
+
+1、如果字符串常量池中不存在字符串对象 “abc”,那么它首先会在字符串常量池中创建字符串对象 "abc",然后在堆内存中再创建其中一个字符串对象 "abc"。
示例代码(JDK 1.8):
@@ -656,16 +775,40 @@ String s1 = new String("abc");
对应的字节码:
-
+```java
+// 在堆内存中分配一个尚未初始化的 String 对象。
+// #2 是常量池中的一个符号引用,指向 java/lang/String 类。
+// 在类加载的解析阶段,这个符号引用会被解析成直接引用,即指向实际的 java/lang/String 类。
+0 new #2
+// 复制栈顶的 String 对象引用,为后续的构造函数调用做准备。
+// 此时操作数栈中有两个相同的对象引用:一个用于传递给构造函数,另一个用于保持对新对象的引用,后续将其存储到局部变量表。
+3 dup
+// JVM 先检查字符串常量池中是否存在 "abc"。
+// 如果常量池中已存在 "abc",则直接返回该字符串的引用;
+// 如果常量池中不存在 "abc",则 JVM 会在常量池中创建该字符串字面量并返回它的引用。
+// 这个引用被压入操作数栈,用作构造函数的参数。
+4 ldc #3
+// 调用构造方法,使用从常量池中加载的 "abc" 初始化堆中的 String 对象
+// 新的 String 对象将包含与常量池中的 "abc" 相同的内容,但它是一个独立的对象,存储于堆中。
+6 invokespecial #4 : (Ljava/lang/String;)V>
+// 将堆中的 String 对象引用存储到局部变量表
+9 astore_1
+// 返回,结束方法
+10 return
+```
-`ldc` 命令用于判断字符串常量池中是否保存了对应的字符串对象的引用,如果保存了的话直接返回,如果没有保存的话,会在堆中创建对应的字符串对象并将该字符串对象的引用保存到字符串常量池中。
+`ldc (load constant)` 指令的确是从常量池中加载各种类型的常量,包括字符串常量、整数常量、浮点数常量,甚至类引用等。对于字符串常量,`ldc` 指令的行为如下:
-2、如果字符串常量池中已存在字符串对象“abc”的引用,则只会在堆中创建 1 个字符串对象“abc”。
+1. **从常量池加载字符串**:`ldc` 首先检查字符串常量池中是否已经有内容相同的字符串对象。
+2. **复用已有字符串对象**:如果字符串常量池中已经存在内容相同的字符串对象,`ldc` 会将该对象的引用加载到操作数栈上。
+3. **没有则创建新对象并加入常量池**:如果字符串常量池中没有相同内容的字符串对象,JVM 会在常量池中创建一个新的字符串对象,并将其引用加载到操作数栈中。
+
+2、如果字符串常量池中已存在字符串对象“abc”,则只会在堆中创建 1 个字符串对象“abc”。
示例代码(JDK 1.8):
```java
-// 字符串常量池中已存在字符串对象“abc”的引用
+// 字符串常量池中已存在字符串对象“abc”
String s1 = "abc";
// 下面这段代码只会在堆中创建 1 个字符串对象“abc”
String s2 = new String("abc");
@@ -673,35 +816,48 @@ String s2 = new String("abc");
对应的字节码:
-
+```java
+0 ldc #2
+2 astore_1
+3 new #3
+6 dup
+7 ldc #2
+9 invokespecial #4 : (Ljava/lang/String;)V>
+12 astore_2
+13 return
+```
这里就不对上面的字节码进行详细注释了,7 这个位置的 `ldc` 命令不会在堆中创建新的字符串对象“abc”,这是因为 0 这个位置已经执行了一次 `ldc` 命令,已经在堆中创建过一次字符串对象“abc”了。7 这个位置执行 `ldc` 命令会直接返回字符串常量池中字符串对象“abc”对应的引用。
### String#intern 方法有什么作用?
-`String.intern()` 是一个 native(本地)方法,其作用是将指定的字符串对象的引用保存在字符串常量池中,可以简单分为两种情况:
+`String.intern()` 是一个 `native` (本地) 方法,用来处理字符串常量池中的字符串对象引用。它的工作流程可以概括为以下两种情况:
+
+1. **常量池中已有相同内容的字符串对象**:如果字符串常量池中已经有一个与调用 `intern()` 方法的字符串内容相同的 `String` 对象,`intern()` 方法会直接返回常量池中该对象的引用。
+2. **常量池中没有相同内容的字符串对象**:如果字符串常量池中还没有一个与调用 `intern()` 方法的字符串内容相同的对象,`intern()` 方法会将当前字符串对象的引用添加到字符串常量池中,并返回该引用。
+
+总结:
-- 如果字符串常量池中保存了对应的字符串对象的引用,就直接返回该引用。
-- 如果字符串常量池中没有保存了对应的字符串对象的引用,那就在常量池中创建一个指向该字符串对象的引用并返回。
+- `intern()` 方法的主要作用是确保字符串引用在常量池中的唯一性。
+- 当调用 `intern()` 时,如果常量池中已经存在相同内容的字符串,则返回常量池中已有对象的引用;否则,将该字符串添加到常量池并返回其引用。
示例代码(JDK 1.8) :
```java
-// 在堆中创建字符串对象”Java“
-// 将字符串对象”Java“的引用保存在字符串常量池中
+// s1 指向字符串常量池中的 "Java" 对象
String s1 = "Java";
-// 直接返回字符串常量池中字符串对象”Java“对应的引用
+// s2 也指向字符串常量池中的 "Java" 对象,和 s1 是同一个对象
String s2 = s1.intern();
-// 会在堆中在单独创建一个字符串对象
+// 在堆中创建一个新的 "Java" 对象,s3 指向它
String s3 = new String("Java");
-// 直接返回字符串常量池中字符串对象”Java“对应的引用
+// s4 指向字符串常量池中的 "Java" 对象,和 s1 是同一个对象
String s4 = s3.intern();
-// s1 和 s2 指向的是堆中的同一个对象
+// s1 和 s2 指向的是同一个常量池中的对象
System.out.println(s1 == s2); // true
-// s3 和 s4 指向的是堆中不同的对象
+// s3 指向堆中的对象,s4 指向常量池中的对象,所以不同
System.out.println(s3 == s4); // false
-// s1 和 s4 指向的是堆中的同一个对象
-System.out.println(s1 == s4); //true
+// s1 和 s4 都指向常量池中的同一个对象
+System.out.println(s1 == s4); // true
```
### String 类型的变量和常量做“+”运算时发生了什么?
@@ -782,6 +938,7 @@ public static String getStr() {
## 参考
- 深入解析 String#intern:
+- Java String 源码解读:
- R 大(RednaxelaFX)关于常量折叠的回答:
diff --git a/docs/java/basis/java-basic-questions-03.md b/docs/java/basis/java-basic-questions-03.md
index f2afe2d8987..a68ac71ca14 100644
--- a/docs/java/basis/java-basic-questions-03.md
+++ b/docs/java/basis/java-basic-questions-03.md
@@ -1,18 +1,16 @@
---
title: Java基础常见面试题总结(下)
+description: Java高级特性面试题总结:深入讲解异常处理机制、泛型原理、反射应用、注解使用、SPI机制、序列化、IO流模型(BIO/NIO/AIO)、语法糖等核心知识点。
category: Java
tag:
- Java基础
head:
- - meta
- name: keywords
- content: Java异常,泛型,反射,IO,注解
- - - meta
- - name: description
- content: 全网质量最高的Java基础常见知识点和面试题总结,希望对你有帮助!
+ content: Java异常,泛型,反射,注解,SPI,序列化,IO流,语法糖,try-with-resources,BIO NIO AIO,Java面试题
---
-
+
## 异常
@@ -25,9 +23,14 @@ head:
在 Java 中,所有的异常都有一个共同的祖先 `java.lang` 包中的 `Throwable` 类。`Throwable` 类有两个重要的子类:
- **`Exception`** :程序本身可以处理的异常,可以通过 `catch` 来进行捕获。`Exception` 又可以分为 Checked Exception (受检查异常,必须处理) 和 Unchecked Exception (不受检查异常,可以不处理)。
-- **`Error`**:`Error` 属于程序无法处理的错误 ,~~我们没办法通过 `catch` 来进行捕获~~不建议通过`catch`捕获 。例如 Java 虚拟机运行错误(`Virtual MachineError`)、虚拟机内存不够错误(`OutOfMemoryError`)、类定义错误(`NoClassDefFoundError`)等 。这些异常发生时,Java 虚拟机(JVM)一般会选择线程终止。
+- **`Error`** :`Error` 属于程序无法处理的错误 ,~~我们没办法通过 `catch` 来进行捕获~~不建议通过`catch`捕获 。例如 Java 虚拟机运行错误(`Virtual MachineError`)、虚拟机内存不够错误(`OutOfMemoryError`)、类定义错误(`NoClassDefFoundError`)等 。这些异常发生时,Java 虚拟机(JVM)一般会选择线程终止。
+
+### ClassNotFoundException 和 NoClassDefFoundError 的区别
+
+- `ClassNotFoundException` 是Exception,发生在使用反射等动态加载时找不到类,是可预期的,可以捕获处理。
+- `NoClassDefFoundError` 是Error,是编译时存在的类,在运行时链接不到了(比如 jar 包缺失),是环境问题,导致 JVM 无法继续。
-### Checked Exception 和 Unchecked Exception 有什么区别?
+### ⭐️Checked Exception 和 Unchecked Exception 有什么区别?
**Checked Exception** 即 受检查异常 ,Java 代码在编译过程中,如果受检查异常没有被 `catch`或者`throws` 关键字处理的话,就没办法通过编译。
@@ -53,10 +56,18 @@ head:

+### 你更倾向于使用 Checked Exception 还是 Unchecked Exception?
+
+默认使用 Unchecked Exception,只在必要时才用 Checked Exception。
+
+我们可以把 Unchecked Exception(比如 `NullPointerException`)看作是代码 Bug。对待 Bug,最好的方式是让它暴露出来然后去修复代码,而不是用 `try-catch` 去掩盖它。
+
+一般来说,只在一种情况下使用 Checked Exception:当这个异常是业务逻辑的一部分,并且调用方必须处理它时。比如说,一个余额不足异常。这不是 bug,而是一个正常的业务分支,我需要用 Checked Exception 来强制调用者去处理这种情况,比如提示用户去充值。这样就能在保证关键业务逻辑完整性的同时,让代码尽可能保持简洁。
+
### Throwable 类常用方法有哪些?
-- `String getMessage()`: 返回异常发生时的简要描述
-- `String toString()`: 返回异常发生时的详细信息
+- `String getMessage()`: 返回异常发生时的详细信息
+- `String toString()`: 返回异常发生时的简要描述
- `String getLocalizedMessage()`: 返回异常对象的本地化信息。使用 `Throwable` 的子类覆盖这个方法,可以生成本地化信息。如果子类没有覆盖该方法,则该方法返回的信息与 `getMessage()`返回的结果相同
- `void printStackTrace()`: 在控制台上打印 `Throwable` 对象封装的异常信息
@@ -89,14 +100,6 @@ Finally
**注意:不要在 finally 语句块中使用 return!** 当 try 语句和 finally 语句中都有 return 语句时,try 语句块中的 return 语句会被忽略。这是因为 try 语句中的 return 返回值会先被暂存在一个本地变量中,当执行到 finally 语句中的 return 之后,这个本地变量的值就变为了 finally 语句中的 return 返回值。
-[jvm 官方文档](https://docs.oracle.com/javase/specs/jvms/se7/html/jvms-4.html#jvms-4.10.2.5)中有明确提到:
-
-> If the `try` clause executes a _return_, the compiled code does the following:
->
-> 1. Saves the return value (if any) in a local variable.
-> 2. Executes a _jsr_ to the code for the `finally` clause.
-> 3. Upon return from the `finally` clause, returns the value saved in the local variable.
-
代码示例:
```java
@@ -213,11 +216,11 @@ catch (IOException e) {
}
```
-### 异常使用有哪些需要注意的地方?
+### ⭐️异常使用有哪些需要注意的地方?
- 不要把异常定义为静态变量,因为这样会导致异常栈信息错乱。每次手动抛出异常,我们都需要手动 new 一个异常对象抛出。
- 抛出的异常信息一定要有意义。
-- 建议抛出更加具体的异常比如字符串转换为数字格式错误的时候应该抛出`NumberFormatException`而不是其父类`IllegalArgumentException`。
+- 建议抛出更加具体的异常,比如字符串转换为数字格式错误的时候应该抛出`NumberFormatException`而不是其父类`IllegalArgumentException`。
- 避免重复记录日志:如果在捕获异常的地方已经记录了足够的信息(包括异常类型、错误信息和堆栈跟踪等),那么在业务代码中再次抛出这个异常时,就不应该再次记录相同的错误信息。重复记录日志会使得日志文件膨胀,并且可能会掩盖问题的实际原因,使得问题更难以追踪和解决。
- ……
@@ -286,7 +289,7 @@ class GeneratorImpl implements Generator{
实现泛型接口,指定类型:
```java
-class GeneratorImpl implements Generator{
+class GeneratorImpl implements Generator {
@Override
public String method() {
return "hello";
@@ -325,56 +328,122 @@ printArray( stringArray );
- 构建集合工具类(参考 `Collections` 中的 `sort`, `binarySearch` 方法)。
- ……
-## 反射
+## ⭐️反射
+
+关于反射的详细解读,请看这篇文章 [Java 反射机制详解](https://javaguide.cn/java/basis/reflection.html) 。
+
+### 什么是反射?
-关于反射的详细解读,请看这篇文章 [Java 反射机制详解](./reflection.md) 。
+简单来说,Java 反射 (Reflection) 是一种**在程序运行时,动态地获取类的信息并操作类或对象(方法、属性)的能力**。
-### 何谓反射?
+通常情况下,我们写的代码在编译时类型就已经确定了,要调用哪个方法、访问哪个字段都是明确的。但反射允许我们在**运行时**才去探知一个类有哪些方法、哪些属性、它的构造函数是怎样的,甚至可以动态地创建对象、调用方法或修改属性,哪怕这些方法或属性是私有的。
-如果说大家研究过框架的底层原理或者咱们自己写过框架的话,一定对反射这个概念不陌生。反射之所以被称为框架的灵魂,主要是因为它赋予了我们在运行时分析类以及执行类中方法的能力。通过反射你可以获取任意一个类的所有属性和方法,你还可以调用这些方法和属性。
+正是这种在运行时“反观自身”并进行操作的能力,使得反射成为许多**通用框架和库的基石**。它让代码更加灵活,能够处理在编译时未知的类型。
-### 反射的优缺点?
+### 反射有什么优缺点?
-反射可以让我们的代码更加灵活、为各种框架提供开箱即用的功能提供了便利。
+**优点:**
-不过,反射让我们在运行时有了分析操作类的能力的同时,也增加了安全问题,比如可以无视泛型参数的安全检查(泛型参数的安全检查发生在编译时)。另外,反射的性能也要稍差点,不过,对于框架来说实际是影响不大的。
+1. **灵活性和动态性**:反射允许程序在运行时动态地加载类、创建对象、调用方法和访问字段。这样可以根据实际需求(如配置文件、用户输入、注解等)动态地适应和扩展程序的行为,显著提高了系统的灵活性和适应性。
+2. **框架开发的基础**:许多现代 Java 框架(如 Spring、Hibernate、MyBatis)都大量使用反射来实现依赖注入(DI)、面向切面编程(AOP)、对象关系映射(ORM)、注解处理等核心功能。反射是实现这些“魔法”功能不可或缺的基础工具。
+3. **解耦合和通用性**:通过反射,可以编写更通用、可重用和高度解耦的代码,降低模块之间的依赖。例如,可以通过反射实现通用的对象拷贝、序列化、Bean 工具等。
+
+**缺点:**
+
+1. **性能开销**:反射操作通常比直接代码调用要慢。因为涉及到动态类型解析、方法查找以及 JIT 编译器的优化受限等因素。不过,对于大多数框架场景,这种性能损耗通常是可以接受的,或者框架本身会做一些缓存优化。
+2. **安全性问题**:反射可以绕过 Java 语言的访问控制机制(如访问 `private` 字段和方法),破坏了封装性,可能导致数据泄露或程序被恶意篡改。此外,还可以绕过泛型检查,带来类型安全隐患。
+3. **代码可读性和维护性**:过度使用反射会使代码变得复杂、难以理解和调试。错误通常在运行时才会暴露,不像编译期错误那样容易发现。
相关阅读:[Java Reflection: Why is it so slow?](https://stackoverflow.com/questions/1392351/java-reflection-why-is-it-so-slow) 。
### 反射的应用场景?
-像咱们平时大部分时候都是在写业务代码,很少会接触到直接使用反射机制的场景。但是!这并不代表反射没有用。相反,正是因为反射,你才能这么轻松地使用各种框架。像 Spring/Spring Boot、MyBatis 等等框架中都大量使用了反射机制。
+我们平时写业务代码可能很少直接跟 Java 的反射(Reflection)打交道。但你可能没意识到,你天天都在享受反射带来的便利!**很多流行的框架,比如 Spring/Spring Boot、MyBatis 等,底层都大量运用了反射机制**,这才让它们能够那么灵活和强大。
+
+下面简单列举几个最场景的场景帮助大家理解。
+
+**1.依赖注入与控制反转(IoC)**
+
+以 Spring/Spring Boot 为代表的 IoC 框架,会在启动时扫描带有特定注解(如 `@Component`, `@Service`, `@Repository`, `@Controller`)的类,利用反射实例化对象(Bean),并通过反射注入依赖(如 `@Autowired`、构造器注入等)。
-**这些框架中也大量使用了动态代理,而动态代理的实现也依赖反射。**
+**2.注解处理**
-比如下面是通过 JDK 实现动态代理的示例代码,其中就使用了反射类 `Method` 来调用指定的方法。
+注解本身只是个“标记”,得有人去读这个标记才知道要做什么。反射就是那个“读取器”。框架通过反射检查类、方法、字段上有没有特定的注解,然后根据注解信息执行相应的逻辑。比如,看到 `@Value`,就用反射读取注解内容,去配置文件找对应的值,再用反射把值设置给字段。
+
+**3.动态代理与 AOP**
+
+想在调用某个方法前后自动加点料(比如打日志、开事务、做权限检查)?AOP(面向切面编程)就是干这个的,而动态代理是实现 AOP 的常用手段。JDK 自带的动态代理(Proxy 和 InvocationHandler)就离不开反射。代理对象在内部调用真实对象的方法时,就是通过反射的 `Method.invoke` 来完成的。
```java
public class DebugInvocationHandler implements InvocationHandler {
- /**
- * 代理类中的真实对象
- */
- private final Object target;
+ private final Object target; // 真实对象
- public DebugInvocationHandler(Object target) {
- this.target = target;
- }
+ public DebugInvocationHandler(Object target) { this.target = target; }
- public Object invoke(Object proxy, Method method, Object[] args) throws InvocationTargetException, IllegalAccessException {
- System.out.println("before method " + method.getName());
+ // proxy: 代理对象, method: 被调用的方法, args: 方法参数
+ public Object invoke(Object proxy, Method method, Object[] args) throws Throwable {
+ System.out.println("切面逻辑:调用方法 " + method.getName() + " 之前");
+ // 通过反射调用真实对象的同名方法
Object result = method.invoke(target, args);
- System.out.println("after method " + method.getName());
+ System.out.println("切面逻辑:调用方法 " + method.getName() + " 之后");
return result;
}
}
-
```
-另外,像 Java 中的一大利器 **注解** 的实现也用到了反射。
+**4.对象关系映射(ORM)**
+
+像 MyBatis、Hibernate 这种框架,能帮你把数据库查出来的一行行数据,自动变成一个个 Java 对象。它是怎么知道数据库字段对应哪个 Java 属性的?还是靠反射。它通过反射获取 Java 类的属性列表,然后把查询结果按名字或配置对应起来,再用反射调用 setter 或直接修改字段值。反过来,保存对象到数据库时,也是用反射读取属性值来拼 SQL。
-为什么你使用 Spring 的时候 ,一个`@Component`注解就声明了一个类为 Spring Bean 呢?为什么你通过一个 `@Value`注解就读取到配置文件中的值呢?究竟是怎么起作用的呢?
+## 代理
-这些都是因为你可以基于反射分析类,然后获取到类/属性/方法/方法的参数上的注解。你获取到注解之后,就可以做进一步的处理。
+关于 Java 代理的详细介绍,可以看看笔者写的 [Java 代理模式详解](https://javaguide.cn/java/basis/proxy.html "Java 代理模式详解")这篇文章。
+
+### 如何实现动态代理?
+
+动态代理是一种非常强大的设计模式,它允许我们在**不修改源代码**的情况下,对一个类或对象的方法进行**功能增强(Enhancement)**。
+
+在 Java 中,实现动态代理最主流的方式有两种:**JDK 动态代理** 和 **CGLIB 动态代理**。
+
+**第一种:JDK 动态代理**
+
+Java 官方提供的,其核心要求是目标类必须实现一个或多个接口。JDK 动态代理在运行时,会利用 `Proxy.newProxyInstance()` 方法,动态地创建一个实现了这些接口的代理类的实例。这个代理类在内存中生成,你看不到它的 `.java` 或 `.class` 文件。
+
+当你调用代理对象的任何一个方法时,这个调用都会被转发到我们提供的一个 `InvocationHandler` 接口的 `invoke` 方法中。在 `invoke` 方法里,我们就可以在调用原始方法(目标方法)之前或之后,加入我们自己的增强逻辑。
+
+**第二种:CGLIB 动态代理**
+
+CGLIB 是一个第三方的代码生成库。它的原理与 JDK 完全不同,它不要求被代理的类实现接口。它在运行时,动态生成目标类的子类作为代理类(通过 ASM 字节码操作技术)。然后,它会重写父类(也就是被代理类)中所有非 `final`、`private` 和 `static` 的方法。
+
+当你调用代理对象的任何一个方法时,这个调用会被 CGLIB 的 `MethodInterceptor` 接口的 `intercept` 方法拦截。和 `InvocationHandler` 的 `invoke` 方法一样,我们可以在 `intercept` 方法里,在调用原始的父类方法之前或之后,加入我们的增强逻辑。
+
+### 静态代理和动态代理有什么区别?
+
+静态代理和动态代理的核心差异在于 **代理关系的确定时机、实现灵活性及维护成本** 。
+
+| 对比维度 | 静态代理 (Static Proxy) | 动态代理 (Dynamic Proxy) |
+| ---------------- | ---------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------ |
+| 代理关系确定时机 | 编译期(编译后生成固定的 `.class` 字节码文件) | 运行时(动态生成代理类字节码并加载到 JVM) |
+| 实现方式 | 手动编写代理类,需与目标类实现同一接口,一对一绑定 | 无需手动编写代理类,通过 `Handler`/`Interceptor` 封装增强逻辑,一对多复用 |
+| 接口依赖 | 必须实现接口(代理类与目标类遵循同一接口规范) | 支持代理接口或直接代理实现类 |
+| 代码量与维护性 | 代码量大(目标类越多,代理类越多),维护成本高;接口新增方法时,目标类与代理类需同步修改 | 代码量极少(通用增强逻辑可复用),维护性好;与接口解耦,接口变更不影响代理逻辑 |
+| 核心优势 | 实现简单、逻辑直观,无额外框架依赖 | 灵活性强、复用性高,降低重复编码,适配复杂场景 |
+| 典型应用场景 | 简单的装饰器模式、少量固定类的增强需求 | Spring AOP、RPC 框架(如 Dubbo)、ORM 框架 |
+
+### ⭐️JDK 动态代理和 CGLIB 动态代理有什么区别?
+
+1. JDK 动态代理是官方的,它要求被代理的类必须实现接口。它的原理是动态生成一个接口的实现类来作为代理。CGLIB 是第三方的,它不需要接口。它的原理是动态生成一个被代理类的子类来作为代理。但也正因为是继承,所以它不能代理 `final` 的类,被代理的方法也不能是 `final` 或 `private` 。
+2. 就二者的效率来说,大部分情况都是 JDK 动态代理更优秀,随着 JDK 版本的升级,这个优势更加明显。
+
+### ⭐️介绍一下动态代理在框架中的实际应用场景
+
+动态代理最典型的应用场景就是**Spring AOP**。
+
+AOP(Aspect-Oriented Programming:面向切面编程)能够将那些与业务无关,却为业务模块所共同调用的逻辑或责任(例如事务处理、日志管理、权限控制等)封装起来,便于减少系统的重复代码,降低模块间的耦合度,并有利于未来的可拓展性和可维护性。
+
+Spring AOP 就是基于动态代理的,如果要代理的对象,实现了某个接口,那么 Spring AOP 会使用 **JDK Proxy**,去创建代理对象,而对于没有实现接口的对象,就无法使用 JDK Proxy 去进行代理了,这时候 Spring AOP 会使用 **Cglib** 生成一个被代理对象的子类来作为代理,如下图所示:
+
+
## 注解
@@ -405,9 +474,9 @@ JDK 提供了很多内置的注解(比如 `@Override`、`@Deprecated`),同
- **编译期直接扫描**:编译器在编译 Java 代码的时候扫描对应的注解并处理,比如某个方法使用`@Override` 注解,编译器在编译的时候就会检测当前的方法是否重写了父类对应的方法。
- **运行期通过反射处理**:像框架中自带的注解(比如 Spring 框架的 `@Value`、`@Component`)都是通过反射来进行处理的。
-## SPI
+## ⭐️SPI
-关于 SPI 的详细解读,请看这篇文章 [Java SPI 机制详解](./spi.md) 。
+关于 SPI 的详细解读,请看这篇文章 [Java SPI 机制详解](https://javaguide.cn/java/basis/spi.html) 。
### 何谓 SPI?
@@ -417,21 +486,20 @@ SPI 将服务接口和具体的服务实现分离开来,将服务调用方和
很多框架都使用了 Java 的 SPI 机制,比如:Spring 框架、数据库加载驱动、日志接口、以及 Dubbo 的扩展实现等等。
-
+
### SPI 和 API 有什么区别?
**那 SPI 和 API 有啥区别?**
-说到 SPI 就不得不说一下 API 了,从广义上来说它们都属于接口,而且很容易混淆。下面先用一张图说明一下:
-
-
+说到 SPI 就不得不说一下 API(Application Programming Interface) 了,从广义上来说它们都属于接口,而且很容易混淆。下面先用一张图说明一下:
-一般模块之间都是通过接口进行通讯,那我们在服务调用方和服务实现方(也称服务提供者)之间引入一个“接口”。
+
-当实现方提供了接口和实现,我们可以通过调用实现方的接口从而拥有实现方给我们提供的能力,这就是 API ,这种接口和实现都是放在实现方的。
+一般模块之间都是通过接口进行通讯,因此我们在服务调用方和服务实现方(也称服务提供者)之间引入一个“接口”。
-当接口存在于调用方这边时,就是 SPI ,由接口调用方确定接口规则,然后由不同的厂商去根据这个规则对这个接口进行实现,从而提供服务。
+- 当实现方提供了接口和实现,我们可以通过调用实现方的接口从而拥有实现方给我们提供的能力,这就是 **API**。这种情况下,接口和实现都是放在实现方的包中。调用方通过接口调用实现方的功能,而不需要关心具体的实现细节。
+- 当接口存在于调用方这边时,这就是 **SPI** 。由接口调用方确定接口规则,然后由不同的厂商根据这个规则对这个接口进行实现,从而提供服务。
举个通俗易懂的例子:公司 H 是一家科技公司,新设计了一款芯片,然后现在需要量产了,而市面上有好几家芯片制造业公司,这个时候,只要 H 公司指定好了这芯片生产的标准(定义好了接口标准),那么这些合作的芯片公司(服务提供者)就按照标准交付自家特色的芯片(提供不同方案的实现,但是给出来的结果是一样的)。
@@ -442,9 +510,9 @@ SPI 将服务接口和具体的服务实现分离开来,将服务调用方和
- 需要遍历加载所有的实现类,不能做到按需加载,这样效率还是相对较低的。
- 当多个 `ServiceLoader` 同时 `load` 时,会有并发问题。
-## 序列化和反序列化
+## ⭐️序列化和反序列化
-关于序列化和反序列化的详细解读,请看这篇文章 [Java 序列化详解](./serialization.md) ,里面涉及到的知识点和面试题更全面。
+关于序列化和反序列化的详细解读,请看这篇文章 [Java 序列化详解](https://javaguide.cn/java/basis/serialization.html) ,里面涉及到的知识点和面试题更全面。
### 什么是序列化?什么是反序列化?
@@ -452,8 +520,8 @@ SPI 将服务接口和具体的服务实现分离开来,将服务调用方和
简单来说:
-- **序列化**:将数据结构或对象转换成二进制字节流的过程
-- **反序列化**:将在序列化过程中所生成的二进制字节流转换成数据结构或者对象的过程
+- **序列化**:将数据结构或对象转换成可以存储或传输的形式,通常是二进制字节流,也可以是 JSON, XML 等文本格式
+- **反序列化**:将在序列化过程中所生成的数据转换为原始数据结构或者对象的过程
对于 Java 这种面向对象编程语言来说,我们序列化的都是对象(Object)也就是实例化后的类(Class),但是在 C++这种半面向对象的语言中,struct(结构体)定义的是数据结构类型,而 class 对应的是对象类型。
@@ -493,7 +561,7 @@ SPI 将服务接口和具体的服务实现分离开来,将服务调用方和
对于不想进行序列化的变量,使用 `transient` 关键字修饰。
-`transient` 关键字的作用是:阻止实例中那些用此关键字修饰的的变量序列化;当对象被反序列化时,被 `transient` 修饰的变量值不会被持久化和恢复。
+`transient` 关键字的作用是:阻止实例中那些用此关键字修饰的变量序列化;当对象被反序列化时,被 `transient` 修饰的变量值不会被持久化和恢复。
关于 `transient` 还有几点注意:
@@ -519,9 +587,9 @@ JDK 自带的序列化方式一般不会用 ,因为序列化效率低并且存
关于 I/O 的详细解读,请看下面这几篇文章,里面涉及到的知识点和面试题更全面。
-- [Java IO 基础知识总结](../io/io-basis.md)
-- [Java IO 设计模式总结](../io/io-design-patterns.md)
-- [Java IO 模型详解](../io/io-model.md)
+- [Java IO 基础知识总结](https://javaguide.cn/java/io/io-basis.html)
+- [Java IO 设计模式总结](https://javaguide.cn/java/io/io-design-patterns.html)
+- [Java IO 模型详解](https://javaguide.cn/java/io/io-model.html)
### Java IO 流了解吗?
@@ -543,11 +611,11 @@ Java IO 流的 40 多个类都是从如下 4 个抽象类基类中派生出来
### Java IO 中的设计模式有哪些?
-参考答案:[Java IO 设计模式总结](../io/io-design-patterns.md)
+参考答案:[Java IO 设计模式总结](https://javaguide.cn/java/io/io-design-patterns.html)
-### BIO、NIO 和 AIO 的区别?
+### ⭐️BIO、NIO 和 AIO 的区别?
-参考答案:[Java IO 模型详解](../io/io-model.md)
+参考答案:[Java IO 模型详解](https://javaguide.cn/java/io/io-model.html)
## 语法糖
diff --git a/docs/java/basis/java-keyword-summary.md b/docs/java/basis/java-keyword-summary.md
index daf13c9ec14..d69513a26c2 100644
--- a/docs/java/basis/java-keyword-summary.md
+++ b/docs/java/basis/java-keyword-summary.md
@@ -1,3 +1,15 @@
+---
+title: Java 关键字总结
+description: 系统总结Java常用关键字:详解final、static、this、super、volatile、transient、synchronized等关键字用法与区别,助力Java开发者掌握核心语法。
+category: Java
+tag:
+ - Java基础
+head:
+ - - meta
+ - name: keywords
+ content: Java关键字,final关键字,static关键字,this关键字,super关键字,volatile,transient,synchronized
+---
+
# final,static,this,super 关键字总结
## final 关键字
@@ -54,7 +66,7 @@ super 关键字用于从子类访问父类的变量和方法。 例如:
```java
public class Super {
protected int number;
- protected showNumber() {
+ protected void showNumber() {
System.out.println("number = " + number);
}
}
@@ -199,7 +211,7 @@ public class Singleton {
```java
//将Math中的所有静态资源导入,这时候可以直接使用里面的静态方法,而不用通过类名进行调用
//如果只想导入单一某个静态方法,只需要将*换成对应的方法名即可
-import static java.lang.Math.*;//换成import static java.lang.Math.max;具有一样的效果
+import static java.lang.Math.*;//换成import static java.lang.Math.max;即可指定单一静态方法max导入
public class Demo {
public static void main(String[] args) {
int max = max(1,2);
@@ -250,7 +262,7 @@ bar.method2();
不同点:静态代码块在非静态代码块之前执行(静态代码块 -> 非静态代码块 -> 构造方法)。静态代码块只在第一次 new 执行一次,之后不再执行,而非静态代码块在每 new 一次就执行一次。 非静态代码块可在普通方法中定义(不过作用不大);而静态代码块不行。
> **🐛 修正(参见:[issue #677](https://github.com/Snailclimb/JavaGuide/issues/677))**:静态代码块可能在第一次 new 对象的时候执行,但不一定只在第一次 new 的时候执行。比如通过 `Class.forName("ClassDemo")`创建 Class 对象的时候也会执行,即 new 或者 `Class.forName("ClassDemo")` 都会执行静态代码块。
-> 一般情况下,如果有些代码比如一些项目最常用的变量或对象必须在项目启动的时候就执行的时候,需要使用静态代码块,这种代码是主动执行的。如果我们想要设计不需要创建对象就可以调用类中的方法,例如:`Arrays` 类,`Character` 类,`String` 类等,就需要使用静态方法, 两者的区别是 静态代码块是自动执行的而静态方法是被调用的时候才执行的.
+> 一般情况下,如果有些代码比如一些项目最常用的变量或对象必须在项目启动的时候就执行,需要使用静态代码块,这种代码是主动执行的。如果我们想要设计不需要创建对象就可以调用类中的方法,例如:`Arrays` 类,`Character` 类,`String` 类等,就需要使用静态方法, 两者的区别是 静态代码块是自动执行的而静态方法是被调用的时候才执行的.
Example:
diff --git a/docs/java/basis/proxy.md b/docs/java/basis/proxy.md
index 615b0f00e42..1882d0f8c4e 100644
--- a/docs/java/basis/proxy.md
+++ b/docs/java/basis/proxy.md
@@ -1,8 +1,13 @@
---
title: Java 代理模式详解
+description: 详解Java代理模式原理与实现:对比静态代理与动态代理差异,深入分析JDK动态代理和CGLIB代理机制,理解AOP横切关注点实现。
category: Java
tag:
- Java基础
+head:
+ - - meta
+ - name: keywords
+ content: Java代理模式,静态代理,动态代理,JDK动态代理,CGLIB代理,AOP,设计模式,代理实现
---
## 1. 代理模式
@@ -21,7 +26,7 @@ tag:
## 2. 静态代理
-**静态代理中,我们对目标对象的每个方法的增强都是手动完成的(_后面会具体演示代码_),非常不灵活(_比如接口一旦新增加方法,目标对象和代理对象都要进行修改_)且麻烦(_需要对每个目标类都单独写一个代理类_)。** 实际应用场景非常非常少,日常开发几乎看不到使用静态代理的场景。
+静态代理中,我们对目标对象的每个方法的增强都是手动完成的(后面会具体演示代码),非常不灵活(比如接口一旦新增加方法,目标对象和代理对象都要进行修改)且麻烦(需要对每个目标类都单独写一个代理类)。 实际应用场景非常非常少,日常开发几乎看不到使用静态代理的场景。
上面我们是从实现和应用角度来说的静态代理,从 JVM 层面来说, **静态代理在编译时就将接口、实现类、代理类这些都变成了一个个实际的 class 文件。**
@@ -99,7 +104,7 @@ after method send()
## 3. 动态代理
-相比于静态代理来说,动态代理更加灵活。我们不需要针对每个目标类都单独创建一个代理类,并且也不需要我们必须实现接口,我们可以直接代理实现类( _CGLIB 动态代理机制_)。
+相比于静态代理来说,动态代理更加灵活。我们不需要针对每个目标类都单独创建一个代理类,并且也不需要我们必须实现接口,我们可以直接代理实现类( CGLIB 动态代理机制)。
**从 JVM 角度来说,动态代理是在运行时动态生成类字节码,并加载到 JVM 中的。**
@@ -330,10 +335,10 @@ public class DebugMethodInterceptor implements MethodInterceptor {
/**
- * @param o 被代理的对象(需要增强的对象)
+ * @param o 代理对象本身(注意不是原始对象,如果使用method.invoke(o, args)会导致循环调用)
* @param method 被拦截的方法(需要增强的方法)
* @param args 方法入参
- * @param methodProxy 用于调用原始方法
+ * @param methodProxy 高性能的方法调用机制,避免反射开销
*/
@Override
public Object intercept(Object o, Method method, Object[] args, MethodProxy methodProxy) throws Throwable {
@@ -387,13 +392,21 @@ after method send
### 3.3. JDK 动态代理和 CGLIB 动态代理对比
-1. **JDK 动态代理只能代理实现了接口的类或者直接代理接口,而 CGLIB 可以代理未实现任何接口的类。** 另外, CGLIB 动态代理是通过生成一个被代理类的子类来拦截被代理类的方法调用,因此不能代理声明为 final 类型的类和方法。
+1. JDK 动态代理是官方的,它要求被代理的类必须实现接口。它的原理是动态生成一个接口的实现类来作为代理。CGLIB 是第三方的,它不需要接口。它的原理是动态生成一个被代理类的子类来作为代理。但也正因为是继承,所以它不能代理 `final` 的类,被代理的方法也不能是 `final` 或 `private` 。
2. 就二者的效率来说,大部分情况都是 JDK 动态代理更优秀,随着 JDK 版本的升级,这个优势更加明显。
## 4. 静态代理和动态代理的对比
-1. **灵活性**:动态代理更加灵活,不需要必须实现接口,可以直接代理实现类,并且可以不需要针对每个目标类都创建一个代理类。另外,静态代理中,接口一旦新增加方法,目标对象和代理对象都要进行修改,这是非常麻烦的!
-2. **JVM 层面**:静态代理在编译时就将接口、实现类、代理类这些都变成了一个个实际的 class 文件。而动态代理是在运行时动态生成类字节码,并加载到 JVM 中的。
+静态代理和动态代理的核心差异在于 **代理关系的确定时机、实现灵活性及维护成本** 。
+
+| 对比维度 | 静态代理 (Static Proxy) | 动态代理 (Dynamic Proxy) |
+| ---------------- | ---------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------ |
+| 代理关系确定时机 | 编译期(编译后生成固定的 `.class` 字节码文件) | 运行时(动态生成代理类字节码并加载到 JVM) |
+| 实现方式 | 手动编写代理类,需与目标类实现同一接口,一对一绑定 | 无需手动编写代理类,通过 `Handler`/`Interceptor` 封装增强逻辑,一对多复用 |
+| 接口依赖 | 必须实现接口(代理类与目标类遵循同一接口规范) | 支持代理接口或直接代理实现类 |
+| 代码量与维护性 | 代码量大(目标类越多,代理类越多),维护成本高;接口新增方法时,目标类与代理类需同步修改 | 代码量极少(通用增强逻辑可复用),维护性好;与接口解耦,接口变更不影响代理逻辑 |
+| 核心优势 | 实现简单、逻辑直观,无额外框架依赖 | 灵活性强、复用性高,降低重复编码,适配复杂场景 |
+| 典型应用场景 | 简单的装饰器模式、少量固定类的增强需求 | Spring AOP、RPC 框架(如 Dubbo)、ORM 框架 |
## 5. 总结
diff --git a/docs/java/basis/reflection.md b/docs/java/basis/reflection.md
index 769d85e620f..c4a233e908f 100644
--- a/docs/java/basis/reflection.md
+++ b/docs/java/basis/reflection.md
@@ -1,8 +1,13 @@
---
title: Java 反射机制详解
+description: 深入讲解Java反射机制原理与应用:掌握Class、Method、Field核心API,理解反射在Spring、MyBatis等框架中的应用,学习动态代理实现。
category: Java
tag:
- Java基础
+head:
+ - - meta
+ - name: keywords
+ content: Java反射,反射机制,Class类,Method方法,Field字段,动态代理,框架原理,运行时操作
---
## 何为反射?
@@ -116,7 +121,7 @@ public class TargetObject {
}
```
-2. 使用反射操作这个类的方法以及参数
+2. 使用反射操作这个类的方法以及属性
```java
package cn.javaguide;
@@ -177,7 +182,7 @@ I love JavaGuide
value is JavaGuide
```
-**注意** : 有读者提到上面代码运行会抛出 `ClassNotFoundException` 异常,具体原因是你没有下面把这段代码的包名替换成自己创建的 `TargetObject` 所在的包 。
+**注意** : 有读者提到上面代码运行会抛出 `ClassNotFoundException` 异常,具体原因是你没有下面把这段代码的包名替换成自己创建的 `TargetObject` 所在的包 。
可以参考: 这篇文章。
```java
diff --git a/docs/java/basis/serialization.md b/docs/java/basis/serialization.md
index 406a7ce38d5..254032b9ef7 100644
--- a/docs/java/basis/serialization.md
+++ b/docs/java/basis/serialization.md
@@ -1,8 +1,13 @@
---
title: Java 序列化详解
+description: 深入解析Java序列化与反序列化机制:详解Serializable接口、transient关键字、serialVersionUID作用、序列化协议选择及RPC、缓存等应用场景。
category: Java
tag:
- Java基础
+head:
+ - - meta
+ - name: keywords
+ content: Java序列化,反序列化,Serializable接口,transient关键字,serialVersionUID,序列化协议,对象持久化
---
## 什么是序列化和反序列化?
@@ -11,8 +16,8 @@ tag:
简单来说:
-- **序列化**:将数据结构或对象转换成二进制字节流的过程
-- **反序列化**:将在序列化过程中所生成的二进制字节流转换成数据结构或者对象的过程
+- **序列化**:将数据结构或对象转换成可以存储或传输的形式,通常是二进制字节流,也可以是 JSON, XML 等文本格式
+- **反序列化**:将在序列化过程中所生成的数据转换为原始数据结构或者对象的过程
对于 Java 这种面向对象编程语言来说,我们序列化的都是对象(Object)也就是实例化后的类(Class),但是在 C++这种半面向对象的语言中,struct(结构体)定义的是数据结构类型,而 class 对应的是对象类型。
@@ -83,7 +88,11 @@ public class RpcRequest implements Serializable {
~~`static` 修饰的变量是静态变量,位于方法区,本身是不会被序列化的。 `static` 变量是属于类的而不是对象。你反序列之后,`static` 变量的值就像是默认赋予给了对象一样,看着就像是 `static` 变量被序列化,实际只是假象罢了。~~
-**🐛 修正(参见:[issue#2174](https://github.com/Snailclimb/JavaGuide/issues/2174))**:`static` 修饰的变量是静态变量,位于方法区,本身是不会被序列化的。但是,`serialVersionUID` 的序列化做了特殊处理,在序列化时,会将 `serialVersionUID` 序列化到二进制字节流中;在反序列化时,也会解析它并做一致性判断。
+**🐛 修正(参见:[issue#2174](https://github.com/Snailclimb/JavaGuide/issues/2174))**:
+
+通常情况下,`static` 变量是属于类的,不属于任何单个对象实例,所以它们本身不会被包含在对象序列化的数据流里。序列化保存的是对象的状态(也就是实例变量的值)。然而,`serialVersionUID` 是一个特例,`serialVersionUID` 的序列化做了特殊处理。关键在于,`serialVersionUID` 不是作为对象状态的一部分被序列化的,而是被序列化机制本身用作一个特殊的“指纹”或“版本号”。
+
+当一个对象被序列化时,`serialVersionUID` 会被写入到序列化的二进制流中(像是在保存一个版本号,而不是保存 `static` 变量本身的状态);在反序列化时,也会解析它并做一致性判断,以此来验证序列化对象的版本一致性。如果两者不匹配,反序列化过程将抛出 `InvalidClassException`,因为这通常意味着序列化的类的定义已经发生了更改,可能不再兼容。
官方说明如下:
@@ -91,13 +100,13 @@ public class RpcRequest implements Serializable {
>
> 如果想显式指定 `serialVersionUID` ,则需要在类中使用 `static` 和 `final` 关键字来修饰一个 `long` 类型的变量,变量名字必须为 `"serialVersionUID"` 。
-也就是说,`serialVersionUID` 只是用来被 JVM 识别,实际并没有被序列化。
+也就是说,`serialVersionUID` 本身(作为 static 变量)确实不作为对象状态被序列化。但是,它的值被 Java 序列化机制特殊处理了——作为一个版本标识符被读取并写入序列化流中,用于在反序列化时进行版本兼容性检查。
**如果有些字段不想进行序列化怎么办?**
对于不想进行序列化的变量,可以使用 `transient` 关键字修饰。
-`transient` 关键字的作用是:阻止实例中那些用此关键字修饰的的变量序列化;当对象被反序列化时,被 `transient` 修饰的变量值不会被持久化和恢复。
+`transient` 关键字的作用是:阻止实例中那些用此关键字修饰的变量序列化;当对象被反序列化时,被 `transient` 修饰的变量值不会被持久化和恢复。
关于 `transient` 还有几点注意:
@@ -198,7 +207,7 @@ GitHub 地址:[https://github.com/protocolbuffers/protobuf](https://github.com
### ProtoStuff
-由于 Protobuf 的易用性,它的哥哥 Protostuff 诞生了。
+由于 Protobuf 的易用性较差,它的哥哥 Protostuff 诞生了。
protostuff 基于 Google protobuf,但是提供了更多的功能和更简易的用法。虽然更加易用,但是不代表 ProtoStuff 性能更差。
diff --git a/docs/java/basis/spi.md b/docs/java/basis/spi.md
index baada46fb95..7392d0f3168 100644
--- a/docs/java/basis/spi.md
+++ b/docs/java/basis/spi.md
@@ -1,22 +1,20 @@
---
title: Java SPI 机制详解
+description: 全面讲解Java SPI机制原理与应用:理解ServiceLoader服务发现机制、SPI在JDBC/Dubbo/Spring中的应用、与API对比及最佳实践。
category: Java
tag:
- Java基础
head:
- - meta
- name: keywords
- content: Java SPI机制
- - - meta
- - name: description
- content: SPI 即 Service Provider Interface ,字面意思就是:“服务提供者的接口”,我的理解是:专门提供给服务提供者或者扩展框架功能的开发者去使用的一个接口。SPI 将服务接口和具体的服务实现分离开来,将服务调用方和服务实现者解耦,能够提升程序的扩展性、可维护性。修改或者替换服务实现并不需要修改调用方。
+ content: Java SPI,SPI机制,ServiceLoader,服务发现,插件化,JDBC驱动加载,Dubbo扩展,SPI应用
---
> 本文来自 [Kingshion](https://github.com/jjx0708) 投稿。欢迎更多朋友参与到 JavaGuide 的维护工作,这是一件非常有意义的事情。详细信息请看:[JavaGuide 贡献指南](https://javaguide.cn/javaguide/contribution-guideline.html) 。
-在面向对象的设计原则中,一般推荐模块之间基于接口编程,通常情况下调用方模块是不会感知到被调用方模块的内部具体实现。一旦代码里面涉及具体实现类,就违反了开闭原则。如果需要替换一种实现,就需要修改代码。
+面向对象设计鼓励模块间基于接口而非具体实现编程,以降低模块间的耦合,遵循依赖倒置原则,并支持开闭原则(对扩展开放,对修改封闭)。然而,直接依赖具体实现会导致在替换实现时需要修改代码,违背了开闭原则。为了解决这个问题,SPI 应运而生,它提供了一种服务发现机制,允许在程序外部动态指定具体实现。这与控制反转(IoC)的思想相似,将组件装配的控制权移交给了程序之外。
-为了实现在模块装配的时候不用在程序里面动态指明,这就需要一种服务发现机制。Java SPI 就是提供了这样一个机制:**为某个接口寻找服务实现的机制。这有点类似 IoC 的思想,将装配的控制权移交到了程序之外。**
+SPI 机制也解决了 Java 类加载体系中双亲委派模型带来的限制。[双亲委派模型](https://javaguide.cn/java/jvm/classloader.html)虽然保证了核心库的安全性和一致性,但也限制了核心库或扩展库加载应用程序类路径上的类(通常由第三方实现)。SPI 允许核心或扩展库定义服务接口,第三方开发者提供并部署实现,SPI 服务加载机制则在运行时动态发现并加载这些实现。例如,JDBC 4.0 及之后版本利用 SPI 自动发现和加载数据库驱动,开发者只需将驱动 JAR 包放置在类路径下即可,无需使用`Class.forName()`显式加载驱动类。
## SPI 介绍
@@ -28,21 +26,20 @@ SPI 将服务接口和具体的服务实现分离开来,将服务调用方和
很多框架都使用了 Java 的 SPI 机制,比如:Spring 框架、数据库加载驱动、日志接口、以及 Dubbo 的扩展实现等等。
-
+
### SPI 和 API 有什么区别?
**那 SPI 和 API 有啥区别?**
-说到 SPI 就不得不说一下 API 了,从广义上来说它们都属于接口,而且很容易混淆。下面先用一张图说明一下:
-
-
+说到 SPI 就不得不说一下 API(Application Programming Interface) 了,从广义上来说它们都属于接口,而且很容易混淆。下面先用一张图说明一下:
-一般模块之间都是通过通过接口进行通讯,那我们在服务调用方和服务实现方(也称服务提供者)之间引入一个“接口”。
+
-当实现方提供了接口和实现,我们可以通过调用实现方的接口从而拥有实现方给我们提供的能力,这就是 API ,这种接口和实现都是放在实现方的。
+一般模块之间都是通过接口进行通讯,因此我们在服务调用方和服务实现方(也称服务提供者)之间引入一个“接口”。
-当接口存在于调用方这边时,就是 SPI ,由接口调用方确定接口规则,然后由不同的厂商去根据这个规则对这个接口进行实现,从而提供服务。
+- 当实现方提供了接口和实现,我们可以通过调用实现方的接口从而拥有实现方给我们提供的能力,这就是 **API**。这种情况下,接口和实现都是放在实现方的包中。调用方通过接口调用实现方的功能,而不需要关心具体的实现细节。
+- 当接口存在于调用方这边时,这就是 **SPI** 。由接口调用方确定接口规则,然后由不同的厂商根据这个规则对这个接口进行实现,从而提供服务。
举个通俗易懂的例子:公司 H 是一家科技公司,新设计了一款芯片,然后现在需要量产了,而市面上有好几家芯片制造业公司,这个时候,只要 H 公司指定好了这芯片生产的标准(定义好了接口标准),那么这些合作的芯片公司(服务提供者)就按照标准交付自家特色的芯片(提供不同方案的实现,但是给出来的结果是一样的)。
@@ -332,6 +329,10 @@ public void reload() {
}
```
+其解决第三方类加载的机制其实就蕴含在 `ClassLoader cl = Thread.currentThread().getContextClassLoader();` 中,`cl` 就是**线程上下文类加载器**(Thread Context ClassLoader)。这是每个线程持有的类加载器,JDK 的设计允许应用程序或容器(如 Web 应用服务器)设置这个类加载器,以便核心类库能够通过它来加载应用程序类。
+
+线程上下文类加载器默认情况下是应用程序类加载器(Application ClassLoader),它负责加载 classpath 上的类。当核心库需要加载应用程序提供的类时,它可以使用线程上下文类加载器来完成。这样,即使是由引导类加载器加载的核心库代码,也能够加载并使用由应用程序类加载器加载的类。
+
根据代码的调用顺序,在 `reload()` 方法中是通过一个内部类 `LazyIterator` 实现的。先继续往下面看。
`ServiceLoader` 实现了 `Iterable` 接口的方法后,具有了迭代的能力,在这个 `iterator` 方法被调用时,首先会在 `ServiceLoader` 的 `Provider` 缓存中进行查找,如果缓存中没有命中那么则在 `LazyIterator` 中进行查找。
@@ -552,7 +553,7 @@ public class MyServiceLoader {
其实不难发现,SPI 机制的具体实现本质上还是通过反射完成的。即:**我们按照规定将要暴露对外使用的具体实现类在 `META-INF/services/` 文件下声明。**
-另外,SPI 机制在很多框架中都有应用:Spring 框架的基本原理也是类似的方式。还有 Dubbo 框架提供同样的 SPI 扩展机制,只不过 Dubbo 和 spring 框架中的 SPI 机制具体实现方式跟咱们今天学得这个有些细微的区别,不过整体的原理都是一致的,相信大家通过对 JDK 中 SPI 机制的学习,能够一通百通,加深对其他高深框的理解。
+另外,SPI 机制在很多框架中都有应用:Spring 框架的基本原理也是类似的方式。还有 Dubbo 框架提供同样的 SPI 扩展机制,只不过 Dubbo 和 spring 框架中的 SPI 机制具体实现方式跟咱们今天学得这个有些细微的区别,不过整体的原理都是一致的,相信大家通过对 JDK 中 SPI 机制的学习,能够一通百通,加深对其他高深框架的理解。
通过 SPI 机制能够大大地提高接口设计的灵活性,但是 SPI 机制也存在一些缺点,比如:
diff --git a/docs/java/basis/syntactic-sugar.md b/docs/java/basis/syntactic-sugar.md
index 64ffd4cde17..615b008e43e 100644
--- a/docs/java/basis/syntactic-sugar.md
+++ b/docs/java/basis/syntactic-sugar.md
@@ -1,15 +1,13 @@
---
title: Java 语法糖详解
+description: 深入剖析Java语法糖原理:详解自动装箱拆箱、泛型擦除、增强for、可变参数、枚举、Lambda等语法糖的编译期实现机制,避免使用误区。
category: Java
tag:
- Java基础
head:
- - meta
- name: keywords
- content: Java 语法糖
- - - meta
- - name: description
- content: 这篇文章介绍了 12 种 Java 中常用的语法糖。所谓语法糖就是提供给开发人员便于开发的一种语法而已。但是这种语法只有开发人员认识。要想被执行,需要进行解糖,即转成 JVM 认识的语法。当我们把语法糖解糖之后,你就会发现其实我们日常使用的这些方便的语法,其实都是一些其他更简单的语法构成的。有了这些语法糖,我们在日常开发的时候可以大大提升效率,但是同时也要避免过渡使用。使用之前最好了解下原理,避免掉坑。
+ content: Java语法糖,自动装箱拆箱,泛型擦除,增强for循环,可变参数,枚举,内部类,Lambda表达式,语法糖原理
---
> 作者:Hollis
@@ -246,7 +244,7 @@ public static transient void print(String strs[])
}
```
-从反编译后代码可以看出,可变参数在被使用的时候,他首先会创建一个数组,数组的长度就是调用该方法是传递的实参的个数,然后再把参数值全部放到这个数组当中,然后再把这个数组作为参数传递到被调用的方法中。(注:`trasient` 仅在修饰成员变量时有意义,此处 “修饰方法” 是由于在 javassist 中使用相同数值分别表示 `trasient` 以及 `vararg`,见 [此处](https://github.com/jboss-javassist/javassist/blob/7302b8b0a09f04d344a26ebe57f29f3db43f2a3e/src/main/javassist/bytecode/AccessFlag.java#L32)。)
+从反编译后代码可以看出,可变参数在被使用的时候,他首先会创建一个数组,数组的长度就是调用该方法是传递的实参的个数,然后再把参数值全部放到这个数组当中,然后再把这个数组作为参数传递到被调用的方法中。(注:`transient` 仅在修饰成员变量时有意义,此处 “修饰方法” 是由于在 javassist 中使用相同数值分别表示 `transient` 以及 `vararg`,见 [此处](https://github.com/jboss-javassist/javassist/blob/7302b8b0a09f04d344a26ebe57f29f3db43f2a3e/src/main/javassist/bytecode/AccessFlag.java#L32)。)
### 枚举
@@ -263,6 +261,7 @@ public enum t {
然后我们使用反编译,看看这段代码到底是怎么实现的,反编译后代码内容如下:
```java
+//Java编译器会自动将枚举名处理为合法类名(首字母大写): t -> T
public final class T extends Enum
{
private T(String s, int i)
@@ -308,7 +307,7 @@ public final class T extends Enum
**内部类之所以也是语法糖,是因为它仅仅是一个编译时的概念,`outer.java`里面定义了一个内部类`inner`,一旦编译成功,就会生成两个完全不同的`.class`文件了,分别是`outer.class`和`outer$inner.class`。所以内部类的名字完全可以和它的外部类名字相同。**
```java
-public class OutterClass {
+public class OuterClass {
private String userName;
public String getUserName() {
@@ -337,10 +336,10 @@ public class OutterClass {
}
```
-以上代码编译后会生成两个 class 文件:`OutterClass$InnerClass.class`、`OutterClass.class` 。当我们尝试对`OutterClass.class`文件进行反编译的时候,命令行会打印以下内容:`Parsing OutterClass.class...Parsing inner class OutterClass$InnerClass.class... Generating OutterClass.jad` 。他会把两个文件全部进行反编译,然后一起生成一个`OutterClass.jad`文件。文件内容如下:
+以上代码编译后会生成两个 class 文件:`OuterClass$InnerClass.class`、`OuterClass.class` 。当我们尝试对`OuterClass.class`文件进行反编译的时候,命令行会打印以下内容:`Parsing OuterClass.class...Parsing inner class OuterClass$InnerClass.class... Generating OuterClass.jad` 。他会把两个文件全部进行反编译,然后一起生成一个`OuterClass.jad`文件。文件内容如下:
```java
-public class OutterClass
+public class OuterClass
{
class InnerClass
{
@@ -353,16 +352,16 @@ public class OutterClass
this.name = name;
}
private String name;
- final OutterClass this$0;
+ final OuterClass this$0;
InnerClass()
{
- this.this$0 = OutterClass.this;
+ this.this$0 = OuterClass.this;
super();
}
}
- public OutterClass()
+ public OuterClass()
{
}
public String getUserName()
@@ -379,6 +378,83 @@ public class OutterClass
}
```
+**为什么内部类可以使用外部类的 private 属性**:
+
+我们在 InnerClass 中增加一个方法,打印外部类的 userName 属性
+
+```java
+//省略其他属性
+public class OuterClass {
+ private String userName;
+ ......
+ class InnerClass{
+ ......
+ public void printOut(){
+ System.out.println("Username from OuterClass:"+userName);
+ }
+ }
+}
+
+// 此时,使用javap -p命令对OuterClass反编译结果:
+public classOuterClass {
+ private String userName;
+ ......
+ static String access$000(OuterClass);
+}
+// 此时,InnerClass的反编译结果:
+class OuterClass$InnerClass {
+ final OuterClass this$0;
+ ......
+ public void printOut();
+}
+
+```
+
+实际上,在编译完成之后,inner 实例内部会有指向 outer 实例的引用`this$0`,但是简单的`outer.name`是无法访问 private 属性的。从反编译的结果可以看到,outer 中会有一个桥方法`static String access$000(OuterClass)`,恰好返回 String 类型,即 userName 属性。正是通过这个方法实现内部类访问外部类私有属性。所以反编译后的`printOut()`方法大致如下:
+
+```java
+public void printOut() {
+ System.out.println("Username from OuterClass:" + OuterClass.access$000(this.this$0));
+}
+```
+
+补充:
+
+1. 匿名内部类、局部内部类、静态内部类也是通过桥方法来获取 private 属性。
+2. 静态内部类没有`this$0`的引用
+3. 匿名内部类、局部内部类通过复制使用局部变量,该变量初始化之后就不能被修改。以下是一个案例:
+
+```java
+public class OuterClass {
+ private String userName;
+
+ public void test(){
+ //这里i初始化为1后就不能再被修改
+ int i=1;
+ class Inner{
+ public void printName(){
+ System.out.println(userName);
+ System.out.println(i);
+ }
+ }
+ }
+}
+```
+
+反编译后:
+
+```java
+//javap命令反编译Inner的结果
+//i被复制进内部类,且为final
+class OuterClass$1Inner {
+ final int val$i;
+ final OuterClass this$0;
+ OuterClass$1Inner();
+ public void printName();
+}
+
+```
+
### 条件编译
—般情况下,程序中的每一行代码都要参加编译。但有时候出于对程序代码优化的考虑,希望只对其中一部分内容进行编译,此时就需要在程序中加上条件,让编译器只对满足条件的代码进行编译,将不满足条件的代码舍弃,这就是条件编译。
@@ -423,7 +499,7 @@ public class ConditionalCompilation
首先,我们发现,在反编译后的代码中没有`System.out.println("Hello, ONLINE!");`,这其实就是条件编译。当`if(ONLINE)`为 false 的时候,编译器就没有对其内的代码进行编译。
-所以,**Java 语法的条件编译,是通过判断条件为常量的 if 语句实现的。其原理也是 Java 语言的语法糖。根据 if 判断条件的真假,编译器直接把分支为 false 的代码块消除。通过该方式实现的条件编译,必须在方法体内实现,而无法在正整个 Java 类的结构或者类的属性上进行条件编译,这与 C/C++的条件编译相比,确实更有局限性。在 Java 语言设计之初并没有引入条件编译的功能,虽有局限,但是总比没有更强。**
+所以,**Java 语法的条件编译,是通过判断条件为常量的 if 语句实现的。其原理也是 Java 语言的语法糖。根据 if 判断条件的真假,编译器直接把分支为 false 的代码块消除。通过该方式实现的条件编译,必须在方法体内实现,而无法在整个 Java 类的结构或者类的属性上进行条件编译,这与 C/C++的条件编译相比,确实更有局限性。在 Java 语言设计之初并没有引入条件编译的功能,虽有局限,但是总比没有更强。**
### 断言
@@ -612,36 +688,21 @@ public static transient void main(String args[])
throwable = throwable2;
throw throwable2;
}
- if(br != null)
- if(throwable != null)
- try
- {
- br.close();
- }
- catch(Throwable throwable1)
- {
- throwable.addSuppressed(throwable1);
- }
- else
- br.close();
- break MISSING_BLOCK_LABEL_113;
- Exception exception;
- exception;
+ finally
+ {
if(br != null)
if(throwable != null)
try
{
br.close();
}
- catch(Throwable throwable3)
- {
- throwable.addSuppressed(throwable3);
+ catch(Throwable throwable1)
+ {
+ throwable.addSuppressed(throwable1);
}
else
br.close();
- throw exception;
- IOException ioexception;
- ioexception;
+ }
}
}
```
diff --git a/docs/java/basis/unsafe.md b/docs/java/basis/unsafe.md
index 423707532d9..cc624113852 100644
--- a/docs/java/basis/unsafe.md
+++ b/docs/java/basis/unsafe.md
@@ -1,8 +1,13 @@
---
title: Java 魔法类 Unsafe 详解
+description: 深入解析Java魔法类Unsafe:讲解Unsafe直接内存操作、CAS原子操作、对象实例化等底层能力,理解JUC并发工具类实现原理及使用风险。
category: Java
tag:
- Java基础
+head:
+ - - meta
+ - name: keywords
+ content: Unsafe类,内存操作,CAS原子操作,堆外内存,直接内存,sun.misc.Unsafe,JUC底层实现
---
> 本文整理完善自下面这两篇优秀的文章:
@@ -10,6 +15,8 @@ tag:
> - [Java 魔法类:Unsafe 应用解析 - 美团技术团队 -2019](https://tech.meituan.com/2019/02/14/talk-about-java-magic-class-unsafe.html)
> - [Java 双刃剑之 Unsafe 类详解 - 码农参上 - 2021](https://xie.infoq.cn/article/8b6ed4195e475bfb32dacc5cb)
+
+
阅读过 JUC 源码的同学,一定会发现很多并发工具类都调用了一个叫做 `Unsafe` 的类。
那这个类主要是用来干什么的呢?有什么使用场景呢?这篇文章就带你搞清楚!
@@ -132,20 +139,34 @@ public native void freeMemory(long address);
```java
private void memoryTest() {
int size = 4;
- long addr = unsafe.allocateMemory(size);
- long addr3 = unsafe.reallocateMemory(addr, size * 2);
- System.out.println("addr: "+addr);
- System.out.println("addr3: "+addr3);
+ // 1. 分配初始内存
+ long oldAddr = unsafe.allocateMemory(size);
+ System.out.println("Initial address: " + oldAddr);
+
+ // 2. 向初始内存写入数据
+ unsafe.putInt(oldAddr, 16843009); // 写入 0x01010101
+ System.out.println("Value at oldAddr: " + unsafe.getInt(oldAddr));
+
+ // 3. 重新分配内存
+ long newAddr = unsafe.reallocateMemory(oldAddr, size * 2);
+ System.out.println("New address: " + newAddr);
+
+ // 4. reallocateMemory 已经将数据从 oldAddr 拷贝到 newAddr
+ // 所以 newAddr 的前4个字节应该和 oldAddr 的内容一样
+ System.out.println("Value at newAddr (first 4 bytes): " + unsafe.getInt(newAddr));
+
+ // 关键:之后所有操作都应该基于 newAddr,oldAddr 已失效!
try {
- unsafe.setMemory(null,addr ,size,(byte)1);
- for (int i = 0; i < 2; i++) {
- unsafe.copyMemory(null,addr,null,addr3+size*i,4);
- }
- System.out.println(unsafe.getInt(addr));
- System.out.println(unsafe.getLong(addr3));
- }finally {
- unsafe.freeMemory(addr);
- unsafe.freeMemory(addr3);
+ // 5. 在新内存块的后半部分写入新数据
+ unsafe.putInt(newAddr + size, 33686018); // 写入 0x02020202
+
+ // 6. 读取整个8字节的long值
+ System.out.println("Value at newAddr (full 8 bytes): " + unsafe.getLong(newAddr));
+
+ } finally {
+ // 7. 只释放最后有效的内存地址
+ unsafe.freeMemory(newAddr);
+ // 如果尝试 freeMemory(oldAddr),将会导致 double free 错误!
}
}
```
@@ -153,35 +174,56 @@ private void memoryTest() {
先看结果输出:
```plain
-addr: 2433733895744
-addr3: 2433733894944
-16843009
-72340172838076673
+Initial address: 140467048086752
+Value at oldAddr: 16843009
+New address: 140467048086752
+Value at newAddr (first 4 bytes): 16843009
+Value at newAddr (full 8 bytes): 144680345659310337
```
-分析一下运行结果,首先使用`allocateMemory`方法申请 4 字节长度的内存空间,调用`setMemory`方法向每个字节写入内容为`byte`类型的 1,当使用 Unsafe 调用`getInt`方法时,因为一个`int`型变量占 4 个字节,会一次性读取 4 个字节,组成一个`int`的值,对应的十进制结果为 16843009。
+`reallocateMemory` 的行为类似于 C 语言中的 realloc 函数,它会尝试在不移动数据的情况下扩展或收缩内存块。其行为主要有两种情况:
-你可以通过下图理解这个过程:
+1. **原地扩容**:如果当前内存块后面有足够的连续空闲空间,`reallocateMemory` 会直接在原地址上扩展内存,并返回原始地址。
+2. **异地扩容**:如果当前内存块后面空间不足,它会寻找一个新的、足够大的内存区域,将旧数据拷贝过去,然后释放旧的内存地址,并返回新地址。
-
+**结合本次的运行结果,我们可以进行如下分析:**
-在代码中调用`reallocateMemory`方法重新分配了一块 8 字节长度的内存空间,通过比较`addr`和`addr3`可以看到和之前申请的内存地址是不同的。在代码中的第二个 for 循环里,调用`copyMemory`方法进行了两次内存的拷贝,每次拷贝内存地址`addr`开始的 4 个字节,分别拷贝到以`addr3`和`addr3+4`开始的内存空间上:
+**第一步:初始分配与写入**
-
+- `unsafe.allocateMemory(size)` 分配了 4 字节的堆外内存,地址为 `140467048086752`。
+- `unsafe.putInt(oldAddr, 16843009)` 向该地址写入了 int 值 `16843009`,其十六进制表示为 `0x01010101`。`getInt` 读取正确,证明写入成功。
-拷贝完成后,使用`getLong`方法一次性读取 8 个字节,得到`long`类型的值为 72340172838076673。
+**第二步:原地内存扩容**
-需要注意,通过这种方式分配的内存属于 堆外内存 ,是无法进行垃圾回收的,需要我们把这些内存当做一种资源去手动调用`freeMemory`方法进行释放,否则会产生内存泄漏。通用的操作内存方式是在`try`中执行对内存的操作,最终在`finally`块中进行内存的释放。
+- `long newAddr = unsafe.reallocateMemory(oldAddr, size * 2)` 尝试将内存块扩容至 8 字节。
+- 观察输出 New address: `140467048086752`,我们发现 `newAddr` 与 `oldAddr` 的值**完全相同**。
+- 这表明本次操作触发了“原地扩容”。系统在原地址 `140467048086752` 后面找到了足够的空间,直接将内存块扩展到了 8 字节。在这个过程中,旧的地址 `oldAddr` 依然有效,并且就是 `newAddr`,数据也并未发生移动。
-**为什么要使用堆外内存?**
+**第三步:验证数据与写入新数据**
-- 对垃圾回收停顿的改善。由于堆外内存是直接受操作系统管理而不是 JVM,所以当我们使用堆外内存时,即可保持较小的堆内内存规模。从而在 GC 时减少回收停顿对于应用的影响。
-- 提升程序 I/O 操作的性能。通常在 I/O 通信过程中,会存在堆内内存到堆外内存的数据拷贝操作,对于需要频繁进行内存间数据拷贝且生命周期较短的暂存数据,都建议存储到堆外内存。
+- `unsafe.getInt(newAddr)` 再次读取前 4 个字节,结果仍是 `16843009`,验证了原数据完好无损。
+- `unsafe.putInt(newAddr + size, 33686018)` 在扩容出的后 4 个字节(偏移量为 4)写入了新的 int 值 `33686018`(十六进制为 `0x02020202`)。
+
+**第四步:读取完整数据**
+
+- `unsafe.getLong(newAddr)` 从起始地址读取一个 long 值(8 字节)。此时内存中的 8 字节内容为 `0x01010101` (低地址) 和 `0x02020202` (高地址) 的拼接。
+- 在小端字节序(Little-Endian)的机器上,这 8 字节在内存中会被解释为十六进制数 `0x0202020201010101`。
+- 这个十六进制数转换为十进制,结果正是 `144680345659310337`。这完美地解释了最终的输出结果。
+
+**第五步:安全的内存释放**
+
+- `finally` 块中,`unsafe.freeMemory(newAddr)` 安全地释放了整个 8 字节的内存块。
+- 由于本次是原地扩容(`oldAddr == newAddr`),所以即使错误地多写一句 `freeMemory(oldAddr)` 也会导致二次释放的严重错误。
#### 典型应用
`DirectByteBuffer` 是 Java 用于实现堆外内存的一个重要类,通常用在通信过程中做缓冲池,如在 Netty、MINA 等 NIO 框架中应用广泛。`DirectByteBuffer` 对于堆外内存的创建、使用、销毁等逻辑均由 Unsafe 提供的堆外内存 API 来实现。
+**为什么要使用堆外内存?**
+
+- 对垃圾回收停顿的改善。由于堆外内存是直接受操作系统管理而不是 JVM,所以当我们使用堆外内存时,即可保持较小的堆内内存规模。从而在 GC 时减少回收停顿对于应用的影响。
+- 提升程序 I/O 操作的性能。通常在 I/O 通信过程中,会存在堆内内存到堆外内存的数据拷贝操作,对于需要频繁进行内存间数据拷贝且生命周期较短的暂存数据,都建议存储到堆外内存。
+
下图为 `DirectByteBuffer` 构造函数,创建 `DirectByteBuffer` 的时候,通过 `Unsafe.allocateMemory` 分配内存、`Unsafe.setMemory` 进行内存初始化,而后构建 `Cleaner` 对象用于跟踪 `DirectByteBuffer` 对象的垃圾回收,以实现当 `DirectByteBuffer` 被垃圾回收时,分配的堆外内存一起被释放。
```java
@@ -421,7 +463,7 @@ public void objTest() throws Exception{
#### 典型应用
-- **常规对象实例化方式**:我们通常所用到的创建对象的方式,从本质上来讲,都是通过 new 机制来实现对象的创建。但是,new 机制有个特点就是当类只提供有参的构造函数且无显示声明无参构造函数时,则必须使用有参构造函数进行对象构造,而使用有参构造函数时,必须传递相应个数的参数才能完成对象实例化。
+- **常规对象实例化方式**:我们通常所用到的创建对象的方式,从本质上来讲,都是通过 new 机制来实现对象的创建。但是,new 机制有个特点就是当类只提供有参的构造函数且无显式声明无参构造函数时,则必须使用有参构造函数进行对象构造,而使用有参构造函数时,必须传递相应个数的参数才能完成对象实例化。
- **非常规的实例化方式**:而 Unsafe 中提供 allocateInstance 方法,仅通过 Class 对象就可以创建此类的实例对象,而且不需要调用其构造函数、初始化代码、JVM 安全检查等。它抑制修饰符检测,也就是即使构造器是 private 修饰的也能通过此方法实例化,只需提类对象即可创建相应的对象。由于这种特性,allocateInstance 在 java.lang.invoke、Objenesis(提供绕过类构造器的对象生成方式)、Gson(反序列化时用到)中都有相应的应用。
### 数组操作
@@ -514,11 +556,94 @@ private void increment(int x){
1 2 3 4 5 6 7 8 9
```
-在上面的例子中,使用两个线程去修改`int`型属性`a`的值,并且只有在`a`的值等于传入的参数`x`减一时,才会将`a`的值变为`x`,也就是实现对`a`的加一的操作。流程如下所示:
+如果你把上面这段代码贴到 IDE 中运行,会发现并不能得到目标输出结果。有朋友已经在 Github 上指出了这个问题:[issue#2650](https://github.com/Snailclimb/JavaGuide/issues/2650)。下面是修正后的代码:
+
+```java
+private volatile int a = 0; // 共享变量,初始值为 0
+private static final Unsafe unsafe;
+private static final long fieldOffset;
+
+static {
+ try {
+ // 获取 Unsafe 实例
+ Field theUnsafe = Unsafe.class.getDeclaredField("theUnsafe");
+ theUnsafe.setAccessible(true);
+ unsafe = (Unsafe) theUnsafe.get(null);
+ // 获取 a 字段的内存偏移量
+ fieldOffset = unsafe.objectFieldOffset(CasTest.class.getDeclaredField("a"));
+ } catch (Exception e) {
+ throw new RuntimeException("Failed to initialize Unsafe or field offset", e);
+ }
+}
+
+public static void main(String[] args) {
+ CasTest casTest = new CasTest();
+
+ Thread t1 = new Thread(() -> {
+ for (int i = 1; i <= 4; i++) {
+ casTest.incrementAndPrint(i);
+ }
+ });
+
+ Thread t2 = new Thread(() -> {
+ for (int i = 5; i <= 9; i++) {
+ casTest.incrementAndPrint(i);
+ }
+ });
+
+ t1.start();
+ t2.start();
+
+ // 等待线程结束,以便观察完整输出 (可选,用于演示)
+ try {
+ t1.join();
+ t2.join();
+ } catch (InterruptedException e) {
+ Thread.currentThread().interrupt();
+ }
+}
+
+// 将递增和打印操作封装在一个原子性更强的方法内
+private void incrementAndPrint(int targetValue) {
+ while (true) {
+ int currentValue = a; // 读取当前 a 的值
+ // 只有当 a 的当前值等于目标值的前一个值时,才尝试更新
+ if (currentValue == targetValue - 1) {
+ if (unsafe.compareAndSwapInt(this, fieldOffset, currentValue, targetValue)) {
+ // CAS 成功,说明成功将 a 更新为 targetValue
+ System.out.print(targetValue + " ");
+ break; // 成功更新并打印后退出循环
+ }
+ // 如果 CAS 失败,意味着在读取 currentValue 和执行 CAS 之间,a 的值被其他线程修改了,
+ // 此时 currentValue 已经不是 a 的最新值,需要重新读取并重试。
+ }
+ // 如果 currentValue != targetValue - 1,说明还没轮到当前线程更新,
+ // 或者已经被其他线程更新超过了,让出CPU给其他线程机会。
+ // 对于严格顺序递增的场景,如果 current > targetValue - 1,可能意味着逻辑错误或死循环,
+ // 但在此示例中,我们期望线程能按顺序执行。
+ Thread.yield(); // 提示CPU调度器可以切换线程,减少无效自旋
+ }
+}
+```
+
+在上述例子中,我们创建了两个线程,它们都尝试修改共享变量 a。每个线程在调用 `incrementAndPrint(targetValue)` 方法时:
+
+1. 会先读取 a 的当前值 `currentValue`。
+2. 检查 `currentValue` 是否等于 `targetValue - 1` (即期望的前一个值)。
+3. 如果条件满足,则调用`unsafe.compareAndSwapInt()` 尝试将 `a` 从 `currentValue` 更新到 `targetValue`。
+4. 如果 CAS 操作成功(返回 true),则打印 `targetValue` 并退出循环。
+5. 如果 CAS 操作失败,或者 `currentValue` 不满足条件,则当前线程会继续循环(自旋),并通过 `Thread.yield()` 尝试让出 CPU,直到成功更新并打印或者条件满足。
+
+这种机制确保了每个数字(从 1 到 9)只会被成功设置并打印一次,并且是按顺序进行的。

-需要注意的是,在调用`compareAndSwapInt`方法后,会直接返回`true`或`false`的修改结果,因此需要我们在代码中手动添加自旋的逻辑。在`AtomicInteger`类的设计中,也是采用了将`compareAndSwapInt`的结果作为循环条件,直至修改成功才退出死循环的方式来实现的原子性的自增操作。
+需要注意的是:
+
+1. **自旋逻辑:** `compareAndSwapInt` 方法本身只执行一次比较和交换操作,并立即返回结果。因此,为了确保操作最终成功(在值符合预期的情况下),我们需要在代码中显式地实现自旋逻辑(如 `while(true)` 循环),不断尝试直到 CAS 操作成功。
+2. **`AtomicInteger` 的实现:** JDK 中的 `java.util.concurrent.atomic.AtomicInteger` 类内部正是利用了类似的 CAS 操作和自旋逻辑来实现其原子性的 `getAndIncrement()`, `compareAndSet()` 等方法。直接使用 `AtomicInteger` 通常是更安全、更推荐的做法,因为它封装了底层的复杂性。
+3. **ABA 问题:** CAS 操作本身存在 ABA 问题(一个值从 A 变为 B,再变回 A,CAS 检查时会认为值没有变过)。在某些场景下,如果值的变化历史很重要,可能需要使用 `AtomicStampedReference` 来解决。但在本例的简单递增场景中,ABA 问题通常不构成影响。
+4. **CPU 消耗:** 长时间的自旋会消耗 CPU 资源。在竞争激烈或条件长时间不满足的情况下,可以考虑加入更复杂的退避策略(如 `Thread.sleep()` 或 `LockSupport.parkNanos()`)来优化。
### 线程调度
diff --git a/docs/java/basis/why-there-only-value-passing-in-java.md b/docs/java/basis/why-there-only-value-passing-in-java.md
index 296a6ec9c60..18cc70caee8 100644
--- a/docs/java/basis/why-there-only-value-passing-in-java.md
+++ b/docs/java/basis/why-there-only-value-passing-in-java.md
@@ -1,8 +1,13 @@
---
title: Java 值传递详解
+description: 详解Java为什么只有值传递:通过示例深入分析Java参数传递机制,澄清值传递与引用传递的常见误区,理解形参实参本质区别。
category: Java
tag:
- Java基础
+head:
+ - - meta
+ - name: keywords
+ content: Java值传递,引用传递,参数传递,形参实参,对象引用,方法调用,Java传参机制
---
开始之前,我们先来搞懂下面这两个概念:
@@ -32,9 +37,9 @@ void sayHello(String str) {
程序设计语言将实参传递给方法(或函数)的方式分为两种:
- **值传递**:方法接收的是实参值的拷贝,会创建副本。
-- **引用传递**:方法接收的直接是实参所引用的对象在堆中的地址,不会创建副本,对形参的修改将影响到实参。
+- **引用传递**:方法接收的直接是实参的地址,而不是实参内的值,这就是指针,此时形参就是实参,对形参的任何修改都会反应到实参,包括重新赋值。
-很多程序设计语言(比如 C++、 Pascal )提供了两种参数传递的方式,不过,在 Java 中只有值传递。
+很多程序设计语言(比如 C++、 Pascal)提供了两种参数传递的方式,不过,在 Java 中只有值传递。
## 为什么 Java 只有值传递?
diff --git a/docs/java/collection/arrayblockingqueue-source-code.md b/docs/java/collection/arrayblockingqueue-source-code.md
index a1c68973683..24631e5954b 100644
--- a/docs/java/collection/arrayblockingqueue-source-code.md
+++ b/docs/java/collection/arrayblockingqueue-source-code.md
@@ -1,8 +1,13 @@
---
title: ArrayBlockingQueue 源码分析
+description: ArrayBlockingQueue源码深度解析:详解有界阻塞队列实现、生产者消费者模式应用、ReentrantLock+Condition并发控制、线程池工作队列机制。
category: Java
tag:
- Java集合
+head:
+ - - meta
+ - name: keywords
+ content: ArrayBlockingQueue源码,阻塞队列,有界队列,生产者消费者模式,ReentrantLock,Condition,线程池工作队列
---
## 阻塞队列简介
@@ -11,7 +16,7 @@ tag:
Java 阻塞队列的历史可以追溯到 JDK1.5 版本,当时 Java 平台增加了 `java.util.concurrent`,即我们常说的 JUC 包,其中包含了各种并发流程控制工具、并发容器、原子类等。这其中自然也包含了我们这篇文章所讨论的阻塞队列。
-为了解决高并发场景下多线程之间数据共享的问题,JDK1.5 版本中出现了 `ArrayBlockingQueue` 和 `LinkedBlockingQueue`,它们是带有生产者-消费者模式实现的并发容器。其中,`ArrayBlockingQueue` 是有界队列,即添加的元素达到上限之后,再次添加就会被阻塞或者抛出异常。而 `LinkedBlockingQueue` 则由链表构成的队列,正是因为链表的特性,所以 `LinkedBlockingQueue` 在添加元素上并不会向 `ArrayBlockingQueue` 那样有着较多的约束,所以 `LinkedBlockingQueue` 设置队列是否有界是可选的(注意这里的无界并不是指可以添加任务数量的元素,而是说队列的大小默认为 `Integer.MAX_VALUE`,近乎于无限大)。
+为了解决高并发场景下多线程之间数据共享的问题,JDK1.5 版本中出现了 `ArrayBlockingQueue` 和 `LinkedBlockingQueue`,它们是带有生产者-消费者模式实现的并发容器。其中,`ArrayBlockingQueue` 是有界队列,即添加的元素达到上限之后,再次添加就会被阻塞或者抛出异常。而 `LinkedBlockingQueue` 则由链表构成的队列,正是因为链表的特性,所以 `LinkedBlockingQueue` 在添加元素上并不会向 `ArrayBlockingQueue` 那样有着较多的约束,所以 `LinkedBlockingQueue` 设置队列是否有界是可选的(注意这里的无界并不是指可以添加任意数量的元素,而是说队列的大小默认为 `Integer.MAX_VALUE`,近乎于无限大)。
随着 Java 的不断发展,JDK 后续的几个版本又对阻塞队列进行了不少的更新和完善:
@@ -28,7 +33,7 @@ Java 阻塞队列的历史可以追溯到 JDK1.5 版本,当时 Java 平台增
3. 当阻塞队列因为消费者消费过慢或者生产者存放元素过快导致队列填满时无法容纳新元素时,生产者就会被阻塞,等待队列非满时继续存放元素。
4. 当消费者从队列中消费一个元素之后,队列就会通知生产者队列非满,生产者可以继续填充数据了。
-总结一下:阻塞队列就说基于非空和非满两个条件实现生产者和消费者之间的交互,尽管这些交互流程和等待通知的机制实现非常复杂,好在 Doug Lea 的操刀之下已将阻塞队列的细节屏蔽,我们只需调用 `put`、`take`、`offfer`、`poll` 等 API 即可实现多线程之间的生产和消费。
+总结一下:阻塞队列就说基于非空和非满两个条件实现生产者和消费者之间的交互,尽管这些交互流程和等待通知的机制实现非常复杂,好在 Doug Lea 的操刀之下已将阻塞队列的细节屏蔽,我们只需调用 `put`、`take`、`offer`、`poll` 等 API 即可实现多线程之间的生产和消费。
这也使得阻塞队列在多线程开发中有着广泛的运用,最常见的例子无非是我们的线程池,从源码中我们就能看出当核心线程无法及时处理任务时,这些任务都会扔到 `workQueue` 中。
@@ -119,8 +124,8 @@ public class ProducerConsumerExample {
生产者添加元素:2
消费者取出元素:1
消费者取出元素:2
-消费者取出元素:3
生产者添加元素:3
+消费者取出元素:3
生产者添加元素:4
生产者添加元素:5
消费者取出元素:4
@@ -226,11 +231,11 @@ public class DrainToExample {

-从图中我们可以看出,`ArrayBlockingQueue` 继承了阻塞队列 `BlockingQueue` 这个接口,不难猜出通过继承 `BlockingQueue` 这个接口之后,`ArrayBlockingQueue` 就拥有了阻塞队列那些常见的操作行为。
+从图中我们可以看出,`ArrayBlockingQueue` 实现了阻塞队列 `BlockingQueue` 这个接口,不难猜出通过实现 `BlockingQueue` 这个接口之后,`ArrayBlockingQueue` 就拥有了阻塞队列那些常见的操作行为。
同时, `ArrayBlockingQueue` 还继承了 `AbstractQueue` 这个抽象类,这个继承了 `AbstractCollection` 和 `Queue` 的抽象类,从抽象类的特定和语义我们也可以猜出,这个继承关系使得 `ArrayBlockingQueue` 拥有了队列的常见操作。
-所以我们是否可以得出这样一个结论,通过继承 `AbstractQueue` 获得队列所有的操作模板,其实现的入队和出队操作的整体框架。然后 `ArrayBlockingQueue` 通过继承 `BlockingQueue` 获取到阻塞队列的常见操作并将这些操作实现,填充到 `AbstractQueue` 模板方法的细节中,由此 `ArrayBlockingQueue` 成为一个完整的阻塞队列。
+所以我们是否可以得出这样一个结论,通过继承 `AbstractQueue` 获得队列所有的操作模板,其实现的入队和出队操作的整体框架。然后 `ArrayBlockingQueue` 通过实现 `BlockingQueue` 获取到阻塞队列的常见操作并将这些操作实现,填充到 `AbstractQueue` 模板方法的细节中,由此 `ArrayBlockingQueue` 成为一个完整的阻塞队列。
为了印证这一点,我们到源码中一探究竟。首先我们先来看看 `AbstractQueue`,从类的继承关系我们可以大致得出,它通过 `AbstractCollection` 获得了集合的常见操作方法,然后通过 `Queue` 接口获得了队列的特性。
@@ -244,7 +249,7 @@ public abstract class AbstractQueue
对于集合的操作无非是增删改查,所以我们不妨从添加方法入手,从源码中我们可以看到,它实现了 `AbstractCollection` 的 `add` 方法,其内部逻辑如下:
-1. 调用继承 `Queue` 接口的来的 `offer` 方法,如果 `offer` 成功则返回 `true`。
+1. 调用继承 `Queue` 接口得来的 `offer` 方法,如果 `offer` 成功则返回 `true`。
2. 如果 `offer` 失败,即代表当前元素入队失败直接抛异常。
```java
@@ -258,7 +263,7 @@ public boolean add(E e) {
而 `AbstractQueue` 中并没有对 `Queue` 的 `offer` 的实现,很明显这样做的目的是定义好了 `add` 的核心逻辑,将 `offer` 的细节交由其子类即我们的 `ArrayBlockingQueue` 实现。
-到此,我们对于抽象类 `AbstractQueue` 的分析就结束了,我们继续看看 `ArrayBlockingQueue` 中另一个重要的继承接口 `BlockingQueue`。
+到此,我们对于抽象类 `AbstractQueue` 的分析就结束了,我们继续看看 `ArrayBlockingQueue` 中实现的另一个重要接口 `BlockingQueue`。
点开 `BlockingQueue` 之后,我们可以看到这个接口同样继承了 `Queue` 接口,这就意味着它也具备了队列所拥有的所有行为。同时,它还定义了自己所需要实现的方法。
@@ -302,11 +307,11 @@ public interface BlockingQueue extends Queue {
}
```
-了解了 `BlockingQueue` 的常见操作后,我们就知道了 `ArrayBlockingQueue` 通过继承 `BlockingQueue` 的方法并实现后,填充到 `AbstractQueue` 的方法上,由此我们便知道了上文中 `AbstractQueue` 的 `add` 方法的 `offer` 方法是哪里是实现的了。
+了解了 `BlockingQueue` 的常见操作后,我们就知道了 `ArrayBlockingQueue` 通过实现 `BlockingQueue` 的方法并重写后,填充到 `AbstractQueue` 的方法上,由此我们便知道了上文中 `AbstractQueue` 的 `add` 方法的 `offer` 方法是哪里是实现的了。
```java
public boolean add(E e) {
- //AbstractQueue的offer来自下层的ArrayBlockingQueue从BlockingQueue继承并实现的offer方法
+ //AbstractQueue的offer来自下层的ArrayBlockingQueue从BlockingQueue实现并重写的offer方法
if (offer(e))
return true;
else
diff --git a/docs/java/collection/arraylist-source-code.md b/docs/java/collection/arraylist-source-code.md
index 762d64b2cec..f2db885d25e 100644
--- a/docs/java/collection/arraylist-source-code.md
+++ b/docs/java/collection/arraylist-source-code.md
@@ -1,8 +1,13 @@
---
title: ArrayList 源码分析
+description: ArrayList源码深度解析:详解ArrayList底层数组结构、1.5倍扩容机制、RandomAccess快速随机访问、序列化实现及与Vector性能对比。
category: Java
tag:
- Java集合
+head:
+ - - meta
+ - name: keywords
+ content: ArrayList源码,ArrayList扩容机制,动态数组,RandomAccess,ArrayList序列化,ArrayList与Vector区别
---
@@ -22,7 +27,7 @@ public class ArrayList extends AbstractList
```
- `List` : 表明它是一个列表,支持添加、删除、查找等操作,并且可以通过下标进行访问。
-- `RandomAccess` :这是一个标志接口,表明实现这个接口的 `List` 集合是支持 **快速随机访问** 的。在 `ArrayList` 中,我们即可以通过元素的序号快速获取元素对象,这就是快速随机访问。
+- `RandomAccess` :这是一个标志接口,表明实现这个接口的 `List` 集合是支持 **快速随机访问** 的。在 `ArrayList` 中,我们就可以通过元素的序号快速获取元素对象,这就是快速随机访问。
- `Cloneable` :表明它具有拷贝能力,可以进行深拷贝或浅拷贝操作。
- `Serializable` : 表明它可以进行序列化操作,也就是可以将对象转换为字节流进行持久化存储或网络传输,非常方便。
@@ -159,19 +164,22 @@ public class ArrayList extends AbstractList
* @param minCapacity 所需的最小容量
*/
public void ensureCapacity(int minCapacity) {
- //如果是true,minExpand的值为0,如果是false,minExpand的值为10
+ // 如果不是默认空数组,则minExpand的值为0;
+ // 如果是默认空数组,则minExpand的值为10
int minExpand = (elementData != DEFAULTCAPACITY_EMPTY_ELEMENTDATA)
- // any size if not default element table
+ // 如果不是默认元素表,则可以使用任意大小
? 0
- // larger than default for default empty table. It's already
- // supposed to be at default size.
+ // 如果是默认空数组,它应该已经是默认大小
: DEFAULT_CAPACITY;
- //如果最小容量大于已有的最大容量
+
+ // 如果最小容量大于已有的最大容量
if (minCapacity > minExpand) {
+ // 根据需要的最小容量,确保容量足够
ensureExplicitCapacity(minCapacity);
}
}
+
// 根据给定的最小容量和当前数组元素来计算所需容量。
private static int calculateCapacity(Object[] elementData, int minCapacity) {
// 如果当前数组元素为空数组(初始情况),返回默认容量和最小容量中的较大值作为所需容量
@@ -305,8 +313,11 @@ public class ArrayList extends AbstractList
/**
* 以正确的顺序(从第一个到最后一个元素)返回一个包含此列表中所有元素的数组。
- * 返回的数组将是“安全的”,因为该列表不保留对它的引用。 (换句话说,这个方法必须分配一个新的数组)。
- * 因此,调用者可以自由地修改返回的数组。 此方法充当基于阵列和基于集合的API之间的桥梁。
+ * 返回的数组将是“安全的”,因为该列表不保留对它的引用。
+ * (换句话说,这个方法必须分配一个新的数组)。
+ * 因此,调用者可以自由地修改返回的数组结构。
+ * 注意:如果元素是引用类型,修改元素的内容会影响到原列表中的对象。
+ * 此方法充当基于数组和基于集合的API之间的桥梁。
*/
public Object[] toArray() {
return Arrays.copyOf(elementData, size);
@@ -426,8 +437,7 @@ public class ArrayList extends AbstractList
}
/*
- * Private remove method that skips bounds checking and does not
- * return the value removed.
+ * 该方法为私有的移除方法,跳过了边界检查,并且不返回被移除的值。
*/
private void fastRemove(int index) {
modCount++;
@@ -435,7 +445,7 @@ public class ArrayList extends AbstractList
if (numMoved > 0)
System.arraycopy(elementData, index + 1, elementData, index,
numMoved);
- elementData[--size] = null; // clear to let GC do its work
+ elementData[--size] = null; // 在移除元素后,将该位置的元素设为 null,以便垃圾回收器(GC)能够回收该元素。
}
/**
@@ -729,7 +739,7 @@ private void grow(int minCapacity) {
**我们再来通过例子探究一下`grow()` 方法:**
- 当 `add` 第 1 个元素时,`oldCapacity` 为 0,经比较后第一个 if 判断成立,`newCapacity = minCapacity`(为 10)。但是第二个 if 判断不会成立,即 `newCapacity` 不比 `MAX_ARRAY_SIZE` 大,则不会进入 `hugeCapacity` 方法。数组容量为 10,`add` 方法中 return true,size 增为 1。
-- 当 `add` 第 11 个元素进入 `grow` 方法时,`newCapacity` 为 15,比 `minCapacity`(为 11)大,第一个 if 判断不成立。新容量没有大于数组最大 size,不会进入 huge`C`apacity 方法。数组容量扩为 15,add 方法中 return true,size 增为 11。
+- 当 `add` 第 11 个元素进入 `grow` 方法时,`newCapacity` 为 15,比 `minCapacity`(为 11)大,第一个 if 判断不成立。新容量没有大于数组最大 size,不会进入 `hugeCapacity` 方法。数组容量扩为 15,add 方法中 return true,size 增为 11。
- 以此类推······
**这里补充一点比较重要,但是容易被忽视掉的知识点:**
diff --git a/docs/java/collection/concurrent-hash-map-source-code.md b/docs/java/collection/concurrent-hash-map-source-code.md
index 2422b36c8fe..695fbf108fe 100644
--- a/docs/java/collection/concurrent-hash-map-source-code.md
+++ b/docs/java/collection/concurrent-hash-map-source-code.md
@@ -1,11 +1,18 @@
---
title: ConcurrentHashMap 源码分析
+description: ConcurrentHashMap源码深入解析:对比JDK1.7分段锁Segment与JDK1.8 CAS+Synchronized实现,理解高并发Map的线程安全机制与性能优化。
category: Java
tag:
- Java集合
+head:
+ - - meta
+ - name: keywords
+ content: ConcurrentHashMap源码,线程安全Map,分段锁Segment,CAS操作,并发容器,JDK7与JDK8区别
---
-> 本文来自公众号:末读代码的投稿,原文地址: 。
+
+
+> 本文来自末读代码投稿: ,JavaGuide 对原文进行了大篇幅改进优化。
上一篇文章介绍了 HashMap 源码,反响不错,也有很多同学发表了自己的观点,这次又来了,这次是 `ConcurrentHashMap` 了,作为线程安全的 HashMap ,它的使用频率也是很高。那么它的存储结构和实现原理是怎么样的呢?
@@ -15,7 +22,7 @@ tag:

-Java 7 中 `ConcurrentHashMap` 的存储结构如上图,`ConcurrnetHashMap` 由很多个 `Segment` 组合,而每一个 `Segment` 是一个类似于 `HashMap` 的结构,所以每一个 `HashMap` 的内部可以进行扩容。但是 `Segment` 的个数一旦**初始化就不能改变**,默认 `Segment` 的个数是 16 个,你也可以认为 `ConcurrentHashMap` 默认支持最多 16 个线程并发。
+Java 7 中 `ConcurrentHashMap` 的存储结构如上图,`ConcurrentHashMap` 由很多个 `Segment` 组合,而每一个 `Segment` 是一个类似于 `HashMap` 的结构,所以每一个 `HashMap` 的内部可以进行扩容。但是 `Segment` 的个数一旦**初始化就不能改变**,默认 `Segment` 的个数是 16 个,你也可以认为 `ConcurrentHashMap` 默认支持最多 16 个线程并发。
### 2. 初始化
@@ -328,7 +335,7 @@ private void rehash(HashEntry node) {
HashEntry e = oldTable[i];
if (e != null) {
HashEntry next = e.next;
- // 计算新的位置,新的位置只可能是不便或者是老的位置+老的容量。
+ // 计算新的位置,新的位置只可能是不变或者是老的位置+老的容量。
int idx = e.hash & sizeMask;
if (next == null) // Single node on list
// 如果当前位置还不是链表,只是一个元素,直接赋值
@@ -337,7 +344,7 @@ private void rehash(HashEntry node) {
// 如果是链表了
HashEntry lastRun = e;
int lastIdx = idx;
- // 新的位置只可能是不便或者是老的位置+老的容量。
+ // 新的位置只可能是不变或者是老的位置+老的容量。
// 遍历结束后,lastRun 后面的元素位置都是相同的
for (HashEntry last = next; last != null; last = last.next) {
int k = last.hash & sizeMask;
@@ -368,7 +375,19 @@ private void rehash(HashEntry node) {
}
```
-有些同学可能会对最后的两个 for 循环有疑惑,这里第一个 for 是为了寻找这样一个节点,这个节点后面的所有 next 节点的新位置都是相同的。然后把这个作为一个链表赋值到新位置。第二个 for 循环是为了把剩余的元素通过头插法插入到指定位置链表。这样实现的原因可能是基于概率统计,有深入研究的同学可以发表下意见。
+有些同学可能会对最后的两个 for 循环有疑惑,这里第一个 for 是为了寻找这样一个节点,这个节点后面的所有 next 节点的新位置都是相同的。然后把这个作为一个链表赋值到新位置。第二个 for 循环是为了把剩余的元素通过头插法插入到指定位置链表。~~这样实现的原因可能是基于概率统计,有深入研究的同学可以发表下意见。~~
+
+内部第二个 `for` 循环中使用了 `new HashEntry(h, p.key, v, n)` 创建了一个新的 `HashEntry`,而不是复用之前的,是因为如果复用之前的,那么会导致正在遍历(如正在执行 `get` 方法)的线程由于指针的修改无法遍历下去。正如注释中所说的:
+
+> 当它们不再被可能正在并发遍历表的任何读取线程引用时,被替换的节点将被垃圾回收。
+>
+> The nodes they replace will be garbage collectable as soon as they are no longer referenced by any reader thread that may be in the midst of concurrently traversing table
+
+为什么需要再使用一个 `for` 循环找到 `lastRun` ,其实是为了减少对象创建的次数,正如注解中所说的:
+
+> 从统计上看,在默认的阈值下,当表容量加倍时,只有大约六分之一的节点需要被克隆。
+>
+> Statistically, at the default threshold, only about one-sixth of them need cloning when a table doubles.
### 5. get
@@ -401,6 +420,8 @@ public V get(Object key) {
## 2. ConcurrentHashMap 1.8
+总的来说 ,`ConcurrentHashMap` 在 Java8 中相对于 Java7 来说变化还是挺大的,
+
### 1. 存储结构

@@ -578,15 +599,70 @@ public V get(Object key) {
3. 如果头节点 hash 值小于 0 ,说明正在扩容或者是红黑树,查找之。
4. 如果是链表,遍历查找之。
-总结:
+### 5. size 计数
+
+`ConcurrentHashMap` 的 `size()` 方法用来获取当前 Map 中元素的总数,但在高并发场景下,如何准确且高效地统计元素数量是一个技术难点。Java8 采用了一套精巧的分段计数机制来解决这个问题。
+
+#### 5.1 为什么需要分段计数
+
+在并发环境下,如果多个线程同时执行 `put` 操作,它们都需要更新元素总数。如果使用一个共享的计数器变量,就会导致激烈的竞争——所有线程都在争抢同一个变量的修改权,这会严重影响性能。
+
+为了解决这个问题,`ConcurrentHashMap` 采用了**分散热点**的设计思想:不使用单一计数器,而是将计数分散到多个变量中。就像银行不会只开一个窗口办业务,而是开多个窗口分流客户一样,这样可以大大减少冲突。
+
+#### 5.2 baseCount 和 counterCells 的设计
+
+`ConcurrentHashMap` 内部维护了两个关键的计数相关字段:
+
+- **baseCount**:基础计数器,在没有竞争的情况下,直接通过 CAS 更新这个变量。可以把它理解为"主计数器"。
+- **counterCells**:计数器数组。当多个线程竞争 `baseCount` 失败时,会尝试将计数增量分散到 `counterCells` 数组的不同位置。
+ - 每个线程根据自己的 **Probe 值**(可理解为线程 ID 生成的一种哈希码)映射到数组的某个槽位,优先在这个“偏向的格子”里进行累加。
+ - **注意**:这个格子并不是严格意义上的“线程私有”,当哈希冲突时,多个线程仍然可能映射到同一个槽位并发更新。
+
+**举个例子**:假设有 10 个线程同时往 Map 中添加元素。第一个线程成功通过 CAS 更新了 `baseCount`,但后面 9 个线程在更新 `baseCount` 时发现有竞争,就会转而去 `counterCells` 数组中找一个位置进行累加。这 9 个线程可能分散到数组的不同位置(比如线程 2 在 `counterCells[1]`,线程 3 在 `counterCells[2]`),从而将竞争从一个点分散到了多个点。。
+
+#### 5.3 put 元素时如何更新计数
+
+在 `putVal` 方法的最后,我们可以看到调用了 `addCount(1L, binCount)` 方法,这个方法就是用来更新元素计数的。
+
+`addCount` 的执行逻辑大致可以概括为:
+
+1. **优先尝试更新 baseCount**
+
+ - 如果当前还没有启用 `counterCells`(`counterCells == null`),线程会先尝试通过 CAS 直接更新 `baseCount`。
+ - 如果 CAS 成功,说明竞争不激烈,直接返回即可。
+
+2. **竞争出现时,转向 counterCells**
+
+ - 如果 CAS 更新 `baseCount` 失败(说明有其他线程在竞争),或者 `counterCells` 已经存在(说明系统之前已经遇到过竞争),线程就会尝试在 `counterCells` 中更新:
+ - 根据自己的 probe 值映射到某个槽位;
+ - 对该槽位对应的 `CounterCell` 做一次 CAS 累加。
+ - 如果这个槽位为空或 CAS 仍然冲突,就会进入一个更“重”的路径 `fullAddCount`,在里面负责初始化槽位、重新选择槽位等。
+
+3. **动态初始化与扩容 counterCells**
+ - 当检测到竞争比较激烈(例如:某个 cell 的 CAS 也频繁失败)时,`fullAddCount` 会在一个轻量级的自旋锁 `cellsBusy` 保护下:
+ - 如果 `counterCells` 还没初始化,就初始化一个较小的数组(比如长度 2);
+ - 如果已经存在并且长度还没达到上限(通常不超过 CPU 核数),就按 2 倍进行扩容,增加更多的计数槽位,把线程进一步打散。
+
+这种设计保证了:在低并发时只使用简单的 `baseCount`,路径非常短;在高并发时则自动切换到分段计数,通过 `counterCells` 和扩容机制摊薄竞争,兼顾了性能和准确性。
+
+#### 5.4 sumCount 如何计算元素总数
+
+当我们调用 `size()` 方法时,最终会调用 `sumCount()` 方法来计算元素总数。`sumCount()` 的逻辑非常简单直接:
+
+1. 读取 `baseCount` 的值作为基础值。
+2. 遍历 `counterCells` 数组,将所有非空位置的计数值累加到基础值上。
+3. 返回累加结果。
+
+**注意**:
-总的来说 `ConcurrentHashMap` 在 Java8 中相对于 Java7 来说变化还是挺大的,
+- **弱一致性**:`sumCount()` 全程**不加锁**。在计算期间如果有其他线程插入数据,返回的结果只是一个**近似值**。但在高并发场景下,追求“刹那间的精确总数”代价过大且无意义,近似值通常已足够。
+- **整型溢出**:`size()` 方法返回 `int` 类型。如果元素数量超过 `Integer.MAX_VALUE`,它只会返回 `Integer.MAX_VALUE`。如果需要获取精确的大容量计数,建议使用 Java 8 新增的 **`mappingCount()`** 方法,该方法返回 `long` 类型。
## 3. 总结
Java7 中 `ConcurrentHashMap` 使用的分段锁,也就是每一个 Segment 上同时只有一个线程可以操作,每一个 `Segment` 都是一个类似 `HashMap` 数组的结构,它可以扩容,它的冲突会转化为链表。但是 `Segment` 的个数一但初始化就不能改变。
-Java8 中的 `ConcurrentHashMap` 使用的 `Synchronized` 锁加 CAS 的机制。结构也由 Java7 中的 **`Segment` 数组 + `HashEntry` 数组 + 链表** 进化成了 **Node 数组 + 链表 / 红黑树**,Node 是类似于一个 HashEntry 的结构。它的冲突再达到一定大小时会转化成红黑树,在冲突小于一定数量时又退回链表。
+Java8 中的 `ConcurrentHashMap` 使用的 `Synchronized` 锁加 CAS 的机制。结构也由 Java7 中的 **`Segment` 数组 + `HashEntry` 数组 + 链表** 进化成了 **Node 数组 + 链表 / 红黑树**,Node 是类似于一个 HashEntry 的结构。它的冲突再达到一定大小时`TREEIFY_THRESHOLD = 8`会转化成红黑树,在冲突小于一定数量时`UNTREEIFY_THRESHOLD = 6`又退回链表。
有些同学可能对 `Synchronized` 的性能存在疑问,其实 `Synchronized` 锁自从引入锁升级策略后,性能不再是问题,有兴趣的同学可以自己了解下 `Synchronized` 的**锁升级**。
diff --git a/docs/java/collection/copyonwritearraylist-source-code.md b/docs/java/collection/copyonwritearraylist-source-code.md
index 9aceb83bc4e..8c5ec08be89 100644
--- a/docs/java/collection/copyonwritearraylist-source-code.md
+++ b/docs/java/collection/copyonwritearraylist-source-code.md
@@ -1,8 +1,13 @@
---
title: CopyOnWriteArrayList 源码分析
+description: CopyOnWriteArrayList源码深度解析:详解写时复制COW机制、适用读多写少场景、线程安全List实现、快照一致性保证及内存开销权衡。
category: Java
tag:
- Java集合
+head:
+ - - meta
+ - name: keywords
+ content: CopyOnWriteArrayList源码,写时复制COW,线程安全List,读多写少,并发容器,快照一致性
---
## CopyOnWriteArrayList 简介
diff --git a/docs/java/collection/delayqueue-source-code.md b/docs/java/collection/delayqueue-source-code.md
index 5fb6f4affad..9c5f0712c2e 100644
--- a/docs/java/collection/delayqueue-source-code.md
+++ b/docs/java/collection/delayqueue-source-code.md
@@ -1,8 +1,13 @@
---
title: DelayQueue 源码分析
+description: DelayQueue源码深度解析:详解延迟队列实现原理、Delayed接口使用、延时任务调度、订单超时取消等应用场景、基于PriorityQueue的线程安全设计。
category: Java
tag:
- Java集合
+head:
+ - - meta
+ - name: keywords
+ content: DelayQueue源码,延迟队列,Delayed接口,延时任务,定时任务,订单超时,PriorityQueue实现
---
## DelayQueue 简介
diff --git a/docs/java/collection/hashmap-source-code.md b/docs/java/collection/hashmap-source-code.md
index a4f92b8a38e..204121bbf32 100644
--- a/docs/java/collection/hashmap-source-code.md
+++ b/docs/java/collection/hashmap-source-code.md
@@ -1,8 +1,13 @@
---
title: HashMap 源码分析
+description: HashMap源码深度剖析:详解JDK1.7/1.8结构差异、hash扰动函数、0.75负载因子、扩容rehash机制、链表转红黑树阈值等HashMap核心原理。
category: Java
tag:
- Java集合
+head:
+ - - meta
+ - name: keywords
+ content: HashMap源码,哈希表,红黑树,链表,扰动函数,负载因子,HashMap扩容,哈希冲突,JDK1.8优化
---
@@ -27,7 +32,7 @@ JDK1.8 之前 HashMap 底层是 **数组和链表** 结合在一起使用也就
HashMap 通过 key 的 hashCode 经过扰动函数处理过后得到 hash 值,然后通过 `(n - 1) & hash` 判断当前元素存放的位置(这里的 n 指的是数组的长度),如果当前位置存在元素的话,就判断该元素与要存入的元素的 hash 值以及 key 是否相同,如果相同的话,直接覆盖,不相同就通过拉链法解决冲突。
-所谓扰动函数指的就是 HashMap 的 hash 方法。使用 hash 方法也就是扰动函数是为了防止一些实现比较差的 hashCode() 方法 换句话说使用扰动函数之后可以减少碰撞。
+所谓扰动函数指的就是 HashMap 的 hash 方法。使用 hash 方法也就是扰动函数是为了防止一些实现比较差的 hashCode() 方法,换句话说使用扰动函数之后可以减少碰撞。
**JDK 1.8 HashMap 的 hash 方法源码:**
@@ -90,7 +95,7 @@ public class HashMap extends AbstractMap implements Map, Cloneabl
static final int MIN_TREEIFY_CAPACITY = 64;
// 存储元素的数组,总是2的幂次倍
transient Node[] table;
- // 存放具体元素的集
+ // 一个包含了映射中所有键值对的集合视图
transient Set> entrySet;
// 存放元素的个数,注意这个不等于数组的长度。
transient int size;
@@ -217,7 +222,9 @@ HashMap 中有四个构造方法,它们分别如下:
}
```
-> 值得注意的是上述四个构造方法中,都初始化了负载因子 loadFactor,由于 HashMap 中没有 capacity 这样的字段,即使指定了初始化容量 initialCapacity ,也只是通过 tableSizeFor 将其扩容到与 initialCapacity 最接近的 2 的幂次方大小,然后暂时赋值给 threshold ,后续通过 resize 方法将 threshold 赋值给 newCap 进行 table 的初始化。
+> 需要特别注意的是:传入的 `initialCapacity` 并不是最终的数组容量。`HashMap` 会调用 `tableSizeFor()` 将其**向上取整为大于或等于该值的最小 2 的幂次方**,并暂时保存到 `threshold` 字段。真正的 `table` 数组会在第一次扩容(`resize()`)时才初始化为这个大小。
+>
+> 例如:`initialCapacity = 9` → `threshold = 16` → `table` 长度最终为 16。
**putMapEntries 方法:**
diff --git a/docs/java/collection/java-collection-precautions-for-use.md b/docs/java/collection/java-collection-precautions-for-use.md
index e636ff5a77a..2214ee59beb 100644
--- a/docs/java/collection/java-collection-precautions-for-use.md
+++ b/docs/java/collection/java-collection-precautions-for-use.md
@@ -1,8 +1,13 @@
---
title: Java集合使用注意事项总结
+description: Java集合使用注意事项总结:基于阿里巴巴开发手册梳理集合判空、Arrays.asList陷阱、subList问题、并发容器选择等最佳实践,避免常见错误。
category: Java
tag:
- Java集合
+head:
+ - - meta
+ - name: keywords
+ content: Java集合最佳实践,集合判空,Arrays.asList,subList,并发容器,集合使用注意事项,性能优化
---
这篇文章我根据《阿里巴巴 Java 开发手册》总结了关于集合使用常见的注意事项以及其具体原理。
@@ -15,35 +20,58 @@ tag:
> **判断所有集合内部的元素是否为空,使用 `isEmpty()` 方法,而不是 `size()==0` 的方式。**
-这是因为 `isEmpty()` 方法的可读性更好,并且时间复杂度为 O(1)。
+这是因为 `isEmpty()` 方法的可读性更好,并且时间复杂度为 `O(1)`。
-绝大部分我们使用的集合的 `size()` 方法的时间复杂度也是 O(1),不过,也有很多复杂度不是 O(1) 的,比如 `java.util.concurrent` 包下的某些集合(`ConcurrentLinkedQueue`、`ConcurrentHashMap`...)。
+绝大部分我们使用的集合的 `size()` 方法的时间复杂度也是 `O(1)`,不过,也有很多复杂度不是 `O(1)` 的,比如 `java.util.concurrent` 包下的 `ConcurrentLinkedQueue`。`ConcurrentLinkedQueue` 的 `isEmpty()` 方法通过 `first()` 方法进行判断,其中 `first()` 方法返回的是队列中第一个值不为 `null` 的节点(节点值为`null`的原因是在迭代器中使用的逻辑删除)
-下面是 `ConcurrentHashMap` 的 `size()` 方法和 `isEmpty()` 方法的源码。
+```java
+public boolean isEmpty() { return first() == null; }
+
+Node first() {
+ restartFromHead:
+ for (;;) {
+ for (Node h = head, p = h, q;;) {
+ boolean hasItem = (p.item != null);
+ if (hasItem || (q = p.next) == null) { // 当前节点值不为空 或 到达队尾
+ updateHead(h, p); // 将head设置为p
+ return hasItem ? p : null;
+ }
+ else if (p == q) continue restartFromHead;
+ else p = q; // p = p.next
+ }
+ }
+}
+```
+
+由于在插入与删除元素时,都会执行`updateHead(h, p)`方法,所以该方法的执行的时间复杂度可以近似为`O(1)`。而 `size()` 方法需要遍历整个链表,时间复杂度为`O(n)`
```java
public int size() {
- long n = sumCount();
- return ((n < 0L) ? 0 :
- (n > (long)Integer.MAX_VALUE) ? Integer.MAX_VALUE :
- (int)n);
+ int count = 0;
+ for (Node p = first(); p != null; p = succ(p))
+ if (p.item != null)
+ if (++count == Integer.MAX_VALUE)
+ break;
+ return count;
}
+```
+
+此外,在`ConcurrentHashMap` 1.7 中 `size()` 方法和 `isEmpty()` 方法的时间复杂度也不太一样。`ConcurrentHashMap` 1.7 将元素数量存储在每个`Segment` 中,`size()` 方法需要统计每个 `Segment` 的数量,而 `isEmpty()` 只需要找到第一个不为空的 `Segment` 即可。但是在`ConcurrentHashMap` 1.8 中的 `size()` 方法和 `isEmpty()` 都需要调用 `sumCount()` 方法,其时间复杂度与 `Node` 数组的大小有关。下面是 `sumCount()` 方法的源码:
+
+```java
final long sumCount() {
CounterCell[] as = counterCells; CounterCell a;
long sum = baseCount;
- if (as != null) {
- for (int i = 0; i < as.length; ++i) {
+ if (as != null)
+ for (int i = 0; i < as.length; ++i)
if ((a = as[i]) != null)
sum += a.value;
- }
- }
return sum;
}
-public boolean isEmpty() {
- return sumCount() <= 0L; // ignore transient negative values
-}
```
+这是因为在并发的环境下,`ConcurrentHashMap` 将每个 `Node` 中节点的数量存储在 `CounterCell[]` 数组中。在 `ConcurrentHashMap` 1.7 中,将元素数量存储在每个`Segment` 中,`size()` 方法需要统计每个 `Segment` 的数量,而 `isEmpty()` 只需要找到第一个不为空的 `Segment` 即可。
+
## 集合转 Map
《阿里巴巴 Java 开发手册》的描述如下:
@@ -112,6 +140,8 @@ public static T requireNonNull(T obj) {
}
```
+> `Collectors`也提供了无需 mergeFunction 的`toMap()`方法,但此时若出现 key 冲突,则会抛出`duplicateKeyException`异常,因此强烈建议使用`toMap()`方法必填 mergeFunction。
+
## 集合遍历
《阿里巴巴 Java 开发手册》的描述如下:
diff --git a/docs/java/collection/java-collection-questions-01.md b/docs/java/collection/java-collection-questions-01.md
index 436be1fc821..6f521064b77 100644
--- a/docs/java/collection/java-collection-questions-01.md
+++ b/docs/java/collection/java-collection-questions-01.md
@@ -1,24 +1,24 @@
---
title: Java集合常见面试题总结(上)
+description: Java集合框架面试题总结:深入解析Collection/List/Set/Queue接口,对比ArrayList/LinkedList/HashMap等常用集合类,掌握集合底层数据结构与使用场景。
category: Java
tag:
- Java集合
head:
- - meta
- name: keywords
- content: Collection,List,Set,Queue,Deque,PriorityQueue
- - - meta
- - name: description
- content: Java集合常见知识点和面试题总结,希望对你有帮助!
+ content: Java集合,Collection,List,Set,Queue,ArrayList,LinkedList,HashMap,集合框架,Java面试题
---
+
+
## 集合概述
### Java 集合概览
-Java 集合, 也叫作容器,主要是由两大接口派生而来:一个是 `Collection`接口,主要用于存放单一元素;另一个是 `Map` 接口,主要用于存放键值对。对于`Collection` 接口,下面又有三个主要的子接口:`List`、`Set` 和 `Queue`。
+Java 集合,也叫作容器,主要是由两大接口派生而来:一个是 `Collection`接口,主要用于存放单一元素;另一个是 `Map` 接口,主要用于存放键值对。对于`Collection` 接口,下面又有三个主要的子接口:`List`、`Set` 、 `Queue`。
Java 集合框架如下图所示:
@@ -26,7 +26,7 @@ Java 集合框架如下图所示:
注:图中只列举了主要的继承派生关系,并没有列举所有关系。比方省略了`AbstractList`, `NavigableSet`等抽象类以及其他的一些辅助类,如想深入了解,可自行查看源码。
-### 说说 List, Set, Queue, Map 四者的区别?
+### ⭐️说说 List, Set, Queue, Map 四者的区别?
- `List`(对付顺序的好帮手): 存储的元素是有序的、可重复的。
- `Set`(注重独一无二的性质): 存储的元素不可重复的。
@@ -77,7 +77,7 @@ Java 集合框架如下图所示:
## List
-### ArrayList 和 Array(数组)的区别?
+### ⭐️ArrayList 和 Array(数组)的区别?
`ArrayList` 内部基于动态数组实现,比 `Array`(静态数组) 使用起来更加灵活:
@@ -152,7 +152,7 @@ System.out.println(listOfStrings);
[null, java]
```
-### ArrayList 插入和删除元素的时间复杂度?
+### ⭐️ArrayList 插入和删除元素的时间复杂度?
对于插入:
@@ -186,13 +186,13 @@ System.out.println(listOfStrings);
0 1 2 3 4 5 6 7 8 9
```
-### LinkedList 插入和删除元素的时间复杂度?
+### ⭐️LinkedList 插入和删除元素的时间复杂度?
- 头部插入/删除:只需要修改头结点的指针即可完成插入/删除操作,因此时间复杂度为 O(1)。
- 尾部插入/删除:只需要修改尾结点的指针即可完成插入/删除操作,因此时间复杂度为 O(1)。
-- 指定位置插入/删除:需要先移动到指定位置,再修改指定节点的指针完成插入/删除,因此需要移动平均 n/2 个元素,时间复杂度为 O(n)。
+- 指定位置插入/删除:需要先移动到指定位置,再修改指定节点的指针完成插入/删除,不过由于有头尾指针,可以从较近的指针出发,因此需要遍历平均 n/4 个元素,时间复杂度为 O(n)。
-这里简单列举一个例子:假如我们要删除节点 9 的话,需要先遍历链表找到该节点。然后,再执行相应节点指针指向的更改,具体的源码可以参考:[LinkedList 源码分析](./linkedlist-source-code.md) 。
+这里简单列举一个例子:假如我们要删除节点 9 的话,需要先遍历链表找到该节点。然后,再执行相应节点指针指向的更改,具体的源码可以参考:[LinkedList 源码分析](https://javaguide.cn/java/collection/linkedlist-source-code.html) 。

@@ -200,7 +200,7 @@ System.out.println(listOfStrings);
`RandomAccess` 是一个标记接口,用来表明实现该接口的类支持随机访问(即可以通过索引快速访问元素)。由于 `LinkedList` 底层数据结构是链表,内存地址不连续,只能通过指针来定位,不支持随机快速访问,所以不能实现 `RandomAccess` 接口。
-### ArrayList 与 LinkedList 区别?
+### ⭐️ArrayList 与 LinkedList 区别?
- **是否保证线程安全:** `ArrayList` 和 `LinkedList` 都是不同步的,也就是不保证线程安全;
- **底层数据结构:** `ArrayList` 底层使用的是 **`Object` 数组**;`LinkedList` 底层使用的是 **双向链表** 数据结构(JDK1.6 之前为循环链表,JDK1.7 取消了循环。注意双向链表和双向循环链表的区别,下面有介绍到!)
@@ -249,9 +249,131 @@ public interface RandomAccess {
`ArrayList` 实现了 `RandomAccess` 接口, 而 `LinkedList` 没有实现。为什么呢?我觉得还是和底层数据结构有关!`ArrayList` 底层是数组,而 `LinkedList` 底层是链表。数组天然支持随机访问,时间复杂度为 O(1),所以称为快速随机访问。链表需要遍历到特定位置才能访问特定位置的元素,时间复杂度为 O(n),所以不支持快速随机访问。`ArrayList` 实现了 `RandomAccess` 接口,就表明了他具有快速随机访问功能。 `RandomAccess` 接口只是标识,并不是说 `ArrayList` 实现 `RandomAccess` 接口才具有快速随机访问功能的!
-### 说一说 ArrayList 的扩容机制吧
+### ⭐️说一说 ArrayList 的扩容机制吧
+
+详见笔主的这篇文章: [ArrayList 扩容机制分析](https://javaguide.cn/java/collection/arraylist-source-code.html#arraylist-扩容机制分析)。
+
+### ⭐️集合中的 fail-fast 和 fail-safe 是什么?
+
+`fail-fast`(快速失败)和 `fail-safe`(安全失败)是Java集合框架在处理并发修改问题时,两种截然不同的设计哲学和容错策略。
+
+关于`fail-fast`引用`medium`中一篇文章关于`fail-fast`和`fail-safe`的说法:
+
+> Fail-fast systems are designed to immediately stop functioning upon encountering an unexpected condition. This immediate failure helps to catch errors early, making debugging more straightforward.
+
+快速失败的思想即针对可能发生的异常进行提前表明故障并停止运行,通过尽早的发现和停止错误,降低故障系统级联的风险。
+
+在`java.util`包下的大部分集合(如 `ArrayList`, `HashMap`)是不支持线程安全的,为了能够提前发现并发操作导致线程安全风险,提出通过维护一个`modCount`记录修改的次数,迭代期间通过比对预期修改次数`expectedModCount`和`modCount`是否一致来判断是否存在并发操作,从而实现快速失败,由此保证在避免在异常时执行非必要的复杂代码。
+
+**ArrayList (fail-fast) 示例:**
+
+```java
+ // 使用线程不安全的 ArrayList,它是一种 fail-fast 集合
+ List list = new ArrayList<>();
+ CountDownLatch latch = new CountDownLatch(2);
+
+ for (int i = 0; i < 5; i++) {
+ list.add(i);
+ }
+ System.out.println("Initial list: " + list);
+
+ Thread t1 = new Thread(() -> {
+ try {
+ for (Integer i : list) {
+ System.out.println("Iterator Thread (t1) sees: " + i);
+ Thread.sleep(100);
+ }
+ } catch (ConcurrentModificationException e) {
+ System.err.println("!!! Iterator Thread (t1) caught ConcurrentModificationException as expected.");
+ } catch (InterruptedException e) {
+ e.printStackTrace();
+ } finally {
+ latch.countDown();
+ }
+ });
+
+ Thread t2 = new Thread(() -> {
+ try {
+ Thread.sleep(50);
+ System.out.println("-> Modifier Thread (t2) is removing element 1...");
+ list.remove(Integer.valueOf(1));
+ System.out.println("-> Modifier Thread (t2) finished removal.");
+ } catch (InterruptedException e) {
+ e.printStackTrace();
+ } finally {
+ latch.countDown();
+ }
+ });
+
+ t1.start();
+ t2.start();
+ latch.await();
+
+ System.out.println("Final list state: " + list);
+```
+
+输出:
+
+```
+Initial list: [0, 1, 2, 3, 4]
+Iterator Thread (t1) sees: 0
+-> Modifier Thread (t2) is removing element 1...
+-> Modifier Thread (t2) finished removal.
+!!! Iterator Thread (t1) caught ConcurrentModificationException as expected.
+Final list state: [0, 2, 3, 4]
+```
+
+程序在线程 t2 修改列表后,线程 t1 的下一次迭代操作立刻就抛出了 `ConcurrentModificationException`。这是因为 ArrayList 的迭代器在每次 `next()` 调用时,都会检查 `modCount` 是否被改变。一旦发现集合在迭代器不知情的情况下被修改,它会立即“快速失败”,以防止在不一致的数据上继续操作导致不可预期的后果。
+
+对此我们也给出`for`循环底层迭代器获取下一个元素时的`next`方法,可以看到其内部的`checkForComodification`具有针对修改次数比对的逻辑:
+
+```java
+ public E next() {
+ //检查是否存在并发修改
+ checkForComodification();
+ //......
+ //返回下一个元素
+ return (E) elementData[lastRet = i];
+ }
+
+final void checkForComodification() {
+ //当前循环遍历次数和预期修改次数不一致时,就会抛出ConcurrentModificationException
+ if (modCount != expectedModCount)
+ throw new ConcurrentModificationException();
+ }
+
+```
+
+而`fail-safe`也就是安全失败的含义,它旨在即使面对意外情况也能恢复并继续运行,这使得它特别适用于不确定或者不稳定的环境:
+
+> Fail-safe systems take a different approach, aiming to recover and continue even in the face of unexpected conditions. This makes them particularly suited for uncertain or volatile environments.
+
+该思想常运用于并发容器,最经典的实现就是`CopyOnWriteArrayList`的实现,通过写时复制(Copy-On-Write)的思想保证在进行修改操作时复制出一份快照,基于这份快照完成添加或者删除操作后,将`CopyOnWriteArrayList`底层的数组引用指向这个新的数组空间,由此避免迭代时被并发修改所干扰所导致并发操作安全问题,当然这种做法也存在缺点,即进行遍历操作时无法获得实时结果:
+
+
+
+对应我们也给出`CopyOnWriteArrayList`实现`fail-safe`的核心代码,可以看到它的实现就是通过`getArray`获取数组引用然后通过`Arrays.copyOf`得到一个数组的快照,基于这个快照完成添加操作后,修改底层`array`变量指向的引用地址由此完成写时复制:
-详见笔主的这篇文章: [ArrayList 扩容机制分析](https://javaguide.cn/java/collection/arraylist-source-code.html#_3-1-%E5%85%88%E4%BB%8E-arraylist-%E7%9A%84%E6%9E%84%E9%80%A0%E5%87%BD%E6%95%B0%E8%AF%B4%E8%B5%B7)。
+```java
+public boolean add(E e) {
+ final ReentrantLock lock = this.lock;
+ lock.lock();
+ try {
+ //获取原有数组
+ Object[] elements = getArray();
+ int len = elements.length;
+ //基于原有数组复制出一份内存快照
+ Object[] newElements = Arrays.copyOf(elements, len + 1);
+ //进行添加操作
+ newElements[len] = e;
+ //array指向新的数组
+ setArray(newElements);
+ return true;
+ } finally {
+ lock.unlock();
+ }
+ }
+```
## Set
@@ -481,7 +603,7 @@ Java 中常用的阻塞队列实现类有以下几种:
日常开发中,这些队列使用的其实都不多,了解即可。
-### ArrayBlockingQueue 和 LinkedBlockingQueue 有什么区别?
+### ⭐️ArrayBlockingQueue 和 LinkedBlockingQueue 有什么区别?
`ArrayBlockingQueue` 和 `LinkedBlockingQueue` 是 Java 并发包中常用的两种阻塞队列实现,它们都是线程安全的。不过,不过它们之间也存在下面这些区别:
diff --git a/docs/java/collection/java-collection-questions-02.md b/docs/java/collection/java-collection-questions-02.md
index 190c928e627..be5c61d7728 100644
--- a/docs/java/collection/java-collection-questions-02.md
+++ b/docs/java/collection/java-collection-questions-02.md
@@ -1,28 +1,27 @@
---
title: Java集合常见面试题总结(下)
+description: Java集合高频面试题:深入分析HashMap底层原理、红黑树转换、哈希冲突解决、ConcurrentHashMap线程安全机制、与Hashtable区别等核心知识点。
category: Java
tag:
- Java集合
head:
- - meta
- name: keywords
- content: HashMap,ConcurrentHashMap,Hashtable,List,Set
- - - meta
- - name: description
- content: Java集合常见知识点和面试题总结,希望对你有帮助!
+ content: HashMap,ConcurrentHashMap,Hashtable,红黑树,哈希冲突,线程安全,集合面试题
---
## Map(重要)
-### HashMap 和 Hashtable 的区别
+### ⭐️HashMap 和 Hashtable 的区别
- **线程是否安全:** `HashMap` 是非线程安全的,`Hashtable` 是线程安全的,因为 `Hashtable` 内部的方法基本都经过`synchronized` 修饰。(如果你要保证线程安全的话就使用 `ConcurrentHashMap` 吧!);
- **效率:** 因为线程安全的问题,`HashMap` 要比 `Hashtable` 效率高一点。另外,`Hashtable` 基本被淘汰,不要在代码中使用它;
- **对 Null key 和 Null value 的支持:** `HashMap` 可以存储 null 的 key 和 value,但 null 作为键只能有一个,null 作为值可以有多个;Hashtable 不允许有 null 键和 null 值,否则会抛出 `NullPointerException`。
- **初始容量大小和每次扩充容量大小的不同:** ① 创建时如果不指定容量初始值,`Hashtable` 默认的初始大小为 11,之后每次扩充,容量变为原来的 2n+1。`HashMap` 默认的初始化大小为 16。之后每次扩充,容量变为原来的 2 倍。② 创建时如果给定了容量初始值,那么 `Hashtable` 会直接使用你给定的大小,而 `HashMap` 会将其扩充为 2 的幂次方大小(`HashMap` 中的`tableSizeFor()`方法保证,下面给出了源代码)。也就是说 `HashMap` 总是使用 2 的幂作为哈希表的大小,后面会介绍到为什么是 2 的幂次方。
- **底层数据结构:** JDK1.8 以后的 `HashMap` 在解决哈希冲突时有了较大的变化,当链表长度大于阈值(默认为 8)时,将链表转化为红黑树(将链表转换成红黑树前会判断,如果当前数组的长度小于 64,那么会选择先进行数组扩容,而不是转换为红黑树),以减少搜索时间(后文中我会结合源码对这一过程进行分析)。`Hashtable` 没有这样的机制。
+- **哈希函数的实现**:`HashMap` 对哈希值进行了高位和低位的混合扰动处理以减少冲突,而 `Hashtable` 直接使用键的 `hashCode()` 值。
**`HashMap` 中带有初始容量的构造函数:**
@@ -47,18 +46,18 @@ head:
下面这个方法保证了 `HashMap` 总是使用 2 的幂作为哈希表的大小。
```java
- /**
- * Returns a power of two size for the given target capacity.
- */
- static final int tableSizeFor(int cap) {
- int n = cap - 1;
- n |= n >>> 1;
- n |= n >>> 2;
- n |= n >>> 4;
- n |= n >>> 8;
- n |= n >>> 16;
- return (n < 0) ? 1 : (n >= MAXIMUM_CAPACITY) ? MAXIMUM_CAPACITY : n + 1;
- }
+/**
+ * Returns a power of two size for the given target capacity.
+ */
+static final int tableSizeFor(int cap) {
+ int n = cap - 1;
+ n |= n >>> 1;
+ n |= n >>> 2;
+ n |= n >>> 4;
+ n |= n >>> 8;
+ n |= n >>> 16;
+ return (n < 0) ? 1 : (n >= MAXIMUM_CAPACITY) ? MAXIMUM_CAPACITY : n + 1;
+}
```
### HashMap 和 HashSet 区别
@@ -72,7 +71,7 @@ head:
| 调用 `put()`向 map 中添加元素 | 调用 `add()`方法向 `Set` 中添加元素 |
| `HashMap` 使用键(Key)计算 `hashcode` | `HashSet` 使用成员对象来计算 `hashcode` 值,对于两个对象来说 `hashcode` 可能相同,所以`equals()`方法用来判断对象的相等性 |
-### HashMap 和 TreeMap 区别
+### ⭐️HashMap 和 TreeMap 区别
`TreeMap` 和`HashMap` 都继承自`AbstractMap` ,但是需要注意的是`TreeMap`它还实现了`NavigableMap`接口和`SortedMap` 接口。
@@ -80,6 +79,15 @@ head:
实现 `NavigableMap` 接口让 `TreeMap` 有了对集合内元素的搜索的能力。
+`NavigableMap` 接口提供了丰富的方法来探索和操作键值对:
+
+1. **定向搜索**: `ceilingEntry()`, `floorEntry()`, `higherEntry()`和 `lowerEntry()` 等方法可以用于定位大于等于、小于等于、严格大于、严格小于给定键的最接近的键值对。
+2. **子集操作**: `subMap()`, `headMap()`和 `tailMap()` 方法可以高效地创建原集合的子集视图,而无需复制整个集合。
+3. **逆序视图**:`descendingMap()` 方法返回一个逆序的 `NavigableMap` 视图,使得可以反向迭代整个 `TreeMap`。
+4. **边界操作**: `firstEntry()`, `lastEntry()`, `pollFirstEntry()`和 `pollLastEntry()` 等方法可以方便地访问和移除元素。
+
+这些方法都是基于红黑树数据结构的属性实现的,红黑树保持平衡状态,从而保证了搜索操作的时间复杂度为 O(log n),这让 `TreeMap` 成为了处理有序集合搜索问题的强大工具。
+
实现`SortedMap`接口让 `TreeMap` 有了对集合中的元素根据键排序的能力。默认是按 key 的升序排序,不过我们也可以指定排序的比较器。示例代码如下:
```java
@@ -138,7 +146,7 @@ TreeMap treeMap = new TreeMap<>((person1, person2) -> {
});
```
-**综上,相比于`HashMap`来说 `TreeMap` 主要多了对集合中的元素根据键排序的能力以及对集合内元素的搜索的能力。**
+**综上,相比于`HashMap`来说, `TreeMap` 主要多了对集合中的元素根据键排序的能力以及对集合内元素的搜索的能力。**
### HashSet 如何检查重复?
@@ -169,13 +177,13 @@ final V putVal(int hash, K key, V value, boolean onlyIfAbsent,
也就是说,在 JDK1.8 中,实际上无论`HashSet`中是否已经存在了某元素,`HashSet`都会直接插入,只是会在`add()`方法的返回值处告诉我们插入前是否存在相同元素。
-### HashMap 的底层实现
+### ⭐️HashMap 的底层实现
#### JDK1.8 之前
JDK1.8 之前 `HashMap` 底层是 **数组和链表** 结合在一起使用也就是 **链表散列**。HashMap 通过 key 的 `hashcode` 经过扰动函数处理过后得到 hash 值,然后通过 `(n - 1) & hash` 判断当前元素存放的位置(这里的 n 指的是数组的长度),如果当前位置存在元素的话,就判断该元素与要存入的元素的 hash 值以及 key 是否相同,如果相同的话,直接覆盖,不相同就通过拉链法解决冲突。
-所谓扰动函数指的就是 HashMap 的 `hash` 方法。使用 `hash` 方法也就是扰动函数是为了防止一些实现比较差的 `hashCode()` 方法 换句话说使用扰动函数之后可以减少碰撞。
+`HashMap` 中的扰动函数(`hash` 方法)是用来优化哈希值的分布。通过对原始的 `hashCode()` 进行额外处理,扰动函数可以减小由于糟糕的 `hashCode()` 实现导致的碰撞,从而提高数据的分布均匀性。
**JDK 1.8 HashMap 的 hash 方法源码:**
@@ -212,10 +220,23 @@ static int hash(int h) {
#### JDK1.8 之后
-相比于之前的版本, JDK1.8 之后在解决哈希冲突时有了较大的变化,当链表长度大于阈值(默认为 8)(将链表转换成红黑树前会判断,如果当前数组的长度小于 64,那么会选择先进行数组扩容,而不是转换为红黑树)时,将链表转化为红黑树,以减少搜索时间。
+相比于之前的版本, JDK1.8 之后在解决哈希冲突时有了较大的变化,当链表长度大于阈值(默认为 8)(将链表转换成红黑树前会判断,如果当前数组的长度小于 64,那么会选择先进行数组扩容,而不是转换为红黑树)时,将链表转化为红黑树。
+
+这样做的目的是减少搜索时间:链表的查询效率为 O(n)(n 是链表的长度),红黑树是一种自平衡二叉搜索树,其查询效率为 O(log n)。当链表较短时,O(n) 和 O(log n) 的性能差异不明显。但当链表变长时,查询性能会显著下降。

+**为什么优先扩容而非直接转为红黑树?**
+
+数组扩容能减少哈希冲突的发生概率(即将元素重新分散到新的、更大的数组中),这在多数情况下比直接转换为红黑树更高效。
+
+红黑树需要保持自平衡,维护成本较高。并且,过早引入红黑树反而会增加复杂度。
+
+**为什么选择阈值 8 和 64?**
+
+1. 泊松分布表明,链表长度达到 8 的概率极低(小于千万分之一)。在绝大多数情况下,链表长度都不会超过 8。阈值设置为 8,可以保证性能和空间效率的平衡。
+2. 数组长度阈值 64 同样是经过实践验证的经验值。在小数组中扩容成本低,优先扩容可以避免过早引入红黑树。数组大小达到 64 时,冲突概率较高,此时红黑树的性能优势开始显现。
+
> TreeMap、TreeSet 以及 JDK1.8 之后的 HashMap 底层都用到了红黑树。红黑树就是为了解决二叉查找树的缺陷,因为二叉查找树在某些情况下会退化成一个线性结构。
我们来结合源码分析一下 `HashMap` 链表到红黑树的转换。
@@ -230,7 +251,7 @@ for (int binCount = 0; ; ++binCount) {
// 遍历到链表最后一个节点
if ((e = p.next) == null) {
p.next = newNode(hash, key, value, null);
- // 如果链表元素个数大于等于TREEIFY_THRESHOLD(8)
+ // 如果链表元素个数大于TREEIFY_THRESHOLD(8)
if (binCount >= TREEIFY_THRESHOLD - 1) // -1 for 1st
// 红黑树转换(并不会直接转换成红黑树)
treeifyBin(tab, hash);
@@ -274,15 +295,59 @@ final void treeifyBin(Node[] tab, int hash) {
将链表转换成红黑树前会判断,如果当前数组的长度小于 64,那么会选择先进行数组扩容,而不是转换为红黑树。
-### HashMap 的长度为什么是 2 的幂次方
+### ⭐️HashMap 的长度为什么是 2 的幂次方
-为了能让 HashMap 存取高效,尽量较少碰撞,也就是要尽量把数据分配均匀。我们上面也讲到了过了,Hash 值的范围值-2147483648 到 2147483647,前后加起来大概 40 亿的映射空间,只要哈希函数映射得比较均匀松散,一般应用是很难出现碰撞的。但问题是一个 40 亿长度的数组,内存是放不下的。所以这个散列值是不能直接拿来用的。用之前还要先做对数组的长度取模运算,得到的余数才能用来要存放的位置也就是对应的数组下标。这个数组下标的计算方法是“ `(n - 1) & hash`”。(n 代表数组长度)。这也就解释了 HashMap 的长度为什么是 2 的幂次方。
+为了让 `HashMap` 存取高效并减少碰撞,我们需要确保数据尽量均匀分布。哈希值在 Java 中通常使用 `int` 表示,其范围是 `-2147483648 ~ 2147483647`前后加起来大概 40 亿的映射空间,只要哈希函数映射得比较均匀松散,一般应用是很难出现碰撞的。但是,问题是一个 40 亿长度的数组,内存是放不下的。所以,这个散列值是不能直接拿来用的。用之前还要先做对数组的长度取模运算,得到的余数才能用来要存放的位置也就是对应的数组下标。
**这个算法应该如何设计呢?**
-我们首先可能会想到采用%取余的操作来实现。但是,重点来了:**“取余(%)操作中如果除数是 2 的幂次则等价于与其除数减一的与(&)操作(也就是说 hash%length==hash&(length-1)的前提是 length 是 2 的 n 次方;)。”** 并且 **采用二进制位操作 &,相对于%能够提高运算效率,这就解释了 HashMap 的长度为什么是 2 的幂次方。**
+我们首先可能会想到采用 % 取余的操作来实现。但是,重点来了:“**取余(%)操作中如果除数是 2 的幂次则等价于与其除数减一的与(&)操作**(也就是说 `hash%length==hash&(length-1)` 的前提是 length 是 2 的 n 次方)。” 并且,**采用二进制位操作 & 相对于 % 能够提高运算效率**。
-### HashMap 多线程操作导致死循环问题
+除了上面所说的位运算比取余效率高之外,我觉得更重要的一个原因是:**长度是 2 的幂次方,可以让 `HashMap` 在扩容的时候更均匀**。例如:
+
+- length = 8 时,length - 1 = 7 的二进制位`0111`
+- length = 16 时,length - 1 = 15 的二进制位`1111`
+
+这时候原本存在 `HashMap` 中的元素计算新的数组位置时 `hash&(length-1)`,取决 hash 的第四个二进制位(从右数),会出现两种情况:
+
+1. 第四个二进制位为 0,数组位置不变,也就是说当前元素在新数组和旧数组的位置相同。
+2. 第四个二进制位为 1,数组位置在新数组扩容之后的那一部分。
+
+这里列举一个例子:
+
+```plain
+假设有一个元素的哈希值为 10101100
+
+旧数组元素位置计算:
+hash = 10101100
+length - 1 = 00000111
+& -----------------
+index = 00000100 (4)
+
+新数组元素位置计算:
+hash = 10101100
+length - 1 = 00001111
+& -----------------
+index = 00001100 (12)
+
+看第四位(从右数):
+1.高位为 0:位置不变。
+2.高位为 1:移动到新位置(原索引位置+原容量)。
+```
+
+⚠️注意:这里列举的场景看的是第四个二进制位,更准确点来说看的是高位(从右数),例如 `length = 32` 时,`length - 1 = 31`,二进制为 `11111`,这里看的就是第五个二进制位。
+
+也就是说扩容之后,在旧数组元素 hash 值比较均匀(至于 hash 值均不均匀,取决于前面讲的对象的 `hashcode()` 方法和扰动函数)的情况下,新数组元素也会被分配的比较均匀,最好的情况是会有一半在新数组的前半部分,一半在新数组后半部分。
+
+这样也使得扩容机制变得简单和高效,扩容后只需检查哈希值高位的变化来决定元素的新位置,要么位置不变(高位为 0),要么就是移动到新位置(高位为 1,原索引位置+原容量)。
+
+最后,简单总结一下 `HashMap` 的长度是 2 的幂次方的原因:
+
+1. 位运算效率更高:位运算(&)比取余运算(%)更高效。当长度为 2 的幂次方时,`hash % length` 等价于 `hash & (length - 1)`。
+2. 可以更好地保证哈希值的均匀分布:扩容之后,在旧数组元素 hash 值比较均匀的情况下,新数组元素也会被分配的比较均匀,最好的情况是会有一半在新数组的前半部分,一半在新数组后半部分。
+3. 扩容机制变得简单和高效:扩容后只需检查哈希值高位的变化来决定元素的新位置,要么位置不变(高位为 0),要么就是移动到新位置(高位为 1,原索引位置+原容量)。
+
+### ⭐️HashMap 多线程操作导致死循环问题
JDK1.7 及之前版本的 `HashMap` 在多线程环境下扩容操作可能存在死循环问题,这是由于当一个桶位中有多个元素需要进行扩容时,多个线程同时对链表进行操作,头插法可能会导致链表中的节点指向错误的位置,从而形成一个环形链表,进而使得查询元素的操作陷入死循环无法结束。
@@ -290,9 +355,12 @@ JDK1.7 及之前版本的 `HashMap` 在多线程环境下扩容操作可能存
一般面试中这样介绍就差不多,不需要记各种细节,个人觉得也没必要记。如果想要详细了解 `HashMap` 扩容导致死循环问题,可以看看耗子叔的这篇文章:[Java HashMap 的死循环](https://coolshell.cn/articles/9606.html)。
-### HashMap 为什么线程不安全?
+### ⭐️HashMap 为什么线程不安全?
+
+`HashMap` 不是线程安全的。在多线程环境下对 `HashMap` 进行并发写操作,可能会导致两种主要问题:
-JDK1.7 及之前版本,在多线程环境下,`HashMap` 扩容时会造成死循环和数据丢失的问题。
+1. **数据丢失**:并发 `put` 操作可能导致一个线程的写入被另一个线程覆盖。
+2. **无限循环**:在 JDK 7 及以前的版本中,并发扩容时,由于头插法可能导致链表形成环,从而在 `get` 操作时引发无限循环,CPU 飙升至 100%。
数据丢失这个在 JDK1.7 和 JDK 1.8 中都存在,这里以 JDK 1.8 为例进行介绍。
@@ -374,11 +442,11 @@ Test.lambda avgt 5 1551065180.000 ± 19164407.426 ns/op
Test.parallelStream avgt 5 186345456.667 ± 3210435.590 ns/op
```
-### ConcurrentHashMap 和 Hashtable 的区别
+### ⭐️ConcurrentHashMap 和 Hashtable 的区别
`ConcurrentHashMap` 和 `Hashtable` 的区别主要体现在实现线程安全的方式上不同。
-- **底层数据结构:** JDK1.7 的 `ConcurrentHashMap` 底层采用 **分段的数组+链表** 实现,JDK1.8 采用的数据结构跟 `HashMap1.8` 的结构一样,数组+链表/红黑二叉树。`Hashtable` 和 JDK1.8 之前的 `HashMap` 的底层数据结构类似都是采用 **数组+链表** 的形式,数组是 HashMap 的主体,链表则是主要为了解决哈希冲突而存在的;
+- **底层数据结构:** JDK1.7 的 `ConcurrentHashMap` 底层采用 **分段的数组+链表** 实现,在 JDK1.8 中采用的数据结构跟 `HashMap` 的结构一样,数组+链表/红黑二叉树。`Hashtable` 和 JDK1.8 之前的 `HashMap` 的底层数据结构类似都是采用 **数组+链表** 的形式,数组是 HashMap 的主体,链表则是主要为了解决哈希冲突而存在的;
- **实现线程安全的方式(重要):**
- 在 JDK1.7 的时候,`ConcurrentHashMap` 对整个桶数组进行了分割分段(`Segment`,分段锁),每一把锁只锁容器其中一部分数据(下面有示意图),多线程访问容器里不同数据段的数据,就不会存在锁竞争,提高并发访问率。
- 到了 JDK1.8 的时候,`ConcurrentHashMap` 已经摒弃了 `Segment` 的概念,而是直接用 `Node` 数组+链表+红黑树的数据结构来实现,并发控制使用 `synchronized` 和 CAS 来操作。(JDK1.6 以后 `synchronized` 锁做了很多优化) 整个看起来就像是优化过且线程安全的 `HashMap`,虽然在 JDK1.8 中还能看到 `Segment` 的数据结构,但是已经简化了属性,只是为了兼容旧版本;
@@ -422,7 +490,7 @@ static final class TreeBin extends Node {
}
```
-### ConcurrentHashMap 线程安全的具体实现方式/底层具体实现
+### ⭐️ConcurrentHashMap 线程安全的具体实现方式/底层具体实现
#### JDK1.8 之前
@@ -453,7 +521,7 @@ Java 8 几乎完全重写了 `ConcurrentHashMap`,代码量从原来 Java 7 中
Java 8 中,锁粒度更细,`synchronized` 只锁定当前链表或红黑二叉树的首节点,这样只要 hash 不冲突,就不会产生并发,就不会影响其他 Node 的读写,效率大幅提升。
-### JDK 1.7 和 JDK 1.8 的 ConcurrentHashMap 实现有什么不同?
+### ⭐️JDK 1.7 和 JDK 1.8 的 ConcurrentHashMap 实现有什么不同?
- **线程安全实现方式**:JDK 1.7 采用 `Segment` 分段锁来保证安全, `Segment` 是继承自 `ReentrantLock`。JDK1.8 放弃了 `Segment` 分段锁的设计,采用 `Node + CAS + synchronized` 保证线程安全,锁粒度更细,`synchronized` 只锁定当前链表或红黑二叉树的首节点。
- **Hash 碰撞解决方法** : JDK 1.7 采用拉链法,JDK1.8 采用拉链法结合红黑树(链表长度超过一定阈值时,将链表转换为红黑树)。
@@ -490,7 +558,7 @@ public static final Object NULL = new Object();
翻译过来之后的,大致意思还是单线程下可以容忍歧义,而多线程下无法容忍。
-### ConcurrentHashMap 能保证复合操作的原子性吗?
+### ⭐️ConcurrentHashMap 能保证复合操作的原子性吗?
`ConcurrentHashMap` 是线程安全的,意味着它可以保证多个线程同时对它进行读写操作时,不会出现数据不一致的情况,也不会导致 JDK1.7 及之前版本的 `HashMap` 多线程操作导致死循环问题。但是,这并不意味着它可以保证所有的复合操作都是原子性的,一定不要搞混了!
diff --git a/docs/java/collection/linkedhashmap-source-code.md b/docs/java/collection/linkedhashmap-source-code.md
index 4803d388ff7..c1c59d04d1f 100644
--- a/docs/java/collection/linkedhashmap-source-code.md
+++ b/docs/java/collection/linkedhashmap-source-code.md
@@ -1,8 +1,13 @@
---
title: LinkedHashMap 源码分析
+description: LinkedHashMap源码深度剖析:详解LinkedHashMap维护双向链表实现插入/访问有序、LRU缓存实现、与HashMap区别及遍历效率优化。
category: Java
tag:
- Java集合
+head:
+ - - meta
+ - name: keywords
+ content: LinkedHashMap源码,插入顺序,访问顺序,LRU缓存,双向链表,有序Map,LinkedHashMap实现原理
---
## LinkedHashMap 简介
@@ -111,15 +116,16 @@ public class LRUCache extends LinkedHashMap {
}
```
-测试代码如下,笔者初始化缓存容量为 2,然后按照次序先后添加 4 个元素。
+测试代码如下,笔者初始化缓存容量为 3,然后按照次序先后添加 4 个元素。
```java
-LRUCache < Integer, String > cache = new LRUCache < > (2);
+LRUCache cache = new LRUCache<>(3);
cache.put(1, "one");
cache.put(2, "two");
cache.put(3, "three");
cache.put(4, "four");
-for (int i = 0; i < 4; i++) {
+cache.put(5, "five");
+for (int i = 1; i <= 5; i++) {
System.out.println(cache.get(i));
}
```
@@ -131,9 +137,10 @@ null
null
three
four
+five
```
-从输出结果来看,由于缓存容量为 2 ,因此,添加第 3 个元素时,第 1 个元素会被删除。添加第 4 个元素时,第 2 个元素会被删除。
+从输出结果来看,由于缓存容量为 3 ,因此,添加第 4 个元素时,第 1 个元素会被删除。添加第 5 个元素时,第 2 个元素会被删除。
## LinkedHashMap 源码解析
@@ -256,7 +263,7 @@ public V get(Object key) {
```java
void afterNodeAccess(Node < K, V > e) { // move node to last
LinkedHashMap.Entry < K, V > last;
- //如果accessOrder 且当前节点不未链表尾节点
+ //如果accessOrder 且当前节点不为链表尾节点
if (accessOrder && (last = tail) != e) {
//获取当前节点、以及前驱节点和后继节点
@@ -270,7 +277,7 @@ void afterNodeAccess(Node < K, V > e) { // move node to last
if (b == null)
head = a;
else
- //如果后继节点不为空,则让前驱节点指向后继节点
+ //如果前驱节点不为空,则让前驱节点指向后继节点
b.after = a;
//如果后继节点不为空,则让后继节点指向前驱节点
@@ -370,10 +377,10 @@ void afterNodeRemoval(Node e) { // unlink
从源码可以看出, `afterNodeRemoval` 方法的整体操作就是让当前节点 p 和前驱节点、后继节点断开联系,等待 gc 回收,整体步骤为:
-1. 获取当前节点 p、以及 e 的前驱节点 b 和后继节点 a。
+1. 获取当前节点 p、以及 p 的前驱节点 b 和后继节点 a。
2. 让当前节点 p 和其前驱、后继节点断开联系。
3. 尝试让前驱节点 b 指向后继节点 a,若 b 为空则说明当前节点 p 在链表首部,我们直接将 head 指向后继节点 a 即可。
-4. 尝试让后继节点 a 指向前驱节点 b,若 a 为空则说明当前节点 p 在链表末端,所以直接让 tail 指针指向前驱节点 a 即可。
+4. 尝试让后继节点 a 指向前驱节点 b,若 a 为空则说明当前节点 p 在链表末端,所以直接让 tail 指针指向前驱节点 b 即可。
可以结合这张图理解,展示了 key 为 13 的元素被删除,也就是从链表中移除了这个元素。
@@ -452,7 +459,7 @@ void afterNodeInsertion(boolean evict) { // possibly remove eldest
## LinkedHashMap 和 HashMap 遍历性能比较
-`LinkedHashMap` 维护了一个双向链表来记录数据插入的顺序,因此在迭代遍历生成的迭代器的时候,是按照双向链表的路径进行遍历的。这一点相比于 `HashMap` 那种遍历整个 bucket 的方式来说,高效需多。
+`LinkedHashMap` 维护了一个双向链表来记录数据插入的顺序,因此在迭代遍历生成的迭代器的时候,是按照双向链表的路径进行遍历的。这一点相比于 `HashMap` 那种遍历整个 bucket 的方式来说,高效许多。
这一点我们可以从两者的迭代器中得以印证,先来看看 `HashMap` 的迭代器,可以看到 `HashMap` 迭代键值对时会用到一个 `nextNode` 方法,该方法会返回 next 指向的下一个元素,并会从 next 开始遍历 bucket 找到下一个 bucket 中不为空的元素 Node。
@@ -482,7 +489,7 @@ void afterNodeInsertion(boolean evict) { // possibly remove eldest
}
```
-相比之下 `LinkedHashMap` 的迭代器则是直接使用通过 `after` 指针快速定位到当前节点的后继节点,简洁高效需多。
+相比之下 `LinkedHashMap` 的迭代器则是直接使用通过 `after` 指针快速定位到当前节点的后继节点,简洁高效许多。
```java
final class LinkedEntryIterator extends LinkedHashIterator
@@ -548,7 +555,7 @@ System.out.println("linkedHashMap get time: " + (end - start));
System.out.println(num);
```
-从输出结果来看,因为 `LinkedHashMap` 需要维护双向链表的缘故,插入元素相较于 `HashMap` 会更耗时,但是有了双向链表明确的前后节点关系,迭代效率相对于前者高效了需多。不过,总体来说却别不大,毕竟数据量这么庞大。
+从输出结果来看,因为 `LinkedHashMap` 需要维护双向链表的缘故,插入元素相较于 `HashMap` 会更耗时,但是有了双向链表明确的前后节点关系,迭代效率相对于前者高效了许多。不过,总体来说却别不大,毕竟数据量这么庞大。
```bash
map time putVal: 5880
diff --git a/docs/java/collection/linkedlist-source-code.md b/docs/java/collection/linkedlist-source-code.md
index 92fee67251d..b6d4c3d598c 100644
--- a/docs/java/collection/linkedlist-source-code.md
+++ b/docs/java/collection/linkedlist-source-code.md
@@ -1,8 +1,13 @@
---
title: LinkedList 源码分析
+description: LinkedList源码深度解析:剖析双向链表结构、Deque接口实现、头尾插入删除O(1)时间复杂度、与ArrayList性能对比及适用场景。
category: Java
tag:
- Java集合
+head:
+ - - meta
+ - name: keywords
+ content: LinkedList源码,双向链表,Deque接口,LinkedList与ArrayList区别,插入删除性能,链表实现
---
@@ -23,7 +28,7 @@ tag:
- 头部插入/删除:只需要修改头结点的指针即可完成插入/删除操作,因此时间复杂度为 O(1)。
- 尾部插入/删除:只需要修改尾结点的指针即可完成插入/删除操作,因此时间复杂度为 O(1)。
-- 指定位置插入/删除:需要先移动到指定位置,再修改指定节点的指针完成插入/删除,因此需要移动平均 n/2 个元素,时间复杂度为 O(n)。
+- 指定位置插入/删除:需要先移动到指定位置,再修改指定节点的指针完成插入/删除,不过由于有头尾指针,可以从较近的指针出发,因此需要遍历平均 n/4 个元素,时间复杂度为 O(n)。
### LinkedList 为什么不能实现 RandomAccess 接口?
@@ -99,7 +104,7 @@ public LinkedList(Collection extends E> c) {
`add()` 方法有两个版本:
- `add(E e)`:用于在 `LinkedList` 的尾部插入元素,即将新元素作为链表的最后一个元素,时间复杂度为 O(1)。
-- `add(int index, E element)`:用于在指定位置插入元素。这种插入方式需要先移动到指定位置,再修改指定节点的指针完成插入/删除,因此需要移动平均 n/2 个元素,时间复杂度为 O(n)。
+- `add(int index, E element)`:用于在指定位置插入元素。这种插入方式需要先移动到指定位置,再修改指定节点的指针完成插入/删除,因此需要移动平均 n/4 个元素,时间复杂度为 O(n)。
```java
// 在链表尾部插入元素
@@ -151,8 +156,9 @@ void linkBefore(E e, Node succ) {
final Node newNode = new Node<>(pred, e, succ);
// 将 succ 节点前驱引用 prev 指向新节点
succ.prev = newNode;
- // 判断尾节点是否为空,为空表示当前链表还没有节点
+ // 判断前驱节点是否为空,为空表示 succ 是第一个节点
if (pred == null)
+ // 新节点成为第一个节点
first = newNode;
else
// succ 节点前驱的后继引用指向新节点
diff --git a/docs/java/collection/priorityqueue-source-code.md b/docs/java/collection/priorityqueue-source-code.md
index b38cae9bcb9..c3cd5c5a5a8 100644
--- a/docs/java/collection/priorityqueue-source-code.md
+++ b/docs/java/collection/priorityqueue-source-code.md
@@ -1,8 +1,13 @@
---
title: PriorityQueue 源码分析(付费)
+description: PriorityQueue源码深度解析:详解基于二叉堆的优先队列实现、堆化siftUp/siftDown操作、Comparator自定义排序、动态扩容机制。
category: Java
tag:
- Java集合
+head:
+ - - meta
+ - name: keywords
+ content: PriorityQueue源码,优先队列,二叉堆,小顶堆,堆排序,Comparator,优先级队列实现
---
**PriorityQueue 源码分析** 为我的[知识星球](https://javaguide.cn/about-the-author/zhishixingqiu-two-years.html)(点击链接即可查看详细介绍以及加入方法)专属内容,已经整理到了[《Java 必读源码系列》](https://javaguide.cn/zhuanlan/source-code-reading.html)中。
diff --git a/docs/java/concurrent/aqs.md b/docs/java/concurrent/aqs.md
index d95388f0e02..8f45336ebbc 100644
--- a/docs/java/concurrent/aqs.md
+++ b/docs/java/concurrent/aqs.md
@@ -1,10 +1,17 @@
---
title: AQS 详解
+description: AQS抽象队列同步器深度解析:详解AQS核心原理、CLH队列结构、独占锁与共享锁实现、ReentrantLock/Semaphore等同步器应用、线程阻塞唤醒机制。
category: Java
tag:
- Java并发
+head:
+ - - meta
+ - name: keywords
+ content: AQS,AbstractQueuedSynchronizer,队列同步器,独占锁,共享锁,CLH队列,ReentrantLock实现原理
---
+
+
## AQS 介绍
AQS 的全称为 `AbstractQueuedSynchronizer` ,翻译过来的意思就是抽象队列同步器。这个类在 `java.util.concurrent.locks` 包下面。
@@ -18,31 +25,88 @@ public abstract class AbstractQueuedSynchronizer extends AbstractOwnableSynchron
}
```
-AQS 为构建锁和同步器提供了一些通用功能的实现,因此,使用 AQS 能简单且高效地构造出应用广泛的大量的同步器,比如我们提到的 `ReentrantLock`,`Semaphore`,其他的诸如 `ReentrantReadWriteLock`,`SynchronousQueue`等等皆是基于 AQS 的。
+AQS 为构建锁和同步器提供了一些通用功能的实现。因此,使用 AQS 能简单且高效地构造出应用广泛的大量的同步器,比如我们提到的 `ReentrantLock`,`Semaphore`,其他的诸如 `ReentrantReadWriteLock`,`SynchronousQueue`等等皆是基于 AQS 的。
## AQS 原理
在面试中被问到并发知识的时候,大多都会被问到“请你说一下自己对于 AQS 原理的理解”。下面给大家一个示例供大家参考,面试不是背题,大家一定要加入自己的思想,即使加入不了自己的思想也要保证自己能够通俗的讲出来而不是背出来。
+### AQS 快速了解
+
+在真正讲解 AQS 源码之前,需要对 AQS 有一个整体层面的认识。这里会先通过几个问题,从整体层面上认识 AQS,了解 AQS 在整个 Java 并发中所位于的层面,之后在学习 AQS 源码的过程中,才能更加了解同步器和 AQS 之间的关系。
+
+#### AQS 的作用是什么?
+
+AQS 解决了开发者在实现同步器时的复杂性问题。它提供了一个通用框架,用于实现各种同步器,例如 **可重入锁**(`ReentrantLock`)、**信号量**(`Semaphore`)和 **倒计时器**(`CountDownLatch`)。通过封装底层的线程同步机制,AQS 将复杂的线程管理逻辑隐藏起来,使开发者只需专注于具体的同步逻辑。
+
+简单来说,AQS 是一个抽象类,为同步器提供了通用的 **执行框架**。它定义了 **资源获取和释放的通用流程**,而具体的资源获取逻辑则由具体同步器通过重写模板方法来实现。 因此,可以将 AQS 看作是同步器的 **基础“底座”**,而同步器则是基于 AQS 实现的 **具体“应用”**。
+
+#### AQS 为什么使用 CLH 锁队列的变体?
+
+CLH 锁是一种基于 **自旋锁** 的优化实现。
+
+先说一下自旋锁存在的问题:自旋锁通过线程不断对一个原子变量执行 `compareAndSet`(简称 `CAS`)操作来尝试获取锁。在高并发场景下,多个线程会同时竞争同一个原子变量,容易造成某个线程的 `CAS` 操作长时间失败,从而导致 **“饥饿”问题**(某些线程可能永远无法获取锁)。
+
+CLH 锁通过引入一个队列来组织并发竞争的线程,对自旋锁进行了改进:
+
+- 每个线程会作为一个节点加入到队列中,并通过自旋监控前一个线程节点的状态,而不是直接竞争共享变量。
+- 线程按顺序排队,确保公平性,从而避免了 “饥饿” 问题。
+
+AQS(AbstractQueuedSynchronizer)在 CLH 锁的基础上进一步优化,形成了其内部的 **CLH 队列变体**。主要改进点有以下两方面:
+
+1. **自旋 + 阻塞**: CLH 锁使用纯自旋方式等待锁的释放,但大量的自旋操作会占用过多的 CPU 资源。AQS 引入了 **自旋 + 阻塞** 的混合机制:
+ - 如果线程获取锁失败,会先短暂自旋尝试获取锁;
+ - 如果仍然失败,则线程会进入阻塞状态,等待被唤醒,从而减少 CPU 的浪费。
+2. **单向队列改为双向队列**:CLH 锁使用单向队列,节点只知道前驱节点的状态,而当某个节点释放锁时,需要通过队列唤醒后续节点。AQS 将队列改为 **双向队列**,新增了 `next` 指针,使得节点不仅知道前驱节点,也可以直接唤醒后继节点,从而简化了队列操作,提高了唤醒效率。
+
+#### AQS 的性能比较好,原因是什么?
+
+因为 AQS 内部大量使用了 `CAS` 操作。
+
+AQS 内部通过队列来存储等待的线程节点。由于队列是共享资源,在多线程场景下,需要保证队列的同步访问。
+
+AQS 内部通过 `CAS` 操作来控制队列的同步访问,`CAS` 操作主要用于控制 `队列初始化` 、 `线程节点入队` 两个操作的并发安全。虽然利用 `CAS` 控制并发安全可以保证比较好的性能,但同时会带来比较高的 **编码复杂度** 。
+
+#### AQS 中为什么 Node 节点需要不同的状态?
+
+AQS 中的 `waitStatus` 状态类似于 **状态机** ,通过不同状态来表明 Node 节点的不同含义,并且根据不同操作,来控制状态之间的流转。
+
+- 状态 `0` :新节点加入队列之后,初始状态为 `0` 。
+
+- 状态 `SIGNAL` :当有新的节点加入队列,此时新节点的前继节点状态就会由 `0` 更新为 `SIGNAL` ,表示前继节点释放锁之后,需要对新节点进行唤醒操作。如果唤醒 `SIGNAL` 状态节点的后续节点,就会将 `SIGNAL` 状态更新为 `0` 。即通过清除 `SIGNAL` 状态,表示已经执行了唤醒操作。
+
+- 状态 `CANCELLED` :如果一个节点在队列中等待获取锁锁时,因为某种原因失败了,该节点的状态就会变为 `CANCELLED` ,表明取消获取锁,这种状态的节点是异常的,无法被唤醒,也无法唤醒后继节点。
+
### AQS 核心思想
-AQS 核心思想是,如果被请求的共享资源空闲,则将当前请求资源的线程设置为有效的工作线程,并且将共享资源设置为锁定状态。如果被请求的共享资源被占用,那么就需要一套线程阻塞等待以及被唤醒时锁分配的机制,这个机制 AQS 是基于 **CLH 锁** (Craig, Landin, and Hagersten locks) 实现的。
+AQS 核心思想是,如果被请求的共享资源空闲,则将当前请求资源的线程设置为有效的工作线程,并且将共享资源设置为锁定状态。如果被请求的共享资源被占用,那么就需要一套线程阻塞等待以及被唤醒时锁分配的机制,这个机制 AQS 是基于 **CLH 锁** (Craig, Landin, and Hagersten locks) 进一步优化实现的。
-CLH 锁是对自旋锁的一种改进,是一个虚拟的双向队列(虚拟的双向队列即不存在队列实例,仅存在结点之间的关联关系),暂时获取不到锁的线程将被加入到该队列中。AQS 将每条请求共享资源的线程封装成一个 CLH 队列锁的一个结点(Node)来实现锁的分配。在 CLH 队列锁中,一个节点表示一个线程,它保存着线程的引用(thread)、 当前节点在队列中的状态(waitStatus)、前驱节点(prev)、后继节点(next)。
+**CLH 锁** 对自旋锁进行了改进,是基于单链表的自旋锁。在多线程场景下,会将请求获取锁的线程组织成一个单向队列,每个等待的线程会通过自旋访问前一个线程节点的状态,前一个节点释放锁之后,当前节点才可以获取锁。**CLH 锁** 的队列结构如下图所示。
-CLH 队列结构如下图所示:
+
-
+AQS 中使用的 **等待队列** 是 CLH 锁队列的变体(接下来简称为 CLH 变体队列)。
+
+AQS 的 CLH 变体队列是一个双向队列,会暂时获取不到锁的线程将被加入到该队列中,CLH 变体队列和原本的 CLH 锁队列的区别主要有两点:
+
+- 由 **自旋** 优化为 **自旋 + 阻塞** :自旋操作的性能很高,但大量的自旋操作比较占用 CPU 资源,因此在 CLH 变体队列中会先通过自旋尝试获取锁,如果失败再进行阻塞等待。
+- 由 **单向队列** 优化为 **双向队列** :在 CLH 变体队列中,会对等待的线程进行阻塞操作,当队列前边的线程释放锁之后,需要对后边的线程进行唤醒,因此增加了 `next` 指针,成为了双向队列。
+
+AQS 将每条请求共享资源的线程封装成一个 CLH 变体队列的一个结点(Node)来实现锁的分配。在 CLH 变体队列中,一个节点表示一个线程,它保存着线程的引用(thread)、 当前节点在队列中的状态(waitStatus)、前驱节点(prev)、后继节点(next)。
+
+AQS 中的 CLH 变体队列结构如下图所示:
+
+
关于 AQS 核心数据结构-CLH 锁的详细解读,强烈推荐阅读 [Java AQS 核心数据结构-CLH 锁 - Qunar 技术沙龙](https://mp.weixin.qq.com/s/jEx-4XhNGOFdCo4Nou5tqg) 这篇文章。
AQS(`AbstractQueuedSynchronizer`)的核心原理图:
-
+
AQS 使用 **int 成员变量 `state` 表示同步状态**,通过内置的 **FIFO 线程等待/等待队列** 来完成获取资源线程的排队工作。
-`state` 变量由 `volatile` 修饰,用于展示当前临界资源的获锁情况。
+`state` 变量由 `volatile` 修饰,用于展示当前临界资源的获取情况。
```java
// 共享变量,使用volatile修饰保证线程可见性
@@ -66,7 +130,7 @@ protected final boolean compareAndSetState(int expect, int update) {
}
```
-以可重入的互斥锁 `ReentrantLock` 为例,它的内部维护了一个 `state` 变量,用来表示锁的占用状态。`state` 的初始值为 0,表示锁处于未锁定状态。当线程 A 调用 `lock()` 方法时,会尝试通过 `tryAcquire()` 方法独占该锁,并让 `state` 的值加 1。如果成功了,那么线程 A 就获取到了锁。如果失败了,那么线程 A 就会被加入到一个等待队列(CLH 队列)中,直到其他线程释放该锁。假设线程 A 获取锁成功了,释放锁之前,A 线程自己是可以重复获取此锁的(`state` 会累加)。这就是可重入性的体现:一个线程可以多次获取同一个锁而不会被阻塞。但是,这也意味着,一个线程必须释放与获取的次数相同的锁,才能让 `state` 的值回到 0,也就是让锁恢复到未锁定状态。只有这样,其他等待的线程才能有机会获取该锁。
+以可重入的互斥锁 `ReentrantLock` 为例,它的内部维护了一个 `state` 变量,用来表示锁的占用状态。`state` 的初始值为 0,表示锁处于未锁定状态。当线程 A 调用 `lock()` 方法时,会尝试通过 `tryAcquire()` 方法独占该锁,并让 `state` 的值加 1。如果成功了,那么线程 A 就获取到了锁。如果失败了,那么线程 A 就会被加入到一个等待队列(CLH 变体队列)中,直到其他线程释放该锁。假设线程 A 获取锁成功了,释放锁之前,A 线程自己是可以重复获取此锁的(`state` 会累加)。这就是可重入性的体现:一个线程可以多次获取同一个锁而不会被阻塞。但是,这也意味着,一个线程必须释放与获取的次数相同的锁,才能让 `state` 的值回到 0,也就是让锁恢复到未锁定状态。只有这样,其他等待的线程才能有机会获取该锁。
线程 A 尝试获取锁的过程如下图所示(图源[从 ReentrantLock 的实现看 AQS 的原理及应用 - 美团技术团队](./reentrantlock.md)):
@@ -74,20 +138,39 @@ protected final boolean compareAndSetState(int expect, int update) {
再以倒计时器 `CountDownLatch` 以例,任务分为 N 个子线程去执行,`state` 也初始化为 N(注意 N 要与线程个数一致)。这 N 个子线程开始执行任务,每执行完一个子线程,就调用一次 `countDown()` 方法。该方法会尝试使用 CAS(Compare and Swap) 操作,让 `state` 的值减少 1。当所有的子线程都执行完毕后(即 `state` 的值变为 0),`CountDownLatch` 会调用 `unpark()` 方法,唤醒主线程。这时,主线程就可以从 `await()` 方法(`CountDownLatch` 中的`await()` 方法而非 AQS 中的)返回,继续执行后续的操作。
-### AQS 资源共享方式
+### Node 节点 waitStatus 状态含义
-AQS 定义两种资源共享方式:`Exclusive`(独占,只有一个线程能执行,如`ReentrantLock`)和`Share`(共享,多个线程可同时执行,如`Semaphore`/`CountDownLatch`)。
+AQS 中的 `waitStatus` 状态类似于 **状态机** ,通过不同状态来表明 Node 节点的不同含义,并且根据不同操作,来控制状态之间的流转。
-一般来说,自定义同步器的共享方式要么是独占,要么是共享,他们也只需实现`tryAcquire-tryRelease`、`tryAcquireShared-tryReleaseShared`中的一种即可。但 AQS 也支持自定义同步器同时实现独占和共享两种方式,如`ReentrantReadWriteLock`。
+| Node 节点状态 | 值 | 含义 |
+| ------------- | --- | ------------------------------------------------------------------------------------------------------------------------- |
+| `CANCELLED` | 1 | 表示线程已经取消获取锁。线程在等待获取资源时被中断、等待资源超时会更新为该状态。 |
+| `SIGNAL` | -1 | 表示后继节点需要当前节点唤醒。在当前线程节点释放锁之后,需要对后继节点进行唤醒。 |
+| `CONDITION` | -2 | 表示节点在等待 Condition。当其他线程调用了 Condition 的 `signal()` 方法后,节点会从等待队列转移到同步队列中等待获取资源。 |
+| `PROPAGATE` | -3 | 用于共享模式。在共享模式下,可能会出现线程在队列中无法被唤醒的情况,因此引入了 `PROPAGATE` 状态来解决这个问题。 |
+| | 0 | 加入队列的新节点的初始状态。 |
-### 自定义同步器
+在 AQS 的源码中,经常使用 `> 0` 、 `< 0` 来对 `waitStatus` 进行判断。
+
+如果 `waitStatus > 0` ,表明节点的状态已经取消等待获取资源。
+
+如果 `waitStatus < 0` ,表明节点的状态处于正常的状态,即没有取消等待。
-同步器的设计是基于模板方法模式的,如果需要自定义同步器一般的方式是这样(模板方法模式很经典的一个应用):
+其中 `SIGNAL` 状态是最重要的,节点状态流转以及对应操作如下:
-1. 使用者继承 `AbstractQueuedSynchronizer` 并重写指定的方法。
-2. 将 AQS 组合在自定义同步组件的实现中,并调用其模板方法,而这些模板方法会调用使用者重写的方法。
+| 状态流转 | 对应操作 |
+| ---------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------- |
+| `0` | 新节点入队时,初始状态为 `0` 。 |
+| `0 -> SIGNAL` | 新节点入队时,它的前继节点状态会由 `0` 更新为 `SIGNAL` 。`SIGNAL` 状态表明该节点的后续节点需要被唤醒。 |
+| `SIGNAL -> 0` | 在唤醒后继节点时,需要清除当前节点的状态。通常发生在 `head` 节点,比如 `head` 节点的状态由 `SIGNAL` 更新为 `0` ,表示已经对 `head` 节点的后继节点唤醒了。 |
+| `0 -> PROPAGATE` | AQS 内部引入了 `PROPAGATE` 状态,为了解决并发场景下,可能造成的线程节点无法唤醒的情况。(在 AQS 共享模式获取资源的源码分析会讲到) |
-这和我们以往通过实现接口的方式有很大区别,这是模板方法模式很经典的一个运用。
+### 自定义同步器
+
+基于 AQS 可以实现自定义的同步器, AQS 提供了 5 个模板方法(模板方法模式)。如果需要自定义同步器一般的方式是这样(模板方法模式很经典的一个应用):
+
+1. 自定义的同步器继承 `AbstractQueuedSynchronizer` 。
+2. 重写 AQS 暴露的模板方法。
**AQS 使用了模板方法模式,自定义同步器时需要重写下面几个 AQS 提供的钩子方法:**
@@ -110,9 +193,1119 @@ protected boolean isHeldExclusively()
除了上面提到的钩子方法之外,AQS 类中的其他方法都是 `final` ,所以无法被其他类重写。
-## 常见同步工具类
+### AQS 资源共享方式
+
+AQS 定义两种资源共享方式:`Exclusive`(独占,只有一个线程能执行,如`ReentrantLock`)和`Share`(共享,多个线程可同时执行,如`Semaphore`/`CountDownLatch`)。
+
+一般来说,自定义同步器的共享方式要么是独占,要么是共享,他们也只需实现`tryAcquire-tryRelease`、`tryAcquireShared-tryReleaseShared`中的一种即可。但 AQS 也支持自定义同步器同时实现独占和共享两种方式,如`ReentrantReadWriteLock`。
+
+### 独占模式与共享模式的深入对比
+
+上面简要介绍了 AQS 的两种资源共享方式,下面从多个维度对独占模式和共享模式进行系统对比,帮助更深入地理解二者的差异。
+
+#### 特性对比
+
+| 对比维度 | 独占模式(Exclusive) | 共享模式(Share) |
+| --- | --- | --- |
+| **并发度** | 同一时刻只有一个线程能获取到资源 | 同一时刻可以有多个线程同时获取到资源 |
+| **获取资源入口** | `acquire(int arg)` | `acquireShared(int arg)` |
+| **释放资源入口** | `release(int arg)` | `releaseShared(int arg)` |
+| **需要重写的模板方法** | `tryAcquire(int)` / `tryRelease(int)` | `tryAcquireShared(int)` / `tryReleaseShared(int)` |
+| **tryXxx 返回值** | `boolean`,`true` 表示获取/释放成功 | `int`(获取时),负数表示失败,0 表示成功但无剩余资源,正数表示成功且有剩余资源;`boolean`(释放时) |
+| **唤醒后继节点** | 释放资源时唤醒一个后继节点 | 获取资源成功后,如果还有剩余资源,会继续唤醒后续节点(传播唤醒) |
+| **Node 类型标识** | `Node.EXCLUSIVE`(`null`) | `Node.SHARED`(一个静态的 `Node` 实例) |
+| **典型实现** | `ReentrantLock`、`ReentrantReadWriteLock` 的写锁 | `Semaphore`、`CountDownLatch`、`ReentrantReadWriteLock` 的读锁 |
+
+#### `state` 在不同同步器中的语义
+
+AQS 中的 `state` 是一个通用的同步状态变量,不同的同步器赋予它不同的含义:
+
+| 同步器 | 模式 | `state` 的语义 |
+| --- | --- | --- |
+| `ReentrantLock` | 独占 | 表示锁的重入次数。`state == 0` 表示锁空闲;`state > 0` 表示锁被持有,值为重入次数 |
+| `ReentrantReadWriteLock` | 独占 + 共享 | 高 16 位表示读锁的持有数量(共享),低 16 位表示写锁的重入次数(独占) |
+| `Semaphore` | 共享 | 表示可用许可证的数量。每次 `acquire()` 减少,`release()` 增加 |
+| `CountDownLatch` | 共享 | 表示需要等待的计数。每次 `countDown()` 减 1,到 0 时唤醒所有等待线程 |
+
+下面通过一个代码示例来直观感受独占模式和共享模式在使用上的区别:
+
+```java
+import java.util.concurrent.Semaphore;
+import java.util.concurrent.locks.ReentrantLock;
+
+public class ExclusiveVsSharedDemo {
+ public static void main(String[] args) {
+ // 独占模式:同一时刻只有 1 个线程能进入临界区
+ ReentrantLock lock = new ReentrantLock();
+
+ // 共享模式:同一时刻最多 3 个线程能进入临界区
+ Semaphore semaphore = new Semaphore(3);
+
+ // 独占模式示例
+ Runnable exclusiveTask = () -> {
+ lock.lock();
+ try {
+ System.out.println(Thread.currentThread().getName()
+ + " 获取到独占锁,正在执行...");
+ Thread.sleep(500);
+ } catch (InterruptedException e) {
+ Thread.currentThread().interrupt();
+ } finally {
+ lock.unlock();
+ }
+ };
+
+ // 共享模式示例
+ Runnable sharedTask = () -> {
+ try {
+ semaphore.acquire();
+ System.out.println(Thread.currentThread().getName()
+ + " 获取到许可证,正在执行...");
+ Thread.sleep(500);
+ } catch (InterruptedException e) {
+ Thread.currentThread().interrupt();
+ } finally {
+ semaphore.release();
+ }
+ };
+
+ System.out.println("=== 独占模式(ReentrantLock)===");
+ for (int i = 0; i < 5; i++) {
+ new Thread(exclusiveTask, "独占线程-" + i).start();
+ }
+
+ try { Thread.sleep(3000); } catch (InterruptedException e) { }
+
+ System.out.println("\n=== 共享模式(Semaphore)===");
+ for (int i = 0; i < 5; i++) {
+ new Thread(sharedTask, "共享线程-" + i).start();
+ }
+ }
+}
+```
+
+运行上面的代码可以观察到:独占模式下 5 个线程严格按顺序一个一个执行,而共享模式下最多有 3 个线程同时执行。
+
+### AQS 资源获取源码分析(独占模式)
+
+AQS 中以独占模式获取资源的入口方法是 `acquire()` ,如下:
+
+```JAVA
+// AQS
+public final void acquire(int arg) {
+ if (!tryAcquire(arg) &&
+ acquireQueued(addWaiter(Node.EXCLUSIVE), arg))
+ selfInterrupt();
+}
+```
+
+在 `acquire()` 中,线程会先尝试获取共享资源;如果获取失败,会将线程封装为 Node 节点加入到 AQS 的等待队列中;加入队列之后,会让等待队列中的线程尝试获取资源,并且会对线程进行阻塞操作。分别对应以下三个方法:
+
+- `tryAcquire()` :尝试获取锁(模板方法),`AQS` 不提供具体实现,由子类实现。
+- `addWaiter()` :如果获取锁失败,会将当前线程封装为 Node 节点加入到 AQS 的 CLH 变体队列中等待获取锁。
+- `acquireQueued()` :对线程进行阻塞,并调用 `tryAcquire()` 方法让队列中的线程尝试获取锁。
+
+#### `tryAcquire()` 分析
+
+AQS 中对应的 `tryAcquire()` 模板方法如下:
+
+```JAVA
+// AQS
+protected boolean tryAcquire(int arg) {
+ throw new UnsupportedOperationException();
+}
+```
+
+`tryAcquire()` 方法是 AQS 提供的模板方法,不提供默认实现。
+
+因此,这里分析 `tryAcquire()` 方法时,以 `ReentrantLock` 的非公平锁(独占锁)为例进行分析,`ReentrantLock` 内部实现的 `tryAcquire()` 会调用到下边的 `nonfairTryAcquire()` :
+
+```JAVA
+// ReentrantLock
+final boolean nonfairTryAcquire(int acquires) {
+ final Thread current = Thread.currentThread();
+ // 1、获取 AQS 中的 state 状态
+ int c = getState();
+ // 2、如果 state 为 0,证明锁没有被其他线程占用
+ if (c == 0) {
+ // 2.1、通过 CAS 对 state 进行更新
+ if (compareAndSetState(0, acquires)) {
+ // 2.2、如果 CAS 更新成功,就将锁的持有者设置为当前线程
+ setExclusiveOwnerThread(current);
+ return true;
+ }
+ }
+ // 3、如果当前线程和锁的持有线程相同,说明发生了「锁的重入」
+ else if (current == getExclusiveOwnerThread()) {
+ int nextc = c + acquires;
+ if (nextc < 0) // overflow
+ throw new Error("Maximum lock count exceeded");
+ // 3.1、将锁的重入次数加 1
+ setState(nextc);
+ return true;
+ }
+ // 4、如果锁被其他线程占用,就返回 false,表示获取锁失败
+ return false;
+}
+```
+
+在 `nonfairTryAcquire()` 方法内部,主要通过两个核心操作去完成资源的获取:
+
+- 通过 `CAS` 更新 `state` 变量。`state == 0` 表示资源没有被占用。`state > 0` 表示资源被占用,此时 `state` 表示重入次数。
+- 通过 `setExclusiveOwnerThread()` 设置持有资源的线程。
+
+如果线程更新 `state` 变量成功,就表明获取到了资源, 因此将持有资源的线程设置为当前线程即可。
+
+#### `addWaiter()` 分析
+
+在通过 `tryAcquire()` 方法尝试获取资源失败之后,会调用 `addWaiter()` 方法将当前线程封装为 Node 节点加入 `AQS` 内部的队列中。`addWaite()` 代码如下:
+
+```JAVA
+// AQS
+private Node addWaiter(Node mode) {
+ // 1、将当前线程封装为 Node 节点。
+ Node node = new Node(Thread.currentThread(), mode);
+ Node pred = tail;
+ // 2、如果 pred != null,则证明 tail 节点已经被初始化,直接将 Node 节点加入队列即可。
+ if (pred != null) {
+ node.prev = pred;
+ // 2.1、通过 CAS 控制并发安全。
+ if (compareAndSetTail(pred, node)) {
+ pred.next = node;
+ return node;
+ }
+ }
+ // 3、初始化队列,并将新创建的 Node 节点加入队列。
+ enq(node);
+ return node;
+}
+```
+
+**节点入队的并发安全:**
+
+在 `addWaiter()` 方法中,需要执行 Node 节点 **入队** 的操作。由于是在多线程环境下,因此需要通过 `CAS` 操作保证并发安全。
+
+通过 `CAS` 操作去更新 `tail` 指针指向新入队的 Node 节点,`CAS` 可以保证只有一个线程会成功修改 `tail` 指针,以此来保证 Node 节点入队时的并发安全。
+
+**AQS 内部队列的初始化:**
+
+在执行 `addWaiter()` 时,如果发现 `pred == null` ,即 `tail` 指针为 null,则证明队列没有初始化,需要调用 `enq()` 方法初始化队列,并将 `Node` 节点加入到初始化后的队列中,代码如下:
+
+```JAVA
+// AQS
+private Node enq(final Node node) {
+ for (;;) {
+ Node t = tail;
+ if (t == null) {
+ // 1、通过 CAS 操作保证队列初始化的并发安全
+ if (compareAndSetHead(new Node()))
+ tail = head;
+ } else {
+ // 2、与 addWaiter() 方法中节点入队的操作相同
+ node.prev = t;
+ if (compareAndSetTail(t, node)) {
+ t.next = node;
+ return t;
+ }
+ }
+ }
+}
+```
+
+在 `enq()` 方法中初始化队列,在初始化过程中,也需要通过 `CAS` 来保证并发安全。
+
+初始化队列总共包含两个步骤:初始化 `head` 节点、`tail` 指向 `head` 节点。
+
+**初始化后的队列如下图所示:**
+
+
+
+#### `acquireQueued()` 分析
+
+为了方便阅读,这里再贴一下 `AQS` 中 `acquire()` 获取资源的代码:
+
+```JAVA
+// AQS
+public final void acquire(int arg) {
+ if (!tryAcquire(arg) &&
+ acquireQueued(addWaiter(Node.EXCLUSIVE), arg))
+ selfInterrupt();
+}
+```
+
+在 `acquire()` 方法中,通过 `addWaiter()` 方法将 `Node` 节点加入队列之后,就会调用 `acquireQueued()` 方法。代码如下:
-下面介绍几个基于 AQS 的常见同步工具类。
+```JAVA
+// AQS:令队列中的节点尝试获取锁,并且对线程进行阻塞。
+final boolean acquireQueued(final Node node, int arg) {
+ boolean failed = true;
+ try {
+ boolean interrupted = false;
+ for (;;) {
+ // 1、尝试获取锁。
+ final Node p = node.predecessor();
+ if (p == head && tryAcquire(arg)) {
+ setHead(node);
+ p.next = null; // help GC
+ failed = false;
+ return interrupted;
+ }
+ // 2、判断线程是否可以阻塞,如果可以,则阻塞当前线程。
+ if (shouldParkAfterFailedAcquire(p, node) &&
+ parkAndCheckInterrupt())
+ interrupted = true;
+ }
+ } finally {
+ // 3、如果获取锁失败,就会取消获取锁,将节点状态更新为 CANCELLED。
+ if (failed)
+ cancelAcquire(node);
+ }
+}
+```
+
+在 `acquireQueued()` 方法中,主要做两件事情:
+
+- **尝试获取资源:** 当前线程加入队列之后,如果发现前继节点是 `head` 节点,说明当前线程是队列中第一个等待的节点,于是调用 `tryAcquire()` 尝试获取资源。
+
+- **阻塞当前线程** :如果尝试获取资源失败,就需要阻塞当前线程,等待被唤醒之后获取资源。
+
+**1、尝试获取资源**
+
+在 `acquireQueued()` 方法中,尝试获取资源总共有 2 个步骤:
+
+- `p == head` :表明当前节点的前继节点为 `head` 节点。此时当前节点为 AQS 队列中的第一个等待节点。
+- `tryAcquire(arg) == true` :表明当前线程尝试获取资源成功。
+
+在成功获取资源之后,就需要将当前线程的节点 **从等待队列中移除** 。移除操作为:将当前等待的线程节点设置为 `head` 节点(`head` 节点是虚拟节点,并不参与排队获取资源)。
+
+**2、阻塞当前线程**
+
+在 `AQS` 中,当前节点的唤醒需要依赖于上一个节点。如果上一个节点取消获取锁,它的状态就会变为 `CANCELLED` ,`CANCELLED` 状态的节点没有获取到锁,也就无法执行解锁操作对当前节点进行唤醒。因此在阻塞当前线程之前,需要跳过 `CANCELLED` 状态的节点。
+
+通过 `shouldParkAfterFailedAcquire()` 方法来判断当前线程节点是否可以阻塞,如下:
+
+```JAVA
+// AQS:判断当前线程节点是否可以阻塞。
+private static boolean shouldParkAfterFailedAcquire(Node pred, Node node) {
+ int ws = pred.waitStatus;
+ // 1、前继节点状态正常,直接返回 true 即可。
+ if (ws == Node.SIGNAL)
+ return true;
+ // 2、ws > 0 表示前继节点的状态异常,即为 CANCELLED 状态,需要跳过异常状态的节点。
+ if (ws > 0) {
+ do {
+ node.prev = pred = pred.prev;
+ } while (pred.waitStatus > 0);
+ pred.next = node;
+ } else {
+ // 3、如果前继节点的状态不是 SIGNAL,也不是 CANCELLED,就将状态设置为 SIGNAL。
+ compareAndSetWaitStatus(pred, ws, Node.SIGNAL);
+ }
+ return false;
+}
+```
+
+`shouldParkAfterFailedAcquire()` 方法中的判断逻辑:
+
+- 如果发现前继节点的状态是 `SIGNAL` ,则可以阻塞当前线程。
+- 如果发现前继节点的状态是 `CANCELLED` ,则需要跳过 `CANCELLED` 状态的节点。
+- 如果发现前继节点的状态不是 `SIGNAL` 和 `CANCELLED` ,表明前继节点的状态处于正常等待资源的状态,因此将前继节点的状态设置为 `SIGNAL` ,表明该前继节点需要对后续节点进行唤醒。
+
+当判断当前线程可以阻塞之后,通过调用 `parkAndCheckInterrupt()` 方法来阻塞当前线程。内部使用了 `LockSupport` 来实现阻塞。`LockSupoprt` 底层是基于 `Unsafe` 类来阻塞线程,代码如下:
+
+```JAVA
+// AQS
+private final boolean parkAndCheckInterrupt() {
+ // 1、线程阻塞到这里
+ LockSupport.park(this);
+ // 2、线程被唤醒之后,返回线程中断状态
+ return Thread.interrupted();
+}
+```
+
+**为什么在线程被唤醒之后,要返回线程的中断状态呢?**
+
+在 `parkAndCheckInterrupt()` 方法中,当执行完 `LockSupport.park(this)` ,线程会被阻塞,代码如下:
+
+```JAVA
+// AQS
+private final boolean parkAndCheckInterrupt() {
+ LockSupport.park(this);
+ // 线程被唤醒之后,需要返回线程中断状态
+ return Thread.interrupted();
+}
+```
+
+当线程被唤醒之后,需要执行 `Thread.interrupted()` 来返回线程的中断状态,这是为什么呢?
+
+这个和线程的中断协作机制有关系,线程被唤醒之后,并不确定是被中断唤醒,还是被 `LockSupport.unpark()` 唤醒,因此需要通过线程的中断状态来判断。
+
+**在 `acquire()` 方法中,为什么需要调用 `selfInterrupt()` ?**
+
+`acquire()` 方法代码如下:
+
+```JAVA
+// AQS
+public final void acquire(int arg) {
+ if (!tryAcquire(arg) &&
+ acquireQueued(addWaiter(Node.EXCLUSIVE), arg))
+ selfInterrupt();
+}
+```
+
+在 `acquire()` 方法中,当 `if` 语句的条件返回 `true` 后,就会调用 `selfInterrupt()` ,该方法会中断当前线程,为什么需要中断当前线程呢?
+
+当 `if` 判断为 `true` 时,需要 `tryAcquire()` 返回 `false` ,并且 `acquireQueued()` 返回 `true` 。
+
+其中 `acquireQueued()` 方法返回的是线程被唤醒之后的 **中断状态** ,通过执行 `Thread.interrupted()` 来返回。该方法在返回中断状态的同时,会清除线程的中断状态。
+
+因此如果 `if` 判断为 `true` ,表明线程的中断状态为 `true` ,但是调用 `Thread.interrupted()` 之后,线程的中断状态被清除为 `false` ,因此需要重新执行 `selfInterrupt()` 来重新设置线程的中断状态。
+
+### AQS 资源释放源码分析(独占模式)
+
+AQS 中以独占模式释放资源的入口方法是 `release()` ,代码如下:
+
+```JAVA
+// AQS
+public final boolean release(int arg) {
+ // 1、尝试释放锁
+ if (tryRelease(arg)) {
+ Node h = head;
+ // 2、唤醒后继节点
+ if (h != null && h.waitStatus != 0)
+ unparkSuccessor(h);
+ return true;
+ }
+ return false;
+}
+```
+
+在 `release()` 方法中,主要做两件事:尝试释放锁和唤醒后继节点。对应方法如下:
+
+**1、尝试释放锁**
+
+通过 `tryRelease()` 方法尝试释放锁,该方法为模板方法,由自定义同步器实现,因此这里仍然以 `ReentrantLock` 为例来讲解。
+
+`ReentrantLock` 中实现的 `tryRelease()` 方法如下:
+
+```JAVA
+// ReentrantLock
+protected final boolean tryRelease(int releases) {
+ int c = getState() - releases;
+ // 1、判断持有锁的线程是否为当前线程
+ if (Thread.currentThread() != getExclusiveOwnerThread())
+ throw new IllegalMonitorStateException();
+ boolean free = false;
+ // 2、如果 state 为 0,则表明当前线程已经没有重入次数。因此将 free 更新为 true,表明该线程会释放锁。
+ if (c == 0) {
+ free = true;
+ // 3、更新持有资源的线程为 null
+ setExclusiveOwnerThread(null);
+ }
+ // 4、更新 state 值
+ setState(c);
+ return free;
+}
+```
+
+在 `tryRelease()` 方法中,会先计算释放锁之后的 `state` 值,判断 `state` 值是否为 0。
+
+- 如果 `state == 0` ,表明该线程没有重入次数了,更新 `free = true` ,并修改持有资源的线程为 null,表明该线程完全释放这把锁。
+- 如果 `state != 0` ,表明该线程还存在重入次数,因此不更新 `free` 值,`free` 值为 `false` 表明该线程没有完全释放这把锁。
+
+之后更新 `state` 值,并返回 `free` 值,`free` 值表明线程是否完全释放锁。
+
+**2、唤醒后继节点**
+
+如果 `tryRelease()` 返回 `true` ,表明线程已经没有重入次数了,锁已经被完全释放,因此需要唤醒后继节点。
+
+在唤醒后继节点之前,需要判断是否可以唤醒后继节点,判断条件为: `h != null && h.waitStatus != 0` 。这里解释一下为什么要这样判断:
+
+- `h == null` :表明 `head` 节点还没有被初始化,也就是 AQS 中的队列没有被初始化,因此无法唤醒队列中的线程节点。
+- `h != null && h.waitStatus == 0` :表明头节点刚刚初始化完毕(节点的初始化状态为 0),后继节点线程还没有成功入队,因此不需要对后续节点进行唤醒。(当后继节点入队之后,会将前继节点的状态修改为 `SIGNAL` ,表明需要对后继节点进行唤醒)
+- `h != null && h.waitStatus != 0` :其中 `waitStatus` 有可能大于 0,也有可能小于 0。其中 `> 0` 表明节点已经取消等待获取资源,`< 0` 表明节点处于正常等待状态。
+
+接下来进入 `unparkSuccessor()` 方法查看如何唤醒后继节点:
+
+```JAVA
+// AQS:这里的入参 node 为队列的头节点(虚拟头节点)
+private void unparkSuccessor(Node node) {
+ int ws = node.waitStatus;
+ // 1、将头节点的状态进行清除,为后续的唤醒做准备。
+ if (ws < 0)
+ compareAndSetWaitStatus(node, ws, 0);
+
+ Node s = node.next;
+ // 2、如果后继节点异常,则需要从 tail 向前遍历,找到正常状态的节点进行唤醒。
+ if (s == null || s.waitStatus > 0) {
+ s = null;
+ for (Node t = tail; t != null && t != node; t = t.prev)
+ if (t.waitStatus <= 0)
+ s = t;
+ }
+ if (s != null)
+ // 3、唤醒后继节点
+ LockSupport.unpark(s.thread);
+}
+```
+
+在 `unparkSuccessor()` 中,如果头节点的状态 `< 0` (在正常情况下,只要有后继节点,头节点的状态应该为 `SIGNAL` ,即 -1),表示需要对后继节点进行唤醒,因此这里提前清除头节点的状态标识,将状态修改为 0,表示已经执行了对后续节点唤醒的操作。
+
+如果 `s == null` 或者 `s.waitStatus > 0` ,表明后继节点异常,此时不能唤醒异常节点,而是要找到正常状态的节点进行唤醒。
+
+因此需要从 `tail` 指针向前遍历,来找到第一个状态正常(`waitStatus <= 0`)的节点进行唤醒。
+
+**为什么要从 `tail` 指针向前遍历,而不是从 `head` 指针向后遍历,寻找正常状态的节点呢?**
+
+遍历的方向和 **节点的入队操作** 有关。入队方法如下:
+
+```JAVA
+// AQS:节点入队方法
+private Node addWaiter(Node mode) {
+ Node node = new Node(Thread.currentThread(), mode);
+ Node pred = tail;
+ if (pred != null) {
+ // 1、先修改 prev 指针。
+ node.prev = pred;
+ if (compareAndSetTail(pred, node)) {
+ // 2、再修改 next 指针。
+ pred.next = node;
+ return node;
+ }
+ }
+ enq(node);
+ return node;
+}
+```
+
+在 `addWaiter()` 方法中,`node` 节点入队需要修改 `node.prev` 和 `pred.next` 两个指针,但是这两个操作并不是 **原子操作** ,先修改了 `node.prev` 指针,之后才修改 `pred.next` 指针。
+
+在极端情况下,可能会出现 `head` 节点的下一个节点状态为 `CANCELLED` ,此时新入队的节点仅更新了 `node.prev` 指针,还未更新 `pred.next` 指针,如下图:
+
+
+
+这样如果从 `head` 指针向后遍历,无法找到新入队的节点,因此需要从 `tail` 指针向前遍历找到新入队的节点。
+
+### 图解 AQS 工作原理(独占模式)
+
+至此,AQS 中以独占模式获取资源、释放资源的源码就讲完了。为了对 AQS 的工作原理、节点状态变化有一个更加清晰的认识,接下来会通过画图的方式来了解整个 AQS 的工作原理。
+
+由于 AQS 是底层同步工具,获取和释放资源的方法并没有提供具体实现,因此这里基于 `ReentrantLock` 来画图进行讲解。
+
+假设总共有 3 个线程尝试获取锁,线程分别为 `T1` 、 `T2` 和 `T3` 。
+
+此时,假设线程 `T1` 先获取到锁,线程 `T2` 排队等待获取锁。在线程 `T2` 进入队列之前,需要对 AQS 内部队列进行初始化。`head` 节点在初始化后状态为 `0` 。AQS 内部初始化后的队列如下图:
+
+
+
+此时,线程 `T2` 尝试获取锁。由于线程 `T1` 持有锁,因此线程 `T2` 会进入队列中等待获取锁。同时会将前继节点( `head` 节点)的状态由 `0` 更新为 `SIGNAL` ,表示需要对 `head` 节点的后继节点进行唤醒。此时,AQS 内部队列如下图所示:
+
+
+
+此时,线程 `T3` 尝试获取锁。由于线程 `T1` 持有锁,因此线程 `T3` 会进入队列中等待获取锁。同时会将前继节点(线程 `T2` 节点)的状态由 `0` 更新为 `SIGNAL` ,表示线程 `T2` 节点需要对后继节点进行唤醒。此时,AQS 内部队列如下图所示:
+
+
+
+此时,假设线程 `T1` 释放锁,会唤醒后继节点 `T2` 。线程 `T2` 被唤醒后获取到锁,并且会从等待队列中退出。
+
+这里线程 `T2` 节点退出等待队列并不是直接从队列移除,而是令线程 `T2` 节点成为新的 `head` 节点,以此来退出资源获取的等待。此时 AQS 内部队列如下所示:
+
+
+
+此时,假设线程 `T2` 释放锁,会唤醒后继节点 `T3` 。线程 `T3` 获取到锁之后,同样也退出等待队列,即将线程 `T3` 节点变为 `head` 节点来退出资源获取的等待。此时 AQS 内部队列如下所示:
+
+
+
+### AQS 资源获取源码分析(共享模式)
+
+AQS 中以共享模式获取资源的入口方法是 `acquireShared()` ,如下:
+
+```JAVA
+// AQS
+public final void acquireShared(int arg) {
+ if (tryAcquireShared(arg) < 0)
+ doAcquireShared(arg);
+}
+```
+
+在 `acquireShared()` 方法中,会先尝试获取共享锁,如果获取失败,则将当前线程加入到队列中阻塞,等待唤醒后尝试获取共享锁,分别对应一下两个方法:`tryAcquireShared()` 和 `doAcquireShared()` 。
+
+其中 `tryAcquireShared()` 方法是 AQS 提供的模板方法,由同步器来实现具体逻辑。因此这里以 `Semaphore` 为例,来分析共享模式下,如何获取资源。
+
+#### `tryAcquireShared()` 分析
+
+`Semaphore` 中实现了公平锁和非公平锁,接下来以非公平锁为例来分析 `tryAcquireShared()` 源码。
+
+`Semaphore` 中重写的 `tryAcquireShared()` 方法会调用下边的 `nonfairTryAcquireShared()` 方法:
+
+```JAVA
+// Semaphore 重写 AQS 的模板方法
+protected int tryAcquireShared(int acquires) {
+ return nonfairTryAcquireShared(acquires);
+}
+
+// Semaphore
+final int nonfairTryAcquireShared(int acquires) {
+ for (;;) {
+ // 1、获取可用资源数量。
+ int available = getState();
+ // 2、计算剩余资源数量。
+ int remaining = available - acquires;
+ // 3、如果剩余资源数量 < 0,则说明资源不足,直接返回;如果 CAS 更新 state 成功,则说明当前线程获取到了共享资源,直接返回。
+ if (remaining < 0 ||
+ compareAndSetState(available, remaining))
+ return remaining;
+ }
+}
+```
+
+在共享模式下,AQS 中的 `state` 值表示共享资源的数量。
+
+在 `nonfairTryAcquireShared()` 方法中,会在死循环中不断尝试获取资源,如果 「剩余资源数不足」 或者 「当前线程成功获取资源」 ,就退出死循环。方法返回 **剩余的资源数量** ,根据返回值的不同,分为 3 种情况:
+
+- **剩余资源数量 > 0** :表示成功获取资源,并且后续的线程也可以成功获取资源。
+- **剩余资源数量 = 0** :表示成功获取资源,但是后续的线程无法成功获取资源。
+- **剩余资源数量 < 0** :表示获取资源失败。
+
+#### `doAcquireShared()` 分析
+
+为了方便阅读,这里再贴一下获取资源的入口方法 `acquireShared()` :
+
+```JAVA
+// AQS
+public final void acquireShared(int arg) {
+ if (tryAcquireShared(arg) < 0)
+ doAcquireShared(arg);
+}
+```
+
+在 `acquireShared()` 方法中,会先通过 `tryAcquireShared()` 尝试获取资源。
+
+如果发现方法的返回值 `< 0` ,即剩余的资源数小于 0,则表明当前线程获取资源失败。因此会进入 `doAcquireShared()` 方法,将当前线程加入到 AQS 队列进行等待。如下:
+
+```JAVA
+// AQS
+private void doAcquireShared(int arg) {
+ // 1、将当前线程加入到队列中等待。
+ final Node node = addWaiter(Node.SHARED);
+ boolean failed = true;
+ try {
+ boolean interrupted = false;
+ for (;;) {
+ final Node p = node.predecessor();
+ if (p == head) {
+ // 2、如果当前线程是等待队列的第一个节点,则尝试获取资源。
+ int r = tryAcquireShared(arg);
+ if (r >= 0) {
+ // 3、将当前线程节点移出等待队列,并唤醒后续线程节点。
+ setHeadAndPropagate(node, r);
+ p.next = null; // help GC
+ if (interrupted)
+ selfInterrupt();
+ failed = false;
+ return;
+ }
+ }
+ if (shouldParkAfterFailedAcquire(p, node) &&
+ parkAndCheckInterrupt())
+ interrupted = true;
+ }
+ } finally {
+ // 3、如果获取资源失败,就会取消获取资源,将节点状态更新为 CANCELLED。
+ if (failed)
+ cancelAcquire(node);
+ }
+}
+```
+
+由于当前线程已经尝试获取资源失败了,因此在 `doAcquireShared()` 方法中,需要将当前线程封装为 Node 节点,加入到队列中进行等待。
+
+以 **共享模式** 获取资源和 **独占模式** 获取资源最大的不同之处在于:共享模式下,资源的数量可能会大于 1,即可以多个线程同时持有资源。
+
+因此在共享模式下,当线程线程被唤醒之后,获取到了资源,如果发现还存在剩余资源,就会尝试唤醒后边的线程去尝试获取资源。对应的 `setHeadAndPropagate()` 方法如下:
+
+```JAVA
+// AQS
+private void setHeadAndPropagate(Node node, int propagate) {
+ Node h = head;
+ // 1、将当前线程节点移出等待队列。
+ setHead(node);
+ // 2、唤醒后续等待节点。
+ if (propagate > 0 || h == null || h.waitStatus < 0 ||
+ (h = head) == null || h.waitStatus < 0) {
+ Node s = node.next;
+ if (s == null || s.isShared())
+ doReleaseShared();
+ }
+}
+```
+
+在 `setHeadAndPropagate()` 方法中,唤醒后续节点需要满足一定的条件,主要需要满足 2 个条件:
+
+- `propagate > 0` :`propagate` 代表获取资源之后剩余的资源数量,如果 `> 0` ,则可以唤醒后续线程去获取资源。
+- `h.waitStatus < 0` :这里的 `h` 节点是执行 `setHead()` 之前的 `head` 节点。判断 `head.waitStatus` 时使用 `< 0` ,主要为了确定 `head` 节点的状态为 `SIGNAL` 或 `PROPAGATE` 。如果 `head` 节点为 `SIGNAL` ,则可以唤醒后续节点;如果 `head` 节点状态为 `PROPAGATE` ,也可以唤醒后续节点(这是为了解决并发场景下出现的问题,后续会细讲)。
+
+代码中关于 **唤醒后续等待节点** 的 `if` 判断稍微复杂一些,这里来讲一下为什么这样写:
+
+```JAVA
+if (propagate > 0 || h == null || h.waitStatus < 0 ||
+ (h = head) == null || h.waitStatus < 0)
+```
+
+- `h == null || h.waitStatus < 0` : `h == null` 用于防止空指针异常。正常情况下 h 不会为 `null` ,因为执行到这里之前,当前节点已经加入到队列中了,队列不可能还没有初始化。
+
+ `h.waitStatus < 0` 主要判断 `head` 节点的状态是否为 `SIGNAL` 或者 `PROPAGATE` ,直接使用 `< 0` 来判断比较方便。
+
+- `(h = head) == null || h.waitStatus < 0` :如果到这里说明之前判断的 `h.waitStatus < 0` ,说明存在并发。
+
+ 同时存在其他线程在唤醒后续节点,已经将 `head` 节点的值由 `SIGNAL` 修改为 `0` 了。因此,这里重新获取新的 `head` 节点,这次获取的 `head` 节点为通过 `setHead()` 设置的当前线程节点,之后再次判断 `waitStatus` 状态。
+
+如果 `if` 条件判断通过,就会走到 `doReleaseShared()` 方法唤醒后续等待节点,如下:
+
+```JAVA
+private void doReleaseShared() {
+ for (;;) {
+ Node h = head;
+ // 1、队列中至少需要一个等待的线程节点。
+ if (h != null && h != tail) {
+ int ws = h.waitStatus;
+ // 2、如果 head 节点的状态为 SIGNAL,则可以唤醒后继节点。
+ if (ws == Node.SIGNAL) {
+ // 2.1 清除 head 节点的 SIGNAL 状态,更新为 0。表示已经唤醒该节点的后继节点了。
+ if (!compareAndSetWaitStatus(h, Node.SIGNAL, 0))
+ continue;
+ // 2.2 唤醒后继节点
+ unparkSuccessor(h);
+ }
+ // 3、如果 head 节点的状态为 0,则更新为 PROPAGATE。这是为了解决并发场景下存在的问题,接下来会细讲。
+ else if (ws == 0 &&
+ !compareAndSetWaitStatus(h, 0, Node.PROPAGATE))
+ continue;
+ }
+ if (h == head)
+ break;
+ }
+}
+```
+
+在 `doReleaseShared()` 方法中,会判断 `head` 节点的 `waitStatus` 状态来决定接下来的操作,有两种情况:
+
+- `head` 节点的状态为 `SIGNAL` :表明 `head` 节点存在后继节点需要唤醒,因此通过 `CAS` 操作将 `head` 节点的 `SIGNAL` 状态更新为 `0` 。通过清除 `SIGNAL` 状态来表示已经对 `head` 节点的后继节点进行唤醒操作了。
+- `head` 节点的状态为 `0` :表明存在并发情况,需要将 `0` 修改为 `PROPAGATE` 来保证在并发场景下可以正常唤醒线程。
+
+#### 为什么需要 `PROPAGATE` 状态?
+
+在 `doReleaseShared()` 释放资源时,第 3 步不太容易理解,即如果发现 `head` 节点的状态是 `0` ,就将 `head` 节点的状态由 `0` 更新为 `PROPAGATE` 。
+
+AQS 中,Node 节点的 `PROPAGATE` 就是为了处理并发场景下可能出现的无法唤醒线程节点的问题。`PROPAGATE` 只在 `doReleaseShared()` 方法中用到一次。
+
+**接下来通过案例分析,为什么需要 `PROPAGATE` 状态?**
+
+在共享模式下,线程获取和释放资源的方法调用链如下:
+
+- 线程获取资源的方法调用链为: `acquireShared() -> tryAcquireShared() -> 线程阻塞等待唤醒 -> tryAcquireShared() -> setHeadAndPropagate() -> if (剩余资源数 > 0) || (head.waitStatus < 0) 则唤醒后续节点` 。
+
+- 线程释放资源的方法调用链为: `releaseShared() -> tryReleaseShared() -> doReleaseShared()` 。
+
+**如果在释放资源时,没有将 `head` 节点的状态由 `0` 改为 `PROPAGATE` :**
+
+假设总共有 4 个线程尝试以共享模式获取资源,总共有 2 个资源。初始 `T3` 和 `T4` 线程获取到了资源,`T1` 和 `T2` 线程没有获取到,因此在队列中排队等候。
+
+- 在时刻 1 时,线程 `T1` 和 `T2` 在等待队列中,`T3` 和 `T4` 持有资源。此时等待队列内节点以及对应状态为(括号内为节点的 `waitStatus` 状态):
+
+ `head(-1) -> T1(-1) -> T2(0)` 。
+
+- 在时刻 2 时,线程 `T3` 释放资源,通过 `doReleaseShared()` 方法将 `head` 节点的状态由 `SIGNAL` 更新为 `0` ,并唤醒线程 `T1` ,之后线程 `T3` 退出。
+
+ 线程 `T1` 被唤醒之后,通过 `tryAcquireShared()` 获取到资源,但是此时还未来得及执行 `setHeadAndPropagate()` 将自己设置为 `head` 节点。此时等待队列内节点状态为:
+
+ `head(0) -> T1(-1) -> T2(0)` 。
+
+- 在时刻 3 时,线程 `T4` 释放资源, 由于此时 `head` 节点的状态为 `0` ,因此在 `doReleaseShared()` 方法中无法唤醒 `head` 的后继节点, 之后线程 `T4` 退出。
+
+- 在时刻 4 时,线程 `T1` 继续执行 `setHeadAndPropagate()` 方法将自己设置为 `head` 节点。
+
+ 但是此时由于线程 `T1` 执行 `tryAcquireShared()` 方法返回的剩余资源数为 `0` ,并且 `head` 节点的状态为 `0` ,因此线程 `T1` 并不会在 `setHeadAndPropagate()` 方法中唤醒后续节点。此时等待队列内节点状态为:
+
+ `head(-1,线程 T1 节点) -> T2(0)` 。
+
+此时,就导致线程 `T2` 节点在等待队列中,无法被唤醒。对应时刻表如下:
+
+| 时刻 | 线程 T1 | 线程 T2 | 线程 T3 | 线程 T4 | 等待队列 |
+| ------ | -------------------------------------------------------------- | -------- | ---------------- | ------------------------------------------------------------- | --------------------------------- |
+| 时刻 1 | 等待队列 | 等待队列 | 持有资源 | 持有资源 | `head(-1) -> T1(-1) -> T2(0)` |
+| 时刻 2 | (执行)被唤醒后,获取资源,但未来得及将自己设置为 `head` 节点 | 等待队列 | (执行)释放资源 | 持有资源 | `head(0) -> T1(-1) -> T2(0)` |
+| 时刻 3 | | 等待队列 | 已退出 | (执行)释放资源。但 `head` 节点状态为 `0` ,无法唤醒后继节点 | `head(0) -> T1(-1) -> T2(0)` |
+| 时刻 4 | (执行)将自己设置为 `head` 节点 | 等待队列 | 已退出 | 已退出 | `head(-1,线程 T1 节点) -> T2(0)` |
+
+**如果在线程释放资源时,将 `head` 节点的状态由 `0` 改为 `PROPAGATE` ,则可以解决上边出现的并发问题,如下:**
+
+- 在时刻 1 时,线程 `T1` 和 `T2` 在等待队列中,`T3` 和 `T4` 持有资源。此时等待队列内节点以及对应状态为:
+
+ `head(-1) -> T1(-1) -> T2(0)` 。
+
+- 在时刻 2 时,线程 `T3` 释放资源,通过 `doReleaseShared()` 方法将 `head` 节点的状态由 `SIGNAL` 更新为 `0` ,并唤醒线程 `T1` ,之后线程 `T3` 退出。
+
+ 线程 `T1` 被唤醒之后,通过 `tryAcquireShared()` 获取到资源,但是此时还未来得及执行 `setHeadAndPropagate()` 将自己设置为 `head` 节点。此时等待队列内节点状态为:
+
+ `head(0) -> T1(-1) -> T2(0)` 。
+
+- 在时刻 3 时,线程 `T4` 释放资源, 由于此时 `head` 节点的状态为 `0` ,因此在 `doReleaseShared()` 方法中会将 `head` 节点的状态由 `0` 更新为 `PROPAGATE` , 之后线程 `T4` 退出。此时等待队列内节点状态为:
+
+ `head(PROPAGATE) -> T1(-1) -> T2(0)` 。
+
+- 在时刻 4 时,线程 `T1` 继续执行 `setHeadAndPropagate()` 方法将自己设置为 `head` 节点。此时等待队列内节点状态为:
+
+ `head(-1,线程 T1 节点) -> T2(0)` 。
+
+- 在时刻 5 时,虽然此时由于线程 `T1` 执行 `tryAcquireShared()` 方法返回的剩余资源数为 `0` ,但是 `head` 节点状态为 `PROPAGATE < 0` (这里的 `head` 节点是老的 `head` 节点,而不是刚成为 `head` 节点的线程 `T1` 节点)。
+
+ 因此线程 `T1` 会在 `setHeadAndPropagate()` 方法中唤醒后续 `T2` 节点,并将 `head` 节点的状态由 `SIGNAL` 更新为 `0`。此时等待队列内节点状态为:
+
+ `head(0,线程 T1 节点) -> T2(0)` 。
+
+- 在时刻 6 时,线程 `T2` 被唤醒后,获取到资源,并将自己设置为 `head` 节点。此时等待队列内节点状态为:
+
+ `head(0,线程 T2 节点)` 。
+
+有了 `PROPAGATE` 状态,就可以避免线程 `T2` 无法被唤醒的情况。对应时刻表如下:
+
+| 时刻 | 线程 T1 | 线程 T2 | 线程 T3 | 线程 T4 | 等待队列 |
+| ------ | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------ | ---------------- | ------------------------------------------------------------------- | ------------------------------------ |
+| 时刻 1 | 等待队列 | 等待队列 | 持有资源 | 持有资源 | `head(-1) -> T1(-1) -> T2(0)` |
+| 时刻 2 | (执行)被唤醒后,获取资源,但未来得及将自己设置为 `head` 节点 | 等待队列 | (执行)释放资源 | 持有资源 | `head(0) -> T1(-1) -> T2(0)` |
+| 时刻 3 | 未继续向下执行 | 等待队列 | 已退出 | (执行)释放资源。此时会将 `head` 节点状态由 `0` 更新为 `PROPAGATE` | `head(PROPAGATE) -> T1(-1) -> T2(0)` |
+| 时刻 4 | (执行)将自己设置为 `head` 节点 | 等待队列 | 已退出 | 已退出 | `head(-1,线程 T1 节点) -> T2(0)` |
+| 时刻 5 | (执行)由于 `head` 节点状态为 `PROPAGATE < 0` ,因此会在 `setHeadAndPropagate()` 方法中唤醒后续节点,此时将新的 `head` 节点的状态由 `SIGNAL` 更新为 `0` ,并唤醒线程 `T2` | 等待队列 | 已退出 | 已退出 | `head(0,线程 T1 节点) -> T2(0)` |
+| 时刻 6 | 已退出 | (执行)线程 `T2` 被唤醒后,获取到资源,并将自己设置为 `head` 节点 | 已退出 | 已退出 | `head(0,线程 T2 节点)` |
+
+### AQS 资源释放源码分析(共享模式)
+
+AQS 中以共享模式释放资源的入口方法是 `releaseShared()` ,代码如下:
+
+```JAVA
+// AQS
+public final boolean releaseShared(int arg) {
+ if (tryReleaseShared(arg)) {
+ doReleaseShared();
+ return true;
+ }
+ return false;
+}
+```
+
+其中 `tryReleaseShared()` 方法是 AQS 提供的模板方法,这里同样以 `Semaphore` 来讲解,如下:
+
+```JAVA
+// Semaphore
+protected final boolean tryReleaseShared(int releases) {
+ for (;;) {
+ int current = getState();
+ int next = current + releases;
+ if (next < current) // overflow
+ throw new Error("Maximum permit count exceeded");
+ if (compareAndSetState(current, next))
+ return true;
+ }
+}
+```
+
+在 `Semaphore` 实现的 `tryReleaseShared()` 方法中,会在死循环内不断尝试释放资源,即通过 `CAS` 操作来更新 `state` 值。
+
+如果更新成功,则证明资源释放成功,会进入到 `doReleaseShared()` 方法。
+
+`doReleaseShared()` 方法在前文获取资源(共享模式)的部分已进行了详细的源码分析,此处不再重复。
+
+### Condition 条件队列的工作机制
+
+前面在 `waitStatus` 状态表格中提到过 `CONDITION`(值为 -2)状态,表示节点在 Condition 条件队列中等待。这里系统讲解 Condition 条件队列的工作机制。
+
+#### 什么是 Condition?
+
+`Condition` 是 `java.util.concurrent.locks` 包中定义的接口,它提供了类似于 `Object.wait()` / `Object.notify()` 的线程等待/通知机制,但功能更加强大和灵活。`Condition` 必须与 `Lock` 配合使用,就像 `wait/notify` 必须与 `synchronized` 配合使用一样。
+
+与 `Object` 的 `wait/notify` 相比,`Condition` 的主要优势在于:
+
+- **支持多个等待队列**:一个 `Lock` 可以创建多个 `Condition` 实例,不同的线程可以在不同的条件上等待,实现更精细的线程协作。而 `synchronized` 只有一个等待队列。
+- **支持不响应中断的等待**:`Condition` 提供了 `awaitUninterruptibly()` 方法。
+- **支持超时等待**:`Condition` 提供了 `awaitNanos(long)` 和 `await(long, TimeUnit)` 方法,可以设定等待的截止时间。
+
+#### AQS 中的两种队列
+
+在 AQS 内部实际上维护了 **两种队列**:
+
+1. **同步队列(CLH 变体队列)**:就是前面详细分析过的双向队列,用于存放获取资源失败而等待的线程节点。
+2. **条件队列(Condition Queue)**:是一个单向链表,用于存放调用了 `Condition.await()` 方法而等待的线程节点。每个 `Condition` 实例维护一个独立的条件队列。
+
+条件队列中的节点使用 `Node` 的 `nextWaiter` 指针来链接下一个节点,形成单向链表。条件队列的头节点为 `firstWaiter`,尾节点为 `lastWaiter`。
+
+#### Condition 的核心工作流程
+
+AQS 的内部类 `ConditionObject` 实现了 `Condition` 接口,其核心方法为 `await()` 和 `signal()`。
+
+**`await()` 方法的工作流程:**
+
+1. 将当前线程封装为 `Node` 节点(`waitStatus` 设置为 `CONDITION`),加入到条件队列的尾部。
+2. 完全释放当前线程持有的锁(即将 `state` 值置为 0),并保存释放前的 `state` 值。
+3. 阻塞当前线程,等待被 `signal()` 唤醒或被中断。
+4. 被唤醒后,重新通过 `acquireQueued()` 进入同步队列竞争锁,并恢复之前保存的 `state` 值(重入次数)。
+
+**`signal()` 方法的工作流程:**
+
+1. 检查调用 `signal()` 的线程是否持有锁(不持有则抛出 `IllegalMonitorStateException`)。
+2. 将条件队列中第一个等待的节点从条件队列移除。
+3. 将该节点的 `waitStatus` 从 `CONDITION` 修改为 `0`,并通过 `enq()` 方法将其加入到同步队列的尾部。
+4. 如果同步队列中前驱节点的状态异常(`CANCELLED`)或者 CAS 设置前驱节点状态为 `SIGNAL` 失败,则直接唤醒该线程。
+
+`signalAll()` 方法与 `signal()` 类似,区别在于它会将条件队列中的 **所有** 节点都转移到同步队列中。
+
+下面的代码示例展示了 `Condition` 的典型用法——实现一个简单的有界阻塞队列:
+
+```java
+import java.util.LinkedList;
+import java.util.Queue;
+import java.util.concurrent.locks.Condition;
+import java.util.concurrent.locks.ReentrantLock;
+
+public class SimpleBlockingQueue {
+ private final Queue queue = new LinkedList<>();
+ private final int capacity;
+ private final ReentrantLock lock = new ReentrantLock();
+ // 两个不同的条件队列:分别用于"队列不满"和"队列不空"
+ private final Condition notFull = lock.newCondition();
+ private final Condition notEmpty = lock.newCondition();
+
+ public SimpleBlockingQueue(int capacity) {
+ this.capacity = capacity;
+ }
+
+ /**
+ * 向队列中添加元素,如果队列已满则等待。
+ */
+ public void put(T item) throws InterruptedException {
+ lock.lock();
+ try {
+ // 队列满时,在 notFull 条件上等待
+ while (queue.size() == capacity) {
+ notFull.await();
+ }
+ queue.offer(item);
+ // 添加元素后,通知在 notEmpty 条件上等待的消费者线程
+ notEmpty.signal();
+ } finally {
+ lock.unlock();
+ }
+ }
+
+ /**
+ * 从队列中取出元素,如果队列为空则等待。
+ */
+ public T take() throws InterruptedException {
+ lock.lock();
+ try {
+ // 队列空时,在 notEmpty 条件上等待
+ while (queue.isEmpty()) {
+ notEmpty.await();
+ }
+ T item = queue.poll();
+ // 取出元素后,通知在 notFull 条件上等待的生产者线程
+ notFull.signal();
+ return item;
+ } finally {
+ lock.unlock();
+ }
+ }
+
+ public static void main(String[] args) {
+ SimpleBlockingQueue blockingQueue = new SimpleBlockingQueue<>(5);
+
+ // 生产者线程
+ Thread producer = new Thread(() -> {
+ try {
+ for (int i = 0; i < 10; i++) {
+ blockingQueue.put(i);
+ System.out.println("生产: " + i);
+ }
+ } catch (InterruptedException e) {
+ Thread.currentThread().interrupt();
+ }
+ }, "Producer");
+
+ // 消费者线程
+ Thread consumer = new Thread(() -> {
+ try {
+ for (int i = 0; i < 10; i++) {
+ int item = blockingQueue.take();
+ System.out.println("消费: " + item);
+ }
+ } catch (InterruptedException e) {
+ Thread.currentThread().interrupt();
+ }
+ }, "Consumer");
+
+ producer.start();
+ consumer.start();
+ }
+}
+```
+
+在上面的例子中,`notFull` 和 `notEmpty` 是两个独立的 `Condition` 实例,分别维护各自的条件队列。生产者在队列满时在 `notFull` 上等待,消费者在队列空时在 `notEmpty` 上等待。这种分离等待条件的设计,避免了不必要的线程唤醒,比 `synchronized` + `wait/notifyAll` 更加高效。
+
+#### `await()` 核心源码分析
+
+```java
+// AQS 内部类 ConditionObject
+public final void await() throws InterruptedException {
+ if (Thread.interrupted())
+ throw new InterruptedException();
+ // 1、将当前线程封装为 Node 节点,加入条件队列
+ Node node = addConditionWaiter();
+ // 2、完全释放锁,并保存释放前的 state 值
+ int savedState = fullyRelease(node);
+ int interruptMode = 0;
+ // 3、如果节点不在同步队列中,则阻塞当前线程
+ while (!isOnSyncQueue(node)) {
+ LockSupport.park(this);
+ if ((interruptMode = checkInterruptWhileWaiting(node)) != 0)
+ break;
+ }
+ // 4、被唤醒后,重新进入同步队列竞争锁
+ if (acquireQueued(node, savedState) && interruptMode != THROW_IE)
+ interruptMode = REINTERRUPT;
+ if (node.nextWaiter != null)
+ unlinkCancelledWaiters();
+ if (interruptMode != 0)
+ reportInterruptAfterWait(interruptMode);
+}
+```
+
+`await()` 方法中有两个关键操作:
+
+- `fullyRelease(node)`:完全释放锁(而不是只释放一次),这样即使线程重入了多次锁,也能在等待期间让其他线程获取到锁。被唤醒后会通过 `acquireQueued(node, savedState)` 恢复之前的重入次数。
+- `isOnSyncQueue(node)`:判断节点是否已经被转移到同步队列。当其他线程调用 `signal()` 时,节点会从条件队列转移到同步队列,此时 `isOnSyncQueue()` 返回 `true`,线程退出 `while` 循环,开始竞争锁。
+
+### 公平锁与非公平锁的性能差异分析
+
+前面的源码分析中,以 `ReentrantLock` 的非公平锁为例讲解了 `tryAcquire()` 的实现。实际上 `ReentrantLock` 同时支持公平锁和非公平锁两种模式。这里深入分析二者的实现差异及其对性能的影响。
+
+#### 源码层面的差异
+
+`ReentrantLock` 默认使用非公平锁,通过构造参数可以切换为公平锁:
+
+```java
+// 非公平锁(默认)
+ReentrantLock unfairLock = new ReentrantLock();
+// 公平锁
+ReentrantLock fairLock = new ReentrantLock(true);
+```
+
+二者的核心差异在于 `tryAcquire()` 方法的实现。非公平锁的 `nonfairTryAcquire()` 前面已经分析过,下面看公平锁的实现:
+
+```java
+// ReentrantLock.FairSync
+protected final boolean tryAcquire(int acquires) {
+ final Thread current = Thread.currentThread();
+ int c = getState();
+ if (c == 0) {
+ // 关键差异:先调用 hasQueuedPredecessors() 判断同步队列中是否有等待更久的线程
+ if (!hasQueuedPredecessors() &&
+ compareAndSetState(0, acquires)) {
+ setExclusiveOwnerThread(current);
+ return true;
+ }
+ }
+ else if (current == getExclusiveOwnerThread()) {
+ int nextc = c + acquires;
+ if (nextc < 0)
+ throw new Error("Maximum lock count exceeded");
+ setState(nextc);
+ return true;
+ }
+ return false;
+}
+```
+
+**唯一的区别** 就是公平锁在 CAS 修改 `state` 之前多了一个 `hasQueuedPredecessors()` 判断:
+
+```java
+// AQS
+public final boolean hasQueuedPredecessors() {
+ Node t = tail;
+ Node h = head;
+ Node s;
+ return h != t &&
+ ((s = h.next) == null || s.thread != Thread.currentThread());
+}
+```
+
+这个方法用于判断当前线程之前是否有其他线程在排队。如果有,则当前线程不能直接获取锁,必须排队等待,从而保证了 **FIFO** 的公平性。
+
+而非公平锁没有这个判断,当锁刚好释放时,新来的线程可以直接通过 CAS 抢到锁,即使同步队列中已经有其他线程在等待。
+
+#### 性能差异对比
+
+| 对比维度 | 非公平锁(默认) | 公平锁 |
+| --- | --- | --- |
+| **吞吐量** | 更高。新线程有机会直接获取锁,减少了线程上下文切换 | 较低。所有线程都必须排队,增加了上下文切换的开销 |
+| **线程饥饿** | 可能发生。极端情况下某些线程长时间无法获取锁 | 不会发生。严格按照请求顺序分配锁 |
+| **上下文切换** | 较少。持有锁的线程释放锁后,新到达的线程可能直接获取锁,不需要唤醒队列中的线程 | 较多。每次释放锁都需要唤醒队列中的下一个线程 |
+| **适用场景** | 大多数场景(对响应时间和吞吐量要求较高) | 对公平性有严格要求的场景(如资源分配、任务调度) |
+
+**为什么非公平锁性能通常更好?**
+
+关键原因在于 **减少了线程上下文切换的次数**。当持有锁的线程 A 释放锁后:
+
+- **非公平锁**:此时如果恰好有线程 B 正在尝试获取锁(还没有进入同步队列),线程 B 可以直接通过 CAS 获取到锁并立即执行,省去了唤醒队列中线程的开销。而队列中等待的线程被唤醒后发现锁被占用,会重新阻塞,虽然看起来"浪费"了一次唤醒,但总体上减少了线程切换次数。
+- **公平锁**:线程 B 必须排到队列尾部,然后唤醒队列头部的线程。从线程被唤醒到真正开始执行之间,存在一段 **调度延迟**(线程状态从阻塞切换到运行),在这段延迟期间锁处于空闲状态,降低了锁的利用率。
+
+Doug Lea 在 `ReentrantLock` 的文档中指出:使用公平锁的程序在多线程环境下的总体吞吐量通常低于使用非公平锁的程序(即更慢),因此 `ReentrantLock` 默认使用非公平模式。但在需要保证请求处理顺序或避免线程饥饿的场景中(如连接池分配),公平锁是更好的选择。
+
+下面通过代码示例来演示公平锁与非公平锁在行为上的差异:
+
+```java
+import java.util.concurrent.locks.ReentrantLock;
+
+public class FairVsUnfairLockDemo {
+ // 分别测试公平锁和非公平锁
+ private static void testLock(ReentrantLock lock, String lockType) {
+ System.out.println("=== " + lockType + " ===");
+ Runnable task = () -> {
+ for (int i = 0; i < 2; i++) {
+ lock.lock();
+ try {
+ System.out.println(Thread.currentThread().getName() + " 获取到锁");
+ } finally {
+ lock.unlock();
+ }
+ }
+ };
+
+ Thread[] threads = new Thread[5];
+ for (int i = 0; i < 5; i++) {
+ threads[i] = new Thread(task, lockType + "-线程-" + i);
+ }
+ for (Thread t : threads) {
+ t.start();
+ }
+ for (Thread t : threads) {
+ try { t.join(); } catch (InterruptedException e) { }
+ }
+ System.out.println();
+ }
+
+ public static void main(String[] args) {
+ // 非公平锁:同一个线程可能连续多次获取到锁
+ testLock(new ReentrantLock(false), "非公平锁");
+
+ // 公平锁:线程按请求顺序交替获取锁
+ testLock(new ReentrantLock(true), "公平锁");
+ }
+}
+```
+
+运行上面的代码可以观察到:非公平锁模式下,同一个线程可能连续多次获取到锁(因为它释放锁后立即又去竞争,有很大概率在队列中的线程被唤醒之前就抢到了锁);而公平锁模式下,线程获取锁的顺序更加均匀,不会出现某个线程连续霸占锁的情况。
+
+## 常见同步工具类
### Semaphore(信号量)
@@ -331,8 +1524,7 @@ semaphore.release(5);// 释放5个许可
[issue645 补充内容](https://github.com/Snailclimb/JavaGuide/issues/645):
-> `Semaphore` 与 `CountDownLatch` 一样,也是共享锁的一种实现。它默认构造 AQS 的 `state` 为 `permits`。当执行任务的线程数量超出 `permits`,那么多余的线程将会被放入等待队列 `Park`,并自旋判断 `state` 是否大于 0。只有当 `state` 大于 0 的时候,阻塞的线程才能继续执行,此时先前执行任务的线程继续执行 `release()` 方法,`release()` 方法使得 state 的变量会加 1,那么自旋的线程便会判断成功。
-> 如此,每次只有最多不超过 `permits` 数量的线程能自旋成功,便限制了执行任务线程的数量。
+> `Semaphore` 基于 AQS 实现,用于控制并发访问的线程数量,但它与共享锁的概念有所不同。`Semaphore` 的构造函数使用 `permits` 参数初始化 AQS 的 `state` 变量,该变量表示可用的许可数量。当线程调用 `acquire()` 方法尝试获取许可时,`state` 会原子性地减 1。如果 `state` 减 1 后大于等于 0,则 `acquire()` 成功返回,线程可以继续执行。如果 `state` 减 1 后小于 0,表示当前并发访问的线程数量已达到 `permits` 的限制,该线程会被放入 AQS 的等待队列并阻塞,**而不是自旋等待**。当其他线程完成任务并调用 `release()` 方法时,`state` 会原子性地加 1。`release()` 操作会唤醒 AQS 等待队列中的一个或多个阻塞线程。这些被唤醒的线程将再次尝试 `acquire()` 操作,竞争获取可用的许可。因此,`Semaphore` 通过控制许可数量来限制并发访问的线程数量,而不是通过自旋和共享锁机制。
### CountDownLatch (倒计时器)
@@ -406,7 +1598,7 @@ protected boolean tryReleaseShared(int releases) {
}
```
-以无参 `await`方法为例,当调用 `await()` 的时候,如果 `state` 不为 0,那就证明任务还没有执行完毕,`await()` 就会一直阻塞,也就是说 `await()` 之后的语句不会被执行(`main` 线程被加入到等待队列也就是 CLH 队列中了)。然后,`CountDownLatch` 会自旋 CAS 判断 `state == 0`,如果 `state == 0` 的话,就会释放所有等待的线程,`await()` 方法之后的语句得到执行。
+以无参 `await`方法为例,当调用 `await()` 的时候,如果 `state` 不为 0,那就证明任务还没有执行完毕,`await()` 就会一直阻塞,也就是说 `await()` 之后的语句不会被执行(`main` 线程被加入到等待队列也就是 变体 CLH 队列中了)。然后,`CountDownLatch` 会自旋 CAS 判断 `state == 0`,如果 `state == 0` 的话,就会释放所有等待的线程,`await()` 方法之后的语句得到执行。
```java
// 等待(也可以叫做加锁)
@@ -511,7 +1703,7 @@ for (int i = 0; i < threadCount-1; i++) {
`CyclicBarrier` 和 `CountDownLatch` 非常类似,它也可以实现线程间的技术等待,但是它的功能比 `CountDownLatch` 更加复杂和强大。主要应用场景和 `CountDownLatch` 类似。
-> `CountDownLatch` 的实现是基于 AQS 的,而 `CycliBarrier` 是基于 `ReentrantLock`(`ReentrantLock` 也属于 AQS 同步器)和 `Condition` 的。
+> `CountDownLatch` 的实现是基于 AQS 的,而 `CyclicBarrier` 是基于 `ReentrantLock`(`ReentrantLock` 也属于 AQS 同步器)和 `Condition` 的。
`CyclicBarrier` 的字面意思是可循环使用(Cyclic)的屏障(Barrier)。它要做的事情是:让一组线程到达一个屏障(也可以叫同步点)时被阻塞,直到最后一个线程到达屏障时,屏障才会开门,所有被屏障拦截的线程才会继续干活。
@@ -792,3 +1984,4 @@ threadnum:7is finish
- 从 ReentrantLock 的实现看 AQS 的原理及应用:
+````
diff --git a/docs/java/concurrent/atomic-classes.md b/docs/java/concurrent/atomic-classes.md
index 95591d60612..2955d1aa33d 100644
--- a/docs/java/concurrent/atomic-classes.md
+++ b/docs/java/concurrent/atomic-classes.md
@@ -1,23 +1,32 @@
---
title: Atomic 原子类总结
+description: Java原子类详解:全面总结JUC包Atomic原子类体系、AtomicInteger/AtomicLong/AtomicReference等常用类、基于CAS的线程安全实现、使用场景与性能优势。
category: Java
tag:
- Java并发
+head:
+ - - meta
+ - name: keywords
+ content: Atomic原子类,AtomicInteger,AtomicLong,AtomicReference,CAS原子操作,JUC并发包,原子类使用
---
## Atomic 原子类介绍
-Atomic 翻译成中文是原子的意思。在化学上,我们知道原子是构成一般物质的最小单位,在化学反应中是不可分割的。在我们这里 Atomic 是指一个操作是不可中断的。即使是在多个线程一起执行的时候,一个操作一旦开始,就不会被其他线程干扰。
+`Atomic` 翻译成中文是“原子”的意思。在化学上,原子是构成物质的最小单位,在化学反应中不可分割。在编程中,`Atomic` 指的是一个操作具有原子性,即该操作不可分割、不可中断。即使在多个线程同时执行时,该操作要么全部执行完成,要么不执行,不会被其他线程看到部分完成的状态。
-所以,所谓原子类说简单点就是具有原子/原子操作特征的类。
+原子类简单来说就是具有原子性操作特征的类。
-并发包 `java.util.concurrent` 的原子类都存放在`java.util.concurrent.atomic`下,如下图所示。
+`java.util.concurrent.atomic` 包中的 `Atomic` 原子类提供了一种线程安全的方式来操作单个变量。
+
+`Atomic` 类依赖于 CAS(Compare-And-Swap,比较并交换)乐观锁来保证其方法的原子性,而不需要使用传统的锁机制(如 `synchronized` 块或 `ReentrantLock`)。
+
+这篇文章我们只介绍 Atomic 原子类的概念,具体实现原理可以阅读笔者写的这篇文章:[CAS 详解](./cas.md)。

-根据操作的数据类型,可以将 JUC 包中的原子类分为 4 类
+根据操作的数据类型,可以将 JUC 包中的原子类分为 4 类:
-**基本类型**
+**1、基本类型**
使用原子的方式更新基本类型
@@ -25,7 +34,7 @@ Atomic 翻译成中文是原子的意思。在化学上,我们知道原子是
- `AtomicLong`:长整型原子类
- `AtomicBoolean`:布尔型原子类
-**数组类型**
+**2、数组类型**
使用原子的方式更新数组里的某个元素
@@ -33,7 +42,7 @@ Atomic 翻译成中文是原子的意思。在化学上,我们知道原子是
- `AtomicLongArray`:长整型数组原子类
- `AtomicReferenceArray`:引用类型数组原子类
-**引用类型**
+**3、引用类型**
- `AtomicReference`:引用类型原子类
- `AtomicMarkableReference`:原子更新带有标记的引用类型。该类将 boolean 标记与引用关联起来,~~也可以解决使用 CAS 进行原子更新时可能出现的 ABA 问题~~。
@@ -41,7 +50,7 @@ Atomic 翻译成中文是原子的意思。在化学上,我们知道原子是
**🐛 修正(参见:[issue#626](https://github.com/Snailclimb/JavaGuide/issues/626))** : `AtomicMarkableReference` 不能解决 ABA 问题。
-**对象的属性修改类型**
+**4、对象的属性修改类型**
- `AtomicIntegerFieldUpdater`:原子更新整型字段的更新器
- `AtomicLongFieldUpdater`:原子更新长整型字段的更新器
@@ -57,7 +66,7 @@ Atomic 翻译成中文是原子的意思。在化学上,我们知道原子是
上面三个类提供的方法几乎相同,所以我们这里以 `AtomicInteger` 为例子来介绍。
-**AtomicInteger 类常用方法**
+**`AtomicInteger` 类常用方法** :
```java
public final int get() //获取当前的值
@@ -66,90 +75,51 @@ public final int getAndIncrement()//获取当前的值,并自增
public final int getAndDecrement() //获取当前的值,并自减
public final int getAndAdd(int delta) //获取当前的值,并加上预期的值
boolean compareAndSet(int expect, int update) //如果输入的数值等于预期值,则以原子方式将该值设置为输入值(update)
-public final void lazySet(int newValue)//最终设置为newValue,使用 lazySet 设置之后可能导致其他线程在之后的一小段时间内还是可以读到旧的值。
+public final void lazySet(int newValue)//最终设置为newValue, lazySet 提供了一种比 set 方法更弱的语义,可能导致其他线程在之后的一小段时间内还是可以读到旧的值,但可能更高效。
```
**`AtomicInteger` 类使用示例** :
```java
-import java.util.concurrent.atomic.AtomicInteger;
-
-public class AtomicIntegerTest {
-
- public static void main(String[] args) {
- int temvalue = 0;
- AtomicInteger i = new AtomicInteger(0);
- temvalue = i.getAndSet(3);
- System.out.println("temvalue:" + temvalue + "; i:" + i); //temvalue:0; i:3
- temvalue = i.getAndIncrement();
- System.out.println("temvalue:" + temvalue + "; i:" + i); //temvalue:3; i:4
- temvalue = i.getAndAdd(5);
- System.out.println("temvalue:" + temvalue + "; i:" + i); //temvalue:4; i:9
- }
-
-}
-```
-
-### 基本数据类型原子类的优势
+// 初始化 AtomicInteger 对象,初始值为 0
+AtomicInteger atomicInt = new AtomicInteger(0);
-通过一个简单例子带大家看一下基本数据类型原子类的优势
+// 使用 getAndSet 方法获取当前值,并设置新值为 3
+int tempValue = atomicInt.getAndSet(3);
+System.out.println("tempValue: " + tempValue + "; atomicInt: " + atomicInt);
-**1、多线程环境不使用原子类保证线程安全(基本数据类型)**
+// 使用 getAndIncrement 方法获取当前值,并自增 1
+tempValue = atomicInt.getAndIncrement();
+System.out.println("tempValue: " + tempValue + "; atomicInt: " + atomicInt);
-```java
-class Test {
- private volatile int count = 0;
- //若要线程安全执行执行count++,需要加锁
- public synchronized void increment() {
- count++;
- }
-
- public int getCount() {
- return count;
- }
-}
-```
+// 使用 getAndAdd 方法获取当前值,并增加指定值 5
+tempValue = atomicInt.getAndAdd(5);
+System.out.println("tempValue: " + tempValue + "; atomicInt: " + atomicInt);
-**2、多线程环境使用原子类保证线程安全(基本数据类型)**
+// 使用 compareAndSet 方法进行原子性条件更新,期望值为 9,更新值为 10
+boolean updateSuccess = atomicInt.compareAndSet(9, 10);
+System.out.println("Update Success: " + updateSuccess + "; atomicInt: " + atomicInt);
-```java
-class Test2 {
- private AtomicInteger count = new AtomicInteger();
-
- public void increment() {
- count.incrementAndGet();
- }
- //使用AtomicInteger之后,不需要加锁,也可以实现线程安全。
- public int getCount() {
- return count.get();
- }
-}
+// 获取当前值
+int currentValue = atomicInt.get();
+System.out.println("Current value: " + currentValue);
+// 使用 lazySet 方法设置新值为 15
+atomicInt.lazySet(15);
+System.out.println("After lazySet, atomicInt: " + atomicInt);
```
-### AtomicInteger 线程安全原理简单分析
-
-`AtomicInteger` 类的部分源码:
+输出:
```java
- // setup to use Unsafe.compareAndSwapInt for updates(更新操作时提供“比较并替换”的作用)
- private static final Unsafe unsafe = Unsafe.getUnsafe();
- private static final long valueOffset;
-
- static {
- try {
- valueOffset = unsafe.objectFieldOffset
- (AtomicInteger.class.getDeclaredField("value"));
- } catch (Exception ex) { throw new Error(ex); }
- }
-
- private volatile int value;
+tempValue: 0; atomicInt: 3
+tempValue: 3; atomicInt: 4
+tempValue: 4; atomicInt: 9
+Update Success: true; atomicInt: 10
+Current value: 10
+After lazySet, atomicInt: 15
```
-`AtomicInteger` 类主要利用 CAS (compare and swap) + volatile 和 native 方法来保证原子操作,从而避免 synchronized 的高开销,执行效率大为提升。
-
-CAS 的原理是拿期望的值和原本的一个值作比较,如果相同则更新成新的值。UnSafe 类的 `objectFieldOffset()` 方法是一个本地方法,这个方法是用来拿到“原来的值”的内存地址。另外 value 是一个 volatile 变量,在内存中可见,因此 JVM 可以保证任何时刻任何线程总能拿到该变量的最新值。
-
## 数组类型原子类
使用原子的方式更新数组里的某个元素
@@ -175,28 +145,57 @@ public final void lazySet(int i, int newValue)//最终 将index=i 位置的元
**`AtomicIntegerArray` 类使用示例** :
```java
-import java.util.concurrent.atomic.AtomicIntegerArray;
-
-public class AtomicIntegerArrayTest {
-
- public static void main(String[] args) {
- int temvalue = 0;
- int[] nums = { 1, 2, 3, 4, 5, 6 };
- AtomicIntegerArray i = new AtomicIntegerArray(nums);
- for (int j = 0; j < nums.length; j++) {
- System.out.println(i.get(j));
- }
- temvalue = i.getAndSet(0, 2);
- System.out.println("temvalue:" + temvalue + "; i:" + i);
- temvalue = i.getAndIncrement(0);
- System.out.println("temvalue:" + temvalue + "; i:" + i);
- temvalue = i.getAndAdd(0, 5);
- System.out.println("temvalue:" + temvalue + "; i:" + i);
- }
+int[] nums = {1, 2, 3, 4, 5, 6};
+// 创建 AtomicIntegerArray
+AtomicIntegerArray atomicArray = new AtomicIntegerArray(nums);
+
+// 打印 AtomicIntegerArray 中的初始值
+System.out.println("Initial values in AtomicIntegerArray:");
+for (int j = 0; j < nums.length; j++) {
+ System.out.print("Index " + j + ": " + atomicArray.get(j) + " ");
+}
+
+// 使用 getAndSet 方法将索引 0 处的值设置为 2,并返回旧值
+int tempValue = atomicArray.getAndSet(0, 2);
+System.out.println("\nAfter getAndSet(0, 2):");
+System.out.println("Returned value: " + tempValue);
+for (int j = 0; j < atomicArray.length(); j++) {
+ System.out.print("Index " + j + ": " + atomicArray.get(j) + " ");
+}
+
+// 使用 getAndIncrement 方法将索引 0 处的值加 1,并返回旧值
+tempValue = atomicArray.getAndIncrement(0);
+System.out.println("\nAfter getAndIncrement(0):");
+System.out.println("Returned value: " + tempValue);
+for (int j = 0; j < atomicArray.length(); j++) {
+ System.out.print("Index " + j + ": " + atomicArray.get(j) + " ");
+}
+// 使用 getAndAdd 方法将索引 0 处的值增加 5,并返回旧值
+tempValue = atomicArray.getAndAdd(0, 5);
+System.out.println("\nAfter getAndAdd(0, 5):");
+System.out.println("Returned value: " + tempValue);
+for (int j = 0; j < atomicArray.length(); j++) {
+ System.out.print("Index " + j + ": " + atomicArray.get(j) + " ");
}
```
+输出:
+
+```plain
+Initial values in AtomicIntegerArray:
+Index 0: 1 Index 1: 2 Index 2: 3 Index 3: 4 Index 4: 5 Index 5: 6
+After getAndSet(0, 2):
+Returned value: 1
+Index 0: 2 Index 1: 2 Index 2: 3 Index 3: 4 Index 4: 5 Index 5: 6
+After getAndIncrement(0):
+Returned value: 2
+Index 0: 3 Index 1: 2 Index 2: 3 Index 3: 4 Index 4: 5 Index 5: 6
+After getAndAdd(0, 5):
+Returned value: 3
+Index 0: 8 Index 1: 2 Index 2: 3 Index 3: 4 Index 4: 5 Index 5: 6
+```
+
## 引用类型原子类
基本类型原子类只能更新一个变量,如果需要原子更新多个变量,需要使用 引用类型原子类。
@@ -210,174 +209,133 @@ public class AtomicIntegerArrayTest {
**`AtomicReference` 类使用示例** :
```java
-import java.util.concurrent.atomic.AtomicReference;
-
-public class AtomicReferenceTest {
-
- public static void main(String[] args) {
- AtomicReference < Person > ar = new AtomicReference < Person > ();
- Person person = new Person("SnailClimb", 22);
- ar.set(person);
- Person updatePerson = new Person("Daisy", 20);
- ar.compareAndSet(person, updatePerson);
-
- System.out.println(ar.get().getName());
- System.out.println(ar.get().getAge());
- }
-}
-
+// Person 类
class Person {
private String name;
private int age;
+ //省略getter/setter和toString
+}
- public Person(String name, int age) {
- super();
- this.name = name;
- this.age = age;
- }
- public String getName() {
- return name;
- }
+// 创建 AtomicReference 对象并设置初始值
+AtomicReference ar = new AtomicReference<>(new Person("SnailClimb", 22));
- public void setName(String name) {
- this.name = name;
- }
+// 打印初始值
+System.out.println("Initial Person: " + ar.get().toString());
- public int getAge() {
- return age;
- }
+// 更新值
+Person updatePerson = new Person("Daisy", 20);
+ar.compareAndSet(ar.get(), updatePerson);
- public void setAge(int age) {
- this.age = age;
- }
+// 打印更新后的值
+System.out.println("Updated Person: " + ar.get().toString());
-}
+// 尝试再次更新
+Person anotherUpdatePerson = new Person("John", 30);
+boolean isUpdated = ar.compareAndSet(updatePerson, anotherUpdatePerson);
+
+// 打印是否更新成功及最终值
+System.out.println("Second Update Success: " + isUpdated);
+System.out.println("Final Person: " + ar.get().toString());
```
-上述代码首先创建了一个 `Person` 对象,然后把 `Person` 对象设置进 `AtomicReference` 对象中,然后调用 `compareAndSet` 方法,该方法就是通过 CAS 操作设置 ar。如果 ar 的值为 `person` 的话,则将其设置为 `updatePerson`。实现原理与 `AtomicInteger` 类中的 `compareAndSet` 方法相同。运行上面的代码后的输出结果如下:
+输出:
```plain
-Daisy
-20
+Initial Person: Person{name='SnailClimb', age=22}
+Updated Person: Person{name='Daisy', age=20}
+Second Update Success: true
+Final Person: Person{name='John', age=30}
```
**`AtomicStampedReference` 类使用示例** :
```java
-import java.util.concurrent.atomic.AtomicStampedReference;
-
-public class AtomicStampedReferenceDemo {
- public static void main(String[] args) {
- // 实例化、取当前值和 stamp 值
- final Integer initialRef = 0, initialStamp = 0;
- final AtomicStampedReference asr = new AtomicStampedReference<>(initialRef, initialStamp);
- System.out.println("currentValue=" + asr.getReference() + ", currentStamp=" + asr.getStamp());
-
- // compare and set
- final Integer newReference = 666, newStamp = 999;
- final boolean casResult = asr.compareAndSet(initialRef, newReference, initialStamp, newStamp);
- System.out.println("currentValue=" + asr.getReference()
- + ", currentStamp=" + asr.getStamp()
- + ", casResult=" + casResult);
-
- // 获取当前的值和当前的 stamp 值
- int[] arr = new int[1];
- final Integer currentValue = asr.get(arr);
- final int currentStamp = arr[0];
- System.out.println("currentValue=" + currentValue + ", currentStamp=" + currentStamp);
-
- // 单独设置 stamp 值
- final boolean attemptStampResult = asr.attemptStamp(newReference, 88);
- System.out.println("currentValue=" + asr.getReference()
- + ", currentStamp=" + asr.getStamp()
- + ", attemptStampResult=" + attemptStampResult);
-
- // 重新设置当前值和 stamp 值
- asr.set(initialRef, initialStamp);
- System.out.println("currentValue=" + asr.getReference() + ", currentStamp=" + asr.getStamp());
-
- // [不推荐使用,除非搞清楚注释的意思了] weak compare and set
- // 困惑!weakCompareAndSet 这个方法最终还是调用 compareAndSet 方法。[版本: jdk-8u191]
- // 但是注释上写着 "May fail spuriously and does not provide ordering guarantees,
- // so is only rarely an appropriate alternative to compareAndSet."
- // todo 感觉有可能是 jvm 通过方法名在 native 方法里面做了转发
- final boolean wCasResult = asr.weakCompareAndSet(initialRef, newReference, initialStamp, newStamp);
- System.out.println("currentValue=" + asr.getReference()
- + ", currentStamp=" + asr.getStamp()
- + ", wCasResult=" + wCasResult);
- }
-}
+// 创建一个 AtomicStampedReference 对象,初始值为 "SnailClimb",初始版本号为 1
+AtomicStampedReference asr = new AtomicStampedReference<>("SnailClimb", 1);
+
+// 打印初始值和版本号
+int[] initialStamp = new int[1];
+String initialRef = asr.get(initialStamp);
+System.out.println("Initial Reference: " + initialRef + ", Initial Stamp: " + initialStamp[0]);
+
+// 更新值和版本号
+int oldStamp = initialStamp[0];
+String oldRef = initialRef;
+String newRef = "Daisy";
+int newStamp = oldStamp + 1;
+
+boolean isUpdated = asr.compareAndSet(oldRef, newRef, oldStamp, newStamp);
+System.out.println("Update Success: " + isUpdated);
+
+// 打印更新后的值和版本号
+int[] updatedStamp = new int[1];
+String updatedRef = asr.get(updatedStamp);
+System.out.println("Updated Reference: " + updatedRef + ", Updated Stamp: " + updatedStamp[0]);
+
+// 尝试用错误的版本号更新
+boolean isUpdatedWithWrongStamp = asr.compareAndSet(newRef, "John", oldStamp, newStamp + 1);
+System.out.println("Update with Wrong Stamp Success: " + isUpdatedWithWrongStamp);
+
+// 打印最终的值和版本号
+int[] finalStamp = new int[1];
+String finalRef = asr.get(finalStamp);
+System.out.println("Final Reference: " + finalRef + ", Final Stamp: " + finalStamp[0]);
```
输出结果如下:
```plain
-currentValue=0, currentStamp=0
-currentValue=666, currentStamp=999, casResult=true
-currentValue=666, currentStamp=999
-currentValue=666, currentStamp=88, attemptStampResult=true
-currentValue=0, currentStamp=0
-currentValue=666, currentStamp=999, wCasResult=true
+Initial Reference: SnailClimb, Initial Stamp: 1
+Update Success: true
+Updated Reference: Daisy, Updated Stamp: 2
+Update with Wrong Stamp Success: false
+Final Reference: Daisy, Final Stamp: 2
```
**`AtomicMarkableReference` 类使用示例** :
```java
-import java.util.concurrent.atomic.AtomicMarkableReference;
-
-public class AtomicMarkableReferenceDemo {
- public static void main(String[] args) {
- // 实例化、取当前值和 mark 值
- final Boolean initialRef = null, initialMark = false;
- final AtomicMarkableReference amr = new AtomicMarkableReference<>(initialRef, initialMark);
- System.out.println("currentValue=" + amr.getReference() + ", currentMark=" + amr.isMarked());
-
- // compare and set
- final Boolean newReference1 = true, newMark1 = true;
- final boolean casResult = amr.compareAndSet(initialRef, newReference1, initialMark, newMark1);
- System.out.println("currentValue=" + amr.getReference()
- + ", currentMark=" + amr.isMarked()
- + ", casResult=" + casResult);
-
- // 获取当前的值和当前的 mark 值
- boolean[] arr = new boolean[1];
- final Boolean currentValue = amr.get(arr);
- final boolean currentMark = arr[0];
- System.out.println("currentValue=" + currentValue + ", currentMark=" + currentMark);
-
- // 单独设置 mark 值
- final boolean attemptMarkResult = amr.attemptMark(newReference1, false);
- System.out.println("currentValue=" + amr.getReference()
- + ", currentMark=" + amr.isMarked()
- + ", attemptMarkResult=" + attemptMarkResult);
-
- // 重新设置当前值和 mark 值
- amr.set(initialRef, initialMark);
- System.out.println("currentValue=" + amr.getReference() + ", currentMark=" + amr.isMarked());
-
- // [不推荐使用,除非搞清楚注释的意思了] weak compare and set
- // 困惑!weakCompareAndSet 这个方法最终还是调用 compareAndSet 方法。[版本: jdk-8u191]
- // 但是注释上写着 "May fail spuriously and does not provide ordering guarantees,
- // so is only rarely an appropriate alternative to compareAndSet."
- // todo 感觉有可能是 jvm 通过方法名在 native 方法里面做了转发
- final boolean wCasResult = amr.weakCompareAndSet(initialRef, newReference1, initialMark, newMark1);
- System.out.println("currentValue=" + amr.getReference()
- + ", currentMark=" + amr.isMarked()
- + ", wCasResult=" + wCasResult);
- }
-}
+// 创建一个 AtomicMarkableReference 对象,初始值为 "SnailClimb",初始标记为 false
+AtomicMarkableReference amr = new AtomicMarkableReference<>("SnailClimb", false);
+
+// 打印初始值和标记
+boolean[] initialMark = new boolean[1];
+String initialRef = amr.get(initialMark);
+System.out.println("Initial Reference: " + initialRef + ", Initial Mark: " + initialMark[0]);
+
+// 更新值和标记
+String oldRef = initialRef;
+String newRef = "Daisy";
+boolean oldMark = initialMark[0];
+boolean newMark = true;
+
+boolean isUpdated = amr.compareAndSet(oldRef, newRef, oldMark, newMark);
+System.out.println("Update Success: " + isUpdated);
+
+// 打印更新后的值和标记
+boolean[] updatedMark = new boolean[1];
+String updatedRef = amr.get(updatedMark);
+System.out.println("Updated Reference: " + updatedRef + ", Updated Mark: " + updatedMark[0]);
+
+// 尝试用错误的标记更新
+boolean isUpdatedWithWrongMark = amr.compareAndSet(newRef, "John", oldMark, !newMark);
+System.out.println("Update with Wrong Mark Success: " + isUpdatedWithWrongMark);
+
+// 打印最终的值和标记
+boolean[] finalMark = new boolean[1];
+String finalRef = amr.get(finalMark);
+System.out.println("Final Reference: " + finalRef + ", Final Mark: " + finalMark[0]);
```
输出结果如下:
```plain
-currentValue=null, currentMark=false
-currentValue=true, currentMark=true, casResult=true
-currentValue=true, currentMark=true
-currentValue=true, currentMark=false, attemptMarkResult=true
-currentValue=null, currentMark=false
-currentValue=true, currentMark=true, wCasResult=true
+Initial Reference: SnailClimb, Initial Mark: false
+Update Success: true
+Updated Reference: Daisy, Updated Mark: true
+Update with Wrong Mark Success: false
+Final Reference: Daisy, Final Mark: true
```
## 对象的属性修改类型原子类
@@ -388,59 +346,55 @@ currentValue=true, currentMark=true, wCasResult=true
- `AtomicLongFieldUpdater`:原子更新长整形字段的更新器
- `AtomicReferenceFieldUpdater`:原子更新引用类型里的字段的更新器
-要想原子地更新对象的属性需要两步。第一步,因为对象的属性修改类型原子类都是抽象类,所以每次使用都必须使用静态方法 newUpdater()创建一个更新器,并且需要设置想要更新的类和属性。第二步,更新的对象属性必须使用 public volatile 修饰符。
+要想原子地更新对象的属性需要两步。第一步,因为对象的属性修改类型原子类都是抽象类,所以每次使用都必须使用静态方法 newUpdater()创建一个更新器,并且需要设置想要更新的类和属性。第二步,更新的对象属性必须使用 volatile int 修饰符。
上面三个类提供的方法几乎相同,所以我们这里以 `AtomicIntegerFieldUpdater`为例子来介绍。
**`AtomicIntegerFieldUpdater` 类使用示例** :
```java
-import java.util.concurrent.atomic.AtomicIntegerFieldUpdater;
-
-public class AtomicIntegerFieldUpdaterTest {
- public static void main(String[] args) {
- AtomicIntegerFieldUpdater a = AtomicIntegerFieldUpdater.newUpdater(User.class, "age");
-
- User user = new User("Java", 22);
- System.out.println(a.getAndIncrement(user));// 22
- System.out.println(a.get(user));// 23
- }
+// Person 类
+class Person {
+ private String name;
+ // 要使用 AtomicIntegerFieldUpdater,字段必须是 volatile int
+ volatile int age;
+ //省略getter/setter和toString
}
-class User {
- private String name;
- public volatile int age;
+// 创建 AtomicIntegerFieldUpdater 对象
+AtomicIntegerFieldUpdater ageUpdater = AtomicIntegerFieldUpdater.newUpdater(Person.class, "age");
- public User(String name, int age) {
- super();
- this.name = name;
- this.age = age;
- }
+// 创建 Person 对象
+Person person = new Person("SnailClimb", 22);
- public String getName() {
- return name;
- }
+// 打印初始值
+System.out.println("Initial Person: " + person);
- public void setName(String name) {
- this.name = name;
- }
+// 更新 age 字段
+ageUpdater.incrementAndGet(person); // 自增
+System.out.println("After Increment: " + person);
- public int getAge() {
- return age;
- }
+ageUpdater.addAndGet(person, 5); // 增加 5
+System.out.println("After Adding 5: " + person);
- public void setAge(int age) {
- this.age = age;
- }
+ageUpdater.compareAndSet(person, 28, 30); // 如果当前值是 28,则设置为 30
+System.out.println("After Compare and Set (28 to 30): " + person);
-}
+// 尝试使用错误的比较值进行更新
+boolean isUpdated = ageUpdater.compareAndSet(person, 28, 35); // 这次应该失败
+System.out.println("Compare and Set (28 to 35) Success: " + isUpdated);
+System.out.println("Final Person: " + person);
```
输出结果:
```plain
-22
-23
+Initial Person: Name: SnailClimb, Age: 22
+After Increment: Name: SnailClimb, Age: 23
+After Adding 5: Name: SnailClimb, Age: 28
+After Compare and Set (28 to 30): Name: SnailClimb, Age: 30
+Compare and Set (28 to 35) Success: false
+Final Person: Name: SnailClimb, Age: 30
```
## 参考
diff --git a/docs/java/concurrent/cas.md b/docs/java/concurrent/cas.md
new file mode 100644
index 00000000000..f7b795791a7
--- /dev/null
+++ b/docs/java/concurrent/cas.md
@@ -0,0 +1,167 @@
+---
+title: CAS 详解
+description: CAS比较并交换深度解析:详解CAS原子操作原理、Unsafe类实现、ABA问题及解决方案、自旋锁机制、与悲观锁性能对比。
+category: Java
+tag:
+ - Java并发
+head:
+ - - meta
+ - name: keywords
+ content: CAS,Compare-And-Swap,原子操作,ABA问题,自旋锁,乐观锁,Unsafe,CAS原理
+---
+
+乐观锁和悲观锁的介绍以及乐观锁常见实现方式可以阅读笔者写的这篇文章:[乐观锁和悲观锁详解](https://javaguide.cn/java/concurrent/optimistic-lock-and-pessimistic-lock.html)。
+
+这篇文章主要介绍 :Java 中 CAS 的实现以及 CAS 存在的一些问题。
+
+## Java 中 CAS 是如何实现的?
+
+在 Java 中,实现 CAS(Compare-And-Swap, 比较并交换)操作的一个关键类是`Unsafe`。
+
+`Unsafe`类位于`sun.misc`包下,是一个提供低级别、不安全操作的类。由于其强大的功能和潜在的危险性,它通常用于 JVM 内部或一些需要极高性能和底层访问的库中,而不推荐普通开发者在应用程序中使用。关于 `Unsafe`类的详细介绍,可以阅读这篇文章:📌[Java 魔法类 Unsafe 详解](https://javaguide.cn/java/basis/unsafe.html)。
+
+`sun.misc`包下的`Unsafe`类提供了`compareAndSwapObject`、`compareAndSwapInt`、`compareAndSwapLong`方法来实现的对`Object`、`int`、`long`类型的 CAS 操作:
+
+```java
+/**
+ * 以原子方式更新对象字段的值。
+ *
+ * @param o 要操作的对象
+ * @param offset 对象字段的内存偏移量
+ * @param expected 期望的旧值
+ * @param x 要设置的新值
+ * @return 如果值被成功更新,则返回 true;否则返回 false
+ */
+boolean compareAndSwapObject(Object o, long offset, Object expected, Object x);
+
+/**
+ * 以原子方式更新 int 类型的对象字段的值。
+ */
+boolean compareAndSwapInt(Object o, long offset, int expected, int x);
+
+/**
+ * 以原子方式更新 long 类型的对象字段的值。
+ */
+boolean compareAndSwapLong(Object o, long offset, long expected, long x);
+```
+
+`Unsafe`类中的 CAS 方法是`native`方法。`native`关键字表明这些方法是用本地代码(通常是 C 或 C++)实现的,而不是用 Java 实现的。这些方法直接调用底层的硬件指令来实现原子操作。也就是说,Java 语言并没有直接用 Java 实现 CAS。
+
+更准确点来说,Java 中 CAS 是 C++ 内联汇编的形式实现的,通过 JNI(Java Native Interface) 调用。因此,CAS 的具体实现与操作系统以及 CPU 密切相关。
+
+`java.util.concurrent.atomic` 包提供了一些用于原子操作的类。
+
+
+
+关于这些 Atomic 原子类的介绍和使用,可以阅读这篇文章:[Atomic 原子类总结](https://javaguide.cn/java/concurrent/atomic-classes.html)。
+
+Atomic 类依赖于 CAS 乐观锁来保证其方法的原子性,而不需要使用传统的锁机制(如 `synchronized` 块或 `ReentrantLock`)。
+
+`AtomicInteger`是 Java 的原子类之一,主要用于对 `int` 类型的变量进行原子操作,它利用`Unsafe`类提供的低级别原子操作方法实现无锁的线程安全性。
+
+下面,我们通过解读`AtomicInteger`的核心源码(JDK1.8),来说明 Java 如何使用`Unsafe`类的方法来实现原子操作。
+
+`AtomicInteger`核心源码如下:
+
+```java
+// 获取 Unsafe 实例
+private static final Unsafe unsafe = Unsafe.getUnsafe();
+private static final long valueOffset;
+
+static {
+ try {
+ // 获取“value”字段在AtomicInteger类中的内存偏移量
+ valueOffset = unsafe.objectFieldOffset
+ (AtomicInteger.class.getDeclaredField("value"));
+ } catch (Exception ex) { throw new Error(ex); }
+}
+// 确保“value”字段的可见性
+private volatile int value;
+
+// 如果当前值等于预期值,则原子地将值设置为newValue
+// 使用 Unsafe#compareAndSwapInt 方法进行CAS操作
+public final boolean compareAndSet(int expect, int update) {
+ return unsafe.compareAndSwapInt(this, valueOffset, expect, update);
+}
+
+// 原子地将当前值加 delta 并返回旧值
+public final int getAndAdd(int delta) {
+ return unsafe.getAndAddInt(this, valueOffset, delta);
+}
+
+// 原子地将当前值加 1 并返回加之前的值(旧值)
+// 使用 Unsafe#getAndAddInt 方法进行CAS操作。
+public final int getAndIncrement() {
+ return unsafe.getAndAddInt(this, valueOffset, 1);
+}
+
+// 原子地将当前值减 1 并返回减之前的值(旧值)
+public final int getAndDecrement() {
+ return unsafe.getAndAddInt(this, valueOffset, -1);
+}
+```
+
+`Unsafe#getAndAddInt`源码:
+
+```java
+// 原子地获取并增加整数值
+public final int getAndAddInt(Object o, long offset, int delta) {
+ int v;
+ do {
+ // 以 volatile 方式获取对象 o 在内存偏移量 offset 处的整数值
+ v = getIntVolatile(o, offset);
+ } while (!compareAndSwapInt(o, offset, v, v + delta));
+ // 返回旧值
+ return v;
+}
+```
+
+可以看到,`getAndAddInt` 使用了 `do-while` 循环:在`compareAndSwapInt`操作失败时,会不断重试直到成功。也就是说,`getAndAddInt`方法会通过 `compareAndSwapInt` 方法来尝试更新 `value` 的值,如果更新失败(当前值在此期间被其他线程修改),它会重新获取当前值并再次尝试更新,直到操作成功。
+
+由于 CAS 操作可能会因为并发冲突而失败,因此通常会与`while`循环搭配使用,在失败后不断重试,直到操作成功。这就是 **自旋锁机制** 。
+
+## CAS 算法存在哪些问题?
+
+ABA 问题是 CAS 算法最常见的问题。
+
+### ABA 问题
+
+如果一个变量 V 初次读取的时候是 A 值,并且在准备赋值的时候检查到它仍然是 A 值,那我们就能说明它的值没有被其他线程修改过了吗?很明显是不能的,因为在这段时间它的值可能被改为其他值,然后又改回 A,那 CAS 操作就会误认为它从来没有被修改过。这个问题被称为 CAS 操作的 **"ABA"问题。**
+
+ABA 问题的解决思路是在变量前面追加上**版本号或者时间戳**。JDK 1.5 以后的 `AtomicStampedReference` 类就是用来解决 ABA 问题的,其中的 `compareAndSet()` 方法就是首先检查当前引用是否等于预期引用,并且当前标志是否等于预期标志,如果全部相等,则以原子方式将该引用和该标志的值设置为给定的更新值。
+
+```java
+public boolean compareAndSet(V expectedReference,
+ V newReference,
+ int expectedStamp,
+ int newStamp) {
+ Pair current = pair;
+ return
+ expectedReference == current.reference &&
+ expectedStamp == current.stamp &&
+ ((newReference == current.reference &&
+ newStamp == current.stamp) ||
+ casPair(current, Pair.of(newReference, newStamp)));
+}
+```
+
+### 循环时间长开销大
+
+CAS 经常会用到自旋操作来进行重试,也就是不成功就一直循环执行直到成功。如果长时间不成功,会给 CPU 带来非常大的执行开销。
+
+如果 JVM 能够支持处理器提供的`pause`指令,那么自旋操作的效率将有所提升。`pause`指令有两个重要作用:
+
+1. **延迟流水线执行指令**:`pause`指令可以延迟指令的执行,从而减少 CPU 的资源消耗。具体的延迟时间取决于处理器的实现版本,在某些处理器上,延迟时间可能为零。
+2. **避免内存顺序冲突**:在退出循环时,`pause`指令可以避免由于内存顺序冲突而导致的 CPU 流水线被清空,从而提高 CPU 的执行效率。
+
+### 只能保证一个共享变量的原子操作
+
+CAS 操作仅能对单个共享变量有效。当需要操作多个共享变量时,CAS 就显得无能为力。不过,从 JDK 1.5 开始,Java 提供了`AtomicReference`类,这使得我们能够保证引用对象之间的原子性。通过将多个变量封装在一个对象中,我们可以使用`AtomicReference`来执行 CAS 操作。
+
+除了 `AtomicReference` 这种方式之外,还可以利用加锁来保证。
+
+## 总结
+
+在 Java 中,CAS 通过 `Unsafe` 类中的 `native` 方法实现,这些方法调用底层的硬件指令来完成原子操作。由于其实现依赖于 C++ 内联汇编和 JNI 调用,因此 CAS 的具体实现与操作系统以及 CPU 密切相关。
+
+CAS 虽然具有高效的无锁特性,但也需要注意 ABA 、循环时间长开销大等问题。
diff --git a/docs/java/concurrent/completablefuture-intro.md b/docs/java/concurrent/completablefuture-intro.md
index 701d1420c59..3061298348d 100644
--- a/docs/java/concurrent/completablefuture-intro.md
+++ b/docs/java/concurrent/completablefuture-intro.md
@@ -1,23 +1,34 @@
---
title: CompletableFuture 详解
+description: CompletableFuture异步编程详解:全面讲解CompletableFuture核心API、异步任务编排、thenCompose/thenCombine组合、allOf/anyOf聚合、线程池配置与最佳实践。
category: Java
tag:
- Java并发
+head:
+ - - meta
+ - name: keywords
+ content: CompletableFuture,异步编程,异步编排,Future,thenCompose,thenCombine,allOf,并行任务
---
-一个接口可能需要调用 N 个其他服务的接口,这在项目开发中还是挺常见的。举个例子:用户请求获取订单信息,可能需要调用用户信息、商品详情、物流信息、商品推荐等接口,最后再汇总数据统一返回。
+实际项目中,一个接口可能需要同时获取多种不同的数据,然后再汇总返回,这种场景还是挺常见的。举个例子:用户请求获取订单信息,可能需要同时获取用户信息、商品详情、物流信息、商品推荐等数据。
-如果是串行(按顺序依次执行每个任务)执行的话,接口的响应速度会非常慢。考虑到这些接口之间有大部分都是 **无前后顺序关联** 的,可以 **并行执行** ,就比如说调用获取商品详情的时候,可以同时调用获取物流信息。通过并行执行多个任务的方式,接口的响应速度会得到大幅优化。
+如果是串行(按顺序依次执行每个任务)执行的话,接口的响应速度会非常慢。考虑到这些任务之间有大部分都是 **无前后顺序关联** 的,可以 **并行执行** ,就比如说调用获取商品详情的时候,可以同时调用获取物流信息。通过并行执行多个任务的方式,接口的响应速度会得到大幅优化。
-
+
-对于存在前后顺序关系的接口调用,可以进行编排,如下图所示。
+对于存在前后调用顺序关系的任务,可以进行任务编排。

1. 获取用户信息之后,才能调用商品详情和物流信息接口。
2. 成功获取商品详情和物流信息之后,才能调用商品推荐接口。
+可能会用到多线程异步任务编排的场景(这里只是举例,数据不一定是一次返回,可能会对接口进行拆分):
+
+1. 首页:例如技术社区的首页可能需要同时获取文章推荐列表、广告栏、文章排行榜、热门话题等信息。
+2. 详情页:例如技术社区的文章详情页可能需要同时获取作者信息、文章详情、文章评论等信息。
+3. 统计模块:例如技术社区的后台统计模块可能需要同时获取粉丝数汇总、文章数据(阅读量、评论量、收藏量)汇总等信息。
+
对于 Java 程序来说,Java 8 才被引入的 `CompletableFuture` 可以帮助我们来做多个任务的编排,功能非常强大。
这篇文章是 `CompletableFuture` 的简单入门,带大家看看 `CompletableFuture` 常用的 API。
@@ -59,7 +70,7 @@ public interface Future {
## CompletableFuture 介绍
-`Future` 在实际使用过程中存在一些局限性比如不支持异步任务的编排组合、获取计算结果的 `get()` 方法为阻塞调用。
+`Future` 在实际使用过程中存在一些局限性,比如不支持异步任务的编排组合、获取计算结果的 `get()` 方法为阻塞调用。
Java 8 才被引入`CompletableFuture` 类可以解决`Future` 的这些缺陷。`CompletableFuture` 除了提供了更为好用和强大的 `Future` 特性之外,还提供了函数式编程、异步任务编排组合(可以将多个异步任务串联起来,组成一个完整的链式调用)等能力。
@@ -74,8 +85,6 @@ public class CompletableFuture implements Future, CompletionStage {

-`CompletionStage` 接口描述了一个异步计算的阶段。很多计算可以分成多个阶段或步骤,此时可以通过它将所有步骤组合起来,形成异步计算的流水线。
-
`CompletableFuture` 除了提供了更为好用和强大的 `Future` 特性之外,还提供了函数式编程的能力。

@@ -647,7 +656,15 @@ abc
我们上面的代码示例中,为了方便,都没有选择自定义线程池。实际项目中,这是不可取的。
-`CompletableFuture` 默认使用`ForkJoinPool.commonPool()` 作为执行器,这个线程池是全局共享的,可能会被其他任务占用,导致性能下降或者饥饿。因此,建议使用自定义的线程池来执行 `CompletableFuture` 的异步任务,可以提高并发度和灵活性。
+`CompletableFuture` 默认使用全局共享的 `ForkJoinPool.commonPool()` 作为执行器,所有未指定执行器的异步任务都会使用该线程池。这意味着应用程序、多个库或框架(如 Spring、第三方库)若都依赖 `CompletableFuture`,默认情况下它们都会共享同一个线程池。
+
+虽然 `ForkJoinPool` 效率很高,但当同时提交大量任务时,可能会导致资源竞争和线程饥饿,进而影响系统性能。
+
+为避免这些问题,建议为 `CompletableFuture` 提供自定义线程池,带来以下优势:
+
+- **隔离性**:为不同任务分配独立的线程池,避免全局线程池资源争夺。
+- **资源控制**:根据任务特性调整线程池大小和队列类型,优化性能表现。
+- **异常处理**:通过自定义 `ThreadFactory` 更好地处理线程中的异常情况。
```java
private ThreadPoolExecutor executor = new ThreadPoolExecutor(10, 10,
diff --git "a/docs/java/concurrent/images/java-thread-pool-summary/\347\272\277\347\250\213\346\261\240\345\220\204\344\270\252\345\217\202\346\225\260\344\271\213\351\227\264\347\232\204\345\205\263\347\263\273.png" b/docs/java/concurrent/images/java-thread-pool-summary/relationship-between-thread-pool-parameters.png
similarity index 100%
rename from "docs/java/concurrent/images/java-thread-pool-summary/\347\272\277\347\250\213\346\261\240\345\220\204\344\270\252\345\217\202\346\225\260\344\271\213\351\227\264\347\232\204\345\205\263\347\263\273.png"
rename to docs/java/concurrent/images/java-thread-pool-summary/relationship-between-thread-pool-parameters.png
diff --git "a/docs/java/concurrent/images/java-thread-pool-summary/threadpoolexecutor\346\236\204\351\200\240\345\207\275\346\225\260.png" "b/docs/java/concurrent/images/java-thread-pool-summary/threadpoolexecutor\346\236\204\351\200\240\345\207\275\346\225\260.png"
deleted file mode 100644
index 6e3c7082eed..00000000000
Binary files "a/docs/java/concurrent/images/java-thread-pool-summary/threadpoolexecutor\346\236\204\351\200\240\345\207\275\346\225\260.png" and /dev/null differ
diff --git a/docs/java/concurrent/java-concurrent-collections.md b/docs/java/concurrent/java-concurrent-collections.md
index 9a669d900e0..e1c05dbfab5 100644
--- a/docs/java/concurrent/java-concurrent-collections.md
+++ b/docs/java/concurrent/java-concurrent-collections.md
@@ -1,8 +1,13 @@
---
title: Java 常见并发容器总结
+description: Java并发容器全面总结:详解ConcurrentHashMap/CopyOnWriteArrayList/BlockingQueue等JUC线程安全容器特性、适用场景与性能对比。
category: Java
tag:
- Java并发
+head:
+ - - meta
+ - name: keywords
+ content: Java并发容器,ConcurrentHashMap,CopyOnWriteArrayList,BlockingQueue,ConcurrentLinkedQueue,线程安全容器
---
JDK 提供的这些容器大部分在 `java.util.concurrent` 包中。
@@ -15,13 +20,19 @@ JDK 提供的这些容器大部分在 `java.util.concurrent` 包中。
## ConcurrentHashMap
-我们知道 `HashMap` 不是线程安全的,在并发场景下如果要保证一种可行的方式是使用 `Collections.synchronizedMap()` 方法来包装我们的 `HashMap`。但这是通过使用一个全局的锁来同步不同线程间的并发访问,因此会带来不可忽视的性能问题。
+我们知道,`HashMap` 是线程不安全的,如果在并发场景下使用,一种常见的解决方式是通过 `Collections.synchronizedMap()` 方法对 `HashMap` 进行包装,使其变为线程安全。不过,这种方式是通过一个全局锁来同步不同线程间的并发访问,会导致严重的性能瓶颈,尤其是在高并发场景下。
-所以就有了 `HashMap` 的线程安全版本—— `ConcurrentHashMap` 的诞生。
+为了解决这一问题,`ConcurrentHashMap` 应运而生,作为 `HashMap` 的线程安全版本,它提供了更高效的并发处理能力。
在 JDK1.7 的时候,`ConcurrentHashMap` 对整个桶数组进行了分割分段(`Segment`,分段锁),每一把锁只锁容器其中一部分数据(下面有示意图),多线程访问容器里不同数据段的数据,就不会存在锁竞争,提高并发访问率。
-到了 JDK1.8 的时候,`ConcurrentHashMap` 已经摒弃了 `Segment` 的概念,而是直接用 `Node` 数组+链表+红黑树的数据结构来实现,并发控制使用 `synchronized` 和 CAS 来操作。(JDK1.6 以后 `synchronized` 锁做了很多优化) 整个看起来就像是优化过且线程安全的 `HashMap`,虽然在 JDK1.8 中还能看到 `Segment` 的数据结构,但是已经简化了属性,只是为了兼容旧版本。
+
+
+到了 JDK1.8 的时候,`ConcurrentHashMap` 取消了 `Segment` 分段锁,采用 `Node + CAS + synchronized` 来保证并发安全。数据结构跟 `HashMap` 1.8 的结构类似,数组+链表/红黑二叉树。Java 8 在链表长度超过一定阈值(8)时将链表(寻址时间复杂度为 O(N))转换为红黑树(寻址时间复杂度为 O(log(N)))。
+
+Java 8 中,锁粒度更细,`synchronized` 只锁定当前链表或红黑二叉树的首节点,这样只要 hash 不冲突,就不会产生并发,就不会影响其他 Node 的读写,效率大幅提升。
+
+
关于 `ConcurrentHashMap` 的详细介绍,请看我写的这篇文章:[`ConcurrentHashMap` 源码分析](./../collection/concurrent-hash-map-source-code.md)。
@@ -136,7 +147,7 @@ private static ArrayBlockingQueue blockingQueue = new ArrayBlockingQueu
最低层的链表维护了跳表内所有的元素,每上面一层链表都是下面一层的子集。
-跳表内的所有链表的元素都是排序的。查找时,可以从顶级链表开始找。一旦发现被查找的元素大于当前链表中的取值,就会转入下一层链表继续找。这也就是说在查找过程中,搜索是跳跃式的。如上图所示,在跳表中查找元素 18。
+跳表内的所有链表的元素都是排序的。查找时,可以从顶级链表开始找。一旦发现被查找的元素小于当前访问节点的后继节点(或后继节点为空),就会转入下一层链表继续找。这也就是说在查找过程中,搜索是跳跃式的。如上图所示,在跳表中查找元素 18。

diff --git a/docs/java/concurrent/java-concurrent-questions-01.md b/docs/java/concurrent/java-concurrent-questions-01.md
index 222f8f2ba75..1f8672db28a 100644
--- a/docs/java/concurrent/java-concurrent-questions-01.md
+++ b/docs/java/concurrent/java-concurrent-questions-01.md
@@ -1,22 +1,22 @@
---
title: Java并发常见面试题总结(上)
+description: Java并发编程基础面试题:深入讲解线程与进程区别、多线程创建方式、线程生命周期状态、死锁四个条件及预防、并发与并行概念等核心知识。
category: Java
tag:
- Java并发
head:
- - meta
- name: keywords
- content: 线程和进程,并发和并行,多线程,死锁,线程的生命周期
- - - meta
- - name: description
- content: Java并发常见知识点和面试题总结(含详细解答),希望对你有帮助!
+ content: Java并发,线程与进程,多线程,死锁,线程生命周期,并发编程,Java面试题,线程创建方式
---
-## 什么是线程和进程?
+## 线程
+
+### ⭐️什么是线程和进程?
-### 何为进程?
+#### 何为进程?
进程是程序的一次执行过程,是系统运行程序的基本单位,因此进程是动态的。系统运行一个程序即是一个进程从创建,运行到消亡的过程。
@@ -26,9 +26,9 @@ head:

-### 何为线程?
+#### 何为线程?
-线程与进程相似,但线程是一个比进程更小的执行单位。一个进程在其执行的过程中可以产生多个线程。与进程不同的是同类的多个线程共享进程的**堆**和**方法区**资源,但每个线程有自己的**程序计数器**、**虚拟机栈**和**本地方法栈**,所以系统在产生一个线程,或是在各个线程之间作切换工作时,负担要比进程小得多,也正因为如此,线程也被称为轻量级进程。
+线程与进程相似,但线程是一个比进程更小的执行单位。一个进程在其执行的过程中可以产生多个线程。与进程不同的是同类的多个线程共享进程的**堆**和**方法区**资源,但每个线程有自己的**程序计数器**、**虚拟机栈**和**本地方法栈**,所以系统在产生一个线程,或是在各个线程之间做切换工作时,负担要比进程小得多,也正因为如此,线程也被称为轻量级进程。
Java 程序天生就是多线程程序,我们可以通过 JMX 来看看一个普通的 Java 程序有哪些线程,代码如下。
@@ -59,7 +59,7 @@ public class MultiThread {
从上面的输出内容可以看出:**一个 Java 程序的运行是 main 线程和多个其他线程同时运行**。
-## Java 线程和操作系统的线程有啥区别?
+### Java 线程和操作系统的线程有啥区别?
JDK 1.2 之前,Java 线程是基于绿色线程(Green Threads)实现的,这是一种用户级线程(用户线程),也就是说 JVM 自己模拟了多线程的运行,而不依赖于操作系统。由于绿色线程和原生线程比起来在使用时有一些限制(比如绿色线程不能直接使用操作系统提供的功能如异步 I/O、只能在一个内核线程上运行无法利用多核),在 JDK 1.2 及以后,Java 线程改为基于原生线程(Native Threads)实现,也就是说 JVM 直接使用操作系统原生的内核级线程(内核线程)来实现 Java 线程,由操作系统内核进行线程的调度和管理。
@@ -82,13 +82,7 @@ JDK 1.2 之前,Java 线程是基于绿色线程(Green Threads)实现的,
在 Windows 和 Linux 等主流操作系统中,Java 线程采用的是一对一的线程模型,也就是一个 Java 线程对应一个系统内核线程。Solaris 系统是一个特例(Solaris 系统本身就支持多对多的线程模型),HotSpot VM 在 Solaris 上支持多对多和一对一。具体可以参考 R 大的回答: [JVM 中的线程模型是用户级的么?](https://www.zhihu.com/question/23096638/answer/29617153)。
-虚拟线程在 JDK 21 顺利转正,关于虚拟线程、平台线程(也就是我们上面提到的 Java 线程)和内核线程三者的关系可以阅读我写的这篇文章:[Java 20 新特性概览](../new-features/java20.md)。
-
-## 请简要描述线程与进程的关系,区别及优缺点?
-
-从 JVM 角度说进程和线程之间的关系。
-
-### 图解进程和线程的关系
+### ⭐️请简要描述线程与进程的关系,区别及优缺点?
下图是 Java 内存区域,通过下图我们从 JVM 的角度来说一下线程和进程之间的关系。
@@ -96,13 +90,13 @@ JDK 1.2 之前,Java 线程是基于绿色线程(Green Threads)实现的,
从上图可以看出:一个进程中可以有多个线程,多个线程共享进程的**堆**和**方法区 (JDK1.8 之后的元空间)**资源,但是每个线程有自己的**程序计数器**、**虚拟机栈** 和 **本地方法栈**。
-**总结:** **线程是进程划分成的更小的运行单位。线程和进程最大的不同在于基本上各进程是独立的,而各线程则不一定,因为同一进程中的线程极有可能会相互影响。线程执行开销小,但不利于资源的管理和保护;而进程正相反。**
+**总结:** 线程是进程划分成的更小的运行单位。线程和进程最大的不同在于基本上各进程是独立的,而各线程则不一定,因为同一进程中的线程极有可能会相互影响。线程执行开销小,但不利于资源的管理和保护;而进程正相反。
下面是该知识点的扩展内容!
下面来思考这样一个问题:为什么**程序计数器**、**虚拟机栈**和**本地方法栈**是线程私有的呢?为什么堆和方法区是线程共享的呢?
-### 程序计数器为什么是私有的?
+#### 程序计数器为什么是私有的?
程序计数器主要有下面两个作用:
@@ -113,61 +107,18 @@ JDK 1.2 之前,Java 线程是基于绿色线程(Green Threads)实现的,
所以,程序计数器私有主要是为了**线程切换后能恢复到正确的执行位置**。
-### 虚拟机栈和本地方法栈为什么是私有的?
+#### 虚拟机栈和本地方法栈为什么是私有的?
- **虚拟机栈:** 每个 Java 方法在执行之前会创建一个栈帧用于存储局部变量表、操作数栈、常量池引用等信息。从方法调用直至执行完成的过程,就对应着一个栈帧在 Java 虚拟机栈中入栈和出栈的过程。
- **本地方法栈:** 和虚拟机栈所发挥的作用非常相似,区别是:**虚拟机栈为虚拟机执行 Java 方法 (也就是字节码)服务,而本地方法栈则为虚拟机使用到的 Native 方法服务。** 在 HotSpot 虚拟机中和 Java 虚拟机栈合二为一。
所以,为了**保证线程中的局部变量不被别的线程访问到**,虚拟机栈和本地方法栈是线程私有的。
-### 一句话简单了解堆和方法区
+#### 一句话简单了解堆和方法区
堆和方法区是所有线程共享的资源,其中堆是进程中最大的一块内存,主要用于存放新创建的对象 (几乎所有对象都在这里分配内存),方法区主要用于存放已被加载的类信息、常量、静态变量、即时编译器编译后的代码等数据。
-## 并发与并行的区别
-
-- **并发**:两个及两个以上的作业在同一 **时间段** 内执行。
-- **并行**:两个及两个以上的作业在同一 **时刻** 执行。
-
-最关键的点是:是否是 **同时** 执行。
-
-## 同步和异步的区别
-
-- **同步**:发出一个调用之后,在没有得到结果之前, 该调用就不可以返回,一直等待。
-- **异步**:调用在发出之后,不用等待返回结果,该调用直接返回。
-
-## 为什么要使用多线程?
-
-先从总体上来说:
-
-- **从计算机底层来说:** 线程可以比作是轻量级的进程,是程序执行的最小单位,线程间的切换和调度的成本远远小于进程。另外,多核 CPU 时代意味着多个线程可以同时运行,这减少了线程上下文切换的开销。
-- **从当代互联网发展趋势来说:** 现在的系统动不动就要求百万级甚至千万级的并发量,而多线程并发编程正是开发高并发系统的基础,利用好多线程机制可以大大提高系统整体的并发能力以及性能。
-
-再深入到计算机底层来探讨:
-
-- **单核时代**:在单核时代多线程主要是为了提高单进程利用 CPU 和 IO 系统的效率。 假设只运行了一个 Java 进程的情况,当我们请求 IO 的时候,如果 Java 进程中只有一个线程,此线程被 IO 阻塞则整个进程被阻塞。CPU 和 IO 设备只有一个在运行,那么可以简单地说系统整体效率只有 50%。当使用多线程的时候,一个线程被 IO 阻塞,其他线程还可以继续使用 CPU。从而提高了 Java 进程利用系统资源的整体效率。
-- **多核时代**: 多核时代多线程主要是为了提高进程利用多核 CPU 的能力。举个例子:假如我们要计算一个复杂的任务,我们只用一个线程的话,不论系统有几个 CPU 核心,都只会有一个 CPU 核心被利用到。而创建多个线程,这些线程可以被映射到底层多个 CPU 上执行,在任务中的多个线程没有资源竞争的情况下,任务执行的效率会有显著性的提高,约等于(单核时执行时间/CPU 核心数)。
-
-## 使用多线程可能带来什么问题?
-
-并发编程的目的就是为了能提高程序的执行效率提高程序运行速度,但是并发编程并不总是能提高程序运行速度的,而且并发编程可能会遇到很多问题,比如:内存泄漏、死锁、线程不安全等等。
-
-## 如何理解线程安全和不安全?
-
-线程安全和不安全是在多线程环境下对于同一份数据的访问是否能够保证其正确性和一致性的描述。
-
-- 线程安全指的是在多线程环境下,对于同一份数据,不管有多少个线程同时访问,都能保证这份数据的正确性和一致性。
-- 线程不安全则表示在多线程环境下,对于同一份数据,多个线程同时访问时可能会导致数据混乱、错误或者丢失。
-
-## 单核 CPU 上运行多个线程效率一定会高吗?
-
-单核 CPU 同时运行多个线程的效率是否会高,取决于线程的类型和任务的性质。一般来说,有两种类型的线程:CPU 密集型和 IO 密集型。CPU 密集型的线程主要进行计算和逻辑处理,需要占用大量的 CPU 资源。IO 密集型的线程主要进行输入输出操作,如读写文件、网络通信等,需要等待 IO 设备的响应,而不占用太多的 CPU 资源。
-
-在单核 CPU 上,同一时刻只能有一个线程在运行,其他线程需要等待 CPU 的时间片分配。如果线程是 CPU 密集型的,那么多个线程同时运行会导致频繁的线程切换,增加了系统的开销,降低了效率。如果线程是 IO 密集型的,那么多个线程同时运行可以利用 CPU 在等待 IO 时的空闲时间,提高了效率。
-
-因此,对于单核 CPU 来说,如果任务是 CPU 密集型的,那么开很多线程会影响效率;如果任务是 IO 密集型的,那么开很多线程会提高效率。当然,这里的“很多”也要适度,不能超过系统能够承受的上限。
-
-## 如何创建线程?
+### 如何创建线程?
一般来说,创建线程有很多种方式,例如继承`Thread`类、实现`Runnable`接口、实现`Callable`接口、使用线程池、使用`CompletableFuture`类等等。
@@ -175,9 +126,7 @@ JDK 1.2 之前,Java 线程是基于绿色线程(Green Threads)实现的,
严格来说,Java 就只有一种方式可以创建线程,那就是通过`new Thread().start()`创建。不管是哪种方式,最终还是依赖于`new Thread().start()`。
-关于这个问题的详细分析可以查看这篇文章:[大家都说 Java 有三种创建线程的方式!并发编程中的惊天骗局!](https://mp.weixin.qq.com/s/NspUsyhEmKnJ-4OprRFp9g)。
-
-## 说说线程的生命周期和状态?
+### ⭐️说说线程的生命周期和状态?
Java 线程在运行的生命周期中的指定时刻只可能处于下面 6 种不同状态的其中一个状态:
@@ -190,7 +139,7 @@ Java 线程在运行的生命周期中的指定时刻只可能处于下面 6 种
线程在生命周期中并不是固定处于某一个状态而是随着代码的执行在不同状态之间切换。
-Java 线程状态变迁图(图源:[挑错 |《Java 并发编程的艺术》中关于线程状态的三处错误](https://mp.weixin.qq.com/s/UOrXql_LhOD8dhTq_EPI0w)):
+Java 线程状态变迁图(图源:[挑错 |《Java 并发编程的艺术》中关于线程状态的三处错误](https://mp.weixin.qq.com/s/0UTyrJpRKaKhkhHcQtXAiA)):

@@ -207,9 +156,7 @@ Java 线程状态变迁图(图源:[挑错 |《Java 并发编程的艺术》中
- 当线程进入 `synchronized` 方法/块或者调用 `wait` 后(被 `notify`)重新进入 `synchronized` 方法/块,但是锁被其它线程占有,这个时候线程就会进入 **BLOCKED(阻塞)** 状态。
- 线程在执行完了 `run()`方法之后将会进入到 **TERMINATED(终止)** 状态。
-相关阅读:[线程的几种状态你真的了解么?](https://mp.weixin.qq.com/s/R5MrTsWvk9McFSQ7bS0W2w) 。
-
-## 什么是线程上下文切换?
+### 什么是线程上下文切换?
线程在执行过程中会有自己的运行条件和状态(也称上下文),比如上文所说到过的程序计数器,栈信息等。当出现如下情况的时候,线程会从占用 CPU 状态中退出。
@@ -222,9 +169,97 @@ Java 线程状态变迁图(图源:[挑错 |《Java 并发编程的艺术》中
上下文切换是现代操作系统的基本功能,因其每次需要保存信息恢复信息,这将会占用 CPU,内存等系统资源进行处理,也就意味着效率会有一定损耗,如果频繁切换就会造成整体效率低下。
-## 什么是线程死锁?如何避免死锁?
+### Thread#sleep() 方法和 Object#wait() 方法对比
+
+**共同点**:两者都可以暂停线程的执行。
+
+**区别**:
+
+- **`sleep()` 方法没有释放锁,而 `wait()` 方法释放了锁** 。
+- `wait()` 通常被用于线程间交互/通信,`sleep()`通常被用于暂停执行。
+- `wait()` 方法被调用后,线程不会自动苏醒,需要别的线程调用同一个对象上的 `notify()`或者 `notifyAll()` 方法。`sleep()`方法执行完成后,线程会自动苏醒,或者也可以使用 `wait(long timeout)` 超时后线程会自动苏醒。
+- `sleep()` 是 `Thread` 类的静态本地方法,`wait()` 则是 `Object` 类的本地方法。为什么这样设计呢?下一个问题就会聊到。
+
+### 为什么 wait() 方法不定义在 Thread 中?
+
+`wait()` 是让获得对象锁的线程实现等待,会自动释放当前线程占有的对象锁。每个对象(`Object`)都拥有对象锁,既然要释放当前线程占有的对象锁并让其进入 WAITING 状态,自然是要操作对应的对象(`Object`)而非当前的线程(`Thread`)。
+
+类似的问题:**为什么 `sleep()` 方法定义在 `Thread` 中?**
+
+因为 `sleep()` 是让当前线程暂停执行,不涉及到对象类,也不需要获得对象锁。
+
+### 可以直接调用 Thread 类的 run 方法吗?
+
+这是另一个非常经典的 Java 多线程面试问题,而且在面试中会经常被问到。很简单,但是很多人都会答不上来!
+
+new 一个 `Thread`,线程进入了新建状态。调用 `start()`方法,会启动一个线程并使线程进入了就绪状态,当分配到时间片后就可以开始运行了。 `start()` 会执行线程的相应准备工作,然后自动执行 `run()` 方法的内容,这是真正的多线程工作。 但是,直接执行 `run()` 方法,会把 `run()` 方法当成一个普通方法在调用该方法的线程去执行,所以这并不是多线程工作。
+
+**总结:调用 `start()` 方法方可启动线程并使线程进入就绪状态,直接执行 `run()` 方法的话不会以多线程的方式执行。**
+
+## 多线程
+
+### 并发与并行的区别
+
+- **并发**:两个及两个以上的作业在同一 **时间段** 内执行。
+- **并行**:两个及两个以上的作业在同一 **时刻** 执行。
+
+最关键的点是:是否是 **同时** 执行。
+
+### 同步和异步的区别
+
+- **同步**:发出一个调用之后,在没有得到结果之前, 该调用就不可以返回,一直等待。
+- **异步**:调用在发出之后,不用等待返回结果,该调用直接返回。
+
+### ⭐️为什么要使用多线程?
+
+先从总体上来说:
+
+- **从计算机底层来说:** 线程可以比作是轻量级的进程,是程序执行的最小单位,线程间的切换和调度的成本远远小于进程。另外,多核 CPU 时代意味着多个线程可以同时运行,这减少了线程上下文切换的开销。
+- **从当代互联网发展趋势来说:** 现在的系统动不动就要求百万级甚至千万级的并发量,而多线程并发编程正是开发高并发系统的基础,利用好多线程机制可以大大提高系统整体的并发能力以及性能。
+
+再深入到计算机底层来探讨:
+
+- **单核时代**:在单核时代多线程主要是为了提高单进程利用 CPU 和 IO 系统的效率。 假设只运行了一个 Java 进程的情况,当我们请求 IO 的时候,如果 Java 进程中只有一个线程,此线程被 IO 阻塞则整个进程被阻塞。CPU 和 IO 设备只有一个在运行,那么可以简单地说系统整体效率只有 50%。当使用多线程的时候,一个线程被 IO 阻塞,其他线程还可以继续使用 CPU。从而提高了 Java 进程利用系统资源的整体效率。
+- **多核时代**: 多核时代多线程主要是为了提高进程利用多核 CPU 的能力。举个例子:假如我们要计算一个复杂的任务,我们只用一个线程的话,不论系统有几个 CPU 核心,都只会有一个 CPU 核心被利用到。而创建多个线程,这些线程可以被映射到底层多个 CPU 核心上执行,在任务中的多个线程没有资源竞争的情况下,任务执行的效率会有显著性的提高,约等于(单核时执行时间/CPU 核心数)。
+
+### ⭐️单核 CPU 支持 Java 多线程吗?
+
+单核 CPU 是支持 Java 多线程的。操作系统通过时间片轮转的方式,将 CPU 的时间分配给不同的线程。尽管单核 CPU 一次只能执行一个任务,但通过快速在多个线程之间切换,可以让用户感觉多个任务是同时进行的。
+
+这里顺带提一下 Java 使用的线程调度方式。
+
+操作系统主要通过两种线程调度方式来管理多线程的执行:
+
+- **抢占式调度(Preemptive Scheduling)**:操作系统决定何时暂停当前正在运行的线程,并切换到另一个线程执行。这种切换通常是由系统时钟中断(时间片轮转)或其他高优先级事件(如 I/O 操作完成)触发的。这种方式存在上下文切换开销,但公平性和 CPU 资源利用率较好,不易阻塞。
+- **协同式调度(Cooperative Scheduling)**:线程执行完毕后,主动通知系统切换到另一个线程。这种方式可以减少上下文切换带来的性能开销,但公平性较差,容易阻塞。
+
+Java 使用的线程调度是抢占式的。也就是说,JVM 本身不负责线程的调度,而是将线程的调度委托给操作系统。操作系统通常会基于线程优先级和时间片来调度线程的执行,高优先级的线程通常获得 CPU 时间片的机会更多。
+
+### ⭐️单核 CPU 上运行多个线程效率一定会高吗?
+
+单核 CPU 同时运行多个线程的效率是否会高,取决于线程的类型和任务的性质。一般来说,有两种类型的线程:
+
+1. **CPU 密集型**:CPU 密集型的线程主要进行计算和逻辑处理,需要占用大量的 CPU 资源。
+2. **IO 密集型**:IO 密集型的线程主要进行输入输出操作,如读写文件、网络通信等,需要等待 IO 设备的响应,而不占用太多的 CPU 资源。
+
+在单核 CPU 上,同一时刻只能有一个线程在运行,其他线程需要等待 CPU 的时间片分配。如果线程是 CPU 密集型的,那么多个线程同时运行会导致频繁的线程切换,增加了系统的开销,降低了效率。如果线程是 IO 密集型的,那么多个线程同时运行可以利用 CPU 在等待 IO 时的空闲时间,提高了效率。
+
+因此,对于单核 CPU 来说,如果任务是 CPU 密集型的,那么开很多线程会影响效率;如果任务是 IO 密集型的,那么开很多线程会提高效率。当然,这里的“很多”也要适度,不能超过系统能够承受的上限。
+
+### 使用多线程可能带来什么问题?
+
+并发编程的目的就是为了能提高程序的执行效率进而提高程序的运行速度,但是并发编程并不总是能提高程序运行速度的,而且并发编程可能会遇到很多问题,比如:内存泄漏、死锁、线程不安全等等。
+
+### 如何理解线程安全和不安全?
+
+线程安全和不安全是在多线程环境下对于同一份数据的访问是否能够保证其正确性和一致性的描述。
+
+- 线程安全指的是在多线程环境下,对于同一份数据,不管有多少个线程同时访问,都能保证这份数据的正确性和一致性。
+- 线程不安全则表示在多线程环境下,对于同一份数据,多个线程同时访问时可能会导致数据混乱、错误或者丢失。
+
+## ⭐️死锁
-### 认识线程死锁
+### 什么是线程死锁?
线程死锁描述的是这样一种情况:多个线程同时被阻塞,它们中的一个或者全部都在等待某个资源被释放。由于线程被无限期地阻塞,因此程序不可能正常终止。
@@ -282,14 +317,37 @@ Thread[线程 1,5,main]waiting get resource2
Thread[线程 2,5,main]waiting get resource1
```
-线程 A 通过 `synchronized (resource1)` 获得 `resource1` 的监视器锁,然后通过`Thread.sleep(1000);`让线程 A 休眠 1s 为的是让线程 B 得到执行然后获取到 resource2 的监视器锁。线程 A 和线程 B 休眠结束了都开始企图请求获取对方的资源,然后这两个线程就会陷入互相等待的状态,这也就产生了死锁。
+线程 A 通过 `synchronized (resource1)` 获得 `resource1` 的监视器锁,然后通过 `Thread.sleep(1000);` 让线程 A 休眠 1s,为的是让线程 B 得到执行然后获取到 resource2 的监视器锁。线程 A 和线程 B 休眠结束了都开始企图请求获取对方的资源,然后这两个线程就会陷入互相等待的状态,这也就产生了死锁。
上面的例子符合产生死锁的四个必要条件:
-1. 互斥条件:该资源任意一个时刻只由一个线程占用。
-2. 请求与保持条件:一个线程因请求资源而阻塞时,对已获得的资源保持不放。
-3. 不剥夺条件:线程已获得的资源在未使用完之前不能被其他线程强行剥夺,只有自己使用完毕后才释放资源。
-4. 循环等待条件:若干线程之间形成一种头尾相接的循环等待资源关系。
+1. **互斥条件**:该资源任意一个时刻只由一个线程占用。
+2. **请求与保持条件**:一个线程因请求资源而阻塞时,对已获得的资源保持不放。
+3. **不剥夺条件**:线程已获得的资源在未使用完之前不能被其他线程强行剥夺,只有自己使用完毕后才释放资源。
+4. **循环等待条件**:若干线程之间形成一种头尾相接的循环等待资源关系。
+
+### 如何检测死锁?
+
+- 使用`jmap`、`jstack`等命令查看 JVM 线程栈和堆内存的情况。如果有死锁,`jstack` 的输出中通常会有 `Found one Java-level deadlock:`的字样,后面会跟着死锁相关的线程信息。另外,实际项目中还可以搭配使用`top`、`df`、`free`等命令查看操作系统的基本情况,出现死锁可能会导致 CPU、内存等资源消耗过高。
+- 采用 VisualVM、JConsole 等工具进行排查。
+
+这里以 JConsole 工具为例进行演示。
+
+首先,我们要找到 JDK 的 bin 目录,找到 jconsole 并双击打开。
+
+
+
+对于 MAC 用户来说,可以通过 `/usr/libexec/java_home -V`查看 JDK 安装目录,找到后通过 `open . + 文件夹地址`打开即可。例如,我本地的某个 JDK 的路径是:
+
+```bash
+ open . /Users/guide/Library/Java/JavaVirtualMachines/corretto-1.8.0_252/Contents/Home
+```
+
+打开 jconsole 后,连接对应的程序,然后进入线程界面选择检测死锁即可!
+
+
+
+
### 如何预防和避免线程死锁?
@@ -339,33 +397,6 @@ Process finished with exit code 0
我们分析一下上面的代码为什么避免了死锁的发生?
-线程 1 首先获得到 resource1 的监视器锁,这时候线程 2 就获取不到了。然后线程 1 再去获取 resource2 的监视器锁,可以获取到。然后线程 1 释放了对 resource1、resource2 的监视器锁的占用,线程 2 获取到就可以执行了。这样就破坏了破坏循环等待条件,因此避免了死锁。
-
-## sleep() 方法和 wait() 方法对比
-
-**共同点**:两者都可以暂停线程的执行。
-
-**区别**:
-
-- **`sleep()` 方法没有释放锁,而 `wait()` 方法释放了锁** 。
-- `wait()` 通常被用于线程间交互/通信,`sleep()`通常被用于暂停执行。
-- `wait()` 方法被调用后,线程不会自动苏醒,需要别的线程调用同一个对象上的 `notify()`或者 `notifyAll()` 方法。`sleep()`方法执行完成后,线程会自动苏醒,或者也可以使用 `wait(long timeout)` 超时后线程会自动苏醒。
-- `sleep()` 是 `Thread` 类的静态本地方法,`wait()` 则是 `Object` 类的本地方法。为什么这样设计呢?下一个问题就会聊到。
-
-## 为什么 wait() 方法不定义在 Thread 中?
-
-`wait()` 是让获得对象锁的线程实现等待,会自动释放当前线程占有的对象锁。每个对象(`Object`)都拥有对象锁,既然要释放当前线程占有的对象锁并让其进入 WAITING 状态,自然是要操作对应的对象(`Object`)而非当前的线程(`Thread`)。
-
-类似的问题:**为什么 `sleep()` 方法定义在 `Thread` 中?**
-
-因为 `sleep()` 是让当前线程暂停执行,不涉及到对象类,也不需要获得对象锁。
-
-## 可以直接调用 Thread 类的 run 方法吗?
-
-这是另一个非常经典的 Java 多线程面试问题,而且在面试中会经常被问到。很简单,但是很多人都会答不上来!
-
-new 一个 `Thread`,线程进入了新建状态。调用 `start()`方法,会启动一个线程并使线程进入了就绪状态,当分配到时间片后就可以开始运行了。 `start()` 会执行线程的相应准备工作,然后自动执行 `run()` 方法的内容,这是真正的多线程工作。 但是,直接执行 `run()` 方法,会把 `run()` 方法当成一个 main 线程下的普通方法去执行,并不会在某个线程中执行它,所以这并不是多线程工作。
-
-**总结:调用 `start()` 方法方可启动线程并使线程进入就绪状态,直接执行 `run()` 方法的话不会以多线程的方式执行。**
+线程 1 首先获得到 resource1 的监视器锁,这时候线程 2 就获取不到了。然后线程 1 再去获取 resource2 的监视器锁,可以获取到。然后线程 1 释放了对 resource1、resource2 的监视器锁的占用,线程 2 获取到就可以执行了。这样就破坏了循环等待条件,因此避免了死锁。
diff --git a/docs/java/concurrent/java-concurrent-questions-02.md b/docs/java/concurrent/java-concurrent-questions-02.md
index a6c125fb64c..78c82fc9140 100644
--- a/docs/java/concurrent/java-concurrent-questions-02.md
+++ b/docs/java/concurrent/java-concurrent-questions-02.md
@@ -1,24 +1,22 @@
---
title: Java并发常见面试题总结(中)
+description: Java并发进阶面试题:深入解析synchronized与ReentrantLock区别、volatile可见性保证、JMM内存模型、happens-before原则等并发编程核心机制。
category: Java
tag:
- Java并发
head:
- - meta
- name: keywords
- content: 多线程,死锁,synchronized,ReentrantLock,volatile,ThreadLocal,线程池,CAS,AQS
- - - meta
- - name: description
- content: Java并发常见知识点和面试题总结(含详细解答)。
+ content: synchronized,ReentrantLock,volatile,JMM,happens-before,可见性,原子性,有序性,并发面试题
---
-## JMM(Java 内存模型)
+## ⭐️JMM(Java 内存模型)
-JMM(Java 内存模型)相关的问题比较多,也比较重要,于是我单独抽了一篇文章来总结 JMM 相关的知识点和问题:[JMM(Java 内存模型)详解](./jmm.md) 。
+JMM(Java 内存模型)相关的问题比较多,也比较重要,于是我单独抽了一篇文章来总结 JMM 相关的知识点和问题:[JMM(Java 内存模型)详解](https://javaguide.cn/java/concurrent/jmm.html) 。
-## volatile 关键字
+## ⭐️volatile 关键字
### 如何保证变量的可见性?
@@ -46,6 +44,49 @@ public native void fullFence();
理论上来说,你通过这个三个方法也可以实现和`volatile`禁止重排序一样的效果,只是会麻烦一些。
+#### 4 种内存屏障类型
+
+JMM(Java 内存模型)定义了 4 种内存屏障(Memory Barrier),用于控制特定条件下的指令重排序和内存可见性:
+
+| 屏障类型 | 指令示例 | 说明 |
+| --- | --- | --- |
+| **LoadLoad** | `Load1; LoadLoad; Load2` | 保证 `Load1` 的读取操作在 `Load2` 及其后续读取操作之前完成 |
+| **StoreStore** | `Store1; StoreStore; Store2` | 保证 `Store1` 的写入操作对其他处理器可见(刷新到内存),先于 `Store2` 及其后续写入操作 |
+| **LoadStore** | `Load1; LoadStore; Store2` | 保证 `Load1` 的读取操作在 `Store2` 及其后续写入操作刷新到内存之前完成 |
+| **StoreLoad** | `Store1; StoreLoad; Load2` | 保证 `Store1` 的写入操作对其他处理器可见,先于 `Load2` 及其后续读取操作。`StoreLoad` 屏障的开销是四种屏障中最大的,它同时具有其他三种屏障的效果,因此也称为 **全能屏障(Full Barrier)** |
+
+#### volatile 读写操作的内存屏障插入策略
+
+JMM 针对编译器制定了 `volatile` 读写操作的内存屏障插入策略,以确保在任意处理器平台上都能获得正确的 volatile 内存语义:
+
+**volatile 写操作的内存屏障插入策略:**
+
+在每个 volatile 写操作的 **前面** 插入一个 `StoreStore` 屏障,在 **后面** 插入一个 `StoreLoad` 屏障。
+
+```
+StoreStore 屏障
+volatile 写操作
+StoreLoad 屏障
+```
+
+- 前面的 `StoreStore` 屏障:保证在 volatile 写之前,其前面的所有普通写操作已经对任意处理器可见(刷新到主内存)。
+- 后面的 `StoreLoad` 屏障:保证 volatile 写之后,其写入的值对后续的 volatile 读/写操作可见。这是开销最大的屏障,但也是最关键的——它避免了 volatile 写与后面可能有的 volatile 读/写操作发生重排序。
+
+**volatile 读操作的内存屏障插入策略:**
+
+在每个 volatile 读操作的 **后面** 插入一个 `LoadLoad` 屏障和一个 `LoadStore` 屏障。
+
+```
+volatile 读操作
+LoadLoad 屏障
+LoadStore 屏障
+```
+
+- `LoadLoad` 屏障:保证 volatile 读之后的普通读操作不会被重排序到 volatile 读之前。
+- `LoadStore` 屏障:保证 volatile 读之后的普通写操作不会被重排序到 volatile 读之前。
+
+这样一来,volatile 写-读的组合就建立了一个类似于 **锁的释放-获取** 的语义:**volatile 写操作之前的所有操作结果,对于后续对该 volatile 变量的读操作之后的所有操作都是可见的。**
+
下面我以一个常见的面试题为例讲解一下 `volatile` 关键字禁止指令重排序的效果。
面试中面试官经常会说:“单例模式了解吗?来给我手写一下!给我解释一下双重检验锁方式实现单例模式的原理呗!”
@@ -60,7 +101,7 @@ public class Singleton {
private Singleton() {
}
- public static Singleton getUniqueInstance() {
+ public static Singleton getUniqueInstance() {
//先判断对象是否已经实例过,没有实例化过才进入加锁代码
if (uniqueInstance == null) {
//类对象加锁
@@ -83,6 +124,67 @@ public class Singleton {
但是由于 JVM 具有指令重排的特性,执行顺序有可能变成 1->3->2。指令重排在单线程环境下不会出现问题,但是在多线程环境下会导致一个线程获得还没有初始化的实例。例如,线程 T1 执行了 1 和 3,此时 T2 调用 `getUniqueInstance`() 后发现 `uniqueInstance` 不为空,因此返回 `uniqueInstance`,但此时 `uniqueInstance` 还未被初始化。
+#### 从内存屏障角度理解 DCL 必须使用 volatile
+
+上面从指令重排序的角度解释了 DCL 单例中 `uniqueInstance` 为什么需要 `volatile` 修饰。下面从内存屏障的角度进一步分析 `volatile` 是如何解决这个问题的。
+
+`uniqueInstance = new Singleton();` 这行代码的三个步骤(分配内存、初始化对象、赋值引用)中,如果不加 `volatile`,步骤 2 和步骤 3 可能会被重排序为 1→3→2。加了 `volatile` 之后,由于 `uniqueInstance` 是 volatile 变量,对它的写操作(步骤 3:将引用赋值给 `uniqueInstance`)会按照前面介绍的 volatile 写的内存屏障插入策略来处理:
+
+1. 在 volatile 写 **之前** 插入 `StoreStore` 屏障:保证步骤 1(分配内存)和步骤 2(初始化对象)的写操作在步骤 3(赋值引用)之前完成,**禁止了步骤 2 和步骤 3 的重排序**。
+2. 在 volatile 写 **之后** 插入 `StoreLoad` 屏障:保证步骤 3 的写入结果对其他线程立即可见。
+
+这样,当线程 T2 读取 `uniqueInstance` 时(volatile 读),如果发现 `uniqueInstance != null`,那么可以保证该对象一定已经被完全初始化了。
+
+### volatile 与 happens-before 的关系
+
+JMM 中的 happens-before 原则是判断数据是否存在竞争、线程是否安全的重要依据。`volatile` 变量的读写操作与 happens-before 原则有着密切的关系。
+
+> 关于 happens-before 原则的详细介绍,可以参考 [JMM(Java 内存模型)详解](https://javaguide.cn/java/concurrent/jmm.html) 这篇文章。
+
+happens-before 原则中与 `volatile` 直接相关的是 **volatile 变量规则**:
+
+> **对一个 volatile 变量的写操作 happens-before 于后续对该 volatile 变量的读操作。**
+
+也就是说,如果线程 A 写入了一个 volatile 变量,线程 B 随后读取了同一个 volatile 变量,那么线程 A 在写入 volatile 变量之前所做的所有修改(包括对非 volatile 变量的修改),对线程 B 都是可见的。
+
+这个规则配合 happens-before 的 **传递性规则**(如果 A happens-before B,B happens-before C,那么 A happens-before C),可以实现一种轻量级的线程间通信。下面通过一个示例来说明:
+
+```java
+public class VolatileHappensBeforeDemo {
+ private int a = 0;
+ private int b = 0;
+ private volatile boolean flag = false;
+
+ // 线程 A 执行
+ public void writer() {
+ a = 1; // 操作1:普通写
+ b = 2; // 操作2:普通写
+ flag = true; // 操作3:volatile 写
+ }
+
+ // 线程 B 执行
+ public void reader() {
+ if (flag) { // 操作4:volatile 读
+ int x = a; // 操作5:普通读,x 一定等于 1
+ int y = b; // 操作6:普通读,y 一定等于 2
+ System.out.println("x=" + x + ", y=" + y);
+ }
+ }
+}
+```
+
+上面代码中,happens-before 关系链如下:
+
+1. 操作1、操作2 happens-before 操作3(**程序顺序规则**:同一线程中,前面的操作 happens-before 后面的操作)
+2. 操作3 happens-before 操作4(**volatile 变量规则**:volatile 写 happens-before volatile 读)
+3. 操作4 happens-before 操作5、操作6(**程序顺序规则**)
+
+根据 **传递性**:操作1、操作2 happens-before 操作5、操作6。
+
+因此,当线程 B 在操作4 读取到 `flag == true` 时,线程 A 在操作3 之前对 `a` 和 `b` 的修改对线程 B 一定是可见的。这里的关键在于:**volatile 变量的写-读操作,不仅保证了 volatile 变量本身的可见性,还通过 happens-before 的传递性"顺带"保证了其前后普通变量的可见性。**
+
+这也解释了为什么在实际开发中,`volatile` 经常被用作 **状态标志位**(如上面例子中的 `flag`),它可以在不使用锁的情况下,安全地在线程间传递状态信息,同时保证相关数据的可见性。
+
### volatile 可以保证原子性么?
**`volatile` 关键字能保证变量的可见性,但不能保证对变量的操作是原子性的。**
@@ -174,7 +276,7 @@ public void increase() {
}
```
-## 乐观锁和悲观锁
+## ⭐️乐观锁和悲观锁
### 什么是悲观锁?
@@ -285,9 +387,111 @@ public final native boolean compareAndSwapLong(Object o, long offset, long expec
关于 `Unsafe` 类的详细介绍可以看这篇文章:[Java 魔法类 Unsafe 详解 - JavaGuide - 2022](https://javaguide.cn/java/basis/unsafe.html) 。
-### 乐观锁存在哪些问题?
+### Java 中 CAS 是如何实现的?
-ABA 问题是乐观锁最常见的问题。
+在 Java 中,实现 CAS(Compare-And-Swap, 比较并交换)操作的一个关键类是`Unsafe`。
+
+`Unsafe`类位于`sun.misc`包下,是一个提供低级别、不安全操作的类。由于其强大的功能和潜在的危险性,它通常用于 JVM 内部或一些需要极高性能和底层访问的库中,而不推荐普通开发者在应用程序中使用。关于 `Unsafe`类的详细介绍,可以阅读这篇文章:📌[Java 魔法类 Unsafe 详解](https://javaguide.cn/java/basis/unsafe.html)。
+
+`sun.misc`包下的`Unsafe`类提供了`compareAndSwapObject`、`compareAndSwapInt`、`compareAndSwapLong`方法来实现的对`Object`、`int`、`long`类型的 CAS 操作:
+
+```java
+/**
+ * 以原子方式更新对象字段的值。
+ *
+ * @param o 要操作的对象
+ * @param offset 对象字段的内存偏移量
+ * @param expected 期望的旧值
+ * @param x 要设置的新值
+ * @return 如果值被成功更新,则返回 true;否则返回 false
+ */
+boolean compareAndSwapObject(Object o, long offset, Object expected, Object x);
+
+/**
+ * 以原子方式更新 int 类型的对象字段的值。
+ */
+boolean compareAndSwapInt(Object o, long offset, int expected, int x);
+
+/**
+ * 以原子方式更新 long 类型的对象字段的值。
+ */
+boolean compareAndSwapLong(Object o, long offset, long expected, long x);
+```
+
+`Unsafe`类中的 CAS 方法是`native`方法。`native`关键字表明这些方法是用本地代码(通常是 C 或 C++)实现的,而不是用 Java 实现的。这些方法直接调用底层的硬件指令来实现原子操作。也就是说,Java 语言并没有直接用 Java 实现 CAS,而是通过 C++ 内联汇编的形式实现的(通过 JNI 调用)。因此,CAS 的具体实现与操作系统以及 CPU 密切相关。
+
+`java.util.concurrent.atomic` 包提供了一些用于原子操作的类。这些类利用底层的原子指令,确保在多线程环境下的操作是线程安全的。
+
+
+
+关于这些 Atomic 原子类的介绍和使用,可以阅读这篇文章:[Atomic 原子类总结](https://javaguide.cn/java/concurrent/atomic-classes.html)。
+
+`AtomicInteger`是 Java 的原子类之一,主要用于对 `int` 类型的变量进行原子操作,它利用`Unsafe`类提供的低级别原子操作方法实现无锁的线程安全性。
+
+下面,我们通过解读`AtomicInteger`的核心源码(JDK1.8),来说明 Java 如何使用`Unsafe`类的方法来实现原子操作。
+
+`AtomicInteger`核心源码如下:
+
+```java
+// 获取 Unsafe 实例
+private static final Unsafe unsafe = Unsafe.getUnsafe();
+private static final long valueOffset;
+
+static {
+ try {
+ // 获取“value”字段在AtomicInteger类中的内存偏移量
+ valueOffset = unsafe.objectFieldOffset
+ (AtomicInteger.class.getDeclaredField("value"));
+ } catch (Exception ex) { throw new Error(ex); }
+}
+// 确保“value”字段的可见性
+private volatile int value;
+
+// 如果当前值等于预期值,则原子地将值设置为newValue
+// 使用 Unsafe#compareAndSwapInt 方法进行CAS操作
+public final boolean compareAndSet(int expect, int update) {
+ return unsafe.compareAndSwapInt(this, valueOffset, expect, update);
+}
+
+// 原子地将当前值加 delta 并返回旧值
+public final int getAndAdd(int delta) {
+ return unsafe.getAndAddInt(this, valueOffset, delta);
+}
+
+// 原子地将当前值加 1 并返回加之前的值(旧值)
+// 使用 Unsafe#getAndAddInt 方法进行CAS操作。
+public final int getAndIncrement() {
+ return unsafe.getAndAddInt(this, valueOffset, 1);
+}
+
+// 原子地将当前值减 1 并返回减之前的值(旧值)
+public final int getAndDecrement() {
+ return unsafe.getAndAddInt(this, valueOffset, -1);
+}
+```
+
+`Unsafe#getAndAddInt`源码:
+
+```java
+// 原子地获取并增加整数值
+public final int getAndAddInt(Object o, long offset, int delta) {
+ int v;
+ do {
+ // 以 volatile 方式获取对象 o 在内存偏移量 offset 处的整数值
+ v = getIntVolatile(o, offset);
+ } while (!compareAndSwapInt(o, offset, v, v + delta));
+ // 返回旧值
+ return v;
+}
+```
+
+可以看到,`getAndAddInt` 使用了 `do-while` 循环:在`compareAndSwapInt`操作失败时,会不断重试直到成功。也就是说,`getAndAddInt`方法会通过 `compareAndSwapInt` 方法来尝试更新 `value` 的值,如果更新失败(当前值在此期间被其他线程修改),它会重新获取当前值并再次尝试更新,直到操作成功。
+
+由于 CAS 操作可能会因为并发冲突而失败,因此通常会与`while`循环搭配使用,在失败后不断重试,直到操作成功。这就是 **自旋锁机制** 。
+
+### CAS 算法存在哪些问题?
+
+ABA 问题是 CAS 算法最常见的问题。
#### ABA 问题
@@ -314,14 +518,29 @@ public boolean compareAndSet(V expectedReference,
CAS 经常会用到自旋操作来进行重试,也就是不成功就一直循环执行直到成功。如果长时间不成功,会给 CPU 带来非常大的执行开销。
-如果 JVM 能支持处理器提供的 pause 指令那么效率会有一定的提升,pause 指令有两个作用:
+如果 JVM 能够支持处理器提供的`pause`指令,那么自旋操作的效率将有所提升。`pause`指令有两个重要作用:
-1. 可以延迟流水线执行指令,使 CPU 不会消耗过多的执行资源,延迟的时间取决于具体实现的版本,在一些处理器上延迟时间是零。
-2. 可以避免在退出循环的时候因内存顺序冲而引起 CPU 流水线被清空,从而提高 CPU 的执行效率。
+1. **延迟流水线执行指令**:`pause`指令可以延迟指令的执行,从而减少 CPU 的资源消耗。具体的延迟时间取决于处理器的实现版本,在某些处理器上,延迟时间可能为零。
+2. **避免内存顺序冲突**:在退出循环时,`pause`指令可以避免由于内存顺序冲突而导致的 CPU 流水线被清空,从而提高 CPU 的执行效率。
#### 只能保证一个共享变量的原子操作
-CAS 只对单个共享变量有效,当操作涉及跨多个共享变量时 CAS 无效。但是从 JDK 1.5 开始,提供了`AtomicReference`类来保证引用对象之间的原子性,你可以把多个变量放在一个对象里来进行 CAS 操作.所以我们可以使用锁或者利用`AtomicReference`类把多个共享变量合并成一个共享变量来操作。
+CAS 操作仅能对单个共享变量有效。当需要操作多个共享变量时,CAS 就显得无能为力。不过,从 JDK 1.5 开始,Java 提供了`AtomicReference`类,这使得我们能够保证引用对象之间的原子性。通过将多个变量封装在一个对象中,我们可以使用`AtomicReference`来执行 CAS 操作。
+
+除了 `AtomicReference` 这种方式之外,还可以利用加锁来保证。
+
+### 总结
+
+| **对比维度** | **乐观锁 (Optimistic Locking)** | **悲观锁 (Pessimistic Locking)** |
+| --------------- | ------------------------------------------- | -------------------------------------------- |
+| **核心假设** | 假设冲突很少发生,提交时才验证。 | 假设冲突必然发生,读取时就加锁。 |
+| **底层原理** | **CAS (Compare And Swap)** 或版本号机制。 | **操作系统互斥锁**,涉及内核态切换。 |
+| **阻塞情况** | **非阻塞**。失败后由业务逻辑决定是否重试。 | **阻塞**。其他线程必须排队等待锁释放。 |
+| **并发开销** | **CPU 消耗**(高并发写时频繁自旋重试)。 | **上下文切换开销**(线程挂起与唤醒)。 |
+| **死锁风险** | **无死锁**(因为不涉及持有锁的等待)。 | **有死锁风险**(多个锁相互等待)。 |
+| **数据库实现** | `UPDATE ... SET version = version + 1` | `SELECT ... FOR UPDATE` |
+| **Java 代表类** | `AtomicInteger`、`LongAdder`、`StampedLock` | `synchronized`、`ReentrantLock` |
+| **适用场景** | **多读少写**、并发冲突概率低的业务。 | **多写少读**、数据一致性要求极高的核心业务。 |
## synchronized 关键字
@@ -371,8 +590,8 @@ synchronized static void method() {
对括号里指定的对象/类加锁:
-- `synchronized(object)` 表示进入同步代码库前要获得 **给定对象的锁**。
-- `synchronized(类.class)` 表示进入同步代码前要获得 **给定 Class 的锁**
+- `synchronized(object)` 表示进入同步代码块前要获得 **给定对象的锁**。
+- `synchronized(类.class)` 表示进入同步代码块前要获得 **给定 Class 的锁**
```java
synchronized(this) {
@@ -388,11 +607,11 @@ synchronized(this) {
### 构造方法可以用 synchronized 修饰么?
-先说结论:**构造方法不能使用 synchronized 关键字修饰。**
+构造方法不能使用 synchronized 关键字修饰。不过,可以在构造方法内部使用 synchronized 代码块。
-构造方法本身就属于线程安全的,不存在同步的构造方法一说。
+另外,构造方法本身是线程安全的,但如果在构造方法中涉及到共享资源的操作,就需要采取适当的同步措施来保证整个构造过程的线程安全。
-### synchronized 底层原理了解吗?
+### ⭐️synchronized 底层原理了解吗?
synchronized 关键字底层原理属于 JVM 层面的东西。
@@ -426,13 +645,13 @@ public class SynchronizedDemo {

-对象锁的的拥有者线程才可以执行 `monitorexit` 指令来释放锁。在执行 `monitorexit` 指令后,将锁计数器设为 0,表明锁被释放,其他线程可以尝试获取锁。
+对象锁的拥有者线程才可以执行 `monitorexit` 指令来释放锁。在执行 `monitorexit` 指令后,将锁计数器设为 0,表明锁被释放,其他线程可以尝试获取锁。

如果获取对象锁失败,那当前线程就要阻塞等待,直到锁被另外一个线程释放为止。
-#### synchronized 修饰方法的的情况
+#### synchronized 修饰方法的情况
```java
public class SynchronizedDemo2 {
@@ -445,7 +664,7 @@ public class SynchronizedDemo2 {

-`synchronized` 修饰的方法并没有 `monitorenter` 指令和 `monitorexit` 指令,取得代之的确实是 `ACC_SYNCHRONIZED` 标识,该标识指明了该方法是一个同步方法。JVM 通过该 `ACC_SYNCHRONIZED` 访问标志来辨别一个方法是否声明为同步方法,从而执行相应的同步调用。
+`synchronized` 修饰的方法并没有 `monitorenter` 指令和 `monitorexit` 指令,取而代之的是 `ACC_SYNCHRONIZED` 标识,该标识指明了该方法是一个同步方法。JVM 通过该 `ACC_SYNCHRONIZED` 访问标志来辨别一个方法是否声明为同步方法,从而执行相应的同步调用。
如果是实例方法,JVM 会尝试获取实例对象的锁。如果是静态方法,JVM 会尝试获取当前 class 的锁。
@@ -453,9 +672,9 @@ public class SynchronizedDemo2 {
`synchronized` 同步语句块的实现使用的是 `monitorenter` 和 `monitorexit` 指令,其中 `monitorenter` 指令指向同步代码块的开始位置,`monitorexit` 指令则指明同步代码块的结束位置。
-`synchronized` 修饰的方法并没有 `monitorenter` 指令和 `monitorexit` 指令,取得代之的确实是 `ACC_SYNCHRONIZED` 标识,该标识指明了该方法是一个同步方法。
+`synchronized` 修饰的方法并没有 `monitorenter` 指令和 `monitorexit` 指令,取而代之的是 `ACC_SYNCHRONIZED` 标识,该标识指明了该方法是一个同步方法。
-**不过两者的本质都是对对象监视器 monitor 的获取。**
+**不过,两者的本质都是对对象监视器 monitor 的获取。**
相关推荐:[Java 锁与线程的那些事 - 有赞技术团队](https://tech.youzan.com/javasuo-yu-xian-cheng-de-na-xie-shi/) 。
@@ -469,7 +688,31 @@ public class SynchronizedDemo2 {
`synchronized` 锁升级是一个比较复杂的过程,面试也很少问到,如果你想要详细了解的话,可以看看这篇文章:[浅析 synchronized 锁升级的原理与实现](https://www.cnblogs.com/star95/p/17542850.html)。
-### synchronized 和 volatile 有什么区别?
+### synchronized 的偏向锁为什么被废弃了?
+
+Open JDK 官方声明:[JEP 374: Deprecate and Disable Biased Locking](https://openjdk.org/jeps/374)
+
+在 JDK15 中,偏向锁被默认关闭(仍然可以使用 `-XX:+UseBiasedLocking` 启用偏向锁),在 JDK18 中,偏向锁已经被彻底废弃(无法通过命令行打开)。
+
+在官方声明中,主要原因有两个方面:
+
+- **性能收益不明显:**
+
+偏向锁是 HotSpot 虚拟机的一项优化技术,可以提升单线程对同步代码块的访问性能。
+
+受益于偏向锁的应用程序通常使用了早期的 Java 集合 API,例如 HashTable、Vector,在这些集合类中通过 synchronized 来控制同步,这样在单线程频繁访问时,通过偏向锁会减少同步开销。
+
+随着 JDK 的发展,出现了 ConcurrentHashMap 高性能的集合类,在集合类内部进行了许多性能优化,此时偏向锁带来的性能收益就不明显了。
+
+偏向锁仅仅在单线程访问同步代码块的场景中可以获得性能收益。
+
+如果存在多线程竞争,就需要 **撤销偏向锁** ,这个操作的性能开销是比较昂贵的。偏向锁的撤销需要等待进入到全局安全点(safe point),该状态下所有线程都是暂停的,此时去检查线程状态并进行偏向锁的撤销。
+
+- **JVM 内部代码维护成本太高:**
+
+偏向锁将许多复杂代码引入到同步子系统,并且对其他的 HotSpot 组件也具有侵入性。这种复杂性为理解代码、系统重构带来了困难,因此, OpenJDK 官方希望禁用、废弃并删除偏向锁。
+
+### ⭐️synchronized 和 volatile 有什么区别?
`synchronized` 关键字和 `volatile` 关键字是两个互补的存在,而不是对立的存在!
@@ -477,6 +720,29 @@ public class SynchronizedDemo2 {
- `volatile` 关键字能保证数据的可见性,但不能保证数据的原子性。`synchronized` 关键字两者都能保证。
- `volatile`关键字主要用于解决变量在多个线程之间的可见性,而 `synchronized` 关键字解决的是多个线程之间访问资源的同步性。
+#### volatile 与 synchronized 的性能对比
+
+上面提到 `volatile` 是线程同步的轻量级实现,性能比 `synchronized` 要好。下面从底层原理的角度分析为什么 `volatile` 性能更好,以及在什么情况下应该选择哪个。
+
+周志明在《深入理解 Java 虚拟机》中指出:
+
+> volatile 变量的读操作的性能消耗与普通变量几乎没有什么差别,但是写操作则可能会慢上一些,因为它需要在本地代码中插入许多内存屏障指令来保证处理器不发生乱序执行。不过即便如此,大多数场景下 volatile 的总开销仍然要比锁来得更低。
+
+二者性能差异的根本原因在于底层实现机制不同:
+
+| 对比维度 | `volatile` | `synchronized` |
+| --- | --- | --- |
+| **实现层面** | 通过插入内存屏障指令实现,不涉及线程阻塞和上下文切换 | 依赖操作系统的互斥锁(Mutex Lock),涉及用户态与内核态的切换 |
+| **读操作开销** | 与普通变量几乎相同 | 需要获取 monitor 锁,即使无竞争也有一定开销(偏向锁/轻量级锁 CAS) |
+| **写操作开销** | 需要插入 `StoreStore` + `StoreLoad` 内存屏障,有一定开销但不会导致线程阻塞 | 需要获取和释放 monitor 锁,有竞争时会导致线程阻塞和上下文切换 |
+| **竞争时的表现** | 不会导致线程阻塞,始终是非阻塞的 | 线程竞争激烈时,会频繁发生阻塞和唤醒,上下文切换开销大 |
+| **功能范围** | 只能修饰变量,只保证可见性和有序性 | 可以修饰方法和代码块,同时保证可见性、有序性和原子性 |
+
+**选择建议:**
+
+- 如果只需要保证变量的可见性(如状态标志位、DCL 单例中的实例引用),优先使用 `volatile`,因为它的开销更小。
+- 如果需要保证复合操作的原子性(如 `i++`、先检查后执行等),则必须使用 `synchronized`、`Lock` 或原子类,`volatile` 无法胜任。
+
## ReentrantLock
### ReentrantLock 是什么?
@@ -507,7 +773,7 @@ public ReentrantLock(boolean fair) {
- **公平锁** : 锁被释放之后,先申请的线程先得到锁。性能较差一些,因为公平锁为了保证时间上的绝对顺序,上下文切换更频繁。
- **非公平锁**:锁被释放之后,后申请的线程可能会先获取到锁,是随机或者按照其他优先级排序的。性能更好,但可能会导致某些线程永远无法获取到锁。
-### synchronized 和 ReentrantLock 有什么区别?
+### ⭐️synchronized 和 ReentrantLock 有什么区别?
#### 两者都是可重入锁
@@ -536,15 +802,16 @@ public class SynchronizedDemo {
`synchronized` 是依赖于 JVM 实现的,前面我们也讲到了 虚拟机团队在 JDK1.6 为 `synchronized` 关键字进行了很多优化,但是这些优化都是在虚拟机层面实现的,并没有直接暴露给我们。
-`ReentrantLock` 是 JDK 层面实现的(也就是 API 层面,需要 lock() 和 unlock() 方法配合 try/finally 语句块来完成),所以我们可以通过查看它的源代码,来看它是如何实现的。
+`ReentrantLock` 是 JDK 层面实现的(也就是 API 层面,需要 `lock()` 和 `unlock()` 方法配合 `try/finally` 语句块来完成),所以我们可以通过查看它的源代码,来看它是如何实现的。
#### ReentrantLock 比 synchronized 增加了一些高级功能
相比`synchronized`,`ReentrantLock`增加了一些高级功能。主要来说主要有三点:
-- **等待可中断** : `ReentrantLock`提供了一种能够中断等待锁的线程的机制,通过 `lock.lockInterruptibly()` 来实现这个机制。也就是说正在等待的线程可以选择放弃等待,改为处理其他事情。
+- **等待可中断** : `ReentrantLock`提供了一种能够中断等待锁的线程的机制,通过 `lock.lockInterruptibly()` 来实现这个机制。也就是说当前线程在等待获取锁的过程中,如果其他线程中断当前线程「 `interrupt()` 」,当前线程就会抛出 `InterruptedException` 异常,可以捕捉该异常进行相应处理。
- **可实现公平锁** : `ReentrantLock`可以指定是公平锁还是非公平锁。而`synchronized`只能是非公平锁。所谓的公平锁就是先等待的线程先获得锁。`ReentrantLock`默认情况是非公平的,可以通过 `ReentrantLock`类的`ReentrantLock(boolean fair)`构造方法来指定是否是公平的。
-- **可实现选择性通知(锁可以绑定多个条件)**: `synchronized`关键字与`wait()`和`notify()`/`notifyAll()`方法相结合可以实现等待/通知机制。`ReentrantLock`类当然也可以实现,但是需要借助于`Condition`接口与`newCondition()`方法。
+- **通知机制更强大**:`ReentrantLock` 通过绑定多个 `Condition` 对象,可以实现分组唤醒和选择性通知。这解决了 `synchronized` 只能随机唤醒或全部唤醒的效率问题,为复杂的线程协作场景提供了强大的支持。
+- **支持超时** :`ReentrantLock` 提供了 `tryLock(timeout)` 的方法,可以指定等待获取锁的最长等待时间,如果超过了等待时间,就会获取锁失败,不会一直等待。
如果你想使用上述功能,那么选择 `ReentrantLock` 是一个不错的选择。
@@ -552,10 +819,95 @@ public class SynchronizedDemo {
> `Condition`是 JDK1.5 之后才有的,它具有很好的灵活性,比如可以实现多路通知功能也就是在一个`Lock`对象中可以创建多个`Condition`实例(即对象监视器),**线程对象可以注册在指定的`Condition`中,从而可以有选择性的进行线程通知,在调度线程上更加灵活。 在使用`notify()/notifyAll()`方法进行通知时,被通知的线程是由 JVM 选择的,用`ReentrantLock`类结合`Condition`实例可以实现“选择性通知”** ,这个功能非常重要,而且是 `Condition` 接口默认提供的。而`synchronized`关键字就相当于整个 `Lock` 对象中只有一个`Condition`实例,所有的线程都注册在它一个身上。如果执行`notifyAll()`方法的话就会通知所有处于等待状态的线程,这样会造成很大的效率问题。而`Condition`实例的`signalAll()`方法,只会唤醒注册在该`Condition`实例中的所有等待线程。
+关于 **等待可中断** 的补充:
+
+> `lockInterruptibly()` 会让获取锁的线程在阻塞等待的过程中可以响应中断,即当前线程在获取锁的时候,发现锁被其他线程持有,就会阻塞等待。
+>
+> 在阻塞等待的过程中,如果其他线程中断当前线程 `interrupt()` ,就会抛出 `InterruptedException` 异常,可以捕获该异常,做一些处理操作。
+>
+> 为了更好理解这个方法,借用 Stack Overflow 上的一个案例,可以更好地理解 `lockInterruptibly()` 可以响应中断:
+>
+> ```JAVA
+> public class MyRentrantlock {
+> Thread t = new Thread() {
+> @Override
+> public void run() {
+> ReentrantLock r = new ReentrantLock();
+> // 1.1、第一次尝试获取锁,可以获取成功
+> r.lock();
+>
+> // 1.2、此时锁的重入次数为 1
+> System.out.println("lock() : lock count :" + r.getHoldCount());
+>
+> // 2、中断当前线程,通过 Thread.currentThread().isInterrupted() 可以看到当前线程的中断状态为 true
+> interrupt();
+> System.out.println("Current thread is intrupted");
+>
+> // 3.1、尝试获取锁,可以成功获取
+> r.tryLock();
+> // 3.2、此时锁的重入次数为 2
+> System.out.println("tryLock() on intrupted thread lock count :" + r.getHoldCount());
+> try {
+> // 4、打印线程的中断状态为 true,那么调用 lockInterruptibly() 方法就会抛出 InterruptedException 异常
+> System.out.println("Current Thread isInterrupted:" + Thread.currentThread().isInterrupted());
+> r.lockInterruptibly();
+> System.out.println("lockInterruptibly() --NOt executable statement" + r.getHoldCount());
+> } catch (InterruptedException e) {
+> r.lock();
+> System.out.println("Error");
+> } finally {
+> r.unlock();
+> }
+>
+> // 5、打印锁的重入次数,可以发现 lockInterruptibly() 方法并没有成功获取到锁
+> System.out.println("lockInterruptibly() not able to Acqurie lock: lock count :" + r.getHoldCount());
+>
+> r.unlock();
+> System.out.println("lock count :" + r.getHoldCount());
+> r.unlock();
+> System.out.println("lock count :" + r.getHoldCount());
+> }
+> };
+> public static void main(String str[]) {
+> MyRentrantlock m = new MyRentrantlock();
+> m.t.start();
+> }
+> }
+> ```
+>
+> 输出:
+>
+> ```BASH
+> lock() : lock count :1
+> Current thread is intrupted
+> tryLock() on intrupted thread lock count :2
+> Current Thread isInterrupted:true
+> Error
+> lockInterruptibly() not able to Acqurie lock: lock count :2
+> lock count :1
+> lock count :0
+> ```
+
+关于 **支持超时** 的补充:
+
+> **为什么需要 `tryLock(timeout)` 这个功能呢?**
+>
+> `tryLock(timeout)` 方法尝试在指定的超时时间内获取锁。如果成功获取锁,则返回 `true`;如果在锁可用之前超时,则返回 `false`。此功能在以下几种场景中非常有用:
+>
+> - **防止死锁:** 在复杂的锁场景中,`tryLock(timeout)` 可以通过允许线程在合理的时间内放弃并重试来帮助防止死锁。
+> - **提高响应速度:** 防止线程无限期阻塞。
+> - **处理时间敏感的操作:** 对于具有严格时间限制的操作,`tryLock(timeout)` 允许线程在无法及时获取锁时继续执行替代操作。
+
### 可中断锁和不可中断锁有什么区别?
-- **可中断锁**:获取锁的过程中可以被中断,不需要一直等到获取锁之后 才能进行其他逻辑处理。`ReentrantLock` 就属于是可中断锁。
-- **不可中断锁**:一旦线程申请了锁,就只能等到拿到锁以后才能进行其他的逻辑处理。 `synchronized` 就属于是不可中断锁。
+它们的区别在于:**线程在获取锁的过程中被阻塞时,是否能够因为中断而提前放弃等待。**
+
+- **不可中断锁**:线程在等待锁期间即使收到中断信号,也不会退出阻塞状态,而是一直等待直到获得锁。中断状态会被保留,但不会影响锁的获取过程。
+ - `synchronized` 属于典型的不可中断锁。
+ - `ReentrantLock#lock()` 也是不可中断的。
+- **可中断锁**:线程在等待锁的过程中如果收到中断信号,会立即停止等待并抛出 `InterruptedException`,从而有机会进行取消或错误处理。
+ - `ReentrantLock#lockInterruptibly()` 实现了可中断锁。
+ - `ReentrantLock#tryLock(long time, TimeUnit unit)` (带超时的尝试获取)也是可中断的。
## ReentrantReadWriteLock
@@ -584,7 +936,7 @@ public interface ReadWriteLock {

-`ReentrantReadWriteLock` 也支持公平锁和非公平锁,默认使用非公平锁,可以通过构造器来显示的指定。
+`ReentrantReadWriteLock` 也支持公平锁和非公平锁,默认使用非公平锁,可以通过构造器来显式地指定。
```java
// 传入一个 boolean 值,true 时为公平锁,false 时为非公平锁
@@ -619,6 +971,32 @@ public ReentrantReadWriteLock(boolean fair) {
## StampedLock
+```mermaid
+flowchart TB
+ subgraph StampedLock["StampedLock(JDK1.8+)"]
+ style StampedLock fill:#F0F2F5,stroke:#E0E6ED,rx:10,ry:10
+ subgraph Modes["模式分类"]
+ style Modes fill:#F5F7FA,stroke:#E0E6ED,rx:10,ry:10
+ Write(["写锁(独占):单线程持有,阻塞其他读写"]):::write
+ Read(["读锁(悲观读):无写锁时多线程共享"]):::read
+ Optimistic(["乐观读:无写锁时直接访问,提交时验证"]):::optimistic
+ end
+ subgraph Features["核心特点"]
+ style Features fill:#F5F7FA,stroke:#E0E6ED,rx:10,ry:10
+ F1(["不可重入,不支持Condition"]):::feature
+ F2(["性能优秀(乐观读减少阻塞)"]):::feature
+ F3(["适用场景:读多写少,无重入需求"]):::feature
+ end
+ end
+
+ classDef write fill:#C44545,color:#fff,rx:10,ry:10
+ classDef read fill:#00838F,color:#fff,rx:10,ry:10
+ classDef optimistic fill:#4CA497,color:#fff,rx:10,ry:10
+ classDef feature fill:#E99151,color:#333,rx:10,ry:10
+
+ linkStyle default stroke-width:1.5px,opacity:0.8
+```
+
`StampedLock` 面试中问的比较少,不是很重要,简单了解即可。
### StampedLock 是什么?
diff --git a/docs/java/concurrent/java-concurrent-questions-03.md b/docs/java/concurrent/java-concurrent-questions-03.md
index 96ff4aa5448..ef3b3269bcd 100644
--- a/docs/java/concurrent/java-concurrent-questions-03.md
+++ b/docs/java/concurrent/java-concurrent-questions-03.md
@@ -1,15 +1,13 @@
---
title: Java并发常见面试题总结(下)
+description: Java并发高级面试题:详解ThreadLocal原理与内存泄漏、线程池参数配置与工作原理、Future/CompletableFuture异步编程、并发容器与工具类使用。
category: Java
tag:
- Java并发
head:
- - meta
- name: keywords
- content: 多线程,死锁,线程池,CAS,AQS
- - - meta
- - name: description
- content: Java并发常见知识点和面试题总结(含详细解答),希望对你有帮助!
+ content: ThreadLocal,线程池,Executor框架,Future,CompletableFuture,并发工具类,并发容器,并发面试题
---
@@ -18,93 +16,36 @@ head:
### ThreadLocal 有什么用?
-通常情况下,我们创建的变量是可以被任何一个线程访问并修改的。**如果想实现每一个线程都有自己的专属本地变量该如何解决呢?**
-
-JDK 中自带的`ThreadLocal`类正是为了解决这样的问题。 **`ThreadLocal`类主要解决的就是让每个线程绑定自己的值,可以将`ThreadLocal`类形象的比喻成存放数据的盒子,盒子中可以存储每个线程的私有数据。**
-
-如果你创建了一个`ThreadLocal`变量,那么访问这个变量的每个线程都会有这个变量的本地副本,这也是`ThreadLocal`变量名的由来。他们可以使用 `get()` 和 `set()` 方法来获取默认值或将其值更改为当前线程所存的副本的值,从而避免了线程安全问题。
+通常情况下,我们创建的变量可以被任何一个线程访问和修改。这在多线程环境中可能导致数据竞争和线程安全问题。那么,**如果想让每个线程都有自己的专属本地变量,该如何实现呢?**
-再举个简单的例子:两个人去宝屋收集宝物,这两个共用一个袋子的话肯定会产生争执,但是给他们两个人每个人分配一个袋子的话就不会出现这样的问题。如果把这两个人比作线程的话,那么 ThreadLocal 就是用来避免这两个线程竞争的。
+JDK 中提供的 `ThreadLocal` 类正是为了解决这个问题。**`ThreadLocal` 类允许每个线程绑定自己的值**,可以将其形象地比喻为一个“存放数据的盒子”。每个线程都有自己独立的盒子,用于存储私有数据,确保不同线程之间的数据互不干扰。
-### 如何使用 ThreadLocal?
+当你创建一个 `ThreadLocal` 变量时,每个访问该变量的线程都会拥有一个独立的副本。这也是 `ThreadLocal` 名称的由来。线程可以通过 `get()` 方法获取自己线程的本地副本,或通过 `set()` 方法修改该副本的值,从而避免了线程安全问题。
-相信看了上面的解释,大家已经搞懂 `ThreadLocal` 类是个什么东西了。下面简单演示一下如何在项目中实际使用 `ThreadLocal` 。
+举个简单的例子:假设有两个人去宝屋收集宝物。如果他们共用一个袋子,必然会产生争执;但如果每个人都有一个独立的袋子,就不会有这个问题。如果将这两个人比作线程,那么 `ThreadLocal` 就是用来避免这两个线程竞争同一个资源的方法。
```java
-import java.text.SimpleDateFormat;
-import java.util.Random;
-
-public class ThreadLocalExample implements Runnable{
-
- // SimpleDateFormat 不是线程安全的,所以每个线程都要有自己独立的副本
- private static final ThreadLocal formatter = ThreadLocal.withInitial(() -> new SimpleDateFormat("yyyyMMdd HHmm"));
-
- public static void main(String[] args) throws InterruptedException {
- ThreadLocalExample obj = new ThreadLocalExample();
- for(int i=0 ; i<10; i++){
- Thread t = new Thread(obj, ""+i);
- Thread.sleep(new Random().nextInt(1000));
- t.start();
- }
+public class ThreadLocalExample {
+ private static ThreadLocal threadLocal = ThreadLocal.withInitial(() -> 0);
+
+ public static void main(String[] args) {
+ Runnable task = () -> {
+ int value = threadLocal.get();
+ value += 1;
+ threadLocal.set(value);
+ System.out.println(Thread.currentThread().getName() + " Value: " + threadLocal.get());
+ };
+
+ Thread thread1 = new Thread(task, "Thread-1");
+ Thread thread2 = new Thread(task, "Thread-2");
+
+ thread1.start(); // 输出: Thread-1 Value: 1
+ thread2.start(); // 输出: Thread-2 Value: 1
}
-
- @Override
- public void run() {
- System.out.println("Thread Name= "+Thread.currentThread().getName()+" default Formatter = "+formatter.get().toPattern());
- try {
- Thread.sleep(new Random().nextInt(1000));
- } catch (InterruptedException e) {
- e.printStackTrace();
- }
- //formatter pattern is changed here by thread, but it won't reflect to other threads
- formatter.set(new SimpleDateFormat());
-
- System.out.println("Thread Name= "+Thread.currentThread().getName()+" formatter = "+formatter.get().toPattern());
- }
-
}
-
-```
-
-输出结果 :
-
-```plain
-Thread Name= 0 default Formatter = yyyyMMdd HHmm
-Thread Name= 0 formatter = yy-M-d ah:mm
-Thread Name= 1 default Formatter = yyyyMMdd HHmm
-Thread Name= 2 default Formatter = yyyyMMdd HHmm
-Thread Name= 1 formatter = yy-M-d ah:mm
-Thread Name= 3 default Formatter = yyyyMMdd HHmm
-Thread Name= 2 formatter = yy-M-d ah:mm
-Thread Name= 4 default Formatter = yyyyMMdd HHmm
-Thread Name= 3 formatter = yy-M-d ah:mm
-Thread Name= 4 formatter = yy-M-d ah:mm
-Thread Name= 5 default Formatter = yyyyMMdd HHmm
-Thread Name= 5 formatter = yy-M-d ah:mm
-Thread Name= 6 default Formatter = yyyyMMdd HHmm
-Thread Name= 6 formatter = yy-M-d ah:mm
-Thread Name= 7 default Formatter = yyyyMMdd HHmm
-Thread Name= 7 formatter = yy-M-d ah:mm
-Thread Name= 8 default Formatter = yyyyMMdd HHmm
-Thread Name= 9 default Formatter = yyyyMMdd HHmm
-Thread Name= 8 formatter = yy-M-d ah:mm
-Thread Name= 9 formatter = yy-M-d ah:mm
-```
-
-从输出中可以看出,虽然 `Thread-0` 已经改变了 `formatter` 的值,但 `Thread-1` 默认格式化值与初始化值相同,其他线程也一样。
-
-上面有一段代码用到了创建 `ThreadLocal` 变量的那段代码用到了 Java8 的知识,它等于下面这段代码,如果你写了下面这段代码的话,IDEA 会提示你转换为 Java8 的格式(IDEA 真的不错!)。因为 ThreadLocal 类在 Java 8 中扩展,使用一个新的方法`withInitial()`,将 Supplier 功能接口作为参数。
-
-```java
-private static final ThreadLocal formatter = new ThreadLocal(){
- @Override
- protected SimpleDateFormat initialValue(){
- return new SimpleDateFormat("yyyyMMdd HHmm");
- }
-};
```
-### ThreadLocal 原理了解吗?
+### ⭐️ThreadLocal 原理了解吗?
从 `Thread`类源代码入手。
@@ -161,15 +102,36 @@ ThreadLocalMap(ThreadLocal> firstKey, Object firstValue) {

-### ThreadLocal 内存泄露问题是怎么导致的?
+### ⭐️ThreadLocal 内存泄露问题是怎么导致的?
-`ThreadLocalMap` 中使用的 key 为 `ThreadLocal` 的弱引用,而 value 是强引用。所以,如果 `ThreadLocal` 没有被外部强引用的情况下,在垃圾回收的时候,key 会被清理掉,而 value 不会被清理掉。
+`ThreadLocal` 内存泄漏的根本原因在于其内部实现机制。
-这样一来,`ThreadLocalMap` 中就会出现 key 为 null 的 Entry。假如我们不做任何措施的话,value 永远无法被 GC 回收,这个时候就可能会产生内存泄露。`ThreadLocalMap` 实现中已经考虑了这种情况,在调用 `set()`、`get()`、`remove()` 方法的时候,会清理掉 key 为 null 的记录。使用完 `ThreadLocal`方法后最好手动调用`remove()`方法
+通过上面的内容我们已经知道:每个线程维护一个名为 `ThreadLocalMap` 的 map。 当你使用 `ThreadLocal` 存储值时,实际上是将值存储在当前线程的 `ThreadLocalMap` 中,其中 `ThreadLocal` 实例本身作为 key,而你要存储的值作为 value。
+
+`ThreadLocal` 的 `set()` 方法源码如下:
+
+```java
+public void set(T value) {
+ Thread t = Thread.currentThread(); // 获取当前线程
+ ThreadLocalMap map = getMap(t); // 获取当前线程的 ThreadLocalMap
+ if (map != null) {
+ map.set(this, value); // 设置值
+ } else {
+ createMap(t, value); // 创建新的 ThreadLocalMap
+ }
+}
+```
+
+`ThreadLocalMap` 的 `set()` 和 `createMap()` 方法中,并没有直接存储 `ThreadLocal` 对象本身,而是使用 `ThreadLocal` 的哈希值计算数组索引,最终存储于类型为`static class Entry extends WeakReference>`的数组中。
+
+```java
+int i = key.threadLocalHashCode & (len-1);
+```
+
+`ThreadLocalMap` 的 `Entry` 定义如下:
```java
static class Entry extends WeakReference> {
- /** The value associated with this ThreadLocal. */
Object value;
Entry(ThreadLocal> k, Object v) {
@@ -179,11 +141,322 @@ static class Entry extends WeakReference> {
}
```
-**弱引用介绍:**
+`ThreadLocalMap` 的 `key` 和 `value` 引用机制:
-> 如果一个对象只具有弱引用,那就类似于**可有可无的生活用品**。弱引用与软引用的区别在于:只具有弱引用的对象拥有更短暂的生命周期。在垃圾回收器线程扫描它 所管辖的内存区域的过程中,一旦发现了只具有弱引用的对象,不管当前内存空间足够与否,都会回收它的内存。不过,由于垃圾回收器是一个优先级很低的线程, 因此不一定会很快发现那些只具有弱引用的对象。
->
-> 弱引用可以和一个引用队列(ReferenceQueue)联合使用,如果弱引用所引用的对象被垃圾回收,Java 虚拟机就会把这个弱引用加入到与之关联的引用队列中。
+- **key 是弱引用**:`ThreadLocalMap` 中的 key 是 `ThreadLocal` 的弱引用 (`WeakReference>`)。 这意味着,如果 `ThreadLocal` 实例不再被任何强引用指向,垃圾回收器会在下次 GC 时回收该实例,导致 `ThreadLocalMap` 中对应的 key 变为 `null`。
+- **value 是强引用**:即使 `key` 被 GC 回收,`value` 仍然被 `ThreadLocalMap.Entry` 强引用存在,无法被 GC 回收。
+
+当 `ThreadLocal` 实例失去强引用后,其对应的 value 仍然存在于 `ThreadLocalMap` 中,因为 `Entry` 对象强引用了它。如果线程持续存活(例如线程池中的线程),`ThreadLocalMap` 也会一直存在,导致 key 为 `null` 的 entry 无法被垃圾回收,即会造成内存泄漏。
+
+也就是说,内存泄漏的发生需要同时满足两个条件:
+
+1. `ThreadLocal` 实例不再被强引用;
+2. 线程持续存活,导致 `ThreadLocalMap` 长期存在。
+
+虽然 `ThreadLocalMap` 在 `get()`, `set()` 和 `remove()` 操作时会尝试清理 key 为 null 的 entry,但这种清理机制是被动的,并不完全可靠。
+
+**如何避免内存泄漏的发生?**
+
+1. 在使用完 `ThreadLocal` 后,务必调用 `remove()` 方法。 这是最安全和最推荐的做法。 `remove()` 方法会从 `ThreadLocalMap` 中显式地移除对应的 entry,彻底解决内存泄漏的风险。 即使将 `ThreadLocal` 定义为 `static final`,也强烈建议在每次使用后调用 `remove()`。
+2. 在线程池等线程复用的场景下,使用 `try-finally` 块可以确保即使发生异常,`remove()` 方法也一定会被执行。
+
+#### 为什么 Entry 的 key 要设计为弱引用?
+
+这是一个经典的面试追问。很多同学知道 `ThreadLocalMap` 的 key 是弱引用,但不清楚**为什么要这样设计**,以及如果换成强引用会怎样。
+
+我们先来看完整的引用链路。当一个线程使用 `ThreadLocal` 时,涉及以下引用关系:
+
+```
+强引用(栈/静态变量)──→ ThreadLocal 实例
+ ↑
+Thread ──→ ThreadLocalMap ──→ Entry ─── key(WeakReference)──┘
+ │
+ └─── value(强引用)──→ 实际存储的对象
+```
+
+理解了这条引用链路,我们来对比两种设计方案:
+
+**假设 key 使用强引用(实际没有采用):**
+
+当业务代码中的 `ThreadLocal` 引用被置为 `null`(例如方法执行结束、对象被回收),此时虽然业务代码已经不再需要这个 `ThreadLocal`,但由于 `ThreadLocalMap` 的 Entry 对 key 持有**强引用**,`ThreadLocal` 实例仍然无法被 GC 回收。只要线程不终止,这个 `ThreadLocal` 和它对应的 value 都会一直存在于内存中,造成 key 和 value **都无法回收**的内存泄漏。
+
+**key 使用弱引用(实际采用的方案):**
+
+当业务代码中的 `ThreadLocal` 引用被置为 `null` 后,由于 Entry 的 key 是弱引用,`ThreadLocal` 实例在下次 GC 时会被回收,key 变为 `null`。此时虽然 value 仍然存在(强引用),但 `ThreadLocalMap` 在执行 `get()`、`set()`、`remove()` 等操作时,会主动探测并清理这些 key 为 `null` 的 "stale entry"(过期条目),从而释放 value 对象。
+
+也就是说,**弱引用的设计是一种"兜底"防御机制**——即便开发者忘记调用 `remove()`,JVM 的 GC 配合 `ThreadLocalMap` 的自清理逻辑,仍然有机会回收泄漏的数据。而如果使用强引用,一旦忘记 `remove()`,就完全没有任何补救机会了。
+
+> 需要注意的是,这种自清理机制是**被动触发**的(只在 `get`/`set`/`remove` 操作时顺便清理),并不能保证所有过期条目都被及时清理。因此,**弱引用只是降低了内存泄漏的风险,并没有彻底消除它**,手动调用 `remove()` 仍然是必须的。
+
+#### 线程池场景下的特殊风险
+
+上面提到内存泄漏的条件之一是"线程持续存活"。在使用 `new Thread()` 创建线程的场景下,线程执行完毕后会被销毁,其持有的 `ThreadLocalMap` 也会随之被 GC 回收,泄漏的影响相对有限。
+
+但在**线程池**场景下,问题会被严重放大。线程池中的核心线程默认不会被销毁,它们会被反复复用来执行不同的任务。这意味着:
+
+1. **内存泄漏持续累积**:每个任务如果使用了 `ThreadLocal` 却没有清理,其 value 就会一直残留在该线程的 `ThreadLocalMap` 中。随着任务不断提交和执行,泄漏的数据会越积越多,最终可能导致 OOM。
+2. **数据污染(脏数据)**:上一个任务设置的 `ThreadLocal` 值,如果没有被清理,下一个被分配到同一线程的任务就能读取到这个残留值。这可能导致严重的业务逻辑错误,比如用户 A 的请求读取到了用户 B 的身份信息。
+
+**美团技术团队的真实事故案例:**
+
+美团技术团队在[《Java 线程池实现原理及其在美团业务中的实践》](https://tech.meituan.com/2020/04/02/java-pooling-pratice-in-meituan.html)一文中就记录了一次因 `ThreadLocal` 使用不当引发的线上事故:在一个依赖 `ThreadLocal` 传递用户上下文的 Web 应用中,由于使用了线程池处理请求,且没有在请求结束后清理 `ThreadLocal`,导致**后续请求复用了同一线程时,读取到了前一个请求遗留的用户信息**,造成了用户数据串号的严重问题。
+
+#### 阿里巴巴 Java 开发手册的强制规约
+
+正因为线程池 + `ThreadLocal` 的组合如此容易踩坑,《阿里巴巴 Java 开发手册》在"并发处理"章节中对此做出了**强制**级别的要求:
+
+> **【强制】** 必须回收自定义的 `ThreadLocal` 变量记录的当前线程的值,尤其在线程池场景下,线程经常会被复用,如果不清理自定义的 `ThreadLocal` 变量,可能会影响后续业务逻辑和造成内存泄露等问题。尽量在代理中使用 `try-finally` 块进行回收。
+
+正确的使用模式如下:
+
+```java
+// 定义为 static final,避免重复创建 ThreadLocal 实例
+private static final ThreadLocal userContextHolder = new ThreadLocal<>();
+
+public void processRequest(HttpServletRequest request) {
+ try {
+ // 在 try 块中设置值
+ UserContext context = buildUserContext(request);
+ userContextHolder.set(context);
+
+ // 执行业务逻辑
+ doBusinessLogic();
+ } finally {
+ // 在 finally 块中必须清理,确保无论是否发生异常都会执行
+ userContextHolder.remove();
+ }
+}
+```
+
+这里有三个关键要点:
+
+1. **`ThreadLocal` 声明为 `static final`**:确保整个应用只有一个 `ThreadLocal` 实例,避免因重复创建导致旧实例失去强引用后 key 被回收,加剧内存泄漏。
+2. **`try-finally` 保证 `remove()` 一定被执行**:即使业务逻辑抛出异常,`finally` 块也能确保 `ThreadLocal` 被清理。
+3. **在使用完毕后立即清理,而不是在下次使用前设置**:在使用前 `set()` 虽然可以覆盖旧值解决脏数据问题,但无法解决上一次任务遗留 value 的内存占用问题。只有在用完后 `remove()`,才能同时避免内存泄漏和数据污染。
+
+### ⭐️如何跨线程传递 ThreadLocal 的值?
+
+**为什么 ThreadLocal 在异步场景下会失效?**
+
+`ThreadLocal` 的值不在 `ThreadLocal` 对象中,而是存储在 `Thread` 里:
+
+```java
+Thread → ThreadLocalMap → Entry(ThreadLocal, value)
+```
+
+`ThreadLocal` 数据结构如下图所示:
+
+
+
+异步执行往往意味着任务会从当前线程切换到另一个线程(例如线程池中的工作线程)执行。由于不同线程各自维护独立的 `ThreadLocalMap`,默认情况下 `ThreadLocal` 的上下文无法在异步执行中自动传递。
+
+**如何跨线程传递 ThreadLocal 的值?**
+
+为了解决这个问题,业界有两套主流的解决方案,一套是 JDK 原生的,另一套是阿里巴巴开源的。
+
+1. `InheritableThreadLocal` :JDK1.2 提供的一个类,继承自 `ThreadLocal` 。使用 `InheritableThreadLocal` 时,会在创建子线程时,令子线程继承父线程中的 `ThreadLocal` 值,但是无法支持线程池场景下的 `ThreadLocal` 值传递。
+2. `TransmittableThreadLocal` : `TransmittableThreadLocal` (简称 TTL) 是阿里巴巴开源的工具类,继承并加强了`InheritableThreadLocal`类,可以在线程池的场景下支持 `ThreadLocal` 值传递。项目地址:。
+
+#### InheritableThreadLocal 原理
+
+`InheritableThreadLocal` 实现了创建异步线程时,继承父线程 `ThreadLocal` 值的功能。该类是 JDK 团队提供的,通过改造 JDK 源码包中的 `Thread` 类来实现创建线程时,`ThreadLocal` 值的传递。
+
+**`InheritableThreadLocal` 的值存储在哪里?**
+
+在 `Thread` 类中添加了一个新的 `ThreadLocalMap` ,命名为 `inheritableThreadLocals` ,该变量用于存储需要跨线程传递的 `ThreadLocal` 值。如下:
+
+```JAVA
+class Thread implements Runnable {
+ ThreadLocal.ThreadLocalMap threadLocals = null;
+ ThreadLocal.ThreadLocalMap inheritableThreadLocals = null;
+}
+```
+
+**如何完成 `ThreadLocal` 值的传递?**
+
+通过改造 `Thread` 类的构造方法来实现,在创建 `Thread` 线程时,拿到父线程的 `inheritableThreadLocals` 变量赋值给子线程即可。相关代码如下:
+
+```JAVA
+// Thread 的构造方法会调用 init() 方法
+private void init(/* ... */) {
+ // 1、获取父线程
+ Thread parent = currentThread();
+ // 2、将父线程的 inheritableThreadLocals 赋值给子线程
+ if (inheritThreadLocals && parent.inheritableThreadLocals != null)
+ this.inheritableThreadLocals =
+ ThreadLocal.createInheritedMap(parent.inheritableThreadLocals);
+}
+```
+
+**`InheritableThreadLocal` 的方案有什么问题?**
+
+这个方案的缺陷在于它的**一次性**,也就是它只在线程创建时发生一次复制。然而,现在的开发中,我们会大量使用线程池,但线程池里的线程是被复用的。
+
+想象一下,任务A在线程1中执行,把它的 `ThreadLocal` 值传给了线程池里的子线程2。任务A结束后,线程1去休息了。接着,任务B来了,它在线程3中执行,线程池又复用了刚才那个子线程2来执行任务B的一部分。此时,子线程2的`ThreadLocal`里还残留着任务A传给它的脏数据,而任务B(在线程3里)的上下文却完全没有传递过来。这就导致了数据污染和上下文丢失。
+
+#### TransmittableThreadLocal 原理
+
+JDK 默认没有支持线程池场景下 `ThreadLocal` 值传递的功能,因此阿里巴巴开源了一套工具 `TransmittableThreadLocal` 来实现该功能。
+
+由于阿里巴巴无法改动 JDK 源码,TTL 巧妙地利用了**装饰器模式**对任务(`Runnable`/`Callable`)或线程池(`Executor`)进行增强,将上下文的传递时机从“线程创建时”延迟到了“任务提交与执行时”。
+
+TTL 的核心逻辑可以概括为三个阶段(CRR):
+
+- **Capture(捕获)**:在提交任务(如调用 `execute`)的一瞬间,`TtlRunnable` 会调用 `TransmittableThreadLocal.Transmitter.capture()`。它通过内部维护的 `holder` 集合,抓取当前父线程中所有活跃的 TTL 变量并存入快照。
+- **Replay(回放)**:在线程池的工作线程执行 `run()` 方法前,调用 `replay()`。它将快照中的值 `set` 到当前工作线程中,并备份该线程原有的旧值。
+- **Restore(恢复)**:任务执行结束后,调用 `restore()`。它根据备份将工作线程恢复到执行前的状态,防止上下文污染或内存泄漏。
+
+这张图是 TTL 官方提供的 CRR 整个过程的时序图:
+
+
+
+不太好理解吧?可以看下我绘制的这张 CRR 时序图,更清晰直观一些:
+
+```mermaid
+sequenceDiagram
+ participant P as 父线程(Submitter)
+ participant W as TTL 包装器(TtlRunnable / Agent)
+ participant C as 线程池工作线程(Worker)
+
+ Note over P: 1. set context = "A"
+ P->>W: 2. 提交任务(Capture)
+ Note right of W: 捕获父线程中所有活跃的 TTL 变量快照
+
+ W->>C: 3. 执行任务 run()
+ Note over C: 4. Replay
+ Note right of C: 备份工作线程原有 TTL 值
并设置 Capture 得到的值
+
+ Note over C: 5. 业务逻辑执行
get context = "A"
+
+ Note over C: 6. Restore
+ Note right of C: 恢复工作线程原有 TTL 值
防止上下文污染
+
+ C-->>P: 7. 任务执行结束
+
+```
+
+也就是说,TTL 的本质是在任务提交时 Capture 上下文,在任务执行前 Replay 上下文,在任务结束后 Restore 线程状态,从而安全地支持线程池中的 `ThreadLocal` 传递。
+
+TTL 提供了两种主要的接入方式,可根据侵入性要求和改造成本进行选择。
+
+**1. 显式包装(手动接入)**
+
+使用 `TtlRunnable.get(Runnable)` 或 `TtlCallable.get(Callable)` 对任务进行包装,使用 `TtlExecutors.getTtlExecutor(Executor)`、`getTtlExecutorService(...)` 对线程池进行包装。这种接入方式清晰可控,但需要业务代码配合,存在一定侵入性。
+
+下面这段代码展示了 TTL 通过 CRR,在支持线程池复用和拒绝策略的前提下,安全地传递并隔离 `ThreadLocal` 上下文。
+
+```java
+public class TtlContextHolder {
+ private static final Logger log = LoggerFactory.getLogger(TtlContextHolder.class);
+
+ // 1. 使用 static final 确保 TTL 实例不被重复创建,防止内存泄漏
+ // 重写 copy 方法(可选):如果是引用类型,建议实现深拷贝
+ private static final TransmittableThreadLocal CONTEXT = new TransmittableThreadLocal() {
+ @Override
+ public String copy(String parentValue) {
+ // 默认是直接返回引用,如果是可变对象(如 Map),请在这里 new 新对象
+ return parentValue;
+ }
+ };
+
+ // 2. 线程池初始化:确保只被 TtlExecutors 包装一次
+ private static final ExecutorService TTL_EXECUTOR_SERVICE;
+
+ static {
+ ExecutorService rawExecutor = new ThreadPoolExecutor(
+ 2, 4, 60L, TimeUnit.SECONDS,
+ new LinkedBlockingQueue<>(1000), (Runnable r) -> new Thread(r, "ttl-worker-" + r.hashCode()),
+ new ThreadPoolExecutor.CallerRunsPolicy() // 关键:TTL 完美支持此拒绝策略
+ );
+ // 包装原始线程池
+ TTL_EXECUTOR_SERVICE = TtlExecutors.getTtlExecutorService(rawExecutor);
+ }
+
+ public static void main(String[] args) throws Exception {
+ try {
+ // 3. 在父线程中设置上下文
+ CONTEXT.set("value-set-in-parent");
+ log.info("父线程上下文: {}", CONTEXT.get());
+
+ // 4. 使用 Lambda 简化任务提交
+ TTL_EXECUTOR_SERVICE.submit(() -> {
+ log.info("异步任务(Runnable)读取上下文: {}", CONTEXT.get());
+ // 模拟业务逻辑
+ // 注意:子线程修改是否影响父线程,取决于 copy() 是否做了深拷贝
+ CONTEXT.set("value-modified-in-child");
+ });
+
+ Future future = TTL_EXECUTOR_SERVICE.submit(() -> {
+ log.info("异步任务(Callable)读取上下文: {}", CONTEXT.get());
+ return "Success";
+ });
+
+ future.get();
+
+ // 5. 验证父线程上下文是否被污染
+ log.info("父线程最终上下文: {}", CONTEXT.get());
+
+ } finally {
+ // 6. 清理当前线程(父线程)的上下文,子线程的上下文由 TTL 的 Restore 机制自动恢复
+ CONTEXT.remove();
+ }
+ }
+}
+```
+
+输出:
+
+```ba
+09:06:31.438 INFO [main] TtlContextHolder - 父线程上下文: value-set-in-parent
+09:06:31.452 INFO [ttl-worker-1663166483] TtlContextHolder - 异步任务(Runnable)读取上下文: value-set-in-parent
+09:06:31.453 INFO [ttl-worker-841283083] TtlContextHolder - 异步任务(Callable)读取上下文: value-set-in-parent
+09:06:31.453 INFO [main] TtlContextHolder - 父线程最终上下文: value-set-in-parent
+```
+
+如果你想要测试这段代码,记得引入 TTL 的 Maven 依赖;
+
+```XML
+
+ com.alibaba
+ transmittable-thread-local
+ 2.14.4
+
+```
+
+**2. 无侵入接入(Java Agent)**
+
+通过 Java Agent 在类加载阶段对线程池相关类进行 字节码增强,自动织入 TTL 的上下文传递逻辑,实现业务代码零改造的上下文透传。这种方式业务代码无需感知 TTL 的存在,但实现复杂度相对较高。
+
+TTL Agent 默认修饰了以下 JDK 执行器组件:
+
+1. **标准线程池**:`java.util.concurrent.ThreadPoolExecutor` 和 `java.util.concurrent.ScheduledThreadPoolExecutor`。
+2. **ForkJoin 体系**:`java.util.concurrent.ForkJoinTask`(从而透明支持了 `CompletableFuture` 和 Java 8 并行流 `Stream`)。
+3. **遗留组件**:`java.util.TimerTask`(自 v2.7.0 起支持,v2.11.2 起默认开启)。
+
+在 Java 启动参数中加入 `-javaagent` 配置:
+
+```bash
+# 基础配置
+java -javaagent:path/to/transmittable-thread-local-2.x.y.jar \
+ -cp classes \
+ com.your.app.Main
+```
+
+#### 应用场景
+
+1. **压测流量标记**: 在压测场景中,使用 `ThreadLocal` 存储压测标记,用于区分压测流量和真实流量。如果标记丢失,可能导致压测流量被错误地当成线上流量处理。
+2. **上下文传递**:在分布式系统中,传递链路追踪信息(如 Trace ID)或用户上下文信息。
+
+#### 总结
+
+`ThreadLocal` 的值默认是无法跨线程传递的,因为它的值是存在**每个 `Thread` 对象自己**的 `ThreadLocalMap` 里的,父子线程是两个不同的对象。
+
+为了解决这个问题,主要有两种方案:
+
+1. **JDK的 InheritableThreadLocal**:它会在**创建子线程**的时候,把父线程的值**复制**一份给子线程。但它的问题是,在**线程池**场景下会失效。因为线程池会**复用**线程,这会导致线程拿到的可能是上一个任务传下来的**脏数据**。
+2. **阿里的 TransmittableThreadLocal (TTL)**:这是我们项目里用的方案,它专门解决线程池的问题。它的原理是,在**提交任务**到线程池时,它会把父线程的 `ThreadLocal` 值**捕获**下来,和任务**绑定**在一起。等线程池里的某个线程要执行这个任务时,它再把捕获的值**设置**到这个线程上,任务执行完再**清理**掉。
+
+简单说,**InheritableThreadLocal是跟线程绑定的,只在创建时有效;而TTL是跟任务绑定的,完美支持线程池。**
## 线程池
@@ -191,38 +464,40 @@ static class Entry extends WeakReference> {
顾名思义,线程池就是管理一系列线程的资源池。当有任务要处理时,直接从线程池中获取线程来处理,处理完之后线程并不会立即被销毁,而是等待下一个任务。
-### 为什么要用线程池?
+### ⭐️为什么要用线程池?
池化技术想必大家已经屡见不鲜了,线程池、数据库连接池、HTTP 连接池等等都是对这个思想的应用。池化技术的思想主要是为了减少每次获取资源的消耗,提高对资源的利用率。
-**线程池**提供了一种限制和管理资源(包括执行一个任务)的方式。 每个**线程池**还维护一些基本统计信息,例如已完成任务的数量。
-
-这里借用《Java 并发编程的艺术》提到的来说一下**使用线程池的好处**:
+线程池提供了一种限制和管理资源(包括执行一个任务)的方式。 每个线程池还维护一些基本统计信息,例如已完成任务的数量。使用线程池主要带来以下几个好处:
-- **降低资源消耗**。通过重复利用已创建的线程降低线程创建和销毁造成的消耗。
-- **提高响应速度**。当任务到达时,任务可以不需要等到线程创建就能立即执行。
-- **提高线程的可管理性**。线程是稀缺资源,如果无限制的创建,不仅会消耗系统资源,还会降低系统的稳定性,使用线程池可以进行统一的分配,调优和监控。
+1. **降低资源消耗**:线程池里的线程是可以重复利用的。一旦线程完成了某个任务,它不会立即销毁,而是回到池子里等待下一个任务。这就避免了频繁创建和销毁线程带来的开销。
+2. **提高响应速度**:因为线程池里通常会维护一定数量的核心线程(或者说“常驻工人”),任务来了之后,可以直接交给这些已经存在的、空闲的线程去执行,省去了创建线程的时间,任务能够更快地得到处理。
+3. **提高线程的可管理性**:线程池允许我们统一管理池中的线程。我们可以配置线程池的大小(核心线程数、最大线程数)、任务队列的类型和大小、拒绝策略等。这样就能控制并发线程的总量,防止资源耗尽,保证系统的稳定性。同时,线程池通常也提供了监控接口,方便我们了解线程池的运行状态(比如有多少活跃线程、多少任务在排队等),便于调优。
### 如何创建线程池?
-**方式一:通过`ThreadPoolExecutor`构造函数来创建(推荐)。**
+在 Java 中,创建线程池主要有两种方式:
+
+**方式一:通过 `ThreadPoolExecutor` 构造函数直接创建 (推荐)**
+
+
-
+这是最推荐的方式,因为它允许开发者明确指定线程池的核心参数,对线程池的运行行为有更精细的控制,从而避免资源耗尽的风险。
-**方式二:通过 `Executor` 框架的工具类 `Executors` 来创建。**
+**方式二:通过 `Executors` 工具类创建 (不推荐用于生产环境)**
-我们可以创建多种类型的 `ThreadPoolExecutor`:
+`Executors`工具类提供的创建线程池的方法如下图所示:
-- **`FixedThreadPool`**:该方法返回一个固定线程数量的线程池。该线程池中的线程数量始终不变。当有一个新的任务提交时,线程池中若有空闲线程,则立即执行。若没有,则新的任务会被暂存在一个任务队列中,待有线程空闲时,便处理在任务队列中的任务。
-- **`SingleThreadExecutor`:** 该方法返回一个只有一个线程的线程池。若多余一个任务被提交到该线程池,任务会被保存在一个任务队列中,待线程空闲,按先入先出的顺序执行队列中的任务。
-- **`CachedThreadPool`:** 该方法返回一个可根据实际情况调整线程数量的线程池。初始大小为 0。当有新任务提交时,如果当前线程池中没有线程可用,它会创建一个新的线程来处理该任务。如果在一段时间内(默认为 60 秒)没有新任务提交,核心线程会超时并被销毁,从而缩小线程池的大小。
-- **`ScheduledThreadPool`**:该方法返回一个用来在给定的延迟后运行任务或者定期执行任务的线程池。
+
-对应 `Executors` 工具类中的方法如图所示:
+可以看出,通过`Executors`工具类可以创建多种类型的线程池,包括:
-
+- `FixedThreadPool`:固定线程数量的线程池。该线程池中的线程数量始终不变。当有一个新的任务提交时,线程池中若有空闲线程,则立即执行。若没有,则新的任务会被暂存在一个任务队列中,待有线程空闲时,便处理在任务队列中的任务。
+- `SingleThreadExecutor`: 只有一个线程的线程池。若多余一个任务被提交到该线程池,任务会被保存在一个任务队列中,待线程空闲,按先入先出的顺序执行队列中的任务。
+- `CachedThreadPool`: 可根据实际情况调整线程数量的线程池。线程池的线程数量不确定,但若有空闲线程可以复用,则会优先使用可复用的线程。若所有线程均在工作,又有新的任务提交,则会创建新的线程处理任务。所有线程在当前任务执行完毕后,将返回线程池进行复用。
+- `ScheduledThreadPool`:给定的延迟后运行任务或者定期执行任务的线程池。
-### 为什么不推荐使用内置线程池?
+### ⭐️为什么不推荐使用内置线程池?
在《阿里巴巴 Java 开发手册》“并发处理”这一章节,明确指出线程资源必须通过线程池提供,不允许在应用中自行显式创建线程。
@@ -234,21 +509,19 @@ static class Entry extends WeakReference> {
`Executors` 返回线程池对象的弊端如下(后文会详细介绍到):
-- **`FixedThreadPool` 和 `SingleThreadExecutor`**:使用的是无界的 `LinkedBlockingQueue`,任务队列最大长度为 `Integer.MAX_VALUE`,可能堆积大量的请求,从而导致 OOM。
-- **`CachedThreadPool`**:使用的是同步队列 `SynchronousQueue`, 允许创建的线程数量为 `Integer.MAX_VALUE` ,如果任务数量过多且执行速度较慢,可能会创建大量的线程,从而导致 OOM。
-- **`ScheduledThreadPool` 和 `SingleThreadScheduledExecutor`** : 使用的无界的延迟阻塞队列`DelayedWorkQueue`,任务队列最大长度为 `Integer.MAX_VALUE`,可能堆积大量的请求,从而导致 OOM。
+- `FixedThreadPool` 和 `SingleThreadExecutor`:使用的是阻塞队列 `LinkedBlockingQueue`,任务队列最大长度为 `Integer.MAX_VALUE`,可以看作是无界的,可能堆积大量的请求,从而导致 OOM。
+- `CachedThreadPool`:使用的是同步队列 `SynchronousQueue`, 允许创建的线程数量为 `Integer.MAX_VALUE` ,如果任务数量过多且执行速度较慢,可能会创建大量的线程,从而导致 OOM。
+- `ScheduledThreadPool` 和 `SingleThreadScheduledExecutor`:使用的无界的延迟阻塞队列`DelayedWorkQueue`,任务队列最大长度为 `Integer.MAX_VALUE`,可能堆积大量的请求,从而导致 OOM。
```java
-// 无界队列 LinkedBlockingQueue
public static ExecutorService newFixedThreadPool(int nThreads) {
-
+ // LinkedBlockingQueue 的默认长度为 Integer.MAX_VALUE,可以看作是无界的
return new ThreadPoolExecutor(nThreads, nThreads,0L, TimeUnit.MILLISECONDS,new LinkedBlockingQueue());
}
-// 无界队列 LinkedBlockingQueue
public static ExecutorService newSingleThreadExecutor() {
-
+ // LinkedBlockingQueue 的默认长度为 Integer.MAX_VALUE,可以看作是无界的
return new FinalizableDelegatedExecutorService (new ThreadPoolExecutor(1, 1,0L, TimeUnit.MILLISECONDS,new LinkedBlockingQueue()));
}
@@ -270,7 +543,7 @@ public ScheduledThreadPoolExecutor(int corePoolSize) {
}
```
-### 线程池常见参数有哪些?如何解释?
+### ⭐️线程池常见参数有哪些?如何解释?
```java
/**
@@ -300,33 +573,111 @@ public ScheduledThreadPoolExecutor(int corePoolSize) {
}
```
-**`ThreadPoolExecutor` 3 个最重要的参数:**
+`ThreadPoolExecutor` 3 个最重要的参数:
-- **`corePoolSize` :** 任务队列未达到队列容量时,最大可以同时运行的线程数量。
-- **`maximumPoolSize` :** 任务队列中存放的任务达到队列容量的时候,当前可以同时运行的线程数量变为最大线程数。
-- **`workQueue`:** 新任务来的时候会先判断当前运行的线程数量是否达到核心线程数,如果达到的话,新任务就会被存放在队列中。
+- `corePoolSize` : 任务队列未达到队列容量时,最大可以同时运行的线程数量。
+- `maximumPoolSize` : 任务队列中存放的任务达到队列容量的时候,当前可以同时运行的线程数量变为最大线程数。
+- `workQueue`: 新任务来的时候会先判断当前运行的线程数量是否达到核心线程数,如果达到的话,新任务就会被存放在队列中。
`ThreadPoolExecutor`其他常见参数 :
-- **`keepAliveTime`**:线程池中的线程数量大于 `corePoolSize` 的时候,如果这时没有新的任务提交,多余的空闲线程不会立即销毁,而是会等待,直到等待的时间超过了 `keepAliveTime`才会被回收销毁,线程池回收线程时,会对核心线程和非核心线程一视同仁,直到线程池中线程的数量等于 `corePoolSize` ,回收过程才会停止。
-- **`unit`** : `keepAliveTime` 参数的时间单位。
-- **`threadFactory`** :executor 创建新线程的时候会用到。
-- **`handler`** :饱和策略。关于饱和策略下面单独介绍一下。
+- `keepAliveTime`:当线程池中的线程数量大于 `corePoolSize` ,即有非核心线程(线程池中核心线程以外的线程)时,这些非核心线程空闲后不会立即销毁,而是会等待,直到等待的时间超过了 `keepAliveTime`才会被回收销毁。
+- `unit` : `keepAliveTime` 参数的时间单位。
+- `threadFactory` :executor 创建新线程的时候会用到。
+- `handler` :拒绝策略(后面会单独详细介绍一下)。
下面这张图可以加深你对线程池中各个参数的相互关系的理解(图片来源:《Java 性能调优实战》):
-
+
+
+### 线程池的核心线程会被回收吗?
+
+`ThreadPoolExecutor` 默认不会回收核心线程,即使它们已经空闲了。这是为了减少创建线程的开销,因为核心线程通常是要长期保持活跃的。但是,如果线程池是被用于周期性使用的场景,且频率不高(周期之间有明显的空闲时间),可以考虑将 `allowCoreThreadTimeOut(boolean value)` 方法的参数设置为 `true`,这样就会回收空闲(时间间隔由 `keepAliveTime` 指定)的核心线程了。
+
+```java
+public void allowCoreThreadTimeOut(boolean value) {
+ // 核心线程的 keepAliveTime 必须大于 0 才能启用超时机制
+ if (value && keepAliveTime <= 0) {
+ throw new IllegalArgumentException("Core threads must have nonzero keep alive times");
+ }
+ // 设置 allowCoreThreadTimeOut 的值
+ if (value != allowCoreThreadTimeOut) {
+ allowCoreThreadTimeOut = value;
+ // 如果启用了超时机制,清理所有空闲的线程,包括核心线程
+ if (value) {
+ interruptIdleWorkers();
+ }
+ }
+}
+```
+
+### 核心线程空闲时处于什么状态?
+
+核心线程空闲时,其状态分为以下两种情况:
+
+- **设置了核心线程的存活时间** :核心线程在空闲时,会处于 `WAITING` 状态,等待获取任务。如果阻塞等待的时间超过了核心线程存活时间,则该线程会退出工作,将该线程从线程池的工作线程集合中移除,线程状态变为 `TERMINATED` 状态。
+- **没有设置核心线程的存活时间** :核心线程在空闲时,会一直处于 `WAITING` 状态,等待获取任务,核心线程会一直存活在线程池中。
+
+当队列中有可用任务时,会唤醒被阻塞的线程,线程的状态会由 `WAITING` 状态变为 `RUNNABLE` 状态,之后去执行对应任务。
+
+接下来通过相关源码,了解一下线程池内部是如何做的。
+
+线程在线程池内部被抽象为了 `Worker` ,当 `Worker` 被启动之后,会不断去任务队列中获取任务。
+
+在获取任务的时候,会根据 `timed` 值来决定从任务队列( `BlockingQueue` )获取任务的行为。
-### 线程池的饱和策略有哪些?
+如果「设置了核心线程的存活时间」或者「线程数量超过了核心线程数量」,则将 `timed` 标记为 `true` ,表明获取任务时需要使用 `poll()` 指定超时时间。
+
+- `timed == true` :使用 `poll(timeout, unit)` 来获取任务。使用 `poll(timeout, unit)` 方法获取任务超时的话,则当前线程会退出执行( `TERMINATED` ),该线程从线程池中被移除。
+- `timed == false` :使用 `take()` 来获取任务。使用 `take()` 方法获取任务会让当前线程一直阻塞等待(`WAITING`)。
+
+源码如下:
+
+```JAVA
+// ThreadPoolExecutor
+private Runnable getTask() {
+ boolean timedOut = false;
+ for (;;) {
+ // ...
+
+ // 1、如果「设置了核心线程的存活时间」或者是「线程数量超过了核心线程数量」,则 timed 为 true。
+ boolean timed = allowCoreThreadTimeOut || wc > corePoolSize;
+ // 2、扣减线程数量。
+ // wc > maximuimPoolSize:线程池中的线程数量超过最大线程数量。其中 wc 为线程池中的线程数量。
+ // timed && timeOut:timeOut 表示获取任务超时。
+ // 分为两种情况:核心线程设置了存活时间 && 获取任务超时,则扣减线程数量;线程数量超过了核心线程数量 && 获取任务超时,则扣减线程数量。
+ if ((wc > maximumPoolSize || (timed && timedOut))
+ && (wc > 1 || workQueue.isEmpty())) {
+ if (compareAndDecrementWorkerCount(c))
+ return null;
+ continue;
+ }
+ try {
+ // 3、如果 timed 为 true,则使用 poll() 获取任务;否则,使用 take() 获取任务。
+ Runnable r = timed ?
+ workQueue.poll(keepAliveTime, TimeUnit.NANOSECONDS) :
+ workQueue.take();
+ // 4、获取任务之后返回。
+ if (r != null)
+ return r;
+ timedOut = true;
+ } catch (InterruptedException retry) {
+ timedOut = false;
+ }
+ }
+}
+```
+
+### ⭐️线程池的拒绝策略有哪些?
如果当前同时运行的线程数量达到最大线程数量并且队列也已经被放满了任务时,`ThreadPoolExecutor` 定义一些策略:
-- **`ThreadPoolExecutor.AbortPolicy`:** 抛出 `RejectedExecutionException`来拒绝新任务的处理。
-- **`ThreadPoolExecutor.CallerRunsPolicy`:** 调用执行自己的线程运行任务,也就是直接在调用`execute`方法的线程中运行(`run`)被拒绝的任务,如果执行程序已关闭,则会丢弃该任务。因此这种策略会降低对于新任务提交速度,影响程序的整体性能。如果您的应用程序可以承受此延迟并且你要求任何一个任务请求都要被执行的话,你可以选择这个策略。
-- **`ThreadPoolExecutor.DiscardPolicy`:** 不处理新任务,直接丢弃掉。
-- **`ThreadPoolExecutor.DiscardOldestPolicy`:** 此策略将丢弃最早的未处理的任务请求。
+- `ThreadPoolExecutor.AbortPolicy`:抛出 `RejectedExecutionException`来拒绝新任务的处理。
+- `ThreadPoolExecutor.CallerRunsPolicy`:调用执行者自己的线程运行任务,也就是直接在调用`execute`方法的线程中运行(`run`)被拒绝的任务,如果执行程序已关闭,则会丢弃该任务。因此这种策略会降低对于新任务提交速度,影响程序的整体性能。如果你的应用程序可以承受此延迟并且你要求任何一个任务请求都要被执行的话,你可以选择这个策略。
+- `ThreadPoolExecutor.DiscardPolicy`:不处理新任务,直接丢弃掉。
+- `ThreadPoolExecutor.DiscardOldestPolicy`:此策略将丢弃最早的未处理的任务请求。
-举个例子:Spring 通过 `ThreadPoolTaskExecutor` 或者我们直接通过 `ThreadPoolExecutor` 的构造函数创建线程池的时候,当我们不指定 `RejectedExecutionHandler` 饱和策略来配置线程池的时候,默认使用的是 `AbortPolicy`。在这种饱和策略下,如果队列满了,`ThreadPoolExecutor` 将抛出 `RejectedExecutionException` 异常来拒绝新来的任务 ,这代表你将丢失对这个任务的处理。如果不想丢弃任务的话,可以使用`CallerRunsPolicy`。`CallerRunsPolicy` 和其他的几个策略不同,它既不会抛弃任务,也不会抛出异常,而是将任务回退给调用者,使用调用者的线程来执行任务
+举个例子:Spring 通过 `ThreadPoolTaskExecutor` 或者我们直接通过 `ThreadPoolExecutor` 的构造函数创建线程池的时候,当我们不指定 `RejectedExecutionHandler` 拒绝策略来配置线程池的时候,默认使用的是 `AbortPolicy`。在这种拒绝策略下,如果队列满了,`ThreadPoolExecutor` 将抛出 `RejectedExecutionException` 异常来拒绝新来的任务 ,这代表你将丢失对这个任务的处理。如果不想丢弃任务的话,可以使用`CallerRunsPolicy`。`CallerRunsPolicy` 和其他的几个策略不同,它既不会抛弃任务,也不会抛出异常,而是将任务回退给调用者,使用调用者的线程来执行任务。
```java
public static class CallerRunsPolicy implements RejectedExecutionHandler {
@@ -342,26 +693,203 @@ public static class CallerRunsPolicy implements RejectedExecutionHandler {
}
```
+### 如果不允许丢弃任务,应该选择哪个拒绝策略?
+
+根据上面对线程池拒绝策略的介绍,相信大家很容易能够得出答案是:`CallerRunsPolicy` 。
+
+这里我们再来结合`CallerRunsPolicy` 的源码来看看:
+
+```java
+public static class CallerRunsPolicy implements RejectedExecutionHandler {
+
+ public CallerRunsPolicy() { }
+
+
+ public void rejectedExecution(Runnable r, ThreadPoolExecutor e) {
+ //只要当前程序没有关闭,就用执行execute方法的线程执行该任务
+ if (!e.isShutdown()) {
+
+ r.run();
+ }
+ }
+ }
+```
+
+从源码可以看出,只要当前程序不关闭就会使用执行`execute`方法的线程执行该任务。
+
+### CallerRunsPolicy 拒绝策略有什么风险?如何解决?
+
+我们上面也提到了:如果想要保证任何一个任务请求都要被执行的话,那选择 `CallerRunsPolicy` 拒绝策略更合适一些。
+
+不过,如果走到`CallerRunsPolicy`的任务是个非常耗时的任务,且处理提交任务的线程是主线程,可能会导致主线程阻塞,影响程序的正常运行。
+
+这里简单举一个例子,该线程池限定了最大线程数为 2,阻塞队列大小为 1(这意味着第 4 个任务就会走到拒绝策略),`ThreadUtil`为 Hutool 提供的工具类:
+
+```java
+public class ThreadPoolTest {
+
+ private static final Logger log = LoggerFactory.getLogger(ThreadPoolTest.class);
+
+ public static void main(String[] args) {
+ // 创建一个线程池,核心线程数为1,最大线程数为2
+ // 当线程数大于核心线程数时,多余的空闲线程存活的最长时间为60秒,
+ // 任务队列为容量为1的ArrayBlockingQueue,饱和策略为CallerRunsPolicy。
+ ThreadPoolExecutor threadPoolExecutor = new ThreadPoolExecutor(1,
+ 2,
+ 60,
+ TimeUnit.SECONDS,
+ new ArrayBlockingQueue<>(1),
+ new ThreadPoolExecutor.CallerRunsPolicy());
+
+ // 提交第一个任务,由核心线程执行
+ threadPoolExecutor.execute(() -> {
+ log.info("核心线程执行第一个任务");
+ ThreadUtil.sleep(1, TimeUnit.MINUTES);
+ });
+
+ // 提交第二个任务,由于核心线程被占用,任务将进入队列等待
+ threadPoolExecutor.execute(() -> {
+ log.info("非核心线程处理入队的第二个任务");
+ ThreadUtil.sleep(1, TimeUnit.MINUTES);
+ });
+
+ // 提交第三个任务,由于核心线程被占用且队列已满,创建非核心线程处理
+ threadPoolExecutor.execute(() -> {
+ log.info("非核心线程处理第三个任务");
+ ThreadUtil.sleep(1, TimeUnit.MINUTES);
+ });
+
+ // 提交第四个任务,由于核心线程和非核心线程都被占用,队列也满了,根据CallerRunsPolicy策略,任务将由提交任务的线程(即主线程)来执行
+ threadPoolExecutor.execute(() -> {
+ log.info("主线程处理第四个任务");
+ ThreadUtil.sleep(2, TimeUnit.MINUTES);
+ });
+
+ // 提交第五个任务,主线程被第四个任务卡住,该任务必须等到主线程执行完才能提交
+ threadPoolExecutor.execute(() -> {
+ log.info("核心线程执行第五个任务");
+ });
+
+ // 关闭线程池
+ threadPoolExecutor.shutdown();
+ }
+}
+
+```
+
+输出:
+
+```bash
+18:19:48.203 INFO [pool-1-thread-1] c.j.concurrent.ThreadPoolTest - 核心线程执行第一个任务
+18:19:48.203 INFO [pool-1-thread-2] c.j.concurrent.ThreadPoolTest - 非核心线程处理第三个任务
+18:19:48.203 INFO [main] c.j.concurrent.ThreadPoolTest - 主线程处理第四个任务
+18:20:48.212 INFO [pool-1-thread-2] c.j.concurrent.ThreadPoolTest - 非核心线程处理入队的第二个任务
+18:21:48.219 INFO [pool-1-thread-2] c.j.concurrent.ThreadPoolTest - 核心线程执行第五个任务
+```
+
+从输出结果可以看出,因为`CallerRunsPolicy`这个拒绝策略,导致耗时的任务用了主线程执行,导致线程池阻塞,进而导致后续任务无法及时执行,严重的情况下很可能导致 OOM。
+
+我们从问题的本质入手,调用者采用`CallerRunsPolicy`是希望所有的任务都能够被执行,暂时无法处理的任务又被保存在阻塞队列`BlockingQueue`中。这样的话,在内存允许的情况下,我们可以增加阻塞队列`BlockingQueue`的大小并调整堆内存以容纳更多的任务,确保任务能够被准确执行。
+
+为了充分利用 CPU,我们还可以调整线程池的`maximumPoolSize` (最大线程数)参数,这样可以提高任务处理速度,避免累计在 `BlockingQueue`的任务过多导致内存用完。
+
+
+
+如果服务器资源以达到可利用的极限,这就意味我们要在设计策略上改变线程池的调度了,我们都知道,导致主线程卡死的本质就是因为我们不希望任何一个任务被丢弃。换个思路,有没有办法既能保证任务不被丢弃且在服务器有余力时及时处理呢?
+
+这里提供的一种**任务持久化**的思路,这里所谓的任务持久化,包括但不限于:
+
+1. 设计一张任务表将任务存储到 MySQL 数据库中。
+2. Redis 缓存任务。
+3. 将任务提交到消息队列中。
+
+这里以方案一为例,简单介绍一下实现逻辑:
+
+1. 实现`RejectedExecutionHandler`接口自定义拒绝策略,自定义拒绝策略负责将线程池暂时无法处理(此时阻塞队列已满)的任务入库(保存到 MySQL 中)。注意:线程池暂时无法处理的任务会先被放在阻塞队列中,阻塞队列满了才会触发拒绝策略。
+2. 继承`BlockingQueue`实现一个混合式阻塞队列,该队列包含 JDK 自带的`ArrayBlockingQueue`。另外,该混合式阻塞队列需要修改取任务处理的逻辑,也就是重写`take()`方法,取任务时优先从数据库中读取最早的任务,数据库中无任务时再从 `ArrayBlockingQueue`中去取任务。
+
+
+
+整个实现逻辑还是比较简单的,核心在于自定义拒绝策略和阻塞队列。如此一来,一旦我们的线程池中线程达到满载时,我们就可以通过拒绝策略将最新任务持久化到 MySQL 数据库中,等到线程池有了有余力处理所有任务时,让其优先处理数据库中的任务以避免"饥饿"问题。
+
+当然,对于这个问题,我们也可以参考其他主流框架的做法,以 Netty 为例,它的拒绝策略则是直接创建一个线程池以外的线程处理这些任务,为了保证任务的实时处理,这种做法可能需要良好的硬件设备且临时创建的线程无法做到准确的监控:
+
+```java
+private static final class NewThreadRunsPolicy implements RejectedExecutionHandler {
+ NewThreadRunsPolicy() {
+ super();
+ }
+ public void rejectedExecution(Runnable r, ThreadPoolExecutor executor) {
+ try {
+ //创建一个临时线程处理任务
+ final Thread t = new Thread(r, "Temporary task executor");
+ t.start();
+ } catch (Throwable e) {
+ throw new RejectedExecutionException(
+ "Failed to start a new thread", e);
+ }
+ }
+}
+```
+
+ActiveMQ 则是尝试在指定的时效内尽可能的争取将任务入队,以保证最大交付:
+
+```java
+new RejectedExecutionHandler() {
+ @Override
+ public void rejectedExecution(final Runnable r, final ThreadPoolExecutor executor) {
+ try {
+ //限时阻塞等待,实现尽可能交付
+ executor.getQueue().offer(r, 60, TimeUnit.SECONDS);
+ } catch (InterruptedException e) {
+ throw new RejectedExecutionException("Interrupted waiting for BrokerService.worker");
+ }
+ throw new RejectedExecutionException("Timed Out while attempting to enqueue Task.");
+ }
+ });
+```
+
### 线程池常用的阻塞队列有哪些?
新任务来的时候会先判断当前运行的线程数量是否达到核心线程数,如果达到的话,新任务就会被存放在队列中。
不同的线程池会选用不同的阻塞队列,我们可以结合内置线程池来分析。
-- 容量为 `Integer.MAX_VALUE` 的 `LinkedBlockingQueue`(无界队列):`FixedThreadPool` 和 `SingleThreadExector` 。`FixedThreadPool`最多只能创建核心线程数的线程(核心线程数和最大线程数相等),`SingleThreadExector`只能创建一个线程(核心线程数和最大线程数都是 1),二者的任务队列永远不会被放满。
+- 容量为 `Integer.MAX_VALUE` 的 `LinkedBlockingQueue`(无界阻塞队列):`FixedThreadPool` 和 `SingleThreadExecutor` 。`FixedThreadPool`最多只能创建核心线程数的线程(核心线程数和最大线程数相等),`SingleThreadExecutor`只能创建一个线程(核心线程数和最大线程数都是 1),二者的任务队列永远不会被放满。
- `SynchronousQueue`(同步队列):`CachedThreadPool` 。`SynchronousQueue` 没有容量,不存储元素,目的是保证对于提交的任务,如果有空闲线程,则使用空闲线程来处理;否则新建一个线程来处理任务。也就是说,`CachedThreadPool` 的最大线程数是 `Integer.MAX_VALUE` ,可以理解为线程数是可以无限扩展的,可能会创建大量线程,从而导致 OOM。
-- `DelayedWorkQueue`(延迟阻塞队列):`ScheduledThreadPool` 和 `SingleThreadScheduledExecutor` 。`DelayedWorkQueue` 的内部元素并不是按照放入的时间排序,而是会按照延迟的时间长短对任务进行排序,内部采用的是“堆”的数据结构,可以保证每次出队的任务都是当前队列中执行时间最靠前的。`DelayedWorkQueue` 添加元素满了之后会自动扩容原来容量的 1/2,即永远不会阻塞,最大扩容可达 `Integer.MAX_VALUE`,所以最多只能创建核心线程数的线程。
+- `DelayedWorkQueue`(延迟队列):`ScheduledThreadPool` 和 `SingleThreadScheduledExecutor` 。`DelayedWorkQueue` 的内部元素并不是按照放入的时间排序,而是会按照延迟的时间长短对任务进行排序,内部采用的是“堆”的数据结构,可以保证每次出队的任务都是当前队列中执行时间最靠前的。`DelayedWorkQueue` 是一个无界队列。其底层虽然是数组,但当数组容量不足时,它会自动进行扩容,因此队列永远不会被填满。当任务不断提交时,它们会全部被添加到队列中。这意味着线程池的线程数量永远不会超过其核心线程数,最大线程数参数对于使用该队列的线程池来说是无效的。
+- `ArrayBlockingQueue`(有界阻塞队列):底层由数组实现,容量一旦创建,就不能修改。
-### 线程池处理任务的流程了解吗?
+### ⭐️线程池处理任务的流程了解吗?
-
+
1. 如果当前运行的线程数小于核心线程数,那么就会新建一个线程来执行任务。
2. 如果当前运行的线程数等于或大于核心线程数,但是小于最大线程数,那么就把该任务放入到任务队列里等待执行。
3. 如果向任务队列投放任务失败(任务队列已经满了),但是当前运行的线程数是小于最大线程数的,就新建一个线程来执行任务。
-4. 如果当前运行的线程数已经等同于最大线程数了,新建线程将会使当前运行的线程超出最大线程数,那么当前任务会被拒绝,饱和策略会调用`RejectedExecutionHandler.rejectedExecution()`方法。
+4. 如果当前运行的线程数已经等同于最大线程数了,新建线程将会使当前运行的线程超出最大线程数,那么当前任务会被拒绝,拒绝策略会调用`RejectedExecutionHandler.rejectedExecution()`方法。
+
+再提一个有意思的小问题:**线程池在提交任务前,可以提前创建线程吗?**
+
+答案是可以的!`ThreadPoolExecutor` 提供了两个方法帮助我们在提交任务之前,完成核心线程的创建,从而实现线程池预热的效果:
+
+- `prestartCoreThread()`:启动一个线程,等待任务,如果已达到核心线程数,这个方法返回 false,否则返回 true;
+- `prestartAllCoreThreads()`:启动所有的核心线程,并返回启动成功的核心线程数。
-### 如何给线程池命名?
+### ⭐️线程池中线程异常后,销毁还是复用?
+
+直接说结论,需要分两种情况:
+
+- **使用`execute()`提交任务**:当任务通过`execute()`提交到线程池并在执行过程中抛出异常时,如果这个异常没有在任务内被捕获,那么该异常会导致当前线程终止,并且异常会被打印到控制台或日志文件中。线程池会检测到这种线程终止,并创建一个新线程来替换它,从而保持配置的线程数不变。
+- **使用`submit()`提交任务**:对于通过`submit()`提交的任务,如果在任务执行中发生异常,这个异常不会直接打印出来。相反,异常会被封装在由`submit()`返回的`Future`对象中。当调用`Future.get()`方法时,可以捕获到一个`ExecutionException`。在这种情况下,线程不会因为异常而终止,它会继续存在于线程池中,准备执行后续的任务。
+
+简单来说:使用`execute()`时,未捕获异常导致线程终止,线程池创建新线程替代;使用`submit()`时,异常被封装在`Future`中,线程继续复用。
+
+这种设计允许`submit()`提供更灵活的错误处理机制,因为它允许调用者决定如何处理异常,而`execute()`则适用于那些不需要关注执行结果的场景。
+
+具体的源码分析可以参考这篇:[线程池中线程异常后:销毁还是复用? - 京东技术](https://mp.weixin.qq.com/s/9ODjdUU-EwQFF5PrnzOGfw)。
+
+### ⭐️如何给线程池命名?
初始化线程池的时候需要显示命名(设置线程池名称前缀),有利于定位问题。
@@ -420,7 +948,7 @@ public final class NamingThreadFactory implements ThreadFactory {
>
> Linux 相比与其他操作系统(包括其他类 Unix 系统)有很多的优点,其中有一项就是,其上下文切换和模式切换的时间消耗非常少。
-类比于实现世界中的人类通过合作做某件事情,我们可以肯定的一点是线程池大小设置过大或者过小都会有问题,合适的才是最好。
+类比于现实世界中的人类通过合作做某件事情,我们可以肯定的一点是线程池大小设置过大或者过小都会有问题,合适的才是最好。
- 如果我们设置的线程池数量太小的话,如果同一时间有大量任务/请求需要处理,可能会导致大量的请求/任务在任务队列中排队等待执行,甚至会出现任务队列满了之后任务/请求无法处理的情况,或者大量任务堆积在任务队列导致 OOM。这样很明显是有问题的,CPU 根本没有得到充分利用。
- 如果我们设置线程数量太大,大量线程可能会同时在争取 CPU 资源,这样会导致大量的上下文切换,从而增加线程的执行时间,影响了整体执行效率。
@@ -446,15 +974,15 @@ CPU 密集型简单理解就是利用 CPU 计算能力的任务比如你在内
>
> IO 密集型任务下,几乎全是线程等待时间,从理论上来说,你就可以将线程数设置为 2N(按道理来说,WT/ST 的结果应该比较大,这里选择 2N 的原因应该是为了避免创建过多线程吧)。
-公示也只是参考,具体还是要根据项目实际线上运行情况来动态调整。我在后面介绍的美团的线程池参数动态配置这种方案就非常不错,很实用!
+公式也只是参考,具体还是要根据项目实际线上运行情况来动态调整。我在后面介绍的美团的线程池参数动态配置这种方案就非常不错,很实用!
-### 如何动态修改线程池的参数?
+### ⭐️如何动态修改线程池的参数?
美团技术团队在[《Java 线程池实现原理及其在美团业务中的实践》](https://tech.meituan.com/2020/04/02/java-pooling-pratice-in-meituan.html)这篇文章中介绍到对线程池参数实现可自定义配置的思路和方法。
美团技术团队的思路是主要对线程池的核心参数实现自定义可配置。这三个核心参数是:
-- **`corePoolSize` :** 核心线程数线程数定义了最小可以同时运行的线程数量。
+- **`corePoolSize` :** 核心线程数定义了最小可以同时运行的线程数量。
- **`maximumPoolSize` :** 当队列中存放的任务达到队列容量的时候,当前可以同时运行的线程数量变为最大线程数。
- **`workQueue`:** 当新任务来的时候会先判断当前运行的线程数量是否达到核心线程数,如果达到的话,新任务就会被存放在队列中。
@@ -466,7 +994,7 @@ CPU 密集型简单理解就是利用 CPU 计算能力的任务比如你在内

-格外需要注意的是`corePoolSize`, 程序运行期间的时候,我们调用 `setCorePoolSize()`这个方法的话,线程池会首先判断当前工作线程数是否大于`corePoolSize`,如果大于的话就会回收工作线程。
+格外需要注意的是`corePoolSize`, 程序运行期间的时候,我们调用 `setCorePoolSize()`这个方法的话,线程池会首先判断当前工作线程数是否大于`corePoolSize`,如果大于的话就会回收工作线程。
另外,你也看到了上面并没有动态指定队列长度的方法,美团的方式是自定义了一个叫做 `ResizableCapacityLinkedBlockIngQueue` 的队列(主要就是把`LinkedBlockingQueue`的 capacity 字段的 final 关键字修饰给去掉了,让它变为可变的)。
@@ -474,18 +1002,20 @@ CPU 密集型简单理解就是利用 CPU 计算能力的任务比如你在内

-还没看够?推荐 why 神的[如何设置线程池参数?美团给出了一个让面试官虎躯一震的回答。](https://mp.weixin.qq.com/s/9HLuPcoWmTqAeFKa1kj-_A)这篇文章,深度剖析,很不错哦!
+还没看够?我在[《后端面试高频系统设计&场景题》](https://javaguide.cn/zhuanlan/back-end-interview-high-frequency-system-design-and-scenario-questions.html)中详细介绍了如何设计一个动态线程池,这也是面试中常问的一道系统设计题。
+
+
如果我们的项目也想要实现这种效果的话,可以借助现成的开源项目:
- **[Hippo4j](https://github.com/opengoofy/hippo4j)**:异步线程池框架,支持线程池动态变更&监控&报警,无需修改代码轻松引入。支持多种使用模式,轻松引入,致力于提高系统运行保障能力。
- **[Dynamic TP](https://github.com/dromara/dynamic-tp)**:轻量级动态线程池,内置监控告警功能,集成三方中间件线程池管理,基于主流配置中心(已支持 Nacos、Apollo,Zookeeper、Consul、Etcd,可通过 SPI 自定义实现)。
-### 如何设计一个能够根据任务的优先级来执行的线程池?
+### ⭐️如何设计一个能够根据任务的优先级来执行的线程池?
这是一个常见的面试问题,本质其实还是在考察求职者对于线程池以及阻塞队列的掌握。
-我们上面也提到了,不同的线程池会选用不同的阻塞队列作为任务队列,比如`FixedThreadPool` 使用的是`LinkedBlockingQueue`(无界队列),由于队列永远不会被放满,因此`FixedThreadPool`最多只能创建核心线程数的线程。
+我们上面也提到了,不同的线程池会选用不同的阻塞队列作为任务队列,比如`FixedThreadPool` 使用的是`LinkedBlockingQueue`(有界队列),默认构造器初始的队列长度为 `Integer.MAX_VALUE` ,由于队列永远不会被放满,因此`FixedThreadPool`最多只能创建核心线程数的线程。
假如我们需要实现一个优先级任务线程池的话,那可以考虑使用 `PriorityBlockingQueue` (优先级阻塞队列)作为任务队列(`ThreadPoolExecutor` 的构造函数有一个 `workQueue` 参数可以传入任务队列)。
@@ -512,6 +1042,10 @@ CPU 密集型简单理解就是利用 CPU 计算能力的任务比如你在内
## Future
+重点是要掌握 `CompletableFuture` 的使用以及常见面试题。
+
+除了下面的面试题之外,还推荐你看看我写的这篇文章: [CompletableFuture 详解](https://javaguide.cn/java/concurrent/completablefuture-intro.html)。
+
### Future 类有什么用?
`Future` 类是异步思想的典型运用,主要用在一些需要执行耗时任务的场景,避免程序一直原地等待耗时任务执行完成,执行效率太低。具体来说是这样的:当我们执行某一耗时的任务时,可以将这个耗时任务交给一个子线程去异步执行,同时我们可以干点其他事情,不用傻傻等待耗时任务执行完成。等我们的事情干完后,我们再通过 `Future` 类获取到耗时任务的执行结果。这样一来,程序的执行效率就明显提高了。
@@ -580,9 +1114,11 @@ public FutureTask(Runnable runnable, V result) {
`FutureTask`相当于对`Callable` 进行了封装,管理着任务执行的情况,存储了 `Callable` 的 `call` 方法的任务执行结果。
+关于更多 `Future` 的源码细节,可以肝这篇万字解析,写的很清楚:[Java 是如何实现 Future 模式的?万字详解!](https://juejin.cn/post/6844904199625375757)。
+
### CompletableFuture 类有什么用?
-`Future` 在实际使用过程中存在一些局限性比如不支持异步任务的编排组合、获取计算结果的 `get()` 方法为阻塞调用。
+`Future` 在实际使用过程中存在一些局限性,比如不支持异步任务的编排组合、获取计算结果的 `get()` 方法为阻塞调用。
Java 8 才被引入`CompletableFuture` 类可以解决`Future` 的这些缺陷。`CompletableFuture` 除了提供了更为好用和强大的 `Future` 特性之外,还提供了函数式编程、异步任务编排组合(可以将多个异步任务串联起来,组成一个完整的链式调用)等能力。
@@ -603,36 +1139,105 @@ public class CompletableFuture implements Future, CompletionStage {

-## AQS
+### ⭐️一个任务需要依赖另外两个任务执行完之后再执行,怎么设计?
-### AQS 是什么?
+这种任务编排场景非常适合通过`CompletableFuture`实现。这里假设要实现 T3 在 T2 和 T1 执行完后执行。
+
+代码如下(这里为了简化代码,用到了 Hutool 的线程工具类 `ThreadUtil` 和日期时间工具类 `DateUtil`):
+
+```java
+// T1
+CompletableFuture futureT1 = CompletableFuture.runAsync(() -> {
+ System.out.println("T1 is executing. Current time:" + DateUtil.now());
+ // 模拟耗时操作
+ ThreadUtil.sleep(1000);
+});
+// T2
+CompletableFuture futureT2 = CompletableFuture.runAsync(() -> {
+ System.out.println("T2 is executing. Current time:" + DateUtil.now());
+ ThreadUtil.sleep(1000);
+});
+
+// 使用allOf()方法合并T1和T2的CompletableFuture,等待它们都完成
+CompletableFuture bothCompleted = CompletableFuture.allOf(futureT1, futureT2);
+// 当T1和T2都完成后,执行T3
+bothCompleted.thenRunAsync(() -> System.out.println("T3 is executing after T1 and T2 have completed.Current time:" + DateUtil.now()));
+// 等待所有任务完成,验证效果
+ThreadUtil.sleep(3000);
+```
+
+通过 `CompletableFuture` 的 `allOf()` 这个静态方法来并行运行 T1 和 T2,当 T1 和 T2 都完成后,再执行 T3。
+
+### ⭐️使用 CompletableFuture,有一个任务失败,如何处理异常?
+
+使用 `CompletableFuture`的时候一定要以正确的方式进行异常处理,避免异常丢失或者出现不可控问题。
-AQS 的全称为 `AbstractQueuedSynchronizer` ,翻译过来的意思就是抽象队列同步器。这个类在 `java.util.concurrent.locks` 包下面。
+下面是一些建议:
-
+- 使用 `whenComplete` 方法可以在任务完成时触发回调函数,并正确地处理异常,而不是让异常被吞噬或丢失。
+- 使用 `exceptionally` 方法可以处理异常并重新抛出,以便异常能够传播到后续阶段,而不是让异常被忽略或终止。
+- 使用 `handle` 方法可以处理正常的返回结果和异常,并返回一个新的结果,而不是让异常影响正常的业务逻辑。
+- 使用 `CompletableFuture.allOf` 方法可以组合多个 `CompletableFuture`,并统一处理所有任务的异常,而不是让异常处理过于冗长或重复。
+- ……
-AQS 就是一个抽象类,主要用来构建锁和同步器。
+### ⭐️在使用 CompletableFuture 的时候为什么要自定义线程池?
+
+`CompletableFuture` 默认使用全局共享的 `ForkJoinPool.commonPool()` 作为执行器,所有未指定执行器的异步任务都会使用该线程池。这意味着应用程序、多个库或框架(如 Spring、第三方库)若都依赖 `CompletableFuture`,默认情况下它们都会共享同一个线程池。
+
+虽然 `ForkJoinPool` 效率很高,但当同时提交大量任务时,可能会导致资源竞争和线程饥饿,进而影响系统性能。
+
+为避免这些问题,建议为 `CompletableFuture` 提供自定义线程池,带来以下优势:
+
+- 隔离性:为不同任务分配独立的线程池,避免全局线程池资源争夺。
+- 资源控制:根据任务特性调整线程池大小和队列类型,优化性能表现。
+- 异常处理:通过自定义 `ThreadFactory` 更好地处理线程中的异常情况。
```java
-public abstract class AbstractQueuedSynchronizer extends AbstractOwnableSynchronizer implements java.io.Serializable {
-}
+private ThreadPoolExecutor executor = new ThreadPoolExecutor(10, 10,
+ 0L, TimeUnit.MILLISECONDS,
+ new LinkedBlockingQueue());
+
+CompletableFuture.runAsync(() -> {
+ //...
+}, executor);
```
-AQS 为构建锁和同步器提供了一些通用功能的实现,因此,使用 AQS 能简单且高效地构造出应用广泛的大量的同步器,比如我们提到的 `ReentrantLock`,`Semaphore`,其他的诸如 `ReentrantReadWriteLock`,`SynchronousQueue`等等皆是基于 AQS 的。
+## AQS
+
+关于 AQS 源码的详细分析,可以看看这一篇文章:[AQS 详解](https://javaguide.cn/java/concurrent/aqs.html)。
+
+### AQS 是什么?
+
+AQS (`AbstractQueuedSynchronizer` ,抽象队列同步器)是从 JDK1.5 开始提供的 Java 并发核心组件。
+
+AQS 解决了开发者在实现同步器时的复杂性问题。它提供了一个通用框架,用于实现各种同步器,例如 **可重入锁**(`ReentrantLock`)、**信号量**(`Semaphore`)和 **倒计时器**(`CountDownLatch`)。通过封装底层的线程同步机制,AQS 将复杂的线程管理逻辑隐藏起来,使开发者只需专注于具体的同步逻辑。
+
+简单来说,AQS 是一个抽象类,为同步器提供了通用的 **执行框架**。它定义了 **资源获取和释放的通用流程**,而具体的资源获取逻辑则由具体同步器通过重写模板方法来实现。 因此,可以将 AQS 看作是同步器的 **基础“底座”**,而同步器则是基于 AQS 实现的 **具体“应用”**。
+
+### ⭐️AQS 的原理是什么?
+
+AQS 核心思想是,如果被请求的共享资源空闲,则将当前请求资源的线程设置为有效的工作线程,并且将共享资源设置为锁定状态。如果被请求的共享资源被占用,那么就需要一套线程阻塞等待以及被唤醒时锁分配的机制,这个机制 AQS 是基于 **CLH 锁** (Craig, Landin, and Hagersten locks) 进一步优化实现的。
+
+**CLH 锁** 对自旋锁进行了改进,是基于单链表的自旋锁。在多线程场景下,会将请求获取锁的线程组织成一个单向队列,每个等待的线程会通过自旋访问前一个线程节点的状态,前一个节点释放锁之后,当前节点才可以获取锁。**CLH 锁** 的队列结构如下图所示。
+
+
+
+AQS 中使用的 **等待队列** 是 CLH 锁队列的变体(接下来简称为 CLH 变体队列)。
-### AQS 的原理是什么?
+AQS 的 CLH 变体队列是一个双向队列,会暂时获取不到锁的线程将被加入到该队列中,CLH 变体队列和原本的 CLH 锁队列的区别主要有两点:
-AQS 核心思想是,如果被请求的共享资源空闲,则将当前请求资源的线程设置为有效的工作线程,并且将共享资源设置为锁定状态。如果被请求的共享资源被占用,那么就需要一套线程阻塞等待以及被唤醒时锁分配的机制,这个机制 AQS 是用 **CLH 队列锁** 实现的,即将暂时获取不到锁的线程加入到队列中。
+- 由 **自旋** 优化为 **自旋 + 阻塞** :自旋操作的性能很高,但大量的自旋操作比较占用 CPU 资源,因此在 CLH 变体队列中会先通过自旋尝试获取锁,如果失败再进行阻塞等待。
+- 由 **单向队列** 优化为 **双向队列** :在 CLH 变体队列中,会对等待的线程进行阻塞操作,当队列前边的线程释放锁之后,需要对后边的线程进行唤醒,因此增加了 `next` 指针,成为了双向队列。
-CLH(Craig,Landin,and Hagersten) 队列是一个虚拟的双向队列(虚拟的双向队列即不存在队列实例,仅存在结点之间的关联关系)。AQS 是将每条请求共享资源的线程封装成一个 CLH 锁队列的一个结点(Node)来实现锁的分配。在 CLH 同步队列中,一个节点表示一个线程,它保存着线程的引用(thread)、 当前节点在队列中的状态(waitStatus)、前驱节点(prev)、后继节点(next)。
+AQS 将每条请求共享资源的线程封装成一个 CLH 变体队列的一个结点(Node)来实现锁的分配。在 CLH 变体队列中,一个节点表示一个线程,它保存着线程的引用(thread)、 当前节点在队列中的状态(waitStatus)、前驱节点(prev)、后继节点(next)。
-CLH 队列结构如下图所示:
+AQS 中的 CLH 变体队列结构如下图所示:
-
+
-AQS(`AbstractQueuedSynchronizer`)的核心原理图(图源[Java 并发之 AQS 详解](https://www.cnblogs.com/waterystone/p/4920797.html))如下:
+AQS(`AbstractQueuedSynchronizer`)的核心原理图:
-
+
AQS 使用 **int 成员变量 `state` 表示同步状态**,通过内置的 **线程等待队列** 来完成获取资源线程的排队工作。
@@ -662,7 +1267,7 @@ protected final boolean compareAndSetState(int expect, int update) {
以 `ReentrantLock` 为例,`state` 初始值为 0,表示未锁定状态。A 线程 `lock()` 时,会调用 `tryAcquire()` 独占该锁并将 `state+1` 。此后,其他线程再 `tryAcquire()` 时就会失败,直到 A 线程 `unlock()` 到 `state=`0(即释放锁)为止,其它线程才有机会获取该锁。当然,释放锁之前,A 线程自己是可以重复获取此锁的(`state` 会累加),这就是可重入的概念。但要注意,获取多少次就要释放多少次,这样才能保证 state 是能回到零态的。
-再以 `CountDownLatch` 以例,任务分为 N 个子线程去执行,`state` 也初始化为 N(注意 N 要与线程个数一致)。这 N 个子线程是并行执行的,每个子线程执行完后`countDown()` 一次,state 会 CAS(Compare and Swap) 减 1。等到所有子线程都执行完后(即 `state=0` ),会 `unpark()` 主调用线程,然后主调用线程就会从 `await()` 函数返回,继续后余动作。
+再以 `CountDownLatch` 以例,任务分为 N 个子线程去执行,`state` 也初始化为 N(注意 N 要与线程个数一致)。这 N 个子线程是并行执行的,每个子线程执行完后`countDown()` 一次,state 会 CAS(Compare and Swap) 减 1。等到所有子线程都执行完后(即 `state=0` ),会 `unpark()` 主调用线程,然后主调用线程就会从 `await()` 函数返回,继续后续动作。
### Semaphore 有什么用?
@@ -843,7 +1448,7 @@ CompletableFuture allFutures = CompletableFuture.allOf(
`CyclicBarrier` 和 `CountDownLatch` 非常类似,它也可以实现线程间的技术等待,但是它的功能比 `CountDownLatch` 更加复杂和强大。主要应用场景和 `CountDownLatch` 类似。
-> `CountDownLatch` 的实现是基于 AQS 的,而 `CycliBarrier` 是基于 `ReentrantLock`(`ReentrantLock` 也属于 AQS 同步器)和 `Condition` 的。
+> `CountDownLatch` 的实现是基于 AQS 的,而 `CyclicBarrier` 是基于 `ReentrantLock`(`ReentrantLock` 也属于 AQS 同步器)和 `Condition` 的。
`CyclicBarrier` 的字面意思是可循环使用(Cyclic)的屏障(Barrier)。它要做的事情是:让一组线程到达一个屏障(也可以叫同步点)时被阻塞,直到最后一个线程到达屏障时,屏障才会开门,所有被屏障拦截的线程才会继续干活。
@@ -973,14 +1578,19 @@ public int await() throws InterruptedException, BrokenBarrierException {
## 虚拟线程
-虚拟线程在 Java 21 正式发布,这是一项重量级的更新。
+虚拟线程在 Java 21 正式发布,这是一项重量级的更新。虽然目前面试中问的不多,但还是建议大家去简单了解一下。我写了一篇文章来总结虚拟线程常见的问题:[虚拟线程常见问题总结](https://javaguide.cn/java/concurrent/virtual-thread.html),包含下面这些问题:
-虽然目前面试中问的不多,但还是建议大家去简单了解一下,具体可以阅读这篇文章:[虚拟线程极简入门](./virtual-thread.md) 。重点搞清楚虚拟线程和平台线程的关系以及虚拟线程的优势即可。
+1. 什么是虚拟线程?
+2. 虚拟线程和平台线程有什么关系?
+3. 虚拟线程有什么优点和缺点?
+4. 如何创建虚拟线程?
+5. 虚拟线程的底层原理是什么?
## 参考
- 《深入理解 Java 虚拟机》
- 《实战 Java 高并发程序设计》
+- Java 线程池的实现原理及其在业务中的最佳实践:阿里云开发者:
- 带你了解下 SynchronousQueue(并发队列专题):
- 阻塞队列 — DelayedWorkQueue 源码分析:
- Java 多线程(三)——FutureTask/CompletableFuture:
diff --git a/docs/java/concurrent/java-thread-pool-best-practices.md b/docs/java/concurrent/java-thread-pool-best-practices.md
index 62b2475e523..7bbc5592871 100644
--- a/docs/java/concurrent/java-thread-pool-best-practices.md
+++ b/docs/java/concurrent/java-thread-pool-best-practices.md
@@ -1,8 +1,13 @@
---
title: Java 线程池最佳实践
+description: Java线程池最佳实践总结:详解线程池参数配置、避免Executors工厂方法OOM风险、拒绝策略选择、线程池监控、线程命名规范等生产级实践。
category: Java
tag:
- Java并发
+head:
+ - - meta
+ - name: keywords
+ content: 线程池最佳实践,ThreadPoolExecutor配置,Executors陷阱,OOM风险,拒绝策略,线程池监控,线程命名
---
简单总结一下我了解的使用线程池的时候应该注意的东西,网上似乎还没有专门写这方面的文章。
@@ -13,9 +18,9 @@ tag:
`Executors` 返回线程池对象的弊端如下(后文会详细介绍到):
-- **`FixedThreadPool` 和 `SingleThreadExecutor`**:使用的是无界的 `LinkedBlockingQueue`,任务队列最大长度为 `Integer.MAX_VALUE`,可能堆积大量的请求,从而导致 OOM。
-- **`CachedThreadPool`**:使用的是同步队列 `SynchronousQueue`, 允许创建的线程数量为 `Integer.MAX_VALUE` ,可能会创建大量线程,从而导致 OOM。
-- **`ScheduledThreadPool` 和 `SingleThreadScheduledExecutor`** : 使用的无界的延迟阻塞队列`DelayedWorkQueue`,任务队列最大长度为 `Integer.MAX_VALUE`,可能堆积大量的请求,从而导致 OOM。
+- **`FixedThreadPool` 和 `SingleThreadExecutor`**:使用的是阻塞队列 `LinkedBlockingQueue`,任务队列的默认长度和最大长度为 `Integer.MAX_VALUE`,可以看作是无界队列,可能堆积大量的请求,从而导致 OOM。
+- **`CachedThreadPool`**:使用的是同步队列 `SynchronousQueue`,允许创建的线程数量为 `Integer.MAX_VALUE` ,可能会创建大量线程,从而导致 OOM。
+- **`ScheduledThreadPool` 和 `SingleThreadScheduledExecutor`** : 使用的无界的延迟阻塞队列`DelayedWorkQueue`,任务队列最大长度为 `Integer.MAX_VALUE`,可能堆积大量的请求,从而导致 OOM。
说白了就是:**使用有界队列,控制线程创建数量。**
@@ -136,15 +141,20 @@ public final class NamingThreadFactory implements ThreadFactory {
>
> Linux 相比与其他操作系统(包括其他类 Unix 系统)有很多的优点,其中有一项就是,其上下文切换和模式切换的时间消耗非常少。
-类比于实现世界中的人类通过合作做某件事情,我们可以肯定的一点是线程池大小设置过大或者过小都会有问题,合适的才是最好。
+类比于现实世界中的人类通过合作做某件事情,我们可以肯定的一点是线程池大小设置过大或者过小都会有问题,合适的才是最好。
- 如果我们设置的线程池数量太小的话,如果同一时间有大量任务/请求需要处理,可能会导致大量的请求/任务在任务队列中排队等待执行,甚至会出现任务队列满了之后任务/请求无法处理的情况,或者大量任务堆积在任务队列导致 OOM。这样很明显是有问题的,CPU 根本没有得到充分利用。
- 如果我们设置线程数量太大,大量线程可能会同时在争取 CPU 资源,这样会导致大量的上下文切换,从而增加线程的执行时间,影响了整体执行效率。
有一个简单并且适用面比较广的公式:
-- **CPU 密集型任务(N+1):** 这种任务消耗的主要是 CPU 资源,可以将线程数设置为 N(CPU 核心数)+1。比 CPU 核心数多出来的一个线程是为了防止线程偶发的缺页中断,或者其它原因导致的任务暂停而带来的影响。一旦任务暂停,CPU 就会处于空闲状态,而在这种情况下多出来的一个线程就可以充分利用 CPU 的空闲时间。
-- **I/O 密集型任务(2N):** 这种任务应用起来,系统会用大部分的时间来处理 I/O 交互,而线程在处理 I/O 的时间段内不会占用 CPU 来处理,这时就可以将 CPU 交出给其它线程使用。因此在 I/O 密集型任务的应用中,我们可以多配置一些线程,具体的计算方法是 2N。
+- **CPU 密集型任务 (N):** 这种任务消耗的主要是 CPU 资源,线程数应设置为 N(CPU 核心数)。由于任务主要瓶颈在于 CPU 计算能力,与核心数相等的线程数能够最大化 CPU 利用率,过多线程反而会导致竞争和上下文切换开销。
+- **I/O 密集型任务(M \* N):** 这类任务大部分时间处理 I/O 交互,线程在等待 I/O 时不占用 CPU。 为了充分利用 CPU 资源,线程数可以设置为 M \* N,其中 N 是 CPU 核心数,M 是一个大于 1 的倍数,建议默认设置为 2 ,具体取值取决于 I/O 等待时间和任务特点,需要通过测试和监控找到最佳平衡点。
+
+CPU 密集型任务不再推荐 N+1,原因如下:
+
+- "N+1" 的初衷是希望预留线程处理突发暂停,但实际上,处理缺页中断等情况仍然需要占用 CPU 核心。
+- CPU 密集场景下,CPU 始终是瓶颈,预留线程并不能凭空增加 CPU 处理能力,反而可能加剧竞争。
**如何判断是 CPU 密集任务还是 IO 密集任务?**
@@ -170,7 +180,7 @@ IO 密集型任务下,几乎全是线程等待时间,从理论上来说,
美团技术团队的思路是主要对线程池的核心参数实现自定义可配置。这三个核心参数是:
-- **`corePoolSize` :** 核心线程数线程数定义了最小可以同时运行的线程数量。
+- **`corePoolSize` :** 核心线程数定义了最小可以同时运行的线程数量。
- **`maximumPoolSize` :** 当队列中存放的任务达到队列容量的时候,当前可以同时运行的线程数量变为最大线程数。
- **`workQueue`:** 当新任务来的时候会先判断当前运行的线程数量是否达到核心线程数,如果达到的话,新任务就会被存放在队列中。
@@ -227,7 +237,7 @@ try {
线程池本身的目的是为了提高任务执行效率,避免因频繁创建和销毁线程而带来的性能开销。如果将耗时任务提交到线程池中执行,可能会导致线程池中的线程被长时间占用,无法及时响应其他任务,甚至会导致线程池崩溃或者程序假死。
-因此,在使用线程池时,我们应该尽量避免将耗时任务提交到线程池中执行。对于一些比较耗时的操作,如网络请求、文件读写等,可以采用异步操作的方式来处理,以避免阻塞线程池中的线程。
+因此,在使用线程池时,我们应该尽量避免将耗时任务提交到线程池中执行。对于一些比较耗时的操作,如网络请求、文件读写等,可以采用 `CompletableFuture` 等其他异步操作的方式来处理,以避免阻塞线程池中的线程。
## 8、线程池使用的一些小坑
@@ -268,7 +278,7 @@ public class ThreadPoolExecutorConfig {
int maxPoolSize = (int) (processNum / (1 - 0.5));
threadPoolExecutor.setCorePoolSize(corePoolSize); // 核心池大小
threadPoolExecutor.setMaxPoolSize(maxPoolSize); // 最大线程数
- threadPoolExecutor.setQueueCapacity(maxPoolSize * 1000); // 队列程度
+ threadPoolExecutor.setQueueCapacity(maxPoolSize * 1000); // 队列长度
threadPoolExecutor.setThreadPriority(Thread.MAX_PRIORITY);
threadPoolExecutor.setDaemon(false);
threadPoolExecutor.setKeepAliveSeconds(300);// 线程空闲时间
diff --git a/docs/java/concurrent/java-thread-pool-summary.md b/docs/java/concurrent/java-thread-pool-summary.md
index cfcd414ba22..9e83f33df3a 100644
--- a/docs/java/concurrent/java-thread-pool-summary.md
+++ b/docs/java/concurrent/java-thread-pool-summary.md
@@ -1,25 +1,30 @@
---
title: Java 线程池详解
+description: Java线程池详解:深入讲解ThreadPoolExecutor核心参数配置、Executor框架体系、任务队列选择、拒绝策略、线程池工作原理及最佳实践。
category: Java
tag:
- Java并发
+head:
+ - - meta
+ - name: keywords
+ content: Java线程池,ThreadPoolExecutor,Executor框架,线程池参数,拒绝策略,任务队列,线程池原理
---
+
+
池化技术想必大家已经屡见不鲜了,线程池、数据库连接池、HTTP 连接池等等都是对这个思想的应用。池化技术的思想主要是为了减少每次获取资源的消耗,提高对资源的利用率。
这篇文章我会详细介绍一下线程池的基本概念以及核心原理。
## 线程池介绍
-顾名思义,线程池就是管理一系列线程的资源池,其提供了一种限制和管理线程资源的方式。每个线程池还维护一些基本统计信息,例如已完成任务的数量。
-
-这里借用《Java 并发编程的艺术》书中的部分内容来总结一下使用线程池的好处:
+池化技术想必大家已经屡见不鲜了,线程池、数据库连接池、HTTP 连接池等等都是对这个思想的应用。池化技术的思想主要是为了减少每次获取资源的消耗,提高对资源的利用率。
-- **降低资源消耗**。通过重复利用已创建的线程降低线程创建和销毁造成的消耗。
-- **提高响应速度**。当任务到达时,任务可以不需要等到线程创建就能立即执行。
-- **提高线程的可管理性**。线程是稀缺资源,如果无限制的创建,不仅会消耗系统资源,还会降低系统的稳定性,使用线程池可以进行统一的分配,调优和监控。
+线程池提供了一种限制和管理资源(包括执行一个任务)的方式。 每个线程池还维护一些基本统计信息,例如已完成任务的数量。使用线程池主要带来以下几个好处:
-**线程池一般用于执行多个不相关联的耗时任务,没有多线程的情况下,任务顺序执行,使用了线程池的话可让多个不相关联的任务同时执行。**
+1. **降低资源消耗**:线程池里的线程是可以重复利用的。一旦线程完成了某个任务,它不会立即销毁,而是回到池子里等待下一个任务。这就避免了频繁创建和销毁线程带来的开销。
+2. **提高响应速度**:因为线程池里通常会维护一定数量的核心线程(或者说“常驻工人”),任务来了之后,可以直接交给这些已经存在的、空闲的线程去执行,省去了创建线程的时间,任务能够更快地得到处理。
+3. **提高线程的可管理性**:线程池允许我们统一管理池中的线程。我们可以配置线程池的大小(核心线程数、最大线程数)、任务队列的类型和大小、拒绝策略等。这样就能控制并发线程的总量,防止资源耗尽,保证系统的稳定性。同时,线程池通常也提供了监控接口,方便我们了解线程池的运行状态(比如有多少活跃线程、多少任务在排队等),便于调优。
## Executor 框架介绍
@@ -80,7 +85,7 @@ public class ScheduledThreadPoolExecutor
线程池实现类 `ThreadPoolExecutor` 是 `Executor` 框架最核心的类。
-### 构造方法介绍
+### 线程池参数分析
`ThreadPoolExecutor` 类中提供的四个构造方法。我们来看最长的那个,其余三个都是在这个构造方法的基础上产生(其他几个构造方法说白点都是给定某些默认参数的构造方法比如默认制定拒绝策略是什么)。
@@ -112,26 +117,52 @@ public class ScheduledThreadPoolExecutor
}
```
-下面这些对创建非常重要,在后面使用线程池的过程中你一定会用到!所以,务必拿着小本本记清楚。
+下面这些参数非常重要,在后面使用线程池的过程中你一定会用到!所以,务必拿着小本本记清楚。
-**`ThreadPoolExecutor` 3 个最重要的参数:**
+`ThreadPoolExecutor` 3 个最重要的参数:
-- **`corePoolSize` :** 任务队列未达到队列容量时,最大可以同时运行的线程数量。
-- **`maximumPoolSize` :** 任务队列中存放的任务达到队列容量的时候,当前可以同时运行的线程数量变为最大线程数。
-- **`workQueue`:** 新任务来的时候会先判断当前运行的线程数量是否达到核心线程数,如果达到的话,新任务就会被存放在队列中。
+- `corePoolSize` : 任务队列未达到队列容量时,最大可以同时运行的线程数量。
+- `maximumPoolSize` : 任务队列中存放的任务达到队列容量的时候,当前可以同时运行的线程数量变为最大线程数。
+- `workQueue`: 新任务来的时候会先判断当前运行的线程数量是否达到核心线程数,如果达到的话,新任务就会被存放在队列中。
`ThreadPoolExecutor`其他常见参数 :
-- **`keepAliveTime`**:线程池中的线程数量大于 `corePoolSize` 的时候,如果这时没有新的任务提交,核心线程外的线程不会立即销毁,而是会等待,直到等待的时间超过了 `keepAliveTime`才会被回收销毁。
-- **`unit`** : `keepAliveTime` 参数的时间单位。
-- **`threadFactory`** :executor 创建新线程的时候会用到。
-- **`handler`** :饱和策略。关于饱和策略下面单独介绍一下。
+- `keepAliveTime`:线程池中的线程数量大于 `corePoolSize` 的时候,如果这时没有新的任务提交,核心线程外的线程不会立即销毁,而是会等待,直到等待的时间超过了 `keepAliveTime`才会被回收销毁。
+- `unit` : `keepAliveTime` 参数的时间单位。
+- `threadFactory` :executor 创建新线程的时候会用到。
+- `handler` :拒绝策略(后面会单独详细介绍一下)。
下面这张图可以加深你对线程池中各个参数的相互关系的理解(图片来源:《Java 性能调优实战》):
-
+
+
+### 线程池生命周期状态
+
+`ThreadPoolExecutor` 使用 `ctl` 变量(`AtomicInteger` 类型)同时管理线程池的运行状态和工作线程数量。线程池共有 5 种状态:
+
+- **运行中(`RUNNING`)**:接受新任务,并处理队列中的任务。线程池创建后的初始状态。
+- **关闭(`SHUTDOWN`)**:不再接受新任务,但会继续处理队列中已有的任务。调用 `shutdown()` 后进入。
+- **停止(`STOP`)**:不接受新任务,不处理队列中的任务,并尝试中断正在执行的任务。调用 `shutdownNow()` 后进入。
+- **整理中(`TIDYING`)**:所有任务已终止,工作线程数为 0,即将执行 `terminated()` 钩子方法。
+- **已终止(`TERMINATED`)**:`terminated()` 方法执行完毕,线程池彻底终结。
+
+状态只能单向流转:运行中(`RUNNING`)→ 关闭(`SHUTDOWN`)→ 整理中(`TIDYING`)→ 已终止(`TERMINATED`),或者运行中(`RUNNING`)→ 停止(`STOP`)→ 整理中(`TIDYING`)→ 已终止(`TERMINATED`)。在关闭(`SHUTDOWN`)状态下再调用 `shutdownNow()` 也会转为停止(`STOP`)。
+
+`shutdown()` 是"温和关闭"——中断空闲线程,但队列中的任务仍会执行完毕。`shutdownNow()` 是"强制关闭"——尝试中断所有正在运行的线程,并将队列中未执行的任务以 `List` 返回。`terminated()` 是一个空的钩子方法,可以通过继承 `ThreadPoolExecutor` 来重写它,用于在线程池终止后做清理工作。
-**`ThreadPoolExecutor` 饱和策略定义:**
+### Worker 工作线程机制
+
+`ThreadPoolExecutor` 将每个工作线程封装为内部类 `Worker`。`Worker` 继承了 AQS 并实现了 `Runnable` 接口。
+
+**为什么 `Worker` 要继承 AQS?** `Worker` 实现了一个**不可重入的独占锁**,用于配合 `shutdown()` 区分线程是空闲还是正在工作——正在执行任务的 Worker 持有锁,`shutdown()` 对每个 Worker 尝试 `tryLock()`,失败则说明该线程正在工作,不会被中断。
+
+**Worker 的生命周期:**
+
+1. **创建**:`execute()` 判断需要新建线程时,调用 `addWorker()` 创建 `Worker` 实例,内部通过 `ThreadFactory` 创建线程。
+2. **运行**:线程启动后进入 `runWorker()` 的 `while` 循环,通过 `getTask()` 不断从队列取任务执行。核心线程用 `workQueue.take()`(阻塞等待),非核心线程用 `workQueue.poll(keepAliveTime, unit)`(超时等待)。
+3. **退出**:`getTask()` 返回 `null` 时 Worker 退出循环并清理。返回 `null` 的情况包括:线程池处于停止(`STOP`)状态、线程池处于关闭(`SHUTDOWN`)状态且队列为空、非核心线程等待超时、或运行时缩小了 `maximumPoolSize`。如果退出后工作线程数低于核心数,会自动补充一个新线程。
+
+**`ThreadPoolExecutor` 拒绝策略定义:**
如果当前同时运行的线程数量达到最大线程数量并且队列也已经被放满了任务时,`ThreadPoolExecutor` 定义一些策略:
@@ -142,46 +173,76 @@ public class ScheduledThreadPoolExecutor
举个例子:
-Spring 通过 `ThreadPoolTaskExecutor` 或者我们直接通过 `ThreadPoolExecutor` 的构造函数创建线程池的时候,当我们不指定 `RejectedExecutionHandler` 饱和策略的话来配置线程池的时候默认使用的是 `ThreadPoolExecutor.AbortPolicy`。在默认情况下,`ThreadPoolExecutor` 将抛出 `RejectedExecutionException` 来拒绝新来的任务 ,这代表你将丢失对这个任务的处理。 对于可伸缩的应用程序,建议使用 `ThreadPoolExecutor.CallerRunsPolicy`。当最大池被填满时,此策略为我们提供可伸缩队列(这个直接查看 `ThreadPoolExecutor` 的构造函数源码就可以看出,比较简单的原因,这里就不贴代码了)。
+举个例子:Spring 通过 `ThreadPoolTaskExecutor` 或者我们直接通过 `ThreadPoolExecutor` 的构造函数创建线程池的时候,当我们不指定 `RejectedExecutionHandler` 拒绝策略来配置线程池的时候,默认使用的是 `AbortPolicy`。在这种拒绝策略下,如果队列满了,`ThreadPoolExecutor` 将抛出 `RejectedExecutionException` 异常来拒绝新来的任务 ,这代表你将丢失对这个任务的处理。如果不想丢弃任务的话,可以使用`CallerRunsPolicy`。`CallerRunsPolicy` 和其他的几个策略不同,它既不会抛弃任务,也不会抛出异常,而是将任务回退给调用者,使用调用者的线程来执行任务
+
+```java
+public static class CallerRunsPolicy implements RejectedExecutionHandler {
+
+ public CallerRunsPolicy() { }
-### 线程池创建两种方式
+ public void rejectedExecution(Runnable r, ThreadPoolExecutor e) {
+ if (!e.isShutdown()) {
+ // 直接主线程执行,而不是线程池中的线程执行
+ r.run();
+ }
+ }
+ }
+```
+
+### 4 种拒绝策略的实际应用场景
+
+上面介绍了 4 种内置拒绝策略的基本行为,下面结合实际生产经验,说明它们各自适合什么场景:
+
+**`AbortPolicy`**:适用于对任务丢失零容忍的核心业务(如支付、转账)。任务被拒绝时调用方会收到 `RejectedExecutionException`,必须在业务代码中捕获并做补偿(如重试或持久化到数据库后补偿执行)。《阿里巴巴 Java 开发手册》指出,如果不做任何配置,队列满时会直接抛异常,开发者必须显式处理。
+
+**`CallerRunsPolicy`**:适用于不允许丢弃任务、且允许降低提交速度的场景。由于任务在调用者线程中执行,调用者在此期间无法提交新任务,形成了一种天然的**反压(back-pressure)**机制。美团技术团队在《Java 线程池实现原理及其在美团业务中的实践》中提到,这是他们线上业务中较常使用的拒绝策略。但需要注意:如果提交任务的线程是 Web 容器的请求处理线程(如 Tomcat 的 Worker 线程),会导致该请求响应时间显著增加,在延迟敏感的场景中需谨慎。
-**方式一:通过`ThreadPoolExecutor`构造函数来创建(推荐)。**
+**`DiscardPolicy`**:适用于任务允许丢失的非关键路径,如日志异步写入、监控指标上报。该策略完全静默(空实现),被拒绝的任务不会留下任何痕迹,排查问题时可能难以发现任务丢失。
-
+**`DiscardOldestPolicy`**:适用于只关心最新数据、旧任务可被覆盖的场景,如实时行情推送、传感器数据采集。需要注意:如果使用了 `PriorityBlockingQueue`,`poll()` 弹出的是优先级最高的任务而非最旧的任务,可能导致重要任务被误丢。
-**方式二:通过 `Executor` 框架的工具类 `Executors` 来创建。**
+**生产环境中的常见做法**:以上 4 种内置策略往往不能完全满足需求。Dubbo 框架自定义了 `AbortPolicyWithReport` 策略,在抛异常之外还会将被拒绝的任务信息 dump 到本地文件,方便事后排查。美团技术团队建议对线程池的拒绝次数进行监控和告警。常见的自定义策略思路包括:将被拒绝的任务写入数据库或消息队列后续补偿消费、递增监控计数器上报 Prometheus、或者调用 `workQueue.put(r)` 阻塞等待队列有空位(Netty 中有类似实现)。
-我们可以创建多种类型的 `ThreadPoolExecutor`:
+### 线程池创建的两种方式
-- **`FixedThreadPool`**:该方法返回一个固定线程数量的线程池。该线程池中的线程数量始终不变。当有一个新的任务提交时,线程池中若有空闲线程,则立即执行。若没有,则新的任务会被暂存在一个任务队列中,待有线程空闲时,便处理在任务队列中的任务。
-- **`SingleThreadExecutor`:** 该方法返回一个只有一个线程的线程池。若多余一个任务被提交到该线程池,任务会被保存在一个任务队列中,待线程空闲,按先入先出的顺序执行队列中的任务。
-- **`CachedThreadPool`:** 该方法返回一个可根据实际情况调整线程数量的线程池。线程池的线程数量不确定,但若有空闲线程可以复用,则会优先使用可复用的线程。若所有线程均在工作,又有新的任务提交,则会创建新的线程处理任务。所有线程在当前任务执行完毕后,将返回线程池进行复用。
-- **`ScheduledThreadPool`**:该返回一个用来在给定的延迟后运行任务或者定期执行任务的线程池。
+在 Java 中,创建线程池主要有两种方式:
-对应 `Executors` 工具类中的方法如图所示:
+**方式一:通过 `ThreadPoolExecutor` 构造函数直接创建 (推荐)**
-
+
+
+这是最推荐的方式,因为它允许开发者明确指定线程池的核心参数,对线程池的运行行为有更精细的控制,从而避免资源耗尽的风险。
+
+**方式二:通过 `Executors` 工具类创建 (不推荐用于生产环境)**
+
+`Executors`工具类提供的创建线程池的方法如下图所示:
+
+
+
+可以看出,通过`Executors`工具类可以创建多种类型的线程池,包括:
+
+- `FixedThreadPool`:固定线程数量的线程池。该线程池中的线程数量始终不变。当有一个新的任务提交时,线程池中若有空闲线程,则立即执行。若没有,则新的任务会被暂存在一个任务队列中,待有线程空闲时,便处理在任务队列中的任务。
+- `SingleThreadExecutor`: 只有一个线程的线程池。若多余一个任务被提交到该线程池,任务会被保存在一个任务队列中,待线程空闲,按先入先出的顺序执行队列中的任务。
+- `CachedThreadPool`: 可根据实际情况调整线程数量的线程池。线程池的线程数量不确定,但若有空闲线程可以复用,则会优先使用可复用的线程。若所有线程均在工作,又有新的任务提交,则会创建新的线程处理任务。所有线程在当前任务执行完毕后,将返回线程池进行复用。
+- `ScheduledThreadPool`:给定的延迟后运行任务或者定期执行任务的线程池。
《阿里巴巴 Java 开发手册》强制线程池不允许使用 `Executors` 去创建,而是通过 `ThreadPoolExecutor` 构造函数的方式,这样的处理方式让写的同学更加明确线程池的运行规则,规避资源耗尽的风险
`Executors` 返回线程池对象的弊端如下(后文会详细介绍到):
-- **`FixedThreadPool` 和 `SingleThreadExecutor`**:使用的是无界的 `LinkedBlockingQueue`,任务队列最大长度为 `Integer.MAX_VALUE`,可能堆积大量的请求,从而导致 OOM。
-- **`CachedThreadPool`**:使用的是同步队列 `SynchronousQueue`, 允许创建的线程数量为 `Integer.MAX_VALUE` ,可能会创建大量线程,从而导致 OOM。
-- **`ScheduledThreadPool` 和 `SingleThreadScheduledExecutor`** : 使用的无界的延迟阻塞队列`DelayedWorkQueue`,任务队列最大长度为 `Integer.MAX_VALUE`,可能堆积大量的请求,从而导致 OOM。
+- `FixedThreadPool` 和 `SingleThreadExecutor`:使用的是阻塞队列 `LinkedBlockingQueue`,任务队列最大长度为 `Integer.MAX_VALUE`,可以看作是无界的,可能堆积大量的请求,从而导致 OOM。
+- `CachedThreadPool`:使用的是同步队列 `SynchronousQueue`, 允许创建的线程数量为 `Integer.MAX_VALUE` ,如果任务数量过多且执行速度较慢,可能会创建大量的线程,从而导致 OOM。
+- `ScheduledThreadPool` 和 `SingleThreadScheduledExecutor`:使用的无界的延迟阻塞队列`DelayedWorkQueue`,任务队列最大长度为 `Integer.MAX_VALUE`,可能堆积大量的请求,从而导致 OOM。
```java
-// 无界队列 LinkedBlockingQueue
public static ExecutorService newFixedThreadPool(int nThreads) {
-
+ // LinkedBlockingQueue 的默认长度为 Integer.MAX_VALUE,可以看作是无界的
return new ThreadPoolExecutor(nThreads, nThreads,0L, TimeUnit.MILLISECONDS,new LinkedBlockingQueue());
}
-// 无界队列 LinkedBlockingQueue
public static ExecutorService newSingleThreadExecutor() {
-
+ // LinkedBlockingQueue 的默认长度为 Integer.MAX_VALUE,可以看作是无界的
return new FinalizableDelegatedExecutorService (new ThreadPoolExecutor(1, 1,0L, TimeUnit.MILLISECONDS,new LinkedBlockingQueue()));
}
@@ -217,9 +278,9 @@ public ScheduledThreadPoolExecutor(int corePoolSize) {
我们上面讲解了 `Executor`框架以及 `ThreadPoolExecutor` 类,下面让我们实战一下,来通过写一个 `ThreadPoolExecutor` 的小 Demo 来回顾上面的内容。
-### ThreadPoolExecutor 示例代码
+### 线程池示例代码
-首先创建一个 `Runnable` 接口的实现类(当然也可以是 `Callable` 接口,我们上面也说了两者的区别。)
+首先创建一个 `Runnable` 接口的实现类(当然也可以是 `Callable` 接口,我们后面会介绍两者的区别。)
`MyRunnable.java`
@@ -311,7 +372,7 @@ public class ThreadPoolExecutorDemo {
- `keepAliveTime` : 等待时间为 1L。
- `unit`: 等待时间的单位为 TimeUnit.SECONDS。
- `workQueue`:任务队列为 `ArrayBlockingQueue`,并且容量为 100;
-- `handler`:饱和策略为 `CallerRunsPolicy`。
+- `handler`:拒绝策略为 `CallerRunsPolicy`。
**输出结构**:
@@ -399,7 +460,7 @@ Finished all threads // 任务全部执行完了才会跳出来,因为executo
1. 如果当前运行的线程数小于核心线程数,那么就会新建一个线程来执行任务。
2. 如果当前运行的线程数等于或大于核心线程数,但是小于最大线程数,那么就把该任务放入到任务队列里等待执行。
3. 如果向任务队列投放任务失败(任务队列已经满了),但是当前运行的线程数是小于最大线程数的,就新建一个线程来执行任务。
-4. 如果当前运行的线程数已经等同于最大线程数了,新建线程将会使当前运行的线程超出最大线程数,那么当前任务会被拒绝,饱和策略会调用`RejectedExecutionHandler.rejectedExecution()`方法。
+4. 如果当前运行的线程数已经等同于最大线程数了,新建线程将会使当前运行的线程超出最大线程数,那么当前任务会被拒绝,拒绝策略会调用`RejectedExecutionHandler.rejectedExecution()`方法。

@@ -508,7 +569,7 @@ Finished all threads // 任务全部执行完了才会跳出来,因为executo
}
```
-更多关于线程池源码分析的内容推荐这篇文章:硬核干货:[4W 字从源码上分析 JUC 线程池 ThreadPoolExecutor 的实现原理](https://www.throwx.cn/2020/08/23/java-concurrency-thread-pool-executor/)
+更多关于线程池源码分析的内容推荐这篇文章:硬核干货:[4W 字从源码上分析 JUC 线程池 ThreadPoolExecutor 的实现原理](https://www.cnblogs.com/throwable/p/13574306.html)。
现在,让我们在回到示例代码, 现在应该是不是很容易就可以搞懂它的原理了呢?
@@ -520,7 +581,7 @@ Finished all threads // 任务全部执行完了才会跳出来,因为executo
#### `Runnable` vs `Callable`
-`Runnable`自 Java 1.0 以来一直存在,但`Callable`仅在 Java 1.5 中引入,目的就是为了来处理`Runnable`不支持的用例。**`Runnable` 接口**不会返回结果或抛出检查异常,但是 **`Callable` 接口**可以。所以,如果任务不需要返回结果或抛出异常推荐使用 **`Runnable` 接口**,这样代码看起来会更加简洁。
+`Runnable`自 Java 1.0 以来一直存在,但`Callable`仅在 Java 1.5 中引入,目的就是为了来处理`Runnable`不支持的用例。`Runnable` 接口不会返回结果或抛出检查异常,但是 `Callable` 接口可以。所以,如果任务不需要返回结果或抛出异常推荐使用 `Runnable` 接口,这样代码看起来会更加简洁。
工具类 `Executors` 可以实现将 `Runnable` 对象转换成 `Callable` 对象。(`Executors.callable(Runnable task)` 或 `Executors.callable(Runnable task, Object result)`)。
@@ -553,14 +614,15 @@ public interface Callable {
#### `execute()` vs `submit()`
-- `execute()`方法用于提交不需要返回值的任务,所以无法判断任务是否被线程池执行成功与否;
-- `submit()`方法用于提交需要返回值的任务。线程池会返回一个 `Future` 类型的对象,通过这个 `Future` 对象可以判断任务是否执行成功,并且可以通过 `Future` 的 `get()`方法来获取返回值,`get()`方法会阻塞当前线程直到任务完成,而使用 `get(long timeout,TimeUnit unit)`方法的话,如果在 `timeout` 时间内任务还没有执行完,就会抛出 `java.util.concurrent.TimeoutException`。
+`execute()` 和 `submit()`是两种提交任务到线程池的方法,有一些区别:
-这里只是为了演示使用,推荐使用 `ThreadPoolExecutor` 构造方法来创建线程池。
+- **返回值**:`execute()` 方法用于提交不需要返回值的任务。通常用于执行 `Runnable` 任务,无法判断任务是否被线程池成功执行。`submit()` 方法用于提交需要返回值的任务。可以提交 `Runnable` 或 `Callable` 任务。`submit()` 方法返回一个 `Future` 对象,通过这个 `Future` 对象可以判断任务是否执行成功,并获取任务的返回值(`get()`方法会阻塞当前线程直到任务完成, `get(long timeout,TimeUnit unit)`多了一个超时时间,如果在 `timeout` 时间内任务还没有执行完,就会抛出 `java.util.concurrent.TimeoutException`)。
+- **异常处理**:在使用 `submit()` 方法时,可以通过 `Future` 对象处理任务执行过程中抛出的异常;而在使用 `execute()` 方法时,异常处理需要通过自定义的 `ThreadFactory` (在线程工厂创建线程的时候设置`UncaughtExceptionHandler`对象来 处理异常)或 `ThreadPoolExecutor` 的 `afterExecute()` 方法来处理
示例 1:使用 `get()`方法获取返回值。
```java
+// 这里只是为了演示使用,推荐使用 `ThreadPoolExecutor` 构造方法来创建线程池。
ExecutorService executorService = Executors.newFixedThreadPool(3);
Future submit = executorService.submit(() -> {
@@ -718,7 +780,7 @@ Exception in thread "main" java.util.concurrent.TimeoutException
#### 为什么不推荐使用`SingleThreadExecutor`?
-`SingleThreadExecutor` 和 `FixedThreadPool` 一样,使用的都是容量为 `Integer.MAX_VALUE` 的 `LinkedBlockingQueue`(无界队列)作为线程池的工作队列。`SingleThreadExecutor` 使用无界队列作为线程池的工作队列会对线程池带来的影响与 `FixedThreadPool` 相同。说简单点,就是可能会导致 OOM。
+`SingleThreadExecutor` 和 `FixedThreadPool` 一样,使用的都是容量为 `Integer.MAX_VALUE` 的 `LinkedBlockingQueue`(无界队列)。`SingleThreadExecutor` 使用无界队列作为线程池的工作队列会对线程池带来的影响与 `FixedThreadPool` 相同。说简单点,就是可能会导致 OOM。
### CachedThreadPool
@@ -747,7 +809,7 @@ Exception in thread "main" java.util.concurrent.TimeoutException
}
```
-`CachedThreadPool` 的`corePoolSize` 被设置为空(0),`maximumPoolSize`被设置为 `Integer.MAX.VALUE`,即它是无界的,这也就意味着如果主线程提交任务的速度高于 `maximumPool` 中线程处理任务的速度时,`CachedThreadPool` 会不断创建新的线程。极端情况下,这样会导致耗尽 cpu 和内存资源。
+`CachedThreadPool` 的`corePoolSize` 被设置为空(0),`maximumPoolSize`被设置为 `Integer.MAX_VALUE`,即它是无界的,这也就意味着如果主线程提交任务的速度高于 `maximumPool` 中线程处理任务的速度时,`CachedThreadPool` 会不断创建新的线程。极端情况下,这样会导致耗尽 cpu 和内存资源。
#### 执行任务过程介绍
diff --git a/docs/java/concurrent/jmm.md b/docs/java/concurrent/jmm.md
index bdba79d816f..578381714cf 100644
--- a/docs/java/concurrent/jmm.md
+++ b/docs/java/concurrent/jmm.md
@@ -1,20 +1,20 @@
---
title: JMM(Java 内存模型)详解
+description: 深入解析Java内存模型JMM:详解CPU缓存模型、指令重排序机制、happens-before原则、内存可见性保证,理解多线程并发编程的底层规范。
category: Java
tag:
- Java并发
head:
- - meta
- name: keywords
- content: CPU 缓存模型,指令重排序,Java 内存模型(JMM),happens-before
- - - meta
- - name: description
- content: 对于 Java 来说,你可以把 JMM 看作是 Java 定义的并发编程相关的一组规范,除了抽象了线程和主内存之间的关系之外,其还规定了从 Java 源代码到 CPU 可执行指令的这个转化过程要遵守哪些和并发相关的原则和规范,其主要目的是为了简化多线程编程,增强程序可移植性的。
+ content: JMM,Java内存模型,CPU缓存,指令重排序,happens-before,内存可见性,并发编程模型
---
-JMM(Java 内存模型)主要定义了对于一个共享变量,当另一个线程对这个共享变量执行写操作后,这个线程对这个共享变量的可见性。
+对于 Java 来说,你可以把 **JMM(Java 内存模型)** 看作是 Java 定义的并发编程相关的一组规范。除了抽象了线程和主内存之间的关系之外,其还规定了从 Java 源代码到 CPU 可执行指令的转化过程要遵守哪些并发相关的原则和规范。其主要目的是为了**简化多线程编程**,**增强程序的可移植性**。
+
+JMM 主要定义了对于一个共享变量,当一个线程执行写操作后,该变量对其他线程的**可见性**。
-要想理解透彻 JMM(Java 内存模型),我们先要从 **CPU 缓存模型和指令重排序** 说起!
+要想透彻理解 JMM,我们需要从 **CPU 缓存模型**和**指令重排序**说起。
## 从 CPU 缓存模型说起
@@ -61,9 +61,13 @@ Java 源代码会经历 **编译器优化重排 —> 指令并行重排 —> 内
**指令重排序可以保证串行语义一致,但是没有义务保证多线程间的语义也一致** ,所以在多线程下,指令重排序可能会导致一些问题。
-编译器和处理器的指令重排序的处理方式不一样。对于编译器,通过禁止特定类型的编译器重排序的方式来禁止重排序。对于处理器,通过插入内存屏障(Memory Barrier,或有时叫做内存栅栏,Memory Fence)的方式来禁止特定类型的处理器重排序。指令并行重排和内存系统重排都属于是处理器级别的指令重排序。
+对于编译器优化重排和处理器的指令重排序(指令并行重排和内存系统重排都属于是处理器级别的指令重排序),处理该问题的方式不一样。
+
+- 对于编译器,通过禁止特定类型的编译器重排序的方式来禁止重排序。
-> 内存屏障(Memory Barrier,或有时叫做内存栅栏,Memory Fence)是一种 CPU 指令,用来禁止处理器指令发生重排序(像屏障一样),从而保障指令执行的有序性。另外,为了达到屏障的效果,它也会使处理器写入、读取值之前,将主内存的值写入高速缓存,清空无效队列,从而保障变量的可见性。
+- 对于处理器,通过插入内存屏障(Memory Barrier,或有时叫做内存栅栏,Memory Fence)的方式来禁止特定类型的处理器重排序。
+
+> 内存屏障(Memory Barrier,或有时叫做内存栅栏,Memory Fence)是一种 CPU 指令,用来禁止处理器指令发生重排序(像屏障一样),从而保障指令执行的有序性。另外,为了达到屏障的效果,它会在处理器写入值时,强制将写缓冲区中的数据刷新到主内存;在读取值之前,使处理器本地缓存中的相关数据失效,强制从主内存中加载最新值,从而保障变量的可见性。
## JMM(Java Memory Model)
@@ -90,7 +94,7 @@ JMM 说白了就是定义了一些规范来解决这些问题,开发者可以
**什么是主内存?什么是本地内存?**
- **主内存**:所有线程创建的实例对象都存放在主内存中,不管该实例对象是成员变量,还是局部变量,类信息、常量、静态变量都是放在主内存中。为了获取更好的运行速度,虚拟机及硬件系统可能会让工作内存优先存储于寄存器和高速缓存中。
-- **本地内存**:每个线程都有一个私有的本地内存,本地内存存储了该线程以读 / 写共享变量的副本。每个线程只能操作自己本地内存中的变量,无法直接访问其他线程的本地内存。如果线程间需要通信,必须通过主内存来进行。本地内存是 JMM 抽象出来的一个概念,并不真实存在,它涵盖了缓存、写缓冲区、寄存器以及其他的硬件和编译器优化。
+- **本地内存**:每个线程都有一个私有的本地内存,本地内存存储了该线程已读 / 写共享变量的副本。每个线程只能操作自己本地内存中的变量,无法直接访问其他线程的本地内存。如果线程间需要通信,必须通过主内存来进行。本地内存是 JMM 抽象出来的一个概念,并不真实存在,它涵盖了缓存、写缓冲区、寄存器以及其他的硬件和编译器优化。
Java 内存模型的抽象示意图如下:
@@ -148,9 +152,9 @@ JSR 133 引入了 happens-before 这个概念来描述两个操作之间的内
- 为了对编译器和处理器的约束尽可能少,只要不改变程序的执行结果(单线程程序和正确执行的多线程程序),编译器和处理器怎么进行重排序优化都行。
- 对于会改变程序执行结果的重排序,JMM 要求编译器和处理器必须禁止这种重排序。
-下面这张是 《Java 并发编程的艺术》这本书中的一张 JMM 设计思想的示意图,非常清晰。
+下面这张是我根据 《Java 并发编程的艺术》这本书中的一张 JMM 设计思想示意图重新绘制的。
-
+
了解了 happens-before 原则的设计思想,我们再来看看 JSR-133 对 happens-before 原则的定义:
@@ -189,9 +193,15 @@ happens-before 的规则就 8 条,说多不多,重点了解下面列举的 5
### happens-before 和 JMM 什么关系?
-happens-before 与 JMM 的关系用《Java 并发编程的艺术》这本书中的一张图就可以非常好的解释清楚。
+happens-before 与 JMM 的关系如下图所示:
+
+
+
+- JMM 向程序员提供了 **“ happens-before 规则 ”**(如程序顺序规则、`volatile` 变量规则等)。这是一种 **“ 强内存模型 ”** 的假象:程序员不需要关心底层复杂的重排序细节,只需要按照这些规则编写代码,就能保证多线程下的内存可见性。
+- JVM 在执行时,会将 happens-before 规则映射到具体的实现上。为了在保证正确性的前提下不丧失性能,JMM 只会 **“ 禁止影响执行结果的重排序 ”**。对于不影响单线程执行结果的重排序,JMM 是允许的。
+- 最底层是编译器和处理器真实的 **“ 重排序规则 ”**。
-
+总结来说,JMM 就像是一个中间层:它向上通过 happens-before 为程序员提供简单的编程模型;向下通过禁止特定重排序,利用底层硬件性能。这种设计既保证了多线程的安全性,又最大限度释放了硬件的性能。
## 再看并发编程三个重要特性
diff --git a/docs/java/concurrent/optimistic-lock-and-pessimistic-lock.md b/docs/java/concurrent/optimistic-lock-and-pessimistic-lock.md
index 022a9458386..ebbb8537cd7 100644
--- a/docs/java/concurrent/optimistic-lock-and-pessimistic-lock.md
+++ b/docs/java/concurrent/optimistic-lock-and-pessimistic-lock.md
@@ -1,13 +1,16 @@
---
title: 乐观锁和悲观锁详解
+description: 乐观锁与悲观锁深度对比:详解synchronized/ReentrantLock悲观锁实现、CAS/版本号乐观锁机制、适用场景分析、性能对比与选型建议。
category: Java
tag:
- Java并发
+head:
+ - - meta
+ - name: keywords
+ content: 乐观锁,悲观锁,synchronized,ReentrantLock,CAS,版本号机制,并发控制,锁优化
---
-如果将悲观锁(Pessimistic Lock)和乐观锁(PessimisticLock 或 OptimisticLock)对应到现实生活中来。悲观锁有点像是一位比较悲观(也可以说是未雨绸缪)的人,总是会假设最坏的情况,避免出现问题。乐观锁有点像是一位比较乐观的人,总是会假设最好的情况,在要出现问题之前快速解决问题。
-
-在程序世界中,乐观锁和悲观锁的最终目的都是为了保证线程安全,避免在并发场景下的资源竞争问题。但是,相比于乐观锁,悲观锁对性能的影响更大!
+如果将悲观锁(Pessimistic Lock)和乐观锁(Optimistic Lock)对应到现实生活中来。悲观锁有点像是一位比较悲观(也可以说是未雨绸缪)的人,总是会假设最坏的情况,避免出现问题。乐观锁有点像是一位比较乐观的人,总是会假设最好的情况,在要出现问题之前快速解决问题。
## 什么是悲观锁?
@@ -31,34 +34,30 @@ try {
}
```
-高并发的场景下,激烈的锁竞争会造成线程阻塞,大量阻塞线程会导致系统的上下文切换,增加系统的性能开销。并且,悲观锁还可能会存在死锁问题,影响代码的正常运行。
+高并发的场景下,激烈的锁竞争会造成线程阻塞,大量阻塞线程会导致系统的上下文切换,增加系统的性能开销。并且,悲观锁还可能会存在死锁问题(线程获得锁的顺序不当时),影响代码的正常运行。
## 什么是乐观锁?
乐观锁总是假设最好的情况,认为共享资源每次被访问的时候不会出现问题,线程可以不停地执行,无需加锁也无需等待,只是在提交修改的时候去验证对应的资源(也就是数据)是否被其它线程修改了(具体方法可以使用版本号机制或 CAS 算法)。
-像 Java 中`java.util.concurrent.atomic`包下面的原子变量类(比如`AtomicInteger`、`LongAdder`)就是使用了乐观锁的一种实现方式 **CAS** 实现的。
-
-
+在 Java 中`java.util.concurrent.atomic`包下面的原子变量类(比如`AtomicInteger`、`LongAdder`)就是使用了乐观锁的一种实现方式 **CAS** 实现的。
+
```java
// LongAdder 在高并发场景下会比 AtomicInteger 和 AtomicLong 的性能更好
// 代价就是会消耗更多的内存空间(空间换时间)
-LongAdder longAdder = new LongAdder();
-// 自增
-longAdder.increment();
-// 获取结果
-longAdder.sum();
+LongAdder sum = new LongAdder();
+sum.increment();
```
-高并发的场景下,乐观锁相比悲观锁来说,不存在锁竞争造成线程阻塞,也不会有死锁的问题,在性能上往往会更胜一筹。但是,如果冲突频繁发生(写占比非常多的情况),会频繁失败和重试(悲观锁的开销是固定的),这样同样会非常影响性能,导致 CPU 飙升。
+高并发的场景下,乐观锁相比悲观锁来说,不存在锁竞争造成线程阻塞,也不会有死锁问题,在性能上往往会更胜一筹。但是,如果冲突频繁发生(写占比非常多的情况),会频繁失败并重试,这样同样会非常影响性能,导致 CPU 飙升。
不过,大量失败重试的问题也是可以解决的,像我们前面提到的 `LongAdder`以空间换时间的方式就解决了这个问题。
理论上来说:
-- 悲观锁通常多用于写比较多的情况下(多写场景,竞争激烈),这样可以避免频繁失败和重试影响性能,悲观锁的开销是固定的。不过,如果乐观锁解决了频繁失败和重试这个问题的话(比如`LongAdder`),也是可以考虑使用乐观锁的,要视实际情况而定。
-- 乐观锁通常多于写比较少的情况下(多读场景,竞争较少),这样可以避免频繁加锁影响性能。不过,乐观锁主要针对的对象是单个共享变量(参考`java.util.concurrent.atomic`包下面的原子变量类)。
+- 悲观锁通常多用于写比较多的情况(多写场景,竞争激烈),这样可以避免频繁失败和重试影响性能,悲观锁的开销是固定的。不过,如果乐观锁解决了频繁失败和重试这个问题的话(比如`LongAdder`),也是可以考虑使用乐观锁的,要视实际情况而定。
+- 乐观锁通常多用于写比较少的情况(多读场景,竞争较少),这样可以避免频繁加锁影响性能。不过,乐观锁主要针对的对象是单个共享变量(参考`java.util.concurrent.atomic`包下面的原子变量类)。
## 如何实现乐观锁?
@@ -73,7 +72,7 @@ longAdder.sum();
1. 操作员 A 此时将其读出( `version`=1 ),并从其帐户余额中扣除 $50( $100-\$50 )。
2. 在操作员 A 操作的过程中,操作员 B 也读入此用户信息( `version`=1 ),并从其帐户余额中扣除 $20 ( $100-\$20 )。
3. 操作员 A 完成了修改工作,将数据版本号( `version`=1 ),连同帐户扣除后余额( `balance`=\$50 ),提交至数据库更新,此时由于提交数据版本等于数据库记录当前版本,数据被更新,数据库记录 `version` 更新为 2 。
-4. 操作员 B 完成了操作,也将版本号( `version`=1 )试图向数据库提交数据( `balance`=\$80 ),但此时比对数据库记录版本时发现,操作员 B 提交的数据版本号为 1 ,数据库记录当前版本也为 2 ,不满足 “ 提交版本必须等于当前版本才能执行更新 “ 的乐观锁策略,因此,操作员 B 的提交被驳回。
+4. 操作员 B 完成了操作,也将版本号( `version`=1 )试图向数据库提交数据( `balance`=\$80 ),但此时比对数据库记录版本时发现,操作员 B 提交的数据版本号为 1 ,而数据库记录当前版本为 2 ,不满足 “ 提交版本必须等于当前版本才能执行更新 “ 的乐观锁策略,因此,操作员 B 的提交被驳回。
这样就避免了操作员 B 用基于 `version`=1 的旧数据修改的结果覆盖操作员 A 的操作结果的可能。
@@ -100,77 +99,21 @@ CAS 涉及到三个操作数:
当多个线程同时使用 CAS 操作一个变量时,只有一个会胜出,并成功更新,其余均会失败,但失败的线程并不会被挂起,仅是被告知失败,并且允许再次尝试,当然也允许失败的线程放弃操作。
-Java 语言并没有直接实现 CAS,CAS 相关的实现是通过 C++ 内联汇编的形式实现的(JNI 调用)。因此, CAS 的具体实现和操作系统以及 CPU 都有关系。
-
-`sun.misc`包下的`Unsafe`类提供了`compareAndSwapObject`、`compareAndSwapInt`、`compareAndSwapLong`方法来实现的对`Object`、`int`、`long`类型的 CAS 操作
-
-```java
-/**
- * CAS
- * @param o 包含要修改field的对象
- * @param offset 对象中某field的偏移量
- * @param expected 期望值
- * @param update 更新值
- * @return true | false
- */
-public final native boolean compareAndSwapObject(Object o, long offset, Object expected, Object update);
-
-public final native boolean compareAndSwapInt(Object o, long offset, int expected,int update);
-
-public final native boolean compareAndSwapLong(Object o, long offset, long expected, long update);
-```
-
-关于 `Unsafe` 类的详细介绍可以看这篇文章:[Java 魔法类 Unsafe 详解 - JavaGuide - 2022](https://javaguide.cn/java/basis/unsafe.html) 。
-
-## 乐观锁存在哪些问题?
-
-ABA 问题是乐观锁最常见的问题。
-
-### ABA 问题
-
-如果一个变量 V 初次读取的时候是 A 值,并且在准备赋值的时候检查到它仍然是 A 值,那我们就能说明它的值没有被其他线程修改过了吗?很明显是不能的,因为在这段时间它的值可能被改为其他值,然后又改回 A,那 CAS 操作就会误认为它从来没有被修改过。这个问题被称为 CAS 操作的 **"ABA"问题。**
+关于 CAS 的进一步介绍,可以阅读读者写的这篇文章:[CAS 详解](./cas.md),其中详细提到了 Java 中 CAS 的实现以及 CAS 存在的一些问题。
-ABA 问题的解决思路是在变量前面追加上**版本号或者时间戳**。JDK 1.5 以后的 `AtomicStampedReference` 类就是用来解决 ABA 问题的,其中的 `compareAndSet()` 方法就是首先检查当前引用是否等于预期引用,并且当前标志是否等于预期标志,如果全部相等,则以原子方式将该引用和该标志的值设置为给定的更新值。
-
-```java
-public boolean compareAndSet(V expectedReference,
- V newReference,
- int expectedStamp,
- int newStamp) {
- Pair current = pair;
- return
- expectedReference == current.reference &&
- expectedStamp == current.stamp &&
- ((newReference == current.reference &&
- newStamp == current.stamp) ||
- casPair(current, Pair.of(newReference, newStamp)));
-}
-```
-
-### 循环时间长开销大
-
-CAS 经常会用到自旋操作来进行重试,也就是不成功就一直循环执行直到成功。如果长时间不成功,会给 CPU 带来非常大的执行开销。
-
-如果 JVM 能支持处理器提供的 pause 指令那么效率会有一定的提升,pause 指令有两个作用:
-
-1. 可以延迟流水线执行指令,使 CPU 不会消耗过多的执行资源,延迟的时间取决于具体实现的版本,在一些处理器上延迟时间是零。
-2. 可以避免在退出循环的时候因内存顺序冲突而引起 CPU 流水线被清空,从而提高 CPU 的执行效率。
-
-### 只能保证一个共享变量的原子操作
+## 总结
-CAS 只对单个共享变量有效,当操作涉及跨多个共享变量时 CAS 无效。但是从 JDK 1.5 开始,提供了`AtomicReference`类来保证引用对象之间的原子性,你可以把多个变量放在一个对象里来进行 CAS 操作.所以我们可以使用锁或者利用`AtomicReference`类把多个共享变量合并成一个共享变量来操作。
+本文详细介绍了乐观锁和悲观锁的概念以及乐观锁常见实现方式:
-## 总结
+- 悲观锁基于悲观的假设,认为共享资源在每次访问时都会发生冲突,因此在每次操作时都会加锁。这种锁机制会导致其他线程阻塞,直到锁被释放。Java 中的 `synchronized` 和 `ReentrantLock` 是悲观锁的典型实现方式。虽然悲观锁能有效避免数据竞争,但在高并发场景下会导致线程阻塞、上下文切换频繁,从而影响系统性能,并且还可能引发死锁问题。
+- 乐观锁基于乐观的假设,认为共享资源在每次访问时不会发生冲突,因此无须加锁,只需在提交修改时验证数据是否被其他线程修改。Java 中的 `AtomicInteger` 和 `LongAdder` 等类通过 CAS(Compare-And-Swap)算法实现了乐观锁。乐观锁避免了线程阻塞和死锁问题,在读多写少的场景中性能优越。但在写操作频繁的情况下,可能会导致大量重试和失败,从而影响性能。
+- 乐观锁主要通过版本号机制或 CAS 算法实现。版本号机制通过比较版本号确保数据一致性,而 CAS 通过硬件指令实现原子操作,直接比较和交换变量值。
-- 高并发的场景下,激烈的锁竞争会造成线程阻塞,大量阻塞线程会导致系统的上下文切换,增加系统的性能开销。并且,悲观锁还可能会存在死锁问题,影响代码的正常运行。乐观锁相比悲观锁来说,不存在锁竞争造成线程阻塞,也不会有死锁的问题,在性能上往往会更胜一筹。不过,如果冲突频繁发生(写占比非常多的情况),会频繁失败和重试,这样同样会非常影响性能,导致 CPU 飙升。
-- 乐观锁一般会使用版本号机制或 CAS 算法实现,CAS 算法相对来说更多一些,这里需要格外注意。
-- CAS 的全称是 **Compare And Swap(比较与交换)** ,用于实现乐观锁,被广泛应用于各大框架中。CAS 的思想很简单,就是用一个预期值和要更新的变量值进行比较,两值相等才会进行更新。
-- 乐观锁的问题:ABA 问题、循环时间长开销大、只能保证一个共享变量的原子操作。
+悲观锁和乐观锁各有优缺点,适用于不同的应用场景。在实际开发中,选择合适的锁机制能够有效提升系统的并发性能和稳定性。
## 参考
- 《Java 并发编程核心 78 讲》
- 通俗易懂 悲观锁、乐观锁、可重入锁、自旋锁、偏向锁、轻量/重量级锁、读写锁、各种锁及其 Java 实现!:
-- 一文彻底搞懂 CAS 实现原理 & 深入到 CPU 指令: