Java 面试题库完整版:151 题完整回答

2026 年 10 月 23 日 · 星期五Java面试职业发展

用法:每题的「答」按 30–60 秒口语节奏写的,直接复述即可;答完最好补一句自己项目的关联,就是从「背题」到「理解」的分水岭。配套页面:面试当晚手册(自我介绍/HR/手写代码) · 五大项目深挖全解。

↑ 目录

一、Java 核心必背(12 题)

Q:讲一下 HashMap 的原理?1.8 做了哪些优化?

答:HashMap 底层是数组加链表加红黑树。put 的时候先对 key 的 hashCode 做扰动——高 16 位异或低 16 位——再和数组长度减一做与运算定位到桶,这样能让高位也参与运算、散列更均匀。冲突的元素在桶里挂链表;当链表长度到 8 且数组长度到 64 时,链表会树化成红黑树,最坏查找从 O(n) 降到 O(logn)。负载因子默认 0.75,元素超过容量乘 0.75 就扩容一倍。1.8 的优化主要三点:头插法改尾插法,解决了 1.7 并发扩容时链表成环、CPU 100% 的死循环问题;引入红黑树应对哈希攻击;扩容时不再重算 hash,只看新增的那个有效位是 0 还是 1,把一条链拆成 lo/hi 两条分到新旧位置,效率更高。

Q:ConcurrentHashMap 1.7 和 1.8 的区别?

答:1.7 是分段锁的方案,整个容器拆成 16 个 Segment,每个 Segment 继承 ReentrantLock,锁的粒度是段,理论并发度就是段数。1.8 把分段锁抛掉了,数据结构和 HashMap 对齐成数组加链表加红黑树,锁粒度细到单个桶:桶为空时直接 CAS 插入,不为空就 synchronized 锁住桶头节点,不同桶之间完全并行,并发度大幅提升。另外统计 size 用 CounterCell 把计数分散到多个格子再求和,避免所有线程竞争一个计数变量。一句话总结:从「分段加锁」进化到「CAS + 桶级 synchronized」。

Q:synchronized 的锁升级过程?

答:锁信息记录在对象头的 Mark Word 里,随着竞争加剧逐级升级。最开始是无锁;如果始终只有一个线程反复获取,会进入偏向锁状态——Mark Word 直接记下这个线程 ID,之后该线程再进入几乎零成本;当出现第二个线程竞争,就升级为轻量级锁,线程在自己的栈帧里建 Lock Record,用 CAS 自旋的方式去抢锁,不切内核;如果自旋一直失败、竞争进一步加剧,就膨胀成重量级锁,依赖操作系统的 mutex,拿不到锁的线程会被挂起,有上下文切换的开销。锁只能升级不能降级。可以补一句:偏向锁的维护成本太高,JDK 15 之后已经默认废弃了。

Q:synchronized 和 ReentrantLock 怎么选?

答:两个都是可重入互斥锁。synchronized 是 JVM 层面实现的关键字,优势是简单、锁会自动释放,永远不会忘记 unlock;局限是只能非公平锁,拿不到锁就一直死等,不能中断。ReentrantLock 是 JDK 层面基于 AQS 实现的,功能更完整:支持公平锁、tryLock 带超时的尝试获取、lockInterruptibly 可中断,还有一个锁能绑多个 Condition 做精准唤醒。我的选择标准是:常规同步直接 synchronized,性能在 1.6 锁优化后差距不大;只有需要超时获取、可中断或者复杂条件队列的场景才用 ReentrantLock。

Q:volatile 的语义?能保证原子性吗?

答:两个语义:可见性和有序性。可见性指写一个 volatile 变量会立刻刷回主存,并让其他 CPU 里对应的缓存行失效,别的线程读时强制从主存拿,所以一个线程的修改对其他线程立即可见;有序性指通过内存屏障禁止编译器和处理器把指令重排到屏障外面。但必须强调:volatile 不保证原子性。i++ 是「读、加、写」三步,多个线程同时执行照样丢更新,要原子得用 AtomicInteger 或者加锁。它最经典的应用场景是 DCL 双重检查锁单例——new 对象实际是分配内存、初始化、赋引用三步,如果重排成先赋引用再初始化,别的线程可能拿到半初始化对象,必须用 volatile 禁止这个重排。

Q:AQS 的原理?

答:AQS 是 JUC 里大部分同步工具的底座,ReentrantLock、Semaphore、CountDownLatch 都基于它。核心就两块:一个 volatile 修饰的 int state 表示同步资源的状态,一条 CLH 变体的双向队列存放等锁的线程。线程获取资源时先尝试 CAS 修改 state,成功就拿走;失败就封装成队列节点入队,然后 park 挂起;前驱节点释放资源时会唤醒后继节点,被唤醒的线程从挂起点继续尝试 CAS。子类只需要定义「state 什么算获取成功、什么算释放完成」,排队、挂起、唤醒这些复杂逻辑全部由框架完成——ReentrantLock 里 state 是重入次数,Semaphore 里是剩余许可数,CountDownLatch 里是倒计数值。

Q:线程池的七个参数和执行流程?

答:corePoolSize 核心线程数、maximumPoolSize 最大线程数、keepAliveTime 非核心线程空闲存活时间、unit 时间单位、workQueue 任务队列、threadFactory 线程工厂、rejectedExecutionHandler 拒绝策略。任务提交后的流程是四步:线程数不到 core 就创建核心线程执行;到了 core 就入队;队列满了就继续开新线程,直到 max;到 max 还处理不过来就执行拒绝策略,四种内置的是 AbortPolicy 抛异常、CallerRunsPolicy 让提交任务的线程自己执行、DiscardPolicy 静默丢弃、DiscardOldestPolicy 丢掉队头最老任务。参数上,CPU 密集型给核数加一,IO 密集型两倍核数起步再压测调优。生产红线:不用 Executors 的快捷方法——FixedThreadPool 是无界队列、CachedThreadPool 线程数上限是 Integer.MAX_VALUE,都有 OOM 风险,必须手动 new ThreadPoolExecutor,队列必须有界。

Q:ThreadLocal 为什么会内存泄漏?

答:ThreadLocalMap 的 Entry 里,key 是对 ThreadLocal 的弱引用,value 是强引用。GC 发生后,弱引用的 key 可能被回收变成 null,但 value 还被 Entry 强引用着,Entry 又被 ThreadLocalMap 引用,而线程不死——尤其线程池里的线程是长期复用的——这些 key 为 null 的 entry 就永远无法回收,越积越多形成泄漏。所以使用规范是:用完一定在 finally 里调 remove()。另外线程池场景下,父线程的 ThreadLocal 值传不到池里的线程,要用阿里的 TransmittableThreadLocal,它通过任务包装或 agent 方式在任务提交时抓一份上下文快照、执行时回放,解决池化线程的上下文传递问题。

Q:JVM 运行时数据区有哪几块?

答:线程私有的三块:程序计数器,记录当前线程执行到的字节码行号,是唯一不会发生 OOM 的区域;虚拟机栈,每个方法调用压一个栈帧,里面是局部变量表和操作数栈,请求的栈深度超限就 StackOverflow;本地方法栈,服务于 native 方法。线程共享的两块:堆,对象实例和数组分配的主战场,也是 GC 的核心区域;方法区在 1.8 之后由元空间实现,存放类元信息,改用本地内存,不再像永久代那样和堆挤在一起、容易配小了溢出。可以补充:字符串常量池从 1.7 起就移到了堆里。

Q:G1、CMS、ZGC 的区别?

答:CMS 是最早的并发低停顿收集器,标记-清除算法,最大的问题是内存碎片,以及并发回收赶不上对象分配速度时会发生 concurrent mode failure,退化成单线程 Serial 整理,停顿雪崩,所以 JDK 14 里已经被移除。G1 是现在的默认收集器,把堆划分成等大的 Region,逻辑上分代、物理上不分代,回收时按停顿目标优先挑垃圾最多的 Region 来收,兼顾吞吐和停顿,可以用 -XX:MaxGCPauseMillis 设定期望停顿,底层是三色标记加 SATB 记录并发期间的引用变化。ZGC 面向超大堆、超低延迟场景,核心技术是指针染色加读屏障,标记、转移几乎全部和业务线程并发执行,停顿能压到 1 毫秒以内,代价是吞吐略降。选型一句话:默认 G1;堆几十 G 且延迟敏感,再考虑 ZGC。

Q:线上 OOM 或者 CPU 100% 怎么排查?

答:OOM 的前提是准备:启动参数加 -XX:+HeapDumpOnOutOfMemoryError,出了事就有 dump 文件,用 MAT 打开看支配树和 Leak Report,定位哪个对象占了几个 G、GC Roots 链上是谁在引用。CPU 高的标准流程:top -Hp 加进程号,找出最耗 CPU 的线程 id;用 printf 转成十六进制;再 jstack 抓线程栈,按 nid 找到对应线程,常见原因是业务死循环、正则回溯、频繁序列化。还有一种容易误判的情况是 GC 线程把 CPU 吃满——表面看是代码问题,其实线程都在等 GC,这时用 jstat -gcutil 看一眼,Full GC 频率异常高就是内存问题,方向就变了。

Q:什么是双亲委派?有什么场景会破坏它?

答:类加载器收到类加载请求时,自己不先加载,而是逐层向上委托给父加载器处理,只有父加载器反馈无法完成,子加载器才会自己加载。这么设计的目的有两个:一是安全,防止核心类被恶意自定义类替换,保证 java.lang.String 永远由启动类加载器加载;二是避免同一个类被重复加载、保证类在全 JVM 里的唯一性。破坏它的典型场景:一是 SPI,比如 JDBC——核心库定义接口、厂商提供实现,引导类加载器加载不到 classpath 里的驱动,只能通过线程上下文类加载器把加载动作「反向」下放;二是热部署和 OSGi 这类模块化体系,都要求打破层层向上委托的模型。

二、语言基础(12 题)

Q:Java 是值传递还是引用传递?

答:Java 只有值传递。传基本类型时,拷贝的是值本身;传对象时,拷贝的是「引用的副本」。所以方法内修改对象的内容,外部能看到;但让参数引用指向一个新对象,外部完全不受影响。把参数理解成方法私有的局部副本,就不会和「引用传递」混淆——真正的引用传递像 C++ 的引用参数,Java 里不存在。

Q:== 和 equals、hashCode 的关系?

答:== 比较的是引用是否指向同一个对象,基本类型则直接比值;equals 是 Object 的方法,默认实现就是 ==,但 String、Integer 这些类重写了它来比较内容,所以比较内容必须用 equals。有一条铁律:equals 相等的两个对象,hashCode 必须相等。因此重写 equals 就必须重写 hashCode,否则两个「相等」的对象会算出不同的哈希值,放进 HashMap 或 HashSet 就会出现「存得进去、查不出来」的经典 bug。

Q:String 为什么设计成不可变?

答:实现上是 final 类加 final 的字节数组(JDK 1.9 之后是 byte 数组加编码标记),内容和指向都改不了。收益有四点:安全——字符串常量池可以放心复用同一份对象;线程安全——不可变对象天然线程安全,不需要同步;hashCode 可以缓存——String 的 hash 字段第一次算完就存起来,特别适合做 HashMap 的 key;安全性——类加载路径、网络参数这些敏感数据如果可变,可能在传参途中被篡改。追问 new String("ab") 创建几个对象:如果常量池里没有 "ab",是两个——常量池一个、堆里 new 出来一个。

Q:String、StringBuilder、StringBuffer 的区别?

答:String 不可变,任何拼接都会产生新对象;StringBuilder 和 StringBuffer 都是可变的字符序列,内部维护一个可扩容的数组。区别在线程安全:StringBuffer 的方法全加了 synchronized,是老版本遗留的线程安全实现,性能有损耗;StringBuilder 去掉了同步,性能最好。使用上的铁律是循环拼接必须用 StringBuilder——编译器只对单表达式的 + 做优化,写到循环里的 += 每次都会 new 一个新的中间对象,量一大就是性能事故。

Q:重载和重写的区别?

答:重载是同一个类里方法同名但参数列表不同,编译期就根据参数静态类型确定调用哪个版本,是静态分派;重写是子类重新实现父类的实例方法,方法签名要一致,运行期根据对象的实际类型决定调用谁,是动态绑定,也是多态的基础。还有一条边界:private、static、final 修饰的方法不能被子类重写——static 方法只能被「隐藏」而不是重写。

Q:接口和抽象类怎么选?

答:语义不同:接口定义「能做什么」的能力契约,抽象类提炼「是什么」的共性模板。机制上,类只能单继承但可以实现多个接口,接口的成员变量只能是常量、没有实例状态,Java 8 之后接口可以有 default 和 static 方法;抽象类可以有字段、构造器和任意访问修饰符的成员,适合放公共实现。我的选型标准:定义能力、给不相关的类实现,用接口;一组类之间共享骨架代码和状态,用抽象类。JDK 里典型的组合是「接口 + 抽象骨架类」,比如 List 和 AbstractList——接口定契约,骨架类给默认实现。

Q:Integer 的缓存陷阱?

答:Integer 对 -128 到 127 之间的小整数做了缓存,Integer.valueOf 走 IntegerCache,这个区间的对象是复用的,所以两个 127 的 Integer 用 == 比较是 true;超出缓存范围,每次都是新对象,两个 128 的 Integer 用 == 就是 false。还有一个更隐蔽的点:如果一边是基本类型 int,== 会自动拆箱按值比较,结果永远正确。所以规范就一句话:包装类型之间的比较,一律用 equals。

Q:讲一下 Java 的异常体系?

答:顶层是 Throwable,分两支:Error 是 JVM 层面的严重问题,比如 OOM、StackOverflowError,应用不应该捕获也不该试图恢复;Exception 分受检异常和非受检异常,受检的像 IOException,编译器强制处理,非受检的就是 RuntimeException 及其子类,多半是程序缺陷,比如空指针、参数非法。生产上的实践:不允许吞异常,也不允许打完日志又原样上抛造成双重日志;Web 应用统一用 @RestControllerAdvice 做全局异常收敛,把异常翻译成统一的错误码结构返回。

Q:final、finally、finalize 的区别?

答:三个完全不同的东西。final 是修饰符:修饰类不可继承、修饰方法不可重写、修饰变量不可重新赋值。finally 是异常处理的一部分,配套 try 使用,无论是否抛异常都会执行,用来做资源清理和状态复位,唯一不执行的场景是前面调了 System.exit 或者 JVM 自身崩溃。finalize 是 Object 的回调方法,对象被回收前会调用,因为执行时机不确定、性能差、容易导致对象复活,Java 9 开始已标记废弃,替代方案是 try-with-resources 管理资源,或者 Cleaner 做最后一道兜底。

Q:反射和动态代理的原理?

答:反射是运行期获取类信息、访问字段和调用方法的能力,Class 对象是入口,它是几乎所有框架的基石——Spring 的依赖注入、MyBatis 的 Mapper 接口、序列化框架都靠它。动态代理是在运行期生成代理对象,两种主流实现:JDK 动态代理基于接口,通过 Proxy.newProxyInstance 生成一个实现了目标接口的代理类,方法调用统一转发给 InvocationHandler;CGLIB 不需要接口,用字节码技术生成目标类的子类,重写非 final 方法来实现拦截。Spring AOP 两条路都支持:有接口默认 JDK 代理,没有接口走 CGLIB,Boot 2 之后统一倾向 CGLIB。

Q:泛型的类型擦除?

答:Java 的泛型是编译期的语法检查,编译成字节码后类型参数会被擦除,擦成 Object 或者声明的上界,所以运行期拿不到泛型的真实类型。带来的限制:不能 new T()、不能 new T[]、不能对泛型直接 instanceof、不能给重载制造只有泛型参数不同的两个方法。通配符的原则记 PECS:生产者(只读数据)用 extends,消费者(只写数据)用 super。运行期想要泛型信息也有办法:通过带泛型的父类或字段上的 getGenericType 拿,很多框架就是这么保留元数据的。

Q:深拷贝和浅拷贝?

答:浅拷贝只复制对象本身和它的基本类型字段,引用类型字段只复制引用——拷贝出来的对象和原对象共享同一个嵌套对象,一边改了另一边看得见。深拷贝要求把整棵对象图都复制一遍,两边完全独立。实现方式有三种:递归手写或实现 Cloneable 并层层重写 clone;通过序列化再反序列化整体复制,简单但要小心循环引用和可序列化性;或者用 MapStruct、拷贝构造器这类明确映射的方式。实践上倾向于序列化或 MapStruct,JDK 的 clone 机制因为 Cloneable 设计问题不太推荐。

三、集合框架(10 题)

Q:ArrayList 和 LinkedList 怎么选?

答:ArrayList 是动态数组,随机访问 O(1),尾部追加均摊 O(1),满了扩容 1.5 倍;LinkedList 是双向链表,头尾插删 O(1),但随机访问要顺着链走,O(n)。实际项目里 ArrayList 几乎全面占优,原因有两个:链表每个节点要额外存前后指针,内存开销大;而且链表节点在堆上分散分配,CPU 缓存基本命不中,数组连续内存的局部性优势实测差距非常大。LinkedList 只有在「只在头尾频繁增删、完全不需要随机访问」的Deque 场景下才有意义——但那个场景 ArrayDeque 也做得更好。

Q:ArrayList 的扩容细节?

答:add 时发现容量不够,会 grow 到原来的 1.5 倍——老容量加上老容量右移一位——然后用 Arrays.copyOf 把整个数组搬迁到新数组,旧的数组变成垃圾等 GC。所以两个实践结论:能预估量级就在构造时给 initialCapacity,避免连续搬迁;Array.asList 和 ArrayList 的 subList 返回的是视图不是拷贝,修改会互相影响,这也是一个高频追问点。

Q:什么是 fail-fast?

答:集合内部维护一个 modCount 修改计数,迭代器创建时记录下来,每次 next 都检查,发现不一致说明迭代期间集合被结构性修改过,立刻抛 ConcurrentModificationException,这就是 fail-fast——宁可快速失败也不在不确定的状态下继续。三个推论:for-each 循环里直接调 list.remove 必然触发;要边遍历边删只能用迭代器自己的 remove,它会同步更新期望计数;多线程并发场景换 CopyOnWriteArrayList 或者 ConcurrentHashMap,它们的迭代器是弱一致性的,拿的是创建时的快照,不抛异常也不保证看到最新修改。

Q:HashMap 的 put 完整流程?

答:一步步说:先对 key 的 hash 做扰动,然后和 n-1 做与运算定位桶;桶为空,直接放进去;桶不空,如果是红黑树就走红黑树插入;是链表就顺着找,遇到相同的 key 覆盖 value,找不到就尾插到链表末尾,插完检查链长是否到 8——到了 8 先看数组是否到 64,不到就先扩容,到了才树化;最后看元素总数是否超过容量乘 0.75,超过就扩容一倍。扩容迁移时每个节点不需要重算 hash,只看 hash 在新增那个比特位上是 0 还是 1,0 留原位、1 挪到原位置加旧容量的位置,一条链拆成两条,设计得很精巧。

Q:ConcurrentHashMap 为什么不允许 null 键和 null 值?

答:核心是二义性问题。并发场景下,get 返回 null,没法区分「这个 key 不存在」还是「存在但值就是 null」——单线程的 HashMap 可以再用 containsKey 复核一下就能确认,但 ConcurrentHashMap 是并发容器,get 和 containsKey 两步调用之间,别的线程可能已经改了数据,复核结果不可信。与其留一个语义含糊的返回值,不如从源头上禁止 null,让每个返回值都有明确含义。

Q:LinkedHashMap 和 TreeMap 的原理与场景?

答:LinkedHashMap 在 HashMap 基础上多维护了一条双向链表,把所有 entry 串起来,所以能保证迭代顺序——默认是插入序,构造参数 accessOrder 设为 true 就变成访问序,刚访问过的挪到链尾。访问序这个特性天然就是 LRU:重写 removeEldestEntry,链头就是最久未用的,超容量淘汰掉,十几行代码实现一个 LRU 缓存。TreeMap 底层是红黑树,按 comparator 或自然序保持 key 有序,增删查都是 O(logn),还能做范围查询——headMap、tailMap、floorKey 这些导航操作,适合需要有序遍历或范围检索的场景。

Q:HashSet 的实现原理?

答:HashSet 内部就是包装了一个 HashMap,add 的元素作为 map 的 key,value 是一个写死的静态 PRESENT 对象占位。所以去重语义完全依赖 key 的 hashCode 和 equals,add 的返回值就是 map.put 的结果——之前不存在返回 true,已存在返回 false。由此推论:放自定义对象去重,必须正确重写 hashCode 和 equals;HashSet 的所有性能特征——负载因子、扩容机制——也和 HashMap 完全一致。

Q:Comparable 和 Comparator 的区别?

答:Comparable 是类自身实现的「自然顺序」,compareTo 写在类里面,一个类只有一种默认排序;Comparator 是外部比较器,把比较策略从类里抽出来,可以为一个类定义任意多套排序规则,配合 Java 8 的 Comparator.comparing 能写出很清晰的链式多级排序。另外提一句排序稳定性:Collections.sort 和 Arrays.sort 对对象排序用的是 TimSort,是稳定的,相等元素的相对顺序不乱——业务里多字段排序经常依赖这一点。

Q:CopyOnWriteArrayList 适用什么场景?

答:写时复制的容器:读操作完全无锁,直接读当前数组;写操作会把整个数组复制一份,在副本上改,改完通过 volatile 引用一次性替换掉旧数组。它的成本模型很极端——读几乎免费,写是 O(n) 的全量复制。所以适用面很明确:读多写极少,而且数据量不太大的场景,典型的是白名单、黑名单、监听器注册表,一天改不了几次、每次读的请求成千上万。反过来,写频繁或集合很大,每次写都全量复制,就是灾难,应该换 ConcurrentLinkedQueue 或加锁的容器。

Q:队列怎么选型?

答:单机场景:需要栈或普通队列,用 ArrayDeque,基于循环数组,无锁、缓存友好,官方就是拿来替代 Stack 和 LinkedList 队列用法的;需要优先级用 PriorityQueue,二叉堆实现,top-K 问题用它。并发场景:非阻塞的高并发队列用 ConcurrentLinkedQueue,CAS 无锁实现,但无界,要自己管容量;需要阻塞语义用 BlockingQueue 家族——ArrayBlockingQueue 有界、一把锁两个条件,天然背压,是手写生产者消费者的标准选择;DelayQueue 支持延迟取出,做延迟任务;SynchronousQueue 不存元素,提交和消费直接交接,newCachedThreadPool 用的就是它。

四、并发编程进阶(10 题)

Q:JMM 和 happens-before 讲一下?

答:Java 内存模型抽象成主内存和各线程的工作内存:所有共享变量在主内存,线程操作时拷到自己的工作内存里改,再刷回去,可见性问题就出在「什么时候刷回去、什么时候重新读」。happens-before 是 JMM 给出的一组可见性保证规则,不是时间上的先后:程序顺序规则、监视器锁的解锁先于后续加锁、volatile 写先于后续的读、线程 start 前的操作对新线程可见、join 等待的动作对等待者可见,以及传递性。写并发代码的正确姿势是按这些规则推导「我的修改对方什么时候能看到」,而不是去背哪条指令加了什么内存屏障。

Q:CAS 的原理?ABA 问题怎么解决?

答:CAS 是比较并交换,三个操作数——内存地址、期望值、新值,只有当前值等于期望值才更新,底层是 CPU 的 lock cmpxchg 指令,lock 前缀保证指令原子并锁定缓存行,由硬件直接背书。它的问题是三个:自旋——竞争激烈时反复失败重试烧 CPU;一次只能保证一个变量的原子性;还有 ABA——值从 A 改成 B 又改回 A,CAS 以为没动过。ABA 的解法是加版本号,AtomicStampedReference 每次变更戳递增,比较时值和版本一起比。高竞争计数的场景还有更优解:LongAdder 把一个计数拆成 base 加多个 Cell 分散热点,写的时候只改自己那个 Cell,读的时候求和,写吞吐远超 AtomicLong,代价是 sum 是弱一致的时刻值。

Q:死锁的条件和排查?

答:死锁要同时满足四个条件:互斥、持有并等待、不可剥夺、循环等待,所以预防只需要打破任意一条,最常用的是两个:所有线程按相同的全局顺序加锁,直接消灭循环等待;或者用 tryLock 带超时,拿不到就放弃已持有的锁重来,打破持有等待。排查很成熟:jstack 直接会输出 "Found one Java-level deadlock" 及互相等待的锁,用 Arthas 的话 thread -b 一条命令找阻塞源头。预防层面再加一条:锁粒度尽量小、锁内不要调外部接口,持有时间短了碰撞概率自然低。

Q:wait/notify 为什么必须在 synchronized 里?为什么要用 while 判断条件?

答:wait 的语义是「释放锁并挂起」,前提是你得先持有这把锁,否则释放无从谈起,所以 JVM 规定必须在同步块内调用,否则抛 IllegalMonitorStateException。而 notify 只是唤醒,它并不释放锁,被唤醒的线程要从 wait 恢复,必须重新竞争这把锁。用 while 而不是 if 判断条件,是因为存在虚假唤醒,而且被唤醒到真正拿到锁之间,条件可能又被别的线程改掉了——所以恢复后必须重新检查条件,不满足继续 wait。这道题其实考的是「wait 是在什么点挂起、就从什么点恢复」这个模型是否清晰。

Q:CompletableFuture 怎么用?

答:它把 Future 的「等结果」升级成了「编排异步任务」。常用算子按用途记:thenApply 做同步转换,thenCompose 串联下一个异步任务,thenCombine 合并两路结果,allOf/anyOf 做多路汇总,exceptionally 或 handle 做异常兜底,orTimeout 控制超时。典型场景是接口聚合:三个下游调用用 supplyAsync 并行发出,allOf 等齐再组装返回,总耗时从串行相加变成取最慢的一个。两个坑要主动说:默认所有任务跑在 ForkJoinPool.commonPool,它是全 JVM 共享的,生产必须给每个链路显式传自定义线程池;另一个是异常只会向后传播,链路上不接 exceptionally,异常就被吞在链尾。

Q:CountDownLatch、CyclicBarrier、Semaphore 的区别?

答:按「谁等谁」区分。CountDownLatch 是一次性倒计数门闩:主线程 await 等 N 个任务各自 countDown,典型用途是一个主流程等多个并行分支完成;计数到零就永久失效,不可重置。CyclicBarrier 是可重置的栅栏:N 个线程互相等,最后一个到的触发屏障放行,大家同时继续,还能配一个屏障动作做汇总计算,适合分阶段迭代的并行计算。Semaphore 是许可证: acquire 拿许可、release 还,本质是限并发数的阀门,用来做资源池、连接池或者接口的并发上限保护。

Q:除了 DCL,单例还有什么写法?

答:三种。静态内部类 Holder 模式:把实例放在私有静态内部类里,外部类加载时内部类不会加载,第一次调用 getInstance 触发内部类加载,类加载机制保证线程安全,又做到了懒加载,是懒汉式的最优解。枚举:Effective Java 首推的方案,天然线程安全,而且防反射 new、防反序列化生成新实例——反射和序列化恰恰是 DCL 和 Holder 的两个攻击面。饿汉式:类加载时就创建,绝对简单可靠,问题是不管用不用都占内存、也不能依赖运行期参数。我的习惯:能用枚举就枚举,要懒加载用 Holder。

Q:虚拟线程了解吗?JDK 21 的这个特性。

答:虚拟线程是 JVM 自己调度的轻量线程,挂载在少量平台线程(载体线程)上运行;关键机制是遇到阻塞操作时自动卸载——虚拟线程 park,载体线程去跑别的虚拟线程,所以用同步阻塞的写法就能撑起百万级并发 IO,不再需要回调式异步。适用场景很明确:IO 密集的服务,比如调用下游、查库这类线程大部分时间在等的服务。限制也要说清楚:JDK 21 里在 synchronized 块内阻塞会 pin 住载体线程,应改用 ReentrantLock,这个问题 JDK 24 起才解决;ThreadLocal 在百万线程量级下内存会被放大,官方推 ScopedValue 替代;还有编程模型变了——虚拟线程廉价,不该再池化,用即抛,通过 Semaphore 控并发度。

Q:生产上的线程池怎么管控?

答:三件事。第一,线程要有名字:用自定义 ThreadFactory 统一命名,出问题 jstack 一眼能定位是哪个池。第二,可观测:重写 beforeExecute/afterExecute 钩子,或者用 Micrometer 上报活跃线程数、队列深度、已完成任务数、拒绝次数,配告警——队列深度持续上涨就是容量不足的前兆。第三,参数可控:队列必须有界,拒绝策略必须明确,CallerRunsPolicy 其实是一种天然背压;另外 ThreadPoolExecutor 的 setCorePoolSize 等方法支持运行期动态调整,接上配置中心就能在线调参,不用重启。

Q:ThreadLocal 的使用红线?

答:三条。第一,用完必删:只要线程会被复用——线程池、Tomcat 工作线程——就必须在 finally 里 remove,否则轻则脏数据串到下一个请求,重则 value 强引用导致泄漏。第二,跨线程传递不能用原生 ThreadLocal:线程池场景要用阿里的 TransmittableThreadLocal,否则异步任务里拿不到上下文,这是我实际踩过的坑。第三,别放大对象:ThreadLocal 的生命周期跟着线程走,放几十 MB 的大对象,池里几百个线程就是几个 G 的占用。

五、JVM 进阶(10 题)

Q:对象的创建过程?

答:五步。虚拟机遇到 new 指令,先检查类是否已加载解析过,没有就先走类加载;然后在堆上分配内存,分配方式看堆是否规齐——规整用指针碰撞,不规整用空闲列表,并发分配靠 TLAB 解决:每个线程在 Eden 预先划一小块私有缓冲,在自己的缓冲里分配无竞争;内存分到后先把对象空间零值初始化,保证字段不赋值也能用;接着设置对象头,填入哈希码、GC 分代年龄、锁标志和类型指针这些元数据;最后执行构造方法 ,对象才算真正可用。

Q:对象在内存里的布局?

答:三块。第一块对象头:Mark Word 存 hashCode、分代年龄、锁标志位、偏向线程 ID 这些运行时数据,另一部分是类型指针,指向类元数据,开了压缩指针只占 4 字节;数组对象还多一个长度。第二块实例数据:真正的字段内容,JVM 会把相同宽度的字段排在一起、父类字段在子类前面来节省空间。第三块对齐填充:HotSpot 要求对象必须是 8 字节的整数倍,不够就补齐。这道题答完最好主动串一句:前面讲的 synchronized 锁升级,改的就是 Mark Word 里的锁标志位——把内存布局和并发串起来,是加分回答。

Q:对象什么情况会进老年代?

答:四种。第一是年龄晋升:对象每熬过一次 Minor GC 年龄加一,到 MaxTenuringThreshold(默认 15)进老年代;而且 JVM 有动态判定——同龄对象总大小超过 Survivor 一半,这个年龄就可以直接晋升。第二是大对象:超过 -XX:PretenureSizeThreshold 的对象直接在老年代分配,避免在新生代来回复制。第三是分配担保:Minor GC 后存活对象 Survivor 放不下,靠老年代担保接收。第四是长期存活:被长期持有的缓存、连接对象自然堆在老年代——这也是老年代治理的重点排查对象。

Q:怎么判断对象可以回收?四种引用?

答:主流是可达性分析:从 GC Roots 出发——虚拟机栈里的局部变量、静态变量、字符串常量、JNI 引用、活跃线程——沿引用链遍历,不可达的就是可回收的,解决了引用计数法解决不了的循环引用问题。四种引用强度递减:强引用,只要还在就绝不回收;软引用,内存不足时才回收,适合做缓存;弱引用,下次 GC 必然回收,ThreadLocalMap 的 key 就是弱引用;虚引用,唯一作用是对象被回收时收到通知,用来管理堆外内存,DirectByteBuffer 的清理就靠它配合 Cleaner。

Q:类加载的五个阶段?

答:加载、验证、准备、解析、初始化。加载是把字节码读进方法区生成 Class 对象;验证做文件格式、元数据、字节码、符号引用四类校验,防止恶意字节码;准备阶段给静态变量分配内存并赋零值——注意是零值,不是初始值,static int a = 1 在这一步 a 是 0;解析把常量池的符号引用换成直接引用;初始化才执行类构造器 clinit,按源码顺序给静态变量真正赋值并执行静态块。这道题的高频考点就是准备和初始化的分工,笔试常拿 static 变量值来考。

Q:逃逸分析能优化什么?

答:逃逸分析是 JIT 的分析手段,判断对象是否逃出方法或线程。不逃逸的对象有三重收益:标量替换——把对象拆散成基本类型直接放在栈上,连堆分配都省了,这是「栈上分配」的真实实现方式;锁消除——对象只在方法内使用,加的 synchronized 直接被编译器删掉;还有更好的缓存局部性。参数默认开启(-XX:+DoEscapeAnalysis)。应用层面的启示:很多「为了性能用对象池」的优化,其实不如让对象生命周期短到不逃逸,JIT 帮你做得更好。

Q:JIT 即时编译怎么工作?

答:Java 先由解释器执行,同时做热点探测:方法调用计数器和回边计数器统计热点,计数超过阈值就触发 JIT 编译成机器码。HotSpot 用分层编译:C1 编译快、优化浅,先顶上;热点继续升温就用 C2 重编译,做逃逸分析、锁消除、内联这些深度优化。所有优化里方法内联是「优化之母」——把小方法调用直接展开,消掉调用开销,也给其他优化创造条件。这也是为什么热点路径上的小函数不该刻意拆太碎:内联是有大小限制的,拆过头反而妨碍优化。

Q:GC 调优怎么做?

答:我的口径是三步,而且顺序不能乱。第一步定目标:这个服务要停顿还是要吞吐,没有目标的调优是玄学。第二步看数据:开 GC 日志(-Xlog:gc*),看 GC 频率、单次时长、每次回收的字节数和回收效果,配合监控看老年代增长趋势。第三步才是动参数:换收集器、调堆大小比例、设停顿目标。同时必须说一句实话:线上大部分「GC 问题」的根因不是参数,是代码——无界缓存、一次查全表的大对象、频繁的字符串拼接,这类问题调参没用,得改代码。这个认知本身就是加分项。

Q:常见的内存泄漏有哪些?

答:Java 有 GC,但「不再需要的对象仍被引用着」照样泄漏。高频场景:ThreadLocal 在线程池里不 remove,entry 的 value 被强引用到线程死亡;静态集合只进不出,当成缓存用却没有容量上限和过期;监听器和回调注册后没注销;连接、流没关闭;还有类加载器泄漏,热部署场景旧 ClassLoader 卸不掉。外在表现很典型:老年代占用持续爬升,Full GC 后也回落不了,最终 OOM。排查就是 dump 加 MAT 看支配树,找那个一直涨的大集合。

Q:Arthas 用过吗?线上怎么诊断?

答:用。它是阿里开源的线上诊断工具, attach 到进程上不重启。我常用的几个:watch——观察方法的入参、返回值和异常,排查「这个方法到底收到了什么」特别高效;trace——输出方法内部调用链每一层的耗时,定位慢在哪一层;profiler——采火焰图,看 CPU 都烧在哪个栈上;还有 dashboard 看整体、thread 看线程(thread -b 直接找死锁阻塞源)、heapdump 拿堆快照。配合 jstack、jmap、jstat 这些自带工具,基本覆盖了「不重启定位线上问题」的需求。能具体讲一个用 trace 定位慢调用的实例最好。

六、IO 与网络(10 题)

Q:BIO、NIO、AIO 的区别?

答:BIO 是阻塞 IO,一个连接一个线程,accept、read、write 全程阻塞,连接数一高线程就爆。NIO 是 IO 多路复用:一个 Selector 线程轮询一堆 Channel,谁就绪处理谁,配合 Buffer 的非阻塞读写,少量线程就能管理海量连接,Netty 就是这套模型。AIO 是真正的异步 IO,发起读后内核完成后回调通知,程序完全不参与等待;但 Linux 的 AIO 实现不完善,所以 Netty 放弃了 AIO 用 NIO 自己做了反应堆封装。一句话:Linux 高并发服务的事实标准就是「epoll 多路复用」的 NIO。

Q:select、poll、epoll 的区别?

答:三个都是 IO 多路复用的系统调用,差异在两点。第一是 fd 上限:select 用固定长度的位图,默认 1024 个;poll 用链表数组,没有数量上限;epoll 也没有上限。第二是效率,这是本质差异:select 和 poll 每次调用都要把整个 fd 集合从用户态拷到内核态,内核再线性遍历所有 fd 检查就绪,连接越多越慢;epoll 在内核里维护两个结构——红黑树管理所有注册的 fd,就绪链表由事件回调填充,调用 epoll_wait 只返回就绪的那部分,复杂度和总连接数无关。所以 Nginx、Redis、Netty 这些单机几万几十万连接的系统全在 epoll 上。

Q:零拷贝是什么?

答:以「读文件发网络」为例,传统方式 read 加 write 要经历 4 次数据拷贝、4 次上下文切换:DMA 拷到内核缓冲区、CPU 拷到用户缓冲区、CPU 拷到 socket 缓冲区、DMA 拷到网卡。优化有两级:mmap 把内核缓冲区映射到用户空间,省掉一次 CPU 拷贝,变成 3 次拷贝;sendfile 数据全程在内核里从文件缓冲区直达网卡,只有 2 次拷贝,如果网卡支持 scatter-gather 聚集分散,连 CPU 拷贝都省了,CPU 只负责发描述符。这就是为什么 Kafka 能把磁盘消息吐得像内存一样快——消费追尾读就是 sendfile;RocketMQ 刷盘、Tomcat 大静态文件也都用了零拷贝。

Q:Netty 的线程模型?

答:主从 Reactor 多线程模型。boss 组专门调 accept 收新连接,收完把连接注册到 worker 组的某个 EventLoop 上;每个 EventLoop 是「一个线程加一个 Selector」,绑定在这条线程上负责这条连接后续所有的读写事件——一个连接从头到尾只被一个线程处理,没有竞争。逻辑上,ChannelPipeline 把处理器串成入站、出站两条方向的链,编解码、业务 handler 挂在链上;事件在链上流动。内存上,用池化的直接内存 ByteBuf,反复利用减少分配和 GC 压力。再带上粘包拆包的处理和 IdleStateHandler 心跳,就是完整的 Netty 答案。

Q:TCP 粘包拆包怎么解决?

答:先说清楚原因:TCP 是字节流协议,只保证字节有序到达,完全没有「消息」的概念,一次 read 可能读到半条、一条或者一条半消息——这不是 bug 是特性。应用层必须自己定义消息边界,三种方案:定长,每条消息固定长度,浪费在填充上;分隔符,用特殊字符切分,内容要转义;最通用的是长度域——消息头里写明正文长度,LengthFieldBasedFrameDecoder 按头切帧。自定义协议的典型结构就是:魔数加版本号加序列化方式加长度域加正文加校验,把这几个字段讲清楚就很完整。

Q:三次握手和四次挥手,为什么?

答:握手三次,是因为双方各自要确认「我能发你能收、你能发我能收」这两件事,最少需要三次报文;更重要的是防历史连接——旧的重复 SYN 到达服务端,三次握手会让服务端回 SYN-ACK,客户端发现序号不对回 RST 拒绝,两次握手就没法拒绝,会被历史连接污染。挥手四次,因为 TCP 全双工,每个方向要单独关:被动方收到 FIN 先 ACK,等自己应用层把数据发完再发自己的 FIN。TIME_WAIT 要等 2MSL:一是最后的 ACK 丢了还能重发,二是让旧连接的报文在网络里自然消亡,不污染新连接。服务端大量 TIME_WAIT 的根因是短连接频繁,解法是长连接、连接池。

Q:TCP 的可靠性靠什么保证?

答:四套机制。序号加确认应答:每个字节编号,接收方 ACK 确认,超时没确认就重传;超时时间还是自适应的,根据 RTT 动态算。滑动窗口做流量控制:接收方通告自己的缓冲区余量,发送方按窗口发,防止把慢接收方淹没。拥塞控制管网络:慢启动指数增长到阈值、拥塞避免线性增长、丢包就快重传加快恢复降窗,四个阶段联动,避免把网络打崩。再加上校验和检测报文损坏。补一句对比:UDP 这些都不做,换来低延迟,适合实时音视频;QUIC 的思路是在 UDP 上把这套可靠性重新实现一遍,同时消灭 TCP 的队头阻塞。

Q:HTTP 1.1、2、3 的演进?

答:1.1 引入长连接和管道化,但应用层是串行响应,前面请求慢就阻塞后面,叫队头阻塞,浏览器只能开多条连接硬扛。HTTP/2 三个核心改造:二进制分帧——报文拆成帧,流交错传输;多路复用——一条 TCP 连接上并行多个流,应用层队头阻塞解决;还有 HPACK 头部压缩和服务端推送。但 TCP 层的队头阻塞还在——任何一个流丢包,TCP 重传期间整条连接都得等。HTTP/3 换底层:基于 UDP 的 QUIC,流之间独立,丢包只阻塞自己那条流;握手融合进加密,1-RTT 甚至复用会话 0-RTT;连接用 Connection ID 标识,换网络不断流。

Q:HTTPS 的握手过程?

答:TCP 三次握手之后进入 TLS 握手。客户端发 ClientHello,带上支持的 TLS 版本、密码套件和一个随机数;服务端回 ServerHello 选定套件、给出自己的随机数,并下发证书链。客户端验证证书:用 CA 的公钥验数字签名,逐级往上验到操作系统内置的根 CA,同时检查域名和有效期;通过后生成预主密钥,用证书里的公钥加密发给服务端——这是非加密协商阶段,也是「非对称」的唯一用途。双方用三个随机数推导出会话密钥,之后所有应用数据都用这个对称密钥加密——对称加密快,适合大流量。TLS 1.3 把握手压到 1-RTT,会话恢复还能 0-RTT。

Q:从输入 URL 到页面返回,发生了什么?

答:按链路讲:浏览器先查 DNS——本地缓存、系统 hosts、递归查询域名服务器拿到 IP;然后 TCP 三次握手建连,HTTPS 再加 TLS 握手;浏览器发 HTTP 请求;服务端侧,请求先到网关做路由、鉴权、限流,再到应用服务:查缓存、读库或调下游,组装响应返回;浏览器拿到 HTML 再解析 DOM、加载资源、渲染。后端面试的重点是服务端这段——每一层都可能成为瓶颈,对应的优化:DNS 与连接层有 keepalive 和 CDN,应用层有缓存和异步,数据层有索引和连接池。把链路和优化点对上,这道题就从背书变成了体系。

七、Java 8→21 新特性(8 题)

Q:函数式接口和 Lambda 的本质?四大函数式接口?

答:Lambda 的本质是「单抽象方法接口的实例」——编译期把 Lambda 体对接到接口唯一的抽象方法上,所以只有一个抽象方法的接口才能接 Lambda,这种接口用 @FunctionalInterface 标注。JDK 内置四件套按用途记:Function 有入参有返回做转换,Consumer 只进不出做消费,Supplier 不进只出做生产,Predicate 进布尔出做判断。再加上方法引用的几种形态,基本覆盖了 Stream 和各种回调的参数需求。

Q:Stream API 的原理与使用注意?

答:Stream 是声明式的数据处理管道。两个关键机制:中间操作是惰性的——filter、map 只是记录操作,不触发计算,直到遇到终端操作(collect、forEach、count)才一遍数据把整条管道跑完,而且很多场景能融合成一次遍历;短路操作 limit、anyMatch、findAny 可以提前终止。注意三点:并行流 parallelStream 共用全局 ForkJoinPool,IO 型任务会污染公共池,慎用;Stream 有状态操作如 sorted 会缓存全量数据;简单遍历别硬上 Stream,可读性和调试成本未必划算。

Q:Optional 的正确用法?

答:Optional 是类型层面的「可能没有值」声明,替代返回 null 让调用方被迫面对空值分支。正确用法:作为返回类型,配合 map、flatMap 链式转换、filter 过滤,最后 orElse、orElseGet、ifPresent 收尾。两条红线:不要拿来做字段和方法参数——字段序列化和框架兼容都有坑,参数传递空值语义更该用重载;注意 orElse 的参数是立即求值的,不管值存不存在都会执行,有副作用或开销大的场景必须用惰性的 orElseGet——这是最经典的坑。

Q:record 是什么?解决什么问题?

答:Java 16 转正的不可变数据载体。一行声明 record Point(int x, int y) {},编译器自动生成全参构造器、访问器 x() y()、equals、hashCode、toString 这一套样板代码——纯数据类从二十行变一行。它是 final 的不能被继承,字段也全不可变,语义上就是值对象,适合做 DTO、消息、多层返回值。它和 sealed 配合能做「代数数据类型」:接口 sealed 限定实现,每个实现是个 record,再配模式匹配 switch 做穷尽分支处理,编译器保证不漏情况——这是 Java 函数式建模的新范式。

Q:sealed 密封类是什么?

答:Java 17 转正。以前的类继承体系是全开的——任何人都能 extends 你的接口;sealed 允许作者显式限定「谁可以实现我」:permits 列表写死实现类,列表外的类编译就报错。它补上了「开放」和「final 全关」之间的中间态。真正威力和 record 加模式匹配连起来:密封接口限定一组 record 实现,switch 模式匹配处理时,编译器知道实现类是封闭集合,漏了分支直接编译错误——这就是穷尽性检查,重构时新增一个实现,所有处理点编译器立刻标出来。

Q:var、switch 表达式、模式匹配分别是什么?

答:var 是 Java 10 的局部变量类型推断,右边信息足够时左边不用写类型,只能用于局部变量,可读性优先的原则不变。switch 表达式 Java 14 转正:switch 从「语句」变成「表达式」有返回值,可以用箭头语法、没有 fall-through 穿透、多值匹配用逗号,配合 yield 在块里返回值。模式匹配两步:instanceof 模式匹配 Java 16 转正,类型判断和强转一行搞定;Java 21 的 record 模式能直接解构记录的字段,switch 里匹配类型同时拆出变量。这些语法减的都是「仪式代码」,答的时候举一行对比例子最直观。

Q:Java 21 除了虚拟线程还有什么?

答:主线就是虚拟线程正式转正,这是关注点。其他值得提的:分代 ZGC 在 21 转正——给 ZGC 加了分代,普通对象在小堆里快速回收,大堆的吞吐损耗显著改善;结构化并发和 ScopedValue 还在预览,一组关联任务当作一个工作单元统一管理生命周期、共享上下文,是并发编程模型的演进方向。面试策略:提一句「21 的主线是虚拟线程,但分代 ZGC 对大堆低延迟服务同样重要」,再展开虚拟线程——能讲 pin 和池化思维转变就已经超过多数候选人。

Q:生产升级 Java 版本怎么规划?

答:路线是 8→11→17→21 这条 LTS 链。升级动因要讲清楚:性能和 GC 的免费收益、新语法的研发效率、生态倒逼——Spring Boot 3 基线就是 17,很多新框架不再发 8 的兼容包。风险点四个:javax 到 jakarta 的包名迁移,这是 Boot 3 升级最大的机械工作量;GC 行为变化和废弃参数要调整;老依赖的字节码增强兼容——cglib、asm 这类直接操作字节码的库版本低了会挂;序列化和反射框架对新增记录类的支持。策略是灰度:先升非核心服务观察 GC 和 RT,配套完整回归,再推全量。

八、Spring 与微服务(18 题)

Q:IOC 和 AOP 用大白话说是什么?

答:IOC 控制反转,解决的是对象创建和装配的问题:以前对象自己 new、自己管依赖,生命周期一团乱;交给容器后,我只声明「我需要什么」,对象的实例化、注入、销毁全由容器负责。好处是依赖解耦、可测试——测试时注入个 mock 就行。AOP 面向切面,解决横切关注点:事务、日志、权限、监控这些逻辑每个方法都要,散在各处就是灾难;AOP 把它们抽成切面,在运行期动态织入目标方法。实现本质是动态代理:类有接口走 JDK 代理,没接口走 CGLIB 子类代理——所以它也是理解事务失效、自调用失效这些坑的钥匙。

Q:Bean 的生命周期?

答:按阶段走:实例化——容器调构造器创建对象;属性填充——依赖注入;Aware 回调——BeanNameAware、ApplicationContextAware 这些让 Bean 拿到容器信息;BeanPostProcessor 前置处理;初始化——依次是 @PostConstruct、InitializingBean 的 afterPropertiesSet、init-method;再过 BeanPostProcessor 后置处理——重点在这里,AOP 代理就是在这个阶段由 AnnotationAwareAspectJAutoProxyCreator 生成的;然后 Bean 进入单例池供使用;容器关闭时走销毁:@PreDestroy、DisposableBean。答的时候点出「代理在后置处理器生成」这一句,直接为循环依赖和事务失效埋好伏笔。

Q:循环依赖的三级缓存?

答:A 依赖 B、B 又依赖 A。流程:A 先实例化,把自己的早期引用放进三级缓存,然后填充属性发现要 B;B 创建时发现要 A,从三级缓存里拿到 A 的早期对象(必要时通过工厂提前生成代理),完成自己的创建;A 拿到 B,继续走完生命周期。三级缓存分别存成品单例、半成品早期对象、以及 ObjectFactory 工厂。为什么第三级是工厂而不是直接放对象:为了延迟代理生成——只有发生循环依赖时才提前创建 AOP 代理,且保证注入的和容器里最终的是同一个代理对象。两个边界:构造器循环依赖无解,只能 @Lazy;prototype 作用域不支持,缓存只对单例生效。

Q:@Transactional 什么时候失效?

答:根源就一条:事务是代理对象通过 AOP 加的,绕过代理或异常不符合规则就失效。六个高频场景:同类自调用——this 调用不走代理,事务注解等于没写;方法不是 public;异常被 catch 吞掉,代理感知不到;默认只回滚 RuntimeException 和 Error,受检异常要 rollbackFor 指定;传播行为配错,比如 NOT_SUPPORTED 挂起事务;多线程里执行——事务上下文绑定在 ThreadLocal,子线程不在同一个事务。排查思路也从根上来:确认调用的是不是代理对象、异常有没有抛到代理层、rollbackFor 覆盖没覆盖。

Q:Spring Boot 自动装配的原理?

答:@SpringBootApplication 里的 @EnableAutoConfiguration 是入口。它通过 AutoConfigurationImportSelector 读取所有 jar 包里 META-INF/spring/...AutoConfiguration.imports 文件(Boot 2.7 之前是 spring.factories),拿到一堆自动配置类的全限定名;再经过条件过滤——@ConditionalOnClass 类路径有这个类才生效、@ConditionalOnMissingBean 用户自己配了就不生效、@ConditionalOnProperty 属性开关——最终只有条件满足的配置类才注册 Bean。starter 的本质就是「依赖打包加自动配置」:引入 starter 引入依赖,自动配置类按条件装配组件,用户用配置文件改参数。所以答这道题的关键词是:imports 文件、条件注解、约定优于配置。

Q:Spring Boot 3 有什么变化?

答:三条主线。基线提升:Java 17 起步,Jakarta EE 9 的包名迁移——javax. 全部改到 jakarta.,这是从 2 升 3 最大的改造成本,所有 import 都要动。原生支持:官方支持 GraalVM AOT 编译成原生镜像,启动毫秒级、内存减半,适合 Serverless 和弹性场景,代价是构建慢、反射和动态代理要提前配置。生态联动:Spring Cloud、Spring Cloud Alibaba 对应升到 2022.x 及以后的版本线,老项目升级必须整条链路一起动。我的建议:新项目直接 3 加 17,老项目评估 jakarta 迁移工作量再排期。

Q:网关在架构里做什么?

答:统一入口层,四个职责。路由:按路径、域名、Header 的谓词把请求转到对应服务;过滤器链:横切逻辑收口——统一鉴权、限流、日志埋点、灰度染色、脱敏;防护:连接超时、重试策略、熔断兜底,把流量问题挡在业务之外;可观测:全站统一的访问日志和 traceId 生成。技术选型上我们用 Spring Cloud Gateway,基于 Netty 非阻塞模型,配 Sentinel 的网关流控做按路由和按参数的限流。价值一句话:把「每个服务都要做一遍」的事,收敛到一个地方做。

Q:服务雪崩怎么防?

答:四个手段组合。熔断:当下游错误率或慢调用比例超过阈值,断路器打开直接快速失败,过一段半开放少量探测请求,恢复了再闭合——防止故障扩散和线程堆积。限流:入口按 QPS 或并发数限流,超出直接拒绝,保护自己不被打穿。降级:被熔断或限流的请求走兜底逻辑——返回默认值、缓存快照、简化流程,有损服务好过无服务。隔离:线程池或信号量隔离,让一个下游的问题只消耗自己的资源池。落地用 Sentinel:滑动窗口实时统计、规则控制台动态下发、链路维度限流。我在农场项目里配的是慢调用比例熔断加热点参数限流,可以举个具体例子。

Q:@Autowired 和 @Resource 的区别?哪种注入方式好?

答:@Autowired 是 Spring 的,按类型装配,类型有多个候选再按字段名或 @Qualifier 指定;@Resource 是 JSR-250 标准,先按名字匹配,找不到再按类型。注入方式上我推荐构造器注入:字段不可变可以声明 final;依赖在构造器签名里一目了然;强依赖显式化,缺依赖启动就报错而不是运行时才发现 NPE;而且构造器注入天然没有循环依赖藏身之处,有问题启动期暴露。Spring Boot 对单构造器的类自动注入,连注解都不用加。

Q:事务传播行为,结合场景讲?

答:常讲的三种。REQUIRED 是默认:方法调用在同一个事务里,要么全成要么全败——常规业务链路用它。REQUIRES_NEW 挂起当前事务、开一个独立的新事务:典型场景是操作日志——业务回滚了日志也要留下来,日志方法必须自己独立提交,不受主事务影响。NESTED 嵌套事务用保存点:内层回滚只回到保存点,外层可以继续;区别于 REQUIRES_NEW 的点是外层回滚时嵌套事务也一起完蛋,因为它在外层事务里。必须补一句:传播行为靠代理实现,同类自调用时任何传播行为都不会生效。

Q:@Async 有什么坑?

答:三个。第一,它和事务一样是代理实现的:同类自调用不生效,异步方法必须从外部调用。第二,最危险的:默认的线程池是 SimpleAsyncTaskExecutor——它不复用线程,每次执行都新建一个线程,高并发下直接把系统线程打爆,所以必须配置自定义的 ThreadPoolTaskExecutor 并指定给 @Async。第三,异步方法的异常不会传回调用方,调用方感知不到失败,必须配 AsyncUncaughtExceptionHandler 或在方法内部把异常处理和告警做完整。这三个坑答全,说明真用过。

Q:循环依赖为什么三级缓存,两级不行吗?

答:假设去掉第三级工厂缓存,直接在第二级放提前生成的代理或原始对象,两种做法都有问题。如果提前生成代理放进去:那所有 Bean 一实例化就得生成代理,AOP 的代理生成本该在初始化后的后置处理器里做,提前生成破坏了生命周期顺序,也不管有没有循环依赖都得做,浪费。如果放原始对象:注入给别人的是原始对象,容器里最终放的是代理对象,两个不是同一个东西,后续判断和注入就乱了。第三级存 ObjectFactory 的精髓:平时不生成;真发生循环依赖、有别人要引用我时,工厂才按需生成代理(或原对象)并升级到二级缓存——既保证延迟性,又保证一致性。

Q:Spring 的事件机制?

答:观察者模式的框架化:发布者调 ApplicationEventPublisher 发布事件,监听方用 @EventListener 注解订阅,双方只耦合事件类型不耦合彼此,典型用于同进程内的解耦——下单成功后通知积分、库存、消息推送各自监听,主流程不用关心谁在听。关键增强是 @TransactionalEventListener:普通 @EventListener 是发布即执行,此时事务还没提交,一旦回滚,「下单成功的消息」已经发出去了;指定 phase = AFTER_COMMIT 后,监听逻辑在事务成功提交后才执行,保证事务边界和副作用一致。跨进程则换 MQ,事件机制是进程内方案。

Q:Spring Boot 的启动流程?

答:SpringApplication.run 一条线:先在构造阶段推断应用类型——普通 Servlet 还是 Reactive,加载 ApplicationContextInitializer 和 Listener 这些 spring.factories 扩展点;run 之后准备 Environment——把命令行、配置文件、环境变量的配置源合并且定优先级;创建并刷新上下文:refresh 过程里执行 BeanFactory 的后置处理器——ConfigurationClassPostProcessor 解析 @Configuration 和 @Import,这是自动装配真正发生的时刻;然后实例化所有非懒加载单例;内嵌 Tomcat 在 onRefresh 阶段启动;最后回调所有 ApplicationRunner 和 CommandLineRunner。答题抓两条主线:配置怎么加载、Bean 怎么注册。

Q:配置中心热更新的原理?

答:以 Nacos 为例分两半。推送侧:客户端发起长轮询,服务端配置变了(或 30 秒超时)返回,客户端拿到变更的 dataId 拉新值——长轮询兼顾实时性和连接成本。生效侧:新值回来后发布 EnvironmentChangeEvent 事件,@ConfigurationProperties 的 Bean 会重新绑定新值;@Value 注解的属性默认不会动,要给 Bean 加 @RefreshScope——原理是该 Bean 的缓存被销毁,下次请求要用时按新配置重新创建一份,旧实例自然替换。所以实践结论:希望热生效的配置优先用 @ConfigurationProperties,少依赖 @Value。

Q:链路追踪怎么落地?

答:核心是 traceId 全链路透传。入口处——网关生成全局唯一 traceId 放进请求头;同步调用用拦截器透传 Header,异步和线程池场景用 TTL 传递 MDC,日志格式里输出 traceId——这样任何一个日志平台里都能用 traceId 串起一次请求的全部日志;跨 MQ 的传递把 traceId 塞进消息头,消费端取出继续。方案上用 Micrometer Tracing(Sleuth 的后继)或 OpenTelemetry,配合 OTLP 导到 Tempo/Jaeger 看调用拓扑和每段耗时。价值一句话:接口慢的时候,它是定位慢在哪一段的第一入口——我们在 coremind 就是靠它把慢请求定位到具体 provider 的。

Q:优雅停机和滚动发布怎么做到不断流?

答:滚动更新时流量切走要走在 Pod 杀死前面。K8s 侧:readiness 探针保证新实例就绪才接流;preStop 配 sleep 几秒,给负载均衡和注册中心留摘除窗口;然后才发 SIGTERM。应用侧:Spring 收到 SIGTERM 后停止接收新请求,等在途请求处理完、销毁 Bean、关闭连接池;同时主动从注册中心下线,避免消费者还往旧实例发调用。两个常见漏点:preStop 不等摘流直接杀,会有几秒 502;Tomcat 关闭超时配太短,长请求被腰斩。把「摘流先行、停收新、等在途、再销毁」这条时序讲清楚就到位了。

Q:OpenFeign 调用要注意什么?

答:四个点。超时:默认的读超时很长,必须按下游 SLA 分 connect 和 read 配置,别一个默认值包打天下。重试:Feign 的重试要显式评估——只有幂等接口才敢开,非幂等接口重试就是重复下单事故。熔断兜底:配 fallback 或 Sentinel 资源规则,重点是把「下游不可用」翻译成降级响应而不是异常堆栈给用户。连接池:默认用 JDK 的 HttpURLConnection,没有连接池,高并发下性能差,要换 okhttp 或 httpclient 实现。再加一个实践:把下游接口的压测数据标注在配置里,超时和限流参数都有依据。

九、MySQL 与 Redis(25 题)

Q:MySQL 索引为什么用 B+ 树,不用 B 树、哈希或跳表?

答:B+ 树两个特性正中数据库要害。第一,非叶子节点只存键不存数据,单个页能塞更多键,扇出极大、树高很低——千万级数据也就三四层,一次查询三四次磁盘 IO;B 树每个节点都带数据,树明显更高。第二,叶子节点之间有双向链表串联,范围查询顺着链表扫就行,排序和分页都靠它;哈希虽然等值查询 O(1),但完全不支持范围和排序。跳表本身是很优秀的结构,但它是内存结构,Redis 的 zset 用它正合适——数据库索引要面对磁盘页和页分裂,B+ 树更匹配。答到「扇出、树高、IO 次数、叶子链表」这四个关键词就完整了。

Q:聚簇索引、回表、覆盖索引?

答:聚簇索引是 InnoDB 的主键索引:叶子节点存的是整行数据,数据和主键索引存在一起,「索引即数据」。二级索引的叶子节点存的是主键值而不是行数据——所以用二级索引查询,先在二级索引树里找到主键,再回到主键树里取整行,这个动作就是回表,等于查了两棵树。覆盖索引是优化手段:如果要查的字段全部包含在二级索引里,就不需要回表了——比如建 (name, phone) 联合索引,select name, phone 的查询直接在索引里拿齐。这也是优化 SQL 时优先考虑的手段:把高频查询做成覆盖索引。

Q:最左前缀原则?哪些情况索引会失效?

答:联合索引 (a,b,c) 的排序规则是先按 a 排、a 相同按 b 排、再按 c——所以查询条件必须从 a 打头才能利用索引的有序性:where a=? 用得到,where a=? and b=? 用到两列,where b=? 单独用就绕开索引了。范围查询后面的列也会失效:a>? 之后 b、c 用不上排序性。失效场景逐个说:对索引列做函数或运算——date(create_time)、id+1;隐式类型转换——字符串列传数字,等价于套了函数;左模糊 like '%x'——有序性直接废掉,右模糊 'x%' 可以;or 连接了没有索引的列;以及优化器估算出回表成本太高、全表扫更便宜时主动放弃索引——所以同一个 SQL 数据量不同可能走不走索引不一样。

Q:事务隔离级别和 MVCC?

答:四个级别:读未提交、读已提交、可重复读、串行化;MySQL 默认可重复读。MVCC 的实现三件套:每行记录有隐藏的版本信息,旧版本通过 undo log 版本链保存;每个事务开启时生成 ReadView,记录当时活跃的事务列表,判断某个版本对当前事务是否可见;读数据时顺着版本链找第一个对自己可见的版本。RC 和 RR 的区别就在 ReadView 的生成时机:RC 每次查询都新建一个,所以能看到别人提交的新数据,产生不可重复读;RR 只在第一次查询时生成、之后复用,整个事务看到的快照不变,基本避免了幻读——「基本」是因为当前读(for update)还是能看到新行,靠间隙锁补全。

Q:慢查询怎么优化?

答:一套固定流程。第一步定位:开慢查询日志,long_query_time 按业务定,抓出慢 SQL。第二步分析:explain 看执行计划——重点看 type 是不是全表扫、key 用没用上预期索引、rows 预估扫描量、Extra 里有没有 Using filesort 和 Using temporary,这两个出现必须处理。第三步优化按优先级:先加索引或改写 SQL——把 select * 改成具体列做覆盖索引、深分页改延迟关联、大事务拆小;再评估表结构——字段类型、冗余字段反范式;实在不行才分库分表,这是最后的手段,带来的分布式复杂度远超慢查询本身。最后把这套流程沉淀成规范:上线前 SQL 必须过 explain 评审。

Q:主从复制的原理?延迟怎么办?

答:主库写 binlog;从库的 IO 线程连上主库,主库的 dump 线程推送 binlog,IO 线程写入本地的 relay log;SQL 线程重放 relay log,数据就同步了。注意复制默认是异步的——主库提交不等待从库,所以有延迟窗口。延迟的处理:第一层缓解——并行复制(MTS),按事务组并行重放;半同步复制,至少一个从库 ACK 主库才返回,降低丢失风险但不消灭延迟。第二层架构——读写分离时识别「写后立读」:刚写完马上要读的场景强制路由主库,用拦截器标记请求;对延迟敏感的报表类查询也走主库。根因治理是控制大事务:一条 binlog 事务越大,从库重放越慢。

Q:分库分表的实际经验?

答:先说触发条件:单表数据量到千万级、单实例写入到瓶颈、或者连接数互相挤压,才动分库分表,不为了拆而拆。拆法:垂直拆按业务域拆库、把大字段拆出去,解决耦合;水平拆按 shard key 拆,用户类数据按 user_id 哈希取模。拆完的三个新问题都有固定解法:分布式 ID 用雪花算法或号段模式;跨片查询在设计阶段就消灭——冗余字段和宽表把查询收进单片,异构到 ES 做复杂检索;跨片事务尽量避免,必要走最终一致的补偿;扩容用一致性哈希或者双写迁移。我的总结:分库分表是「用架构复杂度换单表容量」,能靠索引、归档、读写分离扛住就先扛。

Q:Redis 为什么这么快?

答:四个原因叠加。纯内存操作,微秒级,这是基础。单线程命令处理,没有锁竞争、没有线程上下文切换——注意准确表述:6.0 之后网络 IO 是多线程的,命令执行仍是单线程,所以「单线程」指的是命令执行模型。IO 多路复用:epoll 一个线程监听海量连接,事件驱动不空转。数据结构为性能定制:SDS 预分配 O(1) 取长度、跳表、listpack 小对象紧凑存储、渐进式 rehash 的字典。面试官追问「单线程为什么还快」时的关键:瓶颈不在 CPU 在内存和网络,单线程避免了锁和切换的开销,反而把内存操作的性能吃满。

Q:缓存穿透、击穿、雪崩?

答:三个问题两两不同。穿透是查「根本不存在的数据」——缓存永远不会有,请求每次都打到库,恶意攻击常用手段;解法:布隆过滤器前置拦截,或者把空结果也缓存短 TTL。击穿是「某个热点 key 过期」的瞬间,海量并发同时回源打库;解法:互斥锁重建——只放一个请求去查库回填,其他等待;或者逻辑过期——物理不设 TTL,值里带过期时间,过期后异步刷新。雪崩是「大批 key 同时过期」或「Redis 整体宕机」,流量整体砸向数据库;解法:过期时间加随机抖动错峰、集群高可用防宕机、加上限流降级兜底——就算缓存全挂,数据库也不能被打死。三个问题的区分维度记「点、面、实例」。

Q:分布式锁的演进?

答:三阶段。第一代,自己写:setnx 加 expire——但两条命令不原子,加锁成功还没来得及设置过期就挂了,死锁;演进成一条命令 SET key value NX EX。第二代,安全性补全:value 存唯一标识,删除前用 Lua 脚本「先校验是自己的锁再删」,防止误删别人的锁——A 超时后 B 拿到锁,A 恢复后一把删掉了 B 的锁。业务执行超过锁 TTL 的问题没有完美解,Redisson 提供看门狗自动续期:后台线程定期把 TTL 续回去,同时支持可重入。第三代,可靠性权衡:Redis 主从切换可能丢锁,Redlock 多数派方案有争议(Martin Kleppmann 和 antirez 的论战),了解观点即可;强一致场景换 ZooKeeper 临时顺序节点或数据库乐观锁。我的实践:Redisson 起步,资金类场景不用 Redis 锁。

Q:大 key 和热 key 怎么处理?

答:大 key 是 value 过大或集合元素过多——一个大 hash 几百万元素。危害:操作慢阻塞单线程、网络带宽打爆、集群数据倾斜、删除时阻塞。处理:拆分——大 hash 按字段哈希分桶成多个小 key;删除用 UNLINK 异步代替 DEL;监控用 redis-cli --bigkeys 或 MEMORY USAGE 巡检。热 key 是访问集中在个别 key——明星热搜。危害:单分片 CPU 和带宽被打满。处理:本地缓存兜一层——应用内存缓存热点数据短 TTL;副本打散——key 加后缀复制多份,读写随机路由;极端场景读写分离。治理前提是发现:热 key 一般从监控的分片 QPS 偏差倒查。

Q:Redis 持久化 RDB 和 AOF?

答:RDB 是某时刻的全量快照:fork 子进程把内存数据写成压缩文件,恢复快、文件小,但两次快照之间的数据会丢,fork 瞬间如果页表大会有停顿。AOF 记录每条写命令:恢复时重放,数据最全——appendfsync everysec 每秒刷盘,最多丢一秒;缺点是文件大、恢复慢,靠 AOF 重写压缩——用当前内存状态生成等价的最小命令集。4.0 之后生产推荐混合持久化:AOF 重写时先写一段 RDB 全量再追加增量命令,重启加载快、数据窗口小,两个优点兼得。选型一句话:缓存纯加速可以只 RDB 甚至不持久化,当存储用的实例必须 AOF everysec 起步。

Q:explain 执行计划重点看什么?

答:四个列。type 访问类型,从好到坏:const 主键或唯一索引等值、eq_ref 联表时主键匹配、ref 普通索引等值、range 索引范围扫、index 扫全索引树、ALL 全表扫描——出现 ALL 基本就要优化了。key 实际使用的索引,和预期不一致要查原因。rows 预估扫描行数,量级判断。Extra 里的信号量最大:Using index 是覆盖索引不用回表,好;Using filesort 说明排序没走索引,内存或磁盘排序;Using temporary 建临时表,分组排序没索引支撑——后两个出现,这条 SQL 就是重点整改对象。

Q:深分页 limit 100000,20 为什么慢?怎么优化?

答:MySQL 要把前 10 万零 20 行都取出来——走二级索引的话还要回表 10 万次——然后丢掉前 10 万行只留 20 条,做的全是无效功。三种解法:延迟关联,先用覆盖索引把目标页的主键查出来——select id 只扫索引不回表——再 join 回表取整行,回表次数从 10 万降到 20;游标翻页,记住上一页最后的 id,下一页 where id > lastId limit 20,完全避开偏移量,性能最优,代价是不支持跳页,适合信息流这类连续翻页场景;产品侧治理,限制最大页数或改成「下一页」交互,深分页多数是伪需求。

Q:count(*)、count(1)、count(字段) 的区别?

答:InnoDB 下 count() 和 count(1) 是等价的,优化器专门优化过,不取值不判空,按最小的索引遍历计数,性能相同,网传「count(1) 更快」是过时谣言。count(字段) 语义不同:只统计该字段不为 NULL 的行,所以要多一步判空,而且如果字段上没索引, Server 层要逐行取值,最慢。另外要纠正一个认知差:MyISAM 缓存了总行数所以 count() 秒回,InnoDB 因为 MVCC——不同事务看到的行数不一样——只能真扫。大表实时计数用别的方案:冗余计数表事务里维护、或者 Redis 计数,容忍估算就用 explain 的 rows。

Q:redo log、undo log、binlog 各干什么?

答:三分工。redo log 是 InnoDB 引擎层的物理日志,记「某页改了什么」,先写日志再改数据页(WAL),崩溃后重放恢复——保证持久性,也是事务提交快的根源。undo log 是逻辑日志,记反向操作,两个用途:事务回滚和 MVCC 的版本链——保证原子性和一致性。binlog 是 Server 层的逻辑日志,记语句或行变更,用于主从复制和数据归档恢复。三者的关键配合是两阶段提交:事务提交时 redo log 先写 prepare,再写 binlog,最后 redo log commit——如果没有这个协调,崩在中间会出现主库有从库没有、或反过来的裂缝,两阶段保证两个日志要么都生效要么都不生效。

Q:update 语句没走索引为什么会锁全表?

答:RR 隔离级别下,InnoDB 的行锁是加在索引记录上的。update 的 where 条件如果有精准索引,锁只落在命中的记录和必要间隙上;如果没有索引可用,MySQL 只能走聚簇索引全表扫描——但扫描路径上经过的每一行都会加锁,效果上接近锁全表,并发更新直接大面积等锁甚至超时。而且索引类型还有差异:唯一索引等值命中就停,锁范围小;普通索引还要继续扫到区间结束,会追加间隙锁。实践结论:update 和 delete 的 where 条件必须验证有索引,code review 把这个列为检查项。

Q:MySQL 死锁怎么排查和预防?

答:排查:show engine innodb status,LATEST DETECTED DEADLOCK 段落会打印两个事务各自持有和等待的锁、以及相关的 SQL,按图索骥找业务代码。预防四条:索引要精准——条件走不到索引会把锁范围放大成整段扫描(上一题的原理);事务要小——短事务持锁时间短,碰撞概率指数下降;访问顺序要一致——多个事务按相同顺序更新资源,循环等待条件直接消灭;热点行拆批——高并发更新同一行,改成攒批异步更新或扣减挪到缓存。出现死锁本身不可怕,InnoDB 会回滚代价小的事务,可怕的是高频死锁,那说明设计有问题。

Q:字段类型怎么选?

答:几个高频决策点。定长 vs 变长:长度固定用 char,例如手机号、MD5;长度差异大用 varchar,小于 255 只占一个字节长度前缀,超过占两个。时间类型:timestamp 四字节,自带时区转换,但范围只到 2038 年;datetime 八字节,无时区语义、范围大。跨国或跨时区系统建议统一存 UTC,展示层转换。金额:绝对不用 float/double 有精度损失,用 decimal 或者「分成」的 bigint。状态类:tinyint 加注释足够,别用 varchar 存中文状态。再加一条总原则:能用小类型不用大类型——索引大小、内存占用、比较成本全跟着字段宽度走。

Q:zset 的底层和应用场景?

答:底层根据大小自适应:元素少且短时用 listpack 紧凑存储(7.0 之前是 ziplist),超过阈值转成「跳表加哈希表」的双结构——跳表负责按 score 有序的组织和范围操作,插入删除 O(logn);哈希表负责成员到分数的 O(1) 查询,两个结构共享元素,空间换时间。三个典型场景:排行榜——score 存分数或权重,ZADD 更新、ZREVRANGE 取前 N,天然实时;延迟队列——score 存执行时间戳,轮询 ZRANGEBYSCORE 取到期任务,配合分布式锁防重复消费;滑动窗口限流——成员是请求 id,score 是时间戳,每次请求 ZREMRANGEBYSCORE 清掉窗口外再 ZCARD 计数。

Q:Redis 的过期删除和内存淘汰?

答:两个不同的机制,经常被混为一谈。过期删除解决「key 到期了怎么删」:组合策略——惰性删除,访问时检查过期才删,CPU 友好但冷数据占内存;定期删除,后台周期抽样一批设了 TTL 的 key 删过期的,两者互补。内存淘汰解决「内存满了怎么办」:达到 maxmemory 触发淘汰策略,八种可选——默认 noeviction 内存满写入直接报错;allkeys-lru 从全部 key 里淘汰最近最少使用;volatile-lru 只从设了过期的 key 里淘汰;allkeys-lfu 按访问频率淘汰——缓存场景更准,新版本推荐;还有 random 和 ttl 变体。缓存场景的标准答案是 allkeys-lfu 或 allkeys-lru,当存储用的实例要显式评估。

Q:pipeline、Lua、mget 的区别?

答:三个都是「减少网络往返」的手段,层次不同。mget、hmget 这类原生批量命令是最优解——一次命令一批 key,服务端原子执行。pipeline 把多条命令打包一次发送、一次接收结果,省的是每条命令一个 RTT 的网络开销;但注意它不保证原子性——中间可以穿插别的客户端的命令,所以「批量读」用它没问题,「批量写有依赖关系」不行。Lua 脚本整段原子执行,脚本执行期间不会插入其他命令,适合「判断加操作」的复合逻辑,比如分布式锁的校验删除、库存的原子扣减;红线是脚本不能是重计算,长时间占用单线程会阻塞所有请求。优先级排序:原生命令、pipeline、Lua。

Q:缓存和数据库的一致性怎么保证?

答:主流方案是 Cache Aside 旁路缓存:读——先读缓存,miss 了查库再回填;写——先更新数据库,再删除缓存。选「删缓存」而不是「更新缓存」:避免并发写导致缓存旧值覆盖新值,也避免写了没人读的浪费。这个方案仍有不一致窗口——极端时序下别的请求把旧值又填回去——两种收窄手段:延迟双删,写完删一次,隔几百毫秒再删一次,清掉窗口内的脏读回填;订阅 binlog,用 Canal 监听变更异步删缓存,和业务代码解耦且有重试保障。本质定位要讲清楚:这是最终一致,窗口毫秒到秒级;业务真要强一致,要么别用缓存,要么读写都加锁串行——成本完全不同量级。

Q:Redis 主从、哨兵、集群怎么选?

答:按需求升级。主从复制:一主多从,读扩展和数据冗余,但主挂了要人工切换——只适合读多写少且可容忍人工介入的场景。哨兵:在主从之上加一组哨兵进程做监控和自动故障转移——主观下线升级为客观下线、选举领头哨兵执行切换;解决高可用,但容量还是单机的上限。Cluster 集群:数据分片——16384 个哈希槽分布在多个主节点,客户端直连、gossip 协议 gossip 通信,扩容迁移槽位;解决容量和写扩展,但限制要认:多 key 操作要求同槽(hash tag),事务和 Lua 受限,运维复杂度上一个大台阶。我的建议:数据量单机放得下就主从加哨兵,别为了技术先进上 Cluster。

Q:Redis 怎么做延迟队列?

答:两种方案。zset 方案:score 存任务的执行时间戳,生产者 ZADD 投递;消费者轮询 ZRANGEBYSCORE 取当前时间之前的任务,取出后用 Lua 把「查询加删除」做成原子——或者多个消费者时配合分布式锁抢任务,防止重复消费;优点是简单直观,还能按 score 范围查询。Streams 方案:XADD 投递、消费组 XREADGROUP 消费、XACK 确认,天然支持 pending 列表和未确认重试,可靠性语义比 zset 完整,更接近一个轻量 MQ。边界也要说:Redis 做延迟队列的可靠性弱于专业 MQ——持久化窗口、主从切换都可能丢;任务量大或不可丢的场景用 RocketMQ 的定时消息,我们在生产里就是这么分工的。

十、消息队列 / Flink / K8s(19 题)

Q:为什么用 MQ?带来什么代价?

答:三个收益。解耦:生产方只管发事件,消费方各自订阅,上下游互不感知、独立演进——订单系统发「订单已创建」,积分、物流、短信各自消费,新增消费方不用改生产代码。削峰:突发流量先在队列排队,消费端按自己的能力匀速处理,把流量洪峰变成平稳消费。异步:主链路只做核心写入,旁路逻辑异步化,接口 RT 直接降下来。代价也要讲全,这才是中高级答法:系统复杂度上升——多了一跳链路,排查问题要跟消息走;一致性变复杂——消费延迟、重复消费都要处理;可用性依赖——MQ 挂了链路就断,它自身要高可用。

Q:重复消费怎么办?

答:先立前提:MQ 的投递语义是至少一次,重试、rebalance、生产端重发都可能造成重复——所以重复不可避免,幂等必须做在消费端。方案按成本排序:业务唯一键加数据库唯一约束——插入重复直接冲突,最兜底;去重表——消费前 insert 业务键,成功才继续,本质同上;Redis setnx 判重配短期过期——适合高频场景,但要接受缓存失效窗口;状态机——只允许合法的状态迁移,重复消息到达时状态已推进,直接忽略,业务语义最优雅,订单类场景首选。实践组合:状态机为主、唯一约束兜底、对账事后核。

Q:Kafka 为什么快?

答:四个设计叠加。顺序写磁盘:日志 append-only 追加写,磁盘顺序写接近内存随机写的速度,这和数据库随机 IO 是本质差异。页缓存:读写都优先走操作系统的 page cache,热数据基本不落盘,还能避免 JVM GC 的开销——所以 broker 端不追求大堆,把内存留给系统缓存。零拷贝:消费者拉消息时 sendfile 直接从页缓存到网卡,数据不经过用户态。批量加压缩:生产端攒批、压缩后传输,网络效率最大化;分区机制把负载横向切到多台 broker。这四个点串成「减少磁盘寻道、减少内存拷贝、减少网络往返」一条主线。

Q:Kafka 的顺序性和 ISR 机制?

答:顺序性:Kafka 只保证分区内有序——一个分区就是一个 append 日志,天然有序;跨分区无序。所以要按「需要顺序的维度」路由:同一个订单的消息用 orderId 做 key,哈希到同一分区,消费顺序就有保障;代价是这个 key 的吞吐被单分区限制住。ISR:每个分区有一个 leader 和多个 follower,ISR 是「和 leader 保持同步」的副本集合——落后太多的会被踢出。可靠性配置是三个联动:acks=all 表示写入要等所有 ISR 副本确认;min.insync.replicas=2 保证 ISR 至少两个副本才接受写入;replication.factor≥3 三副本。加上生产端 retries 和幂等,就是「不丢」的完整答案。

Q:Flink 的 Watermark 机制?

答:处理乱序数据的核心机制。事件时间语义下,数据自带时间戳,但网络延迟导致到达顺序乱——watermark 是一个「时间进展标记」,表示「事件时间已经推进到 T,T 之前的数据理论上都到齐了」。生成规则是当前见过的最大事件时间减去允许的乱序度:watermark = maxEventTime - allowedLateness。窗口的触发不再靠系统时间,而是靠 watermark 推进——watermark 越过窗口结束时间,窗口计算触发。乱序度是业务权衡:设小了数据迟到被丢,设大了窗口延迟高、实时性差。真迟到的数据还有兜底:sideOutput 旁路输出,单独处理或修正。

Q:Flink 的状态和 Checkpoint?

答:状态是算子中间数据的记忆:Keyed State 按 key 隔离,有 ValueState、ListState、MapState 等,状态在内存或 RocksDB 里;这是流计算和批处理的本质区别——窗口聚合、去重、机器学习模型都是状态。Checkpoint 是状态的周期快照,做容错:基于 Chandy-Lamport 的 barrier 机制——source 周期性向数据流注入 barrier,随数据向下游流动,算子收到所有输入通道的 barrier 后对齐、把自己状态快照写入状态后端(大状态用 RocksDB 支持增量),然后向下游转发。失败恢复:全部算子回滚到最近一次一致的 checkpoint,source 回到对应 offset 重放。配上「source 可重放 + sink 两阶段提交」,才是端到端的 exactly-once。

Q:K8s 里 Pod 起不来怎么排查?

答:分状态按套路。CrashLoopBackOff:容器反复崩溃——kubectl describe pod 看退出事件和退出码,kubectl logs --previous 看上一次崩溃的日志,常见是启动报错或健康检查失败。OOMKilled:退出码 137,容器内存超 limit 被杀——检查 JVM 堆和容器 limit 的配比,容器里要用 -XX:MaxRAMPercentage 而不是写死 -Xmx,还要给元空间和直接内存留余量。Pending:调度不出去——describe 看 Events,资源不够、节点选择器或亲和性不匹配、配额用尽,分别对应扩容或改规格。ImagePullBackOff:镜像拉不下来——仓库权限或 tag 错误。先看 Events 再看日志,是固定顺序。

Q:K8s 的 QoS 等级和调度?

答:每个 Pod 按 requests 和 limits 的配置落到三个 QoS 等级:Guaranteed——requests 等于 limits,所有容器都设置齐,最高优先;Burstable——有 requests 但小于 limits,中等;BestEffort——什么都不设,最低。资源紧张节点被驱逐时,从低 QoS 且超出 requests 最多的 Pod 开始杀。调度层面:调度器按 requests 而不是 limits 分配节点——limits 可以超卖,节点上所有 Pod 的 limits 之和可以超过物理资源,赌的是不会同时打满;HPA 按 CPU 利用率或自定义指标自动扩缩 Pod 副本数。实践:核心服务 Guaranteed,防止被驱逐和 CPU 节流。

Q:怎么保证消息不丢?

答:消息链路三段,每一段都要设防。生产端:acks=all 等全部 ISR 副本确认才算成功,开启重试和幂等生产者(pid 加序号防重),必须处理发送回调——失败记录重发,不能发后不管。Broker 端:副本数至少 3,min.insync.replicas 设 2,acks=all 和它配合才有意义;刷盘策略按业务权衡——异步刷盘性能高但宕机丢,金融类场景同步刷盘。消费端最容易被忽略:关闭自动提交 offset,业务处理成功后再手动提交——先提交后处理,处理失败消息就丢了;失败的消息进重试和死信队列,人工兜底。三段都守住,才有「不丢」的承诺。

Q:消息积压了怎么处理?

答:先应急再根治。应急三板斧:第一步扩消费者——但要记得消费并行度的上限是分区数,消费者加到和分区数相等就到顶了;第二步还不行就「换道扩容」——写一个临时程序把消息从旧 topic 读出来,按新的 key 转发到一个分区数更大的新 topic,再扩消费者消费新 topic,相当于把一条车道拓成八条;第三步降级消费——业务允许的话改批量消费、跳过非核心字段的加工逻辑,先把消费速度提上去清积压。根治是容量治理:以消费 lag 为核心指标配告警,按峰值写入速度评估分区数和消费者数量,积压应该是容量评审算出来的,不是故障时发现的。

Q:Kafka 的 rebalance 为什么危险?怎么防控?

答:rebalance 是消费组重新分配分区的过程,危险在于触发期间整个消费组暂停消费——所有成员要互相等协调完成。触发诱因主要是三个:消费者处理太慢导致 max.poll.interval.ms 超时被踢出、心跳超时认为消费死了、成员增减滚动发布。防控对应:max.poll.records 调小,让单次批处理控制在间隔内;处理慢的下游做异步化和批量优化;用 cooperative-sticky 增量协议,只迁移必要分区而不是全量重来;配 group.instance.id 静态成员身份,滚动重启不触发 rebalance。判断题里的「消费突然集体暂停几十秒」,第一反应就该是 rebalance。

Q:Kafka 事务和 RocketMQ 事务消息的区别?

答:两个「事务」解决的是不同问题。Kafka 事务解决「多条消息的原子性」:生产端开事务会话,跨分区批量写入,要么全部可见要么全部不可见——read_committed 隔离级别下消费者读不到未提交和中止的消息;典型场景是「处理一批数据,输出多个结果 topic,要求全成全败」,配合幂等生产者实现 exactly-once 语义。RocketMQ 事务消息解决「本地事务和发消息的一致性」:生产者先发半消息——对消费者不可见——执行本地数据库事务,成功则提交消息、失败则回滚;如果生产者挂了没确认,broker 回查回调接口询问事务状态。一句话区分:Kafka 管「多条消息原子」,RocketMQ 管「DB 和消息一致」。

Q:Flink 数据倾斜怎么定位和处理?

答:先定位:Flink WebUI 看各 subtask 的记录数和耗时分布,个别 subtask 数据量或处理时间远超其他就是倾斜;再从 key 的分布找元凶——大 V、热点设备、null 值聚合到一起。解法按场景:两阶段聚合——聚合类算子先用 key 加随机前缀打散做一轮局部聚合,去掉前缀再做全局聚合,把单个 key 的压力拆到所有 subtask,实时大屏的 UV 统计经典方案;null 或默认值单独过滤分支处理;count distinct 类用 bitmap 或近似算法(HyperLogLog)替代精确去重,从计算复杂度上根治。最后补一句认知:倾斜本质上不是 Flink 的问题,是 key 分布问题,方案都是「拆」和「换算法」。

Q:Flink checkpoint 很大或很慢怎么优化?

答:先看数据:checkpoint 的 duration 和 size 趋势,慢和大是两个方向。大状态治理:开 RocksDB 的增量 checkpoint——每次只快照增量,全量恢复成本换存储和速度;给状态设 TTL,过期数据自动清理——很多「状态膨胀」就是忘了设生存期;窗口和聚合的粒度评估能不能缩小。慢的处理:barrier 对齐慢就开 unaligned checkpoint——不等待对齐直接开始快照,at-least-once 语义换速度,反压严重时救急;异步快照的线程和缓冲调优;source 端参数配合。还有一个反直觉点:checkpoint 间隔不是越短越好——间隔太短,快照本身占用算子时间,反而拖垮吞吐,要按恢复容忍度设。

Q:Flink 和 Spark Streaming 怎么选?

答:模型差异是根:Flink 是真正的逐条流处理,来一条处理一条,延迟可以到毫秒级;Spark Streaming 本质是微批——把流切成小批次处理,延迟下限就是批间隔,秒级起步。语义上 Flink 原生支持事件时间和 watermark,处理乱序数据的能力强;状态管理和窗口 API 也更细——会话窗口、允许迟到数据这些语义 Spark 要绕着实现。Spark 的优势在生态:批流一体——同一套 SQL 和引擎跑批和流,团队维护成本低,数据仓库集成成熟。我的选型:对延迟和状态语义要求高的实时链路选 Flink——工业平台的秒级告警就是它;以分析为主、延迟秒级可接受、团队 Spark 栈成熟,用 Structured Streaming。

Q:容器里的 JVM 内存怎么配?

答:这是个踩过坑才知道的题。老版本 JVM 读的是宿主机的内存和 CPU——容器 limit 2G,JVM 以为宿机 64G,默认堆开到 16G,直接被 OOMKilled,退出码 137。JDK 8u191 和 10 之后默认开启 UseContainerSupport,JVM 感知 cgroup 限制,问题基本消失。正确姿势:不写死 -Xmx,用 -XX:MaxRAMPercentage=70 这类百分比——堆占容器内存七成左右,剩下留给元空间、线程栈、直接内存和 JIT 代码缓存;Netty 重的应用直接内存占比高,比例还要压低。GC 线程和 ForkJoinPool 的并行度也要确认按容器 CPU 而不是宿机 CPU 算——新版已自适应。一句话:容器里的 JVM,百分比加余量。

Q:容器 CPU limit 为什么会造成接口毛刺?

答:K8s 用 CFS 做 CPU 限流:limit 是「每 100 毫秒能用多少毫秒 CPU」的配额,用完就被节流冻结到下个周期。Java 服务的 CPU 消耗是突发的——GC 全量回收时所有 GC 线程满跑、JIT 编译高峰、流量突增——这些瞬时突发很容易在一瞬间耗光配额,然后被 CFS 冻结几十毫秒,表现在监控上就是周期性的 RT 尖刺,但 Pod 层面 CPU 平均值看着完全正常,非常隐蔽。解法:对延迟敏感的服务调大 limit 或直接 Guaranteed——requests 等于 limits 不参与超卖节流;或者只设 requests 不设 limit,接受超卖但消除毛刺。排查特征也记住:毛刺周期接近 100ms 的倍数,基本就是 CFS 节流。

Q:滚动发布怎么做到用户无感知?

答:K8s 默认的滚动更新本身不保证不断流,要三层配合。调度层:maxSurge 和 maxUnavailable 控制节奏——先增后减、宁可慢不可断;readiness 探针没通过的新 Pod 不接流量,旧 Pod 还在服务。摘流层:Pod 收到终止信号到 Service 真正不再转发给它之间有传播延迟——endpoints 更新、负载均衡健康检查都需要时间,所以 preStop 要配 sleep 几秒的缓冲,主动等摘流完成,再看退出。应用层:SIGTERM 触发优雅停机——停收新请求、等在途请求完成、回连关闭,注册中心同时主动下线。三层时序对了,发布过程请求零失败;漏了 preStop 这层,每次发布都会有小批 502——这是最常见的疏忽。

Q:Docker 镜像怎么瘦身?

答:四个手段按收益排。多阶段构建:第一阶段用带 Maven 的 JDK 镜像编译打包,第二阶段只把 jar 拷进 JRE 运行镜像——构建工具和源码都不进最终镜像,层体积从 1G 级降到几百 M。分层利用缓存:Spring Boot 的 layered jar 把「不常变的依赖」和「常变的应用类」拆开,依赖层命中缓存后每次构建只推应用层,CI 提速明显。基础镜像选择:eclipse-temurin 的 JRE 版、或 alpine 变体,再激进一点用 jlink 裁出只含所需模块的定制运行时。附带收益是安全面:镜像越小,CVE 扫描出的漏洞越少,攻击面也小。数字上说,我们同类服务镜像从 900M 压到 300M 内。

十一、分布式与高并发(17 题)

Q:CAP 和 BASE?

答:CAP 说分布式系统在网络分区发生时,一致性和可用性只能二选一——注意准确理解:网络分区不是可选项,一定会发生,所以工程上的真实命题是「分区发生时你保 C 还是保 A」。注册中心就是经典对比:ZooKeeper 和 Nacos 的 CP 模式保一致,分区时不可用的节点拒绝服务;Eureka 和 Nacos 的 AP 模式保可用,接受短暂数据不一致。BASE 是 AP 路线的补全哲学:基本可用——降级降质但服务在;软状态——允许中间态存在;最终一致——一段时间后达成一致。互联网主流交易系统的实际选择几乎都是 BASE:可用优先,用对账和补偿把最终一致兜住。

Q:分布式事务怎么选型?

答:按一致性强弱分层选。强一致:2PC/XA——数据库层两阶段提交,严格但阻塞、性能差,互联网高并发场景基本不用。准强一致:TCC——Try 预留资源、Confirm 确认、Cancel 释放,每个参与方写三个接口,侵入大但实时性好,资金类场景在用。最终一致(主流):本地消息表或事务消息——业务操作和消息记录在同一个本地事务里落库,再异步投递,消费方幂等消费,失败重试加对账补偿;RocketMQ 事务消息把「本地事务和发消息」的原子性交给 broker 半消息机制,省掉自建消息表。低侵入兜底:Seata AT 模式——代理 SQL 自动生成 undo log 反向补偿,改造成本最低。我的实践标准:默认本地消息表,资金敏感加 TCC,老系统改造用 Seata。

Q:幂等性怎么设计?

答:先分清天然与非天然:put、delete 天然幂等,post 都要设计。方案按层次:数据库唯一索引——业务唯一键兜底,重复插入直接冲突,最可靠的一层;token 机制——先向服务端领号,提交时带号,号只能用一次,防重复提交的标准解;乐观锁版本号——update ... where version=?,影响行数为零说明已被处理;状态机——只允许合法状态迁移,重复请求到达时状态已流转,天然被拒,订单场景首选;分布式锁加去重表——锁住检查加执行的过程。接口层的关键是防重键的定义:业务类型加业务 ID,而不是自然流水号。最后一层保险是对账:任何幂等设计都要配事后核对,漏网的能发现。

Q:限流算法有哪些?

答:四个演进。固定窗口计数器最简单——每分钟一个计数器,但窗口边界有临界突刺,两个窗口连接处可能放进双倍流量。滑动窗口把窗口切小格滚动统计,消除边界问题,Sentinel 的底层就是滑动窗口。漏桶恒定速率出水——强制平滑,但无法应对合理突发,多用于保护脆弱下游。令牌桶恒定速率放令牌、桶有容量——平时攒令牌,突发来了一次性放行,允许合理突发,Guava RateLimiter 就是它,业务限流首选。分布式场景:集群限流用 Redis 加 Lua——取令牌的动作在 Redis 端原子执行,准确但每请求一跳网络;折中是「中心配额加本地执行」:配额中心分配到各节点,节点本地令牌桶,弱一致换性能。

Q:秒杀系统怎么设计?

答:核心思想一句话:分层削流,让压力层层递减,到数据库时只剩真实成交。入口层:前端按钮置灰防抖、答题或验证码错峰——把瞬时洪峰拉宽。静态层:商品详情等静态内容全推 CDN,动态数据接口分离。网关层:uid 维度限流——同一用户每秒 N 次,加上全局阈值兜底。缓存层:库存预热进 Redis,预扣减用 Lua 原子执行「判断库存、扣减、记录用户」——库存不足直接快速失败,99% 的流量终止在这里,DB 看不到。异步层:预扣成功发 MQ,下单服务匀速消费落库。数据库层:条件更新 update stock set n = n - 1 where n > 0 乐观兜底。两个延伸点:热点库存单 key 的瓶颈用分段库存——拆成 100 个子 key 各自扣减;超时未支付回补库存靠延迟消息。

Q:库存超卖怎么防?

答:两道防线。数据库层:扣减必须是原子条件更新——update stock set num = num - 1 where id = ? and num > 0,影响行数为零就是库存不足,单行锁的排队保证不超卖;不要「先查再改」的两步写法,查和改之间库存早被别人扣了。缓存层:高并发下 DB 扛不住,库存预热到 Redis,预扣用 Lua 脚本把「判断、扣减、写记录」合成一次原子操作——单线程执行天然串行。架构分工:Redis 预扣做第一道快速过滤,DB 条件更新做最终正确性保障,两边以对账核对。补一个完整闭环:预扣成功但订单超时未支付,延迟消息触发库存回补,否则占用库存等于卖出失败。

Q:订单超时自动关闭怎么做?

答:三种方案的权衡。延迟消息:RocketMQ 定时消息或延迟等级,下单时发一条 30 分钟后投递的消息,消费时检查订单状态未支付就关闭——可靠、实时性好,首选方案;Kafka 原生不支持延迟,要自建时间轮。Redis 过期监听:key 过期触发事件——但它的推送是尽力而为不保证送达,还有 key 过期不精确的问题,生产上不建议。扫表兜底:定时任务分页扫描超时未支付订单批量关闭——简单粗暴但全量扫描有压力,作为兜底层与延迟消息双保险。工程答案就是「延迟消息为主,扫表兜底」,再加幂等:关闭动作是状态机迁移,重复消费天然安全。

Q:雪花算法的时钟回拨问题?

答:雪花 ID 依赖机器时钟,NTP 校时或人工调时可能让时钟往回跳——回拨后生成的前几毫秒 ID 和之前的重复,这是它的阿喀琉斯之踵。应对方案:拒绝分配——发现当前时间小于上次时间戳,短则自旋等待时钟追平,长则报错切备用节点,最简单可靠;备用位——预留几位做扩展时间戳或序列号借位,回拨时消耗序列号空间维持单调;序列号借位就是回拨期间继续用上次时间戳但序列号继续递增。工业级方案直接看开源:美团 Leaf——号段模式和雪花模式双实现,解决了回拨和机器号分配的问题;百度 uid-generator 用环形 buffer 预生成。答的时候先把问题成因讲清,方案就顺理成章。

Q:一致性哈希是什么?解决什么问题?

答:解决「节点增减时数据迁移量」的问题。普通哈希取模——mod N——扩一个节点,N 变了,几乎所有 key 的归属都变,缓存全部失效、数据全量迁移。一致性哈希把哈希空间组成一个环:节点和 key 都哈希到环上,key 顺时针找最近的节点;节点增减只影响它在环上逆时针到前一个节点之间的那段 key——迁移量是 1/N,其余不动。但节点少时环上分布不均,且节点下线会把它的负载全压给下一个节点——解法是虚拟节点:每个物理节点哈希成几百个虚拟节点散在环上,负载统计均衡,节点故障的负载也分摊给所有节点。应用:分布式缓存分片、网关路由、分库分表的中间层。

Q:Raft 用一句话讲清楚?

答:Raft 是「一切以领导者为准」的共识算法,三个子问题。选主:节点三种角色——leader、follower、candidate;leader 周期性心跳, follower 超时没收到就转 candidate、加任期号发起投票,拿到多数票当选——任期号是逻辑时钟,保证任意任期最多一个 leader。日志复制:所有写请求过 leader,leader 把日志条目并行发给 follower,收到多数派确认才提交并应用状态机,随后通知 follower 提交——多数派持久化保证了宕机恢复后已提交的日志不丢。安全性:日志匹配原则——term 和索引相同则之前全部相同,加上「只投给日志不比自己旧的 candidate」,保证新 leader 必然持有全部已提交日志。工程实现:etcd、TiKV、Nacos 的 CP 模式。

Q:布隆过滤器的原理?什么场景用?

答:一个长 bit 数组加 k 个哈希函数:写入时把元素经 k 个哈希散到数组上置 1;查询时看 k 个位置是否全为 1——有任何一个 0,绝对不存在;全为 1,可能存在——因为位可能是别的元素置的,这就是误判来源。特性口诀:可能误判「在」,绝不漏判「不在」。误判率由数组大小和哈希个数决定,容量要按预计元素量预留,装太满误判率飙升。两个局限:不能删除——位是共享的,删一个元素可能影响别人,要删就升级计数布隆或布谷鸟过滤器;需要预估容量。典型场景:缓存穿透防护——不存在的 key 在布隆层直接拦掉;爬虫 URL 去重、推荐已读过滤、海量黑名单。我在缓存三兄弟的穿透场景里答它,就带出了实战。

Q:集群限流怎么做?

答:两种架构。集中式:所有限流状态放 Redis——Lua 脚本原子执行「取当前窗口计数、判断、加一」,所有节点共用一份计数,准确;代价是每个请求一次网络往返,Redis 成为集中瓶颈和故障点,用「本地缓存桶」优化——一次取一批令牌。分布式配额式:限流中心把总配额按节点均分或按权重分配,各节点本地令牌桶执行,周期性上报和调整——没有每请求的网络开销,但配额同步有窗口,靠「中心纠偏」弥补偏差。工程实践是两层:入口网关做集中式精确限流(量小),内部服务之间用本地限流兜底(快速失败不求精确)。选型依据:准确性要求、可接受的延迟、Redis 的容量规划。

Q:灰度发布怎么设计?

答:三个子系统。路由:网关按维度匹配——uid 尾号、白名单、地域、设备标签——命中的流量转发到灰度版本;规则在配置中心动态下发,随时调比例:1% → 10% → 50% → 全量。染色:命中灰度的请求打上标签,header 里带标透传全链路——同步调用传 header、异步场景用 TTL 传上下文、跨 MQ 塞消息头——保证一个用户的请求全链路都走灰度版本,否则新旧版本混跑逻辑错乱。观测与回滚:新旧版本的核心指标对比——RT、错误率、业务转化指标——出现异常自动或一键回滚;数据库结构变更要求新旧代码兼容双写、字段只加不删,保证回滚不需要回滚数据。设计题答这三块,就是体系化的灰度。

Q:线上大表加字段加索引怎么做?

答:直接 ALTER 的风险:8.0 之前大部分 DDL 要锁表或长时间元数据锁,主从延迟被大事务拖爆,线上会事故。方案演进:8.0 的 instant ADD COLUMN 秒级完成——只改元数据,加列(且在尾部)首选它。在线变更工具 gh-ost 或 pt-osc:原理是「影子表」——建一张结构变更后的空表,把原表存量数据分批拷入,同时伪装成从库订阅 binlog 增量回放到影子表,追平后一把原子 rename 切换,全程对原表只有轻量级的行锁,可限速、可暂停、可中止。流程纪律:低峰执行、提前在预发环境演练、控制拷贝速率、评估磁盘空间(双倍表空间)、失败可全身而退。答出「影子表、binlog 回放、原子切换」三个词,原理就到位了。

Q:接口突然变慢怎么排查?

答:先定位段,再定层。第一步上链路追踪——traceId 看这次慢请求的瀑布图,慢在哪一段:数据库、下游 RPC、还是自身计算。分层看:DB 层——慢查询日志、锁等待、连接池等位(连接池打满时获取连接的耗时也算在 RT 里,容易被忽略);下游层——超时重试放大、线程池排队;自身——GC 停顿看 jstat 的 FGC 频率、线程池满看队列深度。横向有一个黄金问题:「最近有没有发布」——变慢和变更是强相关的,配置改动、依赖升级、定时任务挤占,先回滚止血再查根因,比现场硬查快得多。答的时候把「链路定位、分层排查、先止血后根因」三个原则讲出来,就是有实战的人。

Q:冷热数据分离怎么做?

答:先分层:热数据——最近活跃,在线库加缓存;温数据——近几个月,按月分表或独立库;冷数据——历史归档,对象存储或数仓,查询走低频通道。关键是路由和治理:查询入口统一,按时间条件路由到不同存储——应用层路由或数据库代理;归档是定时任务把过界数据批量迁出、原表删除,删大表用分批防止长事务。我拿工业平台举例:时序数据天然有生命周期,TDengine 建表时就配数据保留策略——超过保留期的库自动清理;同时配置降采样——原始秒级数据保留 30 天,自动聚合成的分钟级、小时级数据保留数年——查趋势走聚合表,查细节走原始表。存储成本降一个数量级,查询性能反而更好,这就是冷热分离的收益。

Q:怎么设计一个高可用系统?(总纲)

答:六个层面,按这个框架答任何 HA 题都不散。冗余:无单点——服务多副本跨可用区部署、数据库主从、机房级容灾;冗余是一切的前提。故障转移:健康检查加自动摘除—— unhealthy 实例秒级踢出负载均衡,数据库主从自动切换,转移要快且稳。降级预案:提前想好「哪些功能可以牺牲」——非核心功能开关降级、读降级到缓存快照、写降级到队列延后处理,有损服务好过无服务。限流保护:自己不被流量打死——入口限流、依赖隔离、线程池隔离,保住核心容量的水位。监控告警:可观测三支柱——指标、日志、链路,告警分级,故障时 1 分钟发现、5 分钟定位、10 分钟恢复。混沌演练:预案不演练等于没有——定期故障注入,验证自动转移和降级真的生效。这六层是体系,答完可以落到自己的项目:coremind 的多活路由就是这套框架的实践。

← 返回首页