Java 高频面试题 100 道详解-大厂真题汇总
覆盖 Java 基础、并发编程、JVM、MySQL、Redis、消息队列、分布式系统、Spring 生态、微服务架构、场景设计等 10 大方向。每题包含核心原理、详细解析、生产场景与解决方法。
整理日期:2026-07-04 | 来源:CSDN、掘金、阿里云开发者社区、腾讯云开发者社区、51CTO、JavaGuide 等技术平台真实面试题整理
一、Java 基础(Q1-Q10)
Q1:== 和 equals() 的区别是什么?
核心原理: == 比较的是内存地址(引用是否指向同一对象),equals() 比较的是值内容(取决于类是否重写了 equals 方法)。
详细解析:
- 对于基本数据类型(int、char 等),
==比较的是值本身。 - 对于引用数据类型(String、Object 等),
==比较的是对象的内存地址。 Object类的默认equals()实现等同于==,即比较地址。String、Integer等类重写了equals()方法,改为比较值内容。Integer在-128~127范围内有缓存(Integer Cache),此范围内==返回 true,超出范围则返回 false。
生产场景: 在支付系统中比较两个订单号是否相同,必须用 equals() 而非 ==。曾经有线上 Bug:两个超出 Integer 缓存范围的订单 ID 用 == 比较,导致本应匹配的订单未被匹配,用户重复支付。解决方法: 所有对象值比较统一使用 equals(),并在 Code Review 中将 == 对象比较列为检查项。
Q2:String、StringBuilder、StringBuffer 的区别?
核心原理: String 不可变(final 修饰),StringBuilder 可变且非线程安全,StringBuffer 可变且线程安全(synchronized 修饰)。
详细解析:
- String:底层
final char[](JDK9 后改为byte[]),每次拼接都会创建新对象,产生大量临时对象。 - StringBuilder:继承 AbstractStringBuilder,无同步锁,单线程下字符串拼接性能最优。
- StringBuffer:每个方法都加了 synchronized,线程安全但性能比 StringBuilder 差约 10%-20%。
- 编译器对
+拼接做了优化:少量拼接会编译为 StringBuilder.append(),但循环内拼接每次循环都会 new StringBuilder,需手动用 StringBuilder。
生产场景: 日志系统中拼接大量日志字段,如果用 String + 拼接,在循环中每轮创建一个 StringBuilder 对象,GC 压力极大。解决方法: 循环内拼接用 StringBuilder,预分配容量 new StringBuilder(128) 避免扩容。JSON 序列化场景优先用 StringBuilder。
Q3:HashMap 底层实现原理(JDK 1.8)?
核心原理: JDK 1.8 的 HashMap 底层是数组 + 链表 + 红黑树,通过哈希函数定位数组下标,哈希冲突时用链地址法(拉链法)解决。
详细解析:
- 初始化:默认数组长度 16,负载因子 0.75,阈值 = 数组长度 × 负载因子 = 12。
- put 流程:计算 key 的 hash 值(
(h = key.hashCode()) ^ (h >>> 16)扰动处理)→(n-1) & hash定位下标 → 桶为空直接放入 → 桶不为空则遍历链表/红黑树,key 相同则覆盖,不同则尾插。 - 链表转红黑树:链表长度 > 8 且数组长度 ≥ 64 时,链表转红黑树(时间复杂度从 O(n) 降为 O(log n));红黑树节点 ≤ 6 时退化为链表。
- 扩容机制:元素数量超过阈值时扩容为 2 倍,重新计算每个元素的位置(
原位置或原位置 + 旧容量)。 - JDK 1.7 采用头插法,多线程扩容可能形成环形链表导致死循环;JDK 1.8 改为尾插法解决了此问题,但 HashMap 仍非线程安全。
生产场景: 高并发场景下使用 HashMap 导致数据丢失或 CPU 100%(JDK 1.7 死循环)。解决方法: 并发场景使用 ConcurrentHashMap,不要使用 Collections.synchronizedMap(性能差)。
Q4:ConcurrentHashMap 的线程安全原理(JDK 1.7 vs 1.8)?
核心原理: JDK 1.7 用分段锁(Segment + ReentrantLock),JDK 1.8 改为 CAS + synchronized 锁单个桶节点。
详细解析:
- JDK 1.7:Segment 数组(默认 16 个段),每个 Segment 继承 ReentrantLock,锁粒度为段。并发度 = Segment 数量,最大 16 线程并发写。
- JDK 1.8:去掉 Segment,直接用 Node 数组。初始化数组、插入空桶用 CAS(无锁乐观操作);发生哈希冲突时用 synchronized 锁住链表头节点/红黑树根节点。锁粒度从段降到单个桶,并发度大幅提升。
- 读操作完全无锁:Node 的 val 和 next 用 volatile 修饰,保证可见性。
- size() 方法用 LongAdder 思想:baseCount + CounterCell 数组分散计数,减少 CAS 竞争。
生产场景: 秒杀系统中缓存商品库存到本地 Map,多线程同时读写。解决方法: 使用 ConcurrentHashMap,配合 CAS 原子操作保证库存扣减的正确性。注意 size() 是近似值,不保证精确。
Q5:ArrayList 和 LinkedList 的区别?
核心原理: ArrayList 底层是动态数组,LinkedList 底层是双向链表。
详细解析:
- ArrayList:随机访问 O(1),尾部插入均摊 O(1),中间插入/删除 O(n)(需移动元素)。扩容为 1.5 倍(
oldCapacity + (oldCapacity >> 1)),扩容时用Arrays.copyOf复制数组。 - LinkedList:随机访问 O(n)(需从头遍历),头尾插入/删除 O(1)。实现了 Deque 接口,可作为队列/双端队列使用。
- 内存占用:ArrayList 连续内存,空间利用率高;LinkedList 每个节点额外存储前驱和后继指针,内存开销大。
- ArrayList 实现了 RandomAccess 接口(标记接口),推荐用 for 循环遍历;LinkedList 未实现,推荐用迭代器遍历。
生产场景: 批量数据导入场景,如果预先知道数据量,new ArrayList<>(100000) 预分配容量避免多次扩容。解决方法: 频繁头插用 LinkedList 或 ArrayDeque;需要线程安全用 CopyOnWriteArrayList(读多写少)或 Collections.synchronizedList。
Q6:Java 异常体系结构?
核心原理: Throwable 是所有异常的根类,分为 Error 和 Exception 两个分支。Exception 分为受检异常(Checked Exception)和非受检异常(RuntimeException)。
详细解析:
- Error:程序无法处理的严重错误(OOM、StackOverflowError),JVM 会终止程序。
- 受检异常:编译器强制要求处理(IOException、SQLException),除 RuntimeException 以外的 Exception 子类。
- 非受检异常:编译器不强制处理(NullPointerException、ArrayIndexOutOfBoundsException),通常是编程错误。
- try-catch-finally:finally 块无论是否异常都会执行(除 System.exit())。JDK 7 引入 try-with-resources 自动关闭实现 AutoCloseable 的资源。
- 异常链:
throw new RuntimeException("包装异常", e)保留原始异常堆栈。
生产场景: 线上系统大量 catch(Exception e) 吞掉异常,导致问题难以排查。解决方法: 不要捕获大范围异常后忽略,至少记录日志;自定义业务异常体系(BaseException → BusinessException、SystemException),统一异常处理器(@ControllerAdvice + @ExceptionHandler)返回标准错误码。
Q7:Java 反射的原理与应用?
核心原理: 反射是在运行时动态获取类信息、创建对象、调用方法、访问字段的机制。JVM 加载类后会在方法区生成 Class 对象,反射通过 Class 对象操作类的元数据。
详细解析:
- 获取 Class 对象三种方式:
Class.forName("全限定名")、对象.getClass()、类名.class。 - 核心 API:
getDeclaredFields()获取所有字段(含 private)、getDeclaredMethods()获取所有方法、getConstructor()获取构造器、setAccessible(true)突破访问控制。 - 反射性能问题:反射调用比直接调用慢约 10-50 倍(需查找方法、安全检查、参数装箱)。可通过 Method.setAccessible(true) 关闭安全检查提速、或用 MethodHandle / ASM 字节码增强。
- 应用场景:Spring IOC(反射创建 Bean)、MyBatis(反射映射结果集)、JSON 序列化(反射读取字段值)、动态代理。
生产场景: RPC 框架中需要根据接口名动态调用远程方法。解决方法: 缓存 Method 对象避免重复查找;高频调用场景用 ASM/CGLIB 生成字节码代理代替反射,性能接近直接调用。
Q8:JDK 动态代理 vs CGLIB 动态代理?
核心原理: JDK 动态代理基于接口实现(Proxy + InvocationHandler),CGLIB 基于继承实现(生成目标类的子类 + MethodInterceptor)。
详细解析:
- JDK 动态代理:目标类必须实现接口,Proxy.newProxyInstance() 在运行时生成实现相同接口的代理类。通过 InvocationHandler.invoke() 拦截方法调用。
- CGLIB 动态代理:不要求接口,通过 ASM 生成目标类的子类,重写非 final 方法。通过 MethodInterceptor.intercept() 拦截。不能代理 final 类和 final 方法。
- 性能对比:CGLIB 创建代理慢(需生成字节码),但方法调用比 JDK 快(FastClass 机制避免反射)。JDK 代理创建快,但调用使用反射较慢。
- Spring 默认策略:有接口用 JDK 代理,无接口用 CGLIB;
spring.aop.proxy-target-class=true强制用 CGLIB。
生产场景: Spring AOP 中 @Transactional 注解失效的经典问题:同类内部方法调用不经过代理对象,导致事务不生效。解决方法: 通过 AopContext.currentProxy() 获取当前代理对象调用,或将方法拆到不同类中。
Q9:Java 泛型擦除机制及其影响?
核心原理: Java 泛型在编译期做类型检查,编译后擦除泛型信息(替换为上界或 Object),运行时无法获取泛型类型。
详细解析:
- 擦除规则:
List<String>和List<Integer>运行时都是List,泛型类型参数被擦除为 Object(无上界)或上界类型(有上界<T extends Number>擦除为 Number)。 - 桥接方法:子类泛型方法重写父类泛型方法时,编译器生成桥接方法保证多态正确性。
- 无法做的事:
new T()(类型未知)、T.class(擦除后无 Class 对象)、instanceof List<String>(擦除后无法区分)、基本类型泛型(只能用包装类)。 - 获取泛型类型的方法:通过子类继承的泛型参数(Class.getGenericSuperclass())、通过方法返回值类型(Method.getGenericReturnType())。
生产场景: JSON 反序列化时 List<User> 无法直接传入泛型类型,导致返回 List<LinkedHashMap> 而非 List<User>。解决方法: 使用 TypeReference(Jackson)或 TypeReference<T>(Fastjson)保留泛型类型信息,或传入 Class<User[]> 用数组绕过擦除。
Q10:Java 中的 SPI 机制是什么?
核心原理: SPI(Service Provider Interface)是一种服务发现机制,在 META-INF/services 目录下配置接口实现类,运行时通过 ServiceLoader 动态加载。
详细解析:
- 核心流程:定义接口 → 在 META-INF/services/ 下创建以接口全限定名命名的文件 → 文件内容为实现类全限定名 →
ServiceLoader.load(接口.class)加载所有实现。 - 与 API 的区别:API 是调用方依赖接口和实现(正向),SPI 是接口方定义规范、实现方按需插入(反向控制)。
- 应用场景:JDBC 驱动(mysql-connector)、日志门面 SLF4J、Dubbo SPI(增强版,支持 IoC 和 AOP)、Spring Boot 自动配置(spring.factories)。
- ServiceLoader 缺点:一次性加载所有实现、无法按需获取、无依赖注入。Dubbo SPI 做了增强:按 key 加载、支持自适应扩展、支持包装类。
生产场景: 系统需要支持多种支付渠道(微信、支付宝),且希望做到新增渠道不改代码。解决方法: 定义 PaymentService 接口,各渠道实现类配置在 META-INF/services 下,运行时 ServiceLoader 动态加载,策略模式按类型选择实现。
二、并发编程 JUC(Q11-Q25)
Q11:线程的生命周期和状态有哪些?
核心原理: Java 线程有 6 种状态(Thread.State 枚举):NEW、RUNNABLE、BLOCKED、WAITING、TIMED_WAITING、TERMINATED。
详细解析:
- NEW:创建了 Thread 对象但未调用 start()。
- RUNNABLE:调用了 start(),可能正在执行也可能等待 CPU 调度(Java 将操作系统层的就绪和运行合并为 RUNNABLE)。
- BLOCKED:等待获取 synchronized 监视器锁(如 synchronized 代码块/方法)。
- WAITING:调用
Object.wait()、Thread.join()、LockSupport.park()进入无限期等待,需被其他线程显式唤醒。 - TIMED_WAITING:调用
Thread.sleep(ms)、Object.wait(ms)、Thread.join(ms)进入限时等待,超时自动唤醒。 - TERMINATED:线程执行完毕或异常退出。
- 注意:调用 LockSupport.park() 进入的是 WAITING 而非 BLOCKED;ReentrantLock 等待锁的状态也是 WAITING(基于 AQS 的 LockSupport.park),而非 BLOCKED。
生产场景: 线上线程池中线程大量处于 BLOCKED 状态,导致任务积压。解决方法: jstack PID 导出线程堆栈,定位锁竞争点;缩短 synchronized 代码块范围,或改用 ReentrantLock 的 tryLock(timeout) 避免无限等待。
Q12:synchronized 的底层原理和锁升级过程?
核心原理: synchronized 基于 Monitor 对象实现(monitorenter/monitorexit 字节码指令),JDK 1.6 后引入锁升级优化:无锁 → 偏向锁 → 轻量级锁 → 重量级锁。
详细解析:
- Monitor 机制:每个对象关联一个 Monitor(ObjectMonitor),包含 Owner(持有线程)、EntryList(阻塞队列)、WaitSet(等待队列)。monitorenter 获取 Monitor,monitorexit 释放。
- 对象头 Mark Word:存储锁状态、线程 ID、hashcode、GC 年龄等信息,64 位结构。
- 偏向锁:第一个线程获取锁时,Mark Word 记录线程 ID,后续同一线程进入无需 CAS(只需比对线程 ID)。适合单线程反复进入同步块的场景。JDK 15 后默认禁用偏向锁(维护成本高)。
- 轻量级锁:出现竞争时,撤销偏向锁,线程通过 CAS 自旋获取锁(修改 Mark Word 指向栈中 Lock Record)。适合持有时间短、竞争不激烈的场景。
- 重量级锁:自旋失败(超过阈值)后升级,通过操作系统互斥量(Mutex)实现,线程阻塞/唤醒需用户态-内核态切换,开销大。
- 锁升级是单向的,不可降级。
生产场景: 高并发下 synchronized 导致线程大量阻塞,系统吞吐量下降。解决方法: 减小锁粒度(ConcurrentHashMap 分段锁/CAS);缩短临界区;读多写少用读写锁 ReentrantReadWriteLock;超高并发用 StampedLock(乐观读)。
Q13:volatile 关键字的原理和作用?
核心原理: volatile 保证可见性(修改后立即刷新到主内存,其他线程立即看到)和有序性(禁止指令重排序),但不保证原子性。
详细解析:
- 可见性:CPU 缓存导致每个线程有自己的工作内存副本。volatile 变量的写操作会触发缓存行失效(MESI 协议),其他线程读时从主内存重新加载。底层通过 x86 的
lock前缀指令实现(锁定缓存行或总线锁)。 - 有序性(内存屏障):volatile 写前插入 StoreStore 屏障(禁止前面的普通写与 volatile 写重排),写后插入 StoreLoad 屏障;volatile 读前插入 LoadLoad 屏障,读后插入 LoadStore 屏障。
- 不保证原子性:
volatile int i; i++在多线程下仍不安全(i++ 是读-改-写三步操作,可能被中断)。需用AtomicInteger或synchronized。 - 应用场景:双重检查单例(DCL)中 instance 必须加 volatile,防止指令重排导致返回未初始化的对象(new 对象分为:分配内存 → 初始化 → 赋值引用,重排后可能先赋值再初始化)。
生产场景: 线程间状态标志位 private static boolean running = true,另一线程修改为 false 后工作线程不停止。解决方法: 标志位加 volatile;或用 AtomicBoolean;更推荐用 Thread.interrupt() + isInterrupted() 实现优雅停止。
Q14:synchronized 和 ReentrantLock 的区别?
核心原理: synchronized 是 JVM 层面的关键字(基于 Monitor),ReentrantLock 是 JUC 层面的 API(基于 AQS)。
详细解析:
| 对比维度 | synchronized | ReentrantLock |
|---|---|---|
| 实现层面 | JVM 关键字,monitorenter/monitorexit | JUC 类,基于 AQS |
| 锁释放 | 自动释放(出代码块/方法) | 手动 unlock(),必须在 finally 中 |
| 可中断 | 不可中断(等锁时不响应 interrupt) | 可中断 lockInterruptibly() |
| 超时获取 | 不支持 | 支持 tryLock(timeout) |
| 公平锁 | 非公平 | 支持公平/非公平(构造参数) |
| 条件变量 | 一个 waitSet(wait/notify) | 多个 Condition(await/signal) |
| 锁绑定 | 对象/类 | Lock 对象 |
| 性能 | JDK 6 后优化接近 ReentrantLock | 高并发下略优 |
- ReentrantLock 基于 AQS:state 表示重入次数,CLH 队列管理等待线程。公平锁先检查队列有无前驱,非公平锁直接 CAS 抢锁。
- ReentrantLock 可绑定多个 Condition,实现精确唤醒(如生产者-消费者分别唤醒)。
生产场景: 需要实现"尝试获取锁 3 秒,超时走降级逻辑"。解决方法: 用 ReentrantLock 的 tryLock(3, TimeUnit.SECONDS),synchronized 无法实现超时获取。但简单场景仍优先 synchronized(代码简洁、不会忘记释放锁)。
Q15:AQS(AbstractQueuedSynchronizer)的原理?
核心原理: AQS 是 JUC 同步工具的基础框架,核心是 state 变量 + CLH 双向等待队列,通过模板方法模式让子类实现独占/共享获取释放逻辑。
详细解析:
- state:volatile int,不同子类赋予不同含义。ReentrantLock 中表示重入次数;Semaphore 中表示许可数;CountDownLatch 中表示剩余计数。
- CLH 队列:双向链表,存放等待获取锁的线程封装的 Node 节点。线程获取锁失败后封装为 Node 加入队尾,然后调用 LockSupport.park() 挂起;前驱节点释放后调用 LockSupport.unpark() 唤醒后继。
- 独占模式:
tryAcquire()/tryRelease(),同一时刻只有一个线程获取同步状态(ReentrantLock)。 - 共享模式:
tryAcquireShared()/tryReleaseShared(),允许多个线程同时获取(Semaphore、CountDownLatch、ReadWriteLock 的读锁)。 - 公平 vs 非公平:公平锁 tryAcquire 先检查队列是否有前驱节点(hasQueuedPredecessors),非公平锁直接 CAS 抢锁。
生产场景: 自定义限流器,限制同时最多 N 个线程访问某资源。解决方法: 继承 AQS 实现共享模式,state 初始化为 N,tryAcquireShared 时 CAS 减 1(< 0 入队等待),tryReleaseShared 时 CAS 加 1。实际可直接用 Semaphore(N)。
Q16:ThreadLocal 的原理和内存泄漏问题?
核心原理: ThreadLocal 为每个线程提供变量副本,核心结构是 Thread 对象中的 ThreadLocalMap,key 为 ThreadLocal 对象(弱引用),value 为线程私有数据。
详细解析:
- 数据结构:每个 Thread 对象有一个
ThreadLocal.ThreadLocalMap成员。ThreadLocalMap 内部是 Entry[] 数组,Entry 继承 WeakReference,即 key 是弱引用。 - set/get 流程:set 时以当前 ThreadLocal 为 key,存入当前线程的 ThreadLocalMap;get 时从当前线程的 Map 中取对应 ThreadLocal 的 value。
- 内存泄漏根因:key 是弱引用,GC 后 key 变为 null,但 value 是强引用,Entry 对象仍被 ThreadLocalMap 引用。如果线程不结束(如线程池中的线程),这些 null-key 的 value 永远无法回收。
- Hash 冲突:ThreadLocalMap 用开放寻址法(线性探测),不是拉链法。
生产场景: 线程池中使用 ThreadLocal 存储用户上下文(UserContext),线程复用后上一个用户的上下文未清理,导致数据串号。解决方法: 每次使用后在 finally 中调用 threadLocal.remove() 清理;或用阿里 TransmittableThreadLocal 解决线程池中 ThreadLocal 传递问题。InheritableThreadLocal 只能在创建子线程时传递,线程池复用场景无效。
Q17:线程池的核心参数和工作流程?
核心原理: ThreadPoolExecutor 有 7 个核心参数,通过核心线程数、队列、最大线程数、拒绝策略的配合控制任务执行。
详细解析:
- 7 大参数:corePoolSize(核心线程数)、maximumPoolSize(最大线程数)、keepAliveTime(非核心线程空闲存活时间)、unit(时间单位)、workQueue(任务队列)、threadFactory(线程工厂)、handler(拒绝策略)。
- 工作流程:
- 提交任务,如果当前线程数 < corePoolSize,创建核心线程执行任务。
- 如果线程数 >= corePoolSize,任务放入 workQueue 排队。
- 如果队列已满且线程数 < maximumPoolSize,创建非核心线程执行任务。
- 如果队列满且线程数 >= maximumPoolSize,触发拒绝策略。
- 非核心线程空闲超过 keepAliveTime 后被回收。
- 4 种拒绝策略:AbortPolicy(默认,抛 RejectedExecutionException)、CallerRunsPolicy(调用者线程执行)、DiscardPolicy(静默丢弃)、DiscardOldestPolicy(丢弃队列最老任务,重试提交)。
- ** Executors 禁用原因**:
newFixedThreadPool和newSingleThreadExecutor用 LinkedBlockingQueue(无界队列),可能 OOM;newCachedThreadPool最大线程数为 Integer.MAX_VALUE,可能创建大量线程 OOM。阿里规范要求手动创建 ThreadPoolExecutor。
生产场景: 线程池队列使用无界队列,大促期间任务堆积导致 OOM。解决方法: 使用有界队列(如 ArrayBlockingQueue(1000));CPU 密集型任务 corePoolSize = N+1,IO 密集型 = 2N 或 N×(1+等待时间/计算时间);不同业务隔离线程池避免互相影响。
Q18:CAS 原理及 ABA 问题如何解决?
核心原理: CAS(Compare And Swap)是无锁乐观机制,包含三个操作数:内存值 V、期望值 A、新值 B。当 V==A 时将 V 更新为 B,否则不操作,整个操作是原子的(CPU 的 cmpxchg 指令)。
详细解析:
- Unsafe 类:Java 通过
sun.misc.Unsafe提供 CAS 能力,compareAndSwapInt(Object, offset, expect, update)直接操作内存偏移量。 - Atomic 类:AtomicInteger、AtomicReference 等基于 CAS 实现无锁原子操作,
getAndIncrement()内部是 do-while 自旋 CAS。 - ABA 问题:线程 1 读取值 A,线程 2 将 A 改为 B 再改回 A,线程 1 的 CAS 仍然成功,但值实际已被修改过。对基本类型无影响,但对象引用场景可能导致数据不一致。
- 解决方案:
AtomicStampedReference每次修改附带版本号(A→B→A 变为 A1→B2→A3),CAS 时同时比较值和版本号;AtomicMarkableReference用 boolean 标记。
生产场景: 库存扣减场景用 CAS compareAndSet(oldStock, newStock),高并发下 CAS 自旋失败率高(大量空转浪费 CPU)。解决方法: 使用 LongAdder 分段 CAS(base + Cell 数组分散竞争),高并发计数性能优于 AtomicLong;或用 Redis Lua 脚本做原子扣减。
Q19:CountDownLatch 和 CyclicBarrier 的区别?
核心原理: CountDownLatch 是一次性计数器(减到 0 后不可重置),CyclicBarrier 是可循环使用的屏障(所有线程到达后放行,可重置)。
详细解析:
- CountDownLatch:基于 AQS 共享模式,count 表示剩余计数。
countDown()将 count 减 1,await()在 count=0 前阻塞。一个线程等待 N 个线程完成(主线程等子任务),或 N 个线程同时开始。 - CyclicBarrier:基于 ReentrantLock + Condition,parties 表示需要的线程数。
await()到达屏障后阻塞,最后一个线程到达时全部放行,并可执行 barrierAction。可调用reset()重置复用。 - 核心区别:CountDownLatch 一次性不可复用;CyclicBarrier 可复用且支持到达后执行回调。CountDownLatch 是"一个人等多个人";CyclicBarrier 是"多个人互相等"。
生产场景: 多线程并行查询多个数据源,主线程等待所有查询完成后合并结果。解决方法: 用 CountDownLatch(N),每个子线程查询完后 countDown(),主线程 await() 等待全部完成。如果是分批处理(每批 N 个线程),用 CyclicBarrier(N, () -> 合并结果)。
Q20:Java 中的锁分类有哪些?
核心原理: 按不同维度可分为:公平/非公平、可重入/不可重入、乐观/悲观、独占/共享、自旋/阻塞。
详细解析:
- 公平锁 vs 非公平锁:公平锁按请求顺序获取(先到先得),非公平锁可插队。ReentrantLock 支持两种模式,synchronized 是非公平锁。非公平锁吞吐量更高(减少线程切换)。
- 可重入锁:同一线程可多次获取同一把锁(state 递增计数),避免死锁。ReentrantLock 和 synchronized 都是可重入的。
- 乐观锁 vs 悲观锁:悲观锁先加锁再操作(synchronized、ReentrantLock);乐观锁先操作再 CAS 验证(Atomic 类、数据库 version 字段)。乐观锁适合读多写少、冲突少。
- 独占锁 vs 共享锁:独占锁同一时刻一个线程持有(ReentrantLock、synchronized);共享锁允许多线程同时持有(ReadWriteLock 的读锁、Semaphore)。
- 自旋锁:获取锁失败时不阻塞,而是循环重试(CAS 自旋),适合持有时间极短的场景。但自旋过多浪费 CPU。
生产场景: 配置中心更新配置后通知所有节点刷新本地缓存。解决方法: 用读写锁 ReentrantReadWriteLock,读操作获取读锁(多线程并发读),配置更新获取写锁(互斥写),兼顾读并发和写安全。
Q21:死锁产生的条件和排查方法?
核心原理: 死锁是两个或多个线程互相等待对方持有的锁,导致永久阻塞。产生死锁需同时满足四个条件。
详细解析:
- 四个必要条件:① 互斥(资源同一时刻只能一个线程使用);② 持有并等待(持有锁的同时等待其他锁);③ 不可剥夺(不能强行夺走锁);④ 循环等待(线程间形成环形等待链)。
- 排查方法:
jstack PID导出线程堆栈,查找 “Found one Java-level deadlock” 字段。jconsole/Arthas可视化查看线程锁状态。- 日志中线程长期处于 BLOCKED 状态且等待的锁被另一个 BLOCKED 线程持有。
- 预防方法:① 统一加锁顺序(如按 ID 升序加锁,破坏循环等待);② 缩短锁持有时间;③ 使用 tryLock(timeout) 替代无限等待;④ 减小锁粒度。
生产场景: 转账系统中 A→B 和 B→A 同时执行,A 锁了账户 A 等 B 锁,B 锁了账户 B 等 A 锁,死锁。解决方法: 统一按账户 ID 从小到大的顺序加锁(if (fromId < toId) { lock(from); lock(to); } else { lock(to); lock(from); }),破坏循环等待条件。
Q22:CompletableFuture 的使用场景和原理?
核心原理: CompletableFuture 是 Java 8 引入的异步编程工具,支持链式调用、组合多个异步任务、异常处理,基于 ForkJoinPool.commonPool() 执行。
详细解析:
- 创建:
supplyAsync(Supplier)异步执行有返回值;runAsync(Runnable)异步执行无返回值。默认使用 ForkJoinPool.commonPool(),可传入自定义 Executor。 - 链式转换:
thenApply(Function)转换结果;thenAccept(Consumer)消费结果无返回值;thenRun(Runnable)执行后续动作。 - 组合:
thenCompose(Function)串行依赖(前一个的结果传入后一个);allOf(cf1, cf2, cf3).join()等待全部完成;anyOf(cf1, cf2).join()任一完成即返回。 - 异常处理:
exceptionally(Function)捕获异常返回默认值;handle(BiFunction)同时处理正常和异常结果。 - 回调线程:then 系列方法默认在前一个任务执行的线程中回调,可指定
Async后缀使用线程池异步回调。
生产场景: 商品详情页需并行查询商品基础信息、价格、库存、评价四个接口,合并后返回。解决方法:
CompletableFuture<BaseInfo> f1 = CompletableFuture.supplyAsync(() -> queryBase(id), executor);
CompletableFuture<Price> f2 = CompletableFuture.supplyAsync(() -> queryPrice(id), executor);
CompletableFuture<Stock> f3 = CompletableFuture.supplyAsync(() -> queryStock(id), executor);
CompletableFuture.allOf(f1, f2, f3).join(); // 并行等待
ProductDetail detail = new ProductDetail(f1.get(), f2.get(), f3.get());
接口耗时从串行的 300ms 降至并行的 100ms。
Q23:ForkJoinPool 的原理和应用场景?
核心原理: ForkJoinPool 采用工作窃取算法(Work-Stealing),每个工作线程有自己的双端队列,空闲时从其他线程队列尾部窃取任务,提高 CPU 利用率。
详细解析:
- Fork/Join 模式:大任务 fork 拆分为子任务并行执行,子任务结果 join 合并。类似分治法。
- 工作窃取:每个线程维护一个双端队列(Deque),自己 push/pop 任务从队头操作(LIFO,缓存友好),窃取线程从其他线程队尾窃取(FIFO,减少竞争)。
- 与普通线程池区别:普通线程池所有线程共享一个队列(竞争大);ForkJoinPool 每个线程独立队列 + 窃取机制。
- 应用场景:大数组并行计算(parallelSort)、流式计算 parallelStream(默认用 ForkJoinPool.commonPool())、递归分治任务。
生产场景: 批量处理 100 万条数据,需要并行且支持任务拆分。解决方法: 继承 RecursiveTask,定义阈值(如每 10000 条一组),fork 子任务处理,join 合并结果。注意 parallelStream 共用 commonPool,CPU 密集型任务会抢占资源,建议传自定义 ForkJoinPool。
Q24:Java 内存模型(JMM)的三大特性?
核心原理: JMM 定义了线程间共享变量的访问规则,核心是可见性、原子性、有序性三大特性。
详细解析:
- 可见性:一个线程修改了共享变量,其他线程能立即看到。
volatile、synchronized(解锁前刷新到主内存)、final(初始化完成后对其他线程可见)保证可见性。 - 原子性:一个或多个操作不可被中断(要么全执行要么全不执行)。
synchronized、Lock、Atomic类保证原子性。基本类型赋值(除 long/double 外)天然原子。 - 有序性:程序执行顺序符合预期(防止指令重排)。
volatile(内存屏障禁止重排)、synchronized( Happens-Before 规则)保证有序性。 - Happens-Before 规则:① 程序顺序规则(同一线程内前操作 happens-before 后操作);② 锁规则(unlock happens-before 后续 lock);③ volatile 规则(写 happens-before 后续读);④ 传递性;⑤ 线程启动规则(start() happens-before 线程内动作);⑥ 线程终止规则。
生产场景: 双重检查单例模式不加 volatile 导致获取到未初始化的对象。解决方法: instance 字段加 volatile,利用 volatile 的写后 StoreLoad 屏障,保证 new 对象的"分配内存→初始化→赋值引用"不被重排为"分配内存→赋值引用→初始化"。
Q25:如何排查线上 CPU 100% 的问题?
核心原理: CPU 100% 通常由死循环、频繁 Full GC、大量线程竞争、加密/序列化计算密集型操作引起。
详细排查流程:
top命令找到 CPU 最高的 Java 进程 PID。top -Hp PID找到该进程中 CPU 最高的线程 TID(十进制)。printf "%x\n" TID将线程 ID 转为十六进制(nid)。jstack PID | grep nid -A 30查看该线程堆栈,定位到具体代码行。- 如果是 GC 线程 CPU 高,用
jstat -gc PID 1000查看 GC 频率,jmap -dump分析内存。 - Arthas 工具:
thread -n 3直接查看 CPU 占比最高的 3 个线程堆栈。
常见原因及解决:
- 死循环/空转:while(true) 中缺少 sleep 或退出条件 → 修复代码逻辑。
- 频繁 Full GC:内存泄漏导致老年代频繁满 →
jmap -histo:live分析大对象,MAT 分析堆转储。 - 锁竞争:大量线程 BLOCKED 自旋 → 缩小锁粒度,改用 CAS。
- 正则回溯:恶意输入导致正则引擎指数级回溯 → 限制输入长度,用优化过的正则。
生产场景: 线上接口偶发 CPU 飙升到 100%,持续 30 秒后恢复。解决方法: Arthas profiler start 采集火焰图,定位到 JSON 序列化大对象时 CPU 飙升,改用更高效的序列化库(如 Jackson 替代 Gson)并限制序列化深度。
三、JVM 虚拟机(Q26-Q37)
Q26:JVM 内存区域划分(JDK 1.8)?
核心原理: JDK 1.8 内存分为:堆、方法区(元空间)、虚拟机栈、本地方法栈、程序计数器。堆和方法区是线程共享的,其余是线程私有的。
详细解析:
- 程序计数器(PC Register):记录当前线程执行的字节码行号,线程私有,唯一不会 OOM 的区域。
- 虚拟机栈:线程私有,每个方法调用创建栈帧(局部变量表、操作数栈、动态链接、方法出口)。局部变量表存放基本类型和对象引用。StackOverflowError(栈深度超限)或 OOM(无法扩展)。
- 本地方法栈:为 Native 方法服务,HotSpot 与虚拟机栈合二为一。
- 堆:最大内存区域,存放对象实例和数组。分为新生代(Eden + 2 个 Survivor,8:1:1)和老年代。所有线程共享。是 GC 主战场。
- 方法区(元空间 Metaspace):JDK 1.8 用本地内存替代永久代,存放类元信息、常量池、静态变量。元空间使用本地内存,默认无上限(可设 -XX:MaxMetaspaceSize)。解决永久代容易 OOM 的问题。
- 直接内存:NIO 的 DirectByteBuffer 使用堆外内存,不受 JVM 堆大小限制,但受操作系统内存限制。
生产场景: 元空间溢出 java.lang.OutOfMemoryError: Metaspace,因大量动态生成类(CGLIB 代理、Groovy 脚本)。解决方法: 设置 -XX:MaxMetaspaceSize=256m;检查是否有类泄漏(如自定义 ClassLoader 未释放)。
Q27:对象创建的过程?
核心原理: new 关键字创建对象经过:类加载检查 → 分配内存 → 初始化零值 → 设置对象头 → 执行构造方法 <init>。
详细解析:
- 类加载检查:检查常量池中类的符号引用是否已加载、解析、初始化,未加载则先执行类加载。
- 分配内存:在堆中划分内存。指针碰撞(内存规整,Serial/ParNew 收集器);空闲列表(内存碎片,CMS 收集器)。并发安全:CAS + 失败重试 或 TLAB(Thread Local Allocation Buffer) 每个线程预分配一小块内存。
- 初始化零值:将分配的内存空间初始化为零值(int 为 0,引用为 null),使对象实例字段无需赋初值即可使用。
- 设置对象头:设置 Mark Word(hashcode、GC 分代年龄、锁状态)和类型指针(指向类元数据)。
- 执行
<init>:执行构造方法,赋值实例字段,完成对象创建。
生产场景: 高频创建对象导致 Minor GC 频繁。解决方法: 使用对象池复用对象(如 Netty 的 ByteBuf 池化);适当增大新生代大小 -Xmn;考虑 TLAB 调优 -XX:PretenureSizeThreshold 让大对象直接进老年代。
Q28:类加载机制和双亲委派模型?
核心原理: 类加载分为加载、验证、准备、解析、初始化五个阶段。双亲委派模型要求类加载请求先委托给父加载器,父加载器无法加载时才自己加载。
详细解析:
- 类加载阶段:
- 加载:通过类全限定名获取字节流,生成 Class 对象。
- 验证:文件格式、元数据、字节码、符号引用验证。
- 准备:为静态变量分配内存并赋零值(
static int a = 1此阶段 a=0,初始化阶段才赋 1)。 - 解析:符号引用替换为直接引用。
- 初始化:执行
<clinit>方法(静态变量赋值 + static 块)。
- 双亲委派模型:
- 启动类加载器(Bootstrap ClassLoader):加载
JAVA_HOME/lib(rt.jar)。 - 扩展类加载器(Extension ClassLoader):加载
JAVA_HOME/lib/ext。 - 应用类加载器(Application ClassLoader):加载 classpath。
- 自定义类加载器:继承 ClassLoader 重写 findClass()。
- 启动类加载器(Bootstrap ClassLoader):加载
- 工作流程:收到加载请求 → 委托父加载器 → 父加载器再向上委托 → 直到 Bootstrap → 如果 Bootstrap 无法加载则向下返回,由子加载器尝试。
- 意义:保证核心类(如 java.lang.Object)不会被自定义类替换(安全);避免重复加载。
生产场景: Tomcat 打破了双亲委派模型——每个 Web 应用的类加载器优先加载自己的类(隔离不同应用的类)。解决方法: Tomcat 的 WebappClassLoader 先自己加载(findClass),找不到再委托父加载器,实现应用间类隔离。SPI 机制(如 JDBC DriverManager)用线程上下文类加载器(TCCL)解决父加载器无法看到子加载器类的问题。
Q29:JVM 垃圾回收算法有哪些?
核心原理: 垃圾回收核心是判断对象是否存活(可达性分析)和回收策略(标记-清除、标记-复制、标记-整理)。
详细解析:
- 判断存活:
- 引用计数法(已淘汰,无法解决循环引用)。
- 可达性分析:从 GC Roots(栈中引用、静态变量、常量、JNI 引用)出发遍历对象图,不可达的对象为垃圾。
- 标记-清除(Mark-Sweep):标记所有垃圾对象,然后清除。缺点:产生内存碎片,分配大对象时可能触发又一次 GC。
- 标记-复制(Copying):将存活对象复制到另一块区域,清空原区域。缺点:浪费一半内存。适合新生代(存活对象少)。
- 标记-整理(Mark-Compact):标记存活对象后,向一端移动整理。无碎片但效率低(需移动对象)。适合老年代。
- 分代收集:新生代用复制算法(Eden + 2 Survivor,存活率低),老年代用标记-清除或标记-整理(存活率高)。
- 分区算法:G1 将堆分为多个 Region,每个 Region 独立回收,控制停顿时间。
生产场景: CMS 收集器用标记-清除产生大量碎片,导致晋升失败触发 Full GC(Serial Old 单线程,停顿极长)。解决方法: 设置 -XX:CMSFullGCsBeforeCompaction=5 每 5 次 Full GC 后做一次内存整理;或升级到 G1/ZGC。
Q30:常见的垃圾收集器及其特点?
核心原理: 垃圾收集器按分代和并发性分为:Serial、ParNew、Parallel Scavenge、CMS、G1、ZGC、Shenandoah。
详细解析:
| 收集器 | 分代 | 算法 | 并发 | 特点 |
|---|---|---|---|---|
| Serial / Serial Old | 新生代/老年代 | 复制/整理 | 单线程 | 简单,适合客户端 |
| ParNew | 新生代 | 复制 | 多线程并行 | Serial 多线程版,配合 CMS |
| Parallel Scavenge / Old | 新生代/老年代 | 复制/整理 | 多线程并行 | 注重吞吐量 |
| CMS | 老年代 | 标记-清除 | 并发 | 低停顿,4 阶段:初始标记(STW)→并发标记→重新标记(STW)→并发清除 |
| G1 | 全堆(分区) | 复制+整理 | 并发 | 可预测停顿,Region 分区,适合大堆 |
| ZGC | 全堆 | 染色指针+读屏障 | 并发 | 停顿 < 10ms,支持 TB 级堆 |
| Shenandoah | 全堆 | 转发指针 | 并发 | 停顿与堆大小无关 |
- CMS 四阶段:初始标记(STW,标记 GC Roots 直接引用)→ 并发标记(GC 线程与用户线程并发,遍历对象图)→ 重新标记(STW,修正并发标记期间引用变化)→ 并发清除(并发清除垃圾)。
- G1 特点:堆分为 2048 个 Region(1-32MB),每个 Region 可动态切换为 Eden/Survivor/Old/Humongous。维护 Remembered Set 记录跨 Region 引用。
-XX:MaxGCPauseMillis=200设定目标停顿时间,G1 根据回收价值优先回收收益最大的 Region。 - ZGC 特点:染色指针(64 位指针中借几位标记状态),读屏障(读取引用时自动转发),所有阶段都并发(仅初始标记和再标记极短 STW)。
生产场景: 8GB 堆用 CMS 导致频繁 Full GC 且停顿过长(>1秒)。解决方法: 升级到 G1,-XX:+UseG1GC -XX:MaxGCPauseMillis=200,G1 在大堆下停顿可控。JDK 15+ 生产可用 ZGC 实现亚 10ms 停顿。
Q31:Full GC 触发的原因和排查方法?
核心原理: Full GC 是对整个堆(新生代+老年代+元空间)进行全面回收,停顿时间长,应尽量避免。
详细排查流程:
- 常见触发原因:
- 老年代空间不足:大量对象晋升到老年代(大对象直接进入、Survivor 不够晋升、年龄阈值达到)。
- 元空间不足:动态生成类过多,Metaspace 溢出触发 Full GC。
- System.gc():代码显式调用(建议
-XX:+DisableExplicitGC禁用)。 - CMS 并发模式失败(Concurrent Mode Failure):CMS 并发回收期间老年代满了,退化为 Serial Old 单线程 Full GC。
- 空间分配担保失败:Minor GC 前检查老年代连续空间是否够容纳所有新生代对象,不够则触发 Full GC。
- promotion failed:Minor GC 时 Survivor 放不下要晋升的对象,老年代也没有连续空间。
- 排查步骤:
jstat -gc PID 1000 10查看 FGC(Full GC 次数)和 FGCT(总耗时)。- GC 日志加
-Xlog:gc*(JDK 9+)或-XX:+PrintGCDetails。 jmap -histo:live PID查看存活对象分布。jmap -dump:format=b,file=heap.hprof PID导出堆转储,用 MAT 分析。
- 解决方法:内存泄漏 → 修复代码(如静态集合未清理);大对象 → 调
-XX:PretenureSizeThreshold;老年代太小 → 调-XX:NewRatio;CMS 碎片 → 定期整理或换 G1。
生产场景: 系统 FGC 每 5 分钟一次,每次 3 秒。jmap 发现 static List 持有大量用户数据未清理。解决方法: 改用 Guava Cache 设置 maximumSize + expireAfterWrite 自动清理,FGC 降至 1 小时一次。
Q32:Java 中的四种引用类型?
核心原理: Java 提供强、软、弱、虚四种引用,强度依次递减,影响 GC 回收行为。
详细解析:
- 强引用(StrongReference):
Object obj = new Object(),只要强引用存在就不回收。即使 OOM 也不回收强引用对象。 - 软引用(SoftReference):内存不足时才回收。适合内存敏感的缓存。
SoftReference<byte[]> cache = new SoftReference<>(new byte[1024*1024])。 - 弱引用(WeakReference):下一次 GC 时无论内存是否充足都回收。ThreadLocalMap 的 key 是弱引用;WeakHashMap 的 key 是弱引用。
- 虚引用(PhantomReference):最弱引用,get() 永远返回 null,唯一作用是在对象被回收时收到通知(配合 ReferenceQueue)。用于跟踪对象回收时机,NIO DirectByteBuffer 用虚引用管理堆外内存释放。
- 引用队列(ReferenceQueue):软/弱/虚引用关联的对象被回收后,引用对象本身会加入 ReferenceQueue,可用于清理操作。
生产场景: 缓存大对象(如图片)到内存,希望内存充足时命中缓存,内存不足时自动释放。解决方法: 用 SoftReference 包装缓存对象,而非强引用,避免 OOM。或直接用 Guava/Caffeine 设置 weakValues()。
Q33:JVM 调优的常用参数有哪些?
核心原理: JVM 调优核心是堆大小、新生代/老年代比例、收集器选择、GC 日志。
常用参数:
- 堆大小:
-Xms4g -Xmx4g(初始堆和最大堆设为相同,避免动态扩缩引起抖动)。 - 新生代:
-Xmn2g(新生代大小)或-XX:NewRatio=2(老年代:新生代=2:1)。 - Survivor:
-XX:SurvivorRatio=8(Eden:S0:S1=8:1:1)。 - 元空间:
-XX:MetaspaceSize=256m -XX:MaxMetaspaceSize=512m。 - 收集器:
-XX:+UseG1GC(G1)、-XX:MaxGCPauseMillis=200(目标停顿)、-XX:+UseZGC(ZGC)。 - GC 日志:JDK 9+
-Xlog:gc*:file=gc.log:time,uptime,level,tags;JDK 8-XX:+PrintGCDetails -XX:+PrintGCDateStamps -Xloggc:gc.log。 - 堆 dump:
-XX:+HeapDumpOnOutOfMemoryError -XX:HeapDumpPath=/tmp/heap.hprof。 - 直接内存:
-XX:MaxDirectMemorySize=1g(NIO 堆外内存上限)。 - 禁用 System.gc():
-XX:+DisableExplicitGC。 - 大对象阈值:
-XX:PretenureSizeThreshold=10m(大于此值的对象直接进老年代,仅 Serial/ParNew 有效)。
生产场景: 4C8G 服务器部署 Java 服务,初步调优配置。解决方法: -Xms4g -Xmx4g -Xmn2g -XX:+UseG1GC -XX:MaxGCPauseMillis=200 -XX:+HeapDumpOnOutOfMemoryError -XX:HeapDumpPath=/opt/logs/heap.hprof -Xlog:gc*:file=/opt/logs/gc.log。生产环境 Xms 和 Xmx 设为相同值避免动态扩容抖动。
Q34:对象逃逸分析与栈上分配?
核心原理: 逃逸分析是 JIT 编译器分析对象的动态作用域,判断对象是否逃逸出方法或线程。未逃逸的对象可在栈上分配,避免堆 GC。
详细解析:
- 逃逸类型:
- 方法逃逸:对象被方法外引用(如返回值、赋值给静态变量、传给其他方法)。逃逸后对象需在堆上分配。
- 线程逃逸:对象被其他线程访问(如赋值给类成员变量、被其他线程读取)。
- 未逃逸:对象仅在方法内部使用,可优化。
- 优化手段:
- 栈上分配:未逃逸的对象在栈帧上分配,方法结束自动释放,无需 GC。
- 标量替换:将对象拆解为基本类型(标量),直接用局部变量替代,减少对象分配。
- 同步消除:未线程逃逸的对象,其同步操作可被消除(无竞争)。
-XX:+DoEscapeAnalysis开启逃逸分析(JDK 8 默认开启)。
生产场景: 方法内创建大量临时对象(如循环内 new DTO),导致 Minor GC 频繁。解决方法: 确保对象不逃逸(不返回、不赋值给成员变量),JIT 会自动栈上分配。也可手动复用对象。验证:-XX:+PrintEscapeAnalysis 查看分析结果。
Q35:JIT 即时编译器原理?
核心原理: JIT(Just-In-Time)编译器在运行时将热点代码的字节码编译为本地机器码,提升执行性能。HotSpot 有 C1(Client)和 C2(Server)两个编译器。
详细解析:
- 热点探测:基于计数器判断是否为热点代码。方法调用计数器 + 回边计数器(循环),超过阈值(
-XX:CompileThreshold=10000)触发编译。 - C1 编译器:快速编译,简单优化(内联、常量折叠),适合客户端或启动速度优先的场景。
- C2 编译器:激进优化(逃逸分析、锁消除、循环展开、虚方法内联),编译慢但运行快,适合服务端。
- 分层编译(Tiered Compilation):JDK 8 默认开启,先用 C1 快速编译,热点代码再用 C2 重新编译。兼顾启动速度和峰值性能。
- 常见优化:
- 方法内联:将被调用方法体直接嵌入调用处,消除方法调用开销(最重要优化)。
- 锁消除:逃逸分析发现对象未逃逸,消除 synchronized。
- 循环展开:减少循环次数,减少分支判断。
- AOT 编译:JDK 9+ 引入
jaotc,编译期生成机器码(GraalVM),启动快但无运行时 profile 优化。
生产场景: 微服务启动慢(JIT 预热),刚启动时接口响应慢。解决方法: JVM 预热(启动后用脚本发预热请求触发 JIT 编译);或 GraalVM AOT 编译为原生镜像(启动 <50ms),适合 Serverless 场景。
Q36:线上 OOM 如何排查?
核心原理: OOM(OutOfMemoryError)分为 Java 堆空间溢出、元空间溢出、GC 开销超限、直接内存溢出等类型,需根据类型定位。
详细排查流程:
- 确认 OOM 类型:
Java heap space:堆内存不足。Metaspace:元空间不足(类元数据过多)。GC overhead limit exceeded:98% 时间在 GC 且回收不到 2% 内存。Direct buffer memory:NIO 堆外内存不足。
- 获取堆转储:启动参数
-XX:+HeapDumpOnOutOfMemoryError自动 dump,或jmap -dump:format=b,file=heap.hprof PID。 - MAT 分析:
- Histogram:按类统计对象数量和大小。
- Dominator Tree:找占用内存最大的对象。
- Leak Suspects:自动分析疑似泄漏点。
- 查看对象的 GC Root 引用链,定位为何无法回收。
- 常见原因:
- 静态集合无限增长(static List/Map 未清理)。
- ThreadLocal 未 remove(线程池场景)。
- 大查询未分页(一次查百万数据到内存)。
- 连接/流未关闭(数据库连接、IO 流泄漏)。
- 无限递归(StackOverflowError 转化为 OOM)。
生产场景: 线上服务每 2 小时 OOM 重启一次。MAT 分析发现 HashMap 持有 200 万个 User 对象,是缓存未设上限。解决方法: 改用 Caffeine 缓存设置 maximumSize(10000) + expireAfterWrite(30min),OOM 不再出现。
Q37:如何选择垃圾收集器?
核心原理: 根据堆大小、停顿要求、吞吐量要求、JDK 版本选择合适的收集器。
选型指南:
- 堆 < 4GB,停顿不敏感:Parallel Scavenge + Parallel Old(吞吐量优先,JDK 8 默认)。
- 堆 4-8GB,低停顿:ParNew + CMS(JDK 8 常用,CMS 已在 JDK 14 移除)。
- 堆 8GB+,可预测停顿:G1(JDK 9+ 默认,推荐)。
- 堆 16GB+,超低停顿:ZGC(JDK 15+ 生产可用,停顿 <10ms)或 Shenandoah。
- Serverless/容器:Serial GC(资源受限,
-XX:+UseSerialGC)或 GraalVM 原生镜像。
关键考虑因素:
- 停顿时间:CMS/G1/ZGC 并发收集,停顿短。
- 吞吐量:Parallel Scavenge 最高,但停顿长。
- 堆大小:G1 适合 8GB+,ZGC 适合 TB 级。
- JDK 版本:CMS 在 JDK 14 移除,ZGC 在 JDK 15 转 GA。
生产场景: 从 JDK 8 升级到 JDK 17,8GB 堆。解决方法: 直接用默认的 G1(-XX:+UseG1GC),设置 -XX:MaxGCPauseMillis=200,G1 自动调整 Region 大小和回收策略。观察 GC 日志确认停顿在 200ms 以内。
四、MySQL 数据库(Q38-Q49)
Q38:InnoDB 和 MyISAM 的区别?
核心原理: InnoDB 支持事务、行锁、外键;MyISAM 不支持事务、仅表锁、无外键,但查询速度快。
详细解析:
| 特性 | InnoDB | MyISAM |
|---|---|---|
| 事务 | 支持 ACID | 不支持 |
| 锁粒度 | 行锁(默认)+ 表锁 | 仅表锁 |
| 外键 | 支持 | 不支持 |
| 索引结构 | 聚簇索引(数据和主键索引一起) | 非聚簇索引(数据和索引分离) |
| 全文索引 | 5.6+ 支持 | 支持 |
| 崩溃恢复 | 支持(redo log) | 不支持(需修复表) |
| 适用场景 | OLTP(高并发读写) | 只读或以读为主的场景 |
- InnoDB 的行锁是基于索引实现的:如果查询未走索引会退化为表锁。
- MySQL 5.5 后默认存储引擎为 InnoDB。MyISAM 在 MySQL 5.5 前是默认引擎,现在已逐渐被淘汰。
生产场景: 历史日志表只做写入和批量查询,无事务需求,MyISAM 查询更快。解决方法: MySQL 8.0+ 不建议用 MyISAM,InnoDB 的查询性能已大幅优化。日志表可用 InnoDB + 归档策略(按月分区),历史数据归档到专用表。
Q39:MySQL 索引数据结构为什么用 B+ 树?
核心原理: B+ 树是多路平衡查找树,非叶子节点只存索引不存数据(扇出大),所有数据在叶子节点(有序链表),适合磁盘 IO 和范围查询。
详细解析:
- B+ 树 vs B 树:B 树每个节点都存数据,导致单个节点能放的索引键少(扇出小),树更高,IO 次数多。B+ 树非叶子节点只存索引,一个 16KB 页可放上千个索引键(扇出大),3-4 层即可存储千万级数据(每层 1000 倍:1000³ = 10 亿)。
- B+ 树 vs 红黑树:红黑树是二叉树,树高 = log₂(N),1000 万数据约 24 层,即 24 次磁盘 IO。B+ 树 3-4 层仅需 3-4 次 IO。
- B+ 树 vs 哈希:哈希 O(1) 等值查询快,但不支持范围查询、排序、最左前缀匹配。MySQL 的 Memory 引擎用哈希索引。
- 叶子节点链表:B+ 树叶子节点通过双向链表连接,范围查询只需定位起点后顺序遍历,高效。
- InnoDB 页大小:默认 16KB,
innodb_page_size=16K。一个页放一个节点。 - 聚簇索引:InnoDB 主键索引的叶子节点存储完整行数据(数据和索引一体)。辅助索引叶子节点存储主键值(需回表)。
生产场景: 百万级数据的订单表查询慢。解决方法: 确保查询条件走索引(B+ 树 3-4 次 IO 即可定位);避免 SELECT *(减少回表和 IO);联合索引按最左前缀设计。
Q40:聚簇索引和非聚簇索引的区别?
核心原理: 聚簇索引的叶子节点存储完整行数据(索引即数据),非聚簇索引的叶子节点存储主键值(需回表查询)。
详细解析:
- 聚簇索引(Clustered Index):InnoDB 的主键索引。一张表只有一个聚簇索引。叶子节点 = 索引键 + 完整行数据。数据物理上按主键有序存储。如果没有主键,InnoDB 会选唯一非空索引,如果没有则生成隐藏的 ROWID 列。
- 非聚簇索引(Secondary Index / 辅助索引):InnoDB 的非主键索引。叶子节点 = 索引键 + 主键值。查询时先在辅助索引找到主键值,再到聚簇索引查行数据(回表)。
- 覆盖索引:如果查询的字段全部包含在索引中,无需回表。如
SELECT name, age FROM users WHERE name = '张三',如果 (name, age) 是联合索引则覆盖。 - MyISAM:主键索引和辅助索引的叶子节点都存储行数据的物理地址(指针),都是非聚簇的。
生产场景: 高频查询 SELECT id, name FROM user WHERE name = '张三',name 上有单列索引但每次需回表。解决方法: 创建联合索引 idx_name_id (name, id),利用覆盖索引避免回表,查询性能提升数倍。
Q41:索引失效的常见场景有哪些?
核心原理: 索引失效是指查询条件中有索引列但 MySQL 未使用索引,改为全表扫描。
常见失效场景:
- 对索引列做函数/运算:
WHERE YEAR(create_time) = 2026→ 改为WHERE create_time >= '2026-01-01' AND create_time < '2027-01-01'。 - 隐式类型转换:
WHERE phone = 13800138000(phone 是 VARCHAR)→ MySQL 将字符串转数字,索引失效 → 改为WHERE phone = '13800138000'。 - 左模糊查询:
WHERE name LIKE '%张三'→ 索引失效。LIKE '张三%'可走索引(最左匹配)。全模糊需用 ES。 - 联合索引非最左前缀:索引
(a, b, c),WHERE b = 1 AND c = 2不走索引(跳过了 a)。 - OR 条件:
WHERE a = 1 OR b = 2,如果 b 无索引则整体不走索引。两边都有索引才可能走索引合并。 - NOT IN / NOT EXISTS / !=:通常导致全表扫描(取决于数据分布和 MySQL 版本优化)。
- 索引列使用 IS NULL:部分场景可能不走索引(取决于数据分布)。
- 字符集不一致:JOIN 时两张表字符集不同(utf8 vs utf8mb4),隐式转换导致索引失效。
- 优化器认为全表扫描更快:数据量小或索引区分度低时,MySQL 可能选择全表扫描。
排查方法:EXPLAIN 查看 type(ALL 表示全表扫描)、key(实际使用的索引)、rows(预估扫描行数)、Extra(Using index 表示覆盖索引)。
生产场景: 接口慢查询告警,WHERE DATE(create_time) = '2026-07-03' 走全表扫描。解决方法: 改写为范围查询 WHERE create_time >= '2026-07-03 00:00:00' AND create_time < '2026-07-04 00:00:00',走索引扫描。
Q42:MySQL 事务隔离级别和 MVCC 原理?
核心原理: MySQL 有四种隔离级别(读未提交、读已提交、可重复读、串行化),InnoDB 默认可重复读(RR),通过 MVCC 实现。
详细解析:
- 四种隔离级别:
- 读未提交(Read Uncommitted):脏读(读到未提交的数据)。
- 读已提交(Read Committed):不可重复读(同一事务两次读结果不同)。Oracle/PG 默认。
- 可重复读(Repeatable Read):幻读(同一事务两次范围查询结果不同)。InnoDB 默认。
- 串行化(Serializable):完全串行,性能最差。
- MVCC(多版本并发控制):
- 每行记录隐藏字段:
DB_TRX_ID(最后修改的事务 ID)、DB_ROLL_PTR(回滚指针,指向 undo log 中的历史版本)、DB_ROW_ID(行 ID)。 - undo log 版本链:每次修改生成一条 undo log,通过回滚指针串联,形成版本链。
- ReadView:事务执行快照读时生成 ReadView,包含:当前活跃事务 ID 列表、最小活跃事务 ID、下一个事务 ID、创建者事务 ID。
- 可见性判断:遍历版本链,对每个版本判断 DB_TRX_ID 是否在 ReadView 的活跃事务列表中。如果不在(已提交)则可见;如果在(未提交)则不可见,继续往前找。
- RC vs RR:RC 每次 SELECT 都生成新 ReadView(所以能看到最新提交的数据);RR 只在事务第一次 SELECT 时生成 ReadView(后续复用,所以可重复读)。
- 每行记录隐藏字段:
- 当前读 vs 快照读:快照读(普通 SELECT)走 MVCC;当前读(SELECT FOR UPDATE、UPDATE、DELETE)读取最新已提交数据并加锁。
生产场景: 高并发下订单查询和更新冲突,RR 隔离级别下出现间隙锁导致死锁。解决方法: 理解 RR 下的 Next-Key Lock(记录锁 + 间隙锁),缩小事务范围;或降级到 RC 隔离级别(无间隙锁,但需处理不可重复读)。
Q43:MySQL 的锁机制(行锁、间隙锁、临键锁)?
核心原理: InnoDB 的锁分为记录锁(Record Lock)、间隙锁(Gap Lock)、临键锁(Next-Key Lock),用于在不同隔离级别下保证并发安全。
详细解析:
- 记录锁(Record Lock):锁住索引上的一条记录。
WHERE id = 1 FOR UPDATE锁住 id=1 这一行。 - 间隙锁(Gap Lock):锁住两条记录之间的间隙(不含记录本身),防止其他事务在间隙中插入新记录,解决幻读。如 id 有 [1, 5, 10],
WHERE id > 1 AND id < 5 FOR UPDATE锁住 (1, 5) 间隙,其他事务无法插入 id=2,3,4。 - 临键锁(Next-Key Lock):记录锁 + 间隙锁,锁住一条记录及其前面的间隙。如锁住 id=5 的 Next-Key Lock = (1, 5]。这是 RR 隔离级别下的默认行锁类型。
- 意向锁(Intention Lock):表级锁,IS(意向共享锁)/ IX(意向排他锁)。事务加行锁前先加表级意向锁,用于快速判断表中是否有行锁,避免全表扫描。
- 插入意向锁:INSERT 操作在插入前设置,表示意图在某个间隙插入。如果间隙被 Gap Lock 锁住则等待。
- RC 隔离级别:无间隙锁,只有记录锁。RR 隔离级别:有 Next-Key Lock 防幻读。
- 锁降级:唯一索引等值查询且记录存在时,Next-Key Lock 降级为 Record Lock。
生产场景: 事务 A SELECT * FROM orders WHERE id > 100 FOR UPDATE 锁住 (100, +∞),事务 B 尝试 INSERT id=200 被阻塞。解决方法: 缩小锁定范围(明确条件 WHERE id = 101);高并发插入场景考虑用 RC 隔离级别(无间隙锁)。
Q44:MySQL 死锁如何分析和排查?
核心原理: 死锁是两个或多个事务互相等待对方持有的锁,InnoDB 有死锁检测机制(wait-for graph),检测到后回滚代价较小的事务。
排查方法:
SHOW ENGINE INNODB STATUS查看 LATEST DETECTED DEADLOCK 段,记录了死锁时两个事务执行的 SQL 和持有的锁。- 开启死锁日志:
SET GLOBAL innodb_print_all_deadlocks = ON,死锁信息写入 error log。 - 分析死锁 SQL:确认事务加锁顺序、锁类型(记录锁/间隙锁/临键锁)。
information_schema.INNODB_TRX查看当前活跃事务。
常见死锁原因及解决:
- 加锁顺序不一致:事务 A 先锁 id=1 再锁 id=2,事务 B 反向 → 统一加锁顺序。
- 间隙锁冲突:RR 下两个事务分别用范围条件加锁,间隙重叠 → 缩小范围或用 RC。
- 唯一索引冲突:两个事务同时 INSERT 相同唯一键,一个等待另一个的锁 → 业务层加分布式锁或先查后插。
- ** UPDATE 锁升级**:UPDATE 未走索引导致全表扫描行锁 → 确保走索引。
生产场景: 高并发下两个事务交叉更新不同行的库存,偶发死锁。SHOW ENGINE INNODB STATUS 发现事务 A 锁了商品 1 等商品 2,事务 B 反向。解决方法: 业务层统一按商品 ID 排序后加锁;设置 innodb_lock_wait_timeout = 5(默认 50s)缩短锁等待;重试机制捕获 DeadlockLoserDataAccessException 自动重试。
Q45:MySQL 主从复制原理?
核心原理: MySQL 主从复制基于 binlog 日志,主库将变更写入 binlog,从库通过 IO 线程拉取并写入 relay log,SQL 线程重放 relay log 执行变更。
详细解析:
- 三个线程:
- 主库 Dump 线程:读取 binlog 发送给从库 IO 线程。
- 从库 IO 线程:接收 binlog 事件,写入 relay log。
- 从库 SQL 线程:读取 relay log,重放 SQL 语句。
- 复制方式:
- 异步复制(默认):主库提交事务后不等待从库确认,性能高但可能丢数据。
- 半同步复制:主库提交后至少等待一个从库收到 binlog(写入 relay log)才返回,平衡性能和数据安全。
- 全同步复制:等待所有从库执行完毕,性能差,很少用。
- binlog 格式:
- STATEMENT:记录 SQL 语句,日志小但某些函数(NOW()、UUID())可能导致不一致。
- ROW:记录行变更(前镜像+后镜像),日志大但一致性最好,推荐。
- MIXED:混合模式,默认用 STATEMENT,不安全时用 ROW。
- GTID 复制:全局事务 ID,每个事务有唯一 GTID,从库按 GTID 顺序复制,避免传统基于 binlog 位点复制的复杂性。
生产场景: 主从延迟导致读从库时数据不一致(用户刚下单查不到订单)。解决方法: MySQL 5.7+ 并行复制(slave_parallel_workers)多线程重放;写后读强制走主库(ShardingSphere 读写分离配置 forceRouteMaster);关键业务(支付)直连主库。
Q46:MySQL 慢查询如何优化?
核心原理: 慢查询优化的核心是分析执行计划、优化索引、改写 SQL、调整表结构。
优化步骤:
- 开启慢查询日志:
SET GLOBAL slow_query_log = ON; SET GLOBAL long_query_time = 1; - 分析慢查询:
mysqldumpslow -s t -t 10 slow.log或 pt-query-digest 按耗时排序 Top 10。 - EXPLAIN 分析执行计划:
- type:ALL(全表扫描,最差)→ index(全索引扫描)→ range(范围扫描)→ ref(非唯一索引等值)→ eq_ref(唯一索引等值)→ const(主键等值,最优)。
- key:实际使用的索引,NULL 表示未走索引。
- rows:预估扫描行数,越少越好。
- Extra:Using index(覆盖索引好)、Using filesort(需优化)、Using temporary(需优化)。
- 索引优化:为 WHERE、JOIN、ORDER BY、GROUP BY 字段建索引;联合索引按区分度高的字段在前;避免冗余索引。
- SQL 改写:避免
SELECT *;大 IN 列表改为 JOIN;OR 改为 UNION ALL;子查询改为 JOIN;分页用游标WHERE id > lastId LIMIT 10。 - 表结构优化:大字段拆分到扩展表(垂直拆分);冷热数据分离(历史数据归档);适当反范式化(冗余字段避免 JOIN)。
生产场景: SELECT * FROM orders WHERE user_id = 123 ORDER BY create_time DESC LIMIT 100000, 10 耗时 3 秒。解决方法: 深度分页改用游标 WHERE user_id = 123 AND id < #{lastId} ORDER BY id DESC LIMIT 10;建联合索引 idx_user_id_create_time (user_id, create_time);避免 SELECT * 只查需要的列。
Q47:MySQL 深度分页如何优化?
核心原理: LIMIT offset, size 当 offset 很大时,MySQL 需扫描 offset+size 行再丢弃前 offset 行,效率极低。
优化方案:
- 游标分页(推荐):
WHERE id > #{lastId} ORDER BY id ASC LIMIT 10,每次记录上一页最后一条 id,直接定位。O(1) 复杂度,但只能顺序翻页不能跳页。 - 子查询延迟关联:
SELECT * FROM orders o INNER JOIN (SELECT id FROM orders WHERE user_id = 1 ORDER BY create_time DESC LIMIT 100000, 10) t ON o.id = t.id。子查询走覆盖索引快速定位 id,再回表取数据,减少回表次数。 - ES search_after:亿级数据深度分页用 Elasticsearch 的 search_after,基于上一页排序值的游标分页。
- 禁止跳页:产品层面只允许"上一页/下一页",不允许直接跳到第 N 页。
- 冷热分离:历史数据归档到归档表/分区表,主表保持小体量。
生产场景: 订单列表 LIMIT 1000000, 10 耗时 5 秒。解决方法: 改为延迟关联 + 联合索引 (user_id, create_time, id),子查询 SELECT id FROM orders WHERE user_id = 1 ORDER BY create_time DESC LIMIT 1000000, 10 走覆盖索引只扫描索引不回表,再 JOIN 取数据,耗时降至 200ms。
Q48:MySQL 分库分表的方案和问题?
核心原理: 当单表数据量过大(通常 >1000 万或 >100GB),需通过垂直拆分(按字段)或水平拆分(按行)分散到多个库/表。
详细解析:
- 垂直拆分:
- 垂直分表:将大字段(如 TEXT/BLOB)拆到扩展表,主表只留常用字段。
- 垂直分库:按业务拆分(用户库、订单库、商品库),解决单库表过多、IO 竞争。
- 水平拆分:
- 按 hash 取模:
user_id % N分配到 N 个分片,数据均匀但扩容需重新迁移。 - 按范围分片:如按时间(每月一个表),冷热分离但写入热点集中。
- 一致性哈希:扩容时只迁移部分数据,减少迁移量。
- 按 hash 取模:
- 分片键选择:选查询频率最高、区分度高的字段(如 user_id)。非分片键查询需广播或建索引表。
- 跨库问题:
- 跨库 JOIN:应用层组装、数据冗余、ES 异构。
- 跨库事务:分布式事务(Seata AT / 本地消息表 / TCC)。
- 跨库分页:各分片查 Top N 再归并排序,分片多时性能差。
- 全局唯一 ID:Snowflake、Leaf-Segment。
- 中间件:ShardingSphere(Sharding-JDBC / Sharding-Proxy)、MyCat。
生产场景: 日增 100 万订单,单表 5000 万后查询变慢。解决方法: 按 order_id % 16 分 16 张表(4 库 × 4 表);订单 ID 嵌入时间基因(日期前缀 + 雪花 ID),按时间范围查询可定位分片;跨库查询异构到 ES;扩容用双写方案(新旧并行 → 灰度切流 → 旧库下线)。
Q49:MySQL 事务的 ACID 特性和实现原理?
核心原理: ACID 指原子性(Atomicity)、一致性(Consistency)、隔离性(Isolation)、持久性(Durability),InnoDB 通过 redo log、undo log、MVCC、锁机制实现。
详细解析:
- 原子性:事务内操作要么全部成功要么全部回滚。实现:undo log(回滚日志),记录修改前的数据。事务执行过程中先写 undo log,回滚时根据 undo log 恢复。
- 持久性:事务提交后数据永久保存。实现:redo log(重做日志),WAL(Write-Ahead Logging)机制——先写 redo log 再写磁盘数据页。redo log 是物理日志(记录"哪个页哪个偏移量改成什么"),顺序写入性能高。崩溃恢复时重放 redo log。
- 隔离性:并发事务互不干扰。实现:MVCC + 锁。快照读用 MVCC(多版本),当前读用锁(行锁、间隙锁)。
- 一致性:事务执行前后数据状态正确。由原子性 + 隔离性 + 持久性 + 应用层约束共同保证。
- 两阶段提交(2PC):InnoDB 写 redo log prepare → MySQL 写 binlog → InnoDB 写 redo log commit。保证 binlog 和 redo log 一致,主从复制不丢数据。
生产场景: 事务过大导致锁持有时间长,并发性能差。解决方法: 缩小事务范围(只包含写操作,查询移到事务外);避免长事务(SET GLOBAL innodb_kill_idle_transaction 杀空闲事务);大事务拆分为小事务。
五、Redis 缓存(Q50-Q59)
Q50:Redis 常用数据类型及应用场景?
核心原理: Redis 有 5 种基本数据类型(String、List、Hash、Set、ZSet)和扩展类型(Bitmap、HyperLogLog、Geo、Stream)。
详细解析:
| 类型 | 底层结构 | 应用场景 |
|---|---|---|
| String | SDS(简单动态字符串) | 缓存对象(JSON)、计数器(INCR)、分布式锁(SETNX) |
| List | QuickList(ZipList + LinkedList) | 消息队列(LPUSH/BRPOP)、最新动态、文章列表 |
| Hash | ZipList / HashTable | 对象存储(用户信息)、购物车(商品ID→数量) |
| Set | IntSet / HashTable | 去重(UV)、共同好友、标签、抽奖 |
| ZSet | ZipList / SkipList + HashTable | 排行榜、延迟队列(score=时间戳)、热搜排名 |
| Bitmap | String | 签到打卡、布隆过滤器、活跃用户统计 |
| HyperLogLog | 稀疏/密集矩阵 | UV 统计(误差 0.81%,12KB 统计 2^64) |
| Geo | ZSet | 附近的人、地图范围查询(GEOADD/GEORADIUS) |
| Stream | RadixTree | 消息队列(支持消费组、ACK)、事件流 |
- ZSet 底层:跳跃表(SkipList)+ 哈希表。跳跃表支持范围查询 O(logN),哈希表支持单元素查找 O(1)。
- String 最大 512MB,List/Set/ZSet 最大 2^32 个元素。
生产场景: 实时排行榜——百万用户按积分排名。解决方法: ZADD ranking:userId score userId,ZREVRANGE ranking:userId 0 9 WITHSCORES 获取 Top 10,ZREVRANK ranking:userId userId 获取用户排名。O(logN) 复杂度,百万级数据毫秒响应。
Q51:Redis 持久化机制(RDB vs AOF)?
核心原理: RDB 是内存快照(全量),AOF 是命令日志(增量)。RDB 恢复快但可能丢数据,AOF 数据安全但恢复慢。
详细解析:
- RDB(Redis Database):
SAVE(阻塞)或BGSAVE(fork 子进程,COW 机制写快照)。- 触发方式:手动 BGSAVE、配置
save 900 1(900秒内1次修改)、shutdown。 - 优点:二进制紧凑文件小,恢复快,适合备份。
- 缺点:两次快照间数据可能丢失;fork 大内存时耗时。
- AOF(Append Only File):
- 记录每条写命令到 appendonly.aof 文件。
- 刷盘策略:
always(每条命令 fsync,最安全但最慢)、everysec(每秒 fsync,默认,最多丢 1 秒)、no(由 OS 决定)。 - AOF 重写:fork 子进程,根据当前内存数据重新生成最小命令集(如 100 次 INCR 合并为 1 次 SET),减小文件体积。
auto-aof-rewrite-percentage 100(文件翻倍时重写)。 - 优点:数据安全性高(最多丢 1 秒)。
- 缺点:文件比 RDB 大,恢复慢。
- Redis 4.0+ 混合持久化:
aof-use-rdb-preamble yes,AOF 重写时前半部分写 RDB 格式(全量快照),后半部分写 AOF 增量命令。兼具 RDB 的快速恢复和 AOF 的数据安全。
生产场景: 缓存服务重启后需要快速恢复且不能丢太多数据。解决方法: 开启混合持久化 aof-use-rdb-preamble yes + appendfsync everysec,恢复时先加载 RDB 快照(秒级),再重放少量 AOF 命令。
Q52:Redis 过期策略和内存淘汰策略?
核心原理: 过期策略决定何时删除过期 key,淘汰策略决定内存满时删除哪些 key。
详细解析:
- 过期策略(删除过期 key):
- 惰性删除:访问 key 时检查是否过期,过期则删除。优点 CPU 友好,缺点过期 key 不访问会一直占用内存。
- 定期删除:每 100ms 随机抽取一批设了 TTL 的 key 检查,删除过期的。如果过期比例 >25% 则继续抽取。
- Redis 同时使用惰性 + 定期删除,互补。
- 内存淘汰策略(内存满时):
maxmemory-policy配置:noeviction:默认,内存满直接报错(写入操作失败)。allkeys-lru:所有 key 中淘汰最近最少使用(最常用)。allkeys-lfu:所有 key 中淘汰最不经常使用(4.0+)。allkeys-random:随机淘汰。volatile-lru:只在设了过期时间的 key 中 LRU 淘汰。volatile-lfu:只在设了过期时间的 key 中 LFU 淘汰。volatile-random:只在设了过期时间的 key 中随机淘汰。volatile-ttl:淘汰即将过期的 key。
- LRU vs LFU:LRU 最近最少使用(基于时间),LFU 最不经常使用(基于频率)。LFU 解决 LRU 的"偶尔被访问的非热点数据不会被淘汰"问题。Redis 的 LRU/LFU 是近似实现(采样淘汰,默认采样 5 个 key,
maxmemory-samples)。
生产场景: Redis 作为缓存,内存满后写入失败。解决方法: 设置 maxmemory 4gb + maxmemory-policy allkeys-lru,优先淘汰最久未访问的数据。如果热点数据稳定用 LFU。纯缓存场景用 allkeys-lru,混合持久化场景用 volatile-lru(不淘汰持久化数据)。
Q53:缓存穿透、缓存击穿、缓存雪崩的区别和解决方案?
核心原理: 三者都是缓存失效导致请求打到数据库的问题,触发条件不同。
详细解析:
| 问题 | 触发条件 | 危害 | 解决方案 |
|---|---|---|---|
| 缓存穿透 | 查询不存在的数据,缓存和 DB 都没有 | 恶意攻击,DB 被打满 | 布隆过滤器拦截;缓存空值+短TTL |
| 缓存击穿 | 单个热点 key 过期瞬间 | 大量并发请求打到 DB | 互斥锁重建;逻辑过期不设TTL |
| 缓存雪崩 | 大量 key 同时过期或 Redis 宕机 | DB 瞬间压力暴增 | TTL加随机抖动;Redis高可用;本地缓存兜底 |
- 缓存穿透 - 布隆过滤器:在缓存前加布隆过滤器,存储存在的 key。请求先过布隆过滤器判断 key 是否可能存在,不存在直接返回。极小误判率(可调参数降低)。也可缓存空值(
SET key null EX 300)设置短过期时间。 - 缓存击穿 - 互斥锁:缓存未命中时,用 Redis SETNX 获取互斥锁,只有一个线程查 DB 重建缓存,其他线程等待或返回旧值。也可用"逻辑过期"(key 不设 TTL,value 中存逻辑过期时间,后台异步重建)。
- 缓存雪崩 - 随机 TTL:
expire(key, base + random(0, 300))避免同时过期。Redis Cluster 高可用 + Caffeine 本地缓存兜底。熔断降级(Sentinel)保护 DB。
生产场景: 黑客用不存在的 ID 大量请求接口,DB 被打满。解决方法: 布隆过滤器 + 缓存空值双重防护;网关层限流(Sentinel 热点参数限流);IP 黑名单。
Q54:Redis 和 MySQL 双写一致性如何保证?
核心原理: 缓存和数据库分属两个系统,无法保证强一致性,通常追求最终一致性。
常见方案:
- Cache Aside(旁路缓存,推荐):
- 读:先查缓存,未命中查 DB 后写入缓存。
- 写:先更新 DB,再删除缓存(删除而非更新,避免并发写导致缓存与 DB 不一致)。
- 问题:先更新 DB 再删缓存,如果删缓存失败则数据不一致 → 延迟双删(删缓存 → 更新 DB → 延迟 500ms 再删缓存)。
- Canal 监听 binlog:
- Canal 模拟 MySQL 从节点,监听 binlog 变更,消费 binlog 事件后删除/更新 Redis 缓存。
- 优点:业务代码无需关心缓存,完全解耦。
- 缺点:有延迟(毫秒级),需保证 Canal 高可用。
- 消息队列保证:
- 更新 DB 后发 MQ 消息,消费者删除缓存。消息可靠投递保证最终一致。
- 先删缓存再更新 DB:
- 问题:删缓存后、更新 DB 前,另一个请求读到旧数据并写入缓存 → 数据不一致。
- 解决:延迟双删(先删缓存 → 更新 DB → 延迟再删缓存)。
生产场景: 商品价格更新后缓存未及时刷新,用户看到旧价格。解决方法: 采用 Canal 监听 binlog 异步删除缓存 + 延迟双删兜底;读请求走主库(写后读强制走主库);对一致性要求极高的场景(如金融)加分布式锁保护读写。
Q55:Redis 分布式锁如何实现?
核心原理: Redis 分布式锁通过 SET key value EX seconds NX 原子命令实现,生产环境推荐 Redisson 框架。
详细解析:
- 基本实现:
SET lock_key uuid EX 30 NX,NX 保证互斥,EX 设置过期避免死锁,value 用 UUID 标识持有者。 - 解锁(Lua 脚本保证原子性):
先判断 value 是否匹配(防止误删别人的锁),再删除。if redis.call('get', KEYS[1]) == ARGV[1] then return redis.call('del', KEYS[1]) else return 0 end - Redisson 增强:
- 看门狗(Watchdog):默认锁过期 30 秒,每 10 秒自动续期,业务执行时间长也不会锁过期。
- 可重入:Hash 结构存储线程 ID + 重入次数。
- 公平锁/读写锁/信号量:支持多种锁类型。
- RedLock:向多个独立 Redis 节点加锁,多数成功才算获取锁,解决主从切换丢锁问题(争议较大,生产慎用)。
- 常见陷阱:
- SETNX + EXPIRE 非原子 → 用 SET EX NX。
- 锁过期但业务未完成 → 看门狗续期。
- 主从切换丢锁 → RedLock 或 Redlock+ fencing token。
- 误删别人的锁 → UUID + Lua 脚本判断。
生产场景: 定时任务多实例部署,需保证只有一个实例执行。解决方法: Redisson RLock lock = redissonClient.getLock("task:sync:lock"); lock.tryLock(0, -1, TimeUnit.SECONDS),获取锁的实例执行,其他实例跳过。或用 XXL-JOB 调度框架内置分布式锁。
Q56:Redis 集群模式(主从、哨兵、Cluster)?
核心原理: Redis 有三种集群方案:主从复制(读写分离)、哨兵模式(自动故障转移)、Cluster(分片+高可用)。
详细解析:
- 主从复制:
- 一主多从,主写从读,数据异步复制。
- 优点:读写分离分担读压力、数据备份。
- 缺点:主节点故障需手动切换,无法自动恢复。
- 哨兵模式(Sentinel):
- 哨兵独立进程监控主从节点,主节点故障时自动选举从节点为新主。
- 工作原理:哨兵定期 PING 主从节点,主观下线(单个哨兵认为不可用)→ 客观下线(多数哨兵确认)→ 选举 leader 哨兵执行故障转移 → 选优先级最高的从节点升主 → 通知客户端新主地址。
- 优点:自动故障转移,高可用。
- 缺点:单主写入瓶颈,无法水平扩展存储。
- Redis Cluster:
- 数据分片:16384 个哈希槽(slot),
CRC16(key) % 16384定位 slot → slot 分配到不同节点。 - 每个主节点负责一部分 slot,配 1-2 个从节点。
- Gossip 协议:节点间通信,感知集群状态,故障检测和转移。
- 客户端路由:MOVED 重定向(key 不在当前节点返回 MOVED 指令)、ASK 临时重定向(slot 迁移中)。
- 优点:水平扩展(数据分散到多节点)、高可用(每个主节点有从节点)。
- 缺点:不支持跨 slot 的多键操作(除非 hashtag
{user}:1保证同一 slot);事务和 Lua 脚本受限。
- 数据分片:16384 个哈希槽(slot),
生产场景: 数据量 200GB,单机内存不够,需水平扩展。解决方法: Redis Cluster,6 个节点(3 主 3 从),每主负责约 5461 个 slot,数据均匀分布。注意跨 slot 操作需用 hashtag 保证同一 key 落在同一 slot。
Q57:Redis 大 Key 和热 Key 问题如何解决?
核心原理: 大 Key 指 value 过大(如 List 存百万元素),热 Key 指单个 key 访问量过高(如热点商品),都会影响 Redis 性能。
详细解析:
- 大 Key 问题:
- 危害:网络阻塞(传输大 value 耗时)、主线程阻塞(删除大 key 阻塞 Redis 单线程,Redis 4.0+ 用 UNLINK 异步删除)、内存不均(Cluster 中某个 slot 过大导致节点内存倾斜)、阻塞其他操作。
- 检测:
redis-cli --bigkeys扫描分析;MEMORY USAGE key查看单个 key 内存。 - 解决:拆分大 key(百万元素 List 拆成多个小 List);大 value 压缩(Snappy/Gzip)或存 OSS 只存引用;UNLINK 异步删除;设置合理 TTL。
- 热 Key 问题:
- 危害:单 key QPS 过高导致单节点 CPU 打满(Cluster 中热 key 集中在一个节点)、缓存击穿。
- 检测:
redis-cli --hotkeys(需开启 LFU);MONITOR 命令(慎用,影响性能);代理层统计 key 访问频率。 - 解决:多副本分散(热 key 复制多份
key_1, key_2, ..., key_N,随机读副本);本地缓存兜底(Caffeine 缓存热 key,减少 Redis 访问);key 分桶(stock_{productId}_0 ~ stock_{productId}_9随机选桶读写)。
生产场景: 秒杀商品详情页 QPS 10 万+,单个商品 key 访问集中在一个 Redis 节点。解决方法: Caffeine 本地缓存 + Redis 二级缓存,本地缓存 TTL 5 秒兜底;热 key 分桶 product_123_0 ~ product_123_9,读时随机选桶分散压力。
Q58:Redis 的管道(Pipeline)和事务(MULTI/EXEC)?
核心原理: Pipeline 是客户端批量发送命令减少网络往返,MULTI/EXEC 是服务端事务保证命令顺序执行。
详细解析:
- Pipeline:
- 客户端将多条命令打包一次性发送,服务端依次执行后一次性返回结果。
- 减少网络 RTT(Round Trip Time),如 100 条命令从 100 次 RTT 降为 1 次。
- 非原子性:命令间可能穿插其他客户端命令。
- 适合批量写入/读取场景。
- MULTI/EXEC 事务:
MULTI开启事务 → 多条命令入队(返回 QUEUED)→EXEC原子执行所有命令。- 不支持回滚:某条命令运行时错误(如对字符串 INCR)不会影响其他命令执行,也不会回滚。
WATCH key乐观锁:EXEC 前如果 key 被修改则事务中断(返回 nil)。- 原子性仅保证"顺序执行不被打断",不是数据库事务的 ACID 原子性。
- Lua 脚本:
EVAL script numkeys key... arg...执行 Lua 脚本,Redis 单线程执行脚本期间不会被其他命令打断。- 比事务更强:真正的原子性 + 条件判断 + 循环逻辑。
- 秒杀库存扣减的 Lua 脚本:判断库存 → 扣减 → 记录用户 → 返回结果,全部原子执行。
生产场景: 批量写入 1 万条缓存数据,逐条 SET 耗时 10 秒。解决方法: Pipeline 批量发送,100 条一批,耗时降至 1 秒内。注意 Pipeline 批量不宜过大(控制内存),建议 500-1000 条/批。
Q59:布隆过滤器原理和应用?
核心原理: 布隆过滤器是一种概率型数据结构,用位数组 + 多个哈希函数判断元素"一定不存在"或"可能存在",空间效率极高。
详细解析:
- 结构:一个长度为 m 的位数组(初始全 0)+ k 个独立哈希函数。
- 写入:对元素用 k 个哈希函数计算得到 k 个位置,将位数组对应位置设为 1。
- 查询:对元素用 k 个哈希函数计算,如果所有位置都为 1 则"可能存在"(有误判),如果有任一位置为 0 则"一定不存在"(无漏报)。
- 误判率:与位数组大小 m、哈希函数数量 k、元素数量 n 有关。m 越大误判越低,k 太多冲突反而增加。典型配置:100 万元素、1% 误判率,仅需 1.2MB 内存。
- 不支持删除:多个元素可能哈希到同一位置,删除会影响其他元素。Counting Bloom Filter(用计数器代替位)支持删除。
- Redis 中使用:RedisBloom 模块(BF.ADD/BF.EXISTS);或用 Redis Bitmap 手动实现;Guava BloomFilter 本地实现。
生产场景: 缓存穿透防护——查询不存在的用户 ID 打到数据库。解决方法: 系统启动时将所有用户 ID 加入布隆过滤器,查询请求先过布隆过滤器判断,不存在直接返回,不查缓存和 DB。百万用户 ID 仅需 1MB+ 内存,误判率 1%。
六、消息队列(Q60-Q67)
Q60:Kafka、RocketMQ、RabbitMQ 的区别和选型?
核心原理: 三种 MQ 在架构模型、性能、功能特性上有显著差异,需根据业务场景选型。
详细解析:
| 维度 | Kafka | RocketMQ | RabbitMQ |
|---|---|---|---|
| 开发语言 | Scala/Java | Java | Erlang |
| 性能 | 百万级 TPS | 十万级 TPS | 万级 TPS |
| 延迟 | 毫秒级 | 毫秒级 | 微秒级 |
| 消息可靠性 | 高(副本+ack) | 高(同步刷盘+副本) | 高(确认机制+持久化) |
| 顺序消息 | 分区内有序 | 分区内有序 | 队列内有序 |
| 事务消息 | 弱(幂等生产者) | 强(半消息+回查) | 无原生支持 |
| 延迟消息 | 不支持 | 18 个延迟级别 | 插件/TTL+死信 |
| 消息回溯 | 支持(offset) | 支持(时间戳) | 不支持 |
| 适用场景 | 日志采集、大数据流 | 电商交易、金融 | 企业应用、低延迟 |
- Kafka:高吞吐、日志型场景首选。分区+副本机制,Pull 模式消费。不适合需要精确事务和延迟消息的场景。
- RocketMQ:阿里开源,Java 实现,适合电商交易场景。原生支持事务消息、延迟消息、顺序消息、消息回溯。
- RabbitMQ:AMQP 协议,路由灵活(Direct/Topic/Fanout/Headers),低延迟,适合复杂路由和企业应用。Erlang 实现运维门槛高。
生产场景: 电商系统需要订单支付后发消息通知发货、加积分,要求消息不丢且支持事务。解决方法: 选 RocketMQ,事务消息保证本地事务与消息发送的最终一致性,延迟消息实现订单超时取消。
Q61:如何保证消息不丢失?
核心原理: 消息从生产到消费经过三个环节(生产者→Broker→消费者),每个环节都可能丢失,需分别保障。
详细解析:
- 生产者端(发送不丢失):
- Kafka:
acks=all(等待所有 ISR 副本确认)+retries=3(重试)+enable.idempotence=true(幂等避免重试导致重复)。 - RocketMQ:同步发送(
send()等待 Broker 确认)+ 重试;或异步发送 + 回调检查。 - RabbitMQ:开启 confirm 模式(
publisher-confirms=true),Broker 收到后回调 ack。
- Kafka:
- Broker 端(存储不丢失):
- Kafka:
replication.factor≥3(3 副本)+min.insync.replicas=2(至少 2 个副本同步成功)。 - RocketMQ:
flushDiskType=SYNC_FLUSH(同步刷盘,性能下降但数据安全)+ 主从同步复制。 - RabbitMQ:队列和消息持久化(
durable=true+deliveryMode=2)。
- Kafka:
- 消费者端(消费不丢失):
- 关闭自动 ACK,改为手动 ACK:业务处理成功后再
basicAck,异常时basicNack重新入队。 - Kafka:关闭自动提交 offset(
enable.auto.commit=false),业务处理成功后手动提交。
- 关闭自动 ACK,改为手动 ACK:业务处理成功后再
生产场景: 订单支付消息丢失导致仓库未发货。解决方法: RocketMQ 同步发送 + 同步刷盘 + 手动 ACK + 消费幂等,全链路保障不丢。配合本地消息表兜底(定时扫描未确认消息重新投递)。
Q62:如何保证消息不重复消费(消费端幂等)?
核心原理: 网络抖动、生产者重试、消费者 ACK 失败重试都可能导致消息重复投递,需在消费端实现幂等性。
常见方案:
- 消息 ID + Redis 去重:消费前用
SETNX msgId 1 EX 86400,设置成功则处理,失败(已存在)则跳过。简单高效,适合大部分场景。 - 数据库唯一索引:业务表对业务唯一键(如订单号)建唯一索引,重复插入抛 DuplicateKeyException,捕获后视为已处理。绝对可靠。
- 乐观锁/状态机:
UPDATE order SET status='paid' WHERE id=? AND status='unpaid',影响行数=0 表示已处理。 - 去重表:单独建消息处理记录表,消费前查是否已处理。
- Redis Token 机制:与唯一索引类似,用 Redis 的
GETDEL msgId原子操作。
生产场景: 支付成功消息重复消费导致用户积分加两次。解决方法: 消费端用消息 ID + Redis SETNX 去重(TTL 24 小时);数据库积分表对 (user_id, order_id) 建唯一索引兜底。双重保障确保幂等。
Q63:如何保证消息顺序消费?
核心原理: 全局有序难以实现且性能差,通常保证局部有序(同一业务 ID 的消息有序)。
详细解析:
- Kafka 顺序消息:
- 生产者:同一业务 key 的消息发送到同一 partition(
partition = hash(key) % partitionCount)。 - 消费者:一个 partition 只能被消费组内一个消费者消费,保证分区内有序。
- 问题:一个 partition 一个消费者,并发度受限。
- 生产者:同一业务 key 的消息发送到同一 partition(
- RocketMQ 顺序消息:
- 生产者:
MessageQueueSelector将同一 shardingKey 的消息发到同一 MessageQueue。 - 消费者:
MessageListenerOrderly顺序消费(同一队列加锁,单线程消费),MessageListenerConcurrently并发消费(不保证顺序)。 - 顺序消费失败会阻塞当前队列,重试直到成功或达到最大重试次数。
- 生产者:
- RabbitMQ 顺序消息:一个队列对应一个消费者,队列内 FIFO。多个消费者会破坏顺序。
生产场景: 订单状态变更消息(创建→支付→发货→完成)需按顺序消费,否则可能"已完成"先于"支付"被消费。解决方法: 以订单 ID 为 shardingKey,同一订单的消息发到同一 partition/queue,单线程消费保证顺序。注意顺序消费会降低吞吐量,仅对有顺序要求的业务使用。
Q64:消息积压如何处理?
核心原理: 消息积压是消费速度跟不上生产速度,需提升消费能力或减少生产量。
处理方案:
- 扩容消费者:增加消费者实例数(不能超过 partition/queue 数量,否则多余消费者空闲)。Kafka 中一个 partition 只能被一个消费者消费。
- 临时扩 partition + 消费者:Kafka 可以动态增加 partition 数量,同时增加消费者。
- 临时 Topic 批量迁移:积压严重时,新建一个 partition 数更多的临时 topic,消费者从原 topic 快速读取(不做业务处理)转发到临时 topic,再用大量消费者消费临时 topic。
- 提升单消费者处理速度:批量处理(攒一批一起操作 DB)、异步处理(接收后直接入库待处理表,异步慢慢消费)、多线程消费(注意顺序性)。
- 限流降级:上游限流减少消息生产量;非核心业务暂停消费。
- 排查消费慢的原因:DB 慢查询、外部接口超时、GC 频繁、消费者机器资源不足。
生产场景: 大促期间订单消息积压 500 万条,消费者跟不上。解决方法: 紧急扩容 partition 从 8 到 32,消费者从 8 到 32;消费逻辑改为批量写入 DB(每 100 条一批);非核心消息(如通知)暂停消费。积压从 500 万降到 0 耗时 30 分钟。
Q65:延迟消息如何实现?
核心原理: 延迟消息是消息发送后延迟指定时间才被消费,常用于订单超时关闭、延迟通知等场景。
实现方案:
- RocketMQ 延迟消息:原生支持 18 个延迟级别(1s/5s/10s/30s/1m/2m/3m/4m/5m/6m/7m/8m/9m/10m/20m/30m/1h/2h)。RocketMQ 5.0+ 支持任意时间延迟。原理:消息先存入延迟队列(按级别分 Topic),后台定时任务扫描到期消息转发到目标 Topic。
- RabbitMQ TTL + 死信队列:消息设置 TTL 过期后进入死信队列(DLX),消费者监听死信队列实现延迟。注意:队列 TTL 是队头消息过期才检查,可能导致后面的消息延迟不准确。
- Redis ZSet:score=执行时间戳,定时任务轮询
ZRANGEBYSCORE key 0 now获取到期任务。Redisson DelayedQueue 封装更优雅。 - 时间轮:Kafka 内部用 Hierarchical Timing Wheel(层级时间轮),Netty 的 HashedWheelTimer 也可实现。适合大量延迟任务。
- 定时任务扫库:数据库存储待执行任务 + 执行时间,定时任务扫描到期任务执行。精度低但简单可靠。
生产场景: 订单 15 分钟未支付自动取消。解决方法: 下单时发送 RocketMQ 延迟 15 分钟消息,消费时检查订单状态:已支付则忽略,未支付则取消订单 + 释放库存。消费端需幂等(订单可能已被手动取消)。
Q66:RocketMQ 事务消息的原理?
核心原理: RocketMQ 事务消息通过半消息 + 本地事务 + 回查机制,保证本地事务与消息发送的最终一致性。
详细流程:
- 发送半消息:Producer 向 Broker 发送半消息(Half Message),此消息对 Consumer 不可见(存入特殊 Topic
RMQ_SYS_TRANS_HALF_TOPIC)。 - 执行本地事务:Producer 执行本地数据库事务(如创建订单)。
- 提交/回滚:
- 本地事务成功 → Producer 向 Broker 发送 COMMIT,半消息转为可消费消息(转发到目标 Topic)。
- 本地事务失败 → Producer 发送 ROLLBACK,半消息被删除。
- 事务回查:如果 Producer 发送 COMMIT/ROLLBACK 失败(网络问题或 Producer 宕机),Broker 定期(60 秒)回查 Producer 的本地事务状态:
checkLocalTransaction()返回 COMMIT/ROLLBACK/UNKNOWN。 - 超时处理:回查超过一定次数(默认 15 次)后,Broker 自动 ROLLBACK。
生产场景: 订单支付成功后需通知发货和积分系统,要求本地支付记录与消息发送一致。解决方法: 用 RocketMQ 事务消息——发送半消息 → 更新支付状态为"已支付" → COMMIT。如果本地事务成功但 COMMIT 丢失,Broker 回查 checkLocalTransaction() 返回"已支付" → COMMIT。消费者(发货/积分)收到消息后处理,消费端幂等保证不重复。
Q67:消息队列如何实现削峰填谷?
核心原理: MQ 作为缓冲层,突发流量先写入 MQ(削峰),消费者按自身能力匀速消费(填谷),保护下游系统。
详细解析:
- 削峰:前端请求高峰时,同步写入 MQ 后立即返回"排队中"(而非等待业务处理完成),MQ 承受流量峰值。
- 填谷:消费者以固定速率(或根据自身处理能力)从 MQ 拉取消息处理,将突发流量平滑为匀速流量。
- 异步解耦:上游不需要等待下游处理完成,响应时间从"业务处理耗时"降为"写入 MQ 耗时"(毫秒级)。
- 配合限流:消费者端用 Sentinel/RateLimiter 限制消费速率,避免消费过快打垮下游 DB。
生产场景: 秒杀活动 QPS 峰值 10 万,但订单处理系统最大承受 2000 QPS。解决方法: 请求先写入 Kafka(10 万 QPS 峰值),订单消费者限速 2000 QPS 消费,超出能力积压在 Kafka 中。用户收到"排队中"提示,前端轮询查询结果。活动结束后消费者继续消费积压消息,10 分钟内处理完毕。
七、分布式系统(Q68-Q77)
Q68:CAP 理论和 BASE 理论?
核心原理: CAP 指分布式系统无法同时满足一致性(C)、可用性(A)、分区容错性(P),只能选其二。BASE 是 CAP 的实践妥协,追求最终一致性。
详细解析:
- CAP 理论:
- 一致性(Consistency):所有节点同一时刻看到相同数据。
- 可用性(Availability):每个请求都能收到响应(不保证是最新数据)。
- 分区容错性(Partition Tolerance):网络分区时系统仍能运行。
- 由于网络分区不可避免,实际只能在 CP 和 AP 间选择:
- CP:ZooKeeper、etcd、Redis(主从切换时拒绝写入)。保证一致但可能不可用。
- AP:Eureka、Cassandra、Redis Cluster(故障时仍可读写)。保证可用但可能数据不一致。
- BASE 理论:
- Basically Available(基本可用):允许损失部分可用性(响应时间增加、降级服务)。
- Soft State(软状态):允许中间状态存在(数据同步有延迟)。
- Eventually Consistent(最终一致性):系统保证最终数据一致,不要求实时强一致。
- 大部分互联网系统选择 AP + 最终一致性(BASE),金融核心场景选择 CP(强一致)。
生产场景: 电商下单场景中库存扣减和订单创建跨服务,需保证一致性但不能阻塞太久。解决方法: 放弃强一致性,用 RocketMQ 事务消息 + 本地消息表保证最终一致性(BASE)。支付场景则用 TCC 强一致保证资金安全。
Q69:分布式事务有哪些解决方案?
核心原理: 分布式事务解决跨库/跨服务的数据一致性问题,常见方案有 2PC、TCC、SAGA、本地消息表、MQ 事务消息。
详细解析:
| 方案 | 一致性 | 性能 | 复杂度 | 适用场景 |
|---|---|---|---|---|
| 2PC/XA | 强一致 | 差(锁等待) | 中 | 传统数据库,少用 |
| TCC | 强一致 | 好(无锁) | 高(需写3个接口) | 金融核心交易 |
| SAGA | 最终一致 | 好 | 中(需补偿) | 长流程编排 |
| 本地消息表 | 最终一致 | 好 | 低 | 跨服务最终一致 |
| MQ 事务消息 | 最终一致 | 好 | 低 | RocketMQ 生态 |
- 2PC(两阶段提交):协调者发起 prepare → 参与者锁定资源返回 yes/no → 协调者根据全部响应发 commit/rollback。问题:协调者单点故障、同步阻塞、数据不一致(commit 阶段部分参与者超时)。
- TCC(Try-Confirm-Cancel):Try 预留资源(冻结库存)→ Confirm 确认(实际扣减)→ Cancel 回滚(释放冻结)。需处理空回滚(Cancel 先于 Try)、悬挂(Try 在 Cancel 后到达)、幂等。性能好但业务侵入强。
- SAGA:长事务拆分为多个子事务,每个有正向操作和补偿操作。任一失败则反向执行已完成子事务的补偿。适合跨多服务长流程。
- 本地消息表:业务表中附带消息表,本地事务保证业务+消息原子写入,定时任务扫描消息表投递 MQ。
- Seata:提供 AT(自动补偿,基于 undo_log)、TCC、SAGA、XA 四种模式。AT 模式侵入最低。
生产场景: 下单需扣库存(库存服务)+ 创建订单(订单服务)+ 冻结优惠券(券服务)。解决方法: 高并发秒杀用 TCC(性能优先);普通下单用 Seata AT(低侵入)或 RocketMQ 事务消息(最终一致)。
Q70:分布式 ID 生成方案?
核心原理: 分库分表后数据库自增 ID 无法保证全局唯一,需独立 ID 生成方案。
常见方案:
- UUID:本地生成无网络开销,全局唯一。缺点:无序(B+ 树插入碎片多)、过长(36 字符)、无业务含义。适合非数据库主键场景。
- Snowflake(雪花算法):64 位 = 1 位符号 + 41 位时间戳 + 10 位机器 ID + 12 位序列号。趋势递增、高性能(本地生成)。缺点:时钟回拨问题(机器时间回退导致 ID 重复)。
- Leaf-Segment(美团):数据库号段模式,每次申请一个 ID 段(如 1000 个)缓存在本地,用完再申请。双 buffer 优化:当前 buffer 用到 10% 时异步加载下一个。适合高并发,依赖 DB 但压力小。
- Leaf-Snowflake(美团):ZooKeeper 分配机器 ID,解决时钟回拨(记录上次时间戳,回拨时等待或报错)。
- Redis INCR:
INCR id_generator简单原子,但依赖 Redis 可用性,Redis 故障有风险。 - 数据库多主自增:多个 MySQL 实例设置不同初始值和步长(如实例1从1步长2,实例2从2步长2),扩展性差。
生产场景: 日增 100 万订单,分 16 张表,需全局唯一订单 ID。解决方法: Snowflake 生成 64 位 long 型 ID,趋势递增利于 B+ 树索引。机器 ID 通过 ZooKeeper/配置中心分配。订单 ID 可嵌入业务含义(如 时间戳 + 用户ID后4位 + 序列号)。
Q71:一致性哈希原理?
核心原理: 一致性哈希将整个哈希值空间组织成虚拟圆环(0 ~ 2³²-1),节点和 key 都映射到环上,key 顺时针找到最近的节点。解决普通取模哈希在节点增减时大量数据迁移的问题。
详细解析:
- 普通取模的问题:
hash(key) % N,N 变化时几乎所有 key 需要重新映射。如 N=4 增加到 N=5,75% 的数据需迁移。 - 一致性哈希:
- 节点映射:对节点 IP/名称取 hash 映射到环上。
- key 映射:对 key 取 hash 映射到环上,顺时针找到第一个节点。
- 节点增减:只影响相邻区间的 key,迁移量 = 1/N。
- 数据倾斜问题:节点少时可能集中在环的某一段,导致负载不均。
- 虚拟节点解决倾斜:每个物理节点对应 150 个虚拟节点(
node#1, node#2, ..., node#150),均匀分布在环上。虚拟节点越多分布越均匀,但内存开销增加。 - 应用场景:Redis Cluster(实际用 16384 slot 而非一致性哈希)、Memcached 客户端、负载均衡。
生产场景: 缓存集群从 4 节点扩容到 6 节点。解决方法: 一致性哈希 + 虚拟节点,只有约 1/6 的数据需要迁移(而非 2/3)。对比普通取模的 50%+ 迁移量大幅减少。
Q72:限流算法有哪些?
核心原理: 限流算法控制单位时间内的请求量,保护系统不被打垮。常见有固定窗口、滑动窗口、令牌桶、漏桶四种。
详细解析:
- 固定窗口计数:将时间划分为固定窗口(如每秒),窗口内计数超限则拒绝。缺点:窗口边界突刺(0.9s 和 1.1s 各放 100 请求,1 秒内实际 200 请求)。
- 滑动窗口计数:将窗口细分为小格子(如 1 秒分为 10 个 100ms 格子),滑动统计。比固定窗口平滑,但仍有边界问题。Sentinel 默认用滑动窗口。
- 漏桶算法(Leaky Bucket):请求像水滴入桶,桶以固定速率漏水。桶满则拒绝。匀速输出,保护下游,但不允许突发流量。
- 令牌桶算法(Token Bucket):以固定速率往桶中放令牌,请求获取令牌才处理,桶满则丢弃多余令牌。允许突发流量(桶中令牌可积攒),Guava RateLimiter 实现。
- 对比:令牌桶允许突发(适合前端限流),漏桶匀速输出(适合保护下游)。
- 分布式限流:Redis + Lua 脚本实现原子计数(ZSet 滑动窗口:score=时间戳,
ZREMRANGEBYSCORE清理过期 +ZCARD计数)。Sentinel 集群限流。
生产场景: API 网关限制每个用户每秒最多 100 次请求。解决方法: 令牌桶(Guava RateLimiter 单机 / Redis + Lua 分布式),允许短时突发但平均速率可控。Sentinel 热点参数限流可按用户 ID 维度限流。
Q73:分布式系统的设计模式有哪些?
核心原理: 分布式系统设计模式是解决分布式架构中常见问题的最佳实践。
常见模式:
- Saga 模式:长事务拆分为子事务序列,每个有补偿操作,失败时反向补偿。
- Outbox Pattern(发件箱模式):本地事务中同时写业务数据和 outbox 表,后台读取 outbox 发送到 MQ。解决"数据库提交和消息发送"的原子性问题。
- CQRS(命令查询职责分离):写操作和读操作分离到不同模型/存储。写入 MySQL,读取走 ES/Redis。适合读写比例差异大的场景。
- Event Sourcing(事件溯源):不存储当前状态,而是存储所有状态变更事件。通过重放事件重建状态。适合审计追溯场景。
- Circuit Breaker(熔断器):下游故障时快速失败,避免级联雪崩。Hystrix/Sentinel 实现。
- Bulkhead(舱壁隔离):不同业务用独立线程池/连接池,隔离故障。类似船舱隔水设计。
- Sidecar(边车模式):辅助服务与主服务部署在一起(如 Istio 的 Envoy 代理),处理网络、监控、安全等横切关注点。
- Service Mesh(服务网格):Sidecar 的规模化应用,将服务间通信从业务代码中剥离到基础设施层。
生产场景: 订单系统需同时写 MySQL(事务)和发 MQ 通知。解决方法: Outbox Pattern——订单事务中同时写入 orders 表和 outbox 表(同一本地事务保证原子性),Canal/定时任务读取 outbox 表投递 MQ,投递成功后标记 outbox 已发送。
Q74:服务注册与发现原理?
核心原理: 服务注册与发现是微服务架构的基础设施,服务启动时注册到注册中心,调用方从注册中心获取服务列表并负载均衡调用。
详细解析:
- 注册中心核心功能:
- 服务注册:服务启动时将自己的地址(IP:Port)注册到注册中心。
- 服务发现:调用方从注册中心获取目标服务的实例列表。
- 健康检查:注册中心定期检查服务实例健康状态,剔除不健康实例。
- 通知机制:服务列表变化时推送给订阅者(长轮询/推送)。
- 常见注册中心对比:
- Eureka(AP):去中心化 P2P 架构,节点间复制数据。优先可用性,网络分区时仍可注册发现。已停止维护。
- Nacos(AP/CP 可切换):阿里开源,支持 AP 和 CP 模式切换。同时提供服务注册和配置中心功能。
- Consul(CP):基于 Raft 协议强一致,支持健康检查、KV 存储、多数据中心。
- ZooKeeper(CP):基于 ZAB 协议强一致,临时节点+Watch 机制。偏重一致性,适合强一致场景。
- etcd(CP):基于 Raft 协议,Kubernetes 内部使用。
- 健康检查方式:客户端心跳(Eureka 客户端定期发心跳)、TCP/HTTP 探针(Consul/Nacos 主动探测)。
- 负载均衡:客户端负载均衡(Ribbon/LoadBalancer,从注册中心拉取列表后本地选择),服务端负载均衡(Nginx/API 网关)。
生产场景: 微服务 50 个实例,某个实例宕机需快速剔除。解决方法: Nacos 健康检查 5 秒一次,实例 15 秒无心跳标记为不健康,30 秒后剔除。调用方通过 Nacos 客户端订阅服务变更,实例列表秒级更新。配合熔断器(Sentinel)防止调用不健康实例。
Q75:分布式缓存一致性方案?
核心原理: 分布式环境下多级缓存(本地缓存 + Redis + DB)的一致性是难点,通常通过失效模式 + 事件通知实现最终一致。
常见方案:
- Cache Aside + 延迟双删:先删缓存 → 更新 DB → 延迟 500ms 再删缓存。解决"先删缓存后更新 DB 期间其他线程读到旧数据写回缓存"的问题。
- Canal 监听 binlog:Canal 模拟 MySQL 从节点,监听 binlog 变更,消费后删除/更新各级缓存。业务代码无需感知缓存。
- 消息广播:更新 DB 后发 MQ 广播消息,各节点收到后清除本地缓存。Redis 缓存统一删除。
- Redis Pub/Sub:一个节点更新缓存后通过 Redis 发布消息,其他节点订阅后清除本地缓存。
- 版本号/时间戳:缓存中带版本号,读时比对版本号,旧版本则重新加载。
生产场景: 多级缓存(Caffeine + Redis),一个节点更新数据后其他节点的本地缓存未刷新。解决方法: Redis Pub/Sub 广播缓存失效消息,各节点收到后清除对应 Caffeine 缓存。配合 Canal 监听 binlog 兜底(防止 Pub/Sub 消息丢失)。
Q76:Seata 分布式事务框架的原理?
核心原理: Seata 是阿里开源的分布式事务解决方案,核心是全局事务(TC 协调器)+ 分支事务(RM 资源管理器)+ 事务发起者(TM 事务管理器)。
详细解析:
- 三个角色:
- TC(Transaction Coordinator):独立部署的事务协调器,维护全局事务和分支事务状态,决定提交或回滚。
- TM(Transaction Manager):定义全局事务的边界(begin/commit/rollback),通知 TC。
- RM(Resource Manager):管理分支事务上的资源(数据库),向 TC 注册分支事务,上报状态。
- AT 模式(推荐,低侵入):
- 一阶段:拦截 SQL,执行业务 SQL 前记录 before image,执行后记录 after image,存入 undo_log 表。本地事务提交(不阻塞)。向 TC 上报分支状态。
- 二阶段-提交:TC 通知 RM 提交 → RM 异步删除 undo_log(无需做其他操作,因为一阶段已提交)。
- 二阶段-回滚:TC 通知 RM 回滚 → RM 根据 undo_log 的 before image 反向生成补偿 SQL 还原数据。
- 全局锁:AT 模式在写操作时获取全局锁(存入 lock_table),保证写隔离。读隔离需用
@GlobalLock或SELECT FOR UPDATE。
- TCC 模式:需手动实现 Try/Confirm/Cancel 三个接口。Try 预留资源,Confirm 确认,Cancel 回滚。
- SAGA 模式:长事务编排,每步有正向和补偿操作。适合流程长、涉及外部系统的场景。
生产场景: 订单服务调用库存服务和优惠券服务,需保证三服务数据一致。解决方法: Seata AT 模式,订单服务加 @GlobalTransactional,各服务引入 Seata SDK 自动管理 undo_log。一阶段各服务本地事务提交(不阻塞),任一失败 TC 协调全局回滚。注意 AT 模式有短暂不一致窗口(一阶段已提交但全局未提交)。
Q77:如何设计一个限流系统?
核心原理: 限流系统需支持多维度(IP、用户、接口、全局)、多算法(令牌桶/漏桶/滑动窗口)、多层级(网关层/应用层/分布式)。
设计要点:
- 多级限流架构:
- Nginx 层:
limit_req漏桶限流,接入层第一道防线。 - 网关层:Spring Cloud Gateway / Sentinel 全局限流。
- 应用层:Sentinel 注解
@SentinelResource精确限流(接口/方法级)。 - 分布式层:Redis + Lua 原子脚本实现跨实例限流。
- Nginx 层:
- 限流维度:
- 全局限流:保护系统总容量(如 10000 QPS)。
- 接口限流:保护单个接口(如支付接口 1000 QPS)。
- 用户限流:防止单用户刷接口(如每用户 100 QPS)。
- IP 限流:防止恶意 IP 攻击。
- 算法选择:令牌桶(允许突发,适合前端入口)、漏桶(匀速输出,保护下游)、滑动窗口(精确统计)。
- 熔断降级联动:限流触发后可选择快速失败、排队等待、降级返回默认值。
- 监控告警:实时监控限流触发率,触发率 >10% 告警,可能需扩容。
生产场景: API 网关需对每个租户限流(不同租户不同配额)。解决方法: Sentinel 集群限流 + 热点参数限流,以 tenantId 为参数维度限流。租户配额存储在配置中心(Nacos),动态调整。限流后返回 429 + Retry-After 头。
八、Spring 生态(Q78-Q87)
Q78:Spring IOC 的原理?
核心原理: IOC(Inversion of Control,控制反转)是将对象的创建和依赖注入交给 Spring 容器管理,核心是 BeanFactory 和 ApplicationContext。
详细解析:
- IOC 容器:
- BeanFactory:顶层接口,提供最基础的 Bean 管理(延迟加载,getBean 时才创建)。
- ApplicationContext:扩展接口,提供事件发布、国际化、AOP 等功能(启动时预加载所有单例 Bean)。
- 常用实现:ClassPathXmlApplicationContext(XML)、AnnotationConfigApplicationContext(注解)。
- Bean 注册方式:XML
<bean>、@Component/@Service/@Repository、@Bean方法、@Import。 - 依赖注入方式:构造器注入(推荐,不可变、易测试)、Setter 注入、字段注入(
@Autowired,不推荐)。 - DI 实现原理:
- 资源定位:扫描
@ComponentScan包下的类,读取注解元数据。 - BeanDefinition 注册:将每个 Bean 的元数据(类名、作用域、依赖等)封装为 BeanDefinition,注册到 BeanDefinitionRegistry。
- Bean 实例化:根据 BeanDefinition 通过反射创建对象(调用构造方法)。
- 属性注入:解析
@Autowired/@Resource,通过反射设置属性值。 - 初始化:调用
@PostConstruct、InitializingBean.afterPropertiesSet()、init-method。 - Bean 就绪:放入单例池(singletonObjects)。
- 资源定位:扫描
- 三级缓存解决循环依赖:见 Q81。
生产场景: 需要根据配置动态选择不同实现类(如不同支付渠道)。解决方法: 定义 PaymentService 接口,各渠道实现类加 @Service("alipay") 等,注入时用 @Qualifier("alipay") 或 @Resource(name="alipay")。或用 @Conditional 根据条件注册 Bean。
Q79:Spring AOP 的原理?
核心原理: AOP(Aspect-Oriented Programming,面向切面编程)通过动态代理在不修改源码的情况下增强方法功能。Spring AOP 基于 JDK 动态代理或 CGLIB 实现。
详细解析:
- 核心概念:
- 切面(Aspect):封装横切关注点的类(
@Aspect)。 - 切点(Pointcut):定义哪些方法被增强(
@Pointcut("execution(* com.example.service.*.*(..))"))。 - 通知(Advice):增强的逻辑,分为 Before、After、AfterReturning、AfterThrowing、Around。
- 织入(Weaving):将切面应用到目标对象的过程(Spring 用运行时动态代理)。
- 切面(Aspect):封装横切关注点的类(
- 代理选择:
- 目标类实现接口 → JDK 动态代理(Proxy + InvocationHandler)。
- 目标类无接口 → CGLIB 代理(生成子类)。
spring.aop.proxy-target-class=true→ 强制 CGLIB。
- 通知执行顺序:Around-before → Before → 目标方法 → Around-after → AfterReturning → After → AfterThrowing(异常时)。
- AspectJ vs Spring AOP:Spring AOP 基于运行时动态代理(仅支持方法级增强);AspectJ 基于编译时/加载时字节码织入(支持字段、构造器级增强,性能更好但需编译器支持)。
- 应用场景:日志记录、性能监控、事务管理(
@Transactional)、权限校验、缓存(@Cacheable)。
生产场景: 统一记录所有 Service 方法的调用日志和耗时。解决方法: 定义 @Aspect 切面 + @Around("execution(* com.example.service..*.*(..))") 环绕通知,记录方法名、入参、耗时、异常。注意 AOP 失效问题:同类内部方法调用不经过代理(可通过 AopContext.currentProxy() 获取代理对象解决)。
Q80:Spring Bean 的生命周期?
核心原理: Bean 生命周期分为:实例化 → 属性注入 → 初始化 → 使用 → 销毁五个阶段,每个阶段有扩展点。
详细流程:
- 实例化:通过反射调用构造方法创建对象(
createBeanInstance)。 - 属性注入:
populateBean,解析@Autowired/@Resource注入依赖。 - Aware 接口回调:如果 Bean 实现了
BeanNameAware(获取 Bean 名)、BeanFactoryAware(获取工厂)、ApplicationContextAware(获取上下文),执行回调。 - BeanPostProcessor 前置处理:
postProcessBeforeInitialization,所有 BeanPostProcessor 对 Bean 进行前置增强(如@PostConstruct由 CommonAnnotationBeanPostProcessor 处理)。 - 初始化方法:依次调用
@PostConstruct→InitializingBean.afterPropertiesSet()→init-method。 - BeanPostProcessor 后置处理:
postProcessAfterInitialization,如 AOP 代理在此阶段生成(AbstractAutoProxyCreator)。 - Bean 就绪:放入单例池,可被使用。
- 销毁:容器关闭时依次调用
@PreDestroy→DisposableBean.destroy()→destroy-method。
生产场景: 需要在 Bean 初始化完成后执行数据预热(如加载配置到内存)。解决方法: @PostConstruct 注解方法中执行预热逻辑;或实现 InitializingBean 接口;或 @Bean(initMethod = "init")。注意不要在构造方法中注入依赖(构造方法执行时依赖还未注入)。
Q81:Spring 如何解决循环依赖?
核心原理: Spring 通过三级缓存机制解决单例 Bean 的 setter/字段注入循环依赖,核心是用提前暴露半成品 Bean 的方式打破循环。
详细解析:
- 三级缓存:
- 一级缓存(singletonObjects):存放完全初始化好的 Bean(成品)。
- 二级缓存(earlySingletonObjects):存放提前暴露的半成品 Bean(已实例化但未完成属性注入和初始化)。
- 三级缓存(singletonFactories):存放 Bean 的 ObjectFactory(对象工厂,调用 getObject() 生成半成品 Bean)。
- 解决流程(A 依赖 B,B 依赖 A):
- 创建 A:实例化 A → 将 A 的 ObjectFactory 放入三级缓存。
- 注入 A 的属性 B:从一级缓存找 B(没有)→ 创建 B。
- 实例化 B → 将 B 的 ObjectFactory 放入三级缓存。
- 注入 B 的属性 A:一级缓存找 A(没有)→ 二级缓存找 A(没有)→ 三级缓存找 A 的 ObjectFactory → 调用 getObject() 获取半成品 A → 放入二级缓存,删除三级缓存。
- B 获得半成品 A,完成属性注入和初始化 → B 放入一级缓存。
- A 获得成品 B,完成属性注入和初始化 → A 放入一级缓存。
- 为什么需要三级缓存而非二级:如果 Bean 被 AOP 代理,三级缓存的 ObjectFactory 可以在获取时判断是否需要创建代理对象,保证注入的是代理对象而非原始对象。
- 无法解决的循环依赖:构造器注入循环依赖(实例化阶段就需要依赖,无法提前暴露半成品)。解决:改用 setter 注入或
@Lazy延迟注入。
生产场景: AService 和 BService 互相依赖,启动报 BeanCurrentlyInCreationException。解决方法: 改为 setter 注入或字段注入(Spring 自动解决);或在其中一个加 @Lazy 延迟加载;更好的做法是重构消除循环依赖(提取公共逻辑到 CService)。
Q82:Spring 事务的原理和传播机制?
核心原理: Spring 事务基于 AOP 实现,通过 @Transactional 注解标记方法,代理对象在方法执行前后管理事务的开启、提交、回滚。
详细解析:
- 实现原理:
@Transactional被TransactionInterceptor(AOP 通知)拦截。- 方法执行前:通过
PlatformTransactionManager开启事务(getTransaction),绑定到当前线程(ThreadLocal)。 - 方法执行:业务逻辑在事务内执行。
- 方法正常返回:提交事务(
commit)。 - 方法抛出异常:检查回滚规则(默认只回滚 RuntimeException 和 Error),匹配则回滚(
rollback)。
- 7 种传播机制:
- REQUIRED(默认):有事务加入,无事务新建。
- REQUIRES_NEW:总是新建事务,挂起当前事务。适合日志记录(不受外层事务影响)。
- NESTED:有事务则创建嵌套事务(savepoint),无则新建。外层回滚则嵌套也回滚,嵌套回滚不影响外层。
- SUPPORTS:有事务加入,无事务非事务执行。
- NOT_SUPPORTED:非事务执行,挂起当前事务。
- MANDATORY:必须在事务中,否则抛异常。
- NEVER:必须非事务,否则抛异常。
- 隔离级别:
@Transactional(isolation = Isolation.READ_COMMITTED),默认用数据库隔离级别。
生产场景: 事务方法内调用另一个事务方法,内层事务异常不影响外层。解决方法: 内层方法用 @Transactional(propagation = Propagation.REQUIRES_NEW),新开独立事务。注意:同类内部调用不经过代理,传播机制不生效,需通过注入自身代理或拆分到不同类。
Q83:@Transactional 注解失效的场景有哪些?
核心原理: @Transactional 基于 AOP 动态代理,当代码不经过代理对象调用时,事务注解失效。
常见失效场景:
- 同类内部方法调用:
methodA()直接调用this.methodB(),不经过代理对象 → 事务失效。解决:注入自身代理@Lazy @Autowired private XxxService self; self.methodB()或AopContext.currentProxy()。 - 方法非 public:Spring 事务默认只对 public 方法生效(
@Transactional标注在 private/protected 方法上不报错但不生效)。 - 异常被 catch 吞掉:方法内 try-catch 捕获异常未重新抛出,事务不知道发生异常 → 不回滚。解决:catch 后
throw new RuntimeException(e)或手动TransactionAspectSupport.currentTransactionStatus().setRollbackOnly()。 - 回滚异常类型不匹配:默认只回滚 RuntimeException,checked exception 不回滚。解决:
@Transactional(rollbackFor = Exception.class)。 - 数据库引擎不支持事务:MyISAM 引擎不支持事务。
- Bean 未被 Spring 管理:类未被
@Component/@Service等注解标记,或 new 创建的对象。 - 传播机制配置不当:
@Transactional(propagation = Propagation.NOT_SUPPORTED)显式非事务执行。
生产场景: Service 方法中调用 this.saveLog() 记录日志,saveLog 上的 @Transactional(REQUIRES_NEW) 不生效,日志和业务在同一事务。解决方法: 将 saveLog 拆到独立的 LogService 类中注入调用;或用 AopContext.currentProxy().saveLog()(需开启 @EnableAspectJAutoProxy(exposeProxy = true))。
Q84:Spring Boot 自动配置原理?
核心原理: Spring Boot 自动配置通过 @SpringBootApplication → @EnableAutoConfiguration → spring.factories(或 AutoConfiguration.imports)加载自动配置类,根据条件注解决定是否生效。
详细解析:
- 启动注解链:
@SpringBootApplication=@SpringBootConfiguration+@EnableAutoConfiguration+@ComponentScan。 - @EnableAutoConfiguration:通过
AutoConfigurationImportSelector加载META-INF/spring.factories(Spring Boot 2.x)或META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports(Spring Boot 3.x)中配置的自动配置类。 - 条件注解:
@ConditionalOnClass:类路径存在指定类时生效。@ConditionalOnMissingBean:容器中不存在指定 Bean 时生效(允许用户覆盖默认配置)。@ConditionalOnProperty:配置项满足条件时生效(如spring.datasource.url存在才配置数据源)。@ConditionalOnWebApplication:Web 应用时生效。
- 自动配置示例:
DataSourceAutoConfiguration检测到 classpath 有数据源驱动 + 配置了spring.datasource.url→ 自动创建 DataSource Bean。 - 自定义 Starter:创建 autoconfigure 模块(配置类 +
spring.factories)+ starter 模块(依赖管理),实现开箱即用。
生产场景: 需要根据不同环境(开发/测试/生产)自动切换数据源配置。解决方法: @ConditionalOnProperty(name = "env", havingValue = "prod") 区分环境自动配置;@Profile("prod") 配合 spring.profiles.active 激活。
Q85:Spring Boot 启动流程?
核心原理: Spring Boot 启动通过 SpringApplication.run() 完成,核心步骤:创建 SpringApplication → 准备环境 → 创建 ApplicationContext → 加载 BeanDefinition → refresh 容器 → 执行 Runner。
详细流程:
- 创建 SpringApplication:推断应用类型(Servlet/Reactive/None)、加载
spring.factories中的初始化器和监听器、推断主类。 - run() 方法:
- 获取
SpringApplicationRunListener(启动事件监听)。 - 准备 Environment:加载
application.yml/application.properties、命令行参数、环境变量。 - 创建
ApplicationContext(Servlet 应用创建AnnotationConfigServletWebServerApplicationContext)。 prepareContext:设置 Environment、加载主类的 BeanDefinition。refreshContext:核心——执行 BeanFactoryPostProcessor(扫描注册 BeanDefinition)→ 创建 Bean(实例化、注入、初始化)→ 启动内嵌 Web 服务器(Tomcat/Netty)。- 发布
ApplicationReadyEvent。 - 执行
CommandLineRunner和ApplicationRunner。
- 获取
- 内嵌容器启动:
onRefresh()阶段创建 Tomcat,绑定端口,注册 DispatcherServlet。
生产场景: 需要在启动完成后执行初始化任务(如预热缓存、注册到配置中心)。解决方法: 实现 CommandLineRunner 或 ApplicationRunner 接口,@Order 控制执行顺序。注意:如果初始化失败会阻止应用启动,需 try-catch 降级。
Q86:Spring MVC 的请求处理流程?
核心原理: Spring MVC 通过 DispatcherServlet 统一分发请求,核心流程:前端控制器 → HandlerMapping → HandlerAdapter → 执行 Handler → ViewResolver → 视图渲染。
详细流程:
- 请求到达 DispatcherServlet:客户端发送请求,
DispatcherServlet(前端控制器)拦截所有请求(/)。 - HandlerMapping 查找 Handler:根据 URL 找到对应的
HandlerExecutionChain(Controller 方法 + 拦截器)。@RequestMappingHandlerMapping解析@RequestMapping注解。 - HandlerAdapter 执行 Handler:
RequestMappingHandlerAdapter调用 Controller 方法,处理参数绑定(@RequestParam、@RequestBody、@PathVariable)、数据验证(@Valid)、返回值处理。 - Handler 返回结果:
- 返回 View(传统 JSP):
ViewResolver解析视图名 → 渲染页面。 - 返回 JSON(
@ResponseBody/@RestController):HttpMessageConverter(如MappingJackson2HttpMessageConverter)将对象序列化为 JSON 写入响应。
- 返回 View(传统 JSP):
- 拦截器执行:
preHandle(Handler 执行前)→postHandle(Handler 执行后、视图渲染前)→afterCompletion(视图渲染后)。 - 异常处理:
@ExceptionHandler或HandlerExceptionResolver统一处理异常。
生产场景: 统一异常处理返回标准错误格式。解决方法: @RestControllerAdvice + @ExceptionHandler(Exception.class) 捕获全局异常,统一返回 {code, message, data} 格式。自定义 BusinessException 区分业务异常和系统异常。
Q87:Spring Cloud 核心组件有哪些?
核心原理: Spring Cloud 提供微服务治理的完整解决方案,包括服务注册发现、配置中心、负载均衡、熔断限流、网关、链路追踪等。
详细解析:
| 功能 | Spring Cloud Netflix(一代) | Spring Cloud Alibaba(二代) |
|---|---|---|
| 服务注册发现 | Eureka | Nacos |
| 配置中心 | Spring Cloud Config | Nacos Config |
| 负载均衡 | Ribbon | LoadBalancer |
| 熔断限流 | Hystrix | Sentinel |
| 网关 | Zuul | Spring Cloud Gateway |
| 服务调用 | Feign | OpenFeign |
| 链路追踪 | Sleuth + Zipkin | SkyWalking |
| 分布式事务 | — | Seata |
- Nacos:阿里开源,集服务注册发现 + 配置中心于一体,支持 AP/CP 切换。
- OpenFeign:声明式 HTTP 客户端,
@FeignClient接口定义远程调用,集成负载均衡和熔断。 - Sentinel:流量控制、熔断降级、系统负载保护,支持热点参数限流和实时监控。
- Spring Cloud Gateway:基于 WebFlux 的反应式网关,支持路由、过滤器、限流。
- Seata:分布式事务解决方案(AT/TCC/SAGA/XA)。
生产场景: 微服务从 Spring Cloud Netflix 迁移到 Spring Cloud Alibaba。解决方法: Eureka → Nacos(注册中心 + 配置中心一体)、Hystrix → Sentinel(更丰富的流控规则)、Zuul → Gateway(性能更好)。逐步迁移,双注册过渡。
九、微服务与高可用(Q88-Q95)
Q88:什么是服务雪崩?如何防止?
核心原理: 服务雪崩是分布式系统中一个下游服务故障,通过调用链路逐级向上传导,导致上游服务线程池/连接池耗尽,最终引发整个集群级联故障。
详细解析:
- 雪崩过程:服务 D 响应慢 → 服务 C 调用 D 超时,线程堆积 → 服务 C 线程池耗尽,不可用 → 服务 B 调用 C 失败,线程堆积 → 服务 B 不可用 → 全站不可用。
- 根本原因:同步调用中调用方等待被调用方响应,被调用方慢导致调用方资源(线程、连接)被耗尽。
- 预防措施:
- 熔断(Circuit Breaker):下游错误率超阈值时"断路",快速失败不再调用,定期探测恢复。Sentinel/Hystrix 实现。
- 降级(Fallback):熔断或超时后返回默认值/缓存数据/友好提示,保证核心功能可用。
- 限流(Rate Limiting):控制请求速率,防止过载。令牌桶/漏桶算法。
- 隔离(Bulkhead):不同服务用独立线程池/信号量,故障不扩散。
- 超时设置:所有远程调用设置合理超时(如 Feign 超时 3 秒),避免无限等待。
- 重试限制:重试次数有限(1-3 次),避免重试风暴加剧下游压力。
生产场景: 库存服务 DB 慢查询导致响应 5 秒,订单服务线程池 30 秒耗尽,10 秒后全站雪崩。解决方法: Feign 超时 2 秒 + Sentinel 熔断(慢调用比例 >50% 熔断 10 秒)+ 降级返回缓存库存 + 订单/库存独立线程池隔离。雪崩不再发生,故障隔离在库存服务。
Q89:Sentinel 限流熔断的原理和使用?
核心原理: Sentinel 以流量为切入点,通过滑动窗口统计 QPS/响应时间/异常比例,触发流控/熔断/降级规则。核心是责任链模式 + 滑动窗口。
详细解析:
- 流控规则:
- QPS 限流:单机每秒请求数超阈值拒绝。
- 并发线程数限流:同时执行的线程数超阈值拒绝(更适合慢调用场景)。
- 热点参数限流:按参数值限流(如每个商品 ID 限 100 QPS)。
- 熔断规则:
- 慢调用比例:响应时间 > 阈值的请求比例超设定值时熔断。
- 异常比例:异常请求比例超阈值时熔断。
- 异常数:异常请求数超阈值时熔断。
- 熔断状态机:CLOSED(正常)→ OPEN(熔断,快速失败)→ HALF_OPEN(半开,放行少量请求探测)→ CLOSED/OPEN。
- 滑动窗口:Sentinel 将 1 秒分为 2 个 500ms 窗口,滑动统计。LeapArray 数据结构。
- 规则持久化:默认规则在内存,重启丢失。需配合 Sentinel Dashboard + Nacos 持久化规则。
- 使用方式:
@SentinelResource(value = "queryOrder", blockHandler = "blockHandler", fallback = "fallback")。
生产场景: 接口需要按用户 ID 限流,VIP 用户配额更高。解决方法: Sentinel 热点参数限流 paramIdx = 0(第一个参数 userId),配置不同参数值不同限流阈值(VIP userId → 1000 QPS,普通 → 100 QPS)。规则存储在 Nacos 动态调整。
Q90:API 网关的作用和设计?
核心原理: API 网关是微服务架构的统一入口,负责路由转发、鉴权、限流、日志、协议转换等横切关注点。
核心功能:
- 路由转发:根据 URL/Header 将请求路由到对应微服务。
- 统一鉴权:JWT/Token 校验,鉴权通过后转发,未通过返回 401。
- 限流熔断:网关层限流(Sentinel),保护后端服务。
- 负载均衡:集成 LoadBalancer/Ribbon 负载均衡到多个服务实例。
- 协议转换:HTTP → gRPC、WebSocket 代理。
- 日志监控:记录请求日志、响应时间、状态码,接入监控。
- 灰度发布:按 Header/IP/用户路由到灰度实例。
- 跨域处理:CORS 配置。
技术选型:
- Spring Cloud Gateway:基于 WebFlux 反应式编程,性能高,Spring 生态集成好。
- Nginx:高性能,Lua 扩展灵活,适合接入层。
- Kong/APISIX:基于 OpenResty,插件丰富,适合 API 管理。
生产场景: 前端请求需经过鉴权、限流后转发到后端微服务。解决方法: Spring Cloud Gateway + GlobalFilter(JWT 鉴权)+ Sentinel(限流)+ 路由配置(按 path 转发)。灰度发布通过 Header X-Gray=true 路由到灰度实例。
Q91:配置中心的原理和选型?
核心原理: 配置中心统一管理微服务的配置,支持动态刷新(无需重启),核心是配置存储 + 变更通知机制。
核心功能:
- 配置集中管理:所有服务的配置存储在配置中心,而非本地文件。
- 环境隔离:dev/test/prod 环境配置隔离(namespace/dataId/group)。
- 动态刷新:配置变更后实时推送到客户端,
@RefreshScope热更新。 - 版本管理:配置变更历史,支持回滚。
- 灰度发布:按 IP/标签推送配置到部分实例。
选型对比:
- Nacos Config:阿里开源,集注册中心+配置中心于一体。长轮询推送(29.5 秒超时),配置变更秒级感知。Spring Cloud Alibaba 首选。
- Apollo:携程开源,配置管理功能丰富(权限、审批、灰度)。HTTP 长轮询推送,支持多环境。
- Spring Cloud Config:基于 Git/SVN 存储,Webhook 触发刷新。功能简单,适合小规模。
生产场景: 数据库密码需要动态更新不重启服务。解决方法: Nacos Config + @RefreshScope,配置变更后 Nacos 推送到客户端,@RefreshScope Bean 重新创建。注意 @Value 注解的属性需要 @RefreshScope 才能动态刷新;数据源连接池需手动重建(监听 RefreshEvent 重新创建 DataSource)。
Q92:链路追踪的原理?
核心原理: 链路追踪通过 TraceId(全局唯一)+ SpanId(每段调用唯一)串联一次请求经过的所有服务,核心基于 Google Dapper 论文。
详细解析:
- 核心概念:
- Trace:一次完整的请求链路,由一个全局唯一的 TraceId 标识。
- Span:链路中的一段调用(如一次 RPC、一次 DB 查询),有 SpanId 和 ParentSpanId。
- Annotation:Span 中的事件(cs 客户端发送、sr 服务端接收、ss 服务端发送、cr 客户端接收)。
- 数据传递:TraceId 和 SpanId 通过 HTTP Header(
X-B3-TraceId、X-B3-SpanId)或 MQ 消息属性在服务间传递。 - 采样率:全量采集影响性能,通常采样 1%-10%。异常请求优先采样。
- 技术方案:
- SkyWalking:字节码增强(无侵入),Java Agent 方式自动埋点。支持服务拓扑图、慢调用分析。
- Zipkin:需手动/注解埋点,轻量级。
- Jaeger:CNCF 项目,OpenTracing 标准。
- 存储:链路数据量大,通常存 Elasticsearch(查询)或 Cassandra(写入)。
生产场景: 一次请求经过 5 个微服务,响应慢需定位瓶颈。解决方法: SkyWalking Agent 无侵入接入,查看 Trace 链路中每个 Span 的耗时,发现 DB 查询 Span 占 80% 时间,定位到慢 SQL。
Q93:微服务拆分原则?
核心原理: 微服务拆分遵循高内聚、低耦合原则,按业务领域、团队边界、数据自治进行拆分。
拆分原则:
- 单一职责(SRP):每个服务负责一个业务领域(订单、商品、用户),不交叉。
- 领域驱动设计(DDD):按限界上下文(Bounded Context)拆分,每个上下文对应一个微服务。
- 数据自治:每个服务有独立数据库,不直接访问其他服务的数据库(通过 API/RPC 调用)。
- 团队自治:一个微服务由一个小团队(3-8 人)负责,遵循康威定律。
- 适度拆分:不要过度拆分——服务过多增加运维成本、网络开销、分布式事务复杂度。
- 按生命周期拆分:变化频率不同的模块拆开(如核心交易和营销活动)。
拆分策略:
- 纵向拆分(按业务):用户服务、订单服务、商品服务、支付服务。
- 横向拆分(按层次):API 网关、BFF(Backend for Frontend)、核心服务。
- 读写分离:查询服务和写入服务分离(CQRS),查询走 ES/Redis。
生产场景: 单体应用 50 万行代码,20 人团队开发冲突严重。解决方法: 按 DDD 限界上下文拆分为 8 个微服务(用户、商品、订单、支付、库存、营销、物流、通知),每个服务独立数据库、独立部署、独立团队。先拆边界清晰的(通知、营销),后拆核心交易。
Q94:同城多活和异地多活架构?
核心原理: 多活架构是在多个机房部署服务,任一机房故障时其他机房接管流量,核心挑战是数据同步和冲突解决。
详细解析:
- 同城双活:同城两个机房(延迟 <5ms),双机房同时提供服务,数据库主从跨机房同步。
- 两地三中心:同城双活 + 异地灾备中心。同城故障切异地,有数据延迟。
- 异地多活:异地多个机房同时提供服务,数据双向/多向同步。延迟大(10-50ms),需处理数据冲突。
关键技术:
- 数据同步:MySQL Binlog + Canal/Kafka 跨机房同步;Redis CRDT/跨机房同步。
- 流量调度:DNS/GSLB 按用户地理位置路由到最近机房。
- 冲突解决:时间戳/版本号(最后写入获胜)、业务层冲突检测(如用户在两地同时修改同一订单)。
- 单元化部署:按用户 ID 路由到固定单元(机房),数据按用户分片,单元内自包含(写本地、读本地)。
- 全局协调:分布式 ID(Snowflake)、分布式锁、配置中心需考虑跨机房一致性。
生产场景: 支付系统需机房级容灾,单机房故障不能中断服务。解决方法: 同城双活 + 单元化部署,按用户 ID % 2 路由到机房 A/B,每个机房有完整的用户分片数据。机房故障时 DNS 切换,另一个机房接管全部流量。数据同步延迟 <1 秒。
Q95:灰度发布方案?
核心原理: 灰度发布(金丝雀发布)是逐步将流量从旧版本切换到新版本,降低发布风险。核心是流量分流控制。
常见方案:
- 蓝绿发布:两套环境(蓝=旧,绿=新),流量一次性从蓝切换到绿。回滚快(切回蓝),但需双倍资源。
- 滚动发布:逐步替换实例(如每次替换 25%),旧实例逐个更新为新版本。资源占用少,但回滚慢。
- 金丝雀发布:少量流量(如 5%)先到新版本,观察无异常后逐步扩大(5%→20%→50%→100%)。
- A/B 测试:按用户特征分流(如 10% 用户用新功能),对比指标效果。
流量分流方式:
- 按 Header/Token:请求带
X-Gray=true路由到灰度实例。 - 按用户 ID:
userId % 100 < 5的用户走灰度(5% 灰度)。 - 按权重:网关按权重随机分流(90% 旧版本 + 10% 新版本)。
配合技术:
- 网关层路由:Spring Cloud Gateway 按 Header/权重路由。
- 服务注册中心:Nacos 元数据标记灰度实例。
- 链路追踪:灰度流量标记 TraceId,监控灰度请求的指标。
生产场景: 核心支付服务升级新版本,需小流量验证。解决方法: 金丝雀发布——先发 1 个新版本实例(占总实例 10%),按 Header X-Canary=true 路由内部测试流量,观察 30 分钟无异常后逐步扩量到 30%→50%→100%,最后下线旧版本。异常时网关一键切回 100% 旧版本。
十、场景设计题(Q96-Q108)
Q96:设计一个秒杀系统
核心原理: 秒杀系统的核心挑战是短时间内极高并发、防止超卖、保证系统稳定。核心策略:层层削减流量 + 异步处理 + 原子扣减。
架构设计:
- 前端层:CDN 静态化(商品页静态资源)、倒计时按钮防重复点击、前端限流(每秒最多提交一次)。
- 网关层:Nginx limit_req 限流、Sentinel 热点参数限流(按用户 ID/IP)。
- 应用层:
- 验证码/答题防机器人。
- Redis SET 预热白名单(仅预约用户可参与)。
- Redis Lua 原子扣减库存(查库存 → 判断 → 扣减 → 返回结果,一步原子完成)。
- 异步层:扣减成功后发 MQ(RocketMQ/Kafka),异步落库创建订单,返回"排队中"。
- 数据库层:消费者用
UPDATE stock SET num = num - 1 WHERE id = ? AND num > 0(affected rows=0 则失败)+ 用户+商品唯一索引兜底。 - 超时回滚:RocketMQ 延迟消息 15 分钟后检查订单状态,未支付则回滚 Redis + DB 库存。
- 防刷风控:同 IP/用户频率限制 + 黑名单 + 设备指纹。
生产场景: 100 件商品,10 万用户同时抢购。解决方法: CDN + Nginx 限流拦截 90% 流量 → Sentinel 限流拦截 5% → Redis Lua 扣减(100 个成功 + 99900 失败快速返回)→ 100 个 MQ 异步落库。DB 压力仅 100 次写入。
Q97:设计订单超时自动关闭功能
核心原理: 订单创建后一定时间内未支付需自动关闭并释放资源(库存、优惠券),核心是延迟任务的实现。
方案对比:
| 方案 | 优点 | 缺点 | 适用场景 |
|---|---|---|---|
| 定时任务扫表 | 简单可靠 | 精度低(分钟级)、扫描全表慢 | 小规模 |
| RocketMQ 延迟消息 | 精度高、解耦 | 依赖 MQ、固定级别 | 中大规模 |
| Redis ZSet | 轻量灵活 | 需轮询、宕机丢任务 | 中小规模 |
| Redisson DelayedQueue | 封装优雅 | 单机性能受限 | 中小规模 |
| 时间轮 | 高性能、精确 | 实现复杂 | 大规模 |
推荐方案(RocketMQ 延迟消息):
- 下单时发送延迟 15 分钟的 RocketMQ 消息,消息体包含订单 ID。
- 15 分钟后消费者收到消息,查询订单状态。
- 已支付 → 忽略。未支付 → 取消订单 + 释放库存(Redis DECR + DB 更新)+ 解冻优惠券。
- 消费端幂等(订单可能已被手动取消),用消息 ID + Redis 去重。
生产场景: 日订单量 100 万,订单 30 分钟未支付自动关闭。解决方法: RocketMQ 延迟消息(30 分钟级别),消费者批量处理(每 10 个订单一批释放库存),消费失败重试 3 次后入死信队列人工处理。定时任务兜底(每小时扫描超时未关闭的订单)。
Q98:如何防止重复支付?
核心原理: 重复支付是用户快速点击或网络重试导致同一订单被支付多次,核心是接口幂等性。
防护方案:
- 前端防重:支付按钮点击后禁用 + loading 遮罩,防止用户快速重复点击。
- Token 机制:进入支付页时获取一次性 Token,支付请求携带 Token,后端 Lua 原子校验+删除。
- 数据库唯一索引:支付流水表对
(order_id, pay_type)建唯一索引,重复插入抛异常捕获后返回已有支付结果。 - 状态机校验:
UPDATE order SET status='paid' WHERE id=? AND status='unpaid',影响行数=0 表示已支付。 - 分布式锁:Redis SETNX 锁住 order_id,支付完成后释放。
- 对账机制:定时核对支付流水和订单状态,发现重复支付的自动退款。
生产场景: 用户网络不稳定,支付请求重试 3 次,每次都到达后端。解决方法: 数据库唯一索引 + 状态机双重保障。第一次请求成功扣款 + 状态改为 paid,后续请求 UPDATE 影响行数=0,返回"已支付"而非重复扣款。配合对账系统 T+1 核对。
Q99:设计一个短链接系统
核心原理: 短链接将长 URL 映射为短码(如 t.cn/abc123),核心是短码生成 + 映射存储 + 跳转。
设计方案:
- 短码生成:
- 自增 ID + Base62 编码:DB 自增 ID → 转 62 进制(a-z, A-Z, 0-9),6 位可表示 568 亿个。
- MD5 截取:对长 URL 做 MD5,取前 6-8 位(冲突需重试)。
- 预生成号段:Leaf-Segment 批量申请 ID 段,本地生成。
- 存储:MySQL 存储映射关系(short_code, long_url, expire_time);Redis 缓存热点短码 → 长 URL 映射。
- 跳转流程:用户访问短链接 → 查 Redis 缓存(未命中查 DB)→ 302 重定向到长 URL(302 可统计访问次数,301 浏览器缓存不可统计)。
- 布隆过滤器:防止恶意请求不存在的短码打穿缓存。
- 分库分表:短码数据量大时按 short_code hash 分片。
生产场景: 短链系统日访问量 1 亿,要求 10ms 内跳转。解决方法: Redis 缓存热点短码(TTL 7 天)+ Caffeine 本地缓存(TTL 5 分钟)+ MySQL 兜底。布隆过滤器拦截不存在的短码。302 重定向 + 异步记录访问日志到 Kafka(不影响跳转速度)。
Q100:设计分布式限流系统
核心原理: 分布式限流需要跨多个实例统一计数,核心是集中式计数器 + 原子操作。
设计方案:
- Redis + Lua 滑动窗口:
- 用 Redis ZSet,score = 请求时间戳,member = 请求唯一 ID。
- Lua 脚本:
ZREMRANGEBYSCORE key 0 (now - window)清理过期 →ZCARD key计数 → 超限则拒绝 → 未超限ZADD key now uuid→ 返回。 - 原子操作,多实例共享计数。
- Sentinel 集群限流:选一个 Token Server 作为限流协调器,各客户端向 Token Server 申请 token,有 token 才放行。
- 令牌桶服务:独立服务用令牌桶算法发放 token,各应用从服务获取。
多维度限流:
- 全局 QPS 限流(保护系统总容量)。
- 接口级限流(保护单个接口)。
- 用户级限流(防单用户刷接口)。
- IP 级限流(防恶意攻击)。
生产场景: API 网关需限制全局 10000 QPS + 每用户 100 QPS + 每 IP 500 QPS。解决方法: Redis + Lua 多 key 限流(global:qps、user:{uid}:qps、ip:{ip}:qps),一次 Lua 脚本同时检查三个维度。Sentinel 集群限流作为备选方案。限流后返回 429 + Retry-After 头。
Q101:如何处理热点数据?
核心原理: 热点数据是访问量远高于普通数据的数据(如热搜商品),可能导致缓存单节点过载、缓存击穿。
处理方案:
- 多级缓存:Caffeine 本地缓存(L1)+ Redis(L2),本地缓存兜底减少 Redis 访问。
- 热点探测:实时统计 key 访问频率(HotKey 探测工具/Redis --hotkeys),自动识别热点。
- 副本分散:热点 key 复制多份(
key_1, key_2, ..., key_N),读时随机选副本,分散单 key 压力。 - 本地预计算:秒杀等可预知的热点,提前推送到所有节点的本地缓存。
- CDN 静态化:热点页面静态化到 CDN,减少后端访问。
生产场景: 明星出轨新闻上热搜,对应文章 key QPS 突增到 10 万,单个 Redis 节点 CPU 100%。解决方法: 热点探测识别 → 自动复制 key 到 10 个副本 → Caffeine 本地缓存 TTL 10 秒 → 10 万 QPS 分散到 10 个 Redis key + 各节点本地缓存,单节点压力降到 1/10。
Q102:设计大文件上传和断点续传
核心原理: 大文件分片上传 + 秒传(MD5 校验)+ 断点续传(记录已上传分片)。
设计方案:
- 前端分片:大文件按 5MB 切片,计算文件整体 MD5。
- 秒传检查:上传前将 MD5 发送到后端,如果已存在则直接返回成功(秒传)。
- 分片上传:逐个/并行上传分片,每个分片带
fileMd5 + chunkIndex + totalChunks。 - 断点记录:后端记录已上传的分片索引(Redis/DB),中断后前端查询已上传分片,跳过已传的分片继续上传。
- 合并分片:全部分片上传完成后,后端合并为完整文件(或用 OSS 的分片上传 API 自动合并)。
- 存储:分片存临时目录,合并后存 OSS/MinIO,清理临时分片。
生产场景: 视频网站用户上传 2GB 视频,网络不稳定。解决方法: 前端分 400 个 5MB 分片并行上传(4 并发),每片上传成功记录到 Redis。断网恢复后查询已传 350 片,从 351 片继续。全传完后合并存 OSS。MD5 秒传避免同一文件重复上传。
Q103:设计实时排行榜系统
核心原理: 实时排行榜需要频繁更新分数 + 快速查询排名,Redis ZSet(有序集合)是最优数据结构。
设计方案:
- 数据结构:
ZADD ranking:gameId score userId,score 为积分,member 为用户 ID。 - 更新分数:
ZINCRBY ranking:gameId delta userId,原子增加积分。 - 查询 Top N:
ZREVRANGE ranking:gameId 0 9 WITHSCORES,获取前 10 名(降序)。 - 查询用户排名:
ZREVRANK ranking:gameId userId,获取用户在排行榜中的位置。 - 分页查询:
ZREVRANGE ranking:gameId start end WITHSCORES,分页获取排名列表。 - 定时快照:每小时将排行榜快照存入 MySQL(历史排名记录)。
性能优化:
- 百万用户排行榜,ZSet 操作 O(logN) ≈ 20 次比较,毫秒级响应。
- 分活动/分区域排行榜用不同 key(
ranking:activity:123、ranking:region:beijing)。 - 数据量过大(亿级)可分桶(按分数范围分段)。
生产场景: 游戏实时排行榜,100 万玩家,积分实时更新,需展示 Top 100 和个人排名。解决方法: Redis ZSet + ZINCRBY 实时更新 + ZREVRANGE 获取 Top 100 + ZREVRANK 获取个人排名。本地缓存 Top 100(TTL 5 秒)减少 Redis 访问。每小时快照到 MySQL 存历史排名。
Q104:设计"附近的人"功能
核心原理: 基于地理位置(经纬度)查找附近用户,Redis GEO 是最优方案(底层 GeoHash + ZSet)。
设计方案:
- 数据存储:
GEOADD nearby:users longitude latitude userId,存储用户位置。 - 查询附近:
GEORADIUS nearby:users lon lat radius m WITHCOORD WITHDIST COUNT 20 ASC,查询半径内最近的 20 个用户,按距离升序。 - 位置更新:用户移动时定期更新位置(
GEOADD覆盖旧位置),设置过期时间清理不活跃用户。 - 距离计算:
GEODIST nearby:users user1 user2 km,计算两用户距离。
优化:
- 用户位置变化超过一定距离(如 100 米)才更新,减少 Redis 写入。
- 按城市分 key(
nearby:beijing、nearby:shanghai),避免单 key 过大。 - 结合 ZSet 的 TTL 机制清理离线用户。
生产场景: 社交 App 查找附近 1 公里内的在线用户。解决方法: Redis GEO + 定时更新位置(30 秒一次)+ GEORADIUS 查询 1km 内用户。结果中过滤掉已设置"不可被附近的人看到"的用户。离线用户位置 5 分钟过期自动清理。
Q105:设计一个 IM 消息系统
核心原理: IM 系统核心是消息的实时投递(长连接)+ 消息存储 + 消息有序 + 离线消息。
架构设计:
- 长连接层:WebSocket/Netty 维持客户端长连接,连接服务器(Connection Server)管理在线连接。
- 消息路由:发送者 → Connection Server → 消息路由服务(根据接收者所在 Connection Server 路由)→ 接收者 Connection Server → 推送给接收者。
- 消息存储:
- 写扩散:消息写入发送者和每个接收者的收件箱(适合小群)。
- 读扩散:消息写入会话 timeline,接收者拉取(适合大群)。
- 存储用 HBase/MongoDB(海量消息历史)。
- 消息有序:单会话内消息按序列号(递增 ID)排序,接收方按 seq 去重排序。
- 离线消息:用户上线时拉取未读消息(按 seq 对比:客户端最大 seq → 服务端拉取 > seq 的消息)。
- 消息可靠性:客户端 ACK 机制(收到消息回 ACK,未 ACK 的重发);消息存库后才算发送成功。
- 未读数:Redis 维护每个会话的未读计数。
生产场景: 亿级用户 IM 系统,万人群消息。解决方法: 大群用读扩散(消息写一份到会话 timeline,成员拉取);小群/单聊用写扩散。Netty 长连接 + 消息路由服务 + HBase 存历史消息 + Redis 存未读数 + Kafka 削峰。消息推送走推送中心(兼容 APNs/FCM/厂商通道)。
Q106:设计一个分布式定时任务系统
核心原理: 分布式定时任务需解决:多实例防重复执行、任务分片、失败重试、任务编排。
方案对比:
| 方案 | 特点 | 适用场景 |
|---|---|---|
| XXL-JOB | 中心化调度,可视化,分片广播 | 通用 |
| Elastic-Job | 去中心化(ZK),弹性分片 | 大规模 |
| Quartz Cluster | DB 锁竞争,简单 | 小规模 |
| PowerJob | 支持工作流,MapReduce 分片 | 复杂任务 |
XXL-JOB 核心设计:
- 调度中心:负责任务调度(定时触发、路由策略、日志管理),自身可集群部署。
- 执行器:业务应用嵌入 XXL-JOB Core,注册到调度中心。
- 路由策略:第一个/轮询/随机/分片广播/故障转移/忙碌转移。
- 分片广播:调度中心将任务分 N 片,每个执行器收到
shardIndex/shardTotal,只处理hash(dataKey) % shardTotal == shardIndex的数据。 - 失败重试:任务失败自动重试(可配重试次数),超时告警。
生产场景: 每日凌晨处理 1000 万条用户数据,4 台机器并行。解决方法: XXL-JOB 分片广播,shardTotal=4,每台机器处理 250 万条(userId % 4 == shardIndex)。任务失败自动重试 3 次,超过则告警。任务执行日志存调度中心,可视化查看。
Q107:设计一个秒级监控告警系统
核心原理: 监控系统核心链路:指标采集 → 数据传输 → 时序存储 → 查询展示 → 告警通知。
架构设计:
- 指标采集:
- JVM/系统指标:Micrometer + Prometheus Client(暴露
/actuator/prometheus)。 - 业务指标:代码中埋点(Counter/Gauge/Timer)。
- 日志指标:Filebeat 采集日志 → Logstash 过滤。
- JVM/系统指标:Micrometer + Prometheus Client(暴露
- 数据传输:Prometheus Pull 模式定期拉取(15 秒);或 Push Gateway 中转短生命周期任务指标。
- 时序存储:Prometheus 内置 TSDB(按时间分块存储,默认 15 天);VictoriaMetrics(高性能替代)。
- 查询展示:Grafana 连接 Prometheus,配置 Dashboard(QPS、响应时间、错误率、JVM 内存/GC、CPU/内存)。
- 告警:Prometheus AlertManager 规则触发(如错误率 > 5% 持续 1 分钟)→ 通知(钉钉/飞书/邮件/电话)。
- 链路追踪:SkyWalking 补充全链路视角。
核心指标(RED 原则):
- Rate:请求速率(QPS)。
- Errors:错误率(5xx 比例)。
- Duration:响应时间(P50/P95/P99)。
生产场景: 接口 P99 响应时间突增到 5 秒,需 1 分钟内告警。解决方法: Prometheus 采集 http_server_requests_seconds(P99 > 2 秒持续 1 分钟)→ AlertManager → 钉钉告警 + 自动创建 Jira 工单。Grafana Dashboard 可下钻到慢请求的 SkyWalking Trace 链路定位根因。
Q108:如何设计一个高可用的订单系统?
核心原理: 高可用订单系统需从架构分层、数据可靠、容灾切换、降级兜底四个维度设计,目标是核心链路 99.99% 可用。
架构设计:
- 接入层高可用:
- 多机房 DNS 负载均衡 + Nginx 集群(Keepalived VIP)。
- 网关集群 + Sentinel 限流熔断,防止流量洪峰。
- 应用层高可用:
- 微服务多实例部署(至少 3 副本),无状态化设计。
- 线程池隔离(订单、支付、库存独立线程池),故障不扩散。
- 超时 + 重试 + 熔断 + 降级(Sentinel),慢依赖快速失败。
- 数据层高可用:
- MySQL 主从 + 半同步复制 + MHA/MGR 自动切换。
- Redis Cluster 高可用 + Sentinel 哨兵。
- MQ 多副本 + 同步刷盘(RocketMQ)。
- 容灾设计:
- 同城双活 + 单元化部署,按用户分片路由。
- 数据跨机房同步(Canal + Kafka)。
- 故障自动切换(DNS/网关层切换流量)。
- 数据可靠性:
- 订单创建走本地事务 + 本地消息表/MQ 事务消息,保证不丢。
- 对账系统 T+1 核对订单、支付、库存三方数据。
- 关键操作记录操作日志(审计追溯)。
- 降级兜底:
- 库存查询降级返回缓存数据。
- 支付降级走异步队列,先接受后处理。
- 非核心功能(推荐、评论)可降级关闭。
生产场景: 大促期间订单系统需承受 10 万 QPS,99.99% 可用。解决方法: 网关限流 5 万 QPS(超出排队)+ 4 机房单元化部署(每机房 2.5 万 QPS)+ Redis Cluster + MySQL 分库分表(16 库 × 4 表)+ RocketMQ 异步落库 + 全链路监控告警。实测可用性 99.995%,大促零故障。
总结: 本文整理了 108 道 Java 高频面试题,覆盖 Java 基础、并发编程、JVM、MySQL、Redis、消息队列、分布式系统、Spring 生态、微服务架构、场景设计等 10 大方向。每题包含核心原理、详细解析、生产场景与解决方法,适合中高级 Java 工程师面试备考。
参考来源: CSDN、掘金、阿里云开发者社区、腾讯云开发者社区、51CTO、JavaGuide、RocketMQ 官方文档等技术平台公开发布的真实面试题整理。