Java面试核心知识点
学习策略:费曼学习法 + 知识骨架 + 主动提取 + 即学即用 目标岗位:3-5年 Java 开发 · 跨境电商 SaaS ERP 方向
📚 文档导航
本文档整合了10个专项主题文档,涵盖Java后端开发的核心知识点:
并发编程基础 - ThreadLocal、线程池、AQS锁机制
Spring框架核心 - IoC/AOP、Bean生命周期、事务管理
数据库与SQL优化 - 慢SQL排查、MySQL调优五维体系
缓存与一致性 - 缓存一致性方案
分布式系统 - 微服务数据一致性
设计模式实践 - 四大模式业务落地
🗺️ 知识体系全景
Java面试知识体系
│
├── 基础内功
│ ├── Java集合(HashMap / ConcurrentHashMap)
│ ├── JVM(内存模型 / GC / 排查)
│ ├── 并发编程
│ │ ├── ThreadLocal ← 文档01
│ │ ├── 线程池 ← 文档09
│ │ ├── 锁机制
│ │ └── CAS / AQS ← 文档06
│ └── ...
│
├── 框架生态
│ ├── Spring IOC / AOP / 事务 ← 文档08
│ │ └── @Transactional失效 ← 文档05
│ ├── Spring Boot 自动装配
│ └── Spring Cloud 微服务
│
├── 数据与中间件
│ ├── MySQL
│ │ ├── 索引 / 事务 / SQL优化
│ │ ├── 慢SQL排查 ← 文档03
│ │ └── 调优五维体系 ← 文档04
│ ├── Redis
│ │ ├── 缓存三大问题(穿透/击穿/雪崩)
│ │ └── 缓存一致性 ← 文档02
│ └── MQ(异步 / 削峰 / 解耦)
│
├── 分布式系统
│ ├── 分布式锁
│ ├── 分布式事务 ← 文档07
│ └── SaaS多租户
│
└── 业务领域
├── 跨境电商ERP
└── 第三方接口开发一、并发编程基础
1.1 ThreadLocal 深度解析
难度定位:大厂后端分水岭题——普通后端知道ThreadLocal,大厂后端知道线程池下的坑
知识骨架
ThreadLocal
├── 本质:线程私有存储,每个线程一份副本
│
├── 底层结构
│ ├── Thread 对象中持有 ThreadLocalMap
│ ├── Entry extends WeakReference<ThreadLocal<?>> ← key是弱引用
│ └── Entry.value 是强引用 ← value是强引用
│
├── 核心问题
│ ├── 问题1:内存泄露
│ │ 根因:key被GC回收 → value强引用链仍在 → 无法释放
│ │ 触发条件:线程池中线程长期存活
│ │
│ ├── 问题2:线程复用数据串读
│ │ 根因:线程池复用线程 → 未remove → 下一个请求读到上一个请求的上下文
│ │ 场景:用户A的请求 → 线程1处理 → 未清理 → 用户B的请求 → 线程1处理 → 读到A的数据
│ │
│ └── 问题3:上下文传递失效
│ InheritableThreadLocal → 仅适用于new Thread(),线程池下失效
│
├── 解决方案
│ ├── 内存泄露 → remove() + 自定义跟踪/切面兜底
│ ├── 数据串读 → remove()(必须!但不够!)
│ └── 上下文传递
│ ├── TransmittableThreadLocal(TTL)← 线程池场景首选
│ │ 原理:装饰器模式,任务执行前快照、执行后恢复
│ └── Reactor Context ← 响应式编程场景
│
└── 生产落地
├── 自定义 ThreadLocal 跟踪 remove 调用
├── TTL 工具做清理切面
└── 规范:try-finally 中必须 remove核心问题详解
1. 内存泄露
想象你租了一个储物柜(Entry),柜门上贴了张便利贴写"这是3号柜"(key=弱引用),柜子里放了一箱金砖(value=强引用)。
便利贴被风吹掉(key被GC回收,因为弱引用)
柜门上没字了 → 找不到这个柜子了(key=null)
但金砖还在柜子里!而且柜子被长期占用(线程池线程不死)
金砖永远拿不出来 → 内存泄露
如果线程用完就死(普通线程),线程死了 → ThreadLocalMap没了 → 金砖跟着没了,不会泄露。但线程池里的线程长期存活,柜子永远不还。
2. 线程复用数据串读
想象酒店房间(线程)被不同客人(请求)轮流住:
客人A入住,在抽屉里放了身份证(set用户上下文)
客人A退房,但没清抽屉(没调remove)
客人B入住同一个房间(线程池复用线程)
客人B打开抽屉 → 看到客人A的身份证!
后果:用户B的操作用了用户A的身份 → 数据串读
3. 上下文传递
InheritableThreadLocal为什么在线程池下失效?
父亲(主线程)创建儿子(子线程)时,把自己的储物柜内容复制一份给儿子。这就是InheritableThreadLocal。
但线程池里,线程是提前创建好的,不是每次请求新创建的!就像酒店房间是提前装修好的,不是每个新客人来才装修。
TransmittableThreadLocal(TTL)
TTL的做法像"酒店管家服务":
客人入住前,管家把房间按客人的喜好重新布置(快照当前线程上下文,设置到工作线程)
客人住完,管家把房间恢复成之前的样子(恢复工作线程原来的上下文)
这就是装饰器模式:不改变线程池本身,而是在任务外面套一层"上下文快照→执行→恢复"的逻辑。
生产落地建议
面试回答模板
Q:ThreadLocal有什么问题?怎么解决?
ThreadLocal在线程池场景下有三大问题:
第一,内存泄露。ThreadLocal的Entry中key是弱引用,value是强引用。当ThreadLocal对象被回收后,key变成null,但value因为强引用链仍然存活。线程池中线程长期不死,这些"幽灵Entry"的value就永远无法释放。解决方案是在try-finally中调用remove(),生产环境可以加自定义跟踪或切面兜底。
第二,数据串读。线程池复用线程时,如果上一个请求没调remove,下一个请求就会读到上一个请求的上下文数据。remove是必要但不充分的。
第三,上下文传递失效。InheritableThreadLocal只在创建新线程时拷贝父线程上下文,线程池中线程是提前创建的,所以会失效。阿里开源的TransmittableThreadLocal通过装饰器模式,在任务执行前后快照和恢复上下文,是线程池场景的标准方案。
1.2 线程池核心线程数配置
难度定位:大厂后端分水岭题——初级开发背公式N+1/2N,有实战经验的候选人谈资源约束+压测调优
知识骨架
线程池核心线程数配置
│
├── ❌ 错误回答:直接背公式
│ CPU密集=N+1,IO密集=2N → 只讲公式不谈落地 → 面试翻车
│
├── ✅ 正确思路:公式只是起点,落地靠四步
│ ① 公式估算初始值
│ ② 评估下游资源上限(DB连接池/Redis连接/第三方限流)
│ ③ 压测验证(CPU利用率/队列积压/RT/拒绝数)
│ ④ 持续监控调优
│
├── 两套估算公式(理论参考)
│ ├── CPU密集:N+1(N=CPU核心数)
│ │ 纯计算/加密/解析,极少阻塞
│ │ +1预留应对短暂阻塞
│ │
│ └── IO密集:N × (1 + IO等待时间/CPU计算时间)
│ 经验值:2N(仅起步参考)
│ DB查询/RPC/文件读写,大量时间阻塞等待
│
├── 线上四大踩坑
│ ├── 1. 无限放大最大线程数 → 争抢有限连接 → 上下文切换雪崩
│ ├── 2. 使用无界队列 → 任务无限堆积 → OOM
│ ├── 3. 所有业务共用线程池 → 慢任务阻塞关键业务
│ └── 4. AbortPolicy直接丢任务 → 业务数据丢失
│
├── JDK线程池执行顺序(重要!)
│ 核心线程满 → 任务入队列 → 队列满 → 才创建非核心线程
│ ⚠️ IO密集业务:CPU空闲但任务在排队,非核心线程迟迟不创建
│
└── 进阶:动态线程池
运行时调整核心线程/最大线程/队列长度
配合监控告警,应对大促/下游抖动两套估算公式
CPU密集任务
核心线程数 ≈ CPU核心数 + 1比喻:8个厨师(8核CPU),8口锅(8个线程),每个厨师一口锅刚好满负荷。+1口锅是备用的——万一某个厨师临时去洗手(短暂阻塞),备用锅不闲着。
为什么不能更多?9个厨师8口锅→有人闲着;8个厨师10口锅→锅比厨师多,厨师要频繁换锅(上下文切换),反而更慢。
IO密集任务
精准公式:线程数 = CPU核心数 × (1 + 平均IO等待时间 / CPU计算时间)
经验值:2N(起步参考)比喻:8个快递员(8核CPU),每个快递员送一趟要等客户开门2分钟(IO等待),路上走1分钟(CPU计算)。
按公式:8 × (1 + 2/1) = 24个快递员
为什么可以多于8个?因为快递员等开门时CPU是空闲的,可以派更多快递员同时等不同的门
但注意:24个快递员同时等门,如果只有10扇门(数据库连接池=10),14个快递员白等——线程数不能超过下游资源上限!
线上落地四步流程
步骤1:公式估算初始值
↓
步骤2:评估下游资源上限 ← 最关键的一步!
↓
步骤3:压测验证
↓
步骤4:持续监控调优步骤2:评估下游资源上限
比喻:你招了100个快递员,但小区只有10个门禁卡——90个快递员在门口干等。
步骤3:压测验证
观察4个关键指标:
JDK线程池执行顺序
提交任务
│
├── 1. 核心线程未满?→ 创建核心线程执行
│
├── 2. 核心线程已满?→ 任务放入队列
│
├── 3. 队列已满?→ 创建非核心线程
│
└── 4. 非核心线程也满了?→ 执行拒绝策略IO密集业务的坑:
假设核心线程=8,队列容量=1000,最大线程=50:
8个核心线程全在等IO(CPU空闲!)
新任务进队列排队(队列很大,不容易满)
队列没满 → 不创建非核心线程!
结果:CPU空闲,但任务在排队,吞吐上不去
比喻:8个快递员全在等开门,新快递单堆在桌上(队列),但老板说"桌上没堆满就不招临时工"——明明可以多招人干活,但规则不允许!
线上四大踩坑
坑1:无限放大最大线程数
// ❌ 错误:最大线程设500,但数据库连接池只有20
new ThreadPoolExecutor(8, 500, 60, TimeUnit.SECONDS, new LinkedBlockingQueue<>(100));
// 结果:500线程抢20个连接 → 480线程阻塞 → CPU上下文切换雪崩坑2:使用无界队列
// ❌ 错误:无界队列
new ThreadPoolExecutor(8, 50, 60, TimeUnit.SECONDS, new LinkedBlockingQueue<>());
// 结果:流量突增 → 任务无限堆积 → 内存撑爆 → OOM坑3:所有业务共用线程池
// ❌ 错误:慢任务和快任务共用一个池
executor.submit(() -> syncAmazonOrders()); // 慢:调Amazon API,3秒
executor.submit(() -> sendNotification()); // 快:发通知,10ms
// 慢任务占满线程 → 快任务排队等 → 通知延迟3秒!比喻:急诊和体检共用一个候诊室——体检的人占满座位,急诊病人排不上号!
坑4:拒绝策略随意用AbortPolicy
// ❌ 错误:AbortPolicy直接丢弃任务
new ThreadPoolExecutor.AbortPolicy()
// ✅ 推荐:CallerRunsPolicy——队列满了让提交者自己执行
new ThreadPoolExecutor.CallerRunsPolicy()
// 提交任务的线程自己跑 → 自动降速 → 保护系统不崩溃四种拒绝策略对比:
面试回答模板
Q:线程池核心线程数设多少?如何计算?
不能只背公式,线程池配置是"理论估算+资源约束+压测验证+持续监控"的完整流程。
第一步,用公式做初始估算:CPU密集任务用N+1,N是CPU核心数;IO密集任务用N×(1+IO等待时间/CPU计算时间),经验值2N起步。
第二步,评估下游资源上限,这是最关键的。线程数不能超过下游能扛的上限——数据库连接池20个,线程池最大线程就不应该超过20;第三方接口限流10 QPS,线程池吞吐就不能超过10。线程再多,下游扛不住,线程全阻塞,CPU被上下文切换占满,反而雪崩。
第三步,压测验证,观察CPU利用率、队列积压、接口RT、拒绝任务数。
第四步,持续监控调优,根据线上指标动态调整。
另外要注意几个线上常见坑:必须用有界队列防止OOM;业务拆分多线程池隔离;拒绝策略优先用CallerRunsPolicy降速保护。
1.3 AQS——Java锁的底牌
难度定位:大厂后端分水岭题——普通后端死记各种锁特性,大厂后端抓住AQS一张底牌推导所有锁
知识骨架
Java锁体系
│
├── 两套体系(不要混淆!)
│ ├── synchronized → JVM底层C++实现(对象头mark-word + Monitor)
│ └── JUC Lock系列 → Java代码实现,全部基于AQS
│
├── AQS(AbstractQueuedSynchronizer)—— JUC锁的底层基石
│ ├── 核心三件东西
│ │ ├── 1. state(int同步状态变量)—— 灵魂,不同锁state含义不同
│ │ ├── 2. CLH双向阻塞队列(同步队列)—— 排队等锁
│ │ └── 3. Condition条件队列 —— await/signal
│ │
│ ├── 模板方法模式
│ │ ├── AQS提供模板:acquire / release(排队+阻塞逻辑)
│ │ └── 子类实现钩子:tryAcquire / tryRelease(修改state逻辑)
│ │
│ └── 两种模式
│ ├── 独占模式:只有一个线程能获取(ReentrantLock写锁)
│ └── 共享模式:多个线程可同时获取(读锁/CountDownLatch/Semaphore)
│
├── 各锁如何基于AQS实现
│ ├── ReentrantLock:state=重入次数,独占模式
│ ├── ReentrantReadWriteLock:state高位=读锁计数,低位=写锁计数
│ ├── CountDownLatch:state=计数器,共享模式,减到0唤醒
│ └── Semaphore:state=许可数量,共享模式
│
└── 核心认知:AQS本身不是锁,只是框架。锁是AQS的子类。AQS三大核心组件
① state——灵魂变量
state是volatile int,保证可见性;修改state用CAS,保证原子性。
② CLH双向阻塞队列
CLH双向链表队列
┌───┐ ┌───┐ ┌───┐ ┌───┐
│头 │←→│Node│←→│Node│←→│Node│
│dummy│ │线程A│ │线程B│ │线程C│
└───┘ └───┘ └───┘ └───┘
已获锁 等待 等待 等待
↑前驱释放锁后唤醒这个③ Condition条件队列
同步队列(等锁) Condition队列(等条件)
┌───┐ ┌───┐ ┌───┐ ┌───┐
│头 │←→│B │ │D │→│E │
└───┘ └───┘ └───┘ └───┘
线程A持有锁,调用condition.await()
→ A释放锁 → A从同步队列移到Condition队列
→ 唤醒同步队列下一个线程B
其他线程调用condition.signal()
→ D从Condition队列移回同步队列 → D重新排队抢锁与synchronized的wait/notify对比:
模板方法模式
AQS(总部模板) 子类(各店特色)
┌─────────────────────┐ ┌─────────────────────┐
│ acquire() { │ │ tryAcquire() { │
│ if (!tryAcquire()) │ ──调用──→│ // 自己实现抢锁逻辑 │
│ 入队阻塞; │ │ // 修改state │
│ } │ │ } │
│ │ │ │
│ release() { │ │ tryRelease() { │
│ if (tryRelease()) │ ──调用──→│ // 自己实现释放逻辑 │
│ 唤醒后继节点; │ │ // 修改state │
│ } │ │ } │
└─────────────────────┘ └─────────────────────┘
排队、阻塞、唤醒逻辑 抢锁、释放逻辑
(AQS统一搞定) (每个锁自己定义)各锁详解
ReentrantLock——可重入独占锁
state=重入次数,独占模式。比喻:一间会议室,同一时刻只能一个人用(独占),但如果已经在里面了可以再推门进来(可重入),不用重新抢锁。
ReentrantReadWriteLock——读写锁
state高16位=读锁计数,低16位=写锁计数。读锁共享(多人可同时读),写锁独占(只有一人能写)。写锁可以降级为读锁,但读锁不能升级为写锁。
比喻:一间图书馆——多个人可以同时看书(读锁共享),但只有一个人能修改书架(写锁独占)。改完书可以直接开始看书(写锁降级读锁),但看书看到一半不能突然去改书(读锁不能升级写锁)。
CountDownLatch——倒计时门闩
state=计数器,共享模式,减到0唤醒所有等待线程。一次性使用,用完即弃。
比喻:运动会开幕式——5支队伍没到齐不开幕。每来一支队伍countDown(计数器-1),归零瞬间开幕式开始。只能减不能重置。
Semaphore——信号量/许可
state=许可数量,共享模式。acquire()拿许可(许可-1),release()归还许可(许可+1)。常用于限流。
比喻:停车场有3个车位(许可=3)。车进来acquire拿车位(车位-1),车走release还车位(车位+1)。车位满了(许可=0)后来的车排队等。
面试回答模板
Q:Java各种锁怎么记?/ AQS是什么?
不要死记各种锁的零散特性,抓住AQS这张底牌就能推导出来。
AQS是JUC锁的底层基石,核心就三件东西:state同步状态变量、CLH双向阻塞队列、Condition条件队列。state是AQS的灵魂,不同锁state含义不同。AQS采用模板方法模式,提供acquire/release模板方法处理排队阻塞逻辑,子类重写tryAcquire/tryRelease定义修改state的逻辑。
基于这三件东西,各个锁就是state含义+模式的不同组合:ReentrantLock是state计重入+独占模式;ReadWriteLock是state分高低位+读写分别共享独占;CountDownLatch是state做倒计时+共享模式;Semaphore是state做许可+共享模式。
另外要分清两套体系:synchronized是JVM底层C++实现,和AQS无关;JUC下的Lock系列全部基于AQS。
二、Spring框架核心
2.1 Spring高频面试核心
难度定位:Spring面试必考——IoC/AOP/Bean生命周期/循环依赖/事务连环追问
知识骨架
Spring面试三大模块(连环追问)
│
├── 模块1:IoC容器
│ ├── IoC vs DI(思想 vs 实现)
│ ├── BeanFactory vs ApplicationContext(懒加载 vs 预加载+扩展)
│ ├── Bean生命周期(5大阶段)
│ └── 循环依赖(三级缓存)
│ ├── 一级:singletonObjects(完整Bean)
│ ├── 二级:earlySingletonObjects(半成品Bean)
│ └── 三级:singletonFactories(Bean工厂,支持AOP代理)
│ ⚠️ 仅对单例+setter注入生效,构造器注入无法解决
│
├── 模块2:AOP面向切面
│ ├── 两种代理方式
│ │ ├── JDK动态代理(有接口,基于接口)
│ │ └── CGLIB代理(无接口,继承生成子类)
│ │ Spring5后默认优先CGLIB
│ └── AOP是事务的底层 → 绕过代理=事务失效
│
└── 模块3:Spring事务
├── 底层原理:AOP动态代理
├── 传播行为(7种)
├── 隔离级别(沿用数据库标准)
└── 失效场景(详见文档05)IoC vs DI
IoC(控制反转)
比喻:以前你自己去菜市场买菜、自己做饭、自己洗碗——一切自己干(自己new对象)。现在:你告诉餐厅"我要一份宫保鸡丁",餐厅帮你买菜、做饭、端上来——控制权从你转移到了餐厅(Spring容器)。IoC是思想。
DI(依赖注入)
比喻:餐厅怎么帮你买菜的?可能是外卖小哥送来(构造器注入),可能是供应商定期配送(setter注入),可能是自动从仓库取(字段注入@Autowired)。DI是实现手段。
BeanFactory vs ApplicationContext
Bean生命周期——5大阶段
1. 实例化(Instantiation)
→ HR发offer,人来报到了(new出对象)
2. 属性填充(PopulateBean)
→ 分配工位、配电脑、认识同事(注入依赖@Autowired)
3. 初始化(Initialization)
→ 入职培训流程:
① BeanPostProcessor前置处理 → 培训前准备
② @PostConstruct → 入职宣誓
③ InitializingBean.afterPropertiesSet() → 签合同
④ 自定义init-method → 领工牌
⑤ BeanPostProcessor后置处理 → 培训后发工牌
⭐ AOP代理在第⑤步生成!
4. 使用中(In Use)
→ 正式上岗,放在容器里供其他Bean使用
5. 销毁(Destruction)
→ 离职流程:
① @PreDestroy → 交接工作
② DisposableBean.destroy() → 归还设备
③ 自定义destroy-method → 正式离职面试追问:AOP代理在哪一步生成?
在BeanPostProcessor的后置处理阶段(第⑤步)。代理对象在这里替换了原始对象,所以容器里存的是代理对象而非原始对象。
循环依赖——三级缓存
什么是循环依赖?
@Service
public class A {
@Autowired
private B b; // A依赖B
}
@Service
public class B {
@Autowired
private A a; // B依赖A
}
// A创建→需要B→B创建→需要A→A还没创建完→死循环!比喻:两个人互相等对方先出门——A说"你先出我就出",B说"你先出我就出"——谁也出不去。
三级缓存解决方案
比喻:两个人互相等对方,解决方案是——A先出门但留个便签"我在外面等你"(提前暴露自己),B看到便签找到A,B出门后A也完成出门。
三级缓存:
┌──────────────────────────────────────────────────────┐
│ 一级缓存 singletonObjects │
│ 存放:完整初始化完成的Bean(毕业的员工) │
├──────────────────────────────────────────────────────┤
│ 二级缓存 earlySingletonObjects │
│ 存放:实例化完成但未填充属性的半成品Bean(刚报到没配电脑)│
├──────────────────────────────────────────────────────┤
│ 三级缓存 singletonFactories │
│ 存放:Bean工厂对象(能生成Bean的"模具") │
│ ⭐ 核心作用:支持AOP代理场景 │
└──────────────────────────────────────────────────────┘完整流程(A依赖B,B依赖A)
1. 创建A:
① 实例化A(new A(),半成品)
② 将A的工厂放入三级缓存(提前暴露!)
③ 填充A的属性 → 发现需要B
2. 创建B:
① 实例化B(new B(),半成品)
② 将B的工厂放入三级缓存
③ 填充B的属性 → 发现需要A
④ 从三级缓存获取A的工厂 → 调用工厂生成A
⭐ 如果A需要AOP代理 → 工厂返回代理对象
⭐ 如果A不需要代理 → 工厂返回原始对象
⑤ 将A放入二级缓存(半成品)
⑥ B的属性填充完成 → B初始化完成 → B放入一级缓存
3. 回到A:
④ A的属性B已就绪 → A填充完成
⑤ A初始化完成 → A放入一级缓存
⑥ 清除A在二级缓存的记录为什么需要三级缓存?二级不够吗?
如果不需要AOP代理,二级缓存理论够用。但如果A需要AOP代理:
没有三级缓存:B引用的是A的原始对象,但容器里最终存的是A的代理对象 → B引用的和容器里的不是同一个对象!
有三级缓存:B通过工厂获取A时,工厂判断是否需要代理,需要就返回代理对象 → B引用的就是代理对象,和容器里的一致 ✅
三级缓存的前提条件
AOP两种代理方式
@Transactional底层原理
核心一句话:@Transactional基于AOP动态代理实现,只有通过代理对象调用public方法事务才生效。
所有失效场景的根源:绕过代理(内部调用/非public/没被Spring管理)或规则错误(异常被吞/类型错/传播错/引擎不支持)。
事务传播行为与隔离级别
传播行为(7种)
隔离级别
面试回答模板
Q:Spring的IoC和AOP是什么?
IoC是控制反转,将对象创建和依赖管理交给Spring容器,开发者不再手动new对象。DI依赖注入是IoC的实现手段,容器自动装配Bean之间的依赖。IoC是思想,DI是实现方式。
AOP是面向切面编程,在不修改原有代码基础上增强功能,比如日志、事务、监控。底层通过动态代理实现,有两种方式:JDK动态代理基于接口,CGLIB基于继承。Spring5之后默认优先CGLIB。
Q:Bean的生命周期?
五大阶段:实例化→属性填充→初始化→使用→销毁。初始化阶段依次执行BeanPostProcessor前置处理、@PostConstruct、InitializingBean.afterPropertiesSet、自定义init-method、BeanPostProcessor后置处理。AOP代理在BeanPostProcessor后置处理阶段生成。
Q:Spring如何解决循环依赖?
通过三级缓存解决,但仅对单例Bean的setter注入生效。一级缓存singletonObjects存完整Bean,二级缓存earlySingletonObjects存半成品Bean,三级缓存singletonFactories存Bean工厂。核心流程是:A实例化后提前将工厂放入三级缓存,B创建时需要A,从三级缓存获取A的工厂生成A的引用,A放入二级缓存,B完成创建后回到A继续完成。三级缓存的关键作用是支持AOP代理——如果A需要代理,工厂会返回代理对象,保证B引用的和容器里存的是同一个代理对象。
2.2 @Transactional失效七种场景
难度定位:高频面试题+线上高频踩坑
知识骨架
@Transactional 失效
│
├── 底层原理:Spring事务基于AOP动态代理
│ 只有通过代理对象调用 → 事务逻辑才执行
│ 绕过代理对象调用 → 事务直接失效
│ 所有7种失效场景 → 本质都是"绕开AOP代理"或"规则配置错误"
│
├── 七种失效场景
│ ├── 1. 方法不是public → AOP不代理非public方法
│ ├── 2. 同类内部调用(this调用)→ this是原始对象,不走代理
│ ├── 3. 异常被try-catch吞掉 → Spring感知不到异常,不回滚
│ ├── 4. 异常类型不对 → 默认只回滚RuntimeException,checked Exception不回滚
│ ├── 5. 类没被Spring管理 → new出来的对象,不生成AOP代理
│ ├── 6. 传播行为配置错误 → SUPPORTS/NOT_SUPPORTED/NEVER不开启事务
│ └── 7. 数据库不支持事务 → MyISAM引擎,底层无法回滚
│
└── 额外注意:长事务不是失效,是性能故障
事务内写RPC/HTTP调用 → 行锁长时间持有 → 锁等待七种失效速记口诀
非public 内部调 异常吞 类型错
没托管 传播错 引擎差底层原理
比喻:你请了一个保镖(AOP代理)保护你:
别人通过前台找你 → 前台叫保镖跟着你 → 保镖保护你 ✅
你自己在房间里自己打自己 → 保镖在门外看不见 → 保护不了 ❌
所有7种失效,本质上都是:保镖被绕过了,或者保镖的规则配错了。
七种失效场景详解
场景1:方法不是public
@Service
public class OrderService {
@Transactional
private void createOrder() { // ❌ private!事务完全无效
orderMapper.insert(order);
}
}比喻:你只在"公开活动"(public方法)上安排了保镖,"私人聚会"(private方法)保镖不参加。
坑点:写在private上代码不报错!编译通过、运行不报错,但事务完全不起作用——最隐蔽的失效。
场景2:同类内部调用(this调用)
@Service
public class OrderService {
public void createOrder() {
this.deductInventory(); // ❌ this是原始对象,不走代理!
}
@Transactional
public void deductInventory() { ... }
}比喻:你在自己房间里自己打自己——保镖在门外看不见,保护不了你。
三种解决方案:
// 方案1:注入自己(推荐)
@Service
public class OrderService {
@Autowired @Lazy
private OrderService self;
public void createOrder() {
self.deductInventory(); // ✅ 通过代理对象调用
}
}
// 方案2:拆分到其他Service
@Service
public class OrderService {
@Autowired
private InventoryService inventoryService;
public void createOrder() {
inventoryService.deduct(); // ✅ 不同类,走代理
}
}场景3:异常被try-catch吞掉
@Transactional
public void createOrder() {
try {
orderMapper.insert(order);
inventoryMapper.deduct(order);
} catch (Exception e) {
log.error("下单失败", e); // ❌ 异常被吞了!Spring感知不到
}
}比喻:你摔倒了(异常),但自己悄悄爬起来没告诉保镖——保镖以为一切正常,不会帮你处理。
解决方案:
// 方案1:catch后手动标记回滚
@Transactional
public void createOrder() {
try {
orderMapper.insert(order);
inventoryMapper.deduct(order);
} catch (Exception e) {
log.error("下单失败", e);
TransactionAspectSupport.currentTransactionStatus().setRollbackOnly();
}
}场景4:异常类型不对
@Transactional
public void createOrder() throws Exception {
orderMapper.insert(order);
throw new Exception("业务异常"); // ❌ checked Exception,默认不回滚!
}
@Transactional(rollbackFor = Exception.class) // ✅ 所有异常都回滚
public void createOrder() throws Exception { ... }比喻:保镖的合同上写着"只管紧急情况(RuntimeException),普通问题(checked Exception)不管"。
最佳实践:所有@Transactional都加rollbackFor = Exception.class。
场景5:类没被Spring管理
// ❌ 没有@Service/@Component注解
public class OrderService {
@Transactional
public void createOrder() { ... }
}
// 调用处
OrderService service = new OrderService(); // ❌ new出来的,不走Spring容器
service.createOrder(); // 事务完全无效比喻:你没跟保镖公司签合同(没加@Service),保镖根本不知道你的存在——当然不会保护你。
场景6:传播行为配置错误
场景7:数据库不支持事务
-- ❌ MyISAM引擎:不支持事务
CREATE TABLE `order` (...) ENGINE=MyISAM;
-- ✅ InnoDB引擎:支持事务
CREATE TABLE `order` (...) ENGINE=InnoDB;面试回答模板
Q:@Transactional有哪些失效场景?
@Transactional失效的底层原因是Spring事务基于AOP动态代理,只有通过代理对象调用,事务逻辑才会执行。7种失效场景本质上都是"绕开了AOP代理"或"规则配置错误":
第一,方法不是public。AOP只代理public方法,private/protected方法上写@Transactional完全无效。
第二,同类内部调用。A方法直接this调用本类的B方法,this是原始对象不走代理,事务失效。
第三,异常被try-catch吞掉。Spring需要捕获到方法抛出的异常才触发回滚,异常被catch了Spring感知不到。
第四,异常类型不对。默认只回滚RuntimeException和Error,checked Exception不回滚。最佳实践是永远加rollbackFor = Exception.class。
第五,类没被Spring管理。new出来的对象不受Spring容器管理,不会生成AOP代理。
第六,传播行为配置错误。比如SUPPORTS没有外层事务就不开事务。
第七,数据库引擎不支持事务。MyISAM不支持事务回滚,必须用InnoDB。
另外要注意,事务内写RPC或HTTP调用会造成长事务,行锁长时间持有引发锁等待,这不是失效但比失效更危险。
三、数据库与SQL优化
3.1 慢SQL排查全流程
难度定位:大厂后端分水岭题——普通后端看到慢就加索引,大厂后端先分现象、定位根因再优化
知识骨架
慢SQL排查
│
├── 总原则:先分现象,再拿证据,最后优化
│ ├── 是一直慢,还是偶尔抖动?
│ ├── 是单条SQL慢,还是整个数据库卡?
│ └── 不凭感觉猜,用数据说话
│
├── ❌ 三个典型错误
│ ├── 错误1:看到慢就加索引 → 乱建索引伤写入,问题不一定解决
│ ├── 错误2:explain走索引就认为没问题 → 走索引但大量回表,依旧慢
│ └── 错误3:只看SQL不看Java代码 → @Transactional包裹RPC导致长事务锁等待
│
├── 排查流程(5步)
│ ├── ① 应用层定位线索(Druid / SkyWalking)
│ ├── ② 数据库层看慢查询日志(3个关键指标)
│ ├── ③ explain执行计划分析(4个重点字段)
│ ├── ④ 分三类根因定位
│ │ ├── 类别1:SQL本身执行慢(Lock_time低,扫描行数大)
│ │ ├── 类别2:锁等待导致慢(Lock_time高,explain正常)
│ │ └── 类别3:偶发抖抖(资源层面问题)
│ └── ⑤ 优化后验证
│
└── 三类根因速查表
| 信号 | Lock_time | 扫描行数 | explain | 根因 |
|------|-----------|---------|---------|------|
| 类别1 | 低 | 大 | type差/Extra差 | SQL本身慢 |
| 类别2 | 高 | 正常 | 正常 | 锁等待 |
| 类别3 | 波动 | 波动 | 正常 | 资源抖动 |排查流程5步详解
① 应用层定位线索
用Druid连接池慢SQL记录或SkyWalking链路追踪,拿到问题SQL和耗时分布。
② 数据库层看慢查询日志
3个关键指标:
比喻:你要找1个人,翻了10万页电话簿才找到(Rows_examined=10万,Rows_sent=1)——效率极低!
③ explain执行计划分析
4个重点字段:
type从好到差:system > const > eq_ref > ref > range > index > ALL
Extra关键字解读:
关键:Extra列出现Using index才是覆盖索引(不用回表),光key列有值不代表快。
④ 分三类根因定位
类别1:SQL本身执行慢
信号:Lock_time低(没等锁),Rows_examined大(扫描多)
5个常见诱因:
索引失效5种常见情况:
1. WHERE LEFT(name, 3) = '张' ← 列上套函数,索引失效
2. WHERE phone = 13800138000 ← 字符串列传数字,隐式转换,索引失效
3. WHERE name LIKE '%三' ← %开头,索引失效
4. 联合索引(a,b,c),WHERE b=1 ← 跳过最左列a,索引失效
5. WHERE a > 1 AND b = 2 ← 联合索引中a用了范围,b用不上索引深分页优化:
-- ❌ 慢:扫描10万行,丢弃前99990行
SELECT * FROM order ORDER BY id LIMIT 100000, 10;
-- ✅ 快:游标分页,直接从上一页最大id开始
SELECT * FROM order WHERE id > 上一页最大id ORDER BY id LIMIT 10;比喻:旧方案=从第1页翻到第10000页;新方案=书签夹在第10000页,直接翻到。
类别2:锁等待导致慢
信号:Lock_time高(等锁久),explain正常(SQL本身没问题)
最典型的Java场景:
// ❌ 长事务:RPC在事务内
@Transactional
public void placeOrder(Order order) {
orderMapper.insert(order); // SQL很快,0.5ms
httpClient.callPaymentService(); // RPC调用,可能3秒超时!
// 事务一直开着,行锁一直持有!
}
// ✅ 缩小事务边界:RPC放事务外
public void processOrder() {
PaymentResult result = paymentService.pay(); // 事务外,不持锁
doInTransaction(() -> {
orderMapper.updateStatus(result); // 0.5ms
inventoryMapper.deduct(); // 0.5ms
});
// 事务1ms就提交,行锁瞬间释放!
}比喻:事务=占用会议室。旧方案=开会时还打电话聊别的事,会议室白占3小时;新方案=电话在外面打完,进会议室只开会,1分钟搞定。
类别3:偶发突然变慢
⑤ 优化后验证
对比优化前后的扫描行数、耗时
评估新增索引对写入性能的影响
用
EXPLAIN确认执行计划符合预期持续观察慢查询日志,确认不再出现
3.2 MySQL调优五维体系
难度定位:大厂后端分水岭题——简历写"精通MySQL调优",面试官问"除了加索引还会啥",只会加索引直接翻车
知识骨架
MySQL调优五维体系
│
├── 核心认知:索引只是调优很小一部分
│ 线上大量性能问题不是缺索引
│ 而是表设计、事务、配置、业务使用方式带来的
│
├── 第1层:SQL语句层
│ ├── 覆盖索引,减少回表
│ ├── 游标分页,替代深分页
│ ├── 减少join表数量
│ ├── 避免索引失效
│ ├── 优化order by / group by
│ └── 大事务拆小,分批delete/update
│
├── 第2层:表结构设计层
│ ├── 字段类型合理
│ ├── 尽量NOT NULL
│ ├── 自增主键
│ ├── 大表分库分表、冷热分离
│ └── 适当反范式,冗余字段减少join
│
├── 第3层:锁与事务层(线上故障高发点)
│ ├── 缩小事务边界
│ ├── 避免长事务
│ ├── 隔离级别选择(大部分业务用RC)
│ ├── 乐观锁优先于悲观锁
│ └── 排查锁等待/死锁
│
├── 第4层:MySQL配置参数层
│ ├── innodb_buffer_pool_size(服务器内存50%-70%)
│ ├── innodb_log_file_size(redo日志)
│ ├── max_connections(结合连接池)
│ ├── 慢查询日志开启
│ └── sort_buffer_size / join_buffer_size
│
└── 第5层:业务架构层(更高阶)
├── 读写分离
├── 引入Redis缓存热点查询
├── 大表分库分表
├── 冷热数据归档
└── 计算从数据库剥离五层关系:越往上越简单但效果有限,越往下越复杂但收益越大。
各层详解
第2层:表结构设计层
字段类型合理:
自增主键 vs 随机主键:
自增主键:1 → 2 → 3 → 4 → 5(顺序追加,不挪动)
随机主键:3 → 1 → 5 → 2 → 4(乱序插入,可能页分裂)比喻:自增主键=往笔记本后面追加写;随机主键=往笔记本中间插页,后面所有内容都要往后挪。
反范式——适当冗余:
-- 范式设计:每次查订单都要join用户表
SELECT o.id, o.amount, u.name FROM order o JOIN user u ON o.user_id = u.id;
-- 反范式:订单表冗余存用户名
SELECT id, amount, user_name FROM order; -- 不用join了!第3层:锁与事务层
隔离级别选择:RC vs RR
比喻:RC=只锁你碰过的东西;RR=你碰过的东西+旁边的东西都锁上。
大部分互联网业务其实不需要防幻读,用RC更合适。
乐观锁 vs 悲观锁:
-- 悲观锁:先锁再改(阻塞其他人)
SELECT * FROM inventory WHERE id = 1 FOR UPDATE;
UPDATE inventory SET stock = stock - 1 WHERE id = 1;
-- 乐观锁:先改再检查冲突(不阻塞)
UPDATE inventory SET stock = stock - 1, version = version + 1
WHERE id = 1 AND version = 旧版本号;
-- 影响行数=0说明冲突了,重试第4层:MySQL配置参数层
第5层:业务架构层
数据库是"最贵的员工"——让它只做最核心的事(存数据、保证事务),其他活外包出去:
读写分离:老板(主库)只做重要决策(写),助理(从库)帮忙查资料(读)
Redis缓存:热门资料放前台(Redis),不用每次去档案室(MySQL)翻
分库分表:一个档案室装不下,开多个分部
冷热归档:3年前的旧档案搬到仓库,档案室只放近期的
计算剥离:数据库只存只查,排序/统计/聚合在Java代码或ES里做
面试回答模板
Q:简历写精通MySQL调优,除了加索引还会啥?
MySQL调优不只有加索引,索引只是SQL层的一小部分。我一般从5个维度来调优:
第一,SQL语句层,优化SQL写法,使用覆盖索引减少回表,深分页改游标分页,减少join表数量,避免索引失效。
第二,表结构设计层,字段类型选择合理,尽量NOT NULL,主键用自增避免页分裂,必要时反范式冗余字段减少join。
第三,锁与事务层,这是线上故障高发点。核心是缩小事务边界,避免长事务,大部分业务用RC隔离级别,优先用乐观锁。
第四,MySQL配置参数层,最重要的是innodb_buffer_pool_size设服务器内存50%-70%,redo日志文件大小要合理。
第五,业务架构层,读写分离分担查询压力,Redis缓存热点数据,把计算从数据库剥离到业务代码或中间件。
四、缓存与一致性
4.1 缓存一致性方案深度解析
难度定位:大厂后端分水岭题——普通后端知道延时双删,大厂后端知道延时双删的坑和异步替代方案
知识骨架
缓存一致性
│
├── 核心矛盾
│ 缓存(Redis)和数据库(MySQL)是两个独立的存储
│ 无法在一个事务里同时更新 → 任何时刻都可能不一致
│
├── 先更新数据库 vs 先更新缓存?
│ ├── 先更新缓存,再更新数据库 → 缓存更新成功但DB失败 → 数据丢失 ❌
│ ├── 先更新DB,再更新缓存 → 并发写时缓存可能是旧值 ❌
│ └── 先更新DB,再删缓存 ← 业界主流选择 ✅
│ 但删缓存也有窗口期问题 → 延时双删方案诞生
│
├── 方案演进
│ ├── 方案1:延时双删(常规)
│ │ 删缓存 → 更新DB → sleep(N) → 再删缓存
│ │ 问题1:sleep阻塞主链路,吞吐暴跌
│ │ 问题2:sleep时间无法精确,仍可能脏数据
│ │
│ ├── 方案2:Canal + MQ 异步二次删(推荐)
│ │ 同步路径:删缓存 → 更新DB → 直接返回(零阻塞)
│ │ 异步路径:Canal监听binlog → MQ投递 → Consumer二次删缓存
│ │ 优势:主链路零阻塞 + MQ重试保障一致性
│ │
│ └── 方案3:线程池延迟队列(轻量替代)
│ 更新DB → 提交延迟删缓存任务到线程池 → 直接返回
│ 优势:代码成本低,不阻塞业务线程
│ 劣势:进程重启会丢任务,可靠性不如MQ
│
└── 架构权衡核心
复杂度 ←→ 可靠保障
方案3最简单但最不可靠
方案2最可靠但架构最复杂
根据业务容忍度选择为什么选"先更新DB,再删缓存"?
先更新缓存,再更新DB:
先改小公告栏,再去改大公告栏。如果改大公告栏时出错了(DB失败),小公告栏已经是新内容,大公告栏还是旧的——小公告栏显示的是"假消息"!
先更新DB,再更新缓存:
两个人同时改同一条通知,可能出现:大公告栏=60,小公告栏=50——又错了!
先更新DB,再删缓存:
改完大公告栏后,直接把小公告栏擦掉。下次有人看小公告栏,发现空的,就去大公告栏抄一份回来。
这个方案的问题窗口最小——只有在"缓存刚好过期的瞬间+读请求比写请求更快完成更新缓存"这个极短窗口才会不一致,概率很低。
延时双删——常规方案及其致命问题
删缓存 → 更新DB → sleep(500ms) → 再删缓存致命问题1:sleep阻塞主链路
秒杀场景下,1秒要处理10万请求,每个请求本来50ms就返回。现在每个请求都要sleep 500ms → 响应时间从50ms飙升到550ms+ → 吞吐量暴跌10倍!
比喻:快递员送完包裹后必须原地等5分钟才能接下一单——整个快递站瘫痪。
致命问题2:sleep时间无法精确
sleep多久才够?取决于主从同步延迟,这个时间是动态的。sleep太短 → 还是可能脏数据;sleep太长 → 吞吐更差。
脏数据产生的时间线
时刻1:写请求删缓存
时刻2:读请求发现缓存空,去DB读(此时DB还是旧值!因为写请求还没更新DB)
时刻3:读请求把旧值写入缓存
时刻4:写请求更新DB
→ 缓存是旧值,DB是新值,不一致!所以需要延时后再删一次。Canal + MQ 异步二次删(推荐方案)
Canal是什么?
Canal是阿里开源的MySQL binlog监听工具。就像在城东大公告栏旁边装了一个"监控摄像头"——大公告栏有任何改动,摄像头立刻拍下来通知你。
完整流程
同步路径(业务主链路,零阻塞):
删缓存 → 更新DB → 直接返回 ✅(50ms搞定)
异步路径(后台自动执行):
MySQL binlog变更
→ Canal监听到变更
→ Canal投递消息到MQ
→ MQ Consumer消费消息
→ 执行二次删缓存
→ 如果失败,MQ自动重试 ✅比喻:快递员送完包裹直接走(不等待),同时有个监控员(Canal)看到包裹签收了,通知清洁工(Consumer)去清理。
方案对比
线程池延迟队列(轻量替代方案)
更新DB → 提交延迟任务到线程池(延迟N秒后删缓存)→ 直接返回实现方式:
// ScheduledExecutorService
scheduler.schedule(() -> {
redis.del(key);
}, 500, TimeUnit.MILLISECONDS);优劣势
三种方案对比
选择原则:
秒杀/高并发 → Canal + MQ
普通业务、快速上线 → 线程池延迟队列
低并发、不在意响应时间 → 延时双删
面试回答模板
Q:如何保证缓存和数据库的一致性?
业界主流是"先更新DB,再删缓存"。但简单的删缓存有极短窗口的不一致风险,所以演进出了延时双删方案。但延时双删的sleep会直接阻塞业务主链路,秒杀场景下响应时间从50ms飙升到550ms以上,吞吐暴跌。而且sleep时间无法精确匹配主从同步延迟,仍可能产生脏数据。
推荐的异步方案是:同步路径只做删缓存+更新DB后直接返回,异步路径由Canal监听MySQL binlog变更,通过MQ投递消息,Consumer执行二次删缓存。MQ的重试机制保障了最终一致性,主链路零阻塞。
如果项目不想引入Canal+MQ的架构复杂度,轻量替代方案是用线程池延迟队列,更新DB后提交延迟删缓存任务,代码成本低,也不阻塞业务线程,但进程重启会丢任务,可靠性不如MQ方案。
五、分布式系统
5.1 微服务数据一致性
难度定位:大厂后端分水岭题——普通后端遇到分布式一致性就答TCC,大厂后端先分析业务再选型
知识骨架
微服务数据一致性
│
├── 核心问题:本地事务管不了跨服务
│ 单体:一个数据库 → @Transactional管全局 ✅
│ 微服务:多个独立数据库 → 本地事务只管自己库 ❌
│ 结果:部分成功部分失败 → 脏数据
│
├── 典型场景:下单流程
│ 扣优惠券 ✅ → 扣积分 ✅ → 创建订单 ❌
│ 优惠券和积分扣了,订单没生成 → 用户损失
│
├── 四种方案对比
│ ├── 1. TCC(Try-Confirm-Cancel)
│ │ 强一致 · 侵入大 · 每个接口写三套逻辑
│ │ 适用:资金类、一致性极高
│ │
│ ├── 2. 可靠消息最终一致性(RocketMQ事务消息)
│ │ 侵入小 · 最终一致 · 只支持正向提交
│ │ 适用:通知类、新增操作
│ │
│ ├── 3. 最大努力通知
│ │ 不断重试通知 · 只适合通知类
│ │ 适用:支付结果通知等
│ │
│ └── 4. SAGA模式(本地消息表+状态机+正向/补偿接口)← 业务常用
│ 最终一致 · 每个服务提供正向+逆向补偿接口
│ 补偿必须幂等 · 补偿失败要重试
│ 适用:需要回滚已完成操作的电商场景
│
└── 选型原则:先分析业务,再选方案问题根因
想象你请了三个工人分别装修三个房间:
单体架构:你是包工头,三个房间在一个工地,你一个电话就能指挥所有人停工 ✅
微服务架构:三个工人在三个不同工地,各有各的老板,你只能打电话给每个工人的老板——但A老板说"已经刷完墙了",B老板说"已经铺完地板了",C老板说"出问题了干不了"——A和B的活没法自动撤回 ❌
Spring的@Transactional就是"包工头的电话"——只能管自己工地的人,管不了别的工地。
典型场景
用户下单:
1. 优惠券服务:扣减优惠券 → 成功 ✅(已提交,不可自动回滚)
2. 积分服务:扣减积分 → 成功 ✅(已提交,不可自动回滚)
3. 订单服务:创建订单 → 失败 ❌(抛异常)
结果:优惠券没了,积分没了,订单也没了——用户白亏!四种方案详解
方案1:TCC(Try-Confirm-Cancel)
比喻:酒店预订——
Try:预订房间(冻结库存,不真正扣),冻结优惠券(不能用但还没真正扣),冻结积分
Confirm:确认入住(真正扣库存、扣优惠券、扣积分)
Cancel:取消预订(解冻库存、释放优惠券、退回积分)
优点:强一致性 缺点:
侵入业务代码:每个接口要写Try/Confirm/Cancel三套逻辑
开发量巨大:3个服务×3套逻辑=9个接口
不常用:普通电商业务很少大规模使用
方案2:可靠消息最终一致性(RocketMQ事务消息)
比喻:寄快递——
先写快递单(半消息),不真正发出
执行本地事务(打包商品)
本地事务成功 → 确认发出快递(提交消息)
本地事务失败 → 取消快递(回滚消息)
下游收到快递 → 执行自己的业务
优点:业务侵入小,最终一致性 缺点:只支持正向提交,不支持逆向回滚
关键局限:事务消息适合"通知下游去做某事",不适合"通知下游撤销已做的事"。本案例中,优惠券和积分已经扣完了,订单失败需要回滚——事务消息做不了逆向回滚!
方案3:最大努力通知
比喻:快递员送快递——你不在家,他留个条,明天再来,最多来3次。
局限:只适合通知类场景,不能解决需要回滚的业务。
方案4:SAGA模式(本地消息表+状态机+正向/补偿接口)⭐业务常用
SAGA的核心思想:每个服务提供正向业务接口 + 对应的逆向补偿回滚接口。如果某一步失败,从失败点往前,依次调用每一步的补偿接口,撤销已完成的操作。
正常流程(正向):
扣优惠券 → 扣积分 → 创建订单 → 全部成功 ✅
异常流程(补偿回滚):
扣优惠券 ✅ → 扣积分 ✅ → 创建订单 ❌
↓
补偿:退回积分 ← 退回优惠券
(从失败点往前,依次补偿)三个关键细节:
补偿接口必须幂等
比喻:退钱时,不管你按几次"退款"按钮,都只退一次。 实现方式:用唯一业务ID去重,补偿前先查"这个业务ID补偿过没"。
补偿失败要重试
补偿接口调用也可能失败,需要定时任务不断重试,直到补偿成功。
中间状态短暂不一致
SAGA是最终一致性——补偿完成前,数据短暂不一致。没有全局锁,不阻塞其他业务。
TCC vs SAGA本质区别
TCC = 先冻结再确认(Try阶段不真正执行,只是预留),Cancel时释放预留——没有真正执行过,所以回滚很简单
SAGA = 先真正执行再补偿(正向接口直接扣减),失败时调用补偿接口撤销——已经真正执行了,所以补偿是"反向操作"而非"释放预留"
比喻:TCC=先订房再入住(取消=退订,房间没动过);SAGA=先入住再退房(退房=打扫恢复原状,房间已经被用过了)
四种方案对比
选型决策树
需要跨服务数据一致性?
│
├── 需要回滚已完成操作?
│ ├── 是 → 需要强一致?
│ │ ├── 是 → TCC(资金类)
│ │ └── 否 → SAGA(电商常用)⭐
│ └── 否 → 只需通知下游做新增?
│ ├── 是 → RocketMQ事务消息
│ └── 否 → 最大努力通知面试回答模板
Q:微服务下数据一致性怎么保证?
微服务拆分后,每个服务有独立数据库,Spring本地事务只能管自己库,跨服务调用不受事务控制,会出现部分成功部分失败的脏数据。
解决方案要根据业务场景选型,不要上来就说TCC:
如果需要回滚已完成的操作(比如优惠券积分已扣,订单失败要退回),优先用SAGA模式。每个服务提供正向业务接口和逆向补偿接口,失败时从失败点往前依次调用补偿接口撤销。补偿接口必须实现幂等,补偿失败要有定时任务重试。
如果只是通知下游做新增操作(比如订单成功后通知积分服务加积分),用RocketMQ事务消息,侵入小,最终一致。但事务消息只支持正向,不支持逆向回滚。
如果是资金类场景要求强一致,才考虑TCC。TCC先冻结再确认,强一致但开发成本极高。
如果是简单通知(如支付结果通知),用最大努力通知。
总结:先分析业务是否需要回滚,再选方案。电商下单场景优先SAGA,资金场景考虑TCC,通知场景用事务消息。
六、设计模式实践
6.1 Java设计模式业务落地
难度定位:3-5年Java面试加分项——不只背UML图,能说出Spring框架下怎么落地改造
知识骨架
设计模式业务落地(Spring框架下)
│
├── 核心认知:设计模式不是背UML图,是解决"代码腐化"问题
│ 代码腐化的信号:if-else膨胀、改一处动多处、新增功能要改老代码
│ Spring IoC让设计模式落地更简单——自动装配替代手动创建
│
├── 四大模式 + 解决的问题
│ ├── 1. 策略模式 → 消除if-else分支(支付类型选择)
│ │ 传统:if(payType==ALIPAY)...else if(payType==WECHAT)...
│ │ 改造:Map<Enum, Strategy> + Spring自动装配
│ │
│ ├── 2. 防腐层 → 隔离外部接口变更(微服务远程调用)
│ │ 传统:调用方直接依赖远程接口 → 接口变更→调用方全改
│ │ 改造:适配器封装远程调用 → 变更只改适配器
│ │
│ ├── 3. 责任链模式 → 流程编排与扩展(商品保存流程)
│ │ 传统:硬编码调用各环节 → 新增环节要改主流程
│ │ 改造:Handler链表 + @Order排序 + Spring自动装配
│ │
│ └── 4. 模板方法模式 → 通用流程抽象(支付公共逻辑)
│ 传统:每个支付类重复写参数校验、日志等公共逻辑
│ 改造:抽象类封装模板方法 + 子类只实现差异化逻辑策略模式——动态行为封装
传统问题
// ❌ 传统:if-else地狱
public void pay(PaymentRequest request) {
if (request.getPayType() == PayType.ALIPAY) {
alipayService.pay(request);
} else if (request.getPayType() == PayType.WECHAT) {
wechatPayService.pay(request);
}
// 新增Apple Pay?又要加else if,改这个方法!
}改造方案
比喻:收银台放一个"支付方式菜单"——顾客说"支付宝",收银员从菜单里找到支付宝的处理人,直接交给ta。新增Apple Pay?菜单上加一行就行。
// 1. 定义策略接口
public interface PaymentStrategy {
PayType getPayType();
void pay(PaymentRequest request);
}
// 2. 各支付方式实现策略接口
@Service
public class AlipayStrategy implements PaymentStrategy {
@Override
public PayType getPayType() { return PayType.ALIPAY; }
@Override
public void pay(PaymentRequest request) { ... }
}
// 3. Spring自动装配:将所有策略实现注入Map
@Component
public class PaymentStrategyFactory implements ApplicationContextAware {
private Map<PayType, PaymentStrategy> strategyMap;
@Override
public void setApplicationContext(ApplicationContext ctx) {
Map<String, PaymentStrategy> beans = ctx.getBeansOfType(PaymentStrategy.class);
strategyMap = beans.values().stream()
.collect(Collectors.toMap(
PaymentStrategy::getPayType,
Function.identity()
));
}
public PaymentStrategy getStrategy(PayType payType) {
return strategyMap.get(payType);
}
}
// 4. 使用:一行代码,没有if-else!
@Service
public class PaymentService {
@Autowired
private PaymentStrategyFactory factory;
public void pay(PaymentRequest request) {
factory.getStrategy(request.getPayType()).pay(request);
// ✅ 新增Apple Pay?只需加ApplePayStrategy类+枚举值,这里不用改!
}
}防腐层——微服务接口隔离
传统问题
// ❌ 传统:直接调用远程接口
@Service
public class OrderService {
@Autowired
private OrderRemoteClient orderClient;
public OrderDTO getOrder(Long orderId) {
OrderVO vo = orderClient.getOrder(orderId);
if (vo == null) throw new BizException("订单不存在");
return convert(vo); // 转换逻辑散落各处
}
}
// 问题:OrderVO字段变了 → 所有调用方都要改convert方法!改造方案
比喻:买一个"万能转换插头"(防腐层/适配器)——不管外国插座怎么变,你只需要换转换插头,手机充电线不用变。
// 1. 定义防腐层(适配器)
@Component
public class OrderServiceAdapter {
@Autowired
private OrderRemoteClient orderClient;
public OrderDTO getOrder(Long orderId) {
if (orderId == null) throw new BizException("订单ID不能为空");
OrderVO vo = orderClient.getOrder(orderId);
if (vo == null) throw new BizException("订单不存在");
log.info("查询订单: {}, 结果: {}", orderId, vo.getStatus());
return convert(vo); // 远程接口变更时,只修改这里
}
private OrderDTO convert(OrderVO vo) {
OrderDTO dto = new OrderDTO();
dto.setOrderId(vo.getId()); // 远程接口把id改成了orderId?只改这里!
dto.setStatus(vo.getState()); // 远程接口把status改成了state?只改这里!
return dto;
}
}
// 2. 调用方只依赖适配器
@Service
public class PaymentService {
@Autowired
private OrderServiceAdapter orderAdapter;
public void pay(Long orderId) {
OrderDTO order = orderAdapter.getOrder(orderId); // 稳定的接口
}
}责任链模式——流程编排与扩展
传统问题
// ❌ 传统:硬编码调用各环节
public void saveProduct(Product product) {
product.setDefaultInfo();
validateProduct(product);
productMapper.insert(product);
notifyService.send(product);
logService.log(product);
// 新增环节?要改这个方法!
}改造方案
比喻:把流水线改成"接力赛"——每个选手(Handler)跑完自己的棒次,传给下一个选手。新增选手?加一个人就行,不用改赛道。
// 1. 定义Handler接口
public interface IHandler<T> {
void handle(T t);
void setNext(IHandler<T> next);
}
// 2. 抽象基类
public abstract class AbstractHandler<T> implements IHandler<T>, Ordered {
private IHandler<T> next;
@Override
public void handle(T t) {
doHandle(t);
if (next != null) next.handle(t);
}
protected abstract void doHandle(T t);
}
// 3. 各环节实现类,通过@Order控制顺序
@Component
@Order(1)
public class ProductInfoHandler extends AbstractHandler<Product> {
@Override
protected void doHandle(Product product) {
product.setDefaultInfo();
}
}
@Component
@Order(2)
public class ProductValidateHandler extends AbstractHandler<Product> {
@Override
protected void doHandle(Product product) {
if (product.getName() == null) throw new BizException("商品名不能为空");
}
}
// 4. 责任链工厂:Spring自动装配 + 按Order排序 + 组装链表
@Component
public class HandlerChainFactory implements ApplicationContextAware {
private IHandler<Product> chainHead;
@Override
public void setApplicationContext(ApplicationContext ctx) {
List<AbstractHandler<Product>> handlers = ctx.getBeansOfType(AbstractHandler.class)
.values().stream()
.sorted(Comparator.comparingInt(AbstractHandler::getOrder))
.collect(Collectors.toList());
for (int i = 0; i < handlers.size() - 1; i++) {
handlers.get(i).setNext(handlers.get(i + 1));
}
chainHead = handlers.get(0);
}
public IHandler<Product> getChain() { return chainHead; }
}
// 5. 使用:一行代码,整个链路自动执行
@Service
public class ProductService {
@Autowired
private HandlerChainFactory factory;
public void saveProduct(Product product) {
factory.getChain().handle(product);
// ✅ 新增环节?只加Handler类+@Order注解,这里不用改!
}
}模板方法模式——通用流程抽象
传统问题
每种支付方式都要做"参数校验→调API→记日志→发通知",只有"调API"不同,但每个支付类都重复写了一遍。
改造方案
比喻:考试答题卡——题目已经印好(公共逻辑),你只需要填答案(差异逻辑)。
// 1. 定义抽象模板类
public abstract class AbstractPaymentTemplate {
// 模板方法:定义固定流程(final防止子类重写)
public final void pay(PaymentRequest request) {
validate(request); // 第1步:参数校验(公共逻辑)
doPay(request); // 第2步:具体支付(差异逻辑,子类实现)
logPayment(request); // 第3步:记日志(公共逻辑)
notify(request); // 第4步:发通知(公共逻辑)
}
private void validate(PaymentRequest request) {
if (request.getAmount() == null || request.getAmount() <= 0) {
throw new BizException("金额不合法");
}
}
private void logPayment(PaymentRequest request) {
log.info("支付完成: orderId={}, amount={}", request.getOrderId(), request.getAmount());
}
private void notify(PaymentRequest request) {
notifyService.send(request.getOrderId());
}
// 差异逻辑:声明为抽象方法,子类必须实现
protected abstract void doPay(PaymentRequest request);
}
// 2. 具体支付类:只实现差异逻辑
@Service
public class AlipayTemplate extends AbstractPaymentTemplate {
@Override
protected void doPay(PaymentRequest request) {
alipayClient.pay(request); // 只写调支付宝API的逻辑,其他不用管!
}
}四大模式关系
策略模式 + 模板方法 = 完美组合
┌─────────────────────────────────────┐
│ PaymentService │
│ ↓ 策略模式:选哪个模板 │
│ templateMap.get(payType).pay() │
│ ↓ 模板方法:执行固定流程 │
│ validate → doPay → log → notify │
└─────────────────────────────────────┘
防腐层 = 外部接口的"保险丝"
┌──────────┐ ┌──────────┐ ┌──────────┐
│ 调用方 │ → │ 防腐层 │ → │ 远程接口 │
│ 稳定不变 │ │ 变更只改这│ │ 可能变化 │
└──────────┘ └──────────┘ └──────────┘
责任链 = 流程的"接力赛"
Handler1 → Handler2 → Handler3 → Handler4
@Order(1) @Order(2) @Order(3) @Order(4)
新增环节?加一个Handler就行面试回答模板
Q:设计模式在实际业务中怎么用?
我在支付模块中综合运用了四种设计模式:
第一,策略模式消除if-else。支付模块根据不同支付类型调用不同实现,改造后定义PaymentStrategy接口,各支付方式实现该接口,通过Spring自动装配注入Map<PayType, PaymentStrategy>,调用时直接通过枚举值获取策略,消除分支判断。新增支付方式只加实现类和枚举值,上层代码不用改。
第二,防腐层隔离外部接口变更。定义了适配器类作为防腐层,封装远程调用、参数校验、返回值转换、日志打印等公共逻辑。调用方只依赖防腐层,不直接依赖远程接口。接口变更时只改防腐层。
第三,责任链模式编排流程。定义IHandler接口和AbstractHandler基类,各环节实现类通过@Order控制顺序,Spring自动装配后由工厂组装成链表。新增环节只加Handler类,不改主流程。
第四,模板方法模式抽象公共流程。定义AbstractPaymentTemplate抽象类,用final的pay方法封装公共流程,声明doPay抽象方法由子类实现。具体支付类只写差异逻辑,公共逻辑集中管理。
策略模式和模板方法可以组合使用——策略模式选哪个模板,模板方法定义模板内的流程。
📝 面试高频追问链
Spring面试连环追问
IoC是什么? → DI和IoC区别? → Bean生命周期? → 循环依赖怎么解决?
→ 三级缓存每级存什么? → 三级缓存为什么支持AOP? → AOP两种代理?
→ @Transactional底层原理? → 事务失效场景? → 传播行为?MySQL面试连环追问
一条SQL慢怎么排查? → explain四个字段怎么看? → 回表和覆盖索引区别?
→ 索引失效有哪些情况? → 深分页怎么优化? → 长事务为什么会导致锁等待?
→ 除了加索引还会啥? → 五维调优体系?并发面试连环追问
ThreadLocal有什么问题? → 内存泄露根因? → 数据串读怎么发生的?
→ InheritableThreadLocal为什么线程池下失效? → TTL怎么解决的?
→ 线程池核心线程数怎么配置? → 执行顺序是什么? → 四大踩坑?
→ AQS三件套是什么? → 各锁怎么基于AQS实现?分布式面试连环追问
本地事务为什么管不了跨服务? → 四种方案怎么选? → SAGA和TCC区别?
→ 补偿接口为什么必须幂等? → 事务消息为什么不能回滚?🎯 面试答题要点总结
通用答题原则
先分现象,再拿证据,最后优化——不凭感觉猜
分层回答——展示全局视野(如MySQL调优五维体系)
说清底层原理——不只背概念(如AQS三件套推导所有锁)
给出落地方案——不只讲理论(如TTL、SAGA的具体实现)
对比辨析——展示知识深度(如RC vs RR、TCC vs SAGA)
高频踩坑点
📚 学习建议
学习顺序
先建立知识骨架:看每个文档的"知识骨架"章节,建立全局视图
再通俗讲解:看"通俗讲解"章节,用比喻理解底层原理
主动提取:合上文档,画图、联想、对比
即学即用:动手实现小项目,验证理解
复习方法
费曼学习法:假装给同事讲解,讲不清的地方就是知识盲区
画图记忆:每个主题画一张图,面试时边画边讲
对比记忆:建立对比表(如各种方案的优劣对比)
场景化记忆:把知识点和实际业务场景绑定
核心记忆点:所有面试题的底层逻辑都是"先分现象、定位根因、给出落地方案"。不要只背概念,要理解第一性原理,能推导、能对比、能落地。
评论