面试不是背书比赛,是证明你脑子里有结构。
这篇文章把大厂 Java 后端面试最常问的 31 道题按 6 个板块拆开,每题都写三件事:
- 面试官到底在考什么(他想要的不是定义,是你的判断)
- 参考答案(能背下来就能答,含代码和配置)
- 追问与加分项(决定你是 60 分还是 85 分的地方)
全文约 3 万字,建议先收藏,面试前一周按板块刷。
目录
| 板块 | 题号 | 高频程度 |
|---|---|---|
| 一、Java 基础 | Q1–Q6 | ★★★★★ 必问 |
| 二、框架(Spring 全家桶) | Q7–Q12 | ★★★★★ 必问 |
| 三、事务 | Q13–Q16 | ★★★★☆ |
| 四、中间件 | Q17–Q21 | ★★★★☆ |
| 五、数据库与缓存 | Q22–Q26 | ★★★★★ 必问 |
| 六、高并发 | Q27–Q31 | ★★★★☆ |
| 七、答题框架与复盘清单 | — | 送给不会「表达」的你 |

图 1:6 大板块不是 6 个孤岛。面试官真正的套路是「用缓存击穿引出限流,再回到一致性」——所以每答一题,主动往相邻板块勾一句,分数立刻不一样。
一、Java 基础(6 题)
Q1:HashMap 的容量为什么必须是 2 的幂?扩容时发生了什么?
面试官在考什么:你是「看过八股」还是「想过为什么」。
参考答案
① 2 的幂是为了用位运算替代取模。 定位数组下标时,(n - 1) & hash 只有在 n 是 2 的幂时才等价于 hash % n(此时 n-1 的二进制全是低位 1,与运算相当于保留低 log₂n 位)。位运算比取模快一个数量级。
即使构造时传了 new HashMap(17),内部也会通过 tableSizeFor() 向上取到 32。
② 扩容时元素只有两种去处,不需要重新计算 hash。
// JDK 8 resize() 的核心逻辑
if ((e.hash & oldCap) == 0) {
// 低位链:留在原位置 j
} else {
// 高位链:移动到 j + oldCap
}
因为容量翻倍后,参与取模的位数只多了一位,这一位来自 hash & oldCap。所以一个桶里的元素要么原地不动,要么整体后移 oldCap——这就是 JDK 8 不用重新 hash 的原因。
③ JDK 7 与 JDK 8 的扩容差异(这是隐藏考点)
| 维度 | JDK 7 | JDK 8 |
|---|---|---|
| 数据结构 | 数组 + 链表 | 数组 + 链表 + 红黑树 |
| 插入方式 | 头插法 | 尾插法 |
| 并发扩容后果 | 形成环形链表,get 时死循环 CPU 100% | 不会成环,但会丢数据、size 错乱 |
| hash 扰动 | 4 次移位异或 | 1 次 h ^ (h >>> 16) |
头插法在并发扩容时,两个线程同时 transfer,链表指针互相指向对方,就成了环。JDK 8 改尾插解决了这个问题,但 HashMap 依然不是线程安全的——多线程 put 依然会互相覆盖。并发场景请用 ConcurrentHashMap。
④ 几个常被追问的常量
- 默认容量 16、负载因子 0.75:0.75 是空间与冲突概率的折中(源码注释里用泊松分布说明,λ=0.5 时链表长度到 8 的概率约 6×10⁻⁸)。
- 树化阈值 8、退化阈值 6:留一个缓冲区,避免长度在 7/8 之间反复转换。
MIN_TREEIFY_CAPACITY = 64:链表长度到 8 但数组容量不足 64 时,优先扩容而不是树化,因为小表扩容更划算。
追问:为什么 key 和 value 都允许为 null? HashMap 允许(null key 固定在 0 号桶),因为单线程场景没有歧义。而 ConcurrentHashMap 不允许——get 返回 null 时你无法区分「key 不存在」和「value 就是 null」,在并发下 containsKey 再 get 也有竞态,所以干脆禁止。
Q2:ConcurrentHashMap 在 JDK 8 里是怎么保证线程安全的?get 为什么不用加锁?
参考答案
① 从分段锁到「CAS + synchronized 锁桶头」。
JDK 7 用 Segment 分段锁(继承 ReentrantLock,默认 16 段,并发度 16,扩容只能整段扩)。JDK 8 放弃分段,直接把锁粒度降到单个桶的头节点:
// putVal 的核心分支(简化)
if (casTabAt(tab, i, null, new Node<>(hash, key, value, null))) {
// 桶为空:CAS 无锁写入
} else {
synchronized (f) { // f = tabAt(tab, i),只锁这一个桶
// 链表遍历 / 红黑树插入
}
}
f 的 hash 值决定桶的类型:>= 0 是普通链表节点;-1 是 ForwardingNode(正在扩容,当前线程去帮忙迁移);-2 是 TreeBin;-3 是 ReservationNode(computeIfAbsent 占位)。
② get 不加锁,靠 volatile 保证可见性。
static class Node<K,V> implements Map.Entry<K,V> {
final int hash;
final K key;
volatile V val; // 值可见
volatile Node<K,V> next; // 链可见
}
数组本身用 volatile Node<K,V>[] table + Unsafe.getObjectVolatile 读,保证你读到的是已经发布完成的节点。因为没有「修改结构同时读」的强一致需求,get 天然是无锁的。
③ size 用类似 LongAdder 的分段计数。
baseCount + CounterCell[]:无竞争时 CAS 累加 baseCount,有竞争时按线程探针散列到不同的 CounterCell,最后 sum() 汇总。
注意:size() 返回的是弱一致值,并发写入时可能不准。所以别用 size() == 0 判断集合空(应该用 isEmpty(),它用 sumCount() <= 0L 判断)。
④ 扩容是多线程协作的。
transfer() 按步长(默认 16)划分任务,每个线程领一段迁移,迁完的桶放 ForwardingNode 做占位标记,其他线程看到就跳过或加入帮忙。这也是为什么 helpTransfer 能被 put/get 直接触发。
追问:为什么 JDK 8 的锁粒度更小反而不用 ReentrantLock 了? 因为锁的只是「一个桶」,临界区极短(几次指针操作),synchronized 有偏向锁/轻量级锁的优化,且不用手动释放,比 ReentrantLock 更轻。锁的粒度足够细时,JVM 内置锁的性能优势就体现出来了——这句话本身就是加分项。
Q3:线程池七个参数是什么?为什么是先入队再扩到最大线程数?
参考答案
public ThreadPoolExecutor(
int corePoolSize, // 1 核心线程数(常驻,allowCoreThreadTimeOut 可回收)
int maximumPoolSize, // 2 最大线程数
long keepAliveTime, // 3 非核心线程空闲存活时间
TimeUnit unit, // 4 时间单位
BlockingQueue<Runnable> workQueue, // 5 任务队列
ThreadFactory threadFactory, // 6 线程工厂(一定要自定义,方便排查)
RejectedExecutionHandler handler // 7 拒绝策略
)
执行流程(必须按顺序说):
- 线程数 < core → 新建核心线程执行
- 线程数 ≥ core → 任务入队
- 队满 → 线程数 < max 则新建非核心线程
- 队列满且线程数 = max → 触发拒绝策略
为什么第 2 步先入队而不是直接扩线程? 创建线程的成本(栈内存 1MB、内核调度)远高于把任务放进队列。设计意图是:突发流量先用队列缓冲,队列都撑不住了才说明这是持续高负载,才值得扩线程。
代价是:如果用了无界队列(LinkedBlockingQueue 默认 Integer.MAX_VALUE),maximumPoolSize 永远不生效,任务无限堆积直到 OOM。
这也是阿里规范禁止 Executors 的原因:
| 方法 | 隐患 |
|---|---|
newFixedThreadPool | 无界队列,OOM |
newSingleThreadExecutor | 同上 |
newCachedThreadPool | 最大线程数 Integer.MAX_VALUE,可能创建海量线程 |
newScheduledThreadPool | 同上 |
正确姿势:
ThreadPoolExecutor pool = new ThreadPoolExecutor(
16, 32, 60L, TimeUnit.SECONDS,
new ArrayBlockingQueue<>(2000), // 有界
new ThreadFactoryBuilder().setNameFormat("order-pool-%d").build(),
new ThreadPoolExecutor.CallerRunsPolicy() // 背压,让调用方变慢
);
线程数怎么定
- CPU 密集:
N + 1(N 为核数) - IO 密集:
N × (1 + 等待时间 / 计算时间),经验值2N - 最终答案永远是:先按公式给初值,再用压测找拐点。看三个指标:CPU 利用率、队列积压、RT 的 P99。
追问:拒绝策略有几种? AbortPolicy(默认,抛异常)、CallerRunsPolicy(调用线程执行,天然背压)、DiscardPolicy(静默丢弃)、DiscardOldestPolicy(丢最老的),以及自定义(记录日志 + 落库 + 降级返回)。
线上推荐 CallerRunsPolicy 或自定义,绝对不要用静默丢弃——你连丢了多少都不知道。
Q4:synchronized 的锁升级过程?和 ReentrantLock 怎么选?
参考答案
锁存在对象头的 Mark Word 里(64 位 JVM 下 8 字节),状态用低 2~3 位标记:
| 状态 | 标记位 | 说明 |
|---|---|---|
| 无锁 | 001 | 普通对象 |
| 偏向锁 | 101 | Mark Word 记录线程 ID,同一线程重入零成本 |
| 轻量级锁 | 00 | 指向栈帧中的 Lock Record,CAS 自旋 |
| 重量级锁 | 10 | 指向 ObjectMonitor,涉及内核态阻塞 |
| GC 标记 | 11 | — |
升级路径:无锁 → 偏向锁 → 轻量级锁 → 重量级锁,只能升不能降。
- 偏向锁:第一个线程 CAS 写入自己的 ID,之后同一线程进来只比较 ID,连 CAS 都省了。发生竞争时撤销(需要 STW 到 safepoint),所以「高竞争场景偏向锁反而是负担」——JDK 15 起默认关闭(JEP 374),JDK 18 彻底废弃。
- 轻量级锁:竞争时 CAS 把 Mark Word 换成指向 Lock Record 的指针,失败则自旋(自适应自旋)。
- 重量级锁:自旋失败后膨胀为
ObjectMonitor,进入EntryList等待,涉及用户态/内核态切换。
对比表
| 维度 | synchronized | ReentrantLock |
|---|---|---|
| 实现层 | JVM 字节码 monitorenter/monitorexit | JDK 层 AQS(state + CLH 队列) |
| 释放 | 自动(异常也会释放) | 必须 finally 手动 unlock |
| 可中断 | ❌ | ✅ lockInterruptibly() |
| 超时 | ❌ | ✅ tryLock(3, SECONDS) |
| 公平锁 | ❌ 非公平 | ✅ 可选 |
| 条件变量 | 一个 wait/notify | 多个 Condition,可精确唤醒 |
| 锁信息可见性 | jstack 可见 | 需看 AQS 状态 |
怎么选:能不用锁就不用(无状态设计、CAS、ThreadLocal);必须用优先 synchronized(简单、不会忘记释放);需要超时/可中断/多条件队列/公平锁时用 ReentrantLock。
追问(2026 年的新加分项):虚拟线程下 synchronized 会怎样? 在 JDK 21~23 的虚拟线程里,synchronized 块会让虚拟线程 pin 住载体线程(carrier thread),无法卸载,吞吐骤降。JDK 24 的 JEP 491 已经解决了这个问题(synchronized 不再 pin)。但在 JDK 21 LTS 上做虚拟线程项目,必须把 synchronized 换成 ReentrantLock——这一条能直接区分「用过虚拟线程」和「只背过八股」。
Q5:volatile 保证了什么?DCL 单例为什么必须加 volatile?
参考答案
两个语义 + 一个不保证:
- 可见性:写操作立即刷回主存并使其他 CPU 缓存行失效(MESI 协议),读操作直接从主存读。
- 有序性:通过 4 种内存屏障禁止指令重排(LoadLoad / StoreStore / LoadStore / StoreLoad)。
- 不保证原子性:
i++是「读-改-写」三步,volatile int i; i++依然线程不安全,要用AtomicInteger或加锁。
DCL 单例的标准写法:
public class Singleton {
private static volatile Singleton instance; // volatile 不能少
public static Singleton getInstance() {
if (instance == null) { // 第一次检查:避免每次都加锁
synchronized (Singleton.class) {
if (instance == null) { // 第二次检查:防止重复创建
instance = new Singleton();
}
}
}
return instance;
}
}
为什么必须 volatile:new Singleton() 在字节码层面是三步:
memory = allocate(); // 1. 分配内存
ctorInstance(memory); // 2. 初始化对象
instance = memory; // 3. 引用指向内存
2 和 3 之间没有数据依赖,可以被重排。重排后另一个线程可能在第 3 步之后、第 2 步之前进入,看到 instance != null 就直接返回——拿到一个字段全是默认值的半成品对象。加 volatile 后,写操作前插入 StoreStore 屏障,禁止这个重排。
追问:happens-before 有几条? 八条:程序顺序、监视器锁、volatile 变量、线程启动(start())、线程终止(join())、线程中断、对象终结、传递性。答题时挑 3~4 条最常用的说清楚即可,别说成「八股朗诵」。
追问:volatile 数组能让每个元素都可见吗? 不能。volatile int[] arr 只保证引用的可见性,arr[0]++ 依然不安全。
Q6:JVM 内存结构?一次 Full GC 频繁,你怎么排查?
参考答案
① 运行时数据区
| 区域 | 线程 | 存什么 | 异常 |
|---|---|---|---|
| 程序计数器 | 私有 | 当前字节码行号 | 唯一不会 OOM 的区域 |
| 虚拟机栈 | 私有 | 栈帧(局部变量表、操作数栈) | StackOverflowError / OOM |
| 本地方法栈 | 私有 | native 方法 | 同上 |
| 堆 | 共享 | 对象实例、数组 | OutOfMemoryError: Java heap space |
| 方法区(元空间) | 共享 | 类元信息、常量、静态变量 | Metaspace OOM |
| 直接内存 | 共享 | NIO DirectByteBuffer | OutOfMemoryError: Direct buffer memory |
JDK 8 起方法区由元空间实现,从堆移到本地内存,受 -XX:MaxMetaspaceSize 限制。
② 对象存活判定 可达性分析,GC Roots 包括:虚拟机栈中的引用、方法区静态属性、常量引用、JNI 引用、活跃线程、同步锁对象等。不是引用计数——引用计数解决不了循环引用。
③ 分代与回收器
- 新生代:Eden : S0 : S1 = 8 : 1 : 1,复制算法,对象年龄到 15 晋升老年代(
-XX:MaxTenuringThreshold)。 - 老年代:标记-整理 / 标记-清除。
- G1(JDK 9 起默认):堆切成 ~2048 个 Region(1~32MB),逻辑上仍有 Eden/Survivor/Old/Humongous;用 Remembered Set 记录跨 Region 引用避免全堆扫描;用 SATB 快照在并发标记阶段保证不漏标;按「回收收益」排序,优先回收价值最高的 Region(Garbage First 的名字来源),通过
-XX:MaxGCPauseMillis控制停顿目标。
④ 排查 Full GC 的六步
# 1. 看趋势:FGC 次数、耗时、各代占用
jstat -gcutil <pid> 1000
# 2. 看堆内对象分布,找"谁在涨"
jmap -histo:live <pid> | head -30
# 3. dump 下来分析(注意 live 会触发一次 FGC)
jmap -dump:live,format=b,file=/tmp/heap.hprof <pid>
# 4. 线程栈,看是不是死锁/大量线程阻塞
jstack <pid> > /tmp/thread.txt
# 5. GC 日志(JDK 9+ 统一日志)
-Xlog:gc*:file=/var/log/gc.log:time,level,tags:filecount=5,filesize=50m
# 6. MAT / JProfiler 分析支配树、Leak Suspects
常见根因(按出现频率排序):
| 根因 | 特征 | 处理 |
|---|---|---|
| 内存泄漏(静态集合、ThreadLocal、监听器未注销) | 老年代持续上涨,FGC 后降不下来 | MAT 找 GC Root 引用链 |
| 大对象 / 超大数组 | Histogram 里 byte[] 占比极高 | 分页查询、限制附件大小 |
| 元空间泄漏(动态生成类、热部署) | Metaspace 持续增长 | 设 MaxMetaspaceSize,修 ClassLoader 泄漏 |
| 晋升过快(新生代太小 / 大对象直接进老年代) | YGC 频繁且大量晋升 | 调大新生代、-XX:PretenureSizeThreshold |
显式 System.gc()(RMI、DirectByteBuffer 回收) | 日志里出现 System.gc() | -XX:+DisableExplicitGC |
加分说法:先回答「我会先确认是频繁但每次都能回收(说明分配率高,扩堆或调小对象生命周期),还是回收后仍不下降(说明泄漏,必须 dump)」。这一句就把「背八股」和「真排查过」分开了。
二、框架(Spring 全家桶,6 题)
Q7:Spring 三级缓存怎么解决循环依赖?为什么必须要三级,二级不够吗?
参考答案
// DefaultSingletonBeanRegistry 的三个 Map
singletonObjects // 一级:成品 Bean(完全初始化 + 可能已是代理)
earlySingletonObjects // 二级:半成品(已实例化但未填充属性)
singletonFactories // 三级:ObjectFactory,能"按需"生产早期引用(可能是代理)
流程(A ↔ B 互相依赖):
- 创建 A:实例化后,把
A 的 ObjectFactory放进三级缓存 - 填充 A 的属性,发现需要 B → 去创建 B
- 创建 B:实例化后,填充属性发现需要 A
getSingleton("a"):一级没有 → 二级没有 → 三级有 factory,调用getObject()得到 A 的早期引用,放入二级缓存,删掉三级- B 拿到 A 的引用,完成初始化,进入一级缓存
- 回到 A,注入 B,A 完成初始化,进入一级缓存
为什么二级不够? 关键在 AOP。
如果只有二级缓存,在第 1 步就得把「A 的早期引用」直接暴露出去。但此时 A 还没走完 BeanPostProcessor,代理对象还没生成。等 A 初始化完,真实对象和代理对象不是同一个——B 手里拿的是原始对象,容器里放的是代理对象,依赖不一致,事务/切面全失效。
三级缓存存的是 工厂,它能在被调用的那一刻调用 getEarlyBeanReference(),提前触发 AOP 创建代理。这样 B 注入的就是代理对象,和最终放进一级缓存的是同一个。
一句话总结:二级缓存存对象,三级缓存存「能生产对象的能力」,后者才能保证 AOP 场景下的引用一致性。
追问:构造器注入为什么解决不了? 构造器注入时,对象连「实例化」都没完成,没有引用可以提前暴露,工厂也无从生产。Spring 会直接抛 BeanCurrentlyInCreationException。解法:
@Service
public class AService {
private final BService bService;
public AService(@Lazy BService bService) { this.bService = bService; } // 注入代理,延迟解析
}
其他解法:@Autowired(required = false) + setter、ObjectProvider<BService>、ApplicationContext.getBean()(不推荐)、重构拆出第三个类(最优)。
追问:Spring Boot 2.6 之后为什么默认禁止循环依赖? spring.main.allow-circular-references 默认 false。官方态度很明确:循环依赖是设计问题,不是框架问题。允许它存在会让启动顺序、代理逻辑变得难以推理。生产上应该拆分层,而不是打开开关。
追问:prototype 作用域的循环依赖能解决吗? 不能。原型 Bean 不缓存,每次都要新建,无法提前暴露。
Q8:Spring Bean 的完整生命周期?
参考答案(背诵版顺序)
1. 实例化(反射调用构造器)
2. 属性填充(依赖注入)
3. Aware 回调:BeanNameAware → BeanFactoryAware → ApplicationContextAware
4. BeanPostProcessor.postProcessBeforeInitialization
5. @PostConstruct → InitializingBean.afterPropertiesSet() → init-method
6. BeanPostProcessor.postProcessAfterInitialization ← AOP 代理在这里生成
7. 使用中
8. @PreDestroy → DisposableBean.destroy() → destroy-method
几个关键判断:
| 问题 | 答案 |
|---|---|
| AOP 代理在哪一步生成 | 第 6 步 postProcessAfterInitialization(AbstractAutoProxyCreator) |
@PostConstruct 谁执行的 | CommonAnnotationBeanPostProcessor,属于第 4 步的 BPP 链 |
| 循环依赖的早期引用在哪一步产生 | 第 1 步之后,通过 addSingletonFactory |
BeanFactoryPostProcessor 和 BPP 的区别 | BFPP 改 BeanDefinition(早于所有 Bean 实例化);BPP 改 Bean 实例 |
扩展点全景
// 1. BeanFactoryPostProcessor:改元数据
@Component
public class MyBfpp implements BeanFactoryPostProcessor {
@Override
public void postProcessBeanFactory(ConfigurableListableBeanFactory bf) {
BeanDefinition bd = bf.getBeanDefinition("orderService");
bd.setScope("prototype");
}
}
// 2. BeanPostProcessor:改实例(可返回代理)
@Component
public class MyBpp implements BeanPostProcessor {
@Override
public Object postProcessAfterInitialization(Object bean, String name) {
if (bean instanceof OrderService) {
return Proxy.newProxyInstance(/* ... */); // 手动织入
}
return bean;
}
}
// 3. SmartInitializingSingleton:所有单例创建完后执行
@Component
public class WarmUp implements SmartInitializingSingleton {
@Override
public void afterSingletonsInstantiated() {
// 适合做缓存预热、本地缓存加载
}
}
加分项:说出「SmartInitializingSingleton 是所有单例都创建完之后才执行,适合做缓存预热;而 @PostConstruct 执行时其他 Bean 可能还没就绪,不要在里面做跨 Bean 的初始化」。这种细节是真实项目踩过才知道的。
Q9:AOP 为什么会失效?列举场景和解决方案。
参考答案
Spring AOP 的本质是动态代理:调用方持有的必须是代理对象,才能进到通知逻辑。凡是绕过代理的调用,全都失效。
六大失效场景:
| # | 场景 | 原因 | 解决 |
|---|---|---|---|
| 1 | 同类内部 this 调用 | this.method() 走的是原始对象,不是代理 | ① AopContext.currentProxy()(需 @EnableAspectJAutoProxy(exposeProxy = true))② 注入自身 ③ 拆到另一个 Bean |
| 2 | private / final / static 方法 | CGLIB 无法覆写 final,private 不可见 | 改成 public、非 final |
| 3 | 非 public 方法 | Spring AOP 只拦截 public(JDK 代理只能接口方法,CGLIB public/protected) | 改 public;或用 AspectJ 编译期织入 |
| 4 | 自己 new 的对象 | 没经过容器,不是代理 | 交给 Spring 管理 |
| 5 | 异常被内部 catch 吞掉 | 通知拿不到异常,无法回滚 | 别吞,或手动 TransactionAspectSupport.currentTransactionStatus().setRollbackOnly() |
| 6 | 异步/多线程中调用 | 子线程里拿到的可能是原始引用,且事务上下文不传递 | 把事务放到子线程内部,或显式传递代理 |
标准示例:
@Service
public class OrderService {
@Transactional
public void createOrder() { // ❌ 事务不生效
this.deductStock(); // this 是原始对象
}
@Transactional(propagation = Propagation.REQUIRES_NEW)
public void deductStock() { }
}
三种修法:
// 修法 1:注入自身(Spring 4.3+ 支持)
@Service
public class OrderService {
@Autowired @Lazy private OrderService self;
public void createOrder() {
self.deductStock(); // ✅ 走代理
}
}
// 修法 2:AopContext(需要 exposeProxy = true)
@EnableAspectJAutoProxy(exposeProxy = true)
// ...
((OrderService) AopContext.currentProxy()).deductStock();
// 修法 3:拆 Bean(最推荐,架构上也更清晰)
@Service
public class StockService {
@Transactional(propagation = Propagation.REQUIRES_NEW)
public void deductStock() { }
}
加分项:主动说明「修法 3 最好,因为前两种都在跟框架较劲,本质是把一个本该独立的边界硬塞在一个类里」。
Q10:Spring 事务的 7 种传播行为?REQUIRES_NEW 和 NESTED 有什么区别?
参考答案
| 传播行为 | 有外层事务时 | 无外层事务时 | 典型场景 |
|---|---|---|---|
| REQUIRED(默认) | 加入 | 新建 | 绝大多数业务 |
| REQUIRES_NEW | 挂起外层,新建独立事务 | 新建 | 记日志、发通知(失败不能影响主流程) |
| NESTED | 创建保存点(savepoint) | 新建 | 子步骤失败只回滚子步骤 |
| SUPPORTS | 加入 | 不用事务 | 查询 |
| NOT_SUPPORTED | 挂起外层 | 不用事务 | 非核心耗时操作 |
| MANDATORY | 加入 | 抛异常 | 强制要求调用方开事务 |
| NEVER | 抛异常 | 不用事务 | 禁止在事务中执行 |
REQUIRES_NEW vs NESTED(高频)
@Transactional
public void outer() {
insertA();
try {
innerService.inner(); // REQUIRES_NEW
} catch (Exception e) {
// 吞掉异常
}
// inner 已提交,outer 后续异常不会回滚 inner
}
REQUIRES_NEW:两个物理事务,需要两个数据库连接。内层提交后,外层回滚也不会回滚内层。用DataSourceTransactionManager时必须注意连接池要够(外层占一个,内层再要一个,嵌套深了容易死锁)。NESTED:同一个物理事务,靠 JDBC savepoint 实现。内层回滚只回到保存点,外层继续;但外层回滚时内层一定回滚。
一句话区分:REQUIRES_NEW 是「另起炉灶」,NESTED 是「存档读档」。
加分项:事务失效的 6 个原因(和 Q9 呼应) 非 public 方法、同类自调用、异常类型不匹配(默认只回滚 RuntimeException 和 Error,受检异常要 rollbackFor = Exception.class)、多数据源未指定事务管理器、异常被 catch、类没被代理(如 new 出来的、final 类)。
Q11:Spring Boot 自动装配的原理?为什么 3.0 干掉了 spring.factories?
参考答案
① 入口注解
@SpringBootApplication
= @SpringBootConfiguration // 本质是 @Configuration
+ @EnableAutoConfiguration // 自动装配开关
+ @ComponentScan // 扫描当前包及子包
② 自动装配链路
@EnableAutoConfiguration
→ @Import(AutoConfigurationImportSelector.class)
→ getAutoConfigurationEntry()
→ 读取 META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports(3.0+)
或 META-INF/spring.factories(2.x)
→ 得到候选配置类全限定名列表(约 140+ 个)
→ 经 @ConditionalOnClass / @ConditionalOnMissingBean 等条件过滤
→ 注册为 BeanDefinition
③ 条件注解是灵魂
@AutoConfiguration
@ConditionalOnClass(DataSource.class) // 类路径有 DataSource 才生效
@ConditionalOnMissingBean(DataSource.class) // 用户没自己配才生效
@EnableConfigurationProperties(DataSourceProperties.class) // 绑定 spring.datasource.*
public class DataSourceAutoConfiguration {
@Bean
@ConditionalOnMissingBean
public DataSource dataSource(DataSourceProperties props) {
return props.initializeDataSourceBuilder().build();
}
}
「约定优于配置」的本质就是:先给一套默认值,条件满足才装配,用户自定义就退让(@ConditionalOnMissingBean)。
④ 为什么 3.0 移除 spring.factories?
| 原因 | 说明 |
|---|---|
| 启动性能 | spring.factories 是一个大文件,所有扩展点混在一起,每次都要全量解析;新文件按需读取,且支持索引 |
| 可读性 | 一个文件里混着 Listener、Initializer、AutoConfiguration,很难维护 |
| AOT / Native | GraalVM 原生镜像需要在构建期确定装配结果,独立清单更容易做静态分析 |
| 语义清晰 | AutoConfiguration.imports 只放自动配置,职责单一 |
⑤ 自定义 starter 三步
- 建
xxx-spring-boot-starter模块,引入spring-boot-configuration-processor - 写
XxxAutoConfiguration+@ConfigurationProperties - 在
META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports里写全限定名(一行一个)
Q12:MyBatis 的 #{} 和 ${} 有什么区别?一级/二级缓存有什么坑?
参考答案
① #{} vs ${}
| 维度 | #{} | ${} |
|---|---|---|
| 实现 | PreparedStatement 占位符 ? | 字符串直接拼接 |
| 预编译 | ✅ 可复用执行计划 | ❌ 每次都是新 SQL |
| SQL 注入 | 安全 | 有风险 |
| 适用 | 参数值 | 表名、列名、order by 字段等标识符 |
<!-- ✅ 安全 -->
<select id="get" resultType="User">
SELECT * FROM user WHERE name = #{name}
</select>
<!-- ❌ 注入:name 传 "1' OR '1'='1" 就全表泄露 -->
<select id="get" resultType="User">
SELECT * FROM user WHERE name = '${name}'
</select>
<!-- ✅ 排序字段必须用 ${},但要做白名单校验 -->
<select id="list" resultType="User">
SELECT * FROM user ORDER BY ${sortField} ${sortOrder}
</select>
注意:#{} 对 order by 无效(会变成 order by 'create_time',按常量排序,等于没排),所以排序字段只能 ${} + 白名单枚举校验。
② 一级缓存(SqlSession 级,默认开启)
- 同一个
SqlSession内相同 SQL + 参数,第二次直接走缓存 insert/update/delete会清空缓存- 坑:与 Spring 整合后,如果没开事务,每次 Mapper 调用都是新的 SqlSession,一级缓存等于没用;如果开了事务,同一个事务内可能读到旧数据(别的线程已更新但本事务缓存未清)
③ 二级缓存(Mapper/namespace 级,需手动开启)
<cache eviction="LRU" flushInterval="60000" size="1024" readOnly="false"/>
必要条件:<cache/> + @CacheNamespace 或全局 cache-enabled=true + 实体实现 Serializable。
三大坑:
- 跨 namespace 脏读:
OrderMapper和UserMapper都缓存了关联查询结果,更新user表只清UserMapper的缓存,OrderMapper里的旧数据还在 → 必须配<cache-ref>,但配了又变成大范围失效 - 多表联查必然脏:这是二级缓存的死穴
- 分布式环境下各节点缓存不一致:本地缓存没有跨节点失效机制
结论:生产环境建议直接关掉二级缓存,缓存交给 Redis 统一管理(可控失效、可观测、跨节点一致)。
三、事务(4 题)
Q13:ACID 分别靠什么保证?redo log 和 binlog 怎么保证一致?
参考答案
| 特性 | 实现机制 | 说明 |
|---|---|---|
| A 原子性 | undo log | 记录反向操作,回滚时逆序执行 |
| C 一致性 | 上述三者的结果,加上业务约束 | 一致性是目的,不是手段 |
| I 隔离性 | 锁 + MVCC | 写写靠锁,读写靠 MVCC 快照 |
| D 持久性 | redo log(WAL) | 先写日志再刷脏页,崩溃可重放 |
关键细节:为什么是 WAL 而不是直接写数据页? 数据页是随机写(B+Tree 分散在磁盘各处),redo log 是顺序追加写,顺序 IO 比随机 IO 快 2~3 个数量级。而且 redo log 体积小(只记物理页变更),可以先攒着批量刷。
innodb_flush_log_at_trx_commit 三档:
1(默认):每次提交都 fsync → 不丢数据,性能最低0:每秒 fsync 一次 → 最多丢 1 秒2:每次提交写到 OS cache,每秒 fsync → MySQL 挂了不丢,机器断电丢 1 秒
两阶段提交(redo 与 binlog 的一致性)
1. InnoDB 写 redo log,标记 prepare
2. 写 binlog,fsync
3. InnoDB 写 redo log,标记 commit
崩溃恢复判定表(这是加分点):
| redo 状态 | binlog 是否存在完整事务 | 处理 |
|---|---|---|
| prepare | 无 / 不完整 | 回滚 |
| prepare | 完整 | 提交 |
| commit | — | 提交 |
如果不用两阶段提交(先写 redo 再写 binlog),在两步之间崩溃就会出现「主库有、从库没有」或者反过来的数据不一致 —— 这是主从数据不一致最经典的根因。
加分项:组提交(group commit) 多个事务的 redo fsync 合并成一次系统调用,把「每次提交都 fsync」的成本摊薄。这是 MySQL 5.7+ 在高并发下还能开 sync_binlog=1 的原因。
Q14:RR 隔离级别到底解决了幻读吗?
参考答案
这个问题必须分快照读和当前读两种情况答,答错就是 60 分和 85 分的差距。
① 快照读(普通 SELECT)靠 MVCC
- 版本链:每行有
DB_TRX_ID(最后修改事务)和DB_ROLL_PTR(指向 undo log 的旧版本) - ReadView:包含
m_ids(活跃事务 ID 列表)、min_trx_id、max_trx_id、creator_trx_id - 可见性判断:沿版本链找到第一个「对当前 ReadView 可见」的版本
- RC 每次 SELECT 都生成新 ReadView;RR 只在第一次 SELECT 时生成,整个事务复用
所以 RR 下快照读天然不会幻读——你看到的是事务开始时的快照,别人插入的行你看不见。
② 当前读(SELECT ... FOR UPDATE、UPDATE、DELETE、INSERT)靠 Next-Key Lock
-- 假设 id 上有索引,值 5、10、15
SELECT * FROM t WHERE id = 10 FOR UPDATE;
-- 加锁范围:(5, 10] → Next-Key Lock = 记录锁 + 间隙锁
Next-Key Lock = Record Lock + Gap Lock,锁住的是「记录 + 前面的间隙」,所以其他事务无法在这个间隙插入,幻读被阻止。
③ 但 RR 仍有幻读的边角场景
场景:事务 A 先快照读,事务 B 插入并提交,事务 A 再当前读——会看到 B 插入的行,两次读结果不一致。
-- A
BEGIN;
SELECT * FROM t WHERE id > 100; -- 快照读,3 行
-- B: INSERT INTO t(id) VALUES(200); COMMIT;
SELECT * FROM t WHERE id > 100 FOR UPDATE; -- 当前读,4 行 ← 幻读
严格来说这是「写后读」现象,不是 MVCC 的锅,而是快照读和当前读用了两套机制。RR 保证的是「同一快照内的一致性」,不是「任何两次读都一样」。
④ 大厂为什么普遍用 RC?
| 理由 | 说明 |
|---|---|
| 减少间隙锁 | RC 下几乎没有 Gap Lock,大幅降低死锁概率 |
| 提高并发 | 锁范围小 |
| 主从一致 | binlog 用 ROW 格式后,RC 也能安全复制 |
| 业务可控 | 「读到最新数据」比「可重复读」更符合互联网业务预期 |
代价是要在业务层接受「同一事务内两次读可能不同」,用乐观锁/版本号兜底。
Q15:分布式事务有哪些方案?Seata AT 的原理是什么?
参考答案
① 方案对比表(面试直接讲这张表)
| 方案 | 一致性 | 侵入性 | 性能 | 适用场景 |
|---|---|---|---|---|
| 2PC / XA | 强 | 低(数据库层) | 差(同步阻塞,锁到二阶段) | 传统金融、单库跨服务少 |
| TCC | 最终(可做到准实时) | 高(写 Try/Confirm/Cancel) | 好 | 资金、库存等核心链路 |
| SAGA | 最终 | 中(写正向 + 补偿) | 好 | 长流程、跨多服务 |
| 本地消息表 | 最终 | 中 | 好 | 大多数业务(推荐) |
| 事务消息(RocketMQ) | 最终 | 低 | 好 | 已有 MQ 的异步链路 |
| 最大努力通知 | 弱 | 低 | 最好 | 支付回调、对账 |
② TCC 的三个坑(必须主动说)
- 空回滚:Try 没执行(比如网络超时),Cancel 却来了 → Cancel 要能识别「Try 未执行」,直接返回成功
- 悬挂:Cancel 比 Try 先到,之后 Try 才执行 → Try 要检查「是否已有 Cancel 记录」,有就拒绝
- 幂等:Confirm/Cancel 可能重试多次 → 用事务控制表 + 唯一键
③ Seata AT 模式原理
AT 是「自动补偿」的两阶段:
阶段一(业务 SQL 与 undo_log 同一本地事务):
1. 解析 SQL,查出修改前的镜像(beforeImage)
2. 执行业务 SQL
3. 查出修改后的镜像(afterImage)
4. 把前后镜像写入 undo_log
5. 注册分支事务到 TC,申请全局锁
6. 本地事务提交(业务变更已生效!)
阶段二:
全局提交 → 异步删除 undo_log(很快)
全局回滚 → 用 beforeImage 生成反向 SQL 补偿,校验 afterImage 是否被改过(脏写检测)
核心是「全局锁」:SELECT ... FOR UPDATE 之前,TC 会校验该行的全局锁是否被其他全局事务持有,防止跨服务的脏写。
为什么 AT 快:阶段一本地事务直接提交,不占数据库连接和行锁到二阶段(对比 XA 要一直持有)。
AT 的边界:只支持支持本地 ACID 事务的关系型数据库;SQL 必须能被解析(不支持复杂子查询、部分函数);隔离级别默认读未提交(要看住「读隔离」需额外 SELECT FOR UPDATE)。
Q16:怎么保证「下单成功 → 发积分」的最终一致性?
参考答案
这是最高频的场景题,答「方案 + 幂等 + 补偿」三段。
方案:本地消息表 + 定时补偿
CREATE TABLE local_message (
id BIGINT PRIMARY KEY AUTO_INCREMENT,
biz_id VARCHAR(64) NOT NULL COMMENT '业务唯一键',
topic VARCHAR(64) NOT NULL,
payload TEXT NOT NULL,
status TINYINT NOT NULL DEFAULT 0 COMMENT '0待发送 1已发送 2消费成功 3失败',
retry_count INT NOT NULL DEFAULT 0,
next_retry DATETIME,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
UNIQUE KEY uk_biz (biz_id, topic) -- 幂等的第一道防线
);
@Transactional // 业务 + 消息 在同一个本地事务里
public void createOrder(OrderCmd cmd) {
orderMapper.insert(cmd.toOrder());
localMessageMapper.insert(LocalMessage.of(cmd.getOrderId(), "POINT_ADD", cmd));
// 事务提交 → 消息一定落库;不会出现"订单成功消息没发"
}
@Scheduled(fixedDelay = 5000)
public void relay() {
List<LocalMessage> list = localMessageMapper.selectPending(100);
for (LocalMessage m : list) {
try {
rocketMQTemplate.syncSend(m.getTopic(), m.getPayload());
localMessageMapper.markSent(m.getId());
} catch (Exception e) {
localMessageMapper.incrRetry(m.getId(), backoff(m.getRetryCount()));
}
}
}
消费端幂等(第二道防线)
@RocketMQMessageListener(topic = "POINT_ADD", consumerGroup = "point-cg")
public class PointListener implements RocketMQListener<String> {
@Override
public void onMessage(String body) {
PointMsg msg = JSON.parseObject(body, PointMsg.class);
// 唯一键:msg_id 或 (user_id, biz_id)
int affected = jdbcTemplate.update(
"INSERT INTO point_log(msg_id, user_id, amount) VALUES(?,?,?) " +
"ON DUPLICATE KEY UPDATE msg_id = msg_id", // 重复则影响行数为 0
msg.getMsgId(), msg.getUserId(), msg.getAmount());
if (affected == 0) {
log.info("重复消息,跳过: {}", msg.getMsgId());
return;
}
pointService.addPoint(msg.getUserId(), msg.getAmount()); // 同一事务
}
}
为什么这样设计能成立:
- 消息不丢:业务与消息同库同事务,本地事务成功则消息必落库
- 消息必达:定时任务扫描 + 指数退避重试,直到成功
- 重复消费:唯一索引兜底,插入冲突即跳过
- 顺序无关:用状态机(待支付→已支付→已完成)而不是直接赋值,乱序也不会错
加分项:主动提「这套方案的代价是延迟(秒级),如果业务要求强实时,就得用 TCC 或事务消息。没有银弹,只有取舍」。面试官喜欢听取舍。
四、中间件(5 题)
Q17:Kafka 为什么这么快?
参考答案(四个层面)
① 存储:顺序写 + 分段 + 稀疏索引
- 消息追加写入 partition 的 segment 文件(
.log),纯顺序 IO,机械盘也能跑到 600MB/s+ - 每 1GB 切一个 segment,配
.index(稀疏索引,默认每 4KB 记一条)和.timeindex - 查找:二分定位 segment → 二分 index 找到最近的偏移 → 顺序扫一小段
② 传输:零拷贝(zero-copy)
传统 read + write 要 4 次拷贝、4 次上下文切换:
磁盘 → 内核页缓存 → 用户缓冲区 → socket 缓冲区 → 网卡
sendfile 只需 2 次(DMA 到页缓存,页缓存直接到网卡),CPU 拷贝次数从 2 次降到 0 次。Kafka 用 FileChannel.transferTo() 就是走这条路。
注意:开启 SSL 或压缩时零拷贝会失效(数据要进用户态处理),这是性能下降的常见原因。
③ 批量 + 压缩
- 生产者
batch.size(默认 16KB)+linger.ms(默认 0,建议 5~100)攒批 - broker 端消息集(RecordBatch)整体压缩,
compression.type=lz4/zstd,压缩比 3~5 倍 - 批量发送把「N 次网络往返」变成 1 次
④ 并行:分区
Topic 分多个 partition,分散在不同 broker,生产/消费两端都并行。partition 数是 Kafka 并行的物理上限。
加分项:说出「Kafka 快不代表延迟低」。linger.ms 攒批会带来额外延迟,高吞吐和低延迟在 Kafka 里是直接冲突的,要按场景调。
Q18:Kafka 怎么保证不丢消息、不重复消费、顺序消费?
参考答案
① 不丢消息(三个环节都要管)
# 生产者:确认 + 重试 + 幂等
acks=all # 所有 ISR 副本都写入才返回
retries=2147483647
enable.idempotence=true # 幂等生产者,防重试导致重复
max.in.flight.requests.per.connection=5 # 幂等开启后可到 5,仍保序
# Broker:副本 + 最小同步副本
replication.factor=3
min.insync.replicas=2 # 至少 2 个副本确认,配合 acks=all
unclean.leader.election.enable=false # 不让落后副本当 leader(宁可不可用,不丢数据)
# 消费者:关自动提交,处理完再提交
enable.auto.commit=false
消费端正确姿势:
while (true) {
ConsumerRecords<String, String> records = consumer.poll(Duration.ofMillis(500));
for (ConsumerRecord<String, String> r : records) {
handle(r); // 处理成功(或幂等)
}
consumer.commitSync(); // 处理完才提交,最多重复,不会丢
}
关键取舍:先提交后处理 = 可能丢;先处理后提交 = 可能重复。业务只能接受重复,不能接受丢,所以永远选后者 + 幂等。
② 不重复消费
Kafka 只保证 at-least-once(幂等生产者只解决生产端重复,不解决消费端)。消费端重复来自:处理完未提交 offset 就 rebalance、重启、网络超时重试。
解法就是业务幂等:
- 唯一索引 /
ON DUPLICATE KEY UPDATE - 状态机(只允许
待支付 → 已支付,重复的已支付 → 已支付直接忽略) - Redis
SETNX msg_id(注意要设过期时间,避免内存泄漏) - 版本号 / CAS
③ 顺序消费
- 生产端:同一业务 key 打到同一 partition(
key = orderId),Kafka 对 key 做 hash 选分区 - 消费端:一个 partition 只被同一消费组内的一个消费者消费(Kafka 协议保证),所以单分区内天然有序
- 必须
max.in.flight.requests.per.connection=1(未开幂等时),否则重试会乱序 - 消费端单线程处理,或者按 key 哈希到内存队列 + 多线程但同 key 串行
局限:全局有序只能单分区,吞吐会塌。真实业务要的是「同一订单有序」,不是「全局有序」——这句话要说出来。
Q19:RocketMQ 事务消息的原理?和 Kafka 事务有什么区别?
参考答案
① 执行流程(半消息 + 回查)
1. Producer 发送【半消息】到 Broker
2. Broker 持久化成功,返回 OK,但消息对消费者不可见(RMQ_SYS_TRANS_HALF_TOPIC)
3. Producer 执行本地事务(写订单表)
4. 根据本地事务结果,向 Broker 发送 commit / rollback
5. commit → 消息转正(写入真实 topic),消费者可见
rollback → 删除半消息
6. 若第 4 步丢失(Producer 挂了 / 网络断),Broker 定时【回查】Producer
(默认 60s 后开始,最多 15 次,间隔递增)
7. Producer 检查本地事务表,返回 commit / rollback / unknown
unknown 超过最大次数 → 丢弃并告警(需人工介入)
② 为什么需要「半消息」而不是简单先发 MQ 再执行本地事务?
| 顺序 | 问题 |
|---|---|
| 先发 MQ 后执行本地事务 | 消息已发出,本地事务回滚 → 下游多做了(超发) |
| 先执行本地事务后发 MQ | 本地提交,发消息失败 → 下游少做了(漏发) |
| 半消息 | 消息先「暂存不可见」,本地事务结果决定是否转正 → 二者绑定 |
③ 回查接口实现
@RocketMQTransactionListener
public class OrderTxListener implements RocketMQLocalTransactionListener {
@Override
public RocketMQLocalTransactionState executeLocalTransaction(Message msg, Object arg) {
try {
orderService.createOrder((OrderCmd) arg); // 本地事务
return RocketMQLocalTransactionState.COMMIT;
} catch (Exception e) {
return RocketMQLocalTransactionState.ROLLBACK;
}
}
@Override
public RocketMQLocalTransactionState checkLocalTransaction(Message msg) {
String orderId = msg.getHeaders().get("orderId").toString();
// 查本地事务表 / 订单表是否存在
return orderMapper.exists(orderId)
? RocketMQLocalTransactionState.COMMIT
: RocketMQLocalTransactionState.ROLLBACK;
}
}
④ 与 Kafka 事务的区别(高频追问)
| 维度 | RocketMQ 事务消息 | Kafka 事务 |
|---|---|---|
| 解决的问题 | 本地事务 + 消息发送的原子性 | 多条消息的原子写入 + 跨 topic/partition 的 exactly-once |
| 机制 | 半消息 + 回查 | TransactionalId + 事务协调器 + 两阶段提交 |
| 语义 | 最终一致 | 流处理场景的 exactly-once |
| 典型用途 | 订单 → 库存/积分 | Kafka Streams 读-处理-写 |
一句话:Kafka 事务保证「消息之间的原子」,RocketMQ 事务消息保证「数据库与消息之间的原子」。这两个常被混为一谈。
Q20:线上消息积压了几百万条,怎么处理?
参考答案
第一步:先定位,别急着扩
| 现象 | 判断 | 处理 |
|---|---|---|
| 生产速率突增 | 上游活动/重放 | 限流上游,或临时扩容消费 |
| 消费速率下降 | 下游 RT 变高(DB 慢、第三方接口超时) | 优先修下游,扩容只是延缓 |
| 消费者掉线 | 频繁 rebalance、OOM、异常退出 | 看日志/监控,修稳定性 |
| 单分区热点 | 某个 key 过量 | 拆分 key 或加随机后缀 |
第二步:应急手段(按见效速度排序)
- 扩消费者 —— 但并行上限 = 分区数,加到超过分区数没用。所以要先扩分区(Kafka 只能加不能减,且会打乱 key 顺序)
- 临时转发:写一个应急消费者,不做业务处理,只把消息搬到「新 topic(分区数 ×10)」,再用 10 倍消费者并行消费
- 批量消费:
max.poll.records从 500 调到 2000,配合批量写库(insert ... values (),(),()) - 降级:非核心消息(如日志、行为埋点)直接丢弃或落盘后补
- 临时扩机器:CPU/IO 打满时最直接
第三步:根治
# 消费端关键参数
max.poll.records=500 # 单次拉取量,太大导致处理超时触发 rebalance
max.poll.interval.ms=300000 # 处理超时上限,要大于最坏处理耗时
fetch.min.bytes=1 # 有数据就返回,降低延迟
- 消费逻辑异步化(线程池处理,但注意 offset 提交时机)
- 加本地缓存/Redis 挡读
- 慢 SQL 优化(这是最常见的真凶)
- 压测出单消费者吞吐,按「峰值 × 2」配置消费者数量
加分项:「积压不是问题,积压背后的下游瓶颈才是问题。如果只是扩消费者,DB 会被打挂,反而雪崩。」—— 这句话能立刻拉开差距。
Q21:注册中心怎么选?Eureka、ZooKeeper、Nacos 的区别?
参考答案
① CAP 定位
| 注册中心 | CAP | 一致性协议 | 特点 |
|---|---|---|---|
| Eureka | AP | 无(节点间异步复制) | 自我保护机制:心跳丢失比例过高时不再剔除实例,宁可给旧列表 |
| ZooKeeper | CP | ZAB | leader 选举期间不可用(30~120s),服务发现场景不划算 |
| Nacos | AP / CP 可切 | Distro(AP)/ Raft(CP) | 临时实例用 AP,持久实例用 CP |
| Consul | CP | Raft | 自带健康检查、多数据中心 |
为什么服务发现更适合 AP? 服务发现的可用性优先级高于一致性——拿到一个稍微过期的实例列表(多调一次失败的请求,客户端重试即可),远好于「注册中心不可用导致整个集群无法发现服务」。
② 服务发现的推拉模型
| 组件 | 机制 |
|---|---|
| ZooKeeper | 客户端注册 Watcher,节点变化主动推送 |
| Eureka | 客户端每 30s 拉一次增量,服务端每 60s 清理过期实例 |
| Nacos 1.x | 客户端定时拉 + 服务端 UDP 推 |
| Nacos 2.x | 长连接(gRPC)+ 推送,实时性最好,也大幅降低轮询压力 |
③ Nacos 的两个关键设计
- 临时实例 vs 持久实例:临时实例(默认,
ephemeral=true)靠心跳保活,15s 没心跳标记不健康,30s 剔除;持久实例不剔除,只标记不健康,适合数据库这类「必须能看到」的资源。 - 命名空间 / Group / DataId 三层隔离,天然支持多环境 + 多租户。
④ 选型结论
- 新项目、国内环境 → Nacos(注册 + 配置一体,社区活跃,Spring Cloud Alibaba 原生支持)
- 已有 ZK 生态(Dubbo 早期)→ 可继续用,但注意 CP 带来的可用性代价
- AWS 环境 → Eureka 或 Consul
加分项:提一句「注册中心不是越一致越好」。ZK 的 CP 在 Leader 选举时整个集群不可写,对服务发现是灾难;Nacos 把选择权交给实例类型,是更务实的设计。
五、数据库与缓存(5 题)
Q22:索引失效有哪些情况?为什么是最左前缀?
参考答案
① 八种失效场景
-- 假设有联合索引 idx_abc (a, b, c)
-- 1. 跳过最左列
WHERE b = 1 AND c = 2 -- ❌ 失效
-- 2. 范围查询后的列失效
WHERE a = 1 AND b > 2 AND c = 3 -- ⚠️ c 用不上(a、b 可用)
-- 3. 索引列上做函数/运算
WHERE DATE(create_time) = '2026-11-04' -- ❌
WHERE id + 1 = 100 -- ❌
-- ✅ 改写:WHERE create_time >= '2026-11-04 00:00:00' AND create_time < '2026-11-05'
-- 4. 隐式类型转换(最常见也最隐蔽)
-- user.phone 是 varchar
WHERE phone = 13800138000 -- ❌ 数字 → 列转 double,索引失效
WHERE phone = '13800138000' -- ✅
-- 5. 左模糊
WHERE name LIKE '%张' -- ❌
WHERE name LIKE '张%' -- ✅
-- 6. OR 连接非索引列
WHERE a = 1 OR d = 2 -- ❌(d 无索引则全表扫)
-- ✅ 拆成 UNION 或给 d 建索引
-- 7. 不等于 / NOT IN / IS NOT NULL(视选择性与优化器判断)
WHERE status != 1 -- ⚠️ 可能全表扫
WHERE a IS NULL -- ⚠️ 取决于索引是否存 NULL(InnoDB 存)
-- 8. 排序字段与索引顺序不一致
ORDER BY a, c -- ⚠️ 若索引是 (a,b,c),跨列排序可能 filesort
② 为什么必须最左前缀
联合索引 (a, b, c) 的 B+Tree 是按 a 排序,a 相同再按 b,b 相同再按 c 的字典序。
WHERE a = 1→ 在树中 a=1 的连续区间,可用WHERE a = 1 AND b = 2→ 在 a=1 内 b=2 的连续区间,可用WHERE b = 2→ b=2 的记录散布在整个索引里(因为 a 有多个值),无法二分,只能全扫
类比:电话簿按「姓 → 名」排序,你能按姓快速翻,也能按「姓+名」翻,但没法只按「名」翻。
③ 为什么范围查询会「断掉」后面的列
a=1 AND b>2 AND c=3:在 a=1 且 b>2 的区间里,记录是按 b 排序的,c 在这个区间内无序,所以 c 无法再走索引定位。但可以用 ICP(索引条件下推) 在存储引擎层做过滤,减少回表次数——注意 ICP 只是「减少回表」,不是「用 c 定位」。
④ 补救手段
| 手段 | 作用 |
|---|---|
| 覆盖索引 | 查询列全在索引里,免回表 |
| ICP | 索引列条件下推到引擎层过滤 |
| MRR | 回表前按主键排序,把随机 IO 变顺序 IO |
| 索引跳跃扫描(8.0.13+) | a 基数很低时,优化器可对每个 a 值分别用 b 索引,部分场景绕过最左前缀 |
加分项:EXPLAIN 里看到 type=ALL 但 possible_keys 有值,说明优化器算过成本后主动放弃索引(比如回表行数占比过高),这时候 FORCE INDEX 反而可能更慢。「失效」有时是优化器的正确决策。
Q23:深分页怎么优化?为什么 InnoDB 用 B+Tree 而不是 B 树或哈希?
参考答案
① 深分页的问题
SELECT * FROM orders ORDER BY id LIMIT 1000000, 20;
MySQL 会扫描 1000020 行,丢弃前 1000000 行,而且 SELECT * 每行都要回表,代价极高。
三种解法:
-- 方案 1:延迟关联(子查询只取主键,走覆盖索引)
SELECT o.* FROM orders o
INNER JOIN (SELECT id FROM orders ORDER BY id LIMIT 1000000, 20) t ON o.id = t.id;
-- 子查询在 idx_id 上完成,只回表 20 次
-- 方案 2:游标分页(最优,但只能"下一页")
SELECT * FROM orders WHERE id > 1000000 ORDER BY id LIMIT 20;
-- 方案 3:业务限制(禁止跳到第 N 页,或导出走异步任务 + 游标)
方案 2 是唯一 O(1) 的解法,也是所有 App 无限滚动的正确做法。代价是无法随机跳页——但用户真的需要跳到第 5 万页吗?
② 为什么是 B+Tree
| 对比 | 结论 |
|---|---|
| vs 二叉搜索树 / 红黑树 | 树高太高(百万数据红黑树高 ~20 层 = 20 次磁盘 IO),B+Tree 三~四层就够 |
| vs B 树 | B 树每个节点都存数据,扇出小、树更高;B+Tree 非叶子节点只存键,一页能放更多键,扇出更大;且叶子节点链表相连,范围查询只需一次定位 + 顺序扫 |
| vs 哈希索引 | 哈希只能等值查询,不支持范围、排序、最左前缀;且有哈希冲突 |
核心数字:InnoDB 一页 16KB,假设主键 bigint(8B)+ 指针(6B)= 14B,一页可放 ~1170 个键;三层 B+Tree 可存 1170 × 1170 × 16 ≈ 2190 万 行。查 2000 万行只要 3 次 IO——这就是 B+Tree 的意义。
③ 聚簇索引 vs 二级索引
- 聚簇索引(主键):叶子节点存整行数据
- 二级索引:叶子节点存主键值,查非索引列要回表(再走一次聚簇索引)
所以 SELECT * 的代价远高于 SELECT id,这也是「不要写 SELECT *」的真正原因。
加分项:提「主键建议用自增 bigint」。UUID 做主键会导致插入随机、页分裂严重、二级索引膨胀(每个二级索引叶子都要存 16B UUID 而不是 8B)。
Q24:一条 SQL 很慢,你怎么排查?
参考答案
完整链路(六步)
# 1. 确认慢 SQL(慢查询日志)
SET GLOBAL slow_query_log = ON;
SET GLOBAL long_query_time = 1;
# 分析:mysqldumpslow 或 pt-query-digest
# 2. 看执行计划
EXPLAIN SELECT ...;
EXPLAIN ANALYZE SELECT ...; # 8.0.18+,给出实际耗时
# 3. 看优化器为什么选错(关键!)
SELECT * FROM information_schema.OPTIMIZER_TRACE\G
SET optimizer_trace = 'enabled=on';
# 4. 看真实执行耗时分布
SHOW PROFILE FOR QUERY 1; # 或 performance_schema
# 5. 看表的统计信息
SHOW TABLE STATUS LIKE 'orders';
ANALYZE TABLE orders; # 统计信息过期时重算
# 6. 看锁等待
SELECT * FROM performance_schema.data_locks;
SELECT * FROM performance_schema.data_lock_waits;
SHOW PROCESSLIST;
EXPLAIN 重点看四列
| 列 | 关注点 |
|---|---|
type | 性能排序:system > const > eq_ref > ref > range > index > ALL。出现 ALL 或 index 要警惕 |
key / key_len | 实际用的索引、用了几个字节。key_len 能算出联合索引用到了第几列 |
rows | 预估扫描行数,与实际差太多说明统计信息不准 |
Extra | Using filesort(排序无索引)、Using temporary(用了临时表)、Using index(覆盖索引,好)、Using index condition(ICP) |
五大根因与对策
| 根因 | 特征 | 对策 |
|---|---|---|
| 没索引 / 索引失效 | type=ALL | 建索引、改写 SQL(见 Q22) |
| 索引选错 | possible_keys 有但 key 空 | ANALYZE TABLE、FORCE INDEX、改写 |
| 回表过多 | rows 很大但过滤后很少 | 覆盖索引 |
| 深分页 | LIMIT 偏移巨大 | 延迟关联 / 游标(见 Q23) |
| 锁等待 | 执行慢但 EXPLAIN 正常 | 查 data_lock_waits,缩短事务、降隔离级别 |
加分项:说出「先用 EXPLAIN ANALYZE 分清是「扫描行数多」还是「单行处理慢」」。前者是索引问题,后者可能是锁等待、磁盘 IO 抖动、或函数计算——两者的优化方向完全相反。
Q25:Redis 为什么单线程还这么快?持久化怎么选?
参考答案
① 「单线程」的准确含义
Redis 6.0 之前,命令执行是单线程的,但并非全部单线程:
- 主线程:解析命令、执行命令、写回响应
bio_close_file/bio_aof_fsync/bio_lazy_free:后台线程(4.0 引入)io-threads:6.0 引入,只处理网络读写和协议解析,命令执行仍是单线程
② 为什么快(六层原因)
- 纯内存操作,纳秒级
- IO 多路复用(epoll),一个线程管理上万连接
- 单线程无锁,没有上下文切换和竞争开销
- 高效数据结构:SDS(O(1) 取长度)、跳表(范围查询 O(logN))、listpack、intset
- RESP 协议简单,解析成本低
- 全局哈希表 + 渐进式 rehash,避免一次性扩容卡顿
③ 单线程的代价
- 一个慢命令(
KEYS *、大HGETALL、SORT)会阻塞所有请求 - 只能用一个 CPU 核,吞吐上限受单核性能限制
对策:禁用 KEYS(用 SCAN)、大 key 拆分、UNLINK 替代 DEL、集群分片。
④ 持久化三选一
| 方式 | 原理 | 优点 | 缺点 | 适用 |
|---|---|---|---|---|
| RDB | fork + COW,定时生成二进制快照 | 文件小、恢复快 | 可能丢较多数据(分钟级) | 冷备、主从全量同步 |
| AOF | 追加写命令日志 | 丢数据少(everysec 最多 1s) | 文件大、恢复慢 | 数据敏感 |
| 混合(4.0+) | AOF 重写时前半段用 RDB 格式 | 兼顾体积与安全 | 兼容性(旧版本读不了) | 推荐默认 |
appendonly yes
appendfsync everysec # 生产首选;always 太慢,no 不可控
aof-use-rdb-preamble yes # 混合持久化
save 900 1 # RDB 兜底
⑤ 7.0 的 AOF 三段式改造
旧版 AOF 重写时,父子进程共写一个文件,需要靠 aof_rewrite_buf 缓冲增量,内存开销大。7.0 改成 base(RDB 格式)+ incr(增量 AOF)+ manifest(清单) 的多文件结构,重写时新开一个 incr 文件,不再需要大缓冲,内存和延迟都更可控。
Q26:缓存穿透、击穿、雪崩怎么解决?怎么保证缓存与数据库一致?
参考答案
① 三兄弟对比
| 问题 | 定义 | 解法 |
|---|---|---|
| 穿透 | 查不存在的数据,请求每次都打到 DB | ① 缓存空值(短 TTL,如 60s)② 布隆过滤器(前置拦截,有误判无漏判)③ 参数合法性校验 |
| 击穿 | 热点 key 过期瞬间,大量请求同时打到 DB | ① 互斥锁(只放一个请求回源)② 逻辑过期(不设 TTL,后台异步重建,永不阻塞) |
| 雪崩 | 大批 key 同时过期 或 Redis 整体宕机 | ① TTL 加随机抖动 ② 多级缓存(Caffeine + Redis)③ 熔断降级 ④ 集群高可用 |
② 逻辑过期(推荐,不阻塞)
public String get(String key) {
RedisData rd = (RedisData) redis.get(key);
if (rd == null) {
return null; // 或走互斥锁重建
}
if (rd.getExpireTime().isAfter(LocalDateTime.now())) {
return rd.getData(); // 未过期,直接返回
}
// 已过期:尝试拿锁重建,拿不到也返回旧数据
if (redis.setnx("lock:" + key, "1", 10, SECONDS)) {
try {
// 双重检查,避免重复重建
executor.submit(() -> rebuild(key));
} finally {
redis.del("lock:" + key);
}
}
return rd.getData(); // 关键:返回旧数据,不阻塞
}
③ 一致性:为什么是「先更新 DB,再删缓存」
| 策略 | 问题 |
|---|---|
| 先删缓存,再更新 DB | 删完、DB 还没更新时,另一个请求读旧值回填 → 长期脏数据 |
| 先更新 DB,再更新缓存 | 两个并发更新,后写的可能先执行 → 缓存是旧值;且并发写缓存浪费 |
| 先更新 DB,再删缓存(Cache Aside) | 只有极小概率不一致(读请求恰好在「更新前读旧值」和「删缓存后回填」之间) |
极小概率不一致的时序:
T1: 读请求 A → 缓存未命中 → 读 DB 得到 v1
T2: 写请求 B → 更新 DB 为 v2 → 删除缓存
T3: 请求 A → 把 v1 写回缓存 ← 缓存变 v1,DB 是 v2,长期不一致
解法:延迟双删(更新后删一次,500ms 后再删一次),但仍不够可靠(延迟时间难定)。终极方案是订阅 binlog:
业务只更新 DB
↓
Canal / Debezium 订阅 binlog
↓
发 MQ(带 row 变更前后镜像)
↓
缓存服务消费,删除对应 key(失败重试)
优点:缓存删除与业务解耦、可重试、可观测、不依赖业务代码自觉。这也是大厂的标准做法。
④ 大 key / 热 key
| 问题 | 检测 | 治理 |
|---|---|---|
| 大 key(value > 10KB 或元素 > 5000) | redis-cli --bigkeys、MEMORY USAGE key | 拆分(user:1:profile / user:1:orders)、压缩、UNLINK 异步删 |
| 热 key(单 key QPS 极高) | --hotkeys(需 LFU)、监控 | 本地缓存、key 加随机后缀打散、读写分离 |
六、高并发(5 题)
Q27:设计一个秒杀系统,你会怎么做?
参考答案
先给整体图,再讲每层的取舍。

图 2:秒杀的本质是「漏斗」,不是「抗住」。100 万请求进来,最终只有 1000 个能成为订单,中间每一层都在替数据库挡子弹。
① 分层设计
| 层 | 做什么 | 为什么 |
|---|---|---|
| 客户端 | 按钮置灰、答题/验证码、限流 | 挡掉脚本和无意义点击,成本最低 |
| CDN | 静态资源、页面静态化 | 动态请求减少 90% |
| 网关 | 令牌桶限流、黑名单、IP 限频 | 保护后端,不区分好人坏人先限总量 |
| 应用 | 本地缓存活动信息、Redis 预扣库存(Lua) | 内存级判断,QPS 可达 10 万 |
| MQ | 异步下单、削峰 | 把瞬时 5 万压成匀速 2000 |
| DB | 乐观锁扣减 + 唯一索引防重 | 最后一道防线,绝不超卖 |
② Redis 预扣库存(Lua 保证原子)
-- KEYS[1] = stock:sku:1001 KEYS[2] = order:dedup:{userId}:{skuId}
-- ARGV[1] = userId ARGV[2] = 1
if redis.call('EXISTS', KEYS[2]) == 1 then
return 2 -- 2 = 重复下单
end
local stock = tonumber(redis.call('GET', KEYS[1]))
if stock == nil or stock <= 0 then
return 0 -- 0 = 售罄
end
redis.call('DECRBY', KEYS[1], tonumber(ARGV[2]))
redis.call('SET', KEYS[2], '1', 'EX', 3600) -- 限购 + 幂等
return 1 -- 1 = 扣减成功
为什么用 Lua 而不是 DECR + 判断:GET 和 DECR 之间不是原子的,并发下会超卖。Lua 脚本在 Redis 内单线程原子执行。
③ 数据库乐观锁兜底
UPDATE stock
SET quantity = quantity - 1, version = version + 1
WHERE sku_id = 1001 AND quantity > 0;
-- 影响行数 = 0 → 扣减失败,返回售罄
用 quantity > 0 而不是 version,更简洁且天然防超卖。
④ 异步下单
MQ 消费端做「创建订单 + 扣减 DB 库存」,消费失败进重试队列,超过次数进死信队列人工处理。前端轮询/长连接查询结果(「排队中 → 抢购成功/失败」)。
⑤ 三个必答细节
- 超卖:Redis Lua + DB 乐观锁,双保险
- 少卖(Redis 扣了但订单创建失败):定时对账,把「Redis 已扣、DB 无订单」的库存回补
- 防刷:限购(
order:dedup:{userId}:{skuId})、设备指纹、风控名单
加分项:主动说「秒杀系统的核心指标不是 QPS,是「不超卖 + 不宕机」。为了让 1000 个成功用户拿到商品,宁可让 99.9 万用户看到「已售罄」,也不能让数据库崩掉。」
Q28:常见的限流算法有哪些?分布式限流怎么做?
参考答案
① 四种算法对比
| 算法 | 原理 | 优点 | 缺点 |
|---|---|---|---|
| 固定窗口 | 每个时间窗计数,超阈值拒绝 | 实现最简单 | 临界问题:窗口切换瞬间可放过 2 倍流量 |
| 滑动窗口 | 把窗口切成 N 个小格,滚动统计 | 平滑,解决临界问题 | 需要存储 N 个计数 |
| 漏桶 | 请求入桶,以固定速率流出 | 绝对平滑,保护下游 | 无法应对突发(即使下游有能力) |
| 令牌桶 | 按固定速率生成令牌,请求取令牌 | 允许突发(桶内积累的令牌) | 参数需调优 |
面试结论:保护下游用漏桶,允许突发用令牌桶,简单场景用滑动窗口。
② 单机:Guava RateLimiter(令牌桶)
// 每秒 100 个令牌,预热 1 秒(冷启动不打死下游)
RateLimiter limiter = RateLimiter.create(100, 1, TimeUnit.SECONDS);
if (limiter.tryAcquire(200, TimeUnit.MILLISECONDS)) {
doBusiness();
} else {
throw new BizException("请求过于频繁");
}
③ 分布式:Redis + Lua(滑动窗口)
-- KEYS[1] = limit:{api}:{userId} ARGV[1] = 窗口大小(ms) ARGV[2] = 阈值
local now = redis.call('TIME')[1] * 1000 + math.floor(redis.call('TIME')[2] / 1000)
local window_start = now - tonumber(ARGV[1])
redis.call('ZREMRANGEBYSCORE', KEYS[1], 0, window_start) -- 清理过期
local count = redis.call('ZCARD', KEYS[1])
if count >= tonumber(ARGV[2]) then
return 0
end
redis.call('ZADD', KEYS[1], now, now .. '-' .. math.random(100000))
redis.call('PEXPIRE', KEYS[1], ARGV[1])
return 1
为什么用 ZSet:score 存时间戳,天然支持范围删除和计数。
缺点:每个请求都要写 Redis,热点 key 压力大。优化:本地令牌桶 + Redis 兜底(本地先挡 90%,超出的再问 Redis)、或用 Redis Cluster 分片。
④ 生产形态:Sentinel / 网关限流
@SentinelResource(value = "createOrder",
blockHandler = "handleBlock",
fallback = "handleFallback")
public Order createOrder(OrderCmd cmd) { }
public Order handleBlock(OrderCmd cmd, BlockException e) {
throw new BizException("当前排队人数较多,请稍后重试");
}
Sentinel 的核心概念:资源 → 规则(流控/熔断/热点/系统)→ 槽链(Slot Chain)。相比 Hystrix 的线程池隔离,Sentinel 用信号量 + 统计窗口,开销更小。
Q29:分布式锁你有几种实现?为什么有人说 Redis 锁不可靠?
参考答案
① 五个版本的演进(一定要讲演进,不要直接说 Redisson)
// v1:SETNX —— 有死锁风险(进程崩溃锁永不释放)
Boolean ok = redis.setnx("lock:" + id, "1");
// v2:SET NX EX —— 加过期时间,但不原子(setnx + expire 两条命令)
// v3:SET key value NX PX 30000 —— 原子加锁
String v = UUID.randomUUID().toString();
boolean ok = "OK".equals(redis.opsForValue().setIfAbsent(key, v, 30, SECONDS));
// v4:Lua 原子释放(防误删别人的锁)
// if redis.call('get', KEYS[1]) == ARGV[1] then return redis.call('del', KEYS[1]) else return 0 end
// v5:Redisson 看门狗 —— 自动续期,解决"业务没跑完锁过期"
RLock lock = redisson.getLock("order:" + orderId);
lock.lock(30, TimeUnit.SECONDS); // 不传 leaseTime 才启用看门狗(默认 30s 续一次)
② 看门狗的原理(高频追问)
// Redisson 加锁成功后,如果没指定 leaseTime,会启动定时任务
// 每 10s(leaseTime / 3)检查一次,若锁还持有则续期到 30s
// 底层是一段 Lua:pexpire 并校验 value == UUID:threadId
关键细节:lock.lock(30, TimeUnit.SECONDS) 显式指定了 leaseTime,看门狗不生效。只有 lock.lock()(无参)才启用。这个坑很多人踩。
③ 为什么 Redis 锁「不可靠」(Martin Kleppmann 的质疑)
经典场景:
T1: 客户端 A 拿到锁(TTL 30s)
T2: A 发生长时间 GC / 网络延迟,暂停 35s
T3: 锁自动过期
T4: 客户端 B 拿到锁,开始写数据
T5: A 恢复,以为自己还持锁,继续写 ← 数据冲突!
三种应对:
- fencing token:每次加锁返回递增的 token,存储层拒绝比已见过的 token 更小的写入(本质要靠存储层支持)
- 看门狗:减少「业务没跑完锁过期」的概率,但解决不了 GC 停顿(进程都停了,续期也停)
- Redlock:向 N 个独立 Redis 节点加锁,过半成功才算拿到(N=5,容忍 2 个故障)。但 Kleppmann 指出:Redlock 依赖各节点的时钟准确性,时钟跳变仍会破坏安全性;而且它解决不了上面那种「持锁者暂停」的问题
④ 我的实践观点(这是加分项)
| 场景 | 方案 |
|---|---|
| 只是防止重复执行(如定时任务防并发) | Redis 锁够用,加幂等兜底 |
| 需要绝对安全(资金、库存) | 用数据库行锁 / 唯一索引 / 乐观锁,别用分布式锁 |
| 需要强一致 + 高可用 | ZooKeeper / etcd(临时顺序节点 + session 机制,天然有租约) |
| 大多数业务 | Redisson + 业务侧幂等,接受「锁只是降低概率,不是保证」 |
一句话收尾:「分布式锁的本质是「提高互斥概率」,不是「保证互斥」。真正需要保证的地方,必须靠存储层的约束(唯一索引、版本号、状态机)来兜底。」
Q30:接口幂等怎么设计?
参考答案
① 先明确:什么接口需要幂等
- 查询:天然幂等
- 新增:不幂等(重复插入)
- 更新:
SET status = 1幂等,SET count = count + 1不幂等 - 删除:
DELETE WHERE id = 1幂等
② 四种实现
| 方案 | 实现 | 适用 | 缺点 |
|---|---|---|---|
| 唯一索引 | UNIQUE KEY uk_biz(biz_id) + INSERT ... ON DUPLICATE KEY UPDATE | 插入类,最可靠 | 依赖 DB,需要设计业务唯一键 |
| Token 机制 | 先申请 token 存 Redis,提交时 DEL 成功才处理 | 表单提交、支付 | 多一次请求 |
| 状态机 | UPDATE ... WHERE status = '待支付',影响行数为 0 则跳过 | 订单流转 | 需要状态设计 |
| 分布式锁 | SETNX order:123 | 短时防重 | 锁过期/失效问题(见 Q29) |
③ 推荐的组合方案
@PostMapping("/order")
public Result createOrder(@RequestBody OrderCmd cmd,
@RequestHeader("Idempotency-Key") String idemKey) {
// 第一道:Redis 快速拦截(挡 99% 重复)
Boolean first = redis.opsForValue().setIfAbsent("idem:" + idemKey, "1", 24, HOURS);
if (Boolean.FALSE.equals(first)) {
return Result.ok(orderMapper.findByBizNo(cmd.getBizNo())); // 返回首次结果
}
try {
// 第二道:唯一索引兜底(防 Redis 宕机/过期)
return Result.ok(orderService.create(cmd));
} catch (DuplicateKeyException e) {
return Result.ok(orderMapper.findByBizNo(cmd.getBizNo()));
}
}
CREATE TABLE `orders` (
`id` BIGINT PRIMARY KEY AUTO_INCREMENT,
`biz_no` VARCHAR(64) NOT NULL COMMENT '业务单号,幂等键',
`status` TINYINT NOT NULL,
UNIQUE KEY `uk_biz_no` (`biz_no`)
);
④ 三个容易漏的细节
- 先执行还是先占位:Redis 占位成功但业务失败,要删除占位(否则用户重试被误判为重复)
- 返回一致性:重复请求要返回首次的结果,而不是「重复提交」错误——否则前端无法区分
- 异步接口:幂等键要贯穿到 MQ 消费端(消息体带
bizNo),不能只在 HTTP 层做
Q31:一个系统能扛多少 QPS,你怎么评估?
参考答案
① 估算(自顶向下)
日活 100 万,人均 50 次请求 → 日均 5000 万
峰值 QPS = 5000 万 / 86400 × 峰值系数(3~5) ≈ 1700 ~ 2900
再留 2 倍余量 → 目标 6000 QPS
关键认知:平均值毫无意义,要算峰值。 电商的峰值可能是均值的 10 倍以上(大促),社交类通常 3~5 倍。
② 单机能力基线(经验值,用于快速估算)
| 组件 | 单机 QPS(参考) | 说明 |
|---|---|---|
| Nginx(静态) | 3~5 万 | 受带宽限制 |
| Tomcat(简单接口) | 2000~5000 | 复杂业务降到 200~500 |
| Redis(单实例) | 8~10 万 | 单核,pipeline 可更高 |
| MySQL(主键查询) | 2000~5000 | 写入更低,1000~3000 |
| Kafka(单分区) | 10 万+ | 顺序写 |
③ 压测才是唯一真相
# wrk
wrk -t8 -c1000 -d60s --latency http://api.example.com/order
# JMeter 分布式压测(注意压测机本身别成为瓶颈)
jmeter -n -t order.jmx -l result.jtl -e -o report/
看四个指标,不看平均值:
- TPS/QPS:吞吐
- P99 RT:99% 的请求在这个时间内返回(比平均值真实得多)
- 错误率:超过 0.1% 就该停
- 资源水位:CPU < 70%、连接池 < 80%、GC 停顿 < 100ms
④ 找瓶颈的顺序
CPU → 内存/GC → 磁盘 IO → 网络带宽 → 连接池 → 下游依赖 → 数据库锁
用 arthas / async-profiler 抓火焰图,看 CPU 花在哪(序列化?正则?日志?)。
⑤ 高并发的四板斧(总结句)
- 缓存:把读请求挡在 DB 之前(占比 90% 以上)
- 异步:能不同步做的都进 MQ(降低 RT,削峰)
- 拆分:读写分离、分库分表、微服务拆分(降低单点压力)
- 限流降级:主动放弃一部分请求,保住整体(这是最容易被忽视但最重要的一环)
七、答题框架与复盘清单
1. 面试答题的「三段式」
任何一道题都按这个结构说,比想到哪说到哪强 10 倍:
① 一句话结论 → 先给判断,让面试官知道你想清楚了
② 原理/机制展开 → 2~3 个关键点,每个点配一个具体例子
③ 实践与取舍 → 我在项目里怎么用的、遇到什么问题、为什么这么选
示例(被问「为什么用 Redis 不用本地缓存」):
结论:取决于一致性和数据量的要求,我们是两级都用。 原理:本地缓存 Caffeine 在 JVM 内,纳秒级,但多实例之间不一致、容量受堆限制;Redis 集中式,毫秒级,容量可扩,但多一次网络往返。 取舍:我们让 Caffeine 只存「变更极少、允许秒级延迟」的配置类数据(TTL 5 分钟),商品价格这种强一致数据只用 Redis。这样 90% 的读请求不落网络,同时保证价格不会错。
2. 被问住时的正确姿势
不要说「这个我没用过」就结束。可以说:
「这块我在生产上没直接做过,但我的理解是……(说出你能推导的部分)。如果是我来做,我会先从……入手验证。」
面试官要的是推理能力,不是百科。
3. 面试前一周的复盘清单
- [ ] 每个板块能脱稿讲 5 分钟,不卡壳
- [ ] 手写:单例(DCL)、线程池创建、Spring 事务自调用失效的修复、秒杀 Lua 脚本
- [ ] 能量化:QPS、RT、数据量、优化前后的对比数字(有数字的简历才可信)
- [ ] 准备 2 个项目难点:背景 → 你的方案 → 为什么不用别的 → 结果(带数字)
- [ ] 反问准备 3 个:团队技术栈?这个岗位最急需解决的问题?代码 review 和上线流程?
4. 六句话总结全文
- HashMap 的 2 的幂、线程池的先入队、Spring 的三级缓存——三个「为什么」答不出来,说明只看过结论。
- 锁的粒度越细,synchronized 越有优势;虚拟线程时代要警惕 pinning。
- 事务失效 90% 是代理问题,不是配置问题。
- RR 解决了快照读的幻读,没解决「快照读 + 当前读」混合的幻读。
- 缓存一致性的终极方案是订阅 binlog,延迟双删只是权宜。
- 高并发的本质是漏斗和取舍,不是把所有请求都扛下来。
如果这篇文章帮你理清了某个点,欢迎点赞收藏。有想深挖的题(比如「G1 的 SATB 到底怎么实现」「Seata AT 的全局锁细节」),评论区告诉我,我单独写一篇拆到底。
转载自 CSDN-专业IT技术社区
原文链接:https://blog.csdn.net/qq_36021478/article/details/170027933




