面试八股3.0
介绍JVM堆的分代机制
Java 堆的分代机制 (Generational Collection) 是 JVM 垃圾回收(GC)性能优化的核心策略。
其核心理论基础是 “弱分代假说” (Weak Generational Hypothesis),即:
绝大多数对象都是朝生夕死的(存活时间很短),而剩下的对象则往往能存活很久。
基于这个假设,JVM 将堆内存划分为新生代 (Young Generation) 和 老年代 (Old Generation),针对不同区域的特点使用不同的垃圾回收算法,从而提高 GC 效率。
一、 堆内存的区域划分
在经典的垃圾收集器(如 Serial, ParNew, CMS, Parallel Scavenge)中,堆内存主要分为两大部分:
1. 新生代 (Young Generation)
-
占比: 通常占堆内存的 1/3。
-
特点: 对象生命周期极短,频繁发生 GC。
-
内部结构: 进一步划分为三个区域(默认比例 8:1:1):
-
Eden 区 (80%): 对象最初分配的地方。
-
Survivor 0 区 (From, 10%): 上一次 GC 的幸存者。
-
Survivor 1 区 (To, 10%): 垃圾回收时的复制目标。
-
-
GC 方式: Minor GC (Young GC)。使用复制算法(效率高,无碎片,但浪费 10% 空间)。
2. 老年代 (Old Generation)
-
占比: 通常占堆内存的 2/3。
-
特点: 存放生命周期长的对象(如连接池、Spring Bean、缓存数据)。
-
GC 方式: Major GC 或 Full GC。使用标记-清除或标记-整理算法(速度比 Minor GC 慢 10 倍以上)。
(注:JDK 8 之后,永久代 PermGen 被移除,元空间 Metaspace 存储类信息,它使用的是本地内存,不属于堆。)
二、 对象的生命周期流转 (核心流程)
对象在分代机制中的流转过程通常如下:
1. 初始分配 (Allocation)
-
绝大多数新对象在 Eden 区 分配。
-
例外: 如果对象非常大(超过
-XX:PretenureSizeThreshold),直接进入老年代,避免在 Survivor 区之间发生大量的内存复制。
2. Minor GC (Eden 满了)
-
当 Eden 区满时,触发 Minor GC。
-
存活判断: JVM 检查 Eden 和正在使用的 Survivor (From) 区中的对象。
-
复制: 将存活的对象复制到空的 Survivor (To) 区。
-
清理: 清空 Eden 和 From 区。
-
交换: From 和 To 身份互换。
3. 年龄增长 (Aging)
- 对象每经过一次 Minor GC 且存活下来,其年龄(Age)就 +1(记录在对象头中)。
4. 晋升老年代 (Promotion)
对象进入老年代通常有以下几种情况:
-
长期存活: 年龄达到阈值(默认 15,可以通过
-XX:MaxTenuringThreshold设置)。 -
大对象直接进入: 如前所述,超大数组或字符串。
-
动态年龄判断: 如果 Survivor 空间中,相同年龄所有对象的大小总和 > Survivor 空间的一半,则年龄大于或等于该年龄的对象直接进入老年代,无需等到阈值。
-
空间担保失败: Minor GC 后,存活对象太多,Survivor 区放不下,借用老年代空间存储。
三、 为什么需要分代? (作用)
如果不分代,每次 GC 都必须扫描整个堆(几个 GB 甚至几十 GB),效率极低。
-
提升效率:
-
新生代: 只有少量对象存活,使用复制算法,只需处理少量存活对象,速度极快。
-
老年代: 对象变动少,不需要频繁回收,减少了全堆扫描的次数。
-
-
减少 STW (Stop-The-World):
-
频繁发生的 Minor GC 耗时非常短,对用户体验影响小。
-
耗时长的 Full GC 频率被尽可能降低。
-
四、 常见参数总结 (面试常考)
| 参数 | 含义 | 示例 |
|---|---|---|
-Xms / -Xmx |
堆的初始大小 / 最大大小 | -Xms2g -Xmx2g (通常设为一样避免抖动) |
-Xmn |
新生代大小 | -Xmn1g |
-XX:NewRatio |
老年代与新生代的比例 | 2 (表示 老年代:新生代 = 2:1) |
-XX:SurvivorRatio |
Eden 与 Survivor 的比例 | 8 (表示 Eden:S0:S1 = 8:1:1) |
-XX:MaxTenuringThreshold |
晋升老年代的年龄阈值 | 15 |
总结
分代机制本质上是空间换时间和算法分治的思想。
-
Eden/Survivor: 用空间(浪费 10%)换取极快的分配和回收速度(复制算法)。
-
Old: 用复杂的标记算法处理存活率高的对象。
介绍JVM 内存结构
核心架构概览
JVM 在运行 Java 程序时,会把内存划分为以下 5 个主要区域:
1. 线程私有(Thread Local) -> 随着线程创建而创建,线程结束而销毁。无需 GC。
- 程序计数器 (Program Counter Register)
- 虚拟机栈 (Java Virtual Machine Stack)
- 本地方法栈 (Native Method Stack)
2. 线程共享(Shared) -> 随虚拟机启动而创建。是 GC 的重点区域。
- 堆 (Heap)
- 方法区 (Method Area)
一、 线程私有区域 (Thread Local)
这部分内存是"私有"的,线程之间互不干扰,所以不存在线程安全问题。
1. 程序计数器 (PC Register)
-
作用: 记录当前线程执行到哪一行字节码指令。
- 如果执行的是 Java 方法,记录的是指令地址。
- 如果执行的是 Native 方法,这里是
Undefined。
-
特点:
- 内存空间极小。
- 唯一一个在 JVM 规范中没有规定任何
OutOfMemoryError(OOM) 情况的区域。 - 作用类似 CPU 的寄存器,用于线程切换后恢复执行位置。
2. 虚拟机栈 (JVM Stack) - 最常说的"栈"
-
作用: 描述 Java 方法执行的内存模型。
-
栈帧 (Stack Frame): 每个方法被执行的时候,都会创建一个"栈帧"压入栈中。栈帧包含:
- 局部变量表: 存方法参数和内部定义的局部变量(int, boolean, 对象引用等)。
- 操作数栈: 类似于计算器的草稿纸,用于计算过程中的数据暂存和交换。
- 动态链接: 指向方法区中该方法的引用。
- 方法返回地址: 方法正常或异常退出的位置。
-
异常:
StackOverflowError:递归过深,栈帧太多,撑爆了栈深度。OutOfMemoryError:如果栈可以动态扩展,但申请不到足够内存时抛出。
3. 本地方法栈 (Native Method Stack)
-
作用: 与虚拟机栈类似,区别在于它是为 Native 方法(使用 C/C++ 编写的底层方法)服务的。
-
注:在 HotSpot 虚拟机中,本地方法栈和虚拟机栈是合二为一的。
二、 线程共享区域 (Shared)
这部分是多线程共享的,需要考虑线程安全问题,也是垃圾回收 (GC) 的主战场。
1. 堆 (Heap)
-
作用: JVM 内存中最大的一块。存放几乎所有的对象实例和数组。
-
细分: 新生代 (Eden, S0, S1) 和 老年代。
-
TLAB (Thread Local Allocation Buffer): 虽然堆是共享的,但为了提升对象分配效率,JVM 在 Eden 区为每个线程划分了一小块私有分配区(TLAB),避免多线程竞争锁。
-
异常:
OutOfMemoryError: Java heap space(堆内存溢出)。
2. 方法区 (Method Area)
-
作用: 存储已被虚拟机加载的类信息 (Class)、常量、静态变量 (static)、即时编译器 (JIT) 编译后的代码缓存等。
-
演进 (面试重点):
- JDK 1.7 及之前: 实现为 永久代 (PermGen)。它在 JVM 内存中,受限于 JVM 大小,容易出现 OOM。
- JDK 1.8 及之后: 永久代被移除,取而代之的是 元空间 (Metaspace)。
- 区别: 元空间不在虚拟机内存中,而是使用本地内存 (Native Memory)。这意味着它只受限于机器的物理内存大小,大大降低了 OOM 的风险。
-
运行时常量池 (Runtime Constant Pool): 是方法区的一部分。用于存放编译期生成的各种字面量和符号引用。
三、 堆外内存 (直接内存 Direct Memory)
这不是 JVM 运行时数据区的一部分,但也非常重要。
-
作用: NIO 类库引入了一种基于通道与缓冲区的 I/O 方式,可以使用 Native 函数库直接分配堆外内存。
-
优势: 避免了在 Java 堆和 Native 堆之间来回复制数据(零拷贝技术),显著提高 I/O 性能。
-
异常: 虽然不受堆大小限制,但受本机总内存限制,也会抛出
OutOfMemoryError。
四、 总结对比表
| 区域 | 线程归属 | 存储内容 | 抛出异常 | 生命周期 |
|---|---|---|---|---|
| 程序计数器 | 私有 | 指令地址 | 无 | 随线程 |
| 虚拟机栈 | 私有 | 局部变量、操作数栈 | StackOverflow / OOM | 随线程 |
| 本地方法栈 | 私有 | Native 方法信息 | StackOverflow / OOM | 随线程 |
| 堆 | 共享 | 对象实例、数组 | OOM | 随进程 (JVM) |
| 方法区 | 共享 | 类信息、常量、静态变量 | OOM (Metaspace OOM) | 随进程 (JVM) |
介绍GC标记算法
在垃圾回收(GC)的世界里,“标记"其实包含两个层面的含义:
- 判定标准: 怎么知道哪个对象是垃圾?(死活判定)
- 执行手段: 找出垃圾后,用什么算法去清理?(垃圾收集算法)
第一步:判定对象死活(怎么标记?)
在 Java (HotSpot) 中,主要使用的是 可达性分析算法,而不是引用计数法。
1. 引用计数法 (Reference Counting)
-
原理: 给对象添加一个引用计数器。每当有一个地方引用它,计数器 +1;引用失效,计数器 -1。为 0 则可回收。
-
优点: 简单,判定效率高。
-
缺点 (致命): 无法解决"循环引用"问题。
- 例子: 对象 A 引用 B,对象 B 引用 A,除此之外没人引用它们。它们的计数器都是 1,永远无法归零,导致内存泄漏。
-
现状: Java 虚拟机(HotSpot)不使用此算法(Python 和 C++ 智能指针在使用)。
2. 可达性分析算法 (Reachability Analysis) —— Java 的标准
-
原理: 通过一系列称为 “GC Roots” 的根对象作为起始节点集,从这些节点开始向下搜索。
- 凡是能从 GC Roots “顺藤摸瓜” 搜索到的对象,都是存活的(标记为"可达”)。
- 凡是搜索不到的对象,就是垃圾(不可达)。
-
什么是 GC Roots? (面试必考)
可以作为 GC Roots 的对象主要包括以下 4 类:
- 虚拟机栈(栈帧中的局部变量表) 中引用的对象。
- 方法区中类静态属性 引用的对象。(
static User u = ...) - 方法区中常量 引用的对象。(
static final User u = ...) - 本地方法栈中 JNI (Native 方法) 引用的对象。
第二步:执行垃圾收集(标记完怎么清理?)
当 GC 通过可达性分析标记出了哪些是活的、哪些是死的之后,就需要具体的算法来进行内存回收。常见的有以下三种:
1. 标记-清除算法 (Mark-Sweep)
这是最基础的算法。
-
过程:
- 标记: 把所有活动对象标记出来。
- 清除: 统一回收未被标记的对象。
-
优点: 简单,不需要移动对象。
-
缺点: 内存碎片化: 清除后会产生大量不连续的内存碎片。
-
适用场景: 老年代(CMS 收集器就是基于此)。
2. 标记-复制算法 (Mark-Copy)
为了解决碎片问题而生。
-
过程:
- 将内存分为大小相等的两块(A 和 B),每次只使用其中一块。
- 标记 A 中的存活对象。
- 复制 将 A 中所有存活的对象,整齐地复制到 B 中。
- 清理 一次性清空 A。
-
优点: 没有内存碎片,分配内存简单(指针碰撞)。
-
缺点: 空间浪费(内存利用率只有 50%)。
-
优化: 在 HotSpot 的新生代中,使用
Eden : Survivor : Survivor = 8 : 1 : 1的比例,只浪费 10% 的空间。 -
适用场景: 新生代(存活率低,复制成本低)。
3. 标记-整理算法 (Mark-Compact)
结合了前两者的优点。
-
过程:
- 标记 存活对象。
- 整理 让所有存活的对象向内存的一端移动(滑动整理)。
- 清理 直接清理掉边界以外的内存。
-
优点: 没有内存碎片,也不浪费空间。
-
缺点: 移动对象需要更新引用地址,成本较高,且需要暂停用户线程 (STW)。
-
适用场景: 老年代(对象存活率高,不适合复制)。
第三步:进阶 —— 三色标记法 (Tri-color Marking)
如果面试官问及"并发标记"或者 CMS/G1 的底层原理,这个是必杀技。
为了让 GC 线程和用户线程能并发运行,JVM 引入了三色标记法来描述标记过程中的状态:
- 白色: 尚未访问过。(如果在分析结束时仍为白色,代表是垃圾)
- 黑色: 自己和成员变量都已访问过。(肯定是活的对象,且扫描完毕)
- 灰色: 自己访问过,但成员变量还没访问完。(中间状态,待扫描)
并发标记的问题: 在并发过程中,用户线程可能会切断"灰色"到"白色"的引用,同时建立"黑色"到"白色"的引用。这会导致漏标。
- 解决方案:
- CMS 使用 增量更新 (Incremental Update)。
- G1 使用 原始快照 (SATB)。
总结对比表
| 算法 | 优点 | 缺点 | 适用区域 | 代表收集器 |
|---|---|---|---|---|
| 标记-清除 | 简单 | 内存碎片严重 | 老年代 | CMS |
| 标记-复制 | 无碎片,运行快 | 浪费空间 (需 Survivor) | 新生代 | Serial, ParNew, G1 (Young) |
| 标记-整理 | 无碎片,无空间浪费 | 移动对象效率低,需 STW | 老年代 | Parallel Old, Serial Old, G1 (Mixed) |
讲解常见的垃圾回收器
CMS (Concurrent Mark Sweep) —— 里程碑式产品
-
核心特点: 并发收集、低停顿。
-
工作流程 (重点):
- 初始标记 (Initial Mark): STW。只标记 GC Roots 直接关联的对象(速度很快)。
- 并发标记 (Concurrent Mark): 不 STW。GC 线程和用户线程一起跑,遍历整个对象图。
- 重新标记 (Remark): STW。修正并发期间因用户程序变动而导致标记产生变动的那一部分对象。
- 并发清除 (Concurrent Sweep): 不 STW。清理垃圾。
-
缺点 (面试必问):
- CPU 敏感: 占用线程资源,导致程序变慢。
- 浮动垃圾: 并发清理阶段产生的新垃圾无法在当次处理。
- 内存碎片: 使用标记-清除算法,容易产生碎片,导致大对象分配困难,触发 Full GC。
-
现状: JDK 9 标记废弃,JDK 14 正式移除。
G1 (Garbage First) —— JDK 9 之后的默认王者
-
核心理念: 化整为零。
-
内存布局: 不再有物理隔离的新生代和老年代。它把堆内存切分成很多个大小相等的 Region (区域)。这些 Region 逻辑上可以是 Eden、Survivor 或 Old。
-
算法: 整体看是标记-整理,局部(两个 Region 之间)看是标记-复制。不会产生内存碎片。
-
最大优势: 可预测的停顿模型。
- 你可以告诉 G1:“即使堆很大,但我要求 GC 停顿时间不要超过 200ms”。G1 会分析哪个 Region 垃圾最多(回收收益最大),优先回收它(这也是 Garbage First 名字的由来)。
-
适用场景: 面向服务端应用,配备大内存(6GB - 8GB 以上),替代 CMS。
介绍Java的线程池
Java 线程池(Thread Pool)是 Java 并发编程中的核心组件,主要用于管理线程的生命周期、减少资源消耗并提高系统响应速度。
一、为什么要用线程池?
如果不使用线程池,每当有新任务时手动创建线程(new Thread()),会带来以下问题:
- 资源消耗大: 频繁创建和销毁线程需要消耗大量系统资源(CPU、内存)。
- 响应速度慢: 创建线程本身需要时间,增加了任务处理的延迟。
- 难以管理: 无限制创建线程可能导致 CPU 过载或 OOM(内存溢出)。
线程池的好处:
- 降低资源消耗: 重复利用已创建的线程。
- 提高响应速度: 任务到达时无需等待线程创建即可立即执行。
- 提高可管理性: 可以统一分配、调优和监控线程。
二、核心类:ThreadPoolExecutor
Java 线程池的核心实现类是 java.util.concurrent.ThreadPoolExecutor。构造函数包含 7 个核心参数(面试和实战的重点):
|
|
参数详解:
| 参数 | 说明 |
|---|---|
corePoolSize |
核心线程数,线程池中长驻的线程数量 |
workQueue |
任务队列,核心线程都在忙时,新任务暂存于此 |
maximumPoolSize |
最大线程数,队列满了且线程数 < 最大线程数时,创建非核心线程 |
keepAliveTime |
空闲线程存活时间,超过 corePoolSize 的线程空闲超时后被回收 |
unit |
keepAliveTime 的时间单位 |
threadFactory |
线程工厂,用于创建新线程(可自定义线程名) |
handler |
拒绝策略,队列满且线程数达到最大值时触发 |
三、线程池的工作流程(重要)
当一个新任务提交给线程池时,处理逻辑如下:
- 判断核心线程: 如果当前运行的线程数 <
corePoolSize,则立即创建新线程执行任务。 - 进入队列: 如果核心线程都在忙(>= corePoolSize),则将任务放入
workQueue等待。 - 创建非核心线程: 如果队列也满了,判断当前线程数是否 <
maximumPoolSize。如果是,则创建新线程执行任务。 - 触发拒绝策略: 如果队列满了,且线程数已达到
maximumPoolSize,则调用handler执行拒绝策略。
简记口诀: 先核心,后队列,再最大,最后拒绝。
四、四种常见的拒绝策略
JDK 内置了四种 RejectedExecutionHandler 实现:
| 策略 | 行为 |
|---|---|
AbortPolicy (默认) |
直接抛出 RejectedExecutionException 异常 |
CallerRunsPolicy |
由调用线程直接运行该任务(负反馈调节) |
DiscardPolicy |
直接丢弃任务,不抛出异常 |
DiscardOldestPolicy |
丢弃队列中等待最久的任务,然后重新提交当前任务 |
五、常见的线程池类型(Executors 工具类)
JDK 提供了 Executors 工厂类来快速创建线程池,但在生产环境中通常不推荐直接使用(参考《阿里巴巴 Java 开发手册》),建议手动 new ThreadPoolExecutor。
| 类型 | 特点 | 风险 |
|---|---|---|
FixedThreadPool |
固定大小的线程池 | 使用无界队列,可能导致 OOM |
SingleThreadExecutor |
只有一个线程,保证任务按顺序执行 | 同样使用无界队列,有 OOM 风险 |
CachedThreadPool |
可缓存线程池,线程数无上限 | 创建大量线程,导致 CPU 100% 或 OOM |
ScheduledThreadPool |
支持定时及周期性任务执行 | - |
六、线程池参数配置建议
配置线程池大小通常取决于任务类型:
| 任务类型 | 特点 | 建议配置 |
|---|---|---|
| CPU 密集型 | 加密、计算、压缩 | CPU 核数 + 1 |
| IO 密集型 | 数据库查询、HTTP 请求、文件读写 | CPU 核数 * 2 或 CPU 核数 / (1 - 阻塞系数) |
为什么协程适合处理IO密集型任务
协程(Coroutine)之所以被认为是处理 IO 密集型任务的神器,核心原因可以用一句话概括:
协程把"等待 IO"的时间利用了起来,并且以极低的成本实现了并发。
一、协程 vs 线程的核心差异
1. 极其轻量级的"挂起"和"恢复"
-
线程的痛点(阻塞): 当一个线程发起 IO 请求时,如果数据没回来,这个线程就会阻塞(Block)。线程虽然什么都不干(在傻等),但它依然占用了系统资源。
-
协程的优势(非阻塞/挂起): 当协程发起 IO 请求时,它不会"傻等",而是会主动挂起(Yield),把当前占用的线程控制权让出来。底层的物理线程没有被阻塞,它可以立即去执行其他协程的任务。
2. 内存资源消耗极低
-
线程(Heavyweight): Java 的线程是操作系统内核级线程。创建一个线程通常需要预留 1MB 左右的栈内存。
- 10,000 个并发连接 = 10GB 内存
-
协程(Lightweight): 协程是用户态的,完全由程序或语言运行时管理。一个协程的初始栈内存通常只有 几 KB(如 Go 协程约 2KB)。
- 同样的 10GB 内存,理论上可以支撑数百万个协程。
3. 极低的上下文切换成本
-
线程切换(贵): 线程调度由操作系统内核完成。涉及用户态和内核态的转换,需要保存和恢复大量的寄存器状态,开销较大。
-
协程切换(便宜): 协程调度完全在用户态完成,不涉及内核态切换。速度比线程切换快 1~2 个数量级。
4. “同步的代码,异步的执行”
你可以用写同步代码的方式(顺序执行)来写异步逻辑,极大地降低了编写高并发 IO 代码的心智负担。
二、通俗类比:餐厅服务员
-
多线程模型(1 对 1): 来了 100 个客人,老板雇了 100 个服务员。每个服务员负责一个客人,客人点完菜,服务员就站在厨房门口死等厨师做好饭,期间什么也不干。
- 缺点: 雇佣服务员成本太高,且大部分服务员都在发呆(阻塞)。
-
协程模型(1 对 多): 来了 100 个客人,老板只雇了 1 个服务员。服务员给 1 号桌点完菜,把单子扔给厨房,立刻跑去给 2 号桌点菜。等厨房喊"1 号桌菜好了",服务员再回来端菜。
- 优点: 只需要很少的服务员就能服务大量客人,服务员一直在跑动(CPU 高效利用)。
三、协程到底做了什么?
结论:协程并不能让 CPU 偷懒不做数据搬运。
当数据准备好后,依然是 CPU 亲自把数据从内核缓冲区搬运(复制)到用户缓冲区。这一点,协程和线程没有任何区别。
协程赢在"等待数据到达内核缓冲区"的这段时间。
协程底层配合的是 非阻塞 I/O (Non-blocking I/O) 和 IO 多路复用 (Epoll/Selector):
- 协程调用读取操作,底层立即问内核:“数据到了吗?”
- 内核说"没到"。协程不睡,而是仅仅在用户态线程内部记录一下"我先暂停",然后立刻让出 CPU 给其他协程用。
- 协程把"我在等数据"这件事注册给操作系统的监听器(Epoll)。
- 数据到达后,监听器通知程序,协程被恢复继续执行。
总结: 协程并不是让 CPU 少干了活(搬运工作量没变),而是让 CPU 在等待期间别闲着,也别瞎折腾(切换上下文),从而能处理更多的并发任务。
四、什么时候用协程?
| 任务类型 | 特点 | 是否适合协程 | 原因 |
|---|---|---|---|
| IO 密集型 | 网络请求、读写数据库、微服务调用 | 非常适合 | 解决了大量"等待"造成的资源浪费,能支撑超高并发 |
| CPU 密集型 | 视频转码、复杂的数学计算、加密解密 | 不适合 | 任务主要在用 CPU 计算,没有时间让出控制权,协程的调度反而会增加负担 |
IO操作的数据搬运是由谁来做的
从 Socket 内存缓冲区(内核空间)到用户内存缓冲区(用户空间)的数据搬运,必须由 CPU 亲力亲为。
但是,整个 I/O 过程并非全由 CPU 完成。我们需要把一次网络 I/O 拆解为两个阶段:
第一阶段:网卡 -> 内核缓冲区
- 干活的主角: DMA(Direct Memory Access,直接存储器访问)
- CPU 的角色: 基本不参与(只负责发号施令)
- 网线上的光电信号到达网卡(NIC)。
- DMA 控制器(硬件芯片)接管总线,直接把数据从网卡拷贝到内存中的内核缓冲区。
- 搬运完成后,DMA 会给 CPU 发送一个中断信号,告诉 CPU:“数据已经卸到内核仓库了”。
为什么要用 DMA? 如果没有 DMA,CPU 就得一个字节一个字节地从网卡读数据,效率极低。有了 DMA,CPU 就可以做"甩手掌柜"。
第二阶段:内核缓冲区 -> 用户缓冲区
- 干活的主角: CPU
- 状态: 昂贵的"上下文切换"和"内存拷贝"
- 系统调用: Java 程序执行了
socket.read()。 - 模式切换: 线程从用户态切换到内核态。
- CPU 搬运: CPU 亲自上阵,将数据从内核 Socket 缓冲区逐字节拷贝到用户空间缓冲区。
- 返回用户态: 拷贝完成后,CPU 切换回用户态,
read()方法返回。
总结:谁在搬运?
| 数据流向 | 搬运工 (硬件) | 消耗资源 | 备注 |
|---|---|---|---|
| 网卡 → 内核内存 | DMA 控制器 | 总线带宽 | CPU 几乎不参与,效率极高 |
| 内核内存 → 用户内存 | CPU | CPU 周期 | 这是性能瓶颈! CPU 必须暂停其他计算工作来搬砖 |
延伸:零拷贝(Zero-Copy)
既然 CPU 搬运内核到用户空间很慢,那能不能不搬?这就是 Kafka、Netty 等高性能框架使用的零拷贝技术(如 sendfile 或 mmap)。
- 核心思想: 如果你只是想把文件读取出来发给网卡(比如做静态资源服务器),数据其实不需要进入用户空间。
- 做法: 数据由 DMA 从磁盘搬到内核,再由 DMA 直接从内核搬到网卡。
- 结果: 完全跳过了 CPU 将数据搬运到用户缓冲区的步骤,CPU 占用率大幅下降,性能起飞。
Redis 6.0+ 的 IO 多线程
默认是否开启?
默认是关闭的(Disabled)。 虽然 Redis 6.0 引入了多线程 I/O 特性,但在默认配置下,Redis 依然表现为传统的单线程模式。
如何开启?
修改 redis.conf 配置文件中的以下两个参数:
| 参数 | 默认值 | 说明 |
|---|---|---|
io-threads |
1 | 设置为大于 1 的值开启。4 核机器建议设为 2~3 个,8 核机器建议设为 6 个 |
io-threads-do-reads |
no | 设置为 yes 开启多线程读。默认只开启多线程写 |
为什么默认不开启?
-
性能没瓶颈: 对于绝大多数应用程序,Redis 的瓶颈通常在于网络带宽或内存,而不是 CPU。单线程的 Redis 已经足够快(每秒处理数万至十万级请求)。
-
复杂性与稳定性: 引入多线程会增加系统的复杂性。保持默认关闭可以最大程度保证旧版本的行为一致性和稳定性。
重要提示:核心逻辑依然是"单线程"
多线程只负责 I/O: 处理网络数据的读(Read/Decode)和网络数据的写(Write/Encode)。
命令执行依然是单线程: 实际执行 GET、SET、LUA 脚本等操作的,依然是那个唯一的主线程。
这意味着:
- 你依然不需要担心多线程并发带来的数据竞争问题(不需要加锁)。
- 原子性依然得到天然保证。
单线程 Redis 会傻等吗?
绝对不会傻等。 Redis 之所以快,靠的就是 I/O 多路复用(Epoll) 技术。
- DMA 搬运阶段: Redis 主线程不参与,它正在忙着处理其他请求。
- 数据到站: 操作系统通过 Epoll 标记该 Socket “可读”。
- Redis 发现数据: 主线程调用
epoll_wait()问操作系统哪些连接有数据。 - 搬运到用户态: 因为数据已经在内核缓冲区躺好了,
read()调用不需要等待。
结论: Redis 没开启多线程时,它绝不会等"网卡接收数据"(这是 DMA 干的),但它必须亲自做"内核到用户态的数据拷贝"(这是 CPU 干的)。
MySQL 主从同步模型
新节点加入,主节点直接传全量 Binlog 吗?
通常不会,甚至是不可能的。 如果数据库已经运行了一年,Binlog 可能早就滚动删除了。
标准的新节点同步流程是"全量快照 + 增量 Binlog":
-
全量数据搬运(Snapshot): 先手动将主节点(或某个现存从节点)当前的数据快照拷贝给新节点。
- 工具:
mysqldump(逻辑备份,慢)或Percona XtraBackup(物理备份,快,生产环境首选)
- 工具:
-
记录位点(Position): 在做快照的那一瞬间,记录下当前的 Binlog 文件名和偏移量,或者 GTID。
-
增量追赶(Catch-up): 将快照恢复到新节点后,新节点执行
CHANGE MASTER TO,指向记录的"时间戳",请求之后产生的新 Binlog。
从节点 > 1 时,是否由从节点来同步新的从节点?
默认不会,但你可以配置成这样(级联复制)。
默认架构(星型结构)
- 结构:1 主 -> N 从
- 行为:新节点配置的 Master IP 是主节点,它就会直接连接主节点
- 缺点:如果新节点太多,会把主节点的网卡或磁盘 I/O 打满
级联复制(Cascading Replication)
- 结构:Master -> Slave A -> Slave B(新节点)
- 做法:新节点的
MASTER_HOST指向现有的从节点(Slave A) - 前提:中间节点必须开启
log_slave_updates = 1 - 优点:减轻了主节点的压力
- 缺点:同步链路变长,延迟会叠加
Relay Log(中继日志)是什么?
Relay Log 是 MySQL 主从复制架构中,存在于从节点上的一种特殊日志文件。你可以把它理解为从节点上的**“临时待办箱”**。
复制流程中的位置:
|
|
为什么要设计 Relay Log?
-
接收快,执行慢: 网络传输通常很快,但执行 SQL 通常很慢。有了 Relay Log,I/O 线程可以疯狂地把 Master 的日志拉取回来存在本地,哪怕 SQL 线程处理不过来也没关系。
-
断点续传与崩溃恢复: 如果 Slave 宕机重启,Relay Log 还存在磁盘上。SQL 线程知道自己上次执行到了哪个位置,重启后可以继续执行。
| 特性 | Binlog | Relay Log |
|---|---|---|
| 存放位置 | 通常在 Master | 只在 Slave 上 |
| 产生者 | 数据库自身的写操作 | Slave 的 I/O 线程(从 Master 拷贝来的) |
| 生命周期 | 长期保留 | 用完即焚,SQL 线程执行完后自动删除 |
SELECT FOR UPDATE 什么时候解锁
是事务结束时(Commit 或 Rollback 之后)。
锁的生命周期
在 MySQL(InnoDB 引擎)中,SELECT ... FOR UPDATE 遵循两阶段锁协议(2PL):
- 加锁时刻: 当 CPU 执行到这条 SQL 语句时,开始申请并持有锁。
- 持有过程: 即使这条 SQL 执行完了,锁依然不会释放。
- 解锁时刻: 只有当整个事务执行了
COMMIT或ROLLBACK后,锁才会释放。
为什么要设计成"事务结束才释放"?
如果 SQL 执行完立刻释放锁,会发生严重的数据一致性问题(丢失更新)。
只有拿到事务结束,才能保证在你处理完这行数据之前,没有任何人能修改它。
致命的生产事故模型(千万小心)
|
|
后果: 在那 3 秒钟内,这行数据是被死锁住的。所有其他想买这张票的用户,全部卡在第 1 步等待。如果并发量大,数据库连接瞬间就会被这些等待的线程占满,导致整个系统宕机。
最佳实践: 只有在必须要修改数据,且业务逻辑非常快(纯内存计算,无外部 IO)的代码块里,才使用 FOR UPDATE。不要拿着锁去逛街(做耗时操作)。
MySQL 的 MHA 高可用架构
MHA (Master High Availability Manager and Tools for MySQL) 是 MySQL 领域非常经典、使用非常广泛的第三方高可用解决方案。
MHA 是做什么的?
核心目标只有一个:在 Master(主库)宕机后,以最快的速度(通常 10~30秒内)自动将某个 Slave 提升为新的 Master,并让其他 Slave 指向新的 Master,同时尽最大努力不丢失数据。
核心组件架构
| 组件 | 部署位置 | 职责 |
|---|---|---|
| MHA Manager | 单独部署在一台机器上 | 大脑。监控 Master 状态,执行故障转移流程 |
| MHA Node | 每一台 MySQL 服务器上 | 执行者。解析 Binlog/Relay Log,对比差异,应用日志 |
故障切换流程(核心原理)
假设架构是:Master(A) -> Slave(B), Slave(C)。当 Master(A) 宕机时:
-
侦测与确认: Manager 发现 Master(A) 连不上,多次重试确认。
-
选主(Election): 检查 Slave(B) 和 Slave(C),谁的 Relay Log 最完整(数据最新),谁就当新主。
-
数据补全(差异日志补全):
- 如果 Master SSH 还能连:MHA 会登录到死掉的 Master 上,把还没来得及发给 Slave 的最新 Binlog 截取并应用到新主上。数据 0 丢失。
- 如果 Master 物理损坏:对比各 Slave 的 Relay Log,把差异补齐。
-
提升新主与重构拓扑: 对新主执行
STOP SLAVE,解除只读模式;告诉其他 Slave 执行CHANGE MASTER TO指向新主。 -
VIP 漂移: 通过钩子脚本将 VIP 从旧 Master 漂移到新 Master 上。
MHA 的优缺点
| 优点 | 缺点 |
|---|---|
| 数据一致性高(SSH 可达时数据不丢失) | Perl 语言编写,维护困难 |
| 切换速度快(30 秒内) | VIP 配置复杂,需自己写脚本 |
| 对现有架构侵入小 | 脑裂风险(需配置 fencing 脚本) |
| 成熟稳定 | MHA Manager 自身是单点 |
MySQL Group Replication (MGR)
MySQL Group Replication (MGR) 是 MySQL 官方在 5.7.17 版本正式推出的高可用与高扩展解决方案。它引入了分布式系统中最核心的 Paxos 协议,从而实现了数据库集群的强一致性和原生高可用。
核心特点:为什么它比传统主从牛?
- 数据不丢失: 任何一个事务想要提交,必须经过集群中**大多数(N/2 + 1)**节点的同意。
- 原子性: 一个事务要么在所有节点都生效,要么都不生效。
- 自动脑裂防御: 如果发生网络分区,只有拥有大多数节点的那个分区能对外提供服务,少数派分区会自动锁死。
两种运行模式
| 模式 | 特点 | 适用场景 |
|---|---|---|
| 单主模式 (Single-Primary) | 只有一个节点可以写,其他节点只读。Primary 挂掉时自动选新主 | 推荐/主流,几乎没有冲突检测开销 |
| 多主模式 (Multi-Primary) | 所有节点都可以同时处理写请求 | 冲突检测复杂,同时修改同一行数据会报错回滚 |
MGR 的工作原理
假设有 3 个节点(A, B, C),Client 向 A 发送了一条 UPDATE:
-
执行阶段: 节点 A 先在本地内存里预执行这个事务,但不提交。生成一个写集 (Write Set)。
-
广播与共识 (Paxos): 节点 A 把写集广播给整个小组。只要有 2 个收到并排序成功,协议就达成。
-
冲突检测 (Certification): 所有节点都会拿着这个写集,去跟自己内存里正在处理的其他事务比对。如果有冲突,后到的事务直接回滚。
-
提交 (Commit): 通过认证后,事务真正写入 Binlog 和磁盘。
MGR 的硬性限制
- 必须是 InnoDB 引擎
- 每张表必须有主键 (Primary Key):MGR 靠主键的 Hash 来判断写冲突
- 必须开启 GTID
- 网络要求高:节点间通信非常频繁
- 节点数量限制: 最多支持 9 个节点
MGR vs MHA 对比
| 维度 | MHA | MGR |
|---|---|---|
| 核心机制 | 外部脚本监控 + 异步/半同步复制 | 数据库内核插件 + Paxos 协议 |
| 数据一致性 | 理论上可能有丢失 | 强一致性(Quorum 机制保证不丢) |
| 脑裂保护 | 需额外配置 fencing 脚本 | 原生自带 |
| 故障切换 | 需要 VIP 漂移脚本配合 | 自动选主 |
| 多点写入 | 不支持 | 支持(多主模式) |
MySQL InnoDB Cluster
这是一个全家桶解决方案:
- MGR(底层): 负责数据复制和强一致性
- MySQL Router(中间件): 负责流量转发,自动识别谁是主节点
- MySQL Shell(工具): 一键部署和管理 MGR 集群
公式: InnoDB Cluster = MGR + MySQL Router + MySQL Shell
Redis 的中心化集群架构
在 Redis 的生态演进中,提到"中心化"架构,通常指的是两种形态:
- 高可用层面: Redis Sentinel(哨兵模式)
- 分片/代理层面: Codis 或 Twemproxy
与之相对的是 Redis Cluster(官方集群),它是典型的**去中心化(P2P)**架构。
Redis Sentinel(哨兵模式)
这是 Redis 官方推荐的**高可用(HA)**解决方案。
架构拓扑
- 数据节点: 1 个 Master(负责写)+ N 个 Slave(负责读)
- 管理节点(Sentinel): 一组独立的 Redis 进程(建议至少 3 个),互相通信,同时监控所有数据节点
核心功能
| 功能 | 说明 |
|---|---|
| 监控 (Monitoring) | 不断 Ping Master 和 Slave,看它们是否还活着 |
| 通知 (Notification) | 发现节点挂了,可以通过 API 通知管理员 |
| 自动故障转移 (Automatic Failover) | Master 挂了,哨兵投票选出新 Master,指挥其他 Slave 连向新老大 |
| 配置中心 (Configuration Provider) | 客户端连接 Sentinel,Sentinel 告诉客户端当前 Master 的地址 |
故障切换流程
- 主观下线 (SDOWN): 一个哨兵 Ping 不通 Master,它认为 Master 挂了(主观觉得)。
- 客观下线 (ODOWN): 超过半数(Quorum)的哨兵都说连不上,坐实 Master 挂了。
- 选举领头哨兵: 哨兵们内部选出一个"领头哨兵"来执行切换操作。
- 选新主: 领头哨兵根据规则(优先级、复制偏移量)选出一个 Slave 升级为 Master。
- 广播: 通知其余 Slave 和客户端切换到新地址。
优缺点
| 优点 | 缺点 |
|---|---|
| 官方原生,自动化 HA | 写性能无法扩展(只有一个 Master) |
| 解决了单点故障问题 | 存储无法扩展(内存容量受限于单机) |
Proxy 架构(Codis / Twemproxy)
这是一种中心化分片架构。在 Redis Cluster 普及之前,这是处理海量数据的主流方案。
架构拓扑
|
|
核心原理
- 中心入口: 客户端只连接 Proxy,感觉就像连接一个巨大的单机 Redis。
- 数据分片: Proxy 内部维护了路由表(Slot 映射)。当你
SET key value时,Proxy 计算 key 的 Hash 值,转发给后端的某一台 Redis。 - 扩容: Codis 提供了 Dashboard,可以动态地迁移数据(Slot),对客户端透明。
优缺点
| 优点 | 缺点 |
|---|---|
| 客户端简单(无需改代码) | 多了一层网络转发,增加延迟 |
| 支持超大容量 | 架构复杂,需要部署 ZooKeeper/Etcd |
| - | 逐渐被边缘化(Redis Cluster 成熟后) |
对比:Redis Cluster 是"去中心化"
- 没有 Proxy: 客户端直接连任何一个 Redis 节点
- 没有中心 Master: 集群被切分成 16384 个槽(Slots),分配给多个主节点
- Gossip 协议: 节点之间互相对话
- 客户端智能: 请求错了节点,节点会返回
MOVED错误,告诉客户端正确的 IP
LSM Tree 是什么
LSM Tree (Log-Structured Merge-tree) 是一种为高吞吐量写入而优化的数据结构,它是现代 NoSQL 数据库(如 HBase, Cassandra, LevelDB, RocksDB)和部分 NewSQL 数据库(如 TiDB)的存储引擎核心。
核心思想: 将离散的随机写请求,转换成批量的顺序写请求,以换取极致的写入性能。
为什么要发明 LSM Tree?
在传统的 B+ 树(MySQL InnoDB 使用)中,当我们要写入一条数据时,需要先找到它在磁盘 B+ 树中的具体叶子节点位置,然后做原地更新。
痛点: 如果写入的主键是无序的,这会产生大量的磁盘随机 I/O。对于机械硬盘甚至 SSD 来说,随机写比顺序写慢得多。
LSM Tree 的解决思路: 不管你写什么,我先不急着存到磁盘的具体位置,而是像写日志一样,**追加(Append)**到文件末尾。追加是顺序写,速度极快。
LSM Tree 的三大核心组件
1. MemTable(内存表)
- 位置: 内存(RAM)
- 结构: 通常是一个有序的数据结构(如跳表 SkipList 或红黑树)
- 作用: 所有新的写入请求首先都进入 MemTable。因为是在内存中操作,速度极快
2. Immutable MemTable(不可变内存表)
- 位置: 内存
- 作用: 当 MemTable 写满后,它会瞬间变成"不可变"状态,并生成一个新的 MemTable 接收新写入。这个不可变的表会在后台被刷盘(Flush)到磁盘
3. SSTable (Sorted String Table)
- 位置: 磁盘
- 结构: 内部有序的键值对文件
- 特点: 不可变(Immutable)。一旦写入磁盘,就不会再被修改
- 分层(Level): 磁盘上的 SSTable 通常分为 Level 0, Level 1, Level 2… 多层
4. WAL (Write Ahead Log)
- 作用: 为了防止断电导致内存里的 MemTable 数据丢失,每次写 MemTable 之前,会先顺序写入磁盘的 WAL 日志
工作流程详解
写入流程(Write) —— 极快
- 写 WAL(保证持久性)
- 写 MemTable(内存操作,O(logN))
- 结束。(对客户端来说,写入已经完成了)
后台动作: 当 MemTable 满了,转为 Immutable MemTable,然后 Flush 到磁盘成为 Level 0 的 SSTable。
读取流程(Read) —— 稍慢
因为数据可能散落在内存和磁盘的不同层级中,读取需要分层查找:
- 查 MemTable
- 查 Immutable MemTable
- 查 L0 层 SSTable(文件间 Key 可能重叠,需要遍历)
- 查 L1, L2… 层 SSTable
优化: 使用布隆过滤器 (Bloom Filter)。在打开一个 SSTable 文件前,先问布隆过滤器:“这个 Key 可能在这里吗?“如果回答"不在”,就直接跳过该文件。
核心机制:Compaction(合并/压缩)
LSM Tree 需要在后台运行 Compaction 进程:
- Merge: 把多个小的、重叠的 SSTable 读取出来
- Sort: 进行归并排序
- Clean: 丢弃旧版本数据,物理删除带有"墓碑标记"的数据
- Write: 写入新的、更大的、更有序的 Level+1 层 SSTable
LSM Tree vs B+ Tree
| 特性 | LSM Tree | B+ Tree |
|---|---|---|
| 典型代表 | RocksDB, HBase, Cassandra, TiKV | MySQL (InnoDB), PostgreSQL |
| 写入性能 | 极高 (顺序写) | 一般 (随机写,需维护树结构平衡) |
| 读取性能 | 一般 (可能需要查多个文件) | 极高 (索引直接定位) |
| 空间利用率 | 高 (SSTable 紧凑排列,无碎片) | 低 (页分裂导致碎片) |
| 适用场景 | 写多读少,海量数据日志、监控 | 读多写少,强一致性事务 |
MySQL Buffer Pool 和 TiDB 缓存对比
MySQL 的 Buffer Pool 和 TiDB 的缓存机制存在显著差异,这主要源于它们底层存储引擎结构的不同(B+ 树 vs LSM 树)。
MySQL:InnoDB Buffer Pool
在哪里?
- 物理位置: MySQL 服务器的**内存(RAM)**中
- 归属: 属于 InnoDB 存储引擎
- 内容: 缓存数据页、索引页、插入缓冲(Change Buffer)、自适应哈希索引、锁信息等
怎么运作的?
MySQL 的 Buffer Pool 是典型的 Page Cache(页缓存) 机制,默认页大小为 16KB。
读操作:
- 命中(Hit):直接返回,无需读盘(极快)
- 未命中(Miss):从磁盘加载该页到 Buffer Pool,然后再返回
写操作(Write Back 策略):
- 直接修改内存中的页,将其标记为"脏页(Dirty Page)”
- 同时写入 Redo Log 保证持久性
- 后台线程异步将脏页刷回磁盘
怎么"使用"?(调优)
- 核心参数:
innodb_buffer_pool_size - 建议配置: 在专用数据库服务器上,通常设置为物理内存的 50% ~ 75%
- 切记: 留给操作系统和其他进程一些内存,不要设得太满导致 Swap
TiDB:多层级缓存架构
TiDB 是存算分离架构,缓存分为两部分:TiDB Server(计算层)和 TiKV(存储层)。
TiKV 层(存储层 - 核心数据缓存)
TiKV 底层使用 RocksDB(LSM Tree 架构),缓存机制与 MySQL 大不相同。
Block Cache(读缓存):
- 在 TiKV 节点的内存中
- 用于缓存磁盘上的 SSTable 数据块(Block)
- 数据是只读的,不存在"脏页"的概念
MemTable(写缓存):
- 在 TiKV 节点的内存中
- 新数据直接写入 MemTable,写满后 Flush 到磁盘成为 SSTable
写入完全不经过 Block Cache。
TiDB 层(计算层 - 结果缓存)
- Coprocessor Cache: 缓存下推给 TiKV 计算的中间结果
- Plan Cache(执行计划缓存): 缓存 SQL 的解析和优化结果
核心对比总结
| 特性 | MySQL (InnoDB Buffer Pool) | TiDB (TiKV RocksDB Block Cache) |
|---|---|---|
| 数据结构 | B+ Tree 页 (Page, 16KB) | LSM Tree 块 (Block, 4KB~) |
| 缓存内容 | 数据 + 索引 | 主要是 SSTable 的数据块 |
| 写入方式 | 原地更新:直接修改缓存页,标记为脏页 | 追加写:写入单独的 MemTable,不修改 Block Cache |
| 脏页处理 | 需要 Checkpoint 刷盘 | 无脏页概念 (SSTable 是不可变的) |
| 内存分配 | 建议给 50-75% 物理内存 | 建议给 45% 左右 (需预留给 OS Cache) |
TiDB 的读取速度会不会慢很多?
TiKV 的底层完全基于 RocksDB,使用的就是 LSM Tree 结构。
答案是: 理论上单次查询会有轻微延迟(Read Amplification,读放大),但在实际生产和高并发场景下,通常感觉不到慢,甚至在某些场景下更快。
为什么 LSM Tree 这种"原本读取慢"的结构,在 TiDB 里跑得飞快?
1. 布隆过滤器 (Bloom Filter) —— 快速排除
RocksDB 给每个 SSTable 文件都配了一个布隆过滤器。它能以 O(1) 的速度告诉你:“这个文件里绝对没有这个 Key” 或者 “可能有”。
效果: 绝大多数不包含该数据的文件会被瞬间排除,根本不需要发生磁盘 I/O。
2. Block Cache —— 内存命中
热点数据(Hot Data)通常已经被加载到了内存的 Block Cache 中。如果缓存命中,读取速度就等同于读内存。
3. Compaction (合并) —— 保持层级扁平
RocksDB 会在后台不断进行 Compaction,把多层的小文件合并成一层的大文件。保证读取时需要查找的文件层数非常少。
4. TiKV 的杀手锏:Coprocessor (计算下推)
这是 TiDB 架构上最大的优势。
- MySQL 的做法: “把数据全搬到 Server 层,Server 再过滤。”
- TiDB 的做法: “计算下推”。TiDB Server 告诉 TiKV 节点:“你帮我数一下 age > 20 的有多少,直接告诉我结果。”
效果: 虽然 LSM Tree 读单行可能稍慢,但 TiDB 利用多节点并行计算 + 减少网络传输,使得整体查询响应速度往往反超单机 MySQL。
性能对比实话实说
| 场景 | MySQL (B+ Tree) | TiDB (LSM Tree) | 谁赢? |
|---|---|---|---|
点查询 select * from t where id=1 |
极快 (O(logN) 稳定索引查找) | 稍慢 (可能要查 MemTable+BlockCache) | MySQL 险胜(但高并发下 TiDB 吞吐量更大) |
范围查询 select * from t where id > 100 |
快 (叶子节点链表) | 很快 (SSTable 内部本身就是有序的) | 平手 / TiDB 胜 |
| 写入性能 | 一般 (随机 I/O,页分裂) | 极快 (顺序追加写) | TiDB 完胜 |
| 复杂分析 (OLAP) 聚合、Group By | 慢 (单机 CPU 瓶颈) | 极快 (MPP 架构,多节点并行计算) | TiDB 完胜 |
结论
TiDB 的"慢"主要体现在极低延迟要求的"单点查询"上(比如 MySQL 能做到 0.5ms,TiDB 可能是 1-2ms)。
但在绝大多数业务场景下,得益于缓存和布隆过滤器,这个差异是可以忽略的。而它带来的写入吞吐量巨大提升和海量数据扩展能力,是 B+ Tree 架构难以比拟的。
TiDB 如何使用联合索引(KV 结构如何模拟 B+ 树索引)
TiDB 底层 TiKV 是一个巨大的 Map(Key-Value),看起来是平铺的。那怎么用扁平的 KV 来模拟立体的"联合索引"呢?
答案就在于 “Key 的编码规则(Key Encoding)"。
TiDB 并没有真的建立一棵 B+ 树,而是巧妙地把"索引列的值"直接编码到了 KV 的 Key 里面。因为 TiKV(RocksDB)是按 Key 的字节序有序存储的,所以只要设计好 Key 的格式,就能利用"Key 的顺序"来模拟"B+ 树的顺序”。
场景假设
假设你有一张表 user,有一个联合索引 idx_name_age (name, age)。
|
|
TiDB 是怎么把它们变成 KV 的?
存"行数据" (Row)
|
|
存"联合索引" (Index) —— 重点!
TiDB 会按照 (TableID, IndexID, IndexColumnValues) 的格式构造 Key:
| 逻辑上的 Key 结构 | Value | 解释 |
|---|---|---|
t10_i1_Alice_18_1 |
null | Alice(18岁) 对应 ID=1 |
t10_i1_Alice_19_3 |
null | Alice(19岁) 对应 ID=3 |
t10_i1_Bob_20_2 |
null | Bob(20岁) 对应 ID=2 |
Key 的设计玄机:
- 前缀相同: 大家都是
t10_i1(同一个表,同一个索引),所以它们在磁盘上是物理挨在一起的 - 顺序排列: RocksDB 会自动把 Key 排序。
Alice_18排在Alice_19前面,Alice_xxx全部都排在Bob_xxx前面
联合索引如何生效?(查询过程)
场景 1:最左前缀匹配
|
|
TiDB 动作:
- 构造一个扫描范围:从
t10_i1_Alice开始 - TiKV 直接定位到
t10_i1_Alice_18_1 - 接着往下读,读到
t10_i1_Alice_19_3 - 再往下读,发现是 Bob 了,停止
结果: 拿到了 ID 1 和 3。这和 B+ 树的范围查找完全一样!
场景 2:联合查询
|
|
TiDB 动作:直接构造精确的 Key 前缀 t10_i1_Alice_19,TiKV 直接 Seek 定位。
场景 3:索引失效(跳过最左前缀)
|
|
索引 Key 是按 name 排序的。age=19 的数据并不是挨在一起的,没办法 Scan,只能全表扫描。这和 MySQL 的索引失效原理完全一致。
总结
TiDB 并没有像 MySQL 那样维护复杂的树指针,它用了一种"降维打击"的方式:
- 映射: 把多维的"联合索引 (a, b, c)" 扁平化映射成一维的 Key
..._a_b_c - 排序: 利用底层 RocksDB/LSM Tree 对 Key 的天然有序性
- 扫描: 把 SQL 的范围查询转换成 KV 存储的 Prefix Scan(前缀扫描)
所以,你在 MySQL 里学的索引原则(最左前缀、索引覆盖、索引下推),在 TiDB 里 100% 适用。
TiDB 多个索引是否要多存几份表?
你的理解是对的,但有一个关键的定语需要修正: 并不是"多存几份表",而是"多存几份索引列的数据"。
这是数据库领域最核心的交换法则:空间换时间(Space for Time)。
并没有存"整张表",只存了"路标"
假设你的 user 表非常宽,有很多列:id, name, age, address, bio, photo_url, create_time...
第一份:主键/行数据 (Row Data) —— 它是真身
|
|
大小: 假设这一行有 1KB。
第二份:联合索引 (Secondary Index) —— 它是影子
假设你建了索引 idx_name_age (name, age)。
|
|
它只复制了 name("Alice") 和 age(18) 这两个字段,加上一个主键 ID(1)。其他的 address、bio、photo 统统不存。
大小: 这一行 KV 可能只有 50 Bytes。
回表(Back to Table):因为没存整表
正因为索引 KV 里只有 name 和 age,没有 address,所以:
-
查询 1(索引覆盖):
SELECT name, age FROM user WHERE name='Alice'- 只需要读索引 KV,拿到 “Alice” 和 18,任务结束。速度极快。
-
查询 2(回表):
SELECT address FROM user WHERE name='Alice'- 读索引 KV,找到
name='Alice'对应的主键 ID = 1 - 拿着 ID = 1,再去读"主键/行数据"那份 KV,把
address拿出来 - 这就是所谓的**“回表”**代价
- 读索引 KV,找到
TiDB 相比 MySQL 的"空间"优势
虽然逻辑上 TiDB 和 MySQL 都要存这些索引副本,但在物理存储上,TiDB(RocksDB)有一个巨大的优势:前缀压缩(Prefix Compression)。
在 TiDB (LSM Tree) 中,RocksDB 会利用 Delta Encoding 和 Block Compression (Zstd/Lz4):
- 它不会傻傻地把 “Alice” 存 1 万遍
- 它会记:“跟上一行比,前缀有 15 个字节是一样的,只有最后的主键 ID 不一样”
实测结果: 同样的数据量和索引量,迁移到 TiDB 后,磁盘占用通常只有 MySQL 的 30% ~ 50%。
TiDB 的索引查找是否需要遍历?
绝对不是"每一次都遍历"。
虽然 TiKV 底层是 KV 结构,但它支持一种核心操作叫 Seek(key)。这个操作的复杂度是 O(log N),和 MySQL 的 B+ 树查找效率是同一个量级的。
宏观层面:它是分区的(Region)
PD (Placement Driver) 知道 name='Alice' 对应的数据在哪个 Region 上,请求直接发给对应节点。
微观层面:文件内部是有"目录"的
SSTable 文件内部自带索引(Index Block),查找时:
- 布隆过滤器: 先看一眼文件头,如果过滤器说"这里面没有 Alice",直接跳过整个文件
- 查索引块: 发现 Alice 应该在第 0~10KB 这个块里
- 二分查找: 只把这个小块加载到内存,然后做一次二分查找
结论: 无论文件有多大,找到 Alice 只需要读取极少量的元数据块,然后做一次精准跳转。
Tomcat 拥有的是线程池还是连接池
直接回答: Tomcat 两者都有,但"默认连接数"通常指的是它作为 Web 服务器处理 HTTP 请求的能力。
对外:Tomcat 作为 Web 服务器
这里指的是浏览器(Client)连接到 Tomcat。在这个层面,Tomcat 主要维护的是线程池(Thread Pool)和连接数(Connections)。
默认值(关键知识点)
| 参数 | 默认值 | 含义 |
|---|---|---|
maxThreads |
200 | 最大工作线程数,Tomcat 能同时并行处理多少个请求 |
maxConnections |
10,000 (NIO) / 8,192 (Spring Boot) | 最大连接数,Tomcat 能同时维持多少个 TCP Socket 连接 |
acceptCount |
100 | 排队队列,当 maxConnections 也满了,操作系统层面还允许多少个新的 TCP 连接排队 |
为什么连接数比线程数大这么多?
因为用了 NIO (Non-blocking IO)!Tomcat 用少量的线程(Poller Thread)就能挂起成千上万个空闲的 TCP 连接。只有当连接真正要读写数据、处理业务时,才会分配那 200 个 maxThreads 中的一个去干活。
总结: Tomcat 默认能 hold 住 10,000 个连接,但同一时刻只能全力跑 200 个业务代码。
对内:Tomcat 作为数据库连接池提供者
这里指的是你的 Java 代码连接 MySQL。
Tomcat 确实自带了一个组件叫 tomcat-jdbc,这是一个 JDBC 数据库连接池。
但是: 在 Spring Boot 2.0 之后,默认换成了性能更强悍的 HikariCP。
图解 Tomcat 处理流程 (NIO)
|
|
常见误区纠正
误区: “把 maxThreads 加到 10000,Tomcat 就能处理 10000 高并发。”
真相: 可能会死机。
- 线程是昂贵的资源
- 开启 10000 个 Java 线程,光上下文切换就会把 CPU 吃光
调优建议: 对于计算密集型,maxThreads 设小点;对于 IO 密集型,可以适当调大到 500~800,但很少超过 1000。
Java 17 下 Tomcat 的线程限制与虚拟线程
Java 17 能用到默认的 maxThreads: 200 和 maxConnections: 10000 吗?
是的,完全可以。 Java 版本(JDK 17)本身不会改变 Tomcat 的这些默认参数配置。这些参数是由 Tomcat 的版本以及你使用的框架(如 Spring Boot)决定的。
如果你使用的是 Spring Boot(最常见的情况),默认配置如下:
| 参数 | 默认值 | 说明 |
|---|---|---|
server.tomcat.threads.max |
200 | 同一时刻最多只有 200 个线程在全力处理业务逻辑 |
server.tomcat.max-connections |
8192 | 可以同时维持 8000 多个 TCP 连接 |
server.tomcat.accept-count |
100 | 排队队列 |
Java 17 在这里的"尴尬"地位
在 Java 17 下,这 200 个线程是真正的**“重量级系统线程” (Platform Threads)**。
这意味着:
-
硬上限: 你很难把这个值调得特别大(比如调到 10,000)。因为 Java 17 的线程直接对应操作系统内核线程,开几千个线程会导致内存爆炸和 CPU 疯狂切换上下文。
-
IO 阻塞痛点: 如果你的业务里有大量的
Thread.sleep、外部 API 调用、慢 SQL 查询,这 200 个线程很容易被占满(Block 住)。一旦 200 个全被占住,第 201 个请求进来就得去队列里排队。
对比 Java 21+: 如果你升级到 Java 21 使用虚拟线程,这 maxThreads: 200 的限制就不存在了。虚拟线程模式下,Tomcat 甚至不使用传统的线程池,而是为每个请求创建一个全新的虚拟线程,轻松支持 10 万+ 并发。
如何在 Java 17 下调优?
既然不能用虚拟线程,如果你的并发量上来,觉得 200 不够用,可以在 application.yml 中适当调大:
|
|
AI 生成场景(2分钟长连接)的特殊考量
如果你的服务是 AI 生成,且和客户端的连接一次都是在 2 分钟左右,这种情况下:
Java 17 (平台线程) 会直接暴毙:
- 用户 A 进来了,开始 AI 生成。他占用了一个线程,持续时间 2 分钟
- 这个线程在这 2 分钟里,虽然 99% 的时间在睡觉(等 GPU),但它死死抱住茅坑不拉屎
- 只要有 200 个用户同时在生成,你的服务器就满载了
虚拟线程 (Java 21) 能轻松拿捏:
- 虚拟线程发起读取 LLM 数据的请求时,JVM 会把这个虚拟线程从 CPU 上拿下来,挂到堆内存里(只占几 KB 内存)
- 载体线程空出来了,可以立马去服务第 201、202… 第 10000 个用户
- 一台 4 核 8G 的机器,可以轻松维持数万个这种 2 分钟的长连接
Java 17 的解决方案:
- 彻底的异步非阻塞 (Spring WebFlux / Netty):代码难写,但性能和虚拟线程一样强
- 集群 + 增加配置:调大 maxThreads,上多台机器通过 Nginx 负载均衡
- 升级 Java 21:成本最低、收益最高
常见的内存溢出(OOM)类型
在 Java 开发中,java.lang.OutOfMemoryError (OOM) 是最令开发者头疼的问题之一。根据发生区域的不同,OOM 主要分为以下 6 种常见类型。
1. 堆内存溢出 (Heap Space) —— 最常见
报错信息: java.lang.OutOfMemoryError: Java heap space
原因: 堆(Heap)是存放对象实例的地方。当创建的对象太多,且 GC 无法回收(对象一直被引用),堆满了就会报错。
常见场景:
- 内存泄漏: 比如一个
static List不断往里 add 对象,但从来不删 - 大对象: 一次性从数据库查询了 100 万条数据全部加载到内存中
- 流量激增: 并发太高,瞬间产生的对象超过了
-Xmx设置的上限
解决:
- 调大堆内存:
-Xms和-Xmx - 分析 Dump 文件:使用 MAT (Memory Analyzer Tool) 查看是哪个对象占满了内存
2. 元空间溢出 (Metaspace)
报错信息: java.lang.OutOfMemoryError: Metaspace
原因: 元空间主要存储类的元数据(Class Metadata),即类的信息、方法信息等。
常见场景:
- 动态代理泛滥: 使用 CGLib、Spring AOP、MyBatis 等框架时,程序在运行期间动态生成了大量的代理类
- 热部署: 频繁进行热部署,旧的 ClassLoader 没有被卸载,导致类元数据堆积
解决:
- 调大元空间:
-XX:MaxMetaspaceSize - 检查代码中是否有死循环生成动态类的逻辑
3. GC 开销超限 (GC Overhead Limit Exceeded)
报错信息: java.lang.OutOfMemoryError: GC overhead limit exceeded
原因: JVM 发现系统处于"垂死挣扎"状态:CPU 花了 98% 的时间在做 GC,但只回收了不到 2% 的内存。为了防止 CPU 一直空转做无用功,JVM 抛出这个错误终止程序。
常见场景: 通常伴随着 Heap Space 问题出现。由于堆快满了,GC 拼命回收但收不回来。
解决: 同"堆内存溢出"的排查思路。
4. 无法创建新的本地线程 (Unable to create new native thread)
报错信息: java.lang.OutOfMemoryError: unable to create new native thread
原因: Java 的线程直接映射到操作系统的内核线程。创建一个线程需要消耗操作系统的内存资源(主要是线程栈 Stack)。
公式: (物理内存 - JVM堆 - 元空间) / 每个线程栈大小(-Xss) = 最大线程数
或者触碰到了 Linux 系统的最大进程/线程数限制 (ulimit)。
常见场景:
- 线程池泄露: 代码里不断
new Thread()却不销毁 - 高并发: 每个线程栈设置太大(如 1MB),导致物理内存不够创建更多线程
解决:
- 减小线程栈:调整
-Xss参数(如从 1M 减到 256k) - 增加系统限制:修改 Linux 的
/etc/security/limits.conf中的nproc - 使用线程池:禁止直接
new Thread()
5. 栈溢出 (Stack Overflow)
报错信息: java.lang.StackOverflowError
(严格来说是 Error,不属于 OOM,但常被混淆)
原因: 方法调用链太深,把线程栈(Stack)压爆了。
常见场景:
- 死递归: 方法 A 调用 A,没有退出条件
- 方法间循环调用: A 调用 B,B 调用 A
解决:
- 检查递归逻辑
- 临时调大
-Xss(治标不治本)
6. 直接内存溢出 (Direct Buffer Memory)
报错信息: java.lang.OutOfMemoryError: Direct buffer memory
原因: 使用了 ByteBuffer.allocateDirect() 分配堆外内存(Off-Heap Memory)。这部分内存不受 JVM 堆大小限制,但受物理内存限制。如果申请太快,GC 来不及释放堆外内存,就会报错。
常见场景: Netty 数据传输频繁,或者没有显式调用 ReferenceCountUtil.release() 释放 ByteBuf。
解决:
- 调整参数:
-XX:MaxDirectMemorySize - 检查 Netty 代码是否有内存泄漏
OOM 排查神技
遇到 OOM,不要瞎猜,请配置以下 JVM 参数,让它在死之前留下一份"遗书"(Dump 文件):
|
|
有了这份 .hprof 文件,配合 MAT (Memory Analyzer Tool) 或 JProfiler,你可以清楚地看到是哪个对象占用了 90% 的内存。
Tomcat 线程池与自定义线程池的关系
Tomcat 的 200 个线程是用来做什么的?
是用来"执行业务逻辑"的。
Tomcat 底层(NIO 模型)其实有两波线程:
| 线程类型 | 数量 | 职责 |
|---|---|---|
| Poller/Acceptor 线程 | 极少(1-2 个) | 负责在门口"处理网络请求的连接和数据传输",把 socket 里的数据读出来,打包成 Request 对象 |
| Worker 线程 | 默认 200 个 | 负责调用你的 Controller、Service、访问数据库,直到生成 Response 返回 |
结论: 你的 Java 代码(业务逻辑)完全运行在这 200 个 Worker 线程上。
Spring Boot 的高并发是否就靠这 200 个线程?
对于传统的 Spring MVC(阻塞式 I/O)来说,是的。
高并发的公式:QPS = 线程数 / 单次请求耗时
| 场景 | 单次请求耗时 | 理论 QPS | 结论 |
|---|---|---|---|
| 传统 CRUD | 0.05 秒 | 200 / 0.05 = 4000 QPS | 单机也能抗几千 QPS |
| AI 流式生成 | 120 秒 | 200 / 120 ≈ 1.6 QPS | 系统完全瘫痪 |
自己创建的线程池算在 Tomcat 的 200 个里面吗?
不算。 这是完全独立的两块资源。
- Tomcat 线程池: 归 Tomcat 管,默认 max=200
- 你的线程池: 归 JVM 管,大小由你自己
new ThreadPoolExecutor时指定
关键互动流程:
|
|
两种情况:
| 做法 | Tomcat 线程状态 | 并发效果 |
|---|---|---|
傻等(Future.get()) |
被占用 | 并发没提升 |
异步响应(DeferredResult / SseEmitter) |
立即释放 | 并发大幅提升 |
SseEmitter 异步模式详解
正确的做法: Tomcat 线程池 + SseEmitter + 自定义异步线程池
|
|
效果:
- Tomcat 线程池不再是瓶颈,QPS 恢复到几千甚至上万
- 压力转移到:自定义线程池 和 Tomcat 的 maxConnections(8192)
针对 Java 17 的优化建议
1. 调整自定义线程池配置
|
|
2. 调整 JVM 线程栈大小
因为后台线程主要是"搬运工"(读 InputStream 写 OutputStream),不需要很深的调用栈:
|
|
3. 优雅处理超时
|
|
总结
| 方案 | Tomcat 线程 | 并发能力 | 适用场景 |
|---|---|---|---|
| 直接阻塞 | 被占满 | 极低(200 并发) | 快速接口(<100ms) |
| SseEmitter + 自定义线程池 | 立即释放 | 高(受自定义线程池限制) | AI 流式、长连接 |
| Java 21 虚拟线程 | 自动调度 | 极高(数万并发) | 终极方案 |
TiDB 新节点发现与数据调度
新 TiKV 是如何被发现的?(节点注册)
当你在新机器上启动一个 TiKV 实例时,它是**主动向集群"报到"**的。核心调度者是 PD (Placement Driver),而不是 TiDB Server。
流程:
- 读取配置: 新 TiKV 读取配置文件,找到 PD 节点的地址(
pd-endpoints) - 发送心跳: 新 TiKV 向 PD 发送 RPC 请求,告诉 PD:“我是新来的,我的 IP 是多少,我的磁盘容量是多少”
- PD 注册: PD 为新节点分配一个全局唯一的 Store ID,将其状态标记为
Up
注:TiDB Server 此时可能还不知道新节点,它只有在读写数据时发现路由变化,才会更新本地的 Region Cache。
什么时候把数据存过去?(调度与平衡)
数据基于 Region(数据分片) 为单位,通过 Raft 协议 逐步迁移,完全由 PD 的调度器控制。
流程:
-
信息收集: 所有 TiKV 节点定期(默认每 10 秒)向 PD 发送 Store Heartbeat
- 旧节点汇报:我有 1000 个 Region,磁盘使用了 80%
- 新节点汇报:我有 0 个 Region,磁盘使用了 1%
-
生成调度策略: PD 的
balance-region-scheduler发现新节点分数极低,决定把旧节点的一些 Region 搬运过去 -
下发操作指令: PD 生成
MovePeerOperator- Add Peer:在新节点上增加一个副本
- Remove Peer:数据同步完成后,删掉旧节点上多余的副本
-
执行搬运(Raft):
- Leader 向新节点发送 Snapshot(快照数据)
- 快照期间的新写入通过 Raft Log 同步
- 新节点数据追平后,正式成为 Raft Group 成员
TiDB Server 是怎么感知到的?
TiDB Server 在这个过程中是被动的:
- 缓存失效: TiDB Server 维护了 Region Cache(Region ID -> TiKV 地址的映射)
- 请求重试: 数据迁移后,旧 TiKV 会返回
Region Error - 重新拉取: TiDB Server 向 PD 询问最新路由信息
TiKV 的 Raft Log
TiKV 用来同步主从(Leader 和 Follower)副本的日志是 Raft Log。
核心定义
TiKV 是一个基于 Raft 一致性算法 的分布式存储系统。在 Raft 协议中,所有的写操作首先都会被转化为一条日志条目(Log Entry)。
- Leader 的职责: 接收写入请求,把指令追加到自己的 Raft Log 中,然后复制给 Followers
- Follower 的职责: 接收 Leader 发来的 Log,追加到自己的 Log 中,并告诉 Leader “我收到了”
- 达成一致(Commit): 当**大多数节点(Majority)**都收到了这条 Log,Leader 才会把它应用到状态机
物理存储
Raft Log 在 TiKV 内部是作为 KV 数据存储在 RocksDB 里的。
TiKV 内部有两个 RocksDB 实例:
| 实例 | 存储内容 |
|---|---|
| RocksDB-KV (kvdb) | 真正的用户数据(State Machine) |
| RocksDB-Raft (raftdb) | Raft Log |
写入流程:
- 用户写入
Key="A", Value="1" - TiKV Leader 将操作封装成 Log Entry,写入
raftdb(落盘) - Leader 通过网络把 Log Entry 发给 Followers
- Followers 收到后,也写入自己的
raftdb - 达成共识后,Leader 和 Followers 再把数据写入
kvdb(这步叫 Apply) - 旧的 Raft Log 会被清理(Log Compaction)
Raft Log vs MySQL 日志对比
| 特性 | TiKV Raft Log | MySQL Redo Log | MySQL Binlog |
|---|---|---|---|
| 主要用途 | 多副本之间的数据同步、故障恢复 | 单机引擎的故障恢复 | 主从同步、数据归档 |
| 共识机制 | 强一致性(Raft 协议,必须多数派成功) | 无(单机写盘) | 弱一致性/半同步 |
| 存储方式 | 存储在 RocksDB (LSM-Tree) 中 | 循环写的物理日志文件 | 追加写的逻辑日志文件 |
| 内容 | 操作指令 + 索引 + 任期 | 物理页的修改 | SQL 语句或行级数据变化 |
LSM Tree vs B+ 树的读写性能对比
写入速度:LSM Tree 碾压 B+ 树
差距:1 个数量级以上(10x ~ 100x)
| 引擎 | 写入方式 | 问题 |
|---|---|---|
| B+ 树(MySQL) | 随机写(Random Write) | 修改一行数据,需要把整个 16KB Page 读出来、修改、写回去;Page 满了还要分裂 |
| LSM Tree(TiDB) | 顺序写(Sequential Write) | 不管改哪行数据,都直接追加到内存和 WAL 末尾 |
磁盘特性: 机械硬盘的顺序写速度是随机写的 100 倍;即使是 NVMe SSD,顺序写也能跑满带宽。
读取速度:B+ 树稳赢,但 LSM 也没输太惨
B+ 树: 高度为 3,根节点和第二层索引通常在内存里,查询 = 1 次物理 I/O。稳得可怕。
LSM Tree: 数据可能在 MemTable、L0、L1…L6。理论上需要多次 I/O,但有两个"作弊神器":
神器一:布隆过滤器 (Bloom Filter)
每个 SSTable 文件都附带一个 Bloom Filter:
- 回答"绝对没有" → 直接跳过这个文件,0 次磁盘 I/O
- 回答"可能有" → 才去读这个文件
效果: 能过滤掉 99% 不包含目标 Key 的 SSTable 文件。
神器二:Block Cache
热点数据的 SSTable Block 会被缓存在内存中,命中时等同于读内存。
实测对比
| 场景 | MySQL (B+ Tree) | TiDB (LSM Tree) |
|---|---|---|
| 点查(Point Lookup) | 极度稳定,约 1 次 I/O | 最好 0 次(MemTable 命中),普通 1-2 次,最坏多次(Bloom Filter 误判) |
| 范围查询(Range Scan) | 叶子节点有双向链表,顺序读极快 | 需要在多个层级做归并排序,消耗 CPU |
SSD 改变了战局
| 硬件 | 顺序读写 | 随机读写 | 差距 |
|---|---|---|---|
| 机械硬盘 (HDD) | 100MB/s | 1MB/s | 100 倍 |
| 固态硬盘 (SSD) | 2000MB/s | 500MB/s | 4~5 倍 |
SSD 的随机读能力非常强,这在一定程度上削弱了 LSM Tree 顺序写的优势,同时也掩盖了 LSM Tree 多次随机读的劣势。
一句话总结
如果没有布隆过滤器,LSM Tree 的读取速度可能是 B+ 树的 1/10;但加上布隆过滤器和 SSD 后,两者的点查性能差距通常在 10%~20% 以内,而 LSM 却换来了 10 倍 的写入性能提升。
多层 SSTable 结构详解(Leveled Compaction)
TiKV 默认配置是 7 层(Level 0 到 Level 6),每一层的容量通常是上一层的 10 倍。
各层职责
Level 0 (L0) —— “无序的乱流区”
- 来源: MemTable 写满后直接 Flush 成为 L0 的一个 SSTable 文件
- 特点: Key 是重叠的(Overlapping),因为按时间顺序生成,没有经过合并
- 读取代价: 必须遍历 L0 层的所有文件
- 控制: TiKV 严格控制 L0 的文件数量(通常只有几个),一旦多了马上触发 Compaction
Level 1 ~ Level 6 —— “有序的图书馆”
- 来源: 由上一层的数据经过 Compaction(合并排序)下沉而来
- 特点: 全局有序,互不重叠
- 读取优势: 通过 Key 可以直接定位到唯一的一个 SSTable 文件(支持二分查找)
Compaction 机制(数据下沉)
L0 -> L1 (Major Compaction)
- 当 L0 文件太多(例如超过 4 个),系统把 L0 的文件和 L1 里 Key 范围有重叠的文件一起读取
- 在内存中进行多路归并排序(Merge Sort)
- 生成新的、无重叠的 SSTable 文件,放回 L1
注:这一步最耗费 CPU,因为 L0 是乱序的。
L1 -> L2 -> … -> L6
当某层大小超过阈值,挑选一个文件,找到下一层中和它范围重叠的文件,合并、排序、去重、删除(处理墓碑),写入下一层。
查询路径
当 TiDB 发起一个查询 Get(Key="user_1"):
- MemTable: 先看内存里有没有最新的(速度最快)
- Immutable MemTable: 看准备刷盘的那块内存
- L0 SSTables: 依次查询每一个文件(按时间倒序)—— 这里最慢
- L1 SSTables: 根据 Key 范围定位到唯一的一个文件,问 Bloom Filter
- L2 … L6: 同上,每一层只读 1 个文件
总结
- L0 层: 是写性能的缓冲区,允许乱序,写入极快,但读取慢(必须全扫)
- L1-L6 层: 是读性能的保障区,严格有序,读取快(二分查找),但写入需要付出 Compaction 的代价
LSM Tree 是"用后台的 CPU 和 I/O 资源(做 Compaction),来换取前台的极速写入和较好的读取性能"。
布隆过滤器(Bloom Filter)详解
布隆过滤器是计算机科学中设计最巧妙的数据结构之一。
核心逻辑: 如果布隆过滤器说"没有",那就是 100% 没有;如果它说"有",那是**“可能有”**。
工作原理
想象一个非常长的位数组(Bit Array),初始全都是 0,同时有几个哈希函数。
写入数据(存入 “TiKV”)
- 用 3 个哈希函数计算:
Hash1("TiKV")=3,Hash2("TiKV")=5,Hash3("TiKV")=8 - 把位数组中下标为 3、5、8 的格子全都置为 1
查询数据
情况 A:绝对不存在
- 查 “MySQL”:
Hash1=3,Hash2=6,Hash3=8 - 第 6 位是 0 → 绝对从来没被存进去过
情况 B:可能存在(误判)
- 查 “Oracle”:
Hash1=3,Hash2=8,Hash3=5 - 第 3、5、8 位全都是 1 → 系统说"可能存在"
- 真相: 其实从来没存过 “Oracle”,这是哈希碰撞导致的假阳性(False Positive)
在 TiKV 中的作用
TiKV 的磁盘上存了成千上万个 SSTable 文件。当你要找 Key = 10086 时:
- TiKV 拿到 SSTable 文件头部的 Bloom Filter(通常缓存在内存里)
- 问 Bloom Filter:“在这个文件里有 10086 吗?”
- “绝对没有” → 直接跳过,节省一次磁盘 I/O
- “可能有” → 去磁盘加载这个 SSTable 的数据块
核心价值: 能帮数据库过滤掉 99% 以上不包含目标 Key 的文件。
优缺点
| 优点 | 缺点 |
|---|---|
| 极其节省空间: 存 10 亿个 Key 可能只需要几百 MB | 存在误判(False Positive): 会把"没有"说成"有" |
| 极快: 查询和插入都是 O(k),跟数据量无关 | 不支持删除: 不能简单地把位还原成 0 |
| 绝无"漏网之鱼": 说"没有"就绝对没有 | - |
一句话总结
布隆过滤器是一个**“内存很小、不说谎但偶尔会看走眼”**的看门大爷。他能极其高效地告诉你:“这人肯定不在屋里,别进去找了”,从而省下了无数次推门进去(读磁盘)的力气。
TiDB 查询 L0 层的真实流程
TiDB 在 L0 上不是对文件内容的"全量顺序扫描",而是对 L0 层所有文件的**“逐个排查”**。
查询流程还原
假设 L0 层有 4 个 SSTable 文件(File A, B, C, D),按时间顺序生成(A 最新)。查询 Key = 100:
第 1 步:查 File A
- 元数据检查(内存):Key 范围是 200~300
- 判断:100 不在范围内 → 直接跳过
第 2 步:查 File B
- 元数据检查:Key 范围是 0~150,命中
- Bloom Filter 检查(内存):回答"没有" → 跳过
第 3 步:查 File C
- 元数据检查:范围 50~200,命中
- Bloom Filter 检查:回答"可能有"
- 文件内部查找(读取磁盘):
- SSTable 内部也是有序的,有 Index Block
- 二分查找定位到具体的 Data Block(通常 4KB)
- 只加载那个 Data Block,解析出来
- 结果:找到了!返回 Value
第 4 步:查 File D
- 因为在 File C 里已经找到了,File D 不需要看了
为什么 L0 还是慢?
虽然用到了二分查找和布隆过滤器,但 L0 的机制依然比 L1~L6 慢:
| 问题 | 说明 |
|---|---|
| 必须 Loop 每一个文件 | L1 层可以直接定位到唯一的一个文件,L0 层必须一个个问 |
| 多次 I/O 的风险 | Bloom Filter 误判或范围查询时,可能需要读取多个文件 |
查询 L1~L6: 最多读 1 个文件 查询 L0: 最坏情况可能读 N 个文件
范围查询(Range Scan)
如果是 SELECT * FROM t WHERE id > 100 AND id < 200:
- TiKV 打开 L0 层所有涉及该范围的文件
- 同时建立多个迭代器(Iterator)
- 在内存中对比这些迭代器当前指的值,谁最小选谁(还要处理覆盖逻辑)
这个过程非常消耗 CPU,因为要不断地比较、推进迭代器。
MemTable 刷盘到 L0 的过程
排序:其实早已完成
MemTable 的底层数据结构通常是 SkipList(跳表),这是一种天然有序的数据结构。
- 当你往 TiKV 写数据时,它在插入的那一瞬间,就已经被安插到了正确的位置上
- 刷盘时,Flush 线程只需要从头到尾遍历这个 SkipList,按顺序把数据拿出来写到磁盘上即可
- 不需要再进行一次耗时的排序算法
元数据生成:边写边算
在把数据从内存顺序写入磁盘文件的过程中,TiKV 会同时计算并生成大量元数据,追加到 SSTable 文件的末尾(Footer):
| 元数据 | 作用 |
|---|---|
| Min/Max Key | 记录文件里的最小和最大 Key,用于快速判断"不在本文件" |
| Bloom Filter | 把文件里所有的 Key 都算一遍哈希,生成布隆过滤器的位图 |
| Index Block | 记录数据块的位置,支持文件内部的二分查找 |
L0 SSTable 的双重性质
| 维度 | 特点 |
|---|---|
| 内部(Internally) | 绝对完美有序,带有完整的索引和布隆过滤器 |
| 外部(Globally) | 和其他 L0 文件是"乱序/重叠"的 |
这就导致了"必须逐个检查 L0 文件"的问题。
SSTable 二分查找 vs B+ 树多次随机 I/O
核心纠正
SSTable 的"二分查找"并不是在磁盘上跳来跳去的二分查找。如果真的在磁盘上做标准的二分查找,SSTable 会慢到令人发指。
SSTable 的真实查找流程
假设一个 SSTable 文件有 256MB,查询 Key = 100:
第 1 步:查 Index Block(内存操作)
- SSTable 的末尾有一个索引块,通常缓存在内存里
- 在索引里二分查找,发现 Key = 100 在第 5 个数据块(Block 5),位置是
Offset: 16KB, Length: 4KB
第 2 步:精准 I/O(磁盘操作)
- TiKV 只读取这 4KB 的数据,而不是 256MB 的整个文件
第 3 步:块内搜索(内存操作)
- 把这 4KB 数据解压到内存
- 在这个 4KB 的小范围内做二分查找
总结: SSTable 的查找 = 0 次磁盘二分 + 1 次磁盘精准读取
MySQL B+ 树的查找流程
假设查 ID = 100:
- 查根节点(Root Page): 通常常驻内存
- 查分支节点(Internal Page): 如果内存里有直接查,没有就读磁盘
- 查叶子节点(Leaf Page): 把 16KB 整个加载到 Buffer Pool
- 页内搜索: 通过 Page Directory 进行二分查找
终极对比
| 特性 | TiDB (SSTable) | MySQL (B+ Tree) |
|---|---|---|
| 最小读取单位 | Block (通常 4KB) | Page (通常 16KB) |
| 如何定位目标块? | 依靠计算和索引: Bloom Filter 确认存在 → Index Block 算出 Offset | 依靠指针: 父节点记录了子节点的 Page ID,一层层跳过去 |
| 磁盘 I/O 行为 | 跳跃式: 直接算出位置,空降过去读取 | 寻路式: 如果父节点不在内存,可能要读多次盘 |
一句话概括
- TiDB 像是有 GPS 坐标: Bloom Filter 确认你在,Index 告诉我你在第几排第几列,我直接空降过去拿那一本书(Block)
- MySQL 像是查路标: 先看路口的牌子(Root),往左走;再看路口的牌子(Internal),往右走;最后走到房子门口(Leaf),进去拿那一页(Page)
为什么 TiDB 选择 LSM Tree 而不是 B+ 树
TiDB 的定位是**“分布式”且“支持海量数据写入”**的数据库。在这个定位下,B+ 树有几个致命的弱点。
原因一:写入性能的鸿沟
| 引擎 | 问题 |
|---|---|
| B+ 树 | 当数据量达到 TB 级别,且写入是随机的(如 UUID 主键),需要不断地把磁盘上的 Page 读出来、修改、写回去。更糟糕的是页分裂(Page Split),会导致大量的磁盘操作和锁竞争。这被称为**“写墙”(Write Wall)** |
| LSM Tree | 不管改哪个 Key,对于磁盘来说永远都是 Append Only。顺序写磁盘的速度是随机写的几十倍甚至上百倍 |
场景: TiDB 经常被用来应对"双11"这种流量洪峰,或者日志类数据的海量插入。
原因二:分布式架构下的"数据迁移"
TiDB 是分布式的,数据会在不同的 TiKV 节点之间搬来搬去(Rebalance)。
| 引擎 | 迁移难度 |
|---|---|
| B+ 树 | 是一个原地更新的复杂结构。迁移数据需要从复杂的树结构里拆出一部分,锁定,传输,再在另一台机器上重建树结构。非常复杂且难以保证一致性 |
| LSM Tree | SSTable 文件是**不可变(Immutable)**的。快照极快,只需要直接把文件通过网络发过去就行了(Send File) |
原因三:存储成本(压缩率)
| 引擎 | 空间利用率 |
|---|---|
| B+ 树 | Page 通常不会填满(为了预留空间给 Update),典型空间利用率只有 50%~70%。而且很难做高压缩 |
| LSM Tree | SSTable 是一次性生成的,数据紧紧挨在一起。前缀压缩效果极好 |
实测数据: 同样的数据存入 TiDB,通常只占用 MySQL 磁盘空间的 30%~50%。
原因四:站在巨人的肩膀上(RocksDB)
TiDB 团队并没有从零手写存储引擎,而是直接利用经过工业界大规模验证的 RocksDB 作为底层,把精力花在处理 Raft 共识协议和分布式事务上。
总结对比
| 维度 | B+ 树 (MySQL) | LSM Tree (TiDB) | 谁赢? |
|---|---|---|---|
| 读性能 | 极好(稳定 1 次 IO) | 稍弱(依赖 Cache/Bloom Filter) | B+ 树赢 |
| 写性能 | 一般(随机 IO 瓶颈) | 极强(顺序写) | LSM 赢(关键) |
| 空间利用率 | 一般(有碎片) | 极高(压缩好) | LSM 赢(省钱) |
| 扩容/迁移 | 困难(修改复杂结构) | 容易(直接发文件) | LSM 赢(分布式核心) |
一句话总结
TiDB 并不是为了"找麻烦"才选复杂的 LSM Tree,而是因为在分布式和海量写入的场景下,B+ 树"撑不住"了。LSM Tree 虽然读取逻辑复杂一点,但它换来了十倍的写入吞吐量和极高的压缩率,这对分布式数据库来说是最划算的交易。