Java 大厂高频面试题(高并发专题·深度版)
本文档面向大厂求职复习,每道题按「考点定位 → 解题思路 → 参考答案 → 生产问题与排查 → 高频追问详答」五段式展开。重点章节为高并发场景设计,结合真实生产故障案例讲解。建议先理解设计动机再记结论,能讲清「为什么这么设计」和「出了问题怎么排查」才是大厂区分候选人的关键。
一、并发基础
1. 说一下线程的生命周期及状态转换
考点定位: Thread 状态机、操作系统线程状态与 JVM 状态的映射、阻塞与等待的区分。
解题思路: 先回答 Java 层六种状态,再讲清楚操作系统层面的五状态模型与 JVM 状态的对应关系,重点区分 BLOCKED 和 WAITING 的本质差异——这是面试官判断你是否真正理解同步机制的关键。
参考答案:
Java 线程六种状态定义在 Thread.State 枚举中:
| 状态 | 进入条件 | 退出条件 |
|---|---|---|
| NEW | new Thread() 创建,未 start | 调用 start() |
| RUNNABLE | start() 后,包括就绪和运行中 | 等待锁/调用 wait/sleep 等 |
| BLOCKED | 等待 synchronized monitor 锁 | 获取到锁 |
| WAITING | 调用无超时的 wait/join/LockSupport.park | 被 notify/unpark/interrupt |
| TIMED_WAITING | 调用带超时的 sleep(ms)/wait(ms)/join(ms) | 超时或被唤醒 |
| TERMINATED | run 方法正常结束或抛未捕获异常 | 不可恢复 |
关键区分:JVM 把操作系统的「就绪」和「运行中」合并为 RUNNABLE,因为 JVM 不负责 CPU 调度。synchronized 等锁时进入 BLOCKED,而 ReentrantLock.lock() 等待时进入 WAITING——因为 ReentrantLock 基于 AQS 的 LockSupport.park,不经过 monitor 机制,所以不是 BLOCKED。
生产问题与排查:
线上最常见的问题是用 jstack 排查线程卡住时,看到大量线程处于 BLOCKED,需要区分是正常的锁竞争还是死锁。
- 用
jstack <pid>抓取线程栈,关注java.lang.Thread.State字段。 - BLOCKED 状态的线程栈会显示
- waiting to lock <0x000000076b8a2d80>,多个线程等同一个地址基本是锁竞争激烈。 - 如果线程栈末尾显示
Found one Java-level deadlock,说明检测到死锁,jstack 会打印死锁链。 - WAITING 线程如果栈在
parking to wait for且在ThreadPoolExecutor.getTask,那只是线程池空闲线程在等待任务,属于正常现象,不要误判为故障。 - 生产中曾出现线程池核心线程全处于 WAITING 在
Object.wait,表面看像空闲,实际是下游 RPC 超时设置过长导致任务堆积在队列中,需要结合队列大小和任务执行时间判断。
高频追问详答:
Q1:sleep() 和 wait() 的区别?
从四个维度对比:第一,锁释放——sleep 不释放任何锁(包括 synchronized 持有的锁),wait 会释放持有的 monitor 锁。第二,使用位置——sleep 是 Thread 的静态方法,可在任意位置调用;wait 是 Object 的实例方法,必须在 synchronized 块内调用,否则抛 IllegalMonitorStateException。第三,唤醒方式——sleep 超时自动唤醒或被 interrupt 中断;wait 需要其他线程调用 notify/notifyAll 或超时。第四,所属——sleep 是线程控制方法,wait 是线程间通信机制。生产中常见的坑:在 synchronized 块内调用 sleep 持有锁不放,导致其他线程长时间拿不到锁,应该用 wait 释放锁。
Q2:start() 能调用两次吗?
不能。Thread 内部维护一个 threadStatus 字段,start() 会检查该状态,如果不是 0(NEW 状态)就抛 IllegalThreadStateException。调用一次后状态变为 RUNNABLE,再次调用必抛异常。正确做法是每次需要新线程时 new 一个 Thread 对象,或用线程池复用线程。
Q3:BLOCKED 和 WAITING 的本质区别?
BLOCKED 是 JVM 层面的概念,专指等待 synchronized monitor 锁的线程。它只能由获取锁来唤醒,JVM 自动管理,不需要其他线程显式 notify。WAITING 是更广泛的等待状态,包括 wait/join/park 等多种场景,需要其他线程显式唤醒。从实现看,BLOCKED 走 monitor 的 entry list(C++ ObjectMonitor 中的 _EntryList),WAITING 走 wait set 或 AQS 的等待队列。
2. ThreadLocal 原理及内存泄漏问题
考点定位: ThreadLocalMap 结构、弱引用 Key 的设计动机、内存泄漏的完整引用链、线程池场景下的特殊风险。
解题思路: 先画清楚引用链(Thread → ThreadLocalMap → Entry → Key/Value),再解释为什么 Key 用弱引用但 Value 仍是强引用导致泄漏,最后给出线程池场景下的典型故障和修复方法。
参考答案:
每个 Thread 对象持有一个 ThreadLocalMap 实例(字段名 threadLocals)。ThreadLocalMap 的 Entry 继承 WeakReference<ThreadLocal<?>>:
Entry extends WeakReference<ThreadLocal<?>> {
Object value; // 强引用
Entry(ThreadLocal<?> k, Object v) {
super(k); // Key 是弱引用
value = v; // Value 是强引用
}
}
完整引用链:
Thread (强) → ThreadLocalMap (强) → Entry (强)
→ Key: WeakReference → ThreadLocal (弱)
→ Value: 直接字段引用 (强)
当 ThreadLocal 外部强引用被置为 null 后,下次 GC 时 Key 被 WeakReference 回收变为 null。但 Value 仍被 Entry.value 强引用,而 Entry 又被 ThreadLocalMap 强引用,ThreadLocalMap 被 Thread 强引用。只要线程不死,Value 就无法回收——这就是内存泄漏。
生产问题与排查:
在 Web 容器(Tomcat)和线程池场景下,这个问题尤其严重。因为 Tomcat 的线程池线程是复用的,线程生命周期等于容器生命周期(可能几周不停)。如果业务代码用 ThreadLocal 存储了大对象(如用户 Session、数据库连接、大 List)却忘记 remove,会导致堆内存持续增长最终 OOM。
真实案例:某系统用 ThreadLocal 存储用户请求上下文(含大 JSON 对象约 2MB),线程池 200 个线程。每次请求设置但不 remove,运行两天后堆内存增长到 8GB,Full GC 频繁但无法回收,最终 OOM。
排查方法:
- 用
jmap -dump:format=b,file=heap.hprof <pid>导出堆。 - 用 MAT(Memory Analyzer Tool)分析,查看
ThreadLocal相关对象的 Retained Size。 - 用 MAT 的「Leak Suspects」报告,会指出
java.lang.ThreadLocal$ThreadLocalMap占用了大量内存。 - 查看具体 Entry 的 value 引用什么对象,定位是哪个业务模块的 ThreadLocal 没清理。
修复方案:
- 用完务必在 finally 中 remove:
threadLocal.remove()。 - 框架层面用拦截器/Filter 在请求结束时自动清理。
- 用
try-finally模式封装。
高频追问详答:
Q1:为什么 Key 用弱引用而不是强引用?
如果 Key 是强引用,那么只要线程还活着,ThreadLocalMap 就强引用 ThreadLocal 对象。即使业务代码把 ThreadLocal 变量置为 null,ThreadLocal 对象也无法被 GC 回收——因为 Thread → ThreadLocalMap → Entry → Key 这条强引用链还在。这会导致 ThreadLocal 对象本身泄漏。用弱引用后,业务代码释放 ThreadLocal 引用后,Key 可以被 GC 回收。这是「两害相权取其轻」的设计——弱引用解决了 ThreadLocal 对象本身的泄漏,但引入了 Value 的泄漏(Key 被回收后 Value 变成孤儿)。ThreadLocal 的设计者认为 Value 泄漏可以通过 remove() 规避,而 ThreadLocal 对象泄漏更难发现,所以选了弱引用。
Q2:父子线程如何传递 ThreadLocal?
默认不传递。InheritableThreadLocal 可以实现:子线程创建时(Thread.init 方法中),如果父线程有 InheritableThreadLocal 的值,会调用 childValue() 拷贝一份到子线程的 inheritableThreadLocals。但这是「创建时拷贝」,子线程后续修改不会同步回父线程。更关键的问题是线程池场景下失效:线程池的线程是复用的,提交任务时不会重新创建线程,也就不会触发拷贝。阿里开源的 TransmittableThreadLocal(TTL)解决这个问题:它通过 Agent 字节码增强或 TtlRunnable.get(runnable) 包装,在任务提交时快照当前线程的 TTL 值,任务执行时恢复到工作线程,执行完后清理。这是全链路追踪(如 SkyWalking、EagleEye 传递 traceId)的常用方案。
Q3:ThreadLocal 和 synchronized 的区别?
本质上是两种完全不同的思路。synchronized 是「时间换空间」——多个线程共享同一份数据,通过互斥访问保证安全,优点是节省内存,缺点是竞争开销大。ThreadLocal 是「空间换时间」——每个线程拥有自己独立的副本,互不干扰,优点是无竞争无锁开销,缺点是多占内存。适用场景:synchronized 适合共享资源较重、冲突不激烈的场景;ThreadLocal 适合每个线程需要独立状态且状态不需要线程间共享的场景,如数据库连接、SimpleDateFormat(非线程安全)、用户上下文传递。
3. volatile 关键字的作用和底层原理
考点定位: 可见性、有序性的实现、内存屏障的四种类型、MESI 缓存一致性协议、不保证原子性的原因。
解题思路: 从 JMM 层面讲两大作用(可见性 + 有序性),再深入到 CPU 层面的 lock 指令和 MESI 协议,最后解释为什么不保证原子性以及 DCL 单例的指令重排问题。
参考答案:
volatile 有两大语义作用:
作用一:可见性。 JMM 规定 volatile 变量的写操作会立即刷新到主内存,读操作会强制从主内存重新加载。底层实现上,volatile 写会生成带 lock 前缀的汇编指令(x86 架构上是 lock addl $0,0,(%rsp)),该指令做两件事:一是将当前 CPU 缓存行的数据写回主内存,二是通过 MESI 缓存一致性协议使其他 CPU 中缓存了该变量所在缓存行的数据失效。其他 CPU 读取时发现缓存行已失效,就会从主内存重新加载最新值。
作用二:有序性(禁止指令重排)。 JMM 通过插入内存屏障实现。volatile 写之前插入 StoreStore 屏障(禁止前面的普通写与 volatile 写重排),写之后插入 StoreLoad 屏障(禁止 volatile 写与后面的读/写重排);volatile 读之前插入 LoadLoad 屏障(禁止后面的普通读与 volatile 读重排),读之后插入 LoadStore 屏障(禁止后面的普通写与 volatile 读重排)。其中 StoreLoad 屏障开销最大,因为它要等待所有写操作完成并刷新到主内存。
volatile 不保证原子性:以 i++ 为例,它实际上分三步:读取 i 的值(主内存到工作内存)、加 1(工作内存中修改)、写回(工作内存写回主内存)。volatile 只保证每次读都看到最新值,但不能保证这三步是原子的。线程 A 读到 i=0,线程 B 也读到 i=0(因为 A 还没写回),各自加 1 写回 1,最终结果是 1 而不是 2。
生产问题与排查:
最常见的生产问题是 DCL(双重检查锁定)单例忘记加 volatile 导致偶发 NPE。现象:系统运行大部分时间正常,偶尔在启动或高并发初始化时抛 NullPointerException,而且很难复现。
根因:new Object() 在字节码层面分三步:①分配内存空间、②调用构造方法初始化对象、③将引用指向分配的内存地址。JIT 编译器可能将②③重排为「分配内存→赋值引用→初始化对象」。线程 A 执行到③但还没执行②时,线程 B 检查引用不为 null,直接返回了一个半初始化的对象,访问其字段就是 null。
排查方法:
- 如果单例类的构造方法中有日志,会在 NPE 时发现构造方法日志还没打出来但对象已被返回。
- 用
java -XX:+PrintAssembly打印汇编可以看到是否插入了 lock 指令。 - 最直接的修复:给实例变量加 volatile。
public class Singleton {
private static volatile Singleton instance; // 必须 volatile
private Singleton() {}
public static Singleton getInstance() {
if (instance == null) { // 第一次检查,无锁快速路径
synchronized (Singleton.class) {
if (instance == null) { // 第二次检查,防止重复创建
instance = new Singleton();
}
}
}
return instance;
}
}
高频追问详答:
Q1:DCL 单例为什么需要 volatile?
上面已详细解释。补充:有人认为 synchronized 已经保证了可见性,为什么还需要 volatile?因为 synchronized 保证的是「释放锁时把变量写回主内存」「获取锁时从主内存重新读取」,但在第一个 if (instance == null) 判断时并没有获取锁,这个无锁读取不在 synchronized 的保障范围内。可能读到其他线程已赋值但尚未初始化完成的对象。volatile 禁止 new 操作的指令重排,确保对象完全构造好后其他线程才能看到。
Q2:volatile 和 synchronized 的区别?
volatile 是轻量级的同步机制,只保证可见性和有序性,不保证原子性,不会导致线程阻塞。synchronized 是重量级同步机制,保证可见性、有序性和原子性,获取不到锁的线程会阻塞。性能上 volatile 远优于 synchronized(不涉及线程切换),但功能有限。选择标准:如果只需要保证一个 boolean 标志位的可见性,用 volatile;如果需要保证复合操作的原子性或临界区互斥,用 synchronized。
4. synchronized 的底层原理和锁升级过程
考点定位: 对象头 Mark Word 结构、monitor 的 C++ 实现、偏向锁到重量级锁的完整升级链路、JDK 15 关闭偏向锁的影响。
解题思路: 从对象头结构切入,讲清楚 Mark Word 在不同锁状态下存储不同信息,然后按升级顺序逐一讲解每种锁的加锁/解锁流程和升级条件,最后讲锁消除和锁粗化的 JIT 优化。
参考答案:
synchronized 同步代码块通过 monitorenter 和 monitorexit 两条字节码指令实现(编译器会自动在异常处插入 monitorexit 保证锁释放);同步方法在方法表的 access_flags 中设置 ACC_SYNCHRONIZED 标志,JVM 调用方法时检查该标志自动加锁。两者底层都依赖对象的 monitor(C++ 的 ObjectMonitor 实现)。
锁升级基于对象头 Mark Word(64 位 JVM 下占 64 bit)中记录的锁标志位:
第一级:无锁。 初始状态,Mark Word 存储 hashCode(25 位)、分代年龄(4 位)、是否偏向(1 位)、锁标志位(2 位,01)。
第二级:偏向锁。 第一个线程访问时,JVM 通过 CAS 将线程 ID 写入 Mark Word。此后该线程再次进入同步块,只需检查 Mark Word 中的线程 ID 是否是自己,是就直接进入,没有 CAS 也没有自旋,性能极高。设计动机:实际场景中很多锁不仅没有多线程竞争,而且总是由同一个线程获得(如 StringBuffer 在单线程中使用),偏向锁优化这种场景。JDK 15 开始默认关闭偏向锁(-XX:-UseBiasedLocking),因为维护成本高(需要 safepoint 撤销)且现代 CAS 已经足够快。
第三级:轻量级锁。 当出现第二个线程尝试获取锁(竞争),偏向锁撤销升级为轻量级锁。加锁流程:线程在栈帧中创建 Lock Record,把 Mark Word 拷贝到 Lock Record(Displaced Mark Word),然后 CAS 尝试把 Mark Word 指向自己的 Lock Record。成功则获得锁,失败则自旋重试。解锁流程:CAS 把 Displaced Mark Word 替换回 Mark Word,成功则释放,如果有竞争则膨胀为重量级锁。设计动机:大多数锁持有时间很短,自旋等待比阻塞切换划算。
第四级:重量级锁。 当自旋超过阈值(默认 10 次或自适应自旋失败)或有第三个以上线程竞争时,升级为重量级锁。Mark Word 指向 ObjectMonitor 对象(C++ 实现,包含 _owner、_EntryList、_WaitSet、_count 等字段)。未获取锁的线程进入 _EntryList 阻塞(调用 pthread_mutex_lock,进入内核态),持有锁的线程调用 wait 则进入 _WaitSet。设计动机:竞争激烈时自旋浪费 CPU,不如让出 CPU 给其他线程用。
锁升级是单向的,不可降级。但有特例:GC 的 safepoint 点可以批量撤销偏向锁或重偏向(同一类的对象撤销偏向达到阈值 20 次后,该类所有对象触发批量重偏向到新的线程)。
锁消除: JIT 编译时的逃逸分析,如果判断锁对象不可能被其他线程访问(如方法内局部创建的 StringBuffer),直接消除锁操作。
锁粗化: 循环内反复对同一对象加锁解锁,JIT 把多次锁操作合并为一次粗粒度的锁。
生产问题与排查:
线上偶发性能问题:某接口 RT(响应时间)在高峰期从 50ms 飙升到 2s,CPU 使用率不高但 GC 频繁。排查发现是 synchronized 锁竞争激烈导致大量线程 BLOCKED。
排查方法:
jstack抓取线程栈,统计 BLOCKED 线程数量。如果几十个线程都- waiting to lock <同一地址>,说明该锁是热点。- 用
arthas的thread -b命令直接找出阻塞其他线程最多的线程(即持锁时间最长的线程)。 - 用
arthas的watch或trace命令定位持锁方法耗时。 - 修复方案:缩小锁粒度(从锁方法改为锁代码块)、分段锁(ConcurrentHashMap 思路)、读写锁分离(读多写少用 ReentrantReadWriteLock)、CAS 替代锁(Atomic 类)。
高频追问详答:
Q1:synchronized 和 ReentrantLock 的区别?
| 维度 | synchronized | ReentrantLock |
|---|---|---|
| 实现 | JVM 关键字(monitorenter/exit) | JDK 类(基于 AQS) |
| 锁释放 | 自动(异常或代码块结束自动释放) | 手动(必须 finally 中 unlock,否则死锁) |
| 可中断 | 不可中断(等锁时不响应 interrupt) | lockInterruptibly() 可响应中断 |
| 公平性 | 只能非公平 | 可选公平/非公平 |
| 条件变量 | 一个 wait/notify 等待队列 | 可创建多个 Condition,精确分组唤醒 |
| 超时获取 | 不支持 | tryLock(timeout) 支持 |
| 锁绑定 | 绑定对象(锁的是对象头) | 绑定 Lock 对象 |
| 是否可重入 | 可重入 | 可重入 |
选择建议:优先用 synchronized——简单、不会因忘记 unlock 导致死锁、JVM 持续优化(偏向锁、轻量级锁、锁消除、锁粗化)。需要可中断、超时获取、公平锁、多 Condition 时用 ReentrantLock。Java 6 以后 synchronized 性能已不输 ReentrantLock。
Q2:自旋锁的优缺点?
优点:避免线程阻塞和唤醒的内核态切换开销(约 1-10 微秒),适合锁持有时间极短(几十纳秒到几微秒)的场景。缺点:自旋期间持续占用 CPU,如果锁持有时间长或竞争线程多,大量线程自旋会浪费大量 CPU 资源。JVM 用「自适应自旋」优化:根据上次自旋的成功率动态调整——上次自旋成功则增加自旋次数,失败则减少甚至取消自旋。
Q3:锁消除和锁粗化?
锁消除举例:方法内 StringBuffer.append 是 synchronized 方法,但 StringBuffer 是局部变量不会逃逸,JIT 直接消除锁。锁粗化举例:for (int i=0; i<1000; i++) { synchronized(lock) { list.add(i); } } 循环内反复加锁解锁,JIT 合并为 synchronized(lock) { for(...) { list.add(i); } } 一次加锁。
二、JUC 核心
5. AQS 原理详解
考点定位: CLH 队列变体、state 语义、独占与共享模式、模板方法模式、CAS 修改 state、条件队列。
解题思路: 先讲 AQS 的整体架构(state + CLH 队列 + 模板方法),再分别讲独占模式和共享模式的获取/释放流程,最后讲条件变量 ConditionObject 的工作原理。结合 ReentrantLock 和 Semaphore 的源码说明。
参考答案:
AQS(AbstractQueuedSynchronizer)是 JUC 同步器的基石框架,ReentrantLock、Semaphore、CountDownLatch、ReentrantReadWriteLock 都基于它。核心设计三要素:
要素一:state(volatile int)。 不同同步器赋予不同语义。ReentrantLock 中 state 表示锁被重入的次数(0 未锁,2 重入 1 次);Semaphore 中 state 表示剩余许可数;CountDownLatch 中 state 表示还需等待的计数;ReentrantReadWriteLock 中 state 高 16 位为读锁数量、低 16 位为写锁数量。state 的修改都通过 CAS 保证原子性。
要素二:CLH 变体双向队列。 获取锁失败的线程被包装成 Node 加入队尾。Node 核心字段:thread(线程引用)、prev/next(前后驱指针)、waitStatus(等待状态)、nextWaiter(条件队列指针)。等待状态有四种:CANCELLED(1) 线程已取消、SIGNAL(-1) 表示后继节点需要被 unpark 唤醒、CONDITION(-2) 在条件队列等待、PROPAGATE(-3) 共享模式向后传播唤醒。
获取锁失败后的入队流程:
- 将当前线程包装成 Node,CAS 设置为尾节点(自旋保证入队成功)。
- 如果前驱是头节点(头节点是当前持锁线程的占位节点),尝试 tryAcquire。
- 如果前驱不是头节点或获取失败,调用
shouldParkAfterFailedAcquire将前驱的 waitStatus 设为 SIGNAL(表示「我需要你释放时唤醒我」),然后LockSupport.park阻塞当前线程。 - 前驱节点释放锁后,检查 waitStatus 是否为 SIGNAL,是则
LockSupport.unpark唤醒后继节点。
要素三:模板方法模式。 AQS 把「入队、阻塞、唤醒、取消」等公共逻辑写在父类,子类只重写 tryAcquire/tryRelease(独占)或 tryAcquireShared/tryReleaseShared(共享)的判断逻辑。以 ReentrantLock 非公平锁为例:
// 非公平锁 tryAcquire
final boolean nonfairTryAcquire(int acquires) {
final Thread current = Thread.currentThread();
int c = getState();
if (c == 0) { // 无锁状态
// 非公平:不检查队列,直接 CAS 抢
if (compareAndSetState(0, acquires)) {
setExclusiveOwnerThread(current);
return true;
}
} else if (current == getExclusiveOwnerThread()) { // 当前线程重入
setState(c + acquires); // 重入次数+1,注意这里不用 CAS 因为只有自己能改
return true;
}
return false;
}
// 公平锁 tryAcquire
protected final boolean tryAcquire(int acquires) {
final Thread current = Thread.currentThread();
int c = getState();
if (c == 0) {
// 公平:先检查队列是否有前驱节点
if (!hasQueuedPredecessors() &&
compareAndSetState(0, acquires)) {
setExclusiveOwnerThread(current);
return true;
}
} else if (current == getExclusiveOwnerThread()) {
setState(c + acquires);
return true;
}
return false;
}
ConditionObject 条件队列: 每个 Condition 维护一个独立的单向等待队列。await() 时释放当前锁,将当前线程加入条件队列阻塞;signal() 时将条件队列首节点转移到 CLH 同步队列,等重新获取锁后继续执行。这就是 ReentrantLock 能支持多个 Condition 的原理。
生产问题与排查:
线上问题:使用 CountDownLatch 等待多个并行任务,其中一个任务抛异常没有 countDown,导致主线程永久阻塞。
排查方法:
jstack发现主线程处于 WAITING 在CountDownLatch$Sync.tryAcquireShared。- 检查是否所有 countDown 都被调用,特别是异常路径。
- 修复:每个子任务用 try-finally 确保 countDown,或用
await(timeout)设超时兜底。
高频追问详答:
Q1:公平锁和非公平锁区别?为什么默认非公平?
公平锁 tryAcquire 前先调用 hasQueuedPredecessors() 检查 CLH 队列是否有排在自己前面的线程,有则不抢,老老实实排队。非公平锁直接 CAS 尝试抢占,不管队列。默认非公平的原因:性能更高——公平锁要求严格 FIFO,每次释放锁后排在队首的线程被唤醒需要时间(线程从内核态恢复),这段空窗期锁闲置;非公平锁允许刚释放锁的线程或新来的线程直接抢占,减少线程切换。实测非公平锁吞吐量比公平锁高约 5%-10%。缺点是可能导致队列线程饥饿(长时间抢不到锁),但实际场景中线程交替执行,饥饿概率很低。
Q2:AQS 为什么用 CLH 队列变体?
CLH(Craig, Landin, Hagersten)队列的变体被 AQS 选用,原因是:①每个节点只需检查前驱节点的状态来判断自己是否应该阻塞,不需要全局锁,取消和超时操作只需修改前驱的 next 指针;②双向链表方便从队尾取消节点、检查是否头节点;③SIGNAL 状态机制使唤醒精确——前驱释放时检查 SIGNAL 决定是否唤醒后继,避免虚假唤醒。与原始 CLH 的区别:AQS 用了 prev/next 双向指针(CLH 原始是单向隐式链表),并且支持自旋和阻塞两种模式。
Q3:Node 的 SIGNAL 状态含义?
SIGNAL(-1) 表示「当前节点释放锁或取消时,需要 unpark 唤醒它的后继节点」。这个状态是在后继节点入队时设置的:后继节点 park 前先 shouldParkAfterFailedAcquire 把前驱的 waitStatus 改为 SIGNAL(如果前驱是 CANCELLED 就跳过它找到有效前驱)。这样前驱释放时只需检查自己的 waitStatus 是否为 SIGNAL,就知道有没有后继需要唤醒。
6. CAS 原理及 ABA 问题
考点定位: Unsafe 类的 native 方法、CPU CMPXCHG 指令、自旋开销、ABA 的真实危害场景、版本号解决方案、LongAdder 分段累加。
解题思路: 先讲 CAS 的原理和底层 CPU 指令,再讲三个缺点(自旋开销、单变量原子性、ABA),重点展开 ABA 的真实危害场景和 AtomicStampedReference 的解决方案,最后讲 LongAdder 如何解决高并发 CAS 竞争。
参考答案:
CAS(Compare And Swap)是无锁原语:比较内存位置 V 的值与预期旧值 A,如果相等则更新为新值 B,否则不操作,整个操作是原子的。Java 通过 sun.misc.Unsafe(JDK 9+ 为 jdk.internal.misc.Unsafe / VarHandle)的 compareAndSwapInt 等 native 方法实现,底层在 x86 上对应 CMPXCHG 指令,多核环境下加 lock 前缀保证缓存一致性。
CAS 的核心价值:避免了线程阻塞和唤醒的内核态切换开销,在没有竞争或竞争不激烈时性能远超加锁。
CAS 的三个缺点:
缺点一:自旋开销。 CAS 失败后通常自旋重试(do-while 循环),竞争激烈时大量线程自旋失败,CPU 空转浪费。AtomicInteger 在高并发下所有线程 CAS 同一个 value 字段,冲突率极高。
缺点二:只保证单个变量的原子性。 CAS 一次只能操作一个变量。如果需要同时原子更新多个变量,要么用锁,要么把这些变量封装成一个对象用 AtomicReference。
缺点三:ABA 问题。 线程 1 读到值 A,准备 CAS 改为 C。期间线程 2 把 A 改成 B,又改回 A。线程 1 的 CAS 成功,因为值确实还是 A,但中间已经发生了变化。
ABA 的真实危害场景——不是简单的数值计数,而是带状态语义的场景:
// 场景:基于 CAS 实现的无锁栈
// 线程1 要弹出栈顶 A,读到 head=A, next=B
// 线程2 弹出 A,弹出 B,又把 A 压回(此时栈顶是 A,但 B 已不在栈中)
// 线程1 CAS head: A→B 成功,但 B 已经被释放/回收,head 指向了无效内存
解决方案:AtomicStampedReference 引入 int 版本号 stamp,每次修改同时更新值和 stamp。CAS 时比较 (expectedValue, expectedStamp),只有都匹配才更新 (newValue, newStamp)。AtomicMarkableReference 用 boolean 标记,更轻量但只能标记「有没有被修改过」。
LongAdder 解决高并发 CAS 竞争:
AtomicLong 在高并发下所有线程 CAS 同一个 value,冲突率随线程数线性增长。LongAdder 的思路是「分散热点」:维护一个 base 值 + 一个 Cell[] 数组。不同线程根据 hash 分散到不同的 Cell 上各自累加,最终 sum() = base + 所有 Cell 的值。这样 N 个线程分散到 N 个 Cell 上,几乎没有 CAS 冲突。代价是 sum() 不是精确的(遍历 Cell 期间可能有新的写入),但用于统计计数场景足够。
LongAdder 结构:
base (long) — 无竞争时直接 CAS base
Cell[] — 有竞争时分散到不同 Cell
Cell 0: value (volatile long, @Contended 避免伪共享)
Cell 1: value
...
Cell 用 @Contended 注解填充缓存行,避免多个 Cell 在同一缓存行导致伪共享(false sharing,多个 CPU 修改同一缓存行导致频繁 MESI 失效)。
生产问题与排查:
某统计系统用 AtomicLong 做接口调用计数,8 核机器 QPS 2 万时 CPU 飙到 100%。arthas trace 定位到 AtomicLong.incrementAndGet 占用 80% CPU。根因:2 万 QPS 全部 CAS 同一个 value,冲突率极高,大量自旋。换 LongAdder 后 CPU 降到 15%。
排查方法:
arthas trace追踪方法耗时,发现incrementAndGet耗时异常。top -H查看线程 CPU 占用,发现多个线程在 CAS 上自旋。perf工具查看 CPU 热点函数是否在Unsafe.compareAndSwapLong。
高频追问详答:
Q1:CAS 自旋会不会造成 CPU 飙高?
会。竞争越激烈,CAS 失败率越高,自旋次数越多,CPU 空转越多。极端情况下所有线程 CAS 同一个变量,退化成串行且每次都有大量自旋,性能可能还不如加锁。解决:用分段策略(LongAdder、ConcurrentHashMap 的分段锁思路)减少冲突;或用 @Contended 避免伪共享;竞争确实无法分散时退回用锁(锁有等待队列不会自旋浪费 CPU)。
Q2:LongAdder 和 AtomicLong 怎么选?
高频写入、低精度读取场景(计数、统计)用 LongAdder,写性能可提升 5-10 倍。需要精确值或高频读取场景(如序列号生成、作为唯一 ID)用 AtomicLong,因为 LongAdder 的 sum() 是非精确快照。低并发场景两者性能差异不大,AtomicLong 更简单。注意 LongAdder 内存占用更大(Cell[] 数组),内存敏感场景需权衡。
Q3:伪共享是什么?怎么解决?
CPU 缓存以缓存行(通常 64 字节)为单位加载。如果两个 volatile 变量在同一缓存行,线程 A 修改变量 1 会使线程 B 的缓存行失效(MESI 协议),即使 B 只读变量 2。这就是伪共享——变量之间没有逻辑共享但物理共享了缓存行。解决:@Contended 注解让 JVM 在变量前后填充空字节,保证独占缓存行。LongAdder 的 Cell、ConcurrentHashMap 的 CounterCell 都用了这个优化。JVM 需要加 -XX:-RestrictContended 才生效。
7. ConcurrentHashMap 的底层实现(JDK 7 vs JDK 8)
考点定位: 分段锁 vs CAS+synchronized、put 完整流程、扩容机制、size 计数、为什么不允许 null。
解题思路: 对比 JDK7 和 JDK8 的结构差异,详细讲 JDK8 的 put 流程和并发扩容机制,解释设计选择的动机,最后讲 size 的分段计数和 null 禁止的原因。
参考答案:
JDK 7:Segment 分段锁。 结构是 Segment[] + HashEntry[],Segment 继承 ReentrantLock。默认 16 个 Segment,并发度 = Segment 数量。put 时先定位 Segment(hash 的高位),再对 Segment 加锁,然后操作 Segment 内部的 HashEntry 数组。get 不加锁,通过 volatile 读 HashEntry 的 value 和 next 保证可见性。优点:并发安全,锁粒度是段。缺点:Segment 数量固定,并发度不可动态调整;两层 hash 定位开销大。
JDK 8:Node 数组 + CAS + synchronized。 结构是 Node[] table,每个桶可能是链表或红黑树。锁粒度细化到桶级别(锁头节点)。
put 完整流程:
- 计算 hash:
(h ^ (h >>> 16)) & 0x7fffffff(spread 扰动,减少冲突)。 - 如果 table 为 null,调用
initTable()初始化(CAS 抢初始化权,一个线程初始化,其他线程自旋等待)。 - 定位桶位置
i = (n-1) & hash:- 桶为空:CAS 把新 Node 放入空桶。失败说明有并发竞争,自旋重试。
- 桶头节点 hash == MOVED(-1):说明正在扩容,当前线程调用
helpTransfer协助迁移数据。 - 否则:synchronized 锁住头节点,遍历链表/红黑树插入或更新。如果是链表插入后长度 ≥8 且数组长度 ≥64,转红黑树(数组 <64 时优先扩容而非转树)。释放锁。
addCount更新元素数量,检查是否需要扩容。
并发扩容机制(核心亮点):
当元素数量超过阈值(sizeCtl = 0.75 * capacity),触发扩容。扩容从旧 table 创建 2 倍大小的新 table,然后迁移数据。关键设计是多线程协助迁移:
- 把旧 table 按桶分成多个 stride 段(最小 16 个桶),每个线程认领一段迁移。
- 迁移完的桶放置
ForwardingNode(hash=MOVED),put 时遇到它就知道在扩容,协助迁移。 - 迁移时把旧桶的链表拆成两条:一条 hash 新位置 = 原位置,另一条 = 原位置 + 旧容量。通过
hash & oldCap判断。 - 最后一个线程完成迁移后,设
table = nextTable,sizeCtl = 新容量 * 0.75。
size 计数: baseCount + CounterCell[] 分段计数(思路同 LongAdder)。put 时先 CAS 更新 baseCount,失败则分散到 CounterCell。size 时把 baseCount 和所有 CounterCell 求和。这是弱一致性——size 不是精确值但足够准确。
生产问题与排查:
问题一:ConcurrentHashMap 的 size() 在大量并发写入时不精确,导致基于 size 的业务逻辑出错。例如用 if (map.size() > 10000) 限流,实际可能超过 12000 才触发。解决:不要依赖 size 做精确判断,或用独立的 AtomicLong 计数器。
问题二:key 或 value 设为 null 导致 NPE。有开发者在重构 HashMap 代码时直接换成 ConcurrentHashMap,没注意 null 的限制。解决:用 Optional 或空对象模式替代 null。
高频追问详答:
Q1:为什么 JDK 8 用 synchronized 锁头节点而不是 ReentrantLock?
三个原因:第一,锁粒度从段细化到桶,单桶的竞争概率极低,大多数情况下根本不会发生锁竞争,此时 synchronized 的偏向锁/轻量级锁几乎零开销,比 ReentrantLock 的 AQS 入队轻量得多。第二,synchronized 是 JVM 内置的,经过锁升级优化后性能不输 ReentrantLock,且节省了 ReentrantLock 所需的 AQS Node、ConditionObject 等内存开销。第三,ReentrantLock 需要额外的 lock 对象(每个桶一个 ReentrantLock),内存开销大;而 synchronized 直接用对象头,零额外内存。
Q2:链表转红黑树的阈值为什么是 8?
基于泊松分布的概率分析。在负载因子 0.75 下,桶中元素数量服从泊松分布,参数 λ ≈ 0.5。P(桶长度=8) ≈ 0.00000006,即一亿分之一的概率。正常情况下几乎不会触发转树,只有哈希严重冲突或恶意攻击(构造 hash 冲突的 key)时才会。退化阈值设为 6 而不是 7,是为了避免在 8 附近频繁转换(树化和退化都有开销,留缓冲区间)。另外转树要求数组长度 ≥64,否则优先扩容——数组小时冲突多半是容量不足,扩容比转树更有效。
Q3:ConcurrentHashMap 的 put 为什么不允许 null key 和 null value?
核心原因是多线程下的二义性。在 HashMap(单线程)中 get 返回 null 可以用 containsKey 区分「key 不存在」和「value 为 null」。但在 ConcurrentHashMap 多线程场景下,get 返回 null 时调用 containsKey,中间可能有其他线程 put 了值,导致 containsKey 返回 true,无法区分。这种竞态条件无法在 API 层面解决,所以直接禁止 null。另外 Doug Lea 认为多线程下 null value 通常意味着编程错误(如初始化遗漏),禁止它可以帮助发现问题。
8. synchronized 与 ReentrantLock 的区别
(详见第 4 题追问 Q1 的详细对比表,此处补充生产选型实践。)
生产选型实践:
90% 的场景用 synchronized:简单、不会忘记释放、JVM 持续优化。以下场景必须用 ReentrantLock:
- 需要可中断的锁等待(避免死锁时无限等待):用
lockInterruptibly()。 - 需要超时获取锁:用
tryLock(timeout),避免线程永久卡住。 - 需要公平锁(严格 FIFO,避免饥饿):
new ReentrantLock(true)。 - 需要多个条件变量精确唤醒:
Condition,如生产者-消费者模型中生产者和消费者各一个 Condition。 - 需要尝试获取锁(非阻塞):
tryLock()立即返回成功或失败。
生产故障案例: 某系统用 synchronized 保护一个外部 RPC 调用,下游服务卡住导致所有线程阻塞在 synchronized 上,系统雪崩。教训:不要在锁内做耗时 IO 操作。修复:把 RPC 调用移出锁外,锁内只做内存操作;或用 tryLock(timeout) 限制锁等待时间。
9. CountDownLatch、CyclicBarrier、Semaphore 区别
考点定位: 三者的应用场景、实现原理差异、可重用性。
参考答案:
| 维度 | CountDownLatch | CyclicBarrier | Semaphore |
|---|---|---|---|
| 作用 | 等待 N 个任务完成 | N 个线程互相等待到齐 | 控制并发访问数 |
| 计数方向 | 递减到 0 | 递增到 parties | 获取/释放许可 |
| 可重用 | 不可,一次性 | 可,自动重置 | 可,许可循环使用 |
| 实现 | AQS 共享模式 | ReentrantLock + Condition | AQS 共享模式 |
| 典型场景 | 主线程等子任务完成 | 多线程阶段性同步 | 限流、资源池 |
CountDownLatch 基于 AQS 共享模式,state 初始化为 N。countDown() 调用 tryReleaseShared 将 state CAS 减 1。await() 调用 tryAcquireShared,只有 state=0 时返回 1(允许通过),否则阻塞。不可重用——state 到 0 后无法重置。
CyclicBarrier 基于 ReentrantLock + Condition。await() 时获取锁,count 减 1。如果 count > 0,调用 trip.await() 进入条件等待;如果 count == 0(最后一个到达的线程),执行 barrierAction(如果有),然后 trip.signalAll() 唤醒所有等待线程,并重置 count 为 parties(这就是可重用的原理)。如果任何线程在等待时被中断或超时,barrier 被标记为 broken,所有等待线程抛 BrokenBarrierException。
Semaphore 基于AQS 共享模式,state 为许可数。acquire() 时 state CAS 减 1,减到 0 时后续线程入队阻塞。release() 时 state 加 1 并唤醒队首线程。
生产问题与排查:
CyclicBarrier 常见问题:一个线程异常退出,barrier 变成 broken 状态,其他所有线程抛 BrokenBarrierException 集体退出。排查:查看日志中的 BrokenBarrierException 堆栈,定位是哪个线程先异常。修复:在 await 外层加 try-catch,异常后重建 barrier;或用 reset() 重置。更稳妥的做法是用 Phaser(Java 7+,更灵活的同步屏障)替代。
三、线程池
10. 线程池的核心参数及工作流程
考点定位: 七参数含义、执行流程的顺序(核心→队列→非核心→拒绝)、核心线程回收、自定义 ThreadFactory。
解题思路: 先列七参数,再按任务提交后的执行顺序逐步讲解,重点解释「为什么先入队再创建非核心线程」的设计动机。
参考答案:
ThreadPoolExecutor 七个核心参数:
- corePoolSize:核心线程数,即使空闲也不回收(除非
allowCoreThreadTimeOut(true))。 - maximumPoolSize:最大线程数,核心 + 非核心线程的上限。
- keepAliveTime:非核心线程空闲存活时间,超时回收。
- unit:keepAliveTime 的时间单位。
- workQueue:任务队列,暂存来不及执行的任务。
- threadFactory:线程工厂,创建线程时设置名字、是否守护等。
- handler:拒绝策略,队列满且线程达上限时如何处理新任务。
执行流程(任务提交后):
提交任务
│
├─ 线程数 < corePoolSize?──是──→ 创建核心线程执行任务
│
├─ 核心线程满?──是──→ 任务入 workQueue
│
├─ 队列满?──是──→ 创建非核心线程(直到 maximumPoolSize)
│
└─ 线程达上限且队列满?──是──→ 触发拒绝策略
注意执行顺序:先创建核心线程 → 再入队列 → 再创建非核心线程 → 最后拒绝。先入队列再创建非核心线程是反直觉但合理的设计。
高频追问详答:
Q1:为什么先入队再创建非核心线程,而不是先创建线程?
设计动机:队列是廉价的缓冲区(内存中的链表/数组),而线程是昂贵的资源(每个线程约 1MB 栈空间,创建销毁有开销,还会占用 CPU 调度)。先用队列缓冲可以让突发流量平滑化——如果流量高峰很快过去,队列中的任务被核心线程消化即可,不需要创建额外线程。只有在队列也满了(说明流量持续超核心线程处理能力),才创建非核心线程加速。这避免了瞬间的线程创建风暴。
Q2:核心线程会被回收吗?
默认不会。核心线程即使空闲也会保活。如果调用 allowCoreThreadTimeOut(true),核心线程也会在 keepAliveTime 后回收。这个配置在低峰期节省资源(如夜间无流量时回收线程),高峰期再按需创建。注意:核心线程回收后如果新任务到来会重新创建,频繁创建销毁有开销,要权衡。
11. 线程池四种拒绝策略
参考答案:
| 策略 | 行为 | 适用场景 |
|---|---|---|
| AbortPolicy(默认) | 抛 RejectedExecutionException | 要求不丢任务的场景,异常被上层捕获处理 |
| CallerRunsPolicy | 由提交任务的线程自己执行 | 限流反压,降低提交速度 |
| DiscardPolicy | 静默丢弃新任务 | 允许丢失(如日志、埋点) |
| DiscardOldestPolicy | 丢弃队列最老的任务,重试当前 | 新任务更重要(如实时数据覆盖历史) |
生产实践:
生产环境几乎不直接用这四种,而是自定义拒绝策略。常见做法:
// 自定义拒绝策略:记录日志 + 降级
new RejectedExecutionHandler() {
@Override
public void rejectedExecution(Runnable r, ThreadPoolExecutor executor) {
// 1. 记录日志(含队列大小、活跃线程数,便于排查)
log.warn("线程池拒绝任务, queueSize={}, activeCount={}",
executor.getQueue().size(), executor.getActiveCount());
// 2. 降级:写入备选队列 / 发告警 / 返回默认值
// 3. 或降级为 CallerRunsPolicy 限流
if (!executor.isShutdown()) {
r.run(); // 让提交者自己跑
}
}
}
生产问题与排查:
某线上服务偶发 RejectedExecutionException,导致用户请求失败。排查:用 arthas 监控 ThreadPoolExecutor 的 getQueue().size() 和 getActiveCount(),发现队列在高峰期打满(1000),核心和最大线程也都繁忙。根因:maximumPoolSize 设为 200 但队列用了无界 LinkedBlockingQueue——队列永远不满,maximumPoolSize 永远不会触发,非核心线程永远不会创建,实际并发度只有 corePoolSize。修复:把队列改为有界 ArrayBlockingQueue(1000),让队列能满从而触发非核心线程创建。
高频追问详答:
Q1:CallerRunsPolicy 有什么好处?
让提交任务的线程自己执行任务,天然形成「背压」机制——当消费速度跟不上生产速度时,生产者线程被占用来消费,降低了提交速度,防止任务无限堆积导致 OOM。类似于 TCP 的流控。适用于不能丢任务但可以接受延迟的场景。
12. 如何合理配置线程池参数
考点定位: CPU 密集型 vs IO 密集型、动态参数调整、压测调优。
参考答案:
经典经验公式:
CPU 密集型(计算为主,如加密、压缩、排序):线程数 ≈ CPU 核数 + 1。多出的 1 个线程用于在某个线程偶尔缺页中断或等待时保持 CPU 不空闲。线程过多会导致频繁上下文切换(每次切换约 5-10 微秒),反而降低吞吐。
IO 密集型(网络/磁盘 IO 为主,如 RPC 调用、数据库查询):线程数 ≈ CPU 核数 × (1 + IO等待时间/CPU计算时间)。比值越大(IO 等待越长)需要越多线程来掩盖 IO 等待。粗略估算可用 2×CPU 核数起步,但实际要压测。
混合型任务:拆分成两个线程池——CPU 密集任务用一个池,IO 密集任务用另一个池,互不影响。
生产实践——动态参数调整:
美团技术团队的实践是把线程池参数接入配置中心(如 Apollo、Nacos),运行时动态调整:
// ThreadPoolExecutor 支持运行时修改参数
executor.setCorePoolSize(newCore); // 立即生效,多余的核心线程会被 interrupt
executor.setMaximumPoolSize(newMax); // 立即生效
// 注意:workQueue 的 capacity 不支持直接修改(ArrayBlockingQueue 的 capacity 是 final)
// 需要自定义可修改容量的队列,或用反射 hack
配合监控指标(活跃线程数、队列大小、拒绝次数、任务平均执行时间)+ 告警阈值,形成「监控 → 告警 → 调参 → 验证」闭环。
压测调优方法:
- 起步:CPU 密集型用 N+1,IO 密集型用 2N。
- 压测:逐步增加并发,观察 RT(响应时间)、吞吐量、CPU 利用率、队列堆积。
- 调优:如果 CPU 未打满但 RT 上升,说明线程不够(IO 等待未掩盖),增加线程;如果 CPU 已打满且 RT 上升,说明线程过多(上下文切换开销),减少线程。
- 验证:找到吞吐量最大且 RT 可接受的线程数作为生产配置。
高频追问详答:
Q1:线程数越多越好吗?
不是。线程过多有三个危害:①每个线程约 1MB 栈空间,2000 线程就占 2GB 内存,可能 OOM;②CPU 在线程间频繁切换,每次切换约 5-10 微秒,切换开销甚至超过任务本身;③更多的线程竞争锁和资源,反而增加冲突。最佳线程数是「刚好让 CPU 充分利用且上下文切换开销最小」的那个点。
13. 为什么阿里规约禁止用 Executors 创建线程池
考点定位: 无界队列和无界线程的 OOM 风险。
参考答案:
Executors 四个快捷工厂方法都有 OOM 风险:
| 方法 | 隐患 | 根因 |
|---|---|---|
| newFixedThreadPool | 任务堆积导致内存 OOM | 用无界 LinkedBlockingQueue(容量 Integer.MAX_VALUE) |
| newSingleThreadExecutor | 同上 | 同上 |
| newCachedThreadPool | 创建过多线程导致 OOM(unable to create new native thread) | maximumPoolSize = Integer.MAX_VALUE |
| newScheduledThreadPool | 同上 | 同上 |
newFixedThreadPool 源码:
public static ExecutorService newFixedThreadPool(int nThreads) {
return new ThreadPoolExecutor(nThreads, nThreads,
0L, TimeUnit.MILLISECONDS,
new LinkedBlockingQueue<Runnable>()); // 无界!
}
LinkedBlockingQueue 不传容量默认 Integer.MAX_VALUE(约 21 亿),任务可以无限堆积,最终堆内存溢出。
newCachedThreadPool 源码:
public static ExecutorService newCachedThreadPool() {
return new ThreadPoolExecutor(0, Integer.MAX_VALUE, // 无上限!
60L, TimeUnit.SECONDS,
new SynchronousQueue<Runnable>()); // 无容量,来一个建一个线程
}
SynchronousQueue 没有容量,每个任务必须直接交给一个线程执行,没有可用线程就创建新的。突发流量下会瞬间创建大量线程,每个线程 1MB 栈空间,达到操作系统线程上限(通常几万)后抛 OutOfMemoryError: unable to create new native thread。
正确做法:
ThreadPoolExecutor executor = new ThreadPoolExecutor(
10, // corePoolSize
50, // maximumPoolSize
60L, TimeUnit.SECONDS, // keepAliveTime
new ArrayBlockingQueue<>(1000), // 有界队列
new ThreadFactory() { // 自定义线程工厂
private final AtomicInteger counter = new AtomicInteger();
@Override
public Thread newThread(Runnable r) {
Thread t = new Thread(r, "biz-pool-" + counter.incrementAndGet());
t.setDaemon(false);
return t;
}
},
new ThreadPoolExecutor.CallerRunsPolicy() // 限流反压
);
要点:有界队列、合理的 max、自定义线程名(便于 jstack 排查)、合适的拒绝策略。
四、高并发场景设计(重点)
14. 设计一个秒杀系统,如何扛住高并发
考点定位: 分层削峰架构、缓存预热、限流防刷、异步下单、防超卖、库存一致性。
解题思路: 秒杀的本质是「读多写少、瞬时流量巨大」。核心设计思想是层层削峰,把打到数据库的请求降到最小。从前端到数据库逐层讲解削峰手段,并给出每层的具体技术选型和失败兜底。
参考答案:
整体架构(流量漏斗):
百万 QPS
│
├─ 前端层(CDN/静态化/防重复) → 削减无效请求
│ ↓ 剩余 50 万 QPS
├─ 网关层(限流/黑名单/验证码) → 拦截异常流量
│ ↓ 剩余 10 万 QPS
├─ 服务层(Redis 库存判定/用户限购) → 拦截无库存请求
│ ↓ 剩余 几千 QPS(秒杀成功请求)
├─ MQ 异步队列 → 削峰填谷
│ ↓ 削峰到 几百 TPS
└─ 数据库(创建订单/扣减库存) → 最终一致性
第一层:前端层
- 活动页面静态化,部署到 CDN,减少服务端渲染压力。
- 按钮置灰防重复点击(点击后 disable 3-5 秒)。
- 倒计时统一用服务端时间(
Date.now() - serverTimeDiff),防止客户端时钟不准提前抢购。 - 答题/验证码:增加操作成本,防机器人脚本刷单,同时错开请求到达时间(人为延迟天然削峰)。
第二层:网关层
- 限流:令牌桶或滑动窗口,单 IP 限频(如 1 次/秒)、单用户限频(如 5 次/分钟)。
- 黑名单:识别异常 IP(短时间内大量请求)、设备指纹去重。
- 风控:接风控系统,识别羊毛党、机器账号。
第三层:服务层
- 库存预热:活动开始前把库存写入 Redis(
stock:{goodsId})。 - Redis Lua 原子扣减:判定库存是否足够 + 扣减在一个 Lua 脚本中原子执行(Redis 单线程保证原子性),避免「查-判-扣」三步并发问题。
- 用户限购:用 Redis SETNX 标记该用户已秒杀(
user:seckill:{userId}:{goodsId}),防止同一用户重复秒杀。 - 异步下单:Redis 扣减成功即认为秒杀成功,发送 MQ 消息,下游消费者异步创建订单、扣 DB 库存。前端返回一个排队中状态,轮询或 WebSocket 推送最终结果。
-- Redis Lua 原子扣减脚本
local stockKey = KEYS[1]
local userKey = KEYS[2]
local stock = tonumber(redis.call('get', stockKey))
if not stock or stock <= 0 then
return -1 -- 库存不足
end
-- 检查是否已秒杀
if redis.call('get', userKey) then
return -2 -- 已秒杀
end
redis.call('decr', stockKey)
redis.call('set', userKey, '1', 'EX', 86400) -- 24小时防重复
return 1 -- 成功
第四层:存储层
- DB 扣库存用条件 UPDATE 兜底防超卖:
UPDATE goods SET stock=stock-1 WHERE id=? AND stock>0。 - 订单创建和库存扣减在一个本地事务中。
- 消息消费失败重试 + 死信队列,保证最终一致性。
失败兜底:
- Redis 宕机:降级为直接查 DB(限流更严格),或暂停秒杀活动。
- MQ 宕机:Redis 扣减成功的请求暂存本地队列(如 Disruptor),MQ 恢复后补发。
- 超卖:DB 条件 UPDATE 兜底,即使 Redis 和 MQ 出问题也不会超卖。
高频追问详答:
Q1:库存放 Redis 还是 DB?
两者结合。Redis 扛流量层:做库存判定和扣减,拦截掉绝大多数无库存请求,DB 感知不到瞬时流量。DB 是最终账本:做真实扣减和持久化,保证数据不丢。流程:Redis 扣减成功 → 发 MQ → 消费者扣 DB。Redis 扣减是「预扣」,DB 扣减是「实扣」。如果 Redis 扣减成功但 DB 扣减失败(如 DB 异常),消息重试,重试到死信队列后人工介入或回补 Redis 库存。
Q2:怎么保证 Redis 和 DB 一致性?
采用最终一致性方案:以 Redis 预扣为准,DB 异步消费 MQ 扣减。保证一致性的关键:①消息可靠投递——生产者用本地事务消息表(先写业务表和消息表,再发 MQ,定时任务扫描未发送的消息补偿);②消费者幂等——用订单号做唯一索引,重复消费时忽略;③对账补偿——定时任务对比 Redis 库存和 DB 库存,发现不一致时以 DB 为准修正 Redis。不追求强一致(Redis 和 DB 实时一致),而是保证最终一致——最终所有秒杀成功的订单都有对应的 DB 库存扣减。
Q3:怎么防止用户刷单?
四道防线:①前端——验证码/答题/按钮防抖;②网关——IP 限频 + 设备指纹去重;③服务——用户限购(Redis SETNX 标记)+ 账号风控(识别新注册、低活跃账号);④数据——事后分析秒杀订单的收货地址、手机号模式,人工复核可疑订单。
Q4:库存回滚怎么做?
秒杀成功后用户未在规定时间内支付,需要释放库存。方案:订单创建时发送一条延迟消息(如 15 分钟),消费者收到后检查订单状态——未支付则取消订单、回补 Redis 库存(INCR stock:{goodsId})、回补 DB 库存。如果回补时活动已结束,库存不再可售,记入待处理库存。
15. 如何解决高并发下的超卖问题
考点定位: 原子操作、乐观锁、分布式锁、多层兜底设计。
解题思路: 超卖的根因是「查库存-判断-扣减」三步非原子。从三层方案逐一讲解,每层的适用场景和性能权衡,最后给出生产推荐的多层兜底方案。
参考答案:
超卖根因:并发请求同时读到 stock=1,都判断为「有库存」,都执行扣减,导致 stock 变成负数。
方案一:Redis Lua 原子扣减(扛流量层,推荐)
local stock = tonumber(redis.call('get', KEYS[1]))
if stock and stock > 0 then
redis.call('decr', KEYS[1])
return 1 -- 成功
end
return 0 -- 库存不足
Lua 脚本在 Redis 单线程内执行,整个脚本期间不会被其他命令打断,天然原子。性能极高(单 Redis 实例 10 万+ QPS),是秒杀场景的首选。
方案二:DB 条件 UPDATE(兜底层,必须)
UPDATE goods SET stock = stock - 1
WHERE id = #{id} AND stock > 0;
-- 返回影响行数:1=成功,0=库存不足
利用 InnoDB 行锁 + stock > 0 条件,DB 保证不会扣成负数。如果影响行数为 0,说明并发下库存已被其他请求扣完,本次扣减失败。这是最后一道防线,即使上层 Redis 出错也不会超卖。
也可带版本号乐观锁:WHERE id=#{id} AND version=#{version},但需要先查询版本号,多一次查询,且高并发下 CAS 失败率高,不如直接条件 UPDATE。
方案三:分布式锁(串行化,性能低,慎用)
对同一商品加 Redis 分布式锁(如 lock:goods:{goodsId}),获取锁后查库存→扣减→释放锁。简单但吞吐量低——同一商品的扣减请求被串行化,无法并行。仅适用于库存极少(如 1-10 件)或扣减逻辑复杂的场景。
生产推荐:多层兜底
Redis Lua 扣减(拦截 99% 无效请求)
↓ 成功
MQ 异步消息
↓
DB 条件 UPDATE(最终兜底防超卖)
Redis 扛住流量,DB 条件 UPDATE 做最终保证。两层独立工作,任一层失效另一层仍能防超卖。
生产问题与排查:
某促销活动出现超卖,100 件库存卖了 120 件。排查路径:
- 检查 Redis Lua 脚本是否正确——发现运维误改了脚本,把
stock > 0写成了stock >= 0,导致 stock=0 时仍扣减。 - 检查 DB 是否有兜底——发现 DB 的 UPDATE 语句没有
AND stock > 0条件,直接SET stock = stock - 1。 - 修复两层都有正确判断,DB 作为最终防线。
教训:每一层都要独立做防超卖判断,不能依赖上一层正确。这就是「Defense in Depth(纵深防御)」。
高频追问详答:
Q1:为什么不直接用 DB 行锁扣减?
DB 单机 TPS 通常在几千,秒杀瞬时 QPS 可能上万甚至十万,DB 直接承受会打满连接池、CPU 飙高、响应超时,进而导致整个系统雪崩。必须用 Redis 在 DB 前面削峰,把打到 DB 的写请求降到几百 TPS。DB 的条件 UPDATE 是兜底防线,不是扛流量主力。
Q2:乐观锁和悲观锁哪个适合秒杀?
乐观锁(条件 UPDATE 或版本号)更适合。悲观锁(SELECT ... FOR UPDATE)会锁住行,其他事务等待,高并发下大量等待会导致锁超时和连接池耗尽。乐观锁不锁行(只在 UPDATE 时利用行锁瞬间判定),吞吐量高。但乐观锁在高冲突场景下失败率高(大量请求扣减失败需要重试或返回失败),所以要先在 Redis 层过滤掉无库存请求,减少 DB 的无效 UPDATE。
16. 分布式锁的实现方案及对比
考点定位: Redis SETNX、Redisson 看门狗、Zookeeper 临时顺序节点、RedLock、选型权衡。
解题思路: 先讲三种方案的核心实现,再对比性能/可靠性/一致性,最后讲生产中遇到的真实问题(锁失效、锁续期失败、主从切换丢锁)及解决方案。
参考答案:
方案一:Redis SETNX(基础版)
SET lock_key unique_value NX PX 30000
NX:key 不存在才设置(互斥)。PX 30000:30 秒过期(防死锁)。unique_value:唯一值(如 UUID),用于安全释放锁。
释放锁必须用 Lua 校验值再删除,防止误删别人的锁:
if redis.call('get', KEYS[1]) == ARGV[1] then
return redis.call('del', KEYS[1])
else
return 0
end
为什么要校验?场景:线程 A 获锁,业务执行超时锁自动过期,线程 B 获锁。此时 A 执行完直接 del 会删掉 B 的锁。校验 value 确保只删自己的锁。
问题:业务执行时间超过锁过期时间,锁自动释放,其他线程获锁,导致并发执行——这是 SETNX 方案的最大缺陷。
方案二:Redisson(推荐生产使用)
Redisson 在 SETNX 基础上做了大量增强:
- 可重入:用 Hash 结构存储,field 是「客户端ID:线程ID」,value 是重入次数。加锁时如果 field 已存在且是自己,重入次数 +1;解锁时 -1,减到 0 删除 key。
- 看门狗自动续期:加锁时如果不指定 leaseTime(默认 -1),启动一个后台线程,每隔
lockWatchdogTimeout/3(默认 10 秒)检查持锁线程是否存活,存活则把过期时间重置为 30 秒。这样业务只要不宕机,锁就不会过期。 - 公平锁/读写锁/联锁:支持多种锁模式。
- 锁等待队列:未获锁的线程订阅释放通道,而非忙等待。
// Redisson 使用示例
RLock lock = redisson.getLock("orderLock:" + orderId);
try {
// 尝试加锁,等待 5 秒,不设 leaseTime 触发看门狗自动续期
if (lock.tryLock(5, TimeUnit.SECONDS)) {
// 执行业务逻辑
} else {
// 获取锁失败
}
} finally {
if (lock.isHeldByCurrentThread()) {
lock.unlock();
}
}
方案三:Zookeeper
基于临时顺序节点实现公平锁:
- 客户端在锁节点下创建临时顺序节点
/lock/node_00000001。 - 获取锁节点下所有子节点,排序。
- 如果自己是序号最小的节点,获锁成功。
- 否则监听前一个节点的删除事件,前一个删除后自己被唤醒,重新检查是否最小。
- 客户端宕机时临时节点自动删除(Session 心跳断开),锁自动释放。
优点:锁安全性高——宕机自动释放,不存在锁过期问题;CP 强一致(ZAB 协议),主从切换不丢锁。缺点:性能低(每次加锁涉及多次 ZK 节点创建和监听,且 ZK 集群写需要半数以上确认);实现复杂。
对比选型:
| 维度 | Redis (Redisson) | Zookeeper | etcd |
|---|---|---|---|
| 性能 | 高(内存操作,万级 QPS) | 低(集群写广播,千级 QPS) | 中 |
| 可靠性 | 主从切换有极小概率丢锁 | CP 强一致,可靠 | CP(Raft),可靠 |
| 一致性 | AP(异步复制) | CP(ZAB) | CP(Raft) |
| 自动续期 | 看门狗 | 临时节点+Session | Lease TTL |
| 适用场景 | 大多数互联网场景 | 金融、强一致场景 | 云原生场景 |
生产问题与排查:
问题一:Redisson 看门狗续期失败导致锁被释放。根因:Redisson 客户端线程池满,看门狗任务无法调度。排查:检查 Redisson 的 nettyThreads 配置,看 redisson-* 线程是否阻塞。修复:增大线程池或排查阻塞原因。
问题二:Redis 主从切换导致锁丢失。主节点加锁后还没同步到从节点就宕机,哨兵提升从节点为新主,新主上没有锁记录,其他线程获锁。解决:用 RedLock 算法向多个独立 Redis 实例加锁,多数成功才算成功;或接受极小概率失效,业务侧做幂等兜底。
问题三:分布式锁死锁。A 持有锁 1 等待锁 2,B 持有锁 2 等待锁 1。解决:所有加锁按固定顺序(如按 resourceId 排序后加锁),避免环形等待;设超时 tryLock(timeout)。
高频追问详答:
Q1:Redis 主从切换导致锁丢失怎么办?
两种方案:
- RedLock 算法:向 N 个(通常 5 个)独立的 Redis 实例(不是主从,是独立部署)加锁,只要多数(N/2+1)成功,且总耗时小于锁过期时间,就认为加锁成功。任何一个实例宕机不影响,因为多数还持有锁。缺点:实现复杂、性能下降(5 次 RTT)、有争议(Martin Kleppmann 指出时钟漂移问题)。
- 业务兜底:接受 Redis 锁的极小概率失效(AP 语义),在业务侧做幂等设计——即使锁失效导致并发执行,业务结果也不会出错(如唯一索引、状态机)。
Q2:看门狗原理?为什么能自动续期?
Redisson 加锁时如果不指定 leaseTime(传 -1),会启动一个 Timeout 定时任务,间隔 lockWatchdogTimeout / 3(默认 30/3 = 10 秒)。定时任务执行时检查:当前线程是否还持有锁(Lua 脚本检查 Hash 中是否有自己的 field)。如果持有,用 PEXPIRE 重置过期时间为 lockWatchdogTimeout(30 秒)。如果业务执行完毕 unlock,定时任务下次检查发现锁已释放就不续期。如果客户端宕机,定时任务不再执行,锁在 30 秒后自动过期。这个机制既保证了业务执行期间锁不会过期,又保证了客户端宕机后锁能释放。
Q3:锁失效导致重复执行怎么办?
即使分布式锁完美无缺,网络分区、GC STW、客户端宕机等极端情况仍可能导致锁失效。最终防线是业务幂等:用业务唯一键(如订单号)做唯一索引,重复执行时 DB 抛 DuplicateKeyException 被捕获忽略;或用状态机(订单只能从「待支付」变「已支付」,重复请求发现已是「已支付」直接返回成功)。分布式锁 + 幂等设计 = 双保险。
17. 缓存穿透、击穿、雪崩的区别与解决方案
考点定位: 三者的精确区分、布隆过滤器、互斥锁重建、逻辑过期、随机 TTL。
解题思路: 用对比表快速区分三者,再逐一展开解决方案和代码实现,最后讲生产中如何预防和排查。
参考答案:
| 问题 | 成因 | 危害 | 典型场景 |
|---|---|---|---|
| 穿透 | 查询不存在的数据,缓存和 DB 都没有 | 每次请求打到 DB | 恶意攻击构造不存在的 ID |
| 击穿 | 单个热点 key 过期瞬间,大量并发请求穿透 | 瞬间打到 DB | 爆款商品缓存过期 |
| 雪崩 | 大量 key 同时过期或 Redis 宕机 | 请求全压 DB | 批量预热数据 TTL 相同 |
穿透的解决方案:
方案一:缓存空值。查 DB 没有时缓存 null(或特殊标记),设短 TTL(如 60 秒)。下次同样的请求命中缓存返回 null,不打 DB。缺点:浪费内存存空值;如果大量不同的不存在 key 请求,缓存大量空值。
方案二:布隆过滤器。把所有存在的 key 预先加载到布隆过滤器(Redis 或本地 Bitmap)。请求先过布隆过滤器,如果布隆判断「不存在」直接返回(布隆说不存在一定不存在),只有「可能存在」才查缓存和 DB。布隆过滤器只需极少内存(1 亿 key 约 100MB),适合大量 key 场景。
方案三:参数校验。对明显非法的请求(负数 ID、超长字符串、特殊字符)直接拒绝,不查缓存。
public Product getProduct(Long id) {
if (id == null || id <= 0) {
return null; // 参数校验
}
// 布隆过滤器检查
if (!bloomFilter.mightContain(id)) {
return null; // 一定不存在
}
// 查缓存
String key = "product:" + id;
Product product = redis.get(key);
if (product != null) {
return product;
}
// 查 DB
product = db.findById(id);
if (product == null) {
redis.setex(key, 60, "NULL"); // 缓存空值,短 TTL
return null;
}
redis.setex(key, 3600, product); // 正常缓存
return product;
}
击穿的解决方案:
方案一:互斥锁重建(双重检查锁)。缓存未命中时,只让一个线程查 DB 重建缓存,其他线程等待或返回旧值。
public Product getProductWithLock(Long id) {
String key = "product:" + id;
Product product = redis.get(key);
if (product != null) return product;
// 获取分布式锁
String lockKey = "lock:" + key;
try {
if (redis.setnx(lockKey, "1", "NX", "EX", 10)) {
// 双重检查:拿到锁后再查一次缓存(可能其他线程已重建)
product = redis.get(key);
if (product != null) return product;
// 查 DB 重建缓存
product = db.findById(id);
redis.setex(key, 3600, product);
return product;
} else {
// 没拿到锁,短暂等待后重试
Thread.sleep(50);
return getProductWithLock(id); // 递归重试
}
} finally {
redis.del(lockKey);
}
}
方案二:逻辑过期(永不过期)。缓存永不设置 TTL,但在 value 中存一个逻辑过期时间字段。读取时检查是否逻辑过期,未过期直接返回;已过期则返回旧值(保证可用),同时异步触发一个线程重建缓存。重建期间其他请求都返回旧值。优点:不阻塞,性能高;缺点:有短暂数据不一致窗口。
// 缓存结构
{
"data": { ... }, // 实际数据
"expireTime": 1700000000 // 逻辑过期时间
}
// 读取逻辑
CacheData cache = redis.get(key);
if (cache != null) {
if (cache.getExpireTime() > System.currentTimeMillis()) {
return cache.getData(); // 未过期,直接返回
}
// 已过期,异步重建
if (tryGetRebuildLock()) {
executor.submit(() -> {
try {
Product fresh = db.findById(id);
redis.set(key, new CacheData(fresh, nextExpireTime()));
} finally {
releaseRebuildLock();
}
});
}
return cache.getData(); // 返回旧值
}
雪崩的解决方案:
方案一:TTL 加随机值。redis.setex(key, 3600 + random(300), value),把过期时间打散在 3600-3900 秒之间,避免同时失效。
方案二:Redis 高可用。哨兵模式或 Cluster 集群,避免单点宕机导致全部缓存不可用。
方案三:多级缓存。本地缓存(Caffeine)+ Redis + DB。即使 Redis 宕机,本地缓存仍能挡一部分流量。
方案四:限流降级。缓存不可用时触发限流(Sentinel),保护 DB 不被压垮;降级返回默认值或静态数据。
生产问题与排查:
案例:某电商首页推荐缓存批量过期(运维脚本预热时 TTL 统一设为 1 小时),每小时的整点 Redis 命中率骤降,DB CPU 飙到 90%。排查:监控 Redis 的 expired_keys 指标,发现整点过期 key 数量激增。修复:预热时 TTL 加 ThreadLocalRandom.nextInt(600) 随机偏移。
高频追问详答:
Q1:布隆过滤器原理?
一个 bit 数组(长度 m)+ k 个独立的 hash 函数。加入元素时,用 k 个 hash 函数计算 k 个位置,全部置 1。查询时同样计算 k 个位置,如果全部为 1 则「可能存在」(有误判率,因为其他元素可能把这些位置都置过 1),如果有任何一个为 0 则「一定不存在」。特点:空间效率极高(1 亿 key 约 100MB);不支持删除(删除会影响其他元素的判断);误判率可控(m 和 k 越大误判越低)。支持删除的变体是布谷鸟过滤器。
Q2:缓存与 DB 一致性怎么保证?
常用方案——延迟双删:先删缓存 → 更新 DB → 延迟 500ms-1s 再删缓存。第二次删除防止「更新 DB 期间有其他线程读到旧值并写回缓存」。延迟时间根据业务读耗时估算(要大于读耗时)。
更可靠的方案——订阅 binlog:用 Canal 订阅 MySQL binlog,解析到数据变更后异步删除/更新 Redis 缓存。保证最终一致,且业务代码无需关心缓存。缺点:有延迟(秒级)。
强一致方案——加分布式读写锁:写时获取排他锁,读时获取共享锁。保证读写互斥,但性能差,仅在金融等强一致场景使用。
18. 常见的限流方案及实现
考点定位: 四种限流算法、Guava RateLimiter、Sentinel、分布式限流。
解题思路: 先讲四种算法的原理和优缺点,再讲单机和分布式实现,最后讲生产中限流和熔断配合的策略。
参考答案:
四种限流算法:
1. 固定窗口计数。 在固定时间窗口(如每秒)内计数,超过阈值拒绝。实现简单。缺点:临界突刺——窗口边界前后各来阈值数量的请求,1 秒内实际通过 2 倍阈值。例如限流 100/秒,0.99 秒来 100 个,1.01 秒又来 100 个,0.99-1.01 这 1 秒内实际通过 200 个。
2. 滑动窗口。 把固定窗口细分为多个小格子(如 1 秒分为 10 个 100ms 格子),统计当前时间往前 1 秒内所有格子的总数。滑动统计平滑了临界突刺。Sentinel 默认用滑动窗口(LeapArray 实现)。
3. 漏桶(Leaky Bucket)。 请求如水流入桶中,桶以恒定速率漏出(处理)。桶满则拒绝。效果:不管流量多突发,输出速率恒定,强制平滑。缺点:无法应对合理的突发流量——即使系统有能力处理更多,也只能按恒定速率处理。
4. 令牌桶(Token Bucket)。 以固定速率往桶里放令牌,桶有上限(桶满丢弃多余令牌)。请求到来时取一个令牌,取到才处理,取不到拒绝。效果:允许突发——桶里可以攒令牌,突发流量来时一次性消耗积攒的令牌快速处理;长期平均速率受令牌生成速率限制。最常用,Guava RateLimiter、Sentinel、Nginx limit_req 都基于此。
令牌桶:
┌─────────┐ 以 R 速率放令牌
│ ● ● ● ● │ 桶容量 C(可攒 C 个令牌应对突发)
└────┬────┘
│ 请求取令牌
▼
有令牌→处理 无令牌→拒绝/排队
漏桶:
请求流入 ──→ ┌─────────┐ 以 R 速率流出
│ ○ ○ ○ ○ │ 桶满→拒绝
└─────────┘
单机实现:Guava RateLimiter
// 每秒 100 个令牌(QPS 限制 100)
RateLimiter limiter = RateLimiter.create(100);
if (limiter.tryAcquire()) {
// 获取到令牌,处理请求
processRequest();
} else {
// 获取不到,拒绝或排队
return Result.fail("系统繁忙,请稍后重试");
}
// 也可以阻塞等待
limiter.acquire(); // 阻塞直到获取令牌
Guava RateLimiter 有两种实现:SmoothBursty(默认,允许突发,可攒令牌)和 SmoothWarmingUp(预热期逐步提升允许速率,适合冷启动场景)。
分布式实现:Redis + Lua
-- 令牌桶 Lua 脚本(简化版)
local key = KEYS[1]
local capacity = tonumber(ARGV[1]) -- 桶容量
local rate = tonumber(ARGV[2]) -- 令牌生成速率(个/秒)
local now = tonumber(ARGV[3]) -- 当前时间戳(秒)
local requested = tonumber(ARGV[4]) -- 请求的令牌数
local bucket = redis.call('hmget', key, 'tokens', 'lastTime')
local tokens = tonumber(bucket[1]) or capacity
local lastTime = tonumber(bucket[2]) or now
-- 计算自上次以来新增的令牌
local delta = math.max(0, now - lastTime)
tokens = math.min(capacity, tokens + delta * rate)
if tokens >= requested then
tokens = tokens - requested
redis.call('hmset', key, 'tokens', tokens, 'lastTime', now)
redis.call('expire', key, 60)
return 1 -- 允许
else
redis.call('hmset', key, 'tokens', tokens, 'lastTime', now)
return 0 -- 拒绝
end
Sentinel(阿里,推荐生产使用):
Sentinel 不仅是限流,还集成熔断降级、热点参数限流、系统自适应限流:
// 定义限流规则
FlowRule rule = new FlowRule();
rule.setResource("queryOrder");
rule.setGrade(RuleConstant.FLOW_GRADE_QPS);
rule.setCount(1000); // 限制 1000 QPS
FlowRuleManager.loadRules(Collections.singletonList(rule));
// 使用
try (Entry entry = SphU.entry("queryOrder")) {
// 业务逻辑
} catch (BlockException e) {
// 被限流/熔断
return Result.fail("系统繁忙");
}
Sentinel 的优势:①滑动窗口统计精确;②热点参数限流(如对某个商品 ID 单独限流);③熔断规则(慢调用比例、异常比例);④控制台可视化配置和实时监控。
高频追问详答:
Q1:漏桶和令牌桶区别?怎么选?
漏桶强制恒定输出速率,不管输入多突发,输出都平滑,适合保护下游弱系统(如 DB 不能承受突发)。令牌桶允许突发(攒令牌一次性消耗),适合保护自身同时允许合理的流量波动。大多数互联网场景用令牌桶,因为业务有突发特性(如整点活动)。如果是保护 DB 等脆弱下游,可用漏桶强制平滑。
Q2:分布式限流用 Redis 怎么做?
用 Lua 脚本在 Redis 中原子执行限流判断(取令牌-判断-扣减-更新),避免多节点竞态。注意:每次限流判断都要访问 Redis,增加一次网络 RTT。优化:本地预取一批令牌(如每 100ms 从 Redis 取 100 个令牌到本地缓存),本地请求先消耗本地令牌,减少 Redis 访问。这是 Sentinel 集群限流的思路。
Q3:限流和熔断区别?
限流是「保护自身」——控制进入系统的请求速率,超过阈值拒绝,防止系统过载。熔断是「保护下游」——当下游服务异常(超时率高、错误率高)时主动停止调用,快速失败,防止级联雪崩。两者常配合:限流防自身过载,熔断防下游故障扩散。恢复策略也不同——限流恢复是持续的(令牌桶持续放令牌),熔断恢复是渐进的(半开状态试探性放行)。
19. 接口幂等性如何设计
考点定位: 幂等概念、token 机制、唯一索引、乐观锁、状态机。
解题思路: 先讲幂等的定义和为什么需要幂等(网络重试、MQ 重复消费、用户重复点击),再讲五种方案及适用场景,最后讲生产中的组合实践。
参考答案:
幂等性指一次请求和多次请求产生的影响完全一致。需要幂等的场景:①网络超时后客户端重试;②MQ 消费者 at-least-once 投递导致重复消费;③用户快速重复点击提交按钮;④微服务 RPC 框架自动重试。
五种方案:
方案一:唯一索引(最简单可靠)
业务唯一键(如订单号、外部流水号)建唯一索引。重复插入时 DB 抛 DuplicateKeyException,捕获后返回「已处理」。
CREATE TABLE orders (
id BIGINT PRIMARY KEY AUTO_INCREMENT,
order_no VARCHAR(32) UNIQUE, -- 唯一索引
...
);
try {
orderMapper.insert(order);
} catch (DuplicateKeyException e) {
// 重复请求,查询已有订单返回
return orderMapper.selectByOrderNo(order.getOrderNo());
}
适用:插入场景,有天然业务唯一键。
方案二:Token 机制
客户端提交业务请求前,先请求服务端获取一次性 token。提交时携带 token,服务端用 Lua 原子校验并删除 token。
-- Token 校验 Lua 脚本
local token = redis.call('get', KEYS[1])
if token == ARGV[1] then
redis.call('del', KEYS[1]) -- 删除 token,防止重复使用
return 1 -- 有效
end
return 0 -- 无效或已使用
适用:前端表单提交、支付等无天然唯一键的场景。注意 token 必须一次性,用后即删。
方案三:乐观锁版本号
UPDATE orders
SET status = 'PAID', version = version + 1
WHERE id = #{id} AND version = #{version};
-- 影响行数 0 = 已被其他请求更新(可能已处理)
适用:更新场景。重复请求带旧 version,UPDATE 影响行数为 0,说明已被处理。
方案四:状态机
订单等业务有明确状态流转(待支付→已支付→已发货→已完成),状态只能单向流转。重复支付请求检查当前状态,已经是「已支付」直接返回成功。
public PayResult pay(Long orderId) {
Order order = orderMapper.selectById(orderId);
if (order.getStatus() == OrderStatus.PAID) {
return PayResult.success("已支付"); // 幂等返回
}
if (order.getStatus() != OrderStatus.PENDING) {
return PayResult.fail("订单状态异常");
}
// 执行支付
orderMapper.updateStatus(orderId, OrderStatus.PENDING, OrderStatus.PAID);
return PayResult.success();
}
适用:有状态流转的业务。
方案五:防重表(去重表)
请求流水号写入防重表(唯一约束),利用 DB 唯一约束保证幂等。与方案一类似,但独立于业务表,适用于业务表无法加唯一索引的场景。
生产组合实践:
以支付接口为例,三层防护:
- 前端:按钮防重复点击(支付中 disable)。
- 网关:请求流水号去重(防重表),相同流水号短时间内只放行一次。
- 服务:状态机检查 + 唯一索引兜底。
高频追问详答:
Q1:幂等和防重区别?
防重是防止重复提交(侧重阻止重复请求进入系统),幂等是重复请求产生相同结果(侧重即使重复也安全)。防重是手段,幂等是目标。例如 token 机制是防重(第二次请求 token 已失效被拒绝),唯一索引是幂等(第二次请求插入失败但不影响数据正确性)。
Q2:微服务下 RPC 重试如何保证幂等?
接口设计时就要考虑幂等。请求带唯一 requestId,下游用 requestId 做去重——收到相同 requestId 的请求,如果已处理完直接返回上次的结果(缓存结果),如果正在处理中则等待或拒绝。RPC 框架的重试机制(如 Dubbo retries、Feign retryer)要配合幂等接口使用,非幂等接口(如扣款)不能盲目重试。
20. 分布式 ID 生成方案
考点定位: UUID 缺点、雪花算法、数据库号段、时钟回拨、Leaf。
参考答案:
| 方案 | 原理 | 优点 | 缺点 |
|---|---|---|---|
| UUID | 128 位随机数 | 无中心、本地生成 | 无序(索引差)、太长(36字符)、不可读 |
| DB 自增 | auto_increment | 简单、有序 | 单点瓶颈、扩展难 |
| DB 号段 | 批量取一段缓存 | 减少 DB 压力、趋势递增 | 需双 buffer、DB 仍单点 |
| Redis INCR | 原子自增 | 高性能 | 依赖 Redis、持久化丢数据风险 |
| 雪花算法 | 时间戳+机器ID+序列号 | 本地生成、趋势递增、高性能 | 时钟回拨问题 |
| Leaf(美团) | 号段 + Snowflake 双模式 | 综合方案、生产可用 | 部署复杂 |
雪花算法(Snowflake)详解:
64 位 long 型 ID 结构:
| 1 bit | 41 bit | 10 bit | 12 bit |
| 符号位 | 时间戳(ms) | 机器 ID | 序列号 |
| 0 | 约 69 年 | 1024 节点 | 4096/ms |
- 41 位时间戳:从自定义纪元(如 2020-01-01)开始的毫秒数,可用约 69 年。
- 10 位机器 ID:支持 1024 个节点。实际可拆为 5 位数据中心 + 5 位机器。
- 12 位序列号:同一毫秒内可生成 4096 个 ID。
特点:本地生成无网络开销(高性能);趋势递增(利于 B+ 树索引,新数据顺序写入叶子节点,减少页分裂);64 位 long 节省存储。
时钟回拨问题: 机器时间被 NTP 同步回退,导致时间戳比上次生成时小,可能生成重复 ID。解决方案:
- 回拨小(如 <5ms):等待回拨时间过去再生成。
- 回拨大:报错拒绝生成,或切换到备份机器。
- Leaf-Snowflake 方案:用 ZK 持久化上次时间戳,每次生成前比对,回拨则等待或告警。
数据库号段模式(Leaf-Segment):
不每次生成 ID 都访问 DB,而是批量取一段(如 1000 个 ID)缓存到本地。用完再取下一段。
DB 表:
biz_tag | max_id | step | version
order | 1000 | 1000 | 1
应用取号:UPDATE leaf_alloc SET max_id = max_id + 1000, version = version + 1
WHERE biz_tag = 'order' AND version = 1;
-- 返回 max_id = 1000,应用获得 1-1000 的号段
双 buffer 优化:当前号段用到 10% 时,异步加载下一个号段到备用 buffer,切换时无停顿。
生产问题与排查:
案例一:用 UUID 做主键,写入性能随数据量增长急剧下降。根因:UUID 无序,B+ 树插入时新数据随机落到不同叶子页,导致频繁页分裂和随机 IO。修复:改用雪花算法(趋势递增),写入性能提升 3 倍。
案例二:雪花算法机器 ID 重复导致 ID 冲突。根因:手动配置机器 ID,多台机器配了相同值。修复:用 ZK 自动分配机器 ID,启动时注册临时节点获取唯一 ID。
高频追问详答:
Q1:为什么 UUID 不适合做主键?
三个原因:①无序性——InnoDB 主键是聚簇索引(B+ 树),数据按主键顺序存储。UUID 无序导致新数据随机插入到 B+ 树的任意位置,引起频繁的页分裂(页满时分裂为两页)和随机磁盘 IO,写入性能随数据量增长急剧下降。雪花算法趋势递增,新数据追加到 B+ 树末尾,顺序写入效率高。②长度——UUID 36 字符 vs bigint 8 字节,索引和存储占用 4 倍以上。③不可读——UUID 无法人工识别和排查,bigint 递增 ID 可以大致判断创建顺序。
Q2:号段模式如何扛高并发?
双 buffer 机制:应用维护两个号段 buffer,当前号段用到 10% 时,异步线程从 DB 加载下一个号段到备用 buffer。当前号段用完后无缝切换到备用 buffer,同时异步加载新的备用号段。这样 DB 访问频率从「每次生成 ID」降到「每号段一次」(如号段 1000,则每 1000 次生成才访问一次 DB),DB 压力极低。即使 DB 短暂不可用,本地缓存的号段仍能支撑一段时间。
21. 高并发下如何保证库存扣减性能与一致性
考点定位: 分桶库存、Redis 原子扣减、异步落库、热点 key、对账补偿。
解题思路: 这是秒杀场景的进阶版,重点讲如何解决单 key 热点问题(分桶库存)和多级一致性保证。
参考答案:
单纯 DB 行锁扛不住秒杀级 QPS,需要多级架构:
第一级:Redis 原子扣减(流量层)
用 Lua 脚本在 Redis 中原子判定+扣减,拦截绝大多数无效请求。
第二级:分桶库存(解决热点 key)
单个 Redis key(如 stock:goodsId)即使 Lua 原子执行,在高并发下所有请求都打到同一个 key 上,Redis 单线程处理成为瓶颈(虽然单 key 也能 10 万+ QPS,但极端秒杀可能更高)。
分桶思路:把总库存拆成 N 份,分散到 N 个 key:
stock:goodsId:0 = 10
stock:goodsId:1 = 10
stock:goodsId:2 = 10
...
stock:goodsId:9 = 10
扣减请求用 userId % N 或轮询路由到不同桶,分散到不同 key,降低单 key 热点。如果某个桶扣完,从其他桶借调(或通知用户库存不足)。
第三级:MQ 异步落库(削峰层)
Redis 扣减成功后发 MQ,消费者按 DB 承受能力消费,批量更新 DB 库存。DB 写请求被削峰到可控范围(几百 TPS)。
// Redis 扣减成功后
redis.luaDecr(stockKey);
// 发 MQ
mq.send("stock-decrease", new StockDecreaseMsg(goodsId, userId));
// 返回秒杀成功,前端轮询订单状态
第四级:DB 兜底(最终一致层)
DB 用条件 UPDATE 兜底防超卖:
UPDATE goods SET stock = stock - 1 WHERE id = #{id} AND stock > 0;
第五级:对账补偿
定时任务(如每分钟)对比 Redis 库存和 DB 库存:
- 如果 Redis < DB:说明有 Redis 扣减成功但 MQ/DB 消费失败,补偿消费或回补 Redis。
- 如果 Redis > DB:说明有 DB 扣减但 Redis 未同步(如 Redis 被刷了旧值),以 DB 为准修正 Redis。
高频追问详答:
Q1:热点 key 怎么解决?
三种方案:①分桶拆分——如上所述,把一个 key 拆成多个分散热点;②本地缓存——读多写少的场景,把热点数据缓存到应用本地(Caffeine),减少 Redis 访问;③多副本——把同一份数据写到多个 key(如 stock:goodsId:replica0/1/2),读请求随机读不同副本分散读压力(但写需要写所有副本,不适用于高频写场景)。秒杀库存是高频写,适合分桶方案。
Q2:为什么不全用 Redis 不要 DB?
Redis 的持久化(RDB/AOF)不是实时的,宕机可能丢失最近的数据。更关键的是 Redis 是内存数据库,重启后如果没有持久化或持久化不完整,库存数据就丢了。DB 是持久化的最终账本,保证数据不丢。所以 Redis 扛流量,DB 保数据,两者分工。
22. 高并发系统如何做异步化与削峰填谷
考点定位: MQ 削峰、异步解耦、消息可靠投递、消费端背压、消息积压处理。
参考答案:
异步化是高并发系统的核心手段,把同步链路上的非核心、耗时操作异步化,降低主链路 RT,提升吞吐。
MQ 削峰填谷:
前端请求只做核心校验和入队,下游按 DB 能力消费。瞬时百万 QPS 被队列平滑到 DB 可承受的几千 TPS。
请求 → 核心校验 → MQ 入队 → 立即返回(排队中)
↓
消费者按 DB 能力消费 → 创建订单 → 返回结果
↓
前端轮询/推送结果
异步解耦:
下单成功后需要触发:扣库存、加积分、发优惠券、推送通知、更新推荐。这些非核心操作不需要在主链路同步执行,发 MQ 异步处理,主链路只做核心的创建订单。
消息可靠投递(保证不丢):
生产端:
- 本地事务消息表:业务操作和消息写入同一个事务,先写业务表+消息表,再发 MQ。定时任务扫描消息表中「未确认」状态的消息重新发送。
- RocketMQ 事务消息:半消息→本地事务→commit/rollback,RocketMQ 回查机制保证最终一致。
消费端:
- 手动 ACK:消费成功才 ACK,失败重试。
- 幂等消费:用业务唯一键去重,防止重复消费。
- 死信队列:重试 N 次仍失败的消息进入死信队列,人工介入处理。
背压机制(防止积压 OOM):
消费能力不足时,MQ 积压导致内存/磁盘爆满。应对:
- 限流:生产端限流,降低入队速率。
- 拒绝策略:队列满时拒绝新消息,反压生产者。
- 临时扩容:增加消费者实例(注意消息顺序性——同一分区内顺序消费,不能简单增加消费者)。
- 降级:丢弃低优先级消息(如埋点、日志)。
高频追问详答:
Q1:MQ 消息丢失怎么办?
三个环节都可能丢:①生产端丢失——网络异常或 MQ 宕机,用确认机制(ACK)+ 本地消息表补偿;②MQ 存储丢失——Broker 宕机,用持久化(同步刷盘或主从复制);③消费端丢失——消费者拿到消息还没处理完就 ACK 了,消费者宕机消息丢失,改用手动 ACK(处理完才确认)。RocketMQ 用同步刷盘 + 主从同步双写保证不丢;Kafka 用 acks=all + min.insync.replicas 保证。
Q2:消息积压怎么处理?
紧急处理:①增加消费者实例(如果消息无顺序要求);②如果是顺序消息,增加分区数(Kafka partition / RocketMQ queue),让更多消费者并行;③检查消费者是否有慢查询(如 DB 慢 SQL),优化消费逻辑;④如果消息已过期(如秒杀已结束),直接丢弃不再消费。长期治理:监控队列深度设告警,提前扩容;消费端做好背压,积压时限流生产者。
23. 接口性能优化的通用思路
考点定位: 全链路优化视角、并行化、批量、缓存、压缩。
参考答案:
从多个维度系统性优化:
DB 层:
- 合理索引:避免全表扫描,覆盖索引避免回表。
- 避免大事务:长事务持锁影响并发,拆分小事务。
- 深分页优化:
LIMIT 1000000, 10改为游标WHERE id > lastId LIMIT 10。 - 读写分离:读走从库分散主库压力。
- 分库分表:数据量过大时分片。
缓存层:
- 多级缓存:Caffeine(本地)+ Redis(分布式),减少 DB 访问。
- 热点数据预热,避免冷启动击穿。
并行化:
多个独立调用用 CompletableFuture 并行,串行改并行:
// 串行:4 个调用各 100ms = 400ms
User user = userService.getUser(id); // 100ms
Order order = orderService.getOrder(id); // 100ms
Address addr = addressService.getAddress(id); // 100ms
Credit credit = creditService.getCredit(id); // 100ms
// 总计 400ms
// 并行:4 个调用并行 = 100ms
CompletableFuture<User> f1 = CompletableFuture.supplyAsync(
() -> userService.getUser(id), executor);
CompletableFuture<Order> f2 = CompletableFuture.supplyAsync(
() -> orderService.getOrder(id), executor);
CompletableFuture<Address> f3 = CompletableFuture.supplyAsync(
() -> addressService.getAddress(id), executor);
CompletableFuture<Credit> f4 = CompletableFuture.supplyAsync(
() -> creditService.getCredit(id), executor);
CompletableFuture.allOf(f1, f2, f3, f4).join();
// 总计 100ms(取最慢的一个)
注意:CompletableFuture 默认用 ForkJoinPool.commonPool,不适合 IO 密集任务(commonPool 大小为 CPU 核数-1)。必须传自定义线程池。
批量与合并:
- 循环单条查询改批量:
SELECT * FROM t WHERE id IN (?, ?, ?)而非循环SELECT * FROM t WHERE id = ?。 - 多次 RPC 合并为一次批量接口。
数据压缩:
- 响应 Gzip 压缩,减少网络传输。
- Protobuf 替代 JSON,序列化更快、体积更小。
连接池调优:
- DB 连接池(HikariCP):maximumPoolSize 根据
连接数 = (核心数 * 2 + 有效磁盘数)公式,配合压测调优。 - HTTP 连接池:maxTotal、maxPerRoute 根据下游服务能力设置。
- Redis 连接池:maxActive 根据并发量设置。
高频追问详答:
Q1:深分页为什么慢?怎么优化?
LIMIT 1000000, 10 要扫描前 100 万行再丢弃,只返回最后 10 行,浪费大量 IO。优化方案:①游标分页 WHERE id > #{lastId} ORDER BY id LIMIT 10,利用索引直接定位,不扫描前面的行;②如果必须按非主键排序,用覆盖索引 + JOIN:先查 SELECT id FROM t ORDER BY create_time LIMIT 1000000, 10(覆盖索引不回表),再 SELECT * FROM t JOIN (上面子查询) tmp ON t.id = tmp.id;③业务上限制最大翻页深度(如淘宝只允许翻前 100 页)。
Q2:缓存与 DB 一致性怎么保证?
详见第 17 题。核心方案:延迟双删(先删缓存→更新 DB→延迟再删缓存)或 binlog 订阅(Canal 异步刷缓存)。不追求强一致,保证最终一致即可。
五、JMM 与并发理论
24. Java 内存模型(JMM)解决什么问题
考点定位: 主内存与工作内存、三大特性、happens-before、as-if-serial。
参考答案:
JMM(Java Memory Model)是一种抽象规范,定义了多线程下变量的访问规则,屏蔽底层硬件(CPU 缓存、寄存器、内存屏障)的差异,使 Java 程序在不同平台下有一致的并发行为。
JMM 规定:所有共享变量存储在主内存(物理内存的抽象),每个线程有自己的工作内存(CPU 缓存和寄存器的抽象)。线程对变量的操作必须在工作内存中进行——先从主内存读取到工作内存,修改后写回主内存。不同线程之间无法直接访问对方的工作内存,线程间通信必须通过主内存。
JMM 解决三大并发问题:
原子性: 基本数据类型的读取和赋值是原子的(除 long/double 在 32 位 JVM 上可能分两次读写)。更大范围的原子性靠 synchronized 或 Lock 保证(monitorenter/monitorexit 保证临界区原子执行)。
可见性: 一个线程修改了共享变量,其他线程能立即看到。volatile、synchronized(unlock 前刷回主内存)、final(构造完成后对其他线程可见)保证可见性。
有序性: 编译器和 CPU 可能重排指令优化性能,但在多线程下可能导致错误。volatile(内存屏障禁止重排)、synchronized(临界区内重排不影响语义)保证有序性。
JMM 的核心设计哲学:在「性能」和「正确性」之间平衡。不强制强一致(每次都读写主内存太慢),允许线程在本地缓存中操作,但提供 happens-before 规则让程序员推断跨线程的可见性。
高频追问详答:
Q1:工作内存和 CPU 缓存的关系?
JMM 的工作内存是对 CPU 寄存器、L1/L2/L3 缓存的抽象,不一一对应。JMM 是语言层面的规范,CPU 缓存是硬件层面的实现。JMM 规定线程读变量时「从主内存读」,实际可能从 CPU 缓存读(如果缓存有效);volatile 写「刷回主内存」,实际是 flush CPU 缓存行到内存并通过 MESI 协议使其他 CPU 缓存失效。JMM 把这些硬件细节抽象为统一的内存屏障语义。
Q2:as-if-serial 是什么?
as-if-serial 语义:编译器和 CPU 可以重排指令,但前提是重排后单线程执行结果不变。例如 int a = 1; int b = 2; int c = a + b;,a 和 b 的赋值可以重排(无依赖),但 c = a + b 不能重到 a/b 赋值前(有数据依赖)。as-if-serial 保证了单线程程序的正确性,但多线程下重排可能导致问题——这就是需要 happens-before 规则和 volatile/synchronized 的原因。
25. happens-before 原则有哪些规则
考点定位: 先行发生关系、八大规则、传递性推导。
参考答案:
happens-before 是 JMM 的核心原则。如果操作 A happens-before 操作 B,那么 A 的执行结果对 B 可见,且 A 的执行顺序排在 B 之前(注意:这是语义偏序关系,实际执行可能重排,但结果等价于 A 先于 B)。
八大规则:
- 程序顺序规则:单线程内,代码按书写顺序前面的操作 happens-before 后面的操作(as-if-serial 保证)。
- 监视器锁规则:对一个锁的 unlock 操作 happens-before 后续对同一把锁的 lock 操作。
- volatile 变量规则:对一个 volatile 变量的写操作 happens-before 后续对它的读操作。
- 线程启动规则:
Thread.start()happens-before 该线程内的所有操作(主线程在 start 前的修改对子线程可见)。 - 线程终止规则:线程内的所有操作 happens-before
Thread.join()返回(子线程的修改在 join 后对主线程可见)。 - 线程中断规则:
Thread.interrupt()调用 happens-before 被中断线程检测到中断(isInterrupted()或 InterruptedException)。 - 对象终结规则:对象的构造函数执行完毕 happens-before 它的 finalize 方法。
- 传递性:如果 A happens-before B,且 B happens-before C,则 A happens-before C。
利用传递性推导可见性:
int a = 0; // 普通变量
volatile boolean flag = false; // volatile 变量
// 线程 A
a = 1; // (1) 普通写
flag = true; // (2) volatile 写
// 线程 B
if (flag) { // (3) volatile 读
int b = a; // (4) 普通读,b 一定等于 1
}
推导链:(1) happens-before (2)(程序顺序),(2) happens-before (3)(volatile 规则),(3) happens-before (4)(程序顺序),由传递性得 (1) happens-before (4),所以 a=1 对 B 可见。这就是「安全发布」的基础——用 volatile 变量做标志,确保它之前的所有写入对读它的线程可见。
高频追问详答:
Q1:happens-before 是时间上的先后顺序吗?
不是。happens-before 是一种偏序关系,定义的是可见性和语义顺序,不是实际执行时间。例如 a = 1; b = 2; 中 a=1 happens-before b=2,但 CPU 可能先执行 b=2 再执行 a=1(重排),只要结果等价。happens-before 保证的是「a=1 的结果对 b=2 之后的操作可见」,而非「a=1 先执行」。
Q2:volatile 怎么用 happens-before 解释?
volatile 写 happens-before 后续 volatile 读。配合传递性,volatile 写之前的所有操作(无论是 volatile 还是普通变量)都对 volatile 读之后的操作可见。这就是 volatile 除了可见性之外的「发布」语义——它像一扇门,门前的所有写入在门打开(volatile 读读到新值)后对门外可见。
六、数据库并发
26. MySQL 事务的四种隔离级别及解决的问题
考点定位: 脏读/不可重复读/幻读、各级别、MySQL RR 如何解决幻读、为什么大厂用 RC。
参考答案:
三种并发问题:
- 脏读:事务 A 读到了事务 B 未提交的数据。如果 B 回滚,A 读到的是不存在的数据。
- 不可重复读:事务 A 两次读同一行,值被事务 B 提交修改改变。关注的是同一行的 update。
- 幻读:事务 A 两次范围查询,结果集行数被事务 B 的 insert/delete 改变。关注的是新增/删除行。
四个隔离级别(隔离性递增,性能递减):
| 级别 | 脏读 | 不可重复读 | 幻读 |
|---|---|---|---|
| 读未提交 RU | 可能 | 可能 | 可能 |
| 读已提交 RC | 避免 | 可能 | 可能 |
| 可重复读 RR(MySQL 默认) | 避免 | 避免 | 避免* |
| 串行化 Serializable | 避免 | 避免 | 避免 |
MySQL InnoDB 默认 RR,且通过 MVCC + Next-Key Lock 在 RR 级别也基本解决幻读(带 * 号表示特殊情况下仍可能出现)。
高频追问详答:
Q1:MySQL RR 怎么解决幻读?
两种机制配合:
- 快照读(普通 SELECT):通过 MVCC,事务首次 SELECT 时生成 ReadView,之后复用,看到的是事务开始时的快照数据。其他事务 insert 的新行对本事务不可见,不会出现幻行。
- 当前读(SELECT FOR UPDATE / UPDATE / DELETE):通过 Next-Key Lock(行锁 + 间隙锁)锁住查询范围,其他事务无法在范围内插入新行,阻止幻行产生。
仍可能出现幻读的特殊情况:事务内先快照读(生成 ReadView),然后执行当前读(如 UPDATE),UPDATE 后再快照读——此时读到的数据可能包含新插入的行(因为 UPDATE 是当前读,更新后该行对当前事务可见)。或者事务内先快照读再执行了 INSERT 自己插入了一行,再查时看到了自己插入的行。
Q2:为什么大厂常把隔离级别调成 RC?
三个原因:①锁粒度更小——RC 不加间隙锁(Gap Lock),只锁行,并发度更高,死锁更少;②RR 的间隙锁在复杂查询下可能锁住大范围,导致其他事务 insert 被阻塞,并发性能下降;③幻读风险可由业务层兜底——唯一索引保证不重复插入,业务逻辑做幂等。权衡之下,大厂(如阿里、美团)更倾向 RC,牺牲一点一致性换取更高并发和更少死锁。
Q3:RC 和 RR 的 MVCC 区别?
RC:每次 SELECT 都生成新的 ReadView,所以能看到其他事务在每次查询之间提交的修改(不可重复读)。RR:事务首次 SELECT 生成 ReadView,之后整个事务复用,只看到事务开始时的快照(可重复读)。这就是两个隔离级别在 MVCC 实现上的唯一差异。
27. MVCC 原理是什么
考点定位: 版本链、undo log、ReadView 结构、可见性判断算法、当前读与快照读。
参考答案:
MVCC(Multi-Version Concurrency Control)让读操作不加锁也能读到一致性快照,提升读写并发性能。InnoDB 实现依赖三个要素:
要素一:隐藏列。 每行记录有两个隐藏字段:
trx_id:最后修改该行的事务 ID。roll_pointer:指向 undo log 中该行的上一个版本。
要素二:undo log 版本链。 每次 UPDATE 都生成 undo log,记录修改前的值。多个 undo log 通过 roll_pointer 串联成版本链:
当前行: trx_id=300, data="v3" --roll_pointer--> undo_log
undo_log1: trx_id=200, data="v2" --roll_pointer--> undo_log2
undo_log2: trx_id=100, data="v1" (初始版本)
要素三:ReadView。 事务执行快照读时生成,包含:
m_ids:生成 ReadView 时当前活跃(未提交)的事务 ID 列表。min_trx_id:m_ids 中最小值。max_trx_id:下一个将要分配的事务 ID(即当前最大事务 ID + 1)。creator_trx_id:创建 ReadView 的事务 ID。
可见性判断算法(沿版本链查找):
对当前行的 trx_id 进行判断:
trx_id == creator_trx_id:自己修改的行,可见。trx_id < min_trx_id:该事务在 ReadView 生成前已提交,可见。trx_id >= max_trx_id:该事务在 ReadView 生成后才启动,不可见。沿 roll_pointer 找上一个版本继续判断。min_trx_id ≤ trx_id < max_trx_id:- 如果 trx_id 在 m_ids 中:该事务在 ReadView 生成时还未提交,不可见。沿 roll_pointer 找上一个版本。
- 如果 trx_id 不在 m_ids 中:该事务已提交,可见。
找到第一个可见版本后返回其数据。
RC vs RR 的差异:RC 每次 SELECT 生成新 ReadView(所以能看到最新已提交数据);RR 事务首次 SELECT 生成 ReadView 后复用(所以只看快照数据)。
高频追问详答:
Q1:当前读和快照读区别?
快照读(普通 SELECT)读 MVCC 版本链中的可见版本,不加锁。当前读(SELECT FOR UPDATE / SELECT LOCK IN SHARE MODE / UPDATE / DELETE)读最新已提交数据,并加锁(记录锁或 Next-Key Lock)。当前读读的是最新版本而非快照版本,所以 RR 下当前读能看到其他事务已提交的修改(这是 RR 下仍可能出现幻读的原因之一)。
Q2:MVCC 解决了什么问题?没解决什么?
解决了读写冲突——读不需要加锁,读-写不互相阻塞,大幅提升并发性能。没解决写写冲突——两个事务同时 UPDATE 同一行,仍需要行锁互斥。所以 MVCC 不是万能的,写密集场景仍依赖锁机制。
28. MySQL 的锁机制
考点定位: 行锁、间隙锁、Next-Key Lock、意向锁、死锁检测。
参考答案:
InnoDB 行级锁基于索引实现(如果查询没走索引会退化为表锁):
锁类型:
- 记录锁 Record Lock:锁单条索引记录,防止其他事务修改或删除该行。
- 间隙锁 Gap Lock:锁两个索引记录之间的范围(不含记录本身),防止其他事务在范围内插入新行。只在 RR 级别存在。
- 临键锁 Next-Key Lock:Record Lock + Gap Lock,锁左开右闭区间
(前一条记录, 当前记录]。RR 下默认加这种锁。 - 插入意向锁 Insert Intention Lock:insert 操作加的间隙锁,表示「我打算在这个间隙插入」。如果多个事务插入不同位置,不互相阻塞。
- 意向锁 IS/IX:表级锁,表示表内有行级 S/X 锁。作用是快速判断表锁与行锁是否冲突,不用逐行检查。
加锁规则(RR 下):
- 唯一索引等值查询命中记录:加 Record Lock(退化为记录锁,不加间隙锁)。
- 唯一索引等值查询未命中:加 Next-Key Lock,退化为 Gap Lock(锁住查询值所在间隙)。
- 非唯一索引等值查询:加 Next-Key Lock + 下一个间隙的 Gap Lock(因为非唯一可能有多个相同值,需要锁住后续间隙防止插入相同值)。
- 范围查询:加 Next-Key Lock 锁住范围内的所有间隙和记录。
- 无索引查询:锁全表所有间隙(相当于锁表)。
死锁与排查:
死锁是两个事务互相持有对方需要的锁,形成等待环。InnoDB 有死锁检测机制(wait-for graph),检测到死锁时回滚代价较小的事务(undo 量较少的那个)。
排查方法:SHOW ENGINE INNODB STATUS 查看最近一次死锁信息,包含两个事务持有的锁和等待的锁。用 SET GLOBAL innodb_print_all_deadlocks = ON 让所有死锁都记录到 error log。
规避方法:①固定加锁顺序(如按主键排序后加锁);②事务尽量短小,减少持锁时间;③降低隔离级别(RC 不加间隙锁,死锁更少);④用 SELECT ... FOR UPDATE NOWAIT 或 SKIP LOCKED 避免长时间等待。
高频追问详答:
Q1:间隙锁在 RC 下有吗?
没有。RC 隔离级别不加 Gap Lock,只加 Record Lock。所以 RC 下幻读无法通过锁机制解决(靠 MVCC 快照读避免,但当前读仍可能有幻行)。这也意味着 RC 的锁冲突更少,并发更高,死锁更少。
Q2:没走索引会怎样?
InnoDB 的行锁基于索引实现。如果查询条件没走索引(如 WHERE name = '张三' 且 name 无索引),InnoDB 无法定位具体行,只能对所有行加锁(实际是锁聚簇索引的所有记录 + 所有间隙),效果等同于锁表。生产中必须确保更新/删除语句走索引,否则会锁表导致整个系统卡住。
29. 分库分表的方案与问题
考点定位: 垂直/水平拆分、分片策略、跨库 JOIN、分布式事务、迁移。
参考答案:
拆分方式:
- 垂直分库:按业务域拆分(用户库、订单库、商品库),解耦业务,独立扩展。
- 垂直分表:按列拆分,热字段和冷字段分离(如商品基础信息表 + 商品详情表,详情大字段单独存储)。
- 水平分表/分库:按 sharding key 把同一张表的数据分散到多个表/库,解决单表数据量过大的性能瓶颈。
分片策略:
- 范围分片:按 ID 或时间范围(如 ID 1-1000 万在表 0,1000 万-2000 万在表 1)。优点:易扩容(新增表接续范围)。缺点:易热点(最新数据集中在最后一个表,访问量大)。
- Hash 分片:
sharding_key % N(如userId % 16)。优点:数据均匀。缺点:扩容需要 rehash,数据迁移量大。 - 一致性 Hash:节点在 hash 环上,数据顺时针找到第一个节点。扩容只迁移相邻段数据。需虚拟节点解决倾斜。
带来的问题与解决方案:
- 跨库 JOIN:业务层组装(分别查再内存关联)、冗余字段(常用关联字段冗余到各表)、Elasticsearch 宽表(异构索引)。
- 分布式事务:Seata AT(无侵入,最终一致)、TCC(业务侵入,强一致)、本地消息表(最终一致,最常用)、Saga(长事务补偿)。
- 跨库分页排序:每个分片查 N 条,合并后全局排序取前 N。深分页性能差(每个分片查 offset+N),需限制最大翻页或用游标。
- 全局唯一 ID:雪花算法或号段(见第 20 题)。
- 路由与扩容:用 ShardingSphere 或 MyCat 中间件管理分片规则。
平滑迁移方案(双写灰度):
- 新库新表搭建好,应用层双写(同时写旧库和新库),读仍走旧库。
- 数据同步工具(如 DataX)把旧库存量数据同步到新库。
- 开启双写后的增量数据校验对齐(对比新旧库数据一致性)。
- 灰度切读:10% 流量读新库,观察无异常后逐步放大到 100%。
- 下线旧库,清理双写代码。
高频追问详答:
Q1:什么时候该分库分表?
经验阈值:单表数据超过 1000 万行或单表大小超过 10GB,查询性能开始下降(B+ 树层级增加、索引内存占用增大、磁盘 IO 增加)。但不是唯一标准——如果查询都能走索引且 RT 可接受,1000 万也能用。真正触发分表的信号:①DB CPU/IO 持续高水位;②慢查询增多且无法通过索引优化;③单表数据量导致 DDL(如加索引)耗时过长影响业务。先做垂直优化(索引优化、SQL 优化、读写分离、加缓存),再考虑分表。
Q2:一致性 Hash 解决什么?
普通 hash 分片 key % N 在 N 变化时(如扩容从 16 到 32 分片),几乎所有数据都要迁移。一致性 hash 把节点和数据都映射到 hash 环上,数据顺时针找到第一个节点。扩容时新节点只影响环上相邻段的数据,迁移量小。但原始一致性 hash 可能数据倾斜(节点分布不均),用虚拟节点(每个物理节点映射多个虚拟节点)解决。
Q3:分布式事务有哪些方案?
| 方案 | 一致性 | 性能 | 侵入性 | 适用场景 |
|---|---|---|---|---|
| 2PC | 强 | 低 | 低 | 数据库层面 |
| TCC | 强 | 中 | 高(需写 try/confirm/cancel) | 金融 |
| Saga | 最终 | 高 | 中(需写补偿) | 长流程事务 |
| 本地消息表 | 最终 | 高 | 低 | 互联网最常用 |
| Seata AT | 最终 | 中 | 极低(自动代理) | 通用 |
本地消息表方案最常用:业务操作和消息写入同一个本地事务,消息表记录发送状态,定时任务扫描未发送的消息补偿发送。下游消费幂等,保证最终一致。
附录:高并发面试高频知识点速记
| 知识点 | 要点 |
|---|---|
| HashMap 扩容 | JDK7 头插法并发死循环,JDK8 尾插法不死循环但仍丢数据 |
| fail-fast | ArrayList 迭代时修改抛异常,多线程用 CopyOnWriteArrayList |
| Thread.sleep(0) | 触发 CPU 重新调度,让出时间片 |
| ThreadLocalRandom | 替代 Random,避免 CAS 竞争 |
| ForkJoinPool | 分治 + work-stealing,并行流底层 |
| CompletableFuture | 替代 Future,支持编排回调,注意指定自定义线程池 |
| CAS 自适应自旋 | JVM 根据成功率动态调整自旋次数 |
| AQS 独占/共享 | ReentrantLock 独占,Semaphore/CountDownLatch 共享 |
| BlockingQueue | ArrayBQ 有界、LinkedBQ 默认无界、SynchronousQ 直接交付 |
| 锁粗化/锁消除 | JIT 优化,合并循环内锁 / 消除无竞争锁 |
复习建议:原理类题目先理解「为什么这么设计」再记结论。高并发场景题要建立「分层削峰、最终一致、多层兜底」的系统性思维——能把单一方案讲清楚,再讲清楚方案之间的取舍和选型理由,以及出了生产问题如何排查,就是大厂 offer 级别的回答。