JAVA +AI系列

发布于
JAVA +AI系列

JAVA +AI系列

第一章 JVM(8题)


1. JVM 内存结构(堆、栈、方法区、程序计数器、本地方法栈)

标准答案

JVM 运行时数据区分为线程私有和线程共享两大部分:

【线程私有】

1. 程序计数器(Program Counter Register)

  • 作用:当前线程执行的字节码行号指示器,指向下一条要执行的字节码指令

  • 特点:唯一不会抛出 OOM 的区域;线程切换后能恢复到正确执行位置

  • 执行 Java 方法时记录字节码指令地址;执行 Native 方法时为空(Undefined)

2. 虚拟机栈(VM Stack)

  • 作用:每个方法执行时创建一个栈帧(Stack Frame),存储局部变量表、操作数栈、动态链接、方法出口

  • 局部变量表:存放基本数据类型(boolean/byte/char/short/int/float/long/double)、对象引用(reference)、returnAddress

  • 异常:

    • StackOverflowError:栈深度溢出(递归过深)

    • OutOfMemoryError:栈扩展时内存不足

  • 参数:-Xss 控制每个线程栈大小,JDK8 默认 1M

3. 本地方法栈(Native Method Stack)

  • 作用:为 Native 方法服务,与虚拟机栈作用类似

  • HotSpot 中直接将本地方法栈和虚拟机栈合二为一

【线程共享】

4. 堆(Heap)

  • 作用:存放对象实例和数组,是 GC 的主要区域("垃圾收集堆")

  • 分代结构:

    • 新生代(Young Generation):Eden + S0(From Survivor)+ S1(To Survivor),默认比例 8:1:1

    • 老年代(Old Generation)

  • 核心参数:

    • -Xms:初始堆大小

    • -Xmx:最大堆大小

    • -Xmn:新生代大小

    • -XX:SurvivorRatio=8:Eden 与 Survivor 比例

  • 异常:OutOfMemoryError: Java heap space

5. 方法区(Method Area)

  • 作用:存储已被虚拟机加载的类信息、常量、静态变量、即时编译器编译后的代码缓存等

  • 演进:

    • JDK7 及之前:永久代(PermGen)实现,参数 -XX:PermSize、-XX:MaxPermSize

    • JDK8 及之后:元空间(Metaspace)实现,使用本地内存,参数 -XX:MetaspaceSize、-XX:MaxMetaspaceSize

  • 运行时常量池:方法区的一部分,存放编译期生成的字面量和符号引用

  • 异常:OutOfMemoryError: Metaspace / PermGen space

【直接内存(Direct Memory)】

  • 不是 JVM 运行时数据区的一部分,但也被频繁使用

  • NIO 的 DirectByteBuffer 使用 Native 函数库直接分配堆外内存

  • 参数:-XX:MaxDirectMemorySize,默认与 -Xmx 一致

Direct Memory

高频追问

追问1:对象在内存中的布局是怎样的?

对象在内存中分为三部分:

| 部分 | 说明 | |------|------| | 对象头(Header) | Mark Word(哈希码、GC 分代年龄、锁状态标志、线程持有的锁等)+ 类型指针(指向方法区中类元数据的指针) | | 实例数据(Instance Data) | 对象真正存储的有效信息,即字段内容 | | 对齐填充(Padding) | 保证对象起始地址是 8 字节的整数倍,起到占位作用 |

追问2:对象创建的完整过程?

  1. 类加载检查:检查常量池中是否能定位到类的符号引用,该类是否已加载、解析、初始化

  2. 分配内存:

    • 堆内存规整(有压缩的 GC 算法)→ 指针碰撞(Bump the Pointer)

    • 堆内存不规整 → 空闲列表(Free List)

  3. 初始化零值:将分配到的内存空间都初始化为零值(不包括对象头),保证对象字段不赋值也能使用

  4. 设置对象头:设置 Mark Word、类型指针等元信息

  5. 执行 <init> 方法:按程序员的意愿初始化对象(构造函数)

追问3:对象的访问定位方式有哪些?

  • 句柄访问:堆中划分一块内存作为句柄池,reference 存储句柄地址,句柄中包含对象实例数据和类型数据的指针

    • 优点:对象移动时只改句柄中的指针,reference 本身不动

  • 直接指针访问(HotSpot 采用):reference 直接存储对象地址,对象中包含指向类型数据的指针

    • 优点:速度快,节省一次指针定位开销

追问4:为什么需要 Survivor 区?只有 Eden 行不行?

如果没有 Survivor,Eden 区每满一次就触发一次 Minor GC,存活对象直接进入老年代,老年代很快被填满触发 Full GC。

Survivor 的作用是减少被送到老年代的对象数量,只有经过 16 次(默认)Minor GC 仍存活的对象才会进入老年代。

设置两个 Survivor 区(S0、S1)是为了解决内存碎片化问题,使用复制算法保证始终有一个 Survivor 区是空的。

追问5:为什么默认分代年龄是 15 次?

因为对象头的 Mark Word 中用 4 个 bit 位存储分代年龄,最大值是 1111 即 15。


2. 类加载机制与双亲委派

标准答案

【类加载过程】

类从被加载到虚拟机内存中开始,到卸载出内存为止,整个生命周期:

加载 → 验证 → 准备 → 解析 → 初始化 → 使用 → 卸载
        └──── 连接(Linking)────┘

1. 加载(Loading)

  • 通过类的全限定名获取定义类的二进制字节流

  • 将字节流代表的静态存储结构转化为方法区的运行时数据结构

  • 在内存中生成代表这个类的 java.lang.Class 对象,作为方法区这个类的各种数据的访问入口

2. 验证(Verification)

  • 目的:确保 Class 文件的字节流中包含的信息符合当前虚拟机的要求

  • 四个阶段:

    • 文件格式验证:验证字节流是否符合 Class 文件格式规范

    • 元数据验证:语义分析,确保符合 Java 语言规范

    • 字节码验证:数据流和控制流分析,确保程序语义合法

    • 符号引用验证:对类自身以外的信息进行匹配性校验

3. 准备(Preparation)

  • 为类变量(static 变量)分配内存并设置零值

  • 这些变量使用的内存都将在方法区中分配

  • 注意:初始值是数据类型的零值(0、null、false 等),不是代码中显式赋的值

  • 如果是 static final 常量,则直接赋值为常量值

4. 解析(Resolution)

  • 将常量池内的符号引用替换为直接引用的过程

  • 符号引用:以一组符号来描述所引用的目标,与虚拟机内存布局无关

  • 直接引用:直接指向目标的指针、相对偏移量或能间接定位到目标的句柄,与内存布局相关

  • 解析主要针对:类或接口、字段、类方法、接口方法等

5. 初始化(Initialization)

  • 执行类构造器 <clinit>() 方法的过程

  • <clinit>() 由编译器自动收集类中所有类变量的赋值动作和静态语句块中的语句合并产生

  • 虚拟机会保证在子类的 <clinit>() 执行前,父类的 <clinit>() 已经执行完毕

  • 接口的 <clinit>() 不需要先执行父接口的 <clinit>()

  • 虚拟机会保证一个类的 <clinit>() 方法在多线程环境中被正确地加锁、同步

【类加载器】

| 类加载器 | 职责 | 实现 | |----------|------|------| | 启动类加载器(Bootstrap ClassLoader) | 加载 JAVA_HOME/lib 目录中的核心类库 | C++ 实现 | | 扩展类加载器(Extension ClassLoader) | 加载 JAVA_HOME/lib/ext 目录中的扩展类 | Java 实现 | | 应用程序类加载器(Application ClassLoader) | 加载用户类路径(ClassPath)上的类,默认就是这个 | Java 实现 | | 自定义类加载器 | 继承 ClassLoader,重写 findClass 方法 | 用户自定义 |

【双亲委派模型】

工作过程:

  1. 如果一个类加载器收到了类加载请求,它首先不会自己去尝试加载,而是把这个请求委派给父类加载器去完成

  2. 每一个层次的类加载器都是如此,因此所有的加载请求最终都应该传送到顶层的启动类加载器中

  3. 只有当父加载器反馈自己无法完成这个加载请求时,子加载器才会尝试自己去加载

优点:

  • 避免类的重复加载:父类加载过了,子类就不用再加载

  • 安全:防止核心 API 被篡改,比如自己写的 java.lang.String 不会被加载,因为最终由启动类加载器加载 rt.jar 中的 String 类

破坏双亲委派的场景:

  1. SPI 机制(JNDI、JDBC 等):启动类加载器加载的接口需要调用应用类加载器加载的实现类 → 线程上下文类加载器

  2. 热部署/热加载:OSGi 实现模块化热部署,每个模块有自己的类加载器

  3. Tomcat:每个 Web 应用有自己的类加载器,优先加载自己目录下的类


高频追问

追问1:什么时候会触发类初始化?(主动引用 vs 被动引用)

有且只有 5 种情况必须立即对类进行初始化(主动引用):

  1. 遇到 new、getstatic、putstatic、invokestatic 这 4 条字节码指令时

  2. 使用 java.lang.reflect 包的方法对类进行反射调用时

  3. 初始化一个类时,如果其父类还没初始化,先初始化父类

  4. 虚拟机启动时,用户指定的主类(包含 main 方法的类)

  5. 使用 JDK7 的动态语言支持时,MethodHandle 实例解析结果对应静态方法句柄

被动引用(不会触发初始化):

  • 通过子类引用父类的静态字段,不会导致子类初始化

  • 通过数组定义来引用类,不会触发此类的初始化

  • 常量在编译阶段会存入调用类的常量池中,不会触发定义常量的类的初始化

追问2:ClassLoader 的 loadClass 和 findClass 的区别?

  • loadClass:是类加载的入口,实现双亲委派逻辑。先检查是否已加载,没有则调用父加载器的 loadClass,父加载器加载失败才调用自己的 findClass

  • findClass:根据类名查找类的字节码并定义类。自定义类加载器时,推荐重写 findClass 而不是 loadClass,这样不会破坏双亲委派模型

追问3:如何自定义类加载器?使用场景?

实现方式:

  1. 继承 java.lang.ClassLoader

  2. 重写 findClass 方法(不建议重写 loadClass,否则会破坏双亲委派)

  3. 在 findClass 中获取类的字节码(如从文件、网络、加密文件等)

  4. 调用 defineClass 方法将字节码转换成 Class 对象

使用场景:

  • 加密解密:Class 文件加密,加载时解密

  • 热部署/热加载:不重启应用就能加载新的类

  • 从非标准来源加载代码:如从网络、数据库等加载类

追问4:Tomcat 为什么要破坏双亲委派?怎么做的?

Tomcat 需要支持一个 Web 容器部署多个应用,且各应用之间相互隔离。如果使用默认双亲委派,所有应用都由 AppClassLoader 加载,不同应用中相同全限定名的类(比如不同版本的 Spring)会冲突。

Tomcat 的类加载机制:

  • 每个 Web 应用对应一个 WebAppClassLoader

  • 加载顺序:先自己在 WEB-INF/classes 和 WEB-INF/lib 下找,找不到再委托给父加载器

  • 但为了安全,Java 核心库还是遵循双亲委派,不会让应用自己的类覆盖核心库类

追问5:什么是线程上下文类加载器?

线程上下文类加载器(Thread Context ClassLoader)是一种"逆向"使用类加载器的方式。

Java 中 SPI(Service Provider Interface)机制,如 JDBC:

  • 接口在 rt.jar 中由 BootstrapClassLoader 加载

  • 但实现类在 classpath 中由 AppClassLoader 加载

  • BootstrapClassLoader 无法加载实现类

这时就需要线程上下文类加载器:

  • 线程创建时会继承上下文类加载器,默认是 AppClassLoader

  • DriverManager 通过 Thread.currentThread().getContextClassLoader() 获取上下文加载器来加载驱动实现类

  • 这实际上是让父类加载器请求子类加载器去完成类加载动作,打破了双亲委派模型


3. Java 内存模型(JMM)是什么?

标准答案

Java 内存模型(Java Memory Model,JMM)是一种抽象的概念,并不真实存在,它描述的是一组规则或规范,通过这组规范定义了程序中各个变量(包括实例字段、静态字段和构成数组对象的元素)的访问方式。

Java Memory Model

【JMM 的目标】

定义程序中各个变量的访问规则,屏蔽各种硬件和操作系统的内存访问差异,以实现让 Java 程序在各种平台下都能达到一致的内存访问效果。

【主内存与工作内存】

JMM 规定了所有变量都存储在主内存(Main Memory)中,每条线程还有自己的工作内存(Working Memory)。

  • 主内存:所有线程共享,存储变量的"主副本"

  • 工作内存:线程私有,保存该线程使用到的变量的主内存副本拷贝

  • 线程对变量的所有操作(读取、赋值)都必须在工作内存中进行,不能直接读写主内存中的变量

  • 不同线程之间也无法直接访问对方工作内存中的变量,线程间变量值的传递均需要通过主内存来完成

⚠️ 注意:主内存、工作内存与 Java 内存区域中的堆、栈、方法区不是同一层次的划分,两者基本没有关系。如果勉强对应:主内存对应堆中对象的实例数据部分,工作内存对应虚拟机栈中的部分区域。

【内存间交互操作】

JMM 定义了 8 种操作来完成主内存和工作内存之间的交互:

| 操作 | 作用于 | 说明 | |------|--------|------| | lock | 主内存 | 把一个变量标识为一条线程独占的状态 | | unlock | 主内存 | 把一个处于锁定状态的变量释放出来 | | read | 主内存 | 把变量的值从主内存传输到线程的工作内存中 | | load | 工作内存 | 把 read 得到的值放入工作内存的变量副本中 | | use | 工作内存 | 把工作内存中变量的值传递给执行引擎 | | assign | 工作内存 | 把从执行引擎接收到的值赋给工作内存的变量 | | store | 工作内存 | 把工作内存中变量的值传送到主内存中 | | write | 主内存 | 把 store 得到的值放入主内存的变量中 |

【三大特性】

1. 原子性(Atomicity)

  • 由 JMM 直接保证的原子性变量操作包括 read、load、assign、use、store、write

  • 基本数据类型的访问读写是具备原子性的(long 和 double 除外,32 位平台下的非原子性协定)

  • 更大范围的原子性:synchronized(monitorenter/monitorexit)、Lock 接口

2. 可见性(Visibility)

  • 当一个线程修改了共享变量的值,其他线程能够立即得知这个修改

  • 实现可见性的关键字:

    • volatile:新值能立即同步到主内存,每次使用前立即从主内存刷新

    • synchronized:对一个变量执行 unlock 之前,必须把变量同步回主内存

    • final:被 final 修饰的字段在构造器中一旦初始化完成,其他线程就能看到 final 字段的值

3. 有序性(Ordering)

  • 程序执行的顺序按照代码的先后顺序执行

  • Java 中提供了 volatile 和 synchronized 两个关键字来保证线程之间操作的有序性

  • volatile 本身包含禁止指令重排序的语义

  • synchronized 由"一个变量在同一个时刻只允许一条线程对其进行 lock 操作"获得

【先行发生原则(Happens-Before)】

先行发生是 JMM 中定义的两项操作之间的偏序关系。如果操作 A 先行发生于操作 B,就是说在发生操作 B 之前,操作 A 产生的影响能被操作 B 观察到。

JMM 中"天然的"先行发生关系:

  1. 程序次序规则:在一个线程内,按照程序代码顺序,书写在前面的操作先行发生于书写在后面的操作

  2. 管程锁定规则:一个 unlock 操作先行发生于后面对同一个锁的 lock 操作

  3. volatile 变量规则:对一个 volatile 变量的写操作先行发生于后面对这个变量的读操作

  4. 线程启动规则:Thread 对象的 start() 方法先行发生于此线程的每一个动作

  5. 线程终止规则:线程中的所有操作都先行发生于对此线程的终止检测

  6. 线程中断规则:对线程 interrupt() 方法的调用先行发生于被中断线程的代码检测到中断事件的发生

  7. 对象终结规则:一个对象的初始化完成先行发生于它的 finalize() 方法的开始

  8. 传递性:如果 A 先行发生于 B,B 先行发生于 C,那么 A 先行发生于 C

【指令重排序】

  • 编译器优化的重排序:编译器在不改变单线程程序语义的前提下,可以重新安排语句的执行顺序

  • 指令级并行的重排序:现代处理器采用了指令级并行技术,如果不存在数据依赖性,处理器可以改变语句对应机器指令的执行顺序

  • 内存系统的重排序:由于处理器使用缓存和读/写缓冲区,使得加载和存储操作看上去可能是在乱序执行

as-if-serial 语义:不管怎么重排序,单线程程序的执行结果不能被改变。


高频追问

追问1:volatile 的实现原理?

volatile 有两个作用:保证可见性、禁止指令重排序。

可见性实现:

  • 对 volatile 变量的写操作会在汇编层面加 lock 前缀指令

  • lock 前缀指令会引起处理器缓存回写到内存(将当前处理器缓存行的数据写回到系统内存)

  • 一个处理器的缓存回写到内存会导致其他处理器的缓存无效(MESI 协议)

  • 其他处理器发现自己的缓存行对应的内存地址被修改,就会将自己的缓存行置为无效状态,下次读取时重新从内存加载

禁止指令重排序实现:

  • volatile 通过插入内存屏障来禁止指令重排序

  • 内存屏障(Memory Barrier)是一种 CPU 指令,用于控制特定条件下的重排序和内存可见性问题

  • JMM 内存屏障策略:

    • volatile 写操作前插入 StoreStore 屏障

    • volatile 写操作后插入 StoreLoad 屏障

    • volatile 读操作后插入 LoadLoad 屏障

    • volatile 读操作后插入 LoadStore 屏障

追问2:volatile 能保证原子性吗?

不能。 volatile 只能保证可见性和有序性,对于复合操作(如 i++),volatile 无法保证原子性。

i++ 实际上是三个操作:读取 i 的值 → 加 1 → 写回新值。

在多线程环境下可能出现:

  • 线程 A 读取 i 的值(假设为 0)

  • 线程 B 也读取 i 的值(也是 0)

  • 线程 A 加 1 并写回(i=1)

  • 线程 B 加 1 并写回(i=1)

结果应该是 2,但实际是 1。

如果需要保证原子性,应该使用 synchronized、Lock 或 AtomicInteger 等原子类。

追问3:volatile 和 synchronized 的区别?

| 维度 | volatile | synchronized | |------|----------|--------------| | 作用范围 | 变量修饰符,只能修饰变量 | 可以修饰方法、代码块 | | 原子性 | 不保证 | 保证 | | 可见性 | 保证 | 保证 | | 有序性 | 保证(禁止重排序) | 保证(串行执行) | | 性能 | 轻量级,不会引起线程上下文切换 | 重量级,会引起线程阻塞和唤醒 | | 使用场景 | 一写多读、状态标记位、DCL | 多写场景、需要保证操作原子性 |

追问4:DCL(双重检查锁)单例为什么要加 volatile?

public class Singleton {
    private volatile static Singleton instance; // 必须加 volatile
    
    private Singleton() {}
    
    public static Singleton getInstance() {
        if (instance == null) {          // 第一次检查
            synchronized (Singleton.class) {
                if (instance == null) {  // 第二次检查
                    instance = new Singleton(); // 问题出在这里
                }
            }
        }
        return instance;
    }
}

问题在于 instance = new Singleton() 不是原子操作,会被分解为三步:

  1. 分配对象内存空间

  2. 初始化对象(执行构造函数)

  3. 将 instance 指向分配的内存地址

由于指令重排序,可能出现 1→3→2 的顺序:

  • 线程 A 执行 1 和 3,instance 不为 null 但还没初始化

  • 线程 B 第一次检查发现 instance 不为 null,直接返回

  • 线程 B 使用这个未初始化的对象,导致报错

加 volatile 后禁止指令重排序,保证 1→2→3 的顺序,避免拿到半初始化的对象。

追问5:long 和 double 的非原子性协定是什么?

Java 内存模型要求 lock、unlock、read、load、assign、use、store、write 这 8 个操作都具有原子性。

但对于 64 位的数据类型(long 和 double),JMM 允许将没有被 volatile 修饰的 64 位数据的读写操作划分为两次 32 位的操作来进行。这就是所谓的"long 和 double 的非原子性协定"。

也就是说,如果多个线程共享一个没有声明为 volatile 的 long 或 double 变量,并且同时对它们进行读取和修改操作,那么某些线程可能会读到一个既不是原值也不是其他线程修改值的"半个变量"的数值。

不过目前各种平台下的商用虚拟机几乎都选择把 64 位数据的读写操作作为原子操作来对待,因此实际开发中一般不需要专门把 long 和 double 声明为 volatile。


4. 垃圾回收算法与垃圾收集器(G1、CMS)

标准答案

【如何判断对象已死】

1. 引用计数法

  • 给对象添加一个引用计数器,每当有一个地方引用它时,计数器加 1;引用失效时,计数器减 1

  • 优点:实现简单,判定效率高

  • 缺点:无法解决对象之间相互循环引用的问题 → Java 不采用

2. 可达性分析算法(Java 采用)

  • 通过一系列称为"GC Roots"的对象作为起始点,从这些节点开始向下搜索,搜索走过的路径称为引用链

  • 当一个对象到 GC Roots 没有任何引用链相连时,则证明此对象是不可用的

可作为 GC Roots 的对象:

  • 虚拟机栈(栈帧中的本地变量表)中引用的对象

  • 方法区中类静态属性引用的对象

  • 方法区中常量引用的对象

  • 本地方法栈中 JNI(Native 方法)引用的对象

【四种引用类型】

| 引用类型 | 强度 | 回收时机 | 用途 | |----------|------|----------|------| | 强引用(Strong) | 最强 | 永远不回收 | 普通对象引用 | | 软引用(SoftReference) | 次强 | OOM 之前回收 | 内存敏感的缓存 | | 弱引用(WeakReference) | 次弱 | 下一次 GC 回收 | 缓存、ThreadLocal Map | | 虚引用(PhantomReference) | 最弱 | 随时可能回收 | 管理堆外内存 |

【垃圾回收算法】

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

  • 过程:首先标记出所有需要回收的对象,标记完成后统一回收

  • 缺点:

    • 效率问题:标记和清除两个过程效率都不高

    • 空间问题:产生大量不连续的内存碎片,可能导致大对象分配时提前触发 GC

2. 复制算法(Copying)

  • 过程:将可用内存按容量划分为大小相等的两块,每次只使用其中一块。用完了就将存活对象复制到另一块,然后清理已使用的空间

  • 优点:实现简单,运行高效,不会产生内存碎片

  • 缺点:内存代价高,可用内存缩小为原来的一半

  • 应用:新生代的垃圾回收(Eden + S0 + S1,8:1:1)

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

  • 过程:标记过程与"标记-清除"一样,但后续让所有存活对象都向一端移动,然后直接清理掉端边界以外的内存

  • 优点:没有内存碎片

  • 缺点:需要移动对象,成本较高

  • 应用:老年代的垃圾回收

4. 分代收集算法(Generational Collection)

  • 根据对象存活周期的不同将内存划分为几块

  • 新生代:每次 GC 大批对象死去,少量存活 → 选用复制算法

  • 老年代:对象存活率高、没有额外空间担保 → 使用标记-清理或标记-整理算法

【垃圾收集器】

1. Serial 收集器

  • 单线程收集器,进行垃圾收集时必须暂停其他所有工作线程(Stop The World)

  • 新生代采用复制算法,老年代采用标记-整理算法

  • 优点:简单高效,单 CPU 环境下没有线程交互开销

  • 应用:Client 模式下的默认新生代收集器

2. ParNew 收集器

  • Serial 收集器的多线程版本

  • 除了使用多线程进行垃圾收集之外,其余行为与 Serial 完全一样

  • 只有它能与 CMS 收集器配合工作

  • 应用:Server 模式下首选的新生代收集器

3. Parallel Scavenge 收集器

  • 新生代收集器,使用复制算法,并行多线程

  • 目标是达到一个可控制的吞吐量(Throughput)

  • 吞吐量 = 运行用户代码时间 / (运行用户代码时间 + 垃圾收集时间)

  • 被称为"吞吐量优先"收集器

  • 自适应调节策略:虚拟机会根据当前系统运行情况动态调整参数

4. CMS 收集器(Concurrent Mark Sweep)

  • 以获取最短回收停顿时间为目标的收集器

  • 基于标记-清除算法

运作过程:

  1. 初始标记(CMS initial mark):Stop The World,仅标记 GC Roots 能直接关联到的对象,速度很快

  2. 并发标记(CMS concurrent mark):进行 GC Roots Tracing,与用户线程并发执行

  3. 重新标记(CMS remark):Stop The World,修正并发标记期间因用户程序继续运作而导致标记产生变动的那一部分对象的标记记录,停顿时间比初始标记稍长

  4. 并发清除(CMS concurrent sweep):清除已标记的对象,与用户线程并发执行

优点: 并发收集、低停顿

缺点:

  1. 对 CPU 资源非常敏感:并发阶段会占用一部分线程,导致应用变慢

  2. 无法处理浮动垃圾:并发清除阶段用户线程还在运行,产生的新垃圾只能等下一次 GC 再清理

  3. 基于标记-清除算法,会产生大量空间碎片,可能提前触发 Full GC

  4. 需要更大的堆空间:并发收集时用户线程还在运行,需要预留足够空间

5. G1 收集器(Garbage First)

  • 面向服务端应用的垃圾收集器,JDK9 后成为默认

  • 特点:

    1. 并行与并发:利用多 CPU、多核环境缩短 STW 停顿时间

    2. 分代收集:不需要其他收集器配合就能独立管理整个 GC 堆

    3. 空间整合:整体上看是基于"标记-整理"算法,局部是基于"复制"算法,不会产生内存碎片

    4. 可预测的停顿:能让使用者明确指定在一个长度为 M 毫秒的时间片段内,消耗在垃圾收集上的时间不得超过 N 毫秒

Region 概念:

  • G1 将整个 Java 堆划分为多个大小相等的独立区域(Region)

  • 新生代和老年代不再是物理隔离,都是一部分 Region 的集合

  • 每个 Region 的大小在 1MB~32MB 之间,且都是 2 的 N 次幂

  • 大对象直接进入 Humongous 区域

运作过程:

  1. 初始标记(Initial Marking):Stop The World,标记 GC Roots 能直接关联到的对象

  2. 并发标记(Concurrent Marking):从 GC Root 开始对堆中对象进行可达性分析,与用户程序并发执行

  3. 最终标记(Final Marking):Stop The World,修正并发标记期间的标记变动

  4. 筛选回收(Live Data Counting and Evacuation):对各个 Region 的回收价值和成本进行排序,根据用户期望的 GC 停顿时间来制定回收计划,选择回收价值最高的 Region 进行回收

G1 为什么可预测停顿时间?

  • 因为 G1 可以有计划地避免在整个 Java 堆中进行全区域的垃圾收集

  • G1 跟踪各个 Region 里面的垃圾堆积的价值大小,在后台维护一个优先列表

  • 每次根据允许的收集时间,优先回收价值最大的 Region(Garbage First 名称的由来)


高频追问

追问1:Minor GC、Major GC、Full GC 的区别?

| 类型 | 作用区域 | 触发条件 | 速度 | |------|----------|----------|------| | Minor GC | 新生代 | Eden 区满 | 快 | | Major GC | 老年代 | 老年代空间不足 | 比 Minor GC 慢 10 倍以上 | | Full GC | 整个堆 + 方法区 | 老年代不足、元空间不足、System.gc()、担保失败等 | 最慢 |

Full GC 触发条件:

  1. 老年代空间不足

  2. 方法区/元空间空间不足

  3. 显式调用 System.gc()(建议加 -XX:+DisableExplicitGC 禁止)

  4. 之前某次 Minor GC 后晋升到老年代的平均大小大于老年代的剩余空间(空间分配担保失败)

  5. CMS GC 时出现 promotion failed 和 concurrent mode failure

追问2:对象什么时候进入老年代?

  1. 年龄达到阈值:默认 15 岁,对象在 Survivor 区中每熬过一次 Minor GC,年龄就增加 1 岁。参数:-XX:MaxTenuringThreshold

  2. 动态对象年龄判定:如果在 Survivor 空间中相同年龄所有对象大小的总和大于 Survivor 空间的一半,年龄大于或等于该年龄的对象就可以直接进入老年代

  3. 大对象直接进入老年代:需要大量连续内存空间的 Java 对象(如很长的字符串以及数组),直接在老年代分配。参数:-XX:PretenureSizeThreshold

  4. 空间分配担保:Minor GC 之前,虚拟机会检查老年代最大可用的连续空间是否大于新生代所有对象总空间。如果不大于,且 HandlePromotionFailure 不允许冒险,则改为进行一次 Full GC

追问3:什么是 Stop The World?

Stop The World(STW)是指在执行垃圾收集时,Java 应用程序的其他所有线程都被挂起,暂停一切工作,等待 GC 完成。

  • 所有的回收器都有 STW,只是停顿时间长短不同

  • STW 是不可避免的,即使是 CMS、G1 这些并发收集器,也有需要停顿的阶段

  • STW 期间,应用程序会暂停响应,对于用户来说就是"卡顿"

  • 调优的目标就是尽可能缩短 STW 的时间,减少对用户体验的影响

追问4:CMS 的 concurrent mode failure 是什么?

CMS 在并发清理阶段,用户线程还在运行,会不断产生新的垃圾(浮动垃圾)。

如果 CMS 运行期间预留的内存无法满足程序需要,就会出现一次 "Concurrent Mode Failure"。

这时虚拟机将启动后备预案:临时启用 Serial Old 收集器来重新进行老年代的垃圾收集,这样停顿时间就很长了。

触发原因:

  • 老年代空间不足,放不下晋升的对象

  • 产生了大量的浮动垃圾

解决方法:

  • 调大老年代空间

  • 调整 -XX:CMSInitiatingOccupancyFraction 参数,让 CMS 更早启动

  • 减少对象分配速率

追问5:G1 和 CMS 的区别?

| 维度 | CMS | G1 | |------|-----|----| | 算法 | 标记-清除,有内存碎片 | 整体标记-整理,局部复制,无碎片 | | 内存布局 | 传统分代,新生代老年代连续 | Region 划分,不需要物理连续 | | 停顿预测 | 只能尽量减少,无法精确控制 | 可预测停顿时间模型 | | 回收范围 | 整堆回收(老年代) | 筛选回收,只回收部分 Region | | 适用场景 | 重视响应速度,堆大小适中 | 服务端大内存应用,兼顾吞吐和停顿 |


5. JVM 调优思路

标准答案

【调优目标】

JVM 调优的核心目标是:在保证吞吐量的前提下,尽可能减少 GC 停顿时间,避免 Full GC,提高应用稳定性。

具体指标:

  • 吞吐量:用户代码执行时间占总时间的比例,越高越好

  • 停顿时间:GC 时应用暂停的时间,越短越好

  • 内存占用:堆内存使用情况,合理控制

【调优原则】

  1. 先监控后调优:不要凭感觉调优,必须基于实际监控数据

  2. 优先优化代码:很多性能问题不是 JVM 参数能解决的,代码优化是根本

  3. Minor GC 优先:能在新生代解决的,不要让对象进入老年代

  4. 够用就好:不要盲目加大堆内存,堆越大 GC 时间越长

  5. 分场景调优:不同应用场景调优策略不同(吞吐优先 vs 响应优先)

【调优步骤】

第一步:监控分析

  1. 确定当前 JVM 参数配置

  2. 收集 GC 日志,分析 GC 情况

  3. 使用工具分析:jstat、jmap、jstack、VisualVM、Arthas 等

第二步:确定问题类型

  • 频繁 Minor GC:新生代太小,对象分配速率快 → 影响吞吐量

  • 频繁 Full GC:老年代空间不足、内存泄漏、大对象过多 → 严重影响响应时间

  • 单次 GC 时间过长:堆太大,存活对象多 → 单次卡顿严重

第三步:针对性调优

【内存大小调优】

1. 堆大小设置

  • -Xms 和 -Xmx 设置为相同值,避免堆自动扩展带来的性能抖动

  • 一般堆大小设置为物理内存的 1/4 ~ 1/2

  • 不是越大越好,堆越大单次 GC 时间越长

2. 新生代大小

  • -Xmn 设置新生代大小,一般为整个堆的 1/3 ~ 1/2

  • 新生代太小:对象提前进入老年代,触发 Full GC

  • 新生代太大:Minor GC 时间变长

  • 响应时间优先的应用:新生代可以设大一些,减少对象进入老年代

  • 吞吐量优先的应用:新生代可以设大一些

3. 元空间大小(JDK8+)

  • -XX:MetaspaceSize:初始元空间大小

  • -XX:MaxMetaspaceSize:最大元空间大小

  • 一般设置为 256M ~ 512M 足够

【GC 收集器选择】

| 场景 | 推荐收集器 | 说明 | |------|-----------|------| | 吞吐量优先 | Parallel Scavenge + Parallel Old | 后台计算型任务 | | 响应时间优先(中小堆) | CMS | Web 应用、接口服务 | | 响应时间优先(大堆) | G1 | JDK9 默认,大堆下表现更好 | | 超大堆(16G+) | G1 / ZGC / Shenandoah | TB 级堆,亚毫秒级停顿 |

【具体调优手段】

1. 减少对象创建

  • 避免循环中创建对象

  • 重用对象(如 StringBuilder、对象池)

  • 减少不必要的临时对象

2. 减少大对象

  • 大对象直接进入老年代,容易触发 Full GC

  • 避免创建过大的数组、集合

  • 大对象拆分处理

3. 调整对象晋升年龄

  • -XX:MaxTenuringThreshold

  • 对象存活时间长 → 调大阈值,让对象在新生代多待几轮

  • 对象存活时间短但体积大 → 调小阈值,尽快进入老年代

4. 调整 Survivor 区比例

  • -XX:SurvivorRatio=8(默认 Eden:S0:S1 = 8:1:1)

  • Survivor 区经常满,对象提前进入老年代 → 增大 Survivor 比例

5. 调整 GC 触发时机

  • CMS:-XX:CMSInitiatingOccupancyFraction,老年代占用达到多少百分比时触发 CMS GC

  • 一般设置为 70~80,不能太高,要预留空间给浮动垃圾

【常用调优参数模板】

# 堆内存设置
-Xms2g -Xmx2g          # 初始堆和最大堆,建议设为相同值
-Xmn1g                 # 新生代大小
-XX:SurvivorRatio=8    # Eden 与 Survivor 比例

# 元空间设置(JDK8+)
-XX:MetaspaceSize=256m
-XX:MaxMetaspaceSize=512m

# GC 收集器选择
-XX:+UseG1GC                   # 使用 G1
-XX:MaxGCPauseMillis=200       # G1 最大停顿时间目标

# GC 日志
-XX:+PrintGCDetails
-XX:+PrintGCDateStamps
-XX:+PrintHeapAtGC
-Xloggc:/path/to/gc.log

# 其他
-XX:+HeapDumpOnOutOfMemoryError  # OOM 时自动 dump 堆
-XX:HeapDumpPath=/path/to/dump   # dump 文件路径
-XX:+DisableExplicitGC           # 禁止 System.gc()

高频追问

追问1:如何判断是否需要进行 JVM 调优?

不是所有应用都需要 JVM 调优,满足以下情况时考虑调优:

  1. 系统吞吐量达不到要求

  2. 接口响应时间过长,且 GC 停顿占比较大

  3. 频繁 Full GC,每次停顿时间长

  4. 出现 OOM 异常

  5. CPU 使用率高且 GC 线程占比高

如果系统运行稳定,GC 次数少、停顿时间短,就不需要盲目调优。很多时候优化代码比调 JVM 参数效果更好。

追问2:什么情况下会触发 Full GC?

  1. 老年代空间不足:大对象直接进入老年代、对象晋升导致老年代满

  2. 方法区/元空间不足:加载的类太多,元空间满

  3. 显式调用 System.gc():建议加 -XX:+DisableExplicitGC 禁止

  4. 空间分配担保失败:Minor GC 前检查老年代空间不足

  5. CMS 的 concurrent mode failure:CMS 并发清理时老年代空间不够

  6. 晋升担保失败:Minor GC 后存活对象太多,Survivor 放不下,老年代也放不下

追问3:什么是内存泄漏?常见场景?

内存泄漏是指不再使用的对象无法被 GC 回收,持续占用内存,最终导致 OOM。

常见内存泄漏场景:

  1. 静态集合类:static 的 Map、List 等,生命周期和 JVM 一样长

  2. 各种连接未关闭:数据库连接、网络连接、IO 流等

  3. 监听器未反注册:注册了监听器但没有反注册

  4. 内部类/匿名内部类:持有外部类引用,外部类无法回收

  5. ThreadLocal:使用完没有 remove,线程池场景下线程复用导致泄漏

  6. 缓存:缓存没有过期策略,只进不出

追问4:吞吐量优先和响应时间优先分别怎么调优?

| 维度 | 吞吐量优先 | 响应时间优先 | |------|-----------|-------------| | 目标 | 最大化用户代码执行时间占比 | 最小化单次 GC 停顿时间 | | 收集器 | Parallel Scavenge + Parallel Old | CMS / G1 | | 新生代 | 设大一些,减少 Minor GC 频率 | 不能太大,太大 Minor GC 时间长 | | 堆大小 | 可以设大一些,减少 GC 总次数 | 适中,避免单次 GC 时间过长 | | 关注点 | 整体效率 | 用户体验 | | 适用场景 | 后台批处理、大数据计算 | Web 应用、API 服务、电商系统 |

追问5:为什么 -Xms 和 -Xmx 要设置为相同值?

  1. 避免堆自动扩展的性能开销:

    • 如果 -Xms < -Xmx,当堆内存不够时,JVM 会向操作系统申请扩展内存

    • 扩展过程中会有性能开销和停顿

    • 设置为相同值,启动时就申请全部内存,避免运行时扩展

  2. 避免内存碎片:

    • 堆的扩展和收缩可能导致内存碎片化

    • 固定大小的堆内存布局更稳定

  3. 生产环境建议设置为相同值,开发测试环境可以不设置相同,节省内存。


6. OOM 排查流程

标准答案

【OOM 的几种类型】

| OOM 类型 | 说明 | 常见原因 | |----------|------|----------| | Java heap space | 堆内存溢出,最常见 | 堆太小、内存泄漏、对象过多过大 | | Metaspace / PermGen space | 方法区溢出 | 加载类太多、动态生成类过多 | | unable to create new native thread | 无法创建新线程 | 线程数太多,超过系统限制 | | Direct buffer memory | 直接内存溢出 | NIO DirectByteBuffer 分配过多 | | StackOverflowError | 栈溢出(严格来说不是 OOM) | 递归过深、栈大小太小 |

【OOM 排查整体流程】

第一步:保留现场

  1. 保存堆 dump 文件(最重要)

    • 提前配置:-XX:+HeapDumpOnOutOfMemoryError -XX:HeapDumpPath=/path

    • 手动 dump:jmap -dump:format=b,file=heap_$(date +%Y%m%d_%H%M%S).hprof <pid>

  2. 保存 GC 日志

  3. 保存线程栈信息:jstack <pid> > jstack.log

  4. 记录系统状态:top、free、df 等

第二步:分析 OOM 类型 根据错误日志判断是哪种 OOM,针对性排查。

第三步:针对性排查


【堆内存 OOM 排查】

1. 确认堆内存设置是否合理

  • 查看 -Xms、-Xmx 参数

  • 用 jstat -gcutil <pid> 查看各代使用率

  • 如果堆设置确实太小,先调大堆内存试试

2. 分析堆 dump 文件

  • 使用 MAT(Memory Analyzer Tool)打开 hprof 文件

  • 查看 Histogram:哪些类的实例数量最多、占用内存最大

  • 查看 Dominator Tree:哪些对象持有大量引用,阻止 GC 回收

  • 查看 Leak Suspects:自动分析可能的内存泄漏点

  • 查看 Path to GC Roots:对象为什么没有被回收,引用链是什么

3. 判断是内存泄漏还是内存不足

  • 内存泄漏:对象已经不用了但还被引用,无法回收

    • 特征:每次 GC 后堆使用率持续上升,降不下来

    • 解决:找到泄漏点,修复代码

  • 内存不足:对象确实都有用,就是太多了

    • 特征:所有对象都有合理的引用,确实是业务需要

    • 解决:调大堆内存、优化代码减少对象创建、增加机器


【元空间 OOM 排查】

  1. 查看元空间参数设置:-XX:MetaspaceSize、-XX:MaxMetaspaceSize

  2. 分析加载的类:jmap -clstats <pid> 查看类加载统计

  3. 常见原因:

    • 动态代理生成类过多(CGLIB、JDK 动态代理)

    • 热部署频繁,旧的类加载器没有回收

    • 引用了大量第三方 jar 包

  4. 解决:调大 MaxMetaspaceSize、检查类加载器泄漏、减少不必要的动态代理


【线程数 OOM 排查】

  1. 查看当前线程数:jstack <pid> | grep "java.lang.Thread.State" | wc -l

  2. 查看系统限制:ulimit -u

  3. 分析线程:

    • jstack 导出线程栈

    • 查看是否有大量相同状态的线程

    • 是否有线程泄漏(线程用完没销毁)

  4. 常见原因:

    • 线程池配置不合理,最大线程数太大

    • 线程泄漏,创建了线程但没有销毁

    • 系统限制的最大线程数太小

  5. 解决:合理配置线程池、修复线程泄漏、调整 ulimit 限制


【直接内存 OOM 排查】

  1. 查看直接内存参数:-XX:MaxDirectMemorySize

  2. 检查代码中 NIO 的使用:ByteBuffer.allocateDirect()、Netty 等

  3. 常见原因:

    • 分配了大量 DirectByteBuffer 没有释放

    • 堆外内存泄漏

  4. 解决:调大 MaxDirectMemorySize、检查堆外内存泄漏、使用 NMT 追踪


【栈溢出排查】

  1. 查看栈大小:-Xss

  2. 分析错误栈:

    • 看栈溢出时的调用栈

    • 是否有递归调用

    • 调用层次是否过深

  3. 常见原因:递归死循环、方法调用层次太深、栈大小设置太小

  4. 解决:修复递归逻辑、改递归为迭代、适当调大 -Xss


【常用排查工具】

| 工具 | 用途 | |------|------| | jps | 查看 Java 进程 ID | | jstat | GC 统计信息(jstat -gcutil <pid> 1000 10) | | jmap | 堆内存分析、导出 dump | | jstack | 线程栈分析 | | MAT | 堆 dump 分析工具,最常用 | | VisualVM | 可视化监控工具 | | Arthas | 在线诊断工具 |


高频追问

追问1:线上 OOM 了,第一时间做什么?

  1. 保留现场最重要:

    • 如果配置了 HeapDumpOnOutOfMemoryError,确认 dump 文件已生成

    • 如果没配置,手动执行 jmap dump

    • 保存 GC 日志、线程栈、系统状态

  2. 评估影响:

    • 是单节点还是多节点

    • 是否影响核心业务

    • 是否需要紧急回滚

  3. 紧急恢复:

    • 多节点集群:先把出问题的节点从负载均衡摘掉

    • 重启服务恢复业务

    • 单节点的话先重启,事后再分析

  4. 事后分析:

    • 用 MAT 分析 dump 文件

    • 定位根因

    • 修复问题、上线验证

原则:先恢复业务,再排查问题。但一定要保留现场数据,否则无法排查。

追问2:如何用 MAT 分析堆 dump?

MAT(Memory Analyzer Tool)使用步骤:

  1. 打开 hprof 文件:File → Open Heap Dump

  2. 选择 Leak Suspects Report(泄漏嫌疑报告),自动分析可能的泄漏点

  3. 关键视图:

    • Histogram:按类列出实例数和占用大小,按 Retained Heap 排序

    • Dominator Tree:支配树,列出每个对象及其持有的所有对象

    • Leak Suspects:自动分析的泄漏嫌疑

    • Path to GC Roots:查看对象到 GC Root 的引用链

  4. 分析思路:

    • 先看占用内存最大的对象是什么

    • 然后看这些对象是否应该存活

    • 如果不应该存活,看引用链,找到谁持有了它们的引用

    • 结合代码分析为什么没有释放

追问3:内存泄漏和内存溢出的区别?

| 概念 | 说明 | 比喻 | |------|------|------| | 内存泄漏(Memory Leak) | 对象已经不用了,但还被引用持有,GC 无法回收 | 占着茅坑不拉屎 | | 内存溢出(Out of Memory) | 内存不够用了,对象确实都需要 | 人太多坑位不够 |

关系:内存泄漏是原因之一,内存泄漏积累多了最终会导致内存溢出。但内存溢出不一定是内存泄漏导致的,也可能是内存确实不够用。

追问4:什么是对象的深堆和浅堆?

  • 浅堆(Shallow Heap):对象本身占用的内存大小,不包括它引用的其他对象

  • 深堆(Retained Heap):对象本身 + 它直接或间接引用的所有对象的总大小,也就是如果这个对象被 GC 回收,能释放的总内存大小

MAT 中看 Retained Heap 更有意义,能看出哪些对象是内存大户。

追问5:如何避免 OOM?

代码层面:

  • 避免在循环中创建大量对象

  • 及时释放资源(连接、流、文件等),用 try-with-resources

  • 不用的对象及时解除引用,特别是静态集合

  • 避免大对象,特别是大数组、大集合

  • 合理使用缓存,设置过期策略和最大容量

  • ThreadLocal 使用完要 remove

  • 避免内存泄漏

JVM 参数层面:

  • 合理设置堆大小,-Xms 和 -Xmx 设为相同

  • 合理设置新生代和老年代比例

  • 合理设置元空间大小

  • 选择合适的 GC 收集器

  • 配置 HeapDumpOnOutOfMemoryError,出问题方便排查

架构层面:

  • 大对象拆分处理,不要一次性加载全部数据

  • 分页查询,不要一次查全表

  • 使用对象池、连接池复用对象

  • 分布式部署,水平扩展

  • 限流降级,保护系统


7. Arthas 常用命令及使用场景

标准答案

Arthas 是 Alibaba 开源的 Java 诊断工具,可以在线排查问题,无需重启,动态跟踪 Java 代码,实时监控 JVM 状态。

【Arthas 能做什么】

  1. 查看方法调用的入参、返回值、异常

  2. 监控方法执行耗时

  3. 查看 JVM 实时运行状态

  4. 热更新代码,无需重启

  5. 排查类加载问题

  6. 生成火焰图分析性能

  7. 反编译线上代码

【启动方式】

# 下载 arthas-boot.jar
curl -O https://arthas.aliyun.com/arthas-boot.jar

# 启动
java -jar arthas-boot.jar

# 选择要诊断的 Java 进程

【常用命令分类】

【基础命令】

| 命令 | 说明 | |------|------| | help | 查看命令帮助 | | cls | 清屏 | | session | 查看当前会话 | | version | 查看版本 | | quit / exit | 退出当前客户端 | | stop | 关闭 Arthas 服务端,所有客户端退出 |

【JVM 相关命令】

1. dashboard — 实时数据面板

  • 功能:显示当前系统的实时数据面板,包括线程、内存、GC、Runtime 等信息

  • 使用场景:快速了解 JVM 整体运行状态

  • 输出内容:线程状态、内存使用、GC 统计、Runtime 信息

2. jvm — 查看 JVM 信息

  • 功能:查看 JVM 详细信息

  • 包括:运行时信息、类加载统计、编译统计、GC 收集器、系统属性、JVM 参数等

3. sysprop — 查看和修改系统属性

  • sysprop:查看所有系统属性

  • sysprop <key>:查看单个属性

  • sysprop <key> <value>:修改属性值

4. vmoption — 查看和修改 VM 参数

  • vmoption:查看所有 VM 选项

  • vmoption <name> <value>:修改选项值(支持动态修改的参数)

5. getstatic — 查看静态属性

  • 功能:查看类的静态属性值

  • 示例:getstatic com.example.UserService instance

【类/类加载相关命令】

1. sc(Search Class)— 搜索类

  • 功能:搜索所有已经加载到 JVM 中的类信息

  • 常用参数:

    • -d:输出类的详细信息,包括类加载器、所在 jar 包等

    • -f:输出类的成员变量信息(配合 -d 使用)

  • 示例:sc -d com.example.UserService

2. sm(Search Method)— 搜索方法

  • 功能:搜索已加载类的方法信息

  • 示例:sm -d com.example.UserService getUser*

3. jad — 反编译

  • 功能:反编译指定已加载类的源码

  • 常用参数:--source-only 只输出源码

  • 使用场景:线上代码和本地不一致时,确认线上运行的到底是什么代码

  • 示例:jad --source-only com.example.UserService

4. classloader — 类加载器相关

  • 功能:查看类加载器信息、继承树、加载的类

  • 常用子命令:

    • classloader -l:列出所有类加载器

    • classloader -t:查看类加载器的继承树

  • 使用场景:排查类加载冲突、NoClassDefFoundError 等问题

【方法监控/诊断命令】

1. watch — 方法执行监控

  • 功能:观察指定方法的调用情况,能观察到入参、返回值、异常

  • 格式:watch <类名> <方法名> <观察表达式> <条件表达式>

  • 观察点:params(入参)、returnObj(返回值)、throwExp(异常)、target(目标对象)

  • 常用参数:

    • -x:结果属性遍历深度,默认 1

    • -b:方法调用前观察

    • -e:方法异常时观察

    • -s:方法返回后观察

    • -n:执行次数,达到后自动停止

  • 示例:

    watch com.example.UserService getUser "{params,returnObj}" -x 2
    watch com.example.UserService getUser "{params,throwExp}" -e -x 2
    
  • 使用场景:线上问题排查,看方法入参和返回值是否正确

2. trace — 方法内部调用路径和耗时

  • 功能:追踪方法内部的调用路径,并输出每个节点上的耗时

  • 常用参数:-n 执行次数、--skipJDKMethod 跳过 JDK 方法

  • 示例:trace com.example.UserService getUser

  • 使用场景:方法执行慢,找出哪一步最耗时,定位性能瓶颈

3. stack — 方法调用栈

  • 功能:查看当前方法被调用的栈路径

  • 示例:stack com.example.UserService getUser

  • 使用场景:想知道方法被谁调用了,调用链路是什么

4. tt(TimeTunnel)— 时间隧道

  • 功能:记录方法每次调用的入参、返回值、耗时等信息,之后可以随时查看

  • 常用子命令:

    • tt -t <类名> <方法名>:开始记录

    • tt -l:列出所有记录

    • tt -i <index>:查看指定索引的详细信息

    • tt -i <index> -p:重新触发一次调用(重放)

  • 使用场景:偶现问题先记录,出问题时回溯;想重放某次调用

5. monitor — 方法执行监控统计

  • 功能:对方法执行进行监控统计,统计成功次数、失败次数、平均耗时等

  • 常用参数:-c 统计周期(默认 120 秒)

  • 示例:monitor -c 10 com.example.UserService getUser

  • 使用场景:统计方法的 QPS、平均耗时、成功率等

【性能分析命令】

1. profiler — 火焰图生成

  • 功能:生成火焰图,分析 CPU 热点

  • 常用子命令:

    • profiler start:开始采样

    • profiler stop --format html:停止采样,生成火焰图

    • profiler status:查看状态

  • 使用场景:CPU 使用率高,找出哪些方法占用 CPU 最多

2. heapdump — 堆 dump

  • 功能:导出堆 dump 文件,类似 jmap

  • 示例:heapdump /tmp/heap.hprof

【其他实用命令】

1. ognl — 执行 OGNL 表达式

  • 功能:执行 OGNL 表达式,调用任意方法

  • 示例:

    ognl '@java.lang.System@out.println("hello")'
    ognl '@com.example.UserService@getInstance()'
    
  • 使用场景:动态执行代码,查看/修改状态

2. redefine — 热更新代码

  • 功能:加载外部的 .class 文件,redefine 到 JVM 中

  • 格式:redefine <class 文件路径>

  • 使用场景:紧急修复线上小问题,不用重启

  • 注意:不能新增字段和方法,不能修改方法签名,只能修改方法体


【常见使用场景总结】

| 场景 | 推荐命令 | |------|----------| | 接口慢 | trace + watch | | CPU 高 | profiler 火焰图 | | 内存问题 | heapdump + MAT | | 偶现问题 | tt 记录回溯 | | 线上代码不对 | jad 反编译确认 | | 类加载冲突 | sc + classloader | | 紧急修复 | redefine 热更新 | | 方法调用统计 | monitor 统计 QPS、耗时 |


高频追问

追问1:Arthas 的实现原理是什么?

Arthas 基于 Java Agent + 字节码增强技术实现:

  1. Java Agent 机制:

    • Arthas 是一个 Java Agent,通过 attach 机制挂载到目标 JVM 上

    • 使用 Instrumentation API 来修改已加载类的字节码

  2. 字节码增强:

    • 使用 ASM 框架操作字节码

    • 对目标类的目标方法进行增强,插入监控代码

    • 比如 watch 命令,就是在方法入口和出口插入代码,获取入参和返回值

  3. 通信机制:

    • Arthas 服务端运行在目标 JVM 进程内

    • 客户端通过 telnet 或 HTTP 连接到服务端

  4. 为什么不需要重启?

    • 使用了 Instrumentation 的 retransformClasses 机制

    • 可以在运行时重新转换已加载的类

    • 对字节码进行增强,不需要重启应用

追问2:watch 和 trace 的区别?

| 维度 | watch | trace | |------|-------|-------| | 关注点 | 方法的输入输出 | 方法内部的执行路径和耗时 | | 看什么 | 入参、返回值、异常 | 调用了哪些子方法,每个耗时多少 | | 视角 | "面"上的观察,整体行为 | "线"上的追踪,内部细节 | | 适用场景 | 排查业务逻辑问题 | 排查性能问题 |

简单说:watch 看方法的输入输出,trace 看方法的内部耗时。

追问3:Arthas 会影响线上性能吗?

会有一定影响,但通常很小:

  1. 影响程度:

    • 正常使用下,对性能影响很小,一般在 5% 以内

    • 如果监控的方法调用非常频繁,影响会大一些

    • trace 命令因为要追踪整个调用链,影响比 watch 大

  2. 为什么影响小:

    • Arthas 只对指定的类和方法做增强,不是所有类

    • 增强的代码很轻量,主要是记录数据

    • 采样类的命令(如 profiler)是采样,不是全量

  3. 注意事项:

    • 不要监控过于频繁的方法(如循环内调用的方法)

    • 使用 -n 参数限制执行次数,用完及时停止

    • 不要长时间开启大量监控

    • 高峰期谨慎使用

追问4:Arthas 可以排查哪些问题?不能排查什么?

可以排查的问题:

  1. 方法调用问题:入参、返回值、异常、调用栈

  2. 性能问题:方法耗时、CPU 热点、火焰图

  3. 类加载问题:类冲突、类加载器、反编译

  4. JVM 状态:内存、GC、线程、系统属性

  5. 热修复:小问题可以 redefine 热更新

  6. 内存问题:导出堆 dump

不能排查的问题:

  1. 堆内存泄漏的详细分析(还是要用 MAT)

  2. GC 详细调优(还是要 GC 日志)

  3. 本地内存(堆外内存)泄漏

  4. 操作系统层面问题(CPU、IO、网络等)

  5. 新增字段/方法的热更新(redefine 有限制)


8. JVM 性能问题如何定位

标准答案

【性能问题的常见表现】

  1. CPU 使用率高

  2. 内存占用高 / OOM

  3. 接口响应慢 / 吞吐量低

  4. 频繁 GC / Full GC

  5. 线程死锁 / 线程数过多

  6. 磁盘 IO 高 / 网络 IO 高

【定位整体思路】

  1. 先宏观后微观:先看整体系统状态,再深入细节

  2. 先定位现象,再分析原因:先确定是哪类问题,再深挖原因

  3. 先排除法,再深入:先排除明显不可能的,缩小范围

  4. 数据驱动:一切以监控数据为准,不要猜


【CPU 使用率高定位流程】

第一步:确认 CPU 高

  • top 命令:查看整体 CPU 使用率,找到占用高的进程 PID

第二步:定位到线程

  • top -Hp <pid>:查看进程内所有线程的 CPU 使用情况

  • 找到 CPU 使用率最高的线程 TID(十进制)

  • printf "%x\n" <tid>:将线程 ID 转为十六进制

第三步:查看线程栈

  • jstack <pid> > jstack.log:导出线程栈

  • 在 jstack.log 中搜索十六进制的线程 ID

  • 查看这个线程在做什么,执行到哪一行代码

第四步:分析原因

常见 CPU 高的原因:

  1. 死循环:代码中有死循环,或者循环次数过多

  2. 大量计算:复杂计算、加密、正则匹配等

  3. 频繁 GC:GC 线程占用 CPU 高(转去查 GC 问题)

  4. 正则表达式:复杂正则回溯,CPU 飙升

  5. 序列化/反序列化:大量 JSON 序列化

  6. 热点方法:某个方法被频繁调用


【内存问题定位流程】

第一步:确认内存问题

  • top 命令:查看进程内存占用(RES 列)

  • jstat -gcutil <pid>:查看堆各代使用情况

  • 判断是堆内存高还是非堆内存高

第二步:判断是泄漏还是不足

  • 内存持续上升,GC 后降不下来 → 内存泄漏

  • 内存高但 GC 后能降下来,只是峰值高 → 内存不足或对象创建过多

第三步:导出堆 dump 分析

  • jmap -dump:format=b,file=heap.hprof <pid>

  • 用 MAT 分析:看占用最大的对象、泄漏嫌疑、引用链

第四步:针对性解决

  • 内存泄漏:找到泄漏点,修复代码

  • 内存不足:调大堆内存,或优化代码减少对象


【GC 问题定位流程】

第一步:收集 GC 数据

  • 开启 GC 日志(推荐):-XX:+PrintGCDetails -XX:+PrintGCDateStamps -Xloggc:gc.log

  • 实时查看:jstat -gcutil <pid> 1000

第二步:分析 GC 情况

| 指标 | 异常表现 | 可能原因 | |------|----------|----------| | Minor GC 频率 | 太频繁 | 新生代太小或对象分配太快 | | Minor GC 耗时 | 太长 | 新生代太大或存活对象多 | | Full GC 频率 | 频繁 | 老年代不足、内存泄漏 | | Full GC 耗时 | 太长 | 老年代太大、存活对象多 | | 老年代趋势 | 持续上升 | 内存泄漏 |

第三步:定位 Full GC 原因

  1. 老年代空间不足(对象晋升太快、大对象、内存泄漏)

  2. 元空间不足(加载类太多)

  3. 显式调用 System.gc()

  4. 空间分配担保失败

第四步:优化方案

  1. 调优内存大小(堆大小、新生代比例)

  2. 调优对象生命周期(减少大对象、减少对象创建)

  3. 更换 GC 收集器(Parallel → CMS/G1)

  4. 解决内存泄漏


【接口慢/响应时间长定位流程】

第一步:确认慢的范围

  • 所有接口都慢还是个别接口慢?

  • 一直慢还是偶尔慢?

  • 慢的比例有多少?

第二步:从外到内逐层排查(漏斗分析法)

客户端 → 网络 → 负载均衡/网关 → 应用服务 → 数据库/缓存/MQ → 外部依赖

第三步:应用层定位

  1. 看日志:接口耗时日志、慢查询日志、异常日志

  2. 用 Arthas:

    • trace:追踪方法内部调用链和耗时,找出最耗时的步骤

    • watch:看入参和返回值

    • stack:看调用栈

  3. 看线程栈:

    • 大量线程 BLOCKED → 锁竞争

    • 大量线程 WAITING → 等待外部资源

    • 大量线程 RUNNABLE → CPU 密集或 IO 等待

第四步:常见慢的原因

  1. 数据库慢:慢 SQL、没走索引、锁等待、连接池不够

  2. 锁竞争:synchronized 或 Lock 竞争激烈,锁粒度太大

  3. 外部调用慢:调用第三方接口超时,超时时间设置太长

  4. GC 影响:GC 停顿导致接口耗时增加

  5. 代码问题:循环嵌套、算法效率低、大量对象创建


【线程问题定位流程】

第一步:查看线程情况

  • 线程总数:jstack <pid> | grep "java.lang.Thread.State" | wc -l

  • 线程状态分布:

    jstack <pid> | grep "java.lang.Thread.State" | sort | uniq -c | sort -rn
    

第二步:分析线程状态

| 状态 | 说明 | 异常情况 | |------|------|----------| | RUNNABLE | 运行中 | 正常状态 | | BLOCKED | 阻塞,等待锁 | 大量 BLOCKED → 锁竞争严重 | | WAITING | 无限等待 | 大量 WAITING → 等待外部资源或线程池闲置 | | TIMED_WAITING | 计时等待 | sleep、wait 带超时 | | TERMINATED | 已终止 | 正常结束 |

第三步:死锁排查

  1. jstack <pid> 输出最后会有 "Found one Java-level deadlock:"

  2. 会列出死锁的线程和持有/等待的锁

  3. 解决死锁:

    • 调整锁的顺序,所有线程按相同顺序获取锁

    • 减小锁粒度

    • 使用 tryLock 超时机制

    • 用并发工具类替代手动锁


【常用工具总结】

操作系统层面:

  • top:CPU 和内存整体情况

  • vmstat:系统整体负载

  • iostat:磁盘 IO

  • netstat / ss:网络连接

  • pidstat:进程级别的 CPU、IO 统计

JDK 自带工具:

  • jps:查看 Java 进程

  • jstat:GC 统计

  • jmap:堆内存分析、dump

  • jstack:线程栈

  • jinfo:JVM 参数

  • jconsole / VisualVM:可视化监控

阿里工具:

  • Arthas:在线诊断,最常用

  • MAT:堆 dump 分析

  • Druid:数据库连接池监控


高频追问

追问1:CPU 使用率 100% 怎么排查?

排查步骤:

  1. top 找到占用 CPU 最高的 Java 进程 PID

  2. top -Hp <pid> 找到进程内 CPU 最高的线程 TID

  3. printf "%x\n" <tid> 将 TID 转成十六进制

  4. jstack <pid> > jstack.log 导出线程栈

  5. 在 jstack.log 中搜索十六进制 TID,找到对应的线程

  6. 看线程的执行栈,定位到具体代码行

  7. 分析代码为什么占用 CPU 高

常见原因:死循环、大量计算、正则回溯、频繁 GC、热点方法

追问2:如何判断是不是 GC 导致的性能问题?

判断方法:

  1. 看 GC 日志:接口慢的时间点是否正好有 GC 发生?特别是 Full GC 的时间点是否和慢请求对应?

  2. 看 jstat:持续观察 GC 频率和耗时,看 GC 后内存是否下降

  3. 对比验证:GC 期间接口响应时间明显变长,GC 后恢复正常 → 基本可以确定是 GC 影响

如果 GC 频繁或停顿时间长,就需要进行 GC 调优。

追问3:线上服务突然变慢,怎么排查?

按从外到内、从宏观到微观的顺序:

  1. 先看整体:

    • 服务器负载(CPU、内存、IO、网络)

    • 应用的 QPS、响应时间、错误率

    • 有没有发布变更?有没有流量突增?

  2. 看应用层:

    • 错误日志有没有异常?

    • 慢接口是哪些?

    • JVM 监控:GC 情况、内存情况、线程情况

  3. 看依赖:

    • 数据库:慢 SQL、连接数、锁等待

    • 缓存:命中率、连接数

    • MQ:堆积情况

    • 第三方接口:响应时间

  4. 深入排查:

    • 用 Arthas trace 慢接口,找出慢的环节

    • 看线程栈,线程都在做什么

    • 看是否有锁竞争、死锁

常见原因:流量突增、发布引入 bug、数据库慢 SQL、第三方服务挂了、缓存雪崩、Full GC、死锁

追问4:什么是性能瓶颈的漏斗分析法?

漏斗分析法就是从请求的入口开始,逐层往下排查,像漏斗一样逐步缩小范围:

  1. 第一层:客户端 → 网络(客户端本身?网络延迟?丢包?)

  2. 第二层:负载均衡 / 网关(Nginx/网关层瓶颈?连接数?)

  3. 第三层:应用服务(CPU 密集?IO 密集?哪个方法慢?)

  4. 第四层:中间件(数据库?缓存?MQ?)

  5. 第五层:外部依赖(第三方接口?其他服务?)

每一层都排查耗时占比,找到耗时最大的那一层,然后继续深入那一层排查,直到定位到根因。

追问5:如何建立性能监控体系?

完善的监控体系是快速定位性能问题的基础:

  1. 基础监控:

    • 服务器层面:CPU、内存、磁盘 IO、网络

    • JVM 层面:堆内存、GC、线程数、类加载

    • 应用层面:QPS、响应时间、错误率

  2. 链路监控:

    • 分布式调用链(SkyWalking、Pinpoint 等)

    • 能看到一次请求经过的所有环节和耗时

  3. 业务监控:核心业务指标、接口维度监控

  4. 告警体系:关键指标设置阈值告警,异常时及时通知

  5. 日志体系:统一日志收集(ELK)、关键路径打点、慢查询日志、慢接口日志


📌 面试技巧:回答 JVM 性能定位问题时,一定要体现出系统性的排查思路,而不是零散的命令。先说整体方法论(先宏观后微观、数据驱动),再分场景展开,最后总结工具。这样面试官会觉得你有实际排查经验,而不是背题。


第二章 Java 并发(10题)


9. synchronized 底层原理

标准答案

synchronized 是 Java 中最基本的互斥同步机制,保证同一时刻只有一个线程执行被修饰的代码。

【三种使用方式】

| 使用方式 | 锁对象 | 作用范围 | |----------|--------|----------| | 修饰实例方法 | 当前实例对象(this) | 整个方法体 | | 修饰静态方法 | 当前类的 Class 对象 | 整个静态方法体 | | 修饰代码块 | 括号内的对象 | 代码块内部 |

【底层实现原理】

synchronized 的底层是基于 对象头的 Mark Word 和 monitor(监视器锁) 实现的。

1. 对象头 Mark Word

在 HotSpot 虚拟机中,对象在内存中分为三部分:对象头、实例数据、对齐填充。

对象头包含两部分信息:

  • Mark Word:存储对象自身的运行时数据(哈希码、GC 分代年龄、锁状态标志等)

  • 类型指针:指向方法区中类元数据的指针

Mark Word 在不同锁状态下的结构(32 位虚拟机):

| 锁状态 | 25 bit | 4 bit | 1 bit | 2 bit | |--------|--------|-------|-------|-------| | 无锁 | 对象哈希码 | 对象分代年龄 | 0 | 01 | | 偏向锁 | 线程 ID + Epoch | 对象分代年龄 | 1 | 01 | | 轻量级锁 | 指向栈中锁记录的指针 | | | 00 | | 重量级锁 | 指向互斥量(monitor)的指针 | | | 10 | | GC 标记 | 空 | | | 11 |

2. monitor 监视器锁

  • 每个对象都关联一个 monitor

  • 当 monitor 被某个线程持有后,它便处于锁定状态

  • 同步代码块:使用 monitorenter 和 monitorexit 指令实现

    • monitorenter:进入同步块,尝试获取 monitor 的所有权

    • monitorexit:退出同步块,释放 monitor 的所有权

  • 同步方法:使用方法修饰符上的 ACC_SYNCHRONIZED 标志实现,本质也是获取 monitor

【锁升级过程】

JDK6 之后,synchronized 做了大量优化,引入了锁升级机制,锁的状态会随着竞争情况逐步升级:

无锁 → 偏向锁 → 轻量级锁 → 重量级锁

锁只能升级,不能降级(除了偏向锁可以重置)。

1. 偏向锁(Biased Locking)

  • 适用场景:只有一个线程进入同步块,没有竞争

  • 原理:当第一个线程进入同步块时,会在对象头的 Mark Word 中记录当前线程 ID,之后该线程进入/退出同步块时不需要 CAS 操作,直接判断线程 ID 是否是自己

  • 优点:消除了无竞争情况下的同步原语,提高性能

  • 撤销:当有另一个线程尝试获取锁时,偏向锁会撤销,升级为轻量级锁

2. 轻量级锁(Lightweight Locking)

  • 适用场景:多个线程交替进入同步块,竞争不激烈

  • 原理:

    • 线程进入同步块时,如果对象处于无锁状态,会在当前线程的栈帧中创建一个 Lock Record(锁记录)

    • 将对象头的 Mark Word 复制到 Lock Record 中

    • 使用 CAS 尝试将对象头的 Mark Word 更新为指向 Lock Record 的指针

    • 成功则获取轻量级锁,失败则说明有竞争,膨胀为重量级锁

  • 解锁:使用 CAS 将 Lock Record 中的 Mark Word 复制回对象头,成功则解锁成功,失败则说明有竞争,膨胀为重量级锁

  • 优点:在竞争不激烈的情况下,用 CAS 代替操作系统互斥量,减少线程切换开销

3. 重量级锁(Heavyweight Locking)

  • 适用场景:多线程同时竞争,锁持有时间长

  • 原理:依赖操作系统的互斥量(mutex)实现

  • 特点:

    • 线程会被阻塞,需要从用户态切换到内核态,开销大

    • 但不会自旋消耗 CPU,适合锁持有时间长的场景

【其他优化】

1. 自旋锁(Spin Lock)

  • 线程获取锁失败时,不立即阻塞,而是循环尝试获取锁(自旋)

  • 优点:如果锁很快被释放,避免了线程上下文切换的开销

  • 缺点:如果锁持有时间长,自旋会白白消耗 CPU

  • JDK6 引入自适应自旋:自旋时间不固定,根据前一次在同一个锁上的自旋时间和锁拥有者的状态来决定

2. 锁消除(Lock Elimination)

  • 虚拟机即时编译器在运行时,对一些代码上要求同步,但被检测到不可能存在共享数据竞争的锁进行消除

  • 依据:逃逸分析技术,如果判断一段代码中堆上的所有数据都不会逃逸出去被其他线程访问到,就可以把它们当作栈上数据对待,认为是线程私有的,无需加锁

3. 锁粗化(Lock Coarsening)

  • 如果有一系列连续的操作都对同一个对象反复加锁解锁,甚至加锁操作出现在循环体中,虚拟机会把锁的范围扩展(粗化)到整个操作序列的外部

  • 减少频繁加锁解锁的开销


高频追问

追问1:synchronized 和 ReentrantLock 的区别?

| 维度 | synchronized | ReentrantLock | |------|-------------|---------------| | 实现层面 | JVM 层面,关键字 | JDK 层面,API 层面 | | 锁释放 | 自动释放,退出同步块自动释放 | 手动释放,必须在 finally 中 unlock() | | 可重入 | 可重入 | 可重入 | | 公平性 | 非公平锁 | 默认非公平,可设置为公平锁 | | 可中断 | 不可中断 | 可中断(lockInterruptibly) | | 超时获取 | 不支持 | 支持(tryLock 带超时时间) | | 条件变量 | 不支持(wait/notify) | 支持多个 Condition | | 性能 | 优化后性能不错 | 竞争激烈时更可控 |

追问2:什么是可重入性?synchronized 是可重入的吗?

可重入性:同一个线程可以多次获取同一把锁,不会自己把自己锁死。

synchronized 是可重入的。原理:

  • 每个锁关联一个线程持有者和一个计数器

  • 当计数器为 0 时表示锁未被持有

  • 线程获取锁时,计数器 +1

  • 同一个线程再次获取锁时,计数器继续 +1

  • 退出同步块时计数器 -1,减到 0 时释放锁

这样就允许同一个线程多次进入同一个锁保护的代码块。

追问3:synchronized 是公平锁还是非公平锁?

synchronized 是非公平锁。

  • 公平锁:按照线程请求锁的顺序来获取锁,先到先得

  • 非公平锁:新来的线程可以插队,可能先于等待队列中的线程获取锁

为什么用非公平锁?

  • 性能更好:减少线程挂起和唤醒的开销

  • 新来的线程如果正好在锁释放时到达,可以直接获取,不用进入等待队列

  • 缺点:可能产生饥饿现象,某些线程一直获取不到锁

ReentrantLock 默认也是非公平的,可以通过构造函数参数设置为公平锁。

追问4:对象头的 Mark Word 里存了什么?

Mark Word 存储对象自身的运行时数据,包括:

  • 哈希码(HashCode)

  • GC 分代年龄

  • 锁状态标志位

  • 线程持有的锁

  • 偏向线程 ID

  • 偏向时间戳

在不同的锁状态下,Mark Word 的内容是不同的,这是一种动态的数据结构,根据对象的状态复用存储空间。

追问5:为什么 wait/notify 方法要写在 synchronized 里面?

因为 wait/notify 是对象的 monitor 相关的操作,必须先持有对象的 monitor 才能调用。

具体原因:

  1. wait 方法会释放对象的锁,如果没有锁就没法释放

  2. notify 方法需要唤醒等待在这个对象 monitor 上的线程,如果没有锁就没法操作 monitor

  3. 从语义上讲,等待/通知机制是基于共享状态的,需要同步来保证状态的可见性和原子性

如果不在 synchronized 中调用 wait/notify,会抛出 IllegalMonitorStateException。


10. volatile 的作用及内存可见性

标准答案

volatile 是 Java 提供的一种轻量级同步机制,有两个核心作用:保证可见性和禁止指令重排序。

【两大作用】

1. 保证内存可见性

  • 当一个线程修改了 volatile 变量的值,新值对其他线程是立即可见的

  • 实现原理:

    • 写操作时,加入 lock 前缀指令,将当前处理器缓存行的数据写回系统内存

    • 同时使其他处理器的缓存行无效(MESI 协议)

    • 其他线程读取时,发现缓存无效,重新从主内存加载最新值

2. 禁止指令重排序

  • volatile 变量的读写操作前后,指令不能被重排序

  • 实现原理:通过插入内存屏障(Memory Barrier)来实现

    • 在 volatile 写操作前插入 StoreStore 屏障

    • 在 volatile 写操作后插入 StoreLoad 屏障

    • 在 volatile 读操作后插入 LoadLoad 屏障

    • 在 volatile 读操作后插入 LoadStore 屏障

【volatile 能保证原子性吗?】

不能。 volatile 只能保证可见性和有序性,不能保证复合操作的原子性。

例如 i++ 操作,实际上是三步:

  1. 读取 i 的值

  2. 加 1

  3. 写回新值

在多线程环境下,这三步可能被打断,导致结果不正确。

如果需要保证原子性,应该使用:

  • synchronized

  • ReentrantLock

  • AtomicInteger 等原子类

【volatile 的应用场景】

1. 状态标记位

volatile boolean running = true;

// 线程 A
while (running) {
    // 执行业务逻辑
}

// 线程 B:可以安全地停止线程 A
running = false;

2. 双重检查锁(DCL)单例

public class Singleton {
    private volatile static Singleton instance; // 必须加 volatile
    
    private Singleton() {}
    
    public static Singleton getInstance() {
        if (instance == null) {              // 第一次检查
            synchronized (Singleton.class) {
                if (instance == null) {      // 第二次检查
                    instance = new Singleton(); // 禁止重排序
                }
            }
        }
        return instance;
    }
}

为什么要加 volatile?因为 new Singleton() 不是原子操作,可能被重排序,导致其他线程拿到半初始化的对象。

3. 一写多读的场景

  • 一个线程写,多个线程读

  • 写操作不频繁,读操作频繁

  • volatile 的读性能和普通变量差不多,写性能稍差

【volatile 和 synchronized 的对比】

| 维度 | volatile | synchronized | |------|----------|--------------| | 作用范围 | 只能修饰变量 | 修饰方法、代码块 | | 原子性 | 不保证 | 保证 | | 可见性 | 保证 | 保证 | | 有序性 | 保证(禁止重排序) | 保证(串行执行) | | 性能 | 轻量级,无阻塞 | 重量级,有阻塞 | | 使用场景 | 状态标记、DCL 单例、一写多读 | 多写、复合操作、需要原子性 |


高频追问

追问1:volatile 的实现原理?

可见性实现:

  • 写操作时加 lock 前缀指令

  • lock 前缀指令会将当前处理器缓存行的数据写回主内存

  • 通过 MESI 协议,使其他处理器的缓存行无效

  • 其他线程读取时,缓存失效,重新从主内存加载

有序性实现:

  • 通过内存屏障禁止指令重排序

  • 内存屏障是 CPU 指令,确保屏障前后的指令按顺序执行

  • JMM 为 volatile 定义了内存屏障插入策略

追问2:volatile 为什么不能保证原子性?

因为 volatile 只保证单次读/写的原子性,对于复合操作(如 i++),中间可能被其他线程打断。

举例:i = 0,两个线程同时执行 i++

  • 线程 A 读取 i = 0

  • 线程 B 读取 i = 0

  • 线程 A 加 1,写回 i = 1

  • 线程 B 加 1,写回 i = 1

  • 结果应该是 2,实际是 1

volatile 只能保证每次读取的是最新值,但不能保证读-改-写的原子性。

追问3:DCL 单例为什么要加 volatile?

new Singleton() 不是原子操作,会被分解为三步:

  1. 分配对象内存空间

  2. 初始化对象(执行构造函数)

  3. 将 instance 指向分配的内存地址

由于指令重排序,可能出现 1→3→2 的执行顺序:

  • 线程 A 执行了 1 和 3,instance 不为 null 但还没初始化

  • 线程 B 第一次检查发现 instance != null,直接返回

  • 线程 B 使用未初始化的对象,导致报错

加 volatile 后,禁止指令重排序,保证 1→2→3 的顺序,避免拿到半初始化的对象。

追问4:volatile 读和写的内存屏障有什么不同?

volatile 写:

  • 写之前插入 StoreStore 屏障:禁止之前的普通写和 volatile 写重排序

  • 写之后插入 StoreLoad 屏障:禁止 volatile 写和之后的 volatile 读/写重排序

volatile 读:

  • 读之后插入 LoadLoad 屏障:禁止 volatile 读和之后的普通读重排序

  • 读之后插入 LoadStore 屏障:禁止 volatile 读和之后的普通写重排序

简单说:volatile 写之前的操作不能排到写之后,volatile 读之后的操作不能排到读之前。

追问5:happens-before 原则和 volatile 的关系?

happens-before 是 JMM 中定义的偏序关系,如果 A happens-before B,那么 A 的执行结果对 B 可见。

volatile 变量规则是 happens-before 的八大规则之一:

对一个 volatile 变量的写操作先行发生于后面对这个变量的读操作。

也就是说,线程 A 写了一个 volatile 变量,线程 B 读了同一个 volatile 变量,那么线程 A 在写 volatile 变量之前的所有操作,对线程 B 在读 volatile 变量之后都是可见的。

这就是 volatile 除了保证自身可见性之外,还能保证其前后普通变量的可见性的原因。


11. CAS 原理及 ABA 问题

标准答案

【什么是 CAS】

CAS(Compare And Swap,比较并交换)是一种无锁原子操作,用于在多线程环境下实现变量的原子更新。

操作过程: CAS 包含三个操作数:

  • V:要更新的变量(内存位置)

  • A:预期值(旧值)

  • B:新值

只有当 V 的值等于 A 时,才将 V 的值更新为 B;否则不做任何操作。整个过程是原子的。

伪代码:

boolean compareAndSwap(V, A, B) {
    if (V == A) {
        V = B;
        return true;
    }
    return false;
}

【CAS 的底层实现】

CAS 是由 Unsafe 类提供的,底层是 CPU 的原子指令(如 x86 的 cmpxchg 指令)。

在 HotSpot 中:

  • 单核 CPU:直接使用 cmpxchg 指令

  • 多核 CPU:cmpxchg 指令 + lock 前缀,保证多核下的原子性

CAS 是一种乐观锁的实现:

  • 假设没有冲突,直接操作

  • 如果发现冲突(值不对),就重试,直到成功为止

  • 不需要加锁,性能更高

【CAS 的应用】

1. 原子类(Atomic 系列)

java.util.concurrent.atomic 包下的原子类都是基于 CAS 实现的:

  • AtomicInteger、AtomicLong、AtomicBoolean

  • AtomicReference、AtomicStampedReference

  • AtomicIntegerArray、AtomicLongArray

以 AtomicInteger 的 getAndIncrement() 为例:

public final int getAndIncrement() {
    return unsafe.getAndAddInt(this, valueOffset, 1);
}

// Unsafe 中的实现(自旋 + CAS)
public final int getAndAddInt(Object obj, long offset, int delta) {
    int v;
    do {
        v = getIntVolatile(obj, offset);  // 获取当前值
    } while (!compareAndSwapInt(obj, offset, v, v + delta)); // CAS 更新
    return v;
}

这就是典型的自旋 CAS:失败了就重试,直到成功。

2. 自旋锁

3. AQS 同步器框架

【ABA 问题】

什么是 ABA 问题?

CAS 在操作时会检查值有没有变化,如果值从 A 变成了 B,又变回了 A,CAS 会误认为值没有变化,但实际上已经被改过了。

线程1:读取值 A
线程2:将 A 改为 B
线程2:将 B 改回 A
线程1:CAS 操作,发现值还是 A,操作成功(但实际上值已经被改过了)

ABA 问题的影响:

  • 在大多数场景下,ABA 问题不影响最终结果

  • 但在某些场景下会有问题,比如栈操作、链表操作等,中间的变化可能导致数据结构不一致

如何解决 ABA 问题?

使用版本号或时间戳,每次修改都增加版本号,CAS 时不仅比较值,还要比较版本号。

Java 中提供了 AtomicStampedReference 和 AtomicMarkableReference:

1. AtomicStampedReference

  • 给变量加上一个整数版本号(stamp)

  • CAS 时同时比较引用和版本号

AtomicStampedReference<String> ref = new AtomicStampedReference<>("A", 0);

// 比较引用和版本号,都匹配才更新
ref.compareAndSet("A", "B", 0, 1);

2. AtomicMarkableReference

  • 使用一个 boolean 标记(mark)代替整数版本号

  • 只能标记"是否被修改过",不能区分修改次数

  • 适用于只关心是否被改过,不关心改了几次的场景


高频追问

追问1:CAS 的缺点是什么?

  1. ABA 问题:值从 A 变 B 又变回 A,CAS 会误认为没变

    • 解决:加版本号(AtomicStampedReference)

  2. 循环时间长开销大:

    • 自旋 CAS 如果长时间不成功,会一直循环,消耗 CPU

    • 解决:JVM 支持处理器提供的 pause 指令,提高自旋效率;或者限制自旋次数

  3. 只能保证一个共享变量的原子操作:

    • CAS 只能对单个变量进行原子操作

    • 多个变量的复合操作无法用单个 CAS 保证原子性

    • 解决:用 AtomicReference 把多个变量封装成一个对象;或者用 synchronized

追问2:乐观锁和悲观锁的区别?

| 维度 | 悲观锁 | 乐观锁 | |------|--------|--------| | 思想 | 认为一定会有冲突,先加锁再操作 | 认为不会有冲突,直接操作,失败再重试 | | 实现 | synchronized、ReentrantLock | CAS、版本号 | | 适用场景 | 写多、竞争激烈 | 读多、竞争少 | | 性能 | 竞争少时开销大 | 竞争少时性能好,竞争激烈时自旋开销大 | | 举例 | 数据库行锁、表锁 | 数据库版本号、AtomicInteger |

追问3:CAS 和 synchronized 怎么选?

选择依据:

  1. 竞争程度:

    • 竞争少 → CAS(乐观锁),性能更好

    • 竞争激烈 → synchronized,CAS 自旋消耗 CPU 反而更慢

  2. 操作复杂度:

    • 单个变量的原子更新 → CAS(Atomic 系列)

    • 多个变量的复合操作 → synchronized 或 ReentrantLock

  3. 功能需求:

    • 需要可中断、公平锁、超时、条件变量 → ReentrantLock

    • 简单的原子操作 → CAS

简单说:简单的原子操作用 CAS(Atomic 类),复杂的同步用 synchronized 或 Lock。

追问4:什么是 Unsafe 类?

Unsafe 是 sun.misc.Unsafe 包下的类,提供了一些底层操作,可以直接操作内存、线程、CAS 等。

主要功能:

  1. 内存操作:分配内存、释放内存、直接读写内存

  2. CAS 操作:compareAndSwapInt、compareAndSwapLong、compareAndSwapObject

  3. 线程操作:park、unpark(挂起和唤醒线程)

  4. 数组操作:获取数组第一个元素的偏移地址

  5. 对象操作:获取字段的偏移地址,直接修改对象字段值

为什么叫 Unsafe?

  • 因为这些操作很底层,使用不当可能导致内存泄漏、JVM 崩溃等问题

  • 不建议直接在业务代码中使用

  • 主要被 JDK 内部的并发包(JUC)使用

追问5:AtomicInteger 怎么实现的?

AtomicInteger 基于 CAS + 自旋实现:

  1. 使用 Unsafe 类的 objectFieldOffset 获取 value 字段在对象中的偏移量

  2. value 字段用 volatile 修饰,保证可见性

  3. 所有原子操作都通过 Unsafe 的 CAS 方法实现

  4. 如果 CAS 失败,就自旋重试,直到成功

核心代码:

public class AtomicInteger {
    private volatile int value;
    private static final long valueOffset;
    
    static {
        valueOffset = unsafe.objectFieldOffset(AtomicInteger.class.getDeclaredField("value"));
    }
    
    public final int incrementAndGet() {
        return unsafe.getAndAddInt(this, valueOffset, 1) + 1;
    }
}

这是一种典型的无锁编程思想,不需要加锁就能保证原子性。


12. AQS 是什么?

标准答案

AQS(AbstractQueuedSynchronizer,抽象队列同步器)是 Java 并发包中用来构建锁和同步器的基础框架。

ReentrantLock、Semaphore、CountDownLatch、ReentrantReadWriteLock、FutureTask 等都是基于 AQS 实现的。

【AQS 的核心思想】

如果被请求的共享资源空闲,则将当前请求资源的线程设置为有效的工作线程,并将共享资源设置为锁定状态。如果被请求的共享资源被占用,那么就需要一套线程阻塞等待以及被唤醒时锁分配的机制,这个机制 AQS 是用 CLH 队列实现的。

【AQS 的核心组件】

1. 状态变量 state

  • private volatile int state;

  • 表示同步状态,用 volatile 修饰保证可见性

  • 不同的同步器有不同的含义:

    • ReentrantLock:表示锁的重入次数

    • Semaphore:表示剩余许可数

    • CountDownLatch:表示剩余计数

  • 提供三个方法操作 state:

    • getState():获取状态

    • setState():设置状态

    • compareAndSetState():CAS 设置状态,保证原子性

2. CLH 同步队列

  • 一个双向链表结构的队列,用来存放等待的线程

  • 队列中的每个节点(Node)封装了线程、等待状态、前驱后继指针

  • 队首节点是当前持有锁的线程(或者说,队首节点是正在获取锁的节点)

  • 后续节点是等待获取锁的线程

Node 节点的重要属性:

  • waitStatus:等待状态

    • SIGNAL(-1):后继节点需要被唤醒

    • CANCELLED(1):节点被取消

    • CONDITION(-2):节点在条件队列中等待

    • PROPAGATE(-3):传播状态(共享模式用)

    • 0:初始状态

  • prev / next:前驱/后继节点

  • thread:节点对应的线程

  • nextWaiter:条件队列中的下一个节点

3. 两种资源共享方式

| 模式 | 说明 | 实现类 | |------|------|--------| | 独占模式(Exclusive) | 资源只能被一个线程持有 | ReentrantLock | | 共享模式(Share) | 资源可以被多个线程同时持有 | Semaphore、CountDownLatch、ReadWriteLock 的读锁 |

【AQS 的工作流程】

以独占模式获取锁为例:

1. 尝试获取锁(tryAcquire)

  • 调用 tryAcquire(arg) 尝试获取锁

  • 成功 → 直接返回,线程继续执行

  • 失败 → 进入下一步

2. 加入等待队列

  • 将当前线程封装成 Node 节点

  • 使用 CAS 将节点加入到 CLH 队列的尾部

  • 保证线程安全地入队

3. 阻塞等待(park)

  • 节点入队后,找到前驱节点

  • 如果前驱节点是 head(说明自己是第一个等待的),再尝试一次获取锁

  • 还是失败,就调用 LockSupport.park() 挂起当前线程

  • 等待被前驱节点唤醒

4. 释放锁(tryRelease)

  • 持有锁的线程释放锁,调用 tryRelease(arg)

  • 释放成功后,唤醒后继节点(LockSupport.unpark())

  • 后继节点被唤醒后,再次尝试获取锁

【AQS 的设计模式:模板方法】

AQS 使用了模板方法模式:

  • AQS 定义了同步的骨架(入队、出队、阻塞、唤醒等)

  • 具体的获取/释放逻辑由子类实现

子类需要实现的方法:

  • tryAcquire(int):独占方式尝试获取资源

  • tryRelease(int):独占方式尝试释放资源

  • tryAcquireShared(int):共享方式尝试获取资源

  • tryReleaseShared(int):共享方式尝试释放资源

  • isHeldExclusively():是否是独占模式

子类通过重写这些方法,结合 state 变量的含义,就能实现不同的同步器。


高频追问

追问1:AQS 为什么用双向队列?用单向队列行不行?

必须用双向队列,原因:

  1. 节点取消时需要断开连接:

    • 当某个线程等待超时或被中断时,需要从队列中移除

    • 单向队列删除中间节点需要从头遍历,效率低

    • 双向队列可以直接找到前驱和后继,O(1) 时间删除

  2. 唤醒后继节点时需要检查状态:

    • 释放锁时需要唤醒后继节点

    • 但后继节点可能已经取消(CANCELLED 状态)

    • 需要从后往前找第一个未取消的节点

    • 双向队列可以从 tail 往前找

  3. 入队时的安全检查:

    • 入队时需要设置前驱节点的 next 指针

    • 如果 CAS 失败,需要重新尝试

    • 双向结构更安全

简单说:双向队列支持更高效的节点删除和状态检查。

追问2:AQS 中的 state 为什么用 volatile?

state 是 AQS 的核心状态变量,必须保证多线程下的可见性:

  1. 可见性:一个线程修改了 state,其他线程能立即看到

  2. 有序性:禁止指令重排序,保证 state 修改的顺序性

  3. CAS 操作的基础:CAS 操作需要基于 volatile 的内存语义

state 的修改有两种方式:

  • setState():直接设置,用于已经持有锁的情况(单线程写)

  • compareAndSetState():CAS 设置,用于竞争情况下的原子更新

追问3:AQS 和 synchronized 的区别?

| 维度 | AQS | synchronized | |------|-----|-------------| | 实现层面 | Java 代码实现,JDK 层面 | JVM 层面,C++ 实现 | | 锁类型 | 可实现公平/非公平、独占/共享 | 非公平、独占 | | 可中断 | 支持 | 不支持 | | 超时获取 | 支持 | 不支持 | | 条件变量 | 支持多个 Condition | 只支持一个(wait/notify) | | 性能 | 功能更丰富,可控性强 | 优化后性能也不错 | | 使用方式 | 继承 AQS,实现模板方法 | 关键字,简单直接 |

AQS 是更灵活、功能更丰富的同步框架,synchronized 是更简单、更基础的同步机制。

追问4:什么是条件队列?AQS 的 Condition 怎么实现的?

Condition 是 AQS 提供的条件变量,相当于 synchronized 中的 wait/notify 机制,但更灵活,可以有多个条件队列。

实现原理:

  • 每个 Condition 对象对应一个条件队列(单向链表)

  • 调用 await() 时,线程释放锁,加入条件队列,阻塞等待

  • 调用 signal() 时,唤醒条件队列中的第一个节点,转移到同步队列中

  • 被唤醒的节点重新尝试获取锁

和 wait/notify 的区别:

  • synchronized 只能有一个等待队列(对象的 wait set)

  • AQS 的 Condition 可以有多个条件队列,每个条件对应一个队列

  • 可以实现更精确的唤醒,比如只唤醒等待某个条件的线程

典型应用:ArrayBlockingQueue 的 notEmpty 和 notFull 两个条件。

追问5:为什么 AQS 的 CLH 队列要把 head 节点设置成空节点(哑节点)?

head 节点是一个"哑节点"(dummy node),不对应任何等待线程,它的作用:

  1. 简化边界条件处理:

    • 如果没有哑节点,第一个节点入队和后续节点入队的逻辑不同

    • 有了哑节点,所有节点的入队逻辑都一样

    • 减少特殊情况的判断,代码更简洁

  2. 避免头节点竞争:

    • head 是固定的哑节点,不会被多个线程同时修改

    • 只有 tail 会被多个线程竞争修改(入队)

    • 减少 CAS 竞争点

  3. 释放锁时统一处理:

    • 释放锁时只需要唤醒 head 的后继节点

    • 不管队列中有多少节点,逻辑都一样

这是一种常见的链表优化技巧,用哨兵节点简化边界处理。


13. ReentrantLock 与 synchronized 区别

标准答案

ReentrantLock 和 synchronized 都是 Java 中常用的互斥同步机制,都实现了可重入锁,但在实现和功能上有很多区别。

【核心对比】

| 维度 | synchronized | ReentrantLock | |------|-------------|---------------| | 实现层面 | JVM 内置锁,关键字实现 | JDK 层面,基于 AQS 实现 | | 锁释放 | 自动释放,退出同步块自动释放 | 手动释放,必须在 finally 中 unlock() | | 可重入 | 支持 | 支持 | | 公平性 | 非公平锁 | 默认非公平,可设置为公平锁 | | 可中断 | 不可中断 | 支持可中断获取锁(lockInterruptibly) | | 超时获取 | 不支持 | 支持(tryLock 带超时时间) | | 条件变量 | 一个(wait/notify) | 支持多个 Condition | | 性能 | 优化后性能不错 | 竞争激烈时更可控 | | 灵活性 | 简单,不够灵活 | 灵活,功能丰富 | | 监控 | 难以监控 | 可以获取锁的状态、等待线程数等 |

【详细对比】

1. 实现层面

  • synchronized:JVM 层面实现,由 monitorenter 和 monitorexit 指令实现

  • ReentrantLock:JDK 层面实现,基于 AQS(AbstractQueuedSynchronizer)框架

2. 锁的释放

  • synchronized:自动释放,方法正常退出或异常退出时自动释放锁,不会死锁

  • ReentrantLock:手动释放,必须在 finally 中调用 unlock(),否则可能导致死锁

3. 公平性

  • synchronized:非公平锁,新来的线程可以插队

  • ReentrantLock:默认非公平,可以通过构造函数设置为公平锁

    ReentrantLock fairLock = new ReentrantLock(true);  // 公平锁
    ReentrantLock unfairLock = new ReentrantLock(false); // 非公平锁(默认)
    

4. 可中断性

  • synchronized:不可中断,线程一旦进入等待,就只能等锁释放,不能中断

  • ReentrantLock:支持可中断获取锁

    lock.lockInterruptibly(); // 等待锁的过程中可以被中断
    

5. 超时获取

  • synchronized:不支持超时,只能一直等

  • ReentrantLock:支持 tryLock 带超时时间

    if (lock.tryLock(5, TimeUnit.SECONDS)) {
        try {
            // 拿到锁了
        } finally {
            lock.unlock();
        }
    } else {
        // 超时了,没拿到锁
    }
    

6. 条件变量

  • synchronized:只能有一个条件队列(对象的 wait set),wait/notify/notifyAll

  • ReentrantLock:可以创建多个 Condition 对象,每个 Condition 对应一个条件队列

    ReentrantLock lock = new ReentrantLock();
    Condition notEmpty = lock.newCondition();
    Condition notFull = lock.newCondition();
    

    可以实现更精确的唤醒,比如只唤醒等待"非空"条件的线程。

7. 性能

  • synchronized:经过多次优化(偏向锁、轻量级锁、自旋锁、锁消除、锁粗化),性能已经很好了

  • ReentrantLock:在竞争激烈的情况下,性能更稳定,功能更丰富

【使用选择】

优先用 synchronized 的情况:

  • 简单的同步需求

  • 代码简洁,自动释放锁,不容易出错

  • 不需要高级功能(公平、可中断、超时、多条件)

用 ReentrantLock 的情况:

  • 需要公平锁

  • 需要可中断获取锁

  • 需要超时获取锁

  • 需要多个条件变量

  • 需要更细粒度的控制


高频追问

追问1:什么是可重入锁?为什么需要可重入?

可重入锁:同一个线程可以多次获取同一把锁,不会自己把自己锁死。

为什么需要可重入?

  • 递归调用:一个同步方法调用另一个同步方法(同一个锁)

  • 嵌套调用:同步代码块中又调用了同一个锁的同步方法

如果不可重入,线程在第二次获取锁时会被自己阻塞,导致死锁。

实现原理:

  • 锁关联一个持有者线程和一个计数器

  • 线程获取锁时,计数器 +1

  • 同一个线程再次获取,计数器继续 +1

  • 释放锁时计数器 -1,减到 0 时真正释放锁

synchronized 和 ReentrantLock 都是可重入的。

追问2:公平锁和非公平锁的区别?怎么实现的?

公平锁:按照线程请求锁的顺序获取,先到先得,不会产生饥饿。 非公平锁:新来的线程可以插队,可能先于等待队列中的线程获取锁。

性能对比:

  • 非公平锁性能更好:减少线程挂起和唤醒的开销

  • 公平锁更公平:不会有线程饥饿,但吞吐量低一些

ReentrantLock 的实现:

  • 非公平锁(默认):线程获取锁时先 CAS 尝试插队,失败了才入队

  • 公平锁:线程获取锁时先检查队列中有没有在等待的线程,有就老老实实排队

追问3:ReentrantLock 的 tryLock 和 lock 有什么区别?

| 方法 | 是否阻塞 | 返回值 | 可中断 | 超时 | |------|----------|--------|--------|------| | lock() | 阻塞等待 | void | 不可中断 | 不支持 | | lockInterruptibly() | 阻塞等待 | void | 可中断 | 不支持 | | tryLock() | 不阻塞,立即返回 | boolean | 不可中断 | 不支持 | | tryLock(time, unit) | 阻塞等待超时 | boolean | 可中断 | 支持 |

使用场景:

  • lock():简单的同步,一定要拿到锁

  • tryLock():尝试获取,拿不到就做别的事,不阻塞

  • tryLock(time):可以等一会儿,超时就放弃

  • lockInterruptibly():等待过程中可以响应中断

追问4:ReentrantLock 怎么实现可重入的?

ReentrantLock 基于 AQS 实现可重入:

  1. AQS 的 state 变量表示锁的持有次数

  2. 线程获取锁时:

    • 如果 state == 0(锁空闲),CAS 设置 state = 1,设置当前线程为持有者

    • 如果 state > 0 且当前线程就是持有者,state++(重入)

    • 否则获取失败,进入等待队列

  3. 线程释放锁时:

    • state--

    • 如果 state == 0,说明完全释放,清空持有者线程

    • 如果 state > 0,说明还有重入,不释放

  4. 因为是独占锁,只有持有者线程才能释放,所以是线程安全的

追问5:什么是读写锁?ReentrantReadWriteLock 了解吗?

读写锁是一种特殊的锁,维护了一对锁:读锁和写锁。

规则:

  • 读-读:不互斥,可以并发读

  • 读-写:互斥,读的时候不能写,写的时候不能读

  • 写-写:互斥,写的时候不能写

适用场景:读多写少的场景,比独占锁性能更好。

ReentrantReadWriteLock 的特点:

  • 支持公平/非公平

  • 支持可重入

  • 写锁可以降级为读锁(锁降级),读锁不能升级为写锁

  • 支持 Condition(写锁支持,读锁不支持)

state 变量的设计:

  • 高 16 位:读锁持有次数

  • 低 16 位:写锁持有次数(重入次数)


14. ThreadPoolExecutor 七大参数

标准答案

ThreadPoolExecutor 是 Java 中线程池的核心实现类,构造函数有 7 个核心参数。

【七大参数】

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

1. corePoolSize(核心线程数)

  • 线程池中保持的核心线程数量

  • 即使核心线程空闲,也不会被回收(除非设置了 allowCoreThreadTimeOut)

  • 线程池初始化时,核心线程不会立即创建,而是有任务来的时候才创建

  • 核心线程会一直存活,即使没有任务

2. maximumPoolSize(最大线程数)

  • 线程池中允许的最大线程数量

  • 当核心线程都在忙,且任务队列满了之后,会创建非核心线程来处理任务

  • 总线程数 = 核心线程数 + 非核心线程数 ≤ maximumPoolSize

3. keepAliveTime(空闲线程存活时间)

  • 非核心线程空闲时的存活时间

  • 超过这个时间,空闲的非核心线程会被回收

  • 如果设置了 allowCoreThreadTimeOut(true),核心线程空闲超时也会被回收

4. unit(时间单位)

  • keepAliveTime 的时间单位

  • TimeUnit 枚举:NANOSECONDS、MICROSECONDS、MILLISECONDS、SECONDS、MINUTES、HOURS、DAYS

5. workQueue(任务队列)

  • 存放等待执行的任务的阻塞队列

  • 当核心线程都在忙时,新任务会先放入队列等待

常见的任务队列: | 队列类型 | 特点 | 适用场景 | |----------|------|----------| | ArrayBlockingQueue | 有界队列,基于数组 | 固定大小,可控 | | LinkedBlockingQueue | 无界队列(默认 Integer.MAX_VALUE),基于链表 | 任务量不大,不会爆队列 | | SynchronousQueue | 不存储任务,直接交给线程 | 任务量大,配合最大线程数 | | PriorityBlockingQueue | 优先级队列,按优先级执行 | 任务有优先级 | | DelayQueue | 延迟队列,定时任务 | 定时任务、延迟执行 |

6. threadFactory(线程工厂)

  • 用来创建线程的工厂

  • 可以给线程设置名字、优先级、是否守护线程等

  • 默认使用 Executors.defaultThreadFactory(),创建的线程都是非守护的,优先级正常

7. handler(拒绝策略)

  • 当线程池和队列都满了,新任务来的时候的处理策略

  • JDK 提供了 4 种内置拒绝策略:

| 策略 | 说明 | |------|------| | AbortPolicy | 默认策略,直接抛出 RejectedExecutionException 异常 | | CallerRunsPolicy | 由提交任务的线程自己执行这个任务 | | DiscardPolicy | 直接丢弃任务,不抛异常 | | DiscardOldestPolicy | 丢弃队列中最老的任务,然后尝试重新提交 |

【线程池工作流程】

新任务来
   │
   ├─ 当前线程数 < corePoolSize → 创建核心线程执行任务
   │
   ├─ 当前线程数 >= corePoolSize → 尝试放入任务队列
   │     │
   │     ├─ 队列没满 → 放入队列等待
   │     │
   │     └─ 队列满了 → 检查是否达到 maximumPoolSize
   │           │
   │           ├─ 没达到 → 创建非核心线程执行任务
   │           │
   │           └─ 达到了 → 执行拒绝策略

一句话总结:核心线程 → 队列 → 非核心线程 → 拒绝策略


高频追问

追问1:为什么要用线程池?线程池的好处?

  1. 降低资源消耗:通过复用已创建的线程,减少线程创建和销毁的开销

  2. 提高响应速度:任务来了直接用已有线程,不用等线程创建

  3. 提高线程的可管理性:

    • 统一管理线程数量,避免无限制创建线程

    • 可以监控和统计线程池的运行状态

  4. 提供更多功能:定时执行、定期执行、延迟执行等

如果不用线程池,每个任务都创建一个新线程:

  • 线程创建和销毁开销大

  • 线程太多会占用大量内存

  • 线程切换开销大

  • 缺乏管理,容易失控

追问2:核心线程和非核心线程有什么区别?

| 维度 | 核心线程 | 非核心线程 | |------|----------|-----------| | 数量 | corePoolSize | maximumPoolSize - corePoolSize | | 存活时间 | 默认永久存活(除非 allowCoreThreadTimeOut) | 空闲超过 keepAliveTime 就回收 | | 创建时机 | 有任务就创建(初始为 0,懒加载) | 队列满了之后才创建 | | 任务执行 | 直接执行任务 | 直接执行任务 |

注意:线程池内部并没有区分核心线程和非核心线程,只是通过数量来控制。所有线程都是一样的,只是数量上有 corePoolSize 这个阈值。

追问3:常见的线程池有哪些?各自的适用场景?

Executors 提供了几种常用的线程池工厂方法:

| 线程池类型 | 核心参数 | 特点 | 适用场景 | |-----------|----------|------|----------| | FixedThreadPool | core=max,LinkedBlockingQueue | 固定线程数,无界队列 | 任务量稳定,长期任务 | | CachedThreadPool | core=0,max=Integer.MAX_VALUE,SynchronousQueue | 按需创建,60秒回收 | 任务量大但执行时间短 | | SingleThreadExecutor | core=max=1,LinkedBlockingQueue | 单线程,顺序执行 | 需要保证顺序执行的任务 | | ScheduledThreadPool | core 指定,max=Integer.MAX_VALUE,DelayedWorkQueue | 定时/延迟执行 | 定时任务、周期任务 |

⚠️ 注意:《阿里巴巴 Java 开发手册》强制要求不允许使用 Executors 创建线程池,因为:

  • FixedThreadPool 和 SingleThreadPool:队列是无界的,可能堆积大量任务导致 OOM

  • CachedThreadPool 和 ScheduledThreadPool:最大线程数是 Integer.MAX_VALUE,可能创建大量线程导致 OOM

应该用 ThreadPoolExecutor 构造函数手动创建,明确参数,控制资源。

追问4:线程池的状态有哪些?

ThreadPoolExecutor 有 5 种状态:

| 状态 | 说明 | |------|------| | RUNNING | 运行状态,接受新任务,处理队列中的任务 | | SHUTDOWN | 关闭状态,不接受新任务,但处理队列中的任务 | | STOP | 停止状态,不接受新任务,不处理队列任务,中断正在执行的任务 | | TIDYING | 整理状态,所有任务都终止了,工作线程数为 0 | | TERMINATED | 终止状态,terminated() 方法执行完成 |

状态转换:

RUNNING → SHUTDOWN → TIDYING → TERMINATED
RUNNING → STOP → TIDYING → TERMINATED

shutdown():平滑关闭,不接受新任务,但队列中的任务会执行完 shutdownNow():立即关闭,不接受新任务,队列中的任务也不执行了,尝试中断正在执行的任务

追问5:如何合理设置线程池参数?

线程池参数设置需要根据任务类型来定:

1. CPU 密集型任务

  • 特点:大量计算,CPU 使用率高

  • 核心线程数:CPU 核心数 + 1

    • +1 是为了防止某个线程因为偶然的页错误或其他原因暂停,保证 CPU 利用率

  • 队列可以小一些

  • 最大线程数和核心线程数差不多

2. IO 密集型任务

  • 特点:大量 IO 操作(数据库、网络、文件),CPU 使用率低,线程大部分时间在等

  • 核心线程数:CPU 核心数 × 2

    • 或者更精确的公式:核心数 × (1 + 等待时间/计算时间)

  • 因为线程大部分时间在等,所以可以多开一些线程

  • 队列可以大一些

3. 混合型任务

  • 可以拆分成 CPU 密集型和 IO 密集型,分别用不同的线程池

  • 或者根据实际情况调整

其他考虑因素:

  • 任务优先级:用 PriorityBlockingQueue

  • 任务执行时间:长时间任务和短时间任务分开

  • 任务依赖:有依赖的任务要注意死锁问题

  • 内存限制:队列不能太大,避免 OOM

没有固定的标准答案,需要根据实际业务场景和压测结果来调整。


15. CompletableFuture 常见用法

标准答案

CompletableFuture 是 JDK8 引入的异步编程工具,实现了 Future 和 CompletionStage 接口,支持函数式编程,可以链式调用,非常灵活。

【和 Future 的区别】

| 维度 | Future | CompletableFuture | |------|--------|-------------------| | 结果获取 | 阻塞等待(get()) | 支持回调,非阻塞 | | 链式调用 | 不支持 | 支持,链式组合多个异步任务 | | 异常处理 | 需手动 try-catch | 支持异常处理链 | | 组合能力 | 弱 | 强,支持 thenCompose、thenCombine 等 | | 手动完成 | 不支持 | 支持 complete 手动设置结果 |

Future 的问题:

  • get() 是阻塞的,调用后线程就卡住了

  • 不能链式调用,多个 Future 组合很麻烦

  • 异常处理不方便

CompletableFuture 解决了这些问题,支持真正的异步编程。

【创建 CompletableFuture】

1. 直接创建

CompletableFuture<String> future = new CompletableFuture<>();
// 手动设置结果
future.complete("result");
// 手动设置异常
future.completeExceptionally(new RuntimeException("error"));

2. 提交任务(最常用)

// 无返回值,runAsync
CompletableFuture<Void> future1 = CompletableFuture.runAsync(() -> {
    System.out.println("异步任务");
});

// 有返回值,supplyAsync
CompletableFuture<String> future2 = CompletableFuture.supplyAsync(() -> {
    return "异步结果";
});

注意:默认使用 ForkJoinPool.commonPool(),也可以指定自定义线程池:

ExecutorService executor = Executors.newFixedThreadPool(10);
CompletableFuture.supplyAsync(() -> "result", executor);

【链式调用 - 结果处理】

1. thenApply / thenApplyAsync — 转换结果

  • 接收上一步的结果,转换后返回新的结果

  • 有返回值

CompletableFuture<String> future = CompletableFuture.supplyAsync(() -> "Hello")
    .thenApply(s -> s + " World")
    .thenApply(String::toUpperCase);
// 结果:HELLO WORLD

2. thenAccept / thenAcceptAsync — 消费结果

  • 接收上一步的结果,消费掉,没有返回值

  • 返回 CompletableFuture<Void>

CompletableFuture.supplyAsync(() -> "Hello")
    .thenAccept(s -> System.out.println("结果:" + s));

3. thenRun / thenRunAsync — 执行任务

  • 不接收参数,也不返回值,上一步完成后执行一个任务

CompletableFuture.supplyAsync(() -> "Hello")
    .thenRun(() -> System.out.println("任务完成了"));

4. thenCompose / thenComposeAsync — 扁平化组合

  • 接收上一步的结果,返回一个新的 CompletableFuture

  • 用来连接两个 CompletableFuture,避免嵌套

// 不用 thenCompose 会嵌套
CompletableFuture<CompletableFuture<String>> nested = 
    CompletableFuture.supplyAsync(() -> "Hello")
        .thenApply(s -> CompletableFuture.supplyAsync(() -> s + " World"));

// 用 thenCompose 扁平化
CompletableFuture<String> flat = 
    CompletableFuture.supplyAsync(() -> "Hello")
        .thenCompose(s -> CompletableFuture.supplyAsync(() -> s + " World"));

【组合多个 CompletableFuture】

1. thenCombine — 两个任务都完成,组合结果

CompletableFuture<String> future1 = CompletableFuture.supplyAsync(() -> "Hello");
CompletableFuture<String> future2 = CompletableFuture.supplyAsync(() -> "World");

CompletableFuture<String> combined = future1.thenCombine(future2, (s1, s2) -> s1 + " " + s2);
// 结果:Hello World

2. allOf — 所有任务都完成

  • 所有 CompletableFuture 都完成后才完成

  • 返回 CompletableFuture<Void>,没有返回值

CompletableFuture<String> f1 = CompletableFuture.supplyAsync(() -> "result1");
CompletableFuture<String> f2 = CompletableFuture.supplyAsync(() -> "result2");
CompletableFuture<String> f3 = CompletableFuture.supplyAsync(() -> "result3");

CompletableFuture<Void> all = CompletableFuture.allOf(f1, f2, f3);

3. anyOf — 任意一个任务完成

  • 只要有一个 CompletableFuture 完成就完成

  • 返回最先完成的结果

CompletableFuture<Object> any = CompletableFuture.anyOf(f1, f2, f3);

【异常处理】

1. exceptionally — 异常时返回默认值

CompletableFuture<String> future = CompletableFuture.supplyAsync(() -> {
    throw new RuntimeException("出错了");
}).exceptionally(ex -> {
    System.out.println("异常:" + ex.getMessage());
    return "默认值";
});

2. handle — 不管正常还是异常都处理

  • 有两个参数:结果和异常

  • 可以同时处理正常和异常情况

CompletableFuture<String> future = CompletableFuture.supplyAsync(() -> {
    // 可能抛异常
    return "result";
}).handle((result, ex) -> {
    if (ex != null) {
        return "异常了,返回默认值";
    }
    return result;
});

3. whenComplete — 完成时的回调

  • 不改变结果,只是在完成时执行一些操作

  • 类似 finally

CompletableFuture.supplyAsync(() -> "result")
    .whenComplete((result, ex) -> {
        if (ex != null) {
            System.out.println("异常:" + ex.getMessage());
        } else {
            System.out.println("结果:" + result);
        }
    });

【获取结果】

CompletableFuture<String> future = CompletableFuture.supplyAsync(() -> "result");

// 1. 阻塞获取,抛受检异常
String result1 = future.get();

// 2. 阻塞获取,带超时
String result2 = future.get(5, TimeUnit.SECONDS);

// 3. 不阻塞,没完成就返回默认值
String result3 = future.getNow("default");

// 4. 阻塞,不抛受检异常
String result4 = future.join();

高频追问

追问1:thenApply 和 thenCompose 的区别?

| 方法 | 函数返回值 | 结果类型 | 用途 | |------|-----------|----------|------| | thenApply | 普通值 U | CompletableFuture<U> | 同步转换结果 | | thenCompose | CompletableFuture<U> | CompletableFuture<U> | 异步链式调用,扁平化 |

简单说:

  • thenApply:函数返回普通值,用来做同步转换

  • thenCompose:函数返回 CompletableFuture,用来连接两个异步操作,避免嵌套

类比:

  • thenApply 像 Stream 的 map

  • thenCompose 像 Stream 的 flatMap

追问2:thenApply 和 thenApplyAsync 的区别?

  • thenApply:使用上一个任务的线程来执行(可能是调用线程,也可能是异步线程)

  • thenApplyAsync:使用新的线程来执行(默认 ForkJoinPool,也可以指定线程池)

什么时候用 Async 版本?

  • 如果任务比较耗时,不想占用当前线程

  • 想指定特定的线程池来执行

  • 想明确控制线程的使用

一般情况下,如果只是简单的转换,用 thenApply 就行,减少线程切换。

追问3:CompletableFuture 默认用的什么线程池?

默认使用 ForkJoinPool.commonPool()。

特点:

  • 这是一个共享的线程池,整个 JVM 共用

  • 默认线程数是 CPU 核心数 - 1

  • 所有 CompletableFuture、并行流(parallelStream)都共用这个池

⚠️ 注意:

  • 如果你的任务是 IO 密集型的,用默认线程池可能不够用

  • 建议:生产环境中最好用自定义线程池,避免和其他任务竞争

    ExecutorService executor = new ThreadPoolExecutor(...);
    CompletableFuture.supplyAsync(() -> "result", executor);
    

追问4:CompletableFuture 的 complete 方法有什么用?

complete 方法可以手动设置 CompletableFuture 的结果,让它立即完成。

使用场景:

  1. 异步转同步:把回调式的 API 转成 CompletableFuture

    // 比如一个回调式的接口
    CompletableFuture<String> future = new CompletableFuture<>();
    asyncCall(new Callback() {
        void onSuccess(String result) {
            future.complete(result);
        }
        void onError(Exception e) {
            future.completeExceptionally(e);
        }
    });
    
  2. 超时处理:超时后设置默认结果

  3. 测试:单元测试中模拟异步结果

注意:complete 只能调用一次,第二次调用会被忽略(返回 false)。

追问5:CompletableFuture 和 RxJava 有什么区别?

| 维度 | CompletableFuture | RxJava | |------|-------------------|--------| | 数据量 | 单个结果 | 多个结果(流) | | 背压 | 不支持 | 支持 | | 操作符 | 较少 | 非常丰富 | | 学习成本 | 低,简单 | 高,概念多 | | 适用场景 | 单个异步任务、简单组合 | 复杂流处理、事件驱动 |

简单说:

  • 单个异步任务、简单的组合 → CompletableFuture,简单够用

  • 复杂的数据流、事件流、需要背压 → RxJava,功能强大但复杂


16. ThreadLocal 原理及内存泄漏

标准答案

ThreadLocal 是 Java 中一种线程本地变量,每个线程都有自己的一份变量副本,线程之间互不干扰。

【ThreadLocal 的作用】

  1. 线程隔离:每个线程有自己的变量副本,互不影响

  2. 上下文传递:在同一个线程的不同方法间传递数据,不用层层传参

  3. 线程安全:避免了多线程竞争,因为每个线程用自己的

常见使用场景:

  • 保存用户登录信息(Session)

  • 数据库连接(每个线程一个连接)

  • 事务管理(Spring 的 TransactionSynchronizationManager)

  • 日期格式化(SimpleDateFormat 线程不安全,用 ThreadLocal 每个线程一个)

  • MDC 日志追踪(traceId 传递)

【ThreadLocal 的原理】

核心结构:

  • 每个 Thread 对象都有一个 threadLocals 成员变量,类型是 ThreadLocal.ThreadLocalMap

  • ThreadLocalMap 是一个自定义的 Map,key 是 ThreadLocal 对象,value 是线程本地变量的值

Thread
  └── threadLocals (ThreadLocalMap)
        ├── Entry[0]: key=ThreadLocal1, value=value1
        ├── Entry[1]: key=ThreadLocal2, value=value2
        └── ...

set 方法流程:

  1. 获取当前线程

  2. 获取当前线程的 ThreadLocalMap

  3. 以 ThreadLocal 对象为 key,将值存入 Map

  4. 如果 Map 不存在,创建一个

get 方法流程:

  1. 获取当前线程

  2. 获取当前线程的 ThreadLocalMap

  3. 以 ThreadLocal 对象为 key,取出对应的值

  4. 如果 Map 不存在,初始化并返回初始值

remove 方法流程:

  1. 获取当前线程的 ThreadLocalMap

  2. 移除对应的 Entry

【ThreadLocalMap 的结构】

ThreadLocalMap 是 ThreadLocal 的内部类,是一个自定义的哈希表:

Entry 节点:

static class Entry extends WeakReference<ThreadLocal<?>> {
    Object value;
    Entry(ThreadLocal<?> k, Object v) {
        super(k);  // key 是弱引用
        value = v; // value 是强引用
    }
}

关键点:

  • key(ThreadLocal)是弱引用(WeakReference)

  • value 是强引用

为什么 key 用弱引用?

  • 如果 ThreadLocal 对象没有外部强引用了,GC 时就会被回收

  • 这样 ThreadLocalMap 中对应的 key 就变成了 null

  • 避免 ThreadLocal 对象无法被回收

哈希冲突的解决:

  • ThreadLocalMap 不用链表法,用的是线性探测法

  • 冲突了就往后找下一个空位置


内存泄漏问题

【什么是内存泄漏?】

ThreadLocal 使用不当可能导致内存泄漏:不再使用的 value 无法被回收,持续占用内存。

【为什么会内存泄漏?】

原因分析:

  1. ThreadLocalMap 的 key 是弱引用 → ThreadLocal 没有强引用时会被 GC 回收 → key 变成 null

  2. 但是 value 是强引用 → key 变成 null 后,value 还在,无法通过 key 访问到

  3. 如果线程一直存活(比如线程池中的线程),这个 value 就一直被引用,无法回收

  4. 这样就产生了内存泄漏:key 没了,value 还占着内存

Thread(强引用)→ ThreadLocalMap(强引用)→ Entry(强引用)→ value(强引用)
                                          ↓
                                    key(弱引用)→ ThreadLocal(可能被GC回收)

【如何避免内存泄漏?】

1. 手动调用 remove() 方法(最重要)

  • 使用完 ThreadLocal 后,手动调用 remove() 清除

  • 这是最可靠的方式

try {
    threadLocal.set(value);
    // 使用
} finally {
    threadLocal.remove(); // 用完一定要 remove
}

2. ThreadLocalMap 自身的清理机制

  • ThreadLocalMap 在 set、get、remove 等操作时,会顺便清理一些 key 为 null 的 Entry

  • 但这是"启发式"的清理,不是每次都清理,不能完全依赖

3. 使用线程池时要特别注意

  • 线程池中的线程会被复用,不会销毁

  • 如果不 remove,下一个任务可能拿到上一个任务的值

  • 而且 value 会一直存在,导致内存泄漏

【为什么 value 不用弱引用?**

因为 value 是我们存进去的数据,如果 value 也是弱引用,那 GC 时 value 可能就被回收了,我们再 get 的时候就拿不到了。

value 的生命周期应该由我们控制,所以必须是强引用。用完了手动 remove 就好。


高频追问

追问1:ThreadLocal 和 synchronized 的区别?

| 维度 | ThreadLocal | synchronized | |------|-------------|--------------| | 思路 | 空间换时间,每个线程一份副本 | 时间换空间,共用一份,加锁同步 | | 线程安全 | 隔离,不共享,所以安全 | 同步,串行访问,所以安全 | | 性能 | 高,没有竞争 | 低,有锁竞争和上下文切换 | | 适用场景 | 每个线程需要独立的副本 | 多个线程需要共享数据 | | 共享性 | 不共享 | 共享 |

简单说:

  • 数据不共享 → ThreadLocal(每个线程自己用自己的)

  • 数据要共享 → synchronized(大家排队用)

追问2:ThreadLocal 为什么用弱引用?不用强引用?

如果 key 用强引用:

  • 即使 ThreadLocal 对象没有外部引用了,因为 ThreadLocalMap 还强引用着它

  • ThreadLocal 对象就无法被 GC 回收

  • 这样 ThreadLocal 对象本身也会内存泄漏

用弱引用的好处:

  • ThreadLocal 没有外部强引用时,GC 会回收它

  • key 变成 null,至少 ThreadLocal 对象本身不会泄漏

  • 虽然 value 还可能泄漏,但比 key 和 value 都泄漏要好

这是一种权衡:用弱引用至少保证了 ThreadLocal 对象能被回收,value 的泄漏需要我们手动 remove 来解决。

追问3:ThreadLocal 用完为什么要 remove?不 remove 会怎样?

用完 remove 的原因:

  1. 防止内存泄漏:线程池场景下线程复用,value 一直存在

  2. 防止数据混乱:线程复用时,下一个任务可能读到上一个任务的值

  3. 防止业务逻辑错误:比如用户信息串了

不 remove 的后果:

  • 内存泄漏:value 一直占着内存,积累多了 OOM

  • 数据串了:线程池复用线程,下一个任务拿到上一个的值,导致业务错误

所以最佳实践是:try-finally 中用完就 remove。

追问4:InheritableThreadLocal 了解吗?

InheritableThreadLocal 是 ThreadLocal 的子类,支持子线程继承父线程的 ThreadLocal 值。

普通 ThreadLocal:

  • 子线程不能获取父线程的 ThreadLocal 值

  • 因为每个线程有自己的 ThreadLocalMap

InheritableThreadLocal:

  • 创建子线程时,会把父线程的 inheritableThreadLocals 复制一份给子线程

  • 这样子线程就能获取到父线程设置的值

使用场景:

  • 父子线程间传递数据

  • 比如 traceId 传递,子线程也能拿到父线程的 traceId

注意:

  • 是创建子线程时复制的,之后父线程再修改,子线程不会更新

  • 线程池场景下要小心,因为线程是复用的,不是每次都创建新线程

追问5:ThreadLocalMap 为什么用线性探测法解决哈希冲突?不用链表?

ThreadLocalMap 使用线性探测法(开放寻址法)而不是链表法,原因:

  1. 数据量小:

    • 每个线程的 ThreadLocal 数量一般不多

    • 冲突概率低,线性探测效率高

  2. 内存连续:

    • 数组存储,内存连续,CPU 缓存命中率高

    • 访问速度快

  3. Entry 是弱引用,需要清理:

    • ThreadLocalMap 需要清理 key 为 null 的 Entry

    • 数组结构清理起来更方便

    • 清理后可以 rehash 调整位置

  4. 减少对象创建:

    • 链表法需要创建链表节点对象

    • 数组直接存储 Entry,对象更少

对于 ThreadLocal 这种数据量不大、但访问频繁的场景,线性探测法更合适。


17. ConcurrentHashMap 为什么线程安全

标准答案

ConcurrentHashMap 是 Java 中线程安全的 HashMap 实现,支持高并发的读写操作。

【演进历程】

JDK7:分段锁(Segment)

  • 内部由 Segment 数组组成,每个 Segment 是一个独立的哈希表

  • 每个 Segment 有自己的锁(继承 ReentrantLock)

  • 不同 Segment 之间可以并发读写

  • 并发度 = Segment 数量(默认 16)

  • 缺点:分段数量固定,不够灵活;同一 Segment 内还是串行

JDK8:CAS + synchronized + 红黑树

  • 放弃了分段锁,改用 Node 数组 + 链表/红黑树

  • 读操作完全无锁(volatile + CAS)

  • 写操作:

    • 数组元素为空时 → CAS 插入

    • 数组元素不为空时 → synchronized 锁桶头节点

  • 锁粒度更细:锁的是每个桶(链表/红黑树的头节点)

  • 并发度更高,性能更好

下面主要讲 JDK8 的实现。

【JDK8 ConcurrentHashMap 的线程安全实现】

1. 数组初始化的线程安全

  • 使用 sizeCtl 变量控制初始化和扩容

  • 初始化时用 CAS 保证只有一个线程能初始化数组

  • 其他线程等待

2. put 操作的线程安全

计算哈希值 → 定位到桶
   │
   ├─ 桶为空 → CAS 插入新节点(无锁)
   │
   ├─ 桶不为空 → synchronized 锁桶头节点
   │     │
   │     ├─ 链表 → 遍历链表,找到相同 key 则更新,否则尾插
   │     │
   │     └─ 红黑树 → 红黑树插入逻辑
   │
   └─ 如果正在扩容 → 帮助扩容

关键点:

  • CAS 插入:桶为空时,用 CAS 插入,不需要锁

  • synchronized 锁桶头:桶不为空时,只锁这个桶的头节点,其他桶不受影响

  • 锁粒度细:锁的是每个桶,而不是整个 Map

  • 帮助扩容:如果发现正在扩容,当前线程也参与扩容,加快扩容速度

3. get 操作的线程安全

  • get 操作完全无锁

  • 因为 Node 的 val 和 next 用 volatile 修饰,保证可见性

  • 数组用 volatile 修饰(实际是 Unsafe 操作),扩容时读的是新数组

  • 读操作性能很高

4. 扩容的线程安全

  • 多线程并发扩容

  • 每个线程负责一段区间的桶迁移

  • 用 transferIndex 控制迁移进度,CAS 分配任务

  • 迁移时对每个桶加锁,保证迁移过程中数据一致

  • 扩容期间,新的 put 操作会帮助扩容

5. size 计算的线程安全

  • 用 baseCount + CounterCell[] 数组来计数

  • 竞争少的时候直接 CAS 更新 baseCount

  • 竞争激烈时,每个线程在自己的 CounterCell 里计数

  • 最后统计时把所有 CounterCell 加起来 + baseCount

  • 这是一种空间换时间的思路,避免计数时的竞争

【和 HashMap 的区别】

| 维度 | HashMap | ConcurrentHashMap | |------|---------|-------------------| | 线程安全 | 不安全 | 安全 | | 锁 | 无 | CAS + synchronized | | 性能 | 单线程下高 | 并发下高 | | null key/value | 支持 | 不支持 | | 迭代器 | fail-fast | weakly consistent(弱一致) |

【和 Hashtable 的区别】

| 维度 | Hashtable | ConcurrentHashMap | |------|-----------|-------------------| | 锁粒度 | 全表锁(synchronized 方法) | 桶级锁 | | 并发度 | 1 | 数组大小 | | 性能 | 差,所有操作串行 | 好,读无锁,写锁粒度细 | | 扩容 | 单线程扩容 | 多线程并发扩容 | | 状态 | 过时,不推荐 | 推荐使用 |


高频追问

追问1:JDK7 和 JDK8 的 ConcurrentHashMap 有什么区别?

| 维度 | JDK7 | JDK8 | |------|------|------| | 结构 | Segment 分段锁 | Node 数组 + 链表/红黑树 | | 锁机制 | ReentrantLock 锁 Segment | CAS + synchronized 锁桶头 | | 锁粒度 | Segment 级(默认 16 段) | 桶级(每个桶一个锁) | | 并发度 | Segment 数量 | 数组长度 | | 链表结构 | 链表 | 链表 + 红黑树(链表长度>8转红黑树) | | 扩容 | 单线程扩容 | 多线程并发扩容 | | 性能 | 分段内串行 | 更细粒度,性能更好 | | 计数方式 | segment 内计数,遍历求和 | baseCount + CounterCell |

JDK8 的改进主要是:锁粒度更细、并发度更高、性能更好。

追问2:ConcurrentHashMap 的 get 操作为什么不用加锁?

get 操作完全无锁,原因:

  1. volatile 保证可见性:

    • Node 的 val 字段用 volatile 修饰

    • Node 的 next 字段用 volatile 修饰

    • table 数组的元素通过 Unsafe 的 volatile 方式读取

    • 保证一个线程修改后,其他线程能立即看到

  2. 最终一致性:

    • get 操作不需要强一致性

    • 允许短暂的不一致(比如正在扩容时,可能读到旧数据)

    • 这是一种权衡:用弱一致性换取更高的读性能

  3. 扩容时的处理:

    • 扩容时如果读的是旧桶,可能读到旧数据

    • 但最终会读到新数据

    • 这就是弱一致性(weakly consistent)

因为读操作远多于写操作,get 无锁大大提升了并发读的性能。

追问3:ConcurrentHashMap 为什么不支持 null key 和 null value?

ConcurrentHashMap 不允许 key 和 value 为 null,HashMap 允许。

原因:

  1. 二义性问题:

    • 如果 get(key) 返回 null,你不知道是 key 不存在,还是 value 本身就是 null

    • 在单线程的 HashMap 中,可以用 containsKey 来判断

    • 但在并发环境下,containsKey 和 get 之间可能有其他线程修改了,结果不可靠

    • 为了避免这种歧义,ConcurrentHashMap 直接禁止 null value

  2. 作者的设计哲学:

    • Doug Lea 认为 null 是导致歧义的根源

    • 并发容器中应该避免这种歧义

    • 所以 ConcurrentHashMap、ConcurrentSkipListMap 等都不支持 null

追问4:ConcurrentHashMap 的扩容过程是怎样的?

JDK8 的 ConcurrentHashMap 支持多线程并发扩容:

  1. 触发条件:元素数量达到阈值(容量 × 负载因子)

  2. 新容量:旧容量 × 2

  3. 扩容过程:

    • 第一个线程发起扩容,初始化 nextTable

    • 用 transferIndex 标记迁移进度,从后往前迁移

    • 其他线程 put 时发现正在扩容,就参与进来帮忙

    • 每个线程领取一段区间的桶(默认 16 个桶)

    • 迁移一个桶时,先 synchronized 锁桶头节点

    • 把链表拆成两份(高位链和低位链),分别放到新数组的两个位置

    • 迁移完的桶设置为 ForwardingNode,表示已经迁移过了

    • 所有桶迁移完后,把 table 指向新数组

  4. 帮助扩容:

    • put 时发现正在扩容(桶是 ForwardingNode),就调用 helpTransfer 帮忙

    • 这样可以利用多个线程的算力,加快扩容速度

追问5:ConcurrentHashMap 的 size() 怎么实现的?为什么不直接遍历计数?

ConcurrentHashMap 的 size() 不是精确值,是一个估计值。

实现方式:

  1. 用 baseCount + CounterCell[] 数组计数

  2. 元素增加/减少时,尝试用 CAS 更新 baseCount

  3. 如果 CAS 失败(竞争激烈),就用 CounterCell 数组

    • 每个线程对应一个 CounterCell

    • 只更新自己的 CounterCell,没有竞争

  4. size() 计算时,把 baseCount 和所有 CounterCell 的值加起来

为什么不直接遍历?

  • 遍历整个数组计数太慢了

  • 而且并发环境下,遍历过程中数据一直在变,结果也不准确

  • 用 CounterCell 数组是空间换时间,计数操作 O(1)

这和 LongAdder 的思想是一样的,都是把一个值拆成多个,减少竞争。


18. CountDownLatch、CyclicBarrier、Semaphore 区别

标准答案

这三个都是 JUC 包下的同步工具类,用于线程间的协调,但用途不同。

【CountDownLatch — 倒计时门闩】

作用:一个线程等待多个线程完成任务后,自己再继续执行。

原理:

  • 内部维护一个计数器,初始值为需要等待的线程数

  • 每个线程完成任务后调用 countDown(),计数器减 1

  • 等待线程调用 await(),当计数器减到 0 时,await 返回,继续执行

特点:

  • 一次性使用,计数器减到 0 后不能重置

  • 一个线程等多个线程(一对多)

  • 不可重用

使用场景:

  • 主线程等待多个子线程完成任务

  • 模拟并发,多个线程同时开始(设为1,所有线程await,然后countDown)

CountDownLatch latch = new CountDownLatch(3);

// 3个工作线程
for (int i = 0; i < 3; i++) {
    new Thread(() -> {
        // 做任务
        latch.countDown(); // 完成后计数减1
    }).start();
}

latch.await(); // 主线程等待,直到计数为0
System.out.println("所有任务都完成了");

【CyclicBarrier — 循环栅栏】

作用:一组线程互相等待,直到所有线程都到达屏障点,然后一起继续执行。

原理:

  • 内部维护一个计数器,初始值为参与的线程数

  • 每个线程到达屏障点时调用 await(),计数器减 1,然后等待

  • 当计数器减到 0 时,所有等待的线程同时被唤醒,继续执行

  • 计数器自动重置,可以重复使用(循环的意思)

特点:

  • 可重复使用,计数器到 0 后自动重置

  • 多个线程互相等待(多对多)

  • 可重用

  • 可以设置屏障动作(所有线程到达后执行一个任务)

使用场景:

  • 多线程计算数据,最后合并结果

  • 模拟并发,多个线程同时开始

  • 阶段性的任务,每个阶段都要等所有线程完成

CyclicBarrier barrier = new CyclicBarrier(3, () -> {
    System.out.println("所有线程都到了,开始下一阶段");
});

for (int i = 0; i < 3; i++) {
    new Thread(() -> {
        // 第一阶段任务
        barrier.await(); // 等待其他线程
        // 第二阶段任务
        barrier.await(); // 可以重复使用
        // 第三阶段任务
    }).start();
}

【Semaphore — 信号量】

作用:控制同时访问某个资源的线程数量,实现限流。

原理:

  • 内部维护一个许可(permit)数量

  • 线程访问资源前调用 acquire() 获取许可,许可数减 1

  • 用完后调用 release() 释放许可,许可数加 1

  • 如果许可用完了,acquire 就阻塞等待

特点:

  • 控制并发数量

  • 可以公平/非公平

  • 可以一次性获取/释放多个许可

  • 可重用

使用场景:

  • 限流:控制同时访问某个资源的线程数

  • 池化资源:连接池、对象池等

Semaphore semaphore = new Semaphore(10); // 最多10个线程同时访问

// 每个线程访问前获取许可
semaphore.acquire();
try {
    // 访问资源
} finally {
    semaphore.release(); // 释放许可
}

【三者对比】

| 维度 | CountDownLatch | CyclicBarrier | Semaphore | |------|---------------|---------------|-----------| | 作用 | 一个线程等多个线程完成 | 多个线程互相等待 | 控制并发线程数 | | 计数器 | 一次性,减到0就不能用了 | 可循环,自动重置 | 可增减,重复使用 | | 等待关系 | 1个等N个 | N个互相等 | N个等M个许可 | | 释放方式 | countDown 减计数 | await 减计数并等待 | acquire/release | | 可重用 | 不可 | 可 | 可 | | 典型场景 | 主线程等子线程 | 多线程阶段同步 | 限流、资源池 |


高频追问

追问1:CountDownLatch 和 CyclicBarrier 的区别?

这是最常问的对比题:

| 维度 | CountDownLatch | CyclicBarrier | |------|---------------|---------------| | 等待关系 | 1个线程等N个线程 | N个线程互相等 | | 计数器 | 一次性,不能重置 | 可循环,自动重置 | | 减计数方式 | countDown(),调用线程不阻塞 | await(),调用线程阻塞等待 | | 谁来减 | 工作线程减,主线程等 | 所有线程都减,都等 | | 屏障动作 | 没有 | 可以设置 Runnable,所有线程到齐后执行 | | 类比 | 发令枪:所有人准备好,裁判一声令下开始 | 团建:等人到齐了一起出发 |

简单记:

  • CountDownLatch:倒计时,一个人等所有人

  • CyclicBarrier:栅栏,大家一起等,到齐了一起走

追问2:CountDownLatch 和 join 的区别?

| 维度 | CountDownLatch | Thread.join() | |------|---------------|---------------| | 实现 | AQS 实现 | 基于 Object 的 wait/notify | | 灵活性 | 灵活,可以在任意位置 countDown | 必须等线程执行完 | | 控制粒度 | 可以控制到某个步骤就 countDown | 只能等整个线程结束 | | 线程池 | 适合线程池(线程不会退出) | 不适合线程池(线程复用不会结束) | | 用法 | 手动调用 countDown | 调用 thread.join() |

用线程池的时候,线程是复用的,不会退出,所以不能用 join,这时候 CountDownLatch 更合适。

追问3:Semaphore 的公平和非公平是什么意思?

Semaphore 有公平和非公平两种模式:

  • 非公平模式(默认):新来的线程可以插队,不一定按先来后到的顺序获取许可

    • 性能更好,减少线程切换

    • 可能产生饥饿

  • 公平模式:按线程请求的顺序获取许可,先到先得

    • 公平,不会饥饿

    • 性能稍差,需要维护队列

和 ReentrantLock 的公平/非公平是一样的道理。

追问4:Semaphore 能实现互斥锁吗?和 ReentrantLock 有什么区别?

可以。Semaphore 初始化为 1 个许可,就可以实现互斥锁的效果。

但和 ReentrantLock 还是有区别:

| 维度 | Semaphore(1) | ReentrantLock | |------|-------------|---------------| | 可重入 | 不可重入(同一个线程 acquire 两次会死锁) | 可重入 | | 公平性 | 支持公平/非公平 | 支持公平/非公平 | | 可中断 | 支持 | 支持 | | 条件变量 | 不支持 | 支持 Condition | | 用途 | 主要用于限流 | 主要用于互斥同步 |

Semaphore 不是专门的锁,它的主要用途是控制并发数量。如果只是需要互斥锁,还是用 ReentrantLock 更合适。

追问5:什么是 Phaser?

Phaser(阶段器)是 JDK7 引入的更灵活的同步工具,可以看作是 CountDownLatch 和 CyclicBarrier 的结合体。

特点:

  1. 可动态调整参与者数量:CountDownLatch 和 CyclicBarrier 的数量是固定的,Phaser 可以随时注册/注销

  2. 可重复使用,支持多阶段:类似 CyclicBarrier,但更灵活

  3. 支持分层:可以有父子 Phaser,减少竞争

适用场景:

  • 参与者数量不固定的多阶段任务

  • 比 CyclicBarrier 更灵活,但也更复杂

一般面试中 CountDownLatch、CyclicBarrier、Semaphore 问得更多,Phaser 了解一下就行。

📌 面试技巧:回答并发相关问题时,一定要结合实际场景来说,不要只说概念。比如讲 CountDownLatch 就说"主线程等多个子线程加载资源",讲 Semaphore 就说"接口限流"。这样面试官会觉得你真的用过,而不是背题。


第三章 Spring(10题)


19. Spring IOC 原理

标准答案

IOC(Inversion of Control,控制反转)是 Spring 的核心思想之一,指的是对象的创建和依赖关系的管理交给 Spring 容器来负责,而不是由开发者手动 new 对象。

【什么是 IOC】

控制反转:

  • 传统方式:自己 new 对象,自己管理依赖(主动控制)

  • IOC 方式:对象由 Spring 容器创建和管理,需要的时候从容器拿(被动接收)

DI(Dependency Injection,依赖注入):

  • DI 是 IOC 的实现方式

  • 指对象的依赖关系由容器注入,不需要自己创建依赖对象

  • IOC 是思想,DI 是实现

IOC 的好处:

  1. 解耦:对象之间的依赖关系由容器管理,降低耦合度

  2. 便于测试:可以方便地替换实现,注入 Mock 对象

  3. 提高可维护性:修改依赖关系不需要改代码,改配置就行

  4. 对象生命周期管理:由容器统一管理

【IOC 容器的核心组件】

1. BeanFactory

  • Spring 最底层的接口,是 IOC 容器的基本实现

  • 提供了最基本的 bean 管理功能(getBean、containsBean 等)

  • 特点:懒加载,第一次 getBean 的时候才初始化 bean

  • 一般不直接使用,是 Spring 内部使用的

2. ApplicationContext

  • BeanFactory 的子接口,功能更丰富

  • 提供了更多企业级功能:

    • 国际化(i18n)

    • 资源加载(Resource)

    • 事件发布/监听(ApplicationEvent)

    • 环境变量(Environment)

  • 特点:默认启动时就初始化所有单例 bean(非懒加载)

  • 是我们平时用得最多的容器

常见的 ApplicationContext 实现:

  • ClassPathXmlApplicationContext:从 classpath 加载 XML 配置

  • FileSystemXmlApplicationContext:从文件系统加载 XML 配置

  • AnnotationConfigApplicationContext:基于注解配置(最常用)

  • AnnotationConfigWebApplicationContext:Web 环境下的注解配置

【Bean 的定义:BeanDefinition】

Spring 把每个 bean 的定义信息封装成一个 BeanDefinition 对象,包含:

  • bean 的类名(className)

  • 作用域(scope:singleton/prototype 等)

  • 构造函数参数(constructor arguments)

  • 属性值(property values)

  • 自动装配模式(autowire mode)

  • 懒加载(lazy-init)

  • 初始化方法(init-method)

  • 销毁方法(destroy-method)

  • 等等...

BeanDefinition 就是 bean 的"配方",Spring 根据这个配方来创建和管理 bean。

【IOC 容器的初始化流程】

以 AnnotationConfigApplicationContext 为例:

1. 容器启动

  • 创建 ApplicationContext 对象

  • 加载配置类(@Configuration 标注的类)

2. BeanDefinition 的加载(BeanDefinitionReader)

  • 扫描指定包下的类(@ComponentScan)

  • 识别 @Component、@Service、@Controller、@Repository、@Configuration 等注解

  • 把每个类解析成 BeanDefinition 对象

  • 注册到 BeanDefinitionRegistry 中(一个 Map)

3. BeanFactoryPostProcessor 处理

  • 容器初始化后,bean 实例化前

  • 可以修改 BeanDefinition 的属性

  • 典型应用:PropertySourcesPlaceholderConfigurer(解析 ${} 占位符)

4. 实例化 bean

  • 遍历所有 BeanDefinition

  • 根据 BeanDefinition 创建 bean 实例

  • 单例 bean 默认在容器启动时就创建(非懒加载)

  • prototype 类型每次 getBean 时创建

5. 依赖注入

  • 给 bean 的属性赋值

  • 解析 @Autowired、@Resource 等注解

  • 注入依赖的其他 bean

6. BeanPostProcessor 处理

  • bean 初始化前后的后置处理器

  • 可以对 bean 进行增强、包装

  • 典型应用:

    • @PostConstruct 注解的处理

    • AOP 代理的创建(AnnotationAwareAspectJAutoProxyCreator)

7. 初始化 bean

  • 调用 InitializingBean 的 afterPropertiesSet 方法

  • 调用自定义的 init-method

8. 容器就绪

  • 所有单例 bean 都创建好了

  • 可以使用了

【依赖注入的方式】

1. 构造器注入

@Component
public class UserService {
    private final UserDao userDao;
    
    public UserService(UserDao userDao) {
        this.userDao = userDao;
    }
}
  • 优点:依赖不可变、保证不为 null、保证完全初始化

  • 推荐使用,Spring 官方推荐

2. Setter 注入

@Component
public class UserService {
    private UserDao userDao;
    
    @Autowired
    public void setUserDao(UserDao userDao) {
        this.userDao = userDao;
    }
}
  • 优点:可选依赖,可以修改

  • 适合可选的依赖

3. 字段注入(最常用但不推荐)

@Component
public class UserService {
    @Autowired
    private UserDao userDao;
}
  • 优点:简单方便

  • 缺点:依赖不可变、不能在构造时保证不为 null、不利于测试、循环依赖问题

  • 生产代码中不推荐,测试代码中可以用


高频追问

追问1:BeanFactory 和 ApplicationContext 的区别?

| 维度 | BeanFactory | ApplicationContext | |------|-------------|-------------------| | 功能 | 基础的 bean 管理 | 完整的企业级功能 | | 国际化 | 不支持 | 支持 | | 资源加载 | 不支持 | 支持(Resource 接口) | | 事件机制 | 不支持 | 支持(ApplicationEvent) | | 环境变量 | 不支持 | 支持(Environment) | | 初始化时机 | 懒加载,getBean 时才初始化 | 默认启动时初始化所有单例 | | 使用场景 | Spring 内部使用 | 开发者使用 | | 层级 | 顶层接口 | BeanFactory 的子接口 |

简单说:BeanFactory 是低配版,ApplicationContext 是高配版。平时开发都用 ApplicationContext。

追问2:Spring 支持哪些作用域(scope)?

| 作用域 | 说明 | |--------|------| | singleton | 单例,整个容器中只有一个实例(默认) | | prototype | 原型,每次获取都创建新实例 | | request | Web 环境,每个 HTTP 请求一个实例 | | session | Web 环境,每个 Session 一个实例 | | application | Web 环境,整个 ServletContext 一个实例 | | websocket | WebSocket 环境,每个 WebSocket 连接一个实例 |

最常用的是 singleton 和 prototype。Web 相关的作用域需要在 Web 环境下才生效。

追问3:单例 bean 是线程安全的吗?

不一定。 Spring 的单例只是说容器中只有一个实例,但不保证线程安全。

  • 如果 bean 是无状态的(没有成员变量,或者成员变量是只读的),就是线程安全的

  • 如果 bean 是有状态的(有成员变量,且会被修改),就不是线程安全的

举例:

  • Service 层一般是无状态的,只有方法,没有可变成员变量 → 线程安全

  • 如果 Service 里有一个成员变量,多个线程同时修改 → 线程不安全

解决方法:

  1. 尽量用无状态的 bean(推荐)

  2. 用 ThreadLocal 保存状态

  3. 用 synchronized 或 Lock 同步

  4. 把作用域改成 prototype(不推荐,性能差)

追问4:什么是 BeanPostProcessor?有什么用?

BeanPostProcessor 是 bean 的后置处理器,可以在 bean 初始化前后做一些事情。

public interface BeanPostProcessor {
    // 初始化前调用
    Object postProcessBeforeInitialization(Object bean, String beanName) throws BeansException;
    
    // 初始化后调用
    Object postProcessAfterInitialization(Object bean, String beanName) throws BeansException;
}

常见应用:

  1. @PostConstruct 注解处理:CommonAnnotationBeanPostProcessor

  2. AOP 代理创建:AnnotationAwareAspectJAutoProxyCreator,在初始化后创建代理对象

  3. 属性注入:AutowiredAnnotationBeanPostProcessor,处理 @Autowired

  4. 自定义注解处理:自己实现,处理自定义注解

BeanPostProcessor 是 Spring 扩展点之一,很多功能都是通过它实现的。

追问5:什么是 FactoryBean?和 BeanFactory 的区别?

FactoryBean 是一个特殊的 bean,用来创建其他 bean。

public interface FactoryBean<T> {
    T getObject() throws Exception;      // 返回创建的对象
    Class<?> getObjectType();            // 返回对象类型
    boolean isSingleton();               // 是否单例
}

特点:

  • getBean("beanName") 返回的是 FactoryBean 创建的对象(getObject() 的返回值)

  • getBean("&beanName") 返回的是 FactoryBean 本身

使用场景:

  • 创建复杂的 bean,创建过程很复杂

  • 第三方框架集成,比如 MyBatis 的 SqlSessionFactoryBean

和 BeanFactory 的区别:

  • BeanFactory:是 IOC 容器,用来管理所有 bean

  • FactoryBean:是一个特殊的 bean,用来创建其他 bean

名字很像,但完全是两个东西。


20. Spring AOP 原理

标准答案

AOP(Aspect-Oriented Programming,面向切面编程)是对 OOP 的补充,用于处理系统中分散在各个模块的横切关注点(如日志、事务、权限、监控等)。

【什么是 AOP】

OOP 的问题:

  • 日志、事务、权限这些功能,每个模块都需要

  • 如果每个模块都写一遍,代码重复,维护困难

  • 而且和业务逻辑混在一起,耦合度高

AOP 的解决思路:

  • 把这些横切关注点抽出来,做成切面(Aspect)

  • 通过动态代理的方式,在运行时把切面逻辑织入到业务方法中

  • 业务代码只关心业务逻辑,不用管日志、事务这些

AOP 的好处:

  1. 降低耦合:业务逻辑和横切逻辑分离

  2. 代码复用:切面逻辑可以复用

  3. 便于维护:修改切面逻辑只需要改一个地方

【AOP 的核心概念】

| 概念 | 说明 | |------|------| | Aspect(切面) | 横切关注点的模块化,比如日志切面、事务切面 | | Join Point(连接点) | 程序执行过程中的点,比如方法执行、异常抛出 | | Pointcut(切点) | 匹配连接点的表达式,定义哪些方法需要增强 | | Advice(通知) | 切面在特定连接点执行的动作,即增强逻辑 | | Weaving(织入) | 把切面逻辑应用到目标对象上,创建代理对象的过程 | | Target(目标对象) | 被增强的对象,原始对象 | | Proxy(代理) | AOP 框架创建的对象,包含目标对象和增强逻辑 |

Advice 的类型:

  • Before:方法执行前执行

  • After:方法执行后执行(不管正常还是异常)

  • AfterReturning:方法正常返回后执行

  • AfterThrowing:方法抛出异常后执行

  • Around:环绕通知,方法执行前后都执行,最强大,可以决定是否执行原方法

【AOP 的实现原理:动态代理】

Spring AOP 基于动态代理实现,在运行时生成代理对象。

Spring 提供了两种动态代理方式:

1. JDK 动态代理

  • 基于接口实现

  • 目标类必须实现接口

  • 生成的代理类实现目标类的所有接口

  • 核心类:Proxy 和 InvocationHandler

  • 优点:JDK 原生支持,不需要额外依赖

  • 缺点:只能代理接口,不能代理类

2. CGLIB 动态代理

  • 基于继承实现

  • 目标类不需要实现接口

  • 生成的代理类是目标类的子类

  • 核心类:Enhancer 和 MethodInterceptor

  • 优点:可以代理类,不需要接口

  • 缺点:不能代理 final 类和 final 方法

Spring 的选择策略:

  • 如果目标类实现了接口 → 默认用 JDK 动态代理

  • 如果目标类没有实现接口 → 用 CGLIB

  • 可以通过 @EnableAspectJAutoProxy(proxyTargetClass = true) 强制使用 CGLIB

【AOP 的织入时机】

| 织入时机 | 说明 | 实现方式 | |----------|------|----------| | 编译期织入 | 编译时把切面逻辑织入字节码 | AspectJ 编译器 | | 类加载期织入 | 类加载时织入 | AspectJ 加载时织入 | | 运行期织入 | 运行时生成代理对象 | Spring AOP(JDK/CGLIB 动态代理) |

Spring AOP 用的是运行期织入,在运行时生成代理对象。

【Spring AOP 的执行流程】

  1. 容器启动时,扫描所有切面(@Aspect)

  2. 解析切点表达式(@Pointcut)

  3. bean 实例化后,BeanPostProcessor 检查是否需要被代理

  4. 如果需要,根据目标类选择 JDK 或 CGLIB 生成代理对象

  5. 调用方法时,调用的是代理对象的方法

  6. 代理对象先执行切面逻辑(通知链),再执行目标方法


高频追问

追问1:JDK 动态代理和 CGLIB 的区别?

| 维度 | JDK 动态代理 | CGLIB | |------|-------------|-------| | 实现方式 | 基于接口 | 基于继承 | | 目标类要求 | 必须实现接口 | 不需要接口 | | 核心类 | Proxy、InvocationHandler | Enhancer、MethodInterceptor | | 性能 | 启动快,运行稍慢 | 启动慢,运行快 | | 限制 | 只能代理接口方法 | 不能代理 final 类和 final 方法 | | 默认使用 | 目标类有接口时默认 | 目标类无接口时默认 |

Spring Boot 2.x 之后,默认配置是 proxyTargetClass = true,也就是默认用 CGLIB。

追问2:Spring AOP 和 AspectJ 的区别?

| 维度 | Spring AOP | AspectJ | |------|-----------|---------| | 实现方式 | 动态代理(运行期织入) | 字节码操作(编译期/类加载期织入) | | 功能 | 简单,只支持方法级别的拦截 | 强大,支持字段、构造器等各种连接点 | | 性能 | 运行时生成代理,有一定开销 | 编译时织入,运行时无额外开销 | | 复杂度 | 简单,容易上手 | 复杂,需要学习 AspectJ 语法 | | 集成方式 | Spring 原生支持 | 需要 AspectJ 编译器或 agent | | 适用场景 | 简单的切面需求 | 复杂的切面需求 |

Spring AOP 是简化版的 AOP,满足大部分场景需求。如果需要更强大的功能,可以集成 AspectJ。

追问3:同一个方法被多个切面增强,执行顺序是怎样的?

多个切面增强同一个方法时,会形成一个拦截器链,按顺序执行。

默认顺序:

  • 按切面的字母顺序执行(类名字典序)

  • 不太可控

指定顺序的方式:

  1. 实现 Ordered 接口:getOrder() 返回值越小,优先级越高

  2. @Order 注解:value 值越小,优先级越高

  3. @Priority 注解:value 值越小,优先级越高

执行顺序(以两个切面为例):

高优先级切面 @Around 前
  低优先级切面 @Around 前
    目标方法
  低优先级切面 @Around 后
高优先级切面 @Around 后

类似洋葱模型,先进后出。

追问4:Spring 事务是怎么实现的?和 AOP 什么关系?

Spring 事务就是基于 AOP 实现的,事务切面就是一个 AOP 切面。

实现原理:

  1. @EnableTransactionManagement 开启事务管理

  2. 容器中注册一个 BeanPostProcessor(InfrastructureAdvisorAutoProxyCreator)

  3. bean 实例化后,检查是否有 @Transactional 注解

  4. 如果有,创建事务代理对象

  5. 调用方法时,事务拦截器(TransactionInterceptor)拦截方法

  6. 方法执行前:开启事务

  7. 方法正常执行:提交事务

  8. 方法抛出异常:回滚事务(默认 RuntimeException 和 Error 才回滚)

所以事务就是一个特殊的 AOP 切面,专门处理事务逻辑。

追问5:Spring AOP 为什么不能拦截 private 方法?

因为 Spring AOP 基于动态代理:

  1. JDK 动态代理:基于接口,接口里的方法都是 public 的,没有 private 方法

  2. CGLIB 动态代理:基于继承,子类不能重写父类的 private 方法

所以 private 方法不能被代理,也就不能被 AOP 拦截。

同样的,final 方法也不能被 CGLIB 代理(因为不能重写),static 方法也不能被代理。

如果需要拦截 private 方法,就得用 AspectJ(编译期织入)。


21. Bean 生命周期

标准答案

Spring Bean 的生命周期指的是 bean 从创建到销毁的整个过程。

【完整生命周期流程】

1. 实例化 Bean(调用构造函数)
   ↓
2. 属性注入(依赖注入,@Autowired)
   ↓
3. Aware 接口回调
   - BeanNameAware
   - BeanClassLoaderAware
   - BeanFactoryAware
   - ApplicationContextAware 等
   ↓
4. BeanPostProcessor 前置处理(postProcessBeforeInitialization)
   ↓
5. 初始化
   - @PostConstruct 注解
   - InitializingBean 接口的 afterPropertiesSet()
   - 自定义 init-method
   ↓
6. BeanPostProcessor 后置处理(postProcessAfterInitialization)
   ↓
7. Bean 就绪,可以使用了
   ↓
8. 销毁
   - @PreDestroy 注解
   - DisposableBean 接口的 destroy()
   - 自定义 destroy-method

【详细说明】

第一阶段:实例化

  • 调用 bean 的构造函数,创建对象

  • 此时对象已经创建,但属性还没赋值

第二阶段:属性注入

  • 给 bean 的属性赋值,注入依赖

  • 处理 @Autowired、@Resource、@Value 等注解

  • 此时依赖的其他 bean 已经注入了

第三阶段:Aware 接口回调

  • 如果 bean 实现了各种 Aware 接口,就回调对应的方法

  • 让 bean 能获取到容器的一些资源:

    • BeanNameAware:获取 bean 的名字

    • BeanFactoryAware:获取 BeanFactory

    • ApplicationContextAware:获取 ApplicationContext

    • BeanClassLoaderAware:获取类加载器

    • EnvironmentAware:获取环境变量

    • 等等...

第四阶段:BeanPostProcessor 前置处理

  • 调用所有 BeanPostProcessor 的 postProcessBeforeInitialization 方法

  • 在初始化之前对 bean 做一些处理

  • 比如 @PostConstruct 注解就是在这里处理的

第五阶段:初始化 按顺序执行:

  1. @PostConstruct 注解标注的方法

  2. InitializingBean 接口的 afterPropertiesSet() 方法

  3. 自定义的 init-method(XML 或 @Bean(initMethod = ""))

这三个都是初始化方法,只是方式不同。

第六阶段:BeanPostProcessor 后置处理

  • 调用所有 BeanPostProcessor 的 postProcessAfterInitialization 方法

  • 在初始化之后对 bean 做一些处理

  • AOP 代理就是在这里创建的

第七阶段:Bean 就绪

  • bean 完全初始化好了,可以使用了

  • 单例 bean 会一直存在容器中

  • prototype 类型每次获取都创建新的

第八阶段:销毁 容器关闭时,按顺序执行:

  1. @PreDestroy 注解标注的方法

  2. DisposableBean 接口的 destroy() 方法

  3. 自定义的 destroy-method


高频追问

追问1:@PostConstruct、InitializingBean、init-method 的执行顺序?

执行顺序:

  1. @PostConstruct(注解方式,BeanPostProcessor 中处理)

  2. InitializingBean.afterPropertiesSet()(接口方式)

  3. init-method(XML/注解配置方式)

为什么 @PostConstruct 最先执行? 因为 @PostConstruct 是在 BeanPostProcessor 的前置处理中执行的,在 InitializingBean 之前。

推荐使用哪种?

  • 推荐用 @PostConstruct 注解,最灵活,和 Spring 耦合低

  • InitializingBean 和 Spring 耦合度高

  • init-method 是 XML 时代的产物,现在用得少了

追问2:BeanPostProcessor 和 BeanFactoryPostProcessor 的区别?

| 维度 | BeanFactoryPostProcessor | BeanPostProcessor | |------|-------------------------|-------------------| | 作用时机 | bean 实例化之前 | bean 实例化之后,初始化前后 | | 作用对象 | BeanDefinition(bean 的定义) | Bean 实例(已经创建好的对象) | | 能做什么 | 修改 bean 的定义信息 | 修改/包装 bean 实例 | | 典型应用 | PropertyPlaceholderConfigurer(解析占位符) | AOP 代理创建、@PostConstruct 处理 |

简单记:

  • BeanFactoryPostProcessor:改配方(BeanDefinition)

  • BeanPostProcessor:改成品(Bean 实例)

追问3:Spring 怎么解决循环依赖?

循环依赖:A 依赖 B,B 又依赖 A,形成循环。

Spring 解决循环依赖的方式:三级缓存 + 提前暴露对象

三级缓存:

  1. singletonObjects:一级缓存,存放完全初始化好的 bean

  2. earlySingletonObjects:二级缓存,存放提前暴露的 bean(实例化了但还没初始化)

  3. singletonFactories:三级缓存,存放 bean 的工厂对象(ObjectFactory)

解决流程(以 A 和 B 循环依赖为例):

  1. 创建 A,实例化 A(调用构造函数)

  2. A 还没初始化,把 A 的工厂对象放入三级缓存

  3. 给 A 注入属性,发现需要 B

  4. 去创建 B,实例化 B

  5. B 还没初始化,把 B 的工厂对象放入三级缓存

  6. 给 B 注入属性,发现需要 A

  7. 去拿 A:一级缓存没有 → 二级缓存没有 → 三级缓存有 A 的工厂

  8. 通过工厂拿到 A 的早期引用(还没初始化完的 A),放入二级缓存

  9. B 拿到 A 了,B 初始化完成,放入一级缓存

  10. 回到 A 的创建过程,A 拿到 B 了,A 初始化完成,放入一级缓存

注意:

  • 只能解决单例的循环依赖

  • 只能解决setter 注入的循环依赖(构造器注入不行,因为实例化时就需要依赖)

  • prototype 作用域的循环依赖解决不了,直接抛异常

追问4:构造器注入为什么不能解决循环依赖?

因为构造器注入是在实例化的时候就需要依赖对象。

举例:A 的构造函数需要 B,B 的构造函数需要 A

  • 创建 A → 调用构造函数 → 需要 B → 去创建 B

  • 创建 B → 调用构造函数 → 需要 A → 去拿 A

  • A 还没实例化完(构造函数都没执行完),三级缓存里也没有

  • 拿不到 A → 抛异常

而 setter 注入是先实例化(调用构造函数),再注入属性。实例化完了就可以提前暴露到三级缓存中,所以能解决。

追问5:Bean 的生命周期中,哪些地方可以扩展?

Spring 提供了很多扩展点:

  1. BeanFactoryPostProcessor:bean 实例化前,修改 BeanDefinition

  2. BeanPostProcessor:bean 初始化前后,修改 bean 实例

  3. Aware 接口:获取容器资源

  4. InitializingBean:初始化逻辑

  5. DisposableBean:销毁逻辑

  6. @PostConstruct / @PreDestroy:初始化/销毁注解

  7. init-method / destroy-method:自定义初始化/销毁方法

  8. FactoryBean:自定义 bean 的创建逻辑

  9. BeanDefinitionRegistryPostProcessor:注册 BeanDefinition

这些扩展点让 Spring 非常灵活,很多功能都是通过这些扩展点实现的。


22. Spring Boot 自动装配原理

标准答案

Spring Boot 的自动装配(Auto Configuration)是 Spring Boot 最核心的特性之一,能够根据 classpath 中的依赖自动配置 Spring 应用。

【什么是自动装配】

传统 Spring 开发需要手动配置大量的 XML 或注解,而 Spring Boot 只需要引入 starter 依赖,就能自动配置好对应的组件。

举例:

  • 引入 spring-boot-starter-web → 自动配置好 Spring MVC、Tomcat、Jackson 等

  • 引入 spring-boot-starter-data-redis → 自动配置好 RedisTemplate

  • 引入 spring-boot-starter-jdbc → 自动配置好 DataSource、JdbcTemplate

自动装配的好处:

  1. 开箱即用,不需要手动配置

  2. 减少配置代码

  3. 约定大于配置(Convention over Configuration)

【自动装配的核心注解:@SpringBootApplication】

Spring Boot 启动类上的 @SpringBootApplication 是一个组合注解,包含三个核心注解:

@SpringBootConfiguration  // 标记这是一个配置类
@EnableAutoConfiguration  // 开启自动装配(核心)
@ComponentScan            // 组件扫描
public @interface SpringBootApplication {
}

1. @SpringBootConfiguration

  • 就是 @Configuration 的别名

  • 标记这个类是 Spring 的配置类

2. @ComponentScan

  • 扫描启动类所在包及其子包下的所有组件

  • 把 @Component、@Service、@Controller 等注解的类注册到容器

3. @EnableAutoConfiguration

  • 自动装配的核心注解

  • 导入了 AutoConfigurationImportSelector

  • 负责加载所有自动配置类

【自动装配的实现原理】

核心组件:AutoConfigurationImportSelector

@EnableAutoConfiguration 注解导入了 AutoConfigurationImportSelector,它的作用是:

  1. 读取 META-INF/spring.factories 文件

  2. 找到所有 key 为 org.springframework.boot.autoconfigure.EnableAutoConfiguration 的配置类

  3. 根据条件过滤,筛选出需要的自动配置类

  4. 把这些配置类导入到容器中

spring.factories 文件:

  • 位于每个 starter 的 jar 包中

  • 是一个 properties 文件,key=接口全限定名,value=实现类全限定名

  • Spring Boot 启动时会加载所有 jar 包中的 spring.factories

  • 自动配置类就是在这里定义的

条件注解(@Conditional):

不是所有自动配置类都会生效,需要满足一定条件才会生效。Spring Boot 提供了一系列条件注解:

| 注解 | 作用 | |------|------| | @ConditionalOnClass | classpath 中有指定类才生效 | | @ConditionalOnMissingClass | classpath 中没有指定类才生效 | | @ConditionalOnBean | 容器中有指定 bean 才生效 | | @ConditionalOnMissingBean | 容器中没有指定 bean 才生效 | | @ConditionalOnProperty | 配置文件中有指定属性才生效 | | @ConditionalOnWebApplication | Web 应用才生效 | | @ConditionalOnNotWebApplication | 非 Web 应用才生效 | | @ConditionalOnExpression | SpEL 表达式为 true 才生效 |

这些条件注解让自动配置很智能:有对应的依赖才配置,用户自己配置了就用用户的。

【自动装配完整流程】

1. 启动 Spring Boot 应用,运行 main 方法
   ↓
2. 加载 @SpringBootApplication 注解
   ↓
3. @EnableAutoConfiguration 生效
   ↓
4. AutoConfigurationImportSelector 被加载
   ↓
5. 读取所有 META-INF/spring.factories 文件
   ↓
6. 获取所有自动配置类的全限定名
   ↓
7. 根据条件注解过滤(@ConditionalOnClass 等)
   ↓
8. 过滤后的自动配置类注册到容器
   ↓
9. 自动配置类中的 @Bean 方法创建对应的 bean
   ↓
10. 自动装配完成

【自定义 starter 的步骤】

如果要自己写一个 starter:

  1. 创建一个自动配置类(@Configuration)

  2. 在类上加上条件注解(@ConditionalOnClass 等)

  3. 在类中定义 @Bean 方法,创建需要的 bean

  4. 在 META-INF/spring.factories 中注册这个自动配置类

  5. 打包发布


高频追问

追问1:spring.factories 文件的作用是什么?

spring.factories 是 Spring Boot 的扩展机制文件,位于 jar 包的 META-INF 目录下。

作用:

  • 定义各种扩展点的实现类

  • 格式是 properties 文件,key=接口/注解全限定名,value=实现类全限定名列表

  • Spring Boot 启动时会加载所有 jar 包中的 spring.factories 文件

自动装配就是通过这个机制实现的:

  • key = org.springframework.boot.autoconfigure.EnableAutoConfiguration

  • value = 所有自动配置类的全限定名

除了自动配置,spring.factories 还用于:

  • ApplicationContextInitializer

  • ApplicationListener

  • SpringApplicationRunListener

  • 等等...

这是 Spring Boot 的 SPI 机制。

追问2:自动配置类的优先级是怎样的?

自动配置类的加载和应用有优先级机制:

  1. 用户配置优先:

    • 如果用户自己配置了对应的 bean,自动配置就不生效

    • 通过 @ConditionalOnMissingBean 实现

    • 用户的配置覆盖自动配置

  2. 自动配置类之间的顺序:

    • @AutoConfigureBefore:在某个配置类之前加载

    • @AutoConfigureAfter:在某个配置类之后加载

    • @AutoConfigureOrder:指定顺序,值越小优先级越高

  3. 为什么需要顺序?

    • 有些配置依赖其他配置先完成

    • 比如 WebMvc 自动配置需要在 DataSource 自动配置之后?不,它们没关系

    • 比如 Redis 自动配置需要先有连接工厂

追问3:如何禁用某个自动配置?

方式一:在 @SpringBootApplication 上排除

@SpringBootApplication(exclude = {DataSourceAutoConfiguration.class})

方式二:在配置文件中排除

spring.autoconfigure.exclude=org.springframework.boot.autoconfigure.jdbc.DataSourceAutoConfiguration

方式三:用条件注解让它不满足条件

  • 比如不引入对应的 starter 依赖

  • 设置对应的配置属性为 false

追问4:@SpringBootApplication 注解包含哪几个注解?分别什么作用?

@SpringBootApplication 是一个组合注解,包含三个核心注解:

  1. @SpringBootConfiguration

    • 就是 @Configuration 的别名

    • 标记这是一个 Spring 配置类

    • 可以在里面定义 @Bean

  2. @EnableAutoConfiguration

    • 开启自动装配,最核心

    • 导入 AutoConfigurationImportSelector

    • 加载所有自动配置类

  3. @ComponentScan

    • 组件扫描

    • 默认扫描启动类所在包及其子包

    • 把 @Component、@Service、@Controller 等注册到容器

另外还有一些其他注解,但这三个是核心。

追问5:Spring Boot 的 starter 是什么原理?

starter 是 Spring Boot 的一种依赖管理方式,把某个场景需要的所有依赖打包在一起。

starter 本身不包含代码,只是一个 pom.xml,声明了需要的依赖。

真正的自动配置代码在 autoconfigure 包里。

举例:spring-boot-starter-web

  • starter 包:只有 pom.xml,引入了 spring-web、spring-webmvc、tomcat、jackson 等依赖

  • autoconfigure 包:包含 WebMvcAutoConfiguration 等自动配置类

  • spring.factories:注册自动配置类

starter 的好处:

  • 不用自己找依赖、不用管版本兼容

  • 引入一个 starter 就能用

  • 约定大于配置


23. Spring MVC 请求执行流程

标准答案

Spring MVC 是 Spring 框架的 Web 模块,基于 Servlet 实现,采用了前端控制器模式。

【核心组件】

| 组件 | 作用 | |------|------| | DispatcherServlet | 前端控制器,所有请求的入口,负责调度 | | HandlerMapping | 处理器映射器,根据请求找到对应的 Handler(Controller) | | HandlerAdapter | 处理器适配器,调用 Handler 方法 | | Handler | 处理器,就是我们写的 Controller | | ModelAndView | 模型和视图,Handler 返回的结果 | | ViewResolver | 视图解析器,根据视图名解析出 View 对象 | | View | 视图,渲染页面 | | HandlerExceptionResolver | 异常处理器,处理异常 | | LocaleResolver | 国际化解析器 | | MultipartResolver | 文件上传解析器 |

【完整请求流程】

1. 用户发送请求 → DispatcherServlet(前端控制器)
   ↓
2. DispatcherServlet → HandlerMapping
   ↓ 查找对应的 Handler(Controller 方法)
3. HandlerMapping → 返回 HandlerExecutionChain(Handler + 拦截器链)
   ↓
4. DispatcherServlet → HandlerAdapter
   ↓ 选择合适的适配器
5. HandlerAdapter → 调用 Handler(Controller 方法)
   ↓
6. Handler 执行业务逻辑 → 返回 ModelAndView
   ↓
7. HandlerAdapter → 返回 ModelAndView 给 DispatcherServlet
   ↓
8. DispatcherServlet → ViewResolver(视图解析器)
   ↓ 根据视图名解析 View
9. ViewResolver → 返回 View 对象
   ↓
10. DispatcherServlet → 调用 View 的 render 方法渲染页面
    ↓ 把 Model 数据填充到 View 中
11. View 渲染完成 → 返回响应给用户

【详细说明】

第一步:请求到达 DispatcherServlet

  • DispatcherServlet 是 Spring MVC 的核心,所有请求都经过它

  • 它是一个 Servlet,在 web.xml 或 Servlet 3.0 中配置

  • Spring Boot 中自动配置了 DispatcherServlet

第二步:HandlerMapping 查找 Handler

  • DispatcherServlet 拿到请求后,调用 HandlerMapping

  • HandlerMapping 根据请求 URL 找到对应的 Handler(Controller 方法)

  • 返回的是 HandlerExecutionChain,包含 Handler 和拦截器链

  • 常用的 HandlerMapping 实现:RequestMappingHandlerMapping(处理 @RequestMapping)

第三步:HandlerAdapter 调用 Handler

  • DispatcherServlet 拿到 Handler 后,找对应的 HandlerAdapter

  • 为什么需要适配器?因为 Handler 的形式不统一:

    • 有实现 Controller 接口的

    • 有 @RequestMapping 注解的

    • 有 HttpRequestHandler 的

  • 适配器模式,统一调用接口

  • 常用的 HandlerAdapter 实现:RequestMappingHandlerAdapter(处理 @RequestMapping)

第四步:Handler 执行

  • HandlerAdapter 调用 Handler(Controller 方法)

  • 执行过程中会经过拦截器链(preHandle)

  • Handler 执行业务逻辑,返回 ModelAndView

  • ModelAndView 包含模型数据和视图名

第五步:视图解析

  • DispatcherServlet 拿到 ModelAndView 后,调用 ViewResolver

  • ViewResolver 根据视图名解析出 View 对象

  • 常用的 ViewResolver:InternalResourceViewResolver(解析 JSP)、ThymeleafViewResolver 等

第六步:视图渲染

  • 调用 View 的 render 方法

  • 把 Model 中的数据填充到 View 中

  • 生成 HTML 或其他格式的响应

  • 返回给用户

【拦截器的执行时机】

拦截器(HandlerInterceptor)有三个方法:

  • preHandle:Handler 执行前调用,返回 false 则中断

  • postHandle:Handler 执行后,视图渲染前调用

  • afterCompletion:整个请求完成后调用(视图渲染完)

执行顺序:

preHandle1 → preHandle2 → Handler → postHandle2 → postHandle1 → 视图渲染 → afterCompletion2 → afterCompletion1

高频追问

追问1:Spring MVC 和 Struts2 的区别?

| 维度 | Spring MVC | Struts2 | |------|-----------|---------| | 入口 | Servlet(DispatcherServlet) | Filter(StrutsPrepareAndExecuteFilter) | | 粒度 | 方法级别的拦截,一个方法对应一个请求 | 类级别的拦截,一个类对应一个请求 | | 线程安全 | 单例,方法参数注入,线程安全 | 多实例,成员变量接收参数,线程安全但性能差 | | 性能 | 好,单例 + 方法级 | 稍差,每次请求创建新 Action | | 集成 Spring | 原生就是 Spring 的一部分 | 需要额外集成 | | 学习成本 | 低,注解驱动 | 高,配置多 | | 现状 | 主流 | 逐渐被淘汰 |

现在基本都用 Spring MVC 了,Struts2 用得越来越少。

追问2:Spring MVC 怎么获取请求参数?

常见的参数绑定方式:

  1. 简单类型参数:直接写在方法参数上,按名字匹配

    public String test(String name, int age) { ... }
    
  2. @RequestParam:显式指定参数名,required、defaultValue

    public String test(@RequestParam("name") String username) { ... }
    
  3. POJO 对象:自动封装到对象中

    public String test(User user) { ... }
    
  4. @PathVariable:RESTful 风格,路径变量

    @GetMapping("/user/{id}")
    public String test(@PathVariable Long id) { ... }
    
  5. @RequestBody:JSON 格式的请求体

    public String test(@RequestBody User user) { ... }
    
  6. @RequestHeader:获取请求头

  7. @CookieValue:获取 Cookie

  8. HttpServletRequest / HttpServletResponse:直接用原生 API

  9. Map / Model / ModelMap:往 request 域放数据

追问3:@RequestMapping 注解的属性有哪些?

常用属性:

  • value / path:请求路径

  • method:请求方法(GET、POST、PUT、DELETE 等)

  • params:请求参数条件

  • headers:请求头条件

  • consumes:请求的 Content-Type

  • produces:响应的 Content-Type

衍生注解(更简洁):

  • @GetMapping:GET 请求

  • @PostMapping:POST 请求

  • @PutMapping:PUT 请求

  • @DeleteMapping:DELETE 请求

  • @PatchMapping:PATCH 请求

这些都是 @RequestMapping 的别名,只是指定了 method。

追问4:Spring MVC 的异常怎么处理?

Spring MVC 提供了多种异常处理方式:

1. @ExceptionHandler

  • 在 Controller 中写一个方法,用 @ExceptionHandler 标注

  • 只对当前 Controller 有效

@ExceptionHandler(Exception.class)
public String handleException(Exception e) {
    return "error";
}

2. @ControllerAdvice + @ExceptionHandler

  • 全局异常处理,对所有 Controller 有效

@ControllerAdvice
public class GlobalExceptionHandler {
    @ExceptionHandler(Exception.class)
    public String handleException(Exception e) {
        return "error";
    }
}

3. HandlerExceptionResolver

  • 实现 HandlerExceptionResolver 接口

  • 更底层的异常处理方式

  • Spring MVC 默认注册了几个,比如 ExceptionHandlerExceptionResolver(处理 @ExceptionHandler)

4. 配置 error-page

  • 在 web.xml 中配置错误页面

  • 比较老的方式了

最常用的是 @ControllerAdvice + @ExceptionHandler,全局统一异常处理。

追问5:什么是 RESTful API?Spring MVC 怎么实现?

RESTful 是一种 API 设计风格,用 HTTP 方法表示操作,用 URL 表示资源。

| HTTP 方法 | 操作 | 示例 | |-----------|------|------| | GET | 查询 | GET /users → 查询用户列表 | | GET | 查询单个 | GET /users/1 → 查询 id=1 的用户 | | POST | 新增 | POST /users → 新增用户 | | PUT | 修改 | PUT /users/1 → 修改 id=1 的用户 | | DELETE | 删除 | DELETE /users/1 → 删除 id=1 的用户 |

Spring MVC 实现:

  • @GetMapping / @PostMapping / @PutMapping / @DeleteMapping

  • @PathVariable 获取路径变量

  • @RequestBody 接收 JSON 请求体

  • @ResponseBody 返回 JSON 响应

  • @RestController = @Controller + @ResponseBody

前后端分离的项目基本都是 RESTful 风格 + JSON 格式。


24. Spring 事务传播机制

标准答案

事务传播机制指的是:当一个事务方法调用另一个事务方法时,事务该如何传播、如何管理。

Spring 定义了 7 种事务传播行为,定义在 Propagation 枚举中。

【7 种传播行为】

| 传播行为 | 说明 | 特点 | |----------|------|------| | REQUIRED | 必须有事务(默认) | 有就用,没有就新建 | | SUPPORTS | 支持事务 | 有就用,没有就不用 | | MANDATORY | 强制事务 | 必须有,没有就抛异常 | | REQUIRES_NEW | 新建事务 | 不管有没有,都新建一个,挂起当前事务 | | NOT_SUPPORTED | 不支持事务 | 不管有没有,都以非事务方式运行,挂起当前事务 | | NEVER | 从不事务 | 必须没有,有就抛异常 | | NESTED | 嵌套事务 | 有事务就嵌套,没有就新建 |

【详细说明】

1. REQUIRED(默认,最常用)

  • 如果当前有事务,就加入当前事务

  • 如果当前没有事务,就新建一个事务

  • 这是最常用的,默认值

举例:A 调用 B,都是 REQUIRED

  • A 有事务 → B 加入 A 的事务,同一个事务

  • A 没事务 → B 自己新建一个事务

2. SUPPORTS

  • 如果当前有事务,就加入当前事务

  • 如果当前没有事务,就以非事务方式执行

  • 可有可无,随大流

3. MANDATORY(强制)

  • 如果当前有事务,就加入当前事务

  • 如果当前没有事务,就抛异常

  • 必须在事务中运行,否则报错

4. REQUIRES_NEW

  • 不管当前有没有事务,都新建一个事务

  • 如果当前有事务,把当前事务挂起

  • 新建的事务和原事务是独立的,提交/回滚互不影响

举例:A 调用 B,A 是 REQUIRED,B 是 REQUIRES_NEW

  • A 有事务 → 挂起 A 的事务 → B 新建事务 → B 提交/回滚 → 恢复 A 的事务

  • B 的回滚不影响 A,A 的回滚也不影响 B

5. NOT_SUPPORTED

  • 以非事务方式执行

  • 如果当前有事务,把当前事务挂起

  • 不管有没有事务,都不用事务

6. NEVER

  • 以非事务方式执行

  • 如果当前有事务,就抛异常

  • 必须不在事务中运行,否则报错

7. NESTED(嵌套事务)

  • 如果当前有事务,就嵌套在当前事务中

  • 如果当前没有事务,就新建一个事务(和 REQUIRED 一样)

  • 嵌套事务有自己的回滚点(savepoint)

  • 嵌套事务回滚不影响外层事务

  • 外层事务回滚,嵌套事务也会回滚

NESTED 和 REQUIRES_NEW 的区别:

  • REQUIRES_NEW:完全独立的两个事务,互不影响

  • NESTED:嵌套在外层事务中,外层回滚内层也回滚,但内层回滚不影响外层

【事务的隔离级别】

Spring 也支持设置事务隔离级别,定义在 Isolation 枚举中:

| 隔离级别 | 说明 | 脏读 | 不可重复读 | 幻读 | |----------|------|------|-----------|------| | DEFAULT | 使用数据库默认 | - | - | - | | READ_UNCOMMITTED | 读未提交 | 是 | 是 | 是 | | READ_COMMITTED | 读已提交 | 否 | 是 | 是 | | REPEATABLE_READ | 可重复读 | 否 | 否 | 是 | | SERIALIZABLE | 串行化 | 否 | 否 | 否 |

MySQL 默认是 REPEATABLE_READ,Oracle 默认是 READ_COMMITTED。


高频追问

追问1:事务传播行为 REQUIRED 和 REQUIRES_NEW 的区别?

这是最常问的,一定要搞清楚:

| 维度 | REQUIRED | REQUIRES_NEW | |------|----------|-------------| | 事务数量 | 一个事务 | 两个独立事务 | | 外层有事务时 | 加入外层事务 | 挂起外层,新建自己的事务 | | 回滚影响 | 内部回滚 → 外部也回滚(同一个事务) | 内部回滚 → 不影响外部 | | 外部回滚影响 | 外部回滚 → 内部也回滚(同一个事务) | 外部回滚 → 不影响内部 | | 提交时机 | 一起提交 | 内部先提交,外部后提交 |

举例:A 调用 B

  • A 是 REQUIRED,B 是 REQUIRED → 同一个事务,A 或 B 回滚都一起回滚

  • A 是 REQUIRED,B 是 REQUIRES_NEW → 两个事务,B 回滚不影响 A,A 回滚不影响 B

追问2:NESTED 和 REQUIRES_NEW 的区别?

| 维度 | NESTED | REQUIRES_NEW | |------|--------|-------------| | 事务数量 | 一个物理事务,多个保存点 | 两个独立的物理事务 | | 外层回滚 | 内层也回滚 | 内层不受影响 | | 内层回滚 | 不影响外层,只回滚到保存点 | 不影响外层 | | 外层提交 | 内层一起提交 | 内层已经提交了 | | 实现方式 | savepoint(保存点) | 新建事务 |

简单说:

  • REQUIRES_NEW:完全独立,各管各的

  • NESTED:嵌套在外层里面,外层管着内层,但内层可以自己回滚一部分

NESTED 需要数据库支持 savepoint,JDBC 3.0 以上支持。

追问3:Spring 事务什么时候失效?

Spring 事务基于 AOP,只有通过代理对象调用才生效。以下情况事务会失效:

  1. 方法不是 public 的

    • @Transactional 只能用在 public 方法上

    • 非 public 方法代理不了

  2. 同类内部调用(this 调用)

    • 同一个类中方法 A 调用方法 B

    • A 调用 B 用的是 this,不是代理对象

    • B 的事务不生效

    • 解决:注入自己、用 AopContext.currentProxy()、拆成两个类

  3. 异常类型不对

    • 默认只回滚 RuntimeException 和 Error

    • 受检异常(Exception)不回滚

    • 解决:@Transactional(rollbackFor = Exception.class)

  4. 异常被 catch 了

    • 方法里 try-catch 了异常,没有抛出去

    • 事务不知道出异常了,不会回滚

    • 解决:catch 后重新抛出,或者手动设置回滚

  5. 数据库引擎不支持事务

    • 比如 MySQL 的 MyISAM 引擎不支持事务

    • 要用 InnoDB

  6. 没有被 Spring 管理

    • 类没有加 @Service 等注解,不在容器中

    • 没有代理对象,事务不生效

  7. 多线程调用

    • 事务是和线程绑定的

    • 新线程里的方法不在事务中

这是高频面试题,一定要记住。

追问4:同类内部调用为什么事务不生效?怎么解决?

因为 Spring 事务基于 AOP 代理,只有通过代理对象调用才会走事务拦截。

同类内部调用时:

  • 对象 A 的方法 1 调用方法 2

  • 方法 1 里用的是 this 调用方法 2

  • this 是原始对象,不是代理对象

  • 所以方法 2 的 @Transactional 不生效

解决方法:

  1. 注入自己

    @Service
    public class UserService {
        @Autowired
        private UserService self; // 注入自己(代理对象)
    
        public void method1() {
            self.method2(); // 用代理对象调用
        }
    
        @Transactional
        public void method2() { ... }
    }
    
  2. AopContext.currentProxy()

    ((UserService) AopContext.currentProxy()).method2();
    

    需要开启 exposeProxy = true

  3. 拆成两个类

    • 把方法 2 抽到另一个类中

    • 注入另一个类来调用

    • 最推荐的方式,结构清晰

  4. 编程式事务

    • 用 TransactionTemplate 手动管理事务

    • 不依赖代理

追问5:什么是编程式事务和声明式事务?

| 维度 | 编程式事务 | 声明式事务 | |------|-----------|-----------| | 实现方式 | 手动写代码管理事务 | 注解/XML 配置 | | 代码侵入性 | 高,事务代码和业务代码混在一起 | 低,一个注解搞定 | | 灵活性 | 高,精细控制 | 低,只能到方法级别 | | 易用性 | 麻烦 | 简单 | | 推荐 | 少用,特殊场景用 | 推荐,日常开发用 |

声明式事务就是 @Transactional 注解,基于 AOP 实现,最常用。

编程式事务用 TransactionTemplate 或 PlatformTransactionManager,适合需要精细控制的场景。


25. Spring 事务失效场景

标准答案

Spring 事务基于 AOP 动态代理实现,只有通过代理对象调用的方法,事务才会生效。以下是常见的事务失效场景:

【场景 1:方法不是 public 的】

@Transactional 只能作用在 public 方法上,非 public 方法事务不生效。

原因:Spring 的事务拦截器(TransactionInterceptor)在拦截时,会检查方法是否是 public 的,非 public 直接跳过。

// 错误:protected 方法,事务不生效
@Transactional
protected void addUser() {
    // ...
}

解决:方法改成 public。

【场景 2:同类内部调用(this 调用)】

同一个类中,方法 A 调用方法 B,B 的事务不生效。

原因:A 调用 B 用的是 this(原始对象),不是代理对象,不走 AOP 拦截。

@Service
public class UserService {
    public void methodA() {
        this.methodB(); // this 调用,事务不生效
    }
    
    @Transactional
    public void methodB() {
        // ...
    }
}

解决:

  1. 注入自己,用注入的代理对象调用

  2. 用 AopContext.currentProxy() 获取代理对象

  3. 拆成两个类

  4. 用编程式事务

【场景 3:异常类型不对】

Spring 事务默认只回滚 RuntimeException 和 Error,受检异常(Exception)不回滚。

@Transactional
public void addUser() throws Exception {
    // 插入数据
    throw new Exception(); // 受检异常,不会回滚!
}

解决:

@Transactional(rollbackFor = Exception.class) // 指定回滚异常
public void addUser() throws Exception {
    // ...
}

或者用 rollbackForClassName 指定类名。

【场景 4:异常被 catch 了】

方法里 try-catch 捕获了异常,没有抛出去,事务不知道出异常了,不会回滚。

@Transactional
public void addUser() {
    try {
        // 插入数据
        int i = 1 / 0;
    } catch (Exception e) {
        e.printStackTrace(); // 异常被吞了,事务不回滚
    }
}

解决:

  1. catch 后重新抛出异常

  2. 手动设置事务回滚:TransactionAspectSupport.currentTransactionStatus().setRollbackOnly();

【场景 5:数据库引擎不支持事务】

MySQL 的 MyISAM 引擎不支持事务,要用 InnoDB。

现在 MySQL 默认就是 InnoDB,但老项目可能还有 MyISAM 的表。

解决:把表引擎改成 InnoDB。

【场景 6:没有被 Spring 管理】

类没有被 Spring 容器管理(没有加 @Service、@Component 等注解),就不会创建代理对象,事务不生效。

// 错误:没有 @Service,不在 Spring 容器中
public class UserService {
    @Transactional
    public void addUser() { ... }
}

解决:加上 @Service 等注解,让 Spring 管理。

【场景 7:多线程调用】

事务是和线程绑定的(ThreadLocal),新线程不在事务中。

@Transactional
public void addUser() {
    new Thread(() -> {
        // 新线程里的操作,不在事务中
        userDao.insert(user);
    }).start();
}

解决:把事务加到线程内部的方法上,或者用编程式事务。

【场景 8:传播行为设置不对】

传播行为设置成了 SUPPORTS、NOT_SUPPORTED、NEVER 等,可能导致事务不生效。

比如设置成 NOT_SUPPORTED,就是以非事务方式运行。

解决:用默认的 REQUIRED 就行。


高频追问

追问1:同类内部调用事务失效,有哪些解决方式?

  1. 注入自己(最常用)

    @Service
    public class UserService {
        @Autowired
        @Lazy // 解决循环依赖
        private UserService self;
    
        public void methodA() {
            self.methodB(); // 用代理对象调用
        }
    
        @Transactional
        public void methodB() { ... }
    }
    
  2. AopContext.currentProxy()

    // 启动类加:@EnableAspectJAutoProxy(exposeProxy = true)
    ((UserService) AopContext.currentProxy()).methodB();
    
  3. 拆成两个类(最推荐)

    • 把 methodB 抽到另一个 Service 中

    • 结构更清晰,符合单一职责

  4. 编程式事务

    • 用 TransactionTemplate 手动管理

    • 不依赖代理

追问2:try-catch 了异常,事务还能回滚吗?

默认不能,因为异常被 catch 了,事务拦截器感知不到异常。

但可以手动设置回滚:

@Transactional
public void addUser() {
    try {
        // 业务逻辑
    } catch (Exception e) {
        // 手动标记回滚
        TransactionAspectSupport.currentTransactionStatus().setRollbackOnly();
    }
}

或者 catch 后重新抛出异常:

@Transactional(rollbackFor = Exception.class)
public void addUser() throws Exception {
    try {
        // 业务逻辑
    } catch (Exception e) {
        // 处理后重新抛出
        throw e;
    }
}

追问3:@Transactional 加在什么地方?加在接口上还是实现类上?

都可以,但推荐加在实现类上。

原因:

  1. 接口上的注解不能被继承(如果用 CGLIB 代理,基于类的代理可能拿不到接口注解)

  2. 实现类上更直观,一看就知道哪些方法有事务

  3. 接口是契约,事务是实现细节,应该放在实现里

加在类上:类中所有 public 方法都有事务 加在方法上:只有这个方法有事务 方法上的优先级高于类上的

追问4:事务的 ACID 特性是什么?

ACID 是事务的四大特性:

  1. A - 原子性(Atomicity)

    • 事务是不可分割的最小单位

    • 事务中的操作要么全部成功,要么全部失败回滚

  2. C - 一致性(Consistency)

    • 事务执行前后,数据保持一致状态

    • 比如转账,A 转给 B,转之前和转之后总金额不变

  3. I - 隔离性(Isolation)

    • 多个事务之间互不干扰

    • 一个事务的修改对其他事务是隔离的

    • 隔离级别决定了隔离的程度

  4. D - 持久性(Durability)

    • 事务一旦提交,对数据的修改就是永久的

    • 即使系统崩溃也不会丢失

追问5:Spring 事务的实现原理?

Spring 事务基于 AOP 实现:

  1. @EnableTransactionManagement 开启事务

  2. 容器中注册事务拦截器和自动代理创建器

  3. bean 实例化后,检查是否有 @Transactional 注解

  4. 如果有,创建事务代理对象(JDK 动态代理或 CGLIB)

  5. 调用方法时,事务拦截器(TransactionInterceptor)拦截

  6. 方法执行前:获取事务(开启事务)

  7. 方法正常执行:提交事务

  8. 方法抛出异常(根据配置的回滚异常判断):回滚事务

核心就是 AOP + 事务管理器(PlatformTransactionManager)。


26. 循环依赖如何解决

标准答案

循环依赖指的是两个或多个 bean 之间相互依赖,形成闭环,比如 A 依赖 B,B 又依赖 A。

【什么是循环依赖】

@Component
public class A {
    @Autowired
    private B b;
}

@Component
public class B {
    @Autowired
    private A a;
}

A 创建需要 B,B 创建需要 A,形成循环。

【Spring 能解决哪些循环依赖】

| 循环依赖类型 | 能否解决 | 原因 | |-------------|---------|------| | 单例 + setter 注入 | ✅ 能 | 三级缓存 + 提前暴露 | | 单例 + 构造器注入 | ❌ 不能 | 实例化时就需要依赖 | | prototype 作用域 | ❌ 不能 | 每次都创建新的,无法缓存 | | 多例循环 | ❌ 不能 | 同上 |

Spring 只能解决单例的 setter 注入的循环依赖。

【三级缓存机制】

Spring 用三级缓存来解决单例的循环依赖:

// 一级缓存:存放完全初始化好的 bean
private final Map<String, Object> singletonObjects = new ConcurrentHashMap<>(256);

// 二级缓存:存放提前暴露的 bean(实例化了但还没初始化)
private final Map<String, Object> earlySingletonObjects = new HashMap<>(16);

// 三级缓存:存放 bean 的工厂对象(ObjectFactory)
private final Map<String, ObjectFactory<?>> singletonFactories = new HashMap<>(16);

为什么需要三级缓存,一级不行吗?

一级缓存只能存完全初始化好的 bean,但循环依赖时,bean 还没初始化完就需要被引用了,所以需要提前暴露。

为什么需要二级和三级两个?

三级缓存存的是工厂(ObjectFactory),不是直接存对象。因为如果 bean 需要被 AOP 代理,提前暴露的应该是代理对象,而不是原始对象。三级缓存的工厂可以决定返回什么对象。

【解决循环依赖的完整流程】

以 A 和 B 循环依赖为例:

1. 创建 A
   ↓
2. 实例化 A(调用构造函数)→ A 对象创建了,但属性还没赋值
   ↓
3. 把 A 的 ObjectFactory 放入三级缓存(singletonFactories)
   ↓ 提前暴露,允许别人通过工厂拿到 A
4. 给 A 注入属性 → 发现需要 B
   ↓
5. 去创建 B
   ↓
6. 实例化 B → B 对象创建了
   ↓
7. 把 B 的 ObjectFactory 放入三级缓存
   ↓
8. 给 B 注入属性 → 发现需要 A
   ↓
9. 去获取 A:
   - 一级缓存(singletonObjects)→ 没有
   - 二级缓存(earlySingletonObjects)→ 没有
   - 三级缓存(singletonFactories)→ 有 A 的工厂
   ↓
10. 调用 A 的工厂的 getObject() → 拿到 A 的早期引用
    ↓ 如果需要 AOP,这里返回的是代理对象
11. 把 A 放入二级缓存,从三级缓存移除
    ↓
12. B 拿到 A 了 → B 属性注入完成
    ↓
13. B 初始化完成 → B 放入一级缓存
    ↓
14. 回到 A 的创建过程 → A 拿到 B 了
    ↓
15. A 属性注入完成 → A 初始化完成
    ↓
16. A 放入一级缓存,从二级缓存移除
    ↓
17. 完成!

【构造器注入为什么不能解决】

因为构造器注入是在实例化的时候就需要依赖:

创建 A → 调用构造函数 → 需要 B → 去创建 B
创建 B → 调用构造函数 → 需要 A → 去拿 A
A 还没实例化完(构造函数都没执行完)→ 三级缓存里也没有 A
→ 拿不到 A → 抛异常

而 setter 注入是先实例化(构造函数执行完了),再注入属性。实例化完了就可以放入三级缓存提前暴露了。


高频追问

追问1:三级缓存分别存什么?为什么需要三级?

| 缓存 | 存什么 | 时机 | |------|--------|------| | 一级(singletonObjects) | 完全初始化好的 bean | bean 完全创建好之后 | | 二级(earlySingletonObjects) | 提前暴露的 bean(实例化了,没初始化) | 第一次被引用时,从三级取出来放二级 | | 三级(singletonFactories) | bean 的 ObjectFactory | 实例化之后,属性注入之前 |

为什么需要三级?

  • 一级不够:因为循环依赖时 bean 还没初始化完,不能放一级

  • 二级不够:因为如果 bean 需要 AOP 代理,提前暴露的应该是代理对象,不是原始对象。三级的工厂可以生成代理对象。

三级缓存的核心作用是:让 bean 在还没初始化完的时候,就能提前暴露出去,并且可以决定暴露的是原始对象还是代理对象。

追问2:循环依赖和 AOP 代理的关系?

如果 bean 需要被 AOP 代理,那提前暴露的应该是代理对象,而不是原始对象。

三级缓存的 ObjectFactory 的作用就是:

  • 如果 bean 需要代理,就返回代理对象

  • 如果不需要代理,就返回原始对象

这样即使有 AOP,循环依赖也能正确解决,因为注入的是代理对象。

具体来说:

  • 三级缓存里的 ObjectFactory 是一个 lambda 表达式

  • 调用 getObject() 时,会调用 getEarlyBeanReference 方法

  • 这个方法会走 BeanPostProcessor,如果需要 AOP 就创建代理

  • 所以提前暴露的是代理对象,不是原始对象

追问3:prototype 作用域的循环依赖为什么解决不了?

因为 prototype 作用域的 bean 每次获取都创建新的,不会缓存。

举例:A 和 B 都是 prototype

  • 创建 A → 需要 B → 创建 B → 需要 A → 创建 A → 需要 B → ...

  • 死循环了

而单例的 bean 只创建一次,可以缓存起来,所以能解决。

prototype 的循环依赖 Spring 直接抛 BeanCurrentlyInCreationException。

追问4:怎么检测是否存在循环依赖?

Spring 用一个 Set 来记录正在创建中的 bean:

private final Set<String> singletonsCurrentlyInCreation = 
    Collections.newSetFromMap(new ConcurrentHashMap<>(16));

创建 bean 之前,把 beanName 加入这个 Set 创建完成后,从 Set 中移除

如果创建时发现 beanName 已经在 Set 里了,说明出现循环依赖了。

对于构造器注入的循环依赖,检测到后直接抛异常。 对于 setter 注入的循环依赖,检测到后用三级缓存解决。

追问5:怎么避免循环依赖?

虽然 Spring 能解决单例 setter 注入的循环依赖,但循环依赖本身不是好的设计,说明代码耦合度高。

避免方法:

  1. 重新设计:

    • 梳理职责,是不是职责划分有问题

    • 把共同依赖的部分抽出来

  2. 使用 @Lazy:

    • 延迟加载,用到的时候才创建

    @Autowired
    @Lazy
    private B b;
    
  3. 使用 Setter 注入:

    • 构造器注入不行就改成 setter 注入

    • 让 Spring 能解决

  4. 使用 ApplicationContext.getBean():

    • 用到的时候再从容器拿

    • 不推荐,代码丑

  5. 使用 @PostConstruct:

    • 在初始化方法里手动设置依赖

最好的方式还是重新设计,从根源上避免循环依赖。


27. Filter、Interceptor、AOP 区别

标准答案

Filter、Interceptor、AOP 都可以实现对请求或方法的拦截,但它们的层次和作用不同。

【三者的定位】

| 维度 | Filter(过滤器) | Interceptor(拦截器) | AOP(切面) | |------|-----------------|---------------------|------------| | 规范 | Servlet 规范 | Spring MVC 规范 | Spring AOP | | 层次 | Web 层,最外层 | Spring MVC 层,中间 | 业务层,最内层 | | 拦截对象 | HTTP 请求 | Controller 方法 | 任意 Bean 的方法 | | 依赖 | 依赖 Servlet 容器 | 依赖 Spring MVC | 依赖 Spring AOP | | 执行顺序 | 最前 | 中间 | 最后 |

【Filter(过滤器)】

定义:Filter 是 Java Servlet 规范中的组件,用于拦截 HTTP 请求和响应。

作用:

  • 请求到达 Servlet 之前进行预处理

  • 响应返回给客户端之前进行后处理

  • 可以修改请求和响应

常见用途:

  • 字符编码过滤(CharacterEncodingFilter)

  • 登录验证

  • CORS 跨域处理

  • XSS 防护

  • 日志记录

实现方式:实现 javax.servlet.Filter 接口

public class MyFilter implements Filter {
    @Override
    public void doFilter(ServletRequest request, ServletResponse response, FilterChain chain) 
            throws IOException, ServletException {
        // 请求前处理
        chain.doFilter(request, response); // 放行
        // 响应后处理
    }
}

执行时机:在 DispatcherServlet 之前执行,是请求的第一道关卡。

【Interceptor(拦截器)】

定义:Interceptor 是 Spring MVC 提供的组件,用于拦截 Controller 方法的调用。

作用:

  • Controller 方法执行前拦截

  • Controller 方法执行后拦截

  • 视图渲染完成后拦截

常见用途:

  • 登录验证

  • 权限检查

  • 日志记录

  • 性能监控

实现方式:实现 HandlerInterceptor 接口

public class MyInterceptor implements HandlerInterceptor {
    // 方法执行前
    @Override
    public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) {
        return true; // 返回 false 则中断
    }
    
    // 方法执行后,视图渲染前
    @Override
    public void postHandle(HttpServletRequest request, HttpServletResponse response, Object handler, ModelAndView modelAndView) {
    }
    
    // 整个请求完成后
    @Override
    public void afterCompletion(HttpServletRequest request, HttpServletResponse response, Object handler, Exception ex) {
    }
}

执行时机:在 DispatcherServlet 之后,Controller 方法之前。

【AOP(面向切面)】

定义:AOP 是 Spring 的核心特性之一,基于动态代理,可以拦截任意 Bean 的方法。

作用:

  • 方法执行前后增强

  • 可以拦截任意层次的方法(Service、Dao 等)

常见用途:

  • 事务管理(@Transactional)

  • 日志记录

  • 性能监控

  • 权限控制

  • 缓存

实现方式:@Aspect 注解 + 切点表达式

@Aspect
@Component
public class MyAspect {
    @Around("execution(* com.example.service.*.*(..))")
    public Object around(ProceedingJoinPoint joinPoint) throws Throwable {
        // 方法前
        Object result = joinPoint.proceed();
        // 方法后
        return result;
    }
}

执行时机:在方法调用时,通过代理对象拦截。

【执行顺序】

一个请求进来,执行顺序是:

请求 → Filter1 → Filter2 → DispatcherServlet → Interceptor.preHandle 
→ Controller → Interceptor.postHandle → 视图渲染 → Interceptor.afterCompletion 
→ Filter2 → Filter1 → 响应

如果 Controller 调用了 Service,Service 上有 AOP:

Controller → AOP 前置 → Service 方法 → AOP 后置 → Controller

从外到内:Filter → Interceptor → AOP


高频追问

追问1:Filter 和 Interceptor 的区别?

| 维度 | Filter | Interceptor | |------|--------|-------------| | 规范 | Servlet 规范 | Spring MVC 规范 | | 依赖 | 依赖 Servlet 容器 | 依赖 Spring 容器 | | 拦截范围 | 所有请求 | 只有 Controller 请求 | | 能否操作 request/response | 能 | 能 | | 能否访问 Controller 方法信息 | 不能 | 能(handler 参数) | | 能否访问 ModelAndView | 不能 | 能 | | 执行时机 | DispatcherServlet 之前 | DispatcherServlet 之后,Controller 之前 | | 数量限制 | 多个,形成过滤器链 | 多个,形成拦截器链 | | 中断方式 | 不调用 chain.doFilter() | preHandle 返回 false |

简单说:Filter 是 Servlet 层面的,Interceptor 是 Spring MVC 层面的。Filter 更外层,Interceptor 更精细。

追问2:Interceptor 和 AOP 的区别?

| 维度 | Interceptor | AOP | |------|-------------|-----| | 规范 | Spring MVC | Spring AOP | | 拦截对象 | Controller 方法 | 任意 Bean 的方法 | | 粒度 | 粗,只能拦 Controller | 细,可以拦任意方法 | | 配置方式 | 实现 HandlerInterceptor + 注册 | @Aspect + 切点表达式 | | 依赖 | 依赖 Web 环境 | 不依赖 Web 环境 | | 性能 | 稍好 | 稍差(动态代理) |

Interceptor 只能拦 Controller,AOP 可以拦任意层的方法(Service、Dao 等)。 如果只需要拦 Controller,用 Interceptor 更简单。 如果需要拦 Service 或其他层,用 AOP。

追问3:Spring MVC 的拦截器怎么注册?

方式一:实现 WebMvcConfigurer(推荐)

@Configuration
public class WebConfig implements WebMvcConfigurer {
    @Override
    public void addInterceptors(InterceptorRegistry registry) {
        registry.addInterceptor(new MyInterceptor())
                .addPathPatterns("/**")      // 拦截路径
                .excludePathPatterns("/login"); // 排除路径
    }
}

方式二:XML 配置(老项目)

<mvc:interceptors>
    <mvc:interceptor>
        <mvc:mapping path="/**"/>
        <bean class="com.example.MyInterceptor"/>
    </mvc:interceptor>
</mvc:interceptors>

可以配置多个拦截器,按注册顺序执行。

追问4:多个 Filter 的执行顺序怎么控制?

方式一:@Order 注解

@Order(1) // 值越小,优先级越高,越先执行
@Component
public class MyFilter implements Filter { ... }

方式二:FilterRegistrationBean

@Bean
public FilterRegistrationBean<MyFilter> myFilter() {
    FilterRegistrationBean<MyFilter> registration = new FilterRegistrationBean<>();
    registration.setFilter(new MyFilter());
    registration.addUrlPatterns("/*");
    registration.setOrder(1); // 顺序
    return registration;
}

方式三:web.xml 中按配置顺序(老项目)

执行顺序:值越小,优先级越高,越先执行。 响应返回时,顺序反过来,后执行的先返回。

追问5:什么场景用 Filter,什么场景用 Interceptor,什么场景用 AOP?

选择原则:从外到内,能在外层解决的就不用内层。

用 Filter 的场景:

  • 和 Servlet 规范相关的

  • 需要操作 request/response 的

  • 需要过滤所有请求的

  • 比如:编码过滤、CORS、XSS 防护、登录验证(全局的)

用 Interceptor 的场景:

  • 只需要拦截 Controller 的

  • 需要访问 Controller 方法信息的

  • 需要操作 ModelAndView 的

  • 比如:权限校验(Controller 级)、日志记录、性能监控

用 AOP 的场景:

  • 需要拦截 Service 层或 Dao 层方法的

  • 需要更细粒度控制的

  • 和 Web 无关的通用逻辑

  • 比如:事务、缓存、日志(Service 层)、权限(方法级)

简单记:

  • Web 全局 → Filter

  • Controller 层 → Interceptor

  • Service/Dao 层 → AOP


28. Spring Cloud 常用组件及作用

标准答案

Spring Cloud 是一套微服务治理框架,基于 Spring Boot 构建,提供了微服务架构下的各种解决方案。

【Spring Cloud 核心组件】

| 组件 | 作用 | 替代方案 | |------|------|----------| | Eureka / Nacos | 服务注册与发现 | Consul、Zookeeper | | Ribbon / LoadBalancer | 负载均衡 | - | | Feign / OpenFeign | 声明式 HTTP 客户端 | RestTemplate | | Hystrix / Sentinel | 熔断降级 | Resilience4j | | Zuul / Gateway | 网关 | - | | Config | 配置中心 | Nacos Config、Apollo | | Bus | 消息总线 | - | | Sleuth + Zipkin | 链路追踪 | SkyWalking、Pinpoint |

【1. 服务注册与发现:Eureka / Nacos】

作用:服务的注册与发现,让各个服务能找到彼此。

为什么需要?

  • 微服务架构下,服务很多,地址和端口可能变化

  • 需要一个地方统一管理所有服务的地址信息

核心概念:

  • 服务注册:服务启动时,把自己的地址信息注册到注册中心

  • 服务发现:调用方从注册中心获取服务的地址列表

  • 心跳续约:服务定期发送心跳,证明自己还活着

  • 服务剔除:长时间没心跳的服务,从注册中心剔除

Eureka:

  • Netflix 开源,Spring Cloud 早期默认

  • AP 架构,保证可用性,不保证强一致性

  • 自我保护机制:网络故障时,不会剔除服务实例

Nacos:

  • 阿里开源,现在更流行

  • 同时支持 AP 和 CP 模式

  • 还支持配置中心,一个顶俩

  • 控制台更友好

【2. 负载均衡:Ribbon / LoadBalancer】

作用:客户端负载均衡,从服务列表中选择一个实例调用。

为什么需要?

  • 一个服务有多个实例,调用时选哪个?

  • 负载均衡器负责选择,分摊压力

负载均衡策略:

  • 轮询(Round Robin):默认,依次轮询

  • 随机(Random):随机选

  • 加权轮询:根据权重

  • 最少连接:选连接数最少的

  • 一致性哈希:相同请求到同一台

Ribbon:Netflix 开源,客户端负载均衡 LoadBalancer:Spring Cloud 自己的,Spring Cloud 2020 之后取代 Ribbon

【3. 声明式调用:OpenFeign】

作用:声明式的 HTTP 客户端,像调用本地方法一样调用远程服务。

为什么需要?

  • 用 RestTemplate 调用远程服务,代码比较繁琐

  • Feign 用接口 + 注解的方式,更优雅

使用方式:

@FeignClient("user-service")
public interface UserService {
    @GetMapping("/users/{id}")
    User getUserById(@PathVariable("id") Long id);
}

就像调用本地方法一样:userService.getUserById(1L),实际是远程调用。

特点:

  • 集成了 Ribbon/LoadBalancer,自带负载均衡

  • 集成了 Hystrix/Sentinel,支持熔断降级

  • 支持请求/响应压缩

  • 支持日志配置

【4. 熔断降级:Hystrix / Sentinel】

作用:服务容错保护,防止故障蔓延,保护系统稳定性。

为什么需要?

  • 微服务之间相互调用,一个服务挂了,可能导致调用它的服务也挂了

  • 像雪崩一样,整个系统都崩了

  • 需要熔断降级机制,保护系统

核心概念:

  • 熔断(Circuit Breaker):错误率达到阈值,直接熔断,不再调用

  • 降级(Fallback):调用失败或熔断时,返回兜底结果

  • 限流(Rate Limit):限制请求速率,保护服务

  • 隔离(Bulkhead):每个服务用独立的线程池,互不影响

Hystrix:Netflix 开源,经典的熔断器,现在进入维护模式 Sentinel:阿里开源,功能更丰富,现在更流行

【5. 网关:Zuul / Gateway】

作用:统一入口,所有请求先经过网关,再转发到后端服务。

为什么需要?

  • 微服务很多,客户端不能直接调用每个服务

  • 需要一个统一入口,做路由、鉴权、限流等

网关的功能:

  • 路由转发:根据路径转发到对应的服务

  • 认证鉴权:统一登录验证、权限检查

  • 限流熔断:限流、降级、熔断

  • 负载均衡:对后端服务负载均衡

  • 日志监控:统一日志、监控

  • 灰度发布:按比例路由

Zuul:Netflix 开源,基于 Servlet,阻塞式 Gateway:Spring 自己的,基于 WebFlux,响应式,性能更好

【6. 配置中心:Spring Cloud Config / Nacos Config】

作用:统一管理所有服务的配置,动态刷新。

为什么需要?

  • 微服务很多,每个服务都有配置文件

  • 修改配置要改每个服务,还要重启

  • 需要统一管理,动态刷新

功能:

  • 配置集中管理

  • 配置动态刷新(不用重启)

  • 配置版本管理

  • 多环境支持(dev/test/prod)

Spring Cloud Config:Spring 官方,基于 Git/SVN/数据库存储 Nacos Config:阿里的,和注册中心一起用,更方便 Apollo:携程开源,功能强大,企业级

【7. 链路追踪:Sleuth + Zipkin】

作用:分布式链路追踪,追踪一个请求经过了哪些服务,每个环节耗时多少。

为什么需要?

  • 微服务调用链很长,出问题了不知道在哪

  • 性能瓶颈不知道在哪

  • 需要链路追踪来定位问题

核心概念:

  • Trace:一次请求的完整链路,一个 Trace 有唯一 TraceID

  • Span:链路中的一个节点(一次调用),有唯一 SpanID

  • 每个服务打印日志时带上 TraceID 和 SpanID,就能串联起来

Sleuth:Spring Cloud 的,生成 TraceID/SpanID,打印到日志 Zipkin:Twitter 开源的,收集和展示链路数据 SkyWalking:国产,功能更强大,无侵入,现在很流行


高频追问

追问1:什么是服务雪崩?怎么解决?

服务雪崩:一个服务挂了,导致调用它的服务也挂了,像多米诺骨牌一样,整个系统都崩了。

原因:

  • 服务 A 调用服务 B,B 挂了

  • A 的请求都阻塞在等 B 响应

  • A 的线程都耗尽了,A 也挂了

  • 调用 A 的服务也跟着挂了...

  • 雪崩了

解决方法:

  1. 熔断:错误率高了就熔断,不再调用,直接返回

  2. 降级:调用失败返回兜底结果,不阻塞

  3. 限流:限制请求量,保护服务不被打垮

  4. 隔离:每个服务用独立的线程池,不互相影响

  5. 超时:设置超时时间,不无限等待

  6. 重试:偶尔失败可以重试,但要注意幂等性

核心思想:快速失败,不要让故障蔓延。

追问2:什么是服务熔断?什么是服务降级?区别?

服务熔断:

  • 当错误率达到阈值(比如 50%),直接熔断,不再调用

  • 像保险丝一样,电流太大就断开

  • 熔断后过一段时间会进入半开状态,尝试放行几个请求

  • 如果成功了就关闭熔断,失败了继续熔断

  • 目的:保护后端服务,防止故障扩大

服务降级:

  • 调用失败或者熔断时,返回一个兜底的结果

  • 比如推荐服务挂了,返回默认推荐列表

  • 目的:保证核心功能可用,牺牲非核心功能

区别:

  • 熔断是一种保护机制,防止故障蔓延

  • 降级是一种兜底方案,保证可用

  • 熔断通常会触发降级,但降级不一定是因为熔断

  • 熔断是自动的(达到阈值就熔断),降级可以是主动的

追问3:网关的作用是什么?为什么需要网关?

网关是微服务架构的统一入口,所有请求都经过网关。

作用:

  1. 路由转发:根据路径、参数等转发到对应的服务

  2. 统一认证:登录验证、Token 校验,不用每个服务都做

  3. 统一鉴权:权限检查

  4. 限流熔断:限流、降级、熔断,保护后端服务

  5. 负载均衡:对后端服务实例负载均衡

  6. 日志监控:统一日志、监控、统计

  7. 灰度发布:按比例路由到新版本,灰度发布

  8. 协议转换:HTTP 转 RPC 等

  9. 跨域处理:统一处理跨域

为什么需要?

  • 统一入口,客户端只需要知道网关地址

  • 统一处理通用逻辑(认证、限流等),不用每个服务都做

  • 保护内部服务,不直接暴露

追问4:Nacos 和 Eureka 的区别?

| 维度 | Eureka | Nacos | |------|--------|-------| | 出品方 | Netflix | 阿里巴巴 | | 一致性模型 | AP(只保证可用性) | 支持 AP 和 CP,可切换 | | 配置中心 | 不支持 | 支持(集成了配置中心) | | 控制台 | 比较简陋 | 功能丰富,界面友好 | | 健康检查 | 客户端心跳 | 支持多种(心跳、TCP、HTTP、MySQL 等) | | 权重/灰度 | 不支持 | 支持权重、灰度发布 | | 社区活跃度 | 2.x 停止开发 | 活跃,持续更新 | | 国内使用 | 越来越少 | 越来越多 |

现在国内基本都用 Nacos 了,功能更全,更符合国内需求。

追问5:什么是 CAP 定理?注册中心选 AP 还是 CP?

CAP 定理:分布式系统中,一致性(Consistency)、可用性(Availability)、分区容错性(Partition tolerance)三者不可兼得,最多满足两个。

  • C 一致性:所有节点数据一致

  • A 可用性:服务一直可用,正常响应

  • P 分区容错性:网络分区时,系统仍能工作

分布式系统中 P 是必须的,所以只能在 C 和 A 之间选。

注册中心选 AP 还是 CP?

一般选 AP(可用性优先)。

原因:

  • 注册中心最重要的是可用,不能挂

  • 即使数据暂时不一致,问题也不大(大不了调用一个已经挂了的实例,失败了重试)

  • 如果注册中心挂了,整个服务发现都用不了,影响更大

Eureka 是 AP 的,Nacos 默认也是 AP 的,可以切换到 CP。

但有些场景需要 CP,比如需要严格保证服务列表一致的场景。

📌 面试技巧:回答 Spring 相关问题时,要体现出你对 Spring 整体的理解,不要只背零散的知识点。比如讲 AOP 就联系到事务,讲 IOC 就联系到 bean 生命周期,讲自动装配就联系到 starter。知识串联起来,面试官会觉得你体系化掌握得很好。


第四章 MySQL(8题)


29. B+Tree 为什么适合数据库索引

标准答案

B+Tree 是 MySQL InnoDB 引擎默认的索引数据结构,它是 B 树的变种,专门为数据库索引优化设计。

【为什么不用其他数据结构】

1. 为什么不用数组?

  • 插入删除慢,需要移动元素

  • 范围查询可以,但插入删除性能差

2. 为什么不用链表?

  • 查询慢,需要遍历

  • 插入删除快,但查询慢

3. 为什么不用哈希表?

  • 等值查询很快 O(1)

  • 但不支持范围查询(>、<、between)

  • 不支持排序

  • 不支持最左前缀匹配

4. 为什么不用二叉搜索树?

  • 可能退化成链表(有序插入时)

  • 树的高度太高,IO 次数多

5. 为什么不用平衡二叉树(AVL/红黑树)?

  • 平衡了,不会退化

  • 但树的高度还是太高(每个节点只有两个子节点)

  • 数据量大时,树高几十层,每次查询几十次 IO

  • 磁盘 IO 是数据库性能瓶颈,要尽量减少 IO 次数

6. 为什么不用 B 树?

  • B 树每个节点可以有多个子节点,高度低

  • 但 B 树每个节点都存数据,非叶子节点也存数据

  • 同样大小的节点,B 树能存的 key 更少,高度更高

【B+Tree 的结构】

B+Tree 是多路平衡查找树,特点:

1. 多路(多叉)

  • 每个节点可以有多个子节点(几十到几百个)

  • 树的高度很低,通常 3-4 层就能存千万级数据

  • 高度低 = IO 次数少 = 性能好

2. 叶子节点才存数据

  • 非叶子节点只存索引 key 和指针,不存数据

  • 叶子节点存完整的数据(或主键值,取决于聚簇索引还是非聚簇索引)

  • 好处:非叶子节点更小,同样大小能存更多 key,树更矮

3. 叶子节点之间用链表连接

  • 所有叶子节点按 key 顺序排列,用双向链表连接

  • 范围查询非常方便:找到起点,顺着链表往后遍历就行

  • 排序也方便

4. 所有数据都在叶子节点

  • 每次查询都要走到叶子节点

  • 查询路径长度一致,性能稳定

  • B 树可能在非叶子节点就找到数据,性能不稳定

【B+Tree vs B 树】

| 维度 | B 树 | B+Tree | |------|------|--------| | 数据存储 | 所有节点都存数据 | 只有叶子节点存数据 | | 查询性能 | 不稳定,可能在中间层找到 | 稳定,每次都走到叶子 | | 范围查询 | 需要中序遍历,麻烦 | 叶子节点链表,直接遍历 | | 节点大小 | 同样大小存的 key 少 | 同样大小存的 key 多 | | 树的高度 | 更高 | 更矮 | | IO 次数 | 更多 | 更少 |

【为什么 B+Tree 适合数据库索引】

1. 树矮,IO 少

  • 每个节点可以有很多子节点(InnoDB 默认一页 16KB,能存几百个 key)

  • 千万级数据只要 3-4 层

  • 每次查询只要 3-4 次 IO

  • 磁盘 IO 是数据库最大的性能瓶颈,减少 IO 就是提升性能

2. 范围查询快

  • 叶子节点是有序链表

  • 找到起点后,顺着链表往后遍历就行

  • 不用中序遍历,效率高

3. 排序支持好

  • 叶子节点有序

  • order by、group by 都可以利用索引顺序

4. 查询性能稳定

  • 每次查询都走到叶子节点

  • 路径长度一致,性能稳定

  • B 树可能在上面就找到,也可能走到最下面,性能不稳定

5. 磁盘预读友好

  • 磁盘读取时,会预读一页数据(局部性原理)

  • B+Tree 节点大小和磁盘页大小一致(InnoDB 是 16KB)

  • 每次 IO 读一页,正好一个节点

  • 充分利用磁盘预读


高频追问

追问1:InnoDB 一页多大?为什么是 16KB?

InnoDB 默认一页大小是 16KB。

为什么是 16KB?

  • 太小:树变高,IO 次数变多

  • 太大:一页内数据太多,单页查询时间变长

  • 16KB 是一个经验值,平衡了树的高度和单页查询时间

可以通过 innodb_page_size 参数设置,默认 16KB,可选 4KB、8KB、16KB、32KB、64KB。

计算一下:假设一行数据 1KB,叶子节点一页存 16 行。非叶子节点存 key + 指针,假设 key 8 字节,指针 6 字节,一页能存约 1000 个 key。

  • 2 层树:1000 × 16 = 16000 行

  • 3 层树:1000 × 1000 × 16 = 1600 万行

  • 4 层树:1000 × 1000 × 1000 × 16 = 160 亿行

所以千万级数据 3 层就够了,只要 3 次 IO。

追问2:B+Tree 的插入和删除过程?

插入:

  1. 找到对应的叶子节点

  2. 插入数据,保持有序

  3. 如果叶子节点没满,直接插入

  4. 如果叶子节点满了(超过 16KB),分裂成两个节点

  5. 分裂后把中间的 key 提升到父节点

  6. 如果父节点也满了,继续分裂,向上传递

  7. 如果根节点也分裂了,树的高度 +1

删除:

  1. 找到对应的叶子节点

  2. 删除数据

  3. 如果节点数据太少(低于阈值,通常是 50%),尝试合并

  4. 先看兄弟节点能不能借一个(旋转)

  5. 兄弟也不够,就和兄弟节点合并

  6. 合并后父节点的 key 减少,可能导致父节点也需要合并

  7. 一直向上传递

插入和删除都要保持树的平衡,保证高度不会太高。

追问3:什么是聚簇索引和非聚簇索引?

聚簇索引(Clustered Index):

  • 索引和数据存在一起

  • 叶子节点存的是完整的行数据

  • 一张表只能有一个聚簇索引

  • InnoDB 的主键索引就是聚簇索引

  • 数据按主键顺序物理存储

非聚簇索引(Secondary Index,二级索引):

  • 索引和数据分开存

  • 叶子节点存的是主键值

  • 一张表可以有多个非聚簇索引

  • 查询时先查二级索引找到主键,再用主键查聚簇索引(回表)

详细对比见下一题。

追问4:哈希索引和 B+Tree 索引的区别?

| 维度 | 哈希索引 | B+Tree 索引 | |------|---------|-------------| | 等值查询 | O(1),非常快 | O(log n),也很快 | | 范围查询 | 不支持 | 支持,叶子链表 | | 排序 | 不支持 | 支持 | | 最左前缀 | 不支持 | 支持 | | 哈希冲突 | 有冲突问题 | 没有 | | 适用场景 | 纯等值查询 | 各种查询 |

MySQL 的 Memory 引擎支持哈希索引,InnoDB 有自适应哈希索引(自动优化,不用手动建)。

一般都用 B+Tree 索引,因为功能更全面。

追问5:什么是覆盖索引?

覆盖索引(Covering Index):查询的所有字段都在索引中,不需要回表查询。

比如有一个联合索引 (name, age):

SELECT name, age FROM user WHERE name = '张三';

这个查询只需要 name 和 age,索引里都有,直接从索引返回,不需要回表,就是覆盖索引。

好处:

  • 不需要回表,减少 IO

  • 性能更好

判断方法:explain 中 Extra 列显示 "Using index" 就是覆盖索引。


30. 聚簇索引与非聚簇索引

标准答案

聚簇索引和非聚簇索引是 InnoDB 中两种索引类型,核心区别在于叶子节点存的是什么。

【聚簇索引(Clustered Index)】

定义:索引和数据存储在一起,叶子节点存储完整的行数据。

特点:

  • 叶子节点存完整的行数据

  • 数据按主键顺序物理存储

  • 一张表只能有一个聚簇索引

  • InnoDB 中主键索引就是聚簇索引

  • 如果没有主键,InnoDB 会选一个唯一非空索引作为聚簇索引

  • 如果也没有,InnoDB 会隐式创建一个 row_id 作为聚簇索引

优点:

  • 查询快,找到索引就找到了数据,不需要回表

  • 范围查询快,叶子节点有序,直接遍历

  • 按主键排序和查找效率高

缺点:

  • 插入速度依赖主键顺序,乱序插入会导致页分裂,性能差

  • 二级索引需要存主键,主键越大,二级索引越大

  • 更新主键代价大,因为要移动数据

【非聚簇索引(Secondary Index,二级索引)】

定义:索引和数据分开存储,叶子节点存储的是主键值。

特点:

  • 叶子节点存主键值(不是完整数据)

  • 一张表可以有多个二级索引

  • 查询时需要回表:先查二级索引找到主键,再用主键查聚簇索引

  • 二级索引的叶子节点按索引字段排序

回表过程:

1. 查询二级索引 → 找到主键值
2. 用主键值查聚簇索引 → 找到完整行数据

两次 B+Tree 查询,比聚簇索引多一次。

【聚簇索引 vs 非聚簇索引】

| 维度 | 聚簇索引 | 非聚簇索引 | |------|---------|-----------| | 叶子节点存储 | 完整行数据 | 主键值 | | 数量 | 一张表只能一个 | 一张表可以多个 | | 是否需要回表 | 不需要 | 需要(除非覆盖索引) | | 查询速度 | 快,一次查询 | 慢,两次查询(回表) | | 物理存储 | 数据按索引顺序存储 | 索引和数据分开 | | 空间占用 | 索引就是数据,不额外占空间 | 额外占空间 | | 插入顺序影响 | 影响大,乱序插入页分裂 | 影响小 |

【MyISAM 和 InnoDB 的索引区别】

| 维度 | MyISAM | InnoDB | |------|--------|--------| | 聚簇索引 | 不支持,都是非聚簇 | 支持,主键是聚簇索引 | | 主键索引叶子 | 存数据地址 | 存完整数据 | | 二级索引叶子 | 存数据地址 | 存主键值 | | 回表 | 需要,但快(直接地址定位) | 需要,查聚簇索引 | | 主键顺序存储 | 不按顺序 | 按主键顺序物理存储 |

MyISAM 的索引都是非聚簇的,叶子节点存的是数据行的地址指针,通过地址直接定位数据。

InnoDB 的二级索引叶子节点存的是主键值,需要回表查聚簇索引。


高频追问

追问1:为什么主键建议用自增 ID?不用 UUID?

因为 InnoDB 的主键是聚簇索引,数据按主键顺序物理存储。

自增主键的好处:

  1. 顺序插入:每次插入都在最后一页,不会产生页分裂

  2. 性能好:没有页分裂,插入速度快

  3. 空间利用率高:页不会有碎片,利用率高

UUID 的问题:

  1. 乱序插入:UUID 是随机的,插入位置随机

  2. 页分裂频繁:插入到中间,页满了就要分裂

  3. 性能差:页分裂消耗性能,插入慢

  4. 空间利用率低:页分裂后有很多碎片,空间利用率低

  5. 主键大:UUID 16 字节,比 int(4 字节)大很多,二级索引也跟着变大

所以主键建议用自增的整型 ID,不要用 UUID。

追问2:二级索引为什么存主键值?不存数据地址?

原因:

  1. 数据地址会变:

    • 聚簇索引的数据可能因为页分裂、插入、删除等移动位置

    • 如果二级索引存地址,数据移动时所有二级索引都要更新,代价太大

    • 存主键值就没这个问题,主键一般不变

  2. 一致性好:

    • 主键是唯一标识,不会变

    • 数据移动不影响二级索引

  3. 空间权衡:

    • 存主键值需要回表,多一次查询

    • 但维护成本低,整体更优

这是一种权衡:用一次额外的查询,换取更低的维护成本。

追问3:什么是回表?怎么减少回表?

回表:通过二级索引找到主键,再用主键去聚簇索引查完整数据的过程。

回表需要两次 B+Tree 查询,性能比直接查聚簇索引差。

减少回表的方法:

  1. 覆盖索引:

    • 查询的字段都在索引中,不需要回表

    • 比如索引 (name, age),查询 SELECT name, age FROM ... 就是覆盖索引

    • explain 中 Extra 显示 "Using index"

  2. 用主键查询:

    • 直接用主键查,走聚簇索引,不需要回表

  3. 减少查询字段:

    • 只查需要的字段,不要 SELECT *

    • 更容易用到覆盖索引

追问4:什么是索引下推(ICP)?

索引下推(Index Condition Pushdown,ICP):把 where 条件下推到索引层面,在索引遍历的时候就过滤掉不符合条件的数据,减少回表次数。

举例:有联合索引 (name, age)

SELECT * FROM user WHERE name = '张三' AND age > 20;

没有 ICP 时:

  • 先用 name 找到所有 name='张三' 的主键

  • 回表查完整数据

  • 再用 age > 20 过滤

  • 回表次数多

有 ICP 时:

  • 遍历索引时,同时用 age > 20 过滤

  • 只把符合条件的主键回表

  • 回表次数少

ICP 是 MySQL 5.6 引入的优化,默认开启。 explain 中 Extra 显示 "Using index condition" 就是用了 ICP。

追问5:主键索引和唯一索引的区别?

| 维度 | 主键索引 | 唯一索引 | |------|---------|---------| | 数量 | 一张表只能一个 | 可以多个 | | 是否允许 NULL | 不允许 | 允许(但 NULL 不算重复) | | 是否聚簇 | InnoDB 中是聚簇索引 | 非聚簇索引(二级索引) | | 作用 | 唯一标识一行 | 保证字段值唯一 | | 查询性能 | 快(聚簇索引,不回表) | 稍慢(二级索引,可能回表) |

主键一定是唯一的,但唯一索引不一定是主键。 一张表只能有一个主键,但可以有多个唯一索引。


31. 联合索引最左前缀原则

标准答案

联合索引(复合索引)是指包含多个字段的索引,最左前缀原则是联合索引的重要规则。

【什么是最左前缀原则】

联合索引遵循最左前缀匹配原则:查询时从索引的最左边开始匹配,一直到遇到范围查询(>、<、between、like 前缀匹配)就停止匹配。

举例:有联合索引 (a, b, c)

| 查询条件 | 是否走索引 | 说明 | |----------|-----------|------| | WHERE a = 1 | ✅ 走 | 匹配最左列 a | | WHERE a = 1 AND b = 2 | ✅ 走 | 匹配 a 和 b | | WHERE a = 1 AND b = 2 AND c = 3 | ✅ 走 | 匹配 a、b、c | | WHERE b = 2 | ❌ 不走 | 跳过了 a,不满足最左前缀 | | WHERE c = 3 | ❌ 不走 | 跳过了 a 和 b | | WHERE b = 2 AND c = 3 | ❌ 不走 | 跳过了 a | | WHERE a = 1 AND c = 3 | ✅ 部分走 | a 走索引,c 不走(跳过了 b),c 用 ICP 过滤 | | WHERE a = 1 AND b > 2 AND c = 3 | ✅ 部分走 | a 和 b 走,c 不走(b 是范围查询,后面的 c 不匹配) | | WHERE a LIKE '张%' AND b = 2 | ✅ 部分走 | a 走,b 不走(like 前缀匹配是范围查询) |

【为什么叫最左前缀】

因为联合索引的 B+Tree 是按从左到右的顺序构建的:

  • 先按第一个字段排序

  • 第一个字段相同的,再按第二个字段排序

  • 第二个字段相同的,再按第三个字段排序

  • 以此类推

所以查询时必须从最左边开始,才能利用索引的有序性。

就像字典的索引:先按拼音首字母排序,再按第二个字母排序...你不能跳过第一个字母直接查第二个字母。

【联合索引的好处】

  1. 减少索引数量:一个联合索引相当于多个单列索引

  2. 覆盖索引:查询的字段都在索引中,不需要回表

  3. 提高查询效率:多个条件过滤,减少数据量

  4. 减少回表:更多字段在索引中,更少回表

【联合索引的字段顺序怎么定?】

原则:

  1. 等值查询的字段放前面:等值查询的字段可以一直匹配下去

  2. 范围查询的字段放后面:范围查询后面的字段不能匹配索引

  3. 区分度高的字段放前面:区分度高,过滤效果好

  4. 经常查询的字段放前面:让更多查询能用到索引

举例:经常用 a = ? AND b > ? 查询

  • a 是等值查询,b 是范围查询

  • 索引应该建 (a, b),不能是 (b, a)

  • 因为 b 是范围查询,后面的 a 就用不到索引了


高频追问

追问1:范围查询为什么会中断最左匹配?

因为联合索引是按顺序排序的:

  • 先按第一个字段排序

  • 第一个字段相同的,再按第二个字段排序

  • ...

如果前面的字段是范围查询(>、<、between),那后面的字段在索引中就不是有序的了。

举例:索引 (a, b)

a=1, b=1
a=1, b=2
a=1, b=3
a=2, b=1
a=2, b=2
a=3, b=1

如果查询 a > 1 AND b = 2:

  • a > 1 的结果是 a=2 和 a=3

  • 在 a=2 的范围内,b 是有序的

  • 在 a=3 的范围内,b 也是有序的

  • 但整体来看,b 不是有序的(a=2 的 b=2 和 a=3 的 b=1 乱序)

  • 所以不能用 b 的索引来快速定位

所以范围查询后面的字段不能走索引。

追问2:like '%xxx' 为什么不走索引?like 'xxx%' 为什么走?

B+Tree 索引是按字段值的顺序排列的,前缀匹配可以利用顺序,后缀匹配不行。

like 'xxx%'(前缀匹配):

  • 可以利用索引的有序性

  • 找到以 'xxx' 开头的位置,然后往后遍历

  • 相当于范围查询,走索引

like '%xxx'(后缀匹配):

  • 后缀不确定,无法利用索引的有序性

  • 只能全表扫描

  • 不走索引

like '%xxx%'(中间匹配):

  • 更不行,全表扫描

如果需要后缀匹配怎么办?

  • 可以把字段反过来存,查询的时候也反过来

  • 或者用全文索引

  • 或者用 ElasticSearch 等搜索引擎

追问3:为什么 OR 查询通常不走索引?

因为 OR 连接的条件,如果有一个字段没有索引,整个查询就不走索引。

举例:a 有索引,b 没有索引

SELECT * FROM user WHERE a = 1 OR b = 2;
  • a = 1 可以走索引

  • b = 2 没有索引,要全表扫描

  • 既然都要全表扫描了,那 a 也不走索引了

  • 直接全表扫描过滤

如果 a 和 b 都有索引呢?

  • MySQL 可能用 index merge(索引合并)

  • 分别查 a 和 b 的索引,然后合并结果

  • 但效率不一定高,不如联合索引

所以尽量用 AND,少用 OR。

追问4:什么情况下索引会失效?

常见的索引失效场景:

  1. 不满足最左前缀:联合索引跳过了前面的字段

  2. 索引列上用函数:WHERE YEAR(create_time) = 2023

  3. 索引列上有运算:WHERE age + 1 = 20

  4. 隐式类型转换:字符串字段用数字查询,WHERE name = 123

  5. like 前缀有 %:WHERE name LIKE '%张三'

  6. OR 连接的条件有非索引列:WHERE a = 1 OR b = 2(b 没索引)

  7. NOT IN / != / <>:这些通常不走索引(但要看优化器判断)

  8. IS NULL / IS NOT NULL:不一定,看数据分布

  9. 数据量太少:优化器觉得全表扫描更快

  10. 使用了函数转换:WHERE UPPER(name) = 'ZHANGSAN'

注意:不是绝对的,MySQL 优化器会根据数据量、选择性等综合判断。

追问5:怎么判断查询有没有走索引?

用 EXPLAIN 命令查看执行计划。

EXPLAIN SELECT * FROM user WHERE name = '张三';

关键看这些列:

  • type:访问类型,从好到差:system > const > eq_ref > ref > range > index > ALL

  • key:实际使用的索引

  • key_len:使用的索引长度

  • rows:预计扫描的行数

  • Extra:额外信息

如果 key 是 NULL,或者 type 是 ALL,就是没走索引,全表扫描。


32. 回表与覆盖索引

标准答案

回表和覆盖索引是 InnoDB 索引相关的两个重要概念。

【什么是回表】

InnoDB 的二级索引(非聚簇索引)叶子节点存的是主键值,不是完整数据。

查询时,如果需要的数据不在二级索引中,就需要:

  1. 先查二级索引,找到主键值

  2. 再用主键值查聚簇索引,拿到完整数据

这个过程就叫回表。

回表的过程:

二级索引 B+Tree → 找到主键值 → 聚簇索引 B+Tree → 找到完整行数据

两次 B+Tree 查询,比直接查聚簇索引多一次 IO。

举例:

-- name 有二级索引
SELECT * FROM user WHERE name = '张三';
  1. 查 name 索引,找到主键 id = 1

  2. 用 id = 1 查主键索引,拿到完整行数据

  3. 返回结果

这就是一次回表。

【什么是覆盖索引】

覆盖索引(Covering Index):查询需要的所有字段,都在索引中,不需要回表。

也就是说,索引"覆盖"了查询需要的所有字段。

举例: 有联合索引 (name, age)

SELECT name, age FROM user WHERE name = '张三';
  • 查询的字段是 name 和 age

  • 索引 (name, age) 里都有

  • 直接从索引返回,不需要回表

  • 这就是覆盖索引

怎么判断是不是覆盖索引:

  • EXPLAIN 的 Extra 列显示 Using index

  • 表示用了覆盖索引,直接从索引返回,不需要回表

【覆盖索引的好处】

  1. 性能好:不需要回表,减少一次 B+Tree 查询,减少 IO

  2. 减少 IO:索引比数据小,同样大小的内存能缓存更多索引

  3. 避免随机 IO:回表是随机 IO,覆盖索引是顺序 IO

  4. 减少内存占用:索引小,缓存命中率高

【怎么利用覆盖索引优化】

  1. 只查需要的字段:不要 SELECT *,只查需要的字段,更容易用到覆盖索引

  2. 建联合索引:把经常查询的字段建到联合索引中

  3. 利用最左前缀:查询条件和返回字段都在索引中

举例:

-- 经常查询:SELECT name, age FROM user WHERE name = ?
-- 建联合索引
CREATE INDEX idx_name_age ON user(name, age);

这样查询就走覆盖索引,不需要回表。


高频追问

追问1:回表一定慢吗?什么时候回表影响大?

不一定,要看回表的次数。

  • 回表次数少:比如根据唯一索引查一条,回表一次,影响不大

  • 回表次数多:比如范围查询,返回很多行,每行都要回表,性能就差了

为什么回表次数多就慢?

  • 二级索引是按索引字段顺序排列的,顺序读取

  • 回表查聚簇索引是按主键查的,主键可能不连续

  • 每次回表都是随机 IO

  • 随机 IO 比顺序 IO 慢很多

所以:

  • 少量数据回表 → 影响不大

  • 大量数据回表 → 性能差很多,这时候覆盖索引就很有价值

追问2:SELECT * 为什么不好?

  1. 用不到覆盖索引:SELECT * 要所有字段,索引不可能包含所有字段,一定要回表

  2. 浪费带宽:返回不需要的字段,浪费网络带宽

  3. 浪费内存:不需要的字段也加载到内存,浪费空间

  4. 维护性差:表结构变了,SELECT * 可能返回多余的字段

优化建议:

  • 只查需要的字段

  • 尽量让查询能用到覆盖索引

追问3:覆盖索引和索引下推的区别?

| 维度 | 覆盖索引 | 索引下推(ICP) | |------|---------|----------------| | 作用 | 不需要回表,直接从索引返回 | 减少回表次数,在索引层过滤 | | Extra 显示 | Using index | Using index condition | | 性能提升 | 大,完全不回表 | 中,减少回表次数 | | 原理 | 所有字段都在索引中 | where 条件下推到索引层过滤 |

覆盖索引:完全不回表,直接从索引返回数据。 索引下推:还是要回表,但回表的次数少了(在索引层先过滤了一部分)。

两者可以同时存在,都是优化手段。

追问4:count(*) 会走索引吗?用什么索引?

会走索引,MySQL 会选择最小的索引来 count。

为什么?

  • count(*) 只需要数行数,不需要数据

  • 选最小的索引,占用空间小,扫描更快

  • 一般选最小的二级索引(比如唯一索引、普通索引)

  • 不会选聚簇索引(聚簇索引大,扫描慢)

所以 count(*) 通常会走二级索引,而且是最小的那个二级索引。

这也是一种覆盖索引的应用:只需要数行数,索引里就有足够的信息。

追问5:联合索引的字段顺序,考虑覆盖索引的话怎么排?

考虑覆盖索引时,联合索引的字段顺序要综合考虑:

  1. 查询条件的字段放前面:满足最左前缀,where 条件能用索引

  2. 返回的字段放后面:让返回字段也在索引中,实现覆盖索引

  3. 等值查询放前面,范围查询放后面:范围查询后面的字段不能走索引

举例:

-- 查询:SELECT name, age FROM user WHERE name = ? AND age > ?
-- 索引:(name, age)
  • name 是等值查询,放前面

  • age 是范围查询,放后面

  • 返回的 name 和 age 都在索引中

  • 覆盖索引,不需要回表

如果索引是 (age, name):

  • age 是范围查询,后面的 name 不能走索引

  • 只能用 age 过滤,name 不能用索引

  • 而且 name 虽然在索引中,但查询条件不能充分利用索引

所以顺序很重要,要综合考虑查询条件和返回字段。


33. MySQL 为什么选择 MVCC

标准答案

MVCC(Multi-Version Concurrency Control,多版本并发控制)是 InnoDB 实现隔离级别的一种机制,让读写不冲突,提高并发性能。

【什么是 MVCC】

MVCC 的核心思想:每行数据有多个版本,读操作读历史版本,写操作创建新版本,读写不冲突。

  • 读:读数据的历史版本(快照),不需要加锁

  • 写:创建新版本,加锁

  • 读写不冲突,大大提高并发性能

【为什么需要 MVCC】

没有 MVCC 的情况下:

  • 读要加共享锁,写要加排他锁

  • 读写互相阻塞

  • 并发性能差

有了 MVCC:

  • 读不加锁,读的是历史版本

  • 写加锁,创建新版本

  • 读写不冲突

  • 并发性能好

MVCC 主要解决的问题:

  1. 读写冲突:读不阻塞写,写不阻塞读

  2. 提高并发:不用加锁就能读到一致性的数据

  3. 实现隔离级别:实现 READ COMMITTED 和 REPEATABLE READ 隔离级别

【MVCC 的实现原理】

MVCC 基于三个东西实现:

  1. 隐藏字段:每行数据有几个隐藏字段

  2. undo log:保存数据的历史版本

  3. Read View:读视图,判断哪个版本可见

1. 隐藏字段

InnoDB 每行数据都有几个隐藏字段:

  • DB_TRX_ID:最后修改这行数据的事务 ID

  • DB_ROLL_PTR:回滚指针,指向 undo log 中的上一个版本

  • DB_ROW_ID:隐藏的行 ID(如果没有主键的话)

2. undo log(回滚日志)

  • 每次修改数据时,都会把修改前的数据存到 undo log 中

  • 通过 DB_ROLL_PTR 指针,把各个版本串成一个链表(版本链)

  • 这样就能找到历史版本

3. Read View(读视图)

Read View 是一个读视图,用来判断当前事务能看到哪些版本的数据。

Read View 包含四个重要信息:

  • m_ids:生成 Read View 时,当前活跃的事务 ID 列表(还没提交的)

  • min_trx_id:活跃事务中最小的事务 ID

  • max_trx_id:生成 Read View 时,下一个要分配的事务 ID(最大的 +1)

  • creator_trx_id:生成这个 Read View 的事务的 ID

【可见性判断规则】

查询一个版本时,按以下规则判断是否可见:

  1. 如果版本的 trx_id < min_trx_id → 可见(这个事务在 Read View 生成前就提交了)

  2. 如果版本的 trx_id >= max_trx_id → 不可见(这个事务在 Read View 生成后才开始)

  3. 如果版本的 trx_id 在 m_ids 中 → 不可见(这个事务还没提交)

  4. 如果版本的 trx_id 不在 m_ids 中,且在 min 和 max 之间 → 可见(这个事务已经提交了)

简单说:生成 Read View 时已经提交的事务的修改可见,没提交的不可见,之后开始的也不可见。

如果当前版本不可见,就顺着版本链找前一个版本,继续判断,直到找到可见的版本。

【不同隔离级别下的 MVCC】

MVCC 在不同隔离级别下,Read View 的生成时机不同:

READ COMMITTED(读已提交):

  • 每次 SELECT 都生成一个新的 Read View

  • 所以每次 SELECT 都能看到最新提交的数据

  • 所以会有不可重复读的问题

REPEATABLE READ(可重复读):

  • 事务中第一次 SELECT 时生成一个 Read View

  • 之后的 SELECT 都用同一个 Read View

  • 所以整个事务中读到的数据都是一致的

  • 解决了不可重复读的问题

这就是两种隔离级别的核心区别:Read View 的生成时机不同。


高频追问

追问1:MVCC 和锁的关系?

MVCC 和锁是配合使用的:

  • 读操作:用 MVCC,不加锁,读历史版本

  • 写操作:加锁(行锁),创建新版本

MVCC 解决的是读写冲突的问题,让读写不阻塞。 写写冲突还是要靠锁来解决。

另外:

  • 快照读(普通 SELECT):走 MVCC,不加锁

  • 当前读(SELECT ... FOR UPDATE、INSERT、UPDATE、DELETE):加锁,读最新版本

追问2:什么是快照读和当前读?

快照读(Snapshot Read):

  • 普通的 SELECT 语句

  • 读的是快照版本(历史版本)

  • 走 MVCC,不加锁

  • 性能好

当前读(Current Read):

  • SELECT ... FOR UPDATE

  • SELECT ... LOCK IN SHARE MODE

  • INSERT、UPDATE、DELETE

  • 读的是最新版本

  • 需要加锁

  • 性能稍差

简单说:普通 SELECT 是快照读,加锁的和写操作是当前读。

追问3:MVCC 能解决幻读吗?

在 REPEATABLE READ 隔离级别下:

  • 快照读:MVCC 解决了幻读(因为用同一个 Read View,读到的行数不变)

  • 当前读:MVCC 不能解决幻读,需要用 Next-Key Lock(间隙锁 + 行锁)来解决

所以 InnoDB 的 REPEATABLE READ 隔离级别,实际上通过 MVCC + Next-Key Lock 解决了幻读问题。

注意:标准 SQL 的 REPEATABLE READ 是不能解决幻读的,InnoDB 通过 Next-Key Lock 额外解决了。

追问4:undo log 和 redo log 的区别?

| 维度 | undo log | redo log | |------|---------|---------| | 作用 | 回滚数据,MVCC 版本链 | 崩溃恢复,保证持久性 | | 内容 | 修改前的数据 | 修改后的数据 | | 记录方式 | 逻辑日志 | 物理+逻辑日志 | | 写入时机 | 修改数据前 | 修改数据后,提交前 | | 循环写 | 不是,不断追加 | 是,固定大小,循环写 | | 配合 | MVCC、事务回滚 | WAL、崩溃恢复 |

简单记:

  • undo log:回滚用的,存旧数据

  • redo log:重做用的,存新数据

追问5:Read View 在 RC 和 RR 下有什么不同?

核心区别:生成时机不同

READ COMMITTED(读已提交):

  • 每次 SELECT 都生成一个新的 Read View

  • 每次都能看到最新提交的数据

  • 所以同一个事务中,两次 SELECT 可能读到不同的数据(不可重复读)

REPEATABLE READ(可重复读):

  • 事务中第一次 SELECT 时生成 Read View

  • 之后的 SELECT 都复用这个 Read View

  • 所以整个事务中读到的数据都是一致的

  • 解决了不可重复读

这就是两种隔离级别的本质区别,其他都一样。


34. InnoDB 锁机制(行锁、间隙锁、Next-Key Lock)

标准答案

InnoDB 支持多种锁,用来保证并发下的数据一致性。

【锁的分类】

按粒度分:

  • 全局锁:锁整个数据库

  • 表级锁:锁整张表

  • 行级锁:锁某一行

按模式分:

  • 共享锁(S 锁,读锁):多个事务可以同时加 S 锁,互斥 X 锁

  • 排他锁(X 锁,写锁):只有一个事务能加 X 锁,互斥所有锁

按算法分(行锁):

  • 记录锁(Record Lock):锁单个记录

  • 间隙锁(Gap Lock):锁间隙,不锁记录本身

  • Next-Key Lock:记录锁 + 间隙锁,左开右闭区间

【行锁(Record Lock)】

定义:锁定某一行记录。

什么时候加行锁:

  • UPDATE、DELETE、INSERT 语句自动加 X 锁

  • SELECT ... FOR UPDATE 手动加 X 锁

  • SELECT ... LOCK IN SHARE MODE 手动加 S 锁

注意:行锁是加在索引上的,不是加在记录上的。如果没有索引,会升级为表锁。

【间隙锁(Gap Lock)】

定义:锁定两个索引之间的间隙,不锁记录本身。

作用:防止幻读。

为什么需要间隙锁?

如果只有行锁,事务 A 查了 id > 5 的记录,事务 B 插入一条 id = 6 的记录,事务 A 再查就多了一条,这就是幻读。

间隙锁把间隙也锁上,不让插入新数据,就防止了幻读。

间隙锁的特点:

  • 只在 REPEATABLE READ 隔离级别下才有

  • 锁的是间隙,不是记录

  • 间隙锁之间不互斥(你锁你的,我锁我的,大家都锁同一个间隙也没关系)

  • 间隙锁和插入操作互斥(不能往被锁住的间隙里插入)

间隙范围:

  • 索引上的两个值之间的区间

  • 包括负无穷到第一个值,最后一个值到正无穷

【Next-Key Lock】

定义:记录锁 + 间隙锁,锁定一个左开右闭的区间。

默认的行锁算法:InnoDB 默认用 Next-Key Lock 作为行锁算法。

举例:索引上有 10、20、30 三个值

  • Next-Key Lock 区间:(负无穷, 10]、(10, 20]、(20, 30]、(30, 正无穷)

加锁规则(简化版):

  1. 加锁的基本单位是 Next-Key Lock(左开右闭)

  2. 查找过程中访问到的对象才会加锁

  3. 唯一索引等值查询,命中记录 → 退化为记录锁

  4. 唯一索引等值查询,没命中 → 退化为间隙锁

  5. 普通索引等值查询,命中记录 → 还要往后找,直到第一个不满足条件的,加间隙锁

加锁规则(详细版,两个原则 + 两个优化):

  • 原则 1:加锁的基本单位是 Next-Key Lock,左开右闭区间

  • 原则 2:查找过程中访问到的对象才会加锁

  • 优化 1:唯一索引等值查询,命中记录 → Next-Key Lock 退化为记录锁

  • 优化 2:普通索引等值查询,向右遍历到第一个不满足条件的 → Next-Key Lock 退化为间隙锁

【锁的兼容性】

| | S 锁 | X 锁 | |---|------|------| | S 锁 | 兼容 | 互斥 | | X 锁 | 互斥 | 互斥 |

间隙锁之间是兼容的(两个事务可以同时锁同一个间隙),间隙锁和插入操作是互斥的。


高频追问

追问1:什么是幻读?怎么解决的?

幻读:同一个事务中,两次相同的查询,第二次查到了第一次没有的行(或者少了行),像出现了幻觉一样。

注意:幻读特指"新增的行"。更新导致的变化是不可重复读,不是幻读。

解决幻读的方法:

  1. MVCC(快照读):

    • 普通 SELECT 是快照读

    • 用同一个 Read View,读到的行数不变

    • 解决了快照读的幻读问题

  2. Next-Key Lock(当前读):

    • SELECT ... FOR UPDATE 等当前读

    • 用 Next-Key Lock(行锁 + 间隙锁)

    • 把间隙也锁上,不让插入新数据

    • 解决了当前读的幻读问题

所以 InnoDB 的 RR 隔离级别,通过 MVCC + Next-Key Lock 解决了幻读。

追问2:间隙锁为什么会导致死锁?

间隙锁之间是兼容的,两个事务可以同时锁住同一个间隙。

但间隙锁和插入操作是互斥的。

死锁场景:

  1. 事务 A 加了间隙锁(锁住 (5, 10) 这个间隙)

  2. 事务 B 也加了间隙锁(也锁住 (5, 10),因为间隙锁之间兼容)

  3. 事务 A 往 (5, 10) 插入 → 等事务 B 释放间隙锁

  4. 事务 B 往 (5, 10) 插入 → 等事务 A 释放间隙锁

  5. 互相等待 → 死锁

这就是间隙锁导致的死锁,很常见。

怎么避免?

  • 降低隔离级别到 RC(没有间隙锁)

  • 尽量用唯一索引等值查询(退化为记录锁,没有间隙)

  • 控制事务大小,减少锁的持有时间

追问3:行锁是加在数据上还是索引上?

行锁是加在索引上的,不是加在数据行上。

为什么?

  • InnoDB 的数据是按索引组织的(聚簇索引)

  • 行锁通过索引来定位和锁定

  • 如果查询没有走索引,就不能加行锁,会升级为表锁

举例:

-- name 没有索引
UPDATE user SET age = 20 WHERE name = '张三';
  • 因为 name 没有索引,不能定位到具体行

  • 会全表扫描,给所有行加锁(相当于表锁)

  • 性能很差

所以:没有索引,行锁会变成表锁。一定要让查询走索引。

追问4:什么是意向锁?

意向锁是表级锁,用来表示"某个事务打算在表中的行上加锁"。

  • 意向共享锁(IS):打算给行加 S 锁

  • 意向排他锁(IX):打算给行加 X 锁

为什么需要意向锁?

如果没有意向锁,一个事务想加表锁,需要检查每一行有没有行锁,效率很低。

有了意向锁:

  • 加行锁前,先加对应的意向锁

  • 加表锁时,只需要检查意向锁就行

  • 快速判断能不能加表锁

意向锁的兼容性:

| | IS | IX | S | X | |---|----|----|---|---| | IS | 兼容 | 兼容 | 兼容 | 互斥 | | IX | 兼容 | 兼容 | 互斥 | 互斥 | | S | 兼容 | 互斥 | 兼容 | 互斥 | | X | 互斥 | 互斥 | 互斥 | 互斥 |

意向锁之间都是兼容的,因为它们只是"意向",不真正锁数据。 意向锁和表级的 S/X 锁才会互斥。

追问5:怎么查看锁的情况?

常用命令:

-- 查看当前正在等待的锁
SHOW STATUS LIKE 'innodb_row_lock%';

-- 查看 InnoDB 状态,包含锁信息
SHOW ENGINE INNODB STATUS;

-- 查看当前事务
SELECT * FROM information_schema.INNODB_TRX;

-- 查看当前锁
SELECT * FROM information_schema.INNODB_LOCKS;

-- 查看锁等待
SELECT * FROM information_schema.INNODB_LOCK_WAITS;

MySQL 8.0 之后,information_schema 里的锁表被移到 performance_schema 了:

SELECT * FROM performance_schema.data_locks;
SELECT * FROM performance_schema.data_lock_waits;

35. SQL 优化思路

标准答案

SQL 优化是数据库性能优化的重要部分,核心思路是:减少扫描行数,减少回表,减少排序。

【SQL 优化的整体思路】

1. 先看慢查询日志,找到慢 SQL
   ↓
2. 用 EXPLAIN 分析执行计划
   ↓
3. 看 type、key、rows、Extra 等关键字段
   ↓
4. 判断问题在哪(全表扫描?回表多?文件排序?)
   ↓
5. 针对性优化(建索引?改 SQL?改表结构?)
   ↓
6. 验证优化效果

【常见优化手段】

1. 索引优化(最重要)

  • 给经常查询的字段建索引

  • 给 where、join、order by、group by 的字段建索引

  • 建联合索引,利用最左前缀

  • 尽量让查询走覆盖索引,减少回表

  • 删除没用的索引(索引不是越多越好)

2. SQL 语句优化

  • 避免 SELECT *,只查需要的字段

  • 避免在索引列上用函数、运算

  • 避免隐式类型转换

  • 用 UNION ALL 代替 OR(OR 可能导致索引失效)

  • 小表驱动大表(join 时小表在前)

  • 避免深分页(limit 100000, 10)

  • 用 EXISTS 代替 IN(看具体场景)

  • 批量操作代替循环单条操作

3. 表结构优化

  • 选择合适的数据类型(小而简单)

  • 字段尽量 NOT NULL(NULL 会让索引、统计、比较更复杂)

  • 用整型存 IP(INET_ATON)

  • 避免大字段(TEXT、BLOB 尽量少用)

  • 适当冗余字段(空间换时间,减少 join)

4. 架构层面优化

  • 读写分离:主库写,从库读

  • 分库分表:数据量大时水平/垂直拆分

  • 缓存:Redis 缓存热点数据

  • 搜索引擎:复杂查询用 ES

  • 连接池:优化数据库连接

【索引优化的原则】

  1. 最左前缀原则:联合索引从最左开始匹配

  2. 区分度高的字段建索引:区分度低的(比如性别)建索引效果差

  3. 索引不是越多越好:索引加快查询,但减慢写入

  4. 尽量用覆盖索引:减少回表

  5. 避免索引失效:不要在索引列上做函数、运算等

  6. 字符串索引考虑前缀索引:长字符串建前缀索引,节省空间


高频追问

追问1:深分页怎么优化?

深分页:LIMIT 100000, 10,要扫描 100010 行,然后返回最后 10 行,前面的都白扫了。

优化方法:

1. 主键范围分页(推荐)

-- 上一页最后一个 id 是 100000
SELECT * FROM user WHERE id > 100000 ORDER BY id LIMIT 10;
  • 用 id 范围过滤,直接从 100001 开始扫

  • 性能好很多

2. 子查询 + 覆盖索引

SELECT * FROM user 
WHERE id >= (SELECT id FROM user ORDER BY id LIMIT 100000, 1)
LIMIT 10;
  • 子查询用覆盖索引找到起始 id

  • 再用 id 范围查

  • 比直接 limit 快

3. 业务上限制

  • 不让用户翻到那么深的页

  • 比如最多翻 100 页

  • 或者用"加载更多"代替页码

追问2:count(*)、count(1)、count(列名) 的区别?

| 写法 | 说明 | 性能 | |------|------|------| | count() | 统计行数,包含 NULL | 快(优化过) | | count(1) | 统计行数,包含 NULL | 和 count() 差不多 | | count(列名) | 统计该列非 NULL 的行数 | 稍慢(需要判断 NULL) |

InnoDB 中:

  • count(*) 和 count(1) 性能差不多,MySQL 都做了优化

  • count(列名) 要判断 NULL,稍慢

  • 都会选择最小的索引来扫描

MyISAM 中:

  • count(*) 特别快,因为 MyISAM 存了总行数

  • 但有 where 条件时就不快了

注意:count() 是 SQL 标准的统计行数的写法,推荐用 count()。

追问3:JOIN 查询怎么优化?

  1. 小表驱动大表:

    • 小表作为驱动表,大表作为被驱动表

    • 减少循环次数

    • MySQL 优化器会自动选,但有时选不对,可以用 STRAIGHT_JOIN 强制

  2. 被驱动表的关联字段建索引:

    • join 的字段要建索引

    • 最好是被驱动表的关联字段有索引

    • 这样每次 join 都能走索引,不用全表扫描

  3. 尽量用同类型字段 join:

    • 避免隐式类型转换

    • 类型转换会导致索引失效

  4. 减少 join 的表数量:

    • 表越多,优化器越难选最优执行计划

    • 超过 3 张表就要考虑是不是设计有问题

    • 适当冗余字段,减少 join

  5. 用 EXISTS 代替 IN:

    • 外层表小,内层表大 → 用 EXISTS

    • 外层表大,内层表小 → 用 IN

    • 现在 MySQL 优化器已经很智能了,差别不大

追问4:为什么要避免 NULL?NULL 有什么问题?

  1. 索引问题:

    • NULL 值会让索引统计更复杂

    • 优化器更难判断选择性

    • IS NULL / IS NOT NULL 可能导致索引失效

  2. 比较问题:

    • NULL 和任何值比较都是 NULL(未知)

    • 比如 NULL = NULL 结果是 NULL,不是 TRUE

    • 容易出 bug

  3. 函数问题:

    • 很多函数遇到 NULL 结果也是 NULL

    • 比如 CONCAT('a', NULL) = NULL

  4. 空间问题:

    • NULL 需要额外的空间来标记

    • 每行有一个 NULL 位图

所以建表时尽量给字段设默认值,避免 NULL。

但也不是绝对的,有些场景 NULL 是合理的(比如"未知"的状态)。

追问5:怎么判断要不要建索引?

判断标准:

  1. 查询频率:

    • 经常查询的字段建索引

    • 很少查询的不用建

  2. 区分度:

    • 区分度高的字段建索引效果好

    • 区分度低的(比如性别,只有男/女)建索引效果差,不如全表扫

    • 一般区分度 > 30% 才考虑建索引

  3. 数据量:

    • 数据量大的表建索引效果明显

    • 小表(几千行)全表扫描也很快,不用建索引

  4. 写入频率:

    • 写入少、查询多 → 可以多建索引

    • 写入多 → 索引要少(索引减慢写入)

  5. 联合索引优先:

    • 多个字段经常一起查询 → 建联合索引

    • 比多个单列索引好

简单说:经常查、区分度高、数据量大、写入少 → 建索引。


36. explain 如何分析 SQL

标准答案

EXPLAIN 是 MySQL 提供的 SQL 执行计划分析工具,可以查看 SQL 的执行方式,判断是否走索引、扫描行数等。

【EXPLAIN 的使用】

EXPLAIN SELECT * FROM user WHERE name = '张三';

在 SELECT 前面加 EXPLAIN 就行。

MySQL 5.7 之后,还可以用 EXPLAIN ANALYZE 实际执行并分析(MySQL 8.0.18+)。

【EXPLAIN 输出字段】

| 字段 | 说明 | |------|------| | id | 查询编号,id 越大越先执行,相同则从上到下 | | select_type | 查询类型(SIMPLE、PRIMARY、SUBQUERY 等) | | table | 访问的表 | | partitions | 匹配的分区 | | type | 访问类型(最重要,看性能好坏) | | possible_keys | 可能用到的索引 | | key | 实际用到的索引 | | key_len | 使用的索引长度 | | ref | 索引比较的列或常量 | | rows | 预计扫描的行数(估算值) | | filtered | 过滤后的行数百分比 | | Extra | 额外信息(很重要) |

【重点字段详解】

1. type(访问类型)

从好到差排序:

system > const > eq_ref > ref > range > index > ALL

| type | 说明 | 场景 | |------|------|------| | system | 系统表,只有一行 | 很少见 | | const | 常量,主键或唯一索引等值查询,只匹配一行 | 主键查询 | | eq_ref | 唯一索引扫描,每行只匹配一条 | 主键/唯一索引 join | | ref | 非唯一索引扫描,可能匹配多行 | 普通索引等值查询 | | range | 范围扫描 | between、>、<、in 等 | | index | 全索引扫描,扫描整个索引树 | 比 ALL 好,索引比数据小 | | ALL | 全表扫描 | 最差,要优化 |

目标:至少要到 range 级别,最好是 ref 或 eq_ref。

2. key(实际索引)

  • 实际使用的索引名称

  • 如果是 NULL,就是没走索引

  • possible_keys 是可能用到的,key 是实际用到的

3. key_len(索引长度)

  • 使用的索引的字节数

  • 可以判断用了联合索引的哪几个字段

  • 越短越好(但要满足查询需求)

4. rows(扫描行数)

  • 预计扫描的行数

  • 越少越好

  • 注意是估算值,不是精确值

5. Extra(额外信息)

常见值:

| Extra | 说明 | 好坏 | |-------|------|------| | Using index | 覆盖索引,不需要回表 | ✅ 好 | | Using where | 用 where 过滤 | 正常 | | Using index condition | 索引下推(ICP) | ✅ 好 | | Using filesort | 文件排序,需要额外排序 | ❌ 不好,要优化 | | Using temporary | 用了临时表 | ❌ 不好,要优化 | | Using join buffer | join 用了连接缓存 | 一般 | | Impossible WHERE | where 条件永远不成立 | - | | Select tables optimized away | 优化器直接返回结果 | ✅ 好 |

【常见问题及优化方向】

  1. type = ALL → 全表扫描 → 建索引

  2. key = NULL → 没走索引 → 建索引或改 SQL

  3. Extra = Using filesort → 文件排序 → 让排序走索引

  4. Extra = Using temporary → 临时表 → 优化 group by 或 distinct

  5. rows 太大 → 扫描行数太多 → 加索引或优化条件


高频追问

追问1:type 列的各个值是什么意思?哪个最好?

从好到差:

  1. system:系统表,只有一行数据,最快

  2. const:常量查询,主键或唯一索引等值查询,只匹配一行

  3. eq_ref:唯一索引扫描,join 时用主键或唯一索引关联,每行只匹配一条

  4. ref:非唯一索引扫描,普通索引等值查询,可能匹配多行

  5. range:范围扫描,between、>、<、in、like 前缀匹配等

  6. index:全索引扫描,扫描整个索引树,比全表扫描好(索引小)

  7. ALL:全表扫描,最差

一般来说:

  • 至少要到 range 级别

  • 最好是 ref 或 eq_ref

  • ALL 必须优化

追问2:key_len 怎么算?怎么判断用了联合索引的几个字段?

key_len 是索引使用的字节数,可以判断用了联合索引的几个字段。

计算规则:

  • 字符串类型:

    • char(n):n × 字符集字节数(utf8 是 3,utf8mb4 是 4)

    • varchar(n):n × 字符集字节数 + 2(变长)+ 1(NULL 标记)

  • 数值类型:

    • tinyint:1 字节

    • int:4 字节

    • bigint:8 字节

  • 日期类型:

    • date:3 字节

    • datetime:5 字节(5.6+)

  • 允许 NULL:+1 字节

举例:

  • 索引 (name varchar(20), age int)

  • name 是 varchar(20),utf8mb4,允许 NULL → 20×4 + 2 + 1 = 83

  • age 是 int,允许 NULL → 4 + 1 = 5

  • 整个索引长度:83 + 5 = 88

如果 key_len = 83 → 只用了 name 字段 如果 key_len = 88 → 用了 name 和 age 两个字段

追问3:Using filesort 是什么?怎么优化?

Using filesort:排序时用了文件排序(不一定是文件,可能在内存中),不是用索引排序。

为什么不好?

  • 需要额外的排序操作

  • 数据量大时性能差

优化方法:

  1. 让排序字段在索引中:

    • order by 的字段建索引

    • 联合索引中包含排序字段

    • 利用索引的有序性,直接按索引顺序返回

  2. 减少排序的数据量:

    • 先过滤再排序

    • 用 where 条件减少数据量

  3. 调整 sort_buffer_size:

    • 让排序在内存中完成,不用临时文件

    • 但不能太大,否则占用内存过多

追问4:Using temporary 是什么?怎么优化?

Using temporary:使用了临时表,常见于 group by、distinct、union 等操作。

为什么不好?

  • 创建临时表有开销

  • 数据量大时可能用磁盘临时表,更慢

优化方法:

  1. 让 group by 走索引:

    • group by 的字段建索引

    • 利用索引的有序性,直接分组,不用临时表

  2. 尽量用索引覆盖:

    • 查询的字段都在索引中

    • 减少回表

  3. 优化 group by:

    • 用 order by null 禁止隐式排序(MySQL 8.0 已经默认不排序了)

    • 减少分组字段

  4. 调整 tmp_table_size:

    • 增大内存临时表大小

    • 避免用磁盘临时表

追问5:怎么判断 SQL 有没有走索引?

看 EXPLAIN 的几个字段:

  1. type 列:

    • const、eq_ref、ref、range、index → 走索引了

    • ALL → 没走索引,全表扫描

  2. key 列:

    • 有值 → 走了这个索引

    • NULL → 没走索引

  3. key_len 列:

    • 有值 → 走了索引

    • 可以看出用了索引的几个字段

  4. Extra 列:

    • Using index → 覆盖索引,走了索引且不回表

    • Using index condition → 索引下推,走了索引

综合判断:key 列有值,type 不是 ALL,就是走索引了。

📌 面试技巧:回答 MySQL 相关问题时,一定要结合实际优化经验来说,不要只背理论。比如讲索引就说"我之前优化过一个慢查询,原来是全表扫描,加了联合索引之后从 2 秒降到 10 毫秒"。有实际案例,面试官会觉得你真的做过。


第五章 Redis(8题)


37. Redis 常见数据结构及应用场景

标准答案

Redis 是一个高性能的 key-value 存储系统,支持多种数据结构。

【五大基本数据结构】

| 数据结构 | 说明 | 底层实现 | 应用场景 | |----------|------|----------|----------| | String | 字符串,最基础的类型 | 简单动态字符串(SDS) | 缓存、计数器、分布式锁、Session | | List | 列表,双向链表 | quicklist(ziplist + linkedlist) | 消息队列、排行榜、最新列表 | | Hash | 哈希表,键值对集合 | ziplist / hashtable | 对象缓存、购物车 | | Set | 无序集合,元素不重复 | intset / hashtable | 去重、交集并集差集、标签 | | ZSet | 有序集合,每个元素有分数 | ziplist / skiplist + hashtable | 排行榜、延时队列、范围查找 |

【详细说明】

1. String(字符串)

  • 最基础的类型,value 是字符串

  • 最大支持 512MB

  • 底层是 SDS(Simple Dynamic String,简单动态字符串)

  • SDS 特点:预分配空间、二进制安全、O(1) 获取长度

应用场景:

  • 缓存:缓存用户信息、商品信息

  • 计数器:文章阅读数、点赞数(INCR/DECR)

  • 分布式锁:SETNX 实现

  • Session:用户登录态

  • 限流:INCR + 过期时间

常用命令: SET、GET、INCR、DECR、SETNX、SETEX、APPEND、STRLEN

2. List(列表)

  • 双向链表,可以从两端操作

  • 底层是 quicklist(Redis 3.2+)

    • quicklist = ziplist + linkedlist 的结合

    • 每个节点是一个 ziplist,节点之间用链表连接

    • 兼顾了空间效率和操作效率

应用场景:

  • 消息队列:LPUSH + BRPOP 实现简单队列

  • 最新列表:最新文章、最新动态(LPUSH + LRANGE)

  • 排行榜:简单的排行榜

常用命令: LPUSH、RPUSH、LPOP、RPOP、LRANGE、LLEN、LINDEX

3. Hash(哈希)

  • 键值对集合,类似 Java 的 HashMap

  • 底层:

    • 元素少、值小 → ziplist(压缩列表)

    • 元素多或值大 → hashtable(哈希表)

应用场景:

  • 对象缓存:缓存用户信息、商品信息(比 String 更省空间)

  • 购物车:用户 ID 为 key,商品 ID 为 field,数量为 value

  • 存储对象的多个属性

常用命令: HSET、HGET、HGETALL、HDEL、HEXISTS、HINCRBY、HKEYS、HVALS

4. Set(集合)

  • 无序集合,元素不重复

  • 底层:

    • 元素都是整数且数量少 → intset(整数集合)

    • 否则 → hashtable

应用场景:

  • 去重:列表去重

  • 标签:给用户打标签,共同好友

  • 交集/并集/差集:共同好友、共同兴趣

  • 抽奖:SADD 加人,SPOP 随机抽

常用命令: SADD、SREM、SMEMBERS、SISMEMBER、SINTER、SUNION、SDIFF、SPOP、SCARD

5. ZSet(Sorted Set,有序集合)

  • 有序集合,每个元素有一个分数(score),按分数排序

  • 底层:skiplist(跳表)+ hashtable

    • hashtable:存 member → score 的映射,O(1) 查找分数

    • skiplist:按 score 排序,支持范围查找

    • 两者配合,既支持快速查找,又支持有序操作

应用场景:

  • 排行榜:游戏排行榜、热搜榜

  • 延时队列:时间戳作为 score,定时任务

  • 范围查找:按分数范围查找

  • 权重队列:优先级队列

常用命令: ZADD、ZRANGE、ZREVRANGE、ZSCORE、ZRANK、ZINCRBY、ZRANGEBYSCORE、ZCARD

【其他高级数据结构】

| 数据结构 | 说明 | 应用场景 | |----------|------|----------| | Bitmap | 位图,基于 String 的位操作 | 签到、用户在线状态、布隆过滤器 | | HyperLogLog | 基数统计 | UV 统计、独立用户数 | | Geo | 地理位置 | 附近的人、距离计算 | | Stream | 流 | 消息队列(Redis 5.0+) |


高频追问

追问1:Redis 的 SDS 和 C 语言字符串有什么区别?

| 维度 | C 字符串 | SDS | |------|---------|-----| | 获取长度 | O(n),遍历到 \0 | O(1),有 len 字段 | | 缓冲区溢出 | 容易溢出,手动管理 | 不会溢出,自动扩容 | | 内存分配 | 每次修改都重新分配 | 预分配空间,减少分配次数 | | 二进制安全 | 不安全,遇到 \0 就结束 | 安全,用 len 判断长度 | | 兼容 C 函数 | 完全兼容 | 部分兼容(以 \0 结尾) |

SDS 的优势:

  1. O(1) 获取长度

  2. 杜绝缓冲区溢出

  3. 减少内存分配次数(空间预分配 + 惰性释放)

  4. 二进制安全

追问2:ZSet 为什么用跳表?不用红黑树?

ZSet 底层用 skiplist(跳表)而不是红黑树,原因:

  1. 范围查找更方便:

    • 跳表做范围查找很简单,找到起点然后往后遍历就行

    • 红黑树做范围查找比较复杂,需要中序遍历

  2. 实现简单:

    • 跳表实现比红黑树简单

    • 更容易调试和维护

  3. 性能相当:

    • 跳表的查找、插入、删除都是 O(log n)

    • 和红黑树差不多

  4. 并发友好:

    • 跳表的修改只影响局部节点

    • 红黑树的平衡操作可能影响很大范围

    • 跳表更容易实现并发

跳表的缺点:空间利用率稍低(每个节点有多个指针),但可以接受。

追问3:List 的底层实现为什么从 ziplist 改成 quicklist?

Redis 3.2 之前,List 的底层是 ziplist 或 linkedlist:

  • ziplist:紧凑存储,省空间,但插入删除慢(要移动元素)

  • linkedlist:插入删除快,但每个节点有额外开销(前后指针),费空间

quicklist 是两者的结合:

  • 每个节点是一个 ziplist(紧凑存储,省空间)

  • 节点之间用双向链表连接(插入删除快)

  • 兼顾了空间效率和操作效率

相当于把一个大 ziplist 拆成多个小 ziplist,用链表串起来。

  • 小 ziplist 插入删除的代价小

  • 链表节点数不多,额外开销小

  • 两全其美

追问4:Hash 什么时候用 ziplist,什么时候用 hashtable?

Hash 的底层有两种实现:ziplist(压缩列表)和 hashtable(哈希表)。

使用 ziplist 的条件(两个都满足):

  1. 哈希对象保存的键值对数量 < 512 个(hash-max-ziplist-entries)

  2. 所有键值对的键和值的字符串长度都 < 64 字节(hash-max-ziplist-value)

不满足条件时,就转成 hashtable。

ziplist 的优点:

  • 紧凑存储,省空间

  • 内存连续,缓存友好

ziplist 的缺点:

  • 插入删除慢,需要移动元素

  • 元素多了之后性能差

所以元素少的时候用 ziplist 省空间,元素多了转 hashtable 保性能。

追问5:布隆过滤器了解吗?Redis 怎么实现?

布隆过滤器(Bloom Filter)是一种空间效率很高的概率型数据结构,用来判断一个元素是否在集合中。

特点:

  • 可能误判(说存在的可能不存在)

  • 不会漏判(说不存在的一定不存在)

  • 空间效率高,省内存

原理:

  • 一个位数组 + 多个哈希函数

  • 添加元素时,用多个哈希函数计算位置,把对应位置设为 1

  • 查询时,看所有位置是否都是 1,都是 1 就可能存在,有一个 0 就一定不存在

Redis 中的实现:

  • Redis 4.0 之后可以通过布隆过滤器模块(RedisBloom)实现

  • 也可以用 Bitmap 自己实现(SETBIT、GETBIT)

应用场景:

  • 缓存穿透防护:把所有可能的 key 存到布隆过滤器,不存在的直接返回

  • 垃圾邮件过滤

  • URL 去重

  • 推荐系统去重


38. Redis 持久化(RDB、AOF)

标准答案

Redis 是内存数据库,数据存在内存中,断电就丢了。持久化就是把数据写到磁盘,防止数据丢失。

Redis 提供了两种持久化方式:RDB 和 AOF。

【RDB(Redis DataBase)】

定义:在某个时间点,把内存中的数据快照保存到磁盘。

触发方式:

  1. 手动触发

    • SAVE:阻塞式保存,会阻塞 Redis 主线程,保存完才能处理请求

    • BGSAVE:后台保存,fork 一个子进程来保存,主线程不阻塞

  2. 自动触发

    • 配置 save 规则,比如:

      save 900 1    # 900秒内至少1个key被修改
      save 300 10   # 300秒内至少10个key被修改
      save 60 10000 # 60秒内至少10000个key被修改
      
    • 满足任一条件就自动 BGSAVE

RDB 文件:

  • 二进制文件,紧凑,体积小

  • 文件名默认 dump.rdb

  • 恢复时直接加载到内存,速度快

优点:

  1. 文件体积小,紧凑的二进制格式

  2. 恢复速度快,直接加载快照

  3. 对性能影响小,fork 子进程,主线程不阻塞

  4. 适合备份、全量复制

缺点:

  1. 数据丢失风险大:两次快照之间的数据会丢失

  2. fork 子进程有开销:数据量大时,fork 耗时,可能阻塞

  3. 每次都要全量保存:不适合频繁持久化

【AOF(Append Only File)】

定义:把每一条写命令都追加到文件中,恢复时重新执行所有命令。

AOF 持久化流程:

  1. 命令追加(append):写命令追加到 AOF 缓冲区

  2. 文件写入(write):缓冲区写入 AOF 文件

  3. 文件同步(fsync):把缓冲区数据刷到磁盘

刷盘策略(appendfsync):

| 策略 | 说明 | 安全性 | 性能 | |------|------|--------|------| | always | 每条命令都刷盘 | 最安全,最多丢一条 | 最差 | | everysec | 每秒刷一次(默认) | 较安全,最多丢1秒 | 较好 | | no | 由操作系统决定何时刷 | 最不安全,可能丢很多 | 最好 |

默认是 everysec,平衡了安全和性能。

AOF 重写(BGREWRITEAOF):

  • AOF 文件会越来越大,因为每条写命令都记

  • 重写就是把多条命令合并成一条,减小文件体积

  • 比如 INCR 100 次,重写后变成 SET key 100

  • 重写不读旧 AOF 文件,直接读内存数据,生成新的 AOF 文件

  • 也是 fork 子进程来做,不阻塞主线程

优点:

  1. 数据安全性高:最多丢 1 秒数据(默认配置)

  2. 可读性好:文本格式,可以手动修改

  3. 自动重写:文件太大了自动重写,减小体积

缺点:

  1. 文件体积大:文本格式,比 RDB 大

  2. 恢复速度慢:要重新执行所有命令

  3. 写操作频繁:每条命令都要写,对性能有一定影响

【RDB vs AOF 对比】

| 维度 | RDB | AOF | |------|-----|-----| | 持久化方式 | 数据快照 | 写命令追加 | | 文件体积 | 小(二进制) | 大(文本) | | 恢复速度 | 快 | 慢 | | 数据安全性 | 差,丢两次快照之间的数据 | 好,最多丢1秒 | | 性能影响 | 小(fork 子进程) | 稍大(每条命令都写) | | 文件大小 | 全量,体积小 | 不断追加,体积大,可重写 | | 优先级 | 低 | 高(同时开启时优先用 AOF 恢复) |

【混合持久化(Redis 4.0+)】

Redis 4.0 之后支持混合持久化,同时用 RDB 和 AOF。

  • AOF 文件前半部分是 RDB 格式的全量数据

  • 后半部分是 AOF 格式的增量命令

好处:

  • 恢复速度快(先加载 RDB,再执行增量 AOF)

  • 数据安全性高(增量用 AOF,丢数据少)

  • 兼顾了两者的优点

开启方式:aof-use-rdb-preamble yes


高频追问

追问1:RDB 的 BGSAVE 过程是怎样的?

BGSAVE(后台保存)的过程:

  1. Redis 主线程判断当前没有其他子进程在执行

  2. 调用 fork() 创建子进程

  3. fork 过程中主线程是阻塞的(fork 是系统调用,需要复制页表)

  4. fork 完成后,主线程继续处理请求

  5. 子进程遍历内存数据,写入 RDB 文件

  6. 子进程写完后,通知主线程

  7. 主线程更新 RDB 文件相关信息

关键技术:写时复制(Copy-On-Write,COW)

  • fork 之后,父子进程共享内存页

  • 只有当某一页被修改时,才复制那一页

  • 这样不用复制整个内存,节省时间和空间

  • 子进程看到的是 fork 那一刻的数据快照

追问2:AOF 重写的过程?为什么不用读旧文件?

AOF 重写不是读旧文件重写,而是直接读内存中的数据,生成新的 AOF 文件。

过程:

  1. 触发重写(手动 BGREWRITEAOF 或自动触发)

  2. fork 子进程

  3. 子进程读内存数据,生成新的 AOF 文件

  4. 重写过程中,主线程继续处理写命令

  5. 新的写命令同时写到旧 AOF 和重写缓冲区

  6. 子进程写完新 AOF 文件,通知主线程

  7. 主线程把重写缓冲区的命令追加到新 AOF 文件

  8. 用新 AOF 文件替换旧 AOF 文件

为什么不读旧文件?

  • 读旧文件再解析重写,效率低

  • 直接读内存生成新文件,更简单高效

  • 内存中的数据就是最新的,直接生成最优的命令

追问3:fork 子进程有什么开销?

fork 的开销:

  1. 复制页表:

    • 子进程要复制父进程的页表

    • 内存越大,页表越大,复制越慢

    • 数据量大的 Redis 实例,fork 可能耗时几百毫秒甚至几秒

    • fork 期间主线程是阻塞的

  2. 写时复制的开销:

    • fork 之后,如果有大量写操作

    • 会产生很多页复制(COW)

    • 内存占用会增加(最多翻倍)

    • 可能导致内存不足

  3. 子进程的 CPU 和 IO 开销:

    • 子进程要遍历内存、写文件

    • 消耗 CPU 和磁盘 IO

优化建议:

  • 不要在高峰期做持久化

  • 合理配置持久化频率

  • 控制 Redis 实例的内存大小(不要太大)

  • 用物理机或高性能云服务器,fork 更快

追问4:Redis 重启时怎么恢复数据?RDB 和 AOF 都有的话用哪个?

恢复优先级:AOF > RDB

恢复流程:

  1. 判断是否开启了 AOF

  2. 如果开启了 AOF → 用 AOF 文件恢复

    • 因为 AOF 数据更新,丢失少

  3. 如果没开启 AOF → 用 RDB 文件恢复

为什么 AOF 优先级高?

  • AOF 数据更完整,丢失更少

  • RDB 是快照,可能丢更多数据

混合持久化的情况:

  • AOF 文件前半部分是 RDB,后半部分是 AOF

  • 先加载 RDB 部分(快)

  • 再执行 AOF 部分(增量)

  • 兼顾速度和数据完整性

追问5:持久化会影响 Redis 性能吗?怎么优化?

会影响,但可以优化。

RDB 的性能影响:

  • fork 子进程时主线程阻塞(时间和内存大小有关)

  • 子进程写文件消耗 CPU 和 IO

  • 写时复制可能导致内存占用增加

AOF 的性能影响:

  • 每条写命令都要写文件(写缓冲区,刷盘按策略)

  • AOF 重写也要 fork,和 RDB 类似

优化建议:

  1. 合理配置持久化策略:

    • 可以接受丢一点数据 → RDB 就行,性能好

    • 不能丢数据 → AOF + RDB,或者混合持久化

    • 缓存场景 → 可以不开持久化,性能最好

  2. 不要在高峰期持久化:

    • 避开业务高峰时段

    • 手动触发 BGSAVE / BGREWRITEAOF

  3. 控制实例大小:

    • 单实例内存不要太大(建议 10G 以内)

    • 太大了 fork 慢,恢复也慢

  4. 用 SSD 磁盘:

    • 持久化是 IO 密集型

    • SSD 比机械盘好很多

  5. 主从架构:

    • 主库不做持久化,从库做持久化

    • 主库性能最好


39. 缓存穿透、缓存击穿、缓存雪崩区别

标准答案

这三个是缓存常见的问题,名字很像,但原因和解决方案不同。

【缓存穿透】

定义:查询一个不存在的数据,缓存和数据库都没有,每次请求都打到数据库。

原因:

  • 恶意攻击:大量请求不存在的 key

  • 业务异常:参数错误,查不存在的数据

危害:

  • 大量请求直接打到数据库

  • 数据库压力大,甚至被打挂

解决方案:

1. 缓存空值(最简单)

  • 查询数据库也没有的,也缓存一个空值(null)

  • 设置较短的过期时间(比如 5 分钟)

  • 优点:简单

  • 缺点:占内存,可能缓存很多空值

2. 布隆过滤器(推荐)

  • 把所有可能存在的 key 存到布隆过滤器中

  • 请求先过布隆过滤器

  • 布隆过滤器说不存在 → 直接返回,不查数据库

  • 布隆过滤器说存在 → 再查缓存和数据库

  • 优点:省内存,适合大数据量

  • 缺点:有一定误判率(说存在的可能不存在,但说不存在的一定不存在)

3. 参数校验

  • 接口层做参数校验

  • 非法参数直接返回,不查数据库

  • 比如 ID 是负数、格式不对等

4. 网关限流

  • 限制单 IP 的请求频率

  • 防止恶意攻击

【缓存击穿】

定义:一个热点 key,在缓存过期的瞬间,大量并发请求同时打到数据库。

原因:

  • 热点 key 过期了

  • 大量请求同时来,都发现缓存没了

  • 都去查数据库,数据库压力瞬间暴增

和穿透的区别:

  • 穿透:查不存在的数据

  • 击穿:数据存在,但缓存过期了,热点 key

解决方案:

1. 互斥锁(推荐)

  • 缓存失效时,不是所有请求都去查数据库

  • 用分布式锁,只有一个线程去查数据库并重建缓存

  • 其他线程等待,然后读缓存

  • 优点:简单有效

  • 缺点:有等待,性能稍差

2. 热点 key 永不过期

  • 热点 key 不设过期时间

  • 后台异步更新缓存

  • 优点:不会击穿

  • 缺点:可能有短暂的数据不一致

3. 提前续期

  • 快过期时,后台异步刷新缓存

  • 用定时任务提前更新

  • 不让缓存真正过期

【缓存雪崩】

定义:大量 key 同时过期,或者 Redis 挂了,导致大量请求同时打到数据库。

原因:

  1. 大量 key 同一时间过期

    • 比如批量设置了相同的过期时间

    • 到时间了一起失效

  2. Redis 实例挂了

    • 整个缓存都不可用

    • 所有请求都打数据库

和击穿的区别:

  • 击穿:单个热点 key 过期

  • 雪崩:大量 key 同时过期,或者 Redis 挂了

解决方案:

针对大量 key 同时过期:

1. 过期时间加随机值(推荐)

  • 设置过期时间时,加一个随机数

  • 比如本来过期时间是 1 小时,改成 1 小时 + 随机 0~5 分钟

  • 这样不会大量 key 同时过期

  • 简单有效

2. 分级缓存

  • 多级缓存:本地缓存 + Redis 缓存

  • Redis 挂了还有本地缓存顶着

  • 减少打到数据库的请求

针对 Redis 挂了:

1. 高可用集群

  • Redis 主从 + 哨兵,或者 Redis Cluster

  • 一个节点挂了,自动切换

  • 保证缓存服务可用

2. 限流降级

  • 缓存不可用时,接口限流

  • 非核心接口直接降级,返回默认值

  • 保护数据库不被打挂

3. 熔断机制

  • 数据库请求量达到阈值就熔断

  • 直接返回错误或兜底数据

  • 防止数据库被打挂


【三者对比】

| 问题 | 原因 | 特点 | 核心解决方案 | |------|------|------|-------------| | 缓存穿透 | 查不存在的数据 | 缓存和数据库都没有 | 布隆过滤器、缓存空值 | | 缓存击穿 | 热点 key 过期 | 单个热点 key | 互斥锁、永不过期 | | 缓存雪崩 | 大量 key 同时过期 / Redis 挂了 | 大面积失效 | 过期时间加随机值、高可用、限流降级 |


高频追问

追问1:布隆过滤器的原理?有什么缺点?

布隆过滤器原理:

  • 一个位数组 + 多个哈希函数

  • 添加元素:用多个哈希函数计算位置,对应位置设为 1

  • 查询元素:看所有位置是否都是 1

    • 都是 1 → 可能存在(有误判)

    • 有一个 0 → 一定不存在

优点:

  • 空间效率极高,省内存

  • 插入和查询都是 O(k),k 是哈希函数个数,很快

缺点:

  1. 有误判率:说存在的可能不存在(假阳性)

    • 可以通过增加位数组大小和哈希函数个数来降低误判率

    • 但不能完全消除

  2. 不能删除元素:删除会影响其他元素的判断

    • 有变种:计数布隆过滤器,可以删除,但更复杂

  3. 元素多了误判率上升:元素越多,1 越多,越容易误判

追问2:互斥锁怎么实现缓存击穿的解决方案?

用分布式锁实现:

public Object getData(String key) {
    // 1. 先查缓存
    Object data = redis.get(key);
    if (data != null) {
        return data;
    }
    
    // 2. 缓存没了,加锁
    String lockKey = "lock:" + key;
    try {
        boolean locked = redis.setnx(lockKey, "1", 10, TimeUnit.SECONDS);
        if (locked) {
            // 3. 拿到锁,查数据库
            data = db.query(key);
            if (data != null) {
                redis.set(key, data, 1, TimeUnit.HOURS);
            }
            return data;
        } else {
            // 4. 没拿到锁,等一会儿再查缓存
            Thread.sleep(100);
            return getData(key); // 重试
        }
    } finally {
        // 5. 释放锁
        if (locked) {
            redis.del(lockKey);
        }
    }
}

注意:

  • 锁要设过期时间,防止死锁

  • 释放锁要判断是不是自己加的锁(用 Lua 脚本保证原子性)

  • 重试要有次数限制,防止死循环

追问3:缓存和数据库一致性怎么保证?

这是下一题的内容,简单说:

  • 先更数据库,再删缓存(Cache Aside Pattern)

  • 删缓存失败怎么办?重试机制

  • 延迟双删(先删缓存,更数据库,再删一次缓存)

  • 最终一致性,不是强一致

详细见第 44 题。

追问4:怎么发现和定位缓存问题?

监控指标:

  1. 缓存命中率:

    • 命中率低 → 可能有穿透、或者缓存设计有问题

    • 命中率突然下降 → 可能雪崩了

  2. Redis QPS:

    • 突然下降 → Redis 可能有问题

    • 突然上升 → 流量突增

  3. 数据库 QPS:

    • 突然上升 → 缓存可能失效了,穿透/击穿/雪崩

  4. 响应时间:

    • 响应变慢 → 缓存没命中,都打数据库了

  5. 慢查询:

    • 数据库慢查询变多 → 缓存有问题

定位思路:

  • 先看监控,发现异常

  • 看 Redis 状态:是否正常、内存、连接数

  • 看数据库状态:连接数、慢查询

  • 看日志:有没有报错、有没有大量相同 key 的请求

  • 针对性排查:穿透?击穿?雪崩?

追问5:热点 key 怎么发现?怎么处理?

发现热点 key:

  1. 客户端统计:客户端统计访问频率

  2. 代理层统计:在代理层(如 Twemproxy、Codis)统计

  3. Redis 自带:Redis 4.0 有 hotkeys 特性(LFU 模式下)

  4. 监控系统:通过监控发现异常高的 key

  5. 业务预判:根据业务预估哪些是热点

处理热点 key:

  1. 缓存永不过期:热点 key 不设过期,后台异步更新

  2. 本地缓存:应用层加本地缓存(Caffeine、Guava Cache),减少 Redis 压力

  3. key 分片:把一个热点 key 拆成多个(比如 key_1、key_2...),分散压力

  4. 多级缓存:本地缓存 + Redis + 数据库

  5. 限流降级:保护后端不被打垮

  6. 提前预热:活动开始前提前把热点数据加载到缓存


40. Redis 分布式锁实现

标准答案

分布式锁是分布式系统中用来保证多个节点之间互斥访问共享资源的机制。

【为什么需要分布式锁】

在分布式系统中,多个服务部署在不同机器上,synchronized 只能锁单个 JVM 内的线程,跨 JVM 就没用了,需要分布式锁。

【分布式锁的要求】

  1. 互斥性:同一时间只有一个线程持有锁

  2. 防死锁:即使持有锁的线程挂了,锁也能释放

  3. 解铃还须系铃人:加锁和解锁必须是同一个线程

  4. 可重入:同一个线程可以多次获取同一把锁

  5. 高可用:锁服务要高可用

【Redis 分布式锁实现】

基础版本(有问题):

// 加锁
SETNX lock_key value
EXPIRE lock_key 10

// 解锁
DEL lock_key

问题:

  1. SETNX 和 EXPIRE 不是原子的 → SETNX 成功了,EXPIRE 失败了 → 锁不会过期 → 死锁

  2. 解锁直接 DEL → 可能删了别人的锁(锁过期了,别人加了锁,你把别人的删了)

正确版本:

加锁:用 SET 命令,原子操作

SET lock_key value NX EX 10
  • NX:key 不存在才设置(Not eXists)

  • EX:过期时间,秒

  • PX:过期时间,毫秒

  • 一条命令,原子操作,解决了死锁问题

value 设什么?

  • 设一个唯一值(比如 UUID + 线程 ID)

  • 解锁时验证是不是自己加的锁

  • 解决"误删别人的锁"的问题

解锁:用 Lua 脚本,保证原子性

if redis.call('get', KEYS[1]) == ARGV[1] then
    return redis.call('del', KEYS[1])
else
    return 0
end
  • 先判断 value 是不是自己的

  • 是自己的才删除

  • 用 Lua 脚本保证判断和删除是原子操作

Java 代码示例:

// 加锁
String value = UUID.randomUUID().toString() + Thread.currentThread().getId();
Boolean locked = redisTemplate.opsForValue()
    .setIfAbsent("lock_key", value, 10, TimeUnit.SECONDS);

// 解锁(Lua 脚本)
String script = "if redis.call('get', KEYS[1]) == ARGV[1] then return redis.call('del', KEYS[1]) else return 0 end";
redisTemplate.execute(new DefaultRedisScript<>(script, Long.class), 
    Collections.singletonList("lock_key"), value);

【Redlock 算法】

单节点 Redis 的分布式锁有单点问题:Redis 挂了锁就没了。

Redlock(红锁)是 Redis 官方提出的多节点分布式锁算法:

原理:

  • 假设有 N 个独立的 Redis 节点(比如 5 个)

  • 客户端向所有节点请求加锁

  • 超过半数(N/2 + 1)节点加锁成功,且总耗时小于锁过期时间 → 加锁成功

  • 否则加锁失败,向所有节点释放锁

步骤:

  1. 获取当前时间戳

  2. 依次向 N 个节点请求加锁(用 SET NX EX)

  3. 计算加锁总耗时

  4. 成功节点数 > N/2 + 1 且 耗时 < 过期时间 → 成功

  5. 否则失败,释放所有节点的锁

争议:

  • Redlock 有争议,有人认为在某些极端情况下不安全

  • 一般场景单节点够用了,不需要 Redlock

  • 真需要强一致的分布式锁,用 Zookeeper 更合适


高频追问

追问1:分布式锁有哪些实现方式?对比一下?

| 方式 | 原理 | 优点 | 缺点 | |------|------|------|------| | Redis | SET NX + 过期时间 | 性能好、实现简单 | 单点问题、可能丢锁 | | Zookeeper | 临时节点 + 监听 | 强一致、可靠 | 性能稍差、实现复杂 | | MySQL | 行锁 / 唯一索引 | 简单 | 性能差、不适合高并发 | | etcd | 基于 Raft 协议 | 强一致 | 学习成本高 |

Redis vs Zookeeper:

  • Redis:性能好,但可靠性稍差,适合对性能要求高、能接受偶尔失败的场景

  • Zookeeper:可靠性高,但性能稍差,适合对一致性要求高的场景

一般业务场景,Redis 分布式锁够用了。

追问2:锁过期了但业务还没执行完怎么办?

这是分布式锁的经典问题:锁的过期时间不好设。

  • 设短了:业务没执行完,锁就过期了 → 其他线程拿到锁 → 并发问题

  • 设长了:线程挂了,锁要等很久才释放 → 影响可用性

解决方案:

1. 看门狗(Watch Dog)机制(推荐)

  • 加锁成功后,启动一个后台线程(看门狗)

  • 每隔一段时间(比如过期时间的 1/3)检查一下

  • 如果线程还持有锁,就续期(延长过期时间)

  • 如果线程挂了,看门狗也挂了,锁就自动过期

  • Redisson 框架就是这么实现的

2. 合理设置过期时间

  • 根据业务执行时间估算

  • 留一定余量(比如 2~3 倍)

  • 但不能完全解决问题

3. 业务幂等

  • 即使锁过期了,业务也要保证幂等

  • 重复执行不会出问题

  • 兜底方案

追问3:Redisson 了解吗?它的分布式锁怎么实现的?

Redisson 是 Redis 的 Java 客户端,提供了很多高级功能,包括分布式锁。

Redisson 分布式锁的特点:

  1. 可重入:同一个线程可以多次加锁,有计数器

  2. 看门狗:自动续期,解决锁过期问题

  3. Lua 脚本:加锁解锁都用 Lua 脚本,保证原子性

  4. 多种锁:可重入锁、公平锁、读写锁、红锁等

加锁原理:

  • 用 Hash 结构存锁信息:key 是锁名,field 是线程 ID,value 是重入次数

  • 加锁用 Lua 脚本:判断锁是否存在,不存在就加,存在且是自己的就重入次数 +1

  • 看门狗后台线程定时续期

解锁原理:

  • 用 Lua 脚本:重入次数 -1,减到 0 就删除 key

  • 释放锁后发通知(发布订阅),唤醒等待的线程

Redisson 比自己实现的分布式锁更完善、更可靠,推荐用。

追问4:怎么实现可重入的分布式锁?

可重入:同一个线程可以多次获取同一把锁。

实现思路:

  • 用 Hash 结构存锁

  • key:锁名

  • field:线程唯一标识(UUID + 线程 ID)

  • value:重入次数

加锁逻辑(Lua):

-- 锁不存在,直接加
if (redis.call('exists', KEYS[1]) == 0) then
    redis.call('hset', KEYS[1], ARGV[2], 1);
    redis.call('pexpire', KEYS[1], ARGV[1]);
    return nil;
end;
-- 锁存在,且是自己的,重入次数 +1
if (redis.call('hexists', KEYS[1], ARGV[2]) == 1) then
    redis.call('hincrby', KEYS[1], ARGV[2], 1);
    redis.call('pexpire', KEYS[1], ARGV[1]);
    return nil;
end;
-- 锁存在,不是自己的,返回剩余过期时间
return redis.call('pttl', KEYS[1]);

解锁逻辑(Lua):

-- 锁不是自己的
if (redis.call('hexists', KEYS[1], ARGV[2]) == 0) then
    return nil;
end;
-- 重入次数 -1
local counter = redis.call('hincrby', KEYS[1], ARGV[2], -1);
if (counter > 0) then
    -- 还有重入次数,续期
    redis.call('pexpire', KEYS[1], ARGV[1]);
    return 0;
else
    -- 重入次数为 0,删除锁
    redis.call('del', KEYS[1]);
    return 1;
end;

这就是 Redisson 可重入锁的基本原理。

追问5:分布式锁的粒度怎么控制?

锁的粒度很重要:

  • 粒度太粗:锁的范围太大,并发度低,性能差

  • 粒度太细:锁太多,管理复杂,容易死锁

控制粒度的原则:

  1. 锁的范围尽量小:

    • 只锁需要互斥的部分

    • 不要把整个方法都锁了

    • 能锁一行就不锁一个表,能锁一个 key 就不锁一类 key

  2. 锁的时间尽量短:

    • 持有锁的时间越短越好

    • 不要在锁里做耗时操作(比如 RPC 调用、数据库慢查询)

    • 先准备好数据,再加锁做关键操作

  3. 按资源维度拆分:

    • 不同的资源用不同的锁

    • 比如用户 A 和用户 B 互不影响,就用不同的锁(lock:user:A、lock:user:B)

    • 不要用一把大锁锁所有用户

  4. 避免死锁:

    • 按固定顺序加锁

    • 设置超时时间

    • 避免嵌套锁


41. Redis Cluster 原理

标准答案

Redis Cluster 是 Redis 官方的分布式集群方案,实现了数据分片和高可用。

【为什么需要集群】

单节点 Redis 的问题:

  1. 内存有限:单节点内存不能太大(建议 10G 以内),数据量大了存不下

  2. 性能瓶颈:单节点 QPS 有上限,高并发扛不住

  3. 单点故障:一个节点挂了,整个服务不可用

集群解决的问题:

  • 数据分片:数据分散到多个节点,解决容量问题

  • 负载均衡:请求分散到多个节点,解决性能问题

  • 高可用:每个主节点有从节点,主挂了从顶上,解决可用性问题

【数据分片:哈希槽】

Redis Cluster 用**哈希槽(hash slot)**来分片,不是一致性哈希。

哈希槽的数量:16384 个(0 ~ 16383)

分片规则:

  1. 对 key 计算 CRC16 哈希值

  2. 对 16384 取模,得到槽号

  3. 根据槽号找到对应的节点

slot = CRC16(key) % 16384

槽和节点的映射:

  • 集群启动时,把 16384 个槽分配给各个主节点

  • 比如 3 个主节点:

    • 节点 A:0 ~ 5460

    • 节点 B:5461 ~ 10922

    • 节点 C:10923 ~ 16383

为什么用哈希槽,不用一致性哈希?

  1. 扩缩容方便:

    • 哈希槽:扩容时只需要把一些槽从旧节点移到新节点

    • 一致性哈希:加节点会影响相邻节点的数据迁移

  2. 管理简单:

    • 槽的数量固定,映射关系清晰

    • 容易监控和管理

  3. 数据迁移更可控:

    • 按槽迁移,粒度合适

    • 可以逐个槽迁移,影响小

【集群架构】

Redis Cluster 的架构:

  • 多个主节点(master):负责处理请求、存储数据

  • 每个主节点有多个从节点(slave):备份数据,主挂了顶上

  • 所有节点之间互相通信(Gossip 协议)

  • 客户端可以连任意一个节点

节点通信:Gossip 协议

  • 节点之间定期交换信息

  • 每个节点知道整个集群的状态

  • 去中心化,没有中心节点

【高可用:故障转移】

类似哨兵机制:

1. 主观下线(PFAIL)

  • 节点 A 发现节点 B 连不上了

  • 节点 A 认为节点 B 主观下线

2. 客观下线(FAIL)

  • 节点 A 把 B 下线的消息传播给其他节点

  • 如果超过半数的主节点都认为 B 下线了

  • 就标记 B 为客观下线

3. 故障转移

  • 从 B 的从节点中选一个升级为主节点

  • 选举方式:类似 Raft 的选举

  • 新主节点接管旧主的所有槽

  • 通知集群其他节点

4. 恢复

  • 旧主节点恢复后,变成新主的从节点

【集群中的 key 操作限制】

因为数据分散在不同节点,有些操作受限:

  1. 跨槽的多 key 操作不支持:

    • 比如 MSET、MGET、SUNION 等

    • 如果 key 在不同节点,就不支持

    • 会报 CROSSSLOT 错误

  2. 解决方法:Hash Tag

    • 用 {} 包裹 key 的一部分

    • 只对 {} 里的内容计算哈希槽

    • 这样相关的 key 就会落到同一个槽

    • 比如 user:{1001}:info 和 user:{1001}:order 都在同一个槽


高频追问

追问1:Redis Cluster 和哨兵模式的区别?

| 维度 | 哨兵模式(Sentinel) | 集群模式(Cluster) | |------|---------------------|---------------------| | 作用 | 高可用(主从切换) | 高可用 + 数据分片 | | 数据分片 | 不支持,所有数据都在主节点 | 支持,数据分散到多个节点 | | 容量 | 受单节点内存限制 | 可以水平扩展 | | 性能 | 单节点性能上限 | 多节点,性能线性扩展 | | 节点数量 | 1主N从 + N个哨兵 | N主N从 | | 适用场景 | 数据量不大,只要高可用 | 数据量大,需要分片 |

简单说:

  • 哨兵:解决高可用问题,数据还是一份

  • 集群:解决高可用 + 容量/性能问题,数据分多份

追问2:一致性哈希和哈希槽的区别?

| 维度 | 一致性哈希 | 哈希槽 | |------|-----------|--------| | 节点映射 | key → 节点(哈希环) | key → 槽 → 节点 | | 扩缩容影响 | 影响相邻节点 | 影响要迁移的槽 | | 数据迁移 | 不好控制,迁移量不确定 | 按槽迁移,精确可控 | | 管理复杂度 | 简单 | 稍复杂 | | 代表 | Memcached 客户端 | Redis Cluster |

哈希槽的优点:

  • 数据迁移更精确、更可控

  • 扩缩容更方便

  • 槽和节点的映射关系清晰,容易管理

一致性哈希的优点:

  • 实现简单

  • 适合客户端分片

追问3:Redis Cluster 怎么扩容?

扩容步骤(加一个新节点):

  1. 新节点加入集群

    • 启动新节点

    • 用 CLUSTER MEET 命令加入集群

  2. 迁移槽

    • 从其他节点迁移一些槽到新节点

    • 逐个槽迁移

    • 迁移过程中集群还能正常服务

  3. 迁移完成

    • 新节点接管这些槽

    • 集群重新平衡

迁移过程(单个槽):

  1. 把源节点的槽状态设为 MIGRATING(迁移中)

  2. 把目标节点的槽状态设为 IMPORTING(导入中)

  3. 逐个 key 迁移(用 MIGRATE 命令)

  4. 迁移完所有 key,把槽的归属改成目标节点

迁移过程中:

  • 查请求:先查源节点,没有就去目标节点查

  • 写请求:重定向到目标节点

对客户端透明吗?

  • 客户端会收到 MOVED 或 ASK 重定向

  • 智能客户端会缓存槽和节点的映射,自动重定向

追问4:Redis Cluster 的 Gossip 协议是什么?

Gossip 协议(流言协议)是一种去中心化的节点通信协议。

原理:

  • 每个节点定期随机选几个节点,交换信息

  • 信息像流言一样传播,最终所有节点都知道

  • 去中心化,没有中心节点

特点:

  • 优点:去中心化、容错性好、易扩展

  • 缺点:信息传播有延迟、可能有不一致(最终一致)

Redis Cluster 中的 Gossip:

  • 节点之间定期发送 PING/PONG 消息

  • 消息中包含自己知道的集群状态信息

  • 节点通过 Gossip 了解整个集群的状态

  • 故障检测、配置更新都通过 Gossip 传播

常见的 Gossip 消息类型:

  • PING:主动发的,检测对方是否存活

  • PONG:PING 的回复

  • MEET:新节点加入时发的

  • FAIL:节点故障的消息

  • PUBLISH:发布消息

追问5:Redis Cluster 客户端怎么路由?

客户端路由方式:

1. 重定向方式(简单客户端)

  • 客户端随便连一个节点

  • 发请求,如果 key 不在这个节点

  • 节点返回 MOVED 重定向,告诉客户端应该去哪个节点

  • 客户端再连那个节点发请求

  • 缺点:每次可能两次请求,性能差

2. 智能客户端(推荐)

  • 客户端启动时,从集群获取槽和节点的映射关系(槽表)

  • 本地缓存槽表

  • 请求时,先计算 key 的槽号,查槽表找到节点

  • 直接连对应节点

  • 槽表变化时(MOVED 重定向),更新本地缓存

  • 优点:性能好,一次请求搞定

3. 代理方式

  • 中间加一层代理(比如 Codis、Twemproxy)

  • 客户端连代理,代理负责路由

  • 客户端不用管集群细节

  • 缺点:多了一层代理,有性能损耗

一般都用智能客户端,比如 Jedis Cluster、Lettuce 等。


42. Big Key 与 Hot Key 如何处理

标准答案

Big Key(大 key)和 Hot Key(热 key)是 Redis 常见的两个问题,会影响性能甚至导致故障。

【什么是 Big Key】

Big Key 指的是 value 很大的 key,或者元素很多的集合 key。

判断标准(经验值):

  • String 类型:value 超过 10KB

  • List/Hash/Set/ZSet:元素数量超过 5000 个,或者总大小超过 10MB

注意:这只是经验值,具体要看业务场景和 Redis 配置。

Big Key 的危害:

  1. 阻塞 Redis

    • 操作 Big Key 耗时久

    • Redis 是单线程的,会阻塞其他请求

    • 导致响应变慢,甚至超时

  2. 网络拥塞

    • Big Key 传输数据量大

    • 占带宽,影响其他请求

  3. 内存不均

    • Big Key 占大量内存

    • 集群模式下可能导致节点内存不均

  4. 删除困难

    • 删除 Big Key 耗时久

    • 阻塞 Redis

    • Redis 4.0 之后有 UNLINK 命令(异步删除),好一些

【怎么发现 Big Key】

  1. redis-cli --bigkeys

    • Redis 自带的工具

    • 扫描整个实例,找出每种数据类型最大的 key

    • 缺点:只找最大的,不是所有大 key;线上扫描可能有影响

  2. SCAN 扫描

    • 用 SCAN 命令遍历 key

    • 用 STRLEN、LLEN、HLEN 等判断大小

    • 可以自己写脚本扫描

    • 比 --bigkeys 更灵活

  3. rdb 工具分析

    • 分析 RDB 文件

    • 比如 redis-rdb-tools

    • 不影响线上服务

  4. 监控告警

    • 监控慢查询

    • 操作慢的 key 可能是 Big Key

【Big Key 的解决方案】

1. 拆分(推荐)

  • 把一个 Big Key 拆成多个小 key

  • 比如一个大 Hash 拆成多个小 Hash

  • 一个大 List 拆成多个小 List

举例:

  • 原来:user:info 一个 Hash 存了用户的所有信息,几百个字段

  • 拆分:user:info:base、user:info:profile、user:info:setting 等

2. 压缩

  • value 用压缩算法压缩(比如 gzip、snappy)

  • 减少体积

  • 适合 String 类型的大 value

3. 清理过期数据

  • 集合类型的 Big Key,定期清理过期元素

  • 比如 List 只保留最新的 N 条

  • ZSet 按分数清理旧数据

4. 异步删除

  • 删除 Big Key 用 UNLINK 命令(Redis 4.0+)

  • 异步删除,不阻塞主线程

  • 比 DEL 好很多

【什么是 Hot Key】

Hot Key(热 key)指的是访问频率特别高的 key。

Hot Key 的危害:

  1. 单节点压力大

    • 大量请求打在同一个 key 上

    • 集群模式下,这个 key 所在的节点压力大

    • 其他节点很闲,这个节点很忙,负载不均

  2. 缓存击穿

    • 热点 key 一旦过期,大量请求同时打数据库

    • 可能把数据库打挂

  3. 影响其他请求

    • Redis 单线程,热点 key 占了太多处理时间

    • 其他请求响应变慢

【怎么发现 Hot Key】

  1. 客户端统计

    • 客户端记录每个 key 的访问次数

    • 上报统计

  2. 代理层统计

    • 在代理层(Twemproxy、Codis)统计

  3. Redis 自带

    • Redis 4.0+ 的 hotkeys 特性(LFU 模式下)

    • redis-cli --hotkeys

  4. 监控系统

    • 监控 key 的访问频率

    • 异常高的就是热点

  5. 业务预判

    • 根据业务预估

    • 比如秒杀商品、热门话题等

【Hot Key 的解决方案】

1. 本地缓存(推荐)

  • 应用层加本地缓存(Caffeine、Guava Cache)

  • 热点数据存在本地

  • 请求先查本地缓存,有就直接返回

  • 大大减少 Redis 的压力

  • 注意:本地缓存的一致性,过期时间要短

2. 多级缓存

  • 本地缓存 + Redis + 数据库

  • 层层过滤,减少后端压力

3. key 分片(打散)

  • 把一个热点 key 拆成多个

  • 比如 hot_key_1、hot_key_2...hot_key_N

  • 每个存相同的值

  • 请求时随机选一个

  • 把压力分散到多个 key(集群模式下分散到多个节点)

  • 适合读多写少的场景(写的时候要更新所有分片)

4. 缓存永不过期 + 异步更新

  • 热点 key 不设过期时间

  • 后台定时任务异步更新

  • 避免缓存击穿

5. 限流降级

  • 保护后端服务

  • 热点 key 请求太多时,限流或降级


高频追问

追问1:Big Key 删除会有什么问题?怎么解决?

删除 Big Key 的问题:

  • DEL 命令是同步的,Big Key 删除耗时久

  • Redis 单线程,会阻塞其他请求

  • 可能导致 Redis 卡顿几秒甚至更久

解决方法:

  1. UNLINK 命令(Redis 4.0+)

    • 异步删除

    • 主线程只是把 key 从字典里摘掉

    • 真正的释放内存由后台线程做

    • 不阻塞主线程

    • 推荐使用

  2. 渐进式删除

    • 集合类型的 Big Key,逐个删除元素

    • 比如 List 用 LPOP 逐个删,Hash 用 HDEL 逐个删

    • 每次删一点,分多次删

    • 避免一次性删除阻塞太久

  3. 过期淘汰

    • 设置过期时间,让 Redis 自动淘汰

    • 但 Redis 过期删除也可能阻塞(惰性删除 + 定期删除)

    • 不如 UNLINK 好

追问2:集群模式下 Hot Key 有什么特殊问题?

集群模式下,Hot Key 的问题更突出:

  1. 节点负载不均

    • Hot Key 只在一个节点上

    • 这个节点压力特别大

    • 其他节点很闲

    • 整体上不去

  2. 单节点瓶颈

    • 即使集群有很多节点

    • 热点 key 只在一个节点

    • 性能受限于单节点

解决方案:

  1. 本地缓存:最有效,请求不打到 Redis

  2. key 分片:把一个 key 拆成多个,分散到不同节点

  3. 代理层缓存:在代理层缓存热点 key

追问3:怎么优雅地删除一个大的 List/Hash/Set?

渐进式删除,分多次删,避免阻塞:

List:

# 每次删 100 个
while LLEN(key) > 0:
    LTRIM(key, 100, -1)  # 保留从第100个开始的,相当于删前100个
    sleep(0.1)  # 休息一下,给其他请求机会

Hash:

# 每次删 100 个 field
cursor = 0
while True:
    cursor, fields = HSCAN(key, cursor, count=100)
    if fields:
        HDEL(key, *fields.keys())
    if cursor == 0:
        break
    sleep(0.1)

Set:

# 和 Hash 类似,用 SSCAN + SREM

ZSet:

# 按分数范围删,或者 ZREMRANGEBYRANK
while ZCARD(key) > 0:
    ZREMRANGEBYRANK(key, 0, 99)  # 每次删前100个
    sleep(0.1)

核心思路:每次删一点,中间休息一下,给 Redis 处理其他请求的机会,避免长时间阻塞。

追问4:热点 key 的过期时间怎么设?

热点 key 的过期时间要特别小心,设不好容易击穿。

策略:

  1. 永不过期 + 异步更新

    • 热点 key 不设过期时间

    • 后台定时任务异步更新

    • 最安全,不会击穿

    • 缺点:可能有短暂不一致

  2. 逻辑过期

    • value 里存一个过期时间

    • 不设 Redis 的 TTL

    • 读的时候判断是否过期

    • 过期了就异步更新,同时返回旧值

    • 不会击穿,但有不一致

  3. 互斥锁

    • 缓存失效时,用分布式锁,只有一个线程去重建

    • 其他线程等待

    • 前面讲过的缓存击穿解决方案

  4. 提前续期

    • 快过期了就提前刷新

    • 不让缓存真正过期

推荐:热点 key 尽量用"永不过期 + 异步更新",最稳妥。

追问5:Big Key 和 Hot Key 会同时出现吗?怎么处理?

会的,而且同时出现更麻烦。

比如热门商品的信息,既是 Big Key(字段多、value 大),又是 Hot Key(访问多)。

处理思路:

  1. 先解决 Hot Key:

    • 加本地缓存,减少 Redis 的访问量

    • 这是最有效的

  2. 再解决 Big Key:

    • 拆分字段,把大对象拆成小的

    • 只缓存热点字段,不常用的不缓存

    • 压缩 value

  3. 多级缓存:

    • 本地缓存 + Redis 缓存

    • 本地缓存热点数据的核心字段

    • 减少 Redis 压力,也减少传输数据量

  4. 读写分离:

    • 读走本地缓存 + Redis 从节点

    • 写走主节点

    • 减轻主节点压力

核心原则:先减少访问量(解决 Hot Key),再减小每次访问的数据量(解决 Big Key)。


43. Redis 为什么快

标准答案

Redis 是非常快的内存数据库,单节点就能达到 10 万+ QPS。

【Redis 快的原因】

1. 纯内存操作

  • 数据存在内存中,读写都是内存操作

  • 内存访问速度是纳秒级的,比磁盘快几个数量级

  • 这是最根本的原因

2. 单线程模型

  • Redis 是单线程的(指处理命令的是单线程)

  • 好处:

    • 没有线程切换的开销

    • 没有锁竞争的开销

    • 实现简单,不用考虑并发问题

  • 注意:Redis 6.0 之后有多线程 IO,但命令执行还是单线程

3. 高效的数据结构

  • Redis 的数据结构都是专门优化过的

  • 比如 SDS、跳表、压缩列表、quicklist 等

  • 各种操作的时间复杂度都很低

  • 空间效率也高

4. IO 多路复用

  • 用 IO 多路复用模型(epoll/kqueue)

  • 单线程处理大量并发连接

  • 非阻塞 IO,不会卡在某个连接上

  • 高并发下性能好

5. 持久化优化

  • RDB/AOF 持久化用子进程做

  • 主线程不阻塞

  • 写时复制(COW),不用复制整个内存

6. 其他优化

  • 虚拟内存(VM):不常用的数据换出到磁盘(现在基本不用了)

  • 对象共享:小整数对象共享

  • 内存分配优化:jemalloc/tcmalloc


高频追问

追问1:Redis 是单线程还是多线程?

这个问题要分版本说:

Redis 6.0 之前:纯单线程

  • 网络 IO 和命令执行都是单线程

  • 单线程指的是处理客户端请求的是单线程

  • 后台还有其他线程:比如持久化、AOF 重写是子进程

Redis 6.0 之后:多线程 IO

  • 网络 IO 是多线程的(读请求、写响应)

  • 命令执行还是单线程的

  • 多线程只是用来处理网络 IO,不执行命令

为什么引入多线程 IO?

  • 随着网络带宽提升,网络 IO 成了瓶颈

  • 单线程处理网络 IO 不够快

  • 把网络 IO 改成多线程,提高吞吐量

  • 命令执行还是单线程,不用改核心逻辑

为什么命令执行还是单线程?

  • Redis 的瓶颈不在 CPU,而在内存和网络

  • 单线程已经足够快了

  • 多线程会引入锁、线程切换等开销

  • 单线程实现简单,不容易出 bug

追问2:什么是 IO 多路复用?

IO 多路复用是一种 IO 模型,用一个线程就能监控多个连接。

传统的阻塞 IO:

  • 一个线程处理一个连接

  • 连接多了就要很多线程

  • 线程切换开销大,内存占用大

IO 多路复用:

  • 一个线程监控多个 socket

  • 哪个 socket 准备好了,就处理哪个

  • 不用很多线程,一个线程就行

  • 减少线程切换开销

常见实现:

  • select:跨平台,但有连接数限制(1024),效率低

  • poll:和 select 类似,但没有 1024 限制

  • epoll(Linux):性能最好,没有连接数限制,事件驱动

  • kqueue(BSD/macOS):类似 epoll

Redis 用的就是 epoll(Linux 下)。

为什么叫"多路复用"?

  • 多路:多个连接(多个 IO 流)

  • 复用:复用同一个线程

  • 一个线程处理多路 IO

追问3:Redis 单线程为什么能扛住高并发?

因为:

  1. 纯内存操作:

    • 内存操作很快,微秒级

    • 单线程每秒也能处理几十万次操作

  2. IO 多路复用:

    • 单线程处理大量并发连接

    • 非阻塞,不会卡在某个连接上

    • 连接多也不怕

  3. 命令执行快:

    • 数据结构高效,命令执行时间短

    • 大部分命令都是 O(1) 或 O(log n)

    • 单线程也能处理得过来

  4. 没有线程开销:

    • 没有线程切换

    • 没有锁竞争

    • 省了很多开销

类比:就像一个很熟练的收银员,虽然只有一个人,但手脚麻利,排队的人很多也能很快处理完。

追问4:Redis 单线程,怎么利用多核 CPU?

单线程确实只能用一个 CPU 核,但有几种方式利用多核:

  1. 多实例部署

    • 一台机器上部署多个 Redis 实例

    • 每个实例用一个核

    • 组成集群

    • 充分利用多核

    • 这是最常用的方式

  2. Redis 6.0 多线程 IO

    • 网络 IO 用多线程

    • 利用多核处理网络 IO

    • 提高吞吐量

  3. 后台任务用子进程/子线程

    • 持久化、AOF 重写用子进程

    • 异步删除用后台线程

    • 这些也会用其他核

推荐:生产环境一般是一台机器部署多个 Redis 实例,组成集群,充分利用多核。

追问5:Redis 快,那什么操作会慢?

大部分操作都很快,但有些操作可能慢:

  1. 操作 Big Key

    • 大 key 的读写都慢

    • 比如 HGETALL 一个很大的 Hash

    • LRANGE 一个很长的 List

    • 阻塞单线程,影响其他请求

  2. 全量操作

    • KEYS *:遍历所有 key

    • FLUSHALL/FLUSHDB:清空所有数据

    • 数据量大时很慢

    • 线上禁止用 KEYS *

  3. 大量 key 同时过期

    • 集中过期会导致卡顿

    • 因为 Redis 定期删除过期 key,一次删太多就慢了

  4. 持久化 fork

    • RDB/AOF 重写要 fork 子进程

    • 内存大时 fork 耗时久

    • fork 期间主线程阻塞

  5. 慢命令

    • 时间复杂度高的命令

    • 比如 SUNIONSTORE(多个大集合的并集)

    • ZINTERSTORE 等

所以 Redis 快是相对的,用不好也会慢。要避免慢命令,注意 Big Key。


44. Redis 与 MySQL 一致性如何保证

标准答案

缓存和数据库的一致性是经典问题,没有完美的强一致方案,都是最终一致性。

【常见的四种方案】

先看四种常见的操作顺序,分析各自的问题:

方案 1:先更新数据库,再更新缓存

更新数据库 → 更新缓存

问题:

  • 并发情况下,两个线程同时更新

  • 线程 A 更新数据库 → 线程 B 更新数据库 → 线程 B 更新缓存 → 线程 A 更新缓存

  • 结果:数据库是 B 的值,缓存是 A 的值 → 不一致

  • 而且如果更新缓存失败,也不一致

方案 2:先更新缓存,再更新数据库

更新缓存 → 更新数据库

问题更多:

  • 缓存更新成功了,数据库更新失败 → 不一致

  • 并发问题更严重

  • 一般不考虑

方案 3:先删缓存,再更新数据库

删除缓存 → 更新数据库

问题:

  • 线程 A 删除缓存 → 线程 B 读,发现缓存没了 → 读数据库 → 写回缓存(旧值)→ 线程 A 更新数据库

  • 结果:缓存是旧值,数据库是新值 → 不一致

  • 这就是经典的并发不一致问题

方案 4:先更新数据库,再删缓存(推荐)

更新数据库 → 删除缓存

这是最常用的方案,也叫 Cache Aside Pattern(旁路缓存模式)。

读的时候:

  • 先读缓存,有就直接返回

  • 缓存没有就读数据库

  • 读完写入缓存,再返回

写的时候:

  • 先更新数据库

  • 再删除缓存

为什么删缓存而不是更新缓存?

  • 更新缓存:每次写都更新缓存,但缓存可能根本没人读 → 浪费

  • 删除缓存:下次读的时候再加载 → 懒加载,更高效

  • 而且更新缓存的并发问题更多

【先更数据库再删缓存,还有问题吗?】

理论上还是有问题,但概率很低:

异常场景:

  1. 缓存刚好失效了

  2. 线程 A 读数据库(读到旧值)

  3. 线程 B 更新数据库 + 删除缓存

  4. 线程 A 把旧值写回缓存

  5. 结果:缓存是旧的,数据库是新的 → 不一致

为什么概率低?

  • 需要同时满足:

    1. 缓存刚好过期(或者被删了)

    2. 读请求和写请求同时来

    3. 读数据库比写数据库 + 删缓存还慢

  • 读数据库一般比写数据库快(读不用加锁,写要加锁)

  • 所以这个场景概率很低

【怎么解决不一致问题】

1. 给缓存设过期时间(兜底)

  • 缓存都设过期时间

  • 即使不一致,过一段时间也会自动恢复

  • 最终一致

  • 最简单,也是兜底方案

2. 延迟双删(先删缓存 → 更数据库 → 再删一次)

删除缓存 → 更新数据库 → 延迟一会儿 → 再删一次缓存
  • 第二次删除是为了删掉并发读可能写进去的脏数据

  • 延迟时间要大于一次读的时间

  • 可以用消息队列异步删,或者定时任务

3. 消息队列异步删除(重试机制)

  • 更新数据库后发消息

  • 消费者删缓存

  • 删除失败就重试

  • 保证最终一致

4. 订阅 binlog(Canal)

  • 订阅 MySQL 的 binlog

  • 数据变了就删缓存

  • 基于数据库的变更,更可靠

  • 适合复杂场景

5. 加锁

  • 读写都加锁

  • 强一致,但性能差

  • 一般不用


高频追问

追问1:为什么是删缓存,不是更新缓存?

原因:

  1. 并发安全:

    • 更新缓存:并发写时容易出现"先更新的后写缓存",导致不一致

    • 删除缓存:下次读的时候再从数据库加载,不会有这个问题

  2. 性能(懒加载):

    • 更新缓存:每次写都更新缓存,但这个缓存可能根本没人读

    • 删除缓存:只有读的时候才加载

    • 写多读少的场景,删缓存更省性能

  3. 缓存价值:

    • 缓存是用来加速读的,不是用来存数据的

    • 数据以数据库为准

    • 缓存没了,大不了从数据库读

    • 不用每次写都更新

  4. 复杂场景:

    • 如果缓存是计算出来的(不是直接存数据库的值)

    • 更新缓存的代价很大

    • 删缓存更简单

所以一般都是删缓存,不是更新缓存。

追问2:延迟双删怎么实现?

延迟双删:先删缓存,更新数据库,延迟一会儿再删一次缓存。

实现方式:

1. 线程 sleep(简单但不推荐)

// 第一次删
redis.del(key);
// 更新数据库
db.update(data);
// 延迟一会儿
Thread.sleep(1000);
// 第二次删
redis.del(key);
  • 缺点:同步等待,阻塞线程,影响性能

  • 延迟时间不好设

2. 消息队列异步删(推荐)

// 第一次删
redis.del(key);
// 更新数据库
db.update(data);
// 发消息到消息队列
mq.send("cache-delete", key);

// 消费者:延迟消费,删缓存
// 或者用延迟队列(比如 RocketMQ 的延迟消息)
  • 异步,不阻塞

  • 可以用延迟消息,更精确

  • 失败可以重试

3. 定时任务

  • 更新数据库后,记录要删的 key

  • 定时任务每隔一段时间批量删

  • 简单,但延迟比较大

延迟时间怎么设?

  • 要大于一次读请求的时间

  • 比如读数据库 + 写缓存大概 100ms,就设 500ms 或 1s

  • 留一定余量

追问3:删除缓存失败了怎么办?

删除缓存失败会导致不一致,需要重试机制。

解决方案:

1. 重试机制

  • 删除失败了,重试几次

  • 简单的重试:循环重试几次

  • 还是失败就告警,人工处理

2. 消息队列 + 消费者重试

  • 删除操作通过消息队列异步执行

  • 消费者删缓存

  • 删除失败就重试(消息队列的重试机制)

  • 多次失败就进死信队列,告警

3. 订阅 binlog

  • 用 Canal 等工具订阅 MySQL 的 binlog

  • 数据变更就删缓存

  • 基于数据库的变更,更可靠

  • 即使业务删缓存失败了,binlog 还会再删一次

  • 相当于兜底

4. 过期时间兜底

  • 缓存都设过期时间

  • 即使删失败了,过一段时间也会自动过期

  • 最终一致

推荐:消息队列异步删 + 过期时间兜底,基本够用了。

追问4:强一致性怎么实现?

要实现缓存和数据库的强一致,比较难,代价也大。

方案:

1. 加分布式锁

  • 读写都加锁

  • 同一时间只有一个线程操作

  • 保证一致性

  • 缺点:性能差,并发度低

  • 适合对一致性要求极高、并发不高的场景

2. 读写都走缓存 + 异步持久化

  • 以缓存为准,数据库只是持久化

  • 写的时候写缓存,异步写数据库

  • 强一致(缓存层面)

  • 缺点:缓存挂了可能丢数据

  • 适合可以接受丢少量数据的场景

3. 分布式事务

  • 用 Seata 等分布式事务框架

  • 缓存和数据库的事务一起提交/回滚

  • 理论上可以强一致

  • 缺点:性能差,实现复杂

  • 一般不用

实际上,大部分场景都不需要强一致,最终一致就够了。 真需要强一致的场景,可能根本不该用缓存。

追问5:Cache Aside、Read/Write Through、Write Behind 有什么区别?

这是三种常见的缓存模式:

1. Cache Aside(旁路缓存,最常用)

  • 读:先读缓存,没有就读数据库,再写缓存

  • 写:先更新数据库,再删缓存

  • 应用层负责缓存和数据库的交互

  • 最常用,简单灵活

2. Read Through(读穿透)

  • 读:应用只读缓存

  • 缓存没有的话,由缓存自己去加载数据库

  • 应用不用管数据库

  • 对应用透明

3. Write Through(写穿透)

  • 写:应用只写缓存

  • 缓存同步写数据库

  • 由缓存负责写数据库

  • 应用不用管数据库

4. Write Behind(写回,也叫 Write Back)

  • 写:只写缓存

  • 异步批量写数据库

  • 性能好

  • 但可能丢数据(缓存挂了没写进去的就丢了)

对比: | 模式 | 读 | 写 | 一致性 | 性能 | 复杂度 | |------|----|----|--------|------|--------| | Cache Aside | 应用负责 | 应用负责 | 最终一致 | 好 | 简单 | | Read/Write Through | 缓存负责 | 缓存负责 | 较好 | 一般 | 较复杂 | | Write Behind | 缓存负责 | 异步写 | 差 | 最好 | 复杂 |

一般业务开发都用 Cache Aside,最简单最灵活。

📌 面试技巧:回答 Redis 相关问题时,要体现出你对 Redis 的深入理解,不要只停留在"用过"的层面。比如讲数据结构就讲底层实现,讲持久化就讲 fork 和写时复制,讲分布式锁就讲 Redisson 和看门狗。讲得深一点,面试官会觉得你是真的懂。


第六章 消息队列(10题)


45. RabbitMQ 为什么使用消息队列

标准答案

消息队列(Message Queue,MQ)是分布式系统中重要的组件,核心作用是解耦、异步、削峰。

【为什么使用消息队列】

1. 解耦

传统模式:服务 A 直接调用服务 B、C、D

  • A 要知道 B、C、D 的存在

  • 加一个新服务 E,A 也要改代码

  • 耦合度高

用了 MQ:

  • A 只管发消息到 MQ

  • B、C、D 各自订阅消息

  • 加新服务 E,直接订阅就行,A 不用改

  • 服务之间解耦了

举例: 下单系统,下单后要通知库存、支付、物流、积分...

  • 不用 MQ:下单代码里调用一堆服务,加一个新服务就要改下单代码

  • 用 MQ:下单发一条消息,各个服务自己消费,加新服务直接订阅

2. 异步

传统模式:同步调用,A 调用 B,要等 B 执行完才能返回

  • 响应时间长(所有服务执行时间加起来)

  • 某个服务慢,整个链路都慢

用了 MQ:

  • A 发完消息就返回

  • B、C、D 异步消费

  • 响应时间短(只算发消息的时间)

举例: 注册用户后要发邮件、发短信、初始化数据

  • 同步:注册要等这些都做完,响应慢

  • 异步:注册完直接返回,后面异步做这些事

3. 削峰填谷

传统模式:高峰期请求直接打数据库

  • 流量突增(比如秒杀、大促)

  • 数据库扛不住,挂了

用了 MQ:

  • 请求先到 MQ 里排队

  • 消费者按自己的速度慢慢消费

  • 把高峰期的流量削平,慢慢消费

  • 保护下游系统

举例: 秒杀活动,瞬间 10 万请求

  • 直接打数据库:数据库挂了

  • 进 MQ:慢慢消费,数据库扛得住

【消息队列的缺点】

  1. 系统复杂度增加

    • 多了一个中间件

    • 要考虑消息丢失、重复消费、顺序性等问题

  2. 一致性问题

    • 异步了,就有一致性问题

    • A 发了消息,B 消费失败了怎么办

  3. 可用性问题

    • MQ 挂了怎么办

    • 需要高可用集群

  4. 延迟问题

    • 消息有延迟,不如同步调用及时

    • 不适合对实时性要求很高的场景

【常见的消息队列对比】

| 特性 | RabbitMQ | Kafka | RocketMQ | |------|----------|-------|----------| | 开发语言 | Erlang | Scala/Java | Java | | 吞吐量 | 万级 | 十万级~百万级 | 十万级 | | 延迟 | 微秒级 | 毫秒级 | 毫秒级 | | 可靠性 | 高 | 高 | 高 | | 功能丰富度 | 丰富(Exchange 等) | 简单(主要是高吞吐) | 丰富(事务消息等) | | 适用场景 | 业务消息、路由复杂 | 日志、大数据、高吞吐 | 金融、电商、事务消息 |


高频追问

追问1:消息队列怎么选型?RabbitMQ、Kafka、RocketMQ 怎么选?

选型考虑因素:

  1. 吞吐量

    • 超高吞吐(日志、大数据)→ Kafka

    • 一般业务吞吐 → RabbitMQ / RocketMQ

  2. 功能需求

    • 需要复杂路由(Exchange 多种类型)→ RabbitMQ

    • 需要事务消息、顺序消息、延时消息 → RocketMQ

    • 只需要简单的发布订阅 → Kafka

  3. 语言/生态

    • Java 技术栈 → RocketMQ(阿里系,Java 开发)

    • 大数据生态 → Kafka(和 Hadoop、Spark 等集成好)

    • 通用 → RabbitMQ(跨语言,客户端多)

  4. 运维成本

    • RabbitMQ:Erlang 开发,出问题不好排查

    • Kafka:依赖 Zookeeper(新版本用 KRaft),运维稍复杂

    • RocketMQ:Java 开发,对 Java 团队友好

  5. 延迟要求

    • 超低延迟 → RabbitMQ

    • 高吞吐优先 → Kafka

简单总结:

  • 业务系统,消息量不大,功能丰富 → RabbitMQ

  • 日志、大数据、超高吞吐 → Kafka

  • 电商、金融,Java 栈,功能全 → RocketMQ

追问2:消息队列有哪些常见的问题?

  1. 消息丢失:消息发出去了,但没到消费者

    • 生产者弄丢了

    • MQ 弄丢了

    • 消费者弄丢了

  2. 重复消费:同一条消息消费了多次

    • 网络问题,重试导致

    • 消费者处理完了但 ack 失败了

  3. 消息顺序性:消息的消费顺序和发送顺序不一致

    • 多分区/多队列导致

  4. 消息积压:消息太多,消费不过来

    • 消费者挂了

    • 消费速度跟不上生产速度

  5. 消息延迟:消息从生产到消费有延迟

    • 积压了就有延迟

这些都是面试高频问题,后面的题会详细讲。

追问3:什么是点对点和发布订阅模式?

消息队列的两种模式:

1. 点对点(Point-to-Point,P2P)

  • 一个消息只能被一个消费者消费

  • 消息发到队列(Queue)

  • 多个消费者监听同一个队列,只有一个能拿到

  • 类似:一个任务分给多个人抢,谁抢到谁做

举例: 订单处理队列,多个消费者抢订单处理

2. 发布订阅(Publish/Subscribe,Pub/Sub)

  • 一个消息可以被多个消费者消费

  • 消息发到主题(Topic)

  • 订阅了这个主题的消费者都能收到

  • 类似:报纸订阅,所有订阅的人都能收到

举例: 用户注册消息,积分服务、邮件服务、短信服务都订阅了,都能收到

RabbitMQ 两种模式都支持(通过不同的 Exchange 实现),Kafka 是发布订阅模式。

追问4:MQ 的消息怎么保证不丢?(整体思路)

消息丢失可能发生在三个环节:生产者、MQ、消费者。

整体思路:

  1. 生产者端:

    • 确认机制(confirm):发消息后等 MQ 确认收到

    • 失败重试:没收到确认就重试

    • 本地消息表:把消息存数据库,定时重发

  2. MQ 端:

    • 持久化:消息存磁盘,不丢

    • 集群:多副本,一个节点挂了还有其他的

    • 高可用:主从切换

  3. 消费者端:

    • 手动 ack:处理完了再确认,没处理完不确认

    • 失败重试:处理失败了重试

    • 死信队列:重试多次失败的进死信,人工处理

详细的后面的题会讲。

追问5:什么是最终一致性?和强一致性的区别?

强一致性:

  • 任何时刻,所有节点的数据都是一致的

  • 写操作完成后,所有读都能看到最新值

  • 代价大,性能差

最终一致性:

  • 允许短时间内不一致

  • 经过一段时间后,数据最终会一致

  • 性能好,可用性高

用了 MQ 异步之后,一般都是最终一致性:

  • A 服务更新了数据库,发消息

  • B 服务消费消息,更新自己的数据库

  • 中间有短暂的不一致

  • 最终会一致

大部分业务场景,最终一致性就够了。 强一致的场景很少,而且代价大。


46. Exchange 有哪些类型(Direct、Topic、Fanout、Headers)

标准答案

RabbitMQ 的核心概念:Producer → Exchange → Queue → Consumer。

Exchange(交换机)负责接收生产者的消息,并根据路由规则把消息路由到队列。

【RabbitMQ 核心概念】

1. Producer(生产者):发消息的一方

2. Exchange(交换机):接收消息,路由到队列

  • 生产者不直接发消息到队列,而是发给 Exchange

  • Exchange 根据规则把消息路由到队列

3. Queue(队列):存消息的地方

  • 消息最终存在队列里

  • 消费者从队列取消息

4. Binding(绑定):Exchange 和 Queue 之间的绑定关系

  • 绑定的时候指定 routing key(路由键)

  • Exchange 根据 routing key 决定路由到哪个队列

5. Consumer(消费者):消费消息的一方

6. Virtual Host(虚拟主机):

  • 逻辑隔离,类似数据库的 schema

  • 每个 vhost 有自己的 Exchange、Queue、权限

  • 不同 vhost 之间隔离

【四种 Exchange 类型】

1. Direct Exchange(直连交换机)

路由规则:消息的 routing key 和绑定的 routing key 完全匹配。

  • 最简单的类型

  • 消息带一个 routing key

  • Exchange 把消息路由到 routing key 完全匹配的队列

  • 一对一的路由

举例:

  • Exchange 绑定了两个队列:

    • Queue1:routing key = "order"

    • Queue2:routing key = "payment"

  • 发消息 routing key = "order" → 到 Queue1

  • 发消息 routing key = "payment" → 到 Queue2

适用场景:点对点、任务分发

2. Topic Exchange(主题交换机)

路由规则:routing key 可以用通配符匹配。

通配符规则:

  • *:匹配一个单词

  • #:匹配零个或多个单词

  • 单词之间用 . 分隔

举例:

  • Queue1:routing key = order.* → 匹配 order.create、order.cancel 等

  • Queue2:routing key = #.payment → 匹配 order.payment、user.payment 等

  • Queue3:routing key = order.# → 匹配 order.create、order.create.success 等

适用场景:发布订阅、需要灵活路由的场景、日志分类

3. Fanout Exchange(扇出交换机)

路由规则:广播,把消息发给所有绑定的队列。

  • 不管 routing key 是什么

  • 所有绑定到这个 Exchange 的队列都能收到消息

  • 纯广播模式

举例:

  • Fanout Exchange 绑定了 3 个队列

  • 发一条消息,3 个队列都收到

适用场景:广播、群发通知、扇出到多个消费者

4. Headers Exchange(头交换机)

路由规则:根据消息的 headers 属性 匹配,不看 routing key。

  • 用消息的 headers 来匹配

  • 绑定的时候指定 headers 的键值对

  • 匹配所有(all)或匹配任意一个(any)

举例:

  • Queue1 绑定:headers = {format: pdf, type: report},x-match = all

  • 消息的 headers 里有 format=pdf 且 type=report → 路由到 Queue1

适用场景:需要复杂路由规则、routing key 不够用的时候

  • 用得很少,一般 Topic 就够了


【四种 Exchange 对比】

| 类型 | 路由规则 | 匹配方式 | 适用场景 | 使用频率 | |------|---------|---------|----------|---------| | Direct | routing key 完全匹配 | 精确匹配 | 点对点、任务分发 | 高 | | Topic | routing key 通配符匹配 | 模式匹配 | 发布订阅、灵活路由 | 高 | | Fanout | 广播,所有队列都发 | 无匹配 | 广播、群发 | 中 | | Headers | headers 属性匹配 | 键值对匹配 | 复杂路由 | 低 |


高频追问

追问1:RabbitMQ 的消息流转过程是怎样的?

完整的消息流转:

  1. 生产者连接 RabbitMQ

    • 建立 TCP 连接(Connection)

    • 在连接里建信道(Channel)

    • 为什么用 Channel?减少 TCP 连接开销,一个连接多个信道,复用连接

  2. 生产者发消息

    • 消息发到指定的 Exchange

    • 消息带 routing key 和其他属性

  3. Exchange 路由

    • Exchange 收到消息

    • 根据 Exchange 类型和 routing key

    • 查找绑定的队列

    • 把消息路由到对应的队列

  4. 消息存到队列

    • 消息存在队列里

    • 等待消费者消费

  5. 消费者消费

    • 消费者监听队列

    • 收到消息,处理

    • 处理完发送 ack(确认)

  6. 消息删除

    • MQ 收到 ack,从队列删除消息

    • 如果没收到 ack,消费者断开了,消息会重新入队

追问2:什么是 Channel?为什么不用 Connection 直接发消息?

Channel(信道)是 Connection 内部的轻量级连接。

为什么需要 Channel?

  1. TCP 连接开销大

    • 建立和销毁 TCP 连接代价大

    • 每个消费者一个 TCP 连接太浪费

  2. Channel 复用 Connection

    • 一个 TCP 连接里可以有多个 Channel

    • Channel 之间是隔离的

    • 减少 TCP 连接数量

  3. 性能好

    • 不用频繁建连断连

    • 复用连接,性能好

类比:

  • Connection 就像一条光纤电缆

  • Channel 就像电缆里的光纤

  • 一根电缆里有多根光纤,互不干扰

追问3:RabbitMQ 怎么保证消息的顺序性?

RabbitMQ 的顺序性:

单队列单消费者:

  • 一个队列只有一个消费者

  • 消息是按顺序入队的

  • 消费也是按顺序的

  • 能保证顺序

单队列多消费者:

  • 不能保证顺序

  • 因为多个消费者消费速度不一样

  • 先拿到的不一定先处理完

多队列:

  • 更不能保证全局顺序

  • 不同队列之间没有顺序关系

怎么保证顺序:

  1. 单队列单消费者:最简单,但吞吐量低

  2. 按业务维度分片:

    • 同一个业务的消息发到同一个队列

    • 比如同一个订单的消息都发到 order-123 队列

    • 不同订单可以并行,同一个订单有序

  3. 消费者端排序:

    • 消息带序号

    • 消费者端缓存,按序号处理

    • 实现复杂

一般来说,不需要全局有序,只要局部有序(比如同一个订单的消息有序)就行。

追问4:RabbitMQ 的消息确认机制(ACK)是什么?

ACK(Acknowledge,确认)是消费者告诉 MQ "我处理完了"的机制。

两种模式:

  1. 自动确认(autoAck=true)

    • 消息发给消费者就自动确认

    • 消费者还没处理完就挂了 → 消息丢了

    • 性能好,但不可靠

  2. 手动确认(autoAck=false)

    • 消费者处理完了手动调用 basicAck 确认

    • 没确认的消息,消费者挂了 → 重新入队

    • 可靠,但性能稍差

相关方法:

  • basicAck:正常确认,消息删除

  • basicNack:拒绝,可选择是否重新入队

  • basicReject:拒绝单条消息(Nack 可以拒绝多条)

注意:

  • 忘记 ack 会导致消息不删除,越积越多,内存溢出

  • 一定要记得 ack,或者用 try-finally 保证

追问5:死信队列(DLX)是什么?有什么用?

死信队列(Dead Letter Exchange,DLX):消息变成死信后,会被重新发布到另一个 Exchange,这个 Exchange 就是 DLX。

消息变成死信的情况:

  1. 消息被拒绝(basicNack/basicReject),且 requeue=false

  2. 消息过期(TTL 到了)

  3. 队列满了,消息被丢弃

死信队列的作用:

  1. 处理失败的消息:

    • 消费失败的消息进死信队列

    • 不影响正常队列

    • 后面人工处理或重试

  2. 延时队列:

    • 消息设 TTL,过期后进死信队列

    • 消费者消费死信队列

    • 实现延时消息

  3. 监控告警:

    • 死信队列有消息了说明有问题

    • 监控死信队列,及时告警

怎么用:

  • 给队列设置 x-dead-letter-exchange 参数

  • 指定死信发到哪个 Exchange

  • 死信 Exchange 绑定死信队列

  • 消费者消费死信队列


47. RabbitMQ 如何保证消息可靠性

标准答案

消息可靠性是 MQ 的核心问题,消息可能在三个环节丢失:生产者、MQ、消费者。

【消息丢失的三个环节】

生产者 → MQ → 消费者
  ①      ②      ③

要保证消息不丢,三个环节都要保证。


【1. 生产者端:Confirm 确认机制】

问题:生产者发消息,MQ 没收到,消息丢了。

解决方案:Confirm 机制(发布确认)

原理:

  • 生产者发消息后,MQ 收到了会回一个确认

  • 生产者收到确认就知道消息到了

  • 没收到确认就重发

三种 Confirm 模式:

1. 普通 Confirm(同步)

channel.confirmSelect(); // 开启 Confirm 模式
channel.basicPublish(...); // 发消息
if (channel.waitForConfirms()) {
    // 发送成功
} else {
    // 发送失败,重发
}
  • 发一条等一条确认

  • 简单,但慢

2. 批量 Confirm(同步)

channel.confirmSelect();
// 发一批
for (int i = 0; i < 100; i++) {
    channel.basicPublish(...);
}
// 等一批确认
channel.waitForConfirms();
  • 发一批等一批确认

  • 比单条快,但失败了不知道哪条失败了

3. 异步 Confirm(推荐)

channel.confirmSelect();
// 回调
channel.addConfirmListener(new ConfirmListener() {
    @Override
    public void handleAck(long deliveryTag, boolean multiple) {
        // 确认成功
    }
    @Override
    public void handleNack(long deliveryTag, boolean multiple) {
        // 确认失败,重发
    }
});
  • 发消息不等确认

  • MQ 确认了回调通知

  • 性能最好

  • 推荐使用

配合:

  • 本地消息表:把消息存数据库,状态为"待发送"

  • 确认成功了改状态为"已发送"

  • 定时任务扫描超时的消息,重发


【2. MQ 端:持久化 + 集群】

问题:MQ 收到消息了,但还没存磁盘就挂了,消息丢了。

解决方案:

1. 持久化

三个地方都要持久化:

  • Exchange 持久化:声明 Exchange 时 durable=true

  • Queue 持久化:声明 Queue 时 durable=true

  • 消息持久化:发消息时 deliveryMode=2(持久化)

注意:三个都要持久化,缺一不可。

但持久化也不是 100% 不丢:

  • 消息到了 MQ,还没来得及刷盘就挂了 → 还是会丢

  • 因为操作系统有页缓存,不是实时刷盘的

怎么解决?

  • 开启持久化 + 集群(镜像队列)

  • 或者用事务(性能差)

  • 或者用 publisher confirm + 持久化,基本够用

2. 集群(镜像队列)

  • 单节点挂了消息就没了

  • 集群模式,消息有多个副本

  • 一个节点挂了,其他节点还有

  • 高可用

RabbitMQ 的镜像队列:

  • 队列的消息在多个节点都有副本

  • 主节点负责读写

  • 从节点同步

  • 主挂了从顶上


【3. 消费者端:手动 ACK】

问题:消费者拿到消息,还没处理完就挂了,MQ 以为处理完了,消息丢了。

解决方案:手动 ACK

原理:

  • 关闭自动确认(autoAck=false)

  • 消费者处理完消息,手动调用 basicAck 确认

  • 没确认的消息,如果消费者断开了,消息重新入队

代码示例:

channel.basicConsume(queueName, false, new DefaultConsumer(channel) {
    @Override
    public void handleDelivery(String consumerTag, Envelope envelope, 
                               AMQP.BasicProperties properties, byte[] body) {
        try {
            // 处理消息
            processMessage(body);
            // 处理完了,确认
            channel.basicAck(envelope.getDeliveryTag(), false);
        } catch (Exception e) {
            // 处理失败,拒绝,重新入队(或者进死信)
            channel.basicNack(envelope.getDeliveryTag(), false, true);
        }
    }
});

注意:

  • 一定要 ack,不然消息会一直不删,越积越多

  • 用 try-finally 或者 try-catch 保证

  • 处理失败可以重试,但要控制重试次数,不然会无限循环

  • 重试多次失败进死信队列


【完整的可靠性方案】

| 环节 | 方案 | 作用 | |------|------|------| | 生产者 | Confirm 机制 + 本地消息表 | 保证消息发到 MQ | | MQ | 持久化 + 镜像队列集群 | 保证 MQ 不丢消息 | | 消费者 | 手动 ACK + 重试 + 死信队列 | 保证消息被消费 |


高频追问

追问1:RabbitMQ 的事务了解吗?和 Confirm 的区别?

RabbitMQ 也支持事务,但用得少,因为性能差。

事务模式:

try {
    channel.txSelect(); // 开启事务
    channel.basicPublish(...); // 发消息
    channel.txCommit(); // 提交
} catch (Exception e) {
    channel.txRollback(); // 回滚
}

事务 vs Confirm:

| 维度 | 事务 | Confirm | |------|------|---------| | 性能 | 差,同步阻塞 | 好,异步确认 | | 可靠性 | 高 | 高 | | 粒度 | 一批消息 | 单条或批量 | | 使用复杂度 | 简单 | 稍复杂 | | 适用场景 | 对性能要求不高 | 大多数场景 |

事务性能差很多,一般都用 Confirm,不用事务。

追问2:本地消息表是什么?怎么实现?

本地消息表是保证消息可靠性的经典方案。

原理:

  • 业务操作和发消息不在同一个事务里

  • 用本地消息表把两者关联起来

  • 业务操作和写消息表在同一个数据库事务里

  • 定时任务扫描消息表,发消息

  • 发成功了改状态

实现步骤:

  1. 建消息表

CREATE TABLE message (
    id BIGINT PRIMARY KEY,
    content TEXT,        -- 消息内容
    status INT,          -- 状态:0待发送 1已发送 2失败
    retry_count INT,     -- 重试次数
    create_time DATETIME,
    update_time DATETIME
);
  1. 业务操作 + 写消息表(同一个事务)

@Transactional
public void createOrder(Order order) {
    // 1. 创建订单
    orderMapper.insert(order);
    // 2. 写消息表
    messageMapper.insert(new Message("order.create", order));
}
  1. 定时任务扫描发送

@Scheduled(fixedRate = 1000)
public void sendMessage() {
    // 查待发送的消息
    List<Message> messages = messageMapper.selectPending();
    for (Message msg : messages) {
        try {
            // 发消息
            rabbitTemplate.convertAndSend(msg.getContent());
            // 成功,改状态
            messageMapper.updateStatus(msg.getId(), 1);
        } catch (Exception e) {
            // 失败,重试次数 +1
            messageMapper.incrementRetry(msg.getId());
        }
    }
}

优点:

  • 简单可靠

  • 和业务数据库同一个事务,保证一致性

  • 有重试机制

缺点:

  • 和业务耦合

  • 消息表多了要分库分表

这是很经典的方案,面试经常问。

追问3:什么是消息幂等?怎么保证?

幂等:同一条消息消费一次和消费多次,结果一样。

为什么需要幂等?

  • 因为网络问题,可能重复消费

  • 重试机制也会导致重复消费

  • 所以消费者要保证幂等

保证幂等的方法:

  1. 唯一 ID + 数据库去重

    • 消息带唯一 ID

    • 消费前先查数据库,有没有消费过

    • 消费过就跳过,没消费过就处理

    • 处理完了把 ID 存进去

    • 用唯一索引保证不重复插入

  2. 乐观锁

    • 数据库加 version 字段

    • 更新的时候带 version

    • UPDATE ... SET ... WHERE id = ? AND version = ?

    • 更新成功就是没消费过,失败就是已经消费过了

  3. Redis 去重

    • 消息 ID 存 Redis

    • 消费前 SETNX,成功就处理,失败就跳过

    • 设置过期时间,避免 Redis 存太多

  4. 业务本身幂等

    • 比如更新状态,重复更新不影响

    • 比如删除,重复删也一样

    • 天然幂等的操作不用额外处理

推荐:唯一 ID + 数据库去重,或者 Redis 去重。

追问4:消息持久化了就一定不丢吗?

不一定。

持久化只是说消息会写到磁盘,但不是实时写的。

为什么还会丢?

  • 消息到了 RabbitMQ,先存在内存里

  • 操作系统有页缓存,不是立即刷盘

  • 还没来得及刷盘,机器断电了 → 消息丢了

怎么减少丢失?

  1. 镜像队列集群

    • 消息有多个副本,在不同机器上

    • 一台机器断电,其他机器还有

    • 概率低很多

  2. publisher confirm

    • 等消息持久化了(或者同步到副本了)再确认

    • 生产者收到确认才认为成功

    • 没收到就重发

  3. 事务

    • 事务提交后消息才真正生效

    • 但性能差

  4. 刷盘策略

    • 可以调整刷盘策略,但影响性能

    • 一般默认就够了

实际上,持久化 + 集群 + Confirm,已经非常可靠了,丢失概率极低。 要 100% 不丢是不可能的,只能降低概率。

追问5:怎么排查消息丢失的问题?

排查思路:

  1. 先确认消息有没有到 MQ

    • 看生产者日志,有没有发成功

    • 看 Confirm 回调,有没有确认

    • 看 MQ 管理界面,队列里有没有消息

    • 用 rabbitmqctl 命令查看

  2. 确认 MQ 有没有丢

    • 看队列消息数,有没有减少

    • 看 MQ 日志,有没有报错

    • 看节点状态,有没有挂过

    • 检查持久化配置对不对

  3. 确认消费者有没有消费

    • 看消费者日志,有没有收到消息

    • 看 ACK 有没有发

    • 看消费者有没有报错

    • 看死信队列有没有消息

  4. 抓包分析

    • 用 Wireshark 抓包

    • 看消息有没有发出去,有没有收到

  5. 开启消息追踪

    • RabbitMQ 有 firehose 功能,可以追踪消息流向

    • 但影响性能,生产环境慎用

一般从生产者 → MQ → 消费者一步步排查,看消息在哪一步丢的。


48. RabbitMQ 如何避免重复消费

标准答案

重复消费是 MQ 的常见问题,根本原因是网络问题或重试机制导致消息被多次投递。

【为什么会重复消费】

1. 生产者重复发

  • Confirm 机制下,生产者没收到确认,重发

  • 其实 MQ 已经收到了,只是确认丢了

  • 结果 MQ 里有两条一样的消息

2. 消费者重复消费

  • 消费者处理完了,发 ACK

  • ACK 丢了,或者消费者处理完就挂了,没来得及发 ACK

  • MQ 以为没处理,重新投递

  • 消费者又收到一次

3. 重试机制导致

  • 消费失败重试

  • 其实业务处理成功了,只是 ack 失败了

  • 重试又消费一次

核心原因:确认机制的不确定性

  • 发了确认,对方不一定收到

  • 收到了确认,不代表对方处理成功了

  • 所以不可避免会有重复


【怎么解决:幂等性】

重复消费不可避免,关键是保证幂等——消费多次和消费一次结果一样。

幂等性方案:

方案 1:唯一消息 ID + 数据库去重(推荐)

原理:

  • 每条消息带一个唯一 ID(比如 UUID 或业务 ID)

  • 消费者处理前先查数据库,有没有消费过这个 ID

  • 消费过就跳过,没消费过就处理

  • 处理完把 ID 存到去重表

实现:

  1. 消息带唯一 ID

// 发消息时带 messageId
message.getMessageProperties().setMessageId(UUID.randomUUID().toString());
  1. 去重表

CREATE TABLE msg_consume_log (
    id BIGINT PRIMARY KEY AUTO_INCREMENT,
    message_id VARCHAR(64) UNIQUE,  -- 消息唯一ID,唯一索引
    status INT,                     -- 消费状态
    create_time DATETIME
);
  1. 消费逻辑

@Transactional
public void consume(Message message) {
    String messageId = message.getMessageProperties().getMessageId();
    
    // 1. 先查有没有消费过
    if (msgConsumeLogMapper.exists(messageId)) {
        return; // 消费过了,直接返回
    }
    
    // 2. 处理业务
    processBusiness(message);
    
    // 3. 记录消费日志
    msgConsumeLogMapper.insert(messageId, 1);
}

注意:

  • message_id 要加唯一索引,防止并发插入

  • 业务处理和写日志要在同一个事务里

  • 用唯一索引兜底,即使并发判断了也不会重复插入

方案 2:Redis 去重

原理:

  • 用 Redis 的 SETNX 命令

  • 消息 ID 存 Redis

  • SETNX 成功 → 没消费过,处理

  • SETNX 失败 → 已经消费过,跳过

实现:

String messageId = message.getMessageProperties().getMessageId();
String key = "msg:consumed:" + messageId;

Boolean success = redisTemplate.opsForValue()
    .setIfAbsent(key, "1", 24, TimeUnit.HOURS); // 设过期时间

if (success) {
    // 没消费过,处理
    processBusiness(message);
} else {
    // 已经消费过,跳过
}

优点:

  • 性能好,Redis 快

  • 有过期时间,不用清理

缺点:

  • Redis 挂了可能有问题

  • 业务处理失败了,但 Redis 已经设了,就不会重试了

  • 可以改成:先处理,处理成功了再设 Redis

方案 3:乐观锁

原理:

  • 数据库加 version 字段

  • 更新的时候带 version

  • 更新成功 → 没消费过

  • 更新失败 → 已经消费过了

举例:

-- 加 version 字段
ALTER TABLE account ADD version INT DEFAULT 0;

-- 更新时带 version
UPDATE account SET balance = balance - 100, version = version + 1 
WHERE id = 1 AND version = 0;
  • 第一次更新,version=0,成功

  • 第二次更新,version 已经是 1 了,失败

  • 天然幂等

适用场景: 更新操作,有状态的

方案 4:业务天然幂等

有些操作天然就是幂等的:

  • 删除:删一次和删多次一样

  • 查询:查多少次都一样

  • 赋值:把 status 设为 1,设多少次都一样

这些不用额外处理。


【方案对比】

| 方案 | 优点 | 缺点 | 适用场景 | |------|------|------|----------| | 唯一ID+数据库去重 | 可靠,有记录 | 有数据库开销 | 大多数业务场景 | | Redis 去重 | 性能好 | 可能丢,可靠性稍差 | 高并发、对可靠性要求不极高 | | 乐观锁 | 简单,和业务结合 | 只适合更新操作 | 更新类操作 | | 天然幂等 | 不用额外处理 | 不是所有操作都适用 | 查询、删除、赋值 |


高频追问

追问1:重复消费和消息丢失,哪个更严重?

看业务场景:

  • 金融、支付等场景:重复消费更严重

    • 钱扣两次就麻烦了

    • 宁可丢消息(其实也不能丢),也不能重复

    • 其实两个都不能有,要同时保证

  • 日志、通知等场景:丢消息更严重

    • 重复消费影响不大(日志重复了也没事)

    • 丢了就没了

一般来说:

  • 消息丢失:数据丢失,可能找不回来

  • 重复消费:数据重复,可以通过幂等解决

所以通常的做法是:

  • 先保证消息不丢(可靠性)

  • 再保证幂等(重复消费也不怕)

  • 两者都要做

追问2:怎么设计一个通用的幂等框架?

通用幂等框架的思路:

  1. 注解驱动

@Idempotent(key = "#message.id", expireTime = 3600)
public void consume(Message message) {
    // 业务逻辑
}
  1. AOP 拦截

  • 拦截加了 @Idempotent 注解的方法

  • 提取 key(支持 SpEL)

  • 去 Redis 或数据库查

  • 存在就跳过,不存在就执行

  • 执行完标记已消费

  1. 多种存储支持

  • Redis:高性能

  • 数据库:高可靠

  • 可配置切换

  1. 过期清理

  • 自动清理过期的幂等记录

  • 避免存储膨胀

  1. 防并发

  • 分布式锁防止并发重复执行

  • 或者用唯一索引兜底

这是一个常见的设计题,考察架构设计能力。

追问3:消费失败重试,会不会导致重复消费?

会的,重试本身就可能导致重复消费。

场景:

  1. 消费者处理业务成功了

  2. 但 ack 之前报错了(或者网络问题)

  3. MQ 没收到 ack,重新投递

  4. 消费者又消费一次

  5. 业务就执行了两次

怎么解决:

  • 幂等!幂等!幂等!

  • 重试不可避免,只要保证幂等就行

  • 重试多少次都不怕,因为结果一样

重试策略:

  • 不要无限重试

  • 有最大重试次数

  • 重试间隔递增(指数退避)

  • 重试多次失败进死信队列,人工处理

追问4:RabbitMQ 的重试机制怎么配置?

Spring Boot + RabbitMQ 的重试配置:

spring:
  rabbitmq:
    listener:
      simple:
        retry:
          enabled: true          # 开启重试
          max-attempts: 3        # 最大重试次数
          initial-interval: 1000 # 初始间隔 1s
          multiplier: 2          # 间隔倍数(指数退避)
          max-interval: 10000    # 最大间隔 10s

重试是在消费者端做的,不是 MQ 端。

重试次数用完了还失败怎么办?

  • 可以配置 MessageRecoverer

  • 比如发到死信队列

  • 或者记录日志

@Bean
public MessageRecoverer messageRecoverer(RabbitTemplate rabbitTemplate) {
    return new RepublishMessageRecoverer(rabbitTemplate, "dlx.exchange", "dlx.routingkey");
}

重试失败的消息转发到死信交换机。

追问5:什么是 exactly-once?RabbitMQ 能做到吗?

消息投递的三个语义:

  1. at most once(最多一次)

    • 消息最多投一次,可能丢

    • 性能最好

  2. at least once(至少一次)

    • 消息至少投一次,不会丢,但可能重复

    • 最常用,可靠性高

  3. exactly-once(恰好一次)

    • 消息恰好投一次,不丢也不重复

    • 最理想,但很难实现

RabbitMQ 能做到 exactly-once 吗?

  • 不能,RabbitMQ 保证的是 at least once

  • 因为网络的不确定性,无法保证 exactly-once

  • TCP 协议也是 at least once 的语义

怎么实现 exactly-once 的效果?

  • 用 at least once + 幂等

  • 虽然消息可能重复,但通过幂等保证结果和 exactly-once 一样

  • 业务上的 exactly-once

这是业界通用的做法,没有真正的 exactly-once,都是"至少一次 + 幂等"来实现的。


49. RabbitMQ 消息积压如何处理

标准答案

消息积压:消息生产速度 > 消费速度,消息在队列里越积越多。

【消息积压的原因】

1. 消费者出问题

  • 消费者挂了,完全不消费了

  • 消费者处理变慢了(比如依赖的服务慢了)

  • 消费者有 bug,处理失败一直重试

2. 流量突增

  • 大促、秒杀,流量突然变大

  • 消费者扛不住

3. 生产者发太快

  • 生产者发消息速度太快

  • 消费者跟不上

【消息积压的危害】

  1. 内存/磁盘占满

    • 消息积压太多,占满内存/磁盘

    • MQ 挂了,更麻烦

  2. 消息过期丢失

    • 消息有 TTL,过期了就丢了

    • 或者进死信队列

  3. 新消息发不进去

    • MQ 满了,生产者发消息失败

    • 影响业务

  4. 业务延迟

    • 消息处理不及时

    • 业务延迟大


【怎么处理消息积压】

紧急处理思路:先快速消费,再排查原因。


第一步:紧急扩容消费者(最快)

做法:

  • 增加消费者数量

  • 让更多消费者一起消费

  • 加快消费速度

注意:

  • 单队列的消费者数量不是越多越好

  • 太多了反而因为竞争导致性能下降

  • 一般一个队列几个到十几个消费者

  • 如果消费者已经很多了还不够,就要加队列


第二步:临时队列 + 批量消费

如果消费者已经加不上去了,用这个方法:

做法:

  1. 写一个临时的消费者程序

  2. 把积压的消息快速消费出来,存到其他地方(比如另一个 MQ、数据库)

  3. 不做业务处理,只是搬消息

  4. 先把积压的消息搬走,让 MQ 恢复

  5. 后面慢慢处理搬走的消息

优点:

  • 最快速度缓解积压

  • 先保证 MQ 不挂

缺点:

  • 消息搬走了,业务处理还是滞后

  • 只是缓解,不是根本解决


第三步:排查根本原因

积压缓解后,排查为什么会积压:

  1. 消费者是不是挂了?

    • 看消费者进程在不在

    • 看日志有没有报错

    • 重启消费者

  2. 消费是不是变慢了?

    • 看消费者的处理时间

    • 是不是依赖的服务慢了(数据库、第三方接口)

    • 优化消费逻辑

  3. 是不是流量突增?

    • 看生产速度

    • 是不是有活动、大促

    • 临时扩容消费者

  4. 是不是有大量失败重试?

    • 看死信队列

    • 看消费者错误日志

    • 修复 bug


第四步:长期优化

  1. 消费者性能优化

    • 优化消费逻辑,减少处理时间

    • 批量处理

    • 异步处理非核心逻辑

  2. 合理设置消费者数量

    • 根据流量预估

    • 留一定余量

    • 可以自动扩缩容

  3. 监控告警

    • 监控队列消息数

    • 监控消费速度

    • 积压到一定程度告警

    • 提前发现,提前处理

  4. 限流

    • 生产者端限流

    • 防止流量突增打垮 MQ


【预防措施】

  1. 监控告警

    • 队列消息数、消费速度、生产速度

    • 积压阈值告警

  2. 合理的容量规划

    • 根据峰值流量规划消费者数量

    • 留 30%~50% 余量

  3. 消费者高可用

    • 多实例部署

    • 防止单点故障

  4. 限流降级

    • 生产者限流

    • 消费不过来时降级处理


高频追问

追问1:怎么监控消息积压?

监控指标:

  1. 队列消息数

    • ready:待消费的消息数

    • unacked:已发给消费者但没确认的

    • total:总数

    • 这是最核心的指标

  2. 生产速率

    • 每秒生产多少消息

  3. 消费速率

    • 每秒消费多少消息

    • 生产速率 > 消费速率 → 会积压

  4. 消费者数量

    • 在线消费者数

    • 消费者少了可能有问题

  5. 死信队列消息数

    • 死信多了说明消费失败多

监控方式:

  • RabbitMQ 管理界面

  • rabbitmqctl 命令

  • Prometheus + Grafana(rabbitmq_exporter)

  • 云厂商自带的监控

告警阈值:

  • 队列消息数超过阈值(比如 10000 条)

  • 消费速率持续低于生产速率

  • 消费者数量减少

追问2:积压的消息过期了怎么办?

如果消息设了 TTL,积压太久过期了就丢了(或者进死信队列)。

处理方式:

  1. 死信队列

    • 过期的消息进死信队列

    • 不会直接丢

    • 后面可以从死信队列恢复

  2. 延长 TTL

    • 如果还没过期,紧急延长 TTL

    • 但已经发出去的消息改不了 TTL

  3. 从源头恢复

    • 如果消息丢了,看能不能从源头重新生成

    • 比如从数据库重新查,重新发

    • 看业务场景

  4. 数据补偿

    • 丢了的消息,人工补偿

    • 或者跑批处理补偿

教训:

  • 重要消息一定要配死信队列

  • TTL 不要设太短

  • 监控要到位,别等消息都过期了才发现

追问3:一个队列的消费者数量有上限吗?

有上限,不是越多越好。

为什么有上限?

  • 多个消费者从同一个队列取消息,有竞争

  • 消费者太多,竞争开销大

  • 而且 RabbitMQ 给消费者发消息也要开销

  • 一般一个队列十几个消费者就差不多了

怎么判断够不够?

  • 看消费者的 CPU、内存使用率

  • 看消费速度有没有跟上

  • 加消费者后消费速度有没有提升

  • 如果加了消费者速度没提升,说明到瓶颈了

消费者不够了怎么办?

  • 加队列,把消息分散到多个队列

  • 每个队列有自己的消费者

  • 整体吞吐量就上去了

  • 比如按业务维度分队列:order-1、order-2、order-3...

追问4:消费速度跟不上,怎么优化消费者?

优化消费者性能:

  1. 优化业务逻辑

    • 减少处理时间

    • 去掉不必要的操作

    • 优化数据库查询(加索引、减少 SQL)

    • 优化 RPC 调用(并行调用、缓存)

  2. 批量处理

    • 一次消费多条消息,批量处理

    • 比如批量插入数据库

    • 减少 IO 次数

  3. 异步化

    • 非核心逻辑异步处理

    • 比如发通知、记日志,不用同步等

    • 先处理核心逻辑,快速 ack

  4. 多线程消费

    • 消费者内部用线程池

    • 收到消息后丢给线程池处理

    • 注意 ack 的时机,处理完了再 ack

    • 控制并发数,别把下游打挂了

  5. 水平扩展

    • 加消费者实例

    • 加队列

    • 横向扩展

追问5:生产端要不要限流?怎么限?

要的,生产端限流是保护 MQ 和下游的重要手段。

什么时候限流:

  • MQ 快满了

  • 消费速度跟不上

  • 下游系统扛不住

限流方式:

  1. 生产者端限流

    • 控制发消息的速度

    • 用令牌桶、漏桶算法

    • Sentinel、Guava RateLimiter 等

  2. MQ 端限流

    • RabbitMQ 有流量控制

    • 内存/磁盘快满了会阻塞生产者

    • 保护自己不被打挂

  3. 消费者端限流

    • 控制消费速度

    • 保护下游系统

    • 比如 prefetch 控制每次发多少消息给消费者

推荐:

  • 生产者端限流 + 监控告警

  • 提前限流,不要等 MQ 满了才处理

  • 限流是保护系统的重要手段


50. Kafka 为什么吞吐量高

标准答案

Kafka 是高吞吐量的分布式消息系统,单节点就能达到几十万甚至上百万的 QPS。

【Kafka 高吞吐的原因】

1. 顺序读写磁盘

Kafka 的消息是存在磁盘上的,但为什么还这么快?

因为是顺序读写。

  • 传统数据库是随机读写,慢

  • Kafka 是顺序追加写(append-only),消息只往文件末尾追加

  • 顺序读写磁盘的速度很快,甚至比随机内存读写还快

  • 机械盘顺序读写:几百 MB/s

  • 机械盘随机读写:几 MB/s

  • 差了几百倍

为什么顺序读写快?

  • 磁盘寻道时间长(机械臂移动)

  • 顺序读写不用频繁寻道

  • 一直读/写连续的位置

  • 磁盘预读也能发挥作用

2. 页缓存(Page Cache)

Kafka 大量利用操作系统的页缓存(Page Cache)。

  • 写消息:先写到页缓存,由操作系统异步刷盘

  • 读消息:直接从页缓存读,不用读磁盘

  • 大部分读请求都命中页缓存,和读内存差不多

好处:

  • 不用自己管理缓存,用操作系统的就够了

  • 进程重启,缓存还在(操作系统的)

  • 读写都在内存,性能好

3. 零拷贝(Zero Copy)

传统的文件发送:

磁盘 → 内核缓冲区 → 用户缓冲区 → Socket 缓冲区 → 网卡
  • 4 次数据拷贝

  • 2 次系统调用

  • 性能差

零拷贝(sendfile):

磁盘 → 内核缓冲区 → 网卡
  • 2 次拷贝(DMA 拷贝)

  • 1 次系统调用

  • 不用经过用户空间

  • 性能好很多

Kafka 用 sendfile 实现零拷贝,数据直接从磁盘(页缓存)发到网卡,不用经过应用层。

4. 批量处理

Kafka 不是一条一条处理消息,而是批量处理。

  • 生产者:多条消息攒成一批,一起发

  • Broker:批量写入磁盘

  • 消费者:批量拉取消息

批量处理减少了网络 IO 次数和磁盘 IO 次数,大大提高了吞吐量。

5. 压缩

Kafka 支持消息压缩:

  • 生产者批量压缩后发送

  • Broker 存压缩后的消息

  • 消费者解压

  • 减少网络传输量和磁盘存储量

  • 用 CPU 换带宽和存储

支持的压缩算法:gzip、snappy、lz4、zstd

6. 分区(Partition)并行

Kafka 的 Topic 分成多个 Partition:

  • 不同 Partition 可以在不同 Broker 上

  • 读写都可以并行

  • 分区越多,并行度越高,吞吐量越大

  • 水平扩展

7. 高效的数据结构

  • 消息按顺序存,简单的 append-only 日志

  • 索引稀疏索引,节省空间

  • 数据结构简单,操作快


【总结:Kafka 高吞吐的原因】

| 优化点 | 作用 | |--------|------| | 顺序读写磁盘 | 磁盘操作也很快 | | 页缓存 | 读写都在内存,性能好 | | 零拷贝 | 减少数据拷贝,提高传输效率 | | 批量处理 | 减少 IO 次数 | | 压缩 | 减少传输和存储量 | | 分区并行 | 水平扩展,提高并行度 | | 高效数据结构 | 操作简单快速 |


高频追问

追问1:Kafka 和 RabbitMQ 吞吐量差多少?为什么?

大概差一个数量级:

  • RabbitMQ:万级 ~ 十万级 QPS

  • Kafka:十万级 ~ 百万级 QPS

为什么 Kafka 吞吐更高?

  1. 存储方式不同

    • RabbitMQ:消息存在队列里,要维护队列、消息状态等,结构复杂

    • Kafka:简单的日志文件,append-only,结构简单

    • 简单的东西性能更好

  2. 读写模型不同

    • RabbitMQ:每条消息都要确认、删除,操作多

    • Kafka:消息存在日志里,消费只是移动 offset,不删消息

    • 读操作非常轻量

  3. 批量和压缩

    • Kafka 批量和压缩做得更极致

    • 吞吐量更高

  4. 分区并行

    • Kafka 的分区并行度更高

    • 更容易水平扩展

  5. 设计目标不同

    • RabbitMQ:面向业务消息,功能丰富,可靠性高

    • Kafka:面向日志/大数据,高吞吐,简单

    • 设计目标不同,取舍不同

追问2:Kafka 是存在磁盘上的,为什么还这么快?

很多人以为磁盘就慢,其实要看怎么用。

顺序读写 vs 随机读写:

| 操作 | 速度(机械盘) | |------|---------------| | 顺序读 | ~500 MB/s | | 顺序写 | ~300 MB/s | | 随机读 | ~1 MB/s | | 随机写 | ~1 MB/s |

差了几百倍!

Kafka 是顺序读写,所以磁盘操作很快。

再加上:

  • 页缓存:大部分读都在内存

  • 零拷贝:传输快

  • 批量:减少 IO 次数

所以虽然存在磁盘上,但性能非常好。

而且存磁盘有好处:

  • 容量大,内存存不下那么多

  • 持久化,断电不丢

  • 可以存很久,回溯消费

追问3:什么是零拷贝?有几种实现?

零拷贝:减少数据拷贝次数,提高 IO 效率。

传统 IO(4 次拷贝):

1. 磁盘 → 内核缓冲区(DMA 拷贝)
2. 内核缓冲区 → 用户缓冲区(CPU 拷贝)
3. 用户缓冲区 → Socket 缓冲区(CPU 拷贝)
4. Socket 缓冲区 → 网卡(DMA 拷贝)
  • 4 次拷贝

  • 2 次系统调用(read + write)

  • 2 次 CPU 拷贝

mmap(3 次拷贝):

  • 内存映射,把文件映射到用户空间

  • 不用 read/write 系统调用

  • 直接操作内存

1. 磁盘 → 内核缓冲区(DMA 拷贝)
2. 内核缓冲区和用户空间共享(映射)
3. 内核缓冲区 → Socket 缓冲区(CPU 拷贝)
4. Socket 缓冲区 → 网卡(DMA 拷贝)
  • 3 次拷贝(其实还是 4 次,但少了一次 CPU 拷贝)

  • 减少了一次 CPU 拷贝

sendfile(2 次拷贝):

  • 一个系统调用完成文件传输

  • 数据不经过用户空间

1. 磁盘 → 内核缓冲区(DMA 拷贝)
2. 内核缓冲区 → 网卡(DMA 拷贝,带 gather 操作)
  • 2 次拷贝,都是 DMA 拷贝

  • 没有 CPU 拷贝

  • 真正的零拷贝(零 CPU 拷贝)

Kafka 用的就是 sendfile 实现零拷贝。

追问4:Kafka 的消息是存在内存还是磁盘?

都有,但主要是磁盘,内存是缓存。

存储机制:

  1. 写消息

    • 先写到操作系统的页缓存(Page Cache)

    • 由操作系统异步刷到磁盘

    • 也可以配置同步刷盘,但性能差

    • 默认是异步刷盘

  2. 读消息

    • 先看页缓存里有没有,有就直接读

    • 没有就读磁盘

    • 热点数据都在页缓存里,读很快

    • 冷数据才读磁盘

  3. 持久化

    • 消息最终是存在磁盘上的

    • 持久化保存

    • 可以存很久(根据保留策略)

所以:

  • 热数据:在内存(页缓存),读写都快

  • 冷数据:在磁盘,读稍慢,但也能读

  • 容量大,持久化

Kafka 的设计就是利用了页缓存,热数据在内存,冷数据在磁盘,兼顾性能和容量。

追问5:Kafka 怎么保证高吞吐的同时还保证可靠性?

高吞吐和可靠性看起来是矛盾的,但 Kafka 做到了平衡。

可靠性保证:

  1. 多副本(Replication)

    • 每个 Partition 有多个副本

    • 一个 Leader,多个 Follower

    • Leader 挂了 Follower 顶上

    • 数据不丢

  2. 持久化

    • 消息存在磁盘上

    • 断电不丢

  3. 确认机制(acks)

    • acks=0:不等确认,性能最好,可能丢

    • acks=1:Leader 确认就行,性能中等,Leader 挂了可能丢

    • acks=all:所有 ISR 都确认,最可靠,性能最差

  4. 生产者重试

    • 发送失败重试

    • 保证消息发出去

高吞吐保证:

  • 顺序读写

  • 页缓存

  • 零拷贝

  • 批量

  • 压缩

  • 分区并行

怎么平衡:

  • 异步刷盘 + 多副本

    • 不用每条都同步刷盘(性能差)

    • 靠多副本保证可靠性

    • 一个节点挂了,其他节点还有

  • 批量 + 压缩

    • 批量发送,减少 IO 次数

    • 压缩减少传输量

    • 即使多副本同步,也很快

核心思路:用批量和并行来弥补可靠性带来的开销。 不是每条消息都确认,而是一批一批确认,效率高。


51. Consumer Group 原理

标准答案

Consumer Group(消费者组)是 Kafka 的核心概念,实现了发布订阅模式和负载均衡。

【什么是 Consumer Group】

消费者组是一组消费者的集合,它们一起消费一个 Topic 的消息。

特点:

  • 一个 Consumer Group 里的多个消费者

  • 共同消费一个 Topic 的所有 Partition

  • 每个 Partition 只会被组内的一个消费者消费

  • 一个消费者可以消费多个 Partition

两种模式:

1. 队列模式(点对点)

  • 所有消费者在同一个 Group

  • 一条消息只会被一个消费者消费

  • 负载均衡

2. 发布订阅模式

  • 每个消费者在不同的 Group

  • 一条消息会被每个 Group 都消费一次

  • 广播

举例:

  • Topic 有 3 个 Partition

  • Group A 有 3 个消费者 → 每个消费者消费 1 个 Partition

  • Group B 有 1 个消费者 → 消费所有 3 个 Partition

  • 一条消息会被 Group A 的一个消费者和 Group B 的消费者都消费到


【Partition 分配策略】

一个 Group 里的消费者怎么分配 Partition?

分配原则:

  • 一个 Partition 只能被组内一个消费者消费

  • 一个消费者可以消费多个 Partition

  • 尽量均匀分配

常见分配策略:

1. RangeAssignor(范围分配,默认)

  • 按 Partition 序号范围分配

  • 比如 10 个 Partition,3 个消费者:

    • 消费者 1:0、1、2、3

    • 消费者 2:4、5、6

    • 消费者 3:7、8、9

  • 前面的消费者可能多一个

2. RoundRobinAssignor(轮询分配)

  • 按轮询方式分配

  • 10 个 Partition,3 个消费者:

    • 消费者 1:0、3、6、9

    • 消费者 2:1、4、7

    • 消费者 3:2、5、8

  • 更均匀

3. StickyAssignor(粘性分配)

  • 尽量保持原来的分配

  • 消费者变化时,尽量少移动 Partition

  • 减少重平衡的开销

  • 同时也保证均匀

注意:

  • 消费者数量 > Partition 数量 → 多的消费者空闲,浪费

  • 所以消费者数量不要超过 Partition 数量

  • 要提高消费并行度,就加 Partition


【重平衡(Rebalance)】

什么是重平衡:

  • 消费者组里的消费者变化了(加了、减了)

  • 或者 Topic 的 Partition 变化了

  • 就要重新分配 Partition 和消费者的对应关系

  • 这个过程叫 Rebalance(重平衡)

触发 Rebalance 的情况:

  1. 消费者加入组

  2. 消费者离开组(挂了、主动退出)

  3. 消费者心跳超时,被认为挂了

  4. 增加了 Partition

  5. 订阅的 Topic 变了

Rebalance 的过程:

  1. Join Group

    • 消费者向 Coordinator(协调者,某个 Broker)发送 JoinGroup 请求

    • 所有消费者都加入后,Coordinator 选一个消费者作为 Leader

    • Leader 负责制定分配方案

  2. Sync Group

    • Leader 制定分配方案

    • Leader 把方案发给 Coordinator

    • Coordinator 把分配结果发给所有消费者

    • 每个消费者知道自己消费哪些 Partition

Rebalance 的问题:

  • Rebalance 期间,消费者不能消费消息

  • 整个组暂停消费

  • 频繁 Rebalance 影响性能

  • 要尽量避免不必要的 Rebalance


【Coordinator(协调者)】

每个 Consumer Group 有一个 Coordinator,负责管理这个组。

Coordinator 是谁?

  • 是一个 Broker

  • 哪个 Broker?由 Consumer Group 的 group.id 决定

  • __consumer_offsets 这个内部 Topic 有 50 个 Partition(默认)

  • group.id 哈希取模,找到对应的 Partition

  • 这个 Partition 的 Leader 所在的 Broker 就是 Coordinator

Coordinator 的作用:

  • 管理消费者组成员

  • 处理 Rebalance

  • 管理 Offset 提交(Offset 存在 __consumer_offsets 里)


高频追问

追问1:消费者数量和 Partition 数量是什么关系?

关系:

  • 一个 Partition 只能被同一个 Consumer Group 里的一个消费者消费

  • 一个消费者可以消费多个 Partition

  • 消费者数量 ≤ Partition 数量时,每个消费者消费 1 个或多个 Partition

  • 消费者数量 > Partition 数量时,多出来的消费者空闲,不消费任何 Partition

举例:

  • Topic 有 3 个 Partition

  • Group 有 2 个消费者 → 一个消费 2 个,一个消费 1 个

  • Group 有 3 个消费者 → 每个消费 1 个

  • Group 有 5 个消费者 → 3 个各消费 1 个,2 个空闲

怎么提高消费能力?

  • 加 Partition

  • 加消费者(但不要超过 Partition 数量)

  • 两者一起加

经验:

  • Partition 数量决定了最大并行度

  • 要高吞吐,就多设 Partition

  • 但 Partition 也不是越多越好,太多了有开销

追问2:Rebalance 有什么问题?怎么避免频繁 Rebalance?

Rebalance 的问题:

  1. 消费暂停

    • Rebalance 期间整个组都不能消费

    • 影响吞吐量

  2. 重复消费

    • Rebalance 前消费者消费了一些消息,还没提交 offset

    • Rebalance 后 Partition 分给了另一个消费者

    • 新消费者从上次提交的 offset 开始消费

    • 已经消费但没提交的就重复消费了

  3. 开销大

    • 频繁 Rebalance 消耗资源

    • 影响性能

怎么避免频繁 Rebalance:

  1. 调整心跳和超时时间

    • session.timeout.ms:会话超时时间,太久没心跳就认为挂了

    • heartbeat.interval.ms:心跳间隔

    • 设长一点,避免因为网络抖动被误判挂了

    • 但也不能太长,否则真挂了发现晚

  2. 处理时间不要太长

    • max.poll.interval.ms:两次 poll 的最大间隔

    • 消费处理时间太长,超过这个值,就会被认为挂了

    • 触发 Rebalance

    • 优化消费逻辑,减少处理时间

    • 或者调大这个值

  3. 不要频繁启停消费者

    • 发布、扩容时尽量平滑

    • 不要一个一个频繁加

  4. 静态成员(Static Membership)

    • Kafka 2.3+ 支持静态成员

    • 消费者有固定的 group.instance.id

    • 重启不触发 Rebalance

    • 等一段时间,没回来才触发

    • 减少不必要的 Rebalance

追问3:Offset 存在哪里?怎么管理的?

Offset(消费偏移量)记录了消费者消费到哪里了。

Offset 存在哪?

  • 存在 Kafka 的一个内部 Topic 里:__consumer_offsets

  • 这个 Topic 默认有 50 个 Partition

  • 按 group.id 哈希,存到不同的 Partition

  • 是一个 Topic,所以也是高可用的

为什么存在 Kafka 里,不存 ZooKeeper?

  • 早期版本 Offset 存在 ZooKeeper 里

  • 但 ZooKeeper 不适合频繁写

  • 消费者频繁提交 Offset,ZooKeeper 扛不住

  • 所以移到了 Kafka 内部的 Topic 里

  • Kafka 自己就是存消息的,存 Offset 很合适

Offset 的管理:

  • 消费者提交 Offset:告诉 Kafka 我消费到哪了

  • 下次启动从提交的 Offset 继续消费

  • Coordinator 负责管理 Offset

提交方式:

  • 自动提交:定时自动提交,简单,但可能重复消费

  • 手动提交:自己控制提交时机,更灵活

追问4:一个消费者组里的消费者怎么协调?

消费者之间不直接通信,通过 Coordinator 协调。

协调过程:

  1. 加入组

    • 每个消费者启动时,向 Coordinator 发 JoinGroup 请求

    • Coordinator 收集所有成员

  2. 选 Leader

    • Coordinator 选第一个加入的消费者作为 Leader

    • Leader 负责制定分配方案

  3. 分配方案

    • Leader 根据分配策略(Range、RoundRobin 等)制定方案

    • Leader 把方案发给 Coordinator

  4. 同步分配结果

    • Coordinator 把分配结果发给所有消费者

    • 每个消费者知道自己消费哪些 Partition

  5. 心跳维持

    • 消费者定期向 Coordinator 发心跳

    • 证明自己还活着

    • 心跳超时就认为挂了,触发 Rebalance

  6. Offset 提交

    • 消费者向 Coordinator 提交 Offset

    • Coordinator 存到 __consumer_offsets

所以消费者之间不直接通信,都和 Coordinator 通信。 Coordinator 是中间协调者。

追问5:怎么设计 Topic 的 Partition 数量?

Partition 数量很重要,决定了吞吐量和并行度。

考虑因素:

  1. 吞吐量

    • 单 Partition 的吞吐量(生产+消费)

    • 目标吞吐量 / 单 Partition 吞吐量 = 大概需要的 Partition 数

    • 经验:单 Partition 几 MB/s ~ 几十 MB/s,看消息大小

  2. 消费者并行度

    • 最大消费者并行度 = Partition 数量

    • 需要多少消费者并行,就至少多少 Partition

  3. 生产者并行度

    • 生产者也可以并行写

    • Partition 越多,写入并行度越高

  4. Broker 数量

    • Partition 要分布在不同 Broker 上

    • Partition 数量建议是 Broker 数量的倍数

    • 比如 3 个 Broker,设 6、9、12 个 Partition

  5. 不要太多

    • Partition 不是越多越好

    • 每个 Partition 有开销(文件句柄、内存、Leader 选举等)

    • 太多了影响性能

    • 单 Broker 几百个 Partition 就差不多了

经验值:

  • 单 Topic 几个到几十个 Partition 比较常见

  • 特别大的 Topic 上百个

  • 不要一开始就设几千个

原则:

  • 先预估,留一定余量

  • 不够了可以加 Partition(但加 Partition 有代价)

  • 从少到多,逐步调整


52. Offset 提交机制

标准答案

Offset(偏移量)记录了消费者消费到哪个位置了,下次从哪里继续消费。

【什么是 Offset】

每个 Partition 的消息都有一个 Offset,是消息的序号,从 0 开始递增。

消费者消费消息,要记录消费到哪个 Offset 了,这个过程叫提交 Offset。

下次消费者启动(或者 Rebalance 后),从提交的 Offset 继续消费。


【Offset 提交方式】

1. 自动提交(enable.auto.commit = true)

原理:

  • 消费者自动提交 Offset

  • 每隔一段时间提交一次

  • 间隔由 auto.commit.interval.ms 配置,默认 5 秒

优点:

  • 简单,不用管

  • 省心

缺点:

  • 可能重复消费

    • 消费了一批消息,还没到提交时间,消费者挂了

    • 下次从上次提交的 Offset 开始

    • 已经消费但没提交的就重复消费了

  • 可能丢消息

    • 提交了 Offset,但消息还没处理完

    • 消费者挂了,下次从提交的位置继续

    • 没处理完的消息就丢了

    • (其实是先 poll 再提交,所以一般是重复消费,不是丢)

适用场景:

  • 对可靠性要求不高

  • 能接受少量重复消费

  • 简单业务


2. 手动提交(enable.auto.commit = false)

自己控制什么时候提交 Offset。

两种方式:

a. 同步提交(commitSync)

consumer.commitSync();
  • 同步等待提交结果

  • 提交失败会重试

  • 可靠,但会阻塞

  • 性能稍差

b. 异步提交(commitAsync)

consumer.commitAsync(new OffsetCommitCallback() {
    @Override
    public void onComplete(Map<TopicPartition, OffsetAndMetadata> offsets, Exception e) {
        if (e != null) {
            // 提交失败
        }
    }
});
  • 异步提交,不阻塞

  • 提交完回调通知

  • 性能好

  • 但失败不会自动重试(因为异步,重试可能覆盖后面的提交)

推荐用法:

  • 处理完一批消息后提交

  • 一般用同步提交,简单可靠

  • 性能要求高用异步


【提交时机】

什么时候提交 Offset 很重要:

1. 先处理再提交(推荐)

poll 消息 → 处理消息 → 提交 Offset
  • 处理完了再提交

  • 保证处理成功了才提交

  • 如果处理中挂了,Offset 没提交,下次重新消费

  • 可能重复消费,但不会丢消息

  • at least once 语义

2. 先提交再处理

poll 消息 → 提交 Offset → 处理消息
  • 先提交再处理

  • 如果处理中挂了,Offset 已经提交了

  • 下次从新的位置开始

  • 没处理的消息就丢了

  • at most once 语义

  • 一般不推荐

3. 处理中间提交

  • 处理一部分提交一次

  • 减少重复消费的数量

  • 更精细的控制

  • 实现复杂


【Offset 的存储】

Offset 存在 Kafka 的内部 Topic:__consumer_offsets

  • 按 group.id + topic + partition 存储

  • 是一个 compact Topic(只保留最新的 Offset)

  • 高可用,多副本

为什么存在 Kafka 里?

  • 早期版本存在 ZooKeeper 里

  • 但 ZooKeeper 不适合频繁写

  • Kafka 自己就是消息系统,存 Offset 很合适

  • 性能好,高可用


高频追问

追问1:自动提交和手动提交的区别?怎么选?

| 维度 | 自动提交 | 手动提交 | |------|---------|---------| | 复杂度 | 简单 | 稍复杂 | | 可靠性 | 较低 | 较高 | | 重复消费 | 可能较多 | 可控 | | 性能 | 好 | 同步提交稍差 | | 控制粒度 | 粗(按时间) | 细(按业务) |

怎么选:

  • 简单业务、能接受重复 → 自动提交

  • 对可靠性要求高 → 手动提交

  • 一般生产环境用手动提交多一些

手动提交也不复杂,就是处理完调用一下 commitSync 就行。

追问2:同步提交和异步提交的区别?

| 维度 | 同步提交 | 异步提交 | |------|---------|---------| | 阻塞 | 阻塞,等结果 | 不阻塞 | | 性能 | 稍差 | 好 | | 重试 | 自动重试 | 不自动重试 | | 可靠性 | 高 | 稍低 | | 适用 | 可靠性优先 | 性能优先 |

为什么异步不自动重试?

  • 异步提交是乱序的

  • 先提交的可能后返回

  • 如果重试,旧的 Offset 可能覆盖新的

  • 导致 Offset 回退,重复消费更多

最佳实践:

  • 大部分时候用同步提交,简单可靠

  • 性能要求高的话用异步

  • 可以混合用:平时异步提交,关闭前同步提交一次(保证最后提交成功)

追问3:消费者挂了,Offset 没提交怎么办?

如果是先处理再提交的模式:

  • 消费者挂了,Offset 没提交

  • 下次启动(或者 Rebalance 后)从上次提交的 Offset 开始

  • 已经消费但没提交的消息会重新消费

  • 重复消费

怎么减少重复消费?

  1. 增加提交频率

    • 处理完就提交,不要攒太多

    • 减少重复消费的数量

    • 但提交太频繁影响性能

    • 平衡一下

  2. 幂等

    • 重复消费也不怕

    • 保证幂等就行

    • 这是根本解决方案

  3. 事务

    • 消费和提交在一个事务里

    • 要么都成功,要么都失败

    • 实现 exactly-once

    • 但性能差,实现复杂

一般来说,重复消费不可避免,靠幂等来解决。

追问4:Offset 提交失败了怎么办?

同步提交:

  • 会自动重试

  • 直到成功或者抛出异常

  • 抛出异常的话,自己处理(重试、告警等)

异步提交:

  • 失败了回调通知

  • 可以自己重试

  • 但要注意顺序问题,不要让旧的覆盖新的

一般怎么处理:

  1. 先重试几次

  2. 还是失败就告警

  3. 记录日志

  4. 下次 poll 的时候还会再提交(如果 Offset 没更新)

其实 Offset 提交失败概率很低,因为 Offset 存在 Kafka 内部 Topic,和 Kafka 一样高可用。 大部分时候是网络抖动,重试一下就好了。

追问5:什么是 Kafka 的事务?和 Offset 提交有什么关系?

Kafka 事务:保证多个操作(生产消息、消费消息、提交 Offset)在一个事务里,要么都成功,要么都失败。

事务的作用:

  • 实现 exactly-once 语义

  • 消费-处理-生产模式下,保证原子性

  • 比如:消费消息 → 处理 → 生产新消息 → 提交 Offset,这几个操作要么都成功,要么都失败

怎么用:

producer.initTransactions();
try {
    producer.beginTransaction();
    // 生产消息
    producer.send(record1);
    producer.send(record2);
    // 提交消费 Offset
    producer.sendOffsetsToTransaction(offsets, groupId);
    producer.commitTransaction();
} catch (Exception e) {
    producer.abortTransaction();
}

和 Offset 提交的关系:

  • 事务里可以提交 Offset

  • Offset 提交和消息生产在一个事务里

  • 要么都成功,要么都回滚

  • 保证原子性

事务性能比较差,一般只有对 exactly-once 要求很高的场景才用。 大部分场景 at least once + 幂等就够了。


53. Kafka 如何保证消息不丢失

标准答案

Kafka 消息丢失可能发生在三个环节:生产者、Broker、消费者。

【消息丢失的三个环节】

生产者 → Broker → 消费者
  ①       ②        ③

【1. 生产者端:acks + 重试】

问题:生产者发消息,Broker 没收到,消息丢了。

解决方案:

1. acks 参数

acks 参数控制生产者认为发送成功的条件:

| acks 值 | 说明 | 可靠性 | 性能 | |---------|------|--------|------| | acks=0 | 不等确认,发出去就认为成功 | 最低,可能丢 | 最高 | | acks=1 | Leader 收到就确认(默认) | 中等,Leader 挂了可能丢 | 中等 | | acks=all/-1 | 所有 ISR 都收到才确认 | 最高 | 最低 |

推荐:

  • 对可靠性要求高 → acks=all

  • 对性能要求高、能接受少量丢失 → acks=1

  • 日志等不重要的 → acks=0

2. 重试机制

  • 发送失败自动重试

  • retries 参数:重试次数

  • retry.backoff.ms:重试间隔

注意:

  • 重试可能导致消息重复

  • 要保证幂等

  • 或者开启幂等生产者(enable.idempotence=true)

3. 幂等生产者

  • enable.idempotence=true

  • 生产者每个消息带序列号

  • Broker 去重,保证同一条消息即使重发也只存一份

  • 保证单分区内不重复

  • 配合 acks=all,可靠性更高


【2. Broker 端:多副本 + 持久化】

问题:Broker 收到消息了,但还没存好就挂了,消息丢了。

解决方案:

1. 多副本(Replication)

  • 每个 Partition 有多个副本(replication.factor)

  • 一个 Leader,多个 Follower

  • Follower 从 Leader 同步数据

  • Leader 挂了,选一个 Follower 当新 Leader

  • 数据不丢

ISR(In-Sync Replicas):

  • 同步中的副本列表

  • 和 Leader 差距不大的副本才算 ISR

  • 落后太多的不算

  • acks=all 时,要等所有 ISR 都同步了才确认

2. 持久化

  • 消息存在磁盘上

  • 持久化保存

  • 但默认是异步刷盘(靠操作系统页缓存)

  • 可以配置同步刷盘,但性能差

  • 一般靠多副本保证可靠性,不用同步刷盘

3. 最小同步副本数

  • min.insync.replicas:最小 ISR 数量

  • ISR 数量少于这个值,生产者发消息会失败

  • 防止只有 Leader 一个副本,挂了就丢了

  • 配合 acks=all 使用

  • 一般设为 2(1 Leader + 1 Follower)


【3. 消费者端:手动提交 Offset】

问题:消费者拿到消息,还没处理完就提交了 Offset,然后挂了,消息丢了。

解决方案:

手动提交 Offset,先处理再提交

  • 关闭自动提交(enable.auto.commit=false)

  • 处理完消息再提交 Offset

  • 没处理完不提交

  • 消费者挂了,Offset 没提交,下次重新消费

  • 不会丢消息,但可能重复消费

代码示例:

Properties props = new Properties();
props.put("enable.auto.commit", "false"); // 关闭自动提交

while (true) {
    ConsumerRecords<String, String> records = consumer.poll(Duration.ofMillis(100));
    for (ConsumerRecord<String, String> record : records) {
        // 处理消息
        process(record);
    }
    // 处理完了再提交
    consumer.commitSync();
}

注意:

  • 先处理再提交 → 可能重复消费,但不会丢

  • 先提交再处理 → 可能丢消息,但不会重复

  • 一般选先处理再提交,丢消息比重复消费更严重


【完整的可靠性方案】

| 环节 | 方案 | 配置 | |------|------|------| | 生产者 | acks=all + 重试 + 幂等 | acks=all, retries=3, enable.idempotence=true | | Broker | 多副本 + 最小 ISR | replication.factor=3, min.insync.replicas=2 | | 消费者 | 手动提交,先处理再提交 | enable.auto.commit=false, commitSync |

这样配置,消息丢失的概率极低。


高频追问

追问1:acks=all 就一定不丢吗?

不一定,但概率很低。

什么情况下 acks=all 还会丢?

  1. 所有副本都挂了

    • 极端情况,所有 Broker 都挂了

    • 而且数据还没刷盘

    • 概率极低

  2. ISR 只剩 Leader 一个

    • Follower 都落后了,不在 ISR 里

    • 这时候 acks=all 只要 Leader 确认就行

    • Leader 挂了就丢了

    • 可以用 min.insync.replicas 限制,ISR 太少就不让写

  3. Leader 挂了,选了一个落后的 Follower

    • 正常情况下选 ISR 里的

    • 但如果所有 ISR 都挂了,可能选不在 ISR 里的

    • 就会丢数据

    • 可以配置 unclean.leader.election.enable=false,禁止选不在 ISR 里的

    • 但这样可用性就差了(ISR 都挂了就不可用了)

总结:

  • acks=all + min.insync.replicas=2 + unclean.leader.election.enable=false

  • 已经非常可靠了

  • 100% 不丢是不可能的,只能降低概率

追问2:什么是 ISR?什么情况下副本会被踢出 ISR?

ISR(In-Sync Replicas):和 Leader 保持同步的副本集合。

怎么算同步?

  • Follower 落后 Leader 的时间不超过 replica.lag.time.max.ms(默认 10 秒)

  • 也就是 10 秒内同步过就算同步

  • 不是看落后多少条消息,是看时间

什么情况会被踢出 ISR:

  1. Follower 挂了,长时间没同步

  2. Follower 太慢了,跟不上

  3. 新加入的 Follower,还没同步完

ISR 的作用:

  • acks=all 时,等 ISR 里的所有副本都同步了才确认

  • Leader 挂了,从 ISR 里选新 Leader

  • ISR 里的副本数据比较新,选出来不会丢太多数据

追问3:什么是幂等生产者?怎么实现的?

幂等生产者:同一条消息,生产者即使重发多次,Broker 也只存一份。

怎么实现:

  • 每个生产者有一个 PID(Producer ID)

  • 每个 PID + Partition 对应一个序列号

  • 每条消息带序列号

  • Broker 记录每个 PID-Partition 的最大序列号

  • 新消息序列号 > 最大序列号 + 1 → 拒绝(乱序)

  • 新消息序列号 <= 最大序列号 → 已经收到过,直接返回成功(去重)

  • 新消息序列号 = 最大序列号 + 1 → 正常写入

效果:

  • 单分区内,生产者重试不会导致重复

  • 保证单分区的 exactly-once(生产者端)

开启方式:

enable.idempotence=true
  • 开启后,acks 自动设为 all

  • retries 自动设为 Integer.MAX_VALUE

  • 自动保证不重复

注意:只能保证单生产者、单分区的幂等,跨分区、跨生产者不行。 要跨分区的事务,要用事务消息。

追问4:Kafka 和 RabbitMQ 的可靠性哪个好?

都很好,都能做到很高的可靠性,但机制不同。

RabbitMQ:

  • 持久化 + Confirm + 手动 ACK

  • 镜像队列集群

  • 可靠性很高

  • 更偏向业务消息,功能丰富

Kafka:

  • 多副本 + acks=all + 手动提交

  • 分布式架构,高可用

  • 可靠性也很高

  • 更偏向高吞吐、大数据

对比:

  • 两者都能做到 99.99% 以上的可靠性

  • 都不能 100% 保证不丢

  • 都有成熟的可靠性方案

  • 选型主要看场景,不是看可靠性

一般来说:

  • 业务消息、低延迟 → RabbitMQ

  • 日志、大数据、高吞吐 → Kafka

追问5:怎么验证消息有没有丢?

验证方法:

  1. 生产端日志

    • 记录每条消息的 key/ID

    • 记录发送结果

  2. 消费端日志

    • 记录每条消费的消息 key/ID

    • 和生产端对比

  3. 消息对账

    • 定期对比生产数量和消费数量

    • 看有没有差

  4. 消息 ID 去重表

    • 消费时把消息 ID 存数据库

    • 看有没有少

    • 也能看有没有重复

  5. 监控指标

    • 生产速率、消费速率

    • 消息积压数

    • 异常告警

实际生产中,一般靠监控 + 日志 + 对账来保证。 重要的消息可以做对账系统,定期校验。


54. Kafka 为什么一个 Partition 内有序,多 Partition 无法保证全局有序

标准答案

Kafka 的顺序性:单 Partition 内有序,全局(多 Partition)无序。

【为什么单 Partition 内有序】

因为 Partition 是一个有序的日志文件:

  • 消息追加到 Partition 的末尾

  • 每条消息有一个 Offset,按顺序递增

  • 消费者按 Offset 顺序消费

  • 所以同一个 Partition 内,消息是有序的

原理:

  • Partition 是一个 append-only 的日志

  • 消息只能往末尾加

  • 顺序写入,顺序读取

  • 天然有序


【为什么多 Partition 全局无序】

因为消息分散到多个 Partition,每个 Partition 各自有序,但整体没有顺序。

原因:

  1. 消息按 key 哈希到不同 Partition

    • 不同 key 的消息到不同 Partition

    • 每个 Partition 自己的顺序

    • 不同 Partition 之间没有顺序关系

    • 消费者先消费到哪个 Partition 的消息不一定

  2. 多个 Partition 在不同 Broker 上

    • 写入时间有差异

    • 网络延迟有差异

    • 不能保证全局的先后

  3. 消费者并行消费

    • 多个消费者并行消费不同 Partition

    • 消费速度不一样

    • 先消费到哪个 Partition 的消息不一定

举例:

  • 2 个 Partition:P0、P1

  • 消息 A 到 P0,消息 B 到 P1

  • A 比 B 先发

  • 但消费者可能先消费到 B,再消费到 A

  • 全局来看,顺序就反了


【怎么保证顺序】

根据业务需求,不同的顺序要求有不同的方案:

1. 全局有序

要求所有消息严格按发送顺序消费。

方案:Topic 只有一个 Partition

  • 只有一个 Partition,就全局有序了

  • 但吞吐量低,并行度为 1

  • 性能差

适用场景: 对全局顺序要求严格,且吞吐量不高的场景。


2. 局部有序(按 key 有序)

要求同一个 key 的消息有序,不同 key 的消息不用保证顺序。

方案:按 key 哈希到同一个 Partition

  • 相同 key 的消息发到同一个 Partition

  • 同一个 Partition 内有序

  • 所以同一个 key 的消息有序

  • 不同 key 的消息可以并行

举例:

  • 订单消息,按订单 ID 作为 key

  • 同一个订单的消息都到同一个 Partition

  • 同一个订单的消息有序

  • 不同订单可以并行消费

适用场景: 大部分业务场景,只要局部有序就行。

  • 比如:同一个用户的消息有序、同一个订单的消息有序

这是最常用的方案,兼顾了顺序和性能。


3. 消费者端排序

如果必须全局有序,但又要多 Partition 提高吞吐量。

方案:

  • 多 Partition 并行消费

  • 消费者端缓存消息

  • 按序号排序后再处理

  • 类似 TCP 的乱序重排

缺点:

  • 实现复杂

  • 有延迟(要等前面的消息到了才能处理)

  • 缓存有内存开销

适用场景: 很少用,一般不需要全局有序。


高频追问

追问1:什么场景需要消息有序?

需要顺序的场景:

  1. 数据库 binlog 同步

    • 数据库的变更要按顺序同步

    • 乱序会导致数据不一致

    • 按表/主键分 Partition,同一张表的有序

  2. 状态机

    • 比如订单状态流转:创建 → 支付 → 发货 → 完成

    • 必须按顺序来

    • 按订单 ID 分 Partition,同一个订单有序

  3. 日志收集

    • 同一个源的日志要按时间顺序

    • 按源分 Partition

  4. 计数器

    • 比如先加后减,和先减后加结果可能不一样

    • 要看具体业务

  5. 消息依赖

    • 后一条消息依赖前一条的结果

    • 必须按顺序

大部分场景,只要局部有序(同一个业务 ID 有序)就够了,不需要全局有序。

追问2:Kafka 怎么保证同一个 key 的消息发到同一个 Partition?

默认的分区策略:

如果消息有 key:

  • 用 key 的哈希值对 Partition 数量取模

  • partition = hash(key) % numPartitions

  • 相同 key 的消息哈希值一样

  • 所以发到同一个 Partition

如果消息没有 key:

  • 用轮询(Round-Robin)策略

  • 轮流发到各个 Partition

  • 均匀分布

自定义分区策略:

  • 可以实现 Partitioner 接口

  • 自己定义分区规则

  • 比如按业务字段分

注意:

  • 增加 Partition 后,key 的分区结果会变(因为取模的分母变了)

  • 所以不要轻易增加 Partition

  • 或者增加 Partition 后,旧 key 的消息可能到不同 Partition 了

  • 顺序就乱了

追问3:增加 Partition 会有什么问题?

增加 Partition 的影响:

  1. key 的分区结果变化

    • hash(key) % 新的 Partition 数

    • 和之前的结果不一样

    • 同一个 key 的消息可能到不同 Partition

    • 顺序就乱了

    • 依赖 key 有序的业务会出问题

  2. 数据不会自动重新分配

    • 旧的消息还在原来的 Partition

    • 新消息按新的 Partition 数分配

    • 不会自动迁移旧数据

  3. 客户端要刷新元数据

    • 生产者和消费者要知道新的 Partition

    • 有个刷新过程

  4. 增加了 Broker 开销

    • 每个 Partition 有文件句柄、内存等开销

    • Partition 太多影响性能

所以 Partition 数要提前规划好,不要轻易加。 加之前评估对业务的影响。

追问4:消费者端怎么保证顺序?

消费者端保证顺序的要点:

  1. 单线程消费

    • 一个 Partition 用一个线程消费

    • 不要多线程消费同一个 Partition

    • 不然顺序就乱了

  2. 不要异步处理

    • 收到消息后异步处理

    • 后面的消息可能先处理完

    • 顺序就乱了

    • 要顺序就同步处理

  3. 处理完再提交 Offset

    • 处理完一条提交一条

    • 或者批量处理完批量提交

    • 保证提交的 Offset 都是处理完的

  4. 不要乱序提交

    • 异步提交可能乱序

    • 旧的 Offset 覆盖新的

    • 导致重复消费更多

如果要多线程提高吞吐量:

  • 每个 Partition 一个线程

  • 不要多个线程处理同一个 Partition

  • 这样既并行,又保证单 Partition 有序

追问5:RabbitMQ 怎么保证顺序?和 Kafka 有什么不同?

RabbitMQ 的顺序:

  • 单队列单消费者 → 有序

  • 单队列多消费者 → 无序(因为消费速度不一样)

  • 多队列 → 更无序

和 Kafka 类似:

  • 单队列/单 Partition → 有序

  • 多队列/多 Partition → 无序

  • 要全局有序就只有一个队列/Partition

  • 要局部有序就按业务维度分队列/Partition

不同点:

  • Kafka 天然按 key 分区,很容易实现按 key 有序

  • RabbitMQ 要自己按路由键分到不同队列,也能实现

  • Kafka 的分区更适合大数据量的并行

  • RabbitMQ 的队列更适合业务消息

本质上是一样的,要顺序就不能并行,要并行就不能全局有序。 这是分布式系统的基本规律。

📌 面试技巧:回答消息队列相关问题时,要体现出你对消息队列的深入理解,特别是可靠性、顺序性、重复消费这些核心问题。讲的时候要分环节(生产者、MQ、消费者)来分析,条理清晰,面试官会觉得你思路很清楚。


第七章 微服务(5题)


55. Spring Cloud Gateway 原理

标准答案

Spring Cloud Gateway 是 Spring Cloud 生态中的 API 网关,负责请求的路由、过滤、限流等。

【什么是 API 网关】

API 网关是微服务架构的入口,所有请求都先经过网关,再转发到后端服务。

网关的作用:

  1. 路由转发:根据请求路径转发到对应的服务

  2. 统一入口:所有服务一个入口,对外屏蔽内部细节

  3. 统一处理:认证、限流、日志、监控等统一在网关做

  4. 负载均衡:网关层做负载均衡

  5. 服务聚合:聚合多个服务的结果,减少客户端调用次数

为什么需要网关:

  • 微服务很多,客户端直接调用每个服务很麻烦

  • 每个服务都做认证、限流等重复工作

  • 统一在网关做,业务服务只关注业务


【Spring Cloud Gateway 核心概念】

1. Route(路由)

  • 网关的基本构建块

  • 一个路由 = ID + 目标 URI + 断言集合 + 过滤器集合

  • 断言都满足,就路由到目标 URI

2. Predicate(断言)

  • 判断请求是否满足条件

  • 比如:路径匹配、请求方法、Header、Cookie 等

  • 多个断言可以组合

  • 所有断言都满足,才匹配这个路由

3. Filter(过滤器)

  • 请求路由前后做一些处理

  • 比如:添加 Header、修改请求参数、限流、认证等

  • 分 Pre 过滤器(请求前)和 Post 过滤器(响应后)


【Spring Cloud Gateway 工作原理】

请求处理流程:

客户端请求
    ↓
Gateway 接收请求
    ↓
Gateway Handler Mapping 匹配路由(Predicate 判断)
    ↓
Gateway Web Handler 处理
    ↓
过滤器链(Pre 过滤器 → 代理转发 → Post 过滤器)
    ↓
目标服务
    ↓
响应返回(经过 Post 过滤器)
    ↓
客户端

详细流程:

  1. 客户端发请求到 Gateway

  2. 路由匹配

    • Gateway Handler Mapping 根据 Predicate 判断请求匹配哪个路由

    • 找到匹配的路由

  3. 过滤器链执行

    • 请求先经过所有 Pre 过滤器

    • 然后代理转发到目标服务(通过 WebClient / HTTP Client)

    • 响应回来后经过所有 Post 过滤器

    • 过滤器链类似 Servlet 的 Filter 链

  4. 返回响应

    • 经过 Post 过滤器处理后的响应返回给客户端


【Spring Cloud Gateway 的特点】

  1. 基于 Spring 5 + Spring Boot 2 + Reactor

    • 响应式编程,非阻塞

    • 性能好,高并发

  2. 支持动态路由

    • 路由配置可以动态修改

    • 不用重启

  3. 集成服务发现

    • 可以和 Nacos、Eureka 等注册中心集成

    • 自动发现服务,动态路由

  4. 丰富的过滤器

    • 内置很多过滤器

    • 也可以自定义过滤器

  5. 支持限流、熔断

    • 集成 Sentinel、Resilience4j 等


高频追问

追问1:Gateway 和 Zuul 的区别?

| 维度 | Zuul 1.x | Spring Cloud Gateway | |------|----------|---------------------| | 底层 | Servlet,同步阻塞 | Reactor + Netty,异步非阻塞 | | 性能 | 一般 | 更好,高并发 | | 版本 | 进入维护模式 | 活跃开发 | | 编程模型 | 传统 Servlet | 响应式(WebFlux) | | 功能 | 基础功能 | 更丰富 | | 适用 | 低并发 | 高并发 |

Zuul 2.x 也是异步的,但 Spring Cloud 没有集成 Zuul 2.x。 Spring Cloud Gateway 是 Spring Cloud 官方推荐的网关,替代 Zuul。

追问2:Gateway 的过滤器有哪些类型?

按阶段分:

  • Pre 过滤器:请求转发到目标服务之前执行

    • 比如:认证、参数校验、添加 Header、限流

  • Post 过滤器:目标服务返回响应之后执行

    • 比如:修改响应、日志、统计

按作用范围分:

  • GatewayFilter:针对单个路由的过滤器

    • 配置在具体路由下

  • GlobalFilter:全局过滤器,所有路由都生效

    • 不用配置,自动生效

    • 比如:负载均衡过滤器、转发过滤器

常用内置过滤器:

  • AddRequestHeader:添加请求 Header

  • AddRequestParameter:添加请求参数

  • StripPrefix:去掉路径前缀

  • PrefixPath:添加路径前缀

  • Hystrix / Resilience4j:熔断

  • RequestRateLimiter:限流

追问3:Gateway 怎么做认证鉴权?

在网关层统一做认证,业务服务不用管。

方案:

  1. 自定义全局过滤器

@Component
public class AuthFilter implements GlobalFilter, Ordered {
    
    @Override
    public Mono<Void> filter(ServerWebExchange exchange, GatewayFilterChain chain) {
        ServerHttpRequest request = exchange.getRequest();
        String token = request.getHeaders().getFirst("token");
        
        // 白名单路径直接放行
        if (isWhitelist(request.getPath().value())) {
            return chain.filter(exchange);
        }
        
        // 校验 token
        if (token == null || !validateToken(token)) {
            // 未认证,返回 401
            exchange.getResponse().setStatusCode(HttpStatus.UNAUTHORIZED);
            return exchange.getResponse().setComplete();
        }
        
        // 认证通过,把用户信息放到 Header 里传给下游
        ServerHttpRequest newRequest = request.mutate()
            .header("userId", getUserId(token))
            .build();
        
        return chain.filter(exchange.mutate().request(newRequest).build());
    }
    
    @Override
    public int getOrder() {
        return -100; // 顺序,数字越小越先执行
    }
}
  1. 和认证服务集成

    • 调用认证服务校验 token

    • 或者用 JWT,网关自己校验签名

  2. 权限控制

    • 简单的权限可以在网关做

    • 复杂的细粒度权限还是在业务服务做

好处:

  • 统一认证,业务服务不用重复做

  • 未认证的请求直接在网关挡住,不打到业务服务

  • 保护后端服务

追问4:Gateway 怎么做限流?

网关层限流,保护后端服务。

方案:

1. 内置 RequestRateLimiter 过滤器

  • 基于 Redis + Lua 实现

  • 令牌桶算法

  • 配置简单

spring:
  cloud:
    gateway:
      routes:
        - id: user-service
          uri: lb://user-service
          predicates:
            - Path=/user/**
          filters:
            - name: RequestRateLimiter
              args:
                redis-rate-limiter.replenishRate: 10  # 每秒生成令牌数
                redis-rate-limiter.burstCapacity: 20 # 桶容量
                key-resolver: "#{@ipKeyResolver}" # 按什么维度限流

2. 集成 Sentinel

  • 更强大的限流功能

  • 支持多种限流模式

  • 有控制台

  • 更灵活

3. 自定义限流过滤器

  • 自己实现限流逻辑

  • 更灵活,但开发量大

限流维度:

  • 按 IP 限流

  • 按用户限流

  • 按接口限流

  • 按服务限流

追问5:Gateway 怎么实现动态路由?

动态路由:不用重启,动态修改路由配置。

实现方式:

1. 基于 Nacos 配置中心

  • 路由配置存在 Nacos 里

  • 网关监听 Nacos 配置变化

  • 配置变了动态更新路由

  • 最常用

2. 基于数据库

  • 路由配置存在数据库

  • 定时刷新,或者手动触发刷新

  • 有管理界面可以配置

3. 基于 Redis

  • 路由配置存在 Redis

  • 监听 Redis 变化,动态更新

核心原理:

  • Gateway 的路由定义存在 RouteDefinitionRepository 里

  • 自定义 RouteDefinitionRepository,从配置中心/数据库/Redis 读取

  • 配置变化时,发布 RefreshRoutesEvent 事件

  • Gateway 收到事件后刷新路由

// 刷新路由
@Autowired
private ApplicationEventPublisher publisher;

public void refresh() {
    publisher.publishEvent(new RefreshRoutesEvent(this));
}

动态路由是生产环境必备的,不然每次改路由都要重启网关。


56. Nacos 注册中心原理

标准答案

Nacos 是阿里巴巴开源的注册中心和配置中心,是 Spring Cloud Alibaba 的核心组件。

【什么是注册中心】

注册中心是微服务架构中的"通讯录",记录各个服务的地址信息。

为什么需要注册中心:

  • 微服务很多,服务之间互相调用

  • 不能把地址写死(地址会变、会扩容缩容)

  • 需要一个地方统一管理服务地址

注册中心的作用:

  1. 服务注册:服务启动时把自己的地址注册到注册中心

  2. 服务发现:调用方从注册中心获取被调用方的地址列表

  3. 健康检查:注册中心检测服务是否存活,不健康的剔除

  4. 动态变更:服务上下线自动感知,不用手动改配置


【Nacos 的核心概念】

1. Namespace(命名空间)

  • 环境隔离,比如 dev、test、prod 不同环境

  • 不同 Namespace 之间隔离

2. Group(分组)

  • 服务分组,同一环境下不同业务的服务分组

  • 默认是 DEFAULT_GROUP

3. Service(服务)

  • 某个微服务,比如 user-service

  • 一个服务下有多个实例

4. Instance(实例)

  • 服务的一个实例,IP + 端口

  • 一个服务有多个实例

5. Cluster(集群)

  • 实例的集群划分,比如同一个机房的一个集群

  • 同集群优先调用


【服务注册与发现流程】

服务提供者(Provider):

  1. 服务启动时,向 Nacos 注册自己的地址(IP + 端口 + 元数据)

  2. 定期发送心跳,证明自己还活着(默认 5 秒一次)

  3. 服务关闭时,主动注销自己

服务消费者(Consumer):

  1. 启动时,从 Nacos 订阅自己需要的服务

  2. Nacos 返回服务的地址列表

  3. 本地缓存地址列表

  4. 服务列表变化时,Nacos 推送变更,消费者更新本地缓存

  5. 调用时,从本地缓存选一个地址调用(负载均衡)

Nacos 服务端:

  1. 接收服务注册,保存服务实例信息

  2. 接收心跳,更新实例健康状态

  3. 长时间没心跳的实例,标记不健康,甚至剔除

  4. 服务列表变化时,推送给订阅的消费者


【Nacos 的健康检查机制】

Nacos 有两种健康检查模式:

1. 客户端心跳模式(临时实例,默认)

  • 实例定期向 Nacos 发心跳(默认 5 秒一次)

  • 超过 15 秒没心跳 → 标记不健康

  • 超过 30 秒没心跳 → 剔除实例

  • 适合临时实例,服务上下线频繁

2. 服务端主动探测模式(持久化实例)

  • Nacos 主动探测实例是否健康

  • 比如 TCP 探测、HTTP 探测

  • 实例不用发心跳

  • 适合持久化实例,服务比较稳定

默认是临时实例,客户端心跳模式。


【Nacos 集群架构】

Nacos 本身也是集群部署,保证高可用。

架构特点:

  • 去中心化,没有主从

  • 每个节点都能读写

  • 数据通过 Raft 协议保证一致性

  • 半数以上节点存活,集群就可用

数据一致性:

  • 临时实例:AP(可用性优先),最终一致

  • 持久化实例:CP(一致性优先),强一致

  • 可以根据业务选择


高频追问

追问1:Nacos 和 Eureka、ZooKeeper 的区别?

| 维度 | Nacos | Eureka | ZooKeeper | |------|-------|--------|-----------| | 一致性 | AP + CP(可选) | AP | CP | | 健康检查 | 客户端心跳 + 服务端探测 | 客户端心跳 | 会话保持 | | 功能 | 注册中心 + 配置中心 | 注册中心 | 协调服务 | | 性能 | 高 | 高 | 一般 | | 社区 | 活跃(阿里) | 停止维护 | 活跃(Apache) | | 生态 | Spring Cloud Alibaba | Spring Cloud Netflix | 通用 |

CAP 模型:

  • Nacos:支持 AP 和 CP 切换,灵活

  • Eureka:AP,可用性好,最终一致

  • ZooKeeper:CP,一致性好,可用性稍差

注册中心一般选 AP,因为服务发现更看重可用性,短暂不一致没关系。

追问2:服务挂了,注册中心多久能感知到?

看配置,默认情况下:

Nacos(临时实例):

  • 心跳间隔:5 秒

  • 15 秒没心跳 → 标记不健康

  • 30 秒没心跳 → 剔除

  • 所以服务挂了,大概 15~30 秒能感知到

Eureka:

  • 心跳间隔:30 秒

  • 90 秒没心跳 → 剔除

  • 而且 Eureka 有自我保护机制,可能更久

可以调整:

  • 调小心跳间隔,能更快感知

  • 但心跳太频繁增加网络和注册中心压力

  • 平衡一下

消费者端:

  • 本地有缓存

  • 注册中心推送变更

  • 推送有延迟

  • 所以消费者感知到还要晚一点

一般几秒到几十秒,看配置。

追问3:Nacos 怎么做配置中心?

Nacos 不仅是注册中心,还是配置中心。

配置中心的作用:

  • 配置统一管理

  • 动态刷新配置,不用重启

  • 不同环境不同配置

  • 配置版本管理、回滚

核心概念:

  • Data ID:配置文件的 ID

  • Group:配置分组

  • Namespace:命名空间(环境隔离)

动态刷新原理:

  1. 应用启动时从 Nacos 拉取配置

  2. 客户端和 Nacos 保持长连接(长轮询)

  3. 配置变化时,Nacos 推送变更

  4. 客户端收到变更,更新本地配置

  5. 刷新 Spring 容器中的 Bean(@RefreshScope)

和 Spring Cloud 集成:

  • spring-cloud-starter-alibaba-nacos-config

  • bootstrap.yml 配置 Nacos 地址

  • @Value、@ConfigurationProperties 都能自动刷新

  • @RefreshScope 注解的 Bean 会动态刷新

配置中心是 Nacos 的一大优势,一个工具解决注册和配置两个问题。

追问4:服务发现有两种模式:客户端发现和服务端发现,Nacos 是哪种?

客户端发现(Client-side Discovery):

  • 客户端从注册中心获取服务地址列表

  • 客户端自己选一个调用(负载均衡)

  • 比如:Nacos + Ribbon / LoadBalancer

  • 优点:灵活,客户端自己控制

  • 缺点:每个客户端都要实现一套

服务端发现(Server-side Discovery):

  • 请求先到负载均衡器(比如 Nginx、网关)

  • 负载均衡器从注册中心获取地址,转发

  • 客户端不用管服务地址

  • 优点:客户端简单

  • 缺点:多了一层,有性能损耗

Nacos 是客户端发现模式:

  • 消费者从 Nacos 拉取服务列表

  • 消费者本地缓存

  • 消费者自己做负载均衡

  • 配合 Ribbon 或 Spring Cloud LoadBalancer

API 网关其实就是服务端发现的一种,客户端只调用网关,网关负责路由和负载均衡。

追问5:Nacos 的 CP 和 AP 模式怎么选?

Nacos 支持 CP 和 AP 两种模式,可以切换。

AP 模式(默认,临时实例):

  • 可用性优先

  • 网络分区时,仍然能提供服务

  • 数据可能短暂不一致

  • 适合:服务发现(注册中心)

  • 因为服务发现更看重可用性,短暂不一致没关系,大不了调用失败重试

CP 模式(持久化实例):

  • 一致性优先

  • 网络分区时,可能不可用

  • 数据强一致

  • 适合:配置中心、需要强一致的场景

怎么选:

  • 注册中心 → AP 模式(临时实例)

  • 配置中心 → CP 模式

  • 一般默认就好,不用特意改

Nacos 用 Raft 协议实现 CP,用 Distro 协议实现 AP。


57. OpenFeign 调用流程

标准答案

OpenFeign 是 Spring Cloud 中的声明式 HTTP 客户端,用来做服务间调用。

【什么是 OpenFeign】

OpenFeign 让你像调用本地方法一样调用远程服务,不用写 HTTP 请求代码。

用法:

@FeignClient("user-service")
public interface UserService {
    @GetMapping("/user/{id}")
    User getUserById(@PathVariable("id") Long id);
}

用的时候直接注入调用:

@Autowired
private UserService userService;

public void test() {
    User user = userService.getUserById(1L);
}

就像调用本地方法一样,底层自动发 HTTP 请求。


【OpenFeign 的核心组件】

1. @FeignClient 注解

  • 标记这是一个 Feign 客户端

  • value/name 指定服务名

  • 用来生成代理对象

2. Contract(契约)

  • 解析注解,比如 @GetMapping、@PathVariable 等

  • Spring Cloud 用 SpringMvcContract,支持 Spring MVC 注解

3. Encoder(编码器)

  • 把对象编码成请求体(JSON 等)

  • 默认用 Jackson

4. Decoder(解码器)

  • 把响应体解码成对象

  • 默认用 Jackson

5. Client(客户端)

  • 真正发 HTTP 请求的客户端

  • 默认是 HttpURLConnection

  • 可以换成 HttpClient、OkHttp,性能更好

6. Logger(日志)

  • 记录请求和响应日志

  • 可以配置日志级别

7. Interceptor(拦截器)

  • 请求前做一些处理,比如添加 Header

  • 可以自定义拦截器


【OpenFeign 调用流程】

调用接口方法
    ↓
JDK 动态代理(生成代理对象)
    ↓
方法调用交给 ReflectiveFeign
    ↓
根据方法找到对应的 MethodHandler
    ↓
构建请求(解析注解、参数处理)
    ↓
拦截器链(RequestInterceptor)
    ↓
Encoder 编码请求体
    ↓
Client 发送 HTTP 请求
    ↓
收到响应
    ↓
Decoder 解码响应体
    ↓
返回结果

详细流程:

  1. 启动时

    • 扫描 @FeignClient 注解的接口

    • 为每个接口生成 JDK 动态代理对象

    • 注册到 Spring 容器

  2. 调用时

    • 调用接口方法,实际调用的是代理对象

    • 代理对象把方法调用转给 Feign

    • 解析方法上的注解(@GetMapping、@RequestParam 等)

    • 把参数组装成 HTTP 请求

    • 执行拦截器(比如添加 token)

    • 用 Client 发 HTTP 请求

    • 收到响应,解码成返回类型

    • 返回结果

  3. 集成 Ribbon/LoadBalancer

    • 如果是服务名调用(不是直接 URL)

    • 会集成负载均衡

    • 从注册中心获取服务列表

    • 选一个实例调用

    • 有重试机制


【OpenFeign 的特点】

  1. 声明式:写接口和注解就行,不用写 HTTP 代码

  2. 集成 Ribbon:自带负载均衡

  3. 集成 Hystrix/Sentinel:支持熔断降级

  4. 可扩展:自定义编码器、解码器、拦截器

  5. 和 Spring MVC 注解兼容:学习成本低


高频追问

追问1:Feign 和 OpenFeign 的区别?

| 维度 | Feign | OpenFeign | |------|-------|-----------| | 来源 | Netflix | Spring Cloud 开源 | | 注解 | Feign 自带的注解 | 支持 Spring MVC 注解 | | 集成 | 单独用 | 和 Spring Cloud 深度集成 | | 状态 | 进入维护 | 活跃开发 |

简单说:

  • Feign 是 Netflix 开源的,用自己的注解

  • OpenFeign 是 Spring Cloud 在 Feign 基础上做的,支持 Spring MVC 注解

  • 现在 Spring Cloud 用的都是 OpenFeign

追问2:OpenFeign 底层用什么发 HTTP 请求?怎么优化?

默认用的是 JDK 的 HttpURLConnection:

  • 简单,不用额外依赖

  • 性能一般,不支持连接池

优化方案:换成 HttpClient 或 OkHttp

1. 换成 Apache HttpClient

<dependency>
    <groupId>io.github.openfeign</groupId>
    <artifactId>feign-httpclient</artifactId>
</dependency>
feign:
  httpclient:
    enabled: true
    max-connections: 200
    max-connections-per-route: 50

2. 换成 OkHttp

<dependency>
    <groupId>io.github.openfeign</groupId>
    <artifactId>feign-okhttp</artifactId>
</dependency>
feign:
  okhttp:
    enabled: true

好处:

  • 支持连接池,复用连接

  • 减少 TCP 连接建立的开销

  • 性能更好,高并发下提升明显

推荐生产环境用 HttpClient 或 OkHttp,不要用默认的。

追问3:OpenFeign 怎么传递 Token / Header?

服务间调用经常需要传递 token、用户信息等 Header。

方案 1:RequestInterceptor(全局拦截器)

@Configuration
public class FeignConfig {
    
    @Bean
    public RequestInterceptor requestInterceptor() {
        return template -> {
            // 从当前请求的上下文获取 token
            ServletRequestAttributes attributes = 
                (ServletRequestAttributes) RequestContextHolder.getRequestAttributes();
            if (attributes != null) {
                HttpServletRequest request = attributes.getRequest();
                String token = request.getHeader("token");
                template.header("token", token);
            }
        };
    }
}
  • 全局生效,所有 Feign 调用都加 Header

  • 简单方便

方案 2:方法参数加 @RequestHeader

@GetMapping("/user/{id}")
User getUserById(@PathVariable("id") Long id, 
                 @RequestHeader("token") String token);
  • 每个方法单独传

  • 灵活,但麻烦

注意:

  • 异步调用时,RequestContextHolder 拿不到请求上下文

  • 需要手动传递,或者用 TransmittableThreadLocal

追问4:OpenFeign 怎么做超时和重试?

超时配置:

feign:
  client:
    config:
      default:  # 全局默认
        connectTimeout: 5000   # 连接超时 5秒
        readTimeout: 10000     # 读取超时 10秒
      user-service:  # 针对某个服务
        connectTimeout: 3000
        readTimeout: 5000

重试:

  • 默认是不重试的

  • 可以配置重试器

@Configuration
public class FeignConfig {
    @Bean
    public Retryer retryer() {
        // 初始间隔 100ms,最大间隔 1s,重试 3 次
        return new Retryer.Default(100, 1000, 3);
    }
}

注意:

  • 重试要谨慎,不是所有接口都适合重试

  • 幂等接口可以重试(查询、修改)

  • 非幂等接口(新增)重试可能导致重复

  • 一般建议用熔断降级,而不是重试

  • 或者配合 Sentinel/Hystrix 做

追问5:Feign 怎么和 Sentinel 集成做熔断降级?

Sentinel 可以和 OpenFeign 集成,实现熔断降级。

步骤:

  1. 开启 Sentinel 支持

feign:
  sentinel:
    enabled: true
  1. 写降级类

@Component
public class UserServiceFallback implements UserService {
    @Override
    public User getUserById(Long id) {
        // 降级逻辑,返回默认值
        return new User();
    }
}
  1. @FeignClient 指定降级类

@FeignClient(value = "user-service", fallback = UserServiceFallback.class)
public interface UserService {
    @GetMapping("/user/{id}")
    User getUserById(@PathVariable("id") Long id);
}

作用:

  • 服务调用失败、超时、限流时

  • 自动走降级逻辑

  • 不会报错,返回兜底数据

  • 保护服务,防止级联故障

还可以用 fallbackFactory,拿到异常信息,做更灵活的降级。


58. Sentinel 限流、熔断原理

标准答案

Sentinel 是阿里巴巴开源的流量防护组件,用来做限流、熔断、降级。

【什么是 Sentinel】

Sentinel 是分布式系统的流量防卫兵,核心功能:

  • 限流:控制请求流量

  • 熔断:服务不稳定时熔断,防止级联故障

  • 降级:服务不可用时返回兜底数据

  • 系统保护:保护系统不被打垮

和 Hystrix 类似,但功能更丰富,性能更好,有控制台。


【限流原理】

什么是限流:

  • 限制请求的数量

  • 防止流量过大把服务打垮

  • 超过阈值的请求直接拒绝或排队

限流算法:

1. 计数器算法

  • 统计单位时间内的请求数

  • 超过阈值就拒绝

  • 简单,但有"突刺问题"(临界点前后各打满,瞬间流量翻倍)

2. 滑动窗口算法

  • 把时间分成小格子

  • 滑动统计窗口内的请求数

  • 解决了突刺问题

  • Sentinel 默认用的是滑动窗口

3. 漏桶算法

  • 请求进入漏桶

  • 漏桶以固定速率流出

  • 桶满了就拒绝

  • 流量整形,输出速率恒定

  • 适合需要平滑流量的场景

4. 令牌桶算法

  • 令牌以固定速率放入桶里

  • 请求来的时候拿令牌,拿到就通过

  • 拿不到就拒绝

  • 允许一定的突发流量(桶里有令牌就能突发)

  • 最常用的限流算法

Sentinel 的限流模式:

  • 直接拒绝:超过阈值直接拒绝

  • Warm Up(预热):冷启动,慢慢增加到阈值

  • 排队等待:请求排队,匀速通过(漏桶)


【熔断降级原理】

什么是熔断:

  • 服务调用失败率、慢调用比例太高时

  • 暂时切断对这个服务的调用

  • 直接返回降级结果

  • 防止级联故障,保护整个系统

熔断器的三种状态:

1. Closed(关闭)

  • 正常状态,请求正常通过

  • 统计失败率、慢调用比例

  • 达到阈值就打开熔断器

2. Open(打开)

  • 熔断状态,请求直接拒绝(走降级)

  • 持续一段时间(熔断时长)

  • 时间到了进入半开状态

3. Half-Open(半开)

  • 试探状态

  • 放少量请求过去

  • 如果成功了 → 关闭熔断器,恢复正常

  • 如果失败了 → 回到打开状态,继续熔断

状态转换:

Closed →(达到阈值)→ Open
Open →(熔断时间到)→ Half-Open
Half-Open →(试探成功)→ Closed
Half-Open →(试探失败)→ Open

熔断策略:

  1. 慢调用比例

    • 慢调用(响应时间超过阈值)比例太高

    • 说明服务有问题,响应慢

    • 触发熔断

  2. 异常比例

    • 异常比例太高

    • 说明服务经常出错

    • 触发熔断

  3. 异常数

    • 异常数量达到阈值

    • 触发熔断


【Sentinel 的工作原理】

核心:基于滑动窗口的实时统计

  1. 统计数据

    • 每个资源(接口)的调用数据

    • 成功数、失败数、耗时等

    • 用滑动窗口统计

  2. 规则判断

    • 请求进来时,根据规则判断

    • 限流规则:QPS 是否超过阈值

    • 熔断规则:失败率是否超过阈值

    • 系统规则:系统负载是否过高

  3. 通过或拒绝

    • 满足规则 → 通过

    • 不满足 → 拒绝,抛出异常

    • 可以自定义降级处理

Sentinel 的核心 SPI:

  • Slot Chain(插槽链)

  • 每个功能一个 Slot

  • 请求经过 Slot Chain,依次经过各个 Slot

  • 比如:节点选择 Slot、限流 Slot、熔断 Slot、系统保护 Slot 等

  • 责任链模式,扩展性好


高频追问

追问1:Sentinel 和 Hystrix 的区别?

| 维度 | Sentinel | Hystrix | |------|----------|---------| | 来源 | 阿里巴巴 | Netflix | | 隔离策略 | 信号量隔离 | 线程池隔离 / 信号量隔离 | | 限流 | 支持多种限流模式 | 不支持限流(只有熔断) | | 熔断策略 | 慢调用、异常比例、异常数 | 异常比例 | | 控制台 | 有,功能丰富 | 有,功能简单 | | 动态规则 | 支持多种数据源 | 一般要自己实现 | | 性能 | 好 | 一般(线程池开销大) | | 社区 | 活跃 | 停止维护 |

主要区别:

  • Hystrix 侧重熔断隔离,Sentinel 功能更全(限流 + 熔断 + 系统保护)

  • Hystrix 线程池隔离开销大,Sentinel 用信号量,轻量

  • Sentinel 有控制台,动态规则更方便

  • Hystrix 已经停止维护了,Sentinel 还在活跃开发

现在新项目一般用 Sentinel。

追问2:什么是服务降级?和熔断有什么关系?

服务降级:

  • 服务压力大的时候,把非核心服务关掉,保证核心服务可用

  • 或者服务不可用时,返回兜底数据

  • 牺牲非核心,保证核心

熔断和降级的关系:

  • 熔断是手段,降级是目的

  • 熔断触发后,走降级逻辑

  • 熔断是自动的,降级可以是手动的也可以是自动的

降级的方式:

  1. 返回兜底数据:返回默认值、缓存值

  2. 返回错误:直接返回错误提示

  3. 关闭非核心功能:比如推荐、评论等非核心功能关闭

  4. 简化功能:只保留核心功能

降级的触发:

  • 熔断触发自动降级

  • 手动降级(运营后台开关)

  • 定时降级(比如大促前降级非核心)

追问3:Sentinel 的限流粒度有哪些?

Sentinel 可以按不同粒度限流:

  1. 接口级限流

    • 单个接口限流

    • 最常用

  2. 服务级限流

    • 整个服务限流

    • 保护整个服务

  3. 按参数限流

    • 按请求参数限流

    • 比如某个用户 ID 限流

    • 热点参数限流

  4. 按 IP 限流

    • 单个 IP 限流

    • 防止单个 IP 刷接口

  5. 按用户限流

    • 单个用户限流

    • 防止单个用户占用太多资源

  6. 系统级限流

    • 整个系统的入口限流

    • 保护系统整体不被打垮

    • 基于 CPU、负载、RT 等

Sentinel 支持很灵活的限流粒度,可以根据业务需求配置。

追问4:Sentinel 规则怎么持久化?

Sentinel 默认规则存在内存里,重启就没了。 生产环境需要持久化规则。

持久化方式:

  1. Nacos(推荐)

    • 规则存在 Nacos 配置中心

    • Sentinel 控制台推送规则到 Nacos

    • 客户端从 Nacos 拉取规则

    • 动态更新,持久化

    • 最常用

  2. Apollo

    • 和 Nacos 类似,用 Apollo 配置中心存规则

  3. ZooKeeper

    • 规则存在 ZooKeeper

    • 监听变化,动态更新

  4. 数据库

    • 规则存在数据库

    • 定时拉取,或者手动刷新

  5. 文件

    • 规则存在本地文件

    • 简单,但不支持动态推送

推荐: Nacos,和 Spring Cloud Alibaba 生态集成好。

追问5:什么是热点参数限流?

热点参数限流:对请求中的某个参数进行限流。

举例:

  • 商品详情接口,按商品 ID 限流

  • 热点商品访问量大,单独限流

  • 非热点商品不限流,或者限流阈值高

原理:

  • 统计每个参数值的访问频率

  • 对热点参数值单独限流

  • 其他参数值用默认限流

应用场景:

  • 秒杀商品:热点商品单独限流,防止打垮

  • 热门话题:热点话题单独限流

  • 防止单个热点资源占用太多资源

Sentinel 支持热点参数限流,用 @SentinelResource 注解配置。


59. 分布式事务(Seata)基本原理

标准答案

分布式事务是微服务架构的经典问题,Seata 是阿里巴巴开源的分布式事务框架。

【什么是分布式事务】

分布式事务:一个事务涉及多个服务(多个数据库),要保证多个服务要么都成功,要么都失败。

为什么难:

  • 每个服务自己的事务是独立的

  • 跨服务的事务不好协调

  • 网络问题、服务挂了都可能导致不一致

CAP 定理:

  • C(一致性):数据一致

  • A(可用性):服务可用

  • P(分区容错性):网络分区时能工作

  • 分布式系统中,P 是必须的,C 和 A 不能同时满足

  • 所以分布式事务都是在 C 和 A 之间做权衡


【常见的分布式事务方案】

1. 2PC(两阶段提交)

  • 阶段一:协调者问所有参与者,能不能提交

  • 阶段二:都能提交就提交,有一个不能就回滚

  • 强一致,但同步阻塞,可用性差

  • 代表:XA 协议

2. TCC(Try-Confirm-Cancel)

  • Try:预留资源

  • Confirm:确认提交

  • Cancel:取消回滚

  • 业务层面实现,性能好,但开发量大

  • 最终一致

3. 可靠消息最终一致性

  • 发消息,消费者消费消息

  • 保证消息不丢,消费幂等

  • 最终一致

  • 适合异步场景

4. SAGA 模式

  • 长事务,拆成多个本地事务

  • 每个本地事务有对应的补偿操作

  • 失败了反向调用补偿操作

  • 最终一致,适合长事务


【Seata 是什么】

Seata(Simple Extensible Autonomous Transaction Architecture)是阿里巴巴开源的分布式事务框架。

Seata 的四个模式:

  1. AT 模式:自动事务,基于 2PC,自动生成回滚 SQL

  2. TCC 模式:Try-Confirm-Cancel,业务层面实现

  3. SAGA 模式:长事务,补偿机制

  4. XA 模式:基于 XA 协议的 2PC

最常用的是 AT 模式,简单易用,业务侵入小。


【Seata AT 模式原理】

AT 模式是 Seata 默认的模式,基于 2PC,但做了优化。

核心概念:

  1. TC(Transaction Coordinator):事务协调者

    • 独立部署的服务

    • 管理全局事务的状态

    • 协调提交和回滚

  2. TM(Transaction Manager):事务管理器

    • 事务发起方

    • 开启全局事务、提交、回滚

  3. RM(Resource Manager):资源管理器

    • 事务参与方

    • 管理本地事务

    • 上报状态给 TC

AT 模式的流程:

一阶段(业务执行 + 记录回滚日志):

  1. TM 开启全局事务(生成全局事务 ID:XID)

  2. 调用各个服务,XID 在服务间传递

  3. 每个服务的 RM:

    • 解析 SQL

    • 查询修改前的数据(前镜像)

    • 执行业务 SQL

    • 查询修改后的数据(后镜像)

    • 生成回滚日志(undo log)

    • 本地事务提交

    • 把回滚日志和事务状态上报给 TC

二阶段(提交 / 回滚):

提交:

  • 所有服务都成功了

  • TM 通知 TC 提交全局事务

  • TC 通知所有 RM 提交

  • RM 异步删除回滚日志(因为已经提交了,不需要回滚了)

  • 很快,因为一阶段已经提交了本地事务

回滚:

  • 有一个服务失败了

  • TM 通知 TC 回滚全局事务

  • TC 通知所有 RM 回滚

  • RM 根据回滚日志(undo log)生成反向 SQL,执行回滚

  • 回滚完删除回滚日志

  • 上报 TC

AT 模式的特点:

  • 业务侵入小,几乎不用改代码

  • 自动生成回滚 SQL

  • 一阶段就提交本地事务,性能好

  • 最终一致

  • 有脏写问题(需要加全局锁)


【Seata 的隔离机制】

问题: 一阶段就提交了本地事务,其他事务能看到未提交的数据吗?会有脏写吗?

解决方案:全局锁

  • 每个事务修改数据前,先拿全局锁

  • 拿到锁才能修改

  • 没拿到就等

  • 保证同一时间只有一个全局事务能修改这条数据

  • 防止脏写

读隔离:

  • 默认是读未提交(因为一阶段就提交了)

  • 要读已提交的话,需要加全局锁(select for update)

  • 或者用事务消息等其他方案


高频追问

追问1:2PC 和 3PC 的区别?

2PC(两阶段提交):

  • 阶段一:准备(CanCommit)

  • 阶段二:提交/回滚(DoCommit)

  • 问题:

    • 同步阻塞:所有参与者都锁资源等协调者

    • 单点故障:协调者挂了就卡住了

    • 数据不一致:阶段二协调者挂了,部分提交了部分没提交

3PC(三阶段提交):

  • 阶段一:CanCommit(询问能不能提交)

  • 阶段二:PreCommit(预提交)

  • 阶段三:DoCommit(真正提交)

  • 改进:

    • 增加了超时机制,参与者超时后自动提交

    • 减少了阻塞范围

  • 问题:

    • 还是可能不一致

    • 更复杂了

实际中 2PC 用得多,3PC 用得少。 Seata 的 AT 模式是改进的 2PC,一阶段就提交本地事务,性能更好。

追问2:TCC 和 AT 模式的区别?怎么选?

| 维度 | AT 模式 | TCC 模式 | |------|---------|----------| | 业务侵入 | 小,几乎不用改代码 | 大,要写三个方法 | | 实现方式 | 自动生成回滚 SQL | 业务自己实现 Try/Confirm/Cancel | | 性能 | 好 | 更好(没有全局锁) | | 隔离性 | 全局锁,隔离性好 | 业务层面控制 | | 适用场景 | 大多数业务场景 | 对性能要求高、核心业务 | | 开发成本 | 低 | 高 |

怎么选:

  • 一般业务 → AT 模式,简单方便

  • 核心业务、对性能要求高 → TCC 模式

  • 长事务 → SAGA 模式

  • 异步场景 → 可靠消息最终一致性

AT 模式用得最多,因为开发成本低。

追问3:什么是可靠消息最终一致性?怎么实现?

可靠消息最终一致性:用消息队列实现分布式事务,保证最终一致。

原理:

  • 事务发起方发消息

  • 事务参与方消费消息

  • 保证消息不丢,消费幂等

  • 最终数据一致

实现方案:

1. 本地消息表(经典方案)

  • 业务操作和写消息表在同一个本地事务里

  • 定时任务发消息

  • 消费者消费,幂等

  • 简单可靠,就是和业务耦合

2. 事务消息(RocketMQ)

  • RocketMQ 支持事务消息

  • 半消息机制

  • 本地事务成功了才提交消息

  • 失败就回滚消息

  • 更优雅,但依赖 RocketMQ

适用场景:

  • 异步场景

  • 对实时性要求不高

  • 能接受最终一致

这是最常用的分布式事务方案之一,很多公司都用。

追问4:分布式事务有哪些方案?怎么选型?

常见方案对比:

| 方案 | 一致性 | 性能 | 侵入性 | 适用场景 | |------|--------|------|--------|----------| | 2PC/XA | 强一致 | 差 | 中 | 对一致性要求极高,并发不高 | | TCC | 最终一致 | 好 | 高 | 核心业务,性能要求高 | | SAGA | 最终一致 | 好 | 中 | 长事务,流程多 | | 可靠消息 | 最终一致 | 好 | 低 | 异步场景,能接受延迟 | | Seata AT | 最终一致 | 好 | 低 | 大多数业务场景 |

选型原则:

  1. 能不用就不用:尽量避免分布式事务,从设计上规避

  2. 能异步就异步:用消息最终一致性,性能好

  3. 同步场景:用 Seata AT 或 TCC

  4. 长事务:用 SAGA

  5. 强一致:用 XA,但性能差,慎用

实际上,大部分场景最终一致就够了,强一致的场景很少。

追问5:Seata 的 AT 模式有什么坑?

AT 模式虽然好用,但也有一些坑:

  1. 脏写问题

    • 一阶段就提交了本地事务

    • 其他事务可以修改同一条数据

    • 靠全局锁防止,但全局锁影响性能

    • 高并发下要注意

  2. 回滚失败怎么办

    • 如果回滚失败了,数据就不一致了

    • Seata 会重试,但一直失败就人工处理

    • 需要监控和告警

  3. SQL 解析的限制

    • 不是所有 SQL 都支持

    • 复杂 SQL 可能解析不了

    • 比如批量更新、存储过程等

  4. 大事务问题

    • 全局事务时间太长

    • 全局锁持有时间长

    • 影响并发

    • 要避免大事务

  5. TC 单点问题

    • TC 是协调者,挂了怎么办

    • TC 要集群部署,高可用

  6. 性能问题

    • 比本地事务慢

    • 因为要记录回滚日志、和 TC 通信

    • 高并发场景要评估

AT 模式很好用,但不是银弹,要了解它的限制。

📌 面试技巧:回答微服务相关问题时,要体现出你对微服务整体架构的理解,不要只背单个组件的用法。比如讲网关就联系到认证、限流,讲注册中心就联系到服务发现和负载均衡,讲分布式事务就讲 CAP 和各种方案对比。知识体系化,面试官会觉得你有架构思维。


第八章 Linux & Docker(8题)


60. Linux 常用命令(ps、top、grep、find、netstat/ss、lsof、tail 等)

标准答案

Linux 常用命令是后端开发必备技能,面试经常考。

【进程相关】

1. ps(查看进程)

# 查看所有进程
ps aux

# 查看所有进程(另一种格式)
ps -ef

# 查找某个进程
ps aux | grep java

# 查看进程树
ps -ejH

输出字段说明:

  • USER:进程所属用户

  • PID:进程 ID

  • %CPU:CPU 使用率

  • %MEM:内存使用率

  • VSZ:虚拟内存大小

  • RSS:物理内存大小

  • STAT:进程状态(R 运行、S 睡眠、D 不可中断睡眠、Z 僵尸、T 停止)

  • START:启动时间

  • TIME:CPU 时间

  • COMMAND:命令

2. top(实时查看进程)

# 实时显示进程状态
top

# 按 CPU 排序(默认)
top -o %CPU

# 按内存排序
top -o %MEM

# 只看某个进程
top -p 1234

top 输出说明:

  • 第一行:系统时间、运行时间、登录用户数、负载均衡(1/5/15分钟)

  • 第二行:进程总数、运行中、睡眠、停止、僵尸

  • 第三行:CPU 使用率(us 用户、sy 系统、id 空闲、wa 等待 IO)

  • 第四行:内存使用(总、用了、空闲、缓存)

  • 第五行:交换分区

  • 下面是进程列表

常用快捷键:

  • P:按 CPU 排序

  • M:按内存排序

  • T:按时间排序

  • k:杀死进程

  • q:退出


【文件查找与搜索】

3. grep(文本搜索)

# 在文件中搜索
grep "keyword" file.txt

# 递归搜索目录下所有文件
grep -r "keyword" /path/

# 显示行号
grep -n "keyword" file.txt

# 忽略大小写
grep -i "keyword" file.txt

# 反向匹配(不包含的)
grep -v "keyword" file.txt

# 统计匹配行数
grep -c "keyword" file.txt

# 同时搜索多个关键词
grep -E "key1|key2" file.txt

4. find(文件查找)

# 按文件名查找
find /path -name "*.txt"

# 按类型查找(f 文件、d 目录)
find /path -type f -name "*.log"

# 按大小查找(+10M 大于10M,-10M 小于10M)
find /path -size +10M

# 按时间查找(mtime 修改时间,+7 7天前)
find /path -mtime +7

# 按用户查找
find /path -user root

# 查找后执行命令(比如删除)
find /path -name "*.tmp" -delete
find /path -name "*.tmp" -exec rm {} \;

5. tail(查看文件末尾)

# 查看文件最后 10 行
tail file.txt

# 查看最后 100 行
tail -n 100 file.txt

# 实时追踪文件内容
tail -f file.log

# 实时追踪,显示行号
tail -f -n 100 file.log

类似命令:

  • head:看文件开头

  • cat:看整个文件

  • less:分页查看(比 more 强大)

  • more:分页查看


【网络相关】

6. netstat / ss(网络连接)

netstat 逐渐被 ss 替代,ss 更快更高效。

# 查看所有监听端口
netstat -tlnp
ss -tlnp

# 查看所有连接
netstat -an
ss -an

# 查看 TCP 连接
netstat -tn
ss -tn

# 查看 UDP 连接
netstat -un
ss -un

# 查看进程对应的端口
netstat -tlnp | grep java
ss -tlnp | grep java

参数说明:

  • -t:TCP

  • -u:UDP

  • -l:监听

  • -n:数字显示(不解析域名)

  • -p:显示进程

7. lsof(查看文件/端口被谁占用)

# 查看端口被哪个进程占用
lsof -i :8080

# 查看进程打开的文件
lsof -p 1234

# 查看用户打开的文件
lsof -u root

# 查看某个文件被谁打开
lsof /path/to/file

【其他常用命令】

8. df / du(磁盘使用)

# 查看磁盘使用情况
df -h

# 查看目录大小
du -sh /path

# 查看目录下各文件大小
du -h /path

9. free(内存使用)

# 查看内存使用
free -h

10. kill(杀死进程)

# 正常杀死进程
kill 1234

# 强制杀死
kill -9 1234

# 按名称杀死
pkill java
killall java

11. tar(压缩/解压)

# 压缩
tar -czf archive.tar.gz /path/

# 解压
tar -xzf archive.tar.gz

# 解压到指定目录
tar -xzf archive.tar.gz -C /target/

12. chmod / chown(权限)

# 修改权限
chmod 755 file.sh

# 修改所有者
chown user:group file.txt

# 递归修改
chmod -R 755 /path/

高频追问

追问1:怎么查看哪个进程占用 CPU 最高?

# 方法 1:top 命令,按 P 排序
top

# 方法 2:ps 排序
ps aux --sort=-%cpu | head -10

# 方法 3:htop(更强大,需要安装)
htop

找到高 CPU 进程后,再看它的线程:

# 查看进程的线程
top -Hp 1234

# 或者
ps -mp 1234 -o THREAD,tid,time

找到高 CPU 的线程 ID,转成十六进制,再用 jstack 看线程栈(Java 进程)。

追问2:怎么查看哪个进程占用内存最多?

# 方法 1:top 命令,按 M 排序
top

# 方法 2:ps 排序
ps aux --sort=-%mem | head -10

还可以看具体的内存分布:

# 查看进程的内存映射
pmap 1234

# 查看系统内存详细信息
cat /proc/meminfo

追问3:怎么查看端口被哪个进程占用?

# 方法 1:lsof
lsof -i :8080

# 方法 2:netstat
netstat -tlnp | grep 8080

# 方法 3:ss(推荐)
ss -tlnp | grep 8080

# 方法 4:fuser
fuser 8080/tcp

这几个命令都可以,lsof 最直观。

追问4:怎么实时查看日志?

# 最常用:tail -f
tail -f app.log

# 从最后 100 行开始追踪
tail -f -n 100 app.log

# 追踪多个文件
tail -f app1.log app2.log

# 更强大的:less + F
less app.log
# 然后按 F 进入追踪模式,Ctrl+C 退出,q 退出

还可以配合 grep 过滤:

# 只看包含 ERROR 的行
tail -f app.log | grep ERROR

追问5:find 和 grep 的区别?

| 命令 | 作用 | 搜索对象 | |------|------|---------| | find | 查找文件 | 文件名、大小、时间等属性 | | grep | 搜索文本 | 文件内容 |

  • find:找文件(根据文件名、大小、时间等)

  • grep:找内容(在文件里找字符串)

举例:

# 找名字带 log 的文件
find / -name "*.log"

# 在文件里找 ERROR 字符串
grep ERROR app.log

经常配合使用:

# 找到所有 log 文件,在里面搜索 ERROR
find /var/log -name "*.log" -exec grep ERROR {} \;

61. Linux 如何排查线上问题

标准答案

线上问题排查是后端工程师的核心技能,需要系统化的思路。

【排查整体思路】

发现问题
    ↓
确定范围(影响多大、哪些服务、哪些用户)
    ↓
看监控(CPU、内存、磁盘、网络、应用指标)
    ↓
定位方向(CPU 高?内存高?IO 高?网络问题?应用报错?)
    ↓
深入排查(具体进程、具体线程、具体报错)
    ↓
找到根因
    ↓
解决问题 + 复盘

【常见问题排查】

1. CPU 使用率高

排查步骤:

# 1. 看整体 CPU 使用
top

# 2. 找到 CPU 最高的进程
# top 里按 P 排序,或者:
ps aux --sort=-%cpu | head -5

# 3. 看进程里哪个线程 CPU 最高
top -Hp <pid>
# 找到 TID(线程 ID)

# 4. 如果是 Java 进程,看线程栈
# 把 TID 转成十六进制
printf "%x\n" <tid>
# 用 jstack 查看线程栈
jstack <pid> | grep <十六进制tid> -A 30

常见原因:

  • 死循环

  • 频繁 GC

  • 大量计算

  • 线程死锁


2. 内存问题(OOM、内存泄漏)

排查步骤:

# 1. 看整体内存使用
free -h
top  # 按 M 排序

# 2. 找到内存最高的进程
ps aux --sort=-%mem | head -5

# 3. 如果是 Java 进程
# 看堆内存使用
jmap -heap <pid>

# 看对象统计
jmap -histo <pid> | head -20

# dump 堆内存,离线分析
jmap -dump:format=b,file=heap.hprof <pid>
# 然后用 MAT、JVisualVM 分析

# 看 GC 情况
jstat -gcutil <pid> 1000

常见原因:

  • 内存泄漏(对象没释放)

  • 内存溢出(数据量太大)

  • 堆内存设置太小

  • 频繁 GC


3. 磁盘问题(磁盘满、IO 高)

排查步骤:

# 1. 看磁盘空间
df -h

# 2. 看哪个目录占空间大
du -sh /* | sort -rh | head -10

# 3. 看磁盘 IO
iostat -x 1

# 4. 看哪个进程 IO 高
iotop

常见原因:

  • 日志文件太大

  • 临时文件没清理

  • 数据库文件大

  • 磁盘 IO 瓶颈


4. 网络问题

排查步骤:

# 1. 看网络连接
netstat -an | grep ESTABLISHED | wc -l
ss -s

# 2. 看端口占用
netstat -tlnp
ss -tlnp

# 3. 测试连通性
ping ip
telnet ip port
curl url

# 4. 看网络流量
iftop
nload

# 5. 看 DNS
nslookup domain
dig domain

常见原因:

  • 连接数太多

  • 端口被占用

  • 网络延迟高

  • DNS 解析慢

  • 防火墙问题


5. 应用报错

排查步骤:

# 1. 看应用日志
tail -f app.log
tail -f app.log | grep ERROR

# 2. 看错误堆栈
grep -A 50 "Exception" app.log

# 3. 看最近的错误
tail -n 1000 app.log | grep -i error

# 4. 按时间范围查
sed -n '/2024-01-01 10:00/,/2024-01-01 11:00/p' app.log | grep ERROR

【排查工具清单】

| 类型 | 工具 | 作用 | |------|------|------| | CPU | top、ps、pidstat | CPU 使用情况 | | 内存 | free、top、vmstat、jmap | 内存使用情况 | | 磁盘 | df、du、iostat、iotop | 磁盘空间和 IO | | 网络 | netstat、ss、ping、curl、iftop | 网络连接和流量 | | 进程 | ps、top、lsof、strace | 进程信息 | | 日志 | tail、grep、less、awk | 日志分析 | | Java | jstack、jmap、jstat、jinfo | JVM 排查 |


高频追问

追问1:线上服务突然变慢了,怎么排查?

排查思路:从外到内,从整体到局部。

第一步:确认现象

  • 是所有接口都慢还是某个接口慢?

  • 从什么时候开始的?

  • 有没有发版、配置变更?

第二步:看系统层面

# CPU
top

# 内存
free -h

# 磁盘 IO
iostat -x 1

# 网络
iftop
netstat -an | wc -l

第三步:看应用层面

  • 看应用日志,有没有报错

  • 看 GC 情况(jstat)

  • 看线程栈(jstack)

  • 看监控指标(QPS、响应时间、错误率)

第四步:看依赖

  • 数据库慢不慢?

  • Redis 慢不慢?

  • 下游服务慢不慢?

  • 第三方接口慢不慢?

第五步:定位根因,解决问题

核心思路:先排除系统层面,再看应用层面,最后看依赖。

追问2:怎么排查 Java 应用的 CPU 100%?

经典排查步骤:

  1. 找到高 CPU 进程

top
# 找到 CPU 最高的 Java 进程 PID
  1. 找到高 CPU 线程

top -Hp <PID>
# 找到 CPU 最高的线程 TID
  1. 线程 ID 转十六进制

printf "%x\n" <TID>
# 比如 TID 是 1234,转成 0x4d2
  1. 看线程栈

jstack <PID> | grep <十六进制TID> -A 50
  1. 分析线程栈

  • 看线程在做什么

  • 是在跑什么方法

  • 找到问题代码

常见原因:死循环、频繁 GC、正则回溯、大量计算等。

追问3:怎么排查内存泄漏?

排查步骤:

  1. 确认是不是内存泄漏

    • 看内存使用趋势,是不是一直在涨

    • Full GC 后内存降不下来

    • 越来越慢,最后 OOM

  2. dump 堆内存

jmap -dump:format=b,file=heap.hprof <pid>
  1. 分析 dump 文件

    • 用 MAT(Memory Analyzer Tool)

    • 看哪些对象占内存最多

    • 看对象的引用链

    • 找到为什么没被回收

  2. 定位代码

    • 找到泄漏的对象

    • 找到是谁持有引用

    • 修复代码

常见内存泄漏原因:

  • 静态集合类(static Map、List)

  • 各种连接没关闭(数据库连接、网络连接)

  • 监听器没注销

  • ThreadLocal 没清理

  • 缓存没过期策略

  • 内部类持有外部类引用

追问4:怎么排查死锁?

Java 死锁排查:

# 方法 1:jstack 直接看
jstack <pid> | grep -A 50 "Found one Java-level deadlock"
# jstack 会自动检测死锁

# 方法 2:jconsole / jvisualvm
# 图形化工具,检测死锁

# 方法 3:Arthas
thread -b  # 查看阻塞的线程

死锁的四个必要条件:

  1. 互斥:资源不能共享

  2. 持有并等待:持有资源同时等其他资源

  3. 不可剥夺:资源不能被强行剥夺

  4. 循环等待:形成循环等待链

排查死锁的思路:

  • 看线程栈,找到互相等待的线程

  • 看它们在等什么锁

  • 分析锁的获取顺序

  • 修复:统一锁的顺序、用超时锁、用并发工具类

追问5:线上出问题了,怎么快速恢复?

先恢复,再排查!

快速恢复手段:

  1. 重启

    • 最简单粗暴

    • 很多问题重启就能好

    • 先恢复,再慢慢查原因

  2. 回滚

    • 如果是发版后出的问题

    • 立刻回滚到上一个版本

    • 最快恢复

  3. 扩容

    • 如果是流量突增扛不住

    • 临时扩容

    • 先扛住流量

  4. 降级

    • 关闭非核心功能

    • 保证核心功能可用

    • 比如关闭推荐、评论等

  5. 限流

    • 限制流量

    • 防止服务被打垮

    • 保护系统

  6. 切流量

    • 切到备用集群

    • 或者切走问题机器的流量

原则:先恢复业务,再排查根因。 业务恢复是第一位的,根因可以慢慢查。


62. Docker 镜像和容器区别

标准答案

Docker 是容器化技术,核心概念是镜像和容器。

【什么是 Docker 镜像(Image)】

定义:Docker 镜像是一个只读的模板,包含运行应用所需的一切:代码、运行时、库、环境变量、配置文件等。

特点:

  • 只读,不能修改

  • 分层存储(UnionFS)

  • 可以共享和复用

  • 类似"类"的概念

镜像的分层:

  • 镜像由多层组成

  • 每层只读

  • 下面的层是基础,上面的层叠加

  • 不同镜像可以共享底层的层

  • 节省空间,下载快

举例:

  • 基础层:CentOS 系统

  • 中间层:JDK 环境

  • 顶层:应用代码

  • 三层组成一个完整的镜像


【什么是 Docker 容器(Container)】

定义:Docker 容器是镜像运行的实例,是一个独立运行的应用。

特点:

  • 可读写(在镜像上加了一层可写层)

  • 独立运行,有自己的文件系统、网络、进程空间

  • 类似"对象"的概念(镜像就是类,容器就是对象)

容器的分层:

  • 底层是镜像的各层(只读)

  • 最上面加了一层可写层(Container Layer)

  • 容器的修改都写在这一层

  • 不影响镜像

  • 删除容器,可写层就没了,镜像还在


【镜像 vs 容器对比】

| 维度 | 镜像(Image) | 容器(Container) | |------|--------------|------------------| | 类比 | 类(Class) | 对象(Instance) | | 读写性 | 只读 | 可读写 | | 状态 | 静态 | 动态(运行中) | | 数量 | 一个镜像可以创建多个容器 | 多个容器相互独立 | | 生命周期 | 创建后不变 | 可以启动、停止、删除 | | 存储 | 分层,只读 | 镜像层 + 可写层 | | 共享 | 多个容器共享同一个镜像 | 各自独立 |


【Docker 其他核心概念】

1. Dockerfile

  • 用来构建镜像的脚本文件

  • 写构建步骤

  • docker build 命令构建镜像

2. Registry(仓库)

  • 存放镜像的地方

  • 比如 Docker Hub、阿里云镜像仓库

  • 可以上传下载镜像

3. Volume(数据卷)

  • 容器的数据持久化

  • 容器删除了,数据卷还在

  • 用来存数据


高频追问

追问1:Docker 镜像的分层原理是什么?

Docker 镜像用的是联合文件系统(UnionFS)。

什么是联合文件系统:

  • 把多个目录(层)联合挂载成一个目录

  • 看起来像一个目录,实际是多层叠加

  • 下层是只读的,上层可以覆盖下层

镜像分层的好处:

  1. 共享层,节省空间

    • 多个镜像共享底层的层

    • 比如都用 CentOS 基础镜像,就只存一份

    • 节省磁盘空间

  2. 下载快

    • 已经有的层不用再下载

    • 只下载没有的层

    • 拉取镜像快

  3. 复用性好

    • 基础镜像大家共用

    • 在基础镜像上构建自己的镜像

    • 不用从零开始

常见的存储驱动:

  • AUFS

  • Overlay2(现在最常用)

  • DeviceMapper

  • Btrfs

  • ZFS

追问2:容器的可写层是什么?删除容器数据会丢吗?

容器启动时,在镜像的最上面加了一层可写层(Container Layer)。

可写层的特点:

  • 容器的所有修改都写在这一层

  • 比如新建文件、修改文件、删除文件

  • 不影响底下的镜像层

  • 每个容器有自己的可写层,互不影响

删除容器会怎样?

  • 删除容器,可写层就没了

  • 镜像层还在

  • 所以容器里的数据默认会丢

怎么持久化数据?

  • 数据卷(Volume):把数据存在宿主机上,容器删除数据还在

  • 绑定挂载(Bind Mount):直接挂载宿主机目录

  • tmpfs:存在内存里,容器删除就没了

所以:有状态的数据(数据库、文件等)一定要用数据卷,不要存在容器里。

追问3:Docker 和虚拟机的区别?

| 维度 | Docker 容器 | 虚拟机(VM) | |------|------------|-------------| | 虚拟化层级 | 操作系统级虚拟化 | 硬件级虚拟化 | | 内核 | 共享宿主机内核 | 有自己的内核 | | 体积 | 小(MB 级) | 大(GB 级) | | 启动速度 | 快(秒级) | 慢(分钟级) | | 性能 | 接近原生 | 有损耗 | | 隔离性 | 进程级隔离,稍弱 | 系统级隔离,强 | | 资源占用 | 小 | 大 | | 适用场景 | 应用容器化、微服务 | 完整操作系统、不同系统 |

简单说:

  • 虚拟机:模拟了一整台电脑,有自己的操作系统

  • 容器:只是一个进程,共享宿主机内核

  • 容器更轻量、更快,但隔离性不如虚拟机

追问4:Dockerfile 常用指令有哪些?

常用 Dockerfile 指令:

| 指令 | 作用 | 示例 | |------|------|------| | FROM | 基础镜像 | FROM openjdk:8-jre | | MAINTAINER | 作者 | MAINTAINER name | | RUN | 构建时执行命令 | RUN apt-get install xxx | | COPY | 复制文件 | COPY app.jar /app/ | | ADD | 复制文件(支持 URL、解压) | ADD app.tar.gz /app/ | | WORKDIR | 工作目录 | WORKDIR /app | | ENV | 环境变量 | ENV JAVA_OPTS="-Xms512m" | | EXPOSE | 暴露端口 | EXPOSE 8080 | | CMD | 启动命令(容器启动时执行) | CMD ["java", "-jar", "app.jar"] | | ENTRYPOINT | 入口点 | ENTRYPOINT ["java", "-jar"] | | VOLUME | 数据卷 | VOLUME /data | | USER | 运行用户 | USER appuser |

CMD 和 ENTRYPOINT 的区别:

  • CMD:可以被 docker run 后面的命令覆盖

  • ENTRYPOINT:不会被覆盖,一定会执行

  • 经常配合用:ENTRYPOINT 定程序,CMD 定参数

追问5:怎么优化 Docker 镜像大小?

镜像优化技巧:

  1. 用更小的基础镜像

    • 不用 centos、ubuntu 这种大的

    • 用 alpine(很小,几 MB)

    • 或者用 distroless 镜像(只有运行时,没有其他工具)

  2. 多阶段构建

    • 第一阶段:构建(有编译工具,体积大)

    • 第二阶段:运行(只拷贝构建结果,体积小)

    • 最终镜像只有运行需要的东西

# 构建阶段
FROM maven:3.6-jdk-8 AS builder
WORKDIR /app
COPY . .
RUN mvn package

# 运行阶段
FROM openjdk:8-jre-alpine
WORKDIR /app
COPY --from=builder /app/target/app.jar .
CMD ["java", "-jar", "app.jar"]
  1. 减少层数

    • 多个 RUN 合并成一个

    • 减少镜像层数

  2. 清理缓存

    • 安装完包后清理缓存

    • apt-get install ... && apt-get clean

  3. 不用的东西不要放

    • 只放运行需要的

    • 源码、编译工具、文档都不要

优化后镜像可以从几百 MB 降到几十 MB 甚至几 MB。


63. Docker 常用命令

标准答案

Docker 常用命令分几类:镜像、容器、仓库、数据卷、网络等。

【镜像相关命令】

# 查看本地镜像
docker images
docker image ls

# 拉取镜像
docker pull nginx
docker pull nginx:1.21

# 构建镜像(Dockerfile 在当前目录)
docker build -t myapp:1.0 .

# 删除镜像
docker rmi myapp:1.0
docker image rm myapp:1.0

# 强制删除
docker rmi -f myapp:1.0

# 查看镜像详情
docker inspect nginx

# 查看镜像历史(分层)
docker history nginx

# 给镜像打标签
docker tag myapp:1.0 myapp:latest

# 导出镜像为 tar 文件
docker save -o myapp.tar myapp:1.0

# 从 tar 文件导入镜像
docker load -i myapp.tar

【容器相关命令】

# 查看运行中的容器
docker ps

# 查看所有容器(包括停止的)
docker ps -a

# 启动容器
docker start <容器ID或名字>

# 停止容器
docker stop <容器ID或名字>

# 强制停止
docker kill <容器ID或名字>

# 重启容器
docker restart <容器ID或名字>

# 删除容器
docker rm <容器ID或名字>

# 强制删除(运行中的也删)
docker rm -f <容器ID或名字>

# 删除所有停止的容器
docker container prune

# 查看容器详情
docker inspect <容器ID>

# 查看容器日志
docker logs <容器ID>
docker logs -f <容器ID>      # 实时追踪
docker logs --tail 100 <容器ID>  # 最后100行

# 进入容器
docker exec -it <容器ID> /bin/bash
docker exec -it <容器ID> sh

# 查看容器进程
docker top <容器ID>

# 查看容器资源使用
docker stats

# 拷贝文件(宿主机和容器之间)
docker cp file.txt <容器ID>:/path/
docker cp <容器ID>:/path/file.txt ./

# 容器端口映射
docker run -p 8080:80 nginx
# 宿主机 8080 端口映射到容器 80 端口

【docker run 常用参数】

# 基本用法
docker run [选项] 镜像 [命令]

# 常用选项:
# -d:后台运行(守护进程)
docker run -d nginx

# -p:端口映射
docker run -d -p 8080:80 nginx

# --name:给容器起名
docker run -d --name mynginx nginx

# -v:数据卷挂载
docker run -d -v /host/path:/container/path nginx

# -e:环境变量
docker run -d -e "JAVA_OPTS=-Xms512m" myapp

# --rm:容器停止后自动删除
docker run --rm nginx

# -it:交互式运行(进入容器)
docker run -it centos /bin/bash

# --network:指定网络
docker run --network mynet nginx

# --restart:重启策略
docker run --restart=always nginx

重启策略:

  • no:不自动重启(默认)

  • on-failure:失败时重启

  • always:总是重启

  • unless-stopped:总是重启,除非手动停止


【仓库相关命令】

# 登录仓库
docker login
docker login registry.cn-hangzhou.aliyuncs.com

# 登出
docker logout

# 推送镜像
docker push myapp:1.0

# 搜索镜像
docker search nginx

【数据卷相关命令】

# 创建数据卷
docker volume create myvolume

# 查看数据卷列表
docker volume ls

# 查看数据卷详情
docker volume inspect myvolume

# 删除数据卷
docker volume rm myvolume

# 删除未使用的数据卷
docker volume prune

【网络相关命令】

# 查看网络列表
docker network ls

# 创建网络
docker network create mynet

# 查看网络详情
docker network inspect mynet

# 删除网络
docker network rm mynet

# 容器连接到网络
docker network connect mynet <容器ID>

# 断开网络
docker network disconnect mynet <容器ID>

高频追问

追问1:docker run 和 docker exec 的区别?

| 命令 | 作用 | 场景 | |------|------|------| | docker run | 创建并启动一个新容器 | 第一次启动容器 | | docker exec | 在已运行的容器里执行命令 | 进入已运行的容器、执行命令 |

简单说:

  • docker run:新建容器,启动

  • docker exec:在已经运行的容器里执行命令

举例:

# 新建并启动一个 nginx 容器
docker run -d --name mynginx nginx

# 进入这个已经运行的容器
docker exec -it mynginx /bin/bash

追问2:怎么进入一个正在运行的容器?

# 方法 1:docker exec(推荐)
docker exec -it <容器ID> /bin/bash
# 或者
docker exec -it <容器ID> sh

# 方法 2:docker attach(不推荐)
docker attach <容器ID>
# attach 是附加到容器的主进程,退出会导致容器停止

推荐用 docker exec,因为:

  • 退出不会影响容器

  • 可以开多个终端

  • 更灵活

如果容器里没有 bash,就用 sh:

docker exec -it <容器ID> sh

追问3:怎么查看容器的日志?

# 查看所有日志
docker logs <容器ID>

# 实时追踪
docker logs -f <容器ID>

# 看最后 100 行
docker logs --tail 100 <容器ID>

# 看最近 1 小时的
docker logs --since 1h <容器ID>

# 带时间戳
docker logs -t <容器ID>

# 组合使用
docker logs -f --tail 100 <容器ID>

如果日志太多,可以配合 grep:

docker logs <容器ID> | grep ERROR

追问4:怎么把容器里的文件拷出来?

# 从容器拷到宿主机
docker cp <容器ID>:/path/to/file.txt ./local/

# 从宿主机拷到容器
docker cp ./local/file.txt <容器ID>:/path/to/

docker cp 可以在宿主机和容器之间互相拷贝文件,很方便。

追问5:docker stop 和 docker kill 的区别?

| 命令 | 信号 | 说明 | |------|------|------| | docker stop | SIGTERM(15) | 优雅停止,给时间保存数据 | | docker kill | SIGKILL(9) | 强制杀死,立刻停止 |

docker stop 的过程:

  1. 发送 SIGTERM 信号

  2. 等待容器处理(默认 10 秒)

  3. 如果还没停,再发 SIGKILL 强制杀

docker kill:

  • 直接发 SIGKILL

  • 立刻杀死,不给反应时间

怎么选:

  • 正常停止用 docker stop,优雅

  • 实在停不了用 docker kill,强制


64. Docker Compose 使用场景

标准答案

Docker Compose 是用来定义和运行多容器 Docker 应用的工具。

【什么是 Docker Compose】

问题: 一个应用有多个服务(比如 Web + MySQL + Redis),每个服务一个容器,一个个启动很麻烦。

解决方案: Docker Compose

  • 用一个 YAML 文件定义所有服务

  • 一条命令启动所有服务

  • 管理多容器应用

作用:

  • 定义多容器应用

  • 一键启动、停止、重建所有服务

  • 管理服务依赖关系

  • 管理网络和数据卷


【Docker Compose 文件结构】

docker-compose.yml 示例:

version: '3'

services:
  # Web 服务
  web:
    image: nginx:latest
    ports:
      - "80:80"
    volumes:
      - ./html:/usr/share/nginx/html
    depends_on:
      - app
    networks:
      - mynet

  # 应用服务
  app:
    build: ./app
    ports:
      - "8080:8080"
    environment:
      - DB_HOST=mysql
      - REDIS_HOST=redis
    depends_on:
      - mysql
      - redis
    networks:
      - mynet

  # MySQL 数据库
  mysql:
    image: mysql:5.7
    environment:
      - MYSQL_ROOT_PASSWORD=123456
      - MYSQL_DATABASE=myapp
    volumes:
      - mysql_data:/var/lib/mysql
    networks:
      - mynet

  # Redis 缓存
  redis:
    image: redis:alpine
    volumes:
      - redis_data:/data
    networks:
      - mynet

volumes:
  mysql_data:
  redis_data:

networks:
  mynet:

【Docker Compose 常用命令】

# 启动所有服务(后台运行)
docker-compose up -d

# 启动并构建镜像
docker-compose up -d --build

# 停止所有服务
docker-compose stop

# 停止并删除容器、网络
docker-compose down

# 停止并删除容器、网络、数据卷
docker-compose down -v

# 查看服务状态
docker-compose ps

# 查看日志
docker-compose logs
docker-compose logs -f        # 实时
docker-compose logs web      # 只看某个服务

# 重启服务
docker-compose restart
docker-compose restart web   # 重启单个服务

# 构建镜像
docker-compose build

# 拉取镜像
docker-compose pull

# 进入某个服务
docker-compose exec web /bin/bash

# 查看配置
docker-compose config

【使用场景】

1. 开发环境搭建

  • 开发需要的 MySQL、Redis、MQ 等

  • 一个 compose 文件搞定

  • 新人上手快,不用一个个装

2. 测试环境

  • 一键部署整套应用

  • 测试方便

  • 测试完一键清理

3. 小型部署

  • 小项目、单机器部署

  • 用 Compose 管理多个容器

  • 简单方便

4. CI/CD 流水线

  • 构建测试环境

  • 跑测试

  • 测试完销毁


【Docker Compose vs Kubernetes】

| 维度 | Docker Compose | Kubernetes | |------|---------------|------------| | 定位 | 单机多容器编排 | 集群容器编排 | | 规模 | 单机 | 集群(多节点) | | 功能 | 基本的编排 | 完整的容器编排平台 | | 复杂度 | 简单 | 复杂 | | 适用场景 | 开发、测试、小项目 | 生产、大项目、微服务 | | 高可用 | 不支持 | 支持 | | 自动扩缩容 | 不支持 | 支持 |

简单说:

  • Compose:单机,简单,开发测试用

  • K8s:集群,功能强,生产用


高频追问

追问1:Compose 里 depends_on 是什么?能保证启动顺序吗?

depends_on 表示服务依赖关系,被依赖的服务先启动。

能保证启动顺序吗?

  • 能保证启动顺序(谁先启动谁后启动)

  • 但不能保证服务就绪

  • 比如 depends_on 写了 mysql,只是 mysql 容器先启动了

  • 但 mysql 启动需要时间,可能还没就绪

  • 这时候 app 启动连 mysql 就会失败

怎么解决服务就绪问题?

  1. 应用层重试

    • 连接数据库失败了重试

    • 等数据库就绪

    • 最常用

  2. 健康检查(healthcheck)

    • Compose 里配置健康检查

    • 等健康检查通过了再启动依赖的服务

    • Compose 2.x 支持,3.x 去掉了

  3. wait-for-it 脚本

    • 容器启动时先跑脚本等依赖就绪

    • 等端口通了再启动应用

  4. 用 Kubernetes

    • K8s 有更完善的健康检查和就绪探针

实际开发中,最常用的是应用层重试,简单可靠。

追问2:Compose 怎么管理环境变量?

几种方式:

1. 在 compose 文件里直接写

services:
  app:
    environment:
      - DB_HOST=mysql
      - DB_PORT=3306

2. 用 .env 文件

  • 同目录下的 .env 文件

  • Compose 会自动读取

  • 可以在 compose 文件里引用

.env 文件:

DB_HOST=mysql
DB_PORT=3306

docker-compose.yml:

services:
  app:
    environment:
      - DB_HOST=${DB_HOST}
      - DB_PORT=${DB_PORT}

3. env_file 指定文件

services:
  app:
    env_file:
      - ./app.env

4. 命令行传入

DB_HOST=mysql docker-compose up

一般用 .env 文件最方便,不同环境用不同的 env 文件。

追问3:Compose 里的网络是怎样的?

默认情况下:

  • Compose 会创建一个默认的网络

  • 所有服务都加入这个网络

  • 服务之间可以用服务名互相访问

  • 比如 app 服务可以用 mysql:3306 访问 mysql 服务

自定义网络:

services:
  web:
    networks:
      - frontend
      - backend
  app:
    networks:
      - backend
  mysql:
    networks:
      - backend

networks:
  frontend:
  backend:

可以创建多个网络,不同服务加入不同网络,实现网络隔离。

端口映射:

  • ports 把容器端口映射到宿主机

  • 外部可以通过宿主机 IP + 端口访问

  • 服务之间互相访问不用映射,直接用服务名 + 容器端口

追问4:Compose 和 Docker Swarm 什么关系?

  • Docker Compose:单机多容器编排工具

  • Docker Swarm:Docker 官方的集群编排工具(和 K8s 类似)

关系:

  • Compose 文件可以直接用在 Swarm 上

  • docker stack deploy 用 compose 文件部署到 Swarm 集群

  • 语法大部分兼容

但现在 Swarm 用得少了,Kubernetes 是事实标准。

追问5:生产环境用 Compose 吗?

看情况:

小项目、单机器:

  • 可以用 Compose

  • 简单方便

  • 足够用

大项目、微服务、多机器:

  • 不用 Compose

  • 用 Kubernetes(K8s)

  • 功能更强大,高可用、自动扩缩容、滚动更新等

开发测试环境:

  • 强烈推荐用 Compose

  • 快速搭建环境

  • 统一开发环境

总结:

  • 开发测试 → Compose,很好用

  • 小项目生产 → Compose,也可以

  • 大项目生产 → K8s


65. Nginx 正向代理与反向代理

标准答案

正向代理和反向代理是 Nginx 的核心功能,也是面试高频题。

【什么是代理】

代理:中间人的角色,代替你去做某件事。

类比:

  • 你找中介帮你租房 → 中介就是代理

  • 你不直接和房东打交道,通过中介


【正向代理(Forward Proxy)】

定义:代理客户端,代替客户端去访问服务器。

位置:客户端和服务器之间,靠近客户端。

特点:

  • 代理的是客户端

  • 服务器不知道真正的客户端是谁(只知道代理)

  • 客户端知道自己在用代理

典型场景:

  1. 翻墙 / 访问外网

    • 国内访问不了国外网站

    • 通过代理服务器访问

    • 代理服务器在国外

    • 正向代理

  2. 缓存

    • 代理服务器缓存资源

    • 多个客户端访问同一个资源

    • 代理直接返回缓存,不用每次都去服务器取

  3. 访问控制

    • 公司内网,通过代理上网

    • 代理可以控制能访问哪些网站

    • 上网行为管理

示意图:

客户端 → 正向代理 → 服务器
(客户端知道代理的存在,服务器不知道)

【反向代理(Reverse Proxy)】

定义:代理服务器,代替服务器接收请求。

位置:客户端和服务器之间,靠近服务器端。

特点:

  • 代理的是服务器

  • 客户端不知道真正的服务器是谁(只知道代理)

  • 服务器知道自己被代理

典型场景:

  1. 负载均衡

    • 一个域名对应多个后端服务器

    • Nginx 接收请求,分发给后端

    • 负载均衡,提高性能和可用性

  2. 动静分离

    • 静态资源(图片、CSS、JS)Nginx 直接返回

    • 动态请求转发给后端应用服务器

    • 提高性能

  3. 统一入口

    • 多个服务,统一通过 Nginx 访问

    • 对外只有一个域名

    • 隐藏后端服务细节

  4. 安全防护

    • 后端服务器不直接暴露

    • Nginx 做安全控制、限流、防攻击

示意图:

客户端 → 反向代理 → 服务器1
                → 服务器2
                → 服务器3
(服务器知道代理的存在,客户端不知道)

【正向代理 vs 反向代理对比】

| 维度 | 正向代理 | 反向代理 | |------|---------|---------| | 代理对象 | 客户端 | 服务器 | | 位置 | 靠近客户端 | 靠近服务器 | | 谁知道代理存在 | 客户端知道 | 服务器知道 | | 谁不知道 | 服务器不知道 | 客户端不知道 | | 典型用途 | 翻墙、缓存、访问控制 | 负载均衡、动静分离、统一入口 | | 代表 | VPN、Shadowsocks | Nginx、CDN |

一句话总结:

  • 正向代理:代理客户端,服务器不知道真实客户端

  • 反向代理:代理服务器,客户端不知道真实服务器


【Nginx 反向代理配置示例】

server {
    listen 80;
    server_name www.example.com;

    # 静态资源,Nginx 直接处理
    location /static/ {
        root /var/www/static;
    }

    # 动态请求,转发给后端
    location /api/ {
        proxy_pass http://backend;
        proxy_set_header Host $host;
        proxy_set_header X-Real-IP $remote_addr;
        proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
    }
}

upstream backend {
    server 192.168.1.10:8080;
    server 192.168.1.11:8080;
}

高频追问

追问1:Nginx 反向代理的时候,后端怎么拿到真实客户端 IP?

默认情况下,后端收到的是 Nginx 的 IP,不是真实客户端 IP。

解决方法:通过 HTTP Header 传递

Nginx 配置:

location / {
    proxy_pass http://backend;
    # 传递真实客户端 IP
    proxy_set_header X-Real-IP $remote_addr;
    # 传递代理链
    proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
    # 传递原始 Host
    proxy_set_header Host $host;
}

常用 Header:

  • X-Real-IP:真实客户端 IP

  • X-Forwarded-For:请求经过的代理 IP 列表,逗号分隔

  • X-Forwarded-Proto:原始协议(http/https)

  • Host:原始域名

后端应用从这些 Header 里取真实 IP。

注意:

  • 如果有多层代理,X-Forwarded-For 会有多个 IP

  • 第一个 IP 是真实客户端 IP

  • 后面的是各级代理 IP

追问2:什么是动静分离?Nginx 怎么实现?

动静分离:

  • 静态资源(图片、CSS、JS、HTML)由 Nginx 直接处理

  • 动态请求(接口)转发给后端应用服务器

  • 各司其职,提高性能

为什么要动静分离:

  • Nginx 处理静态资源非常快

  • 不用打到应用服务器,减轻应用压力

  • 静态资源可以缓存,性能更好

Nginx 配置:

server {
    listen 80;
    server_name www.example.com;

    # 静态资源,Nginx 直接返回
    location ~* \.(jpg|png|css|js|gif|ico)$ {
        root /var/www/static;
        expires 7d;  # 缓存 7 天
    }

    # 静态页面
    location / {
        root /var/www/html;
        index index.html;
    }

    # 动态接口,转发给后端
    location /api/ {
        proxy_pass http://backend;
    }
}

还可以配合 CDN,静态资源放 CDN,更快。

追问3:Nginx 和 Apache 的区别?

| 维度 | Nginx | Apache | |------|-------|--------| | 架构 | 异步非阻塞,事件驱动 | 同步阻塞,进程/线程模型 | | 性能 | 高并发下性能好 | 高并发下性能一般 | | 资源占用 | 低 | 高 | | 配置 | 简单灵活 | 稍复杂 | | 模块 | 模块较少,但核心功能强 | 模块丰富 | | 动态内容 | 不擅长,要转发 | 擅长(mod_php 等) | | 静态内容 | 非常擅长 | 一般 | | 适用场景 | 高并发、反向代理、静态资源 | 动态内容、功能丰富 |

简单说:

  • Nginx:轻量、高性能、擅长反向代理和静态资源

  • Apache:功能丰富、擅长动态内容、稳定

现在高并发场景一般都用 Nginx,或者 Nginx + Apache 配合(Nginx 做反向代理和静态,Apache 处理动态)。

追问4:Nginx 为什么快?

Nginx 高性能的原因:

  1. 异步非阻塞事件驱动模型

    • 用 epoll(Linux)事件驱动

    • 一个进程处理大量并发连接

    • 不用为每个连接开一个进程/线程

    • 资源占用少

  2. 多进程 + 单线程

    • 一个 Master 进程,多个 Worker 进程

    • 每个 Worker 单线程,处理多个连接

    • 没有线程切换开销

    • 充分利用多核 CPU

  3. 模块化设计

    • 功能模块化,按需加载

    • 轻量高效

  4. 内存占用小

    • 连接占用内存少

    • 高并发下内存占用低

  5. 静态资源处理快

    • 直接从磁盘读,sendfile 零拷贝

    • 性能很好

  6. 缓存机制

    • 可以缓存静态资源

    • 减少磁盘 IO

核心就是:事件驱动 + 异步非阻塞 + 多进程,高并发下性能好。

追问5:Nginx 的 Master 和 Worker 进程是什么?

Nginx 采用多进程模型:

Master 进程(主进程):

  • 管理 Worker 进程

  • 接收管理信号(reload、stop 等)

  • 监控 Worker 状态,Worker 挂了重启

  • 不处理请求

Worker 进程(工作进程):

  • 实际处理请求

  • 每个 Worker 是单线程的

  • 每个 Worker 处理多个连接

  • Worker 数量一般设为 CPU 核数

为什么这样设计:

  • Master 负责管理,Worker 负责干活

  • 一个 Worker 挂了不影响其他 Worker

  • 可以热加载(reload),不中断服务

  • 充分利用多核 CPU

相关命令:

nginx -s reload   # 重新加载配置(热加载)
nginx -s stop     # 停止
nginx -s quit     # 优雅停止
nginx -s reopen   # 重新打开日志文件

reload 的过程:

  1. Master 收到 reload 信号

  2. Master 重新加载配置

  3. Master 启动新的 Worker 进程

  4. 旧 Worker 处理完当前请求后退出

  5. 新 Worker 接管新请求

  • 整个过程不中断服务,热加载


66. Nginx 负载均衡策略

标准答案

Nginx 负载均衡是反向代理的重要应用,把请求分发到多个后端服务器。

【什么是负载均衡】

问题: 一个后端服务器扛不住了怎么办?

答案: 加多台服务器,把请求分发到各台服务器,这就是负载均衡。

作用:

  • 提高系统性能(多台机器一起扛)

  • 提高可用性(一台挂了还有其他的)

  • 水平扩展(加机器就能提升性能)


【Nginx 负载均衡策略】

Nginx 内置了多种负载均衡策略:

1. 轮询(Round Robin,默认)

特点:

  • 按顺序轮流分配

  • 每个请求按时间顺序逐一分配到不同的后端服务器

  • 最基本的策略

配置:

upstream backend {
    server 192.168.1.10:8080;
    server 192.168.1.11:8080;
    server 192.168.1.12:8080;
}

适用场景: 后端服务器性能差不多,无状态服务。


2. 加权轮询(Weight)

特点:

  • 给不同服务器设置不同的权重

  • 权重越高,分配的请求越多

  • 性能好的机器权重高一点

配置:

upstream backend {
    server 192.168.1.10:8080 weight=3;
    server 192.168.1.11:8080 weight=2;
    server 192.168.1.12:8080 weight=1;
}
  • 10 号服务器性能最好,权重 3,分到的请求最多

  • 12 号性能最差,权重 1,分到的请求最少

适用场景: 后端服务器性能不一致。


3. IP 哈希(ip_hash)

特点:

  • 按客户端 IP 哈希分配

  • 同一个客户端的请求总是发到同一个服务器

  • 可以保持会话(Session)

配置:

upstream backend {
    ip_hash;
    server 192.168.1.10:8080;
    server 192.168.1.11:8080;
}

优点:

  • 会话保持,同一个用户的请求都到同一台服务器

  • Session 存在服务器上也没问题

缺点:

  • 可能负载不均(某些 IP 段请求多)

  • 服务器上下线会导致 Session 丢失

适用场景: 需要会话保持,且没有分布式 Session 的场景。


4. 最少连接(least_conn)

特点:

  • 把请求发给当前连接数最少的服务器

  • 动态调整,负载更均衡

配置:

upstream backend {
    least_conn;
    server 192.168.1.10:8080;
    server 192.168.1.11:8080;
}

适用场景: 请求处理时间差异大,有的请求快有的慢。


5. URL 哈希(url_hash)

特点:

  • 按 URL 哈希分配

  • 同一个 URL 的请求总是发到同一个服务器

  • 适合缓存场景

配置:

upstream backend {
    hash $request_uri;
    server 192.168.1.10:8080;
    server 192.168.1.11:8080;
}

适用场景: 后端有缓存,同一个 URL 打到同一台,缓存命中率高。


6. 最短响应时间(fair,第三方模块)

特点:

  • 按响应时间分配

  • 响应快的服务器分更多请求

  • 需要第三方模块(ngx_http_upstream_fair_module)

配置:

upstream backend {
    fair;
    server 192.168.1.10:8080;
    server 192.168.1.11:8080;
}

【策略对比】

| 策略 | 分配方式 | 会话保持 | 适用场景 | |------|---------|---------|----------| | 轮询 | 按顺序轮流 | 否 | 服务器性能一致,无状态 | | 加权轮询 | 按权重 | 否 | 服务器性能不一致 | | ip_hash | 按客户端 IP | 是 | 需要会话保持 | | least_conn | 最少连接数 | 否 | 请求处理时间差异大 | | url_hash | 按 URL | 否 | 缓存场景 | | fair | 最短响应时间 | 否 | 性能差异大,需要第三方模块 |


高频追问

追问1:负载均衡有哪几层?LVS 和 Nginx 什么区别?

OSI 分层的负载均衡:

  1. 二层负载均衡(数据链路层)

    • 基于 MAC 地址

    • 修改目标 MAC 地址

    • 代表:LVS 的 DR 模式

  2. 三层负载均衡(网络层)

    • 基于 IP 地址

    • 修改目标 IP

    • 代表:LVS 的 NAT 模式

  3. 四层负载均衡(传输层)

    • 基于 IP + 端口

    • 转发 TCP/UDP 包

    • 代表:LVS、F5、HAProxy

  4. 七层负载均衡(应用层)

    • 基于 HTTP 内容(URL、Header 等)

    • 可以根据内容路由

    • 代表:Nginx、HAProxy

LVS vs Nginx:

| 维度 | LVS | Nginx | |------|-----|-------| | 层级 | 四层 | 七层 | | 性能 | 高(内核态,转发快) | 稍低(用户态,要解析 HTTP) | | 功能 | 简单,只能转发 | 丰富,路由、限流、缓存等 | | 协议 | TCP/UDP | HTTP/HTTPS 等应用层协议 | | 适用 | 高并发、大流量入口 | 应用层路由、反向代理 |

一般架构:

  • 最外层 LVS(四层,高并发转发)

  • 中间 Nginx(七层,路由、负载均衡)

  • 后端应用服务器

追问2:什么是会话保持?有哪些实现方式?

会话保持: 同一个用户的请求始终打到同一台后端服务器。

为什么需要:

  • Session 存在服务器上的话

  • 如果请求打到不同服务器,就找不到 Session 了

  • 所以要会话保持

实现方式:

  1. ip_hash(Nginx)

    • 按客户端 IP 哈希

    • 同一个 IP 总是到同一台

    • 简单,但可能不均

  2. Cookie 粘滞(Sticky Session)

    • 给客户端种一个 Cookie

    • 根据 Cookie 路由

    • 更精确

    • Nginx 需要第三方模块(sticky 模块)

  3. Session 复制

    • 服务器之间同步 Session

    • 哪台都有 Session

    • 不用会话保持也行

    • 但同步有开销

  4. Session 集中存储

    • Session 存在 Redis 等集中存储

    • 所有服务器都从 Redis 取 Session

    • 不用会话保持

    • 推荐方案,微服务架构常用

推荐: 用分布式 Session(Redis),不用会话保持,扩展性更好。

追问3:后端服务器挂了 Nginx 会怎样?怎么配置健康检查?

默认情况:

  • Nginx 不会主动检查后端健康

  • 请求发过去失败了,才知道挂了

  • 会把请求转发给下一个(默认重试)

  • 但会影响用户体验

Nginx 自带的简单检查:

upstream backend {
    server 192.168.1.10:8080 max_fails=3 fail_timeout=30s;
    server 192.168.1.11:8080 max_fails=3 fail_timeout=30s;
}
  • max_fails:失败次数,达到就标记不可用

  • fail_timeout:标记不可用的时间,过了再试

被动健康检查:

  • 失败了才知道

  • 不是主动探测

  • Nginx 自带的就是这种

主动健康检查:

  • 定期主动探测后端健康

  • 挂了就不转发了

  • Nginx 商业版有,开源版没有

  • 开源版可以用第三方模块(ngx_http_healthcheck_module)

  • 或者用其他工具(比如 Keepalived、Consul)

生产环境一般用主动健康检查,更可靠。

追问4:Nginx 怎么做限流?

Nginx 可以做限流,保护后端服务。

常见限流方式:

1. 限制连接数(limit_conn)

# 定义限流 zone
limit_conn_zone $binary_remote_addr zone=addr:10m;

server {
    location / {
        # 每个 IP 最多 10 个连接
        limit_conn addr 10;
        proxy_pass http://backend;
    }
}

2. 限制请求速率(limit_req)

# 定义限流 zone,每秒 10 个请求
limit_req_zone $binary_remote_addr zone=one:10m rate=10r/s;

server {
    location / {
        # 限流,允许突发 5 个
        limit_req zone=one burst=5 nodelay;
        proxy_pass http://backend;
    }
}
  • rate:速率,每秒多少个请求

  • burst:突发数量,允许超过速率的请求排队

  • nodelay:不延迟,超过直接拒绝

3. 限制带宽

location /download/ {
    limit_rate 100k;  # 每个连接 100KB/s
}

限流维度:

  • 按 IP 限流

  • 按服务器限流

  • 按接口限流

限流是保护系统的重要手段,防止被打垮。

追问5:什么是四层和七层负载均衡?怎么选?

四层负载均衡:

  • 在传输层(TCP/UDP)

  • 只看 IP 和端口

  • 不看内容

  • 转发快,性能高

  • 代表:LVS、F5

七层负载均衡:

  • 在应用层(HTTP)

  • 看 URL、Header、Cookie 等内容

  • 可以根据内容做路由

  • 功能丰富,但性能稍低

  • 代表:Nginx、HAProxy

怎么选:

  1. 高并发入口 → 四层(LVS)

    • 流量大,需要高性能转发

    • 四层转发快

  2. 应用层路由 → 七层(Nginx)

    • 需要根据 URL、域名路由

    • 需要做限流、缓存、鉴权等

    • 功能丰富

  3. 一般架构:四层 + 七层结合

    • 最外层 LVS(四层,高并发)

    • 中间 Nginx(七层,路由和负载均衡)

    • 后端应用

    • 兼顾性能和功能

简单说:

  • 只需要转发,要高性能 → 四层

  • 需要根据内容路由,功能多 → 七层

  • 生产环境一般两者结合


67. Git 常用命令及 Git Flow

标准答案

Git 是最常用的版本控制工具,Git Flow 是一种分支管理模型。

【Git 常用命令】

1. 基础操作

# 初始化仓库
git init

# 克隆仓库
git clone <url>
git clone -b <branch> <url>  # 克隆指定分支

# 查看状态
git status

# 查看差异
git diff          # 工作区和暂存区的差异
git diff --cached # 暂存区和版本库的差异
git diff HEAD     # 工作区和版本库的差异

2. 提交

# 添加到暂存区
git add file.txt
git add .         # 添加所有
git add -A        # 添加所有(包括删除的)

# 提交
git commit -m "message"
git commit -am "message"  # add + commit(只对已跟踪的文件)

# 修改最后一次提交
git commit --amend

3. 查看历史

# 查看提交历史
git log
git log --oneline  # 一行显示
git log --graph    # 图形化
git log -p         # 显示差异
git log --stat     # 显示统计

# 查看某次提交详情
git show <commit-id>

4. 分支

# 查看分支
git branch
git branch -a   # 所有分支(包括远程)
git branch -v   # 各分支最新提交

# 创建分支
git branch <branch-name>

# 切换分支
git checkout <branch-name>
git switch <branch-name>  # 新命令,更直观

# 创建并切换
git checkout -b <branch-name>
git switch -c <branch-name>

# 删除分支
git branch -d <branch-name>   # 删除已合并的
git branch -D <branch-name>   # 强制删除

# 合并分支
git merge <branch-name>

# 变基
git rebase <branch-name>

5. 远程仓库

# 查看远程仓库
git remote -v

# 添加远程仓库
git remote add origin <url>

# 推送
git push origin <branch-name>
git push -u origin <branch-name>  # 推送并关联

# 拉取
git pull
git pull origin <branch-name>

# 抓取(不合并)
git fetch
git fetch origin

6. 撤销与回退

# 撤销工作区修改
git checkout -- file.txt
git restore file.txt

# 撤销暂存(从暂存区放回工作区)
git reset HEAD file.txt
git restore --staged file.txt

# 回退版本
git reset --soft HEAD~1   # 回退到上一个版本,修改保留在暂存区
git reset --mixed HEAD~1  # 回退到上一个版本,修改保留在工作区(默认)
git reset --hard HEAD~1   # 回退到上一个版本,修改都丢弃

# 回退到指定版本
git reset --hard <commit-id>

# 反向提交(创建一个新提交来撤销旧提交)
git revert <commit-id>

7. 暂存(Stash)

# 暂存当前修改
git stash
git stash save "message"

# 查看暂存列表
git stash list

# 恢复暂存
git stash pop      # 恢复并删除
git stash apply    # 恢复但不删除

# 删除暂存
git stash drop
git stash clear

8. 标签

# 查看标签
git tag

# 创建标签
git tag v1.0
git tag -a v1.0 -m "version 1.0"  # 带注释

# 推送标签
git push origin v1.0
git push --tags

# 删除标签
git tag -d v1.0
git push origin --delete v1.0

【Git Flow 分支模型】

Git Flow 是一种经典的分支管理模型,定义了不同分支的作用和协作流程。

五种分支:

1. master(主分支)

  • 生产环境代码

  • 随时可以发布

  • 只能合并,不能直接提交

  • 打 tag 标记版本

2. develop(开发分支)

  • 开发主分支

  • 最新的开发代码

  • 所有功能分支都从这里切,合并回这里

3. feature(功能分支)

  • 开发新功能用

  • 从 develop 切出来

  • 开发完合并回 develop

  • 命名:feature/xxx

4. release(发布分支)

  • 发布新版本用

  • 从 develop 切出来

  • 做发布前的测试、修 bug、改版本号

  • 发布完合并回 master 和 develop

  • 命名:release/xxx

5. hotfix(热修复分支)

  • 线上紧急 bug 修复

  • 从 master 切出来

  • 修复完合并回 master 和 develop

  • 命名:hotfix/xxx


Git Flow 流程图:

                    feature 分支
                       ↑↓
master ← release ← develop ← feature
   ↑          ↑
   └── hotfix ─┘

完整流程:

  1. 日常开发在 develop 分支

  2. 开发新功能 → 切 feature 分支 → 开发完合并回 develop

  3. 要发布了 → 从 develop 切 release 分支 → 测试、修 bug → 发布

  4. 发布完 → release 合并到 master(打 tag)和 develop

  5. 线上出 bug → 从 master 切 hotfix 分支 → 修复 → 合并回 master 和 develop


【其他分支模型】

除了 Git Flow,还有其他模型:

  1. GitHub Flow

    • 更简单,只有 master + feature 分支

    • master 随时可发布

    • 功能开发完合并到 master 就发布

    • 适合持续发布的项目

  2. GitLab Flow

    • 结合了 Git Flow 和 GitHub Flow

    • 有环境分支(测试、预发、生产)

    • 向上合并

  3. Trunk Based Development(主干开发)

    • 大家都在主干开发

    • 小步提交,频繁合并

    • 配合特性开关

    • 适合 CI/CD 成熟的团队


高频追问

追问1:git merge 和 git rebase 的区别?

| 维度 | merge | rebase | |------|-------|--------| | 作用 | 合并分支 | 变基,把提交移到另一个分支上 | | 历史 | 保留完整历史,有合并节点 | 历史是线性的,更干净 | | 冲突 | 只解决一次 | 每个提交都可能冲突 | | 安全性 | 安全,不修改历史 | 修改历史,有风险 | | 适用 | 公共分支,保留历史 | 个人分支,整理历史 |

merge 的特点:

  • 会产生一个合并提交

  • 保留完整的分支历史

  • 不会修改已有提交

  • 安全

rebase 的特点:

  • 把当前分支的提交"移"到目标分支后面

  • 历史是一条直线,干净

  • 修改了提交历史

  • 公共分支不要用 rebase

黄金法则:不要在公共分支上 rebase!

  • 你自己的 feature 分支可以 rebase 整理历史

  • 但 master、develop 这种公共分支不要 rebase

  • 因为会修改历史,影响其他人

追问2:git reset 和 git revert 的区别?

| 维度 | reset | revert | |------|-------|--------| | 作用 | 回退到某个版本,丢弃后面的提交 | 创建一个新提交来撤销旧提交 | | 历史 | 修改历史,后面的提交没了 | 不修改历史,新增一个提交 | | 影响 | 后面的提交都没了 | 只撤销指定的提交 | | 适用 | 还没推送到远程的提交 | 已经推送到远程的提交 |

reset:

  • 把指针往回移

  • 后面的提交就没了

  • 适合还没 push 的提交

  • 有三种模式:soft、mixed、hard

revert:

  • 创建一个新的提交,内容和旧提交相反

  • 相当于反向操作

  • 历史是完整的

  • 适合已经 push 到公共分支的提交

  • 不会影响其他人

怎么选:

  • 本地提交,还没 push → reset

  • 已经 push 到远程了 → revert

追问3:什么是 Git 的工作区、暂存区、版本库?

Git 有三个区域:

1. 工作区(Working Directory)

  • 你电脑上的目录

  • 你编辑的文件在这里

  • 修改了但还没 add 的,就在工作区

2. 暂存区(Staging Area / Index)

  • 临时存放要提交的修改

  • git add 之后,修改就到了暂存区

  • 还没 commit

3. 版本库(Repository)

  • .git 目录里的内容

  • git commit 之后,修改就提交到版本库了

  • 正式的历史记录

三者关系:

工作区 --git add--> 暂存区 --git commit--> 版本库

常用命令对应:

  • git add:工作区 → 暂存区

  • git commit:暂存区 → 版本库

  • git diff:工作区 vs 暂存区

  • git diff --cached:暂存区 vs 版本库

  • git diff HEAD:工作区 vs 版本库

  • git checkout -- file:暂存区 → 工作区(撤销工作区修改)

  • git reset HEAD file:版本库 → 暂存区(撤销 add)

理解这三个区,Git 命令就好理解了。

追问4:遇到冲突怎么办?

冲突怎么产生的:

  • 两个分支修改了同一个文件的同一行

  • 合并的时候就会冲突

解决冲突步骤:

  1. 合并/拉取时提示冲突

git merge feature
# 提示 CONFLICT (content): Merge conflict in file.txt
  1. 查看冲突文件

git status
# 看哪些文件冲突了
  1. 手动解决冲突

  • 打开冲突文件

  • 找到 <<<<<<<、=======、>>>>>>> 标记的地方

  • <<<<<<< HEAD 到 ======= 是当前分支的内容

  • ======= 到 >>>>>>> feature 是要合并的分支的内容

  • 手动修改成正确的内容

  • 删除标记

  1. 标记为已解决

git add file.txt
  1. 提交

git commit

冲突解决的原则:

  • 不要随便删别人的代码

  • 找相关开发一起确认

  • 保留两边都需要的内容

  • 解决完测试一下

怎么减少冲突:

  • 小步提交,频繁合并

  • 避免多人改同一个文件

  • 模块化,不同人改不同模块

  • 及时 pull,保持代码最新

追问5:怎么撤销一次已经 push 的提交?

已经 push 到远程的提交,不能用 reset(会修改历史,影响别人),要用 revert。

方法 1:git revert(推荐)

# 撤销指定提交
git revert <commit-id>

# 推送到远程
git push
  • 创建一个新的提交,内容和旧提交相反

  • 历史完整,不影响其他人

  • 推荐

方法 2:git reset + 强制推送(不推荐)

git reset --hard HEAD~1
git push -f
  • 直接回退,强制推送

  • 会修改远程历史

  • 如果其他人已经 pull 了,会出问题

  • 只有确定没人用这个分支的时候才可以用

原则:公共分支用 revert,个人分支可以 reset。

📌 面试技巧:回答 Linux 和 Docker 相关问题时,要体现出你的实操经验。比如讲排查问题就说"我之前排查过一个线上 CPU 100% 的问题,是怎么一步步定位的",讲 Docker 就说"我们项目是怎么用 Docker 和 Compose 部署的"。有实际经验,面试官会更认可。


第九章 Python(1题)


68. Python 基础(语法、虚拟环境、pip、requests、FastAPI 基础)

标准答案

Python 是一门简洁优雅的脚本语言,在 AI、数据科学、Web 开发、自动化运维等领域广泛使用。

【Python 语法基础】

1. 变量与数据类型

# 变量不用声明类型,动态类型
name = "Alice"      # 字符串
age = 25            # 整数
height = 1.68       # 浮点数
is_student = True   # 布尔值

基本数据类型:

  • 数字:int、float、complex

  • 字符串:str

  • 布尔值:bool

  • 列表:list(有序,可变)

  • 元组:tuple(有序,不可变)

  • 字典:dict(键值对)

  • 集合:set(无序,不重复)

2. 列表(List)

# 列表
fruits = ["apple", "banana", "orange"]

# 索引
print(fruits[0])    # apple
print(fruits[-1])   # orange(倒数第一个)

# 切片
print(fruits[1:3])  # ['banana', 'orange']

# 常用方法
fruits.append("grape")      # 追加
fruits.insert(1, "pear")    # 插入
fruits.remove("banana")     # 删除
fruits.pop()                # 弹出最后一个
len(fruits)                 # 长度
fruits.sort()               # 排序

3. 字典(Dict)

# 字典
person = {
    "name": "Alice",
    "age": 25,
    "city": "Beijing"
}

# 访问
print(person["name"])       # Alice
print(person.get("age"))    # 25
print(person.get("gender", "unknown"))  # 默认值

# 常用操作
person["email"] = "alice@example.com"  # 添加/修改
del person["city"]                     # 删除
person.keys()            # 所有键
person.values()          # 所有值
person.items()           # 所有键值对

4. 条件与循环

# if 条件
age = 18
if age >= 18:
    print("成年人")
elif age >= 12:
    print("青少年")
else:
    print("儿童")

# for 循环
for i in range(5):
    print(i)  # 0,1,2,3,4

for fruit in fruits:
    print(fruit)

for key, value in person.items():
    print(key, value)

# while 循环
count = 0
while count < 5:
    print(count)
    count += 1

5. 函数

# 定义函数
def greet(name, greeting="Hello"):
    return f"{greeting}, {name}!"

# 调用
print(greet("Alice"))              # Hello, Alice!
print(greet("Bob", "Hi"))          # Hi, Bob!

# 可变参数
def sum(*args):
    total = 0
    for num in args:
        total += num
    return total

# 关键字参数
def print_info(**kwargs):
    for key, value in kwargs.items():
        print(f"{key}: {value}")

6. 类与对象

class Person:
    # 构造方法
    def __init__(self, name, age):
        self.name = name
        self.age = age
    
    # 实例方法
    def say_hello(self):
        print(f"Hello, I'm {self.name}")
    
    # 类方法
    @classmethod
    def from_birth_year(cls, name, birth_year):
        age = 2024 - birth_year
        return cls(name, age)

# 使用
p = Person("Alice", 25)
p.say_hello()

p2 = Person.from_birth_year("Bob", 1998)

7. 异常处理

try:
    result = 10 / 0
except ZeroDivisionError:
    print("不能除以零")
except Exception as e:
    print(f"其他错误: {e}")
else:
    print("没有错误")
finally:
    print("总会执行")

8. 列表推导式

# 普通写法
squares = []
for i in range(10):
    squares.append(i * i)

# 列表推导式
squares = [i * i for i in range(10)]

# 带条件
even_squares = [i * i for i in range(10) if i % 2 == 0]

# 字典推导式
square_dict = {i: i * i for i in range(10)}

【虚拟环境】

什么是虚拟环境:

  • 隔离不同项目的 Python 环境

  • 每个项目有自己的依赖包

  • 不同项目的包版本互不影响

  • 避免版本冲突

为什么需要虚拟环境:

  • 项目 A 需要 requests 2.0,项目 B 需要 requests 3.0

  • 不用虚拟环境就会冲突

  • 每个项目一个虚拟环境,互不干扰

venv(Python 内置)

# 创建虚拟环境
python -m venv venv

# 激活虚拟环境
# Windows:
venv\Scripts\activate
# Linux/Mac:
source venv/bin/activate

# 退出虚拟环境
deactivate

virtualenv(第三方,更强大)

# 安装
pip install virtualenv

# 创建
virtualenv venv

# 激活(同上)

conda(Anaconda/Miniconda)

# 创建环境
conda create -n myenv python=3.9

# 激活
conda activate myenv

# 退出
conda deactivate

# 查看环境列表
conda env list

# 删除环境
conda remove -n myenv --all

最佳实践:

  • 每个项目一个虚拟环境

  • 把依赖写到 requirements.txt

  • 方便别人复现环境


【pip 包管理】

pip 是 Python 的包管理工具,用来安装第三方库。

常用命令:

# 安装包
pip install requests
pip install requests==2.28.0    # 指定版本
pip install requests>=2.28.0    # 最低版本

# 安装 requirements.txt 里的所有包
pip install -r requirements.txt

# 升级包
pip install --upgrade requests

# 卸载包
pip uninstall requests

# 查看已安装的包
pip list
pip freeze

# 导出依赖到 requirements.txt
pip freeze > requirements.txt

# 查看包信息
pip show requests

# 搜索包
pip search requests  # 新版本不支持了,去 PyPI 网站搜

# 配置国内镜像源(加速)
pip install requests -i https://pypi.tuna.tsinghua.edu.cn/simple

requirements.txt 示例:

requests==2.28.1
fastapi==0.95.0
uvicorn==0.22.0
pandas==2.0.0
numpy==1.24.0

常用镜像源:

  • 清华:https://pypi.tuna.tsinghua.edu.cn/simple

  • 阿里云:https://mirrors.aliyun.com/pypi/simple/

  • 豆瓣:https://pypi.douban.com/simple/


【requests 库】

requests 是 Python 最常用的 HTTP 请求库,简单易用。

安装:

pip install requests

基本用法:

import requests

# GET 请求
response = requests.get("https://api.example.com/users")

# POST 请求
data = {"name": "Alice", "age": 25}
response = requests.post("https://api.example.com/users", json=data)

# 响应处理
print(response.status_code)   # 状态码
print(response.json())        # JSON 响应
print(response.text)          # 文本响应
print(response.headers)       # 响应头

GET 请求带参数:

params = {"page": 1, "size": 10}
response = requests.get("https://api.example.com/users", params=params)
# URL 变成 https://api.example.com/users?page=1&size=10

POST 不同数据格式:

# JSON 数据
response = requests.post(url, json={"key": "value"})

# 表单数据
response = requests.post(url, data={"key": "value"})

# 文件上传
files = {"file": open("file.txt", "rb")}
response = requests.post(url, files=files)

请求头:

headers = {
    "Authorization": "Bearer token123",
    "User-Agent": "MyApp/1.0"
}
response = requests.get(url, headers=headers)

超时与异常:

try:
    response = requests.get(url, timeout=5)  # 5秒超时
    response.raise_for_status()  # 状态码不是 2xx 抛出异常
except requests.exceptions.Timeout:
    print("请求超时")
except requests.exceptions.HTTPError as e:
    print(f"HTTP 错误: {e}")
except requests.exceptions.RequestException as e:
    print(f"请求错误: {e}")

Session(保持会话):

session = requests.Session()
session.headers.update({"Authorization": "Bearer token"})

# 同一个 session 的请求共享 cookie、header 等
response1 = session.get("https://api.example.com/user")
response2 = session.get("https://api.example.com/orders")

【FastAPI 基础】

FastAPI 是一个现代、高性能的 Python Web 框架,基于类型提示,自动生成 API 文档。

特点:

  • 高性能(接近 Node.js 和 Go)

  • 基于标准 Python 类型提示

  • 自动生成 API 文档(Swagger UI / ReDoc)

  • 数据验证(Pydantic)

  • 异步支持

安装:

pip install fastapi uvicorn

基本示例:

from fastapi import FastAPI

app = FastAPI()

# GET 请求
@app.get("/")
def read_root():
    return {"message": "Hello World"}

# 路径参数
@app.get("/items/{item_id}")
def read_item(item_id: int, q: str = None):
    return {"item_id": item_id, "q": q}

运行:

uvicorn main:app --reload
# --reload 开发时自动重启

访问文档:

  • Swagger UI:http://localhost:8000/docs

  • ReDoc:http://localhost:8000/redoc

请求体(Pydantic 模型):

from pydantic import BaseModel
from typing import Optional

class Item(BaseModel):
    name: str
    price: float
    description: Optional[str] = None
    tax: Optional[float] = None

@app.post("/items/")
def create_item(item: Item):
    return item

查询参数与验证:

from fastapi import Query

@app.get("/items/")
def read_items(
    page: int = 1,
    size: int = Query(10, ge=1, le=100),  # 限制范围
    keyword: str = Query(None, min_length=3)
):
    return {"page": page, "size": size, "keyword": keyword}

响应模型:

class UserResponse(BaseModel):
    id: int
    name: str
    email: str

@app.get("/users/{user_id}", response_model=UserResponse)
def get_user(user_id: int):
    # 返回的字典会自动转成 UserResponse
    # 多余的字段会被过滤掉
    return {"id": user_id, "name": "Alice", "email": "alice@example.com", "password": "123456"}

状态码与响应头:

from fastapi import Response, status

@app.post("/items/", status_code=status.HTTP_201_CREATED)
def create_item(item: Item, response: Response):
    response.headers["X-Custom-Header"] = "value"
    return item

依赖注入:

from fastapi import Depends

def get_db():
    db = Database()
    try:
        yield db
    finally:
        db.close()

@app.get("/items/")
def read_items(db = Depends(get_db)):
    return db.query_items()

异步支持:

@app.get("/items/{item_id}")
async def read_item(item_id: int):
    # 异步操作,比如异步数据库查询
    result = await some_async_function()
    return result

高频追问

追问1:Python 是解释型语言还是编译型语言?

Python 是解释型语言,但有编译过程。

执行过程:

  1. 源代码(.py)→ 编译 → 字节码(.pyc)

  2. 字节码 → Python 解释器(虚拟机)→ 执行

  3. 解释器逐行解释执行字节码

为什么说它是解释型:

  • 不需要提前编译成机器码

  • 运行时由解释器执行

  • 但为了提高速度,会把源码编译成字节码

  • 字节码是中间形式,不是机器码

和 Java 的区别:

  • Java 也是编译成字节码,然后 JVM 执行

  • 但 Java 是先编译再运行,编译是显式的

  • Python 的编译是隐式的,运行时自动做

  • 本质上都是"编译成字节码 + 虚拟机执行"

Python 的实现:

  • CPython:C 语言实现的,最常用

  • PyPy:JIT 编译,性能更好

  • Jython:运行在 JVM 上

  • IronPython:运行在 .NET 上

追问2:Python 的 GIL 是什么?有什么影响?

GIL(Global Interpreter Lock,全局解释器锁)是 CPython 的一个机制。

什么是 GIL:

  • 同一时刻,只有一个线程能执行 Python 字节码

  • 即使在多核 CPU 上,多线程也不能真正并行执行

  • 因为有一把全局锁

为什么有 GIL:

  • Python 的内存管理不是线程安全的

  • 用 GIL 保证线程安全

  • 实现简单

影响:

  • CPU 密集型任务:多线程不能提高性能,因为 GIL 限制了并行

  • IO 密集型任务:多线程可以提高性能,因为 IO 等待时会释放 GIL

怎么绕过 GIL:

  1. 多进程

    • 每个进程有自己的 GIL

    • 可以真正并行

    • 用 multiprocessing 模块

    • 适合 CPU 密集型任务

  2. 用 C 扩展

    • C 扩展可以释放 GIL

    • 比如 numpy、pandas 底层是 C,能并行

  3. 用 PyPy

    • PyPy 有更好的 GIL 实现

    • 性能更好

  4. 用协程(asyncio)

    • 单线程并发

    • 适合 IO 密集型

    • 不是并行,是并发

总结:

  • CPU 密集型 → 多进程

  • IO 密集型 → 多线程或协程

  • GIL 是 CPython 的特点,不是 Python 语言的

追问3:Python 中的 list 和 tuple 有什么区别?

| 维度 | list(列表) | tuple(元组) | |------|-------------|--------------| | 可变性 | 可变(可以增删改) | 不可变(创建后不能改) | | 语法 | 方括号 [] | 圆括号 () | | 性能 | 稍慢 | 稍快 | | 内存 | 占用稍多 | 占用少 | | 哈希 | 不可哈希(不能当字典的 key) | 可哈希(可以当字典的 key) | | 方法 | 方法多(append、pop 等) | 方法少(count、index) | | 适用场景 | 需要修改的数据 | 不需要修改的数据、返回多个值 |

举例:

# list
my_list = [1, 2, 3]
my_list.append(4)  # 可以修改

# tuple
my_tuple = (1, 2, 3)
my_tuple[0] = 10   # 报错,不能修改

什么时候用 tuple:

  • 数据不应该被修改时

  • 作为字典的 key(list 不行)

  • 函数返回多个值时(其实返回的就是 tuple)

  • 解包赋值

# 函数返回多个值,其实是 tuple
def get_user():
    return "Alice", 25

name, age = get_user()  # 解包

追问4:FastAPI 和 Flask 有什么区别?

| 维度 | FastAPI | Flask | |------|---------|-------| | 发布时间 | 较新(2018) | 较早(2010) | | 性能 | 高(异步、基于 Starlette) | 一般 | | 类型提示 | 原生支持,基于类型提示 | 不支持 | | 自动文档 | 自动生成 Swagger/ReDoc | 需要插件(flask-restx 等) | | 数据验证 | 内置(Pydantic) | 需要自己实现或用插件 | | 异步 | 原生支持异步 | 2.0 开始支持,但生态不如 FastAPI | | 生态 | 较新,生态在发展中 | 成熟,生态丰富 | | 学习曲线 | 稍陡(类型提示、Pydantic) | 简单,上手快 | | 适用场景 | API 服务、高性能、现代 Python | 简单 Web 应用、传统项目 |

简单说:

  • FastAPI:现代、高性能、自动文档、类型提示,适合做 API 服务

  • Flask:轻量、简单、生态成熟,适合小项目和传统 Web

FastAPI 是现在 Python Web 开发的热门选择,特别是做 API 服务。

追问5:Python 怎么处理并发?有几种方式?

Python 并发的几种方式:

1. 多线程(threading)

  • 同一进程内的多个线程

  • 受 GIL 限制,CPU 密集型不能真正并行

  • 适合 IO 密集型(网络、文件 IO)

  • 线程切换开销小

import threading

def task():
    print("task")

t = threading.Thread(target=task)
t.start()
t.join()

2. 多进程(multiprocessing)

  • 多个进程,每个进程有自己的 GIL

  • 可以真正并行,利用多核

  • 适合 CPU 密集型

  • 进程切换开销大,进程间通信复杂

import multiprocessing

def task():
    print("task")

p = multiprocessing.Process(target=task)
p.start()
p.join()

3. 协程(asyncio)

  • 单线程内的并发

  • 用户态切换,开销极小

  • 适合 IO 密集型

  • 需要异步库支持

import asyncio

async def task():
    print("task")
    await asyncio.sleep(1)

async def main():
    await asyncio.gather(task(), task())

asyncio.run(main())

对比:

| 方式 | 并发/并行 | 适用场景 | 开销 | GIL 影响 | |------|----------|---------|------|---------| | 多线程 | 并发 | IO 密集型 | 小 | 有 | | 多进程 | 并行 | CPU 密集型 | 大 | 无 | | 协程 | 并发 | IO 密集型(高并发) | 极小 | 无(单线程) |

怎么选:

  • CPU 密集型 → 多进程

  • IO 密集型,并发量不大 → 多线程

  • IO 密集型,高并发 → 协程(asyncio)

📌 面试技巧:回答 Python 相关问题时,要结合你的实际使用场景。比如你用 Python 做过什么项目(AI 应用、爬虫、脚本、API 服务等),用了哪些库,遇到过什么问题怎么解决的。有实际经验比纯背知识点更有说服力。


恭喜!全部 68 道题已完成!🎉

涵盖 9 大模块:

  1. JVM(8题)

  2. Java 并发(10题)

  3. Spring(10题)

  4. MySQL(8题)

  5. Redis(8题)

  6. 消息队列(10题)

  7. 微服务(5题)

  8. Linux & Docker(8题)

  9. Python(1题)

每道题都包含:

  • ✅ 结构化标准答案

  • ✅ 5 个高频追问及详细答案

  • ✅ 面试技巧提示

祝你面试顺利,拿到心仪的 Offer!💪