面试八股3.0

面试八股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 GCFull 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),效率极低。

  1. 提升效率:

    • 新生代: 只有少量对象存活,使用复制算法,只需处理少量存活对象,速度极快。

    • 老年代: 对象变动少,不需要频繁回收,减少了全堆扫描的次数。

  2. 减少 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)的世界里,“标记"其实包含两个层面的含义:

  1. 判定标准: 怎么知道哪个对象是垃圾?(死活判定
  2. 执行手段: 找出垃圾后,用什么算法去清理?(垃圾收集算法

第一步:判定对象死活(怎么标记?)

在 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 类:

    1. 虚拟机栈(栈帧中的局部变量表) 中引用的对象。
    2. 方法区中类静态属性 引用的对象。(static User u = ...
    3. 方法区中常量 引用的对象。(static final User u = ...
    4. 本地方法栈中 JNI (Native 方法) 引用的对象。

第二步:执行垃圾收集(标记完怎么清理?)

当 GC 通过可达性分析标记出了哪些是活的、哪些是死的之后,就需要具体的算法来进行内存回收。常见的有以下三种:

1. 标记-清除算法 (Mark-Sweep)

这是最基础的算法。

  • 过程:

    1. 标记: 把所有活动对象标记出来。
    2. 清除: 统一回收未被标记的对象。
  • 优点: 简单,不需要移动对象。

  • 缺点: 内存碎片化: 清除后会产生大量不连续的内存碎片。

  • 适用场景: 老年代(CMS 收集器就是基于此)。

2. 标记-复制算法 (Mark-Copy)

为了解决碎片问题而生。

  • 过程:

    1. 将内存分为大小相等的两块(A 和 B),每次只使用其中一块。
    2. 标记 A 中的存活对象。
    3. 复制 将 A 中所有存活的对象,整齐地复制到 B 中。
    4. 清理 一次性清空 A。
  • 优点: 没有内存碎片,分配内存简单(指针碰撞)。

  • 缺点: 空间浪费(内存利用率只有 50%)。

  • 优化: 在 HotSpot 的新生代中,使用 Eden : Survivor : Survivor = 8 : 1 : 1 的比例,只浪费 10% 的空间。

  • 适用场景: 新生代(存活率低,复制成本低)。

3. 标记-整理算法 (Mark-Compact)

结合了前两者的优点。

  • 过程:

    1. 标记 存活对象。
    2. 整理 让所有存活的对象向内存的一端移动(滑动整理)。
    3. 清理 直接清理掉边界以外的内存。
  • 优点: 没有内存碎片,也不浪费空间。

  • 缺点: 移动对象需要更新引用地址,成本较高,且需要暂停用户线程 (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) —— 里程碑式产品

  • 核心特点: 并发收集、低停顿

  • 工作流程 (重点):

    1. 初始标记 (Initial Mark): STW。只标记 GC Roots 直接关联的对象(速度很快)。
    2. 并发标记 (Concurrent Mark): 不 STW。GC 线程和用户线程一起跑,遍历整个对象图。
    3. 重新标记 (Remark): STW。修正并发期间因用户程序变动而导致标记产生变动的那一部分对象。
    4. 并发清除 (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 个核心参数(面试和实战的重点):

1
2
3
4
5
6
7
8
9
public ThreadPoolExecutor(
    int corePoolSize,    // 1. 核心线程数
    int maximumPoolSize, // 2. 最大线程数
    long keepAliveTime,  // 3. 空闲线程存活时间
    TimeUnit unit,       // 4. 时间单位
    BlockingQueue<Runnable> workQueue, // 5. 任务队列
    ThreadFactory threadFactory,       // 6. 线程工厂
    RejectedExecutionHandler handler   // 7. 拒绝策略
)

参数详解:

参数 说明
corePoolSize 核心线程数,线程池中长驻的线程数量
workQueue 任务队列,核心线程都在忙时,新任务暂存于此
maximumPoolSize 最大线程数,队列满了且线程数 < 最大线程数时,创建非核心线程
keepAliveTime 空闲线程存活时间,超过 corePoolSize 的线程空闲超时后被回收
unit keepAliveTime 的时间单位
threadFactory 线程工厂,用于创建新线程(可自定义线程名)
handler 拒绝策略,队列满且线程数达到最大值时触发

三、线程池的工作流程(重要)

当一个新任务提交给线程池时,处理逻辑如下:

  1. 判断核心线程: 如果当前运行的线程数 < corePoolSize,则立即创建新线程执行任务。
  2. 进入队列: 如果核心线程都在忙(>= corePoolSize),则将任务放入 workQueue 等待。
  3. 创建非核心线程: 如果队列也满了,判断当前线程数是否 < maximumPoolSize。如果是,则创建新线程执行任务。
  4. 触发拒绝策略: 如果队列满了,且线程数已达到 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 核数 * 2CPU 核数 / (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)

  1. 协程调用读取操作,底层立即问内核:“数据到了吗?”
  2. 内核说"没到"。协程不睡,而是仅仅在用户态线程内部记录一下"我先暂停",然后立刻让出 CPU 给其他协程用。
  3. 协程把"我在等数据"这件事注册给操作系统的监听器(Epoll)。
  4. 数据到达后,监听器通知程序,协程被恢复继续执行。

总结: 协程并不是让 CPU 少干了活(搬运工作量没变),而是让 CPU 在等待期间别闲着,也别瞎折腾(切换上下文),从而能处理更多的并发任务。


四、什么时候用协程?

任务类型 特点 是否适合协程 原因
IO 密集型 网络请求、读写数据库、微服务调用 非常适合 解决了大量"等待"造成的资源浪费,能支撑超高并发
CPU 密集型 视频转码、复杂的数学计算、加密解密 不适合 任务主要在用 CPU 计算,没有时间让出控制权,协程的调度反而会增加负担

IO操作的数据搬运是由谁来做的

从 Socket 内存缓冲区(内核空间)到用户内存缓冲区(用户空间)的数据搬运,必须由 CPU 亲力亲为

但是,整个 I/O 过程并非全由 CPU 完成。我们需要把一次网络 I/O 拆解为两个阶段:


第一阶段:网卡 -> 内核缓冲区

  • 干活的主角: DMA(Direct Memory Access,直接存储器访问)
  • CPU 的角色: 基本不参与(只负责发号施令)
  1. 网线上的光电信号到达网卡(NIC)。
  2. DMA 控制器(硬件芯片)接管总线,直接把数据从网卡拷贝到内存中的内核缓冲区。
  3. 搬运完成后,DMA 会给 CPU 发送一个中断信号,告诉 CPU:“数据已经卸到内核仓库了”。

为什么要用 DMA? 如果没有 DMA,CPU 就得一个字节一个字节地从网卡读数据,效率极低。有了 DMA,CPU 就可以做"甩手掌柜"。


第二阶段:内核缓冲区 -> 用户缓冲区

  • 干活的主角: CPU
  • 状态: 昂贵的"上下文切换"和"内存拷贝"
  1. 系统调用: Java 程序执行了 socket.read()
  2. 模式切换: 线程从用户态切换到内核态。
  3. CPU 搬运: CPU 亲自上阵,将数据从内核 Socket 缓冲区逐字节拷贝到用户空间缓冲区。
  4. 返回用户态: 拷贝完成后,CPU 切换回用户态,read() 方法返回。

总结:谁在搬运?

数据流向 搬运工 (硬件) 消耗资源 备注
网卡 → 内核内存 DMA 控制器 总线带宽 CPU 几乎不参与,效率极高
内核内存 → 用户内存 CPU CPU 周期 这是性能瓶颈! CPU 必须暂停其他计算工作来搬砖

延伸:零拷贝(Zero-Copy)

既然 CPU 搬运内核到用户空间很慢,那能不能不搬?这就是 Kafka、Netty 等高性能框架使用的零拷贝技术(如 sendfilemmap)。

  • 核心思想: 如果你只是想把文件读取出来发给网卡(比如做静态资源服务器),数据其实不需要进入用户空间。
  • 做法: 数据由 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 开启多线程读。默认只开启多线程写

为什么默认不开启?

  1. 性能没瓶颈: 对于绝大多数应用程序,Redis 的瓶颈通常在于网络带宽或内存,而不是 CPU。单线程的 Redis 已经足够快(每秒处理数万至十万级请求)。

  2. 复杂性与稳定性: 引入多线程会增加系统的复杂性。保持默认关闭可以最大程度保证旧版本的行为一致性和稳定性。


重要提示:核心逻辑依然是"单线程"

多线程只负责 I/O: 处理网络数据的读(Read/Decode)和网络数据的写(Write/Encode)。

命令执行依然是单线程: 实际执行 GET、SET、LUA 脚本等操作的,依然是那个唯一的主线程。

这意味着:

  • 你依然不需要担心多线程并发带来的数据竞争问题(不需要加锁)。
  • 原子性依然得到天然保证。

单线程 Redis 会傻等吗?

绝对不会傻等。 Redis 之所以快,靠的就是 I/O 多路复用(Epoll) 技术。

  1. DMA 搬运阶段: Redis 主线程不参与,它正在忙着处理其他请求。
  2. 数据到站: 操作系统通过 Epoll 标记该 Socket “可读”。
  3. Redis 发现数据: 主线程调用 epoll_wait() 问操作系统哪些连接有数据。
  4. 搬运到用户态: 因为数据已经在内核缓冲区躺好了,read() 调用不需要等待。

结论: Redis 没开启多线程时,它绝不会等"网卡接收数据"(这是 DMA 干的),但它必须亲自做"内核到用户态的数据拷贝"(这是 CPU 干的)。

MySQL 主从同步模型

新节点加入,主节点直接传全量 Binlog 吗?

通常不会,甚至是不可能的。 如果数据库已经运行了一年,Binlog 可能早就滚动删除了。

标准的新节点同步流程是"全量快照 + 增量 Binlog":

  1. 全量数据搬运(Snapshot): 先手动将主节点(或某个现存从节点)当前的数据快照拷贝给新节点。

    • 工具:mysqldump(逻辑备份,慢)或 Percona XtraBackup(物理备份,快,生产环境首选)
  2. 记录位点(Position): 在做快照的那一瞬间,记录下当前的 Binlog 文件名和偏移量,或者 GTID。

  3. 增量追赶(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 主从复制架构中,存在于从节点上的一种特殊日志文件。你可以把它理解为从节点上的**“临时待办箱”**。

复制流程中的位置:

1
Master (Binlog) --> 网络 --> Slave I/O 线程 --> [Relay Log] --> Slave SQL 线程 --> Slave 数据

为什么要设计 Relay Log?

  1. 接收快,执行慢: 网络传输通常很快,但执行 SQL 通常很慢。有了 Relay Log,I/O 线程可以疯狂地把 Master 的日志拉取回来存在本地,哪怕 SQL 线程处理不过来也没关系。

  2. 断点续传与崩溃恢复: 如果 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)

  1. 加锁时刻: 当 CPU 执行到这条 SQL 语句时,开始申请并持有锁。
  2. 持有过程: 即使这条 SQL 执行完了,锁依然不会释放。
  3. 解锁时刻: 只有当整个事务执行了 COMMITROLLBACK 后,锁才会释放。

为什么要设计成"事务结束才释放"?

如果 SQL 执行完立刻释放锁,会发生严重的数据一致性问题(丢失更新)。

只有拿到事务结束,才能保证在你处理完这行数据之前,没有任何人能修改它。


致命的生产事故模型(千万小心)

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
@Transactional
public void buyTicket() {
    // 1. 获取锁(开启事务,加锁)
    ticketMapper.selectTicketForUpdate(ticketId); 

    // 2. 危险操作!!!
    // 在持有数据库锁的时候,去调用了第三方的 HTTP 接口
    // 假设这个 HTTP 请求卡了 3 秒...
    thirdPartyPaymentService.pay(); 

    // 3. 更新数据库
    ticketMapper.reduceStock(ticketId);

    // 4. 事务结束(释放锁)
}

后果: 在那 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) 宕机时:

  1. 侦测与确认: Manager 发现 Master(A) 连不上,多次重试确认。

  2. 选主(Election): 检查 Slave(B) 和 Slave(C),谁的 Relay Log 最完整(数据最新),谁就当新主。

  3. 数据补全(差异日志补全):

    • 如果 Master SSH 还能连:MHA 会登录到死掉的 Master 上,把还没来得及发给 Slave 的最新 Binlog 截取并应用到新主上。数据 0 丢失
    • 如果 Master 物理损坏:对比各 Slave 的 Relay Log,把差异补齐。
  4. 提升新主与重构拓扑: 对新主执行 STOP SLAVE,解除只读模式;告诉其他 Slave 执行 CHANGE MASTER TO 指向新主。

  5. 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:

  1. 执行阶段: 节点 A 先在本地内存里预执行这个事务,但不提交。生成一个写集 (Write Set)

  2. 广播与共识 (Paxos): 节点 A 把写集广播给整个小组。只要有 2 个收到并排序成功,协议就达成。

  3. 冲突检测 (Certification): 所有节点都会拿着这个写集,去跟自己内存里正在处理的其他事务比对。如果有冲突,后到的事务直接回滚。

  4. 提交 (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 的地址

故障切换流程

  1. 主观下线 (SDOWN): 一个哨兵 Ping 不通 Master,它认为 Master 挂了(主观觉得)。
  2. 客观下线 (ODOWN): 超过半数(Quorum)的哨兵都说连不上,坐实 Master 挂了。
  3. 选举领头哨兵: 哨兵们内部选出一个"领头哨兵"来执行切换操作。
  4. 选新主: 领头哨兵根据规则(优先级、复制偏移量)选出一个 Slave 升级为 Master。
  5. 广播: 通知其余 Slave 和客户端切换到新地址。

优缺点

优点 缺点
官方原生,自动化 HA 写性能无法扩展(只有一个 Master)
解决了单点故障问题 存储无法扩展(内存容量受限于单机)

Proxy 架构(Codis / Twemproxy)

这是一种中心化分片架构。在 Redis Cluster 普及之前,这是处理海量数据的主流方案。

架构拓扑

1
Client <---> Proxy (Codis/Twemproxy) <---> Redis Group 1, Group 2, ...

核心原理

  • 中心入口: 客户端只连接 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) —— 极快

  1. 写 WAL(保证持久性)
  2. 写 MemTable(内存操作,O(logN))
  3. 结束。(对客户端来说,写入已经完成了)

后台动作: 当 MemTable 满了,转为 Immutable MemTable,然后 Flush 到磁盘成为 Level 0 的 SSTable。

读取流程(Read) —— 稍慢

因为数据可能散落在内存和磁盘的不同层级中,读取需要分层查找:

  1. 查 MemTable
  2. 查 Immutable MemTable
  3. 查 L0 层 SSTable(文件间 Key 可能重叠,需要遍历)
  4. 查 L1, L2… 层 SSTable

优化: 使用布隆过滤器 (Bloom Filter)。在打开一个 SSTable 文件前,先问布隆过滤器:“这个 Key 可能在这里吗?“如果回答"不在”,就直接跳过该文件。

核心机制:Compaction(合并/压缩)

LSM Tree 需要在后台运行 Compaction 进程:

  1. Merge: 把多个小的、重叠的 SSTable 读取出来
  2. Sort: 进行归并排序
  3. Clean: 丢弃旧版本数据,物理删除带有"墓碑标记"的数据
  4. 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)

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
CREATE TABLE user (
    id INT PRIMARY KEY,
    name VARCHAR(20),
    age INT,
    KEY idx_name_age (name, age)
);

INSERT INTO user VALUES (1, 'Alice', 18);
INSERT INTO user VALUES (2, 'Bob', 20);
INSERT INTO user VALUES (3, 'Alice', 19);

TiDB 是怎么把它们变成 KV 的?

存"行数据" (Row)

1
2
Key: t{表ID}_r{行ID}
Value: [Alice, 18, ...] (整行数据)

存"联合索引" (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:最左前缀匹配

1
SELECT * FROM user WHERE name = 'Alice';

TiDB 动作:

  1. 构造一个扫描范围:从 t10_i1_Alice 开始
  2. TiKV 直接定位到 t10_i1_Alice_18_1
  3. 接着往下读,读到 t10_i1_Alice_19_3
  4. 再往下读,发现是 Bob 了,停止

结果: 拿到了 ID 1 和 3。这和 B+ 树的范围查找完全一样!

场景 2:联合查询

1
SELECT * FROM user WHERE name = 'Alice' AND age = 19;

TiDB 动作:直接构造精确的 Key 前缀 t10_i1_Alice_19,TiKV 直接 Seek 定位。

场景 3:索引失效(跳过最左前缀)

1
SELECT * FROM user WHERE age = 19;

索引 Key 是按 name 排序的。age=19 的数据并不是挨在一起的,没办法 Scan,只能全表扫描。这和 MySQL 的索引失效原理完全一致。


总结

TiDB 并没有像 MySQL 那样维护复杂的树指针,它用了一种"降维打击"的方式:

  1. 映射: 把多维的"联合索引 (a, b, c)" 扁平化映射成一维的 Key ..._a_b_c
  2. 排序: 利用底层 RocksDB/LSM Tree 对 Key 的天然有序性
  3. 扫描: 把 SQL 的范围查询转换成 KV 存储的 Prefix Scan(前缀扫描)

所以,你在 MySQL 里学的索引原则(最左前缀、索引覆盖、索引下推),在 TiDB 里 100% 适用。

TiDB 多个索引是否要多存几份表?

你的理解是对的,但有一个关键的定语需要修正: 并不是"多存几份表",而是"多存几份索引列的数据"。

这是数据库领域最核心的交换法则:空间换时间(Space for Time)


并没有存"整张表",只存了"路标"

假设你的 user 表非常宽,有很多列:id, name, age, address, bio, photo_url, create_time...

第一份:主键/行数据 (Row Data) —— 它是真身

1
2
Key: t10_r1 (主键 ID)
Value: {name:Alice, age:18, address:北京, bio:..., photo:..., ...} (整行所有数据都在这)

大小: 假设这一行有 1KB。

第二份:联合索引 (Secondary Index) —— 它是影子

假设你建了索引 idx_name_age (name, age)

1
2
Key: t10_i1_Alice_18_1 (索引 ID + 索引列的值 + 主键 ID)
Value: null (几乎不占空间)

它只复制了 name("Alice")age(18) 这两个字段,加上一个主键 ID(1)。其他的 addressbiophoto 统统不存。

大小: 这一行 KV 可能只有 50 Bytes。


回表(Back to Table):因为没存整表

正因为索引 KV 里只有 nameage,没有 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 拿出来
    • 这就是所谓的**“回表”**代价

TiDB 相比 MySQL 的"空间"优势

虽然逻辑上 TiDB 和 MySQL 都要存这些索引副本,但在物理存储上,TiDB(RocksDB)有一个巨大的优势:前缀压缩(Prefix Compression)

在 TiDB (LSM Tree) 中,RocksDB 会利用 Delta EncodingBlock 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),查找时:

  1. 布隆过滤器: 先看一眼文件头,如果过滤器说"这里面没有 Alice",直接跳过整个文件
  2. 查索引块: 发现 Alice 应该在第 0~10KB 这个块里
  3. 二分查找: 只把这个小块加载到内存,然后做一次二分查找

结论: 无论文件有多大,找到 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)

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
用户请求 (浏览器)
   |
   v
[ TCP 连接 (Socket) ] <--- 限制由 maxConnections (10000) 控制
   |
   | (IO多路复用, Epoll 监听)
   v
[ Poller 线程 ] (极少,负责搬运数据)
   |
   | (数据读取完毕,准备执行业务)
   v
[ Worker 线程池 ] <--- 限制由 maxThreads (200) 控制
   |
   | (执行 Java 代码: Controller -> Service -> Dao)
   v
[ DB 连接池 (HikariCP) ] <--- 限制由 max-size (10) 控制
   |
   v
MySQL

常见误区纠正

误区: “把 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 中适当调大:

1
2
3
4
5
6
7
server:
  tomcat:
    threads:
      max: 500       # 建议值:IO密集型任务可调至 500-800
      min-spare: 50  # 最小空闲线程,保证有任务来能立马干活
    max-connections: 10000 # 如果并发连接确实很高,可以改回 10000 甚至 20000
    accept-count: 200 # 稍微排长一点队

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 的解决方案:

  1. 彻底的异步非阻塞 (Spring WebFlux / Netty):代码难写,但性能和虚拟线程一样强
  2. 集群 + 增加配置:调大 maxThreads,上多台机器通过 Nginx 负载均衡
  3. 升级 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 文件):

1
2
-XX:+HeapDumpOnOutOfMemoryError 
-XX:HeapDumpPath=/tmp/heapdump.hprof

有了这份 .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 时指定

关键互动流程:

1
2
3
4
5
6
7
8
9
@GetMapping("/ai-chat")
public void chat() {
    // 1. 此时占用 1 个 Tomcat 线程
    myCustomThreadPool.submit(() -> {
        // 2. 这里的代码在你的自定义线程池里跑,不占用 Tomcat 线程
        doHeavyAiWork(); 
    });
    // 3. 关键点:Tomcat 线程是释放了,还是得等着?
}

两种情况:

做法 Tomcat 线程状态 并发效果
傻等(Future.get() 被占用 并发没提升
异步响应(DeferredResult / SseEmitter 立即释放 并发大幅提升

SseEmitter 异步模式详解

正确的做法: Tomcat 线程池 + SseEmitter + 自定义异步线程池

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
@GetMapping("/ai-stream")
public SseEmitter stream() {
    SseEmitter emitter = new SseEmitter(5 * 60 * 1000L); // 5分钟超时

    // Tomcat 线程只做这一件事:创建 emitter,扔给后台,立即返回
    aiTaskExecutor.submit(() -> {
        try {
            // 这里在自定义线程池里跑,不占用 Tomcat 线程
            doAiStreamWork(emitter);
        } catch (Exception e) {
            emitter.completeWithError(e);
        }
    });

    return emitter; // Tomcat 线程瞬间释放(2~5ms)
}

效果:

  • Tomcat 线程池不再是瓶颈,QPS 恢复到几千甚至上万
  • 压力转移到:自定义线程池Tomcat 的 maxConnections(8192)

针对 Java 17 的优化建议

1. 调整自定义线程池配置

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
@Bean("aiTaskExecutor")
public Executor aiTaskExecutor() {
    ThreadPoolTaskExecutor executor = new ThreadPoolTaskExecutor();
    executor.setCorePoolSize(200);      // 常驻并发量
    executor.setMaxPoolSize(2000);      // 同时服务的上限(Java 17 单机极限)
    executor.setQueueCapacity(500);     // 超过 2000 人时的排队数量
    executor.setThreadNamePrefix("AI-Stream-");
    executor.initialize();
    return executor;
}

2. 调整 JVM 线程栈大小

因为后台线程主要是"搬运工"(读 InputStream 写 OutputStream),不需要很深的调用栈:

1
-Xss256k  # 默认 1MB,改成 256k 后 2000 个线程只占 500MB 内存

3. 优雅处理超时

1
2
3
SseEmitter emitter = new SseEmitter(5 * 60 * 1000L); // 5分钟超时
emitter.onCompletion(() -> { /* 清理资源 */ });
emitter.onTimeout(() -> { /* 超时处理 */ });

总结

方案 Tomcat 线程 并发能力 适用场景
直接阻塞 被占满 极低(200 并发) 快速接口(<100ms)
SseEmitter + 自定义线程池 立即释放 高(受自定义线程池限制) AI 流式、长连接
Java 21 虚拟线程 自动调度 极高(数万并发) 终极方案

TiDB 新节点发现与数据调度

新 TiKV 是如何被发现的?(节点注册)

当你在新机器上启动一个 TiKV 实例时,它是**主动向集群"报到"**的。核心调度者是 PD (Placement Driver),而不是 TiDB Server。

流程:

  1. 读取配置: 新 TiKV 读取配置文件,找到 PD 节点的地址(pd-endpoints
  2. 发送心跳: 新 TiKV 向 PD 发送 RPC 请求,告诉 PD:“我是新来的,我的 IP 是多少,我的磁盘容量是多少”
  3. PD 注册: PD 为新节点分配一个全局唯一的 Store ID,将其状态标记为 Up

注:TiDB Server 此时可能还不知道新节点,它只有在读写数据时发现路由变化,才会更新本地的 Region Cache。


什么时候把数据存过去?(调度与平衡)

数据基于 Region(数据分片) 为单位,通过 Raft 协议 逐步迁移,完全由 PD 的调度器控制。

流程:

  1. 信息收集: 所有 TiKV 节点定期(默认每 10 秒)向 PD 发送 Store Heartbeat

    • 旧节点汇报:我有 1000 个 Region,磁盘使用了 80%
    • 新节点汇报:我有 0 个 Region,磁盘使用了 1%
  2. 生成调度策略: PD 的 balance-region-scheduler 发现新节点分数极低,决定把旧节点的一些 Region 搬运过去

  3. 下发操作指令: PD 生成 MovePeer Operator

    • Add Peer:在新节点上增加一个副本
    • Remove Peer:数据同步完成后,删掉旧节点上多余的副本
  4. 执行搬运(Raft):

    • Leader 向新节点发送 Snapshot(快照数据)
    • 快照期间的新写入通过 Raft Log 同步
    • 新节点数据追平后,正式成为 Raft Group 成员

TiDB Server 是怎么感知到的?

TiDB Server 在这个过程中是被动的

  1. 缓存失效: TiDB Server 维护了 Region Cache(Region ID -> TiKV 地址的映射)
  2. 请求重试: 数据迁移后,旧 TiKV 会返回 Region Error
  3. 重新拉取: 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

写入流程:

  1. 用户写入 Key="A", Value="1"
  2. TiKV Leader 将操作封装成 Log Entry,写入 raftdb(落盘)
  3. Leader 通过网络把 Log Entry 发给 Followers
  4. Followers 收到后,也写入自己的 raftdb
  5. 达成共识后,Leader 和 Followers 再把数据写入 kvdb(这步叫 Apply)
  6. 旧的 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)

  1. 当 L0 文件太多(例如超过 4 个),系统把 L0 的文件和 L1 里 Key 范围有重叠的文件一起读取
  2. 在内存中进行多路归并排序(Merge Sort)
  3. 生成新的、无重叠的 SSTable 文件,放回 L1

注:这一步最耗费 CPU,因为 L0 是乱序的。

L1 -> L2 -> … -> L6

当某层大小超过阈值,挑选一个文件,找到下一层中和它范围重叠的文件,合并、排序、去重、删除(处理墓碑),写入下一层。


查询路径

当 TiDB 发起一个查询 Get(Key="user_1")

  1. MemTable: 先看内存里有没有最新的(速度最快)
  2. Immutable MemTable: 看准备刷盘的那块内存
  3. L0 SSTables: 依次查询每一个文件(按时间倒序)—— 这里最慢
  4. L1 SSTables: 根据 Key 范围定位到唯一的一个文件,问 Bloom Filter
  5. L2 … L6: 同上,每一层只读 1 个文件

总结

  • L0 层: 是写性能的缓冲区,允许乱序,写入极快,但读取慢(必须全扫)
  • L1-L6 层: 是读性能的保障区,严格有序,读取快(二分查找),但写入需要付出 Compaction 的代价

LSM Tree 是"用后台的 CPU 和 I/O 资源(做 Compaction),来换取前台的极速写入和较好的读取性能"。

布隆过滤器(Bloom Filter)详解

布隆过滤器是计算机科学中设计最巧妙的数据结构之一。

核心逻辑: 如果布隆过滤器说"没有",那就是 100% 没有;如果它说"有",那是**“可能有”**。


工作原理

想象一个非常长的位数组(Bit Array),初始全都是 0,同时有几个哈希函数

写入数据(存入 “TiKV”)

  1. 用 3 个哈希函数计算:Hash1("TiKV")=3, Hash2("TiKV")=5, Hash3("TiKV")=8
  2. 把位数组中下标为 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 时:

  1. TiKV 拿到 SSTable 文件头部的 Bloom Filter(通常缓存在内存里)
  2. 问 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

  1. TiKV 打开 L0 层所有涉及该范围的文件
  2. 同时建立多个迭代器(Iterator)
  3. 在内存中对比这些迭代器当前指的值,谁最小选谁(还要处理覆盖逻辑)

这个过程非常消耗 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

  1. 查根节点(Root Page): 通常常驻内存
  2. 查分支节点(Internal Page): 如果内存里有直接查,没有就读磁盘
  3. 查叶子节点(Leaf Page): 把 16KB 整个加载到 Buffer Pool
  4. 页内搜索: 通过 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 虽然读取逻辑复杂一点,但它换来了十倍的写入吞吐量极高的压缩率,这对分布式数据库来说是最划算的交易。

Licensed under CC BY-NC-SA 4.0