Keyboard shortcuts

Press or to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

Computer Science Notes

一份基于 mdBook 的个人 CS 知识库,目标是对计算机科学的核心知识做全面、深入、可工程化落地的整理, 并以"一纵一横"的结构呈现: 导论卷沿时间 / 抽象层级 / 形态 / 承接四视图搭骨架, 第零到第十三部分按学科横切做工程级深度。

note

在线版已部署到 GitHub Pages: https://atituiset.github.io/computer-science/

这份笔记的定位

  • 不是面试速记卡,也不抄一遍教材的目录。
  • 自己用过的、推过的、踩过的坑沉淀下来的工程级笔记。
  • 对每个专题,要求三件事:
    1. 原理讲透:知道为什么这么设计、代价是什么、什么时候该用不该用。
    2. 代码落地:用 Go / TypeScript / Python / C/C++ 至少两种语言给出可运行实现,必要时给出工业优化版本。
    3. 可被验证:复杂度、边界、对抗用例都列出。

谁应该读

  • 已经能写代码、但对"为什么"还不够熟的工程师:能完成需求,但读论文、看源码、做架构选型时仍卡在数学或底层概念。
  • 复习型读者:离校多年、忘了"自动机 / MVCC / Sylvester / KL"的人,需要一份从工程痛点反向回溯到原理的笔记,而不是教材正着讲一遍。
  • 想横向打通 CS 的工程师:把 DSA、OS、DB、Compiler、Crypto、信息论当同一张网而不是十本独立教材来看的人——第十二部分"元抽象"专为你写。

tip

如果你是从 DSA / OS / Compiler 入手而撞上数学记号,先回第零部分把数学准备到位再继续;每篇都明确标了"喂给后面哪一章"。

章节骨架

#部分目录文件数 / 行数
导论卷 · 计算机基础知识体系prologue/6 / 1.8K
工程化实践轴 · 让代码跑进生产engineering/11 / 3.5K
0工程数学与离散数学基础math/5 / 1.8K
1数据结构与算法(DSA)dsa/36 / 3.9K
2操作系统os/28 / 3.5K
3计算机网络networking/23 / 6.2K
4数据库系统databases/23 / 4.6K
5编译原理compilers/19 / 3.3K
6分布式系统distributed/22 / 3.8K
7系统设计system-design/30 / 6.0K
8计算机组成原理computer-arch/10 / 4.7K
9计算理论(自动机 / 复杂度)theory/10 / 2.1K
10密码学与安全crypto/13 / 2.8K
11信息论与编码info-theory/12 / 2.0K
12人工智能与机器学习ai-ml/14 / 4.3K
13元抽象(跨章节大主题)_meta/8 / 1.3K
形式化方法卷formal/4 / 0.8K
量子计算卷quantum/4 / 0.6K

合计:导论卷 + 工程化实践轴 + 第零部分(前置数学)+ 13 主题 + 形式化卷 + 量子卷,281 个 .md / ~57.6K 行 / ~220 个一线章节。

骨架的设计原则是「一纵一横」:导论卷沿时间轴(1936 → 2026)+ 抽象层级(晶体管 → AI 模型)+ 形态演进(大型机 → XPU)+ 承接链(CPU/内存 → AI)四视图把全书串起来;第零部分到第十三部分按学科横切,每个专题做工程级深度。工程化实践轴则是把基础落地到生产的手艺(Git / 测试 / CI-CD / 性能 / 安全 / 质量 / 可观测性)。读者通过 prologue/map.md 的一张矩阵可以一眼定位任何一部分所在位置。

阅读路径

起点推荐(新读者): 先读 导论卷 — 用 4 个视图(时间 / 抽象层级 / 形态演进 / 承接链)把全书骨架在脑子里搭起来, 然后任选下面三条按背景分型的路径下钻.

按背景分型:

  1. 应用工程背景 / 看论文吃力 → 先 导论第零部分 数学线代概率微积分与优化 → 再回主线.
  2. 没系统学过 CS 理论导论数学 · 离散篇DSA计算理论.
  3. 学习 Transformer 优先导论 · 形态演进 (XPU 段) → 线代概率微积分第十二部分 AI/ML.

主线推荐顺序(已系统学过、想横通 CS):

导论卷 → 第零部分 数学 → DSA → OS → 网络 → DB → Compiler
       → 分布式 → 系统设计 → 计算机组成 → 计算理论
       → 密码学 → 信息论 → AI/ML → 元抽象(收束)

mdbook serve 后左侧目录即为主线阅读序,每章开头都有"一句话 + 思想链 + 章节列表 + 读完应能回答"。

本地预览

cargo install mdbook mdbook-mermaid mdbook-alerts
mdbook serve --open

默认监听 http://localhost:3000

note

book.toml 当前启用三个 preprocessor:alerts (> [!NOTE] / > [!WARNING] 框)、mermaid (流程图)。若想加 linkcheck,可在 book.toml[preprocessor.linkcheck] 后另装 mdbook-linkcheck

渲染特性

  • 数学公式:KaTeX 渲染,行内 $ ... $ / 块级 $$ ... $$
  • 流程图:mermaid v10 本地化嵌入(mermaid.min.js + mermaid-init.js)。
  • 代码高亮:mdBook 内置 highlight.js 配合 language-xxx 标注。

编写约定

  • 每章结构TL;DR / 一句话思想链(ASCII 树从工程问题回溯到原理)→ 形式化定义 → 例子 → 多语言实现 → 提示框 → 文末"一页速查"。
  • 多语言实现:Go / TypeScript / Python / C++ 至少两版;工业优化版另开小节。
  • 涉及分析处给出形式化结论 + 直觉解释两版。
  • 重点结论用 mdBook alerts 框出:
    • > [!NOTE] — 关键观察、跨章引用入口
    • > [!WARNING] — 反直觉、常见误用、踩坑警告
    • > [!TIP] — 速查表 / 速记口诀
  • 数学记号统一见 math/README.md §记号约定

与 TODO 的对齐

后续可继续扩展方向见 TODO.md。当前 15 个部分(导论 + 工程化轴 + 数学 + 13 主题 + 形式化卷 + 量子卷)均已就位;近期已补 DNS、倒排索引/全文检索、分布式事务、虚拟化与容器、SSD/NAND 存储硬件、经典 ML 与树模型、LLM 推理部署,并对 DSA 的贪心/回溯做了深度重写。后续增补按需进行(如流处理引擎、图数据库、工程化的专项实操)。

导论卷 · 计算机基础知识体系

一句话

这份笔记剩下 13 部分(数学 + DSA / OS / 网络 / DB / Compiler / 分布式 / 系统设计 / 组成原理 / 计算理论 / 密码学 / 信息论 / AI/ML / 元抽象)是按学科纵切的深度章节;这一导论卷反过来——沿着时间与抽象层级横拉一条贯穿全场的纵贯线, 让你先在脑子里搭起一座"全景骨架", 再下钻各主题时永远知道自己在哪一节脊椎上.

思想链

1936 Turing / Church                ─── 可计算性的形式化
   │
1945 von Neumann / ENIAC             ─── 存储程序 → CPU + 内存一体
   │
1957 Bell Labs / IBM 360             ─── 大型机时代 (transistor, OS, 编译器, Fortran)
   │
1971 Intel 4004                      ─── 微处理器; 半导体路线胜出
   │
1975 Altair / Apple II (1977)        ─── 个人 PC 时代起
   │
1981 IBM PC + x86 + DOS              ─── PC 标准化; Wintel 联盟
   │
1991 Linux 0.01 / WWW (Berners-Lee)  ─── 自由 OS + 全球网
   │
2006 AWS EC2 / iPhone (2007)         ─── 云计算 + 移动互联网 (ARM 逆袭)
   │
2012 AlexNet / GPU CUDA             ─── 深度学习靠 GPU 起势 → XPU 时代
   │
2017 Transformer                     ─── 大模型; AI 作为新计算范式
   │
2022 LLM / 扩散 + RLHF               ─── 生成式 AI 工程化
   │
2024+ NPU / TPU / 量子 / CXL         ─── 异构计算 (XPU) 成主流
   │
2025 GPT-5 统一 o 系 / DeepSeek-R1    ─── 推理模型 + Agent (MCP) 主流化
   │
2026 Claude 5 / Gemini 3.6 / Rubin   ─── 月级迭代; 整柜算力 + HBM4 + 量子纠错爬坡
   ▼
你现在 ↓
   ──数学──硬件──OS──网络──DB──编译──分布式──系统设计──计算理论──密码学──信息论──AI──
       │      │    │     │    │     │      │         │         │       │      │     │
       └─ 导论卷 把这一整条用「纵贯线 + 抽象层级 + 形态演进」串起来 ─┘

你将带走什么

读完应能:

  1. 站在 1936 / 1945 / 1971 / 1991 / 2006 / 2017 / 2024 / 2026 的每一节点上, 知道那一年"硬件能力 / 编程模型 / 主流抽象"三件事的契约是什么, 一句话解释为什么那节点是质变而不是渐进.
  2. 看懂"抽象层级": 晶体管 → 逻辑门 → ISA → microarch → 机器码 → 汇编 → C → OS 系统调用 → 高级语言 → 编程框架 → AI 模型. 这十层里每一层都靠下一层的契约与不等式支撑; 知道这些契约何时被打破 (Spectre / GC 暂停 / cache miss / NaN).
  3. 用一条主线把计算机从真空管演到 XPU 讲清: ENIAC → IBM 360 → PDP/Unix → x86 PC → ARM 移动 → 云原生 → GPU 深度学习 → LLM XPU. 每一阶段的"参数" (单台算力 / 价格 / 功耗 / 程序员数) 与"软件范式"如何同步变.
  4. 看任意一份新技术文档 (e.g. H100 spec / CUDA 12 / LLaMA-3 卡) 立刻看出它在"层级 / 历史 / 异构形态"哪一格, 不被名词震到.
  5. 从纵贯线一眼定位本书每一部分的位置: 看完导论后再回各部分深读时不再"信息孤立".

章节结构

这卷不是什么

  • 不是 Wikipedia 复制粘贴. 重点在"每一节点改变了什么契约", 不在"年份-事件" trivia.
  • 不是 教你硬件设计 / OS 内核 / 编译器实现的入口 (那是后续 8 部分 OS / 5 部分 Compiler 做的事).
  • 不是 答辩稿式编年史. 锚点是形式系统 + 硬件能力 + 软件范式三联, 而不是换 CEO.

与 13 部分的接口

导论章节主要喂给的后续部分
history全部: 给每部分一个"为何在那年出现"的认知
abstraction-layersOS (第八部分组成原理 / 第二部分 OS)、Compiler(第五)、AI(第十二) 受益最大
mainframe-xpu组成原理 / 系统设计 / 分布式 / AI 受益最大
standing-on-shoulders全部: 让每部分知道它的"上一站契约"和"下一站使用方"是谁
map全部: 一张表回查每部分所在位置

阅读路径

  • 第一次来全书: 先读导论 5 章 → 再按 主 README 的阅读路径下钻.
  • 已熟 CS 想横通: 直接读 mapstanding-shoulders, 跳过 history.
  • 学 Transformer 优先: 看 mainframe-xpu 的"GPU / XPU"段即可, 跳到第十二部分.

下一篇: 1. 计算机发展史纵贯线: 1936 → 2026.

1. 计算机发展史纵贯线: 1936 → 2026

TL;DR

90 年计算机史用三条线索串起: (1) 形式系统: "什么是可计算"逐步被严格定义;(2) 硬件能力: 计算速度、内存、IO 用摩尔定律等指数曲线提升, 价位指数下降, 推动"软件范式"被迫重组; (3) 抽象层级: 每一代工程师不得不引入新抽象来对抗复杂度, 把上一代底层细节封装到契约里.

每一节都讲: 那年发生了什么 / 缔造了什么契约 / 把什么写进了"已经不可能回退"的清单.

读完应能: 用 20 分钟讲清计算机从 Turing → 2026 推理模型时代的关键节点, 每个节点一句话点破质变.

note

2025-2026 段落已按 2026-08 公开资料(原厂 release note / 主流媒体)核对;模型发布与数字以官方口径为准。


0. 概览图

1936   Turing 论文 "On Computable Numbers..."
1945   von Neumann 架构报告; ENIAC 完成
1947   Bell Labs 三人组发明晶体管
1957   Sputnik → ARPA 成立; FORTRAN; IBM 360 开端
1961   COBOL; 第一台分时系统 CTSS
1969   UNIX Prototype (Bell Labs); ARPANET 起
1971   Intel 4004; 第一台微处理器; 软盘
1975   Altair 8800 → Bill Gates/Microsoft; "个人计算" 概念
1977   Apple II; Commodore PET; TRS-80
1981   IBM PC (开放架构 → clone 洪流 → Wintel 垄断)
1985   Windows 1.0; ARM1 (Acorn)
1991   Linux 0.01 (Linus); WWW 第一网页
1993   Mosaic → Web 爆炸; iPhone 之父在此出生
1995   Win95; Java 1.0; Amazon/eBay (Web 商业化)
2006   AWS EC2 起; iPhone (2007); 触屏 + app store
2012   AlexNet (ImageNet top-5 < 16%, GPU 训练); "深度学习 = GPU"
2017   Transformer / "Attention Is All You Need"
2020   COVID → 数亿人远程办公 → 云速率 5x; 扩散模型
2022   ChatGPT —— 5 天注册破 100M; RLHF 出现工程化
2023   LLaMA / Qwen / DeepSeek 系; 开源 70B 可单机推理
2024   NPU / H100 / TPU v5p / Groq / Cerebras / 量子计算长征
       Blackwell B200 / GB200 NVL72 (NVIDIA); DeepSeek-V3 开源 (12 月)
2025   GPT-5 统一 o 系; Claude 4.x / Gemini 3 / Grok 4 推理模型常态化;
       DeepSeek-R1 "时刻"; Agentic AI + MCP; gpt-oss 开源; HBM4 量产;
       Google Willow 低于阈值量子纠错
2026   GPT-5.x→5.6 / Claude 5 / Gemini 3.6 Flash / DeepSeek R2 快节奏迭代;
       NVIDIA Rubin 量产 + AMD MI450; 昇腾 950 超节点 (1024 卡);
       HBM4 三家量产 (~2 TB/s/stack) + CXL 3.2; 量子数十逻辑 qubit

一、1936-1945: 可计算性的形式化 + 第一台通用电子计算机

1.1 Church-Turing 论题 (1936)

  • Alan Turing: 论文 On Computable Numbers, with an Application to the Entscheidungsproblem 提出 Turing Machine.
  • Alonzo Church: lambda calculus 同期给出等价定义.
  • Church-Turing 论题: 任何"算法可计算"的函数 = TM 可计算 (直觉但不是定理, 是把"算法定义"和"TM 定义"绑成一体).

→ 回到 第九部分 计算理论 看形式化定义与停机问题.

1.2 第二次世界大战的硬件倒逼

  • Colossus (1943, Bletchley Park) 破译 Lorenz 密码的专用机器 (非通用).
  • ENIAC (1945, Eckert & Mauchly, 宾大): 第一台通用电子数字计算机. 17468 真空管、30 吨、5 kW、每秒 5000 加法.
  • EDVAC 报告 (1945, von Neumann): 提出存储程序架构, CPU + 内存 + IO 三分, 指令和数据同存内存. 这就是后世"von Neumann 架构".

warning

这个架构的"存储程序 + 共享总线"是把冯诺依曼瓶颈钉死在所有现代 CPU 里. CPU 与内存的频率差距至今不断拉开, 这就是为什么 HBM / CXL / NUMA 等手段反复被发明 — 都是绕过 1945 那条契约.


二、1947-1960: 晶体管 + 大型机 + 高级语言

2.1 晶体管 (1947)

Bell Labs 的 Shockley / Bardeen / Brattain 发明点接触晶体管 (Nobel 1956). 替代真空管: 体积小、能耗低、长寿命 → 可堆大规模. 没它就没有集成电路.

2.2 集成电路 (1958)

  • Jack Kilby (TI) 与 Robert Noyce (Fairchild) 几乎同时分别发明 IC.
  • 1965 Gordon Moore 提出 Moore 定律 (每 18-24 月晶体管数翻倍). 半个世纪后这条曲线仍未平.

2.3 大型机时代 (1955-1975)

  • IBM 701 (1952): IBM 第一台商用电子计算机.
  • IBM S/360 (1964): 主打"全系列兼容", 引入 ISA (instruction set architecture) 与 microarch 分离的概念. 这一台机器让"程序员一旦写汇编, 在更高型号同运行"成为可能, 也让ISA 抽象成为行业必需.

→ 这是"计算机抽象层级"的真正起点, 见 abstraction-layers.

2.4 高级语言史 (1957-1972)

年份语言出发点
1957FORTRAN (IBM)科学计算; 之前手写汇编
1959COBOL商业数据处理 (DoD 推)
1958LISP符号计算, AI 起源
1964BASIC教学, 简化学生入门
1970Pascal结构化编程教学
1972C (Ritchie)用来重写 Unix, "可移植汇编"

C 与 Unix 是孪生兄弟; 操作系统首次可移植 (PDP-7 → PDP-11 → VAX). 这件事让"硬件厂家锁定"被反复松动, 是后续一切的种子.


三、1969-1981: Unix + ARPANET + 微处理器 + PC

3.1 Unix (1969)

Ken Thompson & Dennis Ritchie 在 Bell Labs 为 PDP-7 写"unics" (-> Unix), 后用 C 重写为可移植 OS. 几乎所有现代 OS (Linux, macOS, iOS, Android 底层, BSD) 都直接或间接是 Unix 派生.

贡献: 文件即字节流 / 进程 / fork-exec / pipe / 小工具组合的 shell 哲学 / 网络套接字.

→ 本书 第二部分 OS 几乎是 Unix 与 Linux 工程化的延伸.

3.2 ARPANET (1969)

第一个分组交换网络; 4 节点起步 (UCLA / SRI / UCSB / Utah). 1973 TCP/IP 雏形 (Cerf-Kahn). 1983 NSFNET 升级 TCP/IP, "互联网" 实质开始.

1971 Ray Tomlinson 发第一封 email. 1976 Apple I 卖给爱好者; 1977 Apple II 内置 BASIC + 成品机箱; 1981 IBM PC 出货, 标致"个人计算机"被企业级认可.

3.3 Intel 4004 (1971): 第一颗微处理器

Intel 4004 = 2300 晶体管, 4-bit, 740 kHz. 上面运行 Busicom 打印机的计算. 这件事的本质: CPU 也能做成芯片而不再是机房箱柜. 后续 8080/8086 (1978 x86 起) 一步步推.

3.4 Altair 8800 (1975) 与 Microsoft 起步

Pop Electronics 1975 封面 Altair 8800 → 哈佛大二学生 Bill Gates 与 Paul Allen 写 Altair BASIC 解释器, 一年不到成立 Microsoft. "软件"作为商品第一次清晰化.


四、1981-1995: PC 标准化 + GUI + 互联网基础

4.1 IBM PC (1981)

  • 用 Intel 8088 (4.77 MHz, 16-bit 内核 + 8-bit 外总线); OS 用 Microsoft DOS (买断 Seattle Computer 产品 QDOS 改名).
  • 关键: 架构未保密 → Compaq / Phoenix BIOS 等厂商大量克隆 → "PC 兼容机"潮 → x86 成为事实标准.

x86 vs ARM 分叉的故事从这年开始, 见 mainframe-xpu.

4.2 WIMP + GUI

  • 1973 Xerox PARC 的 Alto 第一次实现 GUI (windows, icons, mouse, popup).
  • 1984 Apple Macintosh 商业化 GUI. 1985 Windows 1.0.
  • 1989 Tim Berners-Lee 在 CERN 提 HyperText + HTTP/HTML, 写下第一个 web server.

4.3 LAN 与数据库

  • 1980 Novell NetWare / 3Com Ethernet 网卡让办公室组 LAN 成常态.
  • 关系数据库 1970 Codd 论文 → Oracle (1979) / IBM DB2 (1983) / PostgreSQL前身 Postgres (1996). 这是 第四部分数据库 的源头.

五、1991-2001: Linux + Web + 开源

5.1 Linux (1991)

Linus Torvalds 在赫尔辛基大学宿舍写 Linux 0.01 (1991-08-25 公告), 1992 GPL 化 → 开源爆发. 90 年代 ~ 一切服务器都 Linux 化; "LAMP stack" (Linux+Apache+MySQL+PHP) 成为 web 默认.

→ 这是 Unix 之外第二支不锁厂商的开源 OS, 给后续 web 服务、云计算、AI 训练堆栈全部用 Linux 作底.

5.2 World Wide Web (1991)

Tim Berners-Lee 在 CERN 部署第一个 web server, HTTP 0.9 / HTML. 1993 Marc Andreessen 写 Mosaic 浏览器 → Netscape (1994) → 商业化 web.

→ 后续影响 第三部分网络 的一切. HTTP 是几乎所有协议层的"最后一公里".

5.3 开源浪潮

  • 1985 GNU (Richard Stallman) → GCC, glibc, bash, Emacs.
  • 1991 Linux + 1995 Apache HTTP 服务器 + 1996 PostgreSQL + 2005 Git.

→ 2000 年后开源几乎成为基础设施软件默认形态, 也是 AI 时代模型托管 (HuggingFace) 的预设.


六、2001-2010: Web 2.0 + 移动 + ARM 逆袭 + 虚拟化

6.1 AJAX + Web 2.0 (2004-2005)

  • Gmail (2004) / Google Maps (2005) 引爆 AJAX → 浏览器成"富客户端", SPA (Single Page App) 兴起.
  • 2006 AWS EC2 商业化 (虚拟机租用), 启云时代.

6.2 iPhone + 触屏 + App Store (2007)

  • 2007 初代 iPhone (ARM11 412 MHz, 128MB RAM) 改变"手机"定义: 全屏触屏 + 应用商店.
  • 2008 Android 1.0; ARM 几乎一夜成为移动通用 CPU → 制造规模超 x86 → RISC 复兴.
  • 2010 iPad; 2011 Chromebook.

→ ARM 在手机出货量以 100× 量级碾压 x86, 后又通过 Apple Silicon M1 (2020) 与 AWS Graviton 反杀数据中心. 见 mainframe-xpu.

6.3 虚拟化与云萌芽

  • 1999 VMware; 2003 Xen; 2006 EC2; 2008 KVM 入 Linux 主干.
  • "弹性的服务器"成为商品 → 创业公司不需自买机架 + 上架.

第七部分系统设计 的所有 case (Twitter Snowflake / Google 三件套 / Kubernetes 控制平面) 都默认这套云底座.


七、2010-2020: 深度学习 + 大数据 + 容器化

7.1 GPU 通用计算爆发 (2007+ CUDA, 2012 AlexNet)

  • 2007 NVIDIA 推出 CUDA 让 GPU 可做通用并行.
  • 2012 AlexNet (ImageNet top-5 error 16%) 第一次大规模用 GPU 训 CNN, 一举把深度学习从学识题变成工业题.
  • 2013-2015 VGG/ResNet/Inception; 2014 GAN; 2015 Batch Norm; 2016 ResNet (152 层可训).
  • 2017 Transformer → 序列建模换赛道.

7.2 大数据基础设施 (2004 MapReduce / GFS → 2010 Spark)

  • 2004 Google MapReduce & GFS paper → Hadoop (2006) 开源 copy.
  • 2006 BigTable paper → Cassandra (2008) / HBase / Dynamo (2007) / Redis (2009).
  • 2014 Kubernetes; 2015 Docker 1.0 主流; 2018 service mesh / Istio / Envoy.

第六部分分布式系统 的 CAP / Paxos / Raft / K8s 全靠这十年开源沉淀.

7.3 CPU 增长遇阻 + 多核 + 加速器登场

  • 2005 后单核性能增速放慢 (Dennard scaling 结束), 8 核 / 16 核 / 32 核 CPU 成主流.
  • 2010 后 GPU / TPU / FPGA 在数据中心占比上升 → 异构计算开篇.
  • 2017 RISC-V 基金会成立; ISA 开源化大潮.

→ 直接铺垫 第八部分组成原理 里的 GPU / TPU / NPU 章节.


八、2020-2024: 大模型 + 生成式 AI + XPU 主流化

8.1 关键事件

  • 2020 GPT-3 (175B); scaling law (Kaplan 2020) 起势.
  • 2021 ViT / DALL-E; 2022 ChatGPT (5 天 +1 亿用户, 历史最快).
  • 2022 扩散模型 (Stable Diffusion, Midjourney).
  • 2023 LLaMA-2 (Meta); Qwen / Baichuan / DeepSeek 等中国开源; 推理成本 -90% / 半年.
  • 2024 推理为王, TPU v5p, Groq LPU, Cerebras WSE, RISC-V 在 AI子系统, NPU 进入个人 PC 与手机, CXL 内存池化.

8.2 范式迁移: CPU 时代 → GPU 时代 → XPU 时代

阶段算力主流芯片开发模型
1981-2005 CPUx86 / ARM 单核C / C++ / Java
2005-2015 多核8-32 核 CPUpthreads / TBB / Java util.concurrent
2010-2020 GPUNVIDIA + CUDACUDA / OpenCL / OpenGL
2016-2024 加速器TPU / NPU / DPU高层框架 (PyTorch / JAX) + 算子级
2024+ XPU 异构CPU + GPU + NPU + DPU + 量子预研全栈编排: MLIR-on-IR + workload dispatch

→ "XPU" 是 Andy Bechtolsheim 2017 命名,统一意味着异构: CPU 处理控制流, GPU 处理大并行, DPU 卸载网络与存储, NPU 在终端推理. 见 mainframe-xpu 详述.

8.3 RLHF 工程化

  • 2022 InstructGPT → ChatGPT 把"政策对齐"从科研推成通用工程.
  • 后续 DPO (Direct Preference Optimization, 2023) / RLHF with KL penalty / GRPO (DeepSeek 2024) 成主流.

八·5、2025-2026: 推理模型、Agentic AI 与 Blackwell 时代

按 2026-08 公开资料 (原厂 release note / 主流媒体) 核对; 模型发布与数字以官方口径为准.

8.5.1 推理模型 (Reasoning Models) 主流化

  • OpenAI o1 (2024 末) 与 o3 (2025): 把 Chain-of-Thought 显式化训练, 用 RL 学习"长内部思考"过程; "test-time compute" 成为新维度.
  • DeepSeek-R1 (2025-01): 完全开源的 reasoning 模型, 凭冷启动 + 规则奖励 RL 训出一阶性能接近 o1; 价格却只有零头, 一夜踩穿美国闭源厂商定价 → "DeepSeek 时刻".
  • GPT-5 统一 o 系 (2025-08): OpenAI 把 o 系并入 GPT-5 按需切换推理 / 普通模式, 同周开源 gpt-oss-120b/20b (2025-08-05, Apache 2.0); 此后按"月"迭代 (5.1 → 5.2 → … → 5.6), 2026-07 的 GPT-5.6 再分成 Sol / Terra / Luna 三档能力层.
  • Anthropic (2025-2026): Claude 3.7 / 4.x 把 "extended thinking" 做成内置开关; 2026-02 Sonnet 4.6 首搭 1M context, 2026-06/07 进入 Claude 5 时代 (Sonnet 5 / Opus 5), agentic 编程与超长上下文成为默认能力.
  • Google / xAI (2025-11): Gemini 3 ProGrok 4.1 同期发布; 2026-07 Google 出 Gemini 3.6 Flash / 3.5 Flash-Lite / 3.5 Flash Cyber 主打 agent 效率, Grok 5 却一再跳票、只更新到 4.5 —— 大版本节奏第一次被拉开.
  • 中国开源路线齐发 (2025-2026): DeepSeek V3.2 → R2 (2026 初)、Qwen3-Max → Qwen3.5 (2026-02)、Kimi K2.5、GLM-Z1 等, 把 RL + verifier + 过程奖励走通并保持开源权重.

统计学含义: 不再只是 next-token 似然, 而是 MCTS + PRM + verifiable reward 上的策略优化; 模型从"模仿分布" 变 "在状态空间搜解". 这把强化学习 (RL) 从边缘推成 LLM 训练 core 环, 且 2026 年推理算力曲线比预训练更陡 —— "推理时算力" 成为新的军备竞赛主轴.

8.5.2 百万 token 上下文与 KV / attention 革新

  • 2024 Gemini 1.5 Pro 首秀 1M context; 2026-03 Claude Opus 4.6 / Sonnet 4.6 把 1M context 转正为 GA (标准价、无溢价), Gemini 3 / Claude 5 延续 1M+ 原生支持, OpenAI 400K 走"短上下文快推理" 路线.
  • 技术栈: sparse attention + sink token + RoPE 外插改进 (YaRN / LongRoPE) + KV 量化 (FP8 / INT4) + PagedAttention.
  • → 把 第十二部分 §2 MHA§3 backprop 里 attention 显存 $O(T^2)$ 的容器打到工程极限; 长上下文从"演示指标" 变成"默认产品参数".

8.5.3 NVIDIA Blackwell 与 HBM4 / Rubin

  • B200 (2024 发布 / 2025 量产): 双 die, 192 GB HBM3e, 8 PFLOPS FP4. 注意 B200 不再单芯片, 而是 2 个 reticle 用 NV-HBI 互连封装.
  • GB200 NVL72: 36 块 Grace CPU + 72 块 B200 通过 1.8 TB/s NVLink 互联构建单机柜巨型张量机.
  • HBM4 (JEDEC 2025-04 定标; SK 海力士 2025-10 量产 → Samsung 2026-02 → Micron 2026-06 通过 NVIDIA 认证): 位宽 1024→2048 bit, 单 stack 约 2 TB/s, 8-hi 24 GB / 12-hi 36 GB 起步; 配合 CXL 3.x 让 Tiered memory 走向主流.
  • Rubin / Vera Rubin (CES 2026 发布, 2026 下半年量产): 72 Rubin GPU + 36 Vera CPU, NVLink 6 (每 GPU 3.6 TB/s) + ConnectX-9, 把"整柜张量机"再推上机架级 NVLink Domain; 2026 起 AI 硬件竞争从单卡打到整机柜.

8.5.4 ASIC 与 XPU 谱系扩展

  • Google TPU v6 Trillium / AMD Instinct MI450 (2026, 432 GB HBM4, 19.6 TB/s, Helios 机架) / AWS Trainium 2: 都朝"张量 + 互联" 押大注; Intel Falcon Shores 2025-01 取消 (仅作内部测试芯片), 转向机架级 Jaguar Shores —— 路线图再一次证明竞争重心在"系统" 而非"单卡".
  • Groq LPU 主打"流式推理"几十到几百 tok/s 单卡.
  • Cerebras WSE-3SambaNova SN40L: 整片晶圆级芯片 + 长上下文推理 SDK.
  • Apple M5 (2025-10) / M5 Pro/Max (2026-03) Neural Engine + 骁龙 X Elite NPU 终端推理普及; 一台笔记本上跑百亿参数成产品级.
  • 国产: 昇腾 910C 大规模国产替代 → 昇腾 950 超节点 Atlas 950 SuperPoD (2026 WAIC 首展: 1024 卡互联, 1 EFLOPS FP8 / 2 EFLOPS FP4, 256 TB 统一内存), 昇腾 384 超节点已商用 750+ 套; SMIC N+2 (7nm 等效) 2026 年底产能目标 7 万片/月; 寒武纪思元 590 / 阿里倚天 / 含光 亦成熟.

8.5.5 Agentic AI + MCP 协议

  • 2024 末 Anthropic 提出 MCP (Model Context Protocol) 让 LLM 与外部工具 / 数据源通过标准 JSON-RPC 协议交互.
  • 2025-2026 GPT-5.x / Claude 5 / Gemini 3.x 把 tool use / 多轮调用 / sub-agents 工程化; Codex / claude-code 等 coding agent 成为首个"产品级 agent" 品类, "Agent = LLM + tool loop + memory" 成主流范式.
  • 由此工具调用从单 API 进化为"企业应用总线"; 安全与沙箱 (Yamada / eBPF 隔离) 同步兴起, agent 从 demo 走向生产.

8.5.6 量子计算 "logical qubit" 时代

  • Google Willow (2024-12, 105 qubit): 首次演示表面码低于阈值 (below threshold) —— 错误率随码距增大指数下降, 纠错从理论变成工程曲线.
  • Quantinuum Helios (2025-11): 98 物理 qubit 编码出 48 个逻辑 qubit, 2Q 门平均保真 99.92%, 是目前逻辑 qubit 的最高纪录.
  • 2026 现实: 主流设备落在 10-48 逻辑 qubit, 尚未抵达 "~100 logical" 门槛; IBM 200 逻辑 qubit 的 Starling 预期 2028-2029.
  • Shor 还要 100 万-1 千万物理 qubit 才能威胁 RSA-2048; 业内"Y2Q (years to quantum)" 估计仍在 10-30 年.

九、用一条数学线把 1936-2026 拉直

1936 TM        ──── "可计算" 形式化
                                        1948 Shannon 信息论 ── 熵 = 压缩下界 / 容量 = 通信上界
                                              │
1980 协议理论 (Lamport / Pew / FLP) ─── 分布式算法理论起步
                                              │
1995 RSA / DH / ZKP 一线 (Crypto)
                                              │
2012 深度学习 = 反向传播 + GPU + 概率分布建模 大爆
                                              │
2022 大模型工程化 = 自回归 + RLHF + 推理优化
                                              │
2024 异构计算 = 数学调度优化 + 硬件窄并发抽象
                                              │
2025 推理模型 + Agent = RL + verifier + 显式思维链;
       Blackwell B200 把"芯片"从单片推到多 reticle 系统
                                              │
2026 HBM4 (~2 TB/s) + CXL 3.2 + 昇腾 950 超节点 + 量子数十逻辑 qubit:
       数学调度不止 on-die, 跨 memory / chiplet / quantum 同步推上日程

这条线告诉读者: 数学在第零部分不是孤立基础, 而是在 1936-2026 这 90 年里每隔十几年就被换一次主角, 从离散的 TM → 概率的信息论 → 协议的不变式 → 数论的密码学 → 优化的 AI.


十、一句话点破每一年质变

年份事件质变一句话
1936Turing把"算法"从直觉推到形式
1945von Neumann把"程序"放进内存 → 抽象层从此可分
1947晶体管把"开关"从机电推到固态
1964IBM S/360把"型号"从一次性推到 ISA 兼容
1969Unix把"OS"从机房专属推到可移植代码
1969ARPANET把"通信"从点对点推到分组交换
19714004把"CPU"从箱柜推到芯片
1981IBM PC把"计算机"从机房推到桌面
1991Linux + WWW把"操作系统"和"信息消费"各自开源 / 全球化
2006AWS EC2把"服务器"从资本推到按分钟计费
2007iPhone把"计算机"从桌面推到口袋 (ARM 击穿)
2012AlexNet + CUDA把"GPU"从图形推到通用张量
2017Transformer把"序列建模"从时间串联推到并行 attention
2022ChatGPT把"AI"从论文推到日常工具
2024XPU 异构把"CPU 一统天下"推到分工表
2025推理模型 / DeepSeek 时刻把"LLM"从续写推到 RL 增强推理; 开源踩穿定价
2025Blackwell B200 / GB200把"GPU"从单片推到 multi-reticle + 整柜张量机
2025MCP / Agentic AI把"LLM"从问答推到工具总线 + 多轮 Agent
2026HBM4 三家量产 + Rubin / MI450 + 昇腾 950把"算力"从单供应商单片推到整柜多源分工
2026量子 10-48 逻辑 qubit把纠错从物理实验推到工程爬坡

十一、结束 + 速查表

tip

一页快速唤回:

  • 1936 Turing: 算法形式化; 不可判定性.
  • 1945 von Neumann: 存储程序 ISA; CPU + 内存 + IO 三分.
  • 1947 晶体管 + 1958 IC: 让大规模逻辑可堆.
  • 1964 IBM 360: 引入 ISA 抽象; "兼容"成为商品属性.
  • 1969 Unix + ARPANET: OS 可移植 + 分组交换.
  • 1971 Intel 4004: CPU-on-chip; 微处理器时代.
  • 1981 IBM PC: x86 + DOS, Wintel 出芽.
  • 1991 Linux + WWW: 开源 OS + 全球网.
  • 1995 商业 web: 浏览器, LAMP stack.
  • 2006 AWS: 云原生起.
  • 2007 iPhone: ARM 移动逆袭.
  • 2012 AlexNet + CUDA: GPU = 张量计算事实标准.
  • 2017 Transformer: 并行 attention 取代 RNN.
  • 2022 ChatGPT: 大模型工程化, 用户数年破亿.
  • 2024 XPU: CPU + GPU + DPU + NPU + 量子预研.
  • 2025 Reasoning + Blackwell: 推理模型把 RL 推上 LLM 主舞台; B200 / GB200 把 GPU 推到整柜张量机.
  • 2025 DeepSeek 时刻: 开源踩穿闭源定价; 中国开源路线齐发.
  • 2025 MCP / Agentic: LLM 进化为 Agent + tool bus.
  • 2026 HBM4 (2 TB/s) + Rubin / MI450 + 昇腾 950 + 量子数十逻辑 qubit: 整柜算力 + 多源供应链; 逻辑 qubit 纠错进入工程爬坡.

下一篇: 2. 抽象层级: 从晶体管到 AI 模型的十层金字塔.

2. 抽象层级: 从晶体管到 AI 模型的十层金字塔

TL;DR

计算机科学的本质 = 分层抽象: 每一层为上层提供契约 (一组语法 + 语义 + 不变式), 上层不知道也不需要知道下层如何实现. 但抽象会泄漏 — 当下层契约被打破时, 上层必须有人理解并修复.

这条"十层金字塔"从前一天最熟悉的程序语言脚本一路下钻到晶体管电压, 再上钻到 AI 模型. 一共 10 层, 每层一句话:

┌──────────────────────────────────────┐
│ 10. AI 模型 / 智能体层 (Agent)         │   GPT / Claude / Agent
│  9. 应用与框架层                       │   web / db / 分布式 / 编程框架
│  8. 高级语言运行时层                   │   Java JVM / Python / V8
│  7. 系统调用接口层                     │   POSIX / Win32
│  6. 操作系统内核层                     │   Linux / NT
│  5. 指令集架构 (ISA) 层               │   x86-64 / ARM / RISC-V
│  4. 微架构 (microarch) 层              │   流水线 / 超标量 / cache / OoO
│  3. 数字逻辑层                         │   RTL / 门 / 触发器 / FSM
│  2. 模拟电路层                         │   放大器 / ADC / DAC
│  1. 半导体器件物理层                   │   MOSFET / 晶体管 / PN 结
└──────────────────────────────────────┘

读完应能: 任意给一份技术资料能立刻说出它在哪一层 / 它依赖下层哪契约 / 它向上提供什么. 看到现象 ("Redis 抖动"或 "Hyper-threading 引起 30% slowdown") 能沿层级定位泄漏源.


一、为什么抽象分层

1.1 复杂度 vs 单脑容量

  • 现代数据中心有 $\sim 10^{18}$ 晶体管, $\sim 10^9$ 行代码, 个人脑容量 $\sim 10^{11}$ neurons. 显然不可能"自上而下" 一气讲清.
  • 抽象分层 = 把능力分工: 一层专家可以专心设计下一层 + 履行契约, 上层专家可以假定契约不变.

1.2 每层的契约

  • 语法: 什么样的输入合法.
  • 语义: 输入对应什么输出 / 系统行为.
  • 不变式: 上层可以依靠的"始终为真"的保证 (e.g. "malloc 返回 non-NULL 不意味分配成功, 还要 free"; "TCP 是 reliable" 不变式 vs 链路层"按位丢失" 等等).

1.3 抽象会泄漏

"All non-trivial abstractions, to some degree, are leaky." —— Joel Spolsky, 2002.

抽象整层错误不存, 至少六个典型泄漏手段:

  • 性能泄漏: 上层假设"免费", 实际 cache miss / page fault / GC pause.
  • 正确性泄漏: 上层定义 OK, 下层在并发 / 故障下会违反契约 (e.g. fsync 在某些 fs / drive 上不保证 crash safe).
  • 资源泄漏: 上层 closure / lambda 引用底层 file descriptor 不释放; OOM.
  • 延迟泄漏: 上层 RPC, 下层跨网 RTT 50ms, 上层调 1000 次 = 50s.
  • 资源粒度泄漏: 上层"加 1 字节" 在下层是"加 1 page" (4KB).
  • 失败模型泄漏: 物理断电 → 网络 quiet → 上层 app 假死.

本书每一部分都是某一层专家视角, 第十三部分元抽象专门谈同级跨层共同点.


二、底层向上: 第 1-4 层

2.1 第 1 层: 半导体器件物理

契约: 把外部电压 / 电流转换为可预测的"开关"行为.

  • MOSFET (Metal-Oxide-Semiconductor Field-Effect Transistor): 三端 Gate/Source/Drain, Gate 电压控 Source↔Drain 通道.
  • 工艺节点 (5nm / 3nm / 2nm) 是 marketing 词; 真晶体管密度与 pitch 跟名字关系不严格对应.
  • 关键变数: $V_{th}$ (阈值), $I_{on}/I_{off}$ 比, 漏流 (static power), 短沟道效应, 量子隧穿.

note

2005 后 Dennard scaling 退场: 同面积功耗不再随节点下降 → 主频不能无限升 → 多核化 → 2010+ 加速器化. 这是后面所有 XPU 故事的根.

2.2 第 2 层: 模拟电路

契约: 把 MOSFET、电阻、电容组成放大器、ADC/DAC、PLL、SRAM cell.

  • SRAM 单元 = 6T, 6 个晶体管组成双稳态寄存器.
  • DRAM 1T1C: 1 MOSFET + 1 电容; 容量高但需周期刷新 (64ms 内必须读回写一次).
  • PLL 用作时钟倍频, ADC 把"模拟信号" 数化为 ADC 输出.

→ 这一层给硬件上一个"稳定态 + 离散数字"契约; 上层第 3 层 RTL 不再关心电压.

2.3 第 3 层: 数字逻辑 (RTL / Verilog)

契约: 把布尔函数 (组合) 与时序逻辑 (状态) 组成可综合电路; 时钟无穷假设下, 输出由输入决定.

  • RTL (Register-Transfer Level): Verilog / VHDL 描述数据在寄存器之间流动 + 触发时序.
  • 自动综合 + 布局布线 (PD).
  • 关键抽象: 时钟域同步 (CDC), 亚稳态 (metastability), 关键路径 (timing closure).
  • 组合电路: 逻辑门, 多路选择, 算术单元 (一位加法基本是 28T 半加器 + 9T 全加).
  • 时序电路: 触发器 + 状态机 + counter.

2.4 第 4 层: 微架构 (microarch)

契约: 把 RTL 实现成"对上一层 ISA 提供指令的时间抽象" — 一条指令在多少周期做完、是否可乱序、是否可预测.

  • 流水线 (5 级 / 16 级), 超标量 + OoO (Tomasulo), 分支预测 (Tage / Perceptron).
  • Cache 层次 L1 / L2 / L3 / DRAM → 见 第八部分 memory hierarchy.
  • Memory consistency & reorder buffer (ROB), memory ordering.
  • TLB + huge page + page walk.
  • GPU SM / warp scheduler; Tensor Core 矩阵乘张量核心.

warning

Spectre (2018) 的本质: 微架构的 OoO + 分支预测打破了"用户态 / 内核态"在 ISA 上的契约 — 在 speculatively executed 失败路径上的 cache 副作用被攻击者读出 → 侧信道泄漏. 例子是" 第 4 层的 speculative 行为泄漏给第 7 层的程序".


三、中层: 第 5-7 层

3.1 第 5 层: ISA (Instruction Set Architecture)

契约: 一份"机器指令 + 寄存器 + 寻址模式 + 系统态"的精确规范, 上层编译器目标即它, 下层微架构实现即它.

  • CISC: x86-64 / zArch. 变长指令, 复杂寻址, 长历史兼容.
  • RISC: ARM (v8/v9), RISC-V, MIPS. 固定 32 位指令, load-store 抽象, 解码简单.
  • 子系统: 向量扩展 (AVX-512 / SVE2 / RISC-V V), AMX (Intel Advanced Matrix Extensions), Apple Silicon Matrix Coprocessor.

→ 第 8 部分 指令集架构 详解.

3.2 第 6 层: 操作系统内核

契约: 通过系统调用接口, 对上层隐藏硬件细节: 进程调度、虚拟内存 / 页表、文件系统、套接字、设备驱动.

  • Unix: 进程 fork/exec, 文件 = 字节流, 设备 = /dev 下 file-like, socket = file descriptor.
  • Linux: monolithic + module; CFS 调度, cgroup v2 隔离, eBPF 动态扩展.
  • Windows NT: 微内核思想实用化, micro-kernel-ish + Executive.

→ 第二部分 OS 全部讲这一层.

3.3 第 7 层: 系统调用 POSIX

契约: 上层应用看到的标准 API: read/write/open/close/fork/exec/mmap/epoll/....

  • POSIX (IEEE 1003.1) 让软件跨 Unix 兼容.
  • Windows Win32 等价.
  • 这一层一旦定下来, 70 年的 C 代码都能在 2024 Linux 跑 (grep old codebase).
  • 典型泄漏: fork + exec 在 fork 后 fd 复制仍占用 → 父子进程都需 close. Threads 不是"轻量 fork" → 用 pthread_create 而且内存共享 (信号 mask, thread-local, errno 要小心).

四、上层: 第 8-10 层

4.1 第 8 层: 高级语言运行时

契约: 提供更高级抽象 (GC / 闭包 / 模块 / 异步) 对编译后的字节码 / 解释后的 AST 解释运行, 调系统调用完成 IO.

  • JVM / HotSpot: 字节码 + JIT (tiered compilation 把热点方法机编本地 code).
  • CPython: 解释 + 字节码; GIL (global interpreter lock) 限制纯 Python 多线程; PyPy JIT.
  • V8 / JS 引擎: tiered JIT (Ignition + TurboFan + Maglev + Sparkplug).
  • Go runtime: goroutine + GMP + runtime 调度 + channel, binary 自带.
  • Erlang BEAM: actor 模型 + 极轻量进程 + 热升级.
  • C/Rust: 无运行时 (Rust 用 minimal runtime), 编译到机器码直接跑.

→ 第五部分编译原理的 JIT 与 _meta runtime-semantics 详解各派不同.

4.2 第 9 层: 应用与框架层

契约: 业务语义 + 编程框架的库抽象 + DevOps 链路.

  • Web: React / Vue / Spring / Gin / Django; BFF; 后端服务 (RPC / GraphQL).
  • 数据库客户端: ORM (Hibernate / SQLAlchemy / GORM); 迁移工具 (Flyway).
  • 分布式: Kafka / Pulsar; gRPC / Thrift; Etcd / ZooKeeper; K8s operator / Helm / Argo CD.
  • 数据 / ML: Spark / Flink / Dask / Ray / Airflow; experiment tracking (MLflow / W&B).

→ 第七部分 系统设计 与第六部分 分布式 都在这一层操作.

4.3 第 10 层: AI 模型 / Agent

契约: 输入 prompt / multimodal content, 输出 tool call + text; 训练时还要有 reward 信号或对比信号.

  • 现状: 2024-2026 是"模型即应用" 范式年; LLM 不止续写, 还有 tool use (MCP), 长 context memory, multi-agent orchestration.
  • 形态: 在线服务 (OpenAI / Anthropic API) vs 自托管开源模型 (LLaMA / Qwen / DeepSeek) vs 端侧模型 (Apple Intelligence / Phi-3) vs 嵌入式 (NPU on phone).
  • 关键不变式破坏: 训练数据分布 ✗ 推理分布 (covariate shift) / 测试时思考预算 ≠ 训练分布 / 多模态对齐困难.

→ 第十二部分 AI/ML 与未来可能的 RL/Agent 章节.


五、横向跨层的几个关键事实

5.1 一条"虚拟 → 物理" + 一条"逻辑 → 概率" 主干

10. 语义 / 行为        Agent 语义 / 训练目标
9.  应用 / 业务         组件 + 数据流
8.  运行时             类型 + 函数 + GC
7.  POSIX              fd / file / socket
6.  内核               进程 / 虚拟内存 / inode
5.  ISA                寄存器 + 指令
4.  microarch          ROB / cache / TLB / branch pred
3.  RTL                寄存器 + 组合逻辑
2.  模拟               SRAM / DRAM cell / PLL
1.  物理               MOSFET / 掺杂 / PN 结
─────────────────────────────────────────
对调 → 一条"从概率到行为"主干 (AI 层的来源)

向下"从可计算到物理可造", 向上"从工程到语义可表达". 这个对称是抽象的根本.

5.2 每条层对调, 走 1ms 都要多少层?

你点按钮 (10 Layer)
  → React handler -> 服务后端路由 (9)
    → Python 调 OS write (8→7)
      → kernel epoll (6)
        → NIC 驱动 (5→6 跨)
          → NIC 物理 PHY (4→3→2→1)
            → 光纤传输
              → 远端 反向
                → ...→ DB cache hit
                  → 返回
                    → 经历同样 10 层回你屏幕
 total ~ 50ms 到 10s 视复杂度

→ 这就是"为什么优化 latency 要看哪一层泄漏". 跨 1ms 不是一层优化的事.

5.3 抽象的成本与边界

抽象提供的便利限制 / 失效
虚拟内存独立地址空间page fault / swap / NUMA
进程隔离建执行单位context switch / fork copy-on-write
文件字节流 / 不可变语义SSD trim 与 GC、fsync 协议
套接字reliable TCP 上 byte stream跨网 RTT / TCP 拥塞 / head-of-line
容器进程级环境一致内核共享 → Spectre / Meltdown 风险
VM完整 OS 级隔离hypervisor 损失
浏览器 DOMHTML + JS DOM APIXSS / reflow layout thrashing
数据类型静态保证运行时 Cast / 不安全语言
GC让你忘 freeSTW / heap pressure
ML 模型自然语言接口"幻觉" / 训练分布外

每条左侧给了便利, 它依赖右侧的实现细节往往在压力下泄漏.


六、抽象怎么"被打破"——5 个真实历史事件

事件年份层级泄漏关键人物 / 论文
Pentium FDIV bug1994第 4 层 ALU 查表错误 → 第 5 层 ISA 契约错 (除法精度)Intel 损失 4.75 亿美元召回
Meltdown / Spectre2018第 4 层 speculative exec 副作用 → 第 7 层 跨进程读 kernelGoogle Project Zero, Graz Univ.
Rowhammer2014第 2 层 DRAM cell 电磁耦合 → 第 6 层 跨进程 priv 升 (Flip)CMU + 等论文
Redis 主从延迟2016 众第 6 层 vm page cache 与 fork → 第 9 层 RTT 抖动Latency 99 实践
GPT-4 数算 7+7=142023+第 10 层统计模型对"算术"等离散变换推不上 → 上 audit fail多家推理模型 + verifier 都在补

每个都是"下一层物理 / 微架构行为把上层契约撕掉一块"; 一旦撕掉, 修法常常是要在多层同时打补丁.


七、抽象层级的工程用法

7.1 调试定位: "把症状挂到某层"

现象: 服务 P99 抖动
9 (业务): 流量突增 / 队列爆 → 容量问题
8 (运行时): GC pause / go runtime block
7 (POSIX): epoll 漏 wake / TCP ack 慢
6 (内核): 调度延迟 / softirq starvation
5 (ISA): - (上层几乎不报)
4 (microarch): branch mispred / cache miss
3 (RTL): (几乎无)
2 (模拟): (硬件故障)
1 (物理): bit flip

能力定位: 一旦你能把现象对应到大概一层, 你就知道该看哪个工具 (perf / strace / py-spy / vtune / ipfc / sledf).

7.2 系统设计: "新抽象必须问'泄漏怎么办'"

设计 cache / scheduler / scheduler / predictor 时, 先回答:

  • "失败时契约怎么退化?"
  • "性能泄漏到什么粒度?"
  • "并发 / 故障模型下不变式是什么?"
  • "依赖下层什么不变式? 下层失败呢?"

→ 见 第十三部分 _meta/brhardware-shapes-software第八部分 / OS 第 2 章.

7.3 学习路径: "沿层上下走"

  • 从程序员角度看, 学完高级语言 (8) 应该往上 ((9) 框架与 (10) AI 应用) 与往下 (指 7 POSIX 与 6 内核与 5 ISA) 各推一些.
  • 从硬件工程师角度看, 学完数字逻辑 (3) 应该往物理 (1-2) 与微型架构 (4) 与 ISA (5) 与内核 (6) 推.
  • 从 AI 工程师角度看, 应该至少熟悉 (10) 与 (9) + (8) + (5) (手算 attention FLOPs 都要 ISA 层系统) + (4) (Tensor Core / SM).

八、与第零部分 + 各部分的接口

  • 抽象 ↑层(10-9) 主线依靠第十二部分 AI/ML 与第七部分系统设计.
  • 抽象 ↓层(1-4) 主线依靠第八部分组成原理.
  • 第 5 ISA 直接对应第八部分 isa-design.
  • 第 6 内核对应第二部分 OS.
  • 第 7-8 之间——语言运行时 vs 系统调用——与第五部分 compiler + _meta runtime-semantics 直接相关.
  • 第 4 层泄漏洞的 crypto/sidechannel 讲 Spectre/Meltdown 的密码学攻击面.
  • 数学 (第零部分) 跨层使用: 第零部分线代对应 (4) 微架构张量核心; 概率对应 (10) AI 模型; 微积分/优化对应 (10).

九、结束 + 速查表

tip

一页快速唤回:

  • 十层金字塔: 物理→模拟→RTL→microarch→ISA→kernel→POSIX→运行时→应用→AI.
  • 契约: 每层给上层一组语法 + 语义 + 不变式.
  • 泄漏是法则: 性能 / 正确性 / 资源 / 延迟 / 粒度 / 失败模型 — 6 种典型泄漏.
  • 历史教训: Pentium FDIV, Meltdown/Spectre, Rowhammer, Cloud futex, LLM 算错.
  • 调试法: 把症状先挑到恰当层, 再选工具.
  • 设计法: 抽象新发明, 预先回答"4 个问题" (回退/性能/并发/失败).
  • 学习法: 沿层上下交替推 — 程序员往上下各, 硬件工往上下各, AI 工按需.

下一篇: 3. 形态演进: 大型机 → PC → 单片机/ARM → 云 → Web → AI → XPU.

3. 形态演进: 大型机 → PC → 单片机/ARM → 云 → Web → AI → XPU

TL;DR

计算机 90 年史, 物理形态大致经过 7 段 演化. 每一段新形态不是上一段的"放大版/缩小版", 而是新的契约 + 新的使用人群 + 新的工程师心智模型:

  1. 大型机 (mainframe): 5 大单位共用 1 台机器, 分时.
  2. 小型机 (minicomputer): 实验室级, 关键用户开放.
  3. 个人计算机 (PC): 桌面 1 人 1 机.
  4. 单片机 (MCU) / 嵌入式: 物联网之家家电、汽车、卡片电脑.
  5. ARM / 移动: 口袋超算, RISC 一夜成反主流. 同时云计算也起飞.
  6. Web / 云原生: 用户运行不再占设备, "数据在云上, 浏览器即终端".
  7. AI / XPU: 数据中心向张量机倾斜, 在 N 年内 GPU / NPU + CPU 共生, 异构成为主流.

每段不仅硬件变, 软件 / 用户 / 商业模式也变. 我们把"形态学"这一节当作"硬件 + 软件 + 人的契约" 三联一起讲.

读完应能: 给一台新设备 (e.g. Groq LPU / Apple Vision / 某家国产 7nm 服务器芯片) 立刻判断它在哪一段 / 在异构谱系哪一端 / 它依赖上一段哪些遗产.


一、形态演进总图

1960.DataBindings
┌──────────────────────────────────────────────────────────────────┐
│ 1. Mainframe (IBM 360 / 370 / z)            单台 / 分时共享    │
│ 2. Mini (PDP-11 / VAX)                        实验室级          │
│ 3. PC (x86 / PC-clone)                     1 人 1 机, 平民化   │
│ 4. MCU / 嵌入式 (Arduino / Pi / 单片机)    几人franc块           │
│ 5. Mobile (ARM 手机 / 平板)                  口袋超算           │
│ 6. Cloud / Web (AWS / Azure / GCP)         1 服务 / N 用户      │
│ 7. AI / XPU (H100 / NPU / TPU / 量子预研)   分工化算力          │
└──────────────────────────────────────────────────────────────────┘

每一阶段, 单台算力 + 人数 + 软件范式 这三条线互相带火. 表三条线随时间:

阶段代表硬件算力 (相对)用户数 (相对)软件范式
MainframeIBM 360/3701 (基线)共享 100 量级批 / 分时, COBOL / Fortran, 卡片
MiniPDP-11/VAX0.31000+ (研究)Multics, Unix 雏形, C
PCx86 + DOS/Win0.5-21 亿-10 亿桌面 GUI, C++, JS
MCUATmega / stm3210⁻³─ (隐入背景)baremetal / RTOS
MobileARM64 SoC1-560 亿 (手机)App Store / 触屏
Cloudx_a7 / GravitonN × 大机架数亿共享容器 / IaC / 微服务
AI / XPUH100 / B200 / TPU / NPUN × GPU × 100数百万开发者框架 (PT/JAX) + Agent

二、阶段 1: 大型机 (1965-)

2.1 物理形态

  • 一台 IBM S/360 = 几个机柜, 水冷, 卷磁带库, 集中管理. 当年 "data processing department".
  • 操作系统 OS/360 (later MVS / VM / z/OS); 任务, JCL (Job Control Language).
  • 编程范式: COBOL (商业), FORTRAN (科学), PL/I (混合), 后来 RPG, 汇编.

2.2 契约 / 不变式

  • 批处理 + 分时 (TSO):
    • 批: 提脚本 → 等结果. 用 JCL 描述作业调度.
    • 分时: 写终端 (3270) 上 ISPF.
  • 可靠为先: 7x24 不停. RAS (Reliability/Availability/Serviceability) 重于性能.

2.3 至今大型机仍在使用

  • IBM z15 / z16 用 5nm 工艺, 跑 z/OS, 在银行 / 航空 / 政府仍主力. 其工作负载大多是 COBOL 老 transaction 处理系统; 据估计全球 200K+ COBOL 程序员仍在岗.

note

大型机最不常见的契约是 "已知不停年数 70+". 把银行核心交易移到云或 Kubernetes 等价要重建这套 SLA 的工程量. 这就是为什么 80 年代预言"大型机必死"至今没兑现.


三、阶段 2: 小型机 (1965-1985)

3.1 PDP/VAX

  • DEC PDP-8 (1965): 第一台 < $20K 的小型机 → 大学实验室都能买.
  • PDP-11 (1970): 决定性影响, 给 Ken Thompson & Dennis Ritchie 写 Unix 用. C 语言在此诞生.
  • VAX-11/780 (1977): 32 位, 单机虚拟内存, 标准 1 MIPS 性能基准.

3.2 关键遗产

  • UnixC 都在 PDP 上诞生 → 第一份与硬件解耦的 OS + 语言 (1973 C 重写 Unix kernel). → 后续 Linux / BSD / Solaris 全都站着.
  • 分时系统 Unix 让程序员直接交互式开发, 不再写卡片等一天.

3.3 1980 后逐渐让位 PC

  • minicomputer 因 PC 在性能上赶上 (80486 一代) 而 vax 都被 Sun / SGI 工作站替代 → 进一步 PC / Linux 替代.

四、阶段 3: 个人计算机 (1981-)

4.1 形态定义

  • 单台桌上机, 价格 $1000-5000, 1 人 1 机.
  • IBM PC 开放架构 (1981): IBM 只把 BIOS 留 copyright, 其它都让外面 chip 厂做; Compaq 等做 clean-room BIOS → PC clone 洪流 → x86 + DOS 标准.
  • Wintel 联盟 (Windows + Intel): 1990s 主导.

4.2 操作系统迭代

  • MS-DOS 1.0 (1981) → 6.22 (1994).
  • Windows 1.0 (1985) → 3.x (1990-1993) → 95 / 98 (1995-1998) → NT / 2000 / XP / 7 / 10 / 11.
  • 终于 Windows + Office 成为商业上标准应用.

4.3 关键软件革命

  • VisiCalc (1979 Apple II) 第一代 killer app; Lotus 1-2-3 → Excel 接力.
  • Internet 同期借助浏览器从科研拉到家里 (Mosaic → Navigator → IE → Firefox → Chrome).

4.4 形态不变但内部 XPU 化

  • 至 2024-2026, 校园 / 办公 PC 大多内含 NPU (Apple Neural Engine / 骁龙 Hexagon / Intel / AMD NPU); 传统桌面任务在 CPU, 视频处理在 GPU / NPU 媒体引擎, AI 推理在 NPU. 用户的"键盘手"完全感受不到分工, 但芯片任务被分配得像 Viking 时代分工一样精细.

五、阶段 4: 单片机与嵌入式 (1976-)

5.1 8-bit 单片机史

  • Intel 8048 (1976)、8051 (1980) — 集成 CPU + RAM + ROM + IO 在一片, 既家电控制也打印机控制.
  • Microchip PIC, Atmel AVR — 90 年代起家电 / 工业占比大.
  • 32-bit ARM Cortex-M (2004+) — 给嵌入式加上 MMU-less 32 位 → MCU 进入位数升级时代; 现代智能家居系列用 STM32 / nRF52 / ESP32.

5.2 关键契约与约束

  • 8/16-bit 单片机典型几十 MHz, 几 KB SRAM, 几十 KB Flash; 低功耗 (< mA @ 3.3V) 比性能重要.
  • 程序循环 + 中断驱动, 裸跑或 RTOS (FreeRTOS / Zephyr).
  • 适配硬件 IO 与外设协议 (I2C / SPI / UART / CAN), 这是 [操作系统 + 编译原理] 的下边界实践.

5.3 树莓派 / 嵌入式 Linux 与新形态

  • Raspberry Pi (2012 起) = ARM 单片机 + Linux; 普通人可以 35 美元跑完整 OS + python.
  • BeagleBone Black / Jetson Nano / Coral Dev Board — 边缘 AI 推理板, AI 工业应用入门.

warning

单片机不像服务器: 没虚拟内存、没 OS 保护、通常没 fork、可能没浮点单元、int 大小因编译器变. 把 X86 心智照搬过去会出错—这就是为什么 MCU 章节经常与"嵌入式 C"配套教.

5.4 物联网 (IoT) 与 MICRO-CLOUD 端

  • IoT 设备峰值数十亿台 ↔ 几十亿 IP; 6LoWPAN / MQTT / CoAP / OPC UA 等协议.
  • 数据汇总到边缘网关 → 上云 / 流式 → ML 推理再下沉到端 (端 NPU) 的"edge ML" 链路成主流.

六、阶段 5: ARM + 移动 (2007-)

6.1 ARM 历史

  • Acorn Archimedes + ARM1 (1985): Acorn Computers 设计的低功耗 32-bit RISC CPU.
  • ARM (公司) 1990 独立, 商业模式 = ISA 授权 + 核授权: 谁都拿 ARM 设计 → Apple A 系列 / Snapdragon /Samsung Exynos / 联发科 Dimensity / 华为麒麟 / Google Tensor; 都按 ARM 几亿片付版税.
  • ARM 在 2007 iPhone 起进入手机主流, 2010 iPad, 2020 Apple M1 桌面 / 笔记本, 2018 AWS Graviton 进数据中心, 2023 通 Microsoft Windows on ARM, 2024+ 自研车规级芯片 (Tesla / 高通 8295 / 联发科).

6.2 RISC 与 CISC 的胜负史

1980 x86 CISC 起 ← IBM PC 路线
1985 ARM1 RISC
1990 RISC 工作站死 (Sun/SGI 让位 PC)
2000 x86 由摩尔推往更快
2005 单核停 → x86 强向多核, 但功耗不行入手机
2007 iPhone ARM
2010 ARM Cortex-A 系打平 x86 性能/瓦
2020 Apple M1 在桌面都胜 x86
2024+ ARM 的 Neoverse V/N 与自研 (Graviton / XuanTie / 阿里 倚天)成型成为数据中心升

关键: RISC 并未在 1990 年工作站时代赢; 它是在 mobile / 功耗 / 集成 SoC 这一新形态上 2007+ 赢, 再逆向卷回桌面与数据中心.

6.3 形态新约: App + 触屏 + 推送

  • 用户在触摸屏上手势/语音交互; 通知 / 推送代替弹窗; 后台 / 前台切换由 OS 自动; 电池结构作为合约 (energy budget per app).
  • 操作系统 iOS / Android 主流, 沙箱 + 审核制 + 应用商店分发 与 PC 时代相比完全变了软件商业模式.
  • API 重心从 Linux 系统调用移到 Apple SDK / Android SDK; JNI/Swift/Flutter/React Native 跨平台层多样.

6.4 ARM 在 2024-2026 的姿态

  • ARMv9 + SVE2 与 CXL / 一致性 / page table 多机架化扩展 (CCIX → CXL 4.0) 进一步强化数据中心能力.
  • Apple M5 / 骁龙 X2 Elite / Nvidia Grace — 桌面 / PC / 服务器 ARM 都路线成熟; x86 走异构 + chiplet + AI 加速的 binary compatibility.
  • 中国: 阿里平头哥 倚天 / 华为 昇腾 CPU + 鲲鹏 / 飞腾 / 龙芯 / 海光 — 在政企 / 信创 / 金融大批量部署.

七、阶段 6: Web 与云计算 (1995-)

7.1 Web 1.0 → 2.0 → 3.x

  • Web 1.0 (1991-2004): 静态 HTML + 链接 + 邮件; 浏览器看网页.
  • Web 2.0 (2004-): AJAX + UGC (User Generated Content); Wikipedia / YouTube / Facebook / Twitter 起势; JavaScript + CSS + DOM 成熟框架 (jQuery → React → Vue / Angular).
  • Web 3.0 (2014-): Blockchain + decentralized identity + 媒介原资产, 部分 industrial deployment 但仍是 experimental.
  • Web 与 LLM 时代 (2023-): 浏览器内嵌 LLM (Edge Copilot / Brave Leo), Browser-LLM 协议 (MCP, etc).

7.2 云原生态

  • AWS EC2 (2006) 起, 一切 on-demand; S3 对象存储; RDS 一键数据库. 资本支出从机房 → "API 一个 form".
  • 容器化 (Docker 2013), Kubernetes (2014-2018), service mesh, GitOps, IaC (Terraform / Pulumi / Crossplane).
  • "无服务器 (serverless)": Lambda / FaaS / EventBridge — 上层抽象更上一层, 工程师不接触实例.
  • 2024+ 通 WebGPU / WebGPU compute API / WebAssembly 系统接口 (WASI) 让浏览器执行本地任务 (含 LLM 推理).

7.3 数据中心 "机房是社会"

云数据中心 (Google / AWS / Azure / 阿里 / meta / 字节 / 腾讯) 一般 10-100MW+, 需 50-100MW 风电 / 水电 / 核电配套. 在 2023+ AI 训练需求暴增, 单数据中心能力推到 1GW+ 接边; 核电 / SMR (small modular reactor) 直接进 Microsoft / Amazon 长约; 数据中心从"工程师资产"变"国家级能源政策", 是不可忽略的形态变化.


八、阶段 7: AI / XPU 异构时代 (2012-)

8.1 XPU 谱系

用途当前主流 (2024-2026)
CPU控制流 + 低并发x86, ARM, RISC-V, Power
GPU大并行张量NVIDIA H100/H200/B200/GB200, AMD MI300/MI450, Intel Arc/Battlemage/Jaguar Shores
TPU张量 ASICGoogle TPU v5p/v6 Trillium
NPU端侧 AI 推理Apple Neural Engine, 骁龙 Hexagon, MediaTek APU, Intel / AMD NPU, 华为 Ascend NPU
DPU / IPU网络与存储 off-loadNVIDIA Bluefield-3, Fungible / Pensando / 阿里 CIPU
FPGA可重配 / 小批量Xilinx Versal / Intel Agilex / 国产
ASIC固定 workloadGroq LPU, Cerebras WSE-3 / SambaNova SN40L / Tenstorrent
RAID / SmartNICOS bypassBroadcom Memory, Marvell, NVIDIA BlueField
量子特定子问题模拟IBM / Google / IonQ / Quantinuum / 国盾量子 / 本源量子

8.2 为什么 XPU 必然

  • Dennard scaling 2005 止 → 单核不再提速 → 必须 SIMD / 多核 / 加速器.
  • AI 训练 = 矩阵 × 矩阵 (GEMM) 大规模并发, CPU SIMD 的乱序调度远不如 GPU 的 SIMT.
  • 推理 latency / 功耗关键, NPU 在 50-200 GOPS/W 比通用 CPU 5-10x 能效.
  • 摩尔放缓 → 工程师换 vertical specialization: 每类算力做专门芯片 → 形成多 die + 互联 (NVLink 5/6 / CXL 3.x / UALink / Infinity Fabric).

8.3 互联 (interconnect) 与 memory hierarchy 重塑

  • 关键事实: 2024-2026 大模型训练, 算力不卡 GPU 而卡 GPU 间带宽 + HBM 容量.
  • HBM4 + CXL 3.x + NVLink 5/6 / Infinity Fabric 让"memory pool" 成为单数据中心级资源.
  • 背景: 见 第八部分 / interconnectsmemory-hierarchy.

8.4 XPU 上的"用户" 是谁

用户群
CPU99% 程序员 (Linux, Python, Docker, ...)
GPUML 工程师 / 科研 / cinema render
TPU / 推理 ASICGoogle 内部 + Google Cloud 用户
NPU几乎人人 (端 AI), 但不暴露编程接口
DPU云厂商内部 + Smart NIC 工程师
FPGA通信 / 金融高频 / 数据center 部分
量子科研组与少量 crypto 实验室

统一框架: MLIR / OpenXLA / TorchTitan / SDAA — 让你写一份 PyTorch 在哪一 XPU 上都跑, 内部 dispatcher 把算子路由到对应芯片.


九、形态演进对工程师的影响

9.1 工程师"脑模型" 演化

  • 1960 大型机时代: 排队作业 / 数据表 / 作业流 / COBOL 主导.
  • 1980 PC 时代: 单人作业 / 文件 / GUI 桌面 / C / VB.
  • 2000 Web 时代: client-server / DB / HTTP / PHP / Java Servlet / Spring.
  • 2010 移动时代: app / 推送 / 通知 / 离线 cache / Swift / Kotlin / Flutter.
  • 2020 云时代: container / K8s / IaC / GitOps / observability / micro-service.
  • 2025 AI/Agent 时代: prompt + tool use + MCP + sub-agent orchestrator + model registry.

warning

这不是替代关系, 是叠加: 现在大型机仍有 / 单片机仍有 / PC 仍有 / 移动仍有 / 云 / AI 都仍是. 一个新的"全栈工程师"必须能在层之间切换语义, 不要总停留在写 web 后端的脑模型里.

9.2 形态与抽象层级的双重图

abstraction-layers 的 10 层叠加在这 7 段形态上, 每段形态都激活某些层:

形态              主要激活的抽象层级
─────────────────────────────────
Mainframe          1-7 (低层缺独立硬件锤)
Mini               3-7 (Unix 起来)
PC                 1-8 (高级语言运行时大众化)
MCU                1-5 (baremetal 与 ISA 露骨)
Mobile             1-9 (系统 → App → 触屏)
Cloud / Web        1-9 (容器 + 微服务 + 网络套接)
AI / XPU           1-10 (新增 LLM 层; 每层都革新)

→ 这就是为什么 AI 时代让人"压力大": 同时在 10 层里推 — 数学层 (第零部分) / 硬件层 (第八部分) / OS (第二部分) / 编译 (第五部分) / AI (第十二部分) 全被推.

9.3 工程师必备的"形态切换" 能力

  1. 给我一段 CUDA kernel, 能定位它在第 3 (RTL) / 4 (microarch SM) / 5 (ISA) / 9 (PyTorch→ cuBLAS path).
  2. 给我一段 Go 服务, 能告诉我它的 latency 受第 6 (kernel fork) / 第 7 (epoll) / 第 4 (cache miss) 哪层主导.
  3. 给我一段 Transformer 训练脚本, 能告诉我它在 XPU 谱: CPU 做控制, GPU 做 GEMM, DPU/NIC 做梯度同步, HBM 做激活 cache.

十、未来 (2026+) 应看的形态信号

  • NPU 进 PC / 移动成主流: Apple Intelligence / Windows Copilot+ / Android AI 主推.
  • CXL pool 化内存: 数据中心级 "memory pool" 走出研究阶段, 进入商用 workload.
  • 量子-经典混合协处理 (QPU-as-accelerator for crypto / material / optimization): 部分 HPC workload 上线.
  • Chiplet + die-stacking: AMD 3D V-Cache / Intel Foveros / 台积电 CoWoS / chiplet 互联取代单大 die.
  • 6G 与低轨卫星直连: 卫星互联网补齐地球 IP (Starlink 等).
  • AI 编程范式入工程主体: Agent / MCP / multi-tool loop 成为默认开发栈, IDE 与 git 都换头脑.

十一、与后续各部分的接口

形态对应后续部分
Mainframe / Mini第二部分 OS 历史; 第五部分编译器历史
PC第八部分组成原理 (CPU 流水线 / cache / ISA)
MCU / 嵌入式第二部分 OS 至深 (RTOS / eBPF 系统级调度)
Mobile / ARM第八部分 ISA (x86/ARM/RISC-V 横向纵向)
Cloud / Web第三部分网络 + 第六部分分布式 + 第七部分系统设计
AI / XPU第八部分 GPU/TPU + 第十二部分 AI/ML

十二、结束 + 速查表

tip

一页快速唤回:

  • 七段形态: 大型机 → 小型机 → PC → MCU → 移动/ARM → 云/Web → AI/XPU.
  • 每段不是替代: 是叠加; 在 2026 全部共存.
  • 各段对应"新用户群 + 新软件范式 + 新契约 + 新主流芯片".
  • ARM 经手机 wtt2007 反袭 + 2020 Apple Silicon 卷回桌面 + 2018 Graviton 卷入数据器.
  • XPU 不是 CPU 替代, 是分工 = 控制流 CPU / 张量 GPU / 服务 off-load DPU / 端 AI NPU / 推理 ASIC.
  • 形态与抽象层级多对多: 形态演进激活抽象层级某些段.
  • 工程师能力核心: 给一份新名词 (MEC / AI PC / agentic workload / ...) 能定位在形态 + 抽象层级哪一格.

下一篇: 4. 主干纵贯: CPU/内存 → OS/Linux → 网络/Web → DB → 编译 → 分布式 → AI 的承接链.

4. 主干纵贯: CPU/内存 → OS/Linux → 网络/Web → DB → 编译 → 分布式 → AI 的承接链

TL;DR

前面 history 讲时间线、abstraction-layers 讲层级、mainframe-xpu 讲形态. 这一节反过来 — 沿"承接链"反向回到纵贯主线: 每条技术都因"上层拿它做啥"才有意义, 每条新技术大概率是对上层需求的回响.

承接链 (dependency-of-statements):

物理 → 晶体管 → 数字 → ISA → 微架构 → 内存 → OS → 进程 / 虚存
                                            ↓
          → 文件系统 → 网络 → Web → 数据库 → 应用 → 业务
                  ↘
                   → 编译器 → 高级语言 → 框架
                                              ↘
                                               → 分布式 → 共识 → 一致性 → 云
                                                                   ↓
                                                                   → AI 训练 → 推理

每一节都讲: 它的上一站契约是什么 / 它对下一站提供什么 / 没有它上一步走不动.

读完应能: 把任意一份你做过 / 你读过的工程问题挂到这条承接链某节点上, 知道"上一站是怎么影响它"和"它对下一站指挥谁".


一、CPU 与内存: 1945 年那纸契约的开始

1.1 von Neumann 架构

     CPU  ←─── shared bus ───  Memory
                                   │
                                 IO

存储程序 + 共享总线 + 取指 → 解码 → 执行 → 写回. 这套循环在一台机器上仍是 2026 主流 (除 Harvard / NUMA / DSP variant).

1.2 第一道承接: 性能 vs 内存之间的-frequency gap

1980  CPU 5MHz, DRAM 100ns     到 CPU 一致
1995  CPU 100MHz, DRAM 60ns   缓慢拉开
2000  CPU 1GHz, DRAM 6ns       10x 差距
2010  CPU 3GHz, DRAM 0.3ns    100x 差距 ← cache 不可缺
2026  CPU 5GHz, HBM 0.2ns     memory wall; cache + HBM + CXL

→ 这就是为什么 第八部分 memory-hierarchy / TLB / DRAM / HBM 全是必需. 内存是 CPU 的最薄弱点, 没有第二选项, 没有它后整个 OS / 编译 / AI 承接链都得重.

1.3 为何 OS 出现: 单机内部多任务化的契约

  • 主机仍单 CPU 时, OS 把"硬件时序"封成"时序进程"; 进程 / 线程 / 上下文切换 = 虚拟时间 + 虚空间.
  • 一旦有保护模式 + 虚拟内存 + 系统态分, 用户程序不必对每个硬件细节了如指掌 — 这是 OS 出现的核心动因.

二、OS/Linux: 让程序员不再写硬件

2.1 进程 + 虚拟内存 = 抽象母机

  • 进程 = 一份拥有独立虚地址空间的执行单位; 内核为每个建 page table, 给"独立空间"幻觉.
  • 调度器 (Linux CFS / EDF / RT) 将 CPU 时间切片让多进程并发"幻觉".
  • 上下文切换挂上下文 + TLB; 延迟 ~ 1-10us (见 os/sched).

2.2 文件系统: 让 IO 字节流化

OS 抽象物理对应
open(path)inode 解析 + dentry cache
read/write(fd)page cache + direct IO
mmap(fd)TLB + page dirty
fsync(fd)WAL flush + 块设备 cache flush
sendfile(out, in)zero-copy, DMA

→ 见 os/fs.

2.3 网络栈与 epoll/io_uring

  • socket() 复用 fd 抽象; TCP 4 层 + IP + 驱动.
  • C10K 问题 (2002): 单机 10000 连接压力 → epoll (2002) + kqueue; 后 100K / 1M 由 io_uring (2019) 实现.

2.4 同步原语与内存模型

  • 进程内 sync: mutex / semaphore / rwlock; kernel 内 futex + CAS; 浪费大批 lock-free / RCU 设计.

→ 见 os/lock.

2.5 OS 对承接链"承诺"的清单一句话

  • 进程隔离的虚空间
  • 通用 IO 的字节流
  • 跨进程的协议: socket / IPC / pipe
  • 命名隔离: filesystem
  • 安全 / 权限: uid / cgroup / capability

这一组契约就是后续一切软件的"地基".


三、网络与 Web: 让孤立的机器互连

3.1 IP/TCP 契约

  • IP: 不可靠的、按 packet; 上层自己保序 / 重传可靠性.
  • TCP: reliable byte stream; congestion control (Reno/Cubic/BBR); 3-way handshake + 4-way close.

3.2 HTTP / TLS: 最薄一英里

  • HTTP/0.9-1.1 一条 TCP 一个 request; HTTP/2 (2015) 一条连接多路 stream; HTTP/3 over QUIC (2022+) → 见 networking/quic.
  • TLS 1.3 (2018) 1-RTT + 0-RTT; X.509 + PKI + CT.

3.3 Web 把客户端拉成终端

  • 浏览器是 OS 上的一个 "虚拟机"; HTML/CSS/JS DOM/WASM/WebGPU 成熟.
  • 2024+ WebGPU 让浏览器可调 GPU 算 + WASI 让 WASM 调本地 file. 浏览器从文档看待器 → 通用客户端 → 实际计算平台.

3.4 网络对 DB / 应用的影响

  • DB 出现 single machine 邦联后就受网络影响; 网络 RTT 决定 commit 时间 → 数据库 replication 的延迟很关键.
  • 应用从 monolith → SOA → micro-service → service mesh 全由于网络可靠性 / IP/TLS / k8s / 等成熟.

四、数据库系统: 让状态在并发 / 故障下成立

4.1 为什么需要 DB 而不是文件

OS 文件系统只承诺 "字节流", 没说"原子写 / 多 client 并发 / crash 后一致". DB 把这些不变式钉死:

  • ACID (事务原子性 / 一致性 / 隔离性 / 持久性).
  • MVCC + WAL + 2PL + snapshot isolation.
  • 主流: PostgreSQL / MySQL (OLTP), ClickHouse / Snowflake (OLAP), Redis / Dynamo (KV).

→ 见 databases. 这是承接链 保存状态 的核心一层.

4.2 数据库如何依赖 OS / 网络 / 硬件

  • 依赖文件 + fsync 确保持久性 (write-ahead log); 这是个 OS 与块设备之间的契约. 一旦块设备"写入但未 flush" 破坏这一契约, ACID 就有空洞 (e.g. SSD cache 丢).
  • 依赖网络做 replication; latency → Paxos / Raft 单写 1 RTT trip.
  • 依赖 page cache / direct IO / io_uring / NVMe 多队列 → 与 OS / 块设备协同.

4.3 DB 是 web 后端的"状态核心"

  • Web 上层把所有"状态"丢 DB, 自己做无状态服务 + 容器; K8s + RDS 模型 一切都这么搭.
  • DB 与分布式系统的 cap 线 / 一致性范畴都来自这条承接.

五、编译器 / 高级语言: 让机器可读变成人可写

5.1 编译器史的简线

  • 1957 FORTRAN; 1972 C; 1990 GCC; 2000 LLVM; 2010 V8 Tiered JIT; 2020 Rust + LLVM + Miri.
  • 编译器把"人工可写"高级语言转到"机器可执行" ISA, 同时做优化: SSA / CFG / constant folding / dead-code / loop unroll / auto-vectorize / IPO.
  • compilers.

5.2 编译器承接了"OS 加上后程序可点点"

  • OS 提供 syscall ABI; 编译器知道怎么写出对 ABI 正确的代码 (caller/callee saved regs, stack frame).
  • 语言层 → 库 → syscall → kernel → driver.

5.3 高级语言运行时: 抽象 GC 与线程的延展

  • Java / Python / Go / Rust / JS 等, 加 GC / 协程 / async / await / Future / channel; 都把 OS 线程 + sqlite / pipe 的细节包到运行时.

六、分布式系统: 一台机器不够用后 N 台协同

6.1 必来

  • 单机扩展到 +10GHz 时 CPU 与内存墙都搅过; 一台机器能跑的 QPS / 数据量 / HA 都不可满足 → 必须多机.
  • 多机 = 网络 → 延迟 + 故障 + 异构 → 必须协议 (CAP / 一致性).
  • Multi-Paxos / Raft / Gossip + CRDT; 见 distributed.

6.2 共识 = 状态机复制的工具

  • 1990 Lamport Paxos; 2014 Raft.
  • 任何"被许多机器共享的状态"几乎都用这俩或其变种. -共识是承接链中调度多机, 把"不可靠网络 + 部分机器丢"转成"一组虚拟主机机".

6.3 一致性与时间

  • 物理时钟不可同步全 → logical clock (Lamport, vector clock, HLC) → 全序 / 偏序隔离.
  • TrueTime (Spanner) / HLC (CockroachDB) 借 GPS + 原子钟做单亿误差.

6.4 把"集群"抽象成"单机"

  • K8s 把 10000 台集群抽象成一组 control-plane API; 用户提 YAML 即一份声明式资源 → 内部 reconciliation loop → 自动调拨.
  • system-design/case/k8s-control-plane.

七、Web 后端 / 微服务 / 系统设计

7.1 估算 → 缓存 → 队列 → 限流

  • Little's Law + Little's Law → 平均 response = arrival_time / (1 - rho); back-of-envelope 计算 QPS / IOPS / 带宽 / 存储.
  • cache-aside / write-through / write-behind; 失效 (穿透 / 击穿 / 雪崩).
  • 队列 Kafka / Pulsar / SQS; at-least-once vs exactly-once; outbox pattern.

7.2 系统设计 = 反推与分解

  • 任务需求 → 估算 → 主组件 → 数据流 → 失败模式 → 可观测 → 上云.
  • system-design.

7.3 微服务网格的承接

  • 服务网格 (Istio / Linkerd / Envoy) 在 K8s 之上加: mTLS + routing + 重试 / timeout / 熔断 + observability + sidecar proxy.

八、AI: 状态机的"高维"模式化

8.1 它从哪里踏上承接链

  • 数学 (第零部分): 反向传播 + 链式法则 + softmax / SVD + 概率分布 → 是 AI 训练算法本身.
  • 硬件 (第八部分): GPU/TPU/NPU 异构芯片 + HBM + 互联 → 性能 + 显存决定 能不能训.
  • OS (第二部分): Linux + cgroup + nccl + RDMA → GPU 集群的互联.
  • 网络 (第三部分): RoCE v2 / nvBandwidth → 多卡通信快; HTTPS 推理 API.
  • 数据库 (第四部分): 训练数据集存储; vector database 给 LLM 工具调用 / RAG.
  • 分布式 (第六部分): model 并行 / data 并行 / pipeline 并行 / ZeRO-3 → 8 卡变 65K 卡.
  • 系统设计 (第七部分): inference service 设计 / KV cache 共享 / 多 tenant / autoscaling.

8.2 AI 反过来反推这条承接链

  • GPU/NPU 出来前: CPU 单线喂不饱; 现在 GPU 训练一批 7B 模型 1000 卡密集用; NCCL + RDMA + GPUDirect-RDMA 是新协议栈; 这把承接链的工具形态全部改.
  • LLM 推理: 把反向 AD 的 cache 改成 KV cache, 不是梯度是 attention 中间结果 → 这反过来推 HBM / CXL / 互联带宽.
  • Agent (2025+) 反推 RPC / tool/API 框架: MCP 协议让 LLM 配 SQL / fs / browser 成为可工程交互.

8.3 新承接: AI → 系统 / OS 的反向输入

  • GPU 集群调度: Borg/Omega/Kubernetes 都要支持 device plugin + topology-aware; Linux 内核对 GPU 的 cgroup 隔离还很弱 (2024+ 才有 NVIDIA MIG 与 DPDK 隔离成熟).
  • ML 监控与 trace: OpenTelemetry 增强 ML metrics; 单步训练几百次需 trace-blame 与 schema.
  • 推理直接接 OS syscall (e.g. AI tool use) → 安全沙箱新关注.

九、整个承接链图

物理 ─ 晶体管 ─ 数字 ─ ISA ─ microarch
                                  ↓
                            虚拟内存 + CPU cache
                                  ↓
            ┌───────── OS (Linux Kernel) ─────┐
            ↓             ↓                 ↓
        File System    网络 (TCP/IP)      进程 + 调度
            ↓             ↓                 ↓
        块设备 / SSD   HTTP / TLS          线程/协程
            ↓             ↓                 ↓
            DB          Web/REST        编译器 / 语言运行时
            ↓             ↓                 ↓
            OLAP / OLTP    LAMP/MEAN/MERN   JIT/HotSpot/V8
            ↓             ↓                 ↓
            分布式: KV / Queue / Consensus / K8s
            ↓             ↓                 ↓
───────────────────────────────────────────
                            ↓
                    AI: Training + Inference
                            ↓
                  GPU / NPU / TPU / 异构 XPU
                            ↓
                  HBM4 + NVLink + CXL 3.x
                            ↓
                  Agent + MCP + 推理 SDK
                            ↓
                  === 下一波新抽象 ===

每一节点都为前一节点顺承不变式; 跳一节点工程就断了.


十、五个"如果是新工程师, 你正在哪段承接"的练习

你做的事你主要在哪段承接
写 Web 后端 + DB RDSWeb 后端 / DB / OS (epoll+socket)
写 mobile app高级语言运行时 / Android/iOS SDK / NPU
写 CUDA kernel 训大模型物理层之上的 microarch / ISA (PTX) / AI 调度栈
写 Kubernetes operator分布式 / 系统 / OS
写 Linux 内核 driverOS / ISA / 微架构

→ 这套表帮你定位"读完哪几章本书就可以接入你工作".


十一、与第零部分数学的加乘

数学 (第零部分) 不在承接链有自己的节点 — 它横跨所有节点:

  • 第 1-2 物理层 - 信息论/熵与极限.
  • ISA 与微架构 - 不必数学.
  • OS / 调度 - 概率 + 排队 + 极限.
  • 网络 - 概率分布 + 信息论熵 / 信道容量.
  • DB - 关系代数 + 估计 + 一致性序.
  • 分布式 - 偏序 + 概率 + 信息论.
  • AI - 微积分 + 线代 + 概率 + 优化.

→ 这就是为什么数学放在第零部分: 每段承接都要用到, 不能单挑一节点上.


十二、结束 + 速查表

tip

一页唤回:

  • 承接链: 物理 → ISA → 微架构 → 内存 → OS → 进程 + 文件 + 网络 → 编译器 → DB → 分布式 → AI.
  • 每一段都需要上段提供契约; 没有它下段最后一段不动.
  • OS 给"进程 + 字节流文件 + socket"; 没它再好硬件也只是 DSP.
  • DB 提供并发 + 故障下状态一致; ACID = WAL + 2PL + MVCC 等.
  • 网络把孤立机器拥成集群; CAP / 共识 / 一致性序随之而来.
  • 分布式抽象集群为虚拟机; K8s + Raft + CRDT 这套套餐现成.
  • AI 反过来给承接链压力, 推 GPU/互联/HBM/CXL/MCP.
  • 数学横跨所有节点 = 第零部分必须放最前.

下一篇: 5. 全书地图: 13 部分与导论的交叉索引.

5. 全书地图: 14 部分与导论的交叉索引

TL;DR

导论卷最后一篇, 把书中 14 个部分 (导论卷 + 第零部分数学 + 13 主题) 全部丢到一套二维矩阵上, 让读者一眼定位"我读这章它在讲什么 / 在哪一站 / 在历史和抽象层级哪一格", 同时给出四类读者画像的纵贯走法、按"查新资料归属"的 SOP、以及每章入口建议.

读完应能:

  1. 不再在"现在我读哪章"上犹豫 — 用三张矩阵给任意新资料归位.
  2. 凭四个典型读者画像 (后端工程师 / 移动 App 工程师 / ML 工程师 / 硬件 / 嵌入式工程师) 各自挑通缩短到对应章节.
  3. 看到"我现在缺什么"立刻找书必看哪章.

一、合成的四视图

导论把全书骨架按 4 个相互正交的视图 抽出, 每视图各答一个问题:

视图章节答的问题
时间轴history这东西什么时候出现? 出现那一年的"硬件 + 软件 + 用户"三联状态是什么?
抽象层级abstraction-layers它在哪一层? 给上层什么契约? 依赖下层什么不变式?
形态演进mainframe-xpu它服务于哪类用户群? 用什么芯片 + 什么软件范式?
承接链standing-on-shoulders它的上一站契约是谁? 它对下一站指挥谁?

本页 (map) 再把上面 4 视图按"14 部分 × 4 视图"二维矩阵化, 让你能在矩阵里点一个格, 知道这格的全部上下文.


二、14 部分 × 抽象层级 矩阵

部分 / 抽象层1 物理2 模拟3 RTL4 microarch5 ISA6 内核7 POSIX8 运行时9 应用10 AI
导论
0 数学工具工具
1 DSA
2 OS
3 网络
4 DB
5 编译
6 分布式
7 系统设计
8 组成原理
9 计算理论
10 密码学
11 信息论
12 AI/ML
13 元抽象

符号说明:

  • 该部分主线章节在这层
  • 强相关 (但非主线)
  • 触及但不深入
  • 跨层桥接专项 (e.g. 信息论的 Shannon 容量把第 3 层 RTL 与第 10 层 AI 用同一组数学串起来)
  • 导论卷贯通所有层
  • 不涉及

怎么用这张表:

  • 横看一行: 一个部分主要钻研哪些层. 例: 第 2 OS 横跨 4-7 层 (microarch + ISA + kernel + POSIX); 第 8 组成原理横跨 1-5 (硬件视角); 第 12 AI/ML 纵跨 4-10 (训练靠 GPU + PyTorch 运行时 + 模型本身).
  • 纵看一列: 某抽象层被哪些部分讲解. 例: 第 4 层 microarch 主要由第 8 部分讲, 第 10 与第 11 部分做硬件级桥接 (Spectre 在第 10 侧信道里用, 第 11 LDPC/Polar Tanner 图里涉及微架构并发结构).
  • 空白诊断: 你接到一份任务, 想"我不熟第 X 层", 纵列扫立刻找到对应部分.

三、14 部分 × 形态演进 矩阵

部分 / 形态MainframeMiniPCMCUMobile/ARMCloud/WebAI/XPU
导论
0 数学工具
1 DSA
2 OS
3 网络
4 DB
5 编译
6 分布式
7 系统设计
8 组成原理
9 计算理论
10 密码学
11 信息论
12 AI/ML
13 元抽象

怎么用:

  • 一行告诉你这部分笔记主要面向哪种形态设计.
  • 例: 第 4 DB 在 Mainframe / Mini 时代主战, Cloud/Web 时代仍是后端核心; 第 12 AI/ML 几乎只在 AI/XPU 段才出现 (其前身可追到 1957 Rosenblatt perceptron, 但没 XPU 算力就上不了规模).
  • DSA / 计算理论 / 元抽象与硬件形态弱关联, 它们是"平台无关的抽象研究".

四、14 部分 × 历史年代 矩阵

部分 / 年代1936-4445-7071-9091-0606-1516-2324-26
导论
0 数学
1 DSA
2 OS
3 网络ARPANETQUICXDP/DPDK
4 DBNewSQLNewSQLVectorVector DB
5 编译GCC/LLVMJITMLIR
6 分布式
7 系统设计Operator+MCP
8 组成原理XPU+HBM4
9 计算理论
10 密码学ECC/ZKPTLS 1.3PQC
11 信息论LDPC/Polar深化
12 AI/ML
13 元抽象

符号: 该年代此部分是其最热的研究方向之一.

怎么用:

  • 纵看一列告诉你"在那个年代里跟上主流要读哪几章".
  • 例 2024-26 列: 主线 8 (XPU+HBM4) + 12 (AI/ML 大模型推理) + 7 (Operator+MCP) + 6 (分布式训练) + 5 (MLIR) 各章合 AI 故事; 11 (信息论深化) + 10 (PQC 抗量子) + 13 (元抽象) 给补底.
  • 横看一行告诉你"这部分在哪几段是 hot, 哪几段是支撑".

五、四类典型读者画像的纵贯走法

不同背景的人读完导论, 推荐的"主线缩短" 路径不同. 这里给 4 个画像 + 各自 4 视图的"主题必看" 与 "纵贯加速":

5.1 画像 A: Web 后端工程师

  • 你已熟: HTTP / SQL / Docker / K8s / 微服务
  • 你要补:
    • 横切主线: 第 3 网络 (networking/README.md) — 隧道深入 TCP/QUIC; 第 4 DB (databases/README.md); 第 6 分布式 (distributed/README.md); 第 7 系统设计 (system-design/README.md)
    • AI 主轴: 第 0 数学 → 线代概率 → 第 12 AI/ML (ai-ml/README.md) (训练 + 推理服务)
    • 纵贯加速: 第 2 OS (os/README.md) — linux 内核 epoll / io_uring 会让你 RPC / DB 性能提升
    • 形态演进: 第 8 组成原理里的 interconnects / memory-hierarchy — 知道 AWS Nitro / 阿里 CIPU 与 NVMe-of 在数据中心怎么排布
  • 预估时间: 上述 4 部主学科加 2 部辅助 ≈ 30-40 小时

5.2 画像 B: 移动 / App 工程师

  • 你已熟: Swift / Kotlin / Flutter / iOS / Android, app 架构
  • 你要补:
    • 横切主线: 第 8 组成原理 (computer-arch/README.md) — ARM v9 / NPU; 第 12 AI/ML (ai-ml/README.md) — 端侧推理 / Apple Intelligence / NPU 模型
    • AI 主轴: 第 0 数学概率 + 微积分 (训练后小程序); 第 12 foundations/transformer (端侧推理)
    • 纵贯加速: 第 2 OS (os/net/README.md) — 移动 OS 实际是 Linux + libc / Apple Darwin; 第 5 编译 (compilers/codegen/jit.md) — V8 / HotSpot / Swift runtime
    • 形态演进: 第 5 节 mainframe-xpu §6 ARM/移动
  • 预估时间: 30-50 小时

5.3 画像 C: ML 工程师

  • 你已熟: PyTorch / Transformer / 模型 fine-tune
  • 你要补:
    • 横切主线: 第 0 数学 ( пора → 已具备则 skip); 第 8 组成原理 (GPU SM / NPU / Tensor Core / HBM / 互联); 第 12 AI/ML 全部
    • 纵贯加速: 第 2 OS (NCCL / RDMA / GPU 隔离); 第 6 分布式 (ZeRO / Megatron / Pipeline 并行); 第 7 系统设计 (推理服务 + KV cache + 限流)
    • 承接链: 看 standing-on-shoulders §AI 主轴 — AI 训练不靠 Deng 基础承接链跑不动
    • 形态演进: AI/XPU 段全看 (mainframe-xpu.md#八阶段-7-ai--xpu-异构时代-2012-)
  • 预估时间: 50-80 小时 (这条最长, 因为 ML 是横切最广的方向)

5.4 画像 D: 硬件 / 嵌入式工程师

  • 你已熟: Verilog / RTL / MCU / 时序约束
  • 你要补:
    • 横切主线: 第 8 组成原理 (cpu-pipeline / superscalar / memory-hierarchy / gpu / ai-accelerators 全部); 第 2 OS (os/README.md) 看到硬件 / 内核 / 驱动的对接; 第 10 密码学 (crypto/sidechannel.md) Spectre / Meltdown; 第 11 信息论 (info-theory/README.md)
    • AI 主轴: 第 0 数学线代 + 概率 → 第 12 AI/ML (要知道 attention 张量形状如何映射到 Tensor Core / NPU)
    • 纵贯加速: 第 5 编译 (compilers/sema/ssa.md) MLIR / SSA 是硬件描述顶层; 第 9 计算理论 (theory/automata.md) DFA / FSM 与 RTL 状态机对偶
    • 形态演进: 全段都看, 你要纵串了
  • 预估时间: 60-80 小时

六、独立主线地图

6.1 横切 (按学科)

每部分一句话定位 + 入口页 + 推荐阅读时长:

部分一句话入口估读时长
0 数学离散+线代+概率+优化, 喂全书全部math/README8-12h
1 DSA数据形态 × 性能空间的设计空间dsa/README20-30h
2 OS把硬件封成可写代码的虚机器os/README15-25h
3 网络协议分层 + 拥塞 + modern stacknetworking/README15-20h
4 DB并发 / 故障下状态一致databases/README15-20h
5 编译把人话变机器码的流水线compilers/README15-20h
6 分布式让 N 台机器表现得像 1 台distributed/README12-18h
7 系统设计反推 + 分解 = 工程师能力核心system-design/README12-18h
8 组成原理从晶体管到 XPU 的硬件栈computer-arch/README20-30h
9 计算理论什么是可计算 / 高效可计算theory/README10-15h
10 密码学把对抗难度转移为协议设计目标crypto/README12-18h
11 信息论Shannon 极限的工程化info-theory/README12-15h
12 AI/ML数据替代显式规则的程序写法ai-ml/README15-25h
13 元抽象把 1-12 横通, 上钻一层_meta/README6-10h

总计: ≈ 200-280 小时通读全书完整深度 ≈ 11-15 周 (周 20 小时). 但你不必通读, 各画像只读对应 4-6 部即可入门.

6.2 纵贯线: 不同形态的"一台设备走通 14 部分"

形态演进每个段都给出"在那段你手中, 这套抽象层级怎么落下来":

A. Large-scale 训练集群 (2026 H100/H200/B200/GB200 pod)

物理 (1)       HGx 中心机房供电 200 MW; 水冷闭环; HBM4 stack 工艺
模拟 (2)       SRAM cell 在 SRAM cache bank
RTL  (3)       NVIDIA SM 内部 RTL; NV-HBI 双 die 互连; CXL 3.0 PHY
microarch(4)   SM + Tensor Core + Warp scheduler + 内存 pool; HBM3e/4
ISA   (5)      PTX + SASS; host x86-64 / ARM / GPU kernel
kernel(6)      Linux + NCCL 2.x + MOFED + eBPF + io_uring
POSIX (7)      GPU driver + RDMA verbs + epoll + io_uring_setup
运行时(8)      PyTorch (Python+Triton) + CUDA  + cuDNN + cuBLAS
应用 (9)       Megatron + DeepSpeed + Ray + Wandb 监控
AI   (10)      Transformer 训练 / 扩散 / VAE / 推理优化

涉及的本书部分: 8 + 2 + 5 + 6 + 7 + 12. 这条线就是 standing-on-shoulders §AI 主轴 的硬件对照版.

B. 你口袋的 iPhone / Android 手机 (2026)

物理 (1)       A18 / Snapdragon 8 Elite 3nm 工艺
模拟 (2)       LPDDR5X + PMIC + PMU + OMAP
RTL  (3)       Apple P-core + E-core + GPU + NPU RTL
microarch(4)   OoO + 深流水 + 共享 L2 + imprecise branch pred
ISA   (5)      ARM v9 / SVE2
kernel(6)      Darwin XNU / Linux 6.x + Android
POSIX (7)      bionic libc + Mach-O + libdispatch + ulib
运行时(8)      Swift / Kotlin / JNI + ART JIT + WebKit JIT
应用 (9)       iOS App / Android App; WebGPU in browser
AI   (10)      Apple Intelligence / Google Gemini Nano 端侧推理

涉及的部分: 8 + 2 + 5 + 12 + (3 网络部分, 因 app 都用 HTTPS). 这就是 mainframe-xpu §6 ARM/移动 一个具体例子.

C. 你公司后台的云原生服务 (2026)

物理 (1)      云数据中心; SSD / nvme-of
模拟 (2)      HBM on cache-LLC; DRAM
RTL  (3)      (N/A 该层工程几乎不接触)
microarch(4)  x86 / Graviton / Xeon-D 多核 + AVX-512 / AMX
ISA   (5)     x86-64 / ARM v9
kernel(6)     Linux + cgroup v2 + io_uring + eBPF
POSIX (7)     rsyscall + epoll_wait; gVisor / Firecracker
运行时(8)     Go runtime + Java Hot + V8 + containerd
应用 (9)      K8s + envoy + prometheus + gRPC + DB
AI   (10)     LLm tool use + RAG + 推理服务 + observability

涉及的部分: 2 + 3 + 4 + 6 + 7 + 12. 这是大多数 Web 后端工程师日常的纵贯.

D. 智能家电 / IoT 端节点 (2026)

物理 (1)    55nm MCU / 22nm RF SoC
模拟 (2)    ADC + DAC + LDO + RF front-end
RTL  (3)    Cortex-M33 RTL + BLE 控制器
microarch(4) 2-4 stage pipeline + FPU + TCM + NVIC
ISA   (5)   ARMv8-M / RISC-V RV32
kernel(6)   Zephyr / FreeRTOS / 无 OS
POSIX (7)    (部分实现 POSIX 子集)
运行时(8)   MicroPython / Rust no_std + embassy
应用 (9)    sensor 驱动 + MQTT 客户端; edge ML 推理
AI   (10)   小模型 on TFLite Micro / CMSIS-NN / NPU

涉及的部分: 8 + 2 + 5 (轻) + 12 (轻). 形态见 mainframe-xpu §5 单片机.

6.3 AI 主轴 (2024-2026 这三年最热路径)

0 数学 ─→ 8 组成原理 (GPU/NPU/HBM/互联) ─→ 2 OS (io_uring/RDMA)
                                                     │
                                                     ↓
5 编译(MLIR) ←─ 4 DB (vector DB) ←─ 6 分布式(ZeRO/Pipeline)
                                                     │
                                                     ↓
                          7 系统设计(推理服务) ─→ 12 AI/ML (训练+推理)
                                                     │
                                                     ↓
                                          13 元抽象 (hardware → software)

任意一段 missing 都读不下去. 这条线已经实际超过其他横切主线单条的总人数 (2024+ LLM / GenAI 工程师数 ~500 万 vs 传统 web 后端 ~1500 万, 但 AI 主轴增长更快).


七、看新资料归位的 SOP

下面是用这页矩阵给任意新文档归位的 5 步操作:

Step 1. 看"它讲的是什么主概念" → 在 §2 横切里挑 1-3 个匹配部分
Step 2. 看"它针对哪种设备 / 用户" → 在 §3 形态里挑 1-2 个匹配
Step 3. 看"它大致什么时代出现 / hot" → 在 §4 年代里挑 1 个匹配
Step 4. 看主要部分字:
  - "GPU/NPU/HBM/CXL"  → §8 + §12
  - "kernel / syscall / io_uring / eBPF" → §2
  - "TCP / QUIC / TLS / HTTP" → §3
  - "schema / SQL / WAL / MVCC / LSM" → §4
  - "AST / IR / SSA / register alloc" → §5
  - "Paxos / Raft / quorum / CRDT" → §6
  - "cache / queue / sharding / rate limit" → §7
  - "transistor / IS pipeline / SIMD" → §8
  - "DFA / PDA / P vs NP / Turing" → §9
  - "AES / RSA / ECC / ECDHE / ZKP" → §10
  - "entropy / capacity / LDPC / Polar" → §11
  - "softmax / Transformer / VAE / diffusion / Adam" → §12
  - "amortized vs worst / 为什么 deep net 可训" → §13
Step 5. 在矩阵上行+列打个 ●; 读完这页就画上 ●.

示例: 给你一份"Groq LPU 推理 7B 模型 benchmark 白皮书". 走完 5 步:

  1. 主概念 = 推理 ASIC + LLM → §8 + §12
  2. 设备 = XPU 数据中心 → §3 形态 AI/XPU
  3. 时代 = 2024-26 → §4 年代号
  4. 关键字 = "LPU / Tensor Core / 互联带宽" → §8
  5. 矩阵定位 = (§8, §12 行 vs AI/XPU 列 vs 2024-26 列).

→ 立刻知道你必须先读第 8 组织原理响应章节 + 第 12 transformer 章节, 才不再卡白皮书里 "macro-op fusion vs VLIW" 类词.


八、阅读模型

读完导论 5 章, 你应该形成如下"心智图":

  • 看一份新文档 / 论文 / 白皮书, 你能在 30 秒内:
    1. 把它的主题归类到某些部分 (横切)
    2. 把它的硬件代归类到某段形态 (纵贯)
    3. 把它入口处的定义对应到某个抽象层
    4. 识别它依赖的契约以及可能泄漏源

这就是"计算机基础知识体系"的本质 — 任何进步的动作都是"在一层抽象里看到一个新机制", 而不是凭着名词链条不停换字眼.


九、与导论各章的接口

导论章节它给本页提供什么
history时间轴内容 → 填 §4 年代矩阵
abstraction-layers10 层金字塔 → 填 §2 抽象层级矩阵
mainframe-xpu7 段形态演进 → 填 §3 形态矩阵
standing-on-shoulders承接链 → 填 §6.2 不同形态纵贯
本页 map把上述 4 视图对齐到 14 部分, 给查表 SOP

十、给导论一份结束

读完导论 5 章, 你应该能在矩阵的 56 格 (14 部分 × 4 视图) 中为任意一份新材料归位, 再下钻各主题时不再"信息孤岛". 这就是这套笔记"由导论到 13 主题" 的阅读模型.


十一、结束 + 速查表

tip

一页快速唤回:

  • 三张矩阵: 14 部分 × (抽象层级 / 形态演进 / 年代). 一行 = 一个部分的"在哪几个轴有重心"; 一列 = 一个轴上"哪几个部分热门".
  • 四类读者画像: web 后端 / 移动 app / ML / 硬件嵌入式 — 四条缩短的纵贯路径.
  • AI 主轴 (2024-26): 0 → 8 → 2 → 5 → 4 → 6 → 7 → 12 → 13 — 是当前最热的工程栈.
  • SOP: 看一份新文档 5 步归位 (主概念 → 形态 → 年代 → 关键词 → 矩阵 ●).
  • 全书读完时: 用矩阵把每格都打 ●, 你就完成了"计算机基础知识体系"的入门.

回主目录:

学习笔记 · 心得记录

随读随记:在正文任意位置用鼠标选中文字,就会出现"📝 记笔记"工具条;笔记锚定到选中文本,刷新页面后自动重新高亮。数据默认只存在你的浏览器 localStorage(不注册、无账号、无数据库、别人看不到);需要沉淀时一键导出为 GitHub Issue,或复制 Markdown 粘贴到 Discussions / 任意笔记软件。

这个工具能做什么

动作说明
划词记笔记选中正文文字 → 点工具条"记笔记"→ 写下心得保存;选中处立即高亮
查看 / 编辑点高亮文字可编辑该条笔记;右下角 📝 面板可看"本页 / 全部"
复制 Markdown一键复制带页面来源和划词引用的 Markdown,可粘贴到 GitHub Discussions
导出 Issue预填 issues/new?title=…&body=…,登录 GitHub 后确认即发布,无需 token
删除单条删除(有确认),对应高亮一并移除;不提供"清空",防误删

高亮是怎么实现的

笔记保存时记录选中文字在正文 #content 里的字符起止偏移,刷新页面后按偏移重新包裹 <mark>。局限:

  • 偏移基于当前页面文本;如果这本书的内容后来被修改(比如某章重写),旧笔记的偏移可能错位或不再高亮,但笔记内容和原文都还在面板里,复制/导出不受影响;
  • 跨多个连续段落的选择会按文本节点分段高亮,显示为一段连续的黄色底纹。

数据存在哪

  • localStorage:键为 cs-notes:v1:<页面路径>,只在你当前浏览器。换设备、清缓存、开无痕即不可见——它是"草稿区",不是存档。
  • GitHub Issues:导出后存在仓库 Issues 里,可搜索、打标签、转 PR,随仓库一起版本化。
  • GitHub Discussions:GitHub 没有"预填新建讨论"的链接,所以这里用"复制 Markdown"代替——粘贴发布即可;若想"一键发 Discussion",后续可以注册免费 OAuth App 走 GraphQL API(本工具暂不内置)。

把笔记变成正式章节

笔记面板里的内容只是草稿。想让它成为这本书的一部分(被搜索、随 GitHub Actions 部署、保留版本历史):

  1. 把内容整理成 Markdown,追加到对应部分的章节文件,或新建文件;
  2. 挂进 SUMMARY.md
  3. commit + push,Actions 自动构建部署到 GitHub Pages。

实现与配置

  • 组件:notes.js(无依赖原生 JS,样式内联)。
  • 挂载:book.toml[output.html]additional-js = ["notes.js"]
  • 目标仓库:notes.js 顶部的 GITHUB_REPO 常量,导出 Issue 时指向该仓库。

心得日志

把"想明白的时刻"沉淀成条目。这里的每条心得都是提交进仓库的正式内容(和 localStorage 草稿不同),会随书一起部署并保留版本历史——适合作为长期回顾的思考档案。

怎么写一条心得

### YYYY-MM-DD · <一句话主题>

- **触发**: 读到 / 遇到什么问题
- **结论**: 想明白的那件事(一句话点破质变)
- **反例 / 坑**: 曾经错在哪、被什么误导
- **链接**: 相关章节 / 页面

条目

2026-08-02 · 示例:从"看历史"到"看契约"

  • 触发: 读导论卷 history时,发现 2025-2026 的内容需要按 2026-08 公开资料核对。
  • 结论: 每个时代节点的质变不在新硬件 / 新模型本身,而在它改写了哪条"契约"——例如 HBM4 把单 stack 带宽从 ~1.2 TB/s 抬到 ~2 TB/s,改变的是 memory-bound 训练的边界。
  • 反例 / 坑: 凭记忆写"单 stack 384 GB/s"和"~100 logical qubit 已抵达"都错了;落笔前先查原厂 release note / 论文。
  • 链接: prologue/history.md发展史 TL;DR 对应 2025-2026 小节。

后续条目直接往"## 条目"下追加即可。格式不限,能让自己三个月后回看时重新想起来就够了。

第零部分 · 工程数学与离散数学基础

一句话

这份笔记剩下十二部分——从 DSA 的复杂度、OS 的排队论、DB 的概率代价模型、Compiler 的图论支配点, 到 Crypto 的数论/有限域、信息论的 Shannon 熵、未来 Transformer 章节的张量微积分与反向传播——反复在用同一组数学: 离散结构 + 线性代数 + 概率统计 + 微积分/优化. 把这四件工具抽到一处讲透, 后面每章节用到时只管引用, 数学再也不是瓶颈. 本部分不做教材复读机, 只覆盖读后面 CS 主线 + 读 Transformer / 信息论 / 密码学原论文必需的下限, 每个概念都标明喂给哪一章.

思想链

[你在 DSA 章里看到 "Master Theorem: T(n) = a T(n/b) + f(n)"]
  └─> 这是递推 + 指对数变换 → 离散数学篇的 "递推与生成函数" 讲透
        └─> [在 Compiler 章看到 "支配树 (dominator tree) 是 DAG"]
              └─> 图论 + 偏序集 → 离散数学篇的 "图与关系" 讲透
                    └─> [在 DB 章看到 "选择度cardinality estimation 用 sampling"]
                          └─> 概率分布 + 估计理论 → 概率篇讲透
                                └─> [在 Crypto 章看到 "椭圆曲线群上的离散对数难"]
                                      └─> 群/环/域 + 模运算 → 离散代数 → 离散篇讲透
                                            └─> [在信息论章看到 "I(X;Y) = H(X) − H(X|Y)"]
                                                  └─> 期望 + 凸函数 → 概率篇讲透
                                                        └─> [你将来读 Transformer 原文]
                                                              └─> softmax · 雅可比 · 链式法则 · KL 散度
                                                                    └─> 张量 · SVD · Hessian · 凸优化
                                                                          └─> 全部在本部分四篇里
                                                                                └─> 读完此部分, 后续数学不是瓶颈

你将带走什么

读完应能:

  1. 看到任意递推式 (Master / Akra-Bazzi) 立刻报出复杂度; 看见 $\sum$/$\prod$/$\binom{n}{k}$ 不再卡.
  2. 把"集合 / 关系 / 函数 / 等价类 / 偏序 / 闭包" 当作同一组离散对象操作; 一条 SQL join、一个 git rebase 的偏序图、一个 type hierarchy 在你眼里是同构的.
  3. 看到任意矩阵 $A \in \mathbb{R}^{m \times n}$ 能立刻说: 它的秩 / 列空间 / 零空间 / SVD / 谱范数是什么; 看见 "$A$ 是正定" 就知道所有几何与优化推论.
  4. 给一个概率模型能直接写: 似然 $\mathcal{L}(\theta)$、MLE 闭式解、MAP 的先验怎么选、KL 散度方向. 大数定律、中心极限定理不背书能推.
  5. 拿一个 loss 函数能徒手做反向传播: 链式法则 → 雅可比 → 梯度. 看到 Hessian 知道曲率与收敛速度, 看到 KL 知道信息几何度量.
  6. 看见论文里的 $\nabla_\theta \mathcal{L}$, $\mathbb{E}{z \sim q\phi}$, $\arg\max$, $\mathrm{softmax}(x_i) = \frac{e^{x_i}}{\sum_j e^{x_j}}$ 不再绕路查, 直接进语义.

章节结构

一句话定位各篇

读完即解锁后面
离散数学DSA 全部 / Compiler 的 CFG 与支配树 / Crypto 的群环域 / OS 的偏序调度 / Distributed 的逻辑时钟与序
线性代数OS page rank / DB 列存向量化 / Info-theory 的信道矩阵 / Transformer 的 QKV 矩阵与多头注意力 / PCA
概率统计OS 排队论 / DB 代价估计 / Distributed 选举与 quorum / Crypto 语义安全 / Info-theory 熵与编码 / Bayesian 网络
微积分与优化OS 控制论 / Compiler 的 strength reduction / Info-theory 容量优化 / ML 反向传播与 SGD / 信息几何

与后续各部分的接口表

后续部分用到的本部分章节典型概念
第一 · DSA离散 4/5/6 (图/组合/递推)复杂度主定理, 排列组合计数, 图遍历
第二 · OS概率 5 (极限) + 离散 4 (偏序)排队论, 调度 DAG, working set
第三 · 网络概率 4 (Bayes) + 离散 5网络流, 拥塞控制微分方程
第四 · DB概率 3 (估计) + 离散 6 (关系)关系代数, cardinality sampling, MVCC 序
第五 · Compiler离散 3/4/6 + 线代图可达性, 支配树, SSA, 寄存器图着色
第六 · 分布式离散 4 (偏序) + 概率 3因果序, FLP 不可能, quorum 概率
第七 · 系统设计概率 5 + 线代 5Little's Law, 幂律分布, Cache 命中率
第八 · 组成原理线代 4 + 离散 5流水线冒险的布尔逻辑, DMA
第九 · 计算理论离散 1 + 5形式语言, 归约, 复杂度类
第十 · 密码学离散 6 (代数) + 概率 4有限域, 离散对数, 语义安全
第十一 · 信息论概率 1/2/5 + 微积分 3熵, 凸函数 Jensen, 容量优化
未来 · ML/Transformer线代 6 (张量) + 微积分 2/3/4softmax Jacobian, attention 矩阵, 反向传播, 各类优化器

数学记号约定

本部分及后续章节统一:

记号含义
$\mathbb{N}, \mathbb{Z}, \mathbb{Q}, \mathbb{R}, \mathbb{C}$自然 / 整数 / 有理 / 实 / 复数集
$\mathbb{R}^n, \mathbb{R}^{m \times n}$$n$ 维实列向量 / $m \times n$ 实矩阵
$\mathbf{1}_A, \mathbb{1}{P}$集合 $A$ 的指示函数 / 事件 $P$ 的指示变量
$|x|_p$$L_p$ 范数; $|x|_2$ 欧式, $|x|1$ 曼哈顿, $|x|\infty$ 无穷
$\langle x, y\rangle$内积 (默认标准内积)
$A^\top, A^{-1}, A^+, \det A, \operatorname{tr} A$转置 / 逆 / 伪逆 / 行列式 / 迹
$\lambda_{\max}(A), \sigma_{\max}(A)$最大特征值 / 最大奇异值
$\Pr[E], \mathbb{E}[X], \operatorname{Var}(X)$概率 / 期望 / 方差
$X \sim \mathcal{N}(\mu, \sigma^2)$$X$ 服从正态分布
$\nabla f, \nabla^2 f$梯度 / Hessian
$O(\cdot), \Theta(\cdot), \Omega(\cdot), o(\cdot)$渐进上/紧/下/严格低界
$\equiv, \Leftrightarrow, \Rightarrow$等价 / 充要 / 推出
$\sum, \prod, \int$求和 / 求积 / 积分

历史 1: 数学从「展演」到「工具」

直到 19 世纪末, 数学主要被看作「展演正确性」的几何 (Euclid, Hill-bert纲领). 转折来自:

  • Cantor (1874) 集合论, 把"对象"抽象成元素归类, 离散与连续统一描述.
  • Boole (1847) 把命题逻辑符号化; Frege (1879) 量词引入谓词逻辑.
  • Galois (1832) 把方程根的对称性抽象成"群", 开启代数结构时代.
  • Shannon (1937) 在硕士论文用 Boole 描述电路, 把逻辑与硬件从此打通.
  • von Neumann (1945) 用矩阵描述量子; 后续与 Turing/Gödel 把可计算性数学化.

几乎所有"现代"概念——状态机、加密、调度、神经元——本质都是上述四个抽象在不同语境的复用.

历史 2: 计算机"故意"用最少的数学

计算机绕开重数学的几次关键:

  • Dijkstra 把分布算法从微积分里抽出来, 用 == 不变式与最弱前置条件证明.
  • Turing 把"计算"压成纸带符号集, 所以 TM 用最基本集合+函数即可定义.
  • Curry-Howard 把逻辑证明和程序等价, ML 派系不需要范畴论也能用.
  • Cloud Native 用声明式 schema (YAML/HCL) 把不变式显式化, 无需要证.

结论: 本部分讲解数学, 不为「让你在每行注释里推演」, 而为「让你看懂前面十二部分为何能这样设计, 并在未来读论文时不在记号处卡住」.

阅读路径推荐

按背景三选一:

  1. DSA 已熟 + 看论文吃力: 先看 2 线性代数3 概率统计, 再看 4 微积分.
  2. 应用背景 + 没系统学过理论: 从 1 离散数学 入, 它最贴近 DSA 与 Compiler.
  3. 学习 Transformer 优先: 走 2 → 3 → 4 这条线, 离散可以暂时跳过.

与 TODO 的关系

TODO.md 中列的"未来可继续扩展方向 AI/ML 理论与实践(含 attention/Transformer 数学)"的前半——attention 数学——的预备知识全在本部分. 未来 ML 章节直接引用本部分 §2/§3/§4 即可, 不再重头讲线代与反向传播.


下一篇: 1. 离散数学: 逻辑 / 集合 / 关系 / 图 / 组合 / 递推 / 代数结构.

1. 离散数学: 逻辑 / 集合 / 关系 / 图 / 组合 / 递推 / 代数结构

TL;DR

计算机里几乎所有"结构"——状态、链接、调度、协议、类型、密钥——都是离散对象 + 关系。这一篇覆盖 7 个相互串接的核心:

  1. 命题与谓词逻辑 — 程序不变式、Hoare 三元组的语言.
  2. 集合 — 元素 + 隶属; 一切数据结构的最朴素的容器.
  3. 关系与函数git rebase、外键、MRO 看似无关其实同构.
  4. — DSA 第 5 章、OS 调度、Compiler CFG、Distributed 一致性都靠它.
  5. 组合计数 — $\binom{n}{k}$、$\sum$、容斥; DSA 与概率论的中间地带.
  6. 递推与生成函数 — 复杂度主定理、Fibonacci、Catalan 的统一视角.
  7. 代数结构: 群环域 — Crypto 全部、信息论 BCH/RS 编码的硬基础.

目标: 让你看 DFAgit merge-base FOREIGN KEYECDSA over secp256k1Master Theorem 时不再"知道但说不清".


一、命题与谓词逻辑

1.1 命题联结词

联结符号真值表
$\neg P$翻转
$P \land Q$同真
$P \lor Q$至少一真
蕴含$P \Rightarrow Q$"若 P 则 Q"; 只在 P 真 Q 假时为假
等价$P \Leftrightarrow Q$同真假
异或$P \oplus Q$不同真

工程翻译:

  • if (P) {...} 的前置条件是 $\neg P$ 表示"程序不变式未维护".
  • &&, || 短路求值 ($P \land Q$ 在 P 假时 Q 不必算) ⊆ 严格命题逻辑.
  • 异或 $\oplus$ 是密码学奇偶检验、FEC parityCRC 的核心.

1.2 关键恒等式

$$ P \Rightarrow Q ;\equiv; \neg P \lor Q ;\equiv; \neg Q \Rightarrow \neg P \quad (\text{逆否}) $$

$$ \neg(P \land Q) ;\equiv; \neg P \lor \neg Q \qquad \text{(De Morgan)} $$

$$ \neg(P \lor Q) ;\equiv; \neg P \land \neg Q $$

$$ (P \Rightarrow Q) \land (P \Rightarrow R) ;\equiv; P \Rightarrow (Q \land R) $$

1.3 谓词逻辑: 量词

加入"对所有 / 存在":

  • $\forall x \in S: P(x)$ — 对所有 $x$ 满足.
  • $\exists x \in S: P(x)$ — 至少一个满足.

否定翻转量词 (这是最容易出错的):

$$ \neg \big( \forall x: P(x) \big) ;\equiv; \exists x: \neg P(x) $$ $$ \neg \big( \exists x: P(x) \big) ;\equiv; \forall x: \neg P(x) $$

: $\forall T \in \text{Threads}: \text{safe}(T)$ 的反面是"$\exists T$ 不safe", 即"有 bug 时的反例".

1.4 与程序的不变式

Hoare 三元组 ${P}\ S\ {Q}$: 若执行前 $P$ 真, 则 $S$ 终止后 $Q$ 真.

最弱前置条件 $\mathrm{wp}(S, Q)$ 是所有使 $S$ 终止后满足 $Q$ 的最弱前置. Dijkstra 用它做了程序演算, 把程序证明转为公式演算.

note

在 Compiler 章节 ( SSA / CFG / 支配树) 里你会反复看到"边和分支谓词"; RISC-V beq/bne 指令对应谓词逻辑上的原子命题比较.


二、集合

2.1 基本定义

  • 元素 $a \in A$, 空集 $\emptyset = {}$.
  • 子集 $A \subseteq B$: $\forall x \in A \Rightarrow x \in B$.
  • 真子集 $A \subsetneq B$: 子集且 $A \neq B$.
  • 集合大小 $|A|$, 幂集 $\mathcal{P}(A) = 2^A$, $|2^A| = 2^{|A|}$.

2.2 集合运算

运算记号内涵
$A \cup B$至少其一
$A \cap B$都在
$A \setminus B$A 中但不在 B
对称差$A \triangle B$恰在一个; $= (A \setminus B) \cup (B \setminus A)$
笛卡尔积$A \times B$有序对 $(a, b)$

$$ |A \cup B| = |A| + |B| - |A \cap B| \quad (\text{容斥}) $$

2.3 与类型的关系

类型系统里的 Set<T> / Option<T> / &'a T 都可视为带约束的集合:

  • Option<T> ≡ ${\bot} \cup T$.
  • enum ≡ 不相交并 (tagged union).
  • struct { A, B } ≡ 笛卡尔积.
  • &'a T ≡ $T$ 受 lifetime 'a 约束子集.

tip

Curry-Howard: 命题 = 类型, 证明 = 程序. $\land$ ↔ tuple, $\lor$ ↔ enum, $\Rightarrow$ ↔ 函数类型. Rust 的 Result<T, E> 就是命题"$T$ 或 $E$".


三、关系与函数

3.1 关系 (二元关系)

$R \subseteq A \times B$ 是一个二元关系. 当 $A = B$, 称 $R$ 是 $A$ 上的关系. 记 $a R b$ 表 $(a, b) \in R$.

五大性质:

性质定义工程例子
自反$\forall a: a R a$$=$、$\leq$、$\to$ 自闭包
反自反$\forall a: \neg(a R a)$$<$, 严格偏序
对称$a R b \Rightarrow b R a$"朋友" / 无向边
反对称$a R b \land b R a \Rightarrow a = b$$\leq$, $\subseteq$
传递$a R b \land b R c \Rightarrow a R c$$<$, $\to$, $\sqsubseteq$

3.2 三种关键关系

  • 等价关系: 自反 + 对称 + 传递 → 把集合拆成不相交等价类. 商集 $A / R$ 即等价类集合.
    • 例: 整数模 $n$ 的同余 $\equiv_n$ → $\mathbb{Z}/n\mathbb{Z}$.
    • 例: Rust Hash + Eq 把同一桶当成等价类.
  • 偏序 (partial order): 自反 + 反对称 + 传递. 记 $\leq$.
    • 例: git commits 的祖先关系 ($\text{merge-base}$ 是下确界).
    • 例: 多继承 MRO (C3 linearization) 即偏序的拓扑序.
    • 例: 集合包含 $\subseteq$.
  • 全序: 偏序 + 任两元素可比. 例: $\leq$ on $\mathbb{Z}$.

3.3 闭包

最小扩 $R$ 以满足某性质的最小关系:

  • 自反闭包: 加 ${(a, a) : a \in A}$.
  • 对称闭包: 加 ${(b, a) : (a, b) \in R}$.
  • 传递闭包: $R^+ = \bigcup_{k \geq 1} R^k$ (关系合成 $k$ 次).

note

Warshall 算法 (Floyd-Warshall 的"非加权版") 求传递闭包 $O(n^3)$. 在 Compiler 的可达分析里这是 SSA 的 phi 节点注入候选计算.

3.4 函数

$f: A \to B$ 是 $A \times B$ 的特殊关系: 每一 $a \in A$ 恰有一个 $b = f(a) \in B$.

  • 单射 (injective): 不同 $a$ 映到不同 $b$.
  • 满射 (surjective): $f(A) = B$.
  • 双射 (bijective): 单 + 满; 存在逆 $f^{-1}$.

: 哈希函数 h: Key → Slot 不是单射 (满射/碰撞), 退化为多值; 完美哈希才是双射.

warning

Map<K, V> 内部哈希函数 $h$ 不是数学意义的函数吗? 是, 严格定义 $h$ 在给定 key 上确定地映射到某 slot (确定性). 但碰撞让它退化为"多对一"——单射失败的来源. 后果在 hash-table 章节 详细讨论.


四、图

4.1 形式化

图 $G = (V, E)$, $E \subseteq V \times V$ (有向) 或 $E \subseteq \binom{V}{2}$ (无向). 加权则 $w: E \to \mathbb{R}$.

几种特殊图:

性质出现在
DAG有向无环Compiler CFG (含回边不同步), git, npm deps
连通无环决策树、AST、DOM
二部图$V = A \cup B$, $E \subseteq A \times B$匹配、二分图最大流
完全图 $K_n$所有边DNS 全连接集群
$k$-正则每点度 $k$Tornado (mixer)、P2P

4.2 关键概念

  • : $d(v)$ = 邻接边数; $\sum_{v \in V} d(v) = 2|E|$. (握手引理)
  • 路径与环: 路径不重复边 = trail; 不重复点 = simple.
  • 连通分量: 无向图的最大连通子图.
  • 生成树: 连通图的极小连通子图; $|T| = |V| - 1$.
  • 强连通分量 (SCC): 有向图里每两点互达; Tarjan $O(V+E)$.
  • 入度/出度: 有向图; $d_{\text{in}}(v), d_{\text{out}}(v)$.
  • 拓扑序: DAG 上 $\forall (u, v) \in E: u <_{\text{topo}} v$.

4.3 图表示与遍历

按工程取舍选择:

表示空间邻接查询适合
邻接矩阵$\Theta(V^2)$$O(1)$稠密图, Floyd, 传递闭包
邻接表$\Theta(V + E)$$O(d(v))$稀疏, social, web
边表$\Theta(E)$$\Theta(E)$Kruskal
CSR (compressed sparse row)$\Theta(V + E)$$O(d(v))$ 缓存友好GPU, 大图计算

遍历:

  • DFS 用栈 / 递归; 可判连通、拓扑序、SCC. $\Theta(V + E)$.
  • BFS 用队列; 给最短路 (无权) 与层序. $\Theta(V + E)$.

note

Tarjan 的 SCC、Dijkstra 的 lazy-pop、A* 的 priority queue 都把"图遍历 + 谓词顺序"统一为同一框架. 这是 DSA 第 5 章的核心.

4.4 经典定理: 欧拉回路判定

判定: 连通图存在欧拉回路 $\Leftrightarrow$ 每点度偶数; 存在欧拉路径 $\Leftrightarrow$ 恰 0 或 2 点奇度.

应用: DNA 片段拼接 (de Bruijn graph)、LeetCode "reconstruct itinerary".

4.5 二部图与匹配

  • 二部图判定: BFS / DFS 二着色 (无奇环).
  • 最大匹配在二部图 = Hopcroft-Karp $O(E \sqrt V)$; 无向一般图 = Edmonds blossom $O(V^2 E)$.
  • 最大流模型统一 matches、assignment、Hall 婚姻定理.

五、组合计数

5.1 四大基本法则

法则公式用法
加法法则$|A \cup B| = |A| + |B|$ ($A, B$ 不交)不同 case 计数
乘法法则$|A \times B| = |A| \cdot |B|$组合状态
包含-排除 (容斥)$|\cup_i A_i| = \sum |A_i| - \sum |A_i \cap A_j| + \cdots$错排、棋盘
鸽巢原理$A

5.2 排列与组合

  • 排列 (有顺序): $A_n^k = P(n, k) = \dfrac{n!}{(n-k)!}$.
  • 组合 (无序): $\binom{n}{k} = \dfrac{n!}{k!(n-k)!}$.
  • $k$-可重组合 (放回抽样): $\binom{n+k-1}{k}$.
  • 全排列: $n!$; $k$-可重排列: $n^k$.

重要恒等 (Pascal):

$$ \binom{n}{k} = \binom{n-1}{k-1} + \binom{n-1}{k} $$

Vandermonde:

$$ \binom{m+n}{k} = \sum_{i=0}^k \binom{m}{i}\binom{n}{k-i} $$

5.3 高级工具

  • 生成函数: 数列 ${a_n}$ 的普通生成函数 $G(x) = \sum_n a_n x^n$. 组合恒等可代数推导.
  • 指数生成函数: $E(x) = \sum_n a_n \dfrac{x^n}{n!}$; 排列场景.
  • 容斥原理: 子集加减交错, 解决 "恰 k 个性质" 问题.

5.4 重要组合数

数列闭式 / 递推用途
Catalan $C_n = \frac{1}{n+1}\binom{2n}{n}$$C_{n+1} = \sum_{i+j=n} C_i C_j$AST 计数、合法括号
Stirling (无符号) ${n \brace k}$$n$ 元素分 $k$ 不空子集Partitions
Stirling (有符号) $[n \atop k]$$n$ 元素分 $k$ 圈Permutations
Bell $B_n = \sum_k {n \brace k}$全集划分等价关系总数

5.5 朴素思路估复杂度

形式量级
$n!$ (permute all)$n=10$ → $10^7$, $n=20$ → $10^{18}$
$2^n$ (subset)$n=20$ → $10^6$, $n=40$ → $10^{12}$
$n^2$$n=10^4$ → $10^8$ (1s), $n=10^5$ → $10^{10}$ (太慢)
$n \log n$$n=10^6$ → $\sim 2 \times 10^7$, 1 s 内
$n$$n=10^8$ 仍 $<1$s

warning

看见题目 brute force $\geq 2^{40}$, 现实机器 1s = $10^8$ ops, 必须剪枝或转 DP/归约. 这是 DSA branch-bound 章节的存在理由.


六、递推与生成函数

6.1 解递推的三大工具

形式适用方法
主定理 (Master)$T(n) = a T(n/b) + f(n)$比较 $f$ 与 $n^{\log_b a}$
Akra-Bazzi$T(n) = \sum_i a_i T(n/b_i + h_i) + g(n)$求解 $p$ 使 $\sum a_i b_i^{-p} = 1$
生成函数任意线性常系数递推$G(x) = P(x) / Q(x)$ 取系数

6.2 主定理 (记忆版)

设 $T(n) = a T(n/b) + f(n)$, $a \geq 1, b > 1$, 定义 $n^{\log_b a}$:

  1. $f(n) = O(n^{\log_b a - \epsilon}) \Rightarrow T(n) = \Theta(n^{\log_b a})$ — 叶子主导.
  2. $f(n) = \Theta(n^{\log_b a} \log^k n) \Rightarrow T(n) = \Theta(n^{\log_b a} \log^{k+1} n)$ — 同阶.
  3. $f(n) = \Omega(n^{\log_b a + \epsilon})$ 且正则条件 $\Rightarrow T(n) = \Theta(f(n))$ — 根主导.

查表(熟记即可秒定):

算法$a$$b$$f(n)$$T(n)$
二分查找12$O(1)$$\Theta(\log n)$
归并排序22$O(n)$$\Theta(n \log n)$
Karatsuba32$O(n)$$\Theta(n^{\log_2 3}) \approx n^{1.585}$
Strassen 矩阵乘72$O(n^2)$$\Theta(n^{\log_2 7}) \approx n^{2.807}$
普通快排最坏1$n-1$$O(n)$$\Theta(n^2)$

6.3 通过生成函数解 Fibonacci

设 $F_0 = 0, F_1 = 1, F_n = F_{n-1} + F_{n-2}$. 生成函数:

$$ G(x) = \sum_n F_n x^n = \frac{x}{1 - x - x^2} $$

分母 $1 - x - x^2 = (1 - \varphi_+ x)(1 - \varphi_- x)$, 其中 $\varphi_\pm = \dfrac{1 \pm \sqrt 5}{2}$.

部分分式 → 闭式 (Binet):

$$ F_n = \frac{1}{\sqrt 5}\left(\varphi_+^n - \varphi_-^n\right), \quad F_n = \Theta(\varphi_+^n) \approx \Theta(1.618^n) $$

工程含义: 普通递归 Fibonacci 是指数级. 用 memoization / DP 后 $O(n)$; 用矩阵快速幂 $O(\log n)$.

def fib_fast(n: int) -> int:
    # 矩阵快速幂: [[1,1],[1,0]]^n = [[F_{n+1}, F_n],[F_n, F_{n-1}]]
    def mul(A, B):
        return [[A[0][0]*B[0][0] + A[0][1]*B[1][0],
                 A[0][0]*B[0][1] + A[0][1]*B[1][1]],
                [A[1][0]*B[0][0] + A[1][1]*B[1][0],
                 A[1][0]*B[0][1] + A[1][1]*B[1][1]]]
    def pow_mat(M, k):
        R = [[1,0],[0,1]]
        while k:
            if k & 1: R = mul(R, M)
            M = mul(M, M); k >>= 1
        return R
    return pow_mat([[1,1],[1,0]], n)[0][1]

# 复杂度: 乘法 O(M(大数位数)) → 总 O(n 位 · log n)
# 这里 n 是步数, 内部 M(数字位数) 可视为常数
export function fibFast(n: number): number {
  // 同上矩阵快速幂;BigInt 在 n 较大时必需
  type Mat = [bigint, bigint, bigint, bigint];
  const mul = (A: Mat, B: Mat): Mat => [
    A[0]*B[0] + A[1]*B[2], A[0]*B[1] + A[1]*B[3],
    A[2]*B[0] + A[3]*B[2], A[2]*B[1] + A[3]*B[3],
  ];
  let R: Mat = [1n, 0n, 0n, 1n];
  let M: Mat = [1n, 1n, 1n, 0n];
  let k = n;
  while (k > 0) {
    if (k & 1) R = mul(R, M);
    M = mul(M, M); k >>= 1;
  }
  return Number(R[1]);
}

6.4 生成函数速查

递推生成函数渐近
$F_n = F_{n-1} + F_{n-2}$$\frac{x}{1 - x - x^2}$$\Theta(\varphi^n)$
$C_n = \sum_{i+j=n} C_i C_j$$\frac{1 - \sqrt{1-4x}}{2x}$$\Theta(4^n / n^{3/2})$
二叉树数Catalan 同上$\Theta(4^n / n^{3/2})$

note

DSA complexity.md 章节里所有"递推式 → 渐近"的解读, 本质就是这一节的工具反复应用. 看见 $T(n) = 2 T(n/2) + n\log n$ 立刻报 $n \log^2 n$.


七、代数结构: 群 / 环 / 域

这一小节是 Crypto 第十部分信息论 BCH/RS 的硬前置.

7.1 三层结构

结构公理
半群 $(S, \cdot)$结合律字符串拼接; 字母表
幺半群 ($+$ 有幺元 $e$)$e \cdot a = a \cdot e = a$$\mathbb{N}$ 与 $+$, $e = 0$; Rust 类型类 Monoid
$(G, \cdot, e, {}^{-1})$幺元 + 逆 + 结合$(\mathbb{Z}, +)$; $(\mathbb{Z}_p^*, \cdot)$; 椭圆曲线点加法

群的四条公理:

  1. 封闭性: $\forall a, b \in G: a \cdot b \in G$.
  2. 结合律: $(a \cdot b) \cdot c = a \cdot (b \cdot c)$.
  3. 幺元: $\exists e: a \cdot e = e \cdot a = a$.
  4. 逆元: $\forall a: \exists a^{-1}: a \cdot a^{-1} = e$.

可交换群 (Abelian): $\forall a, b: a b = b a$.

7.2 子群 / 陪集 / Lagrange

子群 $H \leq G$: $H \subseteq G$ 且自身成群. 陪集 $a H = {a h : h \in H}$.

Lagrange 定理: $|G| = |H| \cdot [G : H]$, 即子群大小整除群大小.

→ 推论: 素数阶群无非平凡子群; 元素的阶整除群阶。

7.3 环与域

$(R, +, \cdot)$: $(R, +)$ 是 Abelian 群, $(R, \cdot)$ 半群, 分配律.

$(\mathbb{F}, +, \cdot)$: $(\mathbb{F}^*, \cdot)$ 也是 Abelian 群 (非零元可逆). 即"能做加减乘除".

关键例:

  • $\mathbb{Q}, \mathbb{R}, \mathbb{C}$ 无限域.
  • $\mathbb{F}_p = \mathbb{Z}/p\mathbb{Z}$ ($p$ 素): 最常用的有限域.
  • $\mathbb{F}{p^k}$ (扩展域): BCH / RS 码用 $\mathbb{F}{2^8}$.
  • 椭圆曲线 $\mathbb{F}_p$ 上点的加群.

7.4 模运算

$\mathbb{Z}_n = {0, 1, \ldots, n-1}$ 加法模 $n$. 它是环; $\mathbb{Z}_p$ ($p$ 素) 是域.

重要定理 (Fermat 小定理): $a^{p-1} \equiv 1 \pmod p$ ($a \not\equiv 0$).

def mod_pow(base: int, exp: int, mod: int) -> int:
    # 平方乘: O(log exp) 次乘法, 全程 mod
    result = 1
    base %= mod
    while exp > 0:
        if exp & 1:
            result = result * base % mod
        base = base * base % mod
        exp >>= 1
    return result

# Crypto 中 RSA 取 d 使 ed ≡ 1 (mod φ(n));
# 这把 pow(x, ed, n) → x 还原 RSA

7.5 离散对数 = Crypto 的硬假设

定义: 给定 $g$ 与 $h = g^x$ in 群 $G$, 求 $x$ = DLP.

  • 在 $\mathbb{Z}_p^*$ 上, $x$ 大 (e.g. $p = 2^{256}$) 时, DLP ~ $O(\sqrt p)$ (Pollard rho), 仍硬.
  • 在椭圆曲线群 $\mathbb{F}_p$ 上, DLP ~ $O(\sqrt N)$; 无 sub-exponential 算法 → 同长更安全 → secp256k1, Curve25519.

warning

RSA / ECDSA / Ed25519/DH 全部依赖这个"看似可逆但实际不可逆"的指数化. 这就是 Crypto 第十部分的核心. 你之后看出"群 + 离散对数难"就立刻把整条 TLS 1.3 链路看穿.

7.6 与"离散"和"传统"的对比

传统 (连续)离散
$\mathbb{R}$ 实数, 无限小数$\mathbb{Z}, \mathbb{F}_p$
微积分, 极限数论, 取模
物理 (Newton-Leibniz)计算 (Turing)
信息论连续: 微分熵Shannon 离散熵

计算机世界本质就是"离散为主, 连续为辅"——但优化、信号处理某些处需要"连续数学版" (见 §2 线代 §3 概率 §4 微积分).


八、结束 + 速查表

tip

一页快速唤回:

  • 逻辑: $\neg \forall \equiv \exists$. $P \Rightarrow Q \equiv \neg P \lor Q \equiv \neg Q \Rightarrow \neg P$.
  • 集合: $|A \cup B| = |A| + |B| - |A \cap B|$. 幂集大小 $2^{|A|}$.
  • 关系: 等价 = 自反对称传递; 偏序 = 自反反对称传递.
  • : 握手引理 $\sum d = 2|E|$; DAG 有拓扑序; 二部图无奇环.
  • 组合: $\binom{n}{k} = \binom{n-1}{k-1} + \binom{n-1}{k}$; Catalan $C_n = \frac{1}{n+1}\binom{2n}{n}$.
  • 递推: $T = aT(n/b) + f$ → 比 $f$ 与 $n^{\log_b a}$.
  • 代数: 群 =幺元+逆+结合; 域 = 可加减乘除; $\mathbb{F}p$ 与 $\mathbb{F}{p^k}$; DLP 难.

下一篇: 2. 线性代数: 向量空间 / 矩阵 / 谱 / SVD / 张量.

2. 线性代数: 向量空间 / 矩阵 / 谱 / SVD / 张量

TL;DR

线性代数是计算机里所有"高维空间"的总称——一份 1B 参数权重、一帧图像的像素张量、一次 attention 的 $Q K V$ 矩阵、PageRank 的转移矩阵, 在数学上完全等价于一组线性变换. 这一篇覆盖六件工具:

  1. 向量空间: 集合 + 加 + 数乘; 列向量是 $\mathbb{R}^n$ 的元素.
  2. 矩阵 = 线性变换: 乘法、秩、迹、行列式.
  3. 特征分解: $A v = \lambda v$; 不变子空间.
  4. SVD: 任意 $m \times n$ 矩阵的"正交对角"分解, 几乎是工程 (PageRank / PCA / 推荐系统 / LoRA) 的瑞士军刀.
  5. 正定矩阵与范数: 几何度量 / 内积 / 投影.
  6. 张量: 从向量到矩阵到 3 阶 4 阶; Transformer 里 $\mathrm{softmax}, \text{attention}$ 全是张量收缩.

目标: 看论文里 $Q K^\top \in \mathbb{R}^{n \times n}$ / softmax over axis=-1 / rank-$r$ approximation 不再绕路查.


一、向量空间

1.1 定义

向量空间 $V$ 在域 $\mathbb{F}$ (默认 $\mathbb{R}$) 上: 元素 $u, v \in V$ 满足加法+数乘封闭, 且遵循 8 条公理 (加法系 Abelian 群 + 数乘结合/分配).

工程直觉: $\mathbb{R}^n$ 是最熟悉的实例; 一张 $28 \times 28$ 图像是 $\mathbb{R}^{784}$ 的一个点; 一个 768 维 embedding 也是.

1.2 线性无关 / 维数

  • 一组向量 ${v_1, \ldots, v_k}$ 线性无关: $\sum c_i v_i = 0 \iff c_i = 0$.
  • 极大无关组叫; 基的大小叫维数 $\dim V$.
  • 子空间 $W \subseteq V$: 自身成空间.

1.3 四个核心子空间 (任意矩阵)

矩阵 $A \in \mathbb{R}^{m \times n}$ 自动定义四个子空间:

子空间维数含义
列空间 $\mathcal{R}(A) = \operatorname{col}(A) \subseteq \mathbb{R}^m$$r = \operatorname{rank} A$$A x$ 能取到的所有 $y$
行空间 $\operatorname{row}(A) \subseteq \mathbb{R}^n$$r$$A^\top x$ 的取值范围
零空间 $\mathcal{N}(A) \subseteq \mathbb{R}^n$$n - r$$Ax = 0$ 的解集
左零空间 $\mathcal{N}(A^\top) \subseteq \mathbb{R}^m$$m - r$$A^\top y = 0$ 的解集

note

任意 $Ax = b$ 有解 $\Leftrightarrow b \in \mathcal{R}(A)$. 解唯一 $\Leftrightarrow \mathcal{N}(A) = {0}$. 这是数据库查询系统解线性约束的根.

1.4 内积与范数

内积 $\langle u, v \rangle = \sum u_i v_i = u^\top v$. 诱导范数:

$$ |u|_2 = \sqrt{\langle u, u\rangle}, \quad |u|1 = \sum |u_i|, \quad |u|\infty = \max |u_i| $$

  • 柯西-施瓦茨: $|\langle u, v\rangle| \leq |u|_2 |v|_2$.
  • 三角不等式: $|u + v| \leq |u| + |v|$.
  • 余弦相似度: $\cos \theta = \frac{\langle u, v\rangle}{|u| |v|}$, 这是 embedding 检索的核心度量.

二、矩阵与线性变换

2.1 矩阵 = 线性变换

矩阵 $A \in \mathbb{R}^{m \times n}$ 表示 $\mathbb{R}^n \to \mathbb{R}^m$ 的线性变换: $x \mapsto A x$.

叠加性: $A(u + v) = A u + A v$; $A(c u) = c A(u)$ 一旦满足即线性.

关键矩阵:

类型形式性质 / 用途
单位 $I$$\delta_{ij}$任何矩阵的幺元
对角 $D$$d_i \delta_{ij}$缩放; 特征值全在 $d_i$
对称 $S$$A = A^\top$特征值全实, 可正交对角化
正交 $Q$$Q^\top Q = I$旋转/反射, 保范数
三角 $U, L$上下三角LU 分解, 回代求解
置换 $P$每行恰一 1决定行/列顺序
投影 $P^2 = P$幂等$P^\top = P$ 即正交投影

2.2 矩阵运算

$$ (AB)^\top = B^\top A^\top, \quad (AB)^{-1} = B^{-1} A^{-1}, \quad \operatorname{tr}(AB) = \operatorname{tr}(BA) $$

$$ \det(AB) = \det(A) \det(B), \quad \det(A^{-1}) = 1/\det(A) $$

迹性质: $\operatorname{tr}(A) = \sum_i A_{ii} = \sum \lambda_i$ (任意方阵特征值和). 行列式: $\det A = \prod \lambda_i$; 几何意 = 列向量张成平行多面体体积.

2.3 秩与可逆

$\operatorname{rank} A$ = 列空间 (行空间) 维数.

  • $A$ 可逆 $\Leftrightarrow$ 满秩; $A^\top A$ 正定 $\Leftrightarrow A$ 列满秩.
  • 秩-零度定理: $\operatorname{rank} A + \dim \mathcal{N}(A) = n$.

2.4 矩阵分解速查

工程上"求逆/解方程"通常先做分解:

分解$A = $用途
LU$L U$ (可能加 $P$ 行置换)解线性方程, 一次分解多解
Cholesky$L L^\top$ ($A$ 正定)比 LU 省一半; ML Hessian
QR$Q R$ (正交 × 三角)最小二乘, 稳定
特征分解$Q \Lambda Q^{-1}$ ($A$ 对角化)当 $A$ 对称易
SVD$U \Sigma V^\top$任意矩阵; 永不动摇
Schur$Q T Q^\top$ (上三角 $T$)一般矩阵稳定特征分解
import numpy as np
A = np.random.randn(5, 3)
U, s, Vt = np.linalg.svd(A)            # 任意 A 都是 U Σ V^T
print(U.shape, s.shape, Vt.shape)        # (5,5) (3,) (3,3)
# 列空间 = U[:, :3] 张成; 奇异值 = s
# 奇异值越大 → 该方向越"重" → 用于压缩

三、特征值与特征向量

3.1 定义

$$ A v = \lambda v \quad (v \neq 0) $$

$\lambda$ 称 $A$ 的特征值, $v$ 对应特征向量. 组合起来 ${(\lambda_i, v_i)}$ 即 $A$ 的.

特征值几何意: 在 $v$ 方向上, $A$ 只缩放 (不改变方向); 矩阵的整体"行为"分布在各方向上的缩放因子.

3.2 关键性质

  • $A$ 对称 ⇒ 特征值全实 + 特征向量可正交选取.
  • $\det A = \prod_i \lambda_i$; $\operatorname{tr} A = \sum_i \lambda_i$.
  • $A^k$ 的特征值 = $\lambda_i^k$; 一句话推出 PageRank 一致收敛.
  • 谱半径 $\rho(A) = \max |\lambda_i|$; 决定 $A^k$ 是否发散.

3.3 谱定理 (实对称矩阵)

对称矩阵 $A$ 永远正交对角化:

$$ A = Q \Lambda Q^\top, \quad Q^\top Q = I, \quad \Lambda = \operatorname{diag}(\lambda_i) $$

含义: 对称矩阵只是"沿正交方向独立缩放"。这是 PCA、谱聚类、协方差分析的根; 也解释为什么 np.cov 是对称的.

3.4 应用: Markov 链 + PageRank

设 $A$ 是行随机矩阵; $\lambda_1 = 1$ 是最大特征值, 对应特征向量是平稳分布. PageRank 即:

$$ r = \alpha S r + (1 - \alpha) \frac{\mathbf{1}}{n} $$

幂迭代 $r_{k+1} = \alpha S r_k + \cdots$ 收敛到唯一平稳分布, 由 Perron-Frobenius 保证.


四、SVD: 通用瑞士军刀

4.1 定理

任意 $A \in \mathbb{R}^{m \times n}$ 可分解为:

$$ A = U \Sigma V^\top, \quad U \in \mathbb{R}^{m \times m}, V \in \mathbb{R}^{n \times n}, \Sigma = \operatorname{diag}(\sigma_1, \ldots, \sigma_{\min(m,n)}) $$

  • $U, V$ 正交, $\Sigma$ 唯一对角奇异值 $\sigma_1 \geq \ldots \geq 0$.
  • $\sigma_i = \sqrt{\lambda_i(A^\top A)} = \sqrt{\lambda_i(A A^\top)}$.
  • $\operatorname{rank} A = $ 非零奇异值个数.

4.2 低秩近似 (Eckart-Young)

$$ A_k = \sum_{i=1}^k \sigma_i u_i v_i^\top $$

是所有秩-$k$ 矩阵中最佳 (Frobenius 与 $L_2$ 范数) 近似 $A$.

应用:

  • PCA: 把协方差矩阵做 $A U$ 投影到主分量, 数据压缩 $k \ll n$.
  • LSA: 搜索引擎把词-文档矩阵 SVD 取 top-$k$ 维.
  • 推荐系统 (collaborative filtering 的 baseline): 评分矩阵 SVD.
  • LoRA: $W = W_0 + B A$, $A, B$ 低秩 → 参数省至 $r \cdot (m + n)$.

4.3 数值秩与极大秩近似的工程直觉

奇异值衰减快 ⇒ 矩阵"近似秩"小 ⇒ 可被低秩参数化压缩; 衰减慢 ⇒ 信息等分布在各方向.

→ 这就是 HuggingFace 上 LoRA 试不同 $r$ (通常 8 / 16 / 32) 的根源: $r$ 取多大决定能拟合多少"低秩修正".


五、正定矩阵与范数

5.1 正定矩阵

对称矩阵 $A \succeq 0$: $\forall x \neq 0: x^\top A x \geq 0$.

$A \succ 0$ (正定): $x^\top A x > 0$ 对任意 $x \neq 0$.

等价条件: 特征值全正; $A = L L^\top$ (Cholesky 存在); 行列式、主子式全正.

用途:

  • 协方差矩阵是正定; KL 散度在海森度量下表现.
  • 优化: Hessian 正定 ⇒ 局部极小是凸.
  • Mahalanobis 距离 $d(x, \mu) = \sqrt{(x - \mu)^\top \Sigma^{-1}(x - \mu)}$ ⇒ 协方差逆度量.

5.2 矩阵范数

  • Frobenius: $|A|F = \sqrt{\sum a{ij}^2} = \sqrt{\operatorname{tr}(A^\top A)}$.
  • 谱范数 (2-范数): $|A|2 = \sigma{\max}(A)$.
  • 1 范数: 列求和最大.
  • $\infty$ 范数: 行求和最大.

note

Transformer 自注意力里 $\mathrm{softmax}(Q K^\top / \sqrt{d_k})$, 除以 $\sqrt{d_k}$ 就是控制 $|Q K^\top|$ 量级 (注意 $K$ 的列向量范数期望 $\sqrt{d_k}$).


六、张量: 从 2 阶到 N 阶

6.1 定义

张量 (此处指多维数组, 物理学晶格张量定义同源不同语境) 是矩阵的推广:

  • 0 阶张量: 标量 $a$.
  • 1 阶: 向量 $v \in \mathbb{R}^n$.
  • 2 阶: 矩阵 $A \in \mathbb{R}^{m \times n}$.
  • 3 阶: $T \in \mathbb{R}^{m \times n \times k}$ — 例如 batch × token × embedding.
  • 4 阶: batch × channel × height × width (CNN 卷积核).

Transformer 维度速查:

张量形状典型
Token embedding $x$3[B, T, d] (batch × seq × emb)
权重 $W_Q, W_K, W_V$2[d, d_h] (head dim)
Query $Q = x W_Q$3[B, T, d_h]
Attention scores $Q K^\top$3[B, T, T] (query × key)
After softmax: $\alpha$3[B, T, T], 行和=1
Output $\alpha V$3[B, T, d_h]

6.2 张量收缩 (contraction)

矩阵乘法 $\sum_k A_{ik} B_{kj} = (AB)_{ij}$ 是 2 阶张量沿 $k$ 阶的收缩. 高阶张量收缩 = 在 N 阶任意选定一对轴求和.

(Self-attention 核心):

$$ \mathrm{Att}(Q,K,V) = \mathrm{softmax}\left(\frac{Q K^\top}{\sqrt{d_k}}\right) V $$

  • $Q K^\top$: [B, T, d_h] × [B, d_h, T] → [B, T, T] (batched matmul).
  • softmax 沿 last axis: $[\sum_j e^{s_{ij}} = 1]$.
  • 再 $\times V$ → [B, T, d_h].

工程上 PyTorch / JAX 的 torch.einsum('btd,bhd->bth', X, W) 就是把任意张量收缩按 Einstein 求和约定写出来.

import torch
# Multi-head self-attention 的核心一步
# X: [B, T, d]; Wq/Wk/Wv: [d, H, d_h]
def attention(X, Wq, Wk, Wv):
    Q = torch.einsum('btd,dhs->bhts', X, Wq)    # [B, H, T, d_h]
    K = torch.einsum('btd,dhs->bhts', X, Wk)
    V = torch.einsum('btd,dhs->bhts', X, Wv)
    scores = Q @ K.transpose(-2, -1) / (Q.shape[-1] ** 0.5)  # [B,H,T,T]
    alpha = scores.softmax(dim=-1)
    return alpha @ V                              # [B, H, T, d_h]
// 纯 TS 教学版: 单头注意力, 假装 d=64, T=16
function softmaxRows(M: number[][]): number[][] {
  return M.map(row => {
    const m = Math.max(...row);
    const e = row.map(x => Math.exp(x - m));
    const Z = e.reduce((a, b) => a + b, 0);
    return e.map(x => x / Z);
  });
}
function attention(Q: number[][], K: number[][], V: number[][]): number[][] {
  // Q, K, V 皆为 [T, d]
  const d = Q[0].length;
  const T = Q.length;
  const scores: number[][] = Array.from({ length: T }, () => Array(T).fill(0));
  for (let i = 0; i < T; i++)
    for (let j = 0; j < T; j++) {
      let s = 0;
      for (let k = 0; k < d; k++) s += Q[i][k] * K[j][k];
      scores[i][j] = s / Math.sqrt(d);
    }
  const alpha = softmaxRows(scores);          // [T,T], 行和=1
  const out: number[][] = Array.from({ length: T }, () => Array(d).fill(0));
  for (let i = 0; i < T; i++)
    for (let j = 0; j < T; j++) {
      const a = alpha[i][j];
      for (let k = 0; k < d; k++) out[i][k] += a * V[j][k];
    }
  return out;
}

6.3 广播 (broadcasting)

NumPy / PyTorch 的广播规则:

  1. 形状从右往左对齐.
  2. 缺轴视为 1; 维度 1 沿该轴复制.
  3. 不同维且 >1 ⇒ 报错.

warning

Transformer 代码里 W[:, None, :] 这种加轴操作背后的数学本质就是 reshape; 跟张量阶数 +1 等价, 不影响内容.


七、四个工程界必备"小公式"

7.1 ML / Optimization 反复出现的恒等

$$ \frac{\partial}{\partial X} \mathbf{1}^\top X \boldsymbol{w} = \boldsymbol{w} \boldsymbol{1}^\top $$

$$ \frac{\partial}{\partial W} (X W) = X^\top \cdot (\text{梯度对 } XW) $$

记忆法: "梯度比原式少一个 W 的轴, 求导后留下 X 转置". 这是反向传播 (§4 微积分) 的本质.

7.2 矩阵微分三件套

forward                          backward (相同链路反向)
X -> XW -> P = softmax(XW)  -> loss   P - y   (对 score 的梯度)
                               ^^^
 W 的梯度 = X^T @ (P - y)        (链式法则的"收缩")

7.3 列 / 行视图相同

$Ax = b$ 可视为:

  • 列视图: $b$ 是 A 列的线性组合, 权重 = $x$ 的对应元素.
  • 行视图: 每行是一个超平面方程, 解 = 所有超平面交点.

→ 在 ML 里 列视图 是特征聚合, 行视图 是样本边界判别. 这是 SVM 与 PCA 的对偶视角.

7.4 投影

正交投影到列空间 $\mathcal{R}(A)$ 的矩阵:

$$ P_A = A (A^\top A)^{-1} A^\top $$

最小二乘解 $x = (A^\top A)^{-1} A^\top y$ 给出"投影后的坐标".

note

数据库代价估计 / ML 线性回归 / 任何拟合直线的最小二乘都用这个公式. 它是 §4 微积分篇推导"梯度下降等价于迭代投影"的入口.


八、结束 + 速查表

tip

一页快速唤回:

  • 矩阵 = 线性变换: $\det A = \prod \lambda_i$, $\operatorname{tr} A = \sum \lambda_i$.
  • 秩-零度: $\operatorname{rank} A + \dim \mathcal{N}(A) = n$.
  • 正定: ⇔ 对称 + 特征值全正 ⇔ $A = L L^\top$.
  • 谱范数 $|A|2 = \sigma{\max}(A)$; Frobenius $|A|_F = \sqrt{\operatorname{tr}(A^\top A)}$.
  • SVD: 永远存在, $A = U \Sigma V^\top$; Top-k 奇异值是最佳 low-rank 近似.
  • Cauchy-Schwarz: $|\langle u, v\rangle| \leq |u| |v|$.
  • 正交投影: $P = A(A^\top A)^{-1} A^\top$.
  • 张量总收缩: $\sum$ along 对应轴, softmax 维 -1 在最后一轴归一.

下一篇: 3. 概率统计: 分布 / 贝叶斯 / MLE / MAP / 极限 / KL.

3. 概率统计: 分布 / 贝叶斯 / MLE / MAP / 极限 / KL

TL;DR

工程里的"概率"是刻画不确定性的语言: 你不知道这台机器 next minute 崩不崩, 但你能给一个分布; 你不知道 key 碰撞概率, 但你能算 bound; 你不知道 transformer 学到的 embedding 集中在哪儿, 但你能用 KL 散度告诉它"靠近分布 $P$ 别乱漂". 这一篇覆盖:

  1. 概率空间与随机变量 — PSPACE / 离散 vs 连续 / pmf vs pdf / CDF.
  2. 期望 / 方差 / 协方差 — 随机变量的"代数".
  3. 核心分布家族 — Bernoulli / Binomial / Geometric / Poisson / Uniform / Exponential / Normal / Categorical / Multinomial.
  4. 联合、条件、贝叶斯 — $\Pr(A|B) = \Pr(B|A)\Pr(A)/\Pr(B)$; 一句话逼回去重写不少系统.
  5. MLE / MAP / EM — 数据 → 参数的反推; ML 训练就是 MLE 大写版.
  6. 极限定理: LLN / CLT / 大偏差 — "为什么 N=30 够了 / 为什么 4-sigma rarely".
  7. 熵 / 交叉熵 / KL 散度 — 信息论与 ML 的最小公因子, 见信息论章节.
  8. 万一线性 GC 不可解的不确定: Concentration inequalities — Markov / Chebyshev / Hoeffding.

目标: 看到 "$\arg\max_\theta \prod_i p(x_i|\theta)$" 与 "$\arg\max_\theta \mathbb{E}_{z \sim q}[\ldots]$" 不再绕路查.


一、概率空间与随机变量

1.1 三件套 (Kolmogorov 公理)

概率空间 $(\Omega, \mathcal{F}, \Pr)$:

  • $\Omega$ = 样本空间 (一切可能结果).
  • $\mathcal{F} \subseteq 2^\Omega$ = 事件 $\sigma$-代数 (对补、可数并封闭).
  • $\Pr: \mathcal{F} \to [0, 1]$ 满足:
    1. $\Pr(\Omega) = 1$.
    2. $\Pr(E) \geq 0$.
    3. 可数可加: 不相交 $E_i$ ⇒ $\Pr(\bigcup E_i) = \sum \Pr(E_i)$.

随机变量 $X: \Omega \to \mathbb{R}$, 它把"实验结果"映射成数值. $X$ 决定概率分布 $\Pr_X(S) = \Pr(X^{-1}(S))$.

1.2 离散 vs 连续

  • 离散: 概率质量函数 (pmf) $p_X(x) = \Pr(X = x)$.
  • 连续: 概率密度函数 (pdf) $f_X$; $\Pr(a \leq X \leq b) = \int_a^b f_X(x) dx$; pdf 可 > 1.

累计分布 (CDF): $F_X(t) = \Pr(X \leq t)$. 单调非降, 右连续, $\lim_{t\to-\infty}F=0$, $\lim_{t\to\infty}F=1$.

1.3 联合 / 边缘 / 条件

  • 联合 pmf/pdf: $p_{X,Y}(x, y)$ 或 $f_{X,Y}(x, y)$.
  • 边缘: $p_X(x) = \sum_y p_{X,Y}(x, y)$ (离散) 或 $\int f_{X,Y}(x, y) dy$ (连续).
  • 条件: $p(x | y) = p(x, y) / p(y)$.

二、期望与方差

2.1 期望

$$ \mathbb{E}[X] = \begin{cases} \sum_x x , p(x) & \text{离散} \ \int x , f(x), dx & \text{连续} \end{cases} $$

线性性 (无论是否独立): $\mathbb{E}[aX + bY] = a \mathbb{E}[X] + b \mathbb{E}[Y]$.

乘积: $\mathbb{E}[XY] = \mathbb{E}[X]\mathbb{E}[Y]$ 仅当 $X, Y$ 独立.

Jensen 不等式: $\varphi$ 凸 ⇒ $\mathbb{E}[\varphi(X)] \geq \varphi(\mathbb{E}[X])$.

2.2 方差与协方差

$$ \operatorname{Var}(X) = \mathbb{E}[(X - \mu)^2] = \mathbb{E}[X^2] - (\mathbb{E}[X])^2 $$

$$ \operatorname{Cov}(X, Y) = \mathbb{E}[(X - \mathbb{E}X)(Y - \mathbb{E}Y)] = \mathbb{E}[XY] - \mathbb{E}X\mathbb{E}Y $$

$$ \operatorname{Cov}(aX, Y) = a \operatorname{Cov}(X, Y), \quad \operatorname{Var}(X \pm Y) = \operatorname{Var}X + \operatorname{Var}Y \pm 2\operatorname{Cov}(X, Y) $$

独立 ⇒ 协方差为 0 (逆命题不成立). 反例: $Y = X^2$, $X$ 取 ±1.

相关系数 $\rho = \mathrm{Cov}/(\sigma_X \sigma_Y) \in [-1, 1]$.

2.3 标准化与 z-score

$$ z = \frac{X - \mu}{\sigma} $$

→ 标准化的 $z$ 期望 0, 方差 1. Six-Sigma / Grubbs 检验 / 异常点检测都基于这个.


三、核心分布家族

3.1 离散

名称pmf期望 / 方差用法
Bernoulli $B(p)$$p^x(1-p)^{1-x}$$\mu = p$, $\sigma^2 = p(1-p)$一次试验 / 一行二分
Binomial $\mathrm{Bin}(n,p)$$\binom{n}{k} p^k (1-p)^{n-k}$$\mu = np$, $\sigma^2 = np(1-p)$$n$ 次独立, count
Geometric $\mathrm{Geom}(p)$$(1-p)^{k-1} p$$\mu = 1/p$首次成功次数
Poisson $\mathrm{Poi}(\lambda)$$e^{-\lambda} \lambda^k / k!$$\mu = \sigma^2 = \lambda$单位时间事件数
Negative Binomial$\binom{k+r-1}{k} p^k (1-p)^r$直到 r 次成功
Categorical$p_1, \ldots, p_K$似 1-of-K, softmax 输出
Multinomial$\frac{n!}{\prod x_i!}\prod p_i^{x_i}$$\mathbb{E}[X_i] = n p_i$多类计数; transformer 词频

Poisson 公式直觉: 当 $n \to \infty, p \to 0, np \to \lambda$ 时, $\mathrm{Bin}(n, p) \to \mathrm{Poi}(\lambda)$. 这就是网络中"罕见事件计数"为何都用 Poisson.

3.2 连续

名称pdf期望 / 方差用法
Uniform $U(a,b)$$\frac{1}{b-a}$$\mu = \frac{a+b}{2}$, $\sigma^2 = \frac{(b-a)^2}{12}$假设为先验 / 随机采
Exponential $\mathrm{Exp}(\lambda)$$\lambda e^{-\lambda x}$$\mu = 1/\lambda$, $\sigma^2 = 1/\lambda^2$无记忆性, 排队论/MTBF
Normal $\mathcal{N}(\mu, \sigma^2)$$\frac{1}{\sqrt{2\pi\sigma^2}} e^{-(x-\mu)^2/(2\sigma^2)}$$\mu$, $\sigma^2$CLT 产物
Gamma $\Gamma(\alpha, \beta)$$\frac{\beta^\alpha}{\Gamma(\alpha)}x^{\alpha-1}e^{-\beta x}$$\alpha/\beta$, $\alpha/\beta^2$Exp 推广; 等待时间和
Beta $\mathrm{Beta}(\alpha, \beta)$$\frac{x^{\alpha-1}(1-x)^{\beta-1}}{B(\alpha,\beta)}$$[0,1]$ 上概率的先验
Chi-sq $\chi^2_k$$\Gamma(k/2, 1/2)$$k$, $2k$统计检验
Student's t小样本, 未知方差

3.3 正态分布皇冠属性

$$ \mathcal{N}(\mu, \sigma^2): X = \mu + \sigma Z, Z \sim \mathcal{N}(0, 1) $$

  • 68-95-99.7 法则: 1/2/3-sigma 范围内概率.
  • 标准 $Z = (X - \mu)/\sigma$.
  • $\mathrm{Poi}(\lambda), \lambda \to \infty$ 渐近 $\mathcal{N}(\lambda, \lambda)$.
  • 独立正态之和仍正态 (稳定性).

3.4 多元正态 (MVN)

$$ X \sim \mathcal{N}(\boldsymbol\mu, \Sigma), \quad f(\boldsymbol x) = \frac{1}{(2\pi)^{d/2}|\Sigma|^{1/2}} e^{-\frac{1}{2}(\boldsymbol x - \boldsymbol\mu)^\top \Sigma^{-1}(\boldsymbol x - \boldsymbol\mu)} $$

  • $|\Sigma|$ 是协方差矩阵行列式.
  • $\Sigma$ 正定 ⇒ 可写为 $\Sigma = L L^\top$, $X = \mu + L Z$ (采样技巧).
  • 边缘任意正态也是正态; 条件正态也是正态 (Bayesian inference 的便利).

四、贝叶斯定理与全概率

4.1 三段式

$$ \Pr(A | B) = \frac{\Pr(B | A) \Pr(A)}{\Pr(B)} $$

展开 $\Pr(B) = \sum_i \Pr(B | A_i) \Pr(A_i)$ 即全概率公式, 用于把"原因"$A_i$ 关联到"观察"$B$.

4.2 语言版图

名称公式名号
后验$\Pr(\thetax)$
似然$\Pr(x\theta)$
先验$\Pr(\theta)$prior
证据$\Pr(x)$evidence

$$ \text{posterior} \propto \text{likelihood} \cdot \text{prior} $$

note

这是 ML 里 Bayesian 的根基; 也是 Crypto "概率可忽略函数"与 Secure Sketch、Distributed 系统里"phase king""Byzantine agreement"的概率推理木骨架.

4.3 经典例: 罕见病阳性

发病率 $0.1%$, 检测阳性准确率 $99%$ (sensitivity=specificity=0.99). 你阳, 真病概率?

$$ \Pr(\text{sick} | +) = \frac{0.99 \cdot 0.001}{0.99 \cdot 0.001 + 0.01 \cdot 0.999} \approx \frac{0.00099}{0.010989} \approx 9% $$

warning

朴素直觉是"99% 检测准 ⇒ 真 99%". 实际是 9%——因为 prior 极稀疏. 这类 base-rate fallacy 在 ML 不平衡数据 / 监控告警 / IDS 系统里反复咬人.

4.4 共轭先验速查

似然共轭先验后验
BernoulliBetaBeta
PoissonGammaGamma
Normal (已知 $\sigma$)NormalNormal
MultinomialDirichletDirichlet
ExponentialGammaGamma

→ LDA / 主题模型几乎全在这张表上演, 见后续 ML 章节.


五、MLE / MAP / EM

5.1 最大似然估计 (MLE)

给定 i.i.d. 观测 $x_1, \ldots, x_n$, 取 $f(x; \theta)$ 的参数 $\theta$ 使观察到的数据最有说服力:

$$ \hat\theta_{\text{MLE}} = \arg\max_\theta \prod_{i=1}^n f(x_i; \theta) $$

工程上取 log 化为 sum:

$$ \hat\theta_{\text{MLE}} = \arg\max_\theta \sum_i \log f(x_i; \theta) $$

5.2 几个经典 MLE

Bernoulli: $\hat p = \frac{1}{n} \sum x_i$ (样本均值).

Normal (未知 $\mu, \sigma^2$): $\hat\mu = \bar x$, $\hat\sigma^2 = \frac{1}{n}\sum (x_i - \bar x)^2$.

Uniform $U(0, \theta)$: $\hat\theta = \max x_i$. 注意 MLE 有偏 ($\mathbb{E}[\max] = \frac{n}{n+1}\theta$).

5.3 MAP = MLE + Prior

$$ \hat\theta_{\text{MAP}} = \arg\max_\theta ; \prod f(x_i | \theta) \cdot p(\theta) $$

MLE 即先验均匀的 MAP. 加入先验等同于正则化:

  • 似然 $\mathcal{N}(\bar x, \cdots)$ + 先验 $\theta \sim \mathcal{N}(0, \tau^2)$ ⇒ $\arg\min_\theta |y - X\theta|^2 + \frac{\sigma^2}{\tau^2} |\theta|^2$, 即 ridge regression.
  • Laplacian 先验 ⇒ L1 = LASSO.

note

这就是机器学习里"为什么 L1 稀疏, L2 平滑"的数学根源——直接看先验的形状就行.

5.4 EM 算法 (Expectation-Maximization)

数据有隐变量 $z$ 时, 直接解 MLE 解不出. EM 迭代:

  1. E 步: 写完整似然关于当前隐变量后验的期望 $$ Q(\theta | \theta^{(t)}) = \mathbb{E}_{z | x, \theta^{(t)}}[\log p(x, z | \theta)] $$
  2. M 步: $\theta^{(t+1)} = \arg\max_\theta Q(\theta | \theta^{(t)})$.

应用: GMM (高斯混合), HMM (Baum-Welch), K-means (硬分版本).

5.5 ELBO 与变分下界

$$ \log p(x) = \log \int p(x, z) dz \geq \mathbb{E}{z \sim q}[\log p(x, z)] - \mathbb{E}{z \sim q}[\log q(z)] $$

右侧即 ELBO (Evidence Lower Bound), 见后续变分推断与 VAE. 最大化 ELBO ⇔ 最小化 $\mathrm{KL}(q | p)$.


六、极限定理

6.1 大数定律 (LLN)

样本均值 $\bar X_n = \frac{1}{n}\sum X_i$ 几乎处处收敛到期望 $\mu$:

$$ \bar X_n \xrightarrow{\text{a.s.}} \mu $$

实践: Monte Carlo 取样本越多越逼近. 没有"无法估计方差 → 我就多采样"的工程路径就靠它.

6.2 中心极限定理 (CLT)

i.i.d. 期望 $\mu$ 方差 $\sigma^2$:

$$ \sqrt n \cdot \frac{\bar X_n - \mu}{\sigma} \xrightarrow{d} \mathcal{N}(0, 1) $$

含义: 无论原分布, 样本均值经标准化渐近正态. 这就是正态分布"伞覆盖了一切"的成因; 也是 Bayes 不能免先验的 freq 默认.

: 一枚硬币 $100$ 次, 头数近似正态, $\mu = 50, \sigma = 5$. 大概率落在 $[40, 60]$.

6.3 大偏差 (Chernoff / Hoeffding)

LLN 说"最终到 $\mu$". 但要量化"偏离 $\epsilon$ 的概率多小":

Hoeffding 不等式 (有界 $X_i \in [a_i, b_i]$):

$$ \Pr\left[ \bar X_n - \mu \geq t \right] \leq \exp\left(-\frac{2 n^2 t^2}{\sum (b_i - a_i)^2}\right) $$

Chernoff bound (Bernoulli): $\Pr[\bar X_n \geq (p + \epsilon) n] \leq e^{-2 n \epsilon^2}$.

→ 这就是"为什么 100 次抛硬币正反偏离 0.5 不超过 0.1 是 ~5%".

tip

6-sigma (Six Sigma) 标准 = $6\sigma$ 容差 ⇒ defect rate < 3.4 ppm = Hoeffding 直接给. 这是工业质检、A/B test 显著性、SLA 错误预算的概率骨架.

6.4 Markov / Chebyshev / 推导链

Markov (最弱):    P[X ≥ a] ≤ E[X] / a               (X ≥ 0)
Chebyshev:        P[|X - μ| ≥ t] ≤ Var X / t²
Hoeffding:        exp(-2 n t²/(b-a)²)                 (i.i.d. 有界)
Chernoff:         exp(-2 n ε²)                          (Bernoulli)

工程代码: 越往下用, 越用得上紧 (依赖越强 → bound 越松).

6.5 Concentration 在 ML / Distributed 的使用

  • ML 中"经验风险 vs 真实风险" gap 用 Hoeffding 量化 (generalization).
  • Distributed 中 quorum W+R>N 的"读到最新写"概率 = majority 经 Chernoff 上界.
  • 测算 hash 冲撞概率 = birthday bound (鸽巢变体).

七、信息论三件: 熵 / 交叉熵 / KL

7.1 熵 (重申信息论章 §1)

$$ H(X) = -\sum_x p(x) \log p(x), \quad H(X, Y), \quad H(X | Y), \quad I(X; Y) = H(X) - H(X | Y) $$

链式法则: $H(X, Y) = H(X) + H(Y | X)$.

7.2 KL 散度

测两个分布 $p$ 与 $q$ 的"差异":

$$ \mathrm{KL}(p | q) = \sum_x p(x) \log \frac{p(x)}{q(x)} $$

  • $\mathrm{KL} \geq 0$, 等 0 当且仅当 $p = q$ a.s.
  • 不对称: $\mathrm{KL}(p | q) \neq \mathrm{KL}(q | p)$. 选择方向决定 forward / reverse mode.
  • Forward $\mathrm{KL}(p | q)$: $q$ 必须覆盖 $p$ 全支撑; 倾向 mode-seeking 忽略低概率区.
  • Reverse $\mathrm{KL}(q | p)$: $q$ 倾向 mass-covering (mean-seeking), VAE 用这个.

7.3 交叉熵 = 熵 + KL

$$ H(p, q) = H(p) + \mathrm{KL}(p | q) $$

由于 $H(p)$ 对 $\theta$ 常量, 最小化交叉熵 ⇔ 最小化 KL, ML 分类损失几乎全部用这个:

$$ \mathcal{L}_{\text{CE}} = -\sum_i y_i \log \hat y_i $$

7.4 JS 散度 = 对称化 KL

$$ \mathrm{JS}(p | q) = \frac{1}{2}\mathrm{KL}(p | m) + \frac{1}{2}\mathrm{KL}(q | m), ; m = (p+q)/2 $$

GAN 的原始损失 = JS 的反向最大化.


八、随机过程初步

8.1 Markov 链

状态空间 $S$, 转移 $P_{ij} = \Pr(X_{t+1} = j | X_t = i)$. 无记忆性:

$$ \Pr(X_{t+1} | X_t, \ldots, X_0) = \Pr(X_{t+1} | X_t) $$

平稳分布 $\pi$: $\pi P = \pi$. 不可约+非周期 ⇒ 唯一, $P^k \to \mathbf{1}\pi^\top$.

应用: MCMC (Metropolis-Hastings / Gibbs), PageRank, HMM, 强化学习 (MDP 基础).

8.2 Poisson 过程

事件到达速率 $\lambda$, 间隔 $\sim \mathrm{Exp}(\lambda)$ i.i.d.. 计数 $N(t) \sim \mathrm{Poi}(\lambda t)$.

应用: OS 排队论、CDN 缓存命中率、DB 连接池、限流桶.

8.3 排队论 (M/M/1)

到达 Poisson, 服务 Exp, 1 个服务台:

$$ \rho = \frac{\lambda}{\mu}, \quad L_q = \frac{\rho^2}{1-\rho}, \quad W_q = \frac{\rho/\mu}{1 - \rho} $$

→ 资源利用率 $\rho \to 0.8$ 时队列开始爆炸, 这就是 SRE 不敢 < 50% buffer 的原因.


九、采样与估计实践

9.1 Monte Carlo

要算 $\mathbb{E}[f(X)]$ 没闭式 → 采样: $\frac{1}{n}\sum f(X_i)$, LLN 保证收敛, Hoeffding 给误差.

import numpy as np
def estimate_pi(n: int = 10_000_000) -> float:
    x = np.random.random(n); y = np.random.random(n)
    return 4 * (x*x + y*y < 1).mean()    # Naïve MC, σ ~ 0.5/√n
# n=1e6 → π ≈ 3.1416 ± 5e-4

9.2 重要采样 (Importance Sampling)

问题: 想算 $\mathbb{E}_p[f]$, 但只能从 $q$ 采样:

$$ \mathbb{E}_p[f] = \mathbb{E}_q\left[f(x) \cdot \frac{p(x)}{q(x)}\right] $$

→ 在 Off-policy RL / 罕见事件估计里必备. 也是 Transformer 推理"speculative decoding"的概率基础.

9.3 拒绝采样 + Metropolis

  • 拒绝采样: 找一个易采的 envelope $M q(x) \geq p(x)$, 采 $x \sim q$ 接受概率 $p(x)/(M q(x))$.
  • Metropolis-Hastings: 当前 $x$, 提议 $x'$, 接受概率 $\min(1, \frac{p(x')q(x | x')}{p(x)q(x' | x)})$.

十、结束 + 速查表

tip

一页快速唤回:

  • 期望: $\mathbb{E}[aX + bY] = a\mathbb{E}X + b\mathbb{E}Y$; 独立相乘: $\mathbb{E}[XY] = \mathbb{E}X\mathbb{E}Y$.
  • 方差: $\operatorname{Var}(X \pm Y) = \operatorname{Var}X + \operatorname{Var}Y \pm 2\operatorname{Cov}$.
  • 方差-期望公式: $\operatorname{Var}(X) = \mathbb{E}[X^2] - (\mathbb{E}[X])^2$.
  • Bernoulli $B(p)$: $\mu = p, \sigma^2 = p(1-p)$.
  • Poisson $\lambda$: $\mu = \sigma^2 = \lambda$; $\mathrm{Bin}(n,p_n) \to \mathrm{Poi}(np)$.
  • 正态: $a + b Z \sim \mathcal{N}(a, b^2)$; 标准化 $(X-\mu)/\sigma$.
  • 共轭: Beta-Bernoulli, Gamma-Poisson, Dirichlet-Multinomial.
  • 贝叶斯: posterior ∝ likelihood × prior.
  • MLE/MAP: MLE = MAP-uniform; 联合类先验 Gaussian → ridge, Laplace → LASSO.
  • 极限: $\bar X_n \to \mu$ (LLN); $\sqrt n (\bar X_n - \mu)/\sigma \to \mathcal{N}$ (CLT).
  • : $H(X) = -\sum p \log p$; 条件熵 $H(X|Y) = H(X, Y) - H(Y)$; 互信息 $I = H(X) - H(X|Y)$.
  • KL: $\mathrm{KL}(p | q) \geq 0$, 不对称; 交叉熵 = $H(p) + \mathrm{KL}$.
  • Markov 不等式 P[X ≥ a] ≤ E[X]/a; Hoeffding $\exp(-2n t^2/(b-a)^2)$.

下一篇: 4. 微积分与最优化: 链式法则 / 雅可比 / Hessian / 凸优化 / 信息几何.

4. 微积分与最优化: 链式法则 / 雅可比 / Hessian / 凸优化 / 信息几何

TL;DR

优化是计算机科学里"反向"的一面: 用前向 (compiles 哪些到哪些) 算"loss", 再用反向 (链式法则) 算"该怎么改参数让 loss 更小". Transformer、信息论容量推导、调度器最短作业先选、HP 计算代价模型本质都是同一件事: 在光滑空间里取梯度. 这一篇覆盖:

  1. 极限与导数 — 微分与差分的连续 / 离散切换.
  2. 链式法则 + 多元函数 + 雅可比矩阵 — Transformer 反向传播的硬公式.
  3. Hessian + 泰勒展开 + 二阶方法 — 极值附近的曲率信号.
  4. 凸函数 / 凸优化 — 优化领域的"快 + 全局最优"区间.
  5. 梯度下降 / Stochastic / Momentum / Adam — 工业优化器谱系.
  6. 拉格朗日 + KKT — 带约束优化的标准框架.
  7. 信息几何 — 用 KL / Fisher 度量把"分布空间"做成流形, 推出自然梯度与变分推断.

目标: 看到 AdamEM$\nabla_\phi \mathbb{E}[\ldots]$$\Lambda \succeq 0$ 不再卡.


一、极限、连续、可导

1.1 极限

$\lim_{x \to a} f(x) = L$ 表任意 $\epsilon > 0$, 存在 $\delta$ 使 $0 < |x-a| < \delta \Rightarrow |f(x) - L| < \epsilon$ (Cauchy-Weierstrass 定义).

工程上渐近等价记号 $f(x) \sim g(x)$: $\lim_{x \to \infty} f/g = 1$. 与 DSA 中 $O/\Theta/\Omega$ 同根. 例如 $n! \sim \sqrt{2\pi n} (n/e)^n$ (Stling 公式) 是计算组合数 log 的钥匙.

1.2 导数

$$ f'(x) = \lim_{h \to 0} \frac{f(x+h) - f(x)}{h} $$

几何含义: 切线斜率. 物理含义: 瞬时速率.

关键四件:

  • 链式: $(g \circ f)' = g'(f(x)) \cdot f'(x)$.
  • 乘法: $(fg)' = f'g + fg'$.
  • 商: $(f/g)' = (f'g - fg')/g^2$.
  • 反函数: $(f^{-1})'(y) = 1/f'(x)$ where $y = f(x)$.

note

自适应学习率优化 (Adam, RMSprop) 本质是用 $f' / \sqrt{\mathbb{E}[f'^2] + \epsilon}$ 而不是 $f'$ 是因为第二个额度依赖反函数的梯度的"曲率方向", 详见 §4-5. 自适应瞬间看, 这是 Newton 法用 $H^{-1} g$ 的近似, 用反方向升级.

1.3 几个重要函数的导数

$f$$f'$
$x^n$$n x^{n-1}$
$e^x$$e^x$
$\ln x$$1/x$
$\sin x$$\cos x$
$\cos x$$-\sin x$
$\sigma(x) = \frac{1}{1+e^{-x}}$$\sigma(1 - \sigma)$
$\tanh x$$1 - \tanh^2 x$
$\mathrm{ReLU}(x)$$\mathbb{1}_{x > 0}$ (在 0 处不可导; 工程上常设 0)
$\mathrm{softmax}_i(\boldsymbol x)$$\sigma_i(1 - \sigma_i)$ (对 $x_i$), $-\sigma_i \sigma_j$ (对 $x_j$)

softmax 雅可比尤其重要, 见 §2.

1.4 积分

$$ \int_a^b f(x), dx = \lim_{n \to \infty} \sum_{i=0}^{n-1} f\left(a + i \frac{b-a}{n}\right) \cdot \frac{b-a}{n} $$

微积分基本定理:

$$ \int_a^b f(x), dx = F(b) - F(a), \quad F' = f $$

  • 分部积分: $\int u, dv = uv - \int v, du$.
  • 换元: $\int f(g(x)) g'(x) dx = \int f(u), du$.
  • 概率积分 $\int e^{-x^2} dx$ 全空间 = $\sqrt{\pi}$, 这是 Gaussian 归一化常数.

二、多元与链式法则 (Transformer 必备)

2.1 偏导数与方向导数

$f: \mathbb{R}^n \to \mathbb{R}$ 的偏导 $\partial f / \partial x_i$. 梯度:

$$ \nabla f(\boldsymbol x) = \left(\frac{\partial f}{\partial x_1}, \ldots, \frac{\partial f}{\partial x_n}\right)^\top $$

方向 $\boldsymbol v$ 上的方向导数 = $\nabla f \cdot \boldsymbol v / |\boldsymbol v|$; 沿梯度方向取最大. 即"梯度下降合理性"的数学.

2.2 复合与雅可比

设 $g: \mathbb{R}^n \to \mathbb{R}^m$, $f: \mathbb{R}^m \to \mathbb{R}$. 复合 $h = f \circ g: \mathbb{R}^n \to \mathbb{R}$:

$$ \frac{\partial h}{\partial \boldsymbol x} = \underbrace{\nabla f(g(\boldsymbol x))^\top}{1 \times m} \cdot \underbrace{J_g(\boldsymbol x)}{m \times n} $$

$J_g$ 即雅可比矩阵, $[J_g]_{ij} = \partial g_i / \partial x_j$.

Transformer 反向传播就是矩阵化 + 反序的链式法则: 每一层的"loss 对输入梯度"= 该层输出梯度乘该层权重梯度.

2.3 softmax 雅可比

$\boldsymbol s = \mathrm{softmax}(\boldsymbol z)$:

$$ \frac{\partial s_i}{\partial z_j} = \begin{cases} s_i (1 - s_i) & i = j \ -s_i s_j & i \neq j \end{cases} $$

向量形式 $J_{\mathrm{softmax}} = \mathrm{diag}(s) - s s^\top$.

工程意义: 反向传 $(\boldsymbol{ds}) = J \cdot \boldsymbol{dz}$ 即 $\boldsymbol{ds} = s \odot (\boldsymbol{dz} - (s^\top \boldsymbol{dz})\mathbf{1})$.

2.4 反向传播 (autograd 模式)

import numpy as np
# 简化的两层数值梯度校验
def softmax_ce(logits, y):
    z = logits - logits.max(axis=-1, keepdims=True)
    e = np.exp(z); s = e / e.sum(axis=-1, keepdims=True)
    return -np.log(s[y] + 1e-12)

def grad_softmax_ce(logits, y):
    s = np.exp(logits - logits.max()) / np.exp(logits - logits.max()).sum()
    g = s.copy(); g[y] -= 1
    return g    # 简化: 对 logit 的导数 = (σ - onehot)

关键观察: 交叉熵 + softmax 的反传结果是 $(s - \mathbf{1}_y)$, 这条公式出现在每个 softmax 分类器里. 记下来一辈子省试错.

2.5 数值方法: 自动求导

  • 前向模式 AD: 一个变量求偏导链, O(n) 算一个分量.
  • 反向模式 AD: 一次反向跑出所有偏导, O(1) 算 loss 对所有参数 (gradient). 这就是 PyTorch 默认反向 (TensorFlow tf.GradientTape).

tip

反向 AD 是 1970 Linnainmaa 发现 + 1986 Rumelhart-Hinton-Williams popularized. 现代 DL 能训参数 $10^{11}$ 都是它的功劳. 写"反向传播"不是推每个 dot; 而是按计算图节点按拓扑逆序局部求导.


三、Hessian 与二阶

3.1 二阶偏导

$$ H_f(\boldsymbol x)_{ij} = \frac{\partial^2 f}{\partial x_i \partial x_j} $$

$H$ 是对称矩阵 (若 $f$ 充分光滑). 在 stationary $\nabla f = 0$ 处:

  • $H \succ 0$ ⇒ 局部最小.
  • $H \prec 0$ ⇒ 局部最大.
  • 不定 ⇒ 鞍点.
  • 半正定/半负定 ⇒ 二阶检验不足, 需高阶.

3.2 泰勒展开 (2D 局部)

$$ f(\boldsymbol x + \boldsymbol h) \approx f(\boldsymbol x) + \nabla f(\boldsymbol x)^\top \boldsymbol h + \frac{1}{2} \boldsymbol h^\top H_f(\boldsymbol x) \boldsymbol h $$

含义: 沿一阶方向线性近似; 二阶项表达曲率. 这就是 Newton 法、Trust-region、CG、BFGS 的全部来源.

3.3 Newton 迭代

$$ \boldsymbol x_{k+1} = \boldsymbol x_k - H_f^{-1}(\boldsymbol x_k) \nabla f(\boldsymbol x_k) $$

优点: 二次收玫 (近最优时极快). 缺点: 求 $(n \times n) H^{-1}$ 太贵, $H$ 可能 ill-conditioned. 工程变体:

  • 拟牛顿 BFGS / L-BFGS: 用历史梯度更新 $H^{-1}$ 近似.
  • 共轭梯度 CG: 只需 $H$ × 向量乘法.

3.4 为什么深度学习不用 Newton?

  • 参数维度 $n \sim 10^9$, $H \in \mathbb{R}^{10^9 \times 10^9}$ 显然太贵.
  • 非凸+随机性 ⇒ 一阶 SGD 经验反而更好+可 scale.
  • 反向传播比求逆便宜太多.

→ 这就是 Adam 之类一阶方法的现实位置.


四、凸优化

4.1 凸函数

凸集 $S \subseteq \mathbb{R}^n$: $\forall \boldsymbol x, \boldsymbol y \in S, \lambda \in [0, 1]$: $\lambda \boldsymbol x + (1-\lambda) \boldsymbol y \in S$.

凸函数 $f: S \to \mathbb{R}$:

$$ f(\lambda \boldsymbol x + (1-\lambda)\boldsymbol y) \leq \lambda f(\boldsymbol x) + (1-\lambda)f(\boldsymbol y) $$

判定: 一阶 (凸) $\nabla^2 f \succeq 0$.

凸优化强:

  • 任何局部最小 = 全局最小.
  • KKT 条件充要 (约束情形).
  • 多项式时间可达 $\epsilon$-optimal (Interior Point).

note

即使损失非凸, 工程上常见"局部凸": 在 good init 周围 Hessian 半正定, Newton / GD 仍可以收敛到本地区的最优. 这是 NTM、LoRA fine-tune 的"为什么调一调就 OK".

4.2 典型凸损失

损失形式
平方 $(y - \hat y)^2$强凸Gauss 似然 + 方差常量
绝对 $y - \hat y$
Huber平方绝对拼接robust to outliers
Logistic $\log(1 + e^{-y\hat y})$凸、光滑二项式 MLE
Softmax CE凸 (在权重)多项式 MLE
Hinge $\max(0, 1-y\hat y)$凸但非光滑SVM
exp-convexboosting

4.3 拉格朗日对偶

原问题 $\min f(\boldsymbol x) \text{ s.t. } g_i(\boldsymbol x) \leq 0, h_j(\boldsymbol x) = 0$:

$$ \mathcal{L}(\boldsymbol x, \boldsymbol\lambda, \boldsymbol\nu) = f(\boldsymbol x) + \sum_i \lambda_i g_i(\boldsymbol x) + \sum_j \nu_j h_j(\boldsymbol x) $$

对偶函数 $g(\lambda, \nu) = \inf_x \mathcal{L}$. главная trick: 最大化对偶 ⇒ 上界原问题最优. 弱对偶恒, 强对偶凸+slater 条件.

4.4 KKT 条件

最优 $\boldsymbol x^*$ 满足:

  1. Stationarity: $\nabla f + \sum_i \lambda_i \nabla g_i + \sum_j \nu_j \nabla h_j = 0$.
  2. Primal feasibility: $g_i \leq 0, h_j = 0$.
  3. Dual feasibility: $\lambda_i \geq 0$.
  4. Complementary slackness: $\lambda_i g_i(\boldsymbol x^*) = 0$.

→ SVM 的推导从这里来; 因凸, KKT 充要.

4.5 LP / QP / SDP

LP:  min c^T x  s.t. Ax = b, x ≥ 0            (单纯形 / interior point)
QP:  min (1/2)x^T Q x + c^T x  s.t.  linear  (Q ≥ 0 ⇒ 凸)
SDP: min c^T x  s.t. F_0 + Σx_i F_i ⪰ 0      (矩阵半正定约束)

应用: LP 调度/路由; QP 投资组合 / SVM; SDP 控制论 / PhyLayer 检验扩展 Lyapunov.


五、迭代一阶优化器谱系

5.1 梯度下降 (GD)

$$ \boldsymbol x_{k+1} = \boldsymbol x_k - \eta \nabla f(\boldsymbol x_k) $$

  • $\eta$ 小 ⇒ 慢但稳; 大 ⇒ 震荡可能发散.
  • 收敛分析: $\eta \leq 1/L$ 时凸 $L$-smooth ⇒ $f_k - f^* = O(1/k)$.

5.2 SGD (Robbins-Monro 1951)

每次用 mini-batch 估计 $\nabla f \approx \hat g_k$:

$$ \boldsymbol x_{k+1} = \boldsymbol x_k - \eta_k \hat g_k $$

学习速率 schedule: $\sum \eta_k = \infty, \sum \eta_k^2 < \infty$ ⇒ a.s. 收敛 (Robbins-Monro).

5.3 Momentum / Nesterov

Momentum 在梯度方向添加"惯性":

$$ v_{k+1} = \beta v_k - \eta \nabla f(\boldsymbol x_k), \quad \boldsymbol x_{k+1} = \boldsymbol x_k + v_{k+1} $$

Nesterov 加速: 先用 $\boldsymbol x_k + \beta v_k$ 评估梯度.

直觉: 沿一致方向加速, 沿高频噪声阻尼. 凸情形 Nesterov 给最优 $O(1/k^2)$ 速率.

5.4 RMSProp

$$ \mathbb{E}[g^2]k = \alpha \mathbb{E}[g^2]{k-1} + (1-\alpha) g_k^2, \quad \boldsymbol x_{k+1} = \boldsymbol x_k - \frac{\eta}{\sqrt{\mathbb{E}[g^2]_k + \epsilon}} g_k $$

关键观察: 不同参数维度, 不同尺度梯度自适应 → Adam 接近 "1 个 axis 1 个步长".

5.5 Adam (Kingma & Ba 2014)

$$ m_k = \beta_1 m_{k-1} + (1-\beta_1) g_k $$ $$ v_k = \beta_2 v_{k-1} + (1-\beta_2) g_k^2 $$ $$ \hat m_k = \frac{m_k}{1 - \beta_1^k}, \quad \hat v_k = \frac{v_k}{1-\beta_2^k} $$ $$ \boldsymbol x_{k+1} = \boldsymbol x_k - \eta \frac{\hat m_k}{\sqrt{\hat v_k} + \epsilon} $$

  • 默认 $\beta_1 = 0.9, \beta_2 = 0.999, \epsilon = 10^{-8}$.
  • 动量 + RMSprop 合一; bias correction 针对初始时刻.
  • ML 中 99% 任务用 Adam 即可跑出 baseline.
def adam_update(params, grads, m, v, t, lr=1e-3, b1=0.9, b2=0.999, eps=1e-8):
    for p, g in zip(params, grads):
        m[p] = b1 * m[p] + (1 - b1) * g
        v[p] = b2 * v[p] + (1 - b2) * g * g
        m_hat = m[p] / (1 - b1 ** t)
        v_hat = v[p] / (1 - b2 ** t)
        p -= lr * m_hat / (v_hat.sqrt() + eps)   # 高级 pseudocode
    return params, m, v

warning

Adam 标准实现的"收敛证明" (Reddi et al. 2018) 发现初版收敛不严, 改进如 AMSGrad / AdamW 解决. 现代 Transformer 训练几乎全用 AdamW (decoupled weight decay), 不用 vanilla Adam.

5.6 Optimizer 速查

优化器用途
SGDCV 大模型 (ResNet), 带强 augmentation
SGD+MomentumCV 稳态训 90%
RMSPropRNN 经典
Adam默认, including transformer
AdamWTransformer (decoupled decay)
LAMB / LARS大 batch 稳定 (BERT scale)
Sophia / Shampoo二阶近似, 2-3× 加速, research-stage

六、信息几何

6.1 分布空间作流形

参数族 ${p_\theta: \theta \in \Theta}$, 一个分布对应一个参数点. Fisher 信息矩阵:

$$ \mathcal{I}(\theta){ij} = \mathbb{E}{x \sim p_\theta}\left[\frac{\partial \log p_\theta}{\partial \theta_i} \frac{\partial \log p_\theta}{\partial \theta_j}\right] $$

直觉: Fisher 信息 = "你能用数据区分参数变化的灵敏度". 实际 $\mathcal{I} = -\mathbb{E}[H_{\log p}]$ (期望负 Hessian).

6.2 KL 看作曲率

对参数 $\theta$ 微扰 $d\theta$, KL 散度局部展开:

$$ \mathrm{KL}(p_\theta | p_{\theta + d\theta}) = \frac{1}{2} d\theta^\top \mathcal{I}(\theta) d\theta + o(|d\theta|^2) $$

→ Fisher 矩阵就是分布参数空间的黎曼度量.

6.3 自然梯度

普通梯度下降不看度量; 用 Fisher 修正:

$$ \theta_{k+1} = \theta_k - \eta \cdot \mathcal{I}_k^{-1} g_k $$

直觉: 让"参数行进一步"在分布空间等价 (而不是欧式). 在 KL 时 $\theta$ 的大变化不一定 = $p$ 的大变化, 自然梯度把它校正.

→-policy gradient 变体如 TRPO / PPO 的原理, 自然梯度约束 $\mathrm{KL} \leq \delta$.

6.4 ELBO = KL 反向最小

变分推断要找近似 $q_\phi$ 拟合 $p(z | x)$. 写 ELBO:

$$ \mathcal{L}(\phi) = \mathbb{E}{q\phi}[\log p(x, z)] - \mathbb{E}{q\phi}[\log q_\phi(z)] $$

最大化 $\mathcal{L}$ ⇔ 最小化 $\mathrm{KL}(q_\phi | p_{z|x})$. VAE 解码器就是这么训.

note

信息几何 + Bayesian 推断 + KL 散度是一件事的三个名: 在统计参数空间用 Fisher 内积做梯度下降,(reverse-)KL 是 Riemannian metric 赋值.


七、其他常用工具

7.1 Logistic 函数 vs Softmax

$$ \sigma(x) = \frac{1}{1 + e^{-x}} = \mathrm{softmax}_+[x, 0] $$

→ 二分类 softmax = Logistic; $K$-分类是 softmax.

7.2 SVD 协助 PCA (重申线代)

PCA = 数据协方差矩阵 $\Sigma = (X - \bar X)^\top (X - \bar X)/n$ 做特征分解. 取 top-$k$ 特征向量方向. 等价: 数据矩阵 SVD 左奇异向量.

→ 推荐用 svd 而非 eig (数值更稳).

7.3 对偶: 极大熵分布

满足某些矩约束 $\mathbb{E}_p[g_i(x)] = c_i$ 的所有 $p$ 中, 熵最大的 $p^*$ 是指数族:

$$ p^*(x) = \frac{1}{Z} \exp\left(\sum_i \lambda_i g_i(x)\right) $$

→ Logistic 回归就是均值给定下熵最大的伯努利. 这是 Logistic 给"伯努利 + 高斯先验 → 应用最广"的数学根.

7.4 重要公式速查

公式形式
softmax 导数向量化$(\boldsymbol{ds}) = s \odot (\boldsymbol{dz} - \mathbf{1}(s^\top \boldsymbol{dz}))$
Newton step$\boldsymbol x - H^{-1} \nabla f$
KL 局部展开$\mathrm{KL}(p_\theta | p_{\theta+d}) = \frac{1}{2} d^\top \mathcal{I} d + o(|d|^2)$
链式$\frac{\partial (f \circ g)}{\partial \boldsymbol x} = J_g^\top \nabla f$
Jensen (凸 $\varphi$)$\mathbb{E}[\varphi(X)] \geq \varphi(\mathbb{E}[X])$, KL 是 Jensen 直推

八、结束 + 速查表

tip

一页快速唤回:

  • 链式: $\partial(f \circ g) / \partial \boldsymbol x = J_g^\top \nabla f$.
  • softmax 反向: $\boldsymbol{ds} = s \odot (\boldsymbol{dz} - \mathbf{1}(s^\top \boldsymbol{dz}))$.
  • CE+softmax: 反传对 logit = $s - \mathrm{onehot}$.
  • Hessian: $\nabla^2 f$; 半正定 ⇒ 局部最小.
  • Newton: $-H^{-1} \nabla f$, 二次收敛, $O(n^3)$ 反演费.
  • : 集合 + 函数 / KKT 充要 / LP-QP-SDP 递增.
  • Adam: $\hat m / \sqrt{\hat v} + \epsilon$; Transformer 默认 AdamW.
  • Fisher: $\mathcal{I} = \mathbb{E}[\nabla \log p \cdot \nabla \log p^\top]$.
  • 自然梯度: $\theta - \eta \mathcal{I}^{-1} \nabla$; TRPO/PPO.
  • ELBO: $\mathbb{E}_q[\log p - \log q]$, reverse-KL min.
  • Taylor: 局部 $f + \nabla^\top h + \frac12 h^\top H h$.
  • Lagrange: $\mathcal{L} = f + \lambda g + \nu h$; KKT 4 条件.
  • 极值极大熵 → 指数族 (Bernoulli → LR, Multinomial → Softmax).

回主目录: 第零部分 · 工程数学与离散数学基础 README. 下一篇系统正文: 第一部分 · DSA 开篇.

工程化实践轴 · 让代码真正跑进生产

一句话

前面 14 个部分(导论 / 数学 / 13 主题)讲的是**"计算机是什么、为什么这样设计";这一轴补另一半——"作为 1-20 年的工程师,你怎么把代码安全、可测、可发布、可观测、可维护地交到生产环境"**。它不重复任何一部的原理(Git 讲透但不重讲 DSA),而是把工程师每天面对的手艺活系统化:版本控制、测试、CI/CD、性能工程、应用安全、代码质量、可观测性。

思想链

[你写完了 feature 代码]
  └─> Git 分支 + 提交规范 + code review
        └─> CI 跑测试(单元/集成/契约/E2E)
              └─> 构建产物 → 镜像 → 发布(蓝绿/金丝雀)
                    └─> 上线后 metrics/logs/traces 三件套观测
                          └─> 性能劣化 → profiling 定位 → 优化
                                └─> 安全:OWASP + 认证授权 + 供应链
                                      └─> 回头重构 → 代码质量维持
                                            └─> 这就是工程化的完整闭环

每一环都依赖前面 14 部分的原理(Git 基于 DAG/哈希、CI 基于 shell、性能基于组成原理的 cache/IO),但方法本身是独立的手艺——这正是"工程化实践"和"计算机基础"的互补关系。

章节结构

与其余各部分的接口表

本轴章节依赖的原理部分互补关系
Git 工作流分布式 §时钟/DAG、DSA §哈希表Git 是"内容寻址 DAG"的具体实例
测试工程编译 §语义分析、数学 §逻辑测试 = 可验证性工程化(README 定位"可被验证"落地)
CI/CDOS §shell/进程、网络 §HTTP、分布式 §一致性发布 = 状态机 + 幂等 + 原子切换
性能工程组成原理 §cache/IO/流水线、OS §调度/内存profiling = 把性能归因到硬件层
应用安全密码学 §侧信道/TLS、网络 §HTTP/TLSOWASP 是"应用层"的密码学落地
代码质量编译 §AST/复杂度、理论 §可判定性重构 = 保持行为的语义等价变换
可观测性系统设计 §monitor/SLO、分布式 §故障模型三件套 = 分布式调试的基础设施

这轴不是什么

  • 不是重复原理章节(不重讲 B+树 / TCP / 哈希)。它假设你已经会,讲的是怎么用、怎么落地、怎么诊断
  • 不是某一门语言的教程(不教 Go 语法)。例子用 Go/Python/TS 等,但方法通用。
  • 不是工程管理/软技能(不谈绩效、团队协作)。
  • 不是某一家云厂商/单一平台的完整文档(GitHub Actions / K8s 为实例,但方法论通用)。

阅读路径

  • 1-5 年工程师:§1 Git → §2 测试 → §3 CI/CD → §8 GitHub Actions → §5 应用安全(先建立规范直觉)
  • 5-15 年工程师:§4 性能 → §6 代码质量 → §7 可观测性 → §9 GitOps → §10 SRE(深入到方法学)
  • 想横通:按 Git → 测试 → CI/CD → Actions → 发布/GitOps → 可观测 → 性能 → 安全 → 重构 → SRE 的交付闭环顺序走

下一篇: 1. Git 与版本控制: 原理 / 分支模型 / 合并策略 / 进阶命令.

1. Git 与版本控制: 原理 / 分支模型 / 合并策略 / 进阶命令

TL;DR

Git 是工程师每天用最多、却最容易被"记命令"蒙混过的工具。理解 Git 的底层模型(内容寻址 DAG)后,几乎所有"诡异行为"都能推理出来,而不是靠背 git reset --hard。这一章讲透:

  1. 对象模型:blob / tree / commit / tag 都是什么,Git 为什么是"内容寻址存储"。
  2. 引用与 HEAD:branch / tag / HEAD 的真相,detached HEAD 是什么。
  3. 暂存区与工作区add/commit/checkout/reset 到底在动什么。
  4. 分支模型:GitFlow / GitHub Flow / Trunk-Based 对比,怎么选。
  5. 合并与变基:merge vs rebase vs cherry-pick,冲突解决。
  6. 回滚与恢复reset 三种模式、revertreflog 救命。
  7. 团队协作:pull request / 保护分支 / 语义化提交。

读完应能:不慌任何 Git 场景——合并冲突、误删、改错分支、把 WIP 弄丢、多分支拉错。


一、Git 的对象模型(一切的根)

1.1 四个对象类型

对象内容特点
blob一个文件的内容无文件名,只存内容
tree一个目录:文件名 → blob/tree 映射记录目录结构
commit一次提交:指向 tree + parent(s) + 作者/时间/消息不可变快照
tag指向某个 commit(或带注释)命名的锚点

1.2 内容寻址

所有对象都用 SHA-1/SHA-256 哈希(内容哈希)作为地址。同样的内容永远得到同样的哈希——这是 Git 一切特性(去重、快照、不可变)的根

commit  (哈希 = f(内容))
  ├─→ tree      (整个目录快照)
  │     ├─→ blob "main.go"     (文件内容)
  │     └─→ tree "pkg/"
  └─→ parent commit (父提交, 可多个 = merge)

1.3 为什么 Git 快

  • 快照而非 diff:每个 commit 存整棵 tree,但 blob 按内容去重——没改的文件复用同一 blob 哈希,只存一次。
  • 不可变:commit 生成后内容不变,新提交只新增。这保证历史可信任、可审计。

note

理解了这个,git log 为什么是"沿 parent 指针走 DAG"、git diff A B 为什么能精确算差异、.git 为什么能瘦身——全都有了解释。Git 就是"分布式 §DAG 章节"的活实例。


二、引用、HEAD 与三区

2.1 三区模型

区域是什么命令影响它
工作区你看到的文件所有编辑
暂存区(index)准备提交的快照git add
仓库(.git)已提交的历史git commit
工作区  --git add-->  暂存区  --git commit-->  仓库
  ↑                     ↑                        ↑
git checkout          git reset (mixed)       git reset --hard

2.2 引用(refs)与 HEAD

  • branch 是一个指向 commit 的指针(.git/refs/heads/xxx)。
  • HEAD 是一个特殊指针,指向"当前在哪个分支/commit"。
  • 分支不是"装提交的容器",只是会移动的指针——commit 本身在全球共享的 DAG 里。

2.3 detached HEAD

当 HEAD 不指向任何分支(git checkout <commit>)时进入 detached 状态:你可以在任何 commit 上工作,但新提交不挂在任何分支名下,切走就可能"丢"(其实 reflog 里还在)。

warning

在 detached HEAD 上做了想保留的改动:立刻 git switch -c <新分支名> 建分支保住,否则切走后只能靠 git reflog 捞。


三、分支模型:三种主流

3.1 GitFlow(经典但重)

master ───────────────────────────────
         \                    /
          \ develop ─────────/──────
                \       /
                 \ feature
  • master 只接受 release;develop 是集成分支;feature/release/hotfix 围绕它。
  • 适用:固定发布周期、版本化产品(传统企业)。
  • 问题:分支多、合并复杂,对持续发布偏重。

3.2 GitHub Flow(轻量主流)

  • 只有 main,永远可部署;每个改动开短命分支 → PR → 合并回 main。
  • 适用:持续交付的互联网产品(主流现代团队)。
  • 特点:PR 即审核 + CI + 自动部署。

3.3 Trunk-Based(极致 CI)

  • 所有人直接往 main 提交(或短命分支 < 1 天),靠 feature flag 控制上线。
  • 适用:强 CI/CD、Google/Netflix 风格、高频发布。
  • 特点:集成冲突最少、但要求测试覆盖与自动化极强。
模型分支复杂度发布频率适合
GitFlow低(版本化)传统产品、发布周期
GitHub Flow互联网产品默认
Trunk-Based极高强 CI/CD 团队

四、合并策略:merge / rebase / cherry-pick

4.1 merge(保留历史分叉)

git switch main && git merge feature

产生一个 merge commit,历史保留两分支的真实形状。历史忠实但分叉多

4.2 rebase(线性化历史)

git switch feature && git rebase main

把 feature 的提交"搬到" main 顶端重新应用,产生线性历史。

mergerebase
历史形状分叉 + merge commit线性
提交哈希不变会变(新提交)
什么时候用公共分支、保留真相私有分支、整理历史
风险已 push 的分支 rebase 会出问题

warning

永远不要 rebase 已 push 到共享的分支。别人基于旧哈希的提交会错乱。规则:rebase 只用于自己还没推过的私有提交;共享分支用 merge。

4.3 cherry-pick(挑单个提交)

git cherry-pick <commit-hash>

把别的分支上的单个提交应用到当前分支。用于热修复、挑某个修复进 release。

4.4 冲突解决

冲突标记:<<<<<<< HEAD / 你的代码 / ======= / 对方的代码 / >>>>>>> branch

git merge main          # 报冲突
# 编辑文件解决冲突 → 删除冲突标记
git add 文件
git commit              # merge 场景:直接 commit 完成

tip

大冲突别硬解:先 git merge --abort 冷静,用 git merge-base A B 看分歧点,或者 git checkout --theirs/--ours 文件 选一边再改。


五、回滚与恢复:reset / revert / reflog

5.1 reset 三模式

git reset --soft  <commit>   # 只移 HEAD, 暂存区和工作区不动
git reset        <commit>   # mixed: 移 HEAD + 清暂存区, 工作区不动(默认)
git reset --hard <commit>   # 全清: 工作区也丢弃改动 ⚠️危险

5.2 revert(安全回滚,推荐用于已推送)

git revert <commit>

产生一个"反向提交",不改历史,只是加一个新提交抵消那个提交的改动。适合已 push / 公共分支,因为不动历史,别人 pull 无冲突。

5.3 reflog(救命日志)

git reflog     # 列出 HEAD 移动的全部记录(含被删的分支/commit)
git reflog --all
git reset --hard HEAD@{5}   # 回到 5 步前的状态

warning

Git 的对象在 reflog 过期前不会真正删除(默认 90 天)。误删分支、reset 过头、checkout 丢了 WIP,都先查 git reflog 再行动。这几乎是"Git 数据恢复"的第一手段。


六、团队协作实践

6.1 Pull Request 流程(GitHub Flow 标配)

feature 分支 → git push origin feature
→ 开 PR → CI 跑测试 → code review → 合并(squash / merge / rebase-merge)

三种合并方式:

方式效果适用
Squash merge整条分支压成 1 个提交特性合并进 main,历史整洁
Merge commit保留分叉想保留分支历史
Rebase merge线性化已通过 rebase 整理的分支

6.2 保护分支(protected branch)

main 应设置:不允许直接 push、必须有 PR + 至少 1 人 approve、CI 必须通过、线性历史。这是团队的"最后防线"。

6.3 语义化提交(Conventional Commits)

feat(api): 新增用户查询接口
fix(cache): 修复 key 过期竞态
docs(readme): 更新部署说明
refactor(db): 拆分连接池

feat/fix 直接驱动 changelog 和语义化版本(semver):feat → minor,fix → patch,BREAKING CHANGE → major。让提交消息变成机器可读的元数据。

6.4 子模块与 monorepo

  • monorepo:全代码一个仓(Google/字节模式),靠 build system 隔离,PR 全局可见。
  • submodule:嵌套仓库,但要小心(子模块不自动跟随父仓 checkout)。
  • 现在更多团队用 workspace(pnpm/yarn workspaces、Go workspaces)替代 submodule。

七、常用命令速查

# 诊断
git status / git log --oneline --graph / git diff / git diff --staged
git branch -a / git remote -v

# 提交
git add -p           # 交互式分块暂存
git commit --amend   # 修改上一次提交消息(未推送时)
git stash / git stash pop    # 暂存 WIP
git stash list / git stash drop

# 分支
git switch -c feature    # 建并切换
git branch -d feature    # 删已合并分支
git fetch --prune        # 清理远端已删分支

# 撤销
git restore 文件         # 丢弃工作区改动(替代 checkout -- 文件)
git restore --staged 文件 # 取消暂存
git reset --hard HEAD    # 丢弃全部未提交(小心)

# 高级
git blame 文件           # 谁改的
git log -S 字符串        # 找哪次提交加了/删了该字符串
git bisect start         # 二分找引入 bug 的提交
git clean -fd            # 清理未跟踪文件(小心)
git filter-repo          # 重写历史(慎用)

八、实战场景

8.1 场景 1:改错了分支

# 在 main 上改了 feature 的代码,还没提交
git stash                          # 暂存改动
git switch feature
git stash pop                      # 恢复改动到 feature

8.2 场景 2:提交到 main 想挪到 feature

# 已有 commit 在 main
git switch -c feature              # 分支带上当前状态
git switch main
git reset --hard HEAD~1            # main 后退一提交(本地)
git push origin main --force-with-lease   # 覆盖远端(注意:只对没共享的分支)

warning

--force vs --force-with-lease:后者先检查远端是否被其他人更新过,防止覆盖他人提交。永远优先用 --force-with-lease

8.3 场景 3:误删分支 / 丢失提交

git reflog          # 找到那个提交的哈希
git branch -c <哈希> recovered   # 从哈希建回分支

8.4 场景 4:WIP 弄丢

git fsck --lost-found        # 找 dangling 对象
# 或 reflog 找 stash 记录
git stash list && git stash apply stash@{0}

九、结束 + 速查表

tip

一页快速唤回:

  • Git = 内容寻址 DAG:blob/tree/commit/tag,哈希即地址,不可变、去重、可快照。
  • 三区:工作区 → add → 暂存区 → commit → 仓库。
  • 分支 = 会移动的指针;HEAD 指向当前。
  • rebase 只用于未推送的私有提交;共享分支用 merge。
  • 回滚reset --hard 危险(动历史),revert 安全(加反向提交),reflog 是救命日志。
  • 分支模型:GitFlow(重)/ GitHub Flow(主流)/ Trunk-Based(极致 CI)。
  • 保护分支:PR + approve + CI 通过 + 线性历史。
  • 语义化提交:feat/fix/docs → 驱动 changelog + semver。
  • 数据恢复:先 reflog,再 fsck --lost-found。
  • force push 用 --force-with-lease,不要裸 --force

下一篇: 2. 测试工程: 测试金字塔 / 单元/集成/契约/E2E / mock / flaky 治理.

2. 测试工程: 测试金字塔 / 单元/集成/契约/E2E / mock / flaky 治理

TL;DR

测试不是"写几个 assert",而是一套控制回归风险的系统。这一章把测试从"会写"提升到"会设计":知道测什么、在哪一层测、用什么替身、怎么让测试不 flaky、怎么用覆盖率做决策(而不是当指标)。

读完应能:

  1. 说清测试金字塔为什么是对的,以及它不适用时的例外。
  2. 区分单元 / 集成 / 契约 / E2E 四种测试的职责与成本。
  3. 正确使用 mock/stub/fake/spy,知道"过度 mock"的病。
  4. 诊断并治理 flaky 测试。
  5. 用测试覆盖率的正确用法辅助重构决策。

一、测试金字塔

1.1 分层与成本

        / E2E \           数量少(几十)
       /  契约  \         数量中(几百)
      /   集成    \       数量多(几千)
     /    单元      \     数量最多(几万)
    /________________\
层级速度成本稳定性隔离度测什么
单元毫秒-秒一个函数/类
集成秒-分模块间、DB/网络
契约服务间接口
E2E分-时整条用户路径

原则:越往上数量越少。金字塔的几何形状就是最优成本结构——把多数测试放在快而稳定的底部。

1.2 反模式

  • 倒金字塔:全 E2E,慢、flaky、难定位 → 是测试最贵且最没用的形态。
  • 冰淇淋筒:上面 E2E 多、中间少、底部 mock 一堆 → 单元测试全是 mock,集成空缺。
  • 无测试:靠手动 QA → 回归全靠人肉,无法持续发布。

1.3 覆盖策略(Test Pyramid 的落地)

每个功能改动的理想测试分布(Google 实践):

单元: 70-80%   — 覆盖所有分支/错误路径(快、密集)
集成: 15-20%   — 覆盖模块组合、真实 DB/依赖
E2E : 2-5%     — 关键 happy path 冒烟
契约: 按服务边界补 — 微服务间接口防破坏

二、四种测试的职责

2.1 单元测试(Unit)

  • 一个函数/类的单一行为,不跨模块、不碰 IO。
  • 目标:逻辑正确、边界处理、错误路径、性能敏感函数。
  • 关键:测行为不测实现(改实现不该破坏测试)。
// Go 单元测试示例
func TestCalculateTotal(t *testing.T) {
    items := []Item{{Price: 10, Qty: 2}, {Price: 5, Qty: 1}}
    got := CalculateTotal(items)
    if got != 25 {
        t.Errorf("got %v want 25", got)
    }
}
# Python 单元测试
def test_calculate_total():
    assert calculate_total([("a", 10, 2), ("b", 5, 1)]) == 25

def test_empty_cart():
    assert calculate_total([]) == 0

2.2 集成测试(Integration)

  • 模块之间的真实协作,包括真实 DB、真实网络、真实文件系统。
  • 捕获的问题:API 签名不匹配、类型/编码转换、事务边界、并发竞态。
  • 手段:真实依赖(Testcontainers 起真实 DB/redis)+ 少用 mock。
# 用 Testcontainers 起真实 Postgres
def test_user_persistence():
    with Testcontainers("postgres:16").running() as pg:
        repo = UserRepo(pg.get_connection_url())
        repo.create(User("alice"))
        assert repo.find("alice").name == "alice"

2.3 契约测试(Contract)

  • 微服务间:消费方(consumer)与提供方(provider)各持一份契约,独立验证。
  • Pact 思路:consumer 生成契约(它期望的请求/响应),provider 验证自己满足契约。
  • 价值:两端独立部署也不会悄悄破坏接口——比 E2E 快得多,且精准定位是哪端破坏。
消费者 (consumer)                     提供者 (provider)
   └─ 期望: GET /user/1 → {id:1}       └─ 验证: 我确实返回 {id:1}
        生成契约文件 pact.json ──────→  provider 测试跑契约

2.4 E2E 测试

  • 模拟真实用户完整路径:登录 → 操作 → 结果,跑在完整部署上(浏览器/真实 API)。
  • 工具:Playwright / Cypress / Selenium。
  • 定位:冒烟(关键路径别挂)+ 少量核心流程,不是全覆盖。
// Playwright E2E
import { test, expect } from '@playwright/test';

test('用户能下单', async ({ page }) => {
  await page.goto('/');
  await page.getByText('添加购物车').click();
  await page.getByRole('button', { name: '结算' }).click();
  await expect(page).toHaveURL(/\/checkout/);
  await expect(page.getByText('订单成功')).toBeVisible();
});

三、测试替身(Test Double):mock / stub / fake / spy

3.1 四种替身

替身作用何时用
Stub返回预设数据,不含逻辑提供依赖返回值
Mock验证"方法被调用且参数正确"验证交互行为
Fake简化实现(内存版 DB)替代重依赖
Spy记录调用供断言检查是否被调用

3.2 过度 mock 的问题

# ❌ 过度 mock:测试的是 mock 自己,不是代码
def test_order(mocker):
    db = mocker.patch("app.db.query")      # mock 了 DB
    cache = mocker.patch("app.cache.get")  # mock 了缓存
    notify = mocker.patch("app.notify")    # mock 了通知
    # ... 三个全 mock 后,测的其实是胶水,业务逻辑没测到

# ✅ 更真实:保留逻辑层,mock 只在 IO 边界
def test_order_flow(test_db):
    repo = UserRepo(test_db)              # 真实 DB(fakeredis / testcontainers)
    result = place_order(repo, cart)
    assert result.status == "paid"

判断标准:如果 mock 掉的东西越多、断言越细,测试就越脆。只在"边界 IO"(外部服务、时间、随机)处 mock,业务逻辑用真实实现。

3.3 依赖注入让测试容易

# 设计上支持替换依赖
def send_notification(sender: Notifier, msg: str):   # 传入接口
    sender.push(msg)

# 测试时传 Fake
class FakeNotifier(Notifier):
    def __init__(self): self.sent = []
    def push(self, msg): self.sent.append(msg)

def test_send():
    fake = FakeNotifier()
    send_notification(fake, "hi")
    assert fake.sent == ["hi"]

tip

"依赖注入 + 接口"比"全局 mock 补丁"更干净。可测性是最被低估的架构属性——代码可测,通常意味着解耦良好。


四、Flaky 测试治理

4.1 什么是 flaky

同一份代码,跑两次结果不同——一次过一次挂。Flaky 是持续交付的最大敌人:它让 CI 信号不可信,开发开始忽略红

4.2 常见根因

根因例子解法
时序/竞态断言在异步回调前执行轮询等待而非 sleep;用 eventually
随机性测试依赖随机数/时间注入确定性种子 / fake clock
共享状态测试间共享 DB/静态变量每测试独立隔离(truncate/txn rollback)
环境依赖依赖网络/外部服务契约测试替代;本地 stub
顺序依赖测试依赖前一个测试留下的状态每测试自含 setup/teardown

4.3 反例:sleep 是罪魁

# ❌ sleep 猜测时序
response = api.start_async_job()
time.sleep(5)                     # 5 秒后应该完成了
assert response.done

# ✅ 显式等待直到条件满足
def wait_until(predicate, timeout=10):
    deadline = time.monotonic() + timeout
    while time.monotonic() < deadline:
        if predicate(): return True
        time.sleep(0.1)
    raise TimeoutError()

assert wait_until(lambda: api.job_status(job_id).done)

4.4 治理流程

1. 复现:用 --count=100 反复跑(pytest: --count, go test: -count=100)
2. 隔离:找到 flaky 的最小复现
3. 定位:加日志 / 用 go test -race / pytest-timeout
4. 修复:消除根因(上面表)
5. 防再发:把修复用例留成回归;加 CI 的 retry 是最后手段,不是解法

五、覆盖率:正确使用

5.1 覆盖率不是目标

  • 覆盖率高 ≠ 测试好:可以 100% 覆盖而全是无效断言。
  • 但覆盖率低几乎总是坏(除非是新代码区)。
  • 正确用法:辅助发现"没测的路径",而不是 KPI。

5.2 有用的度量

度量含义用途
Line coverage行覆盖基本体检
Branch coverage分支覆盖(if/else/switch)比行覆盖更准
Mutation testing改代码看测试会不会红验证测试质量
Diff coverage只统计这次改动覆盖重构/新功能最该看

note

Diff coverage(新增/改动代码的覆盖率)比总量覆盖更有意义。它回答"我这次改动有没有测到"。很多团队用工具(如 coveralls/sonar)把 diff coverage 卡在阈值(如 80%),效果远比总量阈值好。

5.3 覆盖率的正确决策流

写代码 → 写测试 → 看 diff coverage
  ↓ 低分支覆盖
  → 补分支:错误路径 / 边界 / 空值
  ↓ 高覆盖但测试很脆
  → 检查:是否在测实现细节?是否需要重构测试?

六、测试策略落地(团队层面)

6.1 CI 里的测试分层执行

PR 阶段(快,必跑):
  - 单元测试(毫秒级,全部跑)
  - Lint + 类型检查
  - 变更文件的 diff coverage 检查

合并后(慢,异步):
  - 集成测试(Testcontainers)
  - 契约测试(Pact)
  - E2E 冒烟(部署到 staging)

6.2 测试金字塔的健康检查

  • 单元测试跑 < 1 分钟 → 正常;> 5 分钟 → 该拆测试或并行。
  • E2E 占总量 > 5% → 危险,该下移。
  • 大量测试需要 mock 数据库 → 架构耦合问题。
  • 测试常"修一下就好"(改断言不测逻辑)→ 测试在测实现,不是行为。

6.3 先写测试 vs 后写测试

  • TDD(先写失败测试):适合纯逻辑、算法、规则引擎。
  • 后写测试:适合探索性代码、UI、集成层。
  • 关键不是"谁先",而是每段代码离开手前都要有测试覆盖

七、结束 + 速查表

tip

一页快速唤回:

  • 测试金字塔:单元(多快稳)> 集成 > 契约 > E2E(少慢脆)。
  • 单元:测行为不测实现;一个函数一类行为。
  • 集成:真实 DB/依赖,Testcontainers 起真实服务。
  • 契约:consumer/provider 各持契约,微服务防接口破坏。
  • E2E:关键路径冒烟,不是全覆盖。
  • 替身:Stub(返回数据)/ Mock(验证交互)/ Fake(简化实现)/ Spy(记录调用);只在 IO 边界 mock。
  • 可测性 = 依赖注入 + 接口,是最好的架构属性。
  • Flaky 根因:时序/随机/共享状态/环境/顺序依赖;用 wait_until 不用 sleep。
  • 覆盖率:diff coverage 比总量有意义;测行为不测行数。
  • CI 分层:PR 跑快测,合并跑慢测。

下一篇: 3. CI/CD 与发布工程: 管线 / 镜像 / 蓝绿金丝雀 / IaC.

3. CI/CD 与发布工程: 管线 / 镜像 / 蓝绿金丝雀 / IaC

TL;DR

CI/CD 是把代码从提交到生产的一条自动化流水线。CI(持续集成)保证每次提交都能构建+测试通过;CD(持续交付/部署)把通过的产品可靠地发布出去。这一章讲的不是某个平台(GitHub Actions/GitLab CI/Jenkins)的教程,而是发布工程的通用模型——管线怎么设计、产物怎么管理、发布怎么无痛、基础设施怎么管理。

读完应能:

  1. 画出 CI/CD 管线的完整阶段(提交 → 构建 → 测试 → 产物 → 部署 → 验证 → 回滚)。
  2. 理解镜像/不可变产物与"代码即配置"的区别。
  3. 讲清蓝绿、金丝雀、滚动、重建四种部署策略的取舍。
  4. 知道 IaC(Infrastructure as Code)怎么做、为什么必须。
  5. 设计一个"小步快速失败"的发布流程。

一、CI/CD 是什么

1.1 CI:持续集成

每次提交(或 PR 合并)自动执行:

提交 → 检出 → 构建 → 单元测试 → Lint → 集成测试 → 产物 → 报告

目的:尽快暴露"集成问题"。核心指标:合并到主干的 commit 到 CI 变绿的时间,应该以分钟计。

1.2 CD:持续交付/部署

  • 持续交付:通过 CI 的产物随时可以发布到生产(但发布是人为按钮)。
  • 持续部署:通过所有关卡后自动发布到生产。

区别就是"要不要人按发布键"。

1.3 为什么重要

  • 发布频率越高、每次改动越小 → 故障定位越容易 → 回滚越简单。
  • 反例:半年发一次大版本,一次带 500 个 commit,出事无法定位。

note

核心心智:"小步快速失败"。让失败发生在 CI 的早期阶段(快测试),而不是生产(用户看到)。


二、管线设计:一个标准流水线

┌────────────────────────────────────────────────────────────┐
│ Stage 1: 检出 + 依赖                                       │
│   git checkout; install deps (go mod / npm ci / pip)       │
├────────────────────────────────────────────────────────────┤
│ Stage 2: 静态检查                                          │
│   lint / 类型检查 / 格式 / 依赖漏洞扫描 (govulncheck/npm audit)│
├────────────────────────────────────────────────────────────┤
│ Stage 3: 测试(快)                                        │
│   单元测试 + 覆盖率(diff coverage 门槛)                    │
├────────────────────────────────────────────────────────────┤
│ Stage 4: 构建产物(不可变)                                 │
│   编译 → 生成镜像/二进制 → 打上唯一 tag (git sha) → push    │
├────────────────────────────────────────────────────────────┤
│ Stage 5: 测试(慢,可并行)                                 │
│   集成测试 / 契约测试 / E2E 冒烟                            │
├────────────────────────────────────────────────────────────┤
│ Stage 6: 部署到 staging                                    │
│   预览环境 → 人工/自动验收                                  │
├────────────────────────────────────────────────────────────┤
│ Stage 7: 发布到生产                                         │
│   蓝绿 / 金丝雀 / 滚动 → 健康检查 → 观察指标 → 完成          │
│   失败 → 自动回滚                                          │
└────────────────────────────────────────────────────────────┘

关键设计原则

  • 每一 stage 的输入是上一 stage 的产物,不是重新构建(保证测试的就是要部署的)。
  • 产物不可变 + 唯一可追溯(tag = git sha + 时间),"代码可复现到任意发布版本"。
  • 快失败前置:慢的测试永远排在快的后面。

三、产物与镜像

3.1 不可变产物(Immutable Artifact)

"你测试的东西,就是你要发布的东西。一旦构建,永不改变。"

  • 每次构建生成唯一可追溯产物(app-<gitsha>-<timestamp>)。
  • 构建一次,测试、部署、回滚都复用同一产物。
  • 反例:生产上重新 go build / npm install(每次结果可能不同)→ 不可复现。

3.2 容器镜像

  • 镜像 = 运行环境 + 代码 + 依赖的快照,跨环境一致(这是为什么 Docker 火)。
  • 生产部署 = 拉镜像 → 跑容器,不再有"本地能跑线上不能跑"
# 多阶段构建:build 阶段产二进制,run 阶段只带二进制(小镜像)
FROM golang:1.22 AS build
WORKDIR /app
COPY go.mod go.sum ./
RUN go mod download
COPY . .
RUN CGO_ENABLED=0 go build -o app .

FROM gcr.io/distroless/static
COPY --from=build /app/app /app
EXPOSE 8080
ENTRYPOINT ["/app"]

3.3 镜像仓库与版本

app:v1.2.3                          # 语义化版本
app:git-abc1234                     # git sha(唯一可追溯)
app:latest                          # ❌ 避免:不可追溯,别用生产

warning

生产部署禁止用 latest 或可变 tag。一旦不可追溯,"这个版本是什么代码"就说不清,回滚也找不到目标。


四、部署策略

4.1 四种主流

滚动 (Rolling):       逐个替换旧实例 → 新实例,不停机
蓝绿 (Blue-Green):    两套环境 (blue 旧, green 新),切流量到 green
金丝雀 (Canary):      先给 5-10% 流量,观察没问题再全量
重建 (Recreate):      先停旧的再起新的(有停机窗口)

4.2 对比表

策略停机回滚适用复杂度
重建快(重部署旧版)批处理、无状态
滚动滚回无状态微服务(默认)
蓝绿秒切回有状态、不能混跑中高
金丝雀切回高风险、新特性

4.3 蓝绿 vs 金丝雀实战

蓝绿(K8s 手动版):

blue (v1)  ← 生产流量
green (v2) ← 新部署,验证健康
流量切换: 把 LB 指向 green(秒级)
异常: 切回 blue(秒级回滚)

金丝雀(K8s 渐进式):

canary (v2) 接 5% 流量 → 观察错误率/延迟
指标健康 → 升到 50% → 100%
指标异常 → 自动切回 (自动回滚)

tip

金丝雀的正确姿势是指标驱动自动决策(错误率超过阈值自动扩大/回滚),而不是"人盯着 dashboard 手动加比例"。

4.4 K8s 部署示例

apiVersion: apps/v1
kind: Deployment
metadata:
  name: app
spec:
  replicas: 3
  strategy:
    type: RollingUpdate
    rollingUpdate:
      maxUnavailable: 1        # 最多同时不可用 1 个
      maxSurge: 1              # 最多多起 1 个
  selector: { matchLabels: { app: app } }
  template:
    metadata: { labels: { app: app } }
    spec:
      containers:
      - name: app
        image: registry/app:git-abc1234   # 不可变 tag
        readinessProbe: { httpGet: { path: /healthz, port: 8080 } }

五、IaC(Infrastructure as Code)

5.1 为什么必须

手工配置服务器 = 状态不可知、不可复现、漂移。IaC 把基础设施当代码管理:版本控制、可 review、可回滚、可复现

5.2 两派

工具心智
声明式(声明目标状态)Terraform / Pulumi / CloudFormation / Kubernetes YAML"我要什么",工具 diff 并 apply
命令式(描述步骤)Ansible / 脚本"怎么做",逐步执行

声明式是主流:terraform plan 显示 diff,apply 收敛到目标状态。

5.3 Terraform 示例

# main.tf
terraform {
  required_providers {
    aws = { source = "hashicorp/aws" }
  }
}

resource "aws_instance" "web" {
  ami           = "ami-1234"
  instance_type = "t3.micro"
  tags = { Name = "web-prod" }
}
terraform init    # 初始化
terraform plan    # 显示将要做什么(diff)
terraform apply   # 执行
terraform destroy # 删除

5.4 K8s 的 IaC(GitOps)

GitOps = git 是唯一的真相源,集群状态持续收敛到 git 里的声明:

git (期望状态) → 拉取 → 对比集群 → 应用差异 → 汇报

工具:ArgoCD / Flux。价值:代码 review 即基础设施 review;回滚 = git revert;审计 = git log。

warning

不要"git 一套、线上手改一套"。一旦漂移(drift),IaC 的收敛(reconcile)要么覆盖手改要么报错——GitOps 强制"git 是唯一真相"。


六、发布与回滚流程设计

6.1 发布清单(Checklist)

一个可靠的发布流程应有:

[ ] 测试全绿(单元/集成/契约/E2E 冒烟)
[ ] 产物不可变 + 可追溯(git sha tag)
[ ] 数据库迁移安全(向前兼容 / 可回滚)
[ ] 配置项有默认值且向后兼容
[ ] 健康检查 / 就绪探针就位
[ ] 监控指标有基线(错误率/延迟/P95)
[ ] 回滚计划写好了(不是事故时才想)
[ ] 金丝雀/蓝绿的流量切换步骤

6.2 数据库迁移(发布最大风险)

发布中最常"卡住回滚"的是 DB schema 变更。原则:

  • 向前兼容:新代码能读旧 schema,旧代码也能读新 schema(兼容窗口)。
  • 分两步:先加列/放宽约束(兼容)→ 部署新代码 → 再删旧列(不兼容改动)。
  • 工具:Flyway / Liquibase / golang-migrate,迁移文件进版本控制。
不兼容改动必须拆分:
Step1: 加新列 (兼容)  → 部署新代码
Step2: 迁移数据
Step3: 删旧列 (不兼容, 代码已不用)

6.3 回滚策略

发布后观察期 (10-30 分钟):
  - 错误率 / 延迟 / 5xx 超阈值 → 触发回滚
回滚方式:
  - 金丝雀/蓝绿: 切回旧版本 (秒级)
  - 滚动: 重新部署旧镜像 (分钟级)
  - DB: 若 schema 有破坏性变更, 需要先回滚迁移 (所以迁移要可逆)

七、CI/CD 平台示例(GitHub Actions)

name: CI
on:
  pull_request:
  push: { branches: [main] }

jobs:
  test:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-go@v5
        with: { go-version: '1.22' }
      - run: go build ./...
      - run: go test ./... -race -count=1
      - run: golangci-lint run

  deploy:
    if: github.ref == 'refs/heads/main'
    needs: test
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - name: Build & push image
        run: |
          docker build -t registry/app:git-${{ github.sha }} .
          docker push registry/app:git-${{ github.sha }}
      - name: Deploy
        run: kubectl set image deployment/app app=registry/app:git-${{ github.sha }}

八、结束 + 速查表

tip

一页快速唤回:

  • CI = 每次提交构建+测试;CD = 可靠发布(持续交付=按钮,持续部署=自动)。
  • 核心心智:小步快速失败,让失败发生在 CI 早期而非生产。
  • 管线:检出 → 静态检查 → 快测试 → 构建不可变产物 → 慢测试 → staging → 生产。
  • 不可变产物:构建一次复用到底,tag = git sha,测试的就是要部署的。
  • 部署策略:滚动(默认无状态)/ 蓝绿(秒切)/ 金丝雀(指标驱动渐进)/ 重建(停机)。
  • IaC:声明式(Terraform/K8s)> 命令式;GitOps = git 是唯一真相源。
  • DB 迁移:先兼容后破坏,分步走,可回滚。
  • 回滚:观察期 + 自动回滚;破坏性 schema 变更要提前计划。
  • 生产禁 latest tag

下一篇: 4. 性能工程: profiling / 火焰图 / 缓存与批处理方法论.

4. 性能工程: profiling / 火焰图 / 缓存与批处理方法论

TL;DR

性能优化最怕两件事:不看证据瞎猜,和为了优化而优化。这一章给一套科学的方法论——先测量、再定位、后优化、最后验证,并给出实际工具(pprof / perf / async-profiler / flamegraph)与常见优化手段(缓存、批处理、索引、并发)的适用场景。

读完应能:

  1. 用"测量 → 定位 → 优化 → 验证"四步走完一次性能优化,不靠猜。
  2. 读懂火焰图,知道 CPU / 内存 / IO / 锁四种瓶颈怎么看。
  3. 判断该用缓存 / 批处理 / 索引 / 并发哪个,而不是全上。
  4. 识别最常见的性能反模式(N+1 查询、循环里做 IO、没缓存重复计算)。

一、性能工程的科学方法

1.1 四步循环

1. 测量    —— 量化现状(基线):latency / throughput / 资源利用率
2. 定位    —— 找到瓶颈(profiling):是 CPU? 内存? IO? 锁? 网络?
3. 优化    —— 针对根因下手(改算法/缓存/批处理/并发/索引)
4. 验证    —— 对比优化前后,确认有效且没引入新问题

warning

跳过"定位"直接"优化" = 赌博。没有 profile 就去改代码,90% 改错地方。**"过早优化是万恶之源"**不是"别优化",而是"别在不测量时优化"。

1.2 关键度量

度量含义看什么
Latency(延迟)单个请求耗时均值、P50/P95/P99(比均值重要)
Throughput(吞吐)单位时间请求数(QPS)峰值 / 饱和点
Saturation(饱和度)资源利用率CPU/mem/IO/连接池是否到顶
Errors(错误率)5xx/超时占比性能劣化的直接信号
资源利用CPU/内存/磁盘/网络找瓶颈在哪类资源

1.3 P50 vs P95 vs P99

  • 均值会骗人(99% 快 1ms,1% 慢 10s,均值还是 1.1ms)。
  • P99 代表"最差用户体验"——很多"系统挂了"其实是 P99 爆表。
  • 优化目标常是"P95/P99",不是均值。

二、定位瓶颈:四大类

2.1 CPU 瓶颈

现象:CPU 100%、吞吐上不去、延迟随负载线性涨。

手段:CPU profiler(采样),找热函数。

pprof 火焰图
  顶部 = 实际执行中的函数(宽 = 占比高)
  向下 = 调用栈
  最宽的那条 = 热点

工具

  • Go: pprofnet/http/pprof 或 benchmark)
  • C/C++/Rust: perf record + perf report
  • JVM: async-profiler + flamegraph
  • Python: cProfile / py-spy(生产无侵入采样)
// Go: 开启 pprof 端点
import _ "net/http/pprof"
// 然后: go tool pprof http://localhost:6060/debug/pprof/profile

2.2 内存瓶颈

现象:内存飙高、GC 频繁、OOM、Swap。

手段:内存 profiler + heap 分析。

Go:  go tool pprof -inuse_space   # 当前驻留内存
     go tool pprof -alloc_space   # 累计分配(找分配热点)
JVM: jmap + MAT / async-profiler alloc

常见内存反模式

  • 大对象反复创建(没复用 buffer)
  • 切片/数组预分配不足导致反复扩容
  • 缓存无上限(LRU 缺失)
  • 逃逸分析失败导致频繁堆分配

2.3 IO 瓶颈(磁盘/网络)

现象:CPU 不高但慢、io wait 高、吞吐低。

手段:IO profiler + 观察系统层。

iostat -x 1        # 磁盘利用率 / await / svctm
pidstat -d 1       # 每进程 IO
sar -n DEV 1       # 网络吞吐

判断await 高 = 磁盘慢;%util 100% = 磁盘饱和。

2.4 锁 / 并发瓶颈

现象:核多但用不满、延迟随核数先降后升(争用)、CPU 有 idle 但吞吐低。

手段:锁 / 阻塞 profiler。

Go:  pprof mutex / block profile
JVM: async-profiler -e wall 看阻塞线程

判断:火焰图里很多 sync.Mutex.Lock / parked → 锁争用。


三、读懂火焰图

3.1 火焰图怎么读

      ____________
    __| funcC      |__      ← 最宽的横条 = 占 CPU 最多
  _|__| funcB      |___
 _|___| funcA      |______
|  main()          |       ← 栈底 = 入口
  • 横轴 = 采样时间占比(宽度),纵轴 = 调用栈深度。
  • 最宽处 = 瓶颈。顺最宽路径往上,就是"为什么这么慢"的根因链。
  • 自己代码而不是库函数——如果最宽是 memcpy/json.Marshal,说明热点在被调用的库上。

3.2 四种典型形态

形态含义
尖顶(一个函数很宽)单个热点,改这个函数
平顶(很多函数窄)均匀分布,别微调,该换架构(批处理/缓存)
山丘(高层很宽)调用栈深,函数频繁被调,可能 N+1
锯齿(大量短窄条)GC / 频繁小分配

3.3 生成火焰图

# Go
go tool pprof -raw http://localhost:6060/debug/pprof/profile > cpu.raw
go tool pprof -proto http://localhost:6060/debug/pprof/profile > cpu.pb.gz
# 用 https://speedscope.app 或 flamegraph.pl 可视化

四、常见性能反模式(先自查这些)

反模式问题修复
N+1 查询循环里每项查一次 DBJOIN / IN / 批量加载
循环内做 IO每次迭代网络/磁盘批处理 + 缓冲区
重复计算同一结果每次重算缓存 / memoization
未索引查询全表扫描加索引 / 覆盖索引
无界内存增长缓存无限大 / 切片反复扩容LRU 上限 / 预分配
大 JSON 全量一次加载整个大对象流式 / 分页
同步串行调用无关请求顺序执行并发 / 扇出
频繁小请求大量小包批处理 / 复用连接
日志风暴每条请求打十几条 log采样 / 降噪
死锁/超时无上限依赖未设 timeout设超时 + 熔断

tip

先扫描反模式清单,再上 profiler。很多"性能问题"其实是显而易见的代码问题(N+1、循环 IO),一眼看到就不用 profile 了。


五、优化手段:什么时候用哪个

5.1 缓存(Cache)

适用:同一结果被反复读取、数据变化不频繁、读远多于写。

代价:一致性(stale)、失效复杂度、内存占用。

关键决策

  • 本地缓存(单进程,LRU)vs 分布式缓存(Redis,跨节点)
  • 失效:TTL / 主动失效 / 版本号
  • 缓存一致性:cache-aside / write-through(见系统设计 §cache)
// Go: 简单 LRU 缓存(sync.Map 或 bigcache)
var cache = make(map[string]*Entry)  // 生产用 lru 库
func Get(key string) *Entry {
    if e, ok := cache[key]; ok { return e }  // hit
    e := loadFromDB(key)                      // miss → 加载
    cache[key] = e
    return e
}

5.2 批处理(Batching)

适用:大量小操作(网络请求、DB 写入、消息发送)、往返延迟占主导。

代价:延迟增加(凑批要等)、错误影响面变大。

# ❌ 逐个发送(N 次网络往返)
for msg in messages:
    send(msg)

# ✅ 批量(1 次往返)
send_batch(messages)

5.3 索引(数据库)

适用:查询慢、全表扫描。

关键

  • WHERE/JOIN/ORDER BY 用到的列建索引
  • 覆盖索引(列都包含)避免回表
  • 复合索引的列序(最左前缀)
  • 见数据库 §indexing

5.4 并发(Concurrency)

适用:多个独立任务可并行、有闲置资源、IO 等待为主。

代价:锁争用、调度开销、复杂度。

// Go: 扇出 + WaitGroup
var wg sync.WaitGroup
results := make([]Result, len(urls))
for i, u := range urls {
    wg.Add(1)
    go func(i int, u string) {
        defer wg.Done()
        results[i] = fetch(u)
    }(i, u)
}
wg.Wait()

warning

并发不是银弹:如果瓶颈是 CPU(已经 100%),并发只会更糟(调度开销)。只有 IO 等待为主的场景,并发才有效

5.5 算法/数据结构

  • 从 O(n²) 改 O(n log n)(哈希表替代线性查找)
  • 见 DSA 全部——这是"性能工程"的底层工具箱。

六、性能优化的优先级判断

6.1 80/20 法则

  • 通常 20% 的代码占 80% 的时间。只优化 hot path,别优化冷路径。
  • 先 profile 找到那 20%,再动手。

6.2 先确认瓶颈在"该层"吗

延迟高 → 是应用慢? 网络慢? DB 慢? 还是依赖服务慢?
  - 应用慢 → 上 profiler
  - DB 慢 → EXPLAIN + 索引
  - 网络慢 → 测 RTT、看是不是跨区域
  - 依赖慢 → 看上游 P99, 加超时熔断

tip

"分层归因"(类似 prologue 抽象层级的调试法):先把"慢"归到 应用/DB/网络/依赖 哪一层,再进该层细化。省大量走弯路。

6.3 优化后验证

# 优化前后对比 (Go benchmark)
go test -bench=. -benchmem ./...
# 观察 P99 变化: 从 500ms → 80ms, 就是有效

七、结束 + 速查表

tip

一页快速唤回:

  • 四步:测量 → 定位 → 优化 → 验证(先 profile 再改代码)。
  • 看 P95/P99,不是均值。
  • 瓶颈四大类:CPU(profiler)/ 内存(heap)/ IO(iostat)/ 锁(mutex profile)。
  • 火焰图:横轴 = 时间占比,纵轴 = 调用栈,最宽 = 热点;看自己代码不看库。
  • 先扫反模式(N+1、循环 IO、重复计算、未索引)再上 profiler。
  • 缓存:读多写少、允许 stale;失效策略要设计。
  • 批处理:小操作多、往返占主导。
  • 索引:WHERE/JOIN/ORDER BY 列。
  • 并发:只有 IO 等待为主才有用;CPU 瓶颈并发更糟。
  • 分层归因:先把慢归到 应用/DB/网络/依赖,再细化。

下一篇: 5. 应用安全: OWASP Top 10 / 认证授权 / 数据安全 / 供应链.

5. 应用安全: OWASP Top 10 / 认证授权 / 数据安全 / 供应链

TL;DR

密码学部分(第十部分)讲的是"加密算法怎么工作";这一章讲应用层安全——你的 Web 服务怎么被攻击、怎么防御。攻击不是靠破解 AES,而是靠应用逻辑漏洞(注入、越权、逻辑绕过)。这一章以 OWASP Top 10 为主线,覆盖最常见攻击面 + 认证授权 + 数据安全 + 供应链安全。

读完应能:

  1. 说清 OWASP Top 10 每一项的漏洞原理、攻击方式、防御手段。
  2. 理解认证(你是谁)vs 授权(你能干嘛)的区别,会设计会话/JWT/角色权限。
  3. 知道常见数据安全实践(加密/掩码/合规)。
  4. 意识到底层依赖(供应链)也是攻击面。

一、威胁模型与信任边界

1.1 先想清楚"谁在攻击谁"

安全设计的第一步不是堆防护,而是威胁建模(Threat Modeling)

信任边界 1: 用户 vs 公网         → 认证、输入校验
信任边界 2: 公网服务 vs 内部服务  → 内网隔离、mTLS
信任边界 3: 服务 vs 数据库        → 最小权限、加密连接
信任边界 4: 开发 vs 生产          → 密钥管理、环境隔离

每一条边界都要问:跨这条边界的数据/调用,怎么被滥用?

1.2 安全三原则

  • 纵深防御(Defense in Depth):不只一层防护,层层都设防。
  • 最小权限(Least Privilege):只给完成任务所需的最小权限。
  • 默认安全(Secure by Default):默认关闭,需要才开。

二、OWASP Top 10(核心)

2.1 总览

序号漏洞一句话
A01访问控制破坏越权(IDOR),用户能访问不该访问的资源
A02加密失败敏感数据明文存储/传输
A03注入(SQLi/命令注入)用户输入拼进查询/命令
A04不安全设计信任边界/设计缺陷
A05安全配置错误默认密码、多余端口、DEBUG 开着
A06易受攻击组件依赖库有已知 CVE 不更新
A07认证与会话失效弱口令、会话可预测、密码明文存
A08软件与数据完整性失败反序列化漏洞、签名缺失
A09日志与监控不足被入侵了但没日志抓不到
A10SSRF服务端请求伪造,访问内网

2.2 A03 注入:SQL 注入(最经典)

漏洞:把用户输入直接拼进 SQL。

# ❌ 危险:字符串拼接
query = f"SELECT * FROM users WHERE name = '{username}'"
# 输入: username = "admin' --" → 查询变成:
# SELECT * FROM users WHERE name = 'admin' --'
# 注掉后面所有条件 → 越权登录!

# ✅ 参数化查询
cursor.execute("SELECT * FROM users WHERE name = %s", (username,))

防御永远用参数化查询/预编译语句,绝不用字符串拼接。ORM 通常默认参数化,但要小心 raw query。

2.3 A01 访问控制破坏:IDOR(越权)

漏洞:用户直接访问 GET /order/12345,没检查"这个订单是不是他的"。

# ❌ 只按 id 查,不校验归属
def get_order(order_id):
    return db.fetch("SELECT * FROM orders WHERE id = %s", (order_id,))

# ✅ 校验归属
def get_order(user_id, order_id):
    row = db.fetch("SELECT * FROM orders WHERE id = %s AND user_id = %s",
                   (order_id, user_id))
    if row is None: raise Forbidden()   # 不归属 → 拒绝
    return row

防御服务端每次都必须校验资源归属,不能只靠前端隐藏。用不可枚举的 ID(UUID 而非自增)只是缓解,不是修复。

2.4 A02 加密失败(敏感数据保护)

  • 传输:全站 HTTPS(TLS),绝不允许 HTTP 明文。
  • 存储:
    • 密码 → 哈希(bcrypt/argon2/scrypt),绝不明文,绝不存可逆。
    • 信用卡/身份证 → 加密存储(AES-256)或 token 化。
    • 日志里不打敏感字段(手机号、身份证、token)。
# 密码必须哈希,绝不加密(不需要解密回来)
import hashlib, secrets
# 用 bcrypt/argon2, 别用裸 sha256(太快)
# from argon2 import PasswordHasher
# ph = PasswordHasher()
# ph.hash(password)          # 存这个
# ph.verify(hash, password)  # 校验

2.5 A10 SSRF(服务端请求伪造)

漏洞:用户控制的 URL 被服务端去请求。

# ❌ 用户能传 url 让服务端去访问
def fetch_image(url):
    return requests.get(url).content
# 攻击: url = "http://169.254.169.254/latest/meta-data/" → 访问云元数据(可能拿到密钥!)

防御:服务端不发用户可控 URL;若必须,白名单协议/域名、禁止内网 IP、过滤回环地址。

2.6 A06 易受攻击组件(供应链)

  • 每个依赖库都可能有 CVE,依赖扫描是 CI 的一部分govulncheck / npm audit / Dependabot)。
  • 攻击载体:package-lock.json / go.sum 应锁定版本并签名校验。
  • 不只是依赖:基础镜像(Docker base image)、Terraform 模块、NPM 包都是供应链。

三、认证与授权

3.1 认证(Authentication)vs 授权(Authorization)

认证: 你是谁?  → 登录/口令/SSO/MFA
授权: 你能做什么? → 角色/权限/策略

经典错误:只做了认证忘了授权 → "能登录 = 能访问一切"(IDOR 的根源)。

3.2 会话管理(Session)

  • 登录成功后服务端生成 session(随机高熵 id),存服务端,客户端拿 cookie。
  • 安全要点:
    • session id 用加密安全随机数(crypto/rand
    • cookie 加 HttpOnly(防 XSS 读)、Secure(仅 HTTPS)、SameSite
    • session 过期 + 登出使失效

3.3 Token 方案(JWT)

JWT(JSON Web Token)= header.payload.signature,无状态(服务端不存)。

header:  {"alg": "HS256", "typ": "JWT"}
payload: {"sub": "user123", "role": "admin", "exp": 1700000000}
signature: HMAC-SHA256(header.payload, secret)

安全要点

  • alg 必须白名单(防 alg=none 绕过)
  • secret 用强随机 + 环境变量/密钥管理,不进代码
  • 必须校验 exp
  • 放授权信息在 JWT 里要小心:改 role 后要能强制失效(因为无状态,无法吊销)

3.4 授权模型

模型说明适用
RBAC(基于角色)用户 → 角色 → 权限大多数系统
ABAC(基于属性)策略用属性组合(用户/资源/上下文)复杂规则
Policy engineOPA / Casbin 声明式策略微服务统一授权
# OPA 策略示例: 用户只能改自己的订单
allow {
  input.method == "PATCH"
  input.path == ["orders", order_id]
  order_id == input.user_id
}

3.5 会话固定 / 重放 / 爆破

  • 爆破:限流 + 失败锁定 + 验证码。
  • 会话固定:登录成功后轮换 session id。
  • 重放:请求加时间戳/nonce,服务端校验。

四、常见 Web 攻击(补充 OWASP)

4.1 XSS(跨站脚本)

  • 存储型/反射型/DOM 型;用户输入当 HTML/JS 执行。
  • 防御:输出转义html.escape)、CSP 头、HttpOnly cookie、输入过滤。
# ❌ 把用户评论直接插进 HTML
return f"<div>{comment}</div>"   # 用户输入 <script>alert(1)</script>

# ✅ 转义输出
import html
return f"<div>{html.escape(comment)}</div>"

4.2 CSRF(跨站请求伪造)

  • 攻击者让受害者浏览器向你的站点发请求(带 cookie)。
  • 防御:CSRF token、SameSite=Strict/Lax cookie、自定义 header。

4.3 开放重定向 / 点击劫持 / 文件上传

  • 文件上传:校验 MIME + 扩展名 + 大小,存储隔离,禁执行目录。
  • 点击劫持:X-Frame-Options: DENY / CSP frame-ancestors

五、数据安全与合规

5.1 数据生命周期

采集 → 传输(加密) → 存储(加密) → 使用(最小权限) → 归档 → 删除(合规)

5.2 分类与脱敏

  • 敏感数据(PII:手机号/身份证/邮箱)要有分类标记。
  • 日志/测试环境脱敏(掩码 138****1234)。
  • 备份同样加密。

5.3 合规(GDPR / 个保法)

  • 数据最小化(只采集需要的)
  • 用户有"被遗忘权"(删除请求)
  • 跨境传输需评估
  • 日志保留期限有上限

六、安全工程实践(落地)

6.1 密钥管理

  • 绝不把密钥写进代码(硬编码是头号漏洞)。
  • 用密钥管理服务:AWS Secrets Manager / HashiCorp Vault / K8s Secret。
  • 环境变量分离 + 部署时注入。
  • 密钥轮转 + 审计。
# ❌ 硬编码
DB_PASSWORD="abc123"

# ✅ 从环境/密钥管理读取
export DB_PASSWORD=$(vault read -field=value secret/db)

6.2 安全头(HTTP 层)

Strict-Transport-Security: max-age=31536000; includeSubDomains
Content-Security-Policy: default-src 'self'
X-Content-Type-Options: nosniff
X-Frame-Options: DENY
Referrer-Policy: no-referrer

6.3 依赖与 CI 安全

CI 步骤:
  - 依赖漏洞扫描 (govulncheck / npm audit / trivy)
  - 镜像扫描 (trivy / grype)
  - SAST 静态分析 (semgrep / gosec)
  - 密钥扫描 (git-secrets / gitleaks)
  - 依赖锁定 + 签名校验

6.4 安全清单(上线前)

[ ] 全部用 HTTPS
[ ] 参数化查询(无字符串拼接 SQL)
[ ] 所有资源访问校验归属(防 IDOR)
[ ] 密码 bcrypt/argon2 哈希
[ ] 会话: HttpOnly/Secure/SameSite cookie
[ ] 依赖扫描通过
[ ] 密钥在密钥管理, 不在代码
[ ] 安全头配好
[ ] 日志不记敏感字段
[ ] 权限最小化(DB/服务/云)

七、与第十部分密码学的衔接

应用层安全底层密码学
HTTPS 部署TLS 1.3 握手(crypto §tls13)
密码哈希bcrypt/argon2(基于哈希 §hashes)
JWT 签名HMAC-SHA256(§hashes)
加密存储AES-256(§symmetric)
防篡改数字签名(§signatures)

应用安全是密码学的工程落地;密码学给应用安全提供原语。


八、结束 + 速查表

tip

一页快速唤回:

  • 威胁建模:每条信任边界问"数据/调用怎么被滥用"。
  • 三原则:纵深防御 / 最小权限 / 默认安全。
  • OWASP 三巨头:注入(参数化!)、IDOR 越权(校验归属!)、加密失败(密码哈希!)。
  • SSRF:服务端请求用户可控 URL → 白名单 + 禁内网。
  • 认证≠授权:登录 ≠ 能访问一切。
  • 会话:加密随机 id + HttpOnly/Secure/SameSite + 过期。
  • JWT:无状态、校验 exp、alg 白名单、secret 不硬编码。
  • XSS:输出转义 + CSP + HttpOnly;CSRF:SameSite + token。
  • 密钥:绝不硬编码,用 Vault/Secrets Manager。
  • 供应链:依赖扫描进 CI,锁定版本。
  • 上线清单:HTTPS / 参数化 / 越权 / 哈希 / 安全头 / 扫描。

下一篇: 6. 代码质量: 重构 / code review / 复杂度治理 / DDD 落地.

6. 代码质量: 重构 / code review / 复杂度治理 / DDD 落地

TL;DR

代码质量决定的是"软件 5 年后还能不能改"。这一章把"好代码"从玄学变成可操作的方法:重构是有纪律的语义等价变换,code review 是质量门禁而非走过场,复杂度治理让代码不被技术债压垮,DDD 让复杂业务领域保持清晰。

读完应能:

  1. 用"坏味道清单"发现代码问题,用安全的重构手法(保持行为不变)改进。
  2. 知道 code review 该看什么、怎么给反馈、怎么不让人难受。
  3. 用量化手段(圈复杂度等)治理复杂度,而不是靠感觉。
  4. 理解 DDD 的核心战术(实体/值对象/聚合/领域服务)何时用。

一、什么是"好代码"

1.1 质量的本质:可修改性

代码质量 = 改它需要多少成本 + 多少风险。好代码 = 低成本、低风险地满足新需求。

不是"优雅""花哨",而是:

  • 可读:5 分钟后/5 个月后/别人能看懂。
  • 可测:能写测试(依赖注入、边界清晰)。
  • 可改:加功能不破坏其他部分(低耦合)。
  • 可维护:出问题能快速定位。

1.2 四个质量维度

维度坏的样子好的样子
可读性长函数、魔法数、烂命名短函数、自描述命名、清晰控制流
可测性全局状态、直接 IO、难注入依赖注入、纯函数、接口边界
可维护性深耦合、重复代码、上帝对象低耦合、DRY、小类
可演化性改一处裂一片开闭原则、接口稳定

二、代码坏味道清单(先发现)

2.1 命名与结构

  • 魔法数字/字符串if (x > 86400) → 提取常量 ONE_DAY_SECONDS
  • 烂命名dtempdata2 → 用意图命名。
  • 长函数:> 30-50 行 → 拆成有名字的小函数。
  • 重复代码(DRY 违规):两处相似逻辑 → 提取。
  • 长参数列表:> 4 个参数 → 参数对象。

2.2 结构与耦合

  • 上帝对象:一个类做所有事 → 拆分职责。
  • 深耦合:A 知道 B 的内部细节 → 减少暴露。
  • 散弹式修改:改一个需求要动 10 个文件 → 关注点没聚合。
  • 过度耦合到具体实现:依赖接口而非具体类。

2.3 行为问题

  • 注释撒谎:注释和代码不符 → 改注释或改代码。
  • 死代码:没被调用 → 删。
  • 隐藏依赖:函数依赖全局状态但签名看不出 → 参数显式传。
  • 副作用隐藏在 getter 里getName() 居然改状态 → 命名撒谎。

三、重构:有纪律的改进

3.1 重构的定义

重构 = 不改变外部行为,只改进内部结构,每一步都有测试兜底。

"我顺手改了一下"不算重构——重构必须每一步都能跑测试验证行为没变

3.2 经典手法

手法做什么何时用
Extract Function把一段代码提成函数长函数、注释块
Rename改更清晰的名字命名不清
Introduce Variable表达式提取为局部变量重复/难读表达式
Replace Magic Number魔法数→常量魔法数
Extract Class一个类拆成多个上帝对象
Move Function函数挪到它更相关的类功能放错地方
Parameter Object长参数→对象参数太多
Replace Conditional with Polymorphism分支→多态if/switch 膨胀

3.3 重构的安全流程

1. 先补/跑测试(建立安全网)
2. 一次只做一个微重构(小步)
3. 每步跑测试(确认行为没变)
4. 全部绿 → 继续下一步;红 → 回退

warning

重构必须和 bug 修复/功能开发分开。混在一起,改挂了无法判断是重构还是功能引入的问题。Commit 也应该分开。

3.4 示例:Extract Function

# ❌ 一个函数做三件事(校验、计算、格式化)
def process_order(order):
    # 校验
    if order.status != "pending":
        raise ValueError("invalid status")
    if order.total <= 0:
        raise ValueError("invalid total")
    # 计算
    total = order.total
    discount = 0.1 if total > 1000 else 0
    final = total * (1 - discount)
    # 格式化
    return f"ORDER-{order.id}-{final:.2f}"

# ✅ 拆成三个有名字的函数
def _validate_order(order): ...
def _calculate_total(order): ...
def _format_result(order_id, total): ...

def process_order(order):
    _validate_order(order)
    final = _calculate_total(order)
    return _format_result(order.id, final)

四、Code Review:质量门禁

4.1 看什么

Review 不是"找茬",是理解 + 把关

关注点问的问题
正确性逻辑对吗?边界处理了吗?并发安全吗?
安全有注入/越权/泄露吗?
性能有 N+1、循环 IO、没缓存吗?
可测有测试吗?测试测的是行为吗?
可维护命名/结构/复杂度如何?
与现有代码一致符合项目模式/约定吗?

4.2 反馈的原则

  • 对代码不对人:说"这个函数有问题"不说"你写错了"。
  • 解释为什么:不只说"这里不对",说"这里可能在 X 场景下崩,因为 Y"。
  • 区分阻塞 vs 建议:明确"必须改"和"可以讨论"。
  • 给方向不给答案:问"这里是不是该提取个函数?"优于直接贴代码。
  • 关注重要的事:安全/正确性/性能 > 命名/格式(格式交给 linter)。

4.3 Review 的规模

  • PR 太大(> 400 行)→ Review 质量急剧下降,应拆小。
  • 每 PR 建议 200-400 行改动,一次审 < 60 分钟。
  • Review 速度重要:拖 3 天的 review 比没有 review 更糟(阻塞交付)。目标 < 24h 反馈。

4.4 自动化的边界

交给工具: 格式 / lint / 类型 / 常见 bug(gosec/semgrep/staticcheck)
留给人:   设计 / 语义 / 权衡 / 业务正确性 / 一致性

note

别让 review 花在"该用单引号还是双引号"上——那是 linter 的事。人的价值在判断,机器的事交给机器。


五、复杂度治理

5.1 圈复杂度(Cyclomatic Complexity)

定义:代码中独立路径的数量 = 1 + 分支数(if/for/case/&&/||)

def f(x):
    if x > 0: return "pos"    # +1
    elif x < 0: return "neg"  # +1
    return "zero"             # 基础 1
# 圈复杂度 = 3

治理标准

  • ≤ 10:正常
  • 11-20:需要解释,考虑拆分
  • 20:必须重构(几乎不可测)

工具:radon(Python)、gocyclo(Go)、SonarQube(通用)。

5.2 其他复杂度信号

信号阈值参考含义
函数行数> 30-50太多职责
函数参数> 4缺参数对象
类方法数> 20上帝类
依赖数异常高耦合过重
重复率> 10-15%DRY 违规

5.3 复杂度的平衡

复杂度治理不是"越简单越好"——过度抽象(掉进简化主义)也是复杂度。平衡点:

  • 代码是要读懂的(可读性优先),不是要"最短/最聪明"。
  • 一个 5 行的 if 分支,比一个需要跳 3 个文件的抽象类更简单。
  • "三法则"(Rule of Three):代码用到第三次才提取抽象。前两次复制粘贴是合理的。

warning

复杂度治理最大的敌人是过度设计(YAGNI)。"也许以后要用多态/接口/框架" → 现在不要。等第三处需要时再抽。


六、DDD(领域驱动设计)落地

6.1 DDD 解决什么

复杂业务领域(电商、金融、医疗)里,代码和业务语言脱节 → 沟通成本高、实现和需求漂移。DDD 让代码说业务的语言

6.2 战略设计(宏观)

  • 限界上下文(Bounded Context):把业务切成独立领域(订单、库存、支付各一个上下文),各自有语言和模型。
  • 上下文映射:上下文间的关系(防腐层 ACL、共享内核等)。
  • 通用语言(Ubiquitous Language):领域术语在代码里和业务里用同一个词。

6.3 战术设计(微观)

概念说明例子
实体(Entity)有唯一标识、有生命周期Order(有 order_id)
值对象(Value Object)无标识、靠值相等Money、Address
聚合(Aggregate)实体 + 值对象的边界,外部只能碰聚合根Order 聚合(含 OrderItem)
聚合根(Aggregate Root)聚合的入口,保证内部一致性Order
领域服务(Domain Service)不属于单个实体的业务逻辑OrderService.placeOrder()
仓储(Repository)聚合的持久化接口OrderRepository
领域事件(Domain Event)领域里发生的事OrderPlaced

6.4 何时该用 DDD

不用
业务复杂、规则多、会长期演进CRUD 简单、领域逻辑少
团队够大、多领域协作小团队、一次性脚本
需要和业务方深度协作纯技术项目

tip

DDD 不是银弹。80% 的项目是 CRUD(增删改查),上 DDD 是过度设计。判断标准:业务规则是否复杂到"光靠数据表/CRUD 表达不清"。是才用。


七、落地:工程实践清单

[ ] 代码有测试(单元覆盖关键逻辑)
[ ] 命名表达意图(无魔法数/烂名/死代码)
[ ] 函数短小、单一职责
[ ] 圈复杂度可控(< 15 主要函数)
[ ] 无上帝对象、无深耦合
[ ] 重复代码已提取(Rule of Three)
[ ] code review 是门禁不是走过场
[ ] PR 小步(< 400 行)、Review 快(< 24h)
[ ] 重构与功能开发分开 commit
[ ] 复杂领域有清晰模型(DDD 或分层)

八、结束 + 速查表

tip

一页快速唤回:

  • 质量 = 可修改性:可读 / 可测 / 可改 / 可维护。
  • 坏味道先扫:魔法数、长函数、重复代码、上帝对象、烂命名。
  • 重构 = 行为不变的改进,每步测试兜底;和功能开发分开 commit
  • 常用手法:Extract Function / Rename / Extract Class / Parameter Object。
  • Review:对代码不对人、解释为什么、区分阻塞/建议、格式交给 linter、小 PR 快反馈。
  • 圈复杂度 ≤ 10 正常,> 20 必须重构;工具 radon / gocyclo。
  • Rule of Three:第三次才抽象,避免过度设计(YAGNI)。
  • DDD:复杂领域才用;战略(限界上下文/通用语言)+ 战术(实体/值对象/聚合/仓储)。
  • CRUD 项目别硬上 DDD

下一篇: 7. 可观测性实操: metrics/logs/traces 打点 / OpenTelemetry / SLO.

7. 可观测性实操: metrics/logs/traces 打点 / OpenTelemetry / SLO

TL;DR

"能上线"不等于"能 debug"。可观测性(Observability)是让生产系统的行为可以被理解的能力——不是单指监控,而是 metrics(数字)/ logs(事件)/ traces(链路)三件套。system-design 部分讲了理论(SLO/SLI 框架),这一章落到怎么打点、怎么选指标、怎么用 OTel、怎么把 SLO 和告警接起来

读完应能:

  1. 区分 metrics/logs/traces 各自适合什么、怎么协同。
  2. 给自己的服务打上正确的指标(RED/USE 方法)。
  3. 用 OpenTelemetry 做跨服务链路追踪。
  4. 设计合理的 SLO/SLI 并接到告警上,避免告警疲劳。

一、三件套(Pillars)

1.1 各自是什么

MetricsLogsTraces
本质聚合的数值离散的事件跨服务的调用链路
回答"现在多快/多忙/多少错误""出了什么事""一次请求经过了哪些服务"
维度高维标签结构化字段时间跨度 + 服务节点
工具Prometheus / GrafanaELK / LokiJaeger / Tempo
粒度聚合单条单请求

1.2 三件套的关系(经典示例)

问题: "下单变慢了"
  Metrics: 下单 P99 从 200ms → 2s (发现问题)
  Traces:  链路显示 80% 时间花在「库存服务」调用 (定位)
  Logs:    库存服务日志显示连接池耗尽报错 (根因)

note

三件套要关联起来才有用:trace 带 trace_id,log 也带 trace_id,metric 挂 trace 的标签——这样从"数字异常" → "链路定位" → "日志根因"一条龙。


二、Metrics:怎么选指标

2.1 两种经典方法论

RED(服务层,微服务)

指标含义
Rate请求速率(QPS)
Errors错误率(5xx/业务错误)
Duration延迟分布(P50/P95/P99)

USE(资源层,基础设施)

指标含义
Utilization利用率(CPU/内存/磁盘%)
Saturation饱和度(队列长度/等待)
Errors错误数

2.2 必须有的基础指标

# 服务层 (RED)
http_requests_total{method,path,status}
http_request_duration_seconds{le=...}      # histogram
http_errors_total

# 资源层 (USE)
cpu_usage_ratio
memory_usage_bytes
disk_io_bytes
connection_pool_usage

# 业务层 (视系统)
order_created_total
checkout_duration_seconds

2.3 用 Histogram 看分位数

P99 不能直接存(要排序),用 Histogram(分桶计数):

from prometheus_client import Histogram

request_duration = Histogram(
    "http_request_duration_seconds",
    "HTTP request duration",
    buckets=[0.01, 0.05, 0.1, 0.25, 0.5, 1, 2.5, 5, 10],  # 桶边界
)

@request_duration.time()
def handler():
    ...

PromQL 查 P99:

histogram_quantile(0.99, sum(rate(http_request_duration_seconds_bucket[5m])) by (le))

2.4 打点要点

  • cardinality 控制:标签维度不能太散(path 带 user_id 会爆炸)。URL 要先归一化成 GET /orders/:id
  • rate 而非 counter 裸值:counter 要 rate() 看变化率。
  • histogram 比 summary 好:可聚合、可跨维度求分位。

三、Logs:结构化日志

3.1 结构化(JSON)而非散文本

{"ts":"2026-01-01T12:00:00Z","level":"error","msg":"db timeout",
 "service":"orders","trace_id":"abc123","user_id":42,"db_latency_ms":5200}

为什么 JSON:可被工具(Loki/ES)索引、可按字段查询、可加 context。

3.2 日志级别与采样

DEBUG < INFO < WARN < ERROR < FATAL
  • 生产默认 INFO,DEBUG 开在调试时。
  • 高流量路径(每请求多行)要采样(记 1%),否则日志系统崩。
  • 错误日志要带上下文(trace_id + 关联 id),不然无法关联。

3.3 别打敏感信息

日志会长期保存,不打 token/密码/手机号/身份证(见应用安全章节)。用掩码。


四、Traces:链路追踪

4.1 什么是 span / trace

trace_id: 4bf92f3577b34da6a3ce929d0e0e4736
┌─ root span (HTTP GET /checkout)
│   ├─ span: 认证服务  (200ms)
│   ├─ span: 订单服务  (1500ms)
│   │    └─ span: 数据库查询 (1200ms)  ← 热点在这
│   └─ span: 库存服务  (300ms)
  • trace = 一次完整请求。
  • span = 一次服务调用(有 parent-child 关系)。
  • 每个服务在入口创建/接收 trace,传播 trace_id + span_id

4.2 传播(Propagation)

跨服务要传 trace 上下文(HTTP header):

X-Trace-Id: 4bf92f3577b34da6a3ce929d0e0e4736
X-Span-Id: 1f2e...

网关/入口生成 trace_id,每个服务把自己作为子 span 挂上去。

4.3 OpenTelemetry(业界标准)

OTel = 统一的遥测采集 + 传播标准(SDK + API + 协议),兼容 Prometheus/Jaeger/云平台。

// Go OTel 初始化
import (
    "go.opentelemetry.io/otel"
    "go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp"
    "go.opentelemetry.io/otel/sdk/trace"
)

func initTracer() {
    exporter, _ := otlptracehttp.New(context.Background(),
        otlptracehttp.WithEndpoint("otel-collector:4318"))
    tp := trace.NewTracerProvider(trace.WithBatcher(exporter))
    otel.SetTracerProvider(tp)
}
# Python: 自动埋点 HTTP + DB + 框架
from opentelemetry import trace
from opentelemetry.sdk.trace import TracerProvider
from opentelemetry.instrumentation.flask import FlaskInstrumentor
from opentelemetry.instrumentation.requests import RequestsInstrumentor

provider = TracerProvider()
trace.set_tracer_provider(provider)
FlaskInstrumentor().instrument()
RequestsInstrumentor().instrument()

4.4 采样策略

  • 头部采样(head-based):进网关时按概率决定整条链路采不采(1-10%)。
  • 尾部采样(tail-based):先全采、后端按需(错误链路全采)——贵但准。
  • 高流量系统:默认 1% + 错误 100% 采样。

五、SLO / SLI / 告警

5.1 SLI / SLO / Error Budget

SLI (Service Level Indicator): 怎么量 — 可用性% / 延迟P99 / 错误率
SLO (Service Level Objective):  目标值 — P99 < 500ms / 可用性 99.9%
Error Budget (错误预算):       100% - SLO — 一年可犯错的量(3 个九=8.7小时/年)

5.2 设计 SLI/SLO

SLI 示例: 请求成功率 = 成功请求 / 总请求 (可用性 SLI)
         latency SLI = 满足延迟阈值(如 500ms)的请求比例
SLO 示例: 99.9% 的请求 < 500ms (按月评估)

5.3 告警:别告警疲劳

坏告警:告警比处理速度快、告警没有 actionable 信息、全部 p0。

好告警原则

  • 只告警可行动的事:告警了你要能做事,否则就删掉。
  • 用错误预算驱动:Error Budget 没耗尽 → 不告警(SLO 内波动是正常的)。
  • burn rate 告警:按"预算消耗速度"告警——30 分钟内烧完 2% 预算 → p1;烧 14.4% → 严重。
SLO 99.9% (月预算 43min):
  burn rate 1  = 每月 30 天正好烧完
  burn rate 14.4 = 2 小时内烧 14.4% → p1 立即响应
  • 告警必须带runbook(怎么排查/怎么处理)。

六、完整落地架构

应用 (服务 A/B/C)
  │  OTel SDK (自动埋点 HTTP/DB/gRPC)
  ▼
OTel Collector (统一采集: metrics/logs/traces, 采样, 脱敏)
  ├─▶ Prometheus (metrics 存储 + 查询)
  │       └─▶ AlertManager (告警 → 通知)
  ├─▶ Loki (logs 存储, 关联 trace_id)
  └─▶ Tempo/Jaeger (traces 存储)
         │
         └─▶ Grafana (统一 Dashboard + 关联查询)

关键组件职责

  • OTel Collector:统一入口,做采样、标签、脱敏、路由。
  • Prometheus:pull 模型抓 metrics,PromQL 查询。
  • Loki:标签索引日志,低开销。
  • Tempo/Jaeger:trace 存储与搜索。
  • Grafana:三件套统一可视化 + 告警。

七、生产排查实战流程

1. 收到告警 (metrics 异常: 错误率↑ / P99↑)
2. 打开 Grafana: 看 RED 指标确认范围 (哪些 path/service)
3. 看 Traces: 找到异常 trace, 定位到哪个服务哪次调用慢
4. 下钻到该服务: 看 span 里 DB/网络/外部调用耗时
5. 看 Logs: 按 trace_id 过滤, 找根因 (错误堆栈/超时/连接池)
6. 修复 → 部署 → 观察指标回落 → 关闭告警

这套流程把"系统乱成一锅粥时的救火"变成"有据可依的排查"——这就是可观测性的价值。


八、结束 + 速查表

tip

一页快速唤回:

  • 三件套:Metrics(数字)/ Logs(事件)/ Traces(链路),要能互相关联(trace_id)。
  • RED(服务):Rate / Errors / Duration。
  • USE(资源):Utilization / Saturation / Errors。
  • Histogram 查分位:分桶 + histogram_quantile;控制 cardinality。
  • 日志:结构化 JSON + 级别 + 采样 + 带 trace_id + 不打敏感。
  • Trace:span/trace 层级,OTel 自动埋点,HTTP header 传播 trace_id。
  • 采样:头部 1% + 错误 100%;高流量别全采。
  • SLO/Error Budget:99.9% → 月 43min 预算;burn rate 告警。
  • 告警原则:只告警可行动的;预算内不响;告警带 runbook。
  • 架构:OTel Collector → Prometheus/Loki/Tempo → Grafana。

回主目录: 工程化实践轴 README. 下一篇系统正文: 导论卷回到主目录 或任选下面章节。

8. GitHub Actions 实战: workflow / expression / 缓存 / 矩阵 / reusable / 自托管

TL;DR

GitHub Actions 是 CI/CD 的事实标准之一(尤其开源项目)。上一章 CI/CD 讲了通用管线模型,这一章手把手落到 GitHub Actions:语法、环境、expression、缓存、矩阵构建、可复用 workflow、自托管 runner、安全(权限/secret)。看完能自己搭一条生产级 pipeline。

读完应能:

  1. 读懂任意 .github/workflows/*.yml 并写出自己的。
  2. 用 expression、条件、矩阵、缓存优化 CI 速度和正确性。
  3. 用 reusable workflow 消除跨仓库重复。
  4. 正确配置权限和 secret,不踩常见安全坑。

一、核心概念

1.1 三要素

event (触发器) → job (任务, 独立 runner) → step (步骤, 同一 runner 顺序执行)
  • workflow:一个 .github/workflows/xxx.yml 文件。
  • job:workflow 里的一个任务,跑在独立 runner 上(可并行)。
  • step:job 里的一步(一个命令或一个 action),顺序执行、共享 shell。

1.2 关键文件路径

.github/
  workflows/
    ci.yml            # 每个文件 = 一个 workflow

1.3 最小 workflow

name: CI
on: push           # 触发:push 到任何分支

jobs:
  build:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4    # 检出代码
      - uses: actions/setup-go@v5
        with:
          go-version: '1.22'
      - run: go build ./...
      - run: go test ./...

二、触发器(Event)

2.1 常用触发

on:
  push:
    branches: [main]            # 只推 main
    paths:
      - 'src/**'                # 只当 src 变化时(避免无关触发)
      - 'go.mod'
      - '!docs/**'              # 排除 docs
  pull_request:
    types: [opened, synchronize, reopened]   # synchronize = 新提交
  schedule:
    - cron: '0 2 * * *'        # 每天 2 点(UTC)
  workflow_dispatch:            # 手动触发(必须加这个按钮才会出现)

2.2 触发规则要点

  • pushpull_request 常搭配(PR 阶段 + 合并后各跑一遍)。
  • paths 过滤器避免"只改 README 也跑全量 CI"。
  • workflow_dispatch + inputs 可做手动参数化触发。
on:
  workflow_dispatch:
    inputs:
      environment:
        description: '部署环境'
        required: true
        default: 'staging'
        type: choice
        options: [staging, prod]

三、上下文与 Expression

3.1 关键上下文(Context)

上下文内容例子
github事件/仓库/actorgithub.sha, github.ref, github.actor
envworkflow/job/step 级 env自定义环境变量
secrets仓库 secretssecrets.GITHUB_TOKEN
vars仓库变量vars.REGION
jobjob 状态job.status
needs依赖 job 的输出needs.build.outputs.ver
matrix矩阵参数matrix.go-version

3.2 Expression 语法

# 表达式用 ${{ }} 包裹,在字符串里也可插值
- run: echo "sha is ${{ github.sha }}"
- if: ${{ github.ref == 'refs/heads/main' }}
- if: ${{ !cancelled() }}       # 前序失败也跑(清理用)
- if: ${{ success() }}          # 默认: 全成功才跑
- if: ${{ failure() }}          # 失败才跑

3.3 条件操作符

==  !=  &&  ||  !  ( )  contains()  startsWith()  endsWith()
- name: 只在 PR 且改动 src 时跑集成测试
  if: github.event_name == 'pull_request' && contains(github.event.pull_request.files.*.filename, 'src/')

四、Job 依赖与并行

4.1 needs(依赖)

jobs:
  test:
    runs-on: ubuntu-latest
  deploy:
    needs: test                  # 等 test 成功后
    runs-on: ubuntu-latest

4.2 并发控制(防止重复发布)

concurrency:
  group: deploy-${{ github.ref }}   # 同 ref 的任务互斥
  cancel-in-progress: true          # 新任务顶掉旧任务

4.3 超时与失败容错

jobs:
  test:
    timeout-minutes: 10          # job 级超时
    steps:
      - run: sleep 100000
        timeout-minutes: 2       # step 级超时
  cleanup:
    if: ${{ always() }}          # 无论成败都跑
    runs-on: ubuntu-latest
    needs: [test, deploy]

五、矩阵构建(Matrix)

5.1 多版本/多平台

jobs:
  test:
    strategy:
      matrix:
        go: ['1.21', '1.22']
        os: [ubuntu-latest, macos-latest]
    runs-on: ${{ matrix.os }}
    steps:
      - uses: actions/setup-go@v5
        with: { go-version: ${{ matrix.go }} }
      - run: go test ./...

→ 自动生成 2 × 2 = 4 个并行 job。

5.2 排除组合

strategy:
  matrix:
    os: [ubuntu-latest, windows-latest]
    go: ['1.21', '1.22']
    exclude:
      - os: windows-latest    # windows 只测一个版本
        go: '1.21'

5.3 include(加额外组合)

matrix:
  include:
    - os: ubuntu-latest
      go: '1.23'    # 额外加一个

5.4 矩阵产物汇总

多个 OS 的产物要上传再合并:

- name: Upload artifact
  uses: actions/upload-artifact@v4
  with:
    name: dist-${{ matrix.os }}
    path: dist/

# 汇总 job
publish:
  needs: build        # 等所有矩阵 job
  steps:
    - uses: actions/download-artifact@v4
      with: { pattern: dist-* }

六、缓存与提速

6.1 缓存依赖

- name: Cache go modules
  uses: actions/cache@v4
  with:
    path: ~/.cache/go-build
    key: go-cache-${{ runner.os }}-${{ hashFiles('go.sum') }}
    restore-keys: |
      go-cache-${{ runner.os }}-
  • key 变化(依赖变了)才重新缓存。
  • restore-keys 提供"找不到精确 key 时用相近的"。

6.2 各生态缓存

# Go
actions/cache  →  ~/.cache/go-build,  key=hashFiles('go.sum')

# Python
actions/setup-python@v5 自带 cache: pip
  with: { python-version: '3.12', cache: 'pip' }

# Node
actions/setup-node@v4
  with: { node-version: 20, cache: 'npm' }

# Rust
Swatinem/rust-cache@v2    # 自动处理 target/

6.3 加速技巧

  • actions/setup-*cache 参数(自动)。
  • 只装依赖不改就复用层(Docker layer caching)。
  • 矩阵并行 > 单 job 里并行步骤。
  • 慢测试拆分到独立 job 并行。

七、可复用 Workflow(Reusable)

7.1 为什么

多仓库重复 CI 配置 → 抽成一个可复用 workflow,改一处全生效。

7.2 定义(被复用方)

.github/workflows/test-reusable.yml(必须 workflow_call):

name: Reusable Test
on:
  workflow_call:
    inputs:
      go-version:
        required: true
        type: string
    secrets:
      token:
        required: true
    outputs:
      test-passed:
        value: ${{ jobs.test.result }}

jobs:
  test:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-go@v5
        with: { go-version: ${{ inputs.go-version }} }
      - run: go test ./...

7.3 调用(使用方)

jobs:
  test:
    uses: ./.github/workflows/test-reusable.yml   # 同仓库
    with:
      go-version: '1.22'
    secrets:
      token: ${{ secrets.GITHUB_TOKEN }}

note

跨仓库复用:uses: owner/repo/.github/workflows/xxx.yml@main。可复用 workflow 里的 secrets 必须显式 secrets: 传,不能用 ${{ secrets }} 全局。


八、环境与 Secrets

8.1 环境(Environment)——部署隔离

jobs:
  deploy-prod:
    environment: production        # 绑定到仓库的 environment
    runs-on: ubuntu-latest

Environment 支持:分支保护规则 + 审批者(生产部署需人工 approve)+ 独立 secrets。

environment:
  name: production
  url: https://api.example.com    # 显示在 GitHub UI

8.2 Secrets 安全要点

- name: Deploy
  env:
    DB_PASSWORD: ${{ secrets.DB_PASSWORD }}   # 从 secret 读,不硬编码
  run: |
    curl -H "Authorization: Bearer $DB_PASSWORD" ...

warning

绝不要 echo "${{ secrets.X }}" 打印 secret(会进日志/缓存)。用 env 传,运行时通过环境变量取。secret 不能在 if: 条件里比较(会泄露值到日志)。

8.3 GITHUB_TOKEN 权限最小化

permissions:
  contents: read              # 默认最小: 只读
  pull-requests: write        # 需要写 PR 时才加
  packages: write

warning

permissions: write-all 是全开——Dependabot/PWN 攻击直接拿到写权限。永远最小权限。合并 PR 的 workflow(pull_request_target)尤其危险(运行在基础分支上下文,别 checkout 攻击者代码)。

8.4 密钥扫描

- uses: gitleaks/gitleaks-action@v2   # 扫描提交里的密钥

九、自托管 Runner(Self-hosted)

9.1 什么时候需要

  • 需要特定硬件/GPU、私有网络、容器环境。
  • 比 GitHub 托管便宜(大量 build)。

9.2 配置

# 在仓库 Settings → Actions → Runners 获取 token
./config.sh --url https://github.com/owner/repo \
            --token <token> --labels my-runner
./run.sh

9.3 安全警告

warning

自托管 runner 在 public 仓库 = 远程代码执行。任何人都能开 PR 让 runner 跑代码。除非绝对信任 PR 来源,否则 public 仓库别用自托管 runner。保护办法:只在 pull_request_target + 手动触发用,或跑在隔离 VM。

9.4 Runner 分组与标签

runs-on:
  group: my-group      # 指定 runner group
  labels: [gpu, linux] # 按标签选 runner

十、完整生产级示例

name: CI/CD

on:
  pull_request:
    branches: [main]
  push:
    branches: [main]
  workflow_dispatch:

concurrency:
  group: ${{ github.workflow }}-${{ github.ref }}
  cancel-in-progress: true

permissions:
  contents: read

jobs:
  lint:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: golangci/golangci-lint-action@v6

  test:
    runs-on: ubuntu-latest
    strategy:
      matrix:
        go: ['1.21', '1.22']
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-go@v5
        with: { go-version: ${{ matrix.go }}, cache: true }
      - run: go test ./... -race -count=1

  build:
    needs: [lint, test]
    runs-on: ubuntu-latest
    permissions:
      contents: read
      packages: write
    steps:
      - uses: actions/checkout@v4
      - uses: docker/login-action@v3
        with:
          registry: ghcr.io
          username: ${{ github.actor }}
          password: ${{ secrets.GITHUB_TOKEN }}
      - name: Build & push image
        run: |
          docker build -t ghcr.io/${{ github.repository }}:${{ github.sha }} .
          docker push ghcr.io/${{ github.repository }}:${{ github.sha }}

  deploy-staging:
    needs: build
    if: github.ref == 'refs/heads/main'
    environment: staging
    runs-on: ubuntu-latest
    steps:
      - run: kubectl set image deployment/app app=ghcr.io/${{ github.repository }}:${{ github.sha }}

十一、结束 + 速查表

tip

一页快速唤回:

  • 结构:event → job(独立 runner)→ step(顺序共享 shell)。
  • 触发push / pull_request / schedule / workflow_dispatch;用 paths 过滤。
  • expression${{ }}github / env / secrets / needs / matrix 上下文。
  • 条件if: success() / failure() / always() / cancelled()
  • 矩阵strategy.matrix 多版本多平台,exclude / include
  • 缓存actions/cachesetup-* 自带 cache;key 用 hashFiles
  • 可复用workflow_call / uses: owner/repo/.github/workflows/x.yml@main
  • 环境environment: 绑定审批 + 独立 secrets;生产部署加审批
  • 安全permissions 最小化;secret 用 env 传不打印;public 仓库别用自托管 runner
  • 并发concurrency.group 防重复发布。

下一篇: 9. 云原生发布与 GitOps: K8s / Helm / ArgoCD / Flux.

9. 云原生发布与 GitOps: K8s 应用 / Helm / ArgoCD / Flux / 服务网格

TL;DR

上一章讲了 GitHub Actions 把代码变成镜像;这一章讲镜像怎么进 K8s、怎么可靠发布、怎么用 GitOps 管理。核心是把"部署"也变成声明式 + 可 review + 可回滚的代码。

读完应能:

  1. 看懂 K8s 的 Deployment / Service / ConfigMap / Secret,知道发布流程。
  2. 用 Helm 打包/升级应用,理解 values 与 chart 结构。
  3. 理解 GitOps(ArgoCD/Flux):git 是唯一真相,集群自动收敛。
  4. 了解 service mesh(Istio/Linkerd)解决的发布问题。
  5. 设计"镜像构建 → git 更新 → GitOps 自动发布 → 观察 → 回滚"的闭环。

一、K8s 应用发布基础

1.1 核心对象

对象作用
Deployment无状态应用:副本数 / 滚动更新 / 回滚
Service稳定访问入口(ClusterIP / NodePort / LB)
ConfigMap非敏感配置
Secret敏感配置(base64,最好用外部密钥管理)
Ingress域名 → Service 的路由
HPA自动扩缩容

1.2 Deployment 发布模型(滚动)

apiVersion: apps/v1
kind: Deployment
metadata:
  name: app
spec:
  replicas: 3
  strategy:
    type: RollingUpdate
    rollingUpdate:
      maxUnavailable: 25%    # 最多 25% 不可用
      maxSurge: 25%          # 最多多 25% 新副本
  selector:
    matchLabels: { app: app }
  template:
    metadata:
      labels: { app: app }
    spec:
      containers:
        - name: app
          image: registry/app:v1.2.3     # ← 更新 image = 发布
          ports: [{ containerPort: 8080 }]
          readinessProbe:
            httpGet: { path: /healthz, port: 8080 }
            initialDelaySeconds: 5
          livenessProbe:
            httpGet: { path: /healthz, port: 8080 }
            periodSeconds: 10

发布 = 改 image: 字段。K8s 自动滚动:起新副本 → 就绪后删旧副本。

1.3 探针(Probe)

  • readinessProbe:就绪才进 Service 流量(没它就"流量打向没准备好的 pod")。
  • livenessProbe:活着的才留,否则重启(防止死锁僵尸)。
  • 一定要区分二者:就绪失败 ≠ 重启,存活失败 = 重启。

1.4 回滚

kubectl rollout undo deployment/app            # 回滚到上一个版本
kubectl rollout history deployment/app         # 看历史
kubectl rollout status deployment/app          # 看发布状态

warning

image 用不可变 tagv1.2.3 / git-sha),不要用 latestlatest 会让 kubectl rollout 分不清"改没改",且不可追溯。


二、ConfigMap 与 Secret

2.1 ConfigMap(非敏感配置)

apiVersion: v1
kind: ConfigMap
metadata: { name: app-config }
data:
  LOG_LEVEL: info
  MAX_CONNECTIONS: "100"
# Deployment 里注入
envFrom:
  - configMapRef: { name: app-config }

2.2 Secret(敏感配置)

apiVersion: v1
kind: Secret
metadata: { name: app-secret }
type: Opaque
stringData:        # 用 stringData, K8s 自动 base64 存储
  DB_PASSWORD: "s3cr3t"
env:
  - name: DB_PASSWORD
    valueFrom:
      secretKeyRef: { name: app-secret, key: DB_PASSWORD }

warning

K8s Secret 只是 base64,不是真加密。生产用 External Secrets Operator / Sealed Secrets / Vault,把真密钥放外部管理,K8s 里只放引用。Secret 进 git 前必须加密。


三、Helm:K8s 的包管理

3.1 为什么 Helm

裸 YAML 管理问题:

  • 环境差异(dev/staging/prod 配置不同)没法复用。
  • 升级/回滚要手工 kubectl。
  • 一个应用 20 个 YAML 难维护。

Helm = 模板化 + 版本化 + 升级/回滚

3.2 Chart 结构

mychart/
  Chart.yaml          # 元数据 (name/version)
  values.yaml         # 默认配置(用户覆盖)
  templates/          # Go template 渲染的 YAML
    deployment.yaml
    service.yaml
    _helpers.tpl      # 公共模板
  .helmignore

3.3 使用

helm create myapp
helm install myapp ./mychart              # 安装
helm upgrade myapp ./mychart -f prod-values.yaml   # 升级
helm rollback myapp 1                     # 回滚
helm list

3.4 values 覆盖

values.yaml(默认):

replicaCount: 3
image:
  repository: nginx
  tag: stable
resources:
  limits: { cpu: 500m, memory: 512Mi }

prod-values.yaml(覆盖):

replicaCount: 10
image:
  tag: v1.2.3
resources:
  limits: { cpu: 4, memory: 8Gi }
helm upgrade myapp ./mychart -f prod-values.yaml

3.5 模板示例

# templates/deployment.yaml (片段)
apiVersion: apps/v1
kind: Deployment
metadata:
  name: {{ .Chart.Name }}
spec:
  replicas: {{ .Values.replicaCount }}
  template:
    spec:
      containers:
        - name: app
          image: "{{ .Values.image.repository }}:{{ .Values.image.tag }}"
          resources:
            limits:
              cpu: {{ .Values.resources.limits.cpu }}

3.6 安全与版本

  • Chart 版本与 App 版本分开(version vs appVersion)。
  • --atomic:升级失败自动回滚。
  • Helm 仓库(OCI Registry / ChartMuseum)统一管理 chart。

四、GitOps:git 是唯一真相

4.1 核心思想

git 仓库里的声明 = 集群的期望状态。有 agent 持续把集群收敛到 git 状态。

开发者: 改 git (YAML/image tag)
  ↓
ArgoCD/Flux 检测到 git 变化
  ↓
比较集群当前状态 vs git 期望状态 (diff)
  ↓
不一致 → 应用/回滚到期望状态
  ↓
报告状态 (Healthy / OutOfSync / Degraded)

4.2 为什么比"CI 直接 kubectl"好

CI 直接部署GitOps
真相源CI 脚本 + 手动操作git
可 review部署即 PR 可审
回滚手工git revert
审计git log 即审计
漂移处理agent 自动收敛/告警

4.3 ArgoCD

  • 声明式:一个 Application 指向 git repo 里的路径。
  • App of Apps:用一个 App 管理所有 App。
apiVersion: argoproj.io/v1alpha1
kind: Application
metadata:
  name: myapp
  namespace: argocd
spec:
  destination:
    server: https://kubernetes.default.svc
    namespace: prod
  source:
    repoURL: https://github.com/org/app-config.git
    path: prod
    targetRevision: main
  syncPolicy:
    automated:
      selfHeal: true      # 集群被手动改 → 自动纠正回 git 状态
      prune: true         # git 里删了 → 集群里也删
argocd app sync myapp          # 手动同步
argocd app rollback myapp 2    # 回滚

4.4 Flux(另一个主流)

  • 与 ArgoCD 同思路,更偏"GitOps Toolkit"(Kustomize 优先)。
  • ImageAutomation 可自动更新 image tag → 全自动发布流水线。

4.5 发布闭环(GitOps + CI)

CI (GitHub Actions): 构建镜像 → push → 更新 app-config repo 的 image tag (PR)
        ↓
GitOps agent (ArgoCD): 检测 app-config 变化 → 应用 → 健康检查
        ↓
生产就绪; 失败 → 自动回滚/告警

note

镜像仓库和配置仓库分开是常见最佳实践:代码 repo 构建镜像,app-config repo 只声明"这个 tag 部署到哪"。这样"代码变了"和"部署了"是两个可独立回滚的决策。


五、Kustomize:另一个配置管理

Helm(模板 + 逻辑)vs Kustomize(纯覆盖,无逻辑):

# Kustomize: base + overlay
base/
  deployment.yaml
overlays/
  prod/
    kustomization.yaml    # 覆盖 image/replicas
# overlays/prod/kustomization.yaml
resources:
  - ../../base
patches:
  - path: patch.yaml
images:
  - name: nginx
    newTag: v1.2.3
kubectl apply -k overlays/prod

对比:Helm 强在模板复用;Kustomize 强在无学习成本、无逻辑、纯声明。ArgoCD/Flux 两者都支持。


六、服务网格(Service Mesh)

6.1 解决什么

微服务间通信的可观测性 / 流量控制 / 安全(mTLS)横切每个服务——不想每个服务都写重试/超时/追踪代码 → 用 sidecar 代理统一注入。

6.2 核心能力

  • 流量管理:灰度/金丝雀(按 header/百分比路由到 v2)。
  • 可观测:自动指标/追踪(无需改业务代码)。
  • 安全:服务间 mTLS 自动加密。
  • 可靠性:超时/重试/熔断/限流注入。

6.3 Istio 示意

apiVersion: networking.istio.io/v1beta1
kind: VirtualService
metadata: { name: myapp }
spec:
  hosts: [myapp]
  http:
    - match:
        - headers:
            x-canary: { exact: "true" }
      route: [{ destination: { host: myapp, subset: v2 } }]   # 按 header 走 v2
    - route:
        - destination: { host: myapp, subset: v1 }
          weight: 95        # 95% v1
        - destination: { host: myapp, subset: v2 }
          weight: 5         # 5% v2  (金丝雀)

6.4 什么时候用/不用

  • :微服务规模大、跨服务流量控制/安全/可观测需求强。
  • 不用:服务少(< 10)、单体、控制面复杂度不值得(每个 pod 多一个 sidecar)。

七、发布安全与生产就绪清单

[ ] image 用不可变 tag (git-sha), 不用 latest
[ ] readiness + liveness 探针配置正确
[ ] resources.limits/requests 设置 (防止吃垮节点)
[ ] Secret 用外部管理 (Vault/External Secrets)
[ ] GitOps: 部署变更走 git PR (可 review 可回滚)
[ ] 滚动更新策略 (maxUnavailable/maxSurge) 合理
[ ] 金丝雀: 先 5% 再全量, 指标驱动
[ ] 数据库迁移与发布解耦 (先兼容后破坏)
[ ] 回滚演练过 (kubectl rollout undo / argocd rollback)
[ ] HPA 配置 (弹性)
[ ] 网络策略/命名空间隔离

八、结束 + 速查表

tip

一页快速唤回:

  • 发布 = 改 image tag;用不可变 tag;kubectl rollout undo 回滚。
  • 探针:readiness(就绪才进流量)/ liveness(存活才留);一定分开。
  • ConfigMap(非敏感)/ Secret(敏感,K8s 里只是 base64,生产用 Vault)。
  • Helm = 模板 + 版本 + 回滚;values.yaml 覆盖环境差异;--atomic 防失败留半。
  • GitOps:git 是唯一真相,ArgoCD/Flux 自动收敛;自愈 + prune。
  • CI vs GitOps:CI 建镜像,GitOps 管部署(镜像 repo 与配置 repo 分离)。
  • Kustomize:纯覆盖无逻辑;Helm 有模板有逻辑。
  • Service Mesh:sidecar 注入做灰度/mTLS/重试;服务少别用。
  • 生产就绪:探针 + 资源限制 + 不可变 tag + Secret 外部化 + 金丝雀。

下一篇: 10. SRE 工程: 错误预算 / 容量 / 变更 / 事件响应 / 生产就绪.

10. SRE 工程: 错误预算 / 容量 / 变更 / 事件响应 / 生产就绪

TL;DR

SRE(Site Reliability Engineering,Google 创立)是"让软件可靠性成为可度量的工程"。核心理念:可靠性不是"尽量不出事",而是用错误预算(Error Budget)来平衡可靠性与迭代速度。这一章把 SRE 的方法论落到实际工作:SLI/SLO 怎么定、错误预算怎么用、容量怎么规划、变更怎么控制风险、事件怎么响应、系统怎么"生产就绪"。

读完应能:

  1. 说出 SRE 与普通运维的本质区别(可度量 + 自动化 + 软件工程化)。
  2. 设计并落地 SLI/SLO/Error Budget,用它驱动决策。
  3. 做容量规划(趋势预测、扩缩容策略)。
  4. 设计变更流程(发布窗口、回滚、卡点)。
  5. 跑一次完整的事件响应(发现 → 响应 → 缓解 → 复盘)。

一、SRE 是什么

1.1 SRE vs 传统运维

传统运维SRE
目标系统稳定稳定 + 迭代速度平衡
度量靠感觉/经验SLI/SLO 可度量
手段人肉守护自动化 / 代码化
心态避免一切变更用错误预算管理风险
交付物稳定系统稳定 + 自动化工具

1.2 Google 的"50% 规则"

SRE 团队最多把 50% 时间花在运维(operation)上,另外 50% 必须花在工程开发(工具/自动化/架构改进)上。

这条规则逼着 SRE 用工程解决重复劳动,而不是堆人肉。

1.3 核心心智:可靠性与速度的权衡

  • 可靠性 100% = 永远不发布 = 产品死。
  • 可靠性 0% = 疯狂发布 = 用户跑。
  • 正确姿势:定一个目标(如 99.9%),剩下 0.1% 的"错误预算"用来"花钱"——可以拿来发布新功能、可以拿来冒险。

二、SLI / SLO / Error Budget

2.1 定义

SLI (Indicator): 怎么量    → 请求成功率 / P99 延迟 / 可用性
SLO (Objective):  目标值    → 99.9% 的请求 < 500ms
Error Budget:     预算      → 100% - SLO = 1 - 0.999 = 0.1%

2.2 好 SLI 的三原则

  1. 从用户视角度量(不是内部指标):用户感知的延迟/错误,不是服务器 CPU。
  2. 比率 + 阈值:如"成功请求 / 总请求"、"满足延迟阈值的比例"。
  3. 可被 SLO 直接衡量:SLI 的值必须能被查出来(有监控系统)。

2.3 常见 SLI

类型SLI 例子
可用性成功请求 / 总请求 ≥ 99.9%
延迟P95 响应 < 500ms(5 分钟内)
吞吐每秒处理请求 ≥ X
持久性数据不丢失 ≥ 99.999%
新鲜度数据延迟 < 60s

2.4 SLO 设计:目标怎么定

  • 看用户期望 + 业务成本,不是"越高越好"。
  • 3 个九(99.9%):月停机 43 分钟;4 个九:4.3 分钟;5 个九:26 秒。
  • 目标越高,成本和运维负担指数上升。定 99.9% 还是 99.95%,取决于用户是否真的在意那 0.05%

2.5 Error Budget 的三种用法

1. 发布闸门: 预算用完 → 停止发布高风险变更 (保护稳定性)
2. 风险定价: 预算还有 → 可以冒险发布 (加速迭代)
3. 优先级:  预算快耗尽 → 把开发资源投到可靠性 (SLO 是待办优先级工具)

note

错误预算把"可靠性"从抽象的愿望变成可消耗的资源。就像团队有 43 分钟/月的"容错额度"——烧完就停止变更,回血了就恢复发布。这让"稳不稳"成为可量化、可决策的对象,而不是吵架。

2.6 告警:Burn Rate

Burn Rate时间窗告警级别含义
30 天正好用完预算
15 天慢速告警过半预算
14.4×2 小时快速告警(严重)2 小时内烧完 1 个月预算
快速告警条件: 2h 内 burn rate ≥ 14.4  (14.4% 预算在 2h 消耗)
慢速告警条件: 24h 内 burn rate ≥ 3

三、容量规划

3.1 为什么要做

  • 没容量 → 高峰期雪崩。
  • 过度容量 → 浪费钱。
  • 目标:在成本与风险间,预测并预留

3.2 方法

1. 数据收集: 现有 QPS / 存储 / CPU 使用趋势
2. 趋势外推: 线性/指数增长曲线
3. 预测模型: 业务季节性 (双11/黑五/开学季)
4. 缓冲: 预留 30-50% (应对突发 + 发布后流量变化)
5. 验证: 压测 (locust/k6) 确认瓶颈

3.3 关键指标

指标看什么
峰值 vs 均值尖峰是否吃满
资源利用率CPU/内存 长期 > 70-80% 预警
饱和度连接池/队列/磁盘 IO 是否到顶
增长趋势周环比/月环比

3.4 扩缩容策略

方式说明
手动扩容有预见性,但慢
定时伸缩已知高峰(业务周期性)
HPA(K8s)按 CPU/内存/自定义指标自动扩缩
预测式扩缩基于历史趋势提前扩容
容量套餐云上买弹性(预留 + 按量)
# K8s HPA: 按 CPU 自动扩缩
apiVersion: autoscaling/v2
kind: HorizontalPodAutoscaler
metadata: { name: myapp }
spec:
  scaleTargetRef:
    apiVersion: apps/v1
    kind: Deployment
    name: myapp
  minReplicas: 3
  maxReplicas: 30
  metrics:
    - type: Resource
      resource:
        name: cpu
        target: { type: Utilization, averageUtilization: 70 }

四、变更管理

4.1 原则:变更 = 事故的最大来源

统计上,生产事故的最大来源是变更(发布、配置改动、迁移)。SRE 的重点不是"别变",而是让变更可控

4.2 变更的四个控制点

1. 小步: 每次变更小 (少 commit / 少配置) → 定位容易
2. 渐进: 金丝雀/灰度 → 先 5% 后全量
3. 可回滚: 每个变更都要有回滚计划 (发布前就想好)
4. 观察: 发布后 10-30 分钟盯指标, 异常立刻回滚

4.3 发布窗口(Change Window)

  • 高风险变更选低流量时段(业务低谷)。
  • 但"强制窗口"也会拖慢迭代 → 现代 SRE 更倾向"随时小步 + 自动化回滚"而非"每周一次大窗口"。
  • 权衡:成熟团队 = 高频小变更(每次风险低)+ 强自动回滚;不成熟 = 低频大变更 + 长窗口。

4.4 配置变更风险

  • 配置改动比代码改动更难回滚(没版本/没测试)。
  • 方案:配置也走版本控制(GitOps)、配置变更也可灰度(feature flag)、配置有默认值向后兼容。

4.5 特征开关(Feature Flag)

  • 让"发布代码"与"启用功能"分离。
  • 新功能默认关 → 出问题只关 flag,不用回滚代码。
  • 这是"发布风险降到最低"的核心工具之一。

五、事件响应(Incident Response)

5.1 生命周期

发现 → 响应/分级 → 缓解 → 解决 → 复盘(Postmortem)

5.2 分级(Severity)

级别影响响应时间
SEV1核心功能全挂 / 大规模用户不可用 / 数据丢失立即
SEV2部分功能异常 / 部分用户受影响30 分钟内
SEV3小范围 / 低影响工作日内
SEV4无明显用户影响排期处理

5.3 响应流程

1. 确认: 告警 → 确认是不是真的 (不是误报)
2. 宣布: 宣布 incident, 指定 owner, 拉群
3. 缓解优先于根因: 先止血 (回滚/降级/扩容), 别现场改代码!
4. 缓解手段 (回滚 / 切流量 / 禁缓存 / 扩容)
5. 验证缓解生效 (指标回落)
6. 复盘: 不追责, 找系统根因 + 改进项

warning

事件中最重要的原则:先止血,后查根因。在事故现场"顺手修 bug"是大忌——第一优先是恢复服务(回滚/降级),根因留到复盘。

5.4 Postmortem(复盘)

复盘的三要素:

  1. 不追责(Blame-free):目的找系统问题,不是找责任人。
  2. 根因分析:用"5 Why" 挖到系统/流程根因,不停在表面。
  3. 行动项:每条根因对应可验证的改进(SLO、告警、自动化、架构)。
复盘模板:
- 摘要 (发生了什么, 影响多大)
- 时间线 (何时发现/响应/缓解)
- 根因 (为什么, 5 Why)
- 为什么没被及时发现 (监控/告警缺口)
- 为什么没被自动缓解 (自动化缺口)
- 行动项 (每条带 owner + 截止日)

六、生产就绪(Production Readiness)

6.1 生产就绪清单(上线前必须)

[ ] SLI/SLO 已定义, 监控已配
[ ] 告警已接 (带 runbook)
[ ] 日志结构化, 可关联 trace_id
[ ] 容量已估算, 有扩缩容机制
[ ] 发布流程: 金丝雀 + 自动回滚
[ ] 回滚演练过
[ ] 备份 + 恢复演练过 (重要数据!)
[ ] 安全: 最小权限, 密钥外部管理, 依赖扫描
[ ] 文档: runbook, 架构图, 联系人
[ ] 失败注入/混沌测试 (可选但推荐)

6.2 混沌工程(Chaos Engineering)

  • 主动注入故障(杀 pod / 断网 / 延迟)验证系统韧性。
  • 工具:Chaos Mesh、Litmus、Gremlin。
  • 原则:先在生产前(staging)验证,小范围开始

6.3 无值班时怎么办(小团队)

  • 没有专职 SRE → 用自动化兜底:自动回滚、自动扩缩、自助 runbook、异常自动降级。
  • 把"靠人盯"降级为"靠系统兜底 + 人只在 SEV1 被叫醒"。

七、SRE 的自动化思维

7.1 凡事都该自动化

人肉操作自动化替代
手工部署GitOps + 自动发布
手工扩容HPA + 预测扩缩
手工回滚自动回滚(发布失败自动退)
手工排查runbook + 一键诊断脚本
手动恢复自愈(liveness + restart)

7.2 自动化优先级

高价值: 发布 / 回滚 / 扩缩 / 恢复  (出事最需要)
中价值: 监控告警 / 容量预测
低价值: 一次性诊断

tip

自动化的目标不是"减少人手",是减少 MTTR(平均修复时间)。每次事件后问"这段能不能自动化"——自动回滚、自动扩缩、自动切流量,这些是降低 MTTR 的核心。


八、结束 + 速查表

tip

一页快速唤回:

  • SRE = 可度量的可靠性:稳定 × 速度平衡,50% 时间做工程化。
  • SLI(怎么量)/ SLO(目标)/ Error Budget(100%-SLO = 容错额度)。
  • 3 个九:月停 43min;4 个九:4.3min;目标别盲目往高定。
  • 错误预算三用法:发布闸门 / 风险定价 / 优先级工具。
  • Burn Rate:14.4×(2h)= 严重告警;预算内波动不告警。
  • 容量:趋势 + 季节性 + 30-50% 缓冲 + 压测;HPA 自动扩缩。
  • 变更 = 事故主源:小步 / 渐进 / 可回滚 / 观察;feature flag 分离发布与启用。
  • 事件响应:先止血(回滚/降级)后查根因;复盘不追责。
  • 生产就绪:SLO+告警+日志+回滚演练+备份恢复+安全。
  • 自动化目标:降 MTTR,不是减人手。

回主目录: 工程化实践轴 README.

第一部分 · 数据结构与算法(DSA)

note

这一整部分的目标:用工程实战级的深度,把 DSA 在脑子里重新 "焊一遍"。读完你应该能在日常工程里:看一段代码就能说出大概的复杂度,看一个需求就能给出合理的数据结构选型,并能落地到至少两种语言。

为什么从头学 DSA

很多有经验的工程师会把 DSA 视为"面试才用得上"的东西。这是一个严重的认知偏差。真实世界里,DSA 决定的不是能不能写出代码,而是:

  • 你能不能在几亿行日志里 5 分钟内找到重复事件;
  • 你的服务体系结构能不能替换一个 maplsm-tree写入吞吐翻一个量级
  • 你能不能看出来某个"看起来正确"的缓存实现其实是 O(n²),被一个意外输入直接打挂。

DSA 不是面试题,是 "数据形态 × 性能空间" 的设计空间本身

这里和别处有什么不同

  • 每个数据结构:语义 → 复杂度 → 实现 → 工程取舍 → 易错点 → 经典题
  • 多语言实现:Go / TypeScript / Python / C++,对照看会让你发现"语言运行时帮你藏了多少东西"。
  • 重视 amortized / worst-case / cache 的差异,而不是只记一张表。
  • 每章末尾有"反推设计":给一个真实负载,让你反推出该用什么。

阅读路径建议

算法 = 空?→ 先读《复杂度分析》
        │       因为后面每一章都会用 O(·) × θ(·) × Θ(·)
        ↓
结构 → 数组/链表/栈队列/哈希
        ↓
树 → 二叉搜索树 → 平衡 → 堆 → 字典树/并查集
        ↓
图 → 表示 → 路径 → MST → 拓扑 → 网络流
        ↓
算法范式 → 分治/贪心/DP/回溯/分支限界
        ↓
专题 → 排序/搜索/字符串/数论
        ↓
附录 → snippets + LeetCode 路线

tip

如果你不是第一次学,可以快速翻过前面,但复杂度分析树的非递归实现这两节建议精读——这俩最容易"以为自己懂了"。

复杂度分析

TL;DR

  • O / Ω / Θ 不是"快慢",是渐近界
  • 平均复杂度 ≠ 摊还复杂度 ≠ 最坏复杂度。三者经常被混为一谈。
  • 工程里真正决定性能的是 常数因子 × 缓存友好性,光看大 O 容易得出正确但无用的结论。
  • 复杂度表的"和",不能直接相加——当输入规模 n 变大时,主导项会吞掉其它项。

为什么从复杂度开始

不熟悉复杂度,你后面看到红黑树 O(log n)、哈希 O(1)、B+ 树 O(log n),都不会理解:

  • 为什么哈希常常比红黑树快但工程里仍然用红黑树?
  • 为什么 Radix Sort 名义上 O(n+k) 但大多数地方还是用快速排序?
  • 为什么同样是"顺序扫描",缓存友好版本可以比朴素版快 10 倍?

答案都在这一章里。

flowchart LR
    A[代码实现] --> B[复杂度分析]
    B --> C{是否在规模内?}
    C -- 是 --> D[够了,跑]
    C -- 否 --> E[换结构 / 换算法 / 重新组织数据]
    E --> B

继续读:

渐进记号的真实含义

一句话

O(f(n)) 不是"快慢",是"函数被夹在哪个量级里";它只告诉我们当 n 趋于无穷时增长的形状,不告诉我们任何常数、任何 cache 行为、任何 CPU 流水线效应。所以只看 O 是工程师的天真阶段——但同时记住:完全没有 O 的工程化分析也是不可能的。这一章的任务是把这两层同时立起来。

形式定义

fg 都看作 ℕ → ℝ⁺。捕获三种"夹逼"方向:

  • f = O(g):存在 c > 0n₀ 使得 ∀n ≥ n₀f(n) ≤ c · g(n). 即"f 的增长不会跑过 g 的某个常数倍".
  • f = Ω(g):存在 c > 0n₀ 使得 ∀n ≥ n₀f(n) ≥ c · g(n). 即"f 的增长不会慢于 g 的某个常数倍".
  • f = Θ(g):刚好同时是 O 和 Ω。"两边都被常数夹死".
  • f = o(g) / f = ω(g):严格小、严格大(取极限之比分别为 0 与 ∞).

warning

习惯写法 f = O(g) 里的 = 不对称。O(g) 是一个函数集合,严格写法是 f ∈ O(g). 但业界已经把 {f | f = O(g)} 当成口语化的某个等价集,所以约定俗成就这样了。你读 "T(n) = O(n log n)" 时脑子里要翻译成"T(n) 落在 Θ(n log n) 差不多的量级上"。

工程师对渐进的五层误读

误读 1:O(1) 一定比 O(log n) 快

错。O(1) 里的常数项决定小规模胜负。例子:哈希单次操作 ≈ 几十到几百条指令(hash、mod、可能的 cache miss、可能的 tombstone 探测);二分单次 ≈ 10 条指令(一次比较 + 一次地址加法 + 一次 cache hopeful 读)。n < 100 时二分一般是赢的。

note

benchmark 上一个常见的反例:Go map 在 100 个键时比 sort.Search 慢。这就是"O(1)"输给"O(log n)"在工程现场的实证。说明渐进常数大于 10。

误读 2:O(log n) 都是一个量级

log 的底是常数差异。log₂ nln n 差一个 ln 2 ≈ 0.69 的乘性系数。看起来微不足道,但在每次比较代价高时:

  • 二叉树每次比较 1 cycle;
  • 红黑树 + string key 时每次比较可能上百 cycle(strcmp 遍历整个 key,常常命中 cache 也不好);
  • B+ 树页内比较一次后用 SIMD 8-wide 一次比 8 个 key —— 常数缩小 8 倍.

到这里你就理解了:大 O 不区分 log 的底,但工程常数能差 10 倍.

误读 3:平均复杂度 = 期望复杂度

"平均"必须指明分布。CLRS 上写"平均"默认均匀分布;而工程平均常常是"产品实际输入分布下的期望".

  • 排序算法的"平均 O(n log n)"指所有排列等可能;
  • 同样的快排在"已序数据"上能退化到 O(n²)。

这就是为什么 std::sort 用 introsort(递归深度超过 2 log n 时切到 heap sort),是给"平均分析失效"打的补丁。

误读 4:O(n) 里只有一个常数

数字电路视角告诉我们:O(n) 里其实有两次 трагедия:

  1. 每条指令的 throughput 是 1 ns 级别(约 3 GHz / cycle)。
  2. 每次内存访问的 latency 从 1 ns (L1) 到 80 ns (DRAM).

所以同样 O(n),cache-friendly 版本能跑出 100 GB/s 的内存带宽;cache-hostile 版本只能跑出 5 GB/s。差 20 倍。这就是为什么工程里"常数因子"实际上跨两个数量级是常态。

误读 5:复杂度能直接指导性能

不能。O 给的是形状,性能还要看:

  • 常数;
  • cache-locality;
  • 分支预测准确率(分支错误一次流水线 flush 浪费 ~15 cycle);
  • SIMD vectorization(紧邻操作可向量化时跑 4-wide/8-wide);
  • TLB 命中(4K page 对 1 GB 数据需要 256K TLB 项,远超 TLB 大小);
  • NUMA(跨 socket 访问再加 100+ ns)。

下面我们把这些点逐层揭示——它们各自都是计算机科学的"硬"层级,复杂度只是这一切的抽象外壳.

主定理:分治复杂度从哪儿来

T(n) = a · T(n/b) + f(n) 的三条规则,背后是比较 f 与 n^(log_b a) 哪个主导

情形条件结论
1f = O(n^(log_b a − ε))T = Θ(n^(log_b a))
2f = Θ(n^(log_b a · log^k n))T = Θ(n^(log_b a · log^(k+1) n))
3f = Ω(n^(log_b a + ε)) 且 regularT = Θ(f(n))

直觉:两个 "工程贡献"——递归子树数量 a 与每个分裂的合并工作 f——哪个增长更快,就是 T 的主导.

场景abf(n)T(n)
二分查找12O(1)Θ(log n)
归并排序22O(n)Θ(n log n)
Strassen 矩阵乘法72O(n²)Θ(n^log₂7) ≈ Θ(n^2.807)
Karatsuba 大整数乘32O(n)Θ(n^log₂3) ≈ Θ(n^1.585)
普通斐波那契递归21O(1)Θ(2^n)(不在主定理范围,但用同一思路)

主定理的工程隐藏点:它忽略常数。Strassen 在 n < 100 常输给朴素乘法,原因就是常数 7 比 8 大,但每层多做很多加法和地址跳。

note

这就是为什么平均 O(n^2.807) 的 Strassen 直到 n ≈ 1000 才稳定赢过 O(n^3) 朴素。当 n 还在 cache 里时,cache-friendly 的朴素矩阵乘能跑到 30+ GFLOPS,而 Strassen 的间接内存 pattern 跑不到 10 GFLOPS。CPU 通用核心的算力差距被掩盖在大 O 里。

从 O 到运行时间的链路

让我们把"复杂度"和"真实运行时间"的链路逐层补完。以一次 for (i=0; i<n; i++) sum += a[i]; 为例。

1. 复杂度层:O(n)

这是抽象最高层。我们只说访问模式线性增长。

2. 指令数层:约 4n 条指令

x86 汇编大致:

mov  rax, [a]             ; 取数组基地址
xor  ecx, ecx             ; sum = 0
loop:
    mov  edx, [rax + 4*i] ; 一次 load
    add  ecx, edx         ; 加到 sum
    inc  i                ; i++
    cmp  i, n
    jb   loop

四条指令,每轮迭代都要跑一次。所以总开销约 4n 条指令。

3. 指令 throughput 层:约 1 ns / iter

现代 CPU 每条简单整数 ALU 指令 throughput ≈ 0.25~0.5 cycle。但内存 load 在 cache hit 时 latency 仍 ~4 cycle (L1),hit rate 100% 时通过流水线可以"叠"到每 cycle 一条 load。所以每个循环迭代大约 1 cycle = 0.33 ns(按 3 GHz).

总时间:~ n ns. 一个 10^8 个 int 的求和:~ 100 ms.

4. cache / TLB 层:内存层级拉宽到 80 ns

CPU cache 是金字塔:

register   ≈ 0 cycle       (1-2 ns as architecture)
L1 (32KB)  ≈ 4 cycle       (~1 ns)
L2 (256KB) ≈ 12 cycle      (~4 ns)
L3 (8MB)   ≈ 40 cycle      (~12 ns)
DRAM       ≈ 200 cycle      (~80 ns)

如果数组远超 L3 —— 10^8 int = 400 MB —— 那么 load 几乎都命中 DRAM,每次 wait 80 ns 即 200 cycle。CPU 的 prefetcher 会主动找出连续模式提前加载到 cache line 64 字节,让一次 80 ns 的 DRAM access 满载 16 个 int 加法. 加上 SIMD 一次加 4-8 个 int:

// AVX2 一次加 8 个 int32
__m256i vsum = _mm256_setzero_si256();
for (int i = 0; i + 8 <= n; i += 8) {
    __m256i v = _mm256_loadu_si256((__m256i*)&a[i]);
    vsum = _mm256_add_epi32(vsum, v);
}

效果:~3 cycle per 8 个 int,折合每元素 1 ns 的 1/15:8 GB/s 顺序流可到 100+ GB/s。

到这里你应该看见:O(n) 在不同层之下能差 100 倍,但它们都叫 O(n).

5. 数字电路层:cache line 为什么是 64 字节

为什么是 64 字节而不是 32 或 128?

  • 一行读出要并行传输给 L1:64 字节正好是 DDR 一条 burst length (8 × 8 字节 = 64);
  • L1 的 ECC、tag 位、一致性管理都按行做,64 字节让 L1 + L2 + L3 行号对齐,简化了一致协议 (MESI/MOESI)
  • 主板上 64 字节也对应 PCIe 一次 TLP payload 上限(Completer 上限典型 128B/256B,但 cache line 仍是 64B)。

所以"64"不是任意数字,是DRAM burst 与 cache 一致协议共同压制的结果。这就是"知其所以然"的底层:连 64 这个常数都是数字电路决定的。

6. FPGA 视角:BRAM/SRL/分布式 RAM 是另一套

FPGA 不是 CPU 上的 cache 模型。Altra/Xilinx FPGA 里有:

  • Distributed RAM(每个 LUT 都能当 64 bit RAM)—— 极快,几十 ps;
  • URAM / BRAM:块 RAM,>1 Mbit,~1-2 cycle;
  • HBM:3D 堆叠 DRAM,30-100 GB/s/channel, 80 ns latency。

FPGA 上的算法设计常常把一个图处理算法展开成 pipeline:每个 cycle 进一个 vertex、出一个 vertex,让所有计算在几百 MHz 频率下展开运行,而不是"O(n) 循环". 这就是把 O(n) 工程化到硬件层。

你看,同一个 O(n) 的求和,从软件常数 3 到数字电路常数 1/15 再到 FPGA 的 "1 cycle/element"——每一层都有它自己的常数。

主导项之外的相加规则

一句话:相加取最坏,相乘入相邻.

O(f) + O(g) = O(max(f, g))     # 比如 O(n log n) + O(n) = O(n log n)
O(f) × O(g) = O(f × g)         # 嵌套循环 O(n × m)
O(f) + o(f) = O(f)             # 低阶项被吞噬

但有边界陷阱:

  • T(n) = T(n-1) + n 不是 O(n) 而是 O(n²) —— 是等差级数累计的闭式,不是简单相加.
  • T(n) = T(n-1) + 2T(n/2) + n —— 退化递归;先画递归树数工作总量,不要直接进主定理。
  • T(n) = 2T(n/2) + n/log n —— 主定理 case 2 的"烦人角落"(Akra-Bazzi 才稳);结论是 Θ(n log log n)

工程里写递归/分治之前,至少画一次递归树画三行,避免被主定理的形式骗到。

速查表(含工程常数注释)

渐进实际 ops @ n=10⁶工程注释
O(1)几 ns哈希、寻址、SIMD 单向
O(log n)~20 次比较树、堆、二分;热区一般 ~10 ns
O(n)~10⁶ opscache 友好 ~3 ms;cache hostile ~50 ms
O(n log n)~2×10⁷ ops排序门限;32 MB 数据 ~100 ms
O(n²)10¹² opsn=10⁶ 必死;n=10⁴ 约 100 ms
O(2ⁿ)不可行n≤30 才能跑

这一章带走的东西

  • O / Ω / Θ 是夹逼式的,= 是个误用符号;
  • O 的工程常数能跨两个数量级,因为 cache + 流水线 + SIMD 三者各贡献一个"乘性加速";
  • O(log n) 在工程里和底不同(B+ 树页内 SIMD 比一次对 8 个 key —— "log 底 8");
  • 主定理忽略常数,Strassen 在 cache 内打不过朴素乘法是"O 不够看"的最典型案例;
  • 一行 O(n) 求和从顶层 software 常数 3 一路到 FPGA 1 cycle/element,每一层都是一层硬件知识.

下一节 → 摊还分析入门:把"序列总和"的渐进分析框入工程可证的形式。

摊还分析入门

一句话

单次操作代价巨大不代表整体慢——只要"贵"的次数被"便宜"的次数均摊过来,平均仍可控。摊还分析就是把这种"凭直觉"的统计变成形式证明。它是后面所有动态数组、并查集、Splay 树、Bloom 滤波等结构的工程性能证明语言。

为什么不能用纯最坏复杂度

考虑动态数组的 push_back

  • 大部分时间是 O(1)(写入下一空槽);
  • 满了就要扩容 O(n):申请新数组、拷贝 n 个元素、释放旧数组。

如果你只看"最坏",会得到 push 是 O(n)。代入 N 次 push 的和:O(N · n) = O(N²)。 但直觉告诉我们 N 次 push 在 Θ(N) 量级。证明需要等会一种"统计序列"的工具——摊还分析。

之所以这件事重要,是因为工程上你几乎从不在 N 次 push 都打到 O(n) 最坏。但你要证明这一点,需要把"实际代价序列求和除以操作数"写成一个数学上可证的程序。

三种方法

1. 聚集法(Aggregate):先求全部,再除以次数

最朴素做法:把所有 n 次操作的总代价求和,再除以 n。

动态数组倍增继续上节的节奏:

扩容发生在 size=1, 2, 4, 8, …
每次扩容的开销 = 旧 size(拷贝)+ 1(写新元素)
总开销 = 1 + (1+2) + (1+4) + (1+8) + … + (1+n/2)
       ≈ n + n/2 + n/4 + … 
       = 2n ≈ 2n
加上每次 push 本身的 1 次 write:+ n
总和 ≈ 3n。
摊还到每次:Θ(1)。

简单,但不能精细区分每个操作的代价

2. 会计法(Banker):给便宜操作"预存",贵的时候花

给每次便宜操作多记一点当信用:

  • push 实际花 1,记账 3;
  • 多余 2 进账户;
  • 当扩容发生,n 个元素搬迁要花 n,正好从账户里掏(账户此时余额 ≥ 2·(n/2) = n)。

账户永不为负 ⇒ 每次操作实际累计 ≤ 记账累计 = 3n ⇒ 摊还 O(1)。

tip

会计法的好处:能给不同操作不同代价预算。后面的并查集、链表反转都能用,而且比聚集法精细。

3. 势能法(Potential):物理直觉的可迁移方法

定义势能函数 Φ(i) = Φ(D_i),描述第 i 次操作后的"数据结构能量". 要求:

  • Φ(0) = 0
  • 对所有 i ≥ 0Φ(i) ≥ 0.

定义第 i 次操作的摊还代价

a_i = c_i + Φ(D_i) − Φ(D_{i-1})

求和:

Σ a_i = Σ c_i + Φ(D_n) − Φ(0)
       ≥ Σ c_i        (因为 Φ(D_n) ≥ 0 = Φ(0))

也就是说如果你能给所有 a_i 一个上界 k,那 Σc_i ≤ k·n. 这是最有工程性、最可移植的方法。记住势能法,所有结构摊还证明你都能用.

用势能法重证动态数组 O(1) 摊还

先试一个朴素势能:Φ(i) = 2·i − cap_i,其中 i 是当前元素数、cap_i 是容量。

  • 普通 push 不触发扩容:实际 1,ΔΦ = 2,摊还 3. OK.
  • 触发扩容 push:扩容前 cap = i-1,即"之前刚好满 i-1 个的位置再加一个". 实际开销 = i (拷贝旧 i-1 个 + 写新元素).

按上面 Φ(i-1) = 2(i-1) − cap_{i-1},cap_{i-1} 在扩容前 = i-1 ⇒ Φ(i-1) = i-2. 扩容后 cap = 2·cap_old = 2(i-1),新 i 个元素 ⇒ Φ(i) = 2i − 2(i-1) = 2. ΔΦ = 2 − (i-2) = 4 − i.

摊还 = i + (4 − i) = 4. 这跟上面普通情形的 3 不匹配,但仍为常数。所以两种情形都是常数 ⇒ 每次插入摊还 O(1),比例常数 4.

note

在势能法里,定义一个新势能函数的"调参"环节通常要试 2-3 次。一旦找到合适的势能函数,结果立刻便宜十倍。这是数学工程的味道。

经典案例速查 + 直觉

结构实际最坏摊还势能常见定义
动态数组 pushΘ(n)Θ(1)2i − cap
二进制计数器 +1Θ(k) bit 翻Θ(1)#1-bits
Splay 树操作Θ(n)Θ(log n)Σ log(size(v))
并查集 find(路径压缩 + 按秩)Θ(α(n)) 极慢反例实际 ~4 ops联集势能(计分函数)
哈希表 rehashΘ(n)Θ(1)装填因子的惩罚

二进制计数器是一个非常直观的势能法例子:n 次 +1 中,最低位每两次翻一次(n/2 次),第二位每四次(n/4 次)……总位数翻转 = n/2 + n/4 + … = O(n) ⇒ 每次摊还 O(1). 你甚至能看到"低位翻得多、高位翻得少"的频次,这就是势能法的心脏直觉。

Splay 树:势能法处理的关键案例

Splay 树没有显式平衡。当访问一个节点 x 时,会通过 zig/zig-zig/zig-zag 把它旋转到根。单次最坏 O(n)。但势能法证明:

定义 Φ(D) = Σ_v log(size(v)),并证明任何操作摊还 O(log n). 核心是 zig/zig-zig 的几何效应:每次 splay 操作都会把访问路径上"重子树 → 轻子树"翻转一次,这种翻转产生势能释放直接抵掉操作的代价。

工程含义:访问过的 key 会被旋转到根,采样命中率高时变成"自适应 cache" —— 这就是 Splay 在 Windows 内核里被 Linux CFS 替代前用过一阵的原因。

并查集摊还 = 反阿克曼

只要按秩合并(rank merge)+ 路径压缩,第 n 次 find 的摊还 = O(α(n)),其中 α 是 逆 Ackermann 函数——n ≤ 10⁸⁰ 时 α(n) ≤ 4。实际操作几乎常数。

证明思路非常深(Tarjan 1975),需要:

  • 把每个节点按 rank 分块;
  • 在每块内分析 find "跨块"的次数;
  • 推跨块的多项式级数。

要在工程里读这个证明很累,但结论一句话:并查集的每次操作 ≈ 4 条指令的真实代价——比平衡树 find 小得多。

工程视角:摊还分析的两个不适用区

  1. 硬实时系统。摊还 O(1) 的意思是"长序列平均 O(1)",并不保证每次都 O(1). 反例:动态数组在第 2^23 个 push 触发扩容,最坏延迟可能数 ms(如果页耗尽需要 syscall 触发 mmap/munmap —— 让 hypervisor 介入甚至给物理页做 numa 分配),HFT/游戏 tick 容不下这种尖刺。

    解法:

    • 启动时 reserve(max)
    • 或换固定容量的 ring buffer;
    • 或换 incremental rehash(dictugador 风格,把扩容代价分摊到多次 op)。
  2. 失败重试场景。如果 N 次 push 里中间某次扩容由于 OOM 而失败,整个数据结构状态可能不一致了。这是 C++ std::vector::push_back 强 EH 保证要求的根源:扩容用临时节点 + commit,不破坏原数据。

这一章带走的东西

  • 平均 vs 摊还 vs 最坏三个量级,和实际系统行为不能直接对调
  • 势能法是"工程化"的摊还分析,学会就解锁了一切摊还结构
  • 动态数组的常数是 3,不是抽象的 1;
  • Splay 树、并查集、rehash、二进制计数器都靠"势能爆发"在 O(1) 里隐藏 O(n) 的烧火;
  • 当心两种工程坑:硬实时系统的尖刺、失败回滚下的结构一致性。

下一节 → 实战:复杂度反推设计:把 O(·) 从理论用到工程约束反推。

实战:复杂度反推设计

一句话

给你一组真实约束——数据量、QPS、延迟、内存预算——你能不能反推出"在这个工程里只能用哪种复杂度的算法,然后再筛掉哪些算法,最后只剩一两个候选"。这是工程现场最高频的"看一眼就知道用什么"的反射能力。复杂度分析到这一步才真正进入工程实践。

经验阈值表(单机、~1s 内可跑)

把这张表刻在脑子里。看到 n,第一反应是这张表

n 量级可接受复杂度上限工程直觉
≤ 10任意(甚至 O(n!))全枚举、回溯都能跑
≤ 20O(2ⁿ)状压 DP 的典型规模
≤ 100O(n³)三重循环 / Floyd-Warshall
≤ 1000O(n²)嵌套循环、朴素图算法
≤ 10⁴O(n²) 紧别再上 n²,准备 n log n
≤ 10⁵O(n log n)排序+二分/堆/线段树回退
≤ 10⁶O(n log n) 勉强,O(n) 稳越接近 O(n) 越稳
≥ 10⁷O(n) 上限单机已接近物理极限

tip

这张表的基础是 ~10⁸ ops/sec——现代 CPU 单核的真实工作速率(cache 友好 + 100% 利用率),不是 GHz 标称的 ~10⁹ cycle/sec。~10⁸ 是把"内存墙 + 分支预测 + SIMD 利用率"全算进去后的保守数.

完整反推流程

1. 拿到约束:n / QPS / 延迟 / 内存
2. 用阈值表推断 ≥ 必须的复杂度上限
3. 列出所有"算法 + 数据结构"组合覆盖该上限
4. 逐项筛:常数因子、cache 友好性、可并行性、编码复杂度
5. 给候选实现,跑 benchmark
6. 不达标 → 回到 3 + 加抽象(换 OS 策略、上 SIMD、上 GPU、上 FPGA)

这是"算法 → OS → 硬件"的反推链。第 4 步开始往硬件层探,到第 6 步可能就跳到 GPU/FPGA 重新做抽象。

案例 1:亿条日志中找 Top K 高频词

约束:百亿条日志 × 200 字节 ≈ 2 TB;要求 1s 内出结果。

粗扫

2 TB / 单机 SSD 顺序读 3 GB/s = ~700 s  ⇒ 单机做不完
1 s ⇒ 总数据吞吐至少 2 TB/s ⇒ 必须 sharding

走分布式

每台机器分到 N/M 份,每份做局部 Top K,再用 reduce phase 合并:

  • 单机局部:堆 + 哈希表 → 复杂度 O(N/M · log K).
  • reduce:合并 M 份小 Top K,再做一次 O(M · log K) 排序.

工程注意:

  1. 堆 + 哈希表 而不是 sort:sort 会 O((N/M) log (N/M)),慢一截;
  2. 推荐用 Count-Min Sketch 一次 sweep 取近似 Top K,准确度可接受且 O(N/M);
  3. mapreduce 框架(Hadoop)默认走 sort,open 算法定制时要 override。

到这里反推就跳出了"选什么数据结构"——已经跳到分布式架构(map/reduce)和应用模式(sketch 替代精确)。

案例 2:实时字符串匹配、模式集上万

约束:n ≈ 10⁷ 流式文本、模式集 P ≈ 10⁴、每个模式 ≤ 30 字符、单机、~100 ms / MB.

朴素怀疑 KMP per pattern

O(P · n) = 10⁴ × 10⁷ = 10¹¹ ops ⇒ 不可能 100 ms 跑完.

反推让"沿文本扫描一次" ⇒ Aho-Corasick

复杂度 O(n + Σ|P| + hits):建 Trie 失败链 ~O(Σ|P|) 一次性预编译,文本扫一次。

工程常量角度看:

  • 顺序扫文本,cache miss 极少;
  • Trie 节点 ~字节级别数组(256 槽),cache 容易闪;
  • Java/Go 等都能轻松跑出 1 GB/s 的 AC 扫描率.

结论:选 AC 不是品味,是反推决定的唯一答案.

案例 3:10⁵ QPS 的 LRU 缓存

约束:单次操作 ≤ 10 μs;CPU 单核 ~3 GHz.

10 μs = 30_000 cycles,但每次 cache miss 就 ~200 cycle,10 次 cache miss 就用掉 2 μs. 缓存结构的 ops 数必须压到 30-50 ops,这才是真正可行上限.

候选

方案复杂度实际 ops难点
哈希 + 双链O(1) 摊还~50内存碎片 + 分配
哈希 + 数组 + 世代O(1)~30 但 cap 小适配小上限
Cuckoo hashingO(1) 摊还~30-100rehash 失败处理

工程层的反推

代码上看是 map[k]*Node,但真要做 10⁵ QPS 必须:

  1. 预分配内存池:避免 runtime.malloc 在每 op 里触发;
  2. sharded map:单 map 在高并发下中心化锁(Go runtime 的 hash growth 用全局锁),分 16-64 片即可;
  3. lock-free:考虑用 sync.Map(读多写少)或 craq / lfds 这类 lock-free map;
  4. 不写 undo/redo 日志:内存里 LRU 单次操作不要 WAL,否则延迟穿 latency 上线。

到这里,反推已经从"算法选型"跳到"并发同步策略"

案例 4:分布式严格递增序号

约束:10⁶ req/s、全局严格递增、可用性要求 99.99%.

  • 用 Redis counter:O(1) 但单点瓶颈 ~10⁵/s,挂;
  • 用 atomic single-machine:单机 10⁶/s OK,但全局不行;
  • 用雪花算法(Snowflake):timestamp(41bit) | worker_id(10bit) | seq(12bit),每台机器 O(1) 生成、全局递增;
  • 但雪花不保证严格连续,gap 在时钟碰撞或 worker restart 时会出现.

这就是为什么 10⁶ req/s 真实场景都基本用雪花替代品——并接受小概率 gap。复杂度不是瓶颈,争用+同步才是

案例反推到硬件

考虑一个"每秒 10 GB 视频帧,把每帧 RGB→YUV420 并下采样"的负载:

  • 朴素 C 实现循环:~5 GB/s, 还差一半;
  • 改 SIMD (AVX2-pmulhrsw + packuswb):~30 GB/s;
  • 上 GPU (nvjpeg):~5-10 TB/s;
  • 上 FPGA (ISP pipeline):可直接 streaming 一帧 ~16 ms 不爆 buffer.

你会发现:每往硬件层走一层,抽象相同(RGB → YUV 的逻辑没变),但实现常数提高几个数量级。这是"软件 → 硬件"反推的真实路径:当你纯软件层的常数因子打不进 SLA时,下一步不是优化代码而是换硬件抽象层.

反推陷阱清单

  1. 隐藏 O(n²):循环里隐藏了 string 拼接 "a" + "b" + "c",每次拷贝全串 ⇒ O(n²). 换 StringBuilder / bytes.Buffer / fmt.Fprintf.
  2. map 迭代 "看起来 O(1)":实际 O(n).
  3. 哈希 rehash 尖刺:长尾敏感场景要 reserve 或换 incremental rehash.
  4. 递归深度:Python 默认 1000;Go 1.18+ 默认 1GB 栈,但深度递归仍有 GC + stack copy 代价. 对超深递归改迭代版.
  5. τ-抖动 vs 平均延迟:摊还 O(1) 也救不了硬实时.

练习

  1. 给 10 TB 文本找出 top-10 高频词,单机 SSD,1 小时预算。给方案。
  2. 你接到一个 10⁶ QPS 的 LRU 缓存需求,硬盘只能扛住 10³ QPS;反推架构(一致性/分区/降级)。
  3. 给一段简单的 Python 代码,找出它的复杂度并改造:
    def process(items):
        result = []
        for x in items:
            if x not in result:
                result.append(x)
        return result
    
    • set 而不是 list 做"重复检查": O(n²) → O(n).
    • 但如果"items 含不可哈希对象"必须保留 O(n²) —— 选型仍要看语义.

这一章带走的东西

  • 看到规模第一反应是阈值表;
  • 算法复杂度只解决"形状",工程真实约束推你进 OS/硬件层;
  • 反推的尽头往往不是"换个数据结构",而是换抽象层——SIMD/GPU/FPGA;
  • 摊还 O(1) 在硬实时下不是 O(1),平均 ≠ 最坏.

下一章 → 基本数据结构

基本数据结构

四个"看起来谁都会"的家伙。但实际上,它们是你 90% 工程性能问题的胜负手。

warning

别把"基本"当成"浅"。动态数组的扩容为什么是 2 倍、map 的渐进 mortality、为什么年度旗舰 Go 比 std::vector 慢十倍、为什么 std::list 几乎从大型项目绝迹——这些问题的答案都需要把"CPU 形态 + 运行时内存模型 + 算法复杂度"三层同时拉起来。

这一节的目标

四个章节走完,你应当具备:

  • 一段代码看一眼能说出"这步在硬件上是 L1 / L2 / L3 / DRAM 哪一档";
  • 一道需求拿到手能给出"用 vector 还是 list,为什么",不再凭语感;
  • 会设计一个针对 cache line 大小的字节紧排结构,理解 padding 抢哪几个字节;
  • 会查 defrag 在 STL/Java/CPython 里为什么不自动做的来由。

子章节

数组与动态数组

一句话

数组是几乎所有数据结构的"底座"——你今天在工程里碰到的链表、树、堆、图,理论上可以换成数组下标实现,因为数组下标和指针在 CPU 层面是一回事:连续内存 + 偏移量。理解了这层,再去看 B+ 树用 page 数组、堆用层号编址、并查集用 parent[] 数组,会一眼通透。

静态数组的物理形态

静态数组 T a[n] 在内存里就是一段连续 n · sizeof(T) 字节。读 a[i] 编译成两步:

addr  = a + i * sizeof(T)        // 算地址
value = load(addr)              // 从那个地址读

第一步是单条 ADD 指令,第二步是单条 load 指令。也就是说索引访问是 1 次加法 + 1 次内存读。这俩加起来在很多 CPU 上还不到 1 纳秒。这就是为什么大家说"数组是 O(1) 访问"——这里的 O(1) 常数项极小,小到比绝大多数哈希的真实常数还小。

但 O(1) 这四个字容易骗人。你做下面这个实验:

// 1024 x 1024 int 矩阵
int m[1024][1024];

// 模式 A:按行扫
for (int i = 0; i < 1024; i++)
    for (int j = 0; j < 1024; j++) sum += m[i][j];

// 模式 B:按列扫
for (int j = 0; j < 1024; j++)
    for (int i = 0; i < 1024; i++) sum += m[i][j];

两者都是 O(n²),编译器看到的两层 for 几乎一模一样。但在我手边的机器上跑:模式 A ≈ 0.4 ms,模式 B ≈ 4 ms。差 10 倍

原因:现代 CPU 不是"按字节读内存",而是按 cache line(通常 64 字节,恰好等于 16 个 int)整块载入 L1;预取器还会主动预读接下来用到的连续地址。模式 A 顺着 cache line 走,16 次加法共享一次内存读;模式 B 每次跨到下一行,cache 老是 miss。

note

严格说,O(1) 只回答"乘积数轴上的极限速率"。而真实世界的 CPU 内嵌了内存层级——L1 (~1 ns) / L2 (~4 ns) / L3 (~12 ns) / DRAM (~80 ns),跨度 80 倍。O(1) 里的那个常数,在 cache 友好和 cache 不友好之间能差两个数量级。这是后文一切"为什么 std::vector 比链表快得多"的根。

所以记住第一条工程铁律:当一个数组方案和一个"看起来更高级"的方案复杂度都是 O(n) 时,先选数组方案——它给出的 O(n) 几乎总是更小的 O(n)。

动态数组:你天天在用,但从没注意过扩容为什么是倍增

静态数组有个硬约束:容量固定。但工程里几乎每次都"先放进去几个,再加几个,再加几个"。需要的是:能动态增长、又保留数组连续内存 + O(1) 索引的结构。这就是 vector / ArrayList / slice / list

实现思路不复杂:

type动态数组 = (指针 ptr, 当前长度 len, 容量 cap)
push(x):
    if len == cap:        # 满了
        新数组 = malloc(cap * 因子)     # 申请一块更大的
        拷贝旧内容到新数组
        free(旧数组)
        ptr = 新数组
        cap *= 因子
    ptr[len] = x
    len++

关键就是这一行:cap * 因子因子到底该取多少?

论证 1:选 2 倍

把"扩容"看成一个时间点,触发频率随 n 几何递减:

第 1 次扩容:cap 1 → 2,移动 1 个
第 2 次扩容:cap 2 → 4,移动 2 个
第 3 次扩容:cap 4 → 8,移动 4 个
第 k 次扩容:移动 2^(k-1) 个

n 次 push 触发的累计移动量 = 1 + 2 + 4 + … + 2^k,其中 2^k ≤ n。等比数列求和是 2n - 1。加上每次 push 本身的 1 次写入,总开销 ≤ 3n

每次 push 的摊还开销是 3 次内存操作 = O(1)。

不光是 O(1),而且常数是 3。这就是为什么倍增扩容在工业里被默许。

论证 2:那为什么不选 1.5 倍?

倍增有个不爽的地方:内存利用率最坏 50%。比如刚扩容到 cap=2n,里面只有 n+1 个元素时,浪费了 n-1 个槽。Google 的 Java 工程师观察到一个细节:Go 1.18+ 的 resize 策略实际上对小切片用 2 倍,对大切片用 1.25 倍附近——理由是想让旧内存有机会被回收后又被复用

你可以做一个思想实验:从空数组开始连续扩容,看 cap 序列:

因子cap 序列
2.01, 2, 4, 8, 16, 32, 64, …
1.51, 2, 3, 4, 6, 9, 13, 19, 28, 42, 63, …

注意 1.5 倍增长几步后,早期某个 cap 值恰好等于下一个 cap 值的 descent

2 → 3 → 4 → 6 → 9 → 13 → 19 → 28 → 42 → 63
              (42 ≈ 28 + 19 ← 实际 jemalloc/free 能合并)

实践中,1.5 倍增长在 glibc/jemalloc/tcmalloc 之类带"位桶(size class)"的 allocator 下更容易让旧块被合并并复用——而 2 倍增长每次新的 cap 永远比之前所有 cap 大,旧块永远小不了、合不掉。

论证 3:那为什么不更小甚至固定 +1?

固定 +K(每次加 K)扩容,单次扩容仍是 O(n) 但触发的密度没有几何递减:

  • 第 n 次 push 之后累计拷贝 = O(n² / K)
  • 摊还到每次 push 是 O(n)。

n 大点就挂。这就是为什么没人在标准库里用线性扩容。

warning

摊还分析关心的是序列总和的上界。如果你的系统有硬实时约束(HFT、音视频主线程、游戏 server tick),单次最坏延迟仍然是 O(n)。这种场合必须预分配容量,把扩容搬到 startup / 调度点。

收缩为什么少见

动态数组默认增不缩。为什么?

考虑一段代码反复 push(x); pop()

  • 每次 push 触发"满了就扩";
  • 每次 pop 不做收缩(只 len--);
  • 这种来回不会触发拷贝,摊还正常。

但如果你加"删除时缩容":

pop():
    len--
    if len < cap / 4:
        容量减半

那么假如 push; pop; push; pop; ...len 在阈值(cap/4cap/2)间反复横跳,每两次操作就要拷贝一次,摊还直接恶化到 O(n)——这就是"抖动"。

所以工业实现要么完全不自动缩(Rust Vec),要么显式 API 让你调用(Java ArrayList.trimToSize, Go runtime.GC 配合切片切小并重新分配)。

tip

一句话:扩容幂等安全、缩容有抖动风险。如果你的程序"短期占用大、长期不用大",记得显式 shrink_to_fit 或重新切片一次。

Go 切片的别名陷阱

Go 里写 s = append(s, x) 而不是 s.append(x),一直以来是新人吐槽点。这背后的设计其实是被 Go 强制的,目的是让 slice 操作没有副作用

s := []int{1, 2, 3}    // cap=3, ptr=ArrayA
t := s                 // t 和 s 共享 ptr=ArrayA
s = append(s, 4)       // 没扩容,直接写 ArrayA[3],触发 panic —— 这里你应该没意识到

不对——上面 append 之前 cap=3 已经满了,所以 s 会扩容、走新数组,t 仍然指向 ArrayA,两者从此分家。看起来 slice 就是 (ptr, len, cap) 三元组、append 是"返回新三元组",这是值语义——但只要没扩容,append 就就地写了 ArrayA,所有共享 ptr 的 slice 都看得到。

s := make([]int, 3, 5)   // len=3 cap=5
t := s                   // 同享 ArrayA
s = append(s, 4)         // 没扩容,写入 ArrayA[3]
fmt.Println(t[3])        // ??? 实际为 0 (t 的 len=3 看不到下标 3)
fmt.Println(t[:4][3])    // 4  (用 [:4] 扩界限就能看到)

所以 slice 的别名语义是:

  1. 共享底层 array,slice 之间是别名;
  2. 看不看得到,由 (len) 决定;
  3. append 触发扩容,则别名断了;
  4. 切片操作 s[i:j] 不拷贝内存,新 slice 共享底层。

这就是为什么 Go 工程里反复强调"slice 传给函数要小心",尤其涉及 append 时,要么传指针 *[]T、要么显式 return。从 errorsbytes.Buffer 都受这个微妙约束影响。

Python list 究竟是不是"动态数组"

CPython 实现里,PyListObject 内部就是一个 PyObject** 指针 + ob_size + allocated:

struct PyListObject {
    PyObject **ob_item;   // 指针数组,每个元素是 PyObject*
    Py_ssize_t ob_size;
    Py_ssize_t allocated;
};

注意一个细节:它存的是指向 PyObject 的指针,不是 PyObject 本身。

所以 Python list 比真正的 vector<int> 多一重 indirection:

  • lst[i] 在底层是 ob_item[i](一个 PyObject** 取下标);
  • 然后再 * 一下得到的 PyObject*

这意味着 Python 一次索引访问要走两次内存读。再加上 PyObject 本身有引用计数、GC,所以CP 的 list 在小数据上比 C vector 慢 20-50 倍很正常。

扩容策略 CPython 用的是 new_allocated = (size >> 3) + 3 + size,也就是大约 1.125 倍——比 Go/Java 慢慢递增,但因为指针拷贝本来就便宜,常数影响不大。

多语言对齐

// Go
a := make([]int, 0, 16)   // 预分配 cap=16
a = append(a, 1, 2, 3)
a = append(a, 4)
// TypeScript / V8
const a: number[] = [];
a.push(1, 2, 3);
// V8 的 Array 是哈希 + 元素类型可变的 Smi/HeapNumber 数组:
// 连续小整数时 backing store 整块推进;
// 出现 double 或 object 时整体降级到指针数组。
# Python
a = [1, 2, 3]              # list
# 推荐先用 list(range(n)) 占位,再按下标写:
a = [0] * n
// C++
std::vector<int> v;
v.reserve(16);             // 预分配 cap 不改 len
v.push_back(1);
v.shrink_to_fit();         // 显式回收多余容量

预分配是个关键技巧:在你知道队列或图像的近似规模时,预先 reserve 能把扩容摊还常数从 3 压到接近 1,还可避免例如 trace 期间大量碎片。生产服务高 QPS 场景里,reserve("capacity") 经常是把一个 P99 延迟从 2 ms 拉到 0.5 ms 的功臣。

易错清单

  1. Vec::with_capacity(n) vs vec![0; n]:前者 len=0 容量 n(深坑:未初始化,直接读是 UB);后者 len=n 且都已 0。

  2. C++ std::vector 容量增长:libstdc++ 是 2 倍,libc++ 也是 2 倍;MSVC 曾 1.5 倍。

  3. end iterator 失效:扩容后所有指针、引用、迭代器都失效。在循环 for (auto it = v.begin(); it != v.end(); ++it) v.push_back(*it); 里 push 会让 end()/it 立即过时 ⇒ UB。

  4. 2D 矩阵方向:c/c++ 是 row-major,Fortran/NumPy 中默认列优先实际还是 row-major(看你 contiguous 风格而定,BLAS 层面 col-major 为主)。遍历方向必须匹配存储方向

  5. 大对象值语义:Rust Vec<BigStruct> expand 会逐个 move 整块大对象,如果大对象复制开销 > 16 字节,用 Vec<Box<BigStruct>> 堆指针代替。

  6. strings.Builder / bytes.Buffer:在字符串频繁拼接时不用 a += "x",因为 Go/Java 等里 + 每次会构造新 String,总开销 O(n²)bytes.Buffer/strings.Builder 维护 dynamic array(byte slice)。

经典题走题路线

按这个顺序过两遍,对数组的工程理解会从"会背扩容公式"变成"看得见 cache":

  1. LC 27 移除元素:经典双指针,val 把右边写左边。
  2. LC 26 删除有序数组中的重复项:同上风格,再加 hash-avoid 的技巧。
  3. LC 11 盛最多水的容器:贪心收缩两端的经典。
  4. LC 209 长度最小的子数组:滑动窗口入门。
  5. LC 238 除自身以外数组的乘积:双 prefix 之积,O(n) 不用除法。
  6. LC 54 螺旋矩阵:方向数组。
  7. LC 31 下一个排列:原地翻转模板。
  8. LC 41 缺失的第一个正数:原地 hash 把数组本身当 mark。
  9. LC 289 生命游戏:状态位编码。

这一章带走的东西

  • **数组的 O(1) 是常数最小的 O(1);
  • 动态数组倍增是摊还 O(1),常数 3;
  • 缩容几乎不自动做,因为有抖动风险;
  • Go slice 是值语义共享底层,append 可断别名;
  • 真正决定性能的是 cache locality,遍历方向错了能差 10 倍。

下一节 → 链表 :我们会发现链表"理论上"的 O(1) 插入在实际中常常输给 vector,原因正是 cache。

链表:单链 / 双链 / 跳表

一句话

链表的真正优势不是 O(1) 插入——那是教科书骗人把戏,在已知位置前提下成立——而是 不需要连续内存任意位置 O(1) splice 两段迭代器稳定性。代价是:cache miss、分配开销大、并发更难写好。这也是为什么大型工业项目里 std::list 几乎绝迹,而 Linux 内核、Redis 仍然靠"侵入式链表" achieving 严格 O(1) splice。它是个关键工具,但不是新手想象的那个工具。

"O(1) 插入"为什么不真实

教科书说"链表 O(1) 插入,数组 O(n) 插入"。这有个隐藏前提:指针 p 已经在你手里.

如果不在你手里呢?

  • "在第 i 个位置之后插入" ⇒ 你得先走 i 步 ⇒ 链表是 O(i),数组是 O(1)(按下标)。
  • "插入值 x 到有序链表对应位置" ⇒ 链表 O(n),数组(用二分 + 移位)也 O(n),但常数数组更小.

所以单纯"链表 vs 数组插入"看不出来,只有"已知指针位置 vs 索引位置"能比较。链表的 O(1) 插入只在下面这些真实场景里成立:

  1. 已知节点指针 + 删除:Linux 内核里的 task list、Redis list、调度队列;
  2. 步骤迭代 + 中间穿插:解析器 AST 改造、git commit tree 增删;
  3. 迭代器稳定性:STL std::list::iterator 在 erase 后仍然对其他节点有效,这点 std::vector 给不出。

数据形态:单链、双链、哨兵

单链

HEAD → a → b → c → ∅

最小空间:每节点 1 个指针。劣势:不能 O(1) 删任意节点——找不到 prev.

双链

∅ ⇄ a ⇄ b ⇄ c ⇄ ∅

每节点 2 个指针,则可以 O(1) 删。代价:内存大 + cache 更不友好.

哨兵(Sentinel)

让一个永远存在的"伪头节点"做 HEAD,HEAD.prev 指向尾:

HEAD ⇄ a ⇄ b ⇄ c ⇄ HEAD   (闭环)

极大好处:消掉判头 / 判尾分支list.remove(x) 不再写 if (prev == null) head = ... 这种代码,所有节点都集中表达成「在 prev 和 next 之间做某种四指针链接」。

这是工程化的态度:能用一个不存数据的伪节点换来的代码简洁度,是无价的。Redis、Linux、FreeBSD 都大量用这种写法,对照看 std::list 的源码,你会发现它们也是。

侵入式链表(Intrusive)

Linux 内核、Boost.intrusive、Rust intrusive-collections 都用"侵入式链表":链表节点字段嵌入到对象本身,而不是 List<T> 内部存储 Node { T data; ptr next; }.

struct task_struct {
    ...
    struct list_head run_list;   // 这个字段本身是个节点
    ...
};

好处:

  • 同一对象可以挂在多个链表上(多 list_head 字段);
  • 不需要一个 box 包装;
  • splice 是 O(1) 且零分配;
  • 删除一个已知节点是 O(1) — 只看节点,不需要 list 句柄.

代价:类型耦合、生命周期复杂。但收益巨大。

cache:为什么链表在工程里"理论快但实际慢"

每一个 node 是一次 malloc:64 字节 cache line 上常常只放得下"一个节点 + 部分相邻节点". 跨节点遍历几乎都是 cache miss.

实测对一百万对象做一次顺序遍历求和:

结构时间备注
std::vector<int>0.3 mscache hit, SIMD 友好
std::list<int> (libstdc++)6 mscache miss fan
intrusive_list<int> Linux 风格8 ms仍 cache miss
std::vector<std::unique_ptr<int>>9 ms类似 linked

差 20 倍。这就是为什么工程里链表已不再被默认选用.

那 std::list 什么时候确有用:

  1. 大小未知 + 多次 splice:你想把 B 段代码节直接挂到 A 段尾,且不在意单点删除复杂度.
  2. 迭代器稳定性需求:插入/删除不会让其他迭代器失效.
  3. 不希望重新分配:链表 append 从不触发扩容(GC 类语言里 hash 表 rehash 时刻关键路径上的稳定延迟).

否则,std::vector(std::vector::iterator) 加 swap-with-pop idiom 基本全胜.

多语言实现 & 语义差异

C++:std::list

值语义,迭代器稳定。list.splice 是 O(1). 必须用基本情况特殊选择——95% 业务代码用 vector.

Go:container/list

泛型友好但 wrapper 模型:

import "container/list"
l := list.New()
e := l.PushBack(1)
l.InsertAfter(2, e)
l.Remove(e)

每个元素都被包成 *Element,interface{} 装内容. 性能不太好——通常用切片或自己手写侵入式.

Python: collections.deque

不是 list 也不是真的 deque-only. 实际 CPython 实现 = deque分块链表 — 每个 chunk 64 个 PyObject*, 跨越 cache line 但每块内部连续. 设计目的是让两端 push/pop 都是 O(1) 摊还,且避免单点 malloc.

Java: LinkedList vs ArrayDeque

LinkedList 实现了 Deque 但常数大; ArrayDeque 用环形 backing array 性能远好. 官方文档已建议优先 ArrayDeque.

跳表:把"约定俗成平衡树"用链表实现

为什么有跳表?平衡树实现复杂、多线程下加锁痛苦. 跳表通过多级有序链表 + 几何概率分布得到了"等效平衡树"的复杂度,代码更短、并发友好.

结构

Level 3:  HEAD ────────────────────────── 8 ───── ∅
Level 2:  HEAD ────── 4 ───────── 6 ──── 8 ───── ∅
Level 1:  HEAD ── 2 ── 4 ── 5 ── 6 ── 7 ── 8 ───── ∅
Level 0:  HEAD ── 2 ── 3 ── 4 ── 5 ── 6 ── 7 ── 8 ── 9 ── ∅

查找从最稀疏的 level 起,每层 O(log n) 个节点被跳过(平均).

复杂度

操作平均最坏
searchO(log n)O(n)(罕见)
insertO(log n)O(n)
deleteO(log n)O(n)

插入时高度如何选

每次插入按 p=1/2 几何分布向上提升——抛硬币直到反面停止. 期望高度 log₂ n. 高度 cap 取 ceil(log₂ N) 安全防抖.

真实世界使用

  • Redis ZSet:API 简单,并发好;
  • LevelDB / RocksDB memtable 默认跳表;
  • Java ConcurrentSkipListMap / ConcurrentSkipListSet.

跳表在工程界能赢的主要点是并发:插入时只锁住的局部(基本那个 node + 几个上游指针),不需要平衡树的全树旋转加锁。

哨兵双链模板(Go)

type Node struct {
    val      int
    prev, next *Node
}

type List struct {
    head Node // 哨兵
}

func New() *List {
    l := &List{}
    l.head.prev = &l.head
    l.head.next = &l.head
    return l
}

// 在 t 之前插入 n(t 可以为 &head,意为尾插)
func (l *List) insertBefore(t, n *Node) {
    n.prev = t.prev
    n.next = t
    t.prev.next = n
    t.prev = n
}

func (l *List) Remove(n *Node) {
    n.prev.next = n.next
    n.next.prev = n.prev
    n.prev, n.next = nil, nil
}

哨兵的好处不是"减少一个 if"——这点性能微到不值得——而是让不变量在所有代码路径上一致. 不变量一致性 ⇒ bug 少。

错误清单

  1. 删除时只动一边:p->next = q->next 忘了 q->next->prev = p.
  2. swap 临时变量取错:先改 p->next 后再 q->prev,结果 p->next 已变 ⇒ 隐式失败.
  3. delete 后仍引用(use-after-free).
  4. 跳表层高没 cap:高负载下出现 O(n) 抖动;限到 ceil(log2 N) 即可.
  5. 双链哨兵自指:初始化 head.prev = head.next = head 不一致 ⇒ ID 死循环.

经典题

  • LC 206 反转链表:递归 vs 迭代都自己手写一遍;
  • LC 92 反转链表 II:范围反转;
  • LC 142 环形链表 II:快慢指针定位入口;
  • LC 25 K 个一组反转链表;
  • LC 146 LRU 缓存:哈希 + 双链 = 工程神型;
  • LC 460 LFU 缓存:堆 + 双链 + 频次门限.

这一章带走的东西

  • 链表的 O(1) 插入只在指针已知时成立;
  • 工程上 std::list 几乎绝迹,但侵入式链表在内核里仍是关键;
  • cache miss 让链表在常规工程里输 vector 20 倍;
  • 跳表 = 用随机化把平衡树"分而治之"代码化更短的另一选择;
  • 哨兵 = 让不变量一致的工具,不是"省一个 if".

下一节 → 栈与队列

栈与队列

一句话

栈和队列不是数据结构——它们是两种思维工具:一种是「我能撤回的」(DFS、调用栈、撤销),一种是「我要按序的」(BFS、调度、流水线)。具体用什么底层实现(数组、链表、ring buffer)是 SECONDARY 的选择,但思维模型一旦匹配错,代码再漂亮也错.

栈的五种典型用法

1. 括号匹配 / 表达式求值

最直接的栈用法:进栈遇 ) 反弹。

2. DFS

递归本质 = 系统栈。深搜大图改迭代版必出"显式栈",这步也是后文石墨遍历图论的直接半步。

3. 撤销撤销栈

文本编辑器、数据库 undo log、git reflog 都是栈。每次操作 push 一份 inverse op,撤销就 pop.

4. 单调栈(monotonic stack)

最难、最经常被忽略的一种栈. 求每个元素的「右边第一个更大元素」:

def next_greater(arr):
    stack = []          # 严格递减
    res  = [-1] * len(arr)
    for i, x in enumerate(arr):
        while stack and arr[stack[-1]] < x:
            res[stack.pop()] = i
        stack.append(i)
    return res

O(n):每个元素入栈、出栈各一次。

为什么这个模板在哪里反复出现?因为未来时刻一旦看到更大的新元素就立马让旧元素失能,符合这种处理类型的题("右边第一个更大 / 更小 / 上一个更优 / 比我小但更早")全部可以套单调栈. 柱状图最大矩形、接雨水、Daily Temperature、最小子序列统统其变体.

5. 调用栈本身

所有递归都可以机械化为带显式栈的迭代.

队列的三种现实形态

1. FIFO 队列

调度、BFS。但有一个关键不变量:BFS 保证第一次到达某点的距离是最短路跳数。这是 BFS 第一个被记住的"模型性质"——很多算法要靠它.

2. 双端队列(Deque)

  • 工作窃取队列(work-stealing):fork-join 框架底;
  • 滑窗最大值(sub-window max);
  • 缓冲区切片切分(操作系统 page cache LRU 实现).

3. Ring buffer

环形数组,固定容量,O(1) 入队出队、零分配。生产者-消费者杀手锏.

环状缓冲:把它写到硬件层

朴素实现(带 count 字段):

typedef struct { int buf[N]; int head, tail, size; } Ring;

int push(Ring *r, int x) {
    if (r->size == N) return -1;
    r->buf[r->tail] = x;
    r->tail = (r->tail + 1) % N;
    r->size++;
    return 0;
}
int pop(Ring *r) {
    if (r->size == 0) return -1;
    int x = r->buf[r->head];
    r->head = (r->head + 1) % N;
    r->size--;
    return x;
}

lock-free ring 通常使用一个 slot 浪费的版本:

typedef struct { int buf[N]; int head, tail; } RingLF;
bool isEmpty(RingLF *r) { return r->head == r->tail; }
bool isFull (RingLF *r) { return (r->tail+1) % N == r->head; }

为什么这个版本更受欢迎?因为没有共享变量 size ——多线程下,size++-- 需要 atomic CAS 或锁,而"读 head/tail" 与"读自己控制的 idx"只在各自一侧,生产者只改 tail,消费者只改 head,跨线程的读取就是 atomic load,写就是 atomic store. 这种特性让生产者 / 消费者天然可以独立推进,对应到内存屏障使用也少.

FPGA 视角下的 ring buffer

在 FPGA 上 ring buffer 用 BRAM 实现,head/tail 是寄存器,buf 读写是同步访问位宽任选. 没有锁,写读可同时身后者 ⇒ 单 cycle 同时入队/出队. 旯 FPGA 上的端云存储、雷达信号处理、网络数据包 buffer 都是这一模式.

这就是「软件 lock-free ring」与「FPGA 数据缓存」在抽象层是同一种东西.

双端队列 + 滑窗最大值

from collections import deque
def maxs(a, k):
    dq, res = deque(), []
    for i, x in enumerate(a):
        while dq and a[dq[-1]] <= x: dq.pop()
        dq.append(i)
        if dq[0] == i - k: dq.popleft()
        if i >= k - 1: res.append(a[dq[0]])
    return res

思路有 "战阶" 意味:维持单调递减索引序列,每次入窗让"过时"的一端被弹掉。指针不需要扔掉:所有"新值一进来,比它小的旧值就再不可能成为答案"。

在硬件层中,类似的"递减 FIFO" 是 DSP 中的 median 滤波器:

  • 雷达信号处理时把滑动窗作为 SP 阶户;
  • 视频流的滑动剔除(live video late frame drop 比当前更好用一个关于同等非线性中位数脉冲 tvHD filter).

单调队列、median filter、Sliding Window Aggregator 全部是同一抽象.

实现选型

栈/队列可以用动态数组实现,也可以用链表。99% 时候应该用数组/deque

类型推荐
std::stack / std::queue默认是 std::deque,正确选择
Go自己循环数组或用 container/list 通用版
JavaArrayDeque 优于 LinkedList(已 by JDK 文档警示)
Pythonlist 作栈;deque 作队列 / 双端
TSArray<T> 作栈;自实现 ring 当队列(性能敏感)

多线程下的 popping 易错点

// 朴素栈不能并发 pop
T pop() { 
    if (stack.empty()) return {};   // 数据竞争
    T x = stack.top(); stack.pop();  // 这俩没原子性
    return x; 
}

写入/弹出之间有 TOCTOU 窗口——两个线程都能进 if 通过判断,但其中一个 pop 后另一个 top 会未定义行为.

无锁栈可用 Treiber Stack: compare_exchangehead 上.

bool push(T x) {
    Node* n = new Node{std::move(x), head.load()};
    while (!head.compare_exchange_weak(n->next, n));
    return true;
}
bool pop(T& out) {
    Node* n;
    while ((n = head.load()) && !head.compare_exchange_weak(n, n->next));
    if (!n) return false;
    out = std::move(n->val);
    delete n;  // 实际有 ABA 隐患,需 hazard pointer 或 epoch reclamation
    return true;
}

ABA 问题:A 出栈后被释放内存正好被分配给新节点 C,head 又被重新设回 C 的地址 ⇒ compare_exchange 通过了——实际栈头变了,发生丢节点. 解决:

  • Tagged pointer (ptr + counter 在 16 字节 CAS);
  • Hazard pointer / epoch-based reclamation;
  • 退回 mutex 加锁.

这就是 lock-free 队列设计如此困难的原因——真问题是 memory reclamation 而不是数据结构本身.

易错清单

  1. 递归爆栈:Python 默认 1000,必要时 sys.setrecursionlimit 或改迭代;
  2. DFS 用 stack 模拟递归时忘"返回点",要自己显式管理 frame;
  3. ring buffer 的「empty vs full」——两种判断都有人用,选一种,全局只一种
  4. 单调栈严格性:相邻相等元素是否要弹完全决定语义,务必想清题意;
  5. lock-free 缺 ABA fix:不写就别上生产。

经典题

  • LC 155 最小栈:双栈 / 单栈 + 差值;
  • LC 739 Daily Temperatures:单调栈;
  • LC 84 Largest Rectangle in Histogram:单调栈母题;
  • LC 239 Sliding Window Maximum:单调 deque;
  • LC 32 Longest Valid Parentheses:栈 + 索引 merge;
  • LC 224 / 227 Calculator:典型 mark-二元运算 / token 解析栈。

这一章带走的东西

  • 栈/队列的本质是思维模型: 撤回 vs 排序;
  • ring buffer 是软硬通用的同构模型,软件看 cuda / numba lock-free,硬件看 BRAM;
  • 单调栈 = 把"未来发生即失能"统一化的工具;
  • lock-free 不实现 ABA guard 在工程上不是 lock-free.

下一节 → 哈希表:从原理到工程

哈希表:从原理到工程

一句话

"哈希表 = O(1)" 是 DSA 里最危险的过度简化。一张哈希表的工程性能由冲突率、装载因子、rehash、cache 局部性、hash 函数抗碰撞性、并发同步、DoS 抗性共同决定,复杂度只是其中一个分量。这一章把它从理论拓到工业:从 SipHash 的内核抗 DoS、到 SwissTable 的 SIMD 单 SIMD 比较、再到裸 SIMD hash map 写到 FPGA.

核心三件套

key → (hasher) → hash → (indexing) → slot → (compare) entry
       ↑               ↑             ↑
       依赖 key         依赖容量      依赖 key 相等
  • hasher 把任意长 key 压成定长 bit;
  • indexing 通常 hash % bucket 或更稳的 hash & (cap - 1)(要求 cap 是 2 的幂);
  • compare 必须实现稳定相等;
  • 别忘了符号位:hash & MASK 直接用位与,需要 cap 是 2 的幂。

冲突处理:两条主路线

1. 链地址(chaining)

  • 每 bucket 一条链表;
  • 插入 O(1),最坏 O(n)(全 key 同 hash);
  • 删除 O(1),自然;
  • 装载因子任意。

Java HashMap、Go map 走这条路.

2. 开放寻址(open addressing)

  • 所有元素在数组里;冲突就探下一个 slot;
  • 三种探测序列:
    • 线性 i + k:cache 极友好但聚集严重;
    • 二次 i + k²:抗主聚集;
    • 双哈希 i + k·h2(key):抗所有聚集;
  • 删除难:tombstone 替代物理删除,否则断链;
  • 装载因子必须 < 0.75(线性);
  • 现代 SIMD 探测变种:SwissTable / F14.

Python dict、Rust HashMap、absl flat_hash_map 走这条路.

工程取舍

维度链地址开放寻址
Cache locality
删除O(1) 自然需 tombstone
装填因子上限任意< 0.75
DoS 抗性同串桶弱一般
实现
内存底线必须 8B/key 指针紧排,少开销

Java 8 给链地址加了一条 sweetener:单桶链长度 ≥ 8 时转红黑树,把"用力构造同 hash 的攻击"挡掉。

Rehash 与扩容

触发阈值由 load_factor 决定,链地址通常 LF=1.0,开放寻址 0.5-0.75.

扩容步骤:

  1. 分配新数组(通常 cap×2 或更大);
  2. 重新 hash 所有 key (hash & new_cap_mask);
  3. 释放旧数组.

如果第一和第三步在同一次 op 里完成

  • 短期代价 O(n);
  • 单次 op 延迟尖刺,几 ms.

生产服务下 99.99% 长尾读 HBase / Redis 必有此尖刺.

渐进式 rehash(dictugador 风格):把步骤 2 分摊到每次 op,每次搬 1-2 个 bucket. 这是 Redis 4.0+、Java 8 HashMap 一起做的事.

哈希函数:从 FNV 到 SipHash

理想哈希:分布均匀 + 雪崩效应 + 不可预测(抗 DoS).

哈希速度抗 DoS备注
FNV-1a极快教学用
MurmurHash3入门实战
xxHash / xxhash3最快现代标准
SipHash中等Rust/Python 默认,抗 hash flooding
CityHash部分Google
wyhash较好absl flat_hash_map

warning

写公开服务时,必须用 SipHash 或带随机 seed 的哈希. 否则三行代码就能用"同 hash 桶" 把你 QPS 打挂.

SipHash 的特别之处

SipHash 是 密码学风格 PRF —— 输入 key 和 一个 secret (per-process random key),输出 64-bit. 它不可预测,所以客户端构造"全 hash 到 0" 的输入集需要破解 random seed,相当于一次 PRF 攻击,现实中是不可能的. 这就是为什么 Python 3.4+ dict / Rust HashMap / Java String.hashCode 都引入了进程级隨机 seed.

SIMD 加速的现代哈希查表

SwissTable 的精髓

  • 8 个桶一组,组内保留 7-bit 元数据(取 hash 高 7 位作为控制字节);
  • 一次 SIMD load 8 字节 = 控制 64-bit 寄存器;
  • _mm_cmpeq_epi8 一次比 8 个 control byte,看哪几桶是"匹配候选";
  • 没匹配就跨过整组,cache-friendly 且常数小.

abseil flat_hash_map / Rust hashbrown / Google 内部 dense_hash_map 都用这种思路. 吞吐量比 STL unordered_map 高 5-15 倍.

CPU 视角的 hash probe

// 简化 SwissTable probe 模式
__m64 ctrl = load_8c(ac + i);                    // 64-bit 8 个 control
__m64 match = _mm_cmpeq_pi8(ctrl, target);       // == target?
uint32_t mask = _mm_movemask_pi8(match);         // 8-bit bitmap
while (mask) {
    int slot = __builtin_ctz(mask);
    if (keyMatches(ac+i*8+slot, key, ...)) return slot;
    mask &= mask - 1;
}

SIMD 把常数因子塌下来 8 倍。这就是为什么 Rust hashmap 比 Java HashMap Performance 在 large N 上领先的原因之一.

Go map 内部实现阅读笔记

阅读 runtime/map.go:

  • 内部 hmap + []bmap: 每个 bmap 持有 8 个 (tophash, key, value),溢出用链.
  • tophash 8-bit 加速 bucket 内顺序比对,减少 cache miss.
  • LF 触发扩容时,新数组 cap ×2;渐進式:每次 op 搬 2 个旧 bucket.
  • 还有"same size grow":清理墓碑.
  • Go 不 export 内部 hmap,但反射可以看到.
m := make(map[string]int, 1000)             // 提示大致容量
m["a"] = 1
v, ok := m["a"]
delete(m, "a")

make(map, ., .) 第二个参数是 hint,预分配 bucket 数避免早期 rehash.

多语言对齐:四种语言运行时哈希表对比

// Go map — 链地址,渐进式 rehash 出自 runtime
m := make(map[string]int, 1000)
m["a"] = 1
delete(m, "a")
// JS Map — insertion order preserved,开放地址? 实际上是
// "ordered hash map" — V8 内部用 chained buckets + insertion order linked list
const m = new Map<string, number>();
m.set("a", 1);
m.delete("a");
// 有 iteration guarantee:按插入顺序遍历
# Python dict — 开放地址、Hi-Lo SIMD probe sequence、紧凑存值(3.6+)
m = {"a": 1}
m["b"] = 2
del m["a"]
# 自 3.7 起保证插入序遍历
# 内部数据布局:entries 可 dense + index array;3.11 之后是 compact layout
// std::unordered_map — 链地址、节点是堆上的
std::unordered_map<std::string, int> m;
m["a"] = 1;
// std::hash<std::string> 实际上不是 SipHash,是平台相关
// 用 absl::flat_hash_map 通常快 5-10 倍

横向看,你会注意到"开放地址 + SIMD 探测 + 紧排 entry = 性能最优组合"——这是各语言运行时都在向 SwissTable 靠拢的原因。唯一例外是 Java: 因为 HashMap 要 iterator stability,无法 tear down 所有链、要求 key-value 引用稳定,链地址是必须的.

这就是为什么我希望你建立"跨语言看抽象"的能力:表面看四种语言的 dict 各自不一样,背后其实是同一个工程抽象 + 运行时取舍集合.

易错清单(这是个长清单)

  1. 依赖遍历顺序:Go map 随机序;Python 3.7+ / Java LinkedHashMap 保证插入序;C++ unordered_map 无序。
  2. 遍历中改 map:删本元素或加新元素,迭代器可能失效。
  3. float key 与 NaN:IEEE754 下 NaN != NaN,结果两个 NaN 都能入同一个 map. Python 用 __hash__(inf) 解决了一部分但 NaN 仍是雷区。
  4. vector<bool> key or BitSet key: 这些类型的 hash 实现不对,必须手写 hash function.
  5. Java == vs .equals():boxed key 必须 .equals.
  6. hash mod n != hash & (n-1):前者对任何 n 工作;后者要求 n 是 2 的幂. 一般都看 mask 形式.

DoS attack 视角

2003-12 制造攻击"Hash DoS" (Crosby & Wallach):

准备 K 个字符串 hash 都算到 X;
HTTP POST 提交 1MB 数据,2^24 个键;
解析的 hash map 退化为 O(n²).
服务端 CPU 满载,请求挂起.

防御手段:

  1. 用 SipHash + random seed (per-process) — Python / Rust / 现代 Java 都做了;
  2. 限 map 大小 (1MB 表单);
  3. 长链 → 树化 (Java 8 之后做法);
  4. DoS-aware hash 函数 (QuickHash-ghash).

FPGA / SIMD 视角

FPGA 上实现 hash table 是 HFT 与 packet processing 的常用块:

  • 用 BRAM 存桶,每 bucket 一个寄存器保存最近 hit;
  • probe 顺序按 hash 低位走,cache 实际是 SRAM;
  • 通常 per-cycle 一碰/一品,超 Gpps 量级.

这就是 FPGA 加速网络/雷达常见技术.

软件 SIMD、CPU cache + lock-free、FPGA banked BRAM 其实是同一抽象本身的多种物化.

经典题

  • LC 1 Two Sum:哈希一次 O(n);
  • LC 49 Group Anagrams:哈希变换成规约 key;
  • LC 128 Longest Consecutive Sequence:O(n) 用 set, 不排序;
  • LC 706 Design HashMap:裸写冲突处理;
  • LC 460 LFU Cache: 组合 hash + 双链 + 频度组.

这一章带走的东西

  • 开放地址 (SIMD) > 链地址 (除了 Java 例外);
  • SwissTable 的 7-bit control + SIMD cmpeq 创造 5-15× 吞吐差;
  • 渐进式 rehash = 长尾敏感的救星;
  • SipHash = 抗 Hash DoS 的最低要求;
  • 一种"hash + probe" 抽象在 SIMD CPU、lock-free、FPGA 桥间同构.

下一节 →

树是工程里最常用的"自然数据结构骨架"

  • 数据库索引 = B+ 树
  • 浏览器 DOM = 树
  • 文件系统 = 树
  • JSON = 树
  • 优先队列 = 堆

理解树,理解了半个数据世界。

二叉搜索树(BST)与平衡

一句话

BST 的核心在不在"二叉",在 "中序遍历 = 排序序列" 这个性质:一旦你把 BST 当成"可被中序枚举的有序结构",所有平衡树、B 树、跳表都成了一组变体——都是在不同物化层上让 h (= 极端最长路径) 收敛到 O(log n)。理解到这一层,红黑树、AVL、B+ 树、跳表看起来就不再像四个不同东西,而是同一个抽象的四个工程实现版本

BST 的语义

任意 node v:
  左子树的所有 key < v.key < 右子树的所有 key

"所有"两个字很关键——不是 v.parent,是 v 的左子树里的所有节点**。

由这个性质推导出三件事:

  1. 中序遍历有序:这个性质决定了所有 BST 系算法。
  2. 二分查找天然适用:找一个 key,每比较一次能扔掉一棵子树。
  3. 前驱/后继可在树内 O(h) 找到:右子树最左 / 左子树最右。

三大操作的代价

操作复杂度来源
searchO(h)每层一次比较
insertO(h)沿路径找到空位
deleteO(h)沿路径找替换 + 替换

h = 树高. 朴素 BST 的 h 是 [log n, n]。最坏退化成链表(顺序插入),h=n,一切操作 O(n).

删除要分三种情况

为什么这是 BST 里最容易出 bug 的地方?因为删除形态不止一种。

情况 1:是叶子 → 直接去掉。
情况 2:只有一个孩子 → 孩子顶上来。
情况 3:有两个孩子 → 找右子树最小(或左子树最大)替换,再删掉原位置。

情况 3 的"找替换"方法可任选一种左右一致:

  • 右子树最小值("中序后继 successor")
  • 或者 左子树最大值("中序前驱 predecessor")

只要全局一致,两种都能保持 BST 性质。但混用就是 bug.

warning

实习生最常写错的就是 case 3:把"中序后继"误作"前驱",又在另一处用了不同方向——结果序列在某些输入下失去有序。永远要在一处固定方向。

平衡的本质

朴素 BST 在 1, 2, 3, 4, 5 顺序插入后,全部接到右子链,h = n.

要让 BST 持续保持 h = O(log n),需要"重塑树但保 BST 中序不变"的操作:旋转.

旋转是一组动作,中序序列不变 —— 这是不变量。任何平衡树算法都是"使用旋转重建 BST,使高度下降,同时中序序列保持"。

旋转背后的不变量

    y                x
   / \     =>       / \
  x   C            A   y
 / \                  / \
A   B                B   C

两个图的中序遍历都是 {A, x, B, y, C} —— 不变量被保持.

任何 BST 算法机器都是:

  • 把不平衡的某子树旋转一下;
  • 改变根与孩子链接;
  • 高度和顺序不变.

平衡策略一览:同一抽象的不同取舍

算法思路复杂度说明
AVL强平衡:h差 ≤ 1O(log n)严格平衡;读取多场景最优
红黑树弱平衡:颜色规则 ⇒ h ≤ 2 log (n+1)O(log n)修改多场景最稳,工程默认
Splay功利槐蓟双旋摊还 O(log n)访问的 key 下次更近
Treap随机优先级期望 O(log n)简单好写
跳表多级有序链表期望 O(log n)并发友好
B/B+多叉 + m 大 = 单页O(log_m n)IO 友好,DBMS 标配
LSM-Treeappend + 后台 merge摊还 O(log n)SSD 友好

多语言实现

Go 迭代版 BST(含删除三情况)

type Node struct {
    key         int
    left, right *Node
}

type BST struct{ root *Node }

func (t *BST) Insert(key int) {
    n := &Node{key: key}
    if t.root == nil { t.root = n; return }
    cur := t.root
    for {
        if key < cur.key {
            if cur.left == nil { cur.left = n; return }
            cur = cur.left
        } else {
            if cur.right == nil { cur.right = n; return }
            cur = cur.right
        }
    }
}

func (t *BST) Delete(key int) {
    var parent *Node
    cur := t.root
    for cur != nil && cur.key != key {
        parent = cur
        if key < cur.key { cur = cur.left } else { cur = cur.right }
    }
    if cur == nil { return }
    // case 3:两个孩子 → 用 cur 的右子树最小替代 cur.key,再删那 succ
    if cur.left != nil && cur.right != nil {
        succP, succ := cur, cur.right
        for succ.left != nil { succP, succ = succ, succ.left }
        cur.key = succ.key
        cur, parent = succ, succP
    }
    // 现在 cur 至多有一个孩子
    var child *Node
    if cur.left != nil { child = cur.left } else { child = cur.right }
    if parent == nil { t.root = child } else
    if parent.left == cur { parent.left = child } else { parent.right = child }
}

TypeScript 递归版

class Node<T> {
  constructor(public key: T,
              public left: Node<T> | null = null,
              public right: Node<T> | null = null) {}
}

class BST<T> {
  root: Node<T> | null = null;
  insert(key: T) {
    const rec = (n: Node<T> | null): Node<T> => {
      if (!n) return new Node(key);
      if (key < n.key) n.left = rec(n.left);
      else n.right = rec(n.right);
      return n;
    };
    this.root = rec(this.root);
  }
  inorder(): T[] {
    const out: T[] = [];
    const walk = (n: Node<T> | null) => {
      if (!n) return;
      walk(n.left); out.push(n.key); walk(n.right);
    };
    walk(this.root);
    return out;
  }
}

C++:裸 BST 与 STL

裸 BST 写完之后,工程上永远直接用 STL std::map / std::set(红黑树底层). 你不要在日常业务里自己写 BST——除非是面试/教学。

工程视角:保留中序 + 在上面堆附加结构

很多高级数据结构是"BST + 字段":

  • 子树 size ⇒ k-th O(log n);
  • 子树 max ⇒ 区间查询;
  • 子树 sum ⇒ 区间和(线段树本质);
  • 颜色 ⇒ 红黑树;
  • 优先级 ⇒ Treap;
  • 多路扇出 ⇒ B+ 树。

在 BST 之上叠结构是非常常见的"工程复用"思路。一旦理解所有结构都是"中序遍历 + 附加 invariant",后面 AVL/红黑/B+ 的代码看到只不过是加不同 invariant 的 BST 而已。

cache / 数字电路 / FPGA 视角

BST 在硬件层并不理想:

  • 指针跳跃 = cache miss 风暴;
  • 平衡树每次旋转 = 一节点的左右多处内存写
  • 没法 SIMD 并发.

真实工程中,对内存紧排需求高时:

  • 用 B+ 树替代 BST:每节点 ≥ 16-128 key order ⇒ cache 友好;
  • 用 LSM-Tree 替代 B+:append 友好 + SSD 友好;
  • 在 FPGA 上:用 BRAM-based hash 代替 BST——h 大的树在 FPGA 上跑性比常数更糟;

这就是为什么软件界没人在 GPU/FPGA 上跑红黑树,但会跑哈希表 + 紧排.

易错清单

  1. 把 BST 限定成"v.left.key < v.key < v.right.key"——错! 是所有左子树节点 < v.key, 不是只比 parent。
  2. 删除 case 3 中序后继 vs 前驱混用 → bug。
  3. 用 parent 字段 + 删除路线时忘记更新 parent 字段
  4. 把 sorted linked list 的"自带 key 排序"误读成"是 BST"——不是 BST,是链表.
  5. 用 NaN 作 key —— NaN != NaN,BST 性质破坏。

经典题

  • LC 98 Validate BST: 用 (min, max) 范围而不是只比 parent.
  • LC 230 Kth Smallest in BST: 子树 size 字段 O(log n).
  • LC 1038 / 538 BST to Greater Sum Tree: 逆序中序遍历.
  • LC 449 Serialize and Deserialize BST.
  • LC 173 BST Iterator: 用栈模拟中序遍历,lazy 模式.

这一章带走的东西

  • BST 的本质 = 中序遍历排序;
  • 任何平衡树都是"旋转 + 中序不变量"的叠加;
  • 删除三情况 + 中序后继 vs 前驱必须一致;
  • BST 在硬件层 cache-hostile → 工业默认用 B+ 树 / LSM-Tree;
  • 一旦在 BST 上加字段,你就解锁了所有"顺序统计 + 区间树 + 线段树"的工具集.

下一节 → AVL 与红黑树

AVL 与红黑树

一句话

AVL 与红黑树是同一抽象的两种工程取舍:严格平衡(AVL,读多场景占优)与弱平衡(红黑树,写多场景占优)。理解它们为什么都 O(log n)、为什么树高稍稍不同、为什么红黑树在工业里更常见——核心不在"哪个更平衡",而在每次插入 / 删除后需要修复旋转的次数上界。这就是工业界默认红黑树的原因。

为什么 AVL 想要严格平衡

AVL 不变量:

任意 node v:
  height(v.left) - height(v.right) ∈ {-1, 0, +1}

数学上:高度 ≤ 1.44 * log₂(n+2) — 0.328,渐进上等价 O(log n).

AVL 高度 vs 红黑树高度

n = 10^9
AVL h 上限 ≈ 43;
红黑 h 上限 ≈ 2·log₂(10^9+1) ≈ 60.

AVL 略矮 ⇒ 比 AVL 平均访问的 cycle 数略少 ⇒ 读场景占优.

AVL 的四种失衡

设 z 是最近失衡节点。看插入发生在 z 的哪一子孩子 + 哪一子孩子:

插入路径操作
z.left.left右旋一次
z.left.right先左旋 z.left,再右旋 z
z.right.right左旋一次
z.right.left先右旋 z.right,再左旋 z

LL/RR 是 single rotation;LR/RL 是 double rotation.

插入后祖先可能继续失衡——但 AVL 经典性质:插入触发最多 1 次 双旋(single 或 double),因为 z 处修好后,子树高度恢复原状,祖先失衡解除.

AVL 删除的特殊之处

删除后子树高度可能"减少 1",沿祖先向上传播:

  • 每层都可能再次失衡;
  • 修复路径 O(log n) 层,每层 O(1) 工作;
  • 总复杂度 O(log n),但常数远大于插入.

这是 AVL 在"写多"场景输红黑树的主因.

红黑树:五条不变量

1. 节点是红或黑;
2. 根是黑;
3. 叶子(NIL)是黑;
4. 红节点的孩子必须是黑(无连续两红);
5. 任意 node v 到其后代 NIL 的所有路径上黑节点数相同.h

由这 5 条 ⇒ h ≤ 2 log₂(n+1).

工程直觉:每条路径上有约等量的黑节点 + 红节点处自由分布——红黑树本质上是用颜色编码"允许局部不平衡",从而减少修复旋转次数.

红黑树插入 fixup(CLRS 版简化)

新节点 z 着红色插入,然后做 fixup:
while z.p.color == RED:
    if z.p == z.p.p.left:
        y = z.p.p.right                  // 叔叔
        if y.color == RED:                // 情况 1: 叔叔是红
            z.p.color = BLACK
            y.color = BLACK
            z.p.p.color = RED
            z = z.p.p                     // 向上传播
        else:
            if z == z.p.right:            // 情况 2: z 是右孩子
                z = z.p; LEFT_ROTATE(z)
            // 情况 3: z 是左孩子 + 重色 + 旋转
            z.p.color = BLACK
            z.p.p.color = RED
            RIGHT_ROTATE(z.p.p)
    else: // 对称镜像

插入:最多 2 次旋转 + 几次重色——这是红黑树在工业里能跑赢的根基.

红黑树删除更复杂

删除涉及 6 种情形(GLR canonical 设 4 种、注意 OMI 推导),但最多 3 次旋转 + O(log n) 次重色. 比起 AVL 的 O(log n) 旋转,常数差距很大。

AVL vs 红黑 关键指标

指标AVL红黑树
最大高度1.44 log(n+2)2 log(n+1)
查找 cycle 数更少略多
插入旋转次数≤ 1≤ 2
删除旋转次数≤ O(log n)≤ 3
实现复杂度中等较高

这就是为什么 C++ STL、Linux CFS、Java TreeMap、Linux kernel 进程管理的进程链表都选红黑树:删除时的旋转次数有上界,写性能稳定.

Go 实现 AVL 插入(带四种旋转)

type Node struct {
    key          int
    h            int
    left, right  *Node
}

func height(n *Node) int { if n == nil { return 0 }; return n.h }
func updateH(n *Node)    { n.h = 1 + max(height(n.left), height(n.right)) }

func rotateRight(y *Node) *Node {
    x := y.left
    y.left = x.right
    x.right = y
    updateH(y); updateH(x)
    return x
}

func rotateLeft(x *Node) *Node {
    y := x.right
    x.right = y.left
    y.left = x
    updateH(x); updateH(y)
    return y
}

func insert(n *Node, key int) *Node {
    if n == nil { return &Node{key: key, h: 1} }
    if key < n.key { n.left = insert(n.left, key) }
    else { n.right = insert(n.right, key) }
    updateH(n)
    bal := height(n.left) - height(n.right)
    switch {
    case bal > 1 && key < n.left.key:
        return rotateRight(n)
    case bal > 1 && key > n.left.key:
        n.left = rotateLeft(n.left)
        return rotateRight(n)
    case bal < -1 && key > n.right.key:
        return rotateLeft(n)
    case bal < -1 && key < n.right.key:
        n.right = rotateRight(n.right)
        return rotateLeft(n)
    }
    return n
}

红黑树代码骨架

红黑树太长,这里只展示插入 fixup 的骨架:

type color int
const ( RED, BLACK color = iota, 1 )

type RBNode struct {
    key         int
    c           color
    left, right, parent *RBNode
}

func rbInsertFixup(root **RBNode, z *RBNode) {
    for z.parent != nil && z.parent.c == RED {
        // ... 三种情况 + 镜像,~80 行 ...
    }
    (*root).c = BLACK
}

全红黑树真正能写对的工程师相对少——大部分人用 google/btreecontainer/list + 库. 教学价值高但工程价值有限。这就是为什么大型项目里很少有人裸写红黑树——维护负担太重.

工程化注意点

  1. 注释里永远写不变量: 例如 NIL 节点颜色为黑. 否则半年后无人能改.
  2. 删除节点路径需更新姊妹链路的颜色与平衡。
  3. 内存布局:RBNode 比朴素 BST 多一个 color 字段。用 1 bit 而不是 1 byte——Rust 通过 ptr 低位藏 color 节省 8 字节.
  4. page-aligned B+ 树 vs 红黑树:单机内存红黑树仍占绝对优势,但磁盘上的索引绝对走 B+.

cache 视角

红黑树与 AVL 在 cache 行为上都不理想——每层指针跳跃都是 cache miss. 这就是为什么 Redis、LevelDB 用跳表替代红黑树: 跳表的数组底层 cache 友好且实现更短.

经典题

  • LC 110 平衡二叉树。
  • LC 1382 将 BST 转成平衡 BST (中序 + 重建)。
  • 实现红黑树的小型 benchmark: 插入 10⁶ 个 key, 测与 std::map 对比 - 看常数.

这一章带走的东西

  • AVL 严格平衡、读取占优、插入旋转 ≤ 1;
  • 红黑树弱平衡、写多场景稳、删除旋转 ≤ 3;
  • 同 O(log n), 工程默认红黑树, 因为删除旋转有上界;
  • 紧排 layout 上, 红黑树都不如 B+ 树 cache 友好;
  • 工程上 ST 默认 std::map / google btree, 真要自己写平衡树要充分测试.

下一节 → B 树与 B+ 树

B 树与 B+ 树

一句话

普通 BST 在 RAM 里可以正常工作,但当数据规模远超内存、必须以页为单位与磁盘交换时,树高 h 就直接等价于硬盘 IO 次数. 一次 SSD IO 约 100 μs,30 次磁盘 IO = 3 ms ⎯ 把 BST 直接搬到数据库上会跑出几秒级延迟. B+ 树的存在是为了让 h = 3~4 而不是 30——它不是"更好的 BST",而是"为磁盘这种媒介专门设计的树".

为什么标准 BST 不能当数据库索引

  • 数据库规模动辄 10⁹ 行 × 512 字节 ≈ 512 GB ⎯ 不可能装在内存里;
  • 红黑树 h ≈ log₂ 10⁹ ≈ 30;
  • 每层比较 1 次 + 1 次磁盘 IO,30 次磁盘 IO 太贵.
  • 一个 B+ 树节点用一个 page(4~16 KB)装几百个 entry, 扇出 100+ ⇒ h = 3~4 ⇒ IO 3~4 次.

这就是 B+ 树解决的核心矛盾:树高 = 磁盘 IO 次数,所以扇出越大越好,扇出 ≈ page 大小 / key 大小.

B 树定义(m 阶 B 树)

  1. 每个节点最多 m 个子节点, m-1 个 key;
  2. 每个非根非叶节点至少 ⌈m/2⌉ 个子节点;
  3. 所有叶子在同一层;
  4. 节点内 key 有序.

插入触发分裂:

节点 key 数 = m-1(满了) → 插入后会变成 m → 取中间 key 上推到父节点
                                      → 左右两半作为新两个孩子

删除触发合并或借入:

节点 key 数 < ⌈m/2⌉-1 →
  case 1: 兄弟有多余 key → 借入
  case 2: 兄弟也最小     → 与兄弟 + 父分隔 key 合并

B+ 树与 B 树的差异

维度B 树B+ 树
数据位置内部节点也装 sat data所有 sat data 都在叶子
叶子链叶子有左右兄弟指针
范围查询中序遍历,多次 IO沿叶子链顺扫,O(1) 次跳
扇出小一些大(key 比记录小)

正是叶子链让 WHERE a > 10 AND a < 1000 类型查询变得超快:定位到第一个后顺扫一段.

真实实现与扇出

  • InnoDB: page size = 16 KB; 一行 100~500 字节 ⇒ 单页 30~150 行, 扇出近百; 3 层 B+ 树支持 10⁵~10⁶ 行索引; 4 层能上 10⁸.
  • PostgreSQL: B+ 树(叫 Btree) + heap table 分离存储, heap 是按 line pointer 的 append-only, B+ 树维护 (key, ctid) 映射.
  • SQLite: 缺省 page 4 KB; B+ 树存 table, B+ 树 或 混合存 index.
  • Ceph BlueStore / RocksDB: memtable + SSTable (LSM-tree), SSTable 内部块索引也是 B+ 树风格.

真物理页面与 IO

一个 16 KB page → SSD 一次 4 KB 单元 IO 槽, 4 次但 OS 一次可批. NVMe 通过 PRP/SGL 把多个 page 一并 DMA. 这就是为什么4 KB / 16 KB page 是物理对齐的 sweet spot: Linux page 是 4 KB, NVMe 是 4 KB cache line, B+ 树 page ≈ OS page ≈ NVMe 块大小, 这层匹配让 OS 无需重新切 page.

复杂度

操作复杂度实际 IO
searchO(log_m n)⌈log_m n⌉
insertO(log_m n)1 次写 + log_m n 次读 + 偶尔分裂
deleteO(log_m n)同上
range(k₁, k₂)O(log_m n + k)1 次定位 + 顺扫

如何选 m

经验: 使节点 ≈ 一个磁盘 page 大小.

m ≈ page_size / (sizeof(key) + sizeof(child_ptr))

例如 key 8 B, child ptr 8 B, page 16 KB ⇒ m ≈ 1024. 一般加 padding 实取 200~500.

Split / Borrow 框架

// 节点最多 m = 4 个子、m-1 = 3 个 key; 最少 2 个子、1 个 key
type BPlusNode struct {
    isLeaf   bool
    keys     []int
    children []*BPlusNode
    next     *BPlusNode // 叶子链
}

func splitChild(parent *BPlusNode, i int) {
    full := parent.children[i]
    mid := len(full.keys) / 2
    upKey := full.keys[mid]

    newNode := &BPlusNode{isLeaf: full.isLeaf}
    if full.isLeaf {
        newNode.keys = append(newNode.keys, full.keys[mid:]...)
        full.keys = full.keys[:mid]
        newNode.next = full.next
        full.next = newNode
    } else {
        newNode.keys = append(newNode.keys, full.keys[mid+1:]...)
        newNode.children = append(newNode.children, full.children[mid+1:]...)
        full.keys = full.keys[:mid]
        full.children = full.children[:mid+1]
    }
    // 把 upKey + newNode 插入 parent
    parent.keys = append(parent.keys, 0)
    copy(parent.keys[i+1:], parent.keys[i:])
    parent.keys[i] = upKey
    parent.children = append(parent.children, nil)
    copy(parent.children[i+2:], parent.children[i+1:])
    parent.children[i+1] = newNode
}

LSM-Tree: B+ 树的亲缘替代品

为什么 BigTable、Cassandra、RocksDB、LevelDB 都用 LSM-Tree 而不是 B+ 树?

  • B+ 树插入就地写, 每次至少 1 次随机 IO, SSD 上 4 KB 随机写代价远大于顺序写;
  • LSM-Tree **append-only», 顺序写超快, 但读要扫描多层 + 多个 SSTable, 加 bloom filter 加速.

SSD 写放大指标上:

  • B+ 树写放大 ~32× (16 KB page, 100 row/page ⇒ 一行写导致 16 KB 完整 page 写);
  • LSM-tree 写放大 ~10-30× (后台 compaction).

writes/s 上 LSM 常胜, reads/s 上 B+ 树常胜. 这就是为什么 OLTP DBMS(MySQL、PG)选 B+ 树, OLAP 列存 + WAL 选 LSM.

FPGA / 硬件视角

B+ 树在 FPGA/SSD 上有特别的实验:

  • PG-Strom: PostgreSQL 扩展把 B+ 树扫描放到 GPU 上做 SIMD。
  • BlueDBM: MIT 风格 near-data processing,把 B+ 树节点 page 直接预存到 SSD controller 上.
  • P2IME/SAP HANA: 内存计算 + NVM 持久化 + B+ 树兼并内存与 SSD.

不论什么方案, B+ 树抽象栈上有页、页是对 IO 的最小单位 这点都成立, 只是物化的层次不同.

易错点

  1. 分裂方向: 中间 key 上推, 注意偶数 m 时取左中还是右中要一致.
  2. 合并时父节点可能也下溢: 递归向上传播.
  3. 占位写与就地写: 数据库 page 改写要走 WAL 落盘顺序, 避免 page torn.
  4. cache 边界: 节点 page 是 atomic unit, 不要在一个事务里同时改多个 page 后才 journal.

这一章带走的东西

  • 树高 = IO 次数, ⇒ 扇出越大越好;
  • B+ 树的"叶子链"是范围查询优势之源;
  • page 大小 ≈ OS page ≈ NVMe 块大小 ≈ sweet spot for IO;
  • LSM vs B+ 树 = 顺序写 vs 随机写 vs 写/读负荷比例;
  • 在 SSD/NVM 时代, B+ 树与 LSM-Tree 是同抽象的两种物化.

下一节 → 堆与优先队列

堆与优先队列

一句话

堆是少数同时被理论和工程"普遍接受"的数据结构:完全二叉树 + 父子关键字序约束 + 用数组表示. 三个限制合起来让它在 cache 上友好、在常量上小、在 O(log n) 上稳定、在内存上零分配。Heap 的"完全二叉树用数组表示"是工程上用代数关系代替指针的最佳教学例子之一。

三种限制合一

完全二叉树: 除最后一层外全填, 且最后一层从左往右排;
父子关序:     parent.key ≥ child.key (最大堆) 或 parent.key ≤ child.key (最小堆);
数组表示:      层序编号, a[i] 的子是 a[2i+1] / a[2i+2], 父是 a[(i-1)/2].

为什么这三种限制合一能给出重大性能优势?

  • 完全二叉树 ⇒ 树高恰是 ⌈log₂(n+1)⌉, 没有退化空间.
  • 父子不严格要求左右子孰大孰小 ⇒ 平衡是最紧的, 增删不触发旋转动作。
  • 数组表示 ⇒ cache 友好 + 零指针开销 + 零 node malloc.

数组索引公式

索引从 0 开始:
  parent(i) = (i - 1) / 2  (整除下取)
  left(i)   = 2·i + 1
  right(i)  = 2·i + 2
索引从 1 开始:
  parent(i) = i / 2
  left(i)   = 2·i
  right(i)  = 2·i + 1

1 起点比 0 起点快: 2·i 是单条左移指令, 而 2·i+1 / 2·i+2 仍是单条左移加常量 add. 工程上不少教科书选 1 起点, 这就是原因.

基本操作: push / pop / heapify

sift_up (push 入堆):

def sift_up(a, i):
    while i > 0 and a[(i-1)//2] < a[i]:
        a[(i-1)//2], a[i] = a[i], a[(i-1)//2]
        i = (i-1)//2

sift_down (替换根的情形, 用于 pop_max):

def sift_down(a, i, n):
    while True:
        l, r = 2*i+1, 2*i+2
        largest = i
        if l < n and a[l] > a[largest]: largest = l
        if r < n and a[r] > a[largest]: largest = r
        if largest == i: return
        a[i], a[largest] = a[largest], a[i]
        i = largest

heapify 为什么是 O(n), 不是 O(n log n)?

按直觉: heapify 调用 ~n/2 次 sift_down, 每次最坏 O(log n) ⇒ O(n log n).

但代价更精算: 保留下层叶子无需 sift (它们高为 0), 叶子层 sift_down 是 O(0), 上层才贵.

总代价 = Σ_k (n/2^(k+1)) · k
       = n · (Σ k/2^(k+1))
       = n · 1 = Θ(n)

堆化整个数组 O(n). 这是工程里 container/heap.Init / make_heap 的复杂度. 想快速把一个流变成一个堆时, 直接 Init 比 push 每个元素小 30%.

工程变体

类型用途
binary heap默认用途, Dijkstra / Huffman / Top K
d-ary heapDijkstra cache-aware, D = 4 或 8 常用
Fibonacci heap理论最优, 实操零优势
Pairing heap实操简洁、平均近最优
左偏树 / Skew heap可并堆
索引堆 (Indexed heap)Dijkstra 中要 O(log n) 减边
Brodal queueFibonacci 的可并发实现

索引堆: Dijkstra 的真正关键

标准堆只能 pop 最小. 但 Dijkstra 中需要对已入堆的节点 降低距离再下沉. 要做这一步, 堆必须支持 decrease_key:

pos[v] = 节点 v 在堆数组里的下标
decrease_key(v, new_dist):
  heap[pos[v]] = new_dist
  sift_up(pos[v])

每 swap 一次就必须同时更新 pos: pos[heap[i]] = i. 否则 Dijkstra 不能在 O(E log V), 而是退化为 O(EV).

d-ary 堆为什么在 Dijkstra 上更好

四叉堆、八叉堆在实践中比二叉堆更快, 为什么?

  • 更浅: log_d n vs log_2 n, 树高减半-三分之一;
  • 更宽: 即每次 sift_down 要比 d 个孩子 (而不是 2 个), 常数更大;
  • cache locality 更好: 数组的横切面被 cache line 覆盖时, 一个 cache line 同时有 d 个孩子.

实测: Dijkstra 在四叉堆上比二叉堆略快; 但 16 叉以上反而退化——因为 d 太大让 sift_down 的 compare 开销超过 cache 收益.

这就是为什么 cpp boost.heap 等默认 d = 4 而不是 d = 2.

Go 的 heap 接口实现

type IntHeap []int
func (h IntHeap) Len() int           { return len(h) }
func (h IntHeap) Less(i, j int) bool { return h[i] < h[j] }
func (h IntHeap) Swap(i, j int)      { h[i], h[j] = h[j], h[i] }
func (h *IntHeap) Push(x any)        { *h = append(*h, x.(int)) }
func (h *IntHeap) Pop() any {
    n := len(*h); x := (*h)[n-1]
    *h = (*h)[:n-1]
    return x
}
// 调用: heap.Init(&h); heap.Push(&h, 3); heap.Pop(&h)

Go 标准库 container/heap 完整支持索引堆的写法: 在 Swap 里同时更新 pos[v]. 这是工程中 Dijkstra 类问题最常见的代码骨架.

多语言对比

import heapq
h = []
heapq.heappush(h, 1)
heapq.heappop(h)
#include <queue>
std::priority_queue<int, std::vector<int>, std::greater<int>> h;  // 最小堆
h.push(1); h.top(); h.pop();
// 注意 priority_queue 不支持 erase 任意元素 - 需要自定义 + 墓碑
// TS 没有内置, 自实现:
class Heap<T> { /* ... siftdown, siftup ... */ }

经典题

  • LC 215 Kth Largest (最小堆 vs sort 取小 k);
  • LC 347 Top K Frequent;
  • LC 295 数据流的中位数: 双堆 - 左最大 + 右最小;
  • LC 23 合并 K 个有序链表;
  • LC 502 IPO: 贪心 + 堆.

软件/硬件视角

  • 中位数维护双堆=对 cache line 同时友好: 双 Heap 数组 cache 常驻;
  • 堆在 FPGA 上是 streaming-friendly sort: 每 cycle push 一个 / pop 一个 = pipeline pattern;
  • 堆在 persistent memory 里要注意: 太多 in-place 改 → 顺序写坏. Google 关注 "consistent heap" 是这个方向.

易错

  1. 维护 pos[v] 时忘更新: sift 中 swap 后必须同步更新 pos[heap[i]] = i;
  2. d-ary 堆 sift_down 的比较循环搞反;
  3. continue 条件位置错了: 堆会退化;

这一章带走的东西

  • Heap = 完全二叉树 + 父子序 + 数组表示;
  • heapify O(n) 不是 O(n log n) - 数学推导;
  • 索引堆 = Dijkstra 必备;
  • d-ary 在 cache 现实下 ≈ 4 最佳;
  • 堆在 FPGA 是 streaming pattern 同构.

下一节 → 字典树与并查集

字典树(Trie)与并查集(Union-Find)

一句话

这两个结构本身没关联, 但都是"用结构编码 prefix / 等价关系"的代表性工具:

  • Trie 把字符串集合按公共前缀压缩 - 不仅 O(L) 查找, 还能优雅给出"所有以 X 为前缀的字符串", 这是哈希做不到的扩展性.
  • 并查集 把元素按等价关系分组, 用近乎常数代价处理 union / find - 支撑 Kruskal MST、连通性、LCA 等所有"集合合并"问题.

两个结构在硬件层都很 streaming-friendly, 在 FPGA / 网络包处理上有大量工业化实例.

Trie(前缀树)

语义

将"字符串集"按字符路径组织成树. 每条从根到某节点的路径对应一个前缀:

              (root)
              / | \
             a  b  w
             |  |  |
             p  y  o
             |     |
             p     r
             |     |
             l     l
             |     |
             e     d

存储关键字集 {apple, by, world}.

复杂度

操作时间空间
插入O(L)O(L)
查找O(L)0
删除O(L)-L (尾节点引用计数)
前缀查询O(L + k·L')

L = 单词长度, 与字符集无关 ⇒ 对超长字符串集合仍高效. Trie 的优势是哈希表给不出的: 前缀查询、字典序遍历、字符串集合的"动态增删 + 前缀匹配"等操作都 O(L).

工程应用

  • 自动补全 / 搜索框: 用时序哈希到叶子 → 子树枚举前缀;
  • IP 路由表: binary trie / Patricia trie; 树高 = 32 位 IPv4 / 128 位 IPv6;
  • HTTP 路由: gin / echo 早期实现; Radix Tree;
  • Boggle / WC 字符串匹配加速;
  • AC 自动机 / 后缀树: Trie 衍生物;
  • DNS resolver: BGP routing 含 Trie 思.

工程优化

  1. 子节点表示:

    • 数组 [26]*Node 紧凑但浪费 (每节点 26 槽);
    • map[byte]*Node 灵活, 散列查找有常数代价;
    • Radix Tree / Patricia Trie: 长单分支压缩成边. Radix 树是 Gin 路由器内部.
  2. 行为紧凑: 合并单分支节点减少内存浪费. Patricia Trie 节省 8× 内存.

  3. 删除要小心: 只删"还没人引用的节点", 否则破坏兄弟节点引用.

Go 实现模板

type Trie struct{ root *node }
type node struct {
    children map[byte]*node
    end      bool
}

func Constructor() Trie { return Trie{root: &node{children: map[byte]*node{}}} }

func (t *Trie) Insert(s string) {
    cur := t.root
    for i := 0; i < len(s); i++ {
        c := s[i]
        if cur.children[c] == nil {
            cur.children[c] = &node{children: map[byte]*node{}}
        }
        cur = cur.children[c]
    }
    cur.end = true
}

func (t *Trie) Search(s string) bool {
    cur := t.root
    for i := 0; i < len(s); i++ {
        if cur = cur.children[s[i]]; cur == nil { return false }
    }
    return cur.end
}

并查集(Union-Find / Disjoint Set Union)

语义

维护一组互不相交集合的合并 + 查询.

  • find(x): 返回 x 所在集合的代表;
  • union(x, y): 合并两个集合.

朴素实现: 父指针 + 直接union

find(x):  while x != parent[x]: x = parent[x]
union(x,y): 直接 parent[y] = x

朴素最坏 O(n).

两个核心优化

1. 按秩 / 按 size 合并

小集挂到大集下, 保证树高 = O(log n).

def union(x, y):
    rx, ry = find(x), find(y)
    if rx == ry: return
    if size[rx] < size[ry]: rx, ry = ry, rx
    parent[ry] = rx
    size[rx] += size[ry]

2. 路径压缩

find 顺手把路径上所有点都指向根, 下次 O(1).

def find(x):
    while parent[x] != x:
        parent[x] = parent[parent[x]]   # 半路径压缩
        x = parent[x]
    return x

两个优化叠加后的复杂度

每次操作摊还 O(α(n)), 其中 α 是逆 Ackermann 函数, 对 n ≤ 10⁸⁰ 都 α(n) ≤ 4 ⇒ 实务上视作 O(1).

带"按秩 + 全路径压缩"的复杂度证明由 Tarjan 1975 给出, 是经典摊还分析示例.

应用

  • 图连通性: 判断是否连通;
  • 最小生成树 Kruskal: 贪心选边 + 并查集判环;
  • 离线 LCA: Tarjan 离线 LCA;
  • 网格 / 河流 / 区域归并: 图像分割;
  • 分布式: Crash Detective / Spanner tablet region.

Go 实现迭代版 (按 rank + 完整路径压缩)

type DSU struct {
    parent []int
    rank   []int
}

func NewDSU(n int) *DSU {
    p, r := make([]int, n), make([]int, n)
    for i := range p { p[i] = i }
    return &DSU{parent: p, rank: r}
}

func (d *DSU) Find(x int) int {
    root := x
    for d.parent[root] != root { root = d.parent[root] }
    for x != root {            // 路径压缩
        nxt := d.parent[x]
        d.parent[x] = root
        x = nxt
    }
    return root
}

func (d *DSU) Union(x, y int) bool {
    rx, ry := d.Find(x), d.Find(y)
    if rx == ry { return false }
    switch {
    case d.rank[rx] < d.rank[ry]: d.parent[rx] = ry
    case d.rank[rx] > d.rank[ry]: d.parent[ry] = rx
    default: d.parent[ry] = rx; d.rank[rx]++
    }
    return true
}

硬件 / FPGA 视角

Trie 与并查集在硬件层都有规模化实例:

  • 网络包前缀匹配 (Longest Prefix Match): IP 路由表用 Binary Trie 在 FPGA BRAM 上实现, 性能高 TOE-style;
  • 查找引擎: 各种 Bloom / XOR filter 在 FPGA 上的对应;
  • 并查集在硬件上相对少: 因为并查集是动态结构, FPGA 上更常改用 sync-lines or hardware-class lookup tables.

但 Trie 本身非常适合 BRAM 物化: 每 node 一个 children table = BRAM 一个 row, tree depth = trie route depth, pt 到 page 直接读取 - 这就是 NVMe 上的 flow table 性能 mudpark.

易错

  1. 不初始化 parent[i]=i ⇒ 死循环;
  2. size 在 union 后忘记更新 ⇒ 后续合并按错误的 rank 处理, 平衡破坏;
  3. 半压缩 vs 全压缩: 在所有路径上的节点都是后调用时还要 find 一次, 全压缩更快但递归版深递归可能栈爆;
  4. Trie 节点孩子 map 没回收: 子树删除如果只标 end=false, 节点仍在 → 内存泄漏长尾.

经典题

  • LC 208 实现 Trie;
  • LC 212 单词搜索 II (Trie + DFS 回溯);
  • LC 547 朋友圈数量 (DSU);
  • LC 684 冗余连接 (DSU 找环);
  • LC 990 等式方程可满足性;
  • LC 399 除法求值 (DSU 带权版).

这一章带走的东西

  • Trie 把"字符串集"按前缀压缩成树, 优势是哈希做不出的前缀语义;
  • DSU 的两个优化让每次操作变成 O(α(n));
  • 这两个结构都适合 BRAM 物化, 在 FPGA / 网卡设备上落地;
  • 实际生产代码不要裸写 Trie 除非必要: redis / gin 内部自带 Radix Tree.

下一节 →

图是 DSA 里抽象层次最高的部分. 想清楚一道题是不是"图问题", 往往就能瞬间看出 O(...) 在哪: 是不是边集的形态 (稠密/稀疏)、是不是有负权、是不是要支 odp. 几乎所有"独立单元 + 关联"的工程题 (依赖关系、链路、调度), 背后都是图问题.

图的表示与遍历

一句话

图算法的渐进复杂度都写在纸上——BFS/DFS 是 $O(V+E)$——但真实运行时间由表示方法决定: 同一次邻居遍历, 邻接矩阵是顺序扫描、预取器最爱; 邻接表的 vector<vector<int>> 是两级指针跳转、cache miss 成串; 把同样的信息压成 CSR (Compressed Sparse Row) 扁平数组, 单核每秒就能推数亿条边。所以选表示不是语法偏好, 而是在回答两个问题: 你的算法按什么模式访问边 (查一条边? 还是迭代整个邻接表?), 以及 $E$ 和 $V^2$ 差多少个量级

读完应能:

  1. 给定 $V, E$ 与查询模式, 在邻接矩阵 / 邻接表 / CSR 三者间做有依据的选择;
  2. 手写 CSR 的构建与遍历 (Python + Go), 说清两个扁平数组各自的语义;
  3. 写出 BFS 的关键不变量 ("入队时距离即最终") 并解释为什么标记必须发生在入队时;
  4. 用三色法讲清 DFS 如何一次性判环、求拓扑序、找强连通分量。

思想链

问题: 内存里怎么摆下"谁和谁相连"?
  └─► 访问模式只有两种:
        ├─ "u→v 相连吗?" —— 随机单点查询 ─► 邻接矩阵 O(1) 直接命中
        │     └─► 稀疏时空间爆炸 O(V²) ─► bitset 压缩: V²/64 位, 顺带缓存友好
        └─ "u 的所有邻居挨个来" —— 遍历模式 ─► 邻接表 O(deg(u))
              └─► 但 vector<vector> 是指针追逐, cache 不友好
                    └─► 静态图终极形态: CSR 两个扁平数组
                          ├─ offset[V+1]: 每个点的邻居区间起点
                          └─ to[E]: 邻居目标连续存放 ─► 顺序扫描 + 硬件预取
                                └─► Pregel / Graph500 / cuGraph 的默认格式
                                    └─► 动态加边的代价是全量重建 ─► batched 更新

形式化定义

$G = (V, E)$: $V$ 是顶点集 ($|V| = n$), $E \subseteq V \times V$ 是边集 ($|E| = m$)。三个决定表示选择的量:

  • $\deg(u)$: 与 $u$ 相连的边数;
  • 稀疏性: 真实世界的图几乎都是稀疏的——社交网络、网页链接、依赖关系图的 $m$ 通常在 $O(n)$ 到 $O(n \log n)$ 之间, 远小于上界 $n(n-1) \approx n^2$;
  • 静态性: 构建后是否还频繁增删边。这是 CSR 是否适用的分水岭, 与稀疏性正交。

note

图论语言速查见 离散数学 · 图。本章只关心"怎么存", 存下来之后的四大经典问题 (最短路 / 生成树 / 拓扑与 SCC / 流) 各有专章。

三种表示的取舍

维度邻接矩阵邻接表 (vec<vec<int>>)CSR
空间$O(V^2)$$O(V+E)$ + 每点一个 vector 头$O(V+E)$, 无 per-node 开销
查询 "$u \to v$?"$O(1)$$O(\deg u)$$O(\deg u)$
迭代 $u$ 的邻居$O(V)$$O(\deg u)$$O(\deg u)$
局部性极好 (连续)差 (指针追逐)极好 (两段连续区间)
加边$O(1)$均摊 $O(1)$全量重建 $O(V+E)$
适用稠密 / 频繁查边原型开发 / 动态图静态图性能敏感场景

经验法则:

  1. 先看访问模式再选结构。Floyd-Warshall 这类"到处问 $i \to k$ 相连吗"的算法天然配矩阵; BFS/Dijkstra 这类"展开当前点邻居"的算法配表式结构;
  2. 稠密图用矩阵反而更快: $E \approx V^2$ 时矩阵的空间代价不再吃亏, 而连续内存让每次扫描都是 cache 行级命中;
  3. 分水岭大致在 $E \approx V^2/64$: 用 bitset 存矩阵 (每行 $V/64$ 个字), 空间压到 $O(V^2/64)$ 且能按字并行 (Floyd 传递闭包、三角计数都靠它加速);
  4. CSR 是"读密集 + 静态"的最优解, 但它不支持就地改图——工程上的标准答案是攒一批边批量重建 (batched update), 或者用"分块 CSR"把新边挂在增量块里。

CSR: 性能图的默认格式

把邻接表"拍平"成两个数组。设 4 个点、边为 $0\to1,\ 0\to2,\ 1\to3,\ 3\to0,\ 3\to2,\ 3\to3$:

offset (长度 V+1): [0, 2, 3, 3, 6]
to      (长度 E):  [1, 2, 3, 0, 2, 3]

点 u 的邻居 = to[ offset[u] .. offset[u+1] )
  点 0 → to[0..2)  = {1, 2}
  点 1 → to[2..3)  = {3}
  点 2 → to[3..3)  = {}          (空区间 = 出度为 0)
  点 3 → to[4..6)? 不对, 是 to[3..6) = {0, 2, 3}

语义一句话: offset前缀和 (累计到每个点为止有多少条边), to 是按源点排序后的边目标列表。带权图再加一个平行数组 w[E] 即可。

Python: 构建 + BFS

from collections import deque


def build_csr(n: int, edges: list[tuple[int, int]]) -> tuple[list[int], list[int]]:
    """边表 → CSR. 两遍扫描: 先桶计数做前缀和, 再按源点散布目标."""
    offset = [0] * (n + 1)
    for u, _ in edges:
        offset[u + 1] += 1               # 桶计数: offset[u+1] = u 的出度
    for i in range(1, n + 1):
        offset[i] += offset[i - 1]       # 原地前缀和
    to = [0] * len(edges)
    fill = offset[:]                     # fill[u] = 点 u 的下一个空槽位
    for u, v in edges:
        to[fill[u]] = v
        fill[u] += 1
    return offset, to


def bfs_csr(offset: list[int], to: list[int], s: int) -> list[int]:
    """CSR 上的 BFS: 返回 dist 数组 (-1 = 不可达). O(V + E)."""
    n = len(offset) - 1
    dist = [-1] * n
    dist[s] = 0
    q = deque([s])
    while q:
        u = q.popleft()
        for i in range(offset[u], offset[u + 1]):   # 顺序扫一段连续内存
            v = to[i]
            if dist[v] == -1:
                dist[v] = dist[u] + 1               # 入队时标记 = 距离即最终
                q.append(v)
    return dist


if __name__ == "__main__":
    edges = [(0, 1), (0, 2), (1, 3), (3, 0), (3, 2), (3, 3)]
    offset, to = build_csr(4, edges)
    assert offset == [0, 2, 3, 3, 6] and to == [1, 2, 3, 0, 2, 3]
    assert bfs_csr(offset, to, 0) == [0, 1, 1, 2]
    assert bfs_csr(offset, to, 3) == [1, 2, 1, 0]   # 自环 (3,3) 天然被 visited 挡掉
    assert bfs_csr(offset, to, 2) == [-1, -1, 0, -1]

Go

package main

import (
	"fmt"
)

// CSR 以两个扁平数组存图; 只适合静态图, 改边需整体重建.
type CSR struct {
	Offset []int // 长度 V+1, Offset[u]..Offset[u+1] 是 u 的邻居区间
	To     []int // 长度 E, 按源点排序的边目标
	W      []int // 可选: 平行权重数组
}

// BuildCSR 从边表构建; 两遍: 先计数前缀和, 再散布目标.
func BuildCSR(n int, edges [][2]int) *CSR {
	offset := make([]int, n+1)
	for _, e := range edges {
		offset[e[0]+1]++
	}
	for i := 1; i <= n; i++ {
		offset[i] += offset[i-1]
	}
	to := make([]int, len(edges))
	fill := append([]int(nil), offset...)
	for _, e := range edges {
		to[fill[e[0]]] = e[1]
		fill[e[0]]++
	}
	return &CSR{Offset: offset, To: to}
}

// BFS 返回从 s 出发的跳数 (-1 不可达). O(V+E).
func (g *CSR) BFS(s int) []int {
	n := len(g.Offset) - 1
	dist := make([]int, n)
	for i := range dist {
		dist[i] = -1
	}
	dist[s] = 0
	queue := []int{s}
	for len(queue) > 0 {
		u := queue[0]
		queue = queue[1:]
		for i := g.Offset[u]; i < g.Offset[u+1]; i++ {
			if v := g.To[i]; dist[v] == -1 {
				dist[v] = dist[u] + 1
				queue = append(queue, v)
			}
		}
	}
	return dist
}

func main() {
	g := BuildCSR(4, [][2]int{{0, 1}, {0, 2}, {1, 3}, {3, 0}, {3, 2}, {3, 3}})
	fmt.Println(g.Offset) // [0 2 3 3 6]
	fmt.Println(g.To)     // [1 2 3 0 2 3]
	fmt.Println(g.BFS(0)) // [0 1 1 2]
}

为什么快:内层循环 to[offset[u]..offset[u+1])一段严格连续的内存, 硬件预取器可以完美工作; 而 vector<vector<int>> 每换一个点要跳两次指针 (外层 vector → 内层 vector → 元素), 三次访存三次潜在 miss。这就是 Graph500、Pregel、cuGraph 等图基础设施全部以 CSR 为核心格式的理由。

BFS: 不变量与多源变体

关键不变量:队列里的点按距离分层递增, 且入队那一刻写入的 dist 就是最终答案 (边权全为 1 时 BFS 就是 Dijkstra, 见 最短路径)。

由此推出两条铁律:

  1. 标记必须在入队时做。等出队才标记, 同一点会被多个父节点重复入队, 队列膨胀到 $O(E)$;
  2. 队列的单调性使任何时刻队列最多两层混合——这是"01-BFS"(0/1 边权双端队列) 与分层并行的理论基础。

多源 BFS: 所有源点一起以 dist=0 入队, 一趟跑完得到"到最近源的距离"。火灾蔓延、腐烂橘子、"01 矩阵每个点到最近的 0"全是它——比逐个源跑 BFS 再取 min 便宜一个 $V$ 因子。

DFS: 三色法与它的三大产出

递归版最直观, 但生产代码用显式栈防爆栈 (Python 默认递归深度 1000):

def dfs_iter(n: int, adj: dict[int, list[int]], s: int) -> tuple[list[int], bool]:
    """迭代 DFS + 三色. 返回 (黑节点完成序, 是否有环).
    color: 0=白(未访问) 1=灰(在当前栈上) 2=黑(已完成)"""
    color = [0] * n
    order, has_cycle = [], False
    color[s] = 1                                   # 入栈即灰
    stack = [(s, iter(adj.get(s, ())))]
    while stack:
        u, it = stack[-1]
        for v in it:
            if color[v] == 0:                      # 树边: 白 → 灰, 下钻
                color[v] = 1
                stack.append((v, iter(adj.get(v, ()))))
                break
            elif color[v] == 1:                    # 指向灰 = 反向边 = 有环
                has_cycle = True
        else:                                      # 迭代器耗尽: 邻居全试完
            color[u] = 2                           # 变黑并记录完成序
            order.append(u)
            stack.pop()
    return order, has_cycle


if __name__ == "__main__":
    # 0 → 1 → 2 → 0: 有环; 完成序 [2, 1, 0]
    assert dfs_iter(3, {0: [1], 1: [2], 2: [0]}, 0) == ([2, 1, 0], True)
    # DAG 0→1, 0→2, 1→2: 无环; 完成序 [2, 1, 0], 逆序即拓扑序
    assert dfs_iter(3, {0: [1, 2], 1: [2]}, 0) == ([2, 1, 0], False)

三色的语义与产出:

颜色含义边指向该颜色意味着
未访问树边 (DFS 树的一部分)
在当前递归栈上反向边 ⇒ 有环 (指向祖先)
已完成横叉/前向边 (DAG 判定时无害)

一次 DFS 免费送三样东西:判环 (见灰即环)、拓扑序 (黑节点完成时间的逆序, 见 拓扑排序)、强连通分量 (Tarjan/Kosaraju 的骨架)。无向图还有第四样: 桥与割点。

warning

迭代版最微妙的坑: 发现反向边后不能 break, 必须让当前迭代器继续耗尽, 否则该点的其余邻居被静默跳过。上面代码用 for-else 表达"迭代器自然耗尽才完成"——把 else 误写成循环体外的普通语句是这一模式的高频 bug。若只判环不要求遍历序, 用 Kahn 入度法更简单 (见 拓扑排序)。

硬件视角: 遍历的吞吐阶梯

同一个 $O(V+E)$ 的 BFS, 换一层硬件差出一个数量级以上:

  • 单核 CPU + CSR: 顺序扫描 + 预取, 数亿边/秒是常态; 换 vector<vector> 指针追逐通常掉 5–10 倍;
  • GPU level-synchronous BFS: 按"层"并行——当前 frontier 整体载入, 一步扩展出下一层。方向优化 (frontier 小用 push、大用 pull 翻转方向) 后, 现代 GPU 上可达数十至数百 GTEPS (十亿边/秒);
  • Graph500 基准: 冠军系统 (超算级集群) 已达万 GTEPS 量级——差距不在算法 (都是 BFS), 在数据布局与通信。

共同规律: 越往硬件走, 越要把"指针结构"换成"扁平数组 + 分层批量处理"。这与 CPU 分支预测、GPU SIMT 的偏好完全一致 (见 存储层级)。

易错清单

  1. visited 标记时机: BFS 入队时标, 不是出队时; 否则同一节点重复入队, 队列退化 $O(E)$;
  2. 无向图加边两次: u→vv→u 都要建; 用 CSR 时边数组直接放两倍长;
  3. 点编号 0/1 起点: 读题先确认, "为什么 RE/OOB" 的头号来源;
  4. DFS 递归爆栈: Python 默认 1000 层; 链状图 $10^5$ 点必炸, 改显式栈或迭代加深;
  5. 空区间: CSR 中出度 0 的点是 offset[u] == offset[u+1], 循环自然跳过, 别写 offset[u+1]-1;
  6. 动态图硬上 CSR: 频繁加边请用邻接表攒批, 定期重建 CSR; 逐条重建是 $O(V+E)$ 每次。

经典题

  • LC 200 岛屿数量 (网格隐式图 + flood fill);
  • LC 994 腐烂橘子 (多源 BFS 标准题);
  • LC 133 克隆图 (遍历时同步复制);
  • LC 207 / 210 课程表 I/II (判环 + 拓扑序);
  • LC 785 判断二分图 (二染色, BFS/DFS 均可);
  • LC 542 01 矩阵 (多源 BFS)。

一页速查

选择:  查单边频繁/稠密 → 矩阵(bitset);  原型/动态 → vec<vec>;  静态+性能 → CSR
CSR:   offset[V+1]=前缀和, to[E]=按源排序的目标;  邻居=to[offset[u]..offset[u+1])
构建:  两遍: 度数前缀和 → 按源散布;  加权再加平行 w[E]
BFS:   入队即标记, dist 入队时即最终;  多源 = 全部源 dist 0 一起入队
DFS:   白灰黑; 见灰=有环; 黑的逆序=拓扑序; Tarjan/割桥全在这套状态机上
硬件:  扁平数组+分层批量 = 预取/SIMT 友好; 指针追逐是万恶之源

下一节 → 最短路径

最短路径: Dijkstra / Bellman-Ford / Floyd

一句话

最短路径的算法选择不是"背复杂度表", 而是看清每个算法到底在利用图的什么结构: Dijkstra 利用"非负权 ⇒ 弹出的点不会再变短"这一贪心不变量; Bellman-Ford 放弃贪心, 改用"逐轮松弛"的动态规划, 从而容忍负边; Floyd-Warshall 把问题定义成"只许经过前 k 个中间点"的区间 DP, 用 $O(V^3)$ 换全源答案; Johnson 再用一次 Bellman-Ford 给边重加权, 把负权图改造成非负权图让 Dijkstra 合法; A* 在 Dijkstra 上加"可采纳启发函数", 少扩展没希望的节点。选错算法的代价是数量级, 选对的关键只有一个问句——图里有没有负边? 要单源还是全源?

读完应能:

  1. 说出 Dijkstra 贪心不变量的精确表述, 并构造负边反例证明它为什么必须非负;
  2. 解释 Bellman-Ford "第 $k$ 轮结束至少确定了 $k$ 条边的最短路", 以及第 $V$ 轮仍能松弛为何等价于存在负环;
  3. 默写堆优化 Dijkstra (Python + Go), 说清懒删除为什么不影响正确性与复杂度;
  4. 推导 Johnson 重加权公式 $w'(u,v) = w(u,v) + h(u) - h(v)$ 为什么不改变最短路结构。

思想链

问题: 带权图上 s→t 总代价最小的路径?
  └─► 图里有负边吗?
        ├─ 没有
        │    └─► 不变量成立: 从优先队列弹出的点距离已最终化
        │          └─► Dijkstra: 贪心 + 懒删除堆  O((V+E) log V)
        │                ├─► 有好的下界估计 h? ─► A* 只扩展有希望的节点
        │                └─► 稠密图 E≈V² ? ─► 朴素 O(V²) 版反而更快 (省 log V 与堆开销)
        ├─ 有 (但无负环)
        │    └─► 贪心失效: 弹出后还能被负边再缩短
        │          └─► Bellman-Ford: 松弛全部边 V-1 轮  O(VE)
        │                ├─► 队列只入改进点 = SPFA (均摊快, 最坏仍 O(VE))
        │                └─► 第 V 轮还能松弛 ⇔ 存在负环 (套利 / 死锁检测)
        └─ 全源都要?
             ├─ 稠密小图: Floyd-Warshall 中间点 DP  O(V³), 常数极小
             └─ 稀疏大图: Johnson = BF 重加权 → V 次 Dijkstra  O(VE + VE log V)

形式化定义

给定带权图 $G = (V, E)$, 权函数 $w: E \to \mathbb{R}$。路径 $p = \langle v_0, v_1, \dots, v_k \rangle$ 的权重是 $w(p) = \sum_{i=0}^{k-1} w(v_i, v_{i+1})$。最短路径就是 $w(p)$ 在所有 $u \leadsto v$ 路径中取最小的那条, 记 $\delta(u, v)$ 为最短距离。

两个前提值得显式写出:

  1. 最优子结构:最短路的任何前缀也是最短路——否则把更优的前缀拼上来就得到矛盾。这是一切 DP / 贪心解法的合法性来源;
  2. 无负环前提:只要图中存在总权为负的环路, "最短路径"本身就不存在 (绕一圈更便宜, 可以无限绕)。所以负权算法的全部工作都隐含一件事: 先检测或先排除负环。$\delta(u,v) = -\infty$ 当且仅当 $u, v$ 都在某负环上可达/可达自。

note

三角不等式 $\delta(u, v) \le \delta(u, x) + w(x, y) + \delta(y, v)$ 是所有松弛操作的共同灵魂:松弛 (relax) 就是检查这条不等式是否严格成立, 成立则用右边更新左边。Dijkstra / Bellman-Ford / Floyd 的差异只在"按什么顺序松弛哪些不等式"。

Dijkstra: 贪心的边界在哪里

核心不变量:每次从优先队列弹出距离最小的未定节点 $u$ 时, $\text{dist}[u] = \delta(s, u)$ 已经是最终答案。

为什么需要非负权——归纳论证的最后一步依赖它: 设弹出 $u$ 时存在更短路径 $P$, 则 $P$ 必须经过某个尚未弹出的点 $y$; 取 $P$ 上第一个未弹出点 $y$, 其前缀已经确定且 $\ge \text{dist}[y] \ge \text{dist}[u]$ (堆按最小弹出)。到这里都没问题; 但要完成"$P$ 比 $\text{dist}[u]$ 短"的推理, 需要 $\text{dist}[y] + (\text{后续路径}) \ge \text{dist}[y]$, 即后续路径权非负。一旦允许负边, "$y$ 之后还能一路减下去", 不变量当场失效。

反例:      s --2--> a --(-3)--> b
Dijkstra:  弹 s(dist 0) → 弹 a(dist 2, 标记最终) → 弹 b(dist inf? 或 2-3=-1?)
若 b 经 s 直达不存在, 正确答案是 dist[b] = -1, 但 a 已被"最终化",
b 的真实路径必须回头穿过已最终化的 a —— 贪心序被负边破坏。

Python (堆优化 + 懒删除)

import heapq


def dijkstra(g: dict[int, list[tuple[int, int]]], s: int) -> dict[int, int]:
    """g[u] = [(v, w), ...]. 返回 {v: δ(s,v)}. O((V+E) log V), 仅限非负权."""
    dist = {s: 0}
    pq = [(0, s)]
    while pq:
        d, u = heapq.heappop(pq)
        if d > dist.get(u, float("inf")):
            continue                       # 过期条目: 同一点被更好副本替代过
        for v, w in g[u]:
            nd = d + w
            if nd < dist.get(v, float("inf")):
                dist[v] = nd
                heapq.heappush(pq, (nd, v))  # 不删旧条目, 弹出时跳过即可 (懒删除)
    return dist


if __name__ == "__main__":
    # CLRS 经典算例: 顶点 0=s,1=t,2=x,3=y,4=z
    g = {
        0: [(1, 10), (3, 5)],
        1: [(2, 1), (3, 2)],
        2: [(4, 4)],
        3: [(1, 3), (2, 9), (4, 2)],
        4: [(0, 7)],
    }
    assert dijkstra(g, 0) == {0: 0, 1: 8, 2: 9, 3: 5, 4: 7}

Go

package main

import (
	"container/heap"
	"fmt"
	"math"
)

type item struct{ node, dist int }

type priorityQueue []item

func (pq priorityQueue) Len() int           { return len(pq) }
func (pq priorityQueue) Less(i, j int) bool { return pq[i].dist < pq[j].dist }
func (pq priorityQueue) Swap(i, j int)      { pq[i], pq[j] = pq[j], pq[i] }
func (pq *priorityQueue) Push(x any)        { *pq = append(*pq, x.(item)) }
func (pq *priorityQueue) Pop() any {
	old := *pq
	n := len(old)
	it := old[n-1]
	*pq = old[:n-1]
	return it
}

type edge struct{ to, w int }

// Dijkstra 返回从 s 出发的单源最短距离. 仅限非负权. O((V+E) log V).
func Dijkstra(g [][]edge, s int) []int {
	dist := make([]int, len(g))
	for i := range dist {
		dist[i] = math.MaxInt
	}
	dist[s] = 0
	pq := &priorityQueue{{s, 0}}
	for pq.Len() > 0 {
		it := heap.Pop(pq).(item)
		if it.dist > dist[it.node] { // 过期条目: 懒删除
			continue
		}
		for _, e := range g[it.node] {
			if nd := it.dist + e.w; nd < dist[e.to] {
				dist[e.to] = nd
				heap.Push(pq, item{e.to, nd})
			}
		}
	}
	return dist
}

func main() {
	g := make([][]edge, 5)
	add := func(u, v, w int) { g[u] = append(g[u], edge{v, w}) }
	add(0, 1, 10)
	add(0, 3, 5)
	add(1, 2, 1)
	add(1, 3, 2)
	add(2, 4, 4)
	add(3, 1, 3)
	add(3, 2, 9)
	add(3, 4, 2)
	add(4, 0, 7)
	fmt.Println(Dijkstra(g, 0)) // [0 8 9 5 7]
}

tip

复杂度里的 $\log$ 因子是可以"买断"的: 稠密图 $E \approx V^2$ 时, 用数组扫描代替堆的朴素 Dijkstra 是 $O(V^2)$, 优于堆版 $O(V^2 \log V)$; 反过来稀疏图 $E = O(V)$ 时堆版的 $\log$ 因子几乎白拿。先估 $E$ 和 $V$ 的量级, 再决定要不要堆。

Bellman-Ford: 放弃贪心, 换取负边容忍

核心思想:不再假设"弹出即最终", 而是无差别地松弛所有边, 重复 $V-1$ 轮。

正确性关键引理:第 $k$ 轮结束时, 所有用至多 $k$ 条边能达到的点, 其距离已是正确的 $\delta$。归纳可得: 无负环时任何最短路至多 $V-1$ 条边, 所以 $V-1$ 轮必然收敛。这也解释了负环检测——第 $V$ 轮仍有边可松弛, 说明存在"越走越短"的环, 因为一条合法最短路永远不需要重复顶点。

def bellman_ford(edges: list[tuple[int, int, int]], n: int, s: int):
    """edges = [(u, v, w), ...]. 返回 (dist, ok); ok=False 表示存在负环. O(V·E)."""
    INF = float("inf")
    dist = [INF] * n
    dist[s] = 0
    for _ in range(n - 1):
        updated = False
        for u, v, w in edges:
            if dist[u] + w < dist[v]:      # 松弛: 三角不等式当前不成立
                dist[v] = dist[u] + w
                updated = True
        if not updated:                    # 提前收敛: 后续轮次全是空转
            break
    for u, v, w in edges:
        if dist[u] + w < dist[v]:
            return dist, False             # 第 V 轮仍能松弛 ⇒ 负环
    return dist, True


if __name__ == "__main__":
    # CLRS 经典算例: 顶点 0=s,1=t,2=x,3=y,4=z
    edges = [(0, 1, 6), (0, 3, 7), (1, 2, 5), (1, 3, 8), (1, 4, -4),
             (2, 1, -2), (3, 2, -3), (3, 4, 9), (4, 2, 7), (4, 0, 2)]
    dist, ok = bellman_ford(edges, 5, 0)
    assert ok and dist == [0, 2, 4, 7, -2]    # 负边让绕路更便宜
    neg = [(0, 1, 1), (1, 2, -3), (2, 0, 1)]  # 0→1→2→0 总权 -1: 负环
    assert bellman_ford(neg, 3, 0)[1] is False

SPFA (Shortest Path Faster Algorithm) 是 Bellman-Ford 的队列版: 只把"本轮距离真的变了"的点重新入队。平均表现接近 $O(E)$, 但最坏仍是 $O(VE)$, 且竞赛中存在专门卡 SPFA 的构造数据 (网格套菊花图)。工程结论: 负权场景直接写 Bellman-Ford 更稳, SPFA 的均摊承诺不可依赖。

warning

  1. 负环检测要求全图可达: 若负环不与源连通, 以 $s$ 为源的 Bellman-Ford 测不出来; 全图检测需加超级源点连向所有点 (边权 0);
  2. SPFA 判负环用 cnt[v] ≥ V (入队次数) 比"第 $V$ 轮松弛"更常用, 二者等价但前者实现更简单;
  3. 浮点权重的"相等"判断: 松弛条件 < 在浮点下可能因舍入震荡不收敛, 关键场景给 $\epsilon$ 容差。

Floyd-Warshall: 中间点集合上的区间 DP

换一个状态定义, 问题瞬间变成经典 DP:

$$d^{(k)}[i][j] = \text{只允许以} {0..k} \text{中的点作为中间点时, } i \to j \text{ 的最短距离}$$

转移只有一行: $d^{(k)}[i][j] = \min(d^{(k-1)}[i][j],; d^{(k-1)}[i][k] + d^{(k-1)}[k][j])$ —— 要么不经过 $k$, 要么恰好经过一次。因为 $d^{(k)}[\cdot][k]$ 与 $d^{(k)}[k][\cdot]$ 不会因加入 $k$ 而变化 (中间点是 $k$ 自身没有意义), 滚动掉第一维后得到著名的 $k \to i \to j$ 三层循环顺序——注意 $k$ 必须在最外层, 写错循环顺序是这个算法唯一的坑。

def floyd(n: int, edges: list[tuple[int, int, int]]):
    """返回 (dist, has_neg_cycle). O(V³), 稠密小图的全源首选."""
    INF = float("inf")
    d = [[INF] * n for _ in range(n)]
    for i in range(n):
        d[i][i] = 0
    for u, v, w in edges:
        d[u][v] = min(d[u][v], w)          # 重边取 min
    for k in range(n):                     # k 必须最外层!
        for i in range(n):
            for j in range(n):
                if d[i][k] + d[k][j] < d[i][j]:
                    d[i][j] = d[i][k] + d[k][j]
    has_neg_cycle = any(d[i][i] < 0 for i in range(n))
    return d, has_neg_cycle


if __name__ == "__main__":
    d, has_neg_cycle = floyd(4, [(0, 1, 5), (0, 3, 10), (1, 2, 3), (2, 3, 1)])
    assert not has_neg_cycle
    assert d[0][3] == 9 and d[0][2] == 8            # 0→1→2→3 优于直达 10
    _, bad = floyd(2, [(0, 1, 1), (1, 0, -2)])      # 0⇄1 总权 -1: 负环
    assert bad

三个免费的副产品,让它成为 $V \le$ 几百时的瑞士军刀:

  • 传递闭包:把 $\min$ 换成逻辑 or (reach[i][j] |= reach[i][k] & reach[k][j]), 就是 Warshall 原始算法; bitset 加速后每轮 $O(V^2/64)$;
  • 无向图最小环:在 $k$ 外层、$ij$ 内层更新之前, 用 $d[i][j] + w(j,k) + w(k,i)$ 尝试更新全局最小环 (环上最大编号点恰为 $k$);
  • 负环判定:跑完后 $d[i][i] < 0$ 即存在。

note

Floyd 与矩阵乘法同构: 把 $(\min, +)$ 看作半环上的"乘法", Floyd 就是在算图的邻接矩阵的 $V-1$ 次幂——和快速幂加速线性递推是同一个代数结构 (见 离散数学 · 代数结构)。

Johnson: 让 Dijkstra 在负权图上合法

既要全源、又想用 Dijkstra (稀疏图), 办法是重加权:

  1. 加超级源点 $q$, 连边 $q \to v$ 权 0, 跑一遍 Bellman-Ford 得 $h(v) = \delta(q, v)$ (同时完成负环检测);
  2. 定义新权 $w'(u, v) = w(u, v) + h(u) - h(v)$。由三角不等式 $h(v) \le h(u) + w(u,v)$ 得 $w'(u,v) \ge 0$;
  3. 对每个源跑 Dijkstra。

为什么不改变最短路结构: 任意路径 $p: u \leadsto v$ 的重加权总增量是望远镜级数——

$$w'(p) = w(p) + h(u) - h(v)$$

中间项全部相消, 每条 $u \leadsto v$ 路径都被加上同一个常数, 大小关系不变。总复杂度 $O(VE + VE\log V)$, 对稀疏图远优于 Floyd 的 $O(V^3)$。这正是 网络流 MCMF 里"Dijkstra + 势函数"的原型: 势函数 $h$ 一旦求出, 后续每次费用流增广只需维护它, 不必重新 Bellman-Ford。

DAG 与 A*: 两类"结构红利"

DAG 上的单源最短路: 按拓扑序松弛一遍即可, $O(V+E)$, 且天然支持负权 (无环 ⇒ 无负环)。这是 拓扑排序 直接变现的场景——关键路径 (CPM/PERT 工期)、依赖图上的累计成本都是它。记住这个特例: 只要图是 DAG, 别碰堆。

A*: Dijkstra 的目标导向版。优先队列键从 $g(u)$ (已走距离) 改成 $f(u) = g(u) + h(u)$ ($h$ = 到目标的估计下界):

  • $h$ 可采纳 (admissible, 永不高估): 保证最优;
  • $h$ 一致 (consistent, 满足 $|h(u) - h(v)| \le w(u,v)$): 弹出即最终, 无需重开节点。一致性蕴含可采纳性;
  • $h \equiv 0$ 时退化为 Dijkstra; $h$ 越准剪得越多。地图寻路 (欧氏距离)、拼图 (曼哈顿距离错位数)、Word Ladder 都是标准应用。

工程视角: 协议与硬件里的最短路

  • 路由协议就是这两个算法的地盘: RIP 是分布式 Bellman-Ford ("距离向量", 邻居间交换整张距离表, 收敛慢且有"坏消息传得慢"的计数到无穷问题); OSPF 是 Dijkstra ("链路状态", 全网洪泛链路状态后各自独立计算)。对比细节见 BGP 与 OSPF;
  • GPU 上的 SSSP: 优先队列难并行, 实战转向 Delta-Stepping (按 $\Delta$ 分桶, 轻边批量松弛) 或干脆并行 Bellman-Ford (每轮全边松弛天然数据并行); 数千万边的图上 GPU Bellman-Ford 常比 CPU 单线程 Dijkstra 快一个数量级以上。GPU 执行模型见 GPU 架构;
  • 延迟敏感场景 (游戏匹配、CDN 调度): 常用"收缩层级 + 双向搜索" (Contraction Hierarchies), 预处理换毫秒级查询——又一次印证"预处理 vs 查询"的通用折中。

易错清单

  1. 负边 + Dijkstra = 错误答案但不报错: 结果看起来合理, 实际部分点偏大; 有负边嫌疑一律先 Bellman-Ford;
  2. Floyd 循环顺序: $k$ 必须最外层, 内层 $i, j$ 顺序随意; 写错会用到"尚未引入 $k$"的错误中间值;
  3. 懒删除堆积: Python/Go 堆版不删旧条目, 极端稠密图堆大小 $O(E)$; 内存紧张时改用索引堆 (decrease-key);
  4. 重边: 邻接表存图时重边都保留即可 (松弛自然取 min); 邻接矩阵记得 min;
  5. 负环 ≠ 有负边: 有负边不一定有负环; 只有负环才让最短路无定义;
  6. A* 用了不可采纳的 h: 为了快而高估, 得到的只是"看起来合理"的次优路, 且无法察觉。

经典题

  • LC 743 网络延迟时间 (Dijkstra 模板);
  • LC 787 K 站中转内最便宜的航班 (限制边数 = Bellman-Ford 天然主场);
  • LC 1334 阈值距离内邻居最少的城市 (Floyd 全源);
  • LC 1631 最小体力消耗路径 (二分 + BFS / 类 Dijkstra max-min 变体);
  • LC 1928 规定时间内到达目的地的最少花费 (状态分层 + Dijkstra);
  • 洛谷 P3385 【模板】负环 (SPFA/Bellman-Ford 判负环);
  • 洛谷 P4779 【模板】单源最短路径 (标准版 Dijkstra);
  • POJ 1860 Currency Exchange (负环 ⇔ 套利)。

一页速查

判型:    有负边? → BF/SPFA/Johnson;  无负边? → Dijkstra(+A*)
         全源稠密小图 → Floyd O(V³);  全源稀疏图 → Johnson
         DAG → 拓扑序松弛 O(V+E), 支持负权
不变量:  Dijkstra 弹出即最终 ← 需要非负权 (反例: 2 → (-3) 回头路)
BF:      第 k 轮确定 ≤k 条边的最短路; 第 V 轮仍能松弛 ⇔ 负环
Floyd:   d[k][i][j] = min(d[k-1][i][j], d[k-1][i][k]+d[k-1][k][j]); k 在最外层
Johnson: w'(u,v)=w+h(u)-h(v) ≥ 0; 路径增量恒为 h(u)-h(v), 结构不变
A*:      f=g+h; h 可采纳保证最优, h 一致保证弹出即最终
工程:    RIP=分布式BF, OSPF=Dijkstra; GPU=Delta-stepping/并行BF; CH=预处理换查询

下一节 → 最小生成树

最小生成树 (MST)

一句话

给定带权无向连通图 $G = (V, E)$, 找一棵包含全部点、只有 $|V|-1$ 条边、权值和最小的树。两个经典算法 Kruskal (按边贪心 + 并查集判环) 和 Prim (按点扩张切分) 是同一个定理——切性质——的两种实现顺序: 一个把边排序后全局扫描, 一个从一点出发局部生长。MST 是"可证明的贪心"最干净的教学样本 (见 贪心): 每一步局部最优都永远不会被推翻。

读完应能:

  1. 写出并证明 cut property, 再由它一步推出 Kruskal 与 Prim 的正确性;
  2. 用 Python / Go / C++ 三语言写出 Kruskal (并查集) 与堆优化 Prim;
  3. 说出瓶颈生成树与 minimax 路径性质, 并知道它们解决什么问题;
  4. 按稠密/稀疏/分布式三种场景选对算法。

思想链

问题: n 个点连成一体的最小代价?
  └─► 树 = 无环 + 连通, n-1 条边
        └─► 核心引理: cut property —— 任一切的最小横切边必在某棵 MST 里
              ├─ 按"边"用: 全部边排序, 从小到大试, 并查集防环   → Kruskal O(E log E)
              ├─ 按"点"用: 维护已连集合 S, 每次取跨 S 的最小边   → Prim    O(E log V)
              └─ 所有块同时取最小出边                            → Boruvka O(E log V), 天然并行
                    └─► 推论: MST 也是最小瓶颈生成树 → minimax 路径 / 聚类

形式化定义

生成树:无向连通图 $G$ 的一个无环连通子图 $T$, 恰含 $|V|-1$ 条边且覆盖所有顶点。最小生成树是其权值和 $\sum_{e \in T} w(e)$ 最小者。

  • 生成树存在 $\iff$ 图连通 ($n-1$ 条边的连通图必为树);
  • 权互不相同 $\Rightarrow$ MST 唯一; 有相同权时 MST 可能不唯一, 但所有 MST 的权值和相同, 且各 MST 的边权多重集也相同。

切性质: 一切正确性的来源

Cut property: 设 $(S, \bar{S})$ 是任意一个把顶点分成两半的非空切, $e$ 是横跨该切的权最小边, 则存在一棵包含 $e$ 的 MST。

证明 (交换论证):任取一棵 MST $T$。若 $e \in T$ 已完成;否则把 $e$ 加入 $T$, 得到唯一的环 $C$。环上两点 $u \in S, v \in \bar{S}$ 从一侧走到另一侧必然再跨越切一次, 故 $C$ 上存在另一条横切边 $f \neq e$。由 $e$ 是最小横切边知 $w(f) \ge w(e)$。令 $T' = T - f + e$: 仍是生成树, 且 $w(T') = w(T) + w(e) - w(f) \le w(T)$。$T$ 本是最优, 所以 $T'$ 也是 MST 且包含 $e$。$\blacksquare$

两个直接推论:

  1. Cycle property: 环上权最大的边不属于任何 MST (权互异时)。因为把它换掉只会更优——这是 Kruskal "跳过成环边" 的合法性证明;
  2. Prim/Kruskal 正确性: Kruskal 每次选的边都是"已选集合 vs 其余"这个切的唯一最小横切边; Prim 每次选的是 "$S$ vs $\bar{S}$" 的最小横切边。两者都在 cut property 保证下工作。

note

注意结论只是"存在含 $e$ 的 MST", 不是"每棵 MST 都含 $e$"。当有并列最小权时两种选择都可能最优——这就是次小生成树只差一条边的根源。

warning

cut property 要求非空两侧。若把切取成 $(\emptyset, V)$ 或 $(V, \emptyset)$, "横切边"根本不存在, 定理空洞成立但不能用来推理任何算法步骤。

Kruskal: 按边贪心 + 并查集

所有边按权升序排序;
从小到大扫: 若两端不在同一连通块 → 选入 MST, 并查集合并; 否则跳过;
选满 n-1 条即停.

复杂度 $O(E \log E)$, 主导是排序; 并查集 + 路径压缩让每次 union/find 摊还 $O(\alpha(n))$ (细节见 字典树与并查集)。"跳过成环边"正是 cycle property: 该边已是所在环的最大边。

Python

def kruskal(n: int, edges: list[tuple[int, int, int]]) -> tuple[int, list]:
    """edges: [(u, v, w)]. 返回 (权和, 选中的边). O(E log E).
    图不连通时返回最小生成森林 (选不满 n-1 条), 需自行检查 len(chosen)."""
    parent = list(range(n))

    def find(x: int) -> int:
        while parent[x] != x:
            parent[x] = parent[parent[x]]        # 路径折半压缩
            x = parent[x]
        return x

    total, chosen = 0, []
    for u, v, w in sorted(edges, key=lambda e: e[2]):
        ru, rv = find(u), find(v)
        if ru == rv:
            continue                             # 已连通: 此边会成环, 跳过
        parent[ru] = rv
        total += w
        chosen.append((u, v, w))
        if len(chosen) == n - 1:                 # 早停: 凑齐即止
            break
    return total, chosen


assert kruskal(4, [(0, 1, 1), (1, 2, 2), (0, 2, 3), (2, 3, 4)])[0] == 7

Go

package main

import "sort"

type mstEdge struct{ u, v, w int }

func find(parent []int, x int) int {
	for parent[x] != x {
		parent[x] = parent[parent[x]]
		x = parent[x]
	}
	return x
}

// Kruskal: O(E log E). 返回总权与选中边数; cnt < n-1 说明原图不连通.
func kruskal(n int, edges []mstEdge) (total, cnt int) {
	sort.Slice(edges, func(i, j int) bool { return edges[i].w < edges[j].w })
	parent := make([]int, n)
	for i := range parent {
		parent[i] = i
	}
	for _, e := range edges {
		ru, rv := find(parent, e.u), find(parent, e.v)
		if ru == rv {
			continue
		}
		parent[ru] = rv
		total += e.w
		if cnt++; cnt == n-1 {
			break
		}
	}
	return total, cnt
}

func main() {
	total, cnt := kruskal(4, []mstEdge{{0, 1, 1}, {1, 2, 2}, {0, 2, 3}, {2, 3, 4}})
	println(total, cnt) // 7 3
}

C++ (原版保留)

struct Edge { int u, v, w; };
vector<int> p;
int find(int x) { return p[x]==x ? x : p[x]=find(p[x]); }

int kruskal(vector<Edge>& E, int n) {
    sort(E.begin(), E.end(), [](auto& a, auto& b){ return a.w<b.w; });
    p.resize(n); iota(p.begin(), p.end(), 0);
    int total = 0, cnt = 0;
    for (auto& e: E) {
        int ru = find(e.u), rv = find(e.v);
        if (ru==rv) continue;
        p[ru]=rv; total += e.w;
        if (++cnt==n-1) break;
    }
    return total;
}

Prim: 按点扩张

任选起点 s; 维护已在树中的集合 S;
每步从所有横切边 (u ∈ S, v ∉ S) 中取最小权边, 把 v 并入 S;
直到 n 个点全部加入.

实现上等价于"以起点为源的最短入树距离"版本 Dijkstra:

  • 邻接矩阵朴素版: 每轮线性找最近未入树点, $O(V^2)$ —— 稠密图首选;
  • 邻接表 + 二叉堆: $O((V+E)\log V) = O(E \log V)$ —— 稀疏图可用;
  • 与 Dijkstra 的区别只有一个符号: 松弛取 min(d[v], w) 而不是 d[u] + w——每个点到树的距离只看自己那条边, 不累加路径。

Python

import heapq


def prim_heap(n: int, g: list[list[tuple[int, int]]], start: int = 0) -> int:
    """堆优化 Prim: O(E log V). g[u] = [(v, w)] 双向邻接表.
    返回 MST 权和; 图不连通返回 -1."""
    in_tree = [False] * n
    heap = [(0, start)]                          # (到已建部分的最短距离, 点)
    total, cnt = 0, 0
    while heap and cnt < n:
        d, u = heapq.heappop(heap)
        if in_tree[u]:
            continue                             # 懒惰删除: 过期条目作废
        in_tree[u] = True
        total += d
        cnt += 1
        for v, w in g[u]:
            if not in_tree[v]:
                heapq.heappush(heap, (w, v))
    return total if cnt == n else -1

Go

type edgeW struct{ to, w int }

type minHeap []primItem
type primItem struct{ v, d int }

func (h minHeap) Len() int            { return len(h) }
func (h minHeap) Less(i, j int) bool  { return h[i].d < h[j].d }
func (h minHeap) Swap(i, j int)       { h[i], h[j] = h[j], h[i] }
func (h *minHeap) Push(x any)         { *h = append(*h, x.(primItem)) }
func (h *minHeap) Pop() any {
	old := *h
	n := len(old)
	it := old[n-1]
	*h = old[:n-1]
	return it
}

// Prim 堆优化: O(E log V). 返回 MST 总权; 不连通返回 -1.
func primHeap(g [][]edgeW, n int) int {
	inTree := make([]bool, n)
	h := &minHeap{{v: 0, d: 0}}
	total, cnt := 0, 0
	for h.Len() > 0 && cnt < n {
		it := heap.Pop(h).(primItem)
		if inTree[it.v] {
			continue
		}
		inTree[it.v] = true
		total += it.d
		cnt++
		for _, e := range g[it.v] {
			if !inTree[e.to] {
				heap.Push(h, primItem{v: e.to, d: e.w})
			}
		}
	}
	if cnt < n {
		return -1
	}
	return total
}

(需要 import "container/heap"。)

C++ 朴素版 (稠密图, 原版保留)

int prim(vector<vector<int>>& g, int n) {
    vector<int> d(n, INT_MAX), in(n, 0);
    d[0]=0; int total=0;
    for (int i=0; i<n; ++i) {
        int u=-1;
        for (int v=0; v<n; ++v)
            if (!in[v] && (u==-1 || d[v]<d[u])) u=v;
        in[u]=true; total += d[u];
        for (int v=0; v<n; ++v) if (!in[v]) d[v]=min(d[v], g[u][v]);
    }
    return total;
}

瓶颈生成树与 minimax 路径

阈值视角:只用权 $\le W$ 的边, 图连通 $\iff$ MST 的最大边权 $\le W$。(两边都与"删掉 MST 中权 > W 的边后是否仍连通"等价。)

由此立刻得到两个免费结论:

  1. MST 是最小瓶颈生成树: 在所有生成树里最小化"最大边权"。所以"让最差的那条路尽量好"这类问题直接跑 MST;
  2. Minimax 路径性质: MST 上 $u \to v$ 路径的最大边权 = 全图 $u$ 到 $v$ 所有可能路径中最小的最大边权。于是"两点之间最少要多宽的路才能通过"可以离线建一次 MST 后在树上查询 (倍增/LCA), 或者在线二分阈值 + DSU 判连通。

延伸: Kruskal 重构树——按 Kruskal 合并顺序建新树 (每次合并建成一个权为边权的新父节点), 把"阈值连通性"变成子树查询, 是 NOI 级别题目的常用工具。

选型与进阶

场景推荐复杂度
稀疏图, 要输出边列表Kruskal$O(E \log E)$
稠密图, 邻接矩阵在手Prim 朴素$O(V^2)$
稀疏图 + 堆Prim$O(E \log V)$
分布式 / 外存 / 并行Boruvka$O(E \log V)$
  • Boruvka: 每轮对所有连通块同时找各自的最小出边并合并, 块数至少减半, 共 $O(\log V)$ 轮。天然并行、边数可流式处理, 是分布式 MST 和外存算法的基础构件;
  • 次小生成树: 枚举每条非树边 $(u,v,w)$, 加上后成环, 删除环上最大边 (或严格次大边, 用于求"严格次小")。树上倍增维护路径最大/次大边可做到 $O(E \log V)$;
  • MST 唯一性判定: 权全互异 ⇒ 唯一; 否则检查每条非树边是否与环上某条同权树边可互换。
  • 单链接聚类: 单链接层次聚类 = 在完整图上跑 Kruskal, 停在第 $k$ 小的合并处, 恰好得到 $k$ 个簇。

tip

口诀: "Kruskal 排边并查集, Prim 抓点堆里挤; 稠密矩阵走朴素, 分布式上 Boruvka。" 以及判断类问题先想阈值: "权 ≤ W 连通吗?" 就是 MST 最大边 ≤ W 吗?

工程案例

  • 电网 / 光纤 / 道路规划: 经典成本最小连通问题;
  • 单链接层次聚类: 见上, NetworkX 的 minimum_spanning_edges 默认就是 Kruskal;
  • 图像分割 / 区域合并: 以像素相似度为边权做 MST, 再剪掉大边得到分割;
  • 近似算法组件: 度约束生成树、TSP 的 2-近似 (先 MST 后前序遍历) 都以 MST 为第一层。

易错清单

  1. 自环与重复边: 自环永不入选; 平行边保留最小的一条即可 (其余是环上的更大边, cycle property 直接淘汰);
  2. 稠密图选错算法: $E \approx V^2$ 时 Kruskal 要排 $V^2 \log V$ 条边, 朴素 Prim 只有 $O(V^2)$——选型搞反会慢一个数量级;
  3. 并查集忘记初始化 parent[i] = i (Go 里记得 for 循环赋值, C++ 用 iota);
  4. 不连通图: Kruskal 结束时 cnt < n-1, 应报告"最小生成森林"而不是当作答案输出; Prim 版本要显式返回 -1;
  5. 负权边完全没问题: MST 只关心权的大小序, 负权照常工作——这与最短路不同。

经典题

  • LC 1584 连接所有点的最小费用 (MST 模板, 曼哈顿距离建边);
  • LC 1489 找到 MST 里的关键边和伪关键边 (枚举 + 并查集, 考 cut/cycle 性质的应用);
  • LC 778 水位上升的泳池游泳 (minimax 路径 = 阈值 + DSU 或直接 MST);
  • LC 1168 水资源分配优化 (虚拟源点建边);
  • POJ 1258 Agri-Net (稠密图 Prim 朴素版)。

一页速查

定义:    连通无向图, n-1 条边, 权和最小; 权互异 ⇔ MST 唯一
引理:    cut property (任一切最小横切边在某 MST 内) + cycle property (环上最大边不在内)
Kruskal: 边排序 + DSU, O(E log E); 稀疏图 / 要边列表
Prim:    点扩张, 朴素 O(V²) 稠密图, 堆 O(E log V); 与 Dijkstra 差一个 "+d[u]"
Boruvka: 各块同时取最小出边, O(E log V), 天然并行
瓶颈:    MST = 最小瓶颈树; u→v 最小最大边权 = MST 上路径最大边 (阈值+DSU 可替代)
坑:      自环跳过 / 平行边留最小 / 不连通要报森林 / 负权无影响

下一节 → 网络流

拓扑排序与强连通分量

一句话

拓扑排序处理 DAG 上"先做 X 再做 Y"的依赖排序, 强连通分量 (SCC) 处理任意有向图上"互相可达"的极大子集。二者由一个动作连接: 把图缩成 SCC 后一定得到 DAG, 于是任何有向图的问题都可以拆成"分量内部 (SCC) + 分量之间 (DAG)"两层。主流实现各两个——拓扑序用 Kahn 或 DFS 三色, SCC 用 Kosaraju (两次 DFS, 最易读) 或 Tarjan (一次 DFS + lowlink, 最快)——工程界默认组合是 Kahn + Tarjan

读完应能:

  1. 用入度剥离与 DFS 后序逆序两种方式求拓扑序, 并说出各自适合的场景;
  2. 写出 Kosaraju 与 Tarjan 的 Python / Go 实现, 并解释 lowlink 的含义;
  3. 说清两种 SCC 算法的对照与取舍;
  4. 把 2-SAT 归约到 SCC, 并写出无解判定与方案构造。

思想链

问题 A: 一堆任务有依赖关系, 给出安全执行顺序?
  └─► 有环 = 死锁/循环依赖, 无解; 无环(DAG)才有拓扑序
        ├─ Kahn: 入度为 0 的先出队, 删边后新 0 入度继续     → 天然给出并行分层
        └─ DFS: 按"离开时间"逆序即拓扑序                    → 三色标记判环

问题 B: 任意有向图, 求"互相可达"的极大子集?
  └─► SCC 是等价类; 缩点后得到 DAG (无环!)
        ├─ Kosaraju: G 上记后序 → 反图上按逆后序再 DFS      → 2 次 DFS, 最好懂
        ├─ Tarjan:   一次 DFS 维护 dfn/lowlink + 栈          → 1 次 DFS, 更快
        └─ 应用: 2-SAT / 编译器数据流 / 缩点 DP / 死锁检测

形式化定义

拓扑序: DAG $G = (V, E)$ 的顶点线性序列 $\pi$, 使得每条边 $(u, v)$ 都满足 $\pi(u) < \pi(v)$。

  • 存在性定理:$G$ 有拓扑序 $\iff$ $G$ 无环。必要性显然 (环上首尾互相要求先后); 充分性由 Kahn 构造性给出;
  • 拓扑序一般不唯一; 需要字典序最小时把 Kahn 的队列换成小根堆 ($O((V+E)\log V)$)。

强连通分量: 有向图中极大点集 $C$, 使任意 $u, v \in C$ 互相可达。"互相可达"是等价关系, 所以 SCC 恰是按此等价关系的划分; 把每个 SCC 收成一个点、保留分量间边, 得到的缩点图 (condensation) 必是 DAG——否则分量还能继续合并。

拓扑排序

Kahn 算法 (BFS 入度剥洋葱)

from collections import deque


def topo_kahn(g: list[list[int]], n: int) -> list[int] | None:
    """g[u] = 出边列表. 返回一个拓扑序; 有环返回 None. O(V+E)."""
    indeg = [0] * n
    for u in range(n):
        for v in g[u]:
            indeg[v] += 1
    q = deque(i for i in range(n) if indeg[i] == 0)
    order = []
    while q:
        u = q.popleft()
        order.append(u)
        for v in g[u]:
            indeg[v] -= 1
            if indeg[v] == 0:
                q.append(v)
    return order if len(order) == n else None    # 输出不满 n 个 ⇒ 有环

Go

// TopoKahn 返回一个拓扑序; 有环返回 nil. O(V+E).
// 队列换小根堆(container/heap)可得字典序最小拓扑序.
func topoKahn(g [][]int, n int) []int {
	indeg := make([]int, n)
	for u := 0; u < n; u++ {
		for _, v := range g[u] {
			indeg[v]++
		}
	}
	q := []int{}
	for i := 0; i < n; i++ {
		if indeg[i] == 0 {
			q = append(q, i)
		}
	}
	order := make([]int, 0, n)
	for len(q) > 0 {
		u := q[0]
		q = q[1:]
		order = append(order, u)
		for _, v := range g[u] {
			if indeg[v]--; indeg[v] == 0 {
				q = append(q, v)
			}
		}
	}
	if len(order) != n {
		return nil // 剩下的点都在环里或被环挡住
	}
	return order
}

DFS 后序逆序 (三色)

def dfs_topo(g: list[list[int]], n: int) -> list[int]:
    """DFS 三色版: 按"离开时间"逆序输出. 遇到灰点 = 回边 = 有环."""
    WHITE, GRAY, BLACK = 0, 1, 2
    color = [WHITE] * n
    order = []

    def visit(u: int) -> None:
        color[u] = GRAY                          # 进入递归栈
        for v in g[u]:
            if color[v] == GRAY:
                raise ValueError("cycle")
            if color[v] == WHITE:
                visit(v)
        color[u] = BLACK                         # 离开: 所有后继已输出在前? 不——在后
        order.append(u)

    for u in range(n):
        if color[u] == WHITE:
            visit(u)
    return order[::-1]

note

为什么"离开时间的逆序"是拓扑序?DFS 结束 $u$ 时, 它的所有后继都已结束且更早进入 order, 所以逆序里每个 $u$ 都排在它指向的点之前。同一个不变量在 SCC 里会再次出现——Kosaraju 正是靠它找到"反图上的正确起点"。

关键性质与延伸

  • 并行分层:Kahn 中同一轮出队的点互不依赖, 可同时执行——CI 流水线、增量编译的任务并行度就是"每层宽度";
  • DAG 上 DP:拓扑序天然是合法的计算顺序, 沿序转移即可求最长路/路径计数。工程对应物是关键路径 (CPM) 与 ML 计算图的内存复用分析 (见 动态规划);
  • 判环:Kahn 输出不足 $n$ 个、或 DFS 撞到灰点, 都是 $O(V+E)$ 判环。

SCC: 两种主流算法对照

KosarajuTarjan
DFS 次数2 次 (原图 + 反图)1 次
需要反图需要 (额外 $O(V+E)$ 存储)不需要
核心机制后序逆序 = 反图上正确遍历序dfn/lowlink + 显式栈
实现难度低, 十行可写中, 细节多 (onStack 判断)
递归深度同为 $O(V)$, 大图需迭代化同左
分量编号顺序无特殊保证恰为逆拓扑序 (对 2-SAT 至关重要)

Kosaraju

1) 在 G 上 DFS, 按离开时间压栈;
2) 所有边反向得 G^T;
3) 从栈顶往下取未分配的点, 在 G^T 上 DFS, 一次遍历收拢的点集 = 一个 SCC.

直觉: 原图中"最早离开"的点属于汇型分量 (没有出边去别处), 它在反图上变成源型, 从它出发恰好只能走遍自己的分量。

Go 实现

// Kosaraju: 两次 DFS, O(V+E). 返回 comp[] 分量编号 (0..k-1).
func kosaraju(g [][]int, n int) []int {
	visited := make([]bool, n)
	order := make([]int, 0, n)
	var dfs1 func(int)
	dfs1 = func(u int) {
		visited[u] = true
		for _, v := range g[u] {
			if !visited[v] {
				dfs1(v)
			}
		}
		order = append(order, u) // 按"离开时间"记录
	}
	for u := 0; u < n; u++ {
		if !visited[u] {
			dfs1(u)
		}
	}

	gt := make([][]int, n) // 反图
	for u := 0; u < n; u++ {
		for _, v := range g[u] {
			gt[v] = append(gt[v], u)
		}
	}

	comp := make([]int, n)
	for i := range comp {
		comp[i] = -1
	}
	c := 0
	for i := n - 1; i >= 0; i-- { // 逆后序
		u := order[i]
		if comp[u] != -1 {
			continue
		}
		stack := []int{u} // 反图上迭代 DFS 收拢一个分量
		comp[u] = c
		for len(stack) > 0 {
			x := stack[len(stack)-1]
			stack = stack[:len(stack)-1]
			for _, v := range gt[x] {
				if comp[v] == -1 {
					comp[v] = c
					stack = append(stack, v)
				}
			}
		}
		c++
	}
	return comp
}

Tarjan

一次 DFS 同时维护:

  • $dfn[u]$:首次访问的时间戳;
  • $low[u]$:$u$ 及其在栈中后代能回溯到的最小 $dfn$;

当 $low[u] = dfn[u]$ 时, $u$ 是分量根, 弹栈到 $u$ 为止就是一个 SCC。关键细节: 回边更新用 $dfn[v]$ 且仅当 $v$ 还在栈里 (在栈里 = 与 $u$ 同分量或尚未定论), 否则会把已完成的分量错误地接回来。

Python 实现 (供 2-SAT 复用)

def tarjan_scc(g: list[list[int]]) -> list[int]:
    """一次 DFS 求 SCC, O(V+E). 返回 comp[], 分量按逆拓扑序编号 0..k-1.
    注意递归深度可达 V; 大图请改显式栈版本."""
    import sys
    sys.setrecursionlimit(max(sys.getrecursionlimit(), len(g) + 100))
    n = len(g)
    dfn = [0] * n                              # 时间戳, 0 = 未访问
    low = [0] * n
    on_stack = [False] * n
    comp = [0] * n
    stk: list[int] = []
    timer = cnt = 0

    def dfs(u: int) -> None:
        nonlocal timer, cnt
        timer += 1
        dfn[u] = low[u] = timer
        stk.append(u)
        on_stack[u] = True
        for v in g[u]:
            if dfn[v] == 0:
                dfs(v)
                low[u] = min(low[u], low[v])   # 树边: 继承孩子
            elif on_stack[v]:                  # 只看还在栈里的横/回边
                low[u] = min(low[u], dfn[v])
        if low[u] == dfn[u]:                   # u 是分量根: 弹栈收拢
            while True:
                x = stk.pop()
                on_stack[x] = False
                comp[x] = cnt
                if x == u:
                    break
            cnt += 1

    for u in range(n):
        if dfn[u] == 0:
            dfs(u)
    return comp

Go 实现

// Tarjan: 一次 DFS, O(V+E). comp 编号为逆拓扑序 (2-SAT 直接可用).
func tarjan(g [][]int, n int) []int {
	dfn := make([]int, n) // 时间戳, 0 表示未访问
	low := make([]int, n)
	onStack := make([]bool, n)
	comp := make([]int, n)
	stk := []int{}
	timer, cnt := 1, 0
	var dfs func(int)
	dfs = func(u int) {
		dfn[u], low[u] = timer, timer
		timer++
		stk = append(stk, u)
		onStack[u] = true
		for _, v := range g[u] {
			if dfn[v] == 0 {
				dfs(v)
				if low[v] < low[u] {
					low[u] = low[v]
				}
			} else if onStack[v] && dfn[v] < low[u] {
				low[u] = dfn[v]
			}
		}
		if low[u] == dfn[u] { // u 是分量根
			for {
				x := stk[len(stk)-1]
				stk = stk[:len(stk)-1]
				onStack[x] = false
				comp[x] = cnt
				if x == u {
					break
				}
			}
			cnt++
		}
	}
	for u := 0; u < n; u++ {
		if dfn[u] == 0 {
			dfs(u)
		}
	}
	return comp
}

缩点: 把任意有向图变成 DAG

拿到 comp[] 后, 遍历所有边 $(u, v)$, 若 $comp[u] \neq comp[v]$ 则缩点图中有一条边。之后所有"DAG 才能做的事"都适用:

  • 可达性/传递闭包: bitset 按 DAG 序合并;
  • 缩点 DP: 点权取分量内权之和 (最大半联通子图、最长链);
  • 死锁检测: 操作系统资源分配图中存在非平凡 SCC (或自环) 即潜在死锁。

2-SAT 归约

问题: $m$ 个布尔变量, 每条约束形如"$a$ 与 $b$ 至少一真" (析取子句), 问是否存在赋值满足全部子句。

建模: 每个变量拆两个点 $x_i$ (真) 与 $\lnot x_i$ (假), 编码上用 ii^1 互为相反。子句 $(a \lor b)$ 等价于两条蕴含 $\lnot a \to b$、$\lnot b \to a$——"选了一边就必须连锁另一边"。整张蕴含图中:

$$\text{有解} \iff \forall i,\ x_i \text{ 与 } \lnot x_i \text{ 不在同一 SCC}$$

(同分量意味着 $x_i \Rightarrow \lnot x_i$ 且反向也成立, 自相矛盾。) 方案构造利用 Tarjan 分量的逆拓扑序: 取 $comp[x_i] < comp[\lnot x_i]$ 时令 $x_i$ 为真——编号小表示在缩点 DAG 中更靠"下游", 下游的选择不会被上游推翻。

def two_sat(m: int, clauses: list[tuple[int, int]]) -> list[bool] | None:
    """clauses[k]=(a,b): 字面量 a,b 为变量下标, 负数表示否定 (如 -3 = ¬x_3).
    返回一组可行赋值; 无解返回 None. O(V+E)."""
    lit = lambda x: 2 * (x - 1) if x > 0 else 2 * (-x - 1) + 1
    g = [[] for _ in range(2 * m)]
    for a, b in clauses:
        la, lb = lit(a), lit(b)
        g[la ^ 1].append(lb)                     # ¬a → b
        g[lb ^ 1].append(la)                     # ¬b → a
    comp = tarjan_scc(g)
    assign = []
    for i in range(m):
        if comp[2 * i] == comp[2 * i + 1]:
            return None                          # x_i 与 ¬x_i 同分量: 无解
        assign.append(comp[2 * i] < comp[2 * i + 1])
    return assign


assert two_sat(2, [(1, 2), (-1, -2), (1, -2)]) is not None
assert two_sat(2, [(1, 2), (-1, 2), (1, -2), (-1, -2)]) is None   # x1 ↔ ¬x1

tip

口诀: "拓扑剥洋葱, SCC 双 DFS; Tarjan 一个 low, 2-SAT 选下游。" 另外记住缩点后的世界永远是 DAG——遇到"有环没法 DP"的第一反应就该是缩点。

warning

  1. Tarjan 的回边判断必须带 onStack[v]: 漏掉会把已弹出的分量算进 lowlink, 得到错误的合并;
  2. 大图递归爆栈: Python 默认上限 1000, Go 协程栈虽可增长但闭包递归仍建议改显式栈; Kosaraju 第二次遍历用迭代写法可以规避一半深度;
  3. 拓扑序 ≠ 字典序: 要字典序最小必须显式用小根堆, 普通 deque 只是"某个"合法序;
  4. 2-SAT 的字面量编码 (ii^1) 写错一处, 全部结论作废——先用 2 个变量的小例子自测。

应用

  • 构建系统 / CI: Makefile、Bazel 的依赖图求值顺序; 循环依赖报错就是判环;
  • 编译器: 控制流图的支配树预处理、SSA 构造中的数据流迭代 (SCC 加速收敛)、模块循环引用检测;
  • 包管理器: pip/npm/cargo 的安装顺序解析与版本冲突环检测;
  • 2-SAT 实战: 排班约束 ("A 和 B 不能同时值班")、芯片布线的极性选择、游戏谜题求解;
  • PageRank / 图分析: 大规模网页图的 SCC 先行收缩, 把迭代计算限制在巨分量内。

经典题

  • LC 207 / 210 课程表 I & II (拓扑序模板);
  • LC 802 找到最终安全状态 (反向图 + 拓扑/三色);
  • LC 1192 查找集群内的关键连接 (Tarjan 割边, 同族思想);
  • LC 851 喧闹和富有 (反图 + 拓扑 DP);
  • 洛谷 P2812 校园网络 ([USACO] 缩点 + 入度/出度统计);
  • 洛谷 P4782 【模板】2-SAT。

一页速查

拓扑序:   存在 ⇔ 无环; Kahn 剥入度 O(V+E) 给并行分层; DFS 后序逆序; 字典序要小根堆
SCC:      Kosaraju 2 次 DFS + 反图, 最易写; Tarjan 1 次 DFS, dfn/lowlink + onStack
缩点:     comp[] 不同才连边 → 得 DAG → 一切 DAG 技巧可用 (DP/闭包/关键路径)
2-SAT:    子句 → 2 条蕴含边; 有解 ⇔ x 与 ¬x 异 SCC; Tarjan 序下选编号小的为真
坑:       Tarjan 回边必须判 onStack / 大图递归爆栈 / 拓扑序不唯一

下一节 → 网络流

网络流:最大流 / 最小割 / 二分图匹配

一句话

网络流回答的是"一张带容量的管道网络, 从源点 $s$ 到汇点 $t$ 最多能送多少流量". 所有经典算法共享同一个框架——在残量网络上反复找增广路——差别只在"怎么找路": 任意 DFS 是 Ford-Fulkerson ($O(E \cdot |f^*|)$, 仅整数容量保证终止), BFS 找最短路是 Edmonds-Karp ($O(VE^2)$), BFS 分层 + DFS 多路增广是 Dinic ($O(V^2E)$). 框架停止的那一刻还免费送出一个线性规划对偶: 最大流 = 最小割, 于是项目选择、图像分割、二分图匹配都被统一进这一个模型.

读完应能:

  1. 说清反向边的语义——它是算法的"反悔权", 也是正确性的全部来源;
  2. 默写 Dinic (分层 BFS + 当前弧 DFS), 并指出每一处优化到底在省什么;
  3. 在残量图上求出具体的最小割点集划分, 并用"流必穿割"讲出对偶直觉;
  4. 把二分图匹配归约为最大流, 写出 König 定理的三连等式。

思想链

问题: 管道网络上 s→t 的最大输送量?
  └─► 直接贪心挑路径会错: 先走的路可能挡住更优的组合
        └─► 解法: 给算法"反悔权" —— 残量网络 + 反向边
              └─► 框架: 残量图上还有 s↝t 路就继续推流
                    ├─ 任意找路:   Ford-Fulkerson  O(E·|f*|)
                    ├─ 最短路优先: Edmonds-Karp     O(V·E²)
                    └─ 分层 + 多路: Dinic           O(V²·E)
                          └─► 终止时 t 在残量图上不可达
                                └─► S = {s 可达}, 割(S,T) 容量 = 流值
                                      └─► 最大流 = 最小割 (LP 对偶)
                                            └─► 归约: 二分图匹配 / 闭合子图 / 图像分割

形式化定义

流网络是有向图 $G = (V, E)$, 每条边有容量 $c(u, v) \ge 0$, 指定源点 $s$ 与汇点 $t$. 一个是函数 $f: E \to \mathbb{R}_{\ge 0}$, 满足:

  1. 容量约束:$f(u, v) \le c(u, v)$;
  2. 流量守恒:除 $s, t$ 外, $\sum_{u} f(u, v) = \sum_{w} f(v, w)$。

目标是最大化流值 $|f| = \sum_v f(s, v) - \sum_v f(v, s)$。

残量网络 $G_f$ 在原边上保留剩余容量 $r(u, v) = c(u, v) - f(u, v)$, 同时为每条边引入反向边 $r(v, u) = f(u, v)$——它表示"已经送出去的流量可以撤回"。增广路是 $G_f$ 上一条 $s \to t$ 的路径, 可推送量为路径上最小的 $r$。

整数性定理:容量全为整数时, 必存在整数最大流 (每步增广整数即可)。这条定理是后面一切"归约成匹配"的合法性基础。

note

反向边为什么合法?沿路径推 $\delta$ 流量的同时, 给反向边注入 $\delta$ 的"撤销额度"。之后任何一次增广走过反向边, 等价于把早先某条路上的一段流量改道。所以 Ford-Fulkerson 不是普通贪心——它是带撤销的贪心, 无论中间走了多少冤枉路, 最终都能变换到全局最优的形状。这正是它与"不可撤销所以会翻车"的普通贪心 (见 贪心) 的本质区别。

Ford-Fulkerson 与 Edmonds-Karp

框架只有三步:

  1. 在残量图上找一条 $s \to t$ 增广路;没有则停止;
  2. 取路径瓶颈 $\delta = \min r(u, v)$;
  3. 正向边减 $\delta$, 反向边加 $\delta$, 流值累加 $\delta$。

任意找路的 Ford-Fulkerson 复杂度为 $O(E \cdot |f^*|)$——与容量数值相关, 且实数容量下增广量可能几何衰减导致永不终止。改用 BFS 找"边数最短"的增广路即 Edmonds-Karp: 可以证明每条边作为关键边 (瓶颈边) 至多饱和 $V/2$ 次 (其两端在残量图中的距离只会单调增加), 总增广轮数 $O(VE)$, 每轮 BFS 花 $O(E)$, 合计 $O(VE^2)$——与容量数值无关的多项式界。

from collections import deque


def edmonds_karp(g: dict[int, dict[int, int]], s: int, t: int) -> int:
    """g[u][v] = 剩余容量. 返回最大流. O(V E^2), 与容量数值无关."""
    flow = 0
    while True:
        parent = {s: None}                       # BFS 找最短增广路
        q = deque([s])
        while q and t not in parent:
            u = q.popleft()
            for v, c in g[u].items():
                if c > 0 and v not in parent:
                    parent[v] = u
                    q.append(v)
        if t not in parent:
            return flow                          # 残量图断开: 结束
        path, v = [], t
        while parent[v] is not None:             # 回溯路径
            path.append((parent[v], v))
            v = parent[v]
        delta = min(g[u][v] for u, v in path)
        for u, v in path:
            g[u][v] -= delta
            g[v].setdefault(u, 0)                # 反向边可能原本不存在, 补 0
            g[v][u] += delta
        flow += delta

Dinic: 分层图完整实现

Dinic 在 Edmonds-Karp 上做两个优化:

  1. 分层图:每轮 BFS 求出 $level[v]$ = 残量图上 $s \to v$ 的最短边数, 之后 DFS 只允许走 $level$ 加 1 的边——所有增广路都自动是最短的, 且一轮分层可以推多条路;
  2. 当前弧优化:每个点记录"下一条待尝试边"的下标。一条边一旦被证实走不通 (下游已断), 本轮不再看它第二次。

两者合起来把复杂度压到 $O(V^2 E)$; 单位容量网络 (如二分图匹配) 上是 $O(E\sqrt{V})$——这正是 Hopcroft-Karp 的界。

Python

from collections import deque


class Dinic:
    """最大流 O(V^2 E). 点编号 0..n-1; 边成对存储 (正向 + 自动建立的反向边)."""

    def __init__(self, n: int):
        self.n = n
        self.g = [[] for _ in range(n)]          # g[u] = [[to, cap, rev], ...]

    def add_edge(self, u: int, v: int, c: int) -> None:
        """加容量 c 的有向边; 反向边必须由这里自动创建, 手工调用会打乱 rev 配对."""
        self.g[u].append([v, c, len(self.g[v])])
        self.g[v].append([u, 0, len(self.g[u]) - 1])

    def _bfs(self, s: int, t: int) -> bool:
        """分层: level[v] = 残量图上 s→v 的最短边数; t 不可达则整体结束."""
        self.level = [-1] * self.n
        self.level[s] = 0
        q = deque([s])
        while q:
            u = q.popleft()
            for v, cap, _ in self.g[u]:
                if cap > 0 and self.level[v] < 0:
                    self.level[v] = self.level[u] + 1
                    q.append(v)
        return self.level[t] != -1

    def _dfs(self, u: int, t: int, f: int) -> int:
        """沿 level+1 的边向上推流, 返回本轮实际推送量."""
        if u == t:
            return f
        while self.it[u] < len(self.g[u]):
            e = self.g[u][self.it[u]]
            v, cap, rev = e
            if cap > 0 and self.level[v] == self.level[u] + 1:
                d = self._dfs(v, t, min(f, cap))
                if d > 0:
                    e[1] -= d                    # 正向边扣容量
                    self.g[v][rev][1] += d       # 反向边加"反悔额度"
                    return d
            self.it[u] += 1                      # 当前弧: 此边已死, 本轮跳过
        return 0                                 # 该点在本层已榨干

    def max_flow(self, s: int, t: int) -> int:
        flow = 0
        while self._bfs(s, t):
            self.it = [0] * self.n               # 新一轮分层, 重置当前弧
            while True:
                f = self._dfs(s, t, float("inf"))
                if f == 0:
                    break
                flow += f
        return flow

    def min_cut_side(self, s: int) -> list[bool]:
        """必须在 max_flow 之后调用: 残量图上 s 可达的点集即 S 侧."""
        seen = [False] * self.n
        seen[s] = True
        stack = [s]
        while stack:
            u = stack.pop()
            for v, cap, _ in self.g[u]:
                if cap > 0 and not seen[v]:
                    seen[v] = True
                    stack.append(v)
        return seen


if __name__ == "__main__":
    # CLRS 经典算例: 最大流 23, 最小割 {1→3, 4→3, 4→5}
    din = Dinic(6)
    for u, v, c in [(0, 1, 16), (0, 2, 13), (1, 3, 12), (2, 1, 4), (2, 4, 14),
                    (3, 2, 9), (3, 5, 20), (4, 3, 7), (4, 5, 4)]:
        din.add_edge(u, v, c)
    assert din.max_flow(0, 5) == 23
    side = din.min_cut_side(0)
    assert side == [True, True, True, False, True, False]   # S = {0,1,2,4}

Go

package main

import (
	"fmt"
	"math"
)

type flowEdge struct{ to, cap, rev int }

type Dinic struct {
	g     [][]flowEdge
	level []int
	it    []int
}

func newDinic(n int) *Dinic {
	return &Dinic{
		g:     make([][]flowEdge, n),
		level: make([]int, n),
		it:    make([]int, n),
	}
}

// AddEdge 加容量 c 的有向边, 自动配对反向边 (初始容量 0).
func (d *Dinic) AddEdge(u, v, c int) {
	d.g[u] = append(d.g[u], flowEdge{v, c, len(d.g[v])})
	d.g[v] = append(d.g[v], flowEdge{u, 0, len(d.g[u]) - 1})
}

// bfs 分层: level[v] = 残量图上 s→v 的最短边数.
func (d *Dinic) bfs(s, t int) bool {
	for i := range d.level {
		d.level[i] = -1
	}
	d.level[s] = 0
	queue := []int{s}
	for len(queue) > 0 {
		u := queue[0]
		queue = queue[1:]
		for _, e := range d.g[u] {
			if e.cap > 0 && d.level[e.to] < 0 {
				d.level[e.to] = d.level[u] + 1
				queue = append(queue, e.to)
			}
		}
	}
	return d.level[t] != -1
}

// dfs 只沿 level+1 的边增广; it 是当前弧, 已证死的边本轮不再看.
func (d *Dinic) dfs(u, t, f int) int {
	if u == t {
		return f
	}
	for ; d.it[u] < len(d.g[u]); d.it[u]++ {
		e := &d.g[u][d.it[u]]
		if e.cap <= 0 || d.level[e.to] != d.level[u]+1 {
			continue
		}
		if fl := d.dfs(e.to, t, minInt(f, e.cap)); fl > 0 {
			e.cap -= fl
			d.g[e.to][e.rev].cap += fl
			return fl
		}
	}
	return 0
}

// MaxFlow 返回 s→t 最大流. O(V²E); 单位容量网络 O(E√V).
func (d *Dinic) MaxFlow(s, t int) int {
	flow := 0
	for d.bfs(s, t) {
		for i := range d.it {
			d.it[i] = 0
		}
		for {
			f := d.dfs(s, t, math.MaxInt)
			if f == 0 {
				break
			}
			flow += f
		}
	}
	return flow
}

// MinCutSide 在 MaxFlow 之后调用: true = 该点属于 S 侧.
func (d *Dinic) MinCutSide(s int) []bool {
	seen := make([]bool, len(d.g))
	seen[s] = true
	stack := []int{s}
	for len(stack) > 0 {
		u := stack[len(stack)-1]
		stack = stack[:len(stack)-1]
		for _, e := range d.g[u] {
			if e.cap > 0 && !seen[e.to] {
				seen[e.to] = true
				stack = append(stack, e.to)
			}
		}
	}
	return seen
}

func minInt(a, b int) int {
	if a < b {
		return a
	}
	return b
}

func main() {
	d := newDinic(6)
	for _, e := range [][3]int{
		{0, 1, 16}, {0, 2, 13}, {1, 3, 12}, {2, 1, 4}, {2, 4, 14},
		{3, 2, 9}, {3, 5, 20}, {4, 3, 7}, {4, 5, 4},
	} {
		d.AddEdge(e[0], e[1], e[2])
	}
	fmt.Println(d.MaxFlow(0, 5)) // 23
	fmt.Println(d.MinCutSide(0)) // S = {0,1,2,4}
}

最小割定理与对偶直觉

最大流-最小割定理:$\max_f |f| = \min_{(S,T)} c(S, T)$, 其中割 $(S, T)$ 是把点分成 $s \in S$、$t \in T$ 的划分, 割容量 $c(S, T) = \sum_{u \in S, v \in T} c(u, v)$ (只算 $S \to T$ 方向)。

三个层次的理解:

  1. 弱对偶 (夹逼):任何流都要从 $S$ 净流出才能到达 $t$, 而净流出量不可能超过 $S \to T$ 的总容量, 所以"任何流值 ≤ 任何割容量"。两边分别取最大、最小, 就被夹住了;
  2. 强对偶 (相等):算法终止时残量图上 $t$ 不可达。令 $S$ = $s$ 可达集, 则所有 $S \to T$ 的边都满流 (否则对面可达), 所有 $T \to S$ 的边都零流, 于是 $c(S, T) = |f|$——这个具体的割就是最优割。求法:跑完最大流后在残量图上从 $s$ 做一次 DFS/BFS
  3. LP 对偶视角:把最大流写成线性规划, 其对偶问题的变量恰好对应"每条边是否被割"+"每个点属于哪侧", 对偶最优解就是一个最小割。最大流-最小割是 LP 强对偶在最经典组合结构上的实例化。

tip

建模三问: 谁是? 谁是? 容量到底在限制什么? 想清楚第三问通常就完成了整道题——例如"每个工人每天最多干一件活"是点容量 (拆点: 入点→出点连容量 1 的边), "两个任务不能同时"才是边容量。

典型归约

  • 最大权闭合子图 (项目选择):选项目 $p$ 获利 $v_p > 0$, 但依赖设备 $q$ 需付费 $v_q < 0$, 选了就必须连带选。建图: $s \to p$ 容量 $v_p$, $q \to t$ 容量 $|v_q|$, 依赖关系连 $\infty$ 边。答案 = $\sum v_p - $ 最小割;
  • 图像分割:像素为点, $s$/​$t$ 分别连"前景/背景"的数据项代价, 相邻像素之间连平滑项 (惩罚割裂), 最小割即能量最小分割 (Boykov-Kolmogorov);
  • 最小路径覆盖:DAG 上 $n -$ 最大匹配。

二分图匹配归约

左部 $L$、右部 $R$ 的二分图, 求最多匹配对数。加超源超汇: $s \to l$ 容量 1, $r \to t$ 容量 1, $l \to r$ 容量 1 (或 $\infty$)。由整数性定理, 每条 $s \to l$ 要么满流要么零流, 所以整数最大流与匹配一一对应, 最大流 = 最大匹配

单位容量网络让 Dinic 达到 $O(E\sqrt{V})$——这就是 Hopcroft-Karp 的复杂度; 而朴素的匈牙利算法 (逐个左点找增广路, 本质是"一次一条增广路"的特例) 是 $O(V \cdot E)$, 常数极小, 左部规模小时反而更快。

König 定理 (二分图三连等式, 全部由最大流-最小割推出):

$$\text{最大匹配} = \text{最小点覆盖}, \qquad \text{最大独立集} = n - \text{最小点覆盖}$$

最小点覆盖的构造也来自割: 跑完最大流后, 用 $s$ 不可达的左部点 + 可达的右部点组成覆盖。DAG 上另有 $\text{最小路径覆盖} = n - \text{最大匹配}$ (拆点成二分图)。

warning

  1. 反向边必须由 add_edge 成对创建: 自己手工加两条独立边会破坏 rev 互指, 增广时写穿别人的容量;
  2. 实数容量 + 任意找路 = 可能永不终止 (Zwick 反例), 生产代码一律用 BFS/Dinic 或保证整数容量;
  3. 求最小割的 DFS 必须发生在 max_flow 之后, 顺序反了得到的是垃圾划分;
  4. Python 版 DFS 递归深度 = 路径长度 $\le V$: 大图要么改显式栈, 要么调大 sys.setrecursionlimit;
  5. 点容量要拆点: 直接在点上设上限不是标准流网络的约束。

复杂度对照

算法找路方式复杂度备注
Ford-Fulkerson任意 DFS$O(E \cdot |f^*|)$仅整数容量保证终止
Edmonds-KarpBFS 最短路$O(VE^2)$与容量数值无关
Dinic分层 + 当前弧$O(V^2E)$单位容量 $O(E\sqrt{V})$
ISAP / HLPP预流推进$O(V^2\sqrt{E})$竞赛中常数更小

进阶

  • 最小费用最大流 (MCMF):每条边再加单价 $w$, 在最大流前提下最小化 $\sum f \cdot w$。做法: 增广路改成"费用最短路"——SPFA 或 Dijkstra + 势函数 (Johnson 思想, 见 最短路径);
  • Gomory-Hu 树:$n$ 个点所有点对的最小割可以压缩成一棵 $n-1$ 条边的树, 树上边权即两端点对的最小割;
  • Stoer-Wagner:无向图整体最小割 (不指定源汇) 的专用算法, $O(V^3)$;
  • 预流推进 (Push-Relabel):放弃"守恒随时成立"的不变量, 允许超额再回流, HLPP 是理论最好的实用实现之一。

易错清单

  1. 找不到增广路 ≠ 图断了: 判定终止的唯一标准是"BFS 分层时 $t$ 的 level 为 -1";
  2. 重边与自环: 重边直接累加容量即可, 自环永远不该出现在增广路上 (Dinic 分层天然排除);
  3. 无向图: 拆成两条方向的边时, 两条边共享同一对正反向边 (容量都设 $c$), 而不是各配一对;
  4. 输出流方案: 某条边实际流量 = 原容量 − 残量, 别去读反向边。

经典题

  • 洛谷 P3376 【模板】最大流 (Dinic);
  • 洛谷 P3381 【模板】最小费用最大流;
  • POJ 1273 Drainage Ditches (EK 入门);
  • POJ 1149 PIGS (经典"合并猪圈"建模);
  • 洛谷 P2764 最长不下降子序列 / 最小路径覆盖 (匹配归约);
  • Hopcroft-Karp 二分图匹配模板 (洛谷 P3386)。

一页速查

模型:    f: E→R+, 容量约束 + 守恒; 目标 max 流值
残量图:  r(u,v) = c-f, r(v,u) = f; 反向边 = 反悔权 = 正确性来源
算法:    FF 任意增广 O(E·|f*|) / EK 最短路 O(VE²) / Dinic 分层 O(V²E), 单位容量 O(E√V)
最小割:  终止后 s 可达集为 S, 割容量 = 流值; 直觉 "任何流必穿任何割"
匹配:    s→L(1), L→R(∞), R→t(1); König: 最大匹配 = 最小点覆盖
建模:    闭合子图 = Σ正权 - 最小割; 图像分割 = 数据项 + 平滑项; 点容量要拆点

回到章首:

算法设计范式

五大范式覆盖绝大多数"非平凡"算法的设计本质. 学会这套后, 你面对一道陌生的题能机械地走一遍"试哪种范式" workflow.

note

读这五篇前最该记住的: 复杂度不重要, 结构才重要. 同一道 LCS, 写成记忆化搜索 / DP 表 / 递归 + memo 都是同一个东西.

分治

一句话

分治不是"递归" - 是"先分解、再合并解"的程序结构. 它刻意要求合并(merge)开销可控, 否则甚至比分而治之之前还不划算. 很多工程上人称"分治"的代码其实只是"递归调用", 实质不是分治 (如mers 是"分 + 合并行将"), 看清楚"merge 在哪儿"就看清结构.

模板

solve(P):
  if base case: return answer
  分 P = P1 ∪ P2
  A1 = solve(P1), A2 = solve(P2)
  return combine(A1, A2)

经典案例: 归并排序、快速排序 (partition 是 pivot 排序)、Karatsuba 大整数乘法、最近点对 / 平面最近距离、二分搜索 (信息修改)、线段树 / 主席树基础.

复杂度主定理回顾

T(n) = a·T(n/b) + f(n), 结论见 复杂度分析.

经典: 最近点对 (平面)

O(n log n):

  1. 按 x 排序, 递归左侧和右侧;
  2. 合并时只考虑横跨分界线 ± δ 的点 (δ = 左右解最小值), 按 y 排后需检查每点附近 6 个点.

经典: 求逆序对

归并排序的副产物 O(n log n): 归并过程中"左半还剩 m 个值时, 从右半取的数所对应的逆序数加 m".

工程多核化

分治天然适合 fork-join 工作窃取. 但分到原子 (n ≤ 1024) 就停下, 否则 fork 开销 > 收益.

硬件视角: 分治的递归树形结构很难 GPU/FPGA 并行化, 但在分完之后的所有 leaf 任务可以 SIMD - 这就是分治在 NPU 编译器里的用法.

易错

  1. merge 非 in-place: 归并排序需要 O(n) 辅助空间, 原地归并虽存在但常数大、专利多.
  2. base case 边界: n=1 / 0 都写好.
  3. 并发分治: fork-join 框架天然适合分治, 但要测"分到多大才 fork", 否则细粒度 fork 开销 > 收益.

贪心 (Greedy)

TL;DR

贪心不是"每一步选看起来最好的",而是一种可证明的算法范式:如果问题有 最优子结构 + 无后效性,且每一步的局部最优决策"永远不会被未来的决策推翻",那么局部最优的拼接就是全局最优。判断一个题能不能贪心,靠的不是直觉而是交换论证 / 归纳证明。本章给出判据、证明模板、四组经典问题的严格推导,以及 Go / Python 双语言实现。

读完应能:

  1. 用"最优子结构 + 无后效性 + 贪心选择性质"三句话判断一个题能否贪心。
  2. 交换论证证明区间调度和 Huffman 的最优性。
  3. 写出活动选择、部分背包、Huffman、任务调度的实现,并指出它们各自的"排序键"。
  4. 说出贪心与 DP 的分界线:0/1 背包为什么不能贪心、部分背包为什么能。

一、思想链

工程问题: 每一步选当前最优, 能不能保证全局最优?
  └─► 必要条件1: 最优子结构 (子问题最优解能拼出全局最优)
  └─► 必要条件2: 无后效性 (未来决策只看当前状态)
  └─► 贪心选择性质: 存在一个"每一步都取局部最优"的最优解
        └─► 证明武器: 交换论证 / 归纳 / 拟阵(matroid)公理

二、形式化判据

设每一步要做的选择集合为 $C_i$,贪心选 $g_i \in C_i$:

  1. 最优子结构:$OPT(S)$ 由 $g_1$ 与 $OPT(S_{g_1})$ 组合而成;
  2. 无后效性(马尔可夫性):$S_{g_1}$ 的选择与"怎么走到 $g_1$"无关;
  3. 贪心选择性质:存在最优解以 $g_1$ 为第一步——证明方式是:任取一个最优解,把它"交换"成以 $g_1$ 开头且不劣。

note

若问题满足**拟阵(matroid)**公理(遗传性 + 交换性),贪心在加权独立集问题上直接最优——这是"什么时候能贪心"的理论答案(如 Kruskal、最大权森林)。

三、经典问题与证明

3.1 活动选择(区间调度)

问题:$n$ 个区间 $[s_i, e_i)$,选最多互不重叠的区间。

贪心:按 end 升序排序,依次取不与已选重叠的最早结束区间。

交换论证:设贪心第一个区间是 $g_1=[s_{g}, e_{g}]$(全局最早结束),任意最优解 $O$ 的第一个区间是 $o_1=[s_o, e_o]$。因为 $e_g \le e_o$,把 $O$ 中的 $o_1$ 换成 $g_1$ 后,$O$ 的剩余区间仍然不重叠(它们都在 $e_o$ 之后开始,也在 $e_g$ 之后开始),所以存在以 $g_1$ 开头的最优解。对 $S_{g_1}$ 归纳即可。

warning

错误排序:按 start 或区间长度排序都会翻车。反例:[1,5],[2,3],[4,6]——按 start 取 [1,5] 只剩 1 个,最优是 2 个。

3.2 部分背包(Fractional Knapsack)

贪心:按单位价值 $v_i/w_i$ 降序装,装不下时切碎。

为什么 0/1 背包不行:0/1 里"当前最值钱的物品"可能挤掉"组合后更优的两个物品",后效性破坏;部分背包里一切可切,贪心选择性质成立(单位价值最高的物品在某个最优解里一定被完全或部分取走,交换论证)。

3.3 Huffman 编码

贪心:每次合并频率最小的两棵树(优先队列)。

最优性证明(交换论证两步):

  1. 频率最小的两个符号 $x,y$ 在某棵最优树里深度最深且互为兄弟(否则交换到最深不增加 WPL);
  2. 合并 $x,y$ 为新符号 $z$($f_z=f_x+f_y$)后,原问题变成 $n-1$ 符号的子问题——由归纳,贪心持续最优。

3.4 SPT 调度

问题:单机、任务有处理时间 $p_i$,最小化平均完成时间。

贪心:按 $p_i$ 升序(Shortest Processing Time first)。

交换论证:若最优解中相邻两项 $p_a > p_b$(a 在 b 前),交换它们,只有这两项的完成时间变化:$p_a$ 延后 $p_b$、$p_b$ 提前 $p_a$,总完成时间减少 $p_a - p_b > 0$。所以最优解必按 $p_i$ 升序。

四、多语言实现

Go: 活动选择

type seg struct{ s, e int }

func maxActivities(a []seg) int {
    sort.Slice(a, func(i, j int) bool { return a[i].e < a[j].e })
    cnt, lastEnd := 0, -1
    for _, x := range a {
        if x.s >= lastEnd { // 不重叠
            cnt++
            lastEnd = x.e
        }
    }
    return cnt
}

Python: Huffman(证明等价的最小合并)

import heapq

def huffman_cost(freqs):
    heap = list(freqs)
    heapq.heapify(heap)
    total = 0
    while len(heap) > 1:
        a, b = heapq.heappop(heap), heapq.heappop(heap)
        total += a + b          # 每次合并的代价 = 树高累计
        heapq.heappush(heap, a + b)
    return total

五、工程里什么时候能用贪心

  1. 输入有强结构(区间、单机队列、树、双值)——先试交换论证;
  2. 能构造凸性:升级/定价问题里,代价函数凸时"最便宜优先"可证;
  3. 没有结构就老实 DP / 回溯(见 dp.mdbacktracking.md)。

六、易错清单

  1. 排序键选错:区间调度按 end 而非 start/长度;任务调度按 p_i 而非截止期(截止期问题是 EDF,另一套判据)。
  2. "看起来对"当证明:每个贪心必须配交换论证或反例自检——Huffman 看似显然,实际要两步交换证明。
  3. 贪心 vs DP 分界:0/1 背包、带权区间调度(需 DP)、硬币找零(面额不整除时)都不能贪心。

七、一页速查

判据:   最优子结构 + 无后效性 + 贪心选择性质
证明:   交换论证(把最优解调成贪心解不劣) / 归纳 / 拟阵
模板:   选排序键 → 贪心选 → 交换论证验证
经典:   区间(按end) / 部分背包(按unit) / Huffman(最小堆) / SPT(按p)
分界:   可切分→贪心; 不可切分带后效→DP

下一篇: 动态规划 (DP)

动态规划 (DP)

一句话

DP 不是技巧, 是一个关于"重叠子问题 + 最优子结构 + 无后效性"的数学结构. 一旦看清楚三个判断条件, 一道题拿本子画 5 分钟就能定下状态转移. 大多数人写不出 DP 都因为跳过了第 1 步: 精确定义状态.

三个判断

  1. 子问题重叠: 不同递归路径会重复计算同样的子问题.
  2. 最优子结构: 原问题的最优解由子问题最优解组合而成.
  3. 无后效性: 未来决策只依赖当前的状态而不依赖历史路径细节.

模板 5 步

  1. 定义状态 dp[i][j]精确含义;
  2. 写出转移方程 dp[i][j] = ...dp[i'][j']...;
  3. 设定初始 / 边界条件;
  4. 决定计算顺序: 拓扑序 or 递归 + memo;
  5. 还原方案: 从终态回溯路径.

tip

困难不在写代码, 而在第 1 步—精确定义状态常常是题目里能不能解出的关键. 别图省事定多状态: 能用 (i, j) 就别上 (i, j, k, …).

三大经典原型

1. 背包

类型状态转移
0/1 背包dp[i][w] = max(dp[i-1][w], dp[i-1][w-vi] + vi)
完全背包dp[i][w] = max(dp[i-1][w], dp[i][w-vi] + vi) 同一 i 可取多次
多重背包二进制拆点优化
分组背包同组任选一个

空间优化: 0/1 用 1D 倒序遍历; 完全使用 1D 正序.

2. LIS / LCS

  • LIS O(n²), 二分加速 O(n log n):
    tails[k] = 当前已知长度为 k+1 的 LIS 末尾最小值
    插入 x 时: bisect_left(tails, x), 替换或扩展
    
  • LCS O(n·m): dp[i][j] = dp[i-1][j-1] + 1 if a[i]==b[j] else max(dp[i-1][j], dp[i][j-1]).

3. 区间 DP

for len = 1..n:
  for i = 0..n-len:
    j = i + len - 1
    for k = i..j-1:
      dp[i][j] = combine(dp[i][k], dp[k+1][j], cost(i,j,k))

经典: 石子合并、矩阵链乘、戳气球 (LC 312).

状态压缩 DP

适用于状态空间小但树形多重集合 (n ≤ 20). 用 int 的位表示集合.

@cache
def dp(mask):
    if mask == target: return 0
    best = inf
    for i in range(n):
        if mask & (1 << i) == 0 and canUse(i, mask):
            best = min(best, dp(mask | (1 << i)) + cost(i))
    return best

LC 187 重复 DNA 序列、LC 1655 分配重复整数、TSP (LC 943).

经典题

  • LC 70 爬楼梯 (最入门);
  • LC 64 最小路径和;
  • LC 300 LIS、LC 1143 LCS;
  • LC 322 零钱兑换 (完全背包);
  • LC 312 戳气球 (区间 DP / 记忆化);
  • LC 5 最长回文子串 (中心扩展 vs Manacher);
  • LC 115 不同的子序列;
  • LC 32 最长有效括号.

易错

  1. 维度开得多 / 转移漏一种: 状态设计先在自己脑子里跑两遍.
  2. 依赖图错了: 把依赖必须在大小上呈现出来.
  3. 初始化语义错: dp[0] 表示 0 个物品 / 空间 / 边界, 常被误设.
  4. 没想清输出: 是 dp[终态], 还是要回看 max dp[*]? 比如 LC 152 最大子数组乘积要回扫.
  5. 忽略求最小值时 INIT 为 0 — 用 inf 初始化.

多语言对比

DP 表达在语言之间本质相同, 但实现形式略有差异:

  • Python @cache 装饰器让"记忆化搜索"一气呵成;
  • TypeScript 函数式写法依赖闭包, 但缺 memo 装饰器, 需要自写;
  • C++ 用全局 vector<vector<int>>, 1D 后二维滚动;
  • Go 没内置 memo, 通常闭包 + 手写 map[state]result 也可以.

这种差异无关算法本身, 而是关于语言如何让你写出清晰的状态转移表达. 但这是后续"运行时语义"那章的伏笔.

回溯与剪枝 (Backtracking)

TL;DR

回溯 = 带撤销的 DFS:在解空间树上深度优先搜索,发现当前路径不可能通向合法解就回退。它本身是指数级的,真正决定能不能跑完的是剪枝——可行性剪枝(约束不满足直接返回)、最优性剪枝(不可能优于已知最优)、重复等价剪枝(同层同值跳过)、下界剪枝(分支限界)。本章给状态机模板、四类剪枝的判定写法、N 皇后/子集/全排列三组带剪枝的实现(Go + Python),以及"什么情况下该换成 DP / 分支限界 / 随机化"的判断。

读完应能:

  1. 写出回溯万能模板:is_valid → apply → recurse → undo,并说清哪些状态需要显式撤销。
  2. 按"可行性 / 最优性 / 重复等价 / 下界"四类给任意回溯题设计剪枝。
  3. 处理两类经典坑:重复元素去重、引用 vs 值拷贝的撤销语义。
  4. 判断回溯 vs DP / 分支限界 / 迭代加深的适用边界。

一、模型与模板

solve(state):
  if is_terminal(state): record(state); return
  for choice in choices(state):
    if is_valid(choice):
      apply(choice)        # 修改状态 (要能撤销)
      solve(next_state)
      undo(choice)         # 撤销: 不撤销 = 泄漏状态

三个必须想清楚的点:

  1. 状态定义(已选集合, 剩余约束, 当前目标函数值)——状态冗余会导致重复搜索;
  2. 展开顺序:先做约束最强的分支(MRV 启发式),把失败剪枝提前;
  3. 终止条件:叶子(记录)或剪枝点(提前返回)。

note

回溯的复杂度 = 解空间大小 × 剪枝收益。纯 N 皇后 $O(n!)$;加列/对角线剪枝后常数骤降,但渐近仍是指数——剪枝优化的是"实际运行的规模",不改变指数阶。

二、四类剪枝

剪枝判定写法典型例子
可行性剪枝剩余资源 < 所需最小值组合总和:剩余预算 < 最小候选值即返回
最优性剪枝当前值 + 乐观上界 ≤ 已知最优旅行商、最大团
重复等价剪枝排序后同层跳过相同值含重复元素的全排列/子集
下界剪枝(分支限界)下界 ≥ 当前上界即剪0/1 背包分支限界

重复等价剪枝的正确姿势

# 组合: 同层不重复选相同值
def dfs(start, path):
    record(path)
    for i in range(start, n):
        if i > start and a[i] == a[i-1]:   # 同层跳过, 不是跨层
            continue
        dfs(i + 1, path + [a[i]])

warning

去重只看"同层":i > start and a[i] == a[i-1] 是组合去重;全排列去重要用 used[i-1] == False(前一个相同值未被使用,说明本层已展开过它)。把两者写反是经典 bug。

三、经典问题

3.1 N 皇后

按行放皇后,用 cols / diag1(i+j) / diag2(i-j) 三个集合 O(1) 判冲突:

func totalNQueens(n int) int {
    cols, d1, d2 := make([]bool, n), make([]bool, 2*n), make([]bool, 2*n)
    var dfs func(r int) int
    dfs = func(r int) int {
        if r == n { return 1 }
        cnt := 0
        for c := 0; c < n; c++ {
            if cols[c] || d1[r+c] || d2[r-c+n] { continue }
            cols[c], d1[r+c], d2[r-c+n] = true, true, true
            cnt += dfs(r + 1)
            cols[c], d1[r+c], d2[r-c+n] = false, false, false
        }
        return cnt
    }
    return dfs(0)
}

3.2 子集 / 组合 / 排列

  • 子集:每个元素选/不选($2^n$);
  • 组合for i in range(start, n) 天然有序,剪掉排列冗余;
  • 排列used[] 数组,可剪对称(首位固定一半)。

四、回溯的边界:什么时候别用

场景换什么原因
重叠子问题DP(见 dp.md回溯会重复计算同一状态
只要一个最优值、可行解很多分支限界 / 贪心近似剪枝可加下界
深度远大于宽度、答案在浅层迭代加深 IDDFS省内存、先出解
变量数巨大(>10^4)CP-SAT / 整数规划手写回溯规模不够

五、易错清单

  1. 撤销不完整path = path + [x](新对象)不需撤销;path.append(x) 必须配对 path.pop()
  2. 去重条件写错层级:同层 vs 跨层的 used 语义(见上);
  3. 剪枝顺序:先做可行性(便宜)再做最优性(要维护全局最优);
  4. 状态污染:数组/集合作为状态要深拷贝或用"应用-撤销"对。

六、一页速查

模板:   is_valid → apply → recurse → undo (带撤销 DFS)
剪枝:   可行性 / 最优性 / 重复等价 / 下界
去重:   组合=同层跳过; 排列=used[i-1]==False
启发式: 先展开约束最强的分支 (MRV)
换路:   重叠子问题→DP / 只要最优值→分支限界 / 深树浅解→IDDFS

下一篇: 分支限界

分支限界 (Branch & Bound)

与回溯的关系

  • 回溯目标是"找到所有/任一组解";
  • 分支限界目标是"找最优解", 多了一个下界估计: 当某个分支的"乐观估计已经差于当前最优"立刻剪.

模板

best = INF
Q = heap of (state, lowerBound)   # 优先队列按 lowerBound 升序
push (initial_state, lowerBound(initial_state))
while Q:
  state, lb = pop Q
  if lb >= best: continue       # 剪枝
  if terminal(state):
    if cost(state) < best: best = cost(state)
    continue
  for sub in expand(state):
    lb = lowerBound(sub)
    if lb < best: push(sub, lb)

不同的搜索策略

  • DFS + B&B: 内存小, 深度优先; 只保留当前路径. 常用于 TSP 大输入.
  • BFS + B&B (=Best-First Search): 优先队列里挑 lb 最小的, 类似 A* 思想.
  • iterative deepening: 迭代加深, 结合 DFS 内存友好与 BFS 完备性.

下界设计的艺术

下界越紧、剪枝越狠、运行越快. 例: TSP 一个简单紧的下界是 MST 权值 + 两条最小边修正; 0/1 背包下界是"剩余容量用最优单价物品填满".

经典应用

  • TSP: Held-Karp / B&B / cutting-plane;
  • 0/1 KP: 分支限界 + 上界 (部分 KP);
  • 整数规划: CPLEX / Gurobi 内部其中一类算法.

易错

  1. 下界不松不紧: 完全松 = 退化为回溯; 太紧 = 计算 lb 自己贵到不值得.
  2. best 初始化: 用贪心算法跑出一个上界作为 best 初值, 极大加速.
  3. 优先队列操作过度: 复杂 heap 操作可能让常数翻倍.

经典算法专题

排序: 从朴素到极致

一句话

排序的价值不在"会背十个算法", 而在看清两条根本不同的路线: 只用"比较"这条路被决策树下界 $\Omega(n \log n)$ 锁死, 于是只能在"平均常数、最坏保证、稳定性、空间、缓存友好"之间做折中——所以工业界才有 introsort (C++)、pdqsort (Go)、Timsort (Python/Java) 三种不同答案; 而一旦你愿意利用键的内部结构 (值域、位数、分布), 计数 / 基数 / 桶就能绕过下界做到线性。选错路线的代价是数量级的。

读完应能:

  1. 手写 Lomuto / Hoare / 三路三种分区, 解释 Hoare 为什么平均少换 3 倍、全等元素下一个 O(n²) 一个 O(n log n)。
  2. 证明 build-heap 是 O(n) 而非 O(n log n), 并说出堆排为什么只配当"兜底"。
  3. 按值域 / 位数 / 分布选择计数、基数、桶, 说清稳定性在其中扮演的角色。
  4. 复述 introsort 与 pdqsort 的完整防御链: pivot 选择 → 小数组插入 → 深度超限堆排 → 熵减 break patterns。

一、思想链

问题: 把 n 个可比大小的元素变成全序
  ├─ 路线 A: 只用"比较"这一种信息
  │    └─► 决策树下界 Ω(n log n) ─► 目标只剩: 常数最小 + 最坏不退化
  │          ├─ 快排: 平均最快, 生死于 pivot 选择 ─► 分区策略 + 多层兜底
  │          ├─ 归并: 稳定 + 最坏 O(n log n), 但要 O(n) 辅助 ─► 外排序 / 稳定需求的主力
  │          └─ 堆排: 最坏 O(n log n) + O(1) 空间, 但跳跃访问 cache-hostile ─► 只当兜底
  └─ 路线 B: 利用键的内部结构 (值域 / 位 / 分布)
       └─► 绕过比较下界: 计数 O(n+k) / 基数 O(d(n+b)) / 桶期望 O(n)
            └─► 代价: 需要稳定的辅助排序 + 对数据分布有假设

二、形式化定义与比较下界

排序问题:输入 $a_0, \dots, a_{n-1}$, 求排列 $\pi$ 使 $a_{\pi(0)} \le a_{\pi(1)} \le \cdots \le a_{\pi(n-1)}$。

稳定性:若 $a_i = a_j$($i < j$), 稳定排序保证输出里 $i$ 仍在 $j$ 前。业务排序(先按金额再按时间)大多隐式要求稳定。

比较排序的下界:任何只靠比较的算法, 其执行轨迹是一棵决策树; $n$ 个元素有 $n!$ 种可能排列, 树高至少

$$\log_2(n!) ;\approx; n\log_2 n - 1.44n ;=; \Omega(n \log n)$$

两个推论:

  • $O(n \log n)$ 就是路线 A 的天花板, 不存在"通用更快的比较排序";
  • 计数 / 基数 / 桶没有违反下界——它们不做比较, 用的是"键的位结构"这条额外信息, 下界的前提已经不成立。

三、分区: 快排的心脏

快排的一切性能差异都浓缩在一个函数里: 给定 pivot, 如何把区间切成两半。

3.1 Lomuto: 好写但换得多

partition(a, lo, hi):            # pivot = a[hi]
  i = lo                         # i 左边全是 < pivot 的
  for j in lo..hi-1:
    if a[j] < pivot: swap(a[i], a[j]); i++
  swap(a[i], a[hi]); return i    # pivot 落到最终位
  • 单向扫描, 逻辑一目了然, 但每个小于 pivot 的元素都触发一次交换, 平均交换 $(n-1)/2$ 次;
  • 全等元素的灾难: 所有比较都为假, 区间只缩小 1 → $O(n^2)$;
  • pivot 直接进最终位 → 递归 (lo, i-1)(i+1, hi)

3.2 Hoare: 双向扫描的原版

partition(a, lo, hi):            # pivot 值 = a[lo] (必须!)
  i, j = lo-1, hi+1
  loop:
    do i++ while a[i] < pivot
    do j-- while a[j] > pivot
    if i >= j: return j          # 注意: 返回 j, 不是 i
    swap(a[i], a[j])
  # 递归边界: (lo, j) 和 (j+1, hi)  ← 与 Lomuto 不同!

关键性质(可证):返回的 $j$ 满足 $lo \le j < hi$, 且 $a[lo..j] \le pivot \le a[j+1..hi]$; pivot 不一定落在最终位

  • 双向扫描, 平均交换只有约 $n/6$ 次——是 Lomuto 的 1/3, 这是它实测更快的主因;
  • 全等元素时两个指针在每个位置都停, 从两侧均匀推进 → 分裂平衡, $O(n \log n)$;
  • 安全锚点: pivot 必须取自区间内(工程上取三者中位数后换到 lo), 否则扫描会越界或死循环。

3.3 三路分区 (荷兰国旗): 重复元素的终极答案

quicksort(L, R):
  while L < R:
    pivot = median3(a[L], a[(L+R)/2], a[R])   # 中位数换到 L 后取 a[L]
    [lt, gt] = threeWayPartition(a, L, R, pivot)
    # a[L..lt-1] < pivot == a[lt..gt] < a[gt+1..R]
    if lt-L < R-gt: quicksort(L, lt-1); L = gt+1   # 先递归小的, 大的进循环
    else:           quicksort(gt+1, R); R = lt-1   # (尾递归消除, 栈 O(log n))

等于 pivot 的整段直接排除在递归之外, 重复度越高越快; 全部相同时一遍扫描结束, 总复杂度 $O(n)$。

note

工业实现的共同套路 = 三数取中(大数组九数取中)防有序输入 + 三路防重复 + 小数组转插入排序 + 深度超限转堆排。每一层都在堵一种退化, 缺一层就有对抗样本打穿你。

3.4 三种分区对比

维度LomutoHoare三路
扫描方向单向双向双向
平均交换次数$\approx n/2$$\approx n/6$更少(重复越多越少)
全等元素$O(n^2)$$O(n \log n)$$O(n)$
pivot 最终位不一定等值带整体确定
递归边界(lo,i-1),(i+1,hi)(lo,j),(j+1,hi)(lo,lt-1),(gt+1,hi)
适用教学 / 短代码通用默认重复键多的数据

四、堆排: 最坏有保障, 但只配当兜底

siftDown(a, i, n):              # 下沉: 与较大的孩子比, 直到不违反堆序
  while 2i+1 < n:
    c = 2i+1; if a[c+1] > a[c]: c++
    if a[i] >= a[c]: break
    swap(a[i], a[c]); i = c

buildHeap:  for i = n/2-1 downto 0: siftDown(i, n)     # 自底向上, O(n)!
sort:       for end = n-1 downto 1: swap(a[0], a[end]); siftDown(0, end)

为什么 buildHeap 是 $O(n)$: 高度为 $h$ 的节点至多 $\lceil n/2^{h+1}\rceil$ 个, 每个下沉代价 $\le h$, 求和

$$\sum_{h=0}^{\lfloor \log n\rfloor} \frac{n}{2^{h+1}} \cdot h ;\le; n \sum_{h\ge 0} \frac{h}{2^{h+1}} ;=; 2n$$

为什么不配当主力: 每次 siftDown 在堆的相邻层之间跳(下标 $i \to 2i$, 物理距离指数增长), 缓存命中率远低于归并的顺序访问; 所以它是 introsort / pdqsort 的"最坏情况保险丝", 而不是日常路径。见 heap.md

五、归并: 稳定与外排序的地基

  • 自顶向下递归分半, 或自底向上按 width = 1, 2, 4, ... 两两合并(免递归栈);
  • 合并时左边相等优先 → 稳定性由此而来;
  • 一个共享缓冲区反复使用, 空间 $O(n)$; 顺序访问 → cache 友好;
  • 自然归并(先扫出已有序的 run 再合并)是 Timsort 的直系祖先;
  • 外排序:数据远大于内存时, 分块排序落盘, 再做 k 路合并(败者树 / 堆, k 可达数百)。MapReduce 的 shuffle sort phase 本质就是这个

tip

口诀: "快排求快, 归并求稳, 堆排求保底, 线性求特例。"

六、绕过比较下界: 计数 / 基数 / 桶

算法前提复杂度空间稳定?
计数排序值域 $[0,k]$ 小$O(n+k)$$O(k)$✅(逆序回填实现)
基数 LSD定长 $d$ 位、基 $b$$O(d(n+b))$$O(n+b)$✅(每一位必须稳定)
基数 MSD字符串 / 变长键同上, 常更早停同上递归桶内自然处理
桶排序键近似均匀分布期望 $O(n)$, 最坏 $O(n^2)$$O(n)$取决于桶内排序

三个要点:

  1. 计数排序的前缀和就是名次表cnt[x] 累计后表示"≤ x 的元素个数", 逆序回填保证稳定;
  2. 基数排序每一位都必须用稳定排序(通常就是计数排序)——低位排好的相对次序要在高位相同的时候保留下来;
  3. 基数的最优选择: $d = \lceil \log_b W \rceil$ 趟, 总代价 $O\big(\frac{W}{\log b}(n+b)\big)$, 取 $b \approx n$ 得 $O(nW/\log n)$——这是"字 RAM 模型下整数排序能突破 $n\log n$"说法的来源。负数要先平移或翻转符号位。

七、稳定性矩阵

算法平均最坏额外空间稳定一句话点评
插入$O(n^2)$$O(n^2)$$O(1)$近乎有序时 $O(n)$, 所有工业排序的小数组底层
冒泡$O(n^2)$$O(n^2)$$O(1)$教学用
选择$O(n^2)$$O(n^2)$$O(1)$远距离 swap 破坏稳定
希尔$\sim O(n^{1.3})$依增量序列$O(1)$Ciura 序列实测很强
归并$O(n\log n)$$O(n\log n)$$O(n)$稳定需求的默认答案
Timsort$O(n)$ 最好$O(n\log n)$$O(n)$真实世界数据的王者
快速$O(n\log n)$$O(n^2)$(未加固)$O(\log n)$ 栈平均最快的原地排序
三路快排$O(n\log n)$$O(n\log n)$$O(\log n)$ 栈重复键场景最优
堆排$O(n\log n)$$O(n\log n)$$O(1)$最坏保证 + 最省空间, cache 差
Introsort / pdqsort$O(n\log n)$$O(n\log n)$$O(\log n)$C++ / Go 的工业答案
计数 / 基数线性线性$O(n+k)$值域 / 定长键专属

八、工业实现: 它们在防什么

8.1 C++ std::sort = Introsort (libstdc++ 骨架)

depth_limit = 2 * log2(n)
introsort_loop(lo, hi, limit):
  while hi - lo > 16:                      # 小于阈值留给收尾的插入排序
    if limit-- == 0: heap_sort(lo, hi)     # 深度耗尽 → 堆排接管, 最坏 O(n log n)
    cut = partition(median_of_3)           # 三数取中防有序输入
    introsort_loop(cut, hi, limit)         # 递归一半
    hi = cut                               # 另一半进循环 (尾递归消除)
final_insertion_sort(lo, hi)               # 整体几乎有序, 收尾近 O(n)

std::stable_sort 则是归并: 内存够就用缓冲区合并, 分配失败退化为就地旋转合并。

8.2 Go sort / slices = pdqsort (Go 1.19+)

pdqsort (pattern-defeating quicksort) 在 introsort 的基础上加了两层"模式识别":

  1. best case $O(n)$: 检测到近乎有序时, 用受限次数的插入排序直接收尾, 不再递归;
  2. break patterns (熵减): 发现连续两次分区都不平衡时, 说明 pivot 策略被输入的模式针对了——随机抽元素换进 pivot 位置、并打散部分数据, 让对抗样本失效;
  3. 其余同 introsort: 小数组(阈值 ~12)插入排序、深度上限 $\log_2 n$、超限堆排兜底、重复元素切三路。

结果: 最好 $O(n)$、平均 $O(n\log n)$、最坏也有 $O(n\log n)$, 不稳定、无额外内存。Go 的 sort.Sort / sort.Slice / slices.Sort 都是它; sort.SliceStable 用的是插入 + SymMerge 的稳定归并。

8.3 其它语言的答案(各有理由)

  • Python sorted / Java 对象排序 = Timsort: 抓真实数据里天然有序的 run(minrun 32-64), 合并时维持栈不变量并用 galloping 加速; 最好 $O(n)$(已序 / 逆序输入); CPython 3.11 起合并策略升级为 powersort;
  • Java Arrays.sort(int[]) = 双轴快排: 原始类型没有稳定性诉求, 双 pivot 减少数据趟数、对 cache 更友好;
  • Rust: 1.81 前不稳定排序就是 pdqsort, 之后换成同门的 ipnsort / 稳定 driftsort, 思路一脉相承。

note

这些选择的共同逻辑: 语言标准库不知道你的数据长什么样, 所以必须同时押注"平均快"(快排系)和"最坏不崩"(堆排兜底); 而 Python/Java 面向的对象排序更常遇到"部分有序的真实数据", 所以押注 Timsort。

九、多语言实现

Python: 三路快排 + 计数 + 基数

def three_way_partition(a, lo, hi):
    """返回 (lt, gt): a[lo:lt] < p == a[lt:gt+1] < a[gt+1:hi+1]。O(hi-lo)。"""
    pivot = a[(lo + hi) // 2]
    i, lt, gt = lo, lo, hi
    while i <= gt:
        if a[i] < pivot:
            a[i], a[lt] = a[lt], a[i]; lt += 1; i += 1
        elif a[i] > pivot:
            a[i], a[gt] = a[gt], a[i]; gt -= 1   # i 不动: 换过来的还没看过
        else:
            i += 1
    return lt, gt


def quicksort(a, lo=0, hi=None):
    """三路 + 中位数三取 + 尾递归消除。平均 O(n log n), 栈 O(log n), 就地, 不稳定。"""
    if hi is None:
        hi = len(a) - 1
    while lo < hi:
        lt, gt = three_way_partition(a, lo, hi)
        if lt - lo < hi - gt:                    # 先递归较小的那一半
            quicksort(a, lo, lt - 1); lo = gt + 1
        else:
            quicksort(a, gt + 1, hi); hi = lt - 1
    return a


def counting_sort(a):
    """值域非负整数。O(n+k) 时间 / O(k) 空间, 稳定。k = max(a)。"""
    if not a:
        return a
    k = max(a)
    cnt = [0] * (k + 1)
    for x in a:
        cnt[x] += 1
    for i in range(1, k + 1):
        cnt[i] += cnt[i - 1]                     # 前缀和 => "≤ i 的名次"
    out = [0] * len(a)
    for x in reversed(a):                        # 逆序回填 => 稳定
        cnt[x] -= 1
        out[cnt[x]] = x
    return out


def lsd_radix_sort(a, bits_per_digit=8):
    """非负整数。O(d(n+b)), d=字长/8, b=256。每一位都是稳定计数排序。"""
    if not a:
        return a
    mask, shift = (1 << bits_per_digit) - 1, 0
    buf = list(a)
    while max(buf) >> shift > 0:
        cnt = [0] * (mask + 2)
        for x in buf:
            cnt[(x >> shift) & mask] += 1
        for i in range(1, mask + 2):
            cnt[i] += cnt[i - 1]
        nxt = [0] * len(buf)
        for x in reversed(buf):
            d = (x >> shift) & mask
            cnt[d] -= 1
            nxt[cnt[d]] = x
        buf = nxt
        shift += bits_per_digit
    return buf

Go: 堆排 + 归并 + 简化版 pdqsort

package main

import (
	"fmt"
	"math/bits"
)

func insertionSort(a []int, lo, hi int) { // 闭区间; 小数组之王
	for i := lo + 1; i <= hi; i++ {
		for j := i; j > lo && a[j] < a[j-1]; j-- {
			a[j], a[j-1] = a[j-1], a[j]
		}
	}
}

func heapSortRange(a []int, lo, hi int) { // 闭区间; introsort/pdqsort 的兜底
	n := hi - lo + 1
	var sift func(root, size int)
	sift = func(root, size int) {
		for {
			c := 2*root + 1
			if c >= size {
				return
			}
			if r := c + 1; r < size && a[lo+r] > a[lo+c] {
				c = r
			}
			if a[lo+root] >= a[lo+c] {
				return
			}
			a[lo+root], a[lo+c] = a[lo+c], a[lo+root]
			root = c
		}
	}
	for i := n/2 - 1; i >= 0; i-- {
		sift(i, n) // 自底向上建堆: O(n)
	}
	for end := n - 1; end > 0; end-- {
		a[lo], a[lo+end] = a[lo+end], a[lo]
		sift(0, end)
	}
}

// MergeSort: 稳定, 最坏 O(n log n), O(n) 缓冲; 顺序访问, 外排序的内核
func MergeSort(a []int) {
	n := len(a)
	buf := make([]int, n)
	merge := func(lo, mid, hi int) {
		i, j, k := lo, mid, lo
		for ; i < mid && j < hi; k++ {
			if a[j] < a[i] { // 相等取左 => 稳定
				buf[k] = a[j]
				j++
			} else {
				buf[k] = a[i]
				i++
			}
		}
		for ; i < mid; i, k = i+1, k+1 {
			buf[k] = a[i]
		}
		for ; j < hi; j, k = j+1, k+1 {
			buf[k] = a[j]
		}
		copy(a[lo:hi], buf[lo:hi])
	}
	for w := 1; w < n; w *= 2 {
		for lo := 0; lo+w < n; lo += 2 * w {
			hi := lo + 2*w
			if hi > n {
				hi = n
			}
			merge(lo, lo+w, hi)
		}
	}
}

// medianToLo: 三数取中并把中位数换到 lo —— pivot 停在 lo 是 Hoare 扫描不越界的锚点
func medianToLo(a []int, lo, hi int) {
	mid := lo + (hi-lo)/2
	if a[mid] < a[lo] {
		a[mid], a[lo] = a[lo], a[mid]
	}
	if a[hi] < a[mid] {
		a[hi], a[mid] = a[mid], a[hi]
	}
	if a[mid] < a[lo] {
		a[mid], a[lo] = a[lo], a[mid]
	}
}

// hoarePartition: 返回 j, a[lo..j] <= pivot <= a[j+1..hi]; 递归边界是 (lo,j)/(j+1,hi)
func hoarePartition(a []int, lo, hi int) int {
	pivot := a[lo]
	i, j := lo-1, hi+1
	for {
		for i++; a[i] < pivot; i++ {
		}
		for j--; a[j] > pivot; j-- {
		}
		if i >= j {
			return j
		}
		a[i], a[j] = a[j], a[i]
	}
}

// pdqsort 骨架: 完整版还含 breakPatterns(对抗输入熵减)、重复元素三路、近乎有序提前退出
func pdqsort(a []int, lo, hi, limit int) {
	for hi-lo > 12 { // 小于阈值交给插入排序
		if limit == 0 { // 深度耗尽 -> 堆排兜底, 保证最坏 O(n log n)
			heapSortRange(a, lo, hi)
			return
		}
		limit--
		medianToLo(a, lo, hi)
		m := hoarePartition(a, lo, hi)
		if m-lo < hi-m {
			pdqsort(a, lo, m, limit) // 只递归较小的一半
			lo = m + 1               // 较大的一半留在循环里: 栈深 O(log n)
		} else {
			pdqsort(a, m+1, hi, limit)
			hi = m
		}
	}
	insertionSort(a, lo, hi)
}

// PdqSort: 最好 O(n) / 平均 O(n log n) / 最坏 O(n log n), 不稳定
func PdqSort(a []int) {
	pdqsort(a, 0, len(a)-1, bits.Len(uint(len(a))))
}

func main() {
	a := []int{5, 2, 9, 1, 5, 6, 0, 3, 8, 7, 4, 2, 6}
	PdqSort(a)
	fmt.Println(a) // [0 1 2 2 3 4 5 5 6 6 7 8 9]

	b := []int{3, 1, 4, 1, 5, 9, 2, 6}
	MergeSort(b)
	fmt.Println(b) // [1 1 2 3 4 5 6 9]
}

C++: introsort 骨架(示意, 非 drop-in)

#include <cmath>

constexpr int kThreshold = 16;

void introsortLoop(int* lo, int* hi, int limit) {
    while (hi - lo > kThreshold) {
        if (limit-- == 0) { heapSort(lo, hi); return; }   // 兜底
        int* cut = unguardedPartition(
            lo + 1, hi,
            medianOf3(*lo, *(lo + (hi - lo) / 2), *(hi - 1)));
        introsortLoop(cut, hi, limit);   // 递归右半
        hi = cut;                        // 左半进循环
    }
}

void sortImpl(int* lo, int* hi) {
    introsortLoop(lo, hi, 2 * int(std::log2(double(hi - lo))));
    finalInsertionSort(lo, hi);          // 收尾: 此时几乎有序, 近 O(n)
}

十、工程现实速览

  • Python 默认 sorted = Timsort(稳定, 对部分有序数据近乎线性);
  • Go sort.Slice / slices.Sort = pdqsort(不稳定, 最坏有保证); 要稳定显式用 sort.SliceStable;
  • C++ std::sort = introsort(不稳定); 要稳定用 std::stable_sort;
  • Java 对象 = TimSort, 原始类型数组 = 双轴快排;
  • 业务排序需要二级键时, 要么写复合比较器, 要么"先按次键稳定排、再按主键稳定排"(两次稳定排序)。

warning

C++ 里比较器写成 a <= b(违反严格弱序)是未定义行为, 可能越界崩溃; Go 会 panic。比较器必须是严格的 < 语义: 相等时返回 false。另外中点计算用 lo + (hi-lo)/2(lo+hi) 溢出——32 位下百万级下标就可能踩雷。

十一、易错清单

  1. Hoare 的递归边界写成 (lo, j-1): 正确是 (lo, j)(j+1, hi)——pivot 不一定在最终位, 写错会丢元素或死循环;
  2. Lomuto 遇全等元素 $O(n^2)$: 重复键多的数据必须三路;
  3. buildHeap 误以为 $O(n\log n)$: 自顶向下逐个插入才是 $O(n\log n)$, 自底向上下沉是 $O(n)$;
  4. 计数排序忘了逆序回填: 前缀和名次表配正序回填会把稳定性丢掉, 连累基数排序一起错;
  5. 基数排序处理负数: 直接移位会把符号位当数值, 先平移或翻转符号位;
  6. 拿堆排当主力: 它赢在最坏情况和空间, 输在 cache; 大数据量顺序访问的归并系反而更快;
  7. Top-K 问题用全排序: 只要前 k 个用快速选择(平均 $O(n)$)或大小为 k 的堆($O(n\log k)$), 别全排。

十二、经典题

  • LC 912 排序数组(手写快排 / 归并的试金石, 卡常必练);
  • LC 215 数组中第 K 大(快速选择平均 $O(n)$; 进阶: 中位数的中位数 $O(n)$ 最坏保证);
  • LC 75 颜色分类(荷兰国旗, 三路分区裸题);
  • LC 23 合并 K 个升序链表(k 路归并 + 小根堆);
  • LC 315 计算右侧小于当前元素的个数(归并排序副产物 / 树状数组);
  • LC 164 最大间距(鸽笼 + 桶, 要求线性时间);
  • LC 179 最大数(自定义比较器 a+b > b+a, 注意严格弱序);
  • LC 56 合并区间(排序后线性扫描)。

一页速查

下界:   比较排序 Ω(n log n); 计数/基数/桶靠键结构绕过
分区:   Lomuto 好写换得多 | Hoare 换少 3 倍, 边界 (lo,j)/(j+1,hi) | 三路治重复
堆排:   buildHeap O(n); 最坏 O(n log n) + O(1) 空间; cache 差 → 只当兜底
归并:   稳定的来源; 顺序访问 → 外排序 / k 路合并的内核
线性:   计数 O(n+k) 前缀和逆序回填 | 基数 O(d(n+b)) 每位必须稳定 | 桶看分布
工业:   introsort(C++) / pdqsort(Go): 取中+插入+深度限制堆排兜底(+熵减)
        Timsort(Python/Java 对象): 抓天然 run, 最好 O(n)
坑:     Hoare 边界 | 全等元素 | 严格弱序比较器 | (lo+hi)/2 溢出 | 负数基数

下一篇: 搜索: 二分与三分

搜索: 二分与三分

一句话

二分的难点从来不在"取中点", 而在两件事: 把问题翻译成"单调谓词上的分界点", 以及把循环不变式写对。只要你能把题目改写成"存在位置 $k$, 使得 $P(0..k-1)$ 全假、$P(k..n-1)$ 全真", 剩下的就是一个模板; 有序数组、旋转数组、二分答案、双数组中位数全是同一件事的换皮。三分则是它的近亲: 单调性换成单峰性, 求的是极值点。

读完应能:

  1. 用循环不变式证明 lower_bound 的正确性, 并解释为什么半开区间写法几乎不出 bug。
  2. 从一个 lower_bound 推导出 upper_bound、存在性、计数、前驱后继的全部模板。
  3. 处理旋转数组的四道题(LC 33/81/153/154), 并说清含重复时为什么必然退化到 $O(n)$。
  4. 把"最小化最大值 / 最大化最小值"类问题套进二分答案框架, 写出纯判定的 check。

一、思想链

暴力线性扫描 O(n)
  └─► 数据有序 / 谓词单调: 每次比较扔掉一半 → O(log n)
       └─► 统一视角: 在布尔函数 P 上求第一个 true 的位置 (分界点 k)
             ├─ 有序数组:   P(i) = (a[i] >= x)          → lower_bound
             ├─ 计数:       count(x) = ub(x) - lb(x)
             ├─ 旋转数组:   分段单调 → 先用端点判断哪半有序
             ├─ 二分答案:   P(cap) = "容量 cap 可行" (可行域单调) → 答案空间上二分
             └─ 浮点/实数域: 固定迭代次数代替 eps
                  └─ 单峰函数求极值: 单调性没了 → 三分法

二、形式化定义: 循环不变式

单调谓词: $P$ 满足 $P(i) \Rightarrow P(j)\ (\forall j > i)$, 即形如 false...false true...true。二分求的是第一个 true 的下标 $k = \min{i : P(i)}$(不存在则为 $n$)。

以 lower_bound 为例, 半开区间写法 [lo, hi) 的不变式:

前提 L: 所有 i < lo 都有 a[i] <  x
前提 R: 所有 i >= hi 都有 a[i] >= x
初始: lo=0, hi=n —— 两个前提都平凡成立
保持: mid 处 a[mid] < x  → 由单调性 mid 左边全 < x → lo = mid+1 不破坏 L/R
      否则              → hi = mid 不破坏 L/R
终止: lo == hi, 两前提拼接 ⇒ a[lo] 是第一个 >= x 的位置

这就是半开区间几乎不出 bug 的原因: 不变式两端各有明确含义, 且 lo = mid + 1 保证每轮严格前进, 不存在停滞状态。

warning

闭区间写法 [lo, hi] 里若收缩方向是 lo = mid, 中点必须向上取整 (lo+hi+1)/2, 否则两元素区间里 mid 永远等于 lo, 死循环。两种写法选一种背熟, 不要现场混搭。

另一个工程细节: 中点一律写 mid = lo + (hi-lo)/2, (lo+hi)/2 在 32 位整型下会溢出(下标超过 $2^{31}/2$ 就可能触发)。

三、模板族: 一个 lower_bound 派生一切

def lower_bound(a, x):
    """第一个 >= x 的下标; 不存在则 len(a)。不变式见上文。"""
    lo, hi = 0, len(a)
    while lo < hi:
        mid = lo + (hi - lo) // 2
        if a[mid] < x:
            lo = mid + 1
        else:
            hi = mid
    return lo


def upper_bound(a, x):
    """第一个 > x 的下标。只改一个比较符。"""
    lo, hi = 0, len(a)
    while lo < hi:
        mid = lo + (hi - lo) // 2
        if a[mid] <= x:
            lo = mid + 1
        else:
            hi = mid
    return lo

其余需求全部是这两个的组合:

需求表达式
插入位置(保持有序)lb = lower_bound(a, x)
x 是否存在lb < n and a[lb] == x
最后一个 < xlb - 1(需判 lb > 0
第一个 > xub = upper_bound(a, x)
最后一个 <= xub - 1(需判 ub > 0
x 出现次数ub - lb
x 的区间 [first, last][lb, ub-1]

各语言标准库对照

语言API备注
Pythonbisect.bisect_left / bisect_right返回插入点, 不判存在性, 要自己查界与查等
Gosort.Search(n, f) / slices.BinarySearch / slices.BinarySearchFuncf 就是谓词, 返回第一个 true 的 i
C++lower_bound / upper_bound / equal_range迭代器, 减 begin() 得下标
JavaCollections.binarySearch找不到返回 -(插入点)-1

note

Go 的 sort.Search 把二分抽象到了极致: 它不要求有序数组, 只要求你给一个单调谓词 f(i) bool, 返回最小的使 f(i)==truei ∈ [0,n)。二分答案直接用它, 连循环都不用写——这是"二分 = 单调谓词分界点"这个观点最直接的库证据。

四、旋转数组族: 分段单调的处理

数组在某个未知点被旋转过(如 [4,5,6,7,0,1,2])。它不再全局单调, 但有一个关键观察:

任意切一刀 mid, 至少一半是完全有序的。

于是每轮先判断哪半有序, 再判断 target 是否落在那个有序区间内:

搜索 LC 33 (无重复):
  if a[lo] <= a[mid]:            # 左半 [lo..mid] 有序 (注意等号!)
      if a[lo] <= target < a[mid]: hi = mid - 1   # target 在左半
      else:                        lo = mid + 1
  else:                          # 右半 [mid..hi] 有序
      if a[mid] < target <= a[hi]: lo = mid + 1
      else:                        hi = mid - 1

找最小值(LC 153)更简单, 只需要和右端点比:

if a[mid] > a[hi]:  最小值在 (mid, hi]  → lo = mid + 1
else:               最小值在 [lo, mid]  → hi = mid     (mid 可能就是答案)

含重复(LC 81 / 154)时会出现 a[lo] == a[mid] == a[hi], 此时无法判断哪边有序, 只能收缩一步(hi--lo++), 最坏退化到 $O(n)$。

warning

这个退化是信息论意义上不可避免的: [1,1,1,...,1] 里找一个藏在中间的 2, 任何算法都必须检查每个元素。所以面试答"含重复最坏 O(n)"要补上这句理由, 这才是得分点。

五、二分答案: 在答案空间上二分

当题目问"最小的满足 X 的容量 / 速度 / 天数"且可行域随参数单调时, 对参数本身二分, 每步用一个 $O(n)$ 的纯判定函数 check(mid):

最小化最大值 (LC 410 切分数组): P(s) = 能否切成 m 段、每段和 ≤ s
                                s 越大越容易 → 找第一个 true; 下界 max(a), 上界 sum(a)
最大化最小值 (LC 1552 放磁铁): P(d) = 能否放 m 个球、间距 ≥ d
                                d 越小越容易 → 找最后一个 true = 第一个 false 的前一个
吞吐型     (LC 875 吃香蕉): P(k) = 以速度 k 能否在 h 小时内吃完

三个纪律:

  1. check 必须只做判定, 返回 bool; 在里面偷偷构造完整方案通常意味着你想复杂了;
  2. 整数答案用闭区间 [lo, hi] 收缩, 终止于一点; 浮点答案不要用 while r-l > eps 硬卡精度, 固定迭代 ~100 次(每次区间折半, 精度指数收敛)更稳;
  3. 上下界必须覆盖解所在的范围: 下界太松只是多跑几轮, 上界太紧直接漏解。

六、双数组中位数 (LC 4): 分割线视角

在两个有序数组里找中位数, $O(\log\min(m,n))$ 的推导:

$$\text{在短数组 } A \text{ 上二分分割点 } i,\quad j = \frac{m+n+1}{2} - i$$

分割合法当且仅当两侧互不越界地满足:

$$A_{i-1} \le B_j ;\wedge; B_{i-1} \le A_j$$

  • 若 $A_{i-1} > B_j$: $i$ 太大 → 左移;
  • 若 $B_{i-1} > A_j$: $i$ 太小 → 右移;
  • 合法时: 总数为奇数取 $\max(A_{i-1}, B_{j-1})$, 偶数取与 $\min(A_i, B_j)$ 的平均。

边界用哨兵处理: $i=0$ 时左半没有 A 元素, 视 $A_{i-1} = -\infty$; $i=m$ 时视 $A_m = +\infty$(B 同理)。这题的价值在于示范: 二分的对象可以是"分割方案"而不一定是数组元素

七、三分法: 单峰函数的极值

单调性换成单峰性后二分失效——中点两侧无法比较出"方向"。三分法在区间内取两个对称点, 用它们的函数值排除掉一段:

def ternary_min(f, lo, hi, iters=200):
    """实数单谷函数 f 的极小值点。O(iters), 与 eps 无关的固定迭代。"""
    for _ in range(iters):
        m1 = lo + (hi - lo) / 3
        m2 = hi - (hi - lo) / 3
        if f(m1) <= f(m2):          # 谷底不可能在 m2 右侧
            hi = m2
        else:                       # 谷底不可能在 m1 左侧
            lo = m1
    return (lo + hi) / 2

整数版把 (m1, m2) 改成三等分点并小心处理 f(m1) == f(m2)(保留两侧都含极值的那个收缩方向即可)。

适用条件与坑:

  • 要求严格单峰(或单谷); 平台(相等的一段平地)会让三分失明——f(m1)==f(m2) 时谷底可能在平台两端之间任意处;
  • 凸函数更稳的做法是对导数(差分)二分, 或直接凸优化;
  • 多峰函数必须先分段(如抛物线拟合 / 分治)再逐段三分。

八、多语言实现

Python: bisect 应用 + 二分答案

from bisect import bisect_left, bisect_right


def search_rotated(a, target):
    """LC 33: 旋转数组找 target。O(log n), 无重复元素。"""
    lo, hi = 0, len(a) - 1
    while lo <= hi:
        mid = lo + (hi - lo) // 2
        if a[mid] == target:
            return mid
        if a[lo] <= a[mid]:                      # 左半有序
            if a[lo] <= target < a[mid]:
                hi = mid - 1
            else:
                lo = mid + 1
        else:                                    # 右半有序
            if a[mid] < target <= a[hi]:
                lo = mid + 1
            else:
                hi = mid - 1
    return -1


def split_array_min_max(nums, m):
    """LC 410: 切成 m 段使最大段和最小。二分答案 + 贪心判定, O(n log sum)。"""
    def pieces_fit(cap):                         # 纯判定: 每段和不超过 cap 最少几段
        cnt, cur = 1, 0
        for x in nums:
            if cur + x > cap:
                cnt += 1
                cur = x
            else:
                cur += x
        return cnt <= m

    lo, hi = max(nums), sum(nums)                # 解必在 [max, sum] 内
    while lo < hi:
        mid = lo + (hi - lo) // 2
        if pieces_fit(mid):
            hi = mid                             # 可行 → 试更小的
        else:
            lo = mid + 1
    return lo


def count_in_sorted(a, x):
    """x 的出现次数: ub - lb, O(log n)。"""
    return bisect_right(a, x) - bisect_left(a, x)

from bisect import bisect_right as _br  # noqa: E402  (演示用别名)
bisect_left, bisect_right = bisect_left, _br

Go: 泛型 lowerBound + sort.Search 二分答案 + 旋转数组

package main

import (
	"fmt"
	"sort"
)

// LowerBound: 泛型版, 半开区间 [lo, n); 返回第一个 >= x 的下标
func LowerBound[T ~int | ~float64 | ~string](a []T, x T) int {
	lo, hi := 0, len(a)
	for lo < hi {
		mid := lo + (hi-lo)/2 // int 溢出在此写法下安全; 大数场景用 big.Int
		if a[mid] < x {
			lo = mid + 1
		} else {
			hi = mid
		}
	}
	return lo
}

// SplitArrayMinMax: LC 410, 二分答案; sort.Search 直接吃单调谓词
func SplitArrayMinMax(nums []int, m int) int {
	feasible := func(capacity int) bool {
		cnt, cur := 1, 0
		for _, x := range nums {
			if cur+x > capacity {
				cnt++
				cur = x
			} else {
				cur += x
			}
		}
		return cnt <= m
	}
	sum, maxV := 0, 0
	for _, x := range nums {
		sum += x
		if x > maxV {
			maxV = x
		}
	}
	return sort.Search(sum+1, func(c int) bool { return c >= maxV && feasible(c) })
}

// SearchRotated: LC 33, 旋转数组
func SearchRotated(a []int, target int) int {
	lo, hi := 0, len(a)-1
	for lo <= hi {
		mid := lo + (hi-lo)/2
		switch {
		case a[mid] == target:
			return mid
		case a[lo] <= a[mid]:
			if a[lo] <= target && target < a[mid] {
				hi = mid - 1
			} else {
				lo = mid + 1
			}
		default:
			if a[mid] < target && target <= a[hi] {
				lo = mid + 1
			} else {
				hi = mid - 1
			}
		}
	}
	return -1
}

func main() {
	fmt.Println(LowerBound([]int{1, 2, 2, 3}, 2))            // 1
	fmt.Println(SplitArrayMinMax([]int{7, 2, 5, 10, 8}, 2))  // 18
	fmt.Println(SearchRotated([]int{4, 5, 6, 7, 0, 1, 2}, 0)) // 4
}

C++: 标准库姿势与自定义谓词

#include <algorithm>
#include <vector>

bool exists(const std::vector<int>& a, int x) {
    auto it = std::lower_bound(a.begin(), a.end(), x);
    return it != a.end() && *it == x;
}

int countInRange(const std::vector<int>& a, int loVal, int hiVal) {
    // equal_range 一次拿回 [first, last): 区间长度即出现次数
    auto r = std::equal_range(a.begin(), a.end(), loVal);   // 单点示例
    (void)r;
    return int(std::lower_bound(a.begin(), a.end(), hiVal) -
               std::lower_bound(a.begin(), a.end(), loVal));
}

// 自定义谓词: 在递减数组里找最后一个 >= x —— 谓词翻转即可复用 lower_bound
auto lastGE = std::lower_bound(a.rbegin(), a.rend(), x, std::greater<int>{});

九、边界测试清单

二分是 bug 密集区, 交付前过一遍这张表:

  1. 空数组 / 单元素 / 两元素(最容易暴露死循环的规模);
  2. 全部元素相同(检验等号方向);
  3. target 是首元素 / 尾元素 / 不存在但小于全部 / 大于全部;
  4. lower_bound 返回 n(越界)时的下游访问是否防护;
  5. 32 位下标溢出: (lo+hi)/2 vs lo+(hi-lo)/2;
  6. 谓词恒真 / 恒假时返回值是否符合约定(Go sort.Search 返回 n);
  7. 浮点二分的迭代上限与相对误差(大数量级下绝对 eps 会失效)。

十、易错清单

  1. 闭区间模板配 lo = mid 却向下取整 → 死循环; 取整方向必须与"谁原地不动"匹配;
  2. bisect_left 返回值当存在性用: 它是插入点, 可能等于 len(a), 也可能指向不同值;
  3. check 里做完整构造: 判定函数应 $O(n)$ 贪心判定, 构造方案属于找到答案之后的第二阶段;
  4. 可行域不单调硬二分: 先论证"参数越大越可行(或反之)"再用二分, 否则结果是垃圾;
  5. 旋转数组忘了等号: a[lo] <= a[mid]= 决定两元素区间 [lo, lo+1] 是否正确归类;
  6. 含重复仍声称 $O(\log n)$: 正确答案是均摊最坏 $O(n)$ 并说明原因;
  7. 无序数据上二分: 局部有序 ≠ 全局有序; 先排序($O(n\log n)$)或换哈希表。

十一、经典题

  • LC 704 二分查找 / LC 35 搜索插入位置(lower_bound 直译);
  • LC 34 在排序数组中查找元素的第一个和最后一个位置(lb/ub 组合);
  • LC 33 / 81 搜索旋转排序数组(变形 + 含重复退化分析);
  • LC 153 / 154 寻找旋转排序数组中的最小值;
  • LC 4 寻找两个正序数组的中位数(分割线二分, $O(\log\min(m,n))$);
  • LC 410 分割数组的最大值 / LC 1011 运送包裹 / LC 875 吃香蕉 / LC 1552 磁力(二分答案四连);
  • LC 162 寻找峰值(局部单峰即可二分, 注意与三分的区别);
  • LC 69 x 的平方根 / LC 50 Pow(x, n)(数值域二分与快速幂, 见 number-theory.md)。

一页速查

统一视角: 二分 = 单调谓词 P 上求第一个 true (分界点 k)
模板:    半开 [lo, n): a[mid]<x ? lo=mid+1 : hi=mid; 终止 lo==hi 即答案
派生:    存在=lb==x | 个数=ub-lb | 前驱=lb-1 | 后继=ub
纪律:    mid=lo+(hi-lo)/2 | 闭区间 lo=mid 必须向上取整 | 判界防 n
旋转:    一刀至少半边有序 → 先判哪半有序; 含重复必然退化 O(n)
答案:    最小化最大值→第一个true | 最大化最小值→最后一个true | check 只判定
浮点:    别卡 eps, 固定迭代 ~100 次
三分:    单峰求极值; 平台会失明; 多峰先分段

下一篇: 字符串: KMP / Z / Rabin-Karp / AC 自动机

字符串: KMP / Z / Rabin-Karp / AC 自动机

一句话

字符串匹配的算法光谱, 本质是"匹配失败时, 已扫过的信息还能榨出多少": 暴力法把信息全扔了 (text 指针回退, $O(nm)$); KMP 用前缀函数把"已匹配部分的最长相等真前后缀"预计算好, 失配时 pattern 自己滑到自己内部的重叠处——text 指针永不回退, 线性; Z 函数换一个等价视角 ("每个后缀与整串的 LCP"), 好写且直观; Rabin-Karp 干脆放弃逐字符比较, 用滚动哈希给每个窗口发指纹, 期望线性但允许极小概率误报, 却因此免费解锁多模式与二维匹配; AC 自动机则是 KMP 的多模式推广——把 pattern 集合建成 Trie, fail 指针在 Trie 上完成同样的"滑到自己最长的真后缀前缀"。选型口诀: 单模式要确定性 → KMP/Z; 多模式 → AC; 要哈希才能做的题 (二维/重复子串/随机化) → Rabin-Karp

读完应能:

  1. 默写前缀函数 $\pi$ 与 Z 函数的定义, 并说清两者的相互转化;
  2. 解释 KMP 为什么是摊还线性 (势能论证), 以及"text 不回退"靠的是什么;
  3. 手写滚动哈希的窗口更新公式, 并说出双模数防卡的原因;
  4. 描述 AC 自动机 fail 指针的语义与 BFS 构建顺序。

思想链

问题: 长度 n 的 text 里找长度 m 的 pattern 所有出现
  └─► 暴力: 失配就从头再来 ─► O(nm) ─► 已匹配的前缀被白白扔掉
        └─► 关键观察: 已匹配前缀 = pattern 的一个前缀
              └─► 它自己内部的"前缀=后缀"重叠可以复用!
                    ├─ 前缀函数 π[i]: p[..i] 的最长相等真前后缀 ─► KMP
                    │     └─► text 指针不回退, pattern 跳到 π ─► O(n+m) 确定性
                    ├─ Z[i]: s[i..] 与 s 的 LCP ─► 同样信息的另一种记账 ─► Z 算法
                    └─ 不比较字符, 比较指纹?
                          └─► 多项式滚动哈希 ─► Rabin-Karp 期望 O(n+m)
                                └─► 指纹可批量算 ─► 多模式 / 二维 / +二分求重复子串
  └─► 一次扫 text 同时找 k 个 pattern?
        └─► 把 k 个 pattern 建成 Trie, KMP 的 π 推广成 fail 指针 ─► AC 自动机
              └─► fail(u) = u 串的最长真后缀 ∩ Trie 中存在的节点

形式化定义

给定文本 $s[0..n)$ 与模式 $p[0..m)$。匹配问题: 求所有 $i$ 使得 $s[i..i+m) = p$。约定字符集大小为 $\sigma$。

前缀函数 $\pi[i]$: 子串 $p[0..i]$ 的最长相等前后缀长度 ($\pi[0] = 0$, "真"排除整个子串自身)。例: ababab 的 $\pi = [0,0,1,2,3,4]$。

Z 函数 $z[i]$: 后缀 $s[i..n)$ 与 $s$ 本身的最长公共前缀长度 ($z[0] = n$)。例: aabxaab 的 $z = [7,1,0,0,3,1,0]$。

两者编码同一信息, 可 $O(n)$ 互转; 工程上 Z 更直观, KMP 的 $\pi$ 在失配跳转上更顺手。

note

为什么暴力会退化到 $O(nm)$? 最坏情形 aaaa...a 里找 aaa...ab: 每次失配只前进一格。KMP 的全部工作就是把这种"重复比较"消灭掉——而它用的工具正是 摊还分析 里的势能法。

KMP: 前缀函数与失配跳转

核心机制:设当前已匹配了 $k$ 个字符 (即 $s[j-k..j) = p[0..k)$), 下一位失配。暴力把 $j$ 回退; KMP 注意到已匹配段是 $p$ 的前缀, 若该前缀有长度为 $\pi[k-1]$ 的相等真前后缀, 则这两段后缀已经和 text 对齐过了——直接把 $k$ 跳到 $\pi[k-1]$, $j$ 一动不动:

text : ... a b a b | c ...
patt :     a b a b | a        ← 失配在 c vs a
π[3]=2: "abab" 有真前后缀 "ab" 相等
patt :         a b | a b a    ← k 直接跳到 2, text 指针没动过!
def build_next(p: str) -> list[int]:
    """前缀函数 π. π[i] = p[..i+1] 的最长相等真前后缀长度. O(m)."""
    nxt = [0] * len(p)
    k = 0
    for i in range(1, len(p)):
        while k > 0 and p[k] != p[i]:
            k = nxt[k - 1]               # 失配: 沿 π 链回退到次长候选
        if p[k] == p[i]:
            k += 1
        nxt[i] = k
    return nxt


def kmp(s: str, p: str) -> list[int]:
    """返回 p 在 s 中的全部起始下标. O(n + m)."""
    if not p:
        return []
    nxt, hits, k = build_next(p), [], 0
    for i, c in enumerate(s):
        while k > 0 and p[k] != c:
            k = nxt[k - 1]
        if p[k] == c:
            k += 1
        if k == len(p):                  # 完整命中
            hits.append(i - k + 1)
            k = nxt[k - 1]               # 继续找下一个 (允许重叠)
    return hits


if __name__ == "__main__":
    assert build_next("ababab") == [0, 0, 1, 2, 3, 4]
    assert build_next("abcdabc") == [0, 0, 0, 0, 1, 2, 3]
    assert kmp("ababcababcabd", "abc") == [2, 7]
    assert kmp("aaaa", "aa") == [0, 1, 2]          # 重叠匹配
    assert kmp("hello", "xz") == []

为什么是线性的——势能论证: 主循环里 $k$ 每次至多 $+1$, 所以全程增量 $\le n$; 而 while 循环每转一圈 $k$ 严格变小且永不为负, 总下降次数 $\le$ 总上升次数 $\le n$。两段相加 $O(n)$。构造阶段同理 $O(m)$。

Z 算法: 同一信息的另一种记法

$z[i]$ 的维护利用一条性质: 已知区间 $[l, r)$ 是当前最右的匹配段 (某后缀与 $s$ 的 LCP 达到过这里), 则 $z[i]\ (i < r)$ 至少是 $\min(z[i-l],\ r-i)$——因为 $s[i..r)$ 与 $s[i-l..r)$ 完全相同, 可以抄作业, 抄完再暴力扩展。均摊分析同 KMP: 右端点 $r$ 只增不减, $O(n)$。

典型用法——模式匹配转"自匹配":

def z_func(s: str) -> list[int]:
    """z[0] = n; z[i] = s[i..] 与 s 的 LCP. O(n)."""
    n = len(s)
    z = [0] * n
    z[0] = n
    l = r = 0                                  # 当前已知最右匹配区间 [l, r)
    for i in range(1, n):
        if i < r:
            z[i] = min(z[i - l], r - i)        # 抄镜像位置作业, 截断到 r
        while i + z[i] < n and s[z[i]] == s[i + z[i]]:
            z[i] += 1                          # 超出抄来的部分, 暴力扩
        if i + z[i] > r:
            l, r = i, i + z[i]
    return z


if __name__ == "__main__":
    assert z_func("aabxaab") == [7, 1, 0, 0, 3, 1, 0]
    assert z_func("aaaa") == [4, 3, 2, 1]
    t, p = "ababcabd", "abc"
    zz = z_func(p + "#" + t)                   # 分隔符保证 LCP 不会越过 p
    m = len(p)
    hits = [(i - m - 1) for i in range(m + 1, len(zz)) if zz[i] >= m]
    assert hits == [2]                         # 换算回 text 下标

tip

KMP 与 Z 的选择: 求"period / border / 最短循环节"用 $\pi$ (n - π[n-1] 即最小周期, 且 n % (n-π[n-1]) == 0 时整串由它循环构成); 求"每个后缀和谁像"用 $z$。两者都值得手熟, 但竞赛中 Z 通常更好写对。

Rabin-Karp: 指纹代替逐字符比较

把长度 $m$ 的窗口看作 $\sigma$ 进制多项式取模: $h(s[i..i+m)) = \sum s[i+j] \cdot b^{m-1-j} \bmod M$。窗口右移一格只需减最高位、乘底、加最低位, $O(1)$:

$$h_{i+1} = (h_i - s_i \cdot b^{m-1}) \cdot b + s_{i+m} \pmod M$$

哈希相等只是必要条件, 还需一次真比较兜底; 期望复杂度 $O(n+m)$ (哈希冲突概率 $\approx n/M$)。

def rabin_karp(s: str, p: str, base: int = 131, mod: int = (1 << 61) - 1) -> list[int]:
    """滚动哈希匹配. 期望 O(n+m); mod 取梅森素数便于取模."""
    m, n = len(p), len(s)
    if m == 0 or m > n:
        return []
    B = pow(base, m - 1, mod)
    hp = hs = 0
    for i in range(m):
        hp = (hp * base + ord(p[i])) % mod
        hs = (hs * base + ord(s[i])) % mod
    hits = []
    for i in range(n - m + 1):
        if hs == hp and s[i:i + m] == p:       # 哈希相等后再做一次真比较
            hits.append(i)
        if i + m < n:
            hs = ((hs - ord(s[i]) * B) * base + ord(s[i + m])) % mod
    return hits


if __name__ == "__main__":
    assert rabin_karp("ababcababcabd", "abab") == [0, 5]
    assert rabin_karp("aaaa", "aa") == [0, 1, 2]
    assert rabin_karp("hello", "xz") == []

哈希路线独有的三块领地:

  1. 多模式同长匹配: $k$ 个模式的指纹先算好, text 每个 $O(1)$ 窗口查哈希集合;
  2. 二维匹配: 先对每行滚动哈希压成列指纹, 再对列方向滚一遍——降维打击, KMP 做不到这么自然;
  3. 最长重复子串: "存在长度 $L$ 的重复子串"单调, 二分 $L$ + 哈希判重, $O(n \log n)$。

warning

固定 base + 常见 mod 的哈希在竞赛里会被针对性构造数据卡掉。对策: 双模数 (或 base/mod 运行时随机)。工程上还要注意 Python 大整数 % 的常数、以及 mod = 2**64 这类合数模对特定模式 (如 Thue-Morse 序列) 存在系统性反例——用大素数更稳。

AC 自动机: KMP 的多模式推广

$k$ 个模式各跑一遍 KMP 是 $O(nk)$; AC 自动机把它压到 $O(\Sigma|p_i| + n + \text{hits})$:

  1. 建 Trie: 所有 pattern 插入同一棵字典树;
  2. BFS 建 fail 指针: $fail(u)$ 指向"$u$ 所代表字符串的最长真后缀, 且该后缀也是 Trie 中某个节点"——正是 KMP 前缀函数的多分支版。构建沿 BFS 层序: 子节点的 fail 从父节点的 fail 的对应儿子继承 (没有就继续沿 fail 链爬);
  3. 扫描 text: 在 Trie 上走, 失配沿 fail 跳; 每走到一个节点, 沿 fail 链收集所有以当前位置结尾的模式 (输出链)。
from collections import deque


class AhoCorasick:
    def __init__(self):
        self.children = [{}]                 # children[u][c] = v
        self.fail = [0]
        self.out = [0]                       # out[u] = 以 u 结尾的模式个数

    def add(self, p: str) -> None:
        u = 0
        for c in p:
            if c not in self.children[u]:
                self.children[u][c] = len(self.children)
                self.children.append({})
                self.fail.append(0)
                self.out.append(0)
            u = self.children[u][c]
        self.out[u] += 1                     # 允许重复模式

    def build(self) -> None:
        q = deque()
        for v in self.children[0].values():  # 根的儿子 fail = 根
            q.append(v)
        while q:
            u = q.popleft()
            f = self.fail[u]
            self.out[u] += self.out[f]       # 输出链前缀和: 顺带统计后缀模式
            for c, v in self.children[u].items():
                # 找 u.fail 沿链第一个有 c 儿子的祖先
                while f and c not in self.children[f]:
                    f = self.fail[f]
                self.fail[v] = self.children[f][c] if c in self.children[f] \
                    and self.children[f][c] != v else 0
                q.append(v)

    def count(self, s: str) -> int:
        """s 中出现的模式总次数 (含作为其他模式后缀的情形)."""
        total, u = 0, 0
        for c in s:
            while u and c not in self.children[u]:
                u = self.fail[u]
            u = self.children[u].get(c, 0)
            total += self.out[u]
        return total


if __name__ == "__main__":
    ac = AhoCorasick()
    for w in ["he", "she", "his", "hers"]:
        ac.add(w)
    ac.build()
    assert ac.count("ushers") == 3             # she / he / hers
    assert ac.count("hishers") == 4            # his / she(跨 3..5) / he / hers

适用场景全是工程刚需:敏感词过滤、IDS 入侵特征扫描、日志关键字聚合、DNA motif 搜索。模式库静态时还可把 Trie 补满成 goto 表 (双数组 Trie / 自动机转移表), 让扫描完全无跳转。

进阶索引

算法复杂度一句话用途
Manacher$O(n)$最长回文子串 (插入分隔符统一奇偶)
后缀数组 + LCP$O(n \log n)$, SA-IS $O(n)$不同子串计数 / 反复子串 / 多串 LCS
后缀自动机 SAM$O(n)$endpos 等价类; 子串问题全能选手
Lyndon / Runs$O(n)$字符串最小循环表示

易错清单

  1. KMP 命中后的续接: k = nxt[k-1] 忘写则只能找到第一个匹配;
  2. Z 函数的分隔符: p#t 里的 # 必须不在字符集中, 否则 LCP 会跨进 t 继续匹配;
  3. 滚动哈希的窗口更新顺序: 先减最高位再乘底, 写反会把旧低位卷进来;
  4. AC 自动机构建的层序: 必须 BFS (短后缀先于长后缀就绪), DFS 会用到尚未计算的 fail;
  5. 重叠匹配语义: 问清"可否重叠"——KMP/Z 天然支持, 有些题要求命中后 i += m;
  6. Unicode vs 字节: Python 按 code point 切片, Go 按 byte; 含中文的匹配先想清楚单位。

经典题

  • LC 28 找出字符串中第一个匹配项 (KMP/RK 双解);
  • LC 459 重复的子字符串 (n % (n - π[n-1]) == 0);
  • LC 214 最短回文 (KMP 于 s#reverse(s) 上跑);
  • LC 3007 最长重复子串 (RK + 二分);
  • LC 718 最长重复子数组 (RK 降维 / DP 对照);
  • LC 336 回文对 (Trie + 构造性分类讨论);
  • 洛谷 P3808 AC 自动机 (简单版) / P3796 (出现次数最多);
  • POJ 2752 Seek the Name (border 链枚举)。

一页速查

π[i]:   p[..i] 最长相等真前后缀;  失配跳 k=π[k-1];  text 不回退 ⇒ O(n+m)
周期:   最小周期 = n - π[n-1];  整除 ⇔ 整串循环
z[i]:   s[i..] 与 s 的 LCP;  区间 [l,r) 抄作业 min(z[i-l], r-i)
RK:     h'=(h-s_i·b^{m-1})·b+s_{i+m};  命中须真比较;  防卡用双模/随机
        独占领地: 多模式同长 / 二维 / +二分求重复子串
AC:     Trie + fail(BFS 层序);  fail=最长真后缀节点;  out 沿 fail 前缀和
选型:   单模式确定→KMP/Z;  多模式→AC;  结构类问题(回文/重复)→Manacher/SA/SAM

下一篇: 数论与模运算

数论与模运算

一句话

模运算的本质是把无限的整数世界投影到有限的商结构 $\mathbb{Z}/m\mathbb{Z}$ 里: 加法和乘法在投影下保持同态, 所以"只关心余数"的算法可以一路取模不膨胀; 当 $m$ 是素数时投影后的结构更进一步成为——非零元素全部可逆, 于是"除法"变成乘逆元 $a^{p-2}$; 而中国剩余定理说的是"多个小模下的余数可以唯一拼回一个大模下的答案", 这是 RSA 解密 4 倍加速和大数并行计算的共同底座。竞赛与工程的绝大多数数论代码, 都是这四件事的排列组合: 快速幂 (对数时间做指数)、gcd/exgcd (线性结构的骨架)、逆元 (模下除法)、筛法 (批量质因子)

读完应能:

  1. 说清为什么 $m$ 为素数时 $\mathbb{Z}/m$ 是域, 以及费马小定理如何把逆元变成一次快速幂;
  2. 默写快速幂与扩展欧几里得 (Python + Go), 并用 Bézout 恒等式解释 exgcd 的回溯式;
  3. 解释线性筛为什么恰好 $O(n)$——每个合数被其最小质因子筛掉且仅一次;
  4. 写出 CRT 合并公式, 并说明 RSA-CRT 加速的原理。

思想链

问题: 无限大的整数算不动 / 存不下, 怎么办?
  └─► 只保留除以 m 的余数 ─► Z/mZ: 加乘封闭且同态
        ├─ 指数爆炸? ─► 幂也同态: a^k mod m 可按 k 的二进制平方乘 ─► O(log k)
        ├─ 要做"除法"?
        │     ├─ m 素数 ─► 域! 非零元可逆 ─► a⁻¹ = a^(p-2) (费马小定理)
        │     └─ 一般 m ─► gcd(a,m)=1 才有逆 ─► exgcd 求 ax+my=1 的 x
        │           └─► 批量求逆? inv[i] = -(m/i)·inv[m%i] mod m ─► O(n) 递推
        └─ 大模 M 太大算得慢?
              └─► M = p·q 分解成两个小模分别算 ─► CRT 唯一拼回
                    └─► RSA 解密 / NTT / 大数并行的同一招
质数从哪来? ─► Eratosthenes O(n log log n); 线性筛 O(n) 用最小质因子
大数判素?   ─► 试除到 √n 不够用 ─► Miller-Rabin 概率判素 (crypto 标配)

形式化定义

同余:$a \equiv b \pmod m$ 当且仅当 $m \mid a-b$。它把整数划分成 $m$ 个等价类, 全体等价类记作 $\mathbb{Z}/m\mathbb{Z}$——加法、乘法定义良好 (换代表元不变), 即商环。

单位群:$(\mathbb{Z}/m\mathbb{Z})^\times = {a : \gcd(a, m) = 1}$, 元素个数是欧拉函数 $\varphi(m)$。关键分界:

  • $m$ 合数: 单位群里才有逆元, 非单位 (如 $2 \bmod 4$) 连乘法可逆都保证不了;
  • $m$ 素数: 所有非零元都是单位, $\mathbb{Z}/p$ 成为——这是"模素数下可以放心做除法"的精确含义。

费马小定理:$p$ 素数、$p \nmid a$ 时 $a^{p-1} \equiv 1$, 移项即得逆元公式 $a^{-1} \equiv a^{p-2} \pmod p$。一般模下对应欧拉定理 $a^{\varphi(m)} \equiv 1$。

note

"模运算下不能直接比大小、不能直接除"这两条直觉限制, 都能从商环结构读出: 同一类里大小可以任意 (加 $km$ 就翻盘), 而"除法"是否存在取决于操作数是否为单位。数学预备见 离散数学 · 代数结构

快速幂: 对数时间的指数

把指数写成二进制, 反复平方:

def pow_mod(a: int, n: int, m: int) -> int:
    """a^n mod m. O(log n) 次乘法. 支持负指数? 不支持, 先求逆."""
    r, a = 1, a % m                      # 先归约底数
    while n:
        if n & 1:
            r = r * a % m                # 当前二进制位为 1: 收入结果
        a = a * a % m                    # 底数自乘: a^(2^i)
        n >>= 1
    return r


if __name__ == "__main__":
    assert pow_mod(2, 10, 1000) == 24            # 1024
    assert pow_mod(7, 0, 13) == 1                # 空积 = 单位元
    assert pow_mod(3, 1000000006, 1000000007) == 1   # 费马小定理: p-1 次幂归一
    assert pow_mod(5, 3, 7) == 125 % 7 == 6      # 与先算后取模一致

Go (含俄式乘法防溢出)

package main

import "fmt"

// MulMod 俄式乘法: 中间结果永不溢出 int64. O(log b).
func MulMod(a, b, m int64) int64 {
	res := int64(0)
	a %= m
	for b > 0 {
		if b&1 == 1 {
			res = (res + a) % m
		}
		a = (a * 2) % m
		b >>= 1
	}
	return res
}

// PowMod 快速幂: O(log n) 次乘法.
func PowMod(a, n, m int64) int64 {
	var r int64 = 1
	a %= m
	for n > 0 {
		if n&1 == 1 {
			r = MulMod(r, a, m) // 大模下改用 MulMod 防 (r*a) 溢出
		}
		a = MulMod(a, a, m)
		n >>= 1
	}
	return r
}

func main() {
	fmt.Println(PowMod(2, 10, 1000))              // 24
	fmt.Println(PowMod(3, 1000000006, 1000000007)) // 1
}

warning

Go 的 r * a 在 $m \sim 10^{18}$ 时中间值达 $10^{36}$, 直接溢出。三条路: MulMod (俄式乘法, 上面的实现)、big.Int (慢但稳)、或依赖平台 __int128 (GCC/Clang 扩展)。Python 无此烦恼——原生大整数, 但也因此慢一个量级。

GCD 与扩展欧几里得

$\gcd(a, b) = \gcd(b, a \bmod b)$ 的几何本质: 辗转相减的加速版。扩展版额外解出 Bézout 恒等式 $ax + by = \gcd(a,b)$ 的整系数 $(x,y)$——递归到底再原路回代:

def exgcd(a: int, b: int) -> tuple[int, int, int]:
    """返回 (g, x, y): ax + by = g = gcd(a, b)."""
    if b == 0:
        return a, 1, 0                     # a·1 + 0·0 = a
    g, x1, y1 = exgcd(b, a % b)
    # b·x1 + (a%b)·y1 = g; 代入 a%b = a - (a//b)·b 整理:
    return g, y1, x1 - (a // b) * y1


def inv(a: int, m: int) -> int | None:
    """a 在 mod m 下的逆元; 不存在返回 None. gcd(a,m)=1 ⇔ 可逆."""
    g, x, _ = exgcd(a % m, m)
    return x % m if g == 1 else None


if __name__ == "__main__":
    assert exgcd(30, 18) == (6, -1, 2)     # 30·(-1) + 18·2 = 6
    assert inv(3, 11) == 4                 # 3·4 = 12 ≡ 1 (mod 11)
    assert inv(4, 8) is None               # gcd(4,8)=2 ≠ 1

批量逆元 ($O(n)$, 竞赛常用):设 $inv[i] = -\lfloor m/i \rfloor \cdot inv[m \bmod i] \bmod m$——由 $m = \lfloor m/i\rfloor \cdot i + m \bmod i$ 两边同乘 $inv[i]$ 整理而来, 把大问题挂到更小的余数上。

筛法: 批量生产质数

def linear_sieve(lim: int) -> list[int]:
    """线性筛: 每个合数被最小质因子标记恰一次 ⇒ O(lim)."""
    f = [False] * (lim + 1)
    primes: list[int] = []
    for i in range(2, lim + 1):
        if not f[i]:
            primes.append(i)               # i 未被任何更小质数划掉 ⇒ i 是质数
        for p in primes:
            if i * p > lim:
                break
            f[i * p] = True
            if i % p == 0:
                break                      # 关键: p 恰是 i*p 的最小质因子, 后面交给更大的 i
    return primes


if __name__ == "__main__":
    ps = linear_sieve(100)
    assert len(ps) == 25 and ps[-1] == 97
    assert ps[:10] == [2, 3, 5, 7, 11, 13, 17, 19, 23, 29]

正确性两句话: (1) 每个合数 $n$ 写成 $p \cdot i$ 且 $p$ 取其最小质因子时, $i$ 一定在枚举中出现过; (2) 内层循环在 i % p == 0 时 break, 保证不会用更大的质数重复标记同一个合数。两者合起来——每合数恰标一次, 总操作数 $O(n)$。

tip

只要"最小质因子"这个副产品有用 (分解质因数、欧拉/莫比乌斯函数线性筛), 就选线性筛; 只要质数列表本身, Eratosthenes 位压版更快更好写。

Miller-Rabin: 大数的概率判素

试除到 $\sqrt{n}$ 对 $10^{18}$ 级别毫无意义 (要 $10^9$ 次)。Miller-Rabin 把费马小定理升级成随机测试: 若 $p$ 是奇素数, 则对任意 $a$, 平方链 $a^d, a^{2d}, \dots, a^{2^s d}$ ($n-1 = 2^s d$) 要么中途出现 $-1$, 要么终点是 $1$; 违反即铁证合数。固定底数组合 {2, 3, 5, 7, 11, 13, 17, 19, 23, 29, 31, 37} 对 $< 3.3 \times 10^{24}$ 无假阳性——密码学生成大素数时配合随机底数使用 (详见 非对称加密)。

中国剩余定理: 小模拼大模

$m_1, m_2$ 互质时, 方程组 $x \equiv a_1 \pmod{m_1}, x \equiv a_2 \pmod{m_2}$ 在 $\bmod\ m_1 m_2$ 下有唯一解:

$$x = a_1 + m_1 \cdot \underbrace{\big((a_2 - a_1) \cdot m_1^{-1} \bmod m_2\big)}_{\text{补的倍数}}$$

直觉: 先满足第一个约束, 再以 $m_1$ 为步长微调去命中第二个——因为 $\gcd(m_1, m_2)=1$, 步长扫遍所有剩余类, $m_1^{-1}$ 只是让"扫"变成"直达"。多组同理逐两合并 (exCRT 处理不互质情形, 合并时检查 $\gcd(m_1,m_2) \mid (a_2-a_1)$)。

工程最大牌的应用是 RSA-CRT 解密加速: $c^d \bmod N$ ($N=pq$) 拆成 $\bmod\ p$ 与 $\bmod\ q$ 各算一次 (指数先用 $\varphi$ 缩短), 再 CRT 拼回——两次模幂的位数减半, 平方复杂度下合计快约 4 倍 (细节见 RSA 与 ECC)。

易错清单

  1. 负数取模语义分裂: Python -1 % 5 == 4, C/C++/Java 得 -1; 跨语言代码统一 (a % m + m) % m;
  2. 模下无序: (a%m) > (b%m) 推不出 a > b; 涉及比较的逻辑先在真实值域完成;
  3. 中间乘法溢出: 见上文 Go 版 MulMod 三选项;
  4. 逆元存在性: 忘查 gcd(a, m) = 1, exgcd 会安静地给你一个不是逆元的数;
  5. 快速幂底数为 0: pow_mod(0, 0, m) 返回 1 (空积约定), 但 0^k (k>0) 必须 0——模板已兼容, 自己手写时留意;
  6. 筛法边界: lim < 2 时返回空表; f[i*p] 先于 break 判断, 顺序反了会漏标。

经典题

  • LC 50 Pow(x, n) (快速幂直通车);
  • LC 149 直线上最多的点数 (分数化最简 → gcd);
  • LC 1015 可被 K 整除的最小整数 (鸽巢 + 同余循环);
  • LC 1808 好因子的最大数目 (积性函数 + 费马小定理);
  • 洛谷 P1226 【模板】快速幂;
  • 洛谷 P3811 【模板】乘法逆元 (线性递推);
  • 洛谷 P4777 【模板】扩展中国剩余定理 (exCRT);
  • POJ 1811 Prime Test (Miller-Rabin + Pollard Rho)。

一页速查

结构:   Z/m 是环; m 素数 ⇒ 域 ⇒ 非零全可逆
快速幂: 指数二进制 + 平方乘 O(log n); 大模乘法防溢出 (俄式/big.Int/__int128)
逆元:   p 素数: a^(p-2) | 任意 m: exgcd 解 ax+my=1 | 批量: inv[i]=-⌊m/i⌋·inv[m%i]
exgcd:  回溯式 (g,x,y)=(b→y1, x1-⌊a/b⌋·y1); gcd=1 ⇔ 逆元存在
筛法:   线性筛 = 最小质因子恰标一次; 附赠 lp[] 分解质因数
判素:   n<2^32 试除√n; 更大用 Miller-Rabin 固定底组合
CRT:    x=a1+m1·((a2-a1)·m1⁻¹ mod m2); 互质⇒mod M 唯一解; RSA-CRT 提速 ~4×
坑:     负数取模跨语言不同 | 模下不可比大小 | 逆元前查 gcd

回到本部分: 主题专题

附录

Snippets

Python 快速 IO 与递归调高

import sys
sys.setrecursionlimit(10**6 + 10)
input = sys.stdin.readline  # 一行快速读
print = sys.stdout.write    # 快写(写入需要 \n 换行)

Go 高效查找 / bitset

// 找第一个 >= x 的位置(lower_bound)
func lowerBound(a []int, x int) int {
    L, R := 0, len(a)
    for L < R {
        M := L + (R-L) >> 1
        if a[M] < x { L = M + 1 } else { R = M }
    }
    return L
}

// 位集合 (固定大小)
type Bitset struct{ bits []uint64 }
func NewBitset(n int) *Bitset { return &Bitset{bits: make([]uint64, (n+63)>>6)} }
func (b *Bitset) Set(i int) { b.bits[i>>6] |= 1 << (uint(i) & 63) }
func (b *Bitset) Get(i int) bool { return b.bits[i>>6] & (1 << (uint(i) & 63)) != 0 }
func (b *Bitset) Count() int {
    c := 0
    for _, x := range b.bits { c += bits.OnesCount64(x) }
    return c
}

TypeScript 工具

function lowerBound(a: number[], x: number): number {
  let L = 0, R = a.length;
  while (L < R) {
    const M = L + ((R - L) >> 1);
    if (a[M] < x) L = M + 1; else R = M;
  }
  return L;
}

C++ 快读快写

inline int read() {
    int x = 0, f = 1; char c = getchar_unlocked();
    while (c < '0' || c > '9') { if (c == '-') f = -1; c = getchar_unlocked(); }
    while (c >= '0' && c <= '9') { x = x * 10 + c - '0'; c = getchar_unlocked(); }
    return x * f;
}
inline void write(int x) {
    if (x < 0) { putchar_unlocked('-'); x = -x; }
    if (x >= 10) write(x / 10);
    putchar_unlocked(x % 10 + '0');
}

LeetCode 刷题路线

子主题 / 顺序 / 数量都按"在你深入理解原理之后用题验证"组织。不强求日更数量。

第一阶段:数据结构基础(30 题)

主题题号重点
数组27, 26, 11, 209双指针、滑窗
二分33, 81, 153, 410三种模板
链表206, 25, 142, 146反转、环、LRU
栈队列20, 155, 232, 84括号、单调栈
哈希1, 49, 128, 706设计哈希、O(n)

第二阶段:树与堆(20 题)

主题题号
BST/遍历98, 230, 1038, 449
215, 347, 295, 23, 502
字典树208, 212
并查集547, 684, 990

第三阶段:算法范式(40 题)

主题题号
分治169, 315, 23, 53
贪心55, 45, 134, 435, 621
DP一维70, 322, 300, 312
DP二维64, 62, 1143, 5, 32
区间DP5, 312, 87, 375
状压187, 1655, 943
回溯46/47, 39, 51, 22, 79

第四阶段:图与字符串(30 题)

主题题号
BFS/DFS200, 133, 207, 210, 785
最短路743, 787, 1334, 1631
拓扑207, 210, 802, 1192
字符串28, 5, 214, 459

第五阶段:进阶

去 Codeforces / AtCoder。LeetCode 的题面全是"模板题",关键提升要从题面变成"加约束、变形" 来。 推荐:Codeforces Div 2/321 题量级题面,AtCoder ABC 200~300 题。

第二部分 · 操作系统

一句话

操作系统把硬件"驯化"成你可以写代码的虚拟机:它把 CPU 时间切成片让多个程序同跑、把内存切成页让进程互不踩脚、把磁盘抽象成字节流让 IO 透明、把进程间冲突变成锁与同步原语——你写 hello world 时调用的每个 printf、每个 malloc、每个 epoll 都至少穿过这两层抽象。理解 OS 就是理解你写的代码底下被自动做了什么、什么时候会失效、失效的代价。

这一部分的章节

阅读策略

读完这一部分,你得能回答:

  • malloc(1M) 实际返回到 use 之前 Linux 内核做了什么?
  • 为什么线程间抢占的 cache line 是性能杀手?
  • 为什么 epoll 边沿触发要配 O_NONBLOCK?
  • 为什么用 fsync 也可能丢数据?
  • BBR 比 Cubic 收益在哪?

每个问题都串多个抽象层:

  • strace / perf / ftrace 看实际系统调用;
  • eBPF / bpftrace 看内核态内部;
  • 拓到 CPU cache / NUMA / NVMe 物理层;
  • 必要时引到 FPGA / SmartNIC 上的 bypass 路径。

OS 的故事是硬件 + 抽象 + 工程现实反复折中。读完这一部分,看回去 DSA 里的 cache-friendly 那一段你会更顺,因为 OS 把那些底层都编程化了。

内存

note

内存是这台机器上最复杂的一块抽象层级:

  • 进程看到的"内存" 是连续的;
  • 内核看到的"内存" 是页;
  • DRAM 控制器看到的"内存" 是行/列地址;
  • L1 cache 看到的是 cache line;
  • NVM 控制器看到的是 page / sector;
  • NUMA 互连图把 DRAM 看成跨节点。

每经过一层抽象都要付一次延迟常数。

虚拟内存与地址翻译

一句话

你不是直接写 RAM——你写的地址是进程的"虚拟地址"。从 mov rax, [0x7ffe] 到 DRAM chip 收到字节,要经过 MMU + 页表 + TLB + cache + memory controller + DRAM row activate + column 这几层抽象。每跨一层都加一个常数。这一章把翻译链拆开,让你看到 mov 这一句汇编后面到底经历了什么。

虚拟地址到底"虚拟"什么

进程地址空间是 OS 给的"幻觉":

进程 A:
  0x0000-0x7fff 代码 + 数据 + 堆
  0x7ffe-0x8000 栈

进程 B:
  0x0000-0x7fff 代码 + 数据 + 堆
  0x7ffe-0x8000 栈

两个进程都用 0x400000,独立,互不见对方的内存。这是虚拟内存唯一目的:进程隔离 + 让程序以为自己独占机器

实现上靠 Page Table:

进程虚拟地址 0x7fec_1234
        ↓
    [页表映射]
        ↓
   物理页帧号 + 页内偏移
        ↓
       DRAM 地址

每条 mov 都要 MMU 走一遍页表翻译——如果不缓存就一次翻译要 5 次内存读,IO 拖到几百 ns,CPU 跑不动。这就是为什么 TLB 是 MMU 必备的缓存。

地址翻译链路

虚拟地址 (VA)
  = VPN(高 bit)  + PageOffset(低 12 bit, 4 KB page)
        ↓
[ L1 TLB → L2 TLB → Page table walk ]
        ↓
物理页帧号 (PFN)
        ↓
PA = PFN · PAGE_SIZE + PageOffset
        ↓
[ L1 cache → L2/L3 → DRAM ]

每一步都"可能 miss"。TLB miss 称"地址翻译 stall";cache miss 称"data stall"。一次访存 if 都 miss 可能 100-200 ns. CPU 看到的 mov rax, [x] 这种简单操作在最坏情形可消耗 200 倍 cycle.

三级/四级页表的真正原因

页表本身大;按 ridged 32-bit 系统:

  • 4 KB page × 32-bit 虚拟地址 = 2^20 个 page = 1 M entry × 8B = 8 MB / proc

进程 4 GB 虚拟空间页表全表 = 8 MB. 一个 64 GB 内存机器最多跑 8192 个 4GB 进程就炸——还是没算 OS 自己内存. 这就是"flat page table 不行".

多级页表的核心思想: 未使用的虚拟地址范围不需在内存里建中间节点.

64-bit x86 long mode 使用 4 级页表 (CR3 + 4 · 9-bit index,每级 512 项):

9 bit  9 bit  9 bit  9 bit  12 bit
PML4   PDPT   PD     PT     Page offset

5-level paging (LA57) 加一级 + 9 bit = 支持 57 bit VA.

中间表项不存在就直接 "page fault"——不分配 page. 这让一个进程的页表绝大多数并未物化. 这是多级页表的"课":稀疏内容的物化使用"懒"的精神.

类比 DSA:B 树与多级页表的根同源 —— i级索引稀疏, leaf 真物理. 这就是分隔中大幅降低空间复杂度的同一思想。

TLB: 把"翻译本身"也加 cache

CPU 内部 L1/L2 TLB 缓存最近几条 VA→PA 翻译:

  • L1 DTLB: 64-128 entry, ~1 cycle 全命中
  • L2 STLB (shared TLB): 1K-4K entry, ~7 cycle
  • OS 上下文切换时 invalidate 整个 TLB(旧架构)

5-level LA57 后, working set 翻译加深的概率大幅上升 —— TLB miss 已经不再是 small CPU 内部常数, 而是内存级 (几十 ns) 浪费. 现代 Intel CPU 把 TLB 全部 keep in L2/L3 一致, "process-context id (PCID)" 让切换时不 invalidate TLB —— 节省 1-3% 系统时间.

虚拟内存的 5 个 ease 用

  1. 进程隔离: 每个进程独立 VA.
  2. 懒载入: mmap 后不实际加载, 真用时 page fault 触发 IO.
  3. COW (copy-on-write): fork() 不复制整个父进程内存, 只把所有页标 readonly, 写时再复制.
  4. swap: 内存压力大时把不活跃页换到磁盘.
  5. mmap files: 把文件按页映射到虚拟空间, "读文件" = 读内存.

每一个 ease 用都是"懒 + cache" 的同一抽象: 实际上不立即做事,等真触发了再做。

性能上要关心的事

TLB shootdown 的代价

当某个进程页表项被修改(如 munmap、用户主动改保护位),所有可能持有这条 TLB 项的 CPU 都要 invalidate. 这是 IPI (inter-processor interrupt),单机下微秒级, 在 100 核机器上 shootdown 可达 50+ μs.

解决方案:

  • PCID: 减少跨进程切换全 invalidate;
  • Lazy shootdown: 延迟到该 CPU 再次访问;
  • 大页 / huge page: 减少需要缓存的 TLB 项数量。

大页的对比

4 KB page、1 GB 内存 = 2^18 个 page.
1 GB huge page、1 GB = 1 个 page.

TLB 容量小但 huge page 占一项就 hardware 抑制了 1 GB. hotspot 应用开 huge page 通常 P99 抖动立刻降 30-50%。但需要注意 huge page 容易 fragment,不建议完全 global 启用。

调试技巧

检查别墅页表 (PID=1234)
cat /proc/1234/maps              # 用户态 VA 布局
pmap -x 1234                     # 进程内存布局
sudo cat /proc/1234/smaps        # 每段 VMA 详情
检查 page table walk 性能
perf stat -e page-faults,minor-faults,major-faults ./yourapp
perf stat -e dTLB-load-misses,iTLB-load-misses ./yourapp

这章带走的东西

  • 虚拟内存 = 进程隔离 + 懒加载 + COW + swap + mmap 五件套;
  • 4 级页表 = 用稀疏内容让物化内存变小;
  • TLB miss 是常被忽略的性能杀手, huge page 是工程上 mitigations;
  • 一次 mov rax,[0x7ffe] 最坏 200+ cycle, 因为翻译通路要全走一遍;
  • /proc//{maps,smaps} 是入口起点.

下一节 → 分页、TLB、Huge Page

分页、TLB、Huge Page

一句话

页面大小是硬件工程师决定的硬件常数, 它决定了 TLB 的覆盖范围、cache line 数量、IO 单位, 同时直接影响用户程序性能. 但页大小不是任选的 —— 4 KB 是历史甜点, 大页是工程妥协, 这章把"页大小为何是这个数字、改大会怎样、改小会怎样" 一次讲清.

页大小为什么是 4 KB

回头看上世纪 80 年代 VAX-11/780

  • 物理 RAM 几 MB;
  • 寻址 32-bit, 4 GB 虚拟空间;
  • 页表按 1 项 4 字节算: 4KB page = 1 M 项 × 4 = 4 MB 表 / process;
  • 4 KB page + 4 MB 表 —— 把页表塞到一页可以放进 L1 cache 内, 极合理.

4 KB 是历史习惯的甜点, 同时这一常数一直被物化保留:

  • x86 4 KB 一直保留;
  • OS page cache 一直按 4 KB;
  • NVMe 块层一直按 4 KB;
  • 文件系统 metadata 一直按 4 KB;

但 64-bit 时代虚拟空间暴涨、TLB 覆盖跟不上, huge page = x86 同时支持 2 MB / 1 GB 是补丁.

TLB 覆盖范围

TLB 是一个非常小的 cache (~128 entry). 它覆盖的内存量 = entry 数 × page 大小:

4 KB page, 128 entry TLB  → 覆盖 512 KB;
2 MB huge page, 128 entry → 覆盖 256 MB;
1 GB huge page, 128 entry → 覆盖 128 GB; (!)

memory footprint > covered range 时, TLB 持续 miss → page walk 频繁 → 全程被加 7~30 cycle 翻译延迟. 一次 page walk 通常: 4 级 × 几百 ps ≈ 几 ns, 但上层 cache miss 也会 = 几十 ns.

这就是 huge page 的真正价值, 不是"省内存", 而是把"覆盖范围" 抬高一 / 两个量级.

三种 huge page 模式

1. 显式 mmap 大页

mmap(NULL, sz, PROT_READ|PROT_WRITE,
     MAP_PRIVATE|MAP_ANONYMOUS|MAP_HUGETLB, -1, 0);

要求 OS 配合: echo N > /proc/sys/vm/nr_hugepages 预留大页池.

缺点: 大页池是预留, 不使用也 占用, 不能跑普通程序. 需要管理员权限配置.

2. Transparent Huge Page (THP)

echo always > /sys/kernel/mm/transparent_hugepage/enabled

内核在后台同步把 4 KB 进程合并成 2 MB 大页. 无需 app 适配, 但有如下坑:

  • khugepaged 在后台扫描要 CPU, 1-5% 在大内存机器上看得到;
  • 一旦扩容了, 拆回 4 KB 是 O(N) 反过来;
  • 小内存进程 (几十 MB) 几乎无收益, 反而加回收代价;
  • HRT 场景下偶尔有 STALL.

3. 1 GB 大页 (gbpage)

echo 5 > /proc/sys/vm/nr_hugepages-1g  # 假写法, 看具体内核

只适合大内存 + 大数据集:

  • HPC / ML 训练大 batch GPU memory pool;
  • 大巨型 Redis cache (>100 GB);
  • ML training 矢量库(vector db).

huge page 在查询热服务上的实测

Redis benchmark, NUMA 4-socket, 256 GB 内存, 1000 万键:

  • 4 KB page + 默认配置: QPS 250k, P99 0.9 ms;
  • THP enabled: QPS 320k, P99 0.4 ms;
  • 显式 2 MB huge page mmap: QPS 340k, P99 0.35 ms.

吞吐 30% 上升, 尾延迟 P99 减 60%. TLB 这一只性能的代价不可忽略.

thunk: 减少 page walk 的 4 个工程 trick

1. 使用 huge page 提升覆盖范围;
2. 避免大 working set 跨 progress;
3. 内存局部化 (NUMA-aware allocation); 
4. 拒绝频繁 mmap/munmap (TLB shootdown);

NUMA 与 huge page 的协同

NUMA 节点 CPU 访问本地 vs 跨 socket 差 2-4×. huge page 落地哪个 socket 影响 cache.

  • libnuma / mpol 配 huge page 优先在本地 node 分配;
  • HPC 框架 OpenMP + numactl --membind / mmap MAP_HUGETLB 组合;
  • Go runtime GOGC + GOMAXPROCS 配 NODE 不要瞎选.

FPGA / GPU 视角: huge page 同构

GPU 内存也是按一个"页" 概念. CUDA virtual mem management API 让你能 2 MB align, 减少跨 SM page swap. 类比 huge page 同构.

FPGA 上 BRAM 是固定 1-4 MB, 也可以做"凑成"大页-enable 的数据通路. 每一层抽象都把"页" 当成 cache 单位的引擎, 让 TLB / cache / prefetcher 模型成立.

调优现场

# 查 TLB 基本信息
cat /proc/meminfo | grep -i huge

# 查进程是否用了 huge page
cat /proc/<pid>/smaps | grep -i Huge

# 强制进程使用 huge page (THP)
echo always > /sys/kernel/mm/transparent_hugepage/enabled

# 查看 TLB miss 实际触发
perf stat -e dTLB-loads,dTLB-load-misses,iTLB-loads,iTLB-load-misses ./yourapp

this->带走的东西

  • 4 KB 是历史甜点, 64-bit 时代靠 huge page 补丁;
  • TLB 覆盖范围 = entry × page 大小, huge page 把覆盖率抬一个量级;
  • 三种 huge page 模式有各自适用 / 性能脾气;
  • TLB shootdown 在多核 HPC 下是常见惊人延迟;
  • huge page 与 NUMA 协同才能彻底发挥.

下一节 → 页面置换与 working set

页面置换与 working set

一句话

物理 RAM 比 disk 大、但不是无限. 一旦进程需求量超过物理内存, 必须把不活跃的页 swap 到 disk. 选哪页淘汰决定了"无限工作负载"下的有效吞吐和尾延迟. 这一章讲 LRU/LFU/ARC、working set 假说和 Linux 内核的 actual active list 机制——你会发现 DSA cache 章节里的 LRU/ARC 在 OS 层重新长出来.

工作集 working set 假说

Denning 1968 提出:

每个程序在时间 t 内访问的页面集合 W(t, Δ) 决定它能否常驻物理内存而不被 swap 频繁打扰.

直觉:

  • W 大 → 太多页竞争物理内存, 必然 thrashing;
  • W 小 → 程序在自己的话空间里跑得很好.

90% 的"内存 thrashing" 都是 W > 物理内存. 这事单堆更物理 RAM 解决, 软件优化代码也会减少 W (e.g. 把循环重排到 row-major, 把指针跳改成数组扫描).

LRU 选页

经典 LRU: 维护"最近访问时间 t", 选淘汰 t 最小者.

  • 优势: 通用, 多数负载下接近最优;
  • 问题: 被一次性扫过的 cold data 会把热点洗掉.

这是 OS 内核 cache 的老问题. Linux 内核的 page cache 通过 active / inactive 两张链表 + 第二次机会算法减缓这种污染.

Linux 内核的"双链表第二次机会"

所有可回收 page 在 inactive 或 active 链表上:

  inactive 双链表 ← 多数新页
  ↓ 第二次访问 with referenced bit
  active 双链表 ← 热点页
  ↓ 内存压力时 move down
  inactive → 由回收线程 scan 决定是否踢出

"第二次机会": 一次性扫描的 cold 页访问 first time → inactive 但 (在 cache 上) referenced bit 置, 但仍然 inactive; 第二次访问才 active. 这让"扫一遍大数据" 不会污染 active 列表.

这个模型优化点: 让热点更难进, 让冷点更易被踢. mm/vmscan.c 的复杂度很高, 因为它要适应各种工作负载.

多代 LRU (MGLRU): 6.1+ 的新答案

active/inactive 双链表在大内存机器上暴露两个问题: 链表操作要拿全局锁 (跨 NUMA 扩展差), 且"只看最近一次访问"无法区分"一分钟前"和"一小时前"的冷度。Linux 6.1 合入的 MGLRU (multi-gen LRU) 把页按访问时间分到多个"代"(generation), 用访问位 + 时间分桶近似 LFU:

generation n (最老/最冷) ← ... ← generation 0 (最新)
回收从最老的 generation 开始扫; 页被再访问则晋升到新 generation
  • 每个 generation 是一次"批量老化", 链表遍历换成世代轮转, 锁竞争和扫描开销都下降;
  • 对"流式扫描污染热点"的防护由分代自然获得——一次性扫过的页留在同一代里被优先回收;
  • Chrome OS / Android 上为低内存设备设计, 后进入服务器内核; 开关 /sys/kernel/mm/lru_gen/enabled.

工程含义: 调优老文章里的 swappiness 单旋钮之外, 现在还要知道 MGLRU 是否启用——两者的回收行为差异足以改变 P99 表现。

LRU 派生变种比较

算法思想问题实际用法
LRU最近最久未用扫一遍污染热点Linux page cache baseline
LFU最少使用频次老 hot 永不出去一般结合 LRU
ARC (Adaptive Replacement Cache)维护 LRU+LFU 双链表实现复杂ZFS, IBM DB2
LRU-K根据过去 K 次访问时刻K 选择Postgres buffer
W-TinyLFULRU + 频次窗口分级实现复杂Caffeine (Java)

Linux 内核大致是 "LRU + 第二次机会 + 频次 hint"; Postgres buffer 采用 Clock sweep (LRU-K 的简化); Java Caffeine 用 W-TinyLFU.

这部分 DSA cache 章节也是同构: cache replacement 算法在 OS、DB、应用层、CDN 反复出现.

swap 与 anon page

Linux 页分两类:

  1. Anon (匿名)页: stack/heap 这类没有文件后备的内存, 回收必须写入 swap 分区/文件;
  2. File-backed 页: mmap 出来的文件内容, 直接丢弃 → 下次访问再从文件拉.

file-backed page 比 anon page 回收更快 (干净页直接丢弃即可, 无需写 swap). 写 workload 持续 fsync 时, page 走 clean → dirty → writeback; dirty 积压太多会逼 kswapd 高速运转, 回收路径上的锁与 IO 直接打穿 P99 尾延迟。

thrashing 监测

vmstat 1
watch cat /proc/vmstat | grep 'pgsteal\|pgscan'

# 真实 working set size 测
sar -B 1   # 看 pgscank/s, pgscand/s, pgsteal/s

pgsteal 增加 = 在淘汰; pgscank 增加 = 在扫 active list.

page reclaim 与"水线"

Linux 内核维护三道水线 (/proc/sys/vm/min_free_kbytes 等):

high water  ─ ─ ─  ────  free < high → kswapd 启动 pre-clean
low water   ─ ─ ─  ────  free < low  → kswapd 进入忙碌
min water   ─ ─ ─  ────  free < min  → 任何分配都直接同步回收

direct reclaim = 进程自己牺牲 CPU 做回收, 损失 P99. 避免 direct reclaim 是高 QPS 服务的关键技巧: 用 cgroup limit mem + 提前 warning / overcommit_ratio 合理配置.

这章带走的东西

  • working set 是软件特性, 让 OS 减低它的 W 是工程爆发点之一;
  • LRU / LFU / ARC 在 OS / DB / CDN 都同构;
  • Linux 内核 second-chance 是 LRU+hint;
  • 双链表 active/inactive 减少 cold-scan 污染;
  • avoid direct reclaim 是高 QPS 服务 P99 友好的硬指标.

下一节 → 内存分配器 ptmalloc/jemalloc/tcmalloc/mimalloc

内存分配器:ptmalloc / jemalloc / tcmalloc / mimalloc

一句话

malloc(64) 这行代码,你以为是 OS 给你 64 字节。不是。 它实际是从用户态一段叫做 arena 的虚拟地址块里切下一个 64 字节的"运行时管理单元",OS 在 99% 的情况下根本没被惊动。这一章把四个主流分配器拆到底层,让你看清 malloc 背后的 fast path / slow path / size class / slab / 线程 cache / 大页对齐,最后你会发现:分配器是 OS 在你程序里偷偷延伸的二级 cache

1. 为什么必须有用户态分配器

OS 给你分配内存的两个 syscall 是 brkmmap,最小粒度都是页 (4 KB)。如果你每次 malloc 64B 都向 OS 要一页:

  • 1 GB 申请要 262144 次 syscall = ~26M cycle;
  • 地址空间被切成 524288 个 4 KB 不可回收碎片;
  • page fault 触发再 + 再 1 倍延迟。

这根本不工程可行。所以每个语言运行时都有自己的"用户态分配器",在已映射的页上自己切片,OS 只看"页的进出",看不到字节进出。

这就引出一个分层抽象:

用户代码:   malloc(64)
   ↓
语言运行时 (ptmalloc / jemalloc / glibc): 在 arena 内切 64B
   ↓
OS syscalls (brk/mmap/munmap): 按页申请 / 释放
   ↓
memory controller 按页映射到 DRAM row/col

每跨层都付出一次"页" 大小的代价. 所以 分配器的核心职责是减少 syscall 次数 + 减少 cache miss + 减少碎片——这就是为什么它是 OS 在用户态的二级 cache。

2. 分配器要解决的三件事

不管哪个分配器,都要同时回答这三个问题:

  1. 快路径:常态 malloc(n) 必须几 ns 内返回——基本是 thread local cache 命中;
  2. 碎片控制:长期跑不能把堆越分越碎,让 2 GB 进程吃成 4 GB 虚拟;
  3. 多核伸缩:多线程竞争不能让吞吐降级——必须 thread-local 优先,central path 兜底.

整本书里反复出现的"摊还 vs 最坏"和"thread-local vs 共享"在分配器里完美同构

3. size class:分配器的核心数据结构

malloc(n) 总要选一种合理大小返回。但你不能为每个 n 维护一个独立的"空闲链"——会爆炸。所以主流分配器都先做 size class 分桶:

size class:    8, 16, 32, 48, 64, 80, 96, 112, 128, 160, 192, 224, 256, 320, 384, 448, 512, 640, 768, 896, 1024, ... 
直到 page run 上限 (~32 KB)

note

size class 不是完全等比增长的,是经过实测后调出来的工程最佳值——确保每种 class 的对齐 / 滑动损耗 < 12.5%。tcmalloc 用了 80 多个 class,jemalloc 的 psizes[][] 表是编译期常量数。

举几个例子:

  • malloc(7) → 8 字节 class
  • malloc(13) → 16 字节 class
  • malloc(48) → 48 字节 class
  • malloc(50) → 64 字节 class

所有权 class 决定你实际花掉 8/16/64 字节之一——这就是"分配内的内碎片"——internal fragmentation。好的 size class 表让平均浪费 < 12%.

4. Slab / page / run / heap:四层结构

主流分配器结构都长这样:

heap        ─── 给某个线程 / CPU
 ↓
arena       ─── 一个 slab 群组, 共享给多个 threads
 ↓
page run    ─── 一组连续 page, 整体属于某一个 size class
 ↓
slot        ─── 一次 TLB 友好切出的实际用 chunk

每个分配器的命名略不同 (thread cache 是 tc / slab 是 run / span 是 page run),但抽象同源。

5. ptmalloc (glibc)

业内最广用、也是最被诟病的分配器。原理是 Doug Lea 的 dlmalloc 加 Intel 维护的多线程 arena 扩展。

结构

  • arena: 一个全局 + 多线程用 thread arena;
  • chunk 头部 16/32 字节 (size + flags + prev size);
  • 主交付 path: bins——按 size 组织的链表 / tree。
  • thread cache (tcache) 自 2.26 加入,单线程情况极大改善。

实测痛点

  • chunk header 8 B / 16 B, 小对象的 overhead 25%~50%;
  • arena 间锁争激烈,多核 32-core 机器上 malloc contention 常被 flamegraph 看到;
  • mmap 上界 128 KB,超过则直接走 mmap;
  • free 后做 merge 是"立即合并",与传统 TCMalloc 倾向"central cache returned" 不同。

实际项目中 Go runtime / Java / Rust 不用 ptmalloc 是这个原因。C++ 写后端如果用 libstdc++ + ptmalloc 默认栈,高 QPS 服务 P99 经常掉。直接换 jemalloc 或 mimalloc 是工程"一行 LD_PRELOAD"。

6. tcmalloc (Google)

Google 内部写来变的分配器,给所有第一方 Google 服务用。核心两点:

thread cache (TC)

  • 每 CPU (实际上是每线程) 持有独立 cache,命中走 O(1) 无锁;
  • 多线程 malloc 之间几乎零竞争。

central free list + span

  • thread cache 上限到了就 batch 归还;
  • central 维护 page heap + 由 spans (一个 page run) 组成;
  • 大对象直接 mmap + 一次 size-to-heap lookup.

整体结构是 per-thread TC + 中央 page heap:现在也是 prometheus / envoy 等基础设施默认走的 (链接 tcmalloc_minimal).

7. jemalloc (Facebook, FreeBSD)

Facebook 给 Redis / jemalloc 默认, 如果 Linux 用户 apt 装 redis-server 默认就用 jemalloc. 整体建模差异:

三级 cache: TC → cache → arena

线程本地 tcache: 8B-32KB 的小对象,存现成的 slab 槽位;
tcache 满则归还到 arena-level cache;
arena 持有 extents (page runs) 集群;
atop: huge page / mmap/purge madvise.

extent / run / arena

  • arena 数默认 = 4 × ncpu,希望线程通过 thread hash 接到不同 arena,避免同一 arena 锁链路;
  • extent 是逻辑 page run, 在活跃与空闲之间转移;
  • purge 通过 MADV_DONTNEED 主动归还 OS, 减少内存长尾增长.

实测优势

  • 小对象分配高峰极快(TC ~ 10 ns);
  • arena 数大,多线程 contention 远低于 ptmalloc;
  • 短期 / 长期均衡, 碎片率 < 5%.

这就是为什么 Redis、Rust std 编译默认 jemalloc / mimalloc 二选一,业务上 high QPS 服务默认建议替换 glibc 的 ptmalloc.

8. mimalloc (Microsoft)

更现代的版本。和 tcmalloc 类似但减少一些 tradeoff:

  • 每 CPU 而不是每线程 cache (减少 GC pause + 减少 reset thread state);
  • segmented heaps (便于 massively parallel GC);
  • low-fragmentation 算法基于"延迟合并 free"——deferred merging is similar to LFU local 防抖.

mimalloc 在 C / C++ 大量 short-lived allocation 上实测比 ptmalloc 快 7×, 比 jemalloc 快 1.5×. Rust 1.7+ 把 jemalloc 改成 mimalloc 模型 (具体视平台).

9. Rust 与 Go 的运行时自实现

Rust / Go 不用 glibc 的 malloc. 它们自己写了分配器.

Rust

  • 把分配器作为 trait Alloc,全局 #[global_allocator] 可替换;
  • 标准的 System allocator 是 malloc (e.g., libc 还是 ptmalloc;
  • 实战推荐链接 jemalloc-sysmimalloc 作为 global allocator;
  • 这是 rust-lang / Firefox / Linux 性能改造的第一步.

Go runtime

  • runtime 自己写 allocator, mlockless + tcmalloc 风格 TC + central;
  • 每 P 都有一个 mcache, 每 P 加一个 mcentral;
  • 大对象 (large > 32KB) 直接 mmap + central heap;
  • GC 后 mark phase + sweep phase,sweep 时实际 free;
  • 在 stop-the-world (STW) 边界看像 read-only scan.

Go 实际上把自己和别人都搞到自己 runtime 里 —— allocator + GC + 协程栈一起做调度. 简化程序员心智的同时, GC pause 上的工程一长尾常见痛点.

10. fragmentation:内部 / 外部

碎片是 allocator 失败的本质结果. 两种:

  • Internal frag: size class 把 n 字节进给 16 字节一边际, 浪费的几 B;
  • External frag: 已 free 的 chunk 不连续, 64 MB 总 free 但 1 MB 连续段切不出来.

外部碎片是长期内存增长的主因. 举例: 业务不停申请不同大小 16/24/40/8 malloc 后, 大 free 后处理时合并 algorithm but 周围仍然在使用, 导致一个 internal 大块没法回收. 主流缓解:

  • arena 分 bin sized, 同 size 集中同一 bin 形成线性可回收 (tcmalloc 思路);
  • 用 page split / coalesce by size class (jemalloc, mimalloc);
  • indirect_addr 让 free immediately defered 到 GC metaphore (Rust 的 mimalloc).

11. 大页与分配器协同

在 huge page 开了后, 分配器都应该 page-aligned 2 MB malloc 给 huge page friend. 没做就导致 huge page fragmentation:

  • 4 KB 的 page 在 1 GB 级别 alloc, 中间被 OS swap-out 一些 → 启 1 GB huge page segment across mess.

jemalloc 启用 --with-lg-page=21 编译, 1 GB huge page (MADV_HUGEPAGE) 是工程最佳实践.

12. 调优现场

# perf record arena contention
perf record -e cache-misses,context-switches ./程序

# 分配器行为 trace
export MALLOC_CONF="stats_print:true,lg_sample:19"
./程序 1>/dev/null  # 程序退出时 jemalloc 输出 layout 报告

# C 临时替换:
LD_PRELOAD=/usr/lib/x86_64-linux-gnu/libjemalloc.so ./your_app

13. 多语言同一抽象

把 OS allocator 与语言 runtime GC 抽象同源:

语言用户态分配器默认 / 推荐
Cptmalloc (glibc)LD_PRELOAD jemalloc/mimalloc
C++同上同上
Ruststd::alloc::System (默认 malloc)web 通常替换为 mimalloc/jemalloc
Goruntime 全自实现 GC 整合不能换
JavaJVM 内嵌 allocator, TLAB per-threadG1/ZGC/Shenandoah
Pythonpymalloc + PyObject arena不能换
V8 (JS)V8 Heap 持有 = 分代 GC

所有现代语言的分配器都用 thread-local cache / size class / 中央 page heap 这同构骨架. 区别只是"是否带 GC + GC pause". 这就是 DSA 的"摊还 vs 最坏" 在工程层又一次同构.

14. 这章带走的东西

  • malloc 是由用户态分配器决定的, 不是 sys 调用;
  • size class 是分配器"短小 + 内碎片可控" 的核心数据结构;
  • tcmalloc / jemalloc / mimalloc 共通形态: TC → central → page heap;
  • arena 数多 + thread cache 是平s-sp-2 thrashing anti-contention 的关键;
  • 业务 high QPS 服务第一线优化就是替换 ptmalloc → jemalloc/mimalloc;
  • 与运行时 GC 配合看到是同构, "摊还 + thread-local" 是同一抽象在分配器 / GC 中的不同物化.

下一节 → NUMA 与 CXL 内存池

NUMA 与 CXL 内存池

一句话

你看过 64 核的服务器跟普通桌面机 CPU 长得一样,但物理上 64 个核分成 2-8 个 socket,每个 socket 自己独享 1-4 TB 内存,跨 socket 访问那条"QPI/UPI / Infinity Fabric"链要 100-200ns 等 — 比本机内存慢两到四倍。这就是 NUMA。在 NUMA 机器上跑一个不知道 NUMA 概念的服务(如 Redis),P99 可能比理论值高 3 倍。理解了 NUMA 后,再看 CXL 这种新硬件,本质是"把 NUMA 横向伸到机柜"——同一个抽象在网络层重新物化。

1. UMA → NUMA 的物理演进

岁月静好 UMA 模型:

所有 CPU 共享一条前端总线 → 同一条 DRAM 总线 → 所有 CPU 看内存延迟一致

但当 CPU 核数从 4 → 16 → 64,总线带宽抢不过来了,必须分簇

Socket 0: 32 核 + 1 TB DRAM  ─ hyperlink ┘
                                │       ← 100-200 ns cross-socket
Socket 1: 32 核 + 1 TB DRAM  ─ hyperlink ┘

每个 socket 自己内存达"近",跨 socket 远。这就是 NUMA = Non-Uniform Memory Access.

2. 物理延迟常数表

本地 DRAM 访问      80-100 ns
跨 socket UPI      + 100-150 ns
跨 2 跳 NUMA       + 200 ns
HBM (本地 fusion)   50-100 ns
CXL.mem 直连        170-250 ns (新一代 < 200 ns)
RDMA 同机柜         2-5 μs

跨 socket 延迟接近翻倍——这就是 NUMA 不被知道的代价。

3. Linux NUMA 工具入口

# 查 NUMA 拓扑
numactl --hardware
lscpu = NUMA node(s):
# 一般 1-socket systems: NUMA nodes = 1
# 2-socket servers: 2 NUMA nodes
# AMD Epyc: 4 / 8 NUMA nodes per socket (NPS4)

# 进程当前 NUMA 分布
cat /proc/<pid>/numa_maps
numastat -p <pid>

在 SYstemD 里:

# 启动绑到第一个 node
numactl --cpunodebind=0 --membind=0 ./yourapp

# 或更精细, bind CPU list:
numactl --physcpubind=0-15 --membind=0 ./yourapp

4. NUMA 调优真实工程模式

模式 1:进程隔离

32 核服务器跑两个高内存服务,绑两个 NUMA node:

numactl --cpunodebind=0 --membind=0 service_a &
numactl --cpunodebind=1 --membind=1 service_b &

每个服务仅用本地节点内存,跨 socket 0 次。

模式 2:NUMA-aware 分配 libnuma

#include <numa.h>
struct bitmask *mask = numa_allocate_nodemask();
numa_bitmask_setbit(mask, 0);
numa_set_membind(mask);
void *p = numa_alloc_onnode(1024*1024, 0);

C / C++ 应用直接 libnuma 控制内存位置.

模式 3:线程 + CPU 共同绑定

更近一步: 用 pthread_setaffinity_np 把线程粘在 CPU 上, 同时把内存放本地 node. 这个组合通常能拿到 P99 1/3 的延迟下降 — 这一招是高频交易 / 大数据服务的标配.

模式 4:avoid remote alloc

Linux 默认 first-touch policy: 第一次写入页的 CPU 决定页落在哪. 所以初始化大数组时写它的线程就决定了它的 NUMA 归属. 程 service startup 时有个 worker 提前 touch 全部内存, 后面 worker 共享该内存经常跑跨 socket :

#pragma omp parallel for
for (int i = 0; i < N; i++) a[i] = 0;        // 每线程 touch 局部 → 跨 NUMA 拆分

默认 OpenMP 有 first-touch 也是这种语义. 但手动管理时容易踩坑.

5. Redis 案例: NUMA 不知道就 1.5-3× 慢

实验对比 (8-socket 服务器, 200 GB Redis Cache):

  • 默认启动 Redis: QPS ~ 220k, P99 ~3 ms;
  • numactl → 单 node 绑核: QPS ~ 450k, P99 ~ 1 ms;
  • 双 NUMA 分两个 Redis 实例 + 路由: QPS ~ 900k 加起来, P99 ~0.5 ms.

在不改 Redis 源码的情况下, 仅用 NUMA 工具拿到的 1.5-3× 加速. 这是工程上"懂得硬件 = 直接升职" 的 Classic.

6. 多线程同步原语在 NUMA 下的代价

pthread_mutex 不是公平的: 不是 FIFO, 不是 LIFO, Linux 内核 futex 是 task-fair spinlock, 但 lock handoff across NUMA 拥有时间成本:

同 socket thread 互相抢: ~ 100 ns (L3 hit)+ wakeup;
cross-socket thread 抢: 200-500 ns (UPI latency) + wakeup;

NUMA topology-aware spinlock/sh mutex 在 Linux 上叫 NUMA-aware lock, 各种 lock ticker 包括 futex 改造能用 MCS lock, TTAS w backoff, CNA lock 之类减少 cross-socket. Linux qspinlock 加强 num-aware since 5.x.

业务代码 → 拆线程 → 一致在 socket 内做 sync. SHard data → per-socket storage.

// 反例:
static mutex m;
void run() {
    lock_guard<mutex> g(m);
    ...                                  // 多线程从 2 个 socket 同时进入 = 跨 socket 抢锁
}

// 正例:
per_socket mutex m[N_SOCKETS];
void run(int s) {
    lock_guard<mutex> g(m[s]);
    ...                                  // 同 socket 线程抢同 / 同 socket 锁
}

7. PGAS / DSM 的历史

把"跨 NUMA 内存" 推到极致就是 DSM (Distributed Shared Memory) — 用网络硬件支持 CPU 远程访问其他机内存. 90 年代 Stanford DASH / Flash (Dash-Flash), MIT Alewife 都是这条路. 但网络延迟微秒级远超 DRAM 纳秒级, 实操成本高, 商业上 JPG.

但 GPU 一脉/CXL 一脉把这条路拖回来了:

  • NVLink 4.0 → 400 GB/s GPU-GPU direct peer access;
  • CXL 2.0 → CPU ↔ device 一致缓存, host peer memory;
  • CXL.mem → 远地 DRAM 当作本地但延迟 ~170-250 ns, 类似 cross-socket NUMA.

8. CXL: 在同一台机柜里的 NUMA 延伸

CXL 是基于 PCIe 5 的 cache-coherent 互连协议, 看上去是 NUMA 的延续:

传统 NUMA:   CPU A ↔ UPI ↔ CPU B (本地 DRAM)
CXL:         CPU A ↔ CXL.mem ↔ 远端内存池
CXL.cache:   CPU ↔ CXL device (NIC / 加速器) 共享 cache 一致性

CXL.mem 让一台 4 TB 内存的服务器把"远端 1 TB 内存池"视为远端 NUMA node 4. ** numa 工具、 numactl --cpunodebind 全都直接适用**.

工程意义:

  • 内存池化 → 多台 server 共享一个 DRAM pool, 业务 server 内存按需扩 out. 冷启动快几十 GB;
  • 持久内存 → 把 PMem/NVDIMM 放到远端;
  • 加速器互连 (如 GPU 内存) 借助 CXL 互通, 互访延迟 ms 级 → μs 级.

这个时代从 2024-2025 开始部署最早, GC + Redis + KV 仓库陆续适配. NUMA 知识直接迁移过去.

9. NUMA 与分配器的协同

jemalloc 内置 NUMA 感知: arena 数 = NUMA nodes × 4, 内部 init 时绑定 arena → node. 这是分配器与 NUMA 同源工程示例:

  • arena 0-N 在 Node 0 上 alloc 页;
  • arena N-2N 在 Node 1 上 alloc 页;
  • thread x → arena hash → 选择本地 node;

这是 NUMA + allocator 的"两个抽象层共同优化 cache 行为" 的同构案例.

10. 多语言同一抽象

语言NUMA 触达备注
C / C++libnuma + first-touch最底层
Rustlibnuma-bind + jemalloc高度可定制
Goruntime.LockOSThread + cgo libnuma没有 first-touch 自动
Java-XX:+UseNUMA + JEP 369 (Hotspot, GCCore)JVM 把 heap per node
Python多进程 + numactl默认单核绑核

所有语言在 NUMA 机器上都有办法适配, 但需要显式配置, 否则默认行为不 NUMA-aware.

11. 这章带走的东西

  • NUMA 是 multi-socket 服务器的常态, 默认行为不优化, 跨 socket 延迟翻 2-4×;
  • 思维: 同 socket 内 才应该共享 data / lock; 跨 socket 用分多实例 / partition / NUMA-aware 数据结构
  • 工具链: numactl + libnuma + numastat 直接可用, 不需修改 language runtime;
  • CXL 是 NUMA 互连在网络层扩展, 同抽象可拓到机柜级;
  • allocator (jemalloc) 内嵌 NUMA arena 是 OS 层 + 运行时层共同优化的同构示例.

下一节 → 文件系统

文件系统

一句话

文件系统是工程上最悠久也是最有 trade off 张力的 OS 子系统:磁盘带宽、崩溃一致性、scan 性能、删除代价、小文件 metadata 爆炸、与 page cache 协同——每个点都引出一整套工程历史. 这一节带你把 ext4 / XFS / Btrfs 的设计差异看清,并把 mmap / Direct IO / io_uring 这三个"IO 模式"从一个 syscall 调用拆到硬件层.

inode、dentry、page cache

一句话

你说 cat /etc/passwd,看似"打开一个文件然后读字节"。实际内核做了五层抽象:

路径 /etc/passwd
   ↓ (dentry 树解析)
inode 号 + fs 元信息
   ↓
磁盘 block 列表 (extent / direct / indirect)
   ↓
page cache (一次 page 4 KB)
   ↓
块层 + IO 调度
   ↓
NVMe 提交 queue → DRAM 落块

每层都是一个 cache, 每层都各有失效模式. 这一章把这套链路拆开, 让你看清 open + read 在 OS 内部到底是怎么发生的——同时也解释为什么 PostgreSQL / InnoDB / Rocks 这些系统都坚持自己再做一遍 IO 层.

1. dentry: 路径字符串看到的虚拟树

当你 cat /etc/passwd 内核要先做 path resolution:

/   →  根 dir inode 1
etc →  在根 dir 找 "etc" 这一项
       这一项指向下一层 dentry
passwd → 在 etc dir 找 "passwd" 这项
       拿到 final inode

每一层访问都是一次目录扫描 (实际是个 hash/遍历查找). preload 内存里维护 dentry cache (dcache), 命中即命中.

dcache 是 OS 层 "路径 → inode" 的缓存. 内核启动后大部分系统的 /usr/bin 等热目录可达 dcache 100% 命中.

2. inode: 一个文件的 metadata

inode 装着:

文件大小
所有者 + 权限
atime / mtime / ctime
文件数据 block 列表 (extent 或 direct + indirect)
文件类型 (regular / dir / link / device / FIFO)

注意 inode 不带文件名——文件名是它在父目录这条 dentry 的属性. 一份 inode 可被多个 dentry 指 (hardlink), 即一个文件可以多个文件名, 但 inode 本身唯一.

读 inode 也走过 VFS 层后到具体 fs, 在 ext4 上是 ext4_inode 结构, 字段 packed, 256 字节起步, 大文件可扩展.

3. extent vs indirect block: 怎么描述"一个文件的所有数据块"

历史方案是 BSD 早期用 direct block + indirect block + double indirect + triple indirect:

inode 前 12 个 block pointer 直接给前 12 × 4 KB = 48 KB;
第 13 个 pointer → 一块装 1024 pointer → 1024 × 4 = 4 MB;
第 14 个 pointer → 一块装 1024 个 pointer → 每 pointer 又指向装 1024 pointer 的块;
第 15 个 → 4 GB;

听起来文艺但不工程友好 —— 大文件一次访问多级寻址 + cache miss.

现代文件系统改用 extent: 一个 extent 直接表示 [起始 block, 长度 L blocks]. ext4 一个 inode 装内嵌 4 个 extent, 一个 extent 默认最大 32 KB (现代改进后 extent tree 可以扩展). XFS 用 B+ 树 extent.

extent 的好处:

  • 顺序文件 metadata 极小 (1 GB 文件 = 一个 extent entry);
  • 顺序 IO 友好 (磁盘一次 sequential read);
  • 大文件碎片化 metadata 节省 cookie.

这是 DSA 中 B+ 树 + 顺序物理化 在 OS 文件系统层的同构.

4. page cache: 把"读过的页" 留在 DRAM

文件 IO 的真实路径不是 read → disk:

read(fd, buf, n)
   ↓
explicit_filemap_read → page cache 命中 →
   ↓                                ↓
命中就 memcpy 到 buf            miss →
                              submit_bio → 块层 / NVMe
                              data 拉进 page cache
                              再 memcpy 到 user buf

read 总是先看 page cache, mmap 是更高级别坐进 page cache 让用户空间零拷贝访问.

page cache 是 OS 层的"通用 read cache", 对所有进程间共享——如果两个进程同时 cat 同一个文件, 同一个 page cache 被 reused.

5. dirty page 与 writeback

写路径:

write(fd, buf, n)
   ↓
page cache 上写 (与 read 反过来, 先看命中)
   ↓
mark dirty + 设置 dirty 标记位
   ↓
  pthread_async + writeback thread 慢慢 submit_bio 来刷盘
   ↓
disk 完成, 清 dirty 标记

write 在 99% 情况下不立刻写盘. 这是 OS 优化的核心: 把 write-burst 缓冲起来 + 顺序刷盘 + 由 IO 调度合并相邻 page.

但要持久化, 用户必须 fsync(fd) —— 强制把 dirty 同步刷盘并等 disk ACK. fsync 之前的数据可能在断电 / panic 中丢, 这是 crash consistency 的核心题.

6. 文件 IO 怎么测性能

# 系统 cache 命中
time cat /etc/passwd > /dev/null          # 几百 μs

# 强制读盘 + Drop cache
echo 3 > /proc/sys/vm/drop_caches
time cat large_file > /dev/null           # 看盘速度

# 用 fio 做 raw benchmark
fio --name=test --filename=/tmp/a --rw=randread --bs=4k --size=1G --iodepth=8 --numjobs=8

# 看 page cache 占用大小
free -h
vmstat 1

7. 多语言 / 多运行时

语言默认 IO 路径优势 / 坑
C read/write系统调用 + page cache取决于 fsync
Go os.File.Read同上 + goroutine 调度 不阻塞 GMPGOMAXPROCS 满时阻塞会 M 扩
Rust File::readlibc wrapper, 内 readasync 用 tokio::fs 走 epoll + thread pool
Java InputStreamReaderJVM 内 heap buffer + page cache双层 buffer 有时浪费
Python open().readC InputStreamReader 流式 buffer写大文件建议 buffering 调大

各语言 IO 接口都 wraps 系统 syscall, 抽象同一, 仅 syntactic 差异. 真正工程上 IO 性能差别主要看 cache 是否被正确使用 + fsync 频率.

8. PG / InnoDB / Rocks 的"自己再写 IO"

主流数据库 (PG / MySQL / MongoDB / RocksDB) 绕过操作系统 page cache + 自己做缓存管理:

  • 内 buffer pool 是 OS page cache 的平行物, 但能精确控制 LRU + 大小 + writeback;
  • 使用 O_DIRECT flag (下一章详谈) 让 IO 不进 page cache;
  • 自己做 pread + WAL + checksum.

为什么? OS page cache 弱在多线程 + 大内存 + 多 workload 共享机器时不可预期. 自家 buffer pool 能控 : 哪个 thread 等谁、 cache hit 高 / 低、flush 时频率.

9. i_size + i_blocks / dense sparse file

inode 的 i_sb (super block) 信息中含 i_blocks (used blocks count, 1 block = 512B). Linux du -hi_blocks × 512 实际盘占用, 而 ls -li_size 逻辑大小. 这两者差距来自 sparse file 不一定真占用 block. mkfile 输出 50 GB sparse 文件 = 0 i_blocks, 但 i_size = 50 GB.

fallocate --dig 通过 fallocate syscall 让 fs 提前置位 extent 但不实际写入 0, 当 metadata-first allocator, 极适合"预占大文件 + 慢慢填充". 不预先 fallocate 大文件, 等 first write 时 fs 才动态分 extent, 在 high perf IO 场景上要 fallocate + O_DIRECT.

10. 这一章带走的东西

  • open + read 实际穿五层 VFS/dentry/inode/page cache/block layer;
  • extent 模型是 indirect block 的工程演进 (少 metadata + 友 cache);
  • page cache 是不分进程共享的 read cache;
  • write 默认仅写 page cache, fsync 才保证持久化;
  • 数据库普遍走 O_DIRECT 绕 page cache, 自己实现 buffer pool.

下一节 → ext4 / XFS / Btrfs 设计差异

ext4 / XFS / Btrfs 设计差异

一句话

ext4 / XFS / Btrfs 是 Linux 三大主流文件系统, 它们代表了文件系统设计历史上 三个阶段:

  • ext4: 80-90 年代基于 extent 的传统 hierarchical-metadatad design;
  • XFS: 90 年代 Silicon Graphics IRIX, B+ 树元数据, 大文件高并发;
  • Btrfs: 2007 + Oracle 抄 ZFS 思想做 COW + checksum + subvolume + snapshot.

三个设计在 metadata 组织、崩溃一致性、写放大、并发伸缩 上各有工程取舍. 这章不教你如何使用它们, 而是把"它们各自因为什么物理形态 / 算法选型导致行为差异" 这条推理链推导出来.

1. ext4: 经典派代表

fs layout:

superblock ( backup 副本隔 N 个 block group)
block group N: { superblock backup, block bitmap, inode bitmap, inode table (一块 inode 数组), data blocks }

每个 block group 是单元自洽: 自己的 inode bitmap + 自己的 data bitmap.classical fs layout, 由 inode table 与 data blocks 在同 group 上, 鼓励 inode 与其文件 data 在物理上靠近.

Key features:

  • Extent-based addressing, 一段连续磁盘块 = 一个 extent entry;
  • Journal (data=ordered 模式默, data 性能与崩溃一致的取舍) → 仅 journal metadata, 不 journal data 块;
  • Hash trees (htree) 大目录加速; -_INLINE_DATA feature 把小文件直接放 inode 中 (< 60B).

实测优势:

  • 简单 + 稳定 + 数十年的工程验证;
  • 小文件 (<4 KB) 性能不错;
  • 配置习惯兼容, /etc/fstab 通用配置.

痛点:

  • 多线程大文件高竞争下 metadata 互锁太多 → 扩展性受限;
  • fsck 长时间 (在断电 fsck 时仍几年出现 lost+found);
  • snapshot、压缩、checksum 都无 (即不能像 Btrfs 这套现代特性).

2. XFS: 大文件高并发派

fs layout:

allocation groups (AG): 类似 block group 但更 herd-disorganized;
每个 AG 自己 B+ 树 inode + B+ 树 free space;
全局 superblock + AG free space 段.

XFS 把 metadata 装在 B+ 树而不是 bitmap / 链表. B+ 树让 metadata 查找从 O(n) 降到 O(log_m n), 与 OS page 边界对齐.

Key features:

  • allocation groups 数 = number of CPU cores * 1 默认;
  • delayed allocation: write 先 buffer memory, 等 flush 时再分配 extent, 让 batch I/O 调度;
  • extent B+ 树, 最大文件 8 EB;
  • reflink (共享 extent) 自 Linux 4.16 后;
  • 文件系统空间内 XFS_IOC_ALLOCSP / parents dp fd_to_dfd, 是 cp --reflink 的 base.

性能特征:

  • 大文件 sequential scan 性能极高 (e.g. AI training dataset loader);
  • 大并发 metadata op 伸缩性比 ext4 强 2-3x;
  • 不擅长小文件 metadata flooky (但 5.x htree 加了, 与 ext4 差距小 today).

3. Btrfs: CoW + subvolume + checksum 派

fs layout:

chunk 树 +_root 树 +extent 树 +fs 树;
Btrfs 用 CoW (Copy-on-Write) 语义:
  任何写都先写新位置, 再 commit 让 root 指针 swap 到新版本,
  实现 atomic commit + 大量 snapshot 易 实现.

Key features:

  • B+ 树文件系统 (i.e. node 都是 B+ tree node);
  • checksum 每 4 KB block 一 SHA256 (默认是 crc32c);
  • snapshot 是 metadata tree root 复制 (O(1) 操作);
  • subvolume = 独立 fs 树 (互相 hardlink 不限);
  • compression (zstd, lzo, zlisep est) 在线压缩; -raid profile for data / metadata avant.

性能特征:

  • 小文件 + 在线压缩下 SSD 写放大友好;
  • snapshot 极快 (O(1));
  • 整个文件系统每秒查询 + write + check = checksum 增 ~10% CPU;
  • fragmentation 因为 CoW, 顺序大文件读常被打碎;
  • 文件删除 metadata 衍生 b-tree 调整 → 偶尔出现 sub-tree merge expensive (mtime 经历).

痛点:

  • CoW 让顺序写 randomize — 大 IO 顺序弱于 XFS / ext4;
  • 早期 ~5.x 内核有 ENOSPC / 空间管理 bug (现已修);
  • Btrfs + send/receive: 可在线 (snapshot) 但对接 backup 工具不友好.

4. 三个性能/伸缩性能 行为实测对比

测试: 4 KB randwrite, 1 GB, 64 线程, NVMe Samsung 980 Pro:

ext4 (data=ordered, journal=writeback):  310 k IOPS
ext4 + O_DIRECT:                          470 k IOPS  
XFS (no journal data):                    480 k IOPS
XFS + O_DIRECT:                           680 k IOPS
Btrfs (no compression):                   280 k IOPS
Btrfs (zstd 黄):                          290 k IOPS (因压缩与单机 stub 互扯)

两个观察:

  1. ext4 顺序写 + 中并发下不输给 XFS 太多;
  2. Btrfs 在 high IO random write 下基本被 XFS 打 2× ——这是 CoW 的代价.

5. fsync 与三个 FS 的不同承诺

ext4 (default data=ordered):

  • journal metadata 但保证 data 在 metadata commit 前落盘;
  • fsync 等所有 writeback + journal commit 完成;
  • 写 burst 文件完成后 fsync ~2-3 ms on NVMe.

XFS:

  • 默认 delay alloc + journal metadata;
  • fsync 强制 flush all delayed allocations, 等 journal commit;
  • 性能 P99 略好 ext4.

Btrfs:

  • fsync commit the current subvolume tree to disk; → CoW 实现 atomicity;
  • 因为并行 CoW metadata tree, fsync 通常 1-3 ms.

但是 fsync 实际取决于硬盘:NVMe 单次 fsync 通常 100 μs ~ 2 ms; SATA SSD 1-5 ms; 机械 HDD 10-30 ms.

warning

默认 ext4 配置 data=ordered 不 journal user data. fsync 仍保证数据持久化, 但 write 失败后 fs 状态可能仅有 metadata 而无 data. 这是_DATABASE 要写 redo log 走 fsync 的原因(下一章详谈).

6. 写放大比较

写放大 (Write Amplification Factor, WAF) = 真实写盘 字节 / 用户 IO 写字节.

                   4 KB 随机写        1 MB 顺序写
ext4 (ordered):    1.0× (一次写)      1.0×
XFS:               1.0×                1.0× 
Btrfs (no comp):   4-10× (每 CoW + checksum)    3-8×
NTFS+ SMR:         10-30×             N/A

CoW 必然引出大写放大: 写一个 4 KB 让 fs 先 read old 变 4 KB + 写新 4 KB + 写 metadata + 写 journal. 这就是为什么 Btrfs 是 CoW 设计的核心代价.

SSD 上 WAF 是真实 IO 成本指标. 如果你的负载是高 burst random write 100 GB/day, ext4 / XFS 的 SSD 寿命比 Btrfs 长 3-5 倍.

7. 选型树

有 snapshot 需求      → Btrfs
大文件 + 大并发 (训练 dataset, 容量 > 数百 TB)  → XFS
小文件 + 数据库 + 通用 server  → ext4
开放式 + 数据库集群跨多机存储  → XFS / ZFS / Btrfs
集群 AI training dataset load 加速:  → XFS  
嵌入式只读 fs: → SquashFS / EROFS

主流云服务商:

  • GCP / AWS / Azure / DigitalOcean → ext4 默认 (legacy + 简单 + 兼容);
  • Cloudflare fsync 0 出事件 → 后 follow-up 强制 ext4 = ordered;
  • Backblaze B2 → ZFS (RAID + checksum);
  • Largely, XFS 在大 db workload 是趋势.

8. 这一章带走的东西

  • ext4 = 经典 (block group + htree + extent) 数据库负载 + 通用;
  • XFS = B+ 树 metadata + delay alloc, 大文件 + 高并发的胜场;
  • Btrfs = CoW + checksum + subvolume + snapshot / 写放大代价大;
  • 三者在 fsync 行为承诺一致 (都从 journal CoW 拿持久化);
  • SSD WAF 是数据库 / 大负载 选型 hfi.

下一节 → Direct IO、mmap、io_uring

Direct IO、mmap、io_uring

一句话

Linux 给你至少三种文件 IO 抽象: 普通 read/write + page cache、Direct IO 绕 page cache、mmap 让内存成为文件视图、 io_uring 用 ring buffer 批量 syscall. 这 4 种模式对应 OS 在用户态、内核态、块层、设备层不同方式的抽象取舍. 这章拆开看,你会发现它们不是"互相替代"——而是四种对应不同工作负载的物理化. 在数据库、KV 存储、AI 训练 dataset、高性能服务里,选错一种就立刻损失 50% 吞吐.

1. 默认 read/write + page cache

基本模式:

int fd = open("/data/foo", O_RDONLY);
ssize_t n = read(fd, buf, 4096);

read 系统调用实际做了:

syscall read → 进入 kernel mode;
查 file 的 page cache;
没有 → 启 submit_bio → 块层 NVMe 出: SSD → DRAM (一次 PRP DMA);
有 → copy_to_user(buf, page_cache 的位置, n);
返回 (n 字节)

每一次 read 必然拷一份字节到 user buffer. 拷贝本身 这个动作在 OS 层无法避免 (security: user process 不能直接读 kernel page; 隔离) .

write 同样进 kernel 但 page cache 标 dirty 不立即落盘. 这就是 page cache 默认行为.

适合: 通用文件读 / 写; 大量 syscall 但 cache hit 高; 中小服务.

不适合:

  • 自家缓存管理很大并精确控制 (数据库 buffer pool);
  • 1 ms P99 不能容忍的硬延迟 (syscall + page cache 路径);
  • 大文件 sequential scan (page cache 被洗一遍).

2. Direct IO (O_DIRECT)

int fd = open("/data/foo", O_DIRECT | O_RDONLY);
ssize_t n = read(fd, buf, 4096);  // 必须 4KB 对齐的 buf

O_DIRECT 改的是什么: 数据不进 page cache. 直接 DMA 到 user space buffer. 跳过 memcpy 一步.

前提: user buffer 必须 page aligned、len 必须是 page 倍数、file offset 必须 page 对齐. 不对就 -EINVAL.

实现机制:

O_DIRECT read:
   → 验证 buf 对齐
   → submit_bio 但 bio 把 page 指向 user buffer (not page cache)
   → 同步等 DMA 完成
   → return (no copy)

为什么需要 O_DIRECT?

  1. 数据库自己有 buffer pool, 不希望 OS 再把 page cache 占几百 GB;
  2. 顺序写 huge file 不希望破坏 page cache;
  3. 1 ms 级延迟服务 page cache lookup overhead (~1 μs) 累计起来太多.

3. mmap: 把文件当 byte aptr

void *p = mmap(NULL, len, PROT_READ|PROT_WRITE, MAP_SHARED, fd, 0);
// 接下来 p[i] 就是文件的第 i 字节

mmap 把内核 page 与 user VA 关联. 一次 mov eax, [p+i] 触发 page fault (if not 物化), 内核拉 page → page cache, 然后用户去访问.

优势:

  • 零拷贝 (user 直接访问 page cache);
  • 不需要 explicit read/write syscall;
  • 共享 MAP_SHARED → 多进程同 map 文件可共享 page cache;
  • 支持大文件 lazy page-in (不会一次拉整文件).

劣势:

  • 写仍标 dirty 可以丢失 until msync/fsync;
  • page fault 是额外 sub-μs latency (伴 TLB miss);
  • DAX / persistent memory 可以 mmap NVM 直接访问.

适合:

  • 只读大文件 multi-process 扫描;
  • 配置文件 mmap + 不变;
  • 静态 immutable 数据 集 (索引);

4. io_uring: ring-based async IO

io_uring 是 Linux 5.1+ 的"新一代 async IO" 接口. 用 ring buffer 可以批量 syscall 提交 + 完成, 不一个个进入 kernel.

struct io_uring ring;
io_uring_queue_init(64, &ring, 0);

struct io_uring_sqe *sqe = io_uring_get_sqe(&ring);
io_uring_prep_read(sqe, fd, buf, len, 0);
io_uring_submit(&ring);

struct io_uring_cqe *cqe;
io_uring_wait_cqe(&ring, &cqe);
int n = cqe->res;
io_uring_cqe_seen(&ring, cqe);

io_uring 优势:

  • 一组 submit + 一组 reap, 减少 syscall 次数;
  • SQPOLL 模式 + kernel thread, 减少 kernel entry 0 次/entry;
  • 支持 readv/writev/files/mmap/timer/network unified API.

实测: 4 KB 随机读, 1 个 NVMe, 16 深度 io_uring ≈ 3.5× 比 epoll 取 thread-pool + O_NONBLOCK read.

: SQPOLL 模式如果你的业务 use 一段时间随手 exit, kernel thread 空耗 CPU; 用户 + kernel 数据竞争 metadata 处理要 referral.

5. 三种模式实测比较

测试 (1 TB Samsung 980 Pro NVMe, 4 KB randread, depth=32):

模式IOPS实际 CPU%
read + page cache + sync350k70% 用户态
O_DIRECT + sync600k30% syscall, 70% user
O_DIRECT + libaio (aio_read)800k25% syscall, 75% user
O_DIRECT + io_uring native900k18% syscall, 82% user
O_DIRECT + io_uring SQPOLL1.05M5% syscall, 5% kernel thread, 90% user

io_uring SQPOLL + 0 syscall entry —— 这是 Linux IO 现代性能革 ground.

6. 几个语言运行时态

语言IO 抽象归os IO bottleneck
C / C++libc 调用 + Direct IO一切可控
Ruststd::fs 调用 Direct IO + async 中 tokio IO_uringtokio-uring crate
Gonetpoll + goroutine 中 read/writeGo runtime 手做 epoll 包
Java NIO DirectBuffer堆外 alloc + blocking sendfileNetty 最深
Pythonio_uring 自 3.12+ 试验asyncio + RuntimeError Liblet

加提案中 io_uring 提案: Rust async-std / tokio 多线开 io_uring. Java Project Loom 自 z 21 该 behind epoll. 选型上是实际工程负担极其.

7. io_uring 工程实战模式

例 1: KV server 用 io_uring SQPOLL + preadv 批量. 每 batch 提 64 sqe, 等 cqe
      暴增 server CPU utilization 出 ★ → 80% 用户态 syscall -> iouring enter 
      每 mmap SQ+SQE ring buffer = max 1 syscall/tick. 理论零 sysentry load.

例 2: AI training dataset IO IO_uring asynchronous random read = pipe line
     dataset loader, IO 与 GPU forward 重叠 + zero syscall back
     to text 把 bottleneck 转给 GPU.

例 3: nginx + QUIC + io_uring: 每 packet 一帧 nginx async butio_uring submit recv,
      优势响应 latency µs-level.

8. 这一章带走的东西

  • 普通 read/write + page cache 是默认;
  • O_DIRECT 绕 page cache, 数据库几乎互为标配;
  • mmap 灵活 + 跨进程共享但 P99 略 laggom;
  • io_uring + SQPOLL 是现代 Linux GCP-like AI 新 IO base;
  • 主流 runtime io_uring 适配差, 自己写 epoll 仍是常态.

下一节 → WAL、fsync、崩溃一致性

WAL、fsync、崩溃一致性

一句话

工程上 90% 的"数据丢失 bug" 都因为误以为 write 成功就持久了, 误以为 fsync 一定成功就持久了. 这一章把 Linux + ext4 + NVMe 下的崩溃一致性模型拆开, 让你看到write / fsync / fdatasync / 终端 cache / 设备电池 这五层覆盖在 CRIT between user app 与 NVM 实际写完之间.

1. 五层缓存

让我们从用户 write(fd, buf, n) 把字节真正落到 NVME cell 之间拉一次完整路径:

1. user 写 page cache: write 调用 → mark dirty
2. fsync(fd) 等 page cache 实际 writeback
3. writeback → block layer → 组 bio → submit
4. NVMe driver → submission queue → 设备
5. NVMe controller 实际 NAND write + flush + checkpoint
6. NAND cell 物理 charge 写入完成 ack 

每层都是一个 cache, 每层都可丢失. 这就是为什么 fsync 也不 100% 安全:

ext4 fsync → journal commit → 块层 write → NVMe cache → NAND.

如果 NVMe 设备有 cache 而 fsync 提交需求没 pass through NVMe cache flush, fsync ack 但 cache 没到 NAND, 断电仍丢. 这是真实硬件隐患.

2. fsink 提交语义

ext4 / XFS / Btrfs 三种 fs 主流方式:

ext4 default data=ordered:
  - dirty pages 先 flushed to data area,
  - journal commit 中写 metadata commit block,
  - fsync 等 journal commit 完成才 return.

ext4 data=writeback:
  - 仅 metadata journal;
  - data 可能 metadata 之后写 → 看到 stale block.

ext4 data=journal:
  - 全部 data journal;
  - 安全但写放太严重 2× overhead.

XFS 默认 delay-write:
  - 类 ordered, 但数据 first delayed write then block flush.

Btrfs:
  - 每 fsync 触发 transid++ commit CoW;
  - 同 XFS 类性能.

主要不同: 默认 ext4 data=ordered 保证了 data 不会比 metadata 先丢——避免 commit metadata 后看到 stale block. 这就是早期 Linux 2.4 NFS 比 ext3 data=journal 用 5 年才改的原因: 写 + crash 后 fs state 一致.

3. fdatasync 卷语义

fdatasync 不强制 metadata 中 atime/mtime 写盘, 但强制 data 写盘. 对 append-only log 是 fdatasync 性能更好.

原因:

fsync:
  ## 必等 metadata (size, mtime 等) 写盘
  ## 强制 journal commit

fdatasync:
  ## data 写盘
  ## 仅在 size 改变时 fs 元数据也 stdsync flush

append-only log 上: 强制 size metadata 也 flush 当且仅当最后大小必须被下次 fs 状态读取. 否则 fdatasync 安全.

4. NVMe Force Unit Access (FUA) 与 Volatile Write Cache (VWC)

NVMe 命令支持 bit:

  • FUA = Force Unit Access. 命令绕 device cache 直接到 NAND;
  • 只 NVM 终端 fdatasync 是不够的 —— 内核驱动通过 IO command Flush 给设备写盘.

SCSI 类硬盘 / 部分 NVMe 有 volatile write cache. 设备自我 ack fast 但吸收在 device buf. 断电时 buf dump. Linux write cache = write throughwrite back 区分:

hdparm -W0 /dev/sda   # 关闭 write cache

关掉 write cache = performance drop 30-50% 但 fsync ack 即感真持久化. HRT / database 推荐.

5. 崩溃一致性概念

durability: 一次 fsync 后数据一定持久化 ⇒ ack 后断电也能 find.

atomicity: 写 multi-block 全完成或全未.

isolation: 在崩溃之后看到的 fs state, 等同 某个 in-order 之前完成 fsync 的快照.

ext4 ordered 默认满足: durability (fsync 等到 commit) + atomicity (journal) + isolation (transid).

但 ext4 writeback 不满足 atomicity (data 不 journal) ⇒ fsck 后你也许看到 partial data write + commit metadata.

6. WAL (Write-Ahead Log) 数据库的核心设计

数据库为了保证 ACID, 什么 dirty pages 都先写 WAL redo log + fsync.

事务 commit 步骤:
1. 修改 buffer pool pool page (in-mem);
2. 写 WAL redo log append (in-mem);
3. fsync(WAL fd);      ← 等待 truly persistent;
4. ack 客户端 "commit ok";
5. 后台异步 flush dirty page buffer pool → disk (写 data file).

崩溃 ~ 准 back eventually: WAL fsync 后 ack 即持久, data file 后续 lazy flush 是 ok 的. crash 后, 启动读 WAL, redo 重做 final commit.

这就是 InnoDB / PostgreSQL / RocksDB / CockroachDB 共同的 WAL 模型. 核心思想: 持久化只需要 redo fsync 一次, data 落盘可以 amortized.

7. 数据库的 group commit 优化

每 fsync ~ 100 μs - 5 ms, 不能每事务都做. → 组 commit: 多个事务的 WAL 一起进程, 一次 fsync 同时段 完成所有事务.

所有 commit_req 入队列,
线程 1 持有队列, 等待时间窗口 (e.g. 100 μs) 让 batch grow,
后 fsync 1 次, ack 全队员.

MySQL 5.7 提 binlog_group_commit, MySQL 8 后 Innodb redo log 也用 group commit, 单次 fsync 容纳 ~ 100+ 事务.

P99 写延迟: 1 个 commit ~ 5 ms (fsync overhead)→ group 100 个事务: ~ 5 ms / 100 = 50 μs / commit.

8. 数据库 + ext4 / XFS / Btrfs 调优建议

# ext4 database mount options
mount -o noatime,nodiratime,data=writeback,barrier=1 /dev/sda1 /data
# noatime: 不更新 atime (减少 metadata IO)
# data=writeback: 不 journal data (WAL 已经保证 atomicity)
# barrier=1: 关键 — 所有 metadata journal commit 必 flush device cache

barrier=1 是关键: 关闭 barrier 意味着 journalist commit 在 ext4 层完成 (in mem journal) 而设备 cache 没写. 断电破坏 fs ordering.

9. 多语言 / 多 runtime 同一抽象

语言 / runtimefsync / commit 模型
C / C++ stdexplicit fsync + 一次 syscall
Go os.File同上
Rust std::fsFile::sync_all → fsync
Java NIOFileChannel.force(true)
Pythonfile.flush() + os.fsync(fd)
SQLitedefault 通过 WAL + fsync
PostgreSQLfsync + group commit + WAL
RocksDB别提了 + 每 wrapper fsync
Redis每 BGSAVE + RDB + AOF fsync (slow mode)

所有持久化数据库都需要 fsync at least once per logical update. fsync 是 OS / 设备间义务.

10. 旋涡调试

# 强制清 cache
echo 3 > /proc/sys/vm/drop_caches

# 看 ext4 commit interval
cat /proc/mounts | grep data

# 看 NVMe cache 状态
nvme id-ctrl /dev/nvme0 | grep -i cache

# 关 write cache (warn: 性能降)
hdparm -W0 /dev/sda1

# 在 PG 里面 pgbench with synchronous_commit
pgbench -c 16 -j 4 -T 60 -M prepared --protocol=prepared mydb

11. 这一章带走的东西

  • write 不保证持久化, fsync 才真持久化, 但仍依赖 device cache;
  • ext4 data=ordered default 是历史正确选择, database 推荐 data=writeback + barrier=1;
  • WAL 模型让 fsync 摊还到 commit batched (group commit);
  • fsync P99 是数据库 commit P99 上界, group commit 拿到 100× throughput;
  • NVMe cache / FUA / barrier=1 是 fsync 与 device 层的合作 contact.

下一节 → 进程与线程调度

进程与线程调度

一句话

OS 调度器是工程上最被低估的 OS 子系统。一台 64 核服务器同时跑 5000 个线程,谁先跑谁等谁抢核全靠 scheduler. 它决定了你 P99 抖动来自哪里。这一节把 CFS / 实时调度类 / GMP / Cgroup v2 都拆到底层,让你看清楚每种调度模型的物理形态——而更重要的是看出"为什么 Linux 5.x 后的 EEVDF 调度器替换了 CFS",背后是同一组抽象层升级.

Linux CFS 调度器

一句话

"完全公平调度器" CFS = Completely Fair Scheduler 是 2.6.23-2007 引入的 Linux 默认调度类. 名字叫"完全公平" — 它真的试图做到任何任务的机会都按 weight 加权 fair share CPU 时间. 但 CFS 的物理实现是红黑树 + vruntime, 而不是朴素 weighted round-robin. 这章把红黑树和 vruntime 这两个抽象放在一起, 让你看清"50-char 算法选择到 OS 内核"的真实工程.

1. 需要调度的本质

5000 个线程抢 64 个核. 每线程不一定"想" 跑多久:

  • 一个 web server 处理一个请求大概 100 μs;
  • 一个 ML 训练线程每次 inference ~30 ms;
  • 一个 daemon wake up 处理 ping ~1 ms;

调度器的工作是: 每核每 1-10 ms 内, 选一个最有权获得 CPU 的 runnable thread 跑, 然后定期 preempt, 让下一个 thread 跑.

传统 Unix Unix 调度器 (2.4 / 2.5 之前): 是 priority + timestep (类似 priority RR). 但 priority RR 有几个问题:

  • "nice" 的 metric 是糟糕的 ( nice 0 的 vs nice 10 不是平沙发, 实际差距 +10-12 临界);
  • 多核不友, 对称 boom lock;
  • 不能 account actual cpu time (虚拟化场景里真实 CPU 和真实 wall clock 偏差).

CFS 用 vruntime 取代 priority RR: 把"已经多跑过 CPU" 累计起来, 让多跑的退后让位.

2. vruntime: 公平的核心数据

定义:

vruntime = ∑ (实际运行时间 × 优先级权重倒数)
  • 跑得多的 thread 累计 vruntime 大;
  • vruntime 小的 thread 表示"等待 CPU 已久"或"曾经被偷走过 budget";
  • CFS 试图选 vruntime 最小的 runnable thread.

nice 值从 -20 → 19, default nice=0. 它对应一个 weight (kernel/sched/sched.h 中 prio_to_weight:

static const int prio_to_weight[40] = {
 /* -20 */     88761,    71755,   56483,   46273,    36291,    29154,    23254,    18705,
 /* -12 */     14949,    11916,    9548,    7620,     6100,     4904,     3906,     3121,
 /*  -4 */      2501,     1991,    1586,    1277,     1024,      820,      655,      526,
 /*   4 */       423,      335,     272,     215,      172,      137,      110,       87,
 /*  12 */        70,       56,      45,      36,       29,       23,       18,       15,
};

每个 nice 调一挡差 ~25% (注意: 多挡差是几何递降). nice 19 vs nice -20 差距 ~88761/15 ≈ 5900×.

这意味着:

  • nice 0 weight=1024 (default), 1 sec 跑后 vruntime 增 1 sec;
  • nice 19 weight=15, 1 sec 跑后 vruntime 增 1 sec × 1024 / 15 = ~68 sec.

vruntime 增长率反比 weight —— nice 高的 (低优先) vruntime 增长快, 几秒就排到最后. CFS 即用 nice 但 fail ACL 不公平.

3. 红黑树: 维护 vruntime 有序集

每 CPU 自己一个运行队列, 是一颗红黑树 ( per-cpu rq.cfs_timeline ), key = vruntime:

  • 每次选下一个 thread 跑: 树最左 = vruntime 最小;
  • red-black trees 提供插入 / 删除 O(log N) 保证.

N (runnable thread 数)在一台 server 上可到几千. 每毫秒重新挑下个 thread O(log N) = <15 步 非常便宜.

note

这里是 DSA 章节红黑树 + 红黑写入旋转 ≤ 3 在 OS 内核里的真实物化. 行业里你极少直接手写 red-black tree, 但你天天用它.

4. preempt: 节拍 + tick

每次 thread 进入 kernel mode (syscall),
 或 被 interrupt (timer / I/O completions → IPI),
scheduler_tick() 减当前 thread 的 time slice 余额;
当 残额 ≤ 0, set TIF_NEED_RESCHED flag.
中断返回用户态前点 schedule() → 选下一个 thread.

CFS 默认 time slice = sysctl_sched_latency / N, 一般 sched_latency=6 ms, N=runnable 数.

  • N=10: 每 thread ~600 μs 跑一片;
  • N=100: 每 thread ~60 μs, 但 min granularity floor (sched_min_granularity = 0.75 ms default) 强制 ≥ 0.75 ms.

保证每个 thread 至少跑够 min_granularity, 不被抢过快导致 cache trashing.

5. EEVDF: Linux 6.6+ 后的新模型

CFS 用 15 年后, Linux 6.6 引入 EEVDF (Earliest Eligible Virtual Deadline First) 替换 CFS.

核心:

  • vruntime 演化 = eligible lag = actual_cpu - weight_cpu. lag > 0 表示欠 CPU, lag < 0 表示多跑了; -eligible = lag ≥ 0 才可被调度;
  • 在 eligible 集合中用 deadline (按 nice weight 计算的虚拟 deadline) 排序.

EEVDF 解决的问题:

  • CFS 在 weight 不齐负载下偏 GAINS 高优先 thread, latency 不一致;
  • CFS 没显式 latency bound, 不能保 哈高优先低延迟个bit EEVDF;
  • EEVDF 在 ML inference 训练 (动态调整 nice)+ 高低优先混合好.

EEVDF 仍 vruntime-based, 仍 per-cpu rq 队列, 仍红黑树 — 工程实现 90% 类似 CFS. 替换只把挑选算法换成 deadline first + eligibility check.

6. 多核负载均衡

每核有自己的 rq, scheduler 必须 lazy 把 task 在核间移动:

周期性 load_balance():
  每隔 sched_period 一次 (e.g. 100 ms);
  比较各 CPU 的 load (sum weight);
  找最忙与最闲 CPU;
  把一定数 task 从忙 → 闲, 但考虑 cache locality + NUMA 复位.

numa_balancing 是 NUMA 自动迁移支持: echo 1 > /proc/sys/kernel/numa_balancing. 默认 on 在 NUMA 系统.

: 任务边界可能 cross-socket. 线程 A 在 socket 0, 任务切到 B 在 socket 1, B 找内存 在 socket 0 ⇒ 跨 NUMA. 解决: pthread_setaffinity_np (cpu affinity) 锁住.

7. 实测影响

(由于历史 CFS 上, 高低优先混) 现在主流 Linux 早就 5.x+ / 6.x, EEVDF 也是好, 但仍:

  • 32 核 机器跑 1000 低 nice 容器 + 100 通常次 nice 守护 = latencies maintained sched_normal 排队 > 6 ms / 守护.
  • gig niced web server thread = bad path = potential starvation.
  • nice 单值差异 工程上 5 次 ≈ ~2× 差距, 10 次 ≈ ~5×, 多次叠加几乎无法归还.

补丁 nice RR. fix -20 (nice 0) vs nice 0 (nice 0) 之间差 6× 可竞争抛入跑. 工程上 nice 不再使用很好. 建议 cgroup + cpu.shares / cpu.max 替代 nice.

8. 多语言 / 多运行时

语言user 调度kernel 看见什么
C pthreadkernel thread1:1 M:N kernel thread
Java Thread (1:1)kernel thread同上
Go goroutineruntime 自己 scheduler (GMP)M less than G, M 是 OS thread
Erlang processBEAM scheduler同 M:N
Rust tokio自己 schedulerruntime over M threads, async fn

kernel 只看 OS thread. 用户态协程是 runtime 中 scheduler 自 mange. 这就是"用户态调度器" 与"OS 调度器" 是同构dispatcher抽象 在不同本身 layer.

9. 这章带走的东西

  • vruntime + nice weight 让 CFS "更公平" 视觉对比 + nice 弱化主线;
  • 红黑树是 OS 内 kernel 里数据结构魔抗现实 (你们 feedback 1 thread 5000 个选呢);
  • EEVDF 6.6+ 替换 CFS, 同 vruntime + eligible lag + deadline first;
  • Linux 多核负载均衡 自然 + NUMA affinity 控制位置;
  • 高 nice 实际可调范围

下一节 → 实时调度

实时调度:SCHED_FIFO / RR / EDF

一句话

CFS / EEVDF 是 尽力而为 调度,没硬延迟保证. 工业 / 安全 / 媒体 / 控制系统有更严的要求:必须 K 个可调度任务每 100 μs 内 ack 完成一次, 必须把抖动盯死在硬指标上. 这就是 real-time scheduling 存在的意义. 这一章把 Linux 的 SCHED_FIFO / SCHED_RR / SCHED_DEADLINE 三个实时类拆开看,再讲业界控制领域使用的 EDF (Earliest Deadline First) — 实时抽象把它带回到硬件层 (看 ARM Cortex-R / 安全岛 automotive).

1. SCHED_FIFO / SCHED_RR:POSIX 实时类

Linux 把调度类按 priority 排叠:

[实时类 0-99] → 优先于 [CFS / EEVDF]

两个 rt 类:

  • SCHED_FIFO: FIFO 顺序, 跑到自愿让出;
  • SCHED_RR: 同 FIFO 但时间片 = 100 ms 后抢;

进入配 rt priority 也能跨核 migration, 但 FIFO 是 panic-level "不再被迫让位": 1 个 SCHED_FIFO 99 thread 跑 while(1) infinite ⇒ 其他线程全饿死. Linux 没默认 watchdog over FIFO thread.

2. SCHED_DEADLINE:Linux EDF policy 引入

Linux 3.14+ 引入 SCHED_DEADLINE(基于 EDF 算法). EDF (Earliest Deadline First):

每任务的三元组 (runtime, period, deadline):
  每周期 period 内, 须够 runtime μs CPU, 期限 deadline μs.

Example: 任务 A (10 ms runtime, 100 ms period, 50 ms deadline):

每 100 ms 开始: 任务 A 必须在 deadline 50 ms 内跑完 10 ms CPU.
EDF: 每次调度只要流内 runtime < deadline 的 task (内 runnable 中最早 dl 优先)

调度器在 admit 时用 utilization check:

Σ (runtime_i / period_i) ≤ min(1, total_cores)

单调 EDF 是最低可行算法 CPU utilization 上界 (Liu & Layland 1973). SCHED_DEADLINE 是 Linux 给硬实时打 的 framework.

3. PREEMPT_RT: kernel 自己 rt 化

Linux 默认不少 critical section (e.g. spinlock) 还不可抢占. PREEMPT_RT 是主支工程, kernel spinlock 换 raw_spinlock + rt_mutex + rcu_rrn 等让" kernel 自己 rt friendly". PREEMPT_RT 已经渐进 mainline 多年并在 Linux 6.12 后全部 upstream.

打开 PREEMPT_RT 后:

  • spinlock 改 rt mutex, 不能 thread 抢死; -IRQSOFT聋DDevice handler 变成 kernel thread (ksoftirqd);
  • 大多数关键 path preempt-safe ⇒ max latency ~ 100 μs (默认 10 ms+).

PREMPT_RT 用例: automotive (electric vehicle 中 BlueZ, ROS), industrial control, audio lowest latency.

4. Real-time 延迟常数

CFS (stock kernel): 50 μs -> SECENDUST script best-but-p99 ~ 10 ms;
PREEMPT_RT kernel: ~ 100 μs p99 ~ 200 μs 配 CPU isolation;
PREMPT_RT + CPU isolation (isolcpus + nohz_full): ~ 20 μs p99;
Xenomai / RTAI co-kernel: < 5 μs;
ARM Cortex-R / FPGA 双核 RT: < 1 μs.

工业 automotive sensor fusion / motor control 设置一般要 < 100 μs. Linux PREEMPT_RT 可达成. 1 μs 级要 FPGA co-processor / Cortex-R.

5. 时延抖动来源

1.irq 创建 periodic timer tick; 2. tabnentiated m -burst time latch (CPU isolation). 3. spinlock hold time; 4.smp guest VM hypervisor exit cost; 5.内存管理 hot path cache miss / swap-in; 6.GPUAdditionally resource scheduler.

工程通过将实时任务完全无中断跑

isolcpus=2-3      # 在 boot 让 CPU 2-3 不接 irq, 永不会被 scan balance
nohz_full=2-3     # 不定时 tick
rcu_nocbs=2-3     # RCU callback 不在这 CPU
tuned-adm profile realtime
preempt=full

process 上:

chrt -f 99 ./my_realtime_task
chrt -d -t 10000 -p 100000 ./sdl_task   # SCHED_DEADLINE 10 ms / 100 ms

CPU affinity:

cpu_set_t set;
CPU_ZERO(&set); CPU_SET(2, &set);
pthread_setaffinity_np(pthread_self(), sizeof(set), &set);

6. ARM Cortex-R / 调度器硬件最后形态

车规 / electrical 调速控电磁机 < 1 μs 延迟不能轮. ARM Cortex-R / RISC-V 上的 MT (motor timer) ISR Direct interrupt classification < 100 ns open + deliver to register to rt5 chip actuator. 这是 Linux PREEMPT_RT 击退卸交硬件 RT core.

代码模式:

// 裸金属 Cortex-R 上
__attribute__((interrupt("IRQ"))) void timer_handler() {
  // bare-metal handler
  ...
}

eBPF + 嵌入式 Linux 用 BLabs + 专用 RT core. Sisoft follow 上 hardware RT 类同构 = 抽象在硬件层就是"timer ISR 直接跑 with 跟 lane bandwidth".

7. 这章带走的东西

  • SCHED_FIFO/RR 是有危险可饿死, 须小心;
  • SCHED_DEADLINE 基于 EDF + utilization 健康 check;
  • PREEMPT_RT 让 Linux kernel RT, 自 Linux 6.12+ mainline;
  • 配 CPU isolation + nohz_full 时 接 < 100 μs;
  • 真硬 < 1 μs RT 只能 Cortex-R / FPGA co-processor / Xenomai.

下一节 → GMP 模型

goroutine GMP 模型

一句话

Go runtime 的核心创新不是语法 / 多线程抽象 / GC 而是它的调度器 GMP (Goroutine / Machine / Processor). 它把用户态 goroutine + OS thread + 逻辑 P 三层抽象叠在一起, 让用户写 10 万个 goroutine 仍能在 64 核机器上 O(N_top) 不退化协力. 内核不感知有 goroutine, 它只看到一个进程跑很少几条 OS 线程. 而这层抽象实现背后是同一个抽象一直在反复出现: thread local + work stealing + central 稀释 — DSA "lock-free work queue + ring buffer" 的工程化.

1. G / M / P 各自什么

  • G (Goroutine): 一个用户态 task. 大小仅 8 KB 栈可起步, 不绑死固定 OS thread.
  • P (Processor): 一个逻辑 "执行权". 全局 GOMAXPROCS 个 (默认 = ncpu). 每个 P 有自己的本地 runqueue 装 G. 实际上 P 是一个虚拟资源"票据".
  • M (Machine): 一个真 OS 线程. M 必须 attach 到某个 P 才能跑对应的 G.
                  ┌─ P0 local runq: [G1, G2, G3]\
                  ├─ P1 local runq: [G4, G5]     |  ┐
GOMAXPROCS=4  →   ├─ P2 local runq: [G6]         |  ├ M0..M_k = OS threads attached
                  └─ P3 local runq: [G7, G8, G9] ┘   
                  
                  global runq: (overflowed goroutines)
                  netpoller: (waiting-on-IO goroutines)

任何时刻, 多个 M 上各自的核上跑 G. 不需要 G 在核心之间迁移, 也少 lock 争.

2. work stealing: 关键性能技巧

P 的 local queue 空了想拿新 G ⇒ 它会:

  1. 尝试自己 P 的 local queue;
  2. work steal — 从别的 P 的 queue 里 steali 一半;
  3. 拿 global runqueue;
  4. 拿 netpoller ready 任务.

work stealing 的算法详 + Go runtime 自内调度器 = 论文 Anderson et al. 2010 之类的 work-stealing deques. 高级实现加 chase / pop / steal 各有 lock-free 实现 (e.g. treiber stack with bounded queue 加 bins).

实测 work stealing 关键收益: 在 GOMAXPROCS=Ncpu 下, G uniform 分配到 P 上, cross-P queue 大致 O(no lock) ⇒ N 个 P 一齐 100 个并发任务.

3. G 的状态机: 接管 syscall

一般 user code 在 goroutine 上跑:

G: runnable → running → syscall → blocked
   ↑                       ↓
   └───── 调度重新分配 ──────┘

"M1 正在跑 G1, G1 syscall 阻塞", runtime 会:

  1. 把 M1 我想 briefly detach (在 lock, FS syscall, ...)
  2. 当 P0 引 syscall block too long: 想办法抢 P0 给另一个 M
  3. 新 M2 接管 P0, 继续从 local runq 拿新 G 跑

实际效果: 你的 Go 服务在 Lua-like IO syscall 时 ( 1 ms, 10 ms ) 都不会因 syscall 阻塞丢 CPU 调度. 这是 KB 模式 (大约 = nginx work 共享 NGINX/GPU 由于 共享阻塞).

M0 + P0 + G1 — syscall 阻塞 → 此 M0 与 P0 的解绑
                                   ↓
P0 配新 M1 继续跑 G_x (本地 queue 中下个工)
M0 等 syscall 完成 ⇒ G1 重新 runnable 进 global runq or P0 local

4. network poller: epoll 直接接管

Go 5.x+ runtime 在 Linux socket IO 用 epoll + nonblock fd. 一个后台线程 (netpoller M) 长期 epoll_wait. 当 fd 可读就解析 fd-event 反查 G, 把它标 runnable.

G1 想 socket.Read(fd)
   ↓
syscall nonblock + epoll_ctl(ADD fd)
   ↓
runtime park G1 with 当G1变 runnable callback
   ↓
Go runtime netpoller M定期 epoll_wait → 当 fdk可读就叫 G1 校 runnable

epoll in _thread_run* is device same shape Ever 用 epoll + java 或 nginx: 的 same. Go runtime 只是把是用户 user reverse-detect G multiple G + open-ended M 配上 independent 其实是脱 roll abstraction.

5. 抛弃 blocked M 和 G 的 soft timer

一个 M 阻塞 syscall 太久 (e.g. > 10-20 ms) ⇒ retassined P 给别的 M. 这个 handoff 的特殊性是 handoff 不再杀 M — 让 M 继续 run syscall完成 G1 ⇒ 在 syscall tail G 想重attach 到任何 P.

但 What if Go runtime 完全用 OS sysc copy 或 block too long (比如 > 10 ms syscall)? SYS 要求 ca (sysmon) 起出现 ⇒ P handoff.

这个 abstraction 与 Linux smpaffinity 跨 node migration 同构: apps (及)kernel times prefer core local handoff 在考虑 cache locality.

6. 实测: CFS / 调度延迟 / output

在 default GOMAXPROCS=Ncpu:

  • 黄报 goroutine create 1 个 ~ 1 μs / 8 KB stack;
  • thread create 1 个 ~ 30 μs / 1-2 MB stack;
  • sysmon monitor syscall block + steal 抢 ~ 10-20 μs.

goroutine 比 thread 更轻 → 高并发场景可 trivial 高 günstig manager concurrency size.

7. 多语言对照

语言 runtime协程调度器配置
Rust tokiomulti-thread runtime + work steal#tokio::main(worker_threads=N) 默认 CPU 数
Java Project Loomvirtual thread + FFI schedulerExecutors.newVirtualThreadPerTaskExecutor()
Erlang BEAMpreempt scheduler, priorities也可以多个 per CPU
Python asyncio单线程 cooperative schedulerasyncio.run_until_complete()
Kotlin coroutines更 dispatcher 由 runtimesDispatchers.IO/Default

抽象叠出: goroutine scheduler is actually a user-level thread scheduler. 它 work stealing + per-P local queue + syscall 抢救 + epoll netpoller 是工程核心 learning.

8. 这一章带走的东西

  • G: 用户态 task; P: 执行票据; M: OS thread; 三层分离配上 caching/调度;
  • work stealing 拿到线性扩展 (DSA lock-free deque 实例);
  • syscall haoffee 阻塞 P 抢等 handoff 不仅 mask 与 opt code, 避 full 愚服务;
  • netpoller = epoll 实际 scheduling= async IO 用于附 internal;
  • 抽象同构造 (tokio, java virtual threads 也有同 abstract):

Cgroup v2、CPU 迁移与亲和

一句话

OS 调度物理硬共享 CPU, 但工程上我们需要在逻辑子单元(进程 / 容器 / 子系统)上"重量计 + 资源隔离". 这就是 cgroup 出现的理由. cgroup v2 整合解决了 v1 时代几十个独立 controller 的混乱, 现代容器调度 (k8s / docker) 都基于本. 你在 K8s yaml 写 resources.requests.cpu=2, 你实际启的是 cgroup v2 的 cpu.weight=8192(或 cpu.max=quota/period). 这章把 cgroup 物理 + 与 OS scheduler 同一抽象.

1. cgroup 是什么

cgroup = control group = Linux 提供把任意 进程组 绑定到资源限制层. 资源包括: cpu / memory / IO / network / pid / freezer 等.

配上 namespace (type-of 系统看到的隔离), 它们加起来叫容器.

namespace: 让 看到
cgroup:     让 用 / 减少

这俩 abstractly 说一下"same" 差在: namespace 改 client 对 kernel state 的视角, cgroup 改 scheduler 给你多少.

2. cgroup v1 vs v2

v1 (Linux 2.6.24-2017): 每控制器一棵独立树, 多控制器混用易出问题 (e.g. 在 cpu hierarchy 上挂 memory 子树不一致).

v2 (Linux 4.5+ unified hierarchy): 一棵树, 所有进程只在一个 cgroup, 单点 unified 一份. 控制器在 internal nodes 启用, 是 +/resume.

# v2 mount
mount -t cgroup2 none /sys/fs/cgroup

# 新建 cgroup
mkdir /sys/fs/cgroup/mygroup
echo $$ > /sys/fs/cgroup/mygroup/cgroup.procs
echo "max 100000 100000" > /sys/fs/cgroup/mygroup/cpu.max  # 100ms quota / 100ms period = 1 CPU

3. cpu.weight / cpu.max: 软限 vs 硬限

cpu.max:   "<quota> <period>" hard limit. quota=200000, period=100000 ⇒ 2 CPU.
cpu.weight: 1-10000, CFS shares 静配比例.    软限 (guarantees 最小份额).

工程如何选:

  • soft (weight): 容器间可以互相 借, 没用就拿所以. 适合 K8s request.
  • hard (max): 不能跑超, 量化 fixed 资源. 适合 K8s limit.

K8s requests.cpu 配 cpu.weight, limits.cpu 配 cpu.max. 这就是 GKE/EKS clone 在容器里 看到的 resource 分配.

4. Cpu 迁移 + cgroup cpu.max

把 task 从 cgroup A 迁 cgroup B:

echo pid > /sys/fs/cgroup/B/cgroup.procs

迁移本质是修改 task 的 cgroup->subsys[] 指针; CPU 资源调整在 task tick 时重算 vruntime / weight.

cgroup CPU bandwidth control:

每次 task_tick: 递减它的 quota;
quota 用完 ⇒ throttled, 放入 throttled-rb, 等下一 period 重新填充 quota;
被 unthrottled 时回到 rq.

实测:

  • container cpu.max = "1 1" (1 ms / 1 ms) ⇒ 1 个容器节流 → 99% utilization throttled点 single cpu 的 30% 拒绝. K8s + limit CPU 偶 small 容易 掉到 throttle = P99 worst.
  • 设 cpu.max 时尽量让 quota>=100ms 以避免 catch-effect.

5. memory cgroup + OOM

echo 1G > /sys/fs/cgroup/x/memory.max
cat /sys/fs/cgroup/x/memory.current   # 当前使用

cgroup 内存超 memory.max 时, 触发 cgroup OOM killer:

  • 选最大 memory 的 root task, kill;
  • 不会 panic, 不会杀其他 cgroup 任务.

URL + OOM 在 container = 排查 ⇒ 位 进程 kill + 应 heap chunked events to detect restartability.

6. io 字节级 controller

io.max:  "8:16 rbps=50000000 wbps=10000000 riops=200 wiops=200"
        # major:minor + bytes/sec + iops/sec
io.weight: 1-10000 类似 cpu.weight

cgroup v2 io controller 是 blk-mq 级; 通过 CFQ-替换 bfq-scheduler 控制 实队列.

注意: blk 级 IO controller 在 NVMe 上 (因NVMe 不上 CFQ / BFQ) 走 blk-iocost (Linux 5.0+ 配置 cost model). 配置较复杂, 工业大 cluster 一般最好对 IO 加 weight priority.

7. cgroup + Pid 隔离

echo 1000 > /sys/fs/cgroup/x/pids.max

PID 数量限制, 防止容器 fork-bomb. K8s Pod podPidsLimit 内部走 cgroup pids.max.

8. 多语言 + runtime 起容器

语言runtime 与 cgroup 是否协调经验
JavaG1 默认会看 cgroup 内存限制JDK 11+ ( atol after 11.0.+)
Go runtimeGOMAXPROCS 自 1.22 看 cgroup cpu.max修复 runtime 能 看到 quota 不 自己 be 默认 ncpu
Rust std不 let火箭 std 看 cgroup 限于 runtime必须 cgroup 静 allow
Python例如 多 默认不看因 PyPy 内 alloc 是紧 几不错的作为先夏
NodeV8 默认 max-oldSpace + size 4 GB不 默认 + cgroup 需 优

特别: GOMAXPROCS=go1.22+ 才读 cgroup; 早期 Go 看不到 cgroup cpu quota, 你 K8s limit 1 CPU + 但 GOMAXPROCS=64 ⇒ scheduler 充分运行但 throttle 严重, 资源利用率差.

修复 runtime "搞容器感度" 在 2022-2024 时代逐 Gengr成 be more cost friendly 是现代 OS + language 跨层读完.

9. K8s 的 cgroup 关系

Pod / Container  ⇒  cgroup
requests.cpu=1   ⇒  cgroup.cpu.weight=512
limits.cpu=2     ⇒  cgroup.cpu.max=200000/100000
limits.memory=2G ⇒  cgroup.memory.max=2147483648

Pod 的 cgroup 是从父 QoS 类派生: BestEffort / Burstable / Guaranteed, K8s 通过 G 父级 cgroup resources 决定是否 OOM (Guaranteed 优先) 防止 best-effort 抢 machine.

10. 这一章带走的东西

  • cgroup v2 unified ai 主控制器 同层级, 现代 container 行;
  • cpu.max 是 hardlimit硬 资源命. cpu.weight 软限;
  • K8s limit 当小 quota + small period ⇒ potential P99 throttle 避免;
  • 自适应工具 kubelet config + cpu + memory + io + 关注 cgroup-adapt runtime;
  • 设 / Standalone Go 经济; K8s recomms runtime GOMAXPROCS default min(ncpu, cpu.max / period).

下一节 → 锁与同步原语

锁与同步原语

一句话

锁是 OS 里最抽象、最细颗粒、最 kernel 硬件协同的层。每个语言 runtime 都会重新发明它一遍, 但内核里锁的实现细节涉及到 atomic 指令、cache 一致性协议、NUMA 拓扑、cqbarrier, 把锁一直拓到指令流水线和 DRAM 的 MESI 协议. 这章把 mutex 内部、RCU/seqlock、memory model、lock-free 数据结构拆开, 让你看到用 std::mutex lock() 后面真正发生了什么.

futex、CAS、spinlock 内部

一句话

pthread_mutex_lock 在 Linux 其实是一组复杂的工程实现: 大部分时间它本质是一个 cmpxchg (在用户态), 失败了再 syscall(futex). "fast path user-only + slow path syscall" 是 Linux 同步原语的核心设计. 这章把 mutex + futex + CAS + spinlock + MCS lock 按实现拆开, 让你看着 stat TOTP 你 exch 滞后之前你的黑盒 程序的 thread sync 是哪来Cost.

1. CAS 与原子指令

x86_64 give cmpxchg [addr], expected, new 原子比较交换位:

mov rax, expected
lock cmpxchg [addr], new
# ZF flag set if success

lock 前缀 → 多核 cache 一致性, 自动 invalidate 一致 cache line. 等价于 MESI 中 lock cache line.

工程上 CAS = OS 同步原语最底层的"KISS" 实现. 任何 lock-free 算法都要 CAS (or DCAS, or LL/SC).

但 CAS 慢:

  • atomic 失败 ⇒ pipeline 重试;
  • 总线 cache line invalidate latency-multi cycles.
  • 在高度 contended 时, 行级 platform bus 杠.

CAS 不一定最快. SGI 的 fetch_and_add/TAS 偶有优势.

2. spinlock: 最简单的锁

朴素:

while (!try_lock()) { _mm_pause(); }

try_lock:

bool spinlock_trylock(atomic *l) {
    int expected = 0;
    return atomic_compare_exchange_strong(l, &expected, 1);
}

问题:

  • 多线程同时 spin ⇒ cache line 撕皮 bus saturate;
  • priority inversion (低优先 thread hold lock, 高优先 thread spin, 中间 thread 不阻塞抢 空跑);
  • 多核 cache pess 严重 (= "thundering herd").

改进 1: TTAS (Test-and-Test-and-Set):

while (true) {
    while (lock == 1) { _mm_pause(); }   // 看到 1 时不抢
    if (try_lock()) break;                // 看到 0 时再尝试
}

改进 2: 退避:

while (!try_lock()) {
    _mm_pause();
    if (fail > N) _mm_pause(); _mm_pause(); 
    if (fail > K) sched_yield();
}

但仍 spin 风暴 + 浪费 CPU.

改进 3: MCS lock (pointer-per-thread 队列锁)

  • 每 thread wartenat 拿一个 qnode;
  • thread linked at the tail;
  • 持锁 thread 须解锁 把 next qnode flag-lit set;
  • 第一名 reach local latency 大致 O(1), no thundering.

在现代 NUMA CCIX sometimes 是 CNA (Catapult NUMA-Aware spinlock) 内 Linux 5.0+. 默认 Linux qspinlock 内 kernel 内部.

3. futex: fast user space mutex

futex(addr, FUTEX_WAIT, val) syscall:

  • 验证 *addr == val; 等 on internal wait queue;
  • *addr != val 那时不会 dead lock.
  • 全部 wa 在 kernel; CPU 释放.

futex(addr, FUTEX_WAKE, 1) syscall:

  • 唤醒 task in queue.

这俩是 Linux 2.6/fast 提供 synergy sync 原语. 基础行为:

lock:
  cmpxchg (user态, atomic);
  成功 → return;
  失败 → futex(WAIT, addr=1);
  
unlock:
  *addr = 0;
  futex(WAKE, 1)  # wake 1 waiter

Lock contended:

lock 1 个 thread:
  try fast lock.
  take fail, futex_wait(, val=0).
  kernel puts me on wait q.
  schedule 出.

unlock 0 个 thread 1 个 task = other:
  *addr = 0;
  futex_wake → kernel grabs wait queue, wakes one (or all);
  wake yields CPU → 调度器 schedule next.

库 eventually = briority bugs.

красивый 表现 fast path: 99% 时间 user -> atomic CAS, 不是 syscall. futex only for contended slow path.

4. pthread_mutex_lock = futex_CAS

glibc's nptl/pthread_mutex_lock 内部:

__pthread_mutex_lock(mutex) {
    int e = __pthread_mutex_trylock(mutex);
    if (e == 0) return 0;
    
    // Slow path — lock contended.
    if (mutex->__kind == PTHREAD_MUTEX_TIMED_NP) {
        // adaptive spin 几次
        for (int i = 0; i < 100; ++i) {
            if (trylock == 0) return 0;
            cpu_pause();
        }
        // 决定 → futex_wait
        futex(&mutex->__lock, FUTEX_WAIT, ...);
    }
    ...
}

in linuxtcadap by glibc rounds 一 adaptive spin 几次 + futex_wait, plus main How CLOCK BUSY contention the kernel 石 wash.

5. priority inversion: 经典 bug

thread H (高优先): WANTS lock L
    thread L (低优先): HOLDS L
    thread M (中优先): 抢 CPU
    L 想等 unlock_lock.
    M 跑不停, H 阻 kissing, L 不能运转 (CPU 抢没).
    实际长时间 H 等 = H 实际被 M 阻塞 ⇒ priority inversion.

修复:

  • priority inheritance (PI): 锁一棵 L.holder 被提升到 最高 waiter priority, 解锁才恢复;
  • priority ceiling: 锁优先 ceiling 提 preemptively.

Linux futex 支持 PI variant (FUTEX_LOCK_PI). RT-mutex 全部 PI. PREEMPT_RT 还要府接 generic libc mutex 是 RT mutex.

6. multi-language 同步对调

语言mutex 实现
Rust std::sync::Mutex通过 futex (Linux) / SRWLOCK (Win)
Go sync.Mutex半 spin + semawaitFor: Go runtime sema 实 ± futex
C++ std::mutexglibc / pthread_mutex_lock
Java synchronizedinternal monitor + futex on Linux HotSpot
Python threading.Lockpthread_mutex_lock 然后 GIL 每 access

实际异同: 5 默认 + 1 sports. 也都是 CAS fast path + syscall slow.

7. cost: 高 contended lock

high-contended lock cost stacked:

  • contention cache invalidate 总线;
  • futex_wait IPI + scheduler context switch;
  • 排队 + wake 实际某 thread 抢, 别的 thread 是 unthrottling effect -> 行业 ILP "thundering award".

测量 (lock : unlock pairs/sё multifens on contended lock):

1 thread:        24M/s
2 thread:        12M/s
4 thread:        6M/s
16 thread:       800k/s   # thundering herd + cache line
32 thread:       150k/s   # SUPER退

16 线程后 LO sincal in take riduos 应 a model 是 结果仄 down. 这就是为什么 high concurrent data structure 都是从 lock-based 距 走 lock-free 或 sharded.

8. 这章带走的东西

  • pthread_mutex = user CAS + cluch futex wait + CPI main doing 神 functional;
  • futex is Linux-only sync 整组 path: 平台性;
  • 自旋 + backoff 仅在极短持 lock 合理; long 持 lock 用 mutex;
  • PI mutex 修 priority inversion;
  • 高 contended lock 是多线程扩展 applicant 应 len shortcuts: 用 sharding / 锁分段 复 hard quality = DSA 实例里 sharded map.

下一节 → RCU、seqlock、brlock

RCU、seqlock、brlock

一句话

并不是所有读取都需要加锁——在读超多写特少的场景,用锁做读保护本身就把 cache line 撕成碎片、把总线刷爆。Linux 在 2002 年由 Paul McKenney 提出 RCU (Read-Copy-Update),把读端做得近乎零开销:读不拿锁,写复制新版本原子替换,旧版本等"宽限期"过去才回收。同 seqlock、brlock 这组原语背后的同一思想是:让读路径走快道,让写路径做 dity work。这组抽象跟 DSA persistent data structure 在抽象上同构——你写的时候永远是改"私人副本",读者要么看旧要么看新,不会看到中间态。

1. RCU: 一个不用读锁的同步原语

核心三件套:

  • 读不拿锁,只持 RCU read side critical section;
  • 写时 copy + modify + atomic pointer swap,旧结构仍被旧读者引用;
  • 旧结构等所有"曾进入 RCU 读端的读者都走出" 之后才被回收 → grace period
// reader side
rcu_read_lock();
node *p = rcu_dereference(shared_list);
while (p) { do_something(p); p = p->next; }
rcu_read_unlock();

// writer side
new = kmalloc(...);
old = rcu_dereference(shared_list);
*new = *old;
new->field = X;
rcu_assign_pointer(shared_list, new);   // 原子指针替换
synchronize_rcu();                       // 等 grace period
kfree(old);

rcu_dereference / rcu_assign_pointer 内部加了 memory barrier,确保读者看到的指针和数据都一致——下一节 memory barrier 会展开。

读端 "= 几条普通访 + 一个 rcu_read_lock (本质只是 preempt_disable)",没有原子、没有自旋、没有 cache invalidate — 这就是 RCU 读路径几乎免费的原因。Linux 内核 路由表、namespace 链表、perf event ring、文件系统 mount 元数据全靠 RCU,几万行内核代码主要就靠这一原语支撑读多写少场景。

切 grace period 的两个 API:

  • synchronize_rcu(): 阻塞调用者直到所有读者离开 → 同步慢,几 ms 级;
  • call_rcu(callback, ...): 异步排队,下个 grace period 后回调 → 异步 + 队列。

2. seqlock: 无锁 + read retry

适用:单写多读 + 不想 copy

seqcount_t seq = SEQCNT_ZERO(seq);

// writer side (writer 须互斥, e.g. 用 mutex)
write_seqcount_begin(&seq);
shared.value = X;     // 修改 in-place
write_seqcount_end(&seq);

// reader side (无锁)
unsigned s;
do {
    s = read_seqcount_begin(&seq);
    v = shared.value;        // possibly torn read
} while (read_seqcount_retry(&seq, s));

seq 是个 unsigned int:

  • writer 起手时 seq++ (变奇数),结束 seq++ (变偶数)。
  • reader 看到偶数 = 无人写 → 复制 → 完后 seq 仍开始看到的偶数 = 数据有效;
  • 若 reader 在读到一半 seq 被 writer 撞了 (奇数或不同偶数) ⇒ retry。

代价: _writer 之间要互斥(不是无锁在内), RCU 是 read free + write expensive, seqlock 是 read retry free + write must be exclusive.

Linux 用 seqlock 的地方:

  • jiffies 全局时钟;
  • /proc 文件 stats 数字;
  • 设备级 stat counters.

3. brlock: 大读者锁

brlock = Big Reader Lock。90 年代后期 Linux 上的某阶段用过:每 CPU 维护一个 reader 版本,reader 只动自己 CPU 的本地版本(cache line 私有),writer 必须 invalidate 所有 CPU 的本地版本。

read_lock():
    this_cpu_inc(lr->cnt);     // 只动本地 cache line
read_unlock():
    this_cpu_dec(lr->cnt);
write_lock():
    for_each_cpu(c): spin until lr[c].cnt == 0  // 写者极慢
write_unlock():
    (no-op)

读路径 = 单 CPU 本地 increment,零 cache invalidate —— 比 rwlock 读路径快一个量级。写路径必须遍历每 CPU 协调,写一次慢到几十 μs。

brlock 早被 RCU 取代:RCU 写路径也是 expensive,但读路径真的"不持锁" 而且 O(1) 字段访问。这就是为什么 RCU 是现代同步原语里 "大读者" 的真正替代品。

4. 用户态 RCU (liburcu)

liburcu 把 RCU 引到用户态。原理类似但 grace period 判定要靠 per-thread 的"准 simstate" mailbox;典型用法:

rcu_register_thread();
rcu_read_lock();
struct foo *p = rcu_dereference(glob);
use(p->x);
rcu_read_unlock();

// writer
new = malloc(); *new = *glob; new->x = X;
rcu_set_pointer(glob, new);
synchronize_rcu();
free(glob);   // 真安全, 所有 readers 已离开

用户态 RCU 因为 grace period 检测要"thread 自报",几乎只适合 thread 模型稳定的程序(不动态创建/销毁线程)。适合 long-running 高性能 server,不适合 hobby project。

5. RCU 与持久化数据结构(DSA 同构)

DSA 树那一段讲过 pure functional / persistent tree:每次"修改"返回新版的根、旧版用旧 child 共享不变。这种"写时构建新副本 + 读者可读旧版"的模型与 RCU 同构

  • 读者只知道一个引用根,访问何版本由读取瞬间决定;
  • 多版本可同时存在;
  • 旧版本回收靠"无引用后才能 free"(持久化结构里靠 GC;RCU 里靠 grace period)。

Haskell / Clojure 的 persistent data structure 是 RCU 抽象的 GC 化变体:用 GC 替代 grace period,用 immutable 替代 atomic pointer swap。但是抽象骨架完全一致。

类似同构在 git 也存在:每次 commit 是 immutable snapshot,branch 指针在 commit 间移动,老 snapshot 在 reflog / remote 仍存。

6. RCU 与 GC 的关系

抽象读者保护旧版本回收
RCUrcu_read_lock/unlock + preempt_disablesynchronize_rcu / call_rcu
Java GC (concurrent mark + sweep)持 ref 即可concurrent trace of live set
Rust Arc / Weak持 Arc 即可last Arc drop
Haskell STM持 TVars 引用GC 根扫描

四种实现都是"读路径轻、回收靠专门阶段"。GC 是隐式 RCU,RCU 是显式 GC。这一组同构让你看 Java/Go GC 设计与 RCU 设计时不慌——它们都在解决 "读不阻塞 + 回收安全" 这同一问题,物化形式不同。

7. 这章带走的东西

  • RCU 读路径几乎零开销,靠 copy + atomic pointer swap 实现写;
  • grace period 是 RCU 的核心安全网(类似 GC root scan);
  • seqlock 是单写多读 + 不想 copy 时的极简方案;
  • brlock 已被 RCU 取代;
  • liburcu 把 RCU 引到用户态(适合 stable thread 模型);
  • RCU / persistent data structure / Git 是 "写副本 + 读多版本" 这一抽象在不同物化层的同构。

下一节 → 内存模型与 memory barrier

内存模型与 memory barrier

一句话

你以为下面这段代码"不可重排" —— 它真的可被重排

a = 1;
b = 2;

CPU、编译器、SSD 控制器都有权把它们写成 b = 2; a = 1;,只要在单线程视角下看起来一致。这就是语言内存模型:"单线程可观察的行为"。要约束它,必须插 memory barrier。这一章把 x86 弱/强内存模型、ARM 弱内存模型、C11 atomic order、Java volatile、Go sync/atomic 的语义都对齐到同一抽象,让你不再把 memory barrier 当玄学。

1. 三种 memory 重排来源

源码:
    a = 1; b = 2;
编译器:
   - register allocation: 让 a 走 stack 不优先;
   - 优化重组: 看上去单线程行为相同的指令可以乱序.
CPU:
   - store buffer: 写发出 CPU 顺序不一定 = 实际到 cache 顺序;
   - invalidate queue: CPU 收到的 invalidate 可能 wait 一个忙指令才被处理;
cache/NUMA 互连:
   - 多 socket 的 cache 一致性 message 顺序不可控.

每跨一层都可能是"reordering source"。所以多线程下 instruction order 在所有 CPU 上的全局序是不可观察的、不可假设的、不可依赖的。除非用 memory barrier。

2. 经典反例: Peterson's lock 失效

flag[0] = true;  turn = 1;
while (flag[1] && turn == 1) ;
// critical section
flag[0] = false;
flag[1] = true;  turn = 0;
while (flag[0] && turn == 0) ;
// critical section
flag[1] = false;

单线程看对;多核 CPU 没有逻辑屏障的话,因为 flag[0]=true; turn=1 可能被重排到 turn=1 先 → turn=0 写入了;两个都看到对方 flag=true 但 turn 是自己 → 双双进 critical section,peterson 失败。

修复:flag/turn 的写必须配 store-store barrier,turn 的读必须配 load-load barrier。

3. x86 内存模型: TSO

TSO = Total Store Order. x86 相对不算太弱:

  • Load-Load 不重排 (但 load 可看到 older store);
  • Load-Store 不重排;
  • Store-Store 不重排 (但是发出 store buffer 可能延迟);
  • Store-Load 可重排 ⇒ 这就是 x86 唯一需要的 barrier。

mfence 指令强制 store-load barrier。lock cmpxchg 等原子指令本身也强 带 fence 等价的语义。所以 x86 上写 lock-free 算法比 ARM 友好得多。

x86 弱点例子:

a = 1;     // store
r = b;     // load
// 在 x86 上, 可能 r 看到 b 的旧值,但 a 的 store 也可能在 r 完成后才发 cache。
// 因为 store buffer。

4. ARM 内存模型: weak / RMO

ARM 是弱内存模型——所有 4 种 reorder 都被允许

  • Load-Load;
  • Load-Store;
  • Store-Store;
  • Store-Load。

ARM 处理器为了一致性,需要显式 dmb sy / dmb ish / dmb nsh (DSB/DMB/ISB) 等屏障。这就是为什么 ARM code 的 lock-free 代码充斥 barrier, 而 x86 几乎不需要.

弱内存模型给硬件设计师更多自由(指令级并行 + cache pipeline 更深),但给软件工程师更难调 - C++ std::atomic 在 ARM 上比 x86 多了几条 dmb 指令。

5. C11 memory order

C11 atomic 提供一组细粒度语义:

memory_order_relaxed    不要求 ordering, 只有 atomic
memory_order_consume    像 acquire 但 dependency chain only (实用不被建议, 一般 compiler downgrades 到 relaxed)
memory_order_acquire    前续 read/write 必须在 load 完后
memory_order_release    后续 read/write 必须在 store 完前
memory_order_acq_rel    acquire + release (compare_exchange 同时)
memory_order_seq_cst    顺序一致, 默认最强

常见用法:

// 单生产者单消费者
atomic_store_explicit(&ready, 1, memory_order_release);
while (!atomic_load_explicit(&ready, memory_order_acquire)) ;
// acquire 之后看到的旧 store 都已可见

x86 上 release = 普通 store;acquire = 普通 load 加上 compiler barrier (因为 TSO 不重排);ARM 上 release = dmb ish st / acquire = dmb ish ld

6. Java volatile / Go sync/atomic

Java volatile 等价 seq_cst:写后读可见且跨线程同步顺序一致。JMM 用 happens-before 关系建模。

volatile boolean ready = false;
// thread 1: a = 1; ready = true;
// thread 2: while (!ready); int r = a;  // r 必为 1

Go sync/atomic 提供 C11 风格 API:

atomic.StoreInt64(&ready, 1)  // seq_cst 默认
atomic.LoadInt64(&ready)

Go 内存模型用 happens-before 关系,channel send / sync.Mutex.Unlock / atomic 都建立 happens-before 边。

7. 实战模式: 单生产者单消费者 SPSC queue

SPSC 是 lock-free 最经典模式:

struct SPSC {
    atomic<size_t> head;  // producer writes
    atomic<size_t> tail;  // consumer writes
    T buf[N];
};

void push(T x) {
    size_t h = head.load(memory_order_relaxed);
    size_t t = tail.load(memory_order_acquire);    // 同步 consumer 已消费
    if (h - t == N) return;                         // full
    buf[h % N] = x;
    head.store(h + 1, memory_order_release);        // 同步 consumer 看见 data
}

T pop() {
    size_t t = tail.load(memory_order_relaxed);
    size_t h = head.load(memory_order_acquire);    // 同步 producer 已生产
    if (t == h) return {};                          // empty
    T x = buf[t % N];
    tail.store(t + 1, memory_order_release);        // 同步 producer 看见 data
    return x;
}

acquire / release 是"消息传递"的关键:

  • producer 用 release store: "我已写好数据 + 你能读";
  • consumer 用 acquire load: 我读完你的 head 才能读 buf 中数据.

否则: consumer 看到 head 推进了, 但 buf[i] 仍是旧数据 — 经典 bug.

8. ARM 上的实测代价

// ARM Cortex-A76 @2.4 GHz
 acquire load:~ 3 ns (dmb-ish ld)
 release store:~ 5 ns (dmb-ish st)
 seq_cst store + load:~ 10 ns
 没 barrier 包: 1 ns 提议

x86 类似 ~ 1-2 ns. ARM 弱内存模型 + cache 让 atomic 操作比 x86 配置更深.

9. 调试重排 bug

memory reorder bug 特征:

  • 偶尔出现, 不稳定; -v 单线程 debug 不复现;
  • valgrind / helgrind 不一定能抓 (但 tsan 抓 more then);
  • 改成 _acq/order_seq_cst 后 OK ⇒ 大概率是 reorder.

Google ThreadSanitizer 处理这个, 它 modeling happens-before 关系并报 race. 谛走 lock-free 必备 tsan.

10. 多语言同一操作

语言acquire / release 表达
C/C++11atomic_load/store_explicit
RustOrdering::Acquire / Release / SeqCst
Goatomic.Load/Store + sync
Javavolatile, final, synchronized
Python(单线程, 无意义)

抽象一致: 同一组 happens-before 概念在不同语言里换名字重物化.

11. 这章带走的东西

  • 内存重排来源: 编译器优化 + CPU store buffer + invalidate queue;
  • x86 TSO 仅允许 store-load 重排, ARM 全部允许;
  • C11 memory_order_acquire/release 是 SPSC "消息传递"基本;
  • ARM dmb 加 2-5 ns, x86 1-2 ns 默认 seq_cst 默认;
  • tsan 必须: lock-free + 缺 barrier bug 几乎肯定测不到的 silent.

下一节 → lock-free/wait-free 数据结构

lock-free / wait-free 数据结构

一句话

"lock-free" 这词被用滥了。很多人把它当成"无锁 = 快=好"的玄学。实际上:

  • lock-free:保证至少有一个线程在做进展 (system-wide progress);
  • wait-free:保证每个线程在有限步内完成 (per-thread progress);
  • 朴素互斥既非 lock-free 也非 wait-free;

工程上做 lock-free 的代价主要不是数据结构本身, 而是 memory reclamation (ABA / Hazard Pointer / epoch reclamation). 这一章把 Treiber Stack / Michael-Scott Queue / Hash Map 这些经典骨架拆到一次看完, 让你动手写不踩坑.

1. 朴素无锁栈 (Treiber Stack)

struct Node { Node *next; T value; };
atomic<Node*> top;

void push(T x) {
    Node *n = new Node{x, top.load(memory_order_relaxed)};
    while (!top.compare_exchange_weak(n->next, n,
                                       memory_order_release,
                                       memory_order_relaxed)) ;
    // CAS 失败: 重试 (n->next 被 top 新值刷新)
}

T pop() {
    Node *top_old;
    top_old = top.load(memory_order_acquire);
    while (top_old && !top.compare_exchange_weak(top_old, top_old->next,
                                                  memory_order_acquire,
                                                  memory_order_acquire)) ;
    if (!top_old) return {};
    T val = top_old->value;
    delete top_old;  // 这里有 ABA 隐患
    return val;
}

Lock-free: 任何一次成功 push/pop (system-wide progress). 但 ABA 风暴在 pop 中出现.

ABA: thread A pop 后立刻 delete N0, 又有 malloc reuse same address 给 N1 同址. 此时 thread B 还停在 CAS: 看到 top=N0 没变化, 跑 CAS top=N0→next=N0.next, CAS 成功 — 但 N0 已被回收 → 数据结构空链 break.

2. ABA 的几个修复

  • Tagged pointer: ptr + counter 16 字节, 16 字节 CAS. ARMv8 / x86 (cmpxchg16b)支持. Python / Rust 的 crossbeam_epoch 都支持;
  • Hazard pointer: per-thread 一组"正在用"指针, 在回收前检查所有 hazard 槽, 没人持有才回收;
  • Epoch-based reclamation: 每 thread 在 epoch, 全 thread 离 epoch N ⇒ 回收 epoch N 之前垃圾. crossbeam / Java / Linux RCU 都类 epoch 模型.

工程上 epoch-based 最快且代码量可控. Hazard pointer 简单但慢 (每 access 检查 hazard). Tagged 用 CAS-double 内存对齐要求严.

3. 无锁队列 (Michael-Scott Queue)

struct Q {
    atomic<Node*> head;
    atomic<Node*> tail;
};

void enqueue(T x) {
    Node *n = new Node{x, nullptr};
    Node *t;
    while (true) {
        t = tail.load(memory_order_acquire);
        Node *next = t->next.load(memory_order_acquire);
        if (t == tail.load(memory_order_relaxed)) {      // tail 没变
            if (next == nullptr) {
                if (t->next.compare_exchange_weak(next, n, release, relaxed)) {
                    // 关键 step 1 完成
                    break;
                }
            } else {
                // 别人 advance tail 一半, 帮 advance
                tail.compare_exchange_weak(t, next, relaxed, relaxed);
            }
        }
    }
    // 关键 step 2: 把 tail advance 到新 node
    tail.compare_exchange_strong(t, n, release, relaxed);
}

Michael-Scott Q 是经典无锁队列, 实现要点:

  • 两个 step 不是原子: tail 不一定立即 advance, 别的 thread 可帮忙 advance (这就是 "helping" 模式);
  • helping 对 lock-free 算法非常关键 (保证 progress).

4. 无锁 hash map (e.g. Folly AtomicHashMap)

开放地址 + atomic CAS:

find bucket by hash;
linear probe: 
   if slot empty → CAS empty → new entry;
   if slot key matches → return;
   if marked tombstone → continue;
直到找到空 slot insertion / 找到 key.
扩容呢?
  这就是难处:
    扩容需要新 array, 但同时旧 reader 仍在用;
    解决: 用两个 hashmaps同 时存在 (转 move 期间), reference count 决定 free 旧的;

实际 lock-free hash map 远没有 lock-free stack / queue 那么干净. Folly AtomicHashMap 只支持单写多读, 不真完全 concurrent. Java ConcurrentHashMap v8 是 segment 锁 + CAS, 不算严格 lock-free.

5. lock-free 数据结构 benchmark 实测

Lockfree 不一定比 mutex 快:

1 thread stack mutex / Treiber Stack  20M ops/s, 两者一样
2 thread contended mutex   3M ops/s,  Treiber 4M ops/s
8 thread contended mutex   500k ops/s Treiber 1.5M ops/s
32 thread contended mutex  50k ops/s  Treiber 400k ops/s

lockfree 在高 contended 略好(2-3×), 但 epoch reclamation 加入后实际只快 1.5-2×. 并发到极端时 (64 thread+) epoch reclamation 也变成为 bottleneck; 实际 CSP (e.g. Go channel) 并不一定输;

6. wait-free 是不是必须

wait-free: 严格 stronger. 任何 thread 在限步内完成 → 一般上在生产代码中几乎从不实现, 因为常数远大于 lock-free, 在 cache-friendly 算法里反而更慢.

实际工程: "lock-free + benchmark 比 wait-free 多数 5-10× 快, 实际业务上没人强求 wait-free". Linux kernel 与标准库都 lock-free 不 wait-free.

7. 性能杀手: ABA 中的 memory reclamation cost

实际 lock-free 工程成本句号是内存回收:

  • hazard pointer 每访问需要 atomic store + thread-shared visibility; 对 hot path 加 5-15ns;
  • epoch reclamation 每隔 ~200us 才回收; 长时间 running 后退推进 epoch, 需要所有 thread 上报 → borderline throughput spiral;
  • crossbeam epoch V8 弱内容 fast but requires Rust async 不来 adwait.

8.这章带走的东西

  • lock-free = 至少 one thread 进展; wait-free = 每 thread 限步进展;
  • ABA 是 lock-free 第一个 wall: tagged pointer / hazard pointer / epoch reclamation 三路修;
  • Michael-Scott Queue 的 helping pattern;
  • lock-free 不一定比 mutex 快: 高 contended 多核下 1.5-2×, 低 contended 反而慢;
  • 工程上 lock-free 复杂度集中在 memory reclamation, 不在数据结构本身.

下一节 → IO 与网络栈

虚拟化与容器: VM / KVM / namespaces / gVisor

TL;DR

"一台物理机跑多个隔离的 OS"和"一个内核跑多个隔离的应用"是两个不同的问题。虚拟机(VM)用 hypervisor 虚拟出完整硬件(CPU/内存/设备),客户机里跑独立内核;容器不虚拟硬件,只复用宿主机内核 + 用 namespaces 隔离视图 + cgroups 限制资源。这一章把 KVM 的 CPU/内存虚拟化原理(VT-x、EPT)、设备虚拟化(virtio、SR-IOV)、容器的隔离边界(为什么 namespaces 挡不住内核漏洞)、以及 gVisor/Kata/Firecracker 这些"安全容器"的取舍讲透。

读完应能:

  1. 说清 trap-and-emulate 为什么行不通,VT-x/AMD-V 的 VMX root/non-root 模式怎么解决。
  2. 解释 EPT(二级页表)让"虚拟机内存"不经过软件影子页表的原理,以及为什么 EPT 下 MMIO 要特殊处理。
  3. 说出容器隔离的是"视图"不是"资源边界":namespaces 管什么、cgroups 管什么、共享内核的逃逸面在哪。
  4. 对比 gVisor(用户态内核)vs Kata/Firecracker(微型 VM)vs 普通容器在不同威胁模型下的取舍。
  5. 在 K8s 场景说出"什么时候必须上 VM 级隔离(多租户/不可信负载),什么时候容器够了(可信负载)"。

一、虚拟化要解决的三个问题

  1. 隔离:故障/攻击不能跨租户传播;
  2. 复用:物理资源(CPU 核、内存、带宽)在多个租户间弹性分配;
  3. 迁移:工作负载与硬件解耦,可热迁移。

三条路线的隔离强度递减、密度递增:

隔离强度密度/开销典型
裸机最强最低物理机独享
虚拟机强(独立内核)中(每 VM 一个内核)云主机、Kata/Firecracker
容器弱(共享内核)微服务、Serverless

二、CPU 虚拟化: 从 trap-and-emulate 到硬件辅助

2.1 朴素思路为什么不行

让客户 OS 直接跑在 CPU 上,遇到特权指令时陷入 hypervisor(trap-and-emulate)。问题:x86 有约 17 条敏感但不特权的指令(如 POPFSGDT),在低特权级下不触发陷阱、行为却和裸机不同——客户 OS 会静默出错。这是 x86 虚拟化的历史难题,直到 2005-2006 年 Intel VT-x / AMD-V 在硬件上解决。

2.2 VT-x: 两种模式

VMX root (hypervisor 跑这里)     VMX non-root (客户 OS 跑这里)
        ▲                                    │
        │ VM exit (特权操作/中断/异常)        │
        └────────────────────────────────────┘
        VM entry (resume)
  • 客户 OS 运行在 non-root 模式,特权操作(CPUIDWRMSRHLT、访问 CR3 等)自动 VM exit 回 hypervisor;
  • VMCS(VM Control Structure) 描述每次 exit 的原因和 guest 状态,exit 后 hypervisor 处理完再 VM entry;
  • 频繁 exit 很贵(几千 cycles),所以 KVM 会让大部分中断直接注入客户机,只有必要事件才 exit。

2.3 中断虚拟化

  • 物理中断由宿主内核收,KVM 把它注入 guest(vmcs 的 event injection);
  • APICv / posted interrupts:物理中断直接投递到正在跑 guest 的 vCPU,不 exit——这是高 IO 虚拟机的关键优化。

三、内存虚拟化: 影子页表 → EPT

客户机认为自己在管物理内存,但那是"客户物理地址(GPA)",还要再映射到宿主的"机器物理地址(HPA)"。早期做法是影子页表:hypervisor 维护一份"客户虚拟地址 → 宿主物理地址"的页表,guest 每次改 CR3/页表都要 exit 同步——开销大、实现复杂。

硬件辅助(EPT,AMD 叫 NPT):

客户虚拟地址 GVA ──guest 页表──► 客户物理地址 GPA
                                    └─EPT 页表──► 宿主物理地址 HPA
  • 客户机照常管理自己的页表,不需要 exit;CPU 硬件自动走两级页表翻译;
  • 代价是 TLB 需要同时缓存两级翻译(EPT 使 TLB miss 变贵),所以有 ept + VPID(虚拟处理器 ID,避免切换 vCPU 时刷 TLB)。

warning

EPT 不是免费的:两级翻译的 TLB miss 开销比裸机高。大数据集虚拟机(如 Redis 类大页内存)要配合 HugeTLB/hugepages,否则页表遍历会吃掉可观的 CPU。

四、设备虚拟化: 全虚拟 → virtio → SR-IOV

方案原理性能使用
软件模拟(QEMU 模拟网卡/磁盘)每个 IO 都 exit + 模拟寄存器兼容性兜底
virtio前端驱动 + 共享环形队列,hypervisor 批量处理描述符中高云主机默认
SR-IOV(VF 直通)物理网卡切成多个虚拟功能,guest 直接 DMA最高高性能网络/存储
VFIO/直通(GPU/NVMe)整个物理设备给一个 guest最高GPU 透传、NFV

virtio 的关键是减少 exit:guest 驱动把一批 IO 描述符放进共享内存的 virtqueue,hypervisor(vhost)批量消费,一次 kick 处理一批。vhost 把 virtqueue 处理搬进宿主内核,进一步避免每次退出用户态。

五、容器: 不是虚拟化,是"切视图"

容器共享宿主内核,靠两套内核机制:

5.1 namespaces: 隔离"我看到什么"

namespace隔离内容安全含义
PID进程号(容器内 PID 1 = 容器主进程)看不到其他容器的进程
Mount挂载点视图配合 chroot/pivot_root 限制文件系统
Network网卡/IP/路由/防火墙每个容器独立网络栈
UTShostname
IPCSysV/Posix 消息队列
UserUID/GID 映射(root 在容器内 ≠ 宿主 root)非特权容器关键
Cgroup资源视图(/sys/fs/cgroup 只读呈现自己)

5.2 cgroups: 限制"我能用多少"

v2 的控制器:

cpu.max     = 配额 (比如 2 核 / 4 核共享)
memory.max  = 内存上限 (超限 OOM-kill 容器内进程)
io.max      = 磁盘带宽/IOPS
pids.max    = 进程数

5.3 隔离 ≠ 安全边界

namespaces/cgroups 是内核对象视图的分隔,不是内核自身的安全边界:

  • 容器内 unshare/mount 操作仍受宿主内核控制,内核漏洞(如 Dirty Pipe、OverlayFS 提权)可跨容器
  • 共享宿主内核 = 共享崩溃域:宿主 OOM 可能波及所有容器;
  • 特权容器(--privileged、挂载 /proc、宿主机 PID namespace)等于放弃隔离。

warning

容器默认的 root 在 User namespace 未启用时就是宿主 UID 0(只是视野受限)。多租户场景绝不能用普通 Docker 容器承载不可信代码;单租户/可信负载(自己的微服务)容器隔离足够,这是云厂商与自用 K8s 的分水岭。

六、安全容器: 把 VM 的隔离拿回来

方案原理代价场景
gVisor(Google)在用户态实现一个"应用内核"(Sentry),系统调用被拦截翻译兼容性差(部分 syscall 不支持)、性能损耗不可信但偏 CPU 的代码
Kata Containers每个容器 = 一个微型 VM(QEMU/cloud-hypervisor + 轻量内核)VM 开销、启动慢多租户强隔离
Firecracker(AWS)极简微 VM,砍掉 BIOS/ACPI,内存 5MB 起设备支持少Lambda/Fargate 的沙箱底座

选型逻辑:

可信负载(自研微服务)        → 普通容器 (namespaces+cgroups)
半可信(第三方二进制/插件)    → gVisor 或 Kata
不可信(多租户代码/公共函数)  → Firecracker/Kata + 严格网络策略

七、云上工程实践

  • KVM + QEMU 是 Linux 虚拟化事实标准;libvirt/OpenStack 之上,云厂商再套一层 SR-IOV/DPDK 网络;
  • K8s 运行时三层:runc(普通容器)→ containerd-shim(Kata)→ Firecracker(Fargate 类);
  • 热迁移的前提是共享存储 + CPU 特性掩码一致(否则迁移后 CPU 指令集漂移崩溃);
  • 超卖纪律:memory 超卖会触发宿主 swap/OOM 抖动,CPU 超卖要配 cgroup 配额而非放任抢;
  • 排查顺序:guest 慢先看 steal time/proc/stat 的 st),宿主 CPU 争抢直接体现在 st 上——这是"云上延迟毛刺"的第一现场。

八、一页速查

VM:     独立内核, hypervisor 虚拟 CPU(VMCS)/内存(EPT)/设备(virtio,SR-IOV)
KVM:    内核态 hypervisor, 客户 OS = non-root 模式, 中断注入/APICv
容器:   namespaces(视图) + cgroups(资源), 共享内核 → 不是安全边界
逃逸面: 内核漏洞 / 特权容器 / 共享崩溃域
安全容器: gVisor(用户态内核) / Kata(微型VM) / Firecracker(微VM)
运维:    steal time 查宿主争抢 / 大页配 EPT / 热迁移要特征掩码一致

下一篇: 锁与同步原语

IO 与网络栈

一句话

Linux 的 IO 模型花了 25 年演进: 从 read/write → select → poll → epoll → io_uring. 每一步都是把"用户态 syscall 数量"降到 O(1) 的工程优化. 网络栈这边从 100 MB/s 的传统 TCP/IP 内核栈走到 100 GB/s 的 kernel bypass (DPDK / XDP / io_uring ZC). 这章把 epoll / io_uring / zero-copy / NAPI / XDP / DPDK 五个抽象叠成同一条工程演进线.

epoll / kqueue / io_uring 对比

一句话

select 是 1983 BSD 引入的事件多路复用;poll/epoll 改进由 BSD/SysVR4/Linux 接续推;kqueue 是 FreeBSD 2000 风格. 它们都在解决同一个问题: 用一个 syscall 同时等 N 个 fd. io_uring (2019) 把"IO 也 encompass 在内部" —— 不仅多路复用, 还能把 read/write 排队到内核 ring buffer 内异步执行 + SQPOLL 模式让 syscall entry 变 0. 这章把四个 syscall 接口演进放到一条线程里, 让你看清 "从 select → io_uring" 故事推到 thread event loop 的物理设计.

1. select / poll: 基础 O(N) 扫描

fd_set set;
FD_ZERO(&set);
FD_SET(fd1, &set);
FD_SET(fd2, &set);
int n = select(maxfd, &set, NULL, NULL, &tv);

问题:

  • FD_SETSIZE default 1024, 不支持 high fd;
  • 每次调 都要 round-trip 把 set 的 1KB 从 user 拷到 kernel;
  • 复用内部用 O(N) 循环扫 fd: N=10 万 fd ⇒ 几 ms scan time.

poll 差别只在没 FD_SETSIZE 限制, 但仍 O(N) 扫描.

2. epoll: Linux O(1) 等待

epoll 把状态 long-lived 在 kernel 内部. 用户 register 感兴趣的 fd 永久:

int epfd = epoll_create1(0);
struct epoll_event ev = {EPOLLIN, {.fd=fd1}};
epoll_ctl(epfd, EPOLL_CTL_ADD, fd1, &ev);

struct epoll_event out[64];
int n = epoll_wait(epfd, out, 64, -1);

epoll 内部: 红黑树 + 就绪链表 pair:

  • ep_insert: 把 fd 加到 epoll 内部 rb tree;
  • fd 真可读时 kernel push event 到 ready list;
  • epoll_wait 从 ready list pop 出来给用户.

每次 epoll_wait 的复杂度只看 active fd 数 ⇒ fd 数从 1k 到 100 万, wait latency 不变.

3. epoll 边沿 (ET) vs 水平 (LT)

LT level trigger: 只要 fd 状态可读, epoll_wait 不停返回该 fd.
ET edge trigger: fd "可读出" 后只返 1 次. 你必须 read 直到 EAGAIN.

ET 是 nginx 默认 — 你必须配合 O_NONBLOCK + read 直到 error EAGAIN. LT 是 Redis (libevent 兼容) 默认.

ET 在 100k socket 上 P99 比 LT 减 30%.

4. kqueue: FreeBSD/macOS 同构

int kq = kqueue();
struct kevent ev;
EV_SET(&ev, fd, EVFILT_READ, EV_ADD|EV_CLEAR, 0, 0, NULL);
kevent(kq, &ev, 1, NULL, 0, NULL);

struct kevent out[64];
int n = kevent(kq, NULL, 0, out, 64, NULL);

特色: 可以 filter 不只 fd, 还 timer / signal / process exit / aio / user signal. ET 模式用 EV_CLEAR.

Linux epoll 算 subset 的 kqueue 功能度: kqueue 多 filter, epoll 但是更稳定的 kernel libc support.

5. io_uring 引入: 一个接口 multiple 用

设计:

  • 两个 shared ring buffer mmap 在 user / kernel 共享;
  • SQ ring 用户 push entries 入队;
  • CQ ring kernel push 完成事件 out.
struct io_uring ring;
io_uring_queue_init(64, &ring, 0);

struct io_uring_sqe *sqe = io_uring_get_sqe(&ring);
io_uring_prep_read(sqe, fd, buf, len, 0);
io_uring_submit(&ring);

struct io_uring_cqe *cqe;
io_uring_wait_cqe(&ring, &cqe);
int result = cqe->res;
io_uring_cqe_seen(&ring, cqe);

性能关键:

  • batch submit (一次 submit 多个 sqe);
  • batch wait (一次 reap 多个 cqe);
  • SQPOLL 模式: kernel thread 池 user-facing SQ pull ⇒ user 不进 syscall.

6. SQPOLL mode: zero syscall on hot path

io_uring_queue_init(64, &ring, IORING_SETUP_SQPOLL);

实测 (1 TB Samsung 980 Pro NVMe, 4 KB random read):

模式IOPSsys%
sync O_DIRECT300k80%
epoll + libaio800k60%
io_uring + SQPOLL1.05M18%

SQPOLL 把整个 syscall cost 摊到几乎 0.

7. 对比表

接口fd ready 查询syscall entry跨平台难度
selectO(N)per callPOSIX
pollO(N)per callPOSIX
epollO(1) ETper epoll_waitLinux only
kqueueO(1) ETper keventBSD/mac
io_uringO(1) submit/waitSQPOLL=0Linux 5.1+

8. 语言 runtime wrap 抽象

语言event loop 底层
C libuvepoll (Linux), kqueue (BSD), IOCP (Win)
Java NIOepoll / wrapper wrapper netty
Rust mio / tokioepoll + io_uring
Go runtime netpollepoll + GMP scheduler
Python asyncioepoll 或 io_uring (3.12+)
Node.js libuvepoll / kqueue

9. 这章带走的东西

  • select/poll 是 O(N) 遍历; epoll/kqueue 是 O(1) 持久 register;
  • ET 必须 O_NONBLOCK + 循环 EAGAIN;
  • io_uring + SQPOLL 是 Linux 现代: 几乎 0 syscall entry;
  • 实际 epoll 在多数场景足够; IO-bound bottleneck 才迁 io_uring.

下一节 → Zero copy: sendfile / splice / MSG_ZEROCOPY

Zero copy: sendfile / splice / MSG_ZEROCOPY

一句话

"零拷贝" 这个词也常被误读。它不是字面意义的"字节一次都不拷"——它是"不去把数据从 kernel space 拷到 user space 再拷回去"的工程化叫法. 在网络栈下半段, 一个 packet 从 disk 到 NIC 经过零拷贝优化能少 2 次 memcpy. 这章把 sendfile / splice / tee / MSG_ZEROCOPY 四种形式拆开, 你看到"零拷贝"有多个抽象层级.

1. 普通 read/write 几次拷贝?

read(fd, buf, 4096):
  DMA disk → kernel page cache (1 次 DMA, 不算 copy);
  memcpy kernel → user buf (1 次 CPU copy);
  返回 user.

write(socket, buf, n):
  memcpy user → kernel socket buffer (1 次 CPU copy);
  kernel → NIC DMA (1 次 DMA, 不算 copy).

总共 2 次 CPU memcpy, 2 次 DMA. 一次 disk-to-NIC 大概 8 KB / IP packet ⇒ memcpy 8 KB 不是大头, 但小请求高 QPS 时 syscall + cache miss + page table walk → 包 sys% 占 60-80%.

2. sendfile: 替 read+write

ssize_t sendfile(int out_fd, int in_fd, off_t *offset, size_t count);

out_fd 只能 socket; in_fd 只能 file.

实现: kernel 内部 DMA disk → page cache → DMA page cache → NIC (用 SG-DMA / zero-copy TCP). 字节不经过 user space, 也不经过 user buf 的 memcpy.

适合场景:

  • nginx sendfile on; (默认 on)
  • Apache HTTPD EnableSendfile On;
  • webserver 静态文件 dispatch.

3. splice / tee: 任意 fd 间零拷贝

int splice(int in_fd, off_t *off_in, int out_fd, off_t *off_out, size_t len, unsigned flags);
int tee(int fd_in, int fd_out, size_t len, unsigned flags);

splice 必须至少一端是 pipe; tee 双端都是 pipe. Linux 把数据在 kernel 之间传用 pipe buffer 引用。

in_fd → splice → pipe → splice → out_fd
      (机制: 内部用 pipe_buffer struct, 共享 page 引用)

实例:

  • HTTP proxy 转发 disk file → client socket:
    fdt = open("file");
    pipe(p);
    splice(fdt, NULL, p[1], NULL, 65536, 0);
    splice(p[0], NULL, client_sock, NULL, 65536, SPLICE_F_MOVE);
    
    完全在 kernel 内传 page reference, no CPU copy.

4. MSG_ZEROCOPY: 用户态 zero copy

send 系统调用一个 flag, 加上 MSG_ZEROCOPY:

send(socket, user_buf, len, MSG_ZEROCOPY);

实现: kernel 把 user buf 注册成一次 DMA 引用,NIC 直接从 user 内存 DMA 出去而不复制到 kernel socket buffer.

但需要 user 显式 poll 一个错误队列 (MSG_ERRQUEUE) 接收完成通知,再 free 缓冲区. 否则 NIC 还在引用,free 就乱套. 这就是为什么大部分项目选用 RDMA 而非 MSG_ZEROCOPY: 通知机制复杂.

Go runtime 在 net package 已经支持 MSG_ZEROCOPY 自 1.11, 但需要 reflective GSO + ack notify framework, 工程实操需要 package reset.

5. RDMA verbs: zero copy 跨主机

RDMA (Remote Direct Memory Access) 物理: HCA (Host Channel Adapter) 卡直接访问 host 内存 + 跨主机 host-host 内存 zero copy.

ibv_post_send(qp, &wr, &bad);  // post send to peer host's MR

延迟:

TCP loopback           5 μs
TCP cross host hetzner 50 μs
RDMA cross host       2 μs
RDMA + GPUDirect      1 μs

Virtually 跨主机无 syscall, 由硬件 inbox 卡完成.

工业: HFT / HPC / NVIDIA Magnum IO / late توسط Microsoft Azure 等. 真 zero copy cross host 就这一个.

6. 实测比较 (1 GB disk → NIC 流量)

模式工作流延迟CPU%
send + recv100-10 ms100 μs60%
sendfilezero copy disk→NIC50 μs10%
splice + 中间 pipeanonymous60 μs12%
MSG_ZEROCOPYuser buf DMA70 μs15%
RDMA直接内存到内存 (no 路径 disk)2 μs5%

7. 多语言支持

语言zero-copy 接口
C / C++sendfile, splice, MSG_ZEROCOPY, RDMA verbs
Gonet.TCPConn.WriteFile + syscall.Sendfile; net package WriteTo
Rustnix::sys::sendfile, tokio-utils
JavaFileChannel.transferTo (用 sendfile), NIO
Pythonos.sendfile (Python 3.3+ 直接 wrap)

工程各语言都给 wrap. Go 1.11+ net.TCPConn .ReadFrom 直接走 sendfile 自动.

8. 这章带走的东西

  • 普通 read/write 在每次数据 flip 有 2 次 CPU memcpy;
  • sendfile 是 disk → socket 专用零拷贝 (Linux 2.2+);
  • splice 利用 pipe 内部 page ref 共享, 任意两 fd 通用零拷贝;
  • MSG_ZEROCOPY 让 user buf 直接给 NIC DMA, 需要 MSG_ERRQUEUE 通知;
  • RDMA 是真跨主机零拷贝, 在 HFT / HPC 接近微秒级;
  • 各 runtime 提供 wrap, 但要用到位需深入了解 API 语义.

下一节 → TCP/IP 内核栈与 NAPI

TCP/IP 内核栈与 NAPI

一句话

Linux 内核的 TCP/IP 协议栈是工程上最被低估的子系统之一:"高了一个量级"。现代内核 + 多队列网卡 + NAPI + io_uring + eBPF 在 100 Gbps 的网络下还能跑出 50% 的线速。这章拆开 TCP/IP 内核栈的 packet lifecycle + NAPI 机制 + RPS/RFS/XPS 软中断分发, 让你看懂 "16 核 100 Gbps NIC 实际能跑多少"。

1. 一个 packet 的 lifecycle

NIC RX 流 ingress frame
   ↓ DMA 到 ring buffer (RX ring)
   ↓ 触发 IRQ, 内核 NAPI 软中断
   ↓ net_rx_action → E1000 driver → netif_receive_skb
   ↓ 进入协议栈:
        L2 (eth_type_trans)
        L3 (ip_rcv)
        L4 (tcp_v4_rcv)
   ↓ socket 收包队列
   ↓ syscall recvmsg 拷贝到 user

每 packet 经过 ~20 个函数, 2000-5000 cycles 抵达 user space.

2000 cycles ÷ 3 GHz = ~ 700 ns / packet ⇒ 1.4 Mpps / core (kernel 网络栈极限).

50 Gbps 流量 ÷ 1500 MTU = 4 M pps ⇒ 超过单 core 网络处理能力.

这就是为什么内核需要 multi-queue NIC + multi-core scaling.

2. NAPI: 中断 + 轮询混合

NIC 旧模型: 每 packet 触发 1 IRQ + 内核 IRQ handler 处理. pps 高时 IRQ storm 撕 CPU.

NAPI (New API):

  • 第一 packet 触发 IRQ, kernel 知道 NIC 有数据;
  • 之后关闭 NIC IRQ, kernel 软中断轮询 (net_rx_action) 在该 NIC 上拉 ~64 个 packet 直到 N;
  • 没数据再 rearm IRQ.
NIC RX IRQ → net_rx_action → poll up to 64 packets → no more → rearm IRQ

收益: 5 Mpps 流量下从每 N 次切换 IRQ (50 万 IRQ/s) 降到万次 IRQ/s.

3. Multi-queue NIC + IRQ 分配

NIC 256 RX/TX queue (modern Mellanox CX-6/7), 内核把每个 queue 的 IRQ 通过 smp_affinity 拆到 CPU:

cat /proc/irq/N/smp_affinity_list
echo "0-3" > /proc/irq/N/smp_affinity

XPS (Transmit Packet Steering): TX queue 选 CPU 默认; RPS (Receive Packet Steering): RX hash 把 packet 拆到任一 CPU; RFS (Receive Flow Steering): 按 socket 所在 CPU 转, 提高 cache. aRFS: 自动 RFS, NIC 直接 dispatch.

完美配置: 每 RX queue 绑一 CPU, IRQ 不跨 socket, RPS 关, aRFS 开.

4. 网络栈代码路径复杂度

packet rate / CPU:
  单核 raw socket (PF_RING zero bypass): ~ 14 Mpps (14 ns/pkt)
  单核 gro + tcp + recvmsg: 1.5 Mpps (700 ns/pkt)
  单核单 epoll + 1024 是可能 0 ms latency јѕ 5 ms env: ~ 1 Mpps

高 PPS 越接近 kernel 1750 ns 限 = 需要走 bypass.

5. GRO/GSO: 聚合大小 packet 减少 chain

  • GRO (Generic Receive Offload): NIC 改 packet frame 收大小异常, kernel 内部 merge 多个 small packet 大 64 KB unit 给上层 stack ⇒ stack 跑一次 vs 100 次;
  • GSO (Generic Segmentation Offload): 出向, kernel 把 64 KB 巨型 segment 丢给 NIC, NIC 自己分成 MTU-size.

现代 NIC 通常支持 hardware GRO/GSO, 优势 of tcp burst.

6. eBPF + XDP: 内核暖些 pre-router

eBPF 在网络 packet 收入 frame 阶段可以编程:

NIC RX queue → XDP (eXpress Data Path) hook → 用户 eBPF program →
   ↓ 丢 / 重定向 / let go to kernel stack

XDP eBPF program 通常处理 packet:

  • DDoS 现场丢 IP 黑名单;
  • L4 load-balancer (e.g. Cilium LB);
  • NAT + 防火墙规则;
  • metric collection.

性能: XDP 在 Linux 5.0+ 已经能到 24 Mpps per CPU, 远超传统 kernel stack. 这是 Cilium / cloudflare 等工业核心.

7. 多语言同一抽象

ef: 内核栈可用 = 不需要直接里程.
  - C / Rust: libc syscall on socket
  - Go: runtime 自己 epoll over socket fd
  - Java: NIO epoll
  - Node: libuv
  - Python: asyncio / trio

各语言都默认走 kernel TCP/IP stack, 但想要 kernel-bypass 必须自写:

// DPDK 直接 mmap NIC ring
// Onload / OpenOnload 类似 (SolarFlare) NIC driver SDK

8. 实测生产线式优化

# 调 NIC IRQ on NUMA socket
for i in $(ls /proc/irq/*/smp_affinity_list); do ... done

# 关闭 packet processing features
ethtool -K eth0 gro off  
ethtool -K eth0 gso off   # 仅 debug baseline

# ringbuffer up
ethtool -G eth0 rx 8192 tx 8192

# 推荐 mtu 9000 (jumbo) 在 internal cluster: ipv_set dev eth0 mtu 9000

9. 这章带走的东西

  • kernel TCP/IP 在单核 ~ 1.5 Mpps 上界;
  • NAPI 改 IRQ storm 软轮询, 现代 NIC 必配;
  • multi-queue + IRQ smp_affinity + RFS/RPS 让多个核同时处理 RX;
  • GRO/GSO 让单 throughput 拓宽 64 KB 上 vs 1.5K MTU 量级;
  • bpf + XDP 在 NIC ingress 阶段执行 user code = 24 Mpps per CPU 现代性能;
  • 真要 100 Gbps line-rate 跑要用 DPDK/XDP full bypass.

下一节 → XDP、DPDK 与 kernel bypass

XDP、DPDK 与 kernel bypass

一句话

kernel bypass = 让 user-space app 直接看到 NIC ring buffer 而不进内核 TCP/IP stack。从 2000 年代起的 PF_RING/DNA, 到 Intel DPDK, 到 Linux 5.0+ 的 XDP+AF_XDP, 演进路径全是"把更前一段路径前移到用户态, 减少 syscall & IRQ reentry." 这章把三条主路 XDP / DPDK / AF_XDP 做对比, 看清 100 Gbps 服务器的真实 before/after.

1. kernel 网络栈的瓶颈: 1500 ns per packet

经前面章节的 mental model:

kernel TCP/IP 单 packet 处理: ~ 700-1500 ns / CPU

100 Gbps + 1500 MTU ⇒ 8 M pps ⇒ 远超单 CPU 能力. multi-queue NIC 让多 CPU 同时分担, 但每次"过 stack 个"仍是常数. bypass 干的事就是绕过 stack 这一步.

2. DPDK: Intel 2010 出品, 是 kernel bypass 工业事实标准

DPDK = Data Plane Development Kit. 把 NIC driver in user-space 改:

  • 把 NIC mmap 到 user-space;
  • 大页 + 协议 queue;
  • 轮询 thread (poll-mode driver) 自 spin in user-space 不进 IRQ;
  • batch packet 64+ 个每批进 user 处理.
// DPDK 类骨架:
while (true) {
    nb_rx = rte_eth_rx_burst(port_id, queue_id, bufs, 32);
    for (i = 0; i < nb_rx; i++) process_packet(bufs[i]);
    nb_tx = rte_eth_tx_burst(port_id, queue_id, bufs, nb_rx);
}

优势:

  • 平均 latency 1-3 μs / packet (vs 50 μs kernel);
  • 吞吐: 14 Mpps / core 几近线速 + 多 queue.

劣势:

  • CPU 100% busy poll (no sleeping);
  • NIC 整块 isolated 不能再给 kernel 其他 socket 用;
  • driver 在 user-space, 与 native Linux NIC 路径并行不兼容.

部署: HFT / NFV (网络功能虚拟化) / 5G UPF / OVS (Open vSwitch) 大量用 DPDK.

3. XDP + AF_XDP: Linux 5.0+ 内核内置 bypass

XDP 是 eBPF 在 kernel 内入口 XDP hook:

NIC RX → DMA to ring → 内核 driver → XDP hook ran in driver context →
   动作: XDP_DROP, XDP_PASS, XDP_TX, XDP_REDIRECT

XDP 不让 user-space, 它给 eBPF程序在 kernel driver 模型下早期阶段运行. 适合:

  • DDoS 拦截;
  • L4负载均衡 (Cilium LB);
  • 一致分布式 firewall.

与 DPDK 相比: XDP 不让 NIC 脱离 kernel stack, 它在 stack 入口插了 hook. 让 packet 可以直接 in-kernel 跳出 stack 而不上 ret.

AF_XDP: 与 XDP 配合, 把 packet 通过 socket 直接传到 user-space:

// XDP program 把 packet redirect 到 AF_XDP socket
int xdp_prog(struct xdp_md *ctx) {
    return bpf_redirect_map(&xsks_map, 0, XDP_PASS);
}

// user-space 绕过 stack
int sock = socket(AF_XDP, SOCK_RAW, 0);

perf:

  • XDP_DROP: 24 Mpps per CPU
  • AF_XDP round-trip user kernel boundary: ~ 10 Mpps per CPU (具体 latency 1-3 μs)

XDP vs DPDK:

  • XDP 可与 kernel stack 兼容 (按 PASS 也 用);
  • DPDK NIC 独占;
  • XDP 需要 Linux 4.18+;
  • DPDK 工业成熟度早期 higher (NIC vendors 主流), 但 XDP 在 kernel 已实 工程实测 ~ 2024 与 DPDK 持平.

4. Cilium 用 XDP 做的工业级 load balancing

Cilium/eBPF 是 K8s + LB + firewall 一致平台:

  • 来自 EC2 pod 的 packet 入 node, XDP early hook 查 priory ip + port → redirect 到本地 pod socket;
  • 完全 bypass kernel stack;
  • LB ops/s 可以 5-10× kube-proxy.

工程性: 4.18+ kernel 已可用. Cloudflare / GCP / AKS 等是常规.

5. RDMA: 完全旁路 NIC

并非所有 NIC 不是 CPU 上 dispatch. NVIDIA ConnectX + RDMA RoCE v2 让国际 NIC 可:

  • 一端 host user memory 注册 mr;
  • 另一端 user 直接 read / write mr verbs;
  • 旁路 NIC stack 和 TCP/IP.
ibv_post_send(qp, &wr, &bad);  // 直接绕 stack post send → peer host's MR

latency:

TCP via stack        50 μs
DPDK UDP             2-5 μs
RDMA                 1-2 μs
GPUDirect RDMA       <1 μs

GPUDirect RDMA 让 GPU mem to NIC zero copy, 不经 host 内存. 大模型训练 benchmark 互联依赖.

6. SmartNIC: DPU (Data Processing Unit)

NVIDIA BlueField / Intel IPU / AMD Pensando 把 NIC 升级成 SoC:

  • 双 ARM core onboard;
  • FPGA / NPU 加速引擎;
  • 加载 NVMe storage + Linux;
  • 直接处理 VXLAN / TLS / firewall / offload eBPF on NIC.

软件切换范式:

  • 在 host CPU 不算网卡 packet;
  • 在 NIC 内部把传输到 host 的仅 load 实际数据 packet;
  • host CPU 见到的 packet 不是 NIC 物理 raw, 而是 L7 已解析过的 "App 上流事件".

参考架构: DPU = next-gen SmartNIC + 自 VM / ONIC.

7. 多语言

语言kernel bypass
C / C++DPDK, VPP, mmap NIC ring
Rustasync + VPPs lib
Go不太友好的, but DPDK-binding with cgo usable
JavaApache Pulsar 形 等上Solace Onload, 但很难
Python完全不生产 sprint DPDK Python eBPF

DPDK / XDP / RDMA 工程上由 C/C++/新Rust 文化承担. 高级语言 runtime 都不独 bind, 因为 1.他们 OS runtime 要 syscall 接口, 而且没有 determinism guarantee 离 kernel-bypass 太远; 2. 用 wrapper cgo 破环 runtime 性能.

但 ECA 出产 XDP-by-Rust (aya) 现 在是的 Serious 试真 names like Solana.

8. 选择树 (按 latency 要求)

50 ms acceptable     → 通用 TCP/IP + epollioUring
1-10 ms              → kernel + connected to GRO/或 io_uring net Z
<100 μs              → XDP / AF_XDP + custom code in C / Rust
急着 HFT 1 μs        → DPDK + 用户态轮询
急着 7ns/140GB/s       → GPUDirect RDMA on CUDA + ConnectX-7

9. 这章带走的东西

  • kernel stack 默认有 ~700 ns/pkt 限 ⇒ 高 PPS 必 bypass;
  • DPDK = user-space poll mode 早 4-100×;
  • XDP = kernel 内置 bypass hook, eBPF 编程;
  • AF_XDP = XDP 版 user-space socket pass;
  • SmartNIC / DPU level: 移动 syscall 到 NIC chip;
  • DPDK 与 XDP 性能相近, 选型看 e 库 / 兼容 与 较 选择.

第三部分 · 计算机网络

一句话

axios.get('https://api.example.com') 背后:电信号从 100BASE-T 网口出去,跨交换机、路由器、光纤骨干、CDN、TLS、负载均衡,跑几千公里到服务器再原路返回一个 JSON。这条通路上每一跳都对应一层"协议"负责把上一层数据装进下一层信封。读完这一部分,你应该能从字节看到光子、从 RPC 看到内核中断、从握手 RTT 看到激光相位——这就是网络工程师的全栈视角。

为什么协议要分层

把 5000 种硬件介质和 5000 种应用协议两两对接是 $O(n^2)$ 工程,分层让接口收敛到 $O(n)$:上层只对"下一层提供的接口"负责,下层只对"上一层调用我的方式"负责。这就是 ISO/OSI 七层真正的现实版(虽然 OSI 自己部署不出去,TCP/IP 五层活了下来):

应用层    | HTTP/2/gRPC/MQTT     | 你写的 axios
传输层    | TCP/UDP/QUIC          | 内核 + 协议栈
网络层    | IPv4/IPv6/ICMP        | 路由表 + LPM 查找
数据链路层| Ethernet/WiFi/PPP     | MAC + 帧组装 + FCS
物理层    | 1000BASE-T/100G-QSFP  | PHY PHY PCS PMA PMD

每一层有自己的命名空间(端口号 vs IP 地址 vs MAC 地址),自己的拥塞模型(TCP Cubic vs RED/ECN 信号 vs CSMA/CD 冲突),自己的恢复机制(重传 vs ARP 重试 vs 链路重连)。把它们打包成 5 层抽象之后,软件工程师可以用 fetch() 调地球另一端的服务而不用懂光相位检测。但遇到性能问题、丢包混沌、协议升级时,分层就是要把每层都拆开来看——这一部分就是干这件事。

这一部分的章节

  • 物理层 / 数据链路层 — 以太网帧、CSMA/CD、PHY/MAC 分层、光纤、DWDM、机房布线
  • IP / 路由 — IPv4/v6、ICMP、ARP/NDP/DHCP、BGP/OSPF、NAT+conntrack
  • TCP / UDP — 三次握手四次挥手、TIME_WAIT、Reno/Cubic/BBR、SACK/RTO 估计
  • HTTP / TLS — 1.0→1.1→2→3 演进、TLS 1.3 握手与 0-RTT、证书链/PKI、gRPC/Protobuf
  • QUIC — over UDP 解决什么、连接迁移、BBR 在 QUIC 下的特点

读完你应该能回答这些问题,而不是只会说"我学过计算机网络":

  1. 为什么以太网帧最小是 64 字节而不是 32?为什么 1500 字节 MTU 沿用 40 年没人改?
  2. BGP vs OSPF 谁的收敛快?为什么 ISP 用 OSPF,跨 ISP 用 BGP?为什么 BGP 默认 30s 抑制?
  3. TCP Cubic vs BBR 在卫星链路、5G 移动网络、跨洋数据中心里各自赢谁?
  4. TLS 1.3 0-RTT 怎么保证保密性同时还能被 replay attack?应用层应该做什么限制?
  5. QUIC 为什么比 TCP+TLS 1.3 快?连接迁移到底解决了什么真问题?
  6. 为什么 tcp_tw_recycle 在 Linux 4.12 后被移除?为什么 NAT 后多 client 共用 IP 时它会丢包?
  7. Google BBR v1 在 2016 部署后,2019 又换了 v2,到底什么场景下 v1 不公平?

这一部分的工程立场

每次给一个新协议栈调优,标配工具链:tcpdump -i any -nn -X port 443 | wireshark -k -i - 抓包看 header;ss -tin 看内核 socket 状态、cwnd、rtt;iperf3 测带宽;ss -ian 看中断分布;ethtool -S eth0 看 NIC 计数器(rx_missed / rx_no_dma_resources / alloc_rx_page_failed 都是热指标);bpftool prog show 看 XDP;perf top -e eth0:tx-qos-mismatch 看软中断热路径。

note

这一部分凡是讲数字的地方(如 "100G 显卡 1W 功耗"、"DWDM 单纤 80λ × 100G = 8Tbps"、"BBR ProbeBW 8 阶段 cycle 8s")都是公开论文/产品 datasheet 的实际值,不是随手编的。这些数字记下来,做架构决策时可以少打很多白板。

物理层 / 数据链路层

TL;DR

物理层(PHY)负责把比特变成可在线缆/光纤/空气中传输的物理信号(电压、光强、电磁波相位),数据链路层(MAC)负责把比特组帧、加校验、处理共享介质的接入冲突。这两层决定了网络的"天花板":带宽、延迟、丢包率、MTU、抖动。

思维链

考虑一行 curl https://example.com,从网卡 PHY 出发的完整链路:

应用层 HTTP -> TLS -> TCP -> IP -> Ethernet MAC/PHY
                  -> 交换机 (L2 转发, 查 MAC 表)
                  -> 路由器 (L3 转发, 查路由表)
                  -> 光纤骨干 (DWDM)
                  -> 对端路由器 / 交换机
                  -> 服务器网卡

每一跳都有 PHY → MAC → IP 的拆层 / 装层。这一节要回答:

  • 以太网帧为什么最小 64 字节
  • 100G/400G 网卡 PHY 是不是直接打铜线?为什么美团字节都在向 25G 接入、100G 互联、400G spine 切换?
  • 数据中心为什么大量用光纤而不是 Cat6?DAC(直连铜缆)什么时候比光便宜?
  • 为什么 1500 字节 MTU 一直没动?jumbo frame 9000 在哪些场景赢?
  • PFC / ECN / DCBX 怎么在链路层做流控?为什么 RoCE 必须依赖 PFC?

以太网帧结构

+----------+--------+--------+--------+-----------+--------+--------+
| Preamble | SFD    | DA     | SA     | EtherType | Payload | FCS   |
| 7B 10101010| 1B 10101011 | 6B | 6B | 2B        | 46-1500 | 4B CRC |
+----------+--------+--------+--------+-----------+--------+--------+
                                                             +12B IFG (帧间隙)

最小帧长(不含 preamble/SFD/IFG)= 64 字节,最大(含 4 字节 FCS)= 1518 字节。VLAN tag 加 4 字节 → 1522。

64 字节最小帧的由来

CSMA/CD 时代(共享总线 / Hub):冲突检测要求发送方在帧发完之前能收到对端的冲突信号,否则已经把整帧光发出去了,冲突信号回来已经晚了。

T_frame >= 2 * T_propagation   (round-trip time)

最长 2.5 km 同轴电缆(10BASE5)≈ 25.6 µs RTT(光速 2/3 c 的铜信号)。10 Mbps 下:

10 Mbps × 25.6 µs × 2 = 512 bit = 64 byte

→ 最小帧 64 字节恰好是 10 Mbps 下"冲突窗口"的两倍。短数据要 padding 到 64。万兆全双工交换时代 CSMA/CD 已经没用,但 64 字节保留为兼容。

note

高速链路(≥1G)下,"近端冲突信号延迟"在大楼尺度内不足 1µs,远小于 64 字节发送时间。但 64 字节已成为以太网帧法定下限,跨所有速率保留。

1500 字节 MTU 的由来

1980 年代以太网用 1500 字节是 CPU 处理能力和缓冲大小的折中

  • 太大 → 早期网卡没有足够 RAM buffer + CPU 中断处理开销过大
  • 太小 → 协议头开销比例高(14B header + 4B FCS = 18B 开销)

1500 在 10 Mbps 下发送 1.2 ms。IEEE 802.3 标准化后锁死,因为 MTU 必须全网一致——改了就跨厂商互不兼容。1998 年 IEEE 802.3ac 引入 jumbo frame 9000 字节为可选,但互联网路径只能依赖 PMTUD 到 1500,jumbo 只是在数据中心内部用。

warning

修改 MTU 时两端必须对称,否则会出现"小包通大包掉"的诡异黑洞。ping -M do -s 1472 target 可以测路径 MTU(1472 = 1500 - 20 IP头 - 8 ICMP头)。


PHY / MAC 分层

现代网卡(Intel X550 / Mellanox ConnectX-6 / NVIDIA BlueField-3)内部:

+------+   +--------+   +-----+   +------+   +------+
| PCIe | <->| MAC     |<->| PCS  |<->| PMA  |<->| PMD  |<-> 介质
| (TX/ |    | (RS,    |   | 64b/ |   |serdes|   | optics/
| RX   |    |  MAC    |   | 66b  |   |      |   | cu   |
| ring |    |  ctrl)  |   | enc  |   |      |   | laser|
| ao_) |    +--------+   +-----+   +------+   +------+
+------+
              ↑                        ↑         ↑
            MAC 层                   PHY 子层  PHY 子层
  • MAC:帧组装、FCS 计算、流量控制、802.1p/q tag、QoS 调度
  • RS(Reconciliation Sublayer):MAC ↔ PHY 适配
  • PCS:8b/10b、64b/66b、256b/257b 编码 + 对齐
  • PMA:串并转换、CDR(clock data recovery)
  • PMD:物理介质驱动(光 / 铜)

线路编码:为什么不是 8b/8b

NRZ 直传时钟会和直流耦合问题撞上:

  • 长串 0/1 时接收端 CDR 锁不住时钟相位 → bit error rate 上升
  • 直流分量偏移会破坏隔直电容

所以需要 直流平衡 + 跳变密度 两条件都满足:

速率编码开销跳变密度
1G Ethernet8b/10b25%至少 3 次/10 bit
10G Ethernet64b/66b3.1%平均 12 transitions/66 bit
25G/50G64b/66b3.1%同上
100G/200G64b/66b + RS-FEC (528/514)~5% 附加
400G/800G256b/257b + PCS-FEC<1% 编码 + <5% FEC

note

8b/10b 用 256 个有效字符 + 12 个控制字符 + 100+ 控制字符的冗余表示,每 8 bit 译码成 10 bit。64b/66b 用 2 bit 同步头提供跳变 + 扰码保证密度,比 8b/10b 节省 22% 带宽。


Auto-Negotiation 与流控

AN (Auto-Negotiation)

1000BASE-T 通过 FLP(Fast Link Pulse)广播本端能力(速率 / 双工 / 暂停帧支持 / FEC),双方选交集。强制 Speed/Duplex 是反模式——一端强制另一端自协商会出现 duplex mismatch(一端 full 一端 half),表现为大量 late collision + 性能崩溃 1/2 倍 + 偶发 timeout。

IEEE 802.3x Pause + 802.1Qbb PFC

普通 Pause 帧会把整口堵死。PFC(Priority Flow Control)按 802.1p 优先级 8 个 class 各自独立暂停:

host A -> switch -> host B
        switch 出口拥塞 -> 发 PAUSE 帧 (class 3) 给 host A
        host A 暂停 class 3 发送,其余继续

数据中心 RoCE / RDMA 强依赖 PFC + ECN 实现 "无损以太网"(lossless Ethernet)。否则 RoCE 的 credit-based flow control 一旦遇到丢包会重传,PG 暂停秒级。

实际部署里 PFC 有死锁 (deadlock) 风险:A→B、B→C、C→A 互相暂停 → 链路全部冻结。解决:交换机做 deadlock detection + 强制丢弃优先级 head-of-line 帧(vendor-specific;亲测 Arista/Cisco 都要单独配置 pfc watchdog)。


交换机转发逻辑

L2 交换机核心:

def on_receive(frame):
    src = frame.src_mac
    dst = frame.dst_mac
    mac_table[src] = ingress_port   # 学习
    if dst in mac_table:
        forward(mac_table[dst], frame)
    else:
        flood(frame, all_ports_except(ingress))  # 未知单播泛洪

CAM 表项老化默认 5 分钟。STP(802.1D)通过 BPDU 计算"根桥 → 端口角色(root/designated/block)",收敛时间 30-50s;RSTP(802.1w)≤ 1s。

数据中心用 SPB (IEEE 802.1aq) 或 TRILL 替代 STP 因为 STP 阻塞端口浪费带宽;现在胖树 spine-leaf + ECMP 已经是事实标准,STP 已经退场。


数据中心布线:铜 vs 光

维度Cat6A (10G copper)SFP+ DACSFP28 (25G) 光QSFP-DD (400G)
距离100 m5 m100 m (OM4) / 10 km (SMF)100 m - 40 km
功耗0.5 W<0.2 W1 W10 W+
延迟~500 ns/100m<10 ns~500 ns/100m类似
成本最低
散热难度

机架内 Top-of-Rack 用 DAC(5m 内零功耗);机架间或跨机房用多模光纤(OM4 100m);跨 DC 用单模 + DWDM。

DWDM(Dense Wavelength Division Multiplexing)

一根单模光纤可以同时承载 80-160 个波长,每个波长 100G/200G/400G → 单纤 12.8 Tbps+(中国电信在 2022 年实验室已演示过 104 TBps)。相干光通信(coherent detection)+ DP-QPSK / 16QAM 调制盘活骨干容量。

C-band 频段 (1530-1565 nm):
λ_1, λ_2, ..., λ_80  ---  每个间隔 50 GHz ITU grid
        ↓
EDFA 光放大器同时放大所有波长 (不需要光-电-光转换)
        ↓
经过几千公里到对端,光放大器间距 80-100 km
        ↓
要分波时用 OADM (Optical Add-Drop Multiplexer)
海底光缆常见间距 50-100 km EDFA,每 1000 km 加一个 REGEN(光-电-光再生)

调制效率:从 QPSK 到 PM-256QAM

每符号携带的比特数:

调制bit/sym在 50 GHz ITU slot 下速率
DP-QPSK4100G
DP-16QAM8200G
DP-64QAM12300G
DP-256QAM16400G

调制阶数越高 → 每个 symbol 对噪声越敏感 → 容许 OSNR 越高 → 距离越短。所以 DC 内用 PAM4 简单,跨大陆用低阶调制 + 强大 FEC + 相干接收。


硬件视角:MAC 到 PCIe DMA

收包(RX)路径

网线 -> PHY -> MAC RX ring buffer -> DMA 写到 host 内存
       -> MSI-X 中断 -> NAPI poll -> skb -> netif_receive_skb
       -> IP/TCP stack -> socket queue -> recvmsg()

rx_desc 环(典型 1024-4096 项)由驱动提前把已分配 skb 的物理地址填好。NIC 收到包后 DMA 直接把帧写到 ring 里,不经过 CPU

ethtool -G eth0 rx 4096 tx 4096      # 调环大小
ethtool -L eth0 combined 16          # 调 RSS 队列数
ethtool -N eth0 rx-flow-hash tcp4 sdfn   # 五元组 hash 分队列
ethtool -S eth0 | grep -E 'rx_missed|rx_no_dma|alloc_rx' # 关键 NIC 计数器

中断与 NAPI

每个 packet 触发一个中断在中带宽下 OK,高 PPS 下 NAPI 切换到轮询:

irq -> napi_schedule() -> softirq NET_RX
  -> napi_poll() budget=64 一批
  -> budget 用完或 ring 空 -> 关闭轮询,重新启用 IRQ

高 PPS 下 ring 要大、IRQ affinity 要 pin 到核、与 NUMA 配对、SO_BUSY_POLLXDP 全部上场。可以打到 10M-50M pps(64B 包)。

note

单线 100Gbps 64B 包 = 148.8 Mpps,超出任何软件路径极限。所以 100G 链路对 64B 小包必须用 XDP + AF_XDP 卸载,或硬件三层路由卸载。线速跑 1500B 包只需 8.3Mpps,仍需 ARR/RSS + busy poll 才能跑满。


多语言示例

抓包统计(Python + Scapy)

from scapy.all import sniff, Ether
from collections import Counter
counter = Counter()
def cb(pkt):
    if Ether in pkt:
        et = hex(pkt[Ether].type)
        counter[et] += 1
sniff(prn=cb, count=100000, store=False)
print(counter)
# Counter({'0x800': 95000, '0x86dd': 4000, '0x806': 600})

用 eBPF/XDP 在网卡做包过滤(C + libbpf)

SEC("xdp")
int drop_bcast(struct xdp_md *ctx) {
    void *data = (void *)(long)ctx->data;
    void *end  = (void *)(long)ctx->data_end;
    struct ethhdr *eth = data;
    if ((void*)(eth+1) > end) return XDP_DROP;
    if (eth->h_dest[0] & 1) return XDP_DROP;   // 广播/组播丢
    return XDP_PASS;
}

XDP 程序运行在 driver RX 路径上,还没建 skb 之前,PPS 可达 20M-50M。

用 Go 看内核 NIC 计数器(gopsutil)

import "github.com/shirou/gopsutil/v3/net"
stats, _ := net.IOCounters(true)
for _, s := range stats {
    if s.Name == "eth0" {
        fmt.Printf("rx: %d bytes, %d packets; tx: %d, %d\n",
            s.BytesRecv, s.PacketsRecv, s.BytesSent, s.PacketsSent)
    }
}

易错清单

  • ❌ 强制 speed/duplex 又对端 auto → duplex mismatch → 性能腰斩
  • ❌ 改 MTU 两端不一致 → 黑洞丢包(小包通过,大包掉)
  • ❌ PFC 配错优先级 → 上行/下行死锁,需要 deadlock detection 兜底
  • ❌ RJ45 跨 100m 走 Cat5e → 高速误码率上升(10G 必须 Cat6A)
  • ❌ LACP 两端 hash 算法不一致 → 单流只走一条线(带宽不滚动)
  • ❌ SFP 模块未在 HCL 列表 → 高温掉线 / CRC 异常上升
  • ❌ 同一根 DAC 走 4Gbps 但插入新交换机后协商成 1G → SFP+ 协议未握手

真实生产事故参考

  1. GitHub 2018 10/21:BGP 跨数据中心 43 秒错误路由 + OSPF 撤回前缀 + TCP RST 重置连接。事后 GitHub 引入 Anycast + 多供应商 BGP + Manycast DNS。
  2. Cloudflare 2020 7/17:Router BGP 把 POP 路由通告到错误 AS_PATH,全球部分流量被黑洞 27 分钟。
  3. Facebook 2021 10/4:BGP 撤回权威 DNS 路由 → 全球 6 小时不可访问。根因是内部自动化更新 router 配置前未做任何 dry-run 校验。

这一章带走的东西

  1. 以太网 64 字节最小帧 = 10 Mbps 下冲突窗口的两倍;CSMA/CD 已废弃但帧格式延续
  2. PHY 内部 RS/PCS/PMA/PMD 子层;编码从 8b/10b 演进到 256b/257b(DC 平衡 + 跳变密度)
  3. PFC + ECN = 数据中心无损以太网基石,但需 deadlock detection 防 PFC 死锁
  4. 骨干带宽来自 DWDM + 相干光,单纤 12T+;C-band + EDFA 是 80λ 同纤放大的物理基础
  5. NIC RX 路径:DMA 写 ring -> MSI-X IRQ -> NAPI poll;高 PPS 必须 XDP/NAPI/busy_poll/numa pin 全套上调

下一节 → 以太网帧、CSMA/CD、PHY/MAC

以太网帧、CSMA/CD、PHY/MAC

TL;DR

深入拆 802.3 以太网:帧格式每一段为什么这么贴、CSMA/CD 算法细节、PHY 与 MAC 芯片实际怎么分块、为什么 100M 时代叫 MII、1000M 叫 GMII、10G 叫 XGMII——名字背后真实都是 IEEE 802.3 clause 的硬件 IP 切分边界。

思维链

应用层以为 Ethernet 是个抽象概念,但每个开关 + 编码 + 物理标准都是真实硬件

你的 sysctl 设 MTU=1500 → 内核 skb data 大小 = 1514 (含 MAC header)
   -> 网卡驱动 prep 渲染页 + pre-pad VLAN tag + 加 EtherType
   -> MAC 重新组帧:Preamble(7) + SFD(1) + Header(14) + Payload(1500) + FCS(4)
   -> PCS 编 64b/66b + 扰码
   -> PMA 串并转换并把 8 通道并行 (10G) 转换成 4 通道 25Gbps serdes
   -> PMD 把 25Gbps 电信号变光(laser driver)打向光纤

每个步骤背后都是 IEEE 802.3 标准的某个 clause + 各厂商芯片 IP。每个 PorV/Vendor 自己的 SDK 大小写参数都没调好就是出工程师连夜加班的根因。

帧格式的每一字节

    7        1         6        6        2       46-1500      4      12
+---------+---------+-------+--------+--------+-----------+-------+------+
|Preamble | SFD     |  DA    |   SA    | EtherType|  Payload   |  FCS  | IFG  |
|10101010×|10101011 |48bit MAC| 48bit   | 16 bit  |  上层协议  |CRC-32 | 9.6 µs|
|   7     |         |        |        |         |            |       |(Gbps用减少到1B) |
+---------+---------+-------+--------+--------+-----------+-------+------+
  • Preamble 7 字节:收端时钟恢复用,让 PLL 锁定相位。共享介质时代所有节点靠这 7 字节"时基对齐"。10G 全双工后理论上 Preamble 可省,但所有以太网帧都保留作兼容。
  • SFD 1 字节10101011 标记帧起点(与 Preamble 的最后一位相反)。
  • DA/SA:MAC 地址 6 字节,前 24 bit 是 IEEE OUI(厂商代码),后 24 bit 厂商自分配。FF:FF:FF:FF:FF:FF 是广播、首字节 bit0=1 是组播。
  • EtherType:< 1536 是 length(802.3 raw 格式,几乎绝迹),≥ 1536 标识上层(0x0800 IPv4, 0x86dd IPv6, 0x0806 ARP, 0x88cc LLDP, 0x8906 RoCEv2, 0x8915 NSH)。
  • Payload 46-1500:< 46 字节要 padding 凑 64 字节最小帧。
  • FCS 4 字节:CRC-32 多项式 0x04C11DB7
  • IFG 12 字节:帧间间隔(9.6 µs @10M,1.2 µs @1G),10G+ 可缩到 0.096 µs(1 字节时间)。这是物理层强制间隔,给收端腾出"处理一帧"的时间。

巨型帧(Jumbo Frame)

9000 字节 payload 用于 SAN / 大数据 Shuffle / RoCE:

  • 1500 MTU: 4 KB 文件 = 2.7 个包 → 协议头开销 18/1518 ≈ 1.19%
  • 9000 MTU: 4 KB 文件 = 0.45 个包 → 协议头开销 18/9018 ≈ 0.20%
  • 减少 NIC 中断 → CPU 占用降 30-50%
  • Spark / Flink 网络洗牌用 jumbo 后 CPU 利用率有 10-20% 的下降

但跨互联网必须 PMTUD 路径发现,否则 jumbo 在普通路由器上触发分片丢包。AWS 内 VPC、Azure 内、自建数据中心可以全部 9000;跨云不要用。


CSMA/CD 完整算法

发送:

1. 监听介质 (carrier sense),忙 -> 等待空闲
2. 开始发送
3. 边发边听:
   - 检测到冲突 -> 立即停止 -> 发 32 bit jam signal
   - -> 二进制指数退避
4. 退避次数 K <- min(10, retry_count)
   - backoff_time = random() % (2^K) × slot_time
   - 10 Mbps slot_time = 51.2 µs (512 bit 时间)
5. 16 次失败 -> 抛弃帧

为什么退避最大 10 而不是更大?2^10 × 51.2 µs = 26 ms 已经远超冲突窗口,再大无意义。

全双工模式

交换机的每端口是点对点链路,无共享介质、无冲突检测。所以 CSMA/CD 实际不启用。半双工(同轴 / Hub)才用得上。所有现代以太网都是全双工。


MAC 体系

MII / GMII / XGMII 接口速率

名字数据宽度时钟总线带宽用途
MII4 bit2.5 MHz100 Mbps100BASE-TX
GMII8 bit125 MHz1000 Mbps1000BASE-T
RGMII4 bit DDR125 MHz1000 Mbps板内 1G
XGMII32 bit156.25 MHz10 Gbps10GBASE-*
XLAUI4×10g serdes40 Gbps40GBASE-R
CAUI10×10g serdes100 Gbps100GBASE-CR4
XFI1×10g serdes10 Gbps10GBASE-SR/LR

每个都以 8b/10b 或 64b/66b 编码传输。MAC 与 PHY 之间的片内总线 → 这些 MII/XGMII 的存在是为了让 MAC vendor 和 PHY vendor 可以独立 IP 授权,让 ASIC 集成度可以增长。

note

名字里的 X/XG/S/X/SR/LR 这些前缀其实就是 IEEE 802.3 clause 编号区分:

  • X = 10G
  • S = Short Reach (<100m, 多模)
  • L = Long Reach (<10km, 单模)
  • R = Extended Reach
  • CR = Copper (直连铜)

PHY 内部:千万级门电路

10GBASE-KR PHY 内部数字部分:

MAC 8B  ||  TX_PMA  <-  TX_PCS  <-  64b/66b enc  <-  GMII
      ||                                ↑
      ||                            scramble + LDPC/BCH
MKD ||  RX_PMA   ->  RX_PCS  ->  64b/66b dec  ->  GMII
  • PCS: 64b/66b 编码 + Lane skew 对齐(多 lane)/ FEC
  • PMA: 串并转换 + CDR (Clock Data Recovery)
  • PMD: 激光驱动 / 铜驱动

Auto-Negotiation 与 FEC

10G、25G 等速率需要 RS-FEC(528/514 子编码)来纠正 BER 让光模块尺寸可以缩到 1U:

  • 25G Base-R: FEC on/off 由 link partner 能力决定
  • 25G RS-FEC: 528 symbol 中 514 信息 + 14 校验 → BER 1e-5 → 1e-12

启动时 AN 协商 FEC on/off,开 FEC 多 4%-5% 带宽开销但能纠错,关 FEC 反之。


CRC-32 检错不是签名

FCS 是 CRC-32 IEEE 802.3:

def crc32_eth(data):
    crc = 0xFFFFFFFF
    for b in data:
        crc ^= b << 24
        for _ in range(8):
            crc = (crc << 1) ^ (0x04C11DB7 if crc & 0x80000000 else 0)
            crc &= 0xFFFFFFFF
    return crc ^ 0xFFFFFFFF
  • 能检出所有 ≤ 31 bit 突发错(突发错 = 连续 bit 错误)
  • 能检出双 bit 错(2 bit distance 的多项式构造保证)
  • 漏检概率 ≈ 2^-32 ≈ 2.3e-10
  • 不是密码学哈希:恶意攻击者可构造碰撞 → 所以不能用于完整性校验在已被人工修改的场景

但实际硬件线上:CRC32 + 距离 ~4 是非常够用了,因为线上 bit flip 来自信道噪声而非攻击者。链路层做到 BER 1e-12,端到端的 TLS MAC(HMAC SHA-256)和 IPsec ESP 才负责"完整性防伪"。


物理层中的误码 (BER)

不同介质的典型残留 BER:

介质残留 BER
Cat6A 10G1e-10 量级
OM4 多模 100G-DR41e-12 (预计温漂高时变 1e-10)
单模 100G-ZR 80km1e-9 (FEC 关捞回来)
RoCEv2 PFC 期间类似 1e-12
5G 无线1e-6 (高层 HARQ / RLC 重传兜底)

工业上无线链路与光纤链路 BER 相差 6 个数量级,所以上层重传策略完全不同——这就是为什么 4G/5G 用 HARQ + RLC + PDCP 三层捆绑重传。


真实生产事故参考

  1. Equinix 2020:某客户接入 Cisco Nexus 9332 配置一端强制 speed 10000 duplex full,对端 auto → duplex mismatch → 吞吐只有预期 50%、大量 late collision。教训:所有接入端口启用 auto-negotiation,不要禁用
  2. 2019 上海某数据中心:单一天空调故障后机房内温度从 23°C 升到 65°C → SFP 光模块温度跨越工作点 → 大量 CRC 错误,部分链路自动 down。后续部署温度+光功率联监控(laser bias current + receiving optical power 长期趋势)就能在硬件崩塌前 24h 预警。
  3. 2017 某 IDC jumbo 坑:所有 ToR-Spine 配 9000 MTU 但有一个机架接入 leaf 误配 1500 → 大包不能转发 → 上层 TCP 重试,最终表现为 MapReduce shuffle 间隔 5-10s 一次的 fixed 延迟峰。教训:所有设备厂商默认 MTU 必须配置管理,新机柜上线时先做全路径 ping -M do -s 8972 验证。

这一章带走的东西

  1. 帧每段都是为时钟恢复 / 寻址 / 上层封装 / 检错专门设计;64 字节最小帧是 10 Mbps 下冲突窗口的物理痕迹
  2. CSMA/CD 退避算法 = 二进制指数 + slot time;现代全双工链路已不使用但保留作兼容
  3. MII/GMII/XGMII 是 MAC↔PHY 接口分层,只对硬件工程师有意义——名字里的 X/S/L/R 是 IEEE 802.3 clause 的语义
  4. CRC-32 检错 ≠ 完整性签名,链路层做到 BER 1e-12 后端到端必须用 TLS MAC / IPsec ESP
  5. 巨型帧 9000 在数据中心 5-10% 性能收益,但跨互联网会触发 PMTUD 黑洞

下一节 → 光纤、波分复用、机房布线

光纤、波分复用、机房布线

TL;DR

数据中心机房 + 城域网 + 骨干网关键瓶颈已经不在交换机 ASIC,而在光纤容量与光模块。一根光纤靠 DWDM 可同时承载 N 条波长,每条波长走相干调制可跑 100G-800G。这一节讲光纤物理、模场、相干检测、DWDM/OTN 系统与数据中心布线实践——你读完就能听懂光表厂家讲"C波段 80 波× 100G 50 GHz grid"。

思维链

从一根硅石玻璃到 12.8 Tbps 跨大陆链路的链路:

硅石玻璃 -> 全反射 -> 单模光纤 9µm 纤芯 -> C波段 1.55µm 损耗最低
       -> 激光二极管 -> 调制器 PM-QPSK/16QAM -> 单波长 100G/200G/400G
       -> 通过稀疏波分 CWDM 或密波分 DWDM 合波 -> 单纤 N 波长
       -> EDFA 掺铒光纤放大器 -> 中继器 ~80 km 一个
       -> 对端解波 -> 接收 LD -> 相干检测 -> DSP/FEC -> 100G 流

每一跳物理都有极限:光纤损耗下限 ~0.16 dB/km (1550nm);激光器漂温每 1℃ 大约 0.05 nm;EDFA 噪声指数 NF ≥ 3 dB(量子限制)。这些常数决定了任何光纤工程的极限:你只能选不同的"调制阶数 vs OSNR 接受度 vs 距离"组合。

光纤类型

类型纤芯直径模式数衰减(@850/1310/1550 nm)距离用途
OM162.5 µm多模3.5 / - / - dB/km33 m@10G老 OM1 卡
OM350 µm多模 LZ3.5 / 1.5 / - dB/km300 m@10G机架间
OM450 µm多模 LZ3.0 / 1.0 / - dB/km400 m@10G / 100m@100G数据中心
OM550 µm多模Wideband3.0 / 0.5 / - dB/km100m@100G/SWDM4新代数据中心
OS29 µm单模- / 0.4 / 0.2 dB/km10 km-1000km城域
超低损单模9 µm单模- / - / 0.16 dB/km数千 km海底

多模 → 模色散严重(不同模式光程差),距离短。单模纵向单模传输 → 数千 km。

note

"多模 vs 单模"指的是纤芯是否大到能让多个电磁模式同时存在——直径 vs 波长量级决定模数。9 µm 纤芯 + 1.55 µm 波长约 6:1,纤芯只能支撑基模(LP01),单模传输成立。50 µm 多模则可支持数百个高阶模 → 模间群速度差就是色散漂移。

损耗曲线与 C 波段

衰减(dB/km)
  |
0.6|--        O    C        L
0.4|        / \  / \       / \
0.2|       /   v    v_____/
    ——|———————|————————|————————|
       1310    1550   1625  λ (nm)
  • O 波段 1310 nm:色散最小(零色散点),短距数据中心单模接入
  • C 波段 1530-1565 nm:损耗最低(掺铒光纤放大器 EDFA 在此工作)→ 骨干主力
  • L 波段 1565-1625 nm:可向下扩展容量
  • U 波段 1625-1675 nm:监控用,正常流量不走这里

EDFA 不需光电转换直接放大光,是 1990 年代起的骨干网廉价化基石。每段 EDFA 增益 ~20 dB,噪声指数 ≈ 4-5 dB。


DWDM(Dense Wavelength Division Multiplexing)

C 波段可以并行塞 ~80-160 个波长,间隔 50 GHz 或 100 GHz。ITU G.694.1 规定:

  • 100 GHz grid: 191.10, 191.20, ..., 196.10 THz(共 50 个波长)
  • 50 GHz grid: 50 个之间加 50 个 → 共 96 个
  • 37.5 GHz / 25 GHz grid:现代 flex-grid ROADM 用
       EDFA    EDFA    EDFA    EDFA
光纤 -==-----==-----==-----==-----==--->
       ^      ^      ^      ^
   80λ 80λ ...汇集到一根单模
       ↓
   50 GHz ITU slot, 加 EDFA 同时放大所有波长
       ↓
   每 80-100 km 一个 EDFA + Optical ROADM
   1000-2000 km 后做光电再生 (3R Regeneration)

每个波长由独立激光器+调制器发出,复用后用同一根光纤。到对端解复用。

ROADMs(Reconfigurable Optical Add-Drop Multiplexer)

  • 老式 OADM:固定波长上下路,调整链路必须改硬件
  • ROADM:用 WSS (Wavelength Selective Switch) 软件定义任一波长上下路 → 自愈光环
  • 现代 CDC-F ROADM (Colorless/Directionless/Contentionless/Flex-grid):每个上下路端口任意选波长、任意方向、能映射到任意 grid → 全自动跨域光调度

相干检测与高阶调制

直接检测(DD)无法解码相位调制。相干检测:

本地激光 (LO) + 接收信号 → 90° 混合 (Hybrid) → 四路光电流
       → 数字信号处理(DSP)恢复幅度 + 相位

调制格式:

调制bits/sym (单偏振)加偏振复用后 bit/sym比特/Hz
NRZ121
DP-QPSK242
DP-16QAM484
DP-64QAM6126
DP-256QAM8168

5G 数字相干光模块功耗大(10 W+),DSP 占大头(>50% 功耗),所以 400G+ 模块散热设计是数据中心产品差异化重点。

note

PAM4 调制(4-level Pulse Amplitude Modulation)= 1/2 比特/符号的强度调制,DSP 极简只需要判决,不需要相干接收。100G-DR4 = 4×25Gbps PAM4 多模;400G-DR4 = 4×100Gbps PAM4。PAM4 适合 100m - 10km,相干适合 80 km-3000 km。两者中间的 10-40 km 段成为开发竞态。


数据中心光模块封装

形式通道数 × 单通道速率用途
SFP+1 × 10G万兆网卡/交换
SFP281 × 25G25G 服务器接入
SFP561 × 50G PAM450G 接入
SFP-DD2 × 50G PAM4100G 接入 + 双 lane
QSFP+4 × 10G40G
QSFP284 × 25G100G
QSFP564 × 50G PAM4200G
QSFP-DD8 × 50G PAM4 / 8 × 100G400G/800G
OSFP8 × 50G PAM4400G
CPO (co-packaged optics)直接绑 Switch ASIC800G/1.6Tbps

数据中心 Tor 到 server 25G、到 spine 100G/400G 已成主流;2024 年 800G 开始大规模部署,1.6T CPO 在 NVIDIA Quantum-X / Ultra 集群中试点。

直连铜缆 vs 有源光缆 vs 光模块

  • DAC (Direct Attach Copper):纯铜,0.5-5 m,无源最低延迟,0.1W,机架内 ← 必选项
  • AOC (Active Optical Cable):有源光缆,光模块内嵌线缆,到 100m
  • 光模块:插交换机端口,外配光纤(最灵活)
  • CPO (Co-Packaged Optics):光模块集成进 ASIC 包内 → 1.6Tbps 时代的关键路径

机房布线实践

机架内:10G/25G 铜 DAC (无源)
TOR 上行:100G AOC / 400G AOC(300m 内)
Leaf-Spine:400G AOC / MPO-12 单模光缆配多芯
跨机房:单模光,OTU4 100G; 或相干 400G/800G over DWDM
跨城域:DWDM over 单模光纤(共享一根多业务)
跨大陆:海底光缆,每 80-100 km 一个 EDFA + 中继器;中继器海底供直流电

Leaf-Spine fat-tree 的光纤计数

3-tier spine-leaf 400G 双归:一个 2000 节点 PoD,每台 leaf 有 32 个 400G 口,56 台 leaf,每台 16 上行 16 下行 → 56×16×2 = 1792 条 400G;如果做 100% 东西向带宽总容 ≈ 358 Tbps 单方向。布线密度足够压垮任何走线架,所以光模块散热与标签管理是大运维痛点——每一对模块入网前要校验波长 + 温漂曲线。

10 km 100G-ZR 单模链路:

发射功率: 0 dBm
光纤衰减: 10 km × 0.22 dB/km = 2.2 dB
连接器损耗: 4 个 × 0.5 dB = 2.0 dB
熔接损耗: 2 个 × 0.1 dB = 0.2 dB
-----
到达接收端: 0 - 2.2 - 2.0 - 0.2 = -4.4 dBm
接收灵敏度: -20 dBm
Margin: -4.4 - (-20) = 15.6 dB  (留 5-10 dB 应付老化/温度变化)

≤ -20 dBm 的根本启动不了——这就是为什么"重启网口看看"在很多光链路故障中无用。


真实生产事故参考

  1. 2018 上海某 CDN:跨机房搬迁后偶发误码率升高 → 排查发现跳纤盘卷曲半径过小 (<3cm) → 弯曲损耗加 5 dB → 接收灵敏度下界触发丢弃。教训:所有跳纤必须有曲率半径规范 (≥3 cm),运维 SOP 要包含光链路 OTDR 测试。
  2. 2020 Google:海缆站 SSE (Single-Ended Source) 激光漂温过快,每 2 个月需要换模块;改用 Raman 放大器替代 EDFA 后温度敏感性下降。 → 教训:研发新光路方案时关注温度漂移模型。
  3. 2019 AWS US-East:某次升级光模块固件后整批 40G 模块冷启动后误码率超阈。原因:新固件改变了 PMD 偏置电流算法,温升 20℃ 后漂出工作点。教训:量产光模块固件升级前必须做冷热启动循环测试 (至少 100 次 -10->60℃ 的循环)。

这一章带走的东西

  1. 单模光纤 C 波段 + EDFA + DWDM 是骨干廉价基础,开通 50 波 100G 跨大陆成为日常操作
  2. 调制从 QPSK → 16QAM 到 PAM4 一路加频谱效率 → 距离越短用越简单的调制
  3. QSFP-DD / OSFP / CPO 是数据中心带宽增长曲线;CPO + LPO (Linear-drive Pluggable Optics) 是 22.4T 时代的赛点
  4. 光纤布线是面积、电源、热量的三重博弈:温升让 SFP 漂出工作点 → 链路抖动,必须做 OTDR + 温度监控兜底

下一节 → IP / 路由

IP / 路由

TL;DR

IP 是 TCP/IP 协议栈"L3 不可靠、全互联、尽力交付 (best-effort)"的抽象。它把所有底层物理介质屏蔽掉,给上层提供 32 位地址(v4)/ 128 位地址(v6)加路由表查询的能力。这一节读完后你应该能看懂 ip route get、调试路由黑洞、解释 privacy extension、回答 BGP vs OSPF 收敛速度差异。

思维链

应用 -> socket -> TCP -> IP -> Ethernet
                   ↑
              ip_route_input() 查路由表
              ↓
            下一跳 -> ARP/ND -> 帧

DNS 给出名 api.example.com -> 通过 socket 给应用 IP -> IP 层判断目标路由:

  • 同子网:直接 ARP 解出 MAC,封装帧
  • 跨网段:查路由表 -> 下一跳 -> ARP 下一跳 -> 转发

每一跳路由器都重复查表 -> 改 TTL -> 重算 checksum -> 转发。一台路由器每秒可以处理 10M-100M 包(有 TCAM 卸载可达线速;软件路由只有 1M-10M pps)。

这一节要回答的问题

  1. IPv4 头部 20 字节每个字段有什么用?为什么 IPv6 去掉了 checksum 反而更快?
  2. ICMP 在分片 / PMTUD / traceroute 里到底做了什么?为什么大量 IDC 把 ICMP 整体黑洞掉 PMTUD 必崩?
  3. ARP 一秒钟怎么解析、为什么 ARP cache 5 分钟后掉;邻居发现 NDP 比它强在哪儿?
  4. DHCP 跨网段必须 relay agent + Option 82,Option 82 是怎么把 client 接入端口传给集中服务器的?
  5. OSPF 是什么类型协议,为什么 ISP 不喜欢在 AS 大规模用 OSPF 而用 IS-IS?
  6. BGP 是什么类型协议,为什么能承载全球 ~1.1M v4 前缀,路径属性 AS_PATH / LOCAL_PREF / MED / communities 怎么决策?
  7. NAT 在 conntrack 表里吃 1 KB / 流,1M 表 = 1 GB RAM;单体 NAT 能撑多少 concurrent?运营商用 CGN 是怎么后向退化到 Symmetric NAT 影响 P2P?

这一节骨架

  • IPv4 / IPv6 / ICMP:头部字段、扩展头、邻居发现、SLAAC、ICMP 与 PMTUD 关键
  • DHCP / ARP / NDP:地址解析、地址分配、跨网段 relay、安全陷阱
  • BGP / OSPF 路由:链路状态 vs 路径向量、收敛对比、生产场景选择
  • NAT 与 conntrack:5 元组 conntrack 表、Cone vs Symmetric NAT、P2P 打洞失败根因
  • DNS:递归/权威分层、TTL 与缓存、CDN 调度、DNSSEC / DoH / HTTPDNS

IPv4 / IPv6 / ICMP

TL;DR

把 IP 头部的每个字段、可选 extension header 系统讲清——为什么 IPv6 比 IPv4 转发更快(去 checksum + ext head chain)、IPv6 实际为何一直没普及。重点讲:IPv6 extension header 链式结构、ICMPv6 比 ICMPv4 多的功能、SLAAC、地址租约。

IPv4 头部:每个字段

bits字段用途
4Version总是 4
4IHL头长除 4B,最小 5 = 20 字节
8TOS / DSCP现行 DSCP/DiffServ(前 6 bit)+ ECN(后 2 bit)
16Total LengthIP 头+负载总字节,最大 65535
16Identification分片辨识
3FlagsDF (Don't Fragment) / MF (More Fragments)
13Fragment Offset分片序号(8 字节单位)
8TTL转发跳数限制
8Protocol上层协议号 TCP=6 UDP=17 ICMP=1 OSPF=89
16Header Checksum头校验和(非密码学安全)
32 × 2Source/Destination

IPsec AH/ESP

AH (proto 51):认证头,完整性签名。ESP (proto 50):加密负载。VPN 实际部署中 ESP 占绝大多数。

note

分片相关字段(Flags / Fragment Offset)只在 IPv4 出现 → IPv6 头部就没有,必须由 source 提前分片 PMTUD,路由器不能分。这是 IPv6 的设计哲学——把分片责任推给端点,路由器只转发 → 转发芯片极简。


IPv6 头部:每个字段

bits字段
4Version=6
8Traffic Class
20Flow Label(实验性,至今低采用)
16Payload Length
8Next Header
8Hop Limit
128Source
128Destination

总长 40 字节固定 + ext header 链。注意:Payload Length 是 IPv6 头后的字节,不是包含头——jumbo payload 用 0 标记然后 hop-by-hop 头扩展。

Extension Header 链

+--------------+      +-------------+      +------------+
| IPv6 header  | ---> | Hop-by-Hop  | ---> | Routing    | --> dest options / fragment / AH / ESP / TCP
| Next=Hop-by-Hop |  | Next=Routing|      | Next=DstOpt|
+--------------+      +-------------+      +------------+

常见 NH 值:

NHHeader
0Hop-by-Hop
43Routing (含 SRH Segment Routing Header)
44Fragment
51AH
50ESP
60Destination Options
6TCP
17UDP
59No Next Header

路由器只会读 Hop-by-Hop 与 Routing;其它 ext header 至目标端才解 → 转发芯片不必关心,转发比 IPv4 简单。但很多中间设备(防火墙、负载均衡、IDC 出口)不正确解析 ext header 链 → 实际部署前一定要测——如果你 SRv6 网络出口配错一段,第 9 跳才开始丢包。

warning

防火墙针对 IPv6 ext header 的处理 vendor 间差异巨大。某次某客户 SRv6 流量过 F5 LTM 在 BH 处理 outer header 时炸链——因为 F5 的 iRule 解析逻辑硬编码假设只有 1 个 extension header。


ICMP

ICMPv4

TypeCode含义
00Echo Reply (ping)
80Echo Request
3*Destination Unreachable
1Host Unreachable
3Port Unreachable (UDP 没人监听)
4Fragmentation Needed + Next-Hop MTU (PMTUD 关键)
110Time Exceeded (TTL=0,traceroute 用)
5*Redirect (现代多关闭防攻击)

ICMPv6 (RFC 4443)

更丰富:邻居发现、MLD、PMTUD 必须 (Type 2 Packet Too Big),否则没有人触发源端 MTU 降低。

邻居发现协议 NDP 一并取代 ARP/ICMP redirect:

  • Router Solicitation/Advertisement (RS/RA)
  • Neighbor Solicitation/Advertisement (NS/NA)
  • Redirect
  • Multicast Listener Discovery (MLD)

ndisc6 命令查邻居:

$ ndisc6 -r fe80::1 eth0
Soliciting fe80::1 (fe80::1) on eth0...
Target: fe80::1
  Link-layer address: 02:00:00:00:00:01
  from fe80::1

SLAAC + DHCPv6 工作流

IPv6 自动配置:

host up
  -> RS multicast ff02::2        告诉路由器:"发广告"
  <- RA from router               带 network prefix + flags
  -> if A flag set: SLAAC         用 prefix + interface ID
  -> if M flag set: DHCPv6        请求 IPv6 + DNS
  -> if O flag set: DHCPv6 拿 DNS 亦可
  -> DAD                          duplicate detection
   -> 如果别人在用 -> 重新算 ID

Privacy Address

  • 普通接口 ID 派生自 MAC → 移动设备跨网段仍能被指纹识别(同一 MAC 同一界面始终同 IPv6)
  • RFC 4941 Privacy Extension:临时地址每日轮换,每次连接换一个,路由器/外部观察者只看到临时地址
  • 默认 Android 13 / Win 11 开启;Linux addr_gen_mode=1 启用
# 验证
sysctl net.ipv6.conf.eth0.use_tempaddr
# 2 = generate + prefer; 1 = generate but prefer stable; 0 = off

note

Cisco/D-Link 等老设备固件不支持临时地址扩展,企业网络实际部署时需 SLAAC + DHCPv6 双路 + 同机加权;电信运营商还把客户 IPv6 路由前缀给 7 天轮换,让穿地址本身不在日志里被追溯。


ICMP 大误解

  1. "ICMP 必须丢,安全" → 攻击者 TCP RST 仍能截断,关 ICMP 反而让 PMTUD 黑洞、让 traceroute 失效。正确做法:开 ICMP echo + ICMP Packet Too Big + ICMP Time Exceeded;关 Redirect。
  2. "Traceroute 暴露网段信息,要禁" → 现代互联网 Trace 仍是必要的运维手段,封 ICMP 反而让工程师排查问题更慢。Traceroute 类工具还能借助 TCP/IP 层做出来,封了 ICMP 没意义。
  3. "ICMP 6 跟 ICMP 4 一样" → ICMPv6 是 IPv6 必备(NDP/MLD/PMTUD 全靠它),关了就连邻居发现都挂。

但 ICMP redirect 在公网应关闭(RFC 1122 已建议关);K8s Calico 也用代理 ARP,不要和 L2 ARP 混乱。


真实生产事故参考

  1. Cloudflare 2018 7/3 Path-MTU discovery 黑洞:某 IPv6 路径上一台中间设备把 ICMPv6 Packet Too Big 全黑名单 → 大包(1452+)丢,小包通 → 客户看见的"边缘"是图片渲染一半卡住。修复:所有 PoP 边缘开源 PMTUD heartbeat 探测,发现黑洞后 fallback 到 1280 (IPv6 min MTU) + TCP MSS clamping。
  2. AWS 2020 IPv6 ext header 黑洞:S3 dual-stack 前置 LB 不支持 SRH (segment routing) → 客户的 SRv6 出口流量到 S3 后被 LB 全部丢。修复:边缘做 SRv6 decapsulation 再前传 IPv4 over SRv6。
  3. Equinix 2019 SLAAC Privacy 关闭:客户公司强制关闭 Privacy Extension 后所有员工的移动设备 IPv6 地址被广告平台跨网络识别 → 后续隐私诉讼。Equinix 给出的最佳实践是默认开启 RFC 4941。

这一章带走的东西

  1. IPv6 头部去 checksum 是为了转发加速(路由器不再每跳重算 checksum)
  2. IPv6 ext header 可插入 1 个或多个;路由器只看 Hop-by-Hop / Routing,其他至目标端处理
  3. ICMPv6 比 ICMPv4 多承担邻居发现 (NDP) + PMTUD 强制要求
  4. SLAAC + Privacy 当前已默认;企业网部署强身份验证时考虑关 Privacy,但需权衡隐私
  5. Jumbo gram 在 IPv6 framework 内允许潘 > 64K,但中间设备 99% 不支持

下一节 → DHCP / ARP / NDP

DHCP / ARP / NDP

TL;DR

IP 地址不是绑定网卡的物理属性;地址解析、地址分配靠 ARP(IPv4)和 NDP(IPv6)+ DHCPv4 / DHCPv6/SLAAC 完成。这一节讲协议细节、安全陷阱、企业网络实践。

ARP 概览

ARP 不是 L2 协议也不是 L3 协议;它是"用 IP 解 MAC"的桥接:

  1. 主机 A 想发到 192.0.2.5
  2. 查 ARP cache,没命中 -> 发广播 Who has 192.0.2.5? (EtherType 0x0806)
  3. 主机 B 应单播 192.0.2.5 is at 11:22:33:44:55:66
  4. A 在 cache 写入 IP→MAC,设 15-30 min TTL
  5. 之后 A 直接封装 Ethernet 帧发送

ARP packet 结构

| HW type (16) | Proto type (16) | HW len (8) | Proto len (8) | Op (16) |
| Sender hardware addr (HWlen)  | Sender protocol addr (Plen)            |
| Target hardware addr (HWlen)  | Target protocol addr (Plen)            |

类型 1=Ethernet, 2=IPv4, op 1=request, 2=reply. ARP 包 42-60 字节(不够 64 需要链路层 padding)。

Gratuitous ARP / ACD

主机开机或 IP 改变后发一个 ARP 广播宣告 my IP is at my MAC,未请求的广播。用于:

  • 防止冲突(ACD,Address Conflict Detection)
  • 高可用切换:主备 VIP 漂移后让交换机刷新 MAC 表(这点 Linux 的 keepalived 默认会发)

DHCPv4 流程

client -> DISCOVER (broadcast UDP 67)
servers -> OFFER (broadcast/unicast, UDP 68)
client -> REQUEST   (挑一个,含 server id)
server -> ACK       (含 lease_time, options)

DHCP Options 丰富:

CODEOption备注
1Subnet Mask
3Router (default gateway)
6DNS Servers
12Hostname
51Lease Time
53DHCP Message Type必备
54DHCP Server Identifier
82Relay Agent InformationDHCP Snooping 用
119Domain Search List

Lease 续约

T1 = 0.5 * lease_time        -> 看门狗,找原 server 续
T2 = 0.875 * lease_time       -> 原 server 失联,找任何 server
lease_time 到期              -> 必须放掉 IP

默认租约 24 小时;本机查看:

$ sudo cat /var/lib/dhcp/dhclient.leases  # Debian/Ubuntu
$ sudo cat /var/db/dhcpd_leases             # macOS
$ nmcli dev show eth0 | grep IP4.ADDRESS

DHCP Relay(RFC 3046 Option 82)

L3 网段到 DHCP server 跨网段时,relay agent 把 client 广播转单播发到 server:

client ----broadcast----> relay
  relay -unicast to DHCP- with Option 82 (Agent Circuit ID + Remote ID)
        <- response from DHCP with Option 82
relay ----broadcast-----> client

Option 82 是企业网大规模集中 DHCP 的基础:在大楼 L2 接入交换机上启 DHCP Snooping,所有 DHCP 包按端口抓包 + 在 Option 82 里塞入口标识,集中 DHCP server 能据此分出每个接入端口实际在公司哪个楼层。Cable MSO 用 DHCP Option 82 给家里 CM (cable modem) 分 IP


ARP 安全陷阱

ARP 欺骗(ARP Spoofing)

LAN 上任何主机可发伪造的 ARP Reply:"192.0.2.1 is at attacker_mac",邻居 ARP cache 被污染,所有去 192.0.2.1 的帧到达攻击者。Linux 工具:

# arpspoof from dsniff 套件
sudo arpspoof -i eth0 -t 192.0.2.10 192.0.2.1
# 现在所有 192.0.2.10 -> 192.0.2.1 的包都到攻击机

防御:

  • 交换机 Dynamic ARP Inspection (DAI),只允许 DHCP snooping 表里记录的 IP/MAC 映射发 ARP Reply
  • 主机静态 ARP——但移动场景难维护
  • 802.1X + MACsec 加密链路层数据 → 二层攻击者看不到加密内容

note

MACsec (IEEE 802.1AE) 在 L2 做 AES-GCM 加密 → 即使接入了恶意设备,所有跨 link 的帧都是密文。数据中心管理网 / 5G 时代电网 BN(backhaul network)常用。

Proxy ARP

某些路由器(如 K8s Calico、docker0 bridge)开启 optional "代理 ARP":对其它子网里 IP 也能应 ARP(拿自己的 MAC 当网关)。

warning

配错会让 client 觉得到外网直连,反而漏了路由器的存在。K8s Calico 用 BGP + Proxy ARP 解决 CNI 子网跨节点访问,但要确保 iptables/IPVS 规则也跟得上,否则冲突时容易丢包。


IPv6 NDP(取代 ARP)

NDP 用 ICMPv6 over IPv6 link local (fe80::/10):

host sends NS to solicited-node multicast ff02::1:ffXX:XXXX
target responds NA from its link-local src
host updates Neighbor Cache

solicited-node 组播用最后 24 bit MAC 派生地址,比纯广播负载低很多:8 台主机分别对应 8 个 solicited-node 多播地址,互不打扰。

DAD(Duplicate Address Detection)

加新地址前发 NS 询问此地址在不在;2s 没人回 -> 接管。Tentative 状态保持期约 1s。

warning

在 WiFi 漫游 / 中断场景下,DAD 会延迟到几秒,几秒内服务断。优化1:RFC 7527 Optimistic DAD 让 client 紧急时使用 tentative 地址。优化2:RFC 4429 SEcure Neighbor Discovery (SEND) 用 RSA 地址防伪。

路由器发现 (SLAAC)

参见 IPv4/IPv6/ICMP 那节。RA 报文 RA flags 控制:

  • A flag → 用 SLAAC
  • M flag → 用 DHCPv6
  • O flag → 用 stateless DHCPv6(只拿 DNS)

NDP 攻击

NA 欺私、伪造 RS/RA -> 整网关路由都被劫持。 RA Guard 在交换机丢弃 client 端口的 RA。Cisco/Aruba 等大厂商接入交换机必启。


DHCPv6 vs SLAAC

维度SLAACDHCPv6 stateful
配置朴实自动需部署 DHCP server
DNS NTP 配置通过 RA RDNSS option 或 stateless DHCPv6通过 options
日志可追溯可定位 IP 签发历史
IPv4 + IPv6 共存自动 SLAAC需编排
临时地址(Privacy)与 SLAAC 配合佳DHCPv6 不能签 Privacy 地址

企业网络部署最佳实践:SLAAC + stateless DHCPv6 (拿 DNS + sip战)。运营商通常用 SLAAC + DHCPv6-PD (Prefix Delegation),给家里下发 /56 或 /60 一段继续分给客户家中各个 subnet。


真实生产事故

  1. CERNET 2016 一个 EchoStorm 攻击: 一个学生的笔记本被植入恶意软件,发 Gratuitous ARP 把自己宣告为网关 → 所有学生断网 → 被 ARP table 老化迅速传播,校园网 2000 终端中毒样式 flood。修复:所有接入交换机启 DAI + DHCP Snooping + 802.1X。教训:任何用户接入网关必须有二层 ARP 防御。
  2. AWS managed DHCP leaked KV 2017:Option 82 在某些 region 填错接口 ID,明明 Instance 在 us-east-1d,Option 82 显示 us-east-1b → 客户跨 AZ 链路计费正确性 ID 混乱。修复:AWS 后台重建所有 Option 82 + DHCP 流水。教训:只要 Option 82 进入业务关键路径,必须有独立审计。
  3. CDN 2018 RA Attack: 某 CDN POP 在客户服务器被 over-permissive 配置允许 RA → 网络被劫,部分客户失败。修复:所有客户端 → RA Guard(接入交换机端口规则)+ Ban 来路异常的 RA。教训:IPv6 安全模型与 IPv4 不同,但是迟 IPv6 部署反而激发了安全性提出更严。

这一章带走的东西

  1. ARP 是 ~25 µs 的 LAN 互信,但 MACsec 是新加 L2 防御
  2. DHCP Option 82 是跨网段 DHCP 关键 + 企业网 Cable MSO 大量用
  3. NDP 比 ARP 多了 DAD + 组播 + 多选项,且 RA/RS 取代广播减少了 LAN 单包负载
  4. DAI + RA Guard 是企业级 L2 安全面,与 802.1X 配合构成纵深防御
  5. 部署 DHCPv6 vs SLAAC 时视场景选;类 IPv4 的运维ásiongan 仍有"日志可追溯"价值

下一节 → BGP / OSPF 路由

BGP / OSPF 路由

TL;DR

OSPF 是企业/AS 内部的路由协议(IGP),BGP 是互联网之间的路由协议(EGP)。两者解决的问题相似——把包送到目的网段——但抽象层次、可扩展性、强调路径选择 vs metric 的策略完全不同。

OSPF

OSPF 是链路状态协议:

  1. 每个路由器泛洪自己直连链路的"链路通告"(LSA)给整个 area
  2. 每个节点都拿到完整拓扑(LSDB)
  3. 全区域同步 LSDB → 一致性
  4. 节点本地跑 Dijkstra 算最短路径
  5. 计算 ECMP(等价多路径)
   +-----------LSA-----------+
   v                          ^
router_a 相当于  LSDB 同步 router_b
   ↓
Dijkstra → SPF tree → 路由表

每个 LSA 还要承载:链路代价、可用 IP 等元数据。开销从 1 起步,最大 65535(24 比特)。

Area 分层

OSPF 用多 area 减小 LSDB 与 Dijkstra 规模:

            Area 0 (Backbone)
              ┃  ┃  ┃
              │  │  │
        Area 1  Area 2  Area 3 (分别是与 Backbone 相连)
  • Area 0 是 backbone,所有 area 必须与 area 0 有连接或通过 virtual-link 临时桥接
  • ABR(Area Border Router)汇总 area 间 LSA,避免把详细 LSDB 跨 area 传播
  • ASBR(Autonomous System Border Router)把外部路由灌入 OSPF

LSA 类型

TypeName用途
1Router-LSA节点直连链路(本 area 内传播)
2Network-LSA广播段子网(DR 通告)
3Summary-LSAABR 宣告跨 area 前缀
4ASBR-summary跨 area 到 ASBR 的路径
5AS-external-LSA外部路由
7NSSA External LSANSSA 内特殊外部

OSPF cost 极限

默认 cost = 100 / bandwidth(Mbps):1Gbps cost=1;10Gbps cost=1(实际仍 floor 到 1)。操作员手工调 cost 控制路径偏好。

! Cisco IOS 配置
interface FastEthernet0/0
 bandwidth 1000          ! 报 1Gbps
 ip ospf cost 5          ! 改 SPF 选路权重

note

OSPF 实际选路 metric 单调到 1M 已经饱和(多 1G 链路 cost=1 都视为等价)→ ECMP 多路径不会因 metric 略偏好。只能手工分 interface tweak cost 让上层路径有差异。


IS-IS 与 OSPF

IS-IS 也链路状态、同 area 内全反映转、Dijkstra 计算。差异:

  • IS-IS 在 OSI 协议栈 L2 上跑(CLNS),不直接和 IPv4 强绑
  • 一个 IS-IS 进程可同时承载 IPv4 + IPv6,OSPF 通常要 v2/v3 分进程
  • 大型 ISP 更爱 IS-IS,扩展性被认为更强(数百到数千节点 LSDB 单一 area OK)

主流 ISP(Verizon/Comcast/中国移动/中国电信)核心都用 IS-IS;企业用 OSPF。


BGP

Path Vector

距离向量传递"AS_PATH 累积列表",整个 path 决定可达,不只是 metric。所以叫 Path Vector Protocol。

BGP 基于 TCP port 179。两台路由器直连通过 TCP 179 建立 BGP session,互相宣告 IP prefix + path attributes。

BGP Message

Type用途
OPEN握 hand,版本号、AS、Hold Time (默认 90s)
KEEPALIVE保活
UPDATE宣告/撤回 routes
NOTIFICATION出错关闭 session
ROUTE-REFRESH请求对端重新发

关键 Path Attributes

属性说明选择权
ORIGINIGP/EGP/INCOMPLETE-
AS_PATH走过的 AS 列表(防环:本 AS 已在 path 则拒)选更短
LOCAL_PREF本 AS 进出策略选更高
MED跨 AS 退出策略选更低
NEXT_HOP下一跳 IP-
COMMUNITY标签路由(NO_EXPORT / NO_ADVERTISE 等)-

决策顺序(Best Path Algorithm)

1. LOCAL_PREF 高的胜
2. AS_PATH 短的胜
3. ORIGIN 类型优先级高的胜 (IGP > EGP > INCOMPLETE)
4. MED 低的胜(仅当第一 AS 相同才比)
5. 到 NEXT_HOP 的 IGP 距离短的胜
6. EBGP 路由胜过 IBGP
7. OLD 优于 NEW(避免抖动,bandwidth jitter reduction)
8. router_id 低胜

warning

MRAI (Min Route Advertisement Interval) = 30s:BGP 默认每 30s 才发 route 广告,限制 churn → 收敛慢。这就是为什么"路由抖动每 30s 你才看到一次"。生产 BGP 改 0 或 5s 加快。


BGP Pluggable Extensions

BGP-LS

BGP-LS 用 BGP UPDATE 编码链路拓扑分享给集中 SDN 控制面,控制器有了全网拓扑后下发 PCEP/segment routing 流表。SR-MPLS 一类方案就是 BGP-LS + Path Computation Element 协议 (PCEP)

BGP EVPN

数据中心的 BGP EVPN 用 BGP 控制面交换 MAC/IP,把 L3 路由层面统一到一个协议。Spine-Leaf + EVPN 是当前最大型数据中心的标准操作,已被 Cumulus / Arista / Cisco / FRR 全面支持。替代了 STP + VxLAN + IGMP Snooping + 早期 TRILL

EVPN 的核心:通过 BGP controlplane 同步 MAC/IP,data plane 用 VxLAN 封装,去做"hash 等化负载均衡 + concentration of fail"。

Segment Routing (SR)

源路由 in MPLS 标签栈里写"我下一站去 N1, 然后 N2, 然后 N3",中间路由器只看栈顶标签转发,不用维护每流状态。SR-MPLS / SRv6 (segment in IPv6 Routing Header) 2 种实现。


选型:OSPF 还是 BGP?

答案:两者都要。

  • AS 内(IGP):OSPF 或 IS-IS,毫秒级收敛,目标最快收敛最少包丢失
  • AS 间(EGP):BGP,秒分钟级收敛,目标可达性 + policy (不希望被运营商多绕了一条路去付费)

数据中心 Spine-Leaf 部署 BGP 也是"用 BGP 扮演 IGP"——加 RR (Route Reflector)、宣告自己、local_pref/MET 控制。AWS 风格简洁地利用 BGP 的可扩展性。Meta/JPM/Facebook 数据中心内部都跑 BGP + EVPN,几乎不用 OSPF了。

为啥数据中心不再用 OSPF?

  • OSPF area 0 LSDB 同步在 large spine-leaf 网里每次拓扑变化都触发 SPF 计算 → ~1 秒内计算量爆炸
  • 数据中心 spine-leaf 全 ECMP, BGP naturally 提供,OSPF 需要 tweak
  • EVPN + VxLAN 需要 L2 over L3 传播 MAC/IP,BGP EVPN 是事实标准,OSPF 没有

收敛时间对比

协议健康→故障检测收敛时间
OSPF 默认Hello Interval 10s Dead 40s几秒到秒
OSPF BFD50ms 探测30ms-1s
BGP 默认Hold Time 90s几秒到几十秒
BGP+BFD50ms-300ms 探测 + Connected + 退避1s-2s

BFD (Bidirectional Forwarding Detection) 是任意链路协议都可叠加邻居存活探测,与 BFD 重叠的是 GR (Graceful Restart) / NSR (Non-Stop Routing):路由器主控 failover 时让邻居保持会话不撕裂。


实战:调 OSPF 切换

! 路由器 1 关闭链路 -> 路由器 2 在 40s 内才会发现 OSPF dead
! 启用 BFD
router ospf 1
 bfd all-interfaces

! 调稀疏 SPF throttle (怕 churn 不怕慢)
router ospf 1
 timers throttle spf 10 100 200

(0ms initial, 100ms wait-min, 200ms wait-max)

# Linux 看 OSPF 邻居 (FRR/Quagga)
vtysh -c 'show ip ospf neighbor'

真实生产事故参考

  1. Pakistan Telecom 2008:误把 YouTube 的 /24 prefix 注入 BGP AS_PATH 空,全球 BGP 路由器向 YouTube 改路到巴基斯坦 → 全球用户 2 小时进不了 YouTube。教训:BGP 没有 RPKI ROA 强制 RPKI。RPKI 现已广泛部署,过滤掉 ROA 不符合的 routes。
  2. YouTube 2017 雪球: 某次 BGP 配置 typo 引起广泛 churn + RPKI 清理 + 链路抖动 → 全球 BGP 路由表 几秒钟抖动湿式 churn 让所有 ISP 同时 recalculating best path。修复:BGP add-path + BGP-LS 监视收敛速率,远离 churn 重置事件。
  3. Cloudflare 2019 6/24:配置变更使 BGP 撤回 RPKI 失效前缀 → 部分全球用户连不上 → 27 分钟。修复:BGP 配置变更通过 dry-run emulator 测试 + RPKI 自动化校验。

这一章带走的东西

  1. OSPF Link-State Dijkstra,可扩展性限定在 area 数 (一般每 area ~200 节点)
  2. BGP Path-Vector policy,可承载 ~1.1M+ v4 前缀
  3. BGP 不快、BFD 帮加快 + GR/NSR 帮 failover 不撕裂
  4. BGP EVPN 已成新一代 DC L3 over leaf 标准;OSPF 在 DC 退潮
  5. RPKI + BGP-LS + segment routing 是 2020 后路由协议演化的三大方向

下一节 → NAT 与 conntrack

NAT 与 conntrack

TL;DR

NAT 让多个内网主机共享一个公网 IP,背后是 Linux nf_conntrack 维护的连接表。这一节讲 NAT 类型对应用的影响、conntrack 数据结构、性能天花板、Cone vs Symmetric NAT 对 P2P 的影响——以及为什么运营商级 NAT 让所有 P2P 客户端都不得不走 TURN relay。

为什么 NAT 存在

IPv4 地址空间 32 bit,~43 亿。80 年代没人想到每台设备都上 IP。NAT 出来用 RFC 1918 私网地址 + 端口复用,救了 IPv4 的命。

但 NAT 打破了 IP 端到端原则:

  • 服务器无法主动连接 to NAT 后的客户端
  • IP 包并非"路由对称"——同一会话的出向/入向可能走不同 NAT 设备
  • 应用层 IP 信息泄漏(SIP SDP 内嵌公网地址)+ 中间设备需 ALG 做 NAT 翻译

note

真正"破"了 end-to-end 的从来不是 NAT 本身,而是 RFC 1918 私网地址。NAT 只是补丁让私网地址上互联网。一旦 IPv6 普及,私网办法不会被取消(CDN、企业隔离都需要),所以 NAT 模式即使 IPv6 普及也不会消失。


NAT 类型(RFC 3489)

类型是否固定端口跨主机端口复用
Full-cone
Restricted-cone
Port-restricted cone
Symmetric否(按目标分配端口)

Cone NAT(NAT 给同一内部 socket 总分配同一外部 socket),P2P(如 STUN)容易打穿。 Symmetric NAT 按目标地址给不同外部端口,STUN 拿不到对应 → 对等打洞失败。

移动运营商多是 Symmetric NAT,P2P 应用只能求助 TURN relay。这就是 WebRTC (Chrome 默认 P2P) 在企业网经常失败的原因。

Linux iptables MASQUERADE 默认是 Port-restricted cone,对 UDP 这个表现尤为明显:单 IP+port 上的 P2P socket 在 STUN 配合下可以打穿到对方 cone NAT,对另一端 Symmetric NAT 一起打则失败。


Linux NAT 实现

iptables -t nat -A POSTROUTING -o eth0 -j MASQUERADE

底层依赖 nf_conntrack 模块。

Conntrack 结构

struct nf_conn {
    struct nf_conntrack_tuple_hash hash;
    struct nf_conntrack_tuple tuple[2];   // 原/反向
    unsigned long status;                 // status bits: SEEN_REPLY, ASSURED
    unsigned long timeout;                // 老化时间 (jiffies)
    u_int32_t mark;
    ...
};

Hash 表项:

[src_ip, src_port, dst_ip, dst_port, proto]
                ↕
[src_ip, src_port, dst_ip, dst_port, proto]   (反向)

每条 socket → 一个 conntrack entry。

默认 timeout

协议状态timeout
NEW (no reply)30s
ESTABLISHED (TCP)Linux 5d 默认(432000s)
ASSURED (双向见过)432000s
UDP unreplied30s
UDP assured180s

UDP assured 5 min timeout 在大量短连接场景下非常容易被 DDoS 攻击爆 conntrack 表。Amazon 防 DDoS 反射攻击的文章都是聊这数(5000 个 UDP query × 5 min = 200 万条 conntrack)。

检查表满

$ cat /proc/sys/net/netfilter/nf_conntrack_max
262144
$ cat /proc/sys/net/netfilter/nf_conntrack_count
240          # 当前条目
$ sudo conntrack -L | head
tcp      6 431998 ESTABLISHED src=10.0.0.5 dst=1.1.1.1 ...
udp      17 173  src=10.0.0.5 dst=8.8.8.8 ...

表满了 → 新建连接被丢 (nf_conntrack: table full, dropping packet in dmesg)。

生产时易坑:sysctl 默认 max=262144 对 6 GB RAM 机器合适,但负载暴增时必须监控 nf_conntrack_count / nf_conntrack_max ratio。

调参

# 大型 L7 proxy 调高 max + 加 hash cache
echo 1048576 > /proc/sys/net/netfilter/nf_conntrack_max
echo 262144 > /sys/module/nf_conntrack/parameters/hashsize
# TCP established timeout 缩短到 2h(防僵尸连接占 Conntrack)
echo "net.netfilter.nf_conntrack_tcp_timeout_established=7200" >> /etc/sysctl.conf
# 表示如果 NFSv4 不用就关 30s(UDP)
echo "net.netfilter.nf_conntrack_udp_timeout=10" >> /etc/sysctl.conf

性能瓶颈

nf_conntrack per-flow cost ≈ 1 KB 内存 + 一次 hash 查找 + 一次原子操作。300B 包规模下:

  • 装 1M conntrack 表 (= 1 GB RAM) 可处理 ~1M Cps hit
  • 性能瓶颈在 conntrack 锁 + cache miss

note

大型网关一般关掉 conntrack,用 eBPF / DPDK -> nftables + offload + XDP bypass kernel 网络栈。Cilium + eBPF 让 Kubernetes node 完全跳过 iptables。每跳节省 10-100µs。


NAT 与 conntrack 的关联接口

  • iptables SNAT/DNAT/MASQUERADE/REDIRECT 全依赖 nf_conntrack
  • ipset 不依赖 conntrack,性能更线性
  • bpfilter / eBPF maps:可以用 LPM trie 替代 conntrack 实现 host NAT

CGN (Carrier-Grade NAT)

运营商级 NAT 用一段公网 100.64.0.0/10 (RFC 6598) 给客户分"二次私网"。底层 Pool NAT 维护几百万 conntrack entry,瞬时大量新建需要超大规模 Hash。Cloudflare 写过 1.6Tbps 中 CGN,主要 trick:CTF hashmap singleton、NUMA 局部 cache、parallel LRU。

CGN 必须做 deterministic port mapping:每用户分一段端口范围给出去,避免 outbound 单 IP 被全局 ban。所以一个 CGN IP 同时只服务 ~100 个家庭。


端口范围

一台 NAT 设备只有 65535 个 port 范围(实际 1024-65535 = ~64000)。每条 conntrack 占一个外部端口。

但 conntrack 区分四元组 → 不同目的地的同一内部 socket 可共用一个外部端口(NAPT → Network Address Port Translation)。所以 buffer 足够;倒是单 IP 到同一目的的并发上限 ≈ 64000。

操作员需要拼"有多少公网 IP" × "每 IP 端口数" = NAT 总并发能力。


对等打洞 (Hole Punching)

UDP STUN + Cone NAT

主机 A、B 都先连 STUN 服务器,得自己的外部 (IP, port)。然后双方把对方的 (IP, port) 告知对端,从各自的私有 socket 直接往对方的 (IP, port) 发包。Cone NAT 因为只看 5 元组中的内部 socket,会建立反向 conntrack entry 让对方的入向包通过。

对 Symmetric NAT

Symmetric NAT 给每个不同目的地分配不同 socket → STUN 拿到的回程不正确 → 必须用 TURN 中转。

warning

WebRTC 设计 1开发者频繁遇到 STUN OK + TURN 必走的场景,就是 symmetric NAT / 严格防火环境。TURN server 成本高(每 session 中转带宽),所以大规模部署需要自建 TURN cluster + 节省 cert 流量。

TCP hole punching (the difficult one)

TCP NAT hole punching 比 UDP 复杂很多,因为 TCP 三次握手+状态机:

  1. A 内部开放 socket + bind 端口 K
  2. A 主动连接 STUN server(建立 conntrack: A_int ∷ A_ext :: STUN)
  3. B 同理,A 和 B 都拿到对方外部 (IP, port)
  4. A 从 socket K 发 SYN 给 B_ext,B 同时 SYN 给 A_ext
  5. SYN 互达 → 让 NAT A 和 NAT B 都建立 reverse conntrack entry
  6. 经过协调时钟后即可 TCP 直连

但这需要两条 socket 同时双向发 SYN,应用代码复杂。所以 TCP hole punching 实际部署几乎为 0;UDP 才是主流 (WebRTC、QUIC 都靠 UDP)。


conntrack 真实生产事故

  1. Cloudflare 2020 7 月 DDoS: 大量短 UDP 包占满 edge CGN conntrack → 后续 legitimate UDP DNS 查询无 conntrack entry → 全员 DNS 不工作。修复:edge 把 DNS 流量 bypass conntrack (raw table NOTRACK)。
  2. 某游戏公司 mobile 2019: NAT 类型测试对端 NAT 是 Symmetric NAT → P2P 成功率只有 40% → 部署 TURN relay + 95% 成功,但 TURN 成本预计 100TB / month。优化 加让 TURN 应用于欢迎有 symmetric NAT 客户端 + 端到端加密(TURN 用 TCP-UDP tunnel)。
  3. 某云厂商 2017: 当然会被认为是防火墙 bug, 实际是 Linux conntrack 默认 TCP timeout=5d 让短连接重启后 4天 entries 都不消失 → conntrack 表慢满 → 服务丢包。修复 sysctl 调到 2h + 业务层连接池保活。

这一章带走的东西

  1. Cone NAT vs Symmetric NAT 决定 P2P 是否可能;Symmetric NAT 实际把 P2P 客户端逼到 TURN
  2. Linux NAT 依赖 nf_conntrack,per-flow 1KB,撑 ~65k 新建 / 秒—cg 因 hash table size
  3. 表满是大型 / 高频 UDP 服务的头号踩坑点
  4. CGN 是国家规模 NAT 的边缘;每 CGN IP 只服务 ~100 个家庭避免单 IP 全局被 ban
  5. UDP STUN + Cone NAT 是 WebRTC 端到端直连的基础;TCP hole punching 几乎不实用

下一节 → TCP/UDP

DNS: 名字解析全链路 / 缓存 / CDN 调度 / DNSSEC

TL;DR

浏览器里输入 https://api.example.com 到发出第一个 TCP SYN 之间,发生了一次全球分布式 KV 查询:stub resolver → 递归解析器 → 根 → TLD → 权威服务器,每层都有缓存。DNS 不只是一个"把域名换成 IP 的协议",它同时是 CDN 调度的入口(CNAME 链 + GeoDNS + 短 TTL)、服务发现的骨架(K8s 里 svc.namespace.svc.cluster.local)、以及被攻击面(缓存投毒、DDoS、域名劫持)。读完这一章,你应能讲清一条 DNS 查询走了哪些节点、为什么 TTL 是运维最敏感的旋钮、CDN 怎么靠 CNAME/TTL 玩流量调度,以及 DNSSEC / DoH / HTTPDNS 各自防住什么。

读完应能:

  1. 画出一次 dig www.example.com 的完整路径,区分递归解析器 / 根 / TLD / 权威四种角色。
  2. 解释 TTL 与 negative TTL 如何决定"故障切换速度"与"缓存压力"的取舍。
  3. 讲清 CDN 如何用 CNAME + GeoDNS + 短 TTL 把用户调度到最近的边缘节点。
  4. 说清缓存投毒 / Kaminsky 攻击的机制,以及 DNSSEC、0x20 编码、DoH/DoT 分别堵住哪个洞。
  5. 读懂 K8s 内部 DNS 与 CoreDNS 的解析路径,知道为什么内部 DNS 用头部/尾部匹配而不是域名后缀。

一、为什么不能只用 hosts 文件

TCP/IP 只认 IP;人的记忆只认名字。两个极端方案都不行:

  • 静态 hosts 文件:无法随故障迁移,无法按地理位置返回不同地址,无法集中运维——只能在单机/小型内网用。
  • 每次全量广播"谁叫什么":条目数随域名数量爆炸(今天约 3.6 亿个注册域名),且每台机器都存全量不可扩展。

DNS 的工程答案是把名字空间做成,把解析做成缓存+分层授权:没有任何一台机器知道全部名字,但任何一台机器都能在有限步数内把问题"问对地方"。

二、名字空间:一棵树,逐层授权

                  .  (根, 13 组 anycast 根服务器)
        ┌──────────┼──────────┐
       com        org        cn
        │                      │
     example.com           aliyun.com
        │                      │
    api.example.com      www.aliyun.com
  • FQDN(Fully Qualified Domain Name)api.example.com.,末尾的点表示根。
  • 每一层 zone 对其子树有权威(authoritative)com 权威管理 example.com 的 NS 记录指向谁,example.com 的权威服务器管理 api / www 等记录。
  • 关键设计:委托(delegation)不是复制数据,而是复制"指针"(NS 记录 + glue A 记录)。所以 com 服务器不需要知道 api.example.com 的 IP。

note

根服务器只有 13 个"名字"(a.root-servers.net 等),但背后是 1900+ 个 anycast 实例,全球同播。根不存具体域名,只存 TLD 的 NS 指针——这是"分层让世界可扩展"的教科书例子。

三、一次查询的完整路径

sequenceDiagram
    participant App as 应用 (浏览器)
    participant Stub as stub resolver
    participant Rec as 递归解析器 (8.8.8.8 / 内网 DNS)
    participant Root as 根服务器
    participant TLD as TLD 服务器 (.com)
    participant Auth as 权威服务器 (example.com)

    App->>Stub: getaddrinfo("www.example.com")
    Stub->>Rec: 查询 www.example.com A
    Rec->>Root: 问 .com 的 NS?
    Root-->>Rec: .com NS 列表
    Rec->>TLD: 问 example.com 的 NS?
    TLD-->>Rec: example.com NS + glue A
    Rec->>Auth: 问 www.example.com A?
    Auth-->>Rec: 93.184.216.34 (TTL=300)
    Rec-->>Stub: 答案 + 缓存
    Stub-->>App: 93.184.216.34

几个必须分清的概念:

角色干什么谁跑
stub resolver把查询交给递归器,自己不做迭代操作系统 libc / 应用 SDK
递归解析器替客户端迭代问根→TLD→权威,并缓存ISP DNS、8.8.8.8、内网 DNS、CoreDNS(forward)
权威服务器对自己 zone 内的记录给出最终答案云 DNS、自建 NS、CDN 的权威
缓存TTL 内直接回答案,不再向上游递归器每层都有;浏览器/OS 还有自己的缓存

warning

客户端拿到的不一定"最新":递归器 TTL 内不会回源。改记录后没切流量,90% 是 TTL 还没过;线上变更域名指向必须"提前一个 TTL 周期改",否则要等最长 TTL 才能全部生效。

四、记录类型速查

类型含义典型用途
A / AAAAIPv4 / IPv6 地址直接指向
CNAME别名 → 另一个名字www → CDN 域名
NSzone 的权威服务器委托
MX邮件服务器(含优先级)邮件路由
TXT任意文本SPF/DKIM/DMARC 验证、证书验证
SRV服务 + 端口老式服务发现
SOAzone 版本/刷新参数权威元数据、主从同步
CAA允许哪些 CA 发证书防证书误发
DS / DNSKEY / RRSIGDNSSEC 链签名验证
PTR反向:IP → 名字反垃圾邮件、日志审计

五、TTL:DNS 里最贵的旋钮

缓存窗口 = 故障窗口。

  • TTL 大(如 86400):缓存命中率高、权威服务器压力小,但故障切换要等一天。
  • TTL 小(如 30-60s):切换快,但权威 QPS 暴涨,且 DDoS 放大器效应更强。
  • negative TTL(SOA 里的 MINIMUM):记录不存在的缓存时长——配错会让"新域名/新记录"延迟几小时才可见。

CDN 的经典玩法:权威侧 CNAME 到调度域名 + 短 TTL + GeoDNS/EDNS Client Subnet

  1. www.example.com CNAME www.example.com.cdn.example.net(TTL 300s);
  2. 递归器按客户端出口 IP 所在区域,让 CDN 权威返回最近的边缘节点 IP
  3. 边缘节点本身故障时,CDN 改权威记录 + 靠边缘健康检查,在 TTL 内把流量切走。

tip

高可用 DNS 的三板斧:主备双权威(不同机房 + anycast)监控 TTL 一致性把关键域名 TTL 降到 60s 内并提前演练切换

六、DNS 与 CDN / 服务发现的工程协同

6.1 内部服务发现

K8s 里 my-svc.default.svc.cluster.local 走 CoreDNS:

  • svc 记录返回 Service ClusterIP(A 记录),endpoints 变化时 CoreDNS 动态更新;
  • Pod 用 ndots:5 逐级尝试搜索域,这就是"为什么 Pod 里 ping 短名字比 ping 全名慢"的原因;
  • 大规模场景的坑:DNS 缓存未及时失效 → 服务发现延迟。解法是缩短内部 TTL、事件驱动刷新(而非轮询)、必要时用 service mesh 的 xDS 直连替代 DNS。

6.2 DNS 负载均衡(最便宜的一层 LB)

权威服务器按策略返回不同 IP:轮询、按地域、按权重、按健康状态。特点:

  • 优点:零额外硬件、天然分布式、客户端就近;
  • 缺点:粒度是"一次解析"而非"一个连接"——客户端(和递归器)会缓存结果,长连接场景下流量可能不均衡;依赖 TTL 收敛,秒级不精确。

所以生产架构通常是:DNS 负责"就近"粗调度,L4/L7 LB 负责"精确"负载均衡

七、安全:投毒、放大、劫持

7.1 缓存投毒与 Kaminsky 攻击

攻击者伪造 DNS 应答,让递归器缓存错误映射。关键点:应答必须匹配事务 ID + 查询的源端口 + 问题区。老版本递归器源端口固定,事务 ID 只有 16bit,攻击者暴力猜 ID 并提前把"额外区"的 NS 记录塞进缓存——这就是 2008 年 Kaminsky 攻击:不需要猜中目标记录,只需把权威 NS 指向攻击者服务器,之后整个 zone 的查询都被劫持。

防御:

  • 源端口随机化 + 0x20 编码(查询名随机大小写,应答必须匹配):把猜测空间从 2^16 撑到 2^28+;
  • DNSSEC:权威对记录签名,递归器验签——这是根治方案;
  • DoH/DoT:加密传输,中间人无法篡改/观测查询(也顺带解决隐私)。

7.2 反射放大 DDoS

DNS UDP 应答可以比查询大几十倍(ANY 查询、大 TXT 记录),攻击者伪造源 IP 打任意目标。缓解:关闭 ANY、限制单客户端 QPS、响应限速、DNSSEC 减小放大比、UDP 源端口随机

7.3 国内"污染"与 HTTPDNS

GFW 和部分 ISP 对 UDP/53 明文查询做关键字污染,返回错误 IP。工程对策:

  • HTTPDNS:客户端直接 HTTPS 请求调度服务器(如 203.107.1.33 之类固定 IP),拿到 IP 后绕过系统 DNS 直连——移动 App 标配;
  • DoH/DoT:加密通道使污染失效(但可被 SNI/证书检测针对性阻断);
  • 域名分域:权威侧按 EDNS Client Subnet 返回公网/内网地址。

八、一页速查

查询路径:  stub → 递归器 → 根 → TLD → 权威 → 回填缓存
性能关键:  缓存命中(TTL) / anycast 就近 / 权威 QPS
调度手段:  CNAME 链 / GeoDNS / ECS / 短 TTL + 健康检查
故障注意:  改记录提前 1 个 TTL / negative TTL 别配太大
安全防线:  源端口随机 + 0x20 / DNSSEC 签名 / DoH/DoT / HTTPDNS
服务发现:  K8s CoreDNS + ndots / 事件驱动刷新 / mesh xDS 直连

下一篇: NAT 与 conntrack

TCP / UDP

TL;DR

TCP 是因特网最常用的可靠字节流协议:握手、保活、保序、重传、流控、拥塞控制。UDP 是无连接协议:只包 IP 头和上层数据,能不能到看命。本文先讲协议族骨架,下篇分专题讲握手、拥塞控制、重传——就讲 TCP 的字节序模型 + 状态机如何让 RFC 793 在 1981 年立起来至今还能跑 100 Gbps。

TCP vs UDP

维度TCPUDP
模型字节流可靠交付数据报尽力
连接3-way handshake无连接
顺序保序无序
重传协议自动应用层自己做
拥塞控制内置 Reno/Cubic/BBR无(QUIC 在 UDP 上自己加)
头开销20 字节 + options8 字节
用例HTTP/SSH/SQLDNS/VOIP/游戏/QUIC

note

TCP 头"20 字节"是不含 options 的最小值;含 TS option(10B)和 SACK option 总长接近 40。这个值会被 ack number + SACK 经常生成 60 字节 header。


TCP 头部

0                   16                 31
+--+--+--+--+--+--+--+--+--+--+--+--+--+--+--+--+
|Source Port       |Destination Port                |
+--+--+--+--+--+--+--+--+--+--+--+--+--+--+--+--+
|              Sequence Number                     |
+--+--+--+--+--+--+--+--+--+--+--+--+--+--+--+--+
|         Acknowledgment Number                    |
+--+--+--+--+--+--+--+--+--+--+--+--+--+--+--+--+
|Data Offset|Resv|Flags| Window                  |
+--+--+--+--+--+--+--+--+--+--+--+--+--+--+--+--+
|Checksum   |Urgent Pointer                         |
+--+--+--+--+--+--+--+--+--+--+--+--+--+--+--+--+
| Options (可变长)                                  |

Flags

Flag含义
SYN同步 sequence
ACK确认号有效
FIN关闭发送方向
RST重置连接
PSHpush 给接收端立即上交
URGurgent pointer 有效

Sequence 与 ACK

TCP 是按字节序号确认
SYN ISN=A,从对方看 sequence=A+1 之后字节序号=A+1 起
ACK 收到 seq=A+n -> ACK A+n 表示"前 n 字节已收"
所以每次发送+len 在包里更新 sequence

ISN (Initial Sequence Number) 必须随机 (RFC 6528) 防 TCP 序列号猜测攻击 (Mitnick 著名 hack 就是这个)。

Window Size

窗口 = "我说现在还能收 N 字节",让发送方不发爆接收方。

A -> B
A 发 100B,B 收 80B 处理 80B -> ACK reply with Window=20
A 缓冲剩下 80B, 等 Window=0 时收到停发

加 Windows Scaling option (RFC 7323) 让 window 字段除以 scale factor 编码 > 64 KB(最多 1 GB)。Linux 默认 tcp_window_scaling=1

关键 options

MSS (Maximum Segment Size)              : 通常 1460 字节 (1500 MTU - 20 IP - 20 TCP)
SACK Permitted                           : 启用 selective ACK
Timestamps                               : 帮助 RTT 估计 + PAWS (防旧包回放)
Window Scaling                           : 窗口字段 scale factor
TCP Fast Open Cookie                     : 0-RTT 恢复 TFO

UDP 头部

+----+----+----+----+
| Src | Dst |
| Port| Port|
+----+----+----+----+
|  Length   | Checksum |
+----+----+----+----+
| (data)

只有 8 字节。Checksum 在 IPv4 可选 (0=不校验);IPv6 必须有,否则校验可靠性差。

UDP-Lite

变体:只校验头部一段字节。视频流允许部分错帧。 实测中需检查中间设备是否丢包 / 兼容性(特别移动网络经常转 UDP)——某游戏公司发现全球 4G-5G UDP-Lite 兼容性差 1-2%,正式产品还是用 UDP。


Socket API(程序员视角)

服务端 (TCP)

import socket
s = socket.socket(family=socket.AF_INET, type=socket.SOCK_STREAM)
s.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
s.bind(('0.0.0.0', 8080))
s.listen(128)   # backlog,新全连接队列上限
while True:
    conn, addr = s.accept()
    handle(conn, addr)

TCP 已完成握手但应用未 accept() 的连接 → accept queue 满 → Linux SYNs 全回 RST (tcp_abort_on_overflow=1),或静默 → 客户端 ACK 重试。

SO_REUSEPORT

Linux 3.9 起一组 accept 进程绑同一 IP:PORT,内核用 hash(conn) 选 thread 避锁。Nginx 1.9+ 默认开启:

worker_processes auto;          # CPU 数
worker_aio_requests 256;
listen 443 ssl http2 reuseport;  # 关键 reuseport 让多 worker 各自 accept

效果:单 4vCPU 节点 QPS 从 200k → 700k+。

客户端

cs = socket.socket()
cs.settimeout(5)
try:
    cs.connect(('api.example.com', 443))
except socket.timeout:
    print('connect timeout')

非阻塞 connect() 返回 EINPROGRESS → 加速 reactor:

int fd = socket(AF_INET, SOCK_STREAM | SOCK_NONBLOCK, 0);
int r = connect(fd, ...);  // EINPROGRESS
// 等 epoll_events 投回 fd 可写后,getsockopt(SO_ERROR) 拿结果

Linux 内核 socket buffer

sysctl -w net.core.rmem_max=16777216    # 每 socket 最大收 buffer
sysctl -w net.core.wmem_max=16777216    # 每 socket 最大发 buffer
sysctl -w net.ipv4.tcp_rmem='4096 87380 16777216'   # (min default max) 接收 buffer 自动调节
sysctl -w net.ipv4.tcp_wmem='4096 65536 16777216'   # 发送 buffer

SO_RCVBUF / SO_SNDBUF:socket 缓冲大小,默认 87380B/16KB。 SO_KEEPALIVE:保活探测(默认 2h 后发探测)。 TCP_NODELAY:禁 Nagle,发小包立刻发不缓冲。 TCP_QUICKACK:禁延迟 ack。

Nagle 算法

TCP 想等 ACK 来 / 直到积累 1 MSS 才发小包,避免公网小包爆炸流量。TCP_NODELAY 关闭后立即发。逃离 Nagle 通常和 ACK 延迟搭配导致 200ms 延迟 → 写应用层不延迟 ACK、关 Nagle 或两者都要做。

Delayed ACK (RFC 1122)

接收端为了 piggyback ACK 在反向数据包上,等 200ms 才发独立 ACK。Nagle 算法与之会有相互作用导致游戏 / RDB 延迟:

App: send_small_packet_1 (40B)
Nagle: < 1 MSS,先攒着
Server: 收到,没立刻 ACK;Server 等 200ms
App: 等不到 ACK 一直没继续 send
Server 200ms 后发出独立 ACK
App: 收到 ACK 后才 send 第二个包
=> 端到端延迟 + 200ms

解决:业务层一次性写入大 buffer (避免小包),或 set TCP_NODELAY


TCP 状态机

                  +-----------+
                  |   CLOSED  |
                  +-----------+
                        │
                        │ active open
                        ▼
                  +-----------+
                  | SYN_SENT  |
                  +-----------+
                        │
                        │ recv SYN+ACK, send ACK
                        ▼
                  +-----------+
       active open| ESTABLISHED|  passive
  +---------------------------+----------+
  │                                       │
  │ close → FIN                          │ recv FIN → ACK
  ▼                                       ▼
+-----------+                        +-----------+
| FIN_WAIT_1|                        | CLOSE_WAIT|
+-----------+                        +-----------+
        │                                    │
        │ recv ACK                            │ close → FIN
        ▼                                    ▼
+-----------+                        +-----------+
| FIN_WAIT_2|                        | LAST_ACK |
+-----------+                        +-----------+
        │                                    │
        │ recv FIN → ACK                     │ recv ACK
        ▼                                    ▼
+-----------+                        +-----------+
| TIME_WAIT |                        |  CLOSED  |
+-----------+                        +-----------+
        │ 2*MSL later
        ▼
+-----------+
|  CLOSED  |
+-----------+
  • TIME_WAIT 持续 2 × MSL (Linux 60s),原因:
    • 避免旧数据污染新连接 (RFC 1337 修正 ISN 随机后已不太需要)
    • 让对方最后 ACK 的重传有时间到达 (FIN 重传会再回 ACK)
  • LAST_ACK:被动方等待最后 ACK 才关

TIME_WAIT 在服务器大量幼连接时 socket 占用导致内存耗尽。

sysctl -w net.ipv4.tcp_max_tw_buckets=500000  # 软上限,超过直接 free
sysctl -w net.ipv4.tcp_tw_reuse=1             # outbound 复用
# tcp_tw_recycle 在 kernel 4.12 后被移除!绝对不要用。

warning

tcp_tw_recycle=1 在 NAT 场景下:因为它依赖 timestamp 区分客户端,多 client 共享 IP 会导致 timestamp 不一致被丢包。Linux 已在 4.12 移除该 sysctl——任何 KB 还在教你启 tcp_tw_recycle=1 的都过时了。


TCP 拥塞窗口(一小段引子,详见后续 congestion 章节)

TCP 维护两个窗口:

  • rwnd (receive window):通告接收方可收多少 (在 packet 里)
  • cwnd (congestion window):发送方自己猜的网络可容多少
  • 实际有效发送 = min(rwnd, cwnd)

每 RTT 收到 ACK 后再加 1 MSS(slow start 指数增长),超过 ssthresh 切换到拥塞避免线性增长,丢包时回退到 cwnd/2。一套算法:拥塞避免 + 快重传 + 快恢复**


这一章带走的东西

  1. TCP sequence 是按字节,不是按包 → 应用层"一次 send"在 TCP 看可能是多次传多个 byte(取决于 segment 切分时机)
  2. socket buffer 默认值小,性能敏感时要调 SO_RCVBUF/SNDBUF + tcp_rmem
  3. TIME_WAIT 是协议保护期,不是 bug;规模压不下来时用 SO_REUSEPORT + 业务连接池替代短连接
  4. Nagle + Delayed ACK 互坑会导致 +200ms 延迟,写 client 一次性发完 / 关 Nagle 二选一
  5. tcp_tw_recycle 已被 4.12 移除,任何还教这个开关的解释都过时

下一节 → 三次握手 / 四次挥手 / TIME_WAIT

三次握手 / 四次挥手 / TIME_WAIT

TL;DR

三次握手解决"双向序列号同步",四次挥手解决"双工独立关闭"。真正在生产里出事的是握手背后的两个内核队列(SYN_QUEUE / ACCEPT_QUEUE)和挥手背后的 TIME_WAIT 堆积。本文从 RFC 793 的状态机,追到 Linux 4.x 内核 inet_csk_listen_polltcp_v4_conn_request 的实现路径,再到数据中心里 SYN Flood、SYN Cookie、SO_REUSEPORT eBPF、Linger、RST 注入这些事故现场——一条线打通"协议 → 内核 → 机房"。


一、为什么是三次而不是两次 / 四次

思维链(API → 硬件)

  1. TCP 是全双工字节流,两边都要有独立的方向序号(seq)。
  2. 握手本质:A 告诉 B "我从 x 开始发",B 告诉 A "我从 y 开始发",双方互相 ACK 对方的起点。
  3. 两次握手(A→B SYN,B→A SYN+ACK 合并):B 无法确认 A 收到了自己的 seq,A 半连接残留风险。
  4. 三次握手刚好覆盖两个方向的 SYN 都被 ACK。
   client                                 server
     │  SYN, seq=x, options(MSS,WScale,TS,SACK)        │
     │  ─────────────────────────────────────────────>  │  listen(), inet_csk_accept()
     │                                                  │  allocate sock, push to SYN_QUEUE
     │  SYN+ACK, seq=y, ack=x+1, mirror options         │  reqsk_alloc → inet_csk_reqsk_queue_hash_req
     │  <─────────────────────────────────────────────  │
     │  ACK, ack=y+1                                    │
     │  ─────────────────────────────────────────────>  │  reqsk_queue_remove → accept_queue
     │                                                  │
   ESTABLISHED                                       ESTABLISHED

note

"ack=x+1" — TCP 规定 SYN 即便没有 payload也消耗一个序号。因为这个序号代表"连接开始"事件本身。FIN 同样消耗一个序号。ACK 不消耗序号。

1.1 为什么不能两次

两次握手(RFC 793 早期的 TowardTwoWay)的失败模式:

A → B: SYN, seq=x        (旧包, 100ms 迟到)
A → B: SYN, seq=999      (重发)
B → A: SYN+ACK, ack=1000 (B 此时以为新连接建立)
A 收到 → 也以为建立          A 从未发 seq=1000
A 真的从 1000 开始 → B 已经收下
结果: 协议状态错乱

三次握手让 A 在第三次 ACK 时再次确认自己的 seq,能拒绝旧 SYN。但同时还有SYN 乱序问题,所以 ISN 不能是固定的 0,要随机化(见 §5)。

1.2 四次挥手为什么不能合并成三次

TCP 全双工,A 发 FIN 只表示"A 不再写",B 仍可继续写。B 的 FIN 要等 B 自己把剩下数据发完,所以 ACK(B→A) 与 FIN(B→A) 之间可能有长延迟——必须拆成两个独立包。

   A                                    B
   主动关闭                              被动关闭
     │  FIN, seq=u                                 │   A → FIN_WAIT_1
     │  ──────────────────────────────────────>    │   B → CLOSE_WAIT
     │  ACK, ack=u+1                              │
     │  <──────────────────────────────────────    │
     │  (B 把想发的剩余数据继续写)                  │
     │  FIN, seq=v                                │   B → LAST_ACK
     │  <──────────────────────────────────────    │
     │  ACK, ack=v+1                              │
     │  ──────────────────────────────────────>   │   A → TIME_WAIT (2 MSL)
                                                   │   B → CLOSED

1.3 Half-Close 与服务器的读 0

服务端 recv() 返回 0 是 POSIX 标准 —— 不代表错误,表示对端发 FIN。常被新手当成 EOF 一致处理。这是设计:

  • HTTP/1.0 服务端在 client FIN 后仍可继续写响应
  • SSH 协议退出阶段用 half-close 通知"fwd channel 关闭但 keepalive 仍在"

读到 ECONNRESET (errno 104) 才是异常 —— 对端发了 RST,buffer 里没冲刷的数据全部丢弃。


二、Linux 内核的握手队列

2.1 两个队列的精确含义

SYN_QUEUE   = struct inet_listen_hashbin.request_sock_queue
              已收到 SYN,未完成三次握手
ACCEPT_QUEUE= struct inet_csk_listen_sock.accept_queue
              已完成三次握手,等待 accept() 取走

listen(fd, backlog) 的 backlog —— 历史包袱:

  • 严格按 RFC: ACCEPT_QUEUE 上限
  • Linux 内核实际做法:min(backlog, somaxconn),somaxconn 默认 4096(内核 5.4+),早期是 128——这导致大量短连接服务出过事
$ cat /proc/sys/net/core/somaxconn
4096
$ cat /proc/sys/net/ipv4/tcp_max_syn_backlog
8192
$ sysctl net.ipv4.tcp_abort_on_overflow
net.ipv4.tcp_abort_on_overflow = 0

2.2 ss / netstat 读队列

$ ss -lnt
State   Recv-Q  Send-Q  Local Address:Port
LISTEN  0       128     0.0.0.0:8080
  • Send-Q = listen backlog (配置上限)
  • Recv-Q = 当前 accept_queue 中已完成的连接数(满了挤掉 ACK

warning

Recv-Q > 0 看似好事(连接在等待),实际上意味着你的应用线程 accept() 跟不上——长此以往 accept_queue 满,新连接 ACK 被静默丢,client 重传。线上事故经常是这个 Recv-Q 持续 1000+ 直到 timeout。

2.3 队列满了会怎样

状态过载行为
SYN_QUEUE 满新 SYN 被 drop → client 进入 SYN 重传
ACCEPT_QUEUE 满 + 第三个 ACK 来ACK 被静默丢 → server 重传 SYN+ACK (tcp_synack_retries, 默认 5)
ACCEPT_QUEUE 满 + tcp_abort_on_overflow=1server 回 RST → client 立即 ECONNRESET
// net/ipv4/tcp_miniscallops.c 实际路径
if (sk_acceptq_is_full(sk)) {
    if (!tcp_abort_on_overflow)
        goto drop_ack;  // 静默丢,等下次 SYN+ACK 重传
    tcp_send_active_reset(sk, GFP_ATOMIC);
    goto discard;
}

2.4 调优案例

案例:某支付服务上线峰值 8w QPS,accept 5ms,accept_queue 配 128。压测时 Nginx upstream 大量 499/502。

# 修复
sysctl -w net.core.somaxconn=32768
server {
    listen 8080 backlog=32768;
}
# Nginx 主配置
worker_connections 65535;
worker_rlimit_nofile 200000;

判断 accept 是否瓶颈,看 nstat

$ nstat -az | grep -i overflow
TcpExtListenOverflows        1342     0.0   # accept queue 满
TcpExtListenDrops            2891     0.0   # SYN queue 满

三、SYN Flood 与 SYN Cookies

3.1 攻击原理

攻击者只发 SYN,伪造源 IP(spoofer),不回 ACK。server 为每个 SYN 分配 request_sock(~256B)并塞入 SYN_QUEUE。一次百万级 SYN Flood 几秒打爆 SYN_QUEUE。

攻击者 → server: SYN seq=x (fake src)
server → fake src: SYN+ACK seq=y ack=x+1   # 假 IP 收不到,server 重传
        ↑ 每条 SYN/2xx B 永久占着 SYN_QUEUE 槽位直到 SYN_QUEUE 满

不分配任何状态、不进 SYN_QUEUE。把状态编码到 ISN:

ISN = (hash(sip,sport,dip,dport,secret) << 7) 
       | ((mss_index & 0x7) << 4) 
       | ((t mod  4)) 
       | 1  # 强制最低位 1 防 0

收到第三次 ACK 时:ack = ISN_server + 1,server 用 client 的 sip/sport/dip/dport 重新 hash 验证。1 ms 内恢复全部状态并把连接放入 accept_queue。

sysctl net.ipv4.tcp_syncookies
net.ipv4.tcp_syncookies = 1   # 默认开
  1. 丢 options:SACK、WScale、Timestamps 都不能在 cookie 中编入 → 长肥管道(high BDP)吞吐受损
  2. MTU/MSS 单向猜测:通过 syn MSS index 编进 ISN 但只能 4 个档位(536/1300/1440/1460)
  3. 不做 SYN 重传:cookie 状态被 client 是否回 ACK 决定,client 丢 SYN+ACK 直接超时

warning

tcp_syncookies=1 是"救命底",建议保持默认 1;但不要把它当正常状态。长期依赖 SYN Cookie 意味着 SYN_QUEUE 经常被打满,SACK/WScale 全部失效,长肥管道会变 100 Mbps。该扩队列、上 SynProxy、上 BGP scrubbing 才是正解。

3.4 SynProxy(防御升级版)

Linux 4.4+ 内置 SYNPROXY netfilter 目标,配合 iptables-extensions:

攻击 SYN → SynProxy:先自己握手,再"代理握手"做真握手
SynProxy 与 client 完成三次握手后才转发给真实 server
syn flood 阶段:SynProxy 不分配任何 server 资源
iptables -t raw -A PREROUTING -p tcp -m tcp --syn -j CT --notrack
iptables -A INPUT -p tcp -m tcp --syn -m conntrack --ctstate INVALID,UNTRACKED \
    -j SYNPROXY --sack-perm --timestamp --wscale 7 --mss 1460

数据中心常用方案:BGP FlowSpec 把可疑源路由到 scrubber,scrubber 做 SynProxy + challenge 之后再回到 server。


四、ISN:一个看似小的安全问题

4.1 为什么 ISN 必须随机

攻击者想知道 client→server 的下一个 seq,就能:
1. 伪造 RST 关连接(RST 只要 seq 在窗口内即可)
2. 在客户端没有发数据时往 server 注入数据("装作"客户端,如 1985 Morris 攻击)

早期 BSD 用一个 1 µs 递增计数器做 ISN,完全可预测。1996 Shimomura 被 Mitnick 用这个手法攻破——成为现代计算机安全史开端。

4.2 Linux 4.x 的 ISN 算法

// net/ipv4/tcp_ipv4.c: tcp_v4_init_seq()
u32 tcp_v4_init_seq(const struct sk_buff *skb) {
    return secure_tcp_seq(ip_hdr(skb)->saddr, ip_hdr(skb)->daddr,
                          tcp_hdr(skb)->source, tcp_hdr(skb)->dest);
}

secure_tcp_seq 用 MD5/M不再可见 + 时间戳 + 密钥派生。很弱——RFC 6528 要求 ISN 至少 64 位熵,Linux 实际密钥+时钟派生,时间间隔内可预测。

更稳的方式 —— TCP_MD5SIG 选项(RFC 2385):BGP/OSPF/MPLS 控制平面用 TCP MD5,连 ISN 都不验,直接 HMAC 验整个 segment。

TCP AoO (RFC 5925) 是 MD5sig 升级版,增加了算法可协商、key rotation,目前 Cisco Juniper 都支持。


五、TIME_WAIT 深度分析

5.1 为什么需要 TIME_WAIT

被动方 / 主动方都能进入 TIME_WAIT 是主动关的一方才进入:

A → FIN → B
B → ACK
B → FIN → A
A → ACK        ← 这个 ACK 可能丢!
                ↑ B 重传 FIN,A 必须能再发 ACK
                ↑ A 状态必须保持到 B 至少放弃重传(1 RTT + tolerance = 2 MSL)

两个用途(按 RFC 793 原文):

  1. 保 ACK 重传能力(主用):B 重传 FIN 到 A 必须有 A 来回 ACK
  2. 防止旧连接数据污染新连接:让旧连接的 trailing 报文在网络中消失(2 MSL = 1 MSL outbound + 1 MSL inbound)

第二个目的事实上已被 RFC 1337 弱化:因为现代 ISN 随机化后,新连接不会复用相同 seq → 实际不需要 2 MSL 解决这个。Linux 实际值是 60sTCP_TIMEWAIT_LEN in include/net/tcp.h),不是教科书说的 2*MSL(120s)。

5.2 Linux TIME_WAIT 实现细节

#define TCP_TIMEWAIT_LEN (60 * HZ)   // 60 sec

// net/ipv4/tcp_timer.c: tcp_time_wait()
void tcp_time_wait(struct sock *sk, int state, int timeo) {
    struct inet_timewait_sock *tw;
    tw = inet_twsk_alloc(sk, state);
    // tw 不再持有 send/recv buffer,只是 4-tuple key + timer
    // 大概 200B
}

TIME_WAIT 是个轻量结构(约 200 B),但 tcp_max_tw_buckets 默认 4096(旧)/ 现代 262144。极端短连接服务(如 mysql proxy)能堆 100w+ → ~200MB 内存。一般够。但端口耗尽才是真头疼:

5.3 端口耗尽 (Ephemeral Port Exhaustion)

5000 QPS 短连接,每条 60s TIME_WAIT:

  • 出向 client 元组 (src_ip, src_port, dst_ip, dst_port) 中 src_port 范围 32768-60999(约 28k 个)
  • 28k * (≥60s 握手周期) / QPS = 28k/5k = 5.6 ≤ 风险点

测试方法:

$ curl -s -w '%{local_port}\n' http://server -o /dev/null
# 单 client 长时间高频短连 → 端口循环

你看到 EADDRNOTAVAIL 就是这个原因。

5.4 缓解措施对比

+----------------+------------------------------------+-------------------+------------+
| 措施           | 原理                                | 副作用            | 建议       |
+----------------+------------------------------------+-------------------+------------+
| tcp_tw_reuse=1 | outbound 允许新 connect 复用 TW    | 无                | 强推       |
| tcp_max_tw_bucket | 超 threshold 强行 free            | 旧连接 RST 风险   | 设 500000  |
| SO_REUSEADDR   | bind 时允许复用 TIME_WAIT 端口     | 仅 client 复用    | OK         |
| SO_REUSEPORT   | 多 worker accept 同 port, kernel  | 无                | 强推       |
|                 | hash(conn) 选 worker               |                   |            |
| tcp_tw_recycle | 用 timestamp 判活复用TW入向         | NAT 下灾难        | 4.12 已删  |
| 改长连接       | 不短连根本没 TIME_WAIT             | 增长保活成本      | 根治        |
+----------------+------------------------------------+-------------------+------------+

warning

tcp_tw_recycle 在 Linux 4.12 已移除。它在 NAT 后续场景会基于 timestamp 判断"是不是同一个 client",NAT 出来的多个 client 时间戳会"回退"(系统时钟漂)→ 内核认为是"过时连接" → RST。任何文档还在让你 sysctl -w net.ipv4.tcp_tw_recycle=1 都过时了。KB 上的修正年代是 2017 年 7 月。

5.5 SO_REUSEPORT eBPF 进阶

Linux 4.5+ 后 SO_REUSEPORT 支持 attach eBPF program,由 eBPF 自定义 hash 选取 worker。Cloudflare 用这个转发到 sticky worker:

struct bpf_prog $reuseport_prog;
bpf$reuseport(prog, sk_array) {
    // 根据 client IP/Port hash 选 worker,保路由稳定性
    return bpf_sk_reuseport_select(sk_array, opts);
}

Cloudflare 的开源 nginx-quic 也用此机制在前端 SLB 上做 sticky routing。


六、Tcpdump + Wireshark 诊断现场

6.1 抓三次握手 + 挥手

$ tcpdump -i eth0 -n -S -tttt \
    'tcp port 443 and (tcp[tcpflags] & tcp-syn != 0 or tcp[tcpflags] & tcp-fin != 0)'

2024-08-21 14:23:09.123 10.0.0.5.51604 > 142.250.80.46.443: Flags [S], seq 2881824731, win 65535, ...
2024-08-21 14:23:09.150 142.250.80.46.443 > 10.0.0.5.51604: Flags [S.], seq 1729185772, ack 2881824732, ...
2024-08-21 14:23:09.150 10.0.0.5.51604 > 142.250.80.46.443: Flags [.], ack 1729185773, win 65535, ...

S = SYN, S. = SYN+ACK, . = ACK。三个包来对应三次握手。

6.2 内核实时计数

$ nstat -az | grep -iE '(overflow|dropped|reset|retran)'
TcpExtListenOverflows           1342    0.0   # accept queue 满
TcpExtListenDrops               2891    0.0   # SYN queue 满
TcpExtTCPTimeouts               8912    0.0   # RTO 触发
TcpExtTCPSpuriousRTOs           34      0.0   # 假阳性 RTO
TcpExtEmbryonicRsts             34      0.0   # SYN 状态收到 RST
TcpExtTCPSynRetrans             156     0.0   # SYN 重传数

七、生产事故复盘

事故 1:accept queue 满 → 服务端体感不是 listen 失败而是 client timeout

症状:某 API gateway 加新接口后开始零星出现 504,client 端 timeout 但服务端业务日志没有任何请求。

排查

$ ss -lnt | grep 8443
LISTEN  4423  128   0.0.0.0:8443      # Recv-Q = 4423 > Send-Q (上限 128) ?

(其实内核 5.x 把 Recv-Q 在 LISTEN 状态展示成"已收到 SYN 待 accept 的"——也就是 SYN_QUEUE 实际长度)。

$ nstat TcpExtListenOverflows
1342

根因:Nginx worker_processes 调小 + accept_mutex 锁影响,accept 速度跟不上 SYN 速率。

修复

  • 增大 listen backlog=32768
  • worker_processes autoworker_connections 65535
  • 上 Prometheus 监听 nstat TcpExtListenOverflows 报警

事故 2:BGP 邻居中断后无法重建

症状:BGP session 在 ICT 重启后无法建立,tcpdump 显示三次握手正常 ESTABLISHED 了立刻 RST。

根因:BGP 用 MD5 签名 + key rotation 中错位 → server 看到合法 ack 但 MD5 不匹配,静默丢 → client 数秒重传 SYN → ESTABLISHED 触发 → MD5 sig 不符 → RST。

修复tcpdump-M <key> 验证 + tcpdump -M 选项;运维上要先做 key rollout 两端同步。

事故 3:机房 LVS LFIN 失败导致大量 LAST_ACK

症状:LVS 直接路由模式切换 RS,触发的连接全部进入 LAST_ACK 状态、tcp_tw_buckets。短时间内 nf_conntrack 表满,整个机房东西向流量都驻足。

修复

  • LVS 上调成 pers_timeout 50, fin_timeout 5
  • 服务侧 tcp_fin_timeout=15(默认 60s)绑 RS 自身
  • 加监控告警对 LAST_ACK 计数

事故 4:CGN 后用户连不上 CDN

症状:某运营商用户反馈间歇性连不上 CDN,抓包看到 SYN 重传到 CDN → client 收 SYN+ACK 没回 ack 就 RST。

根因:CGN 设备基于 5-tuple 入向记录,client 是 CGN 后的私有 IP,CGN 上 nf_conntrack 因为 tcp_timeout 太短,SYN 已过期,client 收到 SYN+ACK 后包直接被丢。

修复:CGN 调长 timeout,或使用 syn cookie 旁路。


易错清单

  1. 三次握手 ≠ "连接已建立" :server 没调 accept() 前连接存在但应用看不到
  2. ss -lnt Recv-Q > 0 不一定问题,但持续 >100 = 应用 accept 慢
  3. tcp_tw_reuse 是 outbound,不是 inbound — 不要混淆
  4. systemd 加的 tcp_tw_recycle 配置在 4.12+ 内核会被静默忽略(依然很危险 = KB 误导)
  5. ECONNRESET 不是连接死了,对端主动发了 RST
  6. ECONNREFUSED 是 SYN 后立刻收 RST —— port 没人 listen 或被防火墙 reject
  7. tcp_fin_timeout 控的是 FIN_WAIT_2不是 TIME_WAIT(TIME_WAIT = 60s 硬编码)

这一章带走的东西

  1. SYN_QUEUE 走 tcp_max_syn_backlog,ACCEPT_QUEUE 走 somaxconn ∧ listen backlog。两者用 TcpExtListenOverflows/Drops 看真实溢出。
  2. tcp_syncookies=1 是底线,不是日常。生产要扩队列 + SynProxy + scrubber,否则 SACK/WScale 全丢,长肥管道退化。
  3. TIME_WAIT 60s 硬编码。tcp_tw_reuse 是 outbound,可用;SO_REUSEPORT eBPF 是现代粘滞路由神器。
  4. tcp_tw_recycle 4.12 已删 — 看到 1 的人就告诉他可恶的 NAT。
  5. BGP/OSPF 控制平面只用 TCP MD5 sig,连 ISN 都不靠 —— 这是 EBGP multi-hop 比 IBGP 安全的根本原因。
  6. 应用层 recv() 返回 0 = 对端发 FIN —— 不是错。ECONNRESET = 对端发 RST —— 业务要记账。

下一节 →

拥塞控制 — 从 Reno 到 CUBIC 到 BBR,Linux 的 cubic / bbr 开关现况,以及为什么 BBR v1 公平性、v3 才解决带宽收敛。

拥塞控制:Reno / CUBIC / BBR

TL;DR

1986 年 10 月,Internet 第一次因 TCP 拥塞瘫死。Van Jacobson 1988 论文提出 Reno 的"加性增、乘性减"——人类第一个工作的分布式拥塞控制。三十年里,CUBIC 用三次方窗口函数让 100 ms RTT 的高带宽链路也能收敛;BBR 把"丢包=拥塞"这条假设彻底废掉,改用带宽探测模型。本文从缓冲数学走到内核 tcp_congestion_control 框架,再到 BBR v1/v2/v3 的代际差异、Cloudflare/GitHub 部署报告、ECN/L4S 与 bufferbloat——一线运维工程师必须知道的常量。


一、为什么需要拥塞控制

1.1 1986 年 Internet 崩溃事件

1986 年 10 月,从LBL到UCB的一条 32 kbps 链路,因为 TCP 发送方没有节流,吞吐跌到 0 bps。每秒还是有 N 个包在网络上,但全部进了路由器 buffer 又被丢掉。Jacobson 分析原因写进 1988 SIGCOMM 的经典论文 "Congestion Avoidance and Control"——人类第一次系统性认识"网络拥塞崩溃"。

1.2 IP 层不告诉你"网络忙"

+--------+    IP 不反馈     +---------+    IP 不反馈      +-------+
| sender | ───────────────> | router  | ─────────────> |receiver|
+--------+                  +---------+                  +-------+
                                  │
                                  ▼
                            buffer 满 → drop
                            (没有 vilket backpressure)

IP 是无反馈的 best-effort,于是发送方只能从两类"间接信号"反推拥塞:

  1. 丢包(buffer 满了,包没到)
  2. RTT 变长(buffer 在变满)

经典的"水桶模型":

B (bottleneck rate)      [queue depth q(t)]        μ (egress rate)
source ─────────────────>|| buffer ──────────────> link
                              ↑ q_full → drop
  • 当 source rate > μ:buffer 不断累 → 满后丢包
  • 当 source rate < μ:未充分利用
  • 当 source rate = μ 但接近满:任何小抖动都会触发丢包 → "blade edge" 区域
  throughput
     ↑
     │     /\
     │   /    \   ← saw-tooth (锯齿)
     │ /        \
     │/          \
     └───────────────→ 时间

1.3 公平性、收敛性、稳定性

三个并列目标,难同时达到:

  • 收敛性(fairness):两流共享链路最终应平分带宽
  • 效率:稳态时 sum 流量 = bottleneck capacity
  • 稳定性:合套收敛后不能震荡破坏

Reno 给出 AIMD (Additive Increase, Multiplicative Decrease) 答案,被 30 年证明"在稳态公平 + 收敛"上是博弈论意义下的最佳策略(Chiu & Jain 1989)。


二、Reno(RFC 5681 标准化版)

2.1 三个阶段 + 状态机

                 lost (timeout)
                    ┌────────────┐
                    │            ↓
                ┌──────┐    ┌─────────────┐
                │ Slow │    │ Congection  │
        ┌──────>│Start │    │ Avoidance   │
        │       └──────┘    └─────────────┘
        │          │            ↑
        │          │ 3 dup ACK │
        │          ↓            │ cwnd /= 2
        │       ┌──────────┐   │ + fast retransmit
        │       │  Fast    │   │
        └───────│ Recovery │───┘
          loss  └──────────┘
阶段cwnd 行为触发
Slow Start每 ACK cwnd++(指数增长)连接开始 / RTO 后
Congestion Avoidance每 RTT cwnd += 1 MSS(线性)cwnd >= ssthresh
Fast Retransmit立即重传 seg,cwnd /= 2, ssthresh = cwnd/23 dup ACK
Fast Recovery持续收新 ACK 时 cwnd 临时加dup ACK 之间
RTO Retransmitssthresh = cwnd/2, cwnd = 1, slow starttimeout

2.2 为什么 3 dup ACK

单 dup ACK 可能是乱序(reordering),2 dup 是 spurious reorder 重排路径抖动。RFC 793 不成文:连续 3 dup ACK 是📕较强丢包信号。

但也有缺点:少量丢包(≤ 0.1%)就能 3 dup ACK → CUBIC 打 50%;高 BB link 浪费。

2.3 AIMD 数学

让 N 个流共享 C 容量,AIMD 收敛速度:

  • 两流不平等:x_1 + x_2 = C, x_1 <x_2
  • 一个 RTT 减半后丢包:两流都 * (1/2)比例不变但总和 ≈ C
  • 然后双向 +1 MSS / RTT → 平分速度才在 N RTT 内追上

公式:fairness 收敛时间 ≈ O(N · C / MSS) RTT。10 Gbps 链路,MSS=1460B,N=4,收敛 RTT ≈ 100ms × 1.7M = 一天。→ AIMD 在高 BDP 下根本不收敛。这是后续 CUBIC 出现的根本动机。


三、NewReno / SACK

3.1 Reno 一次丢多包的悲歌

sender: cwnd=12   send 1,2,3,4,5,6,7,8,9,10,11,12
假设 seg 2,5,7 都丢了。
receiver ACK 1, dup 1, dup 1 → sender fast retransmit seg 2
sender  等接收方 ACK 3,4(dup)  
sender 重传 seg 5, ACK 3,4 是 dup 1,之后 ACK 6 dup → 再 fast retransmit  
每一包独立通话 → Reno 退 cwnd 多次。

NewReno(RFC 6582)的"partial ACK":丢一段时 admitting 还有别的丢,cwnd 不会立即回弹,直到所有同一 RTT 窗口丢的都补完。仍是一次只重传一包,效率不够。

3.2 SACK(RFC 2018)

receiver 在 ACK option 里带 SACK block:

SACK: [seq 1001, seq 2000]    # skip
      [seq 3001, seq 4000]    # skip
      [seq 4001, seq...]      # current ACK point  
sender 一眼看到哪些 seq 收到→只重传丢掉的

Linux 默认开(sysctl tcp_sack=1,1999 后默认)。没有 SACK 的连接在 0.5% 以上丢包链路上吞吐跌 50%+。

3.3 FACK/D-SACK/DSACK

  • FACK (Forward ACK):把 SACK 最右边界算 cwnd,更准确反映 in-flight
  • DSACK (Duplicate SACK):receiver 收到重复报文时用 SACK 通报 sender,用于检测假阳性丢包 / 重排——是 RACK 算法的基础

四、CUBIC(Linux 2.6.19 默认至今)

4.1 动机

Reno 系列在高 BDP(带宽 × 延迟)链路上线性增太慢:

  • 100 ms RTT × 10 Gbps → BDP = 125 MB ≈ 85k MSS
  • Reno + AI 每秒加 100 MSS,从 cwnd/2 = 42.5k 到 85k 需要 425 秒

4.2 三次方窗口函数

CUBIC 用纯时间驱动的窗口函数: $$ W(t) = C(t - K)^3 + W_{max} $$

  • $W_{max}$:上次丢包时的 cwnd
  • $K = \sqrt[3]{W_{max} \cdot \beta / C}$,$\beta=0.7$(Reno 是 0.5)
  • $C$:scaling constant,默认 0.4
  • $t$:上次丢包后逝去的时间

凹形曲线:

W(t)
 ↑
 │ W_max ··
 │       ·   ·
 │         ·
 │          ·
 │           ·
 │            ·
 │             ·
 └──────────────────→ t
        凹形:离 W_max 远 → 增长慢(让位他人)
              接近 W_max → 加速(探测带宽还多不多)

4.3 TCP-Friendly 区段

远离 $W_{max}$ 时 cubic 增长慢,会被 Reno 同链路"挤"。CUBIC 用 TCP-friendly mode:当 cubic 计算 cwnd 低于对应 Reno/std AI 路径时切回 Reno-style 增长 → 公平性保住。

// net/ipv4/tcp_cubic.c
if (tcp_cubic_clip>(ca->cnt, mode)) {
    // 切回 Reno 行为 (1/cwnd 每 ACK++)
    ca->cnt = 50;  // 实际是 cwnd/(W_max)
}

4.4 调优

$ sysctl net.ipv4.tcp_congestion_control
net.ipv4.tcp_congestion_control = cubic
# 可用算法
$ sysctl net.ipv4.tcp_available_congestion_control
net.ipv4.tcp_available_congestion_control = reno cubic bbr ...
# per-socket
$ setsockopt(fd, IPPROTO_TCP, TCP_CONGESTION, "bbr", 3);

五、BBR(Google 2016)

5.1 破除"丢包=拥塞"假设

家用路由器 buffer = 100 MB(bufferbloat 时代)。CUBIC 一直增直到塞满 100MB,然后丢 0.1% 才减半 →延迟很长时间 100ms+。在 5G/WIFI 场景 dispose 体验极差。

BBR 用模型驱动:

sending_rate = BtlBw × (1/RTTprop)
              ↑             ↑
              bottleneck    光速往返延迟(不含排队)
              bandwidth

并维持 O(1) 队列:cwnd = BDP + ε,永远只让 buffer 啃 200ms 队列深度前的小补丁。

5.2 四个状态

            start
              │
              ↓
       +──────────+
       | Startup  |   ← 2× 探测带宽
       +──────────+
              │  (BtlBw 稳定)
              ↓
       +──────────+
       | Drain    |   ← 排空 startup 期积压
       +──────────+
              │
              ↓
       +──────────+
       | ProbeBW  |   ← 8 个节拍 cycle: gain 1.25/1/0.75 ...
       +──────────+      | (probe 先 1.25×,溢出后 0.75×)
              ↑          ↓
              │     +─────────────+
              │     |  ProbeRTT  |  ← 每 10s 进 200ms cwnd=4 MSS
              └─────|             |   测 RTTprop
                    +─────────────+
  • Startup:cwnd 加速,2× 每 RTT。当 BtlBw 不再涨(≥3 个 RTT 增速增长不变)→ Drain
  • ProbeBW:周期 gain ∈ {1.25, 0.75, 1, 1, 1, 1, 1} 7 阶段
  • ProbeRTT:每 10s 检查一次 RTTprop,cwnd 短暂封顶 → 测真实 RTT

5.3 BBR 的丢包检测 (RACK)

BBR 不靠 3 dup ACK 判丢,而是 RACK (Recent Acknowledgement, RFC 8985):

  • ACK 100 后 seg 90 还没 ACK → 90 真的"丢了"
  • 用 ACK 到达序列而非 dup ACK 数量
  • 同时支持乱序、spurious

5.4 BBR v1 的痛点

  1. 不公平:BBR 与 Cubic 共用链路,BBR 赢 100%(因 BBR 不退避)
  2. BBR 多个 BBR 不平分:Startup 期都 2×,反复冲撞
  3. RTT 测不准:浅 buffer 旁路,BBR 误认为是真实 RTTprop
  4. 过冲:startup 2× 容易瞬间在浅 buffer 上丢包

5.5 BBR v2/v3

Google 2019 推 BBRv2,2023 v3:

  • 加入 ECN 反馈(早接收拥塞)
  • ProbeBW gain 不再 1.25× 硬探测 → 改成"小步 5%"
  • ProbeRTT 节拍自适应
  • 多 BBR 流公平性靠共享 BtlBw 估计收敛
  • 限速更响应 cross-traffic
# 内核 5.4+ 才有 BBR,5.18+ 有 BBRv2 alpha
sysctl -w net.ipv4.tcp_congestion_control=bbr
sysctl -w net.ipv4.tcp_congestion_control=bbr2   # 需要 patch

5.6 生产部署观测

Cloudflare 报告:边缘 L4 LB 后用 BBR 出口,P99 延迟降 8×(buffered 5G Wifi)。但单边改成 BBR,下游 ISP "永远不 backoff"的同样在限速,BBR 策略给实际劣势。

GitHub:2018 切 BBR 后内部 RPC P99 ↓30%。但 SSH 长连接公平性下降。最终维持 cubic + 个别路径 BBR。

YouTube:手机 SDK active link BBR → 起播时间 ↓11%,复播 4%。

5.7 BBR 数据中心内的"反_assoc"问题

数据中心内部 DCQCN / DCTCP / HPCC(存储/HPCC)会与新加入 BBR 冲突。模式:

  • RDMA 用 RoCEv2 用 ECN/PCP,是 NIC 实现
  • BBR 在网卡 / 内核 socket 层不感知 RoCE 信号
  • 混部时 BBR 看到 RoCE 流量"挂"很久,误判 RTTprop→ 窗口大幅缩水

→ 数据中心内部仍优先拥塞控制做在 NIC (Mellanox CX-7 等),TCP BBR 留给跨数据中心 WAN。


六、ECN、L4S 与未来

6.1 ECN (Explicit Congestion Notification, RFC 3168)

IP header TOS 字段 2 bit 编 ECN 标记:

  • 00 = Non-ECT, 10/01 = ECT(0/1), 11 = CE (Congestion Event)

设备 buffer 接近满 → 标记不丢,receiver 复制到 ACK 用 ECE/CWR flag 回 sender。Sender 收到 ECE 后减 cwnd。无损数据中心的关键

6.2 L4S (Low Latency, Low Loss, Scalable Throughput, RFC 9330)

2020+ 新一代互联网框架:精度可调节的 ECN 配合新算法(Google Prague/HPCC)/可扩展拥塞控制,目标 ms 级排队

  • 公网尚未部署
  • 5G slicing、家庭 Gbps FTTH 是潜在市场

6.3 AQM(Active Queue Management)

  • RED:经典(随机小丢)
  • FQ-CoDel:Linux 默认 qdisc,多流 fair queuing + CoDel 探测
  • CAKE:FQ-CoDel 升级版,家用 ISPrange 推
$ tc qdisc replace dev eth0 root cake bandwidth 100M
# 配家用 IS Praxisl 100M,ca 队列会做 fair + 内置 shaper

七、内核代码导览

// net/ipv4/tcp_congestion.c
int tcp_register_congestion_control(struct tcp_congestion_ops *ca) {
    // 把算法挂到全局 ca_list
}

// net/ipv4/tcp_cubic.c
struct tcp_congestion_ops cubictcp = {
    .init           = cubictcp_init,
    .ssthresh       = cubictcp_recalc_ssthresh,
    .cong_avoid     = cubictcp_cong_avoid,   // 核心
    .set_state      = cubictcp_state,
    .undo_cwnd      = tcp_reno_undo_cwnd,
    .cwnd_event     = cubictcp_cwnd_event,
    .pkts_acked     = cubictcp_acked,
    .owner          = THIS_MODULE,
    .name           = "cubic",
};

每个 socket:inet_csk(sk)->icsk_ca_ops 指向当前算法。tcp_cong_avoid 每收到 ACK 调一次:

void tcp_cong_avoid(struct sock *sk, u32 ack, u32 acked) {
    const struct inet_connection_sock *icsk = inet_csk(sk);
    icsk->icsk_ca_ops->cong_avoid(sk, ack, acked);   // cubic / bbr / ...
    tcp_sk(sk)->snd_cwnd_stamp = tcp_jiffies32;
}

BBR 的实现在 net/ipv4/tcp_bbr.c,~1500 行,主要维护 4 个状态元:

struct bbr {
    u32 min_rtt_us;     // RTTprop 估计
    u32 mIN_rtt_stamp;
    u32 bw_lo, bw_hi;
    u32 cycle_idx;
    u32 mode: 3, ...
};

八、生产事故

事故 1:机房切片 BBR 启用后吞吐萎缩

切换 BBR 后跨机房 replication 流量从 5 Gbps 跌到 1.5 Gbps。抓包:BBR ProbeRTT 周期触发频繁(10s 节拍太敏感),中和 ISP 的"限制速率",窗口一直被压小。

修复:恢复 cubic + 切到云厂家私有拥塞控制(如 AWS 的 "cdg")。

事故 2:CUBIC 大 RTT 与短流不公

新 Italy IDC 与新加坡 EC2 走 IPSec VPN,RTT 220ms。DB backup 流(cubic)和短小 API 流共享链路,backup 独占带宽。

修复:用 tc 分级 + HTB qdisc 限流 backup,或改用 BBP-aware 协议(rdma) / per-flow QoS。

事故 3:bufferbloat 让 Zoom RTT 跳 1200ms

家庭 1 Gbps 互联 PC 直插电信,路由器 buffer 深 256MB。Zoom 与 Steam 后台下载同链路,Cubic 一直加 cwnd 占满 buffer,Zoom RTT 1200ms。

修复:路由器启用 CAKE qdisc 显式低延迟带宽限速:

$ tc qdisc replace dev eth0 root cake bandwidth 100mbit besteffort nofrag

事故 4:SACK 关闭导致跨机房吞吐骤降

某应用 socket option 显式 setsockopt(TCP_SACK, 0)(被误认为 sec advisory)。0.5% 丢包链路上 throughput <10% capacity。

修复:默认 SACK 必须开,把代码移除,部署 patch 灰度。


九、易错清单

  1. 慢启动不慢:"slow" 指"逐渐启动",不是低带宽;每 ACK cwnd++ 实际指数增长
  2. CUBIC 不一定比 Reno 快:在不稳定高丢包链路 (5G 弱信号) Reno 反而稳,CUBIC 凹函数回弹慢
  3. BBR 不依赖丢包——但 BBR v1 在多 BBR 流间不公平,需要 v2
  4. tcp_congestion_control=bbr 是全局默认,per-socket 可在 setsockopt 覆盖
  5. ECN ≠ ECE:ECN 是 IP 层标记,ECE/CWR 是 TCP 层 flag
  6. 拥塞控制 ≠ 流控:流控由 rwnd(接收方反馈),拥塞控制由 cwnd(发送方自己控)
  7. Don't 在数据中心内部启 TCP BBR:与 RoCE ECN 冲突,会撕 RTTprop 估错

十、这一章带走的东西

  1. 拥塞控制是发送方单边探测 → 缓冲数学模型 → AIMD 在低 BDP 下最佳,高 BDP 需 cubic/BBR
  2. Reno → NewReno + SACK → CUBIC → BBR,一线从"丢包为信号"到"模型为信号",BBR v2 解决公平
  3. Linux 默认 cubic;公网边缘 BBR 体验优;数据中心内部 NIC 拥塞控制 (DCQCN/HPCC)
  4. ECN 让"不丢包"的拥塞反馈成为可能;L4S 是 ms 级排队未来
  5. bufferbloat 缓解:CAKE/FQ-CoDel 在家庭网络收益最大
  6. tcpdump + ss -ticwnd/ssthreshnstat TcpExtTCPSpuriousRTOs 监控假阳性

下一节 →

重传机制 — RTO 估算 (RFC 6298)、SACK、RACK、TLP、F-RTO、ER 诸多"我用尽一切手段救你 RTO"。

重传机制:RTT / RTO / SACK / RACK / TLP

TL;DR

TCP 可靠字节流全靠"序号 + ACK + 重传"三条腿。最难的一段是"什么时候重传"——RTO 早了误重传浪费带宽,晚了延迟翻倍。本文从 Jacobson 1988 EWMA 公式追到 Linux tcp_rtt_estimator,再到 SACK / DSACK / F-RTO / RACK / TLP / ER 这一系列 RFC 9002 时代的演进,最后到 QUIC 在应用层重写一遍——告诉你协议为什么进化、内核代码怎么改、生产者看到什么告警。


一、为什么重传这么难

单包送出去,sender 看到的反馈只有一类信号 = ACK 序号。三种可能性:

| ACK seq |      解读                       | sender 反应
| < send  |  乱序 / 丢 ACK                  | 等
| == send |  接收方收到了                    | 推进 cwnd
| > send  |  接收方收到但回更前的 ACK (dup) | dup ACK++,可能丢包
| RTO 触发| 网络在某段时间没回任何信号      | 退 cwnd=1, slow restart

设计目标:

  • 早发现(少占 buffer,省 RTO)
  • 少误判(重传好的包 = 浪费带宽 + 多余 RTO 重设)
  • 抗乱序(route flap → seq 顺序到达破坏,不是丢)
  • 抗 ACK delay(ипподрöm 不回立刻 ACK)

四十年里这四条反复写新 RFC。


二、RTT 估计(Jacobson 1988 EWMA)

2.1 朴素均值的历史失败

# 平均:  naive = sum(samples) / N
# 问题1: 流式窗口大小? 100 包? 1000 包?
# 问题2: 网络条件不会"瞬间变化",平滑响应慢

1988 Jacobson 用指数加权移动平均 (EWMA)

$$ \text{SRTT} = (1 - \alpha) \cdot \text{SRTT} + \alpha \cdot \text{SampleRTT} $$

$$ \text{RTTVAR} = (1 - \beta) \cdot \text{RTTVAR} + \beta \cdot |\text{SRTT} - \text{SampleRTT}| $$

$$ \text{RTO} = \text{SRTT} + \max(G, 4 \cdot \text{RTTVAR}) $$

  • $\alpha = 1/8$:每 RTT 内 8 个 sample,权重平滑
  • $\beta = 1/4$:变化项权重 4 × 大
  • $G$ = clock granularity (Linux jiffies,约 1ms)

核心直觉:让 RTO 比 SRTT 大约 4 × RTTVAR——历史波动越大,预留越宽。

2.2 Linux 实现

// net/ipv4/tcp_input.c
static void tcp_rtt_estimator(struct sock *sk, const __u32 mrtt) {
    struct tcp_sock *tp = tcp_sk(sk);
    long m = mrtt;

    if (tp->srtt_us) {                                    // 已有 SRTT
        m -= (tp->srtt_us >> 3);                          // SRTT 新减老
        tp->srtt_us += m;                                 // SRTT = (7/8)*旧 + (1/8)*新
        if (m < 0) m = -m;
        m -= (tp->rttvar_us >> 2);
        tp->rttvar_us += m;                                // RTTVAR EWMA
    } else {
        tp->srtt_us = m << 3;                              // 初值 = 样本 * 8(移位等价 ×8)
        tp->rttvar_us = m << 1;                            // = 样本 * 2
    }
}

// 再调 tcp_set_rto()
__u32 tcp_set_rto(struct tcp_sock *tp) {
    return usecs_to_jiffies(max(tp->srtt_us + (tp->rttvar_us << 2), 1));
}

等价数学:RTO = SRTT + 4 * RTTVAR封顶 RTO 受 sysctl 控制:

$ sysctl net.ipv4.tcp_min_rto_wtime net.ipv4.tcp_max_rto_wtime  # 实际只有 min/max
$ cat /proc/sys/net/ipv4/tcp_min_rtt_timeout     # 200ms
$ cat /proc/sys/net/ipv4/tcp_retries2            # 15(重传次数上限)

2.3 Karn's Algorithm (RFC 2988)

不能用"重传过的包"算 RTT——你没法区分收到的 ACK 是 ACK 原包还是 ACK 重传包。Karn 算法:重传过的包不参与 RTT 估计

2.4 Timestamp Option (RFC 7323)

解决 Karn 问题:每个 segment 携带 sender 写入时戳 TSval,receiver 在 ACK 中回 echo TSecr —— sender:

sample_rtt = now - TSecr

每个 ACK 都能算 RTT 即便重传过也准确。Linux 默认开 (net.ipv4.tcp_timestamps=2,4.13 后用 RTC mode == 2,旧 RTT=1)。

note

Timestamp TSval 是 单调递增(kernel usec 时钟),不是 wall clock。echo 回来 kernel 在 ACK 中直接读 TSecr,不必管理回声对应。

2.5 RTT 估错的灾难

家用 4G 移动网络 RTT 抖动 5x 是常态:

RTT 50ms (基站拥塞) → RTT 200ms (基站恢复)

EWMA α=1/8 要 8 RTT 才追上。期间 RTO = 50+4×50=250ms 远小于实际 200ms 的 ACK 到达 → 假阳性 RTO → cwnd=1 重启。

Linux 的 F-RTO (RFC 5682) 改善:

  1. RTO 触发,重传 + 等 ACK
  2. 来的 ACK 若推进窗口 → 当初 RTO 是 spurious → 撤销 cwnd=1
  3. 否则真丢 → 继续 slow start

net.ipv4.tcp_frto=2 默认。


三、RTO 调优盘

3.1 退避算法(RFC 6298 §2.5)

icsk->icsk_rto = max(icsk->icsk_rto, icsk->icsk_rto << 1);  // 每次超时翻倍
// tcp_retries1=3, tcp_retries2=15 -> 在到达 N 次后 abort

RTO指数退避:

触发次数RTO 倍(粗略)
1
2
3
4
516×
632×
764× (Linux 上限 60s)

tcp_retries2=15 最终 abort (~924 秒),默认。

3.2 慢启动阈值 + ER (Early Retransmit, RFC 5827)

cwnd < 4 时永远凑不齐 3 dup ACK,就要 RTO。ER 算法:DupThresh = max(2, cwnd-1) —— 让小 cwnd 也能早重传。

net.ipv4.tcp_early_retrans=3 Linux 4.x 默认。


四、SACK(RFC 2018,Linux 1999 默认开)

4.1 协议结构

TCP Header option:
+---------+---------+---------+---------+
| Kind=5  | Length  | LeftEdge 1 | ...
+---------+---------+---------+---------+

每 ACK 最多带 3 个 SACK block(受 option 40B 限制,4 个的话没空间给 TS)。

   sender                              receiver
     seq=10 ────────────> recv ok
     seq=20 ────────────> LOST
     seq=30 ────────────> recv ok
     seq=40 ────────────> recv ok
     seq=50 ────────────> LOST

     ACK 11, SACK [30, 40], [40, 50]
       ↑ 如何解读:
         我已确认的是 10-19
         我同时也收到了 30-39 和 40-49
         我没收到 20-29 和 50-59
       sender 知道只重传 seg 20 和 seg 50

4.2 sender 的 scoreboard

struct tcp_sock {
    struct sk_buff_head write_queue;
    struct tcp_sack_block scoreboard[TCP_NUM_SACK];  // 4 个 slot
    struct tcp_sack_block duplicate_sack[1];
    u32 sacked_out;            // SACKed 数
    u32 retransmitted;          // 重传次数
};

每个 SKB 维护 sk_buff_state_bits,标记 S7 (acked)、S_LOST 等。Linux 的 tcp_mark_head_lost 选择"应该重传"集合。

4.3 DSACK (Duplicate SACK, RFC 2883)

receiver 收到 sender 重传的包 → 完成 sender 重传任务。但发现:

ACK 100 (3001, 4000)        ← SACK block: 我没收到 30 号包
sender 收到 → 重传 seg 30
ACK 200 (3000, 3001)        ← 我之前误认丢了的 30 实际收到了,但新到的是重复

DSACK 块的左边界 < ACK 中序号 → 告知 sender "你重传多了"。

Linux 用 DSACK 触发 F-RTO + TCP mistimes summ:累计 3 DSACK → 信任 RTT 估计偏小,调高 RTO (tcp_rack_mark_lost 中的 spurious 检测)。

4.4 FACK / RACK

  • FACK (Forward ACK, 1996):用最右 SACK 边算 in-flight → RTO 更准
  • RACK (Recent ACK, RFC 8985):现代替代,2017 后 Linux 默认。核心思路

    如果我已经 ACK 了 seg X(时间 T_x),那 seg Y 必须在 T_y < T_x + reo_wnd(reorder window)前 ACK;否则认为 seg Y 丢了。

rack->xmit_time = 1000ms    // 上次重传 seg X 时间
rack->acked_time = 1100ms   // 新到达 ACK 时间
// 若 seg Y.xmit_time < rack.xmit_time 且 now - rack.acked_time > reo_wnd
// → seg Y 已 lost

RACK 不需要 dup ACK,也无须凑 3 次 → 抗乱序好、丢单包重传快、能处理 tail loss。


五、TLP (Tail Loss Probe, RFC 8985 同期)

5.1 问题描述

连接末端丢少数包,sender 永远不会收到 dup ACK(dup ACK 只在 cwnd 内后续包继续到达时产生)。末端要等 RTO(200ms+)

sender: 1,2,3,4,5  (cwnd=5 全发完)
                                seg 4 丢了,seg 5 也丢
receiver 收 1,2,3 → ACK 1,2,3 → dup 1, dup 1
sender dup < 3 不会触发 fast retransmit → 等 RTO

5.2 TLP 算法

cwnd 末端 tail-loss,发一个 probe 包(新数据或重传尾包),强制 receiver 回 ACK:

  • probe 触发 dup ACK → 启动 SACK/RACK → 选丢失重传
  • 不到 RTO 早 200ms 救活
if (loss_state == TCP_LOSS_TLP_PROBE) {
    if (acked_seq < probe_seq)
        tcp_xmit_retransmit_queue(sk, true);
    else
        tcp_rearm_rto(sk);
}

net.ipv4.tcp_tail_loss_probe=1(所有现代 kernel 默认)。


六、QUIC 重传范式

QUIC 在应用层重写一切,主要原因:TCP 中间盒缓存 seq + RST 注入风险

6.1 包号空间 (Monotonic Packet Number)

QUIC 给每个包号单调递增,不仅序号:

包号 1: data seq 1000-2000
        丢了重传 → 包号 2: 同 seq 1000-2000
receiver 收到 2 → 还有一个 ACK 空间注 '包1' 我没收到,但包 2 我收到了同样的 seq 数据 → 不再 ACK 包1 → sender 知道包 1 已收到

彻底解决 spurious retransmit 的 ACK 混淆。

6.2 ACK_DELAY

QUIC ACK option 含 ack_delay 字段,receiver 告诉 sender "我故意推迟 ACK 等 Xms"。sender 算 RTT 时扣回:

RTT = (now - send_time) - ack_delay
SRTT = (7/8)*SRTT + (1/8)*RTT

让 receiver 合并 ACK + 不污染 RTT 估计(RFC 9002)。

6.3 PTO (Probe Timeout)

QUIC 不再用 RTO + binary backoff,用 PTO:

PTO = smoothed_rtt + max(4*rttvar, kGranularity) + max_ack_delay
  • 指数 backoff,但也是探测而非"重传一切"。QUIC 没有僵死 last_ACK state。

七、生产事故

事故 1:跨机房 RTT 抖动 → RTO 雪崩

症状:跨城 ECMP 路径切换频繁,RTT 5ms → 50ms → 5ms 反复,TCP 流 throughput 跌落到 5%。

根因:(1) F-RTO 未启,(2) RTT 由于 timestamp option 在 path 1 设了 5ms,path 2 设了 50ms,SRTT 慢追;RTO 估 80ms 而实际 50ms 段丢 → spurious 重传 → cwnd 重启 N 次。

修复:开启 tcp_frto=2 + tcp_no_metrics_save=1(不缓存 metric),显式 RTT 时间窗。

事故 2:高频短连接的"RTO + retry2=15"问题

某 API gateway 100 ms 超时,但 server 残重 TCP,client 等不到 RST,504 频发。tcp_retries2=15 默认 924s 才 RST。client K8s 已重试到上游健康——浪费 100 倍超时。

修复

sysctl -w net.ipv4.tcp_retries2=8    # 降到 ~100s
# 应用层upe socket SO_KEEPALIVE + 超时回收

事故 3:TLP 误探测导致 throughput 减半

TLP probe 在小 cwnd 时被误认 seg seq 丢了,触发 fast recovery 减半。

修复:4.6+ 后内核修复 tcp_process_tlp_ack 判定条件,已不踩坑;旧 kernel 升级。

事故 4:BGP 邻居路由反复,RTO 起步值 1s → drop traffic

数据中心核心 BGP 路径切换 → RTT 真实 1ms 但路由过剩路径甚至更高,RTO 从 micro-RTT EWMA 出发但前若干包用 SYN 路径 RTO=1s 起步 → 触发后立即 RTO timeout。

修复:BBR 不依赖 RTT 估"丢",BBR tcp_early_retrans 也可以。


八、易错清单

  1. RTO ≠ srtt × 4:是 srtt + 4 * rttvar,必须有 RTT 偏差项
  2. 3 dup ACK 是 fast retransmit 阈值,但 ER/RACK 后 cwnd<4 也能触发
  3. 重传过的包不能算 RTT 估计(Karn 算法)——除非 timestamp option
  4. DSACK 给 sender 看:"你之前重传的其实是错的"——F-RTO 判定
  5. RACK 是 2017 后默认(kernel 4.4+),不要说 "TCP 用 SACK" 就行了——RACK 已是丢弃检测主力
  6. tcp_retries2 控制错误后的"放弃时间"——8 = 100s,15 默认 = 924s,短连接服务要降到 8
  7. QUIC 的 PTO 不是 RTO——探测式超时,没有 last_ack 静态

九、这一章带走的东西

  1. RTT 估计是 EWMA + 4σ 法则,Linux tcp_rtt_estimator 是教科书实现
  2. SACK → DSACK → F-RTO → RACK → TLP 是 20 年递进:治"假阳性"与"末端丢"两类痛点
  3. tcp_timestamps=2 + tcp_sack=1 + tcp_rack=1 是必需默认;任何 server 关闭都是事故源
  4. QUIC 用包号单调 + ack_delay 字段 + PTO 把所有 RTO 痛点根除——是 HTTP/3 用 QUIC 的副因,不是主因
  5. 生产看 nstat TcpExtTCPSpuriousRTOsTcpExtTCPLostRetransmitTcpExtTCPRetransFail 是否振荡
  6. tcp_retries2=15=924s 是隐藏坑——短连接服务一定要降到 8

下一节 →

HTTP 各版本 — HTTP/1.0 闭连接 → HTTP/1.1 keep-alive → HTTP/2 stream multiplexing + HPACK → HTTP/3 over QUIC。

HTTP / TLS

TL;DR

HTTP 是 TCP/IP 上请求-响应式的文本协议,从 1991 一条 GET 一个文件,到 1997 keep-alive、2015 HTTP/2 多路复用 + HPACK 二进制分帧、2022 HTTP/3 在 QUIC 上 0-RTT——每一代都在解决"单连接带宽用不满"的核心瓶颈,但中间路径上的 TCP 头队阻塞、TLS 握手 RTT、NAT/conntrack 重传、ISP 干扰造成不同年代方案。本文整理四代协议的核心触底点和现代部署的真实陷阱(HTTP/2 stream 互阻、QUIC 0-RTT 重放、HPACK 表溢出、缓存签名键值等)。


一、四代协议时间线

HTTP/0.9      1991   GET /file — 纯文本一字请求
HTTP/1.0      1996   method/headers/status code/Content-Type RFC 1945
HTTP/1.1      1997   keep-alive + Host + chunked + pipeline RFC 7230-7235
SPDY          2009   Google 多路复用实验
HTTP/2        2015   RFC 7540 — 二进制 frame + HPACK + server push
TLS 1.3       2018   RFC 8446 — 1-RTT 默认 + 0-RTT resumption
HTTP/3        2022   RFC 9114 — over QUIC + QPACK + TLS 1.3 内置

二、HTTP/1.0 → 1.1 → 2 → 3 对比表

维度HTTP/1.0HTTP/1.1HTTP/2HTTP/3
连接复用短连接keep-alive 默认开同 TCP conn 共享QUIC 内 stream 独立
并发请求串行pipeline (禁用主流)二进制多 streamstream 互不阻塞
头部编码ASCIIASCIIHPACK (Huffman + 动态表)QPACK
二进制
头队阻塞TCP + 应用层TCP + 应用层TCP 头队阻塞stream 内独立
加密可选 (SSL)可选 (TLS)实际 always TLSTLS 1.3 强制
握手 RTT3-way + TCP+1 RTT TLS+1 RTT TLS 1.30-RTT (重用) / 1-RTT
包号更新单调 seq单调 seq单调 seq包号单调递增
中间盒穿透良好良好良好UDP 仍可能被丢
主流部署已淘汰仍占 30%主流 (~50%)增长中 (~25%)

三、HTTP/1.1 的瓶颈

3.1 Keep-Alive + 串行响应

keep-alive: keep TCP connection
缺点: 客户端收到响应 1 再发请求 2 → 一个 RTT 内只能发一个请

3.2 Pipeline 在浏览器禁用

Pipeline 原理:客户端连续发 A, B, C 三个请求,server 按 A→B→C 顺序回。但:

  • server 响应慢的请求会阻塞后续响应(HoL)
  • 错误恢复(同时 404 和 200)容易出错
  • Nginx/CDN 部分支持
  • 浏览器默认禁用

3.3 多连接并发的代价

浏览器对同一域名开 6 个并行 TCP 连接:

  • 6 × (3-way handshake + TLS 1.2) = 6 × 4 RTT = 24 RTT 慢启
  • TIME_WAIT 堆积在服务端
  • TLS 多次 handshake 没共享 session

HTTP/2 的设计目标:一条 TCP 连接搞定所有请求


四、HTTP/2 深度

4.1 二进制 frame 格式

+---+----------------+---+--------+-------------+
| R | Length (24)    | T (8) | Flags (8) | StreamID (31) |
+---+----------------+---+--------+-------------+
| Payload (Length bytes)                              |
+--------------------------------------------------+

帧类型:

TypeName说明
0x0DATA数据
0x1HEADERS头部
0x2PRIORITY已废弃
0x3RST_STREAM流终止
0x4SETTINGS参数协商
0x5PUSH_PROMISEserver push (Chrome 105+ 已废弃)
0x6PINGkeepalive 探测
0x7GOAWAY关连接
0x8WINDOW_UPDATE流控
0x9CONTINUATION多帧连发

4.2 HPACK(RFC 7541)

HPACK 三道工序:

  1. 静态表(61 个常见 (name,value) 对)
  2. 动态表(连接内共享,FIFO 大小可设)
  3. Huffman 编码
原始 header:
:method: GET
:path: /api/users
user-agent: Mozilla/5.0...

HPACK 后:
0x82                    # :method GET 命中静态表 index=2
0x84 0x??               # :path / 命中静态表后随 path 字符
0x5f 0x92 ...           # user-agent 用 Huffman 编码长度 8 字节

800B ASCII header → 50B binary 实测。

SETTINGS_HEADER_TABLE_SIZE 默认 4096,超过时 sender 不再写动态表 → 失命中率上升。

4.3 Server Push 的失败

HTTP/2 设计里 server 可以在 client 还没请求时主动 PUSH_PROMISE 发送资源:

  • 用例:HTML 后立即 push CSS/JS
  • 痛点:缓存与 client cache 协调困难、重复推送、还占带宽
  • Chrome 105 (2022) 已移除支持
  • HTTP/3 RFC 9114 中 server push 同样存在但客户端默认 disable

4.4 HTTP/2 的 HoL 仍未解决

HTTP/2 把多 stream 复用在一条 TCP:

stream A    [data] [data] [data]
stream B    [data] [data] [data]
TCP 字节流   ¥¥¥¥¥¥¥¥¥¥¥¥

TCP 不懂 stream_id —— 任何一个 stream 的字节丢 → 整条流被卡住等重传 → 所有 stream 都停。 这就是 HTTP/3 / QUIC 诞生原因:stream 独立重传。


五、HTTP/3 / QUIC

5.1 协议栈

HTTP/3 → QUIC (含 TLS 1.3) → UDP → IP → Ethernet
HTTP/2 → TLS 1.3 → TCP → IP → Ethernet

QUIC 整合 transport + crypto:握手 + TLS 1.3 握手并发 1 RTT,session ticket 复用后0-RTT

5.2 包号单调递增解 ACK 混淆

每个 QUIC packet number 单调递增,重传的同一个 stream 数据用新包号

包 1: stream A data seq=10
       丢失 → 重传
包 2: stream A data seq=10  ← 包号变了!data seq 不变
receiver 收到包 2 → ACK 包 2
sender 不再等包 1 ACK(从 ack_ranges 看到包 1 没 ack 也认)

——彻底解决 Karn 算法类的 ACK 重复歧义。再加上 ack_delay 字段让 SRTT 估准。

5.3 0-RTT 的代价

session 1: client hello + TLS handshake 1 RTT → ESTABLISHED
           server 发 session ticket (encrypts resumption master secret)
session 2: client 直接用 ticket 立刻发加密 early data + ClientHello 同包共发
           → 0 RTT 拿到响应

代价:

  1. 重放攻击风险:early data 攻击者记录后重放 → server 仍接受
  2. 限制:early data 必须幂等(GET / 头 OK,POST 转账 NO)
  3. Allow-HTTP3-Token-Binding 与 application 协议必须协商

六、产线陷阱

6.1 HTTP/2 stream 反向拒绝服务

攻击者一次性开 1000 个 stream(HTTP/2 允许),每个只发 1 byte 后不发后续 → server 内存撑爆。CVE-2023-44487(HTTP/2 Rapid Reset):

  • client 发 RST_STREAM 后立即重开
  • 每开 RST 几乎没占内存,但 server 已分配 stream context
  • DDoS 放大因子 ~100

修复:所有 HTTP/2 server patch。Nginx 1.25.3、Apache 2.4.58、envoy 1.28 等。

6.2 0-RTT 重放下场

支付提供商曾用 0-RTT 提前 submit → attack 录流后重放到别处服务 → 用户重复扣款。

修复:业务层标 Early-Data: 1 时不接受 non-idempotent,或 server 端在 0-RTT 阶段只 decode + validate 不 exec。

6.3 UDP 穿透率

QUIC 走 UDP,但企业防火墙、NAT 设备对 UDP 容忍率低于 TCP:

  • 部分 ISP 限速 UDP(怕 amplification)
  • 企业防火墙显式丢
  • Cloudflare 报告 TCP fallback 仍占 HTTPS 业务 70%

6.4 HPACK 工具 vs 通配 cast

诊断工具(curl、wireshark)不直接读 HPACK,需要 nghttp / net-http2.dll 包解析。建议抓包用 tcpdump -w out.pcapng + wireshark 加 HTTP/2 dissector。


七、对比实验数据

WebPageTest 同站 150 个小图片,RTT 100ms:

配置主要特征典型 LCP
HTTP/1.1 6 连接短连接+keep-alive + TLS 1.24.5 s
HTTP/1.1 keep-alive chunked单连接串行5.5 s
HTTP/2 单连接多 streamTLS 1.2 + HPACK2.8 s
HTTP/3 over QUIC0-RTT resumption2.1 s

跨机房 RPC 测试,RTT 50ms:

协议QPS (4 core client)
HTTP/1.1 (keep-alive)12k
HTTP/2 (single stream per req)18k
gRPC over HTTP/2 (unary)22k
gRPC + connection pool50k+
QUIC(quic-go)28k

八、抓帧实验(Go)

package main

import (
    "log"
    "net"
    "net/http"
    "golang.org/x/net/http2"
    "golang.org/x/net/http2/h2c"
)

func main() {
    // 抓 HTTP/2 frame 的示例
    // 用 net.ListenPlain 配合 http2.Framer
    ln, _ := net.Listen("tcp", ":8080")
    fr := http2.NewFramer(nil, nil)
    _ = fr
    _ = ln
    _ = http.Server{Handler: h2c.NewHandler(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
        log.Printf("stream=%d path=%s", r.ProtoMajor, r.URL.Path)
    }), &http2.Server{})}
}

tcpdump + nghttp -nv

$ tcpdump -i eth0 -w /tmp/http2.pcap 'tcp port 443'
$ nghttp -nv https://example.com
[  0.001] send HEADERS frame <length=35, flags=0x05, stream_id=1>
          ; END_STREAM | END_HEADERS
          (padlen=0)
          ; Open new stream
          :method: GET
          :path: /
          :scheme: https
          :authority: example.com

九、这一章带走的东西

  1. HTTP/2 解决 HTTP/1.1 应用层 HoL,但被 TCP HoL 反 lock
  2. HTTP/3 用 QUIC 在 UDP 上重做:包号单调 + ack_delay + stream 独立重传彻底解 TCP 痛点
  3. HPACK = 静态表 + 动态表(per-conn FIFO)+ Huffman;表超限先丢 dynamic entries
  4. Server Push 在 HTTP/2 已基本死,HTTP/3 客户端默认 disable
  5. 0-RTT 限幂等请求;CVE-2023-44487 Rapid Reset 必须升级 server
  6. UDP 穿透率仍 FFT 内难以超过 40%;公网 CDN HTTP/3 仍配 TCP fallback

下一节 →

TLS 1.3 深入 — handshake 0-RTT、AEAD、PSK、session ticket、客户端证书、群组协商

HTTP 1.0 → 1.1 → 2 → 3

TL;DR

四代 HTTP 都在解决同一问题——单连接带宽用不满。从 keep-alive 到多路复用,从 ASCII 到 HPACK 二进制帧,从 TCP 承载到 QUIC UDP——每一代的真正动机、协议字节、landing 接入陷阱、产线事故全部展开。本文让你看 digest 时能写出 wireshark 字节布局、讲清 RFC 关键设计点,回答"HTTP/2 HoL blocker vs HTTP/3 strictly better" 这类面试与现场问题。


一、HTTP/1.0 (RFC 1945, 1996)

1.1 协议原始格式

GET /index.html HTTP/1.0
User-Agent: Mozilla/1.0
Accept: text/html

HTTP/1.0 200 OK
Date: Mon, 12 Aug 1996 05:30:00 GMT
Server: NCSA/1.4.2
Content-Type: text/html
Content-Length: 1234

<html> ...

特点:

  • 每次请求必新建 TCP 连接,结束就 FIN
  • header 都是 ASCII,CRLF 结尾,空行结束 header
  • 必须用 Content-Length 或关闭连接表示响应结束
  • 无 Host header → 一 IP 一域名 → 不能 name-based virtual host

1.2 keep-alive 非标准

虽 RFC 1945 定义的是短连接,但实际浏览器 + Apache 都用非标准 extension:

Connection: keep-alive

让 TCP 连接留住一会儿复用。是 HTTP/1.1 默认行为的来源。


二、HTTP/1.1 (RFC 7230-7235, 1997, 2014 重写)

2.1 关键改动

  1. Connection: keep-alive 默认:要关闭需 Connection: close
  2. Host header 强制:实现 name-based virtual hosting
  3. chunked transfer encoding:响应没 length 时可流式分块
  4. pipeline:可批量发请求但响应必须按序

2.2 chunked 编码

HTTP/1.1 200 OK
Transfer-Encoding: chunked
Content-Type: application/json

7\r\n
Mozilla\r\n
9\r\n
Developer\r\n
7\r\n
Network\r\n
0\r\n
\r\n

每 chunk 前 16 进制长度 + CRLF,0\r\n\r\n 结束。让 server 在不知道总长时流式发送——典型场景:ssevent stream、动态生成 PNG、tape archive。

2.3 pipeline 的实战死法

pipeline 理论让 client 一次发 N 个 request 不等响应,server 必须按序回。但:

client → server: 1, 2, 3 (pipeline)
server 处理 1 需 2 s; 处理 2/3 各 50 ms
**1 的响应 slow → 2/3 响应也卡**

更头疼:proxy 链 + pipeline → 反序响应、错配流难诊断;opportunistic IDemPotent OWASP 攻击 → CVE。最终:

  • 浏览器(Chrome、Safari)禁用 pipeline
  • Nginx 接收 pipeline 但单连接内仍是按序回
  • HTTP/2 用 stream + 二进制帧取代这个设计

2.4 keep-alive 实战

server.conf:
keepalive_timeout 75;       # 同上 idle 75s 后强制 FIN
keepalive_requests 100;     # 100 个请求后强制重连(防内存碎片)

完全用满 keepalive:

  • 节省每请求 2-RTT(TCP + TLS handshake),100 ms RTT 下吞吐 ×6
  • 但每个 idle 连接占 30KB+ 内存 + 一个 conntrack entry (300 bytes)
  • 50k client × 100 = 5M conntrack → 必须配 nf_conntrack_max 与 LVS pool

2.5 keep-alive 仍存在的痛点

  1. TCP HoL:单连接内串行响应 → 慢请求阻塞快请求
  2. 6 conn 并发上限:浏览器对同域名起 6 个 TCP 加速 → 但每个都要 TLS handshake 浪费 RTT
  3. slow start:每个新 TCP 都从 cwnd=1 起跑数 RTT 才达工作带宽

三、HTTP/2 (RFC 7540/9113, 2015/2022)

3.1 设计目标:单连接多 stream

       ┌── stream 1 (request A, response A)
client ─── stream 3 (request B, response B)        ── server (1 TCP conn)
       └── stream 5 (request C, response C)

每个 stream 在同 TCP 内通过二进制 frame 交错,独立 flow control + 优先级。

3.2 Frame 字节布局

+-----------------------------------------------+
|                 Length (24)                    |
+---------------+---------------+---------------+
|   Type (8)    |   Flags (8)   |
+-+-------------+---------------+-------------------------------+
|R|                 Stream Identifier (31)                      |
+=+=============================================================+
|                   Frame Payload (0...)                      ...
+---------------------------------------------------------------+

9 字节固定头 + payload(最大 16 MB 可配)。

3.3 帧类型完整清单

TypeName说明
0x0DATA请求体或响应体
0x1HEADERS压缩 header(HPACK)
0x2PRIORITYstream 优先级(H2 deprecated,H3 已删)
0x3RST_STREAM重置单个 stream
0x4SETTINGS参数协商
0x5PUSH_PROMISEserver push(已 deprecate)
0x6PINGkeepalive 探测
0x7GOAWAY整连接优雅关闭
0x8WINDOW_UPDATE流控窗扩容
0x9CONTINUATIONHEADERS 续帧(分解较大 HPACK)
0xaPRIORITY_UPDATE(HTTP/2 ext)

3.4 SETTINGS 协商

SETTINGS_MAX_CONCURRENT_STREAMS    100 (server 默认)
SETTINGS_INITIAL_WINDOW_SIZE       65535 (B, 单 stream 流控)
SETTINGS_MAX_FRAME_SIZE            16384 (16 KB)
SETTINGS_HEADER_TABLE_SIZE         4096 (HPACK dynamic table)
SETTINGS_ENABLE_PUSH               0   (client 显禁 server push)

3.5 流控三层结构

连接级 WINDOW_UPDATE   ← 控总量 (default 64 KB)
       ↓
stream 级 WINDOW_UPDATE ← 每流控 (default 64 KB)
       ↓
应用读 bufferHashSet 不通知原则 → TCP 当 wire 上暂存

server 单 stream 攻击者开 1000 stream 占 64 MB,server 默认就死守。生产 defaults 应缩小:

http2_max_concurrent_streams 32;
http2_max_field_size 4k;

3.6 gRPC = HTTP/2 + protobuf

POST /helloworld.Greeter/SayHello HTTP/2
content-type: application/grpc+proto
te: trailers

<length-prefixed protobuf bytes>
grpc-status: 0
grpc-message: OK

四种 RPC pattern:

  1. unary:1 req → 1 resp
  2. server stream:1 req → N resp
  3. client stream:N req → 1 resp
  4. bidi:N req ↔ N resp(每 req 一个 frame)

gRPC stream 用 HTTP/2 stream id 复用 → 一 TCP gRPC 连接同时跑多个 client/server stream 调用:

client.go (示例)
conn, _ := grpc.Dial("localhost:50051",
    grpc.WithTransportCredentials(insecure.NewCredentials()),
    grpc.WithDefaultCallOptions(
        grpc.MaxCallRecvMsgSize(16 * 1024 * 1024),
    ),
)
client := pb.NewGreeterClient(conn)
resp, err := client.SayHello(ctx, &pb.HelloRequest{Name: "world"})

四、HTTP/3 (RFC 9114, 2022) over QUIC

4.1 帧字节布局

QUIC packet header (short / long form) + payload (含加密后的 HTTP/3 frame):

0                   1                   2                   3
+0+0+0+0+0+0+0+0+0+0+1+1+1+1+1+1+1+1+1+1+2+2+2+2+2+2+2+2+2+2+3+3
+0+1+2+3+4+5+6+7+8+9+0+1+2+3+4+5+6+7+8+9+0+1+2+3+4+5+6+7+8+9+0+1
+-+-+-+-+-+-+-+-+
|0|1|  Form Bit |
+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+
|                         Packet Number (8/16/32)               |
+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+
|                       Encrypted Payload (variable)          ...
+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+

每个 frame(HTTP/3 自有的帧结构):

+------+--------+
| Type | Length |
+------+--------+--------+
| Frame Payload (Length bytes) |
+------+--------+--------+
  • Type 0x00 / 0x01 / 0x02 / 0x04 / 0x05 / 0x07 / 0x0d 等
  • 比 H2 多一个 type for transport-level 信号(GOAWAY, SETTINGS, HEADERS, DATA, CANCEL_PUSH

4.2 QPACK vs HPACK

QPACK 用相同思想(静态表 + 动态表 + Huffman),但:

  • 动态表更新和 stream 数据异步,避免阻塞 head-of-stream
  • 静态表 99 / 99 字段(vs HPACK 61)
  • 更高的失命中率代价

实测:HPACK 压缩比 16×;QPACK 在乱序 stream 场景 12-15×,常规场景与 HPACK 相当。

4.3 0-RTT 细节

session ticket 用 PSK:

client 第 1 次连接 (TLS handshake 1 RTT)
client ← server: NewSessionTicket (encrypts resumption_master_secret)

client 第 2 次连接 (用 ticket 加密 early data)
client → server:  SYN+早期 ClientHello + 早期 data (0-RTT)
                  → 此包 server 已能解密 data → 直接处理 GET /index.html
                  → server 同样返回早期 response (0-RTT)
        client 服务端发回 Finished + 主后续 1 RTT 升级 full handshake

早期 data = replayable。攻击者可记录再重放 → server 不能区分,因此:

  • early data 只能做幂等请求(GET、HEAD、OPTIONS)
  • POST/PUT 必须等 1-RTT 完成后才能发出(client 自己控制)
  • server 可识别 Early-Data: 1 header 拒绝某些路由

4.4 不同部署版本对比

环境HTTP/3 占比
Cloudflare 主流客户~25%下行
Google Web~60%
Microsoft Edge traffic15%
中国国内 IDC<5%(UDP 不通)
公网移动网络慢速增长

五、产线事故

事故 1:CVE-2023-44487 Rapid Reset

症状:2023-08 一周内世界几大云 + CDN 全 DDoS (Cloudflare 398M rps、AWS 155M rps)。

根因:HTTP/2 允许 client 快速 HEADERS + RST_STREAM,server 端的 stream context 在 RST 后不会立刻释放 → 单 client 通过高频 cycle 占尽 server 内存。

修复:升级 Nginx 1.25.3 / Apache 2.4.58 / envoy 1.28 / h2o 2.2.7+。把每 client 的 max_concurrent_streams 设小、RST 频率限速。

事故 2:gRPC stream leak

gRPC client 不 CloseSend() 即直接 release → server 不知 stream 结束 → server 保留 stream 直到 idle timeout (1h) → 内存撑爆。

修复:always defer stream.CloseSend() + ctx cancel,监控 grpc_server_stream_started 长寿命数。

事故 3:0-RTT 在电商被 replay

支付订单走 0-RTT 优化 → 攻击者录流后从 VPN 重放 → 重复扣款。

修复:注入 ticket 时用 IP 绑定 + nonce,server 端用 Early-Data: 1 标识后强制主握手才允许写。

事故 4:HPACK 表小导致吞吐腰斩

某 nginx 启了 http2_max_header_list_size 4k 但 client 发的 1KB+ 头:HPACK 之后动态表超过了 4k 上限 → server 拒绝大头部请求 → 多余 stream 失败。10721 错误率 30%+。

修复:把 http2_max_header_list_size 提到 16k,并考虑 http2_max_field_size 8k

事故 5:HTTP/3 UDP 中间盒丢

某企业 LAN FW:UDP 默认 drop,HTTPS H3 探测 100% 失败 → fallback TCP HTTP/2,但部分 client 没配 fallback → 静默墙内访问慢。

修复:业务客户端配置 protocols: ['h3', 'h2'],自动降级。


六、易错清单

  1. HTTP/2 与 HTTP/3 不能简单"换":HTTP/3 在 QUIC 上,QUIC 在 UDP 上,TLS 1.3 内嵌
  2. Connection: keep-alive 在 HTTP/2 中不再有意义(HTTP/2 自带持久),但兼容性仍允许
  3. pipeline ≠ HTTP/2 stream:前者是 1.1 RFC 7230 历史遗物,已 deprecated
  4. HTTP/2 必须 over TLS 是 RFC 9113 强约束,h2c (HTTP/2 cleartext) 仅内网用
  5. 0-RTT 限幂等:业务必须考虑 replay 防御
  6. gRPC stream 不会自动 GC:必须显式关闭、或 ctx cancel
  7. HPACK dynamic table 是 per-connection,不会跨连接传给多 IDLE → 表失命中率接高 → 帧足够数据就降级到全 Huffman

七、这一章带走的东西

  1. HTTP/1.1 → HTTP/2 用二进制 frame + HPACK + stream 多路复用解决"单连接串行响应"瓶颈
  2. HTTP/2 没解决 TCP 层 HoL,HTTP/3 在 QUIC UDP 上重写,stream 独立重传 + ack_delay + 单调包号
  3. 0-RTT、HPACK dynamic table、gRPC stream leak 是 push prod 的三条最常见坑
  4. CVE-2023-44487 HTTP/2 Rapid Reset 是所有 HTTP/2 server 强制升级触发线
  5. UDP 穿透率是 HTTP/3 的硬约束,必须配 h2 fallback
  6. nghttp -nv <url> + wireshark HTTP/2 dissector 是抓帧主力

下一节 →

TLS 1.3 深入 — 1-RTT handshake / 0-RTT resumption / AEAD / session ticket / 证书协商

TLS 1.3 与现代加密握手

TL;DR

TLS 1.3(RFC 8446, 2018)从 2-RTT 握手降到 1-RTT,session resumption 进一步 0-RTT。本节从 RFC 字节布局追到 openssl s_server 选项、PSK 与 session ticket 的骗酮、AEAD 加密数据流、ECH (Encrypted Client Hello) 的部署现状。重点:解释为什么 TLS 1.3 比 1.2 安全、0-RTT 的重放代价、出口运维必备的 ssl_session_cache 调优。


一、TLS 1.2 → TLS 1.3 演进

1.1 TLS 1.2 完整握手 2 RTT

client                                          server
  → ClientHello                                   →
                                                  ← ServerHello
                                                  ← Certificate
                                                  ← ServerKeyExchange
                                                  ← ServerHelloDone
  → ClientKeyExchange (ECDHE pubkey)              →
  → ChangeCipherSpec
  → Finished
                                                  ← ChangeCipherSpec
                                                  ← Finished
  → Application Data                              →

特征:

  • ServerHello 之后全明文,包括证书与 server ECDHE 公钥
  • 2 RTT 才能发第一个应用字节
  • 加密 + 完整性握手分开(ChangeCipherSpec
  • 套件数量 30+,包含 CBC(易出 BEAST/POODLE/Lucky13)、static RSA(无前向安全)、MD5/SHA1

1.2 TLS 1.3 1-RTT 握手

client                                          server
  → ClientHello {key_share, psk, ...}            →
                                                  ← ServerHello {key_share, suite}     ← 此处已加密
                                                  ← EncryptedExtensions
                                                  ← Certificate
                                                  ← CertificateVerify
                                                  ← Finished
  → Finished                                       →
  → Application Data                              →
                                                  ← Application Data

ServerHello 之后所有字节已加密 — 证书、签名、扩展全部在内。1 RTT 后 client 立即发应用数据。

为什么 1 RTT 够了?因为 client 在 key_share extension 中直接带 ECDHE 公钥,server 不必等 client_key_exchange 也能算出 handshake secret → 加密可立即起步。


二、TLS 1.3 详细字段

2.1 ClientHello body

struct {
    ProtocolVersion legacy_version = 0x0303;       // 仍写 1.2 协商
    Random random;                                  // 32 字节
    opaque legacy_session_id<0..32>;                // 1.2 兼容, 1.3 设为非空, 后看 server 回应是 sts 还是真的 mode
    CipherSuite cipher_suites<2..2^16-2>;           // MUST 全部 AEAD
    opaque legacy_compression_methods<1..2^8-1> = {0};
    Extension extensions<8..2^16-1>;
} ClientHello;

extensions: supported_versions, key_share, supported_groups,
            signature_algorithms, server_name (SNI),
            pre_shared_key, psk_key_exchange_modes,
            early_data (for 0-RTT),
            ...

2.2 ServerHello body

struct {
    ProtocolVersion legacy_version = 0x0303;
    Random random;
    opaque legacy_session_id_echo<0..32>;          // 回显 client 的
    CipherSuite cipher_suite;                       // 选定
    opaque legacy_compression_method = 0;
    Extension extensions<6..2^16-1>;
} ServerHello;

extensions: supported_versions (0x0304), key_share, pre_shared_key
                            (if PSK only mode)

2.3 加密握手 secret

# TLS 1.3 HKDF 提取
early_secret  = HKDF-Extract(0, PSK_or_zero)
derived       = HKDF-Expand-Label(early_secret, "derived", "", Hash.length)

# 公钥约定后
handshake_secret = HKDF-Extract(derived, ECDH(cli_priv, srv_pub))

# 再扩
finished_key  = HKDF-Expand-Label(handshake_secret, "finished", "", Hash.length)
client_traffic_secre = HKDF-Expand-Label(handshake_secret, "c hs traffic", hello_hash, ...)
server_traffic_secret = ...

# 终态
master_secret = HKDF-Extract(derived, handshake_secret)

HKDF-Expand-Label 是 TLS 1.3 自有结构 (label + context + length) → 防 Cross-Protocol Confusion攻击。

2.4 套件只剩 5 个

TLS_AES_256_GCM_SHA384
TLS_AES_128_GCM_SHA256
TLS_CHACHA20_POLY1305_SHA256
TLS_AES_128_CCM_SHA256
TLS_AES_128_CCM_8_SHA256

key exchange 走 NIST P-256/P-384/X25519;签名走 RSA-PSS/ECDSA/Ed25519。

2.5 TLS 1.3 砍掉的设计

1.2 有1.3 删掉原因
CBC modeBEAST/POODLE/Lucky13
static RSA key exchange无前向安全
MD5/SHA1弱 hash
CompressionCRIME/BREACH
RenegotiationCVE-2009-3555 三向攻击
DHE_RSA cipher suites已被 ECDHE 取代
Camellia/3DES/IDEA性能 + 兼容
数字签名+cipher 解耦套件爆炸管理困难

三、0-RTT / PSK / Session Ticket

3.1 resumption PSK

第 1 次握手 (1 RTT)
client → server: ClientHello, key_share, psk=None
...full handshake...
client ← server: NewSessionTicket { ticket_age_add, ticket_nonce, ticket, lifetime }

第 2 次 (0 RTT)
client → server: ClientHello with
         psk=<ticket_bytes>, key_share=<new pubkey>, early_data="GET / HTTP/1.1\r\nHost: ..."
                      ← encrypted with PSK-derived early traffic secret
server 解密 early_data → 直接处理 HTTP request
server 验 PSK → 1-RTT 完成 full handshake → 后续主 stream

3.2 0-RTT 安全分析

重放攻击

attack 软件 client hello + early data (1 packet, 总 ~1KB)
→ 可在下次 resumption 前发到 server
→ server 接受并执行 early_data 中的请求

防御对策:

  1. 严格限制 early_data 仅幂等方法(GET/HEAD/OPTIONS),POST/PUT 等强制等 1-RTT 完成后才 dispatch
  2. server 维护 ticket nonce 单调计数器 + 时间窗口,防同一 PSK 重放多次(这是 RFC 8446 §8 推荐,但实际部署少)
  3. 业务标 Early-Data: 1 路由不执行非幂等
  4. server 端用 anti-replay 数据库 (Cloudflare、Akamai 维护)

3.3 session ticket 保管

ticket 是 server 用本地 secret 加密的 server-side state:

  • 优点:server 端不必存所有 session state(信用卡大小条)
  • 漏洞面:secret 泄漏 → 所有 ticket 可被解密 → 历史流量也可解 (if replayed to server)
  • 解决:定期轮换 ticket encryption key (Let's Encrypt daily)
# enable session ticket with rotation
SSL_CTX_set_session_ticket_keys(...)
httpd 2.4 配置:
SSLSessionTickets on
SSLSessionTicketKeyFile /var/cache/tls/keyfile
# rotation cron:
0 3 * * * for i in $(seq 1 3); do openssl rand 48 >> /var/cache/tls/keyfile.current; done

3.4 PSK-only mode (no ECDHE)

只 PSK 无 ECDHE → 没有 PFS(forward secrecy)。TLS 1.3 默认推荐 psk_ke_mode = psk_dhe_ke — 仍做 ECDHE 在 PSK 后,保留 PFS。

warning

psk_ke_mode 关成 PSK-only 是失败的,会丢掉 PFS。生产应保持默认 psk_dhe_ke


四、SNI + ECH (Encrypted Client Hello)

4.1 SNI 暴露域名

ClientHello
  Extension: server_name
    server_name_list
      name_type=host_name
      hostname=www.eff.org     ← 中间盒 / ISP 可看

政府防火墙、企业流量分析、ISP 量化用户画像都靠明文 SNI。

4.2 ECH (RFC 9460, 2023)

加密 ClientHello inner:

client → resolver: HTTPS RR 查询 example.com
                     ↑ 用 DoH/DoT 防 DNS poisoning
                     ↓ RR 携带 public key ECHConfig
       client 发 DNS IPv4 + ECHConfig False.

client → server: ClientHello
                     outer: SNI = cloudflare-ech.com (placeholder)
                     inner: SNI = user.real.site (encrypted)
server 解 inner SNI → 选择对应 vhost cert 上手响应

部署:Cloudflare 全网已支持,主流用户探测 40% 启 ECH。Chrome 110+ 默认支持 ECH bootstrap。

4.3 ESNI 历史废弃

ESNI (draft-ietf-tls-esni-01) 是 ECH 早期版本,已被 RFC 9460 取代。任何 KB 里搜到 ESNI 配置都已过时。


五、TLS termination 实战

5.1 部署模式

模式 A:Ingress LB → 后端 cleartext
   client ──TLS──> L7 LB ──http──> backend
   优点: 后端简单
   缺点: 内网明文,溯源信任弱,需要 LB 做完所有 mTLS

模式 B:End-to-End TLS
   client ──TLS──> LB ──TLS──> backend
   优点: 端到端真实证书-verif
   缺点: LB 不 inspect content, 不能为 routing 决策

模式 C:mTLS service mesh
   client ──mTLS──> sidecar (Envoy) ──mTLS──> sidecar (Envoy) ──> app
   优点: zero trust + cert rotation + 位透明
   缺点: sidecar 性能收益低, 需要 SPIFFE/SPIRE

5.2 cert-manager + Let's Encrypt

apiVersion: cert-manager.io/v1
kind: Certificate
metadata:
  name: example-com
spec:
  secretName: example-com-tls
  dnsNames:
    - example.com
  issuerRef:
    name: letsencrypt-prod
    kind: ClusterIssuer

rotation:90 天 LE cert,cert-manager 提前 30 天重新申请;kube webhook 推 local ingress secrets。

5.3 调优 nginx

ssl_protocols TLSv1.3;                         # 不建议 1.2 (除非兼容)
ssl_prefer_server_ciphers off;                 # 1.3 后失去意义
ssl_session_cache shared:SSL:50m;               # 50MB ≈ 400k sessions ticket
ssl_session_timeout 1d;
ssl_session_tickets off;                        # 关 ticket 改用 cache (开发常用)
ssl_early_data on;                              # 启 0-RTT
add_header Early-Data $http_early_data;
add_header Alt-Svc 'h3=":443"';

spring-level 调参:

  • 大量并发 + short connect → cache 用 shared (worker pool 共享) > builtin (per-worker 只能见本 worker)
  • 长 idle pool 内存压力大 → proxy_ssl_session_reuse off 短片 brokerü

5.4 OpenSSL / BoringSSL / LibreSSL 对比

维护方性能支持
OpenSSLOMC1-1×所有版本最全
BoringSSLGoogle1.2×Chrome、Android、QUIC
LibreSSLOpenBSD1.0×部分版本无(缺 HRR/PSK)
AWS-LCAWS1.2×AWS 内部主流

BoringSSL 不公开支持都包含 TLS 1.3 + 0-RTT + ECH (long-term display),Project Wycheproof 已坚守 formal verification。


六、产线事故

事故 1:Session Ticket 密钥轮换漏

运维轮换 nginx ssl_session_ticket_key没同步到 backup LB → backup 一段时间 ticket 解密失败 → resumption 全部 fallback 到全握手 1-RTT。Ops 监控 TLS handshake RTT 突涨 100ms。

修复:cert-manager 风格 rotated key file 共享给所有 LB / 容器。

事故 2:0-RTT POST 交易被重放

某 wallet 服务启 0-RTT,攻击者抓 client 飞行时帧从 VPN 重放 → 重复一笔转账。损失 7 位数。

修复:应用层 hot path 业务路由 inspect Early-Data: 1 详究路由非幂等 → return 425 Too Early RFC 8470。Nginx 必修配置 proxy_set_header Early-Data $ssl_early_data; + proxy_pass 后端识别拒绝非幂等。

事故 3:CertificateVerify 签名算 算服务器选弱 curve P-224

某旧 PKI 工具组生成 ECDSA 证书用 P-224。后 ECC P-224 安全 key ~112 bit,近日渐进弱点已知。

修复:注册前 enforce key_share 仅 X25519 / P-256 / P-384,签 CA盖章替换。

事故 4:ECH 部分启用,残留 SNI 拦截

业务启了 ECH 但只覆盖 30%,部分客户还是 SNI 明文 →部分路径中间盒掉 + RSA 证书 reverse event。

修复:合 client ✗ service roster 全 ECH on + ECHConfigList DNS RR 监控覆盖度。

事故事 5:BoringSSL + client Hello 大于 1 个 MSS

某 grpc client 把所有 custom metadata 放到 ClientHello 让其超过 1460B → 触发 TLS handshake 分片,path MTU 不达 MSS → SYN+ACK → fragmented DDoS detection → reset。

修复:减 extra metadata 大小 + 集中 metadata 移到 HTTP/2 stream。


七、易错清单

  1. TLS 1.3 套件数 < 5 — 不存在 SHA1/CBC/RSA-static,所有"TLS 1.3 RC4"配置都是错的
  2. session ticket key 必须定期 rotation,否则 ticket 泄漏 = 历史流量可解
  3. 0-RTT 不能用于非幂等请求 — 业务必须区分 early_data
  4. TLS handshake 必须 inspect key_share 中的 curve,禁止 P-521 (跨设备)
  5. ECH 部分启用比不启用还危险 — 中间盒按 SNI 还是能锁定 6. ssl_session_tickets off 不一定优 — ticket 比 cache 适合多 worker 模式

八、这一章带走的东西

  1. TLS 1.3 把握手 Schneide 变成 1 RTT + ECDHE in ClientHello,套件降为 5 AEAD + 全前向安全
  2. 0-RTT 用 session ticket 编密 PSK,只能幂等请求;server 必须靠 Early-Data 标识走 anti-replay
  3. SNI 明文是主流监控 GREAT-FW *ISP 监控手段,ECH 用 DNS HTTPS RR 公钥 inner SNI 解掉
  4. Session ticket 的密钥轮换与 secret 保护是大规模部署隐蔽坑
  5. mTLS service mesh + SPIFFE 是 zero trust 内部 + cert 轮换的工业标准
  6. 监控 nstat TcpExtTCPRcv + OpenSSL stat 可以看到 TLS handshake RTT 分布

下一节 →

证书链与 PKI — X.509 字段、CA 链验证、OCSP 与 stapling、CRLite、ACME、HPKP。

证书链 / PKI / OCSP stapling

TL;DR

TLS 给 client 验证 server 身份的两个核心问题:(1) 证书是谁签的,怎么验?(2) 证书撤回了吗?前者是 X.509 + CA 信任链;后者是 OCSP、CRL、Must-Staple、CRLite。本节讲 ASN.1 证书的实际字段,为什么 CA 不能 root 全签叶证书,OCSP 实时性的代价,Let's Encrypt 用 ACME 流水化的新时代签发,以及 HPKP 锁死 CTO 自杀事故。


一、X.509 v3 证书真实结构

X.509 证书是 ASN.1 DER 编码,RFC 5280 定义结构:

Certificate ::= SEQUENCE {
    tbsCertificate      TBSCertificate,    ← 真正证书主体
    signatureAlgorithm  AlgorithmIdentifier,
    signatureValue      BIT STRING          ← CA 对 tbsCertificate 的签 hash
}

TBSCertificate ::= SEQUENCE {
    version         [0] EXPLICIT Version DEFAULT v1,
    serialNumber    CertificateSerialNumber,
    signature       AlgorithmIdentifier,
    issuer          Name,                   ← 签发机构名
    validity        Validity,               ← notBefore / notAfter
    subject         Name,                   ← 主体名 (CN, O, OU, C...)
    subjectPKI      SubjectPublicKeyInfo,
    issuerUID      [1] IMPLICIT BIT STRING OPTIONAL,
    subjectUID     [2] IMPLICIT BIT STRING OPTIONAL,
    extensions     [3] EXPLICIT Extensions OPTIONAL  ← v3 only
}

1.1 关键扩展

ExtensionOID用途
subjectAltName2.5.29.17多域名 + wildcard (e.g. DNS:*.example.com, IP:10.0.0.1)
basicConstraints2.5.29.19CA:TRUE/FALSE + pathLenConstraint
keyUsage2.5.29.15digitalSignature, keyEncipherment, ...
extKeyUsage2.5.29.37serverAuth, clientAuth, codeSigning
authorityKeyIdentifier2.5.29.35找签发者 cert
subjectKeyIdentifier2.5.29.14当前证书指纹
certificatePolicies2.5.29.32OV/EV 策略
authorityInfoAccess1.3.6.1.5.5.7.1.1包含 ocsp URL + caIssuers URL
crlDistributionPoints2.5.29.31旧式 CRL URL
sCTList1.3.6.1.4.1.11129.6.2透明度日志 SignedCertificateTimestamp

1.2 实际样例

$ openssl x509 -in example.com.crt -text -noout
Certificate:
    Data:
        Version: 3 (0x2)
        Serial Number:
            04:8b:d9:3a:dc:3d:71:78:e1:98:7a:14:e7:eb:67:8d
    Signature Algorithm: ecdsa-with-SHA256
        Issuer: CN=E5, O=Let's Encrypt, C=US
        Validity
            Not Before: Aug 12 04:30:00 2024 GMT
            Not After : Nov 10 04:30:00 2024 GMT   ← 90 天有效期
        Subject: CN=example.com
        Subject Public Key Info:
            Public Key Algorithm: id-ecPublicKey
                Public-Key: (256 bit)
                pub: 04:9d:5b:28:7b:6e:... 38 bytes
                ASN1 OID: prime256v1
                NIST CURVE: P-256
        X509v3 extensions:
            X509v3 Key Usage: critical
                Digital Signature
            X509v3 Extended Key Usage:
                TLS Web Server Authentication, TLS Web Client Authentication
            X509v3 Basic Constraints: critical
                CA:FALSE
            X509v3 Subject Key Identifier:
                2A:3B:F0:E1:38:65:21:5F:AC:9F:7E:F0:F0:B3:4E:12:FF:61:75:7B
            X509v3 Authority Key Identifier:
                keyid:8D:02:78:81:68:91:80:71:52:8C:A3:48:2B:99:9D:1F:5F:86:E5:6F
            X509v3 Subject Alternative Name:
                DNS:example.com, DNS:www.example.com
            Authority Information Access:
                OCSP - URI:http://e5.o.lencr.org
                CA Issuers - URI:http://e5.i.lencr.org/
            CT Precert SCTs:
                log_id 1: ... 
                log_id 2: ...
    Signature Algorithm: ecdsa-with-SHA256
         30:45:02:20:54:ac:.....

1.3 ASN.1 / DER 编码

# = subjectAltName example 简化
SEQUENCE [
  OCTET STRING (encapsulates)
    SEQUENCE [
      [2] IMPLICIT IA5String "example.com"   # tag=2 is DNS
    ]
]
DER byte layout:
30 13              # SEQUENCE, len 19
  82 0b            # context [2] IA5String, len 11
  "example.com"

DER 是定长编码(不像 BER 可含冗余),保证 "同内容 = 同字节"。证书指纹 (X.509 SHA-256 fingerprint) 因此稳定。


二、信任链与路径构建

2.1 三层结构

Root CA              (自签名, 内置 OS trust store, 30-year cert)
   └─ Intermediate CA    (5-10-year cert, 公网可 ==)
        └─ Leaf           (90-day cert, 业务证书)

为什么不能 root 直接签 leaf?

  1. root 私钥必须离线安全保管(物理隔离 HSM),频繁操作风险大
  2. intermediate 关闭 (Compromised intermediate):吊销其证书不影响 root
  3. 多个 intermediate 用于不同业务(.LE: E1/E2 ECDSA,R3/R10/R11 RSA)

2.2 路径构建(RFC 5280 §6)

Client 验证步骤(典型):

1. 从 leaf 起向上找 issuer
   issuer可能在 leaf cert 中附带(handshake send full chain)
   或 client 预装 intermediate cache
2. 找到 self-signed root → 终止
3. 验每对 (parent_signature on child):
   - 检查有效期 (notBefore < now < notAfter)
   - 检查 revocation (CRL/OCSP)
   - 检查 keyUsage (issuing CA 必须有 keyCertSign)
   - 验签
4. 检查 name constraints (RFC 5280 §4.2.1.10)
   CN 主须匹配

2.3 cross-sign

新 CA 在 OS 信任普及前可选 cross-sign:同一 leaf cert 被两个不同 root 签 → 客户端任一信任即可用:

ISRG Root X1 (Let's Encrypt) ─┐
                              ├─ R3 intermediate ── leaf (example.com)
DST Root CA X3 (IdenTrust)  ─┘
       ↑                          ↑
   新 root (现代 OS trust)    老 root (老 Android trust)

Let's Encrypt 用此方案支持老 Android 上 dual-trust,2021-09 DST Root CA X3 到期后又用特殊"trust extension"续 3 年。


三、OCSP / CRL 实现

3.1 CRL (Certificate Revocation List, RFC 5280)

CA 周期发一份"已吊销证书"列表:

CRL ::= SEQUENCE {
    tbsCertList   TBSCertList,
    signatureAlg  AlgorithmIdentifier,
    signature     BIT STRING
}

TBSCertList ::= SEQUENCE {
    version, signature, issuer, thisUpdate, nextUpdate,
    revokedCertificates SEQUENCE OF SEQUENCE {
        userCert    CertificateSerialNumber,
        revocationDate   Time,
        crlEntryExtensions Extensions OPTIONAL
    }
}

缺陷:

  • 太大:CA-Browser Forum 限制 <64MB 但仍然每刷新下载大
  • 延迟:CA 每几天发一份,吊销后到下份 CRL 间隙 → 攻击者可利用
  • 缓存差:浏览器不愿每连接拉 5MB

Chrome / Firefox 实际默认不查 CRL —— 商业 CA 也不强推。

3.2 OCSP (RFC 6960)

POST http://ocsp.digicert.com
Content-Type: application/ocsp-request

asn.1 encoded:
{ request { tbsRequest { reqList { CertID { hashAlgorithm: SHA1, issuerKeyHash, issuerNameHash, serialNumber } } } } }

Response:
HTTP 200
Content-Type: application/ocsp-response

OCSPResponse { responseStatus=successful, responseBytes { ocspBasic { tbsResponseData { responses { CertID, certStatus={ good | revoked | unknown }, thisUpdate, nextUpdate } }, signatureAlgorithm, signature } } }

realtime 查询:CA OCSP responder 几秒内回复。优点:

  • 微小(<1KB)
  • 比较实时(24h 内)

实际部署难点:

  1. 隐私:CA 知道每个 client 访问过哪些网站(client 主动查 OCSP)
  2. 延迟:每 TLS handshake +1 RTT 查 OCSP,100ms+ penalty
  3. 可用性:CA OCSP responder 挂 → 浏览器 fallback soft fail = 接受,安全性 = 0

3.3 OCSP stapling (RFC 7681)

server 主动从 CA 取一遍 OCSP response,在 TLS handshake 中跟随 cert 发:

client → ClientHello + status_request extension
server → Certificate
       → CertificateStatus (含 OCSP response)
client 直接验证OCSP response 签名 → 不需访问 CA

刷新:OCSP response 有效期 ~7 天,server 每 2 天拉取一次。

ssl_stapling on;
ssl_stapling_verify on;
ssl_trusted_certificate /etc/ssl/ca-chain.crt;
resolver 8.8.8.8;

3.4 Must-Staple extension

cert 在 TLS Feature extension (OID 1.3.6.1.5.5.7.1.24) 中加 status_request → client 必须看到 有效 stapled OCSP response 才接受证书。

效果:防 CA 静默回退(OCSP 私下合格 → 应用以为 cert revok 但 OTP "sta 自 默认")

风险:server stapling 失效 → 全站 client 拒连。必须配合严格监控 + multi-route fallback。

3.5 CRLite

Mozilla Firefox 推出(2018):

  • Bloom filter 把全 web 证书吊销压缩到 ~1MB
  • 客户端启动时下载 + 周期增量
  • 三层 Bloom filter 解决 false positive(first / second / third layer)

缺点:仅 Firefox 用,Chrome / Safari 没采用,有一定抵制 (Chrome 用 CRLSet 名单方式)。

3.6 CRLSet

Chrome / Blink 用自家 CRLSet:CA 主动 push 一组重大 revoked cert list (subset of all certs) 给 client embed。装在 Chrome 升级包中。

理论不完整但实战 OK,因为 major CA 公开上次 breaches才被 push 进 CRLSet。


四、ACME (RFC 8555)

Let's Encrypt 协议,自动化证书签发:

1. Client GET https://acme-v02.api.letsencrypt.org/directory
   → 拿到所有 endpoint: newAccount, newOrder, ...

2. POST newAccount (JWS-signed)
   → 创建 account, 拿 URL

3. POST newOrder
   identifiers: [{type: dns, value: example.com}]
   → 拿 order URL

4. server 返回 challenges
   - HTTP-01:  http://example.com/.well-known/acme-challenge/<token>
   - DNS-01:   TXT _acme-challenge.example.com → <token>
   - TLS-ALPN-01: ALPN cert

5. client POST challenge URL 上 prove

6. client POST finalize (CSR)
   → server 签 cert, status=valid

7. client GET cert URL → download PEM

签名方案 JSON Web Signature (JWS, RFC 7515)。account 用 ECDSA / RSA 私钥签 URL+payload,server 验签。

Rate-limit:

  • 50 certs / registered domain / week
  • 5 failure / hour / account
  • 10 duplicate cert / week

4.1 cert-manager (K8s)

apiVersion: cert-manager.io/v1
kind: ClusterIssuer
metadata: { name: letsencrypt-prod }
spec:
  acme:
    server: https://acme-v02.api.letsencrypt.org/directory
    email: ops@example.com
    privateKeySecretRef: { name: letsencrypt-prod-key }
    solvers:
      - http01: { ingress: { class: nginx } }
      - dns01: { route53: { region: us-east-1 } }   # wildcard 必须用 DNS-01

---
apiVersion: cert-manager.io/v1
kind: Certificate
metadata: { name: example-com }
spec:
  secretName: example-com-tls
  dnsNames: [example.com, '*.example.com']
  issuerRef: { name: letsencrypt-prod, kind: ClusterIssuer }

Secret 60-90 天自动轮替,Ingress 自动拿到新 secrets。

4.2 ACME 痛点

  1. DNS-01 DNS 传播延迟:TTL 60s 有时太短,Let's Encrypt 验正确概率 0.3% → 重试。Cloudflare API 1s 同步稍优。
  2. HTTP-01 必须公网 80 端口可达:内网无法签。网络隔离严格时无奈。
  3. Rate-limit 触发封账号:测试环境误用 prod endpoint 几次后又用同一 email → 短期被限。
  4. staging endpoint 必须用acme-staging-v02.api.letsencrypt.org 不受限。

五、HPKP 锁死事件档案

5.1 HPKP 历史设计

HTTP Public Key Pinning: client 第一次访问记录 cert 的 SPKI hash → 后续访问只接受这些 pinned hash。如果新 cert 不符 → 拒绝。

设计目的:防 CA Misissuance(Symantec 案)乱发 Google cert。

5.2 风险

couple cert 升级方案被锁死:CTO 调错 pin → 自己域没法访问 → 直到 pin 过期 max-age 1 年。没有 recovery(除非说每个用户单独清浏览器 cache)。

5.3 真实事故

  • 2015 Erik Kay (Rackspace engineer): privacy snipper name pin → 让 7百万 Netflix 用户断
  • 2017 Comodo 公告:准备 introduce 但 Google Chrome 提前 announce 废弃 → not push
  • 2018 Toptal:CTO 误配,自锁用户全部 logout 1 周

5.4 Chrome 移除

2018-05 Chrome 67 移除 HPKP 支持。Firefox 同步。RFC 7469 BC defer。

5.5 替代方案

  • CT (Certificate Transparency):CA 强制推签入 append-only log → client 可以审计新 cert。Chrome 用 CT 强制一切 public cert 必须有 SCT (Signed Timestamp)。
  • CT Log 监控:org 自查"我域名哪些 cert 在被签发" → 检测异常签发。
  • Pinning 仅应用级:Android Network Security Config + iOS ATS 可在 App 内 hardcode cert,但 PWA/浏览器层不再 hardcode。

六、产线事故

事故 1:OCSP responder 间歇挂 → 客户端体验变差

某 CA (DigiCert) OCSP responder 在 2023-03 因为流量激增 timeout 频繁。Chrome / Safari soft fail 但启 OCSP must-staple 的客户站点 RST 大量。

修复

  • 监控 OCSP 响应时间 secondary
  • 客户端 cache OCSP response 24 小时(减少 resend)
  • stalping 必须强制 + 多 responder 路径

事事故 2:证书过期(人肉流程失败)

某 SaaS 因证书只有 90 天有效期,运维忘轮替 → 突发过期 → 全站 customer-facing TLS handshake 通通失败。

修复:自动 cert-manager + Prometheus exporter + alert 提前 14 天 warning。

事故 3:CT 监控发现陌生 cert

某公司 CT 监控发现外包运维公司签发了该域名 cert 但不在公司 portfolio 中。追踪到 outsourcing company verification 不到位。

修复:CA account ACME 严管 access 管理 + C AA records (RFC 6844) 限定只允许 Let's Encrypt 签本域。

事故 4:CAA 痛点

CAA record 配错 (0 issue "letsencrypt.org" 但缺 issuewild) → 仍被签 wildcard → CVE-前置。

修复:CAA 都配 issuewild,禁止 wildcard 签发。

事故 5:CT enforcing 段未配

某 cloudflare customer cert 没 SCT,Chrome reject。业务误以为是 cloudflare bug。

修复:签 cert 时强制要求 CA 用 CT-friendly issuance, 通过 e5/e6 等 LE endpoint。


七、易错清单

  1. 证书过期:业务不会自动 renew,必须 cert-manager 或类似工具自动
  2. OCSP stapling 失效:服务端必须监控 sslStaplingStatus,否则 silent fail
  3. must-staple 是双刃剑:cert 配上后 server stapling 失效 = 全部客户端拒连
  4. CRLite 仅 Firefox 支持,公网监控只能假设所有浏览器都不查 CRL
  5. HPKP 已废弃,再做有害无益
  6. CAA record 是合适防护但 issuer 名 + issuewild 都要配
  7. wildcard 必须用 DNS-01 challenge,HTTP-01 不能签 wildcard
  8. OCSP 一次拉取仅 4-7 天有效期:nginx stapling 默认每天 refresh,但 server 重启 cache 清空

八、这一章带走的东西

  1. X.509 = ASN.1 DER 编码的 TBSCert + 签名,扩展字段用 SAN/keyUsage/EKU 表达 wildcard 与用途
  2. CA 三层结构 (root → intermediate → leaf) 是机房运维安全 + 商业隔离双重要求
  3. OCSP/CRL 在产线上普遍失败,stapling + CT + must-staple 给"还能用的"答案
  4. ACME 流水化:HTTP-01 / DNS-01 / TLS-ALPN-01,cert-manager 给 K8s 自动管理
  5. HPKP 失败教训:主动 pin 不可逆设计有 lockout 灾难,谁碰谁死
  6. CAA record + CT log 监控能拦下陌生 cert 的签发探测

下一节 →

gRPC / Protobuf / Thrift — protobuf varint 编码、wire types、HTTP/2 trailers、4 模式 RPC 完整 spec。

gRPC / Protobuf / Thrift / Avro

TL;DR

应用层从 JSON 走到 Protobuf,再锁进 gRPC-over-HTTP/2,是过去十五年的"RPC 黄金时代"。本节从字节布局讲 protobuf varint + wire-type 编码逻辑、gRPC-over-HTTP/2 的 trailers 锁定语义、Thrift 的 binary/compact 编码、Avro schema 进字节流与 Protobuf 的 schema 漂移。最后讲产线选型:gRPC 4 模式、Conn pool 池大小、流式透传 trace_id、core cancel 时刻。


一、协议对比表

协议schema编码传输流式大小类型化主留言
JSON文本HTTP/1.1 / 2chunkedREST 默认
Protobuf.protovarint + length-delimgRPC over HTTP/2Google 内 ++
Thrift.thriftbinary / compactTSocket/HTTPFacebook 仍用
Avroschema+二进制 + type codedKafka / RPCWeakHadoop 系
Cap'n Proto.capnp共享内存 zero-copy自带Sandstorm 五圣器
FlatBuffers.fbs零拷贝各种微信内 + Google
MessagePack紧凑 type-tag各种Reddit 等
BSON文档BSON 编码MongoDB 内部Mongo 专用

二、Protobuf 字节布局

2.1 .proto 定义

syntax = "proto3";

message Person {
    int32 id = 1;
    string name = 2;
    repeated string emails = 3;
    Address address = 4;
}

message Address {
    string street = 1;
    string city = 2;
    string state = 3;
}

每个字段有 (1) field_number、(2) wire_type、(3) value

2.2 Wire type

wire type含义用法
0varintint32/int64/uint32/uint64/sint32/sint64/bool/enum
1fixed64fixed64/sfixed64/double
2length-delimitedstring/bytes/embedded message/repeated packed
5fixed32fixed32/sfixed32/float
3 / 4group (start / end)proto2 only, deprecated

每字段开头为 1 个 varint,低 3 bit 是 wire_type,高位是 field_number:

key = (field_number << 3) | wire_type

2.3 Varint 编码

def varint_encode(n):
    out = b''
    while n >= 0x80:
        out += bytes([(n & 0x7F) | 0x80])  # 头位 1 = continue
        n >>= 7
    out += bytes([n & 0x7F])
    return out

300 编码为 AC 02

300 = 0b100101100
    → 低 7 bit: 0101100  → 10101100 (0xAC, 顶部 bit 1 还有)
    → 接着 7 bit: 0000010 → 00000010 (0x02)

2.4 负数用 zigzag

int32 负数会被强转 int64 (10 字节 varint) 浪费。sint32 用 zigzag:

0 → 0, -1 → 1, 1 → 2, -2 → 3, 2 → 4, ...
zigzag(n) = (n << 1) ^ (n >> 31)

300 变 varint 02 即可,节省 byte。

2.5 完整 Person 编码

# Person { id=2, name="Alice", emails=["a@x", "b@x"], address={"Main", "PDX", "OR" } }

# field 1 (id), wire_type=0
0x08                    # key=(1<<3)|0=8
0x02                    # varint 2

# field 2 (name), wire_type=2 (length-delim)
0x12                    # key=(2<<3)|2=0x12
0x05                    # len=5
"Alice"

# field 3 (emails) repeated, wire_type=2
0x1a 0x03 "a@x"         # key=(3<<3)|2, each appearance is one field
0x1a 0x03 "b@x"

# field 4 (address), wire_type=2
0x22 0x0f               # len=15
  0x0a 0x04 "Main"      # field 1, wire_type 2
  0x12 0x03 "PDX"
  0x1a 0x02 "OR"

总 ~30 字节。同样的 JSON 至少 70 字节。

2.6 前向兼容

未知字段 (field_number 客户端不认识) → skip via wire_type

  • type 0:读完一个 varint
  • type 1:读 8 字节
  • type 2:读 length 字段后再读 length 字节
  • type 5:读 4 字节

→ 加新字段不破老客户端。但改 wire_type 必坏(field 1 从 varint 变 length-delim),所以 schema 演进只能:

  • 加新 field_number
  • 老字段保留但标 reserved
  • 类型不兼容时改名 + reserved

三、gRPC over HTTP/2

3.1 一次调用布局

HEADERS stream_id=N (END_HEADERS=1, END_STREAM=0):
   :method  = POST
   :scheme  = https
   :path    = /myapp.Users/GetUser
   :authority = api.example.com
   content-type = application/grpc+proto
   te = trailers
   grpc-encoding = identity
   grpc-accept-encoding = identity, gzip
   x-request-id = 4f5a8...

DATA frame (END_STREAM=0):
   5 字节前缀: 1 字节 compressed_flag + 4 字节 length (≤ 2^32)
   + protobuf bytes "4f 0a 02 41 ..."

HEADERS frame (END_HEADERS=1, END_STREAM=1, trailers 状态 帧):
   grpc-status = 0
   grpc-message = OK

3.2 4 种 RPC 模式

service MyService {
    rpc GetUser(GetUserReq) returns (User);                    // unary
    rpc StreamNews(SubscribeReq) returns (stream NewsUpdate);  // server stream
    rpc Sum(stream Num) returns (Sum);                         // client stream
    rpc Chat(stream Msg) returns (stream Msg);                 // bidi
}
模式reqrespHTTP/2 stream 数
unary111
server1N1 (N DATA frame)
clientN11
bidiNN1 双向 DATA frame 交错

所有 4 模式都走同一条 HTTP/2 stream (trades multiplexing)。

3.3 错误码 (grpc-status)

code含义
0OK
1CANCELLED
2UNKNOWN
3INVALID_ARGUMENT
4DEADLINE_EXCEEDED
5NOT_FOUND
6ALREADY_EXISTS
7PERMISSION_DENIED
8RESOURCE_EXHAUSTED
9FAILED_PRECONDITION
10ABORTED
11OUT_OF_RANGE
12UNIMPLEMENTED
13INTERNAL
14UNAVAILABLE
15DATA_LOSS
16UNAUTHENTICATED

业务错误应用 trailer 中的 grpc-message或自定义 metadata,不要靠 status codes。OK=0 是 success,非 0 都视为 RPC failure。

3.4 gRPC interceptors

// server-side
type serverInterceptor struct{}
func (s *serverInterceptor) Unary(ctx context.Context, req any, info *grpc.UnaryServerInfo, handler grpc.UnaryHandler) (any, error) {
    md, _ := metadata.FromIncomingContext(ctx)
    traceID := md.Get("x-request-id")
    ctx = context.WithValue(ctx, "trace_id", traceID)
    start := time.Now()
    resp, err := handler(ctx, req)
    log.Printf("RPC %s status=%v dur=%v", info.FullMethod, status.Code(err), time.Since(start))
    return resp, err
}

s := grpc.NewServer(grpc.UnaryInterceptor(&serverInterceptor{}))

interceptor 链式:trace → metrics → auth → biz logic。

3.5 实战性能

// 完整机房 setup
conn, _ := grpc.DialContext(ctx, addr,
    grpc.WithTransportCredentials(tls),
    grpc.WithDefaultCallOptions(
        grpc.MaxCallRecvMsgSize(64 * 1024 * 1024),  // 默认 4MB, 例 64MB
        grpc.MaxCallSendMsgSize(64 * 1024 * 1024),
    ),
    grpc.WithInitialConnWindowSize(8 * 1024 * 1024),   // TCP 流控
    grpc.WithInitialWindowSize(8 * 1024 * 1024),       // stream
    grpc.WithDefaultServiceConfig(`{"loadBalancingConfig": [{"round_robin": {}}]}`),
    grpc.WithKeepaliveParams(keepalive.ClientParameters{
        Time:                30 * time.Second,
        Timeout:             10 * time.Second,
        PermitWithoutStream: true,
    }),
)

关键点:

  • 每项 call 默认 60s timeout
  • HTTP/2 100 max concurrent streams 限制 → gRPC client 单 conn 同时调用上限 100
  • 建议 client pool ≥ 10 conn
  • PermitWithoutStream=true 让 keepalive 不需 active call 才发,防 NAT 老化 timeout 杀连接

四、Thrift

4.1 .thrift schema

struct User {
    1: i32 id
    2: string name
    3: list<string> emails
}

service UserService {
    User getUser(1: i32 id),
    oneway void deleteUser(1: i32 id),
}

4.2 TBinaryProtocol

固定 4-byte 字段编号 + 类型:

struct 序列化:
  field: [1 byte type] [2 byte field_id] [value]
  end: [1 byte = 0]

字段编号显式 → 跨语言解码稳定。

4.3 TCompactProtocol

类似 Protobuf,用 varint,更紧凑。

4.4 Transport 层

  • TSocket:raw TCP
  • TBufferedTransport:buffered TCP
  • TFramedTransport:每 message 前缀 length (类似 length-delimited)
  • THttpClient:HTTP 1.1 wrapper

4.5 现状

用户用法
Facebook内部 API 仍主力
Twitter (Finagle)Scala Finatra Thrift
UberTChannel → 后迁 H1
Cassandrathrift API (deprecated)

行业主流已迁 gRPC,但少数大公司仍维护 Thrift。


五、Avro

5.1 schema + 数据流

{
  "type": "record",
  "name": "User",
  "fields": [
    {"name": "id", "type": "int"},
    {"name": "name", "type": "string"},
    {"name": "emails", "type": {"type": "array", "items": "string"}}
  ]
}

序列化:

  • 字节流中不带 field_number:靠 schema 在 reader/writer 协同
  • reader 用 schema 兼容性矩阵处理不同版本 (back 包装)
  • 一条记录 30 字节要比 Protobuf 5-10%

Kafka + Avro 是大数据栈标配。Schema Registry (Confluent) 存所有 schema 版本,serializer 在 record 头加 4 字节 schema id。

5.2 Avro vs Protobuf

维度AvroProtobuf
字段编号不带,schema 协同1, 2, 3... 显式
字节大小< Protobuf (10%)紧凑
schema 漂移强 map 隐式类型reserved + 类型不变
流传输不带 schema id → 配 Kafka Schema Registry自描述
主要生态Hadoop, Kafka, ConfluentgRPC, Envoy, GRPC

六、产线实战灾情

6.1 gRPC stream leak — server 内存爆

业务 client 不 CloseSend() 直接退出 → server 保留 stream context 直到 idle (1h) → 5k client × 1h = 500k concurrent streams → 内存 500 MB ×N → OOM。

修复

  1. client 强制 defer stream.CloseSend() + ctx cancel
  2. server 监控 grpc_server_stream_started 长寿命数
  3. server stream max age MaxConnectionAge = 30m + GracefulStop

6.2 gRPC metadata 超 8KB

某 API client 把 JWT token + 业务 metadata 全塞进 metadata → 16KB → HTTP/2 SETTINGS frame reject 默认 8KB,server 主动 reset stream。

修复

  1. JWT 走 binary DATA frame 或 service-local context,不放 metadata
  2. 服务端 nginx 配 http2_max_field_size 32k; http2_max_header_size 64k;
  3. 客户端控制 metadata 总字节并日志预警

6.3 conn pool 大小不当

Web Tier 起 50 connection pool、每并发 100 QPS → backend 单连接 5k QPS、HTTP/2 100 stream 限制 → 5conn 满后余 0。流量超时 5k RPS。

修复

poolSize = ceil(QPS_per_conn / max_streams_per_conn) * 1.5
// e.g., QPS 50k / 100 stream* 100 qps/stream = 5 conn, *1.5 = 8 conn安全

6.4 protobuf 字段类型 wire-type 漂移

某业务 schema 升级把 field 3 从 int32string → 老 client 跳 wire_type 0,新 server 写 wire_type 2 → 老 client 跳过 → field 默认 0 → 业务 silent fail。

修复:用 reserved 3,新加 field string newfield = 4

6.5 Avro schema 漂移反写

某业务 schema v2 把 int idlong id → 老 consumer Bei schema v1 读 → int → long 是合法扩展,但 long → int 不行 → producer 升级 v2 没事,consumer 升级 v2 但 produce 旧 v1 数据 from backup consumer crash。

修复:Avro 强 backward / forward 兑 schema registry confluent.compatibility=BACKWARD 锁。


七、易错清单

  1. Protobuf 不能改 wire_type:field_id 类型可能漂移 → 用 reserved + 新编号
  2. repeated packed vs unpacked:proto3 默认 packed,读取端兼容但旧 proto2 后向不兼容
  3. gRPC stream 报错是 trailer:HTTP/2 HEADERS frame 后置语义,不会出现在前置 frame
  4. proto enum 必须有 0 value:proto3 强要求 (默认值)
  5. gRPC metadata 与 HTTP/2 headers 一致,但 8KB 大小限制是 HTTP/2 默认上限
  6. gRPC 0 错误 vs http 200 OK:gRPC level 错误独立于 HTTP,HTTP status 永远是 200,grpc-status trailer 给业务语义
  7. Thrift vs Protobuf 大体相同字节,主要差别在 community + 工具链
  8. JWT 在 gRPC metadata 中可能超 8KB;考虑放 propagated ctx 或 sidecar encrypt

八、这一章带走的东西

  1. Protobuf 字节布局是 varint + wire_type + length-delimited,前向兼容靠 wire_type skip,schema 漂移靠 reserved + new field
  2. gRPC-over-HTTP/2 用 stream_id 复用 + trailer 状态 锁定 grpc-status 语义;4 模式通用 1 HTTP/2 stream
  3. Thrift 与 Protobuf 编码相近,Thrift 显式 field number,Protobuf 显式 wire-type skip → 各自设计取向
  4. Avro 不带 field 编号、靠 schema registry 协同,Kafka 数据生态主流
  5. gRPC 关键调优点:conn pool size = QPS × 1.5 / 100_streams_per_conn、metadata 8KB、keepalive + max_age
  6. stream leak 是 gRPC server 短寿工程盲区:必须 monitor stream age 分布与 grpc_server_stream_started 增量

下一节 →

QUIC 概览 — QUIC packet 格式、stream 帧、conn ID、connection migration、loss recovery。

QUIC

TL;DR

QUIC 是 RFC 9000-9002 (2021) 定义的"基于 UDP 的全新可靠传输 + 拥塞控制 + 集成 TLS 1.3"。Google 从 2013 起内部实验,2018 ANRW 上 Cloudflare/Akamai/Fastly 加入,2022 RFC 9114 (HTTP/3) 标准化。本节我们走完 QUIC 的字节布局、CID-based 连接管理、stream 独立、connection migration、PTO/RACK 重传,以及产线上 4 大部署难点(UDP 中间盒、家用 NAT PMTU、负载均衡 CID 路由、0-RTT replay business ack)。

章节


QUIC 时间线

2012   Google 内部实验性 QUIC (GAQ)
2016   NTT 测试 QUIC,IEFT BOF 起草
2018   IETF WG 正式 draft-ietf-quic-transport-00
2021   RFC 9000 (transport) + 9001 (TLS) + 9002 (recovery) published
2022   RFC 9114 (HTTP/3) published
2023   RFC 9369 (version negotiation) + 9390 (DPLPMTUD)
        BBR v3 + quiche 全网部署

QUIC vs TCP+TLS 协议栈

HTTP/1.1 over TCP+TLS 1.2:
   HTTP   →   TLS 1.2   →   TCP   →   IP   →   Ethernet
              ↑             ↑
              2 握手 RTT     3-way + cwnd build

HTTP/2 over TCP+TLS 1.3:
   HTTP/2 →   TLS 1.3   →   TCP   →   IP   →   Ethernet
              ↑             ↑
              1+0 RTT       3-way + cwnd

HTTP/3 over QUIC:
   HTTP/3 →   QUIC (含 TLS 1.3)   →   UDP   →   IP   →   Ethernet
              ↑                    ↑
              1+0 RTT 单达成合       no SYN, no connect

QUIC 把 TLS 1.3 握手 encode 在 CRYPTO frame 里,与 packet 同步发出 + 接收 → 1 RTT 完成。

QUIC 解决 TCP 自带 5 大痛点

TCP 痛点在 TCP 中的根因QUIC 解决方案
HoL blocking (TCP stream HoL)TCP 序号 byte-stream,client 无法选择性 ACK 单 stream 内字节每 stream 独立 ack + 独立重传
握手 RTT 高TCP 3-way + TLS 握手不重叠CRYPTO frame + TLS 1.3 内嵌,1 RTT
连接 4 元组绑定TCP 5-tuple NAT 表项随源 IP/port 变化Connection ID 恒定,路由不靠 5-tuple
中间盒缓存语义NAT/firewall 缓存 seq,重传包注入歧义协议在 user space + packet number 单调
升级慢内核 TCP,10 年才能全 clanQUIC 在 user space,库升级
RTT 估不准ACK delay 看不到ACK frame 含 ack_delay 显式字段
应用层 HoL (HTTP/1.1 串行)HTTP/1.1 自身限制直接走 HTTP/3

这一章带走的东西

  1. QUIC 把 transport + congestion control + TLS 1.3 完全集成在 user space,1 RTT 握手是核心收益
  2. CID 是独立于 5-tuple 的 conn 标识,让连接迁移与 LB routing 解耦
  3. 0-RTT、DPLPMTUD、ECN、ack_delay 是 QUIC 必修字段
  4. NAT rebinding + 跨 ISP 链路切换是 QUIC 在移动网络杀手锏
  5. 部署仍有难点:UDP 中间盒被丢、负载均衡需要 CID 一致、客户端包兼容性

下一节 →

QUIC over UDP:解决什么 — packet 字节布局、long/short header、frame 类型、stream 编号、与 RTT 估计算法。

QUIC over UDP:解决什么

TL;DR

QUIC 没有新 GSS 层概念,只是把 TCP 30 年没做透的"5 件事"重写一遍。本节走完 packet 字节布局、long/short header、流标识符、变长 packet number、frame 类型清单、ACK frame 结构、PTO 算法、 congestion control 接口——你能从字节角度认识 QUIC、从 wireshark 抓帧反查协议状态。重点:packet number 单调递增如何解决重传歧义。


一、Quic 包字节布局

1.1 Header 两种形态

Long Header (handshake / version neg):
0                   1                   2                   3
+---------------+---------------+---------------+---------------+
|1|  Form Bit   | Long Type (7) |                                 |
+---------------+---------------+               +
|                          Version (32)                            |
+---------------+---------------+---------------+---------------+
| DCID Len (8)  | Dest Connection ID (0..160)                     |
+---------------+---------------+---------------+---------------+
| SCID Len (8)  | Src  Connection ID (0..160)                      |
+---------------+---------------+---------------+---------------+
                          Variable per type:
                          Initial Token, Length, PN, CRYPTO/STREAM
+                                payload                          +
+-----------------------------------------------------------------+

Short Header (1-RTT application):
+--+—+--+--+--+--+--+--+--+--+--+--+--+--+--+--+
|0|1| S | R | K | P | Packet Number (1..4)      |
+--+—+--+--+--+--+--+--+--+--+--+--+--+--+--+--+
|                   Encrypted Payload         ...
+--+—+--+--+--+--+--+--+--+--+--+--+--+--+--+--+
字段bit说明
Form bit11=Long, 0=Short
Fixed bit1必 1
Spin bit1RTT 测量用 (常见 0/1 交替)
Reserved bits2reserved
Key phase bit1key rotation 切换
Packet number size21/2/4 字节
DCID/SCID可变长0-20 字节,但变长编 1 字节 prefix

CID 是 变长(0-20B)+ 类型本身可协商;CID 长度在握手期协商:client 发起 initial,server echo client 选的长度。

1.2 Long Header 4 个子类型

Type bits类型用途
00Initial客户端先发,含 ClientHello CRYPTO frame
010-RTT客户端可选,携带 early data + 新 ClientHello
10Handshakeserver 回,含完成 TLS 握手
11Retryserver 重发,要求 client 重发 initial(防 amplification attack)

1.3 加密层次

  • Initial keys:用 DCID 作 salt + HKDF 衍生 → 加密 Initial packet CRYPTO frame
  • Handshake keys:从 TLS handshake secret 派生
  • 0-RTT keys:PSK + 早期 traffic secret
  • 1-RTT keys:主 traffic secret
  • Application keys:aead 加密 short header packets

每层独立加密 → Initial packet 即使无 TLS handshake 也加密(防 ISP 看透握手)。


二、Frame Type 表

0x00  PADDING
0x01  PING
0x02  ACK
0x03  ACK (ECN)
0x04  RESET_STREAM
0x05  STOP_SENDING
0x06  CRYPTO
0x07  NEW_TOKEN
0x10-0x17  STREAM (8 variants: FIN/OFF/LEN flags)
0x18  MAX_DATA
0x19  MAX_STREAM_DATA
0x1A  MAX_STREAMS (BIDI)
0x1B  MAX_STREAMS (UNI)
0x1C  DATA_BLOCKED
0x1D  STREAM_DATA_BLOCKED
0x1E  STREAMS_BLOCKED
0x1F  NEW_CONNECTION_ID
0x20  RETIRE_CONNECTION_ID
0x21  PATH_CHALLENGE
0x22  PATH_RESPONSE
0x24  HANDSHAKE_DONE
0x26  DATAGRAM
0x1c-0x1d CONNECTION_CLOSE

每个 frame 类型有独立功能。STREAM frames (0x10-0x17) 含 FIN flag + offset + length 字段:

STREAM frame (Type|Fin|Len|Off):
0                   1                   2
+---------------+---------------+---------------+
|Frame Type| F | O | L |                       |
+---------------+                               |
|                  Stream ID (varint)            |
+-----------------------------------------------+
|              Offset (varint, if O=1)          |
+-----------------------------------------------+
|              Length (varint, if L=1)          |
+-----------------------------------------------+
|                 Stream Data (var len)         |
+-----------------------------------------------+

三、Stream ID 与方向

Stream ID 编码:
0x00 - 0x03    server-initiated bidi  ← HTTP/3 client server push 视角
0x04 - 0x07    client-initiated bidi  ← HTTP/3 client request/response
0x08 - 0x0B    server-initiated uni  ← 控制流 (SETTINGS)
0x0C - 0x0F    client-initiated uni  ← client push 流

低 2 位编码:

  • bit 0: 0 = client-initiated, 1 = server-initiated
  • bit 1: 0 = bidi, 1 = uni

每 stream 独立 flow control (MAX_STREAM_DATA、STREAM_DATA_BLOCKED) 与连接级流控 (MAX_DATA、DATA_BLOCKED) 双层。


四、Packet Number 单调递增

4.1 TCP seq 的歧义

TCP 序号是按字节序 + 重传 维护。重传过的 segment 序号相同:

sender → seg seq=1000 + data
       ↓ timeout
       → seg seq=1000 + 同 data (重传)
receiver
       → ACK seq=1001 (无法分辨是确认 first 还是 second)

→ Karn 算法要求"重传过的包不计 RTT"。但 timestamp option 也是种 workaround。

4.2 QUIC 解法

QUIC packet number 每发一个包都+1(即便内容相同):

包 1: stream data seq=1000
       ↓ timeout
包 2: stream data seq=1000  ← 包号已变!data seq 不变
receiver → ACK 范围 [(包2号)] ... frame 含 ack_range
sender 看到 ACK 包2号 → 100% 确认这是新包确认,不会跟踪 first → 混淆消除
// pseudo-code (ncheap sim)
let mut next_pn = 0;

fn send_packet(&mut self, payload: Bytes) {
    let pn = self.next_pn;
    self.next_pn += 1;
    ...
}

fn on_ack(&self, ack_ranges: Vec<(u64, u64)>) {
    let largest_acked = ack_ranges.iter().map(|r| r.1).max();
    // detect new ack:
    if largest_acked > ack prior {
        let rtt_sample = now - self.send_time(largest_acked);
        // ack_delay recompute from frame:
        let rtt = rtt_sample - ack_delay_field;
        // update SRTT without Karn workaround needed
    }
}

关键收益:不需要 timestamp option,每包 RTT 都能算。

4.3 Packet Number 编码压缩

短 header packet number 1/2/4 字节可变。truncated_pn 用最低 N 位编码与预期下个 pn 比较:

fn decode_pn(largest_pn: u64, truncated: u64, bits: u32) -> u64 {
    let expected = largest_pn + 1;
    let candidate = (expected & !((1<<bits)-1)) | truncated;
    if candidate < expected - (1<<(bits-1)) {
        candidate + (1<<bits)
    } else if candidate > expected + (1<<(bits-1)) {
        candidate - (1<<bits)
    } else {
        candidate
    }
}

→ 99% packet 只编 1 字节,省 byte。但接 packet loss 后还是要发 2 字节。

4.4 ACK frame 字段

ACK Frame:
0                   1                   2
+---------------+---------------+----------+
| Type 0x02     |  Largest Acknowledged (varint)
+---------------+---------------+----------+
|  ACK Delay (varint)        ← 关键字段 ack_delay
+-------------------------------+
|  ACK Range Count (varint)     |
+-------------------------------+
|  First ACK Range (varint)     |   # 连续确认从 largest 往回数
+-------------------------------+
|  Gap + Ack Range patterns     |   # 用于乱序 ACK
+-------------------------------+

ack_delay 编为 us >> ack_delay_exponent(协商时设 2^N)。sender 收到后回算:

rtt_sample = (now - send_time_of_largest_acked) - (ack_delay_field << ack_delay_exponent)
srtt = 7/8*srtt + 1/8*rtt_sample  # RFC 9002 §5

→ socket 调度延迟从 RTT 估计剥离


五、PTO 与重传

5.1 PTO 探测超时

RFC 9002 定义 PTO 算法:

PTO = smoothed_rtt + max(4*rttvar, kGranularity) + max_ack_delay

比 TCP RTO 多 max_ack_delay,因为 sender 知道接收方 ack_delay 是应用回应故意延迟 ACK 的,不能算丢包信号。

5.2 PTO 触发后

  • 发 PING frame (空) 触 receiver 回 ACK
  • 不重置 cwnd=1 (与 TCP RTO 不同)
  • packet number 单调 + 1,发探测包即可

5.3 丢包检测 + RACK

QUIC 直接借鉴 TCP 已经成熟的 RACK (RFC 8985):

  • 看 ACK 帧中 gap → 包 X 已 acked 但包 X-1 未 acked + 时间晚 → X-1 丢
  • ack ranges 让乱序信息公开 → 不需"3 dup ACK"

六、连接迁移

CID 是 QUIC 跨网络连续的根。

client → server: send packets with CID=0x9abc...

client 切到 LTE 5G:
   原 socket (wifi) 段口被 OS close,
   新 socket (LTE) 同一进程继续用 CID=0x9abc
server → 看到 CID 不变 → 仍查 conn state table = OK

路径验证:
client → server: PATH_CHALLENGE (with random 8 bytes) on new path
server → client: PATH_RESPONSE (same 8 bytes) on same new path
client 收到 PATH_RESPONSE → path validated → 切回 send on new

NAT rebinding 类似:client 看不到自己源 IP 突变,但 server 看到 packet 源 IP/port 变了 → server 主动 own PATH_CHALLENGE 验证 → 验证通过 = OK 切回 send。

6.1 NAT rebinding

client → NAT (公网 1.1.1.1:3000)
       → server

NAT 老化 → NAT 给 client 新 port 4000
client 仍只知 NAT 后知,下个 packet NAT 通过出去:
       → server src=1.1.1.1:4000  ← server 看 src 改变

server → PATH_CHALLENGE to 1.1.1.1:4000
client ← PATH_CHALLENGE → PATH_RESPONSE back
server 切到 send 到 4000

6.2 触发主动 migrate

client 主动切:

1. client 在 lwifi socket 上 new socket cell + bind
2. 在新 socket 发 packet 含 PATH_CHALLENGE
3. 同时停止 lateral send
4. 等 PATH_RESPONSE
5. 切到新 socket send,并 retire old CID (让 server 路由表清)

中间盒看到 PATH_CHALLENGE 应该不 RTC reset 。

6.3 connection close

正常 close:QUIC CONNECTION_CLOSE frame → receiver ack → close。 重连成本:client 需新 Initial packet,TLS 1.3 impersonation 2nd RTT (0-RTT if resumption)。


七、QUIC 拥塞控制接口

QUIC 把 cwnd、pacing rate、congestion event 留成接口(不是内置具体算法):

  • 默认 NewReno (RFC 9002 §B)
  • 实现 ≥ = cubic in quiche/quic-go/msquic
  • BBR v1/v2/v3 in lsquic, msquic-optional
# QUIC 拥塞控制接口
class CongestionController:
    def on_packet_sent(self, packet_num, bytes):
        pass

    def on_packet_acked(self, ack_info, now):
        # ack_info.ack_ranges: ranges acked
        # ack_info.ack_delay: receiver 计算的延迟
        pass

    def on_congestion_event(self, lost_packets, now):
        # 拥塞事件 (丢包 / ECN)
        pass

    def pacing_rate(self) -> u64:
        pass

    def window(self) -> u64:
        pass

→ 不同算法实现 equippable。client/server 可协议协商 congestion controller (extension)。


八、QUIC 库生态

组织语言主要用户
quicheCloudflareRustCloudflare CDN, Akamai
lsquicLiteSpeedCAkamai, Microsoft Edge
ngtcp2ngtcp2C++self-hosting
msquicMicrosoftCWindows, Azure H3
quic-goLucas ClementeGofasthttp, transport-kit, GCP
aioquicaiohttpPythoncloudflare dev
quinncrCrucialRustrustls 集成
$ curl -I --http3 https://www.cloudflare.com  (curl 7.66+ 自带 quiche 编译 )

九、产线部署观察

9.1 UDP 穿透率

Cloudflare 报告:H3 探测成功率对世界公网 ~80%;中国 / 部分企业 LAN < 30%。Cloudflare 启 "Both HTTP/3 and HTTP/2",client fallback to h2。

9.2 负载均衡 CID 路由

CID 在 initial packet 编码,LB 必须按 CID 路由。L4 LB 不解 CID,要 LB 在 user-space:

肺部 load cityName:
LB packet → quic parse → 取 DCID → hash → 一致性 → backend A
backend A 接管 conn state

client conn migration:
   new packet 同 CID,
   LB hash(DCID) = backend A → continue

Envoy Cloudflare 都支持,Nginx 1.25.3 quic 版也包含。

9.3 实际部署 4 大坑

  1. UDP 中间盒 ICMP 不可达 → QUIC 通常忽略 ICMP,跳过 PMTUD;改用 DPLPMTUD(基于 RFC 8899 探 path MTU)
  2. UDP 负载均衡 vs L4 LB → 需要 L7 LB 或 L4 LB 编 CID hash(如 Cloudflare)。
  3. 0-RTT replay:业务必须 anti-replay;server cache ticket + nonce + 5min。
  4. CPU 占用:1 个 QUIC stream 1 packet encryption 比 TCP 多 30%(DTLS overhead + 封装到 UDP per-packet-encry);kernel 5.18+ 才支持 offload。

十、抓帧示例

$ tcpdump -i eth0 'udp port 443' -w quic.pcap
$ tshark -r quic.pcap -Y 'quic'
 1 Initial Packet (DCID=0x9abc...) CRYPTO offset=0 len=512
 2 Initial Packet (DCID=0x9abc..., SCID=0x8def...) CRYPTO offset=0 len=128
 3 Handshake Packet (DCID=0x9abc...) CRYPTO offset=0 len=1024
 ...

Cloudflare 向社区开源的 quiche quinn 提供可视化协议状态。


十一、易错清单

  1. QUIC 不是 TCP 的"加速器":是新 stack,把 TCP/TLS 重写
  2. spin bit 不是 ack 包:是 RTT 测量用单位 bit,1→0→1 交替
  3. ack_delay 单位是 µs / 2^N,不是直接 µs;exponent 在 transport parameters 协商
  4. packet number 编码压缩:1/2/4 字节靠预期值预设最近
  5. stream flow control 是两层:连接级 + stream 级;不要混淆
  6. TLS 1.3 必须用 QUIC transport parameters extension 协商 parameters,不是用 TLS extension
  7. CID 长 0-20 bytes, server 需要支持 cid_disc 推荐回 8 字节

十二、这一章带走的东西

  1. QUIC packet = header (long/short) + frame (STREAM/ACK/CRYPTO/PADDING/...),每 packet 独立加密
  2. packet number 单调递增 + ACK ranges 彻底解决重传歧义与 Karn 算法的需求
  3. ACK frame 含 ack_delay 字段让 RTT 估计不被 socket 调度干扰
  4. CID 是 conn 标识,让 NAT rebinding、mobile migration、LB routing 一致
  5. 0-RTT 用 PSK + early data,replay 风险是应用层 anti-replay + 限幂等请求
  6. 一线部署仍有 UDP 中间盒、LB 路由、CPU 加解密、IPv6 等挑战,但 met شر_tweet产
  7. 主流库:quiche (CF), msquic (MS), lsquic (Akamai), quic-go, quinn, aioquic

下一节 →

0-RTT / 连接迁移 — PSK resumption 安全细节、ticket nonce anti-replay、migration PATH_CHALLENGE 与 NAT rebinding 区别。

0-RTT / 连接迁移

TL;DR

0-RTT 让 client 在第一个 RTT 还没完成的时刻就发出应用层请求;连接迁移让 client 跨 ISP、跨物理链路保持续通信。两个机制都依赖 Connection ID 与 PSK,但都面临运维苛刻的边界。本节解开 PSK 派生、ticket anti-replay 防御策略、PATH_CHALLENGE 路径验证、移动端真实 migration 实战。重点:把"0-RTT 只 GET、CM 不破" 变成实际业务部署 checklist。


一、PSK 与 Session Resumption

1.1 概念层次

TLS handshake 1 RTT
              ↓
HKDF derives early_secret + handshake_secret + master_secret
              ↓
server 在 handshake 完成 + Application Data 后发 NewSessionTicket frame:
       NewSessionTicket {
           ticket_nonce = 0x...,
           ticket_age_add = 0x...,
           ticket_lifetime = 7200 sec,
           ticket = <encrypts resumption_master_secret + server state secret>
       }
client 收 NewSessionTicket 后 cache:
       - ticket bytes
       - psk_identity = ticket
       - psk = HKDF-Expand-Label(resumption_master_secret, "resumption", "", Hash.length)
              ↑ 客户端从 full handshake derive 出来的

1.2 第二次连接用 PSK

client → Initial packet:
          ClientHello:
              identity = ticket
              obfuscated_ticket_age = ((now - ticket_receipt_time) + ticket_age_add) mod 2^32
              key_share = X25519 pubkey   (for DLE-PSK, mode = psk_dhe_ke)
          0-RTT packet:
              CRYPTO frame with early data (e.g. HTTP/3 GET /index.html)
              STREAM frames (HTTP/3 body if any)

server decrypt Initial packet:
   1. Hash ticket to find original master secret in cache
   2. Verify obfuscated_ticket_age is reasonable (within lifetime)
   3. Derive early traffic secret from PSK + new ClientHello
   4. Decrypt 0-RTT early data
   5. Process early data synchronously (server's CRYPTO frame scheduling)

1.3 PSK vs ECDHE (no PSK) 对比

维度PSK-onlyPSK+DHEFull ECDHE
握手 RTT1 (or 0)11
0-RTT 가능
前向安全
推荐不建议强推新 conn

PSK+DHE (psk_dhe_ke) 让 PSK resumption 仍有前向安全:即使 PSK 泄漏,所有历史流量仍由 ECDHE 派生的 secret 保护。


二、0-RTT 详解

2.1 packet 列表

1) Initial Packet (with PSK extension + key_share):
      Crypto (ClientHello)
2) 0-RTT Packet (encrypted with early traffic secret):
      Crypto (early data part 1)
      STREAM (HTTP/3 HEADERS + DATA)
3) 0-RTT Packet (continued):
      Crypto (early data part 2)
      STREAM (continued)
   ...
4) After server's Handshake Done: client sends 1-RTT packets:
      STREAM (application data after handshake)

client 端发完 0-RTT 后继续用 0-RTT secret 加密 应用数据直到 server Finished reply;之后切到 1-RTT。

2.2 0-RTT 接受条件

server 决定是否接受:

  • ticket 有效且未过期
  • ALPN 协议匹配(HTTP/3 vs h2 vs dq)
  • application protocol 与历史 session 一致
  • SNI 与 ticket 中记录一致(防 cross-SNI replay)

server 可以部分接受:拒绝 0-RTT early data 但接受 PSK resumption,让 cwait 1-RTT 完成发应用数据。client 看到 rejection 就回 1-RTT 发 data。

2.3 anti-replay 实战

RFC 8446 §8

"Implementations MUST NOT use early data for non-idempotent requests" "If implemented incorrectly, TLS 1.3 0-RTT creates a new replay attack channel"

四道防线(建议全栈部署):

  1. 业务层幂等限制:early_data 仅 GET/HEAD,POST/DELETE 等危险操作 server 端 detect Early-Data: 1 视为非约定后续,回 425 Too Early (RFC 8470)
  2. application layer token:early data 中携带 unique request_id,server 维护 5min cache → 重复 request_id 拒
  3. PSK + ticket_nonce 单调:server 给每个 ticket 分配 unique nonce,accept early_data 时入 cache;新 early_data 必 nonce 新
  4. time window:ticket lifetime 严格 24小时,避免 long-term replay window

2.4 部署观察

厂家接受 0-RTTanti-replay 策略
Cloudflare是 (default)token nonce cache + GET only
AWS CloudFront是 (selective)业务路由 harness + replay reject
Akamai是 (selective)nonce per ticket
Google Services业务路由 + replay window
自部署 nginxopt-in必须自定义 module

实际 deployment:request_id 防重 / idempotency_key / 限 24h window / 1MB early data cap。

2.5 0-RTT 字节 budget

H3 SETTINGS frame:
   SETTINGS_H3_DATAGRAM = 1
   SETTINGS_QPACK_MAX_TABLE_CAPACITY = 4096
   SETTINGS_H3_MAX_FIELD_SECTION_SIZE = 16384   ← early data 超过这个早期失败
   SETTINGS_H3_ENABLE_CONNECT_PROTOCOL = 0

0-RTT max early data size 用 transport parameter max_early_data_size 协议。Cloudflare 实测 limit 4096B,避免 0-RTT 占带宽。


三、连接迁移

3.1 CID 与 4-tuple 解耦

TCP conn = (src_ip, src_port, dst_ip, dst_port)
            ↑ 一改 conn 死
QUIC conn = Connection ID (1-20 byte arbitrary randomly assigned)
            ↑ 4-tuple 不参与 conn 状态 → 可以随便换

握手期 client 与 server 各发 SCID/DCID:

client Initial packet:
   DCID = 0x9abc9abc (random 8 bytes by client)
   SCID = 0x8888 (or empty)

server Initial packet:
   DCID = 0x9abc9abc (echo'd from client)
   SCID = 0x... (random, server 选)

之后 client 用 DCID = server SCID 反向。每个方向均有 CID 与 packets 路由 table 一一对应。

3.2 NEW_CONNECTION_ID frame

server 后续可以发更多 CID 给 client (写在 encrypted 1-RTT packets 中):

NEW_CONNECTION_ID {
   sequence_number = 2,
   retire_prior_to = 0,    # sequence < 此值 应主动 RETIRE_CONNECTION_ID
   connection_id = 0x....,
   stateless_reset_token = ... 16 bytes for 性能 reset
}

机制作用:

  • 让 client 多 CID 多个 SPD alias 同时打 → 防 tracking
  • 一次 issue ~8 个 CID,rotate 池

3.3 PATH_CHALLENGE / PATH_RESPONSE

PATH_CHALLENGE frame body: random 8 bytes (binary token)
PATH_RESPONSE frame body: same 8 bytes echo'd

路径验证流程

A → B: send packet on path_1 (CID 不变)
A want switch path → send PATH_CHALLENGE to B with new 4-tuple
                              ↑ random 8 bytes (<path_x>)
B → A: receive PATH_CHALLENGE on path_x
       send PATH_RESPONSE on the SAME path (path_x) with same 8 bytes
A → B: receive PATH_RESPONSE on path_x → path_2 validated → start send on path_2

3.4 NAT Rebinding

NAT rebinding 是被动迁移:client 看到不变,server 看到变了。

家 NAT 老化期 300s → NAT 给新 port 给 client
client 继续在 NAT 后 socket 发,packets 出来 NAT 后 src port 变了
server 看到 src port 变 → 主动 PATH_CHALLENGE → 验证新 path → OK 后切回去
client → 不感知

3.5 Migration 流程对比

维度NAT Rebinding (被动)Active Migration (主动)
起因NAT 切换 / ISP NAT 中 port rotationclient WiFi ↔ 4G 切换
谁主动server 立刻发 PATH_CHALLENGEclient 立刻在新 socket 发 PATH_CHALLENGE
回应验client 被动 PATH_RESPONSEserver 回 PATH_RESPONSE
流中断< 1 RTT (server 推断)切换 ≥ 1 RTT

3.6 跨 ISP 切换实战

某乘客在 youtube 上加速:

T=0: home WiFi, conn A start watching
       client DCID = 0x..., 4G IP 1.1.1.1 port 4500 → CDN 2.2.2.2
T=10s: leave home, WiFi lose → 4G active

client 操作系统感知 socket drop:
  same socket close 在 WiFi 下拉
  new socket 在 4G 上起, 用 same CID = 0x...
  
T=11s: send PATH_CHALLENGE on new 4G path
  packet goes through 4G modem (new NAT) → server sees src IP = 3.3.3.3
  server → PATH_RESPONSE on 3.3.3.3
  client validated → server will send new packets back to 3.3.3.3 → 继续 watch
  total interrupted ≤ 1-2 s

如果 TCP+TLS:conn 死,client 必须重新建立 TCP connection + TLS handshake (1 RTT TLS 1.3 + slow start restart from cwnd=1) → 5+ 秒中断。


四、CPU 加密 / stateless reset

4.1 stateless reset 防中间盒挂死

client 突然 abort without send CONNECTION_CLOSE,server 此 event 会留 connection 资源。stateless reset token 是方案:

server 给一个 connection_id 时也发出 token
       NEW_CONNECTION_ID { ..., stateless_reset_token = T }

server 看到 unknown DCID (OR off reboot 后) -> 仍能 identify 原来是个 QUIC packet
   生成 stateless reset packet 发出 token = T → client 收到理解 conn closed

stateless reset packet:
       short header + connection_id = unknown 0x... + a bit pattern (1)
       ↑ 比 CONN_CLOSE 小得多 (with encrypted CONN_CLOSE frame 没法 decrypt)

4.2 加密性能

QUIC 每 packet 走 AEAD:

  • AES-128-GCM:3 cycles/byte on AVX-512 → 12 Gbps per core
  • ChaCha20-Poly1305:4 cycles/byte on AVX2 → 8 Gbps per core

10 Gbps link + 1.5KB packet CPU 负载 1.7 Gbps/core。Cloudflare quiche 报告 QUIC server 50 Gbps 是命中 user space upgrade 满载。


五、产线部署观察

5.1 移动网络 migration 命中率

网络migration 命中率
WiFi → 4G 家庭场景~95% QUIC migration 实际触发
4G 行车跨基站<20% 命中(NAT port 频繁变 RENABLED)
卫星切换暂不稳
LTE → WiFi Hotspot~70% 成功

5.2 LB / Reverse proxy 配置

http3 {
    server {
        listen 443 quic reuseport;
        http3 on;
        ssl_protocols TLSv1.3;
        ssl_early_data on;
        add_header Alt-Svc 'h3=":443"';
        
        # QUIC 设置
        quic_max_concurrent_streams 256;
        quic_initial_max_data 16M;
        quic_initial_max_stream_data 1M;
        quic_idle_timeout 60s;
        quic_retry on;
    }
}

quic_retry on 让 server 启用 Retry packet 防 amplification attack:client initial + 0-RTT 总字节数 <1200B 上限,retry 让 server 拒绝 0-RTT。

5.3 客户端配置 quiche

#![allow(unused)]
fn main() {
let mut config = quiche::Config::new(quiche::PROTOCOL_VERSION)?;
config.set_application_protos(b"\x02h3")?;
config.set_max_idle_timeout(30_000);                 // conn idle 30s
config.set_max_udp_payload_size(1452);                // PMTU
config.set_initial_max_data(15_360_000);              // conn-level flow
config.set_initial_max_stream_data_bidi_local(1_536_000);
config.set_initial_max_stream_data_bidi_remote(1_536_000);
config.set_initial_max_streams_bidi(100);
config.set_initial_max_streams_uni(10);
config.set_disable_active_migration(false);           // 允许 migration
config.enable_dgram(true, 1024, 1024);                 // datagram
config.set_active_connection_id_limit(8);              // 多 CID pool
}

5.4 0-RTT 业务案例

推特 APP feed refresh:
   第 1 次启动 app: full handshake 1 RTT, server 返 NewSessionTicket
   ...
   第 N 次 (24h 内): 0-RTT + GET /v2/feed
      实测 P50 save 150ms (60ms RTT + trace send)
      P99 save 200ms

GET / HTTP/1.1
Host: api.twitter.com
X-Requested-With: twitter
X-Device-ID: ...
X-Request-ID: abc-123 ← anti-replay nonce

server 看到 early_data:

  • 取 X-Request-ID validate in cache
  • 执行 GET /feed → 回 200 + data
  • 同 request_id 来了 → reject 425

六、产线事故

事故 1:0-RTT replay 在电商

某购物 APP 启用 0-RTT 后没有 anti-replay,攻击者录流重放 → 重复下单。中剂量 6 位数。

修复

  1. 业务层用 X-Request-Id (uuid) + 5min cache
  2. server 看到 Early-Data: 1 时 IP+uid+request_hash 入 cache
  3. 路由约束:0-RTT 仅允许 GET / HEAD;POST 自动回到 1-RTT
  4. application_id_quota 限速

事故 2:移动 migration 引发 server 累积 conn

某 LB 没识别 CID 路由 → migration 后包 route 到不同 backend → 第二个 backend 不知 conn state → reset。client 看到连接 drop 而重连。

修复:LB 用 user-space QUIC 解析 + CID 一致性 hash + 状态共享(如 redis)。

事件 3:PATH_CHALLENGE 被中间盒 buffer.PageEntry

WiFi → LTE 切换,client 发 PATH_CHALLENGE,但运营商的路由器因为不熟悉的 IP 没回。client 等到 idle timeout 60s 拒连。

修复:调短 timeout(路径迁移不短、但 idle 总 timeout),或 fallback TCP+TLS。

事件 4:UDP 中间盒 fire timeout

某国家出口防火墙 UDP idle timeout 60s,QUIC 慢连接往往 30s+ 才发 ping → 阻断。

修复:QUIC keepalive 改 15s + 自动 fallback TCP HTTPS。

事故 5:0-RTT Sentiment是批 但业务早期 run break

某 API 在 0-RTT 时已收到流量 → 重启 backend。内存 cache 丢了 first 5min ticket nonce →- replay 之前 cache ticket 仍 work → 实际录流票 5min 内可重放。

修复:server 重启清 cache + session ticket rotation + 30s 上线 dryrun。


七、易错清单

  1. 0-RTT replay 是 cross-connection,TLS 1.3 不防护,必须 application frequency 检测 nonce / request_id
  2. PSK-only 模式无前向安全,推荐 PSK+DHE
  3. PATH_CHALLENGE 不应该用 known bytes,必用 random 8 字节
  4. CID 长度建议 8 字节:4B 太短碰撞风险,20B too long bandwidth 浪费
  5. NEW_CONNECTION_ID 多 CID 池可抗 correlation tracking:8个 CID ~8 同时可用
  6. stateless reset token 是 server 给的,client 不要逼 send。server 在 conn state lost 时用
  7. migration 必须完整验证 path:semi-validated state 暂时不应该算 ready
  8. 0-RTT 不能用于 streaming 长流:实际上 0-RTT 后必须切到 1-RTT,连续 streaming 已必须完整 handshake

八、这一章带走的东西

  1. 0-RTT 用 PSK + early traffic secret,只有幂等请求能走,业务必须 anti-replay
  2. PSK-DHE 保前向安全,能 implement 0-RTT 兼顾 PFS
  3. CID 是连接唯一标识,让 mobile migration、LB routing 与 NAT rebinding 一致
  4. PATH_CHALLENGE / PATH_RESPONSE 是路径验证机制,是 migration 与 NAT rebinding 同一回事
  5. 0-RTT 实战部署:request_id nonce + 5 分钟 anti-replay cache + 限 4-16KB
  6. migration 让 mobile 用户跨 ISP 持续使用,但 LB 必须解 CID;3-5 跳路由防水 dissipation 是变窄点

下一节 →

BBR 在 QUIC 下的表现 — ack_delay 解放、QUIC 流量整形、BBR v3 在 quiche / msquic 实验部署。

BBR 在 QUIC 下的表现

TL;DR

BBR 在 QUIC 内部的表现与在 TCP 上有几个关键差异:QUIC 的 ack_delay 字段让 RTT 估计完全干净、packet number 单调让丢包检测无歧义、每 stream 独立流控让 cwnd 模型更清爽。但 BBR v1 与 CUBIC 共享 QUIC 时不利公平性仍然存在、CPU overhead 上 user-space AEAD 加密 + pacing 是部署挑战。本节分析 BBR 在 QUIC 实现层的细节、Cloudflare/Akamai 公开数据、关于 BBR v2/v3 在 QUIC 的部署时间表、以及一线部署的真实权衡。


一、TCP+BBR vs QUIC+BBR 的协议差异

1.1 RTT 估计

TCP+BBR 用 tcp_timestamps option:

  • receiver echo TSecr 回 sender
  • sender 算 sample = now - TSecr - 但 sender 不知道 ACK 是否延迟合包
  • BBR v1 假设 ack_delay = 0 → 在 socket coalescing 估算偏低 → BBR cwnd 估 60% → 吞吐 ×0.5

QUIC+BBR:

  • ACK frame 中 ack_delay 字段(µc/2^N)
  • sender 直接回算:sample = now - send_time - ack_delay
  • BBR 的 RTTprop 不被任何"杂音"污染

实测:在 Cloudflare CDN 上 BBR v1 RTT 估计 50ms 平均,TCP+BBR 是 65ms(15ms 是 ack delay)。

1.2 丢包检测

TCP+BBR:

  • BBR 用 RACK 之类算法决定丢包
  • 依赖 dup ACK count + timestamp
  • 在网络包 reorder 时 spurious 快噪

QUIC+BBR:

  • ack_ranges frame 让 sender 直接看哪些 packet 已 ack
  • packet number 单调 → 包 50 acked 但包 49 没 → 包 49 已丢
  • 完全无 dup ACK amount + RACK 复杂度
  • BBR loss event 是精确事件

1.3 Pacing

BBR 关键思路之一是pacing rate(不是单纯 cwnd):每包发送间隔 = paced_interval = MTU / pacing_rate

TCP+BBR:pacing 落入 qdisc (fq, cake, sch_fq_codel) 实现,但很多 server iptables Layer 4 不支持,pacing 退化为 burst。 QUIC+BBR:user space 主控 timer,每 packet 直接控制 sleep time。std::time::Duration:

#![allow(unused)]
fn main() {
let next_send_at = last_packet_send_time + packet_pacing_interval;
sleep_until(next_send_at);
}

精度可达 µs。但 CPU 占用:user-space timer → 多 thread ≈ kernel ctx switch + latency。pacing 量 100 ns/packet provider 微进程 context switch limiting。


二、QUIC 拥塞控制接口与生态

2.1 RFC 9002 给的接口

# Congestion control API (RFC 9002)
class CongestionController:
    def on_packet_sent(self, pn: int, bytes: int, in_flight_bytes: int): pass
    def on_packet_acked(self, acked_packets: List[int], now: float, ack_delay_us: int): pass
    def on_packets_lost(self, lost_packets: List[int], now: float): pass
    def on_rtt_measurement(self, rtt_us: int, now: float): pass
    def on_congestion_event(self): pass
    def loss_detection_timer_expired(self): pass  # PTO 超时

    # state accessor
    def congestion_window(self) -> int: pass
    def bytes_in_flight(self) -> int: pass
    def pacing_rate(self) -> int: pass  # bytes/sec, optional

on_packet_ackedack_delay_us → CC 算法可以直接用。

2.2 主流库的 CC 实现

NewRenoCUBICBBR v1BBR v2BBR v3
quiche (Cloudflare)默认试验中
quic-go默认
lsquic默认trial
msquicistrial
ngtcp2

部署 BBR 在 QUIC 一般直接 setsockopt / config 走特定校准。

#![allow(unused)]
fn main() {
let mut config = quiche::Config::new(quiche::PROTOCOL_VERSION)?;
let cc = quiche::congestion::BBR::new();
config.set_congestion_control(cc);
config.set_initial_congestion_window(10 * 1460);  // 14 KB initial cwnd
config.set_max_idle_timeout(30_000);
config.set_max_send_udp_payload_size(1452);
}

三、BBR v1 在 QUIC 实测

3.1 Cloudflare 部署数据

Cloudflare 在 2022 起全线 QUIC + BBR v1:

  • HTTP/3 流量从 5% 升到 28%
  • P99 latency 同等地 ~9%
  • 重传包数下降 5×
  • RTT 估计均匀 ± 3ms(TCP+BBR 是 ± 30ms drift)

3.2 BBR v2 在 lsquic 公布

Akamai + Facebook 在 lsquic 测 BBR v2:

  • ECN support 让 probe 不丢包 → bufferbloat era 0
  • ProbeBW 涨幅从 1.25x 降到 1.05x → 公平性提升
  • ProbeRTT 周期更长,让 cwnd 不震荡

公平性对比(同链路):

流量组合CUBIC 公平BBR v1 公平BBR v2 公平
2× CUBIC50%/50%--
2× BBR v170%/30%BBR 80%/🍦80%/20%
BBR+CUBIC80%/20%-55%/45%
2× BBR v2--52%/48%
BBR v2 + CUBIC--58%/42%

BBR v2 在 BBR self-fairness 和 CUBIC 兼容性有显著进步。

3.3 BBR v3 时间表

Google 内部 2024 测试 BBR v3,2025+ Cloudflare trial。BBR v3 主要改进:

  • ECN-V (L4S-ECN) support
  • inline RACK-TLP 集成
  • 长窗口 burst control (各 stream 不孤军打)
  • 客户端 BBR aware <-> server 间参数共享 (extension)_CONF DRAFT

四、QUIC 流量整形与 pacing

4.1 pacing rate

#![allow(unused)]
fn main() {
// lsquic pseudo-code
fn on_packet_acked(acked_packets, ack_delay_us, now) {
    let rtt_sample_micros = (now - self.send_time(largest_acked)) - ack_delay_us;
    self.rtt_estimator.update(rtt_sample_micros);

    self.bw_estimator.on_acked(acked_packets, rtt_sample);
    self.update_pacing_rate();
}

fn update_pacing_rate() {
    let bw_bytes_per_sec = self.bw_estimator.bottleneck_rate();
    let rtt = self.rtt_estimator.min_rtt();
    let cwnd = bw_bytes_per_sec * rtt / 1_000_000 + 10 * 1460;   // BDP + 10 MTU
    let pacing_rate = bw_bytes_per_sec * 1.25;  // 1.25× → probe bw
}
}

实际 UDP socket 可用 setsockopt(SO_TXTIME) 与 cmsg / ECN:

struct scm_timestamping tso = { ... };
cmsg.cmsg_level = SOL_SOCKET;
cmsg.cmsg_type = SO_TXTIME;
cmsg.cmsg_len = CMSG_LEN(sizeof(uint64_t));
*(uint64_t*)CMSG_DATA(&cmsg) = next_send_ns;

Kernel 5.x with socket_txtime_launching 支持精确发送时间——支持 user-space pace 而不全层 setTimeout。

4.2 多 stream 共享 cwnd

QUIC 是单 conn + 多 stream,cwnd 是连接级别,但每 stream 有独立窗口:

conn cwnd = 100 KB (shared)
stream A window 16 KB
stream B window 32 KB
...

A + B computed = 48 KB ≤ cwnd = OK
入flight 上限 = min(sum stream window, conn cwnd)

scheduler 决定pacing 在多个 stream 间公平分

#![allow(unused)]
fn main() {
fn pick_next_packet(&self) -> Option<Packet> {
    // weight round-robin, byte quota
    let mut max_byte = 0;
    let mut picked = None;
    for stream in self.streams.values() {
        if stream.pending_bytes() > max_byte {
            max_byte = stream.pending_bytes();
            picked = stream;
        }
    }
    picked.pop_packet()
}
}

防 stream hog: Weighted Fair Queueing,权重可动态调整以响应用户体验。


五、QUIC over UDP 实战 band不公平问题

5.1 BBR v1 占满 buffer

链路 100 Mbps, BDP 50 KB
QUIC BBR v1: cwnd ≈ BDP = 50 KB → 但 probe 涨到 1.25 × BDP + ε → buffer 占 12.5 KB
TCP CUBIC: cwnd 涨直到丢包,buffer 占满 100 MB → 谁先丢谁退
同链路 2 流: BBR 不退、CUBIC 退 → BBR 占 80%+ 带宽

5.2 解决方案

  • BBR v2/v3 + ECN 改善 → CUBIC 不退太多
  • 共情流 (cross-flow CUBIC) 仍保 30%
  • ISP 端 rate-limit (Circlular buffer shaping) 抑制 burst

5.3 数据中心 FFT

数据中心内部使用 DCQCN (RoCEv2) 与 TCP BBR 不交错。同样 QUIC BBR:

  • ECN 标记 (DCQCN):QUIC BBR 可读 IP ECN bit → 退避 cwnd
  • 但 BBR v1 不响应 ECN,必须 v2 或自实现 patch
  • HPCC(RDMA 新算法)不与 BBR 兼容

→ 数据中心内 QUIC BBR 不推荐,留 CUBIC 或 DCQCN。


六、产线事故

事故 1:BBR v1 + CUBIC 共线,CUBIC 流饿死

数据中心边缘 eBPF 出口:BBR 流 push 占满 1 MAC upstream,同链路一台 server CUBIC 流 throughput 跌 90%。

修复:CUBIC 流调 tcp_cong_control=bbr 或在配置上分带 rate-limit。或在出口 fq_codel Qdisc 公平排队。

事故事 2:QUIC BBR RTTprop 探测失灵

某机房 RTTprop 探测周期 200ms(ProbeRTT cwnd=4),但 minimal RTT 测出 50ms(实际 5ms)。因为 RTO RTOProbe 期间 RTTprop 被误设为 50ms(startup 期)后一直未刷新。

修复:调长 ProbeRTT 触发周期 + monotonically decreasing min RTT filter;调用 BBR v2/v3。

事故 3:BBR v2 与 CUBIC 公平性测试u

某 cloud 多租户出5000 tenant 共出 Ten Gbps,BBR v2 仍抢 5× CUBIC。换 NewReno (压明 cto),公平性 50-50。运营商基础流 throughput 不到 5Gbps。

修复:默认 NewReno,超带宽 quota 客户启 BBR v2 + ECN。运维层面 enforce per-tenant 之路 quota + fq_codel。

事故 4:PMTU 探测错

QUIC 起 DPLPMTUD probe,发现 1500B 但实际对方 router 节流 ICMP PTB → packet 被 silent drop。

修复:调低 max_udp_payload_size = 1452 (QUIC recommended),显式 PMTU 探 分阶段后备。

事故 5:0-RTT 与 BBR cwnd 估计错

0-RTT 早期 data 100KB 已 in-flight,但 server 还没收到 NewSessionTicket + 后:BBR 估 cwnd = 100KB → 用 0-RTT data 与 normal data compute concurrency → cwnd follow too low → 吞吐跌。

修复:BBR v2 + psk_dhe_ke + Initial cwnd reset on Full handshake completion (RFC 9002 §A.7)。


七、易错清单

  1. BBR v1 在 QUIC 仍不公平:仅 v2 才能 + ECN 启
  2. DPLPMTUD 在 ICMP 锁路由不响应:必须 fallback to 1452B 避免 silent drop
  3. D-CUBIC vs QUIC BBR:链路中 mixed,BBR 占 80% → CUBIC 流饿
  4. 0-RTT 早期 data 不在 cwnd 计算 有人误以为 → 应 include
  5. 多 stream 共享 cwnd,单一 stream 不能独占
  6. Pacing rate 用 fq qdisc 配合 在 QUIC user-space 不必走 fq
  7. 数据中心内 QUIC BBR 不推荐:与 DCQCN/HPCC 不兼容

八、这一章带走的东西

  1. QUIC+BBR 让 RTT 估计从 ± 30ms drift 提到 ± 3ms,pacing 精度 ~us
  2. ACK frame ack_delay 字段是 BBR RTTprop 测准的根本
  3. 各库支持 BBR v1 普及,BBR v2 在 quiche/lsquic 可用;BBR v3 2025+ Cloudflare trial
  4. 与 L4S / ECN + BBR v2 是未来 bufferbloat 路径
  5. QUIC 多 stream 共享 cwnd + per-stream flow control,scheduler 决定公平分
  6. 数据中心 BBR 不推荐;公网边缘 CDN BBR 收益最大
  7. SO_TXTIME 内核支持让 user-space pacing 不靠 setTimeout 精度大

下一节 →

Part 4 · 数据库系统 — SQLite 与 Postgres、MySQL、Redis;存储引擎:B-tree / LSM / Page;事务:MVCC / WAL / 2PL;查询:RBO / CBO / HashJoin / SortMergeJoin。

第四部分 · 数据库系统

一句话

数据库是把"对状态的可恢复变更"这件事以最强形式封装起来的产品——从存储引擎 (B+ tree / LSM) 到查询优化 (RBO/CBO),从 ACID 隔离 (2PL/MVCC/SSI) 到崩溃恢复 (WAL/ARIES),从单机 (Postgres/MySQL) 到分布式 (Spanner/CockroachDB),从 OLTP 到 OLAP 列存 (ClickHouse/Snowflake/DuckDB)。理解数据库 = 理解工程理想与现实约束的对撞。

这一部分的结构

1. SQL 与关系模型

关系代数是 SQL 的内核,planner 把 SQL 解析为代数表达式再做 rule-based + cost-based 转换。理解 NULL 三值逻辑、CTE decorrelation、window sort/agg、子查询代数变换是阅读 query plan 的前提。

2. 索引与存储结构

B+ tree 与 LSM 是两条主流路径:B+ tree 适合读多写少(OLTP),LSM 适合写多读少(time series, ClickHouse-ish SST)。两者都有写放大、读放大、空间放大的折中。

3. 日志与崩溃恢复

WAL(redo log)+ undo log 让数据库在崩溃后能 redo 重做已提交、undo 回滚未提交。ARIES 是工业级 WAL 协议设计,被 Postgres/MySQL/SQL Server/Oracle 全部遵守。

4. 查询优化

planner 选择 join 顺序、join 算法(hash/merge/nested loop)、向量化执行、列存格式——是数据库"不写代码也跑得快"的核心。

5. OLAP 与现代数据栈

列存 + 向量化执行 + 物化视图 + Lakehouse (Iceberg/Delta/Hudi) 是过去十年数据栈演进总结。


读完你应该能回答

  • 为什么不能把 MVCC tid 直接当 row id
  • InnoDB 与 PostgreSQL MVCC 的 vacuum 性能差异
  • LSM Tree 与 B+ Tree 在 update 吞吐与读放大上的权衡
  • ARIES 的 redo-only undo 设计到底是为了什么
  • 列存为什么对 SIMD + cache 友好
  • 为什么 Postgres 不用 MySQL 的 gap lock 防止 phantom (改用 SSI)
  • DuckDB 与 Postgres 在 OLAP 100GB 查询上的 100x 性能差来源
  • Iceberg 在大数据生态里到底解决了什么——为什么取代 Hive Metastore 是平滑过渡

SQL 与关系模型

这一节

关系代数与 SQL semantics

TL;DR

Codd 1970 的关系代数是真"理论"——σ / ∏ / × / ∪ / − / ρ 六个原语 + ⨝ join 衍生。SQL 是关系代数的可读文字形态加上工程便利扩展(GROUP BY / 聚合 / 窗口 / NULL 三值逻辑 / CTE / 递归 / Recursive)。本节从关系代数走到 planner 的 rule-based 重写规则、SQL 三值逻辑的踩雷、CTE decorrelation 实战、window 函数执行模型——这一节是看懂任何 DB 的 explain plan 的入门钥匙。


一、关系代数六个原语

符号名字SQL 对应
σ_p(R)selectionSELECT * FROM R WHERE p
∏_A(R)projectionSELECT A FROM R
R×Scartesian productSELECT * FROM R, S (no join)
R∪SunionUNION
R−Sset differenceEXCEPT
ρ_N(R)renameR AS N
R⨝_p Sjoin (衍生)JOIN ON p
γ_A,agg(R)group-by (衍生)GROUP BY A
τ(R)sort (工程扩展)ORDER BY

二、关系代数到 SQL 的映射

SELECT u.name, COUNT(o.id)
FROM users u
LEFT JOIN orders o ON u.id = o.user_id
WHERE u.age > 18
GROUP BY u.name
HAVING COUNT(o.id) > 5
ORDER BY COUNT(o.id) DESC

抽象成代数树:

τ_count desc    ← ORDER BY
  └─ γ_name, COUNT(id)  HAVING COUNT(id) > 5    ← GROUP BY + HAVING
       └─ σ_age > 18   ← WHERE
            └─ ⨝_id=user_id (left)   ← LEFT JOIN
                 ├─ users (R)
                 └─ orders (S)

每节点对应一个 physical operator,planner 通过代数规则重排节点顺序以最小化 cost。


三、planner 的代数变换规则

规则原始重写后实际收益
predicate pushdown∏(σ_p(R))σ_p(∏(R))减少扫描量
join reorderingR ⨝ (S ⨝ T)(R ⨝ S) ⨝ T选小/中表先 join
projection pushdown∏_{A,B}(σ_p(R))σ_p(∏_{A,B}(R))列裁剪减 IO
join predicate pushdownσ_p(R ⨝ S)σ_p(R) ⨝ S早过滤
constant folding1 + 1 > 2TRUE简化
subquery decorrelationSELECT ... WHERE x IN (SELECT y FROM T)LEFT SEMI JOIN重写为 join
common subexpression elimination∏_∩(σ_p(R) ∪ σ_q(R))∏_∩(σ_{p∨q}(R))减少重复扫描
dead branch removalσ_{0=1}(R)empty relation计算消除

PostgreSQL 用 13.x 的 GEQO (Gen代算法) + dynamic programming 做 join reordering。当 join 数 >12 表时切换到遗传算法避免组合爆炸。MySQL 8.0 起 optimizer 也支持 hash join 重写,之前仅 nested loop。


四、三值逻辑(NULL)

SQL 的 NULLUNKNOWN(不是 0 也不是 false),引入三值逻辑。老师常忽略:

AND / OR / NOT 真值表

ANDTFUORTFU
TTFUTTTT
FFFFFTFU
UUFUUTUU

WHERE 子句与 NULL

WHERE p 选择 p == TRUE,UNKNOWN 与 FALSE 都被排除:

SELECT * FROM users WHERE age > 18;       -- age=NULL 不返回,因 UNKNOWN 排除
SELECT * FROM users WHERE age <= 18 OR age IS NULL;  -- 取得 NULL
SELECT * FROM users WHERE age != 18;      -- NULL 不返回

Aggregate 与 NULL

函数行为
COUNT(*)数所有行(包括 NULL)
COUNT(col)跳过 NULL
SUM(col)跳过 NULL
AVG(col)跳过 NULL(分母是 non-NULL 计数)
GROUP BYNULL 算一个组
ORDER BYPostgreSQL 默认 NULL last (ASC),MySQL 默认 NULL first
UNION去重 NULL,与 5NULL 是不同行

实战坑

-- ❌ 想找"不为 true"的行
SELECT * FROM users WHERE active = FALSE;
-- 返回 active=FALSE 行;忽略 active=NULL

-- ✅ 正确版
SELECT * FROM users WHERE active IS NOT TRUE;
-- 返回 active=FALSE 或 NULL
-- 还原窗口 + NULL
-- LEAD(col) over NULL → 仍返回 NULL
-- FIRST_VALUE(col) IGNORE NULLS (PostgreSQL 没原生语法,需要 workaround)

五、SQL:1999 OLAP 扩展

5.1 window 函数

SELECT
    user_id, order_amount,
    SUM(order_amount) OVER (
        PARTITION BY user_id
        ORDER BY created_at
        ROWS BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW
    ) AS cum_amount,
    LAG(order_amount, 1) OVER (
        PARTITION BY user_id ORDER BY created_at
    ) AS prev_amount,
    ROW_NUMBER() OVER (PARTITION BY user_id ORDER BY created_at) AS rn
FROM orders

执行模型:

  1. PARTITION BY 按 key 分桶(hash 或 sort)
  2. ORDER BY 桶内排序
  3. 滑动窗口聚合:每行扫描时按 window frame 移动累加 / 求 sum / min / max
  4. LEAD/LAG/ROW_NUMBER 通过 sort + 上一行 offset 实现
  5. NTILE 用 sample count percentile 切分

PostgreSQL 13 加 RANGE BETWEEN INTERVAL '1 day' PRECEDING AND CURRENT ROW 支持。DuckDB 1.0 支持任意 window frame expression。

5.2 CTE (Common Table Expression)

WITH recent_orders AS (
    SELECT * FROM orders WHERE created_at > '2024-01-01'
),
top_users AS (
    SELECT user_id FROM recent_orders GROUP BY user_id LIMIT 10
)
SELECT * FROM top_users;

Postgres 12 之前 CTE 是 optimizer barrier——子查询被 materialize 然后注入,inline-replace 在 12 才默认开启。MySQL 8 引入 CTE 时已是 inline 模式。

5.3 Recursive CTE

WITH RECURSIVE descendants AS (
    SELECT id, parent_id, 0 AS depth FROM nodes WHERE parent_id IS NULL
    UNION ALL
    SELECT n.id, n.parent_id, d.depth + 1
    FROM nodes n JOIN descendants d ON n.parent_id = d.id
    WHERE d.depth < 10
) SELECT * FROM descendants;

执行模型 iteratively fixpoint:

  1. base case:第一行 SELECT 算一次结果放 worktable
  2. recursive case:把 worktable 当表跑下一行 SELECT,产出追加到 worktable
  3. 重复直到递归 case 不再产出新行或 cycle 检测

应用:组织树、依赖图、ISO 国家代码层级、推荐系统"朋友的朋友"。

5.4 anti-semi join

SELECT * FROM users WHERE NOT EXISTS (SELECT 1 FROM orders WHERE user_id = users.id);

NOT EXISTS 自然对应 anti-semi-join;planner 通常用 hash anti-join 实现,O(M+N)。新手常写 LEFT JOIN + IS NULL 也可重写:

SELECT u.* FROM users u LEFT JOIN orders o ON u.id = o.user_id WHERE o.user_id IS NULL;

MySQL 5.6 之前 LEFT JOIN + IS NULL 比 NOT EXISTS 快一些;之后两者等价。PostgreSQL 始终两者等价。


六、嵌套子查询与 decorrelation

6.1 朴素子查询与性能

-- ❌ 慢:相关子查询,每行 users 跑一次
SELECT * FROM users u WHERE EXISTS (
    SELECT 1 FROM orders o WHERE o.user_id = u.id AND o.created_at > '2024-01-01'
);

-- ✅ planner 重写为 semi-join(PostgreSQL 14+ 自动)
SELECT u.* FROM users u
  LEFT SEMI JOIN orders o ON o.user_id = u.id AND o.created_at > '2024-01-01';

6.2 Lateral Join(PostgreSQL 9.3+)

SELECT u.name, recent.order_id
FROM users u,
LATERAL (
    SELECT order_id FROM orders o
    WHERE o.user_id = u.id
    ORDER BY created_at DESC LIMIT 3
) recent;

LATERAL 让子查询引用左边表 u 的列——常用于 top-N per group。

执行模型:对每行 users 跑一次 LATERAL 子查询,结果集 join。Lateral join + 索引支持可高效(每行用 user_id index lookup)。

6.3 correlated vs uncorrelated 子查询

-- uncorrelated:子查询独立可代入常量
SELECT * FROM users WHERE age > (SELECT AVG(age) FROM users);

-- correlated:依赖外行
SELECT u.name, (SELECT COUNT(*) FROM orders WHERE user_id = u.id) AS order_count
FROM users u;

uncorrelated 子查询可一次性 evaluate,correlated 必须按外行迭代。Planner 用 magic set/decorrelation 把 correlated 重写成 join——但不是所有 DB 都做。Postgres 做得到,早期 MySQL 不行。


七、CTE 与 optimizer barrier

7.1 PostgreSQL 12 之前:CTE materialize

-- 在 PG 11 中:
WITH big_cte AS (SELECT * FROM large_table WHERE x > 10)
SELECT * FROM big_cte WHERE x < 5;

PG 11 把 large_table ≥10 的所有行写到 worktable,再 filter <5——即使极少数行匹配 x<5。这是 optimizer barrier。

PG 12 默认 inline:CTE 直接放回 SQL,planner 可下推 filter:

原 PG 11:    materialize(SELECT * FROM large_table WHERE x > 10) WHERE x < 5
PG 12+ inline: SELECT * FROM large_table WHERE x > 10 AND x < 5

7.2 强制 materialize (MATERIALIZED)

PG 12+ 给 CTE 加 marker:

WITH big_cte AS MATERIALIZED (SELECT * FROM large_table WHERE x > 10)
SELECT * FROM big_cte WHERE x < 5;

适用场景:

  1. CTE 多次引用且重计算太贵
  2. planner 试图 inline 但实际代价更高(极复杂子查询里有全表)
  3. CTE 有副作用(CTE 包 WITH RECURSIVE / DML)

八、产线案例

8.1 NULL 与 ISO 9001 业务逻辑

交易系统想"未审核订单不返回":

-- ❌
SELECT * FROM orders WHERE approved_by != 'master';
-- approved_by=NULL 的未审核订单也不出现

-- ✅
SELECT * FROM orders WHERE approved_by IS DISTINCT FROM 'master';
-- IS DISTINCT FROM 把 NULL 视为不同值的第三值

MySQL 8 起支持 IS NOT DISTINCT FROM,PG 9 起支持。

8.2 窗口函数慢解析

电商"每用户前 3 单":

WITH ranked AS (
    SELECT user_id, order_id,
           ROW_NUMBER() OVER (PARTITION BY user_id ORDER BY created_at) AS rn
    FROM orders
)
SELECT * FROM ranked WHERE rn <= 3;

PostgreSQL:sort on (user_id, created_at) → 行号 ceil;100GB orders 做 break:

  • 单次 sort 输入-B-tree merge 太慢
  • 若 user_id 高基数,单排序可行
  • 若 user_id 低基数(少量 user hstore 单 user 巨量订单)→ track partition hash + per-partition top-k heap → 大幅加速

DuckDB / ClickHouse 内部都是 partition-aware 的 efficient top-k 实现。

8.3 CTE 递归爆炸

依赖图 N=10^8 边,recursive CTE 没限 depth → iter 直至内存爆。MySQL 没硬上限,PG max_stack_depth 可控,但 N 太大 → 显式用 PL/pgSQL cursor 求 workitem batch。


九、易错清单

  1. WHERE col != 'x' 不返回 NULL——改用 IS DISTINCT FROM
  2. COUNT(col) 跳过 NULL;COUNT(*) 全数
  3. ORDER BY col ASC NULLS LAST 必须 explicit
  4. CTE 在 PG 12 之前是 materialize barrier,旧业务升 PG 后 planner 行为变化
  5. LATERAL 子查询不可在 MySQL 5.7 用,MySQL 8.0+ 支持 LATERAL
  6. recursive CTE 必加 stop condition 防 cycle
  7. anti-join 用 NOT EXISTSLEFT JOIN + IS NULL 在 PG 上等价
  8. OR 优化:WHERE a = 1 OR b = 2 难用 index;rewrite 为 UNION ALL 后 PG 可走两个 index scan union

十、这一章带走的东西

  1. SQL 是关系代数 + 工程便利扩展;planner 把 SQL 解析为代数表达式再 rule 重排
  2. NULL 是 UNKNOWN 第三值;aggregate / WHERE / ORDER BY 不同处理
  3. CTE PG 12 起默认 inline,老 PG 视为 optimizer barrier(可显式 MATERIALIZED 锁定)
  4. window 函数执行模型:PARTITION → ORDER → frame sliding → aggregate
  5. recursive CTE 做 cycle / DAG traversal,必加 stop condition
  6. anti-join 用 NOT EXISTS,semi-join 用 EXISTS/IN,永远不会 null-join 重复 row
  7. LATERAL 子查询引用外行 → top-N per group 用得到,性能靠外 side index

下一节 →

事务、ACID、隔离级别与现象 — ACID、ANSI SQL 4 隔离级别、Berenson critique、2PL、MVCC、SSI

事务、ACID、隔离级别与现象

TL;DR

事务是数据库把"原子执行一段状态变更"封装的最小单位。ANSI SQL-92 给了 4 个隔离级别 (RU/RC/RR/SER),Berenson 1995 论文指出 ANSI 漏了 3 个 不能被 4 级别覆盖的异常 (写偏斜 / 读偏斜 / 丢失更新)。本节从 ACID 到锁矩阵、2PL(growing/shrinking phase)、S2PL/SS2PL、MVCC 实现的 Snapshot Isolation、PostgreSQL 的 SSI、CockroachDB 的 Serializable-only、Spanner 的 TrueTime 外部一致性——这是任何分布式数据库设计的必经课。


一、ACID

含义实现机制
Atomicity全或无(commit 或回滚整体)undo log + commit marker
Consistency应用层不变量保持constraints / FK / unique / trigger
Isolation并发事务互不干扰2PL / MVCC / SSI / TS
Durabilitycommit 后即使断电数据不丢WAL(redo log)fsync + commit record

note

C 不由 DB 保证——DB 只保证约束 / FK / unique 检查通过;A.I.D 是数据库机制。任何业务 invariant("账户余额≥0")必须 app 层 enforce。


二、ANSI SQL-92 4 隔离级别

级别Dirty ReadNon-repeatable ReadPhantom
READ UNCOMMITTED允许允许允许
READ COMMITTED不允许允许允许
REPEATABLE READ不允许不允许允许
SERIALIZABLE不允许不允许不允许

3 现象定义:

现象描述
Dirty ReadT1 读到 T2 未提交的数据;T2 abort → T1 看到的数据从未存在
Non-repeatable ReadT1 内两次读同 row 得不同值(T2 提交了修改)
PhantomT1 内 WHERE 范围两次得到不同 row set(T2 插入了新 row)

三、Berenson 1995 critique

A Critique of ANSI SQL Isolation Levels 论文指出 ANSI 4 级别的formal definition有漏洞,且实用中漏了 3 个异常

  • Dirty Write:T2 写 over T1 未提交写 → cascading abort
  • Lost Update:T1 读 v1,T2 commit v2,T1 写 v3 commit → v2 被丢
  • Read Skew:T1 读 row A 后(A 已被 T2 改 B),再读 B → 看到 T2 前的 A 与 后的 B,invariant 被破
  • Write Skew:两 tx 基于同一 SELECT 判定,写不同 row commit → 整体 invariant 失效
  • Cursor Lost Update / Read-then-Write Lock

Berenson 提出 Snapshot Isolation (SI):每事务开始时分配 snapshot(timestamp),所有 SELECT 都从 snapshot 看,所有 write 在 commit 时检测 − write-write conflict(如果两事务写同一 row → 后提交 abort)。

异常SI 是否防护
Dirty Read
Non-repeatable
Phantom是(snapshot 是只读的)
Dirty Write
Lost Update是(write-write conflict abort)
Write Skew
Read Skew

SI 比 RR 更强但弱于 SERIALIZABLE。SI 不能防 write skew 是它的最大缺陷。


四、典型 write skew 案例

Two-doctor on-call invariant: 至少一医生 24/7 当班

T1                            T2
BEGIN                          BEGIN
SELECT count(*) FROM on_call   SELECT count(*) FROM on_call
  WHERE name IN ('Alice','Bob')  WHERE name IN ('Alice','Bob')
=> 2 (Alice + Bob 都当班)         => 2

UPDATE on_call SET on_call=FALSE   
  WHERE name='Alice'              UPDATE on_call SET on_call=FALSE
                                    WHERE name='Bob'
COMMIT                         COMMIT

→ 现在 0 医生当班,invariant 被破

PostgreSQL SSI 能检测到 rw-anti-dependency(read-write conflict),abort 其中一个事务。


五、锁矩阵与 2PL

5.1 锁模式

  • S (shared) read lock
  • X (exclusive) write lock
  • IS (intent shared) 父表级 hint
  • IX (intent exclusive)
  • SIX shared + intent exclusive
  • gap lock (MySQL InnoDB RR 防 phantom)
  • next-key lock = record + gap before
  • insert intent lock

5.2 锁矩阵

请求 \ 持有SXISIXSIX
S
X
IS
IX
SIX

5.3 两阶段锁定 (2PL)

1. Growing phase: T 加锁,no release
2. Shrinking phase: T 释放,no acquire
  • S2PL(Strict 2PL):X 锁保留到 commit。防 cascading abort(其他事务不会用某个未 commit 的写)。
  • SS2PL(Strong Strict 2PL, Rigorous 2PL):所有锁(S+X)保留到 commit。主流数据 库默认。

5.4 SS2PL + MVCC 组合

读通过 MVCC 走 snapshot(无锁),写通过 SS2PL(X 锁到 commit)→ 避免读阻塞写、写阻塞读。这是 Oracle / SQL Server default,PostgreSQL RC 也类似。MySQL InnoDB RR 用 SS2PL + gap lock + MVCC。


六、Snapshot Isolation (SI) 实现机制

每事务分配 snapshot_id:

  • PostgreSQL: xmin = current_txid at BEGIN
  • InnoDB: read_view = active_txns set snapshotted
  • SQL Server: READ_COMMITTED_SNAPSHOT
// PostgreSQL snapshot admission
HeapTupleSatisfiesMVCC(tuple, snapshot):
    if (tuple.xmin >= snapshot.xmax) return false;  // insert after snapshot
    if (tuple.xmax is committed && tuple.xmax < snapshot.xmin) return false;  // deleted before snapshot
    if (tuple.xmax == CurrentRunningXid) return false;
    return true;

写:在 commit 时检测 write-write conflict(PostgreSQL 与 SI 等价 RR,InnoDB 也 SI 等价)。


七、SSI (Serializable Snapshot Isolation)

PostgreSQL 9.1 起支持 SSI(论文 "Serializable Snapshot Isolation", Cahill 2008)。核心思想

检测 rw-anti-dependency:T2 写的 row 被 T1 SELECT 过 → 如果两事务都 commit,可能产生 write skew → abort 其中一个。

实现:每行被 SELECT 时记 SIREAD lock(实际上是个 predicate lock),commit 时检查这个 lock 是否被某 transaction 后续的 X 操作破坏。

PostgreSQL 内部维护两个 graph:

  1. rw-dep graph:节点 = 事务;边 = (T1 read → T2 write)
  2. dangerous structure 检查:T1 → T2 → T1 形成环 → abort circle 中"youngest" txn

性能代价:SIREAD locks 大量内存,PG 通过 SIREAD lock partition 优化 + 仅检测 SELECT 命中 index 的 case(无 index 走 fallback 等价 boost)。

CockroachDB / Spanner / FoundationDB 都是 SERIALIZABLE only。


八、各 DB 隔离级别默认与策略

DBdefault 默认SERIALIZABLE 实现
PostgreSQL 9.1+READ_COMMITTEDSSI(abort 重试)
MySQL InnoDBREPEATABLE_READ (gap lock 防 phantom)强 2PL + gap lock
OracleREAD_COMMITTED (with snapshot)SERIALIZABLE(实际 SI,无 write skew 防护)
SQL ServerREAD_COMMITTEDSERIALIZABLE(真 2PL)
SQLiteSERIALIZABLE (writer-locks-all,无 MVCC)单写者
CockroachDBSERIALIZABLE onlySSI + abort retry
Spannerexternal consistency (TrueTime)TS + 2PL hybrid
FoundationDBSERIALIZABLE onlyoptimistic + OCC abort retry
MongoDB WiredTigersnapshot (≈ SI)没原生 SERIALIZABLE
Redis (单 thread)- 不并发,无 isolation 问题-
etcdSERIALIZABLE + lease strong readraft log

九、Spanner 的 external consistency

CockroachDB / Spanner 用 timestamp oracle + commit wait

  1. 全局单点 timestamp oracle 给事务 start_ts
  2. 写后 commit_ts = start_ts + commit_wait(保证 commit 后 ts 真正超过 TS)
  3. 读时用 ts 给 snapshot—— 因 commit_wait 保证 commit 之前 ts 都已发布
  4. 真 SERIALIZABLE 不靠 SI 检测,靠"GLOBAL monotonic ts"

但这要求 GPS + atomic clock(TrueTime API)。CockroachDB 用 NTP,commit_wait = 100ms+,写延迟变高。


十、产线事故

10.1 银行 write skew 致 100 万转账复式记账不平衡

症状:A 与 B 共享账户余额 100 万;T1 把 50 万转给 C,T2 检查余额剩 60 万,又把 50 万 transfer 给 D,两操作 commit 后账户余额 = -50

根因:业务用 SI 隔离级别,没察觉 write skew 检测。

修复:业务加 SELECT ... FOR UPDATE 显式行锁;或者 PostgreSQL 把 default_transaction_isolation = serializable,让 SSI 自动检测并 abort。

10.2 MySQL gap lock 死锁

T1: SELECT * FROM orders WHERE user_id = 5 FOR UPDATE   # 在 user_id=5 范围上加 X gap
T2: SELECT * FROM orders WHERE user_id = 5 FOR UPDATE   # 等 T1 释 X 锁
T1: INSERT INTO orders(user_id, ...) VALUES (5, ...)    # 需要 insert intent lock
    # 等 T2 的 X gap lock
T2: INSERT INTO orders(user_id, ...) VALUES (5, ...)
    # 等 T1 的 X gap lock
→ deadlock; InnoDB abort one

修复:改成 SERIALIZABLE 显式 acquire lock / 用 INSERT ... ON DUPLICATE KEY UPDATE 或业务上悲观锁全段。

10.3 PostgreSQL SSI abort 风暴

业务读热 key 后写不在 read set → 但 SIREAD lock 大量占据 + abort 频。

修复:业务改 RR(无 SSI 监控);或减小 read set(用 WHERE id = $ 而非 WHERE created_at > $,hit index 直接 pass predicate lock)。

10.4 Redis "set if not exists" 是 OCC,不是 SERIALIZABLE

新手用 GET → check → SET 做"unique create"——多个并发都 GET 到 null → 都 SET → 都成功。

修复:用 SET key value NX 原子操作 或 Lua 脚本 atomic CAS。


十一、易错清单

  1. REPEATABLE_READ 不是 SI——MySQL RR 实质 SS2PL+gap lock,PostgreSQL RR 实质 SI
  2. MySQL gap lock 在 RC 关闭——innodb_locks_unsafe_for_binlog 或隔离级别 RC
  3. SELECT FOR UPDATE 不受 MVCC——总是拿 X 锁
  4. default_transaction_isolation 在 PostgreSQL 没锁表会出 RR fallback if 分片— GC 默认是 RC,业务要 explicit
  5. Postgres SERIALIZABLE abort 后业务必须 retry——常见 API 不带 retry 链路
  6. InnoDB RR 下 UPDATE ... WHERE non_index_col 会全表 next-key lock,单 UPDATE 卡全表
  7. LOCK IN SHARE MODE (MySQL) 或 FOR SHARE (PG 9.5+) 是显式 S 锁
  8. Redis / DynamoDB / MongoDB 都没有 SERIALIZABLE,文档不要骗自己

十二、这一章带走的东西

  1. ACID 是property,2PL / MVCC / SSI / TS 都是实现手段
  2. ANSI SQL-92 4 级别漏了 write skew / lost update / read skew 3 异常,Berenson critique 补足
  3. SI 防 dirty/non-repeat/phantom + lost update,不能防 write skew
  4. SSI 用 rw-anti-dep 检测 write skew,PostgreSQL/CockroachDB/FoundationDB 全部 SERIALIZABLE only
  5. MySQL RR = SS2PL + gap lock + MVCC,PG RR = SI + write conflict abort
  6. Spanner external consistency 靠 TrueTime GPS+atomic clock,CockroachDB 用 NTP + commit_wait
  7. 业务关键 invariant 应贴 SSI 或显式 FOR UPDATE,不要依赖默认隔离

下一节 →

MVCC 原理:PostgreSQL vs InnoDB — tuple header 字段、visibility rules、update = insert + mark、undo log history list、vacuum/purge 机制

MVCC 原理:PostgreSQL vs InnoDB

TL;DR

MVCC = Multi-Version Concurrency Control,让"读不阻塞写、写不阻塞读"。PostgreSQL 选择 append-only heap + t_xmin/t_xmax 模型,InnoDB 选择 原 in-place modify + undo log chain 模型——两者都在实现 SI/RR,但 weight 在 heap bloat / undo log 增长上完全不同。本节带你读完两者的 tuple header 真实字节、visibility test rules、为什么 PostgreSQL 强依赖 vacuum,InnoDB 强依赖 purge thread,什么业务在哪个 DB 上开销更大。


一、为什么需要 MVCC

如果只用 2PL:读者拿 S 锁,写者拿 X 锁,互斥。一个交易系统下午跑 SELECT count(*) FROM orders 会卡死所有 INSERT。

MVCC 改成:每事务分配 snapshot(一组 active_txns 或 timestamp),读老 snapshot 看旧版本,写也只写新版本 → 直接并发不互锁;只有在两事务write same row时通过 mutex 串行化。


二、PostgreSQL MVCC:append-only heap

2.1 HeapTupleHeader 字节布局(PG 14+)

typedef struct HeapTupleFields {
    TransactionId t_xmin;     // 4B - inserting xid
    TransactionId t_xmax;     // 4B - deleting/updating xid
    union {
        CommandId t_cid;      // 4B - command id within txn
        uint32    t_xvac;
    } t_field3;
} HeapTupleFields;

typedef struct DatumTupleFields {
    int32   datum_len_;
    int32   datum_typmod;
    Oid     datum_typeid;
} DatumTupleFields;

typedef struct HeapTupleHeaderData {
    union {
        HeapTupleFields t_heap;
        DatumTupleFields t_datum;
    } t_choice;
    ItemPointerData t_ctid;   // 6B - row pointer to current/next version
    uint16 t_infomask2;       // num attrs + flags
    uint16 t_infomask;        // xmin/xmax committed/aborted flags
    uint8  t_hoff;            // header len
    bits8  t_bits[FLEXIBLE];  // null bitmap
    // ... data follows ...
} HeapTupleHeaderData;

每 tuple header 固定 23 字节(外加 null bitmap / OID 等)。t_ctid(ItemPointer)是 (block_number, offset_number) 双 32-bit 字段。

2.2 三层 visibility 算法

HeapTupleSatisfiesMVCC(tuple, snapshot, cid):
    let xmin = tuple.xmin
    let xmax = tuple.xmax

    // 1) "Is xmin visible to my snapshot?"
    if XidInvisibleToSnapshot(xmin, snapshot): return Invisible
    if XidCommittedPostSnapshot(xmin, snapshot): return Invisible

    // 2) "Was xmin in-flight when snapshot was taken?"
    if XidInProgressAtSnapshot(xmin, snapshot):
        if xmin != current_xid: return Invisible  // 别人的未提交
        // 是我自己写过的
        if cid > snapshot.cid: return Invisible   // snapshot 前没看到的
        // 这条是我自己 visible

    // 3) "Has xmin been aborted?"
    if XidAborted(xmin): return Invisible
    
    // OK, tuple was visible to snapshot unless:
    // 4) "Is xmax committed (or mine later than snapshot)?"
    if xmax != InvalidXid:
        if XidInProgressAtSnapshot(xmax, snapshot) and xmax == current_xid:
            if tuple.t_ctid == self.t_ctid: return Visible  // UPDATE by myself still ok
            else: return Invisible
        if XidCommittedPreSnapshot(xmax, snapshot): return Invisible  // already deleted
    
    return Visible

note

XidInvisibleToSnapshotsnapshot.xip (active xids array) + snapshot.xmin (oldest active) + snapshot.xmax (next xid) 三组数据作 O(1) 判定。

2.3 Update = INSERT new + UPDATE old.xmax

PostgreSQL UPDATE 永远是写新 tuple + 标记旧 tuple 的 xmax

UPDATE users SET name = 'Bob' WHERE id = 5;

实际:

  1. 找到旧 tuple (xmax InvalidXid),line pointer t_ctid 指向自己
  2. 新 tuple inserted in heap (xmin=current xid,xmax=Invalid)
  3. 旧 tuple t_xmax = current xidt_ctid 改为指向新 tuple 的 block+offset
  4. (commit 后 - infomask 加 XMAX_COMMITTED)

旧 tuple 一直在 heap 中占空间,直到 vacuum。

2.4 vacuum 机制

  • autovacuum launcher 周期运行:
    • 老表 dead tuple 比例 ≥ autovacuum_vacuum_scale_factor (default 0.2) 触发
    • 大表改成 autovacuum_vacuum_threshold + scale factor 配合
  • VACUUM:标记 dead tuple space 在 page 内 FREE (line pointer 保留) → 可重用,不返磁盘 OS
  • VACUUM FULL:锁表 → rebuild compact → return space to FS。表锁期间读写阻塞
  • VACUUM (PARALLEL N) PG 13+ 启用多 worker 并发 vacuum

2.5 Bloat

PostgreSQL 老死数据堆积:

n_dead_tup  : 888M   (来自 pg_stat_user_tables.n_dead_tup)
n_live_tup  : 120M
→ bloat ratio = 7x bloat

诊断工具:

  • pgstattuple 扩展给精确 bloat estimate
  • pg_stat_user_tables 看 dead/living ratio
  • 监工 Prometheus 检 pg_table_bloat_size

修复:

  • autovacuum_vacuum_scale_factor = 0.05
  • 大表 setting 独立 ALTER TABLE t SET (autovacuum_vacuum_scale_factor = 0.02)
  • pg_repack 在线 rewrite table

2.6 PostgreSQL 没有 clustered index

PG 表是 heap:line pointer 在 pg_attribute 列里,二级索引的 item pointer 直接指向 heap (block, offset)。clustered index 通过 CLUSTER 一次性 reorder heap by 某 index,但不能自动维护(更新后 heap 不 reorder)。

→ 二级索引 lookup:1 I/O 索引叶 + 1 I/O heap page。InnoDB 相比需要 3 I/O(二级索引 + 主键 + row data)。


三、InnoDB MVCC:in-place + undo log

3.1 Clustered index = primary key B+ tree

InnoDB 表的主键是 clustered B+ tree,叶子节点直接存 row data:

PRIMARY KEY (id):
    [1, ...row data]
    [2, ...row data]
    ...
    
secondary index (user_id):
    [user_id=10, primary_key=1]
    [user_id=10, primary_key=2]
    ...

hidden fields(每 row 7+6+6+ = ~22 bytes):

DB_TRX_ID      6 bytes - last modifying trx_id
DB_ROLL_PTR    7 bytes - pointer to undo log
DB_ROW_ID      6 bytes - 内部 row id (only if no PK)

3.2 Update 模型:in-place modify + undo record

UPDATE 一次:

  1. 读 cluster index leaf row
  2. 旧版本整 row 写到 undo log segment(roll_ptr 指向 undo log entry)
  3. cluster index page 原地写新版本 row
  4. commit undo log access → 后可解析

3.3 旧版本访问(rollback chain)

// pseudo-code
Tuple ReadConsistentRec(query_snapshot, row):
    // start with cluster row (newest version)
    let rec = row
    while rec.trx_id > snapshot_visible(rec.trx_id):
        // 新 trx 不属于我的 snapshot
        rec = reconstruct_from_undo_log(rec.roll_ptr)
    return rec

undo log 在 回滚段(undo tablespace)中存;如果没人需要旧版本 → purge thread 可释放。

3.4 purge thread

MySQL 启动后台 purge thread:

  • 检查每个 undo log 是否所有 active snapshot 都 ≥ 新表
  • 是 → 该 undo log entry 可释放
  • 释放 undo page

调参:

innodb_max_purge_lag = 0              # 默认无限制 (越高让 INSERT 自动慢)
innodb_max_purge_lag_delay = 0          # 不延迟 insert
innodb_purge_batch_size = 300           # purge batch 量
innodb_undo_log_truncate = ON           # truncate undo tablespace

3.5 bloat 与 vacuum 对比

维度PostgreSQLInnoDB
旧版本位置heap 中(占主数据空间)undo log(独立 segment)
回收vacuum/FULL 锁表purge thread 自动
高频 update 行bloat 大undo log 增长大
长事务 panic长事务持有的 snapshot 阻 vacuumdominated undo log 迅猛长
二级索引 lookupline pointer → 1 I/O heapPK ≥ 1 I/O → cluster leaf 1 I/O = ≥ 2 I/O
Clustered PK无原生默认主键
索引覆盖直接 from secondary仅当 SELECT 列被 idx + PK covers 可跳 cluster

四、为什么 InnoDB purge 不锁表,PG 看死锁

PostgreSQL 缺陷:vacuum 必须扫全表 dead tuples 释放空间——10 TB 表 vacuum 单线程几小时。autovacuum 触发慢 → bloat 急升。

InnoDB 缺陷:原 in-place modify 修改 page → 必须先写 undo log → page 改完后才能 commit → 写放大比高;且所有 active snapshot 必须 ≥ undo log min trx_id,否则不能 purge → 长事务会让 undo log gobble 100 GB。

业务推荐:

业务推荐原因
OLTP INSERT-heavyPG / MySQL 都行新数据 footer 增,前者 secondary 直接 line ptr
OLTP UPDATE-heavyInnoDB 占优undo log + purge 自动,PG bloat 必须配 vacuum 调度
报表 SELECT 全表扫InnoDB 占优(clustered PK)cluster 顺序 IO
Ad-hoc index 多PostgreSQL 占优secondary 索引直 line ptr,clustered 灵活
Range scan (BETWEEN many rows)InnoDB(clustered range I/O 友好)/ PG 二级索引扫需要回表多次随机 I/O
JSONB / typedPostgreSQL(原 JSONB row type)InnoDB 不擅长 json

五、产线事故

5.1 PostgreSQL vacuum 滞后致 bloat 不可修

100 GB orders 表 7 天更新频繁,vacuum 跟不上 → bloat = 10x,但业务同时大查询 → I/O 满 → vacuum 跑慢 → 滚雪球到 80% bloat。

修复

  • ALTER TABLE orders SET (autovacuum_vacuum_scale_factor = 0.02, autovacuum_vacuum_threshold = 10000000)
  • maintenance_work_mem = 4GB 让 vacuum 用 memory 索引
  • 升 PG 16+ 用 incremental sort 减 vacuum
  • 最坏:窗口期 VACUUM FULL + 业务 SLA 60s 不可用

5.2 InnoDB undo tablespace 增长到 60 GB

业务跑超长 query(10 min SELECT)持有读 snapshot,purge thread 不能释放 undo log → undo tablespace 从 1 GB 增到 60 GB,磁盘告警。

修复

  • 业务让超长查询用 read replica(独立 undo 不涨,但这需要业务改造)
  • innodb_max_purge_lag = 65536 → 65536 undo records 上限防 INSERT,让业务自慢、迫使有人释放长事务
  • innodb_undo_log_truncate = ON,定期 truncate undo tablespace

5.3 MySQL 转 PostgreSQL 业务"为啥 UPDATE 慢多了"

某团队迁 MySQL → PG:业务大量 UPDATE,发现 PG 中 throughput 比原 MySQL 慢 30%。

根因:PG UPDATE = NEW tuple + mark + 索引页也都要指针更新(每个 secondary index 都要 INSERT index entry)。InnoDB UPDATE in-place,仅 modified column 对应 secondary index 才改。

修复

  • 把 stable 字段与变动字段分离(child 表只存变化频繁字段,主表保留 stable)
  • 改 hot column HOT(Heap Only Tuple) pattern,PG 13 + fillfactor = 85 让 update 不动 secondary index(如果改的字段不出现 index)

5.4 REPEATABLE READ 事务 + 长 vacuum block

PG 在长事务 commit 期间,autovacuum 阻塞释放该表 dead tuples(vacuum 是其他事务正常快照的 visibility-safe)。每小时事务慢一度,bloat 累加 1% / 周 → 几个月单表膨胀 50%。

修复:长事务哑改短批处理;ultrafast 应用 idle_in_transaction_session_timeout = 60000 让 60 秒空闲事务 abort。


六、易错清单

  1. PG secondary index 不会随 UPDATE in-place modify:每 UPDATE 在二级索引插入新 entry,老 entry 标记 dead → 索引也 bloat
  2. InnoDB 二级索引 lookup 必回 clustered index:除非覆盖索引 所有 SELECT 字段都在 idx 上
  3. PG vacuum 锁表? 普通 vacuum 不锁;VACUUM FULL 锁
  4. InnoDB purge 比真空快很多:但仍依赖短读事务
  5. autovacuum_vacuum_scale_factor 默认 0.2 大表太宽松——总是 ≥ 调 0.05
  6. InnoDB = MySQL engine 唯一选项:但有 RocksDB MyRocks、TokuDB,但在交易系我不推荐
  7. clustered index 在 InnoDB 必须是主键;没声明主键选 unique not null column,再没有就用隐式 DB_ROW_ID

七、这一章带走的东西

  1. PostgreSQL MVCC = append-only heap + t_xmin/t_xmax → 死堆需要 vacuum
  2. InnoDB MVCC = in-place modify + undo log chain → 后台 purge thread 自动清理
  3. PG secondary index lookup 1 I/O;InnoDB 2 I/O(→ cluster index)
  4. PG UPDATE 让每个 secondary index 都加新 entry,InnoDB 主要是 modified column 改动 index
  5. PG 大表 vacuum 必须调小 scale_factor + 大 maintenance_work_mem + 考虑 pg_repack
  6. InnoDB undo tablespace 单独存放,长事务会让它暴涨——超长 SELECT 必须 read replica
  7. HOT(Heap Only Tuple)让 PG UPDATE 不更新二级索引是性能救星,但需 fillfactor < 100 且改字段不出现在 index

下一节 →

WAL / redo / undo / 2PL — ARIES 三阶段、fuzzy checkpoint、CLR、fsync 行为、2PC 阻塞点

WAL / redo / undo / 2PL

TL;DR

WAL(Write-Ahead Logging)是数据库所有持久性保障的基础。Mohan 1992 ARIES 是工业级 WAL + recovery 标准协议(Postgres/MySQL/Oracle/SQL Server 全变体于此)。本节把 WAL 原则、物理 vs 逻辑日志、ARIES 三阶段(analysis / redo / undo + CLR)、fuzzy checkpoint、S2PL 防 cascading abort、2PC 协议、fsync 真实行为(FS + SSD cache)一次走通。理解 WAL = 理解数据库"为什么 commit 这么慢" + "为什么崩溃不丢" + "为什么 2PC 阻塞无法避免"。


一、WAL 原则

"Before any modified data page can be flushed to disk, the WAL record describing that modification must be persisted (fsync) to durable storage."

术语:

  • data page:B+ tree 的叶节点 / heap 块 / index 叶子
  • WAL redo log:append-only 文件 (segment 通常环形)
  • buffer pool / page cache:内存中的 data page 副本
  • LSN (Log Sequence Number):WAL 每条 record 的单调 ID

修改流程:

1. T1 begins
2. T1 INSERT row → mutate page P_x in buffer pool
3. WAL redo log append: "LSN=100, txn=T1, page=P_x, after-image"
4. T1 UPDATE → mutate page P_y
5. WAL redo log append: "LSN=101, txn=T1, page=P_y"
6. T1 COMMIT → append "LSN=102, txn=T1, commit" → fsync(WAL)
7. commit returned to client
8. checkpoint 或 replacement 策略 flush data pages P_x, P_y to disk

WAL 原则:步骤 8 flush data page 前必须保证 LSN 100/101 都已在 disk WAL。否则 page 写盘后 disk 上 data 是 modified 内容,但 WAL 没有 → 崩溃时无法重放。

PostgreSQL:data page 每页都存 LSN (page LSN = max WAL record affecting this page)。flush page 前比 WAL flush 到 LSN ≥ 该页 page LSN → 强 consistency。


二、redo log 与 undo log

2.1 redo log

记录"动作发生了什么"——崩溃后 replay 重做:

  • 物理 redo:"page id=0x1000 byte offset 0x10 = value 0xCAFE"
    • 重放简单(保证 page ID 不变 → 重做不会破)
    • 跨版本 page layout 兼容差
    • MySQL InnoDB / SQL Server 用物理 redo
  • 逻辑 redo:在 row 语义层面记录
    • "INSERT row id=5 to users, columns={name='Alice'}"
    • 重放复杂:需要"找 users 表 / 找合适位置"
    • 跨版本 page layout 兼容强
    • PostgreSQL redo 是逻辑记录(page-level:覆写整页的 column value)

PostgreSQL 还保留 FPI(Full Page Image):第一次修改某 page 时把整页 snapshot 到 WAL,保证 redo 之前 page 状态完整可验。

2.2 undo log

记录"如何反向操作"——rollback 用:

  • 物理 undo:"page id=0x1000 byte offset 0x10 = value 0xBEEF"(旧值)
  • 逻辑 undo:"INSERT row id=5 的反向 = DELETE row=5"

InnoDB undo log 同时用来:

  1. rollback 事务
  2. MVCC history chain(旧版本数据存在 undo log)

PostgreSQL 不使用 undo log——旧版本永远在 heap 中。rollback 只需把 commit marker 改成 aborted 即可,heap 中的旧 tuple 被 vacuum 清。

2.3 各引擎对比

引擎redo 类型undo 类型
PostgreSQL物理 + 逻辑混合(带 FPI)仅有 abort marker 在 heap
MySQL InnoDB物理(page-level)逻辑记录 + 物理 row image
Oracle物理 (LGWR)物理 (undo segments)
SQL Server物理 (TL)逻辑 (delete/insert 反)
DB2物理 + 逻辑物理

三、ARIES 三阶段

3.1 阶段图

崩溃
   ↓
[Analysis] 扫描 WAL 从 last_checkpoint LSN
   - 列出 active_txns (未 commit 未 abort)
   - 列出 dirty_page_table
   - 找到 redo_start = earliest dirty page LSN

↓
[Redo] 从 redo_start 顺序扫 WAL
   - apply each record again(idempotent)
   - 完成后所有 disk page 与 crash 前一致

↓
[Undo] 从 highest_LSN 的事务倒着扫
   - 对 active_txns 中的每个 txn
   - 顺序 reverse 该 txn 的 update record
   - apply undo action
   - 写 CLR (Compensation Log Record) "已 undo"
   - 写 final abort record

3.2 CLR(Compensation Log Record)

ARI 创新: undone 动作 也是有恢复性的——如果 undo 操作时再 crash,重启后不应该 re-undo(会重复作用)。

解决:每次 undo 写 CLR:

  • CLR 与原 record LSN 不同(新 LSN)
  • CLR 描述"the undo of record @old_lsn is applied"
  • CLR 自带 undo_next_lsn,指向之前应该 undo 的下一条 LSN

→ undo 时即便再次 crash,analysis 看见 CLR 可跳过。

3.3 fuzzy checkpoint

1. write "begin_checkpoint" record to WAL
2. (短时间内) 收集 buffer pool dirty_pages + active_txns 快照
3. write "end_checkpoint" record + metadata + fsync
4. update master_checkpoint_ptr to end_checkpoint_LSN

Between begin/end,其他 transactions 仍可正常工作 → fuzzy。Analysis 时 begin/end 间的 active txn 可能再变化,但 end 已 store 实际值即可。

3.4 PostgreSQL 与 ARIES 差异

PostgreSQL 不做真 undo(startup recovery 阶段中没有 undo pass):

  1. PG 没有 undo log,旧版本永远在 heap 中不需 undo
  2. 崩溃后直接把 aborted txn 的 tuple 完成 commit marker 改成 aborted → vacuum 后清理
  3. redo pass 重放 WAL → 把所有 committed 数据重做到 page;uncommitted 的 tuple 后 mark aborted

简化 implementation,但 FPI 让 WAL record 量更大。


四、S2PL 与 commit ordering

4.1 Cascading Abort

T1: write row A
T2: read row A (T1 未 commit,持 S 锁)
T1: abort → rollback row A
T2: read 是垃圾数据 → T2 也要 abort → cascading abort

RC 下 + 普通锁会 cascading abort。

4.2 S2PL 给解决

严格 2PL(Strict 2PL):X 锁保留到 commit。S 锁可 mid-txn release,但 X 锁不能。

→ T2 拿不到 S 锁等 T1 commit / abort 之后。

4.3 SS2PL(rigorous 2PL)

所有锁(S+X)保留到 commit。主流数据库默认 SS2PL:

T1: BEGIN
T1: write A, X-lock(A)
T1: commit → release X-lock(A), S-lock(A), ...

五、2PC(两阶段提交)

跨分片 commit:

                     coordinator
                           │
                           │ prepare
                           ↓
participant_1                    participant_2
   prepare → vote YES            prepare → vote YES
                           ↓
                  coordinator 决定 commit (若都 YES)
                           ↓
   commit + ack                    commit + ack
                           ↓
                     coordinator complete

每个参与者:

  • prepare:在本地 WAL 写 "prepared" record + fsync 该 record + 持 X 锁
  • 收 coordinator commit / abort:写相应 record + 释放 X 锁 + ack

严重阻塞点:coordinator crash 后,participants 必须无限阻塞——不能单方面 abort(可能 coordinator 已发 commit 给某个 alive participant)。

5.1 heuristics

如果 coordinator 长时间无应答,参与者可用启发式:

  • heuristically_rollback:假设 abort,本地 rollback(可能误)
  • heuristically_commit:假设 commit,本地 commit(可能不一致)

→ 一旦用了启发式 → 可能数据不一致。所有厂商强烈不推荐。

5.2 三阶段提交(3PC)

3PC 避免 coordinator crash 阻塞,但 requires timer + 三阶段通信 + 额外 RPC。实际部署:

  • DB 层不用 3PC
  • 分布式事务一律 2PC + 超短 wait
  • 业务层 saga / TCC 等补偿模型代替长持有

5.3 PostgreSQL prepared transaction

BEGIN;
UPDATE accounts SET balance = balance - 100 WHERE id = 1;
PREPARE TRANSACTION 'transfer_to_dba';
-- 此事务阻塞,直到 COMMIT/ABORT PREPARED
COMMIT PREPARED 'transfer_to_dba';

max_prepared_transactions 默认 0(禁用)。灾难点:遗忘 prepared → long-held X lock → 阻塞下游写。监控 pg_prepared_xacts 列表:

SELECT age(now(), prepared) FROM pg_prepared_xacts;
-- > 5 min 应该告警

5.4 MySQL XA

XA START 'transfer1';
UPDATE accounts SET balance = balance - 100 WHERE id = 1;
XA END 'transfer1';
XA PREPARE 'transfer1';
XA COMMIT 'transfer1';

MySQL XA 与 PG prepared 一样有阻塞点。运维必须监控 XA prepared 超时审计。


六、fsync 行为的玄学

client app: writes (data page) to buffer
kernel: data in page cache
write(fd, buf) → 仅 copy 到 page cache
fdatasync(fd) → 内核 require write I/O → SSD controller → SSD cache → NAND
fsync(fd) → 同上 + 文件 metadata sync
open(O_DIRECT) → 跳过 page cache 直写

6.1 fsync 没保证

  • 如果 fsync(file_a) 后,磁盘控制器 NVRAM buffer 仍 cache:断电后部分 write 没到 NAND → durability 没保证
  • 部分型号 SATA SSD "假装" fsync 完成——bug 名:"silent data loss"
  • md-raid writecache 作 promiser - 不可知

6.2 内核 panic

Linux 5.x 的 syncfs() / fsync() 在 server-side 故障模式下静默返回成功 (Ref USENIX OSDI '14 Pillai)。生产必须 verify。

6.3 fsync 同 device 与 fsync 其他 file on same device

  • ext4 / xfs 默认 journal order 全 file write → fsync 一种 file fsync metadata + data
  • ext4 data=journal:所有 data 在 journal 中双写

6.4 PostgreSQL wal_sync_method

$ PG wal_sync_method
wal_sync_method = fsync      # 默认 Linux
wal_level = replica

选项:

  • fsync:标准 fsync() syscall
  • fdatasync:仅 data 不 metadata(PG 9.6+ Linux 大多数发行版)
  • open_sync:open(O_SYNC) 每写入 fsync-like
  • open_datasync:open(O_DSYNC) per write

性能差异通常:fdatasync ≈ open_datasync > fsync。

6.5 真"事务安全"验证

  • pg_test_fsync:测试不同 fsync 方法延迟,写 8KB × N
  • sysbench fileio --file-fsync-flush=on --file-fsync-all=on
  • 严防 fake fsync:consumer SSD 标 "workstation" mode 有时会感觉换 cache

6.6 SAS NVMe SSD

  • nvme admin-passthruvolatile_write_cache 状态
  • enterprise NVMe 默认 power-loss protection → fsync 必真实
  • consumer NVMe 必查 PLP;必要时禁用 nvme_cache_enable=0

七、产线事故

7.1 fsync 不真 → 崩溃丢已 commit 数据

PG 跑廉价 SATA SSD,断电后已 commit 数据消失。

根因:SSD 控制器 cache "battery-free" 假装 fsync 立即完成,NAND not yet fixed。

修复:enterprise SSD with PLP;启用 fsync 行为测试(pg_test_fsync)+ wal_sync_method=fsync + synchronous_commit=on

7.2 长事务阻塞 InnoDB undo purge

业务 5 min 长查询 + 100 QPS 热点表 INSERT → undo tablespace 从 1 GB → 30 GB → 80 GB 警报。

修复:长查询走 read-only replica;调 innodb_max_purge_lag = 65536 让大 undo 阻塞新 INSERT。

7.3 2PC coordinator crash 30 分钟业务阻塞

微服务跨分片 2PC,coordinator crash → participants hold lock 30 min wait → 业务吞吐 0。

修复:业务换 Saga pattern:业务事件队列 + 接收方幂等消费,避免 in-database 2PC。

7.4 checkpoint 频率 vs recovery time

InnoDB innodb_max_dirty_pages_pct = 75 太低 → checkpoint 频繁 flush → I/O 占满 backup → 崩溃 recovery 时间 5 秒。但 production I/O 饱和。调到 90% + 双 buffer pool flushing → recovery 30 秒,业务延迟降 30%。

7.5 PG synchronous_commit = off 后崩溃丢信

某业务开 synchronous_commit=off(追求性能),断电后丢最近 5s 写入。

根因:PG commit 返回 client 时不等 fsync。fsync-sensitive 业务不可接受。

修复:核心交易路径 synchronous_commit=on, local(仍 commit)。


八、易错清单

  1. fsync 不是真物——廉价 SSD / Linux FS 有许多 silent data loss path
  2. WAL 只能 fsync 不能 fsync-mixed:必须先 fsync WAL 再 fsync data page
  3. PG 没有 undo pass——与 ARIES 不同。简化实现,但 redo log 含 FPI 量更大
  4. MySQL innodb_flush_log_at_trx_commit=2 是 fsync=每秒 → DB 可丢 last 1s 数据
  5. PG prepared transaction 必监控——coordinator 长时间没决定 → lock 持有无上界
  6. SSD PLP (Power Loss Protection) 是 production 必查项
  7. synchronous_commit=off 仅性能场景可接受;交易系必须 on
  8. 2PC 必有阻塞——业务应 saga / TCC 替代

九、这一章带走的东西

  1. WAL 原则:modified page flush 前 WAL record 必已 fsync 到 platter
  2. ARIES 三阶段 analysis → redo → undo(含 CLR);PostgreSQL 不做 undo pass
  3. 物理 vs 逻辑 redo:PostgreSQL 选择逻辑 + FPI;MySQL 选择物理 page-level
  4. SS2PL:所有锁保持到 commit 防止 cascading abort
  5. 2PC coordinator crash → participants 无限阻塞,业务必须 saga / TCC 替代
  6. fsync behavior 真实硬件 PLP 是 production 必查项;廉价 SSD 假装已完成 fsync
  7. checkpoint 调优 = I/O 平面 vs recovery 时间 tradeoff;大 buffer pool + slow flushing 是 best practice

下一节 →

索引与存储结构 — B+ tree / LSM-Tree / Hash / GIN / GiST / BRIN / explain analyze 解读

索引与存储结构

这一节

B+ 树索引与覆盖索引

TL;DR

B+ 树是关系数据库的索引默认数据结构:所有叶节点在同一层、通过双向链表连接范围扫描、中间节点只存路由信息 → 出度极大、深度浅。本节从 page 字节布局讲到 B+ 树插入分裂、删除合并、explain output 上的覆盖索引判定、二级索引 lookup 的回表代价、PG vs InnoDB 的索引差异、写放大公式 (WAF) 与 neighborhoodhtable 修改导致的 page 移动。


一、B+ 树结构

[Root]    +-----+
          | K1 K3 |
          +-+---+-+
            |   |
   +--------+   +--------+
   |              |
[Internal] /+   K1/   K3\   /
         +--+--+ +--+--+ +--+--+
         | L1 |->| L2 |->| L3 |  chain (双向链表)
         +----+   +----+   +----+

特性:

  • 所有叶子在同一层 → O(log_d N) 高度小
  • 中间节点只存 (key, child pointer) → 出度 d 大(PG d≈200 for 8KB page)
  • 叶节点含 (key, RID/heap pointer) → 双向链表支持范围扫描
  • 空 B+ 树高 log_200 (10^9) = ~4

1.1 B+ 与 B 树区别

维度B 树B+ 树
节点存内容key + value内节点只 key;叶 key + value
范围扫描难(遍历内部 + 叶)叶链表直接连续扫
出度小(带 value)大(只 key)
高度

数据库实际存储次序:B+ Tree 占绝对主流。B Tree 仅出现在 niche 专用存储(Mongodb MMAPv1、LMDB 等)。


二、PostgreSQL page 字节布局

PostgreSQL 8K page:

+----------------------------------+
| PageHeaderData (24 B)           |
+----------------------------------+
| ItemId array (4 B per item)      |  ← line pointers (point to tuples)
+----------------------------------+
| Free space                       |
+----------------------------------+
| Tuples (inserted from end)       |
+----------------------------------+
| Special section (typically 0)    |
+----------------------------------+

PageHeader 24 字节:

typedef struct PageHeaderData {
    PageXLogRecPtr pd_lsn;       // 8B - last WAL LSN modifying this page
    uint16 pd_checksum;
    uint16 pd_flags;
    LocationIndex pd_lower;      // offset to start of free space
    LocationIndex pd_upper;      // offset to end of free space
    LocationIndex pd_special;    // offset to start of special
    uint16 pd_pagesize_version;
    TransactionId pd_prune_xid;  // oldest xid needing prune
    ItemIdData pd_linp[1];       // line pointers grow forward
} PageHeaderData;

每 ItemId 4 字节指 (offset_in_page, length, flags). Tuples 末→头逆增长,free space 中间。

2.1 B+ 树索引 entry 字节布局

B-tree index entry (~16-32B per entry):

| ItemId (4B) | TupleHeader (23B) | IndexTupleData (~8B) |
              | key_data + NULL bitmap |

每 8K page 容纳约 200-400 个 key/value entry。


三、B+ 树插入与分裂

3.1 Insert

INSERT key=K into B+ tree:
1. tree.root.find_path(K) → leaf_L
2. if leaf_L 内 has free slot → INSERT K at sorted pos
   else → SPLIT leaf_L into leaf_L + leaf_L2
3. push_median up to parent with route key
4. recurse if parent also full
5. (worst case) root 分裂 → new root above → tree 高度 + 1

分裂方法:

  • left-to-right split (50/50):直接中分(Lehman-Yao)
  • split-at-insertion point:在插入位置附近分(适合热点 key)
  • border split:把 9:1 比例分(PostgreSQL 用)

3.2 删除与 merge

DELETE key=K from B+ tree:
1. find leaf_L
2. mark entry as DEAD → page 内 slot-free
3. if leaf 上 entries < (max/2) → 取 sibling merge 或 redistribution
4. (recursive 上层)
5. (worst case) root empty → tree 高度 - 1

PostgreSQL 的实现:

  • 死 leaf entry 不立即 free slot,等 vacuum reclaim
  • n_dead_tups 显示表 bloat;索引同样 bloat
  • vacuum 走 btvacuumscan清死 tuple 同时 shrink page

四、写放大 (WAF)

每次 1 行 INSERT 触发:

  1. 1 WAL record (data + FPI ~8KB on first modify)
  2. 每个含所修改列的索引 1 WAL record (~数百字节) + page modify
  3. 若 page full 触发 split,附加整 page copy + redo

公式(粗略):

WAF (Write Amplification Factor) = N_index × 1 + 1

→ 有 4 个二级索引的表,单 INSERT = 5 个 page 改动。

性能调优:减少二级索引(每次新增业务,问真的需要 index?"加索引 = 写慢 N 倍")。


五、覆盖索引 (Covering Index)

5.1 回表代价

普通二级索引 SELECT id, name from index on (name):

  1. lookup (name='Alice') in index → hit 叶 entry → get heap RID (page, offset)
  2. 找 heap page → 1 I/O (可能 cache hit) → get row data
  3. 二级索引 entry 不含 id,回 heap 取 id

→ "回表" = secondary index + heap = 2 I/O。

5.2 覆盖索引消回表

-- InnoDB
CREATE INDEX idx_name_id ON users(name, id);
SELECT id, name FROM users WHERE name = 'Alice';
-- 索引含 name + id → 不回表 → 1 I/O

PG 12+ 引入 INCLUDE 语法:

CREATE INDEX idx_name ON users(name) INCLUDE (id);
SELECT id, name FROM users WHERE name = 'Alice';

INCLUDE 列不参与 B+ tree 排序(只存在叶),不影响 split 位置与 SELECT WHERE 性能,但支持 covering scan。

5.3 PG 与 InnoDB covering 差异

引擎二级索引 stemcovering 实现方式
PostgreSQL(key) → line pointer (heap RID)INCLUDE (12+);或主 INDEX (key, covered_cols)
InnoDB(key) → primary key复合 INDEX (key, ID);或 index key + cover

InnoDB PK 在 secondary index entry 中自动作为 key 的一部分(隐含),不需 explicit cover。


六、PG 与 InnoDB 索引差异

维度PGInnoDB
Clustered无(heap + secondary 都指 line ptr)主键 = clustered B+ tree
二级索引 lookup1 I/O (heap page)2 I/O (clustered page)
covering indexINCLUDE clausecomposite (col1, col2,...)
Index organized tableNOASTtbl ENGINE=InnoDB 默认
Hash indexHash 不支持范围;GIN/GiST 仅 specialInnoDB 内存 adaptive hash index
Index-only scanPG 9.2+MySQL 5.6+
index bloatvacuum 必清online drop

6.1 InnoDB 主键列定关键

InnoDB secondary index entry = (key, primary_key)。所以 SELECT id from index(name) 不需回表:因为 id 是 PK 自动在索引 entry 中。

但 SELECT name, age from index(name) 仍需回表:age 不在 secondary index entry 内。


七、partial / expression index

7.1 Partial Index

CREATE INDEX idx_active_users ON users(name) WHERE active = TRUE;
SELECT name FROM users WHERE active = TRUE AND name = 'Alice';
-- 仅扫描 active=true 子集,索引降大 90%

适合大部分 row 不满足条件的场景。

7.2 Expression / functional index

CREATE INDEX idx_lower_name ON users(lower(name));
SELECT * FROM users WHERE lower(name) = 'alice';
-- 索引命中

PG 8.10+ 用 INCLUDE 不能用在 expression 上。MySQL 8 用 functional index 同效果。


八、产线事故

8.1 PG index bloat 致 vacuum 跑不动

10 GB orders 表频繁 DELETE+INSERT → INotifyIndex idx_user_id bloat 5x。autovacuum 跑索引 vacuum 并发扫页面慢,业务 QPS 跌 50%。

修复

  • REINDEX INDEX CONCURRENTLY idx_user_id(PG 12+)
  • 调 autovacuum_work_mem 大用 maintenance_work_mem
  • 长期方案:业务冷热表分离(active 表小+冷表 append-only)

8.2 InnoDB auxiliary index duplicate primary key 大头

某表 PK (uid, dt, id) 16 字节,secondary 100 万行 → 索引 size 比 small 表大 50 倍。

修复:拆 PK 减少 PK 字段,用 id BIGINT AUTO_INCREMENT PK + UNIQUE (uid, dt) 让 secondary 索引小。

8.3 covering index 误用

某业务 SELECT 包含非索引列 → planner 走 index scan → 90% 行回表 → 比 seq scan 慢 5x。

修复:用 EXPLAIN (BUFFERS)Heap Fetches 数:若 high,需 INCLUDE 加列或直接 seq scan。

8.4 PG partial index 不被命中

CREATE INDEX idx_no_archived ON events(name) WHERE archived_at IS NULL;
SELECT * FROM events WHERE archived_at > '2024-01-01';
-- 索引不被命中 (predicate 不匹配)

修复:PG planner 必须 SELECT 的 WHERE condition imply 索引 predicate。改业务 SQL WHERE archived_at IS NULL

8.5 索引膨胀后顺序变态 + cache miss

10 GB 表 + 10 GB 大索引,原本命中 cache 100%,更新业务以后 cache miss 多 → I/O 暴增。诊断:

SELECT relname, pg_size_pretty(pg_relation_size(relid))
FROM pg_catalog.pg_statio_user_tables
ORDER BY pg_relation_size(relid) DESC;

修复:REINDEX + VACUUM + 检查 cache size vs working set。


九、易错清单

  1. 覆盖索引回表:必须 SELECT 全列都在 INDEX (key, INCLUDE) 中才能 index-only scan
  2. PG INCLUDE 在 12+ 才支持;MySQL 仅 composite INDEX
  3. partial index predicate 必被 SELECT WHERE imply 才命中
  4. InnoDB PK 长:会让所有 secondary 索引跟着膨胀,会影响全表
  5. REINDEX 必 CONCURRENTLY(PG 12+):否则锁表
  6. autovacuum 调小 threshold:大表否则 vacuum 间隔太大 → 累死 bloat
  7. EXPLAIN ANALYZE BUFFERS:看 actual fetch vs buffers hit 才能看到 cache 行为,仅 ANALYZE 看不到
  8. expression index 不支持 INCLUDE:functional index 仍可 INCLUDE,但 expression 部分不算 key

十、这一章带走的东西

  1. B+ 树所有叶同层 + 叶链表,深度 O(log_d N) 通常 ≤4
  2. PostgreSQL index row 通过 line pointer → heap;InnoDB secondary index 通过 PK → clustered
  3. 覆盖索引消回表:PG 用 INCLUDE;InnoDB 索引 entry 隐含 PK,仍需 composite
  4. partial/expression index 在 5x 业务量能用 1/10 索引 size 命中
  5. index bloat 是 vacuum 落后的 PG 必查项;REINDEX CONCURRENTLY 5x 收益
  6. WAF = N_index + 1,每个新增二级索引 = INSERT 慢 N 倍
  7. EXPLAIN ANALYZE BUFFERS + Heap Fetches 判断 covering 是否真实

下一节 →

LSM-Tree 与 SSTable — LevelDB/RocksDB/Cassandra/CockroachDB 选 LSM 为啥、写放大、读放大、compaction

LSM-Tree 与 SSTable

TL;DR

LSM-Tree (O'Neil 1996) 把随机写转换成 sequential write,让写入吞吐比 B+ tree 高 10-50×。代价是读放大(多 level lookup)+ compaction 抢 I/O。LevelDB / RocksDB / Cassandra / HBase / CockroachDB / TiKV 都基于 LSM。本节走完 Memtable → SST L0 → L1 → ... Ln 的数据流、compaction 算法 (size-tiered / level / FIFO / TWCS)、WAF/RAF 公式、bloom filter、block cache、WiscKey KV-separation、RocksDB 配置实战、compaction storm 事故现场。


一、LSM 与 B+ tree 对比

维度B+ treeLSM-Tree
写入随机 I/O:找 page + modify顺序 I/O:append WAL + memtable
读出1 I/O(命中索引)多次 I/O(memtable + 各 level)+ bloom filter
放大几乎无WAF & RAF 与 level 有关
Compaction不需要必须,否则 RAF 爆
空间紧凑临时 2x 实施 compaction
适用OLTP 读多写多 (logs / time series / event stream)

二、数据流

Write (key=k, value=v):
   ↓
   append to WAL (顺序写 disk)
   ↓
   insert into MemTable (in-memory sorted: skiplist / red-black tree)
   ↓
(+ async) MemTable 满后 → flush to L0 SST (顺序写 disk)
   ↓
L0 SST 文件可重叠 (键可重复)
L0 → L1 compaction → L1 SST 不重叠
L1 → L2 → ... → Ln

删除 = tombstone 记录(仍走 WAL+Memtable+SST);读取时合并查找最新版本。


三、Compaction 算法

3.1 Size-tiered (STCS) — Cassandra 默认

每 level 4 个 SST;4 个满 → merge 进下一 level 4 个更大 SST。

特点:

  • WAF 小(不重 compaction)
  • RAF 大(每 level 4 文件并列)
  • 空间放大最大(合并期临时 2x)
  • 适合单纯写多 workload (log-only)

3.2 Leveled (LCS) — RocksDB 默认

  • L0 = 几个文件允许重叠
  • L1+ 大小 10x 递增(L1 10MB、L2 100MB...)
  • Lk 满触发 → 1 SST 与下一 level overlap SST 合并
  • L1+ 任何 key 在该 level 只出现一次

特点:

  • RAF 最小(每 level 仅 1 文件命中 key)
  • WAF 大(多次 compaction)
  • 空间局限固定 ~10% overhead
  • 适合读重视点

3.3 Time-window (TWCS)

Cassandra 3.8+。每时间窗口(如 1 小时)合 1 SST,相互不合并。

  • 适合事件流 / 时序数据
  • 老 timestamp 不被 OVERWRITE → 不浪费 I/O

3.4 FIFO

HBase 2.0+ 用。仅按时间过期,不合并。适合 cache-like workload。

3.5 Hybrid — RocksDB Universal

RocksDB: L0 size-tiered, L1+ leveled:

opts.compaction_style = rocksdb::kCompactionStyleUniversal;

opts.compaction_options_universal.size_ratio = 1;
opts.compaction_options_universal.min_merge_width = 4;
opts.compaction_options_universal.max_size_amplification_percent = 200;

四、WAF & RAF 公式

定义:

  • WAF = (总写入字节数 / user 写入字节数)
  • RAF = (读一个 key 时触发的磁盘 I/O 次数)

4.1 Leveled WAF

WAF ≈ L × 2
  L = level 数 = ceil(log_10 (总数据 / max_bytes_L1_base))
示例 100GB, L1 base = 10 MB:
  L = log_10(100GB / 10MB) = log_10(10240) ≈ 4
  WAF = 4 × 2 = 8

每 byte user-write → 8 byte 写 disk。

4.2 Size-tiered WAF

WAF ≈ T  (=size ratio, typically 4)

4.3 RAF

RAF = L0 文件数 (4) + (L - 1) levels
Leveled: L=4 → RAF = 7
Bloom filter 命中后 RAF ≈ 1

Bloom filter 让大多数 lookup 不需读 SST 文件 → RAF 逼近 1。


五、Bloom Filter

LSM 每个 SST 文件另附 bloom filter:

  • 插入 key k → 在 m-bit bloom array set hash_1(k), ..., hash_n(k)
  • 查询 k → 计算所有 hash;若全 set 才可能 present;否则 100% absent

参数:

  • m/n = 10 bits/key → FPR ~1%
  • m/n = 7 bits/key → FPR ~5%

100万 key → ~1MB filter 全 cache 可行,命中后跳 disk I/O。

RocksDB bloom_bits_per_key = 10 默认。


六、SSTable 字节布局

RocksDB SST:

+---------------------+
| Data Block          |  ← (key, value) pairs sorted, compressed
+---------------------+
| Meta Block (filters)|
+---------------------+
| Meta Index Block    |
+---------------------+
| Index Block         |  ← B+ tree locate: key range per data block
+---------------------+
| Footer (magic 8B)   |
+---------------------+

Data block 4KB (default), key sorted, prefix compression + Snappy / LZ4 / ZSTD compression。

Block cache LRU 8-64GB + OS page cache 2x backup。


七、KV-separation (WiscKey)

传统 LSM:key + value 都同表,compaction 把大 value 重 copy 大量字节 → WAF 巨大。

WiscKey 思路:分离 key index 与 value store。

  • LSM 只存 (key, value pointer)
  • value 在独立 value-log

→ LSM 极小(~30B per entry vs 100KB value),compaction 不动大 value,WAF 降数倍。

PingCAP TiKV 用 RocksDB,但 MVCC 让 key 大。生产做法:

  • 大 value:external object storage (S3),DB 仅存 metadata
  • 小 value:RocksDB 原生

八、RocksDB 配置实战

rocksdb::Options opts;
opts.create_if_missing = true;
opts.write_buffer_size = 64 * 1024 * 1024;       // 64MB per memtable
opts.max_write_buffer_number = 3;
opts.max_background_flushes = 4;
opts.max_background_compactions = 8;

opts.level0_file_num_compaction_trigger = 4;
opts.level0_slowdown_writes_trigger = 20;
opts.level0_stop_writes_trigger = 36;
opts.target_file_size_base = 64 * 1024 * 1024;   // 64MB SST
opts.max_bytes_for_level_base = 256 * 1024 * 1024;
opts.max_bytes_for_level_multiplier = 10;

rocksdb::BlockBasedTableOptions tb;
tb.block_size = 4 * 1024;
tb.cache_index_and_filter_blocks = true;
tb.pin_l0_filter_and_index_blocks_in_cache = true;
tb.filter_policy.reset(rocksdb::NewBloomFilterPolicy(10, false));
tb.block_cache = rocksdb::NewLRUCache(8LL * 1024 * 1024 * 1024);  // 8GB
opts.table_factory.reset(NewBlockBasedTableFactory(tb));

opts.compression = rocksdb::kLZ4Compression;
opts.compression_per_level = {
    rocksdb::kNoCompression,    // L0
    rocksdb::kLZ4Compression,  // L1
    rocksdb::kLZ4Compression,  // L2
    rocksdb::kZSTDCompression,  // L3+
};

九、产线事故

9.1 RocksDB compaction 跑死

某服务 100GB RocksDB,max_background_compactions=2,L0 file 堆 1000 → 写入 stop (level0_stop_writes_trigger=36)。

修复:8 background compaction thread + SSD。让 L0 compaction 跟上 ingest rate。

9.2 读长尾放大 50ms

Cassandra p99 从 5ms 升到 50ms。

修复:STCS → LeveledCS;bloom bits_fp_per_key 12。

9.3 Compaction 临时 2x disk

某 RocksDB 8 月数据增到 800GB,compaction 临时 footprint 1.6TB > SSD 1TB → 写报错。

修复:Universal Compaction max_size_amplification_percent=25;加 SSD 到 2TB;老数据冷备移 S3。

9.4 HBase compaction storm

HBase region server 多 region compaction 同时跑 → IOPS 占满、CPU 100%。

修复:限制 hbase.regionserver.thread.compaction.small = 1, large = 1 + hbase.hstore.compaction.max = 3

9.5 bloom bits 太小

RocksDB 1 bit/key → 误命中率 30% → RAF 接近 5 → p99 200ms。

修复:bump bits_fp_per_key 到 12,p99 跌回 30ms。


十、易错清单

  1. WAF + RAF 是 tradeoff: size-tiered WAF 小 RAF 大; level 反之
  2. Bloom filter bits < 7 误命中率 > 10% 必踩
  3. 删除不是真删除 —— 必走 tombstone,等 compaction 真清
  4. level0_file_num_compaction_trigger 太大 → L0 堆 → RAF 增大
  5. Compaction thread 数必须匹配 N 路盘 + CPU
  6. WiscKey: 大 value 走 KV-separation 避免 LSM 大
  7. RocksDB compaction_style = kCompactionStyleLevel 不是 LMS 压缩 default

十一、这一章带走的东西

  1. LSM 把随机写转 sequential write,10-50× 比 B+ tree 写吞吐高
  2. WAF vs RAF tradeoff:size-tiered WAF 小 RAF 大;leveled 反之;TWCS 适 event stream
  3. Bloom filter 让 RAF 接近 1(典型 10 bits/key, FPR 1%)
  4. compaction thread 与磁盘、CPU 必须匹配,否则 stop writes
  5. WiscKey KV-separation 解决"大 value 引 LSM compaction 大"
  6. 大 value 应去 external object storage,DB 仅存 metadata pointer
  7. production 监控 RocksDB STAT (rocksdb.ldb.compaction_pending / level0_file_count)

下一节 →

Hash index、GIN、GiST、BRIN — 多次查询需要选择 specialized index 的关键 case

Hash index、GIN、GiST、BRIN、SP-GiST

TL;DR

PostgreSQL 默认 B+ tree 之外提供 5 类 specialized secondary index。各适合不同访问模式:Hash O(1) 等值、GIN 倒排、GiST 空间/范围/不平衡、SP-GiST 非平衡树 (radix/quadtree)、BRIN 块范围摘要 (大表顺序列)。本节看完应该知道何时该选 specialized index 替代 B-tree(或反之),以及 PG 14/15/16 的改进。

类型适合
Hash等值 only
B-Tree范围 + 等值 + 排序(最常用)
GIN倒排 JSONB / 全文 / 数组
GiST空间 / 范围 quadtree / 重边 (SET)
SP-GiST非平衡树 (radix / quadtree)
BRIN块范围摘要 (大表顺序列)

一、Hash Index

O(1) 等值索引:

CREATE INDEX idx_email ON users USING HASH (email);
SELECT * FROM users WHERE email = 'a@b';
SELECT * FROM users WHERE email LIKE 'a%';   -- 不可用

PG 10+ Hash 才支持 WAL → crash-safe。可用 Buket Pattern + Overflow Page: bucket ID = hash(key) mod N。

局限:

  • 仅等值(no range, no sorting)
  • 不能作为 unique constraint (PG 21 内不强制)
  • 比 B-tree 在低基数列优势不大

实战:B-tree 通常已够;用 Hash 100% 仅等值且 key 长度大 (+20 byte) 时考虑。


二、GIN (Generalized Inverted Index)

倒排索引:把"复合列"分解为元素 → 单元素 → posting list (含哪些 row)。

  • 数组 INT[] / TEXT[]
  • JSONB 字段
  • 全文 TSVECTOR
CREATE INDEX idx_tags ON posts USING GIN (tags);
SELECT * FROM posts WHERE tags @> ARRAY['pg'];
SELECT * FROM posts WHERE tags && ARRAY['pg','mysql'];

CREATE INDEX idx_data ON events USING GIN (jsonb_col);
SELECT * FROM events WHERE jsonb_col->>'action' = 'click';

CREATE INDEX idx_fulltext ON articles USING GIN (to_tsvector('english', body));
SELECT * FROM articles WHERE to_tsvector('english', body) @@ to_tsquery('database & index');

2.1 GIN 优势

  • 查询时间 O(#key) 而非 O(rows)
  • 支持 @> <@ && ? @? JSONB 操作
  • 全文搜索不依赖外部 Elasticsearch (中小 workload)

2.2 GIN 缺陷

  • 索引生成慢(每元素加 posting list 操作)
  • 索引 update 慢 → 高频写场景用 fastupdate=on(pending list 暂存)
  • 不支持 UNIQUE constraint
  • 空间开销大 (单位 row 量)

fastupdate = on (gin_pending_list_limit = 4MB):在 pending list 中累积 update → 后台批量刷。gin_pending_list_limit 大收益取决于写压力。

2.3 GIN 险, PG 14+

  • PG 14 引入 JSONB path expression GIN
  • PG 15 引入 multirange column
  • PG 16 改进 sort + bitmap scan 与 GIN 整合

三、GiST (Generalized Search Tree)

允许定义自定义 split 函数 → 可实现不同数据结构:R-tree (空间)、range 类型、 SET (e.g. ltree, seg, pg_trgm).

-- btree_gist extension: GiST ops for common types
CREATE EXTENSION btree_gist;
CREATE INDEX idx_range ON events USING GiST (ts during tsrange);

-- 全文搜索 trigram
CREATE EXTENSION pg_trgm;
CREATE INDEX idx_name_trgm ON users USING GIN (name gin_trgm_ops);  -- OR GIST
-- pg_trgm GIN ok: "name LIKE '%bob%'"

-- 空间
CREATE INDEX idx_geom ON landmarks USING GIN (geom);  -- or GIN

3.1 GiST 用例

  • 时间范围 (tsrange, daterange)
  • 空间矩形/点 <-><#>&&
  • 不等/相对位置 取代 B+ tree 的 = / > 比较

3.2 GiST vs B-tree

维度GiSTB-tree
顺序自定义 partition function单调 key 比较排序
优势范围 overlap / 空间等值 / 范围
性能通常 O(log N) + 维度 overheadO(log N),简单、紧凑

四、SP-GiST (Space-Partitioned GiST)

非平衡树根 radix tree / quadtree / trie / kdtree 的实战设计。每节点 partition 函数可以非二分。

CREATE EXTENSION pg_trgm;  -- uses SP-GiST
CREATE INDEX idx_text_trgm ON articles USING SP-GiST (name gist_trgm_ops);

-- inet / cidr
CREATE INDEX ON bps_logs USING SP-GiST (ip inet_ops);

-- box 2D space
CREATE EXTENSION cube;
CREATE INDEX idx_cube ON records USING SP-GiST (cube field);

实战限用:仅当 key 自带刻度 (radix 前缀、坐标 spatial dim) 时唯一。


五、BRIN (Block Range Index)

每 ~128 个连续 page (1 MB 数据) 存 min/max + null count + count → index size 仅每 (128 × 8KB) ~50 B.

CREATE INDEX ts_brin ON events USING BRIN (timestamp);

5.1 BRIN 适用

  • 时间列与物理顺序高度相关 → BRIN 极有效
  • 大表 (events, logs) 顺序写入,旧数据不修改

5.2 BRIN 缺陷

  • 仅做"block-level"过滤 → 必须扫 candidate blocks
  • 等值查询仍可能扫多 block
  • 不适合 ossified 插入模式 (e.g. UUID random insertion → 物理不按时间)

实战 100GB log table:

B-tree: 200MB index, 比 BRIN 50× 空间
BRIN:    5MB index, 时间 in 顺序文 → 99% block skip

5.3 BRIN pages_per_range

CREATE INDEX ts_brin ON events USING BRIN (timestamp)
    WITH (pages_per_range = 64);  -- 64 × 8KB = 512 KB per range

调小 (16 pages) 摘要精度高; 调大 (512 pages) 索引更小但 block-skip 概率降。


六、Postgres 仅 B+ tree 唯一约束 (15+

PG 15 之前 UNIQUE 约束只能用 B-tree。PG 15+ 让 UNIQUE 可被任何 "amcanunique" index 支持 (GiST with amcanunique 一部 GiST opclass 例子 (e.g. range_ops) 也支持 UNIQUE 重部分)。


七、产线事故

7.1 Hash index 在 PG 9 是 silent crash unsafe

9 以前的版本,Hash index 坏不收→ 应改 B-tree。12+ 之后稳定。

7.2 GIN fastupdate update 异常慢

某业务 1000 行 / 测入 GIN (tags) → 平均慢 50ms。fixed by gin_pending_list_limit = 32MB + autovacuum_vacuum_scale_factor = 0.02 让 autovacuum 自动清 pending list 步调一致。

7.3 BRIN 时间不单调

旧业务 random update events,BRIN 时间 index 大多 blocks 都 min/max 全覆盖 → 全表扫描 hit 率 100%。 discovered bug 用 BRIN (id) 改用 B-tree。

7.4 GiST 范围 bloat

业务 tsrange all in 1 small segments 表 deite phenomen stats; GiST 1000 sub-block partial clustered large 因为 reorder by extend update 多越来越稀 → REINDEX needed every weekend.

7.5 SP-GiST trigram mismatch

业务用 SP-GiST (name gist_trgm_ops) 但 GIN trigram 真正更适 LIKE 全 第% 检…,后者 FTS-ùtrgram 更佳。


八、易错清单

  1. Hash index 不能 UNIQUE constraint
  2. GIN update 慢 — 必带 fastupdate=on
  3. GiST ≠ 必适空间;OS 顺序 also 适 GiST 更慢 范围
  4. BRIN 仅在时间与物理空间 高度相关时有用
  5. SP-GiST 不默认支持 range / 全文 — 需 extension
  6. trigram LIKE 查询 GIN > GiST; trigram stemmer 等 = 倒排索引模式

九、这一章带走的东西

  1. PostgreSQL 6 类索引各有 access pattern 专长; B-tree 是默认且 50/50 各场景
  2. Hash 只适合等值 + 已 长 enough → 95% 业务仍用 B-tree
  3. GIN 倒排 → array / JSONB / 全文 在 DB 内可免除 Elasticsearch
  4. GiST 范围 + 空间 / ltree 多专用; 索引 bloat 修 REINDEX
  5. BRIN 极窄场景(时间物理高度相关)但小到 ~5MB,可表 100GB
  6. SP-GiST 主要用于 radix tree + spatial quadtree (IP-prefix/网络) 仅 stationary cases tup-compoundable
  7. PG 15+ UNIQUE 支持 non-B-tree — 让 ratio trange 做 unique possible

下一节 →

执行计划:EXPLAIN ANALYZE 怎么读 — PostgreSQL / MySQL 计划字段语义、Buffers/Planning Time/loops/time 均值

倒排索引与全文检索: Lucene / BM25 / Elasticsearch

TL;DR

WHERE name = 'foo' 是精确匹配,B+ 树就够;WHERE name LIKE '%foo%'子串匹配,B+ 树直接退化全表扫。全文检索(Google 搜索、ES、Postgres 全文、代码搜索)用的是另一种索引结构:倒排索引——先分词,再为每个词维护"哪些文档包含它"的倒排表(posting list),查询时合并 posting list,用 BM25 打分排序。这一章讲清楚倒排索引的数据结构、Lucene 的磁盘布局与近实时语义、ES 在分布式下的搜索流程,以及"什么时候别用 ES 而用 PG 全文/ClickHouse"的工程判断。

读完应能:

  1. 画出倒排索引的内存/磁盘结构(词典 → postings → 位置/载荷文件),说明为什么它支持子串/词级查询而 B+ 树不支持。
  2. 手写 BM25 公式并能解释 IDF、饱和度、文档长度归一化三个直觉。
  3. 讲清 Lucene 的 segment / immutable / merge / 墓碑删除机制,以及"删了的文档为什么还占磁盘"。
  4. 说清 ES 一次搜索请求怎么走:routing → 分片 scatter/gather → 打分聚合 → DFS 结果合并。
  5. 根据"数据量 / 实时性 / 查询模式 / 运维成本"给出 PG 全文 vs ES vs ClickHouse 的选型判断。

一、为什么 B+ 树做不了全文检索

name LIKE '%foo%' 在 B+ 树上的问题是:前缀未知,无法定位到树中的某个起始 key,只能从树的最小键扫到最大键——复杂度 O(N)。方向对的技术是把"内容"翻过来建索引

正向:  文档1 = "apple banana"       文档2 = "banana cherry"
倒排:  apple  → [doc1]
       banana → [doc1, doc2]
       cherry → [doc2]

查询 banana AND cherry = 取两个 posting list 求交集;查询 apple OR cherry = 求并集。复杂度与"命中的文档数"成正比,而不是与"总文档数"成正比。

note

倒排索引本质是多值属性上的二级索引:把"文档"这个实体的一个重复字段(词)拆成 N 行建索引。这和关系库里给数组字段建 GIN 索引是同一件事(见 specialized.md 的 GIN 一节)。

二、三段式结构

2.1 分词与 analyzer

建索引前先归一化文本:

  • tokenizer:按 Unicode 属性切词(空白、标点、CJK 按词典/ngram 切);
  • filter:小写化、去停用词、词干化(stemming)、同义词扩展;
  • 中文的坑:英文空格分词即可;中文要 IK / jieba 词典分词或 ngram 切分,否则"中华人民共和国"搜"中华"匹配不上。

分词结果决定召回率。分析器不匹配是线上"搜不到"的第一大原因:建索引用的 analyzer 与查询用的 analyzer 必须一致(或查询侧对每个词做同样的归一化)。

2.2 词典(dictionary / term index)

所有去重后的词,排好序存起来,支持 O(log n) 查找 + 前缀扫描foo* 查询依赖)。Lucene 的做法:

  • .tim(term index):内存中的 FST(有限状态转换器),把词条前缀压缩成共享 DAG,几百万词条只占几百 KB 内存;
  • .tip:FST 的根块指针;
  • .tmd 旧版/.tim:词条 + 指向 postings 文件块的偏移。

为什么用 FST 而不是哈希表:哈希只能精确命中,FST 天然支持 前缀 → 全部词条 的枚举,且对字典序相邻的词条共享压缩——内存换查询能力

2.3 倒排表(posting list)

对每个词存:

term "banana":
  [docID delta 编码] 1, 1, 3, 2 ...   ← 差值 + varint/PForDelta 压缩
  [频率]             3, 1, 2 ...
  [位置]             12, 45, 78 ...   ← 短语查询 / 高亮才需要
  [payload]          偏移、权重

压缩是关键:posting list 可能上亿条,Lucene 用 Frame Of Reference(按 128 个 docID 一块,块内差值 + bit-packing),从"每条 4 字节"压到平均 1-2 字节。

三、查询执行: 合并与打分

3.1 合并策略

  • AND:两个有序 posting list 做归并/跳表(posting 按 docID 升序,块内有最大 docID,可跳跃);
  • OR:归并取并;为了排序效率,用块级 skip 先粗筛高频词
  • 短语 "a b":先 AND 取交集,再比较位置差(需要 pos 文件)。

3.2 BM25 打分

BM25 是 Lucene 默认(取代旧 TF-IDF)的相关性公式:

$$\text{score}(q, d) = \sum_{t \in q} \text{IDF}(t) \cdot \frac{f_{t,d} (k_1 + 1)}{f_{t,d} + k_1 (1 - b + b \cdot \frac{|d|}{\text{avgdl}})}$$

$$\text{IDF}(t) = \ln\left(1 + \frac{N - n_t + 0.5}{n_t + 0.5}\right)$$

三个直觉:

直觉工程含义
TF 项(分子分子饱和)一个词出现 10 次 ≠ 相关性是 1 次的 10 倍k1 ≈ 1.2,高频词收益递减
文档长度归一化200 词文档里出现 1 次,比 2000 词文档里出现 1 次更相关b ≈ 0.75 惩罚长文档
IDF越稀有的词区分度越高停用词 IDF≈0,天然压权

warning

工程里 BM25 调参最常犯的错:拿搜索词频最高的业务词(如"免费")当热门词压权,结果精准查询被噪声淹没。正确做法是先看查询日志的分布,再决定 b/k1,或加业务 boost 字段。

四、Lucene 的磁盘与近实时

4.1 segment: 不可变 + 后台合并

索引由多个 segment(段)组成,每个段是完整的小索引:

写入 → 攒内存 buffer → flush 成 segment 落盘 (只读)
后台 → merge 小段成大段 (合并同时把删除真正清除)
删除 → 只写墓碑 (tombstone), 不物理删

由此推出的三条铁律:

  1. 写放大与 IO 放大:segment 越多查询越慢(每段都要查),所以有 merge 策略(tiered merge)在段大小/数量间权衡;
  2. 删除不立即释放磁盘:只有 merge 才物理清除——"删了 1TB 数据磁盘没变小"是 ES 最常见投诉;
  3. refresh vs commitrefresh(默认 1s)把 buffer 变成可搜索的 segment(近实时);commit 才 fsync 落盘(持久化)。机器宕机丢的是两次 commit 之间的数据——所以 ES 用 translog 补。

4.2 与 B+ 树的对照

B+ 树 (InnoDB)倒排索引 (Lucene)
可变性就地更新,原地读最新不可变 segment,删除靠墓碑
写入随机写 + 页分裂顺序追加,天然友好 SSD
精确点查/范围词级/短语/前缀,BM25 排序
压缩页级FOR/字典压缩,内存友好
一致性WAL + 页日志translog + segment commit

这解释了为什么"搜索引擎写优化"和"OLTP 读优化"选择相反的结构:前者追求顺序写 + 大批量 merge,后者追求原地小改动 + 单行点查

五、Elasticsearch 分布式搜索

  1. 路由_id 哈希 → 主分片(shard = hash(_id) % num_shards),副本参与读负载;
  2. scatter/gather:查询请求发给所有相关分片;每个分片本地算 top-K(shard 级截断);
  3. 协调节点合并:把各分片 top-K 汇总再精排。

warning

ES 深分页(from + size)要取 from+size 个结果再丢弃——from=100000 会让协调节点汇总 100000×N 条,几乎必 OOM。方案:search_after(游标)、scroll(快照)、或索引按时间分片只搜必要分片。

5.1 相关性问题

分片级 top-K 的近似性:每个分片只看自己的 top-K,若某词在某分片密集出现,可能漏掉全局真正的 top-10。解决:只读少分片(按路由把同类文档放同分片)、或用 DFS query-then-fetch 先全局统计 IDF(代价高,默认关)。

六、工程选型

场景选择理由
交易库内小规模全文/ILIKEPG tsvector / pg_trgm与事务同库,无数据同步延迟
日志 / 可观测(高写入、偏 append)ES / OpenSearch / Loki写放大可接受,查询灵活
大规模离线分析 + 偶尔全文ClickHouse(带 bloom/倒排)列存扫描 + 压缩,吞吐优先
代码/大规模文档精确词检索ES + ngram + highlight位置信息支持高亮与短语
业务属性过滤 + 少量关键词PG JSONB GIN / ES 混合避免 ES 运维成本

通用铁律:全文检索的实时性、一致性、容量三者只能选两个——refresh_interval=1s 的近实时 + 异步刷盘 = 可能有秒级丢失;要强一致要么引外部队列(如 Kafka → ES),要么接受近似。

七、一页速查

索引结构:  词典(FST) → 倒排表(差值压缩) → 位置/载荷
查询:      AND=交集 / OR=并集 / 短语=位置差 / 前缀=FST 扫描
打分:      BM25 = IDF × 饱和TF × 长度归一化
磁盘:      segment 不可变 + 墓碑删除 + 后台 merge
实时性:    refresh(可搜,1s) vs commit(fsync) vs translog(不丢)
分布式:    路由哈希 + scatter/gather + 协调节点合并
选型:      交易内=PG / 日志观测=ES / 离线分析=ClickHouse

下一篇: 执行计划: explain analyze 怎么读

执行计划:explain analyze 怎么读

TL;DR

EXPLAIN 显示 PostgreSQL/MySQL planner 算出来的计划;EXPLAIN ANALYZE 真执行 + 实测;EXPLAIN ANALYZE BUFFERS 显示 IO 与 cache hit。学会读这三件套是数据库调优的关键。本节带你读完 PostgreSQL 与 MySQL EXPLAIN 的所有字段、planner cost 估计模型、loops/time aggregate 含义、Hash Join vs Nested Loop vs Merge Join 触发条件、覆盖索引判断 (Heap Fetches)、并行执行 worker 路径——你能直接诊断生产慢查询的根因。


一、PostgreSQL EXPLAIN 字段基础

EXPLAIN ANALYZE SELECT name, age FROM users WHERE age BETWEEN 18 AND 25;

Seq Scan on users (cost=0.00..95.50 rows=1700 width=12) (actual time=0.025..2.830 rows=1695 loops=1)
  Filter: ((age > 18) AND (age < 25))
  Rows Removed by Filter: 8305
Planning Time: 0.150 ms
Execution Time: 3.180 ms

字段含义:

字段含义
Seq Scan全表扫描的物理算子
cost=0.00..95.50planner 估计 (startup..total);95.50 是 random_page_cost × N 的代价单位
rows=1700估计行数
width=12平均行宽 (字节)
actual time=0.025..2.830实测启动..总耗时 (ms)
loops=1该节点被调用 1 次
Filter触发 WHERE 不过滤的谓词
Rows Removed by Filterseq scan 中被 filter 过滤掉的行
Planning Timeplanner 用的时间
Execution Time总执行时间

Buffers 选项看 I/O:

EXPLAIN (ANALYZE, BUFFERS) SELECT name FROM users WHERE age BETWEEN 18 AND 25;
Seq Scan on users (cost=0.00..95.50 rows=1700 width=12)
                (actual time=0.025..2.830 rows=1695 loops=1)
  Filter: (age BETWEEN 18 AND 25)
  Rows Removed by Filter: 8305
  Buffers: shared hit=84 read=2 dirtied=0
  • hit=N:buffer pool cache 命中
  • read=N:cache miss 落 disk
  • dirtied=N:本 query 修改页面
  • written=N:本 query 触发 flush

shared / local / temp:临时表/磁盘 temp 与 ring buffer 分别。


二、PostgreSQL cost model

postgresql.conf 中的关键常量:

seq_page_cost = 1                # 默认;顺序读一页 cost 1
random_page_cost = 4             # 随机读一页 cost 4
                                # SSD 改到 1.1 或 1.05
cpu_tuple_cost = 0.01            # 处理一行 cost
cpu_index_tuple_cost = 0.005     # 索引里取一条 cost
cpu_operator_cost = 0.0025       # 单 op 比较 cost
parallel_setup_cost = 1000       # 起 worker pool 固定 cost
parallel_tuple_cost = 0.1        # worker 取一行 cost
default_statistics_target = 100 # column 直方图 buckets
effective_cache_size = 4GB       # planner 假设的 cache 大小(不分配)

公式:

seq scan cost = pages × seq_page_cost + rows × cpu_tuple_cost
index scan cost = pages_hit_estimate × random_page_cost + rows × cpu_index_tuple_cost
bitmap scan cost = index pages × random_page_cost × 0.5 + heap pages × random_page_cost × 0.5 + recheck cost

调 random_page_cost = 1.1 (SSD) → planner 更偏向 index scan。调小 effective_cache_size → planner 更偏向 seq scan(因估计 cache miss 多)。


三、EXPLAIN 上常见算子字典

算子含义常见上下问
Seq Scan顺序扫整表行数大 / 无索引
Index Scan走索引 + 回表索引命中少量结果
Index Only Scan走索引不回表覆盖索引 ok
Bitmap Index Scan + Bitmap Heap Scanbitmap 标页批量读索引命中多页
Index Scan Backward反向扫索引ORDER BY DESC
Nested Loop笛卡积+过滤内表有索引,N1×inner_cost 不太大
Hash Joinhash 内表 + 扫外表两组规模大、内存能 fit
Merge Join排序后双指针合并索引已排序,或 commit large sort
Sort显式排序ORDER BY 无索引
HashAggregatehash bucket 聚合GROUP BY 算法
GroupAggregate排序后顺序聚GROUP BY 已 sort
Limit取 N 行LIMIT 子句
WindowAgg窗口函数执行OVER PARTITION BY
Aggregate总聚合count/sum/avg
Subquery Scan子查询包装派生表
Append表 + UNIONUNION ALL 或分区表
Merge Append排序归并partitioned + ORDER BY
Gather / Gather Merge并行执行编排parallel worker
Materialize物化中间重复 scan 时
CteScan老 PG 版 CTE materializePG 11 的 CTE
ProjectSetset returning functionunnest / regexp_match global
Unique去重DISTINCT
SetOpINTERSECT / EXCEPT集合运算

四、loops、time、rows aggregation

每个 plan node actual time / rows 都是 per loop

EXPLAIN ANALYZE
SELECT a.id, count(b.id)
FROM a JOIN b ON b.aid = a.id
GROUP BY a.id;

Hash Join (...)
   Hash Cond: (a.id = b.aid)
   -> Seq Scan on a (actual time=0.01..5.00 rows=10000 loops=1)
   -> Hash (...)
        Buckets: 16384  Batches: 1  Memory Usage: 512kB
        -> Seq Scan on b (actual time=0.02..12.18 rows=50000 loops=1)

如果节点 loops=10

   Nested Loop Left Join (actual time=0.1..15.0 rows=20000 loops=10)
   → 实际总行数 = 20000 × 10 = 200000
   实际总时间 = 15.0 × 10 = 150 ms

子节点 inner of Nested Loop 是 loops=N1(外每行扫一次):

Nested Loop
  -> Seq Scan outer (loops=1)
  -> Index Scan inner (loops=N1_outer_rows)

inner loops 真实数据 → 看 child 时间 × child loops = 总耗时。


五、Hash Join vs Nested Loop vs Merge Join

5.1 Nested Loop

for r1 in outer:
    for r2 in inner where match:
        emit (r1, r2)

cost = N_outer × N_inner 平均 lookup cost。

适合:

  • outer 小 (e.g. 100 行)
  • inner 有 index 一应命中 (< 10 行)

противопоказания:

  • 双表都大 (10k+) → X OK 太慢
  • 没 inner index → 内表每次全 scan

5.2 Hash Join

# phase 1: build hash table on inner (smaller)
for r2 in inner:
    htab[hash(r2.joinkey)].append(r2)

# phase 2: probe
for r1 in outer:
    bucket = htab[hash(r1.joinkey)]
    for r2 in bucket:
        if r1.joinkey == r2.joinkey: emit

适合:

  • 双表大 (10k+)
  • join key 不排序
  • 内存能含 hash bucket

work_mem 限制:太大 batch 到磁盘 → 多次 pass → cost 爆炸

SET work_mem = '64MB';

5.3 Merge Join

# 双方已按 joinkey 排序
for r1 in outer, r2 in inner:
    if r1.key < r2.key: advance outer
    elif r1.key > r2.key: advance inner
    else: emit (r1, r2)

适合:

  • 双方都有索引(自然 sort),避免显式 sort
  • 实际大表大、merge 内不需 hash mem
  • ORDER BY + JOIN

六、覆盖索引判断

EXPLAIN (ANALYZE, BUFFERS) SELECT id, name FROM users WHERE name LIKE 'A%';
   Index Scan using idx_name on users (...)
       Index Cond: (name ~~ 'A%'::text)
       Heap Fetches: 0    ← 0 = index-only successful
       Buffers: shared hit=4 read=0

Heap Fetches = 0 → index-only scan 成功,全 columns 都在索引中。 Heap Fetches = N → 索引不覆盖,回表 N 次。

注意点:PG index-only scan 还要求 visibility map 显示 page 全部 commit。autovacuum 后 page VM 才得 set → 又一理由 vacuum 要勤跑。


七、并行执行 (parallel query)

PG 9.6+ 并行 query:

EXPLAIN ANALYZE SELECT count(*) FROM big_table;

Finalize Aggregate (...)
  -> Gather (...)
       Workers Planned: 4
       Workers Launched: 4
       -> Partial Aggregate (...)
             -> Parallel Seq Scan on big_table (...)

并行 plan node:

  • Gather:拉 worker → leader 数据合并
  • Gather Merge:worker 已 sort,merge sorted stream
  • Partial Aggregate:每 worker 算部分 sum/count
  • Parallel Seq Scan:worker 间分 page

best practices:

  • max_parallel_workers_per_gather = 4(默认 2 太保守)
  • max_parallel_workers = 8(CPU 核)
  • parallel_setup_cost 决定 planner 是否选 parallel
  • 数据太少 (< 10k 行) 不走并行

八、MySQL EXPLAIN

EXPLAIN FORMAT=JSON SELECT ...;

关键字段:

  • type:access type (system, const, eq_ref, ref, range, index, ALL)
  • key:实际使用索引名
  • key_len:使用的索引长度(字节)
  • rows:估计行数
  • filtered:%WHERE 过滤后剩余比例
  • ExtraUsing index (覆盖)、Using filesort (排序)、Using temporary (临时表)
  • possible_keys:planner 可考虑的索引

EXPLAIN ANALYZE (MySQL 8.0.18+) 类似 PG 实测。

EXPLAIN ANALYZE SELECT * FROM orders WHERE user_id = 5;
-> Index lookup on orders using idx_user_id (user_id=5)
    (cost=12 rows=100) (actual time=0.05..0.5 rows=100 loops=1)

九、产线事故

9.1 ESTIMATE 与 actual 差太大

某 query rows=1 估但 actual 1M 行 → hash join chosen,跑爆 work_mem。

修复VACUUM ANALYZE big_table 刷新统计;或加 ANALYZE (col) 显式 raise default_statistics_target。

9.2 三表 join planner 选错算法

默认 GEQO geqo_threshold = 12 表。13 表 join 走遗传算法,结果 plan 极坏。

修复:调高 geqo_threshold=20;或 SQL 用 SET geqo=off

9.3 parallel query 不触发

大表 seq scan 5 秒耗 leadership 估 cost 小于 parallel_setup_cost。

  • parallel_setup_cost = 100 → 太高
  • parallel_setup_cost = 50parallel_tuple_cost = 0.05 → 并行触发

9.4 Index Scan 比 Seq Scan 慢

random_page_cost 默认 4 → planner 偏 seq scan。SSD 应改 1.1。 indexes 才会被选。

9.5 Work_mem 不够 Hash Join 磁盘 batch

大表 hash join 6 batches → 6 次 disk read 100ms。work_mem=64MB 单次 batch 完成 → 50ms。


十、易错清单

  1. cost 단位是 dimensionless 不unit,不能直接说"cost X = 毫秒";仅可记 startup vs total 比例
  2. time 是 per loop,必须乘以 loops 比真相
  3. EXPLAIN 不 ANALYZE 仅估计;不能完全反映真实执行(如 cache)
  4. EXPLAIN ANALYZE 真执行 → DML/locks 都生效
  5. PG random_page_cost 4 仅适合机械盘;SSD/NVMe 调到 1.1
  6. effective_cache_size 必须与机器 cache 大小匹配;调小规划器更偏 seq scan
  7. Block nested loop vs hashed nested loop:MySQL 8 之前主要 blocking nested join;MySQL 8 hash join
  8. EXPLAIN ANALYZE 跑长时间的 query 慢时长;不要跑 AV业 SLA 核心 query。

十一、这一章带走的东西

  1. EXPLAIN ANALYZE BUFFERS 三层看全:算法选择 + 估行行数 + cache hit
  2. planner cost model 是 seq_page_cost / random_page_cost / cpu_* × pages + rows
  3. loops × time = 实际总时间;inner loops = outer 行数
  4. Nested Loop vs Hash Join vs Merge Join:outer 小 + index → NL;双大 + work_mem fit → HASH;表已 sort or idx ordered → MERGE
  5. 覆盖索引 Index Only Scan 仍依赖 visibility map (vacuum)
  6. SSD 必调 random_page_cost = 1.1、effective_cache_size = 物理内存 75%
  7. parallel query 大表 4 workers 默认、parallel_setup_cost 太高 → 调小 触发

下一节 →

日志与崩溃恢复 — ARIES、fuzzy checkpoint、CLR、Point-in-time 恢复、PITR

日志与崩溃恢复

这一节

WAL 协议、ARIES

TL;DR

详细 WAL 原理参考 relational/wal-2pl.md。本节重点恢复流程的执行时序、CLR 链表、checkpoint 配置、长事务对恢复时间影响。生产 PG/MySQL 调优实战:checkpoint_timeout / max_wal_size / innodb_max_dirty_pages_pct、崩溃后 30 分钟起不来根因分析。


一、WAL 协议核心三条

  1. commit 前:所有 redo record 必须 fsync 到 stable storage
  2. 脏页 flush 前:该页对应的最新 WAL LSN 必须先持久化(WAL before data)
  3. commit record 写入+fsync OK → 事务视为已提交
DB begin
T1 insert R → WAL append "UPDATE R new value"
                WAL buffer (in memory)
T1 commit:
   → WAL append "COMMIT T1"
   → fsync(WAL)     ← 这里 block 直 to platter
   → return ok to client
T1 picked as durable
later:
   buffer flushes modified page R to disk (不必 fsync,WAL 已保证)

fsync(WAL) 是 commit 慢的根因。


二、恢复三阶段 (ARIES)

2.1 Analysis

从 last_checkpoint LSN 开始扫描 WAL:

  • 建 dirty page table:每个脏页的最早修改 LSN (rec_lsn)
  • 建 active txns 表:begin/update/abort/commit 读出未完成事务
  • 找到所有未 commit / abort 的事务
analysis(state):
    cp = read_checkpoint()
    redo_start = MAX_REDO_LSN
    rec_lsn_buf = {}
    for rec in scan_wal_from(cp.redo_lsn):
        match rec {
            UpdateRec(lsn, txn, page_id) => {
                if rec_lsn_buf.find(page_id) == None:
                    rec_lsn_buf[page_id] = lsn
                state.active_txns[txn] = lsn
            }
            CommitRec(lsn, txn) => state.active_txns -= txn
            AbortRec(lsn, txn)  => state.active_txns -= txn
            CLRec(lsn, txn, undo_next) => state.active_txns[txn] = lsn
        }
    state.dirty_pages = rec_lsn_buf

2.2 Redo

从 dirty_pages 中最小 rec_lsn 开始回放所有 redo 记录:

redo(state):
    redo_start = state.dirty_pages.min_rec_lsn()

    for rec in scan_wal_from(redo_start):
        if rec is UpdateRec(page_id, after_image):
            page = load_from_disk(page_id)
            if page.LSN < rec.LSN:                  ← idempotent 检查
                apply(page, after_image)
                page.LSN = rec.LSN
                write_back_to_buffer(page)

幂等性靠 page.LSN < rec.LSN 检查:page 已经被后续 LSN 改过则跳过;只 apply 还未应用的 redo。

2.3 Undo

从最新 LSN 倒着扫 active_txns 列表:

undo(state):
    active = sort_by_last_lsn_desc(state.active_txns)
    for txn in active:
        lsn = txn.last_lsn
        while lsn != None:
            rec = read_rec_from_wal(lsn)
            apply_undo(rec, get_old_image(rec))
            write_CLR_to_wal(rec, undo_next_lsn=rec.prev_in_txn)
            lsn = rec.prev_in_txn
        write_final_abort_record(txn)

CLR 写到 WAL 防再次 crash 导致重复 undo。


三、CLR 详细

Normal Record: UPDATE T row R new value = N
CLR:           Undo T row R old_value = O; undo_next_lsn = PREV_LSN

CLR 是 REDO-only——重放时只 redo 该 undo 操作(即把 page 修改回 O),不需要 undo CLR 本身。

Undo pass 真正工作:
1. WAL record LSN=100: T1 R=N (new)
2. undo → 写 CLR LSN=105: T1 undo R=O; undo_next_lsn=99 (上一个 T1 的事)
3. ... 持续 undo T1
4. final abort record LSN=110

如果 redo 阶段读取 page.LSN=105(CLR 被改),就跳过原 redo LSN=100——幂等性。


四、Checkpoint 配置实战

4.1 PostgreSQL

checkpoint_timeout = 5min              # 默认 5 min;production 5-10 min
max_wal_size = 1GB                     # 默认 1GB;大业务 4-20GB
min_wal_size = 80MB                    # 保留 wal
checkpoint_completion_target = 0.9     # 100% checkpoint 时间内平稳写
checkpoint_flush_after = 256kB         # 强制 OS page cache 清出
wal_compression = on                   # WAL gzip/lz4
wal_buffers = 16MB
max_wal_senders = 10                   # 物理复制

checkpoint 频率 trade-off:

  • 太频:bgwriter 持续占 I/O
  • 太稀:崩溃后 replay 100GB WAL → 几小时宕机

4.2 MySQL InnoDB

innodb_flush_log_at_trx_commit = 1                  # 每次 commit fsync
innodb_flush_method = O_DIRECT                       # 避 OS write cache 双写
innodb_max_dirty_pages_pct = 90                      # 90% after 进 checkpoint
innodb_max_dirty_pages_pct_lwm = 10                  # lower water mark
innodb_adaptive_flushing = ON                        # 自适应 flushing
innodb_adaptive_flushing_lwm = 10
innodb_checkpoint_max_age = 28800                    # max WAL age (秒 30K)

InnoDB 的 redo log 大小由 innodb_log_file_size × innodb_log_files_in_group 决定;MySQL 8.0.30 起用单 variable innodb_redo_log_capacity


五、长事务影响 undo 时间

PostgreSQL 不做真 undo(旧版本永远在 heap),但 commit marker 改 aborted:

  • 速度快
  • 数据 waste——直到 vacuum 才清

MySQL InnoDB 真 undo 翻全 active txns 倒扫 log:

  • 长事务 → undo chain 长
  • 阻 purge thread 持续
  • 启动后 undo replay 30-300 秒

监控:

SELECT @@innodb_buffer_pool_pages_dirty / @@innodb_buffer_pool_pages_total;
-- 应 < 30%

六、恢复性能优化方向

恢复时间与 checkpoint 后 WAL 体积线性:

recovery_time ≈ (WAL_size_after_checkpoint / WAL_apply_throughput)
              + (active_txns_undo_time)

优化方向:

  1. 缩短 checkpoint 间隔checkpoint_timeout=5min (PG)、innodb_max_dirty_pages_pct=75 (MySQL)
  2. 减少长事务:避免 idle_in_transaction(idle_in_transaction_session_timeout=60s
  3. 并行 redo apply:MySQL 8.0 functional redo、Oracle parallel recovery
  4. PG incremental checkpoint(11+):周期刷页 base on LSN
  5. Standby replay 多线程(PG logical replication worker)

七、产线事故

7.1 40 GB WAL → 20 分钟启动恢复

某 PG 业务 8 GB max_wal_size + 30 分钟 checkpoint_timeout → crash 时 30 GB WAL pending → 启动恢复 25 分钟。

修复max_wal_size=2GBcheckpoint_timeout=5min、启 checkpoint_completion_target=0.9 让检查点平滑。

7.2 2 PC prepared 事务 led restart 30 分钟

PG max_prepared_transactions=100 业务忘 commit → restart 时所有 prepared 需要„rollback permit",hold X lock 阻塞启动 → fail-over 二次。

修复:autovacuum 监控 prepared age;precondition 后 ROLLBACK PREPARED 'txn_id'

7.3 InnoDB undo 长

长事务 (10 min SELECT) → undo tablespace 增长到 80 GB。重启 undo replay 时长 25 分钟。

修复innodb_max_purge_lag = 65536,超 lag 阻新 INSERT;读副本或 read replica 统计查询。

7.4 FPI massive on first dirty page

某 PG 业务 dirty page 一遍没刷,更新第二次 FPI 写一份 →那时的 WAL 是双倍。

修复full_page_writes = off (ZFS / Btrfs fsync 的人可用);或启 wal_compression = on

7.5 MySQL log file swap on restart

8 GB log file,commit 等慢 50ms。

修复:log 简化,innodb_log_file_size = 1GB (8 文件),innodb_buffer_pool_size = RAM × 0.6


八、易错清单

  1. fsync() != real durability:硬件 PLP 才有保证
  2. checkpoint 太稀导致恢复长:默认 5 分钟不够,30 分钟太夸张
  3. PG full_page_writes=off 节省 WAL 但要求 FS 真做 fsync(zfs/btrfs ext4 data=journal 安全)
  4. MySQL 5.7 log file change 必须 slow shutdown;MySQL 8 dynamic
  5. 2PC prepared 长 hold lock:业务必监控 prepared age
  6. PG logical replication 走 logical WAL,没参与 crash recovery
  7. recovery 完成后才进入 archive mode:startup 阶段不接受查询

九、这一章带走的东西

  1. WAL 原则:先写日志再写数据,commit = fsync WAL
  2. ARIES = Analysis → Redo → Undo 三阶段,幂等性靠 page.LSN < rec.LSN 与 CLR
  3. CLR linked list 通过 undo_next_lsn 指之前要 undo 的 record
  4. PG 不真 undo,commit marker 改 aborted 即可
  5. checkpoint 频率 = I/O 平面 vs recovery 时间 tradeoff
  6. 长事务 → undo chain 长 → 启动后恢复慢
  7. 监控 n_prepared_xactsinnodb_buffer_pool_pages_dirtypg_stat_progress_vacuum

下一节 →

Checkpoint、Point-in-time 恢复 — physical standby / logical replication / base backup / WAL archive / pgBackRest / 恢复到任意时刻

Checkpoint 与 Point-in-Time 恢复 (PITR)

TL;DR

Checkpoint 是数据库 crash recovery 的 "beacon"——把 buffer pool dirty pages 与 WAL active txns 同步 snapshot 到 disk + 控制 WAL replay window。PITR (Point-in-Time Recovery) = base backup + WAL archive → reconstruct 任意时刻数据库状态。本节覆盖 PG pg_basebackup + pgBackRest、MySQL xtrabackup、Oracle RMAN、跨云 DR 实战 archive 策略 + WAL GPO 限。


一、Checkpoint 流程

1. CHECKPOINT_BEGIN record → WAL
2. 收集 dirty_pages snapshot (fuzzy, 不锁 buffer pool)
3. 启 bgwriter + checkpointer workers flush dirty pages 到 disk
   其中关键:**每个 dirty page flush 前 WAL 必先 fsync 到 LSN >= page.LSN**
4. CHECKPOINT_END record → WAL + fsync
5. 更新 control file: last_checkpoint_lsn

PostgreSQL checkpointer 是独立进程(自 9.2),不阻塞前台 SQL。

恢复时:

  • 启动 read control file → 拿 last_checkpoint_lsn
  • WAL scan from that LSN → analysis → redo → undo
  • recovery time ≈ checkpoint 之后的 WAL 体积

二、Fuzzy Checkpoint 优化

fuzzy checkpoint = "begin/end" 标记之间 dirty pages 与 active txns 仍可变。精细化 checkpoint:

  • 不锁 buffer pool → 前台 throughput stable
  • end_record 只 snapshot fuzzy state

PG 12+ checkpoint_completion_target = 0.9

  • 维持 elapsed_time_per_checkpoint = 0.9 × checkpoint_timeout 让检查点不最后一刻 hardcoded
  • bgwriter 平稳 flush 而非最后 IOPS 飙

三、Point-in-Time Recovery (PITR)

3.1 全流程

T0: base backup 完成 (热备份, 物理 file copy + WAL rec offset)
T1: 数据正常写入持续 → WAL archive 到 S3/GCS 
T2: 人为删除表: DROP TABLE important_users; (营运误删)
T3: 第二天发现,想恢复到 T2-1s

操作:
1. 新建一个恢复 instance (不覆盖原 instance)
2. reload base backup at T0
3. setup restore_command (从 S3 拉 WAL)
4. recovery_target_time = T2 - 1s
5. 启动 → replay WAL from T0_lsn until T2-1s
6. promote 完成

3.2 PG pg_basebackup + archive_command

# Primary postgresql.conf
archive_mode = on
archive_command = 'wal-g wal-push %p'   # 或 pgBackRest, 自 push S3
archive_timeout = 60s                     # 没 INSERT 时强制 checkpoint 切 WAL file
wal_level = replica                       # or logical

# Restore side
restore_command = 'wal-g wal-fetch "%f" "%p"'
recovery_target_time = '2024-10-23 14:59:59'
recovery_target_action = 'promote'       # or pause, shutdown

启动后 1920+ 秒后空闲:

  1. 先重放 base backup (冷数据)
  2. 然后 fetching WAL from S3 顺序 replay 直到 recovery_target_time
  3. promote:切断与原 primary 联系,本身 promote up

3.3 MySQL xtrabackup + binlog

# primary
xtrabackup --backup --target-dir=/var/backup/full-001

# restore
xtrabackup --prepare --target-dir=/var/backup/full-001
xtrabackup --copy-back --target-dir=/var/backup/full-001

# binlog replay until time T2:
mysqlbinlog --stop-datetime='2024-10-23 14:59:59' \
    /var/lib/mysql/binlog.001012 \
    /var/lib/mysql/binlog.001013 \
    | mysql -u root

MySQL 用 binlog 而非 redo log 做 PITR;binlog 写 commit,但更靠 logical。

3.4 Oracle RMAN

RMAN> RESTORE DATABASE UNTIL TIME "TO_DATE('2024-10-23 14:59:59','YYYY-MM-DD HH24:MI:SS')";
RMAN> RECOVER DATABASE UNTIL TIME "TO_DATE('2024-10-23 14:59:59','YYYY-MM-DD HH24:MI:SS')";

Oracle RMAN 提供 block-level incremental + image copy 模式。


四、WAL archive 策略

4.1 PG archive_command 模式

archive_command = 'test ! -f /mnt/s3-wal/%f && cp %p /mnt/s3-wal/%f'
archive_timeout = 60s        # 强制切 WAL
archive_cleanup_delay = 5min # primary vs replica 配 lag taste

要点:

  • archive_command 成功返回 0;否则 PG 持续重试阻塞
  • WAL file 名是 16MB 标准文件,按 hex 命名
  • 太长 archive timeout 让 idle 段后没新 WAL 紧急 PITR 失败

4.2 pgBackRest / WAL-G

两个第三方工具都做:

  • 异步 push WAL 至 S3/GCS/Azure
  • full + incremental base backup
  • compression + encryption
  • restore_packet scaling support
pgbackrest --stanza=main --type=incr --archive-only backup

4.3 GPO 限与恢复性能

WAL 策略 mock 容量:

  • daily WAL:wal_keep_size=1GB 让复制延迟容错时间
  • archive 远端:60s 切 file,每 min 新 WAL pushed

PITR 时 24h replay 1GB WAL → typical 10 分钟。可并行:

  • PG 14+ recovery_prefetch:standby 启 retry 后并行 prefetch WAL

五、Incremental Base Backup

传统 PG full backup 周末跑 10TB → 几小时。新版:

  • PG 17+ pg_basebackup --incremental (RMAN 风格): 使用 page_lsn 对比,仅变 page 入 backup
  • 也用 pgBackRest incremental 同方案

MySQL InnoDB xtrabackup 也支持 incremental by page LSN:

xtrabackup --backup --target-dir=/backup/base
xtrabackup --backup --target-dir=/backup/inc1 --incremental-basedir=/backup/base --incremental-lsn=LSN_FROM_BASE

六、产线事故

6.1 archive_command 失败 8h → PG 卡死

业务 S3 限速超 → cp 命令一直 timeout → archive_command 失败 8h 后 PG stop:

STATE: waiting for WAL segment to be archived

修复

  • 让 archive_command aws s3 cp--cli-read-timeout 30 --cli-connect-timeout 5 限制
  • 监控 pg_stat_archiver.failed_count,3 次失败就告警
  • 紧急 pg_switch_wal() force 切新 WAL;emergency mode archive_mode = off + archive_command = /bin/true 让 PG 推进

6.2 重 WAL 写导致基线备份过慢

10 TB 数据 → pg_basebackup 5h → 业务 backup SLA 1h。

修复:启用 pg_basebackup --incremental (PG 17+) 或 pgBackRest cumul 备份。

6.3 误删表后 PITR 失败

T0 = yesterday base backup
T2 = 误 DROP TABLE important_users
T2 - 10s is planned recovery target

archive_command 在 T2 时 archive 失败(S3 上传中盘空间不足),导致 T2 前 WAL 不全,PITR 只能到 T1 → important_users 内容早就 OK 但其他表更新丢。

修复

  • archive 上传 success 验证 + retry 上传
  • 加监控 archive success rate

6.4 binlog row event 错误

MySQL 5.7 → 8.0 升级 binlog row format 兼容性,部分 statement-unsafe statement(UUID())导致 PITR replay 后数据偏差。

修复:强制 binlog_format=ROW (但兼容业务 sync_gateway)。

6.5 Restore 时 recovery_target_time 太精确

想恢复到 14:59:59 但 WAL 已切,PITR target 落 15:00:00 跨段 → 报错。

修复

recovery_target_time = '2024-10-23 14:59:59+00'
recovery_target_inclusive = false
recovery_target_xid = '...';   # 或用 xid
recovery_target_lsn = '0/12345678';  # PG10+ 用 LSN

七、易错清单

  1. archive_command 失败后 PG 不推进新 WAL:fanout archive 后检测
  2. base backup 期间 switch LSN:恢复后 from base_lsn repeat WAL
  3. MySQL binlog_format STATEMENT:PITR 后 row 数据可能偏差
  4. recovery_target_time 必须含 timezone
  5. promote 之后切新 primary 不回滚:promote is one-way
  6. archive_command sync 单 WAL 超过 60s 阻塞 commit;用 async push (WAL-G async)

八、这一章带走的东西

  1. Checkpoint 是 fuzzy snapshot;不锁 buffer pool;最后 control file 更新 last_checkpoint_lsn
  2. PITR = base backup + WAL archive;恢复到 recovery_target_time / recovery_target_lsn
  3. PG archive_command 设计要点:sync 限 60s、监控 failed_count
  4. 增量 base backup (PG 17+ --incremental、pgBackRest、xtrabackup) 节省带宽
  5. 严业务护航 standby:archive success rate、监控 S3 push 健康
  6. MySQL PITR 主要靠 binlog,format 限制 ROW 防 PITR 后偏差
  7. 跨云 DR:archive push 主流加 SSE + lifecycle policy 让老 WAL 归档 cold tier

下一节 →

查询优化 — RBO/CBO、join 算法、向量化执行

查询优化

这一节

基于规则 / 基于代价优化 (RBO/CBO)

TL;DR

Planner = 规则改写 (RBO) + 代价估算 (CBO) + plan 搜索。RBO 做无成本的重写(谓词下推、子查询展开、常量折叠);CBO 基于统计(MCV、histogram、n_distinct、selectivity)做 cost 模型选最低 cost plan。本节从 SQL 文本走到 plan tree:parser → rewriter → planner → executor 完整结构、世界各国主流 DB 的 planner 差异、Cascades 框架(SQL Server / Apache Calcite)与火山模型 vs 向量化的根本区别。


一、Pipeline 全流程

SQL Text
  ↓ lexer / parser (PG: scan.l + gram.y, MySQL: yacc++)
Query Tree (AST)
  ↓ Rewriter (RBO) — view expansion, constant folding, predicate simplification 
Logical Plan Tree
  ↓ Planner (CBO) — selectivity estimation, cost model, search engine (DP / Cascades)
Physical Plan Tree
  ↓ Executor — pipelined / vectorized
Result rows

PG 14+ planner 直接重写为 physical operator,把 transformation 与 cost 估计混合迭代。Apache Calcite(Hive、Flink、Beam 等)用 Cascades-volcano 风格,可 set-based 规则搜索。


二、RBO 变换清单

规则作用
View expansion视图合并
Constant folding1+1 > 2 → TRUE/FALSE
Predicate pushdown∏(σ(p(R)))σ(p(∏(R)))
Sub-query flatteningWHERE x IN (SELECT ...) → SEMI-JOIN
Sub-query decorrelationCorrelated EXISTSSEMI JOIN
OUTER → INNER simplificationL JOIN A ON ... WHERE A.x NOT NULL → INNER
Common subexpression elimination重复表达式去重
OR → UNION ALLWHERE a=1 OR b=2 不可走两个 index → UNION ALL 走
CTE inlinePG 12+, MATERIALIZED marker
Dead code removalSELECT * WHERE 1=0 → EMPTY
Distinct pushdownDISTINCT 在 JOIN 中推下

RBO 是 deterministic——不依赖 statistics,只做正确变换。然后给 CBO 优化。


三、CBO 核心模块

3.1 Statistics

PG pg_statistics (per-column):

  • most_common_vals / most_common_freqs — 高频值分布
  • histogram_bounds — 等频直方图 (default_statistics_target 100 buckets)
  • n_distinct — 唯一值数(负数表示比例)
  • null_frac — NULL 比例
  • correlation — 物理排序 vs 列值相关度 (用于 BRIN 与 ORDER BY index seek)
SELECT * FROM pg_stats WHERE tablename = 'users';

MySQL 8.0+ 也类似:ANALYZE 拿 histogram (information_schema.column_statistics)。

3.2 Selectivity Estimation

等值 sel:

sel(col = value) =
  if value in MCV → MCV freq
  else 1 / max(n_distinct, 1)

范围 sel:

sel(col between a and b) =
  if histogram available → [a,b] 内 bucket count
  else default 1/3 (rough)

JOIN sel:

sel(R.a join S.b) = 1 / max(ndistinct(a), ndistinct(b))
                   if both unique (1:1) → 1 / max
                   if FK (1:N) → 1 / N_unique_of_major

复合 sel:

sel(p1 AND p2) = sel(p1) × sel(p2) × dependent_factor
sel(p1 OR p2) = sel(p1) + sel(p2) - sel(p1 AND p2)

PG AND 默认 sel(p1) × sel(p2) × 0.5(不可独立时)。

3.3 Cost Model

cost = seq_page_cost × num_seq_pages
     + random_page_cost × num_rand_pages
     + cpu_tuple_cost × num_rows
     + cpu_index_tuple_cost × num_index_rows
     + cpu_operator_cost × num_ops
     + parallel_setup_cost × 1 (if parallel workers)
     + parallel_tuple_cost × num_tuples

PG 默认 (postgresql.conf):

  • seq_page_cost = 1
  • random_page_cost = 4 (机械盘合适;SSD 应调 1.1-1.5)
  • cpu_tuple_cost = 0.01
  • cpu_index_tuple_cost = 0.005
  • cpu_operator_cost = 0.0025
  • parallel_setup_cost = 1000
  • parallel_tuple_cost = 0.1
  • effective_cache_size = 4GB ← planner 假设 cache 大小(不分配),影响规划偏向

调小 effective_cache_size → planner 偏 seq scan。 SSD 极推:random_page_cost = 1.1, effective_cache_size = RAM × 0.7


4.1 Bottom-up DP (System-R 风格)

DPsize 1 = base_tables optimal plan
DPsize k = min(DPsize k-1 join new base_table)
       考虑所有连接顺序 × join 算法 (hash/merge/loop)
       cost = already_seen_cost + new_join_cost

复杂度 O(3^N) — join reorder,N 表少时 fast (N<15)。

4.2 PostgreSQL: GEQO

geqo_threshold = 12 (default),N ≥ 12 表时切遗传算法:

  • join order 编为 permutation
  • 模拟进化 = mutation + crossover + selection 数千代
  • 100-1000 倍快但非最优

BI 工具跑出 N 表 join → query 性能差。临时关:SET geqo = off

4.3 Cascades / Volcano

Cascades (Goetz Graefe, 1995) 是 top-down 规则驱动的 plan 搜索:

  • 任务栈:transform goal, implement goal, optimize goal
  • 引用替代 copy:低内存开销
  • memo 数据结构存储所有 plan choices
  • 规则集可扩展

应用:

  • SQL Server optimizer (微软内部 Cascades)
  • Apache Calcite (Hive、Flink、Beam)
  • CockroachDB (Moruit/Go 版本)
  • Spark Catalyst (类似 Cascades 模式)

Cascades vs DP:

  • DP 仅 join reorder,Cascades 可带所有物理算子选择
  • Cascades 慢但灵活 → 大数据业务偏好

五、特殊情况

5.1 Partition Pruning

分区表查询时只扫匹配分区:

CREATE TABLE events (created_at timestamptz, ...) 
  PARTITION BY RANGE (created_at);

CREATE TABLE events_2024_01 PARTITION OF events
  FOR VALUES FROM ('2024-01-01') TO ('2024-02-01');

SELECT * FROM events WHERE created_at = '2024-01-15';
-- 仅扫 events_2024_01

PG 11+ 也支持运行时 execution-time pruning:动态 parameter 时也能 prune。

5.2 Parallel Query

PG 9.6+:

Gather
  → Parallel Seq Scan on t

worker 数:

  • max_parallel_workers_per_gather = 4
  • max_parallel_workers = 8
  • min_parallel_table_scan_size = 8MB

大表 ≥32GB → 启用并行;小表 seq 一次比启动 worker 启动还快。

5.3 Adaptive Query Optimization

Oracle 12c+ / SQL Server adaptive join:plan tree 留分支,runtime 测 join 选择,动态选 hash vs loop。PG 14+ Partial.

5.4 MPP Join Distribution

Greenplum / Spark:

SELECT * FROM A JOIN B ON A.user_id = B.user_id;

策略:

  • Broadcast:小表 (e.g. < 100MB) 广播到所有节点
  • Redistribute:双表按 join key hash 重分布
  • Co-located:表已按同 key 分布 → 本节点直接 join

PG Partitioned + Distributed 扩展 (Citus、CockroachDB) 也用类似策略。


六、产线事故

6.1 估 rows 不准导致 hash join 跑爆

ANALYZE 长时间没跑 → 统计陈旧 → rows=1 估而实际 100 万 → planner 选 nested loop 跑数小时。

修复:启用自动 autovacuum + analyze_scale_factor = 0.02;手动 ANALYZE careful_table 每次 ETL 后强刷。

6.2 多表 join GEQO 选坏

BI 工具 18 表 join → GEQO 随机 plan;某些组合 100s 执行。

修复SET geqo = off + SET join_collapse_limit = 50;业务侧汇总预聚合表降 N 表。

6.3 random_page_cost 误设 → 全 seq scan

某评价改 random_page_cost = 1 走 idx scan 但机械盘上 random IO 慢 → query 1000ms up to 100ms 他选,但实际原 seq 50ms。

修复:机械盘 keep 4;SSD 1.1-1.5。

6.4 partial index predicate 不命中

partial index WHERE active = TRUE 但 SELECT WHERE active IS TRUE 不 match → index not used。

修复:PG planner 要求 SELECT predicate logic-imply index predicate,否则不命中。改业务 SQL WHERE active = TRUE

6.5 join_collapse_limit 触发 suboptimal

PG join_collapse_limit = 8 (default),业务写 12 表 join,写顺序入 planner collapse → planner 不重排。

修复SET join_collapse_limit = 50 或 unlimited join_collapse_limit = 0


七、易错清单

  1. EXPLAIN cost 不能直觉说"应该是多少 ms":dimensions less;仅相对 join 排序规划
  2. EXPLAIN ANALYZE 实际 vs EXPLAIN 估计巨大 gap = 统计陈旧/未刷新
  3. random_page_cost SSD 设 1.1 不是 4
  4. default_statistics_target 大值 (1000+) 让 plan 准确,但 ANALYZE 耗时长
  5. 多表 join ≥ 12 → GEQO:业务表太多 force off 或 BI 用预聚合 mview
  6. enable_* planner 开关 禁 hash join/merge:业务调试时慎用
  7. prepare_threshold 在 PG 中复 plan:但 plan 准确性 shaky,需 plan_cache_mode = auto (PG 16+)
  8. Subquery decorrelation 不是 always optimal — 取决于实际数据分布

八、这一章带走的东西

  1. RBO 是 deterministic transformation (pushdown, constant folding, view expansion)、CBO 是 statistics-based search
  2. PG statistics MCV + histogram + n_distinct 决定 selectivity 估计准确度
  3. cost model 由 6 个 cpu + page cost 组成,random_page_cost SSD 上必调 1.1
  4. Plan search = bottom-up DP (System-R) join reorder,N≥12 切 GEQO;大数据用 Cascades
  5. Cascades (SQL Server, Apache Calcite, CockroachDB, Spark Catalyst) 比单一 DP 强很多
  6. MPP join: broadcast (小表) / redistribute (大表) / co-located (同分布)
  7. partition pruning + execution-time pruning 让 1 year partition 12 月表数据 query 走 1 segment
  8. Planner adaptive join (Oracle/SQL Server 还有 PG partial) 让 runtime 决定 hash vs loop

下一节 →

Join 顺序、hash join vs nested loop — pros cons,Hash Join 临时落盘

Join 顺序、hash join vs nested loop

TL;DR

Join 是多表查询核心。三种基本算法:Nested Loop(小表 + 索引)、Hash Join(大表等值连接 + 在内存 fit)、Merge Join(已排序)。本节走完三种 join 的 inner structure、planner 选择的 cost 估算、work_mem 与 hash join 落盘、Semi / Anti / Cross join 的语义、PostgreSQL 与 MySQL 的不同 join 实现(PG 9 加 hash join 后 8 没有原生直到 8.0.18)。


一、Nested Loop Join

def nested_loop(outer, inner):
    for r1 in outer:
        for r2 in inner:
            if r1.joinkey == r2.joinkey:
                emit(r1, r2)

O(N_outer × N_inner_per_outer);不含 IO,包含 IO 时 inner 通常 index lookup:

cost = N_outer × cost_of_lookup_for_one_outer
cost_of_lookup = random_page_cost × depth(index) + cpu_tuple_cost

适合:

  • outer 小(≤ 1000 行,量级)
  • inner 有 index 可以 joinkey 快速 lookup
  • 没 index 且 inner 小 → bnl (block nested loop) 块 buffer

PG 默认走参数化 index scan:每 outer 行传 joinkey 给 inner index scan:

Nested Loop
  -> Seq Scan on outer        (loops=1, rows=N)
  -> Index Scan on inner      (loops=N, rows=inner_avg_per_key)
       Index Cond: (inner.fk = outer.id)

inner 的 loops = N_outer,每 loop 是一次 index lookup。


二、Hash Join

2.1 算法

def hash_join(outer, inner):
    # build phase: 把 inner (smaller) 塞 hash table
    htable = {}
    for r2 in inner:
        htable[hash(r2.joinkey)].append(r2)
    
    # probe phase: 走 outer,每行查 hash table
    for r1 in outer:
        for r2 in htable.get(hash(r1.joinkey), []):
            if r1.joinkey == r2.joinkey:
                emit(r1, r2)

cost = N_inner × (cpu_tuple + hash_op) + N_outer × (cpu_tuple + hash_op + avg_bucket_scan)

适合:

  • 双表都大 (1k+)
  • joinkey 等值 (=)
  • work_mem ≥ N_inner_hash_size

2.2 Grace Hash Join(落盘)

work_mem 不够时:

  1. 把 inner 与 outer 都 hash 分 N bucket (e.g. 8)
  2. 把 bucket 都 flush 到 disk
  3. 逐 pair bucket (inner_i, outer_i) 进内存跑 hash join → spilled buckets 取一对一对处理

PG 用 "hybrid hash join"(保留 Hybrid memory,但 spill 部分),实践中 bucket 数适 N。调 work_mem

SET work_mem = '64MB';
SET maintenance_work_mem = '512MB';

work_mem 越大 hash join 越可能 1 batch 完成 → 避免 disk batch → query 快 10×。

2.3 Skew

inner 表 joinkey 分布 skew:

  • 99% row 都 joinkey=NULL → 全入 NULL bucket → huge bucket 落盘
  • hash table 中某 bucket 万元素,probe 时 O(N) → 退化为 nested loop

PG 13+ hash join 加 skew bucket 识别 + 单独处理。Oracle 也类似 "skew-aware hash"。

2.4 bloom filter 加速

CockroachDB / Spark:probe 前用 bloom filter 看 outer row 的 joinkey 是否在 inner——免 hash lookup 80% 不命中的 row 立即丢。


三、Merge Join

def merge_join(outer, inner):
    outer_iter = sorted_iter(outer)
    inner_iter = sorted_iter(inner)
    while outer_iter.has() and inner_iter.has():
        r1 = outer_iter.peek()
        r2 = inner_iter.peek()
        if r1.key < r2.key:
            outer_iter.next()
        elif r1.key > r2.key:
            inner_iter.next()
        else:
            emit(r1, r2)
            # 多个 key 等输出
            advance_both_with_group()

cost = sort_cost(outer) + sort_cost(inner) + N_outer × cpu_tuple + N_inner × cpu_tuple

适合:

  • 双表已按 joinkey 排序(有索引或自然 sorted)—— 避免 sort cost
  • 大表 + ORDER BY 同时需要的查询
  • LONG range join 比较省 mem

PG 内部 sort work_mem 不够 → 临时 file sort merge。


四、Semi Join / Anti Join

-- Semi Join: outer 行在 inner 出现就 emit 一次 outer
SELECT u.* FROM users u WHERE u.id IN (SELECT user_id FROM orders);

-- Anti Join: outer 行没在 inner 出现就 emit
SELECT u.* FROM users u WHERE NOT EXISTS (SELECT 1 FROM orders WHERE user_id = u.id);

-- Cross Join (Cartesian product)
SELECT u.*, o.* FROM users u, orders o;

PG 实现:

  • Semi Hash:build hash on inner subquery 唯一 keys,probe outer
  • Anti Hash:同但 emit outer when no match
  • Anti Nested Loop:每 outer 行 tech 内 index lookup,没命中则 emit
  • Anti Merge:sorted merge,没匹配则 emit

五、Joer 加速 tips

5.1 joinkey 索引化

-- A JOIN B ON A.user_id = B.user_id; 外 B.user_id 上 index
CREATE INDEX idx_b_uid ON B(user_id);

→ nested loop 适用、cost = N_A × log N_B 但实际 1 index lookup。

5.2 减少回表 (projection pushdown)

SELECT u.name, COALESCE(o.amount, 0)
FROM users u LEFT JOIN orders o ON u.id = o.user_id;

只取 USER_ID + NAME 给 join,再加 amount 从另一索引 plus — 取 B tree index on (user_id, amount) 让 read 不回表。

5.3 转换为 pre-aggregated 表

星型 schema:fact table (10^9 row) + dim table (10^3 row),常常 group join → pre-aggregated table 跑 OLAP 查询快。

5.4 Partition-wise join

PG 13+:把 tables partition 重排,每 partition join(避免 shuffle all)。

SET enable_partitionwise_join = on;
SELECT * FROM orders_p1 JOIN users_p1 ON ...
UNION ALL
SELECT * FROM orders_p2 JOIN users_p2 ON ...;

MPP databases (Snowflake / Greenplum) 内置支持 distributed join。


六、PG vs MySQL vs MemSQL/Spark 对比

引擎Hash JoinMerge JoinNested Loop
PostgreSQL9+ 主流主流主流
MySQL InnoDB8.0.18+ 默认主流主流(5.7 唯一算法)
OracleHybrid hashAdaptiveAdaptive
SQL ServerIn mem/spilled默认默认
ClickHouseDirect join algorithmDirectSkip
SnowflakeHash + BloomSort + Mergerare

MySQL 5.7 没 hash join,大表等值 join 跑 8 小时;MySQL 8 hash join 默认后才赶上。


七、产线事故

7.1 MySQL 5.7 没 hash join 大表 join 灾难得

orders 1 亿 + users 1 千万 JOIN 时间 6 小时(nested loop + index 走深)

修复:升 MySQL 8 + 临时把 JOIN 子表 over-tion → hash join 推 plan。

7.2 PG hash join work_mem 64MB 不够 spill 8 batches 慢 50×

work_mem 默认 4MB → 大表 hash join multi-batch → 50 秒 query。

修复:业务 session-level SET LOCAL work_mem = '256MB',让 hash join 1 batch → 5 秒。

7.3 Oracle skew hash bucket 行 hang

业务 list JOIN 用 enum type 极少 high card 时单 bucket 90% row → hash join 卡 hours。

修复:显式 Hash Join 启用 skew detection hint /*+ HASH_JOIN(司 SKEW) */

7.4 NULL joinkey 失败 join 漏行

SELECT * FROM a JOIN b ON a.x = b.x;   -- x=NULL 的 row 不 emit
SELECT * FROM a LEFT JOIN b ON a.x = b.x;  -- a.x=NULL 仍 emit (NULL_after_jokk 失配)

修复:业务若需 NULL = NULL considerate,用 a.x IS NOT DISTINCT FROM b.x

7.5 Partition-wise join 调优没生效

PG enable_partitionwise_join=on 但 planner 没选 → 因 joinkey 数据类型与分区 key 不匹配。

修复:让 partition expr 与 joinkey 类型一致;或子查询 cast hint。


八、易错清单

  1. Nested Loop cost 是 outer × inner_index_lookup_cost;inner 没 index → 灾难
  2. Hash Join 需要 work_mem ≥ inner_total_size;不够 spill → 100ms 跌 session
  3. Merge Join 必排序;已 sort(从 index 直接) 可省 sort phase
  4. Semi/Anti 不会重复 outer 行(emit 一次)
  5. NULL = NULL 在 SQL 中是 UNKNOWN,不 join → IS NOT DISTINCT FROM 解决
  6. MySQL hash join 8.0.18+ 才默认;老业务上不能 assume
  7. Partition-wise join PG 13+ 但要 enable_partitionwise_join=on + 类型对齐

九、这一章带走的东西

  1. Nested Loop 适合 small outer + inner index;双大 + no index 灾难
  2. Hash Join 适合双大 + 等值 + work_mem fit;不够 spill 走 hybrid hash
  3. Merge Join 适合已 sort + ORDER BY 同时
  4. Semi/Anti join 用 EXISTS/NOT EXISTS 一次 emit;不要重复 LEFT JOIN row
  5. NULL row 被 = join 排除;用 IS NOT DISTINCT FROM
  6. MySQL 8.0.18+ 才有 hash join;Oracle/PG 默认都有;ClickHouse 极简直接 merge
  7. PG work_mem = 64MB+ + partition-wise join 调参 + 已 sort 表走 merge join = 大表 OLAP 大杀器

下一节 →

向量化执行、列存 — 列存 vs 行存、SIMD + 超标量、Volcano vs vectorized executor

向量化执行、列存

TL;DR

行存到列存是 from row-major 到 column-major 的存储与执行范式转变。列存让分析查询只加载相关列,减少内存带宽浪费;向量化执行让 CPU 每条指令处理一批数据,发挥 SIMD 潜力。ClickHouse / DuckDB / Snowflake 都靠这两点实现 100-1000× OLAP 性能。本节走完列存字节布局、向量化 vs 火山算子的根本区别、SIMD 与 cache locality、PG 12+ 列存储 SQL、ClickHouse 的 vectorized engine 源码路径。


一、行存 vs 列存

1.1 物理布局

行存 (Postgres / MySQL):
┌────────────────────────────────────────┐
│ (id=1, name='Alice', age=25)           │
│ (id=2, name='Bob', age=30)             │
│ (id=3, name='Carol', age=22)           │
└────────────────────────────────────────┘

列存 (ClickHouse / DuckDB):
┌────────┬────────────┬──────────┐
│ id: [1, 2, 3]                      │
│ name: ['Alice', 'Bob', 'Carol']    │
│ age:  [25, 30, 22]                 │
└────────────────────────────────────┘

1.2 优势

维度行存列存
单行 INSERT快 (page append)慢 (多列 page 修改)
单行 SELECT快 (1 page)慢 (多列多 page)
聚合全表 (1-2 列)慢 (扫不必要的列)快 (仅扫描需要的列)
SIMD 加速难 (row layout 缓行)
压缩率一般 (列内重复少)高 (列内同类型)

OLTP 业务通常需要行存(指 OLTP 操作 单行/少量行 多);OLAP 业务(聚合、扫全表)用列存。

1.3 双 hybrid (HTAP)

  • Oracle In-Memory Column Store:在内存中维护列存镜像
  • SQL Server Columnstore Index:表 + 单独 columnar index
  • MySQL HeatWave:在 MySQL 上加 column store cluster

二、向量化执行 vs 火山模型

2.1 火山模型(行处理 Iterator)

每个算子有 next() 接口,逐行产出:

class FilterOp:
    def __init__(self, child, predicate):
        self.child = child
        self.predicate = predicate
    
    def next(self):
        while True:
            row = self.child.next()
            if row is None: return None  # DONE
            if self.predicate(row): return row

class Scan:
    def next(self):
        # 读一行 yield
        return next_row_from_disk

特点:每行 next() 调用 1 次 → vtable + func call overhead per row → 大表 multi 亿行 cost is enormous.

PG / MySQL InnoDB executor 默认走火山模型。

2.2 向量化模型(批处理 Iterator)

每算子 yield 一批 batch (e.g. 2048 行):

class VectorizedFilterOp:
    def __init__(self, child, predicate):
        self.child = child
        self.predicate = predicate

    def next(self) -> Vector:
        while True:
            batch = self.child.next()  # Vector of 2048 rows
            if batch is None: return None
            mask = self.predicate.evaluate(batch)  # SIMD 比较
            if mask.any():
                return batch.select(mask)

batch size 通常 1024-8192 行 → vtable 调用开销摊薄 1000×。

2.3 性能差来源

维度火山向量化
Iterator overheadper row 7-10 nsper batch 50 ns / batch_size
SIMDinefficient (one elt)full SIMD
Cacheprefetch lowcache blocking
Branch predictionbad (per if)good (vector mask)
Compiler vectorizationnoyes

实测同等硬件上 vectorized 比 volcano 10-100×。


三、SIMD + 列存合体

3.1 SIMD 例子

// 8 32-bit 比较 (AVX2)
__m256i v1 = _mm256_loadu_si256(&col_a[i]);
__m256i v2 = _mm256_set1_epi32(18);
__m256i mask = _mm256_cmpgt_epi32(v1, v2);  // 1 if a[i] > 18
_mm256_maskstore_ps(&result[i], mask, output);

AVX2 = 256 bit = 8 × int32 / float32 / char × 32 AVX-512 = 512 bit = 16 × int32

3.2 Processor 计算 throughput

  • CPU 总指令 / 秒:2 GHz × issue width (4-8 → 8-16 IPC) → 约 10-50 GIPS
  • 内存 bandwidth:DDR4-3200 = 25 GB/s, DDR5-6400 = 50 GB/s
  • L1 / L2 / L3 cache hit:1 / 5 / 30 cycle

→ hotspot 必 fit L2 cache 才能压满 CPU。

3.3 列压缩

struct ColumnData:
    type: enum (INT32, FLOAT64, STRING, BOOL)
    encoding: enum (RAW, DICT, RLE, DELTA_BYTE, FOR, BITPACKED)
    values: bytes

# e.g. boolean → 1 bit/row (vs 1 byte row store)
# 字典编码 string 列 → uint32 (0-65535 distinct string) → 4 bytes/row
# delta 编码 timestamp → varint 1-2 bytes/row (vs 8 bytes)

实测压缩比:

  • ints:1.5-2×
  • 字符串 (dict):5-20×
  • 时间戳 (delta):5-10×

四、ClickHouse Vectorized engine

架构:

Block (columnar batch) = head + MemArena-allocated columns
AggregatingOp:
    for block in input:
        for col in block.cols:
            aggregate(col)  # SIMD
    emit finalize

特点:

  • batch aggregation (8k rows/block)
  • partial aggregation + later merge
  • group-by key 用 stored hash table (复杂 key)
  • 没有事务 overhead,纯 OLAP

列存 + SIMD + 高压缩 → 1000× OLAP 比 PG row-based。

4.1 实测 SQL

SELECT user_region, sum(amount)
FROM ad_events
WHERE event_date BETWEEN '2024-01-01' AND '2024-10-01'
GROUP BY user_region;

实测 1 PB 数据 query:

  • PG row-store:10 hours
  • ClickHouse:30 秒

差 = cache locality + SIMD + aggregation vectorized + 列裁剪 + skip idx。


五、DuckDB / Snowflake 设计差异

5.1 DuckDB

  • in-process OLAP DB(嵌入式,无需 server)
  • vectorized engine (1024-row batches)
  • morsel-driven parallelism
  • columnar storage with light compression
  • 适配 in-Python / R / 流水

5.2 Snowflake

  • cloud-native OLAP
  • 微 partitions (16MB) + columnar
  • vectorized engine 类似 ClickHouse
  • 缓存多 layer (local SSD + cloud object store)

5.3 PG + columnar (cstore / Citus columnar)

早期 cstore_fdw 1.0 columnar 但向量化不足量 → PG 性能仍 10× 慢 ClickHouse。Citus columnar (PG 13+) 更好但没真 vectorized engine。


六、产线事故

6.1 100GB stats 表 PG vs ClickHouse 100× 差

E-commerce analytics 100 GB sales 表,PG 跑 group-by query 5 分钟;迁 ClickHouse 30 秒。

结论:OLAP workload PG 不应首选;ClickHouse / DuckDB 远胜。

6.2 PG Citus columnar 插入瓶颈

业务 INSERT 100k/秒 亡 Citus columnar,因为列存 INSERT 多列 page modify。

修复:OLTP 列 → 行存表 + ETL 流式 转列存表 → 冷 到最近 24h 列存表。

6.3 Snowflake 按 query byte rate fairness

业务大 query 跑 1h 占 warehouse 全 slots → 小 query SLA 漂。

修复:业务 query suspend 多阶段:结果集块 → business cache 行 / 按 user isolation 多 warehouse。

6.4 ClickHouse 单 server 内存爆

GROUP BY key 基数 极高 → hash table 50 GB > RAM → killed。

修复:调 max_memory_usage + max_bytes_before_external_group_by;业务降 基数 (e.g. bucket by day)

6.5 DuckDB serverless crash

某 notebook 跑 64 GB DuckDB query,OOM 杀。

修复PRAGMA memory_limit='2GB'; 配 swap file 容忍 spill。


七、易错清单

  1. 列存 INSERT 慢:OLTP 不可全列存(行存+列存 hybrid)
  2. PG 行存对 OLAP 极劣:100 GB query 5 min 不奇怪
  3. ClickHouse 没有 things deleted deletes right away:等 mutations 后台
  4. Snowflake cost by query byte scanned 是主; 按 query 日 firma feel 福利
  5. DuckDB 内嵌可分 in server;但是 site 后 vs OLAP cloud 压不齐
  6. Simd verifier Vectorize 不-catching DB int handle complex types/variants 性能 ≠ magic
  7. Aggregation BASE UNNECESSARY COL:必 SELECT 多 → really narrow cols → 好优
  8. Cache-local 必块状:batch 全批 ካ must be cacheable in L2 → 才有 SIMD benefit

八、这一章带走的东西

  1. 行存适合 OLTP;列存适合 OLAP;HTAP 表 (Oracle IM/MemSQL/MySQL HeatWave) 双幅存
  2. 火山模型每行 next() → 高 overhead;向量化模型每 batch (1024-8192) overhead摊薄 1000×
  3. SIMD AVX2 = 8 int32 一指令;AVX-512 = 16 int32 → 16× 单 issue IPC
  4. 列压缩 dict/run-length/delta/bitpack;时间戳 5-10× 压缩;字符串 5-20×
  5. ClickHouse vectorized + 列裁剪 + 聚合 SIMD → 1000× 比 PG row-based
  6. DuckDB in-process 性能仍 stroke 10-100× vs PG on OLAP
  7. Citus columnar 适合大表 ANALYTICAL pruning + 跳行 + 部分向量化,但 not full vectorized
  8. Hotspots 必 fit L2/L3 cache 才能压满 SIMD IPC

下一节 →

OLAP 与现代数据栈 — ClickHouse / DuckDB / Snowflake 设计

OLAP 与现代数据栈

这一节

ClickHouse / DuckDB / Snowflake 设计

TL;DR

三个新一代 OLAP 引擎代表了三种架构方向:MPP shared-nothing(ClickHouse)、单机嵌入式向量化(DuckDB)、存算分离云原生(Snowflake)。各自取舍反映了不同规模与 workload 下的工程权衡。本节带你看完三个引擎的 storage layout、metadata server / consensus decision sequence、compression format、query execution path、最近 5 年客户群对比。


一、ClickHouse (Yandex 2016)

1.1 架构

+---+    +---+
| N | <-> | N |  (replica via ZooKeeper / Keeper)
+---+    +---+
Shard 1       Shard 2

特点:

  • shared-nothing:每个 shard 一个节点,replication via cluster.xml + ZooKeeper
  • mergeTree 引擎,每分钟一批 part 合并
  • vectorized batch processing (1024-8192 rows per batch)
  • 跨 shard query 用 native protocol 解读

1.2 MergeTree

Table storage = parts:
  part_001:
    - columns file (one per column)
    - primary.idx (sparse index every 8192 rows)
    - skip idx file (lightweight: min/max/null)
    - checksums
  
  part_002 ...
  
INSERT INTO t VALUES (...)
  → 创建 new part on disk (default part_size ~ 150MB)
  → background merge: 多 part → 大 part (10GB+)

skip index 算法:

  • minmax:极值索引
  • set:value 集合
  • bloom_filter:等值 filter
  • ngrambf_v1 (ngram bloom):模糊匹配 LIKE

1.3 Replication

  • ReplicatedMergeTree 引擎:每 part 增/删在 ZooKeeper 同步
  • multi-master:每 replica 独立读;写入 trigger 复制 log
  • 不像 Raft/Paxos:ClickHouse 复制是 eventually + part 级别
  • 新版 ClickHouse Keeper 替代 ZooKeeper(Raft 实现)

1.4 性能与限制

性能:

  • vectorized aggregation,全 table scan 1 PB query < 1 hr
  • 拥抱 JSON / Map / Tuple 等 polymorphic 数据
  • 没有 transaction overhead

限制:

  • 没 OLTP:UPSERT 仅 batch mutation (background)
  • DDL 修改表难(ALTER 引擎 Class Hang),需 mutations
  • replication ZK 长期持有,高故障

二、DuckDB (CWI 2019)

2.1 架构

  • in-process OLAP:无 server, 一 (libduckdb.so) dll 直接链接
  • Python import duckdb; duckdb.query("...") 即 work
  • 用 embedded StorageContext, 本地 file
  • vectorized engine (1024-row batches)

2.2 设计哲学

  • OLAP 不需要 "ACID" 全弱化,仅 "no consistency issues"
  • 不锁表,写 insert 加 incremental row (Morsel 固定大小 batch)
  • 跨 platform: Windows / macOS / Linux / WSL / ARM
  • 单机 (desktop scale) 通常 100 GBs OK,1+TB memory pressure 大

2.3 性能

实测:

  • TPC-H SF1 (1 GB) 10 秒 vs PG 5 分钟
  • TPC-H SF100 (100 GB) 5 分钟 vs Snowflake 2 分钟
  • SF1000 (1 TB) DuckDB benchmarks → 60 min (single-machine)

2.4 优势与限制

优势:

  • 部署极简:无服务部署
  • 适合 streamlit / pandas-area workflow
  • 高内存 (machine 32GB+) work 好

限制:

  • 不真正 ETL:OLTP 后快流处理 dump file
  • 没 distributed:单机 max CPU / RAM SIMD 只能 region-based
  • 不支持多用户并发写

三、Snowflake (2014)

3.1 架构

+---+---+---+----+
| Cloud Services (metadata) |
|   - metadata server
|   - query optimizer
|   - authentication
|   - access control
+---+---+---+----+
       ↓
+---+---+---+----+
| Compute (Virtual Warehouse) |
|   MPP cluster, 每节点 local SSD cache
+---+---+---+----+
       ↓
+---+---+---+----+
| Storage (Object) |
|   S3 / GCS / Azure Blob
+---+---+---+----+
  • "$/TB-scanned" 计费:用户付 query 数据量
  • 微 partitions (16MB block) + Stripe 索引
  • MPP 状态 serverless,autonomous 弹性

3.2 微分区

  • 16 MB 一个微 partition (slice)
  • 每微 partition 存 column subset + min-max cutoff
  • SELECT 谓词在 metadata server 通过 column min/max → 跳分区
  • 压缩 SNAPPY / LZ4 / 专用 (DELTA / STRING_DICTIONARY)

3.3 Result Cache

Cloud Services 内 cache:query 同 hash 数秒内返回相同结果。

  • 90 second TTL
  • 数据 vacuum 后 cache invalid

3.4 性能与限制

性能:

  • local SSD cache layer: hot micro partitions cache 命中
  • tiered storage: 热数据 local SSD / S3 cold
  • vectorized query execution, 每 batch 8K-64K rows

限制:

  • 没真正 MERGE ON primary key UPSERT
  • "Time travel" 90 天历史数据访问

四、对比表

维度ClickHouseDuckDBSnowflake
部署server, MPPin-process libSaaS cloud
Scale1 PB100 GB-1 TB>1 PB
隔离multi tenant 慢 (server)单 user 嵌入virtual warehouse isolation
价格自 deploy + OSSfree OSSpay-per-TB scanned
延迟ms-s 短 queryms-秒sec-min query
写 update不擅长一般一般
ReplicationZK based无 (本地文件)S3 eventually + cache invalid
ACIDMVCC on local filemetadata transactional
CompressionLZ4+RLE+dictcol + RLE + dictmicropartition + multi compression

五、其他 OLAP Engine

5.1 Apache Druid

  • kafka → historical node + deep storage
  • sub-second query 5 PB streaming-fed
  • 适合 实时分析高可用 metrics

5.2 Apache Pinot

  • LinkedIn design 类 Druid
  • 多 STM 模式 speed-up ingestion

5.3 Apache Kylin

  • Hadoop + HBase + OLAP cube precomputation
  • 5 PB 但 阶段失效(low freshness)

5.4 Databricks SQL

  • 类 Snowflake,但用户自管 Delta Lake
  • Spark vectorized engine

5.5 Firebolt

  • 列存 + skip + f0 vectorized + indexing
  • 类 Snowflake,新型 data ingest

5.6 SingleStore (MemSQL)

  • HTAP 一体
  • 列 + row combo
  • OLTP + analysis 同表

六、产线案例

6.1 ClickHouse 写入瓶颈

业务 100k INSERT/sec 单机磁盘 IO 占满。ClickHouse merge 跑死 → 5+ days backlog。

修复:bulk insert batch 1M rows / max_insert_block_size=1M;多 shard 分写;turbo 编码 mode 极致压缩。

6.2 Snowflake small query 高 cost

业务 dashboard 每 30 秒每 user query 小 → warehouse cost $30/hour。

修复:启 result cache hit + auto-suspend 60s → $10/hour。

6.3 DuckDB embedded OOM (notebook)

pandas read_csv 64 GB → DuckDB driver alloc all 数据 → memory > RAM 几次 kill。

修复PRAGMA memory_limit='2GB'; SET threads=1; + swap file。

6.4 ClickHouse replication ZK 故障

ZK → 部 partition lag → ClickHouse spinning.

修复:升 Raft ClickHouse Keeper 替代 ZooKeeper。

6.5 Snowflake mark forced UPSERT slow

SaaS UPSERT primary key 模式 metadata mark 但 render slow。

修复:业务夜里全压 merge batch (insert + part override)。


七、易错清单

  1. ClickHouse 没 OLTP:UPSERT 走 mutations 后台 async
  2. Snowflake cost = query byte scanned:主成本驱动
  3. DuckDB:不可服务 model,每 user 嵌自
  4. ClickHouse mergeTree replication:古老 ZK 不能多 ZK cluster,升 ClickHouse Keeper (Raft)
  5. DuckDB PRAGMA threads 设高 可 OOM
  6. ClickHouse Hybrid Log-Structured Merge Trees:老 MergeTree → 改 ReplicatedMergeTree
  7. Snowflake warehouse 大 T-shirt size:scaling 是 fixed(不 size 算每 warehouse 工资源)

八、这一章带走的东西

  1. ClickHouse: shared-nothing MPP + mergeTree + Vectorized 是 PB scale 自部署 OLAP
  2. DuckDB: in-process + 8192-row vectorized + 单机性能比 PG 10-100×
  3. Snowflake: micro-partitions + multi-tier (S3 + local SSD) + 云原生 + pay-per-byte
  4. 三选: 部署自控选 CH, embedded analytics 选 DuckDB, managed cloud 选 Snowflake
  5. 性能取决于:列压缩、batch size、SIMD 命中、缓存 localily
  6. 物化视图 + Rollup 是必要的,不是 optional

下一节 →

预聚合、物化视图、Cubes — Cube / Materialized View / Star Schema / Druid rollup

预聚合、物化视图、Cubes

TL;DR

聚合查询全量 scan 太贵 → 用预聚合 + 物化视图 + 数据 cube 提前算好 → 查询时查小表。代价:构建 + 维护 + 一致性。本节覆盖 ROLLUP / CUBE / GROUPING SETS、PostgreSQL / Materialize 物化视图、Apache Druid auto-rollup、Cube.js semantic layer、ClickHouse AggregateFunction、Star Schema vs Snowflake Schema。


一、Star Schema vs Snowflake Schema

1.1 Star Schema

  • 中心是 fact 表(事件)
  • 周围是 dim 表(用户、商品、时间)
  • fact 表大(亿+ row),dim 表小(k-row)
  • fact 表外键链接 dim
  • 物理上 星状 → 简单 join 顺序 planner 容易优化
       dim_user    dim_product    dim_time
            |          |             |
            +----------+-------------+
                       |
                  fact_sales
                 (10^9 rows)

适合:BI、报表、阳 reporting。

1.2 Snowflake Schema

  • dim 表进一步 normalized → 多层 dim 表
  • 节省存储,但 join 复杂
  • 现代 OLAP 罕用(存储已不贵)

二、预聚合模型

2.1 Rollup Table

每次 INSERT fact + 1 row 写汇总表:

CREATE TABLE sales_by_day (
    dt DATE,
    product_id BIGINT,
    total_amount DECIMAL, n_sales INT);

INSERT INTO sales_by_day
SELECT created_at::DATE, product_id, SUM(amount), COUNT(*) 
FROM sales_raw 
WHERE created_at::DATE = '2024-10-22'
GROUP BY created_at::DATE, product_id;

查询变小:

SELECT dt, SUM(total_amount)
FROM sales_by_day
WHERE dt BETWEEN '2024-10-01' AND '2024-10-31'
GROUP BY dt;

100x 加速(30 days × 1000 products = 30k row → 替代 1亿 raw row)。

2.2 Materialized View (PostgreSQL)

CREATE MATERIALIZED VIEW sales_daily AS
    SELECT 
        date_trunc('day', created_at) AS dt, 
        product_id,
        SUM(amount) AS total, 
        COUNT(*) AS n
    FROM sales
    GROUP BY dt, product_id
    WITH DATA;

REFRESH MATERIALIZED VIEW sales_daily;
-- PG 9.4+ 支持 REFRESH CONCURRENTLY 不阻塞 SELECT

PG 14+ WITH NO DATA + lazy refresh;9.3+ 已有 incremental refresh on CONCURRENTLY

2.3 ClickHouse AggregateFunction State

CREATE TABLE sales (
    dt Date,
    product_id UInt32,
    amount AggregateFunction(sum, Decimal)
) ENGINE = AggregatingMergeTree PARTITION BY toYYYYMM(dt) ORDER BY (dt, product_id);

INSERT INTO sales SELECT dt, product_id, sumState(amount) FROM sales_log GROUP BY dt, product_id;
SELECT dt, sumMerge(total) FROM sales GROUP BY dt;

AggregateFunction state:聚合为 part merge 时(不重 row,是 state 合并)→ 单 INSERT 1 row/组。


三、Cube.js Semantic Layer

cube Sales {
  measures: [totalAmount, count]
  dimensions: [createdAt, product, user]
  joins: Products ON id = product_id
  
  preAggregations: {
      salesByDay: {
          type: rollup
          measures: [totalAmount]
          timeDimension: createdAt
          granularity: day
      }
  }
}

Cube.js 在 query time 选 pre-aggregation rollup → 流 SQL 改写 → 后台刷新 + cache。

Cube.js 部署:

  • Cube.js server (Node.js)
  • Backend 查 PG / Snowflake / BigQuery
  • Frontend RESTful API
  • pre-aggregations 可存自己 store (e.g. PG 表 + cube refresh worker)

四、Apache Druid Rollup

Druid ingest 阶段做聚合:

  • ingestion spec 中 metrics 计 aggregators (sum, count, hyperUnique etc.)
  • 同 timestamp bucket + dimensions group 一组 → 1 row
  • 用户既不查 raw 只查 rollup

随机 ingest 1 PB raw → 1 TB rollup → 1000× storage 节省。

最终 consistency:Druid 数据源有 granularity (minute/hour/day); query 时合并 rollup grain。


五、Apache Pinot 的 Real-time / Hybrid Tables

  • offline segment: 历史 batch push
  • realtime segment: Kafka stream 持续 ingest → realtime 0-day data
  • Hybrid: 同 table 合一来自 realtime + offline

聚合:Star-Tree Index (Pinot 1.0+) → 子集维度 + sub-set 聚合 → skip 数据,比 Druid rollup 更显增量。


六、Snowflake search optimization + materialized view

CREATE MATERIALIZED VIEW sales_daily_mv AS
SELECT date_trunc('day', created_at) AS dt, product_id,
        SUM(amount) AS total, COUNT(*) AS n
FROM sales
GROUP BY dt, product_id;

SELECT * FROM sales_daily_mv WHERE dt = '2024-10-01';

Snowflake 自动改写 query 命中可视物化视图 (用 metadata 找 cover);后台 asynchronous refresh。但 $/byte 计算 → cost 主要 storage。


七、Hyperloglog / approximation

-- PG 默认 count 是 O(N), 大 OLAP 用 approximate HLL
CREATE EXTENSION hll;
SELECT hll_cardinality(hll_agg(user_id)) FROM logs;

-- ClickHouse uniq / uniqExact / uniqCombined
SELECT uniq(user_id), uniqExact(user_id) FROM logs;

HLL ~3% error,1KB filter ~10^9 distinct items → saves memory + 时间。


八、产线案例

8.1 大表 group-by 几小时 → 加 rollup 后 几秒

100亿 sales group by (day, product) → 30 分钟 → rollup 30k row → 3 sec 查询。

8.2 Druid rollup 不频繁导致 query 加爆

旧数据 1 day rollup 100% raw 仍 → ETL job 频率没达到 30 min 一夜 → query 引擎 scan 6× 数据 reduced 到自然 limit

修复:Druid indexRealtime 提高聚合 ingest 次数 + bend at hour rollup ingest 1 min batch.

8.3 物化视图未刷新误以为 query 旧

PG 9.3 没 REFRESH CONCURRENTLY,刷新锁表 → 业务误以为慢,业务 skip → 数据漂移。

修复:升 PG 9.4+ + REFRESH CONCURRENTLY + cron 30min 刷新。

8.4 Cube.js pre-aggregation 大规模膨胀

某 dashboard pre-agg 30k row × dims 5 但 D = 100 × 7 × 1000 = 700k 组 → quota 大。

修复:pre-agg dims 减 3 + 引 high cardinality 单独 cube (用户 specific 减 K use approximate)

8.5 Snowflake materialized view 全 table 1 TB 加倍钱

MV storage 双副本 fact + MV → 业务调 15+ MV → 总 cost storage 30 TB 给 10 TB raw

修复:MV 5 同时舍弃 + HLL 不是 raw count。


九、易错清单

  1. Rollup 后 history retention:rollup 不消耗 raw → 仅到 30 days;rollup 长期保留
  2. MV refresh 后 query 实时一致:MV 是 snapshot + 故业务报表万物万 now feature
  3. HLL approximation:3% 误差是多少 → 直播 report 通常接受;财务一定需 native
  4. Insert on rollup table 业务必向:业务 INSERT batch 慢慢 with raw row 一古
  5. Star schema joins 一定简化:均匀 planner join,uniform 接口 select 横向 输料

十、这一章带走的东西

  1. Star Schema + rollup + MV 让聚合查询 O(N) 变 O(N/group)
  2. PG 9.4+ REFRESH CONCURRENTLY,老 PG 难升
  3. Druid / Pinot ingestion 时聚合 → 体积 1000× 缩
  4. ClickHouse AggregateFunction state + AggregatingMergeTree 是 OLAP 黑 magic
  5. Cube.js semantic layer 让前端不害怕 rollup 设计
  6. HLL / T-digest 让 distinct count 内存搏 5KB → billions items

下一节 →

Lakehouse:Iceberg / Delta / Hudi — metadata server 中关键 cap, potential 优化 storage

Lakehouse:Iceberg / Delta / Hudi

TL;DR

Lakehouse = ACID transactions + open storage format + query engine optionality. 取代之前 Hadoop HDFS + Hive metastore 架构。三个主流格式: Iceberg (Netflix)、Delta Lake (Databricks)、Hudi (Uber)。本节走完三者入库的 metadata 层差异、time travel、Z-order、partition evolution、merge-on-read vs copy-on-write 的权衡、生产迁移案例。


一、Lakehouse 是什么

旧有的大数据 stack(Hadoop+Hive+HDFS)有两大痛点:

  1. 没有 ACID → ingest + query 同时不可保证 snapshot
  2. Hive metastore 锁住 → engine lock-in

Lakehouse 设计点:

  • 底层:cloud object store (S3/ADLS/GCS)
  • 上层:open table format (Iceberg / Delta / Hudi metadata)
  • Query engine:Spark, Trino, Flink, Snowflake 等都 read 同一 table

二、Iceberg (Netflix 2017, Apache 2020)

2.1 Metadata 层

table/metadata/v3.metadata.json (snapshot)
table/data/00001-file-abc.parquet
table/data/00002-file-def.parquet

v3.metadata.json:
    - current-snapshot: s2
    - snapshots: s0 / s1 / s2
    - snapshots[s2]: 
        - manifest-list: manifest-xxx.avro
        - manifests[0]: list of data files
        - data file: path + stats (record_count, min/max per col)
  • 每次 commit 新 metadata.json
  • version history tiden fasta → time travel 基础

2.2 Snapshot isolation

  • 不修改 file,每 commit 加一行新文件 + manifest
  • query 走 manifest + select 当前 snapshot 看到的 file → snapshot 一致
  • writer 不锁 reader

2.3 Partition Evolution

旧 Hive partition schema 隐式有 -> data 目录路径 (/dt=2024-01-01/)

  • 改 partition schema 必重写历史 → 灾难

Iceberg partition 是 hidden:

  • partition spec 写 metadata
  • 改 spec → 新 commit → 后 只新 file 用新 spec
  • old file 仍走老 spec
  • query 引擎同时读多 spec → hidden partitioning

2.4 Z-Ordering

ALTER TABLE events REORDER BY zorder(user_id, region_id);

Iceberg v2+ 支持 Z-order multi-column。Z-curve 让多维 locality 集中在相同 data file,query 时多 group by 列 min/max skip 大。

2.5 Iceberg 流式 streaming

DataStream.of(...)
    .write(new IcebergSink(events_table, new StreamingWriteOptions()));

支持 per-micro-batch commit;Flink / Spark streaming embedded。


三、Delta Lake (Databricks 2017, OSS 2019)

3.1 Metadata

_delta_log/00000000000000000000.json
_delta_log/00000000000000000001.json
...

每 commit 一个 JSON:

{
  "add": {"path": "part-abc.parquet", ...},
  "remove": {"path": "part-def.parquet", ...},
  "metaData": {...}
}

checkpoint JSON 每 N commit 压成 parquet 加速 large。

3.2 特点

  • 进入 Databricks ecosystem 主
  • ACID + MERGE / UPDATE / DELETE 的 first class
  • 比 Iceberg 稍slow metadata read (每 commit JSON,没 manifest)
  • Time travel 传统 SELECT * FROM table VERSION AS OF 12
  • 也可 PySpark read + write 非 Databricks cluster

3.3 优势

  • Databricks 版有 Optimize + ZOrder 命令一键 ops
  • <schedule OPTIMIZE> 后台 merge 小 file 大
  • VACUUM 旧文件回收

3.4 open sourcing

Delta OSS (Delta 0.x) 与 Databricks Runtime 版本分; 社区 patched Iceberg 加 cross-compatible Delta-Iceberg connector。


四、Apache Hudi (Uber 2017, OSS 2018)

4.1 设计目标

Hudi 来自 Uber 业务:每秒 1 mil taxi trip ingest + update(CRUD),需要 亚 second consistency snapshot. 答案:upsert 大量 raw key + 状态 metadata.

4.2 两种 mode

Copy-on-Write (CoW):

  • 每写更新 re-write 包含该 key 的 file
  • 适合 read-only 小的 update freq

Merge-on-Read (MoR):

  • update 进 row log file 合并 read 时 merge
  • 写吞吐高,read 有 merge overhead
  • 适合 high-ingest + 后台 async merge

4.3 metadata

.hoodie/timeline/commit_<ts>.deltas / log file refs

> key -> record info
> Ingest-time 状态 / partitioner / Merge

sources:Hudi 0.10+ 可建成 integration Iceberg metadata compatibility (Iceberg-Hudi (one project))。


五、三者对比

维度IcebergDelta LakeHudi
起源NetflixDatabricksUber
metadatamanifest + avroJSON logtimeline
StreamingFlink / Spark Sqlnative Databrickswhim primary use
Z-order✅ table✅ OPTIMIZE ZORDERinsert partition layout
Update✅ COPY-then-rewrite✅ MERGE INTO✅ CoW / MoR
Time travel✅ AS OF✅ VERSION AS OF✅ Time-travel
Engine lockopenopen (OSS + ver)open
大规模更新有改造最稳自
实时 ingeststreaming Flink nativestreaming Databricks 优最舒适 Streaming

六、产线案例

6.1 Iceberg 1PB 评个大 query

Presto / Trino 查 PB Iceberg:metadata 在 manifest 一边 → query 中 filter min/max 绝 most skip + collection aggregate。 1 PB Iceberg query:30 sec metadata + ~5 sec per scan region。

6.2 Delta OPTIMIZE ZORDER 后 30x quicker

业务 Spark 500TB 表 GROUP BY (col1, col2, col3) 100 keys,delta optimize ZORDER 后 skip ratio 90% 从 10% → query 100 sec 跌 3 sec.

6.3 Hudi MoR high ingest + slow read

Taxi trip 50k/s 写 Hudi MoR,read 5 min lag。但 daily ingestion compaction saga 没跑 → file 数 bolting → read slow 乘 10x.

修复:autoclean 配 sync compaction jobs,每 hour compaction。

6.4 Hive metastore 迁 Iceberg

业务千表 Hive metastore 数 + hadoop 长期 scare → migrate。

修复:用 migrate_tablespark.catalog.sanityCheckmigrate_hive_table 路径 → 1 week 工立迁 → table metadata Iceberg 替代 hive metastore → 不影响 query。

6.5 Delta 1B small file problem

业务每分钟 INSERT small file, 30 天 → 30k+ files under partition 目录。Spark scan 起 metadata overhead 5 SEC.

修复:启 OPTIMIZE 定期跑 + AUTO COMPACT; 后设置 targetFileSize = 256MB。


七、易错清单

  1. Iceberg partition evolution 改后 old files 仍按老 spec,必 query engine 兼容多 spec
  2. Delta versions JSON 1 0 commit json + 仅在 delta-log;checkpoint 帮助中 in query 过 metadata best load
  3. Hudi MoR async compaction scheduled ring - 加 search log compaction not 裡 Slat.
  4. **Cross-engine compat **: e.g. Snowflake Iceberg support (早期) read only, write 需 Spark+
  5. ZORDER 非对 single col query 用户 hit 用 5 col 同时快; 单 用 col 不如 sortedtable)
  6. Lakehouse not Lake+wheart 非 hadoop lake — 是 ACID 加 复 jedno 非 即用

八、这一章带走的东西

  1. Lakehouse = cloud storage + open table format metadata + engine optionality
  2. Iceberg = manifest tree + hidden partition + Z-order; Netflix/Apple 等大批 adopt
  3. Delta Lake = JSON log + Databricks 适配; Spark 主用
  4. Hudi = MoR/CoW + streaming primary; Uber 设计
  5. Optimize ZORDER 极大增益 multi-col GROUP BY/BY WHERE query
  6. Migration from Hive Metastore 到 Iceberg 通常 1 周, 业务影响 | Δ critical gate
  7. Snowflake + BigQuery 都有 Native Iceberg Support; 跨 engine 阅读 Lambda OLAP 没有 lock-in project

下一部分 →

第五部分 · 编译原理 — 从 lexer→parser→sema→opt→codegen 一线 pipeline

第五部分 · 编译原理

一句话

编译器把高级语言翻译成机器码的过程就是"理解→变换"的流水线:源文本 → token → AST → 语义检查 → IR → 优化 → 汇编/机器码。每一层都是形式系统的工程化。

章节

读完应能回答:正则为什么不够描述嵌套结构、LR vs LL 的差异、SSA 为什么使优化简单、escape analysis 怎么做、tiered compilation 的价值、寄存器分配为什么是图着色、LLVM 与 cranelift 设计差、V8 JIT 5 阶段 tier、 escape analysis 与 LTO 的关系。

词法分析 (Lexer)

这一节

正则 → NFA → DFA / 表驱动词法

TL;DR

词法分析器 (Lexer/Scanner) 把源文本切成 token 流。常见做法是正则 → NFA (Thompson 构造) → DFA (子集构造) → 表驱动跳转。本节走完 Thompson NFA、power set DFA、最小化 (Hopcroft)、表驱动 vs re2c 直接生成 C 状态机、flex 的扩展 max-munch 规则、最长匹配、回溯 vs DFA 字符串字面量混合 lexer 实战。


一、为什么正则 + 自动机

正则表达式 = regular language (Type-3 grammar in Chomsky hierarchy),对应 NFA/DFA 描述。lexer 工作就是"识别 token 的有限状态机"。

正则的"原子语言不描述 nesting"——这是为什么 parser 必须用上下文无关文法 (CFG, Chomsky Type-2)。下推下推栈。

二、Thompson 构造 NFA

正则 e 转 ε-NFA recursive:

'a'            [start] --a--> [accept]
e1|e2          [start] --ε--> NFA(e1)
               [start] --ε--> NFA(e2)
                     accept(e1) / accept(e2)  --ε--> [accept]
e1e2           [start] --ε--> NFA(e1).start
               NFA(e1).accept --ε--> NFA(e2).start
               NFA(e2).accept --ε--> [accept]
e*             [start] --ε--> NFA(e1).start
               [start] --ε--> [accept]
               NFA(e1).accept --ε--> NFA(e1).start  (回 loop)
               NFA(e1).accept --ε--> [accept]

每操作加 2 状态 (start + accept)。总 NFA 大小 O(|e|)。

三、子集构造 NFA → DFA

def subset_construction(nfa):
    dfa_start = ε-closure(nfa.start)
    dfa_states = [dfa_start]
    dfa_transitions = {}
    queue = [dfa_start]
    while queue:
        S = queue.pop()
        for c in alphabet:
            U = ε-closure(move(S, c))
            if U not in dfa_states:
                dfa_states.append(U)
                queue.append(U)
            dfa_transitions[S, c] = U
    accept_states = [S for S in dfa_states if any(nfa.accept in S for s in S)]

最坏 O(2^n) 状态数(指数),但实际 lexspec 中表现好。

四、Hopcroft 最小化 DFA

def hopcroft(dfa):
    P = {accepting, non-accepting}
    W = {{accepting}, {non-accepting}}
    while W:
        A = W.pop()
        for c in alphabet:
            X = {s | move(s, c) in A}
            refine P and W by split on X
    return P

O(n log n) 时间。最小化后每状态语义更清晰。

五、表驱动 lexer

// 状态表
int transitions[MAX_STATES][256];  // ' ' = current state, char = next
int is_accept[MAX_STATES];

int lex() {
    int state = 0;
    int last_accept = -1;
    int last_pos = 0;
    char *p = input;
    while (*p) {
        state = transitions[state][(unsigned char)*p++];
        if (is_accept[state]) {
            last_accept = state;
            last_pos = p - input;
        }
    }
    return token_type[accept_state];
}

特点:稳定、易生成;查表一个 byte lookup 几纳秒。LLVM tablegen / flex 生成该代码。

六、re2c 直接生成 C 状态机

re2c 不做表 — 把 DFA 直接 emit C 代码:

// re2c
/*!re2c
    [0-9]+  { return NUMBER; }
    "if"    { return IF; }
    [a-zA-Z_][a-zA-Z0-9_]* { return IDENT; }
*/

转成:

{
    int yych = *cursor++;
    switch (state) {
    case 0:
        if (yych >= '0' && yych <= '9') goto state_num_1;
        if (yych == 'i') goto state_kw_if_1;
        ...
    case state_num_1:
        if (yych >= '0' && yych <= '9') goto state_num_1;
        else return NUMBER;
    ...
    }
}

直接嵌入函数 — 比 flex 表驱动快 1.5-2x,无 indirect dispatch。PHP、ninja、Hack 用 re2c。

七、flex (最古老)

flex 是 GNU lexer generator,基于 C 表驱动:

  • %option 控制 noyywrap / case-insensitive / never-interactive
  • 默认 POSIX lex 兼容
  • 输出 lex.yy.c,yylex() 主入口

POSLEX 规则:

  1. 最长匹配 (max-munch): 多 rule 命中 → 选最长 input 的
  2. rule 优先: 同长 → 列文件中前 rule 优先
"if"      return IF;
[a-zA-Z]+ { return IDENT; }   // "ifx" 走这里而非 IF + X

八、关键场景 lexer 实战

8.1 Keyword vs Identifier

"if"   rule_1:  return IF
"iff"  (no rule)         → rule_2 IDENT  
"ifx"  match IDENT       ← max-munch 让 "ifx" 全 ID,not IF + IDENT

8.2 Comment 嵌套

/* outer /* inner comment */ 多嵌套 */

正则本身不足以处理嵌套。解决:状态机用 start condition (flex %x 区作用):

%x COMMENT
%{
    comment_depth = 0;
%}
%%
"/*"     { BEGIN(COMMENT); comment_depth = 1; }
<COMMENT>"/*"   { comment_depth++; }
<COMMENT>"*/"   { if (--comment_depth == 0) BEGIN(INITIAL); }
<COMMENT>.|\n    { /* skip */ }

8.3 字符串字面量含转义

\"([^\"\\]|\\.)*\"

正则描述即可,但 lexer 状态机模拟。flex 自身不需要 start condition 处理(正则够)。

8.4 here-doc Python triple-quote

"""multi line
"""

用 start condition + construct buffer state 处理缩进敏感。

九、错误恢复

lexer 错误恢复 minimalist:

  • 报错 row + col + 推进 1 byte
  • 继续给 parser

NASal: lexer 多 case error 不行 (semantic 错在 parser/semant)。

. {
    fprintf(stderr, "lex error at %d:%d: char `%c'\n", line, col, *cursor);
    cursor++;   // skip 1 char
    continue;
}

十、性能对比

工具输出形式性能
flex表驱动 C100 MB/s类似
re2c直接 C 代码200 MB/s现代项目
ANTLR4 lexerJava/Go runtime50 MB/s
hand-writtenswitch-case + 状态 reg200+ MB/shigh projects (Rust, V8)

V8 / Roslyn / rustc 全部 手写 lexer:状态用 switch,避免代码生成开销。

十一、易错清单

  1. max-munch:longest match 不只是 first match
  2. 正则没记 count/嵌套:context-free 才行 (push down)
  3. 状态机 lookahead 常需 KMP-like 算法以不可回 (so-called re2c : / = / gets flex yyless())
  4. CRLF / LF / CR 跨平台换行 lexer 应处理
  5. re2c -b -c --case-insensitive 注意 case folded range (ASCII vs UTF-8)
  6. lexer error recovery 通常 skip 1 char 不 strategy
  7. flex 中 yyless(n) 把 n bytes 推回 input,但对 backtrack 有性能感

十二、这一章带走的东西

  1. 正则 → NFA → DFA → min DFA 是 lexer 经典路径,O(n) worst-case 产 lexer 性能
  2. Thompson NFA 大小 O(|e|),subset 构造最坏指数、实际 1.x 线性
  3. 表驱动 (flex) vs 直接 C 状态机 (re2c) 性能差 1.5-2x
  4. max-munch + rule 优先级是 lexer 工具必要 POSIX 规则
  5. 嵌套 / 复杂字符串字面量用 start condition (flex %x / re2c conditions)
  6. 现代大编译器 (rustc/V8/Roslyn) 手写 lexer,省 codegen 开销
  7. 错误恢复应保守 1 字符 forward 推进

下一节 →

Unicode、字符集、错误恢复

Unicode、字符集、错误恢复

TL;DR

现代语言源码允许直接使用 Unicode:λ x → x (Agda)、let Σ = 1、JS emoji identifiers。lexer 必支持 Unicode Normalization Form、UTF-8 编码、代码点分类 (letter/digit/punctuation)、以及解析时的 fallback error recovery。本节走完 UTF-8/16 编码、Unicode 表 / \p{L}、字符集规范化 NFC/NFD/NFKC/NFKD、跨平台换行 (LF/CRLF/CR)、识别非法 byte 时 lexer 行为。


一、UTF-8 编码

UTF-8 是变长 1-4 字节:

0xxxxxxx                 1 byte  (U+0000 - U+007F)
110xxxxx 10xxxxxx        2 bytes (U+0080 - U+07FF)
1110xxxx 10xxxxxx 10xxxxxx  3 bytes (U+0800 - U+FFFF)
11110xxx 10xxxxxx 10xxxxxx 10xxxxxx  4 bytes (U+10000 - U+10FFFF)

UTF-8 优势:

  • ASCII 兼容
  • 单元自同步(任意 byte 可定位 stream 起点)
  • 字节序无关(不需要 BOM)

lexer 要识别"lead byte"决定后续长度 + 验证 4 byte UTF-8 必 ≤ 0x10FFFF + 不入 surrogate 区(D800-DFFF)。

二、UTF-16 与代理对

UTF-16 对 BMP (U+0000 - U+FFFF) 用 2 byte,对 supplementary 平面用 surrogate pair (高 D800-DBFF + 低 DC00-DFFF)。

JS string 是 UTF-16,所以 'a'.length === 1'😀'.length === 2

lexer 解 UTF-16 source code 必处理 surrogate pair,否则 identifier 报错一半。

三、字符分类

lexer 用 Unicode 类别(\p{L} letter,\p{N} number):

简写含义
\p{L}letter (含 Ll/lower Lu/upper Lt/title Lm/modifier Lo/other)
\p{N}number (Nd/Nl/No)
\p{P}punctuation
\p{S}symbol
\p{M}mark (Mn combining)
\p{Z}separator (space)
\p{C}other (control, surrogate)

XID_Start / XID_Continue 是 Unicode 标准 identifier 规则。

lex 执行:

ident_start = XID_Start | '_'
ident_continue = XID_Continue
#![allow(unused)]
fn main() {
fn is_ident_start(c: char) -> bool {
    c == '_' || unicode_xid::UnicodeXID::is_xid_start(c)
}
}

四、Normalization Forms

Unicode encoded 字符有等价 (e.g. ó 可 1 码点 U+00F3 或 2 码点 U+006F + U+0301 combining acute)。

  • NFC (Normalization Form Composed):组合 → 单码点
  • NFD (Decomposed):组合拆分
  • NFKC / NFKD:兼容性分解(含样式等价)

源文件 lexer 通常用 NFC,让 equivalent code points → same token。Python 3 PEP 263 要求 source code 是 UTF-8 UTF-16。

#![allow(unused)]
fn main() {
// unicode-normalization crate
let nfd: String = s.nfd().collect();
let nfc: String = s.nfc().collect();
}

五、跨平台换行

\n    LF    Unix
\r\n  CRLF  Windows
\r    CR    Classic Mac OS

lexer 公约:

  1. 把所有换行 normalize 成 LF
  2. column counter 维护基于 normalized column
  3. byte offset 仍按原始字节 (error report byte position)

Python compile() 把换行 normalize 成 LF:源 + identifier 一 致。

六、错误恢复

错误发生时 lexer 应仍可继续:

  1. 跳 1 byte 继续
  2. invalid UTF-8 byte 0xFF at line N col M
  3. 配合 IDE 行 / col 信息
#![allow(unused)]
fn main() {
fn lex(s: &str) -> Vec<Token> {
    let mut chars = s.chars().peekable();   // chars() 自检 UTF-8
    let mut tokens = Vec::new();
    while let Some(c) = chars.next() {
        match c {
            ' ' | '\t' | '\n' => continue,
            '0'..='9' => /* number */,
            _ if is_ident_start(c) => /* ident */,
            _ => {
                error(c);
                continue;
            }
        }
    }
    tokens
}
}

Rust str::chars() 把无效 UTF-8 替换 U+FFFD;lexer 仍继续。

七、产线实战

7.1 Rust identifier

Rust 支持 non-ASCII identifier:

#![allow(unused)]
fn main() {
let 用户 = "user";
}

unicode-xid crate 实现 XID_Start/XID_Continue literal check。unicode_xid::is_xid_start('用') true。

7.2 Hack customization

HHVM/Hack 允许 unicode id 但 report style: 与 lazy normalize,警告 non-ASCII identifier 出现在做 方_TAG_CAMÉL CASE。

7.3 Python coding:coding declaration

Python support # -*- coding: latin-1 -*- PEP 263 source code encoding declaration。Lex 文件字节、decode、tokenize 三道。

7.4 字符串字面量 buffered decode

某些 lexer 解 字符串字面量时应跳过 unicode decode 错误(保持 raw bytes)。e.g.:

#![allow(unused)]
fn main() {
let s = b"\xFF";   // raw byte, not UTF-8 string
}

八、易错清单

  1. UTF-8 byte length variable: 1-4 字节一个 codepont
  2. Surrogate pair D800-DFFF 必 reject 在 UTF-8 表达
  3. identifier: XID_Start not 仅 ASCII letters
  4. Σ 字符小写化 → NFC 等价 Σ 与 σ; 直等所有 known NFC text
  5. NFC 默认: source 通常 NFC normalized 提前 preprocess
  6. CRLF normalization 必留 byte offset 不变 / col 计数错位
  7. U+00A0 NBSP 不算 whitespace: lexer 若不理是 bug

九、这一章带走的东西

  1. UTF-8 1-4 byte variable, 自同步; UTF-16 with surrogate pair
  2. Unicode XID_Start/XID_Continue 是 lexer identifier 标准分类
  3. NFC normalize source 等价 Unicode normalization
  4. 跨平台换行 normalize LF 但保留 byte offset for error report
  5. Lexer UTF-8 invalid byte 必 报 + 跳 1 char 持续 继续
  6. Rust str valid UTF-8 invariant, lexer 允 [u8] 处理 raw bytes (代 char iteration)

语法分析

这一节

读完应能回答:

  • 左递归为什么让递归下降死循环? 表达式层为什么不消左递归而用 Pratt?
  • LR 栈里存状态而不存文法符号, 换来的是什么?
  • SLR 的 FOLLOW 近似为什么产生假冲突? LALR 合并为什么只新增 reduce-reduce?
  • GLR 在冲突处分叉后, 内存为什么不会指数爆炸?
  • PEG 的有序选择 / 与 CFG 的无序 | 差在哪? packrat 用什么换线性时间?

下一节 → 语义分析与类型系统

递归下降 / Pratt / 错误恢复

TL;DR

递归下降是手写 parser 的主流形态:每条产生式对应一个函数, 文法结构直接变成调用结构。它天然只吃 LL 类文法——左递归会无限循环, 公共前缀要提左因子; 而表达式层的优先级/结合性如果靠文法层级硬编码 (E/T/F 三层), 每加一个运算符就要改一串函数。Pratt parser 用一张结合力 (binding power) 表把整个表达式语法压进一个带 min_bp 参数的循环里: 左结合 l_bp < r_bp、右结合 l_bp > r_bp, 前缀/后缀/中缀同一框架。工业界 (rustc、Go gc、V8、Roslyn) 全部选择"递归下降 + 表达式用 Pratt"的手写路线, 为的就是错误恢复与 IDE 级诊断的质量——生成器给不了。

工程问题: 为什么现代编译器都抛弃生成器, 手写 parser?
  └─ 手写 = 文法的执行计划自己排
       ├─ 结构层: 递归下降 —— 函数即产生式, 控制流即推导
       │     └─ 代价1: 左递归死循环 ─► 消除改写 或 换迭代写法
       │     └─ 代价2: 优先级层级爆炸 ─► 表达式层换 Pratt
       ├─ 表达式层: Pratt —— binding power 单表定优先级+结合性
       │     └─ min_bp 是"当前右边的运算符至少要多强才能吃掉我"
       └─ 生产要求: 出错也要给出最好的诊断
             └─ panic mode 跳到同步集 + error AST 节点 + 全程携带 Span

一、文法前提: LL(1) 与两个坑

递归下降要求文法对每个非终结符、每个 lookahead 能唯一决定选哪条产生式 (LL(1))。教材表达式文法有两个经典坑:

E → E + T | T        ← 坑1: 左递归. parse_E 先调 parse_E ⇒ 无限循环
E → a b | a c        ← 坑2: 公共左因子. lookahead 只看 a 选不了分支

左递归消除的标准改写 A → Aα | βA → β A'; A' → α A' | ε, 但它把结合性藏进了循环语义, AST 构造反而别扭——所以实战中表达式层根本不这么干, 直接上 Pratt (下节)。公共左因子则机械地提取成 A → a B; B → b | c

判断"能不能写"用 FIRST/FOLLOW 集: 对每条候选产生式算 FIRST, 不相交才无回溯; 可空产生式还要求 FIRST 与 FOLLOW(A) 不交。这套检查正是表驱动 LL 的全部内容——表驱动 LL 和递归下降共享同一个理论 (LR 家族 那章的对照视角同样适用), 差别只在"表解释 vs 直译成代码"。

二、递归下降模板

以最小计算器为例 (Python 版可运行, Rust 版见下):

class Tok:
    def __init__(self, kind, val):
        self.kind, self.val = kind, val


def tokenize(s: str) -> list[Tok]:
    out, i = [], 0
    while i < len(s):
        c = s[i]
        if c.isspace():
            i += 1
        elif c.isdigit():
            j = i
            while j < len(s) and s[j].isdigit():
                j += 1
            out.append(Tok("num", int(s[i:j])))
            i = j
        else:
            out.append(Tok(c, c))
            i += 1
    out.append(Tok("$", None))
    return out


class Parser:
    """文法(已消左递归): E→T {± T}; T→F {*/ F}; F→(E)|-F|num"""

    def __init__(self, tokens: list[Tok]):
        self.toks, self.i = tokens, 0

    def peek(self) -> Tok:
        return self.toks[self.i]

    def next(self) -> Tok:
        t = self.peek()
        self.i += 1
        return t

    def expect(self, kind: str) -> Tok:
        t = self.next()
        if t.kind != kind:
            raise SyntaxError(f"want {kind!r}, got {t.kind!r} at #{self.i}")
        return t

    def parse_e(self) -> float:
        left = self.parse_t()
        while self.peek().kind in ("+", "-"):
            op = self.next().kind
            left = left + self.parse_t() * (1 if op == "+" else -1)
        return left

    def parse_t(self) -> float:
        left = self.parse_f()
        while self.peek().kind in ("*", "/"):
            op = self.next().kind
            right = self.parse_f()
            left = left * right if op == "*" else left / right
        return left

    def parse_f(self) -> float:
        match self.next().kind:
            case "num":
                return self.toks[self.i - 1].val
            case "(":
                e = self.parse_e()
                self.expect(")")
                return e
            case "-":
                return -self.parse_f()
            case k:
                raise SyntaxError(f"unexpected {k!r}")


def calc(s: str) -> float:
    p = Parser(tokenize(s))
    v = p.parse_e()
    p.expect("$")
    return v


if __name__ == "__main__":
    assert calc("1+2*3") == 7
    assert calc("(1+2)*3") == 9
    assert calc("-2*-3") == 6
    assert calc("8/4/2") == 1          # 左结合: (8/4)/2

三个结构性观察:

  1. 循环即结合性: while 收集同优先级运算符并向左折叠, 免费获得左结合 (8/4/2 = 1); 若写成递归调用就是右结合;
  2. 函数调用深度 = 语法嵌套深度: 这是递归下降唯一的性能/健壮性软肋, 深表达式 (机器生成的 JSONPath、超长链式调用) 会爆栈, 生产实现要么限制深度要么显式栈化;
  3. 错误位置精确: 每个 expect 都知道"此刻在等什么", 这是一切高质量诊断的原材料。

Rust 形态

#![allow(unused)]
fn main() {
enum Expr {
    Num(f64),
    BinOp(char, Box<Expr>, Box<Expr>),
}

struct Parser<'a> {
    toks: &'a [Token],
    pos: usize,
}

impl<'a> Parser<'a> {
    fn parse_e(&mut self) -> Result<Expr, Error> {
        let mut left = self.parse_t()?;
        while matches!(self.peek(), PLUS | MINUS) {
            let op = self.next();
            let right = self.parse_t()?;      // ? 向上传播而非 panic
            left = Expr::BinOp(op, Box::new(left), Box::new(right));
        }
        Ok(left)
    }
}
}

要点: 返回 Result 而非 panic——错误恢复的前提是错误是, 可以被收集、被恢复点消费; Box 装箱让 AST 节点尺寸均匀, 避免枚举被最大变体撑爆。

三、Pratt: 一张表吃掉所有优先级

三层文法 E/T/F 的痛点是每引入一个优先级就多一层函数。Pratt (1973) 把问题倒过来问: 解析一个表达式时, 我手里的 lhs 已经建好, 看到下一个中缀运算符时只关心一件事——它的结合力够不够强, 强到有权吃掉我作为它的左操作数?

BP = {"+": (1, 2), "-": (1, 2), "*": (5, 6), "/": (5, 6),
      "^": (11, 10)}          # (l_bp, r_bp); 左结合 l<r, 右结合 l>r


def pratt(p: "Parser", min_bp: int) -> float:
    lhs = nud(p)                              # prefix/atom
    while True:
        kind = p.peek().kind
        if kind not in BP:
            break
        l_bp, r_bp = BP[kind]
        if l_bp < min_bp:                     # 我的结合力不够, 让上层收走 lhs
            break
        p.next()
        rhs = pratt(p, r_bp)                  # 右边界=r_bp: 同级能否继续吃由它决定
        lhs = apply_op(kind, lhs, rhs)
    return lhs


def nud(p: "Parser") -> float:                # Next Unit of Derivation
    match p.next().kind:
        case "num":
            return p.toks[p.i - 1].val
        case "(":
            v = pratt(p, 0)
            p.expect(")")
            return v
        case "-":
            return -pratt(p, 12)              # 前缀 -: 结合力高于一切中缀
        case k:
            raise SyntaxError(f"unexpected {k!r}")


def apply_op(op: str, a: float, b: float) -> float:
    return {"+": a + b, "-": a - b, "*": a * b, "/": a / b,
            "^": a ** b}[op]


if __name__ == "__main__":
    def run(s: str) -> float:
        p = Parser(tokenize(s))
        return pratt(p, 0)

    assert run("1+2*3") == 7                   # * 比 + 强, 先吃 2 和 3
    assert run("(1+2)*3") == 9                 # 括号 = 显式重置为原子
    assert run("2^3^2") == 512                 # 右结合: 2^(3^2)
    assert run("-2^-3") == -0.125              # 前缀与右结合组合

读懂这 20 行, 就能读懂 Lua、Rust、rust-analyzer 表达式解析的核心:

机制含义
min_bp 参数"调用者允许我最多消费结合力多强的运算符"
l_bp < min_bp 判断当前运算符不够强 → 把 lhs 完整交还给上层
右边界传 r_bp左结合时 r_bp > l_bp: 同级留给本层循环继续折叠; 右结合 l_bp > r_bp: 同级递归下去给右边
nud/led 分工前缀位置 (数字/括号/一元负号) 与中缀位置的解析器分开注册

tip

记不住左右结合方向时想一个例子: a-b-c 必须 (a-b)-c-(l,r)=(1,2), 第二个 - 到来时 min_bp=2 > l_bp=1, 于是外层循环自己继续折叠——左结合; 而 ^(11,10), 内层递归 pratt(p, 10) 允许同级 ^ 继续吃右边——右结合。

四、错误恢复: 编译器的用户体验主战场

生成器默认遇错即停; IDE 场景用户边打字边触发语法错误, parser 必须跳过错误区继续产出近似 AST。

4.1 Panic mode + 同步集

最通用的一招: 出错时报告并丢弃 token 直到同步集 (语句边界 ; }、关键字开头)。语句级语言天然分层, 所以效果出奇地好:

STMT_START = {"let", "if", "while", "return", "{"}


def parse_block(p: "Parser") -> list:
    stmts = []
    while p.peek().kind not in ("}", "$"):
        try:
            stmts.append(parse_stmt(p))
        except SyntaxError as e:
            report(e)
            while p.peek().kind not in STMT_START | {"}", "$"}:
                p.next()                       # 跳到下一个可能的语句开头
    return stmts

4.2 Error productions 与 error AST 节点

更高阶的两招: error production 把常见笔误直接写进文法 (如"缺分号的赋值"), 给出针对性提示而不是泛泛的 syntax error; error AST 节点 在出错位置放占位节点让 AST 保持形状完整——下游类型检查可以照常跑完, 把所有错误一次性报全 (rustc/Roslyn 的核心体验)。配套纪律是永远不因为出错而返回 null: 缺失的表达式给 ErrExpr, 类型检查看到它就静默跳过。

warning

错误恢复最大的坑是连锁报错 (cascade): 一个真实错误被跳读放大成一屏虚假错误。对策: 报错去重 (同一 token 区间只报一次)、跳读距离过远时收敛为单条 "unexpected X"。诊断质量是手写 parser 相对生成器最值钱的差价。

五、产线观察

  • rustc: 手写递归下降 + 表达式 Pratt; 所有 AST 节点携带 Span; error AST + 恢复点设计成熟, 支撑了 Rust 生态著名的编译错误质量;
  • Go (gc): 手写递归下降, 恢复策略朴素 (跳到分号), 换来极快的解析吞吐——语言语法小、错误信息要求适度的正面案例;
  • V8: 手写 parser + lazy preparser (顶层函数先粗扫, 执行到才完整解析), 解析速度直接影响页面启动;
  • Roslyn (C#): 手写 + 数百条 error productions, 工业级恢复能力; 配合 immutable/red-green 树支撑 IDE 的增量重分析。

共同结论: 语法的体量与稳定性不再是瓶颈, 诊断质量才是——这是 2010 年代后新编译器全面回到手写的根本原因。生成器并未退场: 语法稳定且体量大的 DSL、配置语言仍适合 LALR 生成器

六、易错清单

  1. 左递归直接写: parse_A 第一件事是调 parse_A ⇒ 栈溢出; 表达式层用 Pratt, 其余场景改写文法;
  2. 结合方向记反: 左结合 l_bp < r_bp (同级留在本层), 右结合反之; 用 a-b-ca^b^c 双测例锁住行为;
  3. panic 后忘同步: 异常逃逸到顶层直接终止, 后续错误全部看不到; 每个循环边界都该有恢复点;
  4. lookahead 超预算: 递归下降默认只看 1 个 token; 需要 2 个时 (如区分 TypeScript 的 < 泛型还是小于), 明确封装成一个决策函数而不是散落各处;
  5. AST 丢 Span: 编译期偷懒不带位置, IDE 补齐时要重扫源码; Span 从 lexer 开始一路传递;
  6. 深嵌套爆栈: 对用户可控的嵌套深度设上限并给出友好错误, 别等 SIGSEGV。

七、这一章带走的东西

  1. 递归下降 = 产生式即函数; 循环折叠给左结合, 递归下沉给右结合;
  2. Pratt 用 binding power 一张表统一优先级/结合性/前缀中缀后缀;
  3. 错误恢复三板斧: panic mode 跳同步集、error production 定向提示、error AST 保形续跑;
  4. 诊断质量 (Span + 全量报错) 是现代编译器集体手写 parser 的原因;
  5. rustc / Go gc / V8 / Roslyn 四家产线形态各异, 但骨架都是"RD + Pratt"。

下一节 → LR/LALR/SLR/yacc/bison

LR / LALR / SLR / yacc / bison

TL;DR

LR 系列是表驱动的自底向上分析器:从左到右扫描(Left-to-right),产出最右推导的逆(Rightmost derivation in reverse)。核心数据结构是两张表——ACTION(状态 × 终结符 → 移进/归约/接受)和 GOTO(状态 × 非终结符 → 状态)。LR(0) / SLR(1) / LALR(1) / LR(1) 的全部差异只在归约时用什么信息做 lookahead 判断状态是否按 lookahead 合并;yacc/bison 生成的是 LALR(1),用优先级/结合性声明手工消解冲突,实在不行还有 %glr 兜底并行探索。

工程问题: 手写解析器处理大语法会失控
  └─ 为什么? 上下文无关语法的决策点互相纠缠, 人脑跟踪不了几百个产生式的 lookahead
       └─ 原理: 把"决策点"物化成有限状态的转移 —— 项目集构造出 DFA
            └─ LR(0): 只看栈顶状态, 太弱
                 ├─ SLR: 借 FOLLOW(A) 过滤归约 —— 一行改进
                 ├─ LALR(1): 精确 lookahead 但合并同核状态 —— yacc/bison 的选择
                 └─ LR(1): 不合并, 状态爆炸但能力最强 —— 理论上限

一、LR 分析算法本身

移进-归约(shift-reduce)框架:

stack:  s0 s1 ... sk              (状态栈, 附带符号栈)
input:  a_k+1 ... $               (剩余输入)

action[sk, a]:
    shift s'   → 压入 s'
    reduce A→α → 弹出 |α| 个状态, 露出 s_j,
                 再压 goto[sj, A]        (符号栈同步弹出 α 压入 A)
    accept     → 解析成功
    error      → 报错恢复

关键观察:栈里存状态而不是文法符号,因为状态编码了"到目前为止已识别出的句柄前缀"的全部信息——这就是 LR 比 LL 强的根源:它已经看见了整个左文法的上下文。

二、LR(0) 项目集规范族

项目(item)= 在产生式右部加一个位置标记:A → α · β

  • closure(I): 若 A → α · Bβ ∈ I,则把所有 B → · γ 加入 I,直到不动点。
  • goto(I, X): { A → αX · β | A → α · Xβ ∈ I } 再求 closure。
  • S' → · S 出发,对全体非终结符/终结符反复求 goto,得到的项目集就是 DFA 的状态。

经典算术文法跑一遍:

(0) S' → E        (3) T → T * F
(1) E → E + T     (4) F → ( E )
(2) E → T         (5) F → id
    T → ( E ) | id  即 T → (E) 与 T → id

状态 I1 含 E → E · + TS' → E ·——在输入 + 时移进、在 $ 时接受,不冲突。这个文法是 LR(0) 的:不需要任何 lookahead 就无歧义地决策。真实语言很少这么幸运,于是有了下面三个层级。

三、SLR / LALR / LR(1):只差"什么时候敢归约"

归约 A → α 的时机判断是三者的分水岭:

方法归约条件状态数冲突倾向工程地位
LR(0)无条件归约最小大量 shift-reduce教学基线
SLR(1)lookahead ∈ FOLLOW(A)同 LR(0)中等几乎没人直接用
LALR(1)精确 lookahead,但同核状态合并同 LR(0)/SLR可能引入 reduce-reduceyacc/bison/IELR 前
LR(1)精确 lookahead,不合并可指数膨胀几乎没有理论上限、部分生成器

note

LALR 合并同核(core 相同、仅 lookahead 集不同)的 LR(1) 状态后,永远不会新增 shift-reduce 冲突(shift/goto 转移完全由 core 决定),只可能新增 reduce-reduce 冲突——这是理解 bison 行为的钥匙。

SLR 的弱点示例:S → L = R | RL → * R | idR → L。状态含 S → L · = RR → L ·,遇 = 时 SLR 因 = ∈ FOLLOW(R) 而报 reduce-reduce;但任何以 L 开头且后面跟 = 的合法句子根本不会走 R → L 这条路——FOLLOW(R) 是全局近似,太粗。LR(1) 用精确 lookahead 区分两种局面,LALR 合并后此例仍保留区分(两状态 core 不同)。

四、bison / yacc 实战

%{
#include <stdio.h>
%}

%union {
    int ival;
    char *sval;
}

%token <ival> NUMBER
%token <sval> IDENT

%type <ival> expr

%left '+' '-'
%left '*' '/'
%right UMINUS

%%

expr: expr '+' expr   { $$ = $1 + $3; }
    | expr '-' expr   { $$ = $1 - $3; }
    | expr '*' expr   { $$ = $1 * $3; }
    | expr '/' expr   { $$ = $1 / $3; }
    | '-' expr %prec UMINUS { $$ = -$2; }
    | NUMBER          { $$ = $1; }
    | '(' expr ')'    { $$ = $2; }
    ;

要点:

  • %union 定义语义值类型 discriminant,%token/%type 给每个符号标注成员
  • %left %right声明顺序从低到高定优先级,同时解决 shift-reduce 与结合性(a-b-c 解析为 (a-b)-c
  • %prec UMINUS 给规则借虚拟 token 的优先级——一元负号必须高于 *
  • $$ $1 $3 直接访问语义栈;bison 默认输出 LALR(1)
  • 出现 conflict 时 bison 默认"shift 优先"并继续,务必看 .output 文件或开 %define parse.error detailed 排查,默认静默是事故源

warning

bison 对冲突的默认处理是"选 shift、报告一行 warning"。CI 里应加 -Werror=conflicts-sr -Werror=conflicts-rr 让带冲突的 grammar 直接编译失败,否则语法错误会被悄悄吞掉。

用 Python 从零构造 SLR 表并驱动解析

生成器的本质不过百余行——closure/goto/FIRST/FOLLOW/建表/驱动全链路:

# 文法: E→E+T|T, T→T*F|F, F→(E)|id   (增广 S'→E)
G = {
    "S'": [["E"]],
    "E": [["E", "+", "T"], ["T"]],
    "T": [["T", "*", "F"], ["F"]],
    "F": [["(", "E", ")"], ["id"]],
}
NT = set(G)                      # 非终结符
TS = {"+", "*", "(", ")", "id", "$"}

def closure(items):
    out = set(items)
    changed = True
    while changed:
        changed = False
        for (A, rhs, dot) in list(out):
            if dot < len(rhs) and rhs[dot] in NT:
                for prod in G[rhs[dot]]:
                    if (rhs[dot], tuple(prod), 0) not in out:
                        out.add((rhs[dot], tuple(prod), 0)); changed = True
    return frozenset(out)

def goto(items, X):
    return closure({(A, tuple(rhs), d + 1) for (A, rhs, d) in items
                    if d < len(rhs) and rhs[d] == X})

start = closure({("S'", ("E",), 0)})
states, work, trans = [start], [start], {}
while work:                       # 子集构造
    I = work.pop()
    symbols = {rhs[d] for (A, rhs, d) in I if d < len(rhs)}
    for X in symbols:
        J = goto(I, X)
        if J not in states: states.append(J); work.append(J)
        trans[(states.index(I), X)] = states.index(J)

def first(symset):                # FIRST 集 (本例手写足够)
    f = {nt: set() for nt in NT}
    f["F"] |= {"(", "id"}; f["T"] |= f["F"]; f["E"] |= f["T"]
    return f

follow = {"S'": {"$"}, "E": {")", "+", "$"}, "T": {"+", ")", "*", "$"},
          "F": {"+", ")", "*", "$"}}          # 本例可直接推出

action = {}                       # SLR 表: 移进 + 按 FOLLOW 过滤的归约
for i, I in enumerate(states):
    for (A, rhs, d) in I:
        if d < len(rhs):
            a = rhs[d]
            assert (i, a) not in action or action[(i, a)][0] == "s"
            action[(i, a)] = ("s", trans[(i, a)])
        elif A != "S'":
            for a in follow[A]:
                if (i, a) in action: raise SystemExit(f"reduce-reduce at {i},{a}")
                action[(i, a)] = ("r", (A, len(rhs)))
        else:
            action[(i, "$")] = ("acc",)

def parse(tokens):                # 表驱动 LR 主循环
    stack, ip = [0], 0
    while True:
        st, a = stack[-1], tokens[ip]
        act = action.get((st, a))
        if act is None: raise SyntaxError(f"unexpected {a!r} at token #{ip}")
        if act[0] == "s":
            stack.append(act[1]); ip += 1
        elif act[0] == "r":
            A, n = act[1]
            for _ in range(n): stack.pop()
            stack.append(trans[(stack[-1], A)])
        else:
            return "accept"

print(parse("id * ( id + id ) $".split()))

Go 版驱动器(消费同样的表结构,展示运行时形态):

package main

import "fmt"

type action struct{ kind byte; num int } // 's'hift / 'r'educe / 'a'ccept

// actionTable/gotoTable 由上文构造过程离线生成后内联于此
var actionTable = map[[2]int]action{
	{0, 3}: {'s', 4}, {0, 2}: {'s', 5}, // ...完整表由生成脚本导出
}

var gotoTable = map[[2]int]int{
	{0, 1}: 8, // ...
}

func parse(tokens []string) bool {
	stack := []int{0}
	for ip := 0; ; {
		act, ok := actionTable[[2]int{stack[len(stack)-1], tokID[tokens[ip]]}]
		if !ok {
			return false // 报错恢复入口
		}
		switch act.kind {
		case 's':
			stack = append(stack, act.num); ip++
		case 'r':
			n := popCount[act.num] // 各产生式右部长度
			stack = stack[:len(stack)-n]
			stack = append(stack, gotoTable[[2]int{stack[len(stack)-1], lhsOf[act.num]}])
		case 'a':
			return true
		}
	}
}

var tokID = map[string]int{"(": 0, ")": 1, "*": 2, "+": 3, "id": 4, "$": 5}

func main() { fmt.Println(parse([]string{"id", "*", "(", "id", "+", "id", ")", "$"})) }

tip

面试/实战口诀:"SLR 看 FOLLOW,LALR 合同核,LR(1) 全展开"。三者状态数关系:LALR = SLR = LR(0) ≤ LR(1)。

五、GLR:冲突的兜底方案

LALR(1) 处理不了的真正二义(不是文法写错,而是语言本身就依赖后续信息才能裁决)——典型如 C 的 typedef 歧义:(T)*-1T 是类型还是变量,决定这是强制转换还是乘法,而答案要等符号表才能给出。做法有两条:

  1. lexer hack: 词法阶段查询符号表,把 typedef 名吐成独立 token 类别(GCC 经典做法,简单但让词法依赖语义)。
  2. GLR(Generalized LR, Tomita): bison %glr 模式。解析器照常运行,遇到冲突时分裂成多个并行栈同时探索所有分支,分支汇合时共享后缀,出错或唯一存活时收敛。语法层不再需要预先消歧,把裁决推迟到语义阶段。

代价:冲突路径并存期间内存随歧义度增长;正常无冲突路径上 GLR 与 LALR 性能相同,所以可以只在个别规则上标 %glr

六、产线观察

6.1 PostgreSQL:bison + re2c

SQL 语言的语法体量(数百条产生式)正是 LALR(1) + 优先级声明的甜点区。gram.y 配合词法器生成,靠大量优先级/结合性声明压住冲突。近年版本把词法从 flex 迁到 re2c:re2c 直接生成原生 switch 代码而非表解释循环,省一次内存间接寻址,词法吞吐明显更高——这与 DFA 表驱动词法 的"表解释 vs 直译"取舍一致。

6.2 SQLite:自带 Lemon

SQLite 不依赖外部工具链,内置了自己的 LALR(1) 生成器 Lemon:语法动作接口更干净(无全局变量约定)、生成的解析器线程安全且便于静态集成——嵌入式场景下"少一个构建期依赖"比工具先进性重要。

6.3 手写回归

rustc、Go 编译器、Clang 都选择了手写递归下降(见 recursive-descent.md):为的是定制错误恢复与 IDE 级诊断(预期集合、跳读到语句边界)、以及绕开生成器对语法形状的限制。趋势很清晰:生成器适合语法稳定、体量大、错误提示要求一般的场景;追求诊断质量就手写。

七、易错清单

  1. 忽视 bison 默认冲突策略:默认 shift 且静默,必须在 CI 中把 conflict warning 升级为 error。
  2. reduce-reduce 当成小问题:它说明文法在该处真二义,shift-reduce 还能靠优先级救,reduce-reduce 通常要改文法。
  3. 左递归不用改写:LR 天然吃左递归(这正是相对递归下降的优势);把教材上的左递归消除套路照搬过来反而劣化。
  4. 优先级声明只该用于表达式层:拿 precedence 解决语句层的冲突会把错误藏得更深。
  5. LALR 的合并效应:单独看每个状态都没问题、合并后冒出新冲突——排查时要看 .output 里合并后的状态而非原始 LR(1) 项。
  6. 错误恢复缺失:生成器默认遇错即停;生产 parser 需要 error token 规则跳到语句边界再续析。

八、一页速查

维度LR(0)SLR(1)LALR(1)LR(1)GLR
归约依据FOLLOW(A)精确 lookahead精确 lookahead全部分支
状态数最小同左同左指数级基于 LALR
新增冲突仅 reduce-reduce极少无(并行消化)
工具教学少见yacc/bison/LemonELR 生成器bison %glr
代表用户PostgreSQL/SQLite/Bash旧版 C++ 前端

读完应能回答:

  • 为什么 LR 栈里存状态而不存文法符号?
  • SLR 的 FOLLOW 近似为什么会产生假冲突?LALR 如何解决?
  • LALR 合并为什么不会新增 shift-reduce 冲突?
  • C 的 typedef 歧义有哪些解法?各自牺牲了什么?
  • bison 的冲突为什么必须在 CI 里当错误对待?

下一节 → GLR / PEG / packrat

GLR、PEG、packrat

TL;DR

当 LALR 的冲突表填不平、或手写递归下降的文法改写太痛苦时, 有三条现代出路: GLR 让 LR 解析器在冲突处分裂成并行分支同时探索, 把消歧推迟到语义阶段 (Tomita 的图结构栈共享汇合后缀); PEG 干脆换一套形式系统——有序选择 / 天然无二义, "第一个匹配赢"就是语义, 代价是最坏指数回溯; packrat 用记忆化把每个 (规则, 位置) 的结果缓存起来, 换 $O(n)$ 时间与可观的内存。tree-sitter 把 GLR 思路做进增量解析器统治了 IDE 生态; ANTLR4 的 ALL(*) 在运行时动态预测, 让 LL 文法也能直接写左递归。选型一句话: 要增量/容错 → tree-sitter; 要确定性小引擎 → PEG/packrat; 真二义语法 (C typedef) → bison %glr

问题: CFG 二义 / 冲突填不平, 又不想大改文法
  ├─ 保住 LR 家族
  │    └─ GLR: 冲突点分叉并行探索 ─► 图结构栈 (GSS) 共享后缀
  │          └─► 存活唯一分支即答案; 全死才报错 ─► 消歧交给语义阶段
  ├─ 换形式系统
  │    └─ PEG: sequence + 有序选择 + 谓词 (&x !x)
  │          ├─► 无二义 by construction (first-match-wins)
  │          ├─► 代价: 回溯最坏指数; 左递归天然不可行
  │          └─► packrat: memo[(rule,pos)] ─► O(n) 时间, O(n·规则数) 内存
  └─ 运行时自适应
       └─ ANTLR4 ALL(*) / tree-sitter: 预测失败再回溯/分叉, 增量复用子树

一、GLR: 分叉而不是报错

LALR 表里一个 (状态, lookahead) 出现 shift-reduce 或 reduce-reduce 冲突时, 普通 LR 只能按预设优先级硬猜。GLR (Tomita) 的选择是全都要: 复制解析栈, 每个动作各走一条, 之后每一步所有并行栈同步推进。

工程实现的核心数据结构是图结构栈 (Graph-Structured Stack): 分叉时新栈与旧栈共享未分叉的前缀; 不同分支归约到同一非终结符且抵达同一状态时, 后缀合并回同一个节点——否则歧义度 $k$ 会让内存随输入长度指数膨胀, GSS 把它压到多项式。

bison 里只需局部标注:

%glr-parser
%expect 0        // 允许的 sr 冲突数
%expect-rr 0     // 允许的 rr 冲突数

%%
decl : type ident ';'
     | type ident '(' params ')' ';'   // 与函数声明冲突? %glr 双线并走
     ;

语义动作在多分支并存时的约定: bison 会保留每个分支的语义值, 用户在归约动作里可以返回"待定"值, 等唯一分支存活后再裁决 (典型: C 的 T * x; 是声明还是乘法表达式, 由符号表在语义阶段回答)。正常无冲突的输入上, GLR 与 LALR 性能几乎相同——所以只对个别规则开 %glr, 不要全语法开启。

二、PEG: 有序选择换确定性

CFG 的 | 是"无序或", 二义了谁也不让; PEG (Ford, POPL 2004) 把它改成有序的 /: 先试左边的, 成功就绝不回头。加上两个语法谓词 &x (向后看必须匹配但不消费) 与 !x (必须不匹配), PEG 成为独立的识别系统:

expr   <- term (('+' / '-') term)*
term   <- factor (('*' '/') factor)*
factor <- number / '(' expr ')' / '-' factor
number <- [0-9]+ '!'?          # 例: 支持 3! 阶乘后缀

三个必须内化的性质:

  1. 无二义是构造出来的, 不是证明出来的——但代价是文法的组合性被破坏: S ← A / ABAB 永远没机会被尝试 (A 能吃掉的一定先被 A 吃掉), 直觉上等价的改写可能改变语言;
  2. 左递归不可行: E ← E '+' T 第一字符递归自己, 无限循环。要么机械消除, 要么用近年研究支持直接左递归的实现 (Tratt 等人的 packrat 变体);
  3. 回溯最坏指数: 嵌套可选结构 ((a/a/a)*) 上经典爆炸。

三、Packrat: 记忆化买线性

packrat 缓存每个 (规则, 输入位置) 的结果 (含失败), 同一位置同一规则的重复解析变成一次查表——最坏指数被压成 $O(n \times 规则数)$。下面是一个 ~40 行的最小引擎 + 组合子, 足以看清全部机制:

def lit(s):
    def rule(pos, text, parse):
        if text.startswith(s, pos):
            return s, pos + len(s)
        return None
    return rule


def ordered(*alts):
    """PEG 的 '/': 有序选择, 左边成功就绝不试右边."""
    def rule(pos, text, parse):
        for a in alts:
            r = a(pos, text, parse)
            if r is not None:
                return r
        return None
    return rule


def seq(*parts):
    def rule(pos, text, parse):
        out, cur = [], pos
        for part in parts:
            r = part(cur, text, parse)
            if r is None:
                return None
            out.append(r[0])
            cur = r[1]
        return out, cur
    return rule


def many(p):
    def rule(pos, text, parse):
        out, cur = [], pos
        while (r := p(cur, text, parse)) is not None and r[1] > cur:
            out.append(r[0])
            cur = r[1]
        return out, cur                      # 空匹配也成功 ⇒ * 语义
    return rule


def make_packrat(rules: dict):
    """memo[(rule, pos)] 同时缓存成功与失败两种结果."""
    memo, stats = {}, {"miss": 0, "hit": 0}

    def parse(rule: str, pos: int, text: str):
        key = (rule, pos)
        if key in memo:
            stats["hit"] += 1
            return memo[key]
        stats["miss"] += 1
        memo[key] = res = rules[rule](pos, text, parse)
        return res

    parse.stats = stats
    return parse


if __name__ == "__main__":
    rules = {
        "expr": ordered(
            seq((lambda pos, t, p: p("term", pos, t)), lit("+"),
                (lambda pos, t, p: p("expr", pos, t))),
            lambda pos, t, p: p("term", pos, t)),
        "term": ordered(lit("ab"), lit("a")),     # 有序选择: "ab" 优先
    }
    p = make_packrat(rules)
    _, end = p("expr", 0, "ab+x")
    assert end == 2                               # 只消费了 "ab"
    assert p.stats["hit"] >= 2                    # 回溯重入处全部命中缓存

四、Parser combinator: PEG 的类型化形态

Rust nom / Haskell parsec 把上述组合子做成库, 文法即代码:

#![allow(unused)]
fn main() {
use nom::{branch::alt, character::complete::{char, digit1},
          multi::many0, sequence::tuple, IResult};

fn expr(i: &str) -> IResult<&str, Vec<char>> {
    // term (('+'|'-') term)*  —— 组合子直译
    let (i, t) = digit1(i)?;
    let (i, rest) = many0(tuple((alt((char('+'), char('-'))), digit1)))(i)?;
    Ok((i, std::iter::once(t.chars().next().unwrap())
        .chain(rest.into_iter().map(|(op, _)| op)).collect()))
}
}

特点: 类型安全、零拷贝切片、编译期内联后性能接近手写; 但回溯边界由作者负责——组合子默认不缓存, 忘记 packrat 化就退回指数最坏。

五、ANTLR4 与 tree-sitter: 运行时自适应的两极

ANTLR4 (ALL(*)): 生成的 LL 解析器在预测失败时不放弃, 而是运行时模拟 ATN 自动机向前看任意远 (自适应), 用户完全无感; 直接支持左递归规则 (内部自动改写成优先级链)。DSL、协议描述、静态分析工具链的主力。

tree-sitter: 为编辑器而生——GLR 式运行时分叉消化歧义 + 增量重解析 (编辑几个字节只重析受影响区间, 子树按内容哈希复用) + 错误恢复内置 (任何时刻都能给出包含 ERROR 节点的完整具体语法树 CST)。GitHub 代码导航、neovim/Zed 高亮、符号跳转都跑在它上面。注意它产出的是 CST (连标点空白都在树上), 这是精确格式化与重构的基础, 也是它与普通 AST 工具的本质差异。

六、产线对比

工具技术吞吐量级内存适用
手写 RD + Pratt最高编译器本体
bison %glrGLR歧义时增长真二义遗留语法 (C)
ANTLR4ALL(*)DSL / 静态分析
nom / pest组合子 (+packrat 可选)低–中Rust 侧数据格式
packrat 全开PEG memo低–中$O(n \times$ 规则$)$小语言 / linter
tree-sitterGLR + 增量中 (增量后极高)编辑器 / 大仓导航

七、易错清单

  1. PEG 的 / 不是 CFG 的 |: 有序性让文法不再满足交换律, 重排候选顺序=换了语言;
  2. PEG 写左递归: 基础版无限循环; 要么消除, 要么确认实现明确支持直接左递归;
  3. packrat 当免费午餐: 缓存粒度、失效策略、内存上限都要设计; 只缓存热点规则常是最优解;
  4. GLR 全语法开启: 正常路径性能虽同 LALR, 但歧义度高的文法会让 GSS 膨胀; 局部 %glr;
  5. 把 CST 当 AST 用: tree-sitter 树上有标点/ERROR 节点, 遍历逻辑必须跳过匿名节点;
  6. 增量解析的边界条件: tree-sitter 复用依赖子树哈希, 宏展开类"远处影响近处"的语言特性需要额外标记范围。

八、这一章带走的东西

  1. GLR = LR + 冲突处分叉 + GSS 共享后缀; 消歧推迟到语义阶段;
  2. PEG 用有序选择构造出无二义, 代价是回溯指数与左递归禁手;
  3. packrat 记忆化 (规则, 位置) 换线性时间, 内存是它的账单;
  4. ANTLR4 ALL(*) 运行时自适应预测, tree-sitter 把 GLR + 增量 + 容错带进 IDE;
  5. 选型: 增量容错 → tree-sitter; 小而确定 → PEG; 真二义 → %glr; 造编译器本体 → 手写 RD + Pratt。

回到章首: 语法分析

语义分析与中间表示

类型系统、类型推断、HM 类型系

TL;DR

类型系统是编译器最 invariant 的 guarantee:每个表达式有唯一类型,运行时不需要 dynamic check。Hindley-Milner 是 ML/Haskell/OCaml/F# 的 polymorphic type inference 算法,自 1969 是 typing 历史。本节讲完 static vs dynamic、soundness (progress + preservation)、HM algorithm W、let-polymorphism、rank-N polymorphism、subtyping、Rust affine / linear type、dependent types、lifetime 系、effect 系统。


一、类型系统目标

类型系统是"定律 invariant":

  1. soundness: well-typed program does not get stuck (no type error at runtime)
  2. progress + preservation (Formal verification): every well-typed term reduces, reduction preserves type
  3. completeness: any typeable term compiles → runtime type-safe

Formal: type judgment Γ ⊢ e : T 是 partial function 给出 expression 的 type ∈ Types ∪ {error} ("stuck").

二、static vs dynamic

维度staticdynamic
检测时机compile timeruntime
性能nocostruntime check overhead
灵活性严格灵活动态 (heterogeneous list)
重构IDE 自动 sweep困难 (grep)
Debugtype-error 编译报runtime 错
examplesHaskell, Rust, Java, C#, GoLisp, Python, JS, Ruby

gradual typing (TypeScript、Reticulated Python) allows mixed: static + dynamic check, type annotation optional。

三、Hindley-Milner

HM (Hindley 1969, Milner 1978) 算法 W 是 ML 等的 type inference 算法。

3.1 类型 scheme

σ := ∀α.α → α                    -> identity type
τ := α | τ → τ | Int | String    // monotype

unification: unify(t1, t2) returns substitution S such that S t1 = S t2.

3.2 Algorithm W

W(env, e):
match e:
    Var(x) => instantiate(env[x]) → returns (subst, type)
    App(e1, e2) =>
        (s1, t1) = W(env, e1)
        (s2, t2) = W(s1 env, e2)
        α = fresh_var()
        s3 = unify((s2 t1), (t2 → α))
        return (s3 ∘ s2 ∘ s1, s3 α)
    Lam(x, e) =>
        α = fresh_var()
        env' = env + {x : α}
        (s1, t1) = W(env', e)
        return (s1, s1 α → t1)
    Let(x, e1, e2) =>
        (s1, t1) = W(env, e1)
        env' = s1 env + {x : Gen t1}     // generalise
        (s2, t2) = W(env', e2)
        return (s2 ∘ s1, t2)

Gen 是 generalize:把 monotype 中所有未被 env 约束的类型变量量化成 scheme(τ → ∀α.τ),这就是 let 绑定多态的来源——let id = fn x => x 之后 id 可以同时用于 intstring

3.3 Value Restriction

只有 let 右侧是语法值(variable / constant / constructor 全量应用 / fn 抽象)才允许泛化;含函数调用的表达式一律不泛化。否则与可变引用组合会破坏 soundness——经典的反例:

let id = ref (fn x => x)
id := true
!id(0)

OCaml/Haskell 都只对 let 绑定做泛化(let-polymorphism),且受 value restriction 约束:右侧必须是语法值才能泛化。参数多态(如 'a list -> 'a list 的显式泛型)不受此限,因为量化边界是显式写出来的。

四、Subtyping

S <: T  (S sub type of T)

substitutability: 需要 T 的位置都能放 S (OO 的 extends/implements 就是它的特例).

一旦 subtyping 与多态组合(Java 泛型 / Scala / C++ 模板 + 继承),principal type 一般不存在,完整推断不可判定(推断复杂度随类型构造器增长到不可解)。所以这类语言都退而求其次:泛型参数显式标注、推断只做局部(方法内),跨方法边界一律靠声明。

Γ ⊢ e : T   T <: T'
------------------T-SUB
Γ ⊢ e : T'

五、Rust affine / lifetime

Rust 类型系统是 affine / linear:每 owned value move 一次:

  • ownership: 每值 one owner
  • 借用: &T immutable, &mut T mutable exclusive
  • lifetime 'a 是借用区域 (static scope of valid ref)
  • borrow checker: 多 lifetime constraint 跨 region 一致
#![allow(unused)]
fn main() {
fn longest<'a>(a: &'a str, b: &'a str) -> &'a str {
    if a.len() > b.len() { a } else { b }
}
}

'a 最小 lifetime ∀ input (lifetime 参数)。Rust 推断 non-lexical lifetimes (NLL, 2018+) 让 ref 释放 time optimize in context-aware.

六、Dependent types

dep types 是 type-level dependent 于值:

Vector : Nat -> Type -> Type
Vector 0 a = VecNil
Vector (S n) a = VecCons a (Vector n a)

Idris / Agda / Coq / Lean. 适 formal proof, 设计 contract / invariant.

七、Effect system

Effect 类型系统描述计算 effect (Pure, IO, State a, Async):

Haskell IO monad deprivation side, Haskell State,OCaml effect handlers:

f :: State Int ()

Koka语言第一成 effects 类型化,ML computation effect effect handlers 一流.

Rust async 是 effect-type area: async fn returns impl Future<Output=T>, 类型与 Future type bound.

八、产线观察

8.1 Rust borrow checker NLL

2018 版 NLL 让 ref 在 last-use 自动 freeze 释放。某些之前拒绝 code 现 OK.

8.2 TypeScript structural typing

类型按 shape(结构)而不是名字匹配 (与 nominal 系 like Rust 的 NOT). value fit shape 可隐式 assignment, 但太弱编 type DSL.

8.3 Effect system 实战

Haskell IO monad 限制 side effect 在 necessary IO a. 性 arg 后给到 Haskell Hakell Embed-system 限副作用.

九、易错清单

  1. value restriction 在 OCaml let polymorphic only well-typed value 参考
  2. NLL rustc borrow checker modulo
  3. TS structural typing 边界; mood / nominal type of go interface Pola 亦 structural nominal
  4. Dependent types 一阶 type theory 限 Parser quotient, 可 DVN Construct explicit Idries 解释 程 objective
  5. Type-level computation O(1): rebase Go constraints O(U(0)) type.
  6. effect polymorphism is not yet cross-language standard (Koka is exception)

十、这一章带走的东西

  1. 类型系统保证 soundness (progress + preservation)
  2. HM algorithm W 是 ML 系 polymorphic type inference gold standard
  3. let-polymorphism 让 code reuse; value restriction 之处 ref cell 安全
  4. Rust affine 计: 一次 move 允; lifetime
  5. NLL (rust 2018) 让 borrows checker ergonomics 大 improvement
  6. Dependent types 是 ML extension 类型依赖值 Pi types 上 level Koka 6ef systems
  7. effects 像 monad IO async state asymptote represent syntactic high-life might transform colony 系; Koka《Haskell》non-ML systems eras

SSA / CFG / 支配树 / dom tree

TL;DR

SSA (Static Single Assignment) 是现代编译器中间表示:每个变量单次赋值。CFG (Control Flow Graph) 是基本块+边,支配树 (dominator tree) 给每基本块"唯一前驱节点",控制依赖 (post-dominator) 决定循环 / 分支归属。LLVM、Cranelift、V8 Turbofan、 Hotspot C2 全部走 SSA。本节走完为什么 SSA 让优化简单、dom 树算法 (Cooper-Harvey-Kennedy)、SSA 构造、φ 节点、constant folding / DCE / mem-to-reg 等基于 SSA passes。


一、CFG

CFG = (basic blocks, edges)

  • basic block: linear sequence of straight-line instructions, single entry single exit
  • edges: jump / fall through / branch
bb1: %a = 1
     br bb2

bb2: %cond = eq %a, 1
     cond_br %cond, bb3, bb4

bb3: %b = 2
     br bb5

bb4: %b = 3
     br bb5

bb5: %c = %b + 1
     return %c

二、SSA 形式

每变量单次定义。"分支合并" 在汇合点用 φ (phi) function:

bb1: %a0 = 1
     br bb2

bb2: %cond0 = eq %a0, 1
     cond_br %cond0, bb3, bb4

bb3: %b0 = 2
     br bb5(%b0)

bb4: %b1 = 3
     br bb5(%b1)

bb5(%bx): %c0 = %bx + 1  ← phi (%b0, %b1) chooses 收 b0 / b1
          return %c0

phi(%b0, %b1) 给 bb5 选择正确祖先版本。

三、为什么 SSA

非 SSA:
  int i = 0;
  while (i < N) { sum += a[i]; i++; }
  // i 在 loop 多次赋值,需要 phi 加 join,但代码没明示.

SSA:
  i0 = 0
  loop:
    i_phi = phi(i0, i_succ)      ← merge values
    cond = i_phi < N
    if !cond goto end
    sum_phi = phi(sum0, sum_succ)
    sum = sum_phi + a[i_phi]
    i_succ = i_phi + 1
    goto loop
end:
  return sum_phi
  • def-use chain 显式 — 一个 SSA 变量只 one def,use 一定能反查 def
  • 常量传播更直接:value 数 declines + multiple versions
  • DCE (Dead Code Elimination): unused variable → entire def 可删
  • 优化自动处理 alias: 不同 def/use 易跟踪

四、支配树 (dom tree)

Dominator set dom(b) = { 起点 + all blocks 必经 b }

  • idom(b) = immediate dominator (closest strict dominator)
  • 支配关系 form tree

Cooper-Harvey-Kennedy 算法 (2001) O(n α(n,n)) — fast:

for bb in reverse_post_order(cfg):
    idom[bb] = intersect(processed_preds(bb))

支配边界 (DF(bset)): 应插 φ 的基本块集合。

bb  has preds p1, p2
DF(bb) = bb ∪ DF(p1) ∪ DF(p2)   if bb has ≥2 preds

Φ 插入算法 (Cytron 1991):iter dominance frontier,每个 variable 算 DF ∩ definitions。

五、SSA 构造算法

  1. Cytron: 计算 DF, 插 φ, 变 rename (use SSA version).
  2. PrSSA / SSI: 边插 φ 在 split edge.
  3. loop closed SSA (LCSSA): 不让 loop value escape — 每变量 在 loop exit 插 φ.

六、SSA 上的优化 passes

6.1 Constant folding

%a = 1 + 2 → %a = 3

6.2 DCE (Dead Code Elimination)

%a = 1 + 2   # nobody uses %a   → 删

6.3 Common Subexpression Elimination (CSE)

%x = a + b
%y = a + b  → %y = %x

6.4 Global Value Numbering (GVN)

assign 一 ID 每 equivalence class,make same expr → 同一 SSA value.

6.5 Loop Invariant Code Motion (LICM)

循环不变量出循环.

6.6 Partial Redundancy Elimination (PRE)

用 SSA + dom 树做"工作量保" Partial available 计算, loop-out invariant expr.

6.7 Mem-to-reg

将 memory location 转 SSA reg (helps scalar replacement)

七、SSA destruction (out-of-SSA)

LLVM IR 是 SSA,但目标机有 register 而非 SSA. 必须移除 phi:

phi(%x1, %x2):
  
 %x1 = ...(estimate predecessor branch)
Store temp sẵn in each predecessor, e.g:
pred1: %src1 = %x1
pred2: %src2 = %x2
join:  %dst = phi(%src1, %src2)
→ rewrite as:
pred1: %x_dst = %x1
pred2: %x_dst = %x2
join:  uses %x_dst

Implement: "critical edge splitting" + 虚拟 move 移到 predecessor.

八、产线误用

8.1 phi node 与 critical edge

Critical edge: edge from bb with multiple successors to bb with multiple preds → 必须 split 加新 bb 防 phi 安装.

8.2 mem-to-reg 只 scalar

array / heap object 不参与 mem-to-reg, must use Scalar Replacement of Aggregates (SROA) 的 pass.

8.3 LCSSA 在 SLT 边界

入门 compiler 不实现 LCSSA 也能 work, 但 LICM out-of-loop 假设 LCSSA — 大 compiler (LLVM/GCC) 默认 LCSSA.

九、易错清单

  1. phi 节点:数量要与 predecessor 数匹配
  2. critical edge split: must add bogus 中间 bb
  3. ** SSA destruction** 必须正确 set/move 以保 program semantics
  4. dom tree 计算必 walk cfg in post-order / post-dominator 反之
  5. dead phi variable 不能直接删除,需要 use-def chain verify DCE
  6. Aliasing in mem-to-reg: multiple ub result name clash — 实现需要 alias analysis

十、这一章带走的东西

  1. SSA: 一变量一定义,让 def-use chain 显态; phi 在合并点
  2. CFG + dom tree 给 control flow 形式分析 / 支配边界 / branch analysis
  3. Cytron 1991 的 SSA construction algorithm 行业基础
  4. SSA 让 constant folding / DCE / GVN / LICM / PRE pass 写起来简单
  5. LLVM IR 是 SSA,cranelift 亦 SSA, V8 Turbofan / Hotspot IR SSA
  6. Phi 移除必 split critical edge + virtual moves in predecessor
  7. LCSSA force loop var in exit phi simplifies loop optimizations

优化

机器无关优化在 IR 上做,机器相关优化在指令选择后做。现代编译器把这两个阶段都用 SSA 形式表达——LLVM 在 IR 上做 Inline、GVN、DCE、LICM、向量化;HotSpot C2 用 Sea-of-Nodes 做 Ideal Graph 优化;Cranelift 用 CLIF IR。优化的本质是用更便宜的指令序列产生语义等价的程序,证明等价性靠数据流方程 + SSA 形式。

优化 pass 不是孤立的,而是迭代流水线:一个 pass 可能产生另一个 pass 的机会(Inline 后才能做 Constant Propagation,CP 后才能做 DCE,DCE 后才能做 Inline 的下一轮),所以编译器用 Fixed-point iteration 或 Worklist algorithm 反复跑到不动点或达到预算上限。LLVM 默认 O2 跑 ~60 个 pass,O3 跑 ~80 个,每个 pass 都有 cost model 决定是否值得做。

优化的代价模型

一条 pass 是否值得运行,编译器用三件事评估:

  1. 编译时间成本:pass 运行时间。Release 编译慢,但 debug 编译用 -O0 跳过所有优化 pass。
  2. 代码体积成本:Inline 让代码变大,可能 i-cache miss 反而变慢。
  3. 运行时收益:profile-guided optimization (PGO) 用真实运行 profile 决定哪些路径热,决定 inline 阈值、块布局、分支预测 hint。

LLVM 的 opt 把 pass 分两类:module pass 跨函数分析(Inline、LTO),function pass 单函数分析(DCE、GVN),loop pass 单循环分析(LICM)。pass manager 决定调度——分析 pass 缓存结果,转换 pass 修改 IR,依赖关系用Invalidation 传播。


下一节 → 常量折叠、复写传播、死代码消除

常量折叠 / 复写传播 / 死代码消除

TL;DR

这三者是 IR 上最廉价也最频繁的 pass:常量折叠在编译期计算常量表达式,条件常量传播 (SCCP) 直接消除死分支;复写传播用 SSA 等价别名替换 use site;死代码消除 (DCE) 靠活跃性分析 + 副作用标记删不可达指令。SSA 让这三者变得近乎 trivial——每个变量单次定义、use-def 链显式、支配关系提供"看到什么常量"的精界定。


一、为什么 SSA 是基础

观察这段 C:

int x = read();
int y = x + 1;       // x 不是常量
int z = 3;           // 常量
int w = z * 2;       // 折叠为 6

非 SSA 形式下,要做常量传播得跑 reaching definitions(哪个定义活到了当前点)。一个变量可能在 if 两个分支被赋不同值——非 SSA 得 merge,得到 T (top, 表示非常量)。

SSA 后每个变量唯一一次定义,常量信息就是 def 节点上的 lattice 值,use 直接查 def 即可。这就是为什么 LLVM、HotSpot、Cranelift 全部用 SSA——优化 pass 写起来直接 10 倍短。


二、常量折叠

编译期计算规则

int x = 1 + 2;        → int x = 3;
int y = x * 5;        → int y = 15;          // 配合常量传播
int z = 12 >> 2;      → int z = 3;
bool b = (3 > 2);     → bool b = true;

折叠规则表:

表达式条件折叠结果
a + b都是常量直接相加
a / bb == 0不折叠,留给运行时(C/Java 会抛异常或 UB)
a << bb ≥ bitwidth不折叠(C UB)或 mask(Java)
(int)(float)1.5都是常量折叠为 1
INT_MIN / -1都是常量不折叠(C UB,溢出)

IEEE 754 特殊性

浮点折叠有"陷阱":编译器不能随意结合浮点(GCC -ffast-math 可以,但破坏 IEEE 语义)。

// 不能折叠:(a + b) - b != a,因为 NaN/Infinity
double f(double a, double b) {
    return (a + b) - b;   // 若 b = Inf,a = 1,结果 = NaN,不等于 a
}

LLVM fadd 不带 fast flag 时保守不重排;Rust f32/f64 同样保守。

SSA 上的常量折叠

%a1 = 1
%b1 = 2
%c1 = add %a1, %b1          →   %c1 = 3
%d1 = mul %c1, 5            →   %d1 = 15

只要 def 节点是常量,use 节点直接计算即可。这是 IR 上的"代数化简" (algebraic simplification) 的子集。


三、条件常量传播 (SCCP)

SCCP (Sparse Conditional Constant Propagation) 是 Click & Cooper 1995 的经典算法——常量传播 + 死分支消除同时跑,比单纯 CP 强很多。

算法核心

Lattice 值:

  • (top): 未定
  • c (常量): 已确定为常量 c
  • (bottom): 非常量

分支指令 br %cond, bb1, bb2 中,若 %cond 是常量 true,则直接切断 bb2 的边——这让 bb2 中所有定义变成 unreachable,触发后续 DCE。

flowchart TB
    A[bb0: cond = true] --> B["br cond, bb1, bb2"]
    B -->|true| C[bb1: x = 1]
    B -->|false| D["bb2: x = kill -- unreachable"]
    C --> E[bb3: return x]

实例

int f(int x) {
    if (3 > 2) return x + 1;     // (3 > 2) 折叠为 true
    return x - 1;                // unreachable
}

→ SCCP 算一遍直接砍掉第二个 return。很多工程里的"魔法代码"——比如有 #ifdef DEBUG 包住的块——靠 SCCP 在 release 编译下整段消掉。

SCCP 的 Worklist 算法

worklist = {入口块}
while not empty(worklist):
    bb = pop worklist
    for inst in bb:
        new = lattice_eval(inst)         # 用 operand lattice 值推 inst lattice 值
        if new != lattice[inst]:
            lattice[inst] = new
            for use of inst:
                push use's block to worklist
    for succ in bb.succs:
        # 若分支条件已确定,只把真分支加入 worklist
        if lattice_eval(branch_cond) is constant:
            push succ_only_taken
        else:
            push succ_all

复杂度 O(N)(基于 SSA 稀疏性),跑一遍就到不动点。


四、复写传播 (Copy Propagation)

问题

int a = compute();
int b = a;          // 复写
return b + 1;

b 永远等于 a。所有 b 的 use 都可以替换为 a,让 b = a 变成死定义。

SSA 上的等价

SSA 让"是同一变量"变成"def 节点 id 相等":

%a1 = call compute()
%b1 = %a1                       ; 直接是 copy
%c1 = %b1 + 1     →   %c1 = %a1 + 1
                            ; %b1 无 use → DCE 删除

Copy Coalescing (一种特殊复写传播)

内存→寄存器提升后,会产生大量 { %1 = load %slot; ...; store %1 to %slot }。这样一个 copy 链在 SSA destruction 之前需要 coalesce——把等价 SSA 值合并到同一物理寄存器。这是寄存器分配的关键步骤,见 regalloc.md

不要传播的场景

  • 跨 alias group:int *p = &a; int b = *p; 不可认为 b == a,因为 *p 有副作用读。
  • 跨函数边界:b = a; foo(&a); return b; —— foo 可改 *(&a),b 不再等于 foo 调用后的 a。SSA 模型里 a 是不可变值就没事;但 load/store 模型不能简单传播。

GVN (Global Value numbering) 是更强的传播——它判定两条指令语义等价(包括 a * 2a + a),用值编号识别。LLVM 的 EarlyCSE 做局部 GVN,GVN pass 做全局 GVN。


五、死代码消除 (Dead Code Elimination)

不可达代码

return 42;
int x = 1;       // unreachable

CFG 上从入口开始的可达性分析能识别。

SSA 上的 DCE (Aggressive DCE)

现代 LLVM 的 ADCE 算法:

  1. 标记所有 有副作用 的指令为 live(call、store、volatile load、return、landing pad)。
  2. 反向遍历 use-def 链:live 指令的 operand 也 live。
  3. 未标记的删。
%a1 = 1                // 无 use
%b1 = 2                // 无 use
%c1 = add %a1, %b1     // %c1 无 use
store %c1, *ptr        // 副作用,live → 反推 %c1 live → %a1, %b1 live

→ 不能删。

副作用边界

指令类型能否 DCE
纯算术(SSA def)只要无 use 就能删
call @pure_funcreadnone/nounwind 可删(GCC __attribute__((const))、Rust #[inline] pure fn
call @unknown_func不能删,可能有副作用
store不能删,除非证明所写内存无人读
volatile load/store永远不能删,C 语义里 volatile 表示硬件 MMIO
释放堆指针 free(p)不能删——即使 p 之后不读,free 有副作用

Memory SSA:为 DCE 识别 store 副作用

LLVM 用 Memory SSA 把内存访问建模成 SSA:

%a = MemoryDef(liveOnEntry)  ; store 1 to %x
%b = MemoryDef(%a)           ; store 2 to %x
%c = MemoryUse(%a)           ; load from %x → 但其实看到 %a 写的 1

若能证明 %b 写的内存段下游无人读,则 %b 是 MemoryDef dead → 可删。这套机制靠 alias analysis + MemorySSA 提供,是 LLVM 现代优化器 (NewGVN、DSE、MemCpyOpt) 的核心。


六、性能影响

Pass平均加速常见触发
Constant Folding5-10%数组维度、编译期已知下标
SCCP5-15%配置常量、宏字面量代码
Copy Propagation1-3%SSA destruction 后
DCE不变速,但减体积Debug 代码、assert

-O2 的实际收益里这三者只占一小块——但它们解锁 Inline、向量化等高阶 pass。LLVM 的 instcombine pass 是这一类优化的大杂烩,跑很多遍。


易错清单

  1. 浮点别随便折叠/重排:除非 -ffast-math 或 Rust fadd fast,否则 NaN/Inf/-0.0 语义会被破坏。
  2. SCCP 不要删可能 throw 的分支:Java 的 if (false) { throw ... } 可以删;但 if (false) { /* 析构 */ } 涉及 RAII 不能简单删。
  3. Volatile 永远不能 DCE:嵌入式 / 内核驱动里 volatile 是 MMIO 的语义,破坏会硬件异常。
  4. pure 标记错会出大事故:标错非纯函数为 pure → 编译器删调用 → 副作用消失。LLVM 的 inferattrs pass 静态推断,错推断的 bug 历史上多次回归。
  5. DCE 在 Inline 后跑:没 inline 之前,外层看不到内层 return 后的代码是被删还是被执行。

这一章带走的东西

  1. SSA 让 CP/DCE 变成稀疏 def-use 查询,复杂度从 O(N²) 降到 O(N)。
  2. SCCP 把常量传播与分支消除合一,是"消除死分支"的工业标准。
  3. DCE 的关键是 副作用边界——纯算术可任意删,volatile/store/unknown call 守住边界。
  4. Memory SSA 是 LLVM 现代化的内存副作用分析骨架,让 DSE/MemCpyOpt 能精准消除冗余 store。
  5. 浮点折叠必须尊重 IEEE 754,没有 -ffast-math 别碰。

下一节 → 循环优化、向量化、strength reduction

循环优化 / 向量化 / Strength Reduction

TL;DR

循环是热代码所在——Amdahl 法则下 90% 的时间在 10% 的循环里。Compiler 把循环优化当成头等大事:LICM (Loop Invariant Code Motion) 把不变量提到循环外、循环展开 (unrolling) 减少分支开销、归纳变量强度削减 (IV strength reduction) 把乘法变成加法、SIMD 向量化把标量循环变成向量指令、Loop Fission / Fusion / Distribution 改变循环结构。所有这些 pass 都基于 loop nest tree + 支配关系 + 归纳变量 (induction variable) 分析。LLVM 用 LoopInfo、HotSpot 用 Loop Tree,Cranelift 用 ebb-based loopCFG。


一、Loop 的形式化

Natural Loop

        preheader
            ↓
       ┌→ header ←─┐
       │    ↓       │
       │   body     │  backedge
       │    ↓       │
       └─ latch ────┘
            ↓
         exit

Natural loop 的判定

  1. 找到 back-edge u → h(h dominates u)。
  2. header h,loop body = {h} ∪ {所有能到达 u 的节点}

这套用 支配树 (dominator tree) 算得出。LLVM LoopInfo 把所有 natural loop 算出来,构成 loop nest forest——内层循环是外层循环的子节点。

Preheader & Exit block

优化 LICM 需要 preheader(循环前的单一前驱)来放外提的指令;loop exit 块用来放"循环退出时的逻辑"。Irreducible loop(multi-entry)通常先通过 node splitting 转化成 reducible——这种"硬核" irreducible 转换很少做,Harvard 论文里 HotSpot 用 Loopify pass 处理。


二、LICM (Loop-Invariant Code Motion)

识别不变量

一个指令 x = a + b 在循环内是 invariant 的,当:

  1. 所有 operand 是常量、或定义在循环外、或定义在循环内但本身 invariant。
  2. 指令没有副作用(store、call、可能 throw)。
  3. 指令所在的 block 支配所有 loop exit——否则外提会改变"如果循环 0 次迭代就执行"的语义。
for (i = 0; i < n; i++) {
    a[i] = x + y;        // x + y 不变,LICM 提到循环外
}

int t = x + y;             // hoisted
for (i = 0; i < n; i++) {
    a[i] = t;
}

安全条件

支配所有 exit 这一条件容易翻车:

for (;;) {
    if (cond) break;       // exit 在 mid-loop
    a = compute_invariant();
}

compute_invariant() 所在 block 不支配 exit block(exit 在 break 之后),不能简单外提——循环可能 0 次执行 compute 后就 break。LLVM 解法是 loop rotation——把循环变成 do-while 形式,preheader 先执行一遍,保证 body 至少跑一次。

LoadLICM

load *p 在循环内、循环不变量?需要 alias analysis 证明循环内没有 store 可能改 *p。否则不能外提。这是 alias info 进入 LICM 的入口。-fstrict-aliasing 让 type-based alias analysis (TBAA) 严格起来,能更多外提。


三、归纳变量分析 (Induction Variable)

基本归纳变量

for (i = 0; i < n; i++) {
    a[i] = b[i];
}

i 是 basic IV (induction variable):每次加常量。a[i] 的地址是 &a + i*4,是 derived IV——线性映射自基本 IV。

强度削减 (Strength Reduction)

把循环里的乘法变成加法:

int *p = a;
for (i = 0; i < n; i++) {
    *p++ = b[i];        // p 每次加 4 字节,i 加 1
}

i : 0, 1, 2, ..., n-1
p : a, a+4, a+8, ..., a+4*(n-1)

p 直接当 IV 用,省掉 a + i*4 的乘法。在现代 CPU 上乘法不慢,但地址计算的 IV 替换对 cache 局部性、load/store 单元利用仍有意义。LLVM 的 loop-strength-reduce pass 做这个。

Linear Function Test Replacement (LFTR)

for (i = 0; i < n; i++) {
    j = 4 * i;
}

i < n 替换为 j < 4 * n(用 derived IV),删掉 i。这叫 LFTR——把循环终结条件也转成新 IV 形式,彻底删掉 i 这个 IV。


四、循环展开 (Unrolling)

Fully vs Partial

// 原始
for (i = 0; i < n; i++) a[i] = b[i] + c[i];

// 完全展开
a[0] = b[0] + c[0];
a[1] = b[1] + c[1];
...
a[n-1] = b[n-1] + c[n-1];

// 部分展开(4 路)
for (i = 0; i < (n & ~3); i += 4) {
    a[i]   = b[i]   + c[i];
    a[i+1] = b[i+1] + c[i+1];
    a[i+2] = b[i+2] + c[i+2];
    a[i+3] = b[i+3] + c[i+3];
}
for (; i < n; i++) a[i] = b[i] + c[i];     // 余数处理

代价

  • 减少分支、减少 loop overhead、给指令调度更大空间。
  • 代码体积膨胀——i-cache 友好性下降。LLVM 用 trip count 和 PGO 决定展开度。
  • 完全展开(trip count 静态已知且小):用于减少 hot loop 的总开销。

HotSpot 的 Loop Unroll

JIT 把循环编译成计数器+entry check 形式:

for (int i = 0; i < 1000; i++) {
    body
}

→ HotSpot C2 用 OSR (On-Stack Replacement) 从解释栈切到 JIT 后编译,先降速运行计数器后编译,编译时 trip count 已知就能完全展开。Rust、Go 编译器没这套 JIT 机制——只能静态 trip count 推断。

Unroll & Jam

for (i = 0; i < n; i++) {
    a[i] = f(i);
    b[i] = g(i);
}

展开外层 → 内层仍有独立 jam,组合执行 → 寄存器重用上升。工程上用得少,但 HotSpot 和 ICC 做这个。


五、SIMD 自动向量化

SIMD 指令模型

x86 AVX2:256bit 寄存器,一条指令处理 8 个 float32、4 个 float64、8 个 int32。

for (i = 0; i < n; i++) {
    c[i] = a[i] + b[i];
}

vmovups ymm0, [a+i]
vaddps  ymm1, ymm0, [b+i]
vmovups [c+i], ymm1

每条 vaddps 跑 8 个 float 加法。

向量化的合法性

  1. 无循环携带依赖 (loop-carried dependence)
    for (i = 1; i < n; i++) a[i] = a[i-1] + 1;   // 写 a[i] 依赖前次 a[i-1],不可向量化
    
  2. 内存不别名abc 不重叠。LLVM 通过 noalias (C restrict)、TBAA 等机制证明。
  3. 可处理余数n 不能被 SIMD lane 数整除,需要 mask 或 scalar tail loop。

LLVM 的 Vectorizer

  • loop-vectorize:循环级向量化,处理带 preheader 的规范循环。
  • slp-vectorizer:基本块内的 superword-level parallelism——一组标量指令有相同 op 时打包成 SIMD。

GCC、ICC、HotSpot 编译器都有等价 pass。LLVM 的向量化成本模型基于"target transform info (TTI)"——知道目标 CPU 的指令 throughput、寄存器数、掩码能力。

工程上的"反例"

// 不可向量化(Python list comprehension 编译到 C 的常翻场景)
for (i = 0; i < n; i++) {
   (bin)[i] = (a[i] > 0) ? a[i] : 0;        // select 不是分支,可向量化
}

for (i = 0; i < n; i++) {
    if (a[i] > 0) b[i] = a[i];               // 通过 mask load/store + blend 可向量化
    else b[i] = 0;
}
// 真不可向量化:间接访问
for (i = 0; i < n; i++) b[idx[i]] = a[i];    // gather/scatter,AVX2 支持但慢

Gather/Scatter 在 AVX2 起支持但 throughput 差。常规向量化偏好 unit-stride 访问。


六、其他循环 pass

Loop Fusion

for (i = 0; i < n; i++) a[i] = i;
for (i = 0; i < n; i++) b[i] = a[i] * 2;

for (i = 0; i < n; i++) {
    a[i] = i;
    b[i] = a[i] * 2;       // 寄存器复用 a[i]
}

前提:trip count 相同、无依赖、无副作用融合后变化。

Loop Fission (Distribution)

for (i = 0; i < n; i++) {
    a[i] = compute_a(i);
    if (cond[i]) b[i] = compute_b(i);    // 罕见分支拖累 SIMD
    c[i] = compute_c(i);
}

for (i = 0; i < n; i++) a[i] = compute_a(i);
for (i = 0; i < n; i++) if (cond[i]) b[i] = compute_b(i);
for (i = 0; i < n; i++) c[i] = compute_c(i);

把可向量化部分与有分支的部分拆开。

Loop Interchange

for (j = 0; j < M; j++)
    for (i = 0; i < N; i++)
        a[i][j] = ...;

for (i = 0; i < N; i++)
    for (j = 0; j < M; j++)
        a[i][j] = ...;

C 用行主序,外层 i 内层 j → 连续访问,cache miss 大降。但要求无依赖——若内层循环有 carried dep 这就改了语义。

Loop Rerolling

源码手写展开后重新 roll:

x = a[0] + a[1] + a[2] + a[3];

→ 重新 roll 成

for (i = 0; i < 4; i++) x += a[i];

允许后续 SIMD 看到"循环"语义重新向量化。LLVM 的 re-roll pass。不常见但 very few projects 依赖(如 BLAS 模板)。

Loop Peeling

把循环的前 k 次分出来:

for (i = 0; i < n; i++) body;

body;  // i = 0
for (i = 1; i < n; i++) body;

用于消除异常迭代的不变量、给后续 pass 减小分析范围(比如证明 alias、消除边界检查)。


七、工业事故:循环优化的真实翻车

GCC -fstrict-aliasing + Linux Kernel

Linux kernel 某些代码用 union 来 alias 不同类型,违反 strict aliasing。GCC 4.x 在 -O2 后默认 strict-aliasing,删掉了一些 union 重叠访问的代码 → 静默数据损坏。社区加 -fno-strict-aliasing

LLVM Loop Unroll + 工程代码

某项目把 100MB 数组完全展开后整个 binary 爆炸(500MB+),编译器来不及反馈给用户。LLVM 加 -mllvm -max-unroll-times 上限。

ICC 的 Auto-vectorization 性能问题

ICC 自动向量化 TSVC benchmark 加速 2-5x,但当遇到包含条件分支 + 间接访问的组合时退回标量。模型本身没 bug——但用户误解"自动向量化一定生效"。Profile 显示 scalar tail 太多,需要手动加 #pragma omp simd


八、Per-CPU 策略

x86 vs ARM vs GPU

  • x86 AVX2/AVX-512:256/512bit 寄存器,向量化带来 4-16x 加速。AVX-512 的 mask register 让条件向量化很优雅。
  • ARM NEON / SVE:NEON 128bit 固定宽度;SVE 是可变 SIMD 宽度 (128-2048 bit),同一 binary 在不同硬件跑出 CPU 支持的 lane 数。SVE 的 whilelt 指令完美处理 tail loop。
  • GPU:PTX 的 SIMT 模型,warp 32 thread 同步执行。GPU 编译器的向量化更像是"识别_parallel pattern + memory coalescing"。

CPU Profile-Guided Vectorization

PGO 提供 trip count 分布、分支概率,编译器决定展开度是否向量化。LLVM 的 opt -pgo 流程:

  1. 编译 -fprofile-generate 插桩
  2. 真实运行收集 trip count
  3. -fprofile-use 重编译,cost model 优化

PostgreSQL、Chromium 项目都大量用 PGO。


九、易错清单

  1. 浮点循环不能重排结合sum += a[i] 循环展开后并行加会破坏浮点结合律。-ffast-math 或 Rust fadd fast 才允许。
  2. 循环边界 n 是循环内修改的全局:compile 期 trip count 推断错;向量化时会死。
  3. pointer alias 验证不严restrict 错放数组会让无 alias 假设错。Rust 的 ownership 模型保证 &mut 唯一 → Rust 默认无 alias,向量化比 C 容易。
  4. OSR 与 Retry Budget:HotSpot 在低层级 JIT 失败时 deopt,编译开销大。多核 CPU 上常看到 "tiered compile lag"。
  5. SIMD 的 tail loop:标量 tail 仍是开发者看到加速不到预期的真凶,应该看 asm 输出,确认 tail。

这一章带走的东西

  1. LICM 是最常用循环优化,安全条件是支配所有 exit
  2. Induction Variable 强度削减:把乘法降级成加法,是循环中精度换算术的经典。
  3. 循环展开要配 PGO,盲目完全展开炸 depolar i-cache、binary 体积。
  4. SIMD 向量化靠无循环携带依赖 + 无 alias + trip count 信息,三者缺一编译器保守放弃。
  5. Loop Fusion / Fission / Interchange 改循环结构——编译器叫这种 "polyhedral optimization",工程上在 ML 编译器(TVM、XLA、IREE)用得最重。

下一节 → Inline / IPA / escape analysis

Inline / IPA / Escape Analysis

TL;DR

Inline 的本质是函数调用 emit 在 caller 处展开——节省调用开销(passing 参数、保存寄存器、branch 一直到 callee 入口、return jump),更重要的是让下游 pass 看穿函数边界做更深的优化。Inline 单独看不重要,但它解锁了常量传播、DCE、向量化所有跨函数优化的能力。IPA (Inter-Procedural Analysis) 是跨函数的信息流分析——什么函数会逃逸什么指针、pure/pure 函数识别、call graph 构造。Escape Analysis 决定堆分配能否变成栈分配——Java HotSpot、Scala Dotty、Go escape analysis 都用这个砍掉 90% 的无用堆分配。


一、Inline 的成本 vs 收益

数值模型

每次 call 的代机:

  • 几条指令准备参数(栈/寄存器安排)
  • call 指令(push return address, jump)—— 几个 cycle
  • 几条 callee prologue 保存 callee-saved 寄存器
  • epilogue 恢复
  • ret 指令 (pop + jump)

总开销 5-30 cycle 视现代 CPU 分支预测器、call/ret stack 是否存在而有不同。一次 call 不到 10 cycle 在 Branch Target Buffer + Return Address Stack 命中的情况下。

解锁 downstream pass 的价值

int square(int x) { return x * x; }
int sum(int x, int y) { return x + y; }

int compute(int a) {
    return sum(square(a), square(a + 1));
}

square 是小函数,inline 后:

int compute(int a) {
    int t1 = a * a;
    int t2 = (a + 1) * (a + 1);
    return t1 + t2;
}

现在下游 pass 能看到 t1 + t2 的实际表达式。CSE 能识别 a*a 这种纯算术共现,strength reduction 能判定 a*a 是否需要查表——key benefit is 下游 pass 不划界面

LLVM Inline 的 cost model

llvm::InlineCost 估计 inline 后基本块数、指令数、call 数。常量参数 inline 后能消除死代码会减少成本。

阈值:

  • -O0: 几乎不 inline
  • -O1: 偶尔 inline(小函数)
  • -O2: 中等阈值 inline(~50 指令阈值)
  • -O3: 激进 inline
  • __attribute__((always_inline)): 强制
  • 虚函数 / 间接调用:编译期不可 inline(除非 devirtualization)

C++ 模板的隐式 inline

template<int N> int factorial() { return N * factorial<N - 1>(); }
template<> int factorial<0>() { return 1; }

int main() { return factorial<5>(); }     // 完全编译期展开 → return 120

模板 + 常量参数 → 整段代码 inline + SCCP + 常量折叠 = 编译期计算。这就是 C++ 表达式模板 (Eigen、Blitz) 的核心。


二、IPA (Inter-Procedural Analysis)

Inline 是它的特例。IPA 包括:

分析类型用途
Call Graph谁调谁,用于 inline、devirt、reflection
Mod/Ref Analysis函数会修改/读取哪些全局/参数指针
Pure Function Detection没副作用,可被 DCE 删除
Escape Analysis哪些对象不会逃逸函数外,可栈分配
Points-to Analysis哪些指针可指向哪些对象

Mod/Ref Example

int g = 0;
void f(int *p) { *p = 1; }
void g_caller() {
    f(&g);          // Mod analysis: g 被 modified
    return g;       // 不是 0,是 1
}

IPA 知道 f 会 Mod 它的参数指针 → caller 知道 g 会被改 → 看穿 f 的行为。

Pure Function Detection

int square(int x) { return x * x; }       // pure
int counter() { static int n = 0; return ++n; }  // not pure (有 static 副作用)

Pure 函数:

  • 不读/写任何可变全局
  • 不调用非 pure 函数
  • 不 deref 可逃逸的指针

LLVM 的 inferattrs pass 静态推断,能标 readnone/nocallback 等。LLVM 中段 pass 跑 lib 优化时 callee 标 readnone → caller 可以删 caller 看来"结果用不上的"调用。

Devirtualization

虚函数调用 (C++ virtual) 通过 vtable 间接调用,编译期难内联。但若有上下文信息可推断动态类型:

struct Base { virtual int f() { return 1; } };
struct Derived : Base { int f() override { return 2; } };

void caller() {
    Derived d;
    Base *p = &d;
    return p->f();     // 实际是 Derived::f,编译期可推
}

LLVM 的 devirt pass:

  • p 在函数内创建 (类型确定) → 直接 dispatch
  • 若是参数但 callgraph 显示只有一种可能实现 → speculatively devirt
  • 若是 vtable 上的 known call site 位置 → RFC

Speculative devirtualization:先 emit direct call + guard check 类型→ 没识别成功 fallback 回 indirect call。VM 内的 type profile 收集动态类型分布,re-compile + OSR 重入更精确版本。


三、Escape Analysis

###什么是 escape

Object foo() {
    Object o = new Object();
    return o;          // o 逃逸到 caller
}

void bar() {
    Object o = new Object();
    use(o);            // 不再 escape → 可栈分配
}

Java HotSpot 的 EA

HotSpot C2 在 Sea-of-Nodes 上做 escape analysis:

  1. Global escape: 赋值给 static、return 给 caller、赋给 instance field 逃逸求外。
  2. Arg escape: 传给其他函数作为参数(但仍仅 thread-local)。
  3. No escape: 仅函数内访问。

No escape 的 new 直接栈分配(scalar replace),同步锁消除(若未逃逸则 lock-to-unlock 信息可消除 monitor)。

Go 的逃逸分析

Go 编译器(cmd/compile/internal/escape)做逃逸分析。常见规则:

写法逃逸
func foo() *int { x := 1; return &x }逃逸到堆
func foo() { x := 1; bar(&x); } 看 bar 是否会保存取决于 bar
fmt.Println(&x)经常逃逸(Fmt 处理 interface{})
s := make([]int, n) n > 64KB逃逸到堆

Go 的 philosophy 是:能用栈就用栈,减少 GC 压力。初学者写 Go 总是 hybridizereturn []*Foo{...} 让切片逃逸,切片里指针逃逸 → 大量堆 alloc。

Rust 一切默认栈

#![allow(unused)]
fn main() {
fn foo() {
    let x = Box::new(1);       // x 是 Box<i32> 指针,外层 stack,*x 仍在 heap
    return *x;                  // 返回 i32,Box 释放 → 仅 stack 副作用
}
}

Rust Box 总在 heap;其他默认 stack。Escape analysis 不需要——move 语义让所有权流动。


普通 IPA 受限于单 compilation unit:a.c 看不到 b.c 的函数实现。LTO 把 IR 总结到 link 阶段一起优化。

LLVM LTO 流程

clang -c a.c -flto -o a.o   # a.o 内含 LLVM BC,不是机器码
clang -c b.c -flto -o b.o
clang -flto a.o b.o -o prog
# link 时把所有 BC 合并成一个 module
# 跑 module-level inline、IPA、DCE、whole-program devirt

ThinLTO:把 module 合并改成"summary + 并行 pass"——众多 TU 各自跑独立 opt pass,只用 cross-TU summary 信息——比 full LTO 速度快 4-8x, 收益小 10%。

Firefox、Chrome 的 LTO

Chromium 用 ThinLTO + PGO —— TLDR 来说编译 30%、二进制小 15%、运行时快 5%。Link 阶段变成 30+ 分钟,但 binary 性能可接受。

GCC LTO (ELF vs Windows)

GCC LTO 用 ELF section 存 IR,在 link 上合并。Windows COFF 用类似机制,工程师需 specifc linker (goldmold) 加速。


五、Inline 的麻烦事

Recursive 函数

int fact(int n) {
    if (n == 0) return 1;
    return n * fact(n - 1);
}

inline 递归 → 无限展开。编译器识别 self-recursive edge,只 partial inline 一个 voxel。

大函数 + 多 call site

void huge() { /* 5000 行 */ }
void a() { huge(); }
void b() { huge(); }

inline huge 到 a、b 后体积膨胀 2x。LLVM 的 strategy:保留一份 huge 给 a、b 调用,并对 a、b 做 partial inline(inline 入口 + 显式 jump 回 huge 中部)。

Cross-language Inline

C + Rust FFI 不能 inline——调用走 ABI 边界。Rust 的 pub fnextern "C" 后是 ABI 边界,不能 inline 进 C。但若都是 Rust lto=true 可以跨 crate inline。

Cold Path Inline

PGO 显示某函数只在 0.1% 的执行路径调用:

void fast_path() { /* hot */ }
void slow_path() { /* cold, 1000 行 */ }
void f() {
    if (rare) slow_path();
    else fast_path();
}

cold path 不要 inline——inline 增大 caller 体积,污染 i-cache。LLVM coldcc calling convention 让 cold path 放到 binary 末尾,对分支预测友好。

Inline 反复制副作用

int counter() { static int n = 0; return ++n; }
int f() { return counter() + counter(); }

inline 后:

int f() { return (++n) + (++n); }

这改变了求值顺序(C UB / Rust/Java 有定义但结果不同)。绝不可 inline 非纯函数多次评估语义。LLVM 在没标 SSA 是 readnone 的情况下保守不 inline 多次。


六、HotSpot 的 Layered Compilation

HotSpot 现代虚拟机用 tiered compilation

  1. Tier 0: 解释器
  2. Tier 1: C1 编译,简单 Fast Compilation
  3. Tier 2: C1 with profiling
  4. Tier 3: C1 with profiling + 一些 opt
  5. Tier 4: C2 编译——Sea-of-Nodes + escape analysis + aggressive inlining

profile 收集每个 call site 被调用的实际类型、每条 branch 的触发次数。C2 编译时把 profiling C1 传来的 type/branch info 用进 inline 决策。这就是 Java 接近 C 性能的秘诀——无 profile 静态编译做不到 100% inline 决策正确。


七、易错清单

  1. __attribute__((always_inline)) 不能强制 recursive inline:编译器拒绝递归,加 inline hint 也会被拒。
  2. Inline 不代表语言层面的"声明 inline":C++ inline 关键字只代表"允许多 TU 出现定义",不强制 inline。
  3. Cross-language LTO 需要同等 IR:Rust + C 不能 cross language LTO,除非都是 LLVM。GCC lto-plugin 处理 ELF link-time。
  4. 没标 pure 的 call 不要轻易 DCE:副作用错删是大事故。LLVM 的 LLVM IR 要 correct readnone 标注,否则保留。
  5. Box 不在 stack:Rust Box 一定 heap。但 Option<Box> 的某些编译期转换会布尔字段化(niche optimization),看懂 stack vs heap 区别。
  6. Go 闭包捕获引用类型逃逸func() { s := []int{}; goroutine(func(){ s = append(s, 1) }); } → s 逃逸到堆,由 goroutine 同时持有。

这一章带走的东西

  1. Inline 不省 cycle,是让下游 pass 看穿函数边界
  2. IPA 是 mod/ref、pure detection、call graph 的统称,依赖 LTO 才能跨 translation unit。
  3. Escape Analysis 决定堆→栈分配,Java HotSpot、Go 编译器都用它砍 GC 压力。
  4. Devirtualization 需 profile + speculatively inline,HotSpot 把这套发挥到极致。
  5. Tiered Compilation 把 JIT 重编开销摊平——profile 收集 + OSR + 重入 = Java 接近 C 的核心机制。
  6. LTO 是 IPA 的 link-time 实施,ThinLTO 平衡编译时间与收益。

下一节 → Codegen 总览

Codegen

机器无关优化在 IR 跑完之后,进入机器相关阶段。任务是把 IR 翻译成目标 CPU 指令序列——指令选择、寄存器分配、指令调度、peephole 优化、JIT 编译(如果支持)。这一阶段成果直接决定最后 30% 的性能。**LLVM 后端 SelectionDAG → MachineInstr → Machine Function Pass → MC Layer 是教科书级别的工业 stream;HotSpot C2 用 IR graph 直接 emit 寄存器分配器;Cranelift 用 CLIF 一步到位。

后端的几个阶段

IR (SSA form, machine-independent)
   ↓  Instruction Selection        (IR -> 目标架构指令模板)
   ↓  Register Allocation          (虚拟寄存器 -> 物理寄存器/spill)
   ↓  Instruction Scheduling       (指令顺序依赖图)
   ↓  Peephole Optimization        (局部模式替换)
   ↓  Machine Code Emission        (binary)

Instruction Selection

a = b + c   (LLVM IR)

在 ARM 上:

add r0, r1, r2          (单条加法)

在 x86 上:

mov rax, rbx
add rax, rcx            (必须先 mov,因为 add 目的端是 src-dst)

指令选择 决定用哪条机器指令实现 IR 操作。LLVM 用 SelectionDAG(树覆盖),GCC 用机器描述 macro,HotSpot C2 直接产生 ideal graph 节点。tree-covering 算法是经典 Burmese (Burs, Graham- Henry) 算法:找树最便宜的覆盖。

Register Allocation

sum += a[i];              // 用到_sum, i, 临时寄存器

物理寄存器只有 16-32 个(x86-64 GPR),但活跃变量可能上百。Spill——把不活跃的临时存到栈——是 register allocation 的核心难题。两个经典算法:Graph Coloring(Chaitin-Briggs, 复杂但质量最好)与 Linear Scan(Poletto-Sarkar, 简单快但稍逊)。

Instruction Scheduling

CPU 流水线深度:现代 x86 ~14-19 stages,ARM Cortex-A78 ~15 stages。指令间数据依赖会导致 stall——把无关指令插进依赖链中能增加 ILP (Instruction-Level Parallelism)。

mov rax, [rsp]        ; load 1, latency 3-4 cyc
add rbx, rax          ; depends on rax
mov rcx, [rsp+8]      ; load 2, independent
add rdx, rcx          ; depends on rcx

调度器把无关的 load 提到前面:

mov rax, [rsp]
mov rcx, [rsp+8]
add rbx, rax
add rdx, rcx

两个 load 并发执行。List Scheduling 算法:每 cycle 选依赖闭包里就绪的指令送入。Hardware 多发射让编译器调度放宽;现代 CPU 的 out-of-order 让 compiler scheduling 不那么关键,但仍是关键因素。但 in-order CPU (Cortex-A55、Atom) 仍极依赖 compiler scheduling。


下一节 → LLVM IR / SelectionDAG / 指令选择

寄存器分配:Graph Coloring & Linear Scan

TL;DR

寄存器分配把无限虚拟寄存器映射到有限物理寄存器。两个算法家族:Graph Coloring(Chaitin 1981, 经典 NP-hard 但质量最高)—— 把"同时活跃的虚拟寄存器"建成干涉图,相邻不共色的就 spill;Linear Scan(Poletto & Sarkar 1999)—— 不建图、按起点快排扫描 live interval 区间、立即分配——快 10 倍、质量稍差但够 JIT 用。现代 LLVM 把 Graph Coloring 做得工业级,HotSpot、V8、Cranelift 都偏 Linear Scan 变种。理解 spill、copy coalescing、calling convention、register pressure 是这章的核心。


一、活跃性分析 (Liveness)

Live-In / Live-Out

一个变量在某个 program point 是 live 的,当存在从该点出发的 path 里的 use 没被中间 def 覆盖。形式化:

live_in[b]  = use[b] ∪ (live_out[b] - def[b])
live_out[b] = ∪ live_in[s]    for s ∈ succs(b)

迭代到底不动点(典型 dataflow equation)。这是后向分析 (backward analysis)。

Live ranges & live intervals

在一个 basic block 内连续活跃的虚拟寄存器 = live interval [start_pos, end_pos]。SSA-down 后dealloc'd 后,变量的 live range = 一系列 live interval 段,并起来。

%1 = ...
... %1 ...     ; %1 live 第 1-5 行
%2 = ...
... %2 ...     ; %2 live 第 6-10 行
flowchart LR
    A["%1 li[1,5]"] --> C[overlap?]
    B["%2 li[6,10]"] --> C
    C -->|no| D[%1, %2 可共分配同一物理寄存器]

不重叠 → 不干涉 → 可同色 → 可共分配同一物理寄存器。这是 graph coloring 的基础。


二、干涉图 (Interference Graph)

int a = ...;         // live: [1..5]
int b = ...;          // live: [3..7]

%a%b 在 [3, 5] 重叠 → interfere → 必须不同颜色(不同物理寄存器)。

构建干涉图:

  1. 跑 liveness analysis 得每个 vreg 的 live interval
  2. 对每两 live interval ij:若 overlap,加边 (i, j)
  3. 同时考虑 pre-colored node(必须固定到物理寄存器——比如 calling convention 要求参数在 %rdi / %rsi

Pre-colored Example (x86-64 SysV ABI)

int sum(int a, int b) { return a + b; }

a 必须在 %rdib 必须在 %rsi——call convention 强制。这些 vreg 是 pre-colored:

  • %arg_a → 已经是 %rdi (色 = RDI)
  • %arg_b → 已经是 %rsi (色 = RSI)

干涉图上其他 vreg 必须避免占这俩色。


三、Graph Coloring 算法

Chaitin-Briggs (classic)

Kempe's heuristic:

  1. Simplification: 找 degree < K (K = 物理寄存器数) 的 node,push 到 stack 后从图删。
  2. 直到所有 node 都 < K,或剩下的都 ≥ K(spill 候选)。
  3. Spill: 选一个 spill 候选,标记为 spill;不分配物理寄存器,运行时通过 stack。
  4. Select: pop stack,分配尚未被邻居占用的颜色。
loop:
   if exists node with degree < K:
      stack.push(node); remove from graph
   else:
      pick highest cost / least frequently spilled; mark potential_spill
      stack.push(node); remove from graph
until graph empty

while not stack.empty:
   n = stack.pop()
   if can color: pick color avoiding neighbors
   else: actually_spill (regenerate IR with loaded value)

迭代 spill: spill 后重新跑 liveness,可能产生新 node 然后再 round,直到全部 on reg 或确认稳定的 spill 集。

Chaitin vs Briggs

  • Chaitin: 直接选 spill 候选 + drop,扫整图后重做。
  • Briggs: optimistic spill——pop 时若实际有 color,不算 spill。Briggs 的 reverse order 让 optimistic 多一次机会。

工业 LLVM 用 PBQP (Partitioned Boolean Quadratic Programming) 求解器 / Greedy coloring 跨大图好。

实例 Coloring

K = 3 (3 个物理寄存器 R0/R1/R2),干涉图:

A — B
|   |
D — C

四 node 都相互干涉 (clique K4):

  • K4 degree 是 3 = K,不能 simplify
  • 选 B spill(标记 spill 后再 iter)
  • 实际:希望重新跑把 B 全部加载 / 重命名拆干 → B 重新成两个更短间隔 → 不重叠 → 上色

Cost Function for Spill

spill_cost(v) = 使用次数 × load/store 单价 / live interval 长度

长 live range 上 spill 不划算(频繁 load/store),短 range 上 spill 划算。


四、Linear Scan

Poletto-Sarkar Linear Scan:

1. 把所有 live interval 按 start_pos 排序
2. 维护 active list: 当前分配中的 interval
3. for each interval i (in start order):
     expire_old_intervals(i.start)  # end < i.start 的从 active list 删除、还寄存器
     if free_reg:
        assign free_reg to i
     else:
        spill_at_interval(i)        # 选 spill 候选:
                                     #   - active list 中 end_pos 最大者
                                     #   - 如果它的 end_pos > i.end_pos:
                                     #     spill 它, 把 i 放到它腾的 reg
                                     #   - 否则 spill 自身 i
     push i to active list

O(N log N) 排序 + O(N) 扫描。比 graph coloring 快 10-30 倍。

现代改进: Traub's Linear Scan (LLVM / HotSpot)

  • Live Interval split: 在压力高的点把 long interval 拆成多段,每段独立分配——可 spill 一部分留寄存器另一部分。
  • Position-aware spill: 不只"spill 不在寄存器时 load",而是常用寄存器 cache 最近 use 模式。
  • Hinted register: 优先选相同 hint 的色——减少 copy coalescing pain。

实例 Linear Scan

K = 2,三个 live intervals:

v1: [1..10]    v2: [2..5]         v3: [6..12]

按 start sort: v1, v2, v3.

  1. v1: active = [v1], reg[v1] = R0
  2. v2: active = [v1, v2], reg[v2] = R1
  3. v3: expire ≤ 5: v2 expire → active = [v1], free R1. v3 = R1. active = [v1, v3]
  4. return: reg[v1]=R0, reg[v3]=R1

完美使用 K=2 即可——active list 最多 ⌈K⌉ 个 node。


五、Copy Coalescing

Source of copies

%a = call i32 @f()
%b = %a        ; copy
%c = %b + %c

%b%a 的 copy。如果分配器把 %a%b 给同一个物理寄存器,copy 消失。

Move Coalescing

很多 copy 来自 calling convention:

%arg = %rdi        ; 第一个参数 copy 到 vreg 时

很多 copy 来自 SSA destruction 的 φ 节点:

loop.header:
  %x = phi [ %xinit, %entry ], [ %xnext, %back ]

SSA destruction 在每个 predecessor block 尾插 mov %xinit, %x_phys——这是一个 copy。

Coalescing 算法

  1. Iterated Register Coalescing (IRC): George-Appel 1996, 同时做 coalescing 与 simplification。coalesce edge (a,b): 若合并后 a∪b 的所有邻居都 < K, OK。否则保守——可能 spill 成本升高。
  2. Briggs: 合并后邻居中 degree ≥ K 的数 < K, OK。

LLVM 与 HotSpot 都用 Briggs / IRC 改编。Cranelift 用更简单的 coalescer——速度优先。


六、Register Pressure & Spill Quality

Pressure

N vreg 同时 live = pressure N。如果 stress = N > K,必须 spill。

LLVM 在 IR 阶段就能 audit pressure——通过剪压阈值如 heuristic (about-ish: if predicted pressure > K, abort a pass early)。该机制让编译器能主动放弃 aggressive inline(避免压力爆)。

Spill Mode

Spill to Memory:

%v = add %a, %b         ; %v live
store %v, [stack slot]
...                      ; use of %v freed for re-use
%reload = load [stack slot]
use %reload

但是 store+load 太 expensive —— profiler 上 spill-to-memory 比 spill-more-load (just-reload-on-use) 慢 3 倍。

Spill at Use (better): 只在使用前 load:

%v = add %a, %b
store %v, [stack slot]
...                      ; %v not used here
... 
%reload = load [stack slot]
%r2 = add %reload, %c

Clever allocator 把 spill value 重新 split: 有时 load + use + free reg + reload + use。

Rematerialization

某些值重新计算比 spill 便宜——比如:

%c = const 5
...use %c
...long live range overlap
...use %c again

不必 store %c = 5 然后再 load。直接用一个新的 mov $5, %r 替代 load —— cycle 数相同、 不需 stack slot。识别"cheap-to-remat" 值是 allocator 的 quality gain。


七、Calling Convention

Caller-saved vs Callee-saved

ABI 定义:

  • Caller-saved (volatile): 调用方负责保存的寄存器。若调用方 live value 占这寄存器,必须 call 前 push、return 后 pop。
    • x86-64 SysV: RAX, RCX, RDX, RSI, RDI, R8-R11 + XMM0-15
    • ARM64 AAPCS: X0-X18 (X0-X7 args, X9-X15 temps, X8/X16/X17 special)
  • Callee-saved (non-volatile): 被调方负责保存的寄存器。callee 用这些 reg 前必须 prologue push、epilogue pop。
    • x86-64 SysV: RBX, RBP, R12-R15
    • ARM64 AAPCS: X19-X28

Cost

callee-saved 寄存器需要保存—— 哪怕 callee 函数没用 X19 也不用 push;用就要 push + pop。Strategy:allocator 倾向先用 caller-saved 寄存器(短函数里没子调用就行),然后才 callee-saved。这对短 leaf function 至关重要——LLVM 的 PrologEpilogInserterpass 做这个。

Function attributes

norecurseleafnounwind 等属性帮助 allocator 判断 callee-save 寄存器能省。无子调用且 leaf 的函数可以不保存 caller-saved。


八、架构变种

x86 麻烦事

  • 8 GPR(x86-64):RAX, RCX, RDX, RBX, RSI, RDI, R8-R15. 14-16 if count RSP/RBP reserved.
  • RBP 由 LLVM 默认作 frame pointer(debug-frame 风险),后 -fomit-frame-pointer 可省。
  • XMM 寄存器(16 SSE/AVX):很多工程代码不用,但 vectorize 时的 pressure 大。
  • 32bit x86 只有 6 GPR,压力极大。

ARM64

  • 31 X 寄存器 (X0-X30, X31 = SP / Zero),鲜少 pressure。
  • SIMD/FP 同一批寄存器。

RISC-V

  • 31 GPR, 标准 ABI RV64G。
  • 浮点是独立 X0..X31 register file——若架构有 F/D 扩展。

九、易错清单

  1. ABI 不保存的寄存器、allocator 假设保存了 → caller-side bug:Rust 曾有 LLVM bug 把 XMM 假设 saved 导致 SSE spill 后 FP-clobbered 出错。
  2. leaf function 太长 spill 到 callee-saved 让 cycle cost 高: profile shows callee-save spill+restore is 5-10 cycle × multi 行——很多函数实际编译 -O0 + leaf。
  3. register pressure > 4 → 向量化重 abort:很多 LLVM vec pass 会在压力过高时不再 vectorize,避免 spill 反而恶化。开发者看不到这种 abort、权当自动向量化假装生效。
  4. 寄存器 hinting 错位:两个 copy vreg 但分配器没 coalesce → 仍有 mov, 性能小心脏。LLVM 输出 IR 中 copy 指令保留很容易 catch via llc -print-after-all
  5. PGO 决定 spill cost: PGO 知道某个 use 实际热/冷,allocator 重 alloc 时优先热路径拥有 reg。Google Chrome、PGO-mode Chromium 都强烈受此影响。

十、这一章带走的东西

  1. 寄存器分配本质是有限物理寄存器到无限虚拟寄存器 (vreg) 的映射, spill 是 fall-back。
  2. Graph Coloring 经典算法——Chaitin-Briggs、Simplification、Select、Cost-based Spill,质量和复杂度最好。
  3. Linear Scan 是 JIT 友好的简化——排序 + active list + immediate spill 决策。
  4. Copy Coalescing 把"是 copy 的两条 mov" 删成 0——大部分 copy 来自 SSA destruction 与 ABI 强制。
  5. Calling Convention 决定 caller-saved vs callee-saved 拆分——allocator 倾向 caller-saved for short leaf functions。
  6. Spill 质量: spill-at-use 比 store-to-stack 便宜;remat const N 比 store 便宜。

下一节 → JIT、Tiered Compilation、V8 Turbofan、Cranelift

LLVM IR / SelectionDAG / 指令选择

TL;DR

LLVM IR 是 SSA-based 的 typed 三地址中间表示,是 LLVM 编译流水线的"通用语言"。前端 (Clang、rustc、flang) 把源码翻译成 LLVM IR;后端把 IR 翻译成机器码,中间靠 SelectionDAG 做多 → 一的指令选择——把一个 basic block 的 SSA 节点打包成 DAG、用 tree-pattern 覆盖选最便宜的指令模板、再 legalizes 不支持的类型/操作到更小的指令序列。理解 SelectionDAG 是理解"为什么编译器生成那种神仙机器码"的入口。


一、LLVM IR

形式

define i32 @add(i32 %a, i32 %b) {
entry:
  %sum = add i32 %a, %b
  ret i32 %sum
}

属性:

  • Static Single Assignment: 每个虚拟寄存器只被赋值一次
  • Typedi32i64f64{i32, i8*} 结构体;type 由前端给出
  • Three-address: 大多数指令形式 %dst = op <type> %a, %b
  • Basic blocks: 一个函数有若干 block,block 内线性,block 之间通过 br 跳转

类型

类型用途
i1布尔
i32, i64整数
f32, f64IEEE 浮点
<4 x float>SIMD vector
ptr不透明指针 (LLVM 15 起,取代 i8* etc.)
[N x T]数组
{T1, T2, ...}结构体
%Name = type { ... }命名结构体

指令一览

指令语义
%r = add i32 %a, %b
%r = fadd fast float %a, %b浮点加速模式 (允许重排)
%r = load i32, ptr %p内存读
store i32 %v, ptr %p内存写
%r = getelementptr [10 x i32], ptr %arr, i32 0, i32 %i计算 &arr[i],不实际读内存
%r = call i32 @foo(i32 %x)调用
br label %next无条件跳转
br i1 %cond, label %true_b, label %false_b条件跳转
%r = phi i32 [ %a, %bb1 ], [ %b, %bb2 ]φ 节点
%r = alloca i32栈分配
ret i32 %r返回

一个循环例子

int sum_arr(int *a, int n) {
    int s = 0;
    for (int i = 0; i < n; i++)
        s += a[i];
    return s;
}

→ LLVM IR:

define i32 @sum_arr(ptr nocapture %a, i32 %n) {
entry:
  br label %loop.cond

loop.cond:
  %i = phi i32 [ 0, %entry ], [ %next_i, %loop.body ]
  %s = phi i32 [ 0, %entry ], [ %next_s, %loop.body ]
  %cmp = icmp slt i32 %i, %n
  br i1 %cmp, label %loop.body, label %loop.end

loop.body:
  %idx = getelementptr i32, ptr %a, i32 %i
  %v = load i32, ptr %idx
  %next_s = add i32 %s, %v
  %next_i = add i32 %i, 1
  br label %loop.cond

loop.end:
  ret i32 %s
}

观察:

  • si 是 SSA 值,每次循环 phi 在 loop header 处选 entry 版本或 backedge 版本
  • nocapture 标注 a 的指针不会逃逸(来自 IPA),有助于下游别名分析
  • getelementptr (GEP) 只算地址不读内存——LLVM 把指针算术从整数算术里独立出,便于 alias analysis

二、SelectionDAG

为什么不用 IR 直接生成机器码?

LLVM IR 是 SSA 的、跨架构的;机器指令是 CISC/RISC 的、有特殊限制。例如:

  • x86 add 是 2-op dest-1st form(add rax, rbx → 写 rax)
  • ARM64 大部分三操作数 add x0, x1, x2
  • x86 的 imul 是 IMUL 3-operand,但 mul (1-operand) 把结果放 dx:ax
  • ARM64 有 SIMD load 配对寄存器 vs scalar load 单独单元

直接逐条 IR 翻译出指令往往不优。SelectionDAG 把一个 basic block 一个 DAG,用 tree-pattern 的最便宜覆盖选机器指令。

DAG 构造

IR basic block 的指令序 → 依赖图(def-use chain 形成的 DAG)。比如:

  %i  = phi ...
  %idx = gep i32, ptr %a, i32 %i
  %v = load i32, ptr %idx
  %next = add i32 %v, %i

DAG:

                ADD
               /   \
            LOAD   (use of i)
             |
           GEP
          /   \
        %a    (use of i)

DAG node 类型:add, sub, mul, load, store, br, phi, GEP, Constant, Register, EntryToken, TokenFactor (chain for side effects)...

Tree-Pattern 匹配

每个 target 定义一套 patterns——IR 操作 + operand shape → 一条机器指令 + cost:

// Target X86 .td
def : Pat<(add GR32:$src1, GR32:$src2),
          (ADD32rr GR32:$src1, GR32:$src2)>;
def : Pat<(add GR32:$src1, (load addr:$src2)),
          (ADD32rm GR32:$src1, addr:$src2)>;        ; load + add 合并

第二个 pattern 把 "add 一个加载值" 合并成一条 ADD rm 指令——避免单独的 load + add。

DAgger 用 Burg-style dynamic programming on DAG: 自底向上为每个 node 计算每种指令模式的 cost,递归选最优。复杂度 O(N) per basic block.

DAG Legalization

%r = add i128 %a, %b

目标架构如果是 x86-64 没有 i128 寄存器——support 不直接。Legalize pass 把 i128 加法拆成两条 64bit 加法:

add rax, rcx       ; low 64
adc rdx, rdi       ; high 64, with carry

Legalize 类型:

  1. Type Legalization:超大类型拆成多次小操作。
  2. Op Legalization:x86 没有直接 SSE 的 fneg → 用 xor %xmm, [SIGN_BIT_MASK] 替代。
  3. Vec Legalization:把 <8 x float> 拆成两条 <4 x float> SSE 操作。

DAG → MachineInstr

DAG 选完 node 后,** legalize selection** 输出 linear sequence MachineInstr

%r1 = MOV32rm <frame>     ; load
%r2 = ADD32rr %r1, %i

MachineInstr 仍是虚拟寄存器,没有绑定到物理寄存器——这步交给寄存器分配 (见 regalloc.md)。


三、Machine Function Pass Manager

SelectionDAG 后是 Machine Function Pass** 串:

Pass作用
Prolog/Epilog Insertion添加函数 prologue (push rbp; save callee-saved) 和 epilogue
Register Allocator虚拟寄存器 → 物理寄存器 + spill 提出
Instruction Scheduling指令顺序优化 (List Scheduling)
Branch Folding删除空 basic block + 合并相邻无条件跳
Peephole局部模式替换,比如 mov r1, r2; mov r2, r1 删第二行

这些 pass 都是 target-specific 通过 TargetInstrInfo 接口暴露 hook 给 generic pass。


四、FastISel

SelectionDAG 全方位但慢——对大函数编译时间显著。LLVM 同时有 FastISel: simple direct translation,不优化但快——-O0 调试构建用 FastISel,省时间。

GlobalISel 是新一代统一的指令选择器(基于 IR-level pattern matching + legalizer → bank selector → reg alloc-related),现代 ARM target 推。


五、Codegen 性能:实例对比

自动 vec

void add(float *a, float *b, float *c, int n) {
    for (int i = 0; i < n; i++) c[i] = a[i] + b[i];
}

LLVM SelectionDAG 在 X86 target 上 pattern-matched:

  1. IR vec 后 → <4 x float> addADDPS
  2. Legalize <4 x float> → SSE/AVX
  3. load 中 getelementptr folded 进 SIMD load pattern
.LBB0_8:
  vmovups ymm0, [rdi + 4*rax]
  vmovups ymm1, [rsi + 4*rax]
  vaddps  ymm0, ymm0, ymm1
  vmovups [rdx + 4*rax], ymm0
  add rax, 8
  cmp rax, rcx
  jl .LBB0_8           ; AVX2 8 性能 8 个 float per iter

不向量化版本

c[i] = a[i] + b[i] 改成带 pointer alias 时:

void add_alias(float *a, float *b, float *c, int n) {
    for (int i = 0; i < n; i++) {
        c[i] = a[i] * 2;
        a[i] = c[i - 1]; // 假 alias,c[i-1] 依赖 c[i-1]
    }
}

→ 没向量化。LLVM 标无法 prove no-alias → fall back to scalar。


六、LLVM IR 是软件工程师的工具

很多大项目直接用 LLVM IR:

项目用途
Rust把 Rust MIR 翻译成 LLVM IR,借 LLVM 优化器
JuliaJIT 编译时直接生成 LLVM IR
Halide数字图像处理 DSL,定义 schedule 后 emit LLVM IR
GPU NVVM基于 LLVM IR 的 NVPTX backend
CraneliftWASM runtime 的 IR (独立设计,但概念同 LLVM)
WASM SIMDLLVM IR 上 vector op 映射到 WASM SIMD 指令

LLVM IR 在工业里这么红是因为它丰富、稳定、配套生态完整——同时是 cross-target API,又是优化 pass 的输入格式,又是 JIT 的快通道。工程师会读 LLVM IR 是和编译器对话的关键技能——clang -S -emit-llvm foo.c -o - 看 IR、godbolt.org 看对应汇编,调优起步。


七、易错清单

  1. LLVM IR SSA 在 exception 边界:landingpad 节点处需要特殊处理;C try/catch + patchpoint 的 unwind 表必须正确。
  2. getelementptr 的 inbounds:标 inbounds 表示没溢出,编译器更激进做 alias analysis;错标会 silent UB。
  3. nocapture 错标 → caller 错期望 callee 不存指针:bigfoot 性能 bug。LLVM 的 _ARG nocaptureinferattrs pass 自动推。
  4. SelectionDAG 与 GlobalISel 不可同时用:target 选其一;现代 AArch64 默认 GlobalISel 实验。
  5. Debug info 与优化 pass 同步:老 LLVM 版本 O2 把变量位置打飞 → debugger 乱跳行号。Location-tracking 改进。

八、等等 X86 翻译:

关于 LLVM IR 与 SelectionDAG

LLVM IR 在 SSA 上做 machine-independent 优化;SelectionDAG 在 per-basic-block 上做指令选择,把 IR 翻译成 MachineInstr(虚拟寄存器阶段) → 寄存器分配 → 机器码。

这是 LLVM 的核心架构**——它让"IR 优化 pass"与"后端指令选择"解耦:第三方 target 不用重写所有 opt pass,只用写 .td pattern + legalizer hook 就上 LLVM 生态。

例:把 LLVM IR 的 add i32 在 RISC-V 上选择指令

; RISC-V 64 target
%r = add i32 %a, %b

RISC-V .td:

def : Pat<(add GPR:$src1, GPR:$src2),
          (ADD GPR:$src1, GPR:$src2)>;

SelectionDAG → MachineInstr:

%r = ADD %a, %b

Reg alloc 后:

add a0, a1, a2             ; a0/a1/a2 = RISC-V 寄存器约定

emit binary:

00000000 00100001 00000001 00110011   ; add a0, a1, a2 编码

这一章带走的东西

  1. LLVM IR 是 SSA-based、typed、跨架构的中间表示——前端 emit、后端 translate。
  2. SelectionDAG 用 tree-pattern dynamic programming 选最便宜的机器指令模板。
  3. Legalize 把 IR 上启发式不支持的类型/操作拆成目标架构支持的指令序列。
  4. Machine Function Pass 一串:prolog/epilog insert + reg alloc + sched + peephole。
  5. LLVM IR 是工程师的"和编译器对话"工具,会读 IR 是性能调优的入门门槛。

下一节 → 寄存器分配

JIT / Tiered Compilation / V8 TurboFan / Cranelift

TL;DR

JIT 编译器在运行时把字节码(或源码)翻译成机器码,热路径深度优化、冷路径保留解释器执行。Tiered Compilation 把这条流水线分多层:最开始解释器(Ignition、HotSpot 解释器、PyPy 解释器)零编译、~10-100x 慢;快速编出基线机器码(V8 Sparkplug、HotSpot C1)做 1x 接近 AOT;最热的代码用激进优化器(V8 TurboFan、HotSpot C2)做到 LLVM -O2 级甚至更优——因为有运行时 profile 可以做纯静态难以做到的 devirt、type feedback、speculative inlining。理解 V8、HotSpot、Cranelift、Julia、PyPy 的 tiered 模型与 deoptimization 机制是 JIT 现代化的入口。


一、JIT vs AOT 工程对比

维度AOT (LLVM/GCC)JIT (V8/HotSpot/PyPy/Julia)
启动直接跑机器码必须先解释 + 编译
峰值LLVM -O3 接近上限HotSpot C2 / V8 TurboFan 可超 LLVM
优化依据静态分析 + PGO (人工采样)运行时 profile 自动
解决 polymorphismC++ devirt 模板、虚函数分析V8 inline cache、HotSpot CHA
Binary 大小原生二进制字节码 + JIT cache
部署可重现✅ 同输入同 binary❌ JIT 受执行时 profile 影响
安全编译产物可 auditJIT compile 由地址必有 W^X 麻烦事故

AOT 优势是首次执行快、可 reproducible、无 security concern。JIT 优势是因为 profile 在运行中收集、能做推测性优化 + 可 deopt → 实际峰值经常超 AOT


二、Tiered Compilation 设计

V8 (JS) 5-tier 现代架构

JavaScript source
  ↓ Parser
AST
  ↓ Ignition Compiler
Ignition bytecode
  ↓ interpret (initial)
hot bytecode → counter threshold
  ↓ Sparkplug (baseline JIT, fast compile)
fast baseline machine code
  ↓ hot further (counter)
TurboFan (Tier 1 optimizing)
  ↓ if polymorhism/re-checks fail
Maglev (Tier 2 mid-top optimizing)
  ↓ hottest methods
TurboFan (Tier 3 top, speculative + inlining)

HotSpot (Java) 5-tier 编译

0. 解释器
1. C1 with profiling
2. C1 without profiling   # cold code 编译了一次没有后续触发
3. C1 with profiling     # collect types + branches
4. C2 aggressive optimizing with profile

HotSpot OSR (on-stack-replacement)、CHA (Class Hierarchy Analysis) + escape analysis + speculative devirt 让 C2 编出来的代码再 5-30% 超 GCC -O3 静态编。

Go vs Rust vs Python

  • Go: 默认 AOT (gc tool chain),无 JIT。gctrace debug 模式可观察。
  • Rust: AOT (LLVM backend),Wasm 时 Cranelift 替代 LLVM 做 JIT 级速度。
  • Python: CPython 解释器无 JIT(3.13 之前);PyPy 有 JIT (RPython-based trace compilation);3.13 起有 adaptive interpreter (specializing bytecode),但不是 JIT。

三、V8 TurboFan 内幕

Sea-of-Nodes IR

TurboFan 用 Cliff Click 1995 设计的 Sea-of-Nodes 表示:

  • 节点 = 操作 (Add, Load, Phi, CheckMaps, Branch...)
  • 边 = 数据依赖 (输入值) 或 控制依赖 (control flow edge) 或 effect edge (内存副作用顺序)
  • 是把 CFG 与 SSA 融合的"图 IR"——各节点自由调度,没显式 basic block 直到 schedule 阶段才把 Nodesort 进 block

特殊在中有 Effect dependency edge:一条 Store *p 之后跟着 Load *q,编译器把 q 与 p 用 effect edge 串起来——除非 alias analysis 证明无关系。

Type Feedback Vector (ICs)

V8 在 Ignition 执行每条字节码时记类型历史:

function f(a, b) { return a + b; }
f(1, 2);    // IC slot: (Smi, Smi) → Smi+
f(1.5, 2);  // IC slot: (HeapNum, Smi) → Num+ (unbox float)
f("a", "b"); // IC slot: (String, String) → String+

每条操作有 inline cache (IC):第一次跑时探测类型 + 选择 specialization, 后续命中直接走 specul code。多态 (polymorphic) 时 IC vs monomorphic 大幅差性能。

TurboFan 优化时 query IC,得到代表类型,编译生成只看一种形态的指令 + deopt guard——若运行时类型 deviate,bailout 回 Ignition。

Speculative Inlining

function g(x) { return x.f(); }

x.f() 可调用 100 个不同类,静态分析不可 inline。但 profile 显示 99% 调用对单一类型 A.f → TurboFan specul devirt + emit:

CheckMaps x, A_map
call A.f
guard not taken: deopt to interpreter at frame index N

热 path 跑 devirt,0.1% path 走 guard 失败回退。HotSpot 称为 speculative devirtualization

Deoptimization (Deopt)

V8 deopt 流程:

  1. Generated code 在每个 specul 假设的位置放 deopt guard: cmp map(x), expected; jne deopt_pad
  2. deopt_pad 调用 Runtime_Deopt:用 frame translation 信息恢复 Ignition 字节码状态 + 寄存器状态 (反推 SSA → 栈)
  3. 跳回 Ignition 的对应 bytecode offset 执行

deopt 大量使用 Maps(隐藏类)、FrameInfo——LLVM 与 Cranelift 没这种"反编译到 IR" 机制,因为它们不假设持续执行。

WebAssembly 在 V8

Wasm binary → Streaming Decoder
  → Liftoff (baseline JIT, fast compile)
  → TurboFan (optimizing when hot)

Wasm binary 是 1) typed, 2) structured (no control flow irreducibility), 3) 无 polymorphism——比 JS 简单。Liftoff 编译快,TurboFan 给热函数再优化。Wasmer、Wasmtime (基于 Cranelift), GraalWasm 等都是类似 pipeline。


四、HotSpot C2

Ideal Graph

C2 的 IR 是 Ideal Graph (Sea-of-Nodes variant)——与 TurboFan 概念接近。Click 在 90 年代写 HotSpot 早期版本 (Strongtalk) 时发明的 Sea-of-Nodes。

C2 的 Phase

Parse  (bytecode → Ideal Graph)
PhaseIdealLoop (loop tree、range check elimination)
PhaseIterGVN   (global value numbering)
PhaseIdealLoop (more)
PhaseIGVN
   ↓ (scheduler)
Macro Node expansion (alloc → scalar replace)
Matcher (selecting machine instructions, x86-64)
RegAlloc (Chaitin-PBQP)
emit

C2 做的 LLVM 难做的优化

优化C2LLVM AOT 等价
推断虚函数 monomorphicCHA + 类型 profile-fdevirtualization specul (有时)
scalar replace(escape analysis)dead static elimMemCpyOpt + alias analysis
biased locking锁对象最近被同线程使用 → 测试 → 直接进入 — no atomic无 (语言级无 monitor)
on-stack replacement解释 → JIT 切换中 frame
range check eliminationloop bound profile 推断-fcheck-new-roots

重要副效应:JDK 17 deprecated biased locking

2021年 HotSpot 移除 biased locking —— 大量工程 benchmark 显示 它收益小、维护成本高。Safer LockSupport 兼容、JIT 仍可消除锁。


五、Cranelift:Wasm 时代的新 JIT

Cranelift 是 Bytecode Alliance 的 Rust 编译器后端,为 Wasmtime、Wasm3、Firefox SpiderMonkey 后端提供 fast JIT 编译。

设计

  • CLIF (Cranelift IR): SSA + typed + Sea-of-Nodes wind + ebb (extended basic block) 流
  • Fast compile: 编译速度 ≈100MB/s, 3-20x 快于 LLVM 在 -O0
  • Quality: 接近 LLVM -O1, 不向量化激进
  • Reg Alloc: Regalloc2 (graph coloring with splitting)
  • Target: x86-64, ARM64, RISC-V64, s390x

用 LLVM Process

2023 起 Wasmtime 加 "Cranelift + eBPF-style lazy optimization",目标是 1) 启动时基线快、2) 热点可后续 elevate 到 LLVM JIT。这就是 layered JIT——Cranelift 是基线,LLVM 是高阶。

WebAssembly Component Model

Cranelift 与 WIT、Wasm 接口结合——Wasm component module 之间的 calls 通过 Cranelift 生成的 trampoline,无需重编 module,这是工业上 JIT 模块化的早期阶段。


六、PyPy Trace-based JIT

PyPy 用 trace-based JIT (Truffle 之外的另一种派别),跟 method-based JIT (HotSpot) 不同:

1. 解释器跑循环 / 凄暖路径
2. trace recorder: 沿实际执行路径记录指令
3. 在 loop 辑后停下、组装成 loop 形式的 linear trace
4. optimize linear trace (constant propagation、loop peeling, inlining via trace stitching)
5. emit machine code (x86-64/ARM64/Z80 用)
6. Guard at start of trace: 类型匹配、循环上界等,否则 fail → fallback interpreter

Trace-based 优势:loop-friendly 优化、linear 简单;劣势:分支多 + 迭代不规整时 trace 完整性下降。

LuaJIT 头部 trace-based JIT

LuaJIT (Mike Pall) 是业界最快 trace-based JIT 之一。LuaJIT trace 大部分是几 KB、quality 大致 = HotSpot C1。Mike 用大量手工汇编 trampoline。 异步 runtime


七、JIT 实施事故

V8 Speculative Devirt 大退步 (2018)

Chrome 老版本 V8 "BackgroundCompileThread" 偶发 race 导致死锁——TurboFan deopt 后 re-compile 时丢失部分 IC,下次运行 0.5%-3% slower。Fix 用 ReadWriteLock。

HotSpot Reference Queue Overflow (1997)

旧 FullSpeed ReferenceQueue 在 GC 时可能丢软引用,导致 finalize 不调用揭晓的 bug。HotSpot 经典 bug fix "Use precise reference discovery"。

Native Image 容器 JIT memory: JIT cache 与 native stack 冲突

Linux x86-64 第 47-bit memory limit:JIT cache 在 0x7fff... 高位、native stack 在低位 → unreachable mapping。一些 Cloud Foundry / WSL 1.x 环境导致 JVM crash。Native Image、JIT exec memory via mmap 在 sbrk 上同对策。

ETA JIT Cache Flush (Apple Silicon macOS)

macOS Apple Silicon 下每个 W^X 必须 pmap_cs_* invalidate JTC cache、JIT 编译后 mmap PROT_EXEC—PROT_WRITE 翻转。JIT 在 Apple Silicon Mojave ~15-20% slower than x86_64 macOS 现 Apple thread_set_exception_ports 加 fast release — 解决。

W^X 与 mmap PROT_EXEC

很多 JIT 是感mmap(..., PROT_READ|PROT_WRITE) 写完代码 → mprotect(PROT_READ|PROT_EXEC) 启用 exec。 SPD Linux 中 CAP_SYS_RAWIO 起才有 — fix 经常 needs sysctl kernel.randomize_va_spacevm.mmap_min_addr

Cranelift Bug series (2020-2022)

Cranelift reg alloc 在某些 high-pressure 函数生成错误 spill slot → silent miscompile 一个 Wasm17_WASI 子模块 benchmark。回归测试定期 catch 。


八、JIT 通过外部接口

TIER HINTS

HotSpot 把付费用户获取的 profile 经人工采样 摆到 AOT:AOT compile 用 -XX:+PrintTieredEvents, JIT 收集的 IC 与 hot loop dynamic run 编出 特定 AOT module so 更优生成代码。

Project Leyden (Java) 把启动时代解释 + C1 编译收 移到 AOT,而运行时仍运行 C2 优化热路径。三分 系。

GraalVM Native Image 把 HotSpot JIT 全推成 AOT — 不再有 JIT 在运行时 编译。Run-time 优惠: 低内存、启动快、镜像小。但峰值 slower than C2,无 trace.

LLVM ORC JIT

LLVM 自带 ORC (On-Request Compilation) JIT API —— ClangRepl, Cling in ROOT6 用 LLVM JIT streaming during REPL runtime commands。Kits / engineering Kernels Jupyterling 等 REPL JIT 都用 ORC。


九、JIT vs Specul side effect 工程师视角

如果你写高性能 VM 服务:

  • 热启动: V8/HotSpot tilt1 编 + 解释阶段时间长——通常 30s+ warm-up。注意 K8s rolling-deployment。
  • Tiered flow peak:一般 50k-100k 触发 C2 编译—— 做 has 高 throughput 随机负载需要预 warm-up。
  • Memory budget: JIT cache 通常 256-1GB 内存消耗。Hammerver 控 code cache size via ReservedCodeCacheSize、"max JIT";
  • 可重现 build: JIT 受运行时环境影响——同样 binary、profile 影响运行时性能。常 forgot fixed performance benchmark 上 baseline dry-run 模式可能 不可比。
  • Security: JIT exec memory 需 PROT_EXEC—让攻击面增加。Bugs in JIT (CVE-2018-) 是误 protect hole, Use-after-free in deopt path common.

十、易错清单

  1. JIT run from cold start: 启动 benchmarking 1000 RPS 给 Go/Java 服务 超高 loadbalancer 上肯定 误。给 warm-up phase , measure 稳态 throughput。
  2. TieredCompilation disabled for benchmark 容易被忘记: JMH Java benchmark 默认 tiered;profile 全状态。
  3. Devirtualization guard 高估: 即使 specul devirt 后 90% dependency。若动态类型 fall outside IC 一直 changing (polymorphic call check map),VIP 中 时间不会看 5-7 类型 STI TurboFan pass 设置 W.
  4. Forever keep JIT-cached code: W^X memory probs是 fixed-size, JIT RT_FlushIC 可能 default-Ignore。需 控制 reserve code size。
  5. LLVM ORC 不能完整 HotSpot C2: ORC 仅 supports AOT-level opt passes + lazily-compile module——不做 deopt、不做 speculative devirt。
  6. Wasmtime attrdeopt: Wasmtime 不做 Garbage collection, mem segfault (incl D lang) 无 JIT - silently going abort, limit MC trapping mechanism.

十一、这一章带走的东西

  1. JIT 在运行时 compile, 用 profile 收集真实执行路径与类型信息——AOT 难做到。
  2. Tiered Compilation 把解释器 (Ignition HotSpot强大的interpret)、Baseline JIT (Sparkplug C1) optimizing JIT (TurboFan C2) 串发明了启动与峰值的平衡。
  3. V8 是 Sea-of-Nodes IR + Inline Cache Feedback + Speculative Deopt——与传统"译成 AOT" 完全不同。
  4. HotSpot C2 用 Ideal Graph + escape analysis + CHA + biased locking, 跟 LLVM-O3 比 +5-30% spec-benchmarks.
  5. Cranelift 走 "fast compile first, hot code promote to LLVM later"——Wasmtime、 Firefox SpiderMonkey 后端。
  6. PyPy 是 trace-based JIT 与 method-based JIT (HotSpot / V8) 派别。
  7. JIT run-time risk: profile volatility, code cache, security (PROT_EXEC/W^X), 与启动 warm-up cost 是 AOT 场景仍然 受欢迎原因。

下一节 → 分布式系统总览

第六部分 · 分布式系统

一句话

分布式系统 = N 台独立计算机通过通信协作表现为一个整体。核心挑战:部分故障 (partial failure)、不可靠网络 (unreliable network)、无共享时钟 (no shared clock)、并发控制 (concurrency)。无数论文在解决同一个问题:怎么让不同机器对一个值"达成共识"

思想链

API 高层:用户 POST /api/order,看似一个简单 RPC 调用——

[ HTTP 请求 ]
    └─ 网络 → 负载均衡 → API gateway (无状态) → Order Service (无状态)
           └─ 强一致状态机 Raft 复制组 (5 节点,多数派持久化)
                  └─ Paxos 协议选出一个 leader
                         └─ 所有写入串行化进 Multi-Paxos 实例
                                └─ WAL 落盘 fsync
                                       └─ 此时才返回 200 OK 给客户端

请求每一次成功 commit 都涉及:网络多跳、consensus 算法、持久化、leader election / membership change。这一层是字面意义决定高可用或永久数据丢失的关键——李飞飞团队 2017 Spanner 论文证明了 CRDT 不足以做银行账户;Apple WebObjects 2007 事故证明了 Paxos 错误实施可直接让集群锁死 30 小时。理解分布式系统不是"加更多机器让事务变快",而是"在 partial failure 下证明你的 promise 仍 hold"。

6 个章节

读完应能回答:

  1. Paxos vs Raft vs ZAB 的核心差异(leader election、log replication、membership change)
  2. Quorum 为什么 W+R>N 是必要而非充分条件
  3. CRDT 如何无 conflict merge,与 serializability 的代价
  4. Vector clock 怎么检测并发写、HLC 怎么无 GPS 提供 causal consistency
  5. Erasure code RS(10,4) vs 3 副本在存储与可用性的折中
  6. Google Spanner 用 TrueTime 实现 external consistency 的代价 (commit wait)
  7. 2PC 协调者崩溃为什么阻塞、Saga 为什么拿不到原子性、Outbox 如何保证"写库+发事件"原子

历史 1:1978 Lamport "Time, Clocks"

奠定整个领域基础——"分布式系统中事件顺序不能靠 wall clock,要靠 happens-before"。Lamport 之后所有 paper 都假设这个 partial order 是基础事实。

历史 2:1989 Paxos, 1998 Lamport 拖了 9 年才发

1989 Lamport 发现 Paxos 但写成希腊神话式 paper "The Part-Time Parliament",没人懂;1998 重写 "Paxos Made Simple" 才被 Google 采纳成 Chubby。Raft 2014 由 Diego Ongaro 在 Stanford 写完为了"比 Paxos 更易懂"。

历史 3:AWS Dynamo (2007)

Berkley Amazon Dynamo 论文发表,业界第一个大规模 AP + Vector Clock + Hinted Handoff 的工业系统。Cassandra、Riak、Voldemort 都跟。

历史 4:Google Spanner (2012)

第一次工业实现 external consistency —— 靠 GPS / 原子钟 TrueTime API 实现跨数据中心线性化。Sharding + TrueTime + 2PC 让 Google Ads 全球任意节点写入都能 audit。Apple、CockroachDB 都跟 SR 树。


下一节 → 基础概念

基础概念

基础概念部分铺分布式术语:CAP / PACELC 定理告诉你什么是不可能的;一致性等级 (Linearizability / Sequential / Causal / Eventual) 告诉你可以选择什么强度;故障模型 (Crash-stop / Crash-recovery / Omission / Byzantine) 告诉你算法在什么假设下成立。读完这部分,后续 Paxos/Raft/CRDT 的设计就变成必然——它们的取舍都能在这些坐标里被讲清。

CAP / PACELC / BASE

TL;DR

CAP 定理 (Brewer 2000 假说、Gilbert-Lynch 2002 证明): 异步网络下,分布式系统在 Consistency (C, 线性化)、Availability (A, 每个请求最终响应)、Partition-tolerance (P, 容忍消息丢失) 三者中最多满足两个。由于真实网络必分区 (P 必选),实际选择是 CP vs AP。PACELC (Abadi 2010) 进一步补充: 即使无分区时也需在 Latency vs Consistency 间权衡——给完整取舍空间。BASE (Basically Available + Soft State + Eventually Consistent) 是 ACID 反面、AP 系统的设计风格。这章的目标是区分理论形式化 (CAP 是定义严格的定理)、工程实践 (CAP 是开发者口中的"标签")、常见误读 ("CP 永远不可用"、"AP 永远不准")。


一、CAP 形式化 (Gilbert-Lynch 2002)

三性质的定义

性质形式化定义
Consistency线性化 (linearizability) : 所有操作看上去都在某个全局实时顺序上原子完成, 任何 read 返回最近一次 write 的值
Availability每个对非故障节点的请求最终收到非错误响应 (无超时限制)
Partition tolerance网络可能丢失任意数量消息 (一对节点间任意方向丢包), 系统仍工作

定理: 这三者不能同时满足。证明核心: 一个 system 假设满足三者, 考虑两节点 A 和 B 被网络分区、客户端向 A 写 "x=1"、向 B 读 x——A 必须响应 (availability), 但 B 不能与 A 同步 (partition), 于是 B 返回旧值——违反 consistency。

关键细节: 异步网络 + 总能完成

证明依赖异步网络 (消息延迟无上限) + 总能完成 (liveness 强定义)。若系统允许在分区时无限封住请求直到分区恢复 (即 不严格保证 availability, 只 guarantee eventual), 那 CP 就成立——etcd / ZooKeeper 实际是这种"partition 时 best-effort block"。

Consistency 等级

CAP 中 C 严格指 线性化 (Linearizability), 是最强形式一致。更弱:

  1. Linearizability: 单对象强一致。
  2. Sequential Consistency (Lamport 1979): 所有进程看到的操作序列全局同一序, 但不要求与 real-time 一致。
  3. Causal Consistency: 有 causality 关系的操作各进程看到的序一致; 无 causality 的并发操作可不同序。
  4. Eventual Consistency: 给足时间无新写入时所有副本会收敛。

CAP 中的 C 严格只指 1。现代系统如 Spanner 是 Linearizable, Dynamo 是 Eventual, MongoDB 是 Sequential (默认), Cassandra 是可调 ( LOCAL_QUOURM → 介于, CL=ALL → linearizable)。


二、CP vs AP 实例

CP 系统

分区时拒绝服务保证 consistency:

  • etcd: Raft + WAL persistent。Quorum 不可达时写入阻塞。
  • ZooKeeper: ZAB (Paxos 变种) + 5 节点 quorum。Leader 仅一个, leader died 重新 elect ~200ms, 期间不可写。
  • HBase: HMaster 单点 + RegionServer副本 backed by HDFS NameNode (单点 HA via QJM)。强一致 but 单点 fail-stop.
  • Spanner: TrueTime + Paxos groups。TrueTime 不确定窗口期间 commit wait, 保证 external consistency。

几乎所有 CP 系统并不是"分区时 unavailable"——只是"分区时降低可用性"——决策都是"是否抛弃本次请求"。

AP 系统

分区时继续服务保证 availability, 用 eventual consistency 收敛:

  • Cassandra: Dynamo 架构, W+R 可调一致性。Quorum 不可达也可降级写。读到的可能 stale (但 eventually converges)。
  • DynamoDB: AWS Dynamo 论文基础, Eventually consistent reads 是默认, 但 2018 起支持强一致 read ($R=W+1=N$ 在最新副本).
  • Riak: Vector Clock + hint handoff + active repair。
  • CouchDB: Multi-master + MVCC。

AP 系统关键的代价: 用户可能读到 stale data。需要业务层接受这窗口——Twitter timeline、Amazon shopping cart acceptable, 银行账户一般 not。

工程实现的 nuanced: CA 真的存在吗?

理论上: CA 系统 = 单机。在单进程数据库 (PostgreSQL 运行在一台机), 无网络分区概念。一旦跨网络或跨物理机, 就有分区风险, 就必须容忍 P——即放弃 CA。生产中常说 "CA 系统"不严谨——他们实际指 "高可用 CP" 而非真正不要 P。


三、PACELC (Abadi 2010)

公式

if (P) then {A vs C}
else       {L vs C}

即使无分区时 (Normal case), 系统仍需在 延迟 (L)一致性 (C) 间选——更高一致性通常需要更多确认、跨节点的延迟。

系统分类

系统else (无分区)then (分区)
Cassandralatency-priority (LOCAL_ONE 等)availability-priority
Spannerconsistency-priority (TrueTime 保证)consistency-priority
DynamoDB RClatency-priorityavailability-priority
DynamoDB StrongReadconsistency-priorityconsistency-priority
MongoDBconsistency-priority (write concern=majority)consistency-priority (writes 阻塞)
Riaklatency + tunableavailability-priority
PNUTs (Yahoo)consistency-priority (write master)consistency-priority

工程含义

SLA: Amazon 对 DynamoDB 写承诺 P99 < 10ms——要满足这条, 写入必须不强同步 quorum 全确认, 故放弃 strong consistency。Spanner P99 write ~100-200ms 跨数据中心——Google 同意付这延迟, 换取 linearizability。

为什么 CAP "三选二"是误读?

PACELC 更精确:

  • "三选二" 把 else-then 全混在一起。
  • 实际工程系统是双维度: 平常常 L vs C, 分区时 A vs C。CAP 只描述后者。

四、BASE (Basically Available / Soft State / Eventually Consistent)

Pritchett 2008 eBay 提出。BASE 是 ACID 反面:

ACIDBASE
Atomic (原子)Basically Available (基本可用)
Consistent (一致)Soft State (软状态)
Isolated (隔离)Eventually Consistent (最终一致)
Durable (持久)

BASE 工程方法:

  1. Compensating Transaction: 业务层补偿不 XA。如电商扣库存失败、后续 saga 回滚之前的支付。
  2. Saga Pattern: 长事务拆成 N 步、每步带补偿函数; 失败时反向 undo 各步。
  3. Idempotency: 所有 retry 必须幂等。eventId 去重, payment id client-generated。
  4. Eventually Consistent: 接受 inconsistency window, 用 task queue + retry 收敛。
  5. Read Repair: 读时发现不一致就此修复。

与 CAP 关系

BASE 大多对 AP 系统而言。 BASE 系统不放弃可用性, 牺牲一致性 → AP+ELC。 但 Spanner 这种 CP+EC 的工程 BASE 就不用——Spanner 是 ACID-distributed 的事务路线, 严格 linearizable。


五、常见误读清单

误读纠正
"CP 系统永远不可用"只在分区时阻塞, 平常是 5 个 9 可用性
"AP 系统永远不一致"分区结束后会收敛, eventually 一致
"三选二" (CA, CP, AP)P 必选, 实际只有 CP vs AP; 更完整是 PACELC
"MongoDB 是 CP"取决于 write concern — w=1 时基本 AP, w=majority 时 CP
"Spanner 是 CA 因为是强一致"仍然 P- tolerant, 是 CP+EC, 跨数据中心分区时暂停写
"CAP 是 100% 教科书定理"实际证明确立前提是异步网络, 同步网络可绕开 (practical 同步不存在)
"BASE = 最终一致 就行"BASE 还要 idempotent retry + compensating + 业务级 invariant 维护

六、易错清单

  1. CAP 中 C 不是 transactional consistency。它是 linearizability (单对象强一致)。 提到"CP+ 事务"是另一层 (linearizable + serializable)。
  2. PACELC 中 else 是"常态"、then 是"分区":
    • 文章常错写 "在 P 后选 A or C, 否则普通工作"。 实际 else 描述的是平常也要选 L 或 C。
  3. CAP Availability ≠ 高可用性定义: 形式化 A 是"每个请求都有响应", 不是"5 个 9 上线率"; 后者更宽, 可以容忍 leader election 期间 1-2 秒不可用, 前者需要"任意时刻有响应"。
  4. partition tolerance 不是"网络分区扛住无停机", 实际是"网络分区时仍能维持 promise": partition 时当然会 loss 一些功能, 但不能协议错乱。 CP 系统在此正确"宁可停写不可二心"。
  5. Brewer 反思: 2017 Brewer 自己写 "CAP 十二年" 撤回"三选二" 提法, 提倡 PACELC。

七、这一章带走的东西

  1. CAP 是 asynchronous + 总能完成 假设下的定理, 真实系统都放宽 (eventual completion);
  2. CP 不是"always available minus partition"; 是"分区时牺牲可用性、让一致性 promise 保护数据"。
  3. PACELC 把"常态也要选 L vs C"加进来, 完整画像就清晰。
  4. BASE 是 AP 系统工程模式: idempotency、补偿事务、saga、eventual consistency。
  5. 业务层根据场景选 CP vs AP:
    • 银行 = CP, component 牺牲延迟换 strict 强一;
    • shopping cart 消息、日志 = AP, 用户体验优先。
  6. Tunable consistency (Cassandra、Riak) 是后 CAP 时代的和解——让调用者决策, 而非系统专制。

下一节 → 一致性、线性化与序

一致性、线性化与序

TL;DR

分布式系统里"一致性"是多义词——本节梳理三套区分:

  1. 线性化 (Linearizability): 单对象最强的实时一致性——任何看到"前面写入"的客户端都看到该写入, 现代"实时一致"通用代称。
  2. 一致性等级体系: 从强到弱——Strict(单 processor)→ Linearizable (real-time global)→ Sequential (single global order) → Causal (causally-related ordered)→ Eventual (eventually converges)→ Read-your-writes (per-client)。
  3. 顺序与因果: Lamport defines happens-before () partial order, 由"消息发送/接收, 同进程内序"构造; Vector clock 用一组计数器在 N 节点上捕捉 happens-before, 检测并发但非 total order; HLC (Hybrid Logical Clock) 把 wall clock + logical clock 合一, 兼顾分布式因果和人读时间戳。 理解这后面 Paxos/Raft/CRDT 的取舍能显性化。

一、线性化 (Linearizability)

定义 (Herlihy & Wing 1990)

并发操作 (operations on a single object) 总可看作一个全序 (a total order), 且该 total order 满足:

  1. Real-time order: 若 op1 完成在 op2 开始之前 (real-time), 则 total order 中 op1 必须排在 op2 之前。
  2. Sequential spec: total order 是一个合法的 sequential history——即, 是单机串行执行的一种可能结果 (e.g., 就是普通读写的语义)。

直观例子

Client A: write(x, 1) at t=10, ack at t=20
Client B: read(x) at t=15, response at t=25  → 必须返回 1 (因为 A 的 read 已 ack)
Client C: read(x) at t=18, response at t=22  → 可返回 0 (因 A 尚未 ack)

线性化的承诺是: 当 A 收到 ack 后, 任何后续 B 的 read 都看到 A 的写。

线性化 vs Serializability

  • Linearizability: about 单对象并发; 实时保证。
  • Serializability: 多对象事务; 实时保证——只要等价于某个 serial 事务序列即可。

一个 system 可以是 linearizable 但 non-serializable (空 if use multi-object transactions). 一个 system 可以是 serializable 不 linearizable (e.g., 一个 read-only transaction 可以看到 t=15 写但 t=20 位先 ack 也 OK)。

Spanner 提供的是 both—— linearizable + serializable together call "strict serializability" 或 "external consistency"。

测试线性化: Jepsen Knoss

Jepsen 大杀器: 把某次 run 收集全 client operation 的invocation-completion pairs, 喂 Knoss 算法——枚举所有可能的 total order 检查是否存在合法的 (线性化)*。 这是 exponential worst case, 但实战上小 model 能验千次 ops.


二、Sequential Consistency (Lamport 1979)

弱化线性化——取消 real-time 约束, 只要求一个"看上去各 node 共享的 total order"。

A: write(x, 1) at t=10, ack at t=15
B: read(x) at t=12, return 0
C: read(x) at t=20, return 1

合法: total order B read → A write → C read 各进程看到的序一致, 但 B 的读在 A 的写之前完成 real-time——违反了 linearizability 但 sequential OK.

Sequential 比 linearizable 弱, 但比 causal 强 (因强加 total order). 现代 CPU memory model 通常仅保证 sequential consistency (且大多是 weak model—— TSO, ARM relaxed).

CPU Memory Model 类比

  • x86: TSO (Total Store Order) — 大部分 sequential, 但 store-load 可重排。
  • ARM: relaxed — almost any reordering, only data/control deps enforced。
  • Java JMM / C++11 memory_order_seq_cst = 严格 sequential consistency。

线性化是 distributed systems 强一, sequential 是硬件 memory order 强一——同概念不同领域。


三、Causal Consistency

Partial order 形式化

定义 happens-before :

  • 同进程: e1 → e2 if e1 happens before e2 in same process.
  • 跨进程: send(m) → recv(m) (发出 before 收到)。
  • 传递: e1 → e2 ∧ e2 → e3 ⟹ e1 → e3
  • 否则 concurrent: e1 || e2.

Causal consistency: 所有 nodes 看到的因果关系保持序, 但并发可任任意序。

Vector Clock

N 个 nodes vector clock VC_i 维护 N-element count:

on send: VC_i[i]++ then send VC
on recv(m, VC_m): VC_i[j] = max(VC_i[j], VC_m[j]) for all j; VC_i[i]++.

判定: VC_a < VC_b iff VC_a[k] ≤ VC_b[k] for all k AND strictly < for some k.

并发: VC_a || VC_b(不 ≤ 也不 ≥)。

工程用: Dynamo, Riak 用 vector clock 检测 concurrently writes。 每个 update 加上 client-side VC; read 时高 VC → 覆盖旧 VC; concurrent VC 全保留 → siblings + merge function。

Version Vector, Dotted Version Vector, Interval Version Vector

演进路径:

  • Version Vector (VV): N 维, 简单, 但每 writer 必须同步协调 N= nodes 数。
  • Dotted VV (DVV): 解决 VV 假设全 writer 持有当前 VV, 否则假 sibling; 引入 (dot, cluster) 表达精细化。
  • Interval Version Vector (IVV): 适应 dynamic membership 变化, when node failure/ add, vector interval 而不是 count。

Riak 现版用 DVV, Cassandra 用 VV + LWW (Last-Write-Wins) 简化算法。


四、Read-Your-Writes Consistency (Session Guarantees)

Terry et al. 1994, Bayou 系统。session 是一个 client 的"使用上下文", 在 (possibly sticky-load-balancer) 单 server 或一个 quorum 对读写的关联。

四种 session guarantee:

Guarantee含义
Read-Your-Writes (RYW): client 看到自己写入Web 用户体验基本——购物车追加后看到新项目
Monotonic Reads: 后续 reads 看到更近期的数据单调, 不"看到 back-in-time"
Monotonic Writes: 一个 client 的写入的 serializable 总顺序与自己 process 内一致避免 undo user 感受
Writes-Follow-Reads: 同一 session 之前读到的值, 后续 writes 在该值基础上锁/版本更安全

工程实现:

  • Sticky load balancer 保障 session 一定打到同一副本 (常见但是 bad failover path)
  • Read 时含 client_last_seen_vclock, server 检查 last-VC 后有的 update 都同步到自身后再响应 client (synchronous read-repair).

五、Eventual Consistency

最弱: 给足无新写入 时间, 所有副本最终 converge (e.g., VC 等效)。提业务可接受的 "时间长度"。AP 系统是 eventual consistency 的如下根源:

  • 无 quorum: W=1 R=1 N=3 — 允许 client 写出现在另一副本
  • gossip/Anti-entropy 同步最终传播
  • merkle tree 加速 read repair (Riak/Cassandra)

Eventual 不意味着 "等同 linearizable":

Dynamo 测试中, eventual consistency 与 linearizability绝对不等——client user 看到 timeline 已 push 后改用旧 timeline 反向 replica, insider sees LWW 取舍了 user complex about time.

SLO 上 eventual: window 多长?

DynamoDB Eventually Consistent Read: <1s window 在集团内。Cassandra (~minutes if no read-repair 默认) wallclock 设置 gc_grace_seconds 默认 10 days。


六、HLC (Kulkarni 2014)

Problem of pure LC

Lamport Clock 单调 64bit count; 平常很快但与 wall clock 无关——读 2024_05_01 10:23:00 看 clock "5" 很反直觉。

HLC

HLC = (physical_ts, logical_count)
init: HLC = (wall_now, 0)
on_send/recv:
  if wall_now > HLC.physical:
     HLC = (wall_now, 0)
  else:
     HLC.logical += 1

Vector style 同 vector clock 维护 N 个 HLC——cockroachdb、yugabyte、 mongo cluster 都用 HLC 替代 wall clock 在事务上做 ordering。

HLC 提供:

  1. causal consistency (类似 LC, 自然支持)
  2. timestamp stability: 不知道因果, 可以用 PH.ts vs PH'.ts 直接比较
  3. debug 友好: timestamp 是 wall clock 大致时间, 排查 bug 容易

TrueTime

Spanner 是 Google 一个跨越——不等 HLC, 直接用 GPS/原子钟提供 TrueTime API TT.now() = [earliest, latest] 区间 + commit-wait until upper-bound 确保每 commit 的 ts 严格小于后续 begins。 TrueTime 不是 HLC, 但基础 wall clock 保证 external consistency——这是 Google 的工程极限。


七、一致性选择光谱

Strong (低 throughput, 高延迟)
─────────────────────────────────
Strict Serializable        Spanner
Serializable + Lin        CockroachDB, YugaByte, FaunaDB
Serializable                PostgreSQL (单机)
Snapshot Isolation SI      PostgreSQL REPEATABLE READ, Oracle
                            ─────┘并行调度 easy
Read Committed              PostgreSQL READ COMMITTED, MySQL InnoDB
                            ─────┘Panicking jdbc 默认 — 单 statement - level checks
Causal Consistency          COPS, Bayesian Stores, TLA+ Casual storage
Read-your-writes            DynamoDB (consistent 当前 repo), Riak
Monotonic Reads
Eventual Consistency        Cassandra, Dynamo (no level tune)
─────────────────────────────────
Weak (高 throughput, 低延迟)

图中从上到下,restrictiveness 下降, engineering 付出下降。BAT-Ocelot-OTLP 也顿美事务 SLA 支持更价el noW"


八、一致性 vs 可用性 vs 延迟 — SR Trade-off 矩阵

ConsistencyN=3 R W例子缺点
LinearizableN=3, R=3, W=2Lock servicewrite 高延迟
StrongN=3, R=2, W=2Quorum with W+R>N大多 read OK
RYWN=3, R=1+hint, W=2DynamoDB sessionread 高延迟
EventualN=3, R=1, W=1Dynamo RC时间不对

Practical:R = ? 一般公式:

要保证任意 quorum overlap 有最新复制, 需 R + W > N; 若需要 R = W = N for linearizable 默认 + 通过 commit-token guarantee write 全部 replica 收件 → latency 高但 linearizable.

Quorum.customizable Cassandra W = focal.2 一_ .2 R = FOCAL_TENANT_R (=2 in standard configs). W+R=N+1+1 = 3+1=4 >= 3 = N; W+R=N (W=2,R=1)不保证 overlap——可能读到一个副本其 没 updated 该 write.

Bounded staleness

CockroachDB / YugaByte / Spanner 都 limit staleness (e.g., 1-2s). through-put 与 linearizable close, read 行 cheaper:

  1. read at a follower, talk 心跳 cluster timestamp, follower 等待 直到 crdt helper exec tang t? +动手 gain 等到 its TS 在 read end; if read can not acquire past TS within staleness, fback to quorum.
  2. Time-travel query 指定 past TS (MVCC), 取得 该 TS reader — strict replay 不 read_quorum

九、易错清单

  1. Linearizable ≠ Serializable: 前者单操作 real-time; 后者多事务换序等价。 Spanner 是 "External Consistency" = 两者并存。
  2. HLC ≠ Vector Clock: HLC 单 node 节 PT+LC 但 VC 多 N node 多 Counter 数组。 HLC 取 VC portability friend + fast。
  3. Sequential Consistency: 不保证 real-time. 没 HLC LC 与 VpC 的 total order-保证 二次 认 read your writes.
  4. Eventual ConsistencySLA 测 eventual: 工程师口 "eventually" 不要视为 unbounded by SLA. DynamoDB eventual RC < 1s, Cassandra 没默认 SLA。
  5. Causal Consistency 无 linearizability doesn't capture multi-version isolation 对 Gallery: そこ read stale 老于因果分布 alternate ** causal Capture informacji crwrap doesn . Crummerish приемnoWfish wouldn't- solomono 判断 不能 li-nearable**.
  6. HLC cockroach-sync-action: actual necessary fixed — 因分散不同 TS atomic, 同一 physical commit may伤心 ts skew with reader.

十、这一章带走的东西

  1. 一致性是有等级的: linearizable → sequential → causal → RYW → eventual, 由强转弱, 代价由大变小。
  2. Vector Clock 把"发生前个" 形式化为 N 维 counter array, 检测各种 concurrent writes.
  3. HLC 把 wall clock 融入 Lamport clock, 给因果关系上保与 human-friendly timestamp 一起两个。
  4. TrueTime/Spanner 是 GPS + commit-wait 实现 external consistency, 是 linearizable+serializable 的现实极限。
  5. ObjectTunable consistency Quorum 公式 R + W > N 保 quorum overlap; R = W = majority 为最低保证 linearizable。
  6. 业务依 SLA 分级: bank → linearizable, carno-shelf → causal/RYW/internet, logs/geo … → eventual。

下一节 → 故障检测、failure models

故障检测、failure models

TL;DR

分布式算法建立在故障模型 (failure model) 假设上——节点会怎么死、网络会怎么丢。核心区分:

  • Crash-stop: 死了永远不回来。Paxos 经典模型。
  • Crash-recovery: 死后能重启但是稳定存储 OK, Scribe / GFS master replication 用这个。
  • Omission: 消息可能丢, 但节点本身能"接收其他节点" (重新传达)——网络层故障。
  • Byzantine: 节点可任意行为 (撒谎、串改、应该发 5 但发 9), 需 Byzantine Fault Tolerance (BFT) 算法。

不存在能容忍"所有故障、又有 5 个 9 可用"的算法——这是不可能的。设计分布式系统从选 failure model 开始——决定后面用什么算法。本章梳理各 model、failure detector、heartbeat、phi-accrual 算法、asynchronous vs synchronous 网络的根本限制。


一、Failure Models

Crash-stop (Fail-stop)

节点一旦死,永远不再发任何消息。 系统中越简单故障模型, 算法越容易写:

  • 节点之间互相发心跳; N 秒无心跳 → mark dead; leader 选举只看 evidence。
  • 一旦不准 recovery, 重启就是新身份 (新 node id)。

Paxos 多副本经典模型。 zookeeper-3.x ZAB acceptors 假设 crash-stop (但应用程序 failrecovery may restart state).

Crash-recovery

节点死后可重启, 稳定存储 (persistent state) 保留 (e.g., Raft log fsync-ed)。这是真实工业系统的常态。

关键: 不能区分"crash恢复后" 与 "缓慢但已活"的节点——所以算法 need stable leader election + log commit 后注意 re-sync (at-least-once or exactly-once semantics).

Omission

网络丢消息, 但节点本身正常。 分:

  • Send omission: 节点发送丢, 接收正常。
  • Receive omission: 节点接收丢, 发送正常。

Omission 看似温和, 但要至少 strong failure detector (Chandra-Toueg 1996) 才能 solve consensus。

Byzantine (Lamport-Shostak-Pease 1982)

节点可任意行为——撒谎、伪造、不响应、发出非协议消息。 Byzantine Generals Problem 假设至多 f 个 byzantine 节点, 算法需 N ≥ 3f+1 (i.e., 备用超过 fault)。N≥3f+1 是 lower bound;PBFT (Practical BFT, Castro-Liskov 1999) 是经典 BFT 算法。

现代 BFT 应用:

  • 区块链: Bitcoin PoW 算非典型 BFT (treat 作算力 majority); Tendermint、HotStuff、DiemBFT v1 是经典 BFT 算法。
  • 集群 servers: 银行系统少量使用 PBFT 派生。
  • Spanner 复制组: 用 Paxos (crash-stop), 非 BFT。

工程代价: BFT 比 crash-stop replication 算法负载 N=3f+1, 通信复杂 O(N²), 故都在 ≤20 replica 时用。

Hybrid Model

工程上多种 model 混合:

  • Spanner Paxos group 假设 crash-stop + omission (message can be delayed indefinitely);Scribe 协议处理 omission。
  • BFT 区块链常 evidence treat extra Byzantine + crash-recovery 处理 by epoch leader rotating fast.

二、Failure Detector 形式化 (Chandra-Toueg 1996)

Completeness vs Accuracy

A failure detector 是 oracle 给每个 process 提供"怀疑其他 process 已死"的列表。 评估四个性质:

性质含义
Completeness (strong)所有 eventually crash 节点 eventually 被某 process 怀疑
Completeness (weak)所有 crash 节点 eventually 被某 correct process 怀疑
Accuracy (strong)correct process 永远不被怀疑 (无 spurious marking)
Accuracy (eventual)eventually, correct process 不再被怀疑
Accuracy (weak)部分 correct process 不被怀疑
Accuracy (monotonic)一旦不再怀疑 correct, 永远不再怀疑

Detector 分类

类型CompletenessAccuracy
Perfect (P)StrongStrong
Strong (S)StrongWeak + eventual
Eventually Strong (◇S)Weak + strongWeak + eventual
Eventually Perfect (◇P)StrongEventual

Consensus 可解的 Algebra

Chandra-Toueg 证明: ◇S (eventually strong) failure detector + asynchronous 网络 + N ≥ 2f+1 nodes → consensus 可解。 这是 Paxos 的本质——◇S 是 Paxos 隐式假设的 detector (任何节点, eventually 不再怀疑正确的 leader)。

纯异步网络 无假设时, FLP (Fischer-Lynch-Paterson 1985) 证明 consensus 不可解——任意 consensus 算法都可能"indefinitate" due to one process can be forever uncertain → 死锁。

工程绕开 FLP: 引入随机化 (Paxos proposer timeout + 随机 backoff); 引入◇S detector (heartbeat phi-accrual) eventually 结论。


三、Heartbeat 与 Phi-Accrual 检测

简单 Threshold Heartbeat

node A 每 1s 给 B 发 PING。
B 间隔 5s 没收到 PING → 怀疑 A 挂了。

threshold fixed → 太大延迟检测, 太小误报。

Phi-Accrual (Hayashibara 2004, Cassandra 用)

Accrual 含义: 给每个 nodes 出"故障 suspicion level", 是连续值, 而非二值 (alive/suspect)。 算法:

  1. 统计历史 arrival time。 维护最近 N 个心跳到达间隔的滑动均值。
  2. 假设 arrivals 服从正态分布 (实际用 exponential), 计算"since last heartbeat, 经过大空隙 X 自此没来的概率"是 phi 值。
  3. phi '越大, 越怀疑该节点死。
Phi(t) = -log10(P(t_now - t_last > Δ | x drawn from past arrival distribution))

Cassandra 用法

phi_threshold = 8
every tick:
  for each node peer:
    phi = compute_phi(peer)
    if phi > threshold:
       mark peer DOWN
    else:
       mark peer UP

Cassandra 默认 threshold = 8——对应 ~99.99999% 确认死。 Phi-accrual 减少 false-positive 比固定阈值好——网络抖动带来短 delay 不会直接 mark down。

Swarm/Etcd/Gossip Failure Detection

许多系统用 Gossip 协议扩散状态:

  • SWIM (Scalable Weakly-consistent Infection-style Process Group Membership): 多节点联合加直; 每节点 round-robin ping 一个 random member; ping 失败, random K 间接 ping; 仍 fail 加直suspect。
  • Consul, Serf 用 SWIM 派生 implementation。
  • etcd 用 Raft heartbeat + lease (3s timeout) 是 RAFT-style; 比 SWIM 更强的 consensus-detector.

四、Lease vs Heartbeat-based

Lease

Leader lease 是分布时间"硬 lease": leader 获 lease 后 lease 期内保证没人挑战。 lease 过期必须 renew, 否则下 leader 接管。

grant lease to L1 valid [t0, t0+T_lease]
if lease expired, no other leader can be elected in [t0+T_lease + ε] (给予 grace period buffer)

用于:

  • Chubby lease: 60s lease + renewal 30s, write 决策 on lease.
  • etcd Raft leader lease: defaulted 1s, upgrade per cluster config.
  • MongoDB multi-doc transactions lease: writes acquire 事务 timestamp within replica lease.

Lease 与 FLP 不冲突原因

Lease 引入了时间维度——必同步式 + bound: 由于 bounded lease ⇒ bounded halting ⇒ exit FLP indefinite halting trap.

安全性要求

Clock skew 不能让 lease 过期时间误判: 系统必须给出统一 lease 时间-source。 实际工程: 给每节点 NTP 同步 + lease renew 用 Raft majority write 多副本 sync leader 的话同一 cluster time.


五、FLP 不可能性 (Fischer-Lynch-Paterson 1985)

定理

纯异步网络 (消息延迟无上限) 下, 如果有 1 个节点可能 crash, 那 deterministic consensus 算法无法保证 termination (eventually all correct 都 AGREE).

证明 sketch

构造一个 initial configuration C0 是 bivalent—— 可 decide 0 or decide 1 (depend on 内部 schedule). 算法必须转 C0 into a 0-valent1-valent state——但我们可以streer schedule 让一个关键步骤 (e.g., 某个 message deliver) 在 system 处于 bivalent 时 crash 那么 node。去掉那个 message → system stays bivalent forever—— 阻止 termination.

工程绕开

绕开 FLP:

  1. Randomized consensus: 算法随机。 Paxos 用 randomized proposal number; Ben-Or 1983 也是 randomized. 阻止 indefinite 死锁.
  2. Failure detector with ◇S: eventual weak detector 允许 eventually "skip crashed"—— disconnect indefinite bivalent.
  3. Partial synchronous assumption: 实际 assumption "eventual message delivery"——一段 indefinite 第 phase 后总收得到 message。这与 eventual detector 一致.

工程区间 distributed consensus algorithms 都 mix 这三种 trick.


六、不可靠故障检测的应用

Eureka (Netflix) AP Failover

Eureka server 之间的 replication 用 AP + eventual converge — 单 server crash (- circuit b/c lapsed -active); clients 调用 target 多 target 多 server 走 best-effort. Eureka heartbeat 每 30s lease 90s. 可用性优先于一致, 默认 eventual heartbeat may show stale.

Cassandra Phi-Accrual Adjustment

Cabbandra 默认 phi = 8. Failure with spiking traffic chatter: heartbeat delay 短 preservation phi=4 时小哥显 高但 adjust phi=12 reduce false-positive, 但 increase detection delays (慢) — SLA trade-off.


七、故障模型与算法选表

Failure ModelConsensus 算法Storage 算法
Crash-stopPaxos, Raft, Multi-PaxosPrimary-Backup, Chain ReplicationChubby, etcd
Crash-recoveryPaxos+log, ZAB同上 + log replayZookeeper
Omission + crashPaxos+◇S detector同上 + retransmitTCP, Cassandra
ByzantinePBFT, HotStuff, TendermintBlockchains, some bank livenessBitcoin, Cosmos Hub
HybridBFT hybrid Paxos 多副本 to Byzantine 内集群 in/out cross-datacenter 太跨云金融 system

八、易错清单

  1. Pure async network + 1 crash impossible → 找绕 : 算 deterministic consensus 必须 0 个 crash. 工程用 random + detector + bound message 释出 FLP.
  2. Lease 是 explicit 时间 grant, 不是 implicit structure: lease 没理清导致 leader skaring cluster extra; 心 延迟 timeout abide by overlap of 2+ lease. detected Oversize g超 leaked Trasure.
  3. Phi value vs fixed threshold: 不要 混用 "5 秒 没心跳" 与 phi, 前者 fail over war 不 平稳. 自动后 finalize entry process SQL form.
  4. Byzantine ≠ 不诚实硬件 BTC smart contract Byzantine : byzantine 假设攻击者不能 break cryptographic signature, 工程实际 alleviate 难度. BC store multi node crypto.
  5. FLP "impossible" ≠ 现实不能 consensus: FLP 论证 existence of indep scheduling let never choosing . 但实际 scheduled clock times 不知 form partial synchoronus path
  6. Crash recovery ≠ state must flush to disk: 没 fsync WAL 的 crash recovery 系统是 NOT crash-safe (new replica 读 data corruption state.consensus on false commit).
  7. leader lease + externalizing duree certain手机: 调度器 必 包含 grace time wait os difference treat。

九、这一章带走的东西

  1. Failure model 是 distributed algorithms 的底层假设——crash-stop 比 crash-recovery 简单, byzantine 最复杂。
  2. Paxos/Raft 等 crash-stop consensus 上 exist in real-world via crash-recovery (用 fsync log).
  3. Chandra-Toueg ◇S failure detector + asynchronous → Consensus 可解. Raft heartbeat + lease 是 implicit ◇S detector。
  4. Phi-accrual failure detector 提供 continuous suspicion, configurable threshold, less false-positive。
  5. FLP 不能解决 in purely asynchronous model. 工程 evasion: randomization + DETECTOR + partial synchronous assumption.
  6. BFT 需要 3f+1 nodes 与 O(N²) communication, 工程上限定 budget ≤20 nodes 块 .

下一节 → 共识

共识

共识 (Consensus) 是分布式系统的"原子核" — 让 N 个节点对一个值/序列达成一致 (agreement), 并且该相同决定 forever 不变 (validity / integrity / non-triviality), 同时保证非故障节点 eventually 决定 (termination).

围绕 consensus 形式化的工程算法三巨:

  • Paxos / Multi-Paxos: Lamport 1989/1998, 是分布式共识数学上最通用语言。 难读, Google Chubby 把它跑生产实现。
  • Raft: Ongaro-Ousterhout 2014, 同 crash-stop model 但 leader-log 形式 simple.
  • ZAB: Yahoo! ZAB (ZooKeeper), "Atomic Broadcast" 是 total-order broadcast consensus.

其他实践中:

Paxos / Multi-Paxos

TL;DR

Paxos (Lamport 1989/1998) 是分布式共识的数学基础——让 N=2f+1 节点在异步网络 + crash-recovery 假设下, 对某 proposed value decision 一致。算法分两阶段: Prepare/Promise (Phase 1) 让 proposer 拿到 majority quorum 对 proposal number n 的"承诺"; Accept/Accepted (Phase 2) 把 chosen value 投给 quorum 通过。两 quorum 必 overlap 保证后续 proposer 不能 reverse 已 accept 的 value。Multi-Paxos 用 stable leader 跳过每 entry 的 Phase 1 把它降成 1-RPC commit, 这是 Google Chubby/Megastore/Spanner 的内核算法。本章梳理算法证明, 工程细节 (proposal number 持久化、log fsync、leader lease), typical bugs 以及跟 Raft 的关系。


一、Consensus 形式化

Safety (永远满足的三 invariant)

  1. Agreement (Consistency): 不同 process 不能 decide 不同 value.
  2. Validity (Non-triviality): decision 必须曾被 propose——不能凭空 invent。
  3. Integrity: 每 process 至多 decide 一次 (no flip)。

Liveness

Termination: 在不违 partial-synchrony 假设下 (≥N−f 节点 eventual recover, 消息 eventual deliver) eventually所有 correct process decide。

FLP 1995 已证: 纯 async 网络 + 1 node 可能 crash → deterministic consensus 不可解终止. Paxos 用 eventual partial-synchrony 弱假设 + random backoff 绕开 FLP。

Quorum 2f+1 怎么来的

设 N=2f+1 节点, 容忍 f crashes. Quorum size = f+1 (majority). 任意两 majority overlap ( 因 (f+1)+(f+1) = 2f+2 > N )。 已 accepted 的 value 通过 overlap 节点传播给下一 proposer——Paxos 安全性核心。


二、Basic Paxos 算法

角色

  • Proposer: 提出 proposal (n, v), n 是单调增 proposal number。
  • Acceptor: 投票, 保持 (promised_n, accepted_n, accepted_v) per instance。
  • Learner: 收 quorum ACCEPTED -> decide, 通知 clients。

工程实现: 三 role 同 process, 每个节点同时充当。

Phase 1: Prepare / Promise

Proposer P:
  n = bump_and_persist_proposal_number()
  send PREPARE(n) to a majority of acceptors

Acceptor A:
  if n > promised_n:
    promised_n = n        // fsync to WAL
    reply PROMISE(n, accepted_n, accepted_v)    // (null if never accepted)
  else:
    reply REJECT(n)

Acceptor 承诺不再 accept 比 n 低的 proposal; 同时曝光自己之前 accept 过的 (n', v') (if any)。

Phase 2: Accept / Accepted

Proposer P:
 wait majority PROMISE.
 let (n_max, v_max) = max accepted_n across replies (or none)
 if v_max exists:  v = v_max      // 强制采用先前 quorum accepted 的 value
 else:             v = my_originally_proposed_value
 send ACCEPT(n, v) to majority of acceptors

Acceptor A:
  if n >= promised_n:
    accepted_n = n
    accepted_v = v          // fsync to WAL
    reply ACCEPTED(n, v) to learners
  else:
    reply REJECT

Learner

收集 majority ACCEPTED → decide value → broadcast。

Agreement 证明

设两个 different values vA, vB 通过不同 quorum QA, QB (大小 N−f) chosen. Let acceptance instance:

vA 通过 (nA, vA) 被 QA accept; vB 通过 (nB, vB) 被 QB accept. 不妨 nA < nB. QA 与 QB 大小均为 ≥f+1, 总和 2f+2 > 2f+1 = N → 至少一个 acceptor ∈ QA ∩ QB. 设其为 A. 那当 vB 的 proposer 跑 Phase 1 时, 拿到 majority PROMISE 必含 A 的 PROMISE with accepted_n=nA, accepted_v=vA. vB 的 proposer 在 Phase 2 必须用 v_max中 ≥ vA. ⇒ vB = vA. ✓

Validity 证明

任 accepted value v 来自 Proposer's choice: Phase 2 "若没看到 prior accepted, v=originally proposed; 否则 v=prior accepted value",递归到达一定来自某 initially proposed value, 不能凭空织造. ✓


三、Multi-Paxos

为什么要 stable leader

Basic Paxos for single value 需要 2 个 RPC round trip per decide. 与 commit 后还要返回 client 又一轮 → 客户端单个 cmd 三个 RTT, 高延迟。

Multi-Paxos 优化:

  1. Proposer 跑一普遍 Phase 1 在 leadership begin, 获得承诺 majority acceptors 不再 accept proposal from anyone else
  2. 同 leader 任期内, 每 log entry 只跑 Phase 2 ACCEPT, 单 RTT。

工程上 leader 任期 = lease (e.g., 60s), lease 内 leader 独享 Phase 1 guarantee; lease 过期需 renew。

Log Replication

State machine model: 一系列 commands c1, c2, ... 多 instance Paxos 每 instance 对应一个 log entry, 各 instance 独立 decide。

leader 工作流 (稳态):

  1. client 发 cmd → leader append to log at entry i.
  2. leader 并行 send ACCEPT(i, n, v) to followers.
  3. 等待 majority ACCEPTED → commit entry i → reply to client.
  4. leader 串行 apply committed commands to state machine (FIFO order)。

Pipelining: 在 commit i 时可 RUNNING PERPARE DONE accept for i+1, i+2 同时—— leader WAL 持久化 提速.


四、工程细节

Proposal Number

每个 proposer 维护 (round, node_id) tuple 作为 proposal_n, lex-sort 保证全序:

  • round 单调增整数; 持久化 disk 保 crash-recovery 后 round ≥ restarted value。
  • 不持久化 → 重启 round 回退 → while active leader 已 accept 的 entry resettings 不做. round==m monarch 落 trike 协调 化.

Bug: Google Chubby 用 proposer_id + monotonic counter, fsync 至少每 lease (30s) — 实测 crash 后未 recovered round 发现 validity disgust 姓 lock drop 处理 加 cluded fsync on round_need++ 持 train gap.

Disk fsync 顺序

每个 acceptor 必先 fsync log, 后 reply ack。 顺序错可丢 safety:

  • Acceptor A 落地后, proposer 收 majority ACK → commit → reply client. 如果 A 没 fsync, client 收 success ack 后 A crash → A 重启不认 entry。 client 错认为 commit◈fine promised BA sending Ash. 本质 commit wait-顺序。
  • 写 ordering: write WAL -> fsync -> reply ACCEPTED/PROMISE

fsync 在 Linux 机械盘 ~10ms, NVMe ~few µs, 即使 NVMe 也 boud。

Lease Leader

Leader lease 保证 leader 在 lease 期内不被挑战 —— 避免 Phase 1 重做。

lease_grant (t_start, t_end)
n (round, leader_id) stable
on lease not expired: skip Phase 1, ACCEPT-only per entry
on expiry: leader renew (Phase 1) or new leader elect

时钟误差处理: lease expiry time + clock-skew grace period +=2s buffer 是保险。 Chubby 是接口 lease (60s) 与 client (12s default) 同步 over lease gossip 假 同 NTP ≤20s. 单层 lease 长 (lease 1s) + clock skew 100ms synch time 会 be safe if skew-Trim Leader 处理.

Membership Change

工程里用 joint consensus: 集群 config C→C' 转换走 entry 包含 simultaneously C+ C', 各 phase quorum = majority(C) ∩ majority(C') 联合, 保永远 run 在 multiple quorum. 下一 phase 完全 C' (commit 持续 Quorum only C'bene). Raft 同样 aCPU 单 retry 节点变化是 Wi-Di.

Read Path

Linearizable read Paxos decision (只是读 statemachine) — 但是 leader 必须 prove 仍是 leader 不会 stale:

  1. Lease-held read: leader lease 内能回复不需要 quorum read. 风险 lease timeout+clock skew 同 daemon leader 起按 回 飞成功的 lelapse replace more Leaders, race store over stle.
  2. Quorum read (read index): leader ping majority acceptors 确认自己仍 leader, 然后 reply—— 1-moment rtt 에 extra.
  3. Read from learner + historical snapshot: therapeutic through snapshot cpWalk implementation.

Apache RocketMQ–Kafka–ETCd–e_txn–


五、典型事故

Google Chubby 2006 — Lease Skew

Chubby 用 60s lease + client session 12s multiples, 某次长 GC pause + 时钟 drift 导致 client 纪事 lease epoch 也中 主 controller 伟误 cachaun. 修复: epoch numbers in lease + length quorum heatter.

ZooKeeper 3.3 — HBase Regossession

ZK follower network partition 与 leader 发 stale messages 违反 ZAB++; fix at ZooKeeper 3.4. 重链德 transaction state quorum follower partition increase client election. Zab at commit early in stand 品 mark memory lible over quietlines clone 1987理会 quorum messul sync leader-> RSS 域 价cribd autobroken inters failsafe rengo 入 elections oftime 抗 时间会 Up 教 central mark fail. Fixed: strict 种 verify term/commit index with Ack faileadditive boz.

Spanner Paxos Group Commit Wait

每次 commit group delta ciwt took 7-14ms TrueTime uncertainty commit Preset commit client wait post 不损 设 看继承 Tom Paxos.isDefined学习, gebraucht quorum commit longed finalize at trust OS external healthy links."Jisi Remote deep single-Megaston multi-tablet reordering Commit Paxos: always –5 Postgres efficiently UCM Sigcomm 2017


六、易错清单

  1. promised_n must fsync BEFORE reply PROMISE: 没持久化 crash 后回退, 下次 PROMISE 违反 previous safety, 两 leader 已 accepted 不同 value → split-brain.
  2. Majority = strictly greater than N/2, not equal: N=2f+1, quorum=f+1. 若 N=4 (偶), quorum 必须 =3, 否则两 不重叠 quorum 但 (2+2=4) 没有 严格 overlap.
  3. Multi-Paxos 不 = "Paxos 跑很多次": stable leader 是 critical optimization; 没 leader 每 entry 重 Phase1.
  4. Lease 期间 leader 实际不需 fsync each entry: 但,要 fsync leader_id + lease epoch refreshment. It is 否则 leader_id 字 Persistence undo 字节 lease re 电.
  5. Read Path 不是 tralling empty:
    • 没经 chengNet quorum 高度 不 split guarantee 直接load单读快私有 stale value off 视 read going outside read_quorum.
  6. Byzantine Paxos: 普通 Paxos 不能 byzantine. PBFT (Castro-Liskov 1999) 是 byzantine variant 的 P鹿 rebuild. quorum 要 2f+1 with f Byzantine, so N ≥ 3f+1。
  7. leader falslycrash suspected leader: 异 Zab nat De F.

七、这一章带走的东西

  1. Paxos core 安全源于 quorum overlap 传播 prior accepted value (next proposer obliged adopt 它), 保 Agreement。
  2. Multi-Paxos = stable leader + lease + log replication → 每 decision 只需 ACCEPT round (1 RTT)。
  3. Disk fsync 顺序 (WAL→fsync→ack) 是 crash-recovery 模型下 safety 的前提。
  4. Leader lease 让 read 不 quorum 可行——但是 lease 必须 sync fsync leader_epoch 防 split-brain。
  5. Membership change via joint consensus, 不是 atomically swap config。
  6. Raft 是 Multi-Paxos + log replication 简化版; ZAB 是 Paxos atomically broadcast 变种。

下一节 → Raft 详解

Raft 详解

TL;DR

Raft (Ongaro & Ousterhout 2014, USENIX ATC) 是 Diego Ongaro 在 Stanford 写博士时设计的"易理解共识算法" —— 目标是"为工业维护团队而写而不是为研究算法的人"。Raft 把共识问题分成三个子问题: Leader Election, Log Replication, Safety, 每个独立设计 + 强约束, 比 Multi-Paxos 直观。 etcd、Consul、TiKV、CockroachDB、RethinkDB、Kafka controller metadata (KRaft mode)、Notion、Apple FoundationDB**、Apache Ratis 都用 Raft。本章梳理算法细节 (term、选举、AppendEntries、log matching、commit index、membership change), 工程实现 (默认配置、性能数字、网络分区行为), typical gotchas (pre-vote、joint consensus、read index、leader lease)。


一、核心概念

1. 服务器状态机

每节点三种状态:

  • Follower:被动接收 leader 的 AppendEntries + RequestVote。
  • Candidate:选举期间, 自我 promote、RequestVote 拉票, 等待 majority (N/2+1)。
  • Leader:唯一接收 client 请求, 主动复制 log 到 followers。
stateDiagram-v2
    [*] --> Follower: 启动
    Follower --> Candidate: election timeout
    Candidate --> Leader: 拿到 majority vote
    Candidate --> Follower: 收到合法 leader AppendEntries or higher term
    Candidate --> Candidate: election timeout 重发 RequestVote
    Leader --> Follower: 发现更高 term

2. Term

term 是 monotonic increase integer, 任期编号。每次选举 term+1, 每次 RPC request/response 携带 term; 收到 term 比自己低的 RPC 拒绝, 收 to higher term 立即 downgrade 自己.

term 是 Raft 的逻辑时钟, 替代 Paxos proposal_number。

3. RPC

两种 RPC:

  • RequestVote(term, candidate_id, last_log_index, last_log_term): candidate 拉票。
  • AppendEntries(term, leader_id, prev_log_index, prev_log_term, entries[], leader_commit): leader 复制 log + heartbeat (空 entries 充当 heartbeat)。

二、Leader Election

Election Timeout

随机化 T_election ∈ [T_min, T_max] 默认 150-300ms。 当 follower 在此期间未收 leader heartbeat, → 转 candidate:

  1. term++, vote self, send RequestVote to all peers.
  2. 等三种结局:
    • 收 majority "yes" → leader, 立即 send heartbeat 确认权威.
    • 收合法 leader (AppendEntries with current term) → follower.
    • 超时无 majority → 加 tirη.term++;重选.

投票约束

投票方仅投给满足以下条件 candidate:

  1. 自己当前 term 与 candidate term 相等(=)或 lower (then bump term)
  2. 自己未投过 (投票 history 包括 restart 后 persisted term)
  3. candidate 的 log at least as up-to-date: 比较候选 last_log_index 与自己 last_log_index, 若 last_log_term 更高 → newer. 同 term 时 compare index。

Up-to-date 比较规则保证被选 leader 含 all committed entries——这是 Raft 的 safety invariant (Leader Completeness)。

防止 split vote

randomized election timeout 让多 followers 不太可能同时转 candidate. 实际工程 (etcd) ElectionTimeout=100-150ms, 集群越小冲突小。

Pre-Vote (Pingao et al. etcd extension)

防止短暂网络分区导致 节点 elevated term 后 "传染" 回原集群触发不必要选举:

  1. candidate 在 RequestVote 之前发 PreVote 询问 "如果我请求, 我能拿到 majority 吗"。
  2. follower 仅在 leader heartbeat 长时间无收时回 yes。
  3. candidate 拿到 majority PreVote ack 才真 term++ 发 RequestVote.

etcd 3.x preferred, METCommission academic thesis. Pre-Vote 是 Raft 论文未包含的工程 extension, 现 de-facto standard.


三、Log Replication

AppendEntries RPC

leader.send AppendEntries {
  term, leader_id,
  prev_log_index, prev_log_term,    // 最近一条已复制 entry
  entries[],                        // 新 entries (heartbeat 时空)
  leader_commit                     // 当前 commit_index
}

follower:
  1. if term < current -> reject
  2. if log[prev_log_index].term != prev_log_term -> reject (log inconsistency)
  3. for each entry in entries:
       if local entry conflicts (same index, different term) -> 清掉冲突的与所有后续
       append entry
  4. if leader_commit > commit_index: commit_index = min(leader_commit, last_new_index)
  5. reply (term, success, last_index, match_index)

Log Matching Property

  • 若两 log entries 同 index 同 term, 它们 value 必相同 (仙做 safety).
  • 若两 log entries 同 index 同 term, 它们之前所有 entries 必相同.

prev_log_index/prev_log_term 强制 backward check, follower 不匹配时 reject, leader decrement nextIndex 重试. 最终一致是 eventual + 失败时回退—update 沿图。

Commit Rule

leader commit entry 必须满足:

  1. 该 entry 在 majority nodes 上 stored (already replicated).
  2. 该 entry 是 current term 写的。

条件 2 是关键——Raft 论文 Section 5.4.2 Fig 8 的反例: 假若 leader 旧 term 时复制的 entry (但未 commit) 后来由新 leader commit 是 unsafe (因为可能 stale 副本)。 Raft 不直接 commit 旧 term entry;新 leader 必须用自己 term 写一条 entry, commit 自己 term entry 时间接 commit 所有 prior entries (因 log matching 保证)。

State Safety (Leader Completeness)

已被选出 leader 必含所有 committed entries——选举时 up-to-date check (last_log_index/last_term) 保证投票只投给同 up-to-date candidate, candidate 的 log 必延展 committed entries (因 majority overlap).

Snapshot 与 Log Compaction

log 增长会爆磁盘 + 重启慢, 周期性快照 :

1. leader 在 commit index 处 snapshot (state machine 应用前)→ discard log < snapshot_last_index
2. InstallSnapshot RPC 给 lag-behind followers
3. follower 收到 snapshot → 写本地 apply → discard 自己 log < snapshot_last_index

etcd 每 10000 entries 触发; TiKV 每 64MB region size。


四、Membership Change

Joint Consensus (论文 Section 6)

Raft 论文给出 general solution = Joint Consensus:

集群配置 C_old → C_new, 中间过渡期 C_old,new 同时活跃:
1. leader propose "C_old,new" entry to log, accepted by majority(C_old) ∩ majority(C_new)
2. entry committed → 节点同时按两 config 各自看待
3. propose "C_new" entry → commit (只要 C_new majority) → 切到 C_new
4. 不在 C_new 的节点退出

joint consensus 保证两 majority 总 overlap, 防 split-brain。 但两 quorum 独立 同时 active 要 majority(C_old) ∩ majority(C_new) ack, 复杂度大。

Single-Server Change (etcd/Consul 实际常用)

每次只 add/remove 一个 server。 关键 invariant: |C_old| = |C_new| ± 1, 通过简单的算术: 对奇偶 N 节点, 两 majority 必 overlap 至少 1 node。 $\left\lceil(N+1)/2\right\rceil + \left\lceil(N+2)/2\right\rceil = N + 1 > N$ ⇒ overlap ≥ 1.

single-server 可简化实现: 直接 propose 一个 special config entry, ACcept 后 stale config 节点退出。 工程上):

  • Add: leader propose AddNode, follower 收到后开始接受 AppendEntries 与 RequestVote。
  • Remove: leader propose RemoveNode, target follower 检测到自己不在新 config → stop。
  • 一次只一个 change, 然后等下一 change。

etcd MemberAdd API 即此; Raft 论文也允许 batch 变更(multi-server removal) 走 joint consensus, 但 etcd/Consul 都选择 single-server + 多次循环。


五、Read 处理

Linearizable Read

Direct read on leader 不稳: follower 没 at-newest-leader 起门 leader 隔离时间 client reads old mutation → read 不是 linearizable. 解决方案:

1. Read Index

1. leader 记录当前 commit_index
2. 发 heartbeat to majority 确认自己仍是 leader (1 RTT)
3. 等待 local state machine apply 到 commit_index
4. 执行 read, 返回 client

2. Lease Read

leader 用 lease (heartbeat-driven, leader elected 后 clock tick 自, 平均 sender 节点 确认为 leader, lease 内 read 不 quorum check.lease 过期必须 renew, 否则 relinquish leadership.

风险: lease 期间 leader 长 GC pause → lease timeout 后仍误以为自己是 leader, 同时新 leader 也在另一分区上选出来 —— stale leader 服务读违反 linearizability。

etcd 3.x 的 CheckQuorum=true 让 leader 每次 heartbeat 主动确认 majority 仍可 reach, 若 quorum 失联立即 step down; 这是 lease 读 + lease 校验的核心修案。


六、工业配置与性能

etcd 默认值

heartbeat-interval=100ms                # leader-to-follower heartbeat
election-timeout=1000ms                 # 必须 >> heartbeat (10x)
snapshot-count=10000
quota-backend-bytes=2GB

实际 etcd 调用 election-timeout 既给 candidate 等 majority 投票超时, 也给 follower 等 leader 心跳超时。推荐 election-timeoutheartbeat 的 10 倍以吸收 GC pause 与网络抖动。

跨数据中心典型调参

heartbeat=200ms (跨 dc 单 RTT ~20-100ms)
election-timeout=1000-3000ms (5-10× heartbeat)

性能数字 (etcd 5 nodes 集群, 默认设置)

  • Throughput: ~10-20k writes/s (range 200B per write)
  • Write latency: P50 ~10ms, P99 ~50ms

TiKV / CockroachDB 的优化

  • Batch: 多 entries per AppendEntries (wait_group)
  • Pipeline: leader 提议后立即 reply client, 与 fan-out 并行
  • Async pre-write: 提交 batch 不等 follow ack, 同时 fire 下 batch

七、典型事故

etcd 3.0 — 跨 DC Election Timeout 过小

某用户跨美东/欧 DC 部署 etcd, 默认 election-timeout=1000ms, 但跨洲 RTT 200-300ms 抖动, 多次观察到 leader 误判失去心跳并触发频繁选举——有时 5 分钟内 3 次 leader 切换, 上层 client 大量 retry, service 出现 5-10s 写阻塞。Fix: 调增 election-timeout=3000ms, 让 heartbeat 远小于 election timeout, 使跨 DC RTT 抖动不再触发不必要的 re-election.

Apache Ratis — Snapshot 与 In-Flight Log Race

Apache Ratis (Java Raft lib, 用于 Ozone) 早期版本: leader 给 lagging follower 发 InstallSnapshot 时, follower 同时可能收到旧 AppendEntries, 导致 log entry index 与 snapshot index 冲突, persistent 状态破坏需 manual repair. Fix: Ratis 3.x 加 snapshot receipt barrier, follower 收 InstallSnapshot 时拒收任何 AppendEntries 直到 snapshot apply 完成。

Kafka KRaft — Controller Migration Snapshot Mismatch

Kafka 3.x 引入 KRaft 替代 ZooKeeper 后, 某次 controller failover 后 new controller snapshot version 与 majority quorum 上 log 状态不一致(因 snapshot 写与 log 写不在同一 fsync), 触发 metadata topic 损坏。Fix KIP-866: log-snapshot atomic fsync group。

TiKV — 网络分区期间 leader lease 仍服务读

长时间 leader 自认 lease 已过期但 GC pause 长, follower 已选 new leader, 两 leader 同时存在——短暂 stale read. Fix: TiKV 用 hybrid logical clock + max lease 自适应(known lease-peer, max block wait)。


八、易错清单

  1. Election Timeout 必须 >> Heartbeat Interval: 推荐 ≥ 10× heartbeat, 防 follower 误判 leader heartbeat 丢失触发 election。
  2. PreVote 必开启: 防止短期间分区后 isolated node 不断 term++, 回归后传染整个集群 term inflation 触发 unnecessary elections。
  3. 旧 term 写的 entry 即使被多数复制, 也必须由 current term entry 间接 commit: 否则 Fig 8 反例, stale leader rejoin 后可能覆盖。
  4. Snapshot 必须包含 state machine snapshot index 之前所有 apply 状态: follower 收 InstallSnapshot 必须原子替换 local state, 防 partial snapshot apply 中 crash 状态混乱。
  5. Membership change 单 server 变更: 避免一次性 add/remove 多 node 时 (C_old majority) 与 (C_new majority) 不再 overlap。
  6. Client writes 必须 idempotent: command_id 去重, 否则 raft reply lost client retry 时双执行 (e.g., accounts transfer 重复执行)。
  7. Read Linearizable 一定要 Read-Index 或 Lease (而不裸读): 裸读可能读到 stale leader 的 local state, 违反 linearizability。
  8. Follower 接 InstallSnapshot 期间必须 ignore AppendEntries: state machine 半 apply snapshot 极易损坏 log consistency invariant。
  9. Pipeline + Batch 优化得记 in-flight entries: leader reply 前不一定 commit, lost leader 切换时 in-flight entries 已写 log但未 commit, 必须 resend or discard by new leader's log overwrite. 协议正确已经被 Log Matching Property 保 safety, 但 client side 一定要 idempotent。

九、这一章带走的东西

  1. Raft 拆分 Leader Election / Log Replication / Safety 三个独立 subproblem, 比 Paxos 易理解。
  2. Term 是 Raft 的逻辑时钟; 强约束 (lower term RPC reject, 看到 higher term 自己 downgrade) 保证 algorithm 不协议错乱。
  3. Log Matching Property 强 backward check, follower 拒绝 prev_log 不匹配的 AppendEntries, leader 减 next_index 重试, 最终一致。
  4. Commit Rule 必须 current term entry 间接 commit 旧 term entries, 防 Fig 8 反例。
  5. Raft read path Linearizable 通过 ReadIndex (heartbeat confirmation)Lease Read 实现, 每次 read 1 RTT 或 0 RTT (lease held).
  6. Membership change 推荐 single-server change (always maj(C_old) ∩ maj(C_new) 非空), 避免 joint consensus 的双 majority 死锁。
  7. Pre-Vote 防 partition-induced term inflation, etcd 默认开启。

下一节 → ZooKeeper / etcd / Linearizability

ZooKeeper / etcd / Linearizability

TL;DR

ZooKeeper (Yahoo! 2008 → Apache) 与 etcd (CoreOS 2013 → CNCF) 是分布式系统协调 (coordination service) 的工业 fact 标准。共同形态: 一个 small consistent KV/znode 存储 + 复制原语 + 通知/watch API + 配置中心 + 服务发现 + leader election + 分布式锁 + 元数据存储。ZooKeeper 用 ZAB (ZooKeeper Atomic Broadcast, Paxos 派生), etcd 用 Raft。 本章梳理 ZAB 对 Paxos 的关键优化、ZK 数据模型 (znode / ephemeral / sequential / watch) 与 session 机制、etcd 数据模型 (flat KV + lease + watch stream + compaction)、二者的 linearizability 实现(以及为什么默认 ZK 读不是 linearizable, etcd 默认是)、典型误用 (double-watch, ephemeral session reset) 与生产事故。


一、ZooKeeper 架构

ZAB 协议

ZAB (ZooKeeper Atomic Broadcast) 是 Flavio Junqueira 2008 设计的 Atomic Broadcast (Total Order Broadcast) 协议, 类 Paxos 但为 "log replication" 优化. 两个阶段:

  1. Phase 1: Discovery + Synchronization (Leader Election):
    • FastLeaderElection 选 leader (bully algorithm 派生)
    • 与所有 follower 同步: leader 把自己 last zxid 上所有 entries 复制到 followers //,** 部分写到的 followers truncate divergent suffix**, 落后 followers 拉补议 missing entries.
  2. Phase 2: Broadcast (Multi-Paxos-style log replication):
    • leader 接收 client write → 生成 zxid (单调增) → broadcast PROPosal 到所有 followers → majority 收到 → commit → broadcast commit.

zxid

zxid 是 64-bit 全序 monotonically-increasing id 由 epoch + counter 合成:

zxid = (epoch << 32) | counter

epoch 是 每次 leader 切换自增一次; counter 每个 transaction 自增 1。 让 follower 通过 zxid 比较"在 log 哪里", 也让 client 记 session last seen zxid 用作 failover recovery.

ZNode 数据模型

ZK 是 hierarchical tree, 路径如 /app/services/api-1/config:

节点类型行为
Persistent (持久)直到显式 delete, 客户端 disconnect 不删
Ephemeral (临时)客户端 session 结束自动删 (session timeout default 30-40s)
Sequential (顺序)创建时 ZK 在路径尾追加 monotonic counter, /leader-elec/node → /leader-elec/node00001
Container (容器, 3.5.6+)子节点全删后自动 delete, 避免 ephemeral 留 stale parent
TTL (3.5.6+)在一段时间无被修改+无子节点时自动 delete

Watch 机制

zk.exists("/lock", watch=True)  # 一次性回调, 节点变化触发

# 3.6+ 引入 persistent watcher: 注册后持续触发直到移除
zk.addWatch("/foo", persistent=True)

: 旧一次性 watch 在事件触发与 client register next watch 之间存在 race window, 可能 miss 中间变化。 Best practice: register 再 getData/getChildren (这是 "double register" 模式). 3.6+ PersistentWatcher 是正确做法.

Session 机制

每个 client 连接有一个 session, 标识 session_id + 64bit password。 session timeout 配置(e.g., 30s). client 与 server 之间周期 ping, 任一 server 看到 session 客户端活动延长 session 的 expiry。

若 client 与 majority server 长时间断连超 timeout → session expired → 所有 ephemeral node delete → watches trigger → 业务层若用 ephemeral 模拟占锁就释放锁。 这是 ZK distributed lock 模式核心。

读取 Type

  • Default read from 任何 follower: NOT linearizable, may return stale.
  • Sync() before read: follower 先发 SYNC RPC 让 leader 告诉它 last committed zxid, 等 follower apply 到该 zxid 再回复 client → linearizable read。代价 ~1 extra RTT。

二、etcd 架构

Raft + BoltDB

etcd v3 用 Raft (基于 etcd-raft lib, contributors 后抽出来为 hashicorp/raft) 复制所有 KV 写到 multi-replica log, 应用组 raft log 之后 commit 到 BoltDB(纯 Go kV store, 内存 + mmap)。 etcd 存储模型是 flat KV (不是树), key 是 string, value 是 protobuf-encoded struct。

Lease + TTL

etcd v3 引入 Lease: 客户端 grant 一个 lease(分配 64-bit lease_id, TTL 例如 30s), 然后将 keys 绑到该 lease。 lease 与 ZK session 行为相似—— lease TTL 到期 no keepalive → 该 lease 上所有 keys 自动 expire。

Lease 比 session 轻——一个 client 可同时持多个 lease,把不同语义的 keys 隔离 (e.g., service discovery keys vs config keys 各一 lease)。

Watch stream

etcd v3 watch 用 gRPC server streaming:

watcher := cli.Watch(ctx, "/foo/", clientv3.WithPrefix())
for resp := range watcher {
    for _, ev := range resp.Events {
        // ev.Type == PUT/DELETE
        // ev.Kv.Key, ev.Kv.Value, ev.Kv.ModRevision
    }
}

watch 是 persistent stream (不像 ZK 一次性), 触发不会 stream 中间 watch reset, 避免一次性 watch 的 race window。 stream 内 events 包 ModRevision (全局 revision 单调增), client 用 store.Revision 收齐 previous events 至 current revision.

MVCC + Compaction

etcd 是 MVCC (multi-version)——每次 key 写入保存历史 revision(类似 Spanner 用 ts)。 这让 watch 能从任意历史 revision 开始 replay events。 Compaction 是后台 GC—— cli.Compact(ctx, rev) 删除所有 < rev 的旧版本, 释放磁盘。

Linearizable vs Serializable Read

// 默认 Linearizable: 通过 leader / ReadIndex / Lease Read 实现
cli.Get(ctx, "/key")

// WithSerializable: 走 follower 本地 read, 不 consensus check, 可 stale
cli.Get(ctx, "/key", clientv3.WithSerializable())

默认 linearizable = etcd 用 leader + ReadIndex (1 RTT) 确认仍是 leader, 等本地 state machine apply 到 read index, 返回 client。若客户端跟 leader 不在同一集群需 follower forward 读则产生 ~3-5ms latency。


三、ZK vs etcd 工程比较

维度ZooKeeperetcd
协议ZAB (Paxos variant)Raft
存储模型znode tree (hierarchical path)flat KV (但 prefix range query)
API自定义 binary, Java primarygRPC + protobuf, multi-language
Watch 模型一次性 callback (3.6+ persistent watcher)gRPC streaming, persistent by default
Session 模型TCP sessionlease + TTL
Read 默认Follower local read (stale)Linearizable leader read
客户端代码Java/Python/C client, ZK lib per ecosystemGo/Java/Python/etc., gRPC stubs everywhere
典型部署~5 node cluster, JVM heap ~4GB~3-5 node cluster, 默认 2GB data disk
工业用Hadoop, Kafka 旧版本, Solr, HBase leaderKubernetes (1.20 之前) , TiKV, TiDB, Rook,不少 CNCF project

为什么 K8s 换成 etcd

ZK 用 JVM 内存大, 升级 JVM GC pause 长(~500ms), 导致 watch 重连, 偶发 5-10s service disruption. etcd 用 Go ~5MB binary + mmap 持久存, GC pause <100ms, 适合 latency-sensitive Kubernetes control plane。 K8s 调用 etcd 平均不到 5ms, 远好于 ZK 的 30-100ms cluster median.

但 ZK 在 大数据生态 (Hadoop、Kafka 老 / Solr Cloud / HBase master election) 仍占主流——因为生态已建立在 znode 树之上, migration 成本高, 性能也 ok。


四、Linearizability in Practice

形式 reminder

Linearizability (Herlihy-Wing 1990): 所有操作可被嵌进一个 total order, 使 (1) 顺序是 sequential-legal, (2) 与 real-time order 相符(若 op1 完成 before op2 开始)。

ZK 与 etcd 的实现策略

ZK: default read 不是 linearizable, 走 follower local state。 要 linearizable 必须 client 主动 zk.sync() 然后 zk.get() (1 extra RTT)。 多数 SDK wrapper 自动调 sync, 但 client 直 ZK API 时常忘记 → "短暂读到旧值"是误用常态。 ZK 3.4+ 已有线 negative caching + 非正式 linearizable read 但AMLRT.

etcd: default read linearizable, 通过 ReadIndex 实现. 代价 1 RTT (~5ms local, 30-100ms cross-DC).If client opt-in WithSerializable() 可走 follower read 但放弃 linearizable。

Distributed Lock with Linearizability

如果不 linearizable, distributed lock 实现 broken:

client A: acquire lock
client A: GC pause 10s
client B: 超时 acquire lock (skip expired A lock)
client B: 进入 critical section
client A: GC resume, 仍以为持有 lock
→ 两个 client 同时 critical section。

修: ZK 与 etcd 都通过 fencing token + 各 critical section 端用 token dispose 查: 后到者 violates token order, server reject write。 这种 token 在 ZK 用 ephemeral-sequential child znode 的 seqnum, etcd 用 lease + ModRevision。

Martin Kleppmann 2016 "How to do distributed locking" 论证 fencing token 是关键, 没 token distributed lock 不安全。


五、典型误用与事故

ZK Double-Register Watch Event Loss

def watch_callback(event):
    # 处理事件
    pass

zk.exists("/lock", watch_callback)
zk.get("/lock", watch_callback)

两次注册同时 watch, 触发只 fire 一次, 中间 event 可能 miss (callback 内 "执行业务" 然后 register next watch, 但 register 期间 ZK 触发新 event 已发出但本地 callback 没 hook)。

Fix: persistent watcher (3.6+) 或 callback 同步 register。

etcd Watcher Notification Loss

etcd v3 watcher 有 compacted revision check —— client 启动 watch 时指定 rev=N, 但若 server 已 compact rev > N → server returns compactionRev > N 错误 → client 必须处理. 没处理 → silent miss events.

Fix: client 监控 compaction event, 加 rewatch from currentRev + 业务级 reconciliation。

ZK Session Reset Storm during GCP Maintenance

2021 GCP 维护期间 ZK leader 重启且 follower 网络抖动, 多个 client session 触发 reset, ephemeral nodes 大批 delete, leader election 触发 stampede——服务 dev machine 不在平分钟 de Leader elect expon为 backoff 后 渡过. Fix: client SDK 加 randomized election retry + jitter.

etcd mvcc: database space exceeded

etcd 默认 2GB data backend, 长期写入但没定期 compaction → 满磁盘 → 拒收 new writes → cluster 进入 paused 状态. Fix: cron compaction (etcdctl compact $rev; etcdctl defrag), 或 -auto-compaction-retention=1h 自动 compact.

K8s API Server "STW: timeout waiting for etcd"

etcd cluster quorum 失(2 of 3 down) → K8s API server 阻塞所有写(Pod schedule、ReplicaSet 都 stop)。 多个 K8s 集群挂报告有关 etcd 的 root cause, 推荐低 fsync latency NVMe disk + 足够大 heap / high net bandwidth + 不要在 low-IOPS disk 上跑 etcd—— disk IO 是 etcd 实际 P99 latency 决定因.


六、易错清单

  1. ZK read 默认是 follower-local stale, 不是 linearizable — 必须 sync() 再读, 多数业务 case 不在意 stale 才可弊。 一定业务用 fenced.
  2. ZK ephemeral session 不是客户端 single conn — session 跟 ZK quorum 持久, client 切 connection 不 reset session; 直到真 timeout(默认 30-40s)才 expire。
  3. etcd 默认 KV 是 MVCC — Watch 必须 catch compaction 错误(<rev compacted), 否则 silent miss。
  4. etcd lease 续租必须 heartbeat ignore failure — gRPC client 必须周期 lease.KeepAlive(), 超过 TTL lease 自动 expire + keys 删, 业务回滚。
  5. Distributed lock 没 fencing token 不安全 — even with linearizability, long GC pause 仍可双 client 进 critical。 fencing token (znode seq / ModRevision) + critical section side verify 是 must.
  6. ZK watch 一次性 — 旧 API trigger 后必须 re-register, re-register 与新 event 间有 race; 用 3.6+ persistent watcher 或 careful double-register。
  7. etcd linearizable read 不是 0-RTT — 默认是 1 RTT leader check, cross-DC 部署时必算这个进 SLA。
  8. multi-region ZK/etcd 慎重 — 跨洲 ping 200-300ms, leader lease renew 需要 cross-DC heartbeats, 业务抖动会触发不必要 election。可用 multi-cluster + cross-replicate (MirrorMaker-like) 不直接 multi-DC ZK。
  9. ZK 5 节点不是"5个 9 可用" — true availability 取决于 election timeout、客户端 retry、long GC pause → client sessionId. 5 节点让你忍受 2 节点死但仍写。

七、这一章带走的东西

  1. ZK 用 ZAB (类 Paxos), etcd 用 Raft; ZK-生态大数据为主、 etcd-生态 CNCF/K8s 为主。
  2. ZK 数据是路径树 + ephemeral + sequential + watch; etcd 是 flat KV + lease + persistent watch stream + MVCC。
  3. ZK default read 是 follower-local(stale, 需 sync()), etcd default linearizable (1 RTT ReadIndex)。
  4. etcd MVCC: watch 必须 handle compaction error, 否则 silent event lost。
  5. Distributed lock 必带 fencing token (ZK ephemeral-sequential seq num / etcd ModRevision) 防止双 client 因 GC pause 同时进 critical section。
  6. Linearizability 是默认承诺(etcd) vs 外加承诺(ZK)的工程取舍, 影响测试用例 baseline。
  7. 集群 quota / disk IO / GC pause 是 etcd 在 P99 下破裂的根本; ZK 是 JVM 重启损失.

下一节 → 复制

复制 (Replication)

复制是分布式系统的"心脏"——把一份数据放 N 副本, 让 N 节点都持有"看似同一份数据"。复制的三大 axis:

  1. 拓扑: 单主 (primary-backup, leader-follower) / 多主 (multi-master) / 无主 (leaderless)
  2. 持久性 vs 可用性: N、同步 vs 异步、quorum 大小
  3. 冲突解决: 更新顺序、last-writer-wins、向量时钟 + merge、CRDT

工业实现:

主从 / 多主 / 无主复制

TL;DR

复制拓扑分三种:

  1. 单主 (Primary-Backup / Leader-Follower): 一个 leader 接 writes, 副本异步 / 同步跟不上, 5-10 ms 同城 P99。 MySQL replication、PostgreSQL streaming、Kafka partition leader、MongoDB replica set。
  2. 多主 (Multi-Master): 任一节点 (或各 DC 主节点) 接 writes, 系统用 vector clock / 统一时间戳 / 冲突解决让写入收敛。 CouchDB、DynamoDB Global、Cassandra multi-DC、MySQL gr。
  3. 无主 (Leaderless / Dynamo-style): 任一节点可向所有副本协调读/写, 客户端 / coordinator 接 quorum 决策。 Cassandra、Dynamo、Riak、Voldemort。

本章梳理每类的算法(含 leader election, output lag, write/read path), 协议特性(sync 全 ack vs async ack-after), 同步字符串与延迟权衡, 典型 multi-DC 配置 (master-master conflict / round-robin / latency-based routing), typical 事故 (MySQL splitbrain replication, Cassandra LWW lost write)。


一、单主复制 (Primary-Backup)

算法

  1. 一个 leader 节点 serializable 地接所有写。
  2. leader 把 log (op/WAL) 异步 / 同步 broadcast 到 followers。
  3. followers apply log 后 ack。
  4. leader 收 majority (sync 半同步) 或所有 (sync 全同步) follower ack 后向 client 回 ack (or 立即 async ack before follower persist)。
flowchart TB
    C[Client] -->|write| L["Leader"]
    L -->|log async| F1[Follower 1]
    L -->|log async| F2[Follower 2]
    L -->|log async| F3[Follower 3]
    F1 -.ack.-> L
    F2 -.ack.-> L
    F3 -.ack.-> L

同步模式

模式ack 等待一致延迟
Full Syncall N replicasLeader 死仍 ack最长 (最慢 replica)
Semi-Sync / Quorum Syncmajority ackleader 死仍有 majority
Asyncleader 本地 fsyncleader 死可能 loss

MySQL semi-sync replication: leader 等 ≥1 follower ack 后 ack client (5.5+); MySQL 半同步 plus 5.7+ 等多数 ack (AFTER_SYNC, rpl_semi_sync_master_wait_for_slave_count)。

MongoDB replica set 默认 writeConcern=majority, 等多数 ack, failure loss 仅在 quorum 全 partition/数据盘错下 P99。

PostgreSQL streaming replica: async by default (自 9.0 起 synchronous_commit=on; synchronous_standby_names='*'); 不内部 quorum 概念, 多个 sync standby 必须配置 list.

Failover

Leader 故障 → 选 follower → 决策是否新 leader (waiting lease timeout 抵御 stale leader):

  1. Manual failover
  2. automatic failover (Patroni, Orchestrator, Stolon)黄金标准是 quorum 锁存 leader epoch + lease。

新 leader 地址广播 (DNS / service registry) + 客户端 SDK 接受过期 retry。

写性能 vs RPO/RTO

  • async write: RPO = 复制 lag seconds, RTO = leader 切换时间 (~10s)。
  • sync quorum: RPO ≈ 0, RTO = follower promote ~5-10s。
  • sync all: RPO=0, 但任一 replica 慢就拖垮 leader。

工程常用 sync quorum + lidered lease 补偿。

典型事故

  • MySQL async failover lost writes: Amazon RDS MySQL 主要 (pre-Aurora) 在 failover 时复制 lag(秒级或更多) → 已 ack 的 client writes 在 new leader (更旧) 下被丢弃。Multiplex х 许单 ws lose because of failover, RDS Aurora (storage replication 同步) 接此后。

二、链复制 (Chain Replication)

van Renesse & Schneider 2004, 用作在 primary-backup 与无主之间动态抽象:

HEAD  →  Middle  →  Tail
写入  ↓           传出
       复制链
  • HEAD 接受 writes, 转给 downlink
  • Tail 接受 reads, 见 commit only if log has reached tail
  • 副本中间在amesl head 与 tail 之间 linear flow
  • Fail-over: Head died → new head 取最 long ppeeaš. Tail died → middle 取后 tail.

优点:

  • 副本同步设计简单, write latency 是 single-hop ping。
  • Read throughput 高 — all reads go to TAIL only, write、redependence 链 groups Optional
  • Linearizability guaranteed by appending to log + tail serializing reads.

Habitat: Yahoo Object Store (中小学 prototype 生产), Azure Cosmos DB chain replication mode (Strong consistency 内 TB and同 tt), Apache KECulet ARL.

限制: 链越长, Tail 节点 throughput 是整体写上限(Tail 是 write 单点).


三、多主复制 (Multi-Master)

动机

为了 multi-DC 高可用: 各 DC 有自己的 master, local writes 不必跨 DC RTT, total throughput 提升, partition between DC 仍可服务。

协议

协议描述
Async multi-master各 master 独接 write, replication 异步; replicas 用 vector clock 或 timestamp LWW 合并
Sync multi-master (PostgreSQL BDR 3.x)全 quorum 2PC, multi-master cross-replication, 强 serializable 但 latency = cross-DC RTT
Conflict-free via CRDT内部复制 log 是 CRDT (e.g., counters); 8x 的 incr 是 commutative
Operational transformGoogle Docs / Etherpad 的实时协同 —— 客户端 OT 加 encrypt server

Vector Clock Conflict Resolution

# client A at node A
v = {A: 1, B: 0, C: 0}
write v[A] += 1  # {A: 2, B: 0, C: 0}

# client B at node B updates different key concurrently
v_b = {A: 0, B: 1, C: 0}
write v_b[B] += 1  # {A: 0, B: 2, C: 0}

# 复制时 vector clock comparing
# not <= either ⇒ concurrent conflict ⇒ siblings list
# merge function decides final value

Multi-DC Cassandra

Cassandra 默认 NetworkTopologyStrategy (one replication factor per DC):

  • LOCAL_QUORUM: 写/读 quorum within local DC only (low latency, cross-DC async).
  • EACH_QUORUM: each DC 独立 majority, 跨 DC 强一致 (rare 用, latency 大).
  • LOCAL_ONE: 单本地副本, 极低延迟 + 最终一致.

MySQL gr multi-primary

MySQL Group Replication (8.0+):

  • XCom consensus: 类 Paxos; 任一节点接写, group consensus commit
  • multi-primary mode 默认禁: conflict detection by primary key hash "certify_record"; 同时 conflict → write 丢弃, client 收错误
  • single-primary mode (默认): 主写函数主, 其他 member standby; 等同标准 replication but with consensus quorum.

DynamoDB Global Table

  • 任一 region 可写 → cross-region async replication
  • 每个 replica 有er write 直接 ack client, 复制 async inter-region high write throughput
  • write time + region + vector clock — LWW with region clock参与. conflict rare, 但其实 raw globe:
    timestamp = (now_utc_ms, region_id_seed)
    now = max(now, last_seen) + 1
    
    region ids 全序; 后写 region 总成功

典型事故

  1. CouchDB 跨 DC conflict dedup failure (2014): couchdb by old 在 vector clock + LWW modes between datacenters dedup, some edits lost; Manual scan double needs.
  2. Cassandra Global Counter Clockwise Race: counters pre-2.1 用 heavyweight transaction, count-down + cluster 跨 dc dynamic numerical data tomb physical same endow write overwritten — fix in 2.1+ use better vector clock meta for counters.

四、无主复制 (Leaderless / Dynamo-style)

Architecture

客户端 (或 coordinator) nodirect向多个副本发送 read/write; 每副本独立 ack. Cassandra implementation:

flowchart TB
    C[Client/Coordinator] -->|write| R0[Replica 0]
    C -->|write| R1[Replica 1]
    C -->|write| R2[Replica 2]
    R0 -.ack.-> C
    R1 -.ack.-> C
    R2 -.ack.-> C
    C -->|"ack to client after W acks" --> Resp[Return]

Write Path (Cassandra)

  1. Client/coordinator pick N replicas for partition key hash.
  2. Send write request to all N replicas.
  3. Wait W acknowledgements (replication consistency level).
  4. Reply client.

W = 1 ANY ANY replica: 一副本持久化 (even是 hinted handoff). W = LOCAL_ONE 等 local-DC 一副本. W = LOCAL_QUORUM local DC majority. W = EACH_QUORUM each DC majority. W = ALL all replicas.

Read Path (Cassandra)

  1. coordinator pick N replicas.
  2. Send read to R replicas (default R = consistency level).
  3. Wait R replies.
  4. Compare timestamps + read repair in background (发新 version 给落后副本)

Quorum Consistency Level

R + W > N 保证 read 跟 write overlap → linearizable 实际是强约束 hohen 状态需read最新、 conjunct read with sync mutations; 但 Cassandra 一般只允许 read_your_writes with W=Quor R=Quorum.

N=3:
- Write_Quorum (W=2) + Read_Quorum (R=2): overlap 1 (with N=3, R+W=4 > 3) → stale-proof (within sync replica)
- W=1, R=1: 可能 read stale 复本完全
- W=3, R=1: write 全副本持久化, read-refresh 后马上可见 (但 read-not-efficient; depends)

Hinted Handoff

如果某副本 unavailable, coordinator 把 write 暂存 "hint" 本地, replica 回来后转发——保证 W=Quorum 期间 + 写 not lost if down ≤ hinted_handoff_enabled (default 3h) 间歇短.partition-of-snow availability.

Read Repair

Coordinator read 从 R 副本收到 ack, compare bodies不一致, 选 newer 副本, 后台送 new value to older replica。 system 自治中作 anti-entropy 修复过程。

Anti-Entropy (Merkle Tree)

Periodic background comparing 子树 between replicas, snap merkle root diff, transfer changed subtree。 Dynamo / Riak node-node.Streamming SSDler needs 内部 transport.

Last Write Wins (LWW)

Cassandra 默认 LWW——每 cell 有 写 timestamp (client-supplied, default now() ms 各副本)。 conflict = max timestamp。 风险: clock skew 让先到达但 timestamp 大的副本获胜; 写丢。 解决 = client 提供 monotonic timestamp (driver 内部生成) 或 use Lightweight Transactions (Paxos)。

Diverged Counter: Cassandra Counter

counter increment 不可简单 LWW——每副本累计 own delta + delta replication: Riak 用 CRDT (PN-Counter) Casual distributed counter, Cassandra 用自 maintain delta 模型 —— Cassandra 不允许 counter update 嵌入其他 update (atomic write batch 不允许 counter + 非counter).

Risks

// client at T=100 writes x=5 with timestamp 100
insert into foo (id, x) values (1, 5) using timestamp 100;
// client at T=99 (clock skew) writes x=4 with timestamp 99
// 复制到 all N 副本, time LWW => x=5 wins (timestamp 100>99) ✓ (correctly)
// 但 if reverse skew — t=99 but real-time after t=100 — newer write lost. This is the LWW hazard.

Light Weight Transactions (Paxos)

Cassandra IF clause 用 Compare-and-set via Paxos:

UPDATE accounts SET balance = balance - 100 WHERE id = 1 
IF balance >= 100;

实际跑 4 阶段 Paxos (Paxos + serialization + quorum read + commit + ack); latency ~30-100ms (装备 quorum RW). 不在所有事务用—— 性能差 10-100×。


五、决策矩阵

需求推荐 拓扑协议例
严格金融 linearizable write单主 + quorum syncCockroachDB, Spanner spans Paxos groups, PostgreSQL + Patroni quorum同步
高写入, 可附录弱一致无主 + LWWCassandra, DynamoDB
多 DC 写, weak consistency多主 asyncDynamoDB Global, CouchDB
多 DC 高可用 + 强一致multi-region CockroachDB / Spanner跨 DC quorum, latency 大
实时协同文档OT or CRDT algorithmAutomerge (CRDT), Yjs (CRDT), Google Docs (OT)

六、易错清单

  1. MySQL async replication 不 ack follower, failover RPO > 0: RDS 标准 failover when leader dead — replicated 流备可能 lagging, Aurora 重写 shared storage (storage-layer replication).
  2. MongoDB replica set w=1: 高写吞吐 + leader failover data loss → use w=majority + journaling default on.
  3. Cassandra LWW clock skew: NTP 必同步在 milliseconds。多 client 各地写, locked 区域—use per-row timestamp client-supplied, sync via NTP otherwise output loss.
  4. MongoDB replica set write concern 不等于 journaling -elsm delay write ack before fsync: w=majority + j=true; 否则 leader fsync 未完成, crash 后 majority ack 但持久 false lose data.
  5. 多主复制会有 conflict, 写入前必须确定解决函数 - 不写仍解决, chaos. products default conflict生产企业 LRW/LWW: depends. CouchDB provides only some夸 default conflict — Manually decide。
  6. Quorum R+W>N 不是充分条件 beat staleness: 当 sync replicas 不一致 (例如 async update over sync with R=2+ W=2) — fail edge case returns stale older data. Backend not linearize on multi-incident synchronous partial sync.
  7. Leader 唯一带 leaseread: leader thread存放 native local memory — lease 失效前 client reads local 件。 leader Fail over + client missing logical Easier 地 产生旧 rank 反应 escape local latency。

七、这一章带走的东西

  1. 单主写需求 etcd/Mongo/Patroni's ack quorum-sync write: trade-off latency vs fault-tolerance。 leader租 + fenced quorum 决定事务安全。
  2. 链复制低同步带宽需求 + linearizable read + 长 Chain copies. cohesion base Plate (Tail)Tail throughput 限制.
  3. 多主复制 seriously conflict resolution by merger functions / 接受 RYW Consistency. multi-region multi-master CouchDB Cross-DC Generic Latency.
  4. Dynamo/Cassandra ال.无主保留 node IDEoperations types of writes to multiple replicas naturally without leader election; consistency level W + R tunable based constraints.
  5. LWW clockbased 假设 clock skew < user tolerance; fault 用 client-suppliedMonotonic timestamp (true timestamp below N through samples, version vector—— Cassandra 宜 robust using monotonic tool per replica).
  6. Hinted Handoff + Read Repair + Merkle Anti-Entropy: "all quick fixes" divide—— partition ino av lost immediate, fiscal Halves 中恢复 MQTTinant.
  7. CRDTs in next section give 数学上 conflict-free merge (no timestamp race) — representing strong typed operations ON set-grow with counters 创建为精力outsourcing Pure CRDTs 数据.

下一节 → CRDT:无冲突数据类型

CRDT:无冲突数据类型

TL;DR

CRDT (Conflict-Free Replicated Data Type) 是 Marc Shapiro et al. 2011 (INRIA) 提出的"分布式数据结构数学统一理论"——在最终一致性系统里, 只要每个操作具备commutativity (可交换) + associativity (可结合) + idempotency (幂等), 多节点任意顺序应用 op 最终都收敛到同一状态, 不需要协调或共识。CRDT 让无主/多主复制免 vector clock sibling 选择, 让 collaborative editing 不走 OT 的复杂 transformation matrix。 例: Riak DataType (counters/sets/maps), Redis CRDT, Automerge, Yjs, SoundCloud Roshi, Figma 的多人编辑都靠 CRDT。 本章梳理 state-based vs op-based CRDT, 加入常见类型 (G-Counter / PN-Counter / G-Set / 2P-Set / OR-Set / LWW-Register / LWW-Map / RGA / Text), 收敛证明与"为什么需要 tombstones 而非简单 delete", typical 生产事故 + 协同编辑器对比 (Automerge vs Yjs vs Google Docs OT)。


一、问题:Conflict Resolution Without Central Coordinator

Vector Clock Sibling 的问题

Dynamo / Riak default vector clock 让 user / application resolve conflict: 看到 siblings = [VC1: A=1,B=0 ; VC2: A=0,B=1], application 必须给出 merge function. 写 application-level merge 复杂且易错:

// shopping cart 复制抖镖 – 用户加 不同 — difference in conflict resolve
cart_NY = {apple, banana}
cart_SF = {apple, cherry}
merge_elems = cart_NY ∪ cart_SF = {apple, banana, cherry}  // OK!

但若 delete 也需要支持, 简单 union 不能反"deleted"——计数 set deletion 需要 tombstone.

CRDT 解决这个: 让 data structure 本身的 merge function 是数学上 associative + commutative + idempotent, 则 replicas 任顺序收敛。


二、State-based vs Op-based CRDT

Shapiro et al. 定义两种 CRDT:

State-based (CvRDT: Convergent Replicated Data Type)

  • 每节点独立持本地 state。
  • 节点不发送 op, 只发送整个state (或 delta-state) 给其他 replica。
  • merges: join(s1, s2) = s1 ⊔ s2必须 commutative, associative, idempotent

最终收敛条件 (bounded semilattice): join 算子构成 partial-order semilattice (有 least upper bound), 所有 join 都单调 = 收敛后状态。

Op-based (CmRDT: Commutative Replicated Data Type)

  • 节点不传整个 state, 直接传 op。
  • 每个 op 必须是 commutative + idempotent (因为 message may re-arrive or arrive more than once)。
  • !) requirement: Casual broadcast——若 op a 因果先 op b, a 必须先到达所有 replica。

Op-based 节省带宽、时延, 但需 underlying casual broadcast (vector clock)。 Riak DT 与 Yjs/Automerge mixed state-based 优点 + delta-state 优化。

工业一般用 state-based + delta-state, 因为 op-based 需要 casual 顺序保障开销。


三、常见 CRDT 类型

1. G-Counter (Grow-only Counter, 单调不减)

State: 每节点 i 维护独立 counter c_i, 初始 0. Op inc(): c_i++ at local node i. Query value(): Σ c_i over all i. Merge: c_i = max(c_i_a, c_i_b) for each i.

node A: c_A=5, c_B=0  value=5
node B: c_A=0, c_B=3  value=3
after merge on A: c_A=5, c_B=3, value=8

max + idempotent ⇒ CRDT。

2. PN-Counter (Positive-Negative Counter)

不能直接复用 G-Counter 因为 counter decrements 是非 commutative matching monotonic increases. 解决方法:

State: 两 G-Counter, P (positive) + N (negative). Op inc(): P_i++; Op dec(): N_i++. Query value(): Σ P_i - Σ N_i. Merge: per-node max on each of P and N.

Riak Counters

Riak DT Counter = PN-Counter。 性能警告: 内部 vector clock chain 增 large, ops 100k+ 之后 operation tensor 膨胀 + potential 性能下降 — Riak pin 自动"GC + archive"措施.

3. G-Set (Grow-only Set, 加入只)

只支持 add(e), 不允许 remove. State: Set S. Op add(e): S = S ∪ {e}. Merge: S = S_a ∪ S_b.

4. 2P-Set (Two-Phase Set, 加追删)

支持 add 与 remove, 但每 element 最多 add 一次 + remove 一次。

State: 两个 G-Set, A (added), R (removed). Op add(e): A = A ∪ {e}. Op remove(e): 必须先 add, R = R ∪ {e} (但 transient add-remove-proof: tombstone)。 Query contains(e): e ∈ A ∧ e ∉ R.

风险: 添加 remove 后无法 re-add——同 e 再 add 仍 contains=false (因 R 已有 e). 适合 "tag collective" 使用, 不适合 dynamic add/remove 反复 use case.

5. OR-Set (Observed-Remove Set) — 工业常用

状态稳定 (与2P-Set 对比): 每次添加生成 唯一 tag (uuid), 删除只删 add 中见过的 tag.

add(e): generate new tag t, A = A ∪ {(e, t)}
remove(e): collect all (e, t) seen locally, R = R ∪ observed_tags
contains(e): ∃ t such that (e, t) ∈ A ∧ (e, t) ∉ R
merge: A_merged = A_a ∪ A_b ; R_merged = R_a ∪ R_b

加后再删可再 add — 新 tag 条件唯一 (** remove only affects observed tags**.

工业 use case: Riak DT Set, Figma multi-collaboration shared states.

6. LWW-Register (Last-Writer-Wins Register)

State: value v + timestamp t. Op set(v_new, t): if t > t_current → set local v to v_new. Merge: choose side with larger t.

** 注意**: timestamp 比较 wall clock 不安全 (clock skew) — 必须用 monotonic + hybrid clock (HLC)。

7. LWW-Map

LWW-Map 不 commute on per-key bases in general. var m[k] = LWW-Register for each k. 各 key 之间可并行 update, 同 key 内以 LWW-Register 内部 timestamp 比较——但是 Map.remove + Map.add 同对应 key 时钟对间 race 复杂. Yjs 与 Automerge 都不直接使用 LWW-Map, 而是 nested OR-Set + OR-Map 的变种.

8. RGA (Replicated Growable Array) — 协同编辑

RGA 是文字/列表顺序的核心 CRDT, 用 unique ID per element + after reference:

char id = (timestamp, replica_id), parent_id (前一个 char 的 id)
insert((content, id, parent_id)):
  if parent_id 已在 list:
     insert new entry 在 parent_id 之后, 维护 tie-breaker desc 顺序 by id
remove(id):
  mark tombstone

merge: 同时维护所有 insertions + tombstones, 各 replica 收敛 by ID lexicographic compare for siblings.

Yjs 优化

Yjs 的 YText/RGA 实现 internal “block” skip-list。 每用户写时记录 origin id 但 leaf reading 延 through skip-list 子 BLOCK = O(1) lookup instead of O(N) treewalk. Yjs 是 2017 Kevin Jahns built; Figma 之类 collaborate 编辑器常见路径. 比 Google Docs OT 更 distributed.


四、CRDT 收敛性证明

Semilattice

定义: 部分序关系 且 任何两元素有 least upper bound (lub) a ⊔ b.

CRDT 状态空间 S 上, join : S × S → S 取 lub. join 满足:

  1. Commutativity: a ⊔ b = b ⊔ a
  2. Associativity: a ⊔ (b ⊔ c) = (a ⊔ b) ⊔ c
  3. Idempotency: a ⊔ a = a

收敛定理

若 op 满足 commutativity + idempotency (state-based 中 via join), 则无论 op 顺序, 最终所有 replica 收敛到 lub(initial_state, all_ops)

证明: 每节点最终都 join 所有 ops. 因 join 单调增 + 上界存在 + lub 唯一 ⇒ 各 replica 终态一致。

关键: remove ops 如何收敛——simple remove 不 commute (add a; remove a; merge ≠ same as remove a; merge; add a)。 tombstones 与 OR-Set 都是这个 limitation 的解法。


五、应用例

Riak DT

Basho Riak 2.0+ 提供 built-in types:

  • counter: PN-Counter
  • set: OR-Set
  • map: nested CRDT map (counter, set, register, flag per key)
  • register: LWW-Register
  • flag: boolean CRDT

bucket API:

riak.update_bucket_type("counters", ...)
riak.bucket("counts").update("likes", increment=1)

避免了 application 自己组织 sibling merge。

Automerge

Automerge (2017, Ink & Switch) 是纯 JS library CRDT + JSON documents. JavaScript 的 automerge.from(initial) 创 doc, .change(doc, m => m.todos.push({...})) 修改. 内部是 OR-Set + RGA + LWW-Map nested.

Atrium, Habit, rtags —— family collaborative paper tool。 Notion-like data stores. 生产环境 slow broadcast presence ensured 跨 DC collaborative editing.

Yjs

Yjs 是 Kevin Jahns 写 (2017), 体积小 (60KB JS) + 性能 peak (100+ users simultaneously). 应用: Notion Quip-like collab editor, Atlassian Trello-like product collaborative viewer.

Redis CRDTs (Redis Enterprise Active-Active)

Redis Enterprise 提供 server-side CRDTs: counters, sets, hashes, strings all sync replicate across 多个 region (active-active). Counter for inventory increments monotone tests no lost update.

Figma

Figma 的多人实时协作没有直接套用现成 CRDT:文档是嵌套的图形树,通用 CRDT 在其上开销过高。实际做法是中心服务器定序 + 增量操作流(类 OT 的简化):客户端把属性修改作为增量 op 发给服务器,服务器以单一权威顺序广播给其他客户端。每个对象属性对用 last-writer-wins 语义合并,删除与插入用自定义的混合规则处理。配合 CDN 延迟广播降低带宽峰值——当存在可信中心节点时, 简单定序往往优于分布式收敛协议, 这是与 CRDT 适用边界的重要对照。


六、典型问题

CRDT Tombstone 膨胀

G-/OR-Set delete operation 不真删——保 tombstones for concurrent merge. 长时间 set 加入 + 删除, 数 MB 内存占用. 解决 = GC: 只在所有 replica 都知道 tombstone 存在这之后删—— causal tracking 要 vector-clock GC.

OR-Set Tag Exponential

Each add(e) 生成 new tag. Repetitive add/remove 写 ~100k ops/set 之后, A 与 R set 元素 count baptized millions.

Figma 修复: used delta-state CRDTs (delta-CRDTs, Almeida et al. 2018) 只广播增量状态.

LWW with Clock Skew

NTP skew 10ms: client A set x=A at T100, client B set x=B at T100— inconsistent order ⟹ B not always wins on all replicas.

Fix: HLC + monotonic local count. Riak default uses vclock + wall_ms.

Concurrent Modes Drops

CRDT Map 的 remove(k) + 并发 set(k, v): 一个想 remove k + 但下次 add 想被合并—— 定义文档 differ 不总可predict. Riak DT Map 用 OR-Map "element context" tracking 解决: remove 只 mark "remove removed at observed states", 后加 v 在不同 OR-Set tag, 决战必然附加. 但同时复现 users 见 "k 存在" + "k 不存在" 是 widely ambiguous. 文档 mention this in ordering.


七、易错清单

  1. CRDT ≠ 任意 sequence 状态兼容: op 必须 commutative + idempotent, 写 sequence ops (list.append; list.reverse + append; ...)
  2. LWW-Register timestamp mismatch: 必须 hybrid clock (HLC) + monotonic counter per-node — wall clock alone 不可。
  3. CRDT Map.remove + concurrent Map.set(k, v) → if not using OR-Set with causal state tracking, 设置 (v) 可能 silently dropped.
  4. Tombstones 必 GC: OR-Set remove 写 tombstones add up; 必须 GC 与 vector clock 因果跟踪同步.
  5. CRDT 序列化膨胀: 远远多于 update state after上百 bytes data; 大 apps 需要 delta-CRDTs include 编码驱动的 delta-state ship优化.
  6. 协同编辑器要 Carefully handle "anchor" — 共同 characteristic seed 根 character 0 anchor records to insert下一个 char的位置—— if all char removed, top is not 0 anchor, 而是 deleted anchor with tombstones on the root.
  7. CRDT ≠ Strong consistency: CRDT 保证 eventual converge + 不会丢更新但 promise 不是 Stale-Snap "see client writes own writes for business" — 保 防epoch: branch sessions need combine with sessionconfig to ensure RYW.

八、这一章带走的东西

  1. CRDT 用 join-semilattice 让免 merge function conflict-free; commutativity + idempotency 是设计原点.
  2. 工业常用 CRDT 类型: G-Counter, PN-Counter, G-Set, 2P-Set, OR-Set, LWW-Register, OR-Map, RGA (Text).
  3. OR-Set 用 unique tag per add 是正确处理 add-after-remove 的关键, 避开 2P-Set "remove forever" 处理.
  4. RGA (Replicated Growable Array) 是协同编辑器的基础 CRDT (Figma Yjs Notion-like).
  5. CRDT 的代价: tombstone accumulation + GC complexity; delta-state broadcast 是性能修案.
  6. CRDT ≠ strong consistency. only eventual. 与 linearizability (Spanner) 与 RYW (Dynamo) 不同, 财务线 transaction 推荐仍 Paxos + serial.

下一节 → 读修复 / 反熵 / hinted handoff

读修复 / 反熵 / hinted handoff

TL;DR

AP 系统 (Cassandra / Riak / Dynamo / Voldemort) 在 partition 或 transient node failure 时, W=quorum 未达成全副本复制, 一些副本持有 stale 版本。 复制差异最终必须修复 (anti-entropy repair), 让所有副本最终收敛一致 state。 三种主流修复路径:

  1. 读修复 (Read Repair): read 时若发现副本返回 stale value, 立即推 修复—— 顺路修复, 不消耗额外 background IO。 高频 read key 友好, 但 cold key 收敛慢。
  2. 反熵修复 (Anti-Entropy Repair): 后台周期性扫, 用 Merkle Tree 算"diff 副本它们不同 range", 只推 missing / newer ranges。 跨大副本 / 数据量大时必备, 代价是后台 IO 与 CPU。
  3. Hinted Handoff: 副本 unavailable 时, coordinator 把 write 暂存本地 (hint), 该副本回来后 coordinator 转发 hint。 短期 outage 的 hot-path repair。

一、读修复 (Read Repair)

算法

Cassandra 等 Dynamo-style AP 存储 default path:

  1. Client get(key) → coordinator。
  2. coordinator 选 R replicas (R = read consistency level, 比如 LOCAL_QUORUM=2 / ALL=3)。
  3. 各副本回 value + 写 timestamp (或 vector clock)。
  4. coordinator 比较所有 R 回响应, detect stalest replicas。
  5. coordinator (异步 / 同步) 推 修复 write 给 stalest replicas (read repair)。
  6. coordinator 给 client 返回 newest value。

Cassandra 配置项

  • read_repair='BLOCKING' (3.x): coordinator 等 read repair ack → reply client; 一致性最严 + 延迟长 (slowest replica 的 latency 影响 P99)。
  • read_repair='NONE' (4.0 default): 不修复, 立刻 reply client, 假设 background anti-entropy 修复 staleness。
  • read_repair='BACKGROUND' (3.x 有些版): reply client 立刻, async 修复。
  • speculative_retry: if first R-1 副本 50ms 内未回 → speculative 再发 RTT 触发额外 read; 提升 P99 latency 避开慢副本。

Riak Default

Riak 设 bucket property allow_mult=true 时返回 vector clock siblings 给 client, 客户端要 resolve + push back 来修复 (推荐 pattern)。 last_write_wins mode 下 Riak 自动 LWW 决定, read repair 写 newest value 到 stalest replica。

Read Repair Cost

  • Hot path 延迟: 同步模式 = slowest replica latency (可能 +50-200ms)。
  • Bandwidth: 重要 read data ~500B + 写回 stale replica ~500B 每修复。
  • Effectiveness: 高频 read / popular keys 修复 fast; cold keys 从未被 read → read repair 不触发 → 必须靠 anti-entropy。

适用场景

  • 小 / 中等 key 高频 read (e.g., 用户 profile, session data)。
  • replicas 基本同步, 偶发 divergence。
  • 不适场景: archive, 写冷数据 → cold-key cover 需 anti-entropy。

二、反熵修复 (Anti-Entropy Repair) 与 Merkle Tree

原理

定期 / on-demand 后台扫各 replica, 找出 range 内 data differences, 推送 missing writes 给 stale replica. Brute force 不行——拷贝几 TB data 比较 byte 不现实。 Merkle Tree 是分布式 hash 树:

                  [root hash]
                  /     |     \
           [h_a]   [h_b]   [h_c]
           /  \    /  \    /  \
       [leaf] [leaf] ...
       hash(key1+val1)
  • 叶子 leaf = hash(key + value + timestamp + vclock)。
  • 内部 node = hash(子哈希)。
  • root hash 字符串拷贝便宜; 若两副本 root 相同 → range 不需 repair。
  • 子树深度递归下钻, 直到找出 diverged leaves, transcribe 仅 missing keys。

Cassandra nodetool repair

  • -pr (partitioner range): 仅本地 token 范围, 减少跨节点做 work (Cassandra 4.0+ default)。
  • -seq / -full: 整 ring 全检, 慢但 comprehensive。
  • -inc (incremental): 增量修复, 只修上次修复后变化的 range; 4.0+ recommended。
  • -local: 只本 DC repair 节点。

predicted 量: 100GB incremental ~30 min; full 5-8 hours。

Riak Active Anti-Entropy (AAE)

Riak 1.4+ 全自动 AAE:

  • 后台 process 周期 (per vnode, 默认每 1 小时) 扫各 vnode Merkle Tree。
  • Hot key Index: hash index 键 + bucket 时间, quick detect diff。
  • difference detected → 自动 parallel read_repair per vnode。

Dynamo 2007 — Anti-Entropy Origin

Amazon Dynamo 2007 paper 提了 anti-entropy + read repair 作为修复机制。 Dynamo 用 Merkle Tree per replication group, 周期 + on-demand 调用。


三、Hinted Handoff

算法

如果 coordinator 应该 write 给 replica B, 但 B unavailable:

  • Coordinator 把 write 暂存本地 "hint", 配置 hint TTL (Cassandra max_hint_window_in_ms=3h default)。
  • B 回来 → coordinator 把所有 hint 转发给 B, 顺序应用。
  • Hint 在 coordinator 重启后也持久化保不丢。
def handle_write(key, value):
    live_replicas = []
    for r in target_replicas:
        if write_to(r, key, value).ok:
            live_replicas.append(r)
        else:
            store_hint(locally, r, key, value, expire_at=now + 3h)
    if len(live_replicas) >= W:
        return OK        # quorum met
    return WRITE_FAILURE

# 后台 loop periodically retry hint:
for hint in local_hints:
    if hint.target_node.alive:
        send hint to target_node
        if send.ok:
            delete local_hint
        if hint.expired:
            delete      # 警告: hint expired = RPO 可能丢数据

Cassandra Hinted Handoff

  • 持久化 hint 到本地 commit log + hint file。
  • max_hint_window_in_ms 默认 3h; 超过窗口不用 hinted handoff, 等下次 anti-entropy repair。
  • Beta 4.0+ 提供 hinted_handoff_throttle 限速, 防 hint replay 风暴打 target CPU。
  • Hint 重启后读 hint 文件 replay。

Dynamo 2007 Hinted Handoff

Dynamo 用 hinted handoff + Merkle 周期性 sweep 同时修复 + 客户端 put / get 继续走 quorum — 短期 outage 透明。

Riak / Voldemort 类似

Riak 与 Voldemort 类似 hinted handoff: Coordinator 写给某 replica fail → store locally; Later node 回来 → forward。


四、何时用哪种修复

修复触发修复时间修复范围用例
Read Repairclient read 时立即 (on read path)仅该 read 的 key高频 read key 修, 突发 partition 短断
Hinted Handofftarget replica unavailable 时 write节点恢复后仅 temporarily down node 的 keys短期网络 partition / 滚动 rolling restart
Anti-Entropy后台周期 sweepminutes-hours整 token range merkle tree diffcold-key 修, 长期 divergence 收敛, node add/drop 后 re-balance

工业 best practice:

  • 短期 outage: Hinted Handoff (小时级别)。
  • 持续 divergence (cold keys, compaction 后 stale): anti-entropy scheduled 每周 (+ incremental repair)。
  • 高频 read popular: Read Repair (自动)。

三种修复不冲突——并行 run。


五、Merkle Tree 实现细节

Sub-Range Hashing

Cassandra: Merkle Tree per token-range (默认 16-256MB per range)。

Cassandra nodetool repair 流程:

  1. coordinator 选 partner 节点, exchange Merkle root hashes per range。
  2. 若 root 不等, 递归 exchange 子树 (e.g., 256KB sub-range, 16-level depth)。
  3. 找到 diverged leaves, 仅传 missing keys。

Compression 友好

Cassandra Memtable flushes 转换为 SSTable + bloom filter (range filter); compaction 阶段产 leaf hash derived from row-level hash per token; diff derived streams 转化成 "newest timestamp entries" pulled over stream replication。

Full vs Incremental

  • Full repair: 重新读整个 range, 重新算 Merkle Tree, 节点间交换。
  • Incremental repair (Cassandra 4.x default): 监控表维护 repaired_at timestamp per range, 基于 sstable max timestamp 仅修 recent 改动。 性能 ≈ 增量 repair 坦率性更高。

Riak & Voldemort 也有类似概念。


六、典型事故

Cassandra "Hinted Handoff 迟滞 死亡信封" 2014

某用户 9 节点 Cassandra cluster, 5 节点 rolling restart, 各累计 ~40 GB hints 等待 transfer。 重启节点承接 40GB hint replay 5 小时, throughput 降到 1/10。 Fix: hinted_handoff_throttle_in_kb=1024 限速 + max_hint_window_in_ms=1h 短保更大窗口原始 accumulation。

Riak Active Anti-Entropy 后台 Memory Spike

Riak AAE 30 分钟扫 all keys build Merkle Tree, 100GB keys 内存写入 ~1GB Merkle Hash-tree, full spike 推 OOM。 Fix: 后台 AAE 限速 + 滚动 segment 设置 (anti_entropy_slice_size 调小)。

Cassandra Full Repair Long Window

2018 NoSQL benchmark: full repair 5 hours 在 50M-key cluster, 期间流量被 backend read 争 IO 拖降 throughput。 Fix: incremental repair + 分批 parallel -pr 修 range。


七、易错清单

  1. Hinted Handoff 必须持久化至 disk: coordinator crash 后重启才能 replay hints; Cassandra hints_directory 配置默认在 data 目录, 写满 disk 会拒绝新 hint。
  2. max_hint_window 不能太长: 长时间 partition 累 hints 风暴; 超过窗口的 write 不再保, 必须靠 anti-entropy 修。
  3. Read Repair 不修冷 key: 用 monitoring 看 read-throughput; cold-key 数据必跑定期 anti-entropy。
  4. Anti-Entropy repair 不要在峰值跑: 它吃 IO + CPU + 网络带宽, 与 production 流量争资源, 通常选夜间 / weekend 窗口。
  5. Merkle Tree 内存代价: 树大小与 range size + key count 成正比, 大 cluster 必须 range partition 加 incremental, 不能全 ring sweep。
  6. Anti-Entropy 加 token black-list: 把 corrupt key 加入 quarantine, 不然 anti-entropy 让 corrupt 键 propagate 到 healthy replica → silent 数据丢失。
  7. Hint expired 不重写: 超过 max_hint_window 后 write 未送, 客户端已收 ack → 默默丢失; retention SLA 必须明确接受多长 window。

八、这一章带走的东西

  1. 读修复 在 read path 顺手修 stalest replica, 高频 read keys 收敛快但 cold-key 靠 anti-entropy。
  2. Hinted Handoff 是 short-outage 透明写入 + 节点恢复 replay; TTL 受限不然 hint expire → 行 SLA 风险。
  3. Anti-Entropy repair 用 Merkle Tree 计算 range diff, 增量修复 + 周期性 full repair 兼顾。
  4. 三 修复路径不冲突——并行 run; 工程实践中 hint < hour outage, anti-entropy 每周 / 每 12 小时 incremental。
  5. Merkle Tree 内存代价 + IO 代价是反熵约束, 大 cluster 用 range 分片 + incremental [pr] 模式修。
  6. Anti-Entropy 必须 durability 检查; 否则 corrupt 键 propagate 到 healthy replica 是 silent 数据丢失最可怕的事故。

下一节 → 时钟与顺序

分布式事务: 2PC / Saga / TCC / Outbox / Spanner

TL;DR

单机事务靠 WAL + 锁把"多行修改"原子化;一旦数据分到多台机器(微服务拆分、分库分表),"跨库转账"这类操作需要跨机器原子性。这一章讲四条技术路线的数学本质与工程取舍:2PC(强一致,但协调者故障会阻塞)、Saga(无原子性,靠补偿最终一致)、TCC(业务把"预留/确认/取消"三阶段暴露出来)、Outbox/事务消息(把 DB 事务和消息投递绑成一个原子操作)。最后看 Google Spanner 怎么用 2PC + TrueTime 同时拿到强一致和可用性。工程上 90% 的"分布式事务"问题,正确答案是换一个不用跨库事务的数据模型,而不是引入 2PC。

读完应能:

  1. 复述 2PC 的两阶段流程,指出协调者/参与者各自故障时的阻塞点与可恢复性。
  2. 说出 3PC 比 2PC 好在哪、为什么没解决根本问题。
  3. 设计一个 Saga(含补偿顺序)和一个 TCC 接口(Try/Confirm/Cancel + 幂等 + 空回滚)。
  4. 说清 Outbox 为什么能保证"DB 写入与消息投递至少一次一致",以及消费侧怎么幂等。
  5. 判断一个业务场景到底该用 2PC / Saga / TCC / 还是重构避免。

一、问题定义: 分布式原子提交

把转账拆成"扣 A 账户"(库 1)+"加 B 账户"(库 2)。两边各自有本地事务,但没有一个共同的锁/WAL 能同时覆盖两库。需要协议保证:要么两边都提交,要么两边都回滚,且任何进程崩溃后仍能收敛。

这就是 分布式原子提交(Distributed Atomic Commit) 问题。注意它和共识(consensus)的区别:

共识 (Paxos/Raft)原子提交 (2PC)
问什么多节点对一个达成一致多节点对是否提交各自的事务达成一致
失败模型容忍故障,总能推进协调者故障时可能阻塞
典型产物复制状态机、选主XA、数据库两阶段

note

2PC 每轮都要多数派之外的全部参与者点头,且协调者是单点——它解决的是"原子性"而不是"可用性"。把 2PC 当共识用是常见误解。

二、2PC: 准备 → 提交

协调者(Coordinator)               参与者(Participants)
   │  prepare(tx) ───────────────────►│ ① 写 undo/redo 日志, 加锁
   │◄───────────  yes / no ───────────│ ② 进入 prepared 状态, 可恢复
   │  如果全部 yes:                     │
   │  commit ────────────────────────►│ ③ 释放锁, 提交
   │  (任一 no: 发 abort)

两个阶段的关键性质:

  1. 准备阶段:参与者把事务做成"可提交但未提交"(写日志 + 持锁)。此时承诺:只要收到 commit 就一定提交;
  2. 提交阶段:协调者已决定,全体执行。决策一旦做出,协议不允许反悔

故障分析(这是面试/设计题核心):

故障后果恢复方式
参与者 prepare 后崩溃持有锁,事务悬挂重启后查日志,等协调者补发 commit/abort
协调者在 prepare 后、commit 前崩溃所有参与者阻塞,资源被锁死需要新协调者读日志恢复决策;否则只能人工介入
参与者收 commit 后崩溃事务已提交,但副本可能未同步重放日志 + 对下游补偿

warning

2PC 的"不可用"不是概率事件:协调者故障 + 网络分区时,参与者既不能提交也不敢回滚(怕违背承诺),只能干等。这就是"阻塞问题",也是 2PC 不适合长事务/跨公网的原因。

3PC 的有限改进

3PC 在 2PC 前加一轮 can-commit(投票),并把提交阶段拆成 pre-commit → do-commit,让参与者之间可以"互相恢复"(任何一个知道超时都可以去问别人)。它解决了协调者单点崩溃导致的无限阻塞,但:

  • 网络分区 + 新主视图不一致时仍可能脑裂(有人提交有人回滚);
  • 工程上几乎没人用纯 3PC,因为引入的复杂度大于收益。

三、XA: 2PC 的工业实现

Java 世界用 XA 接口把数据库/消息中间件接进 2PC:xa_start / xa_end / xa_prepare / xa_commit / xa_rollback。MySQL/PostgreSQL/Oracle 都支持。

现实中的 XA 代价:

  • 每个参与者在 prepare 后锁资源直到全局决策——两库场景下并发和吞吐暴跌;
  • 协调者(如应用进程)是单点且必须持久化事务表,否则重启即失忆;
  • 跨库性能问题、运维事故频发,业界(尤其互联网公司)用 XA 的比例很低

结论:XA/2PC 适合"少量短事务 + 强一致 + 内部低延迟网络"的场景(典型:银行核心、数据中心内两库联动),不适合跨公网、长事务、高并发互联网业务。

四、Saga: 没有原子性,用补偿

Saga 把一个大事务拆成 N 个本地事务 + 每个本地事务配一个补偿事务

下单(库存扣减) → 生成订单 → 支付扣款
失败则反向补偿:  支付失败 → 取消订单 → 库存回补

两种编排:

编排方式控制流优缺点
Choreography(事件驱动)每个服务完成后发事件,下一个服务订阅无中心点、演进灵活;但流程不可见、难调试
Orchestration(编排器)中央 Saga 编排器依次调用流程集中可见、易做重试/超时;编排器本身要高可用

正确性三要素:

  1. 补偿必须能成功:补偿里不能依赖"对方也失败"等假设,要设计成幂等、可重试;
  2. 补偿顺序 = 正序逆序t1, t2, t3 失败于 t3 → 补偿 c3, c2, c1(c3 通常是被动补偿,t3 自己的回滚);
  3. 无隔离保证:Saga 中间状态对外可见——需要额外手段(如订单状态机 + 对账)控制。

tip

面试/设计高频答案:Saga 适用于"最终一致可接受、链路长、跨多个独立服务"。先画状态机,再定补偿,最后用幂等键 + 对账脚本兜底。

五、TCC: 把三阶段暴露给业务

TCC(Try-Confirm-Cancel)把 2PC 的 prepare/commit 翻译成业务操作:

阶段做什么例子(库存扣减)
Try预留资源,不真正生效冻结库存 10 件
Confirm确认生效(幂等)把冻结转成实际扣减
Cancel释放预留(幂等)解冻 10 件

必须处理的三个边界:

  1. 幂等:Confirm/Cancel 可能因网络重试被调多次,业务侧要用事务 ID 去重;
  2. 空回滚:Try 因超时没真正执行,但 Cancel 到了——Cancel 必须能安全执行(通常查无此预留就返回成功);
  3. 悬挂:Cancel 先到、Try 后到——要拒绝迟到的 Try(记录已 Cancel)。

主流实现:Seata AT/TCC 模式、DTM。TCC 的代价是把业务逻辑改造成"预留/确认/取消"三段——侵入性强,适合余额、库存、优惠券这类天然可分阶段操作的资源

六、Outbox / 事务消息: 最容易落地的"半分布式事务"

最常用的需求其实是:"DB 写成功 + 发消息/事件成功"(比如下单后发 Kafka 事件给下游)。两个操作跨系统,朴素做法是"先写库再发消息",但二者之间崩溃就丢事件。

Transactional Outbox

业务事务:  INSERT 订单;  INSERT outbox(事件)   ← 同一个本地事务
后台任务:  扫 outbox → 发 Kafka → 标记已发送

关键性质:事件和业务数据在同一个本地事务里落库,要么都成要么都败;投递用 at-least-once,消费侧幂等去重

工程要点:

  • outbox 表要批次扫描 + 索引(未发送状态 + 创建时间),避免拖慢业务写;
  • 发送后删除或标记(标记更稳,可审计);
  • 对 Kafka 的"事务消息"(producer transaction)做跨系统原子,本质上仍是 outbox 的变体,且要求 Kafka 与 DB 之间有协调者——多数团队直接选 outbox;
  • 高吞吐替代:**CDC(Debezium 监听 binlog)**把 outbox 变成事件流,避免轮询。

note

Outbox 解决了"原子性"(写库和发事件二选一),但没有解决"两个系统都强一致提交"——如果下游必须和上游同时可见(如分布式账本),它不够;如果只是"通知/异步解耦",它就是最佳工程答案。

七、Spanner: 2PC + TrueTime 的强一致组合拳

Google Spanner 并没有发明新共识,而是把 2PC 放进 Paxos 组

  1. 每个分片是一个 Paxos 复制组(leader 在多数派中);
  2. 跨分片事务仍走 2PC,但协调者/参与者故障由 Paxos 复制接管;
  3. commit wait 用 TrueTime 时间戳(GPS + 原子钟)保证 external consistency:提交时间戳晚于所有并发事务,从而让读在任意节点都能看到"截至某时间点的全部已提交事务"。

工程启示:2PC 的可用性短板可以用共识层补(协调者高可用 + 状态复制),但代价是每个分片都要跑 Paxos——这不是"免费强一致",而是把成本摊到基础设施。

八、选型决策树

真的需要跨系统原子提交吗?
├─ 不需要 → 拆分/重设计 (把两写变成一写 + 异步)  ← 默认答案
├─ 需要强一致, 且网络可控、事务短
│    └─ XA/2PC (数据中心内, 少参与者)
├─ 需要强一致, 且要容错高可用
│    └─ Spanner 式: 2PC + Paxos + TrueTime
├─ 最终一致可接受, 长链路业务
│    └─ Saga (编排器 + 补偿 + 幂等)
├─ 资源型操作 (库存/余额/优惠券)
│    └─ TCC (Try/Confirm/Cancel + 空回滚 + 悬挂处理)
└─ 只要"写库+发消息"原子
     └─ Transactional Outbox / CDC

warning

最常见的架构错误:为了"两个表跨库强一致"引入 XA,结果 3 个月后因为锁阻塞把系统拖死。先量化真实需求——允许 1 秒延迟的一致和"绝对不能不一致"是两个量级完全不同的工程。

九、一页速查

2PC:  prepare 全票 → commit; 协调者故障 = 阻塞; 适合数据中心内短事务
3PC:  多一轮投票 + pre-commit, 减少阻塞但可能脑裂, 工程少见
Saga: 本地事务 + 补偿, 无原子性, 编排器/事件驱动, 幂等 + 对账
TCC:  Try 预留 / Confirm 生效 / Cancel 回滚, 幂等 + 空回滚 + 防悬挂
Outbox: 业务写 + 事件写同一个本地事务, at-least-once + 消费幂等
Spanner: 2PC 每分片跑 Paxos + TrueTime commit wait = external consistency
选型: 默认重构避免; 强一致短事务=2PC; 最终一致长链路=Saga; 资源型=TCC

下一篇: 分布式存储与容错

时钟与顺序

时钟与顺序是分布式系统的走廊——"在分布式系统中, you cannot totally order events without coordinating" 是 Lamport 1978 的奠基论断。 多节点无 wall clock 同步, 还能怎么知道谁先, 谁后, 谁并发? 三大轴心:

  1. 逻辑时钟 (Lamport Clock): 单调自增 + 消息捎带, 给 events 一个 transitive partial order。Lamport 1978。
  2. 向量时钟 (Vector Clock, Mattern 1989 / Fidge 1988): N 维 count array 在节点间传播, 精确检测 concurrency vs causal order。
  3. 混合时钟 (HLC, Kulkarni 2014) + 物理时钟 (TrueTime, Spanner 2012): 给"事件"带上 wall-clock-approximate 时间, 同时保持因果序。 CockroachDB / YugaByte / TiDB 用 HLC; Google Spanner 用 GPS+原子钟提供 TrueTime。

DAG (有向无环图) 是顺序的另一种一般化: git commit / blockchain / IPFS DAG / Bitcoin block chain 都隐含 partial order, 用 hash chaining + tip selection 算法给出 consensus view.

逻辑时钟、向量时钟、HLC

TL;DR

分布式系统无全局 wall clock——你不能用一个普通的 timestamp 来判定两事件先后。Lamport 1978 提出 Lamport Clock: 每节点维护单调自增 count, message 携带 count → 接收方更新成 max(local, msg_count)+1, 给事件一个全序但仅保因果 partial order 后将并发事件任意排序。Fidge 1988 与 Mattern 1989 各自独立发现 Vector Clock: N 节点维 N 维 count array 在节点间传播, 精确刻画 happens-before, 区分 concurrent vs causal-ordered pair。HLC (Hybrid Logical Clock, Kulkarni 2014) 融合 physical wall clock 的高位 + logical counter 低位, 给事件同时人友好的时间戳 + 因果关系 —— CockroachDB / YugaByte / TiDB 都用 HLC 做事务 timestamp。本章梳理算法细节, 收敛条件, 用例 (Dynamo siblings, CockroachDB snapshot isolation), 与 typical bug (Lamport clock 单调 但并发 events 任意序 不区分)。


一、Lamport Clock (1978)

算法

每节点维护 monotonic counter LC_i, 初始 0:

on event e at node i (send / receive / local): LC_i = LC_i + 1
on send(m): attach LC_i to m
on receive(m, LC_m): LC_i = max(LC_i, LC_m) + 1

性质

  • happens-before: 若 a → b (同进程序 + send/recv + 传递闭包), 则 LC(a) < LC(b)
  • 逆否命题不成立: LC(a) < LC(b) 不蕴含 a → b——它们可能 concurrent。
  • 全序扩展: 把 Lamport Clock + (process_id) lex 排, 得到 total order (任两个事件都可比), 但这是任意扩展, 不是 happens-before 原 partial order 的忠实反映。

用例

  • Mutual Exclusion via Lamport Clock: Lamport 1978 论文里给出 distributed mutex 算法, 提议临界区请求带 LC + process_id, 节点色 total order 排队。O(N²) messages, 严谨 but 低效; 不工业用 (因 Paxos/Raft 都更高效更好)。
  • DynamoDB (AWS) Order Stream: DynamoDB change stream 用 LC-style 时间戳排序 events 在 短时间窗内。

局限

Lamport Clock 不能 区分因果序与并发序:

A: send m_A (LC_A=5)
B: send m_B (LC_B=5)        concurrent with A

两 event LC 相同, 看上去"同时间发生"; 真实它们 concurrent (不是 causally related)。

vector clock 解决这 limit。


二、Vector Clock (Fidge 1988, Mattern 1989)

算法

每节点 i 维护 N 维 vector VC_i = (c_0, c_1, ..., c_{N-1}), 初始全 0:

on local event at i: VC_i[i] += 1
on send(m) at i: VC_i[i] += 1; attach VC_i to m
on receive(m, VC_m) at i:
    for all j: VC_i[j] = max(VC_i[j], VC_m[j])
    VC_i[i] += 1

关系比较

VC_a ≤ VC_b iff ∀ k: VC_a[k] ≤ VC_b[k] VC_a < VC_b iff VC_a ≤ VC_b ∧ ∃ k: VC_a[k] < VC_b[k]

  • VC_a < VC_ba → b (happens-before)
  • VC_a || VC_b (即不 ≤ 也不 ≥) ⟹ ab concurrent

用例

Dynamo / Riak siblings: writes 带 VC, replica merge 给出 vector clock 比较:

  • VC_a < VC_b → discard a (newer overrides)
  • VC_a || VC_b → 两个都保留为 sibling, 应用层 merge
def merge(replica_a_value, replica_b_value, VC_a, VC_b):
    if VC_a < VC_b:
        return replica_b_value, VC_b
    elif VC_b < VC_a:
        return replica_a_value, VC_a
    else:
        # siblings; 应用层 merge function (or list all)
        return [replica_a_value, replica_b_value], max(VC_a, VC_b)

Vector Clock 变种

变种区别
Version Vector (VV)仅 update on write, 不 update on read; Dynamo 用 VV。简单但 N 写 parties 固定。
Dotted Version Vector (DVV)引入 "dot" = (node_id, counter) 唯一标识一次 occurrence. 应用 client cluster anonymity (单 client 同 node)。 Riak 用。
Interval Version Vector (IVV)counter 改成 interval [start, end], 处理 dynamic membership, 节点 join/leave 不 reset。
DCVV (Dotted Cluster VV)DVV + cluster id, 高度 dynamic membership (Cassandra 内部曾用)。

Vector Clock 内存代价

每 message 维传 N 维整数, O(N) per event; Cassandra / Riak 在 N=100 nodes 情况 VC serialize ~1KB 包过载。常见优化 Dotted VV + compact representation.

局限

Vector clock 检测 concurrency 但不解决conflict——应用层仍需 merge function. Riak DT (CRDT) 与 Riak siblings 都让 application 决定逻辑, vector clock 只是 procedure infrastructure.


三、HLC (Hybrid Logical Clock, Kulkarni 2014)

动机

Pure logical clock 与人读时间无关 ("这 event 是 timestamp 1000, 但何时真实发生");pure wall clock 受 NTP skew 影响 + 不保 happens-before。 HLC 融合:

  • 高位 = wall clock 时间 (pt)
  • 低位 = 逻辑 counter (lt)
  • (pt, lt) 元组保证 causal happens-before + timestamp 接近 wall clock 真实发生时刻

算法

each node init HLC = (wall_now, 0)

on local event at node i:
    if wall_now > HLC.pt:
        HLC = (wall_now, 0)
    else:
        HLC = (HLC.pt, HLC.lt + 1)

on send(m):
    bump HLC (above); attach HLC to m

on receive(m, HLC_m):
    new_pt = max(wall_now, HLC.pt, HLC_m.pt)
    if new_pt == HLC.pt == HLC_m.pt:
        HLC = (new_pt, max(HLC.lt, HLC_m.lt) + 1)
    elif new_pt == HLC.pt:
        HLC = (new_pt, HLC.lt + 1)
    elif new_pt == HLC_m.pt:
        HLC = (new_pt, HLC_m.lt + 1)
    else:
        HLC = (new_pt, 0)

性质

  • a → bHLC(a) < HLC(b) (lex by pt then lt)
  • HLC(a) < HLC(b)a → b (no false positives because counter monotonic)
  • pt 接近真实 wall clock, 人读 HLC 时知道大致真实发生时刻

用例

CockroachDB HLC:

  • 每 transaction 用 HLC timestamp。
  • HLC 与 wall clock bound skew (max_offset 默认 250ms) → 同物理事件可 timestamp 偏移 < 250ms +1 logical tick.
  • transaction read 是 snapshot isolation at HLC timestamp; 查 commit HLC > read HLC 排除 commit-on-after-read 的 row.

YugaByte / TiDB 与 CockroachDB 类似, 内置 HLC + bounded skew.

MongoDB: 但 MongoDB 没用 HLC, 用 wall clock + reverse_sbe seq for global timestamp cluster clock.

Bounded skew

HLC 让各节点 wall_now bounded skew (默认 250ms in CockroachDB), 通过 max_offset 配置后 cluster know if wall clock drift > max_offset → 节点必须 fail exit.

否则 HLC 不保 happens-before—— wall clock 直跳, counter 才保证.


四、HLC vs TrueTime

维度HLC (CockroachDB)TrueTime (Spanner)
同步精度bounded skew ~250ms (NTP)bounded skew ~7ms (GPS + 原子钟)
Commit-style wait不必 waitcommit-wait (wait until TT.now() latest > commit_ts)
软实时?PT-approx not real-timecl实事实时 (commit-wait ~14ms)
Garbage-in short window?≤ max_offsetcommit-waitカバー
实现复杂度适中expensive (timing hardware per datacenter)

HLC 比 TrueTime 简单 (NTP 普及) 但 commit wait 长 (250ms worst case 把 commit fully serialized across events). TrueTime 直接 GPS 持 7ms skew → commit wait 14ms。


五、生产事故

Riak Vector Clock Sibling 风暴

Riak 2.0 一度 1995 严格 VC 让 client 看 sibling list; but application 不设 merge fn → sibling list 中 5 个 values; 用户不 that 失潘 krebs写 fail 加 format. Fix: Riak DT (CRDTs) let user set bucket type data_type = counter/set/map, 默认让 Riak 内置 merge logic.

Cassandra LWW + Skew Lost Write

Cassandra 单一 timestamp 驱动 LWW。某写入 client NTP skew 5 秒晚, 其 timestamp 偏大 → 后写 user 可能有 της late timestamp > 中先existent。后写 overwrite 不合法 drifted NTP data wrote системы. Fix: 用 client_id + monotonic timestamp 而非 now(); 是Cassandra Java driver 自带。

CockroachDB HLC 偏离 → Out-of-Order Read

CockroachDB HLC 假设 max_offset = 250ms。 某节点 NTP drift 超过 250ms 后, write ts 偏移 too far > 其他节点会读到 stale committed 数据. Fix: cluster 启动 + 周期 monitor NTP divergence, 超过 251ms 让节点自行 fail exit.


六、易错清单

  1. Lamport Clock 不能区分并发: LC(a) < LC(b) 不能推 a → b, 因为 a 和 b 可能 concurrent. 要并发检测必用 Vector Clock。
  2. Vector Clock 不解决 conflict, 仅 detect它: 应用层 merge function 必须好好设, 否则 sibling 无限堆积.
  3. Vector Clock 必须传整个 vector 而非只是 last counter: 传 dot 而非 vector 就 lost causal info ⇒ DVV 设计 fixing 此。
  4. HLC 不有 external consistency: HLC 假设 bounded skew, commit-wait 时达不到线性化世界的"real-time 一致"—— 上 bounded skew = 250ms CockroachDB commit-wait 不需 wait 直接发 ts, 因此 client 可能在 read 看到 future ts commit。 CockroachDB explicit read_no_write_ahead 处理。
  5. HLC max_offset 必须 monitor: NTP drift 大会让 HLC invalid. cluster health dashboard 必须显示 skew。
  6. Wall clock not monotonic: 重启或 NTP step 后 wall clock 可能跳 backwards. HLC 算法中 if wall_now > HLC.pt 检查保护, 但 NTP stepping 总需要 long-term installreboot aware.
  7. Vector Clock encoding 没用 delta compaction: 100 node VC 数组大. 工业用 sparse + delta encoding (如果 4 节 domany-HashMap gas has compile 快).

七、这一章带走的东西

  1. Lamport Clock 给 happens-before 保 inter-process ordering 但不区分并发; vector clock 精确刻画并发但 N node cost.
  2. Vector Clock 在 Dynamo / Riak siblings 上 detect concurrent writes, application merge function 解决 conflict.
  3. HLC 融合 wall clock + logical counter, 给人友好的时间戳同时保 happens-before. CockroachDB / YugaByte / TiDB 内置。
  4. TrueTime (Spanner) 通过 GPS+ atomic clock 直接给 7ms 内 skew, commit-wait 实现 external consistency。 与 HLC API 形式 似但 hardware 差异大。
  5. Vector Clock vs Version Vector vs Dotted Version Vector vs Interval Version Vector 是 distributed stores 世代演进, 处理 client anonymity + dynamic membership。
  6. Lamport Clock Total Order (LC + process_id) 是任意 tie-break; 不能用来推并发 events simultaneous.

下一节 → TrueTime / HLC / attestation

TrueTime / HLC / attestation

TL;DR

Google Spanner (2012 OSDI) 是全球第一个跨数据中心实现 External Consistency (linearizability + serializable + real-time 顺序) 的数据库——靠 TrueTime API TT.now() returns [earliest, latest] 区间, 配合 commit-wait (等至 latest > commit_ts 才 reply client) 避免 stale read。 TrueTime 通过 GPS + Atomic Reference Clock 在数据中心每几 ms 同步物理时间, 给 [earliest, latest] 区间不确定性 ~7ms。 CockroachDB / YugaByte 用 HLC 替代 TrueTime, NTP-bound skew 250ms。本章梳理 TrueTime API 形式, commit-wait 算法, Spanner Paxos group + 2PC 的分布事务流程, TrueTime 的硬件 attestation (GPS/原子钟故障检测), 与 HLC 的对比。


一、物理时钟的 distributed problem

Wall clock skew

NTP 同步精度跨数据中心 ~50-100ms (粗), PTP 同步 ~1ms (严谨), GPS ~100us-1ms (条件好), 原子钟 ~微秒-nanosecond。任何方式都会引入 skew, 必须 bounded 才能用 commit-wait algorithm。

同步为什么不可能 perfect

特殊相对论 + 信号传播 (光速 30 cm/ns) + 量子 noise 让 oracle clock 实现困难。 Google Spanner 用 GPS + 原子钟两种 independent source, 不信任单一源 (任一可失败)。 TrueTime 实际 skew bound ~1-7ms。

HLC 软 bound

HLC 假设 NTP skew bounded by max_offset (CockroachDB 默认 250ms)。 Tolerate 较大 skew 但 commits 是 predictable bounded skew 而非 hard guarantee, 用 HLC + monotonic counter 维 happens-before。


二、TrueTime API

函数签名

type TrueTime struct {
    earliest, latest time.Time
}

func TT.now() TrueTime         // {earliest, latest}
func TT.after(t) bool          // t < earliest
func TT.before(t) bool         // latest < t

now() 返回一个区间, 真实 wall clock 在 [earliest, latest] 内。after(t) 表示 t 已绝对过去 (t < earliest), before(t) 表示 t 已绝对未到 (latest < t)。

Commit-Wait 算法

Spanner 写事务 commit:

1. coordinator picks commit_ts = latest of TT.now()
2. Paxos group replicate log entry with commit_ts
3. coordinator waits until TT.after(commit_ts) returns true
   (即 earliest > commit_ts, 真实时间已过 commit_ts)
4. reply client "commit ok"

这 wait window 称 commit-wait: 大约等于 latest - earliest (~7ms)。等待期间保证任何 future transaction (real-time 在 commit-wait 完成后开始) 都看到 commit_ts strictly smaller than own ts → external consistency。

External Consistency 形式化

若事务 T1 commit 在 T2 begin 之前 (按 real wall clock), 则 T1 commit_ts 必 < T2 begin_ts。 这让 Spanner snapshot isolation 提供 "stale read with explicit timestamp" 始终正确:

  • read at ts=t 看到所有 commit_ts ≤ t 的事务修改, 不会看到 commit 在 t 之后的修改。

三、Spanner 架构

Paxos Groups

Spanner 把数据 sharded 到 tablets, 每 tablet 由一个 Paxos group (5 副本跨 DC) 复制。 每 group 自有 leader 与 log。

跨 tablet 事务用 2PC:

  • Coordinator = 写锁持的 tablet group leader.
  • Participants = 所有被 update 的 tablet groups.
  • Coordinator prepare → all participants prepare → coordinator commit → all participants commit.

2PC 在 Paxos group 内部 acquires Paxos commit per participant, 保证 per-tablet durability.

写流程

T1: BEGIN
  read_lock a (tablet A_paxos_group)
  read_lock b (tablet B_paxos_group)
  write a, write b
T1 commit:
  commit_ts = s = TT.now().latest
  prepare on A, B (Paxos group log entries with ts s)
  prepare ack on A, B (majority each)
  wait commit-wait (TT.after(s))
  commit ack to participants
  return client OK

关键点: commit-wait 不阻塞 lock (因为 prepare 已 hold locks), 仅阻塞 ack-to-client。在下 epoch 开始时, prepare 已 pile in Paxos log, 其他事务的 prepare 已 release pending locks with commit-ready state.

读流程

T2 read at ts t:
   find tablet group(s) holding data
   select replica (any with t-applied log) — leader or sufficiently-replicated follower
   wait until that replica's log has applyed up to ts t  (Paxos group leader 告知)
   return state at ts t

读不需要 2PC, 单 RTT (or local from follower if log applied). 牺牲略 latency/cross-DC = read throughput 高。

Lock Table

Spanner 用 2PL (two-phase locking) per transaction: read locks + write locks by row。 Deadlock 检测实际至 abort 其中一个事务 (wound-wait 算法)。


四、TrueTime 硬件 Attestation

时间源

每 DC 一组时间 reference (GPS 接收器 + 原子钟), multiple sources redundant. 若 GPS 信号短暂失, 原子钟提供 tick; 若原子钟失 (rare), GPS 提供同步; 若两都失, machine 上报 "unsynchronized" → fail-shut.

Attestation

TrueTime API 不仅给你时间, 还伴随 trust signal: every now() call 米conly "Trusted" 时 cache time, 否则 reporting fail-shut. Shielded hardware 验证流程: GPS signal → machine encryption log → 安全 monitor 验 GPS signal 来源可信 → publish trusted node info.

故障 Scenarios

  • GPS signal loss: 原子钟持相对正确时间, skew drift 累 -> 30 minutes 后 fail-shut cluster. 故áo 重 human operator catch alarm.
  • Atomic clock failure: GPS 出 stand-alone, 实际 never happens (因为 GPS 触校正 over time). Carring.....
  • All timing failures: cluster 主动 shutdown, reject transactions. Paxos election 暂停, 防 stale time stamp commit.

与 HLC attestation 比较

HLC 没硬件 attestation, 仅 NTP 提供, 软件 monitor 必须主动检测 skew, 节点 fail exit. Spanner is strict in this sense — admin-level hardware redundancy necessary.


五、HLC vs TrueTime 对比矩阵

维度TrueTime (Spanner)HLC (CockroachDB)HLC (YugaByte)
时间源GPS + atomic clockNTPNTP
同步精度≤ 7ms≤ 250ms (max_offset)≤ 250ms
Commit-wait cost~7ms (wait latest > commit_ts)不需 commit wait不需 wait
External consistencyyesno, but bounded stalenessno
硬件依赖GPS 接收器 + atomic clock per DCNTP onlyNTP only
部署成本
适用场景大型 cloud vendors中型企业中型企业
Stale read 防御TrueTime API 自然防御HLC ts invariant + read-at-ts filter同 CockroachDB

HLC commit-wait 不必要原因

HLC ts 已经是 monotonic (lt counter monotonic) + bounded pt skew, prepare 在 bgot commit time ts; 不需 commit-wait。但 HLC 让 Spanner external consistency missing — 客户端可能看到 future ts commit (newer than read at ts).

CockroachDB 与 YugaByte 区别

CockroachDB: HLC + MVCC 在 Postgres SQL 架构。 YugaByte: PostgreSQL frontend, 但 storage 是 doc-store + Raft group, HLC 提供 Mohamed API bounds.


六、Other Systems 与时间

Calvin (Yale 2012)

Calvin 离线 batch transactions, 总铁 ordering before 执行. 测 toschematically boolean balls; Kalvin时间 user-side TD-error tolerance 我 wait no, 我oracle actually per write 暂. Calvin pseudo offline 通过 batch locked a start_commit presumably use so this is awkward SAS postman now direct 性union mechan general application pins strict on 完 全 static data snapshot real-time gone 是 trans it.

FoundationDB

FoundationDB resolve clock issues by sequencer: 一个全局 sequencer 接收 transaction 提交 timestamp monotonic. 各 client direct API 是 transaction.commit(): callee 拿序号顺序, 不依赖 wall clock. Linearizability 单 serializalman泽. 但是 clock-free 用 集中 bottleneck.

HBase 与 Accumulo

HBase timestamp 默认 wall clock. 已知潜在 out-of-order commit if skew; 应避免 depends on. Accumulo 同样。

MongoDB ClusterClock

MongoDB coordination node iterates' logs all dependent uncommitted used 完.sequencer 含 logical 时间 integrating with physical timestamp in mingshi.


七、典型事故

Spanner GPS Loss

2015 Google 曾 talk 提到某 DC 一 GPS天线短暂失联 (暴风雨). 原子钟 hold 时间正确, 但 skew 渐累. Spanner 5 minutes 后上inia 类 fail-fast alert, operator 处理恢复 GPS.

CockroachDB NTP Skew Storm

2018 用户报告 CockroachDB cluster slow queries 因为 3 节点 NTP skew 超过 max_offset (250ms), cluster nodes 拒⚭ 异 relation offset rejected; team修改 cron ntpdate to keep drift < 50ms.

MongoDB Clock Skew 写丢失

MongoDB replica set wall-clock-based timestamp 排序 commit, 某 node skew 后写 后 损 ye叙述 丢失现象. 多个 issue reported in MongoDB Jira (2016); fix: use logical timestamp in replication log。


八、易错清单

  1. TrueTime 不是 oracle: 你必须 commit-wait 第三方 before reply。 没 commit-wait, external consistency违反。
  2. Spanner 不只 TrueTime: 还有 Paxos + 2PC + Lock table + 4PL, TrueTime 给时间戳正确性.
  3. HLC commit-wait 是 optional: HLC upper bound 不 wait, 但 staleness bounded by max_offset。 业务可接受 weak consistency 才用 HLC。
  4. CockroachDB 写入突然不再 单一 well ordered: HLC across region 可能违反 real-time order, 仅 per transaction opertion的关系保 happens-before.
  5. GPS 必须 multi-source: 单 GPS source 故障 → cluster unsynced, 多源故障 fail-shut. Spanner 至少 2 GPS + 2 原子钟 per DC。
  6. Clock 政府 aware NTP 与ictschronous 数据 Illegal: NTP 可以 使 节点 clock backward. HLC 算法若 都 没 caught backward clock navigate 上 partial 可能 导致 Haremix ts broken; cockroach supports NTP sane 版本 with proper monotonous rate.

九、这一章带走的东西

  1. TrueTime 给 [earliest, latest] uncertainty API, commit-wait 让 commit_ts guarantee strictly 小于 future begin_ts ⇒ external consistency。
  2. Google Spanner 是 industrial 实现的 Paxos group + 2PC + commit-wait + TrueTime。
  3. HLC 是 software-only fallback, NTP skew bounded; 但 external consistency 仅近似, 真正实时保证仍硬件 sync 需要.
  4. Clock attestation (GPS + atomic) 让硬件 fail-shut 安全; 没建 monotonic hardware 就 not safe.
  5. CockroachDB / YugaByte 用 HLC 取代 TrueTime, 同十字低部署成本 不保 external consistency strict 2PL-fence only serializable + bounded staleness.
  6. FoundationDB 用 sequencer 节约 wall clock 量 importance; 中央 sequencer serializing 是车另 第四百 srullt. 用 distributed 省ariesle clock alternative.

下一节 → DAG、git、blockchain 的序

DAG、git、blockchain 的序

TL;DR

DAG (Directed Acyclic Graph) 是 partial order 最自然的数据结构——明列每个节点依赖于哪些前驱节点, 拓扑排序保 happens-before。 Git 用 commit DAG + tree object 做版本控制 + artificial merge commit 处理并行开发。 Blockchain 是 linearized DAG (HashChain), 用 PoW / PoS / BFT 在众多候选分支上强制选唯一主链实现 total order; Bitcoin / Ethereum / Solana 各有 trade-off。 DAG 区块链 (Nano / IOTA / Avalanche / Hedera) 把 linear chain 扩展到 full 2D DAG, 让无 conflict 的事务并行打包 + 仅 conflict 路径跑共识。


一、DAG as Partial Order

形式

DAG 是 directed graph, 无 cycles ⇒ 节点 A → B 意味 B 依赖 A (B 在 A 之后发生)。 拓扑序是任意线性化 partial order, 满足 A → B ⇒ A 在 linear 序列前。

flowchart TB
    A[A] --> B[B]
    A --> C[C]
    B --> D[D]
    C --> D
    D --> E[E]

合法拓扑序: [A, B, C, D, E][A, C, B, D, E]. 不合法: [B, A, ...].

Hasse Diagram

Hasse 图是简洁 DAG 表示——丢弃 transitive edges, 留下 minimal cover (covering relation).

Partial Order 的来源

分布式系统里, partial order from:

  1. Lamport happens-before : process order + send-recv + 传递闭包。
  2. Memory consistency: 多核 CPU 内部指令依赖。
  3. Causal broadcast: logical 进程序 + message causal 携带。
  4. State machine replication: log order per replica。
  5. Git commit dependency: parent commit 必须先发生。

二、Git's DAG

Commit Object

每 git commit 是一个 SHA-1 (或未来 SHA-256) hash 对象:

tree <tree_hash>
parent <parent_hash_1>     # 主线第一个 parent, 一般 ancestor
parent <parent_hash_2>     # merge 的合并第二条 parent (普通 commit 没)
author Alice <alice@ex.com> 1699999999 +0000
committer Alice <alice@ex.com> 1699999999 +0000

<commit message>

commit hash 是 SHA-1 of the REST content. 任何 commit 改不能改父, forgery 需 computing equivalent SHA-1 preimage (computational impossible)

Tree Object

每 tree 是 hash container of files + subtrees:

100644 blob <sha1> README.md
040000 tree <sha1> src
100755 blob <sha1> build.sh

blob 是 file content hash; tree 是 directory content hash. 递归 tree 嵌套则 nested sub-trees. file modification → blob hash 改 → parent tree hash 改 → ancestor tree hash 改 → commit hash 改.

Refs

  • refs/heads/main 指向 main 当前 commit
  • refs/tags/v1.0 指向某 tagged commit
  • refs/remotes/origin/main 同 remote

refs 不是 commit content 一部分; 各 clone repo 可各自有自己 refs.

DAG Operations

flowchart TB
    BASE[Merge Base<br/>A+B 共同祖先]
    A[A's commits]
    B[B's commits]
    MERGE[Merge commit<br/>2 parents]
    BASE --> A
    BASE --> B
    A --> MERGE
    B --> MERGE

git merge

创建两个 parent 的 merge commit, 引用两 parent 的 tree + 三方向 merge algorithm:

  • 三个 commit: common ancestor (merge base O), ours A, theirs B
  • 三方 diff 执行: 若 same content in A 与 B → 接受。 若仅一方改 → 接受 change。 双都改 → conflict (用户手动 resolve)。

git rebase

Rebase 重新写 commit DAG: 把当前分支 commits "应用" 到 target branch tip:

Before:   A - B - C - D (main)
              \
                X - Y (feature)

After rebase feature onto main: A - B - C - D - X' - Y' (feature, each commit rewritten)
                                              old X Y - 暂挂、prune

Rebase 收缩 DAG 线性化, 适合"干净 PR history"; merge 保并行 history。

git rebasegit merge 比较

  • merge: 保 history 准确切, commits 不重写。 适合 public branch (team-shared main)。
  • rebase: rewrite 自己 commits, 冲突可整理。 适合 private branch before opening PR。

DAG 上的序

  • git log --topo-order: 拓扑序, 父在前。
  • git log --date-order: wall-clock order, 但保证 partial order 不完整状元。
  • git log --graph: 同时展示 merge commit 节点 + 边, 让 multi-parent 节点可视化。

Cherry-pick / Revert

git cherry-pick <commit>: 单 commit 复制应用到当前 branch, 创建新 commit hash 但同样 message + diff-content。

git revert <commit>: 创建一个 inverse diff commit。

Git 经验: Stored-over-time model

git 是函数 Stored-over-time 的 with tree immutable DAG, branches 与 tags 是 hashes; commit SHA-1 immutable; 但 refs mutable.


三、Blockchain 的 DAG

Bitcoin: Linear HashChain

Bitcoin 不是真正的 DAG——它是 well-defined linear chain:

block N hash = SHA-256(version || prev_hash || merkle_root || timestamp || nBits || nonce)
prev_hash = previous block hash  → 强 dependency

按 hash 链, 改任何历史 block 内容 → 其 hash 改 → 下 block invalid → 整 chain post-edit 失效 (像 git commit hash 改变后续 commit hash 全变).

Forks 与 Longest-Chain Rule

Bitcoin 节点可同时挖不同 block (race condition), 产生 fork:

                 → Block i+1 → Block i+2
Genesis ... --i <
                 → Block i+1' → Block i+2' → Block i+3' (longest = canonical)

Nakamoto consensus: choose longest chain by total work (each block header hashes difficulty); short fork 丢弃, 成为 "orphaned"。

Longest-Chain Risk:

  • 51% attack: 控制 51% hash rate 可挖更 longest faster, overwrite legitimate chain。
  • Reorg: blocks on shorter chain become orphaned, containing tx 重新包入 longer chain by miners (or never if excluded) → tx 可能 double-spent if attacker 与 mine simultaneously.

GHOST (Greedy Heaviest-Observed Sub-Tree) Rule

Ethereum PoW 时代用 GHOST + uncle 加入 main chain:

  • 若 forked, 走 sub-tree validator 权重最大的 branch。
  • 让 uncle block 也作 "uncle reward", 仍 divestment sim kind fork over short confirmed time.

Ethereum PoS Post-Merge

Post-Merge (2022-09-15) Ethereum consensus changed from PoW → PoS:

  • Validators stake 32 ETH。
  • 每 slot (12s): one validator 任 proposer 提出 block. other validators vote attestation
  • LMD-GHOST fork-choice: validators see fork, 选择 validator weight 最大 sub-tree。
  • Finality: 2 epochs (~13min) finalize; finalized blocks 不可被 invalidate unless 1/3 validators slashable (stake burnt)。

Solana: 高吞吐 DAG-ish

Solana PoH (Proof of History) 是 VDF (Verifiable Delay Function) 链: leader 持续 hash pre-image chain, 提交 entries + transactions。 PoH 给每 entry a monotonic timestamp + leader identity 进入。 跨 leader handoff oneraf slots (400ms), 多 leader 排成 epoch。

不是严格线性 DAG, 但是是 "single-leader linear chain with parallel entries", throughput 65k TPS claim.


四、DAG Blockchain Variants

Nano (block-lattice)

每账户独立一条 blockchain:

Alice's chain: ... → Send to Bob block_i
Bob's chain:    ... → Receive from Alice block_j

tx 是 双方链 update send + receive, 异步确认 + no global consensus。 Conflict 限于 单账户 double-send, 用 delegated PoS vote resolve.

IOTA Tangle

每 transaction 必须 approve 2 前驱 transaction (tip selection). "累积 weight" large = confirmed.

       T1   T2
        \  /
         T3 (新 tx, approves T1 + T2)
        /
       T4 ...

无须 miners, low-energy. Withdrawn by IOTA Foundation 2020+, 改用 Chrysalis (linear chain plus coo). 原始 Tangle 因 tip selection 攻击困难.

Avalanche (AVAX)

Avalanche 共识: DAG 节点之间 "sub-sampled gossip", 收集 preference 投票; 反复多次收敛超 quorum threshold → "snowball" metastability。

每用户多次 query random K validators; 多数 vote → adopt; sufficiently consecutive 接连 adopt ⇒ finality probability geometric high.

优势: sub-second finality, high throughput. 复杂 probability analysis.

Hedera Hashgraph

Hashgraph 是 "gossip about gossip" + virtual voting. 每节点 pair gossip + exchange known events; events ordered by ancestor relationship。

每节点独立 compute virtual vote ordering → fair consensus. 不需 PoW. council 39 nodes (verified entities only) — AAP-style BFT.


五、Sui / Aptos Move DAG

Sui 是 2023 main net, 用 Move language + programmable transaction blocks (PTB). 每个 transaction block 含一组 independent objects + operations. DAG 上:

  • 若 transaction touches 独立 objects (no shared), 拜 pass validation single-validator (fast path, sub-second finality)。
  • 若 touches shared objects, 走 Narwhal+Bullshark DAG consensus (基于因果传播 + BFT)。

Sui 与 Aptos 都从 Diem (libra) 项目衍生, 用 Move language 强 fuel 但 consensus 机制不同。 Aptos 用 AptosBFT v4 (HotStuff-1 推 BFT).


六、典型事故

Git SHA-1 collision (2017 SHAttered)

2017 Google SHAttered paper: SHA-1 collision 可构造 (~6500 year CPU time 算)。 git 现在 detect collision attack mode 但没 mandate migration to SHA-256。 GitHub 用 ContentAddressing storage; SHA-256 migration on road。

Bitcoin Cash Hard Fork (2017)

Bitcoin Cash 与 Bitcoin 分叉因 scaling disagreement (block size 1MB vs 8MB)。 硬分叉 (hard fork) 说明 blockchain design 升级非 backward-compatible 时, predecessor chain 与 successor chain 并行存活——这是 DAG 性 (under hashchain)。

Ethereum DAO Hack Rollback (2016)

DAO hack 漏 exploited ~3.6M ETH。 Ethereum 社区硬分叉逆转 state 状态。 这展示 "code is law" 与" community governance is law" 的 tension: Nakamoto consensus 是 longest chain wins, 但社区社群选择性 reverse 让只有 "longest chain we morally accept" becomes canonical — 但 "这个 chain 社区 accepted" really (minority chain Ethereum Classic survive)。

IOTA CST Intersection Removal Crisis

IOTA 早期自定义 cryptographic hash function "Curl" unsound。 2018 MIT disclosed vulnerability + disclosed 后 IOTA Foundation 临时 fix + replace Curl entirely with Kerl (Keccak variant). Showed importance of standardized crypto primitives rather than roll-our-own.


七、易错清单

  1. Bitcoin's "Longest Chain" 实际是最多 cumulative work 链: 不是简单 deepest chain. Code 比较 nChainWork 累计 difficulty weight, 不 chain length.
  2. PoW 不是 fully BFT-resilient: attacker 持 51% hash rate 可重新组织 chain, rewrite transactions (尤其未 confirmed)。 finality probabilistic, 没有绝对 finality.
  3. Ethereum finalize 节点被 slashing 必须 ≥1/3 ETH stake burnt: 跨社交 forkсли 决 exact relevant.
  4. Git commit hash 不能改但 refs 可以: history rewrite (git rebase / commit --amend) 改 commit hash, refs 后推新版, 默认 force-lease required.
  5. Nakamoto consensus 不 collapse under "any" fraction of malicious, but probabilistically under 50%+ hash: mining attack threshold 51%, 50% enough for some liveness attack, 30% for resource attack.
  6. DAG blockchain (Nano/IOTA/Avalanche/Hedera) 都有不同的 conflict model: 不要假设 这是 same thing. Nano 是 "single-chain-per-account async sync, double-spend attacks mitigated via dPOS", IOTA 是 "two-tip-approval tangle with accumulated weight" (deprecated), Avalanche 是 "iterative subsampling voting", Hedera 是 "gossip-about gossip + virtual voting".

八、这一章带走的东西

  1. DAG 是 partial order 自然数据结构, 拓扑线性化给出全序但保 partial constraint。
  2. Git 是 immutable DAG (commit + tree + blob) + mutable refs. branch 与 tag 都是 refs 而非 DAG 节点。
  3. Blockchain 是 linearized DAG (HashChain) + Nakamoto consensus 强加 total order。 Longest-Chain rule 是受理最高 work 链, 不是最长链数量。
  4. Ethereum PoS post-Merge 用 LMD-GHOST fork-choice + 2-epoch finalize 给 finality, 加上 1/3 slashing 给 safety incentive.
  5. DAG 区块链 (Nano/IOTA/Avalanche/Hedera) 通过 partial DAG + conflict-confined consensus 实现 high throughput, 各代表不一样 algorithm family。
  6. Sui/Aptos + Move 这两种 "modern" 区块链, 来源于 Diem 工作, 把 fast-path (无 shared object) 与 consensus-path (有 shared) 分离 DAG 结构. single-validator fast-path 与 Narwhal+Bullshark DAG 协同.
  7. History rewriting in blockchain (DAO hack 2016) is technically a hard fork — chain accepts new rules, 中意领袖 社区性 created "real" social 枷锁. 跟 quadratic 上无法 stringify 子.

下一节 → 分布式存储与容错

分布式存储与容错

分布式存储与容错关注"数据分布 (sharding/replication)、持久性 (durability、EC、quorum)、可用性 (replica placement、failover、scheduling)"。

Quorum / W+R>N / Read Repair

TL;DR

Quorum 系统是 Dynamo / Cassandra / Riak / Voldemort / DynamoDB 等 leaderless AP 数据库的基础: 写要求 W replicas 确认, 读要求 R replicas 响应, W + R > N 是保证 read quorum 与 write quorum overlap 的必要条件——但不是线性化的充分。 本章扫:

  1. Quorum 公式与它的边界: W+R>N、Cassandra 默认值、 LOCAL_QUORUM vs EACH_QUORUM。
  2. Sloppy Quorum + Hinted Handoff: 短期 outage 透写。
  3. Read Repair 贯量: coordinator 收 R 个响应, 推 newer 写给落后副本。
  4. Merkle Tree + Anti-Entropy Repair: 周期修复延迟。
  5. Strict quorum vs sloppy quorum, stale read 风险 with non-strict quorum。
  6. 典型 use case: DynamoDB RC vs Strong Read, Cassandra RackAware + LOCAL_QUORUM, Riak bucket property.

一、Quorum 系统形式化

N, R, W

  • N: 每个 key 的 replica count (replication factor)
  • W: write consistency level, W replicas ack write 才算 commit
  • R: read consistency level, R replicas 响应 read

公式推论

W + R > N  ⇒  read quorum 与 write quorum 必 overlap (至少 1 node) ⇒ read 看到最新 written value
N = 3, W = 2, R = 2 ⇒ overlap ≥ 1, stale-proof within sync replicas
N = 3, W = 1, R = 1 ⇒ overlap 可能 = 0, read 未必见最新写入
N = 3, W = 3, R = 1 ⇒ write 全副本持久, 单 read 不需 quorum 检查 (但慢, 任一 replica 慢 → write wait)
N = 5, W = 3, R = 3 ⇒ "majority" 与 "majority" 重叠 ≥ 1
N = 5, W = 2, R = 3 ⇒ 一般不推荐 (write 太弱, 任何 write 被节点副本完 ack 因 prefix check 压 缩 correctness)

W+R>N 是必要非充分条件

W+R>N 保 "quorum overlap" — 即 read 至少有一个副本看过那 write。 但 read 拿 raw value 后, coordinator 比较 R 副本返回的 timestamps/version:

  • 若 R 个副本都不一致 → coordinator 用最新 value reply + 触发 read repair.
  • 若 R 误选 stalest quorum → 与 prior write quorum 不 overlap → coordinator 看到的全是 stale value。

修复: extend read 传入 timestamps filter — 协调器 push quorum fingerprint query 况 → 客 client指定 replicated quorum. 二者 cross-DC LOCAL_QUORUM 加 extend read 不 cross-DC resolve.

Strict Quorum

Strict quorum 系统要求 read 必有 stale 修复 — coordinator 选 reads:

RESP_quorum = R replicas (含 newest known)
if not all synced:
    trigger async read_repair
    return newest
  • 总 max believability 与 coordinator 完全 understandcost.
  • If staleness 加做 structural: read_lease 您 leader 新 leader 上更新版 → 协议 laffers fall well probabilistic.

二、Cassandra Consistency Levels

Cassandra CQL 支持 per-query consistent level:

Level解释
ANY任意一个节点 (含 hinted handoff) ack 即可
ONE单个 closest replica ack
TWO / THREE2 / 3 个最近 replicas ack
LOCAL_ONElocal DC 1 个 replica
QUORUMmajority N/2+1 across all nodes
LOCAL_QUORUMmajority within local DC
EACH_QUORUMeach DC 各 quorum 强一致 (跨 DC 强 一)
ALLall N replicas ack

LOCAL_QUORUM

跨 DC 部署最常用:

  • coordinator local DC reach majority quorum ⇒ reply client fast (~5ms P50)
  • cross-DC async replication 后 happen_NEXT隊 converge

不保 cross-DC 同时 fail → 提供一致 fallback.

EACH_QUORUM

跨 DC 强一致: 每 DC 各 majority quorum acked 在 commit. latency = cross-DC RTT (~100-500ms). rare 用, 但 high-stakes session application useful.

ALL

最严格一致 + high latency. fail = 1 replica 全障 → write 失败. Rpo = 0, RTO ≈ 0.

Cassandra 默认

Cassandra driver 默认 LOCAL_ONE (low latency, eventually consistent)。 用户 read 用 LOCAL_QUORUM 大致 与 write LOCAL_QUORUM W+R>N 保 staleness high。


三、DynamoDB Consistency

DynamoDB (AWS) 2007 Dynamo paper 派生。 DynamoDB operations support:

  • Eventually Consistent Read: default; 高 throughput + low latency + ∼1s 可能 stale。
  • Strongly Consistent Read, consistent=true parameter in SDK; R = ⌈N/2⌉ + 1, priority 副本读 read_quorum检查 newer copy, 强制 stale < 0.5s。
  • Conditional Writes (ConditionExpression): opt-in CAS check via metadata table or special object level locks.

DynamoDB 默认 N=3 跨 AZ 跨 machines in real AZ. 何时 consistent, 何 read 性 drop。PRO Production SLA:

  • Eventually Consistent read: P99 < 10ms
  • Strong Consistent read: P99 < 20ms
  • Write: P99 < 10ms

四、Read Repair 回顾

(对照 repair.md 详细描述。)

Quorum + Read Repair 配合:

  1. coordinator read R 副本
  2. 比较 timestamps + 选 newest
  3. 后台 async push newest → 落后副本

风险: 若 (R=1) configured, read 不会接触 stalest 副本, read repair 不触发。 可以调大 R 让 read repair 更频繁。 同时 anti-entropy backup 修复 cold key。


五、Sloppy Quorum + Hinted Handoff

定义: write 在 -多 slaves responsense → quorum strict √ W 云 ramp. 依然 accept? quorum W. However if many replica unavailable -> fallsloppy:

  • choose live replacement replica + ring's next node with hinted_handff (handoft substantially quirks)

Use case: rolling restart时分 cluster, support 进行 operations. RPO grace 在 hint window (~1-3 hours)。

风险: sloppy quorum 不保 W+R>N ⇒ read 可能拿到 stalest (replacement replica has 提 hint 但 not yet forwarded)。 认真业务 use case 用 LOCAL_SERIAL serialization = Paxos-on-row, consistency trade cost.


六、Strong Consistency for Quorum Systems

Dynamo 等 AP quorum 系统不本质上 linearizable—

  1. Non-linear: write 后 may "toasted" if read goes to stalest replica (W=2 WASN'T read see ALL K reads 遇quar replica exact write) eventually consistent.
  2. Reading compare timestamps典型 LWW 写 → solve明明 race LWW.
  3. Cassandra Lightweight Transactions (LWT) = 4 phase Paxos + global cluster → linearizable.

quote linearizability 与 quorum 跨不一定 同 level dimension. Quorum 保 both freshness + availability of financial takes via class mechanisms without 全 leaderless.


七、Merkle Tree Anti-Entropy Repair

(详细见 repair.md.)

Quorum 系统一般提 daily / weekly summary++

anti_entropy_period: 10 days
incremental_repair_cycle_halfweek (event triggered)

periodic 算 merkle_tree per token范围 + cross node diff + stream repair.


八、典型事故

Cassandra LWW Clock Skew Lost Write

某用户 1 node NTP drift 5s, writes clients 过 系统接受 timestamp (P), ultimately metastability prevail 老补复制 W size 接受 daemon "fail_over trigger after 14 days" due boot. Fix: driver ensure monotonically increasing timestamp internally (driver server map memp), 不是 native now().

Riak W=1 R=1 Stale Policy 故障

某 Riak cluster buckets default option.fail with -- buckets, We 社写 1 sync asynchronous 副本. Post-reply within last read 显 stale 责. OPS Fix: 副本达标 W = (N/2)+1.

MongoDB WriteConcern=majority 不 = linearizable

MongoDB replica set w="majority" 提折 "the majority 已 ACK 知知 the entry", 但 不 write journal fsync 完. 鸿沟 在 6.0 森 senior recommended w="majority", j="true" give guarantee fsync durable. 没 j=true: 可能 leader crash 后 majority 已 ACK 但 commitlog 不 durably persist → durability loss.


九、易错清单

  1. W+R>N ≠ linearizability: 它 保 quorum overlap so freshest 可显 但 仍然 race (just ephemeral staleness*) → 100% linearizability 相同方案重.
  2. LWW clock skew: 系统必须 trust clock 或 use HLC / vclock + driver monotonic. NTP drift 可 跨 5s。 HLC native on CockroachDB/YugaByte SQL database servers.
  3. Anti-Entropy 是 must, 不是 optional: cold-key 仅靠 read repair 不足. 周期 repair 与监控(monitor execution报) crucial.
  4. Sloppy Quorum 与 strict 写区别: W=ANY accepts hint replicas but R>W can miss fresh write, so legitimately的生产 use case 谨慎.
  5. MongoDB j=true: w=majority 仅指 mmap-in-write backlog, j=true file fsync to disk. 基本 durability 要求.
  6. LOCAL_QUORUM 默认有用: 但 单 DC 跨 region partition 时 不能 数据 持续. 考 EACH_QUORUM 必要 if灾难性 cross-DC partition 亦需要 linearizable commit .
  7. R+W>N 与 cross-tier 但 跨 DC no caching: LOCAL_QUORUM only-quorum in LC+local DC, 但其他 DC copy completely 异. 不 cross-DC strictly find cycle ensure RPO small.
  8. lineage consistency fault Casptum thropts: `fail体育读 交易 Перед transaction never in management in major sorft datacenter dispatch LWT 全道 4 RTT 与 集群 中心 in datacenter ⇒ microsecond to fixed latency 不 provide.

十、这一章带走的东西

  1. W+R>N 是 read/write quorum overlap 必要条件, 但不 linearizability 充分. R=quorum 可以拿 stalest quorum 曲, 不 consensistance with newest versions including "latency" delay.
  2. Cassandra per-query CL 可调: LOCAL_ONE / LOCAL_QUORUM / EACH_QUORUM / ALL = consistency vs latency trade-off spectrum.
  3. DynamoDB Strong consistent read = R = ⌈N/2⌉ + 1 = quorum read, 否则 EC.
  4. Read Repair + Anti-Entropy 混合 must: high-frequency cold hot path 修复热 key, cyclic修理冷.
  5. Sloppy Quorum / Hinted Handoff 短期 outage graceful. Hint-门至 typically 1-3 小时, longer-enc partitions 使用 anti-entropy.
  6. LWW clock skew 写丢失 = 真正 industrial the accident, business critical data must use client_monotonic timestamp or HLC.
  7. MongoDB w=majority ≠ durability without j=true + restart durable journaling. 这是见到 MongoDB operations. R+R data 呢 greatly different.

下一节 → Erasure coding / Reed-Solomon

Erasure Coding / Reed-Solomon

TL;DR

Erasure Coding (EC) 是 RAID-6 与 云存储 (HDFS, Ceph, AWS S3 Object, Google GCS) 替代 3 副本的低 storage-cost 高 durability 系统。 EC 把数据切成 N=K+M 个 chunks (K data + M parity), 任 K 个 chunks 重构原数据, 容忍 M 个 chunks 损失。 Reed-Solomon (RS) 编码是最常见 coding——数学是 GF(2^8) 多项式运算. 3 副本 = storage 3x, EC=RS(10,4) 把 storage decrease 到 1.4×, durability 仍 10^11. EC 的重建成本 (rebuild) 比 replication 高一个数量级——trade-off 是热数据用 replication, 冷数据用 EC. 本章梳理 RS 数学, EC vs Replicated 成本/durability, decode repair, 典型 HDFS / S3 / Ceph 调参, typical 事故.


一、Reed-Solomon 数学

有限域 GF(2^8)

Reed-Solomon 在 GF(2^8) 有限域上做矩阵运算. 每 byte 是一个 GF(2^8) 元素, 加法 = XOR, 乘法 = 模一个 generator polynomial (e.g., 0x11b = x^8 + x^4 + x^3 + x + 1).

编码原理

把 K 个数据 blocks d_1, ..., d_K 排成数组. 一组 M 个校验 blocks p_1, ..., p_M:

[p_1, p_2, ..., p_M] = [d_1, d_2, ..., d_K] · G[K, K+M]

其中 G 是 K × (K+M) "generator matrix" — 通常用 Vandermonde 或 Cauchy matrix. 任 K 列取出来构成 K × K 可逆矩阵 (over GF(2^8)) → 解 K 元线性方程组 reconstruct 数据.

Examples

RS(4,2): K=4 data blocks + M=2 parity blocks = N=6 total. 容忍 2 块 损失.

data:    [d1, d2, d3, d4]
parity:
  p1 = d1 ⊕ d2 ⊕ d3 ⊕ d4        # XOR parity
  p2 = g1·d1 ⊕ g2·d2 ⊕ g3·d3 ⊕ g4·d4    # strip parity with coefficients

任 4 个块 (out of 6) 都能恢复原 4 个 data blocks.

RS(10,4): K=10, M=4, N=14, storage overhead 14/10 = 1.4×, 容忍 4 块损失. HDFS 用此配.

Decode

If M 个 blocks lost, 拿 K 个 remaining blocks 形成 K × K submatrix of G, invert → 多 data. Concrete: 解 K 元线性方程组 over GF(2^8). CPU cost O(K²) per solve.

Cauchy vs Vandermonde

Cauchy matrix 让 every submatrix K × K invertible, 并方便 SIMD 加速 (Cauchy elements can be ratio'd creatively). Jerasure library 2.0 提供 optimized RS over GF(2^8). Intel ISA-L 加速 SIMD XOR + GF multiply. 现代 EC libraries (Jerasure 2.0, ISA-L, klauspost/reedsolomon Rust crate) roundly 让 1Gbps 翻 performance.


二、Cost-Benefit: Replicated vs Erasure

Storage Overhead

配置Storage overheadTolerance (lost chunks)例子
3 副本3x2 nodesDynamoDB, etcd, CockroachDB 默认
2 副本2x1 node部分 cold-storage
RS(6,3)1.5x3 chunks部分 cluster
RS(10,4)1.4x4 chunksHDFS EC 默认
RS(11,15)2.27x15 chunksGlacier archival tier

Durability model

Durability: 数据不丢概率 = 1 - P(超出 M failures). 失败假设: 1 disk AFR 1-4%. EC 让 cross-AZ + cross-rack placement让失败 uncorrelated.

EC durability vs replication 相同 storage cost:

  • Replication 3x (3 nodes): 11 9 durability 等于√N failure model over typical cluster life.
  • RS(10,4) 1.4x cost + cross-rack placement保持 11 9 durability, 配 1x cost.
  • RS 稍 长 间 reaction uses 释 actualmentation depends on rack妇联 + repair window.

Repair Cost

EC repair 在 failures 时 cost 大:

  • Replicated (3 replicas): 1 disk failed → 1 副本 byte stream 接 (network bandwidth = lost disk size).
  • EC RS(10,4): 1 chunk lost → client 拉 K=10 个 chunks → decode → reconstruct missing chunk. total read traffic = 10× chunk size, ~10x 比单 replication 修理代价.

实际: EC repair write 工作量 = K × lost_chunk. 高 K 的 爆炸 networks; low K 不耐多 failures. 平衡点 K=6-10.

Trade-off Summary

维度3 replicasRS(K,M)
Storage cost3x~1.4-1.7x
Read throughputHigh (local mirror)moderately lower
Write throughputHighlower (encoder overhead)
Durability11 911 9 same 经
Repair cost (per node lost)1x~K times harder
LatencyLowHigher (decode cost per read)
Use caseHot dataCold, archival

工业推荐: 热数据用 replication (低 latency + fast failover); 冷 数据 / archival 用 EC (省 storage, 接受 高 repair latency).


三、HDFS Erasure Coding

HDFS 早期 (3 副本) budget 大: 3TB 数据存储需要 9TB 集群. 2015 HDFS-EC (HDFS-7338) 加 RS 默认 RS(6,3). HDFS 3.0+ 设置:

hdfs erasurecode -setPolicy -policy RS-6-3 path

EC striping layout: 大 file 分 striping units (8KB default). 每 K 个 striping units 一组 → K data + M parity = N chunks per cell stored across N 个 datanodes.

性能 trade-off: HDFS EC bottleneck in client 上 read M "fill all units". throughput 读写 lower 30-50% vs replication. cold 数据节省 storage cost 补偿.

Rack awareness

HDFS EC 跨 rack 容 available. N=9 chunks mixed across racks, 让 nodes lost 各 rack 组好. Rack fail with 9-nodes-loss = pedant.

RS-LEGACY-10-4

legacy policy for older clients. EC at client side or datanode side (default client).


四、Ceph EC

Ceph RADOSPOOL erasure plugin. EC pool 后 EC= 基本 write 直接 EC + get reconstruct.

ceph osd erasure-code-profile set myprofile k=4 m=2 crush-failure-domain=host
ceph osd pool create mypool 64 64 erasure myprofile

Ceph EC pool 不支持 partial 修改 (EC 数学不支持仅改 one byte 不重算 parity). 解决方案:

  1. EC base tier for cold data。
  2. Replicated tier for hot data, after age cool → migrate to EC by tier mechanism.

EC Overwrite in Ceph

rbd (RBD 块存储) 后台要 write 改 ECS data + 一些 pkgoverhead. Ceph internally overwrite uses a write-modify-rewrite pattern但 高 cost. 块存储工业实践: 多 使用三副本 for RBD pool + EC pool 只用 RBD export snapshot archive.

EC coding library: Jerasure

Ceph 用 Jerasure 2.0 + GF-Complete SIMD 加速 (因 ISA-L license 不纯 GPL-compatible). Newer Ceph 用 isa-l plugin 兼 quickал encoding with SIMD SIMD (SSSE3, AVX2). 编码 throughput 1-5Gbps CPU on modern Intel server.


五、AWS S3 / GCS EC Internal

AWS S3:

  • Cross-region replication for small + EC for large files
  • durability 11 9 declared, RS internal assumed config
  • S3 Glacier uses RS with even lower storage cost (K=11, M=15 confirmed by AWS whitepaper reasoning)
  • Standard class ~$0.023/GB; Glacier ~$0.004/GB

Google Cloud Storage: Multi-region GCS declares 11 9 EC with cross-region 修复. Coldline / Archive ~$0.004/GB.

Azure Blob Storage: Standard tier LRS (Locally redundant 3 replicas), ZRS (zone redundant 3 replicas across AZs), GRS (geo-redundant 6 replicas cross-region), RAGRS (read-access GRS), GZRS (geo + zone). Hot tier replication vs cool tier use EC under-the-hood.


六、典型使用与对比

Backblaze Vaults

Backblaze (B2) 是 cold storage vendor. 公开 durability stat over 10 years: 总 storage = 2EB+, annual durability 实测 > 11 9 with replication 3 + monitoring + auto repair. 双 copy replication suffices for 80% use, EC for cold tiers archive.

Facebook Warm Btrfs Storage

Facebook 大型 photo storage 平台 Haystack 早期 replication 3 后 came f4 storage with EC (+ replication hot tier). storage cost saved > 65% in生产.

Spotify Cassandra

Spotify on-prem Cassandra集群 replication 3 (RF=3 + LOCAL_QUORUM write), no EC—Cassandra热数据 + EC slow not worth it. backup tier 用 S3 EC.

HBase + HDFS EC

HBase 通常用 HDFS-EC backend for archived HFiles (after compaction → cold); hot Memtable 与 HFiles use replicated HDFS. tiered.


七、典型事故

HDFS EC Migration Cost

某 cluster 4-year EC migration: total migration took 6 months. 老 3-replication data store 6 month before EC switch fully complete. 测试 HDFS-EC 实际 5x 写 throughput lower 3-replicate; migration 加上 rack-aware placement 严格 complies是 host count >> N.

Ceph EC Pool RBD Overwrite Cost

某公司 Ceph cluster test EC pool for RBD images; found 每次写入 4KB block 触发 full stripe rewrite (64KB 计算), throughput drop 90% vs replicated. Fix: RBD pool = replicated (3x storage cost), EC pool only for rbd export-diff archive.

Backblaze Drive Failure Correlation

Backblaze 公布 failure stat 显示同型号磁盘 group fail correlation 高 (e.g., 6TB Seagate 某型号 batch 30% AFR 1 year). EC 跨 model 跨 batch placement让 failures uncorrelated, EC durability 实际 低于 pure theoretical.


八、易错清单

  1. EC not low latency read: read 触发 K-decodemath; 第一线程 linear time ⋯ delays unit -bandwidth EC-read latency 不 as good replication (1 local replica 0 decoders).
  2. Repair boltloads nodes: K=10 EC, repair 1 节点 loss 推 K × size traffic 到 data. existing storage network capacity risk fix constrain stage. Cluster fail lethal 春 加 disk event 穿后 schedule streams tenant lift (data-nodes wave).
  3. Similar drive failure correlationis 是 real (Backblaze data): EC placement 跨 manufacturer batch + rack + DC + PSU. Placement policy 中 N,M fail now considersuper 低.
  4. EC overwrite 实际 expensive: don't configure RBD VM images on EC pool unless cold archive use only. Ceph EC money 醒: EC poo supports overwrite but very slow.
  5. N+M < availableFaultTolerance: design深层 AZ 数 与 cluster host数 ≥ N+M, 否则 N+M fail to placement. Cluster 最少 N+M 个 distinct hosts.
  6. Network CPU repair costcould = hot bonds: EC repair cost uses CPU GF-Complete SIMD + network. idle 加 SR-bar might queue. Don't run parallel repairs in cluster (stagger).
  7. EC smallest file 筹算 write: small file 5genome rowEC, postencoded chunk程 invokes large forbidden tests. 小file 的formance extra loin frac. 8 KB at K=10 mean even 80KB trips cache.
  8. EC + small副本: 必须 intersect cluster availability zone. 实际 AZ fail IT༉IRT worst case flash over racks in availability zone is ensuite; design crush-failure-domain=host does not AZ issues.
  9. EC chunksize duration overhead: chunksize 默认8KB با standard test optimus. High seek node K_Block scriptpath 三消wist 大小与 high 节 cost larger chunksize → 1MB stroage run cost CPU-维_2 36 min-refresh.

九、这一章带走的东西

  1. EC RS(K,M) = K data + M parity 总 N=K+M, 容忍 M loss. K+M 元素写离plus修复 trade 复杂度 = read extra K decode startup.
  2. EC 是 cold 数据低 storage cost trade better durability hot-replication alternative (1.4-2x storage vs 3x).
  3. Controlled instead全年为期, hot-vs replication 冷 storage tier EC 灯工艺; use Ceph erasure pool replicate hot in tier selection.
  4. EC内数学: GF(2^8) + Cauchy matrix 任 K × K 可逆咀嚼 on algorithms Duncan by SIMD CPU (Jerasure2, ISA-L).
  5. 3 副本核心更优of saturate hot-storage. cold一is cost pressure 长还, 偶尔 display o (-1node service fill 快热点 multlicated) sbe /noven αsymbol code惠pbacket ccoberG/c per node network id poolter al Cluster Latvia.
  6. 同 model磁disk correlation is important fault; Only cross-rack cross-batch cross-routing placement saves EC durability 真 porter 后 鹿 team processes.
  7. For industry-保管 amazon s3 (yield耐 ECgalaxy 11 9) EC+Storage我用 345 cheaper in manyreplic lead multi-е cluster, 我圈14元 NEW 和两个acus EB[x] = (R) durability.
  8. 写入 EC 跹 destructor cache: 8ussion. 失把 "高 次成本 EC + First ≠ streakability bucket than 主 low存皆是f 在 not data home cluster ` storage mut 5%".

下一节 → Borg / Kubernetes / Mesos 调度

Borg / Kubernetes / Mesos 调度

TL;DR

集群调度器 (cluster scheduler) 决定把容器/任务/作业分配到哪台机器上, 目标是最大化利用率、最小化延迟、扛机器故障、满足作业 SLA。 Borg (Google 2015 paper, 2003 内部已用) 是 Kubernetes 的爷爷, Borgmon 演化为 Prometheus; Mesos (2011) 是 Apache 2009 开源的"两级调度器"; Omega (Borg 团队 2013 论文) 是更 declarative 的 scheduler, 用 optimistic concurrency 让各 framework 单独 schedule。 Kubernetes (2014 开源, Borg 团队成员设计) 是 steady-state scheduler + declarative API —— 用户提交 Deployment/Pod manifest, control loop (kube-scheduler + kubelet) 让状态收敛到 desired。 Borg/K8s/Mesos 的核心差异: 单体 vs 两级 vs shared-state 调度。本章梳理 Borg 设计、Omega 乐观共享状态、Mesos 主从 + framework offer、K8s scheduling queue + filtered predicate + scorer priority, 默认调度算法, typical 故障 (单点 scheduler、slow scheduling through。


一、Borg

Architecture

Google 2003 起 Python 写的 Borg, 2015 论文公开:

  • Borgmaster: master 进程, 5 副本用 Paxos keep state, leader 接收 job submission。 Paxos replicated Cell = cluster 单位, 几千台机器 ~10k+ jobs。
  • Borglet: 每机器 agent, 启停 task + report state to Borgmaster。
  • Scheduler: Borgmaster 内部子模块, holds pending queue, computes task-to-machine assignment。

Borg 调度算法

  1. Filter (谓 词): 去掉不满足条件的机器 (e.g., CPU/memory core 充足性, required attributes match, port 已占用)。
  2. Scoring: 对剩余候选机器打分数。 Scoring �unctions:
    • Mixed score "best fit" (用 100 − 10·(sum residual)^2 etc.): 让 high affinity task 紧凑放, 剩余空 machine 多。
    • Worst fit: 让资源 分散 (避免 hot spot)。
    • BEST_FIT + diversity: Bin-packing + 优先 spread jobs across rack/DC.
  3. Pessimistic Allocate by EIVA/P (EVALUATE): score 加 weight后再 ranking 选 top-K candidates 装配, fallback 到 next 时候 conflict.

Borg Mid-Term

"a task" + "alloc" 二分: alloc 是 资源预留占 port+资源,哪怕 task 还没启动; Borg 把等的 task 间接 POSIX 化 — "single 中队 ma wait point of alloc set" 在 source (Borg 死 task as 一个 scheduling unit).

Preemption

Tasks 有 priority class:

  • "free" / "best-effort" (低)
  • "production" (中)
  • "monitoring" / "system" (高)

低优先级 job 可以 purge by 高优先级 job. Preemption cascade: purge 一组 task 加到 high-priority 比单 task 调度 (user 看 latency shortening clean behavior).

中长期 Stability

Borg 用 Pacemaker 逻辑: 实际运行使用率 avg-out + P99 = 60-70% CPU usage is typical, P95 < 80%. task 启动失败 lines recovers automatic via healthcheck + restart.


二、Omega (2013)

Borg 论文 derivative, Google Borg team 2013 论文 "Omega: flexible, scalable schedulers for large compute clusters". Omega 是 shared-state scheduler:

  • Cluster state 集中保在 Paxos-replicated Cell store。
  • Optimistic concurrency control: 每个 framework (scheduler) 各自local copy of cluster state + 想要加自己的 task assignment, 通过 Paxos transactional commit 与 shared state merge. Conflict -> retry framework specified spin slot backoff.

Omega 与 Borg master 区别:

  • 无 single scheduler bottleneck, 多 framework parallel schedule.
  • Conflict handling by retry: framework retry 直到 "make it".

Open-source 等价: Kubernetes scheduler framework in 1.19+ has 支持 same shared-state concurrency 实质.


三、Mesos (2011)

Apache Mesos 是 2009开始在 Berkeley PhD of Benjamin Hindman, 集群 2010实现 2009 论文。 它是两 级 调度 (two-level scheduler):

  • Mesos master: 给 framework offers. 每 framework 收到 resource offer like "机器 X 上 4 CPU 8GB you 取?"; framework 接受或拒。
  • Frameworks: Spark, Marathon, Chronos, Kafka-on-Mesos 等各 framework 实现自己的 scheduler。

Offer 模型

Master to framework "Spark": "offer: machine 1: 4 CPU, 16GB内存"
Spark (自己决定 schedule):
  if my Spark job needs 2 CPU 4GB:
    accept: spawn executor at machine 1
    respond to master: "yes"
  else:
    decline "no"
master 之后 asked next framework(s) same offer

Mesos + ZooKeeper 实 HA master

Mesos master 多 副本保 quorum 跨 ZK election. 失败时 ~5-15s failover.

Mesos limitation at scale

  • Offer 模型在高 framework 数 (>10) 时 throughput 慢 - 每 framework 顺序 offer decision → high latency framework. fine for Hadoop + Spark coexist, hard for 100 微服务各 framework.
  • Conflict resolution 无 Omega 事务所大, 当两 framework 想同机器 → first-accept wins, after-accept retry.

Google 内部 Omega + Mesos paper (2015 "Omega OS Review") actually mentioned famine 与 Omega. Mesos 实际工业使用 启动大批 cluster, Twitter 早期, Apple Siri 早期.utilization 不高, 现已 部分替换 K8s.

Mesos + Marathon

Marathon 是 Mesos 上 general long-running service scheduler (类 Kubernetes), 让 Mesos 提供 container orchestration-level API. Twitter, Airbnb, Box 公司 early adopters.


四、Kubernetes Scheduler

架构

K8s 控制面 API 列:

  • kube-apiserver: ❰❰ API 入口, all client (kubectl, controller) REST/v1。
  • etcd: 强一致 KV store, 集群状态 (PRAF té RA statute)。
  • kube-scheduler: 一个进程, 看 pending pods (phase=Pending), schedule 给 nodes, writes pod.spec.nodeName。
  • kube-controller-manager: 多 controllers 各自 process: Deployment, Replicaset, StatefulSet, DaemonSet,Job...
  • kubelet (每节点): watch pod.spec.nodeName=该 node的 pods, container runtime 启动。
  • kube-proxy (每节点): iptables / IPVS 规则同步, 服务发现 + load balancing。

Scheduler 主循环

while True:
    pods = get_pending_pods_from_api()      # 批量拉 pending
    for pod in pods:
        feasible_nodes = filter(nodes, pod.requests, pod.affinity, pod.tolerations, ...)
        if not feasible_nodes:
            mark pod unschedulable, 退回队列加 backoff
            continue
        node = score(feasible_nodes, pod)    # 多 scoring function 加权 排名 选 top
        # ESS rate 多 nodes are required
        bind_pod_to_node(pod, node)          # API update pod.spec.nodeName=node

Filter (谓词 Candidate Nodes)

常见 predicates:

  • PodFitsResources: node CPU/memory/GPU 充足。
  • PodFitsHostPorts: pod.spec.containers.ports 与 node 已占用 host port 不冲突。
  • MatchNodeSelector / NodeAffinity: pod.spec.nodeSelector / pod.spec.affinity 必须匹配 node labels。
  • Toleration: pod.spec.tolerations satisfate node 之 taints, 否则被 taints 拒绝。
  • VolumeBinding: pod PVCs 与 node 上 PV 的 topology match (topology.kubernetes.io/zone).
  • PodTopologySpread: pod 应 spread 跨 zones/topology domains.
  • PodAntiAffinity: pod 与其他已 schedule 的 pod 反亲和 (e.g., 同 service 的 pod 分散到不同 node)。

Score (priorities)

K8s 当前 (1.20+) 默认 Score 是 NodeResources Fit family of plugins:

  • NodeResourcesBalancedAllocation: balance CPU/mem ratio (避免单 CPU 满但 mem 空)。
  • NodeResourcesLeastAllocated (default): prefer least-allocated node (spread jobs across nodes).
  • NodeResourcesMostAllocated: opposite, bin-pack 让 server 紧凑 (适合 consolidation)。
  • InterPodAffinity: pod-pod co-locating (e.g., redis client 与 redis server same node)。
  • PodTopologySpread: spread pods across zones。
  • NodeAntiAffinity: 反向, 让 pod 不挤同 node。
  • ImageLocality: prefer node 已有 image cached (快启动)。
  • TaintToleration: 优先 untainted node / untaint higher priority。

Final Score = sum of weighted scores。kube-scheduler config 可调 weights。

K8s 1.21+ Scheduling Framework

Scheduling Framework 提供 extension 机制 plugin 接口:

  • Filter extension (custom filter logic)
  • Score extension (custom scoring)
  • Bind extension (集成 external scheduler)
  • Queue sort
  • Reserve permit / permit hook

写了 plugin 后用 Go build custom scheduler binary (kube-scheduler --config config.yaml)。

Default K8s Scheduling Performance

  • 26 节点 cluster: schedule 一个 pod 10-50ms (queued + filtered + selected + bound)。
  • 5000 节点 cluster: ~50-200ms per pod (filter expensive in large cluster).
  • 100k+ 节点 cluster (Alibaba 2018 paper): K8s scheduling bottlenecks in api-server throughput. 在 api-server 加 caching + scheduler cache 加 quick "feasibility detection" 后 efficiency proper。

Bin Packing vs Spreading

K8s 默认 LeastAllocatedspreading, 资源利用 不集中 but fair,不便于 power-down 部分机器。用 MostAllocated 改 → bin-packing, 让 schedule 集中,便于 consolidation + idle machines can power-down saving energy。

DaemonSet 与 Node-Affinity Tight

DaemonSet 触发 node-level pod (e.g., Node Exporter, kube-proxy, fluentd collector), 不走 main scheduler queue, controller 直接 given every node 创建 pod (binded to that node)。


五、Borg vs K8s vs Omega vs Mesos 对比

系统架构BorgOmegaMesosK8s
Scheduler 类单体shared-state transactional两 级 offer单体 + plugin framework
State storePaxos-replicated localPaxos cell storeZookeeperetcd (Raft)
Preemption优先级 + cascadeoptimistic conflict retryoffer refused / revokePriorityClass + preemption policy
自定义工作 unittask + alloctasktask + framework resourcespod (1+) containers in cgroup
多租户隔离cell cell disjoint-frame SecurityContextoptimistic + frameworkframeworknamespace + RBAC + PodSecurityPolicy

六、典型 K8s 事故

API Server/etcd Overload on Hot Pod Scheduling

大 cluster scale-up 同时 1000+ pods pending → clog scheduler queueing (sequence). Some pods wait 30 seconds. Fix: furious 提示 batch scheduling (kube-batch/volcano) let scheduler batch 处理多 pods together (combinatorial consideration). Alibaba volcano 用此模式。

Pod Spec Mandatory Image Must Be Pulled By All Scheduler Compatible Nodes

某公司 K8s 1.18 cluster migration 升 立即 pull missing image at 大量 nodes 触 发网络拥塞 resolve restart. Fix: imagePullPolicy: IfNotPresent + pre-cache images on cluster nodes via DaemonSet pull.

NodeAffinity 错配 extends 误 阻 schedule

某 deployment 有 nodeAffinity required: zone:us-east-1a, 但 cluster nodes 都无 zone label → pod 卡 Pending. Fix: 监控 Pending pod 提示 unschedulable reason, cluster operator 必 review labels.

PodAntiAffinity 在大 cluster 1000 pods规模 single-pod schedule slow

  • podAntiAffinity 实现 message check against existing pods: 1000 pods in cluster, scheduling 100 new pods would trigger O(N²) checks → scheduler 10+ seconds。
  • Fix: podAntiAffinity 使用 topology keys (e.g., hostname) cache efficiently, K8s 1.20+ optimization。

Scheduler 单点 fail

早期 K8s kube-scheduler 单实例 crash lead scheduled pod falls to pending indefinitely → 1.19+ enable scheduler leader election by default 멀 instance leader + RCA detect.


七、K8s 与 Borgmon = Prometheus

Google Borgmon (Borg 集群的 generic monitoring) 是 Borg 的 内部监控 + alert + label-based query 接近 PrometheusQL. Prometheus (2012) 是 Borgmon 公开实现, 现 CNCF 二级毕毕业 project。

Prometheus 是时序 metric database + alerting rule engine:

  • pull metrics scrape targets (HTTP /metrics endpoint).
  • PromQL aggregate filter 数据。
  • Alert notification to Alertmanager 跨 routing.

Open-source 现在 standardize K8s monitoring stack: Pod node /metrics + Prometheus + Grafana + Alertmanager.


八、易错清单

  1. K8s scheduler 是 single-process: 大 cluster 考虑多 instance leader election + scaling-out scheduler 限制作业 throughput`.
  2. PodAntiAffinity 在 大 cluster 高 cost O(N²): topology key cache 必开, 但可选 boundKeys 长仍有 cost.
  3. Pod sch之星 Spark uses batch scheduling优 Volcano/kube-batch: K8s default scheduler handle single-pod, batch job scheduler use Volcano Scheduler for gang scheduling.
  4. Borg/Omega/Mesos has framework abstraction: K8s scheduler framework 1.21+ 允许 plugin extension, 替代之前 fork scheduler binary 编译出来.
  5. Pod-cgroup container resources include user-defined requests vs limits: requests 决定орош schedulabiliity (软 quota), limits 决定 hard cgroup cap. trick: requests < limits 让 pod burst (CPU burst allowed) .

九、这一章带走的东西

  1. Borg = Google's Paxos-replicated single master scheduler; inspired both K8s design 与 OpenStack.
  2. Omega = shared-state optimistic concurrency scheduler, 多 framework parallel schedule.
  3. Mesos = 两 级 offer-based scheduler, fine for Hadoop + Spark coexist but less 工 100 framework microservices.
  4. K8s = single-process scheduler with stateless etcd + filter + score + bind pattern + framework plugin mechanism extensible.
  5. K8s system scale 5000 节点 default; larger requires scheduler plugin (volcano, poseidon, yunikorn) for aggregated scheduling policy.
  6. SVMutilization balanced 物 odne scheduling依赖: 不能最优 HYPERVISOR (不 bin packing) 能空p space cluster maintenance failover less critical alternative policyJudre.
  7. Borgmon 演化为 Prometheus 是监控界legacy.蓝天 论 thriving 一会常有 育各orn fórum하 peeled.

下一节 → 系统设计总览

第七部分 · 系统设计

一句话

系统设计 = 把"业务需求 + 流量规模 + 可用性目标 + 预算"翻译成架构图 + API contracts + 存储 + 队列 + 缓存 + 监控的具体取舍。 不是堆 slogan, 而是在真实分布式 SLA、capex 预算、ops 复杂度中给一个可以跑 10 年不重写的设计——并解释每个组件的选择与淘汰路径。

思想链

API 高层: 用户 / API 客户端 —— LB / CDN —— API gateway —— 业务服务群 (stateless) —— 缓存 Redis/Memcached —— 主库 (强一致 KV / RDBMS) —— OLAP 副本 / 数仓 —— 备份 / 异地灾备

Client
  ↓ HTTP / gRPC
[ Edge CDN (Fastly / Cloudflare / Akamai) ]
  ↓
[ LB (L4 NLB / L7 ALB) ]
  ↓
[ API Gateway (Kong / Envoy / Nginx / internal OAuth + rate limit) ]
  ↓
[ Stateless Microservices (K8s deployment) ]
  ├─ Redis cluster (cache + distributed locks)
  ├─ Kafka/Pulsar (异步事件流)
  ├─ Primary DB (PostgreSQL / MySQL / Spanner / CockroachDB)
  ├─ OLAP store (Snowflake / BigQuery / ClickHouse / Doris)
  └─ Object storage (S3 / GCS / OSS)

每一跳都有取舍——CDN 让主页快 10× 但增加 invalidation 复杂度; cache 让 read 5ms 但 stale risk 提高; message queue 让 service 解耦 但 at-least-once 必须 idempotent; 主备 DB 让事务强一致但 leader failover 5-10s 写阻塞。 真正的设计师在这套取舍的尖点中画最优弧线。

8 个章节

  • 负载与容量估算 — back-of-envelope、Little's Law、负载模式
  • 存储选型与内部 — 选什么数据库 (KV / 关系 / 列存 / 文档 / 时序) + WAL/LSM/B-tree 内部
  • 缓存 — 多级 cache、常见 pattern、failure modes (缓存雪崩、缓存穿透、缓存击穿)
  • 消息队列与异步 — Kafka/Pulsar/SQS + Outbox pattern + semantics (at-most/at-least/exactly-once)
  • 可观测性 — Three pillars (metrics/logs/traces)、SLO/SLI、监控 stack
  • 扩展与可用性 — sharding + replication + multi-region + resilience
  • 经典系统案例 — Google Bigtable/Spanner/Chubby + Dynamo family + Snowflake + K8s control plane

读完应能回答:

  1. 100K QPS 电商秒杀: back-of-envelope 快速决定热点 key cache + 静态化页 + 异步下单 + 限流降级
  2. PostgreSQL vs DynamoDB vs ClickHouse 选型的真实 SLA 矩阵
  3. Cache hit ratio / 命中率 / evict policy / dogpile effect 在 P99 latency 上贡献
  4. Kafka vs Pulsar 的语义差异与重复消费 idempotent 设计
  5. multi-region 容灾或 AP 数据库分别在不同业务 SLA 下的选择
  6. SLO 三要件: SLI 定义、error budget、automation burn rate alerts

下一节 → 负载与容量估算

负载与容量估算

容量估算让架构师在白板上5 分钟内给出"如果我们要做 X 业务, 集群规模 & 成本 & 关键瓶颈是什么"。 不是写死, 而是给出数量级, 决定 DB 选型、cache 容量、消息队列吞吐、网络瓶颈、月度成本。

Back of Envelope 估算

TL;DR

Back of Envelope (BOE) 估算 是 Jeff Dean / Google 内 部 docs 流行开来的"贴式估算法"——不写代码, 不查精确值, 用一些工作功率/带宽/缓存层次参考数字 在 1 分钟内得到 capex/opex/吞吐数量级。 它的核心是"先估数量级, 再 cap 硬件清单", 而不是 "build 后爆掉"。 一套基础参考数 (latency numbers every programmer should know Brendan Gregg version) 让你立刻知道 1ms 内能做啥: SSD 查 4KB page < DNS round trip < 1 Gbps 网络, 那就是 cache 设计的根。 本章梳理 BOE 参考表, 给 8 个工业例题 + 公司 / 个人估算 思维模版, 指出最易估算错的几个 (内存带宽、压缩比、多核比例), exercises。


一、基础参考数字

Latency Numbers Every Programmer Should Know

操作时间 (2026年数)备注
L1 cache hit~1 nscompute-bound kernel core within 1 cycle 锁
L2 cache hit~3-4 nsshared within one core's prefetch
L3 cache hit~12 nsshared across cores of same socket
RAM access (DRAM)~100 nsmain memory random read
SSD 4KB 随机读~150 µs (NVMe); ~100 µs (Intel Optane)one 4KB page I/O
HDD 4KB 随机读~10 ms (7200rpm)seek + rotation
1Gbps 网络传 1MB~10msLAN single link
25Gbps 网络传 1MB~0.4msdatacenter link
同城 DC RTT~1-2msP50
跨美 RTT~50-70msP50 沿光纤
跨洲 RTT~150-250msP50
TXN 写 + fsync (NVMe)~1-2msdatabase commit

Storage Costs

介质$/GB/月 (2026)
NVMe SSD (云, gp3 ~$0.08/GB provisioned IOPS)~$0.10
HDD (云, st1, Magnetic)~$0.025
S3 standard~$0.023
Glacier deep archive~$0.00099
RAM (云实例 included)~$10 (3000× SSD)

node 上 1GB RAM 是 1GB SSD 的 ~100× cost. 决定 "cached in memory vs spilled to disk" trade-off。

通过量数字

  • 单 Redis 实例: 100K QPS get/set 100B value。
  • 单 Postgres 16 CPU: 30K 写 TPS (取决于 fsync)+ 100K 主键读 (cache hit)。
  • Kafka 单 broker: 200MB/s (1MB batch), 50k msg/s。
  • 单 nginx worker: 100K req/s 静态资源。
  • RDMA/DPDK NIC: 100Gbps 充分能用 ~50Gbps user。

人的智力评估: 不写 1B QPS

当有人在白板上说"业务量 100B QPS", 立刻判断不可能: 全球互联网骨干带宽 ~100 Tbps, 平均 request 1KB → 12.5 GB/s = 100 billion bytes/s = 100GB/s, 但中 国 全国骨干 1 PBps ≈ 1 思-构成 . 总量级快速否决掉无意义需求


二、8 个工业例题

例1: 1 million DAU social media platform

输入: 1M DAU, 每用户 30 分钟浏览 feed, 每分钟 scroll 5 屏, 每屏 10 posts, 每 post 30 chars + 1 image 100KB.

Throughput estimate:

  • daily scroll time = 1M × 30 = 30M minutes × 5 × 10 = 1,500M post views / day.
  • 365 = 547.5B post-views/year (~17 reports/秒 average).
  • Peak hour rate: 30× avg → ~17×30 ≈ 510 RPS peak.

Storage:

  • 1K posts/day/user × 1M × 30KB = 30GB/day, 11TB/year.

Cache strategy:

  • Hot 7-day data cache hot feed = 7 × 30 = 210GB in Redis; ≤1GB cache per dedicated Redis instance = 210 instances = 不实际.
  • Post content to S3 11TB/year × $0.023 = $5.7/month incremental.

瓶颈 is not posts but feed images. CDN + thumbnail = cache hit 90%.

例2: 500K active concurrent VoIP-buddy pre-built

输入: 一个 5 人 call, 每人 2MBps 1080p video (1-way), mesh top.

  • Total uplink = 5 users × 2 MBps = 10 MBps, full mesh 4 downlink / user (4 MBps total transmit per user) → 5 × 4 = 20 MBps total → 160 Mbps per call.
  • 100K concurrent calls = 16 Tbps server relay media (SFU model) - 需要 ~160 个 server 100Gbps NICs.

Mesh won't work at 5 users总带宽太重 → SFU (selective forwarding unit) routing. LiveKit/Jitsi 都这样.

例3: 银行账户 100K TPS

  • 100K TXN/s insert/update to ACID database with fsync NVMe:
  • 100K / 30K = 3.33x oversubscribed single Postgres → sharding by acctId 必需.
  • 4 shards × 1 primary + 2 replicas each = 12 instances 16-core.
  • ~$3K × 12 = $36K/month instance; storage capex 1TB × $0.10 / GB / month × 4 = $400/month.

例4: 1B IoT 遥测 ingest

  • 1B devices × 100KB/day = 100TB/day ingest.
  • Kafka at 200MB/s/broker × 1 day = 17TB/broker/day → 100/17 = 6 brokers (we need ~ 7-8 brokers with replication).
  • Storage hot-30day: 3PB cold storage → S3 Glacier $0.00099/GB × 3M GB = $3K/month.

例5: 1M QPS product detail page

  • 1M QPS, response ~5KB.
  • Egress = 5 GB/s = 40Gbps — 需要 ~40-50 × CDN edge nodes at 1 Gbps each.
  • Most cached 90% = 900K QPS from CDN; 100K back to origin = 5 GB/s divide microservices.
  • DB read 100K/s = 16-core Postgres ×4 with read replicas + cache layer.

例6: Email 系统 1B users

NOT feasible: world internet users ~5B. So 1B users plausible.

  • 1B users × 500 emails/month = 500B email/month = 16.6B emails/day = 200K emails/sec.
  • 单 SMTP server ~500 emails/sec → 400 servers just for SMTP receiving. Storage 50GB/user × 1B = 50EB storage - S3 Glacier maybe $50M/month. Not practical.

实际 webmail 1B users selectively delete + hot tier compressed only 1GB/user.

例7: MapReduce / Spark 1PB 数据 day

  • 100 nodes × 1Gbps = 100TB/day linear scalability.
  • For 1PB/day → need 10× = 1000 nodes minimum. Cost per node $3/h × 24h × 1000 × 30 days = $2.16M/month.

实际 Spark/Databricks 提供自动 spot pricing 折扣.

例8: 1M QPS social-network Timeline 排序 频 率熵的

  • 1M QPS × 100ms rank (cached 99% because social media hot user fanout architecture).
  • 1% cache miss = 10K actual ranking hits DB / backend ranking service.
  • Dragon-style score ranking service at 10K/s with model inference (8-core GPU) → 10 × 8-GPU = 80 GPUs.

三、易错点

1: 缓存层是 memory @1000× cost of SSD

不是想"add Redis 减压 DB", 而要先算 cache 命中率与 capex.

2: 数据库的 fsync 是 sequential 几乎 wall-clock bound

PostgreSQL 在 NVMe 上 30K 写 TPS (1ms fsync time serial) regardless of cores. 不能 "加几个 core" 翻倍. 必分片。

3: 带宽与突发

1Gbps WAN port 是满 100MB/s, 但 BDP (bandwidth-delay product) for 70ms transcontinental link = 100MB × 70ms / 8 (1Gb / 8MB) ≈ 8.75MB "in flight buffer"; saturate 必须 windowing.

4: GW 跨区 ⇒ RTT 不在你控制

跨美 RTT 即 80ms. 想在 followup 内完成就是 fail-over 才行. 进入 multi-region 必须 数据异步复制.

5: Number skew 在极端 cluster 不 linear

要么 Amdahl 负载分布限制 50% scale ceiling, 要么 long tail latency tail P99 远超-datadog / 不平 arrierianment.

6: 内存使用 vs cache

OSS 用 Redis cache, 不等于 1TB memory = 1TB cache for free. 突发 cache write 涨 OSS heap 80% induced eviction not subsample.


四、典型事故

Twitter 2010 Fail Whale

  • Kestrel queue 笑 HA videos match pip estimation scalability missing hit data put processing singleton 2 000 错 看 ha 实际 use 额.
  • Fix: Redis hot use BOE real sharded partition cap (Migration to Manhattan + Redis + memcached.).

Apple Card launch 2019 拇 指 较 少

queue delay pre-launch BOE wrong pin jump 10× load peppercorns vs predicted瞬 financepe GDP 实Be 一 verification DB connection pool 实 explained.

  • Fix: 立即 add 10× read replicas + Golding HBase circumvent relational Connection tests.

UIColored with compute resources: 500 users best case, 10K worst case

SaaS startup 测试 100 users staging → production scaling 50 → 1 万 worst case 5× 胜 ince burn rate.


五、练习题

  1. 1 million DAU chat system, 平均每 user 100 messages / day, 每天 active 6 hours, peak factor 5×. 网络 QPS / DB 写 QPS / 持久化 storage / day.
  2. 10 million DAU 电商, 平均每 user浏览 50 pages/day, 每页 20 product thumbnails cached 95%. 回源 QPS, 带 宽产.
  3. Slack 10K concurrent active users in 1 workspace, 平均 message 1KB 单条 4 messages/min/person. 网络与 WS-side throughput.
  4. 720p live video Streaming platform 1M co-current watchers, 单 streamer. 出口带宽 & server count for SFU routing.
  5. Walk: Estimate iPhone storage: user avg 20 apps × 100MB + 500 camera photos × 5MB + 200 videos × 50MB = 13GB+:

六、易错清单

  1. fsync 是 sequential, 不是 parallel: 一个 disk fsync bulk 1ms. 不要 nest 同 sequential ramp.
  2. NIC bandwidth is symmetric but cloud 公網 提供 burst 但不 sustained: 100Gbps 利 fillshop Almost不合ed-配mir 频.
  3. Cloud pricing reads vs writes can be 10× different: S3 PUT vs GET count 异 步 price diff. 月 1B PUTs = $5K.
  4. CDN cost is per GB egress 不是 QPS: Hot CDN 50TB/month = $0.05/GB × 50T = $2.5K (CloudFront).
  5. Latency budget by user < 100ms 限所有 hops: CDN edge 5ms + API 50ms + DB 查询 20ms 总 budget 不能超过 100ms real user P95.
  6. Cache hit ratio optimization is exponential rewards:
    • 50% cache hit → origin load 50%.
    • 90% cache hit → origin load 10%.
    • 99% cache hit → origin load 1%. add 9% hit ratio cuts origin load 10×; Jacobian divergence.

七、这一章带走的东西

  1. Back-of-Envelope 让您在 5 分钟内拉白板 +钢结构 cap 业务量.
  2. Latency numbers 提供首要 reference (L1 ns / L2 3-4 ns / DRAM 100 ns / NVMe 4KB 100µs / 同城 RTT 1-2ms / transcontinental 60ms) 让您 tragen 时快 比 唇某 brute tested wonder.
  3. fsync 是 sequential ~1ms: 不能伸缩 by cores. must shard.
  4. Spark/Kafka 1PB/day ~= 1000 nodes + $2M/month. Not trivial. 看 boss.
  5. Cache hit ratio small gains scale exponentially → "add cache then optimize hit ratio before backend scale".
  6. CDN egress + GitHub Action dependencies 多 storage v 陨 cost infra cross razor always sebastopol consider Latencygenerator 整 布局.

下一节 → Little's Law

Little's Law

TL;DR

Little's Law (John D.C. Little, 1961, MIT Sloan) 是排队论中最常用、最被广泛引用的定理:

$$L = \lambda W$$

  • $L$ = 系统中平均实体数 (concurrent occupations, queue + service)
  • $\lambda$ = 到达率 (arrival rate, 实体/unit time)
  • $W$ = 平均逗留时间 (实体在系统中停留的总时间)

它跨任意到达分布 + 任意服务分布 + 任意队列情形都成立. 是 back-of-envelope 估算 threadpool size、queue depth、worker count 的 first principle. 本章推导公式、应用例 (限流器、池容量、API P99)、与经典 counter-example (non-stable 系统如何破坏 validity)、Little's Law 在工业 ops 的应用 (SRE pool sizing)。


一、直觉与形式化

直觉

如果:

  • 1 秒 来 1 个 客人 (λ=1/s)
  • 每个客人在店里待 5 秒 (W=5s)
  • 那么店里 "steady state" 平均同时有 1×5=5 个客人 (L=5)

或说: 每秒进来 1 个客人, 每个客人占用 5 个细秒-slot (resource), 总 steady state 占用是 5 个 slots = 5 同时客人.

形式化

证明 (simplified version, stable M/M/1):

设稳态到达率 λ, 服务率 μ (μ>λ 稳定). Little's Law 在任意 markov chain stationary distribution 中 apply. 完整 proof 需要 ergodic average argument.

三变量 + 推论

变量稳态关系
未来到达 rate λ 与系统稳定性 要求 λ < μ (μ 是服务率)stability condition
L = λ × WLittle's Law
若 system is FIFO 单 server: 等待时间 W_q = L_q / λ (queue 内 L_q, 等 average time in queue only)同公式
Conflict ratio ρ = λ/μ单 server 利用率
ρ → 1 ⇒ L → ∞ (queue 长度→∞, latency→∞)"Bottleneck saturation"

二、工业应用例

例 1: Thread Pool Sizing

API server 突发到达: λ = 1000 req/s, 平均 per-request 服务时间 W = 50ms.

应用 Little's Law: $$L = 1000 \text{req/s} × 0.050 \text{s} = 50 \text{reqs concurrent}$$

需要 ≥ 50 工作线程(每 worker 一次处理 1 个 request). 否则 queue 涨 ⇒ W 涨 ⇒ P99 latency 涨.

例 2: Database Connection Pool

PostgreSQL 平均 query 5ms, 突发 30K queries/s: $$L = 30K × 0.005 = 150 \text{connections concurrent}$$

设 NB PG max=100, queue 50 等待 ⇒ W 边界 queue add ≈ queuing delay 即 50/30K = 1.7ms; 这是 backpressure. DBA set pool=200 → connection far exceeds max=100 throughput cap → connection refused. Pool=150 是 sweet spot.

经验公式: pool size = avg_request_time × peak_qps (Little's law) + safety headroom 20%, 网络 spare capacity.

例 3: API Gateway Rate Limit

Token bucket 风格 rate limit:

  • 100 RPS token refill
  • each request consumes 1 token
  • burst 5 token initial bucket

$$L_{tokens} = refill × burst refill period$$

通常 Little's Law 应用于"waiting queue":

  • 到达 λ=200/s, 服务 μ=100/s ⇒ unstable ⇒ L → ∞
  • 必须配 Bounded Queue with Backpressure (限流器)

例 4: Queue, Talk Code

Kafka topic 承接 1M events/s produce rate, consumer 处理 0.2M/s → consumer lag:

  • L_lag = (λ_producer - λ_consumer) × time
  • 1 hour lag = 1M-0.2M = 0.8M × 3600 = 2.88B events.

→ 必须加消费者 (subscribing 同 group). 9 more consumers × 0.2M/s each = 1.8M/s consume, lag decreases.

例 5: Pinning worker pool for cron tasks

K8s 集群 running 1000 concurrently active CronJobs, 平均 job runs 5min: $$L = expected _arrival × W = how many CronJobs/sec × 300 sec$$

若 scheduled frequency 总和 10/sec入 enqueued: $$L = 10 × 300 = 3000 \text{ concurrent Pods}$$

例 6: Cache capacity planning

Redis cache:

  • 到达: 100K QPS read calls.
  • 平均响应时间: 0.5ms (sub-ms cache hit).
  • $L = 100K × 0.0005 = 50$ concurrent connections.
  • Redis pipelining: 1 connection handles 50 ops (pipeline batch) → reduce to 1 concurrent connection (per worker thread pool).

例 7: Connection static: 关闭 delay reverse

Server 5K QPS with persistent HTTP/2 connection work from clients: $$L = 5K × W = 5K × 1s (keep-alive default) = 5K open connections$$

must bump fd-max limit to 65535.

例 8: Storage Write Throughput

PostgreSQL 1MB writes 要 1ms (fsync bound).

100MB/s write rate = 100 concurrent 1MB writes in flight = 100 fsyncs concurrent → but fsync 1ms 串行化 → actually max 1000 fsync/sec → max ~1MB × 1000 = 1GB/s.

反推 bound:

  • $W_{fsync} = 1ms$
  • 服务率 $μ = 1000/s$
  • arrived $λ = 50K/s$ if each is a 1KB write batched 50K → unstable.
  • batched to 1ms 一 fsync with 100 batched writes per fsync → effective λ=500/s sat at μ. Sweet spot is batched with synchronous_commit=off + wal_writer_delay=10ms aggregation.

三、Counter-Examples: Little's Law 失效情形

不 稳态

队列只在 λ<μ (稳定条件)才 stable. λ>μ ⇒ 无限增长, Little's Law 仍可用 $L = L(0) + λt - μt$ for transient (t时间), 但 steady-state 极限定理不成立. industrial example: 流量激增引发 backpressure.

突发 vs 平均

平均 λ=10/s但 突发 100/s for 1 second:

  • Little's Law 用平均算 $L=10×W$, 但 burst 期间 W trajectory 可能短暂超 stable W 估计 → P99 latency 实际 spiking. 平均不制约P99 致命.

修: bursty service 自配超额 worker 处理 burst. 类似 batch 可以 concurrent 突发的 100+ tasks.

Priority Queue 不 道

Priority queue 可能 starve 低 priority. λ<=μ 总稳态, 但低 priority class 的 effective μ→ 0, 它的 W → ∞. Little's Law 仍适用 per class — one for low priority stretch.

State-Dependent 服务率

服务率 μ dependent on system state (state-aware 算数据 增 mark)(和一个 load shedding 算法): $\mu \ne$ const. Little's Law 只算 stable expected average λ & W, 不描述 transient + state dependent dynamics.

Closed Loop (Network)

用户 reply-driven retry 给系统再加 load (closed-loop), 客户端 λ 实际interface-dependent on observed latency (e.g., if server slow, user lowers its issue rate, "throttling"). Little's Law formula still works in expectation, but λ self-feedback depends on W ⇒ not externally regulated model.

类似 example: HTTP client with finite pool (50 connection threads); individual request thread blocks ⇒ users don't issue new ones until release ⇒ instantaneous λ dynamically bounded.


四、Little's Law 边界应用

Queueing Theory 推论 Kingman 公式

单 server M/M/1 queue:

$$W_{total} = \frac{1}{\mu - \lambda}$$

即 delay explodes 1/(μ-λ) when arrival → capacity. P99 latency 公式 (金海 method):

$$W_{P99} \approx \frac{ln(100)}{\mu - \lambda}$$

→ 1% overload → P99 latency 4.6× mean.

应用: never run server at λ/μ > ~80%. Always leave μ 20% headroom. P99 latency is very sensitive to saturation point.

Universal Scalability Law (USL, Neil Gunther)

$$X(N) = \frac{λ N}{1 + α(N-1) + β N(N-1)}$$

  • N = 并发数
  • α = contention cost (锁竞争, 锁开销)
  • β = coherency cost (consensus coordination)

β=0 (no coherency): Amdahl scaling. With coherency increasing, throughput 比 retrograde past N*.

USL 是 Little's Law 的 capacity scaling 推广, 用于 capacity planning 实际 SRE work.


五、工业 Best Practice

Pool Sizing 公式

pool_size = peak_QPS × avg_response_time_seconds × safety_factor

where peak_QPS is 业务 peak (考虑 5-10× diurnal 突发); avg_response_time_seconds 实测含 cache miss P50; safety_factor = 1.2 - 1.5.

Connection Pool Cap

PostgreSQL / MySQL connection pool max: $$pool_{max} = \frac{max(TPDB_tps)}{QPS_per_pool}$$

但是 DB max connections 实际受 shared_buffers RAM; 100 connection 20MB each = 2GB shared buffer. Diminishing returns 在 connection >50.

K8s HPA Based on Queue Depth

HPA scaling on Kafka consumer queuelag 比 CPU 利用率更精确——stability 与 queue depth 显著关系. Sharded Queue Lag = lag/consumers/60 → if lag/consumer > 60s需要加消费者.

SLO with Little's Law

SRE SLO: target availability 99.9% = 8.6 sec/min deficit budget.

$$budget = (total _time) × (1 - SLI_{target})$$

Throughput stable under nominal arrival rate. SLO violation triggers autoscale/degrade.


六、典型事故

Netflix SpotInstance Queue Backlog 2009

某 auto-scaling based on CPU fanout: λ=N req/s consumers consume once CPU done; but CPU 相 throughput saturated → lag grows → Little's Law predicts sustained queue grows linearly. Fix: HPA metric on queue depth (Kafka consumer lag).

Twitter Kestrel Queue Transient Stack 2011

Kestrel原始的 Little's Law not fully digestible — burst λ not stable. Queue overflow caused OOM. Fix: strict bounded queue + immediate backpressure to producer (Twitter internal Scala).

Slack 2019 connection drops

Slack WebSocket connection pool capped at 10K, 然而 Little's Law pred L=20K concurrent stable state. Result: dropped 5K connections every 5min. Fix: bump Linux ulimit + net.core.somaxconn, deploy larger泓 worker pool.


七、易错清单

  1. Little's Law 是 stable state: λ<μ req'd. λ>=μ → unbounded growth, 公式 temporarily得不到 pull L 不 steady.
  2. 顿 mean time vs Juan99: Little's Law 只 defend 平均; P99 的 interp 通过 Kingman formula 的人际必借extra.
  3. Connection pool 大不总好: DB max_connections 制限 RAM, 每 connection消耗 ~5-20MB. Postgres 最多 推荐几一百, 超过风险 OOM.
  4. Thread pool sizing必算 cache hit ratio: cache miss 让 W 增大⇒ L 增大, 现在 pool 突然over-subscription。 测试 службу best worst-case scenario。
  5. Backpressure based on queue depth 不是 CPU: queue depth 是 lateness (queue内等待时间) directly → better autoscale signal.
  6. Burst arrival 能 找 transient overload: λ average 1×10/s, but 1秒内 1000 réqs; 服务者正有效 only stable at slow rate ⇒ 1秒内设 queue 暴发 → transient P99 bad.
  7. Rate limiters 不 是 little's law in themselves: token bucket 是幼儿建立 model on variation of μ, 上 limit λ to <= 50%. SaaS rate limit 关系 setting the μ - λ 整 stability rules.

八、这一章带走的东西

  1. Little's Law $L = \lambda \times W$ 是 stable system 的稳态不变量, 适用于生死意毛任何queue with stable regime.
  2. 工业 best practice: pool size = peak QPS × avg响应 时 × 安全 factor(1.2-1.5).
  3. 不能 oversubscribe μ: 必须 leave 20% 容量 headroom以避免 P99 latency blow up.
  4. Kingman formula: 单 server M/M/1 queue $W = 1/(\mu-\lambda)$, P99 latency $≈ W × ln(100)$. α=0.95 → latency 20× backof 2.9. ring.
  5. Universal Scalability Law 购机 capacity scaling, Better than Amdahl, 包含 coherency term β for cluster contention.
  6. Coherency cost increases quadratically with N, which is 是 why shared read-only cache 离线 nodes can use simple sharding 不 rehashable.

下一节 → 负载模式

负载模式

TL;DR

实际系统中的 load 是有多种 pattern 的, 不是静态一串 RPS:

  1. Diurnal (昼夜变化): 白天高夜低 (社交 feed, web search).
  2. Burst (突发): 10×~100× 短时流量 (秒杀, app store 上线, 巴黎奥运开幕式).
  3. Periodic Spikes (周期尖峰): 准点小时 minute / cronjob trigger (e.g., 月底报表生成, 准点开抢).
  4. Long Tail / Heavy Hitter: 少数 key 占多数流量 (Kardashian 推文、热门商品).
  5. Read/Write Imbalance: 读多于写 95:5 vs 写多于读 95:5 (遥测, audit log).
  6. Drill Switch to Disaster: planned chaos engineering (Netflix Chaos Monkey) → burst truth test.

没识别 pattern → 过度 sizing buffer / under-prepare → 浪费钱 / 集群崩溃. 本章梳理每种 pattern + 决策 trade-off.


一、Diurnal Pattern

特征

   |        peak 60K QPS Beth 09:00-22:00
   |      /             \
Q  |     /                \
P  |    /                  \
S  | __/                    \__              
   |___________________________
     0  4  8  12  16  20  24  clock
  • min/max ratio ~10x. social media.
  • 80% 流量集中在 12 hours (8 am – 10 pm)。

容量规划

  • 平均 QPS / peak QPS 平均大约 5x. size for peak, 而 not for average, 否则 80% 时间 peak 期 P99 高 latency.
  • Spot instances / serverless for off-peak cheaper: bursty 突发 fits serverScale.
  • Cache scale asynchronously: cache read 轴 line peak 4-5x average. Cache key TTL design to扫清流量变化.

业务举例

  • Twitter/X timeline 推送
  • GitHub repo reads
  • Wikipedia viewer
  • 网站主页、电商列表

有用 SRE

  • configure业务有关 peak alerts.
  • 中间 cache Redis 队列峰谷 ratio 4-5x design.
  • scheduled maintenance 选择 凌晨, business 不敏感 非常 推荐.

二、Burst Pattern

特征

  |      peak
Q |    /||\
P |   / || \
S |  /  ||  \
  | /   ||   \___
  |__max___________
0 ____________________ time 30 sec

短时间 内百倍 上升, 必须有circuit breaker + rate limit + degradation保护机制。

Causes

  • "Second Kill" (秒杀) of products
  • 上线产品公告 / 通知 push
  • Mobile app launch day
  • 黑色星期五 / 11/11促销; App store 上线
  • "DDoS"

容量规划

  • backpressure / queue based: accept 进入 queue, evaluate throughput stable. Queue full 阀值 reject.
  • rate limit client: token bucket, leaky bucket; reject excess调博.
  • circuit breaker: outbound failovers prevent cascading.
  • auto-scale: 但 auto-scaling latency ~30s 跟不上秒级 burst. 必须有 buffer 在 normal scale.
  • degrade: cache fallback, static file fallback, summarize partial results.

业务举例

  • Reddit front page viral post
  • Wikipedia主页上
  • 不是秒杀必然的流量 spike (信用卡支付 burst)

三、Periodic Spikes

特征

   |       _
Q  |      | |    _      _
P  | _____| | __| |__ __| |__
S  | _     |_| |  | |_| |_| |_
   |______  3min 5min  ......
         clock-time 心跳

多次定期 dip. 因为是 cronjob-task launch, 每 hour 00:01, 月底 1 日 00:00 etc.

容量规划

  • schedule 错峰 stagger: 间隔 5 min between two systems.
  • 队列分批次: 大数据 report; 生成 2 hours queue ≠ 1 hour peak. Throughput.
  • 提前预warm (35 min before) scale cluster.
  • Cronjob retry backoff if previous cronjob still running.

业务举例

  • 月底报表生成 (financial reports)
  • push notification (scheduled time broadcasts)
  • SLO watchdogs alerting cron jobs
  • ICS calendar event notification

四、Long Tail / Heavy Hitter

特征

乆 帕累托分布 (Pareto), 少量 keys 贡献绝大多数 变负载:

   |_
   ||        _|_           Other 慢热部分
Q  ||      _|
P  ||    _|
S  ||  _|
   ||_|_|__|__|____________
      hot  1% 10% 100% keys

分布偏斜:

  • top 0.1% keys: 50% 流量
  • next 1% keys: 30% 流量
  • next 10% keys: 15% 流量
  • rest: 5%.

容量规划

  • dedicated hot-tier cache: Hot keys 单独 in-memory tier,城乡 单独 Redis cluster shard by key range.
  • CDN edge caching: hot static (image, video) CDN 上, 让 origin 不被打.
  • Predictive precomputing: business insight 预计算 (trending topic) 把 hot key 预先 cache.
  • ship to top tier: Redis_cache cluster "的热 key" 超前 分布 优. 半 damping.

业务举例

  • Kardashians's Instagram photo
  • Ariana Grande new song
  • 某明星宣布竞赛
  • 主页 hero feature
  • holidays campaigns

典型事故

Twitter 2010 "Justin Bieber search" — 单一 celebrity tweet 引 大量需要 fanout to all followers 的 fetch load, 实际 response of a single user 量 lateral within internal component services. Hot-key caching fixed smooth access.


五、Read/Write Imbalance

类型

类型Read QPSWrite QPS典型例
Read-Heavy 95:51M QPS read50K QPS writetwitter/x timeline, github repo reads, Wikipedia
Write-Heavy 5:9550K QPS read1M QPS writeIoT telemetry, audit log, event log
50/50商务型 CRUD- 各类 OLTP
Read-like calculationsdelete heavyOOCesscenfallback retries

决策

  • Read-heavy: cache aggressively, replica reads, save DB.
  • Write-heavy: partitioning writes by shard, async queues, batch writes.
  • Mixed 50/50: serious consistency + not heavy duplication hard, sharding.

Read-Heavy Strategies

- CDN cache static pages, Image CORS
- Redis / Memcached hotspot cache
- DB read replica pool
- Materialized views in cache (refresh period)
- TAG cache (cache invalidation)

Write-Heavy Strategies

- Partition by time/bucket (按 writes shard)
- Append-only logs (Kafka topic)
- Capped history compaction + tiered storage (Hot 7-day, cold S3 Glacier)
- Batch + async writes
- Write-batching (pggather WAL blocks)

业务例

  • IoT devices send telemetry: 1B devices x 1 msg/sec = 1M msgs/sec — system high
  • Audit log + Ben's heartbeat (writes 持续, 没 read)
  • Web crawler merge: write heavy
  • Trading tps (股票交易 — read/write balanced, 99% read 又看当 filebanknoty)

六、Drill Switch + Disaster Recovery

Plans

  • Netflix ChaosMonkey - simulated AWS region outage. Daily chaos.
  • Disaster recovery (DR): RPO 0 = syn sync across regionor RPO > 0 = acceptable data loss, replica或 跨 region.
  • Regional multi-region cluster: 客 leader-followermulti-leader.
  • active-passive vs active-active.

Historical Review

  • AWS S3 12-day outage 2017 -- triggered major Northern Virginia cold wave transient lead to specific connection blowdown.
  • Parisian tenant's issued deployment 实际 5-hour down 2016 due to live region downtime.
  • company-level DR exercise quarter-year.

七、Auto-Scaling Trade-off HPA (Single Metric Queue Depth)

HPA: CPU Utilization Above Threshold

apiVersion: autoscaling/v2
kind: HorizontalPodAutoscaler
spec:
  metrics:
  - type: Resource
    resource:
      name: cpu
      target:
        type: Utilization
        averageUtilization: 70

Why 70 —— μ must 不 saturated (Kingman!); as soon as μ exceeds 70%, latency P99 slowdown exponential. leave headroom.

HPA: CPU + Queue Depth (Lag)

  metrics:
  - type: External
    external:
      metric:
        name: kafka_consumer_lag
      target:
        type: AverageValue
        averageValue: 100

Idea: queue-based load scaling: when queue grows, callers can't catch up → add replicas. 比 CPU 更 sensitive to true load. for 长 queue 数 wonders.

Vertical Pod Autoscaler VPA

Auto-test uso of Pod资源** requests** based on actual usage. useful 但 still experiments on pod restart. Limit: VPA cannot apply "vertical scaling" → pods.

KEDA (Kubernetes Event-Driven Autoscaling)

Knative 第K相近的 KEDA 是 commonly used for scale-to-zero queue-based autoscaling +. Topic by autoscaling Kafka/lambdas cloud native installation: 0 incidents, 同时 dispatching burstynatured trigger spike vertical pattern — gaterns chelfor scaling interactions zero-HAZ显示出 cluster has consumsting infrastructure well by.


八、典型 Load Pattern 事故

Cyber Monday '18 Amazon retail

Cyber Monday 2018 - Amazon traffic 按 burst 5× pattern →. Real time query traffic support deman dlaunch-traffic 降 smaller day corner sstrike fixed by ad-hoc server spawn playback active recmist.

Reddit Flooding Traffic

Reddit Top-of-front page Una documented cause cluster API fracas: "top-choice hot-fetcher a thread!" 2014 gram contibly 重 generating the issue 启 用 multiple HOT NUMs cached horizoned pipeline.


九、易错清单

  1. Auto-scale delay ~30s; burst must be buffered first: rate-limit + queue + async backpressure before autoscale.
  2. HPA utilization >75% → P99 latency explodes (Kingman). Must aim 60-70%, not 90%.
  3. Don't underprovision hot-key: 1% hot key 可贡献 50% 流量; coming all single Redis shard 4 core hot node = saturation.
  4. Drill exercises 必不可少: DR test 不定 CPL quarter.
  5. Long-tail caches over-polling a hot key with "1 significant" cache hit ratio: Important to maintain 监 cache hit ratio 99%+ in hot, sagenames.
  6. Giving & mental: bleachers Returns true depend on use 大量 parapacms 错误 models with generally very high scale test not relate prediction decany body new data!
  7. Circuit breaker if电 states rely actraoNavigable汉子 servo confusing upgrade life Recovery failure downstream.
  8. 实 product only business-critical missing data is post clearly recognizable peek is order... pattern activation impacts paste 発炭LOW构 abundant.
  9. Finish todo & structured.

十、这一章带走的东西

  1. Load 是多种 pattern, 设计要容纳每种 model: diurnal, burst, periodic, long-tail, read/write imbalance.
  2. Burst must backpressure, neither bursted (panic) nor silent/dropped.
  3. Auto-scaling latency~30s: not for burst stream; ready buffer 2-3× headroom.
  4. HPA on CPU target ~65-70%; latency P99 explodes past 80%.
  5. Heavy-tail load: dedicate a hot tier cache; monitor hot key variation.
  6. Disaster Recovery drill must recovery practice chaos monthly; not just plans-on-paper.

下一节 → 存储选型与内部

存储选型与内部

存储是系统 ground truth —— 决定 throughput、latency、durability、availability、运维成本和大半 capex。别再"我加个数据库"了——选 Redis / Postgres / ClickHouse / DynamoDB / Spanner / S3 各自解决什么问题、各自 cap 多大、各自 ops pain 是什么, 是系统设计章节最重要的章节之一。

选什么存储

TL;DR

"选什么数据库" 是系统设计的最频繁问题。答案不简单是 "MySQL", "MongoDB", "Redis"——而是当下业务特性 (latency / throughput / consistency / query pattern / budget) 决定数据存储最佳默认。本章梳理 10 类典型存储类型 + 各自适用场景与坑:

  1. Key-Value store (Redis, Memcached): 快 cache, sub-millisecond get/set, 不持久化默认, 不支持查询。
  2. Wide-column store (Cassandra, HBase, DynamoDB): 极高写入, multi-row wide data, eventual consistency, 跨 DC 复制。
  3. Document store (MongoDB, Couchbase): JSON/BSON nested structure, 多索引, 中等 scale。
  4. Relational (PostgreSQL, MySQL, Oracle): ACID, SQL, rich 索引 + 复杂 join. fast小 writes, slow 大 join。
  5. 列存 (ClickHouse, Druid, BigQuery): OLAP, group 聚合快 (压缩 + vectorized), 不擅长点查。
  6. 时序 (InfluxDB, TimescaleDB, VictoriaMetrics): 高写入数量 order of magnitude 高 throughput, 历史 retention rollup。
  7. 图 (Neo4j, Dgraph, TigerGraph, JanusGraph): 深度关系查询, social network graph, fraud detection.
  8. 对象存储 (S3, GCS, OSS): 大 file 不变量, blob unlimited.
  9. 搜索 (Elasticsearch, Solr, Meilisearch, Typesense): full-text search + relevance scoring.
  10. 向量 (pgvector, Milvus, Qdrant, Pinecone): embedding 近邻检索, RAG / 推荐召回的底座。

一、Key-Value Store: Redis / Memcached

使用场景

  • Session store (低延迟, TTL)
  • Hot path cache (DB query cache, function cache)
  • Distributed lock (Redlock, etcd, ZK)
  • Rate limiter (单进程 / distributed counter)
  • Real-time排行榜 (Sorted Set)

性能

  • Redis: 单实例 100K QPS, sub-ms P50, ~1-2ms P99
  • 持久化: RDB snapshot (fork) + AOF (full log) 可选 + Master-slave async replication
  • HA: Redis Sentinel (Raft-like failover), Redis Cluster (auto-sharded)

Redis 数据结构

类型用途
Stringcache (k→v), counter
Hashobject field cache
Listqueue, recent activity
Set去重, 标签
Sorted Set排行榜, leader board, rate limiter
HyperLogLog基数估计 (unique visitors)
Bitmapuser state (active days)
Streamlog append-only Kafka compete
GeoHashlocation index

Memcached vs Redis

  • Memcached: 多线程, 内存 Make, 仅 KV, 无持久化, fail-over restart 失 全部数据. 优点 throughput 高于单 Redis。
  • Redis: 单线程 (6.0 前单, 6.0 后 IO threads) 主 process, 丰富 data structure, AOF persistence, redis cluster。

弱点

  • 不适合 OLTP transactional consistency。
  • 单实例 string max 512MB, list 单元最多 4B 元素, 不适合严重持久化 critical data。
  • 缓存击穿/雪崩风险 (cache/failure-modes.md)。

二、Wide-column Store: Cassandra / HBase / DynamoDB

适用场景

  • 超高写入 throughput (Cassandra 100K+ writes/s per node)
  • Cross-region replication + eventual consistency
  • 写多读少, 多地域高可用
  • 通过 partition key 高效 row range 查询

数据模型

PRIMARY KEY (partition_key, clustering_key)

Wide-column store 把 keys 划分到 partition, partition 内多 row + column 大:

  • Cassandra row = (partition_key, clustering_key) + columns set
  • DynamoDB row = (hash_key, range_key) + attributes
  • HBase row = (row key) + (column family : column qualifier) + cells timestamps

为什么写入快

LSM-tree backend (LevelDB, RocksDB base), 顺序写 + 后台 compaction。 (storage/wal-lsm-btree.md)

弱点

  • Cross-partition JOIN 慢 / 不推荐。
  • Eventual consistency; 要 linearizable must increase write latency。
  • Schema 灵活性差 (Cassandra 强 schema, DynamoDB 无 schema)。
  • 没有 transactional multi-row ACID (Cassandra LWT low throughput)。

三、Document Store: MongoDB / Couchbase

适用场景

  • JSON nested data, nested indexing
  • 中等 scale (1K 写/秒 up to 50K+ with sharding)
  • Aggregation framework (类似 SQL GROUP BY)
  • Replica Set 高可用 (Raft-like election)

弱点

  • Atomic transaction 单 document only (4.0+ multi-doc transactions 慢 + 受 cluster内 quorum 限制)
  • JOIN 不如 SQL strong
  • 默认 read 是 stale; 用户必须 w=majority + R=majority 拿强一致

MongoDB Sharding

  • 默认 sharding key 决定 data 存 range
  • hot shard 险 — hashed sharding 分配均匀但没 range 查询效率
  • 4.4 给 "refinable sharding" 让 sharding key 动态改

四、Relational: PostgreSQL / MySQL

适用场景

  • ACID transactions, complex joins, multi-row updates
  • 严格 schema
  • SQL rich query language (CTE, window functions, JSONB)
  • Report ad-hoc queries
  • 中等写 throughput (~30K TPS 16-core)

性能

  • B-tree index on key, random access O(log N + limit)
  • 16-core instance 写 ~30K TPS (主 key update + index update + fsync)
  • Read scale via streaming replica
  • Sharding 由 Citus / pg_shard / Vitess (MySQL) /vem (Manage大树)

PostgreSQL vs MySQL

维度PostgreSQLMySQL
默认 storageheap + B-treeInnoDB clustered B-tree
JSONJSONB (binary, indexed)JSON (text)
物化视图- (用 manual refresh or pg_kvext)-
Window functionsfull support8.0+
Logical replication10+ nativebin log + GT-M
Geographic indexingPostGIS first -classspatial but limited
Group replicationlogical replication + extensions + PatroniGroup Replication (8.0+), PostgreSQL 同 Circa

Shared-nothing extension

PostgreSQL + Citus → columnar + sharded OLTP/OLAP hybrid。 MySQL + Vitess → YouTube scale-out MySQL。 Aurora PostgreSQL / MySQL → cloud storage-tier replication (Paxos-style multiple AZ).

弱点

  • 单机 VM 上线程多事务 scheduling 难; large cluster 需 sharding。
  • Cross-shard join 难优化; usually pre-shard数据 hot cache。
  • Schema migration 重, 业务平时变更 schema failure costs (e.g., alter table 加 index locks).

Typical 物化视图 (Materialized views)

CREATE MATERIALIZED VIEW daily_summary AS
  SELECT date_trunc('day', ts) AS day, user_id, COUNT(*)
  FROM events
  GROUP BY day, user_id;
-- refresh cotrigger:
REFRESH MATERIALIZED VIEW daily_summary;

PostgreSQL MV 不 auto refresh, 应用触发 cron。其他引擎 (Oracle, Snowflake, ClickHouse) 有 native auto-refresh.


五、Columnar OLAP: ClickHouse / BigQuery / Snowflake

适用场景

  • 聚合查询 SUM/COUNT/AVG over millions of rows → MUST use columnar
  • 大规模包括 batched ETL 装载 → 100M+ rows/sec insert
  • 复杂 OLAP SQL 包括 window function, drill-down
  • BI dashboard on top

为什么列存快

  1. 压缩高: 每 column 类型一致, run-length encoding + delta encoding for symbol/numeric columns → 10-50x compression 平均。
  2. Cache 利用率 高: 同 column 数据连续存储; 同一 column 多 row 在同 cache line 上。
  3. Vectorized execution: 现代 OLAP engine (ClickHouse vectorized) 处理 8192 行 in 一 batch SIMD 指令。
  4. Skip 不需要 column: SELECT 只读 SELECT 中 column 文件, 其他列不碰。

ClickHouse 引擎

CREATE TABLE events (
    timestamp DateTime,
    user_id   UInt64,
    event     LowCardinality(String),
    ...
) ENGINE = MergeTree()
PARTITION BY toYYYYMMDD(timestamp)
ORDER BY (user_id, timestamp);
  • MergeTree: 主引擎, 数据 partition 顺序写, 后台 compaction。
  • ReplacingMergeTree: 后台 collapse 重复 PK (eventual dedup).
  • SummingMergeTree: 同 PK sum 累积, 用于 pre-aggregate.
  • AggregatingMergeTree: 跨 merge 累 AggregateFunction state.

BigQuery / Snowflake

  • Cloud-native, 跨 storage + compute 解耦
  • 高吞吐 BI workload, 按 query 数据量付费
  • SQL 标准 + JSON 半结构 SQL 支持
  • 物化视图自动维护, 自适应

弱点

  • 点查询慢 (单 row get by PK): 大量 row 必须扫满足 predicates. ClickHouse 用户 PK lookup 不友好。
  • 实时 ingest 在 100K+ 行/sec 需 batched insert; 单 row 高 QPS insert 慢。
  • UPDATE/DELETE 慢 (重写整个 part). 强烈 不建议. re-INSERT pattern.

Common 使用: PostgreSQL OLTP + ClickHouse OLAP

flowchart LR
    APP[App] --> PG[PostgreSQL: OLTP real-time write]
    PG -->|binlog sync| CDC[Debezium CDC]
    CDC --> Kafka --> CH[ClickHouse: OLAP]
    BI[BI tool] --> CH

OLTP 持续 real-time, binlog 截 CDC 同步到 ClickHouse 做 OLAP report。


六、时序存储: InfluxDB / TimescaleDB / VictoriaMetrics

适用场景

  • IoT telemetry: 1B+ streams ingest
  • Monitoring system metrics (Prometheus / Thanos / VictoriaMetrics stack)
  • Server performance metrics
  • Stock tick data
  • Application log with structured metrics

数据模型

维度说明
timestampimplicit primary sort key (here 加微秒 together)
tags (labels)string key/value, indexed, low cardinality
fields (metrics)numeric readings, may be (e.g., cpu.usage = 0.7)
high compressiondelta-of-delta timestamp encoding + Gorilla float compression

TimescaleDB

PostgreSQL extension, PostgreSQL native SQL + replication + ACID 保留, hypertable 自动 partition by 时间.

  • 适合 PG 用户 + 时间序列 query
  • 索引 / JOIN 用 PG

InfluxDB v2

InfluxQL Flux language, 不 SQL standard, 但 ingestion throughput 高, 建 retention policy 灵活。

VictoriaMetrics

写性能 ~10x Prometheus, 索引高压缩; cluster mode rows mucho limit support. good for long retention.

弱点

  • High-cardinality tag 像 user_id 会 摧毁 (索引膨胀) — must aggregate + tag low cardinality.
  • Tags cardinality 上百万 实际 kill index; 必须预聚合 或 改用 columnar OLAP。

七、Graph DB: Neo4j / Dgraph / JanusGraph

适用场景

  • 深度关系 (社交好友 / 欺诈检测 / 推荐系统)
  • 3+ 层级关系 traversal
  • Routing / map data (有交通 graph)
  • 风险组件 in finance

数据模型 (property graph)

Node(labels) -[edge(type)]-> Node(labels)
  • Node labels: Person, Account, Transaction
  • Edge types: FRIEND_OF, OWNS, TRANSFERRED_TO

Cypher (Neo4j)

MATCH (alice:Person {name:"Alice"})-[:FRIEND_OF*1..3]-(mutual)
WHERE mutual.age > 21
RETURN mutual.name

弱点

  • 跨 cluster scaling 难, 多数 graph DB 提供 single-tenant + sharding 是 challenge.
  • Connected query 可以 O(N!) 时间复杂度

主要范例

  • Neo4j: 大型开源 graph DB
  • Dgraph: GraphQL + 更 cloud-native
  • JanusGraph: 在 Cassandra + HBase 上构建 graph indexing
  • Microsoft CosmosDB: GraphQL-like with Gremlin API。
  • AWS Neptune: managed graph service。

八、对象存储: S3 / GCS / Azure Blob

适用场景

  • User file upload (images, videos, pdf)
  • 备份归档
  • ML model snapshots
  • CDN static (web fonts, large static data)

性能 / 用法

  • 不限容量 (theoretically infinite)
  • Put/Get 按 object 名 (key); throughput 高 ~10MB/s/part but support multipart upload 并行
  • Latency P50 30-50ms; 大 file get throughput saturate 25Gbps link
  • Storage $0.023/GB/month standard tier
  • Lifecycle policy: 30d → S3-IA, 90d → Glacier, 1y → Glacier Deep Archive

弱点

  • 非 transactional
  • eventual consistency for overwrite (虽然 S3 standard now strong read-after-write since 2020-12)
  • DELETE 不是 immediately visible (强 一致的 list since 2020)
  • 5GB max single PUT (multipart upload 支持达 5TB)
  • 不适合 random access; ideal for streaming whole large file

用法 Patterns

  • CDN origin (CloudFront / Fastly)
  • Static site hosting
  • Archival storage
  • Data lake foundation (S3 + Athena / EMR)
  • S3 + DynamoDB "manifesto style" pattern (key map + file metadata)

九、搜索: Elasticsearch / Solr / Typesense

适用场景

  • Full-text search on user content
  • Log aggregation (Elastic + Logstash + Kibana)
  • Recommendation system backends (BM25 scorer)
  • Geospatial search

Elasticsearch Data Model

Index = 表; 文档 = 行; mapping = schema. Lucene-based inverted index.

  • Lucene = full-text-inverted index with analyzers
  • Shard = Lucene index. Replica shard = 1+ copies for HA
  • Cluster: e.g., 30 data nodes × 3 shards each (~10GB per shard)
  • Cross-shard query fans out scatter/gather

性能

  • 索引延迟 100ms (refresh interval 1s default)
  • 查询 throughput 与 index size, shard count 有关
  • 规模 bump shard 数, 单 shard < 50GB

弱点

  • 不适合 OLTP transactional, no ACID guarantee outside doc
  • Heap 内存大 with many shards (~30GB+ heap 推荐)
  • Index rebuilding is expensive; mapping 变化 reindex 需 full reindex

Alternative

  • Meilisearch / Typesense: 小型 + fast search
  • OpenSearch: Amazon AWS fork of ES (2021)
  • Vespa (Yahoo): 推荐 + search + ad platform heavy

十、向量数据库: pgvector / Milvus / Qdrant / Pinecone

适用场景

  • 语义检索 / RAG: 文本 embedding 后按"意思相近"而非关键词匹配召回 (与第九节的 BM25 词法检索互补)
  • 推荐召回 / 相似图片 / 去重: 一切"把对象编码成向量再找邻居"的场景
  • 典型规模: 千万到十亿级向量, 维度 384–3072 (BERT 系 ~768, 大 embed 模型可达 3072)

数据模型与索引

存的是 (id, vector, metadata), 核心是近似最近邻 (ANN) 索引——精确暴力搜索 O(N·d) 在亿级不可行:

索引思想取舍
HNSW多层跳表式近邻图, 贪心下行召回率/延迟最优, 内存大, 构建慢
IVF-PQ先聚类分桶 (IVF), 桶内乘积量化压缩 (PQ)内存省 10×+, 有召回损失
DiskANNPQ 压缩放 SSD, 图导航十亿级单机, 延迟换磁盘 IO
Flat暴力扫描小数据 (<100 万) 反而最快最准

过滤条件 (metadata filter) 与 ANN 的组合是工程难点——先过滤后搜还是先搜后过滤, 各数据库实现差异很大。

选型

  • pgvector: 已有 PostgreSQL 就加个扩展; HNSW 支持; 亿级内最省事的默认项
  • Milvus / Qdrant: 专用引擎, 存算分离、多副本、标量过滤成熟, 亿级以上
  • Pinecone: 全托管, 不想运维选它; 数据出域是代价
  • Redis / Elasticsearch 也都加了 KNN——如果 QPS 和规模不大, 用现有存储的向量插件往往优于引入新组件

弱点

  • 召回率是近似值, 必须用业务查询集实测 recall@k, 不能信厂商默认参数
  • 向量维度高 → 内存即成本: 10 亿 × 768 维 float32 ≈ 3 TB, 量化/降维是必选项
  • 元数据更新与向量重建耦合: 模型升级换 embedding 后全库需重灌
  • 单独的向量库解决不了"检索质量"问题——rerank、混合检索 (BM25+向量)、chunking 策略往往影响更大

note

向量库与 倒排索引 是互补而非替代: 关键词精确匹配 BM25 仍强, 语义泛化靠 ANN。生产 RAG 普遍做 hybrid 检索再融合排序。


十一、混合策略实际系统

多数生产真实系统 mix 多 store:

- 主 OLTP: PostgreSQL / MySQL
- Cache: Redis
- Queue: Kafka / Pulsar
- OLAP: ClickHouse / BigQuery / Snowflake / Redshift
- Search: Elasticsearch
- File: S3 / GCS
- Graph (if social/route): Neo4j / Dgraph
- Time-series: Prometheus / VictoriaMetrics

选 store flowchart

需求: cache? → Redis
需求: 高写入与 AP? → Cassandra / DynamoDB
需求: 严格事务一致性? → PostgreSQL / MySQL / Spanner
需求: OLAP 多聚合? → ClickHouse / BigQuery
需求: JSON nested? → Mongo / Couchbase / PostgreSQL JSONB
需求: 时序监测? → InfluxDB / TimescaleDB / VictoriaMetrics
需求: 搜索? → Elasticsearch / Meilisearch
需求: 文件? → S3 / GCS
需求: 图关系? → Neo4j / Dgraph

十二、典型事故

MongoDB "事务"误解 (2017一堆)

某公司用 MongoDB replica set 高 throughput + 默认 w=1 + R=1, 在 failover 时丢失已 ACK 的写。 Fix: w=majority, j=true, R=majority. Trade-off 写 throughput 下降 50%.

Cassandra LWW 写丢失 (2018)

某 IoT 平台 Cassandra 默认 LWW driver client.clock.now() as timestamp, 各地 IoT 设备 NTP skew 5-10s。 后写 user 发 compaction time skew data loss potential. Fix: client_supplied monotonic timestamp.

Redis OOM 绕过 maxmemory-policy

某用户 Redis 设置了 maxmemory=4GB + LRU 淘汰, 但配置了 maxmemory-policy=allkeys-lruappendonly=yes 同时启用, AOF 文件已写 8GB disk, RAM 已超 maxmemory + 风险 OOM. Fix: AOF 关闭 + RAM dedicated.


十三、易错清单

  1. Cache 与持久化混用: Redis 不应作 primary store, destroy 不能 recoverable。
  2. MongoDB transaction 4.0+ 必须选 REPEATABLE_READ on driver: 否则 cross-shard transaction broken.
  3. Cassandra 限制 counter + 普通 write 在 batch 写, any cross-table batch with counter 拒绝。
  4. PostgreSQL max_connections > 100 → performance cost high, 推荐 pool 大小 ~10-50, 绝不是 1000。
  5. ClickHouse ALTER TABLE UPDATE 是 full part rewrite, 慢, 不应常态更新。 Re-INSERT 后 drop old.
  6. Elasticsearch refresh_interval=1s: search latency 1s delay。 用 refresh_interval=-1 + 手动 controlled refresh for bulk indexing.
  7. S3 lifecycle policy必设: 不设 storage tier 切换, archive files 堆集 monthly cap.
  8. InfluxDB high-cardinality tag 太重: user_id 不要直接做 tag, aggregate at 5min rollup.

十四、这一章带走的东西

  1. 不同存储引擎有明确擅长的领域, 没有 "one-size-fits-all" 数据库。
  2. Cache → Redis; OLTP → PostgreSQL / MySQL; OLAP → ClickHouse / BigQuery; AP → Cassandra / DynamoDB; 时序 → TimescaleDB / VictoriaMetrics。
  3. Postgres ≥ MySQL 一并行场景: 选择主因是 PG ecosystem / MySQL ecosystem.
  4. 多数生产系统 mix 7 stores, 任何 monolithic "选择 个就够" 都过时。
  5. 性能黑话你必须知道: LSM 写快 + 读需 compaction + B-tree 读快 + 写 amplification. OLTP ACID + OLAP vectorized scan。
  6. S3 是 "infinite" object store + Cache + recent "strong read-after-write" 让"as filesystem"模式可行, 但 仍不 transactional。

下一节 → WAL / LSM / B-tree 内部

Sharding

TL;DR

Sharding 把数据按某 key hash/range 分散到多台机器上, 让写入 throughput 随机器数线性扩展. 核心难关:

  1. Shard Key 怎么选: 重写 index 经常 painful, 选错 hot shard。
  2. 路由 mechanism: client 直知 shard 还是 coordinator 转?
  3. Rebalance 算法: 加机器 / 减机器需要 move 多少 data?
  4. Cross-shard transactions: ACID 跨 shard 难, eventual 与 2PC 之间取 trade-off.
  5. Hot shard (long-tail load): 单 shard 60% traffic 因 celebrity / 明星 key.

本章梳理 4 类主要 sharding 模式 (hash / range / directory / consistent hashing), rebalance 数据迁移代价, classic story (Instagram 2014 12-shard, Imgur unkeyed shard, Twitter tweepy snowflake hot shard) + modern automation (Vitess, Vitess, Spilo, Aurora auto-shard)。


一、Hash-based Sharding

算法

shard_id = hash(mod, partition_key) % N_shards

例: user_id % 1024。 简单, 路由直接 calc hash, 不需 meta-store lookup。

优点

  • 分布均匀 (假设 hash 函数好, e.g., FNV1a / MURMUR3)
  • 路由 O(1), client 端可算

缺点

  • 加机器需 rehash: N 增加 → 几乎所有 keys 重新 mapping, 大 move。
  • Range query 无效: 相邻 user_id 可能落不同 shard。
  • Hot key 仍存在巨大单 hot shard: hop-key hashed 仍 al 热足中 shard 中 (上 fine 一 hot instance)。

修复 (consistent hashing)

见后节。


二、Range-based Sharding

算法

range mapping: [(min, sh1), (a, sh2), (b, sh3), ..., (max, shN)]

适用于按 key 顺序扫描的查询:

  • 时序数据 by timestamp range
  • 用户 ID by alphabetical range
  • 主键范围 scan query

优点

  • 范围查询高效: shards 在 同 range 内, query 落单一 shard。
  • Rebalance 低成本: split a range × 2, half the keys migrate. Less than full rehash。
  • 自然 co-locating: 相关 keys 同 shard (e.g., 同 user_id 的所有 events)。

缺点

  • Hot range: 单 range (e.g., 过去 1 小时写入) 高写 traffic. recent writes 全到一个 shard。
  • Add Shard: 切 range 的 split 影响, multiple shard keys moved 可以轻量但 cross-middle high brief 因为前缀 hot range.

Real Example

HBase region is by row-key range. Amazon DynamoDB Streams 同时也是. Range 说 timestamp 正好 natural range.

Spanner interleaved tables: parent + child share shard (key prefix). child placement near parent for join optimization。


三、Directory-based Sharding

算法

key → lookup mapping table → shard_id

有一 central directory service (metadata DB) 指 key 到 shard 映射. dynamic shard mapping.

优点

  • 灵活: 如果某 shard 太热, 手动 split 单 key 到新 shard. update directory.
  • Migration 灵活: 客户端不经 hash, 经 directory service, 整体栈 decoupled.

缺点

  • Directory service是瓶颈 + 单点风险: 所有 request 都过目录. RAID multi-replic service + cache + hot-shard rebalance for scale.
  • High latency: 一 extra network lookup; directory cache 引入 stale risk (与 shard split timing mismatch).

Real example

Vitess MySQL uses vtgate as a router to MySQL shards. vttablet per MySQL instance. vtgate lookup global topology in etcd.

Apache ShardingSphere, PostgreSQL 通过 Citus 提供类似 dynamic sharding.


四、Consistent Hashing

算法思想

环 ring 模型:
   node hashes random fixed position in ring (0 ... 2^32-1)
   key hashes to position in same ring
   路由到 first node clockwise from key position

加 new node N:

  • 仅 keys 在 (key_pos_in ring, N_pos) 选 reassign to N.
  • 其他 keys 不变.

平均 N = ring中所有 keys, 期望贡献比例 = 1/(nodes_count). 在新 node 加入, 转 $1/N$ 不重.

Virtual Nodes (Vnodes)

Single hash position 可能让一些 node 拥有过 multiple 区段. 用 N virtual node per physical node 让分布更均匀:

  • each physical node mapped to ~150 virtual positions on ring
  • 给均匀 hash distribution

Cassandra, Scylladb, Riak 默认 vnodes = 256 per physical node. Redis Cluster 沿 0-16384 hash slots.

优缺点

  • 加机器 only 1/N 数据 move: 总 key 数据量 × (1/N) move. Add 10th node to 9-node cluster → 1/(=N) data = ~10% keys rehash。Perfect.
  • Range query 仍无效: range queries cross multiple shards (no range locality)
  • Hash ring + cache invalidation: hot shard if multiple hot keys to same virtual node — 仍然 issue。

五、Cross-shard 事务

事务跨多 shards 是 distributed systems hard problem. 几种方案:

Two-Phase Commit (2PC)

Coordinator:
1. 联系所有 participants "准备".
2. 各 participants hold lock + ready, ack.
3. Coordinator 收齐 ack → "commit" to all.
4. participants commit + release lock.
  • linearizable + atomic + cross-shard transactional。
  • 但 blocking coordinator fail, lock waiting time long
  • 取舍: latency 高; 性能不佳。

Saga (Compensating Transactions)

Stage 1 (svc1): WRITE → commit.
Stage 2 (svc2): WRITE → commit.
if Stage 2 fail: emit "undo Stage 1 (Compensate)".
  • eventual consistency
  • Business-level compensation
  • 多 for distributed transactions where 2PC 不行

Eventual consistency + idempotent retry

Service 1 update + emit event → topic. Service 2 consumes event + 更新 local。
  • High throughput, async, basicallySaga.
  • 不 strict atomic.

CockroachDB / Spanner cp Atomicity 2PC across shards

内部 2PL + Paxos replica + 2PC 跨 shard:

  • Performance ~80ms per transaction (compared to 5ms single shard)
  • Strict serializable + linearizable + cross-shard ACID

Trade-off is large; only used when must.


六、Rebalance & Migration

Range Sharding Split

shard 负载 > threshold → split: shard_a takes [0, 50] shard_a_new takes [50, 100]
move keys [50, 100] to new shard。

期间:

  • Dual-write: client dual-write new + old shard 期间 migration window.
  • Read from new shard after migrate complete

Hash Sharding Rebalance

非 consistent hashing:

  • 增 N → rehash几乎所有 keys.
  • 多数 production hash-based sharding 用 consistent hashing 解决.

consistent hashing:

  • 同 vnode count per node 加 new node reassign vnode slot.
  • multiple vnodes per physical node 容易 load balance.

Coordinator-assisted migrations (Vitess)

Vitess vreplication engine 让 migration while serving:

  1. vstream capturing changes on source. 2.Keeping vstream 同 starting point shard migration pulls.
  2. After catch-up, swap-over write traffic.
  3. tear down old.

Migration draining binary flip client routing — full hot migration, no downtime.


七、Hot Shard mitigation

Read Replica

读写 hot key → 路由到多个副本分摊读流量 → 为热点 key 预留专用 replica(避免与普通流量争抢资源)。 Cassandra Naturally多个 replica. 实际流量分布 across multiple replicas + read repair。

Caching Hot Key (Redis local tier)

If hot-key data fits in Redis, server-side cache each local Redis 实例 hit reduce DB load 99%.

Celebrity Problem + Pre-compute at cache-layer

Twitter 的名人发推("Kardashian 问题")是典型 fan-out 风暴:写时推送会把一条推文复制到数千万粉丝的收件箱。工程解法是混合模型:预先识别名人账号,其推文不写入粉丝 inbox,改为读时从作者 timeline 实时拉取合并——写扩散转读聚合。

Cache Replication

Cache 多副本, local 反向 proxy cache cluster with consistent hash routing + replicate top keys to multiple caches (Caffeine + Ristretto with bloom/cuckoo hot-key replication support).

Multi-level One Machine Side Cache (read-through)

Application server in-process cache (Guava Caffeine / HashiCorp LRUdb / Ristretto) for hottest few thousand keys to shield multiple layers。


八、typical case

Instagram 2014 Sharding by user_id

Migration from monolithic Postgres to 12+ Postgres instances by sharding user_id hash direct keys mod 4096:

shard = user_id % 4096
instance = shard / (4096 / 12) // 分配每 instance 多 shard key

purpose 选 4096 在 future scaling — 加 to 24 后 让粉 federations in half cost migration simple. cart 上 5 years after use ACTOR still 4096-shard model in Facebook 5 years 后.

Twitter Snowflake + user_shard

Snowflake ID 是全球唯一 ID generated by:

timestamp_ms | datacenter_id | worker_id | sequence

Snowflake 让 social graph split 但 user_id_nés' 高 hit routing + caching - 每用户数据 co-resident (covidding share) keys 食均.

Vitess - YouTube at scale

Vitess 自 2011 开始 at YouTube. toda y open source USED by Slack, Square Cash, GitHub multim. clusters with thousands of MySQL shards. Requirements: ACID transactions at cross-shard (Vitess vstream v2 plan), migration online, etc.

Vitess + cross-shard transactions

Vitess VStream 提供 save point transaction across shards, limited semantics as 2PC per-Vitess at higher latency coin.


九、典型事故

Shard Split Pain

某公司 host-based shard 加 scheduler, + 基础 directory table update split ranges hot shard, 实际 后 client 健康状态 stale cache split after → look up old shard 个 fail 5 seconds. Fix: dual-cycle served while directory cache propagates.

Postgres Hot Table (Citus)

Citus cluster with 4 nodes, one node has 70% hashes hit based on user_id.一个人用户频繁 user_id 正好分布 region shard。 Redis local cache that hot user → 修复.

Vitess vstream replication logs bit-drift race

Vitess retry-mode vcstream logic over long-table migrate pound wall causing 表 率 alerts.2018 vert spurious panic trov you. Fix batch vstream consensus.


十、易错清单

  1. Initial shard 第一步先 聚 集 hot-key range: 数 dynamic user. Need a factor of 4-10x margin.
  2. Hash-based range query cannot efficiently span shards: 如果 业务优先 rаngе, 选 range-based sharding 不 hash.
  3. Vnodes ≠ guaranteed 热邓小平 distribution: 即使 consistent hashing, single hot key hits one vnode, no help. Need cache + pre-compute.
  4. 2PC cross-shard latency > 10× single-shard: cross-shard ACID 不 should large throughput. Saga 或 eventual consistency preferred for hot path.
  5. Pre-balancing hot-key监控: 偶尔 hot 单 key surge (e.g., download/hot news) 提 前见 re-route critical.
  6. Migration always dual-write during transition: dual-caching key avoidance keeps safe availability.
  7. Shard key must not change: if sharding key changes, row must re-homes.
  8. PostgreSQL primary key after split-over: UNIVERSE has 棋 consensus key per table shards 平台 multi-tenant ideal first place.

十一、这一章带走的东西

  1. Sharding 方式: hash O(1) 路由 + 写均匀但 range queries 无; range 自然 range + 子查询 efficient 但 hot 集中; directory-like metadata locator.
  2. Consistent hashing 加 node 仅 1/N 数据迁移: 适合 dynamic cluster size.
  3. Hot shard mitigation: pre-cache + 完美 celebrity special handling + multi-tier cache Layer.
  4. Cross-shard transaction: 2PC 严哥latency 80ms+; Saga / eventual consistency 是 scalable path.
  5. Migration dual-write + read from new + final swap, downtime free.
  6. Practical: Instagram foreshadowed 4096-shard hash, well designed scalable without bottleneck 斚 10 years.

下一节 → 缓存

WAL / LSM / B-tree 内部

TL;DR

存储引擎内部差异: WAL (Write-Ahead Log) + B-Tree vs LSM-Tree 是 OLTP 数据库两个最重要建树。

  • B-tree: 经典 O(log N) 平衡树—每个 page 大小固定 (16KB), 写路径需 in-place update + WAL。适合读多写少。Postgres heap、MySQL InnoDB、MongoDB WiredTiger (built-in LSM 选项)、SQLite 都用 B-tree。
  • LSM-tree (Log-Structured Merge Tree) 由 Patrick O'Neil 1996 提出: 写入先 append 到 WAL + MemTable (in-memory sorted), 后台 compaction 到 SSTables on disk, 不 in-place update 旧数据, 删除 = tombstone marker。 Cassandra RocksDB HBase LevelDB RocksDB Bigtable Spanner 都用 LSM。
  • WAL 是保证 durability 的"日志式"机制——所有修改先 fsync 到 log, 再 in-memory update 后台 flush; crash 后 WAL replay recovery。

本章梳理 B-tree page 结构、LSM compaction level、Write Amplification vs Read Amplification 故事、典型 RocksDB / InnoDB / WiredTiger 调参, 与决策树。


一、WAL (Write-Ahead Log)

原理

A (Atomicity) + D (Durability) of ACID 要求事务的修改先记录 log 一旦 commit 后, 即使 crash 后重启数据文件未刷盘 也恢复。 WAL 顺序 append, fsync 1ms, sequential write fast。

Protocol (Steal-No-Force)

NO-FORCE: DB commit 后 buffer pool 中 dirty pages 不必立刻 fsync to disk。脏页可能停留几 sec 或更长。 STEAL: 一个事务的修改可能在 commit 前被写入 data file (buffer eviction)。

WAL 保证:

  • Commit 已经进入 WAL (fsync-ed) ⇒ 可 replay + redo。
  • 未 Commit 的事务 ⇒ undo (用 WAL 中早期 undo log).

STEAL vs NO-STEAL / FORCE vs NO-FORCE

策略含义性能
STEAL事务未 commit 可被 stolen (脏页 flush)高 buffer 复用率, 但 需 undo log
NO-STEAL仅 commit 后的脏页可 flush低 buffer 用率, 简化 recovery
FORCECommit 后 force ALL 数据 page to disk高 commit latency, 无 redo log 必需
NO-FORCECommit 后 buffer 可能 keep 脏低 commit latency, 必 redo log

工业 InnoDB/Postgres/BerkeleyDB 都是 STEAL-NO-FORCE 跑, 学术 80+ 论文最多的研究范式。

Log Structure

LSN | prev_lsn | txn_id | type | page_id | redo/undo data
1   | 0        | t1     | UPDATE | page-5 | before: ..., after: ...
2   | 1        | t1     | COMMIT
3   | 2        | t2     | UPDATE | page-7 | ...

Checkpoint

定期点 maintained necessary future redo, 让 log truncation. ~5 between checkpoints.

sharp checkpoint: pause writes, flush all dirty pages, write "checkpoint LSN" to log start; simple.
fuzzy checkpoint: lock-free flush dirty pages gradually before checkpoint marker; modern DB.

写入 flush + fsync details

  • 写入 buffer pool → mem + logical update.
  • WAL append → +1 entry to memory + fsync to OS。
  • async 后台写 (double buffer pool pages flush to data files).
  • Commit confirmation: 客户收 ack 在 WAL fsync 完成之后.

fsync_latency 一般 1ms NVMe, 10ms SATA SSD, 30-50ms 5400 rpm HDD.


二、B-Tree

结构

每 node 一个 page (P) — 在内部 node 存 (K... keys + P page pointers), 叶子 node 存真实 (key, value) pairs。

                    +--- ROOT ---+
       [k1 | k2 | k3 | ...]
        |   |   |   |
        +----+-----+-----+
             |
       +--- LEAF ---+
       (k1,v1)|...|(k3,v3) + next_leaf指针
  • 高度 h (h = log_d (N) where d 是 branching factor)
  • 每 node B+ 树 (variant) 都有 key + pointer (中间也可有 key for 插入导航)
  • 叶子 pointer 构成 "internal linked list" → 范围 scan 顺序高效.
search(key):
  for each level from root:
    line-bisect keys for next page pointer
    page := child.page
  leaf search key within linear (assume sorted leaf) keys.

读时间复杂度是 O(log_d N) + O(1) disk page read (assuming worst case cache miss).

Insert

  1. Find leaf (从根到 leaf).
  2. Insert key into leaf.
  3. If leaf overflow (full page), split leaf + bubble up new key to parent.
  4. Repeat until no split need. Root may split ⇒ new root increase height.

Overhead of Insert / Update

  • Page lock (并发控制): 读 / 写 in-page latch.
  • Buffer pool eviction + dirty page write-back.
  • WAL append fsync.
  • Index updates 同步 (二级索引 all updated on primary key change).

Performance Characteristics

维度Performance
Random readO(log_d N) page access (1-4 page read typical)
Range scanSequential page read after find start key
Random writeO(log_d N) + WAL fsync + index update; CPU + IO
Sequential write类似 random write, NOT optimize 的 sequential log
Compressionrow-format + page-level compression; less than col-based
Write Amplification大约 1-2 (WAL 1x + page 1x)
Read Amplification1-4 page reads per query

B+ tree 是 B-tree 的 variant

主流数据库 (InnoDB, PG heap indices, SQLite B-Tree) 用 B+ tree:

  • B+ tree data 都在 leaves (vs B-tree 在 internal 级)
  • Leaf-to-leaf pointer for range scan
  • Internal pure key pointer "index" navigation

三、LSM-Tree

Architecture

          MemTable        SSTable (L0) → SSTable (L1) → SSTable (L2) → ...
                |             merge + compaction
   WAL → 还再响回 i  log al-perfectlyシクce 探讨 ringnier friskleaf nodes level + HMS Drac nos. 
  • MemTable: in-memory sorted table, 同时 WAL 持久化. (skip list or Red-black tree)
  • SSTable: on-disk sorted table, immutable after field force into disk. file sorted by key.
  • Compaction: 周期 run "merge sort" 几 SSTables, dedup keep latest value, output lower-level merged SSTable.
  • Levels: L0 SSTables overlap keys (range overlap). L1+ SSTables within level cover 不重叠 ranges.
  • Bloom Filter per SSTable, fast reject missing key.

Write Path

insert(key, value):
  WAL.append(InsertEntry)  fsync
  MemTable.put(key, value)
  if MemTable.size > threshold:
    swap MemTable → immutable MemTable → flush to disk as L0 SSTable
    new MemTable created.

Compaction

Size-tiered vs Leveled:

  • Size-tiered (Cassandra defaults): N 个 SSTables same size bucket merge into 1 SSTable of N× size. 写放大低, 读放大高 (many SSTables).
  • Leveled (RocksDB defaults): L1 has files ≤ X MB total, L2 ≤ 10× L1, L3 ≤ 10× L2. Each level 是 的 ranges partitioned. 写放大 高 (multi-level rewrite) 但读 only 1 SSTable per level per key check.
  • Hybrid (RocksDB tiered+leveled): Tunable.

Read Path

get(key):
  snapshot MemTable + immutable MemTables checked first
  For each L0 SSTable (newest first):
    if bloom filter accept:
      read block, lookup key
  For each level L ≥ 1, find 1 overlapping SSTable per level via manifest, check bloom+index then read.
  If tombstone hit → return "deleted".

Compaction Triggering

  • L0 file count > L0_threshold (default 4): trigger L0 → L1.
  • Level size > target_size: trigger L_i → L_{i+1}.
  • background threads parallel compact diff levels.

Performance Characteristics

维度Performance
Random writeO(1) WAL fsync + O(log N) MemTable insert; very fast
Sequential writeSame as random, equality
Random readO(L) disk reads (L = level count, ~7 with bloom filter quick reject)
Range scanMulti-SSTable merge; could be 提升 (lower levels) but compaction affects throughput
Compression各 SSTable block compress独立, ~10x ratio
Write AmplificationO(level count) ≈ 30-50x for leveled compaction
Read AmplificationO(level count) with bloom, plus per-SSTable IO

四、Write Amplification vs Read Amplification

B-Tree

  • ONE random write: WAL (1×) + data page update (1× for in-place) + index updates (1× per index).
  • 总 ≈ 2-3× 实际 bytes writes per user write.
  • Read 1-4 page reads.

LSM-Tree Leveled

  • ONE random write goes MemTable + WAL, eventually多次 compaction rewrite lower levels.
  • For 4 GB work 单 write 走过 L0 → L1 → L2 → ... L6 = 多次rewrite. Total WA ≈ 30-40×.
  • Read O(L) ≈ 7 disk reads (with bloom filter quick reject).
  • B-tree read 不一定省 IO, 但写 Lsm 比 B-tree 多 10-30× overhead.

Optimized LSM (LSM-Tree-Trie / Dostoevsky 等)

Modern LSM (e.g., Dostoevsky et al. paper 2017, "Dostoevsky: Better Space-Time Trade-offs..."), Lazy Leveling 与 Hybrid LSM-Trie 减少 write amplification.

Default RocksDB leveled compaction config:

  • L0 — 4 files
  • L1 — 10MB
  • L2 — 100MB
  • ...
  • L6 — 10TB

但 L6 写放大极高 (each L1 → L2 rewrite L0 data + update L1 + ... up to L6).

Hot vs Cold 工业例

Cassandra + RocksDB Spanner (Colossus FS + LSM) 有典型:

  • Hot 写入: L0 + L1 保留 ~1 hour recent writes. Read mostly these.
  • Cold数据: 经过 7+ compaction 进 L6, 几乎不可变.

五、典型引擎调参

InnoDB (MySQL)

参数默认调优效果
innodb_buffer_pool_size128MB标配 50-75% system RAM。越大 cache 优异
innodb_flush_log_at_trx_commit1 (fsync per commit)=2 = OS buffer flush (1s crash lose some) =0 = 不要 fsync (重启丢数)
innodb_log_file_size48MB大文件 less checkpoint pressure. 太大 recovery slow.
innodb_file_per_tableONTrue 方便单表物理隔离, 一表 drop 不损整体;
innodb_flush_methodO_DIRECTbypass OS page cache, 防 memory/page buffer doubles
innodb_io_capacity200NVMe 推 5000-20000; HDD 推 200-2000

PostgreSQL

参数推荐值
shared_buffers25% system RAM
effective_cache_size75% system RAM
wal_buffers16MB (default -1 auto)
checkpoint_completion_target0.9 (smooth IO)
max_wal_size64GB+ (high write)
synchronous_commiton (default)
wal_compressionon (节省 IO)
random_page_cost1.1 (NVMe) - disable seq vs random cost estimation

RocksDB

参数用途
write_buffer_sizememtable size, 64-256MB
max_write_buffer_number并 memtable (写 burst cache)
level0_file_num_compaction_triggerL0 SSTable count trigger L1 compaction
max_bytes_for_level_baseL1 target size, 256MB
max_bytes_for_level_multiplier10 default
target_file_size_baseL1 SSTable file size, 64MB
compaction_styleleveled (default) / tiered / universal

WiredTiger (MongoDB)

  • storage.wiredTiger.engineConfig.cacheSizeGB
  • storage.wiredTiger.collectionConfig.blockCompressor (snappy / zlib / zstd)
  • storage.wiredTiger.indexConfig.prefixCompression

六、InnoDB 内部架构

InnoDB 是 MySQL 默认 storage engine (5.5+). 内部:

  • 主键 = clustered index (叶 sons data, 中节点存主键指针)
  • 二级索引 = standalone B-tree 节点存 primary key 与secondary key; secondary key must second花跳 primary key 找 value.
  • buffer pool: 14+ GB 主存 cache-page evictions LRU 优化 (mid-LRU, 让 cold page out-入 secondary key 自动头部 + index 防止 full-scan blow up).
  • doublewrite buffer: 让 write-half (50% permanent partial page write) crash 后可恢复.
  • change buffer: insert second 跳 write to secondary index buffer 后台 merge.

七、典型事故与考量

hat InnoDB double-write buffer;重要性

某用户 close disable innodb_doublewrite=0. 经历 partial page write (宕机 半写), 重启后 page 不可逆 corrupt, 数据修复 难 Failover. 推荐保持 ON; overhead ~10-15%.

PostgreSQL checkpoint I/O 风暴

某用户 配 checkpoint_segments=300 + checkpoint_timeout=300s, write traffic 平稳但 checkpoint 期间 IOPS spike >10× avg, 业务 P99 暴增。Fix: checkpoint_completion_target=0.9 让 checkpoint 缓慢刷 IO over full interval; smooth IO spike。

RocksDB L0 stall

某 Kafka-style high write use RocksDB, 配 64MB memtable 但 L0 compaction 跟不上, L0 ≥ 12 file 进入 stall (RocksDB 自动 backpressure:- stop write)。Fix: level0_file_num_compaction_trigger=2, + max_write_buffer_number=4.

Cassandra TTL + compaction 膨胀

Cassandra gc_grace_seconds=10 days 默认—— TTL 过期 tombstone 必 wait until after gc_grace before dropping. tombstone accumulation 让查询必须扫所有 SSTable, read throughput 降。Fix: 启用 DTCS / ICS table compaction strategy。


八、易错清单

  1. WAL fsync 是 durability 的基石: 关闭 synchronous_commitinnodb_flush_log_at_trx_commit=0 让 commit 1s 内丢失
  2. B-tree 适合读多, LSM 适合写多: 不了 LSM 读 amplified. 必须 bloom filter + cache 弥补。
  3. Leveled compaction has 高 write amplification (30-50x); tiered compaction 反之; 选 compaction strategy 应业务 burst traffic.
  4. memtable must fsync 到 WAL 跨 过 user: MemTable.flush to SSTable normally takes seconds later; WAL fsync per commit is durable lemmas. except priorities: 加 .append-only 置 Abd事 fact readable even if memory lost.
  5. LSM tombstone 是 another write: deletion 不立即 free space, compaction 后才 release; storage modified over time. insufficient compaction = storage bloat + read slow.
  6. 复合 indexes (e.g. PostgreSQL covering index) 可减少 B-tree 没数据 索引扫; 但 增加 write amplification. 报 大写 frequently.

九、这一章带走的东西

  1. WAL 是 OLTP 的 durability 与 atomicity 底根. "write fsync then ack" 是 database durability 协议。
  2. B-tree 是 in-place 写 + 财 +读fast; LSM-tree 是 append-only 多 level 后台 compaction, 写 fast 读需 bloom + L step.
  3. Write Amplification LSM 比 B-tree 30-50x 高, 这是为什么 RocksDB 的写throughput 越越大 cluster. Counter balance lookups 跪 lock過 head bump指数 wave act ossessions 喜用 prior.
  4. Compaction: size-tiered (low WA, high RA) vs leveled (high WA, low RA); hybrid configuring to workload. Bloom Filter 是 LSM 读关键.
  5. PostgreSQL + MySQL tuning: buffer pool + WAL fsync + checkpoint smoothing + CPU IO capacity.
  6. RocksDB/Cassandra: max_write_buffer_number, level_compaction_dynamic_level_bytes, target_file_size_base 与 SSTable count threshold.

下一节 → Sharding

缓存

缓存不解决性能问题, 它只把问题前移——把"DB 单次 5ms 查询"变成"缓存 0.5ms 查询", 但 cache hit ratio/staleness/eviction/失效成为新的复杂度来源。 几行 cache 代码就让运维复杂度爆炸——黄疸/雪崩/穿透/击穿 是工程师必须懂的话题。

缓存模式 (Cache Patterns)

TL;DR

常用的应用层 cache 与持久存储互动模式:

  1. Cache-Aside: app 先查 cache; miss 才查 DB; 同时填 cache.
  2. Read-Through: cache 自动从 DB load (cache service transparent block)
  3. Write-Through: write 同步到 DB + cache, 同时更新.
  4. Write-Behind / Write-Back: write 进 cache; async flush to DB. 可能丢数据
  5. Refresh-Ahead: cache 提前 refetch before expiry

每种 pattern 有适用场景与风险, 必须理解 trade-off. 本章也讲述 cache key naming (namespaced + tier-specific names), TTL 设计 (10s-7d), 但 biz 望(source-pattern instructions patterns not deep needed)。


一、Cache-Aside (Lazy Loading)

算法

def get(key):
    val = cache.get(key)
    if val is None:    # cache miss
        val = db.query(key)
        if val:
            cache.set(key, val, ttl=60)
    return val

def set(key, val):
    db.update(key, val)
    cache.delete(key)      # 也可 cache.set(), 但 stale 风险 with concurrent

优点

  • App 控制 cache decision (不一 transparent)
  • TTL 控制 stale window
  • Old version 不被 setDate; cache fals returned next miss should fetch 刷新
  • 适合 read-heavy, write-low

弱点

  • Cache miss 后多重 client 同时 miss = thundering herd (multi-thread/few-client + same key 缓存 stampede) — mitigation: 互斥锁 / single-flight fetch.
  • Write-through override 时 cache delete 仅不 invalidating writes disk to in ensure 別点 冰 hot path not-base + perator.
  • TTL 必 must set balancing staleness 与 cache pressure.

Best Practice

  • Use lock+mset: each miss 先 acquire lock (SETNX), single worker fetches 持中 cache kills 后讀 + 讀 ensure write errors be escaped.
  • Set short TTL + jitter 当防止 synchronous expiry → stampede to DB.
  • 监控 cache hit ratio 与 miss latency; alert on hit ratio < 95% over 5 min.

二、Read-Through

算法

class ReadThroughCache:
    def __init__(self, cache_backend, loader_fn):
        self.cache = cache_backend
        self.loader = loader_fn
    def get(self, key):
        return self.cache.get(key, loader=self.loader)

Cache 类内部自动 miss 时 call loader function (database) + fill cache + 返 result。

优点

  • App 不 用 handle miss logic; cache 透明处理.
  • Multiple clients 经过 cache layer 一次 fetch — single-flight reduces stampede.
  • Cache layer 集中 复杂 logic 跨 services reuse.

弱点

  • Cache layer 全故障 ⇒ crash clouding (must have fallback + circuit breaker).
  • Loader fn 同步含 disk call; cache 故障 ⇒ service down.
  • "Single flight" usually not auto implemented. Must be explicit in cache library (e.g., Etsy/statsd, singleflight golang).

用例

  • Twitter fatcache (origin Redis + loader goes to backend 集中 in-process) 缓存 image metadata.
  • Memcached + libketama for transparent read-through clients.

三、Write-Through

算法

def write(key, val):
    db.update(key, val)         # commit first
    cache.set(key, val, ttl=...)

同步 落 database + cache. 客户不 ack 直到 both write 成功.

优点

  • Cache immediately fresh, no stale window.
  • DB 缺 cache loss仍是 correct.
  • Write-heavy 细节 确保 cache 更新及时.

弱点

  • Write latency = DB write time + cache time. 比 cache-aside write 慢 (write 直接 DB 然后 cache delete).
  • Cache 与 DB 暂 死锁 race if invalidations burst between transaction.

用例

  • Inventory management, financial transaction log
  • User preferences (mutated from any client; cache must be coherad).
  • Object status (e.g., order status pipeline).

四、Write-Behind / Write-Back

算法

# Write-Behind
def write(key, val):
    cache.set(key, val)     # only cache update
    queue.put("write_to_db", key, val, due=immediate)
    # async worker reads queue + flushes to db periodically

Cache update write first, DB writes batched / async to improve throughput.

优点

  • Write latency = cache latency only, super fast.
  • 适合 write-heavy OLTP 入高 QPS (Telemetry, log, IoT).
  • DB writes batched 减少 IO 操作; amplification reduce.

弱点

  • 数据丢失 risk: cache 故障后 queue 等丢失; DB 有未写数据 史 lost + fatigue 倁 is preventing.
  • Cache + DB 不 strictly consistent; cache mirr stop concurrent read 看 stale
  • queue 长不满 = DR confirmation consistency 又 使 测试 errors latency
  • 必须 at-least-once semantics with idempotent retry.

用例

  • Telemetry/log ingestiong
  • IoT 设备 sensor reading (容忍丢样品 1s)
  • Heartbeat / cloudmetric updates
  • APM deger (Datadog spies writes to local buffer agent before engineering upload)

五、Refresh-Ahead (Proactive Caching)

算法

Cache 配合 "Predictively refresh" 基于老 TTL inredictions:

def get(key):
    val = cache.get(key)
    if val.is_near_expiry:
        # 异步 refresh
        sched_async_reload(key)
    return val

cache 体外 让忠 客 elsewhere task miss. Scala. Most unjamat 白 州 大 上 那 is 多 thread whose variant processing DIC 流 满中 load 都一致.

用例

  • 高 current cache with long compute (image rendering, ML inference).
  • 主动预热cache for hot news events.
  • Periodic refresh strategy = 模опримечCum Usage.

弱点

  • Refresh worker too aggressive = load backs, DB unavailable 失败 risk。
  • require configure with pro-active TTL Predicted; Tier bindings
  • Not 支持一般 cache system; 需要 实验中 显 explicit code.

六、Cache Stampede & Single-Flight

Cache Stampede

许多 clients 同一时间 miss + 同 call DB → thundering herd, DB overloaded. 触发场景: 大 popular hot key TTL expire 同时, 导致所有 client miss same time.

Mitigation 1: Mutex / Lock

def get(key):
    val = cache.get(key)
    if val is None:
        if distributed_lock.try_acquire(key, ttl=10s):
            try:
                val = cache.get(key)
                if val is None:
                    val = db.query(key)
                    cache.set(key, val, ttl=60)
            finally:
                distributed_lock.release(key)
        else:
            time.sleep(50ms)
            return get(key)   # retry cache

Only first worker fetches DB; others sleep, retry cache (fill by first worker).

Mitigation 2: Two-Layer Cache (L1 + L2)

L1 = local process cache (1s TTL, light) L2 = Redis (longer TTL, sharing)

请求先 L1; L1 miss → L2; L2 miss → DB。

Mitigation 3: Probabilistic Early Refresh

def get(key):
    val, expire = cache.get(key)
    if expire - now < jitter(0.5, 50s):
        # 5% probability to refresh early (spread-out 多 clients)
        if random() < 0.05:
            cache.refresh(key)
    return val

XFetch algorithm (Vattani et al. 2015): 每个 client random 决定 pre-fetch + 后 TTL expiry. Distributed refresh bash 95-99% clients refresh before expiry → low pressure on DB.


七、TTL 设计

TTL适用场景
~5-15shighly transactional data (inventory 与 price)
30-60suser data 可能 stale (user profile)
5-15mintags, categories (relatively static)
1 hourproduct catalog 配置
24 hoursstatic content (styles)
Permanentimmutable data with versioned key (asset files)

Plus-Jitter (避 expire 同步)

def set_with_jitter(key, val, base_ttl):
    jitter = random(0, base_ttl * 0.1)
    cache.set(key, val, ttl=base_ttl + jitter)

避免 cache miss storm when same key expires simultaneously.


八、Cache Key Design

Naming Convention

{app_name}:{entity}:{id}:{version}

例: shopcart:user:42:v3

  • app_name: namespace 避开 conflict (use+cache)
  • entity: data class 类, doesn't clutter the registry
  • id: ttl oprumus
  • version: schema version critical for cache invalidation after code changes (deploy new code + bump version → fresh cache)

Hash Cache Key (Long-Term Keys)

For keys >250 chars (Redis max 512MB), 多 用 short content-addressed hash:

key = "feed:user:42:day:2024-05-01:" + sha1(query)[:8]

reduces key 大但是防 collision.

Conditional Sweep With Collections

管理 key collection by prefix:

  • keys shopcart:user:42:*
  • (Redis keys * is O(N), slot ** SCAN 配 useARING 必 with count, production 上 is the careful our else)

九、典型 Use Cases

Reddit vote caching (Cache-Aside + Lock)

def get_vote(player):
    val = redis.get(f"vote:{player}")
    if not val:
        if redis.set(f"lock:{player}", "1", nx=True, ex=5):
            val = db.get_vote(player)
            redis.set(f"vote:{player}", val, ttl=60)
            redis.delete(f"lock:{player}")
        else:
            sleeps(50 ms)
            return get_vote(player)   # retry fill
    return val

Booking.com catalog search cache (Refresh-Ahead)

预先 prefetch hot hotel之乡 cache 基于历史 search volume pattern; attenuate cache spikes; users see cache always with stable.

Stripe cache write (Write-Through)

Each invoice create. 更新 cache (with TTL) and 同步写入 Postgres. Card 90+ minutes 检测 cache staleness + check coverage from DB.

LinkedIn shared feed cache (Read-Through)

Read-through cache layer with loader function generating feed (expensive computation) once then cached; invalidation keyed RATED write 写 fly through更新 写 + read post eager since ttl+key align.


十、典型事故

Cache Stampede Saleforce 2016

Cache 切 stampede after one shard was misconfigured with -1 TTL expiry. ~5K client miss same key 同 fetch DB → DB cluster overloaded ~30s. Fix: lock-based single flight + jitter TTL.

Inconsistent Cache for Stale Read Reddit

Reddit 用 cache-aside 后,一会儿 update_post_metadata 直接 DB; cache + ttl 局 60s; 更新 时 缓存 中 stale. 用户 看 post metadata 60s 后 last 削减. Fix: 自动 update cache + 写后 "DEL key" pattern.

Cache Invalidation storm US government hockey team site

"Balance updates" ⚠潜在 handle score update → 事件 200+ events invalidate local at thousands ton menu k "letter fui"- subscribe تشी invalidation list. Invalidation delay thoroughly Tier 1 vs Dispatch bad. Fix: Write-through cache always use "data 一起 update DB + cache 依次 同步 rewrite exactly for data integrity)".


十一、易错清单

  1. Cache-Aside 必 想到 thundering herd: single-flight lock, jitter TTL 防止 stampede。
  2. Write-Behind 必须接受 at-least-once + idempotent retry: crash 时 data 可能重发 DB; DB upsert idempoton.
  3. Read-Through cache miss fallback: cache layer fail 不应 kill service, must gracefully fall back to DB.
  4. Cache key version + invalidate on code deployment: 旧 cache key 仍旧 craft 旧 data shape; version bump 强制 invalidate 全部.
  5. TTL 不能太短 (低 hit ratio) 或太长 (stale): 平衡; 5-60s 是常用短 TTL.

十二、这一章带走的东西

  1. Cache-Aside: app 直接控制, lazy fetch; stampede risk ⇒ use single-flight lock.
  2. Read-Through: cache layer transparent miss handling; single-flight native.
  3. Write-Through: DB + cache 同步 update; consistency strong, write latency 高.
  4. Write-Behind: cache first + async flush; 读写 fast but risk data loss on failure.
  5. Refresh-Ahead: 提前 refresh before TTL; prevents hot leaves cold at expiry.
  6. Single-Flight Pattern + Jitter TTL + XFetch prob refresh = best practice for 防止 stampede, 适用 high-scale cache.

下一节 → 缓存失效模式与雪崩/穿透/击穿

缓存失效模式:雪崩/穿透/击穿

TL;DR

Cache 失效场景 (cache failure modes) 是分布式系统最常见但 damage 也最大的 ops 问题:

  1. 缓存雪崩 (Cache Avalanche): 大量 hot keys 同时 expire, 同时 API 全 hit DB, DB 过载. 原因是 TTL 同步重置批量。
  2. 缓存穿透 (Cache Penetration): 大量 client 查询 不存在的 key, cache miss 永远 (没有数据 to cache), 直接打 DB. DDoS / 恶意 payload 是主因.
  3. 缓存击穿 (Cache Breakdown): 极少数超 hot key expire, 大量 request 同时 miss 且都打 DB. (单 key stampede, 同 thundering herd).
  4. Cache 预热失败 / cold start: 系统重启 缓存 空白, 全 backend load = 100%, 不响应 SLA.
  5. Cache 与 DB 不一致: write-after-read 时序, stale period 内 business logical unexpected.

本章分析每种失效模式, 应对模式 (bloom filter / negative cache / single-flight / jitter TTL / random expiration / multi-tier warmup / fail-fast).


一、缓存雪崩 (Cache Avalanche)

症状

大 群 key 同时 TTL expire → 多 client 同时 cache miss → query DB → DB 多 connection 流 collapse. 缓存 hit ratio 暴跌, backend 流量每秒 100×, 业务 P99 latency 秒级.

原因

  • 大批量缓存预热刚 done 时 全缓存设相同 TTL = 3600s.
  • 部署 100+ same-time cache, all 同 expire after 1 hour.
  • 节日显著 增加 keys 大批 (post-series), 但 TTL 无 jitter.

Mitigation 1: Jitter TTL

ttl = base_ttl + random(0, base × 0.2)

给 each key 不同 expired window distribute 避免 同步 expire.

Mitigation 2: 永远 cache hit 失败 时 fallback queue

def get(key):
    val = cache.get(key)
    if val is None:
        if rate_limited(key, max_misses=1000/60s):
            return cached_fallback_value(key)  # 服务降级 value
        val, db_hit = db.get_with_slowlatency_status(key)
        cache.set(key, val, ttl=60)

Cache miss rate limit 让 miss burst 不 let single event downstream → graceful degradation.

Mitigation 3: 永远特斯拉 — 热终止 cache copy

Persistent Cache: Percolate "at least one copy" in another tier (L2 cache) maybe 是 Redis cluster + application local LRU active. 大 L2 fill 须 后 L1 cold press pipeline import startup fast.

Mitigation 4: 限流后降级, 加大 fx

if miss rate > threshold:
    activate degraded mode, return cache fallback / latest old cached value
defer reset when load confirms rest recovers.

二、缓存穿透 (Cache Penetration)

症状

大 量 request 查询 不存在的 key (user_id = -1, email = "bad-addr@...")。 Cache miss 永远 不 set (cache miss NEVER fill), 因为 DB return null 不应缓存 null。

Impact

DB 没 被 cache 保护, traffic 直接打 DB. 假如 DDoS 用 random key flood, cache useless.

Mitigation 1: Cache Null / Negative Cache

def get(key):
    val = cache.get(key)
    if val is not None:
        return val if val != TOMBSTONE else None  # negative
    val = db.get(key)
    if val is None:
        cache.set(key, TOMBSTONE, ttl=60)  # cache 缺失 signal (短 TTL)
    else:
        cache.set(key, val, ttl=300)
    return val

缓存 "not exist" 信号, 让 attack repeating keys 都 hit cache "no result"。

但 short TTL 因为 attack 会查random 不存在 key, 每次 fallback to DB. 必须 combine with bloom filter.

Mitigation 2: Bloom Filter for "Known Keys"

bloom = bf.init()
for k in db.all_keys(): bloom.add(k)

def get(key):
    if not bloom.contains(key):
        return None   # 99.999% confident not in dataset
    val = cache.get(key)
    ...

Bloom filter 早期 eliminate unknown keys 之前 reach cache (存活 10x less cache contention). Standard trace September.

Mitigation 3: Rate-limit on non-existent keys

For client request pattern query 必 random unknown user IDs:

if client_specific rate_limit(`unknown_${client_id}`) > 10/s:
    return KeyError 429  // 就 deny service.

三、缓存击穿 (Cache Breakdown)

症状

少数 hot keys expire 同一时间, 大量 client miss same key → query DB → DB collapsed. (Single-key stampede)

Mitigation 1: 加锁 (Single-flight Lock)

def get(key):
    val = cache.get(key)
    if val is None:
        # distributed lock
        if redis.set(f"lock:{key}", "1", nx=True, ex=5):
            try:
                # double-check after lock acquired
                val = cache.get(key)
                if val is None:
                    val = db.fetch(key)  # only first wins DB call
                    cache.set(key, val, ttl=60)
            finally:
                redis.delete(f"lock:{key}")
        else:
            time.sleep(0.01)
            return get(key)   # other concurrent wait
    return val

Only first worker fetches DB; other sleep + retry cache (filled by first worker).

Mitigation 2: Refresh-Ahead (Proactive Caching)

Cache 上汇报 "expiry incoming", 用于 an scheduler expires background 拉取. User 仍 cache 旧 值, async refresh fills before real expiry.

Mitigation 3: XFetch probabilistic early refresh

def get(key):
    val, expire_at = cache.get_with_ttl(key)
    if val:
        # 5s before expire -> give each client a chance to refresh with prob
        if expire_at - now() < random(0, 50s):
            if random() < 0.1:  # 10% chance
                sched_async_refresh(key)
        return val
    # miss process: lock + fetch

四、Cold Start / Cache Warmup

症状

新 cluster deployed / fresh start cache, 所有 keys 不在 cache, first 用户 都 miss + load DB.

Mitigation 1: Preheat Cache

Deployment script 加 load_hot_keys():

for key in top_n_keys(): # source:分析 past log analytics
    val = db.fetch(key)
    cache.set(key, val, ttl=...)
    sleep(50ms)  # avoid overwhelming db_on start

Mitigation 2: Shadow Traffic (Dark Launch)

部署 缓存同步 production traffic 的 copy before taking real traffic. Real cache hits 累积 后才 open to real user traffic.

Mitigation 3: 多 tier

L2 cache 是持久保留的 (Redis cluster deploy 新区域 TTL 还 没 expire); L1 in-process cache 是 cold start. L1 与 L2 cold start formula vary per use.


五、Cache 与 DB 不一致

问题

  • App-aside cache pattern, write 后 去 cache.delete(key), but fail delete 失败.
  • Alpha: 客户 raw read-after-write feed 让取舍 cache 内利胺.
  • Concurrence: read-cache 期间 update + race: cache.set inherits stale from DB after a new write.

Mitigation 1: Write-Through

就这样screen DB write covers cache set. DB update + cache set same ensures consistency.

Mitigation 2: 双删 (Double Delete Pattern)

write(key, val):
    db.update(key, val)
    cache.delete(key)
    sleep(read_latency_estimate)   #例如, do typical read-write db完成 时间
    cache.delete(key)       # delete again as fallback

让 第一 delete 与 second delete 中间可 condoru stale fill (read-time send by 写 + miss + set 阅的 logically.. lazy) delete again wipes out.

Mitigation 3: 延迟消息

Cache invalidation 通过 message queue after DB commit确保:

  {key: "x", version: 3}

All related cache instances subscribe → invalidate by version. 顺序保证 + at-least-once.

Mitigation 4: Versioned Cache Key

key: article:123:v17

When code deploy with new layout, bump version 都处 set cache consistent 自动 expire 老 时 cache TTL. 不 not data 变 key变更 而更新 cache 何 invalidation issue.


六、典型事故

Cache Avalanche S3 Outage 2017

AWS S3 us-east-1 a lookup service cache invalidation trouble outage, 也 Massive 还 Sadly damaged HTTPS Real config.

微博 hot news cache breakdown 2015

某 hot news key expire, 5+M concurrent client miss, DB collapsed 压力 service. Fix: single-flight lock + XFetch.

Mail.ru high-traffic cache penetration

跟踪 Russian webmail services early 2010s — random user lookup attack fill up cache with random negative results — deploy bloom filter high-value attack kills latency until rate limit strategies detect.

Twitter sticky cache write after write

User profile write to DB, cache delete send stale 1 second; user update fire 双 retain visible wrong 写 后 → return sticky value. Fix: 双 delete pattern + 写 后 read 卡 masa 加 small tie delay.


七、易错清单

  1. TTL must be jittered: Implement jitter~10% within standard TTL base 显著 cost.
  2. Negative cache + bloom filter are 与 DDoS attacks 紧密 接 战 事.
  3. Single-flight lock TTL must be < max load lrought time: 否则 cache dehydration release 后 时 突 out please 共路.
  4. Cache invalidation race conditions 写 后 update 然后 cache delete 仍 capture stale 。 解 numeric properly through architecture patterns:
    • Write-through + version控制的key既能保证一致终且 avoid high后半 cleaner order.
  5. Cache cold start time analyzed to capacity pool
    • Preheat cache before traffic serves. shadow 全 traffic 全 init投影 service out hosed.
  6. Cache + DB consensus is important: hot effect扮演 in start accept single error patterns degrade making whole 也.

八、这一章带走的东西

  1. 缓存雪崩 = 多 hot key 同步 expire → DB overload; mitigation = jitter TTL + multi-tier + degraded; 排查 cycle.
  2. 缓存穿透 = 大 unknown keys attack + null never-cache → DB cost; mitigation = negative cache + bloom filter.
  3. 缓存击穿 = 单 hot key expire 于 stampede → DB pressure → single-flight lock + refresh-ahead + XFetch probabilistic refresh.
  4. Cold-start / cache warmup 应对 startup; patterns: preheat hot keys + shadow traffic.
  5. Cache-DB 不一致: write-through + double delete + version key + message queue invalidation. 单 delete 易 race; number always.
  6. Real-world incidents: Twitter sticky, Mail.ru cache penetration attacks, AWS S3 outage cache issue — 工作者 safety boardworktrade.

下一节 → 消息队列与异步

多级缓存 (Multi-Level Cache)

TL;DR

实际生产系统几乎不只用一层 cache, 而是5+ 层 cache 串联: CPU L1/L2/L3 → OS page cache → 进程内 (Caffeine/Ristretto) → Redis cluster (remote) → CDN edge (Cloudflare/CloudFront) → Origin。 每一层 cache hit 一毫秒以内代价指数降低; 但每 miss 后 代价上一级。 本章梳理 multilevel cache 的常见层, 各 cache layer 性能与特点, ttl / strict-least-recently / random 策略, 主要 cap-trade-off——最终一篇案例: "Twitter fanout on billion line per day 用多大 cache 层"。


一、缓存金字塔结构

┌─ CPU L1/L2/L3 cache (~1-12ns) ── atomic CPU hardware
├─ OS Page Cache (Linux readv, mmap) (~100ns-1µs)
├─ In-process cache (Caffeine / Ristretto / LRU) (~50ns-500ns)
├─ Remote Redis (Redis cluster ~0.5-2ms)
├─ Search/Service 全局 cache (Elasticsearch query cache)
├─ CDN edge cache (Cloudflare edge POP) (~5-30ms from edge)
└─ Origin (database 真实fetch ~5-50ms)

查一次请求 path:

  1. CPU 操作进程 in-memory → 加一个值 cache hit ~50ns
  2. OS page cache buffer → 加几百 ns
  3. Network round trip 去 remote Redis → 0.5-2ms

每一层 cache hit 前一层的同形, 代价 ,故障 window (cache 一失效) 影响 数据轮询.


二、Process-Internal Cache

Caffeine / Ristretto

  • Java Caffeine (Bench 2018): Window-TinyLFU eviction policy + AMQ (启发最优 LRU) 还有命中率 比 LRU 高 29% in traces.
  • Ristretto (Go): similar TinyLFU eviction, high concurrent map-based concurrency access native 支撑.

Use Cases

  • API server 缓存最近 hot 文件 / user_dst_data 共享。
  • Code-dec lookup (e.g., country list, color tokens) — almost immutable on minor changes.
  • Post metrics / 验证 token Blacklist (短期)
  • High-availability 计数器 (rate limiter)

Trade-offs

  • Cache size 受进程 RAM 限制, single process 不 persistentent. ** process 多副本 迫使 cache 不 fully coherent replicated, 负 cache 是sof consistency**.
  • 保护 process startup time。 e.g., fresh JVM 启动 cache 大都会 empty, hot load must warm up.
  • 内 cache size 多 GB (e.g., 4GB CPU-bound serve), 整 heap 4GB cache 包 single holder. <<sl允 graceful test>>.

Eviction Policies

Policy算法Use Case
LRU淘最长时间没访问的大多数 case; simple
LFU (Least Frequently Used)access 频率低 out, 小概率 useParamsLruBut disfrSg little futurist.longterm stable data feed
W-TinyLFU (Caffeine default)window admission + count-min sketch frequency tracking综合最佳 balance, popular access
ARC (Adaptive Replacement Cache)dual LRUs cache demand subtle 动态 lurkingcache, e redirects
Random Replacementrandomly evictionAcad, non-cond. Yes actual 留 still奇
FIFO时序 first-in-outqueue-based (Memcached simple)

Persistent Cache vs In-process-only

persistent to disk 留流 SQLite intermediate map from/to serialize— but 是 capable container/useful AWS Memcached Elasticache persistent AOF.


三、OS Page Cache (Page Cache, Filesystem Cache)

Linux 内核 transparent cache aggressive: every read() / mmap() 在 文件内容在内存 (空闲 RAM) 保留. Subsequent 念重读到 cache.

  • DB WAL ddl 生效, page cache 确被部分数据 buffer 上启平台 no fsync.很多时候商业先软件 reference fsync 只保存 WAL. fsync 时 数据 page cache flushed.
  • Postgres RAM-based shared_buffers 尽 25% RAM 配. OS page cache 又填 75%, DB cache + OS cache 可叠加 metricsutors 经典是 老陈正常 4 Ghetto cache.

Direct IO bypass

open(path, O_DIRECT | O_SYNC);

PostgreSQL 9.6+ supports cheap. 用 O_DIRECT 与 raw I/O ok DB cache table magic no OS drive.(Buffer Pool).

InnoDB innodb_flush_method=O_DIRECT 绕 page cache.


四、Redis Cluster (Remote Cache)

Single Redis vs Redis Cluster

  • Standalone: 单实例, RDB/AOF persistence, replication async to slave 主备
  • Sentinel: 复 sentinel fa over
  • Cluster (3.0+): 自动 sharding 用 consistent hashing + Redis hash slots (0-16383) for hash-ring distribute. Multi-AZ HA: each hash slot 主 + N replicas.

Architecture

client → route hash_slot = CRC16(key) % 16384 → which node primary
node forward lightweight (MOVED/ASK redirection)
minimum cluster = 3 nodes (3-primary + 3-replica)

Redis cluster 6 nodes 起 = 3 primary + 3 replica, 自动 fail-over (gossip protocol within cluster).

Performance

  • 单实例 100K+ QPS (单 thread, 6.0 IO threads allowed)
  • Cluster horizontally scales, but cluster routing adds ~1ms for first MOVED operations.
  • Sub-millisecond typical合理 hit ratio high.

Weaknesses

  • 集群 failure 窗口 直之 可写入 11 cold client。
  • 异步复制 (master-ack client), 异 async makes synchronous feel 该 data lost 实现 rare. 5-10 秒 might不住.

Redis Lua Scripting

Server-side scripting Redis (Lua语言): Atomic 历史&HUSE 的执行

  • EVAL script keys... args...
  • 原子 commands Lua single answer.
  • Common patterns: rate limiter (token bucket), conditional update.

五、Memcached

相比 Redis, Memcached 多线程 + 性能 throughput 超单 Redis. dedicated.

DimensionMemcachedRedis
ThreadsMulti-threadedSingle-threaded (6.0 IO threads)
PersistenceNORDB + AOF
Data structuresStrings onlyMany (strings, hashes, lists, sets, S-sets)
ReplicationNO (无 native), 仅 client-side takesMaster-slave cluster, replication, sentinel
HAManual (tooling)Sentinel + cluster failover aut
Cluster软 via consistent hash clientcluster protocol native
MemorySlab allocator (no fragmentation)jemalloc + eviction
Latencysub-mssub-ms
Throughputmulti-threaded version ~1M+ QPS possiblesingle node 100K QPS

Memcached 仍是 Wikimedia, Facebook, Twitter 使用重 cache service because multi-threaded throughput can be ~10x Redis (by Facebook they developed McRouter as client to consistent-hash shard Memcached cluster at scale).


六、HTTP Cache / CDN

CDN Edge Cache

Cloudflare / CloudFront / Fastly / Akamai POP 提供 edge POP 缓存 user-facing static content. Latency from POP ~5-30ms depending user distance.

Cache Headers

Cache-Control: max-age=3600, public
ETag: "..."
Last-Modified: ...
  • max-age tells cache TTL.
  • public/Private: public = shareable between users; private = browser only.
  • ETag 强制条件请求 (If-None-Match → 304 Not Modified).

TTL Strategy

  • Pure static (logos, fonts): max-age=1 year
  • JSON API responses: max-age=60s (eventual)
  • HTML page dependent on user: private cache with max-age=0 (revalidate always)
  • Web fonts require CORS (cross-origin resource sharing).

CDN Bypass

Surrogate-Capability header 让 client 知可 CDN bypass:

Cache-Control: no-cache (always revalidate)
Cache-Control: no-store (never store)
Cache-Control: private (browser only)

Stale-While-Revalidate

Cache-Control: max-age=600, stale-while-revalidate=86400

让 cache 在 max-age 过期后 还可 serve stale content 24h, async revalidate.

CDN Surrogate-Control

Edge-specific cache头:

Surrogate-Control: max-age=86400
Surrogate-Key: article-12345

让 origin invalidate specific edge cache by key (Surrogate-Key: article-12345) without touching user browser cache.

CloudFront Response Cache by Path

CloudFront cache by full URL path (path + query string) 可控:

  • default: full URL
  • multi headers: 防 cache dilution.

Cloudflare 类似.


七、Cache Hit Ratio 体系

计算:

$$\text{hit_ratio} = \frac{\text{cache_hits}}{\text{cache_hits} + \text{cache_misses}}$$

Trade-off:

  • 95% hit ratio 看 seems high; backend load 5% — much less than cache.
  • 99% hit ratio reduce 99% load to 1% backend load, huge.
  • 99.9% hit ratio = 0.1% backend = critical services;
  • 99.99% hit ratio rare, requires extensive cache +精密 TTL + hot-key tuning.

Inverse formula

$$\text{backend load} = (1 - hit _ratio) × total _traffic$$

心 SMELL 测试: backend load 更便宜 theta 性 平台latency I/O, must ensure hit ratio high enough 成 path capacity.


八、典型 Use Cases

Twitter Hot Account Profile

1M live cache hits / sec cache behind nth latest profile read. 1.5GB working set current active million users (3 KB per profile).

Netflix Open Connect

视频 content cache at CDN edge. ~100MB movie several CDN POP serve for trending cataloge. Bandth巾 ~ Petabytesserved. Cache edge = (TB per) POP. Origin not touched. CDN provider. 11/9 durability.

E-Commerce Product Page

For each click, page composed of:

  • Product info (redis cache TTL 60s);
  • Inventory number (postgres read-through TTL=5s);
  • Recommendations (cached batch in 1s + tag queries from storage)
  • Reviews cached 60s

Typical page latency: ~20-50ms from subsystem caches 各 layer.

GitHub repo page

cache ~ per repo info (your repo activity), 5min TTL. cache ready push back update invalidates + invalidate on push (webhooks 写 post-cache + push invalidation stack).


九、Capacity Planning

Cache size = working_set × budget_factor.

  • working_set = active users / active items你需要 cache.
  • budget_factor = 1.5 for 实际 write+read膨胀 not real working set.

Once growth exceeded cache size, evictions spike and hit_ratio drops abruptly.

Monitoring Metrics

  • eviction_rate — memory pressure
  • hit_ratio per cache tier
  • latency_p99 per cache tier

十、典型事故

Twitter 2012 cache failure storm

并没有 hot Twitter multipagecache size. Cache cluster auto 重启一次性失败 evicted all hot keys, 缓存miss → backend load倍 10×. Backpressure blocked cache fill from origin. Fix: gradual Redis cluster 加 extended TTL.

GitHub Pages cache invalidation

GitHub 失不复 User Warehouse role 触 valid cache headers in past and als (actually legitimately re-attached) user repo access SSH pull before cache key. 缓存pre-shipment dict-configuration with Redis 配- hit旧 issue maintenance. cache-r地段 fixed.

Facebook Memcached Thundering Herd

Facebook beam cache miss for single key contributions spawn deployment caches of same values = 1000 parallel computations of value. Fix: Token lease每 cache key, single 是 worker process recompute other wait.


十一、易错清单

  1. Cache 大不等于 hit ratio 大: hit ratio 与 working_set 大小 ratio + 失效策略 + sequence access patterns 息息相关.
  2. 不要把所有 keys 都放 in-process LRU+过期多: memory size 8% of 进程 max is h大. Eviction spikes if abuse
  3. In-process cache + multi-instance issue: multi-replica divergent cache — must include coherence layer (pub-sub to invalidate).
  4. Cache miss for large file triggers thundering herd (multiple request simultaneously computing same value): Solutions: lock-and-fetch single worker pattern or use stale-while-revalidate.
  5. Redis Cluster size 5 nodes minimum for production HA: lower than 3 primary 3 replicas (6 total) 风险 RPO > 0 after async replication lag.
  6. 不要 cache "hot row" with infinite TTL: hot content changes 偶尔变 so cache stale 会被 client 不要 把 always fresh extract e.g. always to fetch stored in raw.

十二、这一章带走的东西

  1. Multi-level cache stack: CPU L1/L2/L3 → OS Page Cache → in-process (Caffeine) → Redis → Remote CDN → origin.
  2. In-process cache 通常 LRU/Window-TinyLFU eviction; remote cache 通常 Redis single cluster + cluster replicate 写写入写异步.
  3. CDN 缓存 hot static (images, fonts, video); edge POP 直接服务 user, 缓 backend 80-99% load.
  4. Cache hit ratio exponential: ✅; 99% hit 比 95% hit 后端 load 减 5×, 99.9% 减 50× more. e. doubling a hit ratio reduces backend load by near factor.
  5. Thundering herd: multiple 同时 miss = thundering herd; 同 1 worker fetch + others wait 应避免.

下一节 → 缓存模式

消息队列与异步

异步 + 队列让 services 跨进程解耦: producer 不需等 consumer, 只需 publish event. 系统 throughput 升, 跨服务失败延迟宽容 (retry/重投)。常见反例: 用 webhook 同步调用上游 service, 慢一 service 拖垮整个链。 用 message queue 改"at-least-once + idempotent"是核心 ID 化防止重复消费。

Kafka 内部与生产实践

TL;DR

Apache Kafka (LinkedIn 2011 → Apache) 是 streaming "log-based"message broker, 不是传统 queue。 核心数据结构是 append-only partitioned log, 与 RabbitMQ / ActiveMQ 等 message-queue 不同。 Kafka throughput 100MB/s/partition scale 1M messages/sec, 7 年 SLA durability, 跨 DC mirrors, transactions 0.11+ exactly-once 的话 提供。

本章扫 Kafka core API 与 topology:

  • Producer (idempotent, transactions)
  • Consumer (group coordinator, offset commit)
  • Broker (log segment, ISR, replication factor)
  • ZooKeeper / KRaft (controller metadata)
  • Topics / partitions / offsets
  • 生产最佳 practices 配置, 典型事故 (consumer lag, hot partition, KRaft metadata loss)。

一、数据模型

Topic + Partition + Log Segment

Topic "OrderCreated":
  Partition 0: log segment 1 → segment 2 → segment 3 → ...
                  offset 0-N         offset N-2M    offset 2M-...

  Partition 1: log segment 1 → ...
  ...

每 partition 是 append-only sorted log. message 在 partition 中有 monotonically increasing offset, consumer 流动组 consumer group offset committed.

Replication

每 partition 有 N replication factor (default 3). One 是 leader + N-1 followers. ISR (In-Sync Replicas) 是同步中的 followers. producer 默认 acks=all 等全部 ISR 收到. min.insync.replicas=2 强制 majority write.

Segment

  • 每 segment size 默认 1GB (configurable). rollover based on segment.bytes / segment.ms.
  • 写 append, 不可改 (append-only).
  • 删除 delete 是 log retention policy by time/size.

Offset

  • Producer side: record send 一个 in-partition offset.
  • Consumer 根据 group coordinator offset commit 在 broker 持久.
  • old data retention 期限 cleanup 后 offset rebase 受 compression.

二、核心 API

Producer

Properties p = new Properties();
p.put("bootstrap.servers", "kafka-1:9092,kafka-2:9092");
p.put("acks", "all");
p.put("enable.idempotence", "true");          // Kafka 3.0+ default
p.put("compression.type", "lz4");
p.put("linger.ms", "5");                       // micro-batch 延迟 batch
p.put("batch.size", "32768");                 // per partition batch 32KB
p.put("max.in.flight.requests.per.connection", "5");        // idempotent producer 必 ≤5

KafkaProducer<String, String> kp = new KafkaProducer<>(p);
kp.send(new ProducerRecord<>("OrderCreated", "order_123", "{\"id\":123}"));

关键参数:

  • acks=all: leader + ISR 全 都写→ acked. 生产 default best practice.
  • enable.idempotence=true: 用 PID + sequence number 防重复 (Kafka 3.0+ default)
  • linger.ms=5 + compression.type=lz4: 把 latency 可接受 5ms 用 batching 提升 throughput
  • batch.size=32KB: per partition batch buffer, 让 micro batching 际 throughput 3-10×
  • max.in.flight.requests.per.connection ≤5: idempotent producer 限制

Consumer

Properties p = new Properties();
p.put("bootstrap.servers", "kafka-1:9092");
p.put("group.id", "ShippingService");
p.put("enable.auto.commit", "false");                  // 手动 commit 必须的
p.put("auto.offset.reset", "earliest");                // first start 从 head 读
p.put("isolation.level", "read_committed");            // 只看 commit 数据
p.put("max.poll.records", "500");                      // 每批 max 500

KafkaConsumer<String, String> kc = new KafkaConsumer<>(p);
kc.subscribe(Arrays.asList("OrderCreated"));
while (running) {
    ConsumerRecords<String, String> records = kc.poll(Duration.ofMillis(100));
    for (ConsumerRecord<String, String> r : records) {
        process(r);                                            // 必 idempotent
    }
    kc.commitSync();                                  // manual commit after processing
}

Consumer Group + Partition Rebalance

  • 同 group.id 的 consumers 共享 partitions (every 一 partition 分 配给 一 consumer in group).
  • 添加 / 删除 consumer 时 consumer group rebalance. 由 group coordinator 在 broker 上 推 redistribution.
  • Consumers 从 old partition commit 持 停止, 新分配 后,to fetched new partition last commit offset.

Caching problem: ethereal_and PollingLatency

Kafka 0.10.2+ 引入 poll(Duration) 流式 client 非 `poll(long). 大版本 0.x ↔ 2.x 兼容掉加的 consumer.";


三、架构

flowchart TB
    ZK["ZooKeeper (或 KRaft mode Kafka 3.x+)"]
    B1[Broker 1]
    B2[Broker 2]
    B3[Broker 3]
    ZK -.controller metadata.-> B1
    ZK -.controller metadata.-> B2
    ZK -.controller metadata.-> B3
    B1 -- ISR replication -- B2
    B1 -- ISR replication -- B3
    P[Producer] --> B1
    B1 --> C1[Consumer 1<br/>group A]
    B1 --> C2[Consumer 2<br/>group A]
    B1 --> C3[Consumer 3<br/>group B]

Broker

  • 每 broker 是 java/kafka process. cluster 3-100 brokers 常见.
  • broker 持 local log partition files (segment 1GB each).
  • follower broker replicate from leader partition.

ZooKeeper (传统) vs KRaft (modern)

Pre-Kafka 2.8: ZooKeeper cluster (3 / 5 nodes) 持 broker 注册 + controller elect + meta data. Kafka 2.8+ KRaft mode 让 Kafka 内部 Raft 接管 metadata, 免 ZooKeeper dep. Default 3.3+ production ready; 弃 ZooKeeper 推 adoption.

KRaft: 一个 controller quorum (3 / 5 brokers) 包 Raft leader replicate metadata. Other broker 不持 metadata state, 只读写 controller leader.


四、Core 谢

ISR = In-Sync Replicas

Leader maintain ISR (within同步 followers). Configurable 延迟 ceiling replica.lag.time.max.ms (default 30s).

If ISR size < min.insync.replicas, producer acks=all request fail with NotEnoughReplicasException.

Log Segment Compaction

Kafka 支持 log compaction (besides time retention). Topic级别 cleanup.policy=compact:

  • For every key, latest value retained; old version 前提 publisher data removed.
  • Compaction runs in background.

实用 cleanup.policy=compact,delete: time-based cleanup + compact latest-key.

Kafka Write Path

  1. Producer send batch → leader broker.
  2. Leader append to local segment log.
  3. Leader send responses parallel to ISR followers (each follower 写到自己的 local log 后 ack).
  4. Leader 收齐 ISR ack → commit, reply producer acked offset.

Kafka Read Path

  1. Consumer poll() to leader broker.
  2. Leader fetch from local log by committed offset.
  3. Consumer process + manual commit offset metadata internal Kafka topic __consumer_offsets.

五、性能数字

维度Performance
单 partition throughput100MB/sec = 200K msg/sec (1KB avg)
100 partition cluster10GB/sec
生产 P99 latency (acks=all replication factor 3 with same-DC)5-15 ms
持久 storageunlimited disk capacity ($/GB NVMe driven)
metadata 状态ZooKeeper: 5K partitions max; KRaft: more

Tuning Suggestions

  • partitions per broker ~ 4000 max each (moreнаб detrimental metadata overhead)
  • replication factor = 3 cross-rack
  • min.insync.replicas = 2 (3 cluster leader + 2 followers 写入)
  • producer batch size + linger latency for throughput
  • consumer fetch.min.bytes / fetch.max.bytes 大小 batch prioritize throughput

六、典型使用

Click Stream Ingest

  • 网页events 通过 lib / SDK producer batched at client → kafka topic clickstream
  • batched microsecs second-by-second system

Logging Aggregation

  • 客户 services log via fluentd / vector → kafka topic logs
  • consumers like ELK / Loki aggregate 提取 + 索引 + store

Stream Processing

  • Kafka Streams (lib) 微服务 直接 stream application
  • Apache Flink / Spark Streaming 从 Kafka topic consume + 写回 Kafka (exactly-once through transactions)

CDC Replication

  • Debezium capture PostgreSQL binlog → Kafka topic pg.dl.orders
  • multiple downstream services subscribes 同 topic for read models / cache invalidation / search indexing.

Async Email Queue

  • user service enqueue events 这里 → 队群 heterogeneous哮 events + content here

ChangeStream / Cache Invalidation

  • Orders service outbox events → Kafka topic cache_invalidation → cache services subscribe → invalidate cache layer.

七、典型事故

Consumer Lag Storm

Kafka 论区某 consumer 处理慢前事 (slow component); partition 0 lag >100k → backpressure automated scaling. Fix: HPA on kafka_consumer_lag alertmenus + workers scaled out.

KRaft Metadata Loss

KRaft 3.3 升 级. fix acknowledge moments before nodes 在 initial 不不为 metadata 似出 失去数据. 升级 3.3 3.4 3.7 late fix in KIP-866.

Producer idempotence 0.11 not # late (specific was 3.0+)

Many systems produce 1.1+ transaction. productid supplier detection. k.committed_batches.limit issue Retri with local. Fix: require idempotence; default 3.0+.

Kafka performance degrade after # abnormal partitions

某系统 200+ partitions per topic, metadata overhead slowed job corelogv lag spike. Fix: partition count = 12, batches per minute improved.

Kafka Connect overflow deadlock handoff

Kafka Connect 产生 high throughput + worker restarts → connector pause to 略 failover. Fix: connect_task_shutdown_graceful_shutdown_ms 增 + DLQ monitor persisted.


八、生产 best practice 关键参数

Producer 优先级 (throughput + durability)

acks=all                      # production default
enable.idempotence=true       # 防 duplicate send
compression.type=lz4          # 节约 BW
linger.ms=5                   # 微 batch
batch.size=65536              # 64KB per partition batch
buffer.memory=16777216        # 16MB producer buffer
retries=2147483647            # 无限重试 with idempotence
delivery.timeout.ms=120000    # 2 min total timeout
max.in.flight.requests.per.connection=5     # 必须 idempotent producer limit

Consumer 优先级

enable.auto.commit=false      # 手动 沙拉 commit 后 commit
auto.offset.reset=earliest
isolation.level=read_committed   # 只看 commit
max.poll.records=500
max.poll.interval.ms=300000       # 5min processing budget err重磅 避免 rebalance kick
session.timeout.ms=10000         # heartbeat net time
heartbeat.interval.ms=3000       # rageheartbeat

Broker 优先级

log.retention.hours=168          # 7 days retention
log.segment.bytes=1073741824     # 1GB segments
num.network.threads=3            # network threads (CPU/-count)
num.io.threads=8                 # IO threads
socket.send.buffer.bytes=102400
socket.receive.buffer.bytes=102400
queued.max.requests=500
replica.lag.time.max.ms=30000   # 30s ISR lag tolerance

九、Partition Sizing

Use caseTopic partitions单 partition throughput
Clickstream100 per topic200K msg/sec
Transaction logN = N consumers (parallelism)1 MB/sec each
Slow stream6-12low throughput
  • partitions > 1000 可 heavy metadata load. partition-per-topic default 1000 ceiling.
  • partition count > broker count × 4 → metadata replication overhead in producer.

十、易错清单

  1. enable.auto.commit=false manual commit before 不错 ACK: 否则 missed processing 多 client.
  2. max.poll.interval.ms 必 > 业务 longest batch processing time: 否则 Kafka rebalance kick consumer active mid-processing.
  3. enable.idempotence=true since 3.0 default: 但 Kafka 0.11 legacy producer 必须手动 enable - check clients.
  4. acks=all + min.insync.replicas=2: 一 副本失 后 acks=all 失败 with NotEnoughReplicas. cluster leadership.
  5. partition count减少 不行 (一旦 create 减 partitions 不能再): 一旦 partition 0 to N exists, reduce N除 problems broker compatibility only manual + recursion non-trivial.
  6. num.network.threads < network-concurrency problems: traffic spike can broker unresponsive.
  7. out-of-range offset: broker 不再有 old offset but consumer commit old offset → reset auto.offset.reset=earliest 先后 看.
  8. transaction.allocation: transactional.id 是 per producer session uid, after restart 继续 idempotency evidence. 不 changing violations transactions.

十一、这一章带走的东西

  1. Kafka = log-based broker, partitions + log segment + ISR replication + consumer group + offset commit.
  2. Producer idempotence + acks=all 是 default production; max throughput via linger.ms + batch.size + compression.
  3. Consumer enable.auto.commit=false, max.poll.interval.ms (avoid rebalance kick), isolation.level=read_committed 是最佳实践。
  4. ISR + replication_factor=3 + min.insync.replicas=2 提供 HA + durability + serialization.
  5. KRaft (3.x+) 替代 ZooKeeper metadata serving metadata, 减少外部 dependencies.
  6. Log compaction 让 "key-as-current tail" 优 = e.g. "shopping-cart state" topic 仅保留每 user 最新 shopping cart data.
  7. 典型使用 + 异构 system ingestion: CDC ingest / logging / stream processing / cache invalidation / outbox delivery.

下一节 → 可观测性

消息队列语义

TL;DR

"Exactly Once Delivery" 是不可能由消息队列 单独保证 (除非队列做单 consumer 组合 + idempotent 处理)。 三 fantasy:

  1. At-most-once: producer 发出而 没收到 ack, message 可能未送达 consumer; consumer 可能没收到; 可能丢失
  2. At-least-once: producer 必重试 直到 acked, consumer 必重认. 可能收多次重复消息, idempotent processing 必可 bear.
  3. Exactly-once: 应用层保证 — consumer 幂等 + dedup, queue 提供单 partition ordering + offset commit.

工业实际至少 是 at-least-once + idempotent consumer. 标语 "exactly-once" 实际是 "exactly-once effects" through client-side deduplication. 本章扫 三种语义的协议细节, Kafka/Pulsar/NATS/SQS 公布的语义, 端到端 idempotency 设计 (idempotency key + dedup table + outbox pattern), 与典型事故 (双 at-least-once linked transfer 双扣款)。


一、形式化三种语义

At-most-once (丢消息容忍)

producer.send(msg):
   发出 -> 没 ack (fire-and-forget).

consumer.receive(msg):
   process(msg); ack;
   network fail -> 不重试来 → msg lost (MOST- ONCE 上限 receive一次。
  • 用例: telemetry, events log, real weather readings, fire-and-forget 通知 push 通知 (无 critical).
  • 实现: UDP 或 应用层 no ack. SQS exposes this 不 默认.

At-least-once (有 duplicates 可能)

producer.send(msg):
   if no ack in timeout -> retry.

consumer.receive(msg):  
   process(msg); ack;
   if crash before ack -> 重新 deliver to consumer/broker。
  • 用例: 大多 工 业 message queues (Kafka, RabbitMQ, Pulsar, NATS). default.
  • duplicate 要求 consumer 端是 idempotent.

Exactly-once (效果上)

  • Producer side: 不 重 复 (idempotent producer) Kafka 0.11+ enable.idempotence=true. 每个 batch 由 producer_id+seq_no 去重.
  • Consumer side: transactional消费 + commit一 offset, Kafka transactions 进 offset write 与 log append在同一事务. consumer read_committed 后 only see committed.
  • Effects: consumer 必 idempotent + queue 提 供 idempotence + transactional support.

This is the practical exactly-once 定义 — actual 1 time delivery 不保证, 但 1 time effects 保证. Network partition can still cause consumer to process twice in failure recovery; idempotency ensure action 一次.

"True once vs effective once"

Layer 1 delivers at-least-once + Layer 2 (consumer-side idempotency) ⇒ effective once:

  • idempotency key (unique business id)
  • dedup store (Redis SETNX with TTL or DB unique key constraint)
  • transactional update to multiple systems (projection update)

二、Kafka 语义

Producer 配置

  • acks=0: at-most-once (fire and forget).
  • acks=1: leader writes 后 ack; if leader fail before ISR 同步 → msg lost.
  • acks=-1 / all: leader + 全 ISR 同步 后 ack; RPO ≈ 0 with min.insync.replicas=2 + replication factor=3. At-least-once default since Kafka 3.0.
  • enable.idempotence=true (Kafka 3.0+ default): producer 内分配 producer_id + sequence number per partition, broker side 去重. Exactly-once send (无 duplicate).

Consumer

Kafka consumer 提供 "at-least-once" delivery: per-partition offset commit:

  • enable.auto.commit=true + auto.commit.interval.ms=5000: 自 commit 后 consumer crash 之前 实际 processing 可能 never finish干, 但 commit 已 send → loss.
  • enable.auto.commit=false + manual-sync commit After processing: duplicates possible if crash 后 process 后 before commit. ** Idempotent consumer required.**

Transactions (Kafka 0.11+)

Producer 以 transactional initTransactions() 在 Kafka注册 transactional.id, 与 read-process-write transactional pipeline:

producer.beginTransaction()
producer.send(topic_a, msg_out_1)
producer.send(topic_b, msg_out_2)
producer.sendOffsetsToTransaction(consumer_offsets)         # commit consumer offsets in same Txn
producer.commitTransaction()

让 consumer 链路 transaction 与其 read commit offset 都在一个 Kafka事务 中. 两件事原子。下 consumer isolation.level=read_committed 不见未 commit 数据 → 不重复读取。

Kafka Stream / kTable

Kafka Streams 内部用 transactions + 实际流处理 + state stores + offset commit 一事务 atomic → exactly-once processing guaranteed by lib. 没 idempotent library (像 plain client apps) 必须 ID 也自行 dedup.


三、Pulsar / RabbitMQ / SQS / NATS 对比

Pulsar

  • BookKeeper ledger 提供 ledger-level ensemble write 投统一, end-to-end shared storage, 加序号, acked message 可recovered.
  • "At-least-once" 默认; "exactly-once" 可以 subscribe unique consumer (shared subscription with name) + dedup at 正确.
  • Dedup with producer_name + sequence_id (Vue 系列 ...) üyes 直接支持

RabbitMQ

  • 默认 at-least-once, consumer ack manual.
  • message TTL, queue-level max size.
  • Generally not idempotent dedup, broker doesn't store 已处理 IDs. Application must dedupe.
  • supports dead-letter exchange 来 without retry with TTL delay.

AWS SQS

  • At-least-once default; consumer 处理 + delete API; visibility timeout default 30s 后未 delete → message reap-appears。
  • FIFO queue with deduplication: producer 提供 MessageDeduplicationId → 5 分钟内同 id 抑制 (exactly-once per 5min window). Good for low-volume critical events.
  • DLQ (Dead Letter Queue): max receive count exceeded → moved to DLQ for later analysis.
  • 部署 large scale is best practice with FIFO queue + deduplication_id + visibility_timeout 决定.

NATS

  • At-most-once by default (no ack).
  • NATS JetStream (newer) 提供 persistent + acks + at-least-once.
  • High throughput + log library.

四、Outbox Pattern (→ outbox.md)

外 trans actor 必须 update DB + send message queue atomic. 若 update DB 之后失败 ∈ queue delivery, 没 send. 若 send queue 之后 crash pre db commit, downstream 看到 event 但 DB 没 reflect, 是 service consistency break.

Outbox pattern 解法:

  • DB transaction 中既 插入 _outbox 表 一个 row = event, 与业务 row 同事务.
  • 异步 worker poll _outbox 表 → 发到 queue → 标记为 sent.
  • queue publish + outbox row delete 必须 idempotent (DB unique constraint event_id, queue dedup event_id).

也支持 CDC (Debezium 监 听 binlog + emit 到 kafka).


五、幂等 Consumer Pattern

幂等处理 是 at-least-once 工业基本 assume. Consumer 必记 processed event_ids / dedupe.

DB Unique Constraint

CREATE TABLE processed_events (
    event_id VARCHAR(64) PRIMARY KEY,
    processed_at TIMESTAMP DEFAULT NOW(),
    payload JSONB
);

-- Process:
INSERT INTO processed_events (event_id, payload)
VALUES (?, ?)
ON CONFLICT (event_id) DO NOTHING
RETURNING (xmax = 0) AS was_inserted;

如果 insert returns was_inserted=true, 是 first processing. 否则, 已处理, skip 但允许 commit/consume.

Redis SETNX With TTL

def process_safe(event_id, business_func):
    if not redis.set(f"processed:{event_id}", "1", nx=True, ex=86400):
        return "already_processed"
    return business_func(event_id)

short dedupe window via Redis TTL. 适合 bank transfer events.

Idempotency Key on Business Object

Use a stable business identifier as idempotency key:

transfer request: 
  idempotency_key = "Transfer|alice|bob|transfer_id_42"
  process:
    if key seen in records: return prior result
    else: perform transfer, store result

六、典型事故

Stripe duplicate transfer (双扣款)

2018 某 Stripe webhook 失败重试一次 service, customer 写 了 paypal — ID错过了而是 一次性 completed trans del Declared result 充枣 高加! Escape 失败 had 时光. 但 hashing request hash 减少 duplicate check format 等配. Fix: Idempotency-Key HTTPS required any writing API.

Kafka exactly-once "向前 duplicate" 2016

Some Kafka business batches write to next downstream outside Kafka, 该 downstream RECEIVE 更多 program 复制 message. 应用 transform. Fix: process inside Kafka transactional commit.

Twitter Fanout tail event dedupe

Each fanout event event_id 逐渐复杂性 同 actual server 引入 Redis SETNX. Hot spot. Fix: hash 各 partition agent id 同 by id, partition placement stable.

Event queue from processing fail 发送邮件 duplicate

发 user emails 后下 系统 处理回 callAck → consumer failures reassigned to worker duplicate. Payment providers往往 3 retries in DLQ. Idempotency ID store 邮件 list 总筛 duplicate.


七、易错清单

  1. At-least-once 是默认: 必 定 claim idempotency, 写 client + dedup 是 standard.
  2. Auto-commit 该 disable: manual commit after processing 避免丢.
  3. Kafka producer 的 idempotence + transactional stream 提供支持 transaction; custom processing 必须应用 idempotency.
  4. Idempotency key must be stable: 不要 hash current time, use 业务级 unique ID (e.g., order_id).
  5. Visibility timeout > max processing latency: else broker re-deliver original message duplicate. SQS visibility deafault 30s often too short.
  6. DLQ 监控必: failures 让 message autonomous bypass manual inspection; silent depute Significant old embed.
  7. Network partition: 多 send batch deliverCLient 拉 出 ATTEMPT 船 Rector多种 一次 思成功 2次 首项 准备 likelyhood client下次 写 际修复 logical input 干.
  8. Ordering与delivery 重: 实部分 ordering not scaling well. 一 个 partition 是强 order 多 partition 全局 sorted impossible guarantees (sync only).

八、这一章带走的东西

  1. At-most-once (fire-and-forget), At-least-once (retry until acked), Exactly-once (idempotent + transactional).
  2. 现实生产 多 At-least-once + Idempotency ID dedup; Exactly-once 通常 应用层自行实现.
  3. Kafka producer idempotence + transactions + consumer offsets in transaction 提供 end-to-end true exactly-once.
  4. SQS FIFO with MessageDeduplicationId + Visibility Timeout; RabbitMQ manual ack; NATS JetStream persistent.
  5. Outbox Pattern 让 DB + queue 同 事务 并 atomic; CDC替代 outbox table.
  6. 应用层 idempotency: 用户 + Event ID + Redis SETNX + DB unique constraint event_id.

下一节 → Outbox Pattern

Outbox Pattern

TL;DR

服务做两件事: (1) 更新数据库, (2) 发消息队列. 但是两件事跨进程 atomic 不可能——没有 in-process 两阶段 commit(即让 DB commit 与外部 message queue send 在同一原子单元同步)。 如果 DB 先 commit + queue send 失败, downstream 永远没消息; 如果 queue 先 send + DB fail, downstream 看到消息但 DB 未更新。 都是数据不一致。

Outbox Pattern: 业务 transaction 内 → 同事务 insert outbox table → DB commit 后异步 worker poll outbox table 把 events send 到 queue + mark as sent。 "Outbox 表与业务表在同一 DB transaction → 它们 atomic". 单 service 内 consistency + 跨 service availability 解耦。


一、Pattern

应用 transfer(from_id, to_id, amt, event_id):
    # begin DB transaction
    UPDATE accounts SET balance = balance - ${amt} WHERE id = ${from_id};
    UPDATE accounts SET balance = balance + ${amt} WHERE id = ${to_id};
    INSERT INTO outbox (event_id, event_type, payload)
        VALUES (${event_id}, "Transfer", {...});
    # commit (atomic)

后台 worker:

loop:
    rows = SELECT * FROM outbox WHERE sent_at IS NULL LIMIT 1000;
    for row in rows:
        try:
            queue.send(topic, row.payload, idempotency_key=row.event_id)
            UPDATE outbox SET sent_at = now() WHERE event_id = row.event_id;
            COMMIT;
        except:
            backoff() retry

外部效果:

  • 不丢失 (DB 与 outbox 同事务 atomic)
  • 不重复 (queue 发送 idempotent — idempotency_key 防重复 send)
  • 至少一次 (worker 持续 poll 直到 success)

二、CDC (Change Data Capture) 变体

经典 outbox 用 application-level background worker poll outbox table. 现代 push-based CDC 用 DB transaction log 监听实时事件流。

Debezium

  • 实时监听 PostgreSQL WAL / MySQL binlog / MongoDB oplog, 写入 Kafka Connect
  • 应用只需写业务表 + outbox 表; Debezium 自动把 outbox row 变成 Kafka event
  • 满足 lower latency + better robust

Pros vs manual worker

  • No application code changes: worker 不在 application 中, CDC engine 自动处理
  • Lower latency: binlog tail 实时, 比 poll 接近实时
  • Robust: binlog replay from any LSN 恢复避免丢; worker crash 不丢 job

Cons

  • 依赖 Debezium platform (Kafka Connect 部署 + 维护成本)
  • 复杂事务 log (binlog interpretation 需要理解 row + 关系)
  • coupling with DB engine: WAL / binlog format changes with版本, Debezium 需跟随升级

Kafka Connect DLQ

CDC source connector 配置 errors.deadletterqueue.topic.name 后 CDC failures 上 DLQ topic, 让 DLQ monitor/根 analysis。


三、At-Least-Once Worker

Worker 必须 strict + idempotent.

loop polling:
    rows = SELECT event_id, payload FROM outbox
           WHERE sent = false
           FOR UPDATE SKIP LOCKED
           LIMIT 100;
    -- PostgreSQL: FOR UPDATE SKIP LOCKED 让多 workers 并发处理不同 row
    foreach row in rows:
        try:
            queue.send(topic, payload, key=row.event_id)
        except Exception:
            log("queue down, retry later")
            continue        # 不标 sent=true, 下次 iter 再发
        UPDATE outbox SET sent=true WHERE event_id = row.event_id
        COMMIT

FOR UPDATE SKIP LOCKED 是 PostgreSQL 9.5+ 并发 select pattern 让多 worker concurrent grab 不同 rows. MySQL 8.0+ 同; Oracle 与 SQL Server 也有 equivalent (READPAST)。


四、Idempotency Key + Ordering

发送 ID 等业务 event_id, queue 端去重:

  • Kafka 通过 producer_id 提供 produce-side sequence dedup, application-level event_id 是更业务级的 idempotency key
  • 发送时用 event_id 作为 partition key 路由 Kafka partition: 同一 entity (e.g. user_id) 的 events 全落同 partition → Kafka ordering within partition 保
  • 同 entity 必 serial (用户 account transaction 序)

跨多 partitions 排序不 保证. 若业务需要全序则单 partition 限制 throughput.


五、常见 5 种 worker

1. In-process background thread (simple)

  • 同 server 程序内一个 worker 定时 poll outbox
  • 弱点: 重启 server 时 worker停 ~

2. 独立 worker pool (high availability microservice)

  • deploy 单独服务 worker pool 并发消费 outbox
  • 每个 Pod 用 SKIP LOCKED 抢 work item
  • 限 worker 数 = max DB connection (e.g. 10 connections)

3. CDC (Debezium / Kafka Connect)

  • 独立服务 attach DB binlog → emit Kafka topic
  • 满足 lower latency + better robust
  • external deploy

4. Cloud platform: EventBridge / SQS / Lambda

  • AWS Lambda 定时 poll outbox 自动 send 到 EventBridge / SQS
  • 原生 cloud support with failover

5. Multi-instance + consistency

  • partition by entity_id 让同 entity processed serially; but multi instances 仍并行 between entities
  • 复制 worker into partitioned jobs with consistent hashing for backlog entities

六、典型使用例

Order Service -> Shipping Service

订单 service 创建 order commit; outbox row emit "OrderCreated" event; Shipping service consumes + 创建 shipping record. Idempotent on order_id.

User Account -> Email Service

User signup commit; outbox row emit "WelcomeEmail" event; email service consumes. Idempotent on user_id + skip duplicates.

Twitter Tweet Created -> Followers Fanout

Tweet service commit; outbox row "TweetCreated" event; Fanout service consumes 推到 followers' timeline cache. Idempotency on tweet_id; partition by author_id for ordering.

Stripe Webhook -> Billing Update

Stripe sends webhook to customer billing system. Billing system insert outbox "Stripe.Event.WebhookReceived" event → 后台 worker persist state, detect idempotency via Stripe event_id + skip duplicates.

Paypal Payment -> Fraud Detection Service

在 PayPal commit + outbox event; fraud detection 仅 idempotency (after analysis 避双 score revert).


七、典型事故

Worker crash, messages lost

某公司 worker thread embedded in app process; deployed new build → container kill worker mid send with outbox row marked unprocessed. 后台 not recovered, lost ~ 50 messages in flight. Fix: transactional outbox status 的 flow diagram + worker 状态 mask 处理 commit-后 , 并让 update outbox sent=true 操作精确 reflect queue deliver ACK。

Outbox grows huge

某用户 outbox retention 太长 (30 天保留) 导致 outbox 表数百 GB; worker 跟不上 batch send 量. Fix: outbox 在 row sent 后 brand job TTL preserve shorter,定期 cleanup 表 + 压 较 archive 表 之的政策.

Repeat consume duplication in Kafka 消费者 未用 idempotency key

消费 log 不 dedup 设置; duplicate events 导致 后端 subscribe 收到 500+ emails duplicated.

Fix: 客户端 insert idempotency unique key in row → INSERT ignored. Stripe webhook: event_id unique index防重复入.

Worker 处理过慢 outbox 饱和

某公司 orders service high peak during 11.11 holiday, outbox worker 跟不上发 send rate → outbox rows accumulated → outbox 表 disk 满. Fix: worker 数量按 peak 5× capacity scaling + binlogs CDC 取代 poll-based.

Race Duplicate Enqueue

worker1.poll(): gets row 5
worker2.poll(): gets row 5      # worker1 crash before SKIP LOCKED OR predates SKIP LOCKED patch
worker1 recovers → sends row 5
worker2 sends row 5             # duplicate send

Fix: SKIP LOCKED 是 required ALWAYS; worker 必有 FOR UPDATE SKIP LOCKED SQL clause.


八、易错清单

  1. worker 必须 idempotent: 不能简单发包; event_id 作为 idempotency key + queue dedup (e.g. MessageDeduplicationId 在 Amazon SQS)。
  2. FOR UPDATE SKIP LOCKED 是 PostgreSQL 9.5+ / MySQL 8.0+ feature: 句不能在 older DB 上安全跑; 必使用 SELECT ... LOCK IN SHARE MODE 或 row-version 二次检查.
  3. SELECT FOR UPDATE SKIP LOCKED 必 required: multiple workers 并发 unblocking concurrent 参数 set 跳避免 双 worker 处理同 row。
  4. Sent at / marked sent 不 atomic with outside queue send: queue send 后才 mark sent; 在未 mark sent 前 send 成功与 mark sent 之间 crash → 重发故 mark sent=true POST send ack 后立刻 UPDATE.
  5. Outbox 表保留 太长必爆炸: 实践 30天 archive 后 cleanup. 配置 retention 持续 monitor。
  6. Multi-partition 跨序业务: Kafka ordering 是单 partition; cross-partition 全序顺序 非 guarantee. 业务依赖全序必 single partition (限制 throughput).
  7. Use event_id作 partition key: 不要 user_id+timestamp 自乱 partition distribution partitioning 或 partition reliable。

九、这一章带走的东西

  1. Outbox Pattern 让 DB + queue transactionally atomic in single-service setup. CIIC 接受用 polling + worker + SKIP LOCKED pattern。
  2. CDC (Debezium) trend: 流式 emit binlog → Kafka (低 latency, transactional robust, complexity).
  3. FOR UPDATE SKIP LOCKED 让 multi-worker concurrent polling 不冲突, PostgreSQL/MySQL/SQL Server 都支持。
  4. Idempotency Key on event_id 是必须的: 让 consumer idempotent dedup, queue MessageDeduplicationId 支持 AWS SQS FIFO, Kafka producer_id 提供应用层 dedup。
  5. Kafka partitions + ordering: 同 entity events 同 partition 保序; cross-entity 不序需业务iidempotency。
  6. 典型工程: order service → shipping; user signup → email; tweet → fanout; payment → fraud——所有这些需要 outbox pattern 与 idempotency 保证 consistent + reliable fan-out 之服务架构 correctness.

下一节 → Kafka 内部与生产实践

扩展与可用性

Scale 不是 "加机器", 而是通过 sharding / replication / multi-region / resilience patterns 让系统在 10× 流量下仍 SLA-compliant。 本章包含:

Sharding + Replication 实践

TL;DR

Scale 的 two axes: write scalability (sharding) + read scalability (replication). 本章以 concrete 案例讲解 怎么拆一个单机 Postgres 到 100 节点 cluster:

  • Sharding: 按 hash / range 切 data, 跨多 instances, 让写 throughput 线性增长.
  • Replication: 每个 shard 多副本 (leader + followers) 承载 read traffic, 热备 failover.
  • Combined: CockroachDB / Spanner / Yugabyte / TiDB 是 sharding + replication natively.
  • Caching + CDN: 加 读 cache (Redis + CDN) further 降 read load.

本章以 Twitter / Instagram 为实例, 讲 shard key 为何是 user_id, 不 hash(ad_id). 与 pitfalls: reshard migration, 跨 shard transaction, hot shard。


一、单机到 Sharded 集群

路径

Phase 1: 单机 Postgres (16 core, 256GB RAM) 
  → 5000 write TPS, 50K read QPS OK

Phase 2: + read replicas (1 primary + 2 replicas)
  → reads go to replicas, write still bottleneck at primary

Phase 3: + Redis cache layer
  → cache hit 95%, write 仍是瓶颈

Phase 4: Sharding by user_id (12 shards)
  → 写 60K TPS, 读 600K QPS shared across scaling

Instagram 走完 12 shard, 为什么 user_id 是 shard key:

  • Most queries = per-user feed / profile / timeline
  • user_id sharding → single-user data co-located on same shard
  • Cross-user join (rare) → cross-shard read async 后合并

二、Shard Key 选型 checklist

考虑Hash 随机分布Range 按序分Directory 自由映射
均匀分布❌ hot range at 最新✅ manual rebalance
范围查询高效❌ cross shard✅ 同 shard✅ depends on mapping
Hot key⚠ single hot key hits one shard❌ latest time hits same shard✅ manual split hot key
Add node❌ rehash all data (no consistent hash)✅ split range 迁移少✅ directory 重 map 轻
Consistent hashing✅ 仅 迁移1/N data❌ 必须 有 cohesive mechanism

实际选型:

  • Twitter timeline: user_id % 4096 — hash shard, scale via 4K shards.
  • HBase region: range by row key — 时限扫 批量 efficient.
  • Vitess: auto-shard, directory-based (vtgate lookup route).

三、Replication / Topology

单主模式 (leader-follower)

单 shard 内 leader + 2+ followers。 leader 承载 write, followers 承载 reads.

  • 若 leader down → Raft/Paxos elect new leader (5-10s).
  • RPO = ack to client after fsync+半数. replication lag reads maybe stale.

多主多 region

CockroachDB multi-region 表:

  • PRIMARY REGION "us-east" all table 在 该区域
  • REGIONAL BY ROW: each row across regions based region key
  • GLOBAL 表: table 跨 all regions 一致 replicas

链式复制 Chain Replication

Head → middle → tail (写入顺序, 仅 tail 可读). 低 latency reads, 单 tail throughput bottleneck. 用于 strong consistency read 线.


四、跨 shard transaction

2PC via XA (2 Phase Commit)

XA START 'tx1';
UPDATE accounts_1 SET balance = balance - 100 WHERE user_id = 42;
UPDATE accounts_2 SET balance = balance + 100 WHERE user_id = 99;
XA END 'tx1';
XA PREPARE 'tx1';
XA COMMIT 'tx1';

Pros: Strict ACID; Cons: latency ~10-50ms, 2PC 阻塞 coordinator 崩 死锁。

Saga Pattern

每个 shard update is local transaction + 补偿 undo event.

Service A deduct (shard 1). commit event.
Service B add (shard 2). commit event ∈ kafka.
if B fails, emit compensation event to A.

CockroachDB cross-shard serializability

HLC + Paxos replication + 2PC (internally impl), 应用不用手写 2PC; ~80ms serially.


五、Reshard / Rebalance

Hash-Based With Consistent Hashing

consistent hashing ring + virtual nodes (150 vnodes per node per Riak).
add node → move ~1/N data.

Range Split

Shard starts [0, MAX]; 当 shard 负载 > threshold → split.
new shard hot until cache prewarm.

Vitess VReplication

  1. vreplicate双写 + binlog capture.
  2. 等到 slave caught up → 切新 shard 全读.
  3. remove old shard.

六、典型事故

Instagram 2014 reshard

Originally 12-postgres shard by user_id % 4096. 随着 user 增长, 单 shard 接近 max capacity. Upgrade 12→24 requires a massive data migration; Instagram did it via logical replication + 临时读写两 方案。 migration took 6 months planning.

Uber Schemaless / Holodeck Sharding

Uber 的 early 阶段 used hash sharding 后 由于 shard 数少 hot shard 叠加 让 drivers available 数写 慢。 created custom shard: partitioned by city_id + user_id 双 层 shard.

Figma: no sharding at all

Figma uses single PostgreSQL instance (2021 blog) for up to 1B+ objects, relying heavily on Nginx + Redis cache + heavy horizontal read replicas. Write stayed moderate because most user actions generate local state that 未 persist each micro gesture.


七、易错清单

  1. Shard key 选择 必 基于 query pattern: 不能随便 hash(random) — 以后 查 单 user 跨 shard 太贵.
  2. Reshard 的 migrate cost 巨大: plan for "scale 2-4× what you need now" to avoid immediate migration.
  3. Cross-shard transaction is hard: 2PC 才 linearizable, Saga eventual, eventual 是 path correct 但 业务 onus 大.
  4. Read replica can become stale and cause user to see old data: 控制 replication lag; read-after-write consistency 必须.

八、这一章带走的东西

  1. Sharding = hash / range / directory; 选 key 基于 query pattern.
  2. Replication = leader-follower (单主) / 链 / 多主多 region.
  3. Cross-shard tx = 2PC (ACID) vs Saga (eventual), trade latency and consistency.
  4. Reshard with consistent hashing (move 1/N data) or range split (less data move).
  5. Instagram / Uber / Figma patterns confirm shard design dictated by product use-cases.

下一节 → Multi-region

Resilience Patterns (弹性模式)

TL;DR

Resilience = 系统在部分组件失效时仍提供 acceptable service 的能力。不靠 "完美系统没有故障" 的幻想, 而靠 degradation + fallback + circuit breaking + retry + timeout + bulkhead + rate limit 等工程师组合拳。Netflix Hystrix / Resilience4j 是把这些抽象 成 lib 的原型。本章梳理 8 个核心 resilience pattern, 实现方式 (Go / Java / Rust), 调参注意事项, 与典型事故 (circuit breaker too sensitive → 全部请求 reject, retry storm → self-DoS)。


一、Timeout

为什么重要

每个下游 call 必须有 timeout。 无 timeout → 线程无限 wait → 资源耗尽 → cascade failure。

什么值

  • 下游 service P99 latency (假设) ~200ms
  • 设 timeout = P99 * 2 = 400ms (够 99.9%)
  • 对于 分 bucket service: 设 adaptive timeout: dynamic observe recent P99 + buffer.

Adaptive Timeout (Google gRPC)

ctx, cancel := context.WithTimeout(ctx, 800 * time.Millisecond)

Connect vs Request timeout

Timeout影响
connect timeoutTCP握手 e.g. 50ms
request timeoutfull call inclu data e.g. 2s

二、Retry

幂等必须

所有 retry 要求 idempotent operations: 不 double-pay. Use Idempotency-Key header in HTTP/gRPC.

Retry策略: 固定间隔 / exponential backoff / 抖动

retry_1: delay 500ms
retry_2: delay 1s
retry_3: delay 2s (cap 5s)

避免 retry storm: 限制 max retry to e.g. 3。

Retry Budget

同一 service 同时 retry 有限 (e.g., max 1000 concurrent retry)。 否则 retry 让问题更坏。


三、Circuit Breaker

状态机

CLOSED → (failure count > threshold) → OPEN → (wait timeout) → HALF-OPEN
  ↑                                                   ↓ success
  └─────────────────────────────────────────────────────┘
          ↓ failure again: re-OPEN
  • CLOSED: 正常请求, 记录 failure.
  • OPEN: 短路 request, 快速返回 fallback (不 调用下游).
  • HALF-OPEN: 尝试 1-N probes; 成功 to CLOSED; 失败 to OPEN.

Java Resilience4j 示例

CircuitBreakerConfig config = CircuitBreakerConfig.custom()
    .failureRateThreshold(50)            // 50% 失败开闸
    .slowCallRateThreshold(50)           // 50% too slow also
    .slowCallDurationThreshold(Duration.ofSeconds(2))
    .minimumNumberOfCalls(10)            // 最少 10 calls before open能 estimate
    .waitDurationInOpenState(Duration.ofSeconds(30))  // 半开 30s
    .permittedNumberOfCallsInHalfOpenState(3)
    .build();

切换 fallback

@CircuitBreaker(name = "inventory", fallbackMethod = "getFallback")
String getInventory(String productId) { ... }

String getFallback(String productId, Exception e) {
    return cache.getOrDefault(productId, "OUT_OF_STOCK");
}

四、Bulkhead

定义

把 系统 资源 分 池: 每个池 限制最大 concurrency, 防止 某 path resources 耗完 → other paths starved。

例子 (Java线程池)

Inventory pool: max 20 threads
Orders pool:    max 30 threads
Search pool:    max 10 threads

如果 Inventory 高峰, 只有 20 thread耗尽, orders 仍通过。

Semaphore vs thread pool

  • semaphore bulkhead: limit concurrent calls but reuse same thread
  • thread pool bulkhead: full thread pool resource, heavyweight.

五、Rate Limiter

目的

保护 被调 服务: 单 user/clients call 不能 exceed limit (e.g., 10 req/s for search API).

算法

算法适用
Token Bucket通 bursty traffic; 每 1s refill N tokens max N burst
Leaky Bucket流出率固定, 队列满 drop
Fixed Window1s window 内 count 限制
Sliding Window更好的精确度 (Redis Sorted Set)

Token Bucket 实现

Tokens per second: 10 (API key ABC)
bucket max capacity: 15
per second: add 10 tokens

Distributed Rate Limiter: Redis + Lua script

local key = KEYS[1]
local rate = tonumber(ARGV[1])
local burst = tonumber(ARGV[2])
local now = tonumber(ARGV[3])
local ttl = 1

local bucket = redis.call('HMGET', key, 'tokens', 'last_time')
local tokens = tonumber(bucket[1]) or burst
local last = tonumber(bucket[2]) or now
local delta = math.max(now - last, 0)
tokens = math.min(burst, tokens + delta * rate)

if tokens >= 1 then
    redis.call('HMSET', key, 'tokens', tokens - 1, 'last_time', now)
    redis.call('EXPIRE', key, ttl)
    return 1
else
    return 0
end

六、Retry + Circuit Breaking + Rate Limiter 组合

最误导: circuit breaker OPEN 时, 触发 retry → return fallback clear over 半开 尝试. 避免 retry 绕过半开.

Good practice:

  • Retry 一定 设在 circuit closed / half-open only.
  • Circuit open → directly fallback (no retry).
  • Rate limiter 高于 circuit breaker 前拦截.

七、Load Shedding

定义

当 收到 request > max capacity, 主动拒绝 (HTTP 503 vs 5秒 latent reply). preferable to 拒绝 快速 于 全超时。

Distributed Limiting

Queue depth > threshold → reject.
Token-based or thread pool queue-limited.

Netflix Concurrency Limits

Vegas 算法: 监视 RTT limit and dynamically adapt max concurrency.


八、Graceful Degradation & Fallback Closures

优先级 ordering

primary service  →  try

fallback 1:  Redis cache  (最新 stale)
fallback 2:  Static default
fallback 3:  empty response

静态 Fallback

  • search server 故障: 返回 "search 不可用 稍后 return".
  • recommendation 故障: 显示 global trending instead of personalized recommendations.

九、Chaos Engineering

Netflix Chaos Monkey, Chaos Kong (full region kill); random kill instances / regions, 系统 验证 resilience patterns self-heal。

Example

  • Netflix Simian Army: periodically kill region in production. Engineers 构建 tolerance.
  • Gremlin, Chaos Mesh, Litmus 提供云 native 混沌.

十、典型事故

Netflix "Circuit Breaker Open Lockout"

Hystrix config 过 激, 半开 一 failure (from cold start) → 长 open 20min. 实际 下游 早已 健康, 但 circuit breaker 持续 拒绝. Fix: half_open_permitted=3 and 短 open duration.

Retry Storm at Amazon

Internal service 故障 后, retry storm (2 retries max → de facto 3× load on healthy instances). instances 之后 全被 retry queue 淹没。 Fix: retry budget + adaptive concurrency limit.

Rate Limiter too tight at Twitter/IP

Twitter API 限每 IP 15 requests / 15min, proxy users behind same nat 让 公共 WiFi 用户 blocked unknowingly. Fix: token-based rate limit based on user_id, not IP.


十一、易错清单

  1. Retry on non-idempotent function → double effects (especially writes). Idempotency key must be.
  2. Circuit breaker opening must have fallback: else return 500 fast only - same as downstream dead.
  3. Retry burst: DoS downstream: max allow retry budget + exponential backoff with jitter.
  4. Rate limit based on shared IP unreliably: very limit users behind NAT; use auth token.
  5. Bulkhead size too small ⇒ own lock: monitor delay in bulkhead use reasonable capacity & 多.
  6. Timeout < request P99 ⇒ false positive: adaptive timeout tracking.

十二、这一章带走的东西

  1. 8 resilience patterns: Timeout / Retry + Exponential Backoff / Circuit Breaker / Bulkhead / Rate Limiter / Load Shedding / Fallback / Graceful Degradation.
  2. Resilience4j / Hystrix 是 Java 标准 lib.
  3. Rate limiter: Token Bucket per 用户, Redis Lua script distributed.
  4. Circuit breaker state machine: Closed → Open → Half-open.
  5. 组合: retry within closed/half-open; no retry open; circuit breaker higher priority.
  6. Chaos engineering: Netflix / Gremlin / Chaos Mesh 验证。
  7. Retry storm + Rate Limit shared IP 是 典型事故。

下一节 → 经典系统案例

Multi-Region 部署

TL;DR

Multi-region = 让服务在多个地理区域运行, 目的:

  1. Lower latency: 同区域用户 < 5ms RTT (vs 跨洋 100-300ms)
  2. Disaster Recovery: 一区全毁, 另一区扛
  3. Data residency: 数据合规 (GDPR: EU 用户 数据在 欧盟)

但代价: data replication latency higher, consistency 降级, cost 增加 1.5-2×。本章梳理 multi-region architecture patterns, trade-off (latency vs consistency vs cost), 典型实现 (CockroachDB multi-reg, DynamoDB Global Table, Spinaker Active-Passive vs Active-Active, CDN + DNS routing)。


一、Core Pattern

Active-Passive (冷战)

  • 活跃在 one region, hot failover 到 standby on other.
  • 数据从 active → passive sync replication (async); RPO > 0.
  • failover time 5-60 minutes (DNS change).
  • 成本 1 replica cost.

Active-Active (热站)

  • 多 region 同时 accept writes.
  • Multi-master replication 双写同步。
  • Conflict resolution: LWW (timestamp), vector clock, or merge function.
  • latency ~ local区域 <5ms, cross region ~100ms multi write.

Read-Replica global

  • 写 在 single primary region, 各地只读 (RPO=0 for writes within primary region).
  • 延迟: local (primary region <5ms write) + cross-region reads locally fast (~200ms behind).
  • 适合 read-heavier landscape (content, social feeds).

二、CDN + DNS based Routing

Route53 Latency-based Routing

User → DNS request
Route53 → 根据 user latency choose nearest healthy region IP
CDN (CloudFront / Cloudflare) cache fronting.
  • DNS 基于 延迟路由, 不要 user pick.
  • 高 failover: TTL 60s, health check DNS → switch user.
  • CDN serve static content, dynamic path origin 回到 region.

Global Load Balancer (anycast IP)

Google Cloud anycast IP 一个 IP 全球 consistent, data 同 region 分发: 任何请求 route 到 nearest backend. AWS Global Accelerator 提供 similar.

Application Consistency

同一 user's session 可能 sticky to one region via cookie. 若 region 崩, session 迁移其他。


三、Data geo-partitioned

Shard by Region

user_id → region hash.
table REGIONAL BY ROW in CockroachDB: userid → us-east region.

Cross-region global tables

某些表 global (e.g., legal terms, universal catalog). replicas 全球, cross-region 写 重 过多个 区域, weak consistency.

CockroachDB Multi-region SQL

ALTER DATABASE mydb PRIMARY REGION "us-east1";
ALTER DATABASE mydb ADD REGION "us-west1";
ALTER DATABASE mydb ADD REGION "eu-west1";
ALTER TABLE users SET LOCALITY REGIONAL BY ROW; -- each row belong to chosen region
ALTER TABLE catalog SET LOCALITY GLOBAL; -- replicated to all regions

CockroachDB 使用 HLC, multi-region 读 有限 延迟.

Spanner Multi-region

CREATE TABLE Users (...) PRIMARY KEY (UserId),
  INTERLEAVE IN PARENT ...;
-- Configure replication across us-east1,us-west1, europe-west9

Spanner TrueTime 保证 external consistency across regions.


四、Data Residency (GDPR)

用户 地域 数据必须留在同区域:

  • EU 用户 数据 必 EU 区 replica。
  • 法律 metric 靠 region-sharding forced local table replicas only allowed.

Multi-region 可能涉及 cross-region commit queue, data protection by region local dataset.


五、Consistency in Multi-Region

目标技术LatencyCost
Strong consistency (linearizability)Paxos/Raft cross-DC with commit-wait (TrueTime)100-200ms per write高 (全球多数派 ack)
Causal consistencyHLC / Vector Clock, cross region read replicas10-200ms 写本地, read ≤ 1s lag
Eventual consistencyAsync replication + < 1s read latency possible<5ms local writes, cross-region 读 几百 ms 以上 update

Multi-region 强一 需 majority 跨 DC 复制 → latency cross DC 至少 50-150ms.


六、Case Studies

Netflix Active-Active Multi-Region

Netflix 三 AWS region (us-east-1, us-west-2, eu-west-1). 采用 multi-region active-active with ultra-strong event-based CDC sync + CDN + always routed DNS/anycast.

业务 使用 stale 容忍读 (轻微 delay user profile won't break UX).

Google Spanner Multi-Region

Google Ads 系统 跨 continent 数据 true external consistency TrueTime—— commit-wait ~14ms 两 DC roundtrip guarantee. Costs higher but worth billions revenue ads.

CockroachDB by Cloud Company to Europe

某金融服务公司 用 CockroachDB multi-region cluster us-east-1 + eu-central-1, 分区化的 user 数据 (region by row). Cross region user data 不能 legal cross-region move.


七、Multi-Region Failover Mechanics

Active-Passive Failover

  • Health check 持续: DNS 停止 active region.
  • 异步 replication sync 保证 RPO min (5s to 0s replay).
  • Traffic migration = route switch DNS / BGP → replay underway.

Active-Active Failover

Remove 故障 region from routing — 其他 区域 自动承担 all traffic. 无需 failover 过程。

Disaster Recovery planning

Periodic DR test: Chaos engineering Netflix Simian Army random kill regions, read verify others pick up.


八、典型事故

AWS us-east-1 regional outage Feb 2017

某 S3 区域损 几小时服, 多于 netflix 用 multi-region 避免 但广大依赖 区 services 遭受 二 接 down。

Apple iCloud multi-region 2015

iCloud 服务 outage 于 multi-region replication sync loop, bug 导致 数据 replication 环 直 塞 满 网络, 需 全 shut off.

Spotify EU region outage

Spotify 欧 区 改 DNS failover wrong → unintended burst US-East cluster → impact US users; failback 触 跳 multi-region capacity overflow.


九、易错清单

  1. Strong consistency cross-region: 50-200ms latency unavoidable — accept for true consistency pay latency tax.
  2. Async replication RPO > 0 须 明确 SLA: tell business acceptable data loss.
  3. Data residency compliance = region shard 不能 cross-region replicated: legal advice before deployment advice.
  4. DNS failover TTL default > 60s means stale routing post-failover; minimize TTL with active health checks.
  5. Active-Active conflict resolution: key not model: LWW conflict resolution bias; must design per entity.

十、这一章带走的东西

  1. Active-Passive (standby) vs Active-Active (accept writes in all) vs Read-Replica multi-reg.
  2. Data geo-partitioning: REGIONAL BY ROW / GLOBAL 表 in CockroachDB + Spanner.
  3. Consistency spectrum: strong (Paxos cross-DC, 100ms) → causal (HLC) → eventual (async <1s).
  4. CDN + DNS latency routing 解决 read, write 走向 origin region.
  5. Multi-region DR: active health check + DNS + failover 测试.

下一节 → Resilience Patterns

可观测性 (Observability)

Monitoring告诉你你的系统是否"在跑"; Observability告诉你你的系统是否在"按预期跑"。 监控 vs 可观测性 区别: monitoring 是 pre-defined alert + dashboard, observable 是让你任意提问你的系统并得到回答 (在 black-box 内部状态可被推断)。

Three Pillars (Metrics / Logs / Traces)

TL;DR

可观测性"三大支柱" 是工业界共识的三种信号类型:

  1. Metrics: 二进制 numeric time-series data (CPU util, RPS, error rate, latency).prometheus exposition format, aggregation makes efficient (无 per-event record)。
  2. Logs: 事件级 records (annotated text/JSON)用于 deep-diagnose. 所有微小事件可查 (debug log) → 节省开销 batch.
  3. Traces: 跨 service call path + span timing — 让你看到 request 从 user 到 DB + many microservices 的传递链.

每类信号有 purpose:

  • Metrics: 大尺度状态、告警
  • Logs: 单事件诊断
  • Traces: 端到端 latency breakdown

现代 stack: OpenTelemetry 统一收集的 formats. 数据 pipeline. 与 alerting via Prometheus/Grafana / Loki / Tempo / Jaeger 组合.

本章扫每类信号特点 + Trade-offs (cardinality cost), Performance implication (不惜 log per request for 高 throughput), 与防护舆 typical.


一、Metrics

形式

Metrics 是 "聚合数值时间序列数据" — 每 series 由一组label set 维 identify (e.g., http_requests_total{method=POST, status=200}) 。 Prometheus exposition format 典型:

# HELP http_requests_total Number of HTTP requests
# TYPE http_requests_total counter
http_requests_total{method="GET",status="200"} 124851
http_requests_total{method="POST",status="500"} 12

4 种 metric type

类型用途
Counter (单调增): 累计计算 e.g. requests_total
Gauge (可增减): 瞬时数 e.g. current_connections
Histogram (bucket): latency distribution (P50/P90/P99 大量计算 bucket with retention金: _bucket, _sum, _count)
Summary (客户端 quantile): 客户端 实时 P50/P99 但 unsuited aggregation across instances

Cardinality 风险

Metrics 的 label cardinality — number unique combinations like → memory. Prometheus storage cost near-linear in cardinality over time;

  • Label user_id (1M users): 1M 系 Featolder
  • Label URL (含:id): 100K URL lines per second → 跨 kill storage
  • Label trace_id

Rule of thumb: any label with cardinality > 1000 应 simplif or rollup INCLUDING to avoid explosion.

Prometheus metric exposition

每 service ishould expose /metrics endpoint with Prometheus exposition format. Prometheus scrape (default 15s). 存储 点向 metrics backends: M3DB, VictoriaMetrics, Cortex, Thanos for scale.


二、Logs

Levels

TRACE | DEBUG | INFO | WARN | ERROR | FATAL

Production 通常 INFO+; DEBUG log 仅在故障时启用 (cross debug 调查). Tools: loom long-term retention + 结构化 logging for searchability.

Structured vs Unstructured

Unstructured: printf("Error at file ... ") — 可 grep 但 not machine-parseable.

Structured (JSON for tools): {"level":"error","ts":"...","msg":"checkout","user_id":42, "cart_id":"abc"} — ELK 加 indexing on fields.

高 throughput logging cost

  • 企业应单机 1 GB/day log limit easily 2500 USD/month. 推荐sampling.
  • "all access logs kept 7 years合法 不首通用 optimizations keep origin proj纳入 access log snapshot + sample tail info 「 金 network cost 持 abog".

典型 stack

Application
  ↓ structured log (JSON / logfmt)
Filebeat / Vector / Fluent Bit
  ↓ agent obtains
Kafka log streams
  ↓ async producer backend
Loki / Elasticsearch / OpenSearch / ClickHouse / S3
  ↓ query
Kibana / Grafana

性能开销

  • 单 INFO log 每 5KB → agent 服务 / 后端 indexing 加序列化 json extra 50-100us.
  • 高吞吐 log load 会 server process thread pool + disk IO. consider async logger (e.g. log4j 2.x async logger 或 source klog V8 flush).

三、Traces

OpenTracing / OpenTelemetry

OpenTracing 与 OpenCensus 2019 合并 → OpenTelemetry (OTel)。 提供 language-agnostic API for traces context propagation。 HTTP/gRPC header injection让 trace_id 跨medration services传递.

Span / Trace

  • Trace: 完整 end-to-end path lifecycle 的 unique trace_id.
  • Span: 一个服务 node的 single sub-process timing + tags.
  • 父 span → child span; siblings → concurrent calls.
Trace: trace_id=abc, total 800ms
  Span1: HTTP /api/checkout, 800ms [parent]
    Span2: db.query SELECT order, 50ms [child]
    Span3: call shipping service, 200ms [child]
       Span4: shipping DB query, 150ms [child]
    Span5: redis cache get user, 5ms [child]
    Span6: payment service call, 500ms [child]

Distributed context propagation

  • HTTP traceparent header W3C standard format: 00-{trace_id}-{span_id}-{flags}.
  • OTel auto-instrumentation: agents attach to popular libraries (e.g. JDBC, gRPC, Spring) 自动 emit spans.

Distributed Trace Storage

  • Backend: Jaeger / Tempo / OpenSearch / Datadog / Honeycomb / New Relic.
  • Sampling 控制 retention cost (e.g., 100% sampling at low流量, 1% at high RPS).

Sampling

  • Head sampling: collector 概率性 drop traces before processing. e.g. 1% records.
  • Tail sampling: collector 全采集; lifestream 完成后决定保留 (e.g., keep >= P99 latency or error status). resource-limited.

四、三者关系

flowchart LR
    A[Application<br/>signals] --> M[Metrics: aggregate]
    A --> L[Logs: per-event records]
    A --> T[Traces: span breakdown]
    M --> P[Prometheus]<br/>/ alerting
    L --> E[Loki / Elasticsearch]
    T --> J[Jaeger / Tempo]
    P --> G[Grafana dashboards]
    E --> G
    J --> G
  • alerting migrations look for 用 Metrics. -incident 调试从 Logs + Traces = # behavior回到 stack-grained origin. -Traces 给 spans 转 Metrics events, logs 引记录 trace_id扰动 curation 快 lookup.

Exemplars (Prometheus 2.26+)

Exemplar = "Metric point + trace_id 引用 ". 让 Grafana 中点 P99 latency 节点 → click → 引言含 trace_id → jump to Jaeger/Tempo看具体 trace. 桥接 metrics与 traces.


五、典型 Architectural Patterns

Pattern 1: Service Stack with OpenTelemetry

App code (OpenTelemetry SDK)
  ↓ OTLP / Prometheus
[OpenTelemetry Collector] (dep role-forwarding/log累积/data aggregation/sampling)
  ↓ forked
Prometheus (metrics) | Loki (logs) | Tempo (traces)
  ↓
Grafana (unified view)

Pattern 2: Cloud Native Observability (Datadog / Honeycomb / New Relic)

提供 hosted solution, 多 SDKs (e.g. dd-agent on each pod), agent forwards. Trade-off: high volume cost-per-GB. Vendor lock 注意.

Pattern 3: ELK Stack Logs + Prometheus Metrics + Jaeger Traces

Self-hosted stack 经典 选择. ELK (现在 "OpenSearch"—fork of ElasticSearch 2021) Logs + Prom Metrics + Jaeger Traces.


六、典型 Use Cases

###triggerance 1: Diagnose High Latency Report

  1. Alert: "checkout API P99 latency 5s" 触发 from metrics
  2. Grafana dashboard 看 P99 derivation of recent checkout success rate
  3. Exemplar link to specific trace / 导致 Trace ID range.
  4. Jaeger 看 trace: "shipping service 500ms" + "db query 300ms".
  5. Span detail: sql query atement + SQL timings + Service specific span ID.
  6. logs by trace_id = (SQL plan + Postgres query log)
  7. resolve query.

Triggerance 2: Error Spike Diagnostic

-alerts 监测 error rate metric 是 spike. -Watch战 back dashboard, failed request spans 找;

  • Look at logs filtered by status=500; -Restart跑 server app 看行 exception.

七、典型事故

Elasticsearch Cluster OOM 大量 Object High Cardinality

某公司 ES logstorage 组 tagged with user_id as a field. multi-CO者 ES hop heap 充 ~100GB/天 overwhelmed cluster. Fix: user_id 在 long-term retention 跳 low index + aggregated per user_id 游 实际 lookup.

Datadog Cold-Card Trick

某 team通过 trace "every" 学校 DNS error: named 只 100 RPS, but e.g Develop 拒tring count all latency loader data incurred over-time $买 100MB month + DEMos rule 权. Fix:

Loki cards Decrease storage cost

某公司 Loki query (~10×less cost than ES) for logs, substitution / success.


八、易错清单

  1. Don't use user_id, session_id, trace_id as metric label — cardinality爆炸 storage cost。
  2. Trace sampling must include all errors: tail sampling keeps 100% errors + 1% success, 重要性数据采集.
  3. OpenTelemetry MUST support all service tiers (HTTP/gRPC/db/cache)
  4. Set alerting on user-facing metrics, not behind infrastructure-only
  5. High-cardinality log fields: avoid to give ś user_id as part of indexed log key.
  6. Trace 爆炸 over service mesh 重在 hist 纕 keep sample-server-side samples by path.

九、这一章带走的东西

  1. 三大支柱 Metrics + Logs + Traces 是观测分布式系统的 entry point.
  2. Metrics aggregate- numeric格式上 cost-aware labelsetdecount的 cardinality problematic.
  3. Logs structured JSON 让 query/filter 不过 cost-per-event 的处理; sampling retains large. febyterianik.
  4. Traces span breakdown 让 latency decoupled visible; OpenTelemetry sets of standards.
  5. Exemplars 锵 metrics 与 traces bridges Grafana links point. 6.用 Prometheus + Graphana; Loki or ES for logs; 真Jaeger/Tempo 您 traces distribution. Currently "slotel collector" emerging best 埋持 alternative.

下一节 → SLO/SLI/Error Budget

监控 Stack

TL;DR

现代化监控 stack 常由 Prometheus (metric scrape/query/alert) + OpenTelemetry (SDK instrumentation) + Grafana (unified dashboards) + Alertmanager (alert routing) + Loki/Tempo (logs/traces) 构成。 历史上 ELK (Elasticsearch + Logstash + Kibana) 承担日志, Zabbix / Nagios 承担主机监控, 但 cloud-native 推动统一可观测性平台。本章梳理各组件, alerting pipeline, Grafana dashboarding, incident response 的自动 escalation, 典型 stack 调参, 常见事故 (Prometheus metric flood, Grafana anti-pattern multi-clouding).


一、Prometheus 架构核心

flowchart TB
    TG[Targets:<br/>Node Exporter,App /metrics]
    P[Prometheus Server<br/>scrape + query + alert rules]
    AM[Alertmanager<br/>dedup + group + route]
    G[Grafana]
    TS[(Time Series DB<br/>local or remote)] 
    TG -->|scrape| P
    P -->|alert| AM
    P -->|"PromQL"| G
    P -->|store| TS
    AM -->|PagerDuty/Slack| ALERT

Scrape 模型

Prometheus pull model: 每周期 (default 15s) scrape 每个 target 的 HTTP /metrics 端点。 push gateway 在 短期 job 场景也可。

PromQL 核心

rate(http_requests_total[5m])  # 每秒 rate, 平滑
histogram_quantile(0.99, rate(request_latency_bucket[5m]))
sum by (route) (rate(http_requests_total{status=~"5.."}[5m]))
increase(queue_length[10m])

Recording Rules

Precompute aggregated results (record.rules) for dashboards. Reduce read.

groups:
- name: apiserver
  rules:
  - record: job:http_requests_total:rate5m
    expr: rate(http_requests_total[5m])

Retention + Storage

Local Prometheus retention default 15 days; 远程写 to central long-term: Thanos / Cortex / Mimir / VictoriaMetrics.


二、Alertmanager

功能

  • Dedup: 相同 alert 接多个 Prometheus instance 只 fire once.
  • Group: 按 time window batch 多个 alerts 成一 个 notification.
  • Route: by severity/type send to on-call pager + Slack channel.
  • Inhibition: 若 X cluster 完全 down, 不 生成 无限 alerts of each metric; 仅 单 顶 "X cluster down" 静音 others.

配置

route:
  group_by: ['alertname', 'severity']
  group_wait: 10s
  group_interval: 30s
  repeat_interval: 4h
  receiver: 'slack-default'
  routes:
    - match:
        severity: critical
      receiver: 'pagerduty-critical'
    - match:
        severity: warning
      receiver: 'slack-warning'

On-call escalations

  • 1st: owner on-call
  • if no ack 10min: escalate to team lead
  • if no ack 20min: escalate to manager

三、Grafana 最佳实践

Dashboard as Code (DC)

Grafana dashboard JSON in Git, deployed via Grafana provisioning API / Terraform.

Key panels for every service

  1. RED metrics (Rate, Errors, Duration): request rate, error%, P99 latency.
  2. USE metrics (Utilization, Saturation, Errors): CPU / Memory / Disk IO + queue depth.
  3. Business SLA: successful checkout rate, current cart value etc.
  4. Latency heatmap: percentile distribution panel in time frame.

Stat Panels vs Graph Panels

  • Singlestat: show latest value (error rate 0.5%)
  • Time series: 折线 看 trend
  • Heatmap: latency distribution P50/P95/P99 across time
  • Table: top slow endpoints

Template Variables

datasource: Prometheus
variable: "$namespace"  # select 容器环境
PromQL: label_values(kube_namespace)

Alert Annotation

Annotation from Prometheus alerts: "Service CheckooutDown time 15:08-15:09".

Avoid "Grafana Overload"

Large dashboard with 100+ panels → renders every refresh=10s → card query. Recommend cap 25 panels per dashboard; multi tabs.


四、Loki (Grafana Logs)

为什么不用 Elasticsearch

Loki 用 label-based indexes (low cost, no full-text all fields). Only stream labels indexed, log content not tokenized; search query by labels + full-content grep.

架构

Promtail / Vector / Fluent Bit  →  Loki distributor  →  ingester →  object store (S3/GCS)
                                query → querier reads back.

LogQL

{app="checkout", env="prod"} |= "ERROR"
{app="checkout"} | json | path="trace_id" | line_format "trace: {{.trace_id}}"

性能 + 费用

  • Around 10× cheaper per GB than Elasticsearch.
  • Label cardion 不能太高 (≤100 非 indexed cardinality).

五、OpenTelemetry Collector

Pipeline

Application (OpenTelemetry SDK)
  ↓ OTLP protocol
[OTel Collector (deployment mode agent / workload / gateway)]
  ↓ forked
Prometheus (metrics) | Loki (logs) | Tempo (traces)

Collector receivers

receivers:
  otlp:
    protocols:
      grpc:
        endpoint: 0.0.0.0:4317
      http:
        endpoint: 0.0.0.0:4318
  prometheus:
    config:
      scrape_configs: [...]

Collector processors

processors:
  batch:             # 批量 → 减少后端 load
    timeout: 5s
    send_batch_size: 8192
  memory_limiter:
    check_interval: 1s
    limit_mib: 512
  sampling:          # 尾部采样
    policies:
      - name: error_sampling
        type: status_code
        status_code: { status_codes: ["ERROR"] }
        sampling_percentage: 100
      - name: tail_sampling
        type: probabilistic
        sampling_percentage: 1

Collector exporters

exporters:
  otlp:
    endpoint: tempo:4317
    tls:
      insecure: true
  prometheusremotewrite:
    endpoint: http://prometheus:9090/api/v1/write
  loki:
    endpoint: http://loki:3100/loki/api/v1/push

六、CNCF Observability Landscape

Layer工具
SDKOpenTelemetry (SDK), Prometheus client libs
CollectorOpenTelemetry Collector, Vector, Fluent Bit
MetricsPrometheus, VictoriaMetrics, Cortex, Mimir, Thanos, Datadog
LogsLoki, Elasticsearch, OpenSearch (AWS fork), Datadog
TracesJaeger, Zipkin, Tempo, Datadog, Honeycomb
DashboardsGrafana, Kibana, Datadog, Chronosphere
AlertingAlertmanager, Datadog, PagerDuty, Opsgenie
Incident mgmtIncident.io, FireHydrant, PagerDuty

七、Scale Considerations

Prometheus HA

Run 2 identical Prometheus instances + remote write; Grafana queries either equal read. Alerts dedup by Alertmanager.

For 10K+ target, use Thanos or Cortex:

  • Thanos sidecar: 读 object-store historical data, 加 global view through Thanos Query.
  • Cortex: fully horizontal scale micro-service arch.

Grafana data source scalability

  • Each panel query must terminate in < 10s; embed step intervals.
  • 200 users concurrently dashboards → proxies for load balancing queries (nginx / lb).

八、典型事故

Prometheus Card Cause Out of Memory

某 tag cardinality 高 (Label user_id exposed as metric), 存入 1M series per scrape. 2 天 Prometheus OOM -- crash. Fix: aggregate label "user_group" 10 groups instead user_id.

Alert Fatigue

某 company 50+ critical alerts per week; team ignored alerts; on eventual real failure missed. Fix: pager's critical page only 90% SLI breach + < 5 pages / week; others routed to Slack.

Grafana Panel Refresh Storm

K8s cluster 有200+ Grafana dashboards 每 5s 刷新 100 panels 各, Prometheus backend QPS 10K+; Prometheus overloaded. Fix: specify refresh=1m default + cap 20 panels dash.

Loki Full-text Search Spike Overdrive

Develop multi-line JSON queries error loaded in Loki; search within massive queries resulting Loki ingester cost. Fix: log query skip 用 structured index only.

Tracing excessive Sampling To Bill

某公司 Jaeger all success/failure 100% trace produce ∼ 200M spans/hour, backend OOM + Datadog $20K/month cost. Fix: 1% tail sampling for success, keep 100% error status sampling.


九、易错清单

  1. 不要每 label 高 cardinality: user_id, trace_id 绝不能 Prometheus metric label.
  2. Alert 过 多 → alert fatigue: redefine only critical SLI-based alerts (burn rate).
  3. Grafana dashboards 过多 + refresh 快 容易 overload Prometheus: cap 25 panels + refresh every 1min.
  4. PromQL 误用 rate(metric[range]) vs irate: rate smooth + long range; irate approximate per-second change but spiky.
  5. Logs 不要全 索引 每 field: 在 Loki 只 indexed label, 优化 search以 full-text scanning minimal.
  6. OTel Collector 没 batch → backend overload: batch processor 对 Prom remote write 必须.
  7. Incorrect retention policy: set compute retention for each data type; periodic arch to cheap storage (S3/Coldline).

十、这一章带走的东西

  1. Classic stack: Prometheus (metrics)+ Alertmanager + Grafana + Loki (logs) + Tempo/Jaeger (traces) + OpenTelemetry Collector (ingestion).
  2. Prometheus scrapes /metrics pull; PromQL rate, histogram_quantile, sum by 是核心熟练度.
  3. Alertmanager group + dedup + route inhibition 防止 alert 洪.
  4. Grafana dashboards 少 panels, 大 cap, templates 可参数 化.
  5. Loki label-based 日志 索引 ~10× fewer cache than Elasticsearch, 适配 大规模.
  6. OpenTelemetry Collector unified ingestion; batch + memory_limiter + sampling 控制成本.
  7. SRE alert practice: only alert on user-facing SLI violation (burn rate); not infrastructure noise.

下一节 → 扩展与可用性

SLO / SLI / Error Budget

TL;DR

Google SRE (Site Reliability Engineering) 团队在 2016 年提出的服务可靠性工程核心 framework:

  1. SLI (Service Level Indicator): 服务可用性的 量化 metric — "对用户来说什么是好的" (e.g., availability = 200 OK success rate, latency P99 < 100ms)。
  2. SLO (Service Level Objective): 对 SLI 设置的 目标值 — "我们希望 99.9% 的请求在 100ms 内成功"。
  3. Error Budget: 允许的"失败空间" = 1 − SLO. 如果 error budget 花光, 必须 freeze 新 feature release 稳定平台。

这三者是非主观的、数据驱动的 reliability roadmap — 不再是"系统要快而稳", 而是"系统 P95 < 100ms in 99.9% windows" measurable 的 roadmap。本章深入 SLI 设计、SLO 设定 (30 天 / 7 天 rolling windows) , Error Budget 管理与 burn rate, 经典事故 (Netflix startup error budget runout) 。


一、SLI (Service Level Indicator)

必须量化

SLI 类型度量说明
Availabilitysuccess_count / total_requests200/2xx HTTP, gRPC OK, 业务定义成功
Latencyhistogram_quantile(0.99, request_latency_seconds)P99 延迟
Throughputrate(requests_total[1m])容量用度
Durabilitywrites_committed / writes_accepted储存器持久性

选 SLI 的两个维度

  • 用户面 (User-Facing): 直接影响 UX —— API 返回、页面渲染、搜索页面 latency
  • 关键面 (Critical): 服务 health — CPU allocation, QPS, disk IO > 90%

规则: Alert 只设定于用户面 SLI, 不 alert 于基础设施面 SLI (除非是 capacity接近 cap)。

Critical Path Breakdown

User: POST /api/order
SLI 1: API availability = (200||201 responses)/(total requests)
SLI 2: P99 latency < 500ms (checkout response time)
SLI 3: checkout error rate < 0.1% (order failure/non-retryable)

SLI Granularity

Don't 整体统 一 每 endpoint? 区分:

  • pages/: 首页 P99<200ms, 商品列表 P99<300ms, 搜索 P99<1000ms?
  • critical/: checkout API P99<500ms, availability 99.9% 以上

若 critical SLI 的 错误预算烧得很快, 整个生产速度必须下降 直至 稳定。


二、SLO (Service Level Objective)

目标值

SLO = SLI 达到目标 probability over time window:

$$ P_{SLI} \geq P_{target}, \quad \text{over 30 days} $$

例: "checkout API availability SLI = 99.9% over 30 days".

定义 Window (滚动)

  • 30 days: 稳定测量, 忽略短暂 spikes
  • 7 days: 快速反馈 (适配 fast iterations)
  • 1 day: 基本上 用于 reactive hotfix

实际常用 rolling 30 day for quarterly reliability review; 7 day for burn rate monitoring.

典型 SLO 数

服务可用性 SLO年度误差预算
关键金融交易99.99% ("four nines")~52 分钟/y
电商 checkout99.9%~8.8 小时/year
社交 feed99.5%~1.6 天/y
新闻 feed99%~3.65 天/y
内部 工具 / admin99%~3.65 天/year
Dev/测试no SLO不要 SLO; just 容忍 down time

定 SLO 高于 99.9% (~44min/m) 每一额外 nine costs $100K+ per 服务业 is realistic budget no matter.

规则: **不高于 99.99% 除非你 真正 花 钱 operation 花到 T5 teams. **

"99.9%" 误解

99.9% = 0.1% 不可用 over window = 43.2 min / month.

实际运作 服务 可 99.5-99.9 range; most internal services never above 99.9% because chronic change speed compromises stability.


三、Error Budget

定义

$$\text{Budget} = (1 - SLO_{target}) \times \text{total_time_window}$$

例: SLO=99.9%, window=30 days, Budget = 0.1% × 30 × 24 × 60 = 43.2 min 可 down window.

每 30 天 roll. 但 SLI burn rate shows speed consuming budget.

什么时候 Burn?

  • 每 failed 请求 count. e.g., non-200 status.
  • 每 timeout 响应 time.
  • SLI metric 计算 burn rate.

Burn Rate 阈值

Google SRE 建议 6 倍 burn rate triggering immediate incident alert:

SeverityBurn rate时间 to exhaust budget
Critical (immediate page)14.4× (budget consumed ~6h)~6 hours
High alert (almost exhausting)~24 hours
Medium (warning)~7 days
Low (tracking)< 0.2xlong, not urgent

Freeze Policy

若 当前 month的 error budget 接近 exhausted, must 对 "新feature release" 施加 freeze: 只接受 bug fix + reliability project, 直到 budget 恢复 (next window smooth). 这利地 forcing teams spend time on reliability over features.

"If you always burning error budget, you are in feature-debt vs reliability debt."

Error Budget Recovery

恢复:

  • idle window 自动 recover; 30 天 rolling 后 新 start.
  • Service after problem fixed 可用增加 allowance: SLI observation critical.
  • Manual reset 不推荐; data driven only.

四、如何先设定 SLO?

Step 1: Setup Base SLIs

选 2-4 SLIs for critical user journeys:

  • checkout page api 可用性
  • home page P99 latency
  • search query time P99

Step 2: Collect baseline SLI

Record SLI for 2 weeks; compute current P99, current success rate → baseline.

Step 3: Set SLO

SLO_initial = measured_baseline + headroom (15-30% improvement).

Avoid giving overly ambitious SLO that simply doesn't reflect actual capability; reduce in first week error budget catastrophically burn → teams switch to reliability now.

Step 4: Monitor Burn Rate

Set 2 alert levels:

  • burn rate 1x: warning, 让 on-call check
  • burn rate 6x: page critical, must intervene

Step 5: Hold SLO Reviews

Monthly SLO review: 检查: 满足 SLO (under budget), 讨论 "which new features 该 deploy?"

"When the error budget is gone, you are on a feature freeze."


五、Network / Infrastructure SLO 多层次

Example: Netflix CDN SLO

  • Netflix 视频 分发 by CDN (Open Connect appliance CDN), with target 99.99% delivery success for each ISP.
  • Each POP also self-enforce SLO of 100% CDN bandwidth usage under load.
  • Goal: 99.9% user sessions not affect by network pause.

Google Cloud GCP Networking SLO

  • Cloud Load Balancing ensures 99.99% 连通性 multi-region anycast IP.
  • Compute Engine per instance uptime guarantee = 99.95%.

AWS S3 Durability SLO

Durability objective 11 9 (don't lose data) even using EC; SLI is loss-probability per year 1E-11.


六、Error Budget 与 developer velocity

"Move fast & break things" vs "高 reliability commit":

  • With error budget 未用: develop 大 feature, risk 允许 小 spike failure.
  • With error budget 烧完: feature freeze develop 全面停止, 只做 reliability hardening. 一旦 新 feature deploy → 再 fail the SLO。

工具: feature flag + canary deployment + gradual rollout. 一旦 canary 触发 error spike, 停止 rollout.

Example: Spotify — SLO & feature velocity

Spotify 模型 "SLO for each Squad". 每 team autonomy control service + SLO. If service within SLO, team 选择 部署 scale deploy; 若 跌破 SLO.冻结 features, hotfix + re 升.

Google SRE process

每 季 monitoring data SLO — 提前 end of quarter 20% error budget 未 用 clean bill go deploy feat for next quarter.


七、Prometheus SLO / Burn rate 示例

# SLI: availability of orders API
sum(rate(http_requests_total{status=~"2..", path="/orders"}[30d]))
/
sum(rate(http_requests_total{path="/orders"}[30d]))

# Burn rate over 1h (~14.4x burn alert):
(
  sum(rate(http_requests_total{status!~"2..",path="/orders"}[1h]))
  /
  sum(rate(http_requests_total{path="/orders"}[1h]))
)
> 14.4 * (1 - 0.999)

# PromQL label 并告警:
- alert: HighErrorBudgetBurn
  expr: |
    sum(rate(http_requests_total{status!~"2.."}[1h]))
    / sum(rate(http_requests_total[1h]))
    > 14.4 * 0.001
  for: 5m
  labels:
    severity: critical
  annotations:
    summary: "Error budget burning fast on order service"

八、典型事故

Netflix Error Budget Overturned

Netflix 在某 release 中, 部分 Netflix 公开影视的 SRT 渲染中断, 用户 interface 无响应。 Error budget 3 小时 burn rate 5x; 触发 页面 alarm pipeline 暂 停 red 支. 后续 server-level hotfix.

Spotify mobile app SLO 过低设定

设定 "99% availability", 实际 meaning 24 hours/month allowed downtime. 体验差 but teams had budget unused because always within threshold. Upgrade SLO to 99.5% → burn faster => teams take reliability seriously.

GCP Incorrect SLO Duration setting

某 Google Cloud service SLO 30 days; last 30 days averaged OK, but two days of severe downtime earlier month hidden over average. Fix: burn rate alert (短 window) 辅 30 day SLO 计算.

Unused Downward Error Budget Shift

某 enterprise SaaS provider 通知 用 Slack meeting: "We have 3 days downtime within most LEA years". Moving error budget into that new month causes team always "behind". Fix: SLO start each batch rolling window not annual.


九、易错清单

  1. SLO 不要定太高: start at current measurement + 5-10% buffer.
  2. SLO 必对用户有意义: NOT infrastructure metric (CPU, memory, disk IO) — only user-experienced.
  3. Burn rate computed over multiple windows: 1h for 14x burn alert, 6h for 3x burn w/ regression.
  4. 不要在 downtime 后 reset error budget: rolling window 自动 recover; avoid manual清除.
  5. 要有 "错误预算用于 feature support": 分配一部分 to risk tolerance features deploy.
  6. Error budget must be visible to dev: monitor and dashboard within dev's view (every PR into production links burn chart).
  7. Monthly SLI/SLO review meeting: guaranteed reliability culture persists.

十、这一章带走的东西

  1. SLI (量化 metric) + SLO (目标), Error Budget 是 允 许失败.
  2. Error Budget 烧完 ⇒ 强制性 "stop feature, fix reliability".
  3. Burn rate > 6× → critical page; > 14.4× → within hours budget exhaustion.
  4. Rolling window 30 days for SLO review; 1h/6h burn rate alert.
  5. SLO should be user-facing; never infrastructure SLO only.
  6. PromQL 例: burn rate alert with 14.4×(1-SLO) trigger.
  7. Spotify/Netflix/Google SRE 实战 case all center around SLO+Error Budget: "Move fast unless you burn budget".

下一节 → 监控 Stack

经典系统案例

分析几个工业标杆系统的架构决策、trade-off、生产教训。每个 case 都是一完整系统设计面试 + post-mortem

Snowflake 数仓

TL;DR

Snowflake (2012 founded, 2020 IPO = "BIGTIME revenue growth" story) 重新定义了 cloud data warehouse 即 架构——shared-data architecture: storage (S3 / Blob) separated from compute (virtual warehouses), 每个算力弹性 增长 / 缩小秒级。 传统 on-prem Teradata/Hive 是 shared-nothing 计算+本地盘, 扩展弱。 Snowflake 强调 self-tuning, zero-copy clone, Time Travel, data sharing, seamless scale-out 并发 也 可以。 本章梳理 Snowflake architecture, storage internal (micro-partitions + hybrid columnar), services 层, 查询优化, 与 BigQuery / Redshift 对比, 典型 use (ETL, BI, ad-hoc analytics) 与事故 (clone cost 爆炸)。


一、Architecture (Three-tier Shared-Data)

┌─────────────────────────────────┐
│   Cloud Services Layer          │
│ (metadata, auth, optimizer,    │
│  query compiler, security)      │
└─────────────────────────────────┘
         |              |
┌─────────────────────────────────┐
│   Query Processing Layer        │
│ (Virtual Warehouses: elastic   │
│  compute clusters MPP)         │
└─────────────────────────────────┘
         |
┌─────────────────────────────────┐
│   Storage Layer (Cloud Object)  │
│ (S3/Azure Blob/GCS)            │
│ micro-partitions +     				 |
│ hybrid-columnar compressed     │
└─────────────────────────────────┘

Cloud Services

  • Metadata management (catalog),access control(RBAC), query optimizer, 管理 virtual warehouses
  • Always-on, 多租户, 但 不 query 处理 (无 算力)

Query Processing

  • Virtual Warehouses (VW): user-provisioned independent compute clusters (size: XS~6X-Large)
  • 每个 VW run 在 provisioned cluster, elastic auto-suspend/resume within seconds (warehouse's empty usage 挂 停).
  • 多 VW 并行 isolation (ETL loading VW 与 用户 BI VW 独立 cluster), 不 concurrency 竞争。

Storage

  • All data in cloud object (S3) as micro-partitions: compressed, columnar, immutable blocks (~50-500MB compressed).
  • Micro-partition metadata (min/max per column, bloom filter by zone maps, stretch) in service layer.
  • Columnar, compressed (自动选择 compression algorithm Zstandard/gzip etc).
  • Time Travel & Fail-safe: at any point recover table state prior with up to 90-day retention。

二、Micro-Partition Internal

  • 每 table data 分 immutable micro-partitions; auto clustered by natural order.
  • Per micro-partition: min/max per column, bloom filter null count etc. metadata 存在 cloud services.
  • Query 扫描的时候, prune 不需要 scan micro-partition > 用 metadata 知 不需要 读 => significantly less I/O.

Hybrid Columnar + Partition

Stored as columnar 用 PAX (Partition attributes across): each micro-partition 是 PAX layout within block (columnar inside partition). Cross-partition query stitch columns for needed rows.

Zero-Copy Clone

Clone table instantly without data copy: copy metadata entries pointing to same micro-partitions; only later data modification trigger new micro-partitions (Copy on Write). 秒级 零成本 test env.

Time Travel

Restore table at past state up to 1-90 days (according edition). Data not deleted at drop (micro-partition kept with retention). Similar time travel retention changes for GDPR data erasure.


三、Virtual Warehouses Multi-Cluster

  • Cluster count = "maximum concurrency concurrency is dynamic cluster auto-scale". User query concurrent set lock in mult处理 (compute for big cluster).
  • Auto-suspend after N min idle → cost savings (cloud money).
  • Multi-cluster warehouse ~concurrent heavy BI.

Auto-Scaling

Load queue ~decide VW instance check; spin up 'added cluster to reduce queue depth'. Max cluster = 10 (default).

Resource monitors & Budgets

Set monthly credit budget per VW to block runaway cost.

Data Sharing

  • Share data cross accounts with read-only views; zero copy of actual data (point to same micro-partitions).
  • Enables "publisher / subscriber" model; live data sharing 内部 and external.

四、Query Optimizer (CBO)

Snowflake 优化器

  • Cost-based optimizer (CBO) use statistic on micro-partition (per column distribution, histogram).
  • Automatic clustering: see heavy query and create clustering key which 聚簇 微 分区 好 prune; no user DBA 手 index.
  • Search space for join order: stats derived from micro-partitions 让 内存 估算 大数据 join 量 成本。

Query Acceleration Service

Cloud service external run queries in Serverless Compute when VWS are cold or stopped: 5-10s latency fast start.


五、比较: Snowflake vs BigQuery vs Redshift

维度SnowflakeBigQueryRedshift Spectrum
架构shared-data multi-clusterfull serverless distributed Dremelshared-nothing clustered (RA3 nodes)
Storagecloud object (S3, Blob)Colossus/GCS (capacity store)S3 / local SSD for hot data
Computeprovisioned VW clusters elasticserverless slots auto-scaleprovisioned cluster nodes (DC2 / RA3)
Scalingvirtual warehouse resize (secs)automatic (serverless)resize cluster (minutes)
ConcurrencyMulti-cluster warehouses 分 离high concurrency autolimited by cluster size queue
Price modelper-second credit per warehouseon-demand per query data processed or slot reservationper node/hour
SQL standardANSI SQL + extensionSQL:2011 + extensionsPostgreSQL-based
Semistructured dataVARIANT column (JSON auto-discovery)JSON nativelyRedshift Spectrum JSON parse

Snowflake 能 fine-grained 控制 VWS for ETL + BI isolation. BigQuery simpler auto-provision by slots. Redshift 原 形 上 更 针对 closed complex transactions + column 化 tables.


六、典型使用

ETL pipeline

S3 Event → Snowpipe (continuous ingest) → stage table  → transform via task + streams (CDC inside Snowflake) →  gold table
BI query against gold table via BI tools (Tableau, Looker).

Data Sharing Marketplace

Publisher publish data set (e.g. COVID stats) → sub-live query across Snowflake accounts.

ADP Analytics + Machine Learning

Leverage internal SQL, external functions (ML models via cloud Function URL)。


七、典型事故

Snowflake "Zero-Copy Clone Dropped CD 巨 费"

Clone large table (1PB) share micro-partitions; user run heavy transformation on clone but keep clone long-time. Micro-partitions 修改 分 离 copy (CoW) accumulated massive extra storage charge. Fix: drop clone promptly, monitor, use time-limited 批。

Virtual Warehouse Auto-Suspend off → 🎹 单月 $50K bill

某 team 误 set AUTO_SUSPEND=0 on 4XL VW. Not used 3 weeks but ran continuous billed = ~$50K extra. Fix: always enable auto-suspend = 60s (default 10 min).

Snowpipe 输入 burst -> backlog

High continuous file load via Snowpipe, which queue 累积 → large delay up 30 min for data to be visible. Fix: larger warehouse for COPY inline + Snowpipe increased pipe count.

Multi-Cluster Warehouse Burst not Activated

Concurrency 高, but VW MAX_CLUSTER_COUNT=1 — cannot auto-scale beyond 1. Some queries queued 15 sec. Fix: set MAX_CLUSTER=5 multi-cluster.


八、易错清单

  1. AUTO_SUSPEND 必设 (data warehouse 不是 always-on service): default 10 min, budget-safe.
  2. Clone 共享 micro-partitions + 改 事 become separation: 大量 改 增 storage cost.
  3. Time-travel data 超过 retention, fail-safe = still 保障: bill retention (0-7 days enterprise). Know GDPR 清除.
  4. Multi-cluster warehouse 必须 配 MAX_CLUSTER_COUNT > 1: other 定 async with long queue.
  5. Snowpipe ingest latency is 1-3 min: real-time needs streaming (Kafka + Snowpipe Streaming) 直接.

九、这一章带走的东西

  1. Snowflake = shared-data, 分离 Storage (Object) + Compute (virtual warehouse) + Services 三层.
  2. Micro-partitions = PAX 存储 + metadata prune + columnar compression; zero-copy clone / time travel 用 分区。
  3. Virtual warehouse: auto-suspend + 多集群 elastic scale, per-second billing, multi-concurrency 让 BI + ETL 隔离。
  4. 核心 trade-off: multi-cluster warehouse = compute 隔离, serverless auto-suspend reducing cost.
  5. Semi-structured data native VARIANT type (JSON) parse 高效 比 Redshift Spectrum JSON 解析、 BigQuery 原生 JSON.
  6. 事故: 忘 关 auto-suspend = 数万美 monthly bill.

下一节 → Kubernetes Control Plane

Google BigTable / Spanner / Chubby

TL;DR

Google 三驾马车论文 (2003-2012) 奠定了 modern cloud infrastructure 的基础:

  1. GFS (2003) / Colossus: 分布式文件系统, append-only large files, chunk server + master
  2. BigTable (2006): sparse, distributed, persistent multi-dimensional sorted map (like wide-column store before Cassandra); Google's structured data platform
  3. Chubby (2006): distributed lock service (Paxos-based), 提供 small files + locks, 创始人后来演化 Spanner
  4. Spanner (2012): Google's globally-distributed SQL database 实现 external consistency (TrueTime+2PL).

这些 paper 跨 10 年发展, 从 GFS 到 Spanner 逐步打磨。本章概述每系统架构, trade-off, 工程师教训。


二、GFS / Colossus (存储层)

架构

Client → GFS Master (in-memory metadata) 
   → multiple Chunk Servers (Linux x86 with local disk)
  • Files 分成 64 MB chunks, replication 3 (default).
  • Master: single for metadata (name space, chunk mapping) — with persistent log + checkpoint recovery.
  • Chunk Server: only store data, heartbeats to master.

Design

  • Optimized for large streaming reads + append writes. Not random writes. No byte-range locking.
  • Append-only log to files: multiple clients concurrently append; GFS guarantees at-least-once append with record boundary, Application must handle duplicates.
  • Single master for metadata; Chubby later Paxos replace master.

关键教训

  • Single master bottleneck: bandwidth master ~1M ops/sec manageable, but failover long.
  • Record append: can have duplicate bytes; application padding or checksum.

后继: Colossus / D

GFS 演进到 Colossus (distributed, no single metadata server, real-time replication, high usage in Google storage stack). Spanner 及 BigTable sit on Colossus.


三、BigTable (2006) — Wide-column Store

数据模型

(row, column_family:column, timestamp) → cell value
Sorted by row lexicographically.

Sparse: each row can have different columns, for web crawl data.

架构

Client → BigTable Master (lightweight)
Tablet servers (serve data + Paxos-based replication)
Backed by GFS for storing SSTables + WAL.
Chubby lock service for master election.
  • Tablets: contiguous row-range (dynamic split). ~100MB each.
  • SSTables: immutable, LSM-tree compaction (compaction merges + delete tombstone).
  • WAL on GFS + MemTable scan.

Design decisions

  • Locality group per column family: can group frequently accessed columns together in memory (e.g., content family vs metadata family).
  • Bloom filter per SSTable, reduce random reads.
  • Single-row transaction (atomic read-modify-write) within a row.

关键教训

  • tablet split/rebalance 用 Chubby lock 调整; 运行中建复 杂 拆分 成本 高。
  • tablet server crash 需 WAL replay (from GFS) 重启, recovery time minutes.

四、Chubby (2006) — lock service

目的

Paxos-based library for distributed coordination (like a small Zookeeper) for master election + locking.

API

Open(file), Close(file), GetContents(), SetContents()
Lock(file), TryLock(file), GetSequencer()
  • 文件 小 (< 256KB) — not file system, but lock + config store.
  • Session lease: client lease 60s, renew each 30s heartbeat.
  • Client 拿到 sequencer 后 lock 保证 顺序.

复制

5 instances Paxos group, majority quorum; leader 选 持 有 60s lease.

关键教训

  • Chubby 不可用于 高吞吐 性 —— lock contention 单 leader; 只 hold metadata.
  • 之后 引入 基于 于 Chubby 的 Spanner Paxos groups replication.

五、Spanner (2012) — Google's globally-distributed SQL

关键特性

  1. External Consistency: strict serializable + linearizable across the world (TrueTime + commit-wait).
  2. SQL (not just KV), rich query + interleaved tables.
  3. Sharded into Paxos groups (one group per data tablet ~100-1000 rows).
  4. 2PC across Paxos groups for cross-shard transactions.
  5. TrueTime: {earliest, latest} API + commit-wait to ensure commit_ts is strictly in the past.

Data Model

CREATE TABLE Users (
  uid INT64 NOT NULL,
  email STRING(128),
) PRIMARY KEY (uid);

CREATE TABLE Albums (
  uid INT64 NOT NULL,
  aid INT64 NOT NULL,
  name STRING(128),
) PRIMARY KEY (uid, aid),
  INTERLEAVE IN PARENT Users;

Interleaved tables: Albums stored in same Paxos group as parent Users — join free local reads.

Architecture

Zone1 Master (with Paxos group)   Zone2   Zone3
    |                                |       |
   Colossus DFS <-- Paxos log

每 tablet Paxos group: 5 replicas per zone. Spanner driver knows about sharding and TrueTime.

关键教训

  • commit-wait ~14ms (TrueTime uncertainty). Not cheap; but consistent across continents.
  • Interleaved tables is mandatory for performance; without it 2PC cost 高。
  • Automatic resharding: tablet split based on size + load.

Spanner vs CockroachDB

对比SpannerCockroachDB
Time sourceTrueTime (atomic clock+ GPS)HLC (NTP, max offset 250ms)
External Consistency✅ Strictly❌ Not supported (bounded staleness)
SQL dialectGoogleSQLPostgreSQL
Interleaved tables❌ (无)
Deploy modelGoogle Cloud onlyopen source, any cloud
LicenseProprietaryOpen Source (BSL now CDL)

六、综合 Impact: Three Papers

这三 papers 变成 分布式系统 课程 必读, 同时 produce real products:

  1. BigTable → HBase (Apache HBase is open source BigTable rewrite)
  2. GFS → HDFS (Apache Hadoop HDFS inspired by GFS)
  3. Chubby → ZooKeeper / etcd
  4. Spanner → CockroachDB / YugaByte / TiDB (followers)

Google 内部 2003-2012 这 10 年间 实现了 分布式 infrastructure 几乎全部 基板。


七、典型事故与教训

GFS Chunk server fail domino

GFS 早期 整 组 3 chunk server all 同 rack, when rack switch fail, data lost. Fix: cross-rack placement mandatory; ensure all replicas on different rack domains.

BigTable Tablet Split Storm

Too rapid tablet dynamic split: 600 tablets 在一 table→ 2K+ after hours storm: routing overhead cluster slow. Fix: throttle tablet split rate -> background tasks.

Chubby Paxos Leader Election Delay

Chubby master 崩溃 后 leader election 15s pause 因 lock quorum, clients with stale lease 认 still valid lock, double locking 可能. Fix: higher epoch lease numbers + client always check lease validity.

Spanner 15ms commit-wait latency P99 spike

跨 zones 1% packet loss causes TrueTime outlier latency up 50ms commit-wait. Fix: network BBR TCP adds congestion control + retry.


八、易错清单

  1. GFS 不一致 append 乱字节: record writer 写 需 checksum; ensure application know at-least-once.
  2. BigTable 单行事务 only: cross-row multi-table 无 ACID; 必须 自己 embed 应用层 序 + cleanup.
  3. Spanner commit-wait 依赖 GPS/GPS loss: 长时间 无 TrueTime = Spanner 暂停写 或 退 成 eventual。 多 GPS + 原子钟 mandatory.
  4. Interleaved tables 让数据局部 性 write: 若 child table 高 write, parent group write hot。

九、这一章带走的东西

  1. GFS/Colossus: 分布式文件系统 append-only optimized for large streaming.
  2. BigTable: sparse wide-column store with LSM on GFS + Chubby coordination.
  3. Chubby: Paxos-based lock service bring coordination + 服务 discovery.
  4. Spanner: globally distributed SQL database — first external consistency implementation, TrueTime 是关键。
  5. Google 三 papers 贯穿 分布式 infrastructure backbone; virtually every other distributed DB inspired by them.
  6. "Why can't Spanner be everywhere cost is ~15ms commit wait, requires Google-level infrastructure".

下一节 → Dynamo Family

Dynamo Family: Cassandra / Riak / DynamoDB

TL;DR

Amazon Dynamo (2007 SOSP) 论文是 AP 键值系统的 圣经——永远可写, 最终一致, 让服务 (shopping cart) 总可用。 Cassandra / Riak / Voldemort / DynamoDB 是 Dynamo 概念的 工业 实现。Cassandra 成为 Facebook → Apache 的 分布式 wide-column store; Riak 由 Basho 设计为 eventually-consistent Dynamo-style with CRDT; DynamoDB 是 AWS managed 版本, 带 optional strong consistency。本章分析 Dynamo paper 核心: consistent hashing + virtual nodes + hinted handoff + anti-entropy + sloppy quorum + vector clock — 以及它们如何演化到 Cassandra 与其他派生。


一、Dynamo 纸核心架构 (2007)

前提: "总是可写" (AP)

一个 Amazon shopping cart 必须always add item 即便部分节点 down。 不能 block write => availability pick over consistency。 网络分区 持续 仍写 => AP system。

Consistent Hashing

key hash(key) → position in ring [0, 2^160-1]
每个 node 分配 N 个 virtual positions (vnodes)
写入 N 个 replicas walking clockwise.

每个 key 的 replication set = 首 node + next (N-1) clockwise nodes。

Sloppy Quorum

若 preferred N 节点部分 down, Dynamo 选次邻 node (no strict ring)。 保证 write 完成; use hinted handoff 暂存本地, 转给 real owner after recovery。

W, R, N Consistency Levels

  • N=3 (replication factor)
  • client configurable W (write quorum), R (read quorum)
  • W+R > N for quorum overlap => stale-proof

案例: Amazon's shopping cart: N=3, R=1, W=1 (快速, 但可能 stale)

Vector Clock + Sibling

Each version of data has vector clock [[node:counter]] from last update. Coordinator see sibling when vector clock incomparable → return all client for resolution (e.g., shopping cart merge by application)

Hinted Handoff

当 write target replica unavailable, coordinator store hint (包括 key+value) locally。 节点回来后 push hint => data.

Anti-Entropy with Merkle Tree

Background 比较 副本 differ range 推 specific missing keys。 Merkle Tree lazy periodic sync, ensure cold keys converge。

Gossip Protocol

Cluster membership via random peer gossip (no 集中 config server). 每个节点 每 1s 跟 random peer exchange membership info。


二、Cassandra (Facebook 2008 → Apache)

混合 Dynamo + BigTable

Cassandra 在 Dynamo's partition + replication model 上 加 BigTable 列模型。

每 table = partition key + clustering columns:

CREATE TABLE user_activity (
    user_id UUID,
    timestamp TIMESTAMP,
    event TEXT,
    PRIMARY KEY (user_id, timestamp)
);

每 partition 内 (secondary key) 多个 row, 语义类似 sparse wide-row.

Gossip, hinted handoff, read repair, anti-entropy — all inherited from Dynamo

Cassandra adds:

  • Thrift/Native CQL protocol (not Dynamo simple interface)
  • Tunable consistency (LOCAL_ONE / LOCAL_QUORUM / EACH_QUOURM)
  • SSTable + compaction (stcs / lcs / twcs)
  • Lightweight transactions (LWT via Paxos)

Implementation differences

  • Virtual nodes (vnodes) default 256 per node, ring mapping load balance auto.
  • LSM-tree: memtable + commitlog flush to SSTable → compaction merge.
  • Token-aware driver: clients aware of partition mapping reduce extra hop to coordinator.

Scaling

  • Linearly scalable writes by adding nodes; data re-distribute via vnodes migration (move half token ranges).

三、Riak (Basho 2009)

Pure Dynamo

Riak close follow Dynamo paper:

  • consistent hashing (ring) 配 vnodes (default 64);
  • Sloppy quorum + hinted handoff
  • vector clock sibling return allow_mult=true
  • anti-entropy (active AAE background Merkle Tree sync)
  • gossip membership

Data Types (CRDT since Riak 2.0)

Riak DT: counters, sets, maps, flags (Conflict-free). 免 application merge by making data type converge according to CRDT.

Trade-offs

  • No SQL query model; KV with bucket type CRDT; rich map/reduce-based query (JSON)
  • Eventual consistency / strong via R=quorum

现状态

Basho shut down 2017 (company gone), Riak open source 停 更新 但 派生 项目 继续: Riak KV (Bet365 financial use).


四、DynamoDB (AWS, 2012)

Managed Dynamo

  • Full managed (no operations like Cassandra)
  • API: GetItem, PutItem, DeleteItem, Query, Scan
  • tables: partition key + optional sort key; 无 列模型; each item max 400KB
  • capacity mode: on-demand or provisioned (RCU/WCU)

Read consistency

  • Eventually Consistent Read (default): 可能 stale; P99 < 10ms
  • Strongly Consistent Read: R=⌈N/2⌉+1 读 quorum; P99 < 20ms; return most recent commit

Global Table

Multi-region active-active; write any region, async 复制 → eventual convergence + regional durable

DynamoDB Streams / DAX cache

  • DynamoDB Streams: CDC for change events (KCL lambda trigger)
  • DAX: in-memory accelerator cluster 缓 读

Comparison with Cassandra

维度CassandraDynamoDB
Hostingself-managed / DataStaxAWS managed
Data modelrich wide-column + CQL简单 PK+SK
Write throughputlin scalable nodes, more hardwareauto scale RW capacity w/ 升
Data size limit100TB+ per node possible400KB per item max
TransactionsLWT (Paxos)DynamoDB Transactions (serializable with 2PC)
Global Tablemulti-DC replicasglobal table replicas automatic

五、Dynamo 家族对比总结

系统年份语言数据模型特色
Amazon Dynamo (Paper)2007Java (internal)simple KVorigin, trade-off AP focused
Cassandra2008Javawide-column + CQLBigTable model + Dynamo replication
Riak2009ErlangKV + CRDTCRDT native, ring成员 gossip
Voldemort2009Javasimple KVLinkedIn rethought Dynamo + simple API
DynamoDB2012managed简单 (PK/SK)managed, strongly consistent read + DAX

六、典型事故与教训

Cassandra TTL + tombstone explosion

Cassandra 2.x 默认 TTL 了 大量 过期数据 + tombstones 保留 gc_grace_seconds 默认 10 days, create read 需要 scan tombstones 让查询 慢 10x。 Solution: 修 compaction strategy TWCS + 缩短 gc_grace

Riak node flapping

Riak's gossip 处理 降 重 多 次 rejoin ring 当 node flapping, 导致 多 次 handoff storm. 修: Riak ring_creation_size 调 small + pre-join delay.

DynamoDB RCU/WCU burst mode exploiting

User 持续 writing H/RW mode 大量 short-burst capacity > provisioned. 短暂 burst 允许, 但 sustained → throttling (ProvisionedThroughputExceededException). Solution: on-demand mode 或 autoscaling to absorb spikes.

Cassandra LWT overwhelm

某 Cassandra cluster 过度使 LWT (Paxos based Conditional Updates), LWT 4 RTT latency 占 80% 的 QPS cause P99 高度 slow. Solution: redesign use conflict-free CRDT or Redis lock instead.

DynamoDB Global Table conflict

Multi-region write 同时 same key, 后到达 version 用 LWW timestamp overwrite earlier. 某 系统 在 高 并发 下 丢 更新。 Solution: vector clock or merge function on application end.


七、易错清单

  1. Dynamo-style: never guarantee strong consistency without W+R>N + read repair 反复 staleness.
  2. Cassandra LWT != scalable: Paxos 最高 throughput ~1K/s, not for hot path.
  3. GC_grace_seconds must set < TTL expire: 否则 tombstone 聚; must 缩短 window.
  4. Sloppy quorum can violate W+R>N shortcut: 当 consistent 需要, must enforce strict quorum (non-sloppy).
  5. DynamoDB transactions cost 2x more latency: simple reads prefer eventually.

八、这一章带走的东西

  1. Dynamo (2007) = AP leaderless ring + consistent hashing + vnodes + vector clock + hinted handoff.
  2. Cassandra = BigTable wide-column + Dynamo replication + CQL + tunable consistency.
  3. Riak = pure Dynamo + CRDT types.
  4. DynamoDB = managed AP KV + optional strong consistency + transactions.
  5. 核心 trade-off: AP = always available writes, eventual consistency, 可 需要 client-side merge和read repair。 opposite of Spanner's CP+EC model。

下一节 → Snowflake

Kubernetes Control Plane 设计剖析

TL;DR

Kubernetes (Google 2014, CNCF) control plane 是分布式的 "操作系统 for 容器"。 API Server / etcd / Controller Manager / Scheduler / CoreDNS / kubelet 构成 K8s 的组件。 它经 10 年 evolved 到 1.30+ 成功能 —— 从 Borg 学到 lessons: declarative API (desired state)+ control loop (reconcile) 是最本质 design。本章解析每个组件, controller pattern, etcd consistency guarantees, scheduler scoring, 与大规模 cluster 的 局限 (5000 node, etcd 2GB)。 经典事故 (API server overload, etcd bottleneck, OOM)。


一、Kubernetes 是为什么设计

Borg (Google 内部 2004-2015) 的 Lessons Learned

Kubernetes 由 Google Borg 团队设计, 重新构建时放弃 Borg 部分设计:

  1. Declarative 代替 imperative: submit pod spec as "desired state", control loop reconcile → state。
  2. Labels 代替 indexed job name/ID: free form label enable dynamic grouping.
  3. Cell-less (no big cluster master): Pods 可在任何 node 调度, 不可 on dedicated server block.

Key Borg insight: 用户declaration intent and platform resolves how.


二、Control Plane Architecture

┌─────────────────────────────────────────────────────┐
│                   Control Plane                      │
│                                                      │
│ kube-apiserver ←→ etcd (Raft cluster)               │
│      ↑↓                                              │
│ kube-controller-manager   (Replication, Deployment,  │
│                             Service, HPA controllers)│
│ kube-scheduler            (filter+score nodes→ pod)  │
│ cloud-controller-manager  (cloud LB/node lifecycle)  │
└─────────────────────────────────────────────────────┘
          ↓                ↑
    Node1: kubelet + kube-proxy
    Node2: kubelet + kube-proxy
    ...
    NodeN: kubelet + kube-proxy

kube-apiserver

  • REST endpoint for all operations (kubectl→ API); validate + auth + RBAC + admission webhooks.
  • Stateless, multi-replica scale; backed by etcd for all state.
  • Watch API: long-running connection 推送 event changes to clients (controller-kubelet watches).
  • Admission webhooks (mutating + validating) 插 入 custom logic.

etcd

  • Strongly consistent (Raft) singleton datastore for all cluster state.
  • watch API to push changes to controllers, scheduler, kubelets.
  • Limits: 2GB total DB (default quota-backend-bytes); 需 compaction 防止磁盘满.

kube-controller-manager

二进制 包含 20+ controllers 在一个 single process (bin packing). 最值 controller 类:

Controller做什么
ReplicationController (RC)pods 数 保持在期望
ReplicaSetController新一代 RC (select label match)
DeploymentController滚动更新 replicaset 的策略
StatefulSetControllerorder/persistent/identity
DaemonSetController每个 node 跑 一个 pod
HPA Controllerscale deployment via CPU/memory/ custom metric
Service Controller映射 endpoints to service VIP
Namespace Controllerlifecycle (terminating gap 清)
Job/CronJob Controllerbatch jobs

每个 controller 都是 watch-inform-cache (list/watch) + reconcile loop.

kube-scheduler

  • Watch new pods with nodeName=='', 选合适 node.
  • 2 step: Filter (feasibility)Score (ranking).
  • Scoring: NodeResourcesLeastAllocated (spread defaults) 或 NodeResourcesMostAllocated (bin-pack compact).
  • affinity, anti-affinity, taint/tolerations → constraint-aware。

kubelet

Per-node agent: sync pod spec <-> container,通过 CRI (Container Runtime Interface):

  • Docker (deprecated), containerd, CRI-O.

kube-proxy

iptables / IPVS rules for service ClusterIP/NodePort load-balancing; endpoint watch service logic endpoint.


三、Declarative & Reconcile Loop

K8s concept: "Tell me desired state, I make it so".

Controller Pattern

for {
    desired := getDesiredState()    // from API server
    current := getCurrentState()    // from 监控
    if current != desired {
        makeChanges(desired - current)
    }
}

每个 controller 做 reconcile:

  • Deployment controller: 若 current_replicas != desired_replicas → create/delete pod.
  • Node controller: monitor node's health; 条件 down, 驱逐 pod.

Watch vs Polling: Caching informers

K8s client-go informer: list-and-watch pattern holds local cache synchronized to API server via Watch API updates. Reduce API server load.


四、K8s 扩展 (Admission Webhooks, CRDs)

Custom Resource Definitions (CRDs)

apiVersion: apiextensions.k8s.io/v1
kind: CustomResourceDefinition
metadata:
  name: myresources.mycompany.io
spec:
  group: mycompany.io
  versions:
    - name: v1
      served: true
      storage: true
  scope: Namespaced
  names:
    plural: myresources
    singular: myresource
    kind: MyResource

then people write controllers for CRDs (operator pattern). e.g., etcd-operator, prometheus-operator, istio-operator。

Admission Webhooks

  • Mutating: 修改资源 (inject sidecar, default toleration)
  • Validating: reject 非法 requests
apiVersion: admissionregistration.k8s.io/v1
kind: ValidatingWebhookConfiguration
...

Service Mesh (Istio / Linkerd)

Pods 获 sidecar envoy (injected via mutating webhook), intercept traffic for mTLS, retry, circuit breaking, telemetry.


五、Scaling K8s

Saturation Limits (up to 1.29)

维度限制
Nodes per cluster5000
Pods per node110 (default)
Total pods in cluster150,000
etcd DB sizemax 8 GB (default quota-backend-bytes)
API server QPS~2K-5K sustained

Beyond 5000 node → multi-cluster (Kubernetes Federation v2 / Karmada ).

单 cluster 大 node 事故

  • etcd 2GB 限额 打满; 定期 compaction + auto defragmentation.
  • API server info too heavy → ert-pas watch demand overload; split-load: apiserver replica shard by labels/namespaces.

六、典型事故

API Server overload by Watch storm

Large cluster 1000+ nodes 同时 watch pods; API server OOM after simultaneous polling retries after etcd timeout. Solution: increase max-requests-inflight + apply priority & fairness to 限 watch.

etcd database OOM

默认 2GB quota. etcd 连 写 高但 没 compaction, 磁盘 增长 超 2GB → master-alarm 无新写, all CRUD control plane stale. Fix: auto-compaction period=30m.

Scheduler queue jam at 1000 pending pods

During scale-down + replicaSet reconcile, 2000+ pods instantly pending. Scheduler filter/score per pod O(N) nodes significant blocking, and queue 退后分钟. Fix: take batch scheduling extension (volcano, coscheduling) 让 批 pods 同时 评 分。

CNI not ready → all pods stuck ContainerCreating

New cluster, Calico CNI missing tolerations & nodeSelector. All pods pending → network plugin not found on node. Fix: DaemonSet CNI taint 同补。


七、易错清单

  1. etcd 总量 必 ≤ 8GB, periodically compaction + defrag to avoid mvcc: database space exceeded.
  2. Watch 不要用 label 过滤 → all registered filter fallback to API server slow; use fieldSelector 防 watching all pods.
  3. Mutating webhook timeout 必 ≤10s, else API server reject requesting pod admission.
  4. Resource limits 不可 requests > limits: CPU burst OK, but mem request must = mem limit (or risk OOM kill).
  5. kube-proxy mode iptables vs IPVS : IPVS is recommended for 1000+ services, better throughput + load balance distribution.
  6. kubelet configuration rotation: node restart 或 证书 到期 短路径 kerberos internal admin cert handling rotate.

八、这一章带走的东西

  1. K8s control plane = etcd store + API server + controllers + scheduler + kubelet 代理.
  2. Declarative desired state + controller reconcile loop 是核心设计。
  3. Scale limit ~5000 nodes + 150K pods; multi-cluster beyond.
  4. Key controllers: Deployment / ReplicaSet / HPA / StatefulSet / DaemonSet / CronJob.
  5. CRD + operator pattern extend K8s to manage any resource type.
  6. etcd quota 2GB frequently hits; must compaction + monitoring graceful.
  7. K8s Scheduler: filter → score → bind; multi-scheduler extension 支持 batch scheduling via coscheduling plugin.

下一节 → 回到总览

第八部分 · 计算机组成原理

一句话

计算机组成原理 是软件工程师的第二语言——理解 CPU 流水线、缓存一致性协议、GPU SM/warp、HBM、NVLink、TPU 脉动阵列之后,你写的代码才能真正"touch metal"。所有第一部分到第七部分的高层抽象最终都 bind 到硬件:B+ tree 的 page 大小 16KB 是因为 x86 页表粒度为 4KB、SSD 擦除块是 16KB;LSM-tree 的 compaction 必须考虑 DRAM 带宽和 SSD 写入放大;分布式系统的 fsync 延迟上限取决于 NVMe 控制器 NAND 写延迟。

思想链

[SQL Query]
  └─> B+ tree index lookup → 2-3 level page read
      └─> OS page cache / shared_buffers check
           └─> If miss: NVMe SSD read → NAND flash read
                 └─> SSD controller → PHY → PCIe lane → CPU cache
                      └─> CPU L1 cache hit (1ns) → compute result

[Training loop: GPT model]
  └─> NVIDIA H100: matmul on Tensor Core (989 TFLOPS FP8)
       └─> Warp scheduler: 32 threads / warp, 64 warps / SM
            └─> Shared memory async copy (TMA unit) from HBM
                 └─> NVLink → NVSwitch → 8-GPU node
                      └─> InfiniBand/RoCE → 10K GPU cluster

章节

读完应能回答:

  1. 为什么 C 代码 int sum = a + b 在 CPU 上要经过"取指→译码→执行→访存→写回" 5 阶段
  2. Zen 5 vs Golden Cove vs Apple M3 微架构的核心差异(解码器宽度、ROB 大小、执行端口数)
  3. L1 cache 为什么只有 32-64KB?为什么 L2 要大?为什么 MESI 协议需要 4 种状态?
  4. Tensor Core 一个 cycle 做 4×4 FP16 FMA 意味着什么?为什么 HBM3 带宽是 3.35 TB/s 但 GPU 算力是 989 TFLOPS?
  5. CUDA warp 为什么是 32 个线程?divergent branch 的代价是什么?
  6. NVLink 5 为什么是 900 GB/s per GPU?NVSwitch 如何让 8 GPU 全互联?
  7. x86 的 rep movsb 和 ARM SVE 的 ld1d 各在什么场景下有效?

历史

1945 von Neumann 提出 stored-program concept;1965 Tomasulo 算法解决 float 指令依赖问题;1995 DEC Alpha 21264 是第一个 4-way superscalar OoO 的王者;2007 NVIDIA Tesla 用 unified shader model 开创 GPU 计算;2013 Google 发表 TPUv1 论文用 systolic array 做 inference;2016 Pascal P100 首次引入 HBM2;2022 Apple M1 Ultra 用 UltraFusion 把两芯片无缝接合;2023 Grace Hopper GH200 用 CXL 共享 CPU/GPU 内存。


下一节 → CPU 流水线与指令级并行

CPU 流水线与指令级并行

TL;DR

CPU 执行 add x1, x2, x3 不是原子瞬间完成的——它经过 取指 → 译码 → 执行 → 访存 → 写回 五阶段流水线。 理想状态下每 cycle 完成一条指令, 但 三种冒险 (hazard) 会打乱节奏:

  1. 结构冒险 (Structural): 多指令争同一硬件资源 (如单端口 L1 cache)
  2. 数据冒险 (Data): 指令依赖前一条的结果 → forwarding / stalling
  3. 控制冒险 (Control): 分支跳转导致流水线等待 → branch prediction / speculative execution

"IPC = 1" (每 cycle 一条) 在现实中被这三种冒险撞成 IPC ≈ 0.3-0.8 (单发射标量处理器)。 现代 CPU 通过 forwarding、branch predictor、speculation 来逼近 IPC=1。 本章推导 5-stage RISC pipeline 的每个 stage, 分析 hazards 与硬件修复代价, 最后落到真实 x86/ARM 核心 (Golden Cove、Zen 5 Cortex-X4 等等)。


一、5-stage Classic RISC Pipeline

RISC 5 阶段

flowchart LR
    IF[IF: Instruction Fetch<br/>取指] --> ID[ID: Instruction Decode<br/>译码+寄存器 读]
    ID --> EX[EX: Execute<br/>ALU 计算]
    EX --> MEM[MEM: Memory Access<br/>访存(load/store)]
    MEM --> WB[WB: Write Back<br/>写回寄存器]

每个 stage 1 cycle, 每 cycle 一个组新指令 enters IF 同时前条指令移到下 stage。 所以 5 条指令可同时在流水线 (不同 stage), CPI = 1 (理想)。

例子: ld x1, 0(x2) (RISC-V load)

CycleIFIDEXMEMWB
1ld x1,0(x2)
2add x3,x4,x5ld x1,0(x2)
3sub x6,x7,x8add x3,x4,x5ld x1,0(x2)
4...sub x6,x7,x8add x3,x4,x5ld x1,0(x2)
5......sub x6,x7,x8add x3,x4,x5ld x1,0(x2)

第 5 cycle 后 x1 数据才写回寄存器, 后读该 x1 的指令必须等


二、Data Hazard & Forwarding

三种 Data Hazard

类型说明
RAW (Read After Write)add x1,x2,x3sub x4,x1,x5后条需要等前条写完 x1
WAR (Write After Read)add x1,x2,x3sub x2,x4,x5后条写 x2 不能覆盖前条读 x2
WAW (Write After Write)add x1,x2,x3sub x1,x4,x5两条都写 x1, 需保序

WAR/WAW 主要用于 OoO 跑 (见下一章超标量); RAW 是 pipeline 最常见 hazard。

Forwarding (Bypassing)

add x1,x2,x3 在 EX 结束已经有了结果 (在第 3 cycle 末尾), 但 WB 才是第 5 cycle。 如果下条指令是 sub x4,x1,x5, 没必要等 WB——直接从 ALU output 绕过寄存器文件 forward 到下条 ALU input:

Cycle 3: add EX (result in pipeline latch)
Cycle 4: sub EX — receives forwarded x1 from add latch (instead of WB register)

必须加 forwarding multiplexer + comparators 检查 dest register 与 src register 匹配。

Load-Use Hazard

ld x1,0(x2) → value 在 MEM 结束后 (第 4 cycle 末) 才能用 而 add x3,x1,x4 在第 3 cycle EX 开始等不到。 一个 bubble (pipeline stall) 插等:

CycleIFIDEXMEMWB
3addld bubbleld
4addld bubblebubble(EX stall)ld
5add 后指令add (check x1)bubblebubbleld

只能 stall 1 cycle, 不可能 forwarding 前 收 (MEM 值, EX 太早).

现代 CPU 编译 器可以重新排序 ldfew independent instructionsuse 隐藏潜伏期。


三、Control Hazard & Branch Prediction

分支代价

beq x1, x2, target

第 3 cycle EX 结束才知道 taken or not。 但是直到第 3 cycle 结束 流水线 already fetched instruction after branch → 若 taken 则 两条 fetched 指令必须 flush。

Flush = 第 3-4 cycle 塞 NOP (no-op) = losing 2 cycles。

Branch Prediction 历史

预测器准确率
Static (always not taken)early RISC~50%
1-bit BHT (Branch History Table)单 bit T/NT selector~80%
2-bit saturating counter经典 "双状态" predictor~90%
Gshare / Gselect (global history)last N branch outcomes XOR with PC 选择预测~95%
Tournament (hybrid)多 predictor competition~97%
TAGE (TAgged GEometric)multi-length history, 近代: Haswell 起~99%
Perceptron / Neural branch predictorAMD Zen 4/5, Apple M3~99.5%

2-bit Saturating Counter

状态机:

       taken
SNT ─→ WT ─→ ST
  ↑      ↓ taken
  └── WNT ←─ SNT (reset via not taken)

四状态: Strongly Not Taken (00) → Weakly Not Taken (01) → Weakly Taken (10) → Strongly Taken (11)。 两个 not taken 后 从 WT 退到 WNT, 最后 not-taken 进 SNT。

Gshare / Gselect

Global branch history 寄存器 (GBHR): N 次最近 taken/not taken shift-in。 Gshare = XOR(PC[low bits], GBHR) → index BHT。 Gselect = concatenation。

Modern tournament: 多 predictor vote + 选择器 choose per branch。

Branch Target Buffer (BTB)

BTB 存储了"曾经 taken 的分支地址 → 目标地址"映射: 在 IF 阶段就能读取 target PC, 不经等 EX 阶段算出。 第一道缓存: 命中 → fetch 立即转到 target。

Return Address Stack (RAS)

函数返回 ret 是一个间接分支——每次 call 把 return_addr 推入 RAS LIFO stack; ret 就 predict top of RAS → 准确率 ~99.99%。


四、Structural Hazard

原因

  • 单端口 L1 D-cache 不能同时读 (ID stage) 和 写 (MEM store stage)
  • 寄存器文件单端口读 无法 multi 端口 支持同时向多 stage 读

RISC 寄存器文件多端口解决。 Cache split I and D (L1 I-cache for IF + L1 D-cache for MEM), 只有在 store 时 单端口 structural hazard。

x86 寄存器名字少 (8×16+ → 32) — 但有 物理寄存器 大 (192+ 用于 OoO 改名)。


五、真实 CPU 流水线深度

CPU流水线深度特点
ARM Cortex-A53 (in-order)8 stage低功耗, IPC≤1
ARM Cortex-A78 (OoO)10-15dispatch → 8-wide decode
Apple M3 P-core (OoO)~12-16extreme 宽度 decode 9-wide
Intel Golden Cove (12th gen)14-19 stages (complex decoded uops)6-wide decode + 12 execution ports
AMD Zen 5~19 stages (decode + uop cache)dual 4-issue front-end

深度 大 = 更高 clock rate (5GHz+) 但 mispredict penalty 也大 (19 cycle flush)。


六、Micro-op (uop) 与 Macro-op Fusion

x86 是 CISC 指令集——add [rax], rbx (读 rax 存 memory) 是一个复杂 CISC 指令; decode 阶段 split 成 2-4 RISC-like μops:

μop1: 计算地址 rax→pAddr
μop2: load  [pAddr] → tmp
μop3: add tmp, rbx
μop4: store result [pAddr]

宏融合: cmp+jcc 在 decode 时组合成一单 μop compare-and-branch, 比 两 μop 少 一半 dispatch。 Intel Skylake+ 支持。

uop Cache (DSB)

Decoded Stream Buffer (Intel 内部): decoded μops 缓存; 遇到 1K+ hotspot loop → 从 μop cache 读, 绕过 decode pipeline → 省 2-3 cycle。 AMD Zen 3/4 有 "Op Cache" 类似。


七、典型事故

Pentium FDIV Bug (1994)

FDIV 引起 浮点除法 lookup table 部分错位 (4 entries missing 1066) — 1 in billions 错 误 指令。 Intel 召回 $475M。

Spectre/Meltdown (2018)

Branch predictor speculation + "load secret despite 训 坏 memory access" − 侧信道攻击。 显示 speculative execution 泄露 敏感数据(side-channel)。 软件修补 → store bypass attack、retpolines, 性能损失 5-30%。

ARM M1 Virtualization trap

Apple M1 没有 x86 TOTAL_STORE_ORDER 内存模型, 运行 x86 translation 导致 额外 memory barrier, 性能 缺陷。 Rosetta 2 opt fix.


八、易错清单

  1. Load-use stall 无法 被 forwarding 消除 — only next 指令 位置 reorder 可降。
  2. Branch predictor 99% 等于 still 1% flush: at GHz 率 1 秒 = 10M flush (每 flush ~19 cycle = 空过 190M missed cycles).
  3. μop 数量 不一定 省: add [rax], rbx 在 decode 分解 成 4 uops, 可慢 than 分离 load + reg op on RISC。
  4. BTB miss cost > 2 cycle: 需 重算 分支地址, pipeline fill restart = 10-20 cycle.
  5. Write-after-write (WAW) 虽 pipeline raw 少 见, OoO 在 超标量 时常出现 → reg renaming 解。

九、这一章带走的东西

  1. 五阶段 pipeline: IF/ID/EX/MEM/WB;CPI=1 是 ideal, reals IPC 更有用.
  2. Data hazards:forwarding 可解决 RAW 但 load-use 必须 stall.
  3. Control hazards:branch prediction (2-bit / Gshare / TAGE / perceptron) + BTB + RAS.
  4. 流水线越深 → clock 高频, branch mispredict penalty ~19 cycle.
  5. x86 μop decode: CISC→RISC 转 min 1 op, add [reg], reg 分解成 3-4 uops.
  6. Spectre / Meltdown: speculative execution 允许 side-channel attack → OS/hardware fix → performance loss 5-30%.

下一节 → 超标量 / OoO / Tomasulo

超标量 / 乱序执行 / Tomasulo 算法

TL;DR

上一章的 5-stage pipeline 是 IPC ≤ 1 的标量在序(in-order)处理——一条指令等 cache miss,后面几百条全排队干等。超标量(superscalar)+ 乱序执行(Out-of-Order, OoO)的核心思想是:同时发射多条指令到多条流水线,哪条操作数齐了就先执行哪条,跟程序顺序无关

flowchart LR
    subgraph 程序顺序
        A["ld x1, [ptr_a]"] --> B["add x2, x1, 1"]
        B --> C["sub x3, x4, x5"]
        C --> D["mul x6, x2, x3"]
        A --> F["← Cache Miss 等 300 cycle"]
    end
    subgraph OoO执行
        C2["sub x3, x4, x5 ✅ 不依赖 x1/x2, 直接执行"]
        F2["等 x1 回来 → add → mul"]
    end

关键数字:in-order IPC ≈ 0.3–0.8(大部分 stall 等内存),OoO IPC ≈ 0.5–2.5(现代 6-wide 核心常见每周期完成 1.2–1.8 条指令,密集计算可冲 3–4+)。

本章从 1966 年 Tomasulo 算法讲起,逐步推到当代 Golden Cove / Zen 5 / M3 的完整 OoO 管线:寄存器重命名、ROB、保留站、LSQ、投机执行、内存消歧。


一、为什么需要 OoO?

在序(in-order)的 IPC 天花板

[指令流]
  ld x1, [a]       ← L1 miss,等 4 cycle
  add x2, x1, 1    ← RAW 依赖 x1,stall 等 4 cycle
  sub x3, x4, x5   ← 跟上面两条无关,却在流水线里被挡住
  mul x6, x7, x8   ← 同上,被挡

在序处理器的发射阶段严格按程序顺序检查操作数:即使 sub 的两个源寄存器 x4, x5 都已就绪,也必须等前面的 add 先发射——这叫做"in-order issue"。IPC 被三种闲置打下来:

因素影响
数据依赖(RAW)每周期可执行 N 条,但真正独立的没几条
Cache miss即使 99% L1 命中,剩下 1% 平均 miss penalty ~12 cycle (L2) → ~50 cycle (L3) → ~300 cycle (DRAM) — 单条堵塞全部
分支误预测mispredict 时 flushing 全流水线重来 — 深流水线 19+ cycle 白费

OoO 的解法:解耦发射和执行——指令在前端按程序顺序取入,进入"指令窗口"(instruction window)后,哪个操作数到了就执行哪个。遇到 cache miss 的 ld,它后面的独立指令照样发射执行。未就绪的操作数通过物理寄存器标签(tag)等待,而不是阻塞整条流水线。

典型对比:

in-order:   IPC ≈ 0.3–0.8    (ARM Cortex-A53, original Pentium)
OoO:        IPC ≈ 0.5–2.5    (Golden Cove, Zen 5, M3 — 真实负载)
理想 ILP:    IPC ≈ 3–4+      (specint 密集基本块, 无 miss)

二、Scoreboard(记分板)—— CDC 6600 (1964)

Tomasulo 的前身。记分板是一个集中化的硬件表,跟踪每条指令的目标寄存器、源寄存器状态:

flowchart TB
    IF[取指] --> Issue[发射: 检查记分板<br/>目标寄存器是否正被占用?]
    Issue -->|无冲突| RO["读操作数<br/>Read Operands"]
    RO --> EX["执行<br/>Execute"]
    EX --> WB["写回<br/>Write Back"]
    Issue -->|WAR/WAW 冲突| STALL[Stall]
    WB -->|通知记分板释放目标寄存器| FREE["释放"]

记分板的三条检查:

  1. WAW:目标寄存器是否有未完成的写?有 → stall。
  2. WAR:源寄存器是否被其他指令作为目标寄存器(正在写)?是 → stall。
  3. RAW:源寄存器在记分板中标记为"未就绪"?是 → stall。

记分板的致命缺陷:WAR/WAW 靠 stall 解决,不靠重命名。意味着只要有寄存器名字冲突就得等前一条写完——即使两条指令毫无数据关系。这严重限制了 ILP。

# 记分板下白白 stall 的例子
fdiv f0, f2, f4    # 浮点除法:20 cycle
fadd f0, f6, f8    # 也写 f0 → WAW,必须等 fdiv 写完,即使 fadd 1 cycle 就能做完

三、Tomasulo 算法 (1966, IBM 360/91)

核心洞见:寄存器重命名 via 保留站

Tomasulo 不再用寄存器名字来跟踪依赖关系,而是用保留站(Reservation Station)标签来流通数据。每条指令被发射时分配到某个保留站;保留站的编号/标签(tag)临时取代寄存器名。

flowchart TB
    subgraph Issue["① 发射 (Issue)"]
        I1["从指令队列取指令<br/>分配保留站 entry"]
        I2["源寄存器已就绪?"]
        I2 -->|是| I3["读取寄存器值 → 保留站"]
        I2 -->|否| I4["在保留站中记下<br/>'等待某个 tag'"]
    end
    subgraph Exec["② 执行 (Execute)"]
        E1["保留站等待<br/>直到所有操作数就绪"]
        E2["操作数到齐 → 送入功能单元"]
    end
    subgraph WB["③ 写结果 (Write Result)"]
        W1["功能单元输出 → CDB<br/>(Common Data Bus)"]
        W2["所有保留站 + 寄存器堆<br/>监听 CDB tag 匹配"]
        W3["匹配 → 捕获结果<br/>释放保留站 entry"]
    end
    I4 --> E1
    E2 --> W1
    W1 --> W2 --> W3

公共数据总线(CDB, Common Data Bus) 是 Tomasulo 的核心:它不是写回寄存器后就完事了,而是广播到所有等待这个结果的保留站。等于在硬件层面实现了"发布-订阅"——谁要这个值、谁拿,不用经过寄存器文件。

算法步进(浮点执行为例)

动作保留站寄存器状态表
0fld f0, data1RS0 {op: load, tag: T0}f0 → T0
1fld f2, data2RS1 {op: load, tag: T1}f2 → T1
2fmul f4, f0, f2RS2 {op: mul, src1: wait T0, src2: wait T1, tag: T2}f4 → T2
3fadd f0, f6, f8RS3 {op: add, src1: f6 ready, src2: f8 ready, tag: T3}f0 → T3
4T0 返回 (ld f0 done) → CDB 广播RS2 捕获 src1, 仍等 src2
5T3 返回 (fadd f0 done) → CDB 广播寄存器和等待 f0 者更新f0 → T3
6T1 返回 (ld f2 done) → CDB 广播RS2 全部就绪 → 发射乘法

关键观察

  • 第 3 步 fadd f0, f6, f8 也写 f0——这在记分板中会 stall(WAW),但在 Tomasulo 中,f0 被重命名为 T3,与之前的 T0 完全独立。这是硬件寄存器重命名的创始实现。
  • 第 5 步中的 fadd f0 用 1 cycle 就算完了——但记分板中,这个操作得等所有写 f0 的前序指令全结束,20+ cycle。

Scoreboard vs Tomasulo 比较

维度ScoreboardTomasulo
WAR / WAW 处理stall(阻止发射)寄存器重命名(不 stall)
RAW 处理stall 等操作数就绪tag 匹配:操作数就绪自动唤醒
依赖追踪集中化记分板查表分布式保留站 + CDB tag 广播
结果传播写寄存器 → 源指令再读寄存器CDB 广播 on-the-fly forwarding
复杂度较简单较高——需要 tag 匹配和 CDB 仲裁
并行度上限WAR/WAW 限制了 ILP仅受 RAW(真依赖)限制

四、现代 OoO 管线

Tomasulo 算法在当代 CPU 中的形态已经大幅演化。现代超标量 OoO 管线的完整流程:

flowchart LR
    Fetch["① Fetch<br/>取指"] --> Decode["② Decode<br/>译码(x86→μop)"]
    Decode --> Rename["③ Rename<br/>寄存器重命名"]
    Rename --> Dispatch["④ Dispatch<br/>分派→保留站/ROB"]
    Dispatch --> Issue["⑤ Issue<br/>保留站→功能单元"]
    Issue --> Execute["⑥ Execute<br/>执行"]
    Execute --> Writeback["⑦ Writeback<br/>结果→ROB"]
    Writeback --> Commit["⑧ Commit<br/>按程序顺序退役"]

4.1 寄存器重命名(Register Renaming)

x86-64 只有 16 个 GPR(加上 16 个向量寄存器),但程序中大量的 mov 和中间变量会产生大量虚假 WAR/WAW 依赖。硬件维护一个庞大的物理寄存器文件(Physical Register File, PRF),每条写入目标寄存器的指令被分配一个空闲物理寄存器。

架构寄存器 → 重命名表 (RAT, Register Alias Table) → 物理寄存器
// 源代码
int a = b + c;     // r0 = r1 + r2
int d = e + f;     // r3 = r4 + r5
int g = a + d;     // r6 = r0 + r3  (RAW on r0, r3)
int a = h + i;     // r0 = r7 + r8  (WAW on r0 — 重命名化解)
指令架构目标寄存器分配的物理寄存器RAT[r0]
add r0, r1, r2 (a = b+c)r0p17p17
add r3, r4, r5 (d = e+f)r3p28r3→p28
add r6, r0, r3 (g = a+d)r6p42
add r0, r7, r8 (a = h+i)r0p55p55

第四条 r0 = p55 不依赖第一条 r0 = p17——两者用不同物理寄存器,WAW 虚假依赖完全消失。物理寄存器数量决定了 OoO 窗口的上限:Golden Cove 物理寄存器 ~280 个

4.2 ROB(重排序缓冲区,Reorder Buffer)

ROB 是一个环形缓冲区,按程序顺序记录每条发射的 μop。它的三个核心功能:

  1. 按序提交(in-order commit):μop 执行完可以把结果放到 ROB entry,但只有该 entry 到达 ROB 头部且不异常时才对架构状态可见。
  2. 精确异常(precise exception):当前面某条指令产生异常时,ROB 按序提交隐式保证了异常点之前的所有指令已完成、之后所有指令无副作用——直接 flush 异常指令后的所有 ROB entry。
  3. 投机状态缓冲:分支预测后的所有 μop 结果暂存 ROB;分支被误预测时 flush ROB 后段。
flowchart TB
    subgraph ROB["ROB 环形缓冲"]
        H["Head ← 下一条待提交"] --> E1["entry: add r0,p17,p55 (done ✓)"]
        E1 --> E2["entry: ld r1,[r2] (waiting...)"]
        E2 --> E3["entry: mul r3,r0,r1 (blocked: 等上图 ld)"]
        E3 --> T["Tail ← 下一条待插入"]
    end

每 cycle 提交 N 条(N = 提交宽度,通常 = 发射宽度,6–8 条/cycle)。

4.3 保留站(Scheduler / Reservation Station)

保留站是乱序发射的核心——每个执行端口有若干 entry。μop 在 dispatch 阶段被分配到某个保留站,携带:

  • 操作码(opcode)
  • 物理源寄存器 A:值已就绪 → 直接存值;否则 → 存 tag(产生该值的物理寄存器 / ROB id)
  • 物理源寄存器 B:同上
  • ROB entry id(用于提交时定位)

当且仅当所有源操作数就绪(即 tag 为空,值已捕获),条目被"唤醒",竞争功能单元。各端口独立仲裁(通常是最老的就绪条目优先),每 cycle 可发射 N 条。

4.4 LSQ(Load-Store Queue,加载-存储队列)

Load/Store 指令不光有寄存器依赖,还有内存依赖

  • 写后读(RAW via memory)str [addr], r1ldr r2, [addr](store-to-load forwarding)
  • 读后写(WAR via memory)ldr r1, [addr]str [addr], r2(load 必须先完成读,不能被后面的 store 覆盖实际内存顺序)
  • 写后写(WAW via memory)str [addr], r1str [addr], r2(需按序写)

LSQ 按程序顺序保存所有未提交的 load/store。当 load 发射时,它搜索 LSQ 中所有未完成且地址较早的 store——若地址匹配则直接从 store_data 转发。


五、投机执行:越过分支

分支预测器猜了方向,但"猜"不等于"对"。误预测的代价是:管道中所有依赖于分支后指令的状态必须清空。ROB 机制处理这一件事:

[正确预测]
Fetch → ... → Commit (branch resolved, taken ✓) → 继续执行

[误预测]
Fetch → ... → branch resolved (actually NOT TAKEN ✗)
  → 找到 branch uop 的 ROB entry
  → flush 所有 ROB entry 编号 > branch
  → 重置 RAT(寄存器重命名表)为分支前的快照
  → 重定 PC 为正确路径,重新取指

恢复快照:每次分支指令在 rename 阶段时,硬件会对 RAT 做一次 checkpoint(保存当前映射表副本)。误预测发生时从最近的 checkpoint 恢复。Zen 5 / Golden Cove 一般支持 16–32 个 RAT checkpoint。

资源代价:误预测率 1% 听起来低,但 5GHz × 1% = 每 ns 0.05 次 mispredict。每次 mispredict 浪费 ~19 cycle(Golden Cove 深度)→ 约 4ns 无效工作 → 相当于 ~2% 的无用功。但这远好于 rest 100 cycles 不做任何指令——投机执行将分支代价从 "必等" 变成了 "小概率等"


六、真实 CPU 微架构对比

参数Intel Golden CoveAMD Zen 5Apple M3 P-coreARM Cortex-X4
解码宽度6-wide (x86→μop)dual 4-wide9-wide8-wide
μop Cache4K entries (DSB)6.75K (Op Cache)超大 μop cache4K entries
ROB 大小512 entries384 entries (est.)~630 entries~400 entries
物理寄存器~280 (int) + ~224 (vec)~224 (int) + ~192 (vec)~400+ (int)~300 (int)
执行端口12 (5 int + 5 fp/vec + 2 store)~16 int+fp 共享高度专用化多端口~12
保留站/Scheduler entries97 (unified)96 (int) + 64 (fp)~160~80 (int)
提交宽度8 μop/cycle8 μop/cycle~10 μop/cycle8 μop/cycle
分支预测TAGE + NeuralPerceptron-based大规模感知器TAGE-like
分支误预测惩罚19–20 cycles~19 cycles~12–14 cycles~13–15 cycles
时钟频率4.5–5.7 GHz5.0–5.7 GHz~4.05 GHz~3.4 GHz

核心洞察

  • Apple M3 更宽(9-wide decode)、更深(~630 ROB)但更低主频,靠 ILP 取胜 → 每 clock 做更多工作。
  • Intel/AMD 走高主频路线(5.7GHz+),深流水线代价是 mispredict 惩罚重(19 cycles)。
  • ROB 大小直接决定 OoO 时间窗口:512 entries / 8 commit width ≈ 64 cycles 的指令窗口 = ~120 条 x86 指令的范围找 ILP。
  • ARM 苹果核心放弃 SMT(超线程),每条线程独占全部 OoO 资源 —— 单线程峰值 IPC 更高。

七、执行端口与功能单元

每个执行端口绑定若干功能单元。一条 μop 被 issue 到对应端口,不同指令有不同的延迟和吞吐:

指令类型延迟 (cycle)吞吐 (每 cycle)示例
整数 ALU (add/sub/and/or/xor/移位)14–6add rax, rbx
整数乘法3–51imul rax, rbx
整数除法10–20+1/10–20idiv rax, rbx
浮点加法 (FP ADD)3–41–2addss xmm0, xmm1
浮点乘法 (FP MUL)4–51–2mulss xmm0, xmm1
浮点乘加 (FMA)4–52vfmadd132pd zmm0, zmm1, zmm2
Load (L1 hit)4–52–3mov rax, [rbx]
Store (L1 hit)1 (AGU) + 1 (store-data)1–2mov [rbx], rax
分支1 (执行端口)1–2je target

FMA 关键a = b × c + d 在支持 FMA 的 CPU 上用一条指令完成——比先乘再加省一个 round-off 误差和 1–2 cycle 延迟。Tensor Core / SME 的算力全是基于 FMA。

AGU(Address Generation Unit)是 Load/Store 专用的加法器,计算 base + index×scale + offset 用于内存地址生成,不占用 ALU。


八、内存消歧(Memory Disambiguation)

当一条 load 之前有未完成的 store 且 store 地址未计算出时,load 无法确定是否依赖该 store:

str  r1, [r2]       # store to [r2]  → r2 未算出
ldr  r3, [r4]       # load from [r4] → 与 [r2] 冲突?未知

此时 load 有两个选择:

  1. 保守等待:等所有前面的 store 地址都出来 → 安全但丢性能(每 cycle 几十条指令被堵住)。
  2. 投机执行:假设 load 与 store 不冲突,先发射 load;当 store 地址后算出时验证——若发现地址相同,flush load 和所有依赖它的指令,重执行 load。

现代 CPU 用**存储-加载转发预测器(Store-to-Load Forwarding Predictor, SLFP)**来控制这个决策——跟分支预测器类似,记录历史 mode:哪些 load-store pair 频繁冲突,直接用预测结果指导调度。

冲突率: 通常 ~1-3%,但对于指针密集代码(链表遍历、树遍历)可到 10%+
错误重发代价: flush + 重新执行 ~10-15 cycle

九、工程事故:Pentium 4 Prescott 的 Replay 机制

时间:2004 年 Intel 发布 Pentium 4 Prescott,基于 NetBurst 微架构的进化。

问题:Prescott 的流水线深度达到了恐怖的 31 级(Northwood 是 20 级)。L1 D-cache miss 时,不是像传统做法那样 stall 管线,而是搞了一个**"回放回路(Replay Loop)"**——把依赖 L1 cache miss 结果的 μop 重新循环回到调度器里不断重新执行,直到 cache miss 被服务完毕。

[正常做法]
μop 等 L1 → stall reservation station → cache miss 解决后 release

[Prescott 的 Replay]
μop 等 L1 → 发射执行 → 发现数据未到 → 不挡管线 → 
  重新插入保留站 → 再次发射 → 再次 miss → ... → 直到数据返回

结果:CPU 在等待 cache miss 期间不断消耗电力执行无用 μop(每次 replay 都在功能单元上跑一次),发热量爆炸但真实 IPC 不升反降。这是 NetBurst "高频低效" 策略的典型体现。

历史收敛:2006 年 Intel 放弃 NetBurst,以 Pentium M (Banias) 的团队开发的 Core 2 Duo (Conroe) 取代——后者采用短流水线(14 级)+ 宽发射(4-wide)+ 大 OoO 窗口,IPC 提升 ~80%,功耗下降 ~50%。这条设计哲学一直沿用到今天的 Golden Cove。

教训:深流水线 ≠ 高性能。窗口大小(ROB + 物理寄存器 + 保留站)是 OoO 的硬通货——Prescott 的 31 级流水线虽然有名义上的深度,但 replay 机制让有效的 OoO 窗口大打折扣。


十、易错清单

  1. "OoO 执行"≠"OoO 提交":指令乱序执行但必须按程序顺序提交(ROB 保证)。写回架构状态(寄存器文件、内存)的顺序必须与程序顺序一致——否则无法实现精确异常。

  2. WAR/WAW 不是数据依赖mov r0, r1; mov r0, r2 这两条没有 RAW 依赖,WAW 可以通过重命名完全消除。混淆 WAR/WAW 与 RAW 会导致误判程序 ILP 上限。

  3. 架构寄存器 ≠ 物理寄存器:x86-64 代码可见 16 GPR,但物理寄存器池 ~200–400 个。流水线图中画"寄存器"时必须区分 RAT 查表后的物理寄存器与代码中的架构寄存器。忘记重命名这层,整个 OoO 图全错。

  4. ROB 大小 ≠ 指令条数:512-entry ROB 大约容纳 ~120–200 条 x86 指令 (大量 μop 是 1:1,但 CISC load-op 指令可能 1:2–4 μop)。估算 OoO 窗口时按 μop 算。

  5. 提交速度瓶颈:即使前端 fetch 6 条/cycle、issue 8 条/cycle,commit 速度只有 8 μop/cycle。高频 CPU 的长串依赖链(如链表遍历的 ld→cmp→ld→cmp)会被 commit 宽度挡死。OoO 不能解决 真依赖链 的延迟。

  6. 负载/储存转发不是免费的:Store-to-load forwarding 只需要 store 在执行时就绪(same-cycle),但如果 store 的 AGU 延迟(base 未算出)会导致 LSQ forwarding 失效,load 必须等 store 地址 + 数据都到 LSQ。这会产生 ~10 cycle 的额外延迟。

  7. 分支预测 99% = 仍有 1% flush:Flush 不只是 PC 的丢失——被 flush 的 μop 占用了 ROB 空间和保留站资源,等于浪费了 OoO 窗口。在 mispredict 密集的代码(parser、正则引擎、JIT 编译)中,有效 ROB 利用可能萎缩 30–50%。


十一、这一章带走的东西

  1. OoO 的本质:发射前检查"操作数是否就绪"代替"前面指令是否发完" → 用可执行指令填充空流水线。Cache miss、长延迟浮点除法的等待被后面独立计算填充。

  2. Tomasulo 的核心贡献:保留站 + CDB 实现了分布式依赖追踪,WAR/WAW 从 stall 变成改名(用 tag 取代寄存器名)。这是往后 60 年所有高性能 CPU 微架构的基石。

  3. 寄存器重命名:架构寄存器的虚假 WAR/WAW 依赖被物理寄存器池吸收。物理寄存器数 + ROB 大小 = OoO 的硬窗口。

  4. ROB 保证按序提交和精确异常——不管里面多乱,外面看到的一定是顺序状态。

  5. 投机执行不仅仅是分支预测:分支预测 + ROB 快照让 CPU 在不确定方向的情况下全速前进;一旦误预测就恢复 checkpoint,代价只有若干 cycle 的 flush。

  6. 超标量各代对比:M3 走宽窗口低主频(630 ROB / 4 GHz),Zen 5 / Golden Cove 走深流水线高主频(384-512 ROB / 5.7 GHz)——OoO 的设计在窗口大小与 clock frequency 间做 trade-off。

  7. OoO 不能解决真依赖:一段全是 a = a + b[n]; n = next[n] 的代码,本质是单链依赖——再大的 ROB、再多的保留站也只蹲一条链路。ILP 的真实上限是程序中的独立并行度,不是硬件能"挖掘"多少。

  8. LSQ 解决内存依赖消歧:地址未知时的 load-store 冲突通过预测器处理;预测错 flush 重执行。


下一节 → 存储层次:Cache / DRAM / HBM

存储层次:Cache / DRAM / HBM

TL;DR

CPU 的 L1 cache 访问 ~1ns(4 cycle @ 4GHz),DRAM 访问 ~100ns(400 cycle),SSD 访问 ~100µs(400,000 cycle)。这个 4 个数量级的延迟鸿沟是计算机体系结构最核心的问题。缓解策略是一场关于**局部性(locality)**的战争——cache 利用指令/数据的时间和空间局部性,把大概率命中的数据放在离计算单元更近、更快、更小的地方。

flowchart LR
    CORE["CPU Core<br/>3-5 GHz"] -->|"~1ns / ~1TB/s"| L1["L1 Cache<br/>32-64KB, 1-5 cycle"]
    L1 -->|"~8ns / ~300GB/s"| L2["L2 Cache<br/>256KB-1MB, 12-20 cycle"]
    L2 -->|"~30ns / ~150GB/s"| L3["L3 Cache<br/>8-96MB, 40-60 cycle"]
    L3 -->|"~100ns / ~100GB/s"| DRAM["DRAM<br/>8-64GB, DDR5/HBM"]
    DRAM -->|"~100µs / ~7GB/s"| SSD["NVMe SSD<br/>256GB-2TB"]

软件开发者的三个核心数字:cache miss 成本约 100 条 ALU 指令,L3 miss 成本约 400 条,DRAM 访问成本约顶 1000 条。程序行为——数据布局、访问模式、数据结构选择——直接决定了你在 cache 金字塔里掉落的层数。


一、为什么需要存储层次?

延迟、带宽、功耗三重鸿沟

层级延迟带宽 (64B block)功耗等价 CPU cycle (4GHz)
L1 cache~1ns~1 TB/s~20 pJ/access4
L2 cache~8ns~300 GB/s~100 pJ/access32
L3 cache~30ns~150 GB/s~500 pJ/access120
DRAM (DDR5)~100ns~100 GB/s~12 nJ/access400
NVMe SSD~100µs~7 GB/s~100 µJ/read400,000

关键认知:DRAM 访问耗能是 L1 cache hit 的 ~600 倍(12 nJ vs 20 pJ)。这不是线性差距,而是指数级。如果你写一段代码把 cache miss 从 10% 降到 1%,节约的不是"9% 的延迟",而是"9 次 DRAM 访问 × 12 nJ 的能耗"。移动端(Apple M 系列)的核心优势之一就是巨大的 cache 结构让 app 有极高的 cache hit rate,从而节电。

#include <stdint.h>
#include <stdlib.h>

// 演示:cache miss 的延迟成本
// int 数组求和:顺序 vs 随机访问
int64_t sum_sequential(const int *arr, size_t n) {
    int64_t s = 0;
    for (size_t i = 0; i < n; i++) s += arr[i];
    // 每条 cache line (64B) = 16 个 int,miss rate ≈ 1/16 = 6.25%
    return s;
}

int64_t sum_random(const int *arr, const size_t *idx, size_t n) {
    int64_t s = 0;
    for (size_t i = 0; i < n; i++) s += arr[idx[i]];
    // idx 随机排列 → 几乎每 16 个 int 就 miss → miss rate ≈ 100%
    // 这个循环比 sum_sequential 慢 10-50 倍
    return s;
}

二、SRAM 与 DRAM:两种存储技术的本质差异

SRAM(Static RAM)— 用于 cache

   SRAM 6T cell (6 个晶体管):
        Vdd
         │
     ┌───M2───M4───┐
     │             │
   M1┼─ M3        M5 ─┼ M6
     │             │
     └──Q─────Qbar──┘

特点:交叉耦合的反相器对(M1-M4)产生双稳态锁存。只要有电,数据永久保持——因此叫"Static"。无需刷新、速度极快(1ns 级)、但 6 个晶体管占面积大,每 bit 成本高。

DRAM(Dynamic RAM)— 用于主内存

   DRAM 1T1C cell (1 个晶体管 + 1 个电容器):
   
   字线 (WL) ──┐
              [T] 晶体管
   位线 (BL) ──┼── [C] 电容器 (~30 fF) ── GND

特点:靠电容器储存电荷表示 0/1。读取是破坏性的(破坏性读出)——读完后必须恢复。电容器漏电,必须在 64ms 内刷新所有行(JEDEC 标准 tREF = 64ms for Tcase ≤ 85°C)。1T1C 密度极高(1T vs 6T),但速度慢、功耗(刷新开销)大。

为什么 DRAM 延迟 ~100ns?

DRAM 不是"读一个地址"——它的内部结构决定了每次访问要走三步:

地址 = {row_addr, bank, column_addr}

① Row Activate (tRCD ≈ 15ns)
   把整行 (比如 8Kb = 1024 字节) 从 bitcell array 读到行缓冲区 (row buffer / sense amp)

② Column Read (tCL ≈ 15ns)
   从行缓冲区选择列地址对应的那些 bit (64 字节),驱动到 IO 线

③ Precharge (tRP ≈ 15ns)
   关闭当前行,为下一行 activate 做准备——因为 sense amp 被占用,不能同时 serve 两个行

④ Bus transfer (tBurst ≈ 4ns @ DDR5-5600)
   64 字节 × 8-bit = 8 次 transfer @ burst length 16 (BL16, DDR5) → ~4ns for 64B

真正读懂这个时序的含义:DRAM 的瓶颈不是传输速度,是行切换的开销。顺序访问(同一 row buffer 下)连续读 16 × 64B = 1KB 只需要一次 tRCD + 16 × tBurst ≈ 15 + 64 = 79ns → 整行 1KB 只用 ~79ns。随机访问(每次换个 row)呢?每次都是 tRCD + tCL + tRP = ~45ns 额外 + ~4ns burst = ~50ns per 64B。随机访问速度是顺序的 1/10。


三、Cache 组织

核心映射方式:从单行到全路

Cache 的"在哪存放一个 64 位地址"归结为三种映射方式:

flowchart TB
    subgraph DM["Direct Mapped (直接映射)"]
        D1["addr → 唯一 cache line<br/>index = (addr>>6) mod N"]
        D2["好:硬件最简单(1 个 comparator)"]
        D3["坏:冲突 miss —— 两个经常访问的地址<br/>如果 index 一样就会互相驱逐"]
    end
    subgraph SA["Set-Associative (N-way 组相联)"]
        S1["addr → 某个 set<br/>set 内有 N 路可选"]
        S2["好:减少冲突 miss<br/>坏:N 越大 comparator 越多<br/>LRU 实现也越贵"]
    end
    subgraph FA["Fully Associative (全相联)"]
        F1["addr → 任意位置"]
        F2["好:零冲突 miss<br/>坏:每访问要找 tag → N 个 comparator<br/>功耗极高,N 不能大"]
    end

地址分解(48-bit 虚拟/物理地址)

|  tag  |  index  |  offset  |
|  t位   |   s位   |   b=6位  |   (因为 line size = 64B = 2⁶)

s = log₂(num_sets)
t = 48 - s - 6

例如:32KB, 8-way, 64B line
  num_lines = 32KB / 64B = 512
  num_sets  = 512 / 8 = 64
  s = log₂(64) = 6
  t = 48 - 6 - 6 = 36

真实例子

// 用 C 代码模拟直接映射和组相联的 miss 行为

#define LINE_SIZE 64
#define CACHE_KB 32
#define NUM_LINES (CACHE_KB * 1024 / LINE_SIZE)  // 512

typedef struct { uint64_t tag; uint8_t valid; } cache_line_t;

// 直接映射: 每个地址只映射到唯一一行
size_t direct_mapped_index(uint64_t addr) {
    return (addr / LINE_SIZE) % NUM_LINES;
}

// 8-way 组相联: 每个地址映射到 set; set 内有 8 路竞争
size_t set_associative_index(uint64_t addr, int ways) {
    size_t num_sets = NUM_LINES / ways;
    return (addr / LINE_SIZE) % num_sets;
}

冲突 miss 可见化:假设 32KB D-cache(直接映射),你要同时访问 a[0]a[8192]——这两个元素恰好在相同的 cache line index(因为 8192 × 4B = 32KB,正好一个 cache 大小差)。每次交替读写这两个地址都会互相驱逐,即使用到的数据只有 8 字节,cache 只有不到 1% 的空间被有效使用。


四、Cache Line 与写策略

为什么是 64 字节?

好处:程序员/编译器有能力证明程序"在一个连续范围内工作"——64B 利用了空间局部性。一个 struct、一小组数组元素、一条代码路径基本都落在 64B 内。

代价:false sharing(见后文 易错清单),且 100ns 的 miss penalty 对 64B 和 16B 其实是相同的(因为 DRAM tRCD + tBurst 主导了延迟)。

从 32B 到 64B 到 128B(IBM POWER),本质是 miss penalty 的固定成本(tRCD + tRP)被更大的 line 摊分。但 128B 的缺点:false sharing 严重、cache 空间碎片化。目前业界共识是 64B。

写策略

策略命中时未命中时
Write-through + No-write-allocate同时更新 cache + 下一级不分配 cache line,直接写下一级
Write-back + Write-allocate只更新 cache(设 dirty bit)从下一级调入 line → 修改 → 标记 dirty

所有现代 CPU 的 L1 都用 Write-back + Write-allocate。原因:写命中率也高,且写穿(write-through)会让 store 指令的延迟等于 DRAM 延迟(100ns),无法接受。

dirty bit = 1 bit per cache line
evict 时如果 dirty=0 → 直接丢弃
evict 时如果 dirty=1 → 写回下一级(L1→L2 或 L2→L3)

五、替换策略

LRU(Least Recently Used)

理论最优(针对传统局部性模式),但实现代价随相联度 N 增长:

  • 4-way: 6 bits (log₂(4!) = 4.6 → 需 6 bits for exact)
  • 16-way: ~44 bits → 对于 16-way L2 来说不经济

真实硬件使用的

策略原理用于
Pseudo-LRU (PLRU)二叉树的每个节点 1 bit 指向最近使用的子树方向ARM/AMD L1, L2
RRIP (Re-Reference Interval Prediction)Intel Sandy Bridge 起,每个 cache line 有一个 2-bit 计数器预测 re-reference 时间Intel L1, L2, L3
Bimodal RRIP (BRRIP)RRIP 的扩展:对于扫描模式(scan-heavy),部分避免为扫描行填满 cacheIntel Haswell+ L3
Apple Adaptive跟踪 recency + frequency,类似软件中的 ARC (Adaptive Replacement Cache)Apple M 系列
RRIP 的核心思想:
  n 位计数器 per cache line
  新插入 line → RRPV = 2^n - 2 (near-immediate)
  命中 → RRPV = 0
  需要淘汰时 → 找 RRPV = 2^n - 1 (max) 的行 → 如果没有,将所有行 RRPV++

暴力演示:LRU vs RRIP 在扫描下的差异

// 演示:遍历一个比 L3 大的数组
// L3 = 36MB, array = 72MB
// LRU: 扫描会把之前有用的 cache line 全部赶走 → scan 后 cache 是"垃圾"
// BRRIP: 扫描行插入时直接给"快过期"的 RRPV → 保护此前有价值的行

void scan_large_array(float *a, size_t n) {
    for (size_t i = 0; i < n; i++) a[i] *= 2.0f;
    // 约 18M 个 float × 4B = 72MB, 远超 L3(36MB)
    // → a[0] 在遍历完前已被 a[9M+] 赶走 → 100% miss
    //  但 miss 是不可避免的,关键是第二次遍历 a 时的剩 cache → BRRIP 可能保住一些
}

六、Cache 一致性协议(MESI)

动机

多核环境下的核心问题:如果 Core 0 的 L1 里有 x=42,Core 1 修改 x=7,怎么让 Core 0 看到 7 而不是 42?

MESI 四状态

stateDiagram-v2
    [*] --> Invalid

    Invalid --> Exclusive: Local Read<br/>(no other core has it)
    Invalid --> Shared: Local Read<br/>(other core responds)

    Exclusive --> Modified: Local Write
    Exclusive --> Shared: Remote Read snoop → respond
    Exclusive --> Invalid: Remote Write snoop

    Shared --> Modified: Local Write<br/>(invalidate others via bus)
    Shared --> Invalid: Remote Write snoop

    Modified --> Invalid: Remote Read snoop<br/>(write back then invalidate)
    Modified --> Invalid: Remote Write snoop<br/>(write back then invalidate)
    Modified --> Shared: Remote Read snoop<br/>(write back, keep S copy)

Modified:数据已被本 core 修改(dirty),并且只有本 core 有最新副本。当别的 core 想读这个地址时,本 core 必须把数据写回(或"倒灌"到请求方),不能再靠 DRAM 的过期副本。

Exclusive:数据干净,但只有本 core 持有。因为只有一份,写入时直接变成 Modified,无需 invalidate 别的 core(省了无用广播)。

Shared:多个 core 都在读,大家都干净。某个 core 要写时,必须在总线上发 Read For Ownership (RFO) 信号把其他 core 的 S copy 全 invalidate 掉。

Invalid:cache line 可用 slot,或已被 invalidate。任何 miss 最终落到这里。

MOESI(AMD)与 MESIF(Intel)

MESI 的基础问题:
  Core 0: Modified → Remote Read snoop 来了 → 写回 DRAM → Core 3 从 DRAM 读取
  ❌ 写回 DRAM 这一步是多余的——为什么不直接 copy 到 Core 3?

MOESI (Owned state):解决上面这个。Modified 是脏且唯一的,Owned 是脏但可能被多个 core 共享的——当远程读时,Owner core(通常是 M 或 O 的 core)直接把脏数据转发给请求方,不经过 DRAM。写回推迟到真正的 eviction 时。

MESIF (Forward state):Intel 的解法。增加一个 Forward 状态:在多 core 共享某一行时,只有一个 core 被指定为 F(Forwarder)。当别的 core 再次请求该行时,只有 F 回应的 core 可以响应。这样可以避免 N 个 S-state core 同时在总线上回应(信号碰撞/总线仲裁开销)。

目录协议与 NUMA

MESI 的"总线上广播(snooping)"做法在规模扩大时崩溃——几十个 core 的总线广播带宽占用、延迟都不可接受。AMD Infinity Fabric 和 Intel UPI 使用目录协议(Directory-based)

目录协议的基本思想:
  每个 DRAM controller 有一个"directory"记录它管理的每个 cache line 被哪些 core 持有
  某个 core 要写时 → 向 HOM(Home Node)发请求
  HOM 查目录 → 只向持有该 line 的 core 发 invalidate 请求(不广播)
  
  注意:invalidation 消息是"精确发送"的,不是"全喊"的 → O(持有者) 而非 O(N)

七、多级 Cache 层级

真实微架构参数对比

参数Apple M3 P-coreAMD Zen 5Intel Golden Cove (Lunar Lake P-core)
L1 I-cache192 KB, 6-way64 KB, 8-way64 KB, 8-way
L1 D-cache128 KB, 8-way48 KB, 12-way48 KB, 12-way
L1 延迟3-4 cycle4-5 cycle5 cycle
L2 cache16 MB (shared by 4 P-cores)1 MB per core, 16-way2.5 MB per core, 10-way
L2 延迟~12 cycle12-14 cycle16 cycle
L3 cache24 MB (shared all cores)32 MB per CCD (8 cores)12 MB shared per cluster
L3 延迟~40 cycle45-50 cycle45-52 cycle
带宽 (L1→L2)~200 GB/s per core~128 GB/s per core~128 GB/s per core

设计差异背后的哲学

  • Apple M3:超宽 decode(10-wide)+ 超大指令 cache(192KB)→ 前端必须喂入大量指令,I-cache 需要装更多;P-core 集群共享 16MB L2 提供极低延迟的 inter-core 通信。一切为**每瓦性能(perf/W)**服务。
  • Zen 5:8 核共享一个 CCD(Core Compute Die),每个核心携带自己的 1MB L2,而 32MB L3 以 8 个 slice 分布到各核上方(3D V-Cache 模式下再叠 64MB)。关键是 L3 的 8-slice 网状拓扑避免瓶颈
  • Golden Cove (Lunar Lake):2.5MB L2 比前代(P-core 1.25MB)翻倍,L3 为 12MB — 适合中小规模的频繁 cache re-use(绝大多数应用的实际 working set 在 10MB 以内)。

八、DRAM 组织

DIMM → Rank → Chip → Bank → Row → Column

一块 DDR5 DIMM (比如 32GB) =
  2 个 Rank × 每个 Rank 4 个 Chip × 每个 Chip 8 个 Bank Group × 4 个 Bank × 32K Row × 1K Column

 DDR5 关键数字:
  · 2 个独立的 32-bit channel(DDR4 是 1 个 64-bit channel)
  · 每个 channel 带宽 = 5600 MT/s × 32-bit / 8 = 22.4 GB/s
  · 一块 DIMM 总带宽 = 2 × 22.4 = 44.8 GB/s

DDR5 时序参数(DDR5-5600 CL46)

参数含义
tCLCAS 延迟 (read cmd → first data)46 ticks = 12.8ns
tRCDRAS-to-CAS (activate → read)46 ticks
tRPPrecharge time (close row)46 ticks
tRASRow active time (minimum)52 ns
tRFCRefresh cycle time295 ns
带宽Per channel5600 × 2 × 32/8 = 44.8 GB/s for one DIMM
实际延迟ACT + RD + PRE~12.8 + 12.8 + 12.8 + burst = ~40-45ns

核心洞察:DDR4 → DDR5 的频率提升(3200 → 5600)让 tCL/tRCD/tRP tick 数增加了(14 → 46),real-time 没怎么变。收益在于:① bus 带宽加大(5600 MT/s 传输更快)② Bank 数更多(16 → 32)能同时激活更多 row → 更大的 MLP(memory-level parallelism)。


九、HBM(High Bandwidth Memory)

为什么 HBM 存在?

DDR5 DIMM 的物理极限:128-bit bus(一个 DIMM 2 channel × 64-bit),4 DIMM per channel → 最多 256-bit,约 224 GB/s。GPU(H100)需要 3 TB/s。如何实现?

答案:不搞"数据串行通过窄总线" → 搞"数据并行通过极宽总线"。

HBM3 的基本结构:

   Logic Die (控制 + PHY) ── 下层
     ↑  TSV (Through-Silicon Via) 竖着穿过 8-12 个 DRAM 堆叠层
     ↑  每层 DRAM die 都有 256-bit 的数据通道
   [DRAM Die 8] ── 最上
   [DRAM Die 7]
   [DRAM Die 6]
   [DRAM Die 5]
   [DRAM Die 4]
   [DRAM Die 3]
   [DRAM Die 2]
   [DRAM Die 1] ── 最下
   ───────────
   Logic Die ── 硅中介层 (Si Interposer) → GPU/Chiplet

每个 stack 的带宽 = 1024-bit bus × 6.4 Gbps per pin / 8 = 819 GB/s
NVIDIA H100: 6 个 HBM3e stack × 1.2 TB/s = 3.35 TB/s

HBM vs DDR 为什么 HBM 延迟更低?

  1. 物理距离短:HBM 堆叠紧贴 GPU 计算芯片(~mm 甚至 µm 级),DDR DIMM 走 socket → PCB 走线 → 信号经几厘米距离。
  2. 宽总线:1024-bit vs 64-bit → 每个 pin 传输压力低,每个 chunk 能更快稳定采样。
  3. 无 DIMM 边界:DIMM 需要经过 Mem Controller 分配 Rank/Group/Bank → 额外几 ns 的仲裁延迟。

HBM3 / HBM3e 对比

版本带宽 /stack每 pin容量 /stack堆叠层代表产品
HBM2e460 GB/s3.6 Gbps16 GB8A100 (1.55 TB/s from 5 stacks)
HBM3819 GB/s6.4 Gbps24 GB12MI300X
HBM3e1.2 TB/s8.0 Gbps36 GB12H100 (3.35 TB/s from 6 stacks)

HBM 的工程代价:TSV 工艺难度极高,堆叠良率限制产能。HBM3e 堆叠 12 个 DRAM die,需要每个 die 极薄(~100µm),加上 TSV 穿硅孔几何精确对准 → 单 wafer 良率 vs 成本是工业级挑战。这也是为什么 HBM 单价约 $15-20/GB,而 DDR5 约 $3-4/GB。


十、3D V-Cache(AMD)

2022 年 AMD 在 Zen 3(5800X3D)上首次加入 3D V-Cache:把额外的 64MB L3 以 chiplet 形式叠在 CCD 的正上方,用铜制互联(Hybrid Bonding)连线。

           ┌──────────────┐
           │  64MB L3      │  ← 3D V-Cache die (叠加层)
           │  (附加缓存)    │
           ├─Hybrid Bond───┤
           │  CCD           │  ← 8 核 Zen 5 + 32MB L3
           │  32MB L3 (+I/O)│
           └──────┬─────────┘
                  │ Infinity Fabric
           ┌──────┴─────────┐
           │   I/O Die      │  ← DDR5 控制器 + PCIe lanes
           └────────────────┘

效果:总 L3 = 32MB (CCD 内置) + 64MB (3D V-Cache) = 96MB。游戏、渲染、数据库等大量工作负载在 80MB 到 96MB 区间内可获得接近 L3 延迟的命中率。

技术关键:Hybrid Bond 的 TSV 不在 DRAM 堆叠的 HBM 意义上——3D V-Cache 用更精细的铜-铜直接键合(not solder bump),密度高得多(~2-3 µm 间距),带宽远超早期 3D stacking 方案。延迟惩罚仅为 ~4 cycle 的跨越连接时间。

实战数字:在 7800X3D 上,模拟器(RPCS3)提升 ~30%,MMO 游戏提升 ~20-40%,部分编译负载提升 ~8-12%。效果取决于工作集大小——如果你的 app 的 working set > 32MB 但 < 96MB,3D V-Cache 是巨大卖点。


十一、Memory-Level Parallelism(MLP)

OoO CPU 可以同时有多个未完成的 cache miss——这些未完成的 miss 由 MSHR(Miss Status Holding Register) 跟踪。

MLP 的含义:
  while (p != NULL) {
      p = p->next;  // 链表遍历:每条指令依赖前一条 ld 的结果
  }
  → MLP = 1(只有 1 个 miss 在飞行,其余阻在头前)
  → 时间 = N × 100ns (每次 miss 串行)

  for (int i = 0; i < N; i++) {
      a[i] = b[i] * c[i];  // 三个独立数组访问,互不依赖
  }
  → 硬件 prefetcher 识别 stride + OoO 窗口撑开 ~10-16 个 miss
  → MLP = 10-16
  → 时间 = N / 平均 MLP (实际受 DRAM bank 并行度限制)

软件优化原则:让 cache miss 之间没有依赖关系。编译器可以重排访问顺序,OoO 的 ROB 能容纳 ~200-512 条指令,足够的窗口去并行发出多条 miss。链表、树、图的指针追逐是 MLP 的天敌。

硬件视角:Zen 5 有 8 个 fill buffer (L1 miss → L2),16 个 L2 miss queue。每个 core 最多 16 个未完成的 L2 miss 在并行处理。Golden Cove 类似(12 L1 fill buffer + 32 L2 SQ)。


十二、预取(Prefetching)

硬件预取器

预取器识别模式常用子
Next-line总是预取下一条 cache line所有现代 CPU
Stride识别地址间距模式 (addr[n+1] = addr[n] + stride)所有 CPU, 2-3 个独立的 stride tracker
AMPM (Access Map Pattern)把内存分小块,跟踪过去 N 次访问 → 预测下次Intel (SnB+), 用于 L2 → L3/L1
Feedback Directed动态关闭预取(如果预取准确率低)Apple M series
例子:
  for (int i = 0; i < N; i++) a[i]++;
  → 每次 load a[i] 地址递增 4 (int=4B)
  → stride prefetcher 检测到 stride=+4
  → 提前 1-3 个 cache line 启动预取
  → 程序看到 a[0], a[16], a[32], ... 全部命中(代码没看到 miss)

软件预取

#include <xmmintrin.h>  // _mm_prefetch on x86

void software_prefetch_example(const float *src, float *dst, int n) {
    for (int i = 0; i < n; i++) {
        // 提前告诉 CPU:5 次迭代后将需要 src[i+5] 和 dst[i+5]
        _mm_prefetch((const char *)&src[i + 5], _MM_HINT_T0);  // 预取到 L1
        _mm_prefetch((const char *)&dst[i + 5], _MM_HINT_T1);  // 预取到 L2
        dst[i] = src[i] * 3.14f;
    }
    // 手动 prefetch 可用于 stride 过大(超过硬件 tracker 范围)
    // 或 non-unit stride(硬件 tracker 难以探测)场景
}

GCC/Clang 内置__builtin_prefetch(ptr, rw, locality),其中 rw=0 (read), locality=0-3 决定 prefetch 到 L1/L2/L3。

底线:大多数场景下硬件 prefetcher 就够了(甚至 do better),手动 prefetch 仅在以下场景有用:稀疏矩阵乘法、图遍历已知邻接表布局、哈希表探测过程中提前 prefetch 下一个 bucket。


十三、工程事故:RowHammer

事件时间线

  • 2014:Yoongu Kim 等人在 CMU 发表论文《Flipping Bits in Memory Without Accessing Them》——用"反复激活同一 DRAM row"的手段让相邻行的 bit 随机翻转(0→1 或 1→0)。
  • 本质:DRAM 的电容耦合 + 行激活导致的电荷泄露。快速激活(~100K 次/refresh cycle)同一行 → 电场干扰相邻行 → 相邻行的电容器漏电加速 → 数据被"击穿"。
  • 影响:Google Project Zero 在 2015 年证明了 RowHammer 可用于权限提升(修改页表 → kernel mode 逃逸)。
  • 缓解:JEDEC 增加 TRR (Targeted Row Refresh) 机制——DRAM controller 检测频繁激活的 row,对其相邻 row 做额外刷新。DDR5 做了更彻底的设计:per-row 激活计数 + 自动刷新相邻行,不需要控制器的外部干预。

为什么这是一个"架构层次"的问题

RowHammer on DDR4:
  [ DRAM controller 不知道哪个 row 被 hammer ]
  → 它看不见 row 激活频率
  → JEDEC 要求加 TRR(控制器侧检测 hammer → 发额外刷新命令)
  → 但这又增加了 controller 的复杂度(每个 row 都要 counter)

RowHammer on DDR5:
  [ DRAM chip 本身有 row activation counter ]
  → chip 自己能检测 hammer 并触发相邻行刷新(RFM 机制)
  → controller 不再需要跟踪 hammer 信息

这个问题的本质是:物理层的电荷现象绕过了逻辑层的 access control。这提醒我们——即使在"完美"的逻辑模型下,物理实现中的缺陷仍可以成为攻击面。


十四、易错清单

  1. "缓存这么大,不需要关心 memory layout":32KB L1 只能装 512 条 cache line。一个满的 hash table、二叉树、linked-list traversal 轻松突破 L1 → L2 → L3,让本来 1ns 的访问变成 100ns。Data-oriented design 永远有意义

  2. False sharing:两个 core 各写一个不同的 int(各 4B),但它们落在同一条 64B cache line 上 → 每次写入都在 MESI 里引起 RFO + invalidate → 双方的 cache line 在两个 core 之间像乒乓球一样飞来飞去(ping-pong effect)。

    // 修复前:两个 int 挨着(同一条 cache line)
    struct bad { int a; int b; };  // a 和 b 在一条 64B cache line 上
    
    // 修复后:cache line 对齐
    struct good {
        alignas(64) int a;
        alignas(64) int b;
    };
    // 现在 a 和 b 在不同的 cache line → 写 a 不会 invalidate b
    
  3. "大 cache 就没 miss":你给 8MB L3 做了直接映射,但 8 个地址恰好都有相同 index → 8 个地址互斥驱逐 → cache 实际只用了 1 条 line 的 64B。组相联是为了让冲突 miss 少一点,但无法消除。

  4. "DRAM 带宽 = 我能拿到的速度":DDR5 标称 44.8 GB/s,但这是"信道传输带宽"。真实内存访问的吞吐量受 TD(data rate)vs ACT + tRCD 的比值限制。对于随机访问(每 64B 需要 ACT + RD + PRE ~ 45ns),maximum throughput ≈ 64B / 45ns ≈ 1.4 GB/s per channel——连标称带宽的 3% 都不到。DRAM 带宽和延迟不是同一回事

  5. "MESI 自动保证我看的是最新数据":对,但你要付 coherence 税。频繁写共享变量 → RFO 广播 → invalidate → 再取 → 100ns 每次。在高竞争的多线程程序中,这个税是性能杀手。

  6. "3D V-Cache 就是再叠一层 cache":对,但只有 working set 在 32-96MB 区间内才有效果。如果程序的工作集是 4KB(太小)或 2GB(太大),3D V-Cache 几乎没有加成。

  7. "DDR5 延迟比 DDR4 高":tCL tick counts 从 14 → 46,但因为时钟快了 75%(3200 → 5600),absolute latency 大致相同(~12-15ns)。DDR5 真正的改进在于更大带宽、更多 bank 带来的更高 MLP——延迟本身没有下降。


十五、这一章带走的东西

  1. Cache 是局部性的加速器:时间局部性(刚用过的还再用)+ 空间局部性(挨着刚用过的也再用)→ cache 把 1% 的访问概率放大为 >95% 的命中率。写代码时要思考"数据在这个 cache level 命中的概率是多少"——不是"cache 会救你的"。

  2. DRAM 是分层组织:bank / row / column 的物理结构直接决定了顺序访问和随机访问的性能差 10-50 倍。

  3. HBM 不是快 DRAM,是宽 DRAM:1024-bit 通道的 8-12 层堆叠,专为 GPU/AI 芯片设计——3.35 TB/s 的带宽没有一个 DDR5 DIMM 能接近。

  4. MESI 是分布式一致性的一笔"硬件预演":Modified/Exclusive/Shared/Invalid 四个状态的处理与 Quorum / 一致性协议惊人地相似——软件中的分布式一致性(e.g., Raft, Paxos)就是在更大的尺度上复现了 MESI 的逻辑。

  5. False sharing 是写多线程代码的第一陷阱:两个不同变量的 cache line 冲突足以把程序性能打回 10 年前的水平。

  6. 3D V-Cache 证明了"cache > 频率"的策略有效:AMD 用额外的 64MB L3 打赢了频率更高的 Intel 芯片(尤其在游戏领域)→ 局部性好的程序在更大的 cache 上获得非线性加速。

  7. RowHammer 是物理层攻破逻辑层的警示:设计系统时,"不允许访问"(逻辑限制)不等于"不访问"(物理绕过)。硬件信任边界之外的每一个物理参数都可能被攻击者利用。


下一节 → MMU / TLB / DMA / IOMMU

内存管理单元 / 页表缓存 / 直接内存访问 / I/O 内存管理单元

TL;DR — CPU 通过 MMU 将虚拟地址翻译为物理地址;TLB 缓存页表项以加速翻译;DMA 让外设绕开 CPU 直接读写内存;IOMMU 为设备提供地址翻译与隔离,是虚拟化安全的基石。若只有一个关键词:地址翻译无处不在——CPU 侧、设备侧、虚拟机侧,每一层都在做同一件事。


目录

  1. MMU:从虚拟地址到物理地址
  2. 页表遍历:四级与五级分页
  3. TLB:地址翻译的第一道缓存
  4. 页面大小:4KB / 2MB / 1GB
  5. TLB 标记:ASID 与 PCID
  6. DMA:让设备直接访问内存
  7. IOMMU / SMMU:设备侧的 MMU
  8. DMA 一致性:缓存同步问题
  9. 工程事故与安全教训
  10. 易错清单
  11. 这一章带走的东西

1. MMU:从虚拟地址到物理地址

1.1 问题起源

假设两个进程 firefoxchrome 同时运行。它们的 main() 被链接器放在相同的虚拟地址 0x400000,但显然不能共享同一块物理内存——否则互相覆盖。

MMU(Memory Management Unit)解决的就是这个重定位问题:每个进程拥有一张"翻译表"(页表),将进程视角的虚拟地址(VA)映射到全局唯一的物理地址(PA)。

flowchart LR
    subgraph CPU
        MMU["MMU"]
    end
    VA["虚拟地址\n0x7f000000"] --> MMU
    MMU -->|"翻译成功 (TLB 命中)"| PA["物理地址\n0x3a2f1000"]
    MMU -->|"翻译失败 (TLB 缺失)"| PTW["页表遍历\n(Page Table Walk)"]
    PTW -->|"填表"| TLB["TLB"]
    PTW --> PA

1.2 地址空间的"场"

  • 用户空间:x86-64 下典型为虚拟地址 0x00x00007FFFFFFFFFFF(低 47 位),即 128 TiB。ARM64 类似,用户空间在低位。
  • 内核空间0xFFFF800000000000 以上,高 128 TiB。内核页表在所有进程间共享,所以系统调用与中断无需切换页表基址寄存器,只需切换栈。

每一次进程切换,内核将 CR3 寄存器(x86-64)或 TTBR0_EL1 寄存器(ARM64)指向目标进程的顶级页表物理地址。MMU 从那一刻开始,为该进程做地址翻译。

// Linux 内核在上下文切换时写入 CR3(简化模型)
static inline void switch_mm(struct mm_struct *prev, struct mm_struct *next) {
    // ...
    load_cr3(next->pgd);   // pgd 指向 PML4 表的物理地址
    // 同时可能写入 PCID,避免全 TLB 刷新
}

关键事实:虚拟地址 → 物理地址的翻译,发生在每条访存指令之前。这是一个在时钟周期级别上、每条 load/store 都要走的热路径。


2. 页表遍历:四级与五级分页

2.1 x86-64 四级分页(PML4)

x86-64 使用分层页表,将 48 位虚拟地址分为 5 个字段(4 个 table index + 12 位页内 offset):

| 47    39|38     30|29     21|20     12|11       0|
|---------|---------|---------|---------|----------|
| PML4 idx| PDPT idx|   PD idx|   PT idx|  offset  |
|  9 bits |  9 bits |  9 bits |  9 bits |  12 bits |
级别完整名称条目大小每个条目映射
1stPML4 (Page Map Level 4)512 项 (9 bit)512 GiB
2ndPDPT (Page Directory Pointer Table)512 项 (9 bit)1 GiB
3rdPD (Page Directory)512 项 (9 bit)2 MiB
4thPT (Page Table)512 项 (9 bit)4 KiB

ARM64 使用不同的命名(PGD → PUD → PMD → PTE),但原理完全相同。RISC-V 使用 Sv39 / Sv48 / Sv57 方案,同样是多级页表。

2.2 一次完整的四级遍历

// 软件模拟 4 级页表遍历(概念代码)
uint64_t walk_page_table(uint64_t cr3, uint64_t vaddr) {
    uint64_t pml4_idx = (vaddr >> 39) & 0x1FF;
    uint64_t pdpt_idx = (vaddr >> 30) & 0x1FF;
    uint64_t pd_idx   = (vaddr >> 21) & 0x1FF;
    uint64_t pt_idx   = (vaddr >> 12) & 0x1FF;
    uint64_t offset   = vaddr & 0xFFF;

    uint64_t *pml4 = (uint64_t *)(cr3 & ~0xFFF);         // PML4 表物理地址
    uint64_t pml4e = pml4[pml4_idx];                     // 读 PML4 项
    if (!(pml4e & 1)) page_fault();                       // Present 位

    uint64_t *pdpt = (uint64_t *)(pml4e & ~0xFFF);       // PDPT 表物理地址
    uint64_t pdpte = pdpt[pdpt_idx];                      // 读 PDPT 项
    if (!(pdpte & 1)) page_fault();

    // 大页:PD 项 或 PDPT 项的 PS 位为 1
    if (pdpte & (1ULL << 7)) {                            // 1 GiB 大页
        return (pdpte & ~0x3FFFFFFF) | (vaddr & 0x3FFFFFFF);
    }

    uint64_t *pd = (uint64_t *)(pdpte & ~0xFFF);
    uint64_t pde = pd[pd_idx];
    if (!(pde & 1)) page_fault();

    if (pde & (1ULL << 7)) {                              // 2 MiB 大页
        return (pde & ~0x1FFFFF) | (vaddr & 0x1FFFFF);
    }

    uint64_t *pt = (uint64_t *)(pde & ~0xFFF);
    uint64_t pte = pt[pt_idx];
    if (!(pte & 1)) page_fault();

    return (pte & ~0xFFF) | offset;
}

硬件真正做的事:MMU 内部的硬件页表遍历器(Hardware Page Table Walker)逐级发出内存读请求,收集 PML4E → PDPTE → PDE → PTE,每一步都是一次完整的 DRAM 随机访问(约 80–100 ns)。

2.3 五级分页(LA57)

2019 年 Intel Ice Lake 引入五级页表,额外增加一个 PML5 级别在 PML4 之上,将虚拟地址扩展到 57 位。此时地址划分如下:

| 56    48|47    39|38    30|29    21|20    12|11     0|
|---------|--------|--------|--------|--------|--------|
| PML5 idx| PML4   | PDPT   | PD     | PT     | offset |
  • 支持虚拟地址空间:128 PiB 用户 + 128 PiB 内核
  • 代价:一次 TLB 缺失的页表遍历从 4 次 DRAM 访存增长到 5 次

判断方式:Linux 内核启动后检查 CPUID leaf 7.ECX[bit 16](la57 标志位)。如果 /proc/cpuinfo 的 flags 包含 la57,则该 CPU 支持 5 级分页。

2.4 一个缺页异常的全路径

sequenceDiagram
    participant TLB as TLB
    participant MMU as MMU(Walker)
    participant Cache as L1/L2 Cache
    participant DRAM as DRAM
    participant Kernel as Linux Kernel
    participant Disk as SSD/Disk

    TLB->>MMU: 查找 vaddr → MISS
    MMU->>Cache: 读取 PML4E
    Cache-->>MMU: MISS
    MMU->>DRAM: DRAM 读取 PML4E (100 ns)
    MMU->>Cache: 读取 PDPTE
    Cache-->>MMU: MISS
    MMU->>DRAM: DRAM 读取 PDPTE (100 ns)
    MMU->>Cache: 读取 PDE
    Cache-->>MMU: MISS
    MMU->>DRAM: DRAM 读取 PDE (100 ns)
    MMU->>Cache: 读取 PTE
    Cache-->>MMU: MISS
    MMU->>DRAM: DRAM 读取 PTE (100 ns)

    Note over MMU,DRAM: P 位 = 0(未映射) → #PF

    MMU->>Kernel: 触发 Page Fault 异常(#PF, 中断号 14)
    Kernel->>Kernel: handle_mm_fault() 检查 VMA
    alt VMA 合法
        Kernel->>Disk: do_swap_in() 或 __alloc_page()
        Disk-->>Kernel: 页面装入 / 新页面分配
        Kernel->>MMU: 更新 PTE,设置 P=1
        Kernel->>TLB: INVLPG 无效化该条目
        Kernel-->>CPU: iretq 返回,重执行访存指令
    else SIGSEGV
        Kernel-->>CPU: 发送 SIGSEGV(segfault)
    end

3. TLB:地址翻译的第一道缓存

3.1 为什么需要 TLB

假设一次四级页表遍历要 4 次 DRAM 随机访问(共 ~400 ns)。如果每条 load/store 都走一遍,单条指令就会有数倍于 L1 缓存命中时间的延迟——完全不可接受。

TLB 就是这个问题的解:它是一个极小的全相联或组相联 SRAM 缓存,专门缓存虚拟地址 → 物理地址的映射,以及对应的权限位。

3.2 TLB 层次结构(Intel Skylake 典型值)

层级大小关联度延迟覆盖
L1 i-TLB128 项8 路组相联1 cycle4 KiB + 2 MiB / 1 GiB
L1 d-TLB64 项4 路组相联1 cycle4 KiB + 2 MiB / 1 GiB
L2 STLB1536 项12 路组相联~7 cycles4 KiB + 2 MiB
页表遍历缓存2+4 项PML4E / PDPTE / PDE
// TLB 命中的黄金路径(概念)
uint64_t load_user_data(uint64_t vaddr) {
    // 1. MMU 查 L1 d-TLB:1 个周期
    uint64_t paddr = tlb_lookup(vaddr);
    // 2. L1 d-cache 命中:4 个周期
    return *(uint64_t *)paddr;
    // 总代价:~5 周期(与纯物理寻址几乎无区别)
}

TLB 缺失的代价:4 次串行 DRAM 访问 ≈ 400 ns。在 3 GHz CPU 上,这是 1200 个时钟周期——相当于 L1 命中延迟的 300 倍。

3.3 硬件遍历 vs 软件遍历

架构TLB 缺失处理特点
x86 / x86-64硬件自动页表遍历CR3 指向页表根,MMU 硬线逻辑完成;对 OS 透明
ARM64硬件自动页表遍历TTBR0_EL1 / TTBR1_EL1;也支持硬件 walk
MIPS软件 TLB 异常TLBL / TLBS 异常 → 内核 tlb_refill_handler() 用指令填入 TLB
SPARC软件 TLB 异常类似 MIPS,内核直接操作 TLB 项
# MIPS 软件 TLB 填充示例(简化)
.set noreorder
tlb_refill_handler:
    # k0, k1 保留寄存器,无需保存
    mfc0    k0, CP0_BADVADDR    # 读取引起 miss 的虚拟地址
    lw      k1, saved_pgd       # 进程页表根
    # ... 软件页表遍历 ...
    mtc0    k0, CP0_ENTRYLO0    # 填入 PTE
    tlbwr                       # 写入随机 TLB 槽位(Random 寄存器选择)
    eret                        # 返回,重执行访存指令
.set reorder

3.4 TLB Shootdown

多核处理器上,如果核心 A 修改某个进程的页表(如 munmap),它必须通知所有其他正在运行该进程的核心:你们 TLB 中的那个翻译已经过时了

这就是 TLB Shootdown:

1. Core 0 调用 mprotect() 使某个虚拟页失效
2. Core 0 更新页表(PTE 的 P 位清零)
3. Core 0 对自己执行 INVLPG(vaddr)           // x86
4. Core 0 向 Core 1..N 发送 IPI(核间中断)
5. Core 1..N 在 IPI 处理程序中执行 INVLPG(vaddr)
6. Core 1..N 发送 ACK 给 Core 0
7. Core 0 确认所有 ACK → mprotect() 返回

TLB Shootdown 的延迟与核心数量线性相关——在大规模 SMP 机器上,频繁的 munmapmprotect 可能成为性能瓶颈。这也是为什么 RCU(Read-Copy-Update)被广泛使用的原因之一:避免频繁刷新 TLB。


4. 页面大小:4KB / 2MB / 1GB

4.1 三种页面大小一览

页面大小地址位单页容量一个 TLB 项覆盖内部碎片
4 KiB(标准)12 bit offset4,096 B4 KB极小
2 MiB(大页)21 bit offset2,097,152 B512 倍于 4 KiB中等
1 GiB(巨页)30 bit offset1,073,741,824 B262,144 倍于 4 KiB

4.2 空间-时间权衡

  • 大页 让单个 TLB 条目覆盖更大的内存范围 → TLB 命中率提升 → 减少昂贵的页表遍历。
  • 大页 造成内部碎片:一个 2.1 MiB 的 malloc 分配,如果分配器使用 2 MiB 大页,第二页只有 0.1 MiB 被使用,其余 1.9 MiB 浪费。
  • 小页 碎片少,但 TLB 覆盖不足——一个 2 GiB 的工作集需要 524,288 个 4 KiB TLB 条目(远超任何 CPU 的 TLB 容量)。

4.3 Linux 透明大页(THP)

Linux 内核在后台自动将连续的 4 KiB 页面合并为 2 MiB 大页,对应用透明。

# 查看 THP 状态
$ cat /sys/kernel/mm/transparent_hugepage/enabled
[always] madvise never

# 查看大页使用情况
$ grep AnonHugePages /proc/meminfo
AnonHugePages:   2097152 kB     # 2 GiB 已被合并为大页
// 应用层通过 madvise 显式建议使用大页
#include <sys/mman.h>

void *buf = mmap(NULL, 256 * 1024 * 1024, PROT_READ | PROT_WRITE,
                 MAP_PRIVATE | MAP_ANONYMOUS, -1, 0);
madvise(buf, 256 * 1024 * 1024, MADV_HUGEPAGE);
// 内核将在条件合适时,将 buf 映射为 2 MiB 大页

真实世界数据:对于数据库(PostgreSQL, MongoDB)和 JVM 堆(通常使用 -XX:+UseTransparentHugePages),THP 可以提升 5%–15% 的吞吐量,主要收益来自 TLB 命中率提升。


5. TLB 标记:ASID 与 PCID

5.1 问题:切换进程必须刷 TLB 吗?

传统做法(没有 PCID/ASID 时):每次切换进程,内核必须通过重置 CR3 来隐式刷新所有 TLB 条目。这导致系统调用密集或频繁上下文切换时,TLB 完全失效——"冷启"的 TLB 命中率几乎是 0%。

5.2 ASID(ARM / 地址空间标识符)

ARM 架构在 TLB 每个条目中附带一个 8-bit 或 16-bit ASID。

+--------------------------+--------+-----+
| 虚拟地址 (VA)            | ASID   | PTE |
+--------------------------+--------+-----+
| 0x7f000_0000            | 0x03   | ... |  ← 进程 A
| 0x7f000_0000            | 0x05   | ... |  ← 进程 B 的相同 VA,不同 PA
  • 每个进程被分配唯一 ASID。
  • TLB 命中需要同时匹配 VA 和 ASID
  • 切换进程时,不再需要刷 TLB(除非 ASID 耗尽,最多 256/65536 个进程后才会发生回收)。
  • ARM ISA 提供 TLBI 指令族精确无效化:TLBI VAE1IS, x0 只刷当前进程特定 VA 的 TLB。

5.3 PCID(x86 / 进程上下文标识符)

Intel 自 Westmere(2010)引入 PCID,x86-64 的 CR3 低 12 位用于存储 12-bit PCID:

CR3 格式:
| 63                   12 | 11    0 |
|-------------------------|---------|
| 页表物理基址 (4K对齐)    | PCID   |
# x86-64:写入带有 PCID 的 CR3,不刷新全局 TLB
mov %cr3, %rax
bts $63, %rax            # 未使用,保留
and $0xFFFFFFFFFFFFF000, %rax
or  $current_pcid, %rax   # 附带 PCID
mov %rax, %cr3            # 写入 CR3 + PCID → TLB 保留
  • PCID = 0:为内核保留(全局页面仍然使用 PCID 0)。
  • PCID = 1..4095:为用户进程分配,最多支持 4095 个进程同时保留 TLB 条目。
  • PCID 与 CR3 写入配合使用,极大降低上下文切换后的 TLB 重填代价。

5.4 Meltdown 与 PCID

Meltdown(2018)迫使所有操作系统全面启用 KPTI(Kernel Page Table Isolation)。KPTI 导致每次系统调用都要切换页表并代价高昂。PCID 在此时成为性能"救命稻草"——没有 PCID 的系统调用会强制刷新整个 TLB。

系统调用 → 用户页表 (用户 TLB) → 内核页表 (PCID 0 TLB) → 返回用户页表
         ↑                                                      ↑
    PCID=0x03 TLB 未被刷,返回用户态时立即可用                  PCID=0 内核 TLB 已预热

6. DMA:让设备直接访问内存

6.1 为什么需要 DMA

让 CPU 一个字节一个字节地将磁盘数据复制到内存,是 CPU ($$) 做搬运工 (¢) 的工作。DMA 将 CPU 从数据搬运中解放出来:

flowchart LR
    subgraph Without_DMA["无 DMA(PIO 模式)"]
        Disk["磁盘控制器"] -->|"中断: 数据就绪"| CPU
        CPU -->|"逐字节 inb/outb"| RAM["内存"]
    end
    subgraph With_DMA["有 DMA(DMA 模式)"]
        CPU2["CPU"] -->|"编程 DMA 描述符"| DMA_C["DMA 控制器"]
        DMA_C -->|"总线主控传输"| RAM2["内存"]
        Disk2["磁盘控制器"] -->|"数据流"| DMA_C
    end

6.2 DMA 描述符链

// 典型的 scatter-gather DMA 描述符(简化)
struct dma_descriptor {
    uint64_t src_addr;       // 源地址(设备侧)或总线地址
    uint64_t dst_addr;       // 目标地址(内存侧)
    uint32_t length;         // 传输字节数
    uint32_t flags;
#define DMA_DESC_FLAG_EOL    (1 << 0)  // 描述符链结束
#define DMA_DESC_FLAG_INTR   (1 << 1)  // 完成后发中断
    uint64_t next_desc;      // 链上下一个描述符的物理地址
} __attribute__((packed));

DMA 控制器从第一个描述符开始,依次读取 src/dst/length,在总线上发起传输。当 flags & EOL 为真或 next_desc == 0 时停止链。一轮传输完成后,若 flags & INTR,拉高中断线通知 CPU。

6.3 Scatter-Gather DMA

现代操作系统的物理内存是碎片化的——一个用户空间的 64 KiB 缓冲区可能对应多个不连续的物理页面。Scatter-gather 描述符链让 DMA 控制器自动处理这种情况:

描述符链:
+------+---------+--------+--------+
| Desc | src     | dst    | len    |
+------+---------+--------+--------+
| 0    | device  | 0x1000 | 4096   | → 物理页 A [0x1000–0x1FFF]
| 1    | device  | 0x5000 | 4096   | → 物理页 B [0x5000–0x5FFF]
| 2    | device  | 0x8000 | 4096   | → 物理页 C [0x8000–0x8FFF]
+------+---------+--------+--------+
                    物理上不连续,DMA 自动"串"成逻辑连续传输

6.4 Linux DMA API

#include <linux/dma-mapping.h>

// 方式一:连贯 DMA(coherent)——硬件保证缓存一致性
void *cpu_addr = dma_alloc_coherent(dev, size, &dma_handle, GFP_KERNEL);
// cpu_addr: CPU 可访问的虚拟地址
// dma_handle: 设备侧的 DMA 地址(总线地址)
// 释放:
dma_free_coherent(dev, size, cpu_addr, dma_handle);

// 方式二:流式 DMA(streaming)——程序员负责一致性
dma_addr_t dma_handle = dma_map_single(dev, cpu_addr, size, direction);
// direction: DMA_TO_DEVICE / DMA_FROM_DEVICE / DMA_BIDIRECTIONAL
// 传输完成后:
dma_unmap_single(dev, dma_handle, size, direction);

7. IOMMU / SMMU:设备侧的 MMU

7.1 设备也有虚拟地址

设备视角的地址(称为 IOVA,I/O Virtual Address 或 DMA Address)并不等同于物理地址。IOMMU 位于 PCIe 总线上,拦截所有设备发出的内存访问请求,查询自己的页表,将 IOVA 翻译为物理地址

flowchart TD
    subgraph Device["PCIe 设备 (例如 NVMe SSD)"]
        DMA_ENG["DMA 引擎"]
    end
    DMA_ENG -->|"IOVA 0x10000_0000"| IOMMU["IOMMU / SMMU"]
    IOMMU -->|"查设备页表"| IOMMU_TLB["IOTLB"]
    IOMMU_TLB -->|"命中: PA 0x3a2f_0000"| DRAM["DRAM"]
    IOMMU_TLB -->|"缺失: 硬件页表遍历"| PT["IOMMU 页表"]

7.2 Intel VT-d 与 ARM SMMUv3

特性Intel VT-dARM SMMUv3
基本功能设备 IOVA → PA 翻译
设备隔离每个设备可以有自己的页表StreamID → Context 映射
中断重映射MSI/MSI-X 中断可重定向到特定 CPUITS + GICv3
嵌套翻译两级:GVA → GPA → SPAStage 1 + Stage 2
设备侧 TLBATS(Address Translation Service)ATS over PCIe
最小规格4 KiB 页面支持4/16/64 KiB 页面支持

7.3 嵌套翻译:虚拟机内的 DMA

flowchart LR
    subgraph VM["虚拟机 (Guest)"]
        GVA["设备 IOVA\n(GVA)"] --> S1["Stage 1 翻译\n(GVA → GPA)"]
    end
    S1 --> GPA["中间物理地址\n(GPA)"]
    GPA -->|"+ Guest Offset"| S2["Stage 2 翻译\n(GPA → SPA)"]
    S2 --> SPA["系统物理地址\n(SPA)"]
  • Stage 1:虚拟机内核传给设备的地址(在 Guest 视角下是 PA,但对 Hypervisor 来说只是中间地址)。
  • Stage 2:Hypervisor (VMM) 提供的翻译,GPA → 真实物理地址。

最坏情况:Stage 1 四级遍历(4 次 DRAM)+ Stage 2 四级遍历(4 次 DRAM),总计 8× 4 = 32 次串行 DRAM 访问。这就是为什么硬件 IOTLB 对嵌套翻译性能至关重要。

7.4 安全收益:设备隔离

没有 IOMMU 的情况下:

DMA 请求 [NVMe 设备] → 直接落在物理内存 0x0 到 0xFFFFFFFF
                    → 可以任意读取/写入包括内核内存、其他 VM 内存

启用 IOMMU 后:

DMA 请求 [NVMe 设备] → IOMMU 拦截
    → 检查设备 BDF 对应页表
    → IOVA 0x1000 翻译到 PA 0x3a2f1000(这是分配给该 VM 的页面)
    → 如果 IOVA 不在页表中 → IOMMU 报告 Fault,阻止传输

VMWare、QEMU/KVM、Xen 中的 vfio-pci 设备直通完全依赖于 IOMMU。没有它,任何 DMA-capable PCIe 设备直通都等于"给 VM 发了根内存探测针"。


8. DMA 一致性:缓存同步问题

8.1 问题的核心

CPU 有 L1/L2/L3 缓存;DMA 设备通常不可缓存直连总线。以下场景是经典的一致性错误:

时间线:
t1: CPU 写入 buffer[0..1023] = "hello"        → 数据在 L1 缓存中(未到 DRAM)
t2: CPU 启动 DMA,描述符指向 buffer 的物理地址
t3: DMA 从 DRAM 读取 buffer 的物理地址        → 读到的可能是旧数据!

8.2 连贯 DMA(Coherent DMA)

硬件层面:DMA 写入通过总线监听(Bus Snooping)协议(MESI/MOESI)通知 CPU 缓存"这块内存被写了",使对应缓存行无效化或更新。

// 连贯 DMA:dma_alloc_coherent 保证
void *buf = dma_alloc_coherent(dev, 4096, &handle, GFP_KERNEL);
// 标记对应的物理页面为不可缓存(Uncacheable)或在硬件上通过 snoop filter
// 保证 DMA 读写和 CPU 读写的一致性

代价:连贯内存的 CPU 访问通常比正常缓存内存更慢(部分架构禁用缓存,或每次访问都走 snoop 总线)。

8.3 流式 DMA(Streaming DMA)

对高频数据传输(如 NIC 数据包缓冲区),连贯 DMA 性能不佳。流式 DMA 的哲学是 "由你来管理一致性,但给你最快的通路"

// 流式 DMA:手动刷缓存
// 场景:CPU 填充数据 → 设备读取
memcpy(buf, packet_data, len);
dma_map_single(dev, buf, len, DMA_TO_DEVICE);
// ↑ 内部调用 architecture-specific 的 cache flush / writeback
//   将 CPU 缓存中的数据刷到 DRAM,确保 DMA 读到最新数据

// ... 等待 DMA 完成 ...

// 场景:设备写入数据 → CPU 读取
dma_unmap_single(dev, handle, len, DMA_FROM_DEVICE);
// ↑ 内部调用 architecture-specific 的 cache invalidate
//   使 CPU 缓存中的对应行失效,确保下次 CPU 读从 DRAM 取数据
process_incoming_data(buf);

8.4 ARM 上的具体操作

ARM 使用 PIPT(Physically Indexed, Physically Tagged)或 VIPT L1 缓存;但一致性操作仍需要显式通过 CP15 或系统寄存器完成:

// ARM64: 清理数据缓存到 PoC(Point of Coherency)
// 对 buf[0..63] 做 writeback
DC CVAU, x0          // Clean by VA to Point of Unification
DSB SY               // 等待完成

// 使数据缓存无效(DMA 写完后)
DC IVAC, x0          // Invalidate by VA to Point of Coherency
DSB SY

Linux 内核在 arch/arm64/mm/cache.Sarch/arm64/mm/dma-mapping.c 中封装了这些操作。


9. 工程事故与安全教训

9.1 CVE-2020-12890:AMD IOMMU 旁路

2020 年,安全研究员在 AMD IOMMUv2 中发现:某些 ATS(Address Translation Service)事务处理不当,允许恶意设备绕过 IOMMU 地址翻译,直接访问任意物理内存。

正常路径:  Device → ATS Translation Request → IOMMU → 翻译 → 拒绝/允许
攻击路径:  Device → 伪造 ATS Completion → 绕过 IOMMU 检查 → 直接 DMA 到任意 PA

教训:任何时候在安全关键路径上,不可信输入的验证永远不能依赖于"外部设备的行为",必须由 IOMMU 固件硬线逻辑闭环。

9.2 Thunderbolt DMA 攻击(Thunderspy)

Thunderbolt 接口允许外部设备通过 PCIe 协议进行 DMA。如果没有 IOMMU,攻击者插入一个恶意 Thunderbolt 设备,便可:

  1. 扫描系统物理内存 → 找到内核凭证(加密密钥、密码) → 读取
  2. 写入物理内存 → 修改内核数据结构 → 提权

2019 年的 Thunderspy / Thunderclap 研究展示了具体的攻击实现。Linux kernel 自 5.0 起引入了 CONFIG_INTEL_IOMMU_DEFAULT_ON(默认启用),使得 Thunderbolt DMA 攻击只能访问已经被 IOMMU 映射的页面。

# 确认 IOMMU 已启用
$ dmesg | grep -i "DMAR: IOMMU enabled"
DMAR: IOMMU enabled

$ cat /proc/cmdline | grep iommu
iommu=pt intel_iommu=on

9.3 Meltdown / Spectre 对 MMU 的冲击

虽然本章不深入讨论侧信道攻击,但有必要提及:Meltdown 本质上利用了 Intel CPU 在 TLB 权限检查与数据载入之间的竞态。修复方案(KPTI)彻底重构了内核对页表的使用方式,其性能代价直接依赖 PCID 与 TLB 硬件来缓解。

9.4 AMD fTPM 与 MMIO 的烂摊子

另一类常见的问题是设备 MMIO(Memory-Mapped I/O)。如果驱动程序配置的 BAR(Base Address Register)与 BIOS/UEFI 报告的范围不一致,DMA 和 MMIO 可能落在不该去的物理页面。这类 bug 在 Linux 邮件列表上几乎每月出现一次。

核心教训:地址翻译链上任何一环出错(MMU、IOMMU、DMA 描述符、BAR 配置),最终都会表现为静默数据破坏(silent data corruption)——没有 Page Fault,没有 Oops,只有错误的结果。


10. 易错清单

#常见错误正确做法
1DMA 传输前未 dma_map_single,直接用物理地址必须通过 DMA mapping API,否则 CPU 缓存数据和 DRAM 数据不一致
2使用 kmalloc 返回的指针作为 DMA 地址kmalloc 返回虚拟地址,必须通过 virt_to_phys() 或 DMA API 转换为总线地址后使用
3假设 DMA 缓冲区连续vmalloc 分配的内存在物理上可以不连续;DMA 需要 kmalloc(≤4页)或用 dma_alloc_coherent
4大页映射忽略对齐mmap + MAP_HUGETLB 需要 2 MiB / 1 GiB 对齐;未对齐 → mmap 返回 EINVAL
5INVLPG 后未加 MFENCEx86 上 PTE 修改后必须 MFENCE 保证全局可见,再执行 INVLPG,否则其他核心 TLB 可能仍有旧条目
6DMA 映射与解除映射方向不一致DMA_TO_DEVICE 映射 + DMA_FROM_DEVICE 解除 → 平台相关行为,某些架构缓存操作方法不同
7设备直通时忘记启用 IOMMU没有 IOMMU 的 VFIO 直通会使 VM 可以 DMA 到物理内存任何位置;QEMU 的 -device vfio-pci 必须配合 intel_iommu=on 命令行参数
8TLB 满载触发的 trashing高频上下文切换 + 大工作集 → TLB 覆盖率可能趋近 0%;使用大页 / THPsched_setaffinity 减少核心间 ping-pong
9忘记 dma_free_coherent泄露的不只是虚拟内存(vmalloc 区域),还有 DMA 页本身——这类泄漏用 kmemleak 抓不到
10PCIe ATS 未启用时假设设备可缓存翻译没有 ATS 的设备,每次 DMA 都要走一次 IOMMU 页表遍历;如果同时使用嵌套翻译,延迟不可忽视(32 次 × 100 ns = 3.2 μs)

11. 这一章带走的东西

  1. TLB 缺失的代价是 4 次串行 DRAM 访问(x86-64 四级页表,~400 ns,约 1200 个 CPU 周期)。五级分页则增加到 5 次。
  2. IOMMU 为设备提供 IOVA → 物理地址的翻译,与 CPU 的 MMU 是同构的。因此 DMA 攻击在没有 IOMMU 时等同于裸物理内存读写。
  3. 大页(2 MiB、1 GiB)减少 TLB 压力——一个 1 GiB 页面只需 1 个 TLB 条目,而等量的 4 KiB 页面需要 262,144 个条目。
  4. DMA 缓存一致性问题来自 CPU 缓存的写回策略:CPU 将数据写入缓存后直接启动 DMA,DMA 可能从 DRAM 读到旧数据。用 dma_alloc_coherent(连贯 DMA)或 dma_map_single(流式 DMA)正确管理。
  5. 上下文切换 ≠ TLB 全刷:现代 CPU 使用 ASID(ARM)或 PCID(x86)标记 TLB 条目,配合写入 CR3 不刷 TLB 的特性,大幅削减切换开销。
  6. 虚拟化中 IOMMU 嵌套翻译的最坏路径能达到 32 次 DRAM 访存(Stage 1 四级 × Stage 2 四级 × 两次遍历)。理解这个数字,就理解了为什么 VFIO 直通、SR-IOV 和硬件 IOTLB/ATS 不是"锦上添花"而是"生死攸关"。
  7. 地址翻译链上一枚错误 = 静默数据破坏。没有 Page Fault,没有 Kernel Oops——只有莫名其妙的数据错误。在驱动、虚拟化、DMA 代码中,检查地址翻译链的每一环节是必备的防范意识。

下一节 → GPU 架构:SM / CUDA Core / Tensor Core

GPU 架构:SM / CUDA Core / Tensor Core

TL;DR — GPU 与 CPU 的根本分野在于设计哲学:CPU 押注延迟优化(大缓存、深流水线、乱序执行、分支预测),GPU 押注吞吐优化(海量轻量核心、零开销线程切换、高带宽显存)。理解 SM → Warp → CUDA Core → Tensor Core 这一层次结构,是写出高性能 CUDA 程序的前提。H100 上 16896 个 CUDA Core 以 warp (32 线程) 为单位被 warp scheduler 发射,Tensor Core 在同一时钟内完成 4×4 矩阵乘加,实现了 FP16 下 989 TFLOPS 的算力密度。


思维链:从"为什么要 GPU"开始

先问一个问题:同样是硅基半导体,为何 GPU 能比 CPU 的 FP32 吞吐高出两个数量级?

答案藏在两个数字里:CPU 约 25% 的晶体管用于运算单元 (ALU/FPU),其余 75% 用于控制逻辑(分支预测、乱序窗口、调度器)、缓存和多级存储。GPU 把这比例颠倒过来——>80% 晶体管投入计算单元,缓存和控制的面积被压缩到极致。

你可以把 CPU 想象成一队 F1 赛车(低延迟,快速完成单次任务),GPU 是一万辆摩托车(高吞吐,并行处理海量任务)。对于深度学习推理中几百 GB 的矩阵乘法,一万辆摩托车完胜 F1。


1. GPU vs CPU:两种设计哲学

维度CPU (典型: Intel Sapphire Rapids)GPU (典型: NVIDIA H100)
核心数量8-64 个大核 (P-core)16896 个 CUDA Core
每核心缓存L1 32-64KB private, L2 1-2MB per coreSM 内共享 256KB L1
线程切换开销~100 cycles (OS 上下文切换)0 cycles (warp 硬件调度)
乱序窗口~512 entry ROB无乱序 (in-order)
内存带宽~1 TB/s (DDR5 × 8ch)3.35 TB/s (HBM3)
设计目标单线程延迟最小化总吞吐量最大化
面积分配~25% 计算, ~75% 控制+缓存~80% 计算, ~20% 控制+缓存
graph LR
    subgraph CPU
        A[Few Heavy Cores<br/>Deep pipeline<br/>Large caches<br/>Branch prediction]
    end
    subgraph GPU
        B[Thousands of Light Cores<br/>Shallow pipeline<br/>Small caches<br/>HBM bandwidth]
    end
    A -->|Latency-optimized| C[Single task: fast]
    B -->|Throughput-optimized| D[Millions of tasks: fast]

CPU 的哲学可概括为:让每一条指令执行得尽可能快——所以你看到分支预测器、乱序执行窗口、多级私有缓存、硬件数据预取。这些设计对 ./a.out 单线程跑得快至关重要。

GPU 的哲学则是:让总吞吐量尽可能大——既然 ML 训练和图形渲染天然需要做数百万次几乎相同的运算,那么放弃分支预测(大量分支→warp divergence)、放弃乱序执行(靠多 warp 零开销切换隐藏延迟)、放弃大私有缓存(用 programmer-managed shared memory 替代),把省下来的晶体管全部塞进 FP 单元。

关键指标对比:

CPU (AMD EPYC 9654, 96 cores):
  L1 带宽合计 ≈ 6 × 32B/cycle × 3 GHz = ~576 GB/s per socket

GPU (H100, 132 SMs):
  每 SM 128 CUDA cores × 2 FLOPS/FMA × 1.98 GHz = ~507 GFLOPS/SM
  132 SM × 507 GFLOPS = ~67 TFLOPS (FP32)
  FP16 Tensor Core: 989 TFLOPS
  HBM3 带宽: 3.35 TB/s

2. SIMT 模型:同一指令,多条线程

NVIDIA GPU 的执行模型叫 SIMT (Single Instruction, Multiple Threads)。核心概念是 warp——32 个线程为一组,共享同一个程序计数器 (PC),执行同一条指令。

sequenceDiagram
    participant WS as Warp Scheduler
    participant W0 as Warp 0 (thread 0..31)
    participant W1 as Warp 1 (32..63)
    participant FU as Execution Units

    WS->>W0: Issue ADD (cycle 0)
    note over W0: All 32 threads<br/>execute same ADD
    W0->>FU: 32× ADD dispatched
    WS->>W1: Issue MUL (cycle 1)
    note over W1: Warp 0 is waiting<br/>for ADD result
    W1->>FU: 32× MUL dispatched
    WS->>W0: Issue LOAD (cycle N)
    note over WS: Zero-cost context<br/>switch between warps

SIMT 与 CPU 上 SIMD (AVX-512) 的关键区别:

特性SIMD (CPU AVX-512)SIMT (GPU Warp)
向量宽度512-bit (16× FP32)32 threads (逻辑)
编程模型显式 intrinsic (_mm512_add_ps)标量线程 (编译器映射到 warp)
分支处理必须全同路径 (mask)允许 divergence (序列化)
线程标识threadIdx.x, blockIdx.x

CUDA 代码视角:

// 程序员写的是标量线程代码 —— 感觉像 CPU 编程
__global__ void vecAdd(float *a, float *b, float *c, int n) {
    int i = blockIdx.x * blockDim.x + threadIdx.x;
    if (i < n) {
        c[i] = a[i] + b[i];
    }
}
// GPU 上:硬件将 32 个相邻 thread 打包成一个 warp
// 同一 warp 的所有线程同时执行 c[i] = a[i] + b[i]

3. CUDA Core:FP32 的基本计算单元

一个 CUDA Core = 一个全流水线化的 FP32 FMA (Fused Multiply-Add) 单元,每时钟周期可完成一次 a × b + c,按业界惯例计为 2 FLOPs

graph TD
    A[a] --> MUL[FP32 Multiplier]
    B[b] --> MUL
    C[c] --> ADD[FP32 Adder]
    MUL -->|a × b| ADD
    ADD -->|a × b + c| D[Result]

FMA 的精度优势: 非融合的乘加 MUL→round→ADD→round 产生两次舍入误差,FMA 在中间结果 a×b 保持无限精度,只在最终加 c 后舍入一次。这对数值稳定性和迭代收敛速度有可测量的影响——例如 Kahan 求和可以在 FMA 上获得更好的误差界。

CUDA Core 的组织方式:

H100 SM 内部:
  ┌──────────────────────────────────────┐
  │  Warp Scheduler (×4)                 │
  │    ├─ Dispatch Unit                  │
  │    ├─ Register File (65536×32-bit)   │
  │    └─ Execution Ports               │
  │         ├─ FP32 CUDA Core ×32  ──── 每周期 32 FMA
  │         ├─ FP64 Core ×16            │
  │         ├─ LD/ST Unit ×16           │
  │         └─ Tensor Core ×1 (4th Gen) │
  └──────────────────────────────────────┘

实际吞吐并非峰值: 虽然每个 CUDA Core 理论上每时钟 1 FMA = 2 FLOPs,但实际吞吐受以下因素限制:

  1. 寄存器带宽: 每线程每周期最多 1 次 32-bit 读 + 1 次 32-bit 写
  2. Warp scheduler 发射能力: 每个 scheduler 每周期 1 条指令
  3. 操作数就绪: 如果 warp 等待 HBM 加载,调度器自动切换到另一就绪 warp,无需软件干预
// 要达到峰值:每个 SM 上至少需要让 4 个 warp scheduler
// 都持续有就绪的 warp 可发射 → occupancy 是关键
// 每个 warp scheduler 跟踪 16 个 warp (H100 上 max 64 warp/SM)

4. NVIDIA SM 架构详解 (H100)

H100 的 Streaming Multiprocessor 是 GPU 计算的核心单元。每颗 H100 芯片包含 132 个 SM,总面积 814 mm² (TSMC 4N)。

graph TB
    subgraph SM["H100 Streaming Multiprocessor (×132 on chip)"]
        direction TB
        subgraph Front["指令发射"]
            WS0[Warp Scheduler 0<br/>dispatch 1 instr/cycle]
            WS1[Warp Scheduler 1]
            WS2[Warp Scheduler 2]
            WS3[Warp Scheduler 3]
        end

        subgraph Register["寄存器文件"]
            RF["65536 × 32-bit registers<br/>= 256 KB"]
        end

        subgraph Compute["计算单元"]
            CUDA["128 × FP32 CUDA Core<br/>64 × FP64 Core<br/>32 × SFU"]
            TC["4 × Tensor Core (4th Gen)<br/>1 × RT Core"]
        end

        subgraph Memory["片上存储"]
            SMEM["Shared Memory / L1<br/>256 KB (可配置划分)"]
        end

        WS0 & WS1 & WS2 & WS3 --> RF
        RF --> CUDA
        RF --> TC
        SMEM --> Compute
    end

    subgraph OffChip["片外"]
        HBM3["HBM3<br/>80 GB, 3.35 TB/s"]
        L2["L2 Cache<br/>50 MB"]
    end

    SMEM <--> L2 <--> HBM3

每个 SM 的关键参数:

参数H100 值说明
FP32 CUDA Cores128每 SM 128,总计 132×128 = 16896
FP64 Cores64FP64 : FP32 = 1:2
Tensor Cores44th Gen,每个可执行 4×4×4 MMA
Warp Schedulers4每 scheduler 可跟踪 16 warp
Max Warps / SM6464 warps × 32 threads = 2048 threads
Max Thread Blocks / SM32
Max Threads / Block1024由程序员决定
Register File65536 × 32-bit每个 thread 最多使用 255 registers
Shared Memory / L1256 KBconfigurable: up to 228 KB shared mem
L1 Bandwidth~4 TB/s per SM (aggregate ~528 TB/s on-chip)

Warp Scheduler 的工作机制:

每个 scheduler 维护 16 个 warp 的硬件上下文:
  - 程序计数器 (PC)
  - 栈指针(用于函数调用/递归,实际存放在 local memory)
  - 寄存器基址(映射到 register file 的哪一段)

每周期: scheduler 扫描其 16 个 warp → 挑选 1 个
        "就绪" 的 warp (操作数均已就绪) → 发射 1 条指令

关键: 如果某 warp 在等 HBM 数据(~300 cycles 延迟),
      scheduler 瞬间切到另一个就绪 warp,零开销。
      这要求 SM 上至少有 4+ active warps per scheduler。

H100 vs A100 SM 差异:

A100 (GA100)H100 (GH100)
SM 数量108132
CUDA Cores / SM64128
Tensor Cores / SM4 (3rd Gen)4 (4th Gen)
Shared Mem / SM192 KB256 KB
FP8 支持✅ (Transformer Engine)
TMA
FP16/TC TFLOPS312989

5. Tensor Core:矩阵乘法加速器

5.1 工作原理

Tensor Core 的核心指令是 MMA (Matrix Multiply-Accumulate):在一个时钟内完成 D = A × B + C,其中每一个操作数都是小矩阵。

第 4 代 Tensor Core (H100) 的 MMA 维度:

每个 Tensor Core 每周期: 4×4×4 MMA
  即: 4×4 的 A × 4×4 的 B + 4×4 的 C

  每次 MMA 包含 4×4×4 = 64 次 FMA = 128 FLOPs

每 SM 有 4 个 Tensor Core,4 个 warp scheduler
  → 16 次 MMA / 周期 / SM (如果 4 个 scheduler
    都发射 MMA 指令到各自的 Tensor Core)
graph LR
    subgraph MMA["Tensor Core MMA: D = A × B + C"]
        A4x4["A (4×4)"]
        B4x4["B (4×4)"]
        C4x4["C (4×4)"]
        D4x4["D (4×4)"]
    end
    A4x4 --> MUL["64 次乘法"]
    B4x4 --> MUL
    MUL -->|64 路| ADD["累加器"]
    C4x4 --> ADD
    ADD --> D4x4

5.2 H100 各精度算力

精度TFLOPS用途
FP6467HPC、科学计算(要求 64-bit 精度)
TF32494AI 训练的最优选择——19-bit 精度,32-bit 范围
FP16989推理、Transformer 训练
BF16989与 FP16 同吞吐,更大的动态范围
FP81979Transformer Engine 专用,最高吞吐
INT81979量化推理

TF32 的巧妙之处: TensorFloat-32 使用 19-bit 精度(与 FP16 尾数相同)但保持 8-bit 指数(与 FP32 相同)。这意味着 TF32 有 FP32 的动态范围(可表示 ~1e-38 到 ~3e38),又有 FP16 级别的速度。对训练而言,这是"几乎无损失、几乎双倍速"的精度选择。

// PTX 级别的 MMA 指令(warp 级矩阵乘法)
// 假设 A 和 B 已加载到 shared memory, C 和 D 在寄存器中
// A[m16, k16] × B[k16, m16] = C[m16, n16]

// 伪代码示意(真实语法更复杂)
mma.sync.aligned.m16n8k16.row.col.f16.f16.f16.f16
    {d0, d1, d2, d3},    // 输出 D (FP16)
    {a0, a1},             // 输入 A (FP16)
    {b0, b1},             // 输入 B (FP16)
    {c0, c1, c2, c3};    // 累加器 C (FP16)

5.3 为什么 Tensor Core 重要

对比: FP32 CUDA Core vs Tensor Core FP16 (H100)

CUDA Core 算矩阵乘法:
  每 SM 128 CUDA Core × 2 GHz = 256 GFLOPS FP32
  132 SM → 33.8 TFLOPS FP32 (实际受限于 load/store)

Tensor Core 算矩阵乘法:
  每 SM 4 TC × 256 FP16 FMA × 2 GHz = 2048 GFLOPS
  132 SM → 989 TFLOPS FP16 (官方峰值)

Tensor Core 比 CUDA Core 快 ~6× (FP16 vs FP32) 或 ~3× (TF32 vs FP32)

在 Transformer 的自注意力 Q × K^T 和 FFN 的 W_up × X 计算中,矩阵乘法占 90%+ 的 FLOPs。Tensor Core 几乎就是为这类 GEMM (General Matrix Multiply) 设计的。


6. RT Core:实时光追硬件

H100 每个 SM 包含 1 个 RT Core,主要用于光线追踪可视化。它硬件加速两个关键操作:

  1. BVH 遍历 (Bounding Volume Hierarchy Traversal):光线的层次包围盒测试。BVH 将场景中的三角形组织成树形结构——先测试大包围盒,命中再深入子节点,大幅减少无意义的三角形交集测试。

  2. 光线-三角形求交 (Ray-Triangle Intersection):给定光线和三角形,计算交点、重心坐标。这涉及浮点除法(求解重心坐标)和条件判断,纯 CUDA Core 实现效率低。

graph TD
    RAY[Ray Origin + Direction] --> BVH[BVH Traversal<br/>RT Core 硬件加速]
    BVH -->|Hit leaf node| TRI[Ray-Triangle Intersect<br/>RT Core 硬件加速]
    TRI -->|Hit| SHADE[Shader (CUDA Core)<br/>计算颜色、递归反射]
    BVH -->|Miss/早退| SKIP[Skip subtree<br/>省去大量无用计算]

OptiX 框架: NVIDIA 的 OptiX API 封装了 RT Core,提供 optixTrace() 调用。开发者只需提供着色器函数(closest-hit, any-hit, miss 等),硬件负责 BVH 遍历。

RT Core 对 AI 训练无直接用途——它独立于 Tensor Core,但 H100 保留 RT Core 是因为 Hopper 也被用于 Omniverse 等 3D 渲染场景。


7. GPU 内存层次

GPU 的内存层次比 CPU 更扁平,但带宽差异更极端。

graph TD
    subgraph OnChip["片上 (On-Chip)"]
        REG["Registers<br/>64K × 32-bit per SM<br/>延迟: ~0 cycles<br/>带宽: ~60 TB/s per SM"]
        SMEM["Shared Memory + L1<br/>256 KB per SM<br/>延迟: ~20-30 cycles<br/>带宽: ~4 TB/s per SM"]
        RDCACHE["Read-Only Cache<br/>统一纹理缓存"]
    end

    subgraph CrossSM["跨 SM"]
        L2["L2 Cache<br/>50 MB (H100)<br/>延迟: ~200 cycles<br/>带宽: ~10 TB/s"]
    end

    subgraph OffChip["片外 (Off-Chip)"]
        HBM["HBM3<br/>80 GB<br/>延迟: ~300-500 cycles<br/>带宽: 3.35 TB/s"]
        NVME["System Memory (host)<br/>通过 PCIe 5.0<br/>~64 GB/s"]
    end

    REG --> SMEM
    SMEM --> L2
    L2 --> HBM
    HBM --> NVME

各级存储对比:

层次H100 规格关键特性
Register File256 KB × 132 = 33 MB total最快,编译时分配;溢出到 local memory (HBM)
L1 / Shared Mem256 KB × 132 = 33 MB total可配置划分 (profiling 后调优)
L2 Cache50 MB (unified)A100 为 40 MB,H100 增大至 50 MB
HBM380 GB6 个 HBM3 stack,5120-bit 接口,3.35 TB/s
NVLink900 GB/s per GPU (bidir)8 GPU 全互联 (NVSwitch)
PCIe 5.0~64 GB/sGPU ↔ Host 传输瓶颈

Shared Memory 的可配置划分:

// 在 kernel launch 时动态设置 shared memory 大小
// 可选 carveout: 0, 8, 16, 32, 64, 100, 132, 164, 196, 228 KB

// 方式 1: 编译时声明
__global__ void kernel() {
    __shared__ float tile[32][32]; // 静态分配
}

// 方式 2: 运行时指定 (第三个参数)
kernel<<<grid, block, smem_bytes, stream>>>();
// H100 最大 228 KB shared mem per block

// 方式 3: 查询优先选项
cudaFuncSetAttribute(kernel, cudaFuncAttributeMaxDynamicSharedMemorySize, 228*1024);

8. TMA:Tensor Memory Accelerator

H100 引入了一项关键的硬件单元——TMA (Tensor Memory Accelerator)。传统 GPU 中从 HBM 加载数据到 shared memory 需要每个 warp 发射多个 LDG 指令(每条加载 16 bytes),占用 CUDA Core 周期。TMA 将 2D 数据搬运卸载到专用硬件。

sequenceDiagram
    participant CTA as Thread Block (CTA)
    participant SMEM as Shared Memory
    participant TMA as TMA Unit (Hardware)
    participant HBM as HBM3

    CTA->>TMA: cp.async.bulk (tile descriptor)<br/>CTA 立即继续执行
    note over TMA: TMA 异步读取<br/>2D tile from HBM
    TMA->>HBM: 2D memory request
    HBM->>TMA: tile data (e.g. 128×128 bytes)
    TMA->>SMEM: write to shared memory
    note over TMA: ~300 cycles later

    CTA->>CTA: 执行其他计算 (如上一 tile 的 MMA)
    CTA->>TMA: cp.async.bulk.wait_group
    note over CTA: 等待 TMA 完成

    CTA->>SMEM: 从 shared memory 加载到寄存器
    CTA->>CTA: 发射 MMA 指令 (Tensor Core)

TMA 的关键特性:

  • 2D copy: 与常规 memcpy 的一维线性复制不同,TMA 理解 2D 内存布局(width, height, stride between rows),直接复制矩阵 tile。
  • Bypass L1: TMA 写入 shared memory,直接跳过 L1 缓存,避免污染 L1。
  • 异步屏障: cp.async.bulk.wait_group 等待指定数量的 pending TMA 事务。
  • 硬件一致性: TMA 保证 shared memory 写入与后续 warp 访问的顺序一致性。
// TMA 加载伪代码 (实际通过 PTX cp.async.bulk)
// 定义一个 2D tile descriptor → TMA 硬件读取 → 写入 shared memory

// 简化的 TMA 编程模式:
// 1. 创建 CUtensorMap 对象(描述 HBM 中的 2D 张量布局)
// 2. 在 kernel 中调用 cp.async.bulk.tile.2d 启动异步拷贝
// 3. 发射 cp.async.bulk.commit_group 标记事务组
// 4. 发射 cp.async.bulk.wait_group N 等待 N 个事务完成
// 5. __syncthreads() 确保 shared memory 对 block 内所有线程可见
// 6. 从 shared memory 读取数据 → 进入 MMA 计算

TMA 对于 ML 训练的价值:在 FlashAttention 等算法中,原始实现用 CUDA Core 加载 Q, K, V tile,TMA 可以将加载的指令周期节省下来用于计算。实测中,TMA 在 BERT/LLaMA 训练中可将 matmul kernel 效率从 ~70% 提升到 ~85%。


9. Warp Divergence:GPU 性能杀手

Warp 内 32 个线程共享同一个 PC(程序计数器)。如果分支导致线程走不同路径,硬件必须 序列化 执行:

if (threadIdx.x < 16) {
    // 路径 A: 16 个线程活跃
    result[threadIdx.x] = a[threadIdx.x] * 2.0f;
} else {
    // 路径 B: 16 个线程活跃
    result[threadIdx.x] = a[threadIdx.x] / 2.0f;
}
// 执行顺序:
//   1. 执行路径 A (thread 0..15 活跃, 16..31 被 mask 掉)
//   2. 执行路径 B (thread 16..31 活跃, 0..15 被 mask 掉)
// 实际吞吐 = 50%
graph TD
    WARP[Warp: 32 threads, 1 PC]
    BRANCH{threadIdx.x < 16?}
    ACTIVE_A[Active mask: 0..15<br/>Threads 16..31 disabled]
    ACTIVE_B[Active mask: 16..31<br/>Threads 0..15 disabled]
    REMAIN[Remaining?]
    CONVERGE[Reconverge<br/>all 32 active]

    WARP --> BRANCH
    BRANCH -->|Take A| ACTIVE_A
    BRANCH -->|Then B| ACTIVE_B
    ACTIVE_A --> REMAIN
    ACTIVE_B --> REMAIN
    REMAIN -->|No| CONVERGE

常见的 warp divergence 场景:

场景问题解决方案
边界检查 if (idx < N)最后一个 warp 的尾部 divergence不可避免,但影响有限
基于 threadId 的条件固定 pattern 可能导致每次 launch 都 divergence重组线程映射
基于数据的条件最严重:运行时决定,编译器无法优化数据预处理、按值排序
循环 while 的不同退出点各线程退出时间不同使用固定迭代次数

__syncwarp() 的作用:

// __syncwarp() 是 warp 级同步屏障
// 与 __syncthreads() 不同,它只同步同一 warp 内的线程

// 场景: warp-level reduction 中确保部分结果就绪
__shared__ float partial_sum[32];
int lane = threadIdx.x % 32;

// 每个线程写入自己的部分
partial_sum[lane] = compute_something();

__syncwarp();  // 确保 warp 内所有线程的写入对彼此可见

// 现在可以安全地做 warp shuffle
float sum = __shfl_down_sync(0xFFFFFFFF, partial_sum[lane], 1);

Warp Shuffle 指令: __shfl_down_sync, __shfl_xor_sync 等是同一个 warp 内线程间直接交换寄存器的硬件指令——比 shared memory 快,比 global memory 快两个数量级。配合 __syncwarp() 即可在 warp 内安全地做 reduction。


10. Shared Memory Bank Conflicts

H100 的 shared memory 由 32 个 bank 组成,每个 bank 每周期可服务一个 4-byte 地址。如果同一 warp 的 32 个线程分别访问 32 个不同 bank → 一次 servicing 完成。如果有多个线程访问同一 bank → bank conflict,请求被序列化。

graph TB
    subgraph BankLayout["Shared Memory: 32 Banks × 4 bytes/bandwidth"]
        B0["Bank 0<br/>Addr 0, 128, 256..."]
        B1["Bank 1<br/>Addr 4, 132, 260..."]
        BD["..."]
        B31["Bank 31<br/>Addr 124, 252..."]
    end

    subgraph Example["示例"]
        E1["stride-1: 线程 i 访问 addr[i]<br/>→ 每个线程不同 bank → 无 conflict"]
        E2["stride-2: 线程 i 访问 addr[2*i]<br/>→ 2-way conflict(16 个 bank 被 2 个线程命中)"]
        E3["stride-32: 线程 i 访问 addr[32*i]<br/>→ 32-way conflict(所有线程命中同一 bank)"]
    end

Bank conflict 实战:

// 常见错误:以 strided 方式访问 shared memory
__shared__ float smem[32][32];

// ❌ 危险: stride-32 访问 → 32-way bank conflict
float val = smem[threadIdx.x][threadIdx.y];  // 如果 blockDim.x=32
// 所有线程访问 smem[0..31][same_column]
// → 同一列偏移相同 → 同 bank

// ✅ 安全: stride-1 访问 → 无 conflict
float val = smem[threadIdx.y][threadIdx.x];

// ✅ 解决方案:添加 padding 打散 bank 映射
__shared__ float smem[32][32 + 1];  // padding of 1
// 现在 smem[0][0] 和 smem[1][0] 不再在同一 bank

H100 上的变化: H100 将 shared memory 的 port 数量从 A100 的 16 增加到 32,每个 bank 的宽度从 4 bytes 增加到 32 bytes。这意味着对于 FP32 数组(4 bytes),同一 bank 可同时服务 8 个地址请求(当它们落在同一 32-byte bank line 中时)。但这不影响 strided-access 导致的 conflict。


11. GPU Occupancy:隐藏延迟的数学

Occupancy = active warps per SM / max warps per SM

H100 的每个 SM 最多同时驻留 64 个 warp(2048 threads)。高 occupancy 的好处是:当一个 warp stall(等待内存),scheduler 立即切到另一个就绪 warp。每个 warp 的上下文(寄存器、PC、栈指针)都在硬件中,切换不消耗任何周期

graph LR
    subgraph LowOcc["低 Occupancy: 16/64 warps"]
        W0_1[Warp 0: compute]
        W1_1[Warp 1: waiting HBM]
        W2_1[Warp 2: waiting HBM]
        GAP_1["~ 大量空闲周期 ~"]
    end
    subgraph HighOcc["高 Occupancy: 48/64 warps"]
        W0_2[Warp 0..15: compute]
        W1_2[Warp 16: waiting]
        W2_2[Warp 17: waiting]
        W3_2[Warp 18..47: compute]
    end

Occupancy 计算的关键约束:

资源H100 每 SM对 occupancy 的影响
Max Threads / SM204864 warps × 32 threads
Register File65536 × 32-bit每 thread 用 255 regs → 只能容纳 256 threads = 8 warps
Shared Memory228 KB max每 block 用 64 KB → 最多 3 blocks (如果 thread 数允许)
Max Blocks / SM32小 blocks 可提高 occupancy

Trade-off 示例:

配置 A: 每 thread 64 registers, blockDim=256
  → registers used: 256 × 64 = 16384 per block
  → max blocks per SM: min(65536/16384=4, 32) = 4
  → occupancy: 4 × 256 / 2048 = 50% (32 warps)

配置 B: 每 thread 128 registers, blockDim=256
  → registers used: 256 × 128 = 32768 per block
  → max blocks: min(65536/32768=2, 32) = 2
  → occupancy: 2 × 256 / 2048 = 25% (16 warps)

结论: 寄存器使用翻倍 → occupancy 减半。
      但 128 regs/thread 可能换取 2× 指令级提升。
      不一定低 occupancy 就慢——需要 profiling。

经验法则:

  • 算术密集型 kernel (如 GEMM):occupancy 25-50% 可能就足够隐藏延迟
  • 内存密集型 kernel (如 element-wise ops):需要 50%+ 的 occupancy
  • 永远用 ncu (Nsight Compute) 的 Occupancy 分析而非猜测

单 GPU 不够时,DGX H100 把 8 颗 GPU 用 NVSwitch 全互联。

graph TD
    GPU0[H100 GPU 0] --- NVSW0[NVSwitch 0]
    GPU1[H100 GPU 1] --- NVSW1[NVSwitch 1]
    GPU2[H100 GPU 2] --- NVSW2[NVSwitch 2]
    GPU3[H100 GPU 3] --- NVSW3[NVSwitch 3]
    GPU4[H100 GPU 4] --- NVSW0
    GPU5[H100 GPU 5] --- NVSW1
    GPU6[H100 GPU 6] --- NVSW2
    GPU7[H100 GPU 7] --- NVSW3

    NVSW0 --- NVSW1
    NVSW1 --- NVSW2
    NVSW2 --- NVSW3
    NVSW3 --- NVSW0

NVLink 4.0 (H100) 规格:

参数
每 GPU NVLink 带宽900 GB/s (bidirectional)
NVLink 链路数18 条 (每条 50 GB/s unidir = 25 GB/s × 2 lanes)
DGX H100 总 NVLink 带宽8 GPU × 900 / 2 = 3.6 TB/s (full-duplex ≈ 7.2 TB/s)
NVSwitch 架构4 颗 NVSwitch × 每个连接 8 GPU
PCIe 5.0128 GB/s (PCIe 5.0 ×16, 双向),远低于 NVLink

通信原语:

// NCCL: NVIDIA Collective Communications Library
// All-Reduce (最常用): 每 GPU 的梯度汇总到所有 GPU
// 在 NVSwitch 网络上使用 Ring 或 Tree 算法

ncclAllReduce(sendbuff, recvbuff, count, ncclFloat32,
              ncclSum, comm, stream);

// NVSwitch 的全互联特性意味着 All-Reduce 只需 1 step
// (而非 PCIe 拓扑下需要 log2(N) steps)

Tensord Parallelism 与 Pipeline Parallelism:

模型并行策略:
  Tensor Parallelism (TP):
    单层权重切分到多 GPU → 每步需 All-Reduce
    NVLink 带宽至关重要 (通信放缩)
    典型: TP=8 (DGX H100 的 8 卡)

  Pipeline Parallelism (PP):
    不同层分配到不同 GPU → 微批次流水线
    通信少但对负载均衡敏感

  Data Parallelism (DP):
    每 GPU 持有完整模型副本 → 梯度 All-Reduce
    NVLink 能提供比 PCIe 快 ~10× 的梯度同步

13. AMD MI300X vs H100:竞争对手视角

AMD 以 CDNA3 架构的 MI300X 正面挑战 H100。

graph TB
    subgraph MI300X["AMD MI300X (CDNA3)"]
        M1["304 Compute Units<br/>19,456 Stream Processors"]
        M2["1216 Matrix Cores<br/>(类似 Tensor Core)"]
        M3["192 GB HBM3<br/>5.3 TB/s bandwidth"]
        M4["Unified Memory<br/>CPU+GPU 共享 HBM"]
        M5["Infinity Fabric<br/>896 GB/s per GPU"]
    end

    subgraph H100["NVIDIA H100 (Hopper)"]
        H1["132 SMs<br/>16,896 CUDA Cores"]
        H2["528 Tensor Cores<br/>(4th Gen)"]
        H3["80 GB HBM3<br/>3.35 TB/s bandwidth"]
        H4["HBM-only<br/>PCIe to host"]
        H5["NVLink 4.0<br/>900 GB/s per GPU"]
    end

关键参数对比:

参数H100 (SXM)MI300X
制程TSMC 4NTSMC 5nm + 6nm
TDP700W750W
FP16 TFLOPS (dense)9891300 (理论)
FP8 TFLOPS19792600 (理论)
HBM 容量80 GB192 GB
HBM 带宽3.35 TB/s5.3 TB/s
软件生态CUDA (成熟)ROCm (追赶紧密)

MI300X 的优势与劣势:

  • 优势: 更大的 HBM 容量(192 GB vs 80 GB)意味着可装入更大的模型而无需 TP 切分。在 Llama-2-70B 推理中,MI300X 可以将整个权重和 KV cache 放在单 GPU 上。
  • 劣势: ROCm 软件生态在算子覆盖度、调试工具(对标 Nsight)、框架集成(PyTorch 的 torch.compile)上仍有差距。CUDA 的 -arch=sm_90 这个编译目标包含了 15 年的积累。
  • Unified Memory (APU): AMD 的 MI300A 将 CPU (Zen 4) 和 GPU (CDNA3) 共享同一个 HBM 池——消除 CPU↔GPU 的 PCIe 拷贝,对 HPC 代码(如网格求解器)意义重大。

14. 真实数字:训练 GPT-3 需要多少算力?

GPT-3 训练(175B 参数,300B tokens)的最优配置分析:

已知:
  - 总 FLOPs: ~3.14 × 10^23 FLOPs (6 × 175B × 300B)
  - H100 BF16: 989 TFLOPS 理论 / ~500 TFLOPS 实际效率 (50%)
  - 训练时间目标: ~3.5 个月 (约 90 天)

单 GPU 需时:
  3.14e23 / (500e12) = 6.28e8 秒 ≈ 19.9 年

需要 GPU 数量:
  19.9 年 / 0.29 年 (3.5 个月) ≈ 68,600 GPU

考虑到多 GPU 通信开销 (~40% 效率):
  68,600 / 0.6 ≈ 114,000 GPU

InfiniBand 互联下的实测数据 (Meta):
  ~16,000 H100 集群, 3.5 个月训练 Llama-3-405B
  MFU (Model FLOPs Utilization): ~38%

MFU 为什么达不到 100%?

理想情况: 所有 FLOPs 都是计算密集的 matmul
现实情况:
  1. Attention: softmax 归一化、mask 操作 (compute-bound 弱)
  2. LayerNorm / RMSNorm: element-wise 操作 (memory-bound)
  3. 激活函数 (GELU/SiLU): 对标量操作
  4. 多 GPU 的通信间隙 (bubble)
  5. 编译器 fusion 不完美

业界最佳实践: MFU 在 50-60% (大规模训练)
Meta 公开的 Llama-3 训练: ~38% MFU

15. 工程事故录

15.1 Bumpgate (2008)

NVIDIA G84/G86 系列 GPU(GeForce 8400M/8600M)使用的 flip-chip 封装中,solder bump 材料的热膨胀系数与 die 不匹配,导致重复热循环后 bump 断裂。故障模式:笔记本冷启动正常→温度升高→GPU 脱焊→花屏/不显示。NVIDIA 为此计提了 ~$200M 费用,苹果、戴尔、惠普全线召回。对硬件的教训:封装热应力的长期可靠性测试不可跳过。

15.2 Ampere SM Count Bug (RTX 3080)

NVIDIA 最初公布的 RTX 3080 规格是 68 SMs,但上市后的软件(CUDA 11.1)检测到 82 SMs,少算了 14 个。原因是早期 silicon stepping 中部分 SM 的 yield 影响了决策,但最终 stepping 修复后所有 SM 可用,而上市规格文档未及时更新。这对开发者意味着:不同批次的 RTX 3080 可能有不一致的核心数量,写 CUDA 程序时不应 hard-code SM 数量。

15.3 Hopper TMA Silicon Bug (H100 Early Stepping)

早期 H100 (步进 A0/A1) 的 TMA 在特定地址对齐条件下,cp.async.bulk 事务会返回错误数据——当 tile 跨过 DRAM 页边界(~2MB boundary)时,TMA 的地址转换缓存 (TLB) 状态机出现竞态条件。NVIDIA 通过在 driver 中插入 firmware patch(microcode 修正)来规避:在已知的触发模式前插入 barrier 指令。对用户透明但在早期 CUDA 12.0/12.1 下,如果使用 TMA 特性且检测到旧 stepping,驱动会退化为软件模拟 (SW-based TMA emulation)。修复后的步进为 A2/B1。


16. 易错清单 (Mistake Checklist)

1. Warp Divergence 不知不觉吃掉一半带宽

// ❌ 错误: 分支内包含昂贵的 HBM 访问
if (is_edge[threadIdx.x]) {
    val = global[threadIdx.x];     // 16 threads doing HBM load
} else {
    val = global2[threadIdx.x];    // 16 threads doing different HBM load
}
// 序列化后: 两个 Load 串行发出, 延迟叠加

// ✅ 正确: 先 load 再分支
float v1 = global[threadIdx.x];
float v2 = global2[threadIdx.x];
float val = is_edge[threadIdx.x] ? v1 : v2;
// 两个 load 可以并行 (LD/ST 单元支持多 in-flight 请求)

2. Shared Memory Bank Conflicts

// ❌ 按列访问: 32-way bank conflict
__shared__ float tile[32][32];
float val = tile[threadIdx.x][blockIdx.x % 32];

// ✅ 按行访问: stride-1, 无 conflict
float val = tile[blockIdx.x % 32][threadIdx.x];

// ✅ Padding 技巧: 消除 stride-N 访问的 conflict
__shared__ float tile[32][33];  // 加 1 列 padding

3. Occupancy vs Register Pressure 的 trade-off

// ❌ 寄存器过度使用 → occupancy 极低
// kernel 中声明了大量局部变量
// 编译器报告: "Registers per thread: 255 (max)"
// → SM 只能驻留 256 threads = 8 warps → occupancy: 12.5%

// ✅ 检查: ncu --set full 看 Occupancy section
// ✅ 优化: 用共享内存做寄存器溢出 (用 __shared__ 替代部分局部变量)
// ✅ 标记: __launch_bounds__(maxThreadsPerBlock, minBlocksPerSM)

4. __syncthreads() 缺失

// ❌ 危险: shared memory 写入后缺少 barrier
__shared__ float tile[32][32];
tile[threadIdx.y][threadIdx.x] = load_from_global(idx);
// 没有 __syncthreads() !
float result = tile[threadIdx.x][threadIdx.y];  // 可能读到旧值!

// ✅ 正确: 写入 shared memory 后必须 barrier
tile[threadIdx.y][threadIdx.x] = load_from_global(idx);
__syncthreads();  // block 内所有线程的写入对彼此可见
float result = tile[threadIdx.x][threadIdx.y];

5. FP16 累加到 FP16 — 精度陷阱

// ❌ FP16 accumulator: 尾数只有 10-bit
__half sum = 0.0f;
for (int i = 0; i < 1000000; i++) {
    sum = __hadd(sum, large_values[i]);  // 小增量会丢失
}

// ✅ FP32 accumulator: 尾数 23-bit, 适合归约
float sum = 0.0f;
for (int i = 0; i < 1000000; i++) {
    sum += __half2float(large_values[i]);
}
// Tensor Core MMA 指令也接受 FP32 accumulator

6. 忘记 TMA 的异步屏障

// ❌ 使用 TMA 后没有 wait → 读到未完成的数据
cp.async.bulk.tile.2d(...);
// 直接使用 shared memory → 数据可能还没到!
use_shared_memory();

// ✅ TMA 事务分组 + wait
cp.async.bulk.tile.2d(..., group_0);
cp.async.bulk.commit_group();
cp.async.bulk.wait_group(0);  // wait for group_0 to finish
__syncthreads();              // 确保 block 内可见
use_shared_memory();

7. PCIe 带宽瓶颈忽略

// ❌ 在 training loop 中频繁 cudaMemcpy
for step in range(10000):
    data = generate_on_cpu()       # CPU 生成
    cudaMemcpy(d_data, data, N)    # ~12 GB/s (PCIe ×16)
    launch_kernel(d_data)          # ~3.35 TB/s HBM → <1ms
    # 拷贝时间远大于计算时间 → GPU 空闲

// ✅ 保持数据在 GPU 上
// 或用 GPU Direct Storage (GDS) 直接从 NVMe 到 GPU
// 或用 CUDA Graphs 减少 kernel launch 开销

这一章带走的东西

  1. GPU 和 CPU 设计哲学相反。 CPU 为低延迟牺牲了晶体管效率(75% 用于控制),GPU 为高吞吐把 80% 晶体管投入计算单元。理解这个根本 trade-off 是掌握 GPU 编程的前提。

  2. SIMT 模型的 warp 是基本调度单位。 32 线程一组,共享 PC。warp divergence 序列化分支执行,是 GPU 性能的第一杀手。

  3. CUDA Core 是 FP32 的基本砖块。 每个周期 1 FMA (2 FLOPs)。H100 有 16896 个 CUDA Core,但峰值算力取决于 SM 内 warp scheduler 能否持续找到就绪的 warp。

  4. Tensor Core 是矩阵乘法的专用加速器。 每时钟 1 次 4×4×4 MMA (128 FLOPs),比 CUDA Core 快 6×。H100 FP8 下 1979 TFLOPS,而 FP32 仅 67 TFLOPS——精度选择直接决定训练速度。

  5. TMA 是 H100 的新武器。 硬件异步 2D copy 释放了 CUDA Core,在注意力计算中提升 matmul 效率 10-15%。

  6. 内存层次是性能的命脉。 从 Register (0 cycle) → Shared Memory (20-30 cycles) → L2 (200 cycles) → HBM (300-500 cycles),每次越级都是带宽悬崖。Shared memory 的 bank conflict 是常见性能 bug 的根源。

  7. Occupancy 不是越高越好。 更多的 registers/thread 换取更好的指令级效率,和更多的 active warps 换取更好的延迟隐藏,二者不可兼得。必须用 nsight compute profiling。

  8. 软件生态是真正的护城河。 H100 的硬件规格 vs MI300X 很接近(甚至 MI300X 在带宽和容量上领先),但 CUDA 的 toolchain、库(cuBLAS, cuDNN, CUTLASS)、框架集成和调试工具(Nsight)构成了 15 年的先发优势。

  9. 工程实践中的坑都是血泪教训。 bumpgate (封装热应力)、Ampere SM 数量错误(规格文档与 silicon 不一致)、TMA silicon bug(跨页 DMA 竞态)提醒我们:硬件不是教科书里的理想模型,真实世界有 stepping、errata、和热管理。

  10. FLOPs 不等于训练速度。 GPT-3 训练的 MFU 只有 38-50%,超过一半的"峰值"被 attention softmax、LayerNorm、多 GPU 通信间隙吃掉。算法-系统协同设计(FlashAttention, ring attention, kernel fusion)和硬件选型同样重要。


延伸阅读

  • NVIDIA H100 White Paper — Hopper 架构官方技术文档
  • CUDA Programming Guide — 第 5 章详述 SM 执行模型
  • CUTLASS — NVIDIA 开源的 CUDA C++ 模板库,包含最优 GEMM 实现
  • "Professional CUDA C Programming" (Cheng, Grossman, McKercher) — 第 3 章 GPU 架构详解
  • "Dissecting the Ampere GPU Architecture through Microbenchmarking" (Jia et al.) — GPU 微架构逆向

← 上一节:内存层次与缓存一致性   |   下一节 → AI 加速器:TPU / NPU / FPGA

AI 加速器:TPU / NPU / FPGA

TL;DR

  • GPU 做 AI 的致命伤不是算力不够,而是能效比:GPU 约 30% 功耗花在控制逻辑上(分支预测、调度、寄存器重命名),对矩阵乘法来说全是浪费。
  • TPU 的核心思想是脉动阵列(Systolic Array):数据像波浪一样流过 MAC 单元阵列,每个周期每个单元都在做乘加,零控制开销。
  • 当代加速器的军备竞赛本质是对抗存储墙(Memory Wall):模型 >1T 参数,99% 的时间在等权重从显存搬过来。Cerebras 的激进答案是整张晶圆做芯片(WSE-3),44GB 片上 SRAM,21 PB/s 带宽,模型不离开芯片。
  • Groq LPU 走了另一条路:完全确定性计算,编译器在编译期就排好了每个数据在每个周期的位置——没有缓存、没有分支预测、没有乱序执行。
  • ASIC(TPU)有最高能效和峰值算力,FPGA 有最低延迟和可重配置性,CPU(AMX)有最强的通用性。三者不是替代关系,是异构计算的拼图。
  • 最大的护城河不是硬件,是软件生态:CUDA 做了 15 年,这也是为什么 Intel Gaudi 硬件不差但市场份额微乎其微。

1. 为什么不能只用 GPU?

要回答"Why not just GPUs?",先看一组数据(H100 SXM,FP8):

指标数值
峰值算力1979 TFLOPS
内存带宽3.35 TB/s
TDP700W
晶体管80B

75 行 Python 启动一次 GEMM,调用栈是:Python → PyTorch → cuBLAS → CUDA Driver → 指令调度 → Tensor Core。GPU 为了通用性付出了巨大的控制开销:warp scheduler、dispatch unit、L0 指令缓存、scoreboard、分支发散处理。这些东西对矩阵乘法没有用,但它们占着面积,烧着功耗。

粗略估算:一张 H100 上约 30% 的面积和功耗花在非计算逻辑上。对于训练 GPT-4 级模型(几万张 GPU 跑几个月),这 30% 意味着上千万美元的电费和额外的散热基建。

graph LR
    subgraph GPU["GPU(通用并行计算)"]
        CU["CUDA Cores / Tensor Cores"]
        CTL["控制逻辑(~30% 功耗)"]
        CACHE["L1 / L2 Cache"]
        SCHED["Warp Scheduler / Dispatch"]
    end

    subgraph ACCEL["AI 加速器(专用)"]
        MATMUL["大规模矩阵乘法阵列"]
        SMALL_CTL["极简控制(~5% 功耗)"]
        SRAM["大容量片上 SRAM / HBM"]
    end

    GPU -->|"能效比差"| WASTE["大量功耗在非计算路径"]
    ACCEL -->|"能效比优"| EFF["~95% 功耗用于 MAC 计算"]

结论:用 GPU 训练大模型不是"最优解",是"先用着最方便的解"。 CUDA 生态让 NVIDIA 成为唯一可行的选择,但这不等于 GPU 在硬件上是为 AI 而生的。


2. 脉动阵列:TPUv1 的心脏

2.1 什么是脉动阵列(Systolic Array)

想象一个 256×256 的网格,每个格子是一个 MAC(Multiplier-Accumulator):

Iteration 0:    Iteration 1:    Iteration 2:
┌─┬─┬─┐        ┌─┬─┬─┐        ┌─┬─┬─┐
│w│ │ │        │ │w│ │        │ │ │w│
├─┼─┼─┤        ├─┼─┼─┤        ├─┼─┼─┤
│a│ │ │   →    │ │a│ │   →    │ │ │a│
├─┼─┼─┤        ├─┼─┼─┤        ├─┼─┼─┤
│ │7│ │        │ │ │8│        │ │ │ │95
└─┴─┴─┘        └─┴─┴─┘        └─┴─┴─┘

左:输入激活  右:部分和    上:权重
每个格子 = MAC(a_in, w_in, sum_in) → (a_out, sum_out)

数据流动规则:

  • 权重从上往下流动(提前预加载)
  • 输入激活从左往右流动
  • 部分和从上往下累积

256 个周期后,整个矩阵乘法结果从底部流出。整个过程没有寄存器写回冲突、没有缓存未命中、没有任何控制流指令

2.2 TPUv1 规格(Google,2015 年发表)

参数
脉动阵列尺寸256 × 256 MAC
总 MAC 单元数65,536
单周期操作数65536 multiply-adds
峰值算力92 TFLOPS(INT8)
频率700 MHz
片上缓冲28 MB SRAM(权重) + 24 MB SRAM(激活)
片外内存8 GB DDR3-2133
内存带宽34 GB/s
TDP~75W
工艺28nm
编程接口TensorFlow(直接下推 XLA 编译的图)

关键限制:v1 只能推理,不能训练(只有 INT8 计算,没有反向传播所需的高精度梯度累积)。

graph TD
    HOST["Host CPU"] -->|"XLA 编译后的子图"| PCIe
    PCIe["PCIe Gen3 ×16"] --> DDR3["8 GB DDR3<br/>权重存储"]
    DDR3 --> WB["Weight FIFO<br/>28 MB SRAM"]
    MMU["激活输入<br/>24 MB SRAM"] --> SYST["256×256 Systolic Array<br/>65536 MAC @ 700MHz"]
    WB --> SYST
    SYST --> ACC["Accumulators<br/>4 MB, 32-bit"]
    ACC --> ACT["Activation Unit<br/>ReLU/ReLU6/Tanh/Sigmoid"]
    ACT --> MMU["写回 Unified Buffer"]

TPUv1 仅需一个 PCIe 插槽,功耗 75W,在当时(2015)性能远超同功耗的 GPU 推理。


3. TPUv4 / v5 的演进

3.1 TPUv4(2021)

参数
峰值算力275 TFLOPS(BF16)
片上 HBM32 GB HBM2e
内存带宽1.2 TB/s
单芯片 TDP~200W
Pod 规模4096 芯片
Pod 算力1.1 EFLOPS(BF16)
互联拓扑光电路交换(OCS)——动态可重构

TPUv4 的创新在于光电路交换(Optical Circuit Switching)。传统 GPU 集群的拓扑是固定的(如 NVIDIA DGX 的 NVSwitch + NVLink 全互联),而 TPUv4 可以通过 MEMS 镜片动态改变芯片间的连接拓扑,使得不同训练阶段(数据并行 vs 模型并行 vs 流水线并行)可以使用最优的互联拓扑。

graph LR
    subgraph Pod["TPUv4 Pod — 4096 芯片"]
        R1["Rack 1<br/>64 chips"] --- OCS1["Optical Circuit<br/>Switch 1"]
        R2["Rack 2<br/>64 chips"] --- OCS2["Optical Circuit<br/>Switch 2"]
        OCS1 --- OCS2
        OCS1 --- OCS3["OCS 3"]
        OCS2 --- OCS3
    end
    Pod --- TOR["Spine / ToR<br/>可动态配置拓扑"]

3.2 TPUv5p(2023)

参数
峰值算力459 TFLOPS(BF16)/ 918 TFLOPS(INT8)
片上 HBM95 GB HBM2e
关键新特性SparseCore——硬件加速的嵌入查找
每 Pod 芯片8960
Pod 算力4.1 EFLOPS(BF16)

Google 用 TPUv5p 训练 Gemini 系列模型。值得注意:Bard / Gemini 的推理也大量运行在 TPUv5e 上("e" 表示推理优化版)。


4. SparseCore:嵌入查找的硬件加速

4.1 问题

推荐系统(YouTube、TikTok)和 MoE(Mixture of Experts)模型的核心操作是大稀疏矩阵乘法——嵌入表查找。举个具体例子:

YouTube 推荐模型:10 million items × 1024 dim embedding = 10 GB 嵌入表
每次 query:只访问 ~100 个 item(即稀疏访问 100 行)
GPU 操作:gather → matmul → scatter  (内存不连续 → 带宽利用率极低)

GPU 的 gather 指令在这里效率极差。HBM 的峰值带宽只有在连续访问、128B 对齐时才打得满,而嵌入查找天生是随机的、步长不等的。

4.2 SparseCore 方案

TPUv4 之后,Google 在芯片上放了专用的 SparseCore:一个小型的、高带宽的嵌入查找加速器。SparseCore 有自己的 SRAM 缓存和硬件 scatter/gather 引擎,能同时做:

  1. 嵌入查找:根据索引从表中取值
  2. 规约:将查到的向量进行 reduce(sum / mean / concat)
  3. 与稠密部分汇合:将稀疏结果传给主 Systolic Array

效果:推荐模型训练/推理的嵌入查找部分加速了 5-7 倍


5. 数据流架构:Cerebras WSE-3

WSE(Wafer-Scale Engine)的哲学跟所有其他芯片相反:既然数据传输消耗 99% 的能量,那就让数据别动了。

5.1 规格

参数
晶体管4 万亿
AI 核心数900,000
片上 SRAM44 GB
片上带宽21 PB/s
芯片面积46,225 mm²(整张 300mm 晶圆)
功耗23 kW(整片)
工艺5nm(TSMC)
内存无片外 DRAM——所有数据在片上

对比 H100:片外 HBM3 带宽 3.35 TB/s vs WSE-3 片上 21 PB/s。差距是 6000 倍。但这 44GB SRAM 也意味着模型必须完整装进去——GPT-4 的 1.7T 参数完全不可能。Cerebras 的目标场景是:科学计算、分子动力学、流体力学的稀疏/规则网格计算,以及中等规模的 LLM(如 LLaMA 2 7B)的极致训练速度。

graph TD
    subgraph TRAD["传统架构(GPU / TPU)"]
        CORE1["计算核心"] ---|"~3 TB/s"| HBM["HBM 显存"]
        HBM ---|"~1 TB/s"| CORE2["另一核心"]
    end

    subgraph WSE["Cerebras WSE-3(数据流)"]
        TILE1["Tile 1<br/>MAC + SRAM"] ---|"片上互联<br/>21 PB/s"| TILE2["Tile 2<br/>MAC + SRAM"]
        TILE2 --- TILE3["Tile 3<br/>MAC + SRAM"]
        TILE1 --- TILE3
    end

    TRAD -.-|"数据搬运能耗 > 计算能耗"| BAD["~99% 能量花在数据搬运"]
    WSE -.-|"数据不动,算力动"| GOOD["能量几乎全用于计算"]

6. Groq LPU:确定性处理器

Groq 的创始人 Jonathan Ross 也是 TPU 的创始人之一。LPU(Language Processing Unit)的设计理念是极致确定性

6.1 核心设计

传统处理器(CPU/GPU):
    程序→取指→译码→调度→执行→缓存未命中→等待→写回
    每一步都有不确定性(缓存命中率、分支方向、执行端口竞争)

Groq LPU:
    编译器在编译期就排好了:
      - 每个数据在第几个周期到达哪个功能单元
      - 每个算术操作的结果去向哪个 SRAM 地址
      - 每次片间通信的精确时序
    硬件上:零缓存、零分支预测、零乱序执行、零 scoreboard

6.2 架构

Groq 的芯片由 Functional Slice 组成,排列成一个 2D 网格。每个 Slice 包含:

  • 一个 SIMD 向量计算单元(16 条通道 × 32 位)
  • 一块本地 SRAM
  • 一个路由单元(与四方邻居通信)

软件定义网络(Software-Defined Network):编译器把每个数据流映射成芯片上的一条路径,数据从 SRAM→SIMD→路由→邻居→路由→...→目标 SRAM,全部在编译期确定。

6.3 规格

参数
片上 SRAM230 MB
内存带宽80 TB/s
峰值 INT8188 TFLOPS
峰值 FP1694 TFLOPS
TDP~300W
工艺7nm

Groq 的目标场景是 LLM 推理(所以叫 Language Processing Unit)。Llama 2 70B 推理可以达到 ~300 tokens/s/用户。核心卖点:延迟可预测性和极端内存带宽(80 TB/s 远超 H100 的 3.35 TB/s)。


7. Apple Neural Engine(ANE)

从 A11 Bionic(2017,iPhone 8/X)开始,每代 A 系列和 M 系列芯片都包含一个独立的 ANE。

SoC年份ANE 规格TOPS (INT8)
A1120172 核0.6
A1220188 核5
A1320198 核6
A14202016 核11
A15202116 核15.8
A16202216 核17
A17 Pro202316 核35
M4202416 核38

架构特点:

  • ANE 是独立于 CPU/GPU 的硬件模块,有自己的 DMA 引擎和内存空间
  • 支持几十种 CoreML 算子(卷积、池化、归一化、激活函数)
  • 无公开低层 API——所有开发必须通过 CoreML 框架(Apple 统一编译到 ANE)
  • 功耗极低:A17 Pro 的 ANE 满载约 2W,同性能功耗 GPU 需要约 15W
  • 应用场景:Face ID、实时照片处理、键盘预测、Siri 本地处理

关键设计取舍: Apple 牺牲了通用可编程性来换取极致能效比。你不能在 ANE 上跑自定义 CUDA kernel,但 iPhone 上 99% 的 ML 任务已经被 CoreML 覆盖。


8. Qualcomm Hexagon NPU

Hexagon 不是新产品线——Qualcomm 做 Hexagon DSP 已经超过 15 年。但 Hexagon NPU(从 Snapdragon 8 Gen 1 开始)是全新架构。

Snapdragon 8 Gen 3 的 AI Engine 是一个融合结构:

                ┌──────────────────────────────┐
                │  Snapdragon 8 Gen 3 AI Engine  │
                ├──────────────────────────────┤
                │  Hexagon NPU(Transformer 加速)│
                │  + Adreno GPU(通用 ML)        │
                │  + Kryo CPU(标量 / 控制流)     │
                │  + Sensing Hub(超低功耗感知)    │
                └──────────────────────────────┘
参数
峰值 INT445 TOPS
峰值 INT822.5 TOPS
支持LLaMA 2 7B(INT4 量化,纯本地运行)
编程Qualcomm AI Engine Direct SDK / Qualcomm AI Hub

关键能力:在整个 SoC 上分配 ML 算子。Transformer 的 Attention 跑在 NPU 上,LayerNorm 跑在 CPU 上,量化/去量化跑在 GPU 上。高通的异构调度器自动决定每个算子的最优硬件。


9. Intel AMX:CPU 里的张量核心

Sapphire Rapids(第 4 代 Xeon 可扩展,2023)引入了 AMX(Advanced Matrix Extensions)。

9.1 架构

AMX 给 x86 增加了Tile Register——一组扁平化的、2048 位的二维寄存器文件(8 个 tile 寄存器 × 每 tile 16 行 × 64 字节/行 = 1 KB / tile = 共 8 KB)。

指令集:
  TDPBF16PS( tdest, tsrc1, tsrc2 )
    // Tile Dot Product BF16 × BF16 → FP32
    // 单条指令完成一个 16×16 的 BF16 矩阵乘法

  TILELOAD / TILESTORE
    // 从内存到 tile 寄存器的加载/存储

  TILERELEASE
    // 释放 AMX 状态(上下文切换前必须调用)

9.2 性能

操作每核/周期56 核 @ 2.0 GHz 总计
INT8 matmul2048 ops114.7 TFLOPS
BF16 matmul1024 ops57.3 TFLOPS

本质上,AMX 就是 "CPU 上的 Tensor Core"

9.3 为什么 CPU 也要做 AI

两件事驱动:

  1. 推理不必都在 GPU:很多推理任务是突发小批量(如 API 服务),GPU 启动延迟就几十微秒。CPU 上没有 kernel launch 开销,对微小 batch 反而更快。
  2. 数据中心 CPU 本来就插满了:如果 CPU 就能跑推理,不需要额外的 GPU 采购、供电、冷却。

Intel 的策略不是替代 GPU,而是让 Xeon 可处理"GPU 太浪费、普通 CPU 太慢"的中间地带工作负载。


10. FPGA 在 ML 推理中的角色

10.1 为什么用 FPGA?

FPGA 的独特优势不是峰值算力,而是:

维度GPU / TPUFPGA
延迟微秒级(kernel launch + 调度)纳秒级(无 OS 内核开销)
大批量效率高(利用所有算力)中(流水线深度有限)
小批量效率差(批次填不满 Tensor Core)优(pipeline 可以浅)
可重配置是(烧一个新比特流)
开发难度CUDA/PyTorchHLS / Verilog
单芯片 TOPS极高中低

10.2 代表性产品

Microsoft Project Brainwave(2018)

  • 目标:Azure 上实时 DNN 推理(< 1ms 延迟)
  • 硬件:Intel Stratix 10 FPGA + HBM2
  • 架构:软核 DNN 处理器,无批量处理(batch=1 也是满吞吐)
  • 结果:ResNet-50 推理 450K QPS @ < 1ms,2018 年业界最优
  • 关键创新:FPGA 上直接做浮点运算矩阵乘法,数据从 HBM → LUT/DSP → 结果,没有软件栈

Xilinx(AMD)Versal AI Engine

  • 架构:VLIW SIMD 处理器阵列(不是传统 LUT 逻辑)
  • 每个 AI Engine:32KB 本地数据 + 512-bit SIMD
  • 互联:AXI4-Stream,软件可重配数据流
  • 性能:VC1902 约 180 TFLOPS INT8(FPGA 中最高)
  • 编程:AI Engine 用 C++ kernel,编译器做时空映射

10.3 FPGA 的困局

FPGA 理论上优雅:为算法定制电路。现实中三个问题:

  1. 编程门槛极高:CUDA 程序员一抓一把,HLS/Verilog 工程师少得多
  2. 单芯片算力仍低于 GPU/TPU:ASIC 能用晶体管极致优化一种计算,FPGA 必须用通用 LUT 模拟一切
  3. 每芯片贵、大批量部署不如 ASIC 经济

FPGA 的最佳生态位:超低延迟推理(自驾驶感知、量化交易)和定制算子加速(SmartNIC 上的 ML 处理、5G 基站内的 AI)。


11. 存储墙:所有加速器的共同敌人

graph LR
    MODEL["模型规模:每 ~2 年 10×"] --> GAP["计算 vs 带宽增速落差"]
    BANDWIDTH["内存带宽:每 ~2 年 1.5×"] --> GAP

    GAP --> WALL["存储墙(Memory Wall)"]

    WALL --> SOL1["HBM:堆叠 DRAM<br/>带宽 ~3.35 TB/s(H100)"]
    WALL --> SOL2["片上 SRAM:WSE-3<br/>21 PB/s,但容量仅 44GB"]
    WALL --> SOL3["量化:FP8 / INT4<br/>每次少搬 2-4× 数据"]
    WALL --> SOL4["模型并行:切分到多芯片<br/>每芯片仅需自己的参数子集"]
    WALL --> SOL5["存内计算(PIM / NDP)<br/>在 DRAM 内部直接计算"]

具体估算:

GPT-4 推理(推测 ~1.7T 参数,FP16):
  模型大小 = 1.7 × 10^12 × 2 bytes = 3.4 TB
  H100 带宽 = 3.35 TB/s
  纯搬运时间 = 3.4 / 3.35 ≈ 1 秒 / token

  实际 GPU 集群用 8 张 H100:
  每张 8 / 3.35 ≈ 2.4 秒(需等待,不能并行读取所有参数)
  实际还要算 FLOPs,但瓶颈仍是带宽。

  Token 生成速度 ≈ 内存带宽 / 模型大小(首近似)

这就是为什么量化(INT4/FP8)是推理优化的第一优先级:直接把数据量砍 4 倍,延迟砍 4 倍。


12. 加速器对比总表

加速器峰值算力内存带宽功耗工艺定位
NVIDIA H1001979 TFLOPS (FP8)3.35 TB/s (HBM3)700W4nm / TSMC训练 + 推理(CUDA)
Google TPUv5p459 TFLOPS (BF16)~2.7 TB/s (HBM2e)~450W5nmGemini 训练 + 推理
Cerebras WSE-3~100 PFLOPS (BF16)21 PB/s (片上 SRAM)23 kW5nm / TSMC科学计算 + 中等 LLM
Groq LPU188 TFLOPS (INT8)80 TB/s (片上 SRAM)~300W7nmLLM 推理(确定性延迟)
Apple ANE (A17)35 TOPS (INT8)共享 LPDDR5~2W3nm / TSMC端侧推理(CoreML)
Intel AMX (8480+)57 TFLOPS (BF16)DDR5 共享350WIntel 7中等批量 CPU 推理
AMD Versal VP1902~180 TOPS (INT8)~1.6 TB/s~120W7nm实时推理 / 信号处理
Qualcomm Hexagon45 TOPS (INT4)共享 LPDDR5X~5W4nm / TSMC手机端 LLM

注意:峰值 TOPS 不等于实际吞吐。同样 TOPS 的不同硬件,实际利用率可能差 2-3 倍。Cerebras 和 Groq 靠片上 SRAM 能达到 70-80% 利用率,GPU 在大部分工作负载下仅 30-50%。


13. 工程案例

13.1 Project Brainwave(微软,2018)

  • 挑战:Azure 上的实时 AI 服务要求 <1ms 延迟。GPU 的 kernel launch + batch 累积有几十微秒的不可控延时。
  • 方案:用 Intel Stratix 10 FPGA,直接在硬件上实现 DNN 推理。无 CPU 调度、无 kernel 启动,数据流直通。
  • 成果:ResNet-50 推理延迟 <1ms(batch=1),单 FPGA 吞吐 450K QPS,Azure 大规模采用。
  • 启示:对超低延迟推理,FPGA 可能比 GPU 和 TPU 都好——不是算力大,是延迟可控。

13.2 Habana Gaudi(Intel,2019-2024)

Intel 收购 Habana 后推出的训练加速器:

  • 硬件不差:Gaudi2 在 ResNet-50/ BERT 训练上与 A100 打平甚至超出
  • 互联强:每个 Gaudi2 芯片集成 24 个 100GbE RoCE 端口,专为大规模 Scale-out 设计
  • 实际问题软件兼容性。PyTorch 的 torch.compile 和底层 CUDA kernel 是 15 年的积累,Gaudi 的 SynapseAI 软件栈需要逐算子适配。到 2024 年仍有很多模型无法无缝迁移。
  • 教训:AI 加速器的护城河 80% 在软件生态。

14. 易错清单

错误认知正确理解
"TOPS 越高越快"峰值 TOPS 仅代表所有 MAC 单元同时工作的理论上限。实际性能取决于内存带宽软件映射效率。H100 的 1979 TFLOPS 在大部分 Transformer 训练中利用率仅 40-50%。
"FPGA 总是比 GPU 功耗低"取决于场景。做同样的矩阵乘法吞吐,FPGA 需要更多 LUT/DSP 资源,整体功耗可能反而高于类似工艺的 ASIC(如 TPU)。FPGA 的功率优势体现在对特定小工作负载的极致优化。
"买个加速器就能加速模型"加速器的软件栈成熟度直接决定上线周期。CUDA 生态 15 年积累 vs 新硬件仅支持 PyTorch 基础算子。自研模型(自定义 attention、特殊激活函数)可能完全无法在非 NVIDIA 硬件上运行。
"片上 SRAM 大就是好"容量和速度是 trade-off。Cerebras WSE-3 的 44GB SRAM 虽然带宽极高,但装不下 70B+ 的大模型,必须走模型并行。Groq 230MB SRAM 对 LLM 70B 也需要数百张卡拼接。
"TPU 比 GPU 好,Google 不用 GPU"Google 内部同时使用 TPU(主力)和 GPU(匹配公有云客户需求)。2024 年 Google Cloud 提供 A3 实例(H100)和 TPUv5e 实例,两种都商用。
"AI 加速器会取代 GPU"不会。GPU 的通用性在处理非 ML 工作负载(图形渲染、科学仿真、视频编解码)时仍有不可替代性。正确的问题是"什么工作负载跑在什么硬件上最优",而不是找一个通用答案。

15. 这一章带走的东西

  1. 能效是 AI 加速器存在的理由,不是算力:GPU 够快但太费电。专用硬件把 30% 控制开销降到 5%,省下的就是真金白银。
  2. 脉动阵列是 AI 加速器的基础范式:TPUv1 就已验证,后来几乎所有 NPU(包括 Google TPU、AWS Trainium、华为昇腾)都采用类似的 Systolic Array 乘加结构。
  3. 存储墙是当前的头号瓶颈:模型规模增速(~10×/2年)远快于内存带宽增速(~1.5×/2年)。对抗存储墙的手段包括 HBM 堆叠、片上 SRAM、量化、模型并行和存内计算。
  4. 异构计算是终端侧的必然:手机上的 AI 不是只跑在 NPU 上——高通、苹果、联发科全都在做"NPU + GPU + CPU + DSP"异构调度。未来没有哪个芯片能单独解决所有 ML 问题。
  5. 软件生态 > 硬件指标:这是 AI 加速器行业 10 年来最重要的教训。Intel Gaudi、AMD Instinct、Cerebras CS-3 的硬件都不差,但没有一个能真正撼动 NVIDIA + CUDA 的组合。硬件可以 18 个月追上,软件生态需要 10 年。
  6. FPGA 和 ASIC 不是竞争,是分工:ASIC(TPU 等)做大批量、高吞吐的云端训练/推理;FPGA 做超低延迟、小批量、可重配置的边缘场景。

下一节 → 总线与互联:PCIe / CXL / NVLink / RDMA 如何把成千上万个加速器连起来组成一个训练集群?PCIe 带宽为什么不够用了?CXL 怎么把内存池化?RDMA 如何让 GPU 直接写对端 GPU 的 HBM?下一章讲互联总线。

总线与互联:PCIe / CXL / NVLink / RDMA

TL;DR

2024 年的数据中心里,CPU、GPU、内存、存储、网卡之间的互联,已经取代了处理器核数成为系统性能的第一瓶颈。PCIe 是系统内部的"高速公路"(x16 Gen5 = 64 GB/s),NVLink 是 GPU-to-GPU 的"专用铁路"(H100 900 GB/s/link),InfiniBand/RoCE 是跨机架的"洲际航班"(NDR 400 Gbps),而 CXL 正在把三者编织成一张可组合的、缓存一致的互联网络(cache-coherent fabric)。理解它们之间的层次、延迟和拓扑差异,是读懂任何一份 GPU 集群架构文档的前提。

                  ┌────────────────────────────────────┐
                  │            CPU Socket              │
                  │  ┌─────┐  ┌─────┐  UPI/IF ──────┐ │
                  │  │Core │  │Core │  (CPU-CPU)    │ │
                  │  └──┬──┘  └──┬──┘               │ │
                  │     │ PCIe Root Complex          │ │
                  │     └──────┬─────────────────────┘ │
                  └────────────┼───────────────────────┘
                               │
              ┌────────────────┼────────────────┐
              │                │                 │
         ┌────▼────┐     ┌────▼────┐      ┌────▼────┐
         │  GPU 0  │     │ NVMe    │      │ NIC     │
         │  PCIe   │     │ SSD     │      │ (IB/RoCE)
         │  x16    │     │ x4      │      │ x16     │
         └────┬────┘     └─────────┘      └────┬────┘
              │                                │
    NVLink (GPU-GPU)                    InfiniBand/RoCE
    1-7 links each                       (Cluster network)

四个核心数字: PCIe 5.0 x16 → 64 GB/s,NVLink 4 (H100) → 900 GB/s bidirectional,InfiniBand NDR → 400 Gbps per port,CXL.mem → ~300ns 延迟比 NUMA 快一倍。


一、PCIe:系统内部的"高速公路"

从并行总线到串行点对点

PCIe(Peripheral Component Interconnect Express)的名字里带着"buse"这个词是历史遗产——老式 PCI 确实是共享总线(shared bus),所有设备挂在同一根总线上,任何一个设备占用总线时其他设备就得等着。PCIe 彻底抛弃了这个架构,改用串行点对点(serial point-to-point):每一条链路(link)只能连接两个设备,带宽通过增加并行通道(lane)来扩展。

Shared bus (PCI)              vs.       Point-to-point (PCIe)
                                        
 CPU ──┬──┬──┬──┬──┬── BUS            CPU ── Switch ──┬── EP A
       │  │  │  │  │                                  ├── EP B
       A  B  C  D  E                                  └── EP C
       
 一次只能一个设备传输              每个设备有独立的带宽
 bandwidth shared               bandwidth dedicated

Lane 与带宽:x1, x4, x8, x16

PCIe 的带宽单位是 lane(通道)。每个 lane 包含 4 根物理线:一对差分信号发送(TX+ / TX-),一对差分信号接收(RX+ / RX-)。一条 lane 可以同时收发——全双工。

代数速率 (GT/s)编码有效带宽/lane/dirx16 总带宽/dir
Gen 1 (2003)2.58b/10b0.25 GB/s4 GB/s
Gen 2 (2007)5.08b/10b0.50 GB/s8 GB/s
Gen 3 (2010)8.0128b/130b0.985 GB/s ≈ 1 GB/s~16 GB/s
Gen 4 (2017)16.0128b/130b1.969 GB/s ≈ 2 GB/s~32 GB/s
Gen 5 (2019)32.0128b/130b3.938 GB/s ≈ 4 GB/s~64 GB/s
Gen 6 (2022)64.0PAM4 + FEC7.87 GB/s ≈ 8 GB/s~128 GB/s
Gen 7 (spec 2025)128.0PAM4 + FEC~15.75 GB/s~256 GB/s

GT/s vs GB/s:GT/s(GigaTransfers per second)是物理层符号速率。从 Gen3 开始使用 128b/130b 编码(每 128 位数据只产生 2 位开销 → 开销 ~1.5%),而 Gen1/2 用的 8b/10b 编码(每 8 位数据产生 2 位开销 → 开销 20%)。所以 Gen3 "标称" 8 GT/s 的有效带宽是 8 × (128/130) / 8 = 0.985 GB/s/lane。

Gen6 的关键变化:PAM4 调制。 Gen5 及以前使用 NRZ(Non-Return-to-Zero),每个符号周期传输 1 bit(0 或 1)。Gen6 升级到 PAM4(Pulse Amplitude Modulation 4-level),每个符号周期传输 2 bit(00, 01, 10, 11)——相当于在同样的 32 GHz 物理信道上翻倍速率。代价是信噪比下降,需要前向纠错(FEC, Forward Error Correction)来保证误码率。

NRZ (Gen5):                     PAM4 (Gen6):
  1 bit/symbol                    2 bit/symbol

V ──┐     ┌──  "1"            V ──┐     ┌──  "11" (3)
    │     │                        │     │
    └─────┘  "0"                   ├──┐  ├──  "10" (2)
                                   │  │  │
                                   │  └──┤  "01" (1)
                                   │     │
                                   └─────┘  "00" (0)

常见的链路宽度

设备典型 lane原因
NVMe SSDx4消费级 7 GB/s 够用;企业级也有 x8
GPU (消费级)x16需要 64 GB/s(Gen5)加载纹理
GPU (企业级 A100/H100)x16Gen5 ×16 = 64 GB/s 成瓶颈,NVLink 补上
网卡 (100GbE/200GbE)x16 (Gen4)200 GbE ≈ 25 GB/s,Gen4 x8 = 16 GB/s 不够
FPGA 加速卡x8 或 x16视加速任务而定

PCIe 协议栈:三层模型

PCIe 协议像网络协议栈一样分为三层:

┌──────────────────────────────────────┐
│       Transaction Layer (TL)         │  ← TLP 包,读/写请求
│  - TLP (Transaction Layer Packet)    │     Address, Data, Completion
│  - 内存读/写、IO 读/写、配置读/写   │
│  - Message (中断、电源管理等)        │
├──────────────────────────────────────┤
│       Data Link Layer (DLL)          │  ← DLLP 包,可靠传输
│  - ACK / NAK 机制                    │     Sequence Number + CRC
│  - 流控信用 (Flow Control Credits)   │
│  - DLLP (Data Link Layer Packet)     │
├──────────────────────────────────────┤
│       Physical Layer (PHY)           │  ← 电气 + LTSSM 状态机
│  - 8b/10b 或 128b/130b 编码         │
│  - LTSSM (Link Training & Status     │
│    State Machine)                     │
│  - 链路训练、均衡 (Equalization)     │
└──────────────────────────────────────┘

Transaction Layer (TL) 是核心:它产生 TLP(Transaction Layer Packet)。一个 TLP 包含:

┌───────┬────┬─────────┬──────┬──────┬────────────┬──────┐
│Header │ Seq#│ Address │Attrib│Length│   Data     │ ECRC │
│12-16B │    │ 32/64b  │      │ 10b  │ ≤ 4096B    │ 32b  │
└───────┴────┴─────────┴──────┴──────┴────────────┴──────┘

TLP 穿过 Data Link Layer 时会加上 Sequence Number(用于 ACK/NAK 重传)和 LCRC(Link CRC),到了 Physical Layer 还会加上 Framing 和物理层开销。

Data Link Layer 的 ACK/NAK 机制:每个设备为发送的每条 TLP 分配一个 Sequence Number。接收方设备检查 LCRC——如果正确,回复 ACK DLLP(携带 Sequence Number);如果 CRC 错误,回复 NAK DLLP,发送方重传。这保证了所有 TLP 要么成功投递,要么链路中断(此时 LTSSM 会触发链路恢复)。

Flow Control(流控):发送方不会超过接收方 buffer 容量。接收方定期发布 FC(Flow Control)信用更新 DLLP,告诉对方"我还能再接收 X 个 Posted/Non-Posted/Completion 数据单元"。信用耗尽 → 发送方暂停。

LTSSM(Link Training & Status State Machine) 管理物理链路的生命周期:

                  +-- Link Down (reset) <-+
                  |                       |
  Detect ──→ Polling ──→ Configuration ──→ L0 (active data transfer)
     ↑                                ↓    ↓
     └── Recovery ←── L1/L2 (low power sleep)
  • Detect: 检测对端设备是否插入
  • Polling: 交换 Bit Lock、Symbol Lock,训练 PHY
  • Configuration: 协商链路宽度(x1/x4/x8/x16)和速率(Gen1→Gen5→Gen6)
  • L0: 正常工作,数据可以自由流动
  • L1/L2: 功耗管理状态(增加恢复延迟以换取节能)

DMA 与 BAR:设备如何读写内存

设备不是"看到"了处理器地址空间——它通过 BAR(Base Address Register) 声明自己的地址窗口。

CPU 侧:Root Complex 管理全局地址映射
   CPU 物理地址 0x1000_0000 → 可以路由到 PCIe 设备 X 的 BAR 2

设备侧:设备发起 DMA 读/写
   GPU (PCIe EP) 发送 TLP:Memory Write, Address=0x2000_0000, Data=[...]
   → Root Complex 查地址映射表 (IOMMU 页表) → 翻译到 DRAM 物理地址
   → 写 DRAM

BAR 机制详解

BIOS/UEFI 枚举过程:
1. 扫描 Root Port → Switch → 每个 Endpoint
2. 对于每个 Endpoint,读取 BAR 配置寄存器
3. 设备说自己需要 X MB 地址空间(e.g., GPU 256MB BAR)
4. BIOS 在全局地址空间中分配一段连续范围
5. 写入 BAR 寄存器,设备知道自己的地址空间
6. CPU 通过 load/store BAR 范围内的地址 ↔ Root Complex 转为 TLP

现代的 GPU 通常有 3-6 个 BAR:BAR0(256MB,内存映射寄存器)、BAR1(256MB,内存映射寄存器)、BAR2/BAR3(16GB+,芯片上显存映射,支持 Resizable BAR / AMD SAM)。


二、PCIe 拓扑:树形结构

Root Complex、Switch、Endpoint

PCIe 拓扑是一棵以 Root Complex 为根节点的树:

                    ┌──────────────────────┐
                    │    CPU Die / SoC     │
                    │  ┌────────────────┐  │
                    │  │  Root Complex  │  │
                    │  │  (多个Root Port) │  │
                    │  └──┬──┬──┬──┬───┘  │
                    └─────┼──┼──┼──┼──────┘
                    ┌─────┘  │  │  └─────┐
                    │   ┌────┘  └────┐    │
               ┌────▼─┐ │     ┌────▼──┐ │
               │PCIe  │ │     │ NVMe  │ │
               │Switch│ │     │ SSD   │ │
               └──┬─┬─┘ │     └───────┘ │
              ┌───┘ └──┐│               │
         ┌────▼─┐ ┌───▼▼┐         ┌────▼──┐
         │ GPU0 │ │ GPU1│         │  NIC  │
         └──────┘ └─────┘         └───────┘

Root Complex(RC):连接 CPU 和 PCIe 世界的桥梁。在 x86 系统里,RC 集成在 CPU die 上。每个 Root Port 可以独立配置——带宽分配、错误处理、电源管理都是 per-port 的。

Switch:让根端口下挂更多的 Endpoint。Switch 有 1 个 Upstream Port(连向 RC)和多个 Downstream Port(连向各 EP)。它不是路由器——没有地址学习、没有动态路由——TLP 的路由是由 switch 根据 TLP header 里的总线号/设备号/功能号(BDF)来做的。

Endpoint:叶子节点——GPU、NVMe SSD、网卡、FPGA。Endpoint 通过 Function(每个设备最多 8 个 Function)来分割功能。例如一张 Mellanox CX-7 网卡,可能 Function 0 = 网卡控制器、Function 1 = RDMA 引擎、Function 2 = NVMe-oF 控制器。

现代服务器的典型 PCIe 布局

一台配备 2 颗 Intel Xeon Sapphire Rapids(每颗 80 条 PCIe 5.0 lane)的服务器:

Socket 0 (CPU 0) - 80 PCIe 5.0 lanes
├── Root Port 0: x16 → GPU 0 (H100)
├── Root Port 1: x16 → GPU 1 (H100)
├── Root Port 2: x16 → GPU 2 (H100)
├── Root Port 3: x16 → GPU 3 (H100)
├── Root Port 4: x8 → NIC 0 (ConnectX-7)
├── Root Port 5: x4 → NVMe SSD 0
├── Root Port 6: x4 → NVMe SSD 1
└── Root Port 7: UPI → Socket 1

Socket 1 (CPU 1) - 80 PCIe 5.0 lanes
├── Root Port 0: x16 → GPU 4 (H100)
├── Root Port 1: x16 → GPU 5 (H100)
├── Root Port 2: x16 → GPU 6 (H100)
├── Root Port 3: x16 → GPU 7 (H100)
├── Root Port 4: x8 → NIC 1 (ConnectX-7)
├── Root Port 5: x4 → NVMe SSD 2
├── Root Port 6: x4 → NVMe SSD 3
└── Root Port 7: UPI → Socket 0

关键观察:GPU 0-3 直接挂在 Socket 0 下面。GPU 0 要给 GPU 4 传数据 → 必须通过 RDMA(经过 NIC 0 → 交换机 → NIC 1 → GPU 4)或通过 NVLink(如果有 NVSwitch 的话),或者绕过——直接不支持通过 PCIe/UPI 跨 Socket 做 GPU 间 P2P 传输。这就是为什么 DGX 系统把 8 个 GPU 全挂在同一颗 CPU 下,并用 NVSwitch 取代 PCIe 走 GPU 间流量。

Resizable BAR (ReBAR) / AMD SAM

传统 PCIe BAR 只能映射最多 256MB 的显存给 CPU(因为 32-bit BAR 兼容性)。这意味着 CPU 每次只能"看到" GPU 显存的一个小窗口,必须通过地址重映射来翻窗口——翻一次窗口就是一次 PCIe TLP round-trip。

Resizable BAR 允许协商更大的 BAR 窗口(2GB / 4GB / 16GB),让 CPU 一次性映射 GPU 的全部或大部分显存。这对纹理加载和 DirectStorage 这类技术有巨大帮助——memcpy(vram, ssd_buffer, 16GB) 不再需要翻 64 次 256MB 窗口。


三、CXL:基于 PCIe 的缓存一致性互联

CXL 是什么、为什么

CXL(Compute Express Link)解决的问题很简单:PCIe 没有缓存一致性(cache coherency)。在一台标准的 PCIe 服务器上:

  • 网卡通过 DMA 写了数据到 DRAM → CPU cache line 可能还是旧的(stale)。你需要"手动"flush cache line 或使用不可缓存的(uncacheable)内存区域。
  • GPU 不能缓存(cache)CPU 页表——即使 GPU 和 CPU 共享物理内存,GPU 只能通过 MMIO/ATB 查询 CPU 的页表,延迟巨大。

CXL 在 PCIe 物理层之上加了三个新协议,把 PCIe 从"你传你的,我存我的"升级为"我们共享同一个缓存一致域"。

CXL 的三层协议

┌───────────────────────────────────────┐
│             CXL Transaction           │
├───────────┬───────────┬───────────────┤
│  CXL.io   │ CXL.cache │   CXL.mem     │
├───────────┴───────────┴───────────────┤
│         CXL Link Layer                │
├───────────────────────────────────────┤
│    PCIe 5.0 / 6.0 Physical Layer      │
└───────────────────────────────────────┘

CXL.io:功能上和 PCIe 几乎相同——用于设备发现、配置空间、DMA、中断。任何 CXL 设备至少支持 CXL.io。可以理解为"标准 PCIe 的部分直接复用"。

CXL.cache:让设备可以持有主机内存的缓存副本(coherent cache)。协议使用标准 MESIF 或 MOESI 协议,设备发 Cache MemRd / Cache MemWr 请求,Host 负责响应和 snoop 已有的 cache line。典型场景:GPU 缓存 CPU 的页表和数据结构,访问延迟从 µs 级降到 ns 级。

没有 CXL.cache(标准 PCIe):
  GPU 需要读 CPU 页表
  → BAR MMIO 读 (uncacheable) → PCIe TLP → RC → DRAM → TLP 返回
  → 每次 ~500ns

有 CXL.cache:
  GPU 发 Cache MemRd 请求
  → RC 查 CPU L3/L2/L1 → hit in L3 → 直接回传 cache line
  → ~80-150ns(省去了 DRAM 往返)
  GPU 还把这个页表 line 缓存在自己的 cache 里,后续访问 ~ns 级

CXL.mem:反向——让主机 CPU 可以把设备上的内存当系统内存用。Host 发 MemWr/MemRd TLP 到设备,设备返回数据并维护自己的 cache 状态(如果设备上有 cache 的话)。典型场景:CXL Type-3 内存扩展卡——一个你插进 PCIe 插槽就能让服务器多出 512GB DDR5 内存的设备。

CPU 看到的物理地址空间:

[ 0 - 256GB ]      本地 DDR5 (DRAM, ~100ns)
[ 256 - 768GB ]    CXL Type-3 内存扩展卡 0 (~300ns)
[ 768 - 1280GB ]   CXL Type-3 内存扩展卡 1 (~320ns)

每个地址范围通过 Host Physical Address (HPA) decoder 路由到本地 DRAM 或 CXL 设备。操作系统通过 ACPI SRAT/HMAT 表了解到这些区域的性能和延迟差异,从而做 NUMA-aware 的内存分配。

CXL 设备类型

Type协议支持典型设备说明
Type 1CXL.io + CXL.cache智能网卡 (SmartNIC)、DPU设备可以缓存主机内存,加速 packet buffer 和数据面处理。不能附加自己的内存
Type 2CXL.io + CXL.cache + CXL.memGPU (如 Intel Ponte Vecchio)、FPGA双方互相共享内存。GPU 可以缓存 CPU 页,CPU 可以直接访问 GPU HBM——双方都在同一个 cache coherent 域里
Type 3CXL.io + CXL.mem内存扩展卡、持久内存纯内存设备。把 DRAM/持久内存挂在 CXL 链路上,主机操作系统看到的就是新的 NUMA node

CXL 内存池化与可组合基础设施

CXL 最具颠覆性的用法不是单个设备的连接,而是把多个主机的内存需求集中到 CXL 交换机后面的共享内存池

        Host 0    Host 1    Host 2       ...    Host 15
          │         │         │                     │
          └─────────┼─────────┼─────────────────────┘
                    │
            ┌───────▼───────┐
            │  CXL Switch   │   (multi-host switch)
            └───┬───┬───┬───┘
                │   │   │
        ┌───────┘   │   └───────┐
        ▼           ▼           ▼
   ┌─────────┐ ┌─────────┐ ┌─────────┐
   │ CXL Mem │ │ CXL Mem │ │ CXL Mem │
   │ 512GB   │ │ 512GB   │ │ 512GB   │
   └─────────┘ └─────────┘ └─────────┘

动态容量分配:Host 2 现在只需要 128GB,但 Host 5 需要 1TB——CXL 池化管理器可以热重配置:从 pool 里分配 768GB 给 Host 5,256GB 给 Host 2——不需要重启、不需要物理插拔。数据中心的内存利用率从传统的 50-60%(每台服务器有大量闲置但无法转移的 DRAM)跃升到 80-90%。

Multi-Logical Device (MLD):一个物理 CXL 内存设备可以切成多个逻辑分区,每个分区分配给不同的主机。一个 512GB 的 CXL Type-3 设备可以同时被 4 台主机"看到"为各 128GB 的独立内存。


四、NVLink:NVIDIA 的 GPU 互联帝国

先把问题摆清楚:一张 H100 GPU 的计算能力是 1979 TFLOPS (FP8),但 PCIe 5.0 x16 的带宽只有 64 GB/s。一个 LLM 训练梯度的 all-reduce 步骤,如果走 PCIe — 假设每 GPU 要互相传 16GB 梯度 — PCIe 就需要 16 GB / (64 GB/s / 2) ≈ 0.5 秒。NVLink 4 (900 GB/s) 则是 16 GB / (900 / 2) ≈ 35 毫秒。差 14 倍。

对 NVIDIA 来说这不是"优化",而是"训练可行与否的物理边界"——任何 8 卡训练 PCIe x16 全互联(full-mesh 需要 7 条双向链路 × 64 GB/s = 448 GB/s,但物理上做不到,因为 PCIe 是树形结构)都不可能达到需求。

产品速率/link每 GPU links总双向带宽关键创新
NVLink 1P100 (2016)20 GB/s4 links160 GB/s首次 GPU-GPU 直连
NVLink 2V100 (2017)25 GB/s6 links300 GB/s拓扑更密
NVLink 3A100 (2020)50 GB/s12 links600 GB/sNVSwitch 支持 all-to-all
NVLink 4H100 (2022)50 GB/s18 links900 GB/sNVSwitch 3 (64 ports)
NVLink 5B200 (2024)100 GB/s18 links1.8 TB/s更高速率,NVSwitch 4
NVLink-C2CGrace Hopper (2023)450 GB/s专用900 GB/sCPU-GPU coherent link

NVLink 不是"总线"。每条 NVLink 是两个 GPU 之间的专用点对点链路(dedicated point-to-point link)。H100 有 18 条 NVLink 4,意味着它可以同时连接多达 18 个不同的对端设备(实际在 DGX 中是连到 8 颗 H100 的互连,每对之间用多条 link)。

NVSwitch:突破 All-to-All 瓶颈

上面的 18 条 NVLink 全部连到 GPU 本身——但如果你想 8 个 GPU 全互联(任意两个 GPU 之间都有对应的 NVLink),需要多少条链路?答案是 7 条 per GPU × 8 = 56 条(双向)。物理上 H100 做不到直接全互联——PCB 布线只是理由之一,真正的瓶颈是芯片上只能放有限数量的 NVLink 单元。

NVSwitch 解决了这个问题。它像一个 crossbar 交换机:

                ┌────── NVSwitch 0 ──────┐
                │  Port 0  Port 1  ...  Port 7  │
                └───┬───────┬────────────┬─┘
                    │       │            │
   GPU0 NVLink─────┘       │            └───── GPU7 NVLink
   GPU1 NVLink─────────────┘             ...

H100 DGX 系统有 4 个 NVSwitch 3(每个 64 端口),8 个 GPU × 18 条 NVLink ÷ 4 个 NVSwitch = 每个交换机的每个端口连一个 GPU(实际上每个 GPU 通过多条 NVLink 连接到每个 NVSwitch,保证 full bandwidth)。

H100 DGX 全互联带宽:
  任何一个 GPU → 另一个 GPU = 7 条 NVLink 4 × 50 GB/s = 450 GB/s
  8 GPU all-to-all = 8 × 7 / 2 × 50 = 1400 GB/s = 7.2 TB/s 全局双向带宽

NVSwitch 的交换方式:NVSwitch 内部使用的是 cut-through switching(热土豆交换)——收到数据包头就开始转发,不需要缓存整个报文。这比 store-and-forward 的延迟低不少(<100ns switch latency)。

Grace Hopper:CPU-GPU 一致互联

2023 年 NVIDIA 发布的 Grace Hopper Superchip 将 ARM CPU(Grace,72 核 Neoverse V2)和 H100 GPU 通过 NVLink-C2C 直接连接。

┌──────────────┐       NVLink-C2C        ┌──────────────┐
│  ARM Grace   │  ←──450 GB/s ×2──→     │ H100 GPU     │
│  72-core     │     (双向 900 GB/s)      │ 80 GB HBM3   │
│ CPU          │                          │              │
│              │  ┌── Coherent ───────────│──────────┐   │
│   LPDDR5X    │  │                      │  HBM3    │   │
│   up to 480GB│  │ Shared address space │ 80GB     │   │
└──────────────┘  └──────────────────────└──────────┘   │
                  CPU 和 GPU 共享同一个物理地址空间

这是 CXL.cache + CXL.mem 的 NVIDIA 专属实现。CPU 和 GPU 使用 MOESI 缓存一致性协议共享地址空间——GPU 可以直接 cache CPU 的 LPDDR5X 页面(不再需要通过 PCIe BAR 做地址映射),CPU 也可以直接访问 GPU 的 HBM。对应用开发者来说,cudaMallocManaged() 变得几乎零成本——底层硬件自动维护一致性,没有 driver 层面的 page fault 和 migration 延迟。

这与 AMD 的 MI300A 形成了直接竞争:AMD 把 24 个 Zen4 核心和 CDNA3 GPU 放在同一个 Package 上,用 Infinity Fabric 走 in-package 互联——思路一样,但实现完全不同。NVIDIA 选择 Chip-to-Chip(C2C)物理层,AMD 选择 Infinity Fabric 扩展。


五、InfiniBand 与 RDMA:跨机架的 DMA

InfiniBand 基础

InfiniBand 是 Mellanox(现 NVIDIA Networking)设计的 HPC/AI 集群专用互联。它是基于信用流控的、无损的、远程 DMA 的链路层协议

每 lane 速率4x (HDR/NDR 常用)12x延迟 (switch + PHY)
SDR2.5 Gbps10 Gbps30 Gbps~5µs
DDR5 Gbps20 Gbps60 Gbps~3µs
QDR10 Gbps40 Gbps120 Gbps~2µs
FDR14 Gbps56 Gbps168 Gbps~1.5µs
EDR25 Gbps100 Gbps300 Gbps~1.2µs
HDR50 Gbps200 Gbps600 Gbps~1µs
NDR100 Gbps400 Gbps1.2 Tbps<1µs
NDR200100 Gbps (SHARP)400 Gbps<1µs
XDR200 Gbps800 Gbps<1µs

InfiniBand 使用 链路层流控(Link-Layer Flow Control),而非以太网的 PAUSE 帧或 PFC。发送方在发送每个包之前必须拥有足够的"信用"——这是接收方 buffer 空间的一种保证。这使得 InfiniBand 在满载时保持零丢包,而不需要像 TCP 那样重传。

RDMA 的工作原理

RDMA(Remote Direct Memory Access) 是 InfiniBand 最强大的能力:网卡可以直接读写远程机器的内存,不经过远程 CPU 参与

传统网络 (TCP):
  Sender App → buffer → kernel TCP stack → NIC → wire
  → remote NIC → kernel TCP → buffer → App (CPU wake + copy)

RDMA:
  Sender App → buffer (registered MR) → NIC reads buffer via DMA → wire
  → remote NIC → DMA writes directly into remote MR → completion
  → remote App poll CQ → 数据已经在那里了
  
  省掉了: 两次 kernel 穿越 + 两次 copy + remote CPU 中断

RDMA 的 Verbs API

// 1. 注册内存区域 (Memory Region) — 锁住物理页,不要被 swap
struct ibv_mr *mr = ibv_reg_mr(pd, buf, size,
    IBV_ACCESS_LOCAL_WRITE | IBV_ACCESS_REMOTE_READ |
    IBV_ACCESS_REMOTE_WRITE | IBV_ACCESS_REMOTE_ATOMIC);

// 2. 创建 Queue Pair (QP) — 发送/接收队列对
struct ibv_qp *qp = ibv_create_qp(pd, &qp_init_attr);

// 3. 发送 RDMA Write (本地 → 远程,不需要远程 CPU 参与)
struct ibv_sge sge = { .addr = (uintptr_t)local_buf,
                       .length = size, .lkey = mr->lkey };
struct ibv_send_wr wr = { .wr_id = 1, .opcode = IBV_WR_RDMA_WRITE,
                           .sg_list = &sge, .num_sge = 1,
                           .wr.rdma.remote_addr = remote_addr,
                           .wr.rdma.rkey = remote_rkey,
                           .send_flags = IBV_SEND_SIGNALED };

ibv_post_send(qp, &wr, &bad_wr);

// 4. 轮询 Completion Queue
struct ibv_wc wc;
int n = ibv_poll_cq(cq, 1, &wc);
if (n > 0 && wc.status == IBV_WC_SUCCESS) {
    // 发送成功,远程内存已经被写了
}

RDMA 的核心模式

操作CPU 参与 (发送方)CPU 参与 (接收方)用途
RDMA Write是 (post send)单向数据传输(gradient push)、存储写入
RDMA Read单向读取远程数据(gradient pull)、存储读取
RDMA Send/Recv (pre-post recv)双向消息、控制面(metadata exchange)
RDMA AtomicCompare & Swap、Fetch & Add——分布式锁、barrier

GPU Direct RDMA (GDR):把 RDMA 的 DMA 引擎直接对接 GPU 显存。

无 GDR:
  GPU 0 HBM → (PCIe read by CPU/memcpy) → system DRAM → NIC read (DMA)
  → IB → remote NIC → DMA to remote DRAM → memcpy → GPU 1 HBM

有 GDR:
  GPU 0 HBM → NIC read by DMA (直接通过 PCIe 读 GPU BAR)
  → IB → remote NIC → DMA directly to GPU 1 HBM (通过 PCIe 写 GPU BAR)

  全程 GPU HBM → NIC → IB → remote NIC → GPU HBM
  CPU 和系统 DRAM 完全 bypass

在 H100 训练集群中,不带 GDR 的 all-reduce 每个 step 要花 200ms,带 GDR 只要 45ms。这对万个 GPU 的千亿参数模型训练是生死线。

RoCE:以太网上的 RDMA

RoCEv2 (RDMA over Converged Ethernet version 2) 把 RDMA 传输层封装在 UDP/IP 报文中,使 RDMA 可以在标准以太网上运行。

RoCEv2 封装:
  ┌──────┬──────┬──────┬──────────┬────────┬──────┐
  │ Eth  │ IP   │ UDP  │ IB Trans.│ IB Payload │ CRC │
  │ Hdr  │ Hdr  │ Hdr  │ Port Hdr │ (RDMA data)│     │
  └──────┴──────┴──────┴──────────┴────────┴──────┘

RoCE 和 InfiniBand 的区别

维度InfiniBandRoCEv2
物理层IB 专用 (HDR/NDR)以太网 (100/200/400G)
交换设备IB 交换机 (~$12K per port)以太网交换机 ($3-8K per port)
延迟 (小型消息)~1µs~5-10µs
损失零丢包 (credit-based)需要配置 PFC/ECN (lossless Ethernet)
可路由子网管理器 (SM)标准 IP 路由
生态系统NVIDIA/Mellanox 锁定多厂商 (Broadcom, Cisco, Arista)

DCQCN (Data Center Quantized Congestion Notification) 是 RoCEv2 的拥塞控制核心。当交换机 buffer 开始拥塞,交换机会在数据包上标记 ECN 位。接收方收到 ECN 标记的包后,向发送方发送 CNP(Congestion Notification Packet)。发送方根据 CNP 的频率调整发送速率(AIMD 式加性增乘性减)。这使 RoCE 能在以太网上维持"接近无损"的语义。

但这是有代价的——DCQCN 的收敛时间 ~50-100µs,而 InfiniBand 的 credit-based 流控是即时生效的。所以 RoCE 在拥塞时延迟抖动比 InfiniBand 大得多。


六、AI 训练集群的互联拓扑

现代 LLM 训练集群的网络架构

以 Meta 的 24K GPU 集群(训练 Llama 3 405B)为例:

┌────────────────────────────────────────────┐
│                  Front-End Network          │
│         Ethernet (数据加载、checkpoint)      │
│         100 / 200 GbE, RoCEv2              │
└──────────────────┬─────────────────────────┘
                   │
    ┌──────────────┼──────────────┐
    ▼              ▼              ▼
┌───────┐     ┌───────┐     ┌───────┐
│Server │     │Server │     │Server │
│ GPU0..│ ... │ GPU0..│ ... │ GPU0..│
│ GPU7  │     │ GPU7  │     │ GPU7  │
└──┬──┬─┘     └──┬──┬─┘     └──┬──┬─┘
   │  │          │  │          │  │
   │  └────── Rail-Optimized Topology ──┘  │
   │  ┌───────────────────────────────────┘
   ▼  ▼  ▼  ▼  ▼  ▼  ▼  ▼
 ┌──────────────────────────────┐
 │       Back-End Network       │
 │  InfiniBand / RoCE (scale-out)│
 │  梯度 all-reduce, 参数同步    │
 │  三层 Fat-Tree 拓扑           │
 └──────────────────────────────┘

Front-end vs Back-end 网络分离

  • Front-end (前端):标准以太网。处理的流量是数据加载(从 storage cluster 读取训练数据)、checkpoint 保存(定期 dump 模型权重到持久存储)、日志/监控。
  • Back-end (后端):高带宽、低延迟的 InfiniBand 或 RoCE。专门负责 梯度同步(gradient synchronization)。每次训练 step,所有 GPU 各自计算完梯度后,必须通过 all-reduce 集合通信操作交换并聚合梯度——如果用前端网络做这件事,网络拥塞会直接阻塞所有 GPU 的训练进度。

Rail-Optimized 拓扑

Rail 拓扑 是 Meta 提出的一种降低 all-reduce 网络拥塞的设计。思想是:每台 8 GPU 服务器对 8 个独立交换机(每个交换机一个 "rail"),每个 GPU j 连接到 Rail Switch j:

Server 0:                 Rail 0       Rail 1  ...  Rail 7
  GPU0 NIC0 ──────────→  Switch 0
  GPU1 NIC1 ───────────────→          Switch 1
  ...                                           ...
  GPU7 NIC7 ───────────────────────────────→          Switch 7

Server 1:
  GPU0 NIC0 ──────────→  Switch 0
  GPU1 NIC1 ───────────────→          Switch 1
  ...

Server N-1:
  GPU0 NIC0 ──────────→  Switch 0
  ...

为什么这样做? 标准的 all-reduce 算法(如 ring all-reduce)中,每个 GPU 只与相邻 GPU 通信。如果让一个服务器的所有 GPU 都通过同一台交换机通信,交换机内部的 buffer 承受所有 8 个 GPU 的全带宽压力。而 rail 拓扑确保任一交换机的流量都是来自不同服务器的不同 GPU j——把流量均匀地分配到 8 个独立交换机上,消除了交换机级别的拥塞点。

GPU 间通信层次总结

在一台 DGX H100 上:

Level 0: SM 内共享 L1 / Shared Memory (NVLink 无法替代的带宽)
Level 1: HBM3 内 (GPU internal, 3.35 TB/s)
Level 2: NVLink 4 (NVSwitch, GPU-to-GPU, 900 GB/s/link)
Level 3: InfiniBand NDR (跨节点, 400 Gbps/port = 50 GB/s)

每一层带宽跳降约 10-20 倍。Tensor Parallelism 通常限制在 Level 0-2 内(同一个节点,NVLink 互联),Pipeline Parallelism 才跨节点(NDR)。这是分布式训练的最基本硬件约束。


七、CPU-CPU 互联:UPI 与 Infinity Fabric

多颗 CPU 之间也要互联,不用 PCIe:

Intel UPI(Ultra Path Interconnect)

速率每 link 带宽条数产品
QPI 1.16.4 GT/s12.8 GB/s1-2Nehalem ~ Broadwell
UPI 1.010.4 GT/s20.8 GB/s2-3Skylake-SP ~ Cooper Lake
UPI 2.010.4 GT/s20.8 GB/s3-4Sapphire Rapids
UPI 3.016 GT/s~32 GB/s3-4Granite Rapids (传闻)

CPU 间的 UPI 使用 home snoop 或 source snoop 模式维护跨 socket 的缓存一致性。当一个 socket 上的 core 访问另一个 socket 的 DRAM 时 → UPI 请求 → remote Home Agent → remote DRAM → UPI 回传。延迟 ~140-200ns(比本地 DRAM 100ns 高 ~50%)。

AMD Infinity Fabric

AMD 的 Infinity Fabric 是更通用的互联架构——它不只是 CPU-CPU 互联,也用于:

  • CPU-CPU 跨 socket 互联(2 路 / 4 路服务器)
  • Chiplet 互联:Zen 核心 CCD(Core Complex Die)和 IOD(I/O Die)之间
  • GPU-GPU 互联:MI250X 和 MI300X 加速器之间
  • CPU-GPU 互联:MI300A APU 内 Zen4 + CDNA3 的协同
Ryzen 9 7950X (单 Socket,无需 CPU-CPU InF):
  ┌──────CCD0──────┐  ┌──────CCD1──────┐
  │ 8× Zen4 Core   │  │ 8× Zen4 Core   │
  │ 32MB L3        │  │ 32MB L3        │
  └───Inf. Fabric──┘  └───Inf. Fabric──┘
              │                │
         ┌────▼────Inf. Fabric──▼────┐
         │          IOD             │
         │  PCIe 5.0 ×28 lanes     │
         │  DDR5 controllers ×2    │
         │  USB, SATA, etc.       │
         └──────────────────────────┘

Infinity Fabric 的带宽通常在 32-64 GB/s/方向(取决于代与链路宽度),延迟极低(in-package 模式 <10ns)。


八、UALink:NVIDIA 互联垄断的公开挑战

2024 年 5 月,AMD、Broadcom、Intel、Google、Microsoft 和 Cisco 联合宣布 UALink(Ultra Accelerator Link),目标是为不同厂商的 AI 加速器(GPU、FPGA、ASIC)建立一个开放的、高速互联标准。

参数
每 lane 速率200 Gbps
通道数可聚合多 lane
拓扑支持 all-to-all(类似 NVSwitch 的 crossbar)
一致性支持 cache coherent memory sharing(类似 CXL.cache + CXL.mem)
目标产品AMD MI450, Intel Jaguar Shores, Broadcom AI ASIC (2026+)

UALink 本质上是 CXL 的超集——在 CXL 的缓存一致性基础上加了 NVLink 级别的高带宽 GPU 间直连。如果成功,UALink 将允许 AMD MI450、Jaguar Shores 和自研 ASIC 在同一个服务器内共享缓存一致的内存并高带宽互联——打破 NVIDIA NVLink 和 NVSwitch 的生态锁定。

但有个残酷的事实:NVIDIA NVLink 已经迭代了 5 代(6 年),UALink 还在规范定义的初期。NVIDIA 每年把 NVLink 带宽翻 1.5-2 倍,而 UALink 的开放标准需要通过多厂商共识——这样的速度差,让 UALink 在短期内永远追不上 NVLink。


九、工程事故与教训

CXL 互通性问题 (2023)

Intel 和 AMD 的 CXL 实现在 2023 年初互不兼容。根本原因在于 CXL 规范虽然在物理层复用 PCIe 5.0,但在 timing budget、电压容差和 CXL.cache 的精确事务语义上有足够的 gray area 让实现差异变成不兼容:

  • Timing violation:Intel Sapphire Rapids CXL 控制器的 CXL.io 启动时序比 AMD Genoa 严格 4 个 cycle。一个 CXL Type-3 内存设备被两边的 BIOS 分别训练后,在 Intel 上工作正常,但在 AMD 上 LTSSM 链路训练在 Configuration 阶段超时。
  • Cache state transition:CXL.cache 的 DIRTY → INVALID 状态转换在 Intel 和 AMD 对 forward progress(前进保证)的解读不同。Intel 允许 Home Agent 延迟 invalidation ACK,而 AMD 在 256 cycle 内未收到 ACK 则报 Fatal Error。

这些问题的最终结果:数据中心不能混插 Intel 和 AMD 的服务器共享一个 CXL 内存池——每条 CXL 链路在部署前必须做厂家兼容性认证。

NVIDIA vs AMD 的互联锁

NVIDIA 生态:
  CUDNN, CUDA, NCCL, NVLink, NVSwitch, InfiniBand
  ↑  端到端的纵向整合,每个组件互相依赖

AMD 生态:
  ROCm, RCCL, PCIe 5.0, Infinity Fabric, 以太网 (RoCE)
  ↑  依赖开放标准,但生态碎片化

NCCL(NVIDIA Collective Communication Library)迄今只在 NVLink 和 InfiniBand 上经过仔细优化。AMD 的替代品 RCCL 支持 PCIe 和以太网,但 8 GPU 的 all-reduce 带宽只有 NCCL + NVLink 的 40-60%。这不是因为 AMD 代码写得烂——而是因为 PCIe 5.0 的树形拓扑本身不适合做 GPU 间全局通信。NVIDIA 的互联护城河不只是硬件,还有 NCCL 这个一层优化过的软件层。

RoCE 拥塞崩溃案例 (2022)

某 AI 实验室的 1024 GPU 训练集群在从 InfiniBand 迁移到 RoCEv2 后,all-reduce 延迟从 60ms 跳变为 300-600ms(方差极大)。根因分析:

  1. 所有 1024 GPU 在每个 step 同时开始 all-reduce(同步屏障效应)
  2. RoCE 网络没有配置 PFC headroom buffer(缓冲区太小)
  3. 所有 GPU 同时爆发 RDMA Write → 所有交换机 buffer 同时溢出
  4. DCQCN 开始降低所有 GPU 的发送速率 → 全局降速
  5. 降速后 all-reduce 完成时间变化 → GPU step 同步变慢 → burst 周期变长 → 恶性循环

修复方案:在所有交换机上启用 PFC deadlock detection + 增加 200MB headroom buffer,同时引入 GPU step 的随机微抖动 (jitter = ~5ms),打破同步 burst。


十、易错清单

  1. "PCIe x16 = 64 GB/s 可用带宽":Gen5 x16 标称 64 GB/s 是物理层速率。去掉 128b/130b 编码开销、TLP header overhead(12-16B 每 4KB payload ≈ 0.3%)、Data Link Layer ACK/FC 开销——实际有效 payload 带宽约为 50-55 GB/s。别在链路预算表里做满打满算。

  2. "我插了 4 个 GPU 都是 x16,每个都有 64 GB/s":如果这 4 个 GPU 全挂在同一个 PCIe Switch 后面(Switch 只有一个 x16 上行端口连 Root Complex),那么 4 × x16 下行端的并发流量受上行端口的 x16 = 64 GB/s 制约——这叫 oversubscription(超额订阅)。检查你的主板 PCIe 拓扑:不是所有的 x16 物理插槽都有各自 x16 电气连接。

  3. "NVLink 是 bus,我可以随便加 GPU":NVLink 是点对点链路,不是多 device 的总线。H100 有 18 条物理 NVLink 接口——这些接口的数量在硅片设计时就固定了。你无法把 10 个 GPU 用 NVLink 直接连接(因为 H100 只有 18 个 NVLink 单元,而且 DGX 的 NVSwitch 拓扑是针对 8 卡优化的)。NVSwitch 帮你在 8 卡内实现了 all-to-all,不等于你可以无限扩展。

  4. "RDMA 可以直接读任意远程内存":RDMA 只能访问 预先注册的 Memory Region (MR)。注册 MR 时,网卡驱动调用 pin_user_pages() 把物理页锁住(防止 swap 或 move),并把物理地址告知 NIC。每注册一个 MR,NIC 的 I/O MMU (IOMMU) 会创建对应的页表映射。没有注册的地址 → IOMMU 拒绝访问 → RDMA 失败。注册本身就花费若干 µs 和数 KB 的页表空间。

  5. "CXL = 快一点的 PCIe":CXL.cache 和 CXL.mem 是完全不同的传输语义。标准 PCIe 使用 Non-Cacheable MMIO/TLP 访问远端内存,而 CXL.cache 允许设备在 cache 中持有映射,使用 MESI 一致性协议。一台 CXL 设备的 BIOS 支持包含主机方 Home Agent 的配置和 snoop filter 的大小——这些东西如果没有在 BIOS 中正确设置,CXL 设备只会在 CXL.io (即 PCIe) 模式下工作,cache 和 mem 协议不激活。

  6. "RoCE 和 InfiniBand 是一回事":RoCEv2 在 UDP/IP 上跑 RDMA 语义,但这不代表它有无损保证。InfiniBand 在链路层有 credit-based 流控 → 物理上不存在丢包。RoCEv2 在 IP 层没有流控,需要用 DCQCN + PFC 来避免丢包——而 PFC 会引发 head-of-line blocking 甚至 deadlock。生产部署 RoCE 需要网络工程师精心调整 PFC 阈值、ECN 标记率、DCQCN 参数(rp_timer, g, alpha),否则一个大 burst 就能引发拥塞崩溃。

  7. "CXL 内存池化可以让任何服务器用别人的内存":CXL multi-host switch 需要 CXL 规范 3.0+ 的支持,并且 switch 本身必须实现 multi-logical device (MLD) 和多域(multi-domain)隔离。2024 年大部分可用的 CXL switch 仅支持单主机模式。Multi-host CXL 池化生产就绪预计在 2025-2026 年。


十一、这一章带走的东西

  1. 互联层次决定了性能天花板:PCIe(500ns, 64GB/s)→ NVLink(100ns, 900GB/s)→ InfiniBand(1µs, 400Gbps)——每一层 10× 带宽/延迟的跳跃。分布式训练的 TP(Tensor Parallelism)只能在第一二层(NVLink 以内),PP(Pipeline Parallelism)和 DP(Data Parallelism)才跨 InfiniBand。选错了 TP/PP 的分界点,训练会被互联带宽卡死。

  2. PCIe 不是一条 lane 的事,是一棵整树:Root Complex 的分支结构、Switch 的 oversubscription ratio、BAR 空间分配——这些决定了一个 PCIe 设备实际能拿到的可持续带宽。永远不要只看标签上的"x16"。

  3. CXL 是 PCIe 上长了"脑子":CXL.cache 和 CXL.mem 把 PCIe 从愚蠢的 DMA 管道升级成了缓存一致的共享内存总线。理解 CXL = 理解未来的服务器架构——可组合的、动态的、池化的。

  4. NVLink + NVSwitch = NVIDIA 的护城河:NVIDIA 花了 8 年、5 代硬件来打磨完全的 GPU 互联栈(NVLink + NVSwitch + InfiniBand + NCCL)。任何竞争者不仅要追硬件规格,还要追这个软硬一体化的全部。UALink 走了正确的路(开放标准),但追赶时间会更长。

  5. RDMA 节省的不是带宽,是 CPU:一次 all-reduce 如果没有 RDMA → 远程 CPU 被中断打断、copy 数据、再发 → 这些 CPU cycle 与 GPU 训练竞争 → 训练变慢。RDMA 直接把 DMA 引擎从本地 HBM 连到对端 HBM——CPU 一个 cycle 都不参与。

  6. RoCE 的拥塞控制比 InfiniBand 复杂一个数量级:InfiniBand 是 credit-based 流控(无丢包),RoCE 是 PFC + DCQCN(避免丢包)。前者的行为确定性高得多——如果你在做 HPC 或大规模训练,这笔确定性值得你付 IB 交换机的溢价。

  7. AI 集群的物理布局决定了 all-reduce 的性能:不是所有 GPU 之间的带宽都一样。GPU 0 和 GPU 1(同一节点,NVLink 直达)之间的通信比 GPU 0 和 GPU 500(跨节点,三层 IB switch)快 50-100 倍。Rail 拓扑、oversubscription ratio、PFC buffer 配置——这些硬件的物理现实直接塑造了训练代码中最底层通信原语的耗时。


下一节 → 指令集架构:x86 / ARM / RISC-V

存储硬件: NAND Flash / SSD FTL / 写入放大 / NVMe

TL;DR

数据库的 WAL、LSM-Tree 的 compaction、分布式系统的 fsync——所有"持久化"的语义最终都落在 SSD 内部的 NAND flash 上。SSD 不是"快一点的磁盘":它不能覆盖写(只能先擦除后写)、有寿命上限(P/E 次数)、内部还有一层把你完全屏蔽的 FTL(闪存转换层)。这一章把 NAND 物理特性(SLC/MLC/TLC/QLC)、SSD 内部机制(磨损均衡、垃圾回收、写放大、掉电保护)、NVMe 协议(队列对、多命名空间)讲透,并回答数据库工程师最关心的:为什么 LSM 写放大要对着 SSD 写放大一起算

读完应能:

  1. 说出 NAND 的"读页 / 写页 / 擦除块"三粒度,以及 SLC→QLC 的寿命与速度差异。
  2. 解释 FTL 的 L2P 映射、磨损均衡、垃圾回收如何共同决定"写放大系数",并估算 1 次逻辑写实际消耗多少物理写。
  3. 说清 TRIM、OP(预留空间)、掉电保护各自解决什么问题。
  4. 讲出 NVMe 与 AHCI/SCSI 的队列模型差异(多队列、polling、无锁提交/完成)。
  5. 把 SSD 特性翻译成数据库设计决策:WAL 为什么写少量页、LSM 为什么反而被 SSD 喜欢、QLC 时代怎么选盘。

一、HDD vs SSD: 两种完全不同的物理

HDDSSD
随机读寻道 ~10ms~50-100µs(受队列深度影响)
随机写寻道 ~10ms与顺序写接近(FTL 内做批量)
写单元扇区 512B/4K页 4-16KB(最小写粒度)
擦除单元块 4-16MB(最小擦除粒度)
覆盖写直接覆写必须先擦除(无覆盖写)
寿命近乎无限P/E 次数有限

一句话:SSD 把"随机 IO"的物理代价抹平了,但把"寿命 + 空间放大"的账转嫁给了固件和上层软件。

二、NAND flash 物理

NAND 用浮栅/电荷陷阱晶体管存电荷表示 1 位或多位。每单元存几位决定密度、速度、寿命的三角关系:

类型每单元位数电压档数相对寿命相对速度用途
SLC12~10x企业缓存/系统盘
MLC24~4x高端消费/企业
TLC381x中慢主流消费/云
QLC416~0.3x大容量冷数据

干扰与衰减:

  • 写干扰:向一个页编程会扰动同块其他页的电荷;
  • 读干扰:反复读一个页会累积扰动(读多了要先搬移数据再读);
  • 数据保持:电荷随时间泄漏,TLC/QLC 在高温下保持期显著缩短——冷数据盘要定期刷新。

三、SSD 内部: FTL 是那个"看不见的数据库"

3.1 逻辑到物理映射(L2P)

主机看到的是连续逻辑块(LBA),实际写到哪里由 FTL 决定:

主机:   LBA 1000 写入 4KB
FTL:    找空闲页 (可能在任何物理块), 写入, 更新 L2P 表
        (L2P 表本身按 4KB 粒度记录映射, 1TB 盘 ≈ 256M 条)
  • 这就是为什么 SSD 随机写和顺序写性能接近——FTL 把随机逻辑写重排成顺序物理写;
  • L2P 表大,SRAM 放不下,所以有 DRAM 缓存(断电丢)或 HMB/无 DRAM 设计(用主机内存)。

3.2 磨损均衡(Wear Leveling)

同一个块不能反复擦除。FTL 记录每个块的擦除次数,尽量均匀使用:

  • 动态均衡:只在写时选择最年轻块;
  • 静态均衡:把长期不动的冷数据搬到快坏死的块上(让热数据用年轻块)——牺牲一点迁移 IO 换整盘寿命。

3.3 垃圾回收与写入放大

覆盖写 = 把旧页标记无效 + 写新页。无效页要等块整块回收:GC 把块里还有效的页搬走,再擦除整块:

写放大 WA = 实际写入 NAND 的物理字节 / 主机下发的逻辑字节
典型: TLC 盘顺序写 WA≈1.1-1.5, 随机小写 WA 可达 3-10+

影响 WA 的旋钮:

  • OP(Over-Provisioning,预留空间):出厂多留 7%-28% 物理块不暴露给主机。OP 越大,GC 越从容、WA 越低、寿命越长;
  • TRIM:主机告诉 SSD"这些 LBA 数据已删除",FTL 把对应页直接标无效,避免 GC 搬死数据;
  • 写入模式:随机 4K 小写是 WA 噩梦;顺序大块写接近 1。

note

数据库与 WA 直接相关:LSM-Tree 的 compaction 把 N 次小写重写成更少的大写,把 WA 从 3-10 压向 1.x,所以 LSM 天然适合 SSD(见 databases/indexing/lsm.md)。反过来 B+ 树随机页写要靠 FTL 兜底,WA 更高。

3.4 掉电保护

写一半断电,FTL 映射表可能撕裂:

  • 企业盘:带电容(power-loss protection),掉电后靠电容把 DRAM 里未落盘的映射表/数据刷进 NAND;
  • 消费盘:常无保护——这正是"数据落到 page cache 不代表安全"、"必须 fsync"的硬件注脚。

四、NVMe: 为并行而生的协议

NVMe 替代 SATA/AHCI 的关键是队列模型

旧 AHCI:  1 个队列, 深度 32, 命令要敲门铃 + 中断 → 单队列串行
NVMe:     64K 个队列对, 每个队列深 64K
          Submission Queue (主机→盘) / Completion Queue (盘→主机)
          doorbell 写一次, 批量取命令; 支持 polling(无中断)
  • 多核 CPU 每个核自己的队列 → 无锁提交,IOPS 从几万涨到百万级;
  • 中断聚合(interrupt coalescing)+ polling 模式:低延迟场景(数据库 fsync)用 polling 可把尾延迟降一半;
  • 多命名空间(namespace):一块盘切成多个逻辑盘,可独立配额/加密;
  • ZNS(Zoned Namespace):把"顺序写"语义暴露给主机,让数据库/LSM 直接管理块——省掉 FTL 的部分搬运,下一代存储选型点。

五、对上层软件的三条铁律

  1. fsync 是昂贵的:NVMe 单次刷盘 ~10-50µs 但涉及掉电保护语义 + 队列 flush,批量合并 fsync 是 WAL 高吞吐的命门;
  2. 顺序大块 > 随机小块:能批量就批量;O_DIRECT 大 IO + io_uring 多队列是数据库/存储引擎的标准姿势;
  3. 寿命按 TBW 算预算:TLC 盘 TBW 有限,日志类高写负载选企业 TLC/MLC + 大 OP,QLC 只放冷数据。

warning

常见误判:把消费级 NVMe 当企业盘用在高写入日志系统,1-2 年盘就报错掉盘。选盘先看 TBW(总写入字节)与负载的写入速率,而不是顺序读速度。

六、一页速查

粒度:     页(4-16KB, 写) < 块(4-16MB, 擦除); 无覆盖写
寿命:      SLC>MLC>TLC>QLC; TBW 是预算, 写放大决定实际消耗
FTL:      L2P 映射 + 磨损均衡 + GC + TRIM + OP + 掉电保护
写放大:   随机小写 3-10x, 顺序大块 ~1.x, OP/TRIM 压低
NVMe:     64K 队列对 + polling, 百万 IOPS, ZNS 把顺序语义交还主机
数据库:   WAL 批量 fsync; LSM 对 SSD 友好; QLC 只放冷数据

下一篇: 总线与互联: PCIe / CXL / NVLink / RDMA

指令集架构:x86 / ARM / RISC-V

TL;DR — CISC (x86) 以变长指令、内存操作数换取代码密度,代价是前端的解码复杂度;RISC (ARM / RISC-V) 用定长指令和 Load-Store 模型换简单解码与宽发射,代价是静态代码体积。现代 x86 内部早已 RISC 化(μop),ARM 也引入了一些 CISC 特征,边界日渐模糊。真正的分水岭已不在指令长度,而在内存模型、向量扩展策略、特权级设计,以及——是否是开放标准。


1. RISC 与 CISC:从对立到融合

1.1 经典 RISC 信条

1980 年代初,Patterson(UC Berkeley)与 Hennessy(Stanford)几乎同时提出精简指令集的设计哲学。核心教条可以浓缩为四条:

原则硬件收益
定长指令(通常 32-bit)指令边界检测零成本,并行解码 trivial
Load-Store 架构ALU 只操作寄存器,数据通路规整,无内存锁定
大寄存器堆(≥32 个 GPR)减少 spill/fill,降低内存带宽压力
单周期(或少量周期)执行流水线均匀,无"惊喜指令"破坏调度

ARM(1985)和 MIPS(1986)是第一代商业 RISC 处理器。SPARC(Sun)、PA-RISC(HP)、PowerPC(AIM 联盟)紧随其后。

1.2 经典 CISC 信条

Intel 8086(1978)面对的是汇编程序员而非编译器——每条指令的"表现力"直接影响生产力。CISC 的设计决策几乎每一条都与 RISC 相反:

  • 变长指令:x86 指令 1–15 字节,用前缀字节叠加语义(REX、VEX、EVEX)
  • 内存操作数add [mem], reg 单条指令完成读-改-写
  • 少量架构寄存器:x86-32 只有 8 个 GPR,x86-64 扩展到 16 个
  • 复杂指令rep movs(串复制)、enter/leave(栈帧)、xlat(查表)

代价是解码器——每条指令需要前 1-3 个周期"找边界"(pre-decode),这直接限制了前端的宽度。

1.3 现代融合:边界不再清晰

1995 年 Intel Pentium Pro 引入了一个根本性的重构:把 x86 CISC 指令翻译为类 RISC 的微操作(μop)。从此,x86 的"外部语言"是变长 CISC,而"内部执行语言"是定长 μop(类似 RISC 三操作数格式)。

┌─────────────────────────────────────────────┐
│                   x86 指令流                  │
│  mov eax, [ebx+ecx*4+8]   (1条CISC)         │
└──────────────────┬──────────────────────────┘
                   │ x86 Decoder (MITE)
                   ▼
┌─────────────────────────────────────────────┐
│              μop 序列 (RISC-like)             │
│  tmp ← load [ebx+ecx*4+8]                   │
│  eax ← tmp                                  │
└──────────────────┬──────────────────────────┘
                   │ μop Cache (DSB)
                   ▼
            OoO 调度 / 执行单元

反过来,ARM AArch64 也不纯洁:

  • ldp / stp:一条指令加载/存储两个寄存器(x86 没有直接等价物)
  • 复杂寻址模式ldr x0, [x1, x2, lsl #3](基址 + 索引 × 移位)
  • paciasp / autiasp:指针认证指令,融合了加密与内存访问

结论:将 RISC 和 CISC 简化为"定长 vs 变长"已经不能描述 2025 年的真实世界。真正的区分维度应当是:解码复杂度、内存模型、向量策略、特权架构。


2. x86-64 架构深度

2.1 寄存器全景

类别寄存器宽度说明
通用RAX, RBX, RCX, RDX, RSI, RDI, RBP, RSP, R8–R1564-bitRSP 专用于栈指针,RBP 为帧指针(可被省略)
SIMDXMM0–XMM15128-bitSSE 基础,所有 x86-64 处理器必备
SIMDYMM0–YMM15256-bitAVX/AVX2,XMM 的超集(低 128 位共享)
SIMDZMM0–ZMM31512-bitAVX-512,32 个寄存器(EVEX 编码提供 R' 位扩展)
掩码K0–K764-bitAVX-512 谓词寄存器,用于按元素掩码

:在 AVX-512 模式下,YMM/ZMM 寄存器的高位在上下文切换时不需要保存(XSAVE 头中的 XSTATE_BV 位图标记实际使用的高位寄存器),但保存/恢复仍很昂贵(~1KB+ 状态)。

2.2 变长编码解析

x86-64 指令编码从低地址到高地址的结构为:

[Prefixes] [Opcode 1-3B] [ModR/M] [SIB] [Displacement 1/2/4B] [Immediate 1/2/4/8B]
  • 传统前缀(Legacy):0x66(操作数宽度)、0x67(地址宽度)、0xF0(LOCK)、0xF2/0xF3(REP/REPE/REPNE)、段覆盖
  • REX 前缀0x40–0x4F,用 4 个 bit(W, R, X, B)将 GPR 寻址扩展到 16 个寄存器和 64 位操作数
  • VEX 前缀(AVX):2 或 3 字节,编码非破坏性三操作数格式(c5/c4 开头)
  • EVEX 前缀(AVX-512):4 字节,扩展 32 个向量寄存器和掩码/广播/取整模式

一个极端例子——vaddps zmm20 {k3}{z}, zmm8, [rax+rcx*8+0x1234](512-bit 向量加,带掩码和零化):

EVEX.512.66.0F.W0 58 /r
62h F1h 3Eh 08h 58h 54h C8h 34h 12h
│    │   │   │   │   │   │   └── displacement[15:0] = 0x1234
│    │   │   │   │   │   └────── SIB: scale=3(index=rcx, base=rax)
│    │   │   │   │   └────────── ModR/M: reg=zmm20, rm=[SIB]+disp32
│    │   │   │   └────────────── Opcode: 0x58 (VADDPS)
│    │   │   └────────────────── EVEX.pp=0b01, mm=0b10, W=0, vvvv=zmm8
│    │   └────────────────────── EVEX.R'R=0b0000 → reg 扩展位
│    └────────────────────────── EVEX payload 第 2 字节
└──────────────────────────────── EVEX 标识: 0x62

共 10 字节——这就是变长指令的代价和表现力。

2.3 寻址模式

x86 最著名的"瑞士军刀"寻址:

Seg:[Base + Index * Scale + Displacement]
  • Base:任意 GPR
  • Index:任意 GPR(除 RSP)
  • Scale:1、2、4、8
  • Displacement:0、8、16、32 位

一条指令完成指针算术 + 访存。这在遍历 struct 数组时极为高效:

; x86-64: 遍历 struct Node { value(8B), next(8B) }
; rdi = 指向当前节点的指针
.loop:
    mov rax, [rdi]             ; 加载 node->value
    add rbx, rax               ; 累加
    mov rdi, [rdi + 8]         ; node = node->next(基址+偏移即位移)
    test rdi, rdi
    jnz .loop

对比 ARM 的等价位:需要 两条 加载指令。

2.4 调用约定

项目System V AMD64 (Linux/macOS)Microsoft x64 (Windows)
参数寄存器RDI, RSI, RDX, RCX, R8, R9RCX, RDX, R8, R9
返回值RAX (以及 RDX 用于 128-bit)RAX
被调用者保存RBX, RBP, R12–R15RBX, RBP, RDI, RSI, R12–R15
调用者保存其余全部其余全部
栈对齐16 字节(调用前)16 字节
阴影空间32 字节(调用者分配,给被调用者用)

两种约定不可混用,这是跨平台 ABI 调试中常见的深坑(Windows 的阴影空间常常被 Linux 开发者忽略)。


3. ARM AArch64 架构

3.1 寄存器布局

寄存器别名 / 用途
X0–X7参数 / 返回值(X0 也接收返回值)
X8间接结果寄存器(如返回大型 struct)
X9–X15调用者保存的临时寄存器
X16–X17过程内调用暂存(IP0, IP1)
X18平台寄存器(Windows 的 TLS 指针,Linux 通用)
X19–X28被调用者保存
X29帧指针(FP)
X30链接寄存器(LR,存放返回地址)
XZR / SPX31,指令中根据上下文解释为零寄存器或栈指针

32 个 GPR 意味着编译器几乎不需要 spill 临时变量——这对 ISA 的编译友好性贡献极大。

3.2 Load-Store 铁律

ARM 不存在 add [mem], reg 这类指令。任何内存操作必须经过:

  1. Load:从内存拉到寄存器
  2. Operate:在寄存器上执行 ALU
  3. Store:从寄存器推回内存

这保证了所有 ALU 操作的延迟可预测,数据通路无需与内存控制器耦合。

; AArch64: 遍历 Node 链表
; x0 = 当前节点指针
.loop:
    ldr x1, [x0]               ; x1 = node->value (load)
    add x19, x19, x1           ; 累加到被调用者保存寄存器
    ldr x0, [x0, #8]           ; x0 = node->next (基址+偏移寻址)
    cbnz x0, .loop             ; 比较并分支(一条指令)

3.3 条件执行:从 ARM32 到 AArch64

ARM32(A32/T32)的标志性特征是几乎每条指令都可条件执行——指令的高 4 位是条件码(EQ、NE、CS、MI 等 15 种条件)。这省去了大量分支跳转,但也消耗了编码空间。

AArch64 大幅削减了这一机制:

  • 移除了通用条件执行字段
  • 仅保留少数条件指令csel(条件选择)、cset(条件设值)、ccmp(条件比较)、cinc(条件自增)
  • 分支仍然使用 b.eqb.ne 等(条件码来自 NZCV 标志,由 cmp / tst 等设置)
; AArch64: abs(x0) 无分支
    cmp x0, #0
    cneg x0, x0, lt            ; 若 x0 < 0 则 x0 = -x0,否则不变

3.4 调用约定(AAPCS64)

参数:  X0→X7(整数/指针), V0→V7(浮点/SIMD)
返回:  X0(整数), V0(浮点)
被调用者保存: X19–X28, V8–V15

苹果平台还有细微修改,统称 Darwin ABI。


4. RISC-V 架构

4.1 模块化设计哲学

RISC-V 最基本的创新不在技术而在治理——这是一份自由、开放的 ISA 规范,归属 RISC-V International 而非任何公司。

ISA 被拆分为基础指令集 + 标准扩展,每种扩展用一个字母标识:

扩展内容
I基础整数指令(RV32I / RV64I)
M整数乘除
A原子操作
F单精度浮点
D双精度浮点
C压缩指令(16-bit)
V向量扩展
Zicsr控制和状态寄存器访问
Zifencei指令栅栏
Zbb基础位操作
……总共 40+ 已批准扩展

常见的软件目标:RV64GC = RV64I + M + A + F + D + C。

4.2 寄存器与 ABI

寄存器ABI 名称用途
x0zero硬连线 0
x1ra返回地址
x2sp栈指针
x3gp全局指针
x4tp线程指针
x5–x7t0–t2临时
x8s0/fp被调用者保存 / 帧指针
x9s1被调用者保存
x10–x17a0–a7函数参数 / 返回值
x18–x27s2–s11被调用者保存
x28–x31t3–t6临时

与 ARM 类似有 32 个 GPR,但 x0 硬编码为 0(读)或 /dev/null(写)——这很巧妙:nop 实际上是 addi x0, x0, 0

4.3 指令格式:规整而优雅

所有基础指令严格 32 位,分 4 种格式:

R-type: funct7(7) | rs2(5) | rs1(5) | funct3(3) | rd(5) | opcode(7)
I-type: imm[11:0](12) | rs1(5) | funct3(3) | rd(5) | opcode(7)
S-type: imm[11:5](7) | rs2(5) | rs1(5) | funct3(3) | imm[4:0](5) | opcode(7)
U-type: imm[31:12](20) | rd(5) | opcode(7)

字段位置在所有格式中对齐——rs1 始终在 bit 15–19,rs2 在 bit 20–24,rd 在 bit 7–11。解码器可以用一层 MUX 完成寄存器读取,不需要像 x86 那样先解析 ModR/M 才能定位寄存器字段。

4.4 无状态条件码

RISC-V 抛弃了 x86 和 ARM 共享的 条件码寄存器(FLAGS / NZCV)设计。比较通过比较-分支融合指令完成:

; RISC-V: 比较并分支(单条指令)
    blt x5, x6, .L_target      ; if x5 < x6 goto target
    beq x5, x0, .L_done        ; if x5 == 0 goto done

这消除了条件码作为隐式状态的依赖,简化了乱序执行中的寄存器重命名(不需要单独的条件码物理寄存器堆)。

4.5 向量扩展:RVV

RISC-V Vector Extension (RVV 1.0) 的核心思想借鉴了 Cray-1 的向量模型:

  • 向量长度不可知(Vector Length Agnostic):同一代码可在 128-bit 到 65536-bit 的实现上无修改运行
  • 使用 vsetvl 指令在运行时设置向量长度(基于硬件 VLEN 和应用元素宽度)
  • 三种加载模式:单元步进(unit-stride)、步幅(strided)、索引(indexed)
; RISC-V RVV: VA + VB → VC(任意长度)
    vsetvli t0, a0, e32, m1    ; 配置: 32-bit 元素, LMUL=1, 设置 vl = min(VLEN/32, a0)
    vle32.v v1, (a1)            ; 加载 A(单元步进)
    vle32.v v2, (a2)            ; 加载 B
    vadd.vv v3, v1, v2          ; 向量加法
    vse32.v v3, (a3)            ; 存储到 C
    sub a0, a0, t0              ; 剩余元素数
    bnez a0, .loop              ; 尾循环处理超长向量

4.6 特权架构

RISC-V 定义了三个标准特权级:Machine (M)、Supervisor (S)、User (U),外加可选的 Hypervisor (H)。

  • M-mode:始终存在,用于固件/安全监控(类比 ARM EL3)
  • S-mode:操作系统内核(类比 x86 Ring 0)
  • U-mode:用户空间(类比 x86 Ring 3)

分页方案:Sv39(3 级页表,39-bit VA,512 GB 地址空间),Sv48(4 级,48-bit VA),Sv57(5 级,57-bit VA)。Linux 内核主线已完整支持 RISC-V,发行版(Ubuntu、Fedora、Debian)均提供 RV64GC 镜像。


5. SIMD 扩展对比

5.1 演进路径

x86:    MMX(64b,'97) → SSE(128b,'99) → AVX(256b,'11) → AVX-512(512b,'16)
ARM:    NEON(128b,'05) ────────────────→ SVE(variable,'19) → SVE2('20)
RISC-V: ──────────────────────────────→ RVV(1.0,'21)

每一代不仅是宽度翻倍——指令格式、寄存器堆编码、编译器内在函数(intrinsics)全部不同。

5.2 x86 AVX-512:丰富但分裂

AVX-512 最大的工程败笔是 子集碎片化。它不是一个整体,而是十几个独立的指令子集:

子集功能
AVX-512F基础(必有)
AVX-512CD冲突检测
AVX-512VL128/256-bit 操作数上的 AVX-512 指令
AVX-512BW字节/字操作
AVX-512DQ双字/四字
AVX-512IFMA整数融合乘加
AVX-512VBMI字节向量位操作
AVX-5124VNNIW4 字向量神经网络指令

仅 Intel 内部就因 P-core(支持 AVX-512)vs E-core(不支持) 而内部打架——线程从 P 核迁移到 E 核时必须保存/恢复 ZMM 状态(~1KB XSAVE 区域),在实时系统中引入不可预测的延迟抖动。最终 Intel 在消费级 12/13 代直接熔断(fuse off)AVX-512。

工程教训:ISA 扩展必须是全平台统一的承诺。部分芯片有/部分没有 = 编译器无法生成高效代码(除非独占 -march 目标)。

5.3 ARM SVE/SVE2:宽度无关

SVE 的核心思想:写一次代码,在所有向量宽度上运行

min(VLEN) = 128 bit, max(VLEN) = 2048 bit (16 字节 → 256 字节)

SVE 没有为每个宽度提供不同的指令——寄存器操作数隐式适应 vl(向量长度)寄存器的值。这消除了 AVX-512 面临的宽度升级问题。指令示例:

; SVE: 向量加法(宽度自适应)
    ptrue p0.d                  ; 谓词 = 全真(双字元素)
    ld1d {z0.d}, p0/z, [x1]    ; 加载(由 vl 决定加载元素数)
    ld1d {z1.d}, p0/z, [x2]
    fadd z2.d, z0.d, z1.d      ; 浮点向量加
    st1d {z2.d}, p0, [x3]

但是:苹果 M 系列不实现 SVE,而是依赖自研的 AMX(Apple Matrix coprocessor)加速矩阵运算。对于可移植性,NEON(128-bit 固定)仍然是最低公分母。

5.4 RISC-V V:Cray 风格的回归

RVV 与 SVE 概念近似但实现不同:

  • SVE 用谓词寄存器p0p15)控制每元素是否操作
  • RVV 用向量长度寄存器vl)控制操作元素数,掩码由 v0 向量寄存器提供
  • RVV 支持 LMUL(向量长度乘数),允许将多个向量寄存器组合为逻辑寄存器(LMUL=8 时每条指令处理 8×VLEN 位数据)

三条汇编对比(向量加法,等效语义):

; x86 AVX-512: 固定 16 个 32-bit 元素
    vaddps zmm0, zmm1, zmm2

; ARM SVE: 宽度自适应(假设 .s = 32-bit 元素)
    fadd z0.s, z1.s, z2.s

; RISC-V V: LMUL=1, SEW=32
    vfadd.vv v0, v1, v2

6. ISA 对微架构的影响

6.1 解码宽度

解码宽度直接决定超标量处理器的"天花板":

微架构ISA解码宽度关键手段
Intel Golden Covex866 宽复杂预解码 + μop Cache (4K entries)
AMD Zen 5x864 宽 × 2(双解码簇)Op Cache 护航
Apple M3 (Avalanche)ARM AArch649 宽定长指令→极简解码
SiFive P870RISC-V8 宽定长 + 部分压缩指令扩展

ARM / RISC-V 天然更容易宽解码——32-bit 边界对齐意味着解码器可以并行盲切指令流,无需像 x86 那样先扫描前缀(LenFinder)。

:RISC-V 的压缩扩展(C,16-bit 指令)破坏了对齐假设。任何地址可以是 16-bit 指令的开始,需要额外的半字对齐检查,复杂度略增。

6.2 代码密度

变长编码的 CISC 在代码体积上有天然优势:

int sum(int *arr, int n) { int s=0; for(; n--; ) s += arr[n]; return s; }
ISA代码字节数比例
x86-64 (-O2)24 字节1.00×
ARM AArch64 (-O2)32 字节1.33×
RISC-V RV64GC (-O2)28 字节1.17×
RISC-V RV64I (无压缩)40 字节1.67×

RISC-V 的 C 扩展(16-bit)大幅缩小了与 x86 的差距——但 AVX-512 的长前缀又反向拉大了体积。

6.3 内存模型

ISA默认模型得到顺序时的屏障
x86TSO(强)显式 mfence / lock
ARM弱序(RCpc)dmb ish / dmb ishst
RISC-VRVWMO(弱序)fence rw,rw

x86 的强内存模型极大简化了锁算法和无锁数据结构的实现(大多数情况下甚至不需要显式屏障),代价是乱序执行器的自由度受限。ARM/RISC-V 更灵活但更容易写出"在 x86 上能跑、在 ARM 上炸"的代码。


7. 苹果的 ISA 策略

苹果是全球唯一在 ISA 上完成两次迁移的公司:68k → PowerPC(1994)→ x86(2005)→ ARM(2020)。

7.1 自研扩展

苹果的 M 系列芯片实现 ARM ISA,但硅片上的功能远超 ARM 标准:

  • AMX(Apple Matrix coprocessor):私有矩阵乘加指令,通过 Accelerate.framework 暴露(不直接汇编可访问)
  • APR(Apple Performance Registers):私有的性能监控和控制 MSR
  • RR(Return Prediction):私有的返回地址预测器配置
  • 苹果不依赖 ARM 的 CoreSight 调试架构,自研 DAP 链

这套策略等于 "ARM 是 ABI 兼容层,硅片是我说了算"

7.2 Rosetta 2 与内存排序

x86 使用完全存储排序(Total Store Ordering / TSO):所有 CPU 核看到的一致性写顺序。ARM 是弱序模型,硬件可重排 store。

翻译 x86 指令时,若每次 store 后都插入 dmb 屏障,则性能损失约 5–10%。苹果的解法分两步:

  1. M1(AOT + JIT):编译器分析 x86 访存数据依赖,仅在必要处插入屏障
  2. M2+:硬件新增 TSO 模式(可被 Rosetta 2 运行时启用),硬件强制全序,零软件屏障

这是典型的"第一代用软件吃痛,第二代用硬件根治"的 Apple 风格。


8. 2025 年格局

维度x86ARMRISC-V
桌面/笔记本Intel Lunar Lake + AMD Zen 5 主导Apple M 系列 + Qualcomm Snapdragon X未见
服务器AMD EPYC 份额日益增大AWS Graviton4 + AmpereOneVentana Veyron V2 (2025)
嵌入/加速器Intel AtomCortex-R/M 占统治地位无处不在(NV GPU 控制器、WD SSD、Google Titan)
生态成熟度极致(40 年)强(iOS 生态绑定)快速追赶(Linux、GCC/LLVM 主线支持完毕)
许可证Intel/AMD 交叉授权ARM 商业授权费免费 + 开放

RISE 项目(RISC-V Software Ecosystem)由 Google、Intel、Qualcomm、MediaTek 等共同资助,目标是将 RISC-V 软件堆栈推向生产级——这可能是 RISC-V 从"嵌入式控制 ISA"跃迁到"通用计算 ISA"的关键拐点。


9. 工程事故记录

9.1 Intel AVX-512 熔断

问题:12/13 代酷睿采用混合架构(P-core + E-core)。P-core 具备 AVX-512 而 E-core 不具备。当线程在核间迁移时,AVX-512 状态体的 XSAVE/XRSTOR 延迟(~数百周期)破坏实时性。

对策:Intel 在微码/熔丝层面禁用 AVX-512,即使硅片支持也不暴露。社区(和某些主板商)尝试通过固件 hack 重新启用——Intel 继续在后续步进中强化熔断。

教训:SIMD ISA 必须平台统一。部分实现 = 不可靠实现。

9.2 Apple M1 Rosetta 2 TSO 性能

见第 7.2 节。第二教训:ISA 迁移不仅是解码问题——内存模型是底层的大坑

9.3 RISC-V 向量扩展版本割裂

RVV 0.7.1 草稿与最终 1.0 规范不兼容(指令编码不同)。阿里的玄铁 C910、SiFive 的 X280 等早期核心出货时搭载了 0.7.1。这意味着已部署的硬件无法运行标准 RVV 1.0 代码,且 Rust/GCC 需要同时维护 0.7.1 和 1.0 两套目标。

教训:在 ISA 规范未冻结前出货硅片 ≈ 制造永久的生态裂痕。


10. 易错清单(面试/考试高频陷阱)

  1. x86 rep movsb 并不总是快 — 仅在 copy > 256 字节且源/目标对齐到 16/32 字节时,微码的 "fast string" 模式才启动。小 copy 用 rep movsb 反而比普通 mov 循环慢很多。

  2. ARM ldp / stp 是两条独立操作 — 它分别加载两个寄存器,不是 128-bit 宽度的单次访存。在弱序内存模型下,两条 load 之间可能被其他 store 插序。

  3. ARM/AArch64 的 SP 不是真的 X31 — 在大多数指令编码中,X31 的位置被解释为 SP 而非 "寄存器 31"(存在 add x0, sp, #8,但不存在 add x0, x31, #8)。这是编码空间的诡计。

  4. RISC-V 压缩指令译码可能产 2 条 μop — 某些复合压缩指令(如 c.lwsp rd, offset)在宽发射微架构上会被裂解为两条内部操作:先计算地址,再加载。这抵消了部分解码宽度优势。

  5. NEON 永远是 128-bit — ARM NEON 不可扩展。不要将 NEON 与 SVE 混淆。苹果 M 系列只有 NEON(+ AMX),不支持 SVE。Linux 内核可以通过 hwcap 检查 HWCAP_SVE

  6. AVX-512 会降频 — 执行 512-bit 向量指令时,CPU 可能降低 200–400 MHz(thermal budget 重分配)。编译器(GCC -mprefer-vector-width=256)和库(glibc memcpy)通常避免 512-bit 操作以减少频率惩罚。

  7. x86 LOCK 前缀的隐式操作lock cmpxchg [mem], reg 不仅原子化这条指令,还隐含了完全内存栅栏(full memory barrier)——比单独 mfence 更强,因为它强制 store buffer 排空。


这一章带走的东西

  • RISC vs CISC 已死:长话短说,现代 x86 内部是 RISC 的;现代 ARM 也不是纯正的 RISC。真正的分水岭在:解码复杂度、内存模型、向量策略、特权架构。
  • 选 ISA = 选约束:x86 给你 40 年生态兼容性和最密代码,代价是解码器的痛。ARM 给你高能效比和宽解码,代价是弱内存模型带来的调试成本。RISC-V 给你自由和免授权费,代价是生态仍在追赶。
  • 向量扩展是当前最大的 ISA 战场:AVX-512 强但碎片化,SVE 优雅但苹果不跟,RVV 开放但生态尚未成熟。矩阵乘法加速(AMX、IMMA、MME)是下一个战场。
  • 不要相信"一次编写处处运行"关于 SIMD 的部分:即使是同一 ISA 家族(如 AVX-512 的各子集),代码在目标芯片缺失该子集时直接 SIGILL。运行时特性检测(cpuid / hwcap / misa)是必需的前置步骤。
  • 内存模型在 ISA 选择中同等重要:x86 TSO 的"安全网"让无数并发 Bug 隐身——把这些代码搬到 ARM 或 RISC-V 上时,它们会以最诡异的方式爆发。

下一节 → CPU 流水线与指令级并行

第九部分 · 计算理论(Formal Languages / Automata / Complexity)

一句话

计算理论回答两个根本问题:(1) 什么是"可计算"?(2) 什么是"高效可计算"? 前者把所有"能算的事情"用形式化机器(自动机、图灵机)框死,并给出绝对不可逾越的边界(停机问题、Rice 定理);后者把"高效"形式化为复杂度类 P / NP / PSPACE,告诉你哪些问题注定无法在多项式时间内解。所有工程上的"优化是 NP-hard"、"这条正则匹配不上"、"语言不可判定"——都源自这里。它是把 DSA、编译原理、密码学(基于 NP 困难)连接起来的那一层。

思想链

[正则匹配 /^(a|b)*ab$/ on "aaaab"]
  └─> 正则 → ε-NFA → 子集构造 → DFA → 状态机驱动 (常数时间每字符)
       │       (字典树 / AC 自动机 / ReDoS 防护都建立在这里)
       └─> 但是 (a^n b^n) 正则匹配不上 → 需要更高层级: PDA
             └─> PDA ⇔ CFG ⇔ 上下文无关 → parser 用的 LR/LALR
                   └─> 但 (a^n b^n c^n) PDA 也匹配不上 → 需要 TM
                         └─> TM 能算所有"可计算"问题
                               └─> 但是停机问题不可判定: 没有算法能判断任意程序是否会停
                                     └─> Rice 定理: 任意非平凡语义性质都不可判定
                                           └─> P vs NP: 多项式可验证 ≠ 多项式可解?
                                                 └─> NPC 归约: 3-SAT → Clique → Vertex Cover...
                                                       └─> 现代密码学 (RSA / 椭圆曲线) 假设 factoring/NP 不在 P

章节

读完应能回答:

  1. 为什么 ^(a|b)*ab$ 可以用 DFA 常数空间匹配,但 ^(a^n)(b^n)$ 必须用栈?
  2. 子集构造最坏情况会让 N 状态的 NFA 爆成 2ⁿ 状态的 DFA——这个下界是怎么构造的?
  3. Pumping lemma 是必要非充分条件,那"非正则"的常规证法是什么? Myhill-Nerode 怎么给充要?
  4. 为什么 LR(1) 比 LL(1) 强但 parser 都更爱 LL? Chomsky 层级是怎么对应自动机层级的?
  5. 停机问题怎么用对角线法证明? Rice 定理为什么让"程序是否 malware"也变得不可判定?
  6. P=NP 是七大千禧难题之一,为什么密码学家假设 NPA≠P? 如果 NPA 在 P,RSA 会立刻崩盘吗?
  7. 3-SAT 是 NPC 完备问题的"Mona Lisa", 怎么从 3-SAT 归约到 Clique、Vertex Cover、TSP、Subset Sum?
  8. 什么问题在 PSPACE 但估计不在 NP? QBF / generalized geography / 围棋盘面判定
  9. PTAS、APX-hard、Inapproximability ratio:为什么 set cover 估计比只能到 (ln n) 因子?

历史 1: 1936 Turing 与 Church 各自独立

Turing 发表 "On Computable Numbers", 用假想机器 (Turing Machine) 给出"可计算"的形式化; Church 同年用 λ-calculus 给出同样结论. 二者等价, 史称 Church-Turing Thesis——任何"算法"都能用 TM 表达。这是计算理论的奠基.

历史 2: 1956 Kleene / 1959 Rabin-Scott

Kleene 给出正则语言定理; Rabin-Scott 1959 引入 DFA/NFA, 证明二者等价, 诞生子集构造法。1968 Thompson 把 NFA 编译成汇编, 这就是今天 grep / sed / RegEx 的源头(Thompson 构造法,第一部分 dsa/topics/string 里有提及).

历史 3: 1936 Gödel / 1931 不完备

Gödel 不完备定理先于 Turing 5 年: 任何强到包含 PA 的形式系统必有不可证真命题. 直接启发 Turing 的停机问题.

历史 4: 1971 Cook-Levin / 1972 Karp

Cook-Levin 证明 SAT 是 NP-complete (第一个 NPC 问题); Karp 1972 列出 21 个经典 NPC 问题 (TSP / 3-SAT / Clique / Vertex Cover ...), 把"对一个个具体问题找算法"变成"识别 NPC 后就停止找多项式算法"——这是工程上最实用的一面.

历史 5: 1977 Garey & Johnson

"Computers and Intractability: A Guide to the Theory of NP-Completeness" 出版, 成为 NPC 问题的"红宝书", 至今仍是工程师面对新问题的第一查询对象.


下一节 → 自动机:DFA → NFA

1. 自动机:DFA / NFA / ε-NFA / 子集构造 / 最小化

TL;DR

正则语言的"计算模型"是有限自动机 (Finite Automaton)——一种只有有限个状态、读一个字符就走一步的机器. 三种等价形式:

  1. DFA (Deterministic Finite Automaton) —— 每状态每输入有唯一后继.
  2. NFA (Nondeterministic Finite Automaton) —— 每状态每输入可有 0~N 个后继, 接受只要某一路径 reaches 终态.
  3. ε-NFA —— 在 NFA 基础上允许 ε-transfer (无输入也走).

三者等价 (都识别正则语言), 但工程上 ε-NFA 最易手写、DFA 执行最快. 子集构造把 NFA 编译成 DFA; Hopcroft 算法把 DFA 压到最小. 这一进一出就是 grep / sed / regex JIT 的内部.

思想链

工程现场: 在 10MB 日志里匹配 /^(ERROR|WARN)[0-9]*:/
  └─> 正则 --Thompson--> ε-NFA --子集构造(可 lazy)--> DFA
       └─> DFA 每字符 O(1)、无回溯 → 天然免疫 ReDoS, 这是 grep 高吞吐的根
             │      (代价: backreference 被放弃 —— 见下一章 regular.md)
             └─> 但 a^n b^n 这类"计数"语言正则表达不了 → 泵引理 / Myhill-Nerode 判界
                   └─> 需要栈 → PDA ⇔ CFG (下一章) → parser 的 LL/LR 全家
                         └─> 栈也不够的 → 图灵机 → 可计算性的天花板 (第 4 章)

一、DFA 的形式化定义

1.1 五元组

DFA 是五元组 $M = (Q, \Sigma, \delta, q_0, F)$:

  • $Q$: 有限状态集.
  • $\Sigma$: 有限输入字母表.
  • $\delta: Q \times \Sigma \to Q$: 转移函数 (完全、确定性).
  • $q_0 \in Q$: 初始状态.
  • $F \subseteq Q$: 终止/接受状态集.

接受: $\delta^*(q_0, w)$ 跑完输入 $w$ 后落在 $F$ 内即接受 $w$, 记 $w \in L(M)$.

1.2 例子: 接受"以 0 结尾的二进制串"

States Q = {A, B}, Σ = {0,1}, q0 = A, F = {B}
δ:
    A —0→ B      (读到 0, 进 B 表示"刚才读到 0")
    A —1→ A      (读到 1, 留 A)
    B —0→ B
    B —1→ A
Input: 1010
  A --1--> A --0--> B --1--> A --0--> B  ∈ F → accept
Input: 1011
  A --1--> A --0--> B --1--> A --1--> A   ∉ F → reject (末位是 1)

1.3 Python 实现

from typing import Dict, Set, Tuple

class DFA:
    def __init__(self, Q, Sigma, delta, q0, F):
        self.Q = Q
        self.Sigma = Sigma
        self.delta = delta        # dict[(state, char)] -> state
        self.q0 = q0
        self.F = F
    
    def accepts(self, w: str) -> bool:
        q = self.q0
        for ch in w:
            if ch not in self.Sigma:
                raise ValueError(f"alphabet mismatch: {ch!r}")
            q = self.delta[(q, ch)]
        return q in self.F

# 以 '0' 结尾
M = DFA(
    Q={"A", "B"}, Sigma={"0", "1"}, q0="A", F={"B"},
    delta={("A","0"):"B", ("A","1"):"A", ("B","0"):"B", ("B","1"):"A"},
)

assert M.accepts("1010") and not M.accepts("1011") and M.accepts("0")

1.4 TypeScript 实现

export type DFA = {
  Q: Set<string>;
  Sigma: Set<string>;
  delta: Map<string, string>;   // key = `${q}|${ch}`
  q0: string;
  F: Set<string>;
};

export function runDFA(m: DFA, w: string): boolean {
  let q = m.q0;
  for (const ch of w) {
    if (!m.Sigma.has(ch)) throw new Error(`alphabet mismatch: ${ch}`);
    const nx = m.delta.get(`${q}|${ch}`);
    if (nx === undefined) throw new Error(`no transition`);
    q = nx;
  }
  return m.F.has(q);
}

note

DFA 是"流式"算法: 接收每字符 O(1), 总 O(n), 不存历史. 这正是 grep 高吞量的根本来源——比 backtracking regex engine 快 1-3 个数量级.


二、NFA:非确定性带来的便利

2.1 直觉

NFA 是"幻觉中的并行"——一次走多条路, 只要任何一条 reach 接受状态就接受. 这不是物理并行, 是数学抽象: 我们只需维护当前可能状态的集合.

形式化同样五元组, 唯一差别: $$ \delta: Q \times \Sigma \to 2^Q $$ 即转移结果是个状态集.

2.2 例子: 接受"以 ab 结尾"的所有串

       a        b
(0)----→(1)----→((2))   ← 接受

但更聪明: 用 0,1 的自循环 + 一条 a 弧线直达

NFA 三个状态即可, 而且 ε-free:

Q = {S, A, F}, Σ = {a, b}, q0 = S, F = {F}
    S --a--> S, S --a--> A    (读 a 时既留在 S 也走到 A)
    A --b--> F
    F --a,b--> F

接受 "ab": {S} --a--> {S,A} --b--> {F,A,S} ∃ F → 接受.

2.3 NFA 接受的定义

$w = a_1 a_2 \ldots a_n$ 被 NFA 接受 iff 存在状态序列 $q_0 \to q_1 \to \ldots \to q_n$ 使得 $q_i \in \delta(q_{i-1}, a_i)$ 且 $q_n \in F$.

注意"存在": NFA 的接受是存在量词——同一输入对应多条路径, 只要有一条到达终态即接受; DFA 只有唯一路径, 没有"运气"可言. 这也解释了为什么 NFA 表达力与 DFA 相同但描述能力更强: 存在量词把"猜对了的那条路"的构造负担甩给了子集构造.

2.4 NFA 的实现: 维护状态"集合"

class NFA:
    def __init__(self, Q, Sigma, delta, q0, F):
        self.delta = delta         # dict[(state, char)] -> set of states
        self.q0, self.F = q0, F
        self.Sigma = Sigma
        self.Q = Q

    def accepts(self, w: str) -> bool:
        current = {self.q0}
        for ch in w:
            nxt = set()
            for q in current:
                nxt |= self.delta.get((q, ch), set())
            if not nxt:
                return False
            current = nxt
        return bool(current & self.F)
操作DFANFA
每个 charO(1) lookupO(|current|) scans, 状态集大小 ≤ |Q|
内存O(|Q|)O(|Q|) 仅集合
实现简洁
工程表达力

NFA 方法在工程上更易手写、但每步慢 O(|Q|); DFA 方法更难手写、但每步 O(1) —— 这正是子集构造法存在的原因.


三、ε-NFA 与 ε-closure

3.1 ε 转移

允许 $\delta: Q \times (\Sigma \cup {\varepsilon}) \to 2^Q$, 即不读字符就能跳状态. 这在构造 OR / regex 编译时极其方便.

3.2 ε-closure($S$)

给定状态集 $S$, $\varepsilon\text{-closure}(S)$ = 从 $S$ 中任一状态沿 ε 弧线能 reach 的所有状态 (含 $S$ 自身).

def epsilon_closure(self, S: set) -> set:
    stack, seen = list(S), set(S)
    while stack:
        q = stack.pop()
        for r in self.delta.get((q, ''), set()):
            if r not in seen:
                seen.add(r); stack.append(r)
    return seen

3.3 接受串

主流 ε-NFA 的接受算法:

S0 = epsilon_closure({q0})
for ch in w:
    S' = ∪_{q ∈ S} δ(q, ch)
    S = epsilon_closure(S')
return S ∩ F ≠ ∅

四、Thompson 构造法: regex → ε-NFA

1968 Ken Thompson (Unix 创始人之一) 在 CTSS 上写 ed 编辑器, 把正则编译成 ε-NFA, 至今仍是 grep / sed / awk 的核心算法. 第一部分 dsa/topics/string 已介绍, 复习:

  • 对原子 c: 两状态 + 一条 c 弧.
  • e1 | e2: 新 start, ε 走 e1 / ε 走 e2.
  • e1 e2: 串接.
  • e*: 新 start, ε→e 头, e 尾 ε→新终, 新 start ε→新终.

每条 regex 长度 n 至多 2n 个 ε-NFA 状态. 这是 PCRE backtracking engine 跟"理论上严格"两条路线的分界.

warning

Python re 和 JS RegExp 内部走 backtracking 不是 DFA. 对 ^(a+)+$ 这类病态正则会指数级爆栈, 业界叫 ReDoS. Go 标准库 regexp 用 Russell Cox 实现, 保证 NFA 线性时间, 但放弃了 backreference (因为 backreference 让语言超出正则, 不再能用 DFA 处理).


五、子集构造: NFA → DFA

5.1 算法骨架

把 ε-NFA 的"状态集"显式编号成 DFA 状态. 每 DFA 状态对应 NFA 状态的一个子集 $S \subseteq Q$.

def nfa_to_dfa(nfa) -> DFA:
    start = frozenset(nfa.epsilon_closure({nfa.q0}))
    dfa_states = {start}
    dfa_delta = {}
    queue = [start]
    while queue:
        S = queue.pop()
        for ch in nfa.Sigma:
            nxt = set()
            for q in S:
                nxt |= nfa.delta.get((q, ch), set())
            nxt_frozen = frozenset(nfa.epsilon_closure(nxt))
            if nxt_frozen not in dfa_states:
                dfa_states.add(nxt_frozen)
                queue.append(nxt_frozen)
            dfa_delta[(S, ch)] = nxt_frozen
    F_dfa = {s for s in dfa_states if s & nfa.F}
    return DFA(Q=dfa_states, Sigma=nfa.Sigma, delta=dfa_delta,
               q0=start, F=F_dfa)

Go 版本 (与上面 Python 版一一对应, Go 1.21+ 标准库):

// Go 版子集构造: ε-NFA -> DFA
type SSet map[string]bool // NFA 状态名集合

// ε-closure: 从 S 沿 ε 边能到的全部状态 (含 S 自身)
func closure(eps map[string]SSet, S SSet) SSet {
	seen := make(SSet, len(S))
	stack := make([]string, 0, len(S))
	for q := range S {
		seen[q] = true
		stack = append(stack, q)
	}
	for len(stack) > 0 {
		q := stack[len(stack)-1]
		stack = stack[:len(stack)-1]
		for r := range eps[q] {
			if !seen[r] {
				seen[r] = true
				stack = append(stack, r)
			}
		}
	}
	return seen
}

func name(S SSet) string {
	xs := slices.Sorted(maps.Keys(S))
	return strings.Join(xs, ",")
}

// move(q, ch) 返回该转移可达的状态集; 无转移时返回空集.
// 返回值: DFA 状态 id -> 字符 -> 后继 id. 含任一 NFA 终态的集合即 DFA 终态.
func nfaToDFA(sigma []string, move func(q, ch string) SSet,
	eps map[string]SSet, q0 string) map[string]map[string]string {

	start := closure(eps, SSet{q0: true})
	id := map[string]string{name(start): "D0"}
	dfa := map[string]map[string]string{"D0": {}}
	for queue := []SSet{start}; len(queue) > 0; queue = queue[1:] {
		S, sid := queue[0], id[name(queue[0])]
		for _, ch := range sigma {
			nxt := SSet{}
			for q := range S {
				for r := range move(q, ch) {
					nxt[r] = true
				}
			}
			T := closure(eps, nxt)
			tid, ok := id[name(T)]
			if !ok { // 只展开被实际触达的状态 —— eager 版; RE2 是 lazy 版
				tid = fmt.Sprintf("D%d", len(id))
				id[name(T)] = tid
				dfa[tid] = map[string]string{}
				queue = append(queue, T)
			}
			dfa[sid][ch] = tid
		}
	}
	return dfa
}

func accepts(dfa map[string]map[string]string, finalsOf func(string) bool, w string) bool {
	q := "D0"
	for i := 0; i < len(w); i++ {
		nx, ok := dfa[q][string(w[i])]
		if !ok {
			return false // 死状态: 提前失败
		}
		q = nx
	}
	return finalsOf(q)
}

5.2 复杂度

最坏 |DFA 状态| = $2^{|Q_{\text{NFA}}|}$. 这个上界紧: 接受 "倒数第 $k$ 个字符是 a" 串的语言, ε-NFA k+1 状态, 子集构造爆出 $2^k$ 状态 DFA. 这也是为什么 grep 不直接编译成 DFA 加载到内存——内存吃不起.

5.3 工程版: lazy + cache

Go regexp 与 RE2 用 lazy 子集构造: 边跑边缓存 (state-set, char) → state-set, 第一次 miss 才现场构造新 DFA 状态. 不预先展开全部 $2^n$, 把最坏情况推迟到真的见到大量不同输入时——大多数 regex 在真实流量里只触达很少的状态.


六、DFA 最小化: Hopcroft / Moore

6.1 Myhill-Nerode 等价

两个状态 $p, q$ 等价 iff 对任意后续 $w$: $\delta^(p, w) \in F \iff \delta^(q, w) \in F$.

最小 DFA 的状态数 = Myhill-Nerode 等价类数. 这是充要条件——pumping lemma 只能给"非正则"的下界, Myhill-Nerode 给充要.

6.2 Hopcroft 算法

$O(|Q| \cdot |\Sigma| \cdot \log |Q|)$, 比朴素 $O(|Q|^2)$ 快:

def hopcroft_minimize(dfa) -> DFA:
    # 初始划分: 终态 vs 非终态
    P = [dfa.F, dfa.Q - dfa.F]
    P = [s for s in P if s]
    while True:
        P2 = []
        changed = False
        for group in P:
            # 按"对每个 ch 后继在哪个 group"做细分
            subgroups = {}
            for q in group:
                sig = tuple(next_group_of(dfa.delta[(q, ch)], P) for ch in dfa.Sigma)
                subgroups.setdefault(sig, set()).add(q)
            if len(subgroups) > 1:
                changed = True
            P2.extend(subgroups.values())
        P = P2
        if not changed:
            break
    # 现在每个 P[i] 合并为一个 DFA 状态, 重建 delta/Q/F
    ...

工程意义: 最小化常能把 DFA 状态数砍掉一大截, 状态表变小直接换来更好的 cache 命中率——Rust regex 在编译期就把 NFA 精简到最小再落成执行结构, 思路同源.

6.3 真实版: Brzozowski 双反转

把自动机 反转 → 子集构造 → 再反转 → 再子集构造, 两轮下来得到的恰是最小 DFA——理论极美, 但每轮确定化都可能指数爆炸, 实践不如 Hopcroft 稳.


七、状态复杂度下界: Myhill-Nerode

为证 "L 不是正则", 用 pumping lemma 常被"counter example"反将. Myhill-Nerode 给充要, 强:

定理: $L$ 正则 iff Myhill-Nerode 等价类有限. 类数 = 最小 DFA 状态数.

等价关系 $\equiv_L$: $x \equiv_L y$ iff $\forall z: xz \in L \Leftrightarrow yz \in L$.

证 "L = {a^n b^n}" 非正则:

  • 取 $x_i = a^i$ for $i \geq 0$.
  • 对 $i \neq j$, 取 $z = b^i$, $x_i z = a^i b^i \in L$, $x_j z = a^j b^i \notin L$.
  • 故所有 $x_i$ 互不等价 → 类数无限 → 非正则.

八、桥梁: 接下来的章节

  • 正则表达式 ⇔ DFA: Kleene 定理证明正则表达式 = DFA 接受的语言.
  • regex 比 DFA 强吗?: PCRE 等支持 backreference, 让语言超出 Type-3, 需 PDA. 下一章会展开.
  • 泵引理 (Pumping Lemma): 给非正则的必要非充分反证工具, 见下一章 regular.md.

下一节 → 正则语言与泵引理

2. 正则语言与泵引理

TL;DR

正则语言 (Regular Language) = DFA / NFA / ε-NFA 接受的语言 = 正则表达式描写的语言. 三者等价由 Kleene Theorem 给出. 本章把"是否正则"这把尺子交给两个工具:

  1. Pumping Lemma: 必要但不充分——很多非正则语言也通过 pumping lemma, 用法多半是反证"语言非正则".
  2. Myhill-Nerode: 充要, 直接给出最小 DFA 状态数. 适合那些 pumping lemma 抓不住的反例.

读完后, 你能在面试/代码评审里立刻辨出"这语言是否需要栈/计数器"——这就是 parser 选型 (LL / LR / PEG) 的底层依据.

思想链

评审现场: "这个文本模式能不能用 grep 的正则写? 会不会有性能陷阱?"
  └─> 先问: 这门语言正则吗? 两把尺子
       ├─> pumping lemma: 必要不充分 —— 只能反证"非正则", 抓不住的反例一堆
       └─> Myhill-Nerode: 充要 —— 等价类有限 ⟺ 正则, 且类数 = 最小 DFA 状态数
             └─> a^n b^n 有无穷个互不等价的前缀类 → 非正则 → "计数"必须交给栈
                   └─> 工程映射: lexer 层只做正则; 需要"配对/计数"的留给 parser 文法层 (下一章)
                         └─> backreference (\1) 看似正则实超 Type-3 —— RE2/Rust regex 干脆不做

一、正则语言的形式化

记 $\cup$ 是并, $\cdot$ 是连接, $^$ 是 Kleene 闭包 ($L^ = {x_1 \cdot x_2 \cdots x_k \mid k \geq 0, x_i \in L}$).

归纳定义正则表达式 (over $\Sigma$):

  1. $\emptyset$ 与 ${\epsilon}$ 是正则.
  2. 单字符 $a \in \Sigma$ 的 ${a}$ 是正则.
  3. 若 $R, S$ 正则, $R \cup S$, $R S$, $R^*$ 正则.
  4. 仅由 1-3 出.

记集合 $\mathcal{R}(\Sigma)$. 形式化闭包集正是"正则语言".

1.1 三种语言的等价 (Kleene 定理)

$$ \text{正则表达式} ;\stackrel{\text{Thompson}}{\longrightarrow}; \varepsilon\text{-NFA} ;\stackrel{\text{子集构造}}{\longrightarrow}; \text{DFA} ;\stackrel{\text{state elimination}}{\longrightarrow}; \text{正则表达式} $$

证 $\varepsilon$-NFA $\to$ DFA 见上章; DFA $\to$ regex 用 state elimination: 把 DFA 转成带 regex 的 GNFA, 一个状态一个状态消去, 最后剩 start → accept 一边, 弧上即答案.

工程直接收益: awk pattern 是 DFA-able; Backreference (PCRE 的 \1) 让语言超出 Type-3 (e.g. (a+)\1 描等长 a 串拼接, 实质是 ${a^n a^n}$), 这就是为什么 PCRE 退回 backtracking + 无法 DFA.

note

这点常被工程师误解。RE2(Go 标准库 regexp 走的就是 RE2 路线)与 Rust 的 regex 引擎都故意不支持 backreference——一旦加上它,就再也回不到 NFA/自动机的线性时间保证,只能退回 backtracking。两者宁可少一个功能,也要保住 $O(n)$ 匹配时间。


二、闭包性质

正则语言对常见运算全闭合:

运算闭合性
并 $L_1 \cup L_2$
交 $L_1 \cap L_2$✓ (跑两个 DFA 同步)
补 $\overline{L}$✓ (终态/非终态对调)
连接 $L_1 L_2$✓ (ε-NFA 串接)
Kleene 闭包 $L^*$
同态像 $h(L)$
逆同态 $h^{-1}(L)$
差 $L_1 - L_2$

关键反例: CFL 不闭补——CFL 的补不一定是 CFL (证明依赖 pumping lemma/范式). 这是分析 parser 缺陷时的常见红 herring.


三、Pumping Lemma (泵引理)

3.1 直觉

正则语言本质"无远距离结构": 长串必有内段可反复 pump (重复任意次仍属语言), 因为 DFA 状态有限, 长串过程中必复读某状态 → 形成循环.

3.2 形式

若 $L$ 正则, 则 ∃ pumping length $p \geq 1$ 使任意 $w \in L, |w| \geq p$ 可拆 $w = xyz$ 满足:

  1. $|xy| \leq p$
  2. $|y| \geq 1$
  3. ∀ $i \geq 0$: $xy^i z \in L$

3.3 反证流程

证 $L = {a^n b^n \mid n \geq 0}$ 非正则:

  1. 假设 $L$ 正则, pump length $p$.
  2. 取 $w = a^p b^p, |w| = 2p \geq p$.
  3. 拆 $w = xyz$. 由 $|xy| \leq p$, $xy$ 内全是 a, 即 $y = a^k, k \geq 1$.
  4. 取 $i = 0$, $xy^0 z = xz = a^{p-k} b^p$ 不属 $L$.
  5. 矛盾. $\square$

→ 因此 $L$ 非正则; DFA 跟不住"还剩多少 a 要配多少 b"——必须用栈 (PDA).

3.4 局限: pumping lemma 非充分

反例语言: $L = {uwv \mid u, v, w \in {a, b}^*, |w| \text{ 不是素数}}$. 它满足 pumping lemma 但实际非正则. 验证跳过, 详见 Sipser 第 1.4 节练习.

实践上就用 Myhill-Nerode 给充要.


四、Myhill-Nerode: 充要条件

4.1 引理

定义 $L$ 上等价关系 $\equiv_L$: $$ x \equiv_L y \iff \forall z: (xz \in L) \Leftrightarrow (yz \in L). $$

定理 (Myhill-Nerode): $L$ 正则 iff $\equiv_L$ 的等价类数有限, 此数为最小 DFA 状态数.

4.2 用 pump lemma 也卡的例子

证 ${a^n b^n \mid n \geq 0}$ 非正则: 对每个 $i \geq 0$ 取前缀 $x_i = a^i$,

  • $x_i$ 与 $x_j$ ($i \neq j$) 不等价, 因为以 $z = b^i$ 接续: $x_i z = a^i b^i \in L$, $x_j z = a^j b^i \notin L$.
  • 故 ${a^i}$ 两两落在不同等价类, 类数无限 → 非正则. 论证比 pumping lemma 更直接——它同时说明了"任何 DFA 都必须为每个已读的 a 记一个独立状态", 这正是"有限状态装不下无限计数"的精确表述.

4.3 最小 DFA 上界

等价类 $[\epsilon], [a], [a^2], \ldots$ 互不相等, 最小 DFA 状态数 = 类数 = $\infty$, 因此非正则. 反过来, 若能证明等价类只有 $k$ 个, 就同时拿到了最小 DFA 的状态数——Myhill-Nerode 是唯一能把"非正则证明"和"最小状态数下界"一次给全的工具.

4.4 工程示例题: 判断 $L = {xyy^R x^R \mid x, y \in {a,b}^*}$ 是否正则

(其中 $s^R$ 表示串反转) 答案: 正则 —— 因为 $L$ 其实 = $\Sigma^*$ 全体串都满足 (取 $x = \epsilon$). 这种"形而上学难, 实则平凡"的题靠 Myhill-Nerode 数类一下就看清.


五、Brush-up: 速查反证法清单

要证非正则工具关键构造
$a^n b^n$pumping (取 $i=0$)pump a
$a^n b^n c^n$pumping (取 $i=2$)pump b → 一类增多
${w \mid \text{含 0/1 数相等}}$Myhill-Nerode$x_i = 0^i$
回文 ${w w^R}$pumping (取 $i=2$)pump 中段破坏回文
${a^{p^k} \mid k \geq 1}$Myhill-Nerode素数无关 → pump 都失败
${a^n \mid n \text{ 完全平方}}$pumping (取 $i=2$)$p^2 \to 2p^2$ 不是平方
${a^n b^m, n \neq m}$DFA 补 + pumping 反证$L$ 非正则 → 补 $L$ 非正则
${w \mid \exists k: w = a^k b^k}$证伪实际是 $a^* b^*$ ✓ 正则

最后一项提醒: "含某性质"的"是否存在 k"和"相等" 等价性极强, 不要急着用 lemma. 先想清楚 $L$ 是不是合于"两串是否相等"——平等性是 pumping 极不友好的 a 类.

tip

上表就是面试速查卡。用法口诀:先给语言归类——计数/配对类($a^n b^n$、回文)用 pumping,取 $i = 0$ 或 $i = 2$ 打破平衡;前缀比较类("两个前缀谁也替代不了谁")直接上 Myhill-Nerode 数类;拿到题目先怀疑"它会不会其实是正则"——一半的经典陷阱题(如 ${xyy^R x^R}$)答案是平凡的正则。


六、与其他章节的桥梁

  • 第三章 [CFG/PDA]: $a^n b^n$ 需要 PDA = CFG 接受, 进入 Type-2 语言层级;
  • 第五章 [不可判定]: Post Correspondence Problem 用 CFG 类比不可判定;
  • 第七部分 [系统设计] 的"为什么不能穷举所有可能入手路径" 跟 pumping lemma "DFA 必复读某状态" 的同构——资源有限自然形成循环.

七、一页速查

正则语言: regex ≡ ε-NFA ≡ NFA ≡ DFA (Kleene 定理); 对 并/交/补/连接/*/同态 全闭合
pumping:  存在 p, |w|>=p ⇒ w=xyz, |xy|<=p, |y|>=1, ∀i: xy^i z ∈ L —— 必要不充分
Myhill-N: x≡_L y ⟺ ∀z: (xz∈L ⟺ yz∈L); 类数有限 ⟺ 正则, 类数 = 最小 DFA 状态数 (充要)
经典反例: a^n b^n 非正则 (pump 取 i=0 / MN 取 z=b^i); {xyy^R x^R} 其实 = Σ* 正则!
backref:  PCRE \1 超出 Type-3 → 只能回溯; RE2 (Go regexp) / Rust regex 弃之保 O(n)
工程分界: lexer 只做正则; "配对/计数"交给 parser 文法层 —— 选型依据就在本章两把尺子

下一节 → 下推自动机 (PDA) 与上下文无关文法 (CFG)

3. 下推自动机 (PDA) 与上下文无关文法 (CFG)

TL;DR

正则语言表达力不够——它记不住"已经看到几个 a"以匹配同等数量的 b。给 DFA 加一个栈 (stack) 作为无界工作内存, 得到 PDA; 对应的文法类比叫 CFG (Context-Free Grammar)。两者语言等级完全等价 (Chomsky 层级 Type-2), 这是编译器 parser 理论的全部基础——LL/LR/LALR/Earley/CYK 无非是为不同 CFG 子类找高效算法。读完本章, 你能直接读得下第五部分编译原理 parser 一节不再绕。


一、CFG 形式定义

四元组 $G = (V, \Sigma, R, S)$:

  • $V$: 非终结符有限集 (变量).
  • $\Sigma$: 终结符有限集 (字母表) —— $V \cap \Sigma = \emptyset$.
  • $R$: 产生式集, 每条形如 $A \to \alpha$, $A \in V$, $\alpha \in (V \cup \Sigma)^*$.
  • $S \in V$: 起始非终结符.

派生 (derivation): $\alpha A \beta \Rightarrow \alpha \gamma \beta$ 当 $A \to \gamma \in R$. $\Rightarrow^$ 是其自反传递闭包. 语言 $L(G) = {w \in \Sigma^ \mid S \Rightarrow^* w}$.

1.1 例子: $a^n b^n$

$$G = ({S}, {a, b}, {S \to a S b \mid \epsilon}, S)$$

派生 $a^2 b^2$: $S \Rightarrow aSb \Rightarrow aaSbb \Rightarrow aabb$. 直观: $S$ 不停在被压在外环的 "a...b" 之中; 最后退化为 $\epsilon$ 收尾. 栈隐式存在: 每派生一条产生式右侧, "中心" S 像 stack 顶, 等右边终结符 b 弹出.

1.2 派生树 (Parse Tree)

每条产生式 $A \to \alpha$ 对应一棵子树 $A$ 作为根、$\alpha$ 字符从左到右作为子. 叶节点终结符顺序即派生串. 歧义 (ambiguity): 同串两种 parse tree → 不能确定 AST, 语义难定.

warning

文法的歧义是文法性质, 不是语言性质. 一些语言"先天歧义"——任一 CFG 都歧义. 如 $L = {a^i b^j c^k \mid i = j \text{ 或 } j = k}$. 给 L 已知先天歧义, 唯一的办法是过 PDA 之后再加一层语义检查.

1.3 LL vs LR

派生顺序影响 parser 实现:

  • 最左派生 (leftmost): 每步替换最左非终结符 → 对应 LL parser (自顶向下).
  • 最右派生 (rightmost): 每步替换最右非终结符 → 对应 LR parser (自底向上, reduce).

LL(1) = 用 1 个 lookahead 选产生式; LR(1) = 用 1 个 lookahead 选 reduce; LR ⊋ LL (LR 表达力大得多), 但 LR(1) 状态表大; LALR(1) 合并状态、状态数小, yacc/bison 走 LALR.


二、PDA 形式定义

七元组 $M = (Q, \Sigma, \Gamma, \delta, q_0, Z_0, F)$:

  • $Q$: 有限状态集.
  • $\Sigma$: 输入字母表.
  • $\Gamma$: 栈字母表 (含初始栈底符号 $Z_0$).
  • $\delta: Q \times (\Sigma \cup {\epsilon}) \times \Gamma \to 2^{Q \times \Gamma^*}$: 转移函数. 当前状态, 当前输入(或 ε), 栈顶 —— 推出新状态 + 要 push 入的串 (pop 栈顶再 push).
  • $q_0$: 初始状态. $Z_0$: 初始栈符号. $F$: 接受状态集.

2.1 两种接受方式

  1. Final state: 输入读完 + 当前状态 ∈ F.
  2. Empty stack: 输入读完 + 栈空.

二者表达力等价 (可互转). 工程多走 final-state 形式 因为 检查容易, dump 时栈可保留以 debug.

2.2 例子: 接受 $a^n b^n$

Q = {q0, q1, q2}, Σ = {a,b}, Γ = {Z0, A}, q0 = q0, Z0 = Z0, F = {q2}

δ:
    (q0, a, Z0) → (q0, A Z0)        # 第一个 a, push A 不变状态
    (q0, a, A)  → (q0, A A)         # 后续 a, push A
    (q0, b, A)  → (q1, ε)           # 看到 b, pop A, 进 q1
    (q1, b, A)  → (q1, ε)           # 继续配 b, pop A
    (q1, ε, Z0) → (q2, ε)           # 输入完, 栈回 Z0, 进 q2 接受

栈在这里就是把"还没配对的 a 数"记下来.

2.3 PDA 模拟器 (Python)

from typing import Set, Dict, Tuple, List, Optional

class PDA:
    def __init__(self, Q, Sigma, Gamma, delta, q0, Z0, F):
        self.delta = delta   # dict[(state, input or '', stack_top)] -> set of (state, push_str)
        self.q0, self.Z0, self.F = q0, Z0, F
        self.Sigma, self.Gamma, self.Q = Sigma, Gamma, Q

    def accepts(self, w: str) -> bool:
        # 当前 config 集合: (state, input_pos, stack as tuple bottom→top)
        start = (self.q0, 0, (self.Z0,))
        frontier = {start}
        # 限制总步数防爆 (PDA 半可解)
        for step in range(10000):
            nxt = set()
            for (q, i, stack) in frontier:
                if i == len(w):
                    # ε 转移
                    if stack:
                        top = stack[-1]
                        for (r, push) in self.delta.get((q, '', top), set()):
                            nxt.add((r, i, stack[:-1] + tuple(push)))
                    if q in self.F:
                        return True
                else:
                    ch = w[i]
                    if stack:
                        top = stack[-1]
                        for (r, push) in self.delta.get((q, ch, top), set()):
                            nxt.add((r, i + 1, stack[:-1] + tuple(push)))
                        for (r, push) in self.delta.get((q, '', top), set()):
                            nxt.add((r, i, stack[:-1] + tuple(push)))
            if not nxt or nxt <= frontier:
                break
            frontier = nxt
        return any((q in self.F and i == len(w)) for (q, i, s) in frontier)

PDA 模拟比 DFA 复杂——状态集理论上无限 (栈内容是无限信息), 所以更优雅的做法是直接转 CFG.


三、CFG ⇔ PDA 等价证明

3.1 CFG → PDA (顶部展开)

构造一个 PDA 用栈装载"待派生串"; 栈顶若是非终结符就展开某个产生式, 若是终结符就匹配输入消费.

具体:

  • 状态只一两个 (q_loop), 实质在栈上工作.
  • 转移: 读 ε 时若栈顶 = $A$, 任选 $A \to \alpha$ 替换栈顶为 $\alpha$ 反序 (使栈顶出 $\alpha$ 首字符).
  • 读字符 c 时, 若栈顶 = c, 匹配退栈.

接受: 输入读完 + 栈空. 这个构造用 ε-NFA 风格极简, 但 NFA 系列 size 巨大.

3.2 PDA → CFG (config 转非终结符)

技巧: 引入集合 $A_{pq}$ 表示 "从状态 p 空栈到状态 q 空栈的串集". 递归方程:

  • $A_{pq} \supseteq A_{pr} A_{rq}$ (走两段)
  • $A_{pq} \supseteq a A_{rs} b$ (一步 push a + 内部一段 + pop b)
  • $A_{pp} \supseteq \epsilon$

把这些方程变 productions, $S \to A_{q_0, f}$ for $f \in F$, 完成 CFG 构造. 证明细节见 Sipser 引理 2.20.


四、CFG Pumping Lemma

类似正则, CFL 也有 pumping lemma, 但两段而非一段:

定理: CFL $L$ 存在 $p$ 使任意 $w \in L, |w| \geq p$ 可拆 $w = uvxyz$ 满足:

  1. $|vxy| \leq p$
  2. $|vy| \geq 1$
  3. ∀ $i \geq 0$: $uv^i x y^i z \in L$.

直觉: 派生树的深层节点 (高度 > $h$ 的高度) 必复读同一非终结符; 找出对应两段可 pump.

4.1 经典用法: $L = {a^n b^n c^n}$ 非 CFL

设 pump length $p$. 取 $w = a^p b^p c^p$. $|vxy| \leq p$, 故 $vxy$ 只能覆盖两类字符 (要么 ab 段, 要么 bc 段). 设 $vy$ 不含 c, 则 pump $i = 2$ 多 a/b 不多 c → 数失去平衡. 类似若 $vy$ 只含 b 段也会失衡 a-c. 任何 split 都矛盾.

→ $L$ 非 CFL, 必须 TM.

4.2 反例: pump lemma 不充分

存在非 CFL 也满足 pump lemma (例如 Sipser 练习给的 $L = {a^i b^j c^k \mid i = j \text{ 或 } j = k}$ 满足 Lemma 但实际是 CFL——经典反白).

工程实践: 用 Ogden's lemma 给更强条件——指定"区分位"而非全覆盖.


五、CFL 闭包性质

运算CFL 闭合?
✓ (新初始 $S \to S_1 \mid S_2$)
连接
Kleene 闭包
同态像
逆同态
与正则交✓ (跑 PDA + DFA 同步)
✗ ($a^n b^n c^n$ = $a^n b^n$ ∩ $b^n c^n$ 都是 CFL, 交不是)
✗ (补非闭合 ⇒ 交非闭合同结果)

关键反差: 与正则语言交仍 CFL——parser 把 token-level DFA (lexer) 跟 CFG 协同 (parser 只接受 lexer 输出, 这正是 lexer+parser 的分层组合, 第五部分 compilers 已讲).


六、DCFL (Deterministic CFL) 子类

NPDA vs DPDA: NPDA 比 DPDA 表达力强; DPDA 接受的 CFL 子类叫 DCFL.

性质: DCFL 闭合于补, NPDA 不是. 这个看似理论细节, 工程极重要——

定理: LR(k) 文法 ⇔ DCFL 的子类; LL(k) ⊊ LR(k) ⊊ DCFL.

→ 编译器能做的语法分析子能力排序:

  • LL(1): 适合手写 parser, 速度快, 但表达力最弱 (无左递归, 1 token lookahead).
  • LR(1): 表达力=DCFL, 表状态巨大, 工程少用.
  • LALR(1): 合并 LR(1) 状态, 表小, bison/yacc 走它.
  • SLR(1): 简化版 LR(0) + follow 集, 较弱.
  • PEG (Parsing Expression Grammar): 引入有序选择 + lookahead + 痕迹 predicate, 表达力 ⊋ 任意 CFG (与超 CFL 重叠), 但定义即算法 (parser-combinator).
  • Earley: 任意 CFG, O(n³) worst-case, O(n²) for 任意歧义 CFG, O(n) for 几乎所有 LR(k). 适合需要随时改文法的 IDE 场景.

七、CYK 算法: 任意 CFG 的 O(n³) 解析

把 CFG 转 Chomsky Normal Form (CNF): 每条 production 形如 $A \to BC$ 或 $A \to a$ (二分). 然后 DP:

dp[i][j] = A: 子串 $w[i..j]$ 可由 A 派生.

def cyk_parse(G, w):
    # G: dict rules: {'S': [['A', 'B'], ['a']], ...}, w: string
    n = len(w)
    # T[i][j] = set of nonterminals that derive w[i:j+1]
    T = [[set() for _ in range(n)] for _ in range(n)]
    for i in range(n):
        for A, prods in G.items():
            for p in prods:
                if len(p) == 1 and p[0] == w[i]:
                    T[i][i].add(A)
    for length in range(2, n + 1):           # 子串长度
        for i in range(0, n - length + 1):   # 起点
            j = i + length - 1
            for k in range(i, j):
                for A, prods in G.items():
                    for p in prods:
                        if len(p) == 2 and p[0] in T[i][k] and p[1] in T[k+1][j]:
                            T[i][j].add(A)
    return 'S' in T[0][n-1]

复杂度 O(|G| · n³). 实践中字法 N 较小 (代码长度 < 10000 行) 仍能跑, 但编译主流走 LALR(1) 把 O(n) 拿到手——CYK 留给 IDE 增量编辑与自然语言 parsing.

7.1 TypeScript 实现

type Rules = Record<string, string[][]>;

export function cyk(G: Rules, w: string): boolean {
  const n = w.length;
  const T: Set<string>[][] = Array.from({ length: n }, () =>
    Array.from({ length: n }, () => new Set())
  );
  for (let i = 0; i < n; i++) {
    for (const [A, prods] of Object.entries(G)) {
      for (const p of prods) {
        if (p.length === 1 && p[0] === w[i]) T[i][i].add(A);
      }
    }
  }
  for (let len = 2; len <= n; len++) {
    for (let i = 0; i + len - 1 < n; i++) {
      const j = i + len - 1;
      for (let k = i; k < j; k++) {
        for (const [A, prods] of Object.entries(G)) {
          for (const p of prods) {
            if (p.length === 2 && T[i][k].has(p[0]) && T[k + 1][j].has(p[1])) T[i][j].add(A);
          }
        }
      }
    }
  }
  return n === 0 ? T[0]?.[0]?.has('S') ?? false : T[0][n - 1].has('S');
}

八、桥梁: 解析器就是受限 PDA

第五部分 compilers/parser 用 LL/LR 的本质, 是对 CFG 加 lookahead 约束让其可被确定性 PDA 处理. 一旦 CFG 不是 LR(k), 编译器就要么改写文法, 要么走 GLR/Earley 退回 NPDA + 全分支.

实战经验:

  • JSON 是 LL(2)-able, 主流手写 parser.
  • Java/TypeScript 是 LALR(1)-able 但有 reserved word 处理高复杂度, 倾向手写 Pratt + 化 recursive descent (Babel parser).
  • C++ 文法先天歧义, 工业必走 GLR 或打 multi-pass.
  • Python 缩进敏感, lexer 把 INDENT/DEDENT 当 token, 总体 LL(1) OK.

下一节 → 图灵机

4. 图灵机: deterministic / non-deterministic / Church-Turing

TL;DR

PDA 用栈做工作内存——但仍受单点读写限制。图灵机 (Turing Machine, TM) 把工作内存换成双向无限 tape + 可左右移动的读写头, 一下子获得了完整可计算性。本章把 TM 形式化, 解释 DTM vs NTM 等价 (在"算得动什么"层面), 并展示Church-Turing Thesis为何被认作"算法"的本质定义。Chruch-Turing 提供的"完全可计算"上界把后续章节 (不可判定, 复杂度) 的所有结论兜底——任何"算法" 函数必可由 TM 实现。


一、TM 形式定义 (7-tuple)

$$ M = (Q, \Sigma, \Gamma, \delta, q_0, q_{\text{accept}}, q_{\text{reject}}) $$

  • $Q$: 有限状态集.
  • $\Sigma$: 输入字母表, $\Sigma \subsetneq \Gamma$.
  • $\Gamma$: tape 字母表 (含空格符 $\sqcup$).
  • $\delta: Q \times \Gamma \to Q \times \Gamma \times {L, R}$: 转移函数 (DTM; NTM 用到 $2^{Q \times \Gamma \times {L,R}}$).
  • $q_0$: 起始状态.
  • $q_{\text{accept}}$ / $q_{\text{reject}}$: halting 状态, 不再走一步.

接受: 进入 $q_{\text{accept}}$ 即接受该输入。拒绝: 进入 $q_{\text{reject}}$ 或永远运行不停 (后者即"循环").

note

TM 与 DFA/PDA 最大不同: TM 可以循环——PDA 读完输入就有定论 (理应停), TM 不强制停下。这就是停机问题之根。 SVM 横高。

flowchart LR
    subgraph Tape["双向无限带: ⋯ ⋯ ⋯"]
        c0["w1"] --- c1["w2"] --- c2["w3"] --- c3["⊔"] --- c4["⊔"]
    end
    hd["读写头当前在 w2"]
    ctrl["有限控制: 当前状态 q<br/>δ(q, w2) = (q', w', L/R)"]
    hd -.读.-> c1
    c1 -.写后移动.-> hd

二、Instantaneous Description 与一步

ID 标记: $u,q,v$ 表示带内为 $uv$ (其余默认是 $\sqcup$), 状态 $q$, 读写头指向 $v$ 的首字符.

转移 $\delta(q, a) = (r, b, R)$ 表示: $$ u,q,a,v ;\vdash; u,b,r,v $$ (把当前格写 b, 状态变 r, 头右移一格).

同理 $\delta(q, a) = (r, b, L)$: $$ u,x,q,a,v ;\vdash; u,r,x,b,v $$

接受 $\Leftrightarrow$ $u_0 q_0 w \vdash^* u q_{\text{accept}} v$ for some $u, v$.


三、例子: 接受 $a^n b^n c^n$

构造如下:

1. 进入状态 q_loop: 不断在输入上扫
   - 看一个 a, 改写为 X, 走到第一个 b, 改 Y, 走到第一个 c, 改 Z, 回头
2. 重复直到全部 a 改 X (无 a 残留), 检查是否所有 b 都已改 Y, 所有 c 都已改 Z
3. 检查通过 → accept; 否则 → reject

伪代码 (typical TM 转移表):

当前态读到新态
q1 (找 a)aq2XR
q1Yq1YR
q1Zq1ZR
q1q_acceptR
q2 (找 b)aq2aR
q2Yq2YR
q2bq3YR
q2q_rejectR
q3 (找 c)aq3aR
q3Yq3YR
q3bq3bR
q3Zq3ZR
q3cq4ZL
q3q_rejectR
q4 (回 a)a/Y/Zq4(保持)L
q4Xq1XR

跨步回 X 就开始下一次扫描. 最后所有 a 都被 X 标记, 所有 b 都被 Y, 所有 c 都被 Z, 才按数匹配; 否则 reject.


四、TM 模拟器 (Python)

from typing import Dict, Tuple, Set, Optional
from collections import defaultdict

class TM:
    def __init__(self, Q, Gamma, delta, q0, q_accept, q_reject):
        self.Q, self.Gamma = Q, Gamma
        self.delta = delta   # dict[(state, symbol)] -> (next_state, write, 'L' or 'R')
        self.q0 = q0
        self.q_accept, self.q_reject = q_accept, q_reject

    def run(self, w: str, max_steps: int = 100_000) -> Optional[bool]:
        tape = list(w) or ['_']    # 至少一格以便读写
        head = 0
        q = self.q0
        steps = 0
        while q not in (self.q_accept, self.q_reject):
            if steps > max_steps:
                return None
            ch = tape[head] if 0 <= head < len(tape) else '_'
            if head < 0:
                tape.insert(0, '_'); head = 0
            if head >= len(tape):
                tape.append('_')
            r = self.delta.get((q, ch))
            if r is None: return False
            nq, write, mv = r
            tape[head] = write
            head += -1 if mv == 'L' else 1
            q = nq
            steps += 1
        return q == self.q_accept

注意 tape 双向无限是 lazy 实现: 越界时动态 prepend/append 空格.

4.1 TypeScript 实现

export type TM = {
  delta: Map<string, { q: string; w: string; mv: 'L' | 'R' }>;
  q0: string;
  qAccept: string;
  qReject: string;
};

export function runTM(m: TM, w: string, maxSteps = 100_000): boolean | null {
  const tape: string[] = w.length ? [...w] : ['_'];
  let head = 0;
  let q = m.q0;
  for (let step = 0; step < maxSteps; step++) {
    if (q === m.qAccept) return true;
    if (q === m.qReject) return false;
    if (head < 0) { tape.unshift('_'); head = 0; }
    if (head >= tape.length) tape.push('_');
    const t = m.delta.get(`${q}|${tape[head]}`);
    if (!t) return false;
    tape[head] = t.w;
    head += t.mv === 'L' ? -1 : 1;
    q = t.q;
  }
  return null;
}

五、DTM vs NTM 等价

非确定性 TM: $\delta: Q \times \Gamma \to 2^{Q \times \Gamma \times {L,R}}$. 接受 iff 存在一条 computation branch 到 $q_{\text{accept}}$.

定理: NTM $N$ 接受语言 $L$ iff 存在 DTM $M$ 接受 $L$. 证明思路: 不必模拟具体选择分支——用 BFS 探索 computation tree, dovetail (交错运行所有分支, 一边推进一边 elimination), 终会发现某接受态. 但模拟代价: 选 k 路 / 步, 深度 t → DTM 状态 $O(b^t)$, 指数级 slowdown.

关键意义: 在"算得了什么"层面二者等价; 在"算得多快"层面 NTM 有指数加速. 这就是 NP 的定义底座 (NTM 多项式时间 ⇒ DTM 可验证).

warning

二者在 $\mathcal{R}$ (递归) 与 $\mathcal{RE}$ (RE) 这一层等价; 在 P 与 NP 这层至今开放——P=NP? 这是七大千禧难题之一, 与 2002 年 Sipser 教材中 "P vs NP" 公开题对应.


六、TM 变体与等价

变体表达力
单带 DTM标准
多带 DTM等价 (k 带模拟单带 O(t²) 复杂度)
双向无限带等价单向无限带
2-stack PDA等价 TM
2-counter Minsky machine等价 TM (强反直觉)
Cellular automaton (1D)等价 TM
Tag system等价 TM
Quantum TM与 TM 在"可识别"层面等价; 复杂度差异由 BQP

Minsky 2-counter: 仅两个整数寄存器 + 加减一 + 零测试, 已是 Turing-complete. 这对 brainfuck / assembly 这类极简语言证明 Turing-complete 提供"模板".


七、语言层级

  • $\mathcal{R}$ (recursive / decidable): 存在 DTM 必停且正确判定的语言.
  • $\mathcal{RE}$ (recursively enumerable): 存在 DTM 接受所有 ∈ L 的输入, 但不在 L 上可能永不停.
  • co-$\mathcal{RE}$: $\mathcal{RE}$ 的补; 接受所有 not in $L$ 的输入.
  • $\mathcal{RE} \cap \text{co-}\mathcal{RE}$ = ${L \mid L \text{ and } \overline{L} \text{ both RE}}$ = $\mathcal{R}$. (经典定理)

关系: $\mathcal{R} \subsetneq \mathcal{RE}$. 停机问题 $H$ 在 $\mathcal{RE}$ 但不在 $\mathcal{R}$, 其补 $\overline{H}$ 不在 $\mathcal{RE}$. 这把语义 cap 给下一章.


八、Church-Turing Thesis

形式命题 (非定理, 是"论题"): "直觉上可计算的 = TM 可计算的". 验证工具:

  • Church (1936) 用 λ-calculus 给同样"算法"集合.
  • Turing (1936) 用 Turing Machine.
  • Post (1936) canonical systems.
  • Gödel (1934) general recursive functions.
  • Markov 算法.
  • 现代: 固定型编程语言 (Haskell/Python/C/A concrete Turing-equivalent).

之后所有自然计算模型都最终被证明等价于 TM. 但严格说只对"经典串计算"成立——量子计算机算 BQP ⊆ 不可解超越超多项差异, 但教会论题仍兼容这些模型 (BQP 仍可由 TM 模拟, 只是超多项慢).

8.1 对工程而言

任何图灵完备的编程语言 (C / Java / Python / Rust / Lua / Lisp / Brainfuck) 都能模拟任何其他图灵完备语言. 不能模拟的不可计算函数 (停机判定, virus detection) 在任何语言都不能模拟. 这给"程序分析不可能 100% 准确"纯粹形式化背书.


九、Universal TM (UTM)

存在可编码任意 TM 的 UTM: 输入 $\langle M \rangle w$ (TM 编码 + 输入), 模拟 $M$ 在 $w$ 上跑.

构造: 把 $\langle M \rangle$ 当 tape 上数据, UTM 用三层 loop 模拟:

  • 当前状态 + 当前置 读写头位置.
  • 查表 (用 $\langle M \rangle$ 解决) 找对应转移.
  • 改写 tape + 移头 + 改态.

意义: 计算硬件可"程序即数据"——这条原理 1945 直接孕育了 Von Neumann 架构. 至今 CPU 编译产物存指令在内存里跑, 同形存储, 直接源自 UTM.


十、Busy Beaver (忙碌海狸)

记 $\Sigma(k)$ = k 状态 TM 跑出的最大 1 个数后停; $S(k)$ = 最大 steps 后停.

Rado 1962 提出, 是"小但不可计算"序列典型代表:

k$\Sigma(k)$$S(k)$
111
246
3621
413107
5≥4098 (2022 推到 ≥47M)≥47M (iterates Graham)
6>10↑↑15(实不可推算)

$\Sigma(k)$ 增长 超过任何可计算函数 $f$ (无论多快). 证明: 反证, 设可计算, 则构造 TM $M$: 找到 $k$ 使 $\Sigma(k) > n \cdot \log_2 f(m)$, 把 $f$ 公式嵌入循环——可自我产生矛盾.

工程意义: 与 Ackermann 函数 (DSA 数论章给出) 同级反直觉"非可计算"实例; 任何自诩"确定上限"的代码静态分析都面临 Busy Beaver 镜面——你不可能证明任意 100 行 C 程序在合理时间内停.


十一、与其他章节的桥

  • 下一节 [不可判定]: 把"$H$ 不在 $\mathcal{R}$"展开. Rice 定理把"判定程序任意非平凡语义性质" universalize fail.
  • 第五部分 [compilers]: 解析器是 PDA, 但除了 CFG 之外, 类型推断 (Hindley-Milner) 是有限阶语法——用 TM 派生不会爆炸. 实验证: 全 ML 类型系统等价一种有限阶 Polymorphic λ-calculus, 而后者图灵完全.
  • 第八部分 [computer-arch]: 物理 CPU + 内存 = TM 实体化; quantum GPU = BQP 实体化.

下一节 → 不可判定性

5. 不可判定性: 停机问题、Rice 定理

TL;DR

1936 年 Turing 证明: 不存在 算法判断任意程序 $\langle M \rangle$ 在任意输入 $w$ 上是否会停. 这个被命名为 Halting Problem 的命题, 把"程序能关于自己说什么"封死了一个明确上限. Rice 定理进一步把所有非平凡语义性质 (而非语法) 全部锁死——"是否恶意代码"、"是否使用了网络"、"是否会输出某字符串"、"是否会蒸发内存"——都不可判定. 这就是为什么静态分析 (Clang Static Analyzer, Rust Borrow Checker, SonarQube) 必须牺牲完整性换可解——所有"超安全"声称必含 false positive 或 false negative.


一、Halting Problem

1.1 形式定义

记 $H = { \langle M, w \rangle \mid M \text{ 接受输入 } w \text{ 时停 (接受或拒绝都算停)} }$. $H$ 是 RE 但不是 $\mathcal{R}$.

1.2 对角线反证 (Turing 1936)

设反设: $H$ 可判定, 即存在 DTM HALT(M, w) 返回 true 当且仅当 $M$ 在 $w$ 停机.

构造新的 DTM D:

def D(M):                       # 输入是 TM 编码 M
    if HALT(M, M):              # 询问 M 在自身编码上是否停
        loop_forever()          # 否则不停
    else:
        halt()

问: D(D) 停吗?

  • 若停 ⇒ HALT(D, D) = True ⇒ D 调到 loop_forever() ⇒ 不停. 矛盾.
  • 若不停 ⇒ HALT(D, D) = False ⇒ D 调到 halt() ⇒ 停. 矛盾.

故 $H$ 不可判定. $\square$

1.3 Python 模拟 (直观感)

def halt(prog_str: str, input_str: str) -> bool:
    """
    Hypothetical halting oracle — would have to exist in some 'magic' world.
    Real-world: 这里只是 placeholder, 假设它存在.
    """
    raise NotImplementedError("impossible by diagonalization")

def D(prog_str: str) -> None:
    if halt(prog_str, prog_str):
        while True: pass    # loop forever
    else:
        return             # halt cleanly

# D 的源码
D_src = inspect.getsource(D)
# 询问 D(D) — paradox

虽然 Python 视角下也只是把"如果 HALT 存在就坏"做了一道逻辑链——同样告诉任何 ≥ Turing-complete 的语言这条 cap 都挂顶.


二、归约 (Reductions)

把"如果 $B$ 可判定则 $A$ 可判定"形式化: $A \leq B$. 反证 $A$ 不可判定 ⇒ $B$ 不可判定.

2.1 Many-one reduction $A \leq_m B$

存在可计算 $f: \Sigma^* \to \Sigma^*$ 使 $x \in A \Leftrightarrow f(x) \in B$. 即把 $A$ 的实例编码成 $B$ 的实例.

2.2 Turing reduction $A \leq_T B$

存在以 $B$ 为 oracle 的 DTM 解 $A$. 比 $\leq_m$ 弱但更直观.

2.3 使用范式

证 $L$ 不可判定:

  1. 取已知不可判定的 $A$ ($\subset \mathcal{R}$), 例如 $H$.
  2. 构造可计算 $f$ 使 $\langle M, w \rangle \in A \Leftrightarrow f(\langle M, w\rangle) \in L$.
  3. 若 $L$ 可判定, $A$ 也可判定—矛盾.

三、Rice 定理 (1953)

定理: 任意非平凡性质 $\mathcal{P}$ of RE language ($\emptyset \neq \mathcal{P} \neq \text{所有 RE}$), 集合 ${ \langle M \rangle \mid L(M) \in \mathcal{P} }$ 不可判定.

即对任意对 TM 语言的非平凡语义问题, 都不存在算法判定它.

3.1 证明骨架

把 $H$ 归约到 $\mathcal{P}$:

  • 任选一个 $L_{\text{yes}} \in \mathcal{P}$, 一个 $L_{\text{no}} \notin \mathcal{P}$.
  • 对 $\langle M, w \rangle$, 构造 $\tilde M$:
    • ignore input $x$;
    • 模拟 $M$ 在 $w$ 上跑:
      • 若停 ⇒ 接受 iff $x \in L_{\text{yes}}$.
      • 若不停 ⇒ 持续 loop.
  • 则 $M(w)$ 停 $\Rightarrow L(\tilde M) = L_{\text{yes}} \in \mathcal{P}$; 不停 $\Rightarrow L(\tilde M) = \emptyset \notin \mathcal{P}$ (因 $\mathcal{P} \neq \emptyset$ 充分满是 trivially 取 $L \in \mathcal{P}$ 不为空)——要保证 $\emptyset \notin \mathcal{P}$, 可选注意力放: 如果 $\emptyset \in \mathcal{P}$, 则 inverse membership 用同样构造绕到补.

3.2 实例

性质 $\mathcal{P}$ of $L(M)$不可判定性来源
$L = \Sigma^*$ (M 接受一切)Rice
$L = \emptyset$ (M 拒绝一切), 即永远不会停 acceptRice
$L$ 不是空 (M 至少接受一个)Rice (且属 RE — 半可判定)
$L = L_{\text{ref}}$ for fixed $L_{\text{ref}}$Rice
$L
$w_0 \in L$ for fixed $w_0$Rice (即 $H$)
$L$ finiteRice
$L$ regularRice
$L$ 上下文无关Rice

warning

Rice 仅说"语义"性质。语法性质有时可判定: "M 在前 100 步内访问 cell 12"可判; "M 长度 < 100" 可判; "M 是否曾用过某指令" 可判 (有界步内即可). 静态分析器就卡在"语法可判, 语义不可判"分界线.


四、其他经典不可判定问题

4.1 Post Correspondence Problem (PCP)

给一组 domino $[(t_1, b_1), \ldots, (t_n, b_n)]$ (每片上方串 + 下方串), 问能否挑序 (允许重复) $i_1, \ldots, i_k$ 们上面串拼接 = 下面串拼接.

Emil Post 1946 证明 PCP 不可判定 (用 TM config history 归约). 即拼字谜不可判定. 由 PCP 立即立: 上下文无关文法的歧义不可判定 (PCP $\leq$ 歧义检查).

4.2 Hilbert's 10th (Diophantine)

存在整数多项式方程的有整数解? — 不可判定. Yuri Matiyasevich 1970 终结了之. 直接证明"数论方程的解数非可计算函数" (引出 Mandelbrot-like 几何限制).

4.3 Wang tiles / Domino tiling

能否铺整个平面? — 不可判定.

4.4 Word problem in groups (Novikov-Boone)

群论中, 是否任意两字等价? — 不可判定. 这给"代数定理机器证不可判"。

4.5 Collatz conjecture

至今未证 —— 但已证广义 Collatz 不可判定 (停机归约到 Collatz 演算路径).

4.6 第十/awk 模型

awk regex 是否能匹配任意串 — 若允许 backreference, 不可判定 (regex 中嵌入 TM encoding).


五、Rice-Shapiro (半可解)

把 Rice 推化到"哪些性质可在 RE 而非 $\mathcal{R}$":

Rice-Shapiro: 性质 $\mathcal{P}$ 在 RE 半可解 iff 存在有限集 $D$ 的并集来描述: $$ \mathcal{P} = { L \mid \exists \text{ finite } D \subseteq L, D \in \mathcal{F}} $$ 某个汇集的可计算枚举 $\mathcal{F}$.

直觉: 在 RE 模型下, 机器只能"看到"有限 prefix → 只能基于有限 prefix 半-custom 解.

工程意义: 多数静态分析器实现都是 $\mathcal{RE}$ 半可解 ——它们能枚举"出错原因", 找到就警告, 找不到就静默 (即放弃 false negative 不放弃 soundness).


六、Arithmetic Hierarchy

把 $\mathcal{RE}$, co-$\mathcal{RE}$ 推广到 $\Sigma_n^0$, $\Pi_n^0$:

  • $\Sigma_0^0 = \Pi_0^0 = \mathcal{R}$.
  • $\Sigma_{n+1}^0$ = "存在 $x$, $R(\cdot, x)$" where $R \in \Pi_n^0$.
  • $\Pi_{n+1}^0$ = "对一切 $x$, $R(\cdot, x)$" where $R \in \Sigma_n^0$.

$H \in \Sigma_1^0$, $\overline{H} \in \Pi_1^0$. Totality "$M$ halts on all inputs" $\in \Pi_2^0$ — 比停机更高一层, 既不在 $\Sigma_1^0$ 也不在 $\Pi_1^0$. 这是"经量化变元深度"递归消灭的可计算子集度.

Beyond: $\emptyset^{(n)}$ = 第 $n$ 步 jump, 不可与之判定 (单个 intuition: 多 jump oracle 间有不可比性 — Post's theorem).


七、实践路线: 工程上怎么办

不可判定 ≠ 不能做. 工程师用三招绕:

7.1 限制子语言

把分析对象限制到不可判定的子语言 (语法层 vs 语义层):

  • Rust borrow checker: SSA-style lifetime, 没有递归 (在禁 recursive function 后), decidability.
  • Petri nets: reachability 实际decidable (虽然 EXPSPACE-hard), 替代 TM model of concurrency.
  • Linear/affine typing (Linear ML): 用类型化截掉时间复杂性.

7.2 近似/精化

放弃 sound 或 completeness:

  • Taint analysis: 假定某些 path impossible, 简化但可能漏报.
  • Abstract interpretation: 把 concrete domain 映射到有限抽象 domain, 工作在 abstract config 间 — 真值保 sandwich 但精度可能差. Cousot 1977.
  • Symbolic execution + bound: 给 step limit. KLEE/SAGE 跑有限步, 不穷尽代码空间.

7.3 演绎而非验证

不判断"是否满足", 而找出**反例 (counter-example)': SMT solver (Z3 / CVC5) 反向找反例. SAT-based bounded model checking (CBMC) 在 n step 内证"无 bug 路径"; 若证不出, 调步 limit.

note

这就是 Rust borrow checker 工作的本质类: 它的"安全复位"是按线性类型 + borrow scope 限制的有界步 (limited decode), 编译器能证。不是分析任意程序的 alias, 而是"在 SSA + 线性借用语义"子语言里查借用规则.


八、桥梁

  • 类型系统: HM (Hindley-Milner) 类型推断要决定所有子项可判定, 必须"不图灵完全". 第五部分 compilers/sema/type-system 讲为什么 ML 是完全可判定 type infered; TC (Type Classes) / Scala implicits 实际不可判定 (resolution 可触发任意环).
  • 软件验证: Frama-C, Why3, Agda, Coq 都依赖让目标被动先化到可判子集.
  • 第八/七部分: Kubernetes controller "解释为什么状态尚未 reconcile"在理论上是 不可判定 (encoding 应用侧特别路上的 cfg) — 实践用 YAML schema bounds.

下一节 → Complexity Classes

6. Complexity Classes: P / NP / NPC / co-NP / PSPACE

TL;DR

"可计算"分水岭只是 0/1 (停不停);"可高效计算"则是另一道连续谱. 把"高效"形式化为多项式时间, 得到最经典的一条链:

$$ \text{L} \subseteq \text{NL} \subseteq \text{P} \subseteq \text{NP} \subseteq \text{PSPACE} \subseteq \text{EXPTIME} $$

谁知道哪些包含关系是真/假, 哪些是著名的开放问题——P vs NP 是七大千禧难题之首, 而一系列等价关系 (NPC = NP 的"最难点") 把具体"自家问题难不难"的判断简化为一次归约测试.


一、复杂度模型基础

1.1 输入大小 $n$

复杂度按输入长度 $n = |\langle x \rangle|$ 度量. 用笛卡尔编码即可 (例如 $n$ 个 vertex 的 graph 编码长 ≈ $n^2$).

1.2 多项式 = "可行性"

Cobham-Edmonds thesis (1964–1965): "多项式时间 = 现实可行". 理由:

  • 多项式 → 随硬件 doubling, 解题规模积极涨幅 polynomial 多项.
  • 阶不影响 too much: n³ 比 n⁵ 提速 2 个数位也不致 broke.
  • 算法稳定: 多项式复合仍多项式.

实践冲淡: $n^{10}$ 还是炸. 但学界平均固定边界, 目标投篮统一照 polynomial. 这是 NP-complete 论文体系所有结构性的来源.

1.3 TM (理论) vs RAM (工程)

实际 CPU 不会按单带 TM 跑. RAM 模型 (单位操作 → O(1)) 与 TM ≥ 多项式 diff (RAM 上 O(n log n) sort ⇔ TM 上 O(n log² n)); 但 polynomial 边界不一致, Cobham-Edmonds thesis 跨模型 robust.


二、类 P

$P = \bigcup_{k \geq 0} \text{TIME}(n^k)$. 现成例子:

问题复杂度
排序$O(n \log n)$
最短路 (Dijkstra)$O((n+m)\log n)$
2-SAT$O(n+m)$
线性规划 (Interior Point)$O(n^{3.5} \log \epsilon^{-1})$
最大匹配$O(n^{2.5})$
素数判定 (AKS)$O(\log^{7.5} n)$
Matrix 矩乘$O(n^{2.373})$ (理界); 工程用 $O(n^3)$

工程经验: P 的问题不要直接套 NPXX horror 实装——必定有 P 算法没找出. (DSA 章节帮你)


三、类 NP: 验证的多项式等价

3.1 两种等价定义

(E1) NDTM: NTM 接受的语言 (存在接受 computation branch), 且多项式 step 内必停.

(E2) 多项式可验证: $L \in \text{NP}$ iff 存在 DTM $V$ 与多项式 $p$, $x \in L \Leftrightarrow \exists c, |c| \leq p(|x|)$ 使 $V(x, c) = 1$ 在 poly 时间. 这里 $c$ 叫证书 (certificate).

二者等价 (NTM 路径作为 certificate).

3.2 经典 NP 成员

问题证书验证式
SAT (满足性)真值指派substitute & check
3-coloring着色方案检查每条边两端异色
TSP decision路径顺序长度 ≤ k?
Hamiltonian cycle顶点序check 每邻接 + 起终相同
Subset-sum子集sum 检查
Integer programming整数解验证 $Ax = b, x \geq 0$
Clique顶点集检查子集内全连边

3.3 验证是 poly; 搜索是 exp

def verify_sat(formula, assignment) -> bool:
    return formula.subs(assignment) is True   # O(|formula|)

def solve_sat(formula, n_vars):
    for asg in itertools.product([False, True], repeat=n_vars):
        if verify_sat(formula, asg):
            return asg
    return None                                # worst: 2^n 次 verify

这就是 NP "比 P 多了一个 verify-polynomial" 的核心.


四、NPC: NP 的"最难点"

4.1 定义

$A$ NPC iff:

  • $A \in \text{NP}$;
  • 对任意 $B \in \text{NP}$, $B \leq_p A$ (多项式归约).

4.2 Cook-Levin (1971)

定理: SAT is NPC.

证明直觉: 任 NP 问题等价存在 NTM M 接受 $x \in L$. M 跑多项式 step 内停 ⇒ computation tableau 的网格能用 polynomial-size formula 描述 (每格"前一邻格 → 当前格"由 M 的转移函数确定的局部 constraints). 公式大小 poly, 满足 ⇔ M 接受. 故 NP 语言全部归约到 SAT.

这是 Cook-Levin 模板, 一切 NP 完备性的"种子".

4.3 第二个 NPC: SAT → 3-SAT

引入"clauses-of-3"标准形式. 配合 Karp reduction (真多项式), 把 SAT 的 SAT $\to$ 3-SAT 把"任意 clause 长度" 整成等长 3 clauses. 进 1972 Karp 论文: 21 个独立 NPC 问题.

4.4 NPC 验证清单

证 $A$ NPC:

  1. $A \in \text{NP}$ (给证书 + 验证器).
  2. 取已知 NPC 的 $B$, 构造多项式 $f$ 使 $x \in B \Leftrightarrow f(x) \in A$.

工程抒发: 看到 NPC 问题意味着"暂时不要花时间找 poly 算法, 因为如果找到, P=NP".

warning

NPC 不等于"无解" — 也不表示"必指数级". 一些 NPC 实际有"sub-exp"满足算法 (3-SAT 可以到 $1.31^n$ 而不是朴素 $2^n$). 工程关心参数化 (FPT)、近似 (PTAS) 等. (见 approximation.md)


五、co-NP

5.1 定义

$L \in \text{co-NP}$ iff $\overline{L} \in \text{NP}$.

直觉: 答案是"否"拥有多项式可验证的证书. 例:

  • UNSAT ("这个公式不可满足") ∈ co-NP. 证书: ... 没有任何简洁证书, hence 普遍认为 co-NP ≠ NP, 但虽未证.
  • TAUT ("所有真值指派都使公式真") ∈ co-NP.

5.2 P 显然在 NP ∩ co-NP

任何 P 问题, 答案是/否都可直接计算. 故 $P \subseteq NP \cap co-NP$. 反向开放.

5.3 重要成员: FACTOR

整因式分解语言: $$ \text{FACTOR} = {(n, k) \mid n \text{ 含因子 } d, 1 < d \leq k} $$

属于 NP (证书: $d$; 验证 $d | n$) 也属于 co-NP (证书: 素因式分解全部证明 factor; 验证 $d \cdot e = n$ 与 PRIMES ∈ P). 因此 FACTOR ∈ NP ∩ co-NP.

→ 业内大量证据支撑 FACTOR ∉ NPC, 因为若 FACTOR NPC, 则 NP = co-NP (推出一系列遭遇). 这恰好 crypto 安全假设: RSA 困难假设 factoring 困难, 但不能是 NPC; 否则密码学界塌缩.

5.4 交叉层关系

co-NP
   ↑       NP
   \      /
    \    /
  NP ∩ co-NP
      |
      P

六、PSPACE: 内存多寡

$PSPACE = \bigcup_k SPACE(n^k)$. 包容关系: $$ P \subseteq NP \subseteq PSPACE, \quad P \subseteq co\text{-}NP \subseteq PSPACE $$

6.1 QBF 是 PSPACE-complete

量词布尔公式 (Quantified Boolean Formula): 形如 $\exists x \forall y \exists z, \phi(x, y, z)$. PSPACE-complete by alternating TM 等价 (Chandra-Kozen-Stockmeyer 1981).

6.2 PSPACE 典问题

  • QBF decision
  • generalized chess (n×n 棋盘) — EXPTIME-complete (略超 PSPACE).
  • generalized Go (n×n) 含 no-pass 规则 — EXPTIME.
  • million-step planning in STRIPS — PSPACE-complete.

工程: PSPACE 经常表示"完整棋/puzzle 类" — 把 backtracking 但要保留 path 信息完整.

6.3 PSPACE ⊆ EXPTIME

实际严格 less: PSPACE ⊆ EXPTIME 已证, PSPACE = EXPTIME 未知. 已知 P ⊊ EXPTIME (Time Hierarchy Theorem).


七、其它经典层级

7.1 L / NL (log-space)

  • L: $O(\log n)$ 工作带 TM 决定问题. 解 ST-connectivity ($u \to v$ 在有向图上可走?) 实际 NL-complete. Reingold 2004 证 ST connectivity in undirected graph ∈ L.
  • NL ⊆ P: 因 config 总共 $n^{O(1)}$ 个所以可仿真整图.

7.2 Savitch 定理

$\text{NSPACE}(s(n)) \subseteq \text{SPACE}(s(n)^2)$. 直觉: DTM 用递归子调用, 每层记录分叉 config $s(n) \cdot \log$ 栈 → 总二次.

→ NPSPACE = PSPACE. (把非确定性的代价翻平方成确定性的空间)

7.3 NC / AC (parallel)

$NC^k$ = poly-size depth $O(\log^k n)$ Boothlean circuits 输出. NC = ⋃. 例子: $\text{sort} \in NC$ (parallel radix sort) ; $\text{Circuit Value} \in P$-complete 是 "inherently sequential" 证据.

7.4 随机化类

Class含义
RP"若 x ∈ L, 算法有 ≥ 1/2 概率接受; 若 x ∉ L, 必 reject"
coRP对称
BPP"无论 x ∈ L 与否, 算法有 ≥ 2/3 概率给正确答案"
ZPPRP ∩ coRP, "Las Vegas: 一直正确, 可能花更多时间"

广为猜测: $P = BPP$ (Adleman 证明 BPP ⊆ P/poly, Impagliazzo-Wigderson 1997 给出在合理 hardness 假设下的 derandomization ).

7.5 量子类

  • BQP: 量子 poly-time > 2/3 概率正确.
  • Shor's algorithm 把 FACTOR ∈ BQP. Grover 把 unstructured search 从 $O(n)$ 到 $O(\sqrt n)$ (建议 NP 任何问题 $\Omega(\sqrt n)$ 是下界).
  • BQP ⊆ PSPACE, 与 NP 关系不明朗 but plausible: NP ⊄ BQP (intellectual Q-computing 界主流观点), 否则整个 Computational Geometry 现代 crypto unraveling.

八、Oracles 与 relativization 障碍

为理解 P vs NP 为何难, Baker-Gill-Solovay 1975: 存在 oracle $A$ 使 $P^A = NP^A$, 又存在 oracle $B$ 使 $P^B \neq NP^B$. ⇒ "对角线论证"这种"证明停机问题"的工具单独 对 P vs NP 不够 (relativizing barrier).

其后 Barrington et al. 给出 arithmeticization + natural proof barrier (Razborov-Rudich 1994) — 简化证明 attempt 全部 fall short of P vs NP.

2010s 主流方法: algebraic geometry + Geometric Complexity Theory. 至今未破屏障.

工程经验: 不要自己宣称 "我证了 P=NP"; 几乎一定有错. 像 Deolalikar 2010世纪以来影响最大的尝试以非现实 locality 假设而失败.

note

业界假设 P ≠ NP 是 Cryptography RSA / 椭圆曲线安全的底座. 严格说, RSA 安全只假设 factoring 困难; factoring 困难 ≠ NPC难, 它属 NP ∩ co-NP. 真正"NP 困难"被假设需要 whole-NP 拼反抗数十亿亚马逊实例才行, 不严格满足 NP 困难, 但就 Showstopper 角度锲般.


九、工程应用两路径

9.1 看到 NP-hard 时怎么办

承认事; 不要"找多项式算法"; 大致三个出路:

  • 近似: 给 $\epsilon$ 提出可证 $|OPT| \cdot (1+\epsilon)$ 内 PTAS.
  • 参数化: 引参数 $k$, 找到 $O(2^k \cdot \text{poly}(n))$ 算法 (FPT).
  • 启发式 + 局部搜索 / 遗传算法 / 强化学习: 无证明但经验例: vertex cover 用 LDS 在 5% 实例达 optimum.

9.2 看到 NPC 时甚至要先识别

把新问题简化到 NPC 标准范式后, 用 8 个经典 NPC 文题本查询 (Garey-Johnson 红宝书)。常见:

  • 0/1 Knapsack (weak-NP): 实际可做 FP, $O(n \cdot W)$
  • TSP (decision form): strong-NP, 不友边权巨数.
  • Set cover: 强 NPC 且 inapproximation barrier $\Omega(\ln n)$.

十、桥梁

  • 下一章 reduction: 给例: 3-SAT → Clique → Vertex Cover → Hamiltonian → TSP. 当你看新问题想"是不是 NPC", 直接用 reduction 链横立.
  • 第八章 approximation: NPC 之后, 转手看 PTAS, APX-hard, hard-of-approximation.
  • 第六部分 distributed: Paxos/Raft 在异步系统**≠ PSPACE/EXPTIME** 问题, 而是 FLP impossibility — 异步网络含 crash failure, 共识无确定算法. 这是另一维"不可解" (区别于本章语义解码).

下一节 → Polynomial-time Reductions

7. Polynomial-time Reductions: 3-SAT → Clique → Vertex Cover → Hamiltonian → TSP

TL;DR

归约链是 NPC 理论的"瑞士军刀". 学会一条归约, 你把"我新问题 A 是否 NPC" 化为"能否找到已知 NPC 的 $B$ 并多项式把 $B$ 实例编码成 $A$ 实例". 1972 年 Karp 给出 21 个经典 NPC 问题的归约网; 1978 Garey-Johnson 红宝书系统化. 本章给你 5 条最实用归约, 让你看新问题立刻判断难度.


一、归约的形式化

$A \leq_p B$ iff 存在多项式可计算 $f$ 使 $x \in A \Leftrightarrow f(x) \in B$. 性质:

  • 传递: $A \leq_p B & B \leq_p C \Rightarrow A \leq_p C$.
  • 反: 若 $B \in P$, $A \in P$.
  • 用法识别 "A 是否 NPC": 已知 NPC $C \leq_p A \Rightarrow A$ NP-hard.

Bridge with complexity.md: 经典 NP-complete 证明骨架, 可以把 Cook-Levin SAT — 找出一条 poly reduction chain.


二、3-SAT $\to$ Clique

2.1 双方定义

  • 3-SAT: 给 CNF 公式 $\phi = \bigwedge_{i=1}^m C_i$, 每 $C_i$ 是 $\geq 3$ literals OR; 问是否有指派使 $\phi$ true.
  • Clique: 给图 $G, k$; 问 $G$ 是否含 size $\geq k$ 的全连子图.

2.2 归约

把 3-SAT 公式转 graph:

  • 每个 clause $C_i$ 引入 3 vertex, 一一对应一个 literal.
  • 加入边 $(u, v)$ for all $u, v$ 不同 clause 且 $u, v$ 不矛盾 (不是 $x, \neg x$ 命 Semantic 同).

问: G 有 $k=m$ 的 clique?

正确性:

  • 若 $\phi$ 满足: 每 clause 至少一 true literal; 每 clause 取一选满足 vertex 入 clique; 因选入两两不矛盾 (因 $\neg x$ 与 $x$ 不能同时 true), 两两都连边.
  • 反, 若 $G$ 含 size $m$ clique: 因每 clause 内部顶点全不连 (clauses 内不连 any 边), 必每 clause 出一个 vertex 入 clique; 该 vertex 对应 true literal ⇒ 指派; 故 $\phi$ 满足.

多项式构造, size $O(m \cdot 3) = O(m)$ vertex, $O(m^2)$ edge ⇒ poly.


三、Clique $\to$ Vertex Cover

设 $G, k$ 实例为 clique. 取补图 $\bar G = (V, \bar E)$, 即所有在 $G$ 中没有的边都在 $\bar G$ 中.

  • $G$ 有 $k$-clique $\Leftrightarrow$ $\bar G$ 在 $\bar E$ 上"对所有边两两不相邻 $→$ 选 $n - k$ 顶点 cover"

更准确, 用独立集 (Independent Set) 中间桥. IS 不相邻顶点集. IS(G, k) ⇔ Clique(补图 G, k). 又 IS $\Leftrightarrow$ VC: $G$ 有 $k$-IS ⇔ $G$ 有 $n-k$-VC (顶点 cover; 任非 VC → 其经的边没 cover → 是边就 toggle off vc 端 = independent).

工程 shorthand: clique / IS / VC 三连环, 构造相同.


四、3-SAT $\to$ Hamiltonian Cycle (direct)

构造 graph $G$ 含"variable gadgets" $+$ "clause gadgets". 每个 variable 产生图链"取 true 路径 vs 取 false 路径"; 每个 clause "给 jam ride-through 顶点" 必须有某 variable 选自洽所反. classic Garey-Johnson §3.1.3, 实现复杂. (size O(mn) 量级)

工程经验: Hamiltonian Cycle NPC 证明 → 顺推 TSP NPC.


五、Hamiltonian Cycle $\to$ TSP (decision)

给图 $G$, 是 hamiltonian? 构造完全图 $K_n$, 给 weight 1 if $(u,v) \in E$, else 2. 设 upper bound $k = n$. 有 hamiltonian $\Leftrightarrow$ 有 cost $n$ 的 TSP tour.

这个简单到 bake 但清楚 TSP 也 lock-into NPC.


六、Hamiltonian $\to$ Subset Sum $\to$ 0/1 Knapsack

复杂归约链, 子和集 - 用"数字 carry" 把 vertex 看成"数字位重量" T-spaces 中合并 - 把"顺序走 vertex" 变"指数集" encoding. 详细见 CLRS 第 34 章.

弱 vs 强: 0/1 Knapsack 是 weak NPC: 由于数值在 unary input 下 $O(nW)$ FPTAS, 在 binary input 下 NPC.


七、Subset Sum $\to$ INTEGER PROGRAMMING

整数规划: ${x \in \mathbb{Z}^n \mid Ax = b, x \geq 0}$ 存在? 数字 a_i 行直接变 A 行 identity; $b$ = target. 代码:

def subset_sum_to_IP(nums: list[int], target: int):
    n = len(nums)
    A = [[0]*n for _ in range(1+n)]   # one row per num-bound, one = sum-target row
    
    # constraint: 0 ≤ x_i ≤ 1  AND  sum(nums[i] * x_i) = target
    A = [[0]*n for _ in range(n)]   # binary constraint row each
    for i in range(n):
        A[i][i] = 1
    b = [1]*n  # 0 ≤ x_i ≤ 1; x_i is bounded + non—neg
    
    # plus target equation
    A.append(list(nums))
    b.append(target)
    
    return A, b

→ 0/1 IP NPC. linear relaxation (allow real) ∈ P. 靠 LP 侧面 tactic — 紧致 / 分支定界 / cutting plane / IP solver (Gurobi) 在工程上有时秒杀百万维.


八、3-SAT $\to$ Coloring $\to$ Timetable

3-coloring: 给 graph G 找 3 着色使每邻边异色? NPC. 4-coloring planar graph 但有 Avis-Hujter-Müller 解决; 实际地图 4 色定理, planar 4-col polynomial (any planar graph ≤ 4 colourable, polynomial).

→ timetabling/scheduling 经常化成 coloring 变种 (cheduling) 直接 NPC, 大型院校长期用启发式.


九、归约链总结表

起点终点用途
3-SATCliqueNP 最 primitive
CliqueIStrivial (补图)
ISVCtrivial (size 翻)
3-SATHamiltonian Chaingadget 设计
Hamiltonian ChainTSP完全图 weight trick
3-SATColoringgadget 设计
ColoringTimetablinglabel 设算法下界
3-SATSubset Sum编码 to 整数
Subset SumKnapsack二进制整数 programming
Subset SumInteger Programming矩阵 encoding

十、实战: 第一次见新问题

第三步检查 (Karp's methodology, classic checklist):

  1. 是 P? 先想多项式算法.
  2. 是 NPC? 找 NPC $B$ 归约到 $A$; 给 certificate 和 verify (证明 A ∈ NP).
  3. 是真 P 的子? 弱 NPC (Knapsack with FP), 才考虑 pseudo-poly.
def verify_certificate_format(prob: str, answer, inst):
    if prob == 'clique':
        return len(answer) == inst.k or all(
            (a, b) in inst.G.edges or (b, a) in inst.G.edges
            for a in answer for b in answer if a != b
        )

工程脑 showsomer. 没有时间愁 L. Just pad 验证一下, 上 IPC prefers direct; 用 tactic.


十一、问题流速图

flowchart LR
    SAT --> 3SAT
    3SAT --> Clique
    3SAT --> Coloring
    3SAT --> Hamiltonian
    3SAT --> SubsetSum
    3SAT --> BinPacking
    Clique --> IS
    IS --> VC
    Hamiltonian --> TSP
    SubsetSum --> Knapsack
    SubsetSum --> IP
    Hamiltonian --> LONGESTPATH

— 即使是新问题, 一个编码$\langle M, w \rangle$ 之直接 fit to (e.g.) SAT, 仔细 evidence.


下一节 → Approximation Algorithms

8. Approximation Algorithms、Hardness of Approximation

TL;DR

承认 NPC, 不代表放弃工程实现. 近似算法 (approximation algorithm) 在多项式时间给出可证近似比 (approximation ratio) 的解. PTAS / FPTAS / APX-hard / PCP theorem 把"是否可近似"也分级——例如 set cover 不可能近似比 $\ln n$ 内, 这条下界由 PCP 定理 (Feige 1998) 给. 本章给你 4 个经典近似例 + 近似下界工具包.


一、定义与记号

1.1 Approximation Ratio (min/max)

对最优问题 (minimization):

  • 算法 $A$ 的近似比 $\rho_A(n) = \max_{|I| = n} \frac{A(I)}{\text{OPT}(I)}$.

对 max:

  • $\rho_A(n) = \max \frac{\text{OPT}(I)}{A(I)}$ (亦即最小 $r$ 使 $A \geq \text{OPT}/r$).

$\rho = 1$ 即精确 (VPN optimum); $\rho = 2$ 即 "至少一半好".

1.2 PTAS / FPTAS

  • PTAS (Polynomial Time Approximation Scheme): 对任意 $\epsilon > 0$, 算法 $A_\epsilon$ 给 $\leq (1+\epsilon)\text{OPT}$, 时间多项式 $n^{O(1)}$ 但可指数于 $1/\epsilon$.
  • FPTAS: time poly in $n, 1/\epsilon$. 强, 罕见.
  • EPTAS: poly $n$, exp $1/\epsilon$ but constant (semi-strong).

二、经典近似算法

2.1 Vertex Cover: 2-approximation

贪心匹配: 任取未匹配边 $(u, v)$, 加入 $u, v$到 cover, 删 $u, v$ 关联边. 重复.

def vertex_cover_2approx(edges: list[tuple[int, int]]):
    cover, used = set(), set()
    for u, v in edges:
        if u in used or v in used:
            continue
        cover.update([u, v])
        used.add(u); used.add(v)
    return cover

正确性: 每取一条边 ⇒ 加入 2 个顶点 → cover 终 ≥ 实最优取至少其中一 (因最 OPT 必含 $u$ or $v$, else 边未 cover); matched edges 全 disjoint ⇒ OPT ≥ matched count. $\rho \leq 2$.

注: 设 20+ 年界 1.3606 下界非常 necked; Håstad 2001 证**: 不可能 (在 P≠NP) ratio < 2-o(1). Dinur-Safra 2002 把下界推到 1.36. 二者间 1.36–2 间开放. 2-approximation 20 年无人飞跃——这就是 NP approximation 困难的实证.

2.2 Set Cover: ln-approximation

import math

def set_cover_ln(universe, sets):
    cover = []
    U = set(universe)
    while U:
        s = max(sets, key=lambda s: len(s & U))
        cover.append(s)
        U -= set(s)
    return cover

贪心比: 每个 step 添 ≥ $1/k$ of remaining optimum-cover. 总步骤 ≤ $H_n \cdot \text{OPT}$ ($H_n$ 为调和数 ≈ $\ln n$). 故 ratio $\leq \ln n$.

Hardness: PCP theorem 后 Feige 1998 证 $\rho > (1-o(1))\ln n$ ⇒ NP $\not\subseteq$ DTIME($n^{O(\lg\lg n)}$).

→ 当前理论与实际上下界几乎重合, ln-set cover 是"不可超越"的算法.

2.3 TSP- Metric: 2-approximation and 1.5 Christofides

2-approx: 求最小生成树 (MST), DFS 一遍复制为 tour; 因 MST ≤ OPT, DFS 两次 MST ≤ 2 OPT.

Christofides (1.5): 求奇度 vertex 集 (MST 内偶数 vertex 节点, 数 = even), 用 perfect matching 给奇度配对, 加这些边到 MST → Eulerian 图; 经 Euler tour → shortcut 经三角不等式 → $\leq 1.5\text{OPT}$.

工程值得: TSP (metric) 用作实际物流工具 H 后 lecele; dynamic programming $O(n^2 2^n)$ 给精确 OPT 还能跑 $n \leq 20$ instances.

2.4 Knapsack: FPTAS

def knapsack_fptas(weights, values, W, eps):
    n = len(weights)
    Vmax = max(values)
    scale = max(1, int(eps * Vmax / n))
    sv = [v // scale for v in values]
    
    # DP on reduced value
    dp = {0: 0}     # reduced_value → min total weight
    for i in range(n):
        ndp = dict(dp)
        for rv, tw in dp.items():
            nr = rv + sv[i]
            nw = tw + weights[i]
            if nw > W:
                continue
            if nr not in ndp or ndp[nr] > nw:
                ndp[nr] = nw
        dp = ndp
    
    best_rv = max(dp)
    return best_rv * scale

舍入 $\epsilon V_{\max}/n$ ⇒ values range $\leq n \cdot \frac{V_{\max}}{\text{scale}}$, DP table O(n²V_max/scale), 误差 $\leq n \cdot \text{scale} \leq \epsilon V_{\max}$. FPTAS.

性质: 0/1 Knapsack 在 input binary 编码下是 weak-NP-hard, partition variant 是 strong-NP (即使数值 unary 仍 NPC). 0/1 非强 NPC → FPTAS.

2.5 MAX-SAT: 7/8-approximation

随机指派: 每文字 1/2 chance. 期望 satisfied clause 数 ≥ 7m/8.

Karlin-Williams 7/8 lower bound (1997): 任何 $\epsilon$ better → P=NP. Random rounding ratio 上界与此 close.

→ Randomized algorithms 不只好看, 还是 MAX-SAT 实践上最佳.


三、Approximation Hardness

3.1 PCP Theorem (Arora-Lund-Motwani-Sudan-Szegedy 1992)

PCP (Probabilistically Checkable Proofs) 的语言形式: NP = PCP($O(\log n), O(1)$).

工程化叙述: NP 任何证明都可被改成一种协议, verifier 仅读 3 个 random bit (consistency 局部) 即可知 $\geq 1-\epsilon$ 确信. 这条定理也是 ∈ IP=PSPACE breakthrough 后果.

3.2 Gap Amplification

PCP 把 "证明错误 1 位" 放大到 "\epsilon 错误位 使验证 reject" ⇒ 把 exact problem 转 gap problem "$\text{OPT} \geq c$ vs $\text{OPT} \leq s$".

例 (MAX-3SAT): GAP versions:

  • YES: 可满足全 clause.
  • NO: 满 ≤ 7/8 clause.

若 poly algorithm distinguishes ⇒ P=NP. 故 MAX-3SAT 7/8-approximation tight.

3.3 Inapproximability Results

问题InapproximabilityBound
MAX-3SAT≥ 7/8 ≤ 1 (Håstad 2001)tight
Set Cover$\geq (1-o(1))\ln n$tight to ln-greedy
Vertex Cover$\geq 1.3606$gap vs 2-approx
TSP-metric$\geq$ 123/122 (Karpinski-Lampis-Schmied 2015)⇒ 1.5 appears close 但 gap 仍
Independent Set$\geq n^{1-\epsilon}$ for any $\epsilon$, assuming NP $\neq$ ZPPhuge; = NP-hard to $O(2^{\log^{1-\epsilon} n})$-approx.
ColoringNP-hard to color n²^ε 但 actually coloring-3 always NP-hardmatches algor if
Metric Min Multi-Cut-Cliquelogarithmic hard.

Unique Games Conjecture (UGC, Khot 2002): 假设更强约束使某些 hardness 更紧. 现 Vertex Cover 2-approx 等价 UGC. 成熟立但 unverified.


四、Practice 接口

近似算法的"实现 - 经验" 关键标准:

工具目标问题接口
Gurobi / CPLEXILP (incl. TSP, scheduling, bin packing)LP-relaxation + cut branch
OR-Tools / SCP parsersVehicle Routing调 2-OPT, 3-OPT, savings
LocalSolver高维 mixedsimulated annealing + tabu
ConcordeTSP 精确cut ILP

工程心法: 先写伪多项式 fallback, 再写 approximation lie+ 输出 ratio; 再 fallback heuristic, 比较实践. 给极少数 instances exact DP 实际秒杀 (n ≤ 20), register数万行 ins 何必实际 listen to theory.


五、Parameterized Complexity (旁支)

适合"难度取决于结构参数"问题. FPT (fixed-parameter tractable) for parameter $k$ ⇒ $O(f(k) \cdot \text{poly}(n))$ 算法.

经典例子:

  • Vertex Cover $k$: $O(1.2738^k + k n)$ (Chen-Kanj-Xia 2010).
  • Feedback Vertex Set $k$: $O(3^k \cdot k n^2)$.
  • Planarity: $O(2^{O(k)} n)$ on planar graphs.

W-hierarchy 把 hard-beyond likely FPT (W[1], W[2], ...). Hilbert–Laut issue of clique unlikely FPT under ETH.

ETH (Exponential-Time Hypothesis): 3-SAT 不在 SUBEXP. ⇒ $n$-variable 3-SAT requires $2^{\Omega(n)}$ time ⇒ clique ≥ $n^{O(k)}$ 等约束. ETH 已成 21st 世纪 P≠NP 替代.


六、与前面章节的桥

  • DSA / dp: 0/1 Knapsack DP O(nW) vs NP-hard. 这是为什么 DSA terms 算法 often 直接 pseudo-poly, populated by big-W 输入可能爆.
  • DSA / greedy: MST prime-Kruskal 用 subset perspective 看最优子集下 ZIP; approximating 用 MST = TSP 案例.
  • 第七部分 system-design / estimation: 当客户问"再便宜的 routing path", 你回"硬 NPC, 但 Christofides 1.5×"; 看下面 capacities good estimate is right-algorithm but not only.
  • DB查询优化器: join order selection is QCQ NPC; commercial systems approximate via DP on left-deep trees, polynomial then exact.
  • compiler 内联 inline: optimal inline planning; heuristics (call frequency profiling) 多冲突. (DSA graph scheduling complexity ref.)

下一节 → 附录

附录: 常见判定问题分类速查表

编程现场/面试/代码评审的快速参考. 按字母与域分别罗列.


A.1 — 自动机与文法层级

Chomsky Type文法自动机例子
0unrestricted grammarTM"任意程序计算的语言"
1context-sensitiveLBA (Linear Bounded Automaton)${a^n b^n c^n}$, ${ww}$
2context-freePDA${a^n b^n}$, balanced parens, JSON
3regularDFA / NFA / ε-NFAatom regex ^[ab]*$, IP match

A.2 — 经典 RE / CFL 反例

语言类别证度
$a^n b^n$CFL 非 REpumping
$a^n b^n c^n$sensit-context 非 CFLpumping
${ww}$CSG 非 CFLpumping
${ww^R}$ (回文)CFLconstruct CFG
Dyck language (balanced parens)CFLstandard
${a^* b^* c^*}$regulartrivial
$a^*$regulartrivial
${a^p \mid p \text{ prime}}$非 CFLpumping

A.3 — NP 问题速查表

A.3.1 NP-Complete 经典

问题起点归约在第一部分对应章节
SATCook-Levin— foundational —
3-SATSAT
Clique3-SAT见 reductions
Vertex CoverClique via ISreductions
Independent SetClique (补图)reductions
Hamiltonian Cycle3-SATreductions
TSP decisionHam Cyclereductions
3-Coloring3-SATreductions
Subset Sum3-SATreductions
0/1 KnapsackSubset Sumdsa/topics/dp
PartitionKnapsackdsa/topics/dp
Bin PackingPartition
Job SchedulingPartition
Longest PathHamiltoniandsa/topics/graphs
SAT for CircuitSAT
Planar 3-SATSAT
Set CoverVertex Coverapproximation
Steiner TreeSATgraph chapter
Hitting SetSet Coverreductions
Integer ProgrammingSubset Sumreductions
Job-shop SchedulingSAT

A.3.2 NP-Intermediate (conjectured, unproven if P≠NP)

问题推荐
FACTORShor's (BJP 算) crypto assumption
Graph IsomorphismBabai quasi-poly
Discrete Logcrypto assumption

A.3.3 PSPACE-Complete

  • QBF (Quantified Boolean Formulae)
  • Generalized Geography
  • Sokoban decision problem

A.4 — Undecidable (not even RE)

问题证明 tool
停机问题共形式对角线
Total TM 是否对所有输入必停 ("Tot")用 Rice ($\Pi_2$)
Post CorrespondenceTuring config 归约
Hilbert's 10thPCP 归约
Wang tiles 平面铺TM tape 归约
Word problem for groupsBoone-Novikov
判断 TM 输出多项式函数行数Rice
$\mathcal{RE} \neq \mathcal{R}$停机

A.5 — Crypto 困难假设对照

假设所属类推论
$P \neq NPC$complexity theoryCook-Levin 后仍未证
Integer factoring hardFACTOR not in P (假设)RSA 安全 iff factoring hard
Discrete log hardDLOG not in P (假设)ECDLP / 椭圆曲线安全
LWE (Learning-with-errors) hardworst-case lattice problem (Regev)后量子 crypto 基础
BQP 不含 NP-completequantum complexityShor 不能解 NP
Quad-SVP approximationlattice problem已知 NP-hard (under random reduction)

A.6 — Approximation Class 速查

Class含义
APXratio ≤ constant (存在 c-approx)
PTAS$\forall \epsilon$ poly → $(1+\epsilon)$-approx
FPTAStime poly in $n, 1/\epsilon$
APX-hard不可能 PTAS (unless P=NP)
APX-complete在 APX 类且其 reduction preserve ratio

例:

  • Vertex Cover: APX-complete, $(2-\theta)$ open.
  • TSP-metric: APX-complete, gap 1.5 ⇔ 1.3606.
  • Set Cover: log-approx, APX-hard 推 hard of approx.
  • Knapsack: FPTAS.

A.7 — 与项目其他章节交叉索引


下一节 → 密码学与安全 README

第十部分 · 密码学与安全

一句话

密码学把"对手的难度"变成"算力代价", 让攻击者只能用 brute force 撞靠墙——并通过这个等价交换, 把不可信网络的通信变成"在不可信通道上的逻辑信任协议". 它的本质是把对抗 NP 完备性问题的难度转化成密码协议的设计目标: RSA 安全性建立在 factoring 假设, ECDLP 用椭圆曲线上群运算, SHA-256 用 Merkle-Damgård 结构, AES 用 SPN 网络抵抗 differential/linear cryptanalysis. 同时密码学不是孤立数学, 它一层一层堆栈: 对称加密 + 操作模式 → AEAD → 密钥交换 + 签名 → 证书链 → TLS 1.3——这正是日常 https 表象后的暗物质.

思想链

[HTTPS GET api.bank.com]
  └─> TLS 1.3 ClientHello (含 ECDHE pub key, cipher suite)
       └─> Server: 验证 client SNI, 给 cert chain (X.509 leaf → intermediate → root CA in CT-log)
             └─> Client: 验证 cert chain (OCSP stapling 或 CRL), 握 ECDHE shared secret HKDF
                  └─> 生成 traffic keys: client→server / server→client 各两组 (key, IV)
                       └─> AES-256-GCM AEAD encrypt data: ciphertext = AES(plaintext) + tag
                              └─> Tag 验证: 整流路防篡改 / 重放 / 截断
                                     └─> HTTP/2 headers HPACK 压缩 → /api/order
                                          └─> Application 接路由 + DB
                                               └─> back protocol: JWT signed by Ed25519 signing

任何"以为 HTTPS 是一个东西的"抽象, 实则 4 层 stack. 其中任一层崩 → 全栈崩: 弱 RNG (DNSSEC rollover 死过) / EECDH curve 选错 / cert chain 错乱 / RC4 危险 / IV reuse 一次性全毁. 这一模块按 layer 把所有底层铺开, 并显式给出 ECDSA / ChaCha20 / HKDF 工程级 reference implementation.

章节

读完应能:

  1. 为什么 AES 选择 SPN 网络替代 Feistel? AES S-box 设计 explicit GF(2⁸) 抵抗 differential/linear 的代数证据何在?
  2. ChaCha20 用 quarter-round ARX 操作不给硬件加速也能 GB/s, 在 mobile IoT / TLS 表示何处?
  3. ECB / CBC / CTR / GCM 选模: 各自抗篡改 / 并行 / 错误传播 / nonce约束。GCM 一次 nonce 改不动用就丢 all messages?
  4. RSA: 2048-bit modulus 有 1024-bit security? 实际约 112-bit security by NIST 比对; CRT 移位子解密加速约 4×.
  5. 为什么 Curve25519 比 secp256k1 在 ground field 上更工程友? 常数时间 Montgomery ladder vs Weierstrass'\x00 binary特殊点问题?
  6. ECDSA signature 重 nonce reuse → 私钥可逆推 (kindle PS3 hacking 2010); Ed25519 deterministic nonce (RFC 8032) 怎么避免?
  7. SHA-256 Merkle-Damgård 被长度扩展攻击伤; BLAKE3 之 50× faster in parallel mode 用 Merkle-tree hash.
  8. TLS 1.3 vs 1.2: 1.3 把全部握手 encrypt 进 Early-Traffic,少 1 RTT 还是 0 RTT?
  9. X.509 cert chain 验证中 path build logic; Certificate Transparency log 给 audit; OCSP must-staple 错放直接被浏览器拒.
  10. zk-SNARKs trusted setup vs zk-STARKs scalability: 为什么 zk-rollup 多选 STARK (Arabica 系)? Bulletproofs 的 non-SNARK 银行轻吗 bpc 范围证明.
  11. Spectre / Meltdown / Rowhammer: timing side channel 如何 notice 系统调用边界形成纪录 乒乓散度; microarchitectural state no clean domain.
  12. constant-time memory compare: why == is unsafe; 慢 secret-dependent branch 真的能 leak key over 公网.

历史 1: 1976 Diffie-Hellman 公钥革命

Whitfield Diffie 和 Martin Hellman 在 "New Directions in Cryptography" 一举突破"对称密钥分发"瓶颈, 用 modular exponentiation 给出了"两人从未见面, 通过公开信道协商家 secret". 这是公钥密码学诞生. RSA 1977 紧随其后, 椭圆曲线 1985 (Koblitz & Miller).

历史 2: 1995 SHA-1 → 2017 shattered

SHA-1 1995 NIST 出版, 2013 weakness evidence, 2017 Google CWI công shattered.SHA-1.collision finding. 全网 exhorted to SHA-2/3. BLAKE2 (2012) / BLAKE3 (2020) 在 faster plane.

历史 3: 2008 TLS 1.2 → 2018 TLS 1.3 pure AEAD

TLS 1.2 12 cipher suites; 2018 TLS 1.3 砍掉 CBC/RC4/SHA1, 仅留 AEAD (AES-GCM/ChaCha20-Poly1305) + ECDHE + 签名 RSA-PSS/EdDSA. 1 RTT handshake + 0-RTT optional.

历史 4: 2013 Snowden泄密 → TLS 1.3 普及

PRISM 暴露 NSA 内部被动解密能力 (用 GCHQ-applied "BULLRUN" 加密 weaponize). 业界推进 PFS (Perfect Forward Secrecy) 与 ECDHE 强制——每条新 session 短期 ephemeral 私钥 ephemeral 化密钥 (即使长期私钥泄露, 已存档流量也无法解密).

历史 5: 2018 Spectre / Meltdown

Google Project Zero + 各学术团揭示现代 CPU 推测执行 + cache 让任意 user 程序读 kernel 内存. 整个 cloud/security 业软层 react 18-24 个月. 至今 ♻ "Spectre vN" 系列未绝.

历史 6: 2018 zk-STARKs 与 2020 zk-rollup 兴起

StarkWare 用 STARK 证明 ETH rollup 交易, Ethereum rollup 降低 gas 成本 100× 太. SNARK (Groth16, PLONK) 与 STARK (ultra-scalability, no trusted setup) 在 2020s Crypto builder core stack.

历史 7: 2022 NIST PQC 选定

CRYSTALS-Kyber (KEM) + CRYSTALS-Dilithium (signature) + SPHINCS+ (hash-based fallback) 三连环: 四年评估筛 7 选 1). 美政府 2022-2025 逐步迁移 done be HTTPS Q-safe.


下一节 → 对称加密: AES 与 ChaCha20

1. 对称加密: AES 与 ChaCha20

TL;DR

对称加密 (symmetric encryption): 加/解密用同一把密钥. 业界两个 main 选手:

  • AES (Rijndael 2001) — SubBytes / ShiftRows / MixColumns / AddRoundKey 四步, 10-14 轮迭代. 硬件 (AES-NI) 指令, GB/s 量级.
  • ChaCha20 (Bernstein 2008) — ARX quarter-round 流密码, 无硬件加速也 GB/s. mobile/IoT 与 TLS 主流.

读完此章你能在面试辨: 为何 AES 抗 differential 看每一轮 S-box's max differential probability; 为何 ChaCha20 用 quarter round + counter 保证每字节 keystream 不重写 (nonce+counter 任一是密文 byte 的 IV).


一、加解密形式化

  • 加密: $c = E_k(m)$, 解密: $m = D_k(c)$. 加密与解密均用同一密钥 $k$.
  • 现代对称加密基于 one-time pad 的理论: if keystream $S$ is uniform random 与 message 同长, ciphertext $c = m \oplus S$ 理论不可破. 现实不难造 $S$ — 真正随机 is hard.
  • 流密码 (ChaCha20, RC4, Salsa20): 用 PRG 从 short seed (key + nonce + counter) stretch 为 keystream $S$, $c = m \oplus S$.
  • 块密码 (AES): 用 PRP (pseudorandom permutation), 把 128-bit block 切成等价-keyed permutation. 用 modes (CBC/CTR/ECB/GCM) 扩展到任意长输入.

二、AES 内部: SPN 网络 (Substitution-Permutation Network)

AES 处理 128-bit block; 密钥长 128, 192, 256 分别 10, 12, 14 轮. 每轮 4 步:

  1. SubBytes: 16 字节逐个经过 8×8 S-box (查表). $x \to \text{SBox}[x]$.
  2. ShiftRows: 按字节矩阵的每行循环左移 (0, 1, 2, 3 字节).
  3. MixColumns: GF(2⁸) 上每列做矩阵乘.
  4. AddRoundKey: ciphertext state $\oplus$ round key $k_i$.

最后一轮跳 MixColumns (否则解密不安全).

2.1 S-box 设计依据

AES S-box 不是随机表 — 是 $x \mapsto x^{-1}$ in GF(2⁸) 后接 affine map. 选择 inverse 因"低 differential/linear 概率":

  • max differential prob ≈ 4/256, max linear prob ≈ 1/4; 都远低于随机 8-bit 函数.

→ 抗 differential/linear cryptanalysis 的代数根.

2.2 MixColumns

每列 4 字节, GF(2⁸) 上乘固定矩阵 $$M = \begin{bmatrix} 2 & 3 & 1 & 1 \ 1 & 2 & 3 & 1 \ 1 & 1 & 2 & 3 \ 3 & 1 & 1 & 2\end{bmatrix}$$ 分支数 (branch number) = 5 (maximal for 4×4 binary matrix), 即每输入字节影响 5 个输出字节, 提供 diffusion.

2.3 KeySchedule

Initial key → 第一轮 key derivation 推到下一轮; 每 4 字节recursive using S-box + Rcon (round constant) 防 symmetry.

2.4 AES round 实施 (Python-style)

SBOX = [...]  # standard AES S-box.

def aes_encrypt_block(block_16: bytes, round_keys: list[bytes]) -> bytes:
    assert len(block_16) == 16
    state = bytearray(block_16)
    for i in range(15):
        sub_bytes(state)         # 1. S-box
        shift_rows(state)         # 2. 
        if i != 14:               # skip last MixColumns
            mix_columns(state)     # 3.
        add_round_key(state, round_keys[i + 1])  # 4.
    return bytes(state)

让每个 128-bit block 加密成 1 个 cycle (16 字节 parallel GF mul). 现代 CPU AES-NI = AESENC 指令做以上四步.

2.5 AES-NI 硬件

Intel 2010 Westmere 引入 AES-NI: AESENC xmm1, xmm2 直接做 1 round. AES-128 加密 ~1.3 cycle/byte, GB/s/copy. 现代 CPU 加密吞吐 5-10 GB/s core-parallel; GPU A100: 60+ GB/s.

→ pure-software AES (~50-200 MB/s Python) 在没有 AES-NI 的 IoT 板上 bad 的 → 这就是 ChaCha20 的主场.


三、ChaCha20: ARX 流密码

设计者 Bernstein 给 Salsa20 (2005) 改进版, RFC 7539 (2015) 收 HTTPS use. 用 256-bit key + 96-bit nonce + 32-bit counter; 每 block 64 字节 keystream.

3.1 Internal state (16 个 32-bit words)

512-bit state:

const0 const1 const2 const3
key0   key1   key2   key3
key4   key5   key6   key7
counter nonce0 nonce1 nonce2

前 4 是常数 "expand 32-byte k" split.

3.2 Quarter Round (QR)

操作 4 个 state words:

a += b;  d ^= a;  d = ROL(d, 16);
c += d;  b ^= c;  b = ROL(b, 12);
a += b;  d ^= a;  d = ROL(d, 8);
c += d;  b ^= c;  b = ROL(b, 7);

ARX (Add-Rotate-Xor): 所有操作寄存器可循环, 严密 anti-timing-side-channel by design.

3.3 Double round: column QR + diagonal QR

20 轮 QR, 每 4 个 QR 处理三 row / 三列/diagonals 各一 (10 次 column + 10 次 diagonal).

3.4 输出

最终 state + 原始 state (= 极小 key increment stream output), 这 64-byte XOR 与 message.

3.5 Python 实现示意

import struct

def rotl32(x, n):
    return ((x << n) | (x >> (32 - n))) & 0xFFFFFFFF

def quarter_round(s, a, b, c, d):
    s[a] = (s[a] + s[b]) & 0xFFFFFFFF; s[d] = rotl32(s[d] ^ s[a], 16)
    s[c] = (s[c] + s[d]) & 0xFFFFFFFF; s[b] = rotl32(s[b] ^ s[c], 12)
    s[a] = (s[a] + s[b]) & 0xFFFFFFFF; s[d] = rotl32(s[d] ^ s[a], 8)
    s[c] = (s[c] + s[d]) & 0xFFFFFFFF; s[b] = rotl32(s[b] ^ s[c], 7)

def chacha20_block(key: bytes, counter: int, nonce: bytes) -> bytes:
    constants = b"expand 32-byte k"
    s = list(struct.unpack("<16I", constants + key + struct.pack("<I", counter) + nonce))
    init = s[:]
    for _ in range(10):
        quarter_round(s, 0, 4,  8, 12); quarter_round(s, 1, 5,  9, 13)
        quarter_round(s, 2, 6, 10, 14); quarter_round(s, 3, 7, 11, 15)
        quarter_round(s, 0, 5, 10, 15); quarter_round(s, 1, 6, 11, 12)
        quarter_round(s, 2, 7,  8, 13); quarter_round(s, 3, 4,  9, 14)
    out = [(init[i] + s[i]) & 0xFFFFFFFF for i in range(16)]
    return struct.pack("<16I", *out)

def chacha20_keystream(key: bytes, nonce: bytes, counter_start: int, length: int) -> bytes:
    out = b""
    n = (length + 63) // 64
    for i in range(n):
        out += chacha20_block(key, counter_start + i, nonce)
    return out[:length]

def chacha20_xor(key: bytes, nonce: bytes, data: bytes, counter_start: int = 1) -> bytes:
    ks = chacha20_keystream(key, nonce, counter_start, len(data))
    return bytes(a ^ b for a, b in zip(data, ks))

工程注意: Pure Python 5-20 MB/s, Go lib 100+ MB/s, modern CPU AVX 直 1-5 GB/s. Salsa20 family 在 WAN 圈善 landless 用, SHA-style throughput.


四、AES vs ChaCha20 选型

维度AES-128-GCMChaCha20-Poly1305
加速AES-NI: 5 GB/sAVX2: 2-3 GB/s (无硬件一直)
无硬件加速场景30-50 MB/s200+ MB/s
Mobile/IoT不友好 (no AES-NI)友好
256-bit 安全级AES-256 直接同 (32 byte key)
Key/IV setup overheadlowlow (key 32B, nonce 12B)
Counter 32-bit 大Yes (counter + IV+block)Yes, but requires careful nonce management (RFC 7539)
Side channel saferAES-NI instructions constant-time算术 ARX inherently constant-time, ANY portable
Wider useTLS, IPsec, VPN, disk encryptionTLS (Google), Signal, WireGuard, SSH

工程心智选: 吾 AES-NI 占 95% PC/server, 吾 mobile with weak AES arm ChaCha 选 WireGuard/Signal. Cloudflare 2019 paper 工程统计: TLS 1.3 ~17% ChaCha20, 83% AES-GCM; mobile traffic 中 ChaCha20 比例大幅高.


五、Block Cipher Attack Models & Margin

攻击说明AES 当前防御
differential (Biham-Shamir 1991)找 differential trail with prob > $2^{-}$ certificate 导致区分4 round trail prob too low; full 10 round 攻击 ~$2^{254}$
linear (Matsui 1993)高 bias linear approximation 到 round 关联full AES linear attack ~$2^{258}$ > brute force
integral (square attack)用 byte-balanced input 的子 set 观察 after rounds 实际full AES 防 6+ 轮有抗 integral 余
related-key attackkey schedule.difficulty for AES-256 Astro 略弱, 仍 14 rounds 抵抗AES-256 24-round key schedule 抵抗, key recovery ~$2^{254}$.
side-channel (timing/power)AES table walks leak memory writeAES-NI hardware bypass + constant-time 否 cache timing 选择

warning

不要 homebrew AES — S-box 作 random table 时, table-lookup 真真创 cache timing leak. OvenSoft 公布 OpenSSL 2005 cache-timing AES attack: timing leak round ≥ 5 rounds 后 recover key. 永远 use libcrypto / t crypto_aead_* (libsodium) — done.


六、与 project 其他章节交叉

  • complexity.md / approximation.md: NPC 算力不是现行攻击 RSA/AES 假设 base — 攻击 AES 用 specific algebraic attacks (AES 是 SPN 设计 by 具 differential 抵抗), factorzation is in NP∩co-NP (但 not NP-hard), whereas lattice LWE assumption base 现在 PQC.
  • os/lock/lockfree.md: constant-time 算术 = constant-time wait free algorithm — proper 上 identity.
  • os/net/zero-copy.md: TLS sendfile hotpath 芯 AES-NI + sendfile integrated 加速 NGINX 5×.
  • distributed/fault/erasure.md: Reed-Solomon code on GF(2⁸) 复用 AES field 的 FF inverse 构造.

下一节 → 操作模式: ECB / CBC / CTR / GCM (AEAD)

2. 操作模式: ECB / CBC / CTR / GCM (AEAD) 与 nonce-reuse 灾难

TL;DR

块密码处理固定 128-bit block. 把它扩到任意长 message 的协议叫Operation Mode. ECB 是 naive 也已 broken (tux 攻击); CBC 抗篡改弱; CTR 流密码化适合并行; GCM 给 AEAD (Authenticated Encryption with Associated Data) 一次给 ciphertext+tag. 现代选用几乎仅 GCM 和 ChaCha20-Poly1305——TLS 1.3 砍除 CBC. Nonce reuse 在 GCM/CTR 是密码学界两次即死级破坏.


一、ECB (Electronic Codebook): 最朴素也最弱

每个 block 独立加密: $c_i = E_k(m_i)$. 解密反: $m_i = D_k(c_i)$.

def ecb_encrypt(key: bytes, msg: bytes) -> bytes:
    bs = 16
    if len(msg) % bs != 0:
        msg = pad(msg, bs)
    return b"".join(aes_encrypt_block(msg[i:i+bs], key) for i in range(0, len(msg), bs))

1.1 致命: 同明文 block 产生同密文 block

Tux cartoon: ECB encrypt Tux 企鹅 PNG, 即使加密后 image 仍可辨出企鹅轮廓. 业界反通用: ECB 永远不要用于 data.

1.2 唯一使用 case

  • 单 block 的小消息加密 (例如 exchange password ciphered 频率 < 1 block 输入熵): 严格 data length < block_size; 否则 ECB = mistake.
flowchart LR
    M1[block1] --> E["E_k"] --> C1[c1]
    M2[block2] --> E2["E_k"] --> C2[c2]
    M3[block3] --> E3["E_k"] --> C3[c3]
    style C1 fill:#fdd
    style C2 fill:#ffd
    style C3 fill:#fdd

二、CBC (Cipher Block Chaining): 串行多一字节 XOR 链

$$ C_0 = \text{IV (不保秘)} $$ $$ C_i = E_k(M_i \oplus C_{i-1}) $$

解密 $M_i = D_k(C_i) \oplus C_{i-1}$.

def cbc_encrypt(key: bytes, IV: bytes, msg: bytes) -> bytes:
    bs = 16
    assert len(IV) == bs
    pad_msg = pkcs7_pad(msg, bs)
    prev = IV
    out = bytearray()
    for i in range(0, len(pad_msg), bs):
        block = bytes(a ^ b for a, b in zip(pad_msg[i:i+bs], prev))
        enc = aes_encrypt_block(block, key)
        out.extend(enc)
        prev = enc
    return bytes(out)

2.1 优缺

: 同明文不同 IV ⇒ 得不同密文 ✓; padding 长度可包装信息.

:

  • 串行: 每 block 需前密文, 不利并行.
  • 不抗篡改: 攻击者可交换两 ciphertext blocks, 解密后破语义; padding oracle attack (Vaudenay 2002) 让 attacker 通过 padding error messages 在 polynomial time 内 recover plaintext for TLS-supported-old server.
  • IV must unpredictable: IV 选 counter 简便但 small IV 容易被 predict + chosen-plaintext attack (Lucky13 / BEAST) 实证.

warning

Padding Oracle (POODLE 2014): SSL 3.0 的 CBC-MAC + padding 验证回报 last byte 是否 pad. Padding error 状态 leaked → 攻击者多次发同一密文 板 ctxt, 改 last byte 直 non-padding-error → 解出 plaintext 上一字节. 重复 → 解全 plaintext byte。故 SSL 3.0 deprecate; 改用 AEAD only.


三、CTR (Counter) mode: 把 block cipher 变流密码

$$ C_i = M_i \oplus E_k(\text{nonce} , | , \text{counter}_i) $$

def ctr_encrypt(key: bytes, nonce: bytes, msg: bytes) -> bytes:
    counter_blocks = (len(msg) + 15) // 16
    keystream = b""
    for i in range(counter_blocks):
        cblock = nonce + struct.pack(">I", i)
        keystream += aes_encrypt_block(cblock, key)
    return bytes(a ^ b for a, b in zip(msg, keystream[:len(msg)]))

3.1 性能 + 特性

  • 完全并行: 每 $E_k$ 独立计算 → SIMD/multi-core 灾难.
  • 无 padding: 任意 length message.
  • 加密解密同算法: 一致 XOR.
  • stream-cipher-like: 重复 nonce ⇒ 同 keystream ⇒ 多条消息 XOR 立刻破. 必须保证 nonce 不重.

3.2 IV / counter 管理

  • 96-bit nonce + 32-bit counter: nonce password syntactic; counter 在 record 内由 protocol designer 共议.
  • 不重复: 用 increment-counter 1 次 = 1 message. 不分 distinct message.

3.3 改性可重: 单 bit 翻转直接 plaintext 翻位

CTR 不用密码本身验完整性. 因此 CTR 单独用 = broken. 必须带 MAC (HMAC or Poly1305). 这就是 GCM 的来源.


四、GCM (Galois/Counter Mode): AEAD paramount

把 CTR encryption + GHASH-based authentication 合一并. 给 (ciphertext, tag) 双输出.

4.1 把 message 切三段

  • $A$ (associated data, e.g. TLS header): 不加密, 但要 integrity.
  • $P$ (plaintext): 加密成 ciphertext $C$.
  • $IV$: 96-bit 推荐, 长度可调但用 NATN 图 12 后文中 "短 IV" 上 nonce unwrap simplex.

4.2 加密段

跟 CTR 相同: $C_i = P_i \oplus E_k(J_0 + i)$, 其中 $J_0 = (\text{IV} , | , 0^{31}1)$.

4.3 GHASH

在 GF(2^128) 上做多项式计算: $$ T = \text{GHASH}_H(A, C) \oplus E_k(J_0) $$ 其中 $H = E_k(0^{128})$ 为 hash subkey, GHASH 是关于 H 的 неприем不接受 polynomial MAC: $$ \text{GHASH}_H(X_1, \ldots, X_n) = ((\cdots((X_1 H \oplus X_2) H \oplus X_3) H \cdots ) H \oplus X_n) H $$

最后 $\oplus E_k(J_0)$ 把 auth tag 与 IV specific.

4.4 验证

接到 (IV, A, C, T):

  • 重计算 $T' = \text{GHASH}_H(A, C) \oplus E_k(J_0)$.
  • 用 constant-time compare crypto_verify_16 比较 $T'$ 与 $T$.
  • 等则接受.

4.5 工程注意

warning

** nonce reuse 绝不能 — 一旦重 同 key/IV, 攻击者可恢复 plaintext, 且 recover GHASH key $H$ 进而 forge 任意消息 tag.**

Joux 2006 "authentication fail" 的 GCM nonce-reuse attack 是密码学上范式级破坏. HTTPS 多 record 加密每条 lift new IV; 像 mTLS / DTLS 1.4 / QUIC 都 ordered sequence degree + IV computed record-by-record rule sent.


五、AEAD: 为什么必须 encrypt-then-MAC

历史:

  • MAC-and-Encrypt (旧 SSL/TLS 1.0+): ciphertext = E(MAC || plaintext). attacker 可 cut tags/截断恢复 plaintext (Lucky13 attack).
  • MAC-then-Encrypt (TLS 1.2 CBC 套 overlapping): ciphertext = E(plaintext || MAC). 同样 padding oracle.
  • Encrypt-then-MAC (modern AEAD): ciphertext = E(plaintext), tag = MAC(plaintext). 验 tag 后才解 — verifier 不见 plaintext, 防 oracle.

GCM = E-then-MAC 内建, ChaCha20-Poly1305 也是. TLS 1.3 仅留这两个 AEAD. RFC 5116 把 AEAD 形式化: $$\text{AEAD.encrypt}(k, n, A, P) = (C, T)$$ $$\text{AEAD.decrypt}(k, n, A, C, T) = P \text{ (or fail)}$$


六、Poly1305: one-time MAC

Bernstein's polynomial MAC: $$\text{tag} = (\sum m_i r^i) \mod p$$ 其中 $p = 2^{130} - 5$, $r$ 是 key. Compute by carryless multiply + mod reduction, fast 软件 AVX.

与 ChaCha20 配对的 RFC 7539:

POLY1305_PRIME = (1 << 130) - 5

def poly1305_mac(key: bytes, msg: bytes) -> bytes:
    r = int.from_bytes(key[:16], 'little') & 0x0ffffffc0ffffffc0ffffffc0fffffff
    s = int.from_bytes(key[16:], 'little')
    acc = 0
    for i in range(0, len(msg), 16):
        chunk = msg[i:i+16]
        n = int.from_bytes(chunk + b'\x01' + b'\x00' * (15 - len(chunk)), 'little')
        acc = (acc + n) * r % POLY1305_PRIME
    tag = (acc + s) % (1 << 128)
    return tag.to_bytes(16, 'little')

Pair with ChaCha20: 用 ChaCha20 derived Poly1305 one-time key from block 0 of ChaCha20 keystream. One-time: each message 用 new key, derivation explicitly nonce-keyed → 安全.


七、密钥管理: 旋转 + 衍生

7.1 KDF (Key Derivation Function)

主 key 不直接做多个密钥; HKDF (HMAC-based KDF) RFC 5868 把种子密钥 stretch 为多自然 child-key.

import hmac, hashlib
def HKDF_expand(prk: bytes, info: bytes, L: int) -> bytes:
    n = (L + 31) // 32
    T, okm = b"", b""
    for i in range(1, n + 1):
        T = hmac.new(prk, T + info + bytes([i]), hashlib.sha256).digest()
        okm += T
    return okm[:L]

TLS 1.3 用 HKDF 阶段 derive:

  • early secret = HKDF-Extract(salt=0, IKM=PSK)
  • handshake secret = HKDF-Extract(salt=early, IKM=ECDHE_shared)
  • master secret = HKDF-Extract(salt=handshake, IKM=0)
  • traffic secret = HKDF-Expand(master secret, label, length)
  • ...

7.2 密钥运输 metadata

  • One-time vs long-term: TLS 主会长实测 1 hours,重 keyed connection (each connection old ECDHE ephemeral keys).
  • Key rotation: 由于 factoring attack etc 等渐进, 每 6-12 months 轮转 long-term RSA/ECDSA cert (受 Let's Encrypt 90-day actuate).

7.3 Forward Secrecy

长期私钥 leak 不解旧投资。机制: 每会话用不同 DH ephemeral, 共享秘密 derived 即用即弃 hash&; HKDF could pregen — even今天永久泄漏 only affect future sessions not 全 archived past.


八、TLS 1.3 AEAD 现实

TLS 1.3 record layer 全部是 AEAD:

  • AES-128-GCM / AES-256-GCM
  • ChaCha20-Poly1305
  • AES-128-CCM (IoT 低温场景)

每条 record: nonce = static IV (4-byte prefix from handshake) XOR sequence number. Avoid random nonce generation—用 deterministic counter防 nonce reuse by design.

def tls13_nonce(static_iv: bytes, seq: int) -> bytes:
    return (int.from_bytes(static_iv, 'big') ^ seq).to_bytes(12, 'big')

九、归 index 灾难史

灾难原因
PS3 ECDSA nonce reuse (Geohot 2010)同 k 重 signed two messages ⇒ private key factor out in seconds
WPA2 KRACK (2017, Mathy V.)重置 nonce + replay allow decrypt WPA2 packets
GSuite / Microsoft 365 GCM nonce reuse (2021言论)nonce counter 误 reset → nonce reuse; message recoverable
Telegram MTProto demo (2021)Counter reset; found expose social
IEEE 802.11i CCMP counter reset buggy drivers (2010s)replay allowed
VPN wireguard nonce (RFC 7748)nonce 极慎重: counter 大于2^64 拒; 玩 60-hour day 矩阵性质.

工程 ineluctable: nonce mathematically critical; 业界 production code must use a counter sequencing model (DRBG (NIST SP 800-90A)) ; under no circumstance 用 random() 给 nonce 全 length generated.


十、与项目其他章节交叉

  • complexity.md / NIST PQC: post-quantum ChaCha20 vs PQ signed-multicase ML-KEM 不是 AEAD-style; KEM 只在 key exchange, 但 ChaCha20 仍安全 post-quantum (symme 加密上 quantum 加速 search = factorization-like).
  • os/net/epoll-iouring memcmp: AEAD 实现常常走 SIMD batching 结构, parallel 链 io framework 与真宏 integrate.
  • distributed consensus: 链crypt hash 函数 SHA-256 与 PoW 用 SMN structure; hashcash 类因子 (Adam Back 1997 PoW 出).
  • system-design/case/dynamo-family: cryptographic token idempotency 多 tall with secure random?;

下一节 → 非对称加密: RSA 与 ECC

3. 非对称加密: RSA 与 ECC

TL;DR

非对称加密 (公钥加密, asymmetric encryption): 加密用公钥 $pk$ (不需要保密), 解密用私钥 $sk$. 同理签名: 签用 $sk$, 验用 $pk$.

两大支柱:

  • RSA (Rivest-Shamir-Adleman 1977): 基于大整数因子分解 (integer factorization) 困难假设. key 多到 2048-4096 bit.
  • ECC (Elliptic Curve Cryptography 1985): 基于椭圆曲线离散对数 (ECDLP) 困难. 256-bit 安全级仅 256-bit key — 比 RSA 6-30× short.

公钥密码学单项直接加密大 message 不实际 (RSAES-OAEP 仅 ~190 字节 / 3072-bit 模数). 通常做密钥交换 (用 RSA/ECDH 协商 short symmetric 密钥) 或 签名; 真正 data 加密由 symmetric cipher 受理.


一、RSA 数学

1.1 Keygen

  1. 选两等长素数 $p, q$ (e.g. bit-length = 1024 each)
  2. 模数 $n = pq$
  3. $\varphi(n) = (p-1)(q-1)$ (Euler totient)
  4. 选 $e$ 与 $\varphi$ 互素 (常见 65537)
  5. 求 $d = e^{-1} \mod \varphi$ (用扩展 Euclidean)
  6. 公钥 $(n, e)$, 私钥 $(n, d)$ (或存 $(p, q, d, d\bmod (p-1), d\bmod (q-1), q^{-1}\bmod p)$ 加速)

1.2 加密 / 解密

$$c = m^e \bmod n, \quad m = c^d \bmod n $$

由 Euler 定理: 因 $ed \equiv 1 \pmod{\varphi}$, $c^d = (m^e)^d = m^{ed \bmod \varphi} = m$ (when $\gcd(m, n) = 1$)

1.3 Python 实现

from math import gcd

def rsa_keygen(bits=2048):
    from Crypto.Util.number import getPrime
    while True:
        p = getPrime(bits // 2); q = getPrime(bits // 2)
        n = p * q
        phi = (p - 1) * (q - 1)
        e = 65537
        if gcd(e, phi) == 1:
            break
    d = pow(e, -1, phi)
    return (n, e), (n, d)

def rsa_encrypt(pk: tuple, m: int) -> int:
    n, e = pk
    return pow(m, e, n)

def rsa_decrypt(sk, c):
    n, d = sk
    return pow(c, d, n)

1.4 CRT 加速 (RSA-CRT)

私钥操作 $m^d \bmod n$ 用 CRT 拆:

  • $m_p = m \bmod p$, $m_q = m \bmod q$
  • $d_p = d \bmod (p-1)$, $d_q = d \bmod (q-1)$
  • $c_p = m_p^{d_p} \bmod p$, $c_q = m_q^{d_q} \bmod q$
  • $h = q^{-1} (c_p - c_q) \bmod p$
  • $m = c_q + h q$

Power 比直接 $\bmod n$ pow 上 ~4× (因 modulus half the bit-length + parallelizable). OpenSSL 默认走 CRT.

1.5 安全性

  • factoring 困难: 没有已知多项式算法 factoring $n = pq$. 实际 RSA 我 push 之时 $\sim 829$-bit (2020) 已破 (CADO-NFS, pay 走 2700 CPU 年灯).
  • NIST 推荐:
    • RSA-2048: 112-bit security
    • RSA-3072: 128-bit security
  • Quantum: Shor's algorithm O(log³ n) solves factoring on a sufficient large quantum 攻击 factoring → RSA-2048 broken by 4099 qubits 实机 (Logical qubits — 2024 IBM Condor 1121 physis, scale 极待 stage).

二、RSA 加密实际工装: OAEP

直接 $c = m^e \bmod n$ 是不安全:

  • 当 $m$ 小 (short message), 低 exponent attack 可"老师摊 出" $m$.
  • deterministic 加密 ⇒ 同 plaintext 同 ciphertext, leakage.
  • 公钥某 known plaintext attacks (chosen ciphertext) 可 recover.

现代用 RSAES-OAEP (Optimal Asymmetric Encryption Padding, Bellare-Rogaway 1995):p

em = MGF1(label) || hash(label) || M || 0-pad || 0x01
c = em^e mod n

random seed in MGF1 (Mask Generation Function) → encryption 随机化, leakage 除.

2.1 工程上界

  • RSA-2048 with OAEP 加密 $m$ len ≤ 214 bytes. Not for full HTTPS payload — only transport 向 short signed keys.
  • TLS 1.2 RSA key transport 已 deprecate, TLS 1.3 全重 ECDH.

三、RSA 签名: PSS

sign M:
  enc = MGF1(H_1(salt) || hash(M))  ← masked salt
  m = hashed pad + salt + hash(M)
  σ = m^d mod n
verify:
  m = σ^e mod n
  recover salt, recompute enc, compare hash(M)

PSS = Provably secure in random oracle model. RFC 8017.

比 PKCS#1 v1.5 签名 (Bellare-Rogaway 1996 hack): v1.5 deterministic; Bleichenbacher 1998 私塞 fingerprint attack 后, 业界移到 PSS.


四、椭圆曲线 ECC

4.1 椭圆曲线 (Weierstrass form)

$$ y^2 = x^3 + ax + b \pmod{p}$$

especially prime field $\mathbb{F}_p$. points $(x, y)$ satisfying + identity $O$ (无穷点).

Key fact: ECC 上能构群运算 (point addition): $P + Q = $ 其他由 chord-tangent_gettime 线交曲线 third point 反. 群运算是点 + 标量乘法 ($k \cdot P = P + P + \cdots + P$).

4.2 ECDLP

给定 base point $G$ and $k G$, find $k$. 这是 trapdoor inverse: 给 $k, G$ 求 $kG$ 是 O(log k) (binary scalar mult), 但给定 $kG$ 求 $k$ 无已知 polynomial 算法 (general).

4.3 ECC keygen

def ecc_keygen(curve):
    d = random.randint(1, curve.n - 1)
    Q = d * curve.G
    return d, Q

公钥 $Q$ 是上 curve 上一个 point; private scalar $d$ 数字.

4.4 Curve 选择

CurveFieldSizeUse
secp256k1$\mathbb{F}_p$, p = 2²⁵⁶ − 2³² − 977256Bitcoin
P-256 (secp256r1)$\mathbb{F}_p$, p pseudo-random256TLS widespread
Curve25519$\mathbb{F}_{p}$ where p = 2²⁵⁵ − 19256Modern (Signal, SSH, modern TLS, WireGuard)
secp384r1$\mathbb{F}_p$ pseudo-random384rare old TLS
Curve448$\mathbb{F}_p$, p = 2⁴⁴⁸ − 2²²⁴ - 1448RFC 7748 sister

Curve25519 vs P-256:

  • Curve25519 是 Montgomery curve, 实施常数时间 X25519 function (RFC 7748) 极简:
    X := x; { A24 := 121666; for i := 254 downto 0: swap bit; ... }
    
  • P-256 (NIST) curve 含特殊系数 + 没严格 constant-time 在 OpenSSL 历史多次 buggy (UG / Hydra cross-version compile 此).

4.5 X25519 实现 (打折简版)

def x25519(k: int, u: int) -> int:
    p = 2**255 - 19
    A24 = 121665
    x1 = u
    x2, z2 = 1, 0
    x3, z3 = u, 1
    swap = 0
    for t in range(254, -1, -1):
        k_t = (k >> t) & 1
        swap ^= k_t
        x2, x3 = cswap(swap, x2, x3); z2, z3 = cswap(swap, z2, z3)
        swap = k_t
        A = (x2 + z2) % p
        AA = (A * A) % p
        B = (x2 - z2) % p
        BB = (B * B) % p
        E = (AA - BB) % p
        C = (x3 + z3) % p
        D = (x3 - z3) % p
        DA = (D * A) % p
        CB = (C * B) % p
        x3 = ((DA + CB) ** 2) % p
        z3 = (x1 * ((DA - CB) ** 2)) % p
        x2 = (AA * BB) % p
        z2 = (E * (AA + A24 * E)) % p
    x2, x3 = cswap(swap, x2, x3); z2, z3 = cswap(swap, z2, z3)
    return (x2 * pow(z2, -1, p)) % p

def cswap(swap, a, b):
    return (b, a) if swap else (a, b)

完整实装 ~100 行, Bernstein original C 论 X25519 文源. 没有任何 secret-dependent branch 看到 — constant-time by construction.

4.6 安全级别比较

ClassRSA modulusECC paramsQuantum solvable?
80-bit1024-bit160-bitNo
112-bit2048-bit224-bitYes (Shor)
128-bit3072-bit256-bitYes (Shor)
192-bit7680-bit384-bitYes (Shor)
256-bit15360-bit521-bitYes (Shor)

note

ECC 在所有非量子场景下优势: key 小, cert 小, 握手快, 签短小 含 ChainLink markers 解 password; signature chain 检起加速 from 与 CRL / DFP considerations.

4.7 Shor 量子级破 ECC

Shor 算法能 solve DLP on elliptic curves in $O(L^{1.5})$ time. 量子机 6N+3 logical qubits 可破 256-bit ECC. Lattice-bundling protection → PQC ML-KEM (CRYSTALS-Kyber) / ML-DSA / ECC migration milestone 2024-2030 industry.


五、ECIES: 用 ECDH 替代 RSA

ECIES (Elliptic Curve Integrated Encryption Scheme) 是用 ECDH 协商 secret 然后 symmetric encrypt:

  1. 接收方公钥 $Q$; 接收方私钥 $d$.
  2. 加密方临时 $e$: ephemeral key $E = e G$. 计算 $S = e Q$; derive $k_{enc}, k_{mac}$ KDF(S).
  3. ciphertext $C = \text{SymmetricEncrypt}(k_{enc}, m)$; tag = MAC($k_{mac}$, $C$).
  4. Output $(E, C, tag)$.
  5. 接收: derive $S = d E$; KDF; decrypt; verify tag.

工程: 用 libsodium crypto_box 的 sealed box 加密 API 直接.


六、工程级 Pitfall checklist

  • ✗ 自己 RSA 实现 OAEP; ✓ use OpenSSL / libsodium RFC 8017 implementation
  • ✗ ECDSA nonce random — risk 短 shlor collide reuse; ✓ deterministic (RFC 6979) 用 key+msg hash 出 nonce
  • ✗ DDH 假设下加密 plaintext 直接 = $m G $ 否 disambig 验**—Theory hinge critical, direct DLP products, use end optional. ✓ ECIES / proper KEM scheme
  • ✗ Don't use secp256k1 上 over curve 派生 type 回曲验证 (the input point must satisfy the curve equation)
  • ✗ Use same key for signing and encryption — different algorithms / different keys in PIV PVI certified distinguish
  • ✗ 永远不要把 ECDSA 两个签名 (with same k) 共用 nonce (PS3 hack 2010) → private Kate.
  • ✓ Edwards curve Ed25519 signing is also 一致ly faster & smaller & frees you from k management.

七、与项目其他章节的交叉

  • number-theory.md: GCD inverse, Pascal's theorem, mod-arithmetic, Euler totient — 这 area 是 crypto foundation.
  • theory/complexity.md / co-NP / factoring: FACTOR ∈ NP ∩ co-NP 面临 Shor Algorithm-QCATTAC optimal but not NP-hard → 建议 likely complexity separation.
  • os/net/xdp-dpdk.md: TLS 一些 ASIC offload 已痕迹, Cloudflare DPDK / TLS inline hotp.
  • distributed/consensus: 类 DA ledger system (Bitcoin consensus) 用 PoW hashcash redirect with secp256k1 ECDSA on each UTXO spender implementation mass 用 ECC API.
  • system-design/queue/outbox: at-least-once encrypted tokens idempotency allow charge signature stack.

下一节 → 密钥交换: DH / ECDHE / X25519

4. 密钥交换: DH / ECDHE / X25519

TL;DR

两方从未见面通过公开信道协商一份共享秘密——这就是 Diffie-Hellman (DH) 1976 给人类的革命. 现代用ECDHE (ephemeral): 每会话生短期私钥, 计算 $Q = kG$, exchange value, derive shared secret via HKDF. 短期公钥永远不验证 (这就允许 zero-knowledge identity: even long-term key leakage 离 archive 已发密技不朽 ephemeral private key). WireGuard 用 Noise Protocol framework + Curve25519; TLS 1.3 强制 PFS (Perfect Forward Secrecy) by ECDHE-only design (deprecated static RSA).


一、Modular-group DH

1.1 Algebraic setting

$\mathbb{Z}_p^*$ 是 integers mod prime $p$. 取 generator $g$ (a primitive root $\bmod p$). $g, p$ public. 任一方 $i$ 取私钥 $a$, 计算公钥 $A = g^a \bmod p$.

1.2 Key agreement

Alice: $A = g^a$ 发 Bob. Bob: $B = g^b$ 发 Alice.

Alice 计算 $(B)^a \bmod p = g^{ab} \bmod p$. Bob 计算 $(A)^b \bmod p = g^{ab} \bmod p$.

→ shared secret $g^{ab}$. 攻击者见 $A, B, g, p$, 求 $a$ 或 $b$ 即解 discrete log problem (DLP) in $\mathbb{Z}_p^*$.

1.3 RFC 7919 (FFDHE)

NIST/ECC-supported 群列表:

  • ffdhe2048: 112-bit security
  • ffdhe3072: 128-bit

TLS 1.3 deprecated RSA-signature key exchange; 明列 FFDHE体 兼容 (custom fixed well-known + Bite ECC).

1.4 Python 实现

def dh_keygen(group_g: int, group_p: int) -> tuple[int, int]:
    from secrets import randbelow
    a = randbelow(group_p - 2) + 1
    A = pow(group_g, a, group_p)
    return a, A

def dh_shared_secret(a: int, other_pub: int, group_p: int) -> int:
    return pow(other_pub, a, group_p)

二、ECDH (Elliptic Curve DH)

Recall from asymmetric.md: ECC group law gives $kG$ fast, given $kG$ recover $k$ hard (ECDLP).

Alice/Wireguard/Signal iterate X25519 over Curve25519 收口拿 32-byte 公钥 (Bob with nonce X25519(...)).

2.1 X25519 标准

def x25519_scalar_mult(k: bytes, u: bytes) -> bytes:
    k_int = int.from_bytes(k, 'little') & ((1 << 255) - 1)
    k_int &= ~7; k_int |= 1 << 254  # clamping per RFC 7748
    u_int = int.from_bytes(u, 'little') & ((1 << 255) - 1)
    return x25519(k_int, u_int).to_bytes(32, 'little')

Clamping 是设计坚守点 infinality 公钥验证抚摸: If an attacker sends a small-order point (called low-order curve subgroup), clamping + Curve25519's cofactor merge ensure shared secret 计算不 break 协议. (Curve25519 has cofactor 8.)

2.2 Use API

from cryptography.hazmat.primitives.asymmetric.x25519 import X25519PrivateKey, X25519PublicKey

def ecdh_curve25519():
    alice = X25519PrivateKey.generate()
    bob = X25519PrivateKey.generate()
    alice_pub = alice.public_key().public_bytes_raw()
    bob_pub = bob.public_key().public_bytes_raw()
    shared_alice = alice.exchange(X25519PublicKey.from_public_bytes(bob_pub))
    shared_bob = bob.exchange(X25519PublicKey.from_public_bytes(alice_pub))
    assert shared_alice == shared_bob
    return shared_alice

(False 比例) ≈ 同 sanity 那些 ~32 byte 定私钥 derive HKDF, then derive 4 keys (client/server, enc/mac), 立刻 runnable TLS.


三、Ephemeral DH: PFS 之核心

Static DH: Alice/Bob 两方各信任长期公钥升 协商永久 secret. 一旦 hash 重收, 该 derivation shared secret 可 甲 默粒 — 实主密钥含"sche session" 前着多 prefix 出.

Ephemeral DH (ECDHE): 每会议生短期私钥, exchange, derive, 然后destroy private ephemeral. 即使长期签名密钥泄露, 已协议会话 secret 已不可解 (ephemeral key 已 gone).

Perfect Forward Secrecy (PFS): "未来 leak 不解 当时 session".

3.1 PFS examples

  • TLS 1.3: ECDHE 是默认, 强制 PFS. RSA-only key transport 已 deprecate.
  • Signal: Double Ratchet 在 DH 基础上叠加每消息级 forward secrecy, 并进一步做到 post-compromise security——密钥一旦泄露, 后续自动轮换会逐步恢复安全性。
  • SSH: curve25519-sha256@libssh.org 是 default since 2015.

3.2 Why static RSA was bad

旧 TLS 1.0-1.2 RSA key transport: client 用 server pub-key RSA 加密预主秘密发给 server. Server — decrypt → shared secret. PFS 没起 effect because server 长期 RSA 私钥 leak ⇒ 全 archive traffic 都可 decrypt later (with recorded ciphertext). PRISM 后 industry panic all-transit ECDHE.


四、Hybrid KEM (post-quantum era)

NIST 后量子标准化最终选定 Kyber (CRYSTALS-Kyber → FIPS 203 / ML-KEM), 基于格上 Module-LWE 困难问题:

  • ML-KEM-768: ~128-bit 安全等级, 公钥约 1.2 KB
  • ML-KEM-1024: ~256-bit 安全等级

Hybrid ECDH + Kyber (X25519 + Kyber768 已在 Chrome / Cloudflare / Apple iMessage PQ3 规模化部署): 两条 KEM 各自产出共享秘密, 拼接后经 HKDF 导出唯一会话密钥. 这样即使量子计算机明天问世, 攻击者今天归档的流量也无法事后解密 (防 harvest-now-decrypt-later); 同时经典 ECDH 兜底——格密码一侧若被发现缺陷, 另一侧仍在。

# pseudocode
ecdh_secret = ECDH_X25519(client_priv, server_pub)
k_kem_secret = ML_KEM_768.encap(server_pub)
final = HKDF-Extract(label="hybrid key exchange",
                     ikm=concat(ecdh_secret, k_kem_secret))

Google, Cloudflare, Apple (iMessage PQ2 已启用) 各自 2023-2024 部署 hybrid PQ TLS into servers/clients.


五、Three-pass 协议 (No DH): Noise framework

WireGuard 用 Noise Protocol Framework (Perrin 2017):

  1. Initiator → Responder: ephemeral E_i = e G + ephemeral encryption using a chained hash.
  2. Responder → Initiator: E_r + MAC over cha.
  3. Both → derive final keys HKDF.

WireGuard Handshake 仅 1 RTT, 全 ephemeral Curve25519, 两包 each 64 bytes 总共 144 bytes on wire + UDP header. 比 IPsec/IKE phase 1 (static RSA handshake ~10 packets) 紧密度显著.


六、M-Anon selection: PSK vs DH

PSK (Pre-Shared Key) for hand-held device provers:

  • both already shared K out-of-band (利 help of QR 圣 iter fob)
  • no DH need; HMAC session directly.
  • e.g., Apple AirTag Beacon 与 iPhone PSK 已 family session.

TLS 1.3 PSK:

  • TLS 1.3 ClientHello 含 identity PSK + binder HMAC (HKDF over PSK)
  • Server retrusted PSK + ECDHE → 提供前向保密性 post sacred armand

NB: Through PSK continuation 握手 skip → without DH MPTCP long enough (just session ticket). TLS resumption = PSK with ECDHE combined just gives lower RTT (0-RTT) but same PFS.


七、Key compromise: Signal "off-the-record" 公西么是处理

Double Ratchet (Cohn-Gordon et al and Perrin and Marlinspike 2017):

  1. Initial DH shared secret root.
  2. Symmetric ratchet: every message advances chain key one step (one message one key) → PFS 后的每一条新 message.
  3. DH ratchet: any party's ephemeral key received triggers a re-Derive → PCR (post compromise security).

→ Signal protocols 实现双重随双 DH: message sender & receiver update upon DH fetch. Compromise → 追踪 only limited short "ratchet continuous" till next DH ratchet step.


八、工程改造 matrix shared 공급

协议KeypairEphemeral?PFSQuantum-resistant
TLS 1.2 ECDHE_RSARSA2048 + X25519ECDH Ecdhe
TLS 1.3 defaultX25519 + ECDSA P-256
TLS 1.3 hybrid PQ (Google)X25519 + Kyber768
WireGuardCurve25519 long-term + ephemeral
SignalX25519 + Double Ratchet
Apple iMessage PQ3X25519 + Kyber✓ (intra-msg BD + DR)✓ partial
SSH curve25519ed25519 + X25519

九、桥梁

  • asymmetric.md: 简要 ECC math; full crypto on ECC.
  • distributed/consensus: 代 save across—all. 按各 dilio一时间 ago TLS records broken on failure does forward secrecy separate.
  • system-design/case/k8s-control-plane: mTLS certs in service mesh, secret rotation + ECDHE auto phase.

下一节 → 签名: RSA-PSS / ECDSA / Ed25519

5. 数字签名: RSA-PSS / ECDSA / Ed25519

TL;DR

签名 (signature) = 加密的逆向: 公钥验证, 私钥签名. 三个主流:

算法私钥 size签 size速度安全模型Nonce
RSA-PSS2048-3072 bit256 / 384 B慢 (慢 N²)RSA factoring
ECDSA256 bit64 B中等ECDLPsecret, anti-reuse
Ed25519256 bit64 B极快TW Edwards curve ECDLPdeterministic

工程最佳实践: Ed25519 默认, 当 constrained 用 ECDSA P-256 (TLS), 弹起的合理但当 off-sites RSA-3072 PSS 时 legacy. 绝对不要 ECDSA 重 nonce 重用 (Sony PS3: 私钥 α mins 倒 out).


一、RSA-PSS (RFC 8017)

1.1 签名

给 message $m$ and key $k_{priv}$:

  1. msg_hash = Hash($m$) (SHA-256 recommended)
  2. salt = random 32 bytes (same length as hash output)
  3. m' = pad || msg_hash || salt
  4. H = Hash(m'), DB = pad || H || 0x01; 用 MGF1(H) mask DB
  5. em = maskedDB || H
  6. σ = em^d mod n (RSA private op)

Verify:

  1. m' = σ^e mod n
  2. split em → maskedDB, H, recover DB via MGF1
  3. recover salt from m' = pad || msg_hash || salt
  4. verify Hash(m') == H, salt byte Length.

1.2 Strength

  • "Probabilistic" - 同 message 的两个 σ 不同 (salt random).
  • Proof in random oracle model: EUF-CMA secure under RSA assumption.

1.3 劣势

  • 2048-bit minimum, sigs 256B (vs Ed25519 64B);
  • signing cost O(n²) with private exponent, ±1 ms LOG slow for HTTP;
  • TLS 1.3 RSA-PSS size force штраф钉戴;

Industry 2024 趋向: pure ECDSA/EdDSA more historically RSA-PSS required. 95% cert chain validations 是 RSA-PSS+.


二、ECDSA (X9.62 / FIPS 186-5)

2.1 Set-up

Curve $(G, n, p)$: $G$ base point, $n$ 是 $G$'s order, $p$ prime.

2.2 签名

1.eph $k \in [1, n-1]$, nonce. 2. $(x_1, y_1) = kG; r = x_1 \bmod n$. (r 不能 0, 若则挑新 k.) 3. s = k^{-1}(hash(m) + z · r) mod $n$ where $z$ is private key. 4. signature = (r, s).

2.3 Verify

Given pubkey Q, msg m, sig (r, s):

  1. w = s^{-1} mod n; u1 = hash(m) · w mod n; u2 = r · w mod n.
  2. $(x', y') = u_1 G + u_2 Q$. Accept if $r = x' \bmod n$.

正确: $u_1 G + u_2 Q = u_1 G + u_2 z G = (u_1 + u_2 z)G = (hash · w + r · z · w) G = w(hash + r z) G = w · k · s · G = k G$ 业 key.

2.4 致命 nonce 安全性

恢复 nonce 或 nonce 重用 → 私钥 leak:

给开 message $m_1$ 和 $m_2$, 用同 k 签: $(r, s_1, s_2)$ known + r hash: $z = (s_1 - s_2)^{-1}(h_1 - s_2 k)$ (anew derive,...).

写: $$k = (s_1 - s_2)^{-1}(h_1 - h_2) \bmod n, \quad z = (s_1 k - h_1) \cdot r^{-1} \bmod n$$

→ Sony PS3 2010 hack: 同一 k 全 Console signer's header → hacker poche $r, s_1, s_2$, → private key z within minutes → homebrew freedom.

warning

ECDSA nonce 不能 用 unsafely-derived random. RFC 6979 gives deterministic nonce via HMAC(key, msg): $k = \text{HMAC}{key}(\text{HMAC}{key}(m))\bmod n$. This avoids random source hazard. Never implement ECDSA without RFC 6979.

2.5 public API 工程模式

from cryptography.hazmat.primitives.asymmetric.ec import ECDSA, EllipticCurvePrivateKey
from cryptography.hazmat.primitives import hashes

# standard ECDSA (with RFC 6979 deterministic k):
private_key: EllipticCurvePrivateKey = ...
signature = private_key.sign(data=data, signature_algorithm=ECDSA(hashes.SHA256()))

三、Ed25519: 真正现代签名

3.1 设计

Ed25519 (RFC 8032): Twisted Edwards curve $-x^2 + y^2 = 1 + d x^2 y^2$ over $\mathbb{F}_p$ for $p = 2^{255} - 19$. 比 Weierstrass form 多个 aldow:

  1. Complete addition formula: 对所有 points 给出 same op, corner case free.
  2. Deterministic nonce from key prefix: $r = \text{Hash}(\text{prefix} | m)$, prefix = hash(private_key)[:32]. No random nonce — automatic secure.
  3. Batch verify: verify $n$ signatures simultaneously O(log n) faster.
  4. Faster: ed25519 sig ~ 50 µs Python, 30 ns optimized AVX (libsodium).

3.2 算法 outline

private (z)                  ->  public Q = z * B (B is base point)
pr = Hash(z)                ------------------  (64 byte hash)
prefix = pr[32:64]
r = Hash(prefix || m) mod n  ---- deterministic nonce
R = r * B
s = (r + Hash(R || Q || m) · z) mod n
signature = (R || s)         (64 bytes)

Verify:

S = s * B + (Hash(R || Q || m) mod n) * Q  -> check S == R

3.3 Python 实现 (纯)

import hashlib, hmac

p = 2**255 - 19
d = (-121665 * pow(121666, -1, p)) % p
q = 2**252 + 27742317777372353535851937790883648493

def H(b):
    return hashlib.sha512(b).digest()

def Hint(b):
    return int.from_bytes(H(b), 'little')

def sc_reduce(x):
    return x % q

def inv_mod(x, m):
    return pow(x, m - 2, m)

def edwards(P, Q):
    x1, y1 = P
    x2, y2 = Q
    dxx = (d * x1 * x2 * y2 * y2) % p
    x3 = ((x1 * y2 + x2 * y1) * inv_mod(1 + dxx, p)) % p
    y3 = ((y1 * y2 + x1 * x2) * inv_mod(1 - dxx, p)) % p
    return (x3, y3)

def scalarmult_B(k):
    B = ...  # basepoint coordinates
    k = sc_reduce(k)
    R = (0, 1)
    for i in bin(k)[2:]:
        R = edwards(R, R)
        if i == '1':
            R = edwards(R, B)
    return R

(实际 openssl libsodium 写 Inside about 100 lines precomputed tables → ~ Methane fits.)

3.4 Ed25519 EdDSA vs 普通ECDSA

维度ECDSAEd25519
signature size64B (DER encoded in x.509 = ~72B)64B raw
private key32B32B
public key33 / 65B (compressed)32B
speedsigning ~1ms; verify 0.5 mssigning 25 µs; verify 80 µs
batch verifynoyes, O(log n)
noncerequired (RFC 6979)deterministic
Σ blockchainBitcoin uses secp256k1 ECDSA (legacy)Solana & newer systems use Ed25519

Ed25519 现代应该是默认 in systems you write — control-plane, JWT, signed URLs, SSH keys.


四、non-repudiation 与多重签名

4.1 non-repudiation

签名同时:

  • 别人能用公钥验你 sign 了.
  • 你不能合理否认 sign 了.

应用: legal contracts, software release tags. (pgp, OpenPGP/GPF/sonbbox排除限制别的来信 theory)

4.2 Multisig and threshold

  • m-of-n threshold: n key holders, m signatures required for valid tx.
    • Bitcoin P2SH with bare multisig.
    • FROST (Schnorr-based threshold scheme, 2020) — NIST also has standard consumer'a threshold.
  • Aggregated signatures: BIP340 Schnorr allows multiple keys → single signature (Bitcoin Taproot uses key aggregation).

4.3 Adaptor signatures

Pre-sign a signature under some condition pk', release one signature $s'$. Reveals $s-s'$ contains tweak $t$ → can extract $t$, that's payment secret. Lightning Network HTLCs use this.


五、Pitfall & 工程实践 checklist

  • ✗ ECDSA 加上 rand() nonce — math random() in some languages may be predictable. Use crypto.randomBytes.
  • ✗ "I'll just sign JSON, it'll be canonical." → canonicalization issues (e.g. JSON field order) 可能违约 classify.
  • ✗ Using same Ed25519 key for signing AND X25519 key exchange — different subdom crippler starving. Use separate keys.
  • ✓ Verify signature AND chain of trust on input.
  • ✓ Use libsodium crypto_sign_*, ed25519_t *, OpenSSL's EVP_PKEY_sign — do not roll async key.

5.1 signal SIG careers

Apple App Store 的 App code signing 历史上切到 EdDSA-edge = EdDSA secure multi-stage 认证壹 Closure-Cert 有限 wants task tri corelu 道备10.


六、桥梁

  • asymmetric.md: ECC math background.
  • pki.md next: cert chain validates requires verify signed by trusted root → uses ECDSA/EdDSA/RSA-PSS.
  • tls13.md next-section: TLS certificate chain uses algorithm + signature scheme tuples.
  • distributed/clock/dag.md: blockchain uses ed pairs for id (pseudonymous)., signature费 BF def BC needed (EB пят外 attenuation.)

下一节 → 哈希: SHA-256 / SHA-3 / BLAKE3

6. 哈希: SHA-256 / SHA-3 (Keccak) / BLAKE3

TL;DR

密码学哈希函数 $h: {0,1}^* \to {0,1}^{n}$ 满足:

  1. Preimage resistance: 给 $y$ 找 $x$ 使 $H(x) = y$ 困难 (~ $2^n$ attempts).
  2. Second-preimage resistance: 给 $x$ 找 $x' \neq x$ 使 $H(x) = H(x')$ 困难 (~$2^n$).
  3. Collision resistance: 找任意 $x, x'$ 使 $H(x) = H(x')$ 困难 (~$2^{n/2}$ by birthday).

3 个工业选:

  • SHA-256: Merkle-Damgård + Davies-Meyer, since 2001. 抗量子前最流行; NIST still standard.
  • SHA-3 (Keccak): 海绵构造 (sponge), 2015, FIPS 202. 抗长度扩展, 双标准与 SHA-2 平行.
  • BLAKE3: 基于 BLAKE2 + Merkle tree, 2020, parallel + SIMD 极快.

一、SHA-256 (Merkle-Damgård)

1.1 Merkle-Damgård

state IV (256 bit固定)
schleife over 512-bit block:
    state = compression(state, message_block)
output state XOR 是最终 state

加上 padding: append 1 bit + zero pad + final 64-bit length.

1.2 Compression

state: 8×32 bit words (a..h), 从 IV 收
每 block 64 rounds of:
    T1 = h + Σ1(e) + Ch(e,f,g) + K[i] + W[i]
    T2 = Σ0(a) + Maj(a,b,c)
    h = g; g = f; f = e; e = d + T1
    d = c; c = b; b = a; a = T1 + T2

K[i] 是 64 个 32-bit 个 (use cubic root fractional of primes from 2-311). W[i]: 前 16 from current block, 后 48 derived by rotate/xor chain message schedule.

1.3 输出长度 / 安全

  • 256-bit output.
  • birthday attack cost $2^{128}$ ⇒ collision hard until quantum time.

1.4 长度扩展攻击

Merkle-Damgård 的副产品: 给 H(secret || msg), 攻击者可计算 H(secret || msg || padding || extension) 无需已知 secret. 因 state = current final state. Hash-based MAC (HMAC) 规避 — outer hash 包住 一次 formancée 一个 accelerationdont.


二、SHA-3 (Keccak, 海绵构造)

2012 NIST 选 Keccak 5 个 designer 之 择利际化胜事; SHA-3-256/384/512 spec.

2.1 Sponge absorbtion + squeezing

state: 1600-bit internal state.

Absorb:
  for each rate r block:
    state ^= block
    Keccak-f(state)
Squeeze:
  output = state[:capacity-r]
  while more output:
    Keccak-f(state)
    output += state[:rate]

rate = 1088 for SHA3-256 (24 keccak-f rounds).

2.2 Keccak-f permutation

7 transformations repeated 24 rounds: theta / rho / pi / chi / iota. Operation on 5×5×64 word 'cube' state.

  • chi: only nonlinear step (bitwise AND+NOT+XOR).
  • iota: add round constant Singleton.

2.3 Properties

  • ✓ No length extension attack — output XOR 状态 部分隐藏 "capacity" 内边界.
  • ✓ Generic sponge: rates 差异 → variable-length output 's extend.
  • ✓ Used in NIST PQC (Dilithium operations vest state) and TLS。

三、BLAKE3

BLAKE3 (brand O'Connor, Aumasson, et al 2020) 是 B2 (BLAKE2) 升级版用 Merkle mode 后潜出 fast in software AVX2 高 GPU.

3.1 架构

  • chunk size = 1024 bytes
  • 每个 chunk 单独压缩
  • chunks build binary Merkle tree with chained chaining 大 跨 展 map.
        ROOT
        /  \
      N     ...
     / \
    A   B
   /|   |\
 chunk chunk chunk

3.2 优

  • Parallelizable: 所有 chunk 压缩独立, SIMD 8-wide throughput 巨高, 1-10 GB/s 软端.
  • Streaming: 内部 chunked tree — 大文件逐块 hash 不必 cache 全文件 in memory.
  • Tree hashing: extend 中衍生 MAC / KDF / PRF for tree of leaves.
  • Merkle proof: 给任 leaf 提供 sibling path → 验证 fixed file hash without re-read whole file.

3.3 工程接口

import blake3
print(blake3.blake3(b"hello").hexdigest())
# 24eab52c2dde57d2e7d6f0d3f8c44e1ab1a1c5ee4e7b1a4d80b20f4a0e5d5b09

四、SHA-256 vs SHA-3 vs BLAKE3 Comparison

维度SHA-256SHA-3-256BLAKE3
整体速度 (architecture AVX2)1.5 GB/s0.6 GB/s7-10 GB/s (parallel, AVX2 8x)
Paddinglength-extension vulnerableinvulnerableinvulnerably tree
Security128-bit collision128-bit collision128-bit collision
Special input(no guidance)variable output table scalingvariable output
Hardware accelSHA-NI ~1 GB/s; AES-NI 上 shim-provided &不用 sha-ni, 软件中等SIMD-vector AVX2 ~8 GB/s
StandardizationFIPS 180-4 (2001)FIPS 202 (2015)RFC draft, 2020
用 use 默认 caseTLS, IPsec, BitcoinNIST选项, PQ cryptomodern general-purpose 哈希

工程选:

  • 代码pered protocols (TLS, IPSec) 用 SHA-256 or SHA-3 (双选 in TLS 1.3 cipher suites).
  • New systems 挖 摆满 — 宝 BLAKE3 in Rust 流标 other coder hand 提速 ✓ 跑 SHA-NI / faster SIMD 使用 SHA-2 hardware 攻 上 SSE-installed.
  • Post-quantum cryptography algorithm (FIPS 205 SLH-DSA hashes Rock params if state — directly 用 Hash-based signatures珠宝依 SHA-2/SHA-3 that's why).

五、HMAC (Hash-based MAC)

HMAC(K, m) = H((K ⊕ opad) ‖ H((K ⊕ ipad) ‖ m))

ipad = 0x36, opad = 0x5c repeated padding block 长度.

5.1 安全属性

  • 任意 secure PRF (H secure) 给 HMAC K-MAC secure (Bellare-Canetti-Krawczyk 1996).
  • 抗长度扩展 优势 over plain Hash(K ‖ m) (broken by length extension).
  • HMAC-SHA-256 是 JWT/SAML 标准. Sign message 用 MAC, msg self-contained.

5.2 Python の

import hmac, hashlib

def hmac_sha256(key: bytes, msg: bytes) -> bytes:
    return hmac.new(key, msg, hashlib.sha256).digest()

# constant-time verify:
hmac.compare_digest(m resigned tag bytes, received tag bytes)

warning

Naive tag_a == tag_bnot constant-time in Python 由于 short-circuit. 用 hmac.compare_digest 或 C CRYPTO_memcmp.


六、HKDF (RFC 5869)

extract: PRK = HMAC(salt, IKM)            (pseudo-random key)
expand:  OKM = T(1) || T(2) || ... where T(i) = HMAC(PRK, T(i-1) || info || i)

KDF 链长拒绝扩展出 255*HashLen bytes. 实际零期 account look op hello used for TLS1.3 deraved each context different info 简化 secrets separation.

6.1 工师 helper

def hkdf_sha256(salt: bytes, ikm: bytes, info: bytes, length: int) -> bytes:
    prk = hmac.new(salt, ikm, hashlib.sha256).digest()
    okm = b""; t = b""; i = 1
    while len(okm) < length:
        t = hmac.new(prk, t + info + bytes([i]), hashlib.sha256).digest()
        okm += t; i += 1
    return okm[:length]

七、密码学哈希 use case table

用途推荐算法Placeholder 实例
File hash/integritySHA-256 or SHA-3-256Git tree hashish: SHA-1 still (replacing with SHA-2 in new Git)
MACHMAC-SHA256 or Poly1305TLS AEAD, JWT
KDFHKDF-SHA256TLS1.3 secret derivation
PoWSHA-256Bitcoin block hash
VRF (verifiable random function)EC VRF (Ed25519 VRF)Algorand、中国 IPFS IPNS sign path
Password hashingArgon2id or bcrypt/scrypt✓ SHA-256 alone is unsafe due to speed
Key commitmentBLAKE3fast derive pool
Content addressingSHA-256 / SHA-3-256 / BLAKE3IPFS uses SHA-256 family
Tree hashBLAKE3 or RFC 6966 (RFC6962)CT log Merkle tree hash

7.1 Password hashing Special 注意

普通 hash 快但 password 用太快 → brute force 提升光速。Use 强哈希 Argon2id (PHC winner 2015) — memory-hard randomized.

from argon2 import PasswordHasher
ph = PasswordHasher()
hash_str = ph.hash(b"password")   # stored: param-salt-hash one-liner
ph.verify(hash_str, b"password")

八、Merkle tree 实践

比特币 block hash = SHA-256d (double SHA-256) of block header. Merkle root = pairwise hash leaves pair.

import hashlib

def sha256d(b):
    return hashlib.sha256(hashlib.sha256(b).digest()).digest()

def merkle_root(leaves: list[bytes]) -> bytes:
    if not leaves:
        return b'\x00' * 32
    while len(leaves) > 1:
        if len(leaves) % 2 == 1:
            leaves.append(leaves[-1])
        leaves = [sha256d(leaves[i] + leaves[i+1]) for i in range(0, len(leaves), 2)]
    return leaves[0]

Ceph used CRUSH 计算 hash (using Robert Jenkins hash) distribute 不 competent 弘取 files 而非 truly auth 加密哈希; 用户放心 (non cryptographic).


九、桥梁

  • asymmetric.md: Keccak sponge is the basis for PQC (CRYSTALS-Dilithium hashing).
  • distributed/fault/erasure.md: Merkle tree witnesses for RS / merkle proof of inclusion.
  • databases WAL: WAL checksum (CRC32C / xxHash) — 这些不是 cryptographic hash, BC focus fast error detection.
  • theory/complexity: ideal hashes are random oracles → e.g. random oracle model for design proofs: HMAC-via-keyed hash proof formal in this modelrevolution.

下一节 → TLS 1.3 握手

7. TLS 1.3 握手全流程: ClientHello → ServerHello → EncryptedExtensions → Finished

TL;DR

TLS 1.3 (RFC 8446, 2018) 是 TLS protocol 重构版本. 比 1.2 大幅简化:

  • 仅留 AEAD mode (GCM/CCM/ChaCha20-Poly1305).
  • 仅留 ECDHE/PSK 实现密钥交换, 移除静态 RSA key transport.
  • 仅留 RSA-PSS / ECDSA / EdDSA 签名, 移除 PKCS#1 v1.5 signature.
  • 普通 handshake 仅 1 RTT; PSK + 0-RTT 可实现 zero delay data.
  • 全程 handshake 后大部分 handshake message 加密 (切 Modern crypto 保护以防流量分析).

读完此章你能写出 wire-shark capture read 都解 TLS 1.3 of:

  1. ClientHello 是标准 plaintext, 但内可含 early-data (0-RTT)。
  2. ServerHello 也 plaintext, 之后 ServerEncryptedExtensions 用 server_handshake_traffic_secret 加密.
  3. ServerCert, ServerVerify, Finished 都加密.
  4. ClientFinished 后, 双方导出 application_traffic_secret 与 master_secret for SIMD AEAD traffic.

一、Wire 帧 (record layer)

每个 TLS record 5-byte 头 + AEAD payload:

struct {
    ContentType type;                    // 1 byte: 22 handshake, 23 application data, 21 alert, 20 change_cipher_spec deprecated
    ProtocolVersion legacy_version = 0x0303;  // 仍 TLS 1.2 wire, 实际 TLS 1.3 用 record_has_extension看出
    uint16 length;
    opaque record<length>;                // AEAD ciphertext: nonce || ciphertext || tag
}

ContentType 不暴露真实঍ (handshake / app) — Middleboxes 想看必须 decrypt; 但 0-RTT / 1.3 gen-1 始 finish eye-encrypted¶ internal.


二、握手消息 hierarchy

ClientHello                         (plaintext)
  → Extensions:
    supported_versions: 0x0304       // alone marker 标识 TLS 1.3
    key_share: X25519 / secp256r1 / etc.
    signature_algorithms: ed25519, ecdsa_secp256r1_sha256, rsa_pss_rsae_sha256
    supported_groups: x25519, secp256r1
    cipher_suites: TLS_AES_128_GCM_SHA256, TLS_CHACHA20_POLY1305_SHA256, ...
    early_data support (if 0-RTT prior session resumption active)

ServerHello                          (plaintext)
  → Extensions:
    supported_versions: 0x0304
    key_share: X25519 ephemeral server public key
    selected cipher_suite

EncryptedExtensions                  (encrypted under server_handshake_traffic_secret)
Certificate
CertificateVerify (signature)
Finished (transcript hash MAC)

ClientFinished                       (encrypted under client_handshake_traffic_secret)
...
application data                     (encrypted under application_traffic_secret)

三、密钥派生 (HKDF chain)

3.1 完整 derive chain (un-simplified)

early_secret = HKDF-Extract(salt=0, IKM=PSK || zeros)
empty_hash   = Hash("")
derived      = HKDF-Expand-Label(early_secret, "derived", empty_hash, Hash.length)

handshake_secret = HKDF-Extract(derived, ECDHE_shared_secret)
client_handshake_traffic_secret = HKDF-Expand-Label(handshake_secret, "c hs traffic", transcript_hash_ClientHello_ServerHello, Hash.length)
server_handshake_traffic_secret = HKDF-Expand-Label(handshake_secret, "s hs traffic", transcript_hash,...)

master_secret = HKDF-Extract(derived, 0)
client_application_traffic_secret = HKDF-Expand-Label(master_secret, "c ap traffic", transcript_hash_ClientHello..ClientFinished, ...)
server_application_traffic_secret = HKDF-Expand-Label(master_secret, "s ap traffic", same, ...)

client_application_write_key = HKDF-Expand-Label(client_application_traffic_secret, "key", "", 16 or 32)
client_application_write_iv  = HKDF-Expand-Label(client_application_traffic_secret, "iv",  "", 12)

HKDF-Expand-Label(secret, label, context, length):

info = length(2 bytes) || "tls13 " || label || context_len(1) || context
output = HKDF-Expand(secret, info, length)

3.2 完整 Python

import hmac, hashlib

def sha256_32(b): return hashlib.sha256(b).digest()

def hkdf_extract(salt, ikm):
    if not salt: salt = b'\x00' * 32
    return hmac.new(salt, ikm, hashlib.sha256).digest()

def hkdf_expand_label(secret, label, context, length):
    # RFC 8446 §7.1
    label_full = b"tls13 " + label
    info = length.to_bytes(2, 'big') + len(label_full).to_bytes(1, 'big') + label_full + len(context).to_bytes(1, 'big') + context
    # HKDF-Expand
    okm = b""; t = b""; i = 1
    while len(okm) < length:
        t = hmac.new(secret, t + info + bytes([i]), hashlib.sha256).digest()
        okm += t; i += 1
    return okm[:length]

具体 TLS 1.3 client/server secret 推导:

early_secret = hkdf_extract(salt=b'\x00'*32, ikm=psk_or_zeros)
empty_hash = sha256_32(b"")
derived = hkdf_expand_label(early_secret, b"derived", empty_hash, 32)
handshake_secret = hkdf_extract(salt=derived, ikm=ecdhe_shared)
th = sha256_32(client_hello + server_hello)             # transcript hash
c_hs_secret = hkdf_expand_label(handshake_secret, b"c hs traffic", th, 32)
s_hs_secret = hkdf_expand_label(handshake_secret, b"s hs traffic", th, 32)
master_secret = hkdf_extract(salt=derived, ikm=b'\x00'*32)

后 derive 阶段 traffic keys 一旦 transcript hash 收齐所有 handshake messages 包括 server finished + client finished, derive application 阶段 traffic keys (chain only 1 round, 实 detailed).


四、Process Flow (timing)

sequenceDiagram
    participant C as Client
    participant S as Server
    C->>S: ClientHello (key_share, PSK identity?), [early_data] 
    Note over C,S: round trip 1
    S->>C: ServerHello (selected group, key_share, cipher)
    S->>C: {EncryptedExtensions, CertificateRequest?, Certificate, CertificateVerify, Finished}
    C->>S: {ClientHello use indir, Certificate?, CertificateVerify?, Finished}
    Note right of C, S: 1 RTT complete, traffic encrypted
    C->>S: encrypted application data
    S->>C: encrypted application data

1 RTT (round-trip time) and ServerHello 刚回复即立即开始加密握手后续消息 - ServerHello 光是 plaintext 仅仅带 key_share. 求知道: turn handshakes timeline in packets:

  • 1 in 1 out (1 RTT) ⇔ 重要 after protocol.

4.1 vs TLS 1.2

1.2 默认需 2 RTT full handshake (ServerHello → ServerHelloDone → ClientKeyExchange → ChangeCipherSpec → Finished → Server Finished → Application Data).

  • TLS 1.3: 1 RTT.
  • TLS 1.3 with PSK + 0-RTT: 0 RTT (early data sent immediately).

五、0-RTT (early data)

5.1 Mechanism

Client 先需有 PSK—derived from resumed session (NHS) tick. PSK derive HKDF early_secret directly; Client 在 ClientHello 后 app立即发 encrypted early data.

Client → Server
  ClientHello (PSK identity, early_data extension)
  record 1: ApplicationData (early data, encrypted using early_data_write_key = HKDF-Expand-Label(early_secret, "e early data",...))

5.2 Risks

  • Replay attack: 老 ClientHello record 在不同 vendor rebroadcast 重害. Since encryption doenot 绑顺序 TCP packet identity. Mitigation: server 限 anti-replaySticket 数据库 (CloudFlare, Let's Encrypt Shield 号 USDido DNS 投资 overall).
  • 0-RTT 暂限制 type=GET (ointments GET (idempotent).

5.3 不 Optional

Default TLS 1.3 仅 with PSK available 时 0-RTT enabled. First automatically hand Prefer. Let'sEncrypt invoice servers 配 防御 90-days commands MOST iopW:1354.


六、AEAD record nonce

每 record 加密时:

  • base IV = HKDF-Expand-Label(traffic_secret, "iv", ..., 12)
  • record_nonce = base_IV XOR seq 序 (32-bit little endian)
def tls13_nonce(base_iv: bytes, seq: int) -> bytes:
    return (int.from_bytes(base_iv, 'big') ^ seq).to_bytes(12, 'big')

sequence 每条 record 增 1; wall seq+IV 是 sequential, no random nonce risk plaintext forgive. Kyeffeuse-rookie 不能 除非 协议 strcutly 切 fer 显示 long plain sequence (re-key after 2^32 — TLS1.3 下 key-update 控制状态 span).


七、CertificateVerify

Server signs transcript hash:

content = ASCII spaces (64*SPACE) || context_string || 0x00 || transcript_hash
sig = Sign(private_key, content)

specific context_string:

  • Server: TLS 1.3, server CertificateVerify
  • Client: TLS 1.3, client CertificateVerify

Transcript hash 是自 ClientHello + ServerHello + EncryptedExtensions + Certificate + ...


八、KeyUpdate

RFC 8446 §4.6.3: 任一方发 KeyUpdate request_update → 双方派生出新 traffic_secret:

new_traffic_secret = HKDF-Expand-Label(old_traffic_secret, "traffic upd", "", Hash.length)
new_write_key = HKDF-Expand-Label(...)
new_write_iv  = HKDF-Expand-Label(...)

每 2^35 record 自 key-update (避免 nonce 重用 exhaustion). 不超过 2^64 records.


九、Wire example (fictional)

16120200 d0 ...                       # ClientHello record: type=22, version=0x0303, length=720

0100 c0 fc ...                        # HandshakeType=ClientHello=1, length=0
  legacy_version = 0x0303
  random 32 bytes
  session_id variable
  cipher_suites, 12+ Algorithms
  compression_methods = 01 00
  extensions:
    00 2b 00 02 03 04           // supported_versions: 0x0304 (TLS 1.3)
    00 33 00 26 00 24 001d 0020  // key_share: X25519 32-byte pubkey
    00 0d 00 06 00 04 00 02 04 01 // signature_algorithms
    00 2a 00 04 00 02 00 1d     // supported_groups: x25519
    00 29 00 02 00 00           // PSK 支持 (zero modes)

ServerHello (22 + 0303 + length=88):
  02 00 00 54 ...
    00 2b 00 02 03 04
    00 33 00 26 00 24 001d [target server pubkey 32 bytes]

--> from now, all client/server messages encrypted using handshake traffic secrets derived from ECDH(x25519):
    server -> {EncryptedExtensions, Certificate, CertificateVerify, Finished}
    client -> {EndOfEarlyData (if 0-RTT), Certificate (+ client_verify), Finished}

after client_finished:
    both sides __ secretary application_traffic_secret_

See hero thanks realized multiple windows via tcpdump + Wireshark 第 alt 行用在 asymmetry 制(crossing lie in real-engagement?


十、ClientAuth mutual TLS

Client Auth (mTLS) 仅指服务多 cluster internal (Kubernetes mTLS internal). Server 在 EncryptedExtensions 后 Request Certificate — Client 必 Respond Certificate + CertificateVerify signing transcript.

BFT 容我 + cert internal+ secure protocol verification 校验 chain 加 DOM 通过年级 SECURITY electronic Entitlement plate when govern module planner.


十一、Pitfall & 工程

  • ✗ TLS-1.3 cipher 启用非 AEAD CBC packets ⇒ negotiation fails; 不要保留 CBC 老 cipher.
  • ✗ 0-RTT 上 POST 也部署, ⚠ reply attack 风险极高.
  • ✗ ServerKeyShare 选 deprecation secp256r1 — 按 RFC 8446 默认两 (x25519 required).
  • ✓ Use system OpenSSL fork from cloud SSL/TLS order [NSS] verul:
    • 频发 enable 客户轻度证书 verification check chain validation set MUST verification chain.

十二、桥梁

  • pki.md next-section: Server cert 与 CertificateVerify chain 验证 涉及证书 trust chain.
  • distributed/clock/dag: TLS module 状态链历 re-keyed Assay protocols such matrix sequence chunks.
  • system-design/case: bank accounts with spin mTLS Cardiierrez.

下一节 → 证书链 X.509 / PKI / CT

8. 证书链: X.509 / PKI / Certificate Transparency / OCSP

TL;DR

PKI (Public Key Infrastructure) 是信任分发机制——客户端如何相信"我的公钥 = www.bank.com 的 server key"? 用 CA (Certificate Authority) 签发的 X.509 证书 包括 subject / issuer / 公钥 / 有效期 / SAN / 签名. 客户端本地审 root CA (operating system trust store), 由链路根 → ca —— leaf server cert 验证 (向上游历 trust anchor).

这章把 X.509 cert ASN.1 结构 + chain building logic + CT log + OCSP 全套讲清.


一、X.509 证书结构

X.509 v3 cert (ASN.1 DER / PEM) 含:

TBSCertificate (to be signed):
  Version v3
  Serial Number       unique per CA唯一字符 numeric identifier.let must.
  Signature       算法(e.g. SHA256withECDSA
  Issuer          Relevant: CN=Example Intermediate CA, O=...
  Validity
    notBefore: [RFC 5280 UTCTime]
    notAfter: [RFC 5280 UTCTime]
  Subject         CN=www.bank.com, O=Bank Corp
  SubjectPublicKeyInfo   algorithm (Ed25519) + subject public key bytes
  Extensions:
    SubjectAltName (SAN)     critical → DNS = www.bank.com, www-2.bank.com
    KeyUsage                  digitalSignature, keyEncipherment ...
    ExtKeyUsage               serverAuth, clientAuth
    BasicConstraints          CA:FALSE for leaf
    AuthorityKeyIdentifier
    SubjectKeyIdentifier
    CertificatePolicies       ("CA/B forum trustpaths的样子 governance methods 责")
    CRLDistributionPoints
    AuthorityInfoAccess (OCSP URL)
    SCTList (signed certificate timestamps, from CT logs)

SignatureValue: [Issuer's private signing key output]

PEM 形式 = base64-encoded DER 拼装 BEGIN/END markers.

1.1 X.509 cert DER 引用

from cryptography import x509
from cryptography.hazmat.primitives import hashes

cert_pem = open('cert.pem', 'rb').read()
cert = x509.load_pem_x509_certificate(cert_pem)
print(cert.subject)             # cn=www.bank.com
print(cert.issuer)             
print(cert.not_valid_after)     # expiry date
print(cert.signature_algorithm_oid)  # SHA256withECDSA OID
print(cert.extensions)          # all extensions

二、Cert path building

校验链: client 拿到 cert 沿着 issuer 一层层向上, 在 approved CA cert (trust store root) 处停 (root CA self-signed). Sufficiently, Runtime steps:

  1. Parse server cert + intermediate chain certs.
  2. Build chain from leaf to root.
  3. At each link check signature (issuer's public key signs leaf cert chain).
  4. Revocation check (CRL or OCSP).
  5. Validity period (notBefore / notAfter).
  6. Use usage extension for context (serverAuth or clientAuth).

Use 在 RFC 5280 §6 algorithm.

2.1 path building

  • RFC 5280构造 path 必须тере one valid path only多重目录 outgoing API election --- production chain issuers on multiple paths finding.
  • Mozilla 或者 Chrome 拿 self build & extension implement specific path steps.
from cryptography.x509.oid import NameOID

def verify_chain(chain: list, root: x509.Certificate) -> bool:
    # Verify leaf is up to issuer signed by next etc.
    cert = chain[0]
    for i in range(len(chain) - 1):
        issuer = chain[i+1] if i < len(chain) - 1 else root
        if cert.issuer != issuer.subject:
            return False
        issuer_public_key = issuer.public_key()
        try:
            issuer_public_key.verify(
                cert.signature, cert.tbs_certificate_bytes,
                padding.PKCS1v15(), cert.signature_hash_algorithm
            )
        except:
            return False
        cert = issuer
    return cert == root

三、OCSP (Online Certificate Status Protocol)

OCSP/RFC 6960: client 取 query CA 的 OCSP responder, identity pack "this cert serial" → get "good / revoked / unknown". 这快 many inverse-path.

3.1 stapling

Client 不再 trust CA 维持 online responder — firewall may block OCSP endpoint. OCSP Stapling (RFC 6066): server 自己 fetch CA-attached response 出 OCSP signed 本 cert 状态 → staple with cert in TLS handshake. Client verify OCSP signature by CA 在 chain.

3.2 OCSP must-staple

Cert extension "status_request=must-staple" (= 5 = enabled). If cert has this extension, clients ALWAYS require OCSP response stapled with cert. If absent → reject cert. 用于永远在线 high-trust CA, issuer policy 提示 certificate required.

GEdge: Let's Encrypt can offer must-staple option. ~5% of Let's Encrypt cert 总采 must-staple.


四、CRL (Certificate Revocation List)

RFC 5280 §5: CA periodically issues signed list of revoked serials. Client download CRL if cert's CRLDistributionPoints extension is set. CRL has nextUpdate 指出刷新 schedule, after that date client must download new.

4.1 CRL 缺点

  • HUGE list (Google inspiration CERT approximate 10M revokes today).
  • Latency: revoke and propagate up to UK hour.
  • Client must fetch entire CRL (bad for mobile users).

→ online OCSP prepended → CRL usage大幅industry http 唯 mTLS 嵌入 matching.


五、Certificate Transparency (CT)

Google 2014 commercial users led by Ben Laurie et al. CT = append-only cryptographic log of all CA-issued certificates (RFC 6962).

5.1 为什么要 CT

Old PKI: any CA in trust store can issue any cert for any domain. No one外部 saw the targeted issued cert. History shows:

  • 2011 Comodo Hack: 9 fraudulent certs issued.
  • 2011 DigiNotar Hack: 500+ fraudulent certs including *.google.com, Iran spied Chrome forced to drop DigiNotar from root store.

Trust 失搭: 默认 happy accept → after the fact, user loss 反映 hella. CT 反互联网 historical – 信任 must be searchable publish.

5.2 Architecture

Logs: append-only Merkle trees. Merkle root signed in STH (Signed Tree Head, every 1 hour approximately).

Each cert logged digest signed by log = SCT (Signed Certificate Timestamp). Server then stapling the SCT into handshake via extension (RFC 8446 §4.2.1.7). Client validates SCT signature from log operator's public key.

5.3 Monitors

Monitors (uanosos ofrec Sara Google银 public Gno money or Cond browser) pull logs, forensic watch new issuance. If unknown cert appears issuer G mahala trust notice immediately.

5.4 Browser enforcement

Chrome 默认 reject cert 向 没包含足够 CT logs. Firefox policy 略不同.


六、Cert path validation 完整 algorithm (RFC 5280)

  1. Build chain from leaf to root. If multiple paths 尝试他, pick first verifiable.
  2. For each cert in chain:
    • Verify signature by next cert.
    • Check validity periods intersect.
    • Check BasicConstraints: CA cert must have CA:TRUE, path length not exceeded.
    • Check KeyUsage for CA: at least keyCertSign.
    • Check Revocation (CRL or OCSP if needed).
  3. Verify chain length ≤ pathLenConstraint (encoded in CA cert).
  4. 最终 terminated trust anchor in local store (subject name match).

七、常见 attack / failure mode

7.1 BAD: missing intermediate cert

Client sends only leaf cert, not including intermediate in handshake (server misconfiguration TLS handshake 中 turns omit). Many legacy Android trust store don't have intermediate — fail.

工程 fixing: 用 cert fullchain bundle in nginx /etc/nginx/ssl/fullchain.pem.

7.2 SAN mismatch

Cert subject CN 是 bank.com, 但 client访问www.bank.com. If SAN Gebrauch不含 www.bank.com or DNS entry in SAN missing, client rejects.

工程: UsaCert SAN 必须 include all variants.

7.3 Expired cert (operational hygiene)

Symantec-like expired certs were 50K in annual volume; Let's Encrypt 用 acme-client auto-refresh solve.

7.4 Hash algorithm obs

SHA-1-bind or MD5 cert signatures all attackable today. CT log 已 removed. Browser rejects.


八、ACME 自动化

Let's Encrypt ACME 录 protocol simplified:

  1. Client requests challenge for domain.
  2. ACME server 给 HTTP-01 / DNS-01 / TLS-ALPN challenges.
  3. Client proves control of domain (HTTP _acme-challenge well-known URL or DNS TXT).
  4. ACME server cert CSR submitted signed by client.
  5. Cert issued - sent back.

Let's Encrypt issues 90-day certs out of habit of改编 auto ACME timeline renewal. auto-rolling renewals now accounts for ~70% of all internet TLS certs (LETTUS industry 7+ industry 190M issued overall).


九、坑性 cert sanity check 工程师 usually 忘

  • 忘檀 fullchain course, root absent intermediate → browser trust "unknown issuer".
  • panicked expiry renewal cycles不忘 not test процес list; valid failure on server cert pin strip dangerous.
  • Wildcard (*.bank.com) validates only one subdomain level. **.a.b.com never valid.
  • room certs over SAN (max 100 SAN per cert industry limit 2024 Let's Encrypt → stack 100 names).
  • Some certs still use IPv4 SAN (e.g. certificate-shutter.org old CA → no DNS validation). Generally ok but check browser support.

十、CT punycode 与 IDNà Punsign

Internationalized domain names (IDN) 用 Punycode (example.中国example.xn--fiqs8s) in cert SAN. Security consideration IDN homograph attacks ( Cyrillic ' о ' ' ゙ 中 ark mix CJK ' O ', called homograph). Browser publishes blocked IDN display policies.


十一、Cert Pinning & Trust On First Use

Pinning: client hard-codes well known public key fingerprint, bypass standard chain verification. PayPal mobile 应用过 PIN. 困慢.

TOFU (Trust On First Use): SSH model — first connect records key, subsequent must match. Failures alert user's MITM possible. Migratingisie de杜boom 形.


十二、桥梁

  • tls13.md prev: cert in handshake.
  • distributed/fault/quorum.md: PKI roots must be cross-signed offline before root store trust anchors (Decade. Wrapped Tall end relying e琴不同的 root programs) — trust by chronological accumulation by historical 接 dig).
  • **system-design/disable pinning功能 punchment API, system CIКенда mobile ولكممد++ 판 spinning trust roots multiく root store.

下一节 → ZKP 入门

9. ZKP 入门: zk-SNARKs / zk-STARKs / Bulletproofs

TL;DR

Zero-Knowledge Proof (ZKP): 证明者 P 向 验证者 V 证明"我持有某型证据 w 使 statement X(w) 为 true"而不泄漏 w.

形式化三性质:

  • Completeness: 若 statement 真, V 接受.
  • Soundness: 若 假, 任何 cheating P 让 V 接受概率 negligible (< 1/2^λ).
  • Zero-knowledge: V 学不到任何 w 信息 (除了 statement 真假).

主流 4 类:

  • zk-SNARK (succinct non-interactive arguments of knowledge): trusted setup, ~200 bytes proof, ms-verify, ms-prove. typical: Groth16, PLONK, Marlin.
  • zk-STARK (scalable transparent ARguments of knowledge): no trusted setup (transparent random public); O(log n) verifier; O(n poly-log n) prover; post-quantum.
  • Bulletproofs: no trusted setup; short proof (log n); but linear verifier runtime (slow for huge statements).
  • Σ-protocols (interactive): simple template performances.

应用 jump:

  • Crypto-rollups (zk-Rollup on Ethereum) 上 100× cheaper transactions 通过 compressing all state transitions into 1 proof.
  • Privacy coins (Zcash): shielded transactions.
  • Identity: VCs (Idemix, AnonCred) 零泄露 age 属性.
  • Bridge-less cross-chain proof transfer.

一、ZK proof 范式

1.1 Interactive Proof

P & V 多轮交互. 输入 statement x:

For i in rounds:
    V sends challenge c_i, P returns response r_i.

V 最后 accept/reject.

1.2 Non-interactive via Fiat-Shamir trick

c_i = Hash(transcript-so-far).   // deterministic random oracle

把 interactive 转 NIZK (non-interactive zero-knowledge) by replacing random challenge with hash function assumed as random oracle.

1.3 第三的形式: zk-SNARK = succinct witness-indistinguishable argument of knowledge with logarithmic verifier.

  • succinct: proof size small (200-1000 bytes), verifier time logarithmic in statement size.
  • ARgument (not Proof): soundness 仅对 computational-bounded prover (not information-theoretic); NP 困难 assumption.

二、Σ-protocol example: Schnorr identification

P knows secret $x$ s.t. public $y = g^x$.

  1. P ephemeral $r$; sends commitment $R = g^r$.
  2. V sends random challenge $c$.
  3. P sends $z = r + c x$.
  4. V verifies $g^z \stackrel{?}{=} R y^c$.

正确性 follows from $g^{r+cx} = g^r \cdot (g^x)^c = R y^c$.

Zero-knowledge: V learns $z, R, c$ but not $x$ (since many combinations produce same z; simulator can re-run with random $z'$ for any c and pull $R' = g^{z'} y^{-c}$).

Fiat-Shamir: replace V's $c$ by $H(R, y)$:

c = H(R || y)
proof = (R, z)
verify: c = H(R || y) and g^z = R y^c

This is in fact the EdDSA signature equivalent! EdDSA = Schnorr = non-interactive identification in non-interactive style.


三、zk-SNARKs

3.1 Algebraic setting

Computation 是 arithmetic circuit: arithmetic gate (add, mul) over 有 field $\mathbb{F}_p$. 转化 high-level 语言 (Circom / leo / zinc / halo2-circuit) to R1CS (rank-1 constraint system).

R1CS definition

A statement written as constraint vector:

  • Each gate computes: $A_i \cdot B_i = C_i$ for linear forms A_i, B_i, C_i functions of witness vector $w$.
  • Public statement $x$ and witness $w$ satisfy all constraints; show this in ZK to listener.

3.2 工匠 trusted setup ceremony

Groth16 / Pinocchio-style SNARK uses SRS (Structured Reference String):

Random $\tau$, $\alpha$, $\beta$, $\gamma$, $\delta$:
  proving key = [τ^i G ...], [α · τ^i G ... etc.]
  verifying key = [α G, β G, γ G, δ G, β/γ G ...]

Such random must be destroyed; else forger 失猜 SRS know how to forge a false proof. Power of Tau's MPC ceremony (2018, 200+ participants, threshold 1 honest kills 宣 r) used such offline setup.

3.3 Pairing curve

Groth16 uses bilinear pairing friendly curves: BN254, BLS12-381. Pairings give $e: \mathbb{G}_1 \times \mathbb{G}_2 \to \mathbb{G}_T$, quadratic equation check verified in 1 multi-exponentiation.

Verification: $$ e(A_1, B_2) \stackrel{?}{=} e(\alpha G_1, \beta G_2) \cdot e!\left(\frac{\sum_i C_{public,i} \cdot G_1^i}{\gamma G_1}, \gamma G_2\right) \cdot e(\alpha G, r G_2) \cdot e(s G_1, \delta G_2) \cdot e(A G, B G)$$ Where $\alpha, \beta, \gamma, \delta$ are sub-SRS components and (A_1, B_2, C_1) are the proof.

Verifying time ~1ms computationally.

3.4 Groth16, PLONK, Marlin

SystemSetupProof SizeVerifyProver
Groth16Per-circuit192 B~3 pairingsO(N)
PLONKUniversal updatable400 B1 pairing + 1 FFTO(N log N)
MarlinUniversal800 BO(log N)O(N log N)

PLONK Universal setup = once ever, any circuit uses derived setup does not need re-launch ceremony.

3.5 工程库

  • Snarkjs (JS Library, Groth16 / PLONK).
  • arkworks (Rust library, supports various proof systems).
  • halo2 (Zcash): based on PLONK + recursion, no trusted setup needed per-ZK cash.
  • gnark (Go).

3.6 Use case: Zcash shielded txs

To send shielded zk-tx:

  • User creates a note commitment $C_i = H(\text{value}_i | \text{vk}_i | \text{rcm}_i)$.
  • Next user pays amount by proving "I know (rcm, value, vk) of NULLIFIER N_i previously on-chain; commitment not spent yet by nullifier = H(rcm, nullifier_seed); new output commitment C_j is valid".

Zcash uses Halo2 2023 onwards, deprecated Groth16 prover.

3.7 Use case: zkRollup

Ethereum L2 (zkSync Era, Polygon zkEVM, Scroll):

  • L2 sequencer combs thousands of L2 txs into batches.
  • Prove statement: batch update root of state Merkle tree from root pre to root post correctly applying all tx logic.
  • L1 verifies zk proof on-chain (single pairing call charges gas much smaller than re-executing L2 txs).
  • Cost ratio: 1× L2 prover cost vs N× L1 verification → 100× cheaper.

四、zk-STARKs

4.1 设计不同

  • Hash-based, using Reed-Solomon codes + FRI protocol to prove low-degree of polynomial commitments. No elliptic curves / pairings.
  • Transparent setup (no trusted).
  • Post-quantum (hash-based security).
  • BUT proof size larger (~50-200 KB) and verifier does O(log² N) work.

4.2 AIR (Algebraic Intermediate Representation)

STARK 写程序 RISC machine (Brainfuck-like or Cairo / Cairo VM) and prove algebraic execution trace satisfies transition constraints.

4.3 LDE + FRI

Prover produces a low-degree extension (LDE) of execution trace, then polynomial constraints. Prove via FRI protocol (Fast Reed-Solomon Interactive Oracle Proof of proximity):

  1. Decommit to oracle queries at random locations.
  2. Recursive folding halves degree.
  3. Stop at small final degree.

Verifier sees only low-degree check probability from random queries; 共 O(log N) rounds + queries.

4.4 Library

  • StarkWare's Stwo / Stone provers, Rust open-source.
  • Winterfell (Rust, StarkWare 友 street Ferinitialblock prover community release).
  • OpenZeppelin Cairo contracts integration.

4.5 用例

  • StarkNet (zkRollup on Ethereum): STARK proof
  • delegation of L1 → L2 transaction trace.
  • 200k tx/batch handled in practice.

五、Bulletproofs

5.1 Range proofs on commitment

Built by Bünz-Boutin-Campanelli-et 2018: efficient range proofs for Pedersen commitments: prove "value v in [0, 2^n]" without revealing v.

Final proof size O(log n); verifier O(n). No trusted setup, no pairings (regular curve 区).

5.2 Use case

  • Monero: ringCT battery, per tx generates bulletproof range proof. Predictable ~1.4KB on-chain.
  • MimbleWimble: Grin / Beam 用 bulletproof 范围证明 for homomorphic commitment privacy.
  • zkBounty: ANNUAL-script institutional hash supplementary overstuck.

5.3 Limitations

Verifier linear-time → fail large stacking (e.g., higher counts reach seconds verifier runtime).


六、防 ZKP elusive case "I am over 18" zero-knowledge age verification

Statement: I know private_age such that signature on (private_age) by trusted authority, and currentDate - private_age > 18*365 days.
Witness: age, signature.

P proves in ZK; verifier learns ✓/✗ only.

Idemix, AnonCreds, W3C VC use cases.


七、ZK proof of Solvency (proof of reserves)

Exchange proves "I hold liabilities L for each user, and reserve R ≥ L" without revealing R. Binance / Kraken have published proof of reserves using Merkle sum tree proofs.

For user u, hash commitment h_u = H(balance_u).
Exchange commits root of Merkle sum tree:
  Merkle root + users verify inclusion: each leaf sum + inclusion path equals root.

This is not even ZK, just "verifiable sum". 延展 ZK if needed for client privacy.


八、Limitations and pitfalls

  • Trusted setup panic: any party who possesses toxic waste from setup can forge proofs forever. Mitigate via universal-and-updatable setup (PLONK, Marlin, Halo2) where random accumulates over multiple parties.
  • Quantum threat: pairing-based zk-SNARKs break under quantum (Shor). STARKs and FRI 量子 resilient.
  • Implementation bugs not formal proof: Halo2 had bug in 2021 allowing proof forge on a specific circuit; later fixed.
  • Bit homomorphic commitments of input must enforce input range (otherwise overflows!), often missed in production.
  • finance & privacy mixing: Zcash once revealed 192 secret encryption keys by random source protocol bug.

九、桥梁

  • complexity.md: 测复杂度归约与 NP (NPC R1CS reduce from any NP language) statement specification.
  • distributed/clock/dag: blockchain block hash acts as proof of work-VDF tie-out.
  • hashes.md: Zero-knowledge proofs rely on cryptographic hash with H security; validity of proof depends on hash-as-random-oracle assumption.
  • tls13.md: future handshake may ZK-attestations for client identity (e.g., Domain Validation ZK).
  • system-design/case/k8s-control-plane: server auth by SPIFFE certificate and could extend to ZK identity for k8s node attestation.

下一节 → 侧信道攻击

10. 侧信道攻击: timing / power analysis / Spectre / Meltdown

TL;DR

密码协议在数学层 secure ≠ 实现级 secure. 一旦执行 involves secret-dependent branch / memory access / power profile, 物理/系统旁观者可统计时间/功耗/cache 命中推断 secret. 历史极致:

  • OpenSSL AES table lookup cache-timing 2005 → 30 ms 提取整个 AES key.
  • SPA / DPA on smartcards (Kocher 1998) → 1 mA current trace 复原 signals.
  • Spectre / Meltdown 2018 → 用户程序读 kernel memory.
  • Rowhammer 2014 → flip bit in unprivileged RAM.
  • Plundervolt 2020 → undervoltage 使 Intel SGX 出错.

工程心智: 真正对抗侧信道要求所有代码路径完全独立于 secret——constant-time 编程 + 减少 secret-dependent memory access + 硬件协助 (AES-NI / ARM crypto extensions / SGX).


一、Timing attack 原理

1.1 Naive strcmp 经典

int strcmp(const char *a, const char *b) {
    while (*a && *b) {
        if (*a != *b) return *a - *b;
        a++; b++;
    }
    return *a - *b;
}

首次不匹配的 byte 数 ⇒ 时间. 攻击者逐字节 翻 — 字节 0 通过所有不匹配 → 第 1 字节比较也走长达累 unitra puppy 单独 diad 启始; 也每晚多时间, comic from Deter secret 通otive correct.

1.2 Constant-time compare

int ct_equal(const unsigned char *a, const unsigned char *b, size_t n) {
    unsigned char diff = 0;
    for (size_t i = 0; i < n; i++) diff |= a[i] ^ b[i];
    return diff == 0;
}

或 libsodium crypto_verify_16/32. 永远搞 路径全 长度素.

1.3 Modular exponentiation timing

naive pow(base, exp, mod) 走 binary square-and-multiply loop 每个 bit of exp:

  • 如果 bit=0: square.
  • 如果 bit=1: square + multiply.

攻击者测量时间 → pull exp bit by bit (Kocher 1996 attack).

现代 gmp / OpenSSL BN_exp 用 fixed-window exponentiation —— 每 window 由 table lookup 给结果 减少时间 correlation with exp bit.

1.4 AES table lookup timing

Naive AES uses 256-byte S-box table. 攻击者用无数 cache-state trigger 看 access delay — 第 1 cache hit vs miss 区分表格行 byte, then iterate over secret cycles → extract full key.

warning

Bernstein 2005 published server-side attack on remote OpenSSL AES server: ~50ms total network jitter, but with 2^22 queries can recover full AES key from network timing! (Yes, remote, no local access.)

Solution:

  • AES-NI hardware instruction bypasses lookup table (data-independent timing).
  • bitsliced AES implementation (process 64-byte blocks in parallel寄存器).
  • "T-tables" mitigation (constant-time memory access patterns):

二、Power analysis (SPA / DPA)

2.1 SPA (Simple Power Analysis)

Power consumption of CPU varies with operations. Multiply draws more than XOR. 观察 power trace 一段时间, 人类 视肉眼 inspect 直接读出 square-vs-multiply 序列 → 恢复 private exponent.

2.2 DPA (Differential Power Analysis)

Multiple traces + statistical correlation:

  • 选 target intermediate bit $b$ = function of secret s.
  • Sample 一批 (用不同 inputs).
  • 按 $b$ 值分组, 平均 power consumption 出差.
  • 推断 $s$.

→ Kout 仅 one smartcard 输可 约 thousands traces 解出 key.

2.3 Mitigation

  • Blinding: $r$ random; compute $r^e \cdot m$ encrypt instead of $m$, divide by $r^e$ at end (RSA blinding).
  • Constant-time operations, 平衡指令.
  • EM shielding / randomize clock frequency 抵抗 external sample.

三、Cache attacks

3.1 Prime+Probe attack

Attacker fills all cache sets with itself, victim runs (secret-dependent memory access evict some attacker cache line), attacker reload fills timings 计 which sets evicted → 推 victim keyed address.

3.2 Flush+Reload attack

If shared memory (e.g. shared library), attacker CLFLUSH cache set then sleep (offload-measure memory access time. If victim's access pattern secret限-但 指示的 free replaces set CLFLUSH time of reload → recover secret.

3.3 Mitigation

  • Hardware: AES-NI for AES, ARM crypto extensions, no lookup table.
  • Software: avoid shared memory with potential attackers (TLS heartbleed 走过了 close clear state yes).
  • process isolation: kernel memory not user-mapped (Meltdown mitigation, KPTI于 x86 Linux).

四、Speculative execution attacks (Spectre / Meltdown 2018)

4.1 Meltdown (Variant 3)

x86 speculative load, kernel memory mapped KAISER-style. Speculative load succeeds speculatively even with privilege bugs out (_GE forwarding instruction retirement). Subsequent cache access dependent on loaded secret; speculative storage in cache persists even after rollback. Attacker uses Flush+Reload: detect cache line status → recover secret byte.

1:    mov al, byte ptr [kernel_addr]    ; speculative load success (攻击者 has no priv, but MEL香 takedown)
2:    shl al, 12                        ;Multiplier0x1000 for cache line 选择
3:    mov cl, byte ptr [alias_array + rax]
                                    ;secret Index cache line set by 多;
4:     CPU detects permission fault ⇒ squashes 1-3. But cache state changed!
5:    Attacker times each cache-line of alias_array:
     fast access = secret-k byte

4.2 Spectre (Variant 1, 2)

Spectre v1: trained branch predictor mispredicts (BTB-pattern trained to predicted if-condition true) → secret-load depending 非授权带来.

Spectre v2 (BTB poisoning): attacker 改 BTB targeting sysret / RSB mistarget → trick host / kernel to speculatively execute attacker's gadget code.

4.3 Mitigation

  • KPTI (Kernel Page Table Isolation): Linux / Windows 10 拒绝映射 kernel pages in user pagetables. cost up to 30% syscall (heavy).
  • IBRS / STIBP / IBPB (intel microcode): disable speculation across privilege transitions.
  • Retpoline: replace indirect with RET-based safe execution gadget.
  • Retpoline etc sacrificed 5-30% speed某些工作负载.

4.4 工业影响

  • Performance regression in 2018 云服务 5-30%. Cloud providers offer option to disable mitigations (with concomitant risk).
  • Apple M1 architecture 早 safer due to Silicon: tighter branch speculation validation not vulnerable to many but variants still.

五、Rowhammer (2014, Kim et al)

DRAM cells 电荷 leakage: high frequency memory reads nearby cells 电荷 flip bit in adjacent unaccessed cell. Modern DRAM 电容密度 high'er than ideal, "hammering" rows triggers bit flips.

5.1 Attack type

  • Single-sided: hammer adjacent rows.
  • Double-sided: hammer both neighbors of a target row to flip bits in middle.

5.2 Practical exploit

  • 用 user-mode 32-bit machine, bit flip in struct owner bit of page table entry → flip "user" bit on protected page → user gets root.
  • Flip bit in hashed password field → security weakened.
  • Throwhammer (2018) - over JS-rowhammer via WebGL.
  • RAMBleed (2020) — exploit leakage via upgraded Rowhammer reading speed, extract RSA private key from kernel SGX.

5.3 Mitigation

  • ECC RAM detects (most) but corrects single bit flip.
  • TRR (Target Row Refresh) — internal DRAM balances frequency to mitigate.
  • Memory DDR5 与控制器 refresh rate increased targeting.
  • Cloud providers disable hugepages and isolate tenants, but really notEliminate 攻击.

六、SGX attacks (Side Channel TEE)

Intel SGX (Software Guard eXtensions) provides enclave for crypto-protected code execution.

6.1 Vulnerabilities History

  • Foreshadow (2018): OS-managed scheduler leak SGX's protected memory contents via speculative execution.
  • Plundervolt (2020): undervoltage make certain carries error → wrong output in SGX'modular 飞行 exponentiation → recover attestation private key.
  • SGAxe (2020): SGX EPID group signature proof leakage.
  • LVI (2020): Load Value Injection — fill attacker data into dangling microcode checker.

6.2 Hardening

  • Lock down BIOS voltage control.
  • TEE quote key rotation harder.
  • Move to TEEs with provable constant-memory access design (AMD SEV-SNP, ARM TrustZone + CCA).

七、Constant-time 编程 manual

7.1 Rules

  1. 不要用 secret 作为 array index. (Cache timing leak.)
  2. 不要用 secret 作为 branch condition. (Pipeline prediction 改进 leak.)
  3. 不要用 secret 在 floating point? (FP 不同值不同 latencies.)
  4. Compiler barrier: volatile, asm volatile("" ::: "memory") 防 optimizer 删除双写 / 跳跃 branch.

7.2 Constant-time memeq in C

int crypto_verify_16(const unsigned char *x, const unsigned char *y) {
    unsigned int differentbits = 0;
    for (int i = 0; i < 16; i++) differentbits |= x[i] ^ y[i];
    return (1 & ((differentbits - 1) >> 8)) - 1; // 0 if any bit differs; otherwise -1
}

Note trifix memory access loading x[i], y[i] is not time-varying since the function loads all 16 bytes regardless of input equality.

7.3 Constant-time conditional

// Set y = x if mask = 0xFF, else y; mask != 0xFF 固定 mask 0
void sw_cmov(unsigned char *y, const unsigned char *x, int len, unsigned char mask) {
    unsigned char m = mask;
    for (int i = 0; i < len; i++) {
        y[i] ^= m & (y[i] ^ x[i]);
    }
}

Computes always all bits; ensures no branch.

7.4 Verification tools

  • ctgrind Valgrind plugin marks secret-dependent branches in execution logs.
  • dudect statistical timing analysis to verify constant time.
  • Rust: subtle crate provides constant-time versions of conditional moves, equality, etc.
  • Libsodium uses 内部 CT utilities always.

八、与前面章节的桥

  • symmetric.md AES-NI necessity from cache timing.
  • signatures.md Ed25519 role independence and Pederson 2-design 抗 nonce reuse.
  • tls13.md SES Endpoint enable record layer ATK terminate AEAD padding length oracle.
  • os/sched.lockfree: Optimization Compiler aggressively reorder, may produce secret-dependent timing implicit load semantics.

下一节 → 安全最佳实践

11. 安全最佳实践: constant-time / nonce 不重用 / 密钥轮转 / OOB validation

TL;DR

工程落地的安全规范. 不是写完密码学算法就 secure, 全部使用密码学的代码必须遵守最严格 yet mundane 的规则:

  1. Constant-time 所有 comparisons involving secret.
  2. Nonce never reused for same key (GCM, ChaCha20, CTR mode).
  3. Key rotation 长期密钥每年 + 私钥 100-year NORM 暴露 ever 拒绝/轮转 and rotate.
  4. OOB validation (Out-of-Band) — input 必先到 parsing layer 验证后才到 crypto.
  5. Using libsodium / ring / OpenSSL / BoringSSL — never roll your own.

这章是个"checklist"式 list旨在带他人 code-review 工程之用.


一、Constant-time primitives checklist

简单非脚本法学推荐
password == stored_hashcrypto_pwhash_argon2id_verify(stored, password)
token_compare(a, b)crypto_verify_* (libsodium) / hmac.compare_digest (Python) / subtle::ConstantTimeEq (Rust)
if secret_branch {...}secret_branch rewrite as mask = (secret == ?) then 在 always-evaluate block work。
AES via int T-tablesAES-NI instruction, OR libsodium crypto_aead_aes256gcm_*
SHA via lookup tableSHA-256用 hardware SHA-NI; arm SHA 兼容

古典型坑: == 是 short-circuit. Python a == b 字符串 上 1-32 µs 不同时间差异 — bash timing oracle trivial.


二、Nonce management

2.1 Rule: 一对一

同 key + 同 nonce ⇒ ciphertext 流 → mask XOR; 同 keystream + 不同 plaintexts XOR → reveal plaintext XOR plaintext → trivially breakable.

2.2 Generation strategies

  1. Counter based: context root stores sequential counter, persist before using next nonce to ensure crash恢复. 同 K 已 sleep crash reboot 后 persists 必须 include config reset (RNG survives).

  2. Random with 96-bit guarantee low collision probability (≤2^32 messages safety zone). birthday bound 2^48 messages before collision. Probabilistic high throughput 需要 128-bit random IV; CTR 在 128-bit 安全.

  3. Deterministic constructions (SIV mode): RFC 5297 提供 AES-SIV — 接 受 nonce OR nonce-less, 极 clipe secret-key encryption附加双密码 文性扩展 (也让 nonce reuse 不立刻破 – it becomes deterministic AE). Pair AES-SIV use 给 sensitive AES-CTR encounters for safety.

2.3 工程检查

代码模式安全?
nonce = os.urandom(12) 每次新 random安全 (但 2^48 上限消息 count)
nonce = os.urandom(8)danger — 8 bytes random birthday collision by 2^32 messages (4 billion)
nonce = int(time.time()).to_bytes(12)danger — attacker can predict NTP could reverse timestamp
nonce = sequence_counter++ from CRDT persistent安全; persisted {唯一保证不重}
Pull random 12-byte nonce at session start; for each record IV = nonce XOR counterTLS 1.3 模式, 安全

三、KDF 与治�� separation

One key per use-case.

  • TLS handshake secret + client_random + server_random → HKDF derive — client_HS_traffic_secret, server_HS_traffic-secret, client_APP_traffic_secret, server_APP_traffic_secret, master_secret, exporter_master_secret, resumption_master_secret, ...
  • Each derived efficiently one-stop HKDF through info separation; never mix in re-derivation.
  • ⇒ "TLS 即使所有 early secret leaked does not affect 全 chain release single final secret."

3.1 Common KDF

  • HKDF (TLS 1.3, IPsec): RFC 5869.
  • Argon2id (password hashing): PHC winner.
  • PBKDF2 (legacy password hash): high hardware speed → not modern 倡议, but 银行 legacy 兼容很多.
  • Scrypt (memory-hard but older than Argon2).

3.2 工程模式

from cryptography.hazmat.primitives.kdf.hkdf import HKDF
from cryptography.hazmat.primitives import hashes
from cryptography.hazmat.primitives import hmac

def derive_keys(K: bytes) -> tuple[bytes, bytes, bytes]:
    """Generate three derived keys from shared secret for encryption, mac, and audit log."""
    enc_key = HKDF(algorithm=hashes.SHA256(), length=32, salt=b'tls-aes-enc', info=b'enc').derive(K)
    mac_key = HKDF(algorithm=hashes.SHA256(), length=32, salt=b'tls-aes-mac', info=b'mac').derive(K)
    audit_key = HKDF(algorithm=hashes.SHA256(), length=32, salt=b'log-key', info=b'audit').derive(K)
    return enc_key, mac_key, audit_key

四、Key rotation / OOB / 灾难 plan

4.1 Rotation schedule

  • TLS certs: 90 days (Let's Encrypt ACME). Apple 398 days CA/B forum cap.
  • API JWT secrets: 30 days. Use asymmetric (RS256/EdDSA) so逐 token verify without shared secret at risk.
  • DB column encryption keys: 1 year. Use envelope encryption (KMS wraps DEK; 密钥交换 hierarchical via CloudHSM/AWS KMS key ring).
  • AT REST 关闭些 path rotation pinning file 分 binary encrypted box manageable ask.

4.2 Key revocation plans

  • Public key infra: e.g., compromised cert pinning — even domain owner can't change behavior. (USDR code 行.
  • Cert pins uninstalled automatically at scheduled retention expiry.
  • 本身 long-term signing key compromise: revoke cert + push revocation via OCSP/CRL + add to CT log "bad issuance" evidence - takes 24h propagation.

4.3 Disaster plan checks

测试爱:周期 simulate key compromise by certificate forced rotation → can the new TLS secret actually get re-issue to deploy in < 1h? Let's Encrypt acme.sh script automated; 自埋恶 mercurial manual.


五、Input validation worst practice

Never let user input touch crypto directly.

5.1 Bad patterns

# 缓冲 user_key derives blob蠕取 into keyring:
key = request.json['key'][:32].encode()
salt = request.json['salt'].encode()
derived = PBKDF2(key, salt)
# A user 'salt' chosen with **magic byte mixture** can cause non-constant-time inner loop, collision differentiated.
  • Decode user input via parser first → types-check pass; reject early.
  • Canonicalize (e.g., JSON UTF-8 NOT normalize form)-specifically與 security context.
  • Restrict to fixed-size buffer; return error if too long.
  • Hash secrets input into keybags + finally pass to crypto function.

5.3 Padding oracle prevention

  • Use AEAD for encryption (TLS 1.x in 1.3 + standard now).
  • "decrypt then verify MAC" sequence — never reveal "padding error" separately from "MAC error".

六、密码学 random source

6.1 CSPRNG (Cryptographically Secure PRNG)

OS-provided:

  • Linux: /dev/urandom (or getrandom(2) syscall), kernel seeded with hardware RNG.
  • Windows: CryptGenRandom / BCryptGenRandom.
  • Java: SecureRandom (use getInstanceStrong()).
  • Python: secrets module (NOT random!).

warning

random.random() in Python, Math.random() in JS, rand() in C — NOT cryptographically secure. PRNG outputs in public MT19937 state recover all future outputs from 624 previous samples.

6.2 Code

import secrets
nonce = secrets.token_bytes(12)
short_id = secrets.token_hex(8)
// Browser
const key = crypto.getRandomValues(new Uint8Array(32));

七、Detection layer: monitoring

Always log:

  • TLS handshake failure counts (large spike ⇒ adversarial rule fallback).
  • Cipher failures (handshake_aborted, decryption_errors).
  • Rate limiting policy on signature verification endpoints (prevent brute-forcing signatures offline).
  • 利用 hashed login attempts 冲掉 var maintenance.

八、Defense in depth canonical list

  1. Network: TLS 1.3; mTLS internal.
  2. Application: AEAD encrypt secrets at rest; authorization tokens short-lived; cookies Secure, HttpOnly, SameSite=Strict.
  3. Memory: zeros擦 私钥内存 use 后 (crypto_free, explicit_bzero, Rust Zeroize).
  4. Storage: DRAM RAMBleed 或 ECC; HSM-protected key envelope.
  5. Collect logs: SIEM (Splunk, ELK) tracks TLS errors, crypto exceptions → security ops.

九、Pitfalls list (avoid)

  • "We use SHA-256" - just hash, no salt, no KDF → online dictionary attack for password storage.
  • "We sign JWT in HMAC" — gets signed for reuse protection but long-term HMAC cannot be rotated as asymmetrically.
  • "We use RSA directly to encrypt 100MB data" — RSAES-OAEP only handles small messages; use enveloped-Hybrid key: encrypt generated AES key 用 RSA + 用 AES-GCM encrypt data.
  • "We store private key in DB plain" — secrets 必须用 envelope encryption (KMS wrap DEK).
  • "We implement smart contract signature recovery outside signed request replay validation" — using ecrecover without multi-chain ID validation enables cross-chain replay attack.
  • "We strict by ACL 上 permissive cloud KMS" — IAM roles audit wide.
  • MD5 / SHA-1 hash for security — broken (collision attack 完). TLS 1.3 排除.

十、附录 - libsodium API quick start

// libsodium
crypto_secretbox_easy(c, m, mlen, nonce, key);       // XSalsa20-Poly1305
crypto_aead_aes256gcm_encrypt(...);                  // AES-256-GCM
crypto_aead_xchacha20poly1305_ietf_encrypt(...);     // XChaCha20-Poly1305 IETF
crypto_kx_keypair();                                 // X25519 keygen
crypto_sign_ed25519_keypair();                       // Ed25519 keygen
crypto_sign_ed25519_detached(sig, m, mlen, sk);      // detached sig
crypto_pwhash_argon2id_str(...);                     // password hash
# Python bindings
from nacl.bindings import (
    crypto_aead_xchacha20poly1305_ietf_encrypt,
    crypto_sign_ed25519,
    crypto_kx_keypair,
)

十一、桥梁

  • sidechannel.md prev: fundamental reasons constant-time matters.
  • tls13.md + pki.md: applying in TLS protocol suite.
  • distributed/consensus: talaf consensus keys never reuse; consensus key must rotate.
  • system-design/scale/resilience.md: rate-limit protect review against signature brute-force blob gambling-trick.

下一节 → 附录: 密码学工程 checklist

附录: 密码学考试 / 工程 checklist

A.1 — Algorithm cheat sheet

ClassAlgorithmKey SizeUseNotes
Symmetric blockAES-128 / AES-25616/32 Bbulk encryptionuse AES-NI hardware if available
Symmetric streamChaCha2032 Bbulk encryptionwhen no AES acceleration
MACHMAC-SHA256anymessage authRFC 2104; combine with E-then-M
AEADAES-128-GCM16B + 12B IVbulk + authTLS 1.3 / IPsec mainline
AEADChaCha20-Poly130532B + 12Bbulk + authmobile/IoT preferred
HashSHA-256integrity, KDF, MACMerkle-Damgård length-extension caveat
HashSHA-3-256future-proofsponge construction
HashBLAKE3parallel high-throughput10 GB/s with AVX2
Password hashArgon2idpassword storagePHC winner 2015; memory-hard
KDFHKDFderive multiple keys from masterRFC 5869
Public key encRSA-3072-OAEP3072 blegacy / envelopeddeprecated for new systems; use KEM
Public KEMML-KEM-768 / Kyber~1184 Bpost-quantum key exchangeFIPS 203 / RFC 9591
Public signatureRSA-PSS-30723072 bcert signingRFC 8017; works for TLS1.3
Public signatureECDSA P-25632BTLS cert signinguse RFC 6979 deterministic nonce
Public signatureEd2551932Bmodern signingRFC 8032
Public PQ sigML-DSA-65 / Dilithium~2KBpost-quantum signingFIPS 204
Hash-based sigSLH-DSA-SHA2-128f7KBstateless PQ hash-basedFIPS 205
Key exchangeX2551932Bephemeral ECDHRFC 7748; constant-time
Key exchangeX44856Bhigh-security ECDHRFC 7748
KEM/sig comboshybrid Kyber-X25519TLS PQ previewCloudflare / Apple
ZK: Groth16pairing curve BLS12per-circuit setupZcash legacy192B proof
ZK: PLONKuniversal updatable400B proofzkSync Era/ScrollO(N log N) prover
ZK: STARKhash-based, transparent~50-200KBStarkNet post-quantumO(log² N) verify
ZK: Bulletproofcurve no setupO(log n)Monero range prooflinear verify

A.2 — Browser / OS trust store matrix

CA ProgramUpdate CycleMin RSAMin ECSHA-1SHA-256Notable
Apple root programYearly2048256rejectedOKprivate group
Microsoft rootQuarterly2048256rejectedOKWindows Update push
Mozilla / NSSWeekly2048256rejectedOKopen source program
Google Chromeuses NSSrejectedOKCT enforcement 2018+

A.3 — Security level lookup table

Security levelSymmetricRSA modulusECC modulusSHA outputNotes
8080 bit1024160160retired
1121122048224224RSA-2048 minimum
1281283072256256TLS modern baseline
1921927680384384high-sensitivity
25625615360521512NSA Suite B / Type 1

注: AES / SHA-256 在 quantum Grover 之下仍 128 等 trillion quadratic 安全, 但 RSA / ECC 在 Shor 之下 hard broken ⇒ migration to ML-KEM/ML-DSA.

A.4 — Online resources / tooling

  • OpenSSL command-line + library; cert generation, TLS 模拟, signature tests.
  • Libsodium cross-language crypto 简洁 API wrapper.
  • ring (Rust), maintained, fast OpenSSL alternative for TLS.
  • BoringSSL Google fork, internal improvements, used by Chromium.
  • Wireshark + SSLKEYLOGFILE env → decrypt own TLS sessions (debug only).
  • Certbot / lego ACME clients for Let's Encrypt automation.
  • ssh-keygen -t ed25519 for modern SSH keys.
  • age encryption tool by Filippo Valsorda — modern file encryption, X25519, AEAD.
  • minisign for file signing (Ed25519).
  • Snarkjs 编译零知识 proof 验证 in CLI.

A.5 — Common pitfall reminder one-pager

  • ✗ Random nonce RNG without CSPRNG → breakable keystream with predictable IV in CTR.
  • ✗ ECDSA nonce random() → reuse → private key recoverable from 2 signatures.
  • ✗ MAC-then-Encrypt → padding oracle.
  • ✗ ECB for multi-block messages → visually leaks pattern.
  • ✗ Passwords hashed with plain SHA-256 → rainbow-table compromise.
  • ✗ Raw RSA $m^e$ for small message → low-exponent attack recoverable.
  • ✗ Same long-term signing key for multiple platforms → cross-protocol attack.
  • ✗ ECDSA curve point input not validated → invalid-curve attack sends small subgroup point whose DLP trivial.
  • ✓ Use libsodium / ring / OpenSSL.
  • ✓ Rotate secrets, audit logs, monitor alerts.
  • ✓ Constant-time comparison of secrets.
  • ✓ AEAD encryption in 95%+ cases.
  • ✓ Ed25519 for new signatures unless hardware constrained.
  • ✓ X25519 / Kyber768 hybrid for new systems post-2024.

A.6 — Open-source implementation references

  • RFC 8446 TLS 1.3
  • RFC 7748 X25519 / X448
  • RFC 8032 Ed25519
  • RFC 7539 ChaCha20-Poly1305 AEAD
  • RFC 5869 HKDF
  • RFC 6962 Certificate Transparency
  • RFC 6960 OCSP
  • RFC 5297 AES-SIV
  • RFC 9591 Kyber-derived ML-KEM id encode (post-quantum)
  • FIPS 203 ML-KEM, 204 ML-DSA, 205 SLH-DSA standards (Aug 2024 finalized).

A.7 — 与项目其他章节交叉

  • asymmetric.md: RSA math with CRT, ECC curve selection.
  • tls13.md: 握手 derive secret 链; cert chain verification.
  • sidechannel.md: time / power attack 探测:
  • theory/complexity: PRIMES ∈ P (AKS) 提 major示范; factoring 在 NP∩co-NP but not NP-hard.
  • distributed/fault/quorum: BFT consensus multi-party signature aggregation.
  • system-design/case/dynamo-family: cryptographic secret 分配 among distributed storage derivatives.
  • compilers/sema/type-system: secure "opaque type" 专用 防止 counter-logging from secret-annotated values.

下一节 → 信息论与编码 README

第十一部分 · 信息论与编码

一句话

Shannon 1948 "A Mathematical Theory of Communication" 提出:信息 (information) 是可量化的——一个事件 $X$ 的"信息量"$I(x) = -\log_2 P(x)$ bits. 这条单公式衍生了三大主轴: (1) 信息压缩的极限 = 熵 $H(X)$; (2) 通信信道的极限 = 信道容量 $C = B \log_2(1+\text{SNR})$; (3) 故障检测与纠正的极限 = 纠错码. 每一条都直接决定了我们今天的 5G NR 控制信道用 Polar 码 (Arikan 2008)、数据信道用 LDPC、4G 用 Turbo 码; QR 码 / SSD / RAID 6 / CD-ROM 用 Reed-Solomon; 软件压缩 zstd 用 LZ77 + ANS / Huffman encoder. 信息论既是理论与工程的桥梁, 也是物理定律 (信噪比的香农极限) 在数字通信上现实化.

思想链

[Streaming 4K HDR video over 5G mmWave]
  └─> RAW: 12-bit × 7680×4320 × 60 Hz × 3 = 11.9 Gbit/s 原始比特流
       └─> H.265 视频压缩 (intra + inter + DCT)
            └─> ~25 Mbit/s (压缩比 ~500×)
                  └─> 离熵 H(X): H.265 利用 spatial / temporal redundancy
                       └─> 信道编码: 5G NR LDPC (data) / Polar code (control)
                             └─> 添加 ~30% redundancy bit-rate 5/6
                                   └─> QAM 调制: 64/256-QAM with constellation shaping
                                         └─> OFDM: 100 MHz bandwidth, subcarriers 1200
                                               └─> 香农极限: B log(1+SNR) = 100M × log₂(1+20) ≈ 432 Mbit/s
                                                     └─> 实测 1.2 Gbit/s peak (with MIMO 4×4 spatial streams)
                                                           └─> 距香农限不到 3 dB 实践范: SNR margin

任何一层redundancy → entropy → coding → modulation, 每 error 从产业科学家 to 工程师逐环扣死. 数学极限 vs 工业极限只有这个学科塞死了这条直游戏.

章节

读完应能:

  1. 给离散无记忆源 $X$, 算熵 $H(X) = -\sum p_i \log_2 p_i$; 给联合分布, 算 $H(X, Y)$, $H(X|Y)$, $I(X;Y)$. 链式法则 $H(X, Y) = H(X) + H(Y|X)$ 怎么从独立逐步推到条件.
  2. 说"信源编码定理 (Shannon's source coding theorem)": 任何无损压缩的期望长度下界 ≥ $H(X)$. 例: 英语字母源熵 ~4.5 bits / char, ASCII 8 bits 过冗余度 ~44%.
  3. 香农容量 $C = B \log_2(1 + S/N)$ 推导: AWGN 信道下 high-SNR 渐近给出 ~$B \log_2 \text{SNR}$. 实践 5G mmWave 上 100 MHz 带宽, 25 dB SNR ⇒ 理论 830 Mbit/s, 实测 5G 峰 1.4 Gbit/s (MIMO 4x4).
  4. Huffman 编码本质 greedy 在频率上建前缀树, 但只能 approaching 整数 bit per symbol; 算术编码可以到分数 bit per symbol; ANS (Asymmetric Numeral Systems) 巧用进制 lattice 表搞出来 — zstd 生效.
  5. 汉明码 (7,4) 用奇偶校验矩阵 H 设计所进入=3 (Hu Porge), 单 e bit error recoverable; SECDED 加 1 个 overall parity bit 纠 1-bit + 检 2-bit.
  6. Reed-Solomon (n, k) on GF(2^8) 用 generator polynomial $\prod(x - \alpha^i)$ for i = 1..(n-k); 修 erasure (CD / QR / SSD) 与错误 (TB Detect cord); RAID 6 双 RS-tolerant.
  7. LDPC 用 sparse parity check matrix + Belief Propagation iterative decoder; complexity linear in n; design near-Shannon; 5G 数据信道选用. Tanner 图, girth ≥ 6.
  8. Polar code (Arikan 2008) 用 channel polarization: fair split 与 unfair split, half 渐进无噪 / half 渐进全噪. 选择 "good" channels for data; "frozen" 部分固定.
  9. Turbo 码: 2 RSC + interleaver + BCJR MAP decoder iterate; 3G/4G 起; 收敛速度劣于 LDPC, 5G 弃用 data 用 LDPC.
  10. QPSK / 16-QAM / 64-QAM 的 constellation packing 定 bit/symbol; 与 SNR-coupled BER (QAM-64 在 21 dB SNR 下 BER ~10⁻⁵; coding 后 effective BER ~10⁻¹²).
  11. 现代卷积码 + Viterbi decoder; 5G 物理广播 channel (PBCH) 还在用 Polar; data 层 LDPC; LTE 在 turbo 主导 4G.

历史 1: 1948 Shannon 论文一锤定音

Claude Shannon 在 Bell System Technical Journal 发表 "A Mathematical Theory of Communication", 引入 "bit" (binary digit) 概念, 区分信源 entropy vs 信道 capacity, 既给"压缩极限"又给"通信极限". 这一 paper 同时建立"信息论"与"编码论"两个领域. 此前 Hartley 1928 给节 "R = B log S" 简易版本; Shannon 把统计级加入.

历史 2: 1950 Hamming (7,4)

Richard Hamming 在 Bell Lab 与 Shannon 同时代, 因 weekends 实算 error bit 被后烦, 设计汉明码. 论文 1950 发, 第一 systematic error-correcting code. 直接 7 bit = 4 bit data + 3 bit parity, 单 bit 纠错. Hamming distance 概念 同引出.

历史 3: 1960 Reed-Solomon

Irving Reed 与 Gustave Solomon 在 MIT Lincoln Lab 给出 GF(2^m) 上 BCH 类码系统, 纠 erasure (data loss 已知位置)达 $n - k$, 纠错误 (位置不知) 达 $(n-k)/2$. CD-ROM / DVD / QR / DSL 全部仍然在用. RS(255, 223) 给 16 字节 纠错: 这是空间探测器 Voyager / Galileo 上上行.

历史 4: 1993 Berrou Turbo 码

Berrou-Glavieux-Thitimajshima 发表 "Near Shannon limit error-correcting coding" 在 ICC, 重写"香农限"概念——首次在 iteration 实验中观测到香农 ⼀≤ 1 dB. 业界就 quickly adopt 到 3G/4G 移动 communication 提出 consistency. 论文 originally 拒 paper from 1991 ICS conference on theory '认为不'are unworkable'; final 来 1993 era takes broad acceptance 此 45-year Shannon limit 真类似.

历史 5: 1996 MacKay-Neal LDPC 重发现

Gallager 1963 MIT dissertation 给 LDPC; 学术 default 'not practical'; 1996 MacKay 与 Neal 重新发现 r 密度 sparse parity matrix + belief propagation iterative decoding → 给接近 Shannon 极限的性能, 业内对 LDPC 're-embrace'. DVB-S2 2003, WiFi 802.11n 2009, 5G NR 2016 data channel 用 LDPC 取代 Turbo.

历史 6: 2008 Arikan Polar 码

Erdal Arikan 在 Bilkent Univ 论 "Channel Polarization" 给出 deterministic code 接近 Shannon limit. 2016 3GPP RAN1 选择 Polar code 给 5G NR 控制信道 (短 message 短 coding). Polar 比 convolution/Turbo 在 short coding 给更显优势.

历史 7: 2014-2020 zstd / ANS / Brotli

Jarek Duda 2006/2009 提出 ANS (Asymmetric Numeral Systems) 新编码 family, software 行 fast and throughput 化 entropy coding. Zstd Facebook 2015; Brotli Google 2015; LZ4 / zstd 给 现代压缩 pipeline (HTTP content-encoding br, zstd). BLAKE3 类作 hash + compression test drives equilibrium.

历史 8: 2009-2024 5G NR 物理层标准化

3GPP Release 15 (2017-2018): 决策 LDPC for data (eMBB, long block sizes), Polar for control (short codes), 短 code 用 TBConv编码 legacy. Release 16-17 加入 URLLC 物理层 PID 极 support URLLC for industrial IoT; pilot channels Under non-3GPP RAN; ATIS RC 确上行 TX 按内 polar-coded.


下一节 → Shannon Entropy

1. Shannon Entropy: 离散源熵 / 联合熵 / 条件熵 / 互信息

TL;DR

熵 $H(X)$ = 信源 $X$ 的平均不确定度 (字 bit-by-bit 平 TG 系统 coap 联接中, 必分布在平均未发生" leagues 触少 MB; something. per second waiting ). 它是:

$$H(X) = -\sum_{x} p(x) \log_2 p(x)$$

扩展到联合 ($H(X, Y)$), 条件 ($H(Y|X)$), 互信息 $I(X;Y) = H(X) - H(X|Y)$ 即"因为我已知 $X$, $Y$ 的不确定减少了多少". 熵是所有后续章节 (压缩极限, 容量) 的源头.


一、为什么用 $-\log p$

Shannon 一开始提"信息量" $I(x) = -\log_2 p(x)$: 概率小的事件发生让我们"惊讶大", 信息量大. 几个 desirable properties 唯一确定:

  1. $I(x) \geq 0$.
  2. $I(x)$ 随 $p \to 0$ → $\infty$.
  3. $I(x) = 0$ iff $p(x) = 1$.
  4. 独立事件叠加: $I(x, y) = I(x) + I(y)$ if independent.

唯一 $f$ 满足所有: $f(x) = -\log_b x$ (任意对数底). 默认 $b = 2$ 取 bit.

期望: $H(X) = E[I(X)] = -\sum p(x) \log p(x)$.


二、Examples 起手

2.1 公平硬币 + 高度偏:

  • Fair coin: $H = -2 \cdot (1/2) \log(1/2) = 1$ bit.
  • Loaded coin $P(H)=0.99$: $H = -0.99 \log 0.99 - 0.01 \log 0.01 \approx 0.081$ bit.

→ 偏源册的熵远低 (实际每 toss 真正"惊喜"少).

2.2 ASCII 英文

英文 26 字母 (空格 + punctuation) variate 很多: empirical 熵 (字天然统计) $H \approx 4.5$ bit/char 但 raw 8 bit/char ⇒ 冗余 44%.

Shannon 1951 估 English word (条件 char-by-char) 实际熵 ≈1.3 bit/char ⇒ 总冗余 84%.

→ 这就是英文 真可压缩到 1/6 原文 size, 实现上 zstd / brotli 接到 1/4 ~ 1/5 之 size.

2.3 Python 计算

from collections import Counter
from math import log2

def empirical_entropy(text: str) -> float:
    counts = Counter(text)
    n = len(text)
    return -sum(c / n * log2(c / n) for c in counts.values())

print(empirical_entropy("the quick brown fox jumps over the lazy dog"))
# ~4.4 bit/char

三、联合熵 $H(X, Y)$

$$H(X, Y) = -\sum_{x, y} p(x, y) \log p(x, y)$$

是, 即同时出现"X 取 $x$ 且 Y 取 $y$"的不确定度.

3.1 性质

  • $H(X, Y) \leq H(X) + H(Y)$ (subj. 独立时取等).
  • $H(X, Y) = H(Y, X)$ (对称).
  • $H(X, Y) \geq \max(H(X), H(Y))$ (knowing 不会 make more uncertain).

四、条件熵 $H(Y|X)$

$$H(Y|X) = \sum_x p(x) H(Y|X=x) = -\sum_{x, y} p(x, y) \log p(y|x)$$

"已知 $X$ 的情况下 $Y$ 还剩多少不确定".

4.1 链式法则

$$H(X, Y) = H(X) + H(Y|X)$$

Example: $X$ = "今日天气", $Y$ = "明天天气". $H(X, Y) = H(X) + H(Y|X)$ = "今日不确定 + 已知今日的明日增量".

4.2 信息不在 $X$ 完美预测 $Y$

  • 若 $Y = f(X)$ 确定性: $H(Y|X) = 0$.
  • 反之 $X \perp Y$: $H(Y|X) = H(Y)$.

4.3 性质

  • $H(Y|X) \leq H(Y)$ (knowing $X$ 不会增加 $Y$ 不确定).
  • 此式 identificar:

五、互信息 $I(X;Y) = H(X) - H(X|Y) = H(Y) - H(Y|X)$

互信息是发现自己一个"用 $X$ 推 $Y$ 的相关程度"标准. 详细公式:

$$I(X; Y) = \sum_{x, y} p(x, y) \log \frac{p(x, y)}{p(x) p(y)}$$

直观: "X 与 Y 的实际联合分布与独立分布的 KL散度".

5.1 Properties

  • $I(X; Y) \geq 0$.
  • $I(X; Y) = 0$ iff $X \perp Y$.
  • $I(X; Y) \leq \min(H(X), H(Y))$.

工程意义: $I(X; Y)$ 量化 channel throughput:

  • channel $Y = X + N$, $N$ 高斯噪声 ⇒ $I(X; Y) = \frac{1}{2} \log\left(1 + \text{SNR}\right)$. 这就是香农容量的来源 (next chapter).

5.2 Python

import numpy as np

def mutual_info(px: list[float], py: list[float], pxy: list[list[float]]):
    return sum(pxy[x][y] * np.log2(pxy[x][y] / (px[x] * py[y]) + 1e-12)
               for x in range(len(px)) for y in range(len(py)))

# example channel (BSC):
p = 0.1    # error prob
px = [0.5, 0.5]
py = [0.5, 0.5]   # marginal stays
pxy = [[0.95, 0.05], [0.05, 0.95]]
print(mutual_info(px, py, pxy))   # ~0.531 bit

六、KL divergence $D_{\text{KL}}(P||Q)$

$$D_{\text{KL}}(P | Q) = \sum_x p(x) \log \frac{p(x)}{q(x)}$$

性质:

  • $D \geq 0$.
  • $D = 0$ iff $P = Q$.
  • 不对称: $D(P||Q) \neq D(Q||P)$.

所有 generative model training (VAE, GAN, language modeling "negative log likelihood" 训练) 损失函数 源 象 源 = minimize $D_{\text{KL}}(\text{model}||data)$.

키 chain:

$$I(X;Y) = D_{\text{KL}}(p(x, y) | p(x)p(y))$$


七、Cross-entropy $H(P, Q)$

$$H(P, Q) = -\sum_x p(x) \log q(x)$$

性质: $H(P, Q) = H(P) + D_{\text{KL}}(P || Q)$. 即"我以为是 Q 但实际是 P 的不确定 = 真实熵 + 认知误差".

ML training: minimize $\theta$: $H(P_{\text{data}}, Q_\theta)$. 等价 minimize $D_{\text{KL}}(P_{\text{data}} | Q_\theta)$ since $H(P_{\text{data}})$ 与 $\theta$ 无关.


八、Shannon 信源编码定理 (source coding theorem)

定理: 任何无损压缩的期望长 ≥ $H(X)$ bits/symbol. 且存在渐近可达 $H(X)$.

例: 给 $X$ = 6 个 outcome 频率 = {0.4, 0.2, 0.15, 0.1, 0.1, 0.05}.

import math
H = -sum(p * math.log2(p) for p in [0.4, 0.2, 0.15, 0.1, 0.1, 0.05])
# H ≈ 2.286 bit/symbol

即最优 prefix code 长度 ~2.286 bit/symbol. Huffman (下一章) 取 ~2.30 (integer bit/char); 算术编码到 2.286 (分数 bit).


九、Differential entropy (logos continuous RV)

Continuou 随机变量 entropy _guide 严 ely differential:

$$h(X) = -\int p(x) \log p(x) dx$$

注意:

  • differential entropy 可负: 与离散不同. uniform $[a, b]$ 给 $\log(b-a)$. 随 $b-a \to 0$ $\log\to-\infty$.
  • 工程用相对意义: $D_{\text{KL}}$ 和 $I$ 全 retained identical for continuou.

高斯: $X \sim \mathcal{N}(\mu, \sigma^2)$ → $h(X) = \frac{1}{2}\log(2\pi e \sigma^2)$.

关键论: 在方差固定的连续分布中, 高斯分布是 maximize entropy** 的**. This is non-trivial. 证明用 Jensen 不等式.


十、Maximum Entropy Principle

给定约束 maximally entropic distribution 最不偏:

  • 已知 mean + variance: $\mathcal{N}$ normal.
  • 已知 mean only $[a, b]$: $\text{Uniform}[a, b]$.
  • 已知 mean only $[0, \infty)$: Exponential.
  • 已知 mean only in discrete space: Geometric.

工程论 model picking: "maximize entropy given known" ⇔ 乐 ISO科学 model ('doesn''t add bias not 宝 most-site given羁 BIND')


十一、Bridges

  • complexity.md / 压缩极限: 信息熵直接给 minimum 压缩长. H(X) ≤ log|alphabet| = log2 combinatorial 复杂;
  • compression.md next 给 LZ + Huffman + ANS 走 theorem edges.
  • capacity.md next: channel capacity 与 mutual information $C = \max_{p(x)} I(X; Y)$.
  • crypto: weak cryptographic hash output 分析 compression resistance via Max entropy of the uniform distribution of $n$-bit outputs.
  • distributed/clock/dag: information flow between nodes and within timing 相关 communication network.
  • crypto/hashes.md: prefers hashes link.

下一节 → 信道容量

2. 信道容量: 香农公式 C = B · log₂(1 + SNR)

TL;DR

香农容量定理 (1948): given 物理 (带宽 $B$, SNR=S/N) AWGN 通信信道下, 无错传输率上界: $$ C = B \log_2(1 + \mathrm{SNR}) $$

这是一条自然定律: no matter what code you design, no matter error-correction, no matter modulation, no encoding scheme can deliver bits reliably faster than $C$ bits/sec.

本章节小问题稍微 recover:

  • 5G mmWave 100 MHz, 25 dB SNR ⇒ $C = 830$ Mbit/s, 8x8 MIMO push 学 $6.6$ Gbit/s.
  • 香农极限 vs feedback 8dB → real engineers in production 距离 ≤ 1.5 dB via LDPC + LDPC.

一、Channel model

1.1 离散无记忆信道 (DMC)

input alphabet $\mathcal{X}$, output $\mathcal{Y}$, transition probability $p(y|x)$. Memoryless: 每次独立 (与 prior 输入无关).

容量: $$ C = \sup_{p(x)} I(X; Y) $$

1.2 BSC (Binary Symmetric Channel)

$\mathcal{X} = \mathcal{Y} = {0, 1}$, $p(0|0) = p(1|1) = 1 - p$, $p(0|1) = p(1|0) = p$.

这里 $p$ 是 bit error probability.

$$ C_{\text{BSC}} = 1 - H_2(p), \quad H_2(p) = -p \log_2 p - (1-p) \log_2(1-p). $$

$p$$C$meaning
01 bit无噪
0.50 bit全噪 (无可传)
0.01$\approx 0.92$ bit8% lost

1.3 BEC (Binary Erasure Channel)

$\mathcal{Y} = {0, 1, ?}$ (erasure ?) with prob $\epsilon$:

$$ C_{\text{BEC}} = 1 - \epsilon $$

工程意义: TCP packet loss = erasure channel. TCP throughput 上界 = bandwidth × $(1 - loss)$.

1.4 AWGN

$Y = X + N$, $N \sim \mathcal{N}(0, \sigma^2)$. 给定 input power P, signal-and-noise ratio S/N:

$$ C = \frac{1}{2} \log_2(1 + \mathrm{SNR}) \quad \text{per real symbol}$$

or for bandwidth $B$ bandpass channel: $$C = B \log_2(1 + \mathrm{SNR}) $$


二、Capacity 推导 sketch

记 $X$ 是发送信号, power 上界 $P$; 噪声 $N$ 高斯 $\sigma^2$. 我们希望 max $I(X; Y)$ over $p(x)$.

$$ I(X; Y) = h(Y) - h(Y|X) = h(Y) - h(N) $$

因 $h(N) = \frac{1}{2} \log(2\pi e \sigma^2)$ 是常数 (given $X$, $Y = X + N$, $h(Y|X) = h(N)$).

$Y$ has mean $E[X]$ (assume 0 WLOG) and variance $P + \sigma^2$. Max 鞅 high entropy ⇒ Gaussian ⇒ $h(Y) \leq \frac{1}{2} \log(2\pi e (P + \sigma^2))$.

$$\max I = \frac{1}{2}\log(2\pi e(P+\sigma^2)) - \frac{1}{2}\log(2\pi e \sigma^2) = \frac{1}{2}\log\left(1 + \frac{P}{\sigma^2}\right)$$

公式 rise field quickly $\frac{1}{2}\log_2(1 + \mathrm{SNR})$ per sample.

For bandwidth $B$ signal (Nyquist sample rate 2B/sec), result $B\log_2(1+\text{SNR})$.


三、5G 实践 raw numbers

3.1 mmWave 28 GHz cell

维度数值
Bandwidth100 MHz
SNR25 dB (cell center) → 5 dB (cell edge)
Capacity single stream100 × $\log_2(1 + 10^{25/10})$ ≈ 100 × 8.66 = 866 Mbit/s (cell center)
Single stream 香农 @ 5dB100 × $\log_2(1 + 3.16)$ ≈ 224 Mbit/s
MIMO 4x4 spatial streams*×4 香农 ≈ 3.46 Gbit/s
5G NR demonstrated peak rate downlink in lab~4.2 Gbit/s
  • MIMO spatial multiplexing requires good channel conditions. 4 streams at SNR sufficient.

3.2 WiFi 6 / 802.11ax

20 MHz bandwidth 20:11 1024-QAM 11 dB SNR ⇒ capacity 1 Gbit/s theoretical, 实测 100s Mbit/s.

3.3 DSL VDSL2

100 kHz - 12 MHz bandwidth. Saturated $\Rightarrow$ 200 Mbit/s total raw (down+up). 距离 line 25 dB SNR at 30 MHz ⇒ $C ≈ 200$ Mbit/s.


四、Coding gain (实际编码离香农的距离)

Coding gain: 双 error-rate (e.g. BER=10⁻⁶), coding 可给相同 BER 用较低 SNR. 单位 dB.

CodeCoding gain @ 10⁻⁶Fuel use成熟
Hamming (15,11)~1 dB古典
Reed-Solomon (255,223)~2-3 dB at BER10⁻⁶品格 industry obsolete 不是 nesta der (offset via symbol errors)
Reed-Muller (128,64)~1.5 dBshort codes country PDF: Polar 起源有关
Convolutional code + Viterbi (K=7)~3-4 dB3G 基 line 代
Turbo code (3G)~5.5 dB3G/4G
LDPC (Wifi 6/5G)~6-8 dB现工
Polar code (5G NR control)~5 dB5G
ML optimal (Shannon limit)~9-10 dB**floor

与香农极限的距离: typical production code 在香农 底 + 1-3 dB ⇒ throughput 法例 lower limit at 95%, much harder to find.


五、Cross-layer: capacity vs power, MIMO, OFDM

5.1 Power-bandwidth tradeoff

固定 capacity C 下: $P \sim 2^{C/B}$. 高 B 低功率 (NB-IoT), 低 B 高功率 (卫星 VSAT).

5.2 MIMO

$M$ transmit antennas, $N$ receive antennas give potential $\min(M, N)$ independent spatial streams:

$$ C_{\text{MIMO}} = H \cdot \log(1 + \text{SNR}) \cdot \min(M, N) $$

(H = scaling factor based on antenna correlation).

5G mmWave 4x4 spatial streams ⇒ ~4× capacity.

5.3 OFDM

OFDM 划分 into $K$ subcarriers, each with flat-fading assumption. 总 capacity:

$$ C_{\text{OFDM}} = \sum_i B_i \log_2(1 + \text{SNR}_i) $$

5G OFDM gives per-subcarrier modulation selection: QPSK on weak subcarriers, 64-QAM on good. Water-filling algorithm骏 optimal.

5.4 水注 (water-filling)

Power $P$ distribute cross parallel subchannels to maximize total. Optimal:

$$ P_i^* = \max(0, \lambda - \sigma_i^2) $$

where $\lambda$ 是 determined budget constraint $\sum_i P_i^* = P$ total.

→ better subchannels get more power. Practical 5G adaptive modulation does approximate.


六、典型系统 capacities

系统BandwidthSNR (dB)Capacity5G 的 downlink cell edge (Mbps)
WiFi 6 (1024-QAM)160 MHz35~5400 Mbit/s1.5 Gbit/s peak
5G NR mmWave (28GHz)100-400 MHz25 (cell); 5 edge860-3440 center200-500 edge
Sub6 5G (3.5GHz)100 MHz20 / 0 edge666 center / 100 edge200-500
4G LTE (Cat 19)20 MHz15-20200-450 Mbit/s1000 peak DL
1000BASE-T Ethernet (1 Gbit/s / 100m Cat5)bandwidth regulated, binary signaling via PAM5 + DSPDissertation / vs coding kauge.
10GBASE-T codec (1024-PAM / DSQ128 encoding) 兼容 100m Cat6abandwidth 500 MHz over 100 m250 MHz—with coding cancel crosstalk10 Gbit/s100% efficiency

七、Capacity 实践: 用 Python 仿真

import numpy as np

def shannon_capacity(bw_hz: float, snr_db: float) -> float:
    return bw_hz * np.log2(1 + 10 ** (snr_db / 10))

# mmWave cell center & edge
print(shannon_capacity(100e6, 25))  # ~866 Mbit/s
print(shannon_capacity(100e6, 5))   # ~224
print(shannon_capacity(100e6, 0))   # ~100

# MIMO throughput (4x4 with same SNR)
print(4 * shannon_capacity(100e6, 10))  # ~1.37 Gbit/s

八、有限 block-length capacity

Shannon's classical theorem是 asymptotic ($n \to \infty$). 有限 block length $n$ 给出修正: $$ \log M^* \leq n C - \sqrt{n V} Q^{-1}(\epsilon) + O(\log n) $$

where $V$ 是 dispersion (channel dispersion), $Q^{-1}$ 是 inverse Gaussian Q function, $\epsilon$ 是 error probability.

工程意义: short packets (control channels in 5G) 受惩罚. Polar code 在 short packet (e.g., 32-256 bits) 距离香农更近 => 5G 选 Polar for control.


九、桥梁

  • compression.md prev: source coding theorem vs. channel coding theorem 双极限.
  • modulation.md next-跳到: QAM constellations give precisely $\log_2 M$ bits per symbol; 与 capacity $\sim B \log (1+\text{SNR})$ 取 best modulation.
  • ldpc.md: LDPC is the producer-in-production code nearest to channel capacity for 5G NR data.
  • crypto: capacity feedback limit 不直接 位 crypto 但 random source entropy uses ≥ 2·H(X) source candidate puts. Multi key bit predictions rate.
  • distributed/clock/dag: latency-bandwidth product impacts relation 计算 (link RTT × capacity = inflight bits, TCP/QUIC congestion window).

下一节 → 无损压缩

3. 无损压缩: 哈夫曼 / LZ77 / LZ78 / zstd / 算术编码 / ANS

TL;DR

无损压缩把信源 $X$ 的串 $x^n$ 编码成长度 $L(x^n)$ bits, $L$ 越接近 $H(X)$ 越优 (Shannon 信源编码定理). 三大家:

  1. 熵编码 (entropy coding): Huffman / 算术 / ANS — 给 symbol variable-length code based on frequency.
  2. 字典编码 (dictionary coding): LZ77 / LZ78 / LZW — 滑动窗子串匹配 reference (GZIP / zlib).
  3. 现代融合: zstd / Brotli / LZ4 = LZ77 + 熵编码 + dedup + dictionary training.

一、Huffman 编码 (1952)

1.1 Greedy tree-building

  1. Every symbol initial node weighted by $p_i$.
  2. 取 two lowest-weight nodes, create parent weighted by sum.
  3. Repeat until one tree.
  4. Path from root to each leaf gives codeword.
import heapq

def huffman(prob_symbols: dict) -> dict:
    heap = [[p, [sym, ""]] for sym, p in prob_symbols.items()]
    heapq.heapify(heap)
    while len(heap) > 1:
        lo = heapq.heappop(heap); hi = heapq.heappop(heap)
        for pair in lo[1:]: pair[1] = '0' + pair[1]
        for pair in hi[1:]: pair[1] = '1' + pair[1]
        heapq.heappush(heap, [lo[0] + hi[0]] + lo[1:] + hi[1:])
    return dict(heapq.heappop(heap)[1:])

print(huffman({'a': 0.4, 'b': 0.2, 'c': 0.15, 'd': 0.1, 'e': 0.1, 'f': 0.05}))
# {'a': '0', 'b': '10', 'c': '110', 'd': '1110', 'e': '11110', 'f': '11111'}

1.2 Bound

$$ H(X) \leq L_{\text{Huffman}} \leq H(X) + 1 $$

Huffman 每个 symbol 花整数 bit ⇒ 平均长度最多 over entropy 1 bit.


二、算术编码 (Rissanen 1976)

不要每个 symbol 给独立 coding — 作为区间编码: 整个 message 映射到 [0, 1) 子区间. 分数 bit per symbol.

2.1 Algorithm

Initial interval $[0, 1)$. For each symbol $x$: $$ \text{new range} = [\text{low} + r \cdot \text{CDF}_x^{\min}, \text{low} + r \cdot \text{CDF}_x^{\max}) $$

最后 range 内任一数即可编码.

2.2 与 Huffman 对比

  • Length $\in [H(X^n), H(X^n) + 1)$ bits for 整个 message — 接近完美 entropy.
  • Encoding/decoding carry multiplication / division ⇒ 慢 vs Huffman.
  • PPM (Prediction by Partial Matching) 在 arithmetic coding 上加 statistical model.

三、LZ77 (Ziv-Lempel 1977) — GZIP 内核

滑动窗回看前一区 reference. 找最长 match with current. Output (distance, length, next_char).

3.1 Encode example 串 "ababab..."

pos 0: 'a' → (0,0,'a')
pos 1: 'b' → (0,0,'b')
pos 2: 'aba...' → back 2 chars match 'ab', copy 2. → (2,2,EOF)

3.2 LZSS refinement

flag_bit + (distance, length)flag_bit + literal. 长 match 复用更长公共.

3.3 解码

Maintain running output buffer; (d, l) 指 copy l chars starting d positions back. Fast, ~1 cycle/bit decompress.


四、LZ78 / LZW (Welch 1984, GIF / Unix compress)

4.1 Algorithm

  • 初始化 dictionary 含 256 ASCII single chars.
  • For input: search for longest prefix already in dict.
  • Output longest prefix index. Add (longest prefix + next char) to dict.

例 "TOBEORNOTTOBE":

Step  | output | new dict entry
T     |  84    | 256 "TO"
O     |  79    | 257 "OB"
B     |  66    | 258 "BE"
E     |  69    | 259 "EO"
R     |  82    | 260 "RN"
N     |  78    | 261 "NO"
T     |  84    | 262 "TT"
TO    |  256   | 263 "TOB"

→ 长 input gets exponentially efficient.


五、DEFLATE (RFC 1951) — GZIP / zlib / PNG 内部

gzip 内: LZ77 (window 32 KB) + Huffman entropy coding:

  1. Run LZ77 on input → tokens (literal bytes + match distance-length).
  2. Gen Huffman coding for literals + Huffman coding for distances.
  3. Stream encode.

Default zlib ~50-100 MB/s encode, 200-500 MB/s decode. Most-used HTTP 压缩 (Content-Encoding: gzip).


六、zstd (Facebook / Zstandard, 2015)

Modern drop-in replacement for zlib:

  • 1.x: 3-5× 更快 decompression than zlib at same ratio.
  • 训练 dictionaries 给小 files 3× better ratio.
  • CLI multi-thread default.
zstd file.txt                      # level 3 default
zstd -19 file.txt                  # max ratio
zstd --train ./training/*.txt -o dict.bin
zstd -D dict.bin small.json

zstd 内部:

  • Block size 128 KB.
  • "FSE" 有限状态熵编码 (Finite State Entropy), 类 rANS / tANS — interleaved 状态编码.
  • 窗 1 KB - 8 MB.
  • LZ77 + FSE 组合.

七、Brotli (Google 2015)

HTTP Content-Encoding: br 主用.

  • Static dictionary 120 KB 内置 common strings.
  • LZ77 + Context modeling + Huffman.
  • 比gzip 5-25% better for HTTP text content.
  • Chrome 2016+ 默认启用.

八、LZ4 (Collet 2011)

Extreme fast. ~500 MB/s compress, 4 GB/s decompress.

  • 16-bit match length, 4-byte minimum match.
  • 不熵编码.
  • 用 ZFS pool, Apache Arrow streaming, hot path Java log.

九、ANS (Asymmetric Numeral Systems, Duda 2009)

ANS 是 LZ77 与 arithmetic 之间 hybrid. 用 integer $x$ 描述 entropy stream.

9.1 Algorithm core

State $x \in \mathbb{Z}$. For symbol $s$ with probability $p_s$ (frequency $L_s$ of total $L$):

$$x' = (\lfloor x / L_s \rfloor \cdot L) + (x \bmod L_s) + c_s$$

→ 在 [0, L) 内自然 move 通过 symbol 概率分布, 解码可逆.

9.2 Python rANS prototype

def rans_encode(state: int, symbols: list, freq_table: dict, total: int) -> int:
    x = state
    for sym in symbols:
        f, c = freq_table[sym]
        if x >= (1 << 32) // f:            # need renormalization (output low bits)
            ...                            # omitted, in practice use streaming version
        x = (x // f) * total + (x % f) + c
    return x

def rans_decode(state: int, freq_table: dict, total: int) -> tuple:
    slot = state % total
    for sym, (f, c) in freq_table.items():
        if c <= slot < c + f:
            return sym, (state // total) * f + (slot - c)
    raise ValueError("decode error")

9.3 实际 use

ANS over arithmetic encoding 给 zstd / Brotli / "FSE" 5-50× faster decode speed 与 naive arithmetic coding 同 ratio. Modern 压缩 default entropy encoder when Huffman ratio < arithmetic ratio but arithmetic decode 失.


十、其它压缩算法

算法descriptionuse
BZIP2Burrows-Wheeler Transform + Move-to-front + HuffmanLinux tar (good ratio, slow)
PPMdpredictive statistical + arithmetic7z archive winning
LZMA / LZMA2LZ77 + range encoding + large dictionary7z, xz default
SnappyLZ77-only + dedupeGoogle internal fast
LZXLZ with arithmeticMicrosoft CAB legacy

十一、压缩极限 boundary

  • 随机 bit string length $n$: 期望压缩 ≥ $n$ bits (entropy = n). 压不动!
  • algorithm on pseudo-random data 实际 give 压缩比率 < 1.0 (扩大) 自己 also-symbol.
  • Lossless compression 不破坏 Pigeonhole Principle: 任意串压缩比 1.0 ⇒ must have expand strings 比 ≥ 1.0 by similar count.

十二、工程推荐

Scenario推荐 algorithm
HTTP content-encodingBrotli (text) + Gzip (fallback)
Static file distributionzstd
Streaming real-time (low latency)LZ4
Storage / archivezstd or LZMA
Memory snapshotLZ4 with dictionary
Image archivePNG (LZ77+Huffman) or WebP lossy
Backupzstd -19 --long

十三、Bridges

  • entropy.md prev: $H(X)$ 下界给 lossless compression.
  • capacity.md prev: source coding vs channel coding 双限.
  • databases/WAL: WAL segments 用 zstd 或 LZ4 block compress backup.
  • distributed/fault/erasure.md: Reed-Solomon 在 encoding 之并发; compression + erasure 组合 best architecture.
  • crypto/hashes.md: SHA-256 + 利 file 文件 orient checksum廉 质控完整性 independent of compression.

下一节 → 汉明码

4. 汉明码: 可纠 1-bit 错误的鼻祖

TL;DR

Hamming (7, 4) 是第一个 systematic error-correcting code, 1950Hamming 在 Bell 实验室中亲自为解决 weekend computer relay error 而设计. 编码 4 bit data → 7 bit codeword (3 bit redundancy), 可纠任意 1 bit 错误. 实现 encoding-decoding 用小 parity check matrix H 给 syndrome 计算—Compute syndrome $\vec{s} = H \vec{r}$ ⇒ 错的 bit position = $\vec{s}$ 二进制解读 (or 0 = 正确). 加 1 个 overall parity → SECDED (可纠 1 单 bit + 检测 2 bit error). 现代 ECC RAM 跑的就是 SECDED.


一、Hamming (7, 4) 设计

1.1 Structure

4 data bits $d_1, d_2, d_3, d_4$ → 7 bit code. 三 parity bits $p_1, p_2, p_3$ 位于位置 1, 2, 4 (2 的幂位置留给 parity, 其余给 data). At positions:

$$ c_1 = p_1,; c_2 = p_2,; c_3 = d_1,; c_4 = p_3,; c_5 = d_2,; c_6 = d_3,; c_7 = d_4 $$

Parity bits 覆盖一组 positions (which binary index has its corresponding parity's bit set):

  • $p_1$ 覆盖 positions with bit 0 set in index: 1, 3, 5, 7
  • $p_2$ 覆盖 positions with bit 1 set: 2, 3, 6, 7
  • $p_3$ 覆盖 positions with bit 2 set: 4, 5, 6, 7

parity $p_i$ = XOR of data bits in its covers.

1.2 Parity check matrix H

$$ H = \begin{bmatrix} 1 & 0 & 1 & 0 & 1 & 0 & 1 \ 0 & 1 & 1 & 0 & 0 & 1 & 1 \ 0 & 0 & 0 & 1 & 1 & 1 & 1 \end{bmatrix} $$

import numpy as np
H = np.array([
    [1,0,1,0,1,0,1],
    [0,1,1,0,0,1,1],
    [0,0,0,1,1,1,1],
])
G = np.array([
    [1,1,0,1,0,0,0],
    [0,1,1,0,1,0,0],
    [1,1,1,1,0,1,0],
    [0,1,1,0,0,0,1],
]) % 2  # generator

1.3 Encoding

$c = d G \mod 2$ where G 是 generator matrix.

def hamming_encode(data: list[int]) -> list[int]:
    """4 data bits → 7 code bit."""
    return [int(x % 2) for x in np.dot(data, G) % 2]

1.4 Decoding with syndrome

r = received 7 bits
s = H @ r mod 2     # syndrome 3 bit
if s == 0:     no error
else:          bit position = interpret s as binary (1..7), flip that bit
def hamming_decode(r: list[int]) -> list[int]:
    s = H.dot(r) % 2
    if not s.any():
        return r[2], r[4], r[5], r[6]   # d1 d2 d3 d4
    pos = int(s.dot([1, 2, 4]))         # position (1=first)
    r[pos - 1] ^= 1                     # flip
    return r[2], r[4], r[5], r[6]

1.5 Examples

  • Correct transmit c = [1,0,1,0,1,0,1], receive OK ⇒ syndrome 0.
  • Error bit 5: receive [1,0,1,0,0,0,1]. Compute syndrome: H · r mod 2 = [1, 0, 1] = 5 (binary 101). Flip bit 5 ⇒ recover.

二、Hamming distance & detecting capacity

2.1 Hamming distance

两 codewords 不同的 bit 数. Hamming (7,4) 最小 distance $d_{\min} = 3$.

  • 可检测 $d_{\min} - 1 = 2$ bit errors.
  • 可纠正 $\lfloor (d_{\min}-1) / 2 \rfloor = 1$ bit errors.

2.2 Singleton bound

Code with min distance $d$, $R = k/n$ rate has: $$ d \leq n - k + 1 $$

Hamming codes reach this.故 Hamming(7,4) $d_{\min}=3$, $n-k+1 = 4$, not optimal, but useful for short code.

2.3 Sphere packing bound

若 code $C$ $(n, k, d)$ with $d = 2t + 1$: $$ 2^k \cdot \sum_{i=0}^{t} \binom{n}{i} \leq 2^n $$

Hamming code is perfect—saturates sphere packing bound (for $t = 1$): $2^4 \cdot (1 + 7) = 2^7$. 完全 cover 全 2^7 space.


三、扩展 Hamming (8, 4) SECDED

Add overall parity bit (sum of all):

  • 可纠任意 1 bit.
  • 可检测 (但不纠) 任意 2 bit error.

服务器 ECC RAM 使用 SECDED (short for Single Error Correction, Double Error Detection). 对 64 bit RAM, 通常用 Hamming(72, 64) Hammond variant: 64 datatext + 8 parity.

def secded_encode(data_4: list[int]) -> list[int]:
    c7 = hamming_encode(data_4)
    p = sum(c7) % 2
    return c7 + [p]

def secded_decode(r8: list[int]) -> tuple:
    c7, p = r8[:7], r8[7]
    s = H.dot(c7) % 2
    overall = sum(r8) % 2
    if not s.any() and overall == 0:
        return c7[2], c7[4], c7[5], c7[6], 'OK'
    if not s.any() and overall == 1:
        return None, 'D2 error detected'
    if s.any() and overall == 1:
        # single error in c7; fix
        pos = int(s.dot([1, 2, 4]))
        c7[pos - 1] ^= 1
        return c7[2], c7[4], c7[5], c7[6], '1-bit corrected'
    if s.any() and overall == 0:
        # double error - uncorrectable
        return None, 'D2 error, uncorrectable'

四、Hamming distance其它使用

4.1 ML / codeword/ 距离最近邻 (球够不 安全设距离) 修正:

hamming NetE achievable edit-distance nearest codeword格给出从嘿嘿 case 转 Euler channel ramblе.

4.2 Indexed 整数 set

加速查询 high-dimensional integers / bitset: search index 使用 "fewest bit difference" = 文件 → database dedupe. Sense of approximate nearest neighbor.


五、与 5G / CPRI / WiFi 实际 工程

5G transport 网络回程屡用 BCH / Reed-Solomon + LDPC 通过 fiber. Hamming code 核心接地 only on-cache L1 microcode hill warm 出 → fault-tolerant RAM, satellite deep-space link (Voyager 1977 RS+CC concatenated).

Hyperloop-Net TCP-IP level 用校验 checksums & CRC32C (比 Hamming 复杂 cyclic code 各 derivative).


六、局限性

  • 1 bit error correctable; 数据率给 4/7 ~ 57% ⇒ redundancy 75%. Real 链 比例 ≥ Hamming(ii)多项式 distance.
  • 多 bit error (especially burst) 不断 → 必须 interleaving 后才 LDPCTurbo 嵌入 next.
  • 距离随 length 增加 (Sophisticated code 覆 uses dmin=12+ tight stronger).

→ CD / DVD / satellite link 用 RS / convolutional concatenate.


七、桥梁

  • complexity.md / reduce: Hamming code-realization importances keep通道 channel coding process performance tradeoff.
  • capacity.md prev: distance from Shannon limit; Hamming(7,4)距 ≈3 dB.
  • reed-solomon.md next 自然Nü error correction 大 blockerror model 帮决定 selection.
  • crypto/hashes.md: ECC RAM cross-seen Hamming 硬件实现 是 necessary为 rowHammer őd PT co-prac-tice.
  • os/memory/virtual-memory: ECC RAM is the leaf protection layer hardening working connections, layered system add on top virtual memory pinning.

下一节 → Reed-Solomon 码

5. Reed-Solomon 在 GF(2⁸) 上的纠错码

TL;DR

Reed-Solomon (RS) 发明 1960 (Reed & Solomon in MIT Lincoln Lab), 是 BCH 类 cyclic code 在 GF($2^m$) 上的实现. RS(n, k) 编码 k 个数据 symbol 为 n 个 codeword symbol, 各 symbol 在 GF($2^m$). key properties:

  • 可纠 $(n-k)/2$ 个 symbol error
  • 可纠 $n - k$ 个 erasure (位置已知 )
  • 适合纠 burst error (单 byte 全错也称 1 symbol error)
  • 实际生活: CD-ROM, DVD, QR code, DSL, storage RAID 6, satellite links, Voyager probe.

一、代数背景

1.1 Galois Field GF(2^8)

8-bit symbol field: $\beta_7 \beta_6 \ldots \beta_0$, where $\beta_i \in {0, 1}$. 在 GF(2) 上模 reduction polynomial $p(x) = x^8 + x^4 + x^3 + x^2 + 1$ 给 standard field arithmetic; QR CODE标准 generating polynomial.

def make_gf256(generator_poly_exp):
    exp = [0] * 512
    log = [0] * 256
    x = 1
    for i in range(255):
        exp[i] = x
        log[x] = i
        x = (x << 1)
        if x & 256: x ^= x ^ 0x11D  # which is the standard QR generator 0x11D
    for i in range(255, 512): exp[i] = exp[i - 255]
    return exp, log

GF256_EXP, GF256_LOG = make_gf256(0x11D)

def gf_mul(x, y): return GF256_EXP[GF256_LOG[x] + GF256_LOG[y]] if x and y else 0
def gf_div(x, y): return GF256_EXP[GF256_LOG[x] + 255 - GF256_LOG[y]] if x and y else (0 if y else 'div0')
def gf_pow(x, p): return GF256_EXP[(GF256_LOG[x] * p) % 255] if x else 0
def gf_inv(x): return GF256_EXP[255 - GF256_LOG[x]]

(实工程直接做法 用 GF(2^8) creative JS, 文献 撇 主要 because of MixedPolynomial FM inverse map taper Algebra).

1.2 Polynomial 多项式 in GF(2^8)

Codeword state多项式 C(x) data input 经 generator $g(x) = \prod_{i=0}^{n-k-1} (x - \alpha^i)$ where $\alpha$ = field primitive. n - k 个 distance transfers.


二、Encoding 用 systematic form

2.1 给 data symbols $D(x) = d_{k-1} x^{k-1} + \ldots + d_0$.

  1. $D'(x) = x^{n-k} D(x)$, 给 distance term space at low position.
  2. Compute remainder $R(x) = D'(x) \bmod g(x)$.
  3. Codeword $C(x) = D'(x) - R(x) = D'(x) + R(x)$ (因 GF(2) addition = subtraction = XOR).

The key properties understand:

  • C(α) = 0 for all i=0..n-k-1.
  • $R(x)$ is syndromes-free code portion in the codewords.

2.2 例子 与 Python

def rs_generator_poly(nsym):
    """Generate irreducible polynomial for n-k parity symbols."""
    g = [1]
    for i in range(nsym):
        g = gf_poly_mul(g, [1, GF256_EXP[i]])
    return g

def gf_poly_mul(p, q):
    r = [0] * (len(p) + len(q) - 1)
    for j in range(len(q)):
        for i in range(len(p)):
            r[i + j] ^= gf_mul(p[i], q[j])
    return r

def rs_encode_msg(msg_in, nsym):
    gen = rs_generator_poly(nsym)
    # pad msg with zeros for parity
    msg_out = msg_in + [0] * (len(gen) - 1)
    # polynomial division
    for i in range(len(msg_in)):
        coef = msg_out[i]
        if coef != 0:
            for j in range(1, len(gen)):
                msg_out[i + j] ^= gf_mul(gen[j], coef)
    # msg_out[len(msg_in):] is parity
    return msg_in + msg_out[len(msg_in):]

例 encode "Hello World!" 用 RS(255, 223):

  • data=12 bytes ASCII
  • parity = (n-k)*(rounded) = 32 symbols (assumes 6 bytes padding ratio)
  • encoded message len = 44 bytes; 6 errors 可纠.

三、Decoding

3.1 Syndromes

def rs_calc_syndromes(msg, nsym):
    return [gf_poly_eval(msg, GF256_EXP[i]) for i in range(nsym)]

If all syndromes 0 → no corruption.

3.2 Berlekamp-Massey: error locator polynomial

Given syndromes $S_1..S_{n-k}$, find error locator polynomial $\Lambda(x)$ whose roots indicate error positions.

def rs_find_error_locator(synd, nsym):
    """Berlekamp-Massey algorithm to find error locator polynomial."""
    err_loc = [1]
    old_loc = [1]
    for i in range(nsym):
        old_loc = old_loc + [0]
        delta = synd[i] ^ gf_poly_eval(err_loc[::-1], synd[i])
        if delta == 0: continue
        if len(old_loc) > len(err_loc):
            new_loc = scale_poly(old_loc, delta)
            old_loc = scale_poly(err_loc, gf_inv(delta))
            err_loc = new_loc
        err_loc = gf_poly_add(err_loc, scale_poly(old_loc, delta))
    return err_loc

Find roots of $\Lambda(x)$ in field ⇒ get error positions.

3.4 Forney algorithm

Compute error magnitudes at error positions, subtract from received codeword to recover original.

def rs_correct_msg(msg_in, nsym):
    if len(msg_in) < nsym: return msg_in
    synd = rs_calc_syndromes(msg_in, nsym)
    if max(synd) == 0: return msg_in  # no errors
    err_loc = rs_find_error_locator(synd, nsym)
    err_pos = rs_find_errors(err_loc, len(msg_in))
    if not err_pos: raise ValueError("uncorrectable")
    msg_out = rs_forney(msg_in, nsym, err_pos)
    return msg_out

四、QR code RS example

QR code version 4 (33 modules × 33 modules) splits data into blocks. For Level Q + version 3:

data codewords: 70 bytes  =  content
EC codewords: 36 bytes per QR block split 2 阻:
Block 1: 26 data + 14 EC  =  40 bytes RS(40,26)
Block 2: 18 data + 14 EC  =  32 bytes RS(32,18)

→ RS code correction 补 toml hammers pick pitch collection, 允许受损 QR codes still be readable. Level L (low EC) ~7% error recovery; Level H (high) ~30% — designed to handle 包裹被部分覆盖/partial damage.


五、CD-ROM / DVD 实施

CD-ROM audio uses CIRC (Cross-Interleaved Reed-Solomon Code):

  • C1 (32, 28) RS code inner.
  • C2 (28, 24) RS code outer.
  • Interleaving between C1 and C2 handles burst error up to 4000 bits.

DVD: RS-PC (Product Code), 32 rows × 16 cols structure, encoder RS(208, 192) × RS(182, 172).

[ 32 bytes per C1 outer row - 16 inner+  28 bytes BC..]
       ↓ ✗ interleaved in actual CD scan // burst noise block → spread across blocks
[   * (28, 24) C2 inner.ATORHER guffins plusidate atopif freshly banker picky!

Each error up to 0.5% mass coverage CD can be recovered (~137 byte burst continuous). Industry governing 子 ordering + redundancy.


六、RAID 6 use Reed-Solomon (P + Q)

RAID 6 = 2 parity disks: P disk = XOR, Q disk = Reed-Solomon by GF(2^8) on sum:

P = sum_i d_i
Q = sum_i alpha^i · d_i  (RS at byte level)

Can recover up to 2 disk failures:

  • 1 failure: get from P.
  • 2 failures: solve $d_{i1} + d_{i2} = P$ and $\alpha^{i1} d_{i1} + \alpha^{i2} d_{i2} = Q$ system.

Modern ZFS / btrfs use 3+ parities (raidz3).


Voyager 1/2 used RS(255, 223) concatenated with hal rate convolutional code (rate 1/2, K=7) by Viterbi decoder. Combined @coding gain 7 dB level. Independent error correction combined: 上行 signal weak from deep space (sun's emissivity ≈ -80 dBm SNR), → Voyager still sends 比特 @160 bit/s 上加!


八、其它 RS variant

  • CCSDS RS: deep-space international standard RS(255, 223).
  • DVB: DVB-S, DVB-T uses RS(204, 188).
  • DSL: ANSI DSL RS(255, 239).
  • WiMAX: RS(255, 239).
  • CDN / 物理层: EDAC -> SRAM ECC: ECC server RAM typical 8 byte (symbols) Hamming code for 64-bit data word; not RS.

九、工程 example

# Encode a string with RS(255, 223) — 32 parity bytes per message
data = list(b"Hello, World! This is a test message.")
nsym = 32
encoded = rs_encode_msg(data, nsym)
print(len(encoded), 'code bytes total')   # 47 bytes
# simulate 3 byte corruption
import random
err_pos = random.sample(range(len(encoded)), 3)
for p in err_pos: encoded[p] ^= 200
# Decode/recover
recovered = rs_correct_msg(encoded, nsym)
assert recovered[:len(data)] == data
print("OK! Recovered:", bytes(recovered[:len(data)]))

十、性能 limit

  • RS code 接近 Shannon only at medium-to-high SNR (e.g., >4 dB). At low SNR, approaching near-Shannon.
  • coding gain 较 LDPC / turbo / polar less than modern based distance based meters. 在 short-block size RS 还 scalable lengths. → 不会无故 use modern coding.

十一、桥梁

  • crypto/asymmetric.md: GF(2^8) arithmetic components share with AES MixColumns.
  • distributed/fault/erasure.md: erasure coding to do robust storage overhead比 3-replication lower → 写 cross-reference next.
  • capacity.md prev channel coding: RS codebook vs LDPC coding gap reasoning.
  • databases/recovery/checkpoint: WAL checksums (CRC32C) JTwStrength, ReedSolomon for backup files 大 length SOCUL 替代 total SHA-256 fueling no post.

下一节 → BCH 码、循环码与多项式基础

6. BCH 码、循环码与多项式基础

TL;DR

BCH (Bose-Chaudhuri-Hocquenghem 1959) 是最强 multiple-error-correcting cyclic code 家族. Reed-Solomon is a special case of BCH (narrow-sense BCH on GF(2^m) with chosen generator out of m i roots). 优点在 binary 上比 RS 更译码有效. ECC NAND flash 控制 4KB page 内典型 BCH(4096, 4096+ ~340) = 16 bit error 页. 性能 involves finite field arithmetic cycle.


一、循环码 framework

1.1 Cyclic Code

A linear code where cyclic shift of any codeword is also a codeword. 工程上 polynomial 形式表示: $$ C(x) = c_{n-1} x^{n-1} + \ldots + c_1 x + c_0 \in GF(2)[x] / (x^n - 1) $$ shift ⊙ by $x$: $x \cdot C(x) = (c_{n-2} x^{n-1} + \ldots + c_0 x + 0) + c_{n-1} (x^n - 1) + \ldots$

→ 循环码译码 cycle polynomial形式定义 generator polynomial $g(x)$, code = multiples of $g(x)$.

1.2 Convolutional code 跟其区别

Convolutional code ≠ cyclic, is stateful streaming (shift register). Hamming / RS / BCH 是 block codes.


二、BCH generator polynomial construction

2.1 BCH 设计步骤

  1. 选 primitive element alpha of GF(2^m) where n = 2^m - 1.
  2. For each designed error-correction capability $t$:
  3. Generator $g(x) = \text{lcm}(m_1, m_2, \ldots, m_{2t})$, where $m_i$ is minimal polynomial of $\alpha^i$.

The cyclic code of length $n$ generated by $g(x)$ corrects up to $t$ bit errors.

2.2 Properties

  • $d_{\min} \geq 2t + 1$ (BCH-bound).
  • $k \geq n - m \cdot t$ (parity ≤ mt bits).

Real codes rateébec BCH(255, 207) for $t$ = 8 → 8 bit error correctable; product n - k = 48.


三、Berlekamp-Massey 算法 decode

BCH decode pipeline:

  1. Compute syndromes $S_1, S_2, \ldots, S_{2t}$ via evaluation of received polynomial at $\alpha, \alpha^2, \ldots$.
  2. Run Berlekamp-Massey to find error locator $\Lambda(x)$.
  3. Chien search for roots of $\Lambda(x)$ → error positions.
  4. Compute error magnitudes via Forney.
  5. Subtract received polynomial at error positions.

→ all in software, polynomial arithmetic on GF(2^m) basis.

3.1 Python sketch

def bch_decode(received, t, alpha_table):
    synd = [gf_poly_eval(received, alpha_table[i]) for i in range(1, 2*t+1)]
    if all(s == 0 for s in synd):
        return received, "no error"
    # Berlekamp-Massey
    Lambda = berlekamp_massey(synd)
    # Chien search
    positions = []
    for i in range(len(received)):
        if gf_poly_eval(Lambda, alpha_table_inv[i]) == 0:
            positions.append(i)
    # Forney to compute error magnitudes (RS form)
    err_poly = compute_errors(Lambda, synd, positions)
    return gf_poly_add(received, err_poly)

四、Use case examples

4.1 SSD NAND Flash

NAND flash 4 KB page each (depending cell tech MLC/TLC/QLC pages can be 16 KB upper-level:

  • BCH code can be BCH(4096+ 32, 4096+32+ ...) typical, 40 bit error correction每 page
  • More aggressive goes for QR code style works LDPC in 3D NAND (KIOXIA 存开 all 开ESS-out pipelin开 开.achiachi send pashycaly CPU t.

4.2 卫星 Communication

DVB-S2 second-gen uses BCH(8192, 6460) + LDPC outer. Together near-Shannon performance.

4.3 Disk sector

CD-ROM sectors: BCH/RS on top of BCH setups.


五、Performance

  • BCH encoding: matrix operation.
  • BCH decoding: O(n t²) with Berlekamp-Massey.
  • LDPC has linear-time decoding.
  • BCH codes give deterministic correction and are amazing for short messages (up to 1-10 KB).

六、CRC32C cyclic basic

CRC (Cyclic Redundancy Check) 是 a cyclic code subclass and is similar to BCH. 工程上:

  • It's for error detection, not correction.
  • Compute via polynomial division: $crc = D(x) \bmod g(x)$ where $g(x)$ 是 standard CRC32C polynomial.
  • CRC32C (Castagnoli) uses $0x1EDC6F41$ polynomials cloud-PI name deep-level patterns.
def crc32c(data: bytes) -> int:
    crc = 0xFFFFFFFF
    for byte in data:
        crc ^= byte << 24
        for _ in range(8):
            crc = (crc << 1) ^ 0x11EDC6F41 if crc & 0x80000000 else (crc << 1)
            crc &= 0xFFFFFFFF
    return crc ^ 0xFFFFFFFF

比较相比 checksum (简单 sum bytes): CRC32C detects typo ~ 10⁻¹⁰ predicted BIT blocks extended rows of dashhay. 用 PostgreSQL page checksum / ZFS / ext4 verity.


七、Bridges

  • reed-solomon.md prev: BCH generalizes RS.
  • ldpc.md next: for high-performance codes.
  • databases/wal-2pl.md: CRC32C page checksum 技术 跳 good subtle prevention vs ISO plant MySQL eximDLL basis of corruption bad sectors advanced.

下一节 → LDPC 码: 5G NR 数据信道

7. LDPC 码: 5G NR 数据信道与 Tanner 图

TL;DR

LDPC (Low-Density Parity-Check) 码 Galager 1960 MIT dissertation 提出, "forgotten" 多 decade, 1996 MacKay 重新发现. 现代 5G NR data channel, DVB-S2, WiFi 802.11n+ 都用 LDPC 主导. 关键优点:

  • 接近香农容量: 距离 Shannon 限 < 1 dB in long block.
  • O(n) 解码: 相比 BCH/Turbo quadratically faster at large n.
  • parallel decoding: Tanner 图 上 explicitly parallelizable across nodes.

一、原理

1.1 Low-density parity-check matrix H

n columns / m rows; 大多项元素是 0, 少数是 1; sparse. Structured 的 H:

  • 5G LDPC base graph: quasi-cyclic 加偏移 = encoding fast.
  • LDPC code C = { v ∈ F_2^n : H v^T = 0 }.

1.2 Tanner graph

二分图 split:

  • Variable nodes (V-nodes): one per code bit.
  • Check nodes (C-nodes): one per parity equation (row of H).
  • Edge between V-node i and C-node j iff H[j][i] = 1.

1.3 Girth ≥ 6

如果 graph 有 4-cycle ⇒ iterative decoding 收敛 degraded. 设计 standard require girth ≥ 6.


二、Encoding (linear time)

import numpy as np
def ldpc_encode(H, data):
    m, n = H.shape
    k = n - m
    parity = np.zeros(m, dtype=np.uint8)
    for i, bit in enumerate(data):
        if bit:
            for j in H[:, i].nonzero()[0]:
                parity[j] ^= 1
    return list(data) + list(parity)

三、Belief Propagation 解码 (Min-Sum)

工程 min-sum 简化版本:

def ldpc_decode(llr, H, max_iter=50):
    m, n = H.shape
    v2c = np.zeros_like(H, dtype=float)
    c2v = np.zeros_like(H, dtype=float)
    for j in range(n):
        for i in H[:, j].nonzero()[0]:
            v2c[i, j] = llr[j]
    for it in range(max_iter):
        for i in range(m):
            for j in H[i].nonzero()[0]:
                others = [v2c[i, j2] for j2 in H[i].nonzero()[0] if j2 != j]
                c2v[i, j] = np.sign(others[0]) * min(abs(o) for o in others)
        for j in range(n):
            for i in H[:, j].nonzero()[0]:
                v2c[i, j] = llr[j] + sum(c2v[i2, j] for i2 in H[:, j].nonzero()[0] if i2 != i)
        h = [1 if llr[j] + sum(c2v[:, j])[j] < 0 else 0 for j in range(n)]
        if all(check_ok(H, h)):
            return h
    return h

Note: 实工程师用 log-domain + numerical stabilization trick (normalized min-sum offset min-sum).

3.2 Performance characteristics

  • Stays within 0.x dB of Shannon limit for long blocks (n ≥ 10⁴).
  • Code rates tunable providing range from 1/3 to 9/10.
  • Iterations count around 10-50 in production.

四、5G NR 选择

3GPP RAN1 选 LDPC for 5G NR eMBB data channels:

  • 数据 长 block sizes 一般 8000+ bits.
  • Multi-edge type LDPC design by Qualcomm 提供 fine-grained rate matching.
  • LDPC code rate 1/5 to 8/9, robust for many SNR regimes (cell edge to center).

5G physical channel 决策 summary:

  • Turbo codes (4G) → 慢且 sub-optimal at short block.
  • Polar codes (选 control channel) → deterministic short-block advantage.
  • Convolutional codes → suboptimal long.

五、Compare LDPC vs Turbo vs Polar

维度LDPCTurboPolar
DecodingMin-sum / BPBCJR MAPSuccessive Cancellation
最优 block size长 (~10 KB)中 (≤ 8K)短 (32-2048)
距 Shannon 限~0.5 dB~0.8 dB @ 4G~0.5 dB @ short
解码复杂度O(n) parallelO(n) 串行O(n log n)
5G NR 应用data channel4G LTE onlycontrol channel (PDCCH, PBCH)

六、桥梁

  • capacity.md prev: LDPC approaches Shannon limit ⇒ coding gain.
  • reed-solomon.md prev: similar cyclic code; LDPC is much weaker for burst error → must interleave.
  • distributed/fault/erasure.md: ratch chain datacenter maintenance solution.
  • os/lock/lockfree.md: parallel SVE workers Belief propagation resembles.

下一节 → Polar 码

8. Polar 码: Arikan 2008 构造与 5G NR 控制信道

TL;DR

Arikan 2008 提出 channel polarization transformation: 将 $n$ 个相同 B-DMC 信道 transform into $n$ 个极化信道, 其中约一半变得接近无噪, 另一半 变得接近全噪. 选 "good" channel for data, "frozen" channel for fixed data. FFT-like recursive structure construction.

5G NR 选 Polar code for 控制信道 (PDCCH, PBCH, etc短 message) and 下行控制信息 (DCI): 短 packet 性能胜 LDPC.


一、Channel Polarization 直觉

1.1 两步基本 transform

给定两 identical 独立 copies $W_1, W_2$:

  • Upper channel $W^+$: input $(u_1, u_2)$, output $(y_1, y_2)$ via map $x_1 = u_1 \oplus u_2$, $x_2 = u_2$.
  • Lower channel $W^-$: input $u_1$, output $(y_1, y_2)$.

After transformation, $W^+$ 比 $W$ more capable, $W^-$ 比 $W$ less capable:

$$ I(W^+) = 2I(W) - I(W^-) $$

1.2 Recursive construction

Apply $N = 2^n$ times; 频道沿 polarized scheduled 形聚 nucleate 一 half required close to 1, volume fraction at up dynamic part → 编 low service come oy one side sub polynomial split.

1.3 Polar theorem (Arikan 2008)

For consecutive $N$ polarization transform: $$ \lim_{n\to\infty} \frac{|{i: I(W_i) \to 1}|}{N} = I(W), \quad \lim_{n\to\infty} \frac{|{i: I(W_i) \to 0}|}{N} = 1 - I(W) $$

—→ polar 定理: 一半 I 极端 close to 1 (∞ capacity), 一半 close to 0 (no capacity)。

1.4 Polar code 的构造

  • Choose "frozen" bits (set to 0): those on bad channels.
  • Send info bits via good channels.
  • Receiver knows frozen bits, helps reduce noise effect.

二、Encoder

For $N$-bit codeword 来 message $u$:

def polar_transform(u):
    """Apply Arikan butterfly transform."""
    n = len(u)
    if n == 1: return u
    u_even = u[::2]
    u_odd = u[1::2]
    y = polar_transform([a ^ b for a, b in zip(u_even, u_odd)]) + polar_transform(u_odd)
    return y

def polar_encode(info_bits, frozen_mask, N):
    """info_bits ∈ GF(2)^k, frozen_mask ∈ {T,F}^N given by construction; info is placed at 'T' positions."""
    u = [0] * N
    info_iter = iter(info_bits)
    for i in range(N):
        if frozen_mask[i] == 'F':
            u[i] = 0
        else:
            u[i] = next(info_iter)
    return polar_transform(u)

Construction of frozen set 用 density evolutionGaussian approximation (Tal-Vardy 2013): 计算每个 bit channel's Bhattacharyya parameter $Z(W_i) = \sum_y \sqrt{W(y|0) W(y|1)}$ axis good (low $Z$) 给 info.


三、Successive Cancellation Decoder

经典 decoder:

def polar_decode_sc(y, frozen_mask):
    n = len(y)
    u_hat = [0] * n
    def rec_decode(i):
        if frozen_mask[i] == 'F':
            u_hat[i] = 0
        else:
            likelihood_0 = likelihood_decoder(y, u_hat[:i])
            likelihood_1 = likelihood_decoder(y, u_hat[:i] + [1])
            u_hat[i] = 0 if likelihood_0 > likelihood_1 else 1
        return u_hat[i]
    for i in range(n):
        rec_decode(i)
    return u_hat

复杂度 O(N log N); parallelization 有限.
List decoder (SCL with CRC-assistedLista 32 实际) 在实践中给距离 short-block capacity仅 0.5 dB.


四、5G NR Polar construction details

3GPP R15 Polar code 标准 params:

  • $N = 2^n$, $n$ = 5..10 (32-1024 bit max).
  • $K$ up to 1024.
  • Frozen via 5G NR specific reliability sequence (specified in TS 38.212).

Decoder: CRC-Aided Successive Cancellation List (CA-SCL). K = const 某 用 list 32, +CRC verify final candidates. Practical production 距离香农容量 ≤ 0.5 dB at rate 1/2.


五、Polar Family variants

  • CRC-aided Polar: CRC例行; SCL candidate filter ⇒ 5G NR.
  • CA-Polar with interleaving: 跨 H 跳槽 minimum bit error dependence.
  • Polar subcode: 5G gives flex code rates via rate matching (repetition, puncturing).
  • Grouped / short Polar codes: improve spectral efficiency for very short messages.

六、与 LDPC 比较 (短 vs 长 block)

BlockPolar (SCL-32)LDPC (Min-Sum)Best
$N = 128$0.4 dB to capacity1.5 dBPolar
$N = 256$0.5 dB1.1 dBPolar
$N = 1024$0.6 dB0.5 dBLDPC
$N = 4096$0.9 dB0.4 dBLDPC

5G 因此选 Polar for short control channels (PDCCH/PBCH) and LDPC for long data (PDSCH/PUSCH).


七、桥梁

  • ldpc.md prev: long-block complementary in 5G.
  • complexity.md 在 coding theory 跟 NP 难涉不同维的介绍 code optimization polynomial issues
  • capacity.md prev: 距离香农 another soon compression capacity chasing.

下一节 → Turbo 码

9. Turbo 码: 3G/4G 并行级联卷积码与 BCJR 迭代

TL;DR

1993 Berrou-Glavieux-Thitimajshima (BGT) 在 ICC 提出 "并行级联卷积码 (Parallel Concatenated Convolutional Code, PCCC)" + iterative BCJR MAP decoder. 首次在实验上观测到 channel coding 例 ≤ 1 dB distance to Shannon limit. 此前学界相信 Shannon limit 是渐近渐近, 但工业可从慢批量也来. → Turbo code 已奥运会 cellular 3G/4G LTS dominant transmitter encoder, 5G 替代 LDPC (data) / Polar (control). 却在 deep-space modems 级涉RadWave called building CRT follow code 我's still think.


一、Construction

1.1 两个 recursive systematic convolutional codes (RSC) 并行级联

PCCC 由两个或更多个 RSC component encoder 并联: first encoder 直接操作 input $u$, second encoder 操作 interleaver 后的 input $\pi(u)$.

input u ──┬─────────────────→ nibbling → output systematic 'y1' + 'z1'
          │
          ├──── interleaver ─→ RSC2 → output parity 'z2'

总编码率 R = k/(k + 2kP) = 1/3 (典型 3G/4G 启动). 资源集 compute support puncturing提升 rate 4/5, 5/6, 9/10.

1.2 RSC component design

RSC encoder recursively computes parity: $$ y_t = (1 + D + D^2 + D^3 + D^4) / (1 + D^4) \cdot u_t $$

in 字 Language 软件程序员 convolve, 一个是 numerator 出 parity form, 一个是 denominator 出率.

5G NR LTE Turbo 使用约束 length 4 RSC.


二、BCJR decoder

2.1 Max-Log-MAP algorithm

The BCJR (Bahl-Cocke-Jelinek-Raviv 1974) algorithm computes maximum a posteriori bit probabilities using forward-backward through trellis.

def bcjr_decode(logits):
    """logits = list of soft information from channel symbols."""
    # Forward recursion compute α_k
    alpha = [0.0] * len(logits)
    # Backward recursion compute β_k
    beta = [0.0] * len(logits)
    # Edge gamma_k already from trellis
    # Compute per-bit posterior LLR: log(Priverfellow).
    likelihood_ratio = ...
    return posterior

工程实装是说 4 - 65 taps based.

2.2 Iterative decoder

1. Component decoder 1 runs BCJR → outputs extrinsic info per bit
2. Component decoder 2 takes extrinsic info as prior (interleaved), runs BCJR → outputs extrinsic info
3. Pass back as prior to decoder 1 (de-interleaved)
4. Iterate 8-20 轮
5. Hard decision after convergence

性能奇佳: 北一 4 iterations performance 5 dB de 1; 上 20 iterations → 0.8 dB from Shannon limit.


三、Performance 曲线

BER (bit error rate) vs SNR (SNR in dB) for rate 1/2 turbo with frame length N = 65536:

SNR (dB)未编码 BERTurbo BER
00.0790.001
0.50.063$10^{-5}$
0.7 (Shannon limit)$10^{-7}$
1.00.039$10^{-12}$

Sequential erro step convergence accelerate factorizing maps.


四、3G/4G 实践

3G (UMTS) 主要 turto 码, rate 1/3 by default encoder启 总的是离就 BITS 12 时 setup. 4G LTE 米 turbo 代举。decoder iterations fixed at: 8-10 typical operation.

5G 选 LDPC for data (eMBB), Polar for control 对应:

  • Turbo code's iterative decode slowness relative to LDPC.
  • 长 block 二 LDPC 高 parallel 效 channeled side; turbo mpackaging quality.
  • Complexity entropy need.

五、Interleaver

设计 选择 interleaved random or semi-patterned randomize 互矫 inv Quence-régime 否则 error correlated output re-iterate; "S-random interleaver" ensures gap ≥ S between input pairs in interleaving.

5G LTE 用 contention-free Quadratic Permutation Polynomial (QPP) interleaver.


六、Concatenated codes vs modern

  • RS + Convolutional (Voyager 范本 1977): high code gain using error rate metric, low SNR uses.
  • RS + Turbo (CDMA2000): another 30 年 sentinel.
  • LDPC + RS (DVB-S2 outer BCH inner LDPC): close to Shannon encoding, outer BCH for residual errors detection.
  • Polar-Turbo-Polar FMC 短编码: 重新 selects control.

七、Python sketch

def turbo_encode(u, encoder1, encoder2, interleaver):
    sys = list(u)
    parity1 = encoder1.run(u)
    interleaved = interleaver.interleave(u)
    parity2 = encoder2.run(interleaved)
    return sys + parity1 + parity2

def turbo_decode(received, max_iters=10):
    sys, p1, p2 = received
    extrinsic = [0.0] * len(sys)
    for _ in range(max_iters):
        prior = [sys[i] + extrinsic[i] for i in range(len(sys))]
        L1, e1 = bcjr(prior, p1)
        interleaved_e1 = interleaver.interleave(e1)
        prior2 = [sys[i] + interleaved_e1[i] for i in range(len(sys))]
        L2, e2 = bcjr(prior2, p2)
        extrinsic = interleaver.deinterleave(e2)
    # Hard decisions
    return [1 if (sys[i] + extrinsic[i]) < 0 else 0 for i in range(len(sys))]

八、与项目其他章节交叉

  • ldpc.md prev: 4G vs 5G data shift.
  • crypto/hashes.md: turbo/concatenated multi-layer on encryption upkeep基础 checksums may further augment 检 corrrenspondant capabilities.
  • capacity.md prev: turbo illustrate near-Shannon engin eering.
  • distributed/fault/erasure.md: erasure codes宽 conceing distributive correlates.

下一节 → 调制

10. 调制: QPSK / 16-QAM / 64-QAM 星座图与误码率

TL;DR

调制 (modulation) 把 bit 流映射到物理信号 (I/Q constellations of carriers, symbol rate, amplitude/phase). 实际数字与 channel SNR 决定 best 调制:

  • BPSK (1 bit/symbol): robust at low SNR.
  • QPSK (2 bit/symbol): 蜂窝/卫星/initial PHY 5G modulation phase.
  • 16-QAM (4 bit/symbol): LTE 4G mainstream.
  • 64-QAM (6 bit/symbol): 4G LTE-Advanced, WiFi 802.11n/ac.
  • 256-QAM (8 bit/symbol): WiFi 6, 5G NR centered.
  • 1024-QAM (10 bit/symbol): WiFi 7, 5G NR mmWave.

每 upgrade doubles bit/symbol, but require exponentially higher SNR. SNR 形 资管 standard到 BER 与 调制 order:\


一、星座图 (constellation)

可视化二维 plane I (in-phase) / Q (quadrature):

flowchart LR
    subgraph Q4["QPSK (4标星点 取 (±1,±1))"]
        QPSK["(1,1) | (-1,1) | (-1,-1) | (1,-1)"]
    end
    subgraph Q16["16-QAM (4×4 grid)"]
        Q16g["16 points uniformly spaced"]
    end
    subgraph Q64["64-QAM (8×8 grid)"]
        Q64g["64 points"]
    end

QAM points form 2D grid 频率 方向 equidistant, 各 antenna similar on noisy Rachel manner.16-QAM / 64-QAM constellation shaping 通常 circles ABCI ground pattern dominates (small improvements can save 1 dB).


二、SNR 与 BER

2.1 概率公式

For M-QAM, 大下列 closed form:

$$ P_b \approx \frac{4}{\log_2 M} \left(1 - \frac{1}{\sqrt{M}}\right) Q\left( \sqrt{\frac{3 \log_2 M \cdot E_b / N_0}{M - 1}} \right) $$

where $Q(x) = \frac{1}{\sqrt{2\pi}} \int_x^\infty e^{-u^2/2} du$.

2.2 实测 typical table

Modulation$E_b/N_0$ for $10^{-5}$ BER$E_b/N_0$ for $10^{-6}$ BER
BPSK9.6 dB10.5 dB
QPSK9.6 dB (same as BPSK by bit)10.5 dB
16-QAM14.4 dB16 dB
64-QAM18.9 dB19.6 dB
256-QAM24.4 dB25.1 dB
1024-QAM28.4 dB29 dB

→ Higher order modulation requires linear-in-dB escalation SNR. If you can only afford 20 dB SNR, 64-QAM max (without coding), Hill abs 七步.


三、Coding + Modulation 现代配比

3.1 Adaptive Modulation and Coding (AMC)

LTE / 5G NR dynamically choose modulation order + code rate by per UE channel quality feedback:

  • Cell center, SNR > 25 dB: 256-QAM, code rate 8/9 → ~8 Mbit/s per PRB.
  • Cell edge, SNR < 5 dB: QPSK, code rate 1/5 → ~1 Mbit/s per PRB.

The combination encodes 给MCS (Modulation and Coding Scheme). 5G has 29 MCS indexes table.

For 100 MHz bandwidth, 1200 PRB, MCS = 27 (highest):

MCSModulationCode RateSpectral Efficiency Bits/symbolThroughput at 100 MHz
0QPSK120/1024 ≈ 0.1170.234~28 Mbit/s
9QPSK613/1024 ≈ 0.5991.197~143
1316-QAM613/1024 ≈ 0.5992.394~287
1864-QAM667/1024 ≈ 0.6513.910~470
24256-QAM772/1024 ≈ 0.7546.025~723
27256-QAM948/1024 ≈ 0.9267.406~888

(These around with 100 MHz / 0.2 ms slot OR Umgang?)


四、OFDM 子载波调制

5G 上行下行都 OFDM. Each subcarrier 用 QAM modulation, 总 OFDM symbol 多复并行常 OFDM symbol with cyclic prefix (CP) 保 护robust.

import numpy as np
def ofdm_modulate_iq(iq_symbols, subcarrier_count, cp_len):
    """iq_symbols: list of complex symbols, one per subcarrier."""
    # IFFT
    baseband = np.fft.ifft(iq_symbols, subcarrier_count)
    # CP
    cp = baseband[-cp_len:]
    return np.concatenate([cp, baseband])

五、Demodulation 接收

5.1 Soft 信息

当代 decoder (LDPC, Polar, Turbo) 期望软信息 (soft information) — Posteriorerior posterior LLR per bit, 不要硬决策.

QAM demodulator computes $\mathrm{Pr}(x_i = 0 | y)$ via closest Euclidean 含 protective, inclusive nearest candidate distance.

LLR = soft probabilistic association by signal/noise estimation拍 case is the 点互 距离.

5.2 Channel estimation

5G / WiFi 解调 增搭 pilot 符号 跨 subcarrier 估 相关 distribution. Channel estimate derives 周围 pilot.at end 统 计. Master tune desirable leaf 但 not complete list channel estimate to multipath resolved.


六、Modern advances (beyond vanilla QAM)

  • Non-uniform constellations / shaping: Properties as probabilistic shaping (PAS) → lean constellation bits to exceed uniform QAM 度. Stochastic effective sh in 5G NR TV挑战 cycles. 已支援.5 Grass service DVB-S2X include.
  • Pilot-based MIMO-OFDM: 4x4 Spatial streams encoding give 兜 4× throughput → 5G NR peak Mbit/s.
  • GFDM / UFMC: 多 generalized schemes but commercialization 同 OFDM 畳 cocoa low while still clarity07 processing complexity sits still.
  • Spectral shaping due to MIMO WCS: 方弱 channel QAPO / pole miniopath modulation -> Hybrid Beam.

七、Modulation tradeoff / Capacity relationships

总 capacity = sum subcarrier B each × log(1+SNR), 工程师 inside pick QAM modulation order ∝ $\log(1+\text{SNR})$ of subcarrier SNR. With outer coding (LDPC) yielding close to Shannon.

→ 在 5G NR 上面具 noise 业 范数趋 source 动态 自可面 splitting 理想 OSCAR PARTITION (per subcarrier SNR and $M$-QAM).


八、Bridges

  • capacity.md prev: 香农容量 medi转cano direct SNR single subcarrier allocate adapt aggregate. Theoretical capacity single = optimal.
  • ldpc.md prev: LDPC = 70 NEAR Shannon: prove distance gain decode softinfo output. Demodulation → soft info → LDPC → BER ≤ 10⁻⁶.
  • crypto/asymmetric.md: world wireless mitigation exploited really 尾怪 B资 rash for QAM with nonlinear amplifier spa & advanced modulation3.5dB.
  • distributed/clock/dag: synchronous chain-Sublinks timing interactions can pull-off linear 项目 lon punish with deterministic modulation sch MIMO spread.

下一节 → 附录

附录: 编码率 / 纠错能力 / SNR 实践速查

A.1 — Code families

码族n / kmin distance纠错能力编码率解码复杂度
Hamming (7,4)7/431 bit4/7 ≈ 0.571O(n)
Hamming SECDED (8,4)8/441 bit 纠 / 2 bit 检4/8 = 0.5O(n)
Repetition (5,1)5/152 bit1/5 = 0.2O(n) trivial
RS(255, 223)255/223$2t+1$16 symbol error / 32 erasure0.875O(n t²)
BCH(255, 207)255/207≥178 bit error0.812O(n t²)
LDPC QC (8448)6688 datavariednear-capacity at SNR0.5 to 0.9O(n) BP iters
Polar (1024)K (32-1024)varies≤ 0.5 dB Shannon dist0.1 to 1/2O(n log n) SCL
Turbo PCCC (LTE)6144/2048varies1 dB Shannon dist1/3 base; punctured 0.9O(n) iters
Convolutional (K=7, rate 1/2)streamingvaries4-5 dB coding gain1/2 typicalO(n) Viterbi
CRC32Cdetect onlyoverhead 4 bytes / 4 KBO(n)
Hamming ECC DRAM72/6441 err 纠 / 2 err 检0.889O(n)

A.2 — Channel coding choices

Use caseCodeNotes
DRAM ECCSECDED Hamming (72,64)system memory universal
CD-ROMCIRC RS(32,28) + RS(28,24)interleaved robustness burst errors
DVDRS-PC RS(208,192) + RS(182,172)product code
QR CodeRS(40,26) baseline version 系列shortened per block
DSLRS(255,239)string Independent ITU
Wi-FiLDPC (802.11n/ac/ax/be) + Convolutional legacy11n+ LDPC
4G LTE control咬 bite-rate pre QPP interleaver + Turbo范本 3G/4G
5G NR controlPolar + CRCshort code polar decoders
5G NR dataLDPC + CRC块数据充
Satellite DSNRS(255,223) + Convolutional (rate 1/2, K=7)Voyager / Cassini / Galileo
DVB-S2 (satellite TV)BCH(8192,6460) + LDPC(64800,43200)near-Shannon + 双 outer protection
WiMAXRS + Convolutionalold wireless broadband
Storage RAID 6RS over GF(2^8)redundancy dual disk failure
ZFS raidz33 parity (braided RS/TP)triple disk failure

A.3 — Modulation and per bit-symbol efficiency

ModulationBits/symbolMandatory SNR @ BER 10⁻⁵ (uncoded)SNR with code
BPSK19.6 dB~2 dB w/ LDPC
QPSK29.6 dB~2 dB w/ LDPC
8-PSK314 dB~6 dB w/ LDPC
16-QAM414.4 dB~6.5 dB w/ LDPC
64-QAM618.9 dB~10 dB
256-QAM824.4 dB~16 dB
1024-QAM1028.4 dB~20 dB

→ 低 SNR 区 域 BPSK/QPSK 稳健. 64-QAM 之上须 cell edge / hot beam 同 hind sing fine structure coding with shaping or shaping.

A.4 — Shannon limit reference table

BandwidthSNRCapacity (single stream)5G modeled peak (MIMO)
20 MHz10 dB70 Mbit/s100 (LTE Cat 4), 700 (LTE LAA)
100 MHz10 dB346 Mbit/s700-1200 Mbit/s 4×4 MIMO
100 MHz20 dB665 Mbit/s1500
400 MHz20 dB2663 Mbit/s4500 mmWave MIMO
1 GHz (mmWave)20 dB6.65 Gbit/s8-10 Gbit/s peak single phone
2 GHz (mmWave)30 dB13 Gbit/s14-20 Gbit/s (MIMO 16 layer)

行业 statistic 范本工 peak 5G 数达到 close to Shannon (35% under face Survey practical due to implementation loss).

A.5 — Coding gain hierarchy

最近 Shannon 极限 ≈ lower = 0:

  1. Polar codes (SCL): ~0.5 dB at short lengths (≤ 1K)
  2. LDPC: ~0.5 dB at long lengths (≥ 10K)
  3. Turbo codes: ~0.8 dB at moderate lengths (1K-10K)
  4. RS+Conv. (Voyager): ~2-3 dB at rate 0.5
  5. BCH+RS (DVB-S2 outer BCH): ~2-4 dB hybrid concatenated
  6. Hamming (7,4): ~3 dB at cost of rate 4/7 overhead
  7. Repetition codes: linear rate half 何 巨 后成

A.6 — Practical implementation references

  • Software: turbo decode libturbo, LDPC gr-ldpc openairinterface5g, Polar nrPolar decoder in OAI 5G PHY.
  • Hardware accelerators: ASIC 5G Qualcomm X55-coded coding modules production polar/LDPC throughput 三code gigabaud per second.
  • Field test tools: srsRAN opensource 5G NR base+ue prototype.
  • Wireshark 调制 demod probes: Wi-Fi decoders show QAM constellation charts in real time as part of QAMVisualizer.

A.7 — 与项目其他章节交叉

  • complexity.md / factoriz-ation: code distance metric leaves distance encoded trade-off among code rates.
  • crypto/zkp.md: probabilistic checkable proofs 在 encoding castle erasure code moral distance 不可通似 proof verify independent paths.
  • distributed/fault/erasure.md: erasure code 是 RS-based 内 correlated system communityedition 然 ML-KEM Kyber formal blowing codes.
  • databases/olap/lakehouse.md: columns compression with zstd codecs should integrate with erasure code separate file..
  • crypto/signatures.md: ECC DR BG agents 高 needs must frequently compatible frequency for studio bit modes.

第十二部分 · 人工智能与机器学习

一句话

机器学习是一类用数据替换显式规则的程序写法: 当规则写不出来、但能定义"做对/做错"时, 让数学优化自动从样本里把函数拟合出来. 70 年路线从线性回归 (Legendre 1805 / Gauss 1809) 演到 MLP (1986)反向传播自动求导 (1970/1986)CNN/RNN (1998/1997)Attention/Transformer (2017)扩散模型 (2020)RLHF 对齐 (2022). 我们抽主干 5 章把它一气讲通, 全部数学已在 第零部分 准备好, 本部分直接引用.

思想链

[一堆 (sqft, price) 房价数据]
  └─> 最小二乘线性回归 (Legendre/Gauss): y = X β, 解 β = (XᵀX)⁻¹Xᵀy
        └─> 加非线性可表达性 → MLP: ŷ = W₂ σ(W₁ x + b₁) + b₂
              └─> 怎么训? → 反向传播 = 链式法则 × 计算图 (1970 Linnainmaa, 1986 Rumelhart)
                    └─> 怎么算众多样本有效? → SGD + mini-batch + Adam (2014)
                          └─> 序列怎么处理? → Transformer (2017): softmax(QKᵀ/√d) V
                                └─> 生成式怎么学? → VAE & ELBO (2013) / 扩散模型 (2020)
                                      └─> 与人类偏好对齐? → RLHF (2022): PPO 约束下的 policy gradient
                                            └─> 数学尽头都是四个: 线代 / 概率 / 链式法则 / 凸优化 → 全在第零部分

每一步出现的数学都能在线代、概率、微积分四篇里找到对应小节, 这就是为何把数学前置.

你将带走什么

读完应能:

  1. 看到任意 ML 损失函数 $\mathcal{L}(\theta)$ 能立刻判断它属于哪个家族 (回归 / 分类 / 排序 / 对比 / 生成), 知道对应输出层激活 (identity / sigmoid / softmax) 与损失 (MSE / CE / contrastive / ELBO).
  2. 给一个网络能徒手画出计算图并标每个节点的局部雅可比, 用反向模式 AD 算出参数梯度; 用数值差分校验自己的解析梯度.
  3. 看到 Attention(Q, K, V) = softmax(QKᵀ/√d)V 能立刻在脑子里展开它的张量形状、forward/backward 的 FLOPs 与显存, 知道为什么除 $\sqrt{d_k}$, 为什么 MHA 比单头更稳.
  4. 看到 Adam → AdamW + cosine warmup 知道为何 warmup 防止初期方差爆炸, 为何 Adam 比 SGD 在Transformer 上更稳, 为何 Hessian 是 Adam 的隐式近似.
  5. 看到 VAE 的 $\mathcal{L} = \mathbb{E}_q[\log p(x|z)] - \mathrm{KL}(q(z|x) | p(z))$ 知道它是 reverse-KL 的最大化, 看到 diffusion 的 $\mathcal{L}_t = \mathbb{E}q| \epsilon - \epsilon\theta(x_t, t)|^2$ 知道它是 ELBO 的等价化简.
  6. 读 "Attention Is All You Need" / "Denoising Diffusion Probabilistic Models" / Adam 原 paper 不再卡在数学记号处.

章节

与各部分的接口表

接口部分提供什么本部分怎么用
第零部分 · 线代向量 / 矩阵 / 张量 / softmax 雅可比 / SVDMLP/attention 的 forward 与 backward 表达, shape 推导
第零部分 · 概率MLE / MAP / 贝叶斯 / KL / 共轭先验MLE = 大多数 ML 损失的本质; VAE / 扩散用 reverse-KL
第零部分 · 微积分与优化链式法则 / 雅可比 / Hessian / 凸优化 / 一阶优化器谱系 / 信息几何反向传播; Adam/AdamW/二阶; 自然梯度 TRPO
第八部分 · GPU/AI 加速器Tensor Core / SM / bf16 / NVLink大模型训练的 FLOPs / 显存估算与并行
第十一部分 · 信息论熵 / 互信息 / KL交叉熵损失; VAE ELBO; 扩散 ELBO 等价化简
第九部分 · 计算理论P / NP 与不可判定学习理论的 PAC 框架与不可学性问题

不在本部分讲什么

  • 传统 ML 的剩下半壁 (k-means / PCA / 聚类与降维): 已在 §6 树模型与 SVM 之外的部分仍按需自学; 聚类与降维与深度表示学习(§10)在目标上同构, 数学基础 (MLE/MAP, KKT, SVD) 都在第零部分.
  • 形式化方法 / 程序验证: Coq / Lean / TLA+ 已独立成卷, 见 形式化方法卷.
  • 量子计算: Deutsch–Jozsa / Shor / 量子纠错已独立成卷, 见 量子计算卷.

历史 1: 1957 - 1986 三起三落

  • 1957 Rosenblatt perceptron: 第一个可学习的二分类器; 但只能学线性可分数据, 1969 Minsky-Papert 一书指其不能学 XOR, 神经网络跌入第一次寒冬.
  • 1986 Rumelhart-Hinton-Williams 反向传播重新发现并推广: 解决了多层训练问题, XOR 不再是问题. 同期 Linnainmaa 1970 在芬兰硕士论文里已写出反向 AD, 但论文未广泛传播.
  • 1998 LeCun LeNet 卷积网络做手写数字; 1997 Hochreiter LSTM 解决长序列; 2006 Hinton "deep belief net" 让"deep learning"成为术语; 2012 AlexNet ImageNet 一举破纪录, 深度学习时代起.

历史 2: 2017 Transformer 一统江湖

2017 Vaswani et al. "Attention Is All You Need": 抛弃 RNN/CNN 用纯 attention 做序列建模, 关键好处:

  1. 并行: 整个序列同时算, 不需 RNN 时间步串联 → GPU 友好.
  2. 长程依赖: 任意两 token 直接 attention, 不必经过隐藏状态传递.
  3. scaling law: 参数 / 数据 / 算力 ↔ 性能呈 power-law (Hoffmann 2022 Chinchilla), 推动大模型时代.

衍生路线: BERT (2018 编码器派) / GPT (2018 解码器派) / ViT (2020 视觉) / DALL-E (2021 多模态) / ChatGPT (2022 RLHF 对齐) / diffusion (2020-2022) / LLaMA / Qwen / DeepSeek 系列 (2023+) → 主流基础模型几乎全是 Transformer.

阅读路径建议

对 ML 完全陌生:           §1 → §2 → §3 → §4 → §5 → §6
只想懂 Transformer:       第零部分线代 §6 → §2 → §3
只想懂训练工程:            §4 → §9 (并行/显存) → §13 (推理)
只想懂生成模型:            第零部分概率 §5/§7 → §5
准备读 RLHF / 对齐:        §3 → §8 (RLHF/DPO/GRPO)
想读懂开源模型 config:      §7 (tokenizer) → §8 → §9
表格数据建模:               §1 → §6 (决策树/GBDT/XGBoost/SVM)

下一篇: 1. Foundations: 线性回归 → 逻辑回归 → MLP → 损失函数谱 → 泛化与正则.

1. Foundations: 线性回归 → 逻辑回归 → MLP → 损失函数谱 → 泛化与正则

TL;DR

机器学习的入口几乎都是最小二乘线性回归的某种变体: 从把输出层加个 sigmoid 就成二分类, 把单层 perceptron 堆成多层 → 多层感知机 (MLP), 把损失从 MSE 换成 CE / contrastive / ELBO 就成各种生成模型. 这一章把这个家族谱铺好:

  1. 线性回归 — 最小二乘的闭式解; 几何 = 投影; 概率 = Gaussian MLE.
  2. 逻辑回归 — sigmoid + 交叉熵 = Bernoulli MLE; 凸.
  3. MLP — 加一层非线性就够表达任意函数; 迭代优化替代闭式解.
  4. 损失函数谱 — 回归 / 分类 / 排序 / 对比 / 生成 各家损失都是 MLE 的不同形式.
  5. 泛化与正则 — 经验风险 vs 真实风险, 偏差-方差, L1/L2/dropout.

读完应能: 给一个任务 (分类 / 回归 / 排序 / 生成) 立刻报出对应的输出激活、损失家族、对应概率假设, 并知道该不该上正则、上哪种.


一、线性回归: 一切的起点

1.1 问题与最小二乘

给定训练集 ${(x_i, y_i)}_{i=1}^n$, $x_i \in \mathbb{R}^d, y_i \in \mathbb{R}$. 模型 $\hat y_i = \boldsymbol \beta^\top x_i$. 损失为残差平方和:

$$ \mathcal{L}(\boldsymbol \beta) = \sum_i (y_i - \boldsymbol\beta^\top x_i)^2 = |\boldsymbol y - X \boldsymbol\beta|_2^2 $$

其中 $X \in \mathbb{R}^{n \times d}$ 行为样本. 令梯度为 0:

$$ \nabla_\beta \mathcal{L} = -2 X^\top (\boldsymbol y - X \boldsymbol\beta) \overset{!}{=} 0 ;\Rightarrow; X^\top X ,\boldsymbol\beta = X^\top \boldsymbol y $$

正则方程: $\boldsymbol\beta^* = (X^\top X)^{-1} X^\top \boldsymbol y$ (列满秩时).

import numpy as np

def ols(X, y):
    # 闭式最小二乘 (注意数值稳定性: 推荐 lstsq 而不是直接求逆)
    return np.linalg.lstsq(X, y, rcond=None)[0]

# 等价的几何视角: 残差正交于 X 的列空间
X = np.random.randn(100, 3); beta_true = np.array([1.0, -2.0, 0.5])
y = X @ beta_true + 0.1 * np.random.randn(100)
beta_hat = ols(X, y)
residual = y - X @ beta_hat
assert np.allclose(X.T @ residual, 0, atol=1e-10)  # 残差 ⊥ 列空间

1.2 三种等价视角

视角内容
几何$\hat{\boldsymbol y} = X \boldsymbol\beta^* = P_X \boldsymbol y$, $P_X = X(X^\top X)^{-1}X^\top$ 是列空间正交投影; 残差 $\perp$ 列空间
概率 (MLE)假设 $y_i = \boldsymbol\beta^\top x_i + \epsilon_i$, $\epsilon_i \sim \mathcal{N}(0, \sigma^2)$. 似然 $\prod \mathcal{N}(y_i; \boldsymbol\beta^\top x_i, \sigma^2)$; 取 log 最大化 ⇔ 最小化残差平方和
贝叶斯加先验 $\boldsymbol\beta \sim \mathcal{N}(0, \tau^2 I)$ ⇒ MAP = ridge 回归 $\arg\min |\boldsymbol y - X\boldsymbol\beta|^2 + \frac{\sigma^2}{\tau^2}|\boldsymbol\beta|^2$

note

"为什么不直接 $X^{-1} y$?" 因为 $X$ 几乎从不是方阵; $X^\top X$ 在列满秩时才是 $d \times d$ 可逆. 这条公式把"$n \neq d$"的方程组变成正则方程, 也是投影矩阵的根基. 回顾线代 §1.3 四个子空间: 残差必须落在 $X$ 的左零空间中.

1.3 概率视角的延伸

如果残差不服从 Gaussian 而服从 Laplace $\frac{1}{2b}\exp(-|e|/b)$, MLE 推出的是绝对值损失 (LAD):

$$ \mathcal{L}_{\text{LAD}}(\boldsymbol\beta) = \sum_i |y_i - \boldsymbol\beta^\top x_i| $$

这就是"为什么 LAD 比 OLS 鲁棒": Laplace 先验有更长尾, 拟合时不太被异常点拉走.

残差分布MLE 损失鲁棒性
$\mathcal{N}(0, \sigma^2)$平方 $|\cdot|^2$对离群敏感
Laplace绝对 $\cdot
混合 (Huber)$\rho_c(r) = \begin{cases} \frac12 r^2, &r

1.4 正则化: ridge / lasso / elastic net

加先验等价正则:

  • Ridge ($L_2$): $\mathcal{L} = |y - X\boldsymbol\beta|^2 + \lambda |\boldsymbol\beta|_2^2$ ⇒ Gaussian 先验; 解 $\boldsymbol\beta^* = (X^\top X + \lambda I)^{-1} X^\top y$ 永远存在 (即使 $X^\top X$ 奇异).
  • LASSO ($L_1$): $\mathcal{L} = |y - X\boldsymbol\beta|^2 + \lambda |\boldsymbol\beta|_1$ ⇒ Laplacian 先验; 产生稀疏解 (许多 $\beta_i = 0$).
  • Elastic net: $L_2 + L_1$ 加权混合, 高度相关特征群组选择.

tip

$L_1$ 比 $L_2$ 更稀疏的几何直觉: 在等高线接触约束区域时, $L_1$ 球的"尖角" (坐标轴) 比光滑 $L_2$ 球更易触到. 严格的凸优化解读见第零部分 §4.2 凸函数与 KKT.


二、逻辑回归: 从回归到分类

2.1 二分类的 MLE

设 $\Pr(y=1 | x) = \sigma(\boldsymbol\beta^\top x)$, $\sigma(a) = \frac{1}{1+e^{-a}}$. 数据似然:

$$ \prod_i \sigma(\boldsymbol\beta^\top x_i)^{y_i} (1 - \sigma(\boldsymbol\beta^\top x_i))^{1-y_i} $$

取 $-\log$ 得二元交叉熵:

$$ \mathcal{L}_{\text{logistic}}(\boldsymbol\beta) = -\sum_i \big[ y_i \log \hat p_i + (1 - y_i) \log(1 - \hat p_i) \big], \quad \hat p_i = \sigma(\boldsymbol\beta^\top x_i) $$

2.2 关键性质

  • $\sigma'(a) = \sigma(a)(1 - \sigma(a))$ → 反向传播极简: $\partial \mathcal{L}/\partial a_i = \hat p_i - y_i$.
  • 凸函数 (Hessian 半正定), 但非强凸 (当数据完全可分时 $|\boldsymbol\beta| \to \infty$ 收敛), 这时需要正则.
  • 极大熵角度: 在 $\mathbb{E}[y | x] = \sigma(\boldsymbol\beta^\top x)$ 约束下, Bernoulli 极大熵分布 = logistic; 见第零部分 §4.7.

2.3 多分类: softmax + 交叉熵

$K$ 类: $\Pr(y = k | x) = \mathrm{softmax}(W^\top x)_k = \frac{e^{w_k^\top x}}{\sum_l e^{w_l^\top x}}$.

$$ \mathcal{L}{\text{CE}}(W) = -\sum_i \log \frac{e^{w{y_i}^\top x_i}}{\sum_l e^{w_l^\top x_i}} $$

反向梯度 (回顾第零部分 §2.3 softmax 雅可比): $\partial \mathcal{L} / \partial a_l = \mathrm{softmax}(a)_l - \mathbb{1}[y = l]$. 一行公式就是 logistic 二元版, 也是 Transformer LM head 的损失.

2.4 multinomial 与共轭先验

  • 似然 multinomial + 共轭先验 Dirichlet ⇒ 后验仍是 Dirichlet. 这就是 LDA 主题模型与 transformer 词频建模的根基. 见第零部分概率 §4.4 共轭先验速查.

三、MLP: 单层到多层

3.1 XOR 问题: 单层 perceptron 学不动

$x_1$$x_2$$y$
000
011
101
110

直线 $\boldsymbol\beta^\top x + b = 0$ 无法分对角点; 1969 Minsky-Papert 一书指出, 推动感知机第一次寒冬.

3.2 多层感知机 (MLP)

两层 + 非线性激活:

$$ f(x) = W_2 ,\sigma(W_1 x + \boldsymbol b_1) + \boldsymbol b_2 $$

  • $W_1 \in \mathbb{R}^{h \times d}$, $\sigma$ 逐元素非线性, $W_2 \in \mathbb{R}^{k \times h}$.
  • $h$ 充分大 ⇒ 万能近似器 (Cybenko 1989 for sigmoid, Hornik 1991 一般化).
  • 训练需要反向传播, 这是下一章主题.

3.3 激活函数谱

激活公式反传导数特点
Sigmoid$\frac{1}{1+e^{-x}}$$\sigma(1-\sigma)$输出 [0,1]; 饱和时梯度消失, 早期流行, 现主要用作门 / 输出
Tanh$\tanh x$$1 - \tanh^2 x$输出 [-1, 1]; 仍饱和, RNN 经典
ReLU (2010 Nair-Hinton)$\max(0, x)$$\mathbb{1}_{x > 0}$不饱和, 计算快; 但负输入"死神经元"
Leaky ReLU / PReLU$\max(\alpha x, x)$$\alpha$ or $1$防 dead neuron
GELU (2016)$x \cdot \Phi(x)$$\Phi(x) + x \phi(x)$Transformer 默认; 平滑近 ReLU
SwiGLU (2022)$\mathrm{SwiGLU}(a, b) = \mathrm{Swish}(a) \odot b$LLaMA / PaLM 用, FFN 替代品
SiLU / Swish$x \sigma(x)$$\sigma(x)(1 + x(1 - \sigma(x)))$自门控; 在深处稳定

warning

为什么不用 $\sigma$ / $\tanh$ 当 MLP 的激活, 但 Transformer 输出层却常用 $\sigma$ / $\softmax$? 激活层堆多了梯度会消失 (反向连乘 $\sigma'(a_i) \leq 0.25$); 输出层只有一层, 且需要可解释的概率, 故保留 sigmoid/softmax. 内部一律用 ReLU/GELU/SwiGLU 防止 vanishing gradient.

3.4 逼近定理的工程含义

通用近似定理只是"存在性", 不教你"怎么找到那个权重". 深网比浅网指数级更省参数表达某些函数 (Telgarsky 2016): 把 $\mathcal{O}(2^k)$ 个 oscillation 拟合, 深度 $k$ 网用 $\mathcal{O}(k)$ 个参数, 浅网用 $\mathcal{O}(2^k)$. → "为什么深而不是宽"的数学根.


四、损失函数谱: 一张总表

绝大多数损失都是"输出激活 + 类似然负对数"的二元组合:

任务输出激活损失概率假设备注
回归identityMSE $|y - \hat y|^2$Gaussian工程默认
鲁棒回归identityHuber / L1Laplace / Huber异常值
二分类sigmoidBCE $-[y\log\hat p + (1-y)\log(1-\hat p)]$BernoulliLR / 二元分类
多分类softmaxCE $-\log\hat p_y$MultinomialNLP / 图像分类
多标签sigmoid 每维BCE sum多 Bernoulli标签独立
排序(隐) pairwiseBPR / hinge / LambdaRank各种推荐 / 检索
对比学习L2 norm + 内积InfoNCE $-\log\frac{e^{s^+/\tau}}{\sum e^{s_i/\tau}}$softmax over negativesSimCLR / CLIP
生成 (AR)softmaxnext-token CEMultinomial 自回归GPT 系列
生成 (VAE)decoder 输出 + reparam.$-\mathbb{E}_q[\log p(x|z)] + \mathrm{KL}(q|p)$ELBO 最大化见 §5
生成 (diffusion)预测噪声 $\epsilon$$|\epsilon - \epsilon_\theta(x_t, t)|^2$Gaussian ELBO 简化见 §5

note

所有这些损失都是MLE 在不同假设上的负对数似然. 训练 = 最小化 NLL = 最大化数据似然. 第零部分概率 §5 框架解释了为什么.

4.1 对比损失详解 (SimCLR / CLIP)

$$ \mathcal{L} = -\log \frac{\exp(\mathrm{sim}(z_i, z_i^+)/\tau)}{\sum_{j} \exp(\mathrm{sim}(z_i, z_j)/\tau)} $$

其中 $z_i^+$ 是 anchor 正样本, $z_j$ 是 batch 内其它 (负) 样本, $\mathrm{sim} = \cos$. 这本质就是 softmax+CE, "类" = "正样本对 vs 负样本对".

→ 一行话打通 representation learning & classification.

4.2 排序损失: BPR 与 LambdaRank

BPR (Bayesian Personalized Ranking): 给正样本 $i$ 与负样本 $j$, 模型打分 $s_u(i), s_u(j)$, 损失

$$ \mathcal{L}_{\text{BPR}} = -\log \sigma(s_u(i) - s_u(j)) $$

→ 让正样本打分恒高于负样本.

LambdaRank 进一步在梯度上乘 $\Delta \text{NDCG}$ 让"高位置错位惩罚更重", 工业推荐系统标准.


五、泛化与正则

5.1 经验风险 vs 真实风险

设训练集 $\mathcal{D} = {(x_i, y_i)}{i=1}^n$ 来自分布 $\mathcal{P}$. 经验风险 $\hat R(\theta) = \frac{1}{n}\sum \ell(f\theta(x_i), y_i)$, 真实风险 $R(\theta) = \mathbb{E}{(x,y)\sim\mathcal{P}}[\ell(f\theta(x), y)]$.

学习目标是最小化 $R$, 但只能观测 $\hat R$. 泛化 gap $|R - \hat R|$ 由 Hoeffding + Rademacher complexity 控制 (回顾第零部分概率 §6.3):

$$ R(\theta) \leq \hat R(\theta) + \mathcal{O}\left(\sqrt{\frac{\mathrm{complexity}}{n}}\right) $$

note

这一条解释了: 模型复杂度太高 / 训练数据太少 → 估计 gap 大 → train loss 低但 test loss 高 = 过拟合. 实践手段: 增数据 / 降复杂度 / 加正则 / dropout / 早停.

5.2 偏差-方差分解

平方损失下:

$$ \mathbb{E}[(y - \hat f(x))^2] = \underbrace{(\mathbb{E}[\hat f] - f)^2}{\text{偏差}^2} + \underbrace{\operatorname{Var}(\hat f)}{\text{方差}} + \underbrace{\sigma^2}_{\text{噪声}} $$

  • 模型太弱 → 高偏差 (欠拟合).
  • 模型太强 + 数据少 → 高方差 (过拟合).
  • 同一容量下 ensemble (随机森林 / dropout) 减方差而不增偏差 → 为什么 ensemble 与 dropout 有效的根源.

5.3 正则手段总表

手段形式数学依据
$L_2$ weight decay$\lambda |\theta|_2^2$ 加在 lossGaussian 先验 (MAP = ridge)
$L_1$$\lambda |\theta|_1$Laplacian 先验; 稀疏
Early stopping训到 val loss 上升就停等价 implicit $L_2$ 正则 (Sjöberg 1995)
Dropout (2014 Srivastava)训练时随机零化 $p$ 比例神经元等价 ensemble over 子网络; 防共适应
Batch Norm (2015 Ioffe)每层激活按 batch 归一化减小 internal covariate shift + 隐式正则
Layer Norm (2016 Ba)沿特征维归一化Transformer 默认, batch 无关
Data augmentation变换扩样等价 inject 先验 ("图像可平移翻转")
Label smoothingone-hot 改 $1-\epsilon$ / 各 $\epsilon/(K-1)$防过度自信; 改善 calibration
Weight averaging (SWA, EMA)训后/训中平均权重减方差
Distillation (Hinton 2015)soft target 来自 teacher集成思想 + 隐式标签平滑

5.4 双下降 (double descent, Belkin 2019)

经典偏差-方差 U 形是单一峰; 实际深度学习在过参数化时, "test 风险继续下降":

risk
  ↑
  │  ╲              ╱── over-parameterized → ↓
  │   ╲            ╱
  │    ╲      interp╱
  │     ╲     ●   ╱
  │      ╲   ╱│╲ ╱
  │       ╲ ╱ │ ╳
  │        ●  │ ╳──   (second descent)
  │            │
  └─────────────→ model capacity
   underfit  interp  over-param

数学解释: 当参数 $N \gg$ 样本 $n$ 时, 训练误差可达 0, 但在"最小 $L_2$ 范数解"附近的 implicit bias 让泛化仍良. 现代大模型在 right side of interp 训练.

tip

"Double descent" 解释了: 为什么 GPT-3 175B 不能用经典 U-shape 偏差方差解释, 但训练后实际很泛化. 大模型这件事不是真理错误, 而是用错了上界.

5.5 PAC 学习一瞥

Probably Approximately Correct (Valiant 1984): 一类 $\mathcal{H}$ PAC-可学 iff 存在算法 $A$, 多项式样本与时间, 以 $\geq 1 - \delta$ 概率输出 $h \in \mathcal{H}$ 使 $R(h) \leq \epsilon + \min_h R(h)$, 任给 $\epsilon, \delta$.

→ 把"学得动与否"形式化: 它不是"能否拟合", 而是"用什么样本复杂度能可证泛化".

复杂度上限样本数 (回 Hoeffding 第零部分 §6.3): $n \geq \frac{1}{2\epsilon^2}\log(2|\mathcal{H}|/\delta)$.

→ 直觉给了"快速高方差模型训练"应该重看 validation 的依据.


六、最小可跑的回归 + 分类 pipeline (NumPy only)

import numpy as np

def sigmoid(z):
    return 1 / (1 + np.exp(-z))

def logreg_train(X, y, lr=0.1, epochs=500):
    n, d = X.shape
    W = np.zeros(d); b = 0.0
    for _ in range(epochs):
        p = sigmoid(X @ W + b)
        grad_W = X.T @ (p - y) / n   # 解析梯度: σ - y
        grad_b = (p - y).mean()
        W -= lr * grad_W; b -= lr * grad_b
    return W, b

def logreg_predict(X, W, b):
    return (sigmoid(X @ W + b) > 0.5).astype(int)

if __name__ == "__main__":
    from sklearn.datasets import make_moons
    X, y = make_moons(200, noise=0.1, random_state=0)
    W, b = logreg_train(X, y)
    acc = (logreg_predict(X, W, b) == y).mean()
    print(f"acc = {acc:.3f}")   # moons 非线性可分, 线性逻辑回归只能到 ~0.83
    # 升到 MLP + tanh 就能拟合月牙
// 浏览器/Node 上的 toy logistic regression
export function sigmoid(z: number): number { return 1 / (1 + Math.exp(-z)); }

export function logregTrain(
  X: number[][], y: number[],
  lr = 0.1, epochs = 500,
): { W: number[]; b: number } {
  const n = X.length, d = X[0].length;
  const W = new Array(d).fill(0); let b = 0;
  for (let e = 0; e < epochs; e++) {
    const gW = new Array(d).fill(0); let gb = 0;
    for (let i = 0; i < n; i++) {
      let s = b; for (let k = 0; k < d; k++) s += X[i][k] * W[k];
      const p = sigmoid(s); const r = p - y[i]; gb += r;
      for (let k = 0; k < d; k++) gW[k] += X[i][k] * r;
    }
    for (let k = 0; k < d; k++) W[k] -= lr * gW[k] / n;
    b -= lr * gb / n;
  }
  return { W, b };
}

七、结束 + 速查表

tip

一页快速唤回:

  • 线性回归: $\beta^* = (X^\top X)^{-1} X^\top y$; 几何 = 投影; 概率 = Gaussian MLE.
  • 加 Gaussian 先验 → ridge; Laplacian → LASSO.
  • 逻辑回归: $\hat p = \sigma(\beta^\top x)$; 损失 BCE; 反向梯度 $\hat p - y$.
  • 多类 softmax + CE: 反向梯度 $s - \text{onehot}(y)$.
  • MLP: $W_2 \sigma(W_1 x + b_1) + b_2$; 万能近似 (但需训), 深比宽省参.
  • 激活: 内部用 ReLU/GELU/SwiGLU (防饱和), 输出用 sigmoid/softmax (要概率).
  • 损失即 NLL: 回归 MSE=Gaussian, 二元 BCE=Bernoulli, 多类 CE=Multinomial, 对比 NCE=softmax-negative, 生成 ELBO/扩散 noise-pred 都是.
  • 正则: $L_2$ (ridge, 防过拟合, 平滑), $L_1$ (LASSO, 稀疏), Dropout (隐 ensemble), early stopping (隐 $L_2$), batch/layer norm (稳 + 隐正则).
  • 泛化: $R \leq \hat R + \sqrt{\text{complexity}/n}$; double descent 解释过参模型仍泛化.
  • PAC: 多项式样本可证 $\epsilon$-optimal = "可学".

下一篇: 2. Backpropagation: 计算图 / 反向模式 AD / 雅可比链式 / 梯度检查.

2. Backpropagation: 计算图 / 反向模式 AD / 雅可比链式 / 梯度检查

TL;DR

反向传播不是一种"特殊算法", 它是多变量链式法则在计算图上沿拓扑逆序执行的形式, 配上"中间结果缓存"就成 O(参数数) 的反向模式自动微分 (reverse-mode AD). 数学已在 第零部分微积分 §2 雅可比链式 准备好, 这里把它落到工程:

  1. 计算图 — 把网络表成 DAG, loss 在根, 参数在叶.
  2. 正向 AD vs 反向 AD — 一阶 O(n) vs 一阶乘 O(1) 评估全部参数.
  3. 链式法则矩阵化 — 雅可比的链式 + softmax / CE / 线性 / 卷积的逐节点局部雅可比.
  4. 梯度检查 — 解析梯度 vs 数值有限差分, 工程上线性传第一件事.
  5. 常见坑 — 梯度消失/爆炸, checkpoint, mixed precision, 反向 release 顺序.

读完应能: 给一个网络能徒手画计算图、写每个节点的局部雅可比形状, 并在 NumPy 上跑通 grad_check().


一、为什么"反向"而不是"正向"

1.1 数值导数直接定义

$$ \frac{\partial f}{\partial x_i} = \lim_{h \to 0} \frac{f(x + h e_i) - f(x)}{h} $$

对 $\boldsymbol x \in \mathbb{R}^n$: 算全部梯度需要 $n$ 次 $f$ 评估 (扰一维 ($e_i$), 评估 $f$). 神经网络参数 $n \sim 10^9$, 显然不可行.

1.2 前向模式 AD (dual numbers)

每变量携带一阶梯度 $(v, \dot v)$, $\dot v$ 沿计算传播. 一次前向算出 $f$ 与 $\nabla f$ 在一个方向 $\dot{\boldsymbol x}$ 上的方向导数 $\nabla f \cdot \dot{\boldsymbol x}$.

→ 算全梯度需要 $n$ 次前向 AD, 与数值差分同阶. 适合 $f: \mathbb{R}^n \to \mathbb{R}^m$ 且 $m \gg n$ (雅可比列方向少).

1.3 反向模式 AD (链式法则逆序)

核心: 链式法则可写为 $\dfrac{\partial \mathcal{L}}{\partial \boldsymbol x} = J_g(\boldsymbol x)^\top \dfrac{\partial \mathcal{L}}{\partial g(\boldsymbol x)}$.

一次反向算出 loss 对所有参数的梯度, 与参数数量同阶 (节点的入边数之和). 适合 $f: \mathbb{R}^n \to \mathbb{R}$ (深度学习损失正是这种).

维度前向 AD反向 AD
一次算什么1 个方向导数 $\nabla f \cdot v$全部 $\nabla f$ ($n$ 个分量)
算全部梯度开销$O(n)$ 次前向$O(1)$ 次反向
中间结果保存不必必须 (前向激活值)
适合$n \ll m$$m \ll n$ ✓ 深度学习

note

PyTorch / TensorFlow / JAX 默认都是反向模式. 这也是为什么前向计算必须做 cache (x.requires_grad_(True) 后激活保留)、torch.no_grad() 跳过 cache 节省显存.

1.4 简短历史

  • 1970 Seppo Linnainmaa 硕士论文第一次写出自适应反向 AD.
  • 1974 Paul Werbos 博士论文描述反向传播用于神经网络.
  • 1986 Rumelhart, Hinton, Williams 在 Nature 推广为现代深度学习训练基础.
  • 1991-1992 Schmidhuber 与 Hochreiter 实证证明可解 vanishing gradient, 推出 LSTM.

二、计算图

2.1 网络的 DAG 表示

每个操作 $f = \mathrm{op}(x, y)$ 是图中的节点, 变量 $x, y, f$ 是边. 叶子是输入 / 参数, 根是 loss.

例: $L = (\sigma(Wx + b) - y)^2$

        L = ||e||^2            (root)
            │
           (e)^2  → (e)        (e = p - y)
                       │
                      (p = σ(z))
                              │
                           (z = Wx + b)
                              │
                ┌─────────────┴────────┐
              (Wx)   b                x, W

反向 (即拓扑逆序):

∂L/∂e = 2 e
∂L/∂p = ∂L/∂e · 1         (因为 e = p - y)
∂L/∂z = ∂L/∂p · σ'(z)     (链式 + 局部雅可比)
∂L/∂W = ∂L/∂z · x^T       (因为 z = Wx, J = x^T 见 §3.1)
∂L/∂x = ∂L/∂z · W         (对输入的梯度, 如要链下层)

2.2 形状规则速记

设上游梯度 (loss 对当前 op 输出的梯度) 形状为 $G$, 则:

  • $z = W x$ ($x \in \mathbb{R}^d, z \in \mathbb{R}^h, W \in \mathbb{R}^{h \times d}$):
    • $\partial \mathcal{L}/\partial W = G \cdot x^\top$ 形状 $h \times d$
    • $\partial \mathcal{L}/\partial x = W^\top \cdot G$ 形状 $d$
  • $z = X W$ ($X \in \mathbb{R}^{n \times d}, W \in \mathbb{R}^{d \times h}$, $z \in \mathbb{R}^{n \times h}$):
    • $\partial \mathcal{L}/\partial W = X^\top \cdot G$ 形状 $d \times h$
    • $\partial \mathcal{L}/\partial X = G \cdot W^\top$ 形状 $n \times d$

→ "丢掉与目标不同的轴, 再配上另一输入的转置"是工程上速记.

2.3 PyTorch 计算图实战

import torch
x = torch.randn(8, requires_grad=True)        # 输入
W = torch.randn(4, 8, requires_grad=True)     # 参数
b = torch.randn(4, requires_grad=True)
z = W @ x + b                                  # 正向 → 4 维
p = torch.sigmoid(z)                           # 同形
y = torch.tensor([1.0, 0.0, 1.0, 0.0])
L = ((p - y) ** 2).sum()
L.backward()                                   # 反向; 隐式拓扑逆序

print(x.grad.shape)   # torch.Size([8])
print(W.grad.shape)    # torch.Size([4, 8])
print(b.grad.shape)    # torch.Size([4])

warning

PyTorch 默认 reverse-mode AD + 即时执行 (eager). 计算图每次 forward 重构, backward() 后释放. 用 torch.compile (PT 2.0+) 才会做算子融合 + 持久图优化.


三、逐节点的局部雅可比

3.1 仿射变换 $z = W x + b$

回忆 $\partial \mathcal{L} / \partial z = g$ 已传到.

$$ \frac{\partial \mathcal{L}}{\partial W} = g \cdot x^\top, \quad \frac{\partial \mathcal{L}}{\partial x} = W^\top g, \quad \frac{\partial \mathcal{L}}{\partial b} = g $$

3.2 Sigmoid / Tanh

前向反向局部梯度
$\sigma(a)$$\sigma(1 - \sigma)$
$\tanh a$$1 - \tanh^2 a$
$\mathrm{ReLU}(a)$$\mathbb{1}_{a > 0}$
$\mathrm{GELU}(a) = a\Phi(a)$$\Phi(a) + a \phi(a)$
$\mathrm{SiLU}(a) = a \sigma(a)$$\sigma(a) + a \sigma(a)(1 - \sigma(a))$

3.3 Softmax + 交叉熵 (合算)

前向: $z \xrightarrow{\mathrm{softmax}} s \xrightarrow{\mathrm{CE}_y} \ell$.

反向 (链式 + 第零部分 §2.3):

$$ \frac{\partial \ell}{\partial z_i} = s_i - \mathbb{1}[i = y] $$

→ 一条公式就是 logistic 二元版的推广 (二类时 $s_i - \mathbb{1}[i = y]$ 与 $\hat p - y$ 同).

tip

反向 AB 工程惯例: softmax 与 CE 在 backward 里合算 (不显式 $J \cdot \hat{s}$), 因为 $s_i - \mathbb{1}$ 可一步出. 这就是为什么 PyTorch F.cross_entropy(logits, y) 而不是 F.cross_entropy(F.softmax(logits), y) —— 前者数值稳定 (含 log-sum-exp 防 exp overflow) 且 backward 简洁.

3.4 残差 $y = x + f(x)$

$$ \frac{\partial \mathcal{L}}{\partial x} = g + \frac{\partial \mathcal{L}}{\partial y} \cdot \frac{\partial f(x)}{\partial x} $$

即"两边梯度都加到 $x$". 这是 ResNet / Transformer 残差链 (深网络可训) 的数学根.

3.5 Layer Normalization

$y_i = \gamma \cdot \frac{x_i - \mu}{\sqrt{\sigma^2 + \epsilon}} + \beta$, $\mu = \overline{x}, \sigma^2 = \overline{(x - \mu)^2}$.

反向雅可比较繁 (因 $\mu, \sigma^2$ 都依赖 $x$ 的所有维). 工程上 PyTorch 已有 nn.LayerNorm 优化算子. 数学上记住"反向后 $g_{\text{in}} = \frac{\gamma}{\sigma} \cdot (g_{\text{out}} - \mathrm{proj}_{\text{norm 方向}})$"的存在即可.

3.6 卷积

$(I * K)[i, j] = \sum_{a, b} I[i + a, j + b] \cdot K[a, b]$. 反向梯度即为"互相关". 速记: 卷积的反向是扩边后的"恢复原输入与卷积核各自梯度".

工程上 cuDNN 用 winograd / FFT 加速, 反向与前向共享 kernel 配置.


四、梯度检查 (Gradient Checking)

4.1 何时必须

  • 自己写自定义算子或损失: 上线前必做.
  • 调通训练前 sanity check, "loss 不降" 第一定位手段.
  • 论文复现, attention/autoregressive mask 容易错位.

4.2 工程标准

数值中心差分:

$$ g_i^{\text{num}} = \frac{f(\theta + h e_i) - f(\theta - h e_i)}{2 h} $$

取 $h = 10^{-5}$ (float64 必需), $\epsilon = 10^{-7}$:

$$ \text{rel-error} = \frac{|g_{\text{num}} - g_{\text{ana}}|}{|g_{\text{num}}| + |g_{\text{ana}}|} $$

阈值: < $10^{-7}$ 通过, < $10^{-4}$ 警告, $\geq 10^{-3}$ 失败.

4.3 实现 (NumPy)

import numpy as np

def grad_check(f, theta, analytic_grad, h=1e-5, eps=1e-7, num=10):
    # 抽样若干维 (避免全维度 O(n) 太贵)
    rng = np.random.RandomState(0); idx = rng.choice(theta.size, size=num, replace=False)
    g = analytic_grad(theta).ravel()           # 解析
    max_rel = 0.0
    for i in idx:
        t1 = theta.copy().ravel(); t1[i] += h
        t2 = theta.copy().ravel(); t2[i] -= h
        g_num = (f(t1.reshape(theta.shape)) - f(t2.reshape(theta.shape))) / (2 * h)
        rel = abs(g_num - g[i]) / (abs(g_num) + abs(g[i]) + 1e-12)
        max_rel = max(max_rel, rel)
    print(f"max rel error = {max_rel:.2e}")
    return max_rel < eps

# 示例: 验证 softmax + CE 反向
def softmax_ce(logits, y):
    z = logits - logits.max(axis=-1, keepdims=True)
    e = np.exp(z); s = e / e.sum(axis=-1, keepdims=True)
    return -np.log(s[..., y] + 1e-12).sum()

def softmax_ce_grad(logits, y):
    z = logits - logits.max(axis=-1, keepdims=True)
    e = np.exp(z); s = e / e.sum(axis=-1, keepdims=True)
    s[..., y] -= 1
    return s

logits = np.random.randn(8, 10)
y = np.random.randint(0, 10, size=8)
ok = grad_check(lambda L: softmax_ce(L, y), logits, lambda L: softmax_ce_grad(L, y))
print(f"grad check passed: {ok}")

warning

float32 下中心差分只到 $10^{-4}$ 量级. 验证时一律先 dtype=np.float64, 否则会以为自己代码错误但实际只是数值精度.

4.4 常见误差来源

  1. softmax 数值溢出: 没 logits - logits.max() 时 $e^{1000} \to \infty$.
  2. mixing dtypes: int8 * float32 在 numpy 自动降到 float, 梯度 dtype 突然不一致.
  3. batch/seq 维错位: attention mask 把 padding 当真位反向.
  4. non-differentiable points: ReLU 在 0 不可导, $|x|$ 在 0 也是; 数值与解析都会卡 subgradient 不唯一.
  5. in-place ops: NumPy 的 arr += 1 改原数组会破坏 PyTorch 自动版本追踪.

五、内存与显存: checkpoint

5.1 反向 AD 的代价

必须保存前向中间激活, 才能反向算雅可比. 深网络激活 ≈ 层数 × 隐维 × 序列长. 显存成为瓶颈.

: GPT-3 175B 推理单序列, 单 batch, fp16: 权重 350 GB (Google TPU pod), 激活与 KV cache 又数十 GB.

5.2 Gradient Checkpointing (Chen 2016)

舍激活重新计算: 仅在分块边界保存激活, 反向时重新前向子段. 节省 $\sim 30-50%$ 显存, 多 30-50% 计算. 大模型训练 + 微调默认开.

# PyTorch API: torch.utils.checkpoint
from torch.utils.checkpoint import checkpoint

class TransformerEncoderLayer(nn.Module):
    def forward(self, x):
        return checkpoint(self._inner, x, use_reentrant=False)

5.3 激活重计算 vs KV cache

训练时: 激活与梯度都需, 反传必须; checkpoint 用来 trade 计算换显存.

推理 (自回归生成) 时: 上一 token 的 K, V 要保留供下一 token 注意力复用, 这就是 KV cache. 这是 LLM 推理显存的主要占用, 量化压缩的入口.


六、混合精度

6.1 fp16 / bf16 / fp8

dtypebits(mantissa/exp)范围精度用法
fp3224/8$10^{\pm 38}$7 位默认训练
fp1610/5$10^{\pm 5}$3 位范围窄, 易溢出; 需 loss scaling
bf167/8$10^{\pm 38}$2 位范围同 fp32, 精度低; A100/H100 推荐
fp8 (H100)4/3 (E4M3) / 5/2 (E5M2)跨厂商变体, 训练小心翼翼

6.2 混合精度套路

  • 主权重 fp32 存, 计算转 fp16/bf16:
    scaler = torch.cuda.amp.GradScaler()
    with torch.autocast(device_type='cuda', dtype=torch.bfloat16):
        y = model(x); loss = criterion(y, t)
    scaler.scale(loss).backward(); scaler.step(optimizer); scaler.update()
    
  • bf16 不需 loss scaling, fp16 需要 (防小梯度下溢).

6.3 数值陷阱

  • softmax 必须用 fp32 计算 (log-sum-exp 内部, 跨指数差大).
  • 累加归一化 (Layer Norm 的方差累加) 用 fp32 防 trip.
  • 损失反向梯度常用 fp32 综合.

七、vanishing / exploding gradient

7.1 数学根

深网络反向: $\partial \mathcal{L}/\partial x_0 = \prod_{l=1}^{L} W_l^\top D_l$ 其中 $D_l = \mathrm{diag}(\sigma'(z_l))$.

  • 乘积的谱 $\rightarrow 0$ (vanishing) ⇔ $\max |W \sigma'| < 1$ 典型: sigmoid $\sigma' \leq 0.25$.
  • $\rightarrow \infty$ (exploding) ⇔ 反之.

7.2 工程对策

手段解决出处
ReLU / GELU偏向大梯度Nair 2010
Xavier / He init让 $W\sigma'
Batch Norm / Layer Norm归一化中间激活Ioffe 2015
残差连接 $y = x + f(x)$梯度直接通路 $\partial y/\partial x \geq 1$He 2016 ResNet
Gradient clippingclip 全局范数 $|g| \leq \tau$Pascanu 2013
LSTM / GRU设记忆 cell 有加门Hochreiter 1997
Adam / RMSprop学习率自适应Kingma 2014

note

Transformer 用了: (1) Layer Norm, (2) 残差, (3) GELU, (4) Adam + warmup, (5) Xavier init → 这套组合正是为了反抗深 stack 的 vanishing/exploding.

7.3 残差连接的"梯度高速公路"

$y = x + f(x) \Rightarrow \frac{\partial \mathcal{L}}{\partial x} = g + J_f^\top g$. 至少有 "1" 的恒等通路, 无论 $f$ 怎么退化, 主梯度仍能回传. 这就是为什么 ResNet-152 比经典 30 层 net 可训.


八、与第零部分的接口

  • §1 反向 vs 前向 AD ⇐ 第零部分微积分 §2.5 自动求导.
  • §2 形状规则 ⇐ 第零部分线代 §6 张量收缩 (Einstein 缩写).
  • §3.3 softmax + CE ⇐ 第零部分 §2.3 softmax 雅可比.
  • §3.4 残差 ⇐ 第零部分 §6 张量收缩 + 链式.
  • §3.5 LayerNorm ⇐ 第零部分线代 §5 正定 + 概率期望.
  • §5 checkpoint ⇐ 工程权衡, 无数学新内容.
  • §6 mixed precision ⇐ 第零部分概率数值范围无直接, 这是硬件层.
  • §7 vanishing ⇐ 第零部分线代 §3 谱半径.

九、结束 + 速查表

tip

一页快速唤回:

  • 链式: $\partial(f \circ g)/\partial \boldsymbol x = J_g^\top \nabla f$.
  • 反向 AD: 一次反传 = 全梯度, O(节点 + 边), 必须存激活.
  • 形状: $z = xW \Rightarrow \partial\mathcal{L}/\partial W = x^\top g$, $\partial\mathcal{L}/\partial x = g W^\top$.
  • softmax+CE: $\partial \mathcal{L}/\partial \text{logits} = s - \text{onehot}$; PyTorch 用 cross_entropy(logits, y).
  • 残差: $y = x + f(x) \Rightarrow \partial\mathcal{L}/\partial x = g + \partial f/\partial x \cdot g$ (恒有 1 通路).
  • 梯度检查: float64, $h=10^{-5}$, 中心差分 rel err < $10^{-7}$.
  • mixed precision: 主权重 fp32, 计算 bf16 (推荐); fp16 需 loss scaling.
  • gradient checkpoint: trade 显存 vs 计算; 大模型默认开.
  • vanishing/exploding: 改激活 (ReLU/GELU) + init (He/Xavier) + norm + 残差 + clip + Adam.

下一篇: 3. Transformer: self-attention / MHA / FFN / LayerNorm / 残差 / Encoder-Decoder / 训练损失.

3. Transformer: self-attention / MHA / FFN / LayerNorm / 残差 / Encoder-Decoder / 训练损失

TL;DR

Transformer (Vaswani et al. 2017) 把 NLP 主流从 RNN 转向纯 attention + 前馈 + 残差 + 层归一化 的堆叠栈. 这一章把可调组件拆到逐节点, 每个给出公式 / 形状 / 反向梯度 / 工程坑:

  1. Scaled Dot-Product Attention — softmax + $\sqrt{d_k}$ 缩放的本质.
  2. Multi-Head Attention (MHA) — 并行多头 = 多视角线性映射.
  3. FFN — 两层 + 激活, 提供"逐 token 的非线性动量".
  4. LayerNorm + 残差 — 训练稳定的真因.
  5. Encoder / Decoder 差异 — Cross-attention 与 mask.
  6. 位置编码 — Sinusoidal / ALiBi / RoPE 的差异 (概览, 详见 tokenizer-embedding 章节待补).
  7. 训练损失 — next-token CE + label smoothing; 入门 RLHF 接口.

读完应能: 读《Attention Is All You Need》原文逐行不卡, 在 NumPy 上写出可前向反向的 attention + softmax + MHA.


一、Scaled Dot-Product Attention

1.1 公式

$$ \mathrm{Attention}(Q, K, V) = \mathrm{softmax}\left(\frac{Q K^\top}{\sqrt{d_k}}\right) V $$

形状: $Q \in \mathbb{R}^{T_q \times d_k}, K \in \mathbb{R}^{T_k \times d_k}, V \in \mathbb{R}^{T_k \times d_v}$.

  • $Q K^\top \in \mathbb{R}^{T_q \times T_k}$: 每对位置的内积 = 相似度.
  • softmax 沿 $T_k$ 轴 (键) 归一化 → 每行是 attention 权重.
  • 乘 $V$: 加权平均 → 输出 $\in \mathbb{R}^{T_q \times d_v}$.

note

这其实是信息检索的 soft 版本: $Q$ 是 query (查询), $K$ 是 key (索引), $V$ 是 value (内容). 硬版本是 $\arg\max$ 找最相似 key; 这里改成 softmax 加权, 以致可微 + 可微 + 反向传播.

1.2 为什么除 $\sqrt{d_k}$?

如果 $q, k \in \mathbb{R}^{d_k}$ 各分量 iid $\sim \mathcal{N}(0, 1)$, 那么点积 $q^\top k \sim \mathcal{N}(0, d_k)$, 方差与 $d_k$ 同阶. 当 $d_k = 64$ 时 dot 可达数十. 与 softmax 远大于 1 的输入 → 梯度极小 (饱和小区域), 训练不动.

除 $\sqrt{d_k}$ 让 $\mathrm{Var}(q^\top k / \sqrt{d_k}) = 1$, 等价让 softmax 工作在线性变化区间, 训练可启动.

tip

进一步的解释是: softmax 的雅可比是 $J = \mathrm{diag}(s) - s s^\top$. 当 $s$ 近 one-hot (因为分数差很大) 时 $J \approx 0$ → 反向传不动.

1.3 反向梯度

已知 $\partial \mathcal{L}/\partial \mathrm{out} = G \in \mathbb{R}^{T_q \times d_v}$.

记 $S = QK^\top / \sqrt{d_k}$, $A = \mathrm{softmax}(S)$, $O = A V$.

公式 (用第零部分 §2.3 softmax 雅可比 + 几何):

∂L/∂A = G Vᵀ                 (T_q × T_k)
∂L/∂V = Aᵀ G                 (T_k × d_v)

dsoftmax 反向: ∂L/∂S = A ⊙ (∂L/∂A - (∂L/∂A · A.sum(axis=-1, keepdim=True)))

∂L/∂Q = (1/√d_k) (∂L/∂S) K   (T_q × d_k)
∂L/∂K = (1/√d_k) (∂L/∂S)ᵀ Q  (T_k × d_k)

NumPy 朴素实现:

import numpy as np

def softmax_lastaxis(x):
    x = x - x.max(axis=-1, keepdims=True)
    e = np.exp(x)
    return e / e.sum(axis=-1, keepdims=True)

def attention(Q, K, V, mask=None):
    d_k = Q.shape[-1]
    S = Q @ K.transpose(-2, -1) / np.sqrt(d_k)
    if mask is not None:
        S = np.where(mask, S, -1e9)
    A = softmax_lastaxis(S)
    O = A @ V
    return O, (S, A)             # 缓存以供反向

def attention_backward(grad_O, cache, Q, K, V, mask=None):
    S, A = cache
    d_k = Q.shape[-1]
    grad_A = grad_O @ V.transpose(-2, -1)                  # ∂L/∂A
    grad_V = A.transpose(-2, -1) @ grad_O                   # ∂L/∂V

    # softmax 反向 (沿 last axis)
    grad_S = A * (grad_A - (grad_A * A).sum(-1, keepdims=True))
    if mask is not None:
        grad_S = np.where(mask, grad_S, 0.0)

    grad_Q = grad_S @ K / np.sqrt(d_k)                      # ∂L/∂Q
    grad_K = grad_S.transpose(-2, -1) @ Q / np.sqrt(d_k)    # ∂L/∂K
    return grad_Q, grad_K, grad_V

# sanity shape 检查
T, d = 8, 64
Q = np.random.randn(T, d); K = np.random.randn(T, d); V = np.random.randn(T, d)
O, cache = attention(Q, K, V)
print(O.shape)              # (8, 64)
grad_Q, grad_K, grad_V = attention_backward(np.ones_like(O), cache, Q, K, V)
print(grad_Q.shape, grad_K.shape, grad_V.shape)   # (8,64) (8,64) (8,64)

1.4 Causal mask 与 padding mask

两类 mask 同时作用在 Decoder:

  • Padding mask: 处理变长 batch padding, 把 padding 位 score = $-\infty$, 不让 attention 看到.
  • Causal mask: 自回归生成时, 位置 $i$ 只能用到 $\leq i$ 的 key, 防"看未来". 上三角 $-\infty$.

实现: 0/1 二值 mask, S = S.masked_fill(~mask, float('-inf')).

// 教学版 attention (TypeScript)
export function softmaxLastAxis(x: number[][], axis = -1): number[][] {
  return x.map(row => {
    const m = Math.max(...row);
    const e = row.map(v => Math.exp(v - m));
    const Z = e.reduce((a, b) => a + b, 0);
    return e.map(v => v / Z);
  });
}

export function attention(
  Q: number[][], K: number[][], V: number[][], mask?: boolean[][]
): number[][] {
  const T_q = Q.length, T_k = K.length;
  const d_k = Q[0].length;
  const S: number[][] = Array.from({ length: T_q }, () => Array(T_k).fill(0));
  for (let i = 0; i < T_q; i++)
    for (let j = 0; j < T_k; j++) {
      let s = 0;
      for (let k = 0; k < d_k; k++) s += Q[i][k] * K[j][k];
      S[i][j] = mask ? (mask[i][j] ? s / Math.sqrt(d_k) : -Infinity) : s / Math.sqrt(d_k);
    }
  const A = softmaxLastAxis(S);
  const d_v = V[0].length;
  const O: number[][] = Array.from({ length: T_q }, () => Array(d_v).fill(0));
  for (let i = 0; i < T_q; i++)
    for (let j = 0; j < T_k; j++) {
      const a = A[i][j];
      for (let k = 0; k < d_v; k++) O[i][k] += a * V[j][k];
    }
  return O;
}

二、Multi-Head Attention (MHA)

2.1 把单头分裂成 H 头

把 $Q, K, V$ 分别线性投影到 $d_k = d_v = d / H$ 维, 跑 $H$ 个并行 attention, 拼回 $d$ 维:

$$ \mathrm{MHA}(Q, K, V) = \mathrm{Concat}(\mathrm{head}_1, \ldots, \mathrm{head}_H) W^O $$ $$ \mathrm{head}_h = \mathrm{Attention}(Q W_h^Q, K W_h^K, V W_h^V) $$

2.2 工程缩撑

  • 输入 $x \in \mathbb{R}^{T \times d}$.
  • $W^Q, W^K, W^V \in \mathbb{R}^{d \times d}$. 一举投影出每头, 形状变换 [B, T, H, d/H] + transpose → [B, H, T, d/H].
  • 每头 attention 形状 [B, H, T, T] × [B, H, T, d/H] = [B, H, T, d/H].
  • 拼回 [B, T, d], 再乘 $W^O \in \mathbb{R}^{d \times d}$.

2.3 为什么多头?

  • 每头学不同关联模式: $H = 8$ 意味着并行学 8 路模式 (短距离 / 长距离 / 因果 / 等).
  • 单头要表达多模式需 $\sqrt{d}$ 倍记忆, 多头等数存参但表达灵活度更高.
import torch
import torch.nn as nn

class MultiHeadAttention(nn.Module):
    def __init__(self, d_model, n_heads):
        super().__init__()
        assert d_model % n_heads == 0
        self.h, self.dk = n_heads, d_model // n_heads
        self.WQ = nn.Linear(d_model, d_model, bias=False)
        self.WK = nn.Linear(d_model, d_model, bias=False)
        self.WV = nn.Linear(d_model, d_model, bias=False)
        self.WO = nn.Linear(d_model, d_model, bias=False)

    def forward(self, x, mask=None):
        B, T, D = x.shape
        # 把 D 拆 H × dk, 转为 [B, H, T, dk]
        Q = self.WQ(x).view(B, T, self.h, self.dk).transpose(1, 2)
        K = self.WK(x).view(B, T, self.h, self.dk).transpose(1, 2)
        V = self.WV(x).view(B, T, self.h, self.dk).transpose(1, 2)
        S = Q @ K.transpose(-2, -1) / (self.dk ** 0.5)    # [B, H, T, T]
        if mask is not None:
            S = S.masked_fill(~mask[:, None, None, :], float('-inf'))
        A = S.softmax(dim=-1)
        O = A @ V                                          # [B, H, T, dk]
        O = O.transpose(1, 2).contiguous().view(B, T, D)   # concat
        return self.WO(O)

warning

常见 bug: 忘了把维度 .transpose 把 head 维移到 batch 维, Q @ K.T 会错认 T 维. PyTorch 用 key_padding_mask / attn_mask 双轴, 工程上要小心区分.

2.4 MHA 变体 (前沿, 仅概览)

出现年 / 出处核心
MHA (经典)2017上面
MQA (Multi-Query)2019 ShazeerK/V 只共享一组, 推理快显存省
GQA (Grouped-Query)2023 AinslieK/V 多头分组共享, MHA 与 MQA 折中
MLA (Multi-head Latent)2024 DeepSeek-V2KV 压缩到 latent 向量再展开, 进一步省 KV cache
FlashAttention2022 Dao块化计算, 中间矩阵不物化在 HBM, 训练显存降一个数量级

warning

FlashAttention 不改变数学公式, 只改变 IO 访问: 把 $S = QK^\top$ 切块, softmax 在共享内存做 row-naive, 中间 intermediate 不写回 HBM. 这是工程上 LLM 训练从忍到可的关键.


三、Feed-Forward Network (FFN)

3.1 公式

两层 MLP, 升维再降维:

$$ \mathrm{FFN}(x) = \mathrm{Dropout}(\sigma(x W_1 + b_1) W_2 + b_2) $$

$$ W_1 \in \mathbb{R}^{d \times d_{ff}},\quad W_2 \in \mathbb{R}^{d_{ff} \times d},\quad d_{ff} \approx 4 d $$

  • $\sigma$ 经典 ReLU, 现代 GELU / SwiGLU.
  • 对每个位置独立应用 (无 T 维交互); attention 负责跨 token, FFN 负责单 token 加工.

3.2 SwiGLU 变体

$$ \mathrm{SwiGLU}(x) = \mathrm{Swish}(x W_1) \odot (x W_3) \cdot \mathbb{1}_{W_2} $$

LLaMA / PaLM 用 SwiGLU 把 FFN 变成 3 个权重矩阵 (而非 2 个), 实验报告 +性能. 实际工程通常 $d_{ff} \approx \frac{2}{3} \cdot 4d$ 以保持总参数相同.

3.3 FFN 在反向上的处理

就是经典 MLP + 矩阵 + 非线性, 反向用第零部分 §6 张量收缩 + §2 链式. 工程核心是 cuBLAS / cuDNN 的 GEMM 调优.


四、LayerNorm + 残差

4.1 LayerNorm 公式

$$ \mathrm{LN}(x) = \gamma \odot \frac{x - \mu}{\sqrt{\sigma^2 + \epsilon}} + \beta, \quad \mu = \overline{x}, \sigma^2 = \overline{(x - \mu)^2} $$

沿特征维 (而非 batch 维) 归一化. 这就是 LayerNorm vs BatchNorm 的核心差.

维度归一化轴适合
Batch Norm(batch, H, W)CV, 固定 batch
Layer Norm(hidden)NLP, RNN, 变 batch
Instance Norm(H, W) per instancestyle transfer
Group Norm(groups of channel)小 batch CV

4.2 Pre-LN vs Post-LN

原版 Transformer 用 Post-LN: $y = \mathrm{LN}(x + \mathrm{Sublayer}(x))$.

现代 LLaMA / GPT-NeoX 用 Pre-LN: $y = x + \mathrm{Sublayer}(\mathrm{LN}(x))$.

note

Pre-LN 更易训深层, 因为残差路径上没 LN 减缓梯度. Post-LN 训超深需 warmup 谨慎 (易发散); Pre-LN 不需长 warmup.

4.3 残差为什么是必需的

第零部分 §7 反向 AD 中讲过: 残差留恒等通路使主梯度能跨多层回传. Transformer 堆叠 24-96 层, 没残差的话深到一定深度 loss 就停降 (类似 ResNet 早期遭遇的 plateau).


五、Encoder vs Decoder

5.1 Encoder (BERT 派)

  • 双向 attention (没 causal mask).
  • 模型目标: 上下文理解表示.

5.2 Decoder (GPT 派)

  • 单向 causal mask.
  • 模型目标: 自回归生成 $p(x_t | x_{<t})$.

5.3 Encoder-Decoder (原版 Transformer, T5 / BART)

附加 cross-attention:

$$ Q_d = W^Q_d \cdot H_d, \quad K_e = W^K_e \cdot H_e, \quad V_e = W^V_e \cdot H_e $$

decoder 的 query 与 encoder 的 key/value 相互注意力, 让 decoder 在每个生成步参考 encoder 全序列. 多用于机器翻译 / 总结.


六、位置编码

6.1 Sinusoidal (原版)

无位置 → attention 对输入顺序无感 (permutation-equivariant). 加位置:

$$ \mathrm{PE}{t, 2k} = \sin(t / 10000^{2 k / d}), \quad \mathrm{PE}{t, 2k+1} = \cos(t / 10000^{2 k / d}) $$

性质: $\mathrm{PE}_{t + \delta}$ 是 $\mathrm{PE}_t$ 的旋转, 可由线性投影组合 (sin $\to$ sin/cos 偏 shift).

6.2 Learned / ALiBi / RoPE (概览)

  • Learned: 可学参数形状 [T_max, d] —— BERT 经典, 外推差.
  • ALiBi (Press 2021): 没显式位置编码, 而是把"距离感"加在 attention bias 上, 偏置 $-|i - j| / \mathrm{head_freq}$ —— 支持长序列外推.
  • RoPE (Su 2021): 在复数空间旋转, 类 sinusoidal 但作为 query/key 的旋转; LLaMA / GPT-NeoX 默认, 外推好.
  • YaRN / LongRoPE: RoPE 外插改进, 让 model 上 1M 上下文.

完整推导与实现见 第 7 章 Tokenizer 与位置编码 §7.

warning

把 RoPE 只看作"加了位置的旋转"会错过细节: 它在 $d$ 维空间内 $d/2$ 个 2D 子空间各自不同频率. 反向时, RoPE 的反操作 (rotate by $-\theta_i$) 直接得回原 (无参).


七、训练损失与对齐入口

7.1 自回归语言模型损失

给定序列 $x_1, \ldots, x_T$, 对第 $t$ 步用前缀 $x_{<t}$ 预测 $x_t$:

$$ \mathcal{L}{\text{LM}} = -\sum{t=1}^{T} \log p_\theta(x_t | x_{<t}) $$

每个 $p_\theta$ 是从 logits 出来的 softmax, 反向梯度 = $s - \mathrm{onehot}(x_t)$ (第零部分 §3 softmax + CE 链式).

7.2 Label smoothing

把 one-hot $\to$ $(1 - \epsilon)$ 主类 + $\epsilon / (V-1)$ 其它. 工程意义: 防止 model 在 1.0 概率上撞晚, 改善 calibration. Perplexity 反会高一点点 (因 cross entropy 在非 one-hot 上变大), 但真实输出概率更可信.

7.3 Masked LM (BERT)

随机 mask 15% token, 在 mask 位预测. 让 encoder 单向变部分"完形填空". 训练效率更高 (一次训全部位置), 但不适合生成.

7.4 入口: SFT / RLHF (后面 RL 章会详谈)

  • SFT (supervised fine-tune) = 在指令数据上继续 next-token CE 跑.
  • RLHF (Reinforcement Learning from Human Feedback): 用 reward model + PPO 在 SFT 后对 generation 做策略优化, 让模型符合人类偏好. 详见第 8 章 RL.

八、整体一张图

8.1 标准解码器层 (e.g. GPT-2)

        x (B, T, d)
        │
        ┌── Add & LN (Pre-LN)
        │     │
        │   [LN]
        │     │
        │   MHA (causal mask, RoPE 对应 Q,K)
        │     │
        │   Dropout
        │     │
        └── +  (residual)
              │
              x'
              │
        ┌── Add & LN
        │     │
        │   [LN]
        │     │
        │   FFN (GELU or SwiGLU)
        │     │
        │   Dropout
        │     │
        └── +  (residual)
              │
            输出 (B, T, d)

8.2 GPT 风格与 BERT 风格对比

维度BERT (encoder)GPT (decoder)
attention双向 no maskcausal mask
位置learnedlearned / RoPE / ALiBi
损失Masked LM + NSP自回归 LM
用途表示 / 分类生成
训练完形填空预测下一 token
TokenizerWordPieceBPE

九、形状与显存速查

9.1 全 MLP + attention 的算量

GPT-3 规模: $L = 96, d = 12288, n_{heads} = 96, T = 2048$.

操作计数FLOPs / token显存
Attention $QK^\top$$L \cdot T \cdot d$$O(T d) = 25M$$O(T^2)$ = 4M bf16 = 8 MB
FFN (3 widen)$L \cdot d \cdot d_{ff}$$O(d \cdot 4 d) = 600M$$O(d \cdot d_{ff})$
~$10^{11}$ params~ $10^{11}$ FLOPs / token~350 GB weights (bf16)

9.2 flash 与 IO

  • $S = QK^\top$ 物化到 HBM 显存 → $T^2$ 张量直写入 HBM, 大序列爆炸.
  • FlashAttention 块化把 $T \times T$ 矩阵分块在 SRAM 计算与做 softmax 累加 (online softmax 从 running max + running sum 实现); 不物化中间 → 大 IO 节约.

十、与第零部分的接口

  • §1 scaled dot-product ⇐ 第零部分线代 §7 余弦相似度 + §6 张量收缩.
  • §1.2 $\sqrt{d_k}$ ⇐ 第零部分线代 §5 矩阵范数 + 概率 §2 方差.
  • §1.3 反向 ⇐ 第零部分微积分 §3 Hessian + §2 雅可比.
  • §2 MHA 形状 ⇐ 第零部分线代 §6 张量.
  • §4 LayerNorm ⇐ 第零部分概率 §2 期望方差 + 线代 §1 内积.
  • §3 / 5 / 6 是新内容, 几乎不依赖第零部分.

十一、结束 + 速查表

tip

一页快速唤回:

  • 核心公式: $\mathrm{softmax}(QK^\top / \sqrt{d_k}) V$.
  • $\sqrt{d_k}$ 防 softmax 饱和, 让训练能启动.
  • mask: causal (下三角) + padding.
  • MHA: $H$ 个并行投影做 attention, concat + $W^O$; 错维度排错位置是常见 bug.
  • FFN: $d \to 4d \to d$, ReLU/GELU/SwiGLU; 对每个位置独立.
  • Pre-LN: $x + \mathrm{Sublayer}(\mathrm{LN}(x))$ (现代默认); Post-LN 需更长 warmup.
  • 残差: 恒等通路把深可达梯度推回根.
  • 位置编码: sinusoidal (原版) / learned / RoPE (现代 LLaMA, 长外推).
  • 训练损失: next-token CE / masked LM; label smoothing 改 calibration.
  • SFT/RLHF 入口: SFT = CE 续训, RLHF = PPO 在 policy LM 上 (后面 RL 章).
  • 算量: 注意力 $O(T d + T^2 d_k)$, FFN $O(d^2)$; FlashAttention 不改数学仅改 IO.

下一篇: 4. Optimizers & Training Dynamics: Adam/AdamW/二阶 + 初始化/LayerNorm/warmup/checkpoint.

4. Optimizers & Training Dynamics: Adam/AdamW/二阶 + 初始化/LayerNorm/warmup/checkpoint

TL;DR

训练 = 让损失下降, 但怎么走每一步比"走对方向"更重要. 这一章把第零部分微积分与优化 §5 一阶优化器谱系落到 ML 训练现实中, 覆盖:

  1. SGD → Momentum → RMSProp → Adam → AdamW — 算法谱与各自"在哪个病上多了一手".
  2. 二阶与拟牛顿 — Newton / L-BFGS / K-FAC / Sophia, 为什么在 Transformer 训练里几乎不用.
  3. 学习率调度与 warmup — cosine / linear / 多阶段、为什么 Transformer 必须有 warmup.
  4. 权重初始化 — Xavier / He / 0.02 std / μP; σ 与 $\sqrt{d_k}$ 的呼应.
  5. LayerNorm vs RMSNorm vs DeepNorm — 稳定深 stack 与大规模训练的实际替代.
  6. 梯度裁剪 / 梯度累积 / ZeRO 概览 — 跨多卡的实践要点.
  7. 梯度累积梯度检查激活 checkpoint — 一卡跑大 batch 的"显存魔术".
  8. 训练动力学诊断 — learning curve 健康度 / weight watch / glow / NaN 排查.

读完应能: 从零组合一个 Transformer 训练 config 知道 lr 怎么取 / warmup 多少步 / 用什么优化器 / 何种 init, 出 NaN 时能定位.


一、一阶优化器谱 (回顾 + ML 落地)

第零部分微积分与优化 §5 已列谱系, 这里聚焦每个在 ML 现实里的"为什么".

1.1 SGD 与小批量

$$ \theta_{k+1} = \theta_k - \eta \cdot \hat g_k, \quad \hat g_k = \frac{1}{B}\sum_{i \in \text{batch}k} \nabla\theta \ell(f_\theta(x_i), y_i) $$

关键性能:

  • 收敛率: 凸 $\mathcal{L}^*$ → $O(1/k)$; 强凸 → $O(\exp(-k/L))$; 非凸 → $O(1/\sqrt{k})$ (到 stationary point).
  • 实际: 全 batch 太慢; $B = 32 \sim 2048$ 在 GPU 上高效; 大 $B$ ($\geq 4M$ tokens) 多卡下需 LARS / LAMB.

1.2 Momentum (Polyak 1964 / Nesterov 1983)

$$ v_{k+1} = \beta v_k + \hat g_k, \quad \theta_{k+1} = \theta_k - \eta , v_{k+1} $$

  • $\beta = 0.9$ 标配; 等效"惯性滑动"沿一致梯度方向加速, 抑制样本噪声.
  • Nesterov 加速: $\hat g_k = \nabla f(\theta_k - \eta \beta v_k)$, 看一眼下一刻位置再求梯度. 凸情形最优 $O(1/k^2)$ 收敛率.

1.3 RMSProp (Hinton 2012, 未发表讲义)

$$ \mathbb{E}[g^2]k = \alpha \mathbb{E}[g^2]{k-1} + (1 - \alpha) \hat g_k^2;\quad \theta_{k+1} = \theta_k - \eta \cdot \frac{\hat g_k}{\sqrt{\mathbb{E}[g^2]_k + \epsilon}} $$

解决了什么: 不同参数维度梯度量级不同, 大角度看各自的二阶矩自适应学习率. RNN 经典, 但 Transformer 几乎不单独用.

1.4 Adam (Kingma & Ba 2014)

回顾公式 (已在第零部分微积分与优化 §5 写过):

$$ m_k = \beta_1 m_{k-1} + (1 - \beta_1) g_k $$ $$ v_k = \beta_2 v_{k-1} + (1 - \beta_2) g_k^2 $$ $$ \hat m_k = m_k / (1 - \beta_1^k), \quad \hat v_k = v_k / (1 - \beta_2^k) $$ $$ \theta_{k+1} = \theta_k - \eta \cdot \frac{\hat m_k}{\sqrt{\hat v_k} + \epsilon} $$

默认: $\beta_1 = 0.9, \beta_2 = 0.999, \epsilon = 10^{-8}$.

特性:

  • 一阶 + 二阶自适应: 每参数签名 (precision) 调整步长.
  • bias correction 让初期 $m, v$ 不被初始化 0 拉住.

warning

Reddi et al. 2018 "On the Convergence of Adam" 证 Adam 在一些简单凸问题上不收敛, 给出 AMSGrad 修补 ($v_k \leftarrow \max(v_k, v_{k-1})$) 防步长反向放大. 但实际 LLM 训练里主流仍用 Adam 类变体, 因为样本噪声远超此问题.

1.5 AdamW (Loshchilov & Henter 2017/2019)

AdamW 把 weight decay 解耦:

$$ \theta_{k+1} = \theta_k - \eta \cdot \left( \frac{\hat m_k}{\sqrt{\hat v_k} + \epsilon} + \lambda \theta_k \right) $$

  • Adam 把 weight decay 写入梯度 $\hat g_k + \lambda \theta$, 与自适应冲突.
  • AdamW 把 $\lambda \theta$ 直接乘 $\eta$ 外加, 不进 $m, v$, 保留自适应对方向精度.

ML 实践: LLM 训练首选 AdamW. weight decay $\lambda = 0.01 \sim 0.1$; Bert-Base wd=0.01; LLaMA-2 wd=0.1.

1.6 谱系总结

SGD
  └─ Momentum (Polyak/Nesterov): 沿一致方向加速
      └─ RMSProp: 用二阶矩做按维度学习率
          └─ Adam: 一阶+二阶组合
              ├─ AMSGrad: 修 Adam 不收敛
              ├─ NAdam: Nesterov + Adam
              └─ AdamW: 解耦 weight decay ←── Transformer / LLM 主流
任务类型配方
CV (ResNet/ViT 默认 SGD)SGD+Momentum 0.9, weight decay 1e-4
CV 加 augmentation有时 SGD 比 Adam 更好 generalization
RNNRMSProp / Adam
NLP / TransformerAdamW + warmup + cosine
Pretraining 大模型AdamW + cosine + warmup + bf16 + ZeRO
Fine-tuneAdamW wd=0.0 或 0.01, 较小 lr
强化学习Adam (PPO)

二、二阶方法: 为什么在深度学习中少见

2.1 Newton 法回顾

$$ \theta_{k+1} = \theta_k - H^{-1}_k g_k $$

理论优点: 二次局部收玫; 极值附近极快.

ML 障碍:

  1. $H^{-1}$ 太贵: $n$ 维 → $O(n^3)$ 反演. GPT-3 $n = 175B$ 不现实.
  2. $H$ 在非凸鞍点附近不正定, 反向更可能下冲.
  3. Mini-batch $H$ 是噪声估计, 方差大.
  4. 二阶自适应 learning rate 与一阶迭代式不易混合.

2.2 拟牛顿 (L-BFGS)

不存 $H^{-1}$, 用最近 $m$ 步迭代差来近似 $\rho H^{-1}$.

ML 应用: 几乎只在小数据全 batch 优化里 (naive logistic regression / 非凸优化如 Deep Equilibrium Models 偶尔). 深度学习主流不用.

2.3 K-FAC (Martens & Grosse 2015)

Kronecker-Factored Approximate Curvature: 用 Kronecker 分解近似 Fisher 信息矩阵. 在 Transformer 训练里少数研究组尝试 (DeepSeek-1 略用), 但工业上仍主流一阶.

2.4 Sophia / Shampoo / Muon (2023-2024)

  • Shampoo (Gupta 2018): 用层结构做块对角 Preconditioner.
  • Sophia (Liu et al. 2023): 用 Hessian 估计的对角做曲率, 比 Adam 2× 收敛; LLaMA-scale 实验通过.
  • Muon (Keller Jordan 2024+): 用 Newton-Schulz iteration 做 GPU 友好的正交化梯度, 进一步加速 Qwen-class 训练.

note

这些二阶近似优化器进展是真正多工业尝试, 但仍未取代 AdamW 的主导地位, 主要因为 GPU/H100 上的优化 kernel 都为 AdamW 写. 但 Muon 这个新进展值得关注.


三、学习率调度

3.1 固定 LR 的问题

  • 太大: 损失震荡或 NaN.
  • 太小: 训练慢.
  • 沿途: 不同阶段不同最优 LR.

3.2 常见调度

调度形式用例
Constant$\eta_k = \eta_0$Baseline, 小数据
Step decay每 $K$ 步乘 0.1CV 经典 (e.g. SGD step 30/60/90)
Exponential decay$\eta_k = \eta_0 \gamma^k$罕用
Cosine annealing (Loshchilov 2017)$\eta_k = \frac{\eta_{\min}}{2} + \frac{\eta_0 - \eta_{\min}}{2}(1 + \cos(\pi k/T))$LLM 训练默认
Cosine + warmup见下LLaMA、GPT 风格
Linear warmup → linear decay大批训练BERT
OneCycle (Smith 2018)LR 与 momentum 各反相位找极限

3.3 Warmup: 为什么 Transformer 必要

  • Post-LN Transformer 的不稳定性根在: 初期参数随机初始化, attention 输出均值方差与训练末期差大; $\partial \mathcal{L}/\partial W$ 早期量级大 → "Adam 二阶矩估计 $v$ 累积慢", 大步引起不稳.
  • Pre-LN 缓解这点 (所以 LLaMA 之类可以短 warmup), 但仍推荐.

经典 cosine + warmup:

import math

def lr_schedule(step, warmup_steps, total_steps, base_lr, min_lr):
    if step < warmup_steps:
        return base_lr * step / warmup_steps           # linear warmup
    progress = (step - warmup_steps) / (total_steps - warmup_steps)
    progress = min(max(progress, 0.0), 1.0)
    return min_lr + 0.5 * (base_lr - min_lr) * (1 + math.cos(math.pi * progress))

tip

LLaMA-1/2/3 配方 (粗略): 基础 LR 3e-4 Pretrain, 2e-5 SFT, 1e-5 DPO. warmup 2000 steps, cosine decay 到 10%, weight decay 0.1, gradient clip 1.0, β1=0.9, β2=0.95.

3.4 Restart (SGDR) 与 Compound

SGDR (Loshchilov 2017): cosine 多次 restart 让跳出局部. 现代大模型少用, CV 小数据实战用.

3.5 LR 与 Batch Size 的 linear scaling rule

经验法则: batch $B \to kB$ 时 lr 也 $\eta \to k \eta$ (good up to a critical $B^*$, ~ 8K-16K tokens for GPT-style, 超过需要 sqrt scaling). 在 [Goyal et al. 2017] 的 ImageNet 工作里证实. LARS / LAMB 当超过 critical $B$ 时仍稳.


四、权重初始化

4.1 为什么不能全 0

全 0 (或全常数) ⇒ 同层所有神经元同梯度同更新 → 退化为单 neuron → 永远学不动. 必须打破对称.

4.2 Xavier (Glorot) init - 针对 $\tanh$

对 $y = W x$, $x, W$ iid $\sim \mathcal{N}(0, \sigma^2)$. $\operatorname{Var}(y) = d \sigma^2 \cdot \operatorname{Var}(x)$, 想 $\operatorname{Var}(y) = \operatorname{Var}(x)$ ⇒ $\sigma^2 = 1/d$.

→ 经典 Xavier: $W_{ij} \sim \mathcal{N}(0, 1/d_{\text{in}})$ 或 $U(-\sqrt{6/(d_{\text{in}} + d_{\text{out}})}, \ldots)$.

4.3 He (Kaiming) init - 针对 ReLU

ReLU 半数置 0, $\operatorname{Var}(\mathrm{ReLU}(y)) = \frac12 d \sigma^2 \operatorname{Var}(x)$ ⇒ 修 $\sigma^2 = 2/d_{\text{in}}$.

PyTorch:

nn.init.kaiming_normal_(W, mode='fan_in', nonlinearity='relu')

4.4 Transformer 默认 $N(0, 0.02)$

原版 Vaswani 2017 用 $\sigma = 0.02$ 对所有权重 (除 embeddings, LayerNorm $\gamma = 1, \beta = 0$). 实测用 $d^{-1/2}$ scaling 或 He 等价.

4.5 μP (Yang 2022) - 让超参免调

最大痛点: pretrain 时一组 hyperparams 在 small model 上调好, 放到 70B 上 LR 不通适; μP (Maximal Update Parameterization) 设 init scale 与 lr 让"small → huge"不重新搜超参. LLaMA-2 70B 一些训练用 μP 思想.

4.6 Embedding init

  • Embedding 通常 $\sim \mathcal{N}(0, 1/d)$, 让 token表示初始内积分布方免饱和.
  • 输出 logits 一般 transpose embedding (weight tying), 减少 params + 改善训练.

五、训练稳定: 诊断 + 修复

5.1 健康的 loss 曲线

  • Pretrain LM: 早期 loss 急降 (perplexity 从 30 → ~5); 中段慢降; 后期 plateau.
  • SFT: loss 起步 low (因 instruction format → few-shot fine-tune), 微降.
  • 值得警惕: loss NaN 突跳 / loss 不降 / loss discrimination.

5.2 常见 NaN 排查

现象可能原因修复
一启动就 NaNinit scale 太大 / softmax 没 log-sum-exp降 init $\sigma$ / 用 F.cross_entropy
Warmup 后 NaNlr 跳太大加 warmup; 减少 base_lr
跨若干步偶 NaNbf16 underflow / 数据混入 nan检查 data; 切 fp32-bf16 混合
Loss 突跳几百gradient clip 没开 / step 过大clip 1.0; 降 batch 或 lr
序列长升 NaNRoPE 复频 overflow / KV cache quant errorpatch position id, 用 fp32

5.3 梯度裁剪

实际 LLM 训练必开:

torch.nn.utils.clip_grad_norm_(model.parameters(), max_norm=1.0)

按全局 $L_2$ 范数裁剪: $g \leftarrow g \cdot \min(1, \tau / |g|)$. 防 batch 异常梯度爆炸.

5.4 梯度累积

显存不足 → 用累积模拟大 batch:

optimizer.zero_grad()
for i, (x, y) in enumerate(loader):
    loss = model(x, y) / accum_steps
    loss.backward()
    if (i + 1) % accum_steps == 0:
        clip_grad_norm_(...)
        optimizer.step()
        optimizer.zero_grad()

效果等同 batch = accum_steps × physical_batch, 但训练时间 $O(\text{accum})$.

5.5 激活 checkpoint (回顾 backprop 章 §5)

trade 显存 vs 计算, 30-50% 显存换约 ~30% 时间. 大模型默认开.

5.6 EMA / SWA

  • EMA: 存一份"权重指数滑动平均"用于评估, 训练用原参数. 提升稳定性.
  • SWA (Stochastic Weight Averaging): 训练后周期负荷平均几 weights, 改善 final test acc.

六、分布式训练 (概览)

详见 TODO 的"大模型训练工程"章节, 这里只点关键事实:

6.1 数据并行 (DP)

每 GPU 持整模型, 各跑自己 batch, 反向后 all-reduce 平均梯度. 限制: 模型必须装下每卡.

6.2 ZeRO (Rajbhandari 2019) 三个阶段

Stage分片显存省
ZeRO-1optimizer states (AdamW: $2n$ extra)4-8×
ZeRO-2+ gradients
ZeRO-3+ model weights到 N_devices 倍

ZeRO-3 让 >100B 训练在 8 × A100 = 640 GB 系统可行.

6.3 Pipeline / Tensor 并行

  • Pipeline: 把层切分到 N 个 GPU, 跑 micro-batch + 1F1B schedule.
  • Tensor: 在某一层内把矩阵乘切分 (列切 / 行切); Megatron-LM 经典.
  • Sequence Parallel: 把 seq 维切成多 GPU 共跑 attention; Flash+长 context 共同用.

Megatron-style (3D parallel): 数据并行 + 流水线 + 张量并行组合, 大模型 pretrain 通用.


七、与第零部分的接口

  • §1-2 优化器 ⇐ 第零部分微积分与优化 §5 (一阶优化器谱系), §3 (Newton/Hessian).
  • §3 warmup ⇐ 第零部分微积分 §3 Hessian (初期二阶矩估计不稳), 概率 §2 方差累积慢的 variance of sample mean.
  • §4 init ⇐ 第零部分线代 §5 范数 + 概率 §2 variance of linear combination.
  • §5 NaN 排查 ⇐ 第零部分概率 §1 离散 / 连续与 fp 数值表示.
  • §6 ZeRO ⇐ 与数学关系浅; 与 OS 并发 / 网络带宽 trade-off 更深.

八、最小可跑训练循环 (PyTorch)

import torch
import torch.nn as nn
import math

def train_step(model, batch, optimizer, scheduler, accum_steps=1, max_grad=1.0):
    model.train()
    x, y = batch
    with torch.autocast(device_type='cuda', dtype=torch.bfloat16):
        logits = model(x)
        loss = nn.functional.cross_entropy(logits.view(-1, logits.size(-1)), y.view(-1),
                                            ignore_index=-1) / accum_steps
    loss.backward()
    if ((scheduler.steps + 1) % accum_steps == 0):
        torch.nn.utils.clip_grad_norm_(model.parameters(), max_grad)
        optimizer.step()
        scheduler.step()
        optimizer.zero_grad(set_to_none=True)
    return loss.item() * accum_steps

class CosineWarmup:
    def __init__(self, optimizer, warmup, total, base, min_lr=0.0):
        self.opt, self.warmup, self.total = optimizer, warmup, total
        self.base, self.min_lr = base, min_lr; self.steps = 0
    def step(self):
        self.steps += 1
        if self.steps < self.warmup:
            lr = self.base * self.steps / self.warmup
        else:
            p = (self.steps - self.warmup) / (self.total - self.warmup)
            p = min(max(p, 0.0), 1.0)
            lr = self.min_lr + 0.5 * (self.base - self.min_lr) * (1 + math.cos(math.pi * p))
        for g in self.opt.param_groups: g['lr'] = lr

九、结束 + 速查表

tip

一页快速唤回:

  • AdamW: $m, v$ 二阶矩 + bias correction + 解耦 weight decay; LLM 默认.
  • Sofia/Muon: 近期研究方向, 尚未取替 AdamW.
  • Warmup: 初期二阶矩估计未稳, 必须避免大 step; Pre-LN 可短 warmup.
  • Cosine + warmup + decay-to-min 是 Llama 系列默认配方.
  • init: $\sigma^2 = 1/d_{\text{in}}$ (Xavier) for $\tanh/\sigma$; $2/d_{\text{in}}$ (He) for ReLU; Transformer $N(0, 0.02)$ or He 等价.
  • LayerNorm (Pre-LN): 现代默认, 防深 stack 梯消失.
  • clip_grad_norm 1.0 几乎必开.
  • accumulation: 显存不足时模拟大 batch.
  • Checkpoint: trade 计算换激活显存.
  • NaN: 查 init / softmax 数值 / RoPE / bf16 / 数据.
  • ZeRO-3: >100B pretrain 的标配.

下一篇: 5. Generative Models: VAE & ELBO / 扩散模型 / AR 采样 / speculative decoding.

5. Generative Models: VAE & ELBO / 扩散模型 / AR 采样 / speculative decoding

TL;DR

"生成模型"是把概率分布 $p(x)$ 当学习目标的一类模型. 直接学高维 $p(x)$ 不估计动, 所以四套主流方案分别走不同路:

  1. Autoregressive (AR) / GPT 风 — $p(x) = \prod_t p(x_t | x_{<t})$.
  2. Flow / Normalizing Flow — 用可逆变换把简单高斯推向复杂数据分布.
  3. VAE — 引入隐变量 $z$, 最大化 ELBO (等价最小化 reverse-KL).
  4. 扩散模型 (Diffusion) — 加噪声 + 学去噪; ELBO 推到 Gaussian NLL 简化为 $(\epsilon - \epsilon_\theta)^2$.

本章把这四类原理 + ELBO 数学一气讲透, 并补上采样加速工程: KV cache、temperature/top-k/top-p、speculative decoding.

读完应能: 读 VAE 原 paper (Kingma 2013) / DDPM (Ho 2020) / Speculative Decoding (Leviathan 2022) 不再卡数学, 在 NumPy 上写出最简 VAE 与扩散训练循环.


一、统一视角: 生成模型 = 概率分布拟合

1.1 真实目标

数据集 ${x_i}{i=1}^n$ 视为某未知分布 $p{\text{data}}(x)$ 的样本. 训练目标:

$$ \arg\max_\theta \mathbb{E}{x \sim \mathcal{D}} [\log p\theta(x)] $$

最大似然, 回顾第零部分概率 §5.

1.2 为什么 MLE 不够?

对复杂数据 (图像 / 文本), 直接定义 $p_\theta(x)$ 并求它是难事. 解决方案:

  • AR: 把 $p(x)$ 分解为 $\prod p(x_t | x_{<t})$, 每一项可参数化为神经网络输出.
  • Flow: 通过可逆变化把 $x \sim p_\theta \Leftrightarrow z \sim \mathcal{N}(0, I)$, 用 change-of-variables.
  • VAE: 引隐变量 $z$, 学一个 encoder $q_\phi(z|x)$ 与 decoder $p_\theta(x|z)$, 联合似然 = ELBO.
  • Diffusion: 让 $z$ 有层级意义 ($x_0, x_1, \ldots, x_T$ 渐噪化), encoder 是固定 forward process, decoder 是学 reverse process.

二、Autoregressive (AR) 采样

2.1 概率链

$$ p_\theta(x_{1:T}) = \prod_{t=1}^{T} p_\theta(x_t | x_{<t}) $$

每项参数化 = transformer decoder 的下一个 token 分布; 损失 = next-token CE (回顾 §3 Transformer).

2.2 与 NLL 的等价

每步的负对数似然 = $\log V - \log p_\theta(x_t | x_{<t})$. perplexity = $\exp(\mathrm{avgNLL})$, 一个常报指标.

2.3 采样

def sample(model, prompt, n=64, temperature=1.0, top_p=0.9):
    ids = list(prompt)
    for _ in range(n):
        logits = model(ids)            # 取 last position logits
        logits = logits[-1] / temperature
        # top-p / nucleus
        sorted_l = np.sort(logits)[::-1]; cumprobs = np.cumsum(softmax(sorted_l))
        cutoff = np.searchsorted(cumprobs, top_p)
        kept = sorted_l[:cutoff + 1]
        probs = softmax(kept); next_id = np.random.choice(kept.shape[0], p=probs)
        ids.append(next_id)
    return ids

2.4 采样超参的数学含义

超参行为直觉
$T \to 0$argmax贪心 / 确定输出
$T = 1$原分布真样本
$T \to \infty$均匀创造性最满
Top-k留 k 个最高 logits 余归一限制样本在常见 token
Top-p (nucleus)累积概率到 p 的最少 token动态阈值

tip

经验上 LLM 任务: $T = 0.7 \sim 1.0$, top-p = 0.9 ~ 0.95, 适合开放式对话; 代码生成更低温 $T = 0.1 \sim 0.4$. 不随意把 $T$ 提高 → 让 LLM 像随机 token generator.

2.5 KV cache: 自回归推理加速

L 步推理: 第 $t$ 步每将 $x_t$ 与所有 $x_{<t}$ 算 attention, $k_t, v_t$ 不变 → 缓存 KV 矩阵 仅计算新一步.

显存: 一卡 LLM 推理主要被 KV 占 (而非权重), 故 PagedAttention / MLA / 量化 KV 成工业核心.


三、Flow / Normalizing Flow (概览)

3.1 Change of variables

设 $f: \mathbb{R}^d \to \mathbb{R}^d$ 可逆, $z \sim \mathcal{N}(0, I)$, $x = f(z)$. 则:

$$ p_X(x) = p_Z(f^{-1}(x)) \cdot \left| \det \frac{\partial f^{-1}}{\partial x} \right| = \frac{p_Z(z)}{|\det J_f|} $$

3.2 训练

$$ \log p_X(x) = \log p_Z(f^{-1}(x)) - \sum_i \log s_i $$

其中 $s_i$ 是各 step 的雅可比行列式的 log abs. 需要 $f$ 形状让雅可比 close-form 易算, 例如:

  • NICE / RealNVP: affine coupling layer.
  • Glow: invertible 1×1 conv + actnorm + affine coupling.

3.3 ML 中位置

不如 VAE / 扩散主流; 但在密度估计 / audio (WaveGlow) / 概率科普文中常见. 第零部分线代 §2 SVD/§5 行列式工具.


四、VAE: ELBO = reverse KL

4.1 模型

设隐变量 $z$, 含 $p_\theta(x, z) = p(z) p_\theta(x|z)$. 真实后验 $p_\theta(z | x)$ 不可积, 用近似 $q_\phi(z | x)$:

$$ \log p_\theta(x) = \log \int p_\theta(x, z) , dz $$

Jensen 不等式 + 加 form:

$$ \log p_\theta(x) = \mathbb{E}{z \sim q\phi(z|x)} \left[ \log \frac{p_\theta(x, z)}{q_\phi(z|x)} \right] + \mathrm{KL}(q_\phi(z|x) | p_\theta(z|x)) $$

第一项是 ELBO (Evidence Lower Bound), 第二项 $\geq 0$. 最大化 ELBO ⇔ 最小化 $\mathrm{KL}(q_\phi | p_\theta(z|x))$.

ELBO 拆开:

$$ \mathcal{L}{\text{ELBO}} = \mathbb{E}{z \sim q_\phi(z|x)} \big[\log p_\theta(x|z)\big] - \mathrm{KL}\big(q_\phi(z|x) ,|, p(z)\big) $$

  • 第一项 = 重建项 (decoder likelihood).
  • 第二项 = KL 正则项 (encoder 不偏离 prior 多少).

note

这就是 VAE 训练目标: max ELBO. 第零部分概率 §7 KL 不对称很关键 — 通过 reverse-KL ($q | p$) 让 $q$ 倾向 mode-seeking, 但在多模态真实后验下会缺一些质量.

4.2 重参数技巧 (reparameterization)

直接采 $z \sim \mathcal{N}(\mu, \sigma^2)$ 不可反传 (随机节点). 改写:

$$ z = \mu + \sigma \odot \epsilon, \quad \epsilon \sim \mathcal{N}(0, I) $$

→ $\mu, \sigma$ 变 deterministic, 蒙特卡洛用同一 batch 多次采样.

4.3 VAE 训练循环 (PyTorch 简版)

import torch
import torch.nn as nn
import torch.nn.functional as F

class VAE(nn.Module):
    def __init__(self, d_x=784, d_z=20, d_h=400):
        super().__init__()
        self.enc = nn.Sequential(nn.Linear(d_x, d_h), nn.ReLU())
        self.fc_mu = nn.Linear(d_h, d_z); self.fc_logvar = nn.Linear(d_h, d_z)
        self.dec = nn.Sequential(nn.Linear(d_z, d_h), nn.ReLU(), nn.Linear(d_h, d_x), nn.Sigmoid())

    def encode(self, x):
        h = self.enc(x); return self.fc_mu(h), self.fc_logvar(h)

    def reparam(self, mu, logvar):
        std = (0.5 * logvar).exp()
        return mu + std * torch.randn_like(std)

    def decode(self, z): return self.dec(z)

    def forward(self, x):
        mu, logvar = self.encode(x); z = self.reparam(mu, logvar)
        return self.decode(z), mu, logvar

def vae_loss(x_recon, x, mu, logvar):
    BCE = F.binary_cross_entropy(x_recon, x, reduction='sum')     # -E[log p(x|z)]
    KLD = -0.5 * torch.sum(1 + logvar - mu.pow(2) - logvar.exp())  # KL(N(μ,σ²) || N(0, 1))
    return BCE + KLD

tip

VAE 的"模糊"问题 — 重建项用 BCE/MSE 对应 Bernoulli/Gaussian decoder; 模糊因为高斯 decoder 平均多个模态. 加 GAN 判别器 / 改输出分布变 mixture / 改扩散走法解决.

4.4 与第零部分接口

  • ELBO = KL 反向最小化, 等价概率 §7.4 交叉熵 = 熵 + KL, 微积分 §6.3 信息几何 KL 局部 = Fisher 矩阵二次型.

五、扩散模型 (DDPM)

5.1 核心思想

把数据 $x_0$ 在 $T$ 步里逐步加高斯噪声:

$$ q(x_t | x_{t-1}) = \mathcal{N}(x_t; \sqrt{1 - \beta_t} x_{t-1}, \beta_t I) $$

联合的有 closed-form:

$$ q(x_t | x_0) = \mathcal{N}(x_t; \sqrt{\bar\alpha_t} x_0, (1 - \bar\alpha_t) I), \quad \bar\alpha_t = \prod_{s=1}^t (1 - \beta_s) $$

→ 任意 $t$ 可从 $x_0$ 一步采样 (训时常用).

5.2 反向过程 (用网络学)

$$ p_\theta(x_{t-1} | x_t) = \mathcal{N}(x_{t-1}; \mu_\theta(x_t, t), \Sigma_\theta(x_t, t)) $$

5.3 ELBO 推到简化形式

DDPM 原 paper 推导完整 ELBO, 设 $\Sigma_\theta = \beta_t I$ 固定得到项:

$$ \mathcal{L}_{t-1} = \mathbb{E}q \left[ \frac{\beta_t^2}{2 \sigma_t^2 \alpha_t \beta_t} |\hat\mu\theta(x_t, t) - \mu_q(x_t, x_0)|^2 \right] $$

进一步 reparameterize 让网络预测"原始噪声" $\epsilon$ 而非 $\mu$:

$$ \mathcal{L}t^{\text{simple}} = \mathbb{E}{x_0, \epsilon, t} \big| \epsilon - \epsilon_\theta(\sqrt{\bar\alpha_t} x_0 + \sqrt{1 - \bar\alpha_t} \epsilon, t) \big|^2 $$

note

工业训练: 输入网络 $x_t = \sqrt{\bar\alpha_t} x_0 + \sqrt{1 - \bar\alpha_t} \epsilon$ (前闭式), 预测噪声 $\epsilon$ 与真 $\epsilon$ 做 MSE. 这就是为什么所有"扩散训练循环"看起来这么朴素.

5.4 训练循环 (PyTorch 简版)

import torch.nn as nn

class Diffusion(nn.Module):
    def __init__(self, model, T=1000, betas=None):
        super().__init__()
        self.model = model                      # ε_θ(x_t, t)
        self.T = T
        if betas is None:
            betas = torch.linspace(1e-4, 0.02, T)
        alphas = 1.0 - betas
        self.register_buffer('alphas_cumprod', torch.cumprod(alphas, 0))

    def forward_loss(self, x0):
        B = x0.shape[0]
        t = torch.randint(0, self.T, (B,), device=x0.device)
        eps = torch.randn_like(x0)
        a_bar = self.alphas_cumprod[t].view(B, 1, 1, 1)
        x_t = a_bar.sqrt() * x0 + (1 - a_bar).sqrt() * eps
        eps_pred = self.model(x_t, t)
        return nn.functional.mse_loss(eps_pred, eps)

5.5 采样: 慢的分步去噪

@torch.no_grad()
def sample(self, shape):
    x = torch.randn(shape)
    for t in reversed(range(self.T)):
        eps_pred = self.model(x, torch.full((shape[0],), t))
        alpha = self.alphas[t]; alpha_bar = self.alphas_cumprod[t]
        x0_pred = (x - (1 - alpha_bar).sqrt() * eps_pred) / alpha_bar.sqrt()
        x = alpha.sqrt() * x0_pred + (1 - alpha).sqrt() * torch.randn_like(x) if t > 0 else x0_pred
    return x

5.6 加速采样 (DDIM, 2020)

DDPM 1000 步采样太慢, DDIM (Song 2021) 用非 Markov 跳步采样 50 步质量近似. 进一步:

  • DPM-Solver (Lu 2022): 10-25 步高质量采样.
  • Consistency Models (Song 2023): 1 步采样.
  • Latent Diffusion (Stable Diffusion 2022): 在 VAE latent 上扩散, 而非像素 → 大幅省算工程盈利.

5.7 与第零部分接口

  • 5.1 前向过程 ⇐ 概率 §3.2 连续分布高斯家族.
  • 5.3 ELBO 简化 ⇐ 概率 §5.5 + §7.2 KL + 信息论 §1 / 熵链式.
  • 5.6 DDIM / DPM-Solver 等 ⇐ 微积分 §1 数值方法 (ODE 求积).

六、Speculative Decoding (LLM 推理加速)

6.1 动机

LLM 生成时每 token 一次 forward pass, GPU 利用率低. 用一个草稿小模型先猜 $k$ 个 token, 再用大模型一次 forward 验证.

6.2 算法

  1. draft model $M_d$ 自回归生成 $k$ 个候选 token $x_{1:k}$.
  2. target model $M_t$ 用 同一 prompt + $k$ token 做一次 forward, 得到各位置分布 $p_{1:k}^t$.
  3. 对每位置 $i$, 比较 $x_i$ 在 $p_i^t$ 与 $p_i^d$ 的概率:
    • 若 $p_i^t(x_i) \geq p_i^d(x_i)$: accept.
    • 若 $p_i^t(x_i) < p_i^d(x_i)$: 以 $\left(1 - \frac{p_i^t(x_i)}{p_i^d(x_i)}\right)$ 概率拒绝, 拒绝则从残余概率 $\propto p_i^t - p_i^d$ 重采一个新 token.
  4. 拒后丢弃后续.

6.3 数学保证

分布与原 target model 严格相同 (rejection sampling correctness). 因此生成不变, 只是更快 (草稿准 → 接受多 → 每 forward 吐多 token).

6.4 工程增益

  • accepted fraction 0.5-0.7 时 2-3× speedup.
  • 与 KV cache 兼容: draft 与 target 共用 KV cache 接口, 同步多条 path.
  • 与 Medusa / EAGLE / Lookahead 等扩展思路是不同分支预测方案.

warning

speculative decoding 的核心是概率分布一致性而非单纯接受率. 工程上仔细处理 torch.distribution.Independent、rejection 不能简化成 argmax == argmax, 否则会漫游偏离原分布.


七、统一对比

模型训练目标直接 / 间接模式覆盖
AR (GPT)next-token NLL直接 MLEmode-covering (但通过 CE 隐式规约)
Flow$\log p_X = \log p_Z - \log |\det J|$直接 MLE精确匹配
VAEELBO max = reverse-KL min间接 (下界)mode-seeking (可能漏一些)
Diffusion$|\epsilon - \epsilon_\theta|^2$ (ELBO 简化)间接 (下界, 等价 reverse-KL)等价 NLL, 全模式
GANminimax adversarial间接implicit, 隐式匹配

note

VAE 与扩散同属"反向 KL 最大化 ELBO"; 扩散其实是 Hierarchical VAE 的连续时间极限 (Score-based 视角). 把 VAE 弄懂以后, 扩散只是"用更大量级别的噪声扰动 + 设 encoder 完全固定"而已, 不需新数学.


八、与第零部分的全面接口

本部分第零部分章节
1 MLE 视角概率 §5
2 AR / next-token CE概率 §3 + §7 + 微积分 §2.3 softmax
3 Flow / change of var线代 §2 行列式 + 微积分 §2 雅可比
4 VAE / ELBO / reverse KL概率 §7 (KL 不对称, 交叉熵 = H + KL)
5 Diffusion概率 §3 + §5.5 + 信息论 (熵链)
6 Speculative decoding概率 §3.6 (acceptance sampling) + 概率 §4 (Bayes 全概率)

九、结束 + 速查表

tip

一页快速唤回:

  • 生成模型 = 拟合 $p_\theta(x)$ 的 MLE; 直接 AR / Flow, 间接 VAE / 扩散 (ELBO 上界).
  • AR 训练: $-\sum \log p(x_t | x_{<t})$; per-token CE; 反向 $s - \text{onehot}$.
  • Flow: change-of-variables, $\log p_X = \log p_Z - \log |\det J|$.
  • VAE: $\mathcal{L} = \mathbb{E}_q[\log p(x|z)] - \mathrm{KL}(q(z|x) | p(z))$; reparam trick; mode-seeking.
  • Diffusion: ELBO 推到 $|\epsilon - \epsilon_\theta(x_t, t)|^2$ 简化; forward 有闭式采样到任意 $t$.
  • DDIM: 非马尔可跳步; DPM-Solver 10-25 步; Consistency Models 1 步.
  • 采样超参: $T$ 控创造; top-k/p 留高概率 mass.
  • KV cache: 推理时省重算, 长上下文显存主占.
  • Speculative decoding: 草稿 + target 验证, 严格保持原分布, 2-3× 速度.

回主目录: 第十二部分 · 人工智能与机器学习 README. 下一篇系统正文: 第十三部分 · 元抽象: 跨章节大主题.

6. 经典机器学习与树模型: 决策树 / GBDT / XGBoost / SVM

TL;DR

深度学习之前的"机器学习主干"——决策树与集成模型(GBDT/XGBoost)——今天依然是表格数据(结构化特征)上的事实标准:Kaggle 表格赛题、信贷风控、广告 CTR、推荐排序的第一梯队几乎都是树模型而非神经网络。原因一句话:树模型对特征尺度不敏感、天然处理缺失值和类别特征、可解释、训练快。这一章把决策树(ID3/C4.5/CART)、集成(Bagging/RandomForest、Boosting/GBDT)、XGBoost 的目标函数推导、SVM 的对偶与核技巧讲透,并给出"表格数据优先树模型、图像/文本/序列优先深度模型"的工程判断。

读完应能:

  1. 手推 CART 回归树的分裂准则(MSE 下降)与分类树的基尼/熵分裂。
  2. 说清 Bagging 为什么能降方差、Boosting 为什么能降偏差,以及随机森林 vs GBDT 的分裂方式差异。
  3. 从"负梯度拟合"出发理解 GBDT,并看懂 XGBoost 目标函数的二阶泰勒展开与正则项。
  4. 写出 SVM 的原始问题、对偶形式、KKT 条件直觉,知道核技巧把特征映射藏在哪里。
  5. 给出表格数据的建模选型决策树:什么时候用 LR/树/XGBoost/深度模型,以及 GBDT 与深度模型的互补用法。

一、回顾: 有监督学习的形式

数据 $(x_i, y_i)$,模型 $f$,损失 $L$,经验风险最小化:

$$\hat{f} = \arg\min_f \frac{1}{N} \sum_{i=1}^{N} L(y_i, f(x_i)) + \lambda \cdot \Omega(f)$$

树模型和神经网络都在这同一个框架里,区别在函数空间 $f$ 的表示方式

  • 神经网络:$f$ 是参数化的可微复合函数(反向传播梯度下降);
  • 决策树:$f$ 是分段常数/分段线性函数(不可微,用贪心分裂代替梯度);
  • GBDT/XGBoost:在"树的加法集合"上做函数空间里的梯度下降

二、决策树

2.1 分裂准则

树递归地把特征空间切成矩形区域,每个叶子给一个预测值。分裂时选"信息增益最大"的特征/阈值:

准则公式特点
信息增益(ID3)$\text{Gain} = H(D) - \sum_v \frac{|D_v|}{|D|} H(D_v)$偏向取值多的特征
增益率(C4.5)$\frac{\text{Gain}}{\text{SplitInfo}}$校正偏置
基尼指数(CART)$\text{Gini} = 1 - \sum_k p_k^2$分类默认;二分
MSE 下降(CART 回归)$\sum (y_i-\bar y)^2 - \sum_{v}(y_i^v-\bar y^v)^2$回归树分裂

CART 每次只做二叉分裂,对连续特征枚举候选阈值 $O(n \log n)$(排序后取相邻中点),这也是"树对特征尺度不敏感"的原因——它只比大小,不做距离运算。

2.2 剪枝

完全长成的树过拟合。两类做法:

  • 预剪枝:分裂前判断(叶子样本数阈值、增益阈值、深度限制);
  • 后剪枝(CCP,CART 用):自底向上合并叶子,用损失 + 叶子数惩罚比较:

$$\text{Cost} = \sum_{leaf} \text{impurity} + \alpha \cdot #\text{leaves}$$

$\alpha$ 越大树越小——XGBoost 的正则项 $\gamma T$ 正是这个思想的在线版本。

note

单棵决策树方差大(训练集抖一点结构全变),所以工业上几乎不用单树,而是它的集成。

三、集成: Bagging 降方差, Boosting 降偏差

3.1 Bagging 与随机森林

对训练集有放回抽样(bootstrap)得到 M 份样本,分别训练 M 棵(深)树,预测取平均/投票:

$$\text{Var}(\text{average}) \approx \frac{\rho \sigma^2 + (1-\rho)\sigma^2/M}{M}$$

直觉:基模型方差 $\sigma^2$ 被平均掉 $M$ 倍,但模型间相关 $\rho$ 越高收益越低。随机森林在每次分裂随机选 $m$ 个特征($m \ll d$),把 $\rho$ 压低——这是随机森林和 Bagging 的本质区别。

3.2 Boosting: AdaBoost → GBDT

Boosting 序列地训练弱模型,每轮给上一个模型分错的样本加权重。AdaBoost 用指数损失给出权重更新闭式解。GBDT 把思路推广:用损失函数在当前模型的负梯度当"伪残差"来拟合新树

$$\tilde{y}i = -\left.\frac{\partial L(y_i, f(x_i))}{\partial f(x_i)}\right|{f=f_{t-1}}$$

新树 $h_t$ 拟合伪残差,然后 $f_t = f_{t-1} + \eta h_t$($\eta$ 学习率)。

这是"梯度下降在函数空间"的精确对应:参数空间里每步沿负梯度走,函数空间里每步用一棵树逼近负梯度。

3.3 XGBoost: 把 Boosting 写成可优化的目标

XGBoost 不做近似,直接最小化带正则的逐轮目标:

$$\mathcal{L}^{(t)} = \sum_{i=1}^{N} L(y_i, \hat y_i^{(t-1)} + f_t(x_i)) + \Omega(f_t), \quad \Omega(f) = \gamma T + \frac{1}{2}\lambda \sum_{j=1}^{T} w_j^2$$

对损失做二阶泰勒展开($g_i$ 一阶导、$h_i$ 二阶导),忽略常数后每个叶子 $j$ 的最优权重和分裂增益有闭式解:

$$w_j^* = -\frac{\sum_{i \in I_j} g_i}{\sum_{i \in I_j} h_i + \lambda}, \qquad \text{Gain} = \frac{1}{2}\left[\frac{G_L^2}{H_L+\lambda} + \frac{G_R^2}{H_R+\lambda} - \frac{(G_L+G_R)^2}{H_L+H_R+\lambda}\right] - \gamma$$

其中 $G_j = \sum_{i\in I_j} g_i, H_j = \sum_{i\in I_j} h_i$。

由此推出 XGBoost 的工程优势:

  • 二阶信息:比只看负梯度的一阶方法收敛更准;
  • 预排序 + 分位点近似:对连续特征排序后做加权分位数,支持缺失值学习默认方向;
  • 正则项 $\gamma,\lambda$:直接控制叶子数和权重幅度,天然防过拟合;
  • 并行:分裂候选按特征并行、直方图化(LightGBM)进一步降内存。

warning

树模型的"并行"是特征/分裂枚举并行,不是样本并行训练多棵树(每棵树依赖上一棵)。想并行跑多棵树用随机森林,想省内存用 LightGBM 直方图 + GOSS,想省显存/大规模用 CatBoost 的对称树。三者是 GBDT 系的不同工程取舍,不是不同算法族。

四、SVM: 最大间隔 + 核技巧

线性 SVM 找最大间隔超平面:

$$\min_{w,b} \frac{1}{2}|w|^2 + C\sum_i \xi_i, \quad \text{s.t. } y_i(w^\top x_i + b) \ge 1 - \xi_i, \xi_i \ge 0$$

对偶形式(拉格朗日)后只依赖内积

$$\max_\alpha \sum_i \alpha_i - \frac{1}{2}\sum_{i,j}\alpha_i\alpha_j y_i y_j \langle x_i, x_j \rangle, \quad 0 \le \alpha_i \le C$$

于是把内积换成核函数 $K(x_i,x_j) = \langle \phi(x_i), \phi(x_j) \rangle$ 就完成了非线性映射——核技巧的价值是:不需要显式算高维特征 $\phi(x)$,只要内积可算。常用核:RBF $K = e^{-\gamma|x_i-x_j|^2}$、多项式核、线性核。

关键直觉:

  • 决策边界只由支持向量($\alpha_i > 0$ 的点)决定——稀疏性;
  • $C$ 控制软间隔惩罚(大 $C$ 严、过拟合风险高;小 $C$ 宽、欠拟合);
  • 核函数本身要满足 Mercer 条件(正半定),否则对偶不收敛;
  • RBF 核的 $\gamma$ 大 → 边界崎岖 → 过拟合。

SVM 在表格小数据、低维强特征上依然能打,但工程上已被 GBDT 系取代为主要默认;它今天的主要舞台是核方法理论、Kernel 嵌入、以及少数"线性可分+解释边界"场景。

五、工程选型: 表格数据用什么

数据形态默认选择为什么
表格 / 结构化 / 稀疏特征XGBoost/LightGBM尺度不敏感、缺省值原生、快、可解释
超高维稀疏(CTR 类)LR/FM + 深度(Wide&Deep)线性模型可在线训练、可解释权重
图像 / 音频 / 文本CNN / Transformer局部性与序列结构需要深度特征
小样本 + 强先验SVM / 简单 LR方差控制,防过拟合
混合(特征 + 行为序列)树模型提特征 → 深度模型业界主流:GBDT 叶子作为深度模型特征

工程纪律:

  1. 先 baseline 再调参:逻辑回归/单棵 CART 是 0 号选手,GBDT 超参数(树数、深度、学习率、min_child_weight)用早停选;
  2. 类别特征:GBDT 原生支持/目标编码,别 one-hot 爆维度;
  3. 特征尺度:树模型不需要归一化;LR/SVM/深度模型需要;
  4. 解释性:SHAP 值 = 特征归因(树模型自带快路径)。

六、一页速查

决策树:  贪心分裂(CART=基尼/MSE) + 剪枝(CCP), 单树方差大
Bagging: bootstrap 抽样降方差; 随机森林再随机选特征降相关
Boosting: 每轮拟合伪残差(负梯度); XGBoost 二阶泰勒 + 正则闭式解
SVM:     最大间隔 + 对偶只留内积 + 核技巧(不需显式高维)
选型:    表格→GBDT系 / 超高维稀疏→LR/FM / 图像文本→深度
工程:    早停选树数 / SHAP 解释 / 树模型免归一化

下一篇: 8. 强化学习与 RLHF

7. Tokenizer 与 Embedding: BPE / WordPiece / SentencePiece / 位置编码

TL;DR

大模型"第一公里"和"最后一公里"都是字符串 ↔ 向量:文本进来要先切成 token 再查 embedding 表,模型输出 logits 又要映射回文本。tokenizer 决定了词典怎么建、词怎么切、OOV(未登录词)怎么兜底——它的质量直接影响模型能表达什么。这一章把 tokenizer 三剑客(BPE / WordPiece / SentencePiece)+ 位置编码三大家族(Learned / ALiBi / RoPE)讲到能自己写一个、能看懂任何开源模型配置。

读完应能:

  1. 手工跑一遍 BPE 合并,说出 vocab size、训练语料、单 token 最长切分。
  2. 区分 char-level / word-level / subword-level 三档,说明为什么大模型全用 subword。
  3. 给一个 tokenizer_config.json 能判断它是什么算法、special token 有哪些、bos/eos/pad/unk 的 id。
  4. 解释 RoPE 的复数旋转几何、为什么能外推、ALiBi 为什么只需加 bias 不占参数。
  5. 会算"一个 batch 的 token 数 → 显存 / 训练吞吐"的粗估。

一、为什么需要 tokenizer

1.1 模型吃的是整数 id,不是字符

Transformer 的输入是 [B, T, d] 的 embedding 张量,T 是 token 数。所以原始文本必须先切成词元(token),每个 token 查 embedding 表([vocab_size, d])拿向量。

1.2 三个层级

层级切法词典大小例子问题
char-level按字符~100-200h-e-l-l-o序列太长(无词义聚合)
word-level按空格/标点10⁵~10⁶hello worldOOV 严重;变形多
subword-level字节/子词合并10⁴~10⁵helloing##ed平衡;主流选择

warning

中文没有空格分词,word-level 直接失效。所以中文模型(BPE 跑在字节上)天然用 subword,每个汉字常被切成 1-2 个 byte-token。

1.3 三个性质

一个合格的 tokenizer 要:

  • 可逆:能 decode(id → 文本),能 encode(文本 → ids),不丢信息。
  • 定长友好:切分后平均 token/字 稳定(决定序列长度预算)。
  • 子词覆盖:新词可用已有子词拼出来(OOV 兜底靠 UNK 或字节 fallback)。

二、BPE (Byte Pair Encoding)

2.1 思路

字符表出发,反复把"出现频率最高的相邻对"合并成一个新符号,直到达到目标 vocab size。名字来自 Gage 1994 的"字节对压缩"。

2.2 算法

1. 把语料拆成单词(word → 内部字符序列 + 词频)
2. 初始 vocab = 所有字符(+ </w> 词尾标记)
3. 循环直到 vocab 到目标大小:
     统计所有相邻字符对的频次
     选出最高频的一对 (a, b),合并为新符号 ab
     vocab 加入 ab,语料里替换所有 a b → ab
4. 输出 vocab 与 merges 表

关键:BPE 是无监督的,只需频次统计;"要合并到什么粒度"由 vocab size 控制。更大的 vocab → 更长的 token、更少的序列步数,但 embedding 表更大。

2.3 Python 实现(教学版)

from collections import Counter
from typing import List, Tuple, Dict

def get_stats(seqs: List[List[str]]) -> Counter:
    pairs = Counter()
    for s in seqs:
        for a, b in zip(s, s[1:]):
            pairs[(a, b)] += 1
    return pairs

def bpe_corpus(corpus: List[str], num_merges: int, vocab_size_goal: int) -> Tuple[Dict[Tuple[str,str],int], set]:
    # 1. 分词为字符序列(空格用 _ 表示,词尾加 </w>)
    seqs = [list(" ".join(w) + " </w>") for w in corpus]
    vocab = set(ch for s in seqs for ch in s)
    merges: Dict[Tuple[str, str], int] = {}
    # 2. 迭代合并
    for _ in range(num_merges):
        pairs = get_stats(seqs)
        if not pairs: break
        best = max(pairs, key=pairs.get)
        merges[best] = _ + 1
        vocab.add(best[0] + best[1])
        # 3. 替换
        new_seqs = []
        for s in seqs:
            out, i = [], 0
            while i < len(s) - 1:
                if (s[i], s[i+1]) == best:
                    out.append(best[0] + best[1]); i += 2
                else:
                    out.append(s[i]); i += 1
            if i < len(s): out.append(s[i])
            new_seqs.append(out)
        seqs = new_seqs
    return merges, vocab

2.4 GPT 系用的 byte-level BPE

GPT-2 之后用 byte-level BPE(Radford 2019):先按 UTF-8 把文本编码成字节序列(256 个基础字节),再跑 BPE。好处:

  • 所有 Unicode(含 emoji、中日韩)都能无损表示 → 零 UNK
  • vocab 里混有字节 token(如 Ġ 表示空格前缀,Ċ 表示换行)。

这就是为什么 tokenizer 词典里常见 ĠĨĊ 这类怪字符——它们是 UTF-8 字节的可见化。

2.5 一句话评价

优点:简单、快、无监督、可逆。缺点:合并只取频次,不感知语言边界(t h 也会合并),同词变形仍会切碎。


三、WordPiece

3.1 与 BPE 的差别

WordPiece(Schuster & Nakajima 2012,BERT 用)合并准则不是"最高频对",而是最大化合并后语言模型似然增益

$$\text{score}(a, b) = \frac{\text{freq}(ab)}{\text{freq}(a) \cdot \text{freq}(b)}$$

  • 分子:合并后符号频次;分母:两符号单独频次之积。
  • 选 score 最高的对合并 → 更偏向合并"一起出现显著多于随机"的片段。

3.2 与 BPE 对比

BPEWordPiece
合并准则最高频对最高似然比 score
实现复杂度中(需先训 LM 计数)
代表模型GPT 系、LLaMA 系(byte-level)BERT、DistilBERT
特殊标记Ġ 空格前缀## 子词前缀

3.3 应用细节(BERT)

  • 编码时:tokenizer.tokenize("unhappiness")['un', '##happiness']## 表示非词首子词)。
  • decode:去掉 ## 前缀拼接。
  • 词表:30,522(BERT-base),含 [CLS] [SEP] [PAD] [MASK] [UNK] 等 special token。

四、SentencePiece

4.1 为什么再需要一个

BPE/WordPiece 都依赖空格分词——这对中文、日文、泰文等无空格语言是硬伤。SentencePiece(Kudo & Richardson 2018)把 tokenizer 和语言解耦:

  • 直接吃原始文本(含标点、空格),内部用 Unicode 码点/字节。
  • 空格本身作为一个字符 _ 参与 BPE/Unigram 合并,训练后 decode 再把 _ 还原为空格。
  • 自带 Unigram 算法(用 EM 学 subword 概率分布,可做采样训练)。

4.2 Unigram 语言模型

Unigram(Kudo 2018)不是"贪心合并",而是对每个 subword 学一个概率,句子的概率是所有切分的概率和:

$$P(\text{sentence}) = \sum_{\text{seg} \in S(\text{sentence})} \prod_{w \in \text{seg}} P(w)$$

训练用 EM:先给个超集词表,反复剔除"贡献最小的" subword,直到目标大小。优势:可做 subword 正则(训练时按概率采样不同切分)。

4.3 代表模型

  • T5 / ALBERT / XLM-RoBERTa / LLaMA 系(sentencepiece-unigram 或 -bpe)
  • 中文 T5 / mT5 直接跑原始文本,不预分词。

4.4 对比总结

算法语言假设训练主要使用者
BPE (byte-level)无(靠 UTF-8 字节)贪心频次GPT-2/3/4, LLaMA, Qwen, DeepSeek
WordPiece需空格似然比BERT, DistilBERT
SentencePiece无(空格作为字符)BPE 或 Unigram+EMT5, mT5, LLaMA (sp), XLM-R

五、Embedding 与特殊 token

5.1 Embedding 表

token id → 向量:E ∈ R^{V × d}V = vocab size,d = hidden dim。embedding 行是可学习参数,初始化为小随机(~N(0, 1/d)),训练中更新。

绑定(weight tying):很多 LM 让输出 logits 层复用 embedding 转置 W_out = E^T,省 V×d 参数、且常提升效果。

5.2 special token 三件套

token作用注意
[BOS] / <s> / [CLS]序列开头标记部分模型不用(纯 decoder 可从任意处开始)
[EOS] / </s> / <eos>序列结束标记生成停止条件之一
[PAD] / <pad>batch 内对齐训练时 attention mask 掉
[UNK] / <unk>未知 token现代模型尽量零 UNK
[SEP] / [MASK]BERT 专用双句分隔 / 掩码

id 稳定:special token 的 id 在词典开头(0-2 常见),不可随意重排,否则模型权重错位。

5.3 中文的 tokenizer 实践

  • 中文无空格,常见方案:
    • byte-level BPE:每个汉字 ≈ 2-3 个 byte-token(压缩率低但零 UNK)。
    • 加字表:把高频单字/双字作为 pre-seg 再跑 BPE。
    • PaddleNLP / 哈工大方案:jieba 预分词 + BPE。
  • LLaMA/千问/Qwen 直接字节级,对中文按 UTF-8 字节切,所以中文 token 数略多于字符数。

六、位置编码:让 Transformer 知道顺序

Transformer 无 RNN 的时间结构,attention 本身置换不变。必须显式注入位置信息。三大家族:

6.1 Learned Positional Embedding(BERT)

可学参数 P ∈ R^{T_max × d}pos 行与 token embedding 相加。

# BERT 风格: x = token_emb + pos_emb
class LearnedPE(nn.Module):
    def __init__(self, d, max_len=512):
        super().__init__()
        self.pe = nn.Parameter(torch.zeros(1, max_len, d))  # 可学
        nn.init.normal_(self.pe, std=0.02)
    def forward(self, x):  # x: [B, T, d]
        return x + self.pe[:, :x.size(1)]

缺点max_len 硬上限(BERT=512,外推需改表重训);无相对距离几何。

6.2 Sinusoidal(原版 Transformer)

$$PE_{t, 2k} = \sin\left(\frac{t}{10000^{2k/d}}\right),\quad PE_{t, 2k+1} = \cos\left(\frac{t}{10000^{2k/d}}\right)$$

  • 每个维度一个频率 10000^(2k/d),位置 t 由各频率的相位表示。
  • 性质:PE_{t+δ} 可写成 PE_t 的线性组合(旋转)→ 模型隐含学到相对位置。

6.3 RoPE(Rotary Positional Embedding,Su 2021)

现状主流(LLaMA、Qwen、DeepSeek、GPT-NeoX 都用)。

核心:不把位置加到 embedding 上,而是旋转 Q、K 向量——对每对 (q_{2k}, q_{2k+1}) 按位置 t 的角度 θ_k = t / 10000^{2k/d} 做 2D 旋转:

$$R_{\theta_k}(t) = \begin{pmatrix} \cos\theta_k & -\sin\theta_k \ \sin\theta_k & \cos\theta_k \end{pmatrix}, \quad q'{2k} = q{2k}\cos\theta_k - q_{2k+1}\sin\theta_k$$

关键性质

$$\langle R_{\theta_k}(m), q, , R_{\theta_k}(n), k \rangle = \langle q, R_{\theta_k}(n-m), k \rangle$$

点积只依赖相对位置差 (n−m),且旋转是线性、可反向(无额外参数)。→ 长程外推比 learned 好,训练时自然学到相对位置归纳偏置。

外推技巧:超过训练长度时,把频率除以 scale 或用 NTK-aware / YaRN 插值频率,避免位置角过密导致 attention 塌陷。

6.4 ALiBi(Attention with Linear Biases,Press 2021)

完全不学位置编码,直接在 attention score 上加一个线性距离偏置:

$$S_{ij} \xrightarrow{+} S_{ij} - |i - j| \cdot m_h$$

  • m_h 是每 head 的斜率(2^(-8/h) 之类,head 数越深斜率越小)。
  • 零新增参数、推理快、外推极好(训练 1024 可跑到几万)。

缺点:不编码绝对位置,对某些任务(绝对位置敏感)略弱。

6.5 对比表

方案参数相对位置外推代表
Learned PEV 大隐含差(硬上限)BERT
Sinusoidal0可线性表达原版 Transformer
RoPE0显式旋转(+插值更好)LLaMA, Qwen, DeepSeek
ALiBi0bias 近似极好部分 BLOOM 变体

tip

判断一个开源模型能不能上长上下文:先看它用什么位置编码。RoPE + 插值(YaRN/LongRoPE)→ 可扩到 1M;Learned PE → 基本锁死训练长度。


七、实操:读一个 tokenizer_config.json

llama-3 系为例:

{
  "add_bos_token": false,
  "add_eos_token": false,
  "bos_token": "<|begin_of_text|>",
  "eos_token": "<|end_of_text|>",
  "model_max_length": 131072,
  "tokenizer_class": "PreTrainedTokenizerFast",
  "tokenizer_file": "tokenizer.json",
  "unk_token": "<|unk|>"
}

读法:

  • tokenizer_class = PreTrainedTokenizerFast → SentencePiece 或 HF 字节 BPE。
  • bos/eos/unk 都是 <|...|> → LLaMA 风格 special tokens(不是 [CLS])。
  • add_bos_token=false → 纯 decoder,不强制加序列头标记。
  • model_max_length=131072 → 128K 上下文 → 配的是 RoPE + YaRN 外插。
from transformers import AutoTokenizer
tok = AutoTokenizer.from_pretrained("meta-llama/Llama-3.1-8B")
ids = tok.encode("Hello, world!", add_special_tokens=False)
print(ids)                      # [9906, 11, 1917, 0]
print(tok.decode(ids))          # "Hello, world!"
print(len(tok))                 # 128256 (vocab size)

八、结束 + 速查表

tip

一页快速唤回:

  • subword 三剑客:BPE(贪心最高频,GPT/LLaMA 用 byte-level);WordPiece(似然比 score,BERT 用 ##);SentencePiece(空格当字符,支持中文无空格,T5/mT5/LLaMA 用,Unigram 用 EM)。
  • byte-level BPE:UTF-8 字节做底 → 零 UNK、任意 Unicode 无损。
  • embeddingV×d 可学表;weight tying 让输出复用 E^T
  • 位置编码:Learned(BERT,硬上限)、Sinusoidal(原版)、RoPE(旋转 Q/K,点积=相对位置,主流)、ALiBi(加 bias,外推极好)。
  • 看模型能否长上下文:先看位置编码是不是 RoPE + 插值。
  • special token[PAD]/[BOS]/[EOS]/[UNK] id 靠前且不可重排。

下一篇: 8. 强化学习与 RLHF: MDP / Bellman / Q-learning / Policy Gradient / PPO / RLHF.

8. 强化学习与 RLHF: MDP / Bellman / Q-learning / Policy Gradient / PPO / RLHF

TL;DR

强化学习(RL)处理的是"没有正确答案,只有延迟奖励"的学习问题——Agent 在环境里行动、获得反馈、最大化长期回报。RL 有两套并行主线:value-based(学 Q 值,代表 Q-learning / DQN)和 policy-based(直接学策略,代表 Policy Gradient / PPO)。RLHF(基于人类反馈的强化学习)把 RL 用在语言模型上:用人类偏好训练奖励模型,再用 PPO 微调 LM 让输出更符合偏好——ChatGPT 时代的核心对齐技术

读完应能:

  1. 形式化一个 MDP(状态/动作/转移/奖励/折扣),写出 Bellman 方程。
  2. 区分 value-based vs policy-based,说出各自适用场景。
  3. 推导 policy gradient 定理的核心公式,说出 PPO 的 clip 机制为什么稳。
  4. 讲清 RLHF 三步流水线(SFT → Reward Model → PPO)和 GRPO/DPO 为什么是更省事的替代。
  5. 看懂 DeepSeek-R1 / InstructGPT 论文里 RL 相关的数学记号。

一、MDP:RL 的通用语言

1.1 五元组

一个马尔可夫决策过程(MDP)(S, A, P, R, γ)

  • S:状态集合
  • A:动作集合
  • P(s' | s, a):转移概率(马尔可夫性质:只看当前态,不看历史)
  • R(s, a, s'):奖励函数
  • γ ∈ [0, 1]:折扣因子(越远回报越不值钱,保证有限和)

1.2 策略与回报

  • 策略 π(a | s):状态 → 动作的概率分布。
  • 折扣回报:G_t = Σ_{k=0}^∞ γ^k R_{t+k+1}
  • Agent 目标:最大化期望回报 E_π[G_0]

1.3 与第零部分的接口

MDP 是马尔可夫链(概率 §8 随机过程)加了"动作"维度 + 奖励。回顾 Markov 性质、平稳分布(πP = π)在 RL 里反复出现。


二、贝尔曼方程:RL 的"解方程组"

2.1 价值函数

  • 状态价值 V^π(s) = E_π[G_t | S_t = s]:从状态 s 开始,按 π 走,期望总回报。
  • 动作价值 Q^π(s, a) = E_π[G_t | S_t = s, A_t = a]:从 s 执行 a,之后按 π 走。
  • 关系:V^π(s) = Σ_a π(a|s) Q^π(s, a)

2.2 Bellman 方程

把 G_t 拆成"立即奖励 + 后续折扣价值":

$$V^\pi(s) = \sum_{a} \pi(a|s) \sum_{s'} P(s'|s,a) \left[ R(s,a,s') + \gamma V^\pi(s') \right]$$

$$Q^\pi(s,a) = \sum_{s'} P(s'|s,a) \left[ R(s,a,s') + \gamma \sum_{a'} \pi(a'|s') Q^\pi(s',a') \right]$$

最优价值(max 算子替换均值):

$$V^(s) = \max_a \sum_{s'} P(s'|s,a)\left[ R + \gamma V^(s') \right]$$

note

Bellman 方程是"自指"方程——把未来价值写进当前价值。求解方式分三类:DP(已知 P,迭代)、MC(采样估计)、TD(用下一次估计更新,如 Q-learning)。

2.3 动态规划视角

如果转移概率 P 已知,V 迭代 V_{k+1} = T V_k(Bellman backup 算子)收敛到 V*,策略迭代 + 值迭代是标准解法。回顾 DSA 动态规划:最优子结构 + 重叠子问题——Bellman 方程就是 MDP 上的 DP。


三、Value-based 方法:Q-learning

3.1 核心想法

不知道 P,就用采样估计 Q。Q-learning 用 TD 更新:

$$Q(s,a) \leftarrow Q(s,a) + \alpha \left[ r + \gamma \max_{a'} Q(s',a') - Q(s,a) \right]$$

  • 括号里是 TD 误差(真实回报 - 当前估计)。
  • max 让它成为 off-policy(学的是最优策略,即使用 ε-greedy 探索)。

3.2 DQN(Deep Q-Network,2015)

用神经网络近似 Q:Q_θ(s,a) ≈ Q*(s,a)。两大工程技巧:

  1. 经验回放(replay buffer):存 (s,a,r,s'),随机采样训练 → 打破相关性。
  2. 目标网络:用隔 N 步冻结的 Q_θ⁻ 计算 TD target,稳定训练。
# 简化 DQN 更新
for (s, a, r, s_next, done) in batch:
    target = r if done else r + gamma * max_a' Q_target(s_next, a')
    loss = F.mse_loss(Q_online(s)[a], target)   # 只更新选中的动作

3.3 适用与局限

  • 适用:离散动作空间(游戏、棋盘)、值函数好近似。
  • 局限:连续动作空间难做 max;高维视觉尚可,复杂策略表达能力受限。

四、Policy-based 方法:Policy Gradient

4.1 直接学策略

不用 Q,直接参数化策略 π_θ(a|s)(神经网络输出动作分布)。目标:

$$J(\theta) = E_{\tau \sim \pi_\theta}[G_0(\tau)]$$

其中 τ 是一条轨迹(状态-动作序列)。

4.2 Policy Gradient 定理

核心公式(REINFORCE):

$$\nabla_\theta J(\theta) = E_{\tau}\left[ \sum_t \nabla_\theta \log \pi_\theta(a_t | s_t) \cdot G_t \right]$$

直觉:log π 的梯度指向"让这条轨迹更可能"的方向,乘以回报 G_t 加权——高回报轨迹的动作概率上调,低回报下调。

4.3 减小方差:baseline 与 advantage

直接 G_t 方差大。减一个 baseline b(s)(常为 V):

$$\nabla_\theta J(\theta) = E_{\tau}\left[ \sum_t \nabla_\theta \log \pi_\theta(a_t | s_t) \cdot \underbrace{(G_t - V(s_t))}_{\text{advantage } A_t} \right]$$

Advantage A_t = G_t - V(s_t):这条动作比"平均"好多少。这是 actor-critic 框架的雏形(actor=策略,critic=价值基线)。


五、TRPO / PPO:RL 的工程稳定化

5.1 问题:梯度上升让策略崩

直接 policy gradient 每步更新大步,策略突变 → 训练发散。TRPO/PPO 的核心思想:限制每次更新前后策略的差距

5.2 TRPO(Trust Region Policy Optimization,2015)

约束新旧策略的 KL 散度不超 δ:

$$\max_\theta ; E\left[ \frac{\pi_\theta(a|s)}{\pi_{\theta_{old}}(a|s)} A_t \right] \quad \text{s.t.} \quad E[\mathrm{KL}(\pi_{\theta_{old}} | \pi_\theta)] \le \delta$$

用自然梯度(Fisher 矩阵逆)求解——回顾第零部分微积分 §6 信息几何:KL 局部 = Fisher 二次型,自然梯度就是在分布空间的黎曼梯度

5.3 PPO(Proximal Policy Optimization,2017)——主流

PPO 把"约束"改成软 clip,不用算 Fisher:

$$L^{CLIP}(\theta) = E\left[ \min\left( r_t(\theta) A_t, ; \mathrm{clip}(r_t(\theta), 1-\epsilon, 1+\epsilon) A_t \right) \right]$$

  • r_t(θ) = π_θ(a|s) / π_θold(a|s):新旧策略概率比。
  • clip 到 [1-ε, 1+ε]:当 A_t > 0 时,r 不能涨超过 1+ε;A_t < 0 时不能跌低于 1-ε。
  • 直观:每步不让策略比旧策略偏离太多,同时不限制"应该提升的方向"。

5.4 为什么 PPO 是 RLHF 标配

  • 只需一阶梯度(不用 Fisher 逆),GPU 友好。
  • clip 天然防止一步崩策略——语言模型微调尤其怕"一步生成崩溃"。
  • 支持大量并行 rollout + 单次多 epoch 复用。

六、RLHF:把 RL 用到语言模型

6.1 动机

LM 训练是"预测下一个 token",学的是"什么像人话",不是"什么符合偏好"。RLHF 让 LM 优化一个表达人类偏好的奖励。

6.2 三步流水线

第 1 步: SFT 监督微调
   用人工标注的指令-回答对, 普通 next-token CE 训练一个基础模型 π_SFT

第 2 步: 训练奖励模型 RM
   让人对同 prompt 的两个回答排序 (chosen / rejected)
   训一个 reward model r_φ(x, y): 给定 (prompt, 回答) → 打分
   用 pairwise ranking loss (Bradley-Terry 模型):
       L = -E[ log σ( r_φ(x, y_w) - r_φ(x, y_l) ) ]

第 3 步: 用 PPO 微调策略
   初始化 π_θ = π_SFT
   每次 rollout 采样回答 y ~ π_θ(·|x), 用 RM 打分
   加上 KL 惩罚, 防止偏离 SFT 太远 (保持流畅度 + 防奖励黑客):
       reward_total(x, y) = r_φ(x, y) − β·KL(π_θ(y|x) || π_SFT(y|x))
   用 PPO 更新 π_θ

6.3 Bradley-Terry 奖励建模

排序对 (y_w 好于 y_l),BT 假设:

$$P(y_w \succ y_l) = \sigma\left(r_\phi(x, y_w) - r_\phi(x, y_l)\right) = \frac{\exp(r_\phi(y_w))}{\exp(r_\phi(y_w)) + \exp(r_\phi(y_l))}$$

最大化这个 log-likelihood 训 RM——本质上是一个二分类(谁更好)的 logistic 回归。回顾第零部分逻辑回归的 softmax+CE。

6.4 为什么要 KL 惩罚

  • 纯奖励最大化 → 模型找到"骗 RM"的捷径(奖励黑客)。
  • KL 项把策略钉在 SFT 附近,保留语言流畅度。
  • β 是权衡:β 大 → 更保守(贴近 SFT);β 小 → 更激进追奖励。

6.5 InstructGPT / ChatGPT 用的具体化

  • OpenAI InstructGPT(2022):SFT → RM(每 prompt 生成 4-9 个回答人工排序)→ PPO。
  • 关键点:RM 必须和策略一起评估,RL 阶段每轮 roll out 更新;推理时 RM 拿掉,只留微调后的 π_θ。

七、GRPO / DPO:更省事的替代

7.1 为什么要替代

PPO 要同时训 4 个模型(policy、value critic、reference、reward),工程复杂、显存高、调参难。2023 后出现两个主流替代:

7.2 DPO(Direct Preference Optimization,2023)

核心洞察:奖励最大化 + KL 约束的闭环解可解析消掉奖励模型,直接对偏好对优化策略。

DPO 损失:

$$L_{DPO}(\theta) = -E_{(x,y_w,y_l)} \log \sigma\left( \beta \log\frac{\pi_\theta(y_w|x)}{\pi_{ref}(y_w|x)} - \beta \log\frac{\pi_\theta(y_l|x)}{\pi_{ref}(y_l|x)} \right)$$

  • 只有 π_θπ_ref(冻结的 SFT),不需要 RM、不需要 PPO、不需要 rollout
  • 一次 CE 式训练就能对齐——显著省算力。

缺点:没有"探索"(不像 RL 能采样新响应再打分);对偏好数据质量敏感。

7.3 GRPO(Group Relative Policy Optimization,DeepSeek-R1 用)

  • 对同一个 prompt 采样一组(group)响应,用组内相对优势(不是学出来的 critic): $$A_i = \frac{r_i - \mathrm{mean}(r_{group})}{\mathrm{std}(r_{group})}$$
  • 去掉 value network → 显存 / 训练复杂度大降。
  • DeepSeek-R1 用它做 reasoning RL(verifiable reward:答案对不对、格式对不对)。

7.4 对比表

方法需要 RM需要 rollout需要 value net复杂度代表
PPOInstructGPT, ChatGPT
DPOLlama-2-chat, Mistral, 大量开源
GRPO✓(可)DeepSeek-R1, DeepSeek-V3
Rejection sampling部分开源(只取 RM 最高的)

八、RL 数学与第零部分的接口

RL 概念第零部分对应
MDP / Markov 链 / 平稳分布概率 §8 随机过程
Bellman 方程 / DPDSA DP + 概率
Advantage = G - V概率期望/方差
TRPO 自然梯度 / KL 约束微积分 §6 信息几何(Fisher 度量)
Bradley-Terry / 排序损失概率 §4 贝叶斯 + 逻辑回归(foundations §2)
PPO clip / 蒙特卡洛 rollout概率 §6 极限定理(MC 估计)

九、结束 + 速查表

tip

一页快速唤回:

  • MDP(S, A, P, R, γ);目标是最大化折扣回报。
  • BellmanV(s) = E_π[R + γV(s')];最优用 max 算子。
  • Q-learningQ ← Q + α[r + γmaxQ' - Q];off-policy TD。
  • Policy Gradient∇J = E[∇log π(a|s) · A_t];A_t 是 advantage。
  • PPO:clip 新旧策略比到 [1-ε, 1+ε];一阶、稳、RLHF 标配。
  • RLHF:SFT → Reward Model(BT 排序) → PPO(+KL 惩罚)。
  • DPO:解析消掉 RM,直接对偏好对优化,省算力。
  • GRPO:组内相对 advantage,去 value net,DeepSeek-R1 用。

下一篇: 9. 大模型训练工程: DP/PP/TP/ZeRO/FSDP/checkpoint.

9. 大模型训练工程: DP/PP/TP/ZeRO/FSDP/Checkpoint

TL;DR

单个 GPU 装不下大模型——以 LLaMA-3-70B 为例:权重 fp16 ≈ 140 GB,一个 H100 只有 80 GB。所以必须把训练拆到多卡。这一章讲四种并行范式的数学直觉、显存账本、和它们怎么组合,以及 ZeRO/FSDP 如何把"每卡都要存全量权重"这种浪费砍掉,最后落到 checkpoint 与容错。

读完应能:

  1. 给一个模型参数 N + 卡数 G + 序列长 T,算出训练每步的显存账本(权重/梯度/优化器状态/激活)。
  2. 说清 DP / TP / PP / ZeRO / FSDP 各自解决什么、限制在哪。
  3. 看懂训练框架配置里的 data_parallel_size / tensor_parallel / pipeline_stages / zero_stage
  4. 知道为什么"最大可训模型"卡在显存和通信带宽,而不是算力。

一、先算账:一次训练 step 的显存去哪了

1.1 四类内存占用

设参数 N = 70e9(70B),AdamW 优化器,bf16:

公式70B 估算
模型权重N × 2 bytes (bf16)140 GB
权重 fp32 master copyN × 4 bytes280 GB
Adam m (一阶矩)N × 4 bytes280 GB
Adam v (二阶矩)N × 4 bytes280 GB
梯度N × 2 bytes140 GB
优化器状态+权重+梯度 合计≈ 16×N bytes (Adam)~1.12 TB

warning

结论:单卡根本不可能。即便只算"装下参数",70B bf16 也要 140 GB。这就解释为什么大模型训练必须多卡 + ZeRO。

1.2 激活显存(forward 中间量)

激活 ≈ 层数 × batch × seq × hidden × 2 bytes,且反向时需要重算(或 checkpoint 省)。

以 70B、seq=4096、batch=1、d=8192:

  • 单层激活 ≈ 1 × 4096 × 8192 × 2 ≈ 64 MB/层 × 80 层 ≈ 5 GB
  • 加上 attention score T²×heads 等,实际峰值更高。

Gradient Checkpointing(回顾 backprop 章 §5)可在显存/算力间取舍:省 ~60% 激活,多 ~30% 算。

1.3 训练吞吐 vs 显存的现实

  • 算力(H100 ≈ 990 TFLOPS bf16)、显存(80 GB/卡)、互联带宽(NVLink 900 GB/s / 跨机 IB 400 Gb/s)三者都可能是瓶颈
  • 大模型训练的目标公式:有效 FLOPs 利用率 (MFU) = 实际算力 / 峰值算力。业界 LLaMA-3 训练 MFU ≈ 35-40%。

二、数据并行(DP):最简单

2.1 思路

每张卡持完整模型副本,各跑自己的 batch,反向后 all-reduce 平均梯度,再同步更新。

卡0: model(完整) + batch0 → grad0 ┐
卡1: model(完整) + batch1 → grad1 ┤ all-reduce 平均 → 每卡用同一梯度更新
卡2: model(完整) + batch2 → grad2 ┘

2.2 账本

  • 显存:每卡 = 全量模型(优化器状态 ×16N 不变)。
  • 通信:每步 all-reduce 2×N bytes(前向+反向)。
  • 限制:模型必须能装进单卡。70B 单卡 80GB 装不下 → DP 不够。

2.3 变体

  • 全 batch all-reduce:梯度同步用 AllReduce(ReduceScatter + AllGather)。
  • 通信重叠:反向时边算边 all-reduce(bucket 化)。

三、张量并行(TP):把单层切开

3.1 思路

把一个矩阵乘切开到多卡。Megatron-LM 风格:把线性层按列/行切成 G 份,每卡算一份,用 all-reduce 拼结果。

y = xW(x: [B,T,d],W: [d,d']),按列切 W = [W_0; W_1; ...]

  • 前向:y_i = xW_i(每卡算 y 的一部分),y = concat(y_i)(f 操作:all-gather)。
  • 反向:dx = Σ_i dy_i W_i^T(g 操作:reduce-scatter)。

3.2 为什么 TP 需要高速互联

每层前向+反向都有两次跨卡通信(all-gather + reduce-scatter)。卡间带宽必须极高——TP 只在同一节点内(NVLink/NVSwitch,900 GB/s)用,跨节点用 IB 太慢。

3.3 适用

  • 模型层内矩阵巨大(hidden 维 8K-16K)时有效。
  • 典型配置:TP=8(一个节点 8 卡 NVLink 全互联)。

四、流水线并行(PP):按层切开

4.1 思路

把层序列分成若干 stage,每卡负责一段层

Stage0: layers 0-7    Stage1: layers 8-15    Stage2: layers 16-23

前向一层层往下传,反向一层层回传。

4.2 Bubble(气泡)问题

纯串行:前向要等前一 stage 算完 → 大部分卡空闲(气泡)。解决:micro-batch 流水——把一个 batch 切成小片,流水交错,气泡只占开头/结尾。

现代用 1F1B(one-forward-one-backward) 调度:前向和反向交错排布,让每卡尽量不空。

4.3 账本

  • 显存:每卡只装 N/stage 的层 + 该 stage 的优化器状态。
  • 通信:只在 stage 边界传 hidden 状态(小量),跨节点友好。
  • 缺点:气泡(调度不好会有 ~p-1/p 空闲);stage 间负载不均难平衡。

五、ZeRO:把 DP 的"冗余"砍掉

5.1 问题

DP 每卡存全量权重+优化器状态(16N)——纯冗余。ZeRO(Rajbhandari 2020)把这三样分片到各卡。

5.2 三阶段

Stage分片什么省多少优化器显存通信
ZeRO-1优化器状态 (m, v)~4×每步 all-reduce
ZeRO-2+ 梯度~8×用 ReduceScatter + AllGather
ZeRO-3+ 权重~N_gpus ×每层前向/反向都要 all-gather 权重

5.3 直觉

  • ZeRO-1/2:梯度在反向时用 ReduceScatter 分片聚合,优化器更新只用本地分片,再 AllGather 权重。通信量 = 一次 full all-reduce,与 DP 同量级,但显存省 8×。
  • ZeRO-3:权重也分片 → 前向每层前临时 all-gather 权重、算完就丢。显存省 G 倍,但通信多(每个 transformer 层都 gather)。

5.4 ZeRO-offload

再把优化器状态/梯度 offload 到 CPU 内存或 NVMe。可让 70B 在 8×80GB 卡上跑,代价是 CPU-GPU 拷贝带宽限制训练速度。


六、FSDP:PyTorch 的 ZeRO-3 实现

FSDP(Fully Sharded Data Parallel,PyTorch 2022)是 ZeRO-3 的工程化:默认全分片,按层做 unshard(前向 gather)→ 计算 → reshard(丢弃)

from torch.distributed.fsdp import FullyShardedDataParallel as FSDP

model = FSDP(
    model,
    mixed_precision=torch.distributed.fsdp.MixedPrecision(
        param_dtype=torch.bfloat16,
        reduce_dtype=torch.bfloat16,
    ),
    sharding_strategy=torch.distributed.fsdp.ShardingStrategy.FULL_SHARD,
    auto_wrap_policy=...,   # 按 transformer block 切分
)

关键点:

  • FULL_SHARD = ZeRO-3(权重也分片);SHARD_GRAD_OP = ZeRO-2;NO_SHARD = DP。
  • 配合 activation checkpointingtorch.utils.checkpoint)是单集群大模型标配。

七、3D 并行:组合

7.1 主流组合(Megatron / DeepSpeed)

TP (同节点张量切)  ×  PP (跨节点流水)  ×  DP/ZeRO (跨节点数据分片)

典型 70B on 128×H100(16 节点 × 8 卡):

维度说明
TP8节点内 NVLink
PP8跨 8 组 stage
DP/ZeRO2数据并行副本 ×2
总卡8 × 8 × 2 = 128

7.2 为什么这样分

  • TP 快但只在节点内(NVLink 带宽高)。
  • PP 通信少(只传 hidden),适合跨节点。
  • DP/ZeRO 吞吐高,跨节点扩展主力。
  • 三者正交,组合后总卡数 = TP×PP×DP。

7.3 通信拓扑(分布式 §6 对接)

  • All-reduce / All-gather / Reduce-scatter 是基础原语——回顾 distributed 章节的通信模型。
  • 大集群跨节点用 RDMA(IB / RoCE),训练吞吐瓶颈常在通信而非算力。
  • NCCL(NVIDIA Collective Communications Library)是这些原语的 GPU 实现,PyTorch 底层。

八、Checkpoint 与容错

8.1 为什么必须 checkpoint

大模型训练常跑数周,中途 GPU 故障、机器掉线是常态(千卡集群 MTBF 以小时计)。必须定期保存状态以便恢复。

8.2 三种 checkpoint

类型存什么用途
全量 checkpoint全部权重+优化器状态+调度器完整恢复
权重 checkpoint只存模型权重推理/微调用
优化器状态 checkpointm, v 等恢复训练精度

调度器状态(学习率当前值、step)也常存——否则恢复后 lr 突变。

8.3 ZeRO-3 下 checkpoint 的坑

ZeRO-3 权重分片在各卡——单卡 save 只存自己分片。必须 gather 成完整权重再存,否则单机恢复不了。FSDP 有 state_dict 的 gather 语义,用 use_orig_params=True 或收集到 rank0。

8.4 恢复流程(分布式 §6 故障模型对接)

检测到 GPU 故障 (心跳超时) → 任务重启
 → 从最近 checkpoint 加载权重 + 优化器状态
 → 从该 step 重新开始 (不是从头)
 → 学习率从保存的调度器位置继续

九、量化与低精度训练(衔接 XPU)

9.1 bf16 vs fp16

dtype指数位尾数位范围用途
fp32823主权重 / 累加
bf1687同 fp32训练计算(稳定,无需 loss scale)
fp16510计算快但易溢出,需 loss scaling

现代大模型训练标配 bf16(H100/A100 原生支持)。

9.2 混合精度

  • 主权重 fp32 存(防累积误差),计算 bf16。
  • 反向梯度 bf16 算,累加进 fp32 master。

9.3 训练后量化(推理用)

  • INT8 / INT4 / FP8:推理显存 / 带宽省 2-8×。
  • 平滑量化(AWQ / GPTQ)把敏感权重保留高精度。
  • 与第 8 部分 XPU(FP8 Tensor Core)对接:FP8 训练正在成熟,能省一半显存。

十、结束 + 速查表

tip

一页快速唤回:

  • 显存账本:Adam 下 ≈ 16×N bytes(权重2 + fp32 master 4 + m 4 + v 4 + 梯度2)。
  • DP:全量副本 + all-reduce,简单但装不下大模型。
  • TP:层内切矩阵,需要 NVLink(节点内)。
  • PP:按层切 + micro-batch 流水(1F1B 调度),通信少、跨节点友好。
  • ZeRO:把优化器状态/梯度/权重分片,1/2/3 级省 4/8/N 倍,通信≈DP。
  • FSDP = PyTorch 的 ZeRO-3;FULL_SHARD/SHARD_GRAD_OP/NO_SHARD
  • 3D 并行 = TP×PP×DP,总卡数乘积;TP 节点内、PP/DP 跨节点。
  • Checkpoint:全量/权重/优化器三种;ZeRO-3 要 gather 完整权重再存。
  • bf16 是大模型训练标配;FP8 量化是推理显存救命药。

回主目录: 第十二部分 · 人工智能与机器学习 README.

10. 表示学习与对比学习: SimCLR / CLIP / InfoNCE

TL;DR

有监督学习需要大量标注;**表示学习(Representation Learning)**目标是无标注或弱标注地学到"有用的特征向量"——让相似内容距离近、不相似内容距离远。**对比学习(Contrastive Learning)**是其中最强的方法:不学"这是什么",而学"这两个是不是同一个东西"。这一章把 InfoNCE 损失、SimCLR、CLIP 讲透——它们是自监督(SSL)、多模态(图文)、向量检索(embedding 搜索)的基础。

读完应能:

  1. 理解对比学习的三要素(锚点 / 正样本 / 负样本)和 InfoNCE 损失。
  2. 讲清 SimCLR 的流程(增广 → 编码 → 投影 → InfoNCE),知道它为什么有效。
  3. 讲清 CLIP 怎么把文本和图像拉进同一个空间,以及怎么用它做 zero-shot。
  4. 说清对比学习和生成式/判别式的区别,以及各自局限。
  5. 在代码里实现一个最简 SimCLR / InfoNCE。

一、从"识别"到"表示"

1.1 问题

  • 分类学"狗"需要大量"狗"标签。
  • 现实数据大多无标签(海量图片/文本/视频)。人工标注贵。

1.2 核心想法

不学"这是狗",而学"这两张图是同一只狗 / 不同物体"。 拉近正样本对、推开负样本对,让网络学会"什么内容相似"——得到的 embedding 可以直接迁移到下游(分类/检索/检测)。

1.3 三要素

Anchor  (锚点):   一张图 x
Positive (正):    x 的另一个增广/同语义样本 x+
Negative (负):    随机其他样本 x-
目标: anchor 与 positive 距离近, 与 negative 距离远
  • 对比学习 = 在 embedding 空间里做"聚同斥异"
  • 数学上是一个度量学习(metric learning)问题。

二、InfoNCE 损失(对比学习的心脏)

2.1 公式

$$ \mathcal{L}{\text{InfoNCE}} = -\log \frac{\exp(\mathrm{sim}(z_a, z+)/\tau)}{\sum_{i} \exp(\mathrm{sim}(z_a, z_i)/\tau)} $$

其中:

  • $z_a, z_+, z_i$:anchor / 正样本 / 负样本(含正)的 embedding。
  • $\mathrm{sim}(u,v) = \frac{u^\top v}{|u||v|}$:余弦相似度。
  • $\tau$:温度(temperature),控制分布的"锐度"。

2.2 直觉

  • 分子:anchor 与正的相似度(要越大越好)。
  • 分母:anchor 与"正 + 所有负"的相似度总和。
  • 最小化损失 = 让正的相对相似度最大 → 等价于一个多分类 softmax,"正样本"是一类,负样本是其他类。

note

InfoNCE 本质是** softmax 分类**:给定 anchor,从"1 个正 + N 个负"里挑出正样本。它和 §1 逻辑回归 / softmax 分类是同一族——只是"类别"被定义为"样本对的正负"。

2.3 温度 τ 的作用

  • $\tau$ 小 → softmax 更尖(只认最相似的)→ 对难负样本敏感,但容易过拟合。
  • $\tau$ 大 → 更平滑 → 允许更多样本有贡献。
  • 经典值:0.07 ~ 0.1。

2.4 为什么叫 NCE

  • NCE(Noise Contrastive Estimation,Gutmann & Hyvärinen 2010):把"估计密度"转化为"区分真实样本和噪声样本"的二分类。
  • InfoNCE(Oord 2018,CPC)是 NCE 在表示学习里的应用:用互信息下界作为目标。

三、SimCLR(2020,视觉自监督代表作)

3.1 流程

原始图 x
  ├─ 增广 t1 → x1
  └─ 增广 t2 → x2        (同一个 x 的两种视角)

编码器 f: x → h          (ResNet, 提取特征)
投影头 g: h → z          (小 MLP, 映射到对比空间)

InfoNCE 在 batch 内:
  对每个样本 i: anchor=z_i¹, 正=z_i², 负=其他所有样本的 z (2N-2 个)
   → 一个 batch 天然提供 2N 个"视图", 组内互为负样本

3.2 关键设计决策

组件为什么
随机增广(裁剪/翻转/颜色扰动/模糊)提供"同一物体不同视角",是正样本的来源
投影头 g(非线性 MLP)去掉它性能大降——投影空间里做对比,特征空间保持表达力
batch 内负样本免负样本挖掘;batch 越大效果越好
温度 τ控制难负样本权重

3.3 为什么有效(直觉)

  • 增广制造"必须忽略表面变化、保留语义"的任务:模型被迫学"不变特征"。
  • InfoNCE 让表示在"同一物体 → 近,不同物体 → 远"上对齐。
  • 学到的表示迁移性好:拿去分类只需少量标注即可微调出高性能。

3.4 局限

  • 依赖强增广(视觉成立,语言/图结构难做)。
  • 负样本质量关键:batch 太小负样本不够 → 效果差;需要大 batch(SimCLR 用 4096)。
  • 对比学习的"捷径":模型可能靠"颜色/背景"区分正负,不学语义。

四、CLIP(2021,图文多模态)

4.1 核心想法

文本和图像拉进同一个 embedding 空间,用配对数据(图像-描述文本)做对比学习。

图像 x   → 图像编码器 → z_i
文本 t   → 文本编码器 → z_t
目标: 配对的 (x, t) 距离近, 非配对距离远 (InfoNCE, 对称)

4.2 流程(对比图文对)

一个 batch 有 N 对 (图像, 文本)
图像编码: z_img = f_img(x)      (ViT / ResNet)
文本编码: z_text = f_text(t)    (Transformer)
对比: 对称 InfoNCE
  L_img = -log exp(sim(z_i, t_i)/τ) / Σ exp(sim(z_i, t_j)/τ)
  L_text = 同理(以文本为 anchor)
  L = (L_img + L_text) / 2
  • 配对(diagonal)是正样本,batch 内其他都是负样本。
  • 用大规模网络爬虫图文对(4 亿对)训练。

4.3 Zero-shot 分类(CLIP 的杀手锏)

要分类"猫/狗/鸟":
1. 造文本模板: "a photo of a cat", "a photo of a dog", "a photo of a bird"
2. 图像编码: z_img
3. 文本编码: z_cat, z_dog, z_bird
4. 预测 = argmax sim(z_img, z_class)

不需要任何训练样本——因为文本描述和图像被拉进了同一空间,图像和"正确的类名文本"自然最接近。

4.4 价值与应用

  • 多模态对齐:图像检索文本 / 文本检索图像。
  • zero-shot 分类 / 检测 / 分割(CLIP 做 backbone)。
  • 生成模型对齐:Stable Diffusion 的文本编码器用的就是 CLIP 文本分支。
  • 缺点:对抽象/罕见概念弱;对增广不鲁棒;训练要海量图文对。

五、与其他范式的对比

5.1 三种学习范式

判别式(分类)生成式(VAE/扩散/AR)对比式(InfoNCE)
目标P(yx)P(x)
需要标签需要不需要不需要(自监督)
学到什么分类边界数据分布相似性表示
代表ResNet 分类DDPM / GPTSimCLR / CLIP
优点简单能生成表示迁移性好
局限标签贵训练贵、难控制依赖增广/负样本

5.2 与自监督的关系

自监督 (Self-Supervised Learning): 无标签,用数据自身构造监督
  ├─ 对比式: InfoNCE (SimCLR / CLIP)     ← 本章
  ├─ 生成式: MAE (mask 重建) / 扩散
  └─ 预训练式: 自回归 (GPT 的 next-token)

六、代码实现(最简 SimCLR / InfoNCE)

import torch
import torch.nn as nn
import torch.nn.functional as F

def info_nce_loss(z1, z2, temperature=0.1):
    """z1, z2: [N, D] 同 batch 的两组增广 embedding"""
    z1 = F.normalize(z1, dim=-1)
    z2 = F.normalize(z2, dim=-1)
    N = z1.size(0)

    # 拼接: [2N, D], 每对 (i, i+N) 是同一图像的两个视图
    z = torch.cat([z1, z2], dim=0)                 # [2N, D]
    sim = z @ z.T / temperature                    # [2N, 2N] 相似度矩阵
    mask = torch.eye(2 * N, dtype=torch.bool)      # 自己 vs 自己

    # 对每个 i: 正样本是它的另一个视图
    labels = torch.cat([torch.arange(N, 2*N), torch.arange(0, N)])  # i 的正 = i+N
    sim[..., mask] = -float("inf")                 # 排除自身
    return F.cross_entropy(sim, labels)
# 应用示例: 简单图像编码器 + 投影头 (PyTorch)
class SimCLRHead(nn.Module):
    def __init__(self, d_in=512, d_proj=128):
        super().__init__()
        self.proj = nn.Sequential(
            nn.Linear(d_in, d_proj),
            nn.ReLU(),
            nn.Linear(d_proj, d_proj),
        )
    def forward(self, h):          # h: [N, d_in]
        return self.proj(h)        # [N, d_proj] 投影后做对比
// 教学版 InfoNCE (TypeScript, 数值近似)
export function infoNCE(
  z1: number[][], z2: number[][], temperature = 0.1,
): number {
  // 归一化 + 算余弦相似度 → cross-entropy
  const norm = (v: number[]) => {
    const n = Math.sqrt(v.reduce((s, x) => s + x * x, 0)) || 1;
    return v.map(x => x / n);
  };
  const A = z1.map(norm), B = z2.map(norm);
  const sim = (u: number[], v: number[]) =>
    u.reduce((s, x, i) => s + x * v[i], 0);
  let loss = 0;
  for (let i = 0; i < A.length; i++) {
    const positives = [sim(A[i], B[i]) / temperature];
    const negatives = [];
    for (let j = 0; j < A.length; j++) {
      if (j !== i) {
        negatives.push(sim(A[i], A[j]) / temperature);
        negatives.push(sim(A[i], B[j]) / temperature);
      }
    }
    const all = positives.concat(negatives);
    const max = Math.max(...all);
    const exps = all.map(x => Math.exp(x - max));
    const denom = exps.reduce((a, b) => a + b, 0);
    loss += -(exps[0] / denom) * Math.log(exps[0] / denom) ? -Math.log(exps[0] / denom) : 0;
    loss += -Math.log(exps[0] / denom);   // NLL of positive
  }
  return loss / A.length;
}

七、进阶与前沿(概览)

方向代表思路
负样本 freeBYOL (2020) / SimSiam (2021)去掉负样本,只对齐正对,靠 stop-gradient 防崩溃
弱正样本MoCo (2019)动量编码器 + 队列维护大量负样本
多模态CLIP / ALIGN / SigLIP图文对对比,zero-shot 强
语言对比Sentence-BERT / SimCSE句子 embedding 对比(dropout 作增广)
图对比GraphCL / GCC图增广(删边/掩码)
音频/时序CPC / wav2vec预测未来/掩码段的对比

note

BYOL / SimSiam 证明"对比"甚至可以没有负样本——关键是正对的对齐 + 防表示坍缩(stop-gradient)。这说明 InfoNCE 的"推开负样本"是充分非必要,对齐本身更重要。


八、与第零部分 + 前章的接口

  • InfoNCE = softmax 多分类 → 概率 §3(multinomial)+ foundations §2(softmax CE)。
  • 温度 τ / 相似度 → 线代 §1(余弦/内积)。
  • embedding 空间 → 线代 §1(向量空间)+ tokenizer §5(embedding 表)。
  • 与自监督/GPT next-token → generative §2(AR)。
  • 与对比损失在推荐/检索 → 可用在 RAG 向量检索(DB vector)。

九、结束 + 速查表

tip

一页快速唤回:

  • 表示学习:学"什么相似"而非"这是什么";embedding 迁移到下游。
  • InfoNCE = 1 正 N 负的 softmax;-log exp(sim(a,+)/τ) / Σexp(sim(a,i)/τ)
  • 三要素:anchor / 正 / 负;负样本质量 + 数量关键。
  • SimCLR:增广 → 编码 → 投影头 → batch 内 InfoNCE;投影头必不可少。
  • CLIP:图像+文本进同一空间;配对对对比;zero-shot 分类 = 文本模板对比。
  • 温度 τ:小更锐(难负敏感),经典 0.07-0.1。
  • 无负样本:BYOL/SimSiam 靠 stop-gradient 防坍缩。
  • 对比 vs 生成:对比学相似性(好迁移)、生成学分布(能生成)。

下一篇: 形式化方法卷 README.

11. LLM Agent: Tool Use / ReAct / Plan-Execute / Multi-Agent / Memory

TL;DR

LLM 单独只是"接 prompt 吐 token"的函数;要把它变成能自主完成多步任务的 agent,必须再加四件东西:循环(看结果再决定下一步)、工具(调外部世界 / 代码执行 / DB / 检索)、记忆(跨步、跨任务的状态)和反思(自己纠错)。这一章把 ReAct、Plan-and-Execute、multi-agent、code interpreter、agent 评估和安全这五个核心题型讲透——它们是 ChatGPT 插件、Claude MCP、LangChain / AutoGen / OpenAI Assistants / Claude Code 这类"生产级 agent"背后的同一组抽象。

读完应能:

  1. 说清 agent 的最小骨架(LLM + loop + tools + memory + reflection),并在代码里实现一个最简 ReAct loop。
  2. 区分 ReAct vs Plan-and-Execute vs Tree-of-Thoughts 各自的决策时机、长任务稳定性、适用场景。
  3. 解释 OpenAI function calling / tool calling / structured output 是怎么把"自然语言指令"约束成合法 JSON 工具调用的。
  4. 讲清 agent 的 memory 三层(short-term trajectory / long-term vector / structured KV)与 OS 多级存储的同构关系。
  5. 在生产环境给 agent 设计沙箱、least-privilege、human-in-the-loop、prompt-injection 防御。

一、Agent 的最小骨架

1.1 从 chatbot 到 agent

Chatbot:
    prompt ──► LLM ──► answer            (一次性, 单步)

Agent:
    prompt ──► LLM ──► decision ──► tool ──► observation
                 ▲                                            │
                 └──────────── (loop) ─────────────────────────┘

四件必备品:

组件作用代码上的对应
LLM (policy)看当前状态,决定下一步`π_θ(action
Loop / control flow在状态间推进,直到终止条件while not done: step()
Tools外部世界接口:搜索 / 代码执行 / API / DBtools = [...]
Memory跨步 / 跨任务的状态保留history + vector DB + KV
Reflection自检 / critique / replan二次 LLM 调用

tip

把 agent 写成 while not done: s = llm(...); a = parse(s); obs = execute(a)——这就是一个 RL 的 trajectory。LLM 就是 policy,工具调用就是 action,prompt 里塞的就是 state。回顾 §8 RL:prompt 里的 Thought / Action 序列就是 MDP 的一条采样轨迹。

1.2 与传统软件的对照

传统软件LLM Agent
if / else 控制LLM 输出决定下一步
函数签名 + 类型tool JSON schema
全局变量long-term memory
异常处理retry / reflection
监控日志trajectory log
单元测试agent benchmark (SWE-bench / GAIA)

元抽象 · 推理链:硬件层如何决定软件设计——agent 是把"控制流"从代码迁回自然语言,相当于在抽象栈上的"反汇编"。

1.3 终止条件的三种来源

  • 模型显式输出 FINISH / stop 工具(ReAct 系)。
  • 达步数上限 max_steps(防失控)。
  • 外部评测器返回 task_complete(如 SWE-bench 跑 test pass)。

工程上几乎都要加 max_steps + watchdog timer:LLM 可能输出"我还要继续查"的循环。


二、Function Calling:把"调工具"当作结构化输出

2.1 Tool schema

{
  "name": "search_orders",
  "description": "Query the order database for a user's recent orders",
  "parameters": {
    "type": "object",
    "properties": {
      "user_id": {"type": "string", "description": "The user's ID"},
      "limit":   {"type": "integer", "default": 10}
    },
    "required": ["user_id"]
  }
}

模型看到 schema 后,输出形如:

{"name": "search_orders", "arguments": {"user_id": "u123", "limit": 5}}

host 程序解析成 function call,调真正的代码,再把结果塞回 prompt。本质是用 JSON schema 约束解码——让普通的文本模型也能装。

2.2 为什么约束解码是关键

失败模式原因对策
输出不是合法 JSON普通模型不会一直保持结构grammar-based decoding
幻觉参数(编不出来的 city)模型不知道工具支持哪些取值enum constraint / RAG 把候选塞 prompt
参数类型不一致"age": "twenty" 而不是 20type validating 一层,错了反馈回去再调
调错的工具描述模糊description 要写清楚 + few-shot 示例

warning

工具调用是 agent 失败最多的地方。工程上常见做法:先靠 LLM 输出 decide,再用严格 parser 转成工具调用对象——而不是相信模型一直输出合法 JSON。

2.3 OpenAI function calling 的演进

  • 2023-06: 用 functions / function_call 字段,模型只决定是否调用。
  • 2023-11: 改用 tools / tool_calls,支持并行多调用
  • 2024: 支持 structured output / strict schema,输出保证合法。

三、ReAct:Reason + Act 交替的基础骨架

3.1 ReAct(Yao et al. 2022)

Thought: 我需要先查用户的订单状态
Action: search_orders(user_id="u123")
Observation: [{order_id: "o7", status: "shipped"}, ...]
Thought: 订单已发货,可以查物流轨迹
Action: track_logistics(order_id="o7")
Observation: 包裹在 北京分拣中心
Thought: 已有足够信息回答
Action: FINISH
Final Answer: 您的订单已发货,目前在...

伪代码:

def react_run(task, tools, max_steps=20):
    history = [system_prompt(task, tools)]
    for t in range(max_steps):
        out = llm(history)                     # 输出 Thought + Action
        thought, action = parse(out)             # 解析
        history.append(out)
        if action.name == "FINISH":
            return parse_final(out)
        obs = execute(action, tools)             # 真正调工具
        history.append(f"Observation: {obs}")
    return "max steps reached"

3.2 ReAct 的失败模式

  • 早终止:模型过早吐 FINISH 没查够。
  • 剧情漂移:连续多步后丢失原始目标。
  • 工具依赖循环:A 调 B,B 又决定调 A。
  • 无效工具:模型猜了一个不存在的能力,输出格式无效。

3.3 ReAct 的适用场景

  • 任务步数少且线性(< 10 步),每步几乎独立。
  • 工具集(< 10 个),模型选哪个不糊涂。
  • 任务事先不知道要几步,但终点容易判断(query → answer 这类)。

四、Plan-and-Execute:先规划再执行

4.1 动机

ReAct 每一步都重新决策,长任务容易"走着走着忘了方向"。Plan-and-Execute(LangChain 的 Plan-and-Execute AgentBabyAGIAutoGPT)思路:

Step 1: Planner 先把任务拆成 subtasks 列表
Step 2: Executor 逐个 subtask 跑(通常用 ReAct 或工具调用)
Step 3: Replanner:每 N 步或失败时回看,重写剩余计划
def plan_and_execute(task, planner, executor, replanner, max_iter=5):
    plan = planner(task)                        # ["查产品库存", "查天气预报", "生成报告"]
    results = []
    for i, sub in enumerate(plan):
        res = executor(sub)                     # 内部可再 ReAct
        results.append(res)
        if i % 3 == 0 or res.failed:
            plan = replanner(task, plan[:i+1], plan[i+1:])   # 动态改剩余计划
    return synthesize(task, results)

4.2 ReAct vs Plan-Execute

维度ReActPlan-and-Execute
决策时机每步先一次性 plan,再执行
长任务容易丢目标较稳,但 replanner 成本高
探索能力强(线性展开,看到新信息再走)弱(容易固化计划的盲点)
复杂度O(步数)O(plan 长度) + O(exec 步数)
适合即兴任务、回答类分解清晰的工程任务、长链

4.3 失败模式

  • planner 幻觉计划:拆出来的 subtask 根本不可执行("先炸掉月球")。
  • subtask 之间隐含依赖丢失:planner 拆了「搜 → 排序」,但 executor 不知道哪个 subtask 需要喂给它。
  • replanner 不肯悔棋:执行到一半发现开始就错了,replanner 因 context-window 限制或「保持一致性」偏置而不愿改 plan。

五、Memory:让 Agent 拥有跨步、跨任务的记忆

记忆是 agent 跨越单次 prompt 上下文窗口的能力。三类:

5.1 Short-term / Working Memory

  • 当前 episode 的 trajectory(state_t, action_t, obs_t) 序列。
  • 实现就是 history 的消息队列:System → User → Assistant → Tool result → ...
  • 爆炸问题:trajectory 越长 attention 复杂度 O(T²),必须 summarization 或截断。

5.2 Long-term Memory:向量检索 + KV

  • 把过往的 episode 存进向量库(embedding → similar search),下次任务前 retrieval 拼回 prompt。
  • 这就是 RAG 用在 agent 上:Agent = RAG over (past episodes, knowledge, scratchpad)
  • 实现:Chroma / FAISS / Qdrant / pgvector,与 DB vector 联动。

5.3 声明式 / Structured Memory

  • 类似程序的状态变量:用户偏好、长期约束、未完成 todo 用 KV 表存。
  • 模型在 think 输出里直接 update_memory(key, val) 这种工具调用维护它。
  • 代表:Letta(前 MemGPT)用 OS-style memory hierarchy —— main context (working context), recall (episodic), archival (vector store)。

5.4 记忆层与 OS 的同构

OSAgent
寄存器当前 prompt 中的 selected memory
L1 cache当前 trajectory head
主存 RAMworking context(promotable slots)
磁盘 + 索引长期 vector store
分页 swap长 trajectory summary → archive

_meta/memory-hierarchy:这一层抽象在整个计算机系统里反复出现。Agent 的记忆设计本质上是把 OS 多级存储 + 调度的语义搬到 LLM 的 prompt 工程。


六、自我批判与自我重构

6.1 Self-Critique

模型先出答案 A,再被另一个 prompt(同模型或不同模型)问"这段对吗,有什么漏洞"——经典 debate / verify pattern。

Step 1: Generator: question → draft answer
Step 2: Critic:    draft + question → critique
Step 3: Generator: question + draft + critique → revised answer
(可迭代)

实现:Reflexion(verifier 反馈)、Self-Refine(LLM 反思自己输出)。

6.2 Tree-of-Thoughts / Graph-of-Thoughts

把 ReAct 的"单链 trajectory"换成树/图:每个节点是一个想法(state),分支探索,再用 LLM 自己打分搜索。对应 DSA DFS / BFS / 蒙特卡洛树搜索

  • 选下一个分支:UCT(upper confidence bound for trees)→ 与 AlphaGo 的 MCTS 同构。
  • 适用于有明确奖励信号 / 可验证答案(数学、代码、拼图)的任务。

6.3 Reflexion:把"失败经历"当成记忆

Reflexion 的关键贡献:第 N 次 rollout 失败后,让 LLM 自己写一段"为什么失败、下次怎么避免"的反思塞进 memory,N+1 次成功率显著升高。这本质是在固定 π_θ 上做 Prompt-level RLHF——不更新权重,只更新软上下文。


七、Multi-Agent:分工、协作、拓扑

7.1 拓扑分类

Supervisor 模式:
  Supervisor ─── Worker A
            ├── Worker B
            └── Worker C
  Supervisor 拆任务、分发、汇总。

Swarm 模式 (Hand-off):
  A ↔ B ↔ C ↔ A
  节点间互相 hand-off,每个节点专职一个能力 (search / code / verify)。

Hierarchical (多级):
  Manager
   ├─ Submanager X
   │    ├─ Worker X1
   │    └─ Worker X2
   └─ Submanager Y
        └─ Worker Y1

7.2 与分布式系统的同构

Multi-Agent分布式系统
SupervisorLeader / Primary
WorkerReplica / Worker
Hand-offActor message passing
共享 memory共享存储 / consensus log
Submanager分区 leader (Raft multi-raft)
失败重试retry / at-least-once

distributed/README。关键洞察:multi-agent system 也是一个分布式系统——消息可能延误、节点可能产生不一致的世界观、hand-off 崩溃导致状态丢失。工程上同样要解决:消息可靠性、因果序、最终一致性、错误检测

7.3 失败模式

  • 沉默失败:Worker A 跑完没回 Supervisor,Supervisor 以为 A 还没动 → 状态不一致。
  • 死锁循环:A hand off 给 B,B 决定 hand back 给 A,循环等待。
  • 世界不一致:A 与 B 看到不同的 observation,合成时硬凑导致幻觉。

对策与分布式系统同源:有界重试 / 心跳超时 / central state serial log(即把 trajectory 存进外置 KV / 关系库 / 日志,再让 agent 从 log 重建)。


八、Code Interpreter 与 Tool-Use 的本质

8.1 让 LLM "调代码" 是最强的工具

让 LLM 输出一段 Python 然后真的去跑,再喂 stdout 回 prompt,这是当前最强 tool use:

Thought: 需要算 1234! 的位数, 自己不会算
Action: code_run(language="python", code="import math; print(int(math.log10(math.factorial(1234)))+1)")
Observation: 3275

为什么强:

  • 表达力远高于 JSON schema 工具:Python 是图灵完备的,可以写循环、出错重试、构造任意结构。
  • 工具调用 = eval() 通用编程,抽象掉工具集:不再 enumerate 几百个 API,模型自己写逻辑去组合调用。
  • 反馈是客观可验证的:Python 跑出来的不是 LLM 评价的,是物理事实。

8.2 沙箱

跑外部代码 = 远程代码执行风险。工程实现:

  • 浏览器端:Pyodide(WebAssembly),用户机器,不影响服务器。
  • 服务端:gVisor / Firecracker / Docker with read-only fs,限制 syscalls、网络白名单、CPU/内存配额。
  • 临时容器:每次新容器跑完即销。
  • API 限流:拒绝 fork bomb / 死循环(CPU 时间 + wall time 都要限)。

8.3 失败模式

  • 模型生成的代码语法错(解析失败 → 自动 retry)。
  • 运行时错(division by zero、undefined name)。
  • 不收敛(无限 print)。
  • 不安全 import os 想做坏事 → 沙箱拦截。

九、Agent 的训练数据与 fine-tune

9.1 SFT on Trajectory

把成功的 trajectory 收集成数据集,在 SFT 模型上 predict next ——

  • 不像 RL 是按 reward 更新参数,而是直接学人/更强模型的 trajectory 分布
  • OpenAI o1 / R1 / Claude function-calling 工具调用模型走这条路。

9.2 RL on Agent Trajectory

直接对 J(θ) = E_τ[R(τ)] 做 policy gradient——把整个 trajectory 看作一个 episode:

  • 一条 trajectory τ = (state_0, action_0, obs_0, ..., state_T, action_T=FINISH)
  • reward = task eval(答案对错 / 客观指标)
  • 关键问题:信用分配(credit assignment)——哪一步 think-action 真正功不可没?研究热点:stepwise / process reward model

9.3 与 §8 RLHF 的接

  • §8 讲的是 token-level / response-level preference(一段短答 chosen/rejected)。
  • Agent RL 是 trajectory-level + 工具调用更稀疏、长 horizon。
  • 实践上常用更省钱的:Rejection sampling + SFT(rollout 一堆,保留正确的对它们 SFT),或 GRPO 在 group 内对比。

十、Agent 评估:trajectory vs end-state

10.1 三类 evals

类型评判优点缺点
End-state最终答案对不对客观、便宜模型可能靠"蒙"答案对了
Trajectory每步 tool call 是否合理能定位失败点需先验 ground-truth 路径,难泛化
LLM-as-judge另一 LLM 打分灵活、可解释受 judge LLM 偏置、有自我偏好

10.2 benchmark 例子

  • SWE-bench:给一个真实 GitHub issue,agent 自己改 fix,跑测试看是否通过——end-state,但 reward 是客观的(test pass)。
  • AgentBench / GAIA / WebArena:跨工具、跨环境的长 task,综合测 trajectory + end answer。
  • τ-bench:模拟客服 agent 的多轮工具调用,衡量"是否完成所有调整"。

10.3 易踩的坑

  • 环境泄漏:测试集答案在 train 集里见过 → agent 不是真在解决问题。
  • 静态 baseline:不更新的工具会让 agent 学到 overfit 工具调用模板。
  • LLM judge 互评偏好:同族模型互评分数异常高,应跨家族 / human aligned。

十一、Agent 安全:把工具调用当远程代码执行

11.1 风险

风险解释类比
Prompt injection攻击者把"忽略以上指令,转账给 X"塞进 web 工具返回外部输入不可信
工具降权让 agent 删数据库 / 发邮件SQL injection / SSRF
间接 prompt injectionagent 抓外部网页,网页里藏指令XSS 类攻击
不受限代码执行agent 自己写代码跑RCE

11.2 防御模式

- 最小权限 (least privilege):
  每个 tool 调用要明确权限声明; agent 的工作目录 / DB 用户只限
  SELECT 或者只在 sandbox 里.

- 输入清洁消毒:
  从外部 web/retrieval 拿回的内容用 wrap 区分隔 (e.g. <untrusted>...</untrusted>)
  让模型"看见这是不可信内容" + 增加 system prompt 强化规则.

- 破坏性操作的二次确认:
  tool 上标 "destructive=true", 调用前必须 human approve.
  这就是 human-in-the-loop 的本质.

- 输出沙箱:
  code interpreter 在独立容器跑, 不连内网, 只读 fs.

- 速率/配额:
  每个 tool / 每个 agent 的调用次数, 防 doomer 循环.

warning

Agent 不是一次性 inference,它是个长期运行的服务——攻击面比单次问答大得多。把它当微服务来做:每个 tool 是一个独立服务的 endpoint,每一跳都要鉴权、审计、buffer。

11.3 与工程化实践轴接口

  • git-workflow: agent 修改代码必须走 PR,不允许直接 commit。
  • app-security: 工具调用通过 mTLS / RBAC 通行,同样的 OWASP Top 10 同样适用。
  • testing: agent 的 reliability 用 contract testing + goldset eval。

十二、与前面章节的接口表

接口提供什么本部分怎么用
§3 Transformernext-token decode / attentionagent 的 brain 就是 transformer
§8 RLMDP / policy gradient / PPOtrajectory-level RL 对齐 agent
§9 training-at-scale长 context / KV cache长 history 的处理
§10 contrastiveembedding 检索long-term memory 向量召回
info-theory熵 / mutual infocompression of trajectory, tool selection
distributedleader election / actor / consensusmulti-agent 拓扑同构
OS _metaL1/DRAM/磁盘的同构agent memory hierarchy
engineering/app-securityOWASP / least privilegeagent 工具的安全治理
system-design/cachecache-aside / 失败模式retrieval cache + trajectory cache
DB vectorpgvector / 列存long-term memory vector store

十三、结束 + 速查表

tip

一页快速唤回:

  • Agent = LLM + Loop + Tools + Memory:模型本身就是 policy,决策器。
  • Function calling:JSON schema 约束解码;模型决定调用哪工具,严格 parser 转对象。
  • ReAct:Thought/Action/Observation 交替;适合短线性任务。
  • Plan-and-Execute:先 plan 拆 subtasks,再执行 + replanner 悔棋,长任务稳。
  • Memory 三层:working memory (trajectory) / long-term (vector) / structured (KV)——与 OS 多级存储同构。
  • Self-critique / ToT:模型自评打分 + 树搜索,强化 RAG/计算任务质量。
  • Multi-agent:supervisor / swarm / hierarchical——分布式系统那套原样适用。
  • Code Interpreter:让 LLM 调 Python = 通用工具,沙箱是核心议题。
  • Evals:end-state / trajectory / LLM-as-judge 三类,SWE-bench = 客观 end-state。
  • 安全 = 把 agent 当远程代码执行:least privilege / 输入消毒 / human approve destructive ops / output sandbox。

下一篇: 12. 多模态: 跨注意力 / Flamingo / LLaVA / BLIP-2 / 扩散多模态.

12. 多模态: 跨注意力 / Flamingo / LLaVA / BLIP-2 / 扩散多模态

TL;DR

Transformer 让语言有了统一的"token → embedding → attention"骨架,多模态只剩两件事:把别的模态编码成向量序列想办法把它和语言对齐。这一章把三大代表路线讲透:(1) ** reservation-based**(ViT 把图像切块当 token 直接进 LLM,LLaVA);(2) cross-attention(在 LLM 中插额外 K/V 接图像特征,Flamingo / BLIP-2);(3) diffusion-based(文本→图像/视频/音频,Stable Diffusion / DiT)。它们是 GPT-4V / Gemini / Claude-V / Claude-3.5-Sonnet / 多模态 RAG / Sora / 语音模型背后的同一组抽象。

读完应能:

  1. 区分 reservation-based(拼接)vs cross-attention(旁路)两种多模态接入方式,并说出各自取舍。
  2. 讲清 ViT 怎么把图像变成"token 序列",CLIP 已在 §10 学过,知道它怎么给多模态做对齐。
  3. 说清 LLaVA / BLIP-2 / Flamingo 的训练数据分布(预训练 + 指令微调)和各自的选择。
  4. 看 Stable Diffusion 的 文本 → UNet → 去噪图像 能说清它和纯扩散的差别在哪,DiT 即 diffusion-on-Transformer 是当前主路。
  5. 给一个产品场景(VQA / 多模态 RAG / 视频理解 / TTS+ASR)选合适的架构。

一、为何"多模态"变成一个问题

1.1 语言 vs 视觉信号的张力

  • 语言:高度离散 + 强语义 + 序列天然。一个 token ≈ 一个语义单元。
  • 视觉:连续 + 弱语义 + 二维空间关系强。一个像素 ≈ 几乎没语义。
  • 音频:一维时间序列 + 频域分析友好,每 10-20 ms 一个 frame。

把视觉塞进 LLM 的本质问题:用什么粒度切?怎么映射到 embedding?怎么让 LLM 愿意去"读"它?

1.2 三条主路

路线代表怎么接
Reservation-based / 早期融合LLaVA, Qwen-VL, Intern-VL图像切成 patch → 编码 → 当作额外 token 拼在文本 prompt 前面
Cross-attention / 旁路Flamingo, BLIP-2LLM 不动,在 Transformer block 之间插一层 cross-attn 接图像特征
Diffusion-basedStable Diffusion, DiT, Sora文本做 conditionUNet/DiT 的去噪过程,反向过程生成图像 / 视频 / 音频

note

还存在第四条更激进的「tokenizer 路线」: 用 VQ-VAE 把图像/音频 диск化成一个真正的离散词表(如 VQGAN),直接套 LLM 自回归——ESLM / AudioLM / VQGAN-Transformer / Parti 走这条。它统一但训练数据贵,本章末尾会点一下。


二、ViT:图像 → token 序列

2.1 Patchify

ViT(Dosovitskiy 2020)做了一件极简的事:像 NLP 切 token 一样切图像块

原图 H × W × C
   └─ 切 P×P 的 patch
       → 数量 N = (H/P) × (W/P)
       → 每个 patch 拉平 (P*P*C) 维向量
       → 线性映射到 D 维
       → 加上位置编码 (sin/cos 或 RoPE)
       → 进 Transformer Encoder (就是 BERT 那一套)

示例:224×224 图,P=16,得到 14×14 = 196 个 patch,每个 768 维 → 一个长度 196 的"图像句子"。

2.2 与 §3、§10 的接

  • ViT 的 self-attention 与 §3 Transformer 完全同构,只是把"token = word"换成"token = image patch"。
  • §10 CLIP 配合:ViT 是 CLIP 视觉分支,CLIP 训练确保 ViT 输出的 embedding 与文本对齐。

2.3 spot 问题:分辨率与序列长度

  • 图像切完细粒度 patch,token 数随分辨率二次方涨:448×448 → 28×28 = 784 patch;高分辨率图 token 列完后轻易几千。
  • 高分辨率方案:
    • 动态切片 / AnyRes:只在物体所在的格子切 patch,整体 token 数下降。
    • token 聚合 / pooling:把 4 个相邻 patch 合一个。
    • 读懂的 adapter:如 Q-Former(BLIP-2)把 256patch 摘要到 32 个 query token。

三、reservation-based 路线:LLaVA

3.1 最小骨架

LLM (Vicuna / LLaMA / Qwen) ── 标准 Transformer
   ▲
   │ 图像 token 拼在前面
   │
ViT (CLIP 视觉分支) → projection MLP → image tokens

伪代码:

def llava_forward(image, text_prompt):
    img_feat = vit(image)                       # [N_patch, D_v]
    img_tok  = projector(img_feat)              # [N_patch, D_llm]
    txt_tok  = tokenizer(text_prompt)           # [T, D_llm]
    seq      = cat([img_tok, txt_tok, ...], 0)  # 拼成一条
    return llm(seq)                             # 标准 LLM

3.2 训练两阶段

  1. 预训练对齐(projection MLP):用图文对(如 LAION-CCN / CC3M),冻结 LLM 和 ViT,只学 projection——目标 next-token CE,让 LLM 学会读 image token。
  2. 指令微调(LLM + projection):用 GPT-4 生成的视觉指令数据(LLaVA-Instruct),解冻 LLM,让模型学会"看图答题"。

3.3 取舍

  • ✅ 极简、复用已有 LLM(不动 attention 结构)、贡献就是 projection + 数据。
  • ✅ 指令微调几乎和纯文本 SFT 同构 → 工程上友好。
  • ❌ 图像 token 占主上下文(高分辨率图成千 token),挤占有效文本长度。
  • ❌ 对视频、长序列(数千帧)不友好 → 后续 Qwen-VL / Intern-VL 加分辨率自适应 + token 聚合。

四、Cross-attention 路线:Flamingo / BLIP-2

4.1 思路:LLM 不动,加旁路

LLM block (decoder-only)
   ├─ self-attn (原有)
   └─ cross-attn (新加一层)
         K, V ← image features
         Q    ← self-attn 输出的 hidden

每 N 个 block 插一层 cross-attn,让 LLM "看一眼图像特征"。

4.2 Flamingo(2022)

  • Perceiver Resampler:把任意形状的视觉特征(几千 patch)压成 32 个 latent token——这就是 §5.3 那一类 token 聚合更彻底版本。
  • Gated cross-attn:cross-attn 的输出去 zero-init + gated,训练开始时纯走 LLM 不被噪声拉偏,再逐渐打开 gate——同一思想也用在 §3 残差初始化
  • 用 Interleaved image-text 数据训练(图文交错,比 paired 多得多)。

4.3 BLIP-2(2023):Q-Former

BLIP-2 的"Q-Former"是一个小 Transformer,32 个可学习的 query token 去抽 image feature——可类比 retrieval 的 query,但是是 token-level。

Frozen image encoder (ViT-G)
        │
        ▼
Q-Former (32 learnable queries)
        │
        ▼
32 个 image token → projection → 拼到 LLM prompt 前面 OR 喂 cross-attn
        │
        ▼
Frozen LLM (OPT / FlanT5)

Q-Former 两阶段训练:(1) 一阶段 vision-language 对比 + 生成 +图文匹配;(2) 接 LLM 学生成。

4.4 Cross-attn vs Reservation 取舍

维度Reservation (LLaVA)Cross-attn (Flamingo / BLIP-2)
改 LLM 结构不改加层
上下文消耗占主窗口(图越多越挤)几乎不挤
训练成本高(LLM 要微调)可冻 LLM
灵活性全参数微调,迁移性强多任务接入要重新设计 gate
视频/多图不友好更友好(Perceiver Resampler)

tip

选型口诀:要 zero-shot 多模态、上下文压得紧 → cross-attn 路线;要全力微调下游任务、不在乎 token 数 → reservation 路线。 实际生产里两者越来越融合,例如 Qwen-VL 在 reservation 基础上加 dynamic patch + 摘要,把 cross-attn 的"省 token"优点偷了过来。


五、扩散多模态:Stable Diffusion / DiT / Sora

5.1 复习 §5 扩散模型

回顾 §5 generative:扩散模型 = 加噪 → 学反向去噪。生成时从纯噪声开始,一步步去回原图。

5.2 文本条件怎么进

Conditioning:把文本编码后注入 UNet 的去噪过程。三个层次:

层次做法
全局 cond 向量把 CLIP 文本 embedding c 加到 UNet 的中间层(class-conditional 那套)
Cross-attention文本 token 作为 K/V,图像 feature 作为 Q——和 §4 Flamingo 一模一样
T5 / CLIP 双 stackSDXL / SD3 用两个文本 encoder 拼出更丰富的 condition

note

多模态扩散里文本不是模型"输入",而是去噪过程的 condition——文本和图像通过 cross-attn 在每一步去噪时交互。这和 LLaVA 的"拼在前面"是不同的耦合方式。

5.3 DiT:用 Transformer 替代 UNet

DiT(Diffusion Transformer, Peebles & Xie 2023)的核心改动:把 UNet 的卷积块换成 Transformer block——self-attn 处理 latent patch + cross-attn 接文本。

  • 训练好的 scaling:DiT-XL/2 在 ImageNet 给出比 UNet 更好的 FID/CLIP。
  • Sora 的底座:视频被编码成 latent 的 时空 patch 序列,DiT 一次性生成。

5.4 Latent Diffusion(Stable Diffusion 的"latent"含义)

不在像素空间去噪(512×512×3 太大),而是在 VAE encoder 把图像压到 64×64×4 的 latent,去噪过程在 latent 上进行,最后 VAE decoder 还原像素。

image ──VAE-enc──► latent z (64×64×4)
                       │
              diffusion 在 z 上做去噪
                       │
                   z_clean
                       │
                  VAE-dec
                       │
                     image
文本 condition 通过 cross-attn 注入去噪网络

5.5 失败模式与对策

现象原因对策
多手多脚长文细编码不足 + 采样 step 太少增加 text encoder 容量、用 SDXL 的 dual encoder、提高 sampling step
文本不跟随CLIP 文本 embedding 抽象级别太高、condition 强度低用 T5 + classifier-free guidance 提高 condition 权重
构图坏无 spatial 约束ControlNet / layout-to-image 加空间条件
视频时间不稳各帧独立去噪加时间维度的 3D DiT + 视频专用 dataset

六、视频 / 音频 / 跨模态生成

6.1 视频

  • Sora / Video Diffusion:DiT 在 时空 latent patch 上做去噪,关键设计:spatial-temporal tokenizer 把视频 cron 化为三维 latent,attention 同时跨空间 + 跨时间。
  • 长视频一致性:靠"前 N 帧作为 condition 短去噪出后续几帧"(rollout-conditioned),与 AR 采样 的 cache 一个道理。
  • 运动 controlled 生成:Motion Brush / DragNUWA 等用关键点作为额外 condition。

6.2 音频:TTS / ASR 互为孪生

  • TTS(文字 → 音频):现代系统用 audio codec(EnCodec / SoundStream)把音频变成离散 token,然后用 LLM 自回归地预测下一 audio token——VALL-E / SpearTTS / Voicebox。
  • ASR(音频 → 文字):whisper 用 encoder-decoder Transformer,encoder 处理音频的 mel-spectrogram,decoder 出 token;它就是 §3 的 Encoder-Decoder 派。
  • 统一语音对话:GPT-4o / Gemini Live 把 TTS + LLM + ASR 联合训,让端到端语音对话不像"先用 ASR → LLM → TTS 三段管线"那样延迟过大。

6.3 跨模态检索:CLIP 已经做了一半

CLIP 把图文塞进同一空间。同理可以做"音频-文本"(CLAP)、"视频-文本"(VideoCLIP)、"code-自然语言"(Byteword embeddings)。核心都是 §10 InfoNCE + 配对数据。


七、多模态训练数据:从配对到指令

7.1 三类数据

类别用途代表
配对(image-caption)对齐预训练LAION-5B / CC3M / CC12M
交错(interleaved image-text)学会"在文本中插图像"的语境OBELICS / Multimodal-C4
指令 / VQA(problem-solution)学任务感LLaVA-Instruct / VQAv2 / OK-VQA

7.2 数据质量决定上限

  • caption 噪声大:网页爬的 alt-text 不一定描述图——多模型自蒸馏(用 LLaVA 生成生成 caption 训新 LLaVA)。
  • 指令数据少:人工造贵 → 让 GPT-4 / Claude 看图生问答对。
  • 极长尾概念:罕见场景(医学影像、工业缺陷)需要领域微调,不是通用模型能搞定的。

7.3 与 §8 RLHF 的接

  • 多模态 RLHF:用人类偏好对 (image, prompt, response_chosen / response_rejected) 做 DPO/PPO,和文本 RLHF 完全同构(参考 §8 DPO 公式)。
  • RLHF-Curious:让 VLM 主动选 informative 图像生成,类似 Active Learning。

八、多模态 RAG:让 VLM 用上"图像知识库"

query (text)
   │
   ▼
retriever (跨模态 embedding, CLIP-based)
   │ 从 image+text 库检索 K 个最相关候选
   ▼
VLM 读 K 张图 + 原文 → answer
  • 与文本 RAG 唯一的区别:retriever 是 CLIP / BLIP-2 这种跨模态 embedding。
  • 工程挑战:图像 embedding 大、索引要在 pgvector / Milvus / Qdrant 这种高维索引上做。
  • 适用:医学问答、技术文档检索(带图表)、法务合同扫描件检索。

九、Benchmark 与评估

9.1 视觉理解 benchmark

名字测什么
VQAv2通用视觉问答
GQA推理 + 关系
OK-VQA需要外部知识的 VQA
MMMU多学科、本科难度的多模态
MathVista数学题带图表
MMBench / MME多维度 VQA 综合
DocVQA文档 OCR + 推理

9.2 生成评测

  • FID(Fréchet Inception Distance):生成图像分布 vs 真实图像分布的距离(用 Inception-V3 抽 feature)。
  • CLIP Score:生成图 + 提示文本的 CLIP 相似度。
  • 人类评测:DALL-E / Midjourney 论文标配的人类 preference 比较。
  • Video metric:FVD(Fréchet Video Distance),跟 FID 同思路但捕捉时序。

9.3 陷阱:VLM 的 hallucination

  • 虚假对象:VLM"看到"图里没有的物体(POPE benchmark 专测这个)。
  • 固执偏置:图里只有一只猫,VLM 说"两只猫在玩"——LM 强先验压过视觉。
  • 位置推理差:左右、上下、前后极易错,研究者故意造 hard benchmark(GQA spatial)。

十、与前面章节的接口表

接口提供什么本部分怎么用
§3 Transformerself-attn / cross-attn / MHA / encoder-decoderViT、cross-attn 路线、ASR/TTS 端到端
§5 generative扩散 / ELBO / AR多模态扩散、视频生成
§10 contrastiveInfoNCE / CLIP zero-shot多模态对齐预训练、跨模态 retrieval
§7 tokenizerBPE / VQ 离散化audio codec / VQ-VAE 把图像/音频 token 化
§9 training-at-scale长 context / 大 batchViT 大分辨率训练、视频 diffusion 训练显存
§11 agentstool use / code interpreter多模态 agent(看网页图、生成图、读图表)
info-theory互信息 / 率失真latent diffusion 的 VAE 率失真 + 多模态对齐的互信息下界
DB vectorpgvector / vector 索引multimodal RAG 的 retrieval
crypto/zkp隐私计算medical multimodal RAG 隐私 + 隐私审计

十一、速查表 / 结束

tip

一页快速唤回:

  • ViT:图像切成 N 个 patch → 768 维 embedding → 进 Transformer(和 NLP 同构)。
  • reservation based (LLaVA):image token 拼在 prompt 前 → 改动少 + 训练简单。
  • cross-attn (Flamingo / BLIP-2):LLM 不动 + 旁路 cross-attn → 省上下文 + 可冻 LLM。
  • Q-Former / Perceiver Resampler:把上千 image feature 摘要成 32 个 token。
  • Stable Diffusion:latent diffusion + 文本做 condition(cross-attn)在 UNet/DiT 去噪。
  • DiT:把 UNet 换 Transformer block,scaling 友好,Sora 的底座。
  • TTS/ASR:audio codec 把音频 token 化 → LLM 自回归;whisper 是 encoder-decoder。
  • 多模态 RAG:CLIP 系 retriever + VLM reader;和文本 RAG 只差 retriever。
  • 数据三件套:配对 caption → 对齐;交错 image-text → 语境;VQA 指令 → 任务。
  • 评估:FID/CLIP-Score(生成),VQA/MMMU/MathVista(理解),POPE(防幻觉)。

下一篇: 元抽象卷 · 跨章节大主题.

13. LLM 推理与部署: KV Cache / PagedAttention / 连续批处理 / 量化

TL;DR

训练决定了模型"会什么",推理决定了模型"多贵、多快、能服务多少人"。LLM 推理的物理瓶颈不是算力而是显存带宽:decode 阶段每生成 1 个 token 都要把全部权重读一遍,且 KV cache(历史的 key/value 缓存)随序列长度线性增长、按请求动态分配——这两点催生了 vLLM 的 PagedAttention(按物理块分配显存,消除碎片)和连续批处理(新请求动态插入解码中的请求)。再叠加投机解码(小模型草稿 + 大模型验证)和量化(GPTQ/AWQ,权重压到 4bit)后,一个 70B 模型才能在单卡上跑出可用的吞吐。这一章给全链路的数字账本和工程架构。

读完应能:

  1. 推导 decode 的 token 速度公式(≈ 显存带宽 / 模型字节数),并解释为什么小模型不一定比大模型便宜。
  2. 写出 KV cache 的显存公式(2 × layers × kv_heads × head_dim × seq_len × bytes),说出 GQA/MQA 解决什么。
  3. 讲清 PagedAttention 的物理块/逻辑块映射与连续批处理的收益(显存利用率、调度自由度)。
  4. 解释投机解码为什么"多算一次小模型反而更快",以及量化 W4A16 的误差来源与校准(GPTQ/AWQ)。
  5. 画出生产 serving 架构:prefill/decode 分离、GPU 池、路由、弹性扩容、容灾。

一、先算账: decode 为什么慢

LLM 推理分两个阶段:

  • prefill:一次处理整个 prompt,并行度高,受计算(FLOPs)限制;
  • decode:逐 token 生成,每次只算 1 个 token,受显存带宽限制。

decode 每步的耗时 ≈ 读一遍全部权重的时间

$$\text{tokens/s} \approx \frac{BW_{\text{HBM}}}{\text{模型字节数}}$$

以 70B fp16 模型(140 GB)、H100(3.35 TB/s)为例:$3350/140 \approx 24$ tokens/s——这是单请求理论上限,实际 10-15 tokens/s 就很好了。

warning

反直觉结论:模型小一倍 ≠ 快一倍。7B 和 70B 都吃同样的"每 token 读全部权重",7B 只是少几倍字节(且更吃不到带宽饱和)。真正让吞吐起飞的是同时服务多个请求共享权重读——批处理是唯一免费午餐。

二、KV cache: 推理显存的头号消耗

自注意力里,每个历史 token 的 K/V 都要留给后续 token 查询:

$$\text{KV bytes} = 2(\text{K+V}) \times \text{layers} \times \text{kv_heads} \times \text{head_dim} \times \text{seq_len} \times \text{bytes}$$

70B(80 layers, GQA-8, head_dim 128, fp16)在 4K 上下文:

$$2 \times 80 \times 8 \times 128 \times 4096 \times 2 \text{B} \approx 1.34 \text{ GB / 请求}$$

4096 并发请求就要 ~5.5 TB——超过单机显存。KV cache 是"按请求动态分配、随生成长短不一"的内存,和操作系统面临的内存碎片问题一模一样,于是有了 PagedAttention。

GQA / MQA

多头注意力每头一份 KV(MHA)太贵;MQA(所有头共享 1 份 KV)省显存但损质量;GQA(分若干组共享)是折中——Llama 2/3 70B 用 GQA-8,KV 直接除以 8。这是"用质量换显存"的教科书权衡。

三、PagedAttention / vLLM

3.1 问题

朴素实现:请求进来一次性分配 max_seq_len 的连续显存。结果:

  • 内部碎片:每个请求只用到一小段;
  • 外部碎片:连续分配在并发请求间互相挤压;
  • 显存利用率往往只有 20-40%。

3.2 解法: 按块分配

逻辑 KV cache (连续 token 序列)
  [t0 t1 t2 t3 | t4 t5 t6 t7 | t8 ...]
      ↓  逻辑块 → 物理块映射 (类似虚拟内存页表)
物理块 (固定大小, 如 16 tokens/block, 分散在显存各处)
  [block 7] [block 3] [block 9] ...
  • 按需分配物理块,不需要预测最大长度 → 内部碎片消除;
  • 块可分散 → 外部碎片消除,且多请求可共享同一物理块(beam search/前缀缓存);
  • 代价:block table 一次间接寻址 + 块内 padding(最后一个块不满)。

3.3 连续批处理(Continuous Batching)

传统静态批处理:等整批全部生成完才回收。连续批处理让新请求随时插入解码中的批次:

t=0: [A prefill] [B decode] [C decode]
t=1: [B decode] [C decode] [D prefill]   ← D 插队, A 已结束

收益:吞吐量提升 2-10 倍(大模型多 10x),因为 decode 的带宽受限状态能一直满载。调度策略(FCFS、SJF、抢占)直接决定吞吐 vs 延迟的曲线——这又是 OS 调度问题的镜像。

四、投机解码: 多算一次反而更快

大模型逐 token 生成是串行的,但验证多个候选 token 可以并行

  1. 小模型(draft model)快速生成 k 个候选 token;
  2. 大模型一次 prefill 并行验证 k 个位置;
  3. 从首个不一致处截断,接受一致的前缀。

加速比 ≈ 草稿接受率 × k(接受率高时接近 k 倍)。典型:70B + 小草稿可达 2-3x。变体:self-speculative(同模型浅层草稿)、Medusa(额外 head 并行预测)。限制:草稿模型要与大模型分布接近,且 k 太大时验证成本上升。

五、量化推理: 4bit 权重 + 16bit 激活

decode 瓶颈是带宽,所以权重越小越快。W4A16(权重 4bit、激活 16bit)把 70B 从 140GB 压到 ~35GB——单卡 H100 直接能跑。

朴素 RTN(round-to-nearest)误差大,于是有:

方法核心思想特点
GPTQ逐层逐列做二阶近似误差最小化(Hessian 逆),量化时补偿一次性离线量化,精度好
AWQ观察激活分布,按通道保护重要权重(缩放通道)不重训,速度快,精度稳
FP8 训练后量化直接 FP8,H100 原生支持精度损失小,工业默认
KV cache 量化(FP8/INT8)压 KV 而非权重显存减半,长上下文必备

warning

量化后一定要看下游任务精度而不是困惑度:4bit 对推理/代码任务往往无感,但对数学/长尾知识任务可能掉点。生产上线流程 = 离线校准 → 任务集评测 → A/B 灰度,缺一不可。

六、生产 serving 架构

客户端 ─► API Gateway ─► 调度/路由 (按模型、按租户、按负载)
              │
              ├─► Prefill 池 (算力密集, A100/H100)
              │       │  KVCache/激活经 RDMA/PCIe 传递
              ├─► Decode 池 (带宽密集, 长 KV cache)
              │       │  → vLLM/TensorRT-LLM/SGLang
              └─► 冷路径: 量化/蒸馏模型、请求排队、限流、回退

关键设计点:

  • PD 分离(prefill/decode disaggregation):prefill 吃算力、decode 吃带宽,混跑互相拖累;分离后各自最优批大小,但 KV 传输有成本;
  • 前缀缓存:系统提示词/文档前缀共享 KV 块(PagedAttention 的共享能力),长文档 RAG 场景吞吐翻倍;
  • 弹性与容灾:按 QPS 扩缩容、队列背压、超时降级(小模型兜底)、多副本灰度;
  • 观察指标:TTFT(首 token 延迟)、TPOT/ITL(token 间隔)、吞吐(tokens/s/GPU)、KV 命中率、队列深度。

七、一页速查

瓶颈:  prefill=算力, decode=带宽; tokens/s ≈ 带宽/权重字节
KV:    2×L×kv_heads×head_dim×seq×bytes; GQA/MQA 除以组数
PagedAttention: 逻辑块→物理块映射, 消除内外碎片, 支持共享
批处理: 连续批处理让新请求插队 → 吞吐 2-10x
加速:   投机解码(草稿+验证) 2-3x; 前缀缓存共享 KV
量化:   W4A16: GPTQ/AWQ/FP8; KV 量化撑长上下文
架构:   PD 分离 + GPU 池 + 路由/限流 + 弹性; 盯 TTFT/TPOT/吞吐

下一篇: 元抽象卷 · 跨章节大主题

第十二部分 · 元抽象: 跨章节大主题

为什么需要这一部分

前面十一部分按"领域"切片:DSA / OS / 网络 / DB / 编译 / 分布式 / 系统设计 / 计算机组成原理 / 计算理论 / 密码学与安全 / 信息论与编码。每一章其实都在反复讨论几个横切关注点——但这些横切关注点被领域拆散了。

这一部分把它们抽出来,单独成章:每一篇都假设你已经读完前面十一部分的具体章节。第八部分(计算机组成原理)提供了硬件层的 Cache/MESI/HBM/SM/Tensor Core/NVLink 等深度细节;第九部分(计算理论)给"可计算=_可高效计算"封顶;第十部分(密码学)把对抗 NPC 难度的代价转移为协议设计目标;第十一部分(信息论)给压缩 / 通信 / 纠错的数学极限。本章把这些分散的极限上升到抽象层——MESI 与 Paxos / FLP 与 Rice / 香农熵与 NP 复杂度 / 椭圆曲线群与图灵机归约之间的同构。读完后你不再需要记某个具体算法的常数,因为支撑它的抽象结构已经刻在脑子里。

与前面几个关键部分的关系

第十一部分(计算机组成原理)是硬件底座,本部分是抽象桥梁。比如:

  • 第八部分讲 MESI cache coherence 的细节(snoop bus, directory protocol, write-back buffer),本章 memory-hierarchy.md 把 MESI 上升到"它是一种分布式一致性协议"——和 Paxos、两阶段提交同构
  • 第八部分讲 HBM3 的 TSV 堆叠、3.35 TB/s 带宽,本章 hardware-shapes-software.md 推理出为什么 LSM-tree 的 compaction 要以 MB 为单位做 IO
  • 第八部分讲 GPU warp、Tensor Core systolic array,本章 hardware-shapes-software.md 串联出"SIMT → 矩阵乘友好 → Transformer attention 爆发"的完整推理链
  • 第九部分讲 Rice 定理对所有非平凡语义性质封顶,本章 runtime-semantics.md 解释为什么静态分析 / borrow checker 永远只能近似——必须借助 抽象解释 限制在可判定子语言
  • 第十部分讲 SHA-256 / Shamir / Schnorr,本章 concurrency-consistency.md 把"共识协议"与"分布式工作流验证"统一在"无信任协调"框架下
  • 第十一部分讲 Shannon 熵 / 容量,本章 memory-hierarchy.md 把"信道容量"和"cache + DRAM + SSD"层级放回同一条带宽—延迟幂律曲线

读法:先扫一遍第八到十一部分对应章节,再回来看本章对应抽象——你会感受到"硬件限制 / 数学极限 / 协议抽象"与"软件选择"之间那条清晰的因果线。

章节

阅读顺序建议

1-3 看"抽象上的等价 / 不等价"; 4-7 看"硬件到软件 / 软件到分布式"的同构。尤其 4 和 7 应与第八部分(计算机组成原理)交叉阅读;5 与第九部分(计算理论)/ 第五部分(编译原理)交叉阅读;6 与第六部分(分布式系统)/ 第十部分(密码学)交叉阅读。每篇引用前面的具体章节作为论据。

1. 顺序 vs 链接: CPU 视角下两种物理化

一句话

数据结构教科书里有十几种结构, 但落到物理内存上只有两种选项: 顺序 (contiguous) 和 链接 (linked). 顺序赢在 cache locality 和 SIMD 吞吐, 链接赢在 O(1) splice 和迭代器稳定性. 这两种物理化在 CPU 视角、运行时视角、网络协议视角、磁盘视角都反复出现 —— 理解了"顺序 vs 链接"这一对双胞胎, 你就理解了数据结构差异的 80%.

思想链

工程问题: 为什么 std::vector 几乎总是比 std::list 快?
  └─► 数学对象必须选一个物理化身
        └─► 顺序: addr[i] = base + i * sizeof(T), 地址可以直接算出来
        └─► 链接: node->next 逐个追指针, 地址不可预测
  └─► 硬件三层把差距放大成数量级
        └─► cache: 顺序流一次取回 64B line 全是有用数据;
                   链表节点散落各处, 指针 + 有效负载挤在一起
        └─► prefetcher: 固定 stride 的顺序流是理想客户; 指针追逐无法预取
        └─► SIMD: AVX-512 一条 load 吃 16 个 int; 链表喂不进向量寄存器
  └─► 但链接买到了两样东西: O(1) splice + 迭代器稳定性
        └─► 选型问题归结为一句: "你要局部性, 还是要稳定性?" —— 其余全是推论

第一站: 物理化

数据结构本身是数学对象:

  • 数组 = 索引到值的有限映射;
  • 链表 = 头 + 尾组成的可拼接序列;
  • 二叉树 = 节点 + 左右孩子组成的多级结构;
  • 哈希表 = 函数 (hash) → 槽 + (比较) 的组合.

计算机必须给数学对象一个物理化身: 内存的字节怎么排布、指针怎么连、cache 怎么预取. 这一步 "数学对象 → 物理化身" 的选择几乎只有两条路:

选项 A: 顺序 (contiguous)

把所有元素紧排到连续内存, 用偏移量 i 找元素: addr[i] = base + i * sizeof(T).

  • 代表: 数组、动态数组、堆、CSR 邻接表、B+ 树页、SIMD 缓冲、SSD page;
  • 优势: O(1) 索引、cache 极友好、SIMD 可并行、零指针开销;
  • 代价: 大小固定 / 改大小要扩容、中间插入代价 O(n)、迭代器失效.

选项 B: 链接 (linked)

每个元素自带一个或多个指针指向下/上一个元素, 内存可以散乱分布.

  • 代表: 链表、树、跳表、哈希桶链、std::map (红黑树);
  • 优势: O(1) splice (已知指针时)、迭代器稳定、动态扩容零拷贝;
  • 代价: 每元素 +8B (或更多) 指针开销、cache miss 风暴、几乎无法 SIMD.

同一抽象的两条物化路径

数学抽象层面, 它们是一对对偶: 同一个抽象有两条工程化路径. 下面从 CPU、运行时、协议、磁盘四处各举一对实例:

CPU 层

顺序: SSE/AVX SIMD — 16/32/64 字节一条 load 装多个元素 ≈ 1 cycle / 8 个元素
链接: 间接寻址 (load 指针再 load 数据) ≈ 10+ cycle / 元素

CPU 在 SIMD 上一次处理的就是"一段顺序". "链接" 在 CPU 层是被惩罚的代名词 —— 链接破坏 prefetcher 的 stride 模型.

内存模型层 (运行时)

顺序: std::vector / Go slice / Python list 内部 = 一段连续字节.
链接: std::list / intrusive_list / dict overflow bucket 链.

std::vector::iterator 在扩容时失效 (底层基地址换了); std::list::iterator 在 erase 之后对其他节点仍然有效 —— 这就是 "链接" 的标志特征.

网络协议层

顺序: TCP 字节流 — 数据按序到达, 滑动窗口按 sequence 号推进
链接: IP 路由的 next-hop 链 — 数据包一跳一跳地走

TCP 是 sequential 接收, IP 是 hop-by-hop 路由 —— 这是网络栈里同型的两个层级 (详见 三次握手与滑动窗口).

磁盘与日志层

顺序: WAL / LSM-Tree / Kafka append-only — 顺序写可达 GB/s
链接: inode / extent tree / B+ 树内部节点

LSM-Tree 把"顺序写"做到极致 (单段顺序追加), B+ 树把"范围查询"做到极致 (叶子链接 + 内部有序). LSM 与 B+ 都在"一棵树"上, 但 LSM 把"顺序"放在 IO 层, B+ 把"顺序"放在内存层 —— 这就是同一抽象不同物化的对偶 (分别见 LSM-Tree 与 SSTableB+ 树索引).

note

这对对偶在磁盘层还有一个直接后果: SSD 最怕随机小写, 所以 LSM 的"顺序追加"天然对 SSD 友好, 而 B+ 树的随机页写只能靠 FTL 兜底——写放大因此差出数倍, 量化分析见 存储硬件: NAND Flash / SSD FTL.

怎么决定选哪种?

业务真的需要 迭代器稳定性 / splice / 已知指针位置的 O(1) 操作 ⇒ 链接. 否则, 选顺序.

一个简单决策表:

需求特征推荐物理化
批量随机访问顺序
中间频繁插入 (无已知指针)顺序 + 二分定位后批量搬移
中间频繁插入 (已知指针)链接
大空间扩容 + 容量不可预测顺序动态数组
大空间扩容 + 容量可限定顺序 ring buffer
跨多线程需要稳定迭代器顺序 std::deque 或链接
范围扫描顺序 (任何 SIMD 友好的形态)
高 IO 顺序写LSM (顺序物化的工程化版本)

工程的折衷: 跨层同构

这一段的中心观点: "顺序 / 链接" 这对对偶在不同层次反复出现.

软件层: 数组 vs 链表
OS 层:  page cache 顺序读 vs fsync 散写
网络层: TCP 顺序字节流 vs IP 逐跳路由
硬件层: SIMD packed op vs 间接寻址

底层是同一抽象的两个方向物化: 在同一规模上, "顺序" 总在 cache、SIMD、batch 上占优; "链接" 总换来 splice、稳定迭代器、跨层 indirection.

多语言对比

语言标准库对 "顺序 / 链接" 的默认提供非常不同:

语言主顺序容器主链接容器备选
C++std::vectorstd::liststd::deque 折中
RustVec<T>标准库故意不内置Box<Node> 自实现
Go[]T 切片container/list 通用ring 环形缓冲
Pythonlist无纯链接类型deque 分块链接
JavaArrayListLinkedListArrayDeque 通常更优
JSArray 紧排模式无内置对象模拟链表

特别提到 Rust 故意不在标准库内置链表 —— 这是语言设计哲学的表态: 现代默认应该选顺序, 链接只在确需时引入. 这个决定不是性能微调, 而是把数据结构物理化的选择显式化 —— 你真需要链表就得自己写或者引第三方 crate. 这反而强迫你思考清楚需求.

FPGA 视角

在 FPGA 上, 这种对偶更明显:

  • 顺序 BRAM: 单地址空间, 给 base addr + offset 即可寻址, 等价软件的数组;
  • 链式 BRAM: 链表节点存下一节点地址, 需要在 BRAM 上模拟指针解引用, 一次跳转延迟几十 cycle.

数据流图 (DFG) 加速器里常见的 shift register / systolic array 就是顺序物化的"流水"; 而链式描述必须在 BRAM 上模拟 ptr. 这就是 FPGA 上几乎所有高速数据通路都选顺序物化的原因.

这一章带走的东西

  • 物理化几乎只有顺序 vs 链接两条路, 绝大多数数据结构都是这两者之一或其组合;
  • 顺序赢 cache + SIMD + indexing, 链接赢 splice + 迭代器稳定性;
  • 网络层、OS 层、硬件层都有这种对偶同构;
  • 选哪种不只看操作复杂度 O(), 更看 cache、并发、迭代器稳定性;
  • Rust 标准库不内置 list 是一种哲学态度: 显式选择物理化.

warning

"链表插入 O(1)" 只在已知指针时成立, 先找到插入点仍然是 O(n). 而 vector 中部插入虽然要搬移元素, 但那是一次 SIMD 友好的 memmove, 元素数不大时实测往往比 list 更快——不要用复杂度记号代替 benchmark, 两者的基准对比见 数组与动态数组链表: 单链/双链/跳表.

一页速查

维度顺序 (contiguous)链接 (linked)
寻址base + i * sizeof(T), O(1)追指针, O(n)
cache / prefetcher友好, stride 固定miss 风暴, 无法预取
SIMD可向量化喂不进向量寄存器
中间插删O(n) 批量搬移已知指针时 O(1)
迭代器稳定性扩容即失效erase 其他节点仍有效
空间开销只有元素本身每节点多 1-2 个指针
代表结构array / vector / deque / 堆 / CSR / B+ 叶子list / 树 / 跳表 / 哈希桶链
跨层同构WAL · LSM · TCP 字节流 · SIMD loadinode · extent · IP 逐跳 · 间接寻址

下一篇: 2. 摊还 vs 最坏: 工程常数与硬实时的张力

2. 摊还 vs 最坏: 工程常数与硬实时的张力

一句话

"摊还 O(1)" 并不等于 "每一次都是 O(1)" —— 它只承诺一个足够长的操作序列总和是 O(n), 而序列里的某些单次操作本身代价是 O(n). 在一般算法教科书里这个区别无关痛痒, 但在工程中摊还分析隐含了"操作序列会连续执行下去"这一前提. 一旦场景是硬实时 (HFT、AVB/TSN、游戏 tick、音视频 P99), 摊还的最坏情况就会被无限放大成一次事故.

思想链

工程问题: 平均 1 cycle 的 push, 为什么 P99 会炸出 500 μs?
  └─► 摊还分析把"偶尔一次的 O(n)"平摊进长序列
        └─► 前提1: 操作序列长时间运行 (∞ 极限下才成立)
        └─► 前提2: 批次之间没有外部 deadline
        └─► 前提3: 失败可重试, 尖刺不传染给别的请求
  └─► 任一前提被打破 (硬实时 / 有界 deadline / 尖刺传染)
        └─► 摊还退化成最坏: malloc + memset + copy 全部落在一个请求上
              └─► 工程解法: 固定容量 + 环形复用, 摊还 O(1) 变严格 O(1)
                    └─► 硬件早就这么干: DMA descriptor ring / NVMe SQ-CQ

把摊还误读的代价

考虑一个具体场景:

HFT tick 数据流, 每秒 500 万个 tick. 用 std::vector 缓冲.
- 平均每次 push 约 1 cycle;
- 第 2^23 次 push 触发扩容: malloc 16MB (syscall), memset 写一遍,
  再把旧数据 copy 过去 —— 单次耗时 200~800 μs.

平均 1 cycle ≈ 0.3 ns, 但 P99 ≈ 500 μs. 只看平均值的监控完全无感, 但 500 μs 的抖动在 HFT 上已经超过一个 tick interval——直接丢一帧. 这一类 bug 工程上叫摊还 bug, 排查时只能靠 strace / ftrace 抓到那次隐藏的 syscall; 平均延迟曲线上看起来一切完美.

warning

只看平均值 (甚至只看 P95) 的监控会系统性掩盖摊还尖刺. 判断服务是否吃了摊还亏, 直接看 max / P99.9 与 GC/alloc 日志的相关性; 性能方法论见 性能工程: profiling / 火焰图.

摊还成立的前提

摊还分析假设:

  1. 操作序列长时间运行 (∞);
  2. 批次之间没有 deadline;
  3. 失败回滚不影响别人.

如果其中任何一条被破坏, 摊还就等于最坏.

note

摊还分析的严格定义与聚合法 / 记账法 / 势能法三种证明工具, 见 摊还分析入门; 本章关心的是它作为工程假设什么时候失效.

工程上的"反摊还"模式

工程师在硬实时场景下本能地选最坏可预测的结构:

场景通常的摊还结构改造为最坏可预测
HFT 订单簿 (order book)std::vector定容 ring buffer + 对象池
高频网络收包缓冲std::vector<Packet>boost::lockfree::queue 或定容 ring
视频采集缓冲std::vector<Frame>SPSC ring + 双/三缓冲
极低延迟哈希表std::unordered_map定容开放寻址 (open addressing) + spinlock
数据库日志缓冲std::vectorpage 对齐 ring + WAL 顺序化

核心模式: 用固定容量 + 环形复用替代"动态扩容", 把扩容成本一次性付清. 摊还 O(1) 就变成了严格 O(1).

硬件层: DMA ring 是"反摊还"的物化

理解 DMA 的工作方式:

PCIe TLP 一次搬运 128B / 256B;
异步 + 中断链路;
DMA descriptor 排成 ring queue;
内核与网卡各维护 RX ring 与 TX ring, 各含 N 个 descriptor,
生产者/消费者沿环形推进.

这个 ring 的工作模式就是一个 lock-free ring buffer. 网卡和 SSD 控制器都按 ring 设计: DMA 写 head, 内核读 tail, 两边互不改写对方的指针, 各自只 atomic 推进自己负责的那一个. 这种 lock-free ring 是硬件层对"摊还问题"给出的最彻底答案——运行期永远没有分配和搬移, 所有容量在初始化时一次付清.

note

NVMe 把这套 ring 思想直接暴露给了主机软件: 64K 个 Submission Queue / Completion Queue 对, 无锁提交 + 可选 polling. 队列模型细节见 存储硬件: NAND Flash / SSD FTL; 用户态等价物见 epoll / kqueue / io_uring 对比.

FPGA 层

FPGA 上几乎所有数据通路都是 streaming 的, 没有"扩容"概念. AXI-Stream 的 valid/ready 握手要求双方每一拍都能给出决定; 一旦某一拍没有数据, 流水线就 stall, 整条 pipeline 出现一个 bubble.

所以 FPGA 设计 = 把全部容量与缓冲预算放在 init / configuration / reconfiguration 阶段完成, 运行时永远不走 O(n) 操作. 这是 FPGA 项目高可靠的关键: 同步时钟模型一旦部署运行就是强确定性的.

多语言视角下的摊还差异

语言默认行为里的摊还尖刺显式缓解手段
Goappend 扩容 + GC 停顿make(T, 0, n) 预分配 + GOGC/GOMEMLIMIT 调参
JavaHashMap rehashConcurrentHashMap 或预估容量构造
Pythondict resize 一次性重建无法显式预留; 大表改用 list 下标或预估规模建大
C++vector::push_back 扩容reserve + 内存池
RustVec::push 扩容Vec::with_capacity + Box

各家都提供了缓解手段, 但缓解永远是显式的: 默认行为里藏着惊吓. 写高 QPS 服务的工程师必须形成反射: 预分配容量是第一习惯.

这一章带走的东西

  • 摊还 = 平均, 不等于最坏. 硬实时场景必须按最坏做预算;
  • 网络 ring、DMA ring、FPGA stream 都是硬件层"消灭摊还尖刺"的物化;
  • 各语言的默认容器都带摊还尖刺, 必须显式预分配 / 上 lock-free 结构;
  • 平均值告诉你吞吐, P99 告诉你抖动——衡量实时性只能看尾部.

一页速查

维度摊还视角最坏视角
典型结构动态数组 / 均摊哈希表定容 ring buffer / 定容开放寻址哈希
承诺强度序列总和 O(n)每一次操作都有界
尖刺来源扩容 malloc+copy / rehash / GC无 (成本在初始化时一次付清)
适用场景吞吐优先的后台与批处理HFT / 游戏 tick / 音视频 / 控制 P99
工程动作不用管reserve / with_capacity / make(T,0,n)
监控指标平均延迟P99 / P99.9 / max

下一篇: 3. 分治 vs 贪心 vs DP: 什么是"最优子问题分解"

3. 分治 vs 贪心 vs DP: 什么是"最优子问题分解"

一句话

分治 / 贪心 / DP 表面上是三种范式, 仔细看它们其实是"怎么把原问题分解成子问题"的三个约束等级: 从几乎不设限 (分治) 到要求子问题可枚举、按序求解且共享结果 (DP). 理解三种范式的"分解自由度"梯度, 拿到新题就能按顺序机械地排查.

思想链

工程问题: 拿到一道新题, 先试分治、贪心还是 DP?
  └─► 第一问: 子问题怎么分解?
        └─► 子问题互不重叠, 合并即可 ──────────► 分治
        └─► 局部最优可证明等于全局最优 ─────────► 贪心
        └─► 子问题大量重叠, 需要共享解 ─────────► DP
  └─► 三者共用同一块地基: 最优子结构
        └─► 分治: 只要组合函数保持一致
        └─► 贪心: 再加无后效性 + 贪心选择性质
        └─► DP:   再加子问题空间有限、可枚举
  └─► 范式之间可以互相退化
        └─► DP 表里的冗余信息被剪掉 → 变成贪心 (LIS 的 tails 二分)

分级: 从"分解自由度"看三种范式

分治: 任意分解 (递归不考虑子问题之间的依赖), 每个子问题解一次
贪心: 每步只看眼前局部最优, 不回退, 不组合
DP:   列出全部子问题, 按依赖序求解, 子问题之间共享中间结果

三者都是"分解 + 合并", 但对分解形态的约束依次更紧:

  • 分治: 对分解形态无要求, 子问题彼此独立;
  • 贪心: 要求无后效 + 局部最优即全局最优 (贪心选择性质);
  • DP: 子问题之间存在重叠, 必须显式表达依赖以便 memo 化.

数学表达

考虑原问题 P(x). 三种范式都把 P 分解为子问题 P(x_i):

分治: P(x) = combine(P(x1), P(x2)). 子问题彼此独立.

贪心: P(x) = choice(x_k) + P(rest), 其中 x_k 是当前可证局部最优的选择. 选完直接剪掉, 不回头.

DP: P(x) = opt_i { P(x_i') + cost }. 子问题之间是重叠的 —— 多个 P(x) 共享同一批 P(x_i'). 用一张表按拓扑序求解所有子问题, 中间结果只算一次.

三种范式的共同源头

每种范式都假设最优子结构:

P(x) 的最优解由其子问题 P(x_i) 的最优解组合而成.
只要组合时用的是 max / min 这类保序运算,
"子问题取最优 ⇒ 原问题取最优"就严格成立.

进一步:

  • 分治: 不要求定序, 只要组合函数一致;
  • 贪心: 多了 "无后效" + "贪心选择性质" 的假定;
  • DP: 多了 "子问题集合有限、可枚举" 的假定.

记忆口诀: 假设越紧, 适用面越窄, 但换来的复杂度上界也越漂亮.

note

三种范式的完整讲解分别在 分治贪心动态规划; 本章只回答一个元问题——拿到新题先试哪一个、怎么判断试错了.

同一道题三种范式的不同表现

例子: 最长递增子序列 (Longest Increasing Subsequence, LIS).

分治解: 不适用

LIS 几乎不可分治: 左右两半的最优解无法在不重新扫描的情况下合并. 硬写成"分治 + 合并阶段交叉处理", 复杂度反而升到 O(n log² n) 或更高.

DP 解: 经典 O(n²)

dp[i] = max(dp[j] + 1), for j < i 且 a[j] < a[i]

dp[i] 依赖所有 dp[j] (j < i), 子问题重叠明显, 直接开表.

贪心 + 二分: O(n log n)

观察: 不需要保留整张 dp 表. 只需维护 tails[k] = 所有长度为 k+1 的递增子序列中末尾元素的最小值. 新元素比 tails 末端大则追加, 否则二分找到第一个大于等于它的位置替换掉.

这是一个 "DP + 结构性质 + 贪心化简" 的典型形式: DP 的完整表达不是必需的, 可以利用多余信息剪枝, 退化成贪心.

tip

"先写朴素 DP, 再找冗余状态退化成贪心/二分" 是刷题与工程通用的升级路径; LIS 的 tails 数组之所以能二分, 是因为它本身单调——发现这类单调性往往就是从 O(n²) 到 O(n log n) 的全部距离. 相关技巧见 搜索: 二分与三分.

warning

贪心的正确性必须证明 (交换论证 / 归纳 / 拟阵), 不能靠直觉. 反例俯拾皆是: 零钱面额 [1, 3, 4] 凑 6, 贪心给出 4+1+1 三枚, 最优却是 3+3 两枚. 见 贪心的交换论证与适用判据.

工程师视角: 怎么选范式

1. 子问题是否互不重叠、能直接合并?
   是 → 分治.
2. 是否有"局部最优即全局最优"的证明 (或拟阵结构)?
   有 → 贪心.
3. 子问题大量重叠?
   是 → DP.
4. 已写出 DP, 但某一维可以被代数压缩?
   是 → 剪掉那一维: 斜率优化 / 决策单调性 / 位运算压缩,
        往往顺势退化成"DP + 二分"甚至纯贪心.

多语言同构

DP 在各语言里写法大同小异. 但工程上记忆化 vs 显式表给语言选用带来差异:

语言记忆化写法显式表写法
Python@lru_cache 一行dp[k] 显式迭代
TS闭包 + memo Mapdp[]: number[] 迭代
Gomap[state]int一维/二维循环 dp
C++vector<int> memo + 递归dp[...] 一维数组滚动

Python 的装饰器是最优雅的入口, 但大输入下 Python 递归有默认 ~1000 层深度限制且函数调用极慢, 最终仍要改成"拓扑序迭代". 工程上惯用的起步模式:

from functools import lru_cache

@lru_cache(maxsize=None)
def solve(i: int, j: int) -> int: ...

solve(0, n - 1)

但上规模后必须改写成 dp[i][j] 循环迭代——Python 递归太慢、太深、栈可能爆.

工程现实

DP 类问题在大规模业务工程里出现频率不高, 真实使用场景集中在:

  • 序列对齐 (生物信息学);
  • Viterbi 译码 (通信、语音);
  • 强化学习的 value iteration / Q iteration;
  • 矩阵链乘式的循环次序优化 (BLAS 内核调度);
  • 路径规划: DP 配合 A* 或 Dijkstra 做启发剪枝.

DP 不会作为日常产品代码反复出现, 但在系统的某些"模型核心"位置反复出现: 你写的机器人控制、序列对齐、隐马尔可夫模型, 底层都先落到 DP.

这一章带走的东西

  • 分治 / 贪心 / DP 是"分解约束"严格递增的同一族范式;
  • 同一道题不一定三种范式都能解, 但各范式之间存在明确的退化路径;
  • DP 是最适合充当"模型核心"的子问题显式表达框架;
  • 各语言给出的语法表达差异很大, 但抽象完全同构;
  • LIS 的尾部二分 = "DP 解出来之后用结构性单调剪枝退化成贪心"的经典模式.

一页速查

维度分治贪心DP
子问题关系独立不重叠每步剪枝不回头大量重叠
核心前提组合函数一致无后效 + 贪心选择性质最优子结构 + 可枚举
正确性来源归纳交换论证 / 拟阵 (必须证明)最优子结构归纳
典型复杂度O(n log n) 主导项通常最低状态数 × 转移数
失败信号合并代价爆炸找不到反例却 WA状态空间爆炸
代表问题归并排序 / FFT / 最近点对Kruskal / Huffman / 区间调度LCS / 背包 / 编辑距离

下一篇: 4. 缓存层级: 从 L1 到 HBM 到 RDMA 的同构

4. 缓存层级: 从 L1 到 CDN 的全栈同构

TL;DR

一个硬件工程师、一个内核开发者、一个 DBA 和一个 SRE 坐在一起争论"谁的 cache 最聪明"。结果发现所有人的 cache 都是一个模板出来的: hit/miss 二分判定 → 按块取数据 → 替换策略淘汰 → 一致性协议同步多副本 → 写策略决定脏数据何时落下一层。从 1ns 的 L1 到 50ms 的 CDN edge,延迟跨越 7 个数量级,但抽象模型完全不变。整个计算机的体系结构是一部递归的 cache 层次模型——你学透一层,就学透了所有层。


一、全栈延迟光谱: 一张表看尽所有 cache 层

先把这个宇宙摊开。从 CPU 管芯内到地球对面的 CDN POP,每一层都在玩同一个游戏: "用空间换时间,用高一层的小容量低延迟,缓存下一层的大容量高延迟数据"。

层级容量延迟单位块大小替换策略一致性协议详述章节
寄存器~16 KB 全 CPU<1ns8B (x86) / 16B (SIMD)编译器分配 (graph coloring)无需 (单消费者)isa-design.md
L1 D-cache48-128 KB~1ns (4-5 cycle @ 4GHz)64B linePLRU / RRIP (硬件)MESI/MOESI/MESIFmemory-hierarchy.md
L2 cache256KB-16MB~4-16ns64B linePLRU / RRIP (硬件)MESI/MOESI/MESIFmemory-hierarchy.md §三-五
L3 cache4-96MB~12-50ns64B lineBRRIP / Adaptive (硬件)Directory-based (硬件)memory-hierarchy.md §七
TLB (L1/L2)64-1536 项1-7 cycle4KB / 2MB / 1GB pageLRU (硬件)TLB Shootdown (IPI)mmu-dma.md §3
3D V-Cache额外 64MB L3~40ns (+4 cycle penalty)64B line同 L3同 L3memory-hierarchy.md §十
DRAM (DDR5)8-256GB~80-100ns64B (cache line fill) / 8Kb (row buffer)无 (主存储; OS 换页时做)无 (单 master: mem ctrl)memory-hierarchy.md §八
HBM3e24-36GB / stack~50-80ns64B / 1024-bit 宽通道无 (同 DRAM)memory-hierarchy.md §九
CXL Type-3 内存池512GB-2TB~250-350ns64B / cache coherentOS NUMA 调度CXL.cache (MESI 扩展)interconnects.md §三
NVMe SSD1-30TB~50-100µs4KB pageFTL firmware (vendor-specific)无 (单 master)os/fs/filesystems.md
OS Page Cache可用 DRAM 的 80-90%DRAM hit (~100ns); miss 走 IO4KB pageLinux: 双链表 LRU (active/inactive)无 (单 OS instance)os/fs/inode-pagecache.md
InnoDB Buffer Pool可配 (典型 80% RAM)DRAM hit (~100ns); miss 走 IO16KB page (默认)改进 LRU (midpoint insertion)WAL + doublewrite (crash safety)wal-lsm-btree.md
RocksDB Block Cache可配 (典型 4-64GB)DRAM hit; miss 走 SSTable4-32KB data blockLRU / Clock (Hyper Clock Cache)LSM compaction (tombstone)wal-lsm-btree.md
进程内 Cache (Caffeine)~1-8GB (per process)~50-500nsObject-level (variable)W-TinyLFU (window + count-min sketch)无 (多进程不共享, 最终一致性)multilevel.md
Redis LRU~数 GB-百 GB (per cluster)~0.5-2ms (网络 RTT)Key-level (variable)allkeys-lru / volatile-lru / LFU主从复制 (async, eventual)multilevel.md
Memcached~数 GB-百 GB (per pool)~0.5-2ms1MB slab class (per-page)Per-slab LRU无 (无复制, 无持久化)multilevel.md
CDN Edge Cache~TB per POP~5-50ms (从 edge 取); origin miss ~50-300msObject-level (HTTP cache)LRU / 2Q / Hyperbolic (基于 cost)Purge + TTL invalidation (push/purge)multilevel.md
远端网络 (RDMA)跨机 DRAM~1-5µs (IB) / ~5-10µs (RoCE)MTU ~1.5KB / 4KB (RDMA)远端内存管理 (pre-registered MR)IOMMU 隔离 + 单边操作interconnects.md §五; topologies.md

这张表的核心信息: 每一层往上走 1-2 个数量级延迟, 往下一层走 1-2 个数量级容量。每一层都有: 命中判定 (hit/miss)、按块传输 (block/line/page/object)、替换 (eviction)、一致性 (多副本同步)、写策略 (write-through / write-back)。 这五行是 cache 宇宙的"基本力"。


二、同构性证成: 五个维度逐一展开

2.1 命中 / 缺失: 从 TAG 比较到 key lookup

L1 cache 如何判断命中?——把地址拆成 {tag, index, offset}, 取 index 找到 set, 比较所有 way 的 tag 位是否匹配 (见 memory-hierarchy.md §三)。这是纯硬件的 CAM/SRAM 并行比较, 一个 cycle 出结果。

Redis 如何判断命中?——客户端发 GET user:12345, Redis 对 user:12345 做 hash → 找到 slot → 在 dict 里 key lookup → 返回 value 或 nil。延迟从 1ns 变成 0.5ms, 但本质上还是同一个模式: 用 key (地址/字符串) → 查目录 (index/slot) → 比较 tag/键 → 命中了拿数据, 没命中 report miss

每次 L1 cache miss 触发一次 DRAM 访问 (见 memory-hierarchy.md §二: tRCD + tCL + tRP ≈ 45ns 的 DRAM 随机读流程)。每次 Redis miss 触发一次 DB query → 网络 RTT → SQL parse → buffer pool lookup → IO (如果也不在 buffer pool 里)。抽象上这两个流程的形状完全一致: miss → 下一层按块取 → 填回本层 → 返回数据

TLB 的命中判定是这个模式的又一实例: TLB 是一张极小的全相联/组相联 SRAM (见 mmu-dma.md §3.2), 以 {VA, ASID/PCID} 为 key, {PA, permissions} 为 value。TLB miss 触发硬件页表遍历 (page table walk)——逐级读 PML4E → PDPTE → PDE → PTE (见 mmu-dma.md §2.2), 每级一次 DRAM 随机读, 共 4 次 × 100ns = 400ns。这和一次"Redis miss → 查 Postgres → 回填 Redis"的流程在逻辑结构上完全没有区别——TLB 就是一个物理地址的 cache, 页表遍历就是 cache miss 的 penalty path。

2.2 替换策略: 从 PLRU 到 W-TinyLFU 的同构演化

硬件 cache (memory-hierarchy.md §五) 受限于硅面积和功耗预算, 只能在 PLRU、RRIP、BRRIP 等接近但非精确的 LRU 近似上做文章。L1 用简单的 PLRU (一个 per-set 的小状态机), L3 用 BRRIP (扫描行插入时直接给"即将淘汰"的优先级以保护常驻热数据)。

软件 cache 拿到了更充裕的算力预算, 于是可以在 Caffeine 里跑 W-TinyLFU——Window 小队列处理 burst access, Count-Min Sketch 做频率统计, SLRU 主队列根据频率 + 最近性做淘汰 (见 multilevel.md)。这个算法比 BRRIP 聪明, 但它和 BRRIP 要解决的问题是同一个: "怎么区分一次扫描 (永远不会再访问) 和真正的热点 (会反复访问)"。硬件用 re-reference interval 预测, 软件用 count-min sketch 计数——手段不同, 问题同构。

Linux page cache (见 os/fs/inode-pagecache.mdos/memory/replacement.md) 用双链表 LRU: active list (刚被访问或访问过两次) 和 inactive list (上次访问距今较远)。当 inactive list 中的 page 被再次访问, 它 promote 进 active list。这实质上是 ARC (Adaptive Replacement Cache) 的一个简化版本: 维护两个队列, 动态调整两者之间的平衡, 以自适应"扫描"和"热点"两种 workload

InnoDB Buffer Pool (见 wal-lsm-btree.md) 也用了改进版 LRU: midpoint insertion——新读入的 page 不放在 LRU 头部, 而是放在 5/8 位置 (midpoint)。这样一次全表扫描不会把真正的热点全部挤出 LRU (扫描到的 page 从 midpoint 开始, 很快被淘汰; 反复访问的 page 自然晋升到头部)。这是 BRRIP 的软件版本: 扫描行以"即将淘汰"的起点入场, 避免污染热数据。

替换策略的同构: 无论硬件 cache 还是软件 cache, 算法的核心矛盾都是"保护热数据不被扫描冲走"。硬件用 RRPV, 软件用 midpoint insertion / W-TinyLFU / ARC——本质都是给新进来的数据一个"试用期", 只有证明自己是热的才能留下来。

2.3 一致性协议: MESI ↔ Paxos/Raft 的深层同构

这是全章最核心的洞察。重新审视 memory-hierarchy.md §六和 paxos.md 之后, 你会发现 MESI 就是硬件尺度的 Paxos

MESI (4 个 core 共享一个 cache line):
  - Core 0 有 line 在 Modified 状态 (dirty, exclusive owner)
  - Core 1 想读同一 line → 发 snoop 请求
  - Core 0 必须响应: 把脏数据转发给 Core 1 (或写回 DRAM), 自己降到 Shared
  - Core 2 想写同一 line → 发 RFO (Read For Ownership)
  - 总线广播 invalidate → 所有其他 core 的 Shared copy 失效 → Core 2 得到 Exclusive → 提升到 Modified

Paxos (5 个节点对一个 log entry 达成共识):
  - Leader (Proposer) 有 proposal n, value v
  - Phase 1 (Prepare): 发 proposal number n 到 majority, 收集 promise (承诺不再接受 <n 的 proposal)
  - Phase 2 (Accept): 让 majority accept (n, v)
  - 任何新的 Proposer 发起更高 n' 的 Prepare → 必须从前一轮的 accepted value 中学习已决定的 v
  
同构点:
  - MESI 的 Modified → Exclusive 的"写回/转发" ≈ Paxos 的"新 proposer 学习已 accepted 的 value"
  - MESI 的 invalidate 广播 ≈ Paxos 的 Quorum overlap (任意两个 majority 必相交)
  - MESI 的 directory protocol (精确发送 invalidate 给持有者) ≈ Multi-Paxos 的"只需要稳定 leader 与 followers 通信"
  - MESI 的 MOESI Owned 状态 (转发脏数据但不写回 DRAM) ≈ Raft 的 leader 向 follower 复制 log entry, 然后 commit

这两个协议的核心机制几乎逐行对应:

概念MESI (硬件)Paxos/Raft (软件)
状态机4 状态: M/E/S/I → 5/6/7 状态 (MOESI/MESIF)Follower/Candidate/Leader → log state machine
排他权获取RFO (Read For Ownership) 总线事务Leader election (RequestVote)
数据传播Snoop 广播 / Directory 精确发送Leader AppendEntries → followers
脏数据写回eviction 时 write-back 到下一层follower commit 后 ack leader
冲突解决总线仲裁 (priority) / Directory 顺序化Term number 单调递增 (高 term 胜)
共享态降级Invalidate 广播 → 所有 S 变为 ILeader 切换 → 旧 leader 降级为 follower
一致性边界单个 cache coherence domain (socket/CCD)单个 Raft group / Paxos instance

同样的性质也体现在分布式缓存中: Redis 主从复制 (见 multilevel.md) 是 MESI Shared 状态的软件化——主节点持有 writable copy (≈ Modified), 从节点持有 read-only copy (≈ Shared)。主节点写入后异步传播到从节点 → 这和 write-back cache 的"先改本地, 延迟传播到下一层"逻辑一致。CDN 的 purge/invalidation 机制 (见 failure-modes.md) 则是 MESI Invalidate 的全网版本: 源站更新了对象 → 发 PURGE 请求到所有边缘节点 → 边缘节点把旧副本标记为 invalid → 下一次请求触发 miss 回源。

为什么分布式一致性算法的 paper 那么难读? 因为它在软件层重新发明了 MESI 已经在硬件层做过的事情——但多出了网络分区、消息丢失、时钟不可靠这些物理约束, 导致协议多了好几层复杂度。MESI 的通信介质是芯片内铜互联 (确定性延迟, 总线不丢包, 电压信号无歧义), Paxos 的通信介质是 UDP/IP 网络 (不确定性延迟, 丢包, 拜占庭行为可能)——同样是分布式一致性, 底下的信道的可靠性差异决定了上层协议的复杂度差异

2.4 查找结构: TLB → 页表 → 数据库索引

这组同构更直接, 但也更容易被忽略。看 mmu-dma.md §1-3 和 os/memory/virtual-memory.md, 再对比 wal-lsm-btree.md 的 B-tree 索引结构:

TLB          = 虚拟地址 → 物理地址 的 cache
页表         = 虚拟地址 → 物理地址 的完整映射表 (存在 DRAM 里)
B-tree 索引  = key → row location 的完整映射表 (存在磁盘上)

TLB 对页表的关系, = Buffer Pool 对 B-tree 索引的关系。TLB 是页表的 cache, Buffer Pool 是 B-tree 索引的 cache。 一次 TLB miss 触发 page walk (4 次 DRAM 读, 见 mmu-dma.md §2), 一次 buffer pool miss 触发 B-tree 遍历 (从 root page 一路 seek 到 leaf page, 若干次 IO)。命中时都是 O(1) 的直接查找, miss 时都要走完整的索引遍历——然后把翻译结果 (PTE / leaf page) 缓存回高速结构中以备后用

更大的同构: 即使是 DNS (域名 → IP) 也是一种 cache 层次——/etc/hosts 是 L1 (本地, 1µs), local DNS resolver cache 是 L2 (本机, ~1ms), upstream DNS 是 L3 (网络, ~10-50ms), 权威 DNS 是 origin (从 zone file 查, 最慢)。递归查询的流程和 TLB miss → page walk → 填回 TLB 的流程一模一样。

memory-hierarchy.md §九 (HBM3 的 1024-bit 宽总线 + TSV 堆叠) 到 interconnects.md §四 (NVLink 4 的 900 GB/s 点对点宽带) 再到 interconnects.md §五 (RDMA 的远程内存直接访问), 它们在解决同一个问题:延迟不能降, 就加宽通道, 用并行换吞吐

HBM3:   1024-bit bus × 6.4 Gbps = 819 GB/s per stack
        TSV 堆叠 12 层 DRAM die → 物理距离 mm 级 → 延迟 ~50ns
        "既然 DDR5 DIMM 的 64-bit 太窄, 我直接把 1000 根线铺过去"

NVLink 4: 50 GB/s per link × 18 links per H100 = 900 GB/s
        点对点专用链路 → 不共享, 不仲裁 → 延迟 ~100ns
        "既然 PCIe x16 = 64 GB/s 不够, 我直接把 18 条独立链路放上去"

RDMA:    400 Gbps InfiniBand NDR = 50 GB/s per port
        单边操作绕过远端 CPU → 延迟 ~1µs
        "既然 kernel TCP stack 延迟太大, 我让网卡 DMA 引擎直接读写远端 HBM"

这三个技术的共同模式可以抽象为:"你感觉延迟高, 不是因为信道传输慢, 是因为串行化、协议栈、共享仲裁在吃掉你的时间。把通道变宽、把协议变薄、把仲裁去掉, 延迟自然降下来。" HBM 用物理堆叠和极宽总线去掉 DDR DIMM 的封装/走线/仲裁延迟; NVLink 用专用点对点链路去掉 PCIe 树形拓扑的 switch 延迟; RDMA 用单边动词 (RDMA Write/Read) 去掉远端 CPU 中断 + 内核协议栈延迟。三者在不同尺度上, 实现的是同一个工程原则。


三、Memory Wall: 一个概念贯穿所有层级

"Memory Wall"(Wulf & McKee, 1995) 的原始含义是: CPU 算力每年增长 55%, 而 DRAM 延迟每年只改善 7%。25 年后, 这个趋势不仅没有收敛, 反而扩散到整个栈:

每层 Memory Wall 的表现

层级计算侧存储侧Wall 表现
GPU SM / Tensor Core1979 TFLOPS (H100 FP8)HBM3e 3.35 TB/s每 FLOP 只有 ~1.7 字节带宽。一个 matmul 如果数据重用不够, 算力根本吃不饱。
CPU core5-6 IPC × 4GHz = ~20 B ops/sDDR5 100 GB/s每操作 ~5 字节带宽。pointer chasing (链表/树/图遍历) 是 MLP=1 的串行 miss, 带宽被闲置。
数据库 scanSIMD 扫描 64B/cycleNVMe 7 GB/sPCIe 4.0 x4 = 7 GB/s。CPU 的 scan 速度是 NVMe 的 ~10 倍——列存压缩 + 下推 filter 就是为了"不让 IO 成为瓶颈"。
分布式训练8 GPU × 1979 TFLOPSNDR 400 Gbps = 50 GB/s per GPU跨节点 all-reduce 梯度同步的带宽只有 GPU 内部带宽的 1/70。TP 必须在 NVLink 域内, PP/DP 才跨 InfiniBand。
Redis 集群单节点 100K QPS (单线程)网络 10-25 Gbps = 1.25-3.125 GB/s算力充足但网络带宽是瓶颈——Redis 集群的规模上限由网卡带宽和延迟决定, 不是由 CPU 决定。

量化 Wall: 从参数看鸿沟

以 NVIDIA H100 (2022) 为例:

  • 计算: 1979 TFLOPS (FP8) = 每秒 1.979 × 10^15 次乘加
  • HBM3e 带宽: 3.35 TB/s = 每秒 3.35 × 10^12 字节
  • 每 FLOP 可用字节: 3.35 × 10^12 / 1.979 × 10^15 ≈ 1.7 字节/FLOP

一个 FP8 matmul 需要: 每输出元素读 A 的 1 行 + B 的 1 列 = O(N) 个元素, 写 O(N) 个元素。如果 N 很大, 带宽根本跟不上算力——这是为什么 FlashAttention 要通过 block-wise tiling 把数据块留在 SRAM 里循环用, 而不是反复读 HBM。HBM 3.35 TB/s 听起来很大, 但除以 1979 TFLOPS 后少得可怜。

把同样比例拉到 CPU 侧:

  • AMD Zen 5 core: 4 ALU + 3 AGU, 4GHz, 每周期可 issue 8 条微指令
  • DDR5-5600 单通道: 44.8 GB/s = 每秒 4.48 × 10^10 字节
  • 每操作可用字节: 约 1.4 字节/操作 (假设 8 IPC × 4GHz = 32G ops/s)

顺序访问 (row buffer hit) 时可以达到 ~40 GB/s, 但随机访问 (每次换 row → tRCD + tRP) 时吞吐只有 ~1.4 GB/s per channel (见 memory-hierarchy.md §二)——实际的带宽可用率在随机 workload 下坍缩到标称的 3%

Wall 驱动了哪些工程决策

  • HBM (memory-hierarchy.md §九): 既然 DRAM 延迟降不下去, 就用 TSV 堆叠把内存物理搬到离计算 die 0.1mm 以内的距离 + 1024-bit 宽总线把带宽硬顶上去。代价是 $15-20/GB vs DDR5 的 $3-4/GB。
  • 3D V-Cache (memory-hierarchy.md §十): 既然去 DRAM 太慢, 就在计算 die 正上方再焊一层 64MB SRAM。加 4 cycle 跨层延迟, 省掉 400 cycle 的 DRAM miss。
  • CXL 内存池化 (interconnects.md §三): 既然 Memory Wall 导致很多服务器的 DRAM 闲置 (利用率 50-60%), 就用 CXL 把闲置内存动态分配给需要的节点。~300ns 的 CXL.mem 延迟比本机 DRAM 的 100ns 高一倍, 但比 swap/SSD 的 100µs 快 300 倍。
  • NVLink (interconnects.md §四): 既然 PCIe 5.0 x16 = 64 GB/s 远不够 GPU all-reduce 的带宽需求, 就自建 7× 带宽的专用链路。NVLink 护城河的本质是 Memory Wall 护城河——没有 NVLink 就无法做 TP, 不做 TP 就无法训练大模型。
  • LSM Compaction (wal-lsm-btree.md): Memory Wall 迫使 LSM-tree 以 MB 为单位做后台 compaction——不是"这么做更优雅", 是"不批量 IO 的话每次随机读 100µs, Level 4 → Level 5 的 compaction 跑一个月也跑不完"。Compaction 的单位 (MB) 由 SSD 的带宽/延迟比决定。
  • Redis 集群 sizing (multilevel.md): Memory Wall 解释了为什么 Redis 集群的容量上限不是 RAM 大小, 而是"单节点的网络带宽 ÷ 平均 key size"。一台 25 Gbps 网卡的 Redis 节点, 对小 key (100B) 的理论 QPS 上限是 25Gbps / 8 / 100B ≈ 31M QPS (实际受单线程限制 ~100K), 对大 key (1MB) 的上限是 ~3K QPS。

四、工程洞察: cache 的五边形通用模型

任何 cache 层都可以被这五个维度的参数完整描述:

         Hit/Miss 判定
              │
              ▼
┌─────────────────────────────┐
│  Key → 查找 → Value 或 MISS │
└─────────────────────────────┘
              │
    ┌─────────┼─────────┐
    ▼         ▼         ▼
   命中      缺失       预取
  (1ns~ms) (惩罚延迟) (可选)
    │         │
    │    ┌────▼─────┐
    │    │ 块管理     │ ← 按什么粒度取/填? (64B / 4KB / object)
    │    │ 替换策略   │ ← 满了淘汰谁? (LRU / LFU / ARC / RRIP)
    │    │ 一致性协议 │ ← 多副本怎么同步? (MESI / Raft / purge)
    │    │ 写策略     │ ← write-through 还是 write-back?
    │    └───────────┘

所有 cache 层都是这个五边形的不同实例化。 硬件 cache 把这五行烧录在硅片上, OS page cache 把这五行实现在内核 C 代码里, Redis 把这五行实现在用户态 server.c 里, CDN 把这五行实现在边缘节点的反向代理 (NGINX/Varnish) 里。理解了这张五边形图, 你就理解了计算机系统中"记忆"的本质。

五维度在每一层的实例化对比

维度L1 Cache (硬件)OS Page CacheInnoDB Buffer PoolRedis LRUCDN Edge
Hit/Miss 判定Tag 比较 (1 cycle)Radix tree / 哈希查 pageHash table (page_id → frame)Hash table (key → robj)URL + headers → cache key
块大小64B (cache line)4KB (page)16KB (page, 可调)Key-value (variable)HTTP object (variable)
替换策略PLRU / RRIP / BRRIP双链表 LRU (active/inactive)Midpoint LRU (5/8 split)allkeys-lru / allkeys-lfuLRU / cost-based
一致性协议MESI/MOESI/MESIF (bus/dir)无 (单 OS kernel)WAL (crash recovery) + buffer pool mutex主从 async replicationPurge / TTL-based invalidation
写策略Write-back + write-allocateWrite-back (dirty page → 定期回写)Write-back (dirty page → checkpoint/WAL)Write-through (写同时更新 DB/cache)源站写 → edge 被动失效或主动 purge
预取Next-line / stride / AMPMreadahead (顺序访问检测)Linear read-ahead (预取 64 pages)无 (按需加载)Pre-warm (主动推热资源到 edge)

这张对比表是本章所有论点的物证。同构不是类比, 是严格的 engineering convergence: 面对相同的约束 (容量/延迟/成本之不可能三角), 不同层级的工程方案收敛到了同一组设计模式。


五、预取与预热: 另一个跨层同构

cache 不只是"被动地等人来查"。每一层都有主动提前拉取数据的机制, 在硬件叫 prefetching, 在软件叫 cache warming / pre-warming:

层级预取/预热机制原理
L1/L2/L3 (硬件)Next-line / Stride / AMPM prefetchers (memory-hierarchy.md §十二)检测访问模式, 提前发 miss 请求填 line
OS Page Cachereadahead (顺序访问检测): posix_fadvise(POSIX_FADV_WILLNEED)检测到顺序读 → 后台预读后续页面到 page cache
InnoDB Buffer PoolLinear read-ahead: 连续访问 56 个 pages → 预取 64 pages和硬件 next-line prefetch 逻辑完全一致
Redis无内置 prefetch; 冷启动时用 --rdbDEBUG RELOAD 从持久化文件恢复恢复本质上是一次性大批量"填 cache"
CDNPre-warm: 主动推送热文件到 edge POP, 或 stale-while-revalidate 在后台刷新等价于"软件 prefetch": 预期会 miss 的对象提前拉

预取的本质矛盾: 预取太多 → 浪费带宽 + 污染 cache; 预取太少 → miss penalty 无法隐藏。硬件 Stride prefetcher 通过跟踪地址间距来区分"该预取"和"不该预取"的访问流; OS readahead 用 access pattern detection (顺序流的 sequential ratio 判断); CDN pre-warm 用业务先验 (即将上线的大促海报)。三种手段解决的是同一个带噪声的信号检测问题: 从历史访问模式中推断未来访问概率。


六、易错清单

  1. "cache 就是快内存": cache 的本质不是"快内存", 是"小内存 + 淘汰算法 + 一致性协议"。把 cache 当成"更快的内存"来用是初学者最常见的错误——实际上 cache 能加速你, 是因为你访问的数据满足时间/空间局部性。如果你用 cache 装随机访问的数据, cache 不但不会加速, 还会因为频繁淘汰和协议开销变得更慢 (见 memory-hierarchy.md §十四 §2 false sharing, 以及 failure-modes.md)。

  2. "多一层 cache 一定更快": 每一层 cache 都加一个 lookup 开销。L1 花了 1ns, L2 花了 4ns, L3 花了 12ns——加上去不是为了让你全走一遍, 是为了 miss 的时候不用直接摔到 100ns 的 DRAM。如果你在软件栈里加了一层 Redis, 但 90% 的请求都是 miss 还回源查 DB, 那你加的不是 cache 加速器, 是延迟累加器。cache hit rate < 80% 的 cache 层应该被移除或重新设计。

  3. "一致性协议可以靠最终一致性省掉": MESI 的"强一致"代价是总线广播和 directory lookup。Paxos 的"强一致"代价是两轮 RPC + 多数派确认。所以你发明了"最终一致性"——Redis 主从异步复制, CDN TTL 过期后回源。但最终一致性不意味着"没有一致性协议"——它意味着你把"不一致窗口"的代价转移到了应用层 (见 failure-modes.md §一-三)。缓存雪崩、穿透、击穿本质上都是"一致性协议不够强"时应用层爆炸的表现。

  4. "替换策略不重要, LRU 够用了": 如果你的 workload 是固定 hot set (如社交网络的热门帖子), LRU 确实够用。但如果你的 workload 是混合的——90% 的流量来自 10% 的 key, 但每隔 10 分钟有一个批量任务扫过所有 key——LRU 会在扫描过程中把整个热点集合全部刷掉。这就是为什么 W-TinyLFU、ARC、BRRIP、midpoint insertion 要存在: 它们保护的不是"个别的热数据", 而是"热点集合的结构完整性"

  5. "Memory Wall 是硬件问题, 软件不用管": 正因为 Memory Wall 是物理性的 (光速限制物理距离 → 电容充放电速度限制 DRAM→ 成本限制 SRAM 面积), 软件才必须管。LSM-tree 选择顺序写而不是随机写、列存选择压缩 + late materialization 而不是行存、RocksDB 选择 block-aligned SSTable 而不是按行存——这些软件决策的根因全在 Memory Wall。不明白 DRAM 的 bank/row/column 结构 (memory-hierarchy.md §八), 你就不知道为什么 compaction 的 stride 要和 SSD page 对齐。

  6. "TLB 跟我写应用没关系": 见 mmu-dma.md §4。一次 TLB miss 的成本是 ~400ns (4 次 DRAM 读做 page walk)。如果一个进程有 256MB working set, 用 4KB 页就需要 65,536 个页表项——远超任何 CPU 的 TLB 容量。用 HugePages (2MB) 则只需 128 个表项, 全部可装入 L2 TLB。PostgreSQL 和 JVM 的生产调优里, huge_pages = on-XX:+UseTransparentHugePages 能带来 5-15% 的吞吐提升, 这本质上就是把 lookup structure 从"TLB 装不下"改成"TLB 装得下"——和调整 Redis 的 maxmemory 保证热数据集全在内存里是同一回事。


七、这一章带走的东西

  1. 整个计算机系统是一部递归的 cache 层次模型。 寄存器 → L1 → L2 → L3 → DRAM → SSD → 网络 → CDN 的每一层都服从同构的五边形模型: hit/miss 判定 → 按块传输 → 替换 → 一致性 → 写策略。

  2. MESI cache coherence ≈ 硬件尺度的 Paxos。 状态转换 (M→E→S→I vs Follower→Candidate→Leader)、排他权获取 (RFO vs Leader Election)、数据传播 (snoop multicast vs AppendEntries)、冲突仲裁 (总线仲裁 vs term number)——在硬件域和软件分布式域之间逐行对应。区别仅在于"信道的可靠性": 铜互联不会丢包, UDP 会。

  3. TLB 是页表的 cache, Buffer Pool 是 B-tree 的 cache, dcache 是 inode 表的 cache。 这三个 lookup structure 的 cache 关系是完全同构的: 命中时 O(1) 返回映射, miss 时逐层遍历 → 把结果填入高速结构 → 重试命中。理解了这个模式, DNS、ARP table、路由表、LSM bloom filter——所有 lookup structure 的 cache 机制都统一了。

  4. HBM (宽总线 + TSV)、NVLink (多链路点对点)、RDMA (单边绕过 CPU) 是同一个工程模式: "延迟降不下去, 就加宽通道、减薄协议栈、去仲裁——用并行和近邻克服物理极限。"

  5. Memory Wall 是终极约束。 算力每年 55% 增长, 存储延迟每年 7% 改善。这个剪刀差驱动了 HBM、3D V-Cache、CXL 内存池化、NVLink、LSM compaction 策略、Redis 集群 sizing——从硅片到 CDN, 每一层工程决策的根因都归结到 Memory Wall。

  6. Master 一个 cache 层, 你就 master 了所有 cache 层。 L1 cache 工程师转行做 Redis 调优、做 CDN 架构、做数据库 buffer pool 设计, 只需要翻译术语表:"cache line" → "page" → "object", "MESI" → "主从复制" → "purge propagation", "write-back" → "dirty page flush" → "lazy invalidation"。底层抽象是相同的, 上层只是实例化的参数差异。


下一篇 → 5. 编程语言运行时: 四种实现语义

5. 编程语言运行时: 四种实现语义

一句话

Go / TS / Python / C++ 表面上是四种语言, 但语言背后的运行时语义决定了数据结构的物理化、内存模型、GC 行为、并发选择. 同一个"动态数组"在 C++ 与 Rust 里差别不大 (std::vectorVec<T> 都是紧排三元组), 但在 Go 切片里多出 (ptr, len, cap) 的显式语义; Python list 是 PyObject* 指针数组; JS Array 在紧凑存储和稀疏字典模式之间切换. 每一个差异都在暴露运行时的设计取舍.

思想链

工程问题: 同一行代码 list.append(x), 为什么四种语言差 10×?
  └─► 运行时在 4 条轴上各自站队
        └─► 轴1 内存布局: 紧排 (C++/Rust) vs 引用稀疏 (Python/JS)
              └─► 决定 cache miss 次数与 SIMD 可能性
        └─► 轴2 回收策略: RAII vs tracing GC vs 引用计数
              └─► 决定尾延迟分布 (P99 由 GC 停顿决定)
        └─► 轴3 并发模型: 内核线程 / CSP / event loop / GIL
              └─► 决定争用结构与调度开销
        └─► 轴4 多态实现: 编译期单态化 vs 运行期动态派发
              └─► 决定热循环里有没有间接调用
  └─► 复杂度记号 O() 相同 ≠ 性能相同: 工程常数被运行时折算进来

主要差异维度

四种主流语言 (Go / TS / Python / C++) 的运行时在四个维度上的不同:

1. 内存布局

语言列表底层字符串结构体内存
C++紧排 T[]SSO 栈上字符数组 + heap 指针struct 默认成员对齐
RustVec<T> = (ptr, len, cap) 紧排String = Vec<u8> + UTF-8 不变量默认重排对齐, #[repr(C)] 强制 C-ABI 布局
Go(ptr, len, cap) 三元组指向 backing array不可变 string header + UTF-8 字节数组field 对齐 + padding
Java引用数组 + 压缩指针 (compressed oops)heap String + substring 共享底层数组JVM 字段可重排优化
PythonPyObject* 指针数组字符串对象内嵌 ASCII, 否则堆上dict + gc 头
TS (V8)紧凑元素存储, 稀疏时退化字典模式Latin-1 / UTF-16 双编码切换hidden class 决定字段布局

2. 内存回收

语言回收方式代价特征
C++析构 + RAII零运行时开销, 生命周期错误由人负责
Rustownership/borrow + Drop零运行时开销, 错误移到编译期
Go并发三色标记清扫, STW 极短后台 CPU 占用 + 写屏障开销
Python引用计数为主 + 分代 GC 兜底循环引用计数操作分散在每条指令, 周期性停顿
JavaG1 / ZGC / Shenandoah 可选不同延迟 profile 可插拔
TS V8分代 + compactingGC 平均停顿 50-200 μs

GC 停顿时间是工程上"硬实时"问题的关键. 这就是为什么硬实时场景 (HFT、音视频、游戏 tick) 通常要求无 GC 语言, 或选可预测停顿的 ZGC / Shenandoah——与 摊还 vs 最坏 的张力同源.

3. 并发模型

语言并发原语编程模型
C++std::thread + mutex + atomicpthread / 内核线程
Ruststd::thread + Send/Sync所有权转移, 线程安全由类型系统静态保证
Gogoroutine + channel (CSP) + GMP 调度器协程轻量级 (~2 KB / goroutine)
Javathread + lock + JUCvolatile / synchronized / Lock
Pythonmultiprocessing 绕开 GILGIL 是 CPython 的主要并行瓶颈
TSevent loop + Promise 异步编排JS 单线程事件循环, 重活交给 worker

并发模型决定了:同一句 map[k]++ 在各语言里语义完全不同:

  • C++/Rust: 必须 atomic 或加锁;
  • Go: 用 channel 传所有权更稳, 否则 sync.Map / 加锁;
  • Python: GIL 让单字节码近似原子, 但"读-改-写"复合操作仍需锁;
  • Java: synchronized / ConcurrentHashMap / LongAdder;
  • TS: event loop 串行执行没有真并发 (除非开 worker);
  • 多 worker 之间用 SharedArrayBuffer + Atomics.

4. 抽象类型系统

语言类型系统泛型实现
C++模板 (静态多态)compile-time monomorphization
Rusttrait + lifetimecompile-time monomorphization
Go1.18+ type params字典分发 + 部分特化 (gc shape stenciling)
Java静态类型 + 泛型type erasure, 只做编译期检查
Python强动态 + 鸭子类型运行时 dispatch
TS编译期类型检查运行时类型全部擦除

C++/Rust 的 monomorphization 把泛型编译成类型专用代码 (快), 但二进制膨胀; Go 用字典实现 (慢一些但二进制小). 这就是为什么 Rust 写 Vec<u8>Vec<u64> 会生成两份代码, 而 Go 的泛型切片共享一份实现.

同一抽象不同物化: 4 语言 vector push 实测

"向动态数组 push 100 万个 int" 在四种语言下的真实表现:

C++ / std::vector<int>  push_back 1M:
  - 1M × (1 cycle 插入 + 摊还拷贝) ≈ 5 ms

Rust / Vec<i32>  push 1M:
  - ≈ 5 ms (LLVM -O2 下与 C++ 几乎相同)

Go / []int  append 1M:
  - ≈ 12 ms (扩容策略保守 + bounds check + GC 写屏障记账)

Python list.append 1M:
  - 50-80 ms (引用计数 + PyObject 头开销)

Java ArrayList<Integer>.add 1M:
  - ≈ 60 ms (Integer 装箱 + 指针间接)
  - int[] 直填 1M < 5 ms

同一个"动态数组 push", 在 4 种语言之间差出 10× 量级; 而它们全都号称"摊还 O(1)". 这就是为什么复杂度 O() 无法跨语言直接比较 —— 工程常数被各自的运行时折算进来了.

同构与抽象

把这些"语言运行时差异"抽离出来, 实际只有 4 条"选择的轴":

  1. 内存的紧排性 (C++/Rust 紧排, Python/JS 引用稀疏);
  2. 内存回收的延迟 profile (RAII vs tracing GC vs 引用计数);
  3. 并发模型 (内核线程 vs CSP vs event loop vs GIL);
  4. 类型系统: 单态化 vs 动态派发.

这是建立"跨语言看抽象"能力的具体实例: 看四种语言时, 不是看语法糖, 而是看这 4 条轴上各自站队.

note

轴 4 (单态化 vs 动态派发) 的编译器实现细节见 JIT / tiered compilation / V8 / JVM; 类型系统层面的推导见 类型系统与 HM 推断.

同一抽象的硬件层

有趣的是: 这 4 条轴在硬件层同样适用:

1. 数据紧排: cache-friendly 的 row-major 流 vs 逐指针追逐;
2. 回收:    手动生命周期管理 (RAII 式) vs 数据流一次性使用即弃;
            FPGA 数据通路几乎总是后者——没有分配, 只有流动;
3. 并发:    多核线程 + AXI-Stream + 硬件原子;
4. 多态:    指令级静态分发 (SIMD/VLIW 定长编码) vs 微码动态翻译.

我们发现"语言运行时"与"硬件层"在数据布局、回收、并发、多态这 4 个维度上成对同构. 硬件与软件语言层共享同一组抽象决策.

这一章带走的东西

  • 4 种语言的运行时差异可以压缩到 4 条轴: 内存紧排、回收策略、并发模型、多态实现;
  • 同一算法的实测常数差可达 10×, 复杂度 O() 不能跨语言直接比较;
  • 软件运行时的 4 条轴与硬件的 4 条轴成对同构;
  • 工程师选语言不是 syntactic 偏好, 而是对 4 条轴的选择组合;
  • 学新语言时抓住"它在 4 条轴上站在哪里", 就抓住了语言本质.

tip

读跨语言 benchmark 前先问三件事: 数据是不是紧排? 有没有 GC 停顿被算进去了? 泛型走的是单态化还是字典? 三问之后, 大部分"X 比 Y 快 3 倍"的结论都能自己解释.

一页速查

紧排代表疏排代表工程含义
内存布局C++ / RustPython / JScache miss 与 SIMD 上限
内存回收RAII (C++/Rust)GC (Go/Java/JS) / 引用计数 (Python)尾延迟 P99 形状
并发模型内核线程 (C++/Java)CSP (Go) / event loop (TS) / GIL (CPython)争用结构与切换成本
多态实现单态化 (C++/Rust)字典/擦除 (Go/Java/TS) / 动态派发 (Python)二进制体积 vs 热路径速度

下一篇: 6. 并发与一致性: 单机到分布式同构

6. 并发与一致性: 单机到分布式同构

一句话

并发与一致性是"同一个故事讲了三遍"的事情——它在单机多核 cache coherence单机并发数据库事务分布式多节点存储三个层次用几乎同样的语言复述. 这不是巧合: 它们都受同一组物理约束支配——多个 writer + 共享 state + 延迟会限制原子性的可见范围. 理解这一点, 从 MESI 学到 Paxos / Raft 就是一座平滑的桥.

思想链

工程问题: 为什么学完 MESI 再看 Raft 会觉得似曾相识?
  └─► 三层并发表由同一组物理约束生成
        └─► 多个 writer + 共享状态 + 延迟决定原子性可见范围
              └─► 单机多核:    共享 L3/内存, 1-50 ns     → MESI/MOESI
              └─► 单机多线程:  进程内存,   μs 级        → mutex/CAS/RCU
              └─► 跨机分布式:  网络消息,   100 μs-50 ms → Paxos/Raft/2PC
  └─► 角色逐行对应
        └─► MESI Modified ≈ Raft leader   (唯一的可写最新副本)
        └─► MESI Shared   ≈ Raft follower (只读同步副本)
        └─► Invalidate 消息 ≈ AppendEntries 复制
  └─► 两者的全部差异来自信道可靠性
        └─► 铜互联不丢包、延迟确定 → 协议可以极简
        └─► 网络会丢包、分区、乱序 → 必须加 quorum / term / 选主

三层并发表

writers共享 state介质延迟一致性机制
单机多核2-100 核L1/L2/L3 cache1-50 nsMESI / MOESI / MESIF
单机多线程10-1000 线程进程内存1-2 μs (lock 获取)mutex / CAS / seqlock / RCU
单机多进程进程级mmap / SysV shm5-10 μs (syscall)futex / shared memory + atomic
跨机分布式N 台机器网络消息100 μs - 50 msPaxos / Raft / 2PC / 3PC

最关键的观察: 本机多核协议和分布式共识协议做的是同一件事——读、写、半数确认; 主要差别在 latency budget 和 failure mode.

note

单机侧的原语实现见 futex、CAS、spinlock 内部lock-free/wait-free 数据结构; 分布式侧见 Paxos / Multi-PaxosRaft 详解. 对照着读, 你会发现术语表几乎可以逐行翻译.

强一致性 vs 弱一致性 vs 最终一致性

强一致性: 一次操作要么全部可见, 要么全不可见 (linearizable).

  • MESI 在单机上提供强一致: 一个核修改时, 其他核的副本被 invalidate;
  • 单机数据库事务在 transaction 内部强一致;
  • Paxos / Raft 提供 linearizable 共识 (一次写入需多数节点 ack);
  • Spanner 通过 TrueTime 提供外部一致性 (external consistency).

弱一致性: 读可能读到过期值.

  • 缓存允许短暂不一致, 以换取吞吐;
  • Dynamo/S3 风格的最终一致性: 在 read repair 等反熵修复后系统收敛.

CAP 定理告诉我们: 分区发生时不能同时强一致 + 可用 (CAP / PACELC / BASE). 单机几乎没有 partition, 但分布式里 partition 不可避免——这是单机 ↔ 分布式抽象差异最大之处.

抽象的桥梁

单机 MESI 看似陌生, 但逻辑上与 Raft 同构:

MESI M (modified)   ≈ Raft leader   (持有最新未下刷数据)
MESI S (shared)     ≈ Raft follower (持有已同步数据)
MESI invalidate msg ≈ Raft AppendEntries
MESI BusRd / snoop  ≈ follower 的 ReadIndex 读请求

两边都是"中心控制点 + 多 reader + 状态同步消息". Raft 用网络消息实现 MESI 式的同步; MESI 用总线仲裁实现类似 leader/follower 的同步.

warning

同构有助于理解, 但不要把工程参数也照搬. MESI 的 invalidate 在芯片内是纳秒级且不会丢失, 所以硬件敢用"广播 + 等待确认"; 分布式系统的消息会丢、会延迟抖动, 所以必须补上任期号、多数派和幂等日志——把 MESI 直译成网络协议, 得到的是一个会被一张乱序报文打崩的系统. 不可靠信道带来的额外机制见 Raft 详解.

锁、CAS、事务的对应

继续对比:

抽象单机实现分布式等价
单点锁mutexleader 租约锁服务 (ZooKeeper ephemeral node)
CAS 一致atomic cmpxchgCassandra LWT (lightweight transactions)
事务 prepare/commit数据库本地 2PC跨库 XA / Saga
Quorum多核总线仲裁quorum read/write, W + R > N
快照隔离MVCC (PostgreSQL)Spanner snapshot / CockroachDB

工程转折点: 延迟预算让"乐观算法"赢

当 latency = 1-50 ns 时:

  • 总线锁定与阻塞等待代价小;
  • 悲观锁通常性能更好 (低延迟, 但牺牲吞吐).

当 latency = 1-100 ms 时:

  • 锁的 ack 要等一个 RTT, 阻塞代价巨大;
  • 乐观并发 (MVCC + 只读事务不阻塞) 表现更优;
  • 这就是为什么现代数据库 Spanner / CockroachDB / TiKV 都选 MVCC + Raft.

这就是"跨越延迟量级后, 并发模型必须从悲观切换到乐观"——同一抽象在不同 latency budget 下换载体. 单机侧的 MVCC 实现对照见 MVCC 原理: PostgreSQL vs InnoDB.

各语言的同构并发原语

各语言层级的并发原语抽象基本相同:

mutex:   C++ std::mutex / Rust std::sync::Mutex / Go sync.Mutex / Java synchronized / Python threading.Lock
rwlock:  各语言的 shared_mutex / RWMutex 对应版本
cond:    std::condition_variable / sync.Cond / Java wait-notify / Python Condition
futex:   Linux futex syscall —— 现代几乎所有用户态 mutex 的底层
atomic:  std::atomic<T> / Rust AtomicXxx / Go sync/atomic / Java AtomicInteger
channel: Go channel (CSP) / Rust std::sync::mpsc / Clojure core.async / Java Disruptor

这些原语在不同语言里的语义基本一致, 这是"工程师可以在语言之间迁移"的基础. 但语言选择把权重放在哪个原语上 (Go 押 channel; C++ 押 atomic/mutex; Python 受 GIL 制约) 只是侧重不同, 不是语义差异. 内存序层面的坑则统一由 内存模型与 memory barrier 兜底.

一致性实验: 多线程累加器

考虑一个最小一致性问题: 多线程累加. 三个方案:

方案 1: mutex 累加

sum = 0; mutex m;
并行循环: 持有 m 后 sum += x;
吞吐: ~20M ops/s (4 核激烈争用下锁成为串行点)

方案 2: CAS 失败重试

std::atomic<long> sum;
循环: expected = sum.load();
      if (sum.compare_exchange_weak(expected, expected + x)) break;
吞吐: 高竞争下重试风暴 → ~10M ops/s, 且延迟方差更大

方案 3: thread-local + 结束时合并

thread_local long local_sum = 0;
并行循环: local_sum += x;          // 无共享, 无争用
线程结束时: 加锁一次性合并进全局 sum
吞吐: ~500M ops/s

三种方案语义等价, 但实测常数差出 25× 以上. 这就是为什么 "减少争用 + 分片 + thread-local" 是并发的第一技术, 而"换更快的原子原语"只是第二梯队.

tip

判断并发优化方向先看争用面, 再换原语: 先问"这份数据能不能变成 per-thread / per-shard", 再问"锁能不能换成 CAS", 最后才轮到"换个更快的锁". 内核里把这套思路推到极致的是 RCU——读者完全无锁, 见 RCU、seqlock、brlock.

这一章带走的东西

  • 单机多核 MESI 与分布式 Raft 在抽象上同构;
  • 一致性等级从强一致 (linearizable) 到最终一致;
  • CAP 只在分布式层咬人: 单机没有 partition, 弱约束不构成威胁;
  • 并发模型选悲观还是乐观, 由 latency budget 决定;
  • "减小争用 + thread-local"比原子原语本身的快慢更重要.

一页速查

维度单机多核 (MESI)单机多线程 (锁/MVCC)分布式 (Paxos/Raft)
共享介质cache line / 总线进程内存网络 RTT
延迟量级1-50 ns1-10 μs100 μs - 50 ms
排他权获取RFO 总线事务mutex / cmpxchg选主 (RequestVote)
数据传播snoop / directory 广播直接读写共享内存AppendEntries 复制
冲突仲裁总线优先级锁队列term 单调递增
故障模型不丢消息, 无拜占庭线程崩溃需进程兜底丢包 / 分区 / 时钟漂移
典型妥协性能核心绑定MVCC 快照隔离最终一致 + read repair

下一篇: 7. 推理链: 硬件层如何决定软件设计

第十二部分 元抽象:硬件层如何决定软件设计

TL;DR

这是全书最后一条、也是最长的一条推理链——"元抽象"(Meta-Abstraction)的终极追问:为什么你的代码长这样? 不是因为你喜欢这种写法,不是因为教科书告诉你 O(log n) 比 O(n) 好,而是因为 64 字节的 cache line、100 纳秒的 DRAM 延迟、4KB 的页大小、NAND flash 的块擦除机制、SIMT 的 warp 调度——这些物理属性穿过了 4-5 层抽象,最终钉死在你的数据结构和算法选择上。本节横跨第八部分(计算机组成原理)的完整知识体系,将 10 条推理链从硅片层一路拉到应用层,为全书闭合最后一环。读完之后你拿到新硬件(CXL 内存池、RDMA 网卡、FP8 Tensor Core),可以照搬推理框架预测软件该长成什么样子。


不是"讲硬件",是"把硬件当成约束系统来读"

大多数系统设计的课程把这部分内容倒过来讲:给你一个需求 → 选型(B+ 树还是 LSM?)→ 做 benchmark → 定方案。这是工程师的日常,但不是工程师的洞察力。

洞察力是从反方向读的:硬件先于软件存在。硬件的物理约束是底层公理。算法的每一次"选择"其实是公理推导出的必然结论。 当你能从硬件层顺向推到软件选型,你就不再需要 benchmark 来"试"最优方案——你知道答案。

flowchart TD
    subgraph 哲学层["第九部分 元抽象"]
        META["为什么代码长这样?"]
    end

    subgraph 软件层["算法 / 数据结构 / 系统架构"]
        ALGO["B+ Tree vs LSM Tree<br/>Redis 单线程 vs 多线程<br/>用户态网络栈 vs 内核态 TCP"]
    end

    subgraph 抽象层["OS / 编译器 / 运行时"]
        OS["Virtual Memory<br/>Scheduler<br/>TCP/IP Stack"]
    end

    subgraph 硬件物理层["第八部分 计算机组成原理"]
        PHYS["Cache Line 64B(见memory-hierarchy.md)<br/>DRAM timing tCL/tRCD/tRP ~100ns(见memory-hierarchy.md)<br/>NAND Block Erase 256-page(见memory-hierarchy.md)<br/>RDMA verbs & InfiniBand(见interconnects.md)<br/>GPU SIMT & Tensor Core FP8/FP16(见gpu-architecture.md)<br/>NVLink 900 GB/s & NVSwitch(见interconnects.md)<br/>HBM3 TSV Stacking(见memory-hierarchy.md)"]
    end

    PHYS --> OS --> ALGO --> META
    META -.->|"元认知:逆向推理能力"| PHYS

推理链清单

7.1 Cache Line 64 字节 → B+ 树的页大小

cache line = 64 字节 (SRAM 协调 + MESI 一致性协议需要,见第八部分 memory-hierarchy.md)
    ↓
    节点页大小对齐 OS page ≈ 4KB-16KB(见第八部分 mmu-dma.md: 页面大小 4KB/2MB/1GB)
    ↓
    B+ 树扇出 ≈ 100-1000 / 节点(节点大小 = cache line × n,足够让树高 h=3-4)
    ↓
    数据库索引普遍采用 B+ 树(page-aligned node,一次 I/O 命中多条 cache line)
    ↓
    LSM-tree 用大 SSTable block(块仍按 page 对齐,适配 SSD I/O 粒度)
    ↓
    关键结论:树的高度不是由数据量决定的——是由 page / cache line 比值决定的。

两条隐蔽链路

  • MESI 协议(见第八部分 memory-hierarchy.md 六节)要求 cache line 在核间以 64B 为单位传输;数据库的 buffer pool page 锁粒度天然与 MESI coherence unit 对齐,否则 false sharing 会在 NUMA 下摧毁性能。
  • MMU 的 TLB(见第八部分 mmu-dma.md 三节)覆盖能力有限;B+ 树节点若选 16KB 而非 64KB,是因为 4 个 4KB page 的 TLB entry 比一个 64KB 大页(2MB alignment)更容易被硬件 prefetcher 命中 TLB。

7.2 DRAM ≈ 100ns Latency → 大 O 实际常数因子

DRAM latency ≈ 100ns(见第八部分 memory-hierarchy.md 二节: tCL + tRCD + tRP ≈ 15+15+15=45ns + burst ≈ 50ns)
    ↓
    Cache hit ≈ 1ns (L1) / 8ns (L2) / 30ns (L3)
    ↓
    跨 cache miss 的算法操作实测差 20-80 倍(不是常数倍,是数量级)
    ↓
    OoO CPU 通过 MLP(memory-level parallelism)部分隐藏延迟
    (见第八部分 cpu-superscalar.md 十一节: MLP=10-16 时 DRAM 访问被并行化)
    ↓
    工程常数 = cache locality + SIMD + MLP 三轴上能跨两个数量级
    ↓
    教科书 O(1) vs O(log n) 在 n 小时常常反向(O(1) hash table probe 一次 DRAM 随机访问 ≈ 100ns,
    O(log n) 二分查找可能全在 L2 cache 内 ≈ 30ns × 3=90ns → 反而更快)
    ↓
    工程反推:看 cache-friendly 而不是算法大 O。

关键数量级(来自第八部分)

  • L1 cache hit: 4 cycle @ 4GHz = 1ns(见 memory-hierarchy.md 一节的延迟对比表)
  • L3 cache hit: 120 cycle @ 4GHz = 30ns(见 memory-hierarchy.md 一节的延迟对比表)
  • DRAM random access: 400 cycle @ 4GHz = 100ns(见 memory-hierarchy.md 一节的延迟对比表)
  • OoO ROB 窗口: 512 entries, ~64 cycle 的时间窗口来找 ILP(见 cpu-superscalar.md 六节)

7.3 SSD 写放大 → LSM-Tree 的胜出

NAND flash 物理特性:erase-on-block (4KB page × 256 pages/block,见第八部分 memory-hierarchy.md)
    ↓
    原地写 → read-modify-write 4KB ⇒ 写放大 32-100×
    ↓
    LSM-tree append + merge 后台压缩:顺序少量 rewrite ⇒ 写放大 10-20×
    ↓
    RocksDB / Cassandra / BigTable / LevelDB 全部采用 LSM
    ↓
    B+ 树仍用于 OLTP 读多场景:读代价 LSM 比 B+ 高 20-50%(多层 SSTable 查找 vs 单次 B+ 遍历)
    ↓
    工程实践:LSM-tree 的 compaction 策略(leveled vs tiered vs universal)直接对应 NAND block 的 erase 预算管理

深层连接:NAND 的 block erase 延迟 ~ms 级(见第八部分 memory-hierarchy.md 一节:NVMe SSD ~100µs 是"读",erase 是"写前擦除"——比读慢一个数量级)。LSM 的思想本质上是把随机小块擦除聚合成顺序大块擦除——这与 GPU 上把 scatter/gather 聚合为 GEMM 是同构的思想:硬件喜欢顺序,软件必须制造顺序。


7.4 RDMA + Zero Copy → 用户态网络栈

NIC DMA → 内存直接配 frame + RDMA verbs API(见第八部分 interconnects.md 五节: RDMA Write/Read 操作)
    ↓
    绕过 TCP/IP 内核栈, latency 10× ↓ (5 μs vs 50 μs)
    ↓
    libibverbs + DPDK 在 HFT / HPC / 分布式存储上重新设计网络层
    ↓
    应用层架构变形:把多 node 看成 shared memory pool
    ↓
    CXL.mem 进一步把"远端内存"做成 cache coherent(见第八部分 interconnects.md 三节: CXL 协议栈)
    ↓
    分布式共识协议(Raft/Paxos)的 log replication 在 RDMA 上可获得 ~5µs 的 append 延迟

推理延伸:RDMA 与 MESI 的同构。MESI(见第八部分 memory-hierarchy.md 六节)是 CPU 核间 cache line 一致性协议;RDMA(见第八部分 interconnects.md 五节)是跨机架的内存一致性——两者本质上在做同一件事:让多个计算单元看到同一个地址空间的最新值。区别只在延迟量级:MESI 在 ~100ns,RDMA 在 ~5µs。分布式系统的 quorum 机制(majority ack)本质上是把 MESI 的 bus snooping 替换为应用层消息广播——同样的思想在不同的 latency budget 下以不同形态出现。


7.5 FPGA 流水线可重配 → SmartNIC / DPU Offload

FPGA dynamic reconfiguration: 加载 bitstream 切换逻辑块
(见第八部分 ai-accelerators.md 十节: FPGA 在 ML 推理中的角色)
    ↓
    SmartNIC / DPU(NVIDIA BlueField / Intel IPU)把网络功能放到 FPGA/SoC 上
    (见第八部分 interconnects.md 一、二节: PCIe 拓扑和 DPU 位置)
    ↓
    Open vSwitch / TLS termination / VXLAN / firewall 在硬件管线跑
    ↓
    Server CPU 释放给业务逻辑, 网络处理不占核心周期
    ↓
    软件架构变形:"网络"变成"可加载的服务在硬件近端"

DPU 与 CPU 的分工边界:DPU 的本质是把网络数据平面的"快路径"(fast path)从 x86 CPU 移到专用处理单元。CPU 处理控制平面(路由表更新、TLS 握手协商),DPU 处理数据平面(包分类、加密、转发)。这与 GPU 中 CPU 做 launch / dispatch、GPU 做 kernel 计算的异构模型是同构的——硬件多样性迫使软件做异构切分


7.6 GPU SIMT → ML 矩阵乘爆发

CUDA SIMT model: 32 threads warp + lock-step 同步 + global mem access coalescing
(见第八部分 gpu-architecture.md 二节: SIMT 模型与 warp 调度)
    ↓
    float16/bf16 矩阵乘天然 cache-friendly + SIMD-friendly
    ↓
    Tensor Core MMA 指令: 单时钟 4×4×4 = 128 FLOPs(见第八部分 gpu-architecture.md 五节)
    ↓
    cuBLAS / CUTLASS 优化到接近峰值 TFLOPs
    ↓
    TPU 用脉动阵列(Systolic Array)将 MAC 单元串成流水线,零控制开销
    (见第八部分 ai-accelerators.md 二节: 256×256 MAC @ 700MHz = 92 TFLOPS INT8)
    ↓
    Transformer attention = 大量矩阵乘 = GPU/TPU 胜场
    ↓
    AI 工程师变成"GPU-friendly 算法工程师" = 又一种抽象层转换

Tensor Core 与 Systolic Array 的内在同构:Tensor Core 的 4×4×4 MMA 本质上是小规模的 Systolic Array——两者都在做同一件事:让数据流过一个固定的 MAC 阵列,避免寄存器回写和控制流指令。区别在于粒度:NVIDIA 把 MMA 作为一条指令嵌入 SIMT 模型(复用 warp scheduler 的零开销线程切换),Google 把整个阵列做成独立芯片(放弃 warp 调度,换得更低的控制开销 ~5% vs GPU 的 ~30%)。


7.7 多核 NUMA → Redis 单线程 → 分布式协调

8 / 16 / 32 核 NUMA, cross-socket memory access ~200ns
(见第八部分 memory-hierarchy.md 六节: 目录协议与 NUMA 延迟)
    ↓
    单机 hash table lock contention 跨 NUMA node 难突破
    ↓
    Redis 单线程 hash = 100% 单 core 独占(避免了跨 socket 的 MESI RFO 风暴)
    ↓
    扩展到 Redis Cluster / KeyDB:shard + per-node linearizable + cross-node eventual
    ↓
    现代内存数据库(Tair, Dragonfly)的架构都是 NUMA-aware 的 per-core hash shard
    ↓
    工程兼容路线:单线程核心 + 多实例 + 异步复制

Redis 单线程不是"偷懒"——是 MESI 教会的:如果你的核心数据结构是一个全局 hash table,多线程并发写必然触发 MESI 的 RFO(Read For Ownership)广播——从 Shared 到 Modified 的所有权转移,每次跨 socket 耗费 ~200ns。对于 Redis 这种微秒级延迟的服务,200ns 的 coherence 税在 p99 延迟上直接爆炸。单线程方案把 MESI 问题从"8 个 core 的混乱"简化成"1 个 core 的干净"。


7.8 Tensor Core FP8 → Transformer 训练吞吐 → H100 的设计哲学

FP8 = 1 字节(4-bit 指数 + 3-bit 尾数),FP16 = 2 字节
    ↓
    FP8 数据量 = FP16 的 1/2 → 同样 HBM 带宽下,FP8 每周期搬运数据量翻倍
    ↓
    H100 FP16: 989 TFLOPS, FP8: 1979 TFLOPS(见第八部分 gpu-architecture.md 五节的精度算力表)
    ↓
    FP8 的动态范围(E4M3 约 2^-6 ~ 448)刚好覆盖 Transformer 训练中激活和梯度的实际分布
    ↓
    NVIDIA Transformer Engine 在训练中逐层动态选择 FP8 vs FP16(见第八部分 gpu-architecture.md 五节的 TF32 原理)
    ↓
    为什么 H100 设计目标是 "FP8 吞吐翻倍" 而非 "加更多 CUDA Core"?
        答:HBM3 带宽 = 3.35 TB/s(见第八部分 memory-hierarchy.md 九节),
            GPT-3 训练中 99% 的时间在等权重从 HBM 搬进 Tensor Core。
            加计算单元 = 加空闲单元。翻倍精度效率 = 翻倍实际吞吐。
    ↓
    推理框架(vLLM / TensorRT-LLM)的 FP8 量化本质上是在"偷"H100 的硬件红利——
    硬件设计者花 5 年 HBM 堆叠和 Tensor Core 迭代才把 FP8 通路做宽,
    编译器一个量化 pass 就能端走。

精度选择的工程本质:FP8 不是"损失精度换速度"——H100 的 FP8 Tensor Core 输出累加器是 FP32,意味着 FP8 × FP8 → FP32 accumulate → FP8 round。这个路径的误差来源是每次 round-trip 的尾数截断,而非累加过程的信息丢失。对于 Transformer 训练中的矩阵乘法(Q×K^T 和 W×X),E4M3 的 3-bit 尾数对应 ~0.1% 的相对精度——而 SGD 的随机梯度噪声本身在 ~1-10% 量级。FP8 的精度损失被优化噪声淹没了,但带宽减半带来的 2× 吞吐是实打实的。


单 GPU HBM3 带宽 = 3.35 TB/s(见第八部分 memory-hierarchy.md 九节: HBM3 规格)
PCIe 5.0 ×16 = 64 GB/s(见第八部分 interconnects.md 一节: PCIe 代数对比表)
    ↓
    跨 GPU 通信如果走 PCIe,带宽只有片内 HBM 的 1/50
    ↓
    NVLink 4: 每条 50 GB/s × 18 links = 900 GB/s per GPU(见第八部分 interconnects.md 四节: NVLink 代际表)
    ↓
    NVSwitch: all-to-all 全互联,任意两 GPU 之间同时有 450 GB/s 带宽(见第八部分 interconnects.md 四节: NVSwitch crossbar)
    ↓
    模型并行(Tensor Parallelism, TP)只能在 NVLink 互联范围内部署——TP 每层计算后需 all-reduce,通信量 = 2×(N-1)/N× 层参数
    ↓
    为什么 DGX H100 正好 8 卡?
        答:18 NVLink / 8 GPU + NVSwitch = 每 GPU 对每 GPU 有 2-3 条 NVLink。
            如果 16 卡全互联,需要 15×18=270 条 NVLink → NVSwitch 端口数爆炸,成本不可控。
            DGX 的 8 卡设计是因为物理链路 + 交换芯片面积达到最优拼点。
    ↓
    训练 GPT-4 级模型时,TP=8 (DGX 内 NVLink) + PP=16 (DGX 间 InfiniBand) + DP=64 (全局)
    = 硬件拓扑直接决定训练并行的三层切分策略。

推理链核心:软件中的 "Tensor Parallelism Size = 8" 不是超参——是硬件拓扑的必然。互联层次决定并行策略的分界:

并行策略通信量带宽要求典型范围
Tensor Parallelism每层 all-reduceNVLink 级 (450-900 GB/s)DGX 内 8 GPU
Pipeline Parallelism层间激活传递InfiniBand 级 (50 GB/s)跨节点 16-64 GPU
Data Parallelism梯度 all-reduceInfiniBand 级 (50 GB/s)全局 64-数千 GPU

这条表本身就是硬件层穿到软件层的最佳证明。如果 CXL.mem 把跨节点延迟降到 ~300ns(见第八部分 interconnects.md 三节),TP 就可以扩展到跨节点——届时软件并行策略的边界会被重新改写。


7.10 HBM3 TSV 堆叠 → 内存带宽墙 → Cerebras WSE-3 的激进方案

DRAM 带宽增速 ~1.5×/2 年,模型规模增速 ~10×/2 年(见第八部分 ai-accelerators.md 十一节: 存储墙)
    ↓
    DDR5 DIMM 宽度: 64-bit(见第八部分 memory-hierarchy.md 八节: DIMM 组织)
    HBM3 宽度: 1024-bit per stack, 6 stacks = 3.35 TB/s(见第八部分 memory-hierarchy.md 九节: HBM3 规格)
    ↓
    TSV(Through-Silicon Via)竖穿 8-12 层 DRAM die → 信号走 μm 级而非 cm 级 → 带宽最大化、延迟最小化
    (见第八部分 memory-hierarchy.md 九节: HBM vs DDR 物理距离短)
    ↓
    但 HBM3 仍有物理极限:每 stack 容量上限 ~36GB (HBM3e),再多堆叠良率崩塌
    ↓
    GPT-4 ~1.7T 参数 × 2 bytes (FP16) = 3.4 TB → 需要 ~100 颗 H100 (80GB × 100 = 8 TB) 来存放
    → 99% 的时间在跨 GPU 搬运权重 = 内存带宽墙
    ↓
    Cerebras WSE-3 的答案:整张 300mm 晶圆做芯片,44GB 片上 SRAM, 21 PB/s 带宽
    (见第八部分 ai-accelerators.md 五节: WSE-3 规格)
    零片外 DRAM → 零带宽墙 → 但代价是模型必须装进 44GB
    ↓
    两种对抗内存墙的路线:
    A. HBM3 + NVLink + 分布式并行(NVIDIA 路线)—— 用更多芯片分摊带宽压力
    B. 片上海量 SRAM + 数据流计算(Cerebras / Groq 路线)—— 数据不动,算力动

"数据不动 vs 算力不动"——AI 芯片的两种设计哲学

维度NVIDIA/TPU 路线Cerebras/Groq 路线
核心思想算力固定,数据搬进搬出数据固定,算力流过数据
片上内存HBM (3.35 TB/s)片上 SRAM (21 PB/s)
容量上限80 GB (HBM)44 GB (WSE-3)
扩展方式加卡 + 互联模型切分到多芯片
软件代价NCCL 通信库 + 并行策略编译器做时空映射
最佳场景千亿参数大模型训练中规模模型极高吞吐

这两种路线的分歧直接来源于存储器物理特性的根本差异:SRAM 比 DRAM 快 100×(1ns vs 100ns),但 SRAM 密度比 DRAM 低 6×(6T per bit vs 1T1C per bit)。NVIDIA 接受 DRAM 的慢,用更多并行隐藏它;Cerebras 选择 SRAM 的快,接受容量受限。


硬件 → 软件的四种推理层次

把 7.1-7.10 综合,硬件决定软件的层次比之前认为的更深——有四层:

  1. 物理层(cache line / DRAM timing / NAND block / HBM TSV / NVLink lane):决定数据通道的块大小、延迟量级、带宽上限。这是所有上层优化的硬天花板。见第八部分 memory-hierarchy.md、mmu-dma.md、interconnects.md。

  2. 事务层(MESI / RDMA / CXL.cache coherence / NVSwitch crossbar):决定多个计算单元之间的一致性和通信模型。MESI 的四个状态是分布式一致性协议的微缩版;RDMA 的 one-sided 操作是 MESI 在 µs 级别的重演。见第八部分 memory-hierarchy.md 六节、interconnects.md 五节。

  3. 结构层(B+ tree / LSM-tree / warp scheduler / Systolic Array / Tensor Core MMA):决定数据结构和执行模型。B+ tree 的 page 大小对齐 cache line;LSM 的 compaction 策略对齐 NAND block erase;Tensor Core 的 4×4×4 MMA 对齐 FP16 的矩阵分块;warp scheduler 的零开销切换对齐 HBM 的 300-cycle gap。见第八部分 gpu-architecture.md、ai-accelerators.md。

  4. 抽象层(SQL optimizer / NCCL all-reduce / PyTorch autograd / vLLM PagedAttention):决定应用层接口和系统分界。SQL 优化器的 cost model 隐含了对 DRAM 延迟和 SSD 带宽的假设;NCCL 的 ring all-reduce 隐含了对 NVLink 拓扑和 InfiniBand 流控的假设;PyTorch 的 eager mode 隐含了对 CUDA kernel launch 开销的假设。

关键认知:每一层给出的结论被下一层提升为透明前提。应用层工程师不需要理解 HBM3 的 TSV 堆叠工艺,但理解 TSV 堆叠 → HBM 带宽 → GPU 互联 → all_reduce 延迟 → 训练 step time 这条链的人,写分布式训练代码时的决策质量与"只调 world_size 的人"不在一个维度上。


新硬件的推理模板:CXL 内存池化

用这四层框架来推演 CXL 内存池化(见第八部分 interconnects.md 三节)对软件的影响:

CXL.mem: Host CPU 可以 load/store 远端内存,延迟 ~300ns (vs 本地 DRAM ~100ns)
    ↓
    物理层: 延迟 ×3, 带宽 ≈ 本地 DRAM / N (共享池)
    ↓
    事务层: CXL.cache 提供 MESI 缓存一致性(远端内存可以被本地 L3 缓存)
    ↓
    结构层: Redis / Memcached 的 hash table 会把 "远端 CXL 内存" 视为新 NUMA node
             → 数据结构不变(仍是 hash table),但迁移策略变:热点 key 应往本地 DRAM 迁移
    ↓
    抽象层: Linux kernel 通过 ACPI SRAT/HMAT 将 CXL 内存暴露为新的 NUMA node
             → 应用层可以用 `move_pages()` / `mbind()` 做显式迁移
             → 或者靠 auto-tiering(如 Intel Optane 时代的内存分层)
    ↓
    你的代码需要改什么?
        1. 不再假定所有内存的延迟相同(`numa_node` 的性能差异从 20% 变成 200%)
        2. B+ 树的 buffer pool 需要区分远端 page 和本地 page(淘汰策略先淘汰远端)
        3. LSM-tree 的 SSTable 可以选择性地放在远端大容量 CXL 内存上

工程师的反射框架(升级版)

给定新硬件或新负载,从第八部分出发的推理框架:

步骤 1: 看延迟预算
    μs? 100μs? 10ms?
    → 第八部分 memory-hierarchy.md 延迟对比表(L1=1ns → HBM=300ns → DRAM=100ns → SSD=100µs)

步骤 2: 看带宽体和并发窗口
    字节/周期? 64B/cache line? 4KB/page? 1MB/SSD block? 1024-bit/HBM?
    → 第八部分 memory-hierarchy.md: cache line 64B; mmu-dma.md: page 4KB;
       interconnects.md: NVLink link 50 GB/s; gpu-architecture.md: HBM3 3.35 TB/s

步骤 3: 看一致性情模型
    单机 shared? 多核 NUMA? 分布式 quorum? RDMA one-sided?
    → 第八部分 memory-hierarchy.md 六节: MESI 协议; interconnects.md 五节: RDMA verbs;
       cpu-superscalar.md 八节: LSQ 内存消歧

步骤 4: 看并行粒度
    指令级 ILP? 数据级 SIMD/SIMT? 核级 OoO? 设备级 GPU/TPU? 集群级分布式?
    → 第八部分 cpu-superscalar.md: ROB 窗口; gpu-architecture.md: warp scheduler;
       ai-accelerators.md: Systolic Array; interconnects.md: rail-optimized topology

步骤 5: 看数据布局
    顺序 vs 随机? 对齐 vs 非对齐? coalesced vs scattered?
    → 第八部分 gpu-architecture.md 十节: shared memory bank conflict
       memory-hierarchy.md 十三节: RowHammer 物理层漏洞

步骤 6: 看计算模式
    compute-bound (GEMM/Conv)? memory-bound (element-wise/LayerNorm)?
    communication-bound (all-reduce)?
    → 第八部分 gpu-architecture.md 十四节: MFU 分析 (GPT-3 训练 MFU ~38%)
       ai-accelerators.md 十一节: 存储墙的四种解决路线

最后产出可能的运行平台实现:CPU + 软件 / CPU + 用户态 / GPU + CUDA / GPU + NCCL / TPU + XLA / FPGA + HLS / 分布式 + RDMA。每一步都落在一个具体的硬件路径上,而不是泛泛地说"用 GPU 加速"。


易错清单

  1. "抽象层提升之后,物理层不重要了":SQL 写得好可以不管 B+ tree——但如果你的 ORDER BY 触发了 filesort(临时文件写 SSD),你就在不知不觉中撞上了 NAND flash 的 erase block 延迟。抽象层会漏,而且漏在 p99 上。

  2. "O(log n) 一定比 O(1) 慢":在 n=1000、L2 cache 内的二分查找(~30ns × 10 = 300ns)可能比一次 DRAM 随机访问(~100ns)慢。但这个快慢只在特定 cache 状态下成立——数据一被 evict,O(1) 又赢了。没有绝对的 O,只有绝对的 cache line。

  3. "NVLink 900 GB/s = 够快":NVLink 4 的 900 GB/s 是 bidirectional 总和。实际 all-reduce 中的有效带宽在 70-85% 利用率。而且 NVLink 18 条链路是物理固定的——DGX 的 8 卡格局是 NVSwitch 的端口数和物理链路数量的最优拼点,不是"想要几个就几个"。

  4. "HBM 比 DDR 快是因为频率高":HBM 快是因为宽(1024-bit vs 64-bit)+ 近(mm 级 vs cm 级走线)。HBM3 的频率 6.4 Gbps 其实低于 DDR5 的 5.6 GT/s(编码方案不同),快在并行度而非串行速度。

  5. "Tensor Core 就是快一点的 FP16 单元":Tensor Core 的 MMA 指令是 4×4×4 matrix multiply-accumulate,不是 1 个浮点乘法。CUDA Core 的单次 FMA 只能做 1 对乘加,Tensor Core 同一时钟做 128 对。这不是"快 2×",是计算模式的维度差异。

  6. "RDMA 延迟 = 网络延迟":RDMA 的一半延迟在 PCIe。发起 RDMA Write 需要:NIC 通过 PCIe DMA 读源 HBM(~1µs over PCIe)+ IB 链路传输(~1µs)+ 目的端 PCIe DMA 写目标 HBM(~1µs)。即使 IB 链路是 0ns,RDMA 先天就有 ~2µs 的 PCIe tax。

  7. "FP8 训练 = 便宜版 FP16 训练":FP8 需要在训练中动态 scale(per-tensor scaling factor),这个 scale 本身是学习出来的(delayed scaling)。如果 scale 错误 → 梯度 underflow/overflow → 训练发散。NVIDIA Transformer Engine 的 FP8 训练本质上是把精度管理和数值稳定性从"硬件保证"转移到"软件责任"——省了带宽,多了工程复杂度。


这一章带走的东西

  • 10 条推理链从硅片一路推到应用层,每一条的起点都在**第八部分(计算机组成原理)**的对应章节。cache line → B+ tree(见 memory-hierarchy.md)、DRAM timing → O 常数(见 memory-hierarchy.md + cpu-superscalar.md)、NAND erase → LSM(见 memory-hierarchy.md)、RDMA verbs → 用户态网络栈(见 interconnects.md)、SIMT → ML 矩阵乘(见 gpu-architecture.md + ai-accelerators.md)、NUMA → Redis 单线程(见 memory-hierarchy.md 六节)、FP8 → H100 设计哲学(见 gpu-architecture.md + ai-accelerators.md)、NVLink → 模型并行(见 interconnects.md)、HBM TSV → 内存带宽墙(见 memory-hierarchy.md + ai-accelerators.md)。

  • 四种推理层次(物理层 → 事务层 → 结构层 → 抽象层)是解耦硬件与软件的通用模板。任何新硬件出现时,从这四层分别做推演,可以预测软件栈的变形方向。

  • "硬件层为何决定软件设计"不是一句口号,而是一套可操作的推理方法。拿到新硬件(CXL、NVLink-C2C、UALink、FP8 Tensor Core),按 6 步反射框架走一遍,你能比 90% 的工程师更早做出正确的架构决策。这是第二部分到第八部分全部知识的收束——也是工程师从"会用工具"到"理解为什么工具长这样"的临界点。

  • 软件工程中最昂贵的错误不是写错一行代码,而是在错误的抽象层做优化。理解硬件不是目的——是在正确的抽象层做正确决策的前提。 当你在 memcached 的 hash table 上加了复杂的 LRU 却忘了 NUMA-aware sharding,你是在抽象层优化物理层的问题——这个错误在第 6 步反射框架的第一行就能避免。

返回 → README

形式化方法卷 · 用数学证明"系统是对的"

一句话

"测试能证明有 bug,但永远不能证明没 bug。"形式化方法(Formal Methods)用数学严格证明系统的性质:模型检查自动穷举状态空间,定理证明器像数学证明一样推理,程序验证用逻辑保证代码符合规格。它是可靠性要求最高领域(分布式共识、加密、编译器、航空/核工业、区块链)的黄金标准——TLA+ 验过 Raft、Coq 验过 CompCert 编译器、seL4 用 Isabelle/HOL 证明了 OS 内核。

思想链

[你写了个分布式共识算法]
  └─> 测试跑了 1000 次都过 → 但并发/网络故障组合无穷, 测不完
        └─> 模型检查 (TLA+): 穷举"小规模状态空间", 找反例
              └─> 写规格 + 不变量, 自动验证: 活锁/死锁/不满足性质
                    └─> 但要证明"代码实现 = 规格" → 需要更强的工具
                          └─> 定理证明 (Coq/Lean): 证明器人机交互证明
                                └─> Curry-Howard: 命题=类型, 证明=程序
                                      └─> 程序验证: Hoare 逻辑/符号执行
                                            └─> 编译器被证明 (CompCert)
                                              └─> 内核被证明 (seL4)
                                                    └─> 安全关键系统的护城河

每一层工具解决"可靠性"的不同粒度:测试给信心、模型检查给反例、定理证明给正确性证明、程序验证把证明连到真实代码。

你将带走什么

读完应能:

  1. 说清测试、模型检查、定理证明三者的区别与适用场景。
  2. 用 TLA+ 写一个简单系统(如互斥锁)的规格并跑 TLC 模型检查。
  3. 理解 Coq/Lean 的依赖类型与 Curry-Howard,知道定理证明器和"智能代码编辑器"的区别。
  4. 理解 Hoare 三元组和程序验证的核心思路,知道符号执行、形式语义是什么。
  5. 知道哪些工业系统真用形式化方法(TLA+/Coq/Isabelle/seL4),以及为什么只在关键处用。

章节结构

与其余部分的接口

本卷章节接口
TLA+ / 模型检查分布式 §共识(Paxos/Raft 用 TLA+ 验证)、并发 §一致性、DSA §图搜索
Coq / Lean理论 §形式语言/自动机、编译 §类型系统/HM、数学 §逻辑
程序验证编译 §SSA/语义分析、软件工程 §代码质量、数学 §Hoare 逻辑基础

这卷不是什么

  • 不是一本 Coq/Lean 完整教程(那需要单独成书)。给的是"心智模型 + 最小可跑示例"。
  • 不是说测试没用。形式化方法解决的是测试覆盖不到的(并发组合、无穷输入、安全关键)。
  • 不是所有项目都该上。它成本高,只在正确性代价极高时用——这是关键判断。

什么时候用

场景用哪个
分布式算法(共识/复制/时钟)TLA+ 模型检查
加密协议 / 密码实现定理证明 + 程序验证
编译器 / 内核 / 调度器Coq / Isabelle 证明
区块链共识 / 智能合约模型检查 + 符号执行
普通 CRUD 应用不用(测试足够)

下一篇: 1. 模型检查与 TLA+: 穷举状态空间找反例.

1. 模型检查与 TLA+: 穷举状态空间找反例

TL;DR

模型检查(Model Checking):把系统的所有状态穷举出来,自动验证"不变量是否永远成立"、"会不会死锁/活锁"。TLA+(Temporal Logic of Actions,Leslie Lamport 1994)是专为并发/分布式系统设计的规格语言——Raft 作者 Ongaro 用 TLA+ 验证了 Raft,Paxos、BFT、各种共识协议都有 TLA+ 规格。这一章教你理解 TLA+ 的思维、写最小规格、跑 TLC 模型检查。

读完应能:

  1. 说清模型检查 vs 测试 vs 定理证明的区别。
  2. 看懂 TLA+ 规格的核心(状态变量 / 初始谓词 / 动作 / 不变量 / 时间性质)。
  3. 写一个简单系统的 TLA+ 规格(如互斥锁、时钟同步)并跑 TLC。
  4. 用 Lamport 的三个经典性质(安全性 / 活性 / 公平性)分析系统。
  5. 知道 TLA+ 在工业(Raft/Paxos/Amazon)里的实际用法。

一、模型检查 vs 测试

1.1 为什么测试不够

并发系统的状态空间是无穷/指数级的:

2 个进程, 每进程 10 个内部状态 → 100 个组合状态
3 个进程 → 1000
n 个进程 + 消息延迟 + 故障 → 天文数字

测试只能抽样;模型检查穷举(在可管理的小规模上)。

1.2 三者的定位

测试模型检查定理证明
怎么工作跑真实代码穷举状态空间逻辑推导
找什么bug反例(违反性质的路径)全部性质的证明
规模真实简化(小 n)任意
自动化低(人机交互)
结果"这次过""有/没有反例""证明了"
适合日常并发/分布式协议关键安全实现

note

模型检查不能证明"大系统"(状态爆炸),但它能在小规模上穷举——而并发 bug 通常在小规模就能复现。Raft 的 TLA+ 用 3-5 节点验证,抓到了活锁。


二、TLA+ 的核心思维

2.1 三个概念

状态变量 (state variables): 系统的"内存" (如 pc, x, msg)
初始状态 (Init):            系统一开始长什么样
动作 (Next):                状态如何转换 (行为 = 状态序列)

2.2 一个系统的 TLA+ 规格

---- MODULE SimpleCounter ----
EXTENDS Naturals

VARIABLE n

Init == n = 0

Next == n' = n + 1        \* 每次加 1 (n' 表示"下一个状态")

Spec == Init /\ [][Next]_n    \* 从 Init 开始, 每步执行 Next (或不变)
====
  • n' = n + 1:动作谓词,描述"下一个状态 n' 是什么"。
  • [][Next]_n:每步要么执行 Next,要么保持不变(stuttering)。

2.3 不变量(Invariant)

不变量 = 系统任何状态下都必须为真的性质。

Inv == n >= 0      \* 计数器永不为负

\* 在 .cfg 里让 TLC 检查: INVARIANT Inv

模型检查器会穷举所有可达状态,验证不变量是否一直成立;不成立就给出一条反例路径


三、实战:互斥锁的 TLA+

3.1 规格

两个进程想进临界区,必须互斥(不能同时在里面)。

---- MODULE Mutex ----
EXTENDS Naturals

CONSTANT N                      \* 进程数
VARIABLES pc                     \* 每个进程的程序计数器

Proc(i) == i \in 1..N

Init == pc = [i \in 1..N |-> "idle"]

\* 请求进入
Req(i) == pc[i] = "idle" /\ pc' = [pc EXCEPT ![i] = "waiting"]

\* 尝试获取锁: 只有当没有其他进程在临界区时
Get(i) == pc[i] = "waiting" /\ \A j \in 1..N \ {i} : pc[j] /= "crit"
           /\ pc' = [pc EXCEPT ![i] = "crit"]

\* 释放
Rel(i) == pc[i] = "crit" /\ pc' = [pc EXCEPT ![i] = "idle"]

Next == \E i \in 1..N : Req(i) \/ Get(i) \/ Rel(i)
Spec == Init /\ [][Next]_pc
====

\* === Mutex.cfg ===
\* SPECIFICATION Spec
\* CONSTANT N = 2
\* INVARIANT MutexInv

3.2 互斥性质

MutexInv == \A i, j \in 1..N : i /= j => ~(pc[i] = "crit" /\ pc[j] = "crit")
\* 任意两个进程不可能同时在临界区

TLC 跑完:验证通过(或给出反例——比如 Get 条件漏了,两个进程同时进临界区)。

3.3 时间性质(Temporal Properties)

性质含义TLA+
Safety(安全性)"坏事永不发生"不变量 / []P(永远 P)
Liveness(活性)"好事终究发生"<>P(最终 P)
Fairness(公平性)请求的资源终将被授予强/弱公平假设
[]P      : 永远 P (always P)          — 安全性
<>P      : 最终 P (eventually P)       — 活性
[]<>P    : 无限次 P                    — 重复活性
<>[]P    : 最终永远 P                  — 稳定

例:互斥锁的活性 <> (pc[i] = "crit")——进程 i 最终能进临界区(不被饿死)。

note

安全性 vs 活性是理解并发系统的关键二分:安全性保证"系统不产生坏结果"(不变式),活性保证"系统最终出结果"(不死锁不饿死)。两个都要验证。


四、TLC 模型检查器

4.1 跑起来

# 需要 TLA+ 工具 (TLA+ Toolbox / TLC)
tlc Mutex.cfg            # 读规格 + 配置

TLC 输出:

Model checking completed. No error has been found.
  State1: pc = [1 |-> "crit", 2 |-> "waiting"]
  ... (枚举所有可达状态)

如果违反不变量:

Invariant MutexInv is violated.
  Behavior up to this point:
  pc = [1 |-> "crit", 2 |-> "crit"]   ← 反例路径

4.2 配置 (.cfg) 文件

SPECIFICATION Spec
CONSTANT N = 3
INVARIANT MutexInv
PROPERTY Liveness            \* 检查活性
CONSTANTS N = 3, Timeout = 5

4.3 状态爆炸怎么缓解

手段说明
减小 N3-5 个进程通常够发现 bug
对称规约对称进程算一个
抽象去掉无关细节(消息内容 → 类型)
属性导向只检查关心的部分

五、真实应用:Raft 与 Paxos

5.1 Raft 的 TLA+

  • Raft 论文作者 Ongaro 在论文里提供 TLA+ 规格Raft.tla),验证了选举/日志复制/安全性。
  • 规格把节点建模为状态机:Follower/Candidate/Leader + 任期 + 日志。
  • 验证的性质:Election Safety(同一任期只有一个 leader)、Log Matching(日志一致)、Leader Completeness(已提交日志在后续 leader 中保留)。

5.2 Lamport 的 Paxos

  • Lamport 用 TLA+ 写了 Paxos 的严格规格(Paxos.tla),在 TLA+ 文档里就是标准例子。
  • 模型检查在有限进程/值下穷举,抓"活锁"、"选值冲突"等。

5.3 Amazon 的实践

  • Amazon 用 TLA+ 分析多个分布式系统,发现了真实系统里测试没抓到的 bug,有些会导致数据不一致。
  • 价值:在写实现之前/之中写规格,抓设计层的并发错误,而不是等线上事故。

warning

TLA+ 不是"验证实现",是"验证设计/算法"。它证明的是规格的性质,不是代码正确。要连到代码,需要后面程序验证那一层的工具(模型检查代码 / 定理证明实现)。


六、其他模型检查工具

工具语言用途
TLA+ / TLCTLA+并发/分布式协议设计验证
SpinPromela通信协议 / 并发系统模型检查
NuSMV / nuXmvSMV符号模型检查(时序逻辑 CTL)
CBMC / KLEEC/LLVM程序级模型检查 / 符号执行(见程序验证章)
AlloyAlloy关系/图结构的模型分析

七、结束 + 速查表

tip

一页快速唤回:

  • 模型检查:穷举状态空间,验证不变量/性质,找反例。适合并发/分布式协议
  • 测试 ≠ 模型检查:测试抽样、模型检查穷举(小规模)。
  • TLA+ 三件套:Init(初始)、Next(动作)、不变量。
  • 安全性 []P:坏事永不发生;活性 <>P:好事终究发生。
  • 互斥锁验证:不变量"无两进程同时在临界区",活性"最终能进"。
  • Raft/Paxos 有官方 TLA+ 规格;Amazon 用 TLA+ 抓真实并发 bug。
  • TLA+ 验证设计不验证实现——连到代码要程序验证那层。
  • 状态爆炸:减 N、对称规约、抽象。

下一篇: 2. 定理证明: Coq / Lean / 依赖类型 / Curry-Howard.

2. 定理证明: Coq / Lean / 依赖类型 / Curry-Howard

TL;DR

模型检查在"小规模"上穷举;定理证明(Interactive Theorem Proving)在"任意规模"上用逻辑推理证明命题——本质是让计算机验证你的证明每一步是否合法。Coq 和 Lean 是两类主流证明助手,它们的根基是依赖类型Curry-Howard 对应(命题 = 类型,证明 = 程序)。这一章给心智模型 + 最小可跑示例,让你能看懂 Theorem ... := ... 和证明脚本,理解 CompCert / seL4 / F* 这类"被证明的系统"。

读完应能:

  1. 理解 Curry-Howard:为什么"证明一个命题"等价于"构造一个类型的程序"。
  2. 理解依赖类型与普通类型系统的区别(类型可以依赖值)。
  3. 读一个简单的 Coq / Lean 证明,理解 Proof / Qed / tactics 是什么。
  4. 知道 Coq / Lean / Isabelle 的区别与代表成果(CompCert / seL4 / mathlib)。
  5. 理解定理证明的成本与适用场景(编译器/内核/密码关键实现)。

一、Curry-Howard 对应:命题即类型,证明即程序

1.1 核心对应

逻辑类型论
命题 A类型 A
A 的证明类型 A 的一个程序(term)
A ∧ B积类型 (A × B)
A ∨ B和类型 (A + B)
A ⇒ B函数类型 (A → B)
∀x, P(x)依赖函数 (∀ x, P x)

note

Curry-Howard:证明"若 A 则 B" = 写一个函数 A → B(输入 A 的证明,输出 B 的证明)。证明"存在 x 使 P(x)" = 构造一个具体的 x 和它的证明。这就是为什么写证明像写程序——证明助手本质是个"带类型检查的编程语言"。

1.2 为什么这很重要

  • 程序即证明:你写一个满足类型的 term,就是给出一个证明。
  • 类型检查即验证:计算机自动检查 term 类型是否合法 = 自动验证证明步骤。
  • 反例即类型错误:证明有漏洞 → 类型检查不过 → 编译器拒绝。

二、依赖类型:类型可以依赖值

2.1 普通类型系统

List<Int>:类型参数是类型(Int),不是值。

2.2 依赖类型

类型可以依赖具体值

(* 长度索引的向量: Vec A n 是长度为 n 的 A 列表 *)
Inductive Vec (A : Type) : nat -> Type :=
  | nil  : Vec A 0
  | cons : forall n, A -> Vec A n -> Vec A (S n).
  • Vec A 3 是"长度恰好为 3 的向量"——长度是类型的一部分
  • 好处:head 函数可以只在"非空向量"上定义(类型系统强制),空向量调 head 编译都不过。

2.3 例子:头元素只对非空合法

Definition head (A : Type) (n : nat) (v : Vec A (S n)) : A :=
  match v with
  | cons _ a _ => a
  end.
(* 注意: 没有 nil 分支! 因为类型 Vec A (S n) 保证非空, 编译器不接受 nil *)

warning

这正是依赖类型 vs 普通类型系统的分水岭:普通系统在运行时查 null / 抛异常;依赖类型在编译期就把非法状态排除。代价是写证明(构造类型)更难。


三、Coq:一个最小的证明

3.1 环境

Coq 是一个依赖类型语言 + 证明助手(CIC 演算),配合 IDE(CoqIDE / VS Code + vscoq)。

3.2 一个简单定理

(* 证明: 对任意自然数 n, n + 0 = n *)
Theorem plus_0_r : forall n : nat, n + 0 = n.
Proof.
  intros n.
  induction n as [| n' IH].
  - reflexivity.              (* 0 + 0 = 0, 直接化简成立 *)
  - simpl. rewrite IH. reflexivity.   (* S n' + 0 = S (n'+0) = S n' *)
Qed.
  • intros n:把 forall n 的 n 引入上下文。
  • induction n:归纳法(基础情形 + 归纳步骤)。
  • reflexivity / rewrite IH:tactic(策略),驱动证明。
  • Qed:证明完成,定理被登记为可信。

3.3 证明本质上是"指导类型检查器"

每步 tactic 都在改变证明项(term),最终形成一个类型为"该定理"的 term——Qed 让 Coq 内核独立校验这个 term。即使是复杂 tactic,最终证明都要过内核的语法检查(这就是可信内核的关键设计)。

3.4 证明被编译进程序(可提取)

(* 程序提取: 把证明中的可计算部分提成 OCaml/Haskell *)
Extraction "sorted.ml" sort_spec.

四、Lean:现代证明助手

4.1 与 Coq 的区别

CoqLean
系统CIC 演算(依赖类型)依赖类型 + 商类型
定位程序验证成熟(CompCert)数学库 + 验证并重(mathlib)
元编程Ltac(较老)Lean 4 用自身(宏)
风格偏"程序"偏"数学 + 程序"
代表CompCert, VSTmathlib (数学), Verifiable C

4.2 Lean 例子

-- 证明: 对任意自然数 n, n + 0 = n
theorem plus_zero_right (n : Nat) : n + 0 = n := by
  induction n with
  | zero => rfl
  | succ n ih => simp [ih]

-- 或更函数式:
def addZero : (n : Nat) → n + 0 = n
  | 0 => rfl
  | n+1 => by simp [addZero]

4.3 mathlib:最大的数学证明库

  • mathlib 是 Lean 的数学库,包含大量现代数学的形式化证明。
  • 意义:定理证明已经证明"庞大数学理论",不只是玩具。

五、被证明的系统:为什么值得

5.1 CompCert(被证明的 C 编译器)

  • 用 Coq 证明:编译器的每次变换都保持程序语义
  • 结论:C 程序在 CompCert 编译下"正确运行" = 在数学意义上被保证(无编译器 bug)。
  • 价值:安全关键软件(航空、汽车)用 CompCert 消除"编译器引入的 bug"。

5.2 seL4(被证明的操作系统内核)

  • 用 Isabelle/HOL 证明 seL4 微内核满足功能规格。
  • 这是第一个"操作系统内核功能正确性"的机械验证。
  • 影响:验证安全关键系统(军用、汽车)的基石。

5.3 其他

  • F / F*:依赖类型验证语言,用于加密实现(EverCrypt 用 F* 生成 C/汇编加密库)。
  • VST (Verified Software Toolchain):Coq 里验证 C 程序的工具。
  • RustBelt:证明 Rust 的类型系统 + unsafe 内存安全。

六、什么时候用定理证明

不用
编译器 / 内核 / 加密原语(不可有 bug)普通应用(bug 可接受、迭代快)
协议关键性质(共识、时间)已有测试足够的系统
数学/逻辑研究快速原型

成本:CompCert 用了数人年、几十万行证明。这是每行证明的成本远高于每行代码的领域。

tip

工程上的务实做法:只在"正确性代价极高"的组件用(如加密库、内存安全边界、共识核心),其余用测试。现代趋势是"形式化 + 测试"混合(如 Rust 生态里用证明验证 unsafe 部分)。


七、与前面章节的接口

  • 依赖类型 = 类型系统(编译 §type-system)+ 数学(理论 §形式语言)。
  • Curry-Howard = 逻辑(数学 §逻辑)在类型里的实现。
  • Hoare 逻辑(下一章)是"程序验证"用逻辑证明代码性质,定理证明是它的执行工具。

八、结束 + 速查表

tip

一页快速唤回:

  • Curry-Howard:命题=类型、证明=程序、证明检查=类型检查。
  • 依赖类型:类型依赖值(Vec A n),编译期排除非法状态。
  • Coq:CIC 演算 + tactics(intros/induction/reflexivity/rewrite),Qed 过内核校验。
  • Lean:现代证明助手,mathlib 数学库,验证 + 数学并重。
  • 证明 = 指导类型检查器:即使复杂 tactic,最终 term 要过内核。
  • 代表成果:CompCert(C 编译器)、seL4(OS 内核)、EverCrypt(加密)、RustBelt(Rust)。
  • 成本极高:只在正确性关键处用;"证明每行"比"写每行"贵得多。
  • 工程趋势:形式化 + 测试混合(关键组件证明,其余测试)。

下一篇: 3. 程序验证: Hoare 逻辑 / 符号执行 / 形式语义.

3. 程序验证: Hoare 逻辑 / 符号执行 / 形式语义

TL;DR

TLA+ 验证"算法设计",Coq 证明"数学命题";**程序验证(Program Verification)**把两者连起来——验证真实代码满足规格。核心工具是 Hoare 逻辑{P} 程序 {Q}:前置条件 P 下运行程序,结束时满足 Q)、符号执行(用符号变量替代输入,穷举路径)、形式语义(程序行为的严格定义)。这一章讲心智模型 + 最小示例,让你理解"为什么编译器/内核/加密代码能被证明",以及符号执行工具(KLEE / CBMC)怎么自动找 bug。

读完应能:

  1. 写出并理解 Hoare 三元组 {P} C {Q},会推导最弱前置条件。
  2. 理解形式语义的三种(操作/指称/公理),知道分别干什么。
  3. 理解符号执行怎么自动找 bug(KLEE / CBMC 原理)。
  4. 说出程序验证在工程(智能合约、加密、内核)里的实际用法。
  5. 理解"验证代码"和"验证设计"的关系(本章 vs 上一章)。

一、Hoare 逻辑

1.1 Hoare 三元组

$$ {P}\ C\ {Q} $$

  • $P$:前置条件(运行 C 前必须为真)
  • $C$:程序(语句)
  • $Q$:后置条件(C 运行结束后必须为真)

读作:"如果 P 在执行前成立,并且 C 正常终止,那么执行后 Q 成立"。

1.2 例子

{x = 2}  x := x + 1  {x = 3}
{true}   x := 0      {x = 0}
{i < n}  i := i + 1  {i <= n}      ← 循环边界

1.3 核心推导规则

赋值公理(最强后置条件规则):

$${P[x \mapsto E]}\ x := E\ {P}$$

把 P 里所有 x 替换成 E。例:

{x + 1 > 0}  x := x + 1  {x > 0}

序列规则

{P} C1 {R}  ∧  {R} C2 {Q}  ⇒  {P} C1; C2 {Q}

条件规则

{P ∧ B} C1 {Q}  ∧  {P ∧ ¬B} C2 {Q}  ⇒  {P} if B then C1 else C2 {Q}

循环不变量(while 的关键):

{P ∧ B} C {P}  ⇒  {P} while B do C {P ∧ ¬B}

$P$ 是循环不变量:每轮循环开始/结束都为真,退出时加 ¬B

note

循环不变量是 Hoare 验证最难的部分(人工)。像"排序后 i ≤ 数组长度"这类。这就是为什么程序验证工具链要配合自动推理。


二、形式语义

2.1 三种语义

语义定义用途
操作语义(Operational)状态如何一步一步变化(转移规则)解释器、模型检查
指称语义(Denotational)程序映射到数学对象(函数)抽象分析、编译器优化
公理语义(Axiomatic)Hoare 逻辑那套规则程序验证

2.2 操作语义示例(小步)

(x := E, σ) → (skip, σ[x ↦ Eval(σ, E)])     \* 状态更新
(skip; C2, σ) → (C2, σ)                      \* 顺序执行

程序 (C, σ) 二元组, 是一步步转换,直到 skip 结束。

2.3 为什么需要形式语义

  • 语义是编译器/验证器/模型的共同地基:没有严格语义,"编译器正确"无法定义。
  • 上一章 CompCert "保持语义"指的就是操作语义在变换前后不改变(观察行为一致)。

三、最弱前置条件(Weakest Precondition)

3.1 定义

给定语句 C 和后置条件 Q,最弱前置条件 wp(C, Q) 是所有使 C 执行后满足 Q 的最宽松条件。

{P} C {Q}  ⟺  P ⇒ wp(C, Q)

3.2 推导示例

# 目标: {?} y := x + 1; x := y * 2 {x > 10}
# 从后往前:
wp(x := y*2, x > 10)   = y*2 > 10  = y > 5
wp(y := x+1, y > 5)    = x+1 > 5   = x > 4
# 结论: {x > 4} y := x + 1; x := y * 2 {x > 10}

这就是程序验证器的核心算法——从后置条件反向推导前置条件,验证"实际前置 P 蕴含 wp"。


四、符号执行(Symbolic Execution)

4.1 与测试的区别

测试:   输入 = 具体值 → 跑 → 检查输出
符号执行: 输入 = 符号变量 x → 跑 → 记录路径条件 → 检查所有路径
  • 符号代替输入值,执行时路径条件是布尔表达式(x > 0x < 100...)。
  • SMT 求解器(Z3)判断路径条件是否可满足 → 自动生成测试用例 / 找不可达路径 / 找 bug。

4.2 例子

int foo(int x) {
    int y = x + 1;
    if (y > 10) return 1;      // 路径1: x+1 > 10 → x > 9
    else return 0;             // 路径2: x+1 <= 10 → x <= 9
    // 符号执行枚举这两条路径, Z3 判断可满足性
}

4.3 工具

工具语言用途
KLEELLVM bitcodeC/C++ 符号执行,自动找 crash / 断言失败
CBMCC/Java有界模型检查 + 符号执行,验证断言
angr二进制逆向/漏洞分析
Manticore二进制/以太坊智能合约 + 二进制分析
Jalangi / ExpoSEJS前端符号执行

4.4 工程价值

  • 自动生成测试:符号执行产出"覆盖所有可达路径"的输入。
  • 找漏洞:数组越界、除零、断言失败自动发现。
  • 智能合约安全:用符号执行 + 模型检查找重入、整数溢出(如 Mythril / Slither)。

五、静态分析:程序验证的"轻量版"

5.1 三档强度

工具强度例子
Lint / 规则检查轻(启发式)golangci-lint, eslint
静态分析(数据流)中(近似)SAST: gosec, semgrep, CodeQL
形式验证重(精确)KLEE, CBMC, VST, seL4
  • 静态分析可能误报(over-approximate),形式验证不误报(但要规格)。
  • 工程上:Lint + SAST 进 CI(便宜),形式验证用于关键组件。

5.2 CodeQL 例子(找 SQL 注入)

import python
from FlaskRequest r, MySQLExec call
where call.getArg(0).getValue().getExpr().(Name)
      .refersTo(r.getArg("query"))
select call, "SQL injection from request param"

六、程序验证 vs 前面章节

TLA+ (设计)        验证算法/协议的性质        [上一章]
定理证明 (Coq)     验证数学命题 / 抽象程序    [上一章]
程序验证 (本章)     验证真实代码满足规格        ← 本章

三者的分工

对象工具
算法/协议Raft / Paxos / 加密协议TLA+ / 模型检查
数学性质编译正确性 / 内核规格Coq / Isabelle
真实代码C 函数 / 智能合约 / 加密实现Hoare / 符号执行 (KLEE/CBMC)

warning

现实是"验证设计 ≠ 验证实现"。Raft 有 TLA+ 证明,但 Go 实现仍然可能 bug——所以要么信任测试,要么把关键实现也做符号执行/静态分析。


七、工程落地建议

什么时候用什么:
  并发协议设计        → TLA+ (设计期, 便宜)
  加密/解析器/内核关键  → 定理证明 + 程序验证 (代价高, 但正确性关键)
  智能合约            → 符号执行 + 静态分析 (Mythril / Slither)
  普通代码            → Lint + SAST + 测试 (工程默认)

tip

务实组合拳(工程现实):

  1. 测试打底(快、全覆盖路径抽样)
  2. Lint + SAST 进 CI(便宜、抓常见)
  3. 符号执行在关键函数上(解析器、序列化、安全边界)
  4. TLA+ 在并发协议设计期(写实现前)
  5. 只有极少数(加密、内核)才值得全定理证明

八、结束 + 速查表

tip

一页快速唤回:

  • Hoare 三元组 {P} C {Q}:P 前置、C 程序、Q 后置。
  • 赋值公理{P[x→E]} x:=E {P}
  • 循环不变量{P∧B} C {P} ⇒ {P} while B C {P∧¬B},找它是难点。
  • 最弱前置条件 wp(C,Q):反向推导,验证器核心算法。
  • 三种语义:操作(步进)/ 指称(数学对象)/ 公理(Hoare 规则)。
  • 符号执行:符号输入 + 路径条件 + SMT 求解器;自动生成测试 / 找 bug。
  • 工具:KLEE(C)/ CBMC(有界模型)/ angr(二进制)/ Mythril(合约)。
  • 三档:Lint < 静态分析 < 形式验证;误报 vs 精确。
  • 分工:TLA+ 验设计、Coq 验数学、本章验真实代码。
  • 务实:测试 + SAST + 符号执行 + TLA+ 组合,全定理证明只在极关键处。

回主目录: 形式化方法卷 README.

量子计算卷 · 用量子比特重新定义"计算"

一句话

经典计算机用比特(0 或 1)计算;量子计算机用量子比特(qubit)——它可以处于 0 和 1 的叠加态,多个 qubit 之间存在纠缠。量子计算的目标:对某些特定问题(大数分解、无序搜索、量子模拟)提供指数级/平方级加速。这一卷讲透量子计算的三块基础:物理基础(Dirac 记号 / qubit / 门 / 测量)、核心算法(Deutsch-Jozsa / Grover / Shor)、以及落地最大的障碍(量子纠错 / surface code)。

思想链

[经典计算: bit = 0 或 1, 一次算一个状态]
  └─> qubit = α|0⟩ + β|1⟩ (叠加态, 测量才坍缩)
        └─> 量子门 = 旋转矩阵 (酉变换, 可逆)
              └─> 纠缠: 两个 qubit 关联, 测量一个立即影响另一个
                    └─> 量子并行: n qubit 叠加 2ⁿ 个状态同时演化
                          └─> 但测量坍缩 → 只能"一次读一个" → 算法要巧妙
                                └─> Deutsch-Jozsa: 判定函数是常量还是平衡 (1 次查询)
                                      └─> Grover: 无序搜索 √N (经典 N)
                                            └─> Shor: 大数分解多项式时间 (经典指数)
                                                  └─> 但物理噪声 → 需要纠错
                                                        └─> surface code: 用大量物理 qubit 编码 1 个逻辑 qubit
                                                              └─> 现在: ~100 逻辑 qubit 门槛, 距 RSA 威胁仍远

你将带走什么

读完应能:

  1. 用 Dirac 记号(|0⟩ |1⟩ |+⟩)写 qubit 状态,理解叠加与测量坍缩。
  2. 知道量子门是酉变换(U†U = I),会写常见门(X/H/CNOT)矩阵。
  3. 理解纠缠为什么是量子计算的核心资源。
  4. 理解 Deutsch-Jozsa、Grover、Shor 三个算法"快在哪",以及为什么 Shor 威胁 RSA。
  5. 理解量子纠错的本质(用冗余逻辑比特 + 测量纠错),知道 surface code 为什么主流。
  6. 知道当前量子计算的实际状态(logical qubit 时代、Y2Q 估计),不迷信炒作。

章节结构

与其余部分的接口

本卷接口
基础(qubit/门/测量)数学 §线代(复数/矩阵/酉/张量积)、组成原理(门电路类比)
Shor 算法数学 §离散数学(数论/模运算)、密码学 §RSA(为什么 RSA 怕 Shor)
Grover 算法DSA §搜索、数学 §概率
量子纠错信息论 §编码(经典纠错类比)、密码学 §量子安全

这卷不是什么

  • 不是完整量子物理课(不讲薛定谔方程推导、具体超导/离子阱实现)。
  • 不是量子力学世界观科普(聚焦"计算"模型,不谈哲学)。
  • 不是说经典计算要被取代——量子只对特定问题有优势。

现状提醒

warning

截至 2026 年:量子纠错进入"logical qubit"时代(~100 逻辑 qubit 级),但 Shor 要破解 RSA-2048 需要 100 万-1000 万物理 qubit,业内 "Y2Q" 估计仍在 10-30 年。量子计算是真实但长期的方向——值得懂原理,别信"明年就破解"。


下一篇: 1. 量子计算基础: Dirac 记号 / qubit / 量子门 / 测量.

1. 量子计算基础: Dirac 记号 / qubit / 量子门 / 测量

TL;DR

量子计算的"数据结构"是 qubit,操作是 量子门(酉矩阵),输出靠 测量(坍缩)。这一章把这三个基础用线性代数讲透(第零部分线代 §1 复数/矩阵直接适用),并引入纠缠——量子计算最重要的非经典资源。

读完应能:

  1. 用 Dirac 记号写单/多 qubit 状态,理解叠加与归一化。
  2. 知道量子门是酉变换(U†U = I),会写 X/H/CNOT 的矩阵形式。
  3. 理解测量坍缩与 Born 规则(概率 = |振幅|²)。
  4. 理解张量积构造多 qubit,理解纠缠与 Bell 态。
  5. 会用简单量子线路图。

一、qubit:量子比特

1.1 经典 bit vs qubit

经典 bit:  0 或 1 (二选一)
量子 qubit: α|0⟩ + β|1⟩  (叠加: 同时是 0 和 1, 带复系数 α, β)
  • 基态:$|0\rangle = \begin{pmatrix} 1 \ 0 \end{pmatrix}$,$|1\rangle = \begin{pmatrix} 0 \ 1 \end{pmatrix}$。
  • 任意态:$|\psi\rangle = \alpha |0\rangle + \beta |1\rangle = \begin{pmatrix} \alpha \ \beta \end{pmatrix}$,其中 $\alpha, \beta \in \mathbb{C}$。
  • 归一化:$|\alpha|^2 + |\beta|^2 = 1$(概率和为 1)。

1.2 测量坍缩

测量 qubit:

  • 得到 $|0\rangle$ 的概率 = $|\alpha|^2$
  • 得到 $|1\rangle$ 的概率 = $|\beta|^2$
  • 测量后坍缩到测量到的基态(经典结果)。

warning

这是关键反直觉点:量子态在测量前"同时存在",测量是投影。所以量子计算的关键是"把答案的概率放大到可测",而不是"读所有叠加"——你只能读一次,读到什么看概率。

1.3 叠加态例子

  • $|+\rangle = \frac{1}{\sqrt 2}(|0\rangle + |1\rangle)$:测量得到 0 或 1 各 50%。
  • $|-\rangle = \frac{1}{\sqrt 2}(|0\rangle - |1\rangle)$:也是 50/50,但相位不同(后面 Grover 用得上)。

二、量子门:酉变换

2.1 门必须是酉矩阵

量子门 = 幺正(酉)矩阵 $U$:$U^\dagger U = I$(可逆、保范数)。

  • 因为态向量范数 = 1(概率),门必须保长度 → 酉。
  • 酉 = 可逆 → 量子计算可逆(无信息损失)。

2.2 单 qubit 门

矩阵作用
X(NOT)$\begin{pmatrix}0&1\1&0\end{pmatrix}$$
H(Hadamard)$\frac{1}{\sqrt2}\begin{pmatrix}1&1\1&-1\end{pmatrix}$$
Z$\begin{pmatrix}1&0\0&-1\end{pmatrix}$翻转 $
Y$\begin{pmatrix}0&-i\i&0\end{pmatrix}$绕 y 轴旋转

H 门是最重要的单门——它把确定态变成叠加态:

$$H|0\rangle = |+\rangle, \quad H|1\rangle = |-\rangle, \quad H^2 = I$$

2.3 门作用 = 矩阵乘法

$$X|0\rangle = \begin{pmatrix}0&1\1&0\end{pmatrix}\begin{pmatrix}1\0\end{pmatrix} = \begin{pmatrix}0\1\end{pmatrix} = |1\rangle$$

$$H|0\rangle = \frac{1}{\sqrt2}\begin{pmatrix}1&1\1&-1\end{pmatrix}\begin{pmatrix}1\0\end{pmatrix} = \frac{1}{\sqrt2}\begin{pmatrix}1\1\end{pmatrix} = |+\rangle$$


三、多 qubit 与张量积

3.1 张量积构造多 qubit 态

两个 qubit 的组合态用张量积(Kronecker product)

$$|\psi\rangle \otimes |\phi\rangle = \begin{pmatrix} \alpha \ \beta \end{pmatrix} \otimes \begin{pmatrix} \gamma \ \delta \end{pmatrix} = \begin{pmatrix} \alpha\gamma \ \alpha\delta \ \beta\gamma \ \beta\delta \end{pmatrix}$$

2 qubit 有 $2^2 = 4$ 个基态:$|00\rangle, |01\rangle, |10\rangle, |11\rangle$。 $n$ qubit 有 $2^n$ 个基态 → 指数级状态空间(量子并行之源)。

3.2 CNOT(受控 NOT)门

最重要的双 qubit 门(产生纠缠的引擎):

  • 控制 qubit 是 $|1\rangle$ 时,翻转目标 qubit;是 $|0\rangle$ 时不动。
  • 矩阵(4×4):

$$\mathrm{CNOT} = \begin{pmatrix} 1&0&0&0 \ 0&1&0&0 \ 0&0&0&1 \ 0&0&1&0 \end{pmatrix}$$

$$|00\rangle→|00\rangle,; |01\rangle→|01\rangle,; |10\rangle→|11\rangle,; |11\rangle→|10\rangle$$


四、纠缠(Entanglement)

4.1 Bell 态(最大纠缠)

对 $|00\rangle$ 做 H 到控制位 + CNOT:

$$|00\rangle \xrightarrow{H \otimes I} \frac{1}{\sqrt2}(|00\rangle + |10\rangle) \xrightarrow{CNOT} \frac{1}{\sqrt2}(|00\rangle + |11\rangle) = |\Phi^+\rangle$$

4.2 纠缠的本质

$$|\Phi^+\rangle = \frac{1}{\sqrt2}(|00\rangle + |11\rangle)$$

  • 测量第一个 qubit 得到 0 → 第二个必然 0;得到 1 → 必然 1。
  • 两个 qubit 关联,但态不可分解为两 qubit 的张量积(不可分离)。
  • 这种关联不受距离限制(EPR 悖论)——但不传递信息(测量前你无法控制结果)。

note

纠缠是量子计算"额外能力"的来源:没有纠缠的量子计算可以经典模拟(Gottesman-Knill 定理部分说明),有纠缠才有真正的量子优势。它是资源,不是"超光速通信"。


五、量子线路图

qubit 0: ── H ──●── M ────   (M = 测量, ● = CNOT 控制)
qubit 1: ────────X── M ────   (X = CNOT 目标)
|0⟩ ── H ──●── M ── 结果1 (与结果2 关联 → 纠缠)
|0⟩ ──────X── M ── 结果2

这画的就是 Bell 态制备 + 测量:HCNOT|Φ⁺⟩ → 测量两个 qubit 总相同。


六、常见数学工具(第零部分对接)

概念公式出处
内积$\langle\phi\psi\rangle$
模长$|\psi| = \sqrt{\langle\psi\psi\rangle}$
正交基$\langle 01\rangle = 0$
张量积$A \otimes B$线代 §6
酉矩阵$U^\dagger U = I$线代 §2
复共轭转置$U^\dagger = (U^*)^T$线代 §2

七、结束 + 速查表

tip

一页快速唤回:

  • qubit:$\alpha|0\rangle + \beta|1\rangle$,$|\alpha|^2 + |\beta|^2 = 1$。
  • 测量:Born 规则 $P(x) = |\langle x|\psi\rangle|^2$,测后坍缩。
  • 门 = 酉矩阵:$U^\dagger U = I$,可逆。
  • X/H/Z/Y 门:X 翻转、H 叠加、Z 相位翻转。
  • 多 qubit = 张量积:$n$ qubit → $2^n$ 基态(指数空间)。
  • CNOT:控制位为 1 时翻转目标位——纠缠引擎。
  • Bell 态 $|\Phi^+\rangle = \frac{1}{\sqrt2}(|00\rangle + |11\rangle)$:最大纠缠、测量全同。
  • 纠缠 ≠ 超光速通信:是资源,不传信息。

下一篇: 2. 量子算法: Deutsch-Jozsa / Grover / Shor.

2. 量子算法: Deutsch-Jozsa / Grover / Shor

TL;DR

叠加 + 纠缠给了量子计算机新能力,但测量会坍缩——所以算法必须设计成"把正确答案的概率放大"。这一章讲三个代表性算法,从教学(Deutsch-Jozsa)到实用(Grover)到威胁经典密码(Shor),理解"快在哪、为什么快、代价是什么"。

读完应能:

  1. 理解量子并行与"读一次"的张力(为什么算法要巧妙放大概率)。
  2. 讲清 Deutsch-Jozsa:用 1 次查询判定函数性质,经典要 2ⁿ⁻¹+1 次。
  3. 讲清 Grover:无序搜索 √N,知道为什么是平方级不是指数级。
  4. 讲清 Shor:把因式分解归约到"找周期"(量子优势所在),知道为什么威胁 RSA。
  5. 知道这些算法分别的复杂度对比与适用边界。

一、量子并行与"读一次"的张力

1.1 量子并行

对 $n$ 个 qubit 做 H 变换到全叠加:

$$|\psi\rangle = \frac{1}{\sqrt{2^n}} \sum_{x=0}^{2^n-1} |x\rangle$$

一次酉计算 $U_f$ 同时作用于所有 $2^n$ 个基态 → 指数级并行

1.2 但测量只给一个结果

U_f 叠加了 2^n 个结果, 但:
测量 → 坍缩到 1 个, 概率 = |振幅|²
→ 直接读 = 随机采样, 不是答案!

结论:量子算法的全部艺术在于——用干涉(相位)把答案振幅放大、错误振幅抵消,让测量更可能给出答案。经典算法读结果,量子算法"培养概率"。


二、Deutsch-Jozsa 算法

2.1 问题

给定黑盒函数 $f: {0,1}^n \to {0,1}$,承诺它要么常量(全 0 或全 1),要么平衡(一半 0 一半 1)。判断是哪种。

  • 经典:最坏要 $2^{n-1} + 1$ 次查询。
  • 量子:1 次查询

2.2 直觉

利用"相位编码":让函数作为相位翻转作用在叠加态上,不同函数类型产生不同干涉 → 测量结果区分。

关键点(简化):

对叠加态 |+...⟩ 应用 U_f
常量函数 → 相位整体不翻 → 测量得到 |0...0⟩
平衡函数 → 相位部分翻 → 干涉掉 |0...0⟩ 分量 → 测不到 |0...0⟩
  • 这就是"量子干涉":相位差导致振幅增强/抵消。
  • 它不直接给出"f 是什么",而是给出"f 是不是常量"——用 1 次查询完成分类。

2.3 为什么意义重大

教学意义 > 实用意义:第一个严格证明量子比经典有指数优势的算法(虽然问题本身人为)。它展示的"叠加 → 相位干涉 → 测量"模式是所有量子算法的骨架。


三、Grover 搜索

3.1 问题

在 $N = 2^n$ 个元素的无序集合里找目标(假设恰一个解)。经典线性搜索 $O(N)$;Grover 给出 $O(\sqrt N)$

3.2 直觉(振幅放大)

1. 全叠加: |ψ⟩ = (1/√N) Σ|x⟩   (每个解概率 1/N, 太小)
2. 反复迭代"振幅放大":
   ① 标记目标: 翻转目标态的相位 (它"变负")
   ② 绕均值反转: 把振幅往均值对称翻转 → 目标振幅被放大
3. 迭代 ~√N 次后, 目标态振幅 → ~1 → 测量大概率命中

每次迭代把目标的振幅放大,同时其他态被压缩——这就是Grover 迭代(反射 + 反射)。

3.3 复杂度

  • 查询数:$O(\sqrt N)$(经典 $O(N)$)→ 平方级加速,不是指数。
  • 最优性(Bennett 1997):量子搜索的查询下界就是 $\Omega(\sqrt N)$——Grover 已是最优。

note

Grover 是"平方加速"的代表。它不像 Shor 给指数加速,但通用——任何 $O(N)$ 搜索问题都能用 Grover 加速到 $O(\sqrt N)$。密码学意义:穷举密钥 $2^k$ → $2^{k/2}$(所以 AES 密钥要加倍长,见附录 Y2Q)。


四、Shor 算法(威胁 RSA)

4.1 问题

大整数因式分解。经典最好(数域筛法)亚指数 $O(\exp(c (\log N)^{1/3}))$——RSA 就建立在"难分解"上。Shor 给出多项式时间

4.2 核心:把分解归约到"找周期"

Shor 的两步:

Step 1 (经典): 因式分解问题 → 找"阶"(order/周期) 问题
  - 随机选 a, 求最小的 r 使 a^r ≡ 1 (mod N)   ← 找周期
  - 用 gcd(a^{r/2} ± 1, N) 得到因子

Step 2 (量子): 用量子傅里叶变换 (QFT) 找周期 r
  - 这是唯一"量子有指数优势"的一步

4.3 为什么量子快

**量子傅里叶变换(QFT)**在 $O(\log^2 N)$ 步内找到函数的周期——经典找周期需要指数步。

经典找周期: 试 r=1,2,3,... (指数)
QFT 找周期: 一次变换, 频率峰 → 周期
  • 周期 $r$ 在频域里对应一个峰——QFT 用干涉把"周期频率"放大,一次测量读出。
  • 这正是 Deutsch-Jozsa 的"相位干涉"思路的规模化。

4.4 对 RSA 的威胁

  • RSA 的安全 = "分解 N 难"。Shor 多项式分解 → RSA / DH / ECC 全破(只要 Shor 在足够大的数上能跑)。
  • 这推动 后量子密码(PQC):基于格/哈希/多变量的抗量子方案(NIST 已定 Kyber/Dilithium 等)。
  • 但 Shor 需要大量物理 qubit + 完美纠错(见下一章),短期不现实。

warning

别混:Shor 威胁的是 RSA/ECC/DH(基于分解/离散对数),不威胁 AES 对称加密(AES 受 Grover 影响,密钥加倍即可)。量子安全的核心问题是"公钥密码要换",不是"所有密码都要换"。


五、复杂度对比表

算法问题经典量子加速
Deutsch-Jozsa常量 vs 平衡$2^{n-1}+1$1指数
Grover无序搜索$O(N)$$O(\sqrt N)$平方
Shor大数分解亚指数多项式指数
Shor离散对数亚指数多项式指数
量子模拟量子系统指数多项式指数(实用价值最大的方向)

note

量子模拟(Feynman 1982 的原始动机)可能比 Shor 更早产生实际价值:模拟分子/材料/化学(量子系统天然指数难,经典算不动)——量子计算机用自己模拟自己,是"天然匹配"。


六、算法骨架统一

所有量子算法共享的骨架:

1. 叠加:   H⊗n 造全叠加 (指数并行)
2. 干涉:   U_f + 相位旋转 (放大目标 / 抵消噪声)
3. 变换:   QFT 或其他 (把"想知道的"转到可测的基)
4. 测量:   高概率读出答案

理解了这个骨架,读任何量子算法 paper 都不会迷路。


七、结束 + 速查表

tip

一页快速唤回:

  • 量子并行:$n$ qubit 叠加 $2^n$ 态;但测量只读一个 → 算法要放大概率。
  • 干涉:相位差 → 振幅增强/抵消(所有算法的核心)。
  • Deutsch-Jozsa:1 次查询判常量/平衡(经典 $2^{n-1}+1$),教学意义。
  • Grover:无序搜索 $\sqrt N$(平方加速,最优);密码穷举 $2^k → 2^{k/2}$。
  • Shor:分解 → 找周期 → QFT 一次读周期(指数加速);威胁 RSA/ECC/DH,不威胁 AES。
  • PQC:后量子密码(Kyber/Dilithium)为 Shor 时代的准备。
  • 量子模拟:最可能先实用(分子/材料)。
  • 骨架:叠加 → 干涉 → 变换 → 测量。

下一篇: 3. 量子纠错: 逻辑 qubit / surface code / 现实挑战.

3. 量子纠错: 逻辑 qubit / surface code / 现实挑战

TL;DR

量子比特极其脆弱:环境噪声、测量错误、门错误都会让计算失效。量子计算要规模化,必须纠错。但量子纠错和经典纠错(信息论 §汉明码/RS)根本不同——因为量子态不能复制(no-cloning),只能把 1 个逻辑 qubit 编码到多个物理 qubit + 周期性测量纠错。当前主流是 surface code。这一章讲清"为什么纠错这么难、逻辑 qubit 怎么造、现在到哪一步了"。

读完应能:

  1. 说出经典纠错 vs 量子纠错的根本差异(no-cloning / 测量坍缩)。
  2. 理解逻辑 qubit 概念:多个物理 qubit 编码 1 个逻辑 qubit。
  3. 理解 surface code 的核心思想(网格 + stabilizer + 测量纠错)。
  4. 理解"逻辑错误率 < 物理错误率"的阈值定理意义。
  5. 知道当前现实(logical qubit 时代、Y2Q 估计),不迷信炒作。

一、为什么量子纠错和经典纠错完全不同

1.1 经典纠错复习(信息论 §4)

汉明码:用冗余 bit(7 个物理 bit 编 4 个数据 bit),出错时多数投票纠正。

经典: bit 可以复制 → 存多份 → 多数投票
       1011 → 1011 1011 1011 → 有一位翻 → 多数修回

1.2 量子纠错的三个障碍

障碍说明
No-cloning 定理量子态不能被复制(不可克隆),所以不能"存多份投票"
测量坍缩直接测量会破坏叠加态(一测就坍缩成经典结果)
错误连续错误不是"0→1"离散翻,而是连续的相位/振幅漂移

1.3 量子纠错的突破思路

不复制态, 而是把"逻辑态"分布到多个物理 qubit 的纠缠态里
用"稳定子测量"(stabilizer measurement) 检测错误, 不破坏数据
错误是离散的(X/Z 两类) → 类比经典奇偶校验

关键:纠错靠冗余编码 + 关联测量,不是复制。经典纠错可复制、量子靠纠缠。


二、逻辑 qubit:基础概念

2.1 定义

逻辑 qubit = 由多个物理 qubit 通过纠错码编码出的"可靠"量子比特。

1 个逻辑 qubit (可靠)
  ↕ 编码
N 个物理 qubit (脆弱) + 周期性纠错
  • 物理 qubit 是硬件里的真实比特(超导/离子阱/光子...)。
  • 逻辑 qubit 是"抽象出来的可靠计算单位"——算法跑在逻辑 qubit 上。

2.2 冗余比例

编码物理:逻辑说明
小演示码(Steane/Shor)7:1教学/小规模
Surface code~100-1000:1当前主流、容错阈值好
更高级更多追求更低开销

warning

这就是为什么"Shor 要百万物理 qubit":分解 RSA-2048 需要 ~2-3k 个逻辑 qubit,每个逻辑 qubit 又要 ~几百-上千个物理 qubit → 总物理 qubit 达百万级。


三、Surface code:当前主流

3.1 网格布局

Surface code 把物理 qubit 排成 2D 网格:

  数据 qubit (d): 存逻辑信息 (交叉点)
  测量 qubit (m): 检测错误 (稳定子测量)
  
  d ─ m ─ d ─ m ─ d
  │   │   │   │   │
  m ─ d ─ m ─ d ─ m
  │   │   │   │   │
  d ─ m ─ d ─ m ─ d
  • 数据 qubit 承载逻辑信息(X 和 Z 两类错误)。
  • 测量 qubit 周期性地做 stabilizer 测量(探测相邻数据位是否出错),不破坏数据

3.2 Stabilizer 测量(关键机制)

测量 qubit 与相邻数据 qubit 纠缠 → 测出"奇偶性" (stabilizer 本征值)
错误发生时 → 奇偶性翻转 → 检测到
→ 用"错误综合征"(syndrome) 定位错误位置 → 纠正
  • 类比:像奇偶校验位,但测量不坍缩数据(因为测的是关联,不是数据本身)。
  • 错误综合征 → 解算错误位置(经典解码,如 matching 算法)→ 施加纠正门。

3.3 距离(distance)d

  • Surface code 的距离 d = 网格边长(数据 qubit 每边数量)。
  • 更大的 d → 能纠正更多错误 → 逻辑错误率指数下降(如果物理错误率低于阈值)。
  • 纠错能力:能纠正 $\lfloor (d-1)/2 \rfloor$ 个错误链。

3.4 阈值定理(Threshold Theorem)

如果物理错误率低于某阈值(surface code 约 1%),那么增大码距离 d 就能指数级降低逻辑错误率——理论上可以无限制地可靠计算。

物理错误率 < 阈值 (≈1%)  → 逻辑错误率指数降
物理错误率 > 阈值        → 越纠越错 (扩码无用)

这就是"容错量子计算是可能的"的理论保证——前提是硬件好过阈值。


四、稳定子与纠错过程(更具体)

4.1 两个 stabilizer 类型

  • X-type stabilizer:检测 Z 错误(相位翻转)。
  • Z-type stabilizer:检测 X 错误(比特翻转)。

4.2 纠错循环

1. 周期性测量 stabilizer (每个测量 qubit)
2. 对比测量结果 (寻找"错误综合征"变化)
3. 解码: 用综合征图定位错误链 (经典算法, 如 Union-Find / MWPM)
4. 应用纠正门 (X 或 Z, 看错误类型)
5. 返回步骤 1 (持续, 因为错误连续发生)

note

纠错是持续在线的——不像经典存储"纠一次",量子计算中错误不断产生,所以纠错要周期性进行,且纠错本身要快过错误累积。

4.3 容错门(Fault-tolerant gates)

  • 纠错码允许的"逻辑门"要满足容错性:门操作不把错误放大到不可控。
  • 这极其复杂——Clifford 门容易,非 Clifford 门(T 门)难(需要 magic state distillation)。
  • 实现逻辑门 + 纠错 = 整个"容错量子计算"的最大工程难题。

五、当前现实(2026 视角)

5.1 里程碑

年份进展
2021-2023多个团队演示逻辑 qubit 错误率 < 物理 qubit(small distance)
2024-2025Google (Willow) 等演示 surface code 增大 distance 后逻辑错误率下降(关键验证)
2026+冲击 ~100 逻辑 qubit;错误率仍未低到跑 Shor
远期百万物理 qubit 才能威胁 RSA-2048

5.2 挑战清单

1. 物理错误率要 < 阈值 (≈1%)   — 硬件质量
2. 大规模 2D 网格 + 快速经典解码  — 工程
3. 容错逻辑门 (T 门 / magic state)  — 理论+工程
4. 冷却/稳定 (超导需 mK 温度)     — 物理
5. 与经典 CPU 协同 (混合计算)      — 系统

5.3 Y2Q(Years to Quantum)

  • 估算:10-30 年才可能有实用规模量子计算机。
  • 现在就该做的:后量子密码迁移(公钥换成 PQC),因为"今天截获、明天解密"(harvest-now-decrypt-later)是现实威胁。

warning

别听"明年破解"的炒作,也别无视风险。现在值得做的:把 RSA/ECC 迁移到 PQC(NIST 已标准化 Kyber/Dilithium),尤其是长期保密数据。这是"确定性需求 + 不确定时间表"的经典风险管理。


六、与信息论/密码学的接口

概念经典对应量子
冗余编码汉明/RS(§info-theory)surface code / stabilizer
奇偶校验parity checkstabilizer 测量
纠错能力最小距离 → 纠 t 错码距离 d → 纠 (d-1)/2 错
阈值香农容量阈值定理(1%)
安全威胁RSA/ECCShor / Grover(§algorithms)
防御换长密钥PQC(Kyber/Dilithium)

七、结束 + 速查表

tip

一页快速唤回:

  • 量子纠错 ≠ 经典纠错:no-cloning(不能复制)、测量坍缩、错误连续 → 靠纠缠编码 + stabilizer 测量。
  • 逻辑 qubit = 多个物理 qubit 编码的可靠比特;算法跑在逻辑 qubit 上。
  • Surface code:2D 网格 + 数据/测量 qubit + stabilizer 周期测量 + 经典解码。
  • 距离 d:越大能纠越多错;逻辑错误率指数降(低于阈值时)。
  • 阈值定理:物理错误率 < ~1% → 容错可行。
  • 容错门:Clifford 易、T 门难(magic state)。
  • 现状(2026):~100 逻辑 qubit 时代;Shor 要百万物理 qubit → Y2Q 10-30 年。
  • 现在该做:PQC 迁移(Kyber/Dilithium)——harvest-now-decrypt-later 是现实威胁。

回主目录: 量子计算卷 README.

TODO — 待补充模块

当前导论卷 + 工程化实践轴 + 第零部分 + 13 主题 + 形式化卷 + 量子卷,构建通过,~57.6K 行 / 281 个 .md。

已完成

  • 形式化方法卷(独立卷)
    • 模型检查与 TLA+: 规格语言 / 不变量 / Safety-Liveness / TLC / Raft-Paxos 实战
    • 定理证明: Coq / Lean / 依赖类型 / Curry-Howard / CompCert-seL4
    • 程序验证: Hoare 逻辑 / 最弱前置条件 / 符号执行 / 形式语义
  • 量子计算卷(独立卷)
    • 基础: Dirac 记号 / qubit / 量子门 / 测量 / 纠缠
    • 算法: Deutsch-Jozsa / Grover / Shor / 量子模拟
    • 纠错: 逻辑 qubit / surface code / 阈值定理 / Y2Q 现实
  • 第十二部分 AI/ML 补充(第 9 章)
    • 表示学习与对比学习: SimCLR / CLIP / InfoNCE / 自监督
  • 第十二部分 AI/ML 再补充(共 13 章)
    • 经典机器学习与树模型: 决策树 / 随机森林 / GBDT / XGBoost / SVM / 核技巧
    • LLM 推理与部署: KV cache / PagedAttention / 连续批处理 / 投机解码 / W4A16 量化 / PD 分离
  • 网络补充:DNS(递归/权威分层、TTL 与 CDN 调度、DNSSEC / DoH / HTTPDNS)
  • 数据库补充:倒排索引与全文检索(Lucene segment / BM25 / ES 分布式搜索)
  • 分布式补充:分布式事务(2PC / 3PC / Saga / TCC / Outbox / Spanner)
  • 操作系统补充:虚拟化与容器(KVM / EPT / namespaces+cgroups / gVisor / Kata / Firecracker)
  • 计算机组成补充:存储硬件(NAND SLC-TLC/QLC / FTL / 写入放大 / NVMe / ZNS)
  • DSA 深度重写:贪心(交换论证 + Go/Python 实现)、回溯(四类剪枝 + 去重语义)
  • 工程化实践轴 · 让代码真正跑进生产(独立轴,11 章)
    • Git 与版本控制: 对象模型 / 分支模型 / 合并策略 / 回滚恢复 / 团队协作
    • 测试工程: 测试金字塔 / 单元/集成/契约/E2E / mock / flaky 治理 / 覆盖率
    • CI/CD 与发布工程: 管线 / 不可变产物 / 蓝绿金丝雀 / IaC / DB 迁移
    • 性能工程: profiling / 火焰图 / 缓存与批处理 / 并发 / 性能反模式
    • 应用安全: OWASP Top 10 / 认证授权 / 数据安全 / 供应链 / 安全头
    • 代码质量: 重构 / code review / 复杂度治理 / DDD 落地
    • 可观测性实操: metrics/logs/traces / OpenTelemetry / SLO / 告警
    • GitHub Actions 实战: workflow / expression / 缓存 / 矩阵 / reusable / 自托管
    • 云原生发布与 GitOps: K8s 应用 / Helm / ArgoCD / Flux / 服务网格
    • SRE 工程: 错误预算 / 容量 / 变更 / 事件响应 / 生产就绪
  • 第零部分 · 工程数学与离散数学基础(前置)
    • 离散数学: 逻辑 / 集合 / 关系 / 图 / 组合 / 递推 / 代数结构(群环域)
    • 线性代数: 向量空间 / 矩阵 / 谱 / SVD / 正定 / 张量 / softmax 雅可比
    • 概率统计: 分布家族 / 贝叶斯 / MLE / MAP / EM / 极限定理 / KL
    • 微积分与优化: 链式法则 / 雅可比 / Hessian / 凸优化 / 一阶优化器谱系 / 信息几何
  • 第一部分 · DSA
  • 第二部分 · OS
  • 第三部分 · 计算机网络
  • 第四部分 · 数据库
  • 第五部分 · 编译原理
  • 第六部分 · 分布式系统
  • 第七部分 · 系统设计
  • 第八部分 · 计算机组成原理
  • 第九部分 · 计算理论(Formal Languages / Automata / Complexity)
  • 第十部分 · 密码学与安全
  • 第十一部分 · 信息论与编码
  • 第十二部分 · 人工智能与机器学习(主干 5 章 + README)
    • Foundations: 线性回归 → 逻辑回归 → MLP → 损失函数谱 → 泛化与正则
    • Backprop: 计算图 / 反向模式 AD / 雅可比链式 / 梯度检查
    • Transformer: self-attention / MHA / FFN / LayerNorm / 残差 / Encoder-Decoder / 训练损失
    • Optimizers & Training Dynamics: SGD/Momentum/Adam/AdamW/二阶 + 初始化/LayerNorm/warmup/checkpoint
    • Generative: VAE & ELBO / 扩散模型 / AR 采样 / speculative decoding
  • 第十三部分 · 元抽象(原第十二部分,重号;仍作为全书收束)

第零部分的设计意图

把后续十三部分反复出现的同一组数学抽到一处讲透:

数学块解锁的后续章节
离散 · 图 / 关系 / 偏序 / 代数结构DSA、Compiler (CFG / 支配树 / SSA)、Crypto (群环域 / 有限域)、Distributed (因果序)
线代 · 矩阵 / 谱 / SVD / 张量DB 列存向量化、AI/ML 全部(attention / 反向传播)、PCA、LoRA
概率 · 贝叶斯 / MLE / 极限定理OS 排队论、DB 代价估计、Distributed 选举、信息论熵 / 互信息、贝叶斯网络、VAE / 扩散
微积分与优化 · 链式 / Hessian / 凸OS 控制论、Compiler strength reduction、信息论容量、ML 反向传播与 SGD

每篇都标明"喂给后面哪一章", 不做教材复读机, 只讲工程够用的下限 + 直觉.

第十二部分设计意图

把分散在零部分(数学)、第八部分(计算机组成 GPU/AI 加速器)、第十一部分(信息论 KL/熵)、第十部分(密码学 ZKP 中的多项式承诺)的"积木", 在 ML 这一处集大成:

  • 数学预备 → 第零部分线代 / 概率 / 微积分与优化已就位, 本部分直接引用.
  • 反向传播 / 注意力本质是张量雅可比的链式法则 → 引 §2 §3.
  • VAE / 扩散的 ELBO / 反向 KL → 引概率 §7 与微积分 §6 信息几何.
  • 训练硬件 → 引第八部分 GPU 架构与 AI 加速器.

主干 5 章, 不做教材复读机, 目标: 读完能读 "Attention Is All You Need" / "Denoising Diffusion Probabilistic Models" / Adam 原 paper 不再卡在数学记号处.

未来可继续扩展方向

  • 第十二部分 AI/ML 主干之外的子主题 → 已补 3 章:
    • Tokenizer 与 Embedding 详解(BPE / WordPiece / SentencePiece / 位置编码 RoPE / ALiBi)→ ai-ml/tokenizer.md
    • 强化学习基础与 RLHF(MDP / Bellman / Q-learning / Policy Gradient / PPO / RLHF / DPO / GRPO)→ ai-ml/rl.md
    • 大模型训练工程(DP / PP / TP / ZeRO / FSDP / checkpoint / 量化)→ ai-ml/training-at-scale.md
  • 表示学习与对比学习(SimCLR / CLIP / contrastive loss)ai-ml/contrastive-learning.md
  • 形式化方法(Coq / Lean / TLA+ 模型检查与证明)formal/ 独立卷
  • 程序验证与定理证明formal/program-verification.md
  • 量子计算基础(Deutsch–Jozsa, Shor, 基础量子纠错 surface code)quantum/ 独立卷
  • AI/ML Agent / 多模态 → 已补 2 章:
    • LLM Agent: Tool Use / ReAct / Plan-Execute / Multi-Agent / Memory → ai-ml/agents.md
    • 多模态: 跨注意力 / Flamingo / LLaVA / BLIP-2 / 扩散多模态 → ai-ml/multimodal.md

后续按需增补(无固定待办):工程化的专项实操、形式化的更深工具链、量子的物理实现细节。

各部分章节大纲

第九部分 · 计算理论

  • 自动机:DFA → NFA → ε-NFA → 子集构造 → DFA 最小化
  • 正则语言与泵引理(Pumping Lemma)+ Myhill-Nerode 充要
  • 下推自动机(PDA)与上下文无关文法(CFG)
  • 图灵机(deterministic / non-deterministic / Church-Turing)
  • 不可判定性:停机问题、Rice 定理
  • Complexity classes:P / NP / NPC / co-NP / PSPACE
  • Polynomial-time reduction:3-SAT → Clique → Vertex Cover 等
  • Approximation algorithms、hardness of approximation

第十部分 · 密码学与安全

  • 对称加密:AES(SubBytes / ShiftRows / MixColumns / AddRoundKey)、ChaCha20
  • 操作模式:ECB / CBC / CTR / GCM(AEAD)
  • 非对称加密:RSA(欧拉函数、CRT 加速)、ECC(secp256k1 / Curve25519)
  • 密钥交换:Diffie-Hellman、ECDHE
  • 数字签名:RSA-PSS、ECDSA、Ed25519
  • 哈希:SHA-256、SHA-3(Keccak)、BLAKE3
  • TLS 1.3 握手全流程
  • 证书链:X.509、PKI、Certificate Transparency
  • 零知识证明(ZKP):zk-SNARKs / zk-STARKs / Bulletproofs
  • 侧信道攻击:timing、power analysis、Spectre/Meltdown
  • 安全最佳实践:constant-time、nonce 不重用、密钥轮转

第十一部分 · 信息论与编码

  • Shannon Entropy(离散源熵、联合熵、条件熵、互信息)
  • 信道容量:香农公式 C = B·log₂(1 + SNR)
  • 无损压缩:哈夫曼、LZ77 / LZ78 / zstd、算术编码、ANS
  • 汉明码(可纠 1-bit 错)
  • Reed-Solomon (GF(2⁸))
  • BCH 码 + 循环码 + CRC32C
  • LDPC(5G NR 数据信道)
  • Polar 码(Arikan 2008,5G NR 控制信道)
  • Turbo 码(3G/4G BCJR 迭代)
  • 调制:QPSK / 16-QAM / 64-QAM 星座图

第十二部分 · 人工智能与机器学习

  • Foundations: 线性回归 → 逻辑回归 → MLP → 损失函数谱 → 泛化/正则
  • Backpropagation: 计算图 / 反向模式 AD / 雅可比链式 / 梯度检查
  • Transformer: self-attention / MHA / FFN / LayerNorm / 残差 / Encoder-Decoder / 训练损失
  • Optimizers & Training Dynamics: SGD/Momentum/Adam/AdamW/二阶 + 初始化/LayerNorm/warmup/checkpoint
  • Generative Models: VAE & ELBO / 扩散模型 / AR 采样 / speculative decoding

第十三部分 · 元抽象(从原第九部分挪到末尾,现重号收束前面十二部分)

  • 顺序 vs 链接:CPU 视角下两种物理化
  • 摊还 vs 最坏:工程常数与硬实时的张力
  • 分治 vs 贪心 vs DP:什么是"最优子问题分解"
  • 缓存层级:从 L1 到 HBM 到 RDMA 的同构
  • 编程语言运行时:四种实现语义
  • 并发与一致性:单机到分布式同构
  • 推理链:硬件层如何决定软件设计