学习策略:费曼学习法 + 知识骨架 + 主动提取 + 即学即用 目标岗位:3-5年 Java 开发 · 跨境电商 SaaS ERP 方向


📚 文档导航

本文档整合了10个专项主题文档,涵盖Java后端开发的核心知识点:

  1. 并发编程基础 - ThreadLocal、线程池、AQS锁机制

  2. Spring框架核心 - IoC/AOP、Bean生命周期、事务管理

  3. 数据库与SQL优化 - 慢SQL排查、MySQL调优五维体系

  4. 缓存与一致性 - 缓存一致性方案

  5. 分布式系统 - 微服务数据一致性

  6. 设计模式实践 - 四大模式业务落地


🗺️ 知识体系全景

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的做法像"酒店管家服务":

  1. 客人入住前,管家把房间按客人的喜好重新布置(快照当前线程上下文,设置到工作线程

  2. 客人住完,管家把房间恢复成之前的样子(恢复工作线程原来的上下文

这就是装饰器模式:不改变线程池本身,而是在任务外面套一层"上下文快照→执行→恢复"的逻辑。

生产落地建议

方案

适用场景

try-finally + remove()

所有场景,基础防线

自定义ThreadLocal跟踪remove

团队规范不严时

TTL清理切面

线程池场景

TTL装饰任务

线程池上下文传递

Reactor Context

响应式编程

面试回答模板

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:评估下游资源上限

下游资源

限制

线程池配置约束

数据库连接池

比如HikariCP最大20连接

线程池最大线程数 ≤ 20

Redis连接数

比如Lettuce默认8连接

并发Redis操作 ≤ 8

第三方接口限流

比如Amazon API 10 QPS

线程池吞吐 ≤ 10 QPS

HTTP连接池

比如OkHttp最大200连接

并发HTTP请求 ≤ 200

比喻:你招了100个快递员,但小区只有10个门禁卡——90个快递员在门口干等。

步骤3:压测验证

观察4个关键指标:

指标

健康值

异常值

调整方向

CPU利用率

60%-80%

>85%

线程太多,减少

队列积压

少量或0

持续增长

线程不够,增加

接口RT

稳定

飙升

线程争抢或队列积压

拒绝任务数

0

>0

容量不足,扩容或降级

JDK线程池执行顺序

提交任务
  │
  ├── 1. 核心线程未满?→ 创建核心线程执行
  │
  ├── 2. 核心线程已满?→ 任务放入队列
  │
  ├── 3. 队列已满?→ 创建非核心线程
  │
  └── 4. 非核心线程也满了?→ 执行拒绝策略

IO密集业务的坑

假设核心线程=8,队列容量=1000,最大线程=50:

  1. 8个核心线程全在等IO(CPU空闲!)

  2. 新任务进队列排队(队列很大,不容易满)

  3. 队列没满 → 不创建非核心线程!

  4. 结果: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()
// 提交任务的线程自己跑 → 自动降速 → 保护系统不崩溃

四种拒绝策略对比:

拒绝策略

行为

比喻

推荐度

AbortPolicy

抛异常,丢弃任务

扔包裹

CallerRunsPolicy

提交者线程自己执行

寄件人自己送

✅ 推荐

DiscardPolicy

静默丢弃,不抛异常

偷偷扔包裹

⚠️

DiscardOldestPolicy

丢弃队列最老任务

扔最早到的包裹

⚠️

面试回答模板

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含义

state=0

state>0

比喻

ReentrantLock

重入次数

无锁

数字=重入了几次

会议室门上的"使用次数"

ReadWriteLock

高16位=读锁,低16位=写锁

无锁

高位=几人在读,低位=几人在写

图书馆"几人在读/几人在写"

CountDownLatch

倒计时计数器

倒计时结束

还剩几个没完成

运动会"还有几队没到齐"

Semaphore

许可数量

没许可了

还有几个许可可用

停车场"还有几个空位"

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对比:

synchronized

AQS Condition

等待/唤醒

wait() / notify()

await() / signal()

条件队列数量

只有1个

可以有多个(更灵活)

比喻

只有一个"补材料区"

可以有"补身份证区""补银行卡区"等多个

模板方法模式

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是实现手段。

IoC

DI

是什么

思想/原则

实现方式

关系

IoC是目标

DI是达成目标的手段

BeanFactory vs ApplicationContext

BeanFactory

ApplicationContext

加载方式

懒加载(getBean时才创建)

预加载(启动时创建所有单例)

国际化

事件发布

资源加载

AOP支持

比喻

自助仓库

全能管家

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引用的就是代理对象,和容器里的一致 ✅

三级缓存的前提条件

条件

能否解决

原因

单例Bean + setter注入

可以先创建半成品再注入

单例Bean + 构造器注入

构造器注入要求创建时就有依赖,无法先创建半成品

原型Bean

原型Bean不缓存,无法提前暴露

AOP两种代理方式

JDK动态代理

CGLIB代理

要求

目标类实现接口

无要求(不能是final类)

原理

基于接口生成代理类

继承目标类生成子类

比喻

翻译(同接口)

替身演员(继承)

Spring5默认

✅ 优先CGLIB

@Transactional底层原理

核心一句话:@Transactional基于AOP动态代理实现,只有通过代理对象调用public方法事务才生效

所有失效场景的根源:绕过代理(内部调用/非public/没被Spring管理)或规则错误(异常被吞/类型错/传播错/引擎不支持)。

事务传播行为与隔离级别

传播行为(7种)

传播行为

含义

常用度

REQUIRED(默认)

有事务就加入,没有就新建

⭐⭐⭐⭐⭐

REQUIRES_NEW

必须新建事务,挂起外层

⭐⭐⭐

NESTED

嵌套在当前事务中(保存点)

⭐⭐

SUPPORTS

有事务就加入,没有就非事务运行

NOT_SUPPORTED

非事务运行,挂起外层事务

NEVER

非事务运行,有事务则抛异常

MANDATORY

必须在事务中,没有则抛异常

隔离级别

隔离级别

问题

MySQL默认

READ_UNCOMMITTED

脏读

READ_COMMITTED

不可重复读

REPEATABLE_READ

幻读(InnoDB基本解决)

✅ 默认

SERIALIZABLE

无问题但慢

面试回答模板

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:传播行为配置错误

传播行为

有事务时

无事务时

是否开启事务

REQUIRED(默认)

加入当前事务

新建事务

SUPPORTS

加入当前事务

非事务运行

⚠️ 不一定

NOT_SUPPORTED

挂起当前事务

非事务运行

NEVER

抛异常

非事务运行

场景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个关键指标:

指标

含义

判断

Lock_time

锁等待时间

高 → 不是SQL慢,是拿不到锁

Rows_examined

扫描行数

远大于Rows_sent → 大量无效扫描

Rows_sent

返回行数

和Rows_examined对比看效率

比喻:你要找1个人,翻了10万页电话簿才找到(Rows_examined=10万,Rows_sent=1)——效率极低!

③ explain执行计划分析

4个重点字段:

字段

含义

好的值

坏的值

type

访问类型

ref/range

ALL/index

key

实际用的索引

有值

NULL

rows

预估扫描行数

Extra

额外信息

Using index

Using filesort/Using temporary

type从好到差:system > const > eq_ref > ref > range > index > ALL

Extra关键字解读:

Extra值

含义

影响

Using index

覆盖索引,不用回表

✅ 最好

Using where

在Server层过滤

⚠️ 一般

Using filesort

额外排序

❌ 慢

Using temporary

用临时表

❌ 很慢

关键:Extra列出现Using index才是覆盖索引(不用回表),光key列有值不代表快。

④ 分三类根因定位

类别1:SQL本身执行慢

信号:Lock_time低(没等锁),Rows_examined大(扫描多)

5个常见诱因:

诱因

解决

缺少索引/索引失效

建索引/修正条件

统计信息不准,选错索引

ANALYZE TABLE

深分页LIMIT 100000,10

游标分页WHERE id > 上页最大值 LIMIT 10

SELECT *大量回表

只查需要的列,用覆盖索引

ORDER BY/GROUP BY引发排序

建排序字段的索引

索引失效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:偶发突然变慢

诱因

排查

磁盘IO打满

iostat -x 1

CPU被其他SQL占满

top + SHOW PROCESSLIST

redo log刷盘压力

看Innodb_log_waits

数据库备份

看备份任务时间

大批量写入

看批量导入任务

⑤ 优化后验证

  1. 对比优化前后的扫描行数、耗时

  2. 评估新增索引对写入性能的影响

  3. EXPLAIN确认执行计划符合预期

  4. 持续观察慢查询日志,确认不再出现


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层:表结构设计层

字段类型合理:

❌不合理

✅合理

原因

INT存状态(0/1/2)

TINYINT

节省3字节/行

VARCHAR(500)存姓名

VARCHAR(20)

过大会浪费内存和IO

NULL

NOT NULL

NULL在索引中需额外1字节标记

自增主键 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(可重复读,MySQL默认)

加锁范围

只锁匹配的行

锁行+锁间隙(防幻读)

并发度

低(间隙锁阻塞插入)

适用场景

大部分互联网业务

需要严格一致性的场景(金融)

比喻: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配置参数层

参数

比喻

调优建议

innodb_buffer_pool_size

图书馆的阅览区大小

服务器内存50%-70%

innodb_log_file_size

日记本的大小

太小→频繁checkpoint抖动;太大→崩溃恢复慢

max_connections

餐厅最大座位数

结合连接池,不盲目调大

sort_buffer_size

每个人的工作台大小

会话级,每个连接一份,别盲目调大

第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)去清理。

方案对比

对比项

延时双删

Canal + MQ

主链路阻塞

sleep 500ms+

零阻塞

响应时间

550ms+

~50ms

一致性保障

依赖sleep时间,不可靠

MQ重试机制,可靠

脏数据窗口

sleep期间仍可能存在

极短

架构复杂度

中(引入Canal + MQ)

线程池延迟队列(轻量替代方案)

更新DB → 提交延迟任务到线程池(延迟N秒后删缓存)→ 直接返回

实现方式:

// ScheduledExecutorService
scheduler.schedule(() -> {
    redis.del(key);
}, 500, TimeUnit.MILLISECONDS);

优劣势

优势

劣势

代码简单,几行搞定

进程重启会丢延迟任务

不阻塞业务线程

多实例部署时每个实例都会删(重复但无害)

无需引入Canal/MQ

延迟时间仍然不精确

适合中小项目

不如MQ方案可靠

三种方案对比

延时双删

Canal+MQ

线程池延迟队列

主链路阻塞

❌ sleep阻塞

✅ 零阻塞

✅ 零阻塞

一致性保障

⚠️ 弱

✅ 强(MQ重试)

⚠️ 中

架构复杂度

适用场景

低并发

高并发、强一致性

中等并发、快速落地

选择原则

  • 秒杀/高并发 → 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事务消息)

比喻:寄快递——

  1. 先写快递单(半消息),不真正发出

  2. 执行本地事务(打包商品)

  3. 本地事务成功 → 确认发出快递(提交消息)

  4. 本地事务失败 → 取消快递(回滚消息)

  5. 下游收到快递 → 执行自己的业务

优点:业务侵入小,最终一致性 缺点:只支持正向提交,不支持逆向回滚

关键局限:事务消息适合"通知下游去做某事",不适合"通知下游撤销已做的事"。本案例中,优惠券和积分已经扣完了,订单失败需要回滚——事务消息做不了逆向回滚!

方案3:最大努力通知

比喻:快递员送快递——你不在家,他留个条,明天再来,最多来3次。

局限:只适合通知类场景,不能解决需要回滚的业务。

方案4:SAGA模式(本地消息表+状态机+正向/补偿接口)⭐业务常用

SAGA的核心思想:每个服务提供正向业务接口 + 对应的逆向补偿回滚接口。如果某一步失败,从失败点往前,依次调用每一步的补偿接口,撤销已完成的操作。

正常流程(正向):
  扣优惠券 → 扣积分 → 创建订单 → 全部成功 ✅

异常流程(补偿回滚):
  扣优惠券 ✅ → 扣积分 ✅ → 创建订单 ❌
                                    ↓
  补偿:退回积分 ← 退回优惠券
  (从失败点往前,依次补偿)

三个关键细节

  1. 补偿接口必须幂等

比喻:退钱时,不管你按几次"退款"按钮,都只退一次。 实现方式:用唯一业务ID去重,补偿前先查"这个业务ID补偿过没"。

  1. 补偿失败要重试

补偿接口调用也可能失败,需要定时任务不断重试,直到补偿成功。

  1. 中间状态短暂不一致

SAGA是最终一致性——补偿完成前,数据短暂不一致。没有全局锁,不阻塞其他业务。

TCC vs SAGA本质区别

  • TCC = 先冻结再确认(Try阶段不真正执行,只是预留),Cancel时释放预留——没有真正执行过,所以回滚很简单

  • SAGA = 先真正执行再补偿(正向接口直接扣减),失败时调用补偿接口撤销——已经真正执行了,所以补偿是"反向操作"而非"释放预留"

比喻:TCC=先订房再入住(取消=退订,房间没动过);SAGA=先入住再退房(退房=打扫恢复原状,房间已经被用过了)

四种方案对比

TCC

RocketMQ事务消息

最大努力通知

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区别?
→ 补偿接口为什么必须幂等? → 事务消息为什么不能回滚?

🎯 面试答题要点总结

通用答题原则

  1. 先分现象,再拿证据,最后优化——不凭感觉猜

  2. 分层回答——展示全局视野(如MySQL调优五维体系)

  3. 说清底层原理——不只背概念(如AQS三件套推导所有锁)

  4. 给出落地方案——不只讲理论(如TTL、SAGA的具体实现)

  5. 对比辨析——展示知识深度(如RC vs RR、TCC vs SAGA)

高频踩坑点

场景

常见错误

正确做法

慢SQL排查

看到慢就加索引

先分三类根因,再针对性优化

事务失效

只记7种场景

理解本质:绕过AOP代理或规则配置错误

缓存一致性

只用延时双删

根据业务选方案:Canal+MQ / 线程池延迟队列

分布式事务

上来就答TCC

先分析业务:需要回滚用SAGA,只需通知用事务消息

线程池配置

直接背公式

公式→资源约束→压测→监控四步走

设计模式

只背UML图

说清解决什么问题、怎么改造、Spring怎么落地


📚 学习建议

学习顺序

  1. 先建立知识骨架:看每个文档的"知识骨架"章节,建立全局视图

  2. 再通俗讲解:看"通俗讲解"章节,用比喻理解底层原理

  3. 主动提取:合上文档,画图、联想、对比

  4. 即学即用:动手实现小项目,验证理解

复习方法

  • 费曼学习法:假装给同事讲解,讲不清的地方就是知识盲区

  • 画图记忆:每个主题画一张图,面试时边画边讲

  • 对比记忆:建立对比表(如各种方案的优劣对比)

  • 场景化记忆:把知识点和实际业务场景绑定


核心记忆点:所有面试题的底层逻辑都是"先分现象、定位根因、给出落地方案"。不要只背概念,要理解第一性原理,能推导、能对比、能落地。