# 🙌🏻Hi there!

这是我记笔记的地方。

在平时的开发和折腾中，总会遇到各种各样的问题，踩各种各样的坑，原来是都记录在博客上的，但是发现有人搞这种专门的笔记站，感觉不错，所以我也搞了一个。

思考了一下这个和博客的区别：

* 知识库： 一个记录小知识点和小代码片段的地方，类似于速查手册。
* 博客： 记录大的系统性的文章和项目，还能记录生活和图片之类的。

博客：<https://lanlance.cn>

Github：[github.com/L2ncE](https://github.com/L2ncE)

邮箱：<llance_24@foxmail.com>

小红书：[LanLance](https://raw.githubusercontent.com/L2ncE/images/main/PicGo/imagesb1616c0ba9e91a4d9695dba8b91b4164_720.png)


# Golang

* [Golang 面试非算法代码题](https://juejin.cn/post/7126020110854127653)
* [Go常见面试题【由浅入深】2022版](https://zhuanlan.zhihu.com/p/471490292)

## 1. 数组与切片的关系

1. 切片的底层数据是数组，是对数组的封装，数组是定长的，长度定义好之后，不能再更改。
2. 底层数组是可以被多个切片同时指向的，因此对一个切片的元素进行操作是有可能影响到其他切片的。

## 2. 切片的容量是怎样增长的

在 1.18 之前

> 当原 slice 容量小于 1024 的时候，新 slice 容量变成原来的 2 倍；原 slice 容量超过 1024，新 slice 容量变成原来的 1.25 倍。

在 1.18 之后

> 当原 slice 容量 (oldcap) 小于 256 的时候，新 slice(newcap) 容量为原来的 2 倍；原 slice 容量超过 256，新 slice 容量 newcap = oldcap+(oldcap+3\*256)/4

进行内存对齐之后，新 slice 的容量是要 `大于等于` 按照前半部分生成的 `newcap`。

## 3. append 函数

append 函数执行完后，返回的是一个全新的 slice，并且对传入的 slice 并不影响。

## 4. 向一个 nil 的 slice 添加元素会发生什么？

其实 `nil slice` 或者 `empty slice` 都是可以通过调用 append 函数来获得底层数组的扩容。最终都是调用 `mallocgc` 来向 Go 的内存管理器申请到一块内存，然后再赋给原来的 `nil slice` 或 `empty slice`，然后摇身一变，成为“真正”的 `slice` 了。

## 5. 切片作为函数参数

当 slice 作为函数参数时，就是一个普通的结构体。其实很好理解：若直接传 slice，在调用者看来，实参 slice 并不会被函数中的操作改变；若传的是 slice 的指针，在调用者看来，是会被改变原 slice 的

不管传的是 slice 还是 slice 指针，如果改变了 slice 底层数组的数据，会反应到实参 slice 的底层数据。

## 6. map 的实现原理

> 版本说明（Go 1.24 起）：Go 1.24（2025 年 2 月发布）起，内置 map 的默认实现已切换为基于 Swiss Table 的新实现（源码位于 internal/runtime/maps，官方博客 go.dev/blog/swisstable；可用 GOEXPERIMENT=noswissmap 回退旧实现）。下文描述的是 Go 1.24 之前默认的经典 hmap/bmap 实现，新实现的关键差异见本节末尾。

Go 语言（Go 1.24 之前的默认实现）采用的是哈希查找表，并且使用链表解决哈希冲突。

### 结构

map 的结构体是 hmap，bmap 就是我们常说的“桶”，桶里面会最多装 8 个 key，这些 key 之所以会落入同一个桶，是因为它们经过哈希计算后，哈希结果是“一类”的。在桶内，又会根据 key 计算出来的 hash 值的高 8 位来决定 key 到底落入桶内的哪个位置（一个桶内最多有 8 个位置）。

当 map 的 key 和 value 都不是指针，并且 size 都小于 128 字节的情况下，会把 bmap 标记为不含指针，这样可以避免 gc 时扫描整个 hmap。

### 哈希函数

在程序启动时，会检测 cpu 是否支持 aes，如果支持，则使用 aes hash，否则使用 memhash。

### key 定位

用最后的 B 个 bit 位，例如 01010，值为 10，也就是 10 号桶。这个操作实际上就是取余操作，但是取余开销太大，所以代码实现上用的位操作代替。

再用哈希值的高 8 位，找到此 key 在 bucket 中的位置，这是在寻找已有的 key。最开始桶内还没有 key，新加入的 key 会找到第一个空位，放入。

buckets 编号就是桶编号，当两个不同的 key 落在同一个桶中，也就是发生了哈希冲突。冲突的解决手段是用链表法：在 bucket 中，从前往后找到第一个空位。这样，在查找某个 key 时，先找到对应的桶，再去遍历 bucket 中的 key。

如果在 bucket 中没找到，并且 overflow 不为空，还要继续去 overflow bucket 中寻找，直到找到或是所有的 key 槽位都找遍了，包括所有的 overflow bucket。

### 扩容条件

在向 map 插入新 key 的时候，会进行条件检测，符合下面这 2 个条件，就会触发扩容：

1. 装载因子超过阈值，源码里定义的阈值是 6.5。
2. overflow 的 bucket 数量过多：当 B 小于 15，也就是 bucket 总数 2^B 小于 2^15 时， overflow 的 bucket 数量超过 2^B；当 B >= 15，也就是 bucket 总数 2^B 大于等于 2^15， overflow 的 bucket 数量超过 2^15。

对于条件 1，元素太多，而 bucket 数量太少，很简单：将 B 加 1，bucket 最大数量（2^B）直接变成原来 bucket 数量的 2 倍。于是，就有新老 bucket 了。注意，这时候元素都在老 bucket 里，还没迁移到新的 bucket 来。而且，新 bucket 只是最大数量变为原来最大数量（2^B）的 2 倍（2^B \* 2）。

对于条件 2，其实元素没那么多，但是 overflow bucket 数特别多，说明很多 bucket 都没装满。解决办法就是开辟一个新 bucket 空间，将老 bucket 中的元素移动到新 bucket，使得同一个 bucket 中的 key 排列地更紧密。这样，原来，在 overflow bucket 中的 key 可以移动到 bucket 中来。结果是节省空间，提高 bucket 利用率，map 的查找和插入效率自然就会提升。

### 扩容过程

由于 map 扩容需要将原有的 key/value 重新搬迁到新的内存地址，如果有大量的 key/value 需要搬迁，会非常影响性能。因此 Go map 的扩容采取了一种称为“渐进式”的方式，原有的 key 并不会一次性搬迁完毕，每次最多只会搬迁 2 个 bucket。

> 新实现（Swiss Table，Go 1.24+ 默认）差异要点：
>
> * 基本单元由“桶（bmap，8 个 key + overflow 指针）”变为“组（group）”：每组 8 个槽位（abi.MapGroupSlots = 8），每个槽位带一个控制字节（ctrl byte），控制字节由哈希的低 7 位（h2 = hash & 0x7f）加上 1 位槽位状态组成，用批量位运算（amd64 上为 SIMD 内建函数）比较 h2；
> * 冲突解决由“桶内顺序 + overflow 链表”变为“开放寻址探测（probe）”，不再有 overflow 桶；
> * 删除采用 tombstone（标记删除），不再需要因 overflow 过多而触发同尺寸扩容；
> * 扩容阈值由旧实现的整体装载因子 6.5（及 overflow 数量条件）变为“组平均负载上限 7/8（每组 8 槽最多 7 个占用，maxAvgGroupLoad = 7），并把 tombstone 计入负载”；
> * 扩容采用可扩展哈希（extendible hashing）的目录结构，一次只增长一张表，同样是渐进式扩容，避免一次性大规模搬迁；
> * 哈希函数仍沿用 runtime 的 aes/memhash 体系。

## 7. slice 和 map 分别作为函数参数时有什么区别？

makemap 和 makeslice 的区别，带来一个不同点：当 map 和 slice 作为函数参数时，在函数参数内部对 map 的操作会影响 map 自身；而对 slice 却不会。

主要原因：一个是指针（\*hmap），一个是结构体（slice）。Go 语言中的函数传参都是值传递，在函数内部，参数会被 copy 到本地。\*hmap 指针 copy 完之后，仍然指向同一个 map，因此函数内部对 map 的操作会影响实参。而 slice 被 copy 后，会成为一个新的 slice，对它进行的操作不会影响到实参。

## 8. 如何实现两种 get 操作

Go 语言中读取 map 有两种语法：带 comma 和 不带 comma。当要查询的 key 不在 map 里，带 comma 的用法会返回一个 bool 型变量提示 key 是否在 map 中；而不带 comma 的语句则会返回一个 key 对应 value 类型的零值。如果 value 是 int 型就会返回 0，如果 value 是 string 类型，就会返回空字符串。

## 9. key 为什么是无序的

map 在扩容后，会发生 key 的搬迁，原来落在同一个 bucket 中的 key，搬迁后，有些 key 就要远走高飞了（bucket 序号加上了 2^B）。而遍历的过程，就是按顺序遍历 bucket，同时按顺序遍历 bucket 中的 key。搬迁后，key 的位置发生了重大的变化，有些 key 飞上高枝，有些 key 则原地不动。这样，遍历 map 的结果就不可能按原来的顺序了。

当我们在遍历 map 时，并不是固定地从 0 号 bucket 开始遍历，每次都是从一个随机值序号的 bucket 开始遍历，并且是从这个 bucket 的一个随机序号的 cell 开始遍历。这样，即使你是一个写死的 map，仅仅只是遍历它，也不太可能会返回一个固定序列的 key/value 对了。

## 10. float 类型可以作为 map 的 key 吗

从语法上看，是可以的。Go 语言中只要是可比较的类型都可以作为 key。

除开 slice，map，functions 这几种类型，其他类型都是 OK 的。

当用 float64 作为 key 的时候，先要将其转成 uint64 类型，再插入 key 中。

最后说结论：float 型可以作为 key，但是由于精度的问题，会导致一些诡异的问题，慎用之。

## 11. 可以边遍历边删除吗

map 并不是一个线程安全的数据结构。同时读写一个 map 是未定义的行为，如果被检测到，会直接 panic。

上面说的是发生在多个协程同时读写同一个 map 的情况下。 如果在同一个协程内边遍历边删除，并不会检测到同时读写，理论上是可以这样做的。但是，遍历的结果就可能不会是相同的了，有可能结果遍历结果集中包含了删除的 key，也有可能不包含，这取决于删除 key 的时间：是在遍历到 key 所在的 bucket 时刻前或者后。

一般而言，这可以通过读写锁来解决：sync.RWMutex。

读之前调用 RLock() 函数，读完之后调用 RUnlock() 函数解锁；写之前调用 Lock() 函数，写完之后，调用 Unlock() 解锁。

另外，sync.Map 是线程安全的 map，也可以使用。

## 12. 可以对 map 的元素取地址吗

无法对 map 的 key 或 value 进行取址。

如果通过其他 hack 的方式，例如 unsafe.Pointer 等获取到了 key 或 value 的地址，也不能长期持有，因为一旦发生扩容，key 和 value 的位置就会改变，之前保存的地址也就失效了。

## 13. 如何比较两个 map 相等

### map 深度相等的条件

1. 都为 nil
2. 非空、长度相等，指向同一个 map 实体对象
3. 相应的 key 指向的 value “深度”相等

直接将使用 map1 == map2 是错误的。这种写法只能比较 map 是否为 nil。

因此只能是遍历 map 的每个元素，比较元素是否都是深度相等。

## 14. map 是线程安全的吗

map 不是线程安全的。

在查找、赋值、遍历、删除的过程中都会检测写标志，一旦发现写标志置位（等于 1），则直接 panic。赋值和删除函数在检测完写标志是复位之后，先将写标志位置位，才会进行之后的操作。

## 15. Go 语言与鸭子类型的关系

鸭子类型是一种动态语言的风格，在这种风格中，一个对象有效的语义，不是由继承自特定的类或实现特定的接口，而是由它 " 当前方法和属性的集合 " 决定。Go 作为一种静态语言，通过接口实现了鸭子类型，实际上是 Go 的编译器在其中作了隐式的转换工作。

## 16. 值接收者和指针接收者的区别

如果实现了接收者是值类型的方法，会隐含地也实现了接收者是指针类型的方法。

## 17. iface 和 eface 的区别是什么

iface 和 eface 都是 Go 中描述接口的底层结构体，区别在于 iface 描述的接口包含方法，而 eface 则是不包含任何方法的空接口：interface{}。

## 18. 接口的动态类型和动态值

接口值的零值是指动态类型和动态值都为 nil。当仅且当这两部分的值都为 nil 的情况下，这个接口值就才会被认为 接口值 == nil。

## 19. 编译器自动检测类型是否实现接口

```go
var _ io.Writer = (*myWriter)(nil)
```

编译器会由此检查 \*myWriter 类型是否实现了 io.Writer 接口。

上述赋值语句会发生隐式地类型转换，在转换的过程中，编译器会检测等号右边的类型是否实现了等号左边接口所规定的函数。

## 20. 类型转换和断言的区别

类型转换、类型断言本质都是把一个类型转换成另外一个类型。不同之处在于，类型断言是对接口变量进行的操作。

## 21. channel 底层的数据结构

Go 语言中的 channel 底层数据结构主要是 `hchan`，它实现了一个环形缓冲区，用于支持协程之间的通信。以下是 `hchan` 结构体的详细组成部分和功能：

### `hchan` 结构体

```go
type hchan struct {
    qcount     uint           // 通道中数据个数
    dataqsiz   uint           // 循环数组的长度
    buf        unsafe.Pointer // 指向底层循环数组的指针
    elemsize   uint16         // 元素大小
    closed     uint32         // 通道是否关闭的标志
    timer      *timer         // 与 time 包关联的 channel timer（Go 1.23+，用于计时器可被 GC 回收）
    elemtype   *_type         // 通道中元素的类型
    sendx      uint           // 发送元素的下标
    recvx      uint           // 接收元素的下标
    recvq      waitq          // 等待接收的协程队列
    sendq      waitq          // 等待发送的协程队列
    lock       mutex          // 互斥锁，确保操作的原子性
}
```

> 注：以上为 Go 1.23+ 的字段顺序示意（新增 timer 字段），省略了与运行时实现相关的部分字段；理解核心结构（环形缓冲区 + 等待队列 + 锁）即可。

### 主要字段说明

1. **closed**: 用于标识通道是否已经关闭。
2. **elemtype**: 记录通道中元素的类型，便于类型安全的操作。
3. **buf**: 指向底层的环形缓冲区数组，只有在缓冲通道中存在。
4. **qcount**: 当前通道中存储的元素数量。
5. **dataqsiz**: 环形数组的长度，表示缓冲区的大小。
6. **sendx**: 下一个可以写入数据的位置索引。
7. **recvx**: 下一个可以读取数据的位置索引。
8. **recvq**和**sendq**: 分别是因读取或发送数据而被阻塞的协程的等待队列，使用双向链表实现。
9. **lock**: 互斥锁，用于保证在并发环境下对通道的读写操作是安全的。

### 工作原理

* **发送数据**: 当向通道发送数据时，如果通道是缓冲的，数据会被写入到 `buf` 数组中，`sendx` 会更新到下一个可写入的位置。如果缓冲区已满，发送操作会阻塞并将当前协程放入 `sendq` 等待队列中。
* **接收数据**: 接收数据时，`recvx` 指向当前可读取的位置。如果通道为空，接收操作会阻塞并将协程放入 `recvq` 等待队列中。
* **关闭通道**: 关闭通道后，任何向已关闭的通道发送数据都会引发 panic，而接收操作则会返回通道元素类型的零值。

## 22. 从一个关闭的 channel 仍然能读出数据吗

从一个有缓冲的 channel 里读数据，当 channel 被关闭，依然能读出有效值。只有当返回的 ok 为 false 时，读出的数据才是无效的。

## 23. 操作 channel 的情况总结

| 操作      | nil channel | closed channel | not nil, not closed channel                           |
| ------- | ----------- | -------------- | ----------------------------------------------------- |
| close   | panic       | panic          | 正常关闭                                                  |
| 读 <- ch | 阻塞          | 读到对应类型的零值      | 阻塞或正常读取数据。缓冲型 channel 为空或非缓冲型 channel 没有等待发送者时会阻塞     |
| 写 ch <- | 阻塞          | panic          | 阻塞或正常写入数据。非缓冲型 channel 没有等待接收者或缓冲型 channel buf 满时会被阻塞 |

总结一下，发生 panic 的情况有三种：向一个关闭的 channel 进行写操作；关闭一个 nil 的 channel；重复关闭一个 channel。

读、写一个 nil channel 都会被阻塞。

## 24. 如何优雅地关闭 channel

1. **不要在接收端关闭 channel**：接收端关闭 channel 会导致其他发送端无法判断 channel 的状态，可能会导致 panic。
2. **不要在多个发送端的情况下关闭 channel**：如果有多个发送者，无法确保哪个发送者最后关闭 channel，这样会增加出错的风险。
3. **唯一发送者关闭**：在只有一个发送者的情况下，可以安全地在发送者中关闭 channel。
4. **使用信号 channel**：在多个发送者或接收者的情况下，可以引入一个额外的信号 channel，通知发送者或接收者停止操作。

## 25. channel 发送和接收元素的本质是什么

就是说 channel 的发送和接收操作本质上都是 “值的拷贝”

## 26. channel 在什么情况下会引起资源泄漏

goroutine 操作 channel 后，处于发送或接收阻塞状态，而 channel 处于满或空的状态，一直得不到改变。同时，垃圾回收器也不会回收此类资源，进而导致 goroutine 会一直处于等待队列中，不见天日。

另外，程序运行过程中，对于一个 channel，如果没有任何 goroutine 引用了，gc 会对其进行回收操作，不会引起内存泄漏。

## 27. channel 的应用

* 停止信号
* 任务定时
* 解耦生产方和消费方
* 控制并发数

## 28. context 是什么

它是 goroutine 的上下文，包含 goroutine 的运行状态、环境、现场等信息。

context 主要用来在 goroutine 之间传递上下文信息，包括：取消信号、超时时间、截止时间、k-v 等。

## 29. context 有什么作用

context 用来解决 goroutine 之间退出通知、元数据传递的功能。

* 传递共享的数据（RequestID）
* 取消 goroutine
* 防止 goroutine 泄漏

## 30. context.Value 的查找过程是怎样的

取值的过程，实际上是一个递归查找的过程

它会顺着链路一直往上找，比较当前节点的 key 是否是要找的 key，如果是，则直接返回 value。否则，一直顺着 context 往前，最终找到根节点（一般是 emptyCtx），直接返回一个 nil。所以用 Value 方法的时候要判断结果是否为 nil。

因为查找方向是往上走的，所以，父节点没法获取子节点存储的值，子节点却可以获取父节点的值。

## 31. 什么情况下需要使用反射

1. 不能明确接口调用哪个函数，需要根据传入的参数在运行时决定。
2. 不能明确传入函数的参数类型，需要在运行时处理任意对象。

## 32. 不推荐使用反射的理由

* 与反射相关的代码，经常是难以阅读的。在软件工程中，代码可读性也是一个非常重要的指标。
* Go 语言作为一门静态语言，编码过程中，编译器能提前发现一些类型错误，但是对于反射代码是无能为力的。所以包含反射相关的代码，很可能会运行很久，才会出错，这时候经常是直接 panic，可能会造成严重的后果。
* 反射对性能影响还是比较大的，比正常代码运行速度慢一到两个数量级。所以，对于一个项目中处于运行效率关键位置的代码，尽量避免使用反射特性。

## 33. 反射的应用

IDE 中的代码自动补全功能、对象序列化（encoding/json）、fmt 相关函数的实现、ORM（全称是：Object Relational Mapping，对象关系映射）

## 34. 如何比较两个对象完全相同

DeepEqual 函数的参数是两个 interface，实际上也就是可以输入任意类型，输出 true 或者 false 表示输入的两个变量是否是“深度”相等。

## 35. Go 指针和 unsafe.Pointer 有什么区别

限制一：Go 的指针不能进行数学运算。

限制二：不同类型的指针不能相互转换。

限制三：不同类型的指针不能使用 == 或 != 比较。

限制四：不同类型的指针变量不能相互赋值。

unsafe 包提供了 2 点重要的能力：

* 任何类型的指针和 unsafe.Pointer 可以相互转换。
* uintptr 类型和 unsafe.Pointer 可以相互转换。

## 36. 如何利用 unsafe 获取 slice\&map 的长度

我们可以通过 unsafe.Pointer 和 uintptr 进行转换，得到 slice 的字段值。

```go
var Len = *(*int)(unsafe.Pointer(uintptr(unsafe.Pointer(&s)) + uintptr(8)))

// &s => pointer => uintptr => pointer => *int => int
```

和 slice 不同的是，makemap 函数返回的是 hmap 的指针

我们依然能通过 unsafe.Pointer 和 uintptr 进行转换，得到 hmap 字段的值，只不过，现在 count 变成二级指针了

```go
count := **(**int)(unsafe.Pointer(&mp))

// &mp => pointer => **int => int
```

## 37. 如何实现字符串和 byte 切片的零拷贝转换

需要共享底层 Data 和 Len 就可以实现 zero-copy。

```go
func string2bytes(s string) []byte {
    return *(*[]byte)(unsafe.Pointer(&s))
}
func bytes2string(b []byte) string{
    return *(*string)(unsafe.Pointer(&b))
}
```

原理上是利用指针的强转

## 38. goroutine 和线程的区别

* 内存占用 创建一个 goroutine 的栈内存消耗为 2 KB，实际运行过程中，如果栈空间不够用，会自动进行扩容。创建一个 thread 则需要消耗 1 MB 栈内存，而且还需要一个被称为 “a guard page” 的区域用于和其他 thread 的栈空间进行隔离。
* 创建和销毀 Thread 创建和销毀都会有巨大的消耗，因为要和操作系统打交道，是内核级的，通常解决的办法就是线程池。而 goroutine 因为是由 Go runtime 负责管理的，创建和销毁的消耗非常小，是用户级。
* 切换 当 threads 切换时，需要保存各种寄存器，以便将来恢复，而 goroutines 切换只需保存三个寄存器：Program Counter, Stack Pointer and BP。一般而言，线程切换会消耗 1000-1500 纳秒（引自早年文章，强依赖平台与硬件，现代 x86 上通常更低），一个纳秒平均可以执行 12-18 条指令。所以由于线程切换，执行指令的条数会减少 12000-18000。Goroutine 的切换约为 200 ns，相当于 2400-3600 条指令。Goroutine 切换成本显著低于线程切换的本质是：只切换少量寄存器（PC/SP/BP），不涉及内核态与用户态切换，由 Go 运行时调度器在用户态完成。

## 39. 为什么要 scheduler

Go scheduler 可以说是 Go 运行时的一个最重要的部分了。Runtime 维护所有的 goroutines，并通过 scheduler 来进行调度。Goroutines 和 threads 是独立的，但是 goroutines 要依赖 threads 才能执行。

Go 程序执行的高效和 scheduler 的调度是分不开的。

## 40. scheduler 底层原理

Go Scheduler 的核心在于其 GPM 模型：

* **G (goroutine)**：表示一个 goroutine，包含其状态和执行信息。
* **M (machine)**：表示一个操作系统线程，用于执行 goroutines。
* **P (processor)**：表示一个虚拟处理器，维护一个可运行的 goroutine 队列。

当然还有一个核心的结构体：sched，它总览全局。

Runtime 起始时会启动一些 G：垃圾回收的 G，执行调度的 G，运行用户代码的 G；并且会创建一个 M 用来开始 G 的运行。随着时间的推移，更多的 G 会被创建出来，更多的 M 也会被创建出来。

当然，在 Go 的早期版本，并没有 p 这个结构体，m 必须从一个全局的队列里获取要运行的 g，因此需要获取一个全局的锁，当并发量大的时候，锁就成了瓶颈。后来加上了 p 结构体。每个 p 自己维护一个处于 Runnable 状态的 g 的队列，解决了原来的全局锁问题。

Go 程序启动后，会给每个逻辑核心分配一个 P（Logical Processor）；同时，会给每个 P 分配一个 M（Machine，表示内核线程），这些内核线程仍然由 OS scheduler 来调度。（更准确地说：启动时只创建一个主 M，其余 M 是按需惰性创建的，例如发生阻塞式系统调用、需要更多并行度时才会新建 M。）

| 状态        | 解释                                                                                 |
| --------- | ---------------------------------------------------------------------------------- |
| Waiting   | 等待状态，goroutine 在等待某件事的发生。例如等待网络数据、硬盘；调用操作系统 API；等待内存同步访问条件 ready，如 atomic, mutexes |
| Runnable  | 就绪状态，只要给 M 我就可以运行                                                                  |
| Executing | 运行状态。goroutine 在 M 上执行指令，这是我们想要的                                                   |

## 41. goroutine 的调度时机

| 情形         | 说明                                                                                                                 |
| ---------- | ------------------------------------------------------------------------------------------------------------------ |
| 使用关键字 `go` | go 创建一个新的 goroutine，Go scheduler 会考虑调度                                                                             |
| GC         | 由于进行 GC 的 goroutine 也需要在 M 上运行，因此肯定会发生调度。当然，Go scheduler 还会做很多其他的调度，例如调度不涉及堆访问的 goroutine 来运行。GC 不管栈上的内存，只会回收堆上的内存 |
| 系统调用       | 当 goroutine 进行系统调用时，会阻塞 M，所以它会被调度走，同时一个新的 goroutine 会被调度上来                                                         |
| 内存同步访问     | atomic，mutex，channel 操作等会使 goroutine 阻塞，因此会被调度走。等条件满足后（例如其他 goroutine 解锁了）还会被调度上来继续运行                              |

## 42. 什么是 M:N 模型

Runtime 会在程序启动的时候，创建 M 个线程（CPU 执行调度的单位），之后创建的 N 个 goroutine 都会依附在这 M 个线程上执行。这就是 M:N 模型

在同一时刻，一个线程上只能跑一个 goroutine。当 goroutine 发生阻塞（例如上篇文章提到的向一个 channel 发送数据，被阻塞）时，runtime 会把当前 goroutine 调度走，让其他 goroutine 来执行。目的就是不让一个线程闲着，榨干 CPU 的每一滴油水。

## 43. 什么是工作窃取

1. **触发条件**：当 M 的本地队列和全局队列都没有可执行的 G 时，Work Stealing 机制会被激活。
2. **窃取过程**：
   * M 会随机选择一个或多个 P 进行遍历，尝试从这些 P 的本地队列中窃取 G。
   * 窃取的数量通常为目标 P 本地队列中 G 的数量的一半。例如，如果某个 P 的本地队列中有 4 个 G，M 将尝试窃取 2 个 G。
3. **公平性**：为了避免每次都按照相同的顺序访问 P，Golang 使用伪随机算法来选择要窃取的 P，这样可以保证公平性，减少竞争。
4. **重试机制**：如果在第一次尝试窃取后没有成功，M 会进行最多 4 次的重试，直到成功窃取到 G 或者所有尝试都失败。

## 44. GMP

G 取 goroutine 的首字母，主要保存 goroutine 的一些状态信息以及 CPU 的一些寄存器的值，例如 IP 寄存器，以便在轮到本 goroutine 执行时，CPU 知道要从哪一条指令处开始执行。

M 取 machine 的首字母，它代表一个工作线程，或者说系统线程。G 需要调度到 M 上才能运行，M 是真正工作的人。结构体 m 就是我们常说的 M，它保存了 M 自身使用的栈信息、当前正在 M 上执行的 G 信息、与之绑定的 P 信息。当 M 没有工作可做的时候，在它休眠前，会“自旋”地来找工作：检查全局队列，查看 network poller，试图执行 gc 任务，或者“偷”工作。

P 取 processor 的首字母，为 M 的执行提供“上下文”，保存 M 执行 G 时的一些资源，例如本地可运行 G 队列，memory cache 等。一个 M 只有绑定 P 才能执行 goroutine，当 M 被阻塞时，整个 P 会被传递给其他 M ，或者说整个 P 被接管。

## 45. 什么是 GC，有什么作用？

当程序向操作系统申请的内存不再需要时，垃圾回收主动将其回收并供其他代码进行内存申请时候复用，或者将其归还给操作系统，这种针对内存级别资源的自动回收过程，即为垃圾回收。

## 46. 根对象到底是什么？

根对象在垃圾回收的术语中又叫做根集合，它是垃圾回收器在标记过程时最先检查的对象，包括：

* 全局变量：程序在编译期就能确定的那些存在于程序整个生命周期的变量。
* 执行栈：每个 goroutine 都包含自己的执行栈，这些执行栈上包含栈上的变量及指向分配的堆内存区块的指针。
* 寄存器：寄存器的值可能表示一个指针，参与计算的这些指针可能指向某些赋值器分配的堆内存区块。

## 47. STW 是什么意思？

STW 在垃圾回收过程中为了保证实现的正确性、防止无止境的内存增长等问题而不可避免的需要停止赋值器进一步操作对象图的一段过程。

在这个过程中整个用户代码被停止或者放缓执行， STW 越长，对用户代码造成的影响（例如延迟）就越大

## 48. Go 语言 GC(垃圾回收) 的工作原理

Go 语言采用标记清除算法。并在此基础上使用了三色标记法和写屏障技术，提高了效率。

标记清除收集器是跟踪式垃圾收集器，其执行过程可以分成标记（Mark）和清除（Sweep）两个阶段：

* 标记阶段 — 从根对象出发查找并标记堆中所有存活的对象；
* 清除阶段 — 遍历堆中的全部对象，回收未被标记的垃圾对象并将回收的内存加入空闲链表。

标记清除算法的一大问题是在标记期间，需要暂停程序（Stop the world，STW），标记结束之后，用户程序才可以继续执行。为了能够异步执行，减少 STW 的时间，Go 语言采用了三色标记法。

三色标记算法将程序中的对象分成白色、黑色和灰色三类。

* 白色：不确定对象。
* 灰色：存活对象，子对象待处理。
* 黑色：存活对象。

标记开始时，所有对象加入白色集合（这一步需 STW ）。首先将根对象标记为灰色，加入灰色集合，垃圾搜集器取出一个灰色对象，将其标记为黑色，并将其指向的对象标记为灰色，加入灰色集合。重复这个过程，直到灰色集合为空为止，标记阶段结束。那么白色对象即可需要清理的对象，而黑色对象均为根可达的对象，不能被清理。

三色标记法因为多了一个白色的状态来存放不确定对象，所以后续的标记阶段可以并发地执行。当然并发执行的代价是可能会造成一些遗漏，因为那些早先被标记为黑色的对象可能目前已经是不可达的了。所以三色标记法是一个 false negative（假阴性）的算法。

三色标记法并发执行仍存在一个问题，即在 GC 过程中，对象指针发生了改变。比如下面的例子：

```txt
A (黑) -> B (灰) -> C (白) -> D (白)

正常情况下，D 对象最终会被标记为黑色，不应被回收。但在标记和用户程序并发执行过程中，用户程序删除了 C 对 D 的引用，而 A 获得了 D 的引用。标记继续进行，D 就没有机会被标记为黑色了（A 已经处理过，这一轮不会再被处理）。
```

```txt
A (黑) -> B (灰) -> C (白)
↓
D (白)
```

为了解决这个问题，Go 使用了内存屏障技术。垃圾收集器使用了写屏障（Write Barrier）技术，当对象新增或更新时，会将其着色为灰色。这样即使与用户程序并发执行，对象的引用发生改变时，垃圾收集器也能正确处理了。

一次完整的 GC 分为四个阶段：

1. 标记准备 (Mark Setup，需 STW)，打开写屏障 (Write Barrier)
2. 使用三色标记法标记（Marking, 并发）
3. 标记结束 (Mark Termination，需 STW)，关闭写屏障。
4. 清理 (Sweeping, 并发)

## 49. SingleFlight

一般情况下我们在写一写对外的服务的时候都会有一层 cache 作为缓存，用来减少底层数据库的压力，但是在遇到例如 redis 抖动或者其他情况可能会导致大量的 cache miss 出现。

这时候就可以使用 singleflight 库了，直译过来就是单飞，这个库的主要作用就是将一组相同的请求合并成一个请求，实际上只会去请求一次，然后对所有的请求返回相同的结果。

## 50. Hertz Handler 中两个上下文的原因

核心原因在于请求上下文（RequestContext）的生命周期无法优雅的按需延长， 最终在各种设计权衡下，我们在路由的处理函数签名中增加一个标准的上下文入参，通过分离出生命周期长短各异的两个上下文的方式，从根本上解决各种因为上下文生命周期不一致导致的异常问题

## 52. mutex 有几种模式？

正常模式

所有 goroutine 按照 FIFO 的顺序进行锁获取，被唤醒的 goroutine 和新请求锁的 goroutine 同时进行锁获取，通常新请求锁的 goroutine 更容易获取锁 (持续占有 cpu)，被唤醒的 goroutine 则不容易获取到锁。公平性：否。

饥饿模式

所有尝试获取锁的 goroutine 进行等待排队，新请求锁的 goroutine 不会进行锁获取 (禁用自旋)，而是加入队列尾部等待获取锁。公平性：是。

## 53. string

底层有一个指针指向 \[]byte , 还有一个长度

string 并不能被修改

## 54. sync.map 原理

底层是两个 map，一个 read map，一个 dirty map，一开始读 read map，没有数据则加锁穿透去读 dirty map，并且读 dirty map 会记录一个计数（misses），当 misses 大于等于 dirty 的长度时，read map 用 dirty map 进行覆盖（把 dirty 提升为 read）

## 55. gRPC 有几种通信方式

gRPC 有四种通信方式

* 简单 RPC：客户端发送一个请求给服务端，服务端返回一个响应给客户端，就像一次普通的函数调用。
* 服务端流式 RPC：客户端发送一个请求给服务端，服务端返回一个数据流给客户端，适用于服务端返回的数据量比较大的情况。
* 客户端流式 RPC：客户端发送一个数据流给服务端，服务端返回一个响应给客户端，适用于客户端发送的数据量比较大的情况。
* 双向流式 RPC：双方都可以发送一个数据流到对方，适用于需要在长时间内保持连接并交换大量数据的场景。

## 56. new 和 make 的区别

make 和 new 都可以用来分配内存，但是它们的使用场景不同。make 只能用来分配及初始化类型为 slice、map、chan 的数据；而 new 可以分配任意类型的数据。new 分配返回的是指针，即类型“\*Type”；而 make 返回引用，即 Type。new 分配的空间会被清零；make 分配空间后，会进行初始化。

## 57. 如何终止一个运行中的协程

在 Go 中，可以使用 context 包来取消正在运行的 goroutine。context 包提供了一种在 goroutine 之间传递请求作用域的方法，包括取消信号。

## 58. slice 深度拷贝

在 Golang 中，slice 是一个引用类型，它的底层实现是一个结构体，包含了指向底层数组的指针、长度和容量。当你对一个 slice 进行拷贝时，你只是拷贝了这个结构体，而不是底层数组。因此，如果你修改了一个 slice 的元素，那么原始的 slice 和拷贝的 slice 都会受到影响。

如果你想要深度拷贝一个 slice，可以使用内置的 copy 函数。例如，如果你有两个 slice：sliceA 和 sliceB，并且 sliceA 已经通过 make 分配好了足够的长度，你可以使用以下代码将 sliceB 的元素复制到 sliceA：

```go
copy(sliceA, sliceB)
```

注意：copy 本身并不会创建新的底层数组，它只是把源切片的元素逐个复制到目标切片已有的底层数组中（若 sliceA 为 nil 或长度为 0，则一个元素都不会复制）。正确的深拷贝姿势是：先 make 一个与源切片等长的新切片，再 copy 进去，例如 `sliceB := make([]int, len(sliceA)); copy(sliceB, sliceA)`，这样两者的修改互不影响。

## 59. 协程创建子协程，如果子协程发生 panic，协程会 panic 吗

在 Go 语言中，父协程和子协程之间是相互独立的。如果子协程发生 panic，父协程不会直接 panic。但是，如果子协程的 panic 没有被捕获和处理，它会导致整个程序崩溃。

```go
func main() {
    // 父协程的 defer 不会执行
    defer fmt.Println("父协程的 defer 执行")

    // 创建子协程
    go func() {
        // 子协程的 defer 会执行
        defer fmt.Println("子协程的 defer 执行")
        panic("子协程 panic")
    }()

    // 等待子协程执行完成
    time.Sleep(time.Second)
    fmt.Println("父协程执行完成")
}
```

```sh
子协程的 defer 执行
panic: 子协程 panic

goroutine 5 [running]:
main.main.func1()
    /path/to/file.go:9 +0x39
created by main.main
    /path/to/file.go:8 +0x35
```

可以看到，子协程的 defer 语句执行了，但是父协程的 defer 没有执行。程序因为子协程的 panic 而崩溃。

## 60. defer 的执行顺序

在 Go 语言中，defer 语句会在当前函数返回之前执行 defer 注册的函数。defer 语句的执行顺序是后进先出，也就是说，先被 defer 的语句最后执行。如果有多个 defer 语句，它们会以 LIFO（后进先出）的顺序执行。

## 61. = 和 := 的区别？

\=是赋值变量，:=是定义变量。

## 62. 如何判断一个变量在栈还是在堆

在 Go 语言中，编译器会自动决定把一个变量放在栈还是放在堆，编译器会做逃逸分析 (escape analysis)，当发现变量的作用域没有跑出函数范围，就可以在栈上，反之则必须分配在堆。

如果变量离开作用域后没有被引用，则优先分配到栈上，否则分配到堆上。那么如何判断是否发生了逃逸呢？

`go build -gcflags '-m -m -l' xxx.go`

关于逃逸的可能情况：变量大小不确定，变量类型不确定，变量分配的内存超过用户栈最大值，暴露给了外部指针。

## 63. Tag 的应用场景

在 Go 语言中，标签（tag）是一个结构体字段的元信息，可以在运行时通过反射读取。标签可以是任何字符串，但通常用于存储结构体字段的元数据，例如验证规则、ORM 映射等。语言规格（spec）对标签的长度没有限制（不过编译器内部对 tag 字符串长度有硬性上限：旧版本为 2^16-1≈64KB，新版本已放宽到 2^29，超出会触发内部编译错误，实践中几乎不会遇到）。

除此之外，Go 语言中的 tags 还可以通过 `go build -tags` 实现编译控制，例如：项目中有如下文件代表不同的运行环境，通过 tag 控制不同环境下要编译的文件。（注意：编译期的 build tag 与上文的结构体 tag 是两个完全不同的概念——build tag 是写在源文件头部的 `//go:build` 约束行，配合 `go build -tags` 做条件编译；结构体 tag 是运行时通过反射读取的字段元信息。）

* json 序列化或反序列化时字段的名称
* db: sqlx 模块中对应的数据库字段名
* form: gin 框架中对应的前端的数据字段名
* binding: 搭配 form 使用, 默认如果没查找到结构体中的某个字段则不报错值为空, binding 为 required 代表没找到返回错误给前端

## 64. 怎么样输出一个有序的 map

* 创建一个 slice 来存储 map 中的 key。
* 遍历 map 并将 key 添加到 slice 中。
* 对 slice 进行排序。
* 遍历排序后的 slice 并输出 map 中的值。

## 65. Gin 框架的路由是如何实现的

gin 框架的路由是基于 httprouter 实现的，采用类似字典树一样的数据结构来存储路由与 handle 方法的映射。这也是框架高性能的原因之一。

## 66. Go 内存模型

Go 语言内存模型是基于 happens-before 关系的，它定义了在并发编程中，对共享变量进行读写时，不同的 goroutine 之间会产生什么样的同步和可见性保证。happens-before 关系是指在一个 goroutine 中，按照程序顺序，前面的操作 happens-before 于后续的任意操作；在不同的 goroutine 中，如果两个操作没有 happens-before 关系，那么它们就可以并发执行。

## 67. 锁释放后，等待中的 goroutine 中哪一个会优先获取 Mutex 呢？

唤醒顺序是公平的（FIFO）：当 Mutex 被释放时，等待队列中的第一个 goroutine 会被唤醒。但“获取是公平的”这个说法只说对了一半——在正常模式下，被唤醒的等待者要和新来的 goroutine 竞争锁，新来的通常更容易抢到（见上文 mutex 的两种模式）；只有在饥饿模式下，锁的所有权才严格按 FIFO 传递给等待时间最长的 goroutine。因此：唤醒顺序是 FIFO 的，但实际获取顺序在正常模式下并不严格公平。

## 68. Mutex 底层实现

### 加锁

当一个 goroutine 尝试加锁时，它首先会尝试通过原子操作（CAS）来获取锁。如果成功，则直接返回；如果失败，则进入慢路径处理逻辑。在慢路径中，goroutine 会被放入等待队列，直到锁被释放。在正常模式下，goroutine 会先自旋几次（最多 4 次），尝试获取锁。如果在自旋后仍未成功，则通过信号量进入阻塞状态，等待锁的释放。

### 解锁

解锁过程相对简单。持有锁的 goroutine 在调用 `Unlock` 时，会更新 `state` 并唤醒等待队列中的第一个 goroutine。此时，如果处于饥饿模式，锁的所有权会直接传递给等待队列的头部，而不经过自旋，以提高性能。

### 饥饿模式

Go 的 Mutex 实现中引入了饥饿模式，以解决在高并发情况下某些 goroutine 长时间无法获取锁的问题。在饥饿模式下，锁的所有权会优先给等待队列中的 goroutine，而不是唤醒后再进行竞争。这种设计旨在提高系统的响应性和吞吐量。

## 69. 等待一个 Mutex 的 goroutine 数最大是多少？

Go 语言中 Mutex 的 goroutine 数最大是 536870911，这个数字是由 Mutex 的 state 类型决定的，目前是 int32，由于 3 个字节代表了状态，还有：2^(32 – 3) – 1 等于 536870911。

## 70. 如何设计一个可重入锁

1. **记录拥有锁的 goroutine ID**：通过获取当前 goroutine 的 ID 来判断持有锁的是否是同一个协程（goroutine）。
2. **重入计数**：使用一个计数器来记录当前 goroutine 对锁的重入次数。
3. **加锁和解锁逻辑**：在加锁时检查当前 goroutine 是否已经拥有该锁，如果是，则增加重入计数；如果不是，则执行常规的加锁操作。在解锁时，检查重入计数，如果大于 1，则只减少计数；如果等于 1，则释放锁并重置状态。

## 71. 对 select 和 case 的理解

在 Go 语言中，select 语句类似于 switch 语句，但是 case 语句是针对通信的，即通道上的发送或接收操作。每个 case 必须是一个通道操作，要么是发送要么是接收。select 语句会监听所有指定的通道上的操作，一旦其中一个通道准备好就会执行相应的代码块。如果多个通道都准备好，那么 select 语句会随机选择一个通道执行。

## 72. 如何分析锁竞争的激烈程度

使用 Grafana 等监控关键互斥锁上等待的 goroutine 的数量，是我们分析锁竞争的激烈程度的一个重要指标。

## 73. RWMutex 的使用场景

如果你遇到可以明确区分 reader 和 writer goroutine 的场景，且有大量的并发读、少量的并发写，并且有强烈的性能需求，你就可以考虑使用读写锁 RWMutex 替换 Mutex。

## 74. Go 中 RWMutex 的策略

Go 标准库中的 RWMutex 设计是 Write-preferring 方案。一个正在阻塞的 Lock 调用会排除新的 reader 请求到锁。

写优先的设计意味着，如果已经有一个 writer 在等待请求锁的话，它会阻止新来的请求锁的 reader 获取到锁，所以优先保障 writer。当然，如果有一些 reader 已经请求了锁的话，新请求的 writer 也会等待已经存在的 reader 都释放锁之后才能获取。所以，写优先级设计中的优先权是针对新来的请求而言的。这种设计主要避免了 writer 的饥饿问题。

## 75. RWMutex 的 3 个踩坑点

1. 不可复制
2. 重入导致死锁
3. 释放未加锁的 RWMutex

## 76. 使用 WaitGroup 时的常见错误

1. 计数器设置为负值
2. 不期望的 Add 时机
3. 前一个 Wait 还没结束就重用 WaitGroup

### 如何避免

* 不重用 WaitGroup。新建一个 WaitGroup 不会带来多大的资源开销，重用反而更容易出错。
* 保证所有的 Add 方法调用都在 Wait 之前。
* 不传递负数给 Add 方法，只通过 Done 来给计数值减 1。
* 不做多余的 Done 方法调用，保证 Add 的计数值和 Done 方法调用的数量是一样的。
* 不遗漏 Done 方法的调用，否则会导致 Wait hang 住无法返回。

## 77. noCopy：辅助 vet 检查

noCopy 字段的类型是 noCopy，它只是一个辅助的、用来帮助 vet 检查用的类型:

```go
type noCopy struct{}

// Lock is a no-op used by -copylocks checker from `go vet`.
func (*noCopy) Lock()   {}
func (*noCopy) Unlock() {}
```

如果你想要自己定义的数据结构不被复制使用，或者说，不能通过 vet 工具检查出复制使用的报警，就可以通过嵌入 noCopy 这个数据类型来实现。

## 78. Cond 怎么用

* Signal 方法，允许调用者 Caller 唤醒一个等待此 Cond 的 goroutine。如果此时没有等待的 goroutine，显然无需通知 waiter；如果 Cond 等待队列中有一个或者多个等待的 goroutine，则需要从等待队列中移除第一个 goroutine 并把它唤醒。在其他编程语言中，比如 Java 语言中，Signal 方法也被叫做 notify 方法。调用 Signal 方法时，不强求你一定要持有 c.L 的锁。
* Broadcast 方法，允许调用者 Caller 唤醒所有等待此 Cond 的 goroutine。如果此时没有等待的 goroutine，显然无需通知 waiter；如果 Cond 等待队列中有一个或者多个等待的 goroutine，则清空所有等待的 goroutine，并全部唤醒。在其他编程语言中，比如 Java 语言中，Broadcast 方法也被叫做 notifyAll 方法。同样地，调用 Broadcast 方法时，也不强求你一定持有 c.L 的锁。
* Wait 方法，会把调用者 Caller 放入 Cond 的等待队列中并阻塞，直到被 Signal 或者 Broadcast 的方法从等待队列中移除并唤醒。

Go 标准库提供 Cond 原语的目的是，为等待 / 通知场景下的并发问题提供支持。Cond 通常应用于等待某个条件的一组 goroutine，等条件变为 true 的时候，其中一个 goroutine 或者所有的 goroutine 都会被唤醒执行。

## 79. Cond 的常见错误

* 调用 Wait 的时候没有加锁
  * 如果调用 Wait 之前不加锁的话，就有可能 Unlock 一个未加锁的 Locker。
* 没有检查等待条件是否满足
  * waiter goroutine 被唤醒不等于等待条件被满足，只是有 goroutine 把它唤醒了而已。你也可以理解为，等待者被唤醒，只是得到了一次检查的机会而已。

## 80. Once 的使用场景

Once 常常用来初始化单例资源，或者并发访问只需初始化一次的共享资源，或者在测试的时候初始化一次测试资源。

## 81. 如何实现一个 Once

一个正确的 Once 实现要使用一个互斥锁，这样初始化的时候如果有并发的 goroutine，就会进入 doSlow 方法。互斥锁的机制保证只有一个 goroutine 进行初始化，同时利用双检查的机制（double-checking），再次判断 o.done 是否为 0，如果为 0，则是第一次执行，执行完毕后，就将 o.done 设置为 1，然后释放锁。

即使此时有多个 goroutine 同时进入了 doSlow 方法，因为双检查的机制，后续的 goroutine 会看到 o.done 的值为 1，也不会再次执行 f。

这样既保证了并发的 goroutine 会等待 f 完成，而且还不会多次执行 f。

```go
type Once struct {
    done uint32
    m    Mutex
}

func (o *Once) Do(f func()) {
    if atomic.LoadUint32(&o.done) == 0 {
        o.doSlow(f)
    }
}


func (o *Once) doSlow(f func()) {
    o.m.Lock()
    defer o.m.Unlock()
    // 双检查
    if o.done == 0 {
        defer atomic.StoreUint32(&o.done, 1)
        f()
    }
}
```

注：标准库实现后来把 done 字段换成了 atomic.Uint32（Go 1.21/1.22 期间），当前版本（Go 1.26）进一步改为 atomic.Bool，但“原子读快速路径 + 互斥锁慢路径 + 双检查”的原理与上面经典实现完全一致。

## 82. 使用 Once 可能出现的 2 种错误

1. 死锁

如果 f 中再次调用这个 Once 的 Do 方法的话，就会导致死锁的情况出现。这还不是无限递归的情况，而是的的确确的 Lock 的递归调用导致的死锁。

2. 未初始化

如果 f 方法执行的时候 panic，或者 f 执行初始化资源的时候失败了，这个时候，Once 还是会认为初次执行已经成功了，即使再次调用 Do 方法，也不会再次执行 f。

我们可以自己实现一个类似 Once 的并发原语，既可以返回当前调用 Do 方法是否正确完成，还可以在初始化失败后调用 Do 方法再次尝试初始化，直到初始化成功才不再初始化了。

## 83. 使用 struct 类型做 Map 的 key 有什么坑

如果 struct 的某个字段值修改了，查询 map 时无法获取它 add 进去的值。

如果要使用 struct 作为 key，我们要保证 struct 对象在逻辑上是不可变的，这样才会保证 map 的逻辑没有问题。

## 84. 使用 Map 的常见错误

* 未初始化
* 并发读写

## 85. 使用 sync.Map 的特殊场景

* 只会增长的缓存系统中，一个 key 只写入一次而被读很多次；
* 多个 goroutine 为不相交的键集读、写和重写键值对。

## 86. sync.Map 的实现

* 空间换时间。通过冗余的两个数据结构（只读的 read 字段、可写的 dirty），来减少加锁对性能的影响。对只读字段（read）的操作不需要加锁。
* 优先从 read 字段读取、更新、删除，因为对 read 字段的读取不需要锁。
* 动态调整。miss 次数多了之后，将 dirty 数据提升为 read，避免总是从 dirty 中加锁读取。
* double-checking。加锁之后先还要再检查 read 字段，确定真的不存在才操作 dirty 字段。
* 延迟删除。删除一个键值只是打标记，只有在提升 dirty 字段为 read 字段的时候才清理删除的数据。

## 87. sync.Pool 的实现原理

每次垃圾回收的时候，Pool 会把 victim 中的对象移除，然后把 local 的数据给 victim，这样的话，local 就会被清空，而 victim 就像一个垃圾分拣站，里面的东西可能会被当做垃圾丢弃了，但是里面有用的东西也可能被捡回来重新使用。

victim 中的元素如果被 Get 取走，那么这个元素就很幸运，因为它又“活”过来了。但是，如果这个时候 Get 的并发不是很大，元素没有被 Get 取走，那么就会被移除掉，因为没有别人引用它的话，就会被垃圾回收掉。

## 88. sync.Pool 的坑

* 内存泄漏
  * 在使用 sync.Pool 回收 buffer 的时候，一定要检查回收的对象的大小。如果 buffer 太大，就不要回收了，否则就太浪费了。
* 内存浪费

## 89. Context 的 Done 方法返回什么

如果 Done 没有被 close，Err 方法返回 nil；如果 Done 被 close，Err 方法会返回 Done 被 close 的原因。

## 90. 对一个地址的赋值是原子操作吗？

对于现代的多处理多核的系统来说，由于 cache、指令重排，可见性等问题，我们对原子操作的意义有了更多的追求。

atomic 包提供的方法会提供内存屏障的功能，所以，atomic 不仅仅可以保证赋值的数据完整性，还能保证数据的可见性，一旦一个核更新了该地址的值，其它处理器总是能读取到它的最新值。

## 91. 使用信号量的常见错误

* 请求了资源，但是忘记释放它；
* 释放了从未请求的资源；
* 长时间持有一个资源，即使不需要它；
* 不持有一个资源，却直接使用它。

## 92. 使用信号量的场景

在批量获取资源的场景中，我建议你尝试使用官方扩展的信号量。

## 93. CyclicBarrier 的用处

CyclicBarrier 允许一组 goroutine 彼此等待，到达一个共同的执行点。同时，因为它可以被重复使用，所以叫循环栅栏。具体的机制是，大家都在栅栏前等待，等全部都到齐了，就抬起栅栏放行。

## 94. ErrGroup 的作用

ErrGroup 是 Go 官方提供的一个同步扩展库。我们经常会碰到需要将一个通用的父任务拆成几个小任务并发执行的场景，其实，将一个大的任务拆成几个小任务并发执行，可以有效地提高程序的并发度。

ErrGroup 就是用来应对这种场景的。它和 WaitGroup 有些类似，但是它提供功能更加丰富：

* 和 Context 集成；
* error 向上传播，可以把子任务的错误传递给 Wait 的调用者。

## 95. Gin 为什么使用前缀树作为路由数据结构

1. 高效的路由匹配：前缀树可以实现高效的路由匹配。在 Gin 框架中，路由是通过比较 URL 路径和已注册的路由路径来进行匹配的。使用前缀树可以将 URL 路径分解为一系列的字符节点，并通过前缀匹配快速定位到对应的路由节点，减少了不必要的比较操作，提高了路由匹配的效率。
2. 灵活的路由配置：前缀树允许在每个节点上存储额外的信息，例如 HTTP 方法（GET、POST 等）和处理函数。这样，Gin 框架可以根据前缀树节点上存储的信息，灵活地配置路由规则和处理逻辑。节点的子节点可以代表路径的不同部分，使得可以方便地构建出各种路由规则，支持动态路由和参数匹配。
3. 节省空间：前缀树可以共享相同的前缀，从而节省存储空间。在 Gin 框架中，相同前缀的路由路径可以共享相同的节点，避免了冗余存储。这对于大规模的路由配置来说，可以显著减少内存占用。

## 96. Go 的多路复用模型与数据结构

在 Go 语言中，多路复用模型通常指的是使用 `net` 包中的 `Listen` 和 `Accept` 函数实现的并发网络服务器。它允许服务器同时处理多个客户端连接，而无需为每个连接创建一个新的操作系统线程。（补充：真正的 I/O 多路复用发生在更底层——Go 的 netpoller 基于操作系统的 epoll/kqueue（Windows 为 IOCP）等机制统一监听大量 fd，等待 I/O 的网络 goroutine 被挂起到 netpoller，事件就绪后再被唤醒调度；“一个连接一个 goroutine”只是建立在其之上的编程模型。）

多路复用模型的核心数据结构是 `net.Listener` 和 `net.Conn`。`net.Listener` 用于监听指定的网络地址，接受客户端的连接请求，而 `net.Conn` 代表一个客户端连接。

通常，多路复用模型使用 `goroutine` 和 `channel` 来实现并发处理多个连接。当有新的连接到达时，`Accept` 函数会返回一个新的 `net.Conn` 对象，然后可以将该对象传递给一个新的 `goroutine` 进行处理。这样，每个连接都可以在独立的 `goroutine` 中执行，实现并发处理。

## 97. 如果将 Listener 关闭，那么之前已经 Accept 的连接是否会关闭

在 Go 中，如果你关闭了一个 `net.Listener`，那么之前已经通过该监听器接受的连接不会自动关闭。关闭监听器只会停止接受新的连接请求，已经接受的连接将继续保持打开状态。

如果你希望在关闭监听器时同时关闭所有已经接受的连接，你需要在关闭监听器之前显式地关闭每个已接受的连接。

## 98. 如何判断读文件结束了

1. 使用 `bufio.NewScanner` 创建一个文件扫描器 `scanner`，然后通过循环调用 `scanner.Scan()` 来逐行读取文件内容。当 `scanner.Scan()` 返回 `false` 时，表示已经读取到文件末尾。
2. 使用 `file.Read` 从文件中读取数据，并判断读取的字节数 `n`。当 `n` 为 0 时，表示已经读取到文件末尾。

## 99. 如何在打开一次文件后，读到末尾，不重新打开文件，再读一次

可以使用 `os.Seek` 和 `os.File.Seek` 函数将文件指针移动到文件的开头，以便重新读取文件内容。

```go
// 将文件指针移动到开头
	_, err = file.Seek(0, 0)
```

## 100. new 一个 map 结构会有什么问题

使用 `make` 函数来创建一个空的 `map` 结构是推荐的做法，而不是使用 `new` 关键字。使用 `new` 关键字创建 `map` 结构可能会导致以下问题：

1. `new` 函数返回的是指向该类型零值的指针。而 `map` 类型的零值是 `nil`，不能直接用于存储键值对。如果你使用 `new` 创建一个 `map`，会得到一个 `nil` 指针，当你尝试向该 `map` 中存储键值对时，会触发运行时错误。
2. `new` 函数只会为 `map` 类型分配存储空间，而不会进行初始化。这意味着你无法立即开始向该 `map` 中添加键值对，因为它的内部数据结构还没有被初始化。如果你尝试在一个通过 `new` 创建的 `map` 上执行添加操作，会导致运行时错误。

## 101. 传数组和传切片有什么区别

当数组作为函数参数时，函数操作的是数组的一个副本，不会影响原始数组；当切片作为函数参数时，函数操作的是切片的引用，会影响原始切片。

## 102. 为什么 bmap 里面存储的是八个键值对

> 说明：bmap（桶）是 Go 1.24 之前经典实现的叫法；Go 1.24 起默认的新实现中对应概念是 group（组），每组同样是 8 个槽位（abi.MapGroupSlots = 8），本题的几点理由依然适用。

1. 内存分配效率：固定大小的桶可以在预分配的内存块上操作，避免频繁的内存分配和释放，提高性能。
2. 冲突处理：哈希表中可能存在冲突，即不同的键映射到了同一个桶中。通过使用固定大小的桶，可以在桶内部使用更高效的冲突解决方法，例如链表或开放寻址法。
3. 空间利用：通过固定大小的桶，可以在一定程度上减少内存空间的浪费。如果每个桶的大小过小，会导致内存碎片和额外的内存开销；如果每个桶的大小过大，会导致内存浪费。

## 103. Go 是深拷贝还是浅拷贝

拷贝操作既可以是深拷贝 (deep copy) 也可以是浅拷贝 (shallow copy)

1. 对于基本数据类型 (如整数、浮点数等) ，赋值会直接复制值本身，不存在可共享的底层结构，修改副本不会影响原变量。
2. 对于切片，赋值操作进行的是浅拷贝：只复制 slice 头（指向底层数组的指针、长度、容量），多个切片共享同一个底层数组，因此修改一个切片的元素会影响到其他共享底层数组的切片；而数组是值类型，赋值会复制整个数组（含全部元素），副本之间互不影响。
3. 对于 map 类型，默认进行浅拷贝。这意味着新的 map 会指向与原始 map 相同的底层数据。如果修改新的 map 中的键值对，原始 map 中对应的键值对也会被修改。
4. 对于结构体类型，默认进行浅拷贝。这意味着新的结构体会复制原始结构体的字段值，但是如果结构体中包含引用类型的字段 (如指针、切片、map 等) ，则新的结构体和原始结构体会共享相同的引用。

如果需要进行深拷贝，需要手动逐个复制每个字段的值，并确保引用类型的字段也进行深拷贝。可以使用递归或其他方法来实现深拷贝操作。

## 104. Context 原理

Context 对象的原理是通过 channel 来实现的。Context 接口中的 Done 方法返回一个 channel，当 Context 对象被取消或超时时，这个 channel 会被关闭。函数可以通过监听这个 channel 来判断是否应该取消操作。Context 对象还提供了其他方法，如 Err、Deadline 和 Value，用于获取 Context 对象的状态、截止时间和传递的值。

## 105. Context 数据结构

Context 是一个接口类型，定义如下：

```go
type Context interface {
    Deadline() (deadline time.Time, ok bool)
    Done() <-chan struct{}
    Err() error
    Value(key interface{}) interface{}
}
```

Context 接口提供了以下方法：

* `Deadline()` 方法返回 Context 的截止时间，如果没有设置截止时间，则返回 false。
* `Done()` 方法返回一个通道，当 Context 被取消或超时时关闭。
* `Err()` 方法返回 Context 被取消的原因。如果 Context 尚未被取消，则返回 nil。
* `Value(key interface{})` 方法用于获取与指定键关联的值。

## 106. Goroutine 通信方式

Go 协程的通信可以通过共享内存和通道两种方式实现。

1. 共享内存：通过共享内存进行通信意味着多个协程可以访问和修改相同的内存空间。在 Go 中，可以使用互斥锁 (sync.Mutex) 来保护共享内存的访问，以避免数据竞争和并发问题。
2. 通道：通道是 Go 语言提供的一种用于协程之间通信的机制。通道提供了一种同步和安全的方式来传递数据。通道可以在协程之间传递消息，确保数据的顺序和一致性。

## 107. Go GC 缺陷

1. 停顿时间 (Stop-the-World) ：这是早期 Go 版本的主要痛点；自 Go 1.5 起引入并发标记、Go 1.8 之后 STW 已大幅缩短（通常为亚毫秒级，典型最坏情况 <100µs），“在大型内存堆上进行垃圾回收时停顿时间可能会很长”的说法已过时。当前 GC 的主要代价转为并发标记阶段的 CPU 开销、内存分配压力大时出现的延迟毛刺，以及极端情况下（如超过 GOMEMLIMIT 触发频繁 scavenge）仍可能出现的较长停顿。
2. 内存占用：Go 的垃圾回收器需要维护堆上的对象元信息（如标记位图），这会占用一定的内存空间；Go 1.19 起可通过 GOMEMLIMIT 设置软内存上限，接近上限时 GC 会更激进地回收，但若存活对象本就很多，也可能导致 GC 抖动（thrashing）。
3. 内存碎片：Go 采用非移动式（不压缩）的标记 - 清除算法，理论上可能产生碎片，但运行时使用基于 size class 的多级内存分配器（mcache/mcentral/mheap），实际碎片问题被大幅缓解，一般不会导致内存不足。

## 108. pprof 怎么用来排查内存泄漏

在代码中导入 `net/http/pprof` 包，并在主函数中启动一个 HTTP 服务器，以便 pprof 可以收集运行时的分析数据。然后，使用 `go tool pprof` 命令连接到 pprof HTTP 服务器，并生成内存分析报告。

pprof 提供了一系列的命令用于分析内存分析报告，比如 `top` 命令可以显示分配内存最多的函数，`list` 命令可以显示指定函数的源代码，`web` 命令可以生成内存分配和函数调用的图形展示。通过这些命令，可以定位到可能存在内存泄漏的代码位置。

## 109. 排查 gc 问题思路

1. 查看程序运行过程中的 GC 信息：可以设置 `gctrace` 的变量值为 1，通过环境变量或命令行参数来开启 GC 追踪功能。这样可以输出程序运行过程中的 GC 信息，包括执行次数、耗时等。
2. 使用 pprof 进行性能分析：可以通过引入 `net/http/pprof` 包并启动一个 HTTP 服务器，然后使用 `go tool pprof` 命令连接到服务器来进行性能分析。可以查看堆的使用情况、CPU 耗时、当前运行的 goroutine 等信息，以定位 GC 问题所在。
3. 观察内存分配和逃逸分析：通过分析内存分配和逃逸情况，可以了解对象的生命周期和内存使用情况，进而判断是否存在内存泄漏或频繁的内存回收。可以使用 `go build -gcflags="-m"` 命令来查看编译器的逃逸分析信息，或使用 `go tool pprof -alloc_space` 命令来查看内存分配情况。
4. 分析程序日志和监控数据：通过查看程序日志和监控数据，可以观察程序的运行状态、资源使用情况和错误信息，从而判断是否存在 GC 问题。可以关注 CPU 使用率、内存占用、GC 时间等指标。

## 110. 值类型和引用类型的区别

值类型（Value Types）：

在 Go 语言中，值类型包括：int、float、bool、string、array 和 struct。当我们创建一个值类型的变量时，变量值被存储在栈中，每个变量都有自己的内存地址。

引用类型（Reference Types）：

在 Go 语言中，引用类型包括: slice、map、chan、interface、以及 pointer。当创建一个引用类型的变量时，它的值实际上是存储在堆内存中的一个地址。当我们把引用类型的变量赋值给一个新的变量时，我们实际上复制的是内存地址，也就是说，两个变量引用的是内存中的同一个地址。

（争议点：以上是面试中常见的简化说法。严格地说，变量分配在栈还是堆由编译器的逃逸分析决定，与类型是“值类型”还是“引用类型”没有必然关系——值类型变量逃逸后也会分配在堆上，切片头等所谓“引用类型”若未逃逸也可能在栈上。两派观点：面试答题派认为按值类型/引用类型分类足以回答大多数问题；实现派认为脱离逃逸分析谈栈/堆分配是不准确的。）

## 111. Gin 中间件洋葱模型

1. 当一个请求到达 Gin 的路由器时，它首先经过全局中间件。全局中间件是在路由器创建时添加的，它们会对每个请求都执行。
2. 接下来，请求进入路由组中的中间件。路由组是一组相关路由的集合，可以为它们添加共享的中间件。这些中间件会在请求进入路由组时执行。
3. 然后，请求进入路由组中具体的路由处理函数。这是请求的最终目的地，它会执行特定的业务逻辑。
4. 在路由处理函数执行完毕后，请求会从路由组中逐层返回，依次执行路由组中的中间件。这个过程就像剥离洋葱的外层一样，每一层都会执行相应的中间件。
5. 最后，请求返回到全局中间件，执行全局中间件的剩余部分。这时，请求已经完成了整个处理过程。

Gin 中间件的洋葱模型是一种层层剥离和包裹的执行模型，中间件的执行顺序是先进后出的。它允许开发者在请求的不同阶段添加和执行中间件，以实现各种功能，如身份验证、日志记录、错误处理等。这种模型使得中间件的添加和配置更加灵活和可扩展，提高了代码的可维护性和可读性。

## 112. CSP 模型

与其他主流语言通过共享内存来进行并发控制不同，Go 语言采用了 CSP 模式，通过显式地使用通道 (channel) 来进行并发通信和同步。CSP 模型的核心思想是通过通信来实现内存共享，而不是通过共享内存来进行通信。

## 113. Go 程序启动时发生什么

1. 可执行文件加载
   * 源代码编译成可执行文件
   * 将可执行文件加载到内存
2. 运行时初始化
3. 启动调度系统
4. 执行 main 包
   * 先执行 init 再执行 main
5. 执行用户代码

## 114. Gin 框架路由

Gin 框架的路由实现采用了一种类似于前缀树（Trie）的数据结构，称为路由树（Router Tree）。路由树是一种多叉树，每个节点代表一个路由规则，叶子节点存储处理该路由的处理函数。

当使用 `r.GET`、`r.POST` 等方法注册路由时，Gin 会将路由规则构建成路由树的形式。例如注册路由 `/user/:id`，Gin 会将其解析为包含两个节点的树，根节点为 `/user`，子节点为 `:id`。

### 路由查找

当 Gin 接收到 HTTP 请求时，会根据请求路径在路由树中查找匹配的路由规则。查找过程如下：

1. 根据请求方法找到对应的**methodTree**。
2. 从**methodTree**的**root**节点开始，与请求路径逐个匹配节点。
3. 如果匹配到通配符节点（如 `:id`），会将参数值保存在请求上下文中。
4. 一旦找到完全匹配的叶子节点，即找到了处理该请求的处理函数。

### 中间件处理

Gin 支持中间件机制，中间件被实现为一系列处理函数，串联起来形成处理链。当请求到来时，Gin 会按照注册顺序依次执行中间件函数。每个中间件可以对请求进行处理，并决定是否将请求传递给下一个中间件或直接返回响应。


# Java

## 1. 什么是字节码，采取字节码的好处是什么

JVM 可以理解的代码就叫做字节码（即扩展名为 `.class` 的文件），它不面向任何特定的处理器，只面向虚拟机。

由于字节码并不针对一种特定的机器，因此，Java 程序无须重新编译便可在多种不同操作系统的计算机上运行。

## 2. 为什么说 Java 编译与解释并存

* 编译型：编译型语言会通过编译器将源代码一次性翻译成可被该平台执行的机器码。一般情况下，编译语言的执行速度比较快，开发效率比较低。常见的编译性语言有 C、C++、Go、Rust 等等。
* 解释型：解释型语言会通过解释器一句一句的将代码解释（interpret）为机器代码后再执行。解释型语言开发效率比较快，执行速度比较慢。常见的解释性语言有 Python、JavaScript、PHP 等等。

Java 语言既具有编译型语言的特征，也具有解释型语言的特征。因为 Java 程序要经过先编译，后解释两个步骤，由 Java 编写的程序需要先经过编译步骤，生成字节码（`.class` 文件），这种字节码必须由 Java 解释器来解释执行。

## 3. AOT 有什么优点，为什么不全部使用 AOT

和 JIT 不同的是，这种编译模式会在程序被执行前就将其编译成机器码，属于静态编译。AOT 避免了 JIT 预热等各方面的开销，可以提高 Java 程序的启动速度，避免预热时间长。并且，AOT 还能减少内存占用和增强 Java 程序的安全性（AOT 编译后的代码不容易被反编译和修改）。

JIT 与 AOT，两者各有优点，只能说 AOT 更适合当下的云原生场景，对微服务架构的支持也比较友好。除此之外，AOT 编译无法支持 Java 的一些动态特性，如反射、动态代理、动态加载、JNI（Java Native Interface）等。然而，很多框架和库（如 Spring、CGLIB）都用到了这些特性。（**注：此说法存在争议：一派观点认为 GraalVM Native Image 通过配置文件（reachability metadata）可以有限地支持反射、JNI 等动态特性，并非完全无法支持；另一派观点认为这些特性在 AOT 下不再是开箱即用、无条件的动态能力，配置繁琐且限制多，因此实践中常简称为“不支持”。**）

## 4. Java 和 C++ 的区别

Java 和 C++ 都是面向对象的语言，都支持封装、继承和多态

* Java 不提供指针来直接访问内存，程序内存更加安全
* Java 的类是单继承的，C++ 支持多重继承；虽然 Java 的类不可以多继承，但是接口可以多继承。
* Java 有自动内存管理垃圾回收机制 (GC)，不需要程序员手动释放无用内存。
* C ++ 同时支持方法重载和操作符重载，但是 Java 只支持方法重载（操作符重载增加了复杂性，这与 Java 最初的设计思想不符）。

## 5. 基本类型与包装类型的存储方式

基本数据类型的局部变量存放在 Java 虚拟机栈中的局部变量表中，基本数据类型的成员变量（未被 `static` 修饰 ）存放在 Java 虚拟机的堆中。包装类型属于对象类型，我们知道几乎所有对象实例都存在于堆中。HotSpot 虚拟机引入了 JIT 优化之后，会对对象进行逃逸分析，如果发现某一个对象并没有逃逸到方法外部，那么就可能通过标量替换来实现栈上分配，而避免堆上分配内存

> **基本数据类型存放在栈中是一个常见的误区！** 基本数据类型的存储位置取决于它们的作用域和声明方式。如果它们是局部变量，那么它们会存放在栈中；如果它们是成员变量，那么它们会存放在堆中。

## 6. 包装类型的缓存机制

Java 基本数据类型的包装类型的大部分都用到了缓存机制来提升性能。

`Byte`,`Short`,`Integer`,`Long` 这 4 种包装类默认创建了数值 **\[-128，127]** 的相应类型的缓存数据，`Character` 创建了数值在 **\[0,127]** 范围的缓存数据，`Boolean` 直接返回 `True` or `False`。

如果超出对应范围仍然会去创建新的对象，缓存的范围区间的大小只是在性能和资源之间的权衡。

两种浮点数类型的包装类 `Float`,`Double` 并没有实现缓存机制。

```java
Integer i1 = 33;
Integer i2 = 33;
System.out.println(i1 == i2);// 输出 true

Float i11 = 333f;
Float i22 = 333f;
System.out.println(i11 == i22);// 输出 false

Double i3 = 1.2;
Double i4 = 1.2;
System.out.println(i3 == i4);// 输出 false
```

**所有整型包装类对象之间值的比较，全部使用 equals 方法比较**。

## 7. 自动装箱与拆箱

* **装箱**：将基本类型用它们对应的引用类型包装起来；
* **拆箱**：将包装类型转换为基本数据类型；

```java
Integer i = 10;  //装箱
int n = i;   //拆箱
```

从字节码中，我们发现装箱其实就是调用了 包装类的 `valueOf()` 方法，拆箱其实就是调用了 `xxxValue()` 方法。

因此，

* `Integer i = 10` 等价于 `Integer i = Integer.valueOf(10)`
* `int n = i` 等价于 `int n = i.intValue()`;

**如果频繁拆装箱的话，也会严重影响系统的性能。我们应该尽量避免不必要的拆装箱操作。**

## 8. 如何解决浮点数运算的精度丢失问题

`BigDecimal` 可以实现对浮点数的运算，不会造成精度丢失。通常情况下，大部分需要浮点数精确运算结果的业务场景（比如涉及到钱的场景）都是通过 `BigDecimal` 来做的。

```java
BigDecimal a = new BigDecimal("1.0");
BigDecimal b = new BigDecimal("0.9");
BigDecimal c = new BigDecimal("0.8");

BigDecimal x = a.subtract(b);
BigDecimal y = b.subtract(c);

System.out.println(x); /* 0.1 */
System.out.println(y); /* 0.1 */
System.out.println(Objects.equals(x, y)); /* true */
```

## 9. 超过 long 的数据应该怎么表示

基本数值类型都有一个表达范围，如果超过这个范围就会有数值溢出的风险。

在 Java 中，64 位 long 整型是最大的整数类型。

```java
long l = Long.MAX_VALUE;
System.out.println(l + 1); // -9223372036854775808
System.out.println(l + 1 == Long.MIN_VALUE); // true
```

`BigInteger` 内部使用 `int[]` 数组来存储任意大小的整形数据。

相对于常规整数类型的运算来说，`BigInteger` 运算的效率会相对较低。

## 10. 静态变量有什么用

静态变量也就是被 `static` 关键字修饰的变量。它可以被类的所有实例共享，无论一个类创建了多少个对象，它们都共享同一份静态变量。也就是说，静态变量只会被分配一次内存，即使创建多个对象，这样可以节省内存。

## 11. 静态方法为什么不能调用非静态成员

* 静态方法是属于类的，在类加载的时候就会分配内存，可以通过类名直接访问。而非静态成员属于实例对象，只有在对象实例化之后才存在，需要通过类的实例对象去访问。
* 在类的非静态成员不存在的时候静态方法就已经存在了，此时调用在内存中还不存在的非静态成员，属于非法操作。

## 12. 静态方法和实例方法有什么不同

在外部调用静态方法时，可以使用 `类名.方法名` 的方式，也可以使用 `对象.方法名` 的方式，而实例方法只有后面这种方式。也就是说，**调用静态方法可以无需创建对象** 。一般建议使用 `类名.方法名` 的方式来调用静态方法。

静态方法在访问本类的成员时，只允许访问静态成员（即静态成员变量和静态方法），不允许访问实例成员（即实例成员变量和实例方法），而实例方法不存在这个限制。

## 13. 重载和重写有什么区别

* 重载

编译期，发生在同一个类中（或者父类和子类之间），方法名必须相同，参数类型不同、个数不同、顺序不同，方法返回值和访问修饰符可以不同。同一个类中多个同名方法根据不同的传参来执行不同的逻辑处理。

* 重写

重写发生在运行期，是子类对父类的允许访问的方法的实现过程进行重新编写。

1. 方法名、参数列表必须相同，子类方法返回值类型应比父类方法返回值类型更小或相等，抛出的异常范围小于等于父类，访问修饰符范围大于等于父类。
2. 如果父类方法访问修饰符为 `private/final/static` 则子类就不能重写该方法，但是被 `static` 修饰的方法能够被再次声明。
3. 构造方法无法被重写

## 14. 什么是可变长参数

可变参数只能作为函数的最后一个参数，但其前面可以有也可以没有任何其他参数。

```java
public static void method2(String arg1, String... args) {
   //......
}
```

**遇到方法重载的情况会优先匹配固定参数的方法，因为固定参数的方法匹配度更高。**

另外，Java 的可变参数编译后实际会被转换成一个数组，我们看编译后生成的 `class` 文件就 可以看出来了。

## 15. 对象实体与对象引用有何不同

new 创建对象实例（对象实例在堆内存中），对象引用指向对象实例（对象引用存放在栈内存中）。

* 一个对象引用可以指向 0 个或 1 个对象（一根绳子可以不系气球，也可以系一个气球）；
* 一个对象可以有 n 个引用指向它（可以用 n 条绳子系住一个气球）。
* 对象的相等一般比较的是内存中存放的内容是否相等。
* 引用相等一般比较的是他们指向的内存地址是否相等。

## 16. 构造方法的特点

* 名字与类名相同。
* 没有返回值，但不能用 void 声明构造函数。
* 生成类的对象时自动执行，无需调用。

构造方法不能被 override（重写）,但是可以 overload（重载）,所以你可以看到一个类中有多个构造函数的情况。

## 17. Java 深浅拷贝

* **浅拷贝**：浅拷贝会在堆上创建一个新的对象（区别于引用拷贝的一点），不过，如果原对象内部的属性是引用类型的话，浅拷贝会直接复制内部对象的引用地址，也就是说拷贝对象和原对象共用同一个内部对象。
* **深拷贝**：深拷贝会完全复制整个对象，包括这个对象所包含的内部对象。

**那什么是引用拷贝呢？** 简单来说，引用拷贝就是两个不同的引用指向同一个对象。

## 18. hashcode 有什么用

`hashCode()` 的作用是获取哈希码（`int` 整数），也称为散列码。这个哈希码的作用是确定该对象在哈希表中的索引位置。

当你把对象加入 `HashSet` 时，`HashSet` 会先计算对象的 `hashCode` 值来判断对象加入的位置，同时也会与其他已经加入的对象的 `hashCode` 值作比较，如果没有相符的 `hashCode`，`HashSet` 会假设对象没有重复出现。但是如果发现有相同 `hashCode` 值的对象，这时会调用 `equals()` 方法来检查 `hashCode` 相等的对象是否真的相同。如果两者相同，`HashSet` 就不会让其加入操作成功。如果不同的话，就会重新散列到其他位置。这样我们就大大减少了 `equals` 的次数，相应就大大提高了执行速度。

* 如果两个对象的 `hashCode` 值相等，那这两个对象不一定相等（哈希碰撞）。
* 如果两个对象的 `hashCode` 值相等并且 `equals()` 方法也返回 `true`，我们才认为这两个对象相等。
* 如果两个对象的 `hashCode` 值不相等，我们就可以直接认为这两个对象不相等。

## 19. 为什么重写 equals() 时必须重写 hashCode() 方法

如果重写 `equals()` 时没有重写 `hashCode()` 方法的话就可能会导致 `equals` 方法判断是相等的两个对象，`hashCode` 值却不相等。

* `equals` 方法判断两个对象是相等的，那这两个对象的 `hashCode` 值也要相等。
* 两个对象有相同的 `hashCode` 值，他们也不一定是相等的（哈希碰撞）。

## 14. String StringBuffer StringBuilder 的区别

* 操作少量的数据: 适用 `String`
* 单线程操作字符串缓冲区下操作大量数据: 适用 `StringBuilder`
* 多线程操作字符串缓冲区下操作大量数据: 适用 `StringBuffer`

`String` 中的对象是不可变的，也就可以理解为常量，线程安全。`AbstractStringBuilder` 是 `StringBuilder` 与 `StringBuffer` 的公共父类。`StringBuffer` 对方法加了同步锁或者对调用的方法加了同步锁，所以是线程安全的。`StringBuilder` 并没有对方法进行加同步锁，所以是非线程安全的。

## 15. String 为什么是不可变的

* 保存字符串的数组（JDK 8 及以前为 `char[]`，JDK 9 起改为 `byte[]`，见 JEP 254 Compact Strings）被 `final` 修饰且为私有的，并且 `String` 类没有提供/暴露修改这个字符串的方法。
* `String` 类被 `final` 修饰导致其不能被继承，进而避免了子类破坏 `String` 不可变。

## 16. 字符串常量池的作用

**字符串常量池** 是 JVM 为了提升性能和减少内存消耗针对字符串（String 类）专门开辟的一块区域，主要目的是为了避免字符串的重复创建。

```java
// 在堆中创建字符串对象”ab“
// 将字符串对象”ab“的引用保存在字符串常量池中
String aa = "ab";
// 直接返回字符串常量池中字符串对象”ab“的引用
String bb = "ab";
System.out.println(aa==bb);// true
```

## 17. String s1 = new String("abc") 这行代码创建了几个字符串对象

1. 如果字符串常量池中不存在字符串对象“abc”的引用，那么它会在堆上创建两个字符串对象，其中一个字符串对象的引用会被保存在字符串常量池中。
2. 如果字符串常量池中已存在字符串对象“abc”的引用，则只会在堆中创建 1 个字符串对象“abc”。

## 18. 编译器常量折叠

常量折叠会把常量表达式的值求出来作为常量嵌在最终生成的代码中，这是 Javac 编译器会对源代码做的极少量优化措施之一 (代码优化几乎都在即时编译器中进行)。

对于 `String str3 = "str" + "ing";` 编译器会给你优化成 `String str3 = "string";` 。

并不是所有的常量都会进行折叠，只有编译器在程序编译期就可以确定值的常量才可以：

* 基本数据类型 ( `byte`、`boolean`、`short`、`char`、`int`、`float`、`long`、`double`) 以及字符串常量。
* `final` 修饰的基本数据类型和字符串变量
* 字符串通过 “+”拼接得到的字符串、基本数据类型之间算数运算（加减乘除）、基本数据类型的位运算（<<、>>、>>> ）

## 19. Exception 和 Error 有什么区别

* **`Exception`** : 程序本身可以处理的异常，可以通过 `catch` 来进行捕获。`Exception` 又可以分为 Checked Exception (受检查异常，必须处理) 和 Unchecked Exception (不受检查异常，可以不处理)。
* **`Error`**：`Error` 属于程序无法处理的错误 ，不建议通过 `catch` 捕获 。例如 Java 虚拟机运行错误（`Virtual MachineError`）、虚拟机内存不够错误 (`OutOfMemoryError`)、类定义错误（`NoClassDefFoundError`）等 。这些异常发生时，Java 虚拟机（JVM）一般会选择线程终止。

## 20. finally 中的代码一定会执行吗

不一定，在某些情况下，finally 中的代码不会被执行。

* finally 之前虚拟机被终止运行
* 程序所在的线程死亡。
* 关闭 CPU

## 21. 如何使用 `try-with-resources` 代替 `try-catch-finally`？

* **适用范围（资源的定义）：** 任何实现 `java.lang.AutoCloseable` 或者 `java.io.Closeable` 的对象
* **关闭资源和 finally 块的执行顺序：** 在 `try-with-resources` 语句中，任何 catch 或 finally 块在声明的资源关闭后运行

## 22. 注解的解析方法有哪几种

* **编译期直接扫描**：编译器在编译 Java 代码的时候扫描对应的注解并处理，比如某个方法使用 `@Override` 注解，编译器在编译的时候就会检测当前的方法是否重写了父类对应的方法。
* **运行期通过反射处理**：像框架中自带的注解 (比如 Spring 框架的 `@Value`、`@Component`) 都是通过反射来进行处理的。

## 23. Java 序列化常见协议

* **序列化**：将数据结构或对象转换成二进制字节流的过程
* **反序列化**：将在序列化过程中所生成的二进制字节流转换成数据结构或者对象的过程

**序列化的主要目的是通过网络传输对象或者说是将对象存储到文件系统、数据库、内存中。**

JDK 自带的序列化方式一般不会用 ，因为序列化效率低并且存在安全问题。比较常用的序列化协议有 Hessian、Kryo、Protobuf、ProtoStuff，这些都是基于二进制的序列化协议。

## 24. serialVersionUID 有什么作用

序列化号 `serialVersionUID` 属于版本控制的作用。反序列化时，会检查 `serialVersionUID` 是否和当前类的 `serialVersionUID` 一致。如果 `serialVersionUID` 不一致则会抛出 `InvalidClassException` 异常。

`static` 修饰的变量是静态变量，属于类而非类的实例，本身是不会被序列化的。然而，`serialVersionUID` 是一个特例。当一个对象被序列化时，`serialVersionUID` 会被写入到序列化的二进制流中。也就是说，`serialVersionUID` 只是用来被 JVM 识别，实际并没有被序列化。

## 25. 有些字段不想进行序列化怎么办

对于不想进行序列化的变量，可以使用 `transient` 关键字修饰。

`transient` 关键字的作用是：阻止实例中那些用此关键字修饰的的变量序列化；当对象被反序列化时，被 `transient` 修饰的变量值不会被持久化和恢复。

关于 `transient` 还有几点注意：

* `transient` 只能修饰变量，不能修饰类和方法。
* `transient` 修饰的变量，在反序列化后变量值将会被置成类型的默认值。例如，如果是修饰 `int` 类型，那么反序列后结果就是 `0`。
* `static` 变量因为不属于任何对象 (Object)，所以无论有没有 `transient` 关键字修饰，均不会被序列化。

## 26. 为什么不推荐使用 JDK 自带的序列化

* **不支持跨语言调用** : 如果调用的是其他语言开发的服务的时候就不支持了。
* **性能差**：相比于其他序列化框架性能更低，主要原因是序列化之后的字节数组体积较大，导致传输成本加大。
* **存在安全问题**：序列化和反序列化本身并不存在问题。但当输入的反序列化的数据可被用户控制，那么攻击者即可通过构造恶意输入，让反序列化产生非预期的对象，在此过程中执行构造的任意代码

## 27. 反射的优缺点

**优点**：可以让咱们的代码更加灵活、为各种框架提供开箱即用的功能提供了便利 **缺点**：让我们在运行时有了分析操作类的能力，这同样也增加了安全问题。比如可以无视泛型参数的安全检查（泛型参数的安全检查发生在编译时）。另外，反射的性能也要稍差点，不过，对于框架来说实际是影响不大的。

## 28. 什么是代理模式

我们使用代理对象来代替对真实对象 (real object) 的访问，这样就可以在不修改原目标对象的前提下，提供额外的功能操作，扩展目标对象的功能。

代理模式的主要作用是扩展目标对象的功能，比如说在目标对象的某个方法执行前后你可以增加一些自定义的操作。

## 29. 静态代理

静态代理中，我们对目标对象的每个方法的增强都是手动完成的，非常不灵活（比如接口一旦新增加方法，目标对象和代理对象都要进行修改）。 实际应用场景非常非常少，日常开发几乎看不到使用静态代理的场景。

上面我们是从实现和应用角度来说的静态代理，从 JVM 层面来说， **静态代理在编译时就将接口、实现类、代理类这些都变成了一个个实际的 class 文件。**

静态代理实现步骤:

1. 定义一个接口及其实现类；
2. 创建一个代理类同样实现这个接口
3. 将目标对象注入进代理类，然后在代理类的对应方法调用目标类中的对应方法。这样的话，我们就可以通过代理类屏蔽对目标对象的访问，并且可以在目标方法执行前后做一些自己想做的事情。

## 30. 动态代理

相比于静态代理来说，动态代理更加灵活。我们不需要针对每个目标类都单独创建一个代理类，并且也不需要我们必须实现接口，我们可以直接代理实现类 ( *CGLIB 动态代理机制*)。

就 Java 来说，动态代理的实现方式有很多种，比如 **JDK 动态代理**、**CGLIB 动态代理**等等。

## 31. JDK 动态代理机制

**在 Java 动态代理机制中 `InvocationHandler` 接口和 `Proxy` 类是核心。**

`Proxy` 类中使用频率最高的方法是：`newProxyInstance()` ，这个方法主要用来生成一个代理对象。

```java
public static Object newProxyInstance(ClassLoader loader,
									  Class<?>[] interfaces,
									  InvocationHandler h)
	throws IllegalArgumentException
{
	......
}
// 1. loader :类加载器，用于加载代理对象。
// 2. interfaces : 被代理类实现的一些接口；
// 3. h : 实现了 `InvocationHandler` 接口的对象；
```

要实现动态代理的话，还必须需要实现 `InvocationHandler` 来自定义处理逻辑。 当我们的动态代理对象调用一个方法时，这个方法的调用就会被转发到实现 `InvocationHandler` 接口类的 `invoke` 方法来调用。

也就是说：**你通过 `Proxy` 类的 `newProxyInstance()` 创建的代理对象在调用方法的时候，实际会调用到实现 `InvocationHandler` 接口的类的 `invoke()` 方法。** 你可以在 `invoke()` 方法中自定义处理逻辑，比如在方法执行前后做什么事情。

## 32. CGLIB 动态代理机制

**JDK 动态代理有一个最致命的问题是其只能代理实现了接口的类。为了解决这个问题，我们可以用 CGLIB 动态代理机制来避免。**

CGLIB 通过继承方式实现代理。Spring 中的 AOP 模块中：如果目标对象实现了接口，则默认采用 JDK 动态代理，否则采用 CGLIB 动态代理。

**在 CGLIB 动态代理机制中 `MethodInterceptor` 接口和 `Enhancer` 类是核心。**

你需要自定义 `MethodInterceptor` 并重写 `intercept` 方法，`intercept` 用于拦截增强被代理类的方法。

```java
public interface MethodInterceptor
extends Callback{
    // 拦截被代理类中的方法
    public Object intercept(Object obj, java.lang.reflect.Method method, Object[] args,MethodProxy proxy) throws Throwable;
}
```

1. **obj** : 被代理的对象（需要增强的对象）
2. **method** : 被拦截的方法（需要增强的方法）
3. **args** : 方法入参
4. **proxy** : 用于调用原始方法

你可以通过 `Enhancer` 类来动态获取被代理类，当代理类调用方法的时候，实际调用的是 `MethodInterceptor` 中的 `intercept` 方法。

## 33. JDK 动态代理和 CGLIB 动态代理对比

* **JDK 动态代理只能代理实现了接口的类或者直接代理接口，而 CGLIB 可以代理未实现任何接口的类。** 另外， CGLIB 动态代理是通过生成一个被代理类的子类来拦截被代理类的方法调用，因此不能代理声明为 final 类型的类和方法。
* 就二者的效率来说，大部分情况都是 JDK 动态代理更优秀，随着 JDK 版本的升级，这个优势更加明显。

## 34. 静态代理和动态代理的对比

* **灵活性**：动态代理更加灵活，不需要必须实现接口，可以直接代理实现类，并且可以不需要针对每个目标类都创建一个代理类。另外，静态代理中，接口一旦新增加方法，目标对象和代理对象都要进行修改，这是非常麻烦的！
* **JVM 层面**：静态代理在编译时就将接口、实现类、代理类这些都变成了一个个实际的 class 文件。而动态代理是在运行时动态生成类字节码，并加载到 JVM 中的。

## 35. Unsafe 的使用注意

**为什么 `public static` 方法无法被直接调用呢？**

这是因为在 `getUnsafe` 方法中，会对调用者的 `classLoader` 进行检查，判断当前类是否由 `Bootstrap classLoader` 加载，如果不是的话那么就会抛出一个 `SecurityException` 异常。也就是说，只有启动类加载器加载的类才能够调用 Unsafe 类中的方法，来防止这些方法在不可信的代码中被调用。

**为什么要对 Unsafe 类进行这么谨慎的使用限制呢?**

`Unsafe` 提供的功能过于底层（如直接访问系统内存资源、自主管理内存资源等），安全隐患也比较大，使用不当的话，很容易出现很严重的问题。

## 36. 为什么 Unsafe 分配内存使用堆外内存

* 对垃圾回收停顿的改善。由于堆外内存是直接受操作系统管理而不是 JVM，所以当我们使用堆外内存时，即可保持较小的堆内内存规模。从而在 GC 时减少回收停顿对于应用的影响。
* 提升程序 I/O 操作的性能。通常在 I/O 通信过程中，会存在堆内内存到堆外内存的数据拷贝操作，对于需要频繁进行内存间数据拷贝且生命周期较短的暂存数据，都建议存储到堆外内存。

## 37. 集合框架底层数据结构总结

先来看一下 `Collection` 接口下面的集合。

### List

* `ArrayList`：`Object[]` 数组。
* `Vector`：`Object[]` 数组。
* `LinkedList`：双向链表 (JDK1.6 之前为循环链表，JDK1.7 取消了循环)。

### Set

* `HashSet`(无序，唯一): 基于 `HashMap` 实现的，底层采用 `HashMap` 来保存元素。
* `LinkedHashSet`: `LinkedHashSet` 是 `HashSet` 的子类，并且其内部是通过 `LinkedHashMap` 来实现的。
* `TreeSet`(有序，唯一): 红黑树 (自平衡的排序二叉树)。

### Queue

* `PriorityQueue`: `Object[]` 数组来实现小顶堆。
* `DelayQueue`:`PriorityQueue`。
* `ArrayDeque`: 可扩容动态双向数组。

再来看看 `Map` 接口下面的集合。

### Map

* `HashMap`：JDK1.8 之前 `HashMap` 由数组 + 链表组成的，数组是 `HashMap` 的主体，链表则是主要为了解决哈希冲突而存在的（“拉链法”解决冲突）。JDK1.8 以后在解决哈希冲突时有了较大的变化，当链表长度大于阈值（默认为 8）（将链表转换成红黑树前会判断，如果当前数组的长度小于 64，那么会选择先进行数组扩容，而不是转换为红黑树）时，将链表转化为红黑树，以减少搜索时间。
* `LinkedHashMap`：`LinkedHashMap` 继承自 `HashMap`，所以它的底层仍然是基于拉链式散列结构即由数组和链表或红黑树组成。另外，`LinkedHashMap` 在上面结构的基础上，增加了一条双向链表，使得上面的结构可以保持键值对的插入顺序。同时通过对链表进行相应的操作，实现了访问顺序相关逻辑。
* `Hashtable`：数组 + 链表组成的，数组是 `Hashtable` 的主体，链表则是主要为了解决哈希冲突而存在的。
* `TreeMap`：红黑树（自平衡的排序二叉树）。

## 38. 如何选用集合

我们主要根据集合的特点来选择合适的集合。比如：

* 我们需要根据键值获取到元素值时就选用 `Map` 接口下的集合，需要排序时选择 `TreeMap`，不需要排序时就选择 `HashMap`，需要保证线程安全就选用 `ConcurrentHashMap`。
* 我们只需要存放元素值时，就选择实现 `Collection` 接口的集合，需要保证元素唯一时选择实现 `Set` 接口的集合比如 `TreeSet` 或 `HashSet`，不需要就选择实现 `List` 接口的比如 `ArrayList` 或 `LinkedList`，然后再根据实现这些接口的集合的特点来选用。

## 39. ArrayList 和 Array（数组）的区别

`ArrayList` 内部基于动态数组实现，比 `Array`（静态数组） 使用起来更加灵活：

* `ArrayList` 会根据实际存储的元素动态地扩容或缩容，而 `Array` 被创建之后就不能改变它的长度了。
* `ArrayList` 允许你使用泛型来确保类型安全，`Array` 则不可以。
* `ArrayList` 中只能存储对象。对于基本类型数据，需要使用其对应的包装类（如 Integer、Double 等）。`Array` 可以直接存储基本类型数据，也可以存储对象。
* `ArrayList` 支持插入、删除、遍历等常见操作，并且提供了丰富的 API 操作方法，比如 `add()`、`remove()` 等。`Array` 只是一个固定长度的数组，只能按照下标访问其中的元素，不具备动态添加、删除元素的能力。
* `ArrayList` 创建时不需要指定大小，而 `Array` 创建时必须指定大小。

## 40. ArrayList 可以添加 null 值吗

`ArrayList` 中可以存储任何类型的对象，包括 `null` 值。不过，不建议向 `ArrayList` 中添加 `null` 值， `null` 值无意义，会让代码难以维护比如忘记做判空处理就会导致空指针异常。

## 41. ArrayList 插入和删除元素的时间复杂度

对于插入：

* 头部插入：由于需要将所有元素都依次向后移动一个位置，因此时间复杂度是 O(n)。
* 尾部插入：当 `ArrayList` 的容量未达到极限时，往列表末尾插入元素的时间复杂度是 O(1)，因为它只需要在数组末尾添加一个元素即可；当容量已达到极限并且需要扩容时，则需要执行一次 O(n) 的操作将原数组复制到新的更大的数组中，然后再执行 O(1) 的操作添加元素。
* 指定位置插入：需要将目标位置之后的所有元素都向后移动一个位置，然后再把新元素放入指定位置。这个过程需要移动平均 n/2 个元素，因此时间复杂度为 O(n)。

对于删除：

* 头部删除：由于需要将所有元素依次向前移动一个位置，因此时间复杂度是 O(n)。
* 尾部删除：当删除的元素位于列表末尾时，时间复杂度为 O(1)。
* 指定位置删除：需要将目标元素之后的所有元素向前移动一个位置以填补被删除的空白位置，因此需要移动平均 n/2 个元素，时间复杂度为 O(n)。

## 42. LinkedList 插入和删除元素的时间复杂度

* 头部插入/删除：只需要修改头结点的指针即可完成插入/删除操作，因此时间复杂度为 O(1)。
* 尾部插入/删除：只需要修改尾结点的指针即可完成插入/删除操作，因此时间复杂度为 O(1)。
* 指定位置插入/删除：需要先移动到指定位置，再修改指定节点的指针完成插入/删除，因此需要遍历平均 n/2 个元素，时间复杂度为 O(n)。

## 43. LinkedList 为什么不能实现 RandomAccess 接口

`RandomAccess` 是一个标记接口，用来表明实现该接口的类支持随机访问（即可以通过索引快速访问元素）。由于 `LinkedList` 底层数据结构是链表，内存地址不连续，只能通过指针来定位，不支持随机快速访问，所以不能实现 `RandomAccess` 接口。

## 44. ArrayList 和 LinkedList 区别

* **是否保证线程安全：** `ArrayList` 和 `LinkedList` 都是不同步的，也就是不保证线程安全。
* **底层数据结构：** `ArrayList` 底层使用的是 **`Object` 数组**；`LinkedList` 底层使用的是 **双向链表** 数据结构。
* **插入和删除是否受元素位置的影响：**
  * `ArrayList` 采用数组存储，所以插入和删除元素的时间复杂度受元素位置的影响。
  * `LinkedList` 采用链表存储，所以在头尾插入或者删除元素不受元素位置的影响（`add(E e)`、`addFirst(E e)`、`addLast(E e)`、`removeFirst()`、 `removeLast()`），时间复杂度为 O(1)，如果是要在指定位置 `i` 插入和删除元素的话（`add(int index, E element)`，`remove(Object o)`,`remove(int index)`）， 时间复杂度为 O(n) ，因为需要先移动到指定位置再插入和删除。
* **是否支持快速随机访问：** `LinkedList` 不支持高效的随机元素访问，而 `ArrayList`（实现了 `RandomAccess` 接口） 支持。快速随机访问就是通过元素的序号快速获取元素对象 (对应于 `get(int index)` 方法)。
* **内存空间占用：** `ArrayList` 的空间浪费主要体现在在 list 列表的结尾会预留一定的容量空间，而 LinkedList 的空间花费则体现在它的每一个元素都需要消耗比 ArrayList 更多的空间（因为要存放直接后继和直接前驱以及数据）。

我们在项目中一般是不会使用到 `LinkedList` 的，需要用到 `LinkedList` 的场景几乎都可以使用 `ArrayList` 来代替，并且，性能通常会更好。

## 45. Comparable 和 Comparator 的区别

`Comparable` 接口和 `Comparator` 接口都是 Java 中用于排序的接口，它们在实现类对象之间比较大小、排序等方面发挥了重要作用：

* `Comparable` 接口实际上是出自 `java.lang` 包 它有一个 `compareTo(Object obj)` 方法用来排序
* `Comparator` 接口实际上是出自 `java.util` 包它有一个 `compare(Object obj1, Object obj2)` 方法用来排序

一般我们需要对一个集合使用自定义排序时，我们就要重写 `compareTo()` 方法或 `compare()` 方法，当我们需要对某一个集合实现两种排序方式，比如一个 `song` 对象中的歌名和歌手名分别采用一种排序方法的话，我们可以重写 `compareTo()` 方法和使用自制的 `Comparator` 方法或者以两个 `Comparator` 来实现歌名排序和歌星名排序，第二种代表我们只能使用两个参数版的 `Collections.sort()`。

## 46. 无序性和不可重复性的含义是什么

* 无序性不等于随机性 ，无序性是指存储的数据在底层数组中并非按照数组索引的顺序添加 ，而是根据数据的哈希值决定的。
* 不可重复性是指添加的元素按照 `equals()` 判断时 ，返回 false，需要同时重写 `equals()` 方法和 `hashCode()` 方法。

## 47. 比较 HashSet、LinkedHashSet 和 TreeSet 三者的异同

* `HashSet`、`LinkedHashSet` 和 `TreeSet` 都是 `Set` 接口的实现类，都能保证元素唯一，并且都不是线程安全的。
* `HashSet`、`LinkedHashSet` 和 `TreeSet` 的主要区别在于底层数据结构不同。`HashSet` 的底层数据结构是哈希表（基于 `HashMap` 实现）。`LinkedHashSet` 的底层数据结构是链表和哈希表，元素的插入和取出顺序满足 FIFO。`TreeSet` 底层数据结构是红黑树，元素是有序的，排序的方式有自然排序和定制排序。
* 底层数据结构不同又导致这三者的应用场景不同。`HashSet` 用于不需要保证元素插入和取出顺序的场景，`LinkedHashSet` 用于保证元素的插入和取出顺序满足 FIFO 的场景，`TreeSet` 用于支持对元素自定义排序规则的场景。

## 48. Queue 和 Deque 的区别

`Queue` 是单端队列，只能从一端插入元素，另一端删除元素，实现上一般遵循 **先进先出（FIFO）** 规则。

`Deque` 是双端队列，在队列的两端均可以插入或删除元素。

## 49. ArrayDeque 和 LinkedList 的区别

`ArrayDeque` 和 `LinkedList` 都实现了 `Deque` 接口，两者都具有队列的功能。

* `ArrayDeque` 是基于可变长的数组和双指针来实现，而 `LinkedList` 则通过链表来实现。
* `ArrayDeque` 不支持存储 `NULL` 数据，但 `LinkedList` 支持。
* `ArrayDeque` 插入时可能存在扩容过程, 不过均摊后的插入操作依然为 O(1)。虽然 `LinkedList` 不需要扩容，但是每次插入数据时均需要申请新的堆空间，均摊性能相比更慢。

从性能的角度上，选用 `ArrayDeque` 来实现队列要比 `LinkedList` 更好。此外，`ArrayDeque` 也可以用于实现栈。

## 50. 什么是 BlockingQueue

`BlockingQueue` （阻塞队列）是一个接口，继承自 `Queue`。`BlockingQueue` 阻塞的原因是其支持当队列没有元素时一直阻塞，直到有元素；还支持如果队列已满，一直等到队列可以放入新元素时再放入。

```java
public interface BlockingQueue<E> extends Queue<E> {
  // ...
}
```

`BlockingQueue` 常用于生产者 - 消费者模型中，生产者线程会向队列中添加数据，而消费者线程会从队列中取出数据进行处理。

## 51. ArrayBlockingQueue 和 LinkedBlockingQueue 有什么区别

`ArrayBlockingQueue` 和 `LinkedBlockingQueue` 是 Java 并发包中常用的两种阻塞队列实现，它们都是线程安全的。不过，它们之间也存在下面这些区别:

* 底层实现：`ArrayBlockingQueue` 基于数组实现，而 `LinkedBlockingQueue` 基于链表实现。
* 是否有界：`ArrayBlockingQueue` 是有界队列，必须在创建时指定容量大小。`LinkedBlockingQueue` 创建时可以不指定容量大小，默认是 `Integer.MAX_VALUE`，也就是无界的。但也可以指定队列大小，从而成为有界的。
* 锁是否分离： `ArrayBlockingQueue` 中的锁是没有分离的，即生产和消费用的是同一个锁；`LinkedBlockingQueue` 中的锁是分离的，即生产用的是 `putLock`，消费是 `takeLock`，这样可以防止生产者和消费者线程之间的锁争夺。
* 内存占用：`ArrayBlockingQueue` 需要提前分配数组内存，而 `LinkedBlockingQueue` 则是动态分配链表节点内存。这意味着，`ArrayBlockingQueue` 在创建时就会占用一定的内存空间，且往往申请的内存比实际所用的内存更大，而 `LinkedBlockingQueue` 则是根据元素的增加而逐渐占用内存空间。

## 52. HashMap 和 Hashtable 的区别

* **线程是否安全：** `HashMap` 是非线程安全的，`Hashtable` 是线程安全的,因为 `Hashtable` 内部的方法基本都经过 `synchronized` 修饰。（如果你要保证线程安全的话就使用 `ConcurrentHashMap` 吧！）
* **效率：** 因为线程安全的问题，`HashMap` 要比 `Hashtable` 效率高一点。另外，`Hashtable` 基本被淘汰，不要在代码中使用它
* **对 Null key 和 Null value 的支持：** `HashMap` 可以存储 null 的 key 和 value，但 null 作为键只能有一个，null 作为值可以有多个；Hashtable 不允许有 null 键和 null 值，否则会抛出 `NullPointerException`。
* **初始容量大小和每次扩充容量大小的不同：**
  * 创建时如果不指定容量初始值，`Hashtable` 默认的初始大小为 11，之后每次扩充，容量变为原来的 2n+1。`HashMap` 默认的初始化大小为 16。之后每次扩充，容量变为原来的 2 倍。
  * 创建时如果给定了容量初始值，那么 `Hashtable` 会直接使用你给定的大小，而 `HashMap` 会将其扩充为 2 的幂次方大小（`HashMap` 中的 `tableSizeFor()` 方法保证）。也就是说 `HashMap` 总是使用 2 的幂作为哈希表的大小,后面会介绍到为什么是 2 的幂次方。
* **底层数据结构：** JDK1.8 以后的 `HashMap` 在解决哈希冲突时有了较大的变化，当链表长度大于阈值（默认为 8）时，将链表转化为红黑树（将链表转换成红黑树前会判断，如果当前数组的长度小于 64，那么会选择先进行数组扩容，而不是转换为红黑树），以减少搜索时间。`Hashtable` 没有这样的机制。

## 54. HashMap 和 HashSet 的区别

`HashSet` 底层就是基于 `HashMap` 实现的。

| `HashMap`                       | `HashSet`                                                                            |
| ------------------------------- | ------------------------------------------------------------------------------------ |
| 实现了 `Map` 接口                    | 实现 `Set` 接口                                                                          |
| 存储键值对                           | 仅存储对象                                                                                |
| 调用 `put()` 向 map 中添加元素          | 调用 `add()` 方法向 `Set` 中添加元素                                                           |
| `HashMap` 使用键（Key）计算 `hashcode` | `HashSet` 使用成员对象来计算 `hashcode` 值，对于两个对象来说 `hashcode` 可能相同，所以 `equals()` 方法用来判断对象的相等性 |

## 55. HashMap 和 TreeMap 的区别

`TreeMap` 和 `HashMap` 都继承自 `AbstractMap` ，但是需要注意的是 `TreeMap` 它还实现了 `NavigableMap` 接口和 `SortedMap` 接口。

实现 `SortedMap` 接口让 `TreeMap` 有了对集合中的元素根据键排序的能力。

实现 `NavigableMap` 接口让 `TreeMap` 有了对集合内元素的搜索的能力。`NavigableMap` 接口提供了丰富的方法来探索和操作键值对:

1. **定向搜索**: `ceilingEntry()`, `floorEntry()`, `higherEntry()` 和 `lowerEntry()` 等方法可以用于定位大于、小于、大于等于、小于等于给定键的最接近的键值对。
2. **子集操作**: `subMap()`, `headMap()` 和 `tailMap()` 方法可以高效地创建原集合的子集视图，而无需复制整个集合。
3. **逆序视图**:`descendingMap()` 方法返回一个逆序的 `NavigableMap` 视图，使得可以反向迭代整个 `TreeMap`。
4. **边界操作**: `firstEntry()`, `lastEntry()`, `pollFirstEntry()` 和 `pollLastEntry()` 等方法可以方便地访问和移除元素。

这些方法都是基于红黑树数据结构的属性实现的，红黑树保持平衡状态，从而保证了搜索操作的时间复杂度为 O(log n)，这让 `TreeMap` 成为了处理有序集合搜索问题的强大工具。

## 56. HashSet 如何检查重复

当你把对象加入 `HashSet` 时，`HashSet` 会先计算对象的 `hashcode` 值来判断对象加入的位置，同时也会与其他加入的对象的 `hashcode` 值作比较，如果没有相符的 `hashcode`，`HashSet` 会假设对象没有重复出现。但是如果发现有相同 `hashcode` 值的对象，这时会调用 `equals()` 方法来检查 `hashcode` 相等的对象是否真的相同。如果两者相同，`HashSet` 就不会让加入操作成功。

在 JDK1.8 中，实际上无论 `HashSet` 中是否已经存在了某元素，`HashSet` 都会直接插入，只是会在 `add()` 方法的返回值处告诉我们插入前是否存在相同元素。

## 57. HashMap 底层实现

### JDK1.8 之前

JDK1.8 之前 `HashMap` 底层是 **数组和链表** 结合在一起使用也就是 **链表散列**。HashMap 通过 key 的 `hashcode` 经过扰动函数处理过后得到 hash 值，然后通过 `(n - 1) & hash` 判断当前元素存放的位置（这里的 n 指的是数组的长度），如果当前位置存在元素的话，就判断该元素与要存入的元素的 hash 值以及 key 是否相同，如果相同的话，直接覆盖，不相同就通过拉链法解决冲突。

所谓扰动函数指的就是 HashMap 的 `hash` 方法。使用 `hash` 方法也就是扰动函数是为了防止一些实现比较差的 `hashCode()` 方法 换句话说使用扰动函数之后可以减少碰撞。

* 先获取 key 的 hashCode() 值,赋给变量 h。
* 然后将 h 的高 16 位与低 16 位进行异或运算。
* 最终返回异或运算的结果。

### JDK1.8 之后

当链表长度大于阈值（默认为 8）（将链表转换成红黑树前会判断，如果当前数组的长度小于 64，那么会选择先进行数组扩容，而不是转换为红黑树）时，将链表转化为红黑树，以减少搜索时间。

## 58. HashMap 的长度为什么是 2 的幂次方

一个 40 亿长度的数组，内存是放不下的。所以这个散列值是不能直接拿来用的。用之前还要先做对数组的长度取模运算，得到的余数才能用来要存放的位置也就是对应的数组下标。这个数组下标的计算方法是“ `(n - 1) & hash`”。（n 代表数组长度）。

我们首先可能会想到采用% 取余的操作来实现。但是，重点来了：“取余 (%) 操作中如果除数是 2 的幂次则等价于与其除数减一的与 (&) 操作（也就是说 hash%length == hash&(length-1) 的前提是 length 是 2 的 n 次方）。” 并且 采用二进制位操作 &，相对于 % 能够提高运算效率，这就解释了 HashMap 的长度为什么是 2 的幂次方。

## 59. HashMap 多线程操作导致死循环问题

JDK1.7 及之前版本的 `HashMap` 在多线程环境下扩容操作可能存在死循环问题，这是由于当一个桶位中有多个元素需要进行扩容时，多个线程同时对链表进行操作，头插法可能会导致链表中的节点指向错误的位置，从而形成一个环形链表，进而使得查询元素的操作陷入死循环无法结束。

为了解决这个问题，JDK1.8 版本的 HashMap 采用了尾插法而不是头插法来避免链表倒置，使得插入的节点永远都是放在链表的末尾，避免了链表中的环形结构。但是还是不建议在多线程下使用 `HashMap`，因为多线程下使用 `HashMap` 还是会存在数据覆盖的问题。并发环境下，推荐使用 `ConcurrentHashMap` 。

## 60. HashMap 为什么线程不安全

JDK1.7 及之前版本，在多线程环境下，`HashMap` 扩容时会造成死循环和数据丢失的问题。

数据丢失这个在 JDK1.7 和 JDK 1.8 中都存在，这里以 JDK 1.8 为例进行介绍。

JDK 1.8 后，在 `HashMap` 中，多个键值对可能会被分配到同一个桶（bucket），并以链表或红黑树的形式存储。多个线程对 `HashMap` 的 `put` 操作会导致线程不安全，具体来说会有数据覆盖的风险。

* 两个线程 1,2 同时进行 put 操作，并且发生了哈希冲突（hash 函数计算出的插入下标是相同的）。
* 不同的线程可能在不同的时间片获得 CPU 执行的机会，当前线程 1 执行完哈希冲突判断后，由于时间片耗尽挂起。线程 2 先完成了插入操作。
* 随后，线程 1 获得时间片，由于之前已经进行过 hash 碰撞的判断，所有此时会直接进行插入，这就导致线程 2 插入的数据被线程 1 覆盖了。

还有一种情况是这两个线程同时 `put` 操作导致 `size` 的值不正确，进而导致数据覆盖的问题

* 线程 1 执行 `if(++size > threshold)` 判断时，假设获得 `size` 的值为 10，由于时间片耗尽挂起。
* 线程 2 也执行 `if(++size > threshold)` 判断，获得 `size` 的值也为 10，并将元素插入到该桶位中，并将 `size` 的值更新为 11。
* 随后，线程 1 获得时间片，它也将元素放入桶位中，并将 size 的值更新为 11。
* 线程 1、2 都执行了一次 `put` 操作，但是 `size` 的值只增加了 1，也就导致实际上只有一个元素被添加到了 `HashMap` 中。

## 61. ConcurrentHashMap 和 HashTable 的区别

* **底层数据结构：** JDK1.7 的 `ConcurrentHashMap` 底层采用 **分段的数组 + 链表** 实现，JDK1.8 采用的数据结构跟 `HashMap1.8` 的结构一样，数组 + 链表/红黑二叉树。`Hashtable` 和 JDK1.8 之前的 `HashMap` 的底层数据结构类似都是采用 **数组 + 链表** 的形式，数组是 HashMap 的主体，链表则是主要为了解决哈希冲突而存在的；
* **实现线程安全的方式（重要）：**
  * 在 JDK1.7 的时候，`ConcurrentHashMap` 对整个桶数组进行了分割分段 (`Segment`，分段锁)，每一把锁只锁容器其中一部分数据，多线程访问容器里不同数据段的数据，就不会存在锁竞争，提高并发访问率。
  * 到了 JDK1.8 的时候，`ConcurrentHashMap` 已经摒弃了 `Segment` 的概念，而是直接用 `Node` 数组 + 链表 + 红黑树的数据结构来实现，并发控制使用 `synchronized` 和 CAS 来操作。（JDK1.6 以后 `synchronized` 锁做了很多优化） 整个看起来就像是优化过且线程安全的 `HashMap`，虽然在 JDK1.8 中还能看到 `Segment` 的数据结构，但是已经简化了属性，只是为了兼容旧版本；
  * **`Hashtable`(同一把锁)** : 使用 `synchronized` 来保证线程安全，效率非常低下。当一个线程访问同步方法时，其他线程也访问同步方法，可能会进入阻塞或轮询状态，如使用 put 添加元素，另一个线程不能使用 put 添加元素，也不能使用 get，竞争会越来越激烈效率越低。

## 62. ConcurrentHashMap 线程安全的具体实现方式

### JDK1.8 之前

首先将数据分为一段一段的存储，然后给每一段数据配一把锁，当一个线程占用锁访问其中一个段数据时，其他段的数据也能被其他线程访问。

**`ConcurrentHashMap` 是由 `Segment` 数组结构和 `HashEntry` 数组结构组成**。

`Segment` 继承了 `ReentrantLock`,所以 `Segment` 是一种可重入锁，扮演锁的角色。`HashEntry` 用于存储键值对数据。

一个 `ConcurrentHashMap` 里包含一个 `Segment` 数组，`Segment` 的个数一旦**初始化就不能改变**。 `Segment` 数组的大小默认是 16，也就是说默认可以同时支持 16 个线程并发写。

`Segment` 的结构和 `HashMap` 类似，是一种数组和链表结构，一个 `Segment` 包含一个 `HashEntry` 数组，每个 `HashEntry` 是一个链表结构的元素，每个 `Segment` 守护着一个 `HashEntry` 数组里的元素，当对 `HashEntry` 数组的数据进行修改时，必须首先获得对应的 `Segment` 的锁。也就是说，对同一 `Segment` 的并发写入会被阻塞，不同 `Segment` 的写入是可以并发执行的。

### JDK1.8 之后

`ConcurrentHashMap` 取消了 `Segment` 分段锁，采用 `Node + CAS + synchronized` 来保证并发安全。数据结构跟 `HashMap` 1.8 的结构类似，数组 + 链表/红黑二叉树。Java 8 在链表长度超过一定阈值（8）时将链表（寻址时间复杂度为 O(N)）转换为红黑树（寻址时间复杂度为 O(log(N))）。

Java 8 中，锁粒度更细，`synchronized` 只锁定当前链表或红黑二叉树的首节点，这样只要 hash 不冲突，就不会产生并发，就不会影响其他 Node 的读写，效率大幅提升。

## 63. ConcurrentHashMap 为什么 key 和 value 不能为 null

`ConcurrentHashMap` 的 key 和 value 不能为 null 主要是为了避免二义性。null 是一个特殊的值，表示没有对象或没有引用。如果你用 null 作为键，那么你就无法区分这个键是否存在于 `ConcurrentHashMap` 中，还是根本没有这个键。同样，如果你用 null 作为值，那么你就无法区分这个值是否是真正存储在 `ConcurrentHashMap` 中的，还是因为找不到对应的键而返回的。

拿 get 方法取值来说，返回的结果为 null 存在两种情况：

* 值没有在集合中。
* 值本身就是 null。

> 单线程下可以容忍歧义，而多线程下无法容忍。

与此形成对比的是，`HashMap` 可以存储 null 的 key 和 value，但 null 作为键只能有一个，null 作为值可以有多个。如果传入 null 作为参数，就会返回 hash 值为 0 的位置的值。单线程环境下，不存在一个线程操作该 HashMap 时，其他的线程将该 `HashMap` 修改的情况，所以可以通过 `contains(key)` 来做判断是否存在这个键值对，从而做相应的处理，也就不存在二义性问题。

多线程下无法正确判定键值对是否存在（存在其他线程修改的情况），单线程是可以的（不存在其他线程修改的情况）。

## 64. ConcurrentHashMap 能保证复合操作的原子性吗

复合操作是指由多个基本操作 (如 `put`、`get`、`remove`、`containsKey` 等) 组成的操作，例如先判断某个键是否存在 `containsKey(key)`，然后根据结果进行插入或更新 `put(key, value)`。这种操作在执行过程中可能会被其他线程打断，导致结果不符合预期。

**那如何保证 `ConcurrentHashMap` 复合操作的原子性呢？**

`ConcurrentHashMap` 提供了一些原子性的复合操作，如 `putIfAbsent`、`compute`、`computeIfAbsent` 、`computeIfPresent`、`merge` 等。这些方法都可以接受一个函数作为参数，根据给定的 key 和 value 来计算一个新的 value，并且将其更新到 map 中。

## 65. Java 线程和操作系统的线程有什么区别

* JDK 1.2 之前，Java 线程是基于绿色线程（Green Threads）实现的，这是一种用户级线程（用户线程），也就是说 JVM 自己模拟了多线程的运行，而不依赖于操作系统。绿色线程在使用时有一些限制（比如绿色线程不能直接使用操作系统提供的功能如异步 I/O、只能在一个内核线程上运行无法利用多核）
* 在 JDK 1.2 及以后，Java 线程改为基于原生线程（Native Threads）实现，也就是说 JVM 直接使用操作系统原生的内核级线程（内核线程）来实现 Java 线程，由操作系统内核进行线程的调度和管理。

用户线程创建和切换成本低，但不可以利用多核。内核态线程，创建和切换成本高，可以利用多核。

一句话概括 Java 线程和操作系统线程的关系：**现在的 Java 线程的本质其实就是操作系统的线程**。

## 66. 如何创建线程

* **继承 Thread 类**：简单易用，但不支持多继承，且不便于共享资源。
* **实现 Runnable 接口**：支持多继承，适合多个线程共享同一资源。
* **使用 Callable 和 Future**：可以返回结果，适合需要处理结果的场景。

不过，这些方式其实并没有真正创建出线程。准确点来说，这些都属于是在 Java 代码中使用多线程的方法。

严格来说，Java 就只有一种方式可以创建线程，那就是通过 `new Thread().start()` 创建。不管是哪种方式，最终还是依赖于 `new Thread().start()`。

## 67. 线程的生命周期与状态

* NEW: 初始状态，线程被创建出来但没有被调用 `start()` 。
* RUNNABLE: 运行状态，线程被调用了 `start()` 等待运行的状态。
* BLOCKED：阻塞状态，需要等待锁释放。
* WAITING：等待状态，表示该线程需要等待其他线程做出一些特定动作（通知或中断）。
* TIMED\_WAITING：超时等待状态，可以在指定的时间后自行返回而不是像 WAITING 那样一直等待。
* TERMINATED：终止状态，表示该线程已经运行完毕。

线程在生命周期中并不是固定处于某一个状态而是随着代码的执行在不同状态之间切换。

* 线程创建之后它将处于 **NEW（新建）** 状态，调用 `start()` 方法后开始运行，线程这时候处于 **READY（可运行）** 状态。可运行状态的线程获得了 CPU 时间片（timeslice）后就处于 **RUNNING（运行）** 状态。
* 当线程执行 `wait()` 方法之后，线程进入 **WAITING（等待）** 状态。进入等待状态的线程需要依靠其他线程的通知才能够返回到运行状态。
* **TIMED\_WAITING(超时等待)** 状态相当于在等待状态的基础上增加了超时限制，比如通过 `sleep（long millis）` 方法或 `wait（long millis）` 方法可以将线程置于 TIMED\_WAITING 状态。当超时时间结束后，线程将会返回到 RUNNABLE 状态。
* 当线程进入 `synchronized` 方法/块或者调用 `wait` 后（被 `notify`）重新进入 `synchronized` 方法/块，但是锁被其它线程占有，这个时候线程就会进入 **BLOCKED（阻塞）** 状态。
* 线程在执行完了 `run()` 方法之后将会进入到 **TERMINATED（终止）** 状态。

## 68. 什么是线程上下文切换

线程在执行过程中会有自己的运行条件和状态（也称上下文），比如上文所说到过的程序计数器，栈信息等。当出现如下情况的时候，线程会从占用 CPU 状态中退出。

* 主动让出 CPU，比如调用了 `sleep()`, `wait()` 等。
* 时间片用完，因为操作系统要防止一个线程或者进程长时间占用 CPU 导致其他线程或者进程饿死。
* 调用了阻塞类型的系统中断，比如请求 IO，线程被阻塞。
* 被终止或结束运行

这其中前三种都会发生线程切换，线程切换意味着需要保存当前线程的上下文，留待线程下次占用 CPU 的时候恢复现场。并加载下一个将要占用 CPU 的线程上下文。这就是所谓的 **上下文切换**。

## 69. Thread.sleep() 方法和 Object.wait() 方法对比

**共同点**：两者都可以暂停线程的执行。

**区别**：

* **`sleep()` 方法没有释放锁，而 `wait()` 方法释放了锁** 。
* `wait()` 通常被用于线程间交互/通信，`sleep()` 通常被用于暂停执行。
* `wait()` 方法被调用后，线程不会自动苏醒，需要别的线程调用同一个对象上的 `notify()` 或者 `notifyAll()` 方法。`sleep()` 方法执行完成后，线程会自动苏醒，或者也可以使用 `wait(long timeout)` 超时后线程会自动苏醒。
* `sleep()` 是 `Thread` 类的静态本地方法，`wait()` 则是 `Object` 类的本地方法。

## 70. 为什么 wait() 方法不定义在 Thread 中

`wait()` 是让获得对象锁的线程实现等待，会自动释放当前线程占有的对象锁。每个对象（`Object`）都拥有对象锁，既然要释放当前线程占有的对象锁并让其进入 WAITING 状态，自然是要操作对应的对象（`Object`）而非当前的线程（`Thread`）。

类似的问题：**为什么 `sleep()` 方法定义在 `Thread` 中？**

因为 `sleep()` 是让当前线程暂停执行，不涉及到对象类，也不需要获得对象锁。

## 71. 可以直接调用 Thread 类的 run 方法吗

new 一个 `Thread`，线程进入了新建状态。调用 `start()` 方法，会启动一个线程并使线程进入了就绪状态，当分配到时间片后就可以开始运行了。 `start()` 会执行线程的相应准备工作，然后自动执行 `run()` 方法的内容，这是真正的多线程工作。 但是，直接执行 `run()` 方法，会把 `run()` 方法当成一个 main 线程下的普通方法去执行，并不会在某个线程中执行它，所以这并不是多线程工作。

**总结：调用 `start()` 方法方可启动线程并使线程进入就绪状态，直接执行 `run()` 方法的话不会以多线程的方式执行**

## 72. 如何理解线程安全和不安全

* 线程安全指的是在多线程环境下，对于同一份数据，不管有多少个线程同时访问，都能保证这份数据的正确性和一致性。
* 线程不安全则表示在多线程环境下，对于同一份数据，多个线程同时访问时可能会导致数据混乱、错误或者丢失。

## 73. 单核 CPU 上运行多个线程效率一定会高吗

在单核 CPU 上，同一时刻只能有一个线程在运行，其他线程需要等待 CPU 的时间片分配。如果线程是 CPU 密集型的，那么多个线程同时运行会导致频繁的线程切换。如果线程是 IO 密集型的，那么多个线程同时运行可以利用 CPU 在等待 IO 时的空闲时间，提高了效率。

因此，对于单核 CPU 来说，如果任务是 CPU 密集型的，那么开很多线程会影响效率；如果任务是 IO 密集型的，那么开很多线程会提高效率。当然，这里的“很多”也要适度，不能超过系统能够承受的上限。

## 74. 如何预防和避免线程死锁

**预防**

1. **破坏请求与保持条件**：一次性申请所有的资源。
2. **破坏不剥夺条件**：占用部分资源的线程进一步申请其他资源时，如果申请不到，可以主动释放它占有的资源。
3. **破坏循环等待条件**：靠按序申请资源来预防。按某一顺序申请资源，释放资源则反序释放。破坏循环等待条件。

**避免**

避免死锁就是在资源分配时，借助于算法（比如银行家算法）对资源分配进行计算评估，使其进入安全状态。

## 75. 如何保证变量的可见性

在 Java 中，`volatile` 关键字可以保证变量的可见性，如果我们将变量声明为 **`volatile`** ，这就指示 JVM，这个变量是共享且不稳定的，每次使用它都到主存中进行读取。

`volatile` 关键字能保证数据的可见性，但不能保证数据的原子性。`synchronized` 关键字两者都能保证。

## 76. 如何禁止指令重排序

**在 Java 中，`volatile` 关键字除了可以保证变量的可见性，还有一个重要的作用就是防止 JVM 的指令重排序。** 如果我们将变量声明为 **`volatile`** ，在对这个变量进行读写操作的时候，会通过插入特定的 **内存屏障** 的方式来禁止指令重排序。

## 77. volatile 可以保证原子性吗

**`volatile` 关键字能保证变量的可见性，但不能保证对变量的操作是原子性的。**

## 78. 如何实现乐观锁

### 版本号机制

1. 操作员 A 此时将其读出（ `version`=1 ），并从其帐户余额中扣除 $50（ $100-$50 ）。
2. 在操作员 A 操作的过程中，操作员 B 也读入此用户信息（ `version`=1 ），并从其帐户余额中扣除 $20 （ $100-$20 ）。
3. 操作员 A 完成了修改工作，将数据版本号（ `version`=1 ），连同帐户扣除后余额（ `balance`=$50 ），提交至数据库更新，此时由于提交数据版本等于数据库记录当前版本，数据被更新，数据库记录 `version` 更新为 2 。
4. 操作员 B 完成了操作，也将版本号（ `version`=1 ）试图向数据库提交数据（ `balance`=$80 ），但此时比对数据库记录版本时发现，操作员 B 提交的数据版本号为 1 ，数据库记录当前版本也为 2 ，不满足 “ 提交版本必须等于当前版本才能执行更新 “ 的乐观锁策略，因此，操作员 B 的提交被驳回。

这样就避免了操作员 B 用基于 `version`=1 的旧数据修改的结果覆盖操作员 A 的操作结果的可能。

### CAS 算法

CAS 涉及到三个操作数：

* **V**：要更新的变量值 (Var)
* **E**：预期值 (Expected)
* **N**：拟写入的新值 (New)

当且仅当 V 的值等于 E 时，CAS 通过原子方式用新值 N 来更新 V 的值。如果不等，说明已经有其它线程更新了 V，则当前线程放弃更新。

## 79. CAS 算法存在的问题

### ABA 问题

如果一个变量 V 初次读取的时候是 A 值，并且在准备赋值的时候检查到它仍然是 A 值，那我们就能说明它的值没有被其他线程修改过了吗？很明显是不能的，因为在这段时间它的值可能被改为其他值，然后又改回 A，那 CAS 操作就会误认为它从来没有被修改过。这个问题被称为 CAS 操作的 **"ABA" 问题。**

ABA 问题的解决思路是在变量前面追加上**版本号或者时间戳**。

### 循环时间长开销大

CAS 经常会用到自旋操作来进行重试，也就是不成功就一直循环执行直到成功。如果长时间不成功，会给 CPU 带来非常大的执行开销。

如果 JVM 能支持处理器提供的 pause 指令那么效率会有一定的提升，pause 指令有两个作用：

1. 可以延迟流水线执行指令，使 CPU 不会消耗过多的执行资源，延迟的时间取决于具体实现的版本，在一些处理器上延迟时间是零。
2. 可以避免在退出循环的时候因内存顺序冲突而引起 CPU 流水线被清空，从而提高 CPU 的执行效率。

### 只能保证一个共享变量的原子操作

CAS 只对单个共享变量有效，当操作涉及跨多个共享变量时 CAS 无效。但是从 JDK 1.5 开始，提供了 `AtomicReference` 类来保证引用对象之间的原子性，你可以把多个变量放在一个对象里来进行 CAS 操作.所以我们可以使用锁或者利用 `AtomicReference` 类把多个共享变量合并成一个共享变量来操作。

## 80. synchronized 是什么

主要解决的是多个线程之间访问资源的同步性，可以保证被它修饰的方法或者代码块在任意时刻只能有一个线程执行。

在 Java 早期版本中，`synchronized` 属于 **重量级锁**，效率低下。这是因为监视器锁（monitor）是依赖于底层的操作系统的 `Mutex Lock` 来实现的，Java 的线程是映射到操作系统的原生线程之上的。如果要挂起或者唤醒一个线程，都需要操作系统帮忙完成，而操作系统实现线程之间的切换时需要从用户态转换到内核态，这个状态之间的转换需要相对比较长的时间，时间成本相对较高。

不过，在 Java 6 之后， `synchronized` 引入了大量的优化。因此， `synchronized` 还是可以在实际项目中使用的，像 JDK 源码、很多开源框架都大量使用了 `synchronized` 。

## 81. synchronized 底层原理

`synchronized` 同步语句块的实现使用的是 `monitorenter` 和 `monitorexit` 指令，其中 `monitorenter` 指令指向同步代码块的开始位置，`monitorexit` 指令则指明同步代码块的结束位置。

`synchronized` 修饰的方法并没有 `monitorenter` 指令和 `monitorexit` 指令，取而代之的是 `ACC_SYNCHRONIZED` 标识，该标识指明了该方法是一个同步方法。

**不过两者的本质都是对对象监视器 monitor 的获取。**

## 82. JDK1.6 之后的 synchronized 底层做了哪些优化？锁升级原理了解吗

在 Java 6 之后， `synchronized` 引入了大量的优化如自旋锁、适应性自旋锁、锁消除、锁粗化、偏向锁、轻量级锁等技术来减少锁操作的开销，这些优化让 `synchronized` 锁的效率提升了很多（JDK18 中，偏向锁已经被彻底废弃）。

锁主要存在四种状态，依次是：无锁状态、偏向锁状态、轻量级锁状态、重量级锁状态，他们会随着竞争的激烈而逐渐升级。注意锁可以升级不可降级，这种策略是为了提高获得锁和释放锁的效率。

## 83. synchronized 和 volatile 有什么区别

* `volatile` 关键字是线程同步的轻量级实现，所以 `volatile` 性能肯定比 `synchronized` 关键字要好 。但是 `volatile` 关键字只能用于变量而 `synchronized` 关键字可以修饰方法以及代码块 。
* `volatile` 关键字能保证数据的可见性，但不能保证数据的原子性。`synchronized` 关键字两者都能保证。
* `volatile` 关键字主要用于解决变量在多个线程之间的可见性，而 `synchronized` 关键字解决的是多个线程之间访问资源的同步性。

## 84. ReentrantLock 是什么

`ReentrantLock` 实现了 `Lock` 接口，是一个可重入且独占式的锁，和 `synchronized` 关键字类似。不过，`ReentrantLock` 更灵活、更强大，增加了轮询、超时、中断、公平锁和非公平锁等高级功能。

`ReentrantLock` 里面有一个内部类 `Sync`，`Sync` 继承 AQS（`AbstractQueuedSynchronizer`），添加锁和释放锁的大部分操作实际上都是在 `Sync` 中实现的。`Sync` 有公平锁 `FairSync` 和非公平锁 `NonfairSync` 两个子类。

`ReentrantLock` 默认使用非公平锁，也可以通过构造器来显式的指定使用公平锁。

```java
// 传入一个 boolean 值，true 时为公平锁，false 时为非公平锁
public ReentrantLock(boolean fair) {
    sync = fair ? new FairSync() : new NonfairSync();
}
```

## 85. 公平锁和非公平锁有什么区别

* **公平锁** : 锁被释放之后，先申请的线程先得到锁。性能较差一些，因为公平锁为了保证时间上的绝对顺序，上下文切换更频繁。
* **非公平锁**：锁被释放之后，后申请的线程可能会先获取到锁，是随机或者按照其他优先级排序的。性能更好，但可能会导致某些线程永远无法获取到锁。

## 86. synchronized 和 ReentrantLock 有什么区别

### 都是可重入锁

**可重入锁** 也叫递归锁，指的是线程可以再次获取自己的内部锁。比如一个线程获得了某个对象的锁，此时这个对象锁还没有释放，当其再次想要获取这个对象的锁的时候还是可以获取的，如果是不可重入锁的话，就会造成死锁。

### synchronized 依赖于 JVM 而 ReentrantLock 依赖于 API

`synchronized` 是依赖于 JVM 实现的。

`ReentrantLock` 是 JDK 层面实现的（也就是 API 层面，需要 lock() 和 unlock() 方法配合 try/finally 语句块来完成），所以我们可以通过查看它的源代码，来看它是如何实现的。

### ReentrantLock 比 synchronized 增加了一些高级功能

* **等待可中断** : `ReentrantLock` 提供了一种能够中断等待锁的线程的机制，通过 `lock.lockInterruptibly()` 来实现这个机制。也就是说正在等待的线程可以选择放弃等待，改为处理其他事情。
* **可实现公平锁** : `ReentrantLock` 可以指定是公平锁还是非公平锁。而 `synchronized` 只能是非公平锁。所谓的公平锁就是先等待的线程先获得锁。`ReentrantLock` 默认情况是非公平的，可以通过 `ReentrantLock` 类的 `ReentrantLock(boolean fair)` 构造方法来指定是否是公平的。
* **可实现选择性通知（锁可以绑定多个条件）**: `synchronized` 关键字与 `wait()` 和 `notify()`/`notifyAll()` 方法相结合可以实现等待/通知机制。`ReentrantLock` 类当然也可以实现，但是需要借助于 `Condition` 接口与 `newCondition()` 方法。

## 87. 可中断锁和不可中断锁有什么区别

* **可中断锁**：获取锁的过程中可以被中断，不需要一直等到获取锁之后 才能进行其他逻辑处理。`ReentrantLock` 就属于是可中断锁。
* **不可中断锁**：一旦线程申请了锁，就只能等到拿到锁以后才能进行其他的逻辑处理。 `synchronized` 就属于是不可中断锁。

## 88. ReentrantReadWriteLock 适合什么场景

由于 `ReentrantReadWriteLock` 既可以保证多个线程同时读的效率，同时又可以保证有写入操作时的线程安全。因此，在读多写少的情况下，使用 `ReentrantReadWriteLock` 能够明显提升系统性能。

## 89. 线程持有读锁还能获取写锁吗

* 在线程持有读锁的情况下，该线程不能取得写锁 (因为获取写锁的时候，如果发现当前的读锁被占用，就马上获取失败，不管读锁是不是被当前线程持有)。
* 在线程持有写锁的情况下，该线程可以继续获取读锁（获取读锁时如果发现写锁被占用，只有写锁没有被当前线程占用的情况才会获取失败）。

## 90. ThreadLocal 有什么用

**`ThreadLocal` 类主要解决的就是让每个线程绑定自己的值，可以将 `ThreadLocal` 类形象的比喻成存放数据的盒子，盒子中可以存储每个线程的私有数据。**

如果你创建了一个 `ThreadLocal` 变量，那么访问这个变量的每个线程都会有这个变量的本地副本，这也是 `ThreadLocal` 变量名的由来。他们可以使用 `get()` 和 `set()` 方法来获取默认值或将其值更改为当前线程所存的副本的值，从而避免了线程安全问题。

## 91. ThreadLocal 的原理

从 `Thread` 类源代码入手。

```java
public class Thread implements Runnable {
    //......
    //与此线程有关的ThreadLocal值。由ThreadLocal类维护
    ThreadLocal.ThreadLocalMap threadLocals = null;

    //与此线程有关的InheritableThreadLocal值。由InheritableThreadLocal类维护
    ThreadLocal.ThreadLocalMap inheritableThreadLocals = null;
    //......
}
```

从上面 `Thread` 类 源代码可以看出 `Thread` 类中有一个 `threadLocals` 和 一个 `inheritableThreadLocals` 变量，它们都是 `ThreadLocalMap` 类型的变量,我们可以把 `ThreadLocalMap` 理解为 `ThreadLocal` 类实现的定制化的 `HashMap`。默认情况下这两个变量都是 null，只有当前线程调用 `ThreadLocal` 类的 `set` 或 `get` 方法时才创建它们，实际上调用这两个方法的时候，我们调用的是 `ThreadLocalMap` 类对应的 `get()`、`set()` 方法。

**最终的变量是放在了当前线程的 `ThreadLocalMap` 中，并不是存在 `ThreadLocal` 上,`ThreadLocal` 可以理解为只是 `ThreadLocalMap` 的封装，传递了变量值。** `ThreadLocal` 类中可以通过 `Thread.currentThread()` 获取到当前线程对象后，直接通过 `getMap(Thread t)` 可以访问到该线程的 `ThreadLocalMap` 对象。

**每个 `Thread` 中都具备一个 `ThreadLocalMap`，而 `ThreadLocalMap` 可以存储以 `ThreadLocal` 为 key ，Object 对象为 value 的键值对。**

```java
ThreadLocalMap(ThreadLocal<?> firstKey, Object firstValue) {
    //......
}
```

比如我们在同一个线程中声明了两个 `ThreadLocal` 对象的话， `Thread` 内部都是使用仅有的那个 `ThreadLocalMap` 存放数据的，`ThreadLocalMap` 的 key 就是 `ThreadLocal` 对象，value 就是 `ThreadLocal` 对象调用 `set` 方法设置的值。

## 92. ThreadLocal 内存泄露问题是怎么导致的

`ThreadLocalMap` 中使用的 key 为 `ThreadLocal` 的弱引用，而 value 是强引用。所以，如果 `ThreadLocal` 没有被外部强引用的情况下，在垃圾回收的时候，key 会被清理掉，而 value 不会被清理掉。

这样一来，`ThreadLocalMap` 中就会出现 key 为 null 的 Entry。假如我们不做任何措施的话，value 永远无法被 GC 回收，这个时候就可能会产生内存泄露。`ThreadLocalMap` 实现中已经考虑了这种情况，在调用 `set()`、`get()`、`remove()` 方法的时候，会清理掉 key 为 null 的记录。使用完 `ThreadLocal` 方法后最好手动调用 `remove()` 方法

## 93. 为什么要使用线程池

* **降低资源消耗**。通过重复利用已创建的线程降低线程创建和销毁造成的消耗。
* **提高响应速度**。当任务到达时，任务可以不需要等到线程创建就能立即执行。
* **提高线程的可管理性**。线程是稀缺资源，如果无限制的创建，不仅会消耗系统资源，还会降低系统的稳定性，使用线程池可以进行统一的分配，调优和监控。

## 94. 线程池的常见参数

* `corePoolSize` : 任务队列未达到队列容量时，最大可以同时运行的线程数量。
* `maximumPoolSize` : 任务队列中存放的任务达到队列容量的时候，当前可以同时运行的线程数量变为最大线程数。
* `workQueue`: 新任务来的时候会先判断当前运行的线程数量是否达到核心线程数，如果达到的话，新任务就会被存放在队列中。
* `keepAliveTime`: 线程池中的线程数量大于 `corePoolSize` 的时候，如果这时没有新的任务提交，核心线程外的线程不会立即销毁，而是会等待，直到等待的时间超过了 `keepAliveTime` 才会被回收销毁。
* `handler` : 拒绝策略。

## 95. 线程池的拒绝策略有哪些

* `ThreadPoolExecutor.AbortPolicy`：抛出 `RejectedExecutionException` 来拒绝新任务的处理。
* `ThreadPoolExecutor.CallerRunsPolicy`：调用执行自己的线程运行任务，也就是直接在调用 `execute` 方法的线程中运行 (`run`) 被拒绝的任务，如果执行程序已关闭，则会丢弃该任务。因此这种策略会降低对于新任务提交速度，影响程序的整体性能。如果你的应用程序可以承受此延迟并且你要求任何一个任务请求都要被执行的话，你可以选择这个策略。
* `ThreadPoolExecutor.DiscardPolicy`：不处理新任务，直接丢弃掉。
* `ThreadPoolExecutor.DiscardOldestPolicy`：此策略将丢弃最早的未处理的任务请求。

## 96. CallerRunsPolicy 拒绝策略有什么风险

如果走到 `CallerRunsPolicy` 的任务是个非常耗时的任务，且处理提交任务的线程是主线程，可能会导致主线程阻塞，影响程序的正常运行。

因为 `CallerRunsPolicy` 这个拒绝策略，导致耗时的任务用了主线程执行，导致线程池阻塞，进而导致后续任务无法及时执行，严重的情况下很可能导致 OOM。

我们从问题的本质入手，可以增加阻塞队列 `BlockingQueue` 的大小并调整堆内存以容纳更多的任务，确保任务能够被准确执行。

为了充分利用 CPU，我们还可以调整线程池的 `maximumPoolSize` （最大线程数）参数，这样可以提高任务处理速度，避免累计在 `BlockingQueue` 的任务过多导致内存用完。

导致主线程卡死的本质就是因为我们不希望任何一个任务被丢弃。换个思路，有没有办法既能保证任务不被丢弃且在服务器有余力时及时处理呢？

这里提供的一种**任务持久化**的思路，这里所谓的任务持久化，包括但不限于:

1. 设计一张任务表间任务存储到 MySQL 数据库中。
2. `Redis` 缓存任务。
3. 将任务提交到消息队列中。

如此一来，一旦我们的线程池中线程以达到满载时，我们就可以通过拒绝策略将最新任务持久化到 MySQL 数据库中，等到线程池有了有余力处理所有任务时，让其优先处理数据库中的任务以避免 " 饥饿 " 问题。

## 97. 线程池常用的阻塞队列有哪些

* 容量为 `Integer.MAX_VALUE` 的 `LinkedBlockingQueue`(无界队列):`FixedThreadPool` 和 `SingleThreadExecutor` 。`FixedThreadPool` 最多只能创建核心线程数的线程（核心线程数和最大线程数相等），`SingleThreadExecutor` 只能创建一个线程（核心线程数和最大线程数都是 1）,二者的任务队列永远不会被放满。
* `SynchronousQueue`（同步队列）：`CachedThreadPool` 。`SynchronousQueue` 没有容量，不存储元素，目的是保证对于提交的任务，如果有空闲线程，则使用空闲线程来处理；否则新建一个线程来处理任务。也就是说，`CachedThreadPool` 的最大线程数是 `Integer.MAX_VALUE` ，可以理解为线程数是可以无限扩展的，可能会创建大量线程，从而导致 OOM。
* `DelayedWorkQueue`（延迟阻塞队列）：`ScheduledThreadPool` 和 `SingleThreadScheduledExecutor` 。`DelayedWorkQueue` 的内部元素并不是按照放入的时间排序，而是会按照延迟的时间长短对任务进行排序，内部采用的是“堆”的数据结构，可以保证每次出队的任务都是当前队列中执行时间最靠前的。`DelayedWorkQueue` 添加元素满了之后会自动扩容原来容量的 1/2，即永远不会阻塞，最大扩容可达 `Integer.MAX_VALUE`，所以最多只能创建核心线程数的线程。

## 98. 线程池处理任务的流程

1. 如果当前运行的线程数小于核心线程数，那么就会新建一个线程来执行任务。
2. 如果当前运行的线程数等于或大于核心线程数，但是小于最大线程数，那么就把该任务放入到任务队列里等待执行。
3. 如果向任务队列投放任务失败（任务队列已经满了），但是当前运行的线程数是小于最大线程数的，就新建一个线程来执行任务。
4. 如果当前运行的线程数已经等同于最大线程数了，新建线程将会使当前运行的线程超出最大线程数，那么当前任务会被拒绝，拒绝策略会调用 `RejectedExecutionHandler.rejectedExecution()` 方法。

## 99. 线程池中的线程异常后，销毁还是复用

* **使用 `execute()` 提交任务**：当任务通过 `execute()` 提交到线程池并在执行过程中抛出异常时，如果这个异常没有在任务内被捕获，那么该异常会导致当前线程终止，并且异常会被打印到控制台或日志文件中。线程池会检测到这种线程终止，并创建一个新线程来替换它，从而保持配置的线程数不变。
* **使用 `submit()` 提交任务**：对于通过 `submit()` 提交的任务，如果在任务执行中发生异常，这个异常不会直接打印出来。相反，异常会被封装在由 `submit()` 返回的 `Future` 对象中。当调用 `Future.get()` 方法时，可以捕获到一个 `ExecutionException`。在这种情况下，线程不会因为异常而终止，它会继续存在于线程池中，准备执行后续的任务。

简单来说：使用 `execute()` 时，未捕获异常导致线程终止，线程池创建新线程替代；使用 `submit()` 时，异常被封装在 `Future` 中，线程继续复用。

这种设计允许 `submit()` 提供更灵活的错误处理机制，因为它允许调用者决定如何处理异常，而 `execute()` 则适用于那些不需要关注执行结果的场景。

## 100. 如何设定线程池大小

&#x20;线程数量过多的影响主要是增加了**上下文切换**成本。

* 如果我们设置的线程池数量太小的话，如果同一时间有大量任务/请求需要处理，可能会导致大量的请求/任务在任务队列中排队等待执行，甚至会出现任务队列满了之后任务/请求无法处理的情况，或者大量任务堆积在任务队列导致 OOM。
* 如果我们设置线程数量太大，大量线程可能会同时在争取 CPU 资源，这样会导致大量的上下文切换，从而增加线程的执行时间，影响了整体执行效率。

有一个简单并且适用面比较广的公式：

* **CPU 密集型任务 (N+1)：** 这种任务消耗的主要是 CPU 资源，可以将线程数设置为 N（CPU 核心数）+1。比 CPU 核心数多出来的一个线程是为了防止线程偶发的缺页中断，或者其它原因导致的任务暂停而带来的影响。一旦任务暂停，CPU 就会处于空闲状态，而在这种情况下多出来的一个线程就可以充分利用 CPU 的空闲时间。
* **I/O 密集型任务 (2N)：** 这种任务应用起来，系统会用大部分的时间来处理 I/O 交互，这时就可以将 CPU 交出给其它线程使用。因此在 I/O 密集型任务的应用中，我们可以多配置一些线程，具体的计算方法是 2N。

## 101. 如何设计一个能够根据任务的优先级来执行的线程池

可以考虑使用 `PriorityBlockingQueue` （优先级阻塞队列）作为任务队列（`ThreadPoolExecutor` 的构造函数有一个 `workQueue` 参数可以传入任务队列）。

`PriorityBlockingQueue` 是一个支持优先级的无界阻塞队列，可以看作是线程安全的 `PriorityQueue`，两者底层都是使用小顶堆形式的二叉堆，即值最小的元素优先出队。不过，`PriorityQueue` 不支持阻塞操作。

要想让 `PriorityBlockingQueue` 实现对任务的排序，传入其中的任务必须是具备排序能力的，方式有两种：

1. 提交到线程池的任务实现 `Comparable` 接口，并重写 `compareTo` 方法来指定任务之间的优先级比较规则。
2. 创建 `PriorityBlockingQueue` 时传入一个 `Comparator` 对象来指定任务之间的排序规则 (推荐)。

不过，这存在一些风险和问题，比如：

* `PriorityBlockingQueue` 是无界的，可能堆积大量的请求，从而导致 OOM。
* 可能会导致饥饿问题，即低优先级的任务长时间得不到执行。
* 由于需要对队列中的元素进行排序操作以及保证线程安全（并发控制采用的是可重入锁 `ReentrantLock`），因此会降低性能。

对于 OOM 这个问题的解决比较简单粗暴，就是继承 `PriorityBlockingQueue` 并重写一下 `offer` 方法 (入队) 的逻辑，当插入的元素数量超过指定值就返回 false 。

饥饿问题这个可以通过优化设计来解决（比较麻烦），比如等待时间过长的任务会被移除并重新添加到队列中，但是优先级会被提升。

对于性能方面的影响，是没办法避免的，毕竟需要对任务进行排序操作。并且，对于大部分业务场景来说，这点性能影响是可以接受的。

## 102. Future 类有什么用

当我们执行某一耗时的任务时，可以将这个耗时任务交给一个子线程去异步执行。等我们的事情干完后，我们再通过 `Future` 类获取到耗时任务的执行结果。这样一来，程序的执行效率就明显提高了。

在 Java 中，`Future` 类只是一个泛型接口，位于 `java.util.concurrent` 包下，其中定义了 5 个方法，主要包括下面这 4 个功能：

* 取消任务；
* 判断任务是否被取消;
* 判断任务是否已经执行完成;
* 获取任务执行结果。

## 103. Callable 和 Future 有什么关系

`FutureTask` 提供了 `Future` 接口的基本实现，常用来封装 `Callable` 和 `Runnable`，具有取消任务、查看任务是否执行完成以及获取任务执行结果的方法。

`FutureTask` 不光实现了 `Future` 接口，还实现了 `Runnable` 接口，因此可以作为任务直接被线程执行。

`FutureTask` 有两个构造函数，可传入 `Callable` 或者 `Runnable` 对象。实际上，传入 `Runnable` 对象也会在方法内部转换为 `Callable` 对象。

```java
public FutureTask(Callable<V> callable) {
    if (callable == null)
        throw new NullPointerException();
    this.callable = callable;
    this.state = NEW;
}
public FutureTask(Runnable runnable, V result) {
    // 通过适配器RunnableAdapter来将Runnable对象runnable转换成Callable对象
    this.callable = Executors.callable(runnable, result);
    this.state = NEW;
}
```

`FutureTask` 相当于对 `Callable` 进行了封装，管理着任务执行的情况，存储了 `Callable` 的 `call` 方法的任务执行结果。

## 104. CompletableFuture 类有什么用

`Future` 在实际使用过程中存在一些局限性比如不支持异步任务的编排组合、获取计算结果的 `get()` 方法为阻塞调用。

Java 8 才被引入 `CompletableFuture` 类可以解决 `Future` 的这些缺陷。`CompletableFuture` 除了提供了更为好用和强大的 `Future` 特性之外，还提供了函数式编程、异步任务编排组合（可以将多个异步任务串联起来，组成一个完整的链式调用）等能力。

```java
public class CompletableFuture<T> implements Future<T>, CompletionStage<T> {
}
```

`CompletableFuture` 同时实现了 `Future` 和 `CompletionStage` 接口。

`CompletionStage` 接口描述了一个异步计算的阶段。很多计算可以分成多个阶段或步骤，此时可以通过它将所有步骤组合起来，形成异步计算的流水线。

## 105. AQS 是什么

AQS 为构建锁和同步器提供了一些通用功能的实现，因此，使用 AQS 能简单且高效地构造出应用广泛的大量的同步器，比如我们提到的 `ReentrantLock`，`Semaphore`，其他的诸如 `ReentrantReadWriteLock`，`SynchronousQueue` 等等皆是基于 AQS 的。

## 106. AQS 的原理是什么

AQS 核心思想是，如果被请求的共享资源空闲，则将当前请求资源的线程设置为有效的工作线程，并且将共享资源设置为锁定状态。如果被请求的共享资源被占用，那么就需要一套线程阻塞等待以及被唤醒时锁分配的机制，这个机制 AQS 是用 **CLH 队列锁** 实现的，即将暂时获取不到锁的线程加入到队列中。

CLH(Craig,Landin,and Hagersten) 队列是一个虚拟的双向队列（虚拟的双向队列即不存在队列实例，仅存在结点之间的关联关系）。AQS 是将每条请求共享资源的线程封装成一个 CLH 锁队列的一个结点（Node）来实现锁的分配。在 CLH 同步队列中，一个节点表示一个线程，它保存着线程的引用（thread）、 当前节点在队列中的状态（waitStatus）、前驱节点（prev）、后继节点（next）。

AQS 使用 **int 成员变量 `state` 表示同步状态**，通过内置的 **线程等待队列** 来完成获取资源线程的排队工作。

以 `ReentrantLock` 为例，`state` 初始值为 0，表示未锁定状态。A 线程 `lock()` 时，会调用 `tryAcquire()` 独占该锁并将 `state+1` 。此后，其他线程再 `tryAcquire()` 时就会失败，直到 A 线程 `unlock()` 到 `state=`0（即释放锁）为止，其它线程才有机会获取该锁。当然，释放锁之前，A 线程自己是可以重复获取此锁的（`state` 会累加），这就是可重入的概念。但要注意，获取多少次就要释放多少次，这样才能保证 state 是能回到零态的。

再以 `CountDownLatch` 为例，任务分为 N 个子线程去执行,`state` 也初始化为 N(注意 N 要与线程个数一致)。这 N 个子线程是并行执行的，每个子线程执行完后 `countDown()` 一次，state 会 CAS(Compare and Swap) 减 1。等到所有子线程都执行完后 (即 `state=0` ),会 `unpark()` 主调用线程，然后主调用线程就会从 `await()` 函数返回，继续后余动作。

## 107. Semaphore 有什么用

`synchronized` 和 `ReentrantLock` 都是一次只允许一个线程访问某个资源，而 `Semaphore`(信号量) 可以用来控制同时访问特定资源的线程数量。

Semaphore 的使用简单，我们这里假设有 N(N>5) 个线程来获取 `Semaphore` 中的共享资源，下面的代码表示同一时刻 N 个线程中只有 5 个线程能获取到共享资源，其他线程都会阻塞，只有获取到共享资源的线程才能执行。等到有线程释放了共享资源，其他阻塞的线程才能获取到。

`Semaphore` 有两种模式：

* **公平模式：** 调用 `acquire()` 方法的顺序就是获取许可证的顺序，遵循 FIFO
* **非公平模式：** 抢占式的

```java
// 初始共享资源数量
final Semaphore semaphore = new Semaphore(5);
// 获取1个许可
semaphore.acquire();
// 释放1个许可
semaphore.release();
```

当初始的资源个数为 1 的时候，`Semaphore` 退化为排他锁。

`Semaphore` 通常用于那些资源有明确访问数量限制的场景比如限流（仅限于单机模式，实际项目中推荐使用 Redis +Lua 来做限流）。

## 108. Semaphore 的原理是什么

`Semaphore` 是共享锁的一种实现，它默认构造 AQS 的 `state` 值为 `permits`，你可以将 `permits` 的值理解为许可证的数量，只有拿到许可证的线程才能执行。

调用 `semaphore.acquire()` ，线程尝试获取许可证，如果 `state >= 0` 的话，则表示可以获取成功。如果获取成功的话，使用 CAS 操作去修改 `state` 的值 `state=state-1`。如果 `state<0` 的话，则表示许可证数量不足。此时会创建一个 Node 节点加入阻塞队列，挂起当前线程。

调用 `semaphore.release();` ，线程尝试释放许可证，并使用 CAS 操作去修改 `state` 的值 `state=state+1`。释放许可证成功之后，同时会唤醒同步队列中的一个线程。被唤醒的线程会重新尝试去修改 `state` 的值 `state=state-1` ，如果 `state>=0` 则获取令牌成功，否则重新进入阻塞队列，挂起线程。

## 109. CountDownLatch 有什么用

`CountDownLatch` 允许 `count` 个线程阻塞在一个地方，直至所有线程的任务都执行完毕。

`CountDownLatch` 是一次性的，计数器的值只能在构造方法中初始化一次，之后没有任何机制再次对其设置值，当 `CountDownLatch` 使用完毕后，它不能再次被使用。

## 110. CountDownLatch 的原理是什么

`CountDownLatch` 是共享锁的一种实现,它默认构造 AQS 的 `state` 值为 `count`。当线程使用 `countDown()` 方法时,其实使用了 `tryReleaseShared` 方法以 CAS 的操作来减少 `state`,直至 `state` 为 0 。当调用 `await()` 方法的时候，如果 `state` 不为 0，那就证明任务还没有执行完毕，`await()` 方法就会一直阻塞，也就是说 `await()` 方法之后的语句不会被执行。直到 `count` 个线程调用了 `countDown()` 使 state 值被减为 0，或者调用 `await()` 的线程被中断，该线程才会从阻塞中被唤醒，`await()` 方法之后的语句得到执行。

## 111. CountDownLatch 使用场景

`CountDownLatch` 的作用就是 允许 count 个线程阻塞在一个地方，直至所有线程的任务都执行完毕。

例如：我们要读取处理 6 个文件，这 6 个任务都是没有执行顺序依赖的任务，但是我们需要返回给用户的时候将这几个文件的处理的结果进行统计整理。

为此我们定义了一个线程池和 count 为 6 的 `CountDownLatch` 对象 。使用线程池处理读取任务，每一个线程处理完之后就将 count-1，调用 `CountDownLatch` 对象的 `await()` 方法，直到所有文件读取完之后，才会接着执行后面的逻辑。

**是否可以优化呢？**

`CompletableFuture` 提供了很多对多线程友好的方法，使用它可以很方便地为我们编写多线程程序，什么异步、串行、并行或者等待所有线程执行完任务什么的都非常方便。

## 112. CyclicBarrier 有什么用

`CyclicBarrier` 和 `CountDownLatch` 非常类似，它也可以实现线程间的技术等待，但是它的功能比 `CountDownLatch` 更加复杂和强大。主要应用场景和 `CountDownLatch` 类似。

> `CountDownLatch` 的实现是基于 AQS 的，而 `CyclicBarrier` 是基于 `ReentrantLock`(`ReentrantLock` 也属于 AQS 同步器) 和 `Condition` 的。

`CyclicBarrier` 的字面意思是可循环使用（Cyclic）的屏障（Barrier）。它要做的事情是：让一组线程到达一个屏障（也可以叫同步点）时被阻塞，直到最后一个线程到达屏障时，屏障才会开门，所有被屏障拦截的线程才会继续干活。

## 113. CyclicBarrier 的原理是什么

`CyclicBarrier` 内部通过一个 `count` 变量作为计数器，`count` 的初始值为 `parties` 属性的初始化值，每当一个线程到了栅栏这里了，那么就将计数器减 1。如果 count 值为 0 了，表示这是这一代最后一个线程到达栅栏，就尝试执行我们构造方法中输入的任务。

## 114. Java 中 3 种常见 IO 模型

Java 中常见的 3 种 IO 模型分别是：

### BIO (Blocking I/O): 同步阻塞 IO 模型

* 应用程序发起 read 调用后，会一直阻塞，直到内核把数据拷贝到用户空间
* 数据的读取写入必须阻塞在一个线程内等待其完成
* 适用于连接数量小且连接时间较长的应用

### NIO (Non-blocking/New I/O): 同步非阻塞 IO 模型

* 应用程序发起 read 调用后，如果内核中没有数据，会直接返回而不阻塞
* 通过 Selector 实现了 IO 多路复用，一个线程可以监听多个连接
* 适用于连接数量多且连接时间较短的应用

### AIO (Asynchronous I/O): 异步 IO 模型

* 应用程序发起 read 后直接返回，不阻塞在那里
* 当后台处理完成，操作系统会通知相应的线程进行后续操作
* 目前应用还不是很广泛，Netty 之前也尝试使用过 AIO，不过又放弃了

## 115. NIO 核心组件作用

* **Buffer（缓冲区）**：NIO 读写数据都是通过缓冲区进行操作的。读操作的时候将 Channel 中的数据填充到 Buffer 中，而写操作时将 Buffer 中的数据写入到 Channel 中。
* **Channel（通道）**：Channel 是一个双向的、可读可写的数据传输通道，NIO 通过 Channel 来实现数据的输入输出。通道是一个抽象的概念，它可以代表文件、套接字或者其他数据源之间的连接。
* **Selector（选择器）**：允许一个线程处理多个 Channel，基于事件驱动的 I/O 多路复用模型。所有的 Channel 都可以注册到 Selector 上，由 Selector 来分配线程来处理事件。

## 116. NIO 零拷贝

零拷贝是指计算机执行 IO 操作时，CPU 不需要将数据从一个存储区域复制到另一个存储区域，从而可以减少上下文切换以及 CPU 的拷贝时间。

无论是传统的 I/O 方式，还是引入了零拷贝之后，2 次 DMA(Direct Memory Access) 拷贝是都少不了的。因为两次 DMA 都是依赖硬件完成的。零拷贝主要是减少了 CPU 拷贝及上下文的切换。

Java 对零拷贝的支持：

* `MappedByteBuffer` 是 NIO 基于内存映射（`mmap`）这种零拷⻉⽅式的提供的⼀种实现，底层实际是调用了 Linux 内核的 `mmap` 系统调用。它可以将一个文件或者文件的一部分映射到内存中，形成一个虚拟内存文件，这样就可以直接操作内存中的数据，而不需要通过系统调用来读写文件。
* `FileChannel` 的 `transferTo()/transferFrom()` 是 NIO 基于发送文件（`sendfile`）这种零拷贝方式的提供的一种实现，底层实际是调用了 Linux 内核的 `sendfile` 系统调用。它可以直接将文件数据从磁盘发送到网络，而不需要经过用户空间的缓冲区。

## 117. Java 运行时内存模型

**线程私有的：**

* 程序计数器
* 虚拟机栈
* 本地方法栈

**线程共享的：**

* 堆
* 方法区
* 直接内存 (非运行时数据区的一部分)

### 程序计数器

程序计数器是一块较小的内存空间，可以看作是当前线程所执行的字节码的行号指示器。字节码解释器工作时通过改变这个计数器的值来选取下一条需要执行的字节码指令。

另外，为了线程切换后能恢复到正确的执行位置，每条线程都需要有一个独立的程序计数器，各线程之间计数器互不影响，独立存储，我们称这类内存区域为“线程私有”的内存。

注意：程序计数器是唯一一个不会出现 `OutOfMemoryError` 的内存区域，它的生命周期随着线程的创建而创建，随着线程的结束而死亡。

### Java 虚拟机栈

Java 虚拟机栈（后文简称栈）也是线程私有的，它的生命周期和线程相同，随着线程的创建而创建，随着线程的死亡而死亡。

除了一些 Native 方法调用是通过本地方法栈实现的，其他所有的 Java 方法调用都是通过栈来实现的（也需要和其他运行时数据区域比如程序计数器配合）。

方法调用的数据需要通过栈进行传递，每一次方法调用都会有一个对应的栈帧被压入栈中，每一个方法调用结束后，都会有一个栈帧被弹出。

栈由一个个栈帧组成，而每个栈帧中都拥有：局部变量表、操作数栈、动态链接、方法返回地址。和数据结构上的栈类似，两者都是先进后出的数据结构，只支持出栈和入栈两种操作。

* 局部变量表：存放各种数据类型、对象引用
* 操作数栈：存放方法执行过程中产生的中间计算结果、临时变量
* 动态链接：将符号引用转换为调用方法的直接引用
* 方法返回地址： **栈帧随着方法调用而创建，随着方法结束而销毁。无论方法正常完成还是异常完成都算作方法结束。**
* **`StackOverFlowError`：** 若栈的内存大小不允许动态扩展，那么当线程请求栈的深度超过当前 Java 虚拟机栈的最大深度的时候，就抛出 `StackOverFlowError` 错误。
* **`OutOfMemoryError`：** 如果栈的内存大小可以动态扩展， 如果虚拟机在动态扩展栈时无法申请到足够的内存空间，则抛出 `OutOfMemoryError` 异常。

### 本地方法栈

和虚拟机栈所发挥的作用非常相似，区别是：**虚拟机栈为虚拟机执行 Java 方法 （也就是字节码）服务，而本地方法栈则为虚拟机使用到的 Native 方法服务。** 在 HotSpot 虚拟机中和 Java 虚拟机栈合二为一。

### 堆

Java 堆是所有线程共享的一块内存区域，在虚拟机启动时创建。**此内存区域的唯一目的就是存放对象实例，几乎所有的对象实例以及数组都在这里分配内存。**

随着 JIT 编译器的发展与逃逸分析技术逐渐成熟，栈上分配、标量替换优化技术将会导致一些微妙的变化，所有的对象都分配到堆上也渐渐变得不那么"绝对"了。从 JDK 6 Update 23 起（HotSpot Server 模式）默认开启逃逸分析，JDK 7 及之后保持默认开启；如果某些方法中的对象引用没有被返回或者未被外面使用（也就是未逃逸出去），那么对象可能通过标量替换等方式被分配到栈上，而避免在堆上分配内存。

Java 堆是垃圾收集器管理的主要区域，因此也被称作 **GC 堆（Garbage Collected Heap）**。从垃圾回收的角度，由于现在收集器基本都采用分代垃圾收集算法，所以 Java 堆还可以细分为：新生代和老年代；再细致一点有：Eden、Survivor、Old 等空间。进一步划分的目的是更好地回收内存，或者更快地分配内存。

在 JDK 7 版本及 JDK 7 版本之前，堆内存被通常分为下面三部分：

1. 新生代内存 (Young Generation)
2. 老年代 (Old Generation)
3. 永久代 (Permanent Generation)

**JDK 8 版本之后 PermGen(永久代) 已被 Metaspace(元空间) 取代，元空间使用的是本地内存。**

大部分情况，对象都会首先在 Eden 区域分配，在一次新生代垃圾回收后，如果对象还存活，则会进入 S0 或者 S1，并且对象的年龄还会加 1(Eden 区 ->Survivor 区后对象的初始年龄变为 1)，当它的年龄增加到一定程度（默认为 15 岁），就会被晋升到老年代中。对象晋升到老年代的年龄阈值，可以通过参数 `-XX:MaxTenuringThreshold` 来设置。

\*\*为什么年龄只能是 0-15? \*\*

因为记录年龄的区域在对象头中，这个区域的大小通常是 4 位。这 4 位可以表示的最大二进制数字是 1111，即十进制的 15。因此，对象的年龄被限制为 0 到 15。

堆这里最容易出现的就是 `OutOfMemoryError` 错误，并且出现这种错误之后的表现形式还会有几种，比如：

1. **`java.lang.OutOfMemoryError: GC Overhead Limit Exceeded`**：当 JVM 花太多时间执行垃圾回收并且只能回收很少的堆空间时，就会发生此错误。
2. **`java.lang.OutOfMemoryError: Java heap space`** : 假如在创建新的对象时, 堆内存中的空间不足以存放新创建的对象, 就会引发此错误。

### 方法区

在不同的虚拟机实现上，方法区的实现是不同的。

当虚拟机要使用一个类时，它需要读取并解析 Class 文件获取相关信息，再将信息存入到方法区。方法区会存储已被虚拟机加载的 **类信息、字段信息、方法信息、常量、静态变量、即时编译器编译后的代码缓存等数据**。

**方法区和永久代以及元空间是什么关系呢？** 方法区和永久代以及元空间的关系很像 Java 中接口和类的关系，类实现了接口，这里的类就可以看作是永久代和元空间，接口可以看作是方法区，也就是说永久代以及元空间是 HotSpot 虚拟机对虚拟机规范中方法区的两种实现方式。并且，永久代是 JDK 1.8 之前的方法区实现，JDK 1.8 及以后方法区的实现变成了元空间。

### 运行时常量池

Class 文件中除了有类的版本、字段、方法、接口等描述信息外，还有用于存放编译期生成的各种字面量（Literal）和符号引用（Symbolic Reference）的 **常量池表 (Constant Pool Table)** 。

常量池表会在类加载后存放到方法区的运行时常量池中。既然运行时常量池是方法区的一部分，自然受到方法区内存的限制，当常量池无法再申请到内存时会抛出 `OutOfMemoryError` 错误。

### 字符串常量池

**字符串常量池** 是 JVM 为了提升性能和减少内存消耗针对字符串（String 类）专门开辟的一块区域，主要目的是为了避免字符串的重复创建。

### 直接内存

直接内存是一种特殊的内存缓冲区，并不在 Java 堆或方法区中分配的，而是通过 JNI 的方式在本地内存上分配的。

直接内存并不是虚拟机运行时数据区的一部分，也不是虚拟机规范中定义的内存区域，但是这部分内存也被频繁地使用。而且也可能导致 `OutOfMemoryError` 错误出现。

JDK1.4 中新加入的 **NIO（Non-Blocking I/O，也被称为 New I/O）**，引入了一种基于通道（Channel）与缓存区（Buffer）的 I/O 方式，它可以直接使用 Native 函数库直接分配堆外内存。这样就能在一些场景中显著提高性能，因为\*\*避免了在 Java 堆和 Native 堆之间来回复制数据。

直接内存的分配不会受到 Java 堆的限制，但是，既然是内存就会受到本机总内存大小以及处理器寻址空间的限制。

## 118. 为什么要将永久代替换为元空间呢

* 整个永久代有一个固定的大小上限（可通过 `-XX:MaxPermSize` 调整，但总归受 JVM 内存的限制），而元空间使用的是本地内存，受本机可用内存的限制，虽然元空间仍旧可能溢出，但是比原来出现的几率会更小。
* 元空间里面存放的是类的元数据，这样加载多少类的元数据就不由 `MaxPermSize` 控制了, 而由系统的实际可用空间来控制，这样能加载的类就更多了。
* 在 JDK8，合并 HotSpot 和 JRockit 的代码时, JRockit 从来没有一个叫永久代的东西, 合并之后就没有必要额外的设置这么一个永久代的地方了。
* 永久代会为 GC 带来不必要的复杂度，并且回收效率偏低。

## 119. JDK 1.7 为什么要将字符串常量池移动到堆中

主要是因为永久代（方法区实现）的 GC 回收效率太低，只有在整堆收集 (Full GC) 的时候才会被执行 GC。Java 程序中通常会有大量的被创建的字符串等待回收，将字符串常量池放到堆中，能够更高效及时地回收字符串内存。

## 120. HotSpot 虚拟机对象创建、布局和访问过程

### 对象的创建

1. 类加载检查

虚拟机遇到一条 new 指令时，首先将去检查这个指令的参数是否能在常量池中定位到这个类的符号引用，并且检查这个符号引用代表的类是否已被加载过、解析和初始化过。如果没有，那必须先执行相应的类加载过程。

2. 分配内存

在**类加载检查**通过后，接下来虚拟机将为新生对象**分配内存**。对象所需的内存大小在类加载完成后便可确定，为对象分配空间的任务等同于把一块确定大小的内存从 Java 堆中划分出来。**分配方式**有 **“指针碰撞”** 和 **“空闲列表”** 两种，**选择哪种分配方式由 Java 堆是否规整决定，而 Java 堆是否规整又由所采用的垃圾收集器是否带有压缩整理功能决定**。

**内存分配的两种方式** ：

* 指针碰撞：
  * 适用场合：堆内存规整（即没有内存碎片）的情况下。
  * 原理：用过的内存全部整合到一边，没有用过的内存放在另一边，中间有一个分界指针，只需要向着没用过的内存方向将该指针移动对象内存大小位置即可。
  * 使用该分配方式的 GC 收集器：Serial, ParNew
* 空闲列表：
  * 适用场合：堆内存不规整的情况下。
  * 原理：虚拟机会维护一个列表，该列表中会记录哪些内存块是可用的，在分配的时候，找一块儿足够大的内存块儿来划分给对象实例，最后更新列表记录。
  * 使用该分配方式的 GC 收集器：CMS

选择以上两种方式中的哪一种，取决于 Java 堆内存是否规整。而 Java 堆内存是否规整，取决于 GC 收集器的算法是 " 标记 - 清除 "，还是 " 标记 - 整理 "，值得注意的是，复制算法内存也是规整的。

**内存分配并发问题**

在创建对象的时候有一个很重要的问题，就是线程安全，因为在实际开发过程中，创建对象是很频繁的事情，作为虚拟机来说，必须要保证线程是安全的，通常来讲，虚拟机采用两种方式来保证线程安全：

* **CAS + 失败重试：** **虚拟机采用 CAS 配上失败重试的方式保证更新操作的原子性。**
* **TLAB：** 为每一个线程预先在 Eden 区分配一块儿内存，JVM 在给线程中的对象分配内存时，首先在 TLAB 分配，当对象大于 TLAB 中的剩余内存或 TLAB 的内存已用尽时，再采用上述的 CAS 进行内存分配

3. 初始化零值

内存分配完成后，虚拟机需要将分配到的内存空间都初始化为零值（不包括对象头），这一步操作保证了对象的实例字段在 Java 代码中可以不赋初始值就直接使用，程序能访问到这些字段的数据类型所对应的零值。

4. 设置对象头

**虚拟机要对对象进行必要的设置**，例如这个对象是哪个类的实例、如何才能找到类的元数据信息、对象的哈希码、对象的 GC 分代年龄等信息。 **这些信息存放在对象头中。**&#x20;

5. 执行 init 方法

在上面工作都完成之后，从虚拟机的视角来看，一个新的对象已经产生了，但从 Java 程序的视角来看，对象创建才刚开始，`<init>` 方法还没有执行，所有的字段都还为零。所以一般来说，执行 new 指令之后会接着执行 `<init>` 方法，把对象按照程序员的意愿进行初始化，这样一个真正可用的对象才算完全产生出来。

### 对象的内存布局

在 Hotspot 虚拟机中，对象在内存中的布局可以分为 3 块区域：**对象头（Header）、实例数据（Instance Data）** 和**对齐填充（Padding）**。

对象头包括两部分信息：

1. 标记字段（Mark Word）：用于存储对象自身的运行时数据， 如哈希码（HashCode）、GC 分代年龄、锁状态标志、线程持有的锁、偏向线程 ID、偏向时间戳等等。
2. 类型指针（Klass Word）：对象指向它的类元数据的指针，虚拟机通过这个指针来确定这个对象是哪个类的实例。

**实例数据部分是对象真正存储的有效信息**，也是在程序中所定义的各种类型的字段内容。

**对齐填充部分不是必然存在的，也没有什么特别的含义，仅仅起占位作用。** 因为 Hotspot 虚拟机的自动内存管理系统要求对象起始地址必须是 8 字节的整数倍，换句话说就是对象的大小必须是 8 字节的整数倍。在 64 位虚拟机中，未开启压缩指针时对象头为 16 字节（8 的倍数）；开启压缩类指针（默认开启）时对象头为 12 字节，此时同样需要依靠对齐填充补足到 8 的整数倍。

### 对象的访问定位

对象的访问方式由虚拟机实现而定，目前主流的访问方式有：**使用句柄**、**直接指针**。

* 如果使用句柄的话，那么 Java 堆中将会划分出一块内存来作为句柄池，reference 中存储的就是对象的句柄地址，而句柄中包含了对象实例数据与对象类型数据各自的具体地址信息。
* 如果使用直接指针访问，reference 中存储的直接就是对象的地址。

这两种对象访问方式各有优势。使用句柄来访问的最大好处是 reference 中存储的是稳定的句柄地址，在对象被移动时只会改变句柄中的实例数据指针，而 reference 本身不需要修改。使用直接指针访问方式最大的好处就是速度快，它节省了一次指针定位的时间开销。

## 121. 内存分配和回收原则

### 对象优先在 Eden 区分配

大多数情况下，对象在新生代中 Eden 区分配。当 Eden 区没有足够空间进行分配时，虚拟机将发起一次 Minor GC。

通过 **分配担保机制** 把新生代的对象提前转移到老年代中去，老年代上的空间足够存放，所以不会出现 Full GC。执行 Minor GC 后，后面分配的对象如果能够存在 Eden 区的话，还是会在 Eden 区分配内存。

### 大对象直接进入老年代

大对象就是需要大量连续内存空间的对象（比如：字符串、数组）。大对象直接进入老年代的行为是由虚拟机动态决定的，它与具体使用的垃圾回收器和相关参数有关。大对象直接进入老年代是一种优化策略，旨在避免将大对象放入新生代，从而减少新生代的垃圾回收频率和成本。

* G1 垃圾回收器会根据 `-XX:G1HeapRegionSize` 参数设置的堆区域大小和 `-XX:G1MixedGCLiveThresholdPercent` 参数设置的阈值，来决定哪些对象会直接进入老年代。
* Parallel Scavenge 垃圾回收器中，默认情况下，并没有一个固定的阈值（Parallel Scavenge 默认开启自适应调节策略 `-XX:UseAdaptiveSizePolicy`，阈值由虚拟机动态调整）来决定何时直接在老年代分配大对象。而是由虚拟机根据当前的堆内存情况和历史数据动态决定。

### 长期存活的对象将进入老年代

大部分情况，对象都会首先在 Eden 区域分配。如果对象在 Eden 出生并经过第一次 Minor GC 后仍然能够存活，并且能被 Survivor 容纳的话，将被移动到 Survivor 空间（s0 或者 s1）中，并将对象年龄设为 1(Eden 区 ->Survivor 区后对象的初始年龄变为 1)。

对象在 Survivor 中每熬过一次 MinorGC，年龄就增加 1 岁，当它的年龄增加到一定程度（默认为 15 岁），就会被晋升到老年代中。对象晋升到老年代的年龄阈值，可以通过参数 `-XX:MaxTenuringThreshold` 来设置。

### 主要进行 GC 的区域

针对 HotSpot VM 的实现，它里面的 GC 其实准确分类只有两大种：

部分收集 (Partial GC)：

* 新生代收集（Minor GC / Young GC）：只对新生代进行垃圾收集；
* 老年代收集（Major GC / Old GC）：只对老年代进行垃圾收集。需要注意的是 Major GC 在有的语境中也用于指代整堆收集；
* 混合收集（Mixed GC）：对整个新生代和部分老年代进行垃圾收集。

整堆收集 (Full GC)：收集整个 Java 堆和方法区。

## 122. 死亡对象的判断方法

### 引用计数法

* 每当有一个地方引用它，计数器就加 1；
* 当引用失效，计数器就减 1；
* 任何时候计数器为 0 的对象就是不可能再被使用的。

**这个方法实现简单，效率高，但是目前主流的虚拟机中并没有选择这个算法来管理内存，其最主要的原因是它很难解决对象之间循环引用的问题。**

### 可达性分析算法

这个算法的基本思想就是通过一系列的称为 **“GC Roots”** 的对象作为起点，从这些节点开始向下搜索，节点所走过的路径称为引用链，当一个对象到 GC Roots 没有任何引用链相连的话，则证明此对象是不可用的，需要被回收。

**哪些对象可以作为 GC Roots 呢？**

* 虚拟机栈 (栈帧中的局部变量表) 中引用的对象
* 本地方法栈 (Native 方法) 中引用的对象
* 方法区中类静态属性引用的对象
* 方法区中常量引用的对象
* 所有被同步锁持有的对象
* JNI（Java Native Interface）引用的对象

**对象可以被回收，就代表一定会被回收吗？**

要真正宣告一个对象死亡，至少要经历两次标记过程;可达性分析法中不可达的对象被第一次标记并且进行一次筛选，筛选的条件是此对象是否有必要执行 `finalize` 方法。当对象没有覆盖 `finalize` 方法，或 `finalize` 方法已经被虚拟机调用过时，虚拟机将这两种情况视为没有必要执行。（**注：`finalize` 机制自 JDK 9 起被弃用，JDK 18 起（JEP 421）进入移除流程，不应再依赖它。**）

被判定为需要执行的对象将会被放在一个队列中进行第二次标记，除非这个对象与引用链上的任何一个对象建立关联，否则就会被真的回收。

## 123. Java 的引用类型

**1．强引用（StrongReference）**

以前我们使用的大部分引用实际上都是强引用，这是使用最普遍的引用。如果一个对象具有强引用，垃圾回收器绝不会回收它。当内存空间不足，Java 虚拟机宁愿抛出 OutOfMemoryError 错误，使程序异常终止，也不会靠随意回收具有强引用的对象来解决内存不足问题。

**2．软引用（SoftReference）**

如果一个对象只具有软引用，那就**可有可无**。如果内存空间足够，垃圾回收器就不会回收它，如果内存空间不足了，就会回收这些对象的内存。只要垃圾回收器没有回收它，该对象就可以被程序使用。软引用可用来实现内存敏感的高速缓存。

软引用可以和一个引用队列（ReferenceQueue）联合使用，如果软引用所引用的对象被垃圾回收，JAVA 虚拟机就会把这个软引用加入到与之关联的引用队列中。

**3．弱引用（WeakReference）**

弱引用与软引用的区别在于：只具有弱引用的对象拥有更短暂的生命周期。在垃圾回收器线程扫描它所管辖的内存区域的过程中，一旦发现了只具有弱引用的对象，不管当前内存空间足够与否，都会回收它的内存。不过，由于垃圾回收器是一个优先级很低的线程， 因此不一定会很快发现那些只具有弱引用的对象。

弱引用可以和一个引用队列（ReferenceQueue）联合使用，如果弱引用所引用的对象被垃圾回收，Java 虚拟机就会把这个弱引用加入到与之关联的引用队列中。

**4．虚引用（PhantomReference）**

" 虚引用 " 顾名思义，就是形同虚设，与其他几种引用都不同，虚引用并不会决定对象的生命周期。如果一个对象仅持有虚引用，那么它就和没有任何引用一样，在任何时候都可能被垃圾回收。

**虚引用主要用来跟踪对象被垃圾回收的活动**。

**虚引用与软引用和弱引用的一个区别在于：** 虚引用必须和引用队列（ReferenceQueue）联合使用。当垃圾回收器准备回收一个对象时，如果发现它还有虚引用，就会在回收对象的内存之前，把这个虚引用加入到与之关联的引用队列中。程序可以通过判断引用队列中是否已经加入了虚引用，来了解被引用的对象是否将要被垃圾回收。程序如果发现某个虚引用已经被加入到引用队列，那么就可以在所引用的对象的内存被回收之前采取必要的行动。

特别注意，在程序设计中一般很少使用弱引用与虚引用，使用软引用的情况较多，这是因为**软引用可以加速 JVM 对垃圾内存的回收速度，可以维护系统的运行安全，防止内存溢出（OutOfMemory）等问题的产生**。

## 124. 如何判断一个常量是废弃常量

假如在字符串常量池中存在字符串 "abc"，如果当前没有任何 String 对象引用该字符串常量的话，就说明常量 "abc" 就是废弃常量，如果这时发生内存回收的话而且有必要的话，"abc" 就会被系统清理出常量池了。

## 125. 如何判断一个类是无用的类

类需要同时满足下面 3 个条件才能算是 **“无用的类”**：

* 该类所有的实例都已经被回收，也就是 Java 堆中不存在该类的任何实例。
* 加载该类的 `ClassLoader` 已经被回收。
* 该类对应的 `java.lang.Class` 对象没有在任何地方被引用，无法在任何地方通过反射访问该类的方法。

虚拟机可以对满足上述 3 个条件的无用类进行回收，这里说的仅仅是“可以”，而并不是和对象一样不使用了就会必然被回收。

## 126. Java 垃圾收集算法

### 标记 - 清除算法

标记 - 清除（Mark-and-Sweep）算法分为“标记（Mark）”和“清除（Sweep）”阶段：首先标记出所有不需要回收的对象，在标记完成后统一回收掉所有没有被标记的对象。

它是最基础的收集算法，后续的算法都是对其不足进行改进得到。这种垃圾收集算法会带来两个明显的问题：

1. **效率问题**：标记和清除两个过程效率都不高。
2. **空间问题**：标记清除后会产生大量不连续的内存碎片。

### 复制算法

为了解决标记 - 清除算法的效率和内存碎片问题，复制（Copying）收集算法出现了。它将内存分为大小相同的两块，每次使用其中的一块。当这一块的内存使用完后，就将还存活的对象复制到另一块去，然后再把使用的空间一次清理掉。这样就使每次的内存回收都是对内存区间的一半进行回收。

虽然改进了标记 - 清除算法，但依然存在下面这些问题：

* **可用内存变小**：可用内存缩小为原来的一半。
* **不适合老年代**：如果存活对象数量比较大，复制性能会变得很差。

### 标记 - 整理算法

标记 - 整理（Mark-and-Compact）算法是根据老年代的特点提出的一种标记算法，标记过程仍然与“标记 - 清除”算法一样，但后续步骤不是直接对可回收对象回收，而是让所有存活的对象向一端移动，然后直接清理掉端边界以外的内存。

由于多了整理这一步，因此效率也不高，适合老年代这种垃圾回收频率不是很高的场景。

### 分代收集算法

当前虚拟机的垃圾收集都采用分代收集算法，根据对象存活周期的不同将内存分为几块。一般将 Java 堆分为新生代和老年代，这样我们就可以根据各个年代的特点选择合适的垃圾收集算法。

比如在新生代中，每次收集都会有大量对象死去，所以可以选择”标记 - 复制“算法，只需要付出少量对象的复制成本就可以完成每次垃圾收集。而老年代的对象存活几率是比较高的，而且没有额外的空间对它进行分配担保，所以我们必须选择“标记 - 清除”或“标记 - 整理”算法进行垃圾收集。

## 127. Java 垃圾收集器

JDK 默认垃圾收集器（使用 `java -XX:+PrintCommandLineFlags -version` 命令查看）：

* JDK 8：Parallel Scavenge（新生代）+ Parallel Old（老年代）
* JDK 9 及以后（截至 JDK 26 仍是默认收集器）：G1

### Serial 收集器

Serial（串行）收集器是最基本、历史最悠久的垃圾收集器。这是一个单线程收集器。它的 **“单线程”** 的意义不仅仅意味着它只会使用一条垃圾收集线程去完成垃圾收集工作，更重要的是它在进行垃圾收集工作的时候必须暂停其他所有的工作线程（ **"Stop The World"** ），直到它收集结束。

**新生代采用标记 - 复制算法，老年代采用标记 - 整理算法。**

Serial 收集器有没有优于其他垃圾收集器的地方呢？它**简单而高效（与其他收集器的单线程相比）**。Serial 收集器由于没有线程交互的开销，自然可以获得很高的单线程收集效率。Serial 收集器对于运行在 Client 模式下的虚拟机来说是个不错的选择。

### ParNew 收集器

ParNew 收集器其实就是 Serial 收集器的多线程版本，除了使用多线程进行垃圾收集外，其余行为（控制参数、收集算法、回收策略等等）和 Serial 收集器完全一样。

**新生代采用标记 - 复制算法，老年代采用标记 - 整理算法。**

它是许多运行在 Server 模式下的虚拟机的首要选择，除了 Serial 收集器外，只有它能与 CMS 收集器（真正意义上的并发收集器）配合工作。

### Parallel Scavenge 收集器

Parallel Scavenge 收集器也是使用标记 - 复制算法的多线程收集器，它看上去几乎和 ParNew 都一样。

Parallel Scavenge 收集器关注点是吞吐量（高效率的利用 CPU）。CMS 等垃圾收集器的关注点更多的是用户线程的停顿时间（提高用户体验）。所谓吞吐量就是 CPU 中用于运行用户代码的时间与 CPU 总消耗时间的比值。 Parallel Scavenge 收集器提供了很多参数供用户找到最合适的停顿时间或最大吞吐量。

**新生代采用标记 - 复制算法，老年代采用标记 - 整理算法。**

### Serial Old 收集器

**Serial 收集器的老年代版本**，它同样是一个单线程收集器。它主要有两大用途：一种用途是在 JDK1.5 以及以前的版本中与 Parallel Scavenge 收集器搭配使用，另一种用途是作为 CMS 收集器的后备方案。

### Parallel Old 收集器

**Parallel Scavenge 收集器的老年代版本**。使用多线程和“标记 - 整理”算法。在注重吞吐量以及 CPU 资源的场合，都可以优先考虑 Parallel Scavenge 收集器和 Parallel Old 收集器。

### CMS 收集器

**CMS（Concurrent Mark Sweep）收集器是一种以获取最短回收停顿时间为目标的收集器。是第一款真正意义上的并发收集器，它第一次实现了让垃圾收集线程与用户线程（基本上）同时工作。**

CMS 收集器是一种 **“标记 - 清除”算法**实现的。整个过程分为四个步骤：

* **初始标记：** 暂停所有的其他线程，并记录下直接与 root 相连的对象，速度很快 ；
* **并发标记：** 同时开启 GC 和用户线程，用一个闭包结构去记录可达对象。但在这个阶段结束，这个闭包结构并不能保证包含当前所有的可达对象。因为用户线程可能会不断的更新引用域，所以 GC 线程无法保证可达性分析的实时性。所以这个算法里会跟踪记录这些发生引用更新的地方。
* **重新标记：** 重新标记阶段就是为了修正并发标记期间因为用户程序继续运行而导致标记产生变动的那一部分对象的标记记录。
* **并发清除：** 开启用户线程，同时 GC 线程开始对未标记的区域做清扫。

它有下面三个明显的缺点：

* **对 CPU 资源敏感；**
* **无法处理浮动垃圾；**
* **它使用的回收算法 -“标记 - 清除”算法会导致收集结束时会有大量空间碎片产生。**

**CMS 垃圾回收器在 Java 9 中已经被标记为过时 (deprecated)，并在 Java 14 中被移除。**

### G1 收集器

**G1 (Garbage-First) 是一款面向服务器的垃圾收集器，主要针对配备多处理器大容量内存的机器，以极高概率满足 GC 停顿时间要求的同时，还具备高吞吐量性能特征。**

具备以下特点：

* **并行与并发**：G1 能充分利用 CPU、多核环境下的硬件优势，使用多个 CPU 核心来缩短 STW 停顿时间。部分其他收集器原本需要停顿 Java 线程执行的 GC 动作，G1 收集器仍然可以通过并发的方式让 Java 程序继续执行。
* **分代收集**：虽然 G1 可以不需要其他收集器配合就能独立管理整个 GC 堆，但是还是保留了分代的概念。
* **空间整合**：G1 从整体来看是基于“标记 - 整理”算法实现的收集器；从局部上来看是基于“标记 - 复制”算法实现的。
* **可预测的停顿**：G1 除了追求低停顿外，还能建立可预测的停顿时间模型，能让使用者明确指定在一个长度为 M 毫秒的时间片段内，消耗在垃圾收集上的时间不得超过 N 毫秒。

G1 收集器的运作大致分为以下几个步骤：

* **初始标记**
* **并发标记**
* **最终标记**
* **筛选回收**

**G1 收集器在后台维护了一个优先列表，每次根据允许的收集时间，优先选择回收价值最大的 Region(这也就是它的名字 Garbage-First 的由来)** 。这种使用 Region 划分内存空间以及有优先级的区域回收方式，保证了 G1 收集器在有限时间内可以尽可能高的收集效率（把内存化整为零）。

**从 JDK9 开始，G1 垃圾收集器成为了默认的垃圾收集器。**

### ZGC 收集器

ZGC 也采用标记 - 复制算法，不过 ZGC 对该算法做了重大改进。

ZGC 可以将暂停时间控制在几毫秒以内，且暂停时间不受堆内存大小的影响，出现 STW 的情况会更少，但代价是牺牲了一些吞吐量。ZGC 最大支持 16TB 的堆内存。

在 Java21 中，引入了分代 ZGC，暂停时间可以缩短到 1 毫秒以内。

## 128. 类加载过程

系统加载 Class 类型的文件主要三步：**加载 ->连接 ->初始化**。连接过程又可分为三步：**验证 ->准备 ->解析**。

### 加载

1. 通过全类名获取定义此类的二进制字节流。
2. 将字节流所代表的静态存储结构转换为方法区的运行时数据结构。
3. 在内存中生成一个代表该类的 `Class` 对象，作为方法区这些数据的访问入口。

### 验证

**验证是连接阶段的第一步，这一阶段的目的是确保 Class 文件的字节流中包含的信息符合《Java 虚拟机规范》的全部约束要求，保证这些信息被当作代码运行后不会危害虚拟机自身的安全。**

如果程序运行的全部代码都已经被反复使用和验证过，在生产环境的实施阶段就可以考虑使用 `-Xverify:none` 参数来关闭大部分的类验证措施，以缩短虚拟机类加载的时间。**注意：`-Xverify:none`（及 `-noverify`）自 JDK 13 起已被弃用（JDK-8214719），使用时会输出弃用警告，不建议再使用。**

1. 文件格式验证（Class 文件格式检查）
2. 元数据验证（字节码语义检查）
3. 字节码验证（程序语义检查）
4. 符号引用验证（类的正确性检查）

### 准备

**准备阶段是正式为类变量分配内存并设置类变量初始值的阶段**，这些内存都将在方法区中分配。对于该阶段有以下几点需要注意：

1. 这时候进行内存分配的仅包括类变量（ Class Variables ，即静态变量，被 `static` 关键字修饰的变量，只与类相关，因此被称为类变量），而不包括实例变量。
2. 从概念上讲，类变量所使用的内存都应当在 **方法区** 中进行分配。而在 JDK 7 及之后，HotSpot 已经把原本放在永久代的字符串常量池、静态变量等移动到堆中，这个时候类变量则会随着 Class 对象一起存放在 Java 堆中。
3. 这里所设置的初始值 " 通常情况 " 下是数据类型默认的零值（如 0、0L、null、false 等），比如我们定义了 `public static int value=111` ，那么 value 变量在准备阶段的初始值就是 0 而不是 111（初始化阶段才会赋值）。特殊情况：比如给 value 变量加上了 final 关键字 `public static final int value=111` ，那么准备阶段 value 的值就被赋值为 111。

### 解析

**解析阶段是虚拟机将常量池内的符号引用替换为直接引用的过程。** 解析动作主要针对类或接口、字段、类方法、接口方法、方法类型、方法句柄和调用点限定符 7 类符号引用进行。

在程序执行方法时，系统需要明确知道这个方法所在的位置。Java 虚拟机为每个类都准备了一张方法表来存放类中所有的方法。当需要调用一个类的方法的时候，只要知道这个方法在方法表中的偏移量就可以直接调用该方法了。通过解析操作符号引用就可以直接转变为目标方法在类中方法表的位置，从而使得方法可以被调用。

### 初始化

**初始化阶段是执行初始化方法 `<clinit> ()` 方法的过程，是类加载的最后一步，这一步 JVM 才开始真正执行类中定义的 Java 程序代码 (字节码)。**

`<clinit> ()` 方法是带锁线程安全，在多线程环境下进行类初始化的话可能会引起多个线程阻塞，并且这种阻塞很难被发现。

对于初始化阶段，虚拟机严格规范了有且只有 6 种情况下，必须对类进行初始化 (只有主动去使用类才会初始化类)：

1. 当遇到 `new`、 `getstatic`、`putstatic` 或 `invokestatic` 这 4 条字节码指令时。
2. 使用 `java.lang.reflect` 包的方法对类进行反射调用时如 `Class.forName("…")`, `newInstance()` 等等。如果类没初始化，需要触发其初始化。
3. 初始化一个类，如果其父类还未初始化，则先触发该父类的初始化。
4. 当虚拟机启动时，用户需要定义一个要执行的主类 (包含 `main` 方法的那个类)，虚拟机会先初始化这个类。
5. `MethodHandle` 和 `VarHandle` 可以看作是轻量级的反射调用机制，而要想使用这 2 个调用，就必须先使用 `findStaticVarHandle` 来初始化要调用的类。
6. 当一个接口中定义了 JDK8 新加入的默认方法（被 default 关键字修饰的接口方法）时，如果有这个接口的实现类发生了初始化，那该接口要在其之前被初始化。

## 129. 什么时候会类卸载

**卸载类即该类的 Class 对象被 GC。**

卸载类需要满足 3 个要求:

1. 该类的所有的实例对象都已被 GC，也就是说堆不存在该类的实例对象。
2. 该类没有在其他任何地方被引用
3. 该类的类加载器的实例已被 GC

所以，在 JVM 生命周期内，由 jvm 自带的类加载器加载的类是不会被卸载的。但是由我们自定义的类加载器加载的类是可能被卸载的。

## 130. 什么是类加载器

* 类加载器是一个负责加载类的对象，用于实现类加载过程中的加载这一步。
* 每个 Java 类都有一个引用指向加载它的 `ClassLoader`。
* 数组类不是通过 `ClassLoader` 创建的（数组类没有对应的二进制字节流），是由 JVM 直接生成的。

**类加载器的主要作用就是加载 Java 类的字节码（ `.class` 文件）到 JVM 中（在内存中生成一个代表该类的 `Class` 对象）。** 字节码可以是 Java 源程序（`.java` 文件）经过 `javac` 编译得来，也可以是通过工具动态生成或者通过网络下载得来。

## 131. 类加载器加载规则

JVM 启动的时候，并不会一次性加载所有的类，而是根据需要去动态加载。也就是说，大部分类在具体用到的时候才会去加载，这样对内存更加友好。

对于已经加载的类会被放在 `ClassLoader` 中。在类加载的时候，系统会首先判断当前类是否被加载过。已经被加载的类会直接返回，否则才会尝试加载。也就是说，对于一个类加载器来说，相同二进制名称的类只会被加载一次。

## 132. 类加载器有哪些

1. **`BootstrapClassLoader`(启动类加载器)**：最顶层的加载类，由 C++ 实现，通常表示为 null，并且没有父级，主要用来加载 JDK 内部的核心类库。
2. **`ExtensionClassLoader`(扩展类加载器)**：主要负责加载 `%JRE_HOME%/lib/ext` 目录下的 jar 包和类以及被 `java.ext.dirs` 系统变量所指定的路径下的所有类（**JDK 9 起该加载器更名为 `PlatformClassLoader`（平台类加载器），`lib/ext` 扩展机制与 `java.ext.dirs` 已移除，改为加载模块化系统中的平台模块**）。
3. **`AppClassLoader`(应用程序类加载器)**：面向我们用户的加载器，负责加载当前应用 classpath 下的所有 jar 包和类。

用户还可以加入自定义的类加载器来进行拓展，以满足自己的特殊需求。就比如说，我们可以对 Java 类的字节码（ `.class` 文件）进行加密，加载时再利用自定义的类加载器对其解密。

## 133. 自定义类加载器

除了 `BootstrapClassLoader` 其他类加载器均由 Java 实现且全部继承自 `java.lang.ClassLoader`。如果我们要自定义自己的类加载器，需要继承 `ClassLoader` 抽象类。

`ClassLoader` 类有两个关键的方法：

* `protected Class loadClass(String name, boolean resolve)`：加载指定二进制名称的类，实现了双亲委派机制 。
* `protected Class findClass(String name)`：根据类的二进制名称来查找类，默认实现是空方法。

如果我们不想打破双亲委派模型，就重写 `ClassLoader` 类中的 `findClass()` 方法即可，无法被父类加载器加载的类最终会通过这个方法被加载。但是，如果想打破双亲委派模型则需要重写 `loadClass()` 方法。

## 134. 什么是双亲委派模型

* `ClassLoader` 类使用委托模型来搜索类和资源。
* 双亲委派模型要求除了顶层的启动类加载器外，其余的类加载器都应有自己的父类加载器。
* `ClassLoader` 实例会在试图亲自查找类或资源之前，将搜索类或资源的任务委托给其父类加载器。

> 双亲委派模型并不是一种强制性的约束，只是 JDK 官方推荐的一种方式。

类加载器之间的父子关系一般不是以继承的关系来实现的，而是通常使用组合关系来复用父加载器的代码。

```java
public abstract class ClassLoader {
  ...
  // 组合
  private final ClassLoader parent;
  protected ClassLoader(ClassLoader parent) {
       this(checkCreateClassLoader(), parent);
  }
  ...
}
```

在面向对象编程中，有一条非常经典的设计原则：**组合优于继承，多用组合少用继承。**

## 135. 双亲委派模型执行流程

每当一个类加载器接收到加载请求时，它会先将请求转发给父类加载器。在父类加载器没有找到所请求的类的情况下，该类加载器才会尝试去加载。

总结一下双亲委派模型的执行流程：

* 在类加载的时候，系统会首先判断当前类是否被加载过。已经被加载的类会直接返回，否则才会尝试加载（每个父类加载器都会走一遍这个流程）。
* 类加载器在进行类加载的时候，它首先不会自己去尝试加载这个类，而是把这个请求委派给父类加载器去完成（调用父加载器 `loadClass()` 方法来加载类）。这样的话，所有的请求最终都会传送到顶层的启动类加载器 `BootstrapClassLoader` 中。
* 只有当父加载器反馈自己无法完成这个加载请求（它的搜索范围中没有找到所需的类）时，子加载器才会尝试自己去加载（调用自己的 `findClass()` 方法来加载类）。
* 如果子类加载器也无法加载这个类，那么它会抛出一个 `ClassNotFoundException` 异常。

> **JVM 判定两个 Java 类是否相同的具体规则**：JVM 不仅要看类的全名是否相同，还要看加载此类的类加载器是否一样。只有两者都相同的情况，才认为两个类是相同的。

## 136. 双亲委派模型的好处

双亲委派模型保证了 Java 程序的稳定运行，可以避免类的重复加载（JVM 区分不同类的方式不仅仅根据类名，相同的类文件被不同的类加载器加载产生的是两个不同的类），也保证了 Java 的核心 API 不被篡改。

比如我们编写一个称为 `java.lang.Object` 类的话，那么程序运行的时候，系统就会出现两个不同的 `Object` 类。`AppClassLoader` 在加载你的 `Object` 类时，会委托给 `ExtClassLoader`（JDK 9 起为 `PlatformClassLoader`）去加载，而 `ExtClassLoader` 又会委托给 `BootstrapClassLoader`,`BootstrapClassLoader` 发现自己已经加载过了 `Object` 类，会直接返回，不会去加载你写的 `Object` 类。

## 137. 打破双亲委派模型方法

重写 `loadClass()` 方法之后，我们就可以改变传统双亲委派模型的执行流程。例如，子类加载器可以在委派给父类加载器之前，先自己尝试加载这个类，或者在父类加载器返回之后，再尝试从其他地方加载这个类。具体的规则由我们自己实现，根据项目需求定制化。

## 138. 常用 JVM 参数

### 显式指定堆内存

如果我们需要指定最小和最大堆大小（推荐显示指定大小），以下参数可以帮助你实现：

```sh
-Xms<heap size>[unit]
-Xmx<heap size>[unit]
```

如果我们要为 JVM 分配最小 2 GB 和最大 5 GB 的堆内存大小，我们的参数应该这样来写：

```
-Xms2G -Xmx5G
```

### 显式新生代内存

一共有两种指定 新生代内存 (Young Generation) 大小的方法：

**1.通过 `-XX:NewSize` 和 `-XX:MaxNewSize` 指定**

```sh
-XX:NewSize=<young size>[unit]
-XX:MaxNewSize=<young size>[unit]
```

如果我们要为 新生代分配 最小 256m 的内存，最大 1024m 的内存我们的参数应该这样来写：

```sh
-XX:NewSize=256m
-XX:MaxNewSize=1024m
```

**2.通过 `-Xmn<young size>[unit]` 指定**

如果我们要为 新生代分配 256m 的内存（NewSize 与 MaxNewSize 设为一致），我们的参数应该这样来写：

```sh
-Xmn256m
```

GC 调优策略中很重要的一条经验总结是这样说的：

> 将新对象预留在新生代，由于 Full GC 的成本远高于 Minor GC，因此尽可能将对象分配在新生代是明智的做法，实际项目中根据 GC 日志分析新生代空间大小分配是否合理，适当通过“-Xmn”命令调节新生代大小，最大限度降低新对象直接进入老年代的情况。

另外，你还可以通过 **`-XX:NewRatio=<int>`** 来设置老年代与新生代内存的比值。

比如下面的参数就是设置老年代与新生代内存的比值为 1。也就是说老年代和新生代所占比值为 1：1，新生代占整个堆栈的 1/2。

```sh
-XX:NewRatio=1
```

### 显式指定永久代/元空间的大小

**从 Java 8 开始，如果我们没有指定 Metaspace 的大小，随着更多类的创建，虚拟机会耗尽所有可用的系统内存（永久代并不会出现这种情况）。**

JDK 1.8 之前永久代还没被彻底移除的时候通常通过下面这些参数来调节方法区大小

```sh
-XX:PermSize=N #方法区 (永久代) 初始大小
-XX:MaxPermSize=N #方法区 (永久代) 最大大小,超过这个值将会抛出 OOM
```

相对而言，垃圾收集行为在这个区域是比较少出现的，但并非数据进入方法区后就“永久存在”了。

**JDK 1.8 的时候，方法区（HotSpot 的永久代）被彻底移除了，取而代之是元空间，元空间使用的是本地内存。**

下面是常用参数：

```sh
-XX:MaxMetaspaceSize=N #设置 Metaspace 的最大大小
```

对于 64 位 JVM 来说，Metaspace 的初始容量都是 21807104（约 20.8m）。

### 垃圾回收器

JVM 具有四种类型的 GC 实现：

* 串行垃圾收集器
* 并行垃圾收集器
* CMS 垃圾收集器
* G1 垃圾收集器

可以使用以下参数声明这些实现：

```sh
-XX:+UseSerialGC
-XX:+UseParallelGC
-XX:+UseConcMarkSweepGC
-XX:+UseG1GC
```

## 139. Spring IoC 是什么

**IoC（Inversion of Control: 控制反转）** 是一种设计思想，而不是一个具体的技术实现。IoC 的思想就是将原本在程序中手动创建对象的控制权，交由 Spring 框架来管理。

**为什么叫控制反转？**

* **控制**：指的是对象创建（实例化、管理）的权力
* **反转**：控制权交给外部环境（Spring 框架、IoC 容器）

将对象之间的相互依赖关系交给 IoC 容器来管理，并由 IoC 容器完成对象的注入。IoC 容器就像是一个工厂一样，当我们需要创建一个对象的时候，只需要配置好配置文件/注解即可，完全不用考虑对象是如何被创建出来的。

在实际项目中一个 Service 类可能依赖了很多其他的类，假如我们需要实例化这个 Service，你可能要每次都要搞清这个 Service 所有底层类的构造函数。如果利用 IoC 的话，你只需要配置好，然后在需要的地方引用就行了。

在 Spring 中， IoC 容器是 Spring 用来实现 IoC 的载体， IoC 容器实际上就是个 Map（key，value），Map 中存放的是各种对象。

## 140. 什么是 Spring Bean

简单来说，Bean 代指的就是那些被 IoC 容器所管理的对象。

我们需要告诉 IoC 容器帮助我们管理哪些对象，这个是通过配置元数据来定义的。配置元数据可以是 XML 文件、注解或者 Java 配置类。

```java
<!-- Constructor-arg with 'value' attribute -->
<bean id="..." class="...">
   <constructor-arg value="..."/>
</bean>
```

## 141. 将一个类声明为 Bean 的注解有哪些

* `@Component`：通用的注解，可标注任意类为 `Spring` 组件。如果一个 Bean 不知道属于哪个层，可以使用 `@Component` 注解标注。
* `@Repository` : 对应持久层即 Dao 层，主要用于数据库相关操作。
* `@Service` : 对应服务层，主要涉及一些复杂的逻辑，需要用到 Dao 层。
* `@Controller` : 对应 Spring MVC 控制层，主要用于接受用户请求并调用 `Service` 层返回数据给前端页面。

## 142. @Component 和 @Bean 的区别是什么

* `@Component` 注解作用于类，而 `@Bean` 注解作用于方法。
* `@Component` 通常是通过类路径扫描来自动侦测以及自动装配到 Spring 容器中。`@Bean` 注解通常是我们在标有该注解的方法中定义产生这个 bean，`@Bean` 告诉了 Spring 这是某个类的实例，当我需要用它的时候还给我。
* `@Bean` 注解比 `@Component` 注解的自定义性更强，而且很多地方我们只能通过 `@Bean` 注解来注册 bean。比如当我们引用第三方库中的类需要装配到 `Spring` 容器时，则只能通过 `@Bean` 来实现。

## 143. @Autowired 和 @Resource 的区别是什么

* `@Autowired` 是 Spring 提供的注解，`@Resource` 是 JDK 提供的注解（`javax.annotation.Resource` 在 JDK 8 及以前随 JDK 提供；JDK 9/10 仍存在于 java.xml.ws.annotation 模块但已标记 deprecated；JDK 11 起随 JEP 320 移除，需额外引入 jakarta.annotation 依赖）。
* `Autowired` 默认的注入方式为 `byType`（根据类型进行匹配），`@Resource` 默认注入方式为 `byName`（根据名称进行匹配）。
* 当一个接口存在多个实现类的情况下，`@Autowired` 和 `@Resource` 都需要通过名称才能正确匹配到对应的 Bean。`Autowired` 可以通过 `@Qualifier` 注解来显式指定名称，`@Resource` 可以通过 `name` 属性来显式指定名称。
* `@Autowired` 支持在构造函数、方法、字段和参数上使用。`@Resource` 主要用于字段和方法上的注入，不支持在构造函数或参数上使用。

## 144. Bean 的作用域有哪些

* **singleton** : IoC 容器中只有唯一的 bean 实例。Spring 中的 bean 默认都是单例的，是对单例设计模式的一种应用。
* **prototype** : 每次获取都会创建一个新的 bean 实例。也就是说，连续 `getBean()` 两次，得到的是不同的 Bean 实例。
* **request** （仅 Web 应用可用）: 每一次 HTTP 请求都会产生一个新的 bean（请求 bean），该 bean 仅在当前 HTTP request 内有效。
* **session** （仅 Web 应用可用） : 每一次来自新 session 的 HTTP 请求都会产生一个新的 bean（会话 bean），该 bean 仅在当前 HTTP session 内有效。
* **application/global-session** （仅 Web 应用可用）：每个 Web 应用在启动时创建一个 Bean（应用 Bean），该 bean 仅在当前应用启动时间内有效。
* **websocket** （仅 Web 应用可用）：每一次 WebSocket 会话产生一个新的 bean。

## 145. Bean 是线程安全的吗

prototype 作用域下，每次获取都会创建一个新的 bean 实例，不存在资源竞争问题。singleton 作用域下，IoC 容器中只有唯一的 bean 实例，可能会存在资源竞争问题（取决于 Bean 是否有状态）。如果这个 bean 是有状态的话，那就存在线程安全问题（有状态 Bean 是指包含可变的成员变量的对象）。

不过，大部分 Bean 实际都是无状态（没有定义可变的成员变量）的（比如 Dao、Service），这种情况下， Bean 是线程安全的。

对于有状态单例 Bean 的线程安全问题，常见的有两种解决办法：

1. 在 Bean 中尽量避免定义可变的成员变量。
2. 在类中定义一个 `ThreadLocal` 成员变量，将需要的可变成员变量保存在 `ThreadLocal` 中（推荐的一种方式）。

## 146. Bean 的生命周期

1. **创建 Bean 的实例**：Bean 容器首先会找到配置文件中的 Bean 定义，然后使用 Java 反射 API 来创建 Bean 的实例。
2. **Bean 属性赋值/填充**：为 Bean 设置相关属性和依赖，例如 `@Autowired` 等注解注入的对象、`@Resource` 注入的各种资源。
3. **Bean 初始化**：根据实现了哪些接口调用对应的方法，例如 Bean 实现了 `BeanNameAware` 接口，调用 `setBeanName()` 方法，传入 Bean 的名字。
4. **销毁 Bean**：把 Bean 的销毁方法先记录下来，将来需要销毁 Bean 或者销毁容器的时候，就调用这些方法去释放 Bean 所持有的资源。

## 147. Spring AOP 设计

AOP 能够将那些与业务无关，却为业务模块所共同调用的逻辑或责任（例如事务处理、日志管理、权限控制等）封装起来，便于减少系统的重复代码，降低模块间的耦合度，并有利于未来的可拓展性和可维护性。

Spring AOP 是基于动态代理的，如果要代理的对象，实现了某个接口，那么 Spring AOP 会使用 **JDK Proxy**,去创建代理对象，而对于没有实现接口的对象，就无法使用 JDK Proxy 去进行代理了，这时候 Spring AOP 会使用 **Cglib** 生成一个被代理对象的子类来作为代理。（**注：这是 Spring Framework 的默认行为；Spring Boot 2.0 起默认 `spring.aop.proxy-target-class=true`，即默认一律使用 CGLIB 代理，可通过配置改回 JDK 动态代理。**）

## 148. 多个切面的执行顺序如何控制

1. 通常使用 `@Order` 注解直接定义切面顺序

```java
// 值越小优先级越高
@Order(3)
@Component
@Aspect
public class LoggingAspect implements Ordered {
```

2. 实现 `Ordered` 接口重写 `getOrder` 方法。

```java
@Component
@Aspect
public class LoggingAspect implements Ordered {

    // ....

    @Override
    public int getOrder() {
        // 返回值越小优先级越高
        return 1;
    }
}
```

## 148. Spring MVC 工作流程

1. 客户端发送请求， `DispatcherServlet` 拦截请求。
2. `DispatcherServlet` 根据请求信息调用 `HandlerMapping` 。`HandlerMapping` 根据 URL 去匹配查找能处理的 `Handler`，并会将请求涉及到的拦截器和 `Handler` 一起封装。
3. `DispatcherServlet` 调用 `HandlerAdapter` 适配器执行 `Handler` 。
4. `Handler` 完成对用户请求的处理后，会返回一个 `ModelAndView` 对象给 `DispatcherServlet`。
5. `ViewResolver` 会根据逻辑 `View` 查找实际的 `View`。
6. `DispatcherServlet` 把返回的 `Model` 传给 `View`(视图渲染)。
7. 把 `View` 返回给请求者。

## 149. 统一异常处理怎么做

推荐使用使用到 `@ControllerAdvice` + `@ExceptionHandler` 这两个注解进行处理。

```java
@ControllerAdvice
@ResponseBody
public class GlobalExceptionHandler {

    @ExceptionHandler(BaseException.class)
    public ResponseEntity<?> handleAppException(BaseException ex, HttpServletRequest request) {
      //......
    }

    @ExceptionHandler(value = ResourceNotFoundException.class)
    public ResponseEntity<ErrorReponse> handleResourceNotFoundException(ResourceNotFoundException ex, HttpServletRequest request) {
      //......
    }
}
```

这种异常处理方式下，会给所有或者指定的 `Controller` 织入异常处理的逻辑（AOP），当 `Controller` 中的方法抛出异常的时候，由被 `@ExceptionHandler` 注解修饰的方法进行处理。

`ExceptionHandlerMethodResolver` 中 `getMappedMethod` 方法决定了异常具体被哪个被 `@ExceptionHandler` 注解修饰的方法处理异常。

**`getMappedMethod()` 会首先找到可以匹配处理异常的所有方法信息，然后对其进行从小到大的排序，最后取最小的那一个匹配的方法 (即匹配度最高的那个)。**

## 150. Spring 用了哪些设计模型

* **工厂设计模式** : Spring 使用工厂模式通过 `BeanFactory`、`ApplicationContext` 创建 bean 对象。
* **代理设计模式** : Spring AOP 功能的实现。
* **单例设计模式** : Spring 中的 Bean 默认都是单例的。
* **模板方法模式** : Spring 中 `jdbcTemplate`、`hibernateTemplate` 等以 Template 结尾的对数据库操作的类，它们就使用到了模板模式。
* **包装器设计模式** : 我们的项目需要连接多个数据库，而且不同的客户在每次访问中根据需要会去访问不同的数据库。这种模式让我们可以根据客户的需求能够动态切换不同的数据源。
* **观察者模式:** Spring 事件驱动模型就是观察者模式很经典的一个应用。
* **适配器模式** : Spring AOP 的增强或通知 (Advice) 使用到了适配器模式、Spring MVC 中也是用到了适配器模式适配 `Controller`。

## 150. Spring 三级缓存

1. **一级缓存（singletonObjects）**：存放最终形态的 Bean（已经实例化、属性填充、初始化）。一般情况我们获取 Bean 都是从这里单例池获取的，但是并不是所有的 Bean 都在单例池里面，例如原型 Bean 就不在里面。
2. **二级缓存（earlySingletonObjects）**：存放过渡 Bean（半成品，尚未属性填充），也就是三级缓存中 `ObjectFactory` 产生的对象，与三级缓存配合使用的，可以防止 AOP 的情况下，每次调用 `ObjectFactory#getObject()` 都是会产生新的代理对象的。
3. **三级缓存（singletonFactories）**：存放 `ObjectFactory`，`ObjectFactory` 的 `getObject()` 方法（最终调用的是 `getEarlyBeanReference()` 方法）可以生成原始 Bean 对象或者代理对象（如果 Bean 被 AOP 切面代理）。三级缓存只会对单例 Bean 生效。

&#x20;Spring 创建 Bean 的流程：

1. 先去 **一级缓存 `singletonObjects`** 中获取，存在就返回；
2. 如果不存在或者对象正在创建中，于是去 **二级缓存 `earlySingletonObjects`** 中获取；
3. 如果还没有获取到，就去 **三级缓存 `singletonFactories`** 中获取，通过执行 `ObjectFactory` 的 `getObject()` 就可以获取该对象，获取成功之后，从三级缓存移除，并将该对象加入到二级缓存中。

## 151. 如何解决 Spring 的循环依赖

Spring 框架通过使用三级缓存来解决这个问题，确保即使在循环依赖的情况下也能正确创建 Bean。

如果发生循环依赖的话，就去 **三级缓存 `singletonFactories`** 中拿到三级缓存中存储的 `ObjectFactory` 并调用它的 `getObject()` 方法来获取这个循环依赖对象的前期暴露对象（虽然还没初始化完成，但是可以拿到该对象在堆中的存储地址了），并且将这个前期暴露对象放到二级缓存中，这样在循环依赖时，就不会重复初始化了。

不过，这种机制也有一些缺点，比如增加了内存开销（需要维护三级缓存，也就是三个 Map）。并且，还有少部分情况是不支持循环依赖的，比如非单例的 bean 和 `@Async` 注解的 bean 无法支持循环依赖。

## 152. @Lazy 能解决循环依赖吗

如非必要，尽量不要用全局懒加载。全局懒加载会让 Bean 第一次使用的时候加载会变慢，并且它会延迟应用程序问题的发现（当 Bean 被初始化时，问题才会出现）。

有两个 Bean，A 和 B，他们之间发生了循环依赖，那么 A 的构造器上添加 `@Lazy` 注解之后（延迟 Bean B 的实例化），加载的流程如下：

* 首先 Spring 会去创建 A 的 Bean，创建时需要注入 B 的属性；
* 由于在 A 上标注了 `@Lazy` 注解，因此 Spring 会去创建一个 B 的代理对象，将这个代理对象注入到 A 中的 B 属性；
* 之后开始执行 B 的实例化、初始化，在注入 B 中的 A 属性时，此时 A 已经创建完毕了，就可以将 A 给注入进去。

通过 `@Lazy` 就解决了循环依赖的注入， 关键点就在于对 A 中的属性 B 进行注入时，注入的是 B 的代理对象，因此不会循环依赖。

之前说的发生循环依赖是因为在对 A 中的属性 B 进行注入时，注入的是 B 对象，此时又会去初始化 B 对象，发现 B 又依赖了 A，因此才导致的循环依赖。

## 153. SpringBoot 允许循环依赖发生吗

SpringBoot 2.6.x 以前是默认允许循环依赖的，SpringBoot 2.6.x 以后官方不再推荐编写存在循环依赖的代码。

SpringBoot 2.6.x 以后，如果你不想重构循环依赖的代码的话，也可以采用下面这些方法：

* 在全局配置文件中设置允许循环依赖存在：`spring.main.allow-circular-references=true`。
* 在导致循环依赖的 Bean 上添加 `@Lazy` 注解，这是一种比较推荐的方式。`@Lazy` 用来标识类是否需要懒加载/延迟加载，可以作用在类上、方法上、构造器上、方法参数上、成员变量中。

## 154. Spring 管理事务的方式有几种

* **编程式事务**：在代码中通过 `TransactionTemplate` 或 `TransactionManager` 手动控制事务边界，粒度可精确控制，适合事务边界复杂或动态的场景（代码侵入性强，需手动提交/回滚）。
* **声明式事务**：基于 `@Transactional` 注解或 XML 配置，由 AOP 代理实现，代码侵入小，适用于大多数业务场景（事务边界由方法注解范围决定、粒度较粗，且同类内部自调用会绕过代理导致事务失效）

## 155. Spring 事务中的隔离级别有哪几种

* **`TransactionDefinition.ISOLATION_DEFAULT`** : 使用后端数据库默认的隔离级别，MySQL 默认采用的可重复读隔离级别。
* **`TransactionDefinition.ISOLATION_READ_UNCOMMITTED`** : 最低的隔离级别，它允许读取尚未提交的数据变更，**可能会导致脏读、幻读或不可重复读**。
* **`TransactionDefinition.ISOLATION_READ_COMMITTED`** : 允许读取并发事务已经提交的数据，**可以阻止脏读，但是幻读或不可重复读仍有可能发生**。
* **`TransactionDefinition.ISOLATION_REPEATABLE_READ`** : 对同一字段的多次读取结果都是一致的，除非数据是被本身事务自己所修改，**可以阻止脏读和不可重复读，但幻读仍有可能发生。**
* **`TransactionDefinition.ISOLATION_SERIALIZABLE`** : 最高的隔离级别，所有的事务依次逐个执行，**该级别可以防止脏读、不可重复读以及幻读**。但是这将严重影响程序的性能。

## 156. @Transactional(rollbackFor = Exception.class) 注解

`@Transactional` 注解默认回滚策略是只有在遇到 `RuntimeException`(运行时异常) 或者 `Error` 时才会回滚事务，而不会回滚 `Checked Exception`（受检查异常）。这是因为 Spring 认为 `RuntimeException` 和 Error 是不可预期的错误，而受检异常是可预期的错误，可以通过业务逻辑来处理。

如果想要修改默认的回滚策略，可以使用 `@Transactional` 注解的 `rollbackFor` 和 `noRollbackFor` 属性来指定哪些异常需要回滚，哪些异常不需要回滚。

## 157. MinorGC、MajorGC、FullGC 的区别及触发场景

### Minor GC

* **定义**：Minor GC 是针对年轻代（Young Generation）进行的垃圾回收，主要清理 Eden 区和两个 Survivor 区（S0 和 S1）。
* **触发场景**：当 Eden 区满时，JVM 会触发 Minor GC 以释放空间。由于新对象通常在 Eden 区分配，因此如果对象的创建速度较快，Minor GC 会频繁触发。
* **特点**：
  * 采用标记 - 复制算法，清理过程不会导致内存碎片。
  * 触发时会导致所有应用线程暂停（Stop-the-World），但通常暂停时间较短。

### Major GC

* **定义**：Major GC 通常指针对老年代（Tenured Generation）的垃圾回收，虽然没有严格的定义，但它主要清理老年代中的对象。
* **触发场景**：Major GC 通常在 Minor GC 之后触发，尤其是当老年代内存不足以存放从年轻代晋升的对象时。
* **特点**：
  * 可能会使用并发标记 - 清除算法，减少对应用线程的影响。
  * 由于清理范围较大，暂停时间通常比 Minor GC 长。

### Full GC

* **定义**：Full GC 是对整个堆（包括年轻代和老年代）的垃圾回收。
* **触发场景**：Full GC 通常在以下情况下触发：
  * JVM 内存不足，无法为新对象分配空间。
  * 显式调用 System.gc() 方法。
  * Major GC 未能释放足够内存时。
* **特点**：
  * 清理范围最广，可能导致较长的暂停时间。
  * 可能会导致性能下降，尤其是在应用对响应时间敏感的情况下。

## 158. 在 Bean 加载/销毁前后实现特定逻辑的方法

* 实现 InitializingBean 和 DisposableBean 接口
* 使用 @PostConstruct 和 @PreDestroy 注解
* 在 XML 配置中指定 init-method 和 destroy-method

## 159. ConcurrentHashMap 怎么保证可见性

1. **volatile 修饰关键字段**：ConcurrentHashMap 中的一些关键字段使用 volatile 进行修饰。这些字段的修改对其他线程是可见的。
2. **volatile 保证内存可见性**：加上 volatile 关键字，其他线程就能立即看到这些字段的最新值。这就是内存可见性。

## 160. HashMap 中的 put 和 get

### `put` 方法的过程

1. **计算哈希值**：首先，通过 `key.hashCode()` 计算出键的哈希值。为了确保哈希值的均匀分布，Java 会使用扰动函数来减少冲突的概率。
2. **确定索引**：使用处理后的哈希值与数组长度进行位与运算，以确定在数组中的位置（索引）。
3. **检查位置**：检查该索引位置是否为空：
   * **如果为空**：直接将新的节点（`Entry<K,V>`）插入到该位置。
   * **如果不为空**：需要处理哈希冲突。遍历该位置的链表，检查是否存在相同的键：
     * **如果找到相同的键**：替换旧的值，并返回旧值。
     * **如果没有找到**：在链表的尾部插入新的节点。
4. **更新大小和扩容**：每次插入后，更新存储的元素数量，并检查是否超过扩容阈值（通常为当前容量的 75%），如超过则进行扩容。

### `get` 方法的过程

1. **计算哈希值**：与 `put` 方法相同，首先计算出键的哈希值。
2. **确定索引**：同样使用哈希值与数组长度进行位与运算，确定索引位置。
3. **查找节点**：在该索引位置查找对应的节点：
   * 遍历链表，查找与给定键相等的节点。
   * **如果找到**：返回该节点的值。
   * **如果未找到**：返回 `null`。

## 161. 双端检索单例

```java
public class Singleton {
    private static volatile Singleton instance;

    private Singleton() {}

    public static Singleton getInstance() {
        if (instance == null) {
            synchronized (Singleton.class) {
                if (instance == null) {
                    instance = new Singleton();
                }
            }
        }
        return instance;
    }
}
```

### 注意事项

1. 使用 `volatile` 关键字修饰实例变量，防止指令重排序导致的错误
2. 第一次检查减少不必要的同步开销
3. 第二次检查避免多个线程同时创建实例

### 优化方案

双重检查锁在某些情况下可能会存在隐患。更安全的方式是使用静态内部类实现单例

```java
public class Singleton {
    private Singleton() {}

    private static class SingletonHolder {
        private static final Singleton INSTANCE = new Singleton();
    }

    public static Singleton getInstance() {
        return SingletonHolder.INSTANCE;
    }
}
```

这种方式利用了 Java 的类加载机制来保证线程安全。当 `Singleton` 类被加载时，内部类 `SingletonHolder` 不会被加载。只有当 `getInstance()` 方法第一次被调用时，`SingletonHolder` 才会被加载，此时才会创建唯一实例。这种方式既保证了线程安全，又避免了同步带来的性能开销。

## 162. 父类和子类的初始化顺序

**父类静态块** → **子类静态块** → **父类初始化块** → **父类构造方法** → **子类初始化块** → **子类构造方法**

这种结构确保了在创建子类实例时，父类的所有静态和非静态初始化操作都已完成，这样可以保证子类能够正确地继承和使用父类的属性和方法。

## 163. SpringBoot 的启动流程

### 启动入口

每个 Spring Boot 应用都有一个主入口，通常是一个带有 `@SpringBootApplication` 注解的类，其 `main` 方法调用 `SpringApplication.run()` 方法启动应用。

### 启动流程

1. **调用 main 方法**：应用程序从 `main` 方法开始执行，调用 `SpringApplication.run()` 方法。
2. **创建 SpringApplication 实例**：在 `run` 方法中，首先创建一个 `SpringApplication` 对象，这个对象负责整个应用的启动过程。
3. **准备环境**：`SpringApplication` 会准备应用的环境，包括加载配置文件和设置属性。
4. **加载初始化器和监听器**：加载所有的 `ApplicationContextInitializer` 和 `ApplicationListener`，这些组件可以在应用启动的不同阶段执行自定义逻辑。
5. **创建应用上下文**：创建 `ConfigurableApplicationContext` 实例，通常是 `AnnotationConfigApplicationContext` 或 `WebApplicationContext`。
6. **刷新上下文**：调用上下文的 `refresh()` 方法，执行以下操作：
   * 加载所有的 Bean 定义。
   * 实例化所有的单例 Bean。
   * 在刷新过程中发布 `ContextRefreshedEvent` 等事件（`ApplicationEnvironmentPreparedEvent` 在环境准备阶段发布，`ApplicationContextInitializedEvent` 在上下文创建后、刷新前发布）。
7. **调用 CommandLineRunner 和 ApplicationRunner**：在应用上下文刷新完成后，Spring Boot 会执行所有实现了 `CommandLineRunner` 和 `ApplicationRunner` 接口的 Bean，这些 Bean 可以用来在应用启动后执行特定逻辑。
8. **发布应用启动事件**：最后，发布 `ApplicationReadyEvent` 事件，表示应用已准备好接收请求。

## 164. Java 多态体现形式

1. **编译时多态（静态多态）**：通过方法重载（overloading）实现，即同一方法名根据参数类型或数量的不同，调用不同的方法。
2. **运行时多态（动态多态）**：通过方法重写（overriding）实现，即子类重写父类的方法，运行时根据对象的实际类型调用相应的方法。

### 实现机制

Java 通过**方法表**（Method Table）来实现动态绑定。每个类在加载时，JVM 会为其创建一个方法表，记录该类及其父类的方法信息。当调用一个方法时，JVM 会根据对象的实际类型查找方法表，找到对应的方法执行。

## 165. SpringMVC 的工作流程

1. **用户请求**: 用户通过浏览器发送请求，首先到达前端控制器 `DispatcherServlet`。
2. **处理器映射**: `DispatcherServlet` 接收到请求后，调用 `HandlerMapping` 来查找与请求 URL 对应的处理器（Controller）。
3. **处理器适配**: 一旦找到处理器，`DispatcherServlet` 会调用 `HandlerAdapter` 来适配处理器，以执行具体的处理逻辑。
4. **执行处理器**: `HandlerAdapter` 执行找到的 Controller，并返回一个 `ModelAndView` 对象，这个对象包含了模型数据和视图信息。
5. **视图解析**: `DispatcherServlet` 将 `ModelAndView` 传递给 `ViewResolver`，后者解析出具体的视图（例如 JSP 页面）。
6. **视图渲染**: 最后，`DispatcherServlet` 将模型数据填充到视图中，并将结果返回给用户。

## 166. Maven 依赖仲裁与运行时类加载顺序

### Maven 依赖仲裁（构建期，版本冲突解决）

Maven 遇到传递依赖中的版本冲突时，按以下规则仲裁（官方文档）：

1. **最短路径优先**：依赖路径最短的版本胜出。例如 A→B→C→D 1.0 与 A→D 2.0，取 D 2.0（路径更短）。
2. **路径相同时先声明者优先**：如果两个版本路径长度相同，则在 POM 中先声明的依赖胜出。
3. **`dependencyManagement` 显式声明优先**：在父 POM 的 `dependencyManagement` 中锁定的版本，会覆盖传递依赖解析出的版本（这也是统一版本管理的主要手段）。
4. **与版本号大小无关**：仲裁只依据依赖路径，不看版本号大小（版本 9.0 不一定胜过 1.0，取决于路径）。

### 运行时类加载顺序（ClassLoader 父委托机制）

JVM 的类加载采用**父委托机制（Parent Delegation）**：类加载请求先交给父加载器，父加载器能加载就用父的，只有父无法加载时才由子加载器尝试。加载器层级：

```
Bootstrap ClassLoader（启动类加载器，JVM 实现，加载 JRE/lib）
   ↓ 委托
Extension / Platform ClassLoader（JDK 9 起更名 Platform）
   ↓ 委托
App ClassLoader（应用类加载器，加载 classpath）
   ↓ 委托
自定义 ClassLoader
```

因此多个 JAR 出现同名类冲突时，**先被父加载器命中的版本生效**（如启动类加载器加载的 JDK 类总是优先于应用 classpath 中的同名类）；应用层的同名类则取决于 App ClassLoader 的加载顺序，与构建期 Maven 仲裁是两回事。

## 167. SOLID 设计原则

SOLID 是面向对象设计的五个基本原则的首字母缩写:

* **S**ingle Responsibility Principle (单一职责原则)
* **O**pen/Closed Principle (开闭原则)
* **L**iskov Substitution Principle (里氏替换原则)
* **I**nterface Segregation Principle (接口隔离原则)
* **D**ependency Inversion Principle (依赖倒置原则)

这五个原则旨在使软件设计更加清晰、灵活和可维护。遵循这些原则可以让代码更加健壮和稳定。

## 168. volatile 原理

1. 可见性

* **强制刷新主内存**：当一个线程写入一个 volatile 变量时，Java 虚拟机会确保这个值被立即写入主内存，而不是仅仅保存在线程的工作内存中。
* **失效其他线程的缓存**：写入 volatile 变量后，其他线程的工作内存中对应的变量值会失效，确保下次读取时会从主内存中获取最新值。

2. 有序性

volatile 通过禁止指令重排序来保证操作的有序性。Java 内存模型通过内存屏障（Memory Barriers）来实现这一点：

* 在 volatile 写操作之前，插入 StoreStore 屏障，确保当前的写操作不会与之前的写操作重排序。
* 在 volatile 写操作之后，插入 StoreLoad 屏障，确保当前的写操作在后续的读操作之前完成。
* 在 volatile 读操作之后，插入 LoadLoad 和 LoadStore 屏障，确保当前的读操作不会与后续的读写操作重排序。

## 169. synchronized 锁膨胀

synchronized 锁的升级过程是从无锁到偏向锁,再到轻量级锁,最后到重量级锁。这个过程也被称为锁膨胀。

### 无锁

一开始 synchronized 锁是无锁状态,对象的 MarkWord 存储的是对象的 hashcode、age 等信息。

### 偏向锁

当一个线程访问同步块并获取锁时,会将对象头中的 MarkWord 设置为指向当前线程的偏向锁。以后该线程进入和退出同步块时不需要 CAS 操作来加锁和解锁,只需简单地测试一下 MarkWord 是否指向当前线程即可。

### 轻量级锁

当有另一个线程想要获取锁时,偏向锁就会升级为轻量级锁。轻量级锁使用 CAS 操作来加锁和解锁,不需要申请操作系统资源。

### 重量级锁

当自旋超过一定次数或者有其他线程在等待该锁时,轻量级锁就会升级为重量级锁。重量级锁会让线程阻塞,使用操作系统的 mutex lock 来实现。

## 170. 创建对象的四种方式

在 Java 中，创建对象主要有以下四种方式：

### 1. 使用 `new` 关键字

这是最常见的创建对象的方法。通过调用类的构造器，可以创建对象的实例。示例代码如下：

```java
User user = new User();
```

### 2. 使用反射机制

反射机制允许在运行时动态创建对象。可以使用 `Class` 类的 `newInstance()` 方法，或者使用 `Constructor` 类的 `newInstance()` 方法来创建对象。示例代码如下：

```java
Class<User> userClass = User.class;
User user = userClass.newInstance(); // 调用无参构造器

Constructor<User> constructor = userClass.getConstructor(String.class);
User userWithArgs = constructor.newInstance("张三"); // 调用有参构造器
```

### 3. 使用 `clone()` 方法

通过实现 `Cloneable` 接口并重写 `clone()` 方法，可以使用克隆机制创建对象。此方法不会调用构造器。示例代码如下：

```java
public class User implements Cloneable {
    @Override
    protected Object clone() throws CloneNotSupportedException {
        return super.clone();
    }
}

// 使用克隆
User user1 = new User();
User user2 = (User) user1.clone();
```

### 4. 通过序列化和反序列化

对象可以通过序列化存储到文件中，然后通过反序列化读取文件来创建对象。示例代码如下：

```java
ObjectOutputStream out = new ObjectOutputStream(new FileOutputStream("user.ser"));
out.writeObject(user);
out.close();

ObjectInputStream in = new ObjectInputStream(new FileInputStream("user.ser"));
User userFromFile = (User) in.readObject();
in.close();
```

## 171. Java 基本数据类型

| 数据类型    | 字节数 | 范围                                                        | 默认值      |
| ------- | --- | --------------------------------------------------------- | -------- |
| byte    | 1   | -128 to 127                                               | 0        |
| short   | 2   | -32,768 to 32,767                                         | 0        |
| int     | 4   | -2,147,483,648 to 2,147,483,647                           | 0        |
| long    | 8   | -9,223,372,036,854,775,808 to 9,223,372,036,854,775,807   | 0L       |
| float   | 4   | ±3.40282347E+38 (6-7 significant decimal digits)          | 0.0f     |
| double  | 8   | ±1.79769313486231570E+308 (15 significant decimal digits) | 0.0d     |
| char    | 2   | 0 to 65,535                                               | '\u0000' |
| boolean | 1   | true or false                                             | false    |

## 172. 如何设置可以使 JVM 进行频繁的 Full GC

* 使用 `-XX:NewRatio` 参数来设置新生代和老年代的比例。默认情况下，这个比例是 2，表示新生代: 老年代=1:2。可以通过减小老年代的大小来增加 Full GC 的频率，因为老年代的空间不足会导致 Full GC 的发生。
* 使用 `-XX:PretenureSizeThreshold` 参数，可以设置一个偏小的阈值，使得超过该大小的对象直接分配到老年代，从而增加 Full GC 的发生频率。

## 173. 一个线程 OOM，进程里其他线程还能运行吗

当一个 Java 进程中的某个线程发生了 OutOfMemoryError(OOM) 时，通常情况下只有该线程会被终止，其他线程不会受到直接影响，仍可以继续运行。


# Python

## 1. 列表和元组的内部实现

list 本质上是一个 over-allocate 的 array。tuple 和 list 相似，本质也是一个 array，但是空间大小固定。不同于一般 array，Python 的 tuple 做了许多优化，来提升在程序中的效率。

举个例子，当 tuple 的大小不超过 20 时（对应 CPython 源码中的 PyTuple\_MAXSAVESIZE=20，Python 3.7～3.14 均如此），Python 就会把它缓存在内部的一个 free list 中，且每种大小的 tuple 最多缓存 2000 个（PyTuple\_MAXFREELIST）。这样，如果你以后需要再去创建同样大小的 tuple，Python 就可以直接从缓存中载入，提高了程序运行效率。

## 2. Python 中对象的比较和拷贝

* 比较操作符'=='表示比较对象间的值是否相等，而'is'表示比较对象的标识是否相等，即它们是否指向同一个内存地址。
* 比较操作符'is'效率优于'=='，因为'is'操作符无法被重载，执行'is'操作只是简单的获取对象的 ID，并进行比较；而'=='操作符则会递归地遍历对象的所有值，并逐一比较（对 list、dict 等容器类型而言；自定义类型则取决于其 **eq** 的实现，默认回退为身份比较）。
* 浅拷贝中的元素，是原对象中子对象的引用，因此，如果原对象中的元素是可变的，改变其也会影响拷贝后的对象，存在一定的副作用。
* 深度拷贝则会递归地拷贝原对象中的每一个子对象，因此拷贝后的对象和原对象互不相关。另外，深度拷贝中会维护一个字典，记录已经拷贝的对象及其 ID，来提高效率并防止无限递归的发生。

## 3. 变量及其赋值的基本原理

Python 中参数的传递既不是值传递，也不是引用传递，而是赋值传递，或者是叫对象的引用传递。

需要注意的是，这里的赋值或对象的引用传递，实质是让变量名绑定到一个具体的对象（CPython 中 `id(obj)` 返回的就是对象的内存地址，变量本身是对象表中的一个引用）。

* 如果对象是可变的，当其改变时，所有指向这个对象的变量都会改变。
* 如果对象不可变，简单的赋值只能改变其中一个变量的值，其余变量则不受影响。

清楚了这一点，如果你想通过一个函数来改变某个变量的值，通常有两种方法。一种是直接将可变数据类型（比如列表，字典，集合）当作参数传入，直接在其上修改；第二种则是创建一个新变量，来保存修改后的值，然后将其返回给原变量。在实际工作中，我们更倾向于使用后者，因为其表达清晰明了，不易出错。

## 4. 装饰器的使用场景

所谓的装饰器，其实就是通过装饰器函数，来修改原函数的一些功能，使得原函数不需要修改。

而实际工作中，装饰器通常运用在身份认证、日志记录、输入合理性检查以及缓存等多个领域中。合理使用装饰器，往往能极大地提高程序的可读性以及运行效率。

## 5. 迭代器和生成器

* 容器是可迭代对象，可迭代对象调用 iter() 函数，可以得到一个迭代器。迭代器可以通过 next() 函数来得到下一个元素，从而支持遍历。
* 生成器是一种特殊的迭代器（注意这个逻辑关系反之不成立）。使用生成器，你可以写出来更加清晰的代码；合理使用生成器，可以降低内存占用、优化程序结构、提高程序速度。
* 生成器在 Python 2 的版本上，是协程的一种重要实现方式；而 Python 3.5 引入 async await 语法糖后，生成器实现协程的方式就已经落后了。我们会在下节课，继续深入讲解 Python 协程。

## 6. Python 的协程

* 协程和多线程的区别，主要在于两点，一是协程为单线程；二是协程由用户决定，在哪些地方交出控制权，切换到下一个任务。
* 协程的写法更加简洁清晰，把 async / await 语法和 create\_task 结合来用，对于中小级别的并发需求已经毫无压力。
* 写协程程序的时候，你的脑海中要有清晰的事件循环概念，知道程序在什么时候需要暂停、等待 I/O，什么时候需要一并执行到底。

## 7. Asyncio 解析

不同于多线程，Asyncio 是单线程的，但其内部 event loop 的机制，可以让它并发地运行多个不同的任务，并且比多线程享有更大的自主控制权。

Asyncio 中的任务，在运行过程中不会被打断（单线程事件循环、协作式调度），因此不会出现线程间抢占式切换导致的 data race。但注意，这不代表完全没有 race condition：协程在 await 处会让出控制权，多个任务在 await 点交错执行时仍可能产生逻辑上的竞态（如先检查后修改共享状态），共享状态仍需加锁或使用 asyncio.Lock 等同步原语（官方文档亦如此建议）。尤其是在 I/O 操作 heavy 的场景下，Asyncio 比多线程的运行效率更高。因为 Asyncio 内部任务切换的损耗，远比线程切换的损耗要小；并且 Asyncio 可以开启的任务数量，也比多线程中的线程数量多得多。

## 8. CPython 引入 GIL 的原因

* 一是设计者为了规避类似于内存管理这样的复杂的竞争风险问题（race condition）；
* 二是因为 CPython 大量使用 C 语言库，但大部分 C 语言库都不是原生线程安全的（线程安全会降低性能和增加复杂度）。
* 版本说明：以上是 GIL 存在的历史原因。Python 3.13 起（PEP 703）支持 free-threaded 构建，可在编译时通过 --disable-gil 禁用 GIL，使多线程真正并行利用多核；该特性目前仍处于实验阶段（转正标准见 PEP 779），默认构建依然启用 GIL。

## 9. Python GC

* 垃圾回收是 Python 自带的机制，用于自动释放不会再用到的内存空间；
* 引用计数是其中最简单的实现，不过切记，仅有引用计数并不足够（它不是充分条件），因为循环引用无法仅靠引用计数回收，需要通过不可达判定来确定是否可以回收；
* Python 的自动回收算法包括标记清除和分代收集，主要针对的是循环引用的垃圾收集；


# C++

## 1. 预处理阶段能做什么

* 预处理不属于 C++ 语言，过多的预处理语句会扰乱正常的代码，除非必要，应当少用慎用；
* “#include”可以包含任意文件，所以可以写一些小的代码片段，再引进程序里；
* 头文件应该加上“Include Guard”，防止重复包含；
* “#define”用于宏定义，非常灵活，但滥用文本替换可能会降低代码的可读性；
* “条件编译”其实就是预处理编程里的分支语句，可以改变源码的形态，针对系统生成最合适的代码。

## 2. 编译阶段能做什么

* “属性”相当于编译阶段的“标签”，用来标记变量、函数或者类，让编译器发出或者不发出警告，还能够手工指定代码的优化方式。
* 标准属性不多，常用的有 \[\[noreturn]]（C++11）、\[\[deprecated]]（C++14）、\[\[nodiscard]]、\[\[fallthrough]]、\[\[maybe\_unused]]（C++17），C++20 又增加了 \[\[likely]]、\[\[unlikely]]、\[\[no\_unique\_address]]，C++23 增加 \[\[assume]]；我们也可以使用非官方的属性（如 gnu:: 前缀），需要加上名字空间限定。
* static\_assert 是“静态断言”，在编译阶段计算常数和类型，如果断言失败就会导致编译错误。它也是迈向模板元编程的第一步。
* 和运行阶段的“动态断言”一样，static\_assert 可以在编译阶段定义各种前置条件，充分利用 C++ 静态类型语言的优势，让编译器执行各种检查，避免把隐患带到运行阶段。

## 3. 面向对象编程

* “面向对象编程”是一种设计思想，要点是“抽象”和“封装”，“继承”“多态”是衍生出的特性，不完全符合现实世界。
* 在 C++ 里应当少用继承和虚函数，降低对象的成本，绕过那些难懂易错的陷阱。
* 使用特殊标识符“final”可以禁止类被继承，简化类的层次关系。
* 类有六大基本函数，对于重要的构造 / 析构函数，可以使用“= default”来显式要求编译器使用默认实现。
* “委托构造”和“成员变量初始化”特性可以让创建对象的工作更加轻松。
* 使用 using 或 typedef 可以为类型起别名，既能够简化代码，还能够适应将来的变化。

## 4. 自动类型推导

* “自动类型推导”是给编译器下的指令，让编译器去计算表达式的类型，然后返回给程序员。
* auto 用于初始化时的类型推导，总是“值类型”，也可以加上修饰符产生新类型。它的规则比较好理解，用法也简单，应该积极使用。
* decltype 使用类似函数调用的形式计算表达式的类型，能够用在任意场合，因为它就是 一个编译阶段的类型。
* decltype 能够推导出表达式的精确类型，但写起来比较麻烦，在初始化时可以采用 decltype(auto) 的简化形式。
* 因为 auto 和 decltype 不是“硬编码”的类型，所以用好它们可以让代码更清晰，减少后期维护的成本。

## 5. const & volatile & mutable

1. const

* 它是一个类型修饰符，可以给任何对象附加上“只读”属性，保证安全；
* 它可以修饰引用和指针，“const &”可以引用任何类型，是函数入口参数的最佳类型；
* 它还可以修饰成员函数，表示函数是“只读”的，const 对象只能调用 const 成员函数。

2. volatile

* 它表示变量可能会被“不被察觉”地修改，禁止编译器优化，影响性能，应当少用。

3. mutable

* 它用来修饰成员变量，允许 const 成员函数修改，mutable 变量的变化不影响对象的常量性，但要小心不要误用损坏对象。

尽可能多用 const，让代码更安全

## 6. 智能指针到底智能在哪里

* 智能指针使用 RAII 技术包装裸指针，能够自动释放内存，无需程序员干预，所以被称为“智能指针”；主流归类是 RAII 惯用法，也有观点视其为代理模式的具体应用（代理裸指针的所有权与访问）。
* 如果指针是“独占”使用，就应该选择 unique\_ptr，它为裸指针添加了很多限制，更加安全。
* 如果指针是“共享”使用，就应该选择 shared\_ptr，它的功能非常完善，用法几乎与原始指针一样。
* 应当使用工厂函数 make\_unique()、make\_shared() 来创建智能指针，强制初始化，而且还能使用 auto 来简化声明。
* shared\_ptr 有少量的管理成本，也会引发一些难以排查的错误，所以不要过度使用。

## 7. C++ 中的异常处理

* 异常是针对错误码的缺陷而设计的，它不能被忽略，而且可以“穿透”调用栈，逐层传播到其他地方去处理；
* 使用 try-catch 机制处理异常，能够分离正常流程与错误处理流程，让代码更清晰；
* throw 可以抛出任何类型作为异常，但最好使用标准库里定义的 exception 类；
* 完全用或不用异常处理错误都不可取，而是应该合理分析，适度使用，降低异常的成本；
* 关键字 noexcept 标记函数不抛出异常，可以让编译器做更好的优化。

## 8. lambda 表达式

* lambda 表达式是一个闭包，能够像函数一样被调用，像变量一样被传递；
* 可以使用 auto 自动推导类型存储 lambda 表达式，但 C++ 鼓励尽量就地匿名使用，缩小作用域；
* lambda 表达式使用“\[=]”的方式按值捕获，使用“\[&]”的方式按引用捕获，空的“\[]”则是无捕获（也就相当于普通函数）；
* 捕获引用时必须要注意外部变量的生命周期，防止变量失效；
* C++14 里可以使用泛型的 lambda 表达式，相当于简化的模板函数；C++20 又允许显式写出模板参数列表（模板 lambda）。

## 9. C++ 中的字符串

* C++ 支持多种字符类型，常用的 string 其实是模板类 basic\_string 的特化形式；
* C++ 对 Unicode 的支持在逐步完善（C++11 引入 char16\_t/char32\_t 与 UTF-8 字符串字面量，C++20 引入 UTF-8 专用类型 char8\_t 与 std::format 格式化库，C++23 增加 std::print/std::println），但标准库仍缺少大小写转换、规范化等完整的 Unicode 处理设施，实际项目中建议尽量避开国际化和编码转化，必要时借助第三方库；
* 应当把 string 视为一个完整的字符串来操作，不要把它当成容器来使用；
* 字面量后缀“s”表示字符串类，可以用来自动推导出 string 类型；
* 原始字符串不会转义，是字符串的原始形态，适合在代码里写复杂的文本；

## 10. 详解 C++ 容器

* 标准容器可以分为三大类，即顺序容器、有序容器和无序容器；
* 所有容器中最优先选择的应该是 array 和 vector，它们的速度最快，开销最低；
* list 是链表结构，插入删除的效率高，但查找效率低；
* 有序容器（map/set）对 key 自动排序，查找效率高，但有插入成本；标准只要求对数复杂度、并未强制底层数据结构，主流实现（libstdc++、libc++、MSVC）均为红黑树；
* 无序容器是散列表结构，由 hash 值计算存储位置，查找和插入的成本都很低；
* 有序容器和无序容器都属于关联容器，元素有 key 的概念，操作元素实际上是在操作 key，所以要定义对 key 的比较函数或者散列函数。

## 11. 多线程编程

* 多线程是并发最常用的实现方式，好处是任务并行、避免阻塞，坏处是开发难度高，有数据竞争、死锁等很多“坑”；
* call\_once() 实现了仅调用一次的功能，避免多线程初始化时的冲突；
* thread\_local 实现了线程局部存储，让每个线程都独立访问数据，互不干扰；
* atomic 实现了原子化变量，可以用作线程安全的计数器，也可以实现无锁数据结构；
* async() 启动一个异步任务，相当于开了一个线程，但内部通常会有优化，比直接使用线程更好。


# Rust

## 基础

### 1. 为何要手动设置变量的可变性？

* 灵活性
* 安全性
* 除了以上两个优点，还有一个很大的优点，那就是运行性能上的提升，因为将本身无需改变的变量声明为不可变在运行期会避免一些多余的 `runtime` 检查。

> 争议点：「不可变可避免运行时检查」这一说法在社区存在争议：一派（如 rust-course 的表述）认为不可变声明能在运行期省去一些多余的检查；另一派指出 `mut` 只是编译期概念，借用检查在编译期完成，是否 `mut` 生成的机器码通常相同（优化后性能一致），不可变的主要收益是安全性与可推导性而非直接的运行时开销。

### 2. 变量绑定

为何不用赋值而用绑定呢（其实你也可以称之为赋值，但是绑定的含义更清晰准确）？这里就涉及 Rust 最核心的原则——**所有权**，简单来讲，任何内存对象都是有主人的，而且一般情况下完全属于它的主人，绑定就是把这个对象绑定给一个变量，让这个变量成为它的主人。

### 3. 变量可变性

Rust 的变量在默认情况下是**不可变的**。这让我们编写的代码更安全，性能也更好。当然你可以通过 `mut` 关键字让变量变为**可变的**，让设计更灵活。

### 4. 使用下划线开头忽略未使用的变量

有时创建一个不会被使用的变量是有用的，比如你正在设计原型或刚刚开始一个项目。这时**你希望告诉 Rust 不要警告未使用的变量，为此可以用下划线作为变量名的开头**。

### 5. 常量与变量的差异

* 常量不允许使用 `mut`。**常量不仅仅默认不可变，而且自始至终不可变**，因为常量在编译完成后，已经确定它的值。
* 常量使用 `const` 关键字而不是 `let` 关键字来声明，并且值的类型**必须**标注。

```rust
const MAX_POINTS: u32 = 100_000;
```

### 6. 变量遮蔽

```rust
fn main() {
    let x = 5;
    // 在main函数的作用域内对之前的x进行遮蔽
    let x = x + 1;

    {
        // 在当前的花括号作用域内，对之前的x进行遮蔽
        let x = x * 2;
        println!("The value of x in the inner scope is: {}", x);
    }

    println!("The value of x is: {}", x);
}

// The value of x in the inner scope is: 12 
// The value of x is: 6
```

### 7. 整型溢出

* 使用 `wrapping_*` 方法在所有模式下都按照补码循环溢出规则处理，例如 `wrapping_add`
* 如果使用 `checked_*` 方法时发生溢出，则返回 `None` 值
* 使用 `overflowing_*` 方法返回该值和一个指示是否存在溢出的布尔值
* 使用 `saturating_*` 方法使值达到最小值或最大值

```rust
fn main() {
    let a : u8 = 255;
    let b = a.wrapping_add(20);
    println!("{}", b);  // 19
}
```

### 8. 浮点数陷阱

* 避免在浮点数上测试相等性
* 当结果在数学上可能存在未定义时，需要格外的小心

```rust
fn main() {
  // 断言0.1 + 0.2与0.3相等
  assert!(0.1 + 0.2 == 0.3);
}

// panic
```

因为二进制精度问题，导致了 0.1 + 0.2 并不严格等于 0.3，它们可能在小数点 N 位后存在误差。

### 9. NaN

对于数学上未定义的结果，例如对负数取平方根 `(-42.1_f32).sqrt()` ，会产生一个特殊的结果：Rust 的浮点数类型使用 `NaN` (not a number)来处理这些情况。**所有跟 `NaN` 交互的操作，都会返回一个 `NaN`**，而且 `NaN` 不能用来比较。

> 出于防御性编程的考虑，可以使用 `is_nan()` 等方法，可以用来判断一个数值是否是 `NaN` 。

### 10. 语句与表达式

语句会执行一些操作但是不会返回一个值，而表达式会在求值后返回一个值，因此在上述函数体的三行代码中，前两行是语句，最后一行是表达式。

```rust
fn add_with_extra(x: i32, y: i32) -> i32 {
    let x = x + 1; // 语句
    let y = y + 5; // 语句
    x + y // 表达式
}
```

**表达式不能包含分号**。这一点非常重要，一旦你在表达式后加上分号，它就会变成一条语句，再也**不会**返回一个值，请牢记！

### 11. 发散函数

当用 `!` 作函数返回类型的时候，表示该函数永不返回( diverge function )，特别的，这种语法往往用做会导致程序崩溃的函数：

```rust
fn forever() -> ! {
  loop {
    //...
  };
}
```

### 12. 所有权原则

1. Rust 中每一个值都被一个变量所拥有，该变量被称为值的所有者
2. 一个值同时只能被一个变量所拥有，或者说一个值只能拥有一个所有者
3. 当所有者 (变量)离开作用域范围时，这个值将被丢弃 (drop)

```rust
let s1 = String::from("hello");
let s2 = s1;

println!("{}, world!", s1);

// error[E0382]: use of moved value: `s1`
```

### 13. 深拷贝（克隆）

如果我们**确实**需要深度复制 `String` 中堆上的数据，而不仅仅是栈上的数据，可以使用一个叫做 `clone` 的方法。

```rust
let s1 = String::from("hello");
let s2 = s1.clone();

println!("s1 = {}, s2 = {}", s1, s2);
```

### 14. Copy 特征

**任何基本类型的组合可以 `Copy` ，不需要分配内存或某种形式资源的类型是可以 `Copy` 的**。如下是一些 `Copy` 的类型：

* 所有整数类型，比如 `u32`
* 布尔类型，`bool`，它的值是 `true` 和 `false`
* 所有浮点数类型，比如 `f64`
* 字符类型，`char`
* 元组，当且仅当其包含的类型也都是 `Copy` 的时候。比如，`(i32, i32)` 是 `Copy` 的，但 `(i32, String)` 就不是
* 不可变引用 `&T` ，例如转移所有权中的最后一个例子，**但是注意: 可变引用 `&mut T` 是不可以 Copy 的**

### 15. 可变引用与不可变引用

```rust
fn main() {
    let s = String::from("hello");

    change(&s);
}

fn change(some_string: &String) {
    some_string.push_str(", world");
}

// error[E0596]: cannot borrow `*some_string` as mutable, as it is behind a `&` reference
```

声明 `s` 是可变类型，其次创建一个可变的引用 `&mut s` 和接受可变引用参数 `some_string: &mut String` 的函数。

```rust
fn main() {
    let mut s = String::from("hello");

    change(&mut s);
}

fn change(some_string: &mut String) {
    some_string.push_str(", world");
}
```

***可变引用同时只能存在一个***

```rust
let mut s = String::from("hello");

let r1 = &mut s;
let r2 = &mut s;

println!("{}, {}", r1, r2);

// error[E0499]: cannot borrow `s` as mutable more than once at a time
```

***可变引用与不可变引用不能同时存在***

```rust
let mut s = String::from("hello");

let r1 = &s;
let r2 = &s;
let r3 = &mut s;

println!("{}, {}, and {}", r1, r2, r3);

// error[E0502]: cannot borrow `s` as mutable because it is also borrowed as immutable
```

> 注意，引用的作用域 `s` 从创建开始，一直持续到它最后一次使用的地方，这个跟变量的作用域有所不同，变量的作用域从创建持续到某一个花括号 `}`

### 16. 悬垂引用

悬垂引用也叫做悬垂指针，意思为指针指向某个值后，这个值被释放掉了，而指针仍然存在，其指向的内存可能不存在任何值或已被其它变量重新使用。

```rust
fn main() {
    let reference_to_nothing = dangle();
}

fn dangle() -> &String {
    let s = String::from("hello");

    &s
} // 这里 s 离开作用域并被丢弃。其内存被释放。
```

因为 `s` 是在 `dangle` 函数内创建的，当 `dangle` 的代码执行完毕后，`s` 将被释放，但是此时我们又尝试去返回它的引用。这意味着这个引用会指向一个无效的 `String`。

其中一个很好的解决方法是直接返回 `String`：

```rust
fn no_dangle() -> String {
    let s = String::from("hello");

    s
}
```

这样就没有任何错误了，最终 `String` 的 **所有权被转移给外面的调用者**。

### 17. 字符串切片

字符串切片是非常危险的操作，因为切片的索引是通过字节来进行，但是字符串又是 UTF-8 编码，因此你无法保证索引的字节刚好落在字符的边界上。

```rust
let hello = "中国人";

let s = &hello[0..2];
```

我们索引的字节落在了 `中` 字符的内部，程序在运行时会直接 panic（字节索引 2 不是合法的字符边界）。

### 18. 字符串深度剖析

* 首先向操作系统请求内存来存放 `String` 对象
* 在使用完成后，将内存释放，归还给操作系统

对于第二点，就是百家齐放的环节，在有**垃圾回收 GC** 的语言中，GC 来负责标记并清除这些不再使用的内存对象，这个过程都是自动完成，无需开发者关心，非常简单好用；但是在无 GC 的语言中，需要开发者手动去释放这些内存对象，就像创建对象需要通过编写代码来完成一样，未能正确释放对象造成的后果简直不可估量。

对于 Rust 而言，如果使用 GC，那么会牺牲性能；如果使用手动管理内存，那么会牺牲安全，这该怎么办？为此，Rust 的开发者想出了一个无比惊艳的办法：变量在离开作用域后，就自动释放其占用的内存。

### 19. 结构体的所有权

把结构体中具有所有权的字段转移出去后，将无法再访问该字段，但是可以正常访问其它的字段。

### 20. Option 枚举用于处理空值

在对 `Option<T>` 进行 `T` 的运算之前必须将其转换为 `T`。通常这能帮助我们捕获到空值最常见的问题之一：期望某值不为空但实际上为空的情况。

为了拥有一个可能为空的值，你必须要显式的将其放入对应类型的 `Option<T>` 中。接着，当使用这个值时，必须明确的处理值为空的情况。只要一个值不是 `Option<T>` 类型，你就 **可以** 安全的认定它的值不为空。

### 21. 想在循环中获取元素的索引

```rust
fn main() {
    let a = [4, 3, 2, 1];
    // `.iter()` 方法把 `a` 数组变成一个迭代器
    for (i, v) in a.iter().enumerate() {
        println!("第{}个元素是{}", i + 1, v);
    }
}
```

### 22. for ... in 的使用方法

| 使用方法                          | 等价使用方式                                            | 所有权   |
| ----------------------------- | ------------------------------------------------- | ----- |
| `for item in collection`      | `for item in IntoIterator::into_iter(collection)` | 转移所有权 |
| `for item in &collection`     | `for item in collection.iter()`                   | 不可变借用 |
| `for item in &mut collection` | `for item in collection.iter_mut()`               | 可变借用  |

### 23. Match 模式匹配

* `match` 的匹配必须要穷举出所有可能，因此这里用 `_` 来代表未列出的所有可能性
* `match` 的每一个分支都必须是一个表达式，且所有分支的表达式最终返回值的类型必须相同
* **X | Y**，类似逻辑运算符 `或`，代表该分支可以匹配 `X` 也可以匹配 `Y`，只要满足一个即可

其实 `match` 跟其他语言中的 `switch` 非常像，`_` 类似于 `switch` 中的 `default`。

### 24. If let 匹配

```rust
if let Some(3) = v {
    println!("three");
}
```

**当你只要匹配一个条件，且忽略其他条件时就用 `if let` ，否则都用 `match`**。

### 25. matches! 宏

```rust
let foo = 'f';
assert!(matches!(foo, 'A'..='Z' | 'a'..='z'));

let bar = Some(4);
assert!(matches!(bar, Some(x) if x > 2));
```

### 26. 匹配守卫

**匹配守卫**（*match guard*）是一个位于 `match` 分支模式之后的额外 `if` 条件，它能为分支模式提供更进一步的匹配条件。

这个条件可以使用模式中创建的变量：

```rust
let num = Some(4);

match num {
    Some(x) if x < 5 => println!("less than five: {}", x),
    Some(x) => println!("{}", x),
    None => (),
}
```

### 27. @绑定

`@`（读作 at）运算符允许为一个字段绑定另外一个变量。下面例子中，我们希望测试 `Message::Hello` 的 `id` 字段是否位于 `3..=7` 范围内，同时也希望能将其值绑定到 `id_variable` 变量中以便此分支中相关的代码可以使用它。

```rust
enum Message {
    Hello { id: i32 },
}

let msg = Message::Hello { id: 5 };

match msg {
    Message::Hello { id: id_variable @ 3..=7 } => {
        println!("Found an id in range: {}", id_variable)
    },
    Message::Hello { id: 10..=12 } => {
        println!("Found an id in another range")
    },
    Message::Hello { id } => {
        println!("Found some other id: {}", id)
    },
}
```

当你既想要限定分支范围，又想要使用分支的变量时，就可以用 `@` 来绑定到一个新的变量上，实现想要的功能。

#### @前绑定后解构

使用 `@` 还可以在绑定新变量的同时，对目标进行解构：

```rust
#[derive(Debug)]
struct Point {
    x: i32,
    y: i32,
}

fn main() {
    // 绑定新变量 `p`，同时对 `Point` 进行解构
    let p @ Point {x: px, y: py } = Point {x: 10, y: 23};
    println!("x: {}, y: {}", px, py);
    println!("{:?}", p);


    let point = Point {x: 10, y: 5};
    if let p @ Point {x: 10, y} = point {
        println!("x is 10 and y is {} in {:?}", y, p);
    } else {
        println!("x was not 10 :(");
    }
}
```

### 28. 为具体的泛型类型实现方法

对于 `Point<T>` 类型，你不仅能定义基于 `T` 的方法，还能针对特定的具体类型，进行方法定义：

```rust
impl Point<f32> {
    fn distance_from_origin(&self) -> f32 {
        (self.x.powi(2) + self.y.powi(2)).sqrt()
    }
}
```

这段代码意味着 `Point<f32>` 类型会有一个方法 `distance_from_origin`，而其他 `T` 不是 `f32` 类型的 `Point<T>` 实例则没有定义此方法。这个方法计算点实例与坐标 `(0.0, 0.0)` 之间的距离，并使用了只能用于浮点型的数学运算符。

### 29. 泛型的性能

在 Rust 中泛型是零成本的抽象，意味着你在使用泛型时，完全不用担心性能上的问题。

但是任何选择都是权衡得失的，Rust 是在编译期为泛型对应的多个类型，生成各自的代码，因此损失了编译速度和增大了最终生成文件的大小。

### 30. const 泛型

针对类型实现的泛型，所有的泛型都是为了抽象不同的类型，const 泛型则是针对值的泛型。

```rust
fn display_array<T: std::fmt::Debug, const N: usize>(arr: [T; N]) {
    println!("{:?}", arr);
}
fn main() {
    let arr: [i32; 3] = [1, 2, 3];
    display_array(arr);

    let arr: [i32; 2] = [1, 2];
    display_array(arr);
}
```

如上所示，我们定义了一个类型为 `[T; N]` 的数组，其中 `T` 是一个基于类型的泛型参数，这个和之前讲的泛型没有区别，而重点在于 `N` 这个泛型参数，它是一个基于值的泛型参数！因为它用来替代的是数组的长度。

### 31. 特征孤儿规则

**如果你想要为类型** `A` **实现特征** `T`**，那么** `A` **或者** `T` **至少有一个是在当前作用域中定义的**。

### 32. 使用特征作为函数参数

```rust
pub fn notify(item: &impl Summary) {
    println!("Breaking news! {}", item.summarize());
}
```

`impl Summary`，顾名思义，它的意思是 **实现了 `Summary` 特征** 的 `item` 参数。

你可以使用任何实现了 `Summary` 特征的类型作为该函数的参数，同时在函数体内，还可以调用该特征的方法，例如 `summarize` 方法。

### 33. 多重约束

除了单个约束条件，我们还可以指定多个约束条件，例如除了让参数实现 `Summary` 特征外，还可以让参数实现 `Display` 特征以控制它的格式化输出：

```rust
pub fn notify(item: &(impl Summary + Display)) {}
```

除了上述的语法糖形式，还能使用特征约束的形式：

```rust
pub fn notify<T: Summary + Display>(item: &T) {}
```

通过这两个特征，就可以使用 `item.summarize` 方法，以及通过 `println!("{}", item)` 来格式化输出 `item`。

### 34. Where 约束

```rust
fn some_function<T, U>(t: &T, u: &U) -> i32
    where T: Display + Clone,
          U: Clone + Debug
{}
```

### 35. 使用条件约束有条件地实现方法或特征

```rust
use std::fmt::Display;

struct Pair<T> {
    x: T,
    y: T,
}

impl<T> Pair<T> {
    fn new(x: T, y: T) -> Self {
        Self {
            x,
            y,
        }
    }
}

impl<T: Display + PartialOrd> Pair<T> {
    fn cmp_display(&self) {
        if self.x >= self.y {
            println!("The largest member is x = {}", self.x);
        } else {
            println!("The largest member is y = {}", self.y);
        }
    }
}
```

`cmp_display` 方法，并不是所有的 `Pair<T>` 结构体对象都可以拥有，只有 `T` 同时实现了 `Display + PartialOrd` 的 `Pair<T>` 才可以拥有此方法。该函数可读性会更好，因为泛型参数、参数、返回值都在一起，可以快速的阅读，同时每个泛型参数的特征也在新的代码行中通过**特征约束**进行了约束。

**也可以有条件地实现特征**, 例如，标准库为任何实现了 `Display` 特征的类型实现了 `ToString` 特征：

```rust
impl<T: Display> ToString for T {
    // --snip--
}
```

### 36. 通过 derive 派生特征

形如 `#[derive(Debug)]` 的代码，这种是一种特征派生语法，被 `derive` 标记的对象会自动实现对应的默认特征代码，继承相应的功能。

例如 `Debug` 特征，它有一套自动实现的默认代码，当你给一个结构体标记后，就可以使用 `println!("{:?}", s)` 的形式打印该结构体的对象。

### 37. 特征对象

可以通过 `&` 引用或者 `Box<T>` 智能指针的方式来创建特征对象。

```rust
trait Draw {
    fn draw(&self) -> String;
}

impl Draw for u8 {
    fn draw(&self) -> String {
        format!("u8: {}", *self)
    }
}

impl Draw for f64 {
    fn draw(&self) -> String {
        format!("f64: {}", *self)
    }
}

// 若 T 实现了 Draw 特征， 则调用该函数时传入的 Box<T> 可以被隐式转换成函数参数签名中的 Box<dyn Draw>
fn draw1(x: Box<dyn Draw>) {
    // 由于实现了 Deref 特征，Box 智能指针会自动解引用为它所包裹的值，然后调用该值对应的类型上定义的 `draw` 方法
    x.draw();
}

fn draw2(x: &dyn Draw) {
    x.draw();
}

fn main() {
    let x = 1.1f64;
    // do_something(&x);
    let y = 8u8;

    // x 和 y 的类型 T 都实现了 `Draw` 特征，因为 Box<T> 可以在函数调用时隐式地被转换为特征对象 Box<dyn Draw> 
    // 基于 x 的值创建一个 Box<f64> 类型的智能指针，指针指向的数据被放置在了堆上
    draw1(Box::new(x));
    // 基于 y 的值创建一个 Box<u8> 类型的智能指针
    draw1(Box::new(y));
    draw2(&x);
    draw2(&y);
}
```

上面代码，有几个非常重要的点：

* `draw1` 函数的参数是 `Box<dyn Draw>` 形式的特征对象，该特征对象是通过 `Box::new(x)` 的方式创建的
* `draw2` 函数的参数是 `&dyn Draw` 形式的特征对象，该特征对象是通过 `&x` 的方式创建的
* `dyn` 关键字只用在特征对象的类型声明上，在创建时无需使用 `dyn`

因此，可以使用特征对象来代表泛型或具体的类型。

### 38. 特征对象的动态分发

泛型是在编译期完成处理的：编译器会为每一个泛型参数对应的具体类型生成一份代码，这种方式是**静态分发(static dispatch)**，因为是在编译期完成的，对于运行期性能完全没有任何影响。

与静态分发相对应的是**动态分发(dynamic dispatch)**，在这种情况下，直到运行时，才能确定需要调用什么方法。之前代码中的关键字 `dyn` 正是在强调这一“动态”的特点。

### 39. Self 和 self

在 Rust 中，有两个 `self`，一个指代当前的实例对象，一个指代特征或者方法类型的别名：

```rust
trait Draw {
    fn draw(&self) -> Self;
}

#[derive(Clone)]
struct Button;
impl Draw for Button {
    fn draw(&self) -> Self {
        return self.clone()
    }
}

fn main() {
    let button = Button;
    let newb = button.draw();
}
```

上述代码中，`self` 指代的就是当前的实例对象，也就是 `button.draw()` 中的 `button` 实例，`Self` 则指代的是 `Button` 类型。

### 40. 特征对象的限制

不是所有特征都能拥有特征对象，只有对象安全的特征才行。当一个特征的所有方法都有如下属性时，它的对象才是安全的：

* 方法的返回类型不能是 `Self`
* 方法没有任何泛型参数

### 41. 关联类型

使用泛型，你将得到以下的代码：

```rust
trait Container<A,B> {
    fn contains(&self,a: A,b: B) -> bool;
}

fn difference<A,B,C>(container: &C) -> i32
  where
    C : Container<A,B> {...}
```

可以看到，由于使用了泛型，导致函数头部也必须增加泛型的声明，而使用关联类型，将得到可读性好得多的代码：

```rust
trait Container{
    type A;
    type B;
    fn contains(&self, a: &Self::A, b: &Self::B) -> bool;
}

fn difference<C: Container>(container: &C) {}
```

### 42. 完全限定语法

```rust
fn main() {
    println!("A baby dog is called a {}", <Dog as Animal>::baby_name());
}
```

在尖括号中，通过 `as` 关键字，我们向 Rust 编译器提供了类型注解，也就是 `Animal` 就是 `Dog`，而不是其他动物，因此最终会调用 `impl Animal for Dog` 中的方法

### 43. 特征定义中的特征约束

有时，我们会需要让某个特征 A 能使用另一个特征 B 的功能(另一种形式的特征约束)，这种情况下，不仅仅要为类型实现特征 A，还要为类型实现特征 B 才行，这就是 `supertrait` (实在不知道该如何翻译，有大佬指导下嘛？)

例如有一个特征 `OutlinePrint`，它有一个方法，能够对当前的实现类型进行格式化输出：

```rust
use std::fmt::Display;

trait OutlinePrint: Display {
    fn outline_print(&self) {
        let output = self.to_string();
        let len = output.len();
        println!("{}", "*".repeat(len + 4));
        println!("*{}*", " ".repeat(len + 2));
        println!("* {} *", output);
        println!("*{}*", " ".repeat(len + 2));
        println!("{}", "*".repeat(len + 4));
    }
}
```

### 44. NewType

在特征章节中，有提到孤儿规则，简单来说，就是特征或者类型必需至少有一个是本地的，才能在此类型上定义特征。

这里提供一个办法来绕过孤儿规则，那就是使用 **newtype 模式**。就是为一个元组结构体创建新类型。该元组结构体封装有一个字段，该字段就是希望实现特征的具体类型。

下面来看一个例子，我们有一个动态数组类型： `Vec<T>`，它定义在标准库中，还有一个特征 `Display`，它也定义在标准库中，如果没有 `newtype`，我们是无法为 `Vec<T>` 实现 `Display` 的：

```rust
use std::fmt;

struct Wrapper(Vec<String>);

impl fmt::Display for Wrapper {
    fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
        write!(f, "[{}]", self.0.join(", "))
    }
}

fn main() {
    let w = Wrapper(vec![String::from("hello"), String::from("world")]);
    println!("w = {}", w);
}
```

注意到我们怎么访问里面的数组吗？`self.0.join(", ")`，是的，很啰嗦，因为需要先从 `Wrapper` 中取出数组: `self.0`，然后才能执行 `join` 方法。

类似的，任何数组上的方法，你都无法直接调用，需要先用 `self.0` 取出数组，然后再进行调用。

Rust 提供了一个特征叫 `Deref`，实现该特征后，可以自动做一层类似类型转换的操作，可以将 `Wrapper` 变成 `Vec<String>` 来使用。这样就会像直接使用数组那样去使用 `Wrapper`，而无需为每一个操作都添加上 `self.0`。

同时，如果不想 `Wrapper` 暴露底层数组的所有方法，我们还可以为 `Wrapper` 去重载这些方法，实现隐藏的目的。

### 45. 存储不同类型的元素

数组的元素必须类型相同，但是也提到了解决方案：那就是通过使用枚举类型和特征对象来实现不同类型元素的存储。先来看看通过枚举如何实现：

```rust
#[derive(Debug)]
enum IpAddr {
    V4(String),
    V6(String)
}
fn main() {
    let v = vec![
        IpAddr::V4("127.0.0.1".to_string()),
        IpAddr::V6("::1".to_string())
    ];

    for ip in v {
        show_addr(ip)
    }
}

fn show_addr(ip: IpAddr) {
    println!("{:?}",ip);
}
```

数组 `v` 中存储了两种不同的 `ip` 地址，但是这两种都属于 `IpAddr` 枚举类型的成员，因此可以存储在数组中。

再来看看特征对象的实现：

```rust
trait IpAddr {
    fn display(&self);
}

struct V4(String);
impl IpAddr for V4 {
    fn display(&self) {
        println!("ipv4: {:?}",self.0)
    }
}
struct V6(String);
impl IpAddr for V6 {
    fn display(&self) {
        println!("ipv6: {:?}",self.0)
    }
}

fn main() {
    let v: Vec<Box<dyn IpAddr>> = vec![
        Box::new(V4("127.0.0.1".to_string())),
        Box::new(V6("::1".to_string())),
    ];

    for ip in v {
        ip.display();
    }
}
```

比枚举实现要稍微复杂一些，我们为 `V4` 和 `V6` 都实现了特征 `IpAddr`，然后将它俩的实例用 `Box::new` 包裹后，存在了数组 `v` 中，需要注意的是，这里必须手动地指定类型：`Vec<Box<dyn IpAddr>>`，表示数组 `v` 存储的是特征 `IpAddr` 的对象，这样就实现了在数组中存储不同的类型。

在实际使用场景中，**特征对象数组要比枚举数组常见很多**，主要原因在于特征对象非常灵活，而编译器对枚举的限制较多，且无法动态增加类型。

### 46. 函数签名中的生命周期标注

从两个字符串切片中返回较长的那个

```rust
fn longest<'a>(x: &'a str, y: &'a str) -> &'a str {
    if x.len() > y.len() {
        x
    } else {
        y
    }
}
```

* 和泛型一样，使用生命周期参数，需要先声明 `<'a>`
* `x`、`y` 和返回值至少活得和 `'a` 一样久 (因为返回值要么是 `x`，要么是 `y`)

### 47. 结构体中的生命周期

```rust
struct ImportantExcerpt<'a> {
    part: &'a str,
}

fn main() {
    let novel = String::from("Call me Ishmael. Some years ago...");
    let first_sentence = novel.split('.').next().expect("Could not find a '.'");
    let i = ImportantExcerpt {
        part: first_sentence,
    };
}
```

`ImportantExcerpt` 结构体中有一个引用类型的字段 `part`，因此需要为它标注上生命周期。结构体的生命周期标注语法跟泛型参数语法很像，需要对生命周期参数进行声明 `<'a>`。该生命周期标注说明，**结构体 `ImportantExcerpt` 所引用的字符串 `str` 必须比该结构体活得更久**。

### 48. 生命周期消除

1. **每一个引用参数都会获得独自的生命周期**

   例如一个引用参数的函数就有一个生命周期标注: `fn foo<'a>(x: &'a i32)`，两个引用参数的有两个生命周期标注:`fn foo<'a, 'b>(x: &'a i32, y: &'b i32)`, 依此类推。
2. **若只有一个输入生命周期(函数参数中只有一个引用类型)，那么该生命周期会被赋给所有的输出生命周期**，也就是所有返回值的生命周期都等于该输入生命周期

   例如函数 `fn foo(x: &i32) -> &i32`，`x` 参数的生命周期会被自动赋给返回值 `&i32`，因此该函数等同于 `fn foo<'a>(x: &'a i32) -> &'a i32`
3. **若存在多个输入生命周期，且其中一个是 `&self` 或 `&mut self`，则 `&self` 的生命周期被赋给所有的输出生命周期**

   拥有 `&self` 形式的参数，说明该函数是一个 `方法`，该规则让方法的使用便利度大幅提升。

### 49. 生命周期约束

* `'a: 'b`，是生命周期约束语法，跟泛型约束非常相似，用于说明 `'a` 必须比 `'b` 活得久
* 可以把 `'a` 和 `'b` 都在同一个地方声明（如上），或者分开声明但通过 `where 'a: 'b` 约束生命周期关系，如下：

```rust
impl<'a> ImportantExcerpt<'a> {
    fn announce_and_return_part<'b>(&'a self, announcement: &'b str) -> &'b str
    where
        'a: 'b,
    {
        println!("Attention please: {}", announcement);
        self.part
    }
}
```

### 50. 静态生命周期

在 Rust 中有一个非常特殊的生命周期，那就是 `'static`，拥有该生命周期的引用可以和整个程序活得一样久。

```rust
let s: &'static str = "我没啥优点，就是活得久，嘿嘿";
```

### 51. 线程 panic 后，程序是否会终止

如果是 `main` 线程，则程序会终止，如果是其它子线程，该线程会终止，但是不会影响 `main` 线程。因此，尽量不要在 `main` 线程中做太多任务，将这些任务交由子线程去做，就算子线程 `panic` 也不会导致整个程序的结束。

### 52. 对返回的错误进行处理

直接 `panic` 还是过于粗暴，因为实际上 IO 的错误有很多种，我们需要对部分错误进行特殊处理，而不是所有错误都直接崩溃：

```rust
use std::fs::File;
use std::io::ErrorKind;

fn main() {
    let f = File::open("hello.txt");

    let f = match f {
        Ok(file) => file,
        Err(error) => match error.kind() {
            ErrorKind::NotFound => match File::create("hello.txt") {
                Ok(fc) => fc,
                Err(e) => panic!("Problem creating the file: {:?}", e),
            },
            other_error => panic!("Problem opening the file: {:?}", other_error),
        },
    };
}
```

上面代码在匹配出 `error` 后，又对 `error` 进行了详细的匹配解析，最终结果：

* 如果是文件不存在错误 `ErrorKind::NotFound`，就创建文件，这里创建文件`File::create` 也是返回 `Result`，因此继续用 `match` 对其结果进行处理：创建成功，将新的文件句柄赋值给 `f`，如果失败，则 `panic`
* 剩下的错误，一律 `panic`

### 53. 失败就 panic: unwrap 和 expect

```rust
use std::fs::File;

fn main() {
    let f = File::open("hello.txt").unwrap();
}
```

如果调用这段代码时 *hello.txt* 文件不存在，那么 `unwrap` 就将直接 `panic` `expect` 跟 `unwrap` 很像，也是遇到错误直接 `panic`, 但是会带上自定义的错误提示信息，相当于重载了错误打印的函数：

```rust
use std::fs::File;

fn main() {
    let f = File::open("hello.txt").expect("Failed to open hello.txt");
}
```

### 54. ? 运算符

```rust
use std::fs::File;
use std::io;
use std::io::Read;

fn read_username_from_file() -> Result<String, io::Error> {
    let mut f = File::open("hello.txt")?;
    let mut s = String::new();
    f.read_to_string(&mut s)?;
    Ok(s)
}
```

其实 `?` 并不是宏，而是语言内建的问号运算符（Rust 1.13 起稳定，取代了此前的 `try!` 宏），它的作用跟下面的 `match` 等价

```rust
let mut f = match f {
    // 打开文件成功，将file句柄赋值给f
    Ok(file) => file,
    // 打开文件失败，将错误返回(向上传播)
    Err(e) => return Err(e),
};
```

### 55. ? 用于 Option 的返回

```rust
fn first(arr: &[i32]) -> Option<&i32> {
   let v = arr.get(0)?;
   Some(v)
}
```

上面的函数中，`arr.get` 返回一个 `Option<&i32>` 类型，因为 `?` 的使用，如果 `get` 的结果是 `None`，则直接返回 `None`，如果是 `Some(&i32)`，则把里面的值赋给 `v`。

### 56. 易混淆的 Package 和包

牢记 `Package` 是一个项目工程，而包只是一个编译单元，基本上也就不会混淆这个两个概念了：`src/main.rs` 和 `src/lib.rs` 都是编译单元，因此它们都是包。

### 57. 结构体和枚举的可见性

* 将结构体设置为 `pub`，但它的所有字段依然是私有的
* 将枚举设置为 `pub`，它的所有字段也将对外可见

## 进阶

TODO


# 计算机网络

## 1. 键入网址到网页显示，期间发生了什么？

* 解析 URL：首先浏览器要解析 URL，URL 包括协议 + 主机名 + 端口号 + 路径，继而生成发送给 web 服务器的、对该资源的请求信息。
* DNS 解析：查询服务器域名对应的 IP 地址。DNS 服务器保存了 Web 服务器域名与 IP 的对应关系。PS：查询时先在浏览器、操作系统、hosts 文件依次看有没有缓存，不一定每次都要解析 IP。
* TCP 连接：HTTP 报文是基于 TCP 传输的，涉及到三次握手。组装好 TCP 报文后交给网络层处理。
* 发送 HTTP 请求：
  1. 加入 IP 头部，生成 IP 报文
  2. 加入 MAC 头部。（发送方 MAC 地址在网卡里，接收方的 MAC 地址靠 ARP 协议在以太网广播寻找，当然也是有缓存的）
  3. 再经过网卡、交换机、路由器，抵达服务器
* 服务器处理请求并返回 HTTP 报文：服务器依次检查 MAC 头部、IP 头、TCP 头检查序列号和端口号，HTTP 进程收到后把这个网页封装在 HTTP 响应报文里并返回（相同步骤）。
* 浏览器解析渲染页面：浏览器是一个边解析边渲染的过程。
* 关闭 TCP 连接

## 2. UDP 与 TCP 的区别

1. 连接

TCP 是面向连接的传输层协议，传输数据前先要建立连接。

UDP 是不需要连接，即刻传输数据。

2. 服务对象

TCP 是一对一的两点服务，即一条连接只有两个端点。

UDP 支持一对一、一对多、多对多的交互通信

3. 可靠性

TCP 是可靠交付数据的，数据可以无差错、不丢失、不重复、按序到达。

UDP 是尽最大努力交付，不保证可靠交付数据。但是我们可以基于 UDP 传输协议实现一个可靠的传输协议，比如 QUIC 协议。

4. 拥塞控制、流量控制

TCP 有拥塞控制和流量控制机制，保证数据传输的安全性。

UDP 则没有，即使网络非常拥堵了，也不会影响 UDP 的发送速率。

5. 首部开销

TCP 首部长度较长，会有一定的开销，首部在没有使用「选项」字段时是 20 个字节，如果使用了「选项」字段则会变长的。

UDP 首部只有 8 个字节，并且是固定不变的，开销较小。

6. 传输方式

* **TCP** 将数据分成数据流，使用字节流进行传输，不保证数据包的边界。
* **UDP** 将数据封装成数据报，每个数据报都是独立的，保留了数据的边界。

7. 分片不同

TCP 的数据大小如果大于 MSS 大小，则会在传输层进行分片，目标主机收到后，也同样在传输层组装 TCP 数据包，如果中途丢失了一个分片，只需要传输丢失的这个分片。

UDP 的数据大小如果大于 MTU 大小，则会在 IP 层进行分片，目标主机收到后，在 IP 层组装完数据，接着再传给传输层。

## 3. 如何唯一确定一个 TCP 连接

TCP 四元组可以唯一的确定一个连接

* 源地址
* 源端口
* 目的地址
* 目的端口

源地址和目的地址的字段（32 位）是在 IP 头部中，作用是通过 IP 协议发送报文给对方主机。

源端口和目的端口的字段（16 位）是在 TCP 头部中，作用是告诉 TCP 协议应该把报文发给哪个进程。

## 4. 为什么是三次握手？不是两次、四次？

* 三次握手才可以阻止重复历史连接的初始化（主要原因）
* 三次握手才可以同步双方的初始序列号
  * 序列号能够保证数据包不重复、不丢弃和按序传输。
* 三次握手才可以避免资源浪费

「两次握手」：无法防止历史连接的建立，会造成双方资源的浪费，也无法可靠的同步双方序列号；

「四次握手」：三次握手就已经理论上最少可靠连接建立，所以不需要使用更多的通信次数。

## 5. 为什么每次建立 TCP 连接时，初始化的序列号都要求不一样呢？

* 为了防止历史报文被下一个相同四元组的连接接收（主要方面）；
* 为了安全性，防止黑客伪造的相同序列号的 TCP 报文被对方接收；

## 6. 既然 IP 层会分片，为什么 TCP 层还需要 MSS 呢？

当如果一个 IP 分片丢失，整个 IP 报文的所有分片都得重传。因为 IP 层本身没有超时重传机制，它由传输层的 TCP 来负责超时和重传。因此，可以得知由 IP 层进行分片传输，是非常没有效率的。

经过 TCP 层分片后，如果一个 TCP 分片丢失后，进行重发时也是以 MSS 为单位，而不用重传所有的分片，大大增加了重传的效率。

## 7. 握手丢失三部曲

### 第一次握手丢失

客户端迟迟收不到服务端的 SYN-ACK 报文（第二次握手），就会触发「超时重传」机制，重传 SYN 报文，而且重传的 SYN 报文的序列号都是一样的。

客户端的 SYN 报文最大重传次数由 `tcp_syn_retries` 内核参数控制，这个参数是可以自定义的，默认值一般是 5。

通常，第一次超时重传是在 1 秒后，第二次超时重传是在 2 秒，第三次超时重传是在 4 秒后，第四次超时重传是在 8 秒后，第五次是在超时重传 16 秒后。没错，每次超时的时间是上一次的 2 倍。

### 第二次握手丢失

* 客户端会触发超时重传机制，重传 SYN 报文。
* 服务端这边会触发超时重传机制，重传 SYN-ACK 报文。

因此，当第二次握手丢失了，客户端和服务端都会重传：

客户端会重传 SYN 报文，也就是第一次握手，最大重传次数由 `tcp_syn_retries` 内核参数决定； 服务端会重传 SYN-ACK 报文，也就是第二次握手，最大重传次数由 `tcp_synack_retries` 内核参数决定。

### 第三次握手丢失

第三次握手丢失了，如果服务端那一方迟迟收不到这个确认报文，就会触发超时重传机制，重传 SYN-ACK 报文，直到收到第三次握手，或者达到最大重传次数。

注意，ACK 报文是不会有重传的，当 ACK 丢失了，就由对方重传对应的报文。

## 8. 什么是 SYN 攻击？如何避免 SYN 攻击？

SYN 攻击方式最直接的表现就会把 TCP 半连接队列打满，这样当 TCP 半连接队列满了，后续再收到 SYN 报文就会丢弃，导致客户端无法和服务端建立连接。

避免 SYN 攻击方式，可以有以下方法：

* 增大 TCP 半连接队列；
* 开启 tcp\_syncookies；
* 减少 SYN+ACK 重传次数

## 9. 为什么挥手需要四次？

* 关闭连接时，客户端向服务端发送 FIN 时，仅仅表示客户端不再发送数据了但是还能接收数据。
* 服务端收到客户端的 FIN 报文时，先回一个 ACK 应答报文，而服务端可能还有数据需要处理和发送，等服务端不再发送数据时，才发送 FIN 报文给客户端来表示同意现在关闭连接。

从上面过程可知，服务端通常需要等待完成数据的发送和处理，所以服务端的 ACK 和 FIN 一般都会分开发送，因此是需要四次挥手。

## 10. 挥手丢失四部曲

### 第一次挥手丢失

如果第一次挥手丢失了，那么客户端迟迟收不到被动方的 ACK 的话，也就会触发超时重传机制，重传 FIN 报文，重发次数由 `tcp_orphan_retries` 参数控制。

### 第二次挥手丢失

ACK 报文是不会重传的，所以如果服务端的第二次挥手丢失了，客户端就会触发超时重传机制，重传 FIN 报文，直到收到服务端的第二次挥手，或者达到最大的重传次数。

### 第三次挥手丢失

内核会发出 FIN 报文，同时连接进入 LAST\_ACK 状态，等待客户端返回 ACK 来确认连接关闭。

如果迟迟收不到这个 ACK，服务端就会重发 FIN 报文，重发次数仍然由 `tcp_orphan_retries` 参数控制，这与客户端重发 FIN 报文的重传次数控制方式是一样的。

如果客户端是通过 `close` 函数关闭连接的，处于 FIN\_WAIT\_2 状态是有时长限制的，如果 `tcp_fin_timeout` 时间内还是没能收到服务端的第三次挥手（FIN 报文），那么客户端就会断开连接。

### 第四次挥手丢失

如果第四次挥手的 ACK 报文没有到达服务端，服务端就会重发 FIN 报文，重发次数仍然由前面介绍过的 `tcp_orphan_retries` 参数控制。

客户端在收到第三次挥手后，就会进入 TIME\_WAIT 状态，开启时长为 2MSL 的定时器，如果途中再次收到第三次挥手（FIN 报文）后，就会重置定时器，当等待 2MSL 时长后，客户端就会断开连接。

## 11. 为什么 TIME\_WAIT 等待的时间是 2MSL？

TTL 的值一般是 64，Linux 将 MSL 设置为 30 秒，意味着 Linux 认为数据报文经过 64 个路由器的时间不会超过 30 秒，如果超过了，就认为报文已经消失在网络中了。

例如被动关闭方没有收到断开连接的最后的 ACK 报文，就会触发超时重发 FIN 报文，另一方接收到 FIN 后，会重发 ACK 给被动关闭方， 一来一去正好 2 个 MSL。可以看到 2MSL 时长 这其实是相当于至少允许报文丢失一次。

为什么不是 4 或者 8 MSL 的时长呢？你可以想象一个丢包率达到百分之一的糟糕网络，连续两次丢包的概率只有万分之一，这个概率实在是太小了，忽略它比解决它更具性价比。

## 12. 为什么需要 TIME\_WAIT 状态？

主动发起关闭连接的一方，才会有 TIME-WAIT 状态。

* 防止历史连接中的数据，被后面相同四元组的连接错误的接收。2MSL 时间足以让两个方向上的数据包都被丢弃，使得原来连接的数据包在网络中都自然消失。
* 保证「被动关闭连接」的一方，能被正确的关闭；

## 13. TIME\_WAIT 过多有什么危害？

* 第一是占用系统资源，比如文件描述符、内存资源、CPU 资源、线程资源等；
* 第二是占用端口资源，端口资源也是有限的，一般可以开启的端口为 32768 ～ 60999（不同内核版本/发行版略有差异，旧内核曾为 61000）。

## 14. 如何优化 TIME\_WAIT？

* 打开 net.ipv4.tcp\_tw\_reuse 和 net.ipv4.tcp\_timestamps 选项；
* net.ipv4.tcp\_max\_tw\_buckets
* 程序中使用 SO\_LINGER ，应用强制使用 RST 关闭。

> 当设置 `SO_LINGER` 的超时时间为 0，那么 socket 将立即关闭，并且未发送的数据将被丢弃，这会通过发送一个 RST 包来强制关闭连接。

## 15. 服务器出现大量 TIME\_WAIT 状态的原因有哪些？

* 第一个场景：HTTP 没有使用长连接
* 第二个场景：HTTP 长连接超时
* 第三个场景：HTTP 长连接的请求数量达到上限
  * 调大 nginx 的 keepalive\_requests 参数就行。

## 16. 服务器出现大量 CLOSE\_WAIT 状态的原因有哪些？

当服务端出现大量 CLOSE\_WAIT 状态的连接的时候，说明服务端的程序没有调用 `close` 函数关闭连接。

## 17. 如果已经建立了连接，但是客户端突然出现故障了怎么办？

TCP 有保活机制。如果开启了 TCP 保活，需要考虑以下几种情况：

* 第一种，对端程序是正常工作的。当 TCP 保活的探测报文发送给对端, 对端会正常响应，这样 TCP 保活时间会被重置，等待下一个 TCP 保活时间的到来。
* 第二种，对端主机宕机并重启。当 TCP 保活的探测报文发送给对端后，对端是可以响应的，但由于没有该连接的有效信息，会产生一个 RST 报文，这样很快就会发现 TCP 连接已经被重置。
* 第三种，是对端主机宕机，当 TCP 保活的探测报文发送给对端后，石沉大海，没有响应，连续几次，达到保活探测次数后，TCP 会报告该 TCP 连接已经死亡。

## 18. 如果已经建立了连接，但是服务端的进程崩溃会发生什么？

TCP 的连接信息是由内核维护的，所以当服务端的进程崩溃后，内核需要回收该进程的所有 TCP 连接资源，于是内核会发送第一次挥手 FIN 报文，后续的挥手过程也都是在内核完成，并不需要进程的参与，所以即使服务端的进程退出了，还是能与客户端完成 TCP 四次挥手的过程。

## 19. 重传机制

### 超时重传

重传机制的其中一个方式，就是在发送数据时，设定一个定时器，当超过指定的时间后，没有收到对方的 ACK 确认应答报文，就会重发该数据，也就是我们常说的超时重传。

TCP 会在以下两种情况发生超时重传：

* 数据包丢失
* 确认应答丢失

超时重传时间 RTO 的值应该略大于报文往返 RTT 的值。

每当遇到一次超时重传的时候，都会将下一次超时时间间隔设为先前值的两倍。两次超时，就说明网络环境差，不宜频繁反复发送。

### 快速重传

快速重传的工作方式是当收到三个相同的 ACK 报文时，会在定时器过期之前，重传丢失的报文段。

快速重传机制只解决了一个问题，就是超时时间的问题，但是它依然面临着另外一个问题。就是重传的时候，是重传一个，还是重传所有的问题。

### SACK 方法

这种方式需要在 TCP 头部「选项」字段里加一个 SACK，它可以将已收到的数据的信息发送给「发送方」，这样发送方就可以知道哪些数据收到了，哪些数据没收到，知道了这些信息，就可以只重传丢失的数据。

### Duplicate SACK

主要使用了 SACK 来告诉「发送方」有哪些数据被重复接收了。

* 可以让「发送方」知道，是发出去的包丢了，还是接收方回应的 ACK 包丢了;
* 可以知道是不是「发送方」的数据包被网络延迟了;
* 可以知道网络中是不是把「发送方」的数据包给复制了;

## 20. 滑动窗口

窗口大小就是指无需等待确认应答，而可以继续发送数据的最大值。

接收窗口的大小是约等于发送窗口的大小的。因为滑动窗口并不是一成不变的。比如，当接收方的应用进程读取数据的速度非常快的话，这样的话接收窗口可以很快的就空缺出来。那么新的接收窗口大小，是通过 TCP 报文中的 Window 字段来告诉发送方。那么这个传输过程是存在时延的，所以接收窗口和发送窗口是约等于的关系。

## 21. 流量控制

TCP 提供一种机制可以让「发送方」根据「接收方」的实际接收能力控制发送的数据量，这就是所谓的流量控制。

但是实际上，发送窗口和接收窗口中所存放的字节数，都是放在操作系统内存缓冲区中的，而操作系统的缓冲区，会被操作系统调整。当应用进程没办法及时读取缓冲区的内容时，也会对我们的缓冲区造成影响。如果发生了先减少缓存，再收缩窗口，就会出现丢包的现象。

为了防止这种情况发生，TCP 规定是不允许同时减少缓存又收缩窗口的，而是采用先收缩窗口，过段时间再减少缓存，这样就可以避免了丢包情况。

## 22. 窗口关闭

如果窗口大小为 0 时，就会阻止发送方给接收方传递数据，直到窗口变为非 0 为止，这就是窗口关闭。

发送方一直等待接收方的非 0 窗口通知，接收方也一直等待发送方的数据，如不采取措施，这种相互等待的过程，会造成了死锁的现象。

为了解决这个问题，TCP 为每个连接设有一个持续定时器，只要 TCP 连接一方收到对方的零窗口通知，就启动持续计时器。

如果持续计时器超时，就会发送窗口探测 ( Window probe ) 报文，而对方在确认这个探测报文时，给出自己现在的接收窗口大小。

窗口探测的间隔按指数退避递增（Linux 中首次约 0.2s，之后逐次翻倍，上限约 120s；各实现不同）。如果持续超时仍未收到非零窗口通知，有的 TCP 实现就会发 RST 报文来中断连接。

## 23. 糊涂窗口综合症

如果接收方腾出几个字节并告诉发送方现在有几个字节的窗口，而发送方会义无反顾地发送这几个字节，这就是糊涂窗口综合症。

* 接收方通常的策略如下:

当「窗口大小」小于小于 MSS 与 1/2 缓存大小中的最小值时，就会向发送方通告窗口为 0，也就阻止了发送方再发数据过来。

* 发送方通常的策略如下:

使用 Nagle 算法，该算法的思路是延时处理，只有满足下面两个条件中的任意一个条件，才可以发送数据：

条件一：要等到窗口大小 >= MSS 并且 数据大小 >= MSS； 条件二：收到之前发送数据的 ack 回包； 只要上面两个条件都不满足，发送方一直在囤积数据，直到满足上面的发送条件。

接收方得满足「不通告小窗口给发送方」+ 发送方开启 Nagle 算法，才能避免糊涂窗口综合症。

## 24. 拥塞控制

控制的目的就是避免「发送方」的数据填满整个网络。

拥塞窗口 cwnd 是发送方维护的一个的状态变量，它会根据网络的拥塞程度动态变化的。

### 慢启动

当发送方每收到一个 ACK，拥塞窗口 cwnd 的大小就会加 1。

发包的个数是指数性的增长。

有一个叫慢启动门限 ssthresh （slow start threshold）状态变量。

* 当 cwnd < ssthresh 时，使用慢启动算法。
* 当 cwnd >= ssthresh 时，就会使用「拥塞避免算法」。

### 拥塞避免算法

进入拥塞避免算法后，它的规则是：每当收到一个 ACK 时，cwnd 增加 1/cwnd。

当 8 个 ACK 应答确认到来时，每个确认增加 1/8，8 个 ACK 确认 cwnd 一共增加 1，于是这一次能够发送 9 个 MSS 大小的数据，变成了线性增长。

就这么一直增长着后，网络就会慢慢进入了拥塞的状况了，于是就会出现丢包现象，这时就需要对丢失的数据包进行重传。

当触发了重传机制，也就进入了「拥塞发生算法」。

### 拥塞发生

当发生了「超时重传」，则就会使用拥塞发生算法。

这个时候，ssthresh 和 cwnd 的值会发生变化：

* ssthresh 设为 cwnd/2
* cwnd 恢复为初始化值

还有更好的方式，前面我们讲过「快速重传算法」。当接收方发现丢了一个中间包的时候，发送三次前一个包的 ACK，于是发送端就会快速地重传，不必等待超时再重传。

TCP 认为这种情况不严重，因为大部分没丢，只丢了一小部分，则 ssthresh 和 cwnd 变化如下：

* cwnd = cwnd/2 ，也就是设置为原来的一半;
* ssthresh = cwnd;
* 进入快速恢复算法

### 快速恢复

* 拥塞窗口 cwnd = ssthresh + 3 （ 3 的意思是确认有 3 个数据包被收到了）；
* 重传丢失的数据包；
* 如果再收到重复的 ACK，那么 cwnd 增加 1；
* 如果收到新数据的 ACK 后，把 cwnd 设置为第一步中的 ssthresh 的值，原因是该 ACK 确认了新的数据，说明从 duplicated ACK 时的数据都已收到，该恢复过程已经结束，可以回到恢复之前的状态了，也即再次进入拥塞避免状态；

## 25. TCP 半连接和全连接队列

* 半连接队列，也称 SYN 队列；
* 全连接队列，也称 accept 队列；

### 全连接队列

服务端收到客户端发起的 SYN 请求后，内核会把该连接存储到半连接队列，并向客户端响应 SYN+ACK，接着客户端会返回 ACK，服务端收到第三次握手的 ACK 后，内核会把连接从半连接队列移除，然后创建新的完全的连接，并将其添加到 accept 队列，等待进程调用 accept 函数时把连接取出来。

当服务端并发处理大量请求时，如果 TCP 全连接队列过小，就容易溢出。发生 TCP 全连接队溢出的时候，后续的请求就会被丢弃，这样就会出现服务端请求数量上不去的现象。

TCP 全连接队列的最大值取决于 somaxconn 和 backlog 之间的最小值，也就是 min(somaxconn, backlog)

### 半连接队列

如果半连接队列满了，并且没有开启 tcp\_syncookies，新到达的 SYN 请求会被丢弃；若全连接队列已满，且处于 SYN+ACK 重传状态的连接请求多于 1 个，新的连接请求也会被丢弃；

开启 syncookies 功能就可以在不使用 SYN 半连接队列的情况下成功建立连接

## 26. 如何优化 TCP?

### TCP 三次握手的性能提升

#### 客户端优化

当客户端发起 SYN 包时，可以通过 `tcp_syn_retries` 控制其重传的次数。

#### 服务端优化

当服务端 SYN 半连接队列溢出后，会导致后续连接被丢弃，可以通过 `netstat -s` 观察半连接队列溢出的情况，如果 SYN 半连接队列溢出情况比较严重，可以通过 `tcp_max_syn_backlog` `somaxconn` `backlog` 参数来调整 SYN 半连接队列的大小。

服务端回复 SYN+ACK 的重传次数由 `tcp_synack_retries` 参数控制。如果遭受 SYN 攻击，应把 `tcp_syncookies` 参数设置为 1，表示仅在 SYN 队列满后开启 `syncookie` 功能，可以保证正常的连接成功建立。

可以通过 `ss -lnt` 查看服务端进程的 accept 队列长度，如果 accept 队列溢出严重，可以通过 `listen` 函数的 `backlog` 参数和 `somaxconn` 系统参数提高队列大小，accept 队列长度取决于 `min(backlog, somaxconn)`。

#### 如何绕过三次握手？

TCP Fast Open 功能可以绕过三次握手，使得 HTTP 请求减少了 1 个 RTT 的时间，Linux 下可以通过 `tcp_fastopen` 开启该功能，同时必须保证服务端和客户端同时支持。

### TCP 四次挥手的性能提升

#### 主动方的优化

主动发起 FIN 报文断开连接的一方，如果迟迟没收到对方的 ACK 回复，则会重传 FIN 报文，重传的次数由 `tcp_orphan_retries` 参数决定。

当主动方接收到 FIN 报文，并返回 ACK 后，主动方的连接进入 TIME\_WAIT 状态。这一状态会持续 1 分钟，为了防止 TIME\_WAIT 状态占用太多的资源，`tcp_max_tw_buckets` 定义了最大数量，超过时连接也会直接释放。

当 TIME\_WAIT 状态过多时，还可以通过设置 `tcp_tw_reuse` 和 `tcp_timestamps` 为 1 ，将 TIME\_WAIT 状态的端口复用于作为客户端的新连接，注意该参数只适用于客户端。

#### 被动方的优化

被动关闭的连接方应对非常简单，它在回复 ACK 后就进入了 CLOSE\_WAIT 状态，等待进程调用 close 函数关闭连接。因此，出现大量 CLOSE\_WAIT 状态的连接时，应当从应用程序中找问题。

当被动方发送 FIN 报文后，连接就进入 LAST\_ACK 状态，在未等到 ACK 时，会在 `tcp_orphan_retries` 参数的控制下重发 FIN 报文。

### TCP 传输数据的性能提升

默认的滑动窗口最大值只有 64 KB，不满足当今的高速网络的要求，要想提升发送速度必须提升滑动窗口的上限，在 Linux 下是通过设置 `tcp_window_scaling` 为 1 做到的，此时最大值可高达 1GB。

滑动窗口定义了网络中飞行报文的最大字节数，当它超过带宽时延积时，网络过载，就会发生丢包。而当它小于带宽时延积时，就无法充分利用网络带宽。因此，滑动窗口的设置，必须参考带宽时延积。

内核缓冲区决定了滑动窗口的上限，缓冲区可分为：发送缓冲区 `tcp_wmem` 和接收缓冲区 `tcp_rmem`。Linux 会对缓冲区动态调节，我们应该把缓冲区的上限设置为带宽时延积。发送缓冲区的调节功能是自动打开的，而接收缓冲区需要把 `tcp_moderate_rcvbuf` 设置为 1 来开启。其中，调节的依据是 TCP 内存范围 `tcp_mem`。

## 27. 如何理解是 TCP 面向字节流协议？

### UDP 是面向报文的协议

当用户消息通过 UDP 协议传输时，操作系统不会对消息进行拆分，在组装好 UDP 头部后就交给网络层来处理，所以发出去的 UDP 报文中的数据部分就是完整的用户消息，也就是每个 UDP 报文就是一个用户消息的边界，这样接收方在接收到 UDP 报文后，读一个 UDP 报文就能读取到完整的用户消息。

### TCP 是面向字节流的协议

当用户消息通过 TCP 协议传输时，消息可能会被操作系统分组成多个的 TCP 报文，也就是一个完整的用户消息被拆分成多个 TCP 报文进行传输。

## 28. 如何解决粘包

* 固定长度的消息；
* 特殊字符作为边界；
* 自定义消息结构。

## 29. 为什么 TCP 每次建立连接时，初始化序列号都要不一样呢？

为了防止历史报文被下一个相同四元组的连接接收。

客户端和服务端的初始化序列号都是随机生成，能很大程度上避免历史报文被下一个相同四元组的连接接收，然后又引入时间戳的机制，从而完全避免了历史报文被接收的问题。时间戳有两个好处，一个是便于精确计算 RTT ，另一个是能防止序列号回绕（PAWS）。

## 30. SYN 报文什么时候情况下会被丢弃？

* 开启 `tcp_tw_recycle` 参数，并且在 NAT 环境下，造成 SYN 报文被丢弃（注：该参数缺陷严重，已于 Linux 4.12 内核移除，现网内核已无此参数）
* TCP 两个队列满了（半连接队列和全连接队列），造成 SYN 报文被丢弃

## 31. 已建立连接的 TCP，收到 SYN 会发生什么？

1. 客户端的 SYN 报文里的端口号与历史连接不相同

如果客户端恢复后发送的 SYN 报文中的源端口号跟上一次连接的源端口号不一样，此时服务端会认为是新的连接要建立，于是就会通过三次握手来建立新的连接。

2. 客户端的 SYN 报文里的端口号与历史连接相同

处于 Established 状态的服务端，如果收到了客户端的 SYN 报文（注意此时的 SYN 报文其实是乱序的，因为 SYN 报文的初始化序列号其实是一个随机数），会回复一个携带了正确序列号和确认号的 ACK 报文，这个 ACK 被称之为 Challenge ACK。

接着，客户端收到这个 Challenge ACK，发现确认号（ack num）并不是自己期望收到的，于是就会回 RST 报文，服务端收到后，就会释放掉该连接。

## 32. 四次挥手中收到乱序的 FIN 包会如何处理？

在 FIN\_WAIT\_2 状态时，如果收到乱序的 FIN 报文，那么就被会加入到「乱序队列」，并不会进入到 TIME\_WAIT 状态。

等再次收到前面被网络延迟的数据包时，会判断乱序队列有没有数据，然后会检测乱序队列中是否有可用的数据，如果能在乱序队列中找到与当前报文的序列号保持的顺序的报文，就会看该报文是否有 FIN 标志，如果发现有 FIN 标志，这时才会进入 TIME\_WAIT 状态。

## 33. 在 TIME\_WAIT 状态的 TCP 连接，收到 SYN 后会发生什么？

* 如果处于 TIME\_WAIT 状态的连接收到「合法的 SYN 」后，就会重用此四元组连接，跳过 2MSL 而转变为 SYN\_RECV 状态，接着就能进行建立连接过程。
* 如果处于 TIME\_WAIT 状态的连接收到「非法的 SYN 」后，就会再回复一个第四次挥手的 ACK 报文，客户端收到后，发现并不是自己期望收到确认号（ack num），就回 RST 报文给服务端。

非法合法根据序列号和时间戳（如果有）来进行判断。

## 34. 在 TIME\_WAIT 状态，收到 RST 会断开连接吗？

会不会断开，关键看 net.ipv4.tcp\_rfc1337 这个内核参数（默认情况是为 0）：

* 如果这个参数设置为 0， 收到 RST 报文会提前结束 TIME\_WAIT 状态，释放连接。
* 如果这个参数设置为 1， 就会丢掉 RST 报文。

## 35. TCP 连接，一端断电和进程崩溃有什么区别？

如果「客户端进程崩溃」，客户端的进程在发生崩溃的时候，内核会发送 FIN 报文，与服务端进行四次挥手。

但是，「客户端主机宕机」，那么是不会发生四次挥手的，具体后续会发生什么？还要看服务端会不会发送数据？

* 如果服务端会发送数据，由于客户端已经不存在，收不到数据报文的响应报文，服务端的数据报文会超时重传，当重传总间隔时长达到一定阈值后，会断开 TCP 连接；
* 如果服务端一直不会发送数据，再看服务端有没有开启 TCP keepalive 机制？
  * 如果有开启，服务端在一段时间没有进行数据交互时，会触发 TCP keepalive 机制，探测对方是否存在，如果探测到对方已经消亡，则会断开自身的 TCP 连接；
  * 如果没有开启，服务端的 TCP 连接会一直存在，并且一直保持在 ESTABLISHED 状态。

## 36. 拔掉网线后， 原本的 TCP 连接还存在吗？

客户端拔掉网线后，并不会直接影响 TCP 连接状态。所以，拔掉网线后，TCP 连接是否还会存在，关键要看拔掉网线之后，有没有进行数据传输。

有数据传输的情况：

* 在客户端拔掉网线后，如果服务端发送了数据报文，那么在服务端重传次数没有达到最大值之前，客户端就插回了网线，那么双方原本的 TCP 连接还是能正常存在，就好像什么事情都没有发生。
* 在客户端拔掉网线后，如果服务端发送了数据报文，在客户端插回网线之前，服务端重传次数达到了最大值时，服务端就会断开 TCP 连接。等到客户端插回网线后，向服务端发送了数据，因为服务端已经断开了与客户端相同四元组的 TCP 连接，所以就会回 RST 报文，客户端收到后就会断开 TCP 连接。至此， 双方的 TCP 连接都断开了。

没有数据传输的情况：

* 如果双方都没有开启 TCP keepalive 机制，那么在客户端拔掉网线后，如果客户端一直不插回网线，那么客户端和服务端的 TCP 连接状态将会一直保持存在。
* 如果双方都开启了 TCP keepalive 机制，那么在客户端拔掉网线后，如果客户端一直不插回网线，TCP keepalive 机制会探测到对方的 TCP 连接没有存活，于是就会断开 TCP 连接。而如果在 TCP 探测期间，客户端插回了网线，那么双方原本的 TCP 连接还是能正常存在。

## 37. tcp\_tw\_reuse 为什么默认是关闭的？

tcp\_tw\_reuse 的作用是让客户端快速复用处于 TIME\_WAIT 状态的端口，相当于跳过了 TIME\_WAIT 状态，这可能会出现这样的两个问题：

* 历史 RST 报文可能会终止后面相同四元组的连接，因为 PAWS 检查到即使 RST 是过期的，也不会丢弃。
* 如果第四次挥手的 ACK 报文丢失了，有可能被动关闭连接的一方不能被正常的关闭;

## 38. HTTPS 中 TLS 和 TCP 能同时握手吗？

* 客户端和服务端都开启了 TCP Fast Open 功能，且 TLS 版本是 1.3；
* 客户端和服务端已经完成过一次通信；

## 39. TCP Keepalive 和 HTTP Keep-Alive 是一个东西吗？

HTTP 的 Keep-Alive 也叫 HTTP 长连接，该功能是由「应用程序」实现的，可以使得用同一个 TCP 连接来发送和接收多个 HTTP 请求/应答，减少了 HTTP 短连接带来的多次 TCP 连接建立和释放的开销。

TCP 的 Keepalive 也叫 TCP 保活机制，该功能是由「内核」实现的，当客户端和服务端长达一定时间没有进行数据交互时，内核为了确保该连接是否还有效，就会发送探测报文，来检测对方是否还在线，然后来决定是否要关闭该连接。

## 40. TCP 协议有什么缺陷？

* 升级 TCP 的工作很困难；
* TCP 建立连接的延迟；
* TCP 存在队头阻塞问题；
* 网络迁移需要重新建立 TCP 连接；

## 41. QUIC 是如何实现可靠传输的？

QUIC 通过单向递增的 Packet Number，配合 Stream ID 与 Offset 字段信息，可以支持乱序确认而不影响数据包的正确组装，摆脱了 TCP 必须按顺序确认应答 ACK 的限制，解决了 TCP 因某个数据包重传而阻塞后续所有待发送数据包的问题。

## 42. QUIC 是如何解决 TCP 队头阻塞问题的？

QUIC 给每一个 Stream 都分配了一个独立的滑动窗口，这样使得一个连接上的多个 Stream 之间没有依赖关系，都是相互独立的，各自控制的滑动窗口。

## 43. QUIC 对拥塞控制改进

QUIC 在应用层实现拥塞控制，允许根据不同应用的需求选择不同的拥塞控制算法。当前 QUIC 默认使用 TCP 的 Cubic 算法，同时也支持其他算法，如 BBR。这种灵活性使得 QUIC 能够更有效地适应网络条件。

## 44. QUIC 更快的连接建立

QUIC 内部包含了 TLS，它在自己的帧会携带 TLS 里的“记录”，再加上 QUIC 使用的是 TLS1.3，因此仅需 1 个 RTT 就可以「同时」完成建立连接与密钥协商，甚至在第二次连接的时候，应用数据包可以和 QUIC 握手信息（连接信息 + TLS 信息）一起发送，达到 0-RTT 的效果。

## 45. QUIC 是如何迁移连接的？

QUIC 协议没有用四元组的方式来“绑定”连接，而是通过连接 ID 来标记通信的两个端点，客户端和服务器可以各自选择一组 ID 来标记自己，因此即使移动设备的网络变化后，导致 IP 地址变化了，只要仍保有上下文信息（比如连接 ID、TLS 密钥等），就可以“无缝”地复用原连接，消除重连的成本，没有丝毫卡顿感，达到了连接迁移的功能。

## 46. TCP UDP 端口问题

### TCP 和 UDP 可以同时绑定相同的端口吗？

可以的。

TCP 和 UDP 传输协议，在内核中是由两个完全独立的软件模块实现的。

因此， TCP/UDP 各自的端口号也相互独立，互不影响。

### 多个 TCP 服务进程可以同时绑定同一个端口吗？

如果两个 TCP 服务进程同时绑定的 IP 地址和端口都相同，那么执行 bind() 时候就会出错，错误是“Address already in use”。

如果两个 TCP 服务进程绑定的端口都相同，而 IP 地址不同，那么执行 bind() 不会出错。

### 如何解决服务端重启时，报错“Address already in use”的问题？

当我们重启 TCP 服务进程的时候，意味着通过服务器端发起了关闭连接操作，于是就会经过四次挥手，而对于主动关闭方，会在 TIME\_WAIT 这个状态里停留一段时间，这个时间大约为 2MSL。

当 TCP 服务进程重启时，服务端会出现 TIME\_WAIT 状态的连接，TIME\_WAIT 状态的连接使用的 IP+PORT 仍然被认为是一个有效的 IP+PORT 组合，相同机器上不能够在该 IP+PORT 组合上进行绑定，那么执行 bind() 函数的时候，就会返回了 Address already in use 的错误。

要解决这个问题，我们可以对 socket 设置 SO\_REUSEADDR 属性。

这样即使存在一个和绑定 IP+PORT 一样的 TIME\_WAIT 状态的连接，依然可以正常绑定成功，因此可以正常重启成功。

### 客户端的端口可以重复使用吗？

在客户端执行 connect 函数的时候，只要客户端连接的服务器不是同一个，内核允许端口重复使用。

TCP 连接是由四元组（源 IP 地址，源端口，目的 IP 地址，目的端口）唯一确认的，那么只要四元组中其中一个元素发生了变化，那么就表示不同的 TCP 连接的。

### 客户端 TCP 连接 TIME\_WAIT 状态过多，会导致端口资源耗尽而无法建立新的连接吗？

要看客户端是否都是与同一个服务器（目标地址和目标端口一样）建立连接。

如果客户端都是与同一个服务器（目标地址和目标端口一样）建立连接，那么如果客户端 TIME\_WAIT 状态的连接过多，当端口资源被耗尽，就无法与这个服务器再建立连接了。即使在这种状态下，还是可以与其他服务器建立连接的，只要客户端连接的服务器不是同一个，那么端口是重复使用的。

### 如何解决客户端 TCP 连接 TIME\_WAIT 过多，导致无法与同一个服务器建立连接的问题？

打开 net.ipv4.tcp\_tw\_reuse 这个内核参数。

因为开启了这个内核参数后，客户端调用 connect 函数时，如果选择到的端口，已经被相同四元组的连接占用的时候，就会判断该连接是否处于 TIME\_WAIT 状态。

如果该连接处于 TIME\_WAIT 状态并且 TIME\_WAIT 状态持续的时间超过了 1 秒，那么就会重用这个连接，然后就可以正常使用该端口了。

## 47. 服务端没有 listen，客户端发起连接建立，会发生什么？

服务端如果只 bind 了 IP 地址和端口，而没有调用 listen 的话，然后客户端对服务端发起了连接建立，服务端会回 RST 报文。

## 48. 不使用 listen ，可以建立 TCP 连接吗？

是可以的，客户端是可以自己连自己的形成连接（TCP 自连接），也可以两个客户端同时向对方发出请求建立连接（TCP 同时打开），这两个情况都有个共同点，就是没有服务端参与，也就是没有 listen，就能建立连接。

## 49. 没有 accept，能建立 TCP 连接吗？

可以，accept 方法只是为了从全连接队列中拿出一条连接，本身跟三次握手几乎毫无关系。

## 50. 用了 TCP 协议，数据一定不会丢吗？

* 数据从发送端到接收端，链路很长，任何一个地方都可能发生丢包，几乎可以说丢包不可避免。
* 平时没事也不用关注丢包，大部分时候 TCP 的重传机制保证了消息可靠性。
* 当你发现服务异常的时候，比如接口延时很高，总是失败的时候，可以用 ping 或者 mtr 命令看下是不是中间链路发生了丢包。
* TCP 只保证传输层的消息可靠性，并不保证应用层的消息可靠性。如果我们还想保证应用层的消息可靠性，就需要应用层自己去实现逻辑做保证。

## 51. TCP 四次挥手，可以变成三次吗？

当被动关闭方在 TCP 挥手过程中，如果「没有数据要发送」，同时「没有开启 TCP\_QUICKACK（默认情况就是没有开启，没有开启 TCP\_QUICKACK，等于就是在使用 TCP 延迟确认机制）」，那么第二和第三次挥手就会合并传输，这样就出现了三次挥手。

所以，出现三次挥手现象，是因为 TCP 延迟确认机制导致的。

TCP 延迟确认的策略：

* 当有响应数据要发送时，ACK 会随着响应数据一起立刻发送给对方
* 当没有响应数据要发送时，ACK 将会延迟一段时间，以等待是否有响应数据可以一起发送
* 如果在延迟等待发送 ACK 期间，对方的第二个数据报文又到达了，这时就会立刻发送 ACK

## 52. TCP 序列号和确认号是如何变化的？

* 公式一：序列号 = 上一次发送的序列号 + len（数据长度）。特殊情况，如果上一次发送的报文是 SYN 报文或者 FIN 报文，则改为 上一次发送的序列号 + 1。
* 公式二：确认号 = 上一次收到的报文中的序列号 + len（数据长度）。特殊情况，如果收到的是 SYN 报文或者 FIN 报文，则改为上一次收到的报文中的序列号 + 1。

## 53. HTTP 常见状态码

1xx 类状态码属于提示信息，是协议处理中的一种中间状态，实际用到的比较少。

2xx 类状态码表示服务器成功处理了客户端的请求，也是我们最愿意看到的状态。

* 「200 OK」是最常见的成功状态码，表示一切正常。如果是非 HEAD 请求，服务器返回的响应头都会有 body 数据。
* 「204 No Content」也是常见的成功状态码，与 200 OK 基本相同，但响应报文没有 body 数据。
* 「206 Partial Content」是应用于 HTTP 分块下载或断点续传，表示响应返回的 body 数据并不是资源的全部，而是其中的一部分，也是服务器处理成功的状态。

3xx 类状态码表示客户端请求的资源发生了变动，需要客户端用新的 URL 重新发送请求获取资源，也就是重定向。

* 「301 Moved Permanently」表示永久重定向，说明请求的资源已永久迁移到新的 URL，需改用新的 URL 再次访问。
* 「302 Found」表示临时重定向，说明请求的资源还在，但暂时需要用另一个 URL 来访问。

301 和 302 都会在响应头里使用字段 Location，指明后续要跳转的 URL，浏览器会自动重定向新的 URL。

* 「304 Not Modified」不具有跳转的含义，表示资源未修改，重定向已存在的缓冲文件，也称缓存重定向，也就是告诉客户端可以继续使用缓存资源，用于缓存控制。

4xx 类状态码表示客户端发送的报文有误，服务器无法处理，也就是错误码的含义。

* 「400 Bad Request」表示客户端请求的报文有错误，但只是个笼统的错误。
* 「403 Forbidden」表示服务器禁止访问该资源，通常是客户端权限不足，而不是请求报文格式有误。
* 「404 Not Found」表示请求的资源在服务器上不存在或未找到，所以无法提供给客户端。

5xx 类状态码表示客户端请求报文正确，但是服务器处理时内部发生了错误，属于服务器端的错误码。

* 「500 Internal Server Error」与 400 类似，是个笼统通用的错误码，服务器发生了什么错误，我们并不知道。
* 「501 Not Implemented」表示客户端请求的功能还不支持，类似“即将开业，敬请期待”的意思。
* 「502 Bad Gateway」通常是服务器作为网关或代理时返回的错误码，表示服务器自身工作正常，访问后端服务器发生了错误。
* 「503 Service Unavailable」表示服务器当前很忙，暂时无法响应客户端，类似“网络服务正忙，请稍后重试”的意思。
* 「504 Gateway Timeout」代表网关超时，指服务器作为网关或代理，但是没有及时从上游服务器收到响应。

## 54. GET 和 POST 有什么区别？

GET 的语义是请求获取指定的资源。GET 方法是安全、幂等、可被缓存的。

POST 的语义是根据请求负荷（报文主体）对指定的资源做出处理，具体的处理方式视资源类型而不同。POST 不安全，不幂等，（大部分实现）不可缓存。

## 55. 什么是强制缓存？

强缓存是利用下面这两个 HTTP 响应头部（Response Header）字段实现的，它们都用来表示资源在客户端缓存的有效期：

* Cache-Control， 是一个相对时间；
* Expires，是一个绝对时间；

如果 HTTP 响应头部同时有 Cache-Control 和 Expires 字段的话，Cache-Control 的优先级高于 Expires 。

## 56. 什么是协商缓存？

协商缓存就是与服务端协商之后，通过协商结果来判断是否使用本地缓存。

* 第一种：请求头部中的 `If-Modified-Since` 字段与响应头部中的 `Last-Modified` 字段实现
* 第二种：请求头部中的 `If-None-Match` 字段与响应头部中的 `ETag` 字段

协商缓存这两个字段都需要配合强制缓存中 `Cache-Control` 字段来使用，只有在未能命中强制缓存的时候，才能发起带有协商缓存字段的请求。

## 57. HTTP/1.1

### 优点

* 简单
* 灵活易扩展（例如 HTTPS 和 HTTP/3 对 TCP 层的修改）
* 应用广泛，跨平台

### 缺点

* 无状态——Cookie 解决
* 不安全
  * 明文传输 - 被窃听
  * 不验证通信方身份 - 被冒充和伪装
  * 不校验报文完整性 - 被篡改

### 性能（和 HTTP1.0 相比的优势）总之一般般

* 优点 1. 长连接——减少重复操作的开销
* 优点 2. 管道网络传输——不等回应即可发送第二个请求，减少响应时间
* 缺点 3. 队头阻塞（管道传输没有解决的缺点）

## 58. HTTPS 如何解决 HTTP 的安全问题

* 明文传输产生的窃听问题——信息加密 （混合加密）
* 不验证身份产生的冒充问题——校验机制 （证书）
* 不校验完整性产生的篡改问题——摘要算法

## 59. HTTPS 一定安全可靠吗？

HTTPS 协议本身到目前为止还是没有任何漏洞的，即使你成功进行中间人攻击，本质上是利用了客户端的漏洞（用户点击继续访问或者被恶意导入伪造的根证书），并不是 HTTPS 不够安全。

（争议说明：『HTTPS 协议本身没有任何漏洞』的说法过于绝对。支持方认为：在正确配置（TLS 1.2+/1.3、禁用弱算法）下，HTTPS/TLS 协议层未暴露根本性设计缺陷，中间人攻击多利用客户端信任链漏洞；反对方指出：TLS 协议历史上出现过 POODLE（2014，SSLv3）、DROWN（2016，SSLv2 降级）、ROBOT（2017，RSA 密钥交换）、BEAST（2011，TLS 1.0 CBC）等协议级漏洞及 OpenSSL Heartbleed（2014，CVE-2014-0160）等实现漏洞，SSLv3/TLS 1.0/1.1 已被废弃，不能简单说『没有任何漏洞』。）

## 60. HTTPS 的应用数据是如何保证完整性的？

在数据传输过程中，SSL/TLS 使用消息认证码（MAC）来验证数据的完整性。对每个数据包，发送方会计算一个 MAC，并将其附加到数据包中。在接收端，接收方会使用相同的算法和密钥重新计算 MAC，并与接收到的 MAC 进行比较。如果匹配，数据被认为是完整且未被篡改的。

## 61. HTTPS 是如何建立连接的？其间交互了什么？

1. 客户端向服务器发送 HTTPS 请求，并发起握手过程。
2. 服务器将公钥证书发送给客户端。公钥证书中包含了服务器的公钥、服务器的域名、证书颁发机构等信息。
3. 客户端验证服务器的证书。客户端会检查证书的有效性，包括验证证书是否由可信任的证书颁发机构签发，以及证书中的域名是否与实际域名一致。
4. 如果证书验证通过，客户端生成一个用于会话的对称密钥。
5. 客户端使用服务器的公钥对对称密钥进行加密，并将加密后的密钥发送给服务器。
6. 服务器使用私钥对客户端发送的加密密钥进行解密，得到对称密钥的值。
7. 服务器和客户端使用对称密钥进行加密和解密数据传输。

## 62. HTTP 的演变

### HTTP/1.1

* 改进

1. 长连接
2. 管道运输：一次发送多个请求

* 不足

1. 头部冗长，未经压缩，浪费带宽，造成延迟
2. 没有请求优先级，所以队头阻塞 (HTTP 层)
3. 服务器能被动接收客户端的请求

### HTTP/2

* 改进

1. 头部压缩（解决 1）
2. 二进制格式
3. 数据流：可以乱序发送，通过 stream ID 组成 HTTP 信息
4. 多路复用，串行变成并发（解决 2）
5. 服务器推送（解决 3）

* 不足

1. TCP 层队头阻塞
2. TCP 和 TLS 握手时延

### HTTP/3

* 改进

1. 无队头阻塞
2. 更快的连接建立（QUIC 包含 TLS，只需 1 个 RTT）
3. 连接迁移（没有用四元组的方式来“绑定”连接，而是通过连接 ID）

* 不足
* 普及慢，很多网络设备不识别 QUIC

## 63. HTTP/1.1 优化

| 三个角度                   | 具体优化方法                                                                                                                                 |
| ---------------------- | -------------------------------------------------------------------------------------------------------------------------------------- |
| **\_ 避免发送 HTTP 请求 \_** | `缓存` 客户端第一次请求及数据保存在本地磁盘，形成\<key,value> 过期后，在请求头 `If-None-Match` 带上第一次请求响应头 `ETag` 的值，服务器收到后与本地资源的摘要作比较，如果相同则返回不含包体的 `304 Not Modified` |
| **\_ 减少请求次数 \_**       | 1. **\_ 减少重定向请求次数 \_**：利用中间的代理服务器知晓规则 2. **\_ 合并请求 \_**：合并资源，如 CSS、webpack 3. **\_ 延迟发送请求 \_**：按需获取，滑动页面的时候再获取资源                       |
| **\_ 减少服务器响应数据大小 \_**  | 无损压缩：gzip。请求 `Accept-Encoding`，响应 `Content-Encoding`（文本、程序代码) 有损压缩：舍弃一些数据（质量）。请求 `Accept` 中的 q 质量因子（音视频、图片）                            |

## 64. HTTPS 如何优化

硬件优化、软件优化：HTTPS 协议是计算密集型，而不是 I/O 密集型，所以不能把钱花在网卡、硬盘等地方，应该花在 CPU 上

1. 协议优化
   * （1）用 ECDHE 替换 RSA，配合 TLS False Start 可省约 1 RTT（完整 TLS 1.2 握手仍为 2 RTT），
   * （2）TLS 1.2->TLS 1.3，往返 1RTT；在 Hello 时就发送椭圆曲线（key\_share），且废除 RSA 密钥交换与静态 DH，仅保留 (EC)DHE
2. 证书优化
   * （1）证书选择：椭圆曲线证书比 RSA 密钥长度短
   * （2）证书验证优化：OCSP(Online Certificate Status Protocol)、OCSP Stapling
3. 密钥缓存（无前向安全，且易被重放攻击）
   * （1）Session ID：双方缓存密钥，Session ID 和密钥相当于 key-value。但是也有缺点，首先是每一个客户端都要保存密钥，其次是现在网站一般多服务器，不一定命中上次的服务器
   * （2）Session Ticket：客户端负责缓存。
   * （3）Pre-shared Key：TLS 1.3 重连只需要 0 RTT。重连时 Ticket 和 HTTP 一起发给服务端

解决重放攻击，应给密钥设定过期时间。

## 65. HTTP2

* 第一点，对于常见的 HTTP 头部通过静态表和 Huffman 编码的方式，将体积压缩了近一半，而且针对后续的请求头部，还可以建立动态表，将体积压缩近 90%，大大提高了编码效率，同时节约了带宽资源。
* 第二点，HTTP/2 实现了 Stream 并发，多个 Stream 只需复用 1 个 TCP 连接，节约了 TCP 和 TLS 握手时间，以及减少了 TCP 慢启动阶段对流量的影响。不同的 Stream ID 可以并发，即使乱序发送帧也没问题，比如发送 A 请求帧 1 -> B 请求帧 1 -> A 请求帧 2 -> B 请求帧 2，但是同一个 Stream 里的帧必须严格有序。
* 第三点，服务器支持主动推送资源，大大提升了消息的传输性能，服务器推送资源时，会先发送 PUSH\_PROMISE 帧，告诉客户端接下来在哪个 Stream 发送资源，然后用偶数号 Stream 发送资源给客户端。

HTTP/2 是基于 TCP 协议来传输数据的，TCP 是字节流协议，TCP 层必须保证收到的字节数据是完整且连续的，这样内核才会将缓冲区里的数据返回给 HTTP 应用，那么当「前 1 个字节数据」没有到达时，后收到的字节数据只能存放在内核缓冲区里，只有等到这 1 个字节数据到达时，HTTP/2 应用层才能从内核中拿到数据，这就是 HTTP/2 队头阻塞问题。

## 66. HTTP3

HTTP/3 就将传输层从 TCP 替换成了 UDP，并在 UDP 协议上开发了 QUIC 协议，来保证数据的可靠传输。

QUIC 协议的特点：

* 无队头阻塞，QUIC 连接上的多个 Stream 之间并没有依赖，都是独立的，也不会有底层协议限制，某个流发生丢包了，只会影响该流，其他流不受影响；
* 建立连接速度快，因为 QUIC 内部包含 TLS 1.3，因此仅需 1 个 RTT 就可以「同时」完成建立连接与 TLS 密钥协商，甚至在第二次连接的时候，应用数据包可以和 QUIC 握手信息（连接信息 + TLS 信息）一起发送，达到 0-RTT 的效果。
* 连接迁移，QUIC 协议没有用四元组的方式来“绑定”连接，而是通过「连接 ID 」来标记通信的两个端点，客户端和服务器可以各自选择一组 ID 来标记自己，因此即使移动设备的网络变化后，导致 IP 地址变化了，只要仍保有上下文信息（比如连接 ID、TLS 密钥等），就可以“无缝”地复用原连接，消除重连的成本；

另外 HTTP/3 的 QPACK 通过两个特殊的单向流来同步双方的动态表，解决了 HTTP/2 的 HPACK 队头阻塞问题。

## 67. RPC

* 纯裸 TCP 是能收发数据，但它是个无边界的数据流，上层需要定义消息格式用于定义消息边界。于是就有了各种协议，HTTP 和各类 RPC 协议就是在 TCP 之上定义的应用层协议。
* RPC 本质上不算是协议，而是一种调用方式，而像 gRPC 和 Thrift 这样的具体实现，才是协议，它们是实现了 RPC 调用的协议。目的是希望程序员能像调用本地方法那样去调用远端的服务方法。同时 RPC 有很多种实现方式，不一定非得基于 TCP 协议。
* 从发展历史来说，HTTP 主要用于 B/S 架构，而 RPC 更多用于 C/S 架构。但现在其实已经没分那么清了，B/S 和 C/S 在慢慢融合。很多软件同时支持多端，所以对外一般用 HTTP 协议，而内部集群的微服务之间则采用 RPC 协议进行通讯。
* RPC 其实比 HTTP 出现的要早，且比目前主流的 HTTP/1.1 性能要更好，所以大部分公司内部都还在使用 RPC。

## 68. WebSocket

* TCP 协议本身是全双工的，但我们最常用的 HTTP/1.1，虽然是基于 TCP 的协议，但它是半双工的，对于大部分需要服务器主动推送数据到客户端的场景，都不太友好，因此我们需要使用支持全双工的 WebSocket 协议。
* 在 HTTP/1.1 里，只要客户端不问，服务端就不答。基于这样的特点，对于登录页面这样的简单场景，可以使用定时轮询或者长轮询的方式实现服务器推送 (comet) 的效果。
* 对于客户端和服务端之间需要频繁交互的复杂场景，比如网页游戏，都可以考虑使用 WebSocket 协议。
* 正因为各个浏览器都支持 HTTP 协议，所以 WebSocket 会先利用 HTTP 协议加上一些特殊的 header 头进行握手升级操作，升级成功后就跟 HTTP 没有任何关系了，之后就用 WebSocket 的数据格式进行收发数据。

## 69. ping 是用什么协议完成的？在哪一层？

ping 命令使用的是 ICMP 协议，全称 Internet Control Message Protocol，即 Internet 控制消息协议。 ICMP 协议是 TCP/IP 协议集中的一个子协议，属于网络层协议。因此，ping 命令属于网络层协议，即第三层协议。

## 70. OSI 七层模型和各层有什么协议

* 应用层：HTTP、FTP、SMTP、POP3 等。
* 表示层：JPEG、MPEG 等。
* 会话层：RPC 等。
* 传输层：TCP、UDP 等。
* 网络层：IP、ICMP 等。
* 数据链路层：ARP（IP 地址转换为 MAC 地址） 等。
* 物理层：IEEE802.3 等。

## 71. 不同序列化方式各自的优势

* JSON：JSON 是一种轻量级的数据交换格式，易于阅读和编写，支持几乎所有编程语言和平台，因此广泛用于 Web 应用程序和移动应用程序中。它的缺点是序列化和反序列化过程较慢，尤其是在处理大型数据集时。
* XML：XML 是一种与 JSON 类似的文本格式，也被广泛用于 Web 和移动应用程序中。然而，与 JSON 相比，XML 更为冗长，解析速度较慢。
* Thrift：Thrift 是 Facebook 开发的一种高效的跨语言序列化框架，支持多种数据类型、压缩和 RPC（远程过程调用）。这使得它在大型分布式系统中得到广泛应用。Thrift 的缺点是需要通过代码生成器来生成序列化和反序列化代码，这使得开发过程略微复杂。
* Protobuf：相比较其他序列化方式，Protobuf 序列化后的数据更为紧凑，解析速度更快，在性能方面表现出色。Protobuf 支持架构演进，可以在不影响现有代码的情况下进行升级和扩展，这使得它非常适合在大型分布式系统中使用。

## 72. DNS 查询时什么时候用 TCP 什么时候用 UDP

DNS 查询时，一般情况下使用 UDP 协议，但当 DNS 响应超过 512 字节时，响应的 TC（Truncated）标志置位，这时则改用 TCP 发送。传统上（RFC 1035）UDP 消息不超过 512 字节，超过则会被截断；启用 EDNS0（RFC 2671/6891）后，客户端可通告更大的 UDP 负载长度（常见 1232/4096 字节），超过该长度时才回退到 TCP。DNS 将 TCP 用于区域传输（Zone Transfer，主从 DNS 间同步数据），将 UDP 用于普通名称查询。UDP 可用于交换小信息，而超过 UDP 负载上限的信息需改用 TCP。

## 73. TCP 三次握手的时候可以带数据吗？为什么前两次不能带？

前两次握手只是为了协商双方的初始序列号，不需要携带数据。只有在第三次握手时，客户端已经处于 ESTABLISHED 状态，并且已经能够确认服务器的接收、发送能力正常，这个时候相对安全了，可以携带数据。

## 74. 路由转发时目的地址和源地址的变化

路由转发时，源地址和目的地址的变化取决于数据包经过的设备类型。在数据包传递过程中，源 IP 地址和目的 IP 地址一直不变。每次经过交换机，源 MAC 地址和目的 MAC 地址不变。每次经过路由器，源 MAC 地址为本路由器接口 MAC 地址，目的 MAC 地址为该目的 IP 地址下一条对应 IP 地址的 MAC 地址。

## 75. 两次握手但是我一直发数据，和三次握手一样吗

不完全相同。在 TCP 协议中，两次握手只是建立了一个简单的连接，但是并没有进行双向通信确认。也就是说，在两次握手的情况下，客户端可以发送数据，但是服务器端无法确认是否已经接收到这些数据，因此可能会导致数据包丢失的情况。

相反，三次握手确保了双方的通信能力，并且在客户端和服务器端之间建立了可靠的连接。在三次握手过程中，双方都有机会发送和接收数据，从而确保了连接的可靠性和稳定性。

## 76. gRPC 和 HTTP 区别

1. 数据格式：HTTP 使用文本格式（通常是 JSON 或 XML）进行数据交换，而 gRPC 使用二进制格式进行数据序列化（通常是 Protocol Buffers）。二进制格式比文本格式更紧凑，可以减少网络传输的数据量。
2. 传输方式：HTTP 使用请求 - 响应模型，客户端发送请求，服务器返回响应。gRPC 使用双向流模型，客户端和服务器可以同时发送和接收消息。这种模型可以提供更高的并发性和效率，特别适用于大规模的实时应用。
3. 支持的语言：HTTP 是一种通用的协议，几乎所有的编程语言都有 HTTP 库和框架可以使用。gRPC 最初是为 Google 开发的，并支持多种编程语言，如 C++, Java, Python, Go, Ruby 等。由于 gRPC 使用基于 IDL（Interface Definition Language）的方式定义服务接口，可以自动生成客户端和服务器端的代码，使得不同语言之间的通信更加简便。
4. 传输效率：由于 gRPC 使用二进制格式和高效的序列化机制，它通常比 HTTP 更高效。gRPC 使用 HTTP/2 作为底层协议，提供了多路复用、头部压缩、流控制等特性，可以减少网络延迟和带宽消耗。
5. 应用场景：HTTP 适用于多种场景，包括传统的 Web 应用、API 调用、浏览器和服务器之间的通信等。gRPC 更适合于分布式系统、微服务架构和高性能的实时通信场景，如实时数据传输、流式处理等。

## 77. TCP 报文头

1. 源端口号：16 位字段，用于标识发送端口的端口号。
2. 目的端口号：16 位字段，用于标识接收端口的端口号。
3. 序号：32 位字段，用于标识 TCP 连接中传输的数据流中每个字节的编号。
4. 确认号：32 位字段，用于确认接收方期望收到的下一个数据字节的序号。
5. 数据偏移：4 位字段，表示 TCP 报文头的长度，以 4 字节为单位。
6. 保留字段：6 位字段，保留供将来使用，目前置为 0。
7. 控制位：6 位字段，包括 URG、ACK、PSH、RST、SYN 和 FIN 等标志位，用于控制 TCP 连接的建立、终止和数据传输等操作。
8. 窗口大小：16 位字段，用于告知发送方本端的 TCP 接收缓冲区还能容纳多少字节的数据。
9. 校验和：16 位字段，用于检验 TCP 报文段在传输过程中是否损坏。
10. 紧急指针：16 位字段，用于指示 TCP 报文段中的紧急数据的最后一个字节的序号。
11. 选项字段：可变长度的字段，用于传递一些额外的信息，如最大报文段长度等。

## 78. HTTP2.0 的 Stream

HTTP/2.0 中的 "stream" 指的是多路复用中的一个逻辑概念，一个 Stream 是由多个帧组成的数据流。每个帧都有一个流标识符，通过流标识符可以将帧组装成完整的数据流。一个 HTTP/2 连接可以同时存在多个流，这些流可以并发地在同一个 TCP 连接上发送和接收数据。

HTTP/1.x 中，每个请求都需要建立一个独立的连接，导致请求在传输过程中会被阻塞，只有前一个请求完成后才能发送下一个请求。而在 HTTP/2 中，可以在同一个 TCP 连接上同时发送多个请求和接收多个响应，这样可以避免 HTTP 层的队头阻塞问题（但 TCP 层的队头阻塞依然存在）。

## 79. HTTP 大文件上传

1. 压缩 HTML 等文本文件是传输大文件最基本的方法；
2. 分块传输可以流式收发数据，节约内存和带宽，使用响应头字段 `Transfer-Encoding: chunked` 来表示，分块的格式是 16 进制长度头 + 数据块；
3. 范围请求可以只获取部分数据，即 **分块请求**，实现视频拖拽或者断点续传，使用请求头字段 `Range` 和响应头字段 `Content-Range` ，响应状态码必须是 206；
4. 也可以一次请求多个范围，这时候响应报文的数据类型是 `multipart/byteranges` ，body 里的多个部分会用 boundary 字符串分隔。

## 80. TCP 如果大量丢包会怎样

1. **传输效率下降**：大量丢包会导致 TCP 频繁重传数据包，浪费大量带宽和 CPU 资源，整体传输效率大幅降低。
2. **传输延迟增加**：丢包会触发 TCP 的重传机制，每次重传都会增加一个往返时延。大量丢包会导致 TCP 不得不频繁重传，从而大幅增加总的传输延迟。
3. **连接可能被中断**：如果丢包严重到一定程度，TCP 的重传次数达到上限，连接就会被强制中断。这种情况下需要客户端和服务端重新建立连接。

## 81. TCP 常见丢包原因

1. **网络拥塞**：当网络中数据包过多时，路由器等设备的缓冲区会溢出，导致大量数据包被丢弃。
2. **主机资源不足**：如果接收主机的内存、CPU 等资源不足，无法及时处理接收到的数据包，也会造成丢包。

## 82. 局域网的机器是怎么和公网 IP 通信的

局域网（LAN）中的设备与公网 IP 通信主要依赖于网络地址转换（NAT）技术。

NAT 可以将局域网内的私有 IP 地址转换为一个或多个公网 IP 地址，从而实现与互联网的通信。具体过程如下：

1. **请求发起**：局域网内的设备（如计算机）向路由器发送请求，路由器的公网 IP 地址是局域网与外部网络的唯一标识。
2. **地址转换**：路由器接收到来自局域网设备的请求后，会将请求中的源 IP 地址（私有 IP）替换为其自身的公网 IP 地址，并将请求转发到目标服务器。
3. **端口映射**：为了区分来自不同局域网设备的请求，路由器会为每个请求分配一个临时端口号，并记录下私有 IP 地址与公网 IP 及端口号的映射关系。
4. **响应处理**：当目标服务器响应请求时，它会将数据发送回路由器的公网 IP 和指定的端口。路由器根据之前记录的映射关系，将数据包转发回原始请求的局域网设备。

## 83. Reactor 模型

### Reactor 模型的基本组成

Reactor 模型主要由以下几个组件构成：

* **Reactor**：负责监听和分发事件。
* **Acceptor**：处理客户端的连接请求。
* **Handler**：处理具体的 IO 事件（如读写操作）。

### 工作流程

Reactor 模型的工作流程可以概括为以下几个步骤：

1. **事件监听**：Reactor 通过 IO 多路复用技术（如 Java 中的 Selector）监听多个 IO 事件。
2. **事件分发**：当有事件发生时，Reactor 会根据事件类型将其分发给相应的处理器（Acceptor 或 Handler）。
3. **事件处理**：
   * 对于连接事件，Acceptor 会建立与客户端的连接，并将其注册到 Reactor 上。
   * 对于读写事件，Handler 会处理数据的读写逻辑。

## 84. IP 组播的好处

IP 组播是一种网络通信方式，允许一台主机向多个特定接收者同时发送数据。与单播（一对一）和广播（一对全体）不同，组播（Multicast）通过在网络中创建一个组播分发树，能够有效地将数据流向一组特定的用户，从而实现高效的数据传输。

1. **带宽利用率高**：组播允许在网络中仅传送一份数据，避免了单播中因每个接收者都需接收单独数据而造成的带宽浪费。
2. **降低网络负载**：由于数据只需在网络中传输一次，网络的整体负担减轻，尤其是在用户数量增加时，组播的优势更加明显。
3. **实时性**：组播技术非常适合需要实时数据传输的应用，如视频会议、在线教育和流媒体服务等，这些场景通常要求低延迟和高效率的传输。

## 85. IP 头部在传输中哪些字段发生变化

在 IP 数据包的传输过程中，某些字段会发生变化。主要变化的字段包括：

1. 源 IP 地址和目的 IP 地址

* **源 IP 地址**：发送方的 IP 地址，通常在数据包的整个传输过程中保持不变，但在某些情况下（如 NAT 网络环境中），可能会被修改。
* **目的 IP 地址**：接收方的 IP 地址，通常在数据包传输过程中保持不变。

2. 标识字段（Identification） 该字段用于唯一标识一个 IP 数据包，主要用于分片和重组。在数据包经过不同路由器时，这个字段通常保持不变。
3. 分段标志（Flags）和分段偏移（Fragment Offset）

* **分段标志**：指示该数据包是否需要分段（如 DF 标志表示不分段）。
* **分段偏移**：在数据包被分片时，该字段会根据分片的顺序进行调整。

4. 校验和（Checksum） 该字段用于验证 IP 头部在传输过程中是否发生了变化。发送方计算并填入校验和，接收方在接收数据包后会重新计算校验和以确认数据包的完整性。如果数据包经过路由器，校验和会被重新计算并更新。
5. TTL（生存时间） TTL 字段用于限制数据包在网络中的生存时间。每经过一个路由器，该字段的值会减，当 TTL 值减为 0 时，数据包会被丢弃。这个字段在数据包传输过程中会发生变化。
6. 服务类型（Type of Service） 虽然在大多数情况下，这个字段的值保持不变，但在某些网络配置中，可能会根据 QoS 策略进行修改。

## 86. 如何防范 DNS 劫持

DNS 劫持是指攻击者通过篡改 DNS 服务器的域名解析结果,将正常的域名指向恶意 IP 地址

1. 使用国外公共 DNS 服务器,如 Google DNS(8.8.8.8) 等
2. 使用 VPN 或域名远程解析绕过 DNS 劫持
3. 修改本地 Hosts 文件,手动设置域名的正确 IP 地址
4. 使用 HTTPDNS 服务,绕过运营商 DNS 服务器
5. 对网站启用 HTTPS 加密传输,避免明文劫持

## 87. 为什么 TCP 不支持一对多

TCP 是一个面向连接的协议，这意味着在数据传输之前，必须先建立一条专用的连接。每条 TCP 连接都是点对点的，只有两个端点（发送方和接收方），这使得 TCP 不具备一对多的通信能力。

## 88. DNS 解析过程

1. **浏览器缓存检查** 用户在浏览器中输入一个域名（如 [www.baidu.com](http://www.baidu.com) ）后，浏览器首先会检查其自身的 DNS 缓存。如果缓存中存在该域名对应的 IP 地址，解析过程在此结束，直接使用缓存的 IP 地址。
2. **操作系统缓存检查** 如果浏览器缓存中没有找到相应的 IP 地址，浏览器会查询操作系统的 DNS 缓存。操作系统同样会有一个 DNS 缓存机制。如果在此找到有效的记录，解析过程也会结束。
3. **本地 DNS 服务器查询** 若浏览器和操作系统的缓存均未命中，本地 DNS 服务器会被查询。用户的计算机会向本地 DNS 服务器发送请求，请求解析该域名。此时，本地 DNS 服务器首先会检查其自身的缓存。
4. **根 DNS 服务器查询** 如果本地 DNS 服务器的缓存中没有该记录，它会向根 DNS 服务器发起请求。根 DNS 服务器并不直接提供域名和 IP 地址的映射，而是指向相应的顶级域名服务器（如.com、.net 等）。
5. **顶级域名服务器查询** 本地 DNS 服务器接收到根 DNS 服务器的响应后，会向相应的顶级域名服务器发送请求。顶级域名服务器会返回该域名的权威 DNS 服务器的地址。
6. **权威 DNS 服务器查询** 本地 DNS 服务器随后向权威 DNS 服务器发送请求。权威 DNS 服务器持有域名的最终解析记录，并会返回该域名对应的 IP 地址。
7. **返回结果** 本地 DNS 服务器收到权威 DNS 服务器的响应后，会将该 IP 地址返回给用户的计算机，并将此记录缓存，以便下次快速响应相同的请求。
8. **浏览器访问** 最后，用户的浏览器使用获取到的 IP 地址向目标服务器发送 HTTP 请求，加载网页内容。

## 89. 客户端能维持的最大 TCP 连接数量

客户端能维持的最大 TCP 连接数量主要受限于可用的本地端口数量。每个 TCP 连接都需要一个独特的本地端口来建立连接，而 TCP 端口的范围是 0 到 65535，因此理论上，单个客户端与同一目标服务器（相同四元组）可建立的最大 TCP 连接数为 65535 个（实际还受内核 `ip_local_port_range` 默认 32768\~60999 与文件描述符数量的限制；且连接不同服务器时可复用本地端口，总连接数可远超 65535）。


# 操作系统与 Linux

## 1. 进程、线程和协程

1. 线程是进程的子集，一个进程中可以包含多个线程，每条线程执行不同的任务，协程是线程的抽象单位，减少了线程切换过程中的资源代价。
2. 进程和线程切换时，需要切换进程和线程的上下文，进程的上下文切换时间开销远远大于线程上下文切换时间，耗费资源较大，效率要差一些。
3. 不同的进程使用不同的内存空间，而所有的线程共享一片相同的内存空间。
4. 进程拥有一个完整的资源平台，而线程只独享必不可少的资源，如寄存器和栈。
5. 一个进程崩溃后，在保护模式下不会对其他进程产生影响，但是一个线程崩溃整个进程都死掉。所以多进程要比多线程健壮。
6. 协程是用户态轻量级线程，Go 语言中在函数前加上 go 关键字就能实现并发。一个 goroutine 会以一个很小的栈启动（Go 1.4 起在 Linux 上初始栈大小为 2KB；历史上 Go 1.2 曾将初始栈从 4KB 提升到 8KB，Go 1.3 引入连续栈后 Go 1.4 又降回 2KB），当遇到栈空间不足时，栈会自动伸缩，因此可以轻易实现成千上万个 goroutine 同时启动。

## 2. 进程通信的手段

由于每个进程的用户空间都是独立的，不能相互访问，这时就需要借助内核空间来实现进程间通信，原因很简单，每个进程都是共享一个内核空间。

1. 管道

管道分为「匿名管道」和「命名管道」。

匿名管道没有名字标识，是特殊文件只存在于内存，没有存在于文件系统中，shell 命令中的「|」竖线就是匿名管道，通信的数据是无格式的流并且大小受限，通信的方式是单向的，数据只能在一个方向上流动，如果要双向通信，需要创建两个管道。匿名管道是只能用于存在父子关系的进程间通信，匿名管道的生命周期随着进程创建而建立，随着进程终止而消失。

命名管道突破了匿名管道只能在亲缘关系进程间的通信限制，因为使用命名管道需要在文件系统创建一个类型为 p 的设备文件，毫无关系的进程可以通过这个设备文件进行通信。

不管是匿名管道还是命名管道，进程写入的数据都是缓存在内核中，另一个进程读取数据时候自然也是从内核中获取，同时通信数据都遵循先进先出原则。

2. 消息队列

消息队列克服了管道通信的数据是无格式的字节流的问题，消息队列实际上是保存在内核的「消息链表」，消息队列的消息体是可以用户自定义的数据类型，发送数据时，会被分成一个一个独立的消息体，当然接收数据时，也要与发送方发送的消息体的数据类型保持一致，这样才能保证读取的数据是正确的。消息队列通信的速度不是最及时的，毕竟每次数据的写入和读取都需要经过用户态与内核态之间的拷贝过程，消息体也有大小限制。

3. 共享内存

共享内存可以解决消息队列通信中用户态与内核态之间数据拷贝过程带来的开销，它直接分配一个共享空间，每个进程都可以直接访问，就像访问进程自己的空间一样快捷方便，不需要陷入内核态或者系统调用，大大提高了通信的速度。

4. 信号量

共享内存通信中，多进程竞争同个共享资源会造成数据的错乱。需要信号量来保护共享资源，以确保任何时刻只能有一个进程访问共享资源。信号量不仅可以实现访问的互斥性，还可以实现进程间的同步，信号量其实是一个计数器，表示的是资源个数，其值可以通过两个原子操作来控制，分别是 P 操作和 V 操作。

5. 信号

信号是唯一的异步通信机制，可以在应用进程和内核之间直接交互，内核也可以利用信号来通知用户空间的进程发生了哪些系统事件，信号事件的来源主要有硬件来源（如键盘 Ctrl+C ）和软件来源（如 kill 命令），一旦有信号发生，进程有三种方式响应信号 1. 执行默认操作、2. 捕捉信号、3. 忽略信号。有两个信号是应用进程无法捕捉和忽略的，即 SIGKILL 和 SIGSTOP，这是为了方便我们能在任何时候结束或停止某个进程。

6. Socket

如果要与不同主机的进程间通信，需要 Socket 通信。它不仅用于不同的主机进程间通信，还可以用于本地主机进程间通信，可根据创建 Socket 的类型不同，分为三种常见的通信方式，一个是基于 TCP 协议的通信方式，一个是基于 UDP 协议的通信方式，一个是本地进程间通信方式。

> 线程通信间的方式呢？

同个进程下的线程之间都是共享进程的资源，只要是共享变量都可以做到线程间通信，比如全局变量，所以对于线程间关注的不是通信方式，而是关注多线程竞争共享资源的问题。

## 3. 线程数据如何同步（互斥与同步的实现与使用）

1. 锁

任何想进入临界区的线程，必须先执行加锁操作。若加锁操作顺利通过，则线程可进入临界区；在完成对临界资源的访问后再执行解锁操作，以释放该临界资源。

分为忙等待锁（自旋锁）和无忙等待锁，其中忙等待锁不能在单核 CPU 中使用。

2. 信号量

通常信号量表示资源的数量，对应的变量是一个整型变量（sem）。有两个原子操作的系统调用函数来控制信号量的，分别是：

* P 操作：将 sem 减 1，相减后，如果 sem < 0，则进程/线程进入阻塞等待，否则继续，表明 P 操作可能会阻塞；
* V 操作：将 sem 加 1，相加后，如果 sem <= 0，唤醒一个等待中的进程/线程，表明 V 操作不会阻塞；

### 互斥

* 如果互斥信号量为 1，表示没有线程进入临界区；
* 如果互斥信号量为 0，表示有一个线程进入临界区；
* 如果互斥信号量为 -1，表示一个线程进入临界区，另一个线程等待进入。

通过互斥信号量的方式，就能保证临界区任何时刻只有一个线程在执行，就达到了互斥的效果。

### 同步

同步的方式是设置一个信号量，其初值为 0。

## 4. 死锁和如何避免死锁

死锁问题的产生是由两个或者以上线程并行执行的时候，争夺资源而互相等待造成的。

死锁只有同时满足以下四个条件才会发生：

* 互斥条件
* 持有并等待条件
* 不可剥夺条件
* 环路等待条件

所以要避免死锁问题，就是要破坏其中一个条件即可，最常用的方法就是使用资源有序分配法来破坏环路等待条件。

线程 A 和 线程 B 获取资源的顺序要一样，当线程 A 是先尝试获取资源 A，然后尝试获取资源 B 的时候，线程 B 同样也是先尝试获取资源 A，然后尝试获取资源 B。也就是说，线程 A 和 线程 B 总是以相同的顺序申请自己想要的资源。

## 5. 操作系统是如何管理虚拟地址与物理地址之间的关系？

主要有两种方式，分别是内存分段和内存分页。对于虚拟地址与物理地址的映射关系，可以有分段和分页的方式，同时两者结合。

内存分段是根据程序的逻辑角度，分成了栈段、堆段、数据段、代码段等，这样可以分离出不同属性的段，同时是一块连续的空间。但是每个段的大小都不是统一的，这就会导致外部内存碎片和内存交换效率低的问题。

于是，就出现了内存分页，把虚拟空间和物理空间分成大小固定的页，如在 Linux 系统中，每一页的大小为 4KB。由于分了页后，就不会产生细小的内存碎片，解决了内存分段的外部内存碎片问题。同时在内存交换的时候，写入硬盘也就一个页或几个页，这就大大提高了内存交换的效率。

为了解决简单分页产生的页表过大的问题，就有了多级页表，它解决了空间上的问题，但这就会导致 CPU 在寻址的过程中，需要有很多层表参与，加大了时间上的开销。于是根据程序的局部性原理，在 CPU 芯片中加入了 TLB，负责缓存最近常被访问的页表项，大大提高了地址的转换速度。

## 6. 虚拟内存有什么用

* 虚拟内存可以使得进程对运行内存超过物理内存大小，因为程序运行符合局部性原理，CPU 访问内存会有很明显的重复访问的倾向性，对于那些没有被经常使用的内存，我们可以把它换出到物理内存之外，比如硬盘上的 swap 区域。
* 由于每个进程都有自己的页表，所以每个进程的虚拟内存空间就是相互独立的。进程也没有办法访问其他进程的页表，所以这些页表是私有的，这就解决了多进程之间地址冲突的问题。

## 7. 内存满了，会发生什么？

如果空闲物理内存不够，那么就会进行内存回收的工作，主要有两种方式：

* 后台内存回收：在物理内存紧张的时候，会唤醒 kswapd 内核线程来回收内存，这个回收内存的过程是异步的，不会阻塞进程的执行。
* 直接内存回收：如果后台异步回收跟不上进程内存申请的速度，就会开始直接回收，这个回收内存的过程是同步的，会阻塞进程的执行。

可被回收的内存类型有文件页和匿名页：

* 文件页的回收：对于干净页是直接释放内存，这个操作不会影响性能，而对于脏页会先写回到磁盘再释放内存，这个操作会发生磁盘 I/O 的，这个操作是会影响系统性能的。
* 匿名页的回收：如果开启了 Swap 机制，那么 Swap 机制会将不常访问的匿名页换出到磁盘中，下次访问时，再从磁盘换入到内存中，这个操作是会影响系统性能的。

文件页和匿名页的回收都是基于 LRU 算法，也就是优先回收不常访问的内存。

## 8. 在 4GB 物理内存的机器上，申请 8G 内存会怎么样？

在 32 位操作系统，因为进程理论上最大能申请 3 GB 大小的虚拟内存，所以直接申请 8G 内存，会申请失败。

在 64 位操作系统，因为进程理论上最大能申请 128 TB 大小的虚拟内存，即使物理内存只有 4GB，申请 8G 内存也是没问题，因为申请的内存是虚拟内存。如果这块虚拟内存被访问了，要看系统有没有 Swap 分区：

* 如果没有 Swap 分区，因为物理空间不够，进程会被操作系统杀掉，原因是 OOM（Out Of Memory，内存耗尽）；
* 如果有 Swap 分区，即使物理内存只有 4GB，程序也能正常使用 8GB 的内存，进程可以正常运行；

## 9. 如何避免预读失效和缓存污染的问题？

为了避免「预读失效」造成的影响，Linux 和 MySQL 对传统的 LRU 链表做了改进：

* Linux 操作系统实现了两个 LRU 链表：活跃 LRU 链表（active list）和非活跃 LRU 链表（inactive list）。
* MySQL Innodb 存储引擎是在一个 LRU 链表上划分出 2 个区域：young 区域 和 old 区域。

为了避免「缓存污染」造成的影响，Linux 操作系统和 MySQL Innodb 存储引擎分别提高了升级为热点数据的门槛：

* Linux 操作系统：在内存页被访问第二次的时候，才将页从 inactive list 升级到 active list 里。
* MySQL Innodb：在内存页被访问第二次的时候，并不会马上将该页从 old 区域升级到 young 区域，因为还要进行停留在 old 区域的时间判断：
  * 如果第二次的访问时间与第一次访问的时间在 1 秒内（默认值），那么该页就不会被从 old 区域升级到 young 区域；
  * 如果第二次的访问时间与第一次访问的时间超过 1 秒，那么该页就会从 old 区域升级到 young 区域；

通过提高进入 active list（或者 young 区域）的门槛，就能很好地避免缓存污染带来的影响。

## 10. 操作系统的锁机制

### 自旋锁

自旋锁是一种忙等待锁，主要用于短时间的临界区。其实现方式是通过不断循环检查锁的状态，直到获得锁为止。在 Linux 中，自旋锁的实现通常涉及对一个锁变量的原子操作，具体过程为：

1. 锁变量用 0 表示锁空闲、1 表示锁被占用。
2. 加锁时，线程通过原子操作（如 xchg / cmpxchg 等指令）尝试将锁变量从 0 交换为 1；若交换前的原值为 0，说明锁空闲，加锁成功。
3. 若原值已经是 1，说明锁被其他线程持有，线程会不断重试（自旋）直到成功。
4. 释放锁时，只需将锁变量的值置回 0 即可。

自旋锁的优点是简单且开销小，但由于其忙等待的特性，长时间持有锁会导致 CPU 资源的浪费，因此适合于临界区执行时间较短的情况。

### 互斥锁

互斥锁是一种睡眠等待型锁，适用于需要长时间持有的临界区。其底层实现主要依赖于 futex（fast user-space mutex），一个用户态和内核态混合的同步机制。互斥锁的实现步骤如下：

1. 当线程调用 `pthread_mutex_lock` 时，如果锁已被其他线程持有，当前线程将进入休眠状态，释放 CPU 资源。
2. 当锁被释放时，内核会唤醒等待该锁的线程，使其重新竞争锁。
3. 互斥锁的状态通过原子操作进行管理，确保在多线程环境下的安全性。

这种机制确保了在多线程环境中对共享资源的独占性访问，有效避免了竞态条件。

### 信号量

信号量是一种用于控制对共享资源访问的同步机制，通常用于进程间的同步。其底层实现依赖于内核的等待队列与唤醒机制（Linux 用户态 POSIX 信号量基于 futex 实现）。信号量的基本操作包括：

1. `sem_wait`：当信号量的值大于 0 时，减 1 并继续执行；当值为 0 时，线程进入等待状态。
2. `sem_post`：增加信号量的值并唤醒等待的线程。

信号量的实现使得多个线程或进程能够安全地访问共享资源，而不必担心数据不一致的问题。

## 11. 一个进程最多可以创建多少个线程？

* 32 位系统，用户态的虚拟空间只有 3G，如果创建线程时分配的栈空间是 10M，那么一个进程最多只能创建 300 个左右的线程。
* 64 位系统，用户态的虚拟空间大到有 128T，理论上不会受虚拟内存大小的限制，而会受系统的参数或性能限制。

## 12. 线程崩溃了，进程也会崩溃吗？

各个线程的地址空间是共享的，既然是共享，那么某个线程对地址的非法访问就会导致内存的不确定性，进而可能会影响到其他线程，这种操作是危险的，操作系统会认为这很可能导致一系列严重的后果，于是干脆让整个进程崩溃。

但如果进程觉得罪不致死，那么它也可以选择自定义一个信号处理函数，这样的话它就可以做一些自定义的逻辑，比如记录 crash 信息等。

## 13. 调度算法合集

### 进程调度

系统调度需要考虑的因素（原因）

1. `CPU利用率`：IO 请求阻塞时，CPU 要从就绪队列运行一个进程
2. `吞吐率`：单位时间完成进程数
3. `等待时间`：就绪队列中进程的等待时间
4. `响应时间`：对于交互性应用（鼠标键盘）所考虑

| 名称                                 | 算法                                           | 适用范围                              |
| ---------------------------------- | -------------------------------------------- | --------------------------------- |
| 先来先服务 `FCFS`                       | 先来后到                                         | 对 `长作业` 有利，适用于 CPU 繁忙型，不适用 IO 繁忙型 |
| 最短作业优先 `SJF`                       | 优先短作业                                        | 对 `短作业` 有利                        |
| 高响应比优先 `HRRN`                      | 优先权 = (等待时间 + 要求服务时间) / 要求服务时间               | 无法预知要求时间，是 `理想` 的                 |
| 时间片轮转 `RR`                         | 各教材口径不一（常见 10\~100ms，亦有 20-50ms）             | 最简单 `公平`                          |
| 多级队列反馈 `Multilevel Feedback Queue` | 每个队列不同优先级，第一级按照 `FCFS`,没完成转入第二级队尾，有高优先级的立马响应 | `兼顾` 长短作业                         |

### 页面置换

将页面从磁盘 ***调入*** 物理内存

| 名称             | 算法           | 适用范围                      |
| -------------- | ------------ | ------------------------- |
| 最佳页面置换算法 `OPT` | 置换未来最长时间不访问的 | `理想` 算法，衡量效率              |
| 先进先出 `FIFO`    | 置换驻留时间长的     | 实现简单，但可能产生 Belady 异常，性能较差 |
| 最近最久未使用 `LRU`  | 置换最久没访问的     | 效率高但开销大，需要每次更新频率链表，因此较少使用 |
| 最不常用 `LFU`     | 置换访问次数最少的    | 计数器成本也不低；只考虑频率没考虑时间       |

## 14. 零拷贝

为了提高文件传输的性能，出现了零拷贝技术，它通过一次系统调用（sendfile 方法）合并了磁盘读取与网络发送两个操作，降低了上下文切换次数。另外，拷贝数据都是发生在内核中的，天然就降低了数据拷贝的次数。

零拷贝技术是基于 PageCache 的，PageCache 会缓存最近访问的数据，提升了访问缓存数据的性能，同时，为了解决机械硬盘寻址慢的问题，它还协助 I/O 调度算法实现了 IO 合并与预读，这也是顺序读比随机读性能好的原因。这些优势，进一步提升了零拷贝的性能。

需要注意的是，零拷贝技术是不允许进程对文件内容作进一步的加工的，比如压缩数据再发送。

1. 减少了 CPU 拷贝数据的次数，提高了文件传输的性能
2. 减少了上下文切换的次数，降低了 CPU 的开销
3. 利用 DMA 技术，CPU 不需要参与数据拷贝，可以做其他的事情

## 15. 怎么传输大文件

当传输大文件时，不能使用零拷贝，因为可能由于 PageCache 被大文件占据，而导致「热点」小文件无法利用到 PageCache，并且大文件的缓存命中率不高，这时就需要使用「异步 IO + 直接 IO 」的方式。

## 16. I/O 多路复用

为了解决 C10K 问题，出现了 I/O 的多路复用，可以只在一个进程里处理多个文件的 I/O，Linux 下有三种提供 I/O 多路复用的 API，分别是：select、poll、epoll。

select 和 poll 并没有本质区别，它们内部都是使用「线性结构」来存储进程关注的 Socket 集合。

在使用的时候，首先需要把关注的 Socket 集合通过 select/poll 系统调用从用户态拷贝到内核态，然后由内核检测事件，当有网络事件产生时，内核需要遍历进程关注 Socket 集合，找到对应的 Socket，并设置其状态为可读/可写，然后把整个 Socket 集合从内核态拷贝到用户态，用户态还要继续遍历整个 Socket 集合找到可读/可写的 Socket，然后对其处理。

很明显发现，select 和 poll 的缺陷在于，当客户端越多，也就是 Socket 集合越大，Socket 集合的遍历和拷贝会带来很大的开销，因此也很难应对 C10K。

epoll 是解决 C10K 问题的利器，通过两个方面解决了 select/poll 的问题。

* epoll 在内核里使用「红黑树」来关注进程所有待检测的 Socket，红黑树是个高效的数据结构，增删改一般时间复杂度是 O(logn)，通过对这棵红黑树的管理，不需要像 select/poll 在每次操作时都传入整个 Socket 集合，减少了内核和用户空间大量的数据拷贝和内存分配。
* epoll 使用事件驱动的机制，内核里维护了一个「链表」来记录就绪事件，只将有事件发生的 Socket 集合传递给应用程序，不需要像 select/poll 那样轮询扫描整个集合（包含有和无事件的 Socket ），大大提高了检测的效率。

而且，epoll 支持边缘触发和水平触发的方式，而 select/poll 只支持水平触发，一般而言，边缘触发的方式会比水平触发的效率高。

## 17. 负载均衡与一致性哈希

哈希算法虽然能建立数据和节点的映射关系，但是每次在节点数量发生变化的时候，最坏情况下所有数据都需要迁移，这样太麻烦了，所以不适用节点数量变化的场景。

为了减少迁移的数据量，就出现了一致性哈希算法。

一致性哈希是指将「存储节点」和「数据」都映射到一个首尾相连的哈希环上，如果增加或者移除一个节点，仅影响该节点在哈希环上顺时针相邻的后继节点，其它数据也不会受到影响。

但是一致性哈希算法不能够均匀的分布节点，会出现大量请求都集中在一个节点的情况，在这种情况下进行容灾与扩容时，容易出现雪崩的连锁反应。

为了解决一致性哈希算法不能够均匀的分布节点的问题，就需要引入虚拟节点，对一个真实节点做多个副本。不再将真实节点映射到哈希环上，而是将虚拟节点映射到哈希环上，并将虚拟节点映射到实际节点，所以这里有「两层」映射关系。

## 18. 进程写文件时，进程发生了崩溃，已写入的数据会丢失吗？

不会，因为进程在执行 write （使用缓冲 IO）系统调用的时候，实际上是将文件数据写到了内核的 PageCache，它是文件系统中用于缓存文件数据的缓冲，所以即使进程崩溃了，文件数据还是保留在内核的 PageCache，我们读数据的时候，也是从内核的 PageCache 读取，因此还是依然读的进程崩溃前写入的数据。

内核会找个合适的时机，将 PageCache 中的数据持久化到磁盘。但是如果 PageCache 里的文件数据，在持久化到磁盘之前，系统发生了崩溃，那这部分数据就会丢失了。

## 19. 进程内存结构

内核区域

栈（从上到下分配）

文件映射匿名内存区（动态库、共享内存，从低地址开始向上增长）

堆内存（从下到上分配）

BSS 段（包括未初始化的静态变量和全局变量）

数据段（包括已初始化的静态常量和全局变量）

代码段（包括二进制可执行代码）

## 20. Linux fork 和 exec 的区别

fork 主要是 Linux 用来建立新的进程（线程）而设计的，exec 系列函数则是用来用指定的程序替换当前进程的全部内容。

## 21. 内核态和用户态的区别

* 用户态是指程序在执行时，所处的特权级较低，只能访问自己的内存空间和 CPU 寄存器，不能直接访问操作系统的资源。
* 内核态是指程序在执行时，所处的特权级较高，可以访问所有的内存空间和 CPU 寄存器，可以直接访问操作系统的资源。

## 22. epoll 的边沿触发与水平触发

* 水平触发是指只要缓冲区中还有数据可读或可写，就会不断地通知应用程序
* 边缘触发是指只有在缓冲区状态发生变化时才会通知应用程序，这样可以减少不必要的事件通知

在水平触发模式下，如果文件描述符已经就绪可以非阻塞的执行 IO 操作了，此时会触发通知。允许在任意时刻重复检测 IO 的状态。select 和 poll 就属于水平触发。而在边缘触发模式下，如果文件描述符自上次状态改变后有新的 IO 活动到来，此时会触发通知。在收到通知后，应用程序需要尽可能多地读取或写入数据，直到返回 EAGAIN 错误为止。

## 23. CPU 缓存

CPU 缓存是位于 CPU 与内存之间的临时存储器，它的容量比内存小的多但是交换速度却比内存要快得多。CPU 缓存分为 3 个部分： L1、L2、L3。其中，L1（一般 32KB；缓存行 cacheLine 一般是 64Byte）、L2（一般 256KB） 是 CPU 独享的，不和其他 CPU 的缓存数据共享，而 L3 （一般 2MB）缓存是所有 CPU 共享的。

## 24. 什么情况下从用户态陷入内核态

1. **系统调用**：这是用户程序主动请求切换到内核态的方式。用户程序通过系统调用接口请求操作系统执行特权操作，如文件读写、进程管理等。系统调用的实现通常涉及软中断机制。
2. **异常**：当 CPU 在执行用户态程序时发生异常（如缺页异常、非法操作码等），CPU 会自动切换到内核态，以便操作系统处理这些异常情况。这种切换是自动的，不需要用户程序的干预。
3. **中断**：外部设备发生中断事件时，CPU 也会自动切换到内核态，以便操作系统处理这些中断。中断可以是来自硬件设备（如键盘、鼠标等）的信号，操作系统会根据中断类型进行相应的处理。

## 25. 用户态向内核态切换时的资源消耗主要是什么资源

当用户态进程需要访问内核态资源时，就会发生用户态到内核态的切换。这个切换过程需要保存用户态的现场，包括寄存器、用户栈等，同时需要复制用户态参数，将用户栈切换到内核栈，进入内核态。这个过程会消耗一定的资源，包括 CPU 时间、内存空间等。

## 26. 为什么内核态的上下文切换开销会大？

内核态上下文切换（进程切换）开销大的主要原因是需要切换地址空间：切换页表后，TLB 和各级 CPU 缓存会失效，后续访问内存需要重新建立映射（缓存未命中），这部分开销远大于寄存器保存/恢复本身。相比之下，进程内线程切换不涉及页表切换，开销要小得多。

## 27. 从本地读取一个文件通过网络发送到另一端，中间涉及几次拷贝？

1. 第一次拷贝：操作系统从磁盘读取文件数据到内核缓冲区。这一步通常通过文件系统来完成。
2. 第二次拷贝：应用程序从内核缓冲区中获取数据并拷贝到应用程序的缓冲区。这一步通常通过系统调用（如 read() 或 recv()）来完成。
3. 第三次拷贝：应用程序将数据从其缓冲区写入到内核的网络堆栈缓冲区。这一步通常通过系统调用（如 write() 或 send()）来完成。
4. 第四次拷贝：网络堆栈将数据从其缓冲区发送到网络接口（如网卡）。这一步为驱动程序负责。

## 28. 如何优化磁盘 I/O

* 在顺序读比较多的场景中，可以增大磁盘的预读数据。
* 可以对应用程序的数据，进行磁盘级别的隔离。比如可以为日志、数据库等 I/O 压力比较重的应用，配置单独的磁盘。
* 可以使用缓存 IO，充分利用系统缓存，降低实际 IO 的次数。

## 29. 线程通信的方式

同个进程下的线程之间都是共享进程的资源，只要是共享变量都可以做到线程间通信，比如全局变量

## 30. socket 可读的情况

* socket 接收缓冲区中已经接收的数据的字节数大于等于 socket 接收缓冲区低潮限度的当前值。
* 连接的读一半关闭 (即接收到对方发过来的 FIN 的 TCP 连接)，并且返回 0。
* socket 收到了对方的 connect 请求已经完成的连接数为非 0。这样的 socket 处于可读状态。

## 31. 查看一个文件最新更新的 100 行怎么做

查看文件 100 行到 200 行：

`head -n 200 filename | tail -n 100`

如果您只想查看文件的最后 100 行，可以使用以下命令：

`tail -n 100 filename`

## 32. 改变文件夹所有权

`chown -R`

## 33. 当一个服务器 CPU 打满了如何排查

* 使用 top 命令查看，是否有进程占用 CPU 过高。可以按 shift+p 按照 CPU 排序，找到占用 CPU 过高的进程的 PID。
* 使用 top -H -p \[进程 ID] 找到进程中消耗资源最高的线程的 ID。

## 34. 当一个服务器内存打满了如何排查

* 使用 top 命令查看，是否有进程占用内存过高。可以按 shift+m 按照内存排序，找到占用内存过高的进程的 PID。
* 使用 top -H -p \[进程 ID] 查看进程内各线程的资源占用，进入后按 M 键按内存占用排序，找到占用内存最高的线程的 ID。

## 35. 文件权限 755

7 代表文件所有者的权限，5 代表组用户和其他用户的权限。具体来说，755 权限将读、写、执行权限分配给文件所有者，将读、执行权限分配给组用户和其他用户

## 36. Linux 中，如何查看系统负载？

可以使用 top 命令来查看系统负载。top 命令会显示当前系统的进程列表，并按照 CPU 占用率或内存占用率进行排序。你可以使用 top 命令来查看系统的负载情况，包括 CPU 使用率、内存使用率、交换分区使用率等.

也可以使用 uptime 命令来查看系统的负载情况。uptime 命令会显示系统的运行时间、当前登录用户数以及系统的平均负载情况。平均负载是指在过去 1 分钟、5 分钟和 15 分钟内，系统处于等待 CPU 或 I/O 的进程数的平均值.

## 37. 什么是平均负载

平均负载（Load Average）是一段时间内系统的平均负载，这个一段时间一般取 1 分钟、5 分钟、15 分钟。它是指在这段时间内，系统处于等待 CPU 或 I/O 的进程数的平均值。如果平均负载是 1，表示系统的 CPU 在这段时间内被占用了 100%。如果平均负载是 2，表示系统的 CPU 在这段时间内被占用了 200%。如果平均负载是 0.5，表示系统的 CPU 在这段时间内被占用了 50%。

> 注：把平均负载直接等同于 CPU 使用率（如负载 2 = CPU 占用 200%）的说法存在争议。平均负载统计的是处于可运行（R）和不可中断睡眠（D，如等待磁盘 I/O）状态的进程数，并不等于 CPU 使用率。支持简化说法的观点认为，在单核且纯 CPU 计算的场景下两者近似；反对观点指出，多核机器上应把负载与 CPU 核数对比（4 核负载 2 表示只用了约一半算力），且大量 D 状态进程会推高负载而 CPU 使用率并不高。

## 38. git pull 和 git fetch 的区别

git pull 是 git fetch + git merge

git fetch 只是将远程仓库的最新的版本下载到本地，但是不会自动 merge，相当于工作区中的文件并没有更新

git pull 会从远程仓库获取到最新的版本并 merge 到本地。

git fetch 更保险一些，git pull 操作更简单

## 39. git merge 和 git rebase 的区别

1. 工作方式：
   * git merge：将一个分支的更改合并到另一个分支时，会创建一个新的合并提交，将两个分支的更改整合在一起。这个合并提交会保留两个分支的完整历史记录，并创建一个新的合并节点。
   * git rebase：将一个分支的更改合并到另一个分支时，会将当前分支的提交复制到目标分支的顶部，然后将当前分支指向复制后的提交。这个过程会“变基”当前分支的提交，使其基于目标分支的最新提交。这样可以创建一个更线性的提交历史。
2. 提交历史：
   * git merge：合并操作会保留两个分支的完整提交历史，包括合并提交。这样可以清楚地看到每个分支的贡献和合并点。
   * git rebase：变基操作会修改当前分支的提交历史，将其放在目标分支的顶部。这样可以创建一个更线性、干净的提交历史。但是，由于提交历史被修改，可能会导致其他开发者在共享分支上的问题。
3. 冲突处理：
   * git merge：当合并操作存在冲突时，Git 会将所有冲突一次性显示给开发者，需要手动解决所有冲突后才能完成合并。
   * git rebase：当变基操作存在冲突时，Git 会在每个提交应用时逐个显示冲突，需要在每个提交上手动解决冲突。这样可以更容易地处理冲突，因为每个提交的冲突范围更小。
4. 适用场景：
   * git merge：适用于在合并分支时保留完整的提交历史记录，并且不需要修改提交的顺序。
   * git rebase：适用于创建一个干净、线性的提交历史，并且不介意修改提交的顺序。特别适用于个人分支或私有分支，不适合在共享分支上使用。

## 40. 什么是 git cherry-pick？

命令 git cherry-pick 通常用于把特定提交从存储仓库的一个分支引入到其他分支中。常见的用途是从维护的分支到开发分支进行向前或回滚提交。

## 41. 操作系统层面，CAS 操作是怎么做的？

通过硬件的支持来实现的。现代的处理器通常提供了一些原子操作的指令，用于执行 CAS 操作。这些指令可以在单个指令周期内完成读取、比较和写入操作，从而保证了操作的原子性。

下面是 CAS 操作的一般过程：

1. 读取共享变量的当前值。
2. 将当前值与预期值进行比较。
3. 如果当前值等于预期值，则将新值写入共享变量。
4. 如果当前值不等于预期值，则说明其他线程已经修改了共享变量，操作失败。

如果 CAS 操作失败，通常需要重新执行整个过程，直到操作成功为止。这种方式可以避免使用锁或其他同步机制，提高并发性能。

## 42. Protobuf fixed32 类型和 int32 类型有什么区别

1. **存储大小**: `fixed32` 类型在内存中占据固定的 4 个字节（32 位），无论存储的值的大小如何。而 `int32` 类型则使用变长编码，根据存储的实际值的大小来决定所占用的字节数。通常情况下，非负的 `int32` 值使用 1-5 个字节来存储（负数会按 64 位符号扩展编码，固定占用 10 个字节，因此大量负数值的场景推荐使用 `sint32`）。
2. **数值范围**: `fixed32` 类型的取值范围是从 0 到 2^32-1（约 42 亿），而 `int32` 类型的取值范围是从 -2^31 到 2^31-1（约 -21 亿到 21 亿）。
3. **符号性**: `fixed32` 类型是无符号的，即只能表示非负整数。而 `int32` 类型是有符号的，可以表示正数、负数和零。

## 43. Linux 系统的 8080 端口有多少个 TCP 连接

```sh
lsof -i :8080 | grep TCP | wc -l
```

```sh
netstat -anp | grep 8080 | grep ESTABLISHED | wc -l
```

## 44. 共享内存的方式如何保证并发安全

* 互斥锁
* 读写锁
* 原子操作（CAS）
* 同步机制（信号量）

## 45. 用户态转到内核态的方式

1. 系统调用：用户态程序通过系统调用请求操作系统提供的服务。系统调用是一种特殊的软件中断，当用户态程序调用系统调用时，会触发一个软件中断，将控制权转移到内核态的系统调用处理程序。
2. 异常：当用户态程序发生异常事件时，如访问非法内存、除零等，会触发异常，导致用户态程序切换到内核态的异常处理程序。
3. 外围设备中断：当外围设备完成用户请求的操作后，会向 CPU 发送中断信号，这时 CPU 会转去处理对应的中断处理程序。

## 46. 什么是中断

中断是计算机系统中的一种机制，用于在程序执行过程中暂停当前任务的执行，并转而处理某个特定事件或请求。当发生中断时，处理器会立即停止当前正在执行的指令，保存当前的执行状态，并跳转到预定义的中断处理程序去处理中断事件。中断可以分为硬件中断和软件中断两种类型。

## 47. 孤儿进程和僵尸进程

孤儿进程是指父进程已经终止，而子进程仍在运行的情况。此时，子进程的父进程 ID 变成 1 (即 init 进程) ，该进程接管孤儿进程的控制，并进行状态收集工作，防止孤儿进程一直运行并占用资源。孤儿进程被 init 进程接管后，通常不会对系统造成危害，因为 init 进程会负责清理孤儿进程并释放它们所占用的资源。

僵尸进程是指子进程已经终止，但其父进程尚未获取子进程的终止状态信息。在这种情况下，子进程的进程描述符仍然保存在系统中，尽管它不再运行，但仍占用系统资源，如进程表项和一些系统资源。如果大量僵尸进程积累，可能导致系统资源耗尽，甚至系统崩溃。

僵尸进程可以通过父进程调用 wait() 或 waitpid() 函数来处理，以避免产生大量的僵尸进程。孤儿进程则由 init 进程自动接管并清理。

## 48. 如果我输入某个域名，想让他不访问这个 ip 要怎么办

* 编辑本地 Hosts 文件
* 使用 DNS 过滤

## 49. 硬链接和软链接

1. **共享 inode**：硬链接共享 inode，软链接则不共享。
2. **删除文件的影响**：删除源文件不会影响硬链接的访问，而删除源文件会导致软链接失效。
3. **创建限制**：硬链接不能跨文件系统或链接目录，而软链接可以。
4. **文件类型**：硬链接是文件的另一个入口，软链接是一个独立的文件，类似于 Windows 的快捷方式。

## 50. 取一个文件操作系统层面中间执行过的所有系统

1. 用户请求

当用户通过应用程序（如文本编辑器或文件管理器）请求打开一个文件时，操作系统接收到这个请求。

2. 系统调用

操作系统使用系统调用接口来处理文件操作。用户进程会调用如 `open()` 的系统调用来请求打开文件。这时，操作系统会进行以下操作：

* **路径解析**：操作系统会解析用户提供的文件路径，确定文件在磁盘上的位置。
* **文件控制块（FCB）**：操作系统会在内存中为该文件创建一个文件控制块，FCB 中包含文件的元数据，如文件大小、权限、位置等信息。

3. 文件系统操作

* **查找目录**：操作系统会在文件系统的目录结构中查找文件。文件系统通常以树状结构组织文件，根目录下可能有多个子目录。
* **读取磁盘信息**：一旦找到文件，操作系统会读取与该文件相关的磁盘块信息，包括文件的起始块号和数据块的位置。

4. 磁盘 I/O 操作

* **磁头定位**：操作系统控制磁盘的磁头移动到正确的柱面和扇区，以便读取文件数据。这个过程包括将磁头移动到指定的柱面，激活对应的盘面，然后在磁盘旋转时读取数据。
* **数据传输**：一旦磁头定位到正确的位置，操作系统会将数据块从磁盘读取到内存中。这个过程可能涉及多个 I/O 操作，特别是当文件数据分散在多个磁盘块上时。

5. 内存管理

* **文件缓冲**：读取的数据会被存储在内存中的缓冲区，以便快速访问。操作系统会维护一个文件描述符表，跟踪打开的文件及其状态。
* **访问权限检查**：在打开文件之前，操作系统还会检查当前进程对该文件的访问权限，确保没有未授权的访问。

6. 完成操作

* **返回文件句柄**：操作系统成功打开文件后，会返回一个文件句柄（或文件描述符）给用户进程，用户可以通过这个句柄进行后续的读写操作。
* **关闭文件**：当用户完成文件操作后，应用程序会调用 `close()` 系统调用，操作系统会更新文件的状态，并释放相关资源。

## 51. 线程创建和进程创建在操作系统层面有什么区别

* **进程创建**：创建一个新进程通常涉及复制父进程的所有资源，包括内存空间、文件描述符等，这一过程开销较大。操作系统需要为新进程分配独立的内存和资源，通常使用 `fork()` 系统调用来实现，这会导致较高的性能开销。
* **线程创建**：线程的创建则相对简单，通常通过调用 `pthread_create()` 等函数来实现。新线程共享其父进程的资源，避免了大量的内存复制，因此创建速度快，开销小。

## 52. 缺页中断

当程序试图访问一个尚未分配物理内存空间的虚拟地址时会发生缺页中断。这种中断会导致当前执行的指令被暂停,然后操作系统会接管控制权,并负责处理这种异常情况。

缺页中断属于内中断,由当前指令发出。中断后程序会阻塞,等待中断程序结束后再执行。中断程序首先判断内存中是否有空闲内存块:

* 如果有,就调入该内存块,并修改页表项
* 如果没有,则启动调度算法选择一个页面淘汰,并将所需页面调入内存。如果被淘汰页面被修改过,需要先写回外存。

### 页面置换算法

当发生缺页中断但内存无空闲物理块时,需要根据页面置换算法从内存中选择一页淘汰到磁盘。常见算法有:

* FIFO(先进先出): 淘汰最先调入内存的页面,但会淘汰经常访问的页面
* LRU(最近最少使用): 淘汰最长时间未访问的页面,实现复杂
* OPT(最佳置换): 淘汰以后不再被访问或最迟才被访问的页面,但无法实现

## 53. Linux 的一台服务器，怎么样去找到某一个程序它对应的进程是多少

* `ps -ef`: 显示所有进程的完整信息,包括进程号和父进程号
* `ps aux`: 以用户为主的格式显示进程状况
* `ps -C <程序名>`: 显示指定程序名的进程信息

例如，查找 tomcat 进程：`ps -ef | grep tomcat`

> 找到了这个进程号，然后我想进一步去找这个进程对应的有哪些线程怎么找 `ps -T -p <pid>`

## 54. 频繁磁盘 I/O 怎么解决

1. **使用内存映射（mmap）**：
   * mmap 将文件映射到进程的虚拟内存空间，允许程序像访问内存一样直接访问文件内容，减少了传统 I/O 操作中的多次数据拷贝过程，从而降低了 I/O 延迟
2. **增加缓存**：
   * 通过增加内存缓存，可以减少对磁盘的直接访问频率。数据首先被读入内存中，后续操作优先从内存中获取。
3. **异步 I/O**：
   * 采用异步 I/O 可以使得程序在等待 I/O 操作完成时继续执行其他任务，从而提高整体效率。
4. **优化文件访问模式**：
   * 通过批量读取或写入数据，减少频繁的小规模 I/O 请求。

## 55. 文件系统的 inode 有什么信息

* **文件类型**：指示该 inode 所对应的对象是文件、目录、设备文件等。
* **所有者信息**：
  * **用户 ID (User ID)**：表示文件的拥有者。
  * **组 ID (Group ID)**：表示文件所属的用户组。
* **访问权限**：包括对文件的读、写和执行权限。
* **时间戳**：
  * **变更时间 (ctime)**：inode 元数据（如权限、链接数等）最后一次变动的时间。注意 ctime 并非文件创建时间，Linux 常规文件系统一般没有标准的创建时间字段（ext4 等有独立的 crtime）。
  * **修改时间 (mtime)**：文件内容最后一次变动的时间。
  * **访问时间 (atime)**：文件最后一次被访问的时间。
* **链接数**：指向该 inode 的硬链接数量，即有多少个文件名指向这个 inode。
* **文件大小**：以字节为单位，表示文件的总字节数。
* **数据块位置**：记录该文件数据存储在磁盘上的具体块（block）位置。

## 56. 为什么频繁系统调用会降低系统性能

1. 上下文切换开销：每次系统调用都会导致用户空间与内核空间之间的上下文切换。这一过程需要保存和恢复寄存器、栈等状态信息，因此引入了性能开销。当系统调用频繁时，程序会花费大量时间在这些切换上，从而减少了实际的计算时间。
2. CPU 资源消耗：系统调用不仅需要进行上下文切换，还涉及到权限检查和参数传递等额外操作。这些操作比普通函数调用的开销要大得多，导致 CPU 需要处理更多的任务，从而影响整体性能。例如，系统调用可能会导致缓存失效，增加了 CPU 访问内存的时间。

## 57. TOCTOU 竞态条件

TOCTOU（Time of Check to Time of Use）是一类并发安全漏洞，核心问题在于：程序先检查某个条件（Check），再基于检查结果执行操作（Use），而这两步之间存在时间窗口，窗口内被检查的状态可能已被其他线程/进程修改。

典型场景是银行转账：

```java
// 线程 A 和线程 B 同时执行以下逻辑
public void transfer(Account account, int amount) {
    if (account.getBalance() >= amount) {   // Check：余额足够
        // CPU 在此切换到另一个线程，另一个线程也通过了检查
        account.deduct(amount);              // Use：实际扣款，可能导致余额为负
    }
}
```

两个线程都读到余额 100，都通过了检查，随后各自扣款，账户最终透支。

**修复方式**有三种。一是原子操作，将检查和使用合并为不可分割的一步，Java 中可用 `AtomicInteger.compareAndSet` 或数据库的 `UPDATE ... WHERE balance >= amount`。二是加锁，用 `synchronized` 或 `ReentrantLock` 保护整个检查+使用的代码块，确保同一时刻只有一个线程进入。三是乐观锁，不加锁，在 Use 阶段验证状态是否与 Check 时一致，不一致则重试，JDK 的 `ConcurrentHashMap` 和数据库版本号机制都是这个思路。

TOCTOU 也是 Linux 提权漏洞的常见来源：攻击者在 root 进程检查文件路径和实际读写之间，将普通文件替换为指向 `/etc/passwd` 的符号链接，从而绕过权限检查。


# 数据存储

## MySQL

### 1. 执行一条 select 语句，期间发生了什么？

* 连接器：建立连接，管理连接、校验用户身份；
* 查询缓存：查询语句如果命中查询缓存则直接返回，否则继续往下执行。MySQL 8.0 已删除该模块；
* 解析 SQL，通过解析器对 SQL 查询语句进行词法分析、语法分析，然后构建语法树，方便后续模块读取表名、字段、语句类型；
* 执行 SQL：执行 SQL 共有三个阶段：
  * 预处理阶段：检查表或字段是否存在；将 select \* 中的 \* 符号扩展为表上的所有列。
  * 优化阶段：基于查询成本的考虑， 选择查询成本最小的执行计划；
  * 执行阶段：根据执行计划执行 SQL 查询语句，从存储引擎读取记录，返回给客户端；

### 2. MySQL 的 NULL 值是怎么存放的？

MySQL 的 Compact 行格式中会用「NULL 值列表」来标记值为 NULL 的列，NULL 值并不会存储在行格式中的真实数据部分。

NULL 值列表按位图方式存储，每 8 个可空字段占用 1 字节（只要存在可空字段就至少占用 1 字节），当表中所有字段都定义成 NOT NULL，行格式中就不会有 NULL 值列表，这样可以节省这部分空间。

### 3. MySQL 怎么知道 varchar(n) 实际占用数据的大小？

MySQL 的 Compact 行格式中会用「变长字段长度列表」存储变长字段实际占用的数据大小。

### 4. varchar(n) 中 n 最大取值为多少？

如果一张表只有一个 varchar(n) 字段，且允许为 NULL，字符集为 ascii。varchar(n) 中 n 最大取值为 65532。

计算公式：65535 - 变长字段字节数列表所占用的字节数 - NULL 值列表所占用的字节数 = 65535 - 2 - 1 = 65532。

如果有多个字段的话，要保证所有字段的长度 + 变长字段字节数列表所占用的字节数 + NULL 值列表所占用的字节数 <= 65535。

### 5. 行溢出后，MySQL 是怎么处理的？

如果一个数据页存不了一条记录，InnoDB 存储引擎会自动将溢出的数据存放到「溢出页」中。

Compact 行格式针对行溢出的处理是这样的：当发生行溢出时，在记录的真实数据处只会保存该列的一部分数据，而把剩余的数据放在「溢出页」中，然后真实数据处用 20 字节存储指向溢出页的地址，从而可以找到剩余数据所在的页。

Compressed 和 Dynamic 这两种格式采用完全的行溢出方式，记录的真实数据处不会存储该列的一部分数据，只存储 20 个字节的指针来指向溢出页。而实际的数据都存储在溢出页中。

### 6. 事务隔离级别是怎么实现的？

InnoDB 引擎的默认隔离级别是可重复读。

* 要解决脏读现象，就要将隔离级别升级到读已提交以上的隔离级别
* 要解决不可重复读现象，就要将隔离级别升级到可重复读以上的隔离级别。
* 对于幻读现象。MySQL InnoDB 引擎的默认隔离级别虽然是「可重复读」，但是它很大程度上避免幻读现象（并不是完全解决了），解决的方案有两种：
  * 针对快照读（普通 select 语句），是通过 MVCC 方式解决了幻读，因为可重复读隔离级别下，事务执行过程中看到的数据，一直跟这个事务启动时看到的数据是一致的，即使中途有其他事务插入了一条数据，是查询不出来这条数据的，所以就很好了避免幻读问题。
  * 针对当前读（select ... for update 等语句），是通过 next-key lock（记录锁 + 间隙锁）方式解决了幻读，因为当执行 select ... for update 语句的时候，会加上 next-key lock，如果有其他事务在 next-key lock 锁范围内插入了一条记录，那么这个插入语句就会被阻塞，无法成功插入，所以就很好了避免幻读问题。

对于「读提交」和「可重复读」隔离级别的事务来说，它们是通过 Read View 来实现的，它们的区别在于创建 Read View 的时机不同：

* 「读提交」隔离级别是在每个 select 都会生成一个新的 Read View，也意味着，事务期间的多次读取同一条数据，前后两次读的数据可能会出现不一致，因为可能这期间另外一个事务修改了该记录，并提交了事务。
* 「可重复读」隔离级别是启动事务时生成一个 Read View，然后整个事务期间都在用这个 Read View，这样就保证了在事务期间读到的数据都是事务启动前的记录。（严格来说，Read View 是在事务中第一次执行快照读语句时才生成的，而不是在 begin 时立即生成）

这两个隔离级别实现是通过「事务的 Read View 里的字段」和「记录中的两个隐藏列」的比对，来控制并发事务访问同一个记录时的行为，这就叫 MVCC（多版本并发控制）。

### 7. MySQL 可重复读隔离级别，完全解决幻读了吗？

* 对于快照读， MVCC 并不能完全避免幻读现象。因为当事务 A 更新了一条事务 B 插入的记录，那么事务 A 前后两次查询的记录条目就不一样了，所以就发生幻读。
* 对于当前读，如果事务开启后，并没有执行当前读，而是先快照读，然后这期间如果其他事务插入了一条记录，那么事务后续使用当前读进行查询的时候，就会发现两次查询的记录条目就不一样了，所以就发生幻读。

所以，MySQL 可重复读隔离级别并没有彻底解决幻读，只是很大程度上避免了幻读现象的发生。

要避免这类特殊场景下发生幻读的现象的话，就是尽量在开启事务之后，马上执行 select ... for update 这类当前读的语句，因为它会对记录加 next-key lock，从而避免其他事务插入一条新记录。

### 8. 为什么 MySQL InnoDB 选择 B+tree 作为索引的数据结构？

1. B+Tree vs B Tree

B+Tree 只在叶子节点存储数据，而 B 树 的非叶子节点也要存储数据，所以 B+Tree 的单个节点的数据量更小，在相同的磁盘 I/O 次数下，就能查询更多的节点。

另外，B+Tree 叶子节点采用的是双链表连接，适合 MySQL 中常见的基于范围的顺序查找，而 B 树无法做到这一点。

2. B+Tree vs 二叉树

对于有 N 个叶子节点的 B+Tree，其搜索复杂度为 O(logdN)，其中 d 表示节点允许的最大子节点个数为 d 个。

在实际的应用当中， d 值是大于 100 的，这样就保证了，即使数据达到千万级别时，B+Tree 的高度依然维持在 3~~4 层左右，也就是说一次数据查询操作只需要做 3~~4 次的磁盘 I/O 操作就能查询到目标数据。

而二叉树的每个父节点的儿子节点个数只能是 2 个，意味着其搜索复杂度为 O(logN)，这已经比 B+Tree 高出不少，因此二叉树检索到目标数据所经历的磁盘 I/O 次数要更多。

3. B+Tree vs Hash

Hash 在做等值查询的时候效率高，搜索复杂度为 O(1)，但是 Hash 表不适合做范围查询。

### 9. 联合索引

联合索引的最左匹配原则，在遇到范围查询（如 >、<）的时候，就会停止匹配，也就是范围查询的字段可以用到联合索引，但是在范围查询字段的后面的字段无法用到联合索引。注意，对于 >=、<=、BETWEEN、like 前缀匹配的范围查询，并不会停止匹配。

### 10. 索引下推

在 MySQL 5.6 之前，只能从 ID（主键值）开始一个个回表，到「主键索引」上找出数据行，再对比 b 字段值。

而 MySQL 5.6 引入的索引下推优化（index condition pushdown）， 可以在联合索引遍历过程中，对联合索引中包含的字段先做判断，直接过滤掉不满足条件的记录，减少回表次数。

当你的查询语句的执行计划里，出现了 Extra 为 Using index condition，那么说明使用了索引下推的优化。

### 11. 什么时候需要 / 不需要创建索引？

#### 需要

* 字段有唯一性限制的，比如商品编码；
* 经常用于 WHERE 查询条件的字段，这样能够提高整个表的查询速度，如果查询条件不是一个字段，可以建立联合索引。
* 经常用于 GROUP BY 和 ORDER BY 的字段，这样在查询的时候就不需要再去做一次排序了，因为我们都已经知道了建立索引之后在 B+Tree 中的记录都是排序好的。

#### 不需要

* WHERE 条件，GROUP BY，ORDER BY 里用不到的字段，索引的价值是快速定位。
* 字段中存在大量重复数据，不需要创建索引，比如性别字段，只有男女。因为 MySQL 还有一个查询优化器，查询优化器发现某个值出现在表的数据行中的百分比很高的时候，它一般会忽略索引，进行全表扫描。
* 表数据太少的时候，不需要创建索引。
* 经常更新的字段不用创建索引，比如不要对电商项目的用户余额建立索引，因为索引字段频繁修改，由于要维护 B+Tree 的有序性，那么就需要频繁的重建索引，这个过程是会影响数据库性能的。

### 12. 有什么优化索引的方法？

* 前缀索引优化；

使用前缀索引是为了减小索引字段大小，可以增加一个索引页中存储的索引值，有效提高索引的查询速度。在一些大字符串的字段作为索引时，使用前缀索引可以帮助我们减小索引项的大小。

* 覆盖索引优化；

覆盖索引是指 SQL 中 query 的所有字段，在索引 B+Tree 的叶子节点上都能找得到的那些索引，从二级索引中查询得到记录，而不需要通过聚簇索引查询获得，可以避免回表的操作。

* 主键索引最好自增；

如果我们使用自增主键，那么每次插入的新数据就会按顺序添加到当前索引节点的位置，不需要移动已有的数据，当页面写满，就会自动开辟一个新页面。因为每次插入一条新记录，都是追加操作，不需要重新移动数据，因此这种插入数据的方法效率非常高。

* 防止索引失效；
  * 当我们使用左或者左右模糊匹配的时候，也就是 like %xx 或者 like %xx% 这两种方式都会造成索引失效；
  * 当我们在查询条件中对索引列做了计算、函数、类型转换操作，这些情况下都会造成索引失效；
  * 联合索引要能正确使用需要遵循最左匹配原则，也就是按照最左优先的方式进行索引的匹配，否则就会导致索引失效。
  * 在 WHERE 子句中，如果在 OR 前的条件列是索引列，而在 OR 后的条件列不是索引列，那么索引会失效。

### 13. 从数据页的角度看 B+ 树

InnoDB 的数据是按「数据页」为单位来读写的，默认数据页大小为 16 KB。每个数据页之间通过双向链表的形式组织起来，物理上不连续，但是逻辑上连续。

数据页内包含用户记录，每个记录之间用单向链表的方式组织起来，为了加快在数据页内高效查询记录，设计了一个页目录，页目录存储各个槽（分组），且主键值是有序的，于是可以通过二分查找法的方式进行检索从而提高效率。

为了高效查询记录所在的数据页，InnoDB 采用 B+ 树作为索引，每个节点都是一个数据页。

如果叶子节点存储的是实际数据的就是聚簇索引，一个表只能有一个聚簇索引；如果叶子节点存储的不是实际数据，而是主键值则就是二级索引，一个表中可以有多个二级索引。

在使用二级索引进行查找数据时，如果查询的数据能在二级索引找到，那么就是「索引覆盖」操作，如果查询的数据不在二级索引里，就需要先在二级索引找到主键值，需要去聚簇索引中获得数据行，这个过程就叫作「回表」。

### 14. 为什么 MySQL 采用 B+ 树作为索引？

二分查找树虽然是一个天然的二分结构，能很好的利用二分查找快速定位数据，但是它存在一种极端的情况，每当插入的元素都是树内最大的元素，就会导致二分查找树退化成一个链表，此时查询复杂度就会从 O(logn) 降低为 O(n)。

为了解决二分查找树退化成链表的问题，就出现了自平衡二叉树，保证了查询操作的时间复杂度就会一直维持在 O(logn) 。但是它本质上还是一个二叉树，每个节点只能有 2 个子节点，随着元素的增多，树的高度会越来越高。

B 树和 B+ 都是通过多叉树的方式，会将树的高度变矮，所以这两个数据结构非常适合检索存于磁盘中的数据。

但是 MySQL 默认的存储引擎 InnoDB 采用的是 B+ 作为索引的数据结构，原因有：

* B+ 树的非叶子节点不存放实际的记录数据，仅存放索引，因此数据量相同的情况下，相比存储即存索引又存记录的 B 树，B+ 树的非叶子节点可以存放更多的索引，因此 B+ 树可以比 B 树更「矮胖」，查询底层节点的磁盘 I/O 次数会更少。
* B+ 树有大量的冗余节点（所有非叶子节点都是冗余索引），这些冗余索引让 B+ 树在插入、删除的效率都更高，比如删除节点的时候，B+ 树只需要在叶子节点层进行调整，而 B 树可能发生复杂的节点合并等树结构变化；
* B+ 树叶子节点之间用链表连接了起来，有利于范围查询，而 B 树要实现范围查询，因此只能通过树的遍历来完成范围查询，这会涉及多个节点的磁盘 I/O 操作，范围查询效率不如 B+ 树。

### 15. MySQL 单表不要超过 2000W 行，靠谱吗？

索引结构不会影响单表最大行数，2000W 也只是推荐值，超过了这个值可能会导致 B + 树层级更高，影响查询性能。

### 16. 索引失效有哪些？

* 当我们使用左或者左右模糊匹配的时候，也就是 like %xx 或者 like %xx% 这两种方式都会造成索引失效；
* 当我们在查询条件中对索引列使用函数、表达式计算，就会导致索引失效。
* 联合索引要能正确使用需要遵循最左匹配原则，也就是按照最左优先的方式进行索引的匹配，否则就会导致索引失效。
* 在 WHERE 子句中，如果在 OR 前的条件列是索引列，而在 OR 后的条件列不是索引列，那么索引会失效。

### 17. MySQL 使用 like “%x“，索引一定会失效吗？

如果数据库表中的字段只有主键 + 二级索引，那么即使使用了左模糊匹配，也不会走全表扫描（type=all），而是走全扫描二级索引树 (type=index)。

### 18. count(\*) 和 count(1) 有什么区别？哪个性能最好？

count(1)、 count(\*)、 count(主键字段) 在执行的时候，如果表里存在二级索引，优化器就会选择二级索引进行扫描。

所以，如果要执行 count(1)、 count(\*)、 count(主键字段) 时，尽量在数据表上建立二级索引，这样优化器会自动采用 key\_len 最小的二级索引进行扫描，相比于扫描主键索引效率会高一些。

再来，就是不要使用 count(字段) 来统计记录个数，因为它的效率是最差的，会采用全表扫描的方式来统计。如果你非要统计表中该字段不为 NULL 的记录个数，建议给这个字段建立一个二级索引。

### 19. 如何优化 count(\*)？

* 近似值

可以使用 show table status 或者 explain 命令来表进行估算。

* 额外表保存计数值

当我们在数据表插入一条记录的同时，将计数表中的计数字段 + 1。也就是说，在新增和删除操作时，我们需要额外维护这个计数表。

### 20. 全局锁是怎么用的？

整个数据库就处于只读状态了，这时其他线程执行以下操作，都会被阻塞：

对数据的增删改操作，比如 insert、delete、update 等语句； 对表结构的更改操作，比如 alter table、drop table 等语句。 如果要释放全局锁，则要执行这条命令：

```sh
unlock tables
```

当然，当会话断开了，全局锁会被自动释放。

### 21. 全局锁应用场景是什么？

全局锁主要应用于做全库逻辑备份，这样在备份数据库期间，不会因为数据或表结构的更新，而出现备份文件的数据与预期的不一样。

### 22. 全局锁又会带来什么缺点呢？

加上全局锁，意味着整个数据库都是只读状态。

那么如果数据库里有很多数据，备份就会花费很多的时间，关键是备份期间，业务只能读数据，而不能更新数据，这样会造成业务停滞。

### 23. MySQL 表级锁有哪些？具体怎么用的。

#### 表锁

表锁除了会限制别的线程的读写外，也会限制本线程接下来的读写操作。

也就是说如果本线程对学生表加了「共享表锁」，那么本线程接下来如果要对学生表执行写操作的语句，是会被阻塞的，当然其他线程对学生表进行写操作时也会被阻塞，直到锁被释放。

不过尽量避免在使用 InnoDB 引擎的表使用表锁，因为表锁的颗粒度太大，会影响并发性能。

#### 元数据锁（MDL）

我们不需要显示的使用 MDL，因为当我们对数据库表进行操作时，会自动给这个表加上 MDL：

* 对一张表进行 CRUD 操作时，加的是 MDL 读锁；
* 对一张表做结构变更操作的时候，加的是 MDL 写锁；

MDL 是为了保证当用户对表执行 CRUD 操作时，防止其他线程对这个表结构做了变更。

当有线程在执行 select 语句（ 加 MDL 读锁）的期间，如果有其他线程要更改该表的结构（ 申请 MDL 写锁），那么将会被阻塞，直到执行完 select 语句（ 释放 MDL 读锁）。

反之，当有线程对表结构进行变更（ 加 MDL 写锁）的期间，如果有其他线程执行了 CRUD 操作（ 申请 MDL 读锁），那么就会被阻塞，直到表结构变更完成（ 释放 MDL 写锁）。

#### 意向锁

意向共享锁和意向独占锁是表级锁，不会和行级的共享锁和独占锁发生冲突，而且意向锁之间也不会发生冲突，只会和共享表锁（lock tables ... read）和独占表锁（lock tables ... write）发生冲突。

意向锁的目的是为了快速判断表里是否有记录被加锁。

#### AUTO-INC 锁

之后可以在插入数据时，可以不指定主键的值，数据库会自动给主键赋值递增的值，这主要是通过 AUTO-INC 锁实现的。

* 当 innodb\_autoinc\_lock\_mode = 0，就采用 AUTO-INC 锁，语句执行结束后才释放锁；
* 当 innodb\_autoinc\_lock\_mode = 2，就采用轻量级锁，申请自增主键后就释放锁，并不需要等语句执行后才释放。
* 当 innodb\_autoinc\_lock\_mode = 1：
  * 普通 insert 语句，自增锁在申请之后就马上释放；
  * 类似 insert … select 这样的批量插入数据的语句，自增锁还是要等语句结束后才被释放；

### 24. MDL 不需要显式调用，那它是在什么时候释放的?

MDL 是在事务提交后才会释放，这意味着事务执行期间，MDL 是一直持有的。

申请 MDL 锁的操作会形成一个队列，队列中写锁获取优先级高于读锁，一旦出现 MDL 写锁等待，会阻塞后续该表的所有 CRUD 操作。

### 25. 行级锁

#### Record Lock

Record Lock 称为记录锁，锁住的是一条记录。而且记录锁是有 S 锁和 X 锁之分的：

* 当一个事务对一条记录加了 S 型记录锁后，其他事务也可以继续对该记录加 S 型记录锁（S 型与 S 锁兼容），但是不可以对该记录加 X 型记录锁（S 型与 X 锁不兼容）;
* 当一个事务对一条记录加了 X 型记录锁后，其他事务既不可以对该记录加 S 型记录锁（S 型与 X 锁不兼容），也不可以对该记录加 X 型记录锁（X 型与 X 锁不兼容）。

#### Gap Lock

Gap Lock 称为间隙锁，只存在于可重复读隔离级别，目的是为了解决可重复读隔离级别下幻读的现象。（严格来说，在读已提交隔离级别下，间隙锁仍会用于外键约束检查和重复键检查等场景，但不会用于防止幻读）

假设，表中有一个范围 id 为（3，5）间隙锁，那么其他事务就无法插入 id = 4 这条记录了，这样就有效的防止幻读现象的发生。

间隙锁虽然存在 X 型间隙锁和 S 型间隙锁，但是并没有什么区别，间隙锁之间是兼容的，即两个事务可以同时持有包含共同间隙范围的间隙锁，并不存在互斥关系，因为间隙锁的目的是防止插入幻影记录而提出的。

#### Next-Key Lock

Next-Key Lock 称为临键锁，是 Record Lock + Gap Lock 的组合，锁定一个范围，并且锁定记录本身。

假设，表中有一个范围 id 为（3，5] 的 next-key lock，那么其他事务即不能插入 id = 4 记录，也不能修改 id = 5 这条记录。

next-key lock 是包含间隙锁 + 记录锁的，如果一个事务获取了 X 型的 next-key lock，那么另外一个事务在获取相同范围的 X 型的 next-key lock 时，是会被阻塞的。

#### 插入意向锁

一个事务在插入一条记录的时候，需要判断插入位置是否已被其他事务加了间隙锁（next-key lock 也包含间隙锁）。

如果有的话，插入操作就会发生阻塞，直到拥有间隙锁的那个事务提交为止（释放间隙锁的时刻），在此期间会生成一个插入意向锁，表明有事务想在某个区间插入新记录，但是现在处于等待状态。

插入意向锁名字虽然有意向锁，但是它并不是意向锁，它是一种特殊的间隙锁，属于行级别锁。

插入意向锁与间隙锁的另一个非常重要的差别是：尽管「插入意向锁」也属于间隙锁，但两个事务却不能在同一时间内，一个拥有间隙锁，另一个拥有该间隙区间内的插入意向锁。

### 26. MySQL 是怎么加锁的？

唯一索引等值查询：

* 当查询的记录是「存在」的，在索引树上定位到这一条记录后，将该记录的索引中的 next-key lock 会退化成「记录锁」。
* 当查询的记录是「不存在」的，在索引树找到第一条大于该查询记录的记录后，将该记录的索引中的 next-key lock 会退化成「间隙锁」。

非唯一索引等值查询：

* 当查询的记录「存在」时，由于不是唯一索引，所以肯定存在索引值相同的记录，于是非唯一索引等值查询的过程是一个扫描的过程，直到扫描到第一个不符合条件的二级索引记录就停止扫描。在扫描的过程中，对扫描到的二级索引记录加的是 next-key 锁，而对于第一个不符合条件的二级索引记录，该二级索引的 next-key 锁会退化成间隙锁。同时，在符合查询条件的记录的主键索引上加记录锁。
* 当查询的记录「不存在」时，扫描到第一条不符合条件的二级索引记录，该二级索引的 next-key 锁会退化成间隙锁。因为不存在满足查询条件的记录，所以不会对主键索引加锁。

### 27. update 没加索引会锁全表？

当我们要执行 update 语句的时候，确保 where 条件中带上了索引列，防止因为扫描全表，而对表中的所有记录加上锁。

我们可以打开 MySQL sql\_safe\_updates 参数，这样可以预防 update 操作时 where 条件没有带上索引列。

如果发现即使在 where 条件中带上了列索引列，优化器走的还是全表扫描，这时我们就要使用 force index(\[index\_name]) 可以告诉优化器使用哪个索引。

### 28. MySQL 记录锁 + 间隙锁可以防止删除操作而导致的幻读吗？

在 MySQL 的可重复读隔离级别下，针对当前读的语句会对索引加记录锁 + 间隙锁，这样可以避免其他事务执行增、删、改时导致幻读的问题。

### 29. MySQL 死锁了，怎么办？

死锁的四个必要条件：互斥、占有且等待、不可剥夺（不可抢占）、循环等待。只要系统发生死锁，这些条件必然成立，但是只要破坏任意一个条件就死锁就不会成立。

在数据库层面，有两种策略通过「打破循环等待条件」来解除死锁状态：

* 设置事务等待锁的超时时间。当一个事务的等待时间超过该值后，就对这个事务进行回滚。
* 开启主动死锁检测。主动死锁检测在发现死锁后，主动回滚死锁链条中的某一个事务，让其他事务得以继续执行。

### 30. 为什么需要 undo log？

* 实现事务回滚，保障事务的原子性。事务处理过程中，如果出现了错误或者用户执行了 ROLLBACK 语句，MySQL 可以利用 undo log 中的历史数据将数据恢复到事务开始之前的状态。
* 实现 MVCC（多版本并发控制）关键因素之一。MVCC 是通过 ReadView + undo log 实现的。undo log 为每条记录保存多份历史数据，MySQL 在执行快照读（普通 select 语句）的时候，会根据事务的 Read View 里的信息，顺着 undo log 的版本链找到满足其可见性的记录。

### 31. 为什么需要 Buffer Pool？

* 当读取数据时，如果数据存在于 Buffer Pool 中，客户端就会直接读取 Buffer Pool 中的数据，否则再去磁盘中读取。
* 当修改数据时，如果数据存在于 Buffer Pool 中，那直接修改 Buffer Pool 中数据所在的页，然后将其页设置为脏页（该页的内存数据和磁盘上的数据已经不一致），为了减少磁盘 I/O，不会立即将脏页写入磁盘，后续由后台线程选择一个合适的时机将脏页写入到磁盘。

### 32. Buffer Pool 缓存什么？

InnoDB 会为 Buffer Pool 申请一片连续的内存空间，然后按照默认的 16KB 的大小划分出一个个的页， Buffer Pool 中的页就叫做缓存页。

开启事务后，InnoDB 层更新记录前，首先要记录相应的 undo log，如果是更新操作，需要把被更新的列的旧值记下来，也就是要生成一条 undo log，undo log 会写入 Buffer Pool 中的 Undo 页面。

当我们查询一条记录时，InnoDB 是会把整个页的数据加载到 Buffer Pool 中，将页加载到 Buffer Pool 后，再通过页里的「页目录」去定位到某条具体的记录。

### 33. 为什么需要 redo log？

* 实现事务的持久性，让 MySQL 有 crash-safe 的能力，能够保证 MySQL 在任何时间段突然崩溃，重启后之前已提交的记录都不会丢失；
* 将写操作从「随机写」变成了「顺序写」，提升 MySQL 写入磁盘的性能。

### 34. redo log 和 undo log 区别在哪？

* redo log 记录了此次事务「完成后」的数据状态，记录的是更新之后的值；
* undo log 记录了此次事务「开始前」的数据状态，记录的是更新之前的值；

所以有了 redo log，再通过 WAL 技术，InnoDB 就可以保证即使数据库发生异常重启，之前已提交的记录都不会丢失，这个能力称为 crash-safe（崩溃恢复）。可以看出来， redo log 保证了事务四大特性中的持久性。

### 35. 什么是 WAL

为了防止断电导致数据丢失的问题，当有一条记录需要更新的时候，InnoDB 引擎就会先更新内存（同时标记为脏页），然后将本次对这个页的修改以 redo log 的形式记录下来，这个时候更新就算完成了。

后续，InnoDB 引擎会在适当的时候，由后台线程将缓存在 Buffer Pool 的脏页刷新到磁盘里，这就是 WAL （Write-Ahead Logging）技术。

WAL 技术指的是， MySQL 的写操作并不是立刻写到磁盘上，而是先写日志，然后在合适的时间再写到磁盘上。

### 36. 产生的 redo log 是直接写入磁盘的吗？

不是的。

实际上， 执行一个事务的过程中，产生的 redo log 也不是直接写入磁盘的，因为这样会产生大量的 I/O 操作，而且磁盘的运行速度远慢于内存。

所以，redo log 也有自己的缓存—— redo log buffer，每当产生一条 redo log 时，会先写入到 redo log buffer

### 37. redo log 什么时候刷盘？

* MySQL 正常关闭时；
* 当 redo log buffer 中记录的写入量大于 redo log buffer 内存空间的一半时，会触发落盘（该触发条件适用于 MySQL 8.0.11 之前的版本；8.0.11 起 InnoDB 引入专用的 log writer 线程持续将 redo log buffer 写入系统缓存，8.0.22 起可通过 innodb\_log\_writer\_threads 参数控制）；
* InnoDB 的后台线程每隔 1 秒，将 redo log buffer 持久化到磁盘。
* 每次事务提交时都将缓存在 redo log buffer 里的 redo log 直接持久化到磁盘

除此之外，InnoDB 还提供了另外两种策略，由参数 innodb\_flush\_log\_at\_trx\_commit 参数控制，可取的值有：0、1、2，默认值为 1，这三个值分别代表的策略如下：

* 当设置该参数为 0 时，表示每次事务提交时 ，还是将 redo log 留在 redo log buffer 中 ，该模式下在事务提交时不会主动触发写入磁盘的操作。
* 当设置该参数为 1 时，表示每次事务提交时，都将缓存在 redo log buffer 里的 redo log 直接持久化到磁盘，这样可以保证 MySQL 异常重启之后数据不会丢失。
* 当设置该参数为 2 时，表示每次事务提交时，都只是缓存在 redo log buffer 里的 redo log 写到 redo log 文件，注意写入到「 redo log 文件」并不意味着写入到了磁盘，因为操作系统的文件系统中有个 Page Cache，是专门用来缓存文件数据的，所以写入「 redo log 文件」意味着写入到了操作系统的文件缓存。

### 38. innodb\_flush\_log\_at\_trx\_commit 为 0 和 2 的时候，什么时候才将 redo log 写入磁盘？

InnoDB 的后台线程每隔 1 秒：

* 针对参数 0 ：会把缓存在 redo log buffer 中的 redo log ，通过调用 write() 写到操作系统的 Page Cache，然后调用 fsync() 持久化到磁盘。所以参数为 0 的策略，MySQL 进程的崩溃会导致上一秒钟所有事务数据的丢失;
* 针对参数 2 ：调用 fsync，将缓存在操作系统中 Page Cache 里的 redo log 持久化到磁盘。所以参数为 2 的策略，较取值为 0 情况下更安全，因为 MySQL 进程的崩溃并不会丢失数据，只有在操作系统崩溃或者系统断电的情况下，上一秒钟所有事务数据才可能丢失。

### 39. redo log 文件写满了怎么办？

如果 write pos 追上了 checkpoint，就意味着 redo log 文件满了，这时 MySQL 不能再执行新的更新操作。此时会停下来将 Buffer Pool 中的脏页刷新到磁盘中，然后标记 redo log 哪些记录可以被擦除，接着对旧的 redo log 记录进行擦除，等擦除完旧记录腾出了空间，checkpoint 就会往后移动，然后 MySQL 恢复正常运行，继续执行新的更新操作。

所以，一次 checkpoint 的过程就是脏页刷新到磁盘中变成干净页，然后标记 redo log 哪些记录可以被覆盖的过程。

### 40. 为什么需要 binlog ？

binlog 用于备份恢复、主从复制；

### 41. redo log 和 binlog 有什么区别？

1. 适用对象不同：

* binlog 是 MySQL 的 Server 层实现的日志，所有存储引擎都可以使用；
* redo log 是 Innodb 存储引擎实现的日志；

2. 文件格式不同：

* binlog 有 3 种格式类型，分别是 STATEMENT、ROW（MySQL 5.7.7 起为默认格式）、MIXED，区别如下：
  * STATEMENT：每一条修改数据的 SQL 都会被记录到 binlog 中（相当于记录了逻辑操作，所以针对这种格式， binlog 可以称为逻辑日志），主从复制中 slave 端再根据 SQL 语句重现。但 STATEMENT 有动态函数的问题，比如你用了 now 函数，你在主库上执行的结果并不是你在从库执行的结果，这种随时在变的函数会导致复制的数据不一致；
  * ROW：记录行数据最终被修改成什么样了，不会出现 STATEMENT 下动态函数的问题。但 ROW 的缺点是每行数据的变化结果都会被记录，比如执行批量 update 语句，更新多少行数据就会产生多少条记录，使 binlog 文件过大；
  * MIXED：包含了 STATEMENT 和 ROW 模式，它会根据不同的情况自动使用 ROW 模式和 STATEMENT 模式；
* redo log 是物理日志，记录的是在某个数据页做了什么修改，比如对 XXX 表空间中的 YYY 数据页 ZZZ 偏移量的地方做了 AAA 更新；

3. 写入方式不同：

* binlog 是追加写，写满一个文件，就创建一个新的文件继续写，不会覆盖以前的日志，保存的是全量的日志。
* redo log 是循环写，日志空间大小是固定，全部写满就从头开始，保存未被刷入磁盘的脏页日志。

4. 用途不同：

* binlog 用于备份恢复、主从复制；
* redo log 用于掉电等故障恢复。

### 42. 如果不小心整个数据库的数据被删除了，能使用 redo log 文件恢复数据吗？

不可以使用 redo log 文件恢复，只能使用 binlog 文件恢复。

因为 redo log 文件是循环写，是会边写边擦除日志的，只记录未被刷入磁盘的数据的物理日志，已经刷入磁盘的数据都会从 redo log 文件里擦除。

binlog 文件保存的是全量的日志，也就是保存了所有数据变更的情况，理论上只要记录在 binlog 上的数据，都可以恢复，所以如果不小心整个数据库的数据被删除了，得用 binlog 文件恢复数据。

### 43. 主从复制是怎么实现？

MySQL 集群的主从复制过程梳理成 3 个阶段：

* 写入 Binlog：主库写 binlog 日志，提交事务，并更新本地存储数据。
* 同步 Binlog：把 binlog 复制到所有从库上，每个从库把 binlog 写到暂存日志中。
* 回放 Binlog：回放 binlog，并更新存储引擎中的数据。

具体详细过程如下：

* MySQL 主库在收到客户端提交事务的请求之后，按两阶段提交写入：先写 redo log 并进入 prepare 状态，再写入 binlog，最后将 redo log 置为 commit 状态，事务才真正提交（binlog 与 redo log 的两阶段提交保证主从一致性），提交完成后返回给客户端“操作成功”的响应。
* 从库会创建一个专门的 I/O 线程，连接主库的 log dump 线程，来接收主库的 binlog 日志，再把 binlog 信息写入 relay log 的中继日志里，再返回给主库“复制成功”的响应。
* 从库会创建一个用于回放 binlog 的线程，去读 relay log 中继日志，然后回放 binlog 更新存储引擎中的数据，最终实现主从的数据一致性。

### 44. 从库是不是越多越好？

不是的。

因为从库数量增加，从库连接上来的 I/O 线程也比较多，主库也要创建同样多的 log dump 线程来处理复制的请求，对主库资源消耗比较高，同时还受限于主库的网络带宽。

所以在实际使用中，一个主库一般跟 2 ～ 3 个从库（1 套数据库，1 主 2 从 1 备主），这就是一主多从的 MySQL 集群结构。

### 45. MySQL 主从复制还有哪些模型？

* 同步复制：MySQL 主库提交事务的线程要等待所有从库的复制成功响应，才返回客户端结果。这种方式在实际项目中，基本上没法用，原因有两个：一是性能很差，因为要复制到所有节点才返回响应；二是可用性也很差，主库和所有从库任何一个数据库出问题，都会影响业务。
* 异步复制（默认模型）：MySQL 主库提交事务的线程并不会等待 binlog 同步到各从库，就返回客户端结果。这种模式一旦主库宕机，数据就会发生丢失。
* 半同步复制：MySQL 5.5 版本引入（MySQL 5.7 增强了无损复制模式）的一种复制方式，介于两者之间，事务线程不用等待所有的从库复制成功响应，只要一部分复制成功响应回来就行，比如一主二从的集群，只要数据成功复制到任意一个从库上，主库的事务线程就可以返回给客户端。这种半同步复制的方式，兼顾了异步复制和同步复制的优点，即使出现主库宕机，至少还有一个从库有最新的数据，不存在数据丢失的风险。

### 46. binlog 什么时候刷盘？

MySQL 给每个线程分配了一片内存用于缓冲 binlog ，该内存叫 binlog cache，参数 binlog\_cache\_size 用于控制单个线程内 binlog cache 所占内存的大小。如果超过了这个参数规定的大小，就要暂存到磁盘。

### 47. 什么时候 binlog cache 会写到 binlog 文件？

MySQL 提供一个 sync\_binlog 参数来控制数据库的 binlog 刷到磁盘上的频率：

* sync\_binlog = 0 的时候，表示每次提交事务都只 write，不 fsync，后续交由操作系统决定何时将数据持久化到磁盘；
* sync\_binlog = 1 的时候，表示每次提交事务都会 write，然后马上执行 fsync；
* sync\_binlog =N(N>1) 的时候，表示每次提交事务都 write，但累积 N 个事务后才 fsync。

### 48. 为什么需要两阶段提交？

可以看到，在持久化 redo log 和 binlog 这两份日志的时候，如果出现半成功的状态，就会造成主从环境的数据不一致性。这是因为 redo log 影响主库的数据，binlog 影响从库的数据，所以 redo log 和 binlog 必须保持一致才能保证主从数据一致。

### 49. 两阶段提交的过程是怎样的？

* prepare 阶段：将 XID（内部 XA 事务的 ID） 写入到 redo log，同时将 redo log 对应的事务状态设置为 prepare，然后将 redo log 持久化到磁盘（innodb\_flush\_log\_at\_trx\_commit = 1 的作用）；
* commit 阶段：把 XID 写入到 binlog，然后将 binlog 持久化到磁盘（sync\_binlog = 1 的作用），接着调用引擎的提交事务接口，将 redo log 状态设置为 commit，此时该状态并不需要持久化到磁盘，只需要 write 到文件系统的 page cache 中就够了，因为只要 binlog 写磁盘成功，就算 redo log 的状态还是 prepare 也没有关系，一样会被认为事务已经执行成功；

### 50. 异常重启会出现什么现象？

* 如果 binlog 中没有当前内部 XA 事务的 XID，说明 redolog 完成刷盘，但是 binlog 还没有刷盘，则回滚事务。
* 如果 binlog 中有当前内部 XA 事务的 XID，说明 redolog 和 binlog 都已经完成了刷盘，则提交事务。

### 51. 处于 prepare 阶段的 redo log 加上完整 binlog，重启就提交事务，MySQL 为什么要这么设计?

binlog 已经写入了，之后就会被从库（或者用这个 binlog 恢复出来的库）使用。

所以，在主库上也要提交这个事务。采用这个策略，主库和备库的数据就保证了一致性。

### 52. 事务没提交的时候，redo log 会被持久化到磁盘吗？

会的。

事务执行中间过程的 redo log 也是直接写在 redo log buffer 中的，这些缓存在 redo log buffer 里的 redo log 也会被「后台线程」每隔一秒一起持久化到磁盘。

也就是说，事务没提交的时候，redo log 也是可能被持久化到磁盘的。

有的同学可能会问，如果 mysql 崩溃了，还没提交事务的 redo log 已经被持久化磁盘了，mysql 重启后，数据不就不一致了？

放心，这种情况 mysql 重启会进行回滚操作，因为事务没提交的时候，binlog 是还没持久化到磁盘的。

### 53. 两阶段提交有什么问题？

* 磁盘 I/O 次数高：对于“双 1”配置，每个事务提交都会进行两次 fsync（刷盘），一次是 redo log 刷盘，另一次是 binlog 刷盘。
* 锁竞争激烈：两阶段提交虽然能够保证「单事务」两个日志的内容一致，但在「多事务」的情况下，却不能保证两者的提交顺序一致，因此，在两阶段提交的流程基础上，还需要加一个锁来保证提交的原子性，从而保证多事务的情况下，两个日志的提交顺序一致。

### 54. 为什么两阶段提交的磁盘 I/O 次数会很高？

binlog 和 redo log 在内存中都对应的缓存空间，binlog 会缓存在 binlog cache，redo log 会缓存在 redo log buffer，它们持久化到磁盘的时机分别由下面这两个参数控制。一般我们为了避免日志丢失的风险，会将这两个参数设置为 1：

* 当 sync\_binlog = 1 的时候，表示每次提交事务都会将 binlog cache 里的 binlog 直接持久到磁盘；
* 当 innodb\_flush\_log\_at\_trx\_commit = 1 时，表示每次事务提交时，都将缓存在 redo log buffer 里的 redo log 直接持久化到磁盘；

可以看到，如果 sync\_binlog 和 当 innodb\_flush\_log\_at\_trx\_commit 都设置为 1，那么在每个事务提交过程中， 都会至少调用 2 次刷盘操作，一次是 redo log 刷盘，一次是 binlog 落盘，所以这会成为性能瓶颈。

### 55. 为什么事务提交锁竞争激烈？

在早期的 MySQL 版本中，通过使用 prepare\_commit\_mutex 锁来保证事务提交的顺序，在一个事务获取到锁时才能进入 prepare 阶段，一直到 commit 阶段结束才能释放锁，下个事务才可以继续进行 prepare 操作。

通过加锁虽然完美地解决了顺序一致性的问题，但在并发量较大的时候，就会导致对锁的争用，性能不佳。

### 56. binlog 组提交

MySQL 引入了 binlog 组提交（group commit）机制，当有多个事务提交的时候，会将多个 binlog 刷盘操作合并成一个，从而减少磁盘 I/O 的次数，如果说 10 个事务依次排队刷盘的时间成本是 10，那么将这 10 个事务一次性一起刷盘的时间成本则近似于 1。

引入了组提交机制后，prepare 阶段不变，只针对 commit 阶段，将 commit 阶段拆分为三个过程：

* flush 阶段：多个事务按进入的顺序将 binlog 从 cache 写入文件（不刷盘）；
* sync 阶段：对 binlog 文件做 fsync 操作（多个事务的 binlog 合并一次刷盘）；
* commit 阶段：各个事务按顺序做 InnoDB commit 操作；

上面的每个阶段都有一个队列，每个阶段有锁进行保护，因此保证了事务写入的顺序，第一个进入队列的事务会成为 leader，leader 领导所在队列的所有事务，全权负责整队的操作，完成后通知队内其他事务操作结束。

### 57. redo log 组提交

在 MySQL 5.7 版本中，做了个改进，在 prepare 阶段不再让事务各自执行 redo log 刷盘操作，而是推迟到组提交的 flush 阶段，也就是说 prepare 阶段融合在了 flush 阶段。

这个优化是将 redo log 的刷盘延迟到了 flush 阶段之中，sync 阶段之前。通过延迟写 redo log 的方式，为 redolog 做了一次组写入，这样 binlog 和 redo log 都进行了优化。

### 58. MySQL 磁盘 I/O 很高，有什么优化的方法？

我们知道事务在提交的时候，需要将 binlog 和 redo log 持久化到磁盘，那么如果出现 MySQL 磁盘 I/O 很高的现象，我们可以通过控制以下参数，来 “延迟” binlog 和 redo log 刷盘的时机，从而降低磁盘 I/O 的频率：

* 设置组提交的两个参数： binlog\_group\_commit\_sync\_delay 和 binlog\_group\_commit\_sync\_no\_delay\_count 参数，延迟 binlog 刷盘的时机，从而减少 binlog 的刷盘次数。这个方法是基于“额外的故意等待”来实现的，因此可能会增加语句的响应时间，但即使 MySQL 进程中途挂了，也没有丢失数据的风险，因为 binlog 早被写入到 page cache 了，只要系统没有宕机，缓存在 page cache 里的 binlog 就会被持久化到磁盘。
* 将 sync\_binlog 设置为大于 1 的值（比较常见是 100\~1000），表示每次提交事务都 write，但累积 N 个事务后才 fsync，相当于延迟了 binlog 刷盘的时机。但是这样做的风险是，主机掉电时会丢 N 个事务的 binlog 日志。
* 将 innodb\_flush\_log\_at\_trx\_commit 设置为 2。表示每次事务提交时，都只是缓存在 redo log buffer 里的 redo log 写到 redo log 文件，注意写入到「 redo log 文件」并不意味着写入到了磁盘，因为操作系统的文件系统中有个 Page Cache，专门用来缓存文件数据的，所以写入「 redo log 文件」意味着写入到了操作系统的文件缓存，然后交由操作系统控制持久化到磁盘的时机。但是这样做的风险是，主机掉电的时候会丢数据。

### 59. 详解更新一条记录的流程

具体更新一条记录 UPDATE t\_user SET name = 'xiaolin' WHERE id = 1; 的流程如下:

1. 执行器负责具体执行，会调用存储引擎的接口，通过主键索引树搜索获取 id = 1 这一行记录：
   * 如果 id=1 这一行所在的数据页本来就在 buffer pool 中，就直接返回给执行器更新；
   * 如果记录不在 buffer pool，将数据页从磁盘读入到 buffer pool，返回记录给执行器。
2. 执行器得到聚簇索引记录后，会看一下更新前的记录和更新后的记录是否一样：
   * 如果一样的话就不进行后续更新流程；
   * 如果不一样的话就把更新前的记录和更新后的记录都当作参数传给 InnoDB 层，让 InnoDB 真正的执行更新记录的操作；
3. 开启事务， InnoDB 层更新记录前，首先要记录相应的 undo log，因为这是更新操作，需要把被更新的列的旧值记下来，也就是要生成一条 undo log，undo log 会写入 Buffer Pool 中的 Undo 页面，不过在内存修改该 Undo 页面后，需要记录对应的 redo log。
4. InnoDB 层开始更新记录，会先更新内存（同时标记为脏页），然后将记录写到 redo log 里面，这个时候更新就算完成了。为了减少磁盘 I/O，不会立即将脏页写入磁盘，后续由后台线程选择一个合适的时机将脏页写入到磁盘。这就是 WAL 技术，MySQL 的写操作并不是立刻写到磁盘上，而是先写 redo 日志，然后在合适的时间再将修改的行数据写到磁盘上。
5. 至此，一条记录更新完了。
6. 在一条更新语句执行完成后，然后开始记录该语句对应的 binlog，此时记录的 binlog 会被保存到 binlog cache，并没有刷新到硬盘上的 binlog 文件，在事务提交时才会统一将该事务运行过程中的所有 binlog 刷新到硬盘。
7. 事务提交（为了方便说明，这里不说组提交的过程，只说两阶段提交）：
   * prepare 阶段：将 redo log 对应的事务状态设置为 prepare，然后将 redo log 刷新到硬盘；
   * commit 阶段：将 binlog 刷新到磁盘，接着调用引擎的提交事务接口，将 redo log 状态设置为 commit（将事务设置为 commit 状态后，刷入到磁盘 redo log 文件）；

### 60. 详解 Buffer Pool

Innodb 存储引擎设计了一个缓冲池（Buffer Pool），来提高数据库的读写性能。

Buffer Pool 以页为单位缓冲数据，可以通过 innodb\_buffer\_pool\_size 参数调整缓冲池的大小，默认是 128 M。

Innodb 通过三种链表来管理缓存页：

* Free List （空闲页链表），管理空闲页；
* Flush List （脏页链表），管理脏页；
* LRU List，管理脏页 + 干净页，将最近且经常查询的数据缓存在其中，而不常查询的数据就淘汰出去。

InnoDB 对 LRU 做了一些优化，我们熟悉的 LRU 算法通常是将最近查询的数据放到 LRU 链表的头部，而 InnoDB 做 2 点优化：

* 将 LRU 链表 分为 young 和 old 两个区域，加入缓冲池的页，优先插入 old 区域；页被访问时，才进入 young 区域，目的是为了解决预读失效的问题。
* 当「页被访问」且「 old 区域停留时间超过 innodb\_old\_blocks\_time 阈值（默认为 1 秒）」时，才会将页插入到 young 区域，否则还是插入到 old 区域，目的是为了解决批量数据访问，大量热数据淘汰的问题。

可以通过调整 innodb\_old\_blocks\_pct 参数，设置 young 区域和 old 区域比例。

在开启了慢 SQL 监控后，如果你发现「偶尔」会出现一些用时稍长的 SQL，这可因为脏页在刷新到磁盘时导致数据库性能抖动。如果在很短的时间出现这种现象，就需要调大 Buffer Pool 空间或 redo log 日志的大小。

### 61. MySQL 三范式

* 第一范式要求数据库表中的每一列都是不可分割的原子数据项。这意味着每一列的数据都应该是单一的值，而不是列表或集合。
* 第二范式在满足第一范式的基础上，要求所有非主键属性完全依赖于主键，而不是部分依赖。解决的是复合主键的部分依赖问题。
* 第三范式在满足第二范式的基础上，任何非主键属性不依赖于其他非主键属性。解决的是非主键属性之间的传递依赖问题。

> 在满足第一范式的基础上，若这张表只有一个主键，那么它必定满足第二范式。

### 62. MySQL 实现分布式锁

MySQL 可以通过在 select 语句后增加 for update 来获取排它锁，从而实现分布式锁。当某条记录被加上排他锁之后，其他线程无法再在该行记录上增加排他锁，因此获得排它锁的线程即可获得分布式锁。

### 63. 慢 SQL 优化

* 索引优化：索引是提高查询效率的重要手段，可以通过 explain 命令查看 SQL 语句的执行计划，找到慢查询的原因，然后对相应的字段添加索引。
* 优化 SQL 语句：常见的 SQL 优化技巧包括避免使用通配符查询、使用 JOIN 代替嵌套 SELECT、减少子查询等。
* 分析表结构：创建索引、垂直分割表等。创建合适的索引可以加快查询速度，但同时会增加写入数据的时间。垂直分割表可以将表根据不同的功能、访问模式分为多个表，避免查询全部字段和频繁更新次数相同的字段造成的性能问题。
* 查看监控和报警：使用数据库监控来实时监控数据库的性能，包括查询时间、锁等待时间、缓存命中率等。配置报警规则，当某些关键性能指标（如查询时间、CPU 使用率、内存使用率等）超过阈值时，触发报警，及时发现和解决性能问题。

### 64. 如何防止 SQL 注入

使用预编译语句：GORM 使用 database/sql 的参数占位符来构造 SQL 语句，这可以自动转义参数，避免 SQL 注入数据。

### 65. InnoDB 有 Hash 索引吗

InnoDB 不支持用户定义的 Hash 索引，但通过 Adaptive Hash Index 提供了类似的优化功能，用于加速特定查询。用户无需手动管理 AHI，InnoDB 会根据查询模式自动调整和优化。

### 66. int(1) 和 int(10) 的区别

在 MySQL 中，int(1) 和 int(10) 本身没有区别，(M) 只是显示宽度（display width），不是存储数据的大小，也不影响取值范围。注意：从 MySQL 8.0.17 开始，整数类型的显示宽度已被官方废弃（预计未来版本移除），int(1) 与 int(10) 完全等价。如果你想要限制存储数据的大小，可以使用 char(10) 或者 varchar(10)。

### 67. 字符串做主键索引的危害

* 字符串长度大，占用空间大，不利于查询和排序。
* 字符串作为主键，会导致索引文件变大，降低查询效率。

### 68. utf8 varchar 最长是多少

utf8 编码下，MySQL 中的 varchar 最长长度是 **21844 个字符**

### 69. MySQL 乐观锁实现方式

MySQL 的乐观锁是通过使用版本号或时间戳来实现的。

```sql
UPDATE products
SET price = 12.99,
    version = version + 1
WHERE id = 1
  AND version = 0;
```

在上述 SQL 语句中，我们将 `price` 更新为 `12.99`，并将 `version` 增加 1。同时，我们使用 `WHERE` 子句来限制更新只能在 `id` 为 1 且 `version` 为 0 的记录上进行，这是为了确保在更新期间没有其他并发操作修改了该记录。

如果更新成功，则表示没有其他并发操作修改了该记录。如果更新失败，则表示有其他并发操作修改了该记录，这时可以选择重试操作或执行其他逻辑以适应该冲突。

### 70. MyISAM 和 InnoDB 的区别有哪些

1. InnoDB 支持事务，MyISAM 不支持事务。这是 MySQL 将默认存储引擎从 MyISAM 变成 InnoDB 的重要原因之一；
2. InnoDB 支持外键，而 MyISAM 不支持。对一个包含外键的 InnoDB 表转为 MYISAM 会失败；
3. InnoDB 是聚集索引，MyISAM 是非聚集索引。聚簇索引的文件存放在主键索引的叶子节点上，因此 InnoDB 必须要有主键，通过主键索引效率很高。但是辅助索引需要两次查询，先查询到主键，然后再通过主键查询到数据。而 MyISAM 是非聚集索引，数据文件是分离的，索引保存的是数据文件的指针。主键索引和辅助索引是独立的；
4. InnoDB 不保存表的具体行数，执行 select count(\*) from table 时需要全表扫描。而 MyISAM 用一个变量保存了整个表的行数，执行上述语句时只需要读出该变量即可，速度很快；
5. InnoDB 最小的锁粒度是行锁，MyISAM 最小的锁粒度是表锁。一个更新语句会锁住整张表，导致其他查询和更新都会被阻塞，因此并发访问受限。这也是 MySQL 将默认存储引擎从 MyISAM 变成 InnoDB 的重要原因之一；

### 71. 读写 MySQL 超时

* 调整连接超时设置：MySQL 有两个主要的超时设置，分别是 wait\_timeout 和 interactive\_timeout。这两个设置决定了连接在多长时间没有活动后会被关闭。
* 优化查询和事务：如果查询或事务的执行时间超过了连接的超时时间，可以考虑对查询进行优化，例如添加索引、优化查询语句等。另外，可以将长时间运行的查询或事务拆分为多个较小的操作，以减少单个操作的执行时间。
* 使用合适的连接池：通过使用连接池，可以避免频繁地创建和关闭连接，提高连接的复用率。连接池可以自动管理连接的超时时间，并在需要时重新创建新的连接。

### 72. 数据库分表

> 注：本条原为外部链接（知乎），内容未逐一核验，建议以官方文档为准。

### 73. MySQL 索引如何存储？每个索引一个 B+ 树，还是多个索引放一个 B+ 树？

每个索引都对应一个独立的 B+ 树。每个索引节点包含多个关键字和对应的指针，关键字按照顺序排列，指针指向下一层的节点或者叶子节点。叶子节点存储了索引的值以及对应的数据行的指针。这种结构可以高效地支持单个值的查找、范围查询和排序操作。

### 74. MySQL 叶子节点中存的是什么数据？

MySQL 的叶子节点存储的是具体数据或者主键 KEY。

在 B+ 树索引中，叶子节点是存储实际数据的节点。每个叶子节点包含一定范围的键值和对应的数据块的指针 (或者是数据本身) 。叶子节点之间通过双向指针连接，形成一个有序的链表。通过这些指针，可以在树中进行快速的搜索和定位，以找到包含特定键值的叶子节点。

### 75. B+ 树的范围查找怎么做的？

1. 从根节点开始，按照 B+ 树的搜索算法，找到第一个大于或等于范围的起始值的叶子节点。这可以通过在每个内部节点上进行二分查找来实现。
2. 从起始叶子节点开始，顺序遍历叶子节点，找到所有在范围内的值。由于 B+ 树的叶子节点是按照关键字顺序链接的，所以可以通过遍历叶子节点来找到范围内的所有值。
3. 如果范围的结束值大于当前叶子节点的最大关键字，继续遍历下一个叶子节点，重复步骤 2，直到找到所有满足范围条件的值。

B+ 树的范围查找是基于关键字的有序性进行的，因此可以快速定位到范围的起始位置，并按顺序获取范围内的值。这使得 B+ 树在范围查询方面非常高效。

### 76. 如果只有原子性能保持一致性吗

两个操作是原子操作，即要么全部执行成功，要么全部失败，如果发生故障（如系统挂起、断电等），就不会出现只从 A 账户扣款但没有给 B 账户加款的情况。

然而，仅仅保证了原子性，并不意味着能保证系统的一致性。一致性是指数据要满足预设的一系列约束和规则。若一个转账操作，一致性则要求在转账操作完成后，A 账户和 B 账户的总金额应该和转账操作前保持一致。但是，如果在执行转账操作的同时，同时有其他的操作（如 A 账户有一笔 100 元的存款），那么仅仅依靠原子性，是无法保证数据的一致性的。

### 77. MySQL 索引类型

MySQL 的索引主要有以下几种类型：

1. 主键索引 (PRIMARY KEY) ：用于唯一标识表中的每一行数据。一个表只能有一个主键索引，且主键索引的值不能为 NULL。
2. 唯一索引 (UNIQUE) ：保证索引列的值在表中是唯一的，可以有多个唯一索引。
3. 普通索引 (INDEX) ：最基本的索引类型，用于加快查询速度。可以在一个表中创建多个普通索引。
4. 全文索引 (FULLTEXT) ：用于在文本字段上进行全文搜索。

### 78. undo log 与 redo log 里面记录的是物理数据还是逻辑数据

undo log 记录的内容是逻辑数据。如果某个事务发生了回滚，undo log 就会发生作用，这个过程通常被称为「反向」操作。undo log 记录了一个事务执行所做的每一步修改，这样在需要撤销的时候，就可以利用 undo log 中记录的信息，将数据状态回滚到事务开始之前。

redo log 记录的是物理数据。也就是说，它保存了数据库中的实际更改。当系统崩溃或其他故障发生时，redo log 能够通过重做（redo）之前的操作，将数据恢复到故障发生时的状态。它是数据库恢复的一个重要手段。

### 79. 单纯的 SELECT 会加锁吗

* 单纯的 SELECT 语句（快照读）在 InnoDB 中不会加锁，它通过 MVCC 机制读取数据，不会阻塞其他会话的读写操作；如果想让 SELECT 加共享锁（读锁），需要显式使用 SELECT ... LOCK IN SHARE MODE（MySQL 8.0 起为 SELECT ... FOR SHARE）。
* 使用了 FOR UPDATE 子句，则会对查询的行进行加锁，此时会获取排他锁 (也称为写锁) 。排他锁会阻塞其他会话的读取和写入操作，直到当前会话释放锁为止。

### 80. MySQL 自增 id 能回滚吗

MySQL 的自增 ID 在事务回滚后是不会回退的。当一个事务中插入了数据并回滚后，虽然插入的数据被删除了，但是自增 ID 的值仍然会自增。

### 81. MySQL 内连接和左外连接的区别

* 内连接 (INNER JOIN) ： 内连接返回两个表中满足连接条件的行，即只返回两个表中连接字段相等的行。它只返回符合条件的交集部分。
* 左外连接 (LEFT JOIN) ： 左外连接返回左表中的所有记录和右表中连接字段相等的记录。即左表的所有行都会被返回，而右表中没有匹配的行则会填充为 NULL。

### 82. 主从数据库在对主库写的时候加锁会不会锁从库

当我们在主库进行写操作（更新，插入，删除等）时，会对相关数据加锁，以保证数据的一致性和完整性。这种锁只在主库生效，并不会影响到从库。

主库完成写操作后，会通过数据库的复制机制将这些变更写入日志文件，然后再将这些日志复制到从库。在从库上重放这些日志，完成与主库的数据同步。

### 83. 分库分表怎么实现数据的平滑迁移

1. 设计分库分表方案：根据业务需求和数据特点，设计合适的分库分表方案。常见的策略可以用哈希。
2. 创建新的数据库和表结构：根据分库分表方案，在新的数据库中创建分片并设计相应的表结构。确保新的数据库和表结构与原始数据库保持一致。
3. 数据同步：将原始数据库中的数据逐步同步到新的数据库中。可以采用增量同步和全量同步相结合的方式。增量同步可以通过开启增量数据单向同步，将数据从旧库同步到新库。全量同步则是将历史数据从旧库全量同步到新库。
4. 切换读写操作：当数据同步完成后，可以将读写操作切换到新的数据库上。这可以通过修改应用程序的连接配置或者使用中间件来实现。
5. 监控和验证：在切换完成后，需要对新的数据库进行监控和验证，确保数据迁移过程中没有出现问题，并且新的数据库能够正常工作。可以通过监控指标、日志和测试来进行验证。

### 84. 一张表里有班级学号分数，用 SQL 求每个班级分数前三的学生

```sql
SELECT 班级, 学号, 分数
FROM (
  SELECT 班级, 学号, 分数,
    ROW_NUMBER() OVER(PARTITION BY 班级 ORDER BY 分数 DESC) as rn
  FROM student_info
) t
WHERE rn <= 3
```

在这个查询中，首先我们在内部查询中使用窗口函数 ROW\_NUMBER()，对每个分区（这里的分区就是班级）的记录进行排序（按分数降序）并编号。然后，在外部查询中，我们筛选出每个班级分数前三的学生。

### 85. 联合索引的存储结构

联合索引的实现主要基于 B+ 树数据结构。在创建联合索引时，MySQL 会按照定义的列顺序对数据进行排序和存储。具体过程如下：

1. **排序**：在创建联合索引时，系统首先根据索引列的顺序（从左到右）对表中的数据进行排序。例如，对于联合索引 `(b, c, d)`，数据会首先按 `b` 列排序，如果 `b` 相等，则按 `c` 排序，再按 `d` 排。
2. **数据页结构**：每个数据页存储了联合索引的所有列的值以及对应的主键值。数据页内部是有序的，形成单向链表，数据页之间则组成双向链表。这种结构使得在查询时可以快速定位到所需的数据。
3. **查询过程**：
   * 当执行查询时，MySQL 会首先在索引页中查找最小值记录，进而定位到对应的数据页。
   * 在数据页内部，系统会依次比较每个字段的值，直到找到匹配的记录。例如，在查找某个班级、学生和科目的成绩时，系统会依次根据班级、学生姓名和科目名称进行查找。

### 86. 为什么 MySQL 要借助数据库引擎而不是自己实现

MySQL 的架构采用了可插拔的存储引擎设计，这意味着开发者可以根据特定的应用需求选择合适的存储引擎，如 InnoDB、MyISAM 等。这种设计使得 MySQL 能够灵活适应不同的使用场景（如 OLTP 和 OLAP），并且不需要对整个数据库系统进行重大修改。

### 87. 深度翻页问题

#### 标记记录法

1. 在第一次查询时,获取最大的 ID 值 (或其他唯一标识列)。
2. 在后续查询时,使用 ID > 标记值 的条件进行查询,并更新标记值。
3. 这样每次只需要查询比标记值大的数据,可以大幅提升性能。

#### 分批次查询

这种方法与标记记录法同理：把大结果集按 ID 或时间字段分段，每次以上一次的边界值为游标继续查询。比如第一次查询 ID < 10000 的最多 1000 条，记录最后一条的 ID，第二次查询 ID 大于该游标的下一批。注意：如果仍用 LIMIT offset 做深翻页（如 LIMIT 100000, 1000），offset 越大扫描越慢，无法根治深度翻页问题，必须用游标/ID 分段。

### 88. 哈希索引适合什么场景

* **等值查询**：哈希索引在处理等值查询（如 `=`、`IN`）时表现优越，因为它可以通过哈希函数直接定位到数据位置，查询时间复杂度为 O(1)。
* **高频率的单值查找**：在需要频繁查找特定键值的场景中，哈希索引能够提供快速的访问速度。

然而，哈希索引不支持范围查询和排序操作，因此在需要这些功能的场景中就不适用。例如，哈希索引无法处理 `>`、`<` 或 `BETWEEN` 等条件查询，也无法用于 `ORDER BY` 操作。

### 89. 索引是如何选择出来的

主要目的是为了找到执行代价最低的方案。

1. **唯一性索引匹配**：如果存在一个唯一性索引完全匹配查询条件且不需要回表（即覆盖索引，可直接从索引取得所需数据），则直接选择该索引。
2. **回表需求**：如果存在唯一性索引完全匹配但需要回表（即需要先通过二级索引找到主键，再回聚簇索引取数据），则选择满足该条件且回表行数最小的索引。
3. **普通索引选择**：如果存在普通索引不需要回表且读取行数小于某个阈值，则选择满足该条件且读取行数最小的索引。
4. **候选索引比较**：在规则 2 和规则 3 中，如果各自选出了候选索引，则选择读取行数（读索引行数 + 回表行数）较小的索引。

#### 影响优化器选择的因素

* **扫描行数**：优化器会计算每个索引的预计扫描行数，扫描行数越少，访问磁盘数据的次数就越少，从而消耗的 CPU 资源也越少。
* **基数（Cardinality）**：基数指的是一个索引上不同值的个数。高基数意味着更好的区分度，从而提高查询效率。优化器倾向于选择基数高的列作为索引。
* **统计信息**：数据库会定期更新统计信息，以便优化器能够做出更准确的决策。通过命令如 `ANALYZE TABLE` 可以手动触发统计信息更新，以确保优化器使用最新的信息进行索引选择。

## Redis

### 1. Redis 和 Memcached 有什么区别？

#### 共同点

* 都是基于内存的数据库，一般都用来当做缓存使用。
* 都有过期策略。
* 两者的性能都非常高。

#### 不同点

* Redis 支持的数据类型更丰富（String、Hash、List、Set、ZSet），而 Memcached 只支持最简单的 key-value 数据类型；
* Redis 支持数据的持久化，可以将内存中的数据保持在磁盘中，重启的时候可以再次加载进行使用，而 Memcached 没有持久化功能，数据全部存在内存之中，Memcached 重启或者挂掉后，数据就没了；
* Redis 原生支持集群模式，Memcached 没有原生的集群模式，需要依靠客户端来实现往集群中分片写入数据；
* Redis 支持发布订阅模型、Lua 脚本、事务等功能，而 Memcached 不支持；

### 2. 为什么用 Redis 作为 MySQL 的缓存？

* Redis 具备高性能、低延迟（存在于内存）
* Redis 能够分布式高可用（哨兵、集群机制）
* Redis 提供丰富的数据结构

### 3. Redis 是单线程吗？

Redis 程序并不是单线程的，Redis 在启动的时候，是会启动后台线程

* Redis 在 2.6 版本，会启动 2 个后台线程，分别处理关闭文件、AOF 刷盘这两个任务；
* Redis 在 4.0 版本之后，新增了一个新的后台线程，用来异步释放 Redis 内存，也就是 lazyfree 线程。例如执行 unlink key 等命令，会把这些删除操作交给后台线程来执行。当我们要删除一个大 key 的时候，不要使用 del 命令删除，因为 del 是在主线程处理的，我们应该使用 unlink 命令来异步删除大 key。

关闭文件、AOF 刷盘、释放内存这三个任务都有各自的任务队列

* BIO\_CLOSE\_FILE
* BIO\_AOF\_FSYNC
* BIO\_LAZY\_FREE

### 4. Redis 采用单线程为什么还这么快？

* Redis 的大部分操作都在内存中完成，并且采用了高效的数据结构，因此 Redis 瓶颈可能是机器的内存或者网络带宽，而并非 CPU。
* Redis 采用单线程模型可以避免了多线程之间的竞争，省去了多线程切换带来的时间和性能上的开销，而且也不会导致死锁问题。
* Redis 采用了 I/O 多路复用机制处理大量的客户端 Socket 请求，在 Redis 只运行单线程的情况下，该机制允许内核中，同时存在多个监听 Socket 和已连接 Socket。内核会一直监听这些 Socket 上的连接请求或数据请求。一旦有请求到达，就会交给 Redis 线程处理，这就实现了一个 Redis 线程处理多个 IO 流的效果。

### 5. Redis 6.0 之前为什么使用单线程？

CPU 并不是制约 Redis 性能表现的瓶颈所在，更多情况下是受到内存大小和网络 I/O 的限制

使用了单线程后，可维护性高，多线程模型虽然在某些方面表现优异，但是它却引入了程序执行顺序的不确定性，带来了并发读写的一系列问题，增加了系统复杂度、同时可能存在线程切换、甚至加锁解锁、死锁造成的性能损耗。

### 6. Redis 6.0 之后为什么引入了多线程？

Redis 的性能瓶颈有时会出现在网络 I/O 的处理上。Redis 6.0 对于网络 I/O 采用多线程来处理。但是对于命令的执行，Redis 仍然使用单线程来处理。

Redis 6.0 版本支持的 I/O 多线程特性，默认情况下 I/O 多线程只针对发送响应数据，并不会以多线程的方式处理读请求。要想开启多线程处理客户端读请求，就需要把 Redis.conf 配置文件中的 io-threads-do-reads 配置项设为 yes。

Redis 一直维护 3 个后台线程（bio\_close\_file / bio\_aof\_fsync 自 2.6 引入，bio\_lazy\_free 自 4.0 引入），与 6.0 的 I/O 多线程是两回事：

* Redis-server ： Redis 的主线程，主要负责执行命令；
* bio\_close\_file、bio\_aof\_fsync、bio\_lazy\_free：三个后台线程，分别异步处理关闭文件任务、AOF 刷盘任务、释放内存任务；

I/O 线程默认不启用：io-threads 配置项的默认值是 1（即默认只使用主线程处理网络 I/O），只有将 io-threads 配置为大于 1（例如 4）时，才会额外启动 3（4-1）个 I/O 线程（io\_thd\_1、io\_thd\_2、io\_thd\_3），用来分担 Redis 网络 I/O 的压力。

### 7. Redis 如何实现数据不丢失？

* AOF 日志：每执行一条写操作命令，就把该命令以追加的方式写入到一个文件里；
* RDB 快照：将某一时刻的内存数据，以二进制的方式写入磁盘；
* 混合持久化方式：Redis 4.0 新增的方式，集成了 AOF 和 RDB 的优点；

### 8. AOF 日志是如何实现的？

Redis 在执行完一条写操作命令后，就会把该命令以追加的方式写入到一个文件里，然后 Redis 重启时，会读取该文件记录的命令，然后逐一执行命令的方式来进行数据恢复。

### 9. AOF 为什么先执行命令，再把数据写入日志呢？

* 避免额外的检查开销：先将写操作命令记录到 AOF 日志里，再执行该命令，如果当前的命令语法有问题，那么不进行命令语法检查就会将该错误的命令记录到 AOF 日志里后，Redis 在使用日志恢复数据时，就可能会出错。
* 不会阻塞当前写操作命令的执行：因为当写操作命令执行成功后，才会将命令记录到 AOF 日志。

### 10. AOF 写回策略有几种？

* Always，每次写操作命令执行完后，同步将 AOF 日志数据写回硬盘；
* Everysec，每次写操作命令执行完后，先将命令写入到 AOF 文件的内核缓冲区，然后每隔一秒将缓冲区里的内容写回到硬盘；
* No，每次写操作命令执行完后，先将命令写入到 AOF 文件的内核缓冲区，再由操作系统决定何时将缓冲区内容写回硬盘。

### 11. AOF 日志过大，会触发什么机制？

AOF 重写机制，当 AOF 文件的大小超过所设定的阈值后，Redis 就会启用 AOF 重写机制，来压缩 AOF 文件。

在使用重写机制后，就会读取 key 最新的 value

### 12. 重写 AOF 日志的过程是怎样的？

重写 AOF 过程是由后台子进程 bgrewriteaof 来完成的

* 避免堵塞主进程
* 发生写时复制，复用加锁保证数据安全

重写过程中，主进程依然可以正常处理命令，为了解决这种数据不一致问题，Redis 设置了一个 AOF 重写缓冲区，这个缓冲区在创建 bgrewriteaof 子进程之后开始使用。

在重写 AOF 期间，当 Redis 执行完一个写命令之后，它会同时将这个写命令写入到 「AOF 缓冲区」和 「AOF 重写缓冲区」。

主进程收到该信号后，会调用一个信号处理函数，该函数主要做以下工作：

* 将 AOF 重写缓冲区中的所有内容追加到新的 AOF 的文件中，使得新旧两个 AOF 文件所保存的数据库状态一致；
* 新的 AOF 的文件进行改名，覆盖现有的 AOF 文件。

### 13. RDB 快照是如何实现的呢？

RDB 快照就是记录某一个瞬间的内存数据，记录的是实际数据

在 Redis 恢复数据时， RDB 恢复数据的效率会比 AOF 高些，因为直接将 RDB 文件读入内存就可以

### 14. RDB 做快照时会阻塞线程吗？

* 执行了 save 命令，就会在主线程生成 RDB 文件，由于和执行操作命令在同一个线程，所以如果写入 RDB 文件的时间太长，会阻塞主线程；
* 执行了 bgsave 命令，会创建一个子进程来生成 RDB 文件，这样可以避免主线程的阻塞；

### 15. RDB 在执行快照的时候，数据能修改吗？

如果主线程执行写操作，则被修改的数据会复制一份副本，然后 bgsave 子进程会把该副本数据写入 RDB 文件，在这个过程中，主线程仍然可以直接修改原来的数据。

### 16. 为什么会有混合持久化？

1. **启动时加载 RDB 文件**：Redis 启动时首先加载 RDB 文件，这样可以快速恢复大部分数据。
2. **追加 AOF 日志**：在加载 RDB 文件后，继续通过 AOF 文件记录的操作日志来恢复最近的数据变更。

混合持久化优点：

* 混合持久化结合了 RDB 和 AOF 持久化的优点，开头为 RDB 的格式，使得 Redis 可以更快的启动，同时结合 AOF 的优点，有减低了大量数据丢失的风险。

混合持久化缺点：

* AOF 文件中添加了 RDB 格式的内容，使得 AOF 文件的可读性变得很差；
* 兼容性差，如果开启混合持久化，那么此混合持久化 AOF 文件，就不能用在 Redis 4.0 之前版本了。

### 17. Redis 如何实现服务高可用？

* 主从模式
* 哨兵模式
* 切片集群模式

### 18. 集群脑裂导致数据丢失怎么办？

由于网络问题，集群节点之间失去联系。主从数据不同步；重新平衡选举，产生两个主服务。等网络恢复，旧主节点会降级为从节点，再与新主节点进行同步复制的时候，由于会从节点会清空自己的缓冲区，所以导致之前客户端写入的数据丢失了。

#### 解决方案

* min-slaves-to-write x，主节点必须要有至少 x 个从节点连接，如果小于这个数，主节点会禁止写数据。（Redis 5.0 起引入 min-replicas-to-write 新名，min-slaves-to-write 保留为别名；Redis 7.0 移除旧别名）
* min-slaves-max-lag x，主从数据复制和同步的延迟不能超过 x 秒，如果超过，主节点会禁止写数据。（Redis 5.0 起引入 min-replicas-max-lag 新名，min-slaves-max-lag 保留为别名；Redis 7.0 移除旧别名）

### 19. Redis 使用的过期删除策略是什么？

Redis 使用的过期删除策略是「惰性删除 + 定期删除」这两种策略配和使用。

### 20. 什么是惰性删除策略？

不主动删除过期键，每次从数据库访问 key 时，都检测 key 是否过期，如果过期则删除该 key。

惰性删除策略的优点：

* 因为每次访问时，才会检查 key 是否过期，所以此策略只会使用很少的系统资源，因此，惰性删除策略对 CPU 时间最友好。

惰性删除策略的缺点：

* 如果一个 key 已经过期，而这个 key 又仍然保留在数据库中，那么只要这个过期 key 一直没有被访问，它所占用的内存就不会释放，造成了一定的内存空间浪费。所以，惰性删除策略对内存不友好。

### 21. 什么是定期删除策略？

每隔一段时间「随机」从数据库中取出一定数量的 key 进行检查，并删除其中的过期 key。

定期删除策略的优点：

* 通过限制删除操作执行的时长和频率，来减少删除操作对 CPU 的影响，同时也能删除一部分过期的数据减少了过期键对空间的无效占用。

定期删除策略的缺点：

* 难以确定删除操作执行的时长和频率。如果执行的太频繁，就会对 CPU 不友好；如果执行的太少，那又和惰性删除一样了，过期 key 占用的内存不会及时得到释放。

### 22. Redis 持久化时，对过期键会如何处理的？

RDB 文件分为两个阶段，RDB 文件生成阶段和加载阶段。

* RDB 文件生成阶段：从内存状态持久化成 RDB（文件）的时候，会对 key 进行过期检查，过期的键「不会」被保存到新的 RDB 文件中，因此 Redis 中的过期键不会对生成新 RDB 文件产生任何影响。
* RDB 加载阶段：RDB 加载阶段时，要看服务器是主服务器还是从服务器，分别对应以下两种情况：
  * 如果 Redis 是「主服务器」运行模式的话，在载入 RDB 文件时，程序会对文件中保存的键进行检查，过期键「不会」被载入到数据库中。所以过期键不会对载入 RDB 文件的主服务器造成影响；
  * 如果 Redis 是「从服务器」运行模式的话，在载入 RDB 文件时，不论键是否过期都会被载入到数据库中。但由于主从服务器在进行数据同步时，从服务器的数据会被清空。所以一般来说，过期键对载入 RDB 文件的从服务器也不会造成影响。

AOF 文件分为两个阶段，AOF 文件写入阶段和 AOF 重写阶段。

* AOF 文件写入阶段：当 Redis 以 AOF 模式持久化时，如果数据库某个过期键还没被删除，那么 AOF 文件会保留此过期键，当此过期键被删除后，Redis 会向 AOF 文件追加一条 DEL 命令来显式地删除该键值。
* AOF 重写阶段：执行 AOF 重写时，会对 Redis 中的键值对进行检查，已过期的键不会被保存到重写后的 AOF 文件中。

### 23. Redis 主从模式中，对过期键会如何处理？

当 Redis 运行在主从模式下时，从库不会进行过期扫描，从库对过期的处理是被动的。也就是即使从库中的 key 过期了，如果有客户端访问从库时，依然可以得到 key 对应的值，像未过期的键值对一样返回。

从库的过期键处理依靠主服务器控制，主库在 key 到期时，会在 AOF 文件里增加一条 del 指令，同步到所有的从库，从库通过执行这条 del 指令来删除过期的 key。

### 24. Redis 内存满了，会发生什么？

在 Redis 的运行内存达到了某个阀值，就会触发内存淘汰机制，这个阀值就是我们设置的最大运行内存，此值在 Redis 的配置文件中可以找到，配置项为 maxmemory。

* 在 64 位操作系统中，maxmemory 的默认值是 0
* 在 32 位操作系统中，maxmemory 的默认值是 3G，因为 32 位的机器最大只支持 4GB 的内存

### 25. 如何避免缓存雪崩？

大量缓存数据在同一时间过期（失效）时，如果此时有大量的用户请求，都无法在 Redis 中处理，于是全部请求都直接访问数据库，从而导致数据库的压力骤增。

* 将缓存失效时间随机打散：我们可以在原有的失效时间基础上增加一个随机值（比如 1 到 10 分钟）这样每个缓存的过期时间都不重复了，也就降低了缓存集体失效的概率。
* 缓存预热：在系统启动或者缓存失效时，提前将一些热点数据加载到缓存中，避免在缓存失效的瞬间，大量请求直接打到数据库。
* 设置缓存不过期：我们可以通过后台服务来更新缓存数据，从而避免因为缓存失效造成的缓存雪崩，也可以在一定程度上避免缓存并发问题。

### 26. 如何避免缓存击穿？

如果缓存中的某个热点数据过期了，此时大量的请求访问了该热点数据，就无法从缓存中读取，直接访问数据库，数据库很容易就被高并发的请求冲垮

* 互斥锁方案（Redis 中使用 setNX 方法设置一个状态位，表示这是一种锁定状态），保证同一时间只有一个业务线程请求缓存，未能获取互斥锁的请求，要么等待锁释放后重新读取缓存，要么就返回空值或者默认值。
* 不给热点数据设置过期时间，由后台异步更新缓存，或者在热点数据准备要过期前，提前通知后台线程更新缓存以及重新设置过期时间；

### 27. 如何避免缓存穿透？

当用户访问的数据，既不在缓存中，也不在数据库中，导致请求在访问缓存时，发现缓存缺失，再去访问数据库时，发现数据库中也没有要访问的数据，没办法构建缓存数据，来服务后续的请求。那么当有大量这样的请求到来时，数据库的压力骤增

* 非法请求的限制：当有大量恶意请求访问不存在的数据的时候，也会发生缓存穿透，因此在 API 入口处我们要判断求请求参数是否合理，请求参数是否含有非法值、请求字段是否存在，如果判断出是恶意请求就直接返回错误，避免进一步访问缓存和数据库。
* 设置空值或者默认值：当我们线上业务发现缓存穿透的现象时，可以针对查询的数据，在缓存中设置一个空值或者默认值，这样后续请求就可以从缓存中读取到空值或者默认值，返回给应用，而不会继续查询数据库。
* 使用布隆过滤器快速判断数据是否存在，避免通过查询数据库来判断数据是否存在：我们可以在写入数据库数据时，使用布隆过滤器做个标记，然后在用户请求到来时，业务线程确认缓存失效后，可以通过查询布隆过滤器快速判断数据是否存在，如果不存在，就不用通过查询数据库来判断数据是否存在。

### 28. 如何设计一个缓存策略，可以动态缓存热点数据呢？

通过数据最新访问时间来做排名，并过滤掉不常访问的数据，只留下经常访问的数据。

* 先通过缓存系统做一个排序队列（比如存放 1000 个商品），系统会根据商品的访问时间，更新队列信息，越是最近访问的商品排名越靠前；
* 同时系统会定期过滤掉队列中排名最后的 200 个商品，然后再从数据库中随机读取出 200 个商品加入队列中；
* 这样当请求每次到达的时候，会先从队列中获取商品 ID，如果命中，就根据 ID 再从另一个缓存数据结构中读取实际的商品信息，并返回。

在 Redis 中可以用 zadd 方法和 zrange 方法来完成排序队列和获取 200 个商品的操作。

### 29. Cache Aside（旁路缓存）策略

写策略的步骤：

* 先更新数据库中的数据，再删除缓存中的数据。

读策略的步骤：

* 如果读取的数据命中了缓存，则直接返回数据；
* 如果读取的数据没有命中缓存，则从数据库中读取数据，然后将数据写入到缓存，并且返回给用户。

Cache Aside 策略适合读多写少的场景，不适合写多的场景

### 30. Redis 如何实现延迟队列？

使用 zadd score1 value1 命令就可以一直往内存中生产消息。再利用 zrangebyscore 查询符合条件的所有待处理的任务， 通过循环执行队列任务即可。

### 31. Redis 的大 key 有什么影响

一般而言，下面这两种情况被称为大 key：

* String 类型的值大于 10 KB；
* Hash、List、Set、ZSet 类型的元素的个数超过 5000 个；

大 key 会带来以下四种影响：

* 客户端超时阻塞。由于 Redis 执行命令是单线程处理，然后在操作大 key 时会比较耗时，那么就会阻塞 Redis，从客户端这一视角看，就是很久很久都没有响应。
* 引发网络阻塞。每次获取大 key 产生的网络流量较大，如果一个 key 的大小是 1 MB，每秒访问量为 1000，那么每秒会产生 1000MB 的流量。
* 阻塞工作线程。如果使用 del 删除大 key 时，会阻塞工作线程，这样就没办法处理后续的命令。
* 内存分布不均。集群模型在 slot 分片均匀情况下，会出现数据和查询倾斜情况，部分有大 key 的 Redis 节点占用内存多。

### 32. 如何删除大 key？

1. 分批次删除
2. 异步删除

### 33. Redis 管道有什么用？

使用管道技术可以解决多个命令执行时的网络等待，它是把多个命令整合到一起发送给服务器端处理之后统一返回给客户端，这样就免去了每条命令执行后都要等待的情况，从而有效地提高了程序的执行效率。

但使用管道技术也要注意避免发送的命令过大，或管道内的数据太多而导致的网络阻塞。

要注意的是，管道技术本质上是客户端提供的功能，而非 Redis 服务器端的功能。

### 34. Redis 事务支持回滚吗？

Redis 中并没有提供回滚机制，虽然 Redis 提供了 DISCARD 命令，但是这个命令只能用来主动放弃事务执行，把暂存的命令队列清空，起不到回滚的效果

### 35. 为什么 Redis 不支持事务回滚？

* 他认为 Redis 事务的执行时，错误通常都是编程错误造成的，这种错误通常只会出现在开发环境中。
* 不支持事务回滚是因为这种复杂的功能和 Redis 追求的简单高效的设计主旨不符合。

### 36. 如何用 Redis 实现分布式锁的？

Redis 的 SET 命令有个 NX 参数可以实现「key 不存在才插入」，所以可以用它来实现分布式锁：

* 如果 key 不存在，则显示插入成功，可以用来表示加锁成功；
* 如果 key 存在，则会显示插入失败，可以用来表示加锁失败。

### 37. 基于 Redis 实现分布式锁有什么优缺点？

基于 Redis 实现分布式锁的优点：

* 性能高效（这是选择缓存实现分布式锁最核心的出发点）。
* 实现方便。选择使用 Redis 来实现分布式锁，很大成分上是因为 Redis 提供了 setnx 方法，实现分布式锁很方便。
* 避免单点故障（因为 Redis 是跨集群部署的，自然就避免了单点故障）。

基于 Redis 实现分布式锁的缺点：

* 超时时间不好设置。如果锁的超时时间设置过长，会影响性能，如果设置的超时时间过短会保护不到共享资源。
  * 我们可以基于续约的方式设置超时时间：先给锁设置一个超时时间，然后启动一个守护线程，让守护线程在一段时间后，重新设置这个锁的超时时间。当主线程执行完成后，销毁续约锁即可。
* Redis 主从复制模式中的数据是异步复制的，这样导致分布式锁的不可靠性。如果在 Redis 主节点获取到锁后，在没有同步到其他节点时，Redis 主节点宕机了，此时新的 Redis 主节点依然可以获取锁，所以多个应用服务就可以同时获取到锁。

### 38. Redis 如何解决集群情况下分布式锁的可靠性？

为了保证集群环境下分布式锁的可靠性，Redis 官方已经设计了一个分布式锁算法 Redlock（红锁）。

Redlock 算法的基本思路，是让客户端和多个独立的 Redis 节点依次请求申请加锁，如果客户端能够和半数以上的节点成功地完成加锁操作，那么我们就认为，客户端成功地获得分布式锁，否则加锁失败。

加锁失败后，客户端向所有 Redis 节点发起释放锁的操作，释放锁的操作和在单节点上释放锁的操作一样，只要执行释放锁的 Lua 脚本就可以了。

### 39. Redis 内存淘汰策略

针对「进行数据淘汰」这一类策略，又可以细分为「在设置了过期时间的数据中进行淘汰」和「在所有数据范围内进行淘汰」这两类策略。

在设置了过期时间的数据中进行淘汰：

* volatile-random：随机淘汰设置了过期时间的任意键值；
* volatile-ttl：优先淘汰更早过期的键值。
* volatile-lru（Redis 3.0 之前的默认内存淘汰策略）：淘汰所有设置了过期时间的键值中，最久未使用的键值；（注：Redis 3.0 起默认策略改为 noeviction，即不淘汰任何数据，内存写满时直接拒绝写入新数据）
* volatile-lfu（Redis 4.0 后新增的内存淘汰策略）：淘汰所有设置了过期时间的键值中，最少使用的键值；

在所有数据范围内进行淘汰：

* allkeys-random：随机淘汰任意键值;
* allkeys-lru：淘汰整个键值中最久未使用的键值；
* allkeys-lfu（Redis 4.0 后新增的内存淘汰策略）：淘汰整个键值中最少使用的键值。

### 40. Redis 是如何实现 LRU 算法的？

Redis 实现的是一种近似 LRU 算法，目的是为了更好的节约内存，它的实现方式是在 Redis 的对象结构体中添加一个额外的字段，用于记录此数据的最后一次访问时间。

当 Redis 进行内存淘汰时，会使用随机采样的方式来淘汰数据，它是随机取 X 个值（此值可配置），然后淘汰最久没有使用的那个。

Redis 实现的 LRU 算法的优点：

* 不用为所有的数据维护一个大链表，节省了空间占用；
* 不用在每次数据访问时都移动链表项，提升了缓存的性能；

但是 LRU 算法有一个问题，无法解决缓存污染问题，比如应用一次读取了大量的数据，而这些数据只会被读取这一次，那么这些数据会留存在 Redis 缓存中很长一段时间，造成缓存污染。

### 41. Redis 是如何实现 LFU 算法的？

头部中记录 ldt 和 logc

* ldt 用来记录 key 的访问时间戳；
* logc 用来记录 key 的访问频次，它的值越小表示使用频率越低，越容易淘汰，每个新加入的 key 的 logc 初始值为 5。logc 会随时间推移而衰减。

logc 增加操作并不是单纯的 + 1，而是根据概率增加，如果 logc 越大的 key，它的 logc 就越难再增加。

所以，Redis 在访问 key 时，对于 logc 是这样变化的：

* 先按照上次访问距离当前的时长，来对 logc 进行衰减；
* 然后，再按照一定概率增加 logc 的值

### 42. String

#### 内部实现

String 类型的底层的数据结构实现主要是 int 和 SDS（简单动态字符串）。

* SDS 不仅可以保存文本数据，还可以保存二进制数据。
* SDS 获取字符串长度的时间复杂度是 O(1)。
* Redis 的 SDS API 是安全的，拼接字符串不会造成缓冲区溢出。

#### 应用场景

* 缓存对象
* 常规计数
* 分布式锁（NX）
* 共享 Session 信息

### 43. List

#### 内部实现

List 类型的底层数据结构是由双向链表或压缩列表实现的：

* 如果列表的元素个数小于 512 个（默认值，可由 list-max-ziplist-entries 配置），列表每个元素的值都小于 64 字节（默认值，可由 list-max-ziplist-value 配置），Redis 会使用压缩列表作为 List 类型的底层数据结构；
* 如果列表的元素不满足上面的条件，Redis 会使用双向链表作为 List 类型的底层数据结构；

但是在 Redis 3.2 版本之后，List 数据类型底层数据结构就只由 quicklist 实现了，替代了双向链表和压缩列表。（Redis 7.0 起，quicklist 内部节点使用的压缩列表也替换成了 listpack）

#### 应用场景

**消息队列**

1. 如何满足消息保序需求？

List 本身就是按先进先出的顺序对数据进行存取的。

2. 如何处理重复的消息？

* 每个消息都有一个全局的 ID。
* 消费者要记录已经处理过的消息的 ID。

我们需要自行为每个消息生成一个全局唯一 ID，生成之后，我们在用 LPUSH 命令把消息插入 List 时，需要在消息中包含这个全局唯一 ID。

3. 如何保证消息可靠性？

当消费者程序从 List 中读取一条消息后，List 就不会再留存这条消息了。

为了留存消息，List 类型提供了 BRPOPLPUSH 命令，这个命令的作用是让消费者程序从一个 List 中读取消息，同时，Redis 会把这个消息再插入到另一个 List（可以叫作备份 List）留存。

### 44. List 作为消息队列有什么缺陷？

List 不支持多个消费者消费同一条消息，因为一旦消费者拉取一条消息后，这条消息就从 List 中删除了，无法被其它消费者再次消费。

要实现一条消息可以被多个消费者消费，那么就要将多个消费者组成一个消费组，使得多个消费者可以消费同一条消息，但是 List 类型并不支持消费组的实现。

### 45. Hash

#### 内部实现

Hash 类型的底层数据结构是由压缩列表或哈希表实现的：

* 如果哈希类型元素个数小于 512 个（默认值，可由 hash-max-ziplist-entries 配置，Redis 7.0 起更名为 hash-max-listpack-entries），所有值小于 64 字节（默认值，可由 hash-max-ziplist-value 配置，Redis 7.0 起更名为 hash-max-listpack-value）的话，Redis 会使用压缩列表作为 Hash 类型的底层数据结构；
* 如果哈希类型元素不满足上面条件，Redis 会使用哈希表作为 Hash 类型的 底层数据结构。

在 Redis 7.0 中，压缩列表数据结构已经废弃了，交由 listpack 数据结构来实现了。

#### 应用场景

* 缓存对象
* 购物车

### 46. Set

Set 类型和 List 类型的区别如下：

* List 可以存储重复元素，Set 只能存储非重复元素；
* List 是按照元素的先后顺序存储元素的，而 Set 则是无序方式存储元素的。

#### 内部实现

* 如果集合中的元素都是整数且元素个数小于 512 （默认值，set-maxintset-entries 配置）个，Redis 会使用整数集合作为 Set 类型的底层数据结构；
* 如果集合中的元素不满足上面条件，则 Redis 使用哈希表作为 Set 类型的底层数据结构。

#### 应用场景

* 点赞
* 共同关注
* 抽奖活动

### 47. Zset

有序集合保留了集合不能有重复成员的特性（分值可以重复），但不同的是，有序集合中的元素可以排序。

#### 内部实现

* 如果有序集合的元素个数小于 128 个，并且每个元素的值小于 64 字节时，Redis 会使用压缩列表作为 Zset 类型的底层数据结构；
* 如果有序集合的元素不满足上面的条件，Redis 会使用跳表作为 Zset 类型的底层数据结构；

ZSet 使用跳表的主要原因是为了实现有序集合的快速访问和范围查询。（实际上 Zset 底层是「跳表 + 哈希表」的组合，哈希表用于 O(1) 定位成员的分值）跳表通过层级结构和索引节点，提供了快速查找和范围查询的能力，并且具有较小的空间占用。这使得 ZSet 能够高效地处理有序集合的各种操作。

在 Redis 7.0 中，压缩列表数据结构已经废弃了，交由 listpack 数据结构来实现了。

#### 应用场景

* 排行榜
* 电话、姓名排序

### 48. BitMap

#### 内部实现

Bitmap 本身是用 String 类型作为底层数据结构实现的一种统计二值状态的数据类型。

String 类型是会保存为二进制的字节数组，所以，Redis 就把字节数组的每个 bit 位利用起来，用来表示一个元素的二值状态，你可以把 Bitmap 看作是一个 bit 数组。

#### 应用场景

* 签到统计
* 判断用户登录态
* 连续签到用户总数

### 49. HyperLogLog

HyperLogLog 提供不精确的去重计数，每个 HyperLogLog 键只需要花费 12 KB 内存，就可以计算接近 2^64 个不同元素的基数

#### 应用场景

* 百万级网页 UV 计数

### 50. GEO

#### 内部实现

GEO 本身并没有设计新的底层数据结构，而是直接使用了 ZSet 集合类型。

GEO 类型使用 GeoHash 编码方法实现了经纬度到 ZSet 中元素权重分数的转换，这其中的两个关键机制就是「对二维地图做区间划分」和「对区间进行编码」。一组经纬度落在某个区间后，就用区间的编码值来表示，并把编码值作为 ZSet 元素的权重分数。

这样一来，我们就可以把经纬度保存到 ZSet 中，利用 ZSet 提供的“按权重进行有序范围查找”的特性，实现 LBS 服务中频繁使用的“搜索附近”的需求。

#### 应用场景

* 滴滴打车
* 美团附件商家召回

### 51. Stream

支持消息的持久化、支持自动生成全局唯一 ID、支持 ack 确认消息的模式、支持消费组模式等，让消息队列更加的稳定和可靠。

* 消息保序：XADD/XREAD
* 阻塞读取：XREAD block
* 重复消息处理：Stream 在使用 XADD 命令，会自动生成全局唯一 ID；
* 消息可靠性：内部使用 PENDING List 自动保存消息，使用 XPENDING 命令查看消费组已经读取但是未被确认的消息，消费者使用 XACK 确认消息；
* 支持消费组形式消费数据

### 52. Redis 基于 Stream 消息队列与专业的消息队列有哪些差距？

* Redis 本身可能会丢数据；
* 面对消息挤压，内存资源会紧张；

### 53. 如何避免大 Key

最好在设计阶段，就把大 key 拆分成一个一个小 key。或者，定时检查 Redis 是否存在大 key ，如果该大 key 是可以删除的，不要使用 DEL 命令删除，因为该命令删除过程会阻塞主线程，而是用 unlink 命令（Redis 4.0+）删除大 key，因为该命令的删除过程是异步的，不会阻塞主线程。

### 54. Redis 主从复制

主从复制共有三种模式：全量复制、基于长连接的命令传播、增量复制。

主从服务器第一次同步的时候，就是采用全量复制，此时主服务器会两个耗时的地方，分别是生成 RDB 文件和传输 RDB 文件。为了避免过多的从服务器和主服务器进行全量复制，可以把一部分从服务器升级为「经理角色」，让它也有自己的从服务器，通过这样可以分摊主服务器的压力。

第一次同步完成后，主从服务器都会维护着一个长连接，主服务器在接收到写操作命令后，就会通过这个连接将写命令传播给从服务器，来保证主从服务器的数据一致性。

如果遇到网络断开，增量复制就可以上场了，不过这个还跟 repl\_backlog\_size 这个大小有关系。

如果它配置的过小，主从服务器网络恢复时，可能发生「从服务器」想读的数据已经被覆盖了，那么这时就会导致主服务器采用全量复制的方式。所以为了避免这种情况的频繁发生，要调大这个参数的值，以降低主从服务器断开后全量同步的概率。

### 55. 怎么判断 Redis 某个节点是否正常工作？

Redis 判断节点是否正常工作，基本都是通过互相的 ping-pong 心态检测机制，如果有一半以上的节点去 ping 一个节点的时候没有 pong 回应，集群就会认为这个节点挂掉了，会断开与这个节点的连接。

Redis 主从节点发送的心态间隔是不一样的，而且作用也有一点区别：

* Redis 主节点默认每隔 10 秒对从节点发送 ping 命令，判断从节点的存活性和连接状态，可通过参数 repl-ping-slave-period 控制发送频率。
* Redis 从节点每隔 1 秒发送 replconf ack{offset} 命令，给主节点上报自身当前的复制偏移量，目的是为了：
  * 实时监测主从节点网络状态；
  * 上报自身复制偏移量， 检查复制数据是否丢失， 如果从节点数据丢失， 再从主节点的复制缓冲区中拉取丢失数据。

### 56. 主从复制架构中，过期 key 如何处理？

主节点处理了一个 key 或者通过淘汰算法淘汰了一个 key，这时主节点会模拟一条 del 命令发送给从节点，从节点收到该命令后，就进行删除 key 的操作。

### 57. Redis 是同步复制还是异步复制？

Redis 主节点每次收到写命令之后，先写到内部的缓冲区，然后异步发送给从节点。

`replication buffer` 是主节点（Master）用来暂时存储发送给从节点（Slave）的数据的缓冲区。每个从节点都有自己独立的 `replication buffer`。

### 58. 主从复制中两个 Buffer(replication buffer 、repl backlog buffer) 有什么区别？

replication buffer 、repl backlog buffer 区别如下：

* 出现的阶段不一样：
  * repl backlog buffer 是在增量复制阶段出现，一个主节点只分配一个 repl backlog buffer；
  * replication buffer 是在全量复制阶段和增量复制阶段都会出现，主节点会给每个新连接的从节点，分配一个 replication buffer；
* 这两个 Buffer 都有大小限制的，当缓冲区满了之后，发生的事情不一样：
  * 当 repl backlog buffer 满了，因为是环形结构，会直接覆盖起始位置数据;
  * 当 replication buffer 满了，会导致连接断开，删除缓存，从节点重新连接，重新开始全量复制。

### 59. 为什么会出现主从数据不一致？

之所以会出现主从数据不一致的现象，是因为主从节点间的命令复制是异步进行的，所以无法实现强一致性保证（主从数据时时刻刻保持一致）。

### 60. 如何如何应对主从数据不一致？

* 尽量保证主从节点间的网络连接状况良好，避免主从节点在不同的机房。
* 可以开发一个外部程序来监控主从节点间的复制进度。具体做法：
  * 我们可以开发一个监控程序，先用 INFO replication 命令查到主、从节点的进度，然后，我们用 master\_repl\_offset 减去 slave\_repl\_offset，这样就能得到从节点和主节点间的复制进度差值了。
  * 如果某个从节点的进度差值大于我们预设的阈值，我们可以让客户端不再和这个从节点连接进行数据读取，这样就可以减少读到不一致数据的情况。

### 61. 主从切换如何减少数据丢失？

#### 异步复制同步丢失

Redis 配置里有一个参数 min-slaves-max-lag，表示一旦所有的从节点数据复制和同步的延迟都超过了 min-slaves-max-lag 定义的值，那么主节点就会拒绝接收任何请求。

假设将 min-slaves-max-lag 配置为 10s 后，根据目前 master->slave 的复制速度，如果数据同步完成所需要时间超过 10s，就会认为 master 未来宕机后损失的数据会很多，master 就拒绝写入新请求。这样就能将 master 和 slave 数据差控制在 10s 内，即使 master 宕机也只是这未复制的 10s 数据。

那么对于客户端，当客户端发现 master 不可写后，我们可以采取降级措施，将数据暂时写入本地缓存和磁盘中，在一段时间（等 master 恢复正常）后重新写入 master 来保证数据不丢失。

#### 集群产生脑裂数据丢失

* min-slaves-to-write x，主节点必须要有至少 x 个从节点连接，如果小于这个数，主节点会禁止写数据。
* min-slaves-max-lag x，主从数据复制和同步的延迟不能超过 x 秒，如果主从同步的延迟超过 x 秒，主节点会禁止写数据。 （注：Redis 5.0 起以上两个参数更名为 min-replicas-to-write / min-replicas-max-lag，旧名保留为别名，Redis 7.0 移除旧名。）

我们可以把 min-slaves-to-write 和 min-slaves-max-lag 这两个配置项搭配起来使用，分别给它们设置一定的阈值，假设为 N 和 T。

这两个配置项组合后的要求是，主节点连接的从节点中至少有 N 个从节点，「并且」主节点进行数据复制时的 ACK 消息延迟不能超过 T 秒，否则，主节点就不会再接收客户端的写请求了。

### 62. 主从如何做到故障自动切换？

主节点挂了 ，从节点是无法自动升级为主节点的，这个过程需要人工处理，在此期间 Redis 无法对外提供写操作。

可以使用哨兵在发现主节点出现故障时，由哨兵自动完成故障发现和故障转移，并通知给应用方，从而实现高可用性。

### 63. 为什么要有哨兵

Redis 在 2.8 版本以后提供的哨兵（Sentinel）机制，它的作用是实现主从节点故障转移。它会监测主节点是否存活，如果发现主节点挂了，它就会选举一个从节点切换为主节点，并且把新主节点的相关信息通知给从节点和客户端。

哨兵一般是以集群的方式部署，至少需要 3 个哨兵节点，哨兵集群主要负责三件事情：监控、选主、通知。

哨兵节点通过 Redis 的发布者/订阅者机制，哨兵之间可以相互感知，相互连接，然后组成哨兵集群，同时哨兵又通过 INFO 命令，在主节点里获得了所有从节点连接信息，于是就能和从节点建立连接，并进行监控了。

1. 第一轮投票：判断主节点下线

当哨兵集群中的某个哨兵判定主节点下线（主观下线）后，就会向其他哨兵发起命令，其他哨兵收到这个命令后，就会根据自身和主节点的网络状况，做出赞成投票或者拒绝投票的响应。

当这个哨兵的赞同票数达到哨兵配置文件中的 quorum 配置项设定的值后，这时主节点就会被该哨兵标记为「客观下线」。

2. 第二轮投票：选出哨兵 leader

某个哨兵判定主节点客观下线后，该哨兵就会发起投票，告诉其他哨兵，它想成为 leader，想成为 leader 的哨兵节点，要满足两个条件：

* 第一，拿到半数以上的赞成票；
* 第二，拿到的票数同时还需要大于等于哨兵配置文件中的 quorum 值。

3. 由哨兵 leader 进行主从故障转移

选举出了哨兵 leader 后，就可以进行主从故障转移的过程了。该操作包含以下四个步骤：

* 第一步：在已下线主节点（旧主节点）属下的所有「从节点」里面，挑选出一个从节点，并将其转换为主节点，选择的规则：
  * 过滤掉已经离线的从节点；
  * 过滤掉历史网络连接状态不好的从节点；
  * 将剩下的从节点，进行三轮考察：优先级、复制进度、ID 号。在每一轮考察过程中，如果找到了一个胜出的从节点，就将其作为新主节点。
* 第二步：让已下线主节点属下的所有「从节点」修改复制目标，修改为复制「新主节点」；
* 第三步：将新主节点的 IP 地址和信息，通过「发布者/订阅者机制」通知给客户端；
* 第四步：继续监视旧主节点，当这个旧主节点重新上线时，将它设置为新主节点的从节点；

### 64. 数据库和缓存如何保证一致性？

旁路一致性保证。

针对「先删除缓存，再更新数据库」方案在「读 + 写」并发请求而造成缓存不一致的解决办法是「延迟双删」。

### 65. 如何保证两个操作都能执行成功？

我们可以引入消息队列，将第二个操作（删除缓存）要操作的数据加入到消息队列，由消费者来操作数据。

* 如果应用删除缓存失败，可以从消息队列中重新读取数据，然后再次删除缓存，这个就是重试机制。当然，如果重试超过的一定次数，还是没有成功，我们就需要向业务层发送报错信息了。
* 如果删除缓存成功，就要把数据从消息队列中移除，避免重复操作，否则就继续重试。

### 66. Redis 使用的协议

Redis 底层使用的通信协议是 RESP（Redis Serialization Protocol 的缩写），此协议只适用于 Redis 客户端 - 服务端 之间的通信， Redis 集群中节点间通信使用的是 Gossip 协议。

### 67. Redis 集群有多少个槽

Redis 集群默认情况下有 16384 个槽。这是因为 Redis 使用哈希槽（hash slots）来分片数据，并将数据分布在多个节点上。每个槽可以保存多个键值对（槽是数据分片的基本单元，一个槽内可存放任意数量的 key），16384 只是槽的总数，并不限制集群中键值对的总数。

### 68. Redis 如何解决 hash 结构的冲突

Redis 在哈希结构中使用了链地址法来解决冲突。具体实现中，Redis 的哈希表（dict）采用链地址法（拉链法），落在同一个哈希桶上的多个键通过链表串联；当哈希表的负载因子过高时，Redis 会触发扩容（rehash），将哈希桶数量翻倍，并通过渐进式 rehash 分批迁移数据。跳表（Skip List）是 ZSet 类型的底层结构，与哈希冲突处理无关。

### 69. Redis 保证 incr 命令原子性的原理是什么？

因为 Redis 是单线程的。

### 70. 用 Redis ZSet 实现排行榜先用分数再用时间排序怎么实现？

如果是用 41bit 表示时间戳，22bit 表示积分的话，那么 score 的组成就是这样的： 0（最高位不用）|0000000 00000000 0000000（22bit 表示积分）|0 00000000 00000000 00000000 00000000 00000000（41bit 表示时间戳）

因为排序首先按积分排再按时间排，所以积分在高位，时间戳在低位，这样不管时间戳的值是多少，积分越大，64bit 表示的数值就越大。

但我们需要的是按时间升序排，也就是最先达到 xx 积分的用户排在最前面，所以我们不能单纯的使用 41bit 存储时间戳，而应该是存储一个随时间流逝而变小的数值。

由于排行榜都会有一个周期，如周榜是一周，月榜是一个月，所以我们使用 41bit 存储的是一个周期的结束时间 `yyy-MM-dd 23:59:59` 对应的时间戳与用户积分更新时间的时间戳的差值，这个值会随着时间的推移而变小，而且不会出现负数的情况，刚好能够达到目的。

### 71. 判断 Key 是否存在

要判断 Redis 中的 Key 是否存在，可以使用 EXISTS 命令。EXISTS 命令用于检查给定的 Key 是否存在于 Redis 中。它的基本语法如下：

```
EXISTS KEY_NAME
```

如果 Key 存在，命令返回整数 1；如果 Key 不存在，命令返回整数 0。

### 72. Redis 的扩容方式

1. 水平扩容 (分区) ：通过增加更多的 Redis 服务器来分摊数据和负载。数据将被客户端分散（分片）存储到多个 Redis 实例中，每个实例只存储整个数据的一部分。这种方式需要在客户端实现分片逻辑，Redis Cluster 提供了自动分片和高可用性的解决方案。
2. 垂直扩容：通过增加单个 Redis 服务器的硬件资源 (如 RAM、CPU 等) 来提升其性能和容量。这种方式的缺点是硬件资源是有上限的，无法无限扩容。

### 73. Redis 如何实现乐观锁

通过 `WATCH` 命令配合 `MULTI` 和 `EXEC` 来模拟乐观锁的行为。核心的想法是：我们“监视”一个变量，然后在执行事务前检查监视的变量是否已经发生改变。如果变量发生了改变，我们就放弃执行事务。

1. **监控键**：使用 `WATCH` 命令监控需要保护的键。
2. **开启事务**：使用 `MULTI` 命令开启事务。
3. **执行操作**：在事务内执行一系列命令。
4. **提交事务**：使用 `EXEC` 命令提交事务。如果监控的键在此期间被修改，事务将失败。

### 74. Redis 渐进式 Rehash

重新哈希/扩容操作会占用大量的 CPU 和内存资源，如果一次性完成该操作，可能会导致 Redis 在一段时间内阻塞，无法对外提供服务。

在渐进式 rehash 中，Redis 会同时维持新旧两个哈希表，新的哈希表是旧的哈希表大小的两倍。Redis 在对哈希表执行任何操作时，会顺带将旧哈希表中的一部分键值对移动到新哈希表中。具体移动的数量由服务器的配置决定。

假设服务器配置了每次把 100 个键值对从旧哈希表移动到新哈希表，Redis 每次执行一个哈希表操作，都会顺带完成这个移动操作；当旧哈希表的所有键值对都移动到新哈希表后，Redis 就释放旧哈希表的内存空间，渐进式 rehash 操作完成。

这种渐进方式的好处是将高负载的操作分摊到了一个时间段内，避免了 Redis 在进行哈希表扩容的时候服务暂停，提高了 Redis 的高可用性。

### 75. 怎么用 Bitmap 统计用户登录情况

使用 Bitmap 统计用户一年登录情况的基本思路是利用 Redis 的 Bitmap 数据结构，将每个用户的登录状态映射到一个特定的位上。以下是具体的步骤和实现方法：

#### 基本概念

Bitmap 是一种高效的数据结构，能够以极小的内存占用来表示大量的二值状态（如登录与未登录）。在 Redis 中，每个用户的登录状态可以用一个比特位（bit）来表示，1 表示已登录，0 表示未登录。

#### 实现步骤

1. **定义 Key**： 为每个用户的登录状态定义一个唯一的 Key，通常可以采用以下格式：

```bash
   login_status:{user_id}
```

例如，用户 ID 为 1001 的用户，其 Key 可以是 `login_status:1001`。

2. **设置登录状态**： 每当用户登录时，使用 `SETBIT` 命令将对应的位设置为 1。假设用户在一年中的第 n 天登录，可以这样设置：

```bash
   SETBIT login_status:1001 n 1
```

这里，n 从 0 到 364 表示一年中的每一天。

3. **检查登录状态**： 要检查某个特定日期用户是否登录，可以使用 `GETBIT` 命令：

```bash
   GETBIT login_status:1001 n
```

如果返回 1，则表示该用户在第 n 天登录。

4. **统计登录次数**： 使用 `BITCOUNT` 命令可以统计用户在一年中登录的总次数：

```bash
   BITCOUNT login_status:1001
```

这将返回该用户在一年内登录的总天数。

### 76. Redis 分布式锁方案

> 注：本条为外部参考链接（掘金/CSDN），内容未逐一核验，建议以官方文档（Redis SET NX EX、Redisson 实现）为准。

### 77. Redis 热点数据解决方案

* 本地缓存，将热 Key 提前加载到本地内存中
* 热点 Key 限流，读命令通过添加从节点解决，写命令添加限流器

## MongoDB

### 1. MongoDB 的优势有哪些

* 面向文档的存储：以 JSON 格式的文档保存数据。
* 任何属性都可以建立索引。
* 复制以及高可扩展性。
* 自动分片。
* 丰富的查询功能。
* 快速的即时更新。

### 2. 为什么在 MongoDB 中使用 "Object ID" 数据类型

"ObjectID" 数据类型用于存储文档 id

### 3. MongoDB 中的“Namespace”是什么?

MongoDB 在集合中存储 BSON(二进制交换和结构对象表示法) 对象。集合名称和数据库名称的连接称为命名空间。

### 4. MongoDB 中的分片是什么?

跨多台机器存储数据记录的过程称为分片。它是一种 MongoDB 方法，以满足数据增长的需求。它是数据库或搜索引擎中的数据的水平分区。每个分区都称为切分或数据库切分。

## 对象存储

### 1. 何为对象存储？

对象存储服务（Object Storage Service，OSS）是一种海量、安全、低成本、高可靠的云存储服务，适合存放任意类型的文件。容量和处理能力弹性扩展，多种存储类型供选择，全面优化存储成本。

### 2. MinIO 基础概念

* Object：存储到 MinIO 的基本对象，如文件、字节流，Anything…
* Bucket：用来存储 Object 的逻辑空间。每个 Bucket 之间的数据是相互隔离的。对于客户端而言，就相当于一个存放文件的顶层文件夹。
* Drive：即存储数据的磁盘，在 MinIO 启动时，以参数的方式传入。Minio 中所有的对象数据都会存储在 Drive 里。
* Set：即一组 Drive 的集合，分布式部署根据集群规模自动划分一个或多个 Set ，每个 Set 中的 Drive 分布在不同位置。一个对象存储在一个 Set 上。（For example: {1…64} is divided into 4 sets each of size 16.）

一个对象存储在一个 Set 上

一个集群划分为多个 Set

一个 Set 包含的 Drive 数量是固定的，默认由系统根据集群规模自动计算得出

一个 Set 中的 Drive 尽可能分布在不同的节点上

### 3. MinIO 的数据高可靠

Minio 使用了 Erasure Code 纠删码和 Bit Rot Protection 数据腐化保护这两个特性，所以 MinIO 的数据可靠性做的高。

Minio 纠删码可以在丢失一半的盘的情况下，仍可以保证数据安全。 而且 Minio 纠删码是作用在对象级别，可以一次恢复一个对象

### 4. SeaweedFS 特点

SeaweedFS 最初作为一个对象存储来有效地处理**小文件**。中央主服务器（master）只管理文件卷（volume），而不是管理中央主服务器中的所有文件元数据，它允许这些卷服务器管理文件及其元数据。这减轻了中央主服务器的并发压力，并将文件元数据传播到卷服务器，允许更快的文件访问 (只需一个磁盘读取操作)。每个文件的元数据只有 40 字节的磁盘存储开销。使用 O(1) 磁盘读取。

### 5. SeaweedFS Master 原理

Master Server 集群之间的副本同步是基于 Raft 协议。

Master Server 保存整个 Volume Server 的拓扑信息，结构: Topology -> Data Center-> Rack -> Data Node -> Volume。可以通过 Data Center-> Rack -> Data Node 查找一个 Data Node 上的所有 Volume 信息。

另外，也需要根据 Volume 去查找它所在的所有节点，所以 Leader Master 还维护着另一个结构: Topology -> Collection -> VolumeLayout-> Data Node List 其中，Collection 对 VolumeLayout 的组织是按备份方式分类区分，VolumeLayout 保存每个 volume id 到其具体位置 Data Node List 的映射，同时保存了 Volume 的可读写性质。

Leader Master 跟各个 Volume Server 通过心跳保持连接, Volume Server 通过心跳将本地的卷信息 (增、删、过期等) 上报给 Leader Master。

然后 Leader Master 再将 Volume 的位置信息通过 gRPC 同步 *（KeepConnected）* 给其余 Master。所以, 当查询一个 Volume 的具体位置时，Leader Master 直接从本地的 Topology 中读取, 非 Leader Master 则从本地 Master Client 的 vidMap 中获取数据。

当要上传文件时，Leader Master 还负责分配一个全局唯一且递增的 id 作为 file id。

### 6. SeaweedFS Volume 原理

volume server 是文件实际存储的位置, 对文件的组织是按如下格式进行的：Store -> DiskLocations -> Volumes -> Needles

其中 DiskLocations 对应不同的目录, 每个目录中有很多 Volumes, 每个 Volume 实际是一个硬盘文件 .dat，其中分段保存一批上传的业务文件, 为了快速定位一个业务文件在 .dat 中的位置, 每个 Volume 对应个 .idx 文件, 用于保存所有业务文件在 .dat 文件中的偏移量和文件大小。为加快查询索引速度, Volume Server 启动时会将 .idx 读到 LevelDb 中，虽然一个业务文件在多个 Volume Server 作为主备, 但各 Volume Server 是对等的，平等接收外部请求, 当收到上传业务文件后, 会将文件传到其它机器。

## ElasticSearch

### 1. Elasticsearch 的搜索过程是怎样的？

Elasticsearch 的搜索过程可以分为两个主要阶段：**查询阶段（Query）** 和 **获取阶段（Fetch）**。

* **查询阶段**：
  1. 当接收到搜索请求时，Elasticsearch 会确定该请求命中的分片（主分片或副本分片）。
  2. 每个分片在本地执行查询，并将结果存储在一个有序的优先队列中。
  3. 查询结果被发送到协调节点，协调节点会整合所有分片的结果并生成一个全局排序列表。
* **获取阶段**：
  1. 协调节点根据全局排序列表，向各个分片请求具体的文档数据。
  2. 最终，路由节点将这些文档返回给客户端。

### 2. 为什么使用倒排索引？

倒排索引是一种高效的文本检索结构，它将文档中的单词映射到包含该单词的文档列表。相比于正排索引（按文档存储单词），倒排索引在搜索时能显著提高查询速度，因为它可以快速定位到包含特定关键词的文档，减少了需要扫描的文档数量。这种结构特别适合于全文搜索场景。

### 3. Elasticsearch 如何处理大数据量的聚合？

Elasticsearch 使用 **基数聚合（Cardinality Aggregation）** 来处理大数据量的聚合。基数聚合基于 HyperLogLog（HLL）算法，能够高效地计算字段的唯一值数量。它通过对输入数据进行哈希处理，并根据哈希结果中的位数进行概率估算，从而得到基数。该方法的优点是可以配置精度，以控制内存使用，适用于数十亿的唯一值。

### 4. Elasticsearch 的集群架构是怎样的？

Elasticsearch 的集群架构由多个节点组成，每个节点可以扮演不同的角色，包括：

* **主节点**：负责集群的管理和状态维护。
* **数据节点**：存储数据和执行数据相关的操作，如索引和搜索。
* **协调节点**：负责接收客户端请求，并将请求分发到相应的数据节点，最终将结果返回给客户端。

这种架构设计使得 Elasticsearch 能够扩展并处理大规模的数据集。

### 5. Elasticsearch 中的分片是什么？

分片是 Elasticsearch 中用于存储和管理数据的基本单位。每个索引可以被划分为多个分片，每个分片都是一个独立的 Lucene 索引。分片的目的是为了提高数据的可扩展性和查询性能。通过将数据分散到多个分片上，Elasticsearch 能够并行处理查询和索引操作，从而提升整体性能。分片可以是主分片或副本分片，副本分片用于提供冗余和提高查询性能。


# 消息队列

## 1. 消息队列的作用？

* 异步处理
  * 可以更快地返回结果；
  * 减少等待，自然实现了步骤之间的并发，提升系统总体的性能。
* 流量控制
* 服务解耦

## 2. 消息队列选型

* 如果对消息队列功能和性能都没有很高的要求，只需要一个开箱即用易于维护的产品，建议使用 RabbitMQ。
* 如果消息队列主要场景是处理在线业务，比如在交易系统中用消息队列传递订单，那 RocketMQ 的低延迟和金融级的稳定性更优秀。
* 如果你需要处理海量的消息，像收集日志、监控信息或是前端的埋点这类数据，或是你的应用场景大量使用了大数据、流计算相关的开源产品，那 Kafka 更优秀。

## 3. 主题和队列有什么区别

这两个概念的背后实际上对应着两种不同的消息模型：队列模型和发布 - 订阅模型。这两种消息模型其实并没有本质上的区别，都可以通过一些扩展或者变化来互相替代。

常用的消息队列中，RabbitMQ 采用的是队列模型，但是它一样可以实现发布 - 订阅的功能。RocketMQ 和 Kafka 采用的是发布 - 订阅模型，并且二者的消息模型是基本一致的。（补充：RabbitMQ 底层基于 Exchange/Binding 机制，本身就能原生实现发布-订阅，因此也有观点认为把它归为『队列模型』是一种简化；两派分歧主要在于按消息模型还是按消息路由机制来归类，两种模型本身可以通过扩展互相替代。）

## 4. RabbitMQ 的消息模型

在 RabbitMQ 中，Exchange 位于生产者和队列之间，生产者并不关心将消息发送给哪个队列，而是将消息发送给 Exchange，由 Exchange 上配置的策略来决定将消息投递到哪些队列中。

同一份消息如果需要被多个消费者来消费，需要配置 Exchange 将消息发送到多个队列，每个队列中都存放一份完整的消息数据，可以为一个消费者提供消费服务。这也可以变相地实现发布 - 订阅模型中，“一份消息数据可以被多个订阅者来多次消费”这样的功能

## 5. 如何利用事务消息实现分布式事务？

首先，订单系统在消息队列上开启一个事务。然后订单系统给消息服务器发送一个“半消息”，这个半消息包含的内容就是完整的消息内容，和普通消息的唯一区别是：在事务提交之前，对于消费者来说，这个消息是不可见的。

半消息发送成功后，订单系统就可以执行本地事务了，在订单库中创建一条订单记录，并提交订单库的数据库事务。然后根据本地事务的执行结果决定提交或者回滚事务消息。如果订单创建成功，那就提交事务消息，购物车系统就可以消费到这条消息继续后续的流程。如果订单创建失败，那就回滚事务消息，购物车系统就不会收到这条消息。这样就基本实现了“要么都成功，要么都失败”的一致性要求。

RocketMQ 的事务反查机制通过定期反查事务状态，来补偿提交事务消息可能出现的通信失败。

## 6. 如何确保消息不会丢失

### 生产阶段

在生产阶段，消息从生产者发送到消息队列（Broker）。此阶段可能会因为网络问题导致消息丢失。为了确保消息不丢失，可以采取以下措施：

* **确认机制**：使用消息确认机制，确保 Broker 成功接收并存储消息后，才返回成功响应给生产者。
* **重试机制**：如果生产者长时间未收到确认响应，可以自动重试发送消息，直到达到最大重试次数或明确失败。

### 存储阶段

在存储阶段，消息被存储在 Broker 中，这个过程也可能导致消息丢失，特别是在 Broker 故障时。为确保消息的持久性，可以采取以下策略：

* **持久化存储**：配置 Broker 使用同步刷盘（SYNC\_FLUSH）策略，即在消息写入磁盘后再返回成功响应，这样即使 Broker 宕机，消息也不会丢失。
* **副本机制（主从复制）**：在集群模式下，确保消息被复制到多个 Broker 节点（主从复制），这样即使主节点出现故障，从节点也能提供备份，防止消息丢失。

### 消费阶段

在消费阶段，消费者从 Broker 拉取消息并进行处理。此阶段同样可能导致消息丢失，主要是因为消费者未能成功处理消息或未确认消费。为避免此类问题，可以采取以下措施：

* **手动确认 offset**：消费者在成功处理消息后，手动提交 offset，确保 Broker 知道该消息已被成功消费。如果处理失败，消费者可以选择不提交 offset，从而在下次拉取时重新获取该消息。
* **重试机制**：如果消费者处理消息失败，可以在处理逻辑中实现重试机制，确保消息最终被成功处理。

### 消息丢失的检测

为了检测消息是否丢失，可以利用消息的有序性。生产者在发送每条消息时附加一个递增的序号，消费者在接收消息时检查这些序号的连续性。如果发现序号不连续，则说明有消息丢失。

## 7. 检测消息丢失的方法

* 使用分布式链路追踪系统，使用类似的追踪系统可以很方便地追踪每一条消息。
* 我们可以利用消息队列的有序性来验证是否有消息丢失。在 Producer 端，我们给每个发出的消息附加一个连续递增的序号，然后在 Consumer 端来检查这个序号的连续性。

## 8. 如何处理消费过程中的重复消息？

通过幂等消费来解决消息重复的问题

* **唯一约束**：在数据库中设置唯一约束，确保重复的插入操作不会导致数据重复。
* **状态检查**：在处理消息前检查状态，如果消息已处理则跳过。例如，使用状态标识（如已处理标志）来记录消息处理状态。
* **去重表**：使用去重表记录已处理的消息 ID，处理前检查消息 ID 是否存在于去重表中。

## 9. 消息积压了该如何处理？

优化消息收发性能，预防消息积压的方法有两种，增加批量或者是增加并发，在发送端这两种方法都可以使用，在消费端需要注意的是，增加并发需要同步扩容分区数量，否则是起不到效果的。因为对于消费者来说，在每个分区上实际上只能支持单线程消费。

对于系统发生消息积压的情况，需要先解决积压，再分析原因，毕竟保证系统的可用性是首先要解决的问题。快速解决积压的方法就是通过水平扩容增加 Consumer 的实例数量。

## 10. 如何保证消息的严格顺序？

* 使用单一消费者处理消息，确保消息按照发送顺序依次处理。
* 将消息按照某种规则（如消息键、用户 ID 等）分配到不同的分区，每个分区由一个消费者处理，确保同一分区内的消息顺序。可以在发送端使用账户 ID 作为 Key，采用一致性哈希算法计算出队列编号，指定队列来发送消息。这样可以保证相同 Key 的消息是严格有序的。

## 11. RabbitMQ 的缺点

* 系统可用性降低
* 系统复杂度提高
* 管理复杂性 —— RabbitMQ 的安装、配置和管理相对复杂
* 吞吐量相对 Kafka、RocketMQ 一般，高吞吐场景下不如后两者
* 依赖 Erlang 语言实现，生态相对小众，深度定制门槛高
* 消息堆积能力较弱，堆积过多会显著影响性能

## 12. RabbitMQ 的工作模式

1. Simple 模式（即最简单的收发模式）

* 消息生产者产生消息，将消息放入队列。
* 消息的消费者 (consumer) 监听消息队列，消息被拿走后，自动从队列中删除（隐患：消息可能没有被消费者正确处理，已经从队列中消失了，造成消息的丢失，这里可以设置成手动的 ack，但如果设置成手动 ack，处理完后要及时发送 ack 消息给队列，否则会造成内存溢出）。

2. Work 工作模式（资源的竞争）

消息生产者将消息放入队列，消费者可以有多个，消费者 1、消费者 2 同时监听同一个队列，共同消费队列中的消息。每条消息只会被投递给其中一个消费者，默认按轮询（round-robin）方式分发，不区分消费者的处理能力。（隐患：默认预取（prefetch）数量无上限，处理慢的消费者会积压大量未确认消息；可通过 Channel 设置 basicQos（prefetchCount=1）并配合手动 ack，实现“处理完一条再取一条”的公平分发，避免消息堆积与丢失。）

3. Publish/Subscribe 发布订阅（共享资源）

* 每个消费者监听自己的队列。
* 生产者将消息发给 broker，由交换机将消息转发到绑定此交换机的每个队列，每个绑定交换机的队列都将接收到消息。

4. Routing 路由模式

* 消息生产者发送消息时携带一个路由键（routing key，字符串），交换机根据路由键与队列绑定的规则进行匹配，匹配成功的队列才会收到消息，对应的消费者才能消费。
* 根据业务功能定义路由字符串。
* 从系统的代码逻辑中获取对应的功能字符串,将消息任务扔到对应的队列中。

5. Topic 主题模式

* 星号井号代表通配符
* 星号（\*）代表一个单词，井号（#）代表零个或多个单词
* 路由功能添加模糊匹配
* 消息生产者产生消息，把消息交给交换机
* 交换机根据 key 的规则模糊匹配到对应的队列，由队列的监听消费者接收消息消费

## 13. Kafka 如何实现高性能

* 磁盘顺序读写 Kafka 对磁盘的应用，得益于消息队列的存储特性。与普通的关系型数据库、各类 NoSQL 数据库等不同，消息队列对外提供的主要方法是生产和消费，不涉及数据的 CRUD。所以在写入磁盘时，可以使用顺序追加的方式来避免低效的磁盘寻址。
* 批量操作优化 Kafka 的批量包括批量写入、批量发布等。它在消息投递时会将消息缓存起来，然后批量发送；同样，消费端在消费消息时，也不是一条一条处理的，而是批量进行拉取，提高了消息的处理速度。
* Sendfile 零拷贝 Kafka 把所有的消息都存放在单独的文件里，在消息投递时直接通过 Sendfile 方法发送文件，减少了上下文切换，因此大大提高了性能。
* MMAP 技术 Kafka 使用 Memory Mapped Files 完成内存映射，Memory Mapped Files 对文件的操作不是 write/read，而是直接对内存地址的操作。如果是调用文件的 read 操作，则把数据先读取到内核空间中，然后再复制到用户空间。 但 MMAP 可以将文件直接映射到用户态的内存空间，省去了用户空间到内核空间复制的开销，所以说 MMAP 也是一种零拷贝技术。（补充说明："MMAP 也是零拷贝"是常见说法但有争议——mmap 避免了 read/write 的用户缓冲区拷贝，但严格来说仍涉及一次 CPU 拷贝，并不算真正的零拷贝；且 Kafka 实际只用 mmap（MappedByteBuffer）映射索引文件，消息数据文件本身并不用 mmap，Kafka 真正意义上的零拷贝是消费阶段通过 sendfile 直接从 page cache 将数据发送到网卡。）

## 14. MQTT 中的 QoS

1. **QoS 0：最多分发一次（At most once）** — 这是一种最低级别的服务，消息传递给接收者没有任何的确认机制，消息可能会丢失，也不可知。同时，这种模式的性能最好，因为没有重传或者确认的开销。
2. **QoS 1：至少分发一次（At least once）** — 针对这种级别的消息，确保消息至少会被传递到接收者一次，但可能会有重复。发送者将发送消息直到他收到接收者已收到消息的确认。这种模式可能会导致消息的重复传送。
3. **QoS 2：仅分发一次（Exactly once）** — 这是最高级别的消息分发保证，确保消息只被接收者接收和处理一次。这种模式适用于所有对消息重复敏感的场景，但它的开销也是最大的。

## 15. MQTT 的优点

MQTT（Message Queuing Telemetry Transport）是一种轻量级的发布/订阅消息传输协议，特别适合在网络带宽低、网络不稳定、硬件性能差等情况下使用。所以，它经常被用于硬件设备的交互。

1. **资源消耗小**：MQTT 设计得非常精简且易于实现，因此占用的资源非常低。对于性能有限的硬件设备来说，这一点非常重要。
2. **网络带宽需求低**：因为 MQTT 使用了发布/订阅模型，所以消息可以有效地在所有订阅的客户端之间分发，而无需创建多个连接。MQTT 的固定报头也非常小（最小只有 2 字节），因此减少了网络带宽的需求。
3. **遥测特性**：MQTT 协议是为大规模远程设备构建的，非常适合物联网（IoT）应用。
4. **质量服务**：MQTT 支持 3 种不同级别的消息传送质量（QoS），可以根据需要选择最适合设备和网络条件的级别。

## 16. Kafka 是如何做数据持久化的

Kafka 通过日志文件的方式实现数据持久化。具体来说，Kafka 将消息追加写入到一个称为日志（Log）的文件中，这个日志文件是一个持久化的、有序的、不可修改的消息记录。一旦消息写入到日志文件中，就会被存储在磁盘上，即使 Kafka 服务发生故障或 Broker 重启，消息数据仍然可以从磁盘上加载并重新构建。

为了快速检索消息，Kafka 维护了一个消息索引，存储了每个分区中消息的偏移量和物理位置，使得 Kafka 能够快速定位和检索消息。此外，为了进一步提高可靠性，Kafka 支持消息的复制，每个分区的消息可以有多个副本，它们分布在不同的 Broker 上，通过 ISR（In-Sync Replica）机制保障了 Leader 和 Follower 之间的数据同步，保证了消息的持久性。

## 17. Kafka 如何保证消息有序

1. 1 个 Topic 只对应一个 Partition。
2. 发送消息的时候指定 key/Partition。

## 18. Kafka 如何保证消息不重复消费

**Kafka 出现消息重复消费的原因：**

* 消费端已经消费完消息但没有成功提交 offset（根本原因）。
* 由于消费端处理业务时间过长（超过会话超时时间）或者网络连接异常等原因，让 Kafka 认为消费者已经假死，从而触发了分区 rebalance。

**解决方案：**

* 消费消息服务做幂等校验，比如 Redis 的 set、MySQL 的主键等天然的幂等功能。这种方法最有效。
* 将 **`enable.auto.commit`** 参数设置为 false，关闭自动提交，开发者在代码中手动提交 offset。那么这里会有个问题：**什么时候提交 offset 合适？**
  * 处理完消息再提交：依旧有消息重复消费的风险，和自动提交一样
  * 拉取到消息即提交：会有消息丢失的风险。允许消息延时的场景，一般会采用这种方式。然后，通过定时任务在业务不繁忙（比如凌晨）的时候做数据兜底。

## 19. Kafka 的分区分配策略

1. **RangeAssignor**（范围分配策略）：Kafka 2.3 及以前版本的默认分配策略（当时默认项仅有 RangeAssignor，但 RoundRobin 等策略早已可选配置）；它首先将分区按数字顺序排列，然后将消费者按字典序排列。分区数量除以消费者数量，以确定每个消费者应负责的分区数量。如果无法均匀分配，则前面的消费者会多一个分区。自 Kafka 3.0 起，默认配置改为 RangeAssignor + CooperativeStickyAssignor 的组合（RangeAssignor 仍为首选分配器；只有从配置中移除 RangeAssignor，才会真正启用 CooperativeSticky 的增量再均衡。KIP-429 在 Kafka 2.4 引入 CooperativeStickyAssignor，KAFKA-13023 在 Kafka 3.0 将其加入默认配置）。
2. **RoundRobinAssignor**（轮循分配策略）：该策略将所有分区和消费者按字典序排序，然后通过轮询的方式将分区依次分配给每个消费者。这种方法适用于消费者数量和分区数量相对接近的情况。
3. **StickyAssignor**（粘性分配策略）：这是较新的分配策略，旨在减少重新平衡时的分区变更，提供更好的负载均衡和更少的分区迁移。它在分配时优先考虑保持消费者之前的分配状态。

### 分区分配的实施过程

分区分配的过程主要由 Kafka 的组协调器（GroupCoordinator）管理。其实施步骤如下：

1. **消费者提案**：每个消费者向 GroupCoordinator 发送 JoinGroupRequest 请求，包含其分配策略和订阅信息。
2. **选举 Leader 和分配策略**：GroupCoordinator 收集所有消费者的提案，选举出一个消费者作为组的 Leader，并确定使用的分区分配策略。
3. **执行分配**：Leader 根据选定的分配策略执行分区的具体分配，并将结果通知所有消费者。
4. **重新平衡**：当消费者加入或离开、主题新增分区时，Kafka 会触发重新平衡，重新分配分区。

## 20. 哪些 MQ 能有序消费

### RocketMQ

RocketMQ 支持两种有序消费模式：

1. **全局顺序消息**: 某个 Topic 下的所有消息都保证严格的 FIFO 顺序。适用于性能要求不高的场景。
2. **分区顺序消息**: 根据 sharding key 将消息分区。同一个分区内消息按 FIFO 顺序发布和消费。适用于性能要求高的场景。

实现原理是生产者有序存储消息到不同队列,消费者从队列中有序拉取消费。

### Kafka

Kafka 支持分区有序,即同一个分区内的消息是有序的。不同分区之间是并行无序的。

### RabbitMQ

RabbitMQ 支持通过设置消费者 prefetch\_count 参数为 1 来实现单个消费者的有序消费。


# 分布式系统

## 1. 什么是注册中心

* 服务提供者（RPC Server）：在启动时，向 Registry 注册自身服务，并向 Registry 定期发送心跳汇报存活状态。
* 服务消费者（RPC Client）：在启动时，向 Registry 订阅服务，把 Registry 返回的服务节点列表缓存在本地内存中，并与 RPC Server 建立连接。
* 服务注册中心（Registry）：用于保存 RPC Server 的注册信息，当 RPC Server 节点发生变更时，Registry 会同步变更，RPC Client 感知后会刷新本地 内存中缓存的服务节点列表。

## 2. CAP 理论

### CAP 理论

CAP 理论指出,对于一个分布式系统来说,当设计读写操作时,只能同时满足以下三点中的两个:

1. **一致性 (Consistency)**：所有节点访问同一份最新的数据副本。
2. **可用性 (Availability)**：非故障的节点在合理的时间内返回合理的响应。
3. **分区容错性 (Partition Tolerance)**：分布式系统出现网络分区（由于某种故障，系统中的某些节点之间失去了通信，导致整个系统被分为多个不连通的部分）的时候,仍然能够对外提供服务。

### CAP 三选二

CAP 理论告诉我们 C、A、P 三者不能同时满足,最多只能满足其中两个。

* 如果允许其中一个副本更新,则会导致数据不一致,即丧失了 C 性质。
* 如果为了保证一致性,将分区某一侧的副本设置为不可用,那么又丧失了 A 性质。
* 除非两个副本可以互相通信,才能既保证 C 又保证 A,这又会导致丧失 P 性质。

一般来说使用网络通信的分布式系统,无法舍弃 P 性质,那么就只能在一致性和可用性上做一个艰难的选择。

## 3. Etcd 与 Raft

[从 Raft 原理到实践](https://mp.weixin.qq.com/s?__biz=Mzg3OTU5NzQ1Mw==\&mid=2247485759\&idx=1\&sn=41957e94a2c69426befafd373fbddcc5\&chksm=cf034bddf874c2cb52a7aafea5cd194e70308c7d4ad74183db8a36d3747122be1c7a31b84ee3\&token=179167416\&lang=zh_CN#rd)

## 4. Consul 的主要特征

* CP 模型，使用 Raft 算法来保证强一致性，不保证可用性；
* 支持服务注册与发现、健康检查、KV Store 功能。
* 支持多数据中心，可以避免单数据中心的单点故障，而其部署则需要考虑网络延迟, 分片等情况等。

## 5. Consul 多数据中心

若两个 DataCenter，他们通过 Internet 互联，同时请注意为了提高通信效率，只有 Server 节点才加入跨数据中心的通信。

在单个数据中心中，Consul 分为 Client 和 Server 两种节点（所有的节点也被称为 Agent），Server 节点保存数据，Client 负责健康检查及转发数据请求到 Server；Server 节点有一个 Leader 和多个 Follower，Leader 节点会将数据同步到 Follower，Server 的数量推荐是 3 个或者 5 个，在 Leader 挂掉的时候会启动选举机制产生一个新的 Leader。

集群内的 Consul 节点通过 gossip 协议（流言协议）维护成员关系，也就是说某个节点了解集群内现在还有哪些节点，这些节点是 Client 还是 Server。

集群内数据的读写请求既可以直接发到 Server，也可以通过 Client 使用 RPC 转发到 Server，请求最终会到达 Leader 节点，在允许数据延时的情况下，读请求也可以在普通的 Server 节点完成。

## 6. Consul 的底层通讯协议 Gossip

gossip 协议也称之为流行病协议，它的信息传播行为类似流行病，或者森林的大火蔓延一样，一个接着一个，最终导致全局都收到某一个信息。

在 gossip 协议的网络中，有很多节点交叉分布，当其中的一个节点收到某条信息的时候，它会随机选择周围的几个节点去通知这个信息，收到信息的节点也会接着重复这个过程，直到网络中所有的节点都收到这条信息，才算信息同步完成。

在某个时刻下，网络节点中的信息可能是不对称的，gossip 协议不是一个强一致性的协议，而是最终一致性的协议，理解了这一层，我们去看 consul 的日志的时候，就能有一些端倪了，因为 consul 服务网络在运行的过程中，如果有新的服务注册进来，那么其他的节点会收到某个服务或者节点加入的信息。

## 7. Gossip 的优缺点

优点：

* 扩展性好，加入网络方便
* 容错性好，某个节点离开网络，不会影响整体的消息传播
* 去中心化，Gossip 协议的网络中，不存在中心节点的概念，每个节点都可以成为消息的第一个传播者，只要网络可达，信息就能散播到全网。
* 一致性收敛：这种一传十、十传百的消息传递机制，能够保证消息快速收敛，并保证最终一致性。

缺点：

* 消息延迟：这个是由它的特性决定的，消息的扩散需要时间，这中间各个节点的消息是不一致的。
* 消息冗余：A 节点告知 B 的信息，B 可能会反过来告知 A，这个时候 A 本身已经包含这个消息，却还要处理 B 的请求，这会造成消息的冗余，提高节点处理信息的压力。

## 8. Base 理论

Base 理论的核心思想是最终一致性。

* 基本可用

不追求 CAP 中的「任何时候，读写都是成功的」，而是系统能够基本运行，一直提供服务。基本可用强调了分布式系统在出现不可预知故障的时候，允许损失部分可用性，相比正常的系统，可能是响应时间延长，或者是服务被降级。

* 软状态

软状态可以对应 ACID 事务中的原子性，在 ACID 的事务中，实现的是强制一致性，要么全做要么不做，所有用户看到的数据一致。软状态则是允许系统中的数据存在中间状态，并认为该状态不影响系统的整体可用性，即允许系统在多个不同节点的数据副本存在数据延时。

* 最终一致性

数据不可能一直是软状态，必须在一个时间期限之后达到各个节点的一致性，在期限过后，应当保证所有副本保持数据一致性，也就是达到数据的最终一致性。 在系统设计中，最终一致性实现的时间取决于网络延时、系统负载、不同的存储选型、不同数据复制方案设计等因素。

## 9. Paxos 算法

### Quorum 选举算法

用一句话解释那就是，在 N 个副本中，一次更新成功的如果有 W 个，那么我在读取数据时是要从大于 N－W 个副本中读取，这样就能至少读到一个更新的数据了。

### Quorum 的应用

Quorum 机制无法保证强一致性，也就是无法实现任何时刻任何用户或节点都可以读到最近一次成功提交的副本数据。 Quorum 机制的使用需要配合一个获取最新成功提交的版本号的 metadata 服务，这样可以确定最新已经成功提交的版本号，然后从已经读到的数据中就可以确认最新写入的数据。

### Paxos 的节点角色

* Proposer 提案者
  * 不同的 Proposer 可以提出不同的甚至矛盾的 value，比如某个 Proposer 提议“将变量 X 设置为 1”，另一个 Proposer 提议“将变量 X 设置为 2”，但对同一轮 Paxos 过程，最多只有一个 value 被批准。
* Acceptor 批准者
  * 在集群中，Acceptor 有 N 个，Acceptor 之间完全对等独立，Proposer 提出的 value 必须获得超过半数（N/2+1）的 Acceptor 批准后才能通过。
* Learner 学习者
  * 这里 Learner 的流程就参考了 Quorum 议会机制，某个 value 需要获得 W=N/2 + 1 的 Acceptor 批准，Learner 需要至少读取 N/2+1 个 Acceptor，最多读取 N 个 Acceptor 的结果后，才能学习到一个通过的 value。
* Client 产生议题者
  * Client 角色，作为产生议题者，实际不参与选举过程，比如发起修改请求的来源等。

### 选举过程

1. 准备阶段

Proposer 生成全局唯一且递增的 ProposalID，向 Paxos 集群的所有机器发送 Prepare 请求，这里不携带 value，只携带 N 即 ProposalID。 Acceptor 收到 Prepare 请求后，判断收到的 ProposalID 是否比之前已响应的所有提案的 N 大，如果是，则：

* 在本地持久化 N，可记为 Max\_N；
* 回复请求，并带上已经 Accept 的提案中 N 最大的 value，如果此时还没有已经 Accept 的提案，则返回 value 为空；
* 做出承诺，不会 Accept 任何小于 Max\_N 的提案。 如果否，则不回复或者回复 Error。

2. 选举阶段

Proposer 提出一个提案（Prepare 请求）并发送给 Acceptor；Acceptor 收到 Prepare 请求后会回复（返回已 Accept 的提案中 N 最大的 value），如果回复数量大于一半且 value 为空，则 Proposer 发送 Accept 请求，并带上自己指定的 value；如果回复数量大于一半且有的回复 value 不为空，则 Proposer 发送 Accept 请求，并带上 ProposalID 最大的 value；如果回复数量小于等于一半，则 Proposer 尝试更新生成更大的 ProposalID 再进行下一轮；Acceptor 收到 Accept 请求后，如果收到的 N 大于等于 Max\_N，则回复提交成功并持久化 N 和 value，否则不回复或者回复提交失败；最后，Proposer 统计所有成功提交的 Accept 回复，如果回复数量大于一半，则表示提交 value 成功，并通知所有 Proposer 和 Learner；否则，尝试更新生成更大的 ProposalID 进入下一轮。

## 10. Paxos 常见的问题

1. 如果半数以内的 Acceptor 失效，如何正常运行？

第一种，如果半数以内的 Acceptor 失效时还没确定最终的 value，此时所有的 Proposer 会重新竞争提案，最终有一个提案会成功提交。

第二种，如果半数以内的 Acceptor 失效时已确定最终的 value，此时所有的 Proposer 提交前必须以最终的 value 提交，也就是 Value 实际已经生效，此值可以被获取，并不再修改。

2. Acceptor 需要接受更大的 N，也就是 ProposalID 有什么意义？

这种机制可以防止其中一个 Proposer 崩溃宕机产生阻塞问题，允许其他 Proposer 用更大 ProposalID 来抢占临时的访问权。

## 11. Zab 与 Paxos 算法的联系与区别

Paxos 的思想在很多分布式组件中都可以看到，Zab 协议可以认为是基于 Paxos 算法实现的（注：此说法存在争议——Zab 论文明确说明 Zab 是独立设计的崩溃恢复原子广播协议，并非 Paxos 的直接实现；但业界普遍认为两者思想同源，Zab 可视为 Paxos 思想在 ZooKeeper 场景下的简化变体），先来看下两者之间的联系：

* 都存在一个 Leader 进程的角色，负责协调多个 Follower 进程的运行
* 都应用 Quorum 机制，Leader 进程都会等待超过半数的 Follower 做出正确的反馈后，才会将一个提案进行提交
* 在 Zab 协议中，Zxid 中通过 epoch 来代表当前 Leader 周期，在 Paxos 算法中，同样存在这样一个标识，叫做 Ballot Number

两者之间的区别是，Paxos 是理论，Zab 是实践，Paxos 是论文性质的，目的是设计一种通用的分布式一致性算法，而 Zab 协议应用在 ZooKeeper 中，是一个特别设计的崩溃可恢复的原子消息广播算法。

Zab 协议增加了崩溃恢复的功能，当 Leader 服务器不可用，或者已经半数以上节点失去联系时，ZooKeeper 会进入恢复模式选举新的 Leader 服务器，使集群达到一个一致的状态。

## 12. 基于消息补偿的最终一致性

### 核心思想

基于消息的最终一致性通常涉及将分布式事务拆分为多个本地事务，并通过消息队列进行协调。消息的发送和处理过程是关键，确保消息能够可靠地传递到所有相关服务。此方案的基本流程如下：

1. **本地事务执行**：事务发起者首先执行本地事务，例如创建订单。
2. **消息发送**：在本地事务成功后，发送一条消息到消息队列，通知其他服务（如库存服务）进行相应的操作。
3. **消息处理**：消息消费者接收到消息后，执行其本地事务，如更新库存。
4. **结果反馈**：消费者处理完毕后，反馈处理结果。如果处理失败，可能需要进行补偿操作。

### 关键问题

在实现基于消息的最终一致性时，需要解决几个关键问题：

* **原子性**：确保本地事务和消息发送操作要么同时成功，要么同时失败。这通常通过使用本地消息表或事务消息来实现。
* **消息可靠性**：确保消息能够被消费者成功接收和处理。若消息未被处理，需设计重试机制。
* **幂等性**：确保消息的重复消费不会导致数据的不一致。例如，如果同一条消息被处理多次，系统的状态应保持一致。

### 实现方案

#### 本地消息表

核心思想是将消息的发送与业务操作放在同一个数据库事务中。具体步骤如下：

* 在事务发起方，新建一个存储事务消息表即是本地消息表。
* 事务发起方在一个事务中处理业务和把消息状态写入消息表，并发送消息到消息中间件。发送消息到消息中间件失败会重试发送。
* 事务参与方，从中间件中读取消息，然后在一个本地事务中完成自己的业务逻辑。如果本地事务处理成功，就返回一个处理结果消息：成功。如果本地事务处理失败，给事务发起方发送一个业务补偿的消息，通知事务发起方进行回滚操作。
* 事务发起方读取中间件的处理结果，如果是成功的消息，那么更新消息表状态。如果是失败的消息，那么进行事务回滚操作。
* 定时扫描还没处理的消息或失败消息，在发送一遍，进行事务处理。

#### 事务消息

事务消息通过消息队列的事务机制来确保消息的可靠性。生产者在发送消息前，先执行本地事务，只有在事务成功后，才会提交消息。

1. 生产者生产消息给 MQ，此时未提交，称该消息为 HalfMsg，即半消息
2. MQ 收到该消息，会给生产者一个 OK 确认
3. 生产者执行本地事务
4. 根据本地事务执行的结果向 MQ 提交 commit 或者 rollback，MQ 根据 commit 或者 rollback 对消息进行相应操作，即消费或者丢弃
5. 如果生产者提交 commit 或者 rollback 提交超时，即第四步没有收到来自生产者的请求，MQ 回调检查该消息
6. MQ 回调检查本地事务的状态

## 13. 对比两阶段提交，三阶段协议有哪些改进？

* 引入超时机制 在 2PC 中，只有协调者拥有超时机制，如果在一定时间内没有收到参与者的消息则默认失败，3PC 同时在协调者和参与者中都引入超时机制。
* 添加预提交阶段 在 2PC 的准备阶段之前增加一个询问阶段（CanCommit），并把原来的准备阶段细化为 PreCommit，使 3PC 拥有 CanCommit、PreCommit、DoCommit 三个阶段，PreCommit 是一个缓冲，保证了在最后提交阶段之前各参与节点的状态是一致的。

优点：

* 1.协调者和参与者都引入了超时机制，优化了在 2PC 中长时间收不到消息导致长时间阻塞的情况，一定时间内收不到消息就超时处理。
* 2.优化了阻塞范围，3PC 把 2PC 第一阶段细分成 2 个阶段，这样 3PC 第一阶段就是很简单的操作，这个阶段不会阻塞。
* 3.3PC 的 PreCommit 中中断事务回滚的操作是一个轻量级操作，这个也是因为细分为 2 个阶段缘故。
* 4.由于增加了 CanCommit 询问阶段，事务成功提交的把握增大。即使不成功，这个阶段回滚风险也变小。同上 3。
* 5.协调者单点风险减小，在 3PC 中，如果 PreCommit 阶段之后发生了协调者宕机，下一阶段 DoCommit 无法执行了，但是 3PC 这时默认操作是提交事务而不是回滚事务或持续等待，就相当于避免了协调者单点问题。为什么能这样？因为 PreCommit 阶段的存在。

3PC 对单点问题、阻塞问题和回滚时性能都有所改善。

缺点：

* 数据不一致没有改善，比如进入 PreCommit 阶段后出现网络分区，已收到 PreCommit 消息的参与者在 DoCommit 阶段超时后会默认提交事务，而未收到 PreCommit 消息的参与者超时后默认回滚，导致参与者之间数据不一致的问题。

### 三阶段提交协议存在的问题

三阶段提交协议同样存在问题，具体表现为，在阶段三中，如果参与者接收到了 PreCommit 消息后，出现了不能与协调者正常通信的问题，在这种情况下，参与者依然会进行事务的提交，这就出现了数据的不一致性。

## 14. TCC 事务模型

* Try 阶段：调用 Try 接口，尝试执行业务，完成所有业务检查，预留业务资源。
* Confirm 或 Cancel 阶段：两者是互斥的，只能进入其中一个，并且都满足幂等性，允许失败重试。
  * Confirm 操作：对业务系统做确认提交，确认执行业务操作，不做其他业务检查，只使用 Try 阶段预留的业务资源。
  * Cancel 操作：在业务执行错误，需要回滚的状态下执行业务取消，释放预留资源。

把上面步骤在分阶段，可以把 TCC 看作是应用层实现的 2PC 两阶段提交：

* 第一阶段：Try 操作，确认资源是否可执行，同时对要用到的资源进行锁定，
* 第二阶段：Confirm 操作或 Cancel 操作。如果第一阶段 Try 执行成功，那么就开始真正执行业务，并释放资源；如果第一阶段 Try 执行失败，那么执行回滚操作，预留的资源取消，使资源回到初始状态。

> Try 阶段失败可以 Cancel，如果 Confirm 和 Cancel 阶段失败了怎么办？

TCC 中会添加事务日志，如果 Confirm 或者 Cancel 阶段出错，则会进行重试，所以这两个阶段需要支持幂等；如果重试失败，则需要人工介入进行恢复和处理等。

## 15. 分布式锁的常用实现

### 基于关系型数据库

以唯一索引为例，创建一张锁表，定义方法或者资源名、失效时间等字段，同时针对加锁的信息添加唯一索引，比如方法名，当要锁住某个方法或资源时，就在该表中插入对应方法的一条记录，插入成功表示获取了锁，想要释放锁的时候就删除这条记录。

* 存在单点故障风险 数据库实现方式强依赖数据库的可用性，一旦数据库挂掉，则会导致业务系统不可用，为了解决这个问题，需要配置数据库主从机器，防止单点故障。
* 超时无法失效 如果一旦解锁操作失败，则会导致锁记录一直在数据库中，其他线程无法再获得锁，解决这个问题，可以添加独立的定时任务，通过时间戳对比等方式，删除超时数据。

### 基于 Redis 缓存

setnx 是「set if not exists」如果不存在，则 SET 的意思，当一个线程执行 setnx 返回 1，说明 key 不存在，该线程获得锁；当一个线程执行 setnx 返回 0，说明 key 已经存在，那么获取锁失败，expire 就是给锁加一个过期时间。

使用 setnx 和 expire 有一个问题，这两条命令不是原子操作，可能一个成功一个失败，如果一个线程在执行完 setnx 之后突然崩溃，导致锁没有设置过期时间，那么这个锁就会一直存在，无法被其他线程获取。

为了解决这个问题，从 Redis 2.6.12 版本起，SET 命令支持 NX、EX 等选项，`SET key value NX EX seconds` 可以原子地完成 setnx + expire 的组合，解决了加锁过程中失败的问题（注意：SETEX 命令虽然也能设置过期时间，但它是 Redis 2.0.0 引入的，只有 EX 语义、没有 NX 语义，不能替代 setnx）。

### 基于 Zookeeper 实现

当客户端对某个方法加锁时，在 ZooKeeper 中该方法对应的指定节点目录下，生成一个唯一的临时有序节点。

判断是否获取锁，只需要判断持有的节点是否是有序节点中序号最小的一个，当释放锁的时候，将这个临时节点删除即可，这种方式可以避免服务宕机导致的锁无法释放而产生的死锁问题。

下面描述使用 ZooKeeper 实现分布式锁的算法流程，根节点为 /lock：

* 客户端连接 ZooKeeper，并在 /lock 下创建临时有序子节点，第一个客户端对应的子节点为 /lock/lock01/00000001，第二个为 /lock/lock01/00000002；
* 其他客户端获取 /lock/lock01 下的子节点列表，判断自己创建的子节点是否为当前列表中序号最小的子节点；
* 如果是则认为获得锁，执行业务代码，否则通过 watch 事件监听 /lock/lock01 的子节点变更消息，获得变更通知后重复此步骤直至获得锁；
* 完成业务流程后，删除对应的子节点，释放分布式锁。

## 16. 微服务中使用应用网关的优劣

通过在微服务架构中引入 API 网关，可以带来以下的收益：

* API 服务网关对外提供统一的入口供客户端访问，隐藏系统架构实现的细节，让微服务使用更为友好；
* 借助 API 服务网关可统一做切面任务，避免每个微服务自己开发，提升效率，使系统更加标准化；
* 通过 API 服务网关，可以将异构系统进行统一整合，比如外部 API 使用 HTTP 接口，内部微服务可以使用一些性能更高的通信协议，然后在网关中进行转换，提供统一的外部 REST 接口；
* 通过微服务的统一访问控制，可以更好地实现鉴权，提高系统的安全性。

API 网关并不是一个必需的角色，在系统设计中引入网关，也会导致系统复杂性增加，带来下面的问题：

* 在发布和部署阶段需要管理网关的配置，保证外部 API 访问的是正常的服务实例；
* API 服务网关需要实现一个高可用伸缩性强的服务，避免单点失效，否则会成为系统的瓶颈；
* 引入 API 服务网关额外添加了一个需要维护的系统，增加了开发和运维的工作量，提高了系统复杂程度。

## 17. 分布式调用跟踪的业务场景

* 故障快速定位：通过调用链跟踪，一次请求的逻辑轨迹可以完整清晰地展示出来。在开发的过程中，可以在业务日志中添加调用链 ID，还可以通过调用链结合业务日志快速定位错误信息。
* 各个调用环节的性能分析：在调用链的各个环节分别添加调用时延，并分析系统的性能瓶颈，进行针对性的优化。
* 各个调用环节的可用性，持久层依赖等：通过分析各个环节的平均时延、QPS 等信息，可以找到系统的薄弱环节，对一些模块做调整，比如数据冗余等。
* 数据分析等：调用链是一条完整的业务日志，可以得到用户的行为路径，并汇总分析。

## 18. 分布式链路追踪实现原理

Span 来表示一个服务调用开始和结束的时间，也就是时间区间，并记录了 Span 的名称以及每个 Span 的 ID 和父 ID，如果一个 Span 没有父 ID 则被称之为 Root Span。

一个请求到达应用后所调用的所有服务，以及所有服务组成的调用链就像是一个树结构，追踪这个调用链路得到的树结构称之为 Trace，所有的 Span 都挂在一个特定的 Trace 上，共用一个 TraceId。

在一次 Trace 中，每个服务的每一次调用，就是一个 Span，每一个 Span 都有一个 ID 作为唯一标识。同样，每一次 Trace 都会生成一个 TraceId 在 Span 中作为追踪标识，另外再通过一个 parentSpanId，标明本次调用的发起者。

## 19. 容器化升级对服务有哪些影响

### Namespace

> Namespace 的目的是通过抽象方法使得 Namespace 中的进程看起来拥有它们自己的隔离的全局系统资源实例。 Linux 内核目前实现了八种 Namespace：Mount namespaces、UTS namespaces、IPC namespaces、PID namespaces、Network namespaces、User namespaces、Cgroup namespaces、Time namespaces（前六种为经典分类，Cgroup 命名空间于 Linux 4.6 引入，Time 命名空间于 Linux 5.6 引入），功能分别为：隔离文件系统、定义 hostname 和 domainname、特定的进程间通信资源、独立进程 ID 结构、独立网络设备、用户和组 ID 空间、隔离 cgroup 根目录视图、隔离系统时间（monotonic/boot time）。

Docker 在创建一个容器的时候，默认创建 mnt、uts、ipc、pid、net 五种 Namespace（Docker 20.10+ 在内核支持时还会默认启用 cgroup namespace，即六种）；user namespace 默认不启用（需通过 userns-remap 显式开启），time namespace 不创建。然后将隔离的系统资源放入到相应的 Namespace 中，使得每个容器只能看到自己独立的系统资源。

### Cgroups

Docker 利用 CGroups 进行资源隔离。CGroups（Control Groups）也是 Linux 内核中提供的一种机制，它的功能主要是限制、记录、隔离进程所使用的物理资源，比如 CPU、Memory、IO、Network 等。

简单来说，CGroups 在接收到调用时，会给指定的进程挂上钩子，这个钩子会在资源被使用的时候触发，触发时会根据资源的类别，比如 CPU、Memory、IO 等，然后使用对应的方法进行限制。

## 20. 服务网格有哪些应用

### Sidecar 设计模式

在系统设计时，边车模式通过给应用程序添加边车的方式来拓展应用程序现有的功能，分离通用的业务逻辑，比如日志记录、流量控制、服务注册和发现、限流熔断等功能。通过添加边车实现，微服务只需要专注实现业务逻辑即可，实现了控制和逻辑的分离与解耦。

边车模式中的边车，实际上就是一个 Agent，微服务的通信可以通过 Agent 代理完成。在部署时，需要同时启动 Agent，Agent 会处理服务注册、服务发现、日志和服务监控等逻辑。这样在开发时，就可以忽略这些和对外业务逻辑本身没有关联的功能，实现更好的内聚和解耦。

### 服务网格

Service Mesh 基于边车模式演进，通过在系统中添加边车代理，也就是 Sidecar Proxy 实现。

Service Mesh 可以认为是边车模式的进一步扩展，提供了以下功能：

* 管理服务注册和发现
* 提供限流和降级功能
* 前置的负载均衡
* 服务熔断功能
* 日志和服务运行状态监控
* 管理微服务和上层容器的通信

## 21. 分表分库后引入的问题

* 分布式事务问题 对业务进行分库之后，同一个操作会分散到多个数据库中，涉及跨库执行 SQL 语句，也就出现了分布式事务问题。

  比如数据库拆分后，订单和库存在两个库中，一个下单减库存的操作，就涉及跨库事务。关于分布式事务的处理，可以使用分布式事务中间件，实现 TCC 等事务模型；也可以使用基于本地消息表的分布式事务实现。
* 跨库关联查询问题 分库分表后，跨库和跨表的查询操作实现起来会比较复杂，性能也无法保证。在实际开发中，针对这种需要跨库访问的业务场景，一般会使用额外的存储，比如引入搜索引擎（Elasticsearch）建立冗余索引，或维护一张宽表/汇总表。另一个方案是通过合理的数据库字段冗余，避免出现跨库查询。
* 跨库跨表的合并和排序问题 分库分表以后，数据分散存储到不同的数据库和表中，如果查询指定数据列表，或者需要对数据列表进行排序时，就变得异常复杂，则需要在内存中进行处理，整体性能会比较差，一般来说，会限制这类型的操作。

## 22. 高并发系统的通用设计方法

高并发系统的演进应该是循序渐进，以解决系统中存在的问题为目的和驱动力的。

Scale-out（横向拓展）、缓存和异步这三种方法可以在做方案设计时灵活地运用，但它不是具体实施的方案，而是三种思想，在实际运用中会千变万化。

## 23. 高并发下的架构分层

### 分层有什么好处

* 分层的设计可以简化系统设计，让不同的人专注做某一层次的事情
* 分层之后可以做到很高的复用
* 分层架构可以让我们更容易做横向扩展

### 如何来做系统分层

* 需要理清楚每个层次的边界是什么
* 层次之间一定是相邻层互相依赖，数据的流转也只能在相邻的两层之间流转

### 分层架构的不足

* 增加了代码的复杂度
* 把每个层次独立部署，层次间通过网络来交互，那么多层的架构在性能上会有损耗

## 24. 如何提升系统性能

高并发系统设计的三大目标：高性能、高可用、可扩展

### 性能优化原则

* 性能优化一定不能盲目，一定是问题导向的
* 性能优化也遵循“八二原则”，用 20% 的精力解决 80% 的性能问题。所以我们在优化过程中一定要抓住主要矛盾，优先优化主要的性能瓶颈点
* 性能优化也要有数据支撑。在优化过程中，你要时刻了解你的优化让响应时间减少了多少，提升了多少的吞吐量
* 性能优化的时候要明确目标

### 性能的度量指标

* 平均值 平均值对于度量性能来说只能作为一个参考
* 最大值 过于敏感
* 分位值 分位值排除了偶发极慢请求对于数据的影响，能够很好地反应这段时间的性能情况，分位值越大，对于慢请求的影响就越敏感

脱离了并发来谈性能是没有意义的，我们通常使用吞吐量或者同时在线用户数来度量并发和流量，使用吞吐量的情况会更多一些。根据 Little's Law（并发数 = 吞吐量 × 平均响应时间），在并发数不变时，吞吐量与平均响应时间呈倒数关系。

### 高并发下的性能优化

* 提高系统的处理核心数 提高系统的处理核心数就是增加系统的并行处理能力
* 减少单次任务响应时间 CPU 密集型系统中需要处理大量的 CPU 运算，那么选用更高效的算法或者减少运算次数就是这类系统重要的优化手段 IO 密集型系统指的是系统的大部分操作是在等待 IO 完成，这类系统的性能瓶颈可能出在系统内部，也可能是依赖的其他系统。可以采用工具和监控的方法进行优化

## 25. 系统怎样做到高可用

### 可用性的度量

\*\*MTBF（Mean Time Between Failure）\*\*是平均故障间隔的意思，代表两次故障的间隔时间，也就是系统正常运转的平均时间。这个时间越长，系统稳定性越高。

\*\*MTTR（Mean Time To Repair）\*\*表示故障的平均恢复时间，这个值越小，故障对于用户的影响越小。

### 灰度发布

灰度发布指的是系统的变更不是一次性地推到线上的，而是按照一定比例逐步推进的。一般情况下，灰度发布是以机器维度进行的。比方说，我们先在 10% 的机器上进行变更，同时观察 Dashboard 上的系统性能指标以及错误日志。如果运行了一段时间之后系统指标比较平稳并且没有出现大量的错误日志，那么再推动全量变更。

### 系统设计思路

* 故障转移
* 超时控制
* 降级
* 限流

## 26. 如何让系统易于拓展

集群系统中，不同的系统分层上可能存在一些“瓶颈点”，这些瓶颈点制约着系统的横向扩展能力。

### 高可扩展性的设计思路

拆分是提升系统扩展性最重要的一个思路，它会把庞杂的系统拆分成独立的，有单一职责的模块。相对于大系统来说，考虑一个一个小模块的扩展性当然会简单一些。将复杂的问题简单化，这就是我们的思路。

## 27. 如何减少频繁创建数据库连接的性能损耗

使用池化技术

以数据库连接池为例

> 如果当前连接数小于最小连接数，则创建新的连接处理数据库请求；如果连接池中有空闲连接则复用空闲连接；如果空闲池中没有连接并且当前连接数小于最大连接数，则创建新的连接处理请求；如果当前连接数已经大于等于最大连接数，则按照配置中设定的时间（C3P0 的连接池配置是 checkoutTimeout）等待旧的连接可用；如果等待超过了这个设定时间则向用户抛出错误。

* 池子的最大值和最小值的设置很重要，初期可以依据经验来设置，后面还是需要根据实际运行情况做调整。
* 池子中的对象需要在使用之前预先初始化完成，这叫做池子的预热，比方说使用线程池时就需要预先初始化所有的核心线程。如果池子未经过预热可能会导致系统重启后产生比较多的慢请求。
* 池化技术核心是一种空间换时间优化方法的实践，所以要关注空间占用情况，避免出现空间过度使用出现内存泄露或者频繁垃圾回收等问题。

## 28. 如何实现分库分表

### 垂直拆分

在微博系统中有和用户相关的表，有和内容相关的表，有和关系相关的表，这些表都存储在主库中。在拆分后，我们期望用户相关的表分拆到用户库中，内容相关的表分拆到内容库中，关系相关的表分拆到关系库中。

### 水平拆分

* 按照某一个字段的哈希值做拆分，这种拆分规则比较适用于实体表，比如说用户表，内容表，我们一般按照这些实体表的 ID 字段来拆分。比如说我们想把用户表拆分成 16 个库，64 张表，那么可以先对用户 ID 做哈希，哈希的目的是将 ID 尽量打散，然后再对 16 取余，这样就得到了分库后的索引值
* 按照某一个字段的区间来拆分，比较常用的是时间字段。你知道在内容表里面有“创建时间”的字段，而我们也是按照时间来查看一个人发布的内容

## 29. 如何保证分库分表后 ID 的全局唯一性

### 基于 Snowflake 算法搭建发号器

Snowflake 算法设计的非常简单且巧妙，性能上也足够高效，同时也能够生成具有全局唯一性、单调递增性和有业务含义的 ID，但是它也有一些缺点，其中最大的缺点就是它依赖于系统的时间戳，一旦系统时间不准，就有可能生成重复的 ID。所以如果我们发现系统时钟不准，就可以让发号器暂时拒绝发号，直到时钟准确为止。

## 30. 如何选择缓存的读写策略

1. Cache Aside 是我们在使用分布式缓存时最常用的策略，你可以在实际工作中直接拿来使用。
2. Read/Write Through 和 Write Back 策略需要缓存组件的支持，所以比较适合你在实现本地缓存组件的时候使用；
3. Write Back 策略是计算机体系结构中的策略，不过写入策略中的只写缓存，异步写入后端存储的策略倒是有很多的应用场景。

## 31. 使用 CDN 进行静态资源加速

### 如何让用户的请求到达 CDN 节点

CNAME 记录在 DNS 解析过程中可以充当一个中间代理层的角色，可以将用户最初使用的域名代理到正确的 IP 地址上。

DNS 解析结果需要做本地缓存，降低 DNS 解析过程的响应时间。

### 如何找到离用户最近的 CDN 节点

GSLB（Global Server Load Balance，全局负载均衡）, 它的含义是对于部署在不同地域的服务器之间做负载均衡，下面可能管理了很多的本地负载均衡组件。它有两方面的作用：

* 它是一种负载均衡服务器，负载均衡，顾名思义嘛，指的是让流量平均分配使得下面管理的服务器的负载更平均；
* 它还需要保证流量流经的服务器与流量源头在地缘上是比较接近的。

GSLB 可以通过多种策略，来保证返回的 CDN 节点和用户尽量保证在同一地缘区域，比如说可以将用户的 IP 地址按照地理位置划分为若干的区域，然后将 CDN 节点对应到一个区域上，然后根据用户所在区域来返回合适的节点；也可以通过发送数据包测量 RTT 的方式来决定返回哪一个节点。

## 32. 每秒 1 万次请求的系统要做服务化拆分吗？

其实，系统的 QPS 并不是决定性的因素。影响的因素可以归纳为以下几点：

* 系统中，使用的资源出现扩展性问题，尤其是数据库的连接数出现瓶颈；
* 大团队共同维护一套代码，带来研发效率的降低，和研发成本的提升；
* 系统部署成本越来越高。

## 33. 微服务化后，系统架构要如何改造？

### 微服务拆分的原则

* 做到单一服务内部功能的高内聚，和低耦合。
* 你需要关注服务拆分的粒度，先粗略拆分，再逐渐细化。
* 拆分的过程，要尽量避免影响产品的日常功能迭代。

## 34. 通过 RPC 框架实现 10 万 QPS 下毫秒级的服务调用

1. 选择高性能的 I/O 模型，推荐使用同步多路 I/O 复用模型；
2. 调试网络参数，这里面有一些经验值的推荐。比如将 tcp\_nodelay 设置为 true，也有一些参数需要在运行中来调试，比如接受缓冲区和发送缓冲区的大小，客户端连接请求缓冲队列的大小（back log）等等；
3. 序列化协议依据具体业务来选择。如果对性能要求不高，可以选择 JSON，否则可以从 Thrift 和 Protobuf 中选择其一。

## 35. 分布式系统如何寻址？

* 注册中心可以让我们动态地，变更 RPC 服务的节点信息，对于动态扩缩容，故障快速恢复，以及服务的优雅关闭都有重要的意义；
* 心跳机制是一种常见的探测服务状态的方式，在实际的项目中也可以使用；
* 我们需要对注册中心中管理的节点提供一些保护策略，避免节点被过度摘除导致的服务不可用。

## 36. 横跨几十个分布式组件的慢请求要如何排查？

无论是服务追踪还是业务问题排查，你都需要在日志中增加 requestId，这样可以将你的日志串起来，给你呈现一个完整的问题场景。如果 requestId 可以在客户端上生成，在请求业务接口的时候传递给服务端，那么就可以把客户端的日志体系也整合进来，对于问题的排查帮助更大。

采用 traceId + spanId 这两个数据维度来记录服务之间的调用关系（这里 traceId 就是 requestId），也就是使用 traceId 串起单次请求，用 spanId 记录每一次 RPC 调用。

## 37. 怎样提升系统的横向扩展能力？

在微服务架构中，我们也会启动多个服务节点，来承接从用户端到应用服务器的请求，自然会需要一个负载均衡服务器

负载均衡分为两种

* 代理类 —— Nginx
* 客户端类 —— 嵌入 RPC 框架配合服务发现等

## 38. API 网关如何做?

### 入口网关

* 它提供客户端一个统一的接入地址，API 网关可以将用户的请求动态路由到不同的业务服务上，并且做一些必要的协议转换工作。
* 在 API 网关中，我们可以植入一些服务治理的策略，比如服务的熔断、降级，流量控制和分流。
* 客户端的认证和授权的实现，也可以放在 API 网关中。
* API 网关还可以做一些与黑白名单相关的事情，比如针对设备 ID、用户 IP、用户 ID 等维度的黑白名单。

### 出口网关

在应用服务器和第三方系统之间，部署出口网关，在出口网关中，对调用外部的 API 做统一的认证、授权，审计以及访问控制。

## 39. 多机房部署实现跨地域的分布式系统

不同机房的数据传输延迟，是造成多机房部署困难的主要原因，你需要知道，同城多机房的延迟一般在 1ms\~3ms，异地机房的延迟在 50ms 以下，而跨国机房的延迟在 200ms 以下。

同城多机房方案可以允许有跨机房数据写入的发生，但是数据的读取，和服务的调用应该尽量保证在同一个机房中。

异地多活方案则应该避免跨机房同步的数据写入和读取，而是采取异步的方式，将数据从一个机房同步到另一个机房。

多机房部署是一个业务发展到一定规模，对于机房容灾有需求时，才会考虑的方案，能不做则尽量不要做。一旦你的团队决定做多机房部署，那么同城双活已经能够满足你的需求了，这个方案相比异地多活要简单很多。而在业界，很少有公司，能够搭建一套真正的异步多活架构，这是因为这套架构在实现时过于复杂，所以，轻易不要尝试。

## 40. 通过服务网格屏蔽服务化系统的服务治理细节

1. Service Mesh 分为数据平面和控制平面。数据平面主要负责数据的传输；控制平面用来控制服务治理策略的植入。出于性能的考虑，一般会把服务治理策略植入到数据平面中，控制平面负责服务治理策略数据的下发。
2. Sidecar 的植入方式目前主要有两种实现方式，一种是使用 iptables 实现流量的劫持；另一种是通过轻量级客户端来实现流量转发。

## 41. 服务端指标监控怎么做

### 监控指标如何选择

谷歌针对分布式系统监控的经验总结，四个黄金信号（Four Golden Signals）。它指的是，在服务层面一般需要监控四个指标，分别是延迟，通信量、错误和饱和度。

* 延迟指的是请求的响应时间。比如，接口的响应时间、访问数据库和缓存的响应时间。
* 通信量可以理解为吞吐量，也就是单位时间内，请求量的大小。比如，访问第三方服务的请求量，访问消息队列的请求量。
* 错误表示当前系统发生的错误数量。这里需要注意的是， 我们需要监控的错误既有显示的，比如在监控 Web 服务时，出现 4XX 和 5XX 的响应码；也有隐示的，比如，Web 服务虽然返回的响应码是 200，但是却发生了一些和业务相关的错误（出现了数组越界的异常或者空指针异常等），这些都是错误的范畴。
* 饱和度指的是服务或者资源到达上限的程度（也可以说是服务或者资源的利用率），比如说 CPU 的使用率，内存使用率，磁盘使用率，缓存数据库的连接数等等。

### 如何采集数据指标

* Agent 是一种比较常见的，采集数据指标的方式。我们通过在数据源的服务器上，部署自研或者开源的 Agent，来收集收据，发送给监控系统，实现数据的采集。在采集数据源上的信息时，Agent 会依据数据源上，提供的一些接口获取数据。
* 另一种很重要的数据获取方式，是在代码中埋点。
* 通过日志进行收集。

### 监控数据的处理和存储

可以通过 Grafana 来连接时序数据库，将监控数据绘制成报表。

## 42. 用户的使用体验应该如何监控

可以搭建一个端到端的 APM 监控系统

1. 从客户端采集到的数据可以用通用的消息格式，上传到 APM 服务端，服务端将数据存入到 Elasticsearch 中，以提供原始日志的查询，也可以依据这些数据形成客户端的监控报表；
2. 用户网络数据是我们排查客户端，和服务端交互过程的重要数据，你可以通过代码的植入，来获取到这些数据；
3. 无论是网络数据，还是异常数据，亦或是卡顿、崩溃、流量、耗电量等数据，你都可以通过把它们封装成 APM 消息格式，上传到 APM 服务端，这些用户在客户端上留下的踪迹可以帮助你更好地优化用户的使用体验。

服务端的开发人员往往会陷入一个误区，认为我们将服务端的监控做好，保证接口性能和可用性足够好就好了。事实上，接口的响应时间只是我们监控系统中很小的一部分，搭建一套端到端的全链路的监控体系，才是你的监控系统的最终形态。

## 43. 怎样设计全链路压力测试平台

压力测试是一种发现系统性能隐患的重要手段，所以应该尽量使用正式的环境和数据；

* 对压测的流量需要增加标记，这样就可以通过 Mock 第三方依赖服务和影子库的方式来实现压测数据和正式数据的隔离；
* 压测时，应该实时地对系统性能指标做监控和告警，及时地对出现瓶颈的资源或者服务扩容，避免对正式环境产生影响。

**这套全链路的压力测试系统对于我们来说有三方面的价值：**

* 其一，它可以帮助我们发现系统中可能出现的性能瓶颈，方便我们提前准备预案来应对；
* 其次，它也可以为我们做容量评估，提供数据上的支撑；
* 最后，我们也可以在压测的时候做预案演练，因为压测一般会安排在流量的低峰期进行，这样我们可以降级一些服务来验证预案效果，并且可以尽量减少对线上用户的影响。所以，随着你的系统流量的快速增长，你也需要及时考虑搭建这么一套全链路压测平台，来保证你的系统的稳定性。

## 44. 成千上万的配置项要如何管理

* 配置存储是分级的，有公共配置，有个性的配置，一般个性配置会覆盖公共配置，这样可以减少存储配置项的数量；
* 配置中心可以提供配置变更通知的功能，可以实现配置的热更新；
* 配置中心关注的性能指标中，可用性的优先级是高于性能的，一般我们会要求配置中心的可用性达到 99.999%，甚至会是 99.9999%。

并不是所有的配置项都需要使用配置中心来存储，如果你的项目还是使用文件方式来管理配置，那么你只需要，将类似超时时间等，需要动态调整的配置，迁移到配置中心就可以了。对于像是数据库地址，依赖第三方请求的地址，这些基本不会发生变化的配置项，可以依然使用文件的方式来管理，这样可以大大地减少配置迁移的成本。

## 45. 如何屏蔽非核心系统故障的影响？

在分布式环境下最怕的是服务或者组件慢，因为这样会导致调用者持有的资源无法释放，最终拖垮整体服务。

* 服务熔断的实现是一个有限状态机，关键是三种状态之间的转换过程。
  * 当调用失败的次数累积到一定的阈值时，熔断状态从关闭态切换到打开态。一般在实现时，如果调用成功一次，就会重置调用失败次数。
  * 当熔断处于打开状态时，我们会启动一个超时计时器，当计时器超时后，状态切换到半打开态。你也可以通过设置一个定时器，定期地探测服务是否恢复。
  * 在熔断处于半打开状态时，请求可以达到后端服务，如果累计一定的成功次数后，状态切换到关闭态；如果出现调用失败的情况，则切换到打开态。
* 开关降级的实现策略主要有返回降级数据和异步两种方案。
  * 数据库的压力比较大，我们在降级的时候，可以考虑只读取缓存的数据，而不再读取数据库中的数据。
  * 对于写数据的场景，一般会考虑把同步写转换成异步写，这样可以牺牲一些数据一致性和实效性来保证系统的可用性。

熔断和降级是保证系统稳定性和可用性的重要手段，在你访问第三方服务或者资源的时候都需要考虑增加降级开关或者熔断机制，保证资源或者服务出现问题时，不会对整体系统产生灾难性的影响。

## 46. 高并发系统中我们如何操纵流量？

* 限流是一种常见的服务保护策略，你可以在整体服务、单个服务、单个接口、单个 IP 或者单个用户等多个维度进行流量的控制；
* 基于时间窗口维度的算法有固定窗口算法和滑动窗口算法，两者虽然能一定程度上实现限流的目的，但是都无法让流量变得更平滑；
* 令牌桶算法和漏桶算法则能够塑形流量，让流量更加平滑，但是令牌桶算法能够应对一定的突发流量，所以在实际项目中应用更多。

限流策略是微服务治理中的标配策略，只是你很难在实际中确认限流的阈值是多少，设置的小了容易误伤正常的请求，设置的大了则达不到限流的目的。所以，一般在实际项目中，我们会把阈值放置在配置中心中方便动态调整；同时，我们可以通过定期地压力测试得到整体系统以及每个微服务的实际承载能力，然后再依据这个压测出来的值设置合适的阈值。

## 47. 分布式限流器如何解决超高 QPS 问题

本地限流（Sentinel 等）在很多场景由于精确度低，不能很好的满足业务需求，需要引入分布式集中限流能力。但分布式精准限流对 QPS 不是太大时效果很好，但对于几万甚至几十万 QPS 的服务，依赖中心化 Redis 的精准限流器无法达到要求，就需要采用优化的手段来解决瓶颈问题。

### 超高 QPS 优化

本质上是 Redis 的热 Key 问题，业界通常有两种常见解决方案：

1. 拆 Key，将热 Key 数据分散存储在不同的 Redis 节点上，可以使用一致性哈希等分片技术。
2. 增加本地缓存池，加入 step（步长）的概念，每次访问在 Redis 取令牌是用步长为单位取到本地缓存，新的请求现在本地令牌桶获取令牌，消耗完之后再去到 Redis 直到 Redis 桶中的令牌被消耗完毕。相当于依靠远端 Redis + 本地结合的方式可以支持高并发。

在本方案中针对平稳的高 QPS 水位，采用固定 step + 本地缓存结合的概念来优化高 QPS 问题。

### 突发的高 QPS 流量

在极端场景下上面的解决方案也会遇到问题，突发不可预期的高流量，固定 step 不能解决流量突刺，导致 Redis 被打崩。

我们可以引入动态 step 的概念来解决突发流量的问题，根据当时的流量并发度动态调整。

### 故障的保底——自动熔断

分布式限流中 Redis 错误率上升，需要支持自动 & 手动降级到本地限流，而熔断器需要有三种状态

1. Closed 状态：对资源的访问直接通过熔断器的检查。
2. Open 状态：开启熔断器，对资源的访问会被切断。
3. Half-Open 状态：该状态下除了探测流量，其余对资源的访问也会被切断。探测流量指熔断器处于半开状态时，会周期性的允许一定数目的探测请求通过，如果能够正常返回说明探测成功，重置到 Closed 状态，失败则回滚到 Open 状态。

## 48. 高并发下如何保证接口的幂等性

1. 分布式锁 ：用户通过浏览器发起请求，生成订单号作为唯一业务字段，使用 redis 的 SetNX 命令，将该订单 code 设置到 redis 中，同时设置超时时间，判断是否设置成功，如果设置成功，说明是第一次请求，则进行数据操作。 如果设置失败，说明是重复请求，则直接返回成功。
2. 乐观锁 (Optimistic Locking) ：使用乐观锁机制可以在并发环境下保证接口的幂等性。在处理请求之前，先获取资源的版本号或时间戳，并将其作为请求的一部分发送给服务器。服务器在处理请求时，先检查资源的版本号或时间戳是否与请求中的一致。
3. 悲观锁 (Pessimistic Locking) ：使用悲观锁可以在并发环境下保证接口的幂等性。在处理请求时，先对资源进行加锁，确保同一时间只有一个请求可以访问该资源。

## 49. 布隆过滤器底层实现

布隆过滤器由一个位数组和多个哈希函数组成。当要添加一个元素时，将该元素经过多个哈希函数得到多个哈希值，并将对应的位数组位置置为 1。当要查询一个元素是否存在时，同样将该元素经过多个哈希函数得到多个哈希值，并检查对应的位数组位置是否都为 1。如果有任意一个位置为 0，则可以确定该元素一定不存在于集合中；如果所有位置都为 1，则该元素可能存在于集合中，但有一定的误判率。


# 云原生与 DevOps

## 1. Docker 是什么？

* 是实现容器技术的一种工具
* 是一个开源的应用容器引擎
* 使用 C/S 架构模式，通过远程 API 来管理
* 可以打包一个应用及依赖包到一个轻量级、可移植的容器中

## 2. 虚拟化是什么？

* 可以理解成虚拟机技术
* 一个主机可以部署多个虚拟机，每个虚拟机又可以部署多个应用
* 对于主机来说，虚拟机就是一个普通文件

## 3. 虚拟化的缺点是什么？

* 资源占用多：每个虚拟机都是完整的操作系统，需要给它分配大量系统资源
* 冗余步骤多：一个完整的操作系统，一些系统级别的步骤无法避免，比如用户登录
* 启动慢：启动操作系统需要多久，启动虚拟机就要多久

## 4. Docker 有什么优势？

* 资源占用少：每个容器都共享主机的资源，容器需要多少就用多少
* 启动快：一条命令即可将容器启动，而容器启动时一般会将服务或应用一并启动

## 5. Docker 与虚拟化的不同

1. 启动速度不同

docker 启动快速属于秒级别。虚拟机通常需要几分钟去启动。

2. 性能损耗不同

docker 需要的资源更少，docker 在操作系统级别进行虚拟化，docker 容器和内核交互，几乎没有性能损耗，性能优于通过 Hypervisor 层与内核层的虚拟化。

3. 系统利用率不同

docker 更轻量，docker 的架构可以共用一个内核与共享应用程序库，所占内存极小。同样的硬件环境，Docker 运行的镜像数远多于虚拟机数量，对系统的利用率非常高。

4. 隔离性不同

与虚拟机相比，docker 隔离性更弱，docker 属于进程之间的隔离，虚拟机可实现系统级别隔离。

5. 安全性不同

docker 的安全性也更弱。Docker 的租户 root 和宿主机 root 等同，一旦容器内的用户从普通用户权限提升为 root 权限，它就直接具备了宿主机的 root 权限，进而可进行无限制的操作。虚拟机租户 root 权限和宿主机的 root 虚拟机权限是分离的。

6. 可管理性不同

早期 docker 的集中化管理工具还不算成熟，但如今 Docker 生态已有 Portainer、Rancher 等成熟管理平台，Kubernetes 更是容器编排的事实标准。各种虚拟化技术也都有成熟的管理工具，例如 VMware vCenter 提供完备的虚拟机管理能力。

7. 可用和可恢复性不同

docker 对业务的高可用支持是通过快速重新部署实现的。虚拟化具备负载均衡，高可用，容错，迁移和数据保护等经过生产实践检验的成熟保障机制，VMware 宣传可承诺虚拟机 99.999% 高可用（厂商营销口径，具体取决于部署与容灾方案），保证业务连续性。

8. 创建、删除速度不同

虚拟化创建是分钟级别的，Docker 容器创建是秒级别的，Docker 的快速迭代性，决定了无论是开发、测试、部署都可以节约大量时间。

9. 交付、部署速度不同

虚拟机可以通过镜像实现环境交付的一致性，但镜像分发无法体系化；Docker 在 Dockerfile 中记录了容器构建过程，可在集群中实现快速分发和快速部署;

### 虚拟化

虚拟化帮助我们在单个物理服务器上运行和托管多个操作系统。在虚拟化中，管理程序为客户操作系统提供了一个虚拟机。VM 形成了硬件层的抽象，因此主机上的每个 VM 都可以充当物理机。

### 容器化

容器化为我们提供了一个独立的环境来运行我们的应用程序。我们可以在单个服务器或 VM 上使用相同的操作系统部署多个应用程序。容器构成了应用层的抽象，所以每个容器代表一个不同的应用。

## 6. 什么是镜像？

* 创建容器的模板
* 同一个镜像可以创建多个不同的容器

## 7. 什么是容器？

* 通过镜像生成的运行实例
* 不同容器之间是相互隔离，独立运行的
* 通常一个容器就是一个应用或一个服务，也是我们常说的微服务

## 8. Docker 如何做到资源隔离

Namespace 对内核资源进行隔离，使得容器中的进程到可以在单独的命名空间中运行，并且只可访问当前容器命名空间的资源。

## 9. OpenTelemetry 是什么

OpenTelemetry 是一组 API、SDK、工具和集成，旨在创建和管理遥测数据，例如 Trace、Metrics 和 Logs。该项目提供了一个与供应商无关的实现，可以将其配置为将遥测数据发送到您选择的后端。

## 10. Jaeger 是什么

Jaeger 是用于追踪分布式服务之间事务的开源软件，它为微服务场景而生。它主要用于分析多个服务的调用过程，图形化服务调用轨迹，是诊断性能问题、分析系统故障的利器。

## 11. Prometheus 是什么

Prometheus 是一个开源的系统监控和报警系统

样本：在时间序列中的每一个点称为一个样本（sample），样本由以下三部分组成：

* 指标（metric）：指标名称和描述当前样本特征的 labelsets；
* 时间戳（timestamp）：一个精确到毫秒的时间戳；
* 样本值（value）： 一个 float64 的浮点型数据表示当前样本的值。

## 12. Dockerfile 常用指令

* FROM：指定基础镜像。
* MAINTAINER：指定镜像创建者信息。（已废弃：Docker v1.13.0 起不再推荐，官方建议改用 LABEL 指令设置元数据）
* RUN：在新的镜像内部运行命令。
* CMD：指定容器启动时要运行的命令。
* EXPOSE：声明容器运行时需要监听的端口。
* ENV：设置环境变量。
* ADD：将文件或目录复制到容器中。
* COPY：将文件或目录复制到容器中。
* ENTRYPOINT：配置容器启动后执行的命令和参数。
* VOLUME：创建一个可以从本地主机或其他容器挂载的挂载点。

## 13. Docker 数据卷

Docker 数据卷是一个可供一个或多个容器使用的特殊目录，它绕过了 UFS，可以提供很多有用的特性：

* 数据卷可以在容器之间共享和重用。
* 对数据卷的修改会立马生效。
* 对数据卷的更新，不会影响镜像。
* 数据卷默认会一直存在，即使容器被删除。

Docker 数据卷有两种创建方式：在创建容器时创建数据卷和先创建好数据卷，然后在创建容器时挂载这个数据卷。

## 14. K8s 组件

### Master

* apiserver 是 Master 节点——同时也是整个 Kubernetes 系统的唯一入口，它对外公开了一系列的 RESTful API，并且加上了验证、授权等功能，所有其他组件都只能和它直接通信，可以说是 Kubernetes 里的联络员。
* etcd 是一个高可用的分布式 Key-Value 数据库，用来持久化存储系统里的各种资源对象和状态，相当于 Kubernetes 里的配置管理员。注意它只与 apiserver 有直接联系，也就是说任何其他组件想要读写 etcd 里的数据都必须经过 apiserver。
* scheduler 负责容器的编排工作，检查节点的资源状态，把 Pod 调度到最适合的节点上运行，相当于部署人员。因为节点状态和 Pod 信息都存储在 etcd 里，所以 scheduler 必须通过 apiserver 才能获得。
* controller-manager 负责维护容器和节点等资源的状态，实现故障检测、服务迁移、应用伸缩等功能，相当于监控运维人员。同样地，它也必须通过 apiserver 获得存储在 etcd 里的信息，才能够实现对资源的各种操作。

### Node

* kubelet 是 Node 的代理，负责管理 Node 相关的绝大部分操作，Node 上只有它能够与 apiserver 通信，实现状态报告、命令下发、启停容器等功能，相当于是 Node 上的一个“小管家”。
* kube-proxy 的作用有点特别，它是 Node 的网络代理，只负责管理容器的网络通信，简单来说就是为 Pod 转发 TCP/UDP 数据包，相当于是专职的“小邮差”。
* container-runtime 我们就比较熟悉了，它是容器和镜像的实际使用者，在 kubelet 的指挥下创建容器，管理 Pod 的生命周期，是真正干活的“苦力”。

## 15. K8s 不同功能会放在同一个容器中吗

K8s 中不同的功能可以放在同一个容器中，但是这并不是最佳实践。通常情况下，每个容器应该只负责一个进程或服务，这样可以更好地管理和维护容器。如果将多个服务放在同一个容器中，那么当其中一个服务出现问题时，可能会影响到其他服务的正常运行。

## 16. K8s 声明式 api 和普通 api 有什么差异

K8s 的 API 本身是统一的 REST 资源 API，声明式/命令式是两种**交互使用方式**而非 API 类型：声明式（Declarative）方式通过 YAML 文件描述期望状态（desired state），由控制器（Controller）不断调谐使实际状态收敛到期望状态；命令式（Imperative）方式则通过 `kubectl create/run` 等命令直接指定具体操作。K8s 核心设计推崇声明式：用户只管「要什么」，系统负责「怎么做」。

## 17. Dockerfile 合并多个 RUN 指令的原因

当我们编写 Dockerfile 时，可以合并多个 RUN 指令，减少不必要的镜像层的产生，并且在之后将多余的命令清理干净，只保留运行时需要的依赖。

比如，下面这个 Dockerfile：

```dockerfile
FROM ubuntu

RUN apt-get install vim -y

RUN apt-get remove vim -y
```

虽然这个操作创建的镜像中没有安装 Vim，但是镜像的大小和有 Vim 是一样的。原因就是，每条指令都会新加一个镜像层，执行 install vim 后添加了一层，执行 remove vim 后也会添加一层，而这一删除命令并不会减少整个镜像的大小。

## 18. docker stats 命令

docker stats 命令用于监视容器的实时资源使用情况，包括 CPU、内存、网络和磁盘等方面的信息。通过该命令，可以查看每个容器的 CPU 利用率、内存使用量、网络传输速度以及磁盘读写速度等指标，在容器出现性能问题时，可以帮助开发人员快速地定位问题所在。

使用 docker stats 命令可以列出正在运行的所有容器的实时资源使用情况，也可以通过指定容器名称或 ID 来获取特定容器的实时资源使用情况。默认情况下，docker stats 命令会每秒钟更新一次容器的资源使用情况，并按照容器 ID 或名称进行排序显示。

## 19. 持续交付的价值

持续交付的价值不仅仅局限于简单地提高产品交付的效率，它还通过统一标准、规范流程、工具化、自动化等等方式，影响着整个研发生命周期。

持续交付最终的使命是打破一切影响研发的“阻碍墙”，为软件研发工作本身赋能。无论你是持续交付的老朋友还是新朋友，无论你在公司担任管理工作还是普通的研发人员，持续交付都会对你的工作产生积极的作用。

## 20. DevOps 与持续交付

* DevOps 的本质其实是一种鼓励协作的研发文化；
* 持续交付与 DevOps 所追求的最终目标是一致的，即快速向用户交付高质量的软件产品；
* DevOps 的概念比持续交付更宽泛，是持续交付的继续延伸；
* 持续交付更专注于技术与实践，是 DevOps 的工具及技术实现。

## 21. 代码分支策略的选择

* “主干开发”集成效率高，冲突少，但对团队个人的开发能力有较高要求；
* “特性分支开发”有利于并行开发，需要一定的流程保证，能保证主干代码质量。

相信在没有绝对自信能力的情况下，面对绝大多数的场景，企业还是会选择“特性分支开发”的策略。

## 22. 如何做到分钟级搭建环境

* 可以使用虚拟机资源池，提升获取机器资源的速度；
* 合理打造并行的应用部署流水线，是进一步提升环境创建速度的方法；
* 利用配置等方式快速达到环境变更需求，可以再次有效地提升整个环境部署的效率。

## 23. 容器与环境管理

容器是一种轻量级、可移植、自包含的软件打包技术，使应用程序几乎可以在任何地方以相同的方式运行。

容器技术统一了软件环境和软件代码，交付产物中既包括了软件环境，又包括了软件代码。也就是说，容器帮我们重新定义了交付标准。

* 第一 交付结果一致
* 第二 交付自动化
* 第三 交付个性化
* 第四 交付版本控制

但它并不是银弹，它同样会带来问题，而这些问题，则需要改造和重新设计既有的持续交付模式来解决。

## 24. 如何做到构建的提速

* 升级硬件资源，最直接和粗暴的提速方式；
* 搭建私有仓库，避免从外网下载依赖；
* 使用本地缓存，减少每次构建时依赖下载的消耗；
* 规范构建流程，通过异步方式解决旁支流程的执行；
* 善用构建工具，根据实际情况合理发挥的工具特性。

## 25. 构建资源的弹性伸缩

* 通常建议使用成熟的 CI 产品（比如，Travis CI、Circle CI、Jenkins CI）来作为平台的基础；
* 虽然这些 CI 工具是成熟产品，但面对日新月异的技术需求，高可用和伸缩问题还是要自己解决；
* 通过请求分发等设计，可以实现 Master 节点的横向伸缩及高可用问题；
* 利用容器技术，可以解决 Slave 节点的弹性伸缩和资源利用率问题。

## 26. 容器镜像构建

容器镜像是一个独立的文件系统，它包含了容器运行初始化时所需要的数据或软件。Docker 容器的文件系统是分层的、只读的，每次创建容器时只要在最上层添加一个叫作 Container layer 的可写层就可以了。这种创建方式不同于虚拟机，可以极大的减少对磁盘空间的占用。

Docker 提供了 Dockerfile 这个可以描述镜像的文本格式的配置文件。你可以在 Dockerfile 中运行功能丰富的指令，并可以通过 docker build 将这些指令转化为镜像。

基于 Dockerfile 的特性，我分享了 Dockerfile 镜像构建优化的三个建议，包括：选择合适的 Base 镜像、减少不必要的镜像层产生，以及善用构建缓存。

用容器来构建容器镜像，主要有 DooD 和 DinD 两种方案。这两种方案，各有优劣，可以根据自身情况去选择。

## 27. 容器镜像的个性化及合规检查

* 用户自定义环境脚本，通过 build-env.sh 和 image-env.sh 两个文件可以在构建的两个阶段改变镜像的内容；
* 平台环境选项与服务集市，利用这两个自建系统，可以将个性化的内容进行抽象，以达到快速复用，和高度封装的作用；
* 自定义镜像，是彻底解决镜像个性化的方法，但也要注意符合安全和合规的基本原则。

关于对镜像的安全合规检查，在实践过程中总结有 5 条合规检查的基本建议

* 基础镜像来自于 Docker 官方认证的，并做好签名检查；
* 不使用 root 启动应用进程；
* 不在镜像保存密码，Token 之类的敏感信息；
* 不使用 --privileged 参数标记使用特权容器；
* 安全的 Linux 内核、内核补丁。如 SELinux，AppArmor，GRSEC 等。

## 28. 几种常见的灰度方式

1. 蓝绿发布，是先增加一套新的集群，发布新版本到这批新机器，并进行验证，新版本服务器并不接入外部流量。此时旧版本集群保持原有状态，发布和验证过程中老版本所在的服务器仍照常服务。验证通过后，流控处理把流量引入新服务器，待全部流量切换完成，等待一段时间没有异常的话，老版本服务器下线。
   * 这种发布方法需要额外的服务器集群支持，对于负载高的核心应用机器需求可观，实现难度巨大且成本较高。
   * 蓝绿发布的好处是所有服务都使用这种方式时，实际上创造了蓝绿两套环境，隔离性最好、最可控，回滚切换几乎没有成本。
2. 滚动发布，是不添加新机器，从同样的集群服务器中挑选一批，停止上面的服务，并更新为新版本，进行验证，验证完毕后接入流量。重复此步骤，一批一批地更新集群内的所有机器，直到遍历完所有机器。这种滚动更新的方法比蓝绿发布节省资源，但发布过程中同时会有两个版本对外提供服务，无论是对自身或是调用者都有较高的兼容性要求，需要团队间的合作妥协。但这类问题相对容易解决，实际中往往会通过功能开关等方式来解决。
3. 金丝雀发布，从集群中挑选特定服务器或一小批符合要求的特征用户，对其进行版本更新及验证，随后逐步更新剩余服务器。这种方式，比较符合携程对灰度发布的预期，但可能需要精细的流控和数据的支持，同样有版本兼容的需求。

## 29. 不可变模型

> 每一次变更都是一次发布，而每一次发布都是系统重新构建，更形象点说，每一次发布都是一个独立镜像的启动。

首先，任何的变更，包括代码的、配置的、环境的，甚至是 CPU、内存、磁盘的大小变化，都需要制作成独立版本的镜像。

其次，变更的镜像可以提前制作，但必须通过发布才能生效。 这有 2 个好处：

1. 重新生成新的实例进行生效，完全遵循不可变模型的做法；
2. 发布内容既包含代码也包含基础设施，更有利于 DevOps 的实施。

再次，一组运行中的同一个镜像的实例，因为“不可变”的原因，其表现和实质都是完全一样的，所以不再需要关心顺序的问题。因为任何一个都等价，所以也就没有发布或替换的先后问题了。

最后，根据“一致”模型的要求，我们需要记录系统从第一天发展到今天的所有有序变更。 对 Docker 而言，不仅要能向上追溯层层 Base 镜像的情况，更建议将系统和软件的配置以 Dockerfile 的方式进行处理，以明确整个过程的顺序。

## 30. 发布系统的附加问题思考

一款出色的发布系统，除了要考虑架构、核心模型，以及发布流程的问题外，还必须同时考虑一些附加问题，比如：

* 为了降低 Quick and Dirty 方式对业务功能的影响，你需要提供发布刹车机制；
* 利用分布式存储、尾单加速、symlink 回滚等方式，可以提升发布速度；
* 必要的降级机制，可以保证发布系统的高可用。

## 31. 如何利用监控保障发布质量

监控的几种分类，以及分别可以采用什么方式去采集数据：

* 用户侧监控，可以通过打点收集，或者定期采集日志的方式进行数据收集；
* 网络监控，通过模拟手段或定期采样进行收集；
* 业务监控，需要定义正确的指标以及相匹配的采集技术，务必注意实时性；
* 应用监控，可以通过中间件打点采集，也可以通过日志联合分析进行数据采集；
* 系统监控，通常采用定期采样的方式收集数据。

三个对发布来说特别重要的监控问题：

* 测试环境的监控需要视作用而定，如果不能帮助分析和定位问题，则不需要很全面的监控；
* 一般发布后，我建议继续坚持监控 30 分钟，把这个流程纳入发布流程中；
* 完整的运维事件记录体系，可以帮你定位某次故障是否是由发布引起的。

## 32. 破坏性测试

破坏性测试就是通过有效的测试手段，使软件应用程序出现奔溃或失败的情况，然后测试在这样的情况下，软件运行会产生什么结果，而这些结果又是否符合预期。

* 第一，破坏性测试的手段和过程，并不是无的放矢，它们是被严格设计和执行的。不要把破坏性测试和探索性测试混为一谈。也就是说，破坏性测试不应该出现，“试试这样会不会出问题”的假设，而且检验破坏性测试的结果也都应该是有预期的。
* 第二，破坏性测试，会产生切实的破坏作用，你需要权衡破坏的量和度。因为破坏不仅仅会破坏软件，还可能会破坏硬件。通常情况下，软件被破坏后的修复成本不会太大，而硬件部分被破坏后，修复成本就不好说了。所以，你必须要事先考虑好破坏的量和度。

破坏性测试能够很好地测试分布式系统的健壮性，但也因为其破坏特点，使得它在持续交付中无法显示真正的威力；而混沌工程的提出，很好地解决了这个问题，使破坏性测试的威力能够在持续交付过程中被真正发挥出来。

## 33. 自动化回归测试

自动化回归测试会遇到三个难题：测试数据的准备和清理、分布式系统的依赖，以及测试用例的高度仿真。

我们可以利用 Mock 技术（即通过代理的方式模拟被依赖的对象、方法或服务的技术），通过不同的框架，解决自动化回归测试的前两个问题：

* 基于对象和类的 Mock，解决一个应用内部依赖的问题；
* 基于微服务的 Mock，解决应用与应用之间外部依赖的问题。

“回放技术”，即先通过虚拟交换机，复制和记录生产用户的实际请求，在测试时“回放”这些真实操作，以达到更逼真地模拟用户行为的目的，从而解决了自动化回归测试遇到的第三个问题。

所以，利用 Mock 和“回放”技术，我们能够提高自动化回归测试的效率和准确度，从而使整个持续交付过程更顺滑，自动化程度更高。

## 34. 持续交付为什么要实现平台化

* 随着软件技术的发展，任何企业最终都将面临多技术栈的现实。不同的技术栈，就意味着不同的标准、不同的工具、不同的方式，所以我们就必须要通过合理的持续交付平台，去解决不同技术栈的适配工作。
* 随着持续交付业务的发展，团队会越来越庞大，分工也会越来越明细。这就要求持续交付体系能够支持更大规模的并发处理操作，同时还要不断地提升效率。更重要的是，当持续交付成为企业研发的生命线时，它必须做到高可用，否则一旦停产，整个研发就停产了。
* 随着持续交付技术本身的发展，还会不断引入新的工具，或新的流程方法。如果我们的持续交付体系不能做到快速适应、局部改造、高可扩展的话，那它自身的发展与优化将会面临严峻的挑战。

## 35. 持续交付中有哪些宝贵数据

首先，你可以利用与业务量相关的数据模型来衡量持续交付系统的稳定性；然后，在日常的数据分析中，除了要抓住主要数据的大趋势外，你还要关注那些异常的个性数据，它能帮你及早地发现问题；最后，通过日常的数据分析，你也能发现持续交付流程上的一些问题，并协助团队一起改进。

当然，这只是三个比较突出的例子而已。在实践中，实施持续交付的过程中还有很多数据需要我们关注。包括：

* 稳定性相关指标；
* 性能相关指标；
* 持续交付能力成熟度指标。

## 36. Prometheus 中的 Pushgateway

用于接收短生命周期任务的指标上报，是 PUSH 的接收方式。因为 Prometheus 主要是 PULL 的方式拉取监控数据，这就要求在拉取的时刻，监控对象得活着，但是很多短周期任务，比如 cronjob，可能半秒就运行结束了，就没法拉取了。为了应对这种情况，才单独做了 Pushgateway 组件作为整个生态的补充。

## 37. Prometheus 中的服务发现

我们演示抓取数据时，是直接在 prometheus.yml 中配置的多个 Targets。这种方式虽然简单直观，但是也有弊端，典型的问题就是如果 Targets 是动态变化的，而且变化得比较频繁，那就会造成管理上的灾难。所以 Prometheus 提供了多种服务发现机制，可以动态获取要监控的目标，比如 Kubernetes 的服务发现，可以通过调用 kube-apiserver 动态获取到需要监控的目标对象，大幅降低了抓取目标的管理成本。

## 38. 为什么 Prometheus 默认为拉模式

拉模式有个最重要的优势，就是解耦。对于各类中间件，特别是非常基础的那些，很大概率是先于监控系统部署的。如果是拉模式，部署好监控系统之后，再来调用中间件的接口获取数据即可。如果是推模式，就需要在中间件里重新配置监控数据上报地址，然后重启中间件，这个代价就太高了。

## 39. 时序数据有哪些部分组成

每一个点称为一个样本（sample），样本由三部分组成。

* 指标（metric）：metric name 和描述当前样本特征的 labelsets。
* 时间戳（timestamp）：一个精确到毫秒的时间戳。
* 值（value）：表示该时间样本的值。

PromQL 就是对这样一批样本数据做查询和计算操作。

## 40. Prometheus 联邦机制

原本一个 Prometheus 解决不了的问题，拆成了多个，然后又把多个 Prometheus 的数据聚拢到中心的 Prometheus 中。但是，中心的 Prometheus 仍然是个瓶颈。所以在联邦机制中，中心端的 Prometheus 去抓取边缘 Prometheus 数据时，不应该把所有数据都抓取到中心，而是应该 **只抓取那些需要做聚合计算或其他团队也关注的指标**，大部分数据还是应该下沉在各个边缘 Prometheus 内部消化掉。

## 41. Prometheus 的远端存储方案

默认情况下，Prometheus 收集到监控数据之后是存储在本地，在本地查询计算。由于单机容量有限，对于海量数据场景，需要有其他解决方案。最直观的想法就是：既然本地搞不定，那就在远端做一个集群，分治处理。

* VictoriaMetrics
  * VM 虽然可以作为 Prometheus 的远程存储，但它志不在此。VM 是希望成为一个更好的 Prometheus，所以它不只是时序库，它还有抓取器、告警等各个组件，体系非常完备。
* Thanos
  * Thanos 的做法和 VM 不同，Thanos 完全拥抱 Prometheus，对 Prometheus 做了一个增强，核心特点是使用 **对象存储** 做海量时序存储。

## 42. RED 方法

* （Request）Rate：请求速率，每秒请求数。
* （Request）Errors：错误，每秒错误请求数。
* （Request）Duration：延迟，每个请求的延迟分布情况。

## 43. USE 方法

* 使用率：这个我们最熟悉，比如内存使用率、CPU 使用率等，是一个百分比。
* 饱和度：资源排队工作的指标，无法再处理额外的工作。通常用队列长度表示，比如在 iostat 里看到的 aqu-sz 就是队列长度。
* 错误：资源错误事件的计数。比如 malloc() 失败次数、通过 ifconfig 看到的 errors、dropped 包量。有很多错误是以系统错误日志的方式暴露的，没法直接拿到某个统计指标，此时可以进行日志关键字监控。

## 44. OpenTelemetry 架构

OpenTelemetry 主要包括了下面三个部分：

* 跨语言规范 （Specification）；
* API / SDK；
* 接收、转换和导出遥测数据的工具，又称为 OpenTelemetry Collector。

## 45. 容器技术原理

### chroot

chroot 就是可以改变某进程的根目录，使这个程序不能访问目录之外的其他目录，这个跟我们在一个容器中是很相似的。

### Namespace

Namespace 是 Linux 内核的一项功能，该功能对内核资源进行隔离，使得容器中的进程都可以在单独的命名空间中运行，并且只可以访问当前容器命名空间的资源。Namespace 可以隔离进程 ID、主机名、用户 ID、文件名、网络访问和进程间通信等相关资源。

Docker 主要用到以下五种命名空间。

* pid namespace：用于隔离进程 ID。
* net namespace：隔离网络接口，在虚拟的 net namespace 内用户可以拥有自己独立的 IP、路由、端口等。
* mnt namespace：文件系统挂载点隔离。
* ipc namespace：信号量,消息队列和共享内存的隔离。
* uts namespace：主机名和域名的隔离。

（注：除上述五种外，user namespace 可隔离用户 ID，但 Docker 默认不启用，需通过 dockerd 的 `--userns-remap` 开启；Docker 20.10 起，在内核支持时还会默认启用 cgroup namespace，故也有“六种”的说法。）

### Cgroups

Cgroups 是一种 Linux 内核功能，可以限制和隔离进程的资源使用情况（CPU、内存、磁盘 I/O、网络等）。在容器的实现中，Cgroups 通常用来限制容器的 CPU 和内存等资源的使用。

### 联合文件系统

联合文件系统，又叫 UnionFS，是一种通过创建文件层进程操作的文件系统，因此，联合文件系统非常轻快。Docker 使用联合文件系统为容器提供构建层，使得容器可以实现写时复制以及镜像的分层构建和存储。Docker 常用的存储驱动有 overlay2、AUFS、Devicemapper 等，其中 AUFS、Overlay（OverlayFS）属于联合文件系统（UnionFS），而 Devicemapper 是块级存储驱动，并非联合文件系统。

## 46. Docker 核心概念

### 镜像

通俗地讲，它是一个只读的文件和文件夹组合。它包含了容器运行时所需要的所有基础文件和配置信息，是容器启动的基础。

### 容器

容器是镜像的运行实体。镜像是静态的只读文件，而容器带有运行时需要的可写文件层，并且容器中的进程属于运行状态。即**容器运行着真正的应用进程。容器有初建、运行、停止、暂停和删除五种状态。**（注：Docker 官方定义的容器状态为 created、restarting、running、removing、paused、exited、dead 七种，此处“五种”是常见简化说法。）

虽然容器的本质是主机上运行的一个进程，但是容器有自己独立的命名空间隔离和资源限制。也就是说，在容器内部，无法看到主机上的进程、环境变量、网络等信息，这是容器与直接运行在主机上进程的本质区别。

## 47. Docker 核心组件

* runC 是 Docker 官方按照 OCI 容器运行时标准的一个实现。通俗地讲，runC 是一个用来运行容器的轻量级工具，是真正用来运行容器的。
* containerd 是 Docker 服务端的一个核心组件，它是从 dockerd 中剥离出来的，它的诞生完全遵循 OCI 标准，是容器标准化后的产物。containerd 通过 containerd-shim 启动并管理 runC，可以说 containerd 真正管理了容器的生命周期。

`dockerd` 通过 gRPC 与 `containerd` 通信，由于 `dockerd` 与真正的容器运行时，`runC` 中间有了 `containerd` 这一 OCI 标准层，使得 `dockerd` 可以确保接口向下兼容。

## 48. Dockerfile 构建镜像特性

* Dockerfile 的每一行命令都会生成一个独立的镜像层，并且拥有唯一的 ID；
* Dockerfile 的命令是完全透明的，通过查看 Dockerfile 的内容，就可以知道镜像是如何一步步构建的；
* Dockerfile 是纯文本的，方便跟随代码一起存放在代码仓库并做版本管理。

分层的结构使得 Docker 镜像非常轻量，每一层根据镜像的内容都有一个唯一的 ID 值，当不同的镜像之间有相同的镜像层时，便可以实现不同的镜像之间共享镜像层的效果。

## 49. 使用 Dockerfile 进行构建的好处

* 易于版本化管理，Dockerfile 本身是一个文本文件，方便存放在代码仓库做版本管理，可以很方便地找到各个版本之间的变更历史；
* 过程可追溯，Dockerfile 的每一行指令代表一个镜像层，根据 Dockerfile 的内容即可很明确地查看镜像的完整构建过程；
* 屏蔽构建环境异构，使用 Dockerfile 构建镜像无须考虑构建环境，基于相同 Dockerfile 无论在哪里运行，构建结果都一致。

## 50. CMD 和 ENTRYPOINT

* Dockerfile 中如果使用了 `ENTRYPOINT` 指令，启动 Docker 容器时需要使用 --entrypoint 参数才能覆盖 Dockerfile 中的 `ENTRYPOINT` 指令，而使用 `CMD` 设置的命令则可以被 `docker run` 后面的参数直接覆盖。
* `ENTRYPOINT` 指令可以结合 `CMD` 指令使用，也可以单独使用；`CMD` 指令同样可以单独使用，或在存在 `ENTRYPOINT` 时作为其默认参数传入（这是二者最常见的搭配用法）。

如果你希望你的镜像足够灵活，推荐使用 `CMD` 指令。如果你的镜像只执行单一的具体程序，并且不希望用户在执行 `docker run` 时覆盖默认程序，建议使用 `ENTRYPOINT`。

最后再强调一下，无论使用 `CMD` 还是 `ENTRYPOINT`，都尽量使用 `exec` 模式。

## 51. Docker 的安全问题

* 镜像安全
* Linux 内核隔离性不够

  尽管目前 Namespace 已经提供了非常多的资源隔离类型，但是仍有部分关键内容没有被完全隔离，其中包括一些系统的关键性目录（如 /sys、/proc 等），这些关键性的目录可能会泄露主机上一些关键性的信息，让攻击者利用这些信息对整个主机甚至云计算中心发起攻击。
* 所有容器共享主机内核

## 52. 为什么 Docker 需要 Namespace

当 Docker 新建一个容器时，它会创建六种 Namespace（默认的 mnt、uts、ipc、pid、net 五种，加上 Docker 20.10 起默认启用的 cgroup namespace；user namespace 默认不启用），然后将容器中的进程加入这些 Namespace 之中，使得 Docker 容器中的进程只能看到当前 Namespace 中的系统资源。

正是由于 Docker 使用了 Linux 的这些 Namespace 技术，才实现了 Docker 容器的隔离，可以说没有 Namespace，就没有 Docker 容器。

## 53. Cgroups 功能及核心概念

### 功能

* 资源限制： 限制资源的使用量，例如我们可以通过限制某个业务的内存上限，从而保护主机其他业务的安全运行。
* 优先级控制：不同的组可以有不同的资源（ CPU 、磁盘 IO 等）使用优先级。
* 资源统计（accounting）：计算控制组的资源使用情况。
* 控制：控制进程的挂起或恢复。

### 概念

* 子系统（subsystem）：是一个内核的组件，一个子系统代表一类资源调度控制器。例如内存子系统可以限制内存的使用量，CPU 子系统可以限制 CPU 的使用时间。
* 控制组（cgroup）：表示一组进程和一组带有参数的子系统的关联关系。例如，一个进程使用了 CPU 子系统来限制 CPU 的使用时间，则这个进程和 CPU 子系统的关联关系称为控制组。
* 层级树（hierarchy）：是由一系列的控制组按照树状结构排列组成的。这种排列方式可以使得控制组拥有父子关系，子控制组默认拥有父控制组的属性，也就是子控制组会继承于父控制组。（注：此为简化表述，cgroup v1 中资源限制作用于设置它的控制组及其整个子树，而非自动复制到子组）比如，系统中定义了一个控制组 c1，限制了 CPU 可以使用 1 核，然后另外一个控制组 c2 想实现既限制 CPU 使用 1 核，同时限制内存使用 2G，那么 c2 就可以直接继承 c1，无须重复定义 CPU 限制。

cgroups 的三个核心概念中，子系统是最核心的概念，因为子系统是真正实现某类资源的限制的基础。

## 54. Docker 相关的组件

* docker

docker 是 Docker 客户端的一个完整实现，它是一个二进制文件，对用户可见的操作形式为 docker 命令，通过 docker 命令可以完成所有的 Docker 客户端与服务端的通信

* dockerd

dockerd 是 Docker 服务端的后台常驻进程，用来接收客户端发送的请求，执行具体的处理任务，处理完成后将结果返回给客户端。

* docker-init

在容器内部，当我们自己的业务进程没有回收子进程的能力时，在执行 docker run 启动容器时可以添加 --init 参数，此时 Docker 会使用 docker-init 作为 1 号进程，帮你管理容器内子进程，例如回收僵尸进程等。

* docker-proxy

docker-proxy 是 Docker 的用户态代理进程，主要用于辅助端口映射。当我们使用 docker run 命令启动容器时，如果使用了 -p 参数，Docker 会通过 iptables DNAT 规则把容器内相应的端口映射到主机上来，默认同时运行 docker-proxy 作为用户态代理协助转发（例如 hairpin NAT 等场景）。

## 55. Containerd 相关组件

* containerd

[containerd](https://github.com/containerd/containerd) 组件是从 Docker 1.11 版本正式从 dockerd 中剥离出来的，它的诞生完全遵循 OCI 标准，是容器标准化后的产物。containerd 完全遵循了 OCI 标准，并且是完全社区化运营的，因此被容器界广泛采用。

containerd 不仅负责容器生命周期的管理，同时还负责一些其他的功能：

* 镜像的管理，例如容器运行前从镜像仓库拉取镜像到本地；
* 接收 dockerd 的请求，通过适当的参数调用 runc 启动容器；
* 管理存储相关资源；
* 管理网络相关资源。

containerd 包含一个后台常驻进程，默认的 socket 路径为 /run/containerd/containerd.sock，dockerd 通过 UNIX 套接字向 containerd 发送请求，containerd 接收到请求后负责执行相关的动作并把执行结果返回给 dockerd。

如果你不想使用 dockerd，也可以直接使用 containerd 来管理容器，由于 containerd 更加简单和轻量，生产环境中越来越多的人开始直接使用 containerd 来管理容器。

* containerd-shim

containerd-shim 的主要作用是将 containerd 和真正的容器进程解耦，使用 containerd-shim 作为容器进程的父进程，从而实现重启 containerd 不影响已经启动的容器进程。

* ctr

ctr 实际上是 containerd-ctr，它是 containerd 的客户端，主要用来开发和调试，在没有 dockerd 的环境中，ctr 可以充当 docker 客户端的部分角色，直接向 containerd 守护进程发送操作容器的请求。

## 56. 容器运行时组件 runc

runc 是一个标准的 OCI 容器运行时的实现，它是一个命令行工具，可以直接用来创建和运行容器。负责真正意义上创建和启动容器。

## 57. Libnetwork 常见网络模式

1. null 空网络模式：可以帮助我们构建一个没有网络接入的容器环境，以保障数据安全。
2. bridge 桥接模式：可以打通容器与容器间网络通信的需求。
3. host 主机网络模式：可以让容器内的进程共享主机网络，从而监听或修改主机网络。
4. container 网络模式：可以将两个容器放在同一个网络命名空间内，让两个业务通过 localhost 即可实现访问。

**bridge 桥接模式是 Docker 的默认网络模式，当我们创建容器时不指定任何网络模式，Docker 启动容器默认的网络模式为 bridge。**

## 58. Docker 卷的实现原理

Docker 卷的实现原理是在主机的 /var/lib/docker/volumes 目录下，根据卷的名称创建相应的目录，然后在每个卷的目录下创建 \_data 目录，在容器启动时如果使用 --mount 参数，Docker 会把主机上的目录直接映射到容器的指定目录下，实现数据持久化。

## 59. Kubernetes 生成对象的 YAML 模版

使用参数 `--dry-run=client -o yaml` 可以生成对象的 YAML 模板，简化编写工作。

## 60. 进入/拷贝文件到 Pod

```sh
kubectl cp a.txt ngx-pod:/tmp
kubectl exec -it ngx-pod -- sh
```

## 61. 快速暴露 Kubernetes 服务

因为 Pod 都是运行在 Kubernetes 内部的私有网段里的，外界无法直接访问，想要对外暴露服务，需要使用一个专门的 `kubectl port-forward` 命令，它专门负责把本机的端口映射到在目标对象的端口号，有点类似 Docker 的参数 `-p`，经常用于 Kubernetes 的临时调试和测试。

下面我就把本地的“8080”映射到 WordPress Pod 的“80”，kubectl 会把这个端口的所有数据都转发给集群内部的 Pod：

```sh
kubectl port-forward wp-pod 8080:80 &
```

注意在命令的末尾使用了一个 `&` 符号，让端口转发工作在后台进行，这样就不会阻碍我们后续的操作。

如果想关闭端口转发，需要敲命令 `fg` ，它会把后台的任务带回到前台，然后就可以简单地用“Ctrl + C”来停止转发了。

## 62. Kubernetes 应用伸缩

`kubectl scale` 是专门用于实现“扩容”和“缩容”的命令，你只要用参数 `--replicas` 指定需要的副本数量，Kubernetes 就会自动增加或者删除 Pod，让最终的 Pod 数量达到“期望状态”。

```sh
kubectl scale --replicas=5 deploy ngx-dep
```

## 63. Kubernetes labels

我们通过 `labels` 为对象“贴”了各种“标签”，在使用 `kubectl get` 命令的时候，加上参数 `-l`，使用 `==`、`!=`、`in`、`notin` 的表达式，就能够很容易地用“标签”筛选、过滤出所要查找的对象（有点类似社交媒体的 `#tag` 功能），效果和 Deployment 里的 `selector` 字段是一样的。

```sh
kubectl get pod -l app=nginx
```

## 64. 什么是污点和容忍度

控制平面（Master）节点默认带有一个 `taint`，效果是 `NoSchedule`，也就是说这个污点会拒绝 Pod 调度到本节点上运行。在 kubeadm 部署的集群中，该污点的 key 为 `node-role.kubernetes.io/control-plane`（旧版为 `node-role.kubernetes.io/master`：Kubernetes 1.20 起废弃该命名，kubeadm 1.24 起新集群改用 control-plane 污点，1.25 起不再添加 master 污点），而 Worker 节点的 `taint` 字段则是空的。

`tolerations` 是一个数组，里面可以列出多个被“容忍”的“污点”，需要写清楚“污点”的名字、效果。比较特别是要用 `operator` 字段指定如何匹配“污点”，一般我们都使用 `Exists`，也就是说存在这个名字和效果的“污点”。

如果我们想让 DaemonSet 里的 Pod 能够在控制平面（Master）节点上运行，就要写出这样的一个 `tolerations`，容忍节点的 `node-role.kubernetes.io/control-plane:NoSchedule` 这个污点（旧版本集群对应 `node-role.kubernetes.io/master:NoSchedule`）：

```yaml
tolerations:
- key: node-role.kubernetes.io/control-plane  # 旧版本集群（kubeadm < 1.24）的 key 为 node-role.kubernetes.io/master
  effect: NoSchedule
  operator: Exists
```

“容忍度”并不是 DaemonSet 独有的概念，而是从属于 Pod，你可以在 Job/CronJob、Deployment 里为它们管理的 Pod 也加上 `tolerations`，从而能够更灵活地调度应用。

## 65. 什么是静态 Pod

DaemonSet 是在 Kubernetes 里运行节点专属 Pod 最常用的方式，但它不是唯一的方式，Kubernetes 还支持另外一种叫“**静态 Pod**”的应用部署手段。

“静态 Pod”非常特殊，它不受 Kubernetes 系统的管控，不与 ApiServer、scheduler 发生关系，所以是“静态”的。

但既然它是 Pod，也必然会“跑”在容器运行时上，也会有 YAML 文件来描述它，而唯一能够管理它的 Kubernetes 组件也就只有在每个节点上运行的 kubelet 了。

“静态 Pod”的 YAML 文件默认都存放在节点的 `/etc/kubernetes/manifests` 目录下，它是 Kubernetes 的专用目录。

Kubernetes 的 4 个核心组件 apiserver、etcd、scheduler、controller-manager 原来都以静态 Pod 的形式存在的，这也是为什么它们能够先于 Kubernetes 集群启动的原因。

## 66. 动态存储卷

Kubernetes 里有“**动态存储卷**”的概念，它可以用 StorageClass 绑定一个 Provisioner 对象，而这个 Provisioner 就是一个能够自动管理存储、创建 PV 的应用，代替了原来系统管理员的手工劳动。


# 网络安全

## 1. 什么是 JWT？主要用来做什么？

JWT：JSON Web Token，是基于 JSON 的一个公开规范（RFC 7519），这个规范允许我们使用 JWT 在用户和服务器之间传递安全可靠的信息，它的两大使用场景是：认证和数据交换

## 2. JWT 由几部分组成？分别是什么？

一个 JWT 实际上就是一个字符串，它由三部分组成：头部、载荷与签名

## 3. JWT 校验 token 时，怎么保证数据并没有被黑客拦截并篡改？

signature 由服务端的私钥（或共享密钥）对 header 和 payload 计算生成，签名本身并不包含私钥；客户端一旦篡改数据，服务端重新计算出的签名就会与 token 中的签名不一致，校验失败，从而保证数据未被篡改

## 4. Session + Cookie 实现

1. 客户端登录成功后，服务器会给每一个客户端（浏览器）分配一个唯一的 session\_id，用来区分它们，除了存入服务器的缓存，数据库或者内存中，还会把这个 session\_id 返回给相应的客户端
2. 客户端收到 session\_id 后会存入 cookie 中，以后每一次发送其他类型的请求的操作都会携带这个 session\_id
3. 服务器会将客户端发来的这个 session\_id 和服务端查到的 session\_id 进行对比，如果匹配，则返回给对应主机所需要的资源，否则拒绝

缺点

1. 因为服务器缓存 session\_id，需要定期清理 session\_id 表
2. 无法区分跨站/跨域请求：Cookie 会随跨站请求自动携带，服务端难以判断请求是否来自同源页面，因此存在 CSRF 风险
3. 在分布式 (即多台服务器) 的环境下，还得做 session 同步，一般不推荐，

## 5. JWT 的缺陷

1. 不安全的加密算法 JWT 给开发者提供了很多的加密算法选择，其中就包括了已知的易受攻击的算法
2. 在 header 中包含了签名算法的种类 攻击者只需要将 header 中的 alg 字段设置为 none 就可以绕过签名验证过程，在知道服务器使用非对称加密算法的情况下，修改 alg 为一个对称加密算法

## 6. Paseto 相比 JWT 的改变

* 不会向用户开放所有的加密算法
* header 中不再含有 alg 字段，也不会有 none 算法
* local 模式下 payload 使用加密算法（如 XChaCha20-Poly1305），而不是像 JWT 那样仅做 base64url 编码；public 模式则只签名、payload 不加密

## 7. CSRF 攻击

攻击者诱导受害者进入第三方网站，在第三方网站中，向被攻击网站发送跨站请求。利用受害者在被攻击网站已经获取的登录凭证，绕过后台的用户验证，达到冒充用户对被攻击的网站执行某项操作的目的。

## 8. 防御 CSRF 攻击

前面讲到 CSRF 的一个特征是，攻击者无法直接窃取到用户的信息（Cookie，Header，网站内容等），仅仅是冒用 Cookie 中的信息。

而 CSRF 攻击之所以能够成功，是因为服务器误把攻击者发送的请求当成了用户自己的请求。那么我们可以要求所有的用户请求都携带一个 CSRF 攻击者无法获取到的 Token。服务器通过校验请求是否携带正确的 Token，来把正常的请求和攻击的请求区分开，也可以防范 CSRF 的攻击。

## 9. XSS CSRF SSRF 区别以及防范

1. XSS（跨站脚本攻击）：
   * 原理：攻击者通过在网页中插入恶意脚本，使用户在浏览器中执行该脚本，从而获取用户的敏感信息或进行其他恶意操作。
   * 防御方式：对用户输入进行过滤和转义、使用 HttpOnly 属性禁止 JavaScript 读取 Cookie 值、输入时校验、浏览器与 Web 应用端采用相同的字符编码等。
2. CSRF（跨站请求伪造）：
   * 原理：攻击者利用用户已登录的身份，通过伪造请求发送到受信任的网站，使用户在不知情的情况下执行恶意操作。
   * 防御方式：使用随机化的 CSRF 令牌来验证请求的合法性。
3. SSRF（服务器端请求伪造）：
   * 原理：攻击者通过构造恶意请求，使服务器发起对内部资源的请求，从而获取敏感信息或攻击内部系统。
   * 防御方式：限制协议为 HTTPS、限制 URL 白名单、限制内网 IP 等。

## 10. SSL 加密算法

### 对称加密算法

* **DES**：DES 是最早被推出的加密算法之一,但由于密钥长度短,容易被暴力破解,已于 2005 年被弃用。
* **3DES**：3DES 是 DES 算法的升级版,通过三次加密提高了安全性,但也存在严重安全漏洞,NIST 已规定 2023 年 12 月 31 日之后新应用禁用 3DES（SP 800-131A Rev.2,2019 年发布）。
* **AES**：AES 是 DES 的替代方案,是目前使用最广泛的对称加密算法之一。AES 密钥长度为 128、192 或 256 位,安全性高,广泛用于金融、在线交易等领域。

### 非对称加密算法

* **RSA**：RSA 是 1977 年发明的非对称加密算法,是目前使用最广泛的公钥算法。其安全性建立在大整数因子分解的困难性上,即使用超级计算机也很难破解。
* **ECC**：ECC 又称椭圆曲线加密算法,相比 RSA 可以使用更短的密钥实现更高的安全性。160 位 ECC 加密安全性相当于 1024 位 RSA。

TLS 握手过程中（SSL 已废弃,现行协议为 TLS 1.3,现行规范为 RFC 9846）,客户端和服务器通过非对称加密算法协商对称加密算法的密钥,然后使用对称加密算法加密传输的数据。这样既保证了密钥交换的安全性,又提高了数据加密的效率。

## 11. 对称加密和非对称加密有什么区别

### 对称加密

对称加密使用相同的密钥进行数据的加密和解密。发送方和接收方必须在通信前共享这个密钥。

### 非对称加密

非对称加密使用一对密钥，即公钥和私钥。公钥可以公开，而私钥必须保密。


# 数据结构与算法

## 1. 1 亿个 IP 地址，如何去取 Top3

首先，将 1 亿个 IP 地址按照哈希函数的值分成 1024 个桶，每个桶里面存储的是对应哈希值的 IP 地址。然后，对于每个桶，用哈希表统计桶内每个 IP 的出现次数，并取出该桶内出现次数最多的若干个 IP 作为候选（注意：不能只取每个桶的 Top1，因为全局 Top3 可能集中在同一个桶内，那样会漏掉真正的 Top3）。最后，将所有桶取出的候选 IP 按出现次数合并排序，取出前三个即可。

## 2. 七大排序时间复杂度与稳定性

七大排序算法的时间复杂度和稳定性如下：

| 排序算法 |       时间复杂度      | 稳定性 |
| :--: | :--------------: | :-: |
| 冒泡排序 |      O(n^2)      |  稳定 |
| 选择排序 |      O(n^2)      | 不稳定 |
| 插入排序 |      O(n^2)      |  稳定 |
| 希尔排序 | O(nlogn)\~O(n^2) | 不稳定 |
| 快速排序 | O(nlogn)\~O(n^2) | 不稳定 |
| 归并排序 |     O(nlogn)     |  稳定 |
|  堆排序 |     O(nlogn)     | 不稳定 |

## 3. 快速排序、归并排序、堆排序

* 快速排序：快速排序是一种基于分治思想的高效排序算法。它的基本思想是通过一趟排序将要排序的数据分割成独立的两部分，其中一部分的所有数据都比另外一部分的所有数据都要小，然后再按此方法对这两部分数据分别进行快速排序，整个排序过程可以递归进行，以此达到整个数据变成有序序列。
* 归并排序：归并排序是一种基于分治思想的高效排序算法。它的基本思想是将待排元素分成大小大致相等的两个子集合，分别对这两个子集合进行递归处理，直到子集合中只有一个元素为止，然后将两个子集合合并成一个有序序列。
* 堆排序：堆排序是一种基于堆结构的高效排序算法。它的基本思想是将待排元素构造成一个堆，然后依次取出堆顶元素（最大或最小），再将剩余元素重新构造成一个堆，重复上述操作直到所有元素都被取出。

## 4. 令牌桶算法原理

令牌桶算法是一种流量整形算法，它可以平滑地限制数据的传输速率，同时允许一定的突发流量。令牌桶算法的原理是系统会以一个恒定的速率往桶里放入令牌，桶有容量上限（超过则丢弃新令牌），而如果请求需要被处理，则需要先从桶里获取一个令牌；桶里有令牌时请求可以被立即处理（突发时桶内积攒的令牌可以一次性消耗），当桶里没有令牌可取时，请求则需要排队等待或被丢弃（依实现而定）。

## 5. 红黑树、avl 的复杂度

红黑树和 AVL 树都是常用的平衡二叉树，它们查找、插入、删除的时间复杂度都是 O(logn)。AVL 树是严格平衡的（任意节点的左右子树高度差不超过 1），树更矮，因此查找效率略高于红黑树；红黑树是近似平衡（平衡条件更宽松），插入和删除时所需的旋转和重新着色次数比 AVL 树更少（AVL 插入/删除后可能需要 O(logn) 次旋转，而红黑树平均只需常数次），因此维护成本更低，在频繁插入和删除的场景下统计性能更好。（注：上述为传统主流观点；2024 年基准研究 arXiv:2406.05162 表明，在某些工作负载下 AVL 树的插入/删除反而更快，两派结论均有实验支撑。）

## 6. 红黑树简介

红黑树是一种自平衡的二叉搜索树，它在计算机科学中被广泛应用。它的名称来自于树中节点的颜色，每个节点要么是红色，要么是黑色。红黑树具有以下特性：

1. 根节点是黑色的。
2. 每个叶子节点 (NIL 节点，空节点) 都是黑色的。
3. 如果一个节点是红色的，则它的两个子节点都是黑色的。
4. 从任意节点到其每个叶子节点的路径上包含相同数量的黑色节点。
5. 由上述性质可推出平衡保证：从任意节点到其叶子节点的所有简单路径中，最长路径的长度不超过最短路径的两倍（每条路径上黑色节点数量相同，且红色节点不能连续出现，因此最长路径上的红色节点数至多等于黑色节点数）。

这些特性保证了红黑树的平衡性，使得它的高度始终保持在可接受的范围内。红黑树的高度是 O(logn)，其中 n 是树中节点的数量。

红黑树的自平衡性是通过在插入和删除操作后进行调整来实现的。插入和删除操作可能会破坏红黑树的特性，但通过一系列的旋转和重新着色操作，可以保持红黑树的平衡性。

红黑树在许多领域都有广泛的应用，特别是在需要高效地进行插入、删除和搜索操作的场景中。它的时间复杂度在平均和最坏情况下都是 O(log n)，使得它成为一种非常有效的数据结构。

## 7. 哈希查找时间复杂度

哈希查找的时间复杂度通常被认为是 O(1)。这是因为哈希表利用哈希函数将键映射到数组的特定位置，使得对于任意给定的键，可以通过哈希函数快速计算出其对应的存储位置。在平均情况下，哈希表的搜索、插入和删除操作的时间复杂度都是 O(1)。然而，在最坏情况下，如果发生大量的哈希冲突，哈希表的性能会退化成 O(n)。

## 8. 快排为什么不稳定

当快速排序中的枢纽元与其他元素进行交换时，可能会改变相同键值的元素的相对顺序。例如，如果有两个相同的元素 A 和 B，且 A 在 B 的前面，但在排序过程中，A 被移动到了 B 的后面，那么相同键值的元素的相对顺序就发生了改变。这就是快速排序不稳定的一个例子。

## 9. 红黑树属性

1. 节点颜色：每个节点要么是红色，要么是黑色。
2. 根节点颜色：根节点必须是黑色。
3. 叶子节点颜色：叶子节点 (NIL 节点或空节点) 都是黑色。
4. 颜色约束：红色节点的子节点必须是黑色。换句话说，不能有两个连续的红色节点。
5. 黑高度：从任意节点（不包含该节点本身）到其每个叶子节点的路径上，黑色节点的数量都相同，这个数量称为该节点的黑高度。

## 10. 跳表原理

跳表基于链表实现，通过在原始链表的基础上建立多层索引来加速查找操作。跳表的原理是通过每两个节点抽出一个节点，建立索引层，从而减少查找路径，降低查找时间复杂度。具体原理如下：

1. 遍历有序链表时，需要从头节点开始逐个节点遍历，直到找到目标节点。这种遍历方式的时间复杂度是 O(n)。
2. 跳表的思想是每两个节点抽出一个节点，建立第一层索引。第一层索引的节点个数是原始链表节点个数的一半。
3. 在第一层索引的基础上，再每两个节点抽出一个节点，建立第二层索引。第二层索引的节点个数是第一层索引节点个数的一半。
4. 以此类推，建立多层索引，直到达到某个条件（如节点个数小于等于 2）为止。
5. 当需要查找数据时，从最高层索引开始，逐层向下查找，直到找到目标节点或者找不到为止。
6. 在每一层索引中，通过比较节点的值，确定下一步的查找方向，可以快速缩小查找范围。
7. 如果在某一层索引中找到目标节点，则返回该节点；如果在最底层索引中仍然找不到目标节点，则表示数据不存在。

跳表的查询时间复杂度平均为 O(log⁡(n))（期望复杂度；随机化结果最差时可能退化为 O(n)），其中 n 是原始链表的节点个数。跳表的插入和删除操作也可以通过类似的方式进行实现，时间复杂度也是 O(log⁡(n))。跳表的优势在于可以在不使用平衡树的情况下，实现高效的查找、插入和删除操作。

## 11. 有哪些常见的二叉树

1. **满二叉树**：
   * 每个非叶子节点都有两个子节点，且所有叶子节点都在同一层。（注：" 满二叉树 " 存在两种定义，另一种定义为 " 每个节点要么没有子节点、要么有两个子节点 "，不要求叶子节点在同一层；两种定义在不同教材中均有使用。）
2. **完全二叉树**：
   * 除了最后一层外，每层的节点数都达到最大，且最后一层的节点集中在最左边。
3. **二叉搜索树（Binary Search Tree, BST）**：
   * 对于每个节点，左子树的所有节点值小于该节点，右子树的所有节点值大于该节点。中序遍历该树会得到一个有序序列。
4. **平衡二叉树**：
   * 也称为 AVL 树，要求每个节点的左右子树高度差不超过 1，确保树的高度保持在对数级别，从而提高查找效率。
5. **红黑树**：
   * 一种自平衡的二叉搜索树，每个节点有颜色属性（红或黑），并遵循特定的性质以保持树的平衡。
6. **哈夫曼树**：
   * 一种带权路径长度最小的二叉树，通常用于数据压缩。

## 12. 跳表和红黑树的优劣对比

### 时间复杂度

* **查询**：跳表和红黑树的平均时间复杂度均为 O(log⁡n)，但在最坏情况下，跳表的查询复杂度可能达到 O(n)，而红黑树则保持在 O(log⁡n)。
* **插入和删除**：跳表的插入和删除操作相对简单，通常只涉及指针的调整，时间复杂度为 O(log⁡n)。红黑树的插入和删除则需要进行复杂的平衡调整，旋转和着色操作，因此实现较为复杂，但时间复杂度同样为 O(log⁡n)。

### 空间复杂度

跳表的空间复杂度较高，因为它需要额外的指针来维护多层结构，通常为 O(n)。相比之下，红黑树的每个节点只需要存储颜色信息和指向父节点的指针，整体上占用的内存较少。（注：这一对比存在争议：跳表每个节点平均约需 2 个前向指针，而红黑树每个节点需要 left/right/parent 共 3 个指针外加颜色位，实际两者内存开销差异不大，甚至红黑树可能更高，具体取决于实现。）

### 实现复杂度

* **跳表**：实现较为简单，易于理解和调试，尤其适合快速开发和原型设计。跳表的结构类似于链表，插入和删除操作相对直接。
* **红黑树**：实现复杂，需要处理多种情况以保持树的平衡，涉及旋转和颜色调整，增加了开发和维护的难度。

### 并发性能

在并发环境下，跳表的更新操作相对较少，锁的竞争较低，因此在多线程环境中表现良好。而红黑树在并发操作时，由于其复杂的平衡调整，可能会导致更多的锁竞争。

### 应用场景

* **跳表**：由于其简单性和良好的性能，跳表常用于需要快速插入、删除和查找的场景，如 Redis 的有序集合（zset）实现中。
* **红黑树**：适用于对性能要求较高且需要频繁查找的场景，如许多标准库中的集合实现，特别是在需要保持严格平衡时。


# 业务场景

## 1. 强制用户下线，让其无法再次登录怎么设计？

**会话管理:** 当用户登录成功时，你可以在服务器端为该用户生成一个唯一的会话 ID，并且在用户的设备上设置一个相应的 cookie。用户后续每次发起请求时，服务器都会检查这个 cookie 来确认用户的身份。当你想要强制用户下线时，只需要在服务器上销毁这个会话 ID,然后用户的设备就不能再用这个 cookie 来验证自己了。

**更改用户状态:** 在用户的数据库记录中添加一个字段，比如叫做 " 是否被禁止 "。当你想要强制用户下线时，将这个字段设置为 " 是 "，那么下次用户试图登录时，系统会因为这个字段是 " 是 " 而拒绝用户的登录请求。

> 当黑名单用户由运营配置时可以使用配置中心配置黑名单用户

## 2. 多个客户端单一账号怎么禁止登录？

1. 核心思想是使用 Redis 来存储每个账户的 SessionID。当用户登录时，服务器验证其凭证，并生成一个新的 SessionID。
2. 当服务器收到客户端的请求时，它会检查该请求的 SessionID。如果 SessionID 匹配存储在服务器上的 ID，那么请求将被处理；否则，请求将被拒绝。
3. 当服务端接收到一个新的登录请求时，它会创建一个新的 SessionID，并改变存储在服务器上的 SessionID。这意味着旧的客户端将不能再通过旧的 SessionID 发送请求，因此它被迫注销。

这种方法的一个潜在问题是，如果旧客户端由于无法访问网络而没有收到注销信号，那么它可能会持续认为自己仍在登录状态。为解决此问题，客户端应设计为在检测到网络恢复后尝试向服务器发送心跳。如果心跳被拒，那么客户端应自动注销，并提示用户重新登录。

## 3. 限流器的设计

### 固定窗口

利用 Redis 的原子自增和过期淘汰策略

&#x20; \- 初次调用时直接设置为 1，并设置过期时间为 1s。在这一秒以内的后续调用，每次都自增 1，客户端拿到自增后的值如果没有超过限制 10000 就放行。   - 细节实现：为避免超限后无谓的 redis 调用，第一次发现超限时可以记录该值的 TTL 时间，例如只过去 100ms 就有 1w 个请求过来，剩下的 900ms 就不用请求 redis 而是直接返回超限即可。   - 精度可调节。假如限流阈值很大，比如 100w，可以改用 INCRBY 每次累加 100（客户端攒批），那么 Redis 的访问 QPS 直接降低 100 倍，为 1w QPS（代价是限流精度变为 100 的粒度）

### 优化

* 滑动窗口 你可以使用 Redis 提供的有序集合（sorted set）数据结构来存储每一次请求的时间戳，用 score 来表示请求的时间。每次来新的请求，都添加到有序集合中。然后，根据当前的时间戳，删除窗口之外的请求记录，并查看当前窗口内的请求数量是否超过阈值。
* 令牌桶 可以设定每秒钟往桶中放入 100 个令牌，如果有新的请求进来，就从桶中拿走一个令牌，如果桶中没有令牌了，新的请求就需要等待或者被丢弃，这样就可以保护系统不会被突然的大流量压垮。

### 超高 QPS 优化

使用令牌桶 + 本地缓存：每次按固定步长从 Redis 批量取一批令牌到本地，本地令牌消耗完后再到 Redis 中补充，大幅减少 Redis 压力（本质是本地预取 + 异步补充）。如果是突发的高 QPS 流量，可以按实时速率动态调整步长。

## 4. 短链接系统设计

1. 长网址到短网址转换: 主流方案是"发号器 + 进制转换"：由发号器为每个长网址分配一个唯一的数值 ID，再把 ID 转换成 62 进制（0-9、a-z、A-Z 共 62 个字符）的字符串作为短码，例如 ID=1000 对应短码 "g8"。另一种思路是使用哈希算法（MD5/Sha256）截取前若干位作为短码，但这种方式存在问题：一是不同长网址哈希截取后可能碰撞（MD5 截取前 6 位只有 16^6 种组合，长网址一多必然冲突）；二是短码可被枚举/猜测，存在可预测性问题。因此哈希截断只适合小型或原型系统，生产环境建议采用发号器方案。
2. 发号器设计：为每个长地址分配一个号码 ID。主流做法是数据库自增 ID 或号段模式（如美团 Leaf，一次批量取一段号，避免每次请求都访问数据库），也可以用雪花算法生成分布式 ID；发号后把短码与长网址的映射写入数据库并建立短码唯一索引，防止短码冲突和同一长网址重复生成。
3. 二义性（碰撞）检查：生成短码后需要确认该短码未被占用。可以用布隆过滤器快速判断短码是否已存在（已存在则重新发号），由于布隆过滤器有误判率，最终要以数据库短码唯一索引为准。
4. 短网址到长网址转换: 需要创建一个数据库或者使用缓存服务器来存储长网址和短网址的映射关系。当用户访问短网址时，系统将查找对应的长网址并进行跳转。

## 5. 文章浏览量计数系统设计

**使用 Redis 存储统计数据:** 后端首先判断 Redis 里是否已有当前 ip 对这篇文章的浏览记录，这个 key 为：`isViewed:articleId:ip`。如果有，就说明之前浏览过，就什么也不做，直接返回。如果没有就加上这个 key,并在 Redis 里给这篇文章的浏览量 +1 和记录时间戳(Hash 类型)。Redis 支持原子操作，所以不用担心并发问题。key 为 `viewCount:articleId`,value 为缓存的浏览量。

**定时任务同步数据库**:每 5 分钟，去 Redis 里拿缓存的浏览量，拿到后就更新到数据库里，并把 Redis 的数据清零。为了防止并发带来的问题，这里应该是拿到 m,就在 Redis 里减去 m,而不是直接设置为 0。顺便删除超过十天未再更新的 key。

对于大并发的写入压力问题可以采用如下策略：

**消息队列缓冲：** 对于大量的更新统计数据请求，我们可以先放入消息队列中，然后由后台服务逐渐消费这些请求，这样可以平滑处理瞬间的大量请求。

对于去重问题，我们可以采用以下方案：

**使用布隆过滤器：** 对于同一个用户发起的重复的计数请求，我们可以使用布隆过滤器进行去重。布隆过滤器是一种空间效率极高的概率型数据结构，能够判别一个元素是否在集合中。

## 6. IM 消息服务设计

[从新手到专家：如何设计一套亿级消息量的分布式IM系统](http://www.52im.net/thread-3472-1-1.html)

### 读写扩散

**读扩散的优点（时间线模型）：**

* 1）写操作（发消息）很轻量，不管是单聊还是群聊，只需要往自己的发件时间线写一次就好了；
* 2）读扩散模型天然保存了完整消息流，可以方便地查看聊天记录和进行搜索。

**读扩散的缺点：** 读操作（读消息）很重，在复杂业务下，读扩散需要把多个会话时间线里的消息合并、排序后才是一条完整的时间线，合并逻辑复杂。

**写扩散优点：**

* 1）读操作很轻量；
* 2）可以很方便地做消息的多终端同步。

**写扩散缺点：** 写操作很重，尤其是对于群聊来说（因为如果群成员很多的话，1 条消息源要扩散写成“成员数 -1”条目标消息，这是很恐怖的）。

### 推拉模式

* 推模式：有新消息时服务器主动推给所有端（iOS、Android、PC 等）；
* 拉模式：由前端主动发起拉取消息的请求，为了保证消息的实时性，一般采用推模式，拉模式一般用于获取历史消息；
* 推拉结合模式：有新消息时服务器会先推一个有新消息的通知给前端，前端接收到通知后就向服务器拉取消息。

**可以使用推拉结合模式解决推模式可能会丢消息的问题：** 即在用户发新消息时服务器推送一个通知，然后前端请求最新消息列表，为了防止有消息丢失，可以再每隔一段时间主动请求一次。可以看出，使用推拉结合模式最好是用写扩散，因为写扩散只需要拉一条时间线的个人信箱就好了，而读扩散有 N 条时间线（每个信箱一条），如果也定时拉取的话性能会很差。

## 7. 秒杀系统设计

用户层：用户发出请求后，首先到达系统的前端，我们可以在这个环节加入图形验证码、滑动验证码等反爬虫措施，防止恶意刷单和机器人参与抢购。

限流层：用户请求通过反爬虫措施后，接着到达限流层。在这个阶段，我们可以采用比如令牌桶算法来限制流量。如果请求超出了设定的阈值，那么超出部分的请求将不会被处理，直接返回系统繁忙的消息给用户。

队列层：限流层通过后，用户请求会进入到队列层, 我们可以使用消息队列（如 Kafka, RabbitMQ) 等技术，异步处理用户请求，缓解高并发带来的压力。

业务处理层：这个是秒杀业务处理的核心部分。从队列中取出用户请求，进行库存判断。判断成功后，进行减库操作，并生成订单等业务操作。判断库存和减库操作需要保证原子性，可以采用数据库事务进行处理。为了保证数据一致性，我们可以引入乐观锁或者悲观锁的概念。

### 库存判断与减库操作

为了保证库存判断和减库操作的原子性，我们需要在数据库级别或者服务级别做控制。

1. 数据库事务： 利用数据库事务的原子性，来保证库存的查询和减库在一个事务中完成。简单说，我们可以先查询库存，然后判断库存数量，最后减少库存，这一整个过程在一个事务中，不会受到其他操作的干扰。此外，此操作中应使用悲观锁或者乐观锁保障并发操作下减库的正确性。
2. 借助 Redis 单线程执行 + Lua 脚本中的逻辑可以在一次执行中顺序完成的特性达到原子性。
3. 乐观锁：每次先获取商品记录版本号，然后减库操作时候带上版本号。只有当版本号和服务器版本号一致时，才会减库，减库同时升级版本号。因此，只有最早获取版本号的线程可以成功扣库。
4. 悲观锁：悲观锁与乐观锁相对，认为并发冲突大概率发生，因此"先加锁、再操作"。秒杀减库场景的典型实现是 `SELECT ... FOR UPDATE` 对库存行加排他锁，锁住后再判断库存并更新，其他事务必须等待锁释放。注意：读锁（共享锁）和写锁（排他锁）是数据库锁的类型划分，并不等同于悲观锁本身；悲观锁强调的是一种"先取锁后操作"的策略。

### 重复下单问题

MQ 在整个秒杀流程中扮演了很重要的角色，因为下单数据全部暂存在 MQ 中，一旦消费者重复消费，就有可能出现一个用户秒杀到两个商品的重复下单情况。

解决方案：实现幂等，利用 Redis，发送消息时指定消息的全局唯一 id；收到消息后查询 Redis 是否有该 id，有则说明是重复消息。然后立即将 id 存入 Redis 中。

业务校验：生成订单时校验用户是否已经秒杀过该商品。（MySQL 的唯一索引）

### 下单消息丢失

**生产者丢失消息**

（1）使用事务； （2）使用 confirm 模式：在这种模式下，一旦消息发送至服务器，服务器会返回一个确认给生产者，这样生产者知道消息已成功达到目的地。如果没有收到确认，生产者知道消息没有送达，可以尝试重新发送。 （3）异步监听确认模式：这是一种特殊的 Confirm 模式，其中生产者不等待每个消息的确认，而是将它们发送出去并继续处理其他事务。同时，它们在监听确认——如果有未确认的消息，则会重新发送。

**MQ 丢失消息**

开启消息持久化，消息将被写入在服务器崩溃情况下能够保存的地方，比如硬盘。这样，即使服务器崩溃，消息也不会丢失，并且在服务器恢复时可以被重新加载到队列中。

**消费者丢失消息**

消费者消费完成后回执确认，如果一段时间后 MQ 没有收到消费者的回执确认，MQ 就认为消息没有被成功消费，会将消息重新发送给其他消费者。

### 如何处理恶意下单请求

1. 首先保证刷单者最多也只能刷到一件商品：真正的下单逻辑校验一个用户只能购买一件商品，可以把**用户 id**和**商品 id**作为**联合主键索引**存储到数据库中，重复购买会自动报错；
2. 重复提交校验；
3. 验证码机制；

## 8. 海量评论系统

1. **数据分片方案** 一种策略是使用一致性哈希算法进行数据分片：将评论 ID 哈希后映射到不同的存储节点上。当添加或删除节点时，只需要迁移少量数据（涉及到的重新分配的数据量很小），避免了大范围 rehash 导致的数据迁移问题。
2. **索引和翻页优化** 为了提高翻页效率，可以使用二级索引，将父 ID 和子 ID 关联起来，比如你可以使用\<PostID, CommentID 列表>的形式存储评论。然后，对评论进行排序以便于分页。当然，前几页的数据访问频率最高，如果在内存中缓存它们，可以进一步提高读取性能。你还可以利用 Redis 的 LRU 淘汰策略来管理缓存。
3. **冷热数据处理** 我们可以设定一个基准让访问频次高于这个基准的数据被标记为热数据，针对读取频繁的热数据，可以利用 Redis，把热门数据缓存起来，提高数据读取速度。
4. **突发流量的处理** 对突发流量这种场景，我们可以考虑使用消息队列，比如 RabbitMQ，Kafka 等。这样，当瞬时流量超过系统处理能力时，请求可以先进入队列中，然后系统按自己的处理能力进行处理，避免系统因为突然的流量增加而崩溃。
5. **按照热度排序** 对于按照热度排序，可以依靠 Redis 的 Zset 进行处理。Zset 中的元素是唯一的，对于每一篇文章，可以建立一个 Zset，评论 ID 作为 member，热度作为 score 进行存储，这样就能快速地获取热度排名的评论。
6. **处理评论删除** 对评论的删除，我们可以采取标记删除的方式，也就是并不真实删除数据，只是添加一个标记。等到业务高峰过去，系统空闲的时候，进行删除操作。对于索引的更新，也遵循相同的原则，将修改标记下来，在系统空闲时进行更新。

## 9. 推送去重系统设计

使用位图 (BitMap) 进行数据存储，若用户池子是一千万，一千万 bit ≈ 1.25MB（10,000,000 bit ÷ 8 = 1.25MB）。为了节约更多存储，我们还可以进一步压缩 bitmap 的大小。使用的时候算一下 hash(userId) % size(bitmap) 即可。在存储上当然是越小越好的。但是小了会冲突严重，大了会浪费存储。

布隆过滤为了降低 hash 碰撞，引入了多个 hash 函数。插入元素时，字符串经过 k 次 hash 函数计算 (可以是不同 hash 函数)，将映射到 bitmap 相应位置的 bit 位置为 1。查询时同样需要 k 个位置的 bit 为 1，元素才算存在。

针对数据清除的问题，我们可以采取定期清理的策略，如每天清理一次，将无效的、过期的数据清除出去，保持数据的实时性。


# Go 底层设计

## 数组与编译

```go
arr1 := [3]int{1, 2, 3}
arr2 := [...]int{1, 2, 3}
```

上述两种声明方式在运行期间得到的结果是完全相同的，后一种声明方式在编译期间就会被转换成前一种，这也就是编译器对数组大小的推导。

1. 当元素数量小于或者等于 4 个时，会直接将数组中的元素放置在栈上；
2. 当元素数量大于 4 个时，会将数组中的元素放置到静态区并在运行时取出；

数组访问越界是非常严重的错误，Go 语言中可以在编译期间的静态类型检查判断数组越界，数组和字符串的一些简单越界错误都会在编译期间发现，例如：直接使用整数或者常量访问数组；但是如果使用变量去访问数组或者字符串时，编译器就无法提前发现错误，我们需要 Go 语言运行时阻止不合法的访问：

```go
arr[4]: invalid array index 4 (out of bounds for 3-element array)
arr[i]: panic: runtime error: index out of range [4] with length 3
```

Go 语言运行时在发现数组、切片和字符串的越界操作会由运行时的 [`runtime.panicIndex`](https://draveness.me/golang/tree/runtime.panicIndex) 和 [`runtime.goPanicIndex`](https://draveness.me/golang/tree/runtime.goPanicIndex) 触发程序的运行时错误并导致崩溃退出。

## 切片与编译

从切片的定义我们能推测出，切片在编译期间的生成的类型只会包含切片中的元素类型，即 `int` 或者 `interface{}` 等。

使用 `append` 关键字向切片中追加元素也是常见的切片操作，中间代码生成阶段的 [`cmd/compile/internal/gc.state.append`](https://draveness.me/golang/tree/cmd/compile/internal/gc.state.append) 方法会根据返回值是否会覆盖原变量，选择进入两种流程，最大的区别在于得到的新切片是否会赋值回原变量。如果我们选择覆盖原有的变量，就不需要担心切片发生拷贝影响性能，因为 Go 语言编译器已经对这种常见的情况做出了优化。

相比于依次拷贝元素，[`runtime.memmove`](https://draveness.me/golang/tree/runtime.memmove) 能够提供更好的性能。需要注意的是，整块拷贝内存仍然会占用非常多的资源，在大切片上执行拷贝操作时一定要注意对性能的影响。

## 理解 Go 中哈希表的原理

### 数据结构

```go
type hmap struct {
	count     int
	flags     uint8
	B         uint8
	noverflow uint16
	hash0     uint32

	buckets    unsafe.Pointer
	oldbuckets unsafe.Pointer
	nevacuate  uintptr

	extra *mapextra
}

type mapextra struct {
	overflow    *[]*bmap
	oldoverflow *[]*bmap
	nextOverflow *bmap
}
```

1. `count` 表示当前哈希表中的元素数量；
2. `B` 表示当前哈希表持有的 `buckets` 数量，但是因为哈希表中桶的数量都 2 的倍数，所以该字段会存储对数，也就是 `len(buckets) == 2^B`；
3. `hash0` 是哈希的种子，它能为哈希函数的结果引入随机性，这个值在创建哈希表时确定，并在调用哈希函数时作为参数传入；
4. `oldbuckets` 是哈希在扩容时用于保存之前 `buckets` 的字段，它的大小是当前 `buckets` 的一半；

哈希表 [`runtime.hmap`](https://draveness.me/golang/tree/runtime.hmap) 的桶是 [`runtime.bmap`](https://draveness.me/golang/tree/runtime.bmap)。每一个 [`runtime.bmap`](https://draveness.me/golang/tree/runtime.bmap) 都能存储 8 个键值对，当哈希表中存储的数据过多，单个桶已经装满时就会使用 `extra.nextOverflow` 中桶存储溢出的数据。

上述两种不同的桶在内存中是连续存储的，我们在这里将它们分别称为正常桶和溢出桶，上图中黄色的 [`runtime.bmap`](https://draveness.me/golang/tree/runtime.bmap) 就是正常桶，绿色的 [`runtime.bmap`](https://draveness.me/golang/tree/runtime.bmap) 是溢出桶，溢出桶是在 Go 语言还使用 C 语言实现时使用的设计，由于它能够减少扩容的频率所以一直使用至今。

桶的结构体 [`runtime.bmap`](https://draveness.me/golang/tree/runtime.bmap) 在 Go 语言源代码中的定义只包含一个简单的 `tophash` 字段，`tophash` 存储了键的哈希的高 8 位，通过比较不同键的哈希的高 8 位可以减少访问键值对次数以提高性能：

```go
type bmap struct {
	tophash [bucketCnt]uint8
}
```

### 初始化

#### 字面量

```go
hash := map[string]int{
	"1": 2,
	"3": 4,
	"5": 6,
}
```

当哈希表中的元素数量少于或者等于 25 个时，编译器会将字面量初始化的结构体转换成以下的代码，将所有的键值对一次加入到哈希表中：

```go
hash := make(map[string]int, 3)
hash["1"] = 2
hash["3"] = 4
hash["5"] = 6
```

这种初始化的方式与的 [数组](https://draveness.me/golang/docs/part2-foundation/ch03-datastructure/golang-array/) 和 [切片](https://draveness.me/golang/docs/part2-foundation/ch03-datastructure/golang-array-and-slice/) 几乎完全相同，由此看来集合类型的初始化在 Go 语言中有着相同的处理逻辑。

一旦哈希表中元素的数量超过了 25 个，编译器会创建两个数组分别存储键和值，这些键值对会通过 for 循环加入哈希。

不过无论使用哪种方法，使用字面量初始化的过程都会使用 Go 语言中的关键字 `make` 来创建新的哈希并通过最原始的 `[]` 语法向哈希追加元素。

#### 运行时

当创建的哈希被分配到栈上并且其容量小于 `BUCKETSIZE = 8` 时，Go 语言在编译阶段会快速初始化哈希，这也是编译器对小容量的哈希做的优化。 除了上述特定的优化之外，无论 `make` 是从哪里来的，只要我们使用 `make` 创建哈希，Go 语言编译器都会在 [类型检查](https://draveness.me/golang/docs/part1-prerequisite/ch02-compile/golang-typecheck/) 期间将它们转换成 [`runtime.makemap`](https://draveness.me/golang/tree/runtime.makemap)，使用字面量初始化哈希也只是语言提供的辅助工具，最后调用的都是 [`runtime.makemap`](https://draveness.me/golang/tree/runtime.makemap)。

这个函数会按照下面的步骤执行：

1. 计算哈希占用的内存是否溢出或者超出能分配的最大值；
2. 调用 [`runtime.fastrand`](https://draveness.me/golang/tree/runtime.fastrand) 获取一个随机的哈希种子；
3. 根据传入的 `hint` 计算出需要的最小需要的桶的数量；
4. 使用 [`runtime.makeBucketArray`](https://draveness.me/golang/tree/runtime.makeBucketArray) 创建用于保存桶的数组；[`runtime.makeBucketArray`](https://draveness.me/golang/tree/runtime.makeBucketArray) 会根据传入的 `B` 计算出的需要创建的桶数量并在内存中分配一片连续的空间用于存储数据

* 当桶的数量小于 2^4 时，由于数据较少、使用溢出桶的可能性较低，会省略创建的过程以减少额外开销；
* 当桶的数量多于 2^4 时，会额外创建 2^(B-4) 个溢出桶；

### 读写操作

#### 访问

[`runtime.mapaccess1`](https://draveness.me/golang/tree/runtime.mapaccess1) 会先通过哈希表设置的哈希函数、种子获取当前键对应的哈希，再通过 [`runtime.bucketMask`](https://draveness.me/golang/tree/runtime.bucketMask) 和 [`runtime.add`](https://draveness.me/golang/tree/runtime.add) 拿到该键值对所在的桶序号和哈希高位的 8 位数字。哈希会依次遍历正常桶和溢出桶中的数据，它会先比较哈希的高 8 位和桶中存储的 `tophash`，后比较传入的和桶中的值以加速数据的读写。用于选择桶序号的是哈希的最低几位，而用于加速访问的是哈希的高 8 位，这种设计能够减少同一个桶中有大量相等 `tophash` 的概率影响性能。

每一个桶都是一整片的内存空间，当发现桶中的 `tophash` 与传入键的 `tophash` 匹配之后，我们会通过指针和偏移量获取哈希中存储的键 `keys[0]` 并与 `key` 比较，如果两者相同就会获取目标值的指针 `values[0]` 并返回。

另一个同样用于访问哈希表中数据的 [`runtime.mapaccess2`](https://draveness.me/golang/tree/runtime.mapaccess2) 只是在 [`runtime.mapaccess1`](https://draveness.me/golang/tree/runtime.mapaccess1) 的基础上多返回了一个标识键值对是否存在的 `bool` 值。

#### 写入

函数会根据传入的键拿到对应的哈希和桶，然后通过遍历比较桶中存储的 `tophash` 和键的哈希，如果找到了相同结果就会返回目标位置的地址。其中 `inserti` 表示目标元素的在桶中的索引，`insertk` 和 `val` 分别表示键值对的地址，获得目标地址之后会通过算术计算寻址获得键值对 `k` 和 `val`。

上述的 for 循环会依次遍历正常桶和溢出桶中存储的数据，整个过程会分别判断 `tophash` 是否相等、`key` 是否相等，遍历结束后会从循环中跳出。

如果当前桶已经满了，哈希会调用 [`runtime.hmap.newoverflow`](https://draveness.me/golang/tree/runtime.hmap.newoverflow) 创建新桶或者使用 [`runtime.hmap`](https://draveness.me/golang/tree/runtime.hmap) 预先在 `noverflow` 中创建好的桶来保存数据，新创建的桶不仅会被追加到已有桶的末尾，还会增加哈希表的 `noverflow` 计数器。

#### 扩容

[`runtime.mapassign`](https://draveness.me/golang/tree/runtime.mapassign) 函数会在以下两种情况发生时触发哈希的扩容：

1. 装载因子已经超过 6.5；
2. 哈希使用了太多溢出桶；

根据触发的条件不同扩容的方式分成两种，如果这次扩容是溢出的桶太多导致的，那么这次扩容就是等量扩容 `sameSizeGrow`。

哈希在扩容的过程中会通过 [`runtime.makeBucketArray`](https://draveness.me/golang/tree/runtime.makeBucketArray) 创建一组新桶和预创建的溢出桶，随后将原有的桶数组设置到 `oldbuckets` 上并将新的空桶设置到 `buckets` 上，溢出桶也使用了相同的逻辑更新。

我们在 [`runtime.hashGrow`](https://draveness.me/golang/tree/runtime.hashGrow) 中还看不出来等量扩容和翻倍扩容的太多区别，等量扩容创建的新桶数量只是和旧桶一样，该函数中只是创建了新的桶，并没有对数据进行拷贝和转移。哈希表的数据迁移的过程在是 [`runtime.evacuate`](https://draveness.me/golang/tree/runtime.evacuate) 中完成的，它会对传入桶中的元素进行再分配。

[`runtime.evacuate`](https://draveness.me/golang/tree/runtime.evacuate) 会将一个旧桶中的数据分流到两个新桶，所以它会创建两个用于保存分配上下文的 [`runtime.evacDst`](https://draveness.me/golang/tree/runtime.evacDst) 结构体，这两个结构体分别指向了一个新桶。

如果这是等量扩容，那么旧桶与新桶之间是一对一的关系，所以两个 [`runtime.evacDst`](https://draveness.me/golang/tree/runtime.evacDst) 只会初始化一个。而当哈希表的容量翻倍时，每个旧桶的元素会都分流到新创建的两个桶中。

> 我们简单总结一下哈希表扩容的设计和原理，哈希在存储元素过多时会触发扩容操作，每次都会将桶的数量翻倍，扩容过程不是原子的，而是通过 [`runtime.growWork`](https://draveness.me/golang/tree/runtime.growWork) 增量触发的，在扩容期间访问哈希表时会使用旧桶，向哈希表写入数据时会触发旧桶元素的分流。除了这种正常的扩容之外，为了解决大量写入、删除造成的内存泄漏问题，哈希引入了 `sameSizeGrow` 这一机制，在出现较多溢出桶时会整理哈希的内存减少空间的占用。

#### 删除

如果想要删除哈希中的元素，就需要使用 Go 语言中的 `delete` 关键字，这个关键字的唯一作用就是将某一个键对应的元素从哈希表中删除，无论是该键对应的值是否存在，这个内建的函数都不会返回任何的结果。

## 字符串原理设计

只读只意味着字符串会分配到只读的内存空间，但是 Go 语言只是不支持直接修改 `string` 类型变量的内存空间，我们仍然可以通过在 `string` 和 `[]byte` 类型之间反复转换实现修改这一目的：

1. 先将这段内存拷贝到堆或者栈上；
2. 将变量的类型转换成 `[]byte` 后并修改字节数据；
3. 将修改后的字节数组转换回 `string`；

与切片的结构体相比，字符串只少了一个表示容量的 `Cap` 字段，而正是因为切片在 Go 语言的运行时表示与字符串高度相似，所以我们经常会说字符串是一个只读的切片类型。

## 函数调用

Go 通过栈传递函数的参数和返回值，在调用函数之前会在栈上为返回值分配合适的内存空间，随后将入参从右到左按顺序压栈并拷贝参数，返回值会被存储到调用方预留好的栈空间上，我们可以简单总结出以下几条规则：

1. 通过堆栈传递参数，入栈的顺序是从右到左，而参数的计算是从左到右；
2. 函数返回值通过堆栈传递并由调用者预先分配内存空间；
3. 调用函数时都是传值，接收方会对入参进行复制再计算；

### 参数传递

不同语言会选择不同的方式传递参数，Go 语言选择了传值的方式，**无论是传递基本类型、结构体还是指针，都会对传递的参数进行拷贝**。

## 接口 itab 结构体设计

### itab 结构体

[`runtime.itab`](https://draveness.me/golang/tree/runtime.itab) 结构体是接口类型的核心组成部分，每一个 [`runtime.itab`](https://draveness.me/golang/tree/runtime.itab) 都占 32 字节，我们可以将其看成接口类型和具体类型的组合，它们分别用 `inter` 和 `_type` 两个字段表示：

```go
type itab struct { // 32 字节
	inter *interfacetype
	_type *_type
	hash  uint32
	_     [4]byte
	fun   [1]uintptr
}
```

Go

除了 `inter` 和 `_type` 两个用于表示类型的字段之外，上述结构体中的另外两个字段也有自己的作用：

* `hash` 是对 `_type.hash` 的拷贝，当我们想将 `interface` 类型转换成具体类型时，可以使用该字段快速判断目标类型和具体类型 [`runtime._type`](https://draveness.me/golang/tree/runtime._type) 是否一致；
* `fun` 是一个动态大小的数组，它是一个用于动态派发的虚函数表，存储了一组函数指针。虽然该变量被声明成大小固定的数组，但是在使用时会通过原始指针获取其中的数据，所以 `fun` 数组中保存的元素数量是不确定的；

## 反射法则

1. 从 `interface{}` 变量可以反射出反射对象；
2. 从反射对象可以获取 `interface{}` 变量；
3. 要修改反射对象，其值必须可设置；

```go
v := reflect.ValueOf(1)
v.Interface().(int)
```

从反射对象到接口值的过程是从接口值到反射对象的镜面过程，两个过程都需要经历两次转换：

* 从接口值到反射对象：
  * 从基本类型到接口类型的类型转换；
  * 从接口类型到反射对象的转换；
* 从反射对象到接口值：
  * 反射对象转换成接口类型；
  * 通过显式类型转换变成原始类型；

由于 Go 语言的函数调用都是传值的，所以我们得到的反射对象跟最开始的变量没有任何关系，那么直接修改反射对象无法改变原始变量，程序为了防止错误就会崩溃。

想要修改原变量只能使用如下的方法：

```go
func main() {
	i := 1
	v := reflect.ValueOf(&i)
	v.Elem().SetInt(10)
	fmt.Println(i)
}

$ go run reflect.go
10
```

1. 调用 [`reflect.ValueOf`](https://draveness.me/golang/tree/reflect.ValueOf) 获取变量指针；
2. 调用 [`reflect.Value.Elem`](https://draveness.me/golang/tree/reflect.Value.Elem) 获取指针指向的变量；
3. 调用 [`reflect.Value.SetInt`](https://draveness.me/golang/tree/reflect.Value.SetInt) 更新变量的值；

### 类型和值

当我们想要将一个变量转换成反射对象时，Go 语言会在编译期间完成类型转换，将变量的类型和值转换成了 `interface{}` 并等待运行期间使用 [`reflect`](https://golang.org/pkg/reflect/) 包获取接口中存储的信息。

### 更新变量

当我们想要更新 [`reflect.Value`](https://draveness.me/golang/tree/reflect.Value) 时，就需要调用 [`reflect.Value.Set`](https://draveness.me/golang/tree/reflect.Value.Set) 更新反射对象，该方法会调用 [`reflect.flag.mustBeAssignable`](https://draveness.me/golang/tree/reflect.flag.mustBeAssignable) 和 [`reflect.flag.mustBeExported`](https://draveness.me/golang/tree/reflect.flag.mustBeExported) 分别检查当前反射对象是否是可以被设置的以及字段是否是对外公开的：

```go
func (v Value) Set(x Value) {
	v.mustBeAssignable()
	x.mustBeExported()
	var target unsafe.Pointer
	if v.kind() == Interface {
		target = v.ptr
	}
	x = x.assignTo("reflect.Set", v.typ, target)
	typedmemmove(v.typ, v.ptr, x.ptr)
}
```

[`reflect.Value.Set`](https://draveness.me/golang/tree/reflect.Value.Set) 会调用 [`reflect.Value.assignTo`](https://draveness.me/golang/tree/reflect.Value.assignTo) 并返回一个新的反射对象，这个返回的反射对象指针会直接覆盖原反射变量。

[`reflect.Value.assignTo`](https://draveness.me/golang/tree/reflect.Value.assignTo) 会根据当前和被设置的反射对象类型创建一个新的 [`reflect.Value`](https://draveness.me/golang/tree/reflect.Value) 结构体：

* 如果两个反射对象的类型是可以被直接替换，就会直接返回目标反射对象；
* 如果当前反射对象是接口并且目标对象实现了接口，就会把目标对象简单包装成接口值；

在变量更新的过程中，[`reflect.Value.assignTo`](https://draveness.me/golang/tree/reflect.Value.assignTo) 返回的 [`reflect.Value`](https://draveness.me/golang/tree/reflect.Value) 中的指针会覆盖当前反射对象中的指针实现变量的更新。

### 实现协议

[`reflect`](https://golang.org/pkg/reflect/) 包还为我们提供了 [`reflect.rtype.Implements`](https://draveness.me/golang/tree/reflect.rtype.Implements) 方法可以用于判断某些类型是否遵循特定的接口。在 Go 语言中获取结构体的反射类型 [`reflect.Type`](https://draveness.me/golang/tree/reflect.Type) 还是比较容易的，但是想要获得接口类型需要通过以下方式：

```go
reflect.TypeOf((*<interface>)(nil)).Elem()
```

我们通过一个例子在介绍如何判断一个类型是否实现了某个接口。假设我们需要判断如下代码中的 `CustomError` 是否实现了 Go 语言标准库中的 `error` 接口：

```go
type CustomError struct{}

func (*CustomError) Error() string {
	return ""
}

func main() {
	typeOfError := reflect.TypeOf((*error)(nil)).Elem()
	customErrorPtr := reflect.TypeOf(&CustomError{})
	customError := reflect.TypeOf(CustomError{})

	fmt.Println(customErrorPtr.Implements(typeOfError)) // #=> true
	fmt.Println(customError.Implements(typeOfError)) // #=> false
}
```

## for 与 range

### 现象

#### 循环永动机

如果我们在遍历数组的同时修改数组的元素，能否得到一个永远都不会停止的循环呢？

```go
func main() {
	arr := []int{1, 2, 3}
	for _, v := range arr {
		arr = append(arr, v)
	}
	fmt.Println(arr)
}

$ go run main.go
1 2 3 1 2 3
```

上述代码的输出意味着循环只遍历了原始切片中的三个元素，我们在遍历切片时追加的元素不会增加循环的执行次数，所以循环最终还是停了下来。

对于所有的 range 循环，Go 语言都会在编译期将原切片或者数组赋值给一个新变量 `ha`，在赋值的过程中就发生了拷贝，而我们又通过 `len` 关键字预先获取了切片的长度，所以在循环中追加新的元素也不会改变循环执行的次数，这也就解释了循环永动机的现象。

#### 神奇的指针

第二个例子是使用 Go 语言经常会犯的错误。当我们在遍历一个数组时，如果获取 `range` 返回变量的地址并保存到另一个数组或者哈希时，会遇到令人困惑的现象，下面的代码会输出 “3 3 3”：

```go
func main() {
	arr := []int{1, 2, 3}
	newArr := []*int{}
	for _, v := range arr {
		newArr = append(newArr, &v)
	}
	for _, v := range newArr {
		fmt.Println(*v)
	}
}

$ go run main.go
3 3 3
```

一些有经验的开发者不经意也会犯这种错误，正确的做法应该是使用 `&arr[i]` 替代 `&v`。

#### 遍历清空数组

当我们想要在 Go 语言中清空一个切片或者哈希时，一般都会使用以下的方法将切片中的元素置零：

```go
func main() {
	arr := []int{1, 2, 3}
	for i, _ := range arr {
		arr[i] = 0
	}
}
```

依次遍历切片和哈希看起来是非常耗费性能的，因为数组、切片和哈希占用的内存空间都是连续的，所以最快的方法是直接清空这片内存中的内容。

[`cmd/compile/internal/gc.arrayClear`](https://draveness.me/golang/tree/cmd/compile/internal/gc.arrayClear) 是一个非常有趣的优化，它会优化 Go 语言遍历数组或者切片并删除全部元素的逻辑：

```go
// 原代码
for i := range a {
	a[i] = zero
}

// 优化后
if len(a) != 0 {
	hp = &a[0]
	hn = len(a)*sizeof(elem(a))
	memclrNoHeapPointers(hp, hn)
	i = len(a) - 1
}
```

相比于依次清除数组或者切片中的数据，Go 语言会直接使用 [`runtime.memclrNoHeapPointers`](https://draveness.me/golang/tree/runtime.memclrNoHeapPointers) 或者 [`runtime.memclrHasPointers`](https://draveness.me/golang/tree/runtime.memclrHasPointers) 清除目标数组内存空间中的全部数据，并在执行完成后更新遍历数组的索引，这也印证了我们在遍历清空数组中观察到的现象。

#### 随机遍历

当我们在 Go 语言中使用 `range` 遍历哈希表时，往往都会使用如下的代码结构，但是这段代码在每次运行时都会打印出不同的结果：

```go
func main() {
	hash := map[string]int{
		"1": 1,
		"2": 2,
		"3": 3,
	}
	for k, v := range hash {
		println(k, v)
	}
}
```

两次运行上述代码可能会得到不同的结果，第一次会打印 `2 3 1`，第二次会打印 `1 2 3`，如果我们运行的次数足够多，最后会得到几种不同的遍历顺序。

```bash
$ go run main.go
2 2
3 3
1 1

$ go run main.go
1 1
2 2
3 3
```

Go 语言在运行时为哈希表的遍历引入了不确定性，也是告诉所有 Go 语言的使用者，程序不要依赖于哈希表的稳定遍历。

### 经典循环

#### 哈希表

首先会选出一个绿色的正常桶开始遍历，随后遍历所有黄色的溢出桶，最后依次按照索引顺序遍历哈希表中其他的桶，直到所有的桶都被遍历完成。

#### 字符串

遍历字符串的过程与数组、切片和哈希表非常相似，只是在遍历时会获取字符串中索引对应的字节并将字节转换成 `rune`。我们在遍历字符串时拿到的值都是 `rune` 类型的变量，`for i, r := range s {}` 的结构都会被转换成如下所示的形式：

```go
ha := s
for hv1 := 0; hv1 < len(ha); {
    hv1t := hv1
    hv2 := rune(ha[hv1])
    if hv2 < utf8.RuneSelf {
        hv1++
    } else {
        hv2, hv1 = decoderune(ha, hv1)
    }
    v1, v2 = hv1t, hv2
}
```

字符串是一个只读的字节数组切片，所以范围循环在编译期间生成的框架与切片非常类似，只是细节有一些不同。

使用下标访问字符串中的元素时得到的就是字节，但是这段代码会将当前的字节转换成 `rune` 类型。如果当前的 `rune` 是 ASCII 的，那么只会占用一个字节长度，每次循环体运行之后只需要将索引加一，但是如果当前 `rune` 占用了多个字节就会使用 [`runtime.decoderune`](https://draveness.me/golang/tree/runtime.decoderune) 函数解码，具体的过程就不在这里详细介绍了。

#### 通道

使用 range 遍历 Channel 也是比较常见的做法，一个形如 `for v := range ch {}` 的语句最终会被转换成如下的格式：

```go
ha := a
hv1, hb := <-ha
for ; hb != false; hv1, hb = <-ha {
    v1 := hv1
    hv1 = nil
    ...
}
```

这里的代码可能与编译器生成的稍微有一些出入，但是结构和效果是完全相同的。该循环会使用 `<-ch` 从管道中取出等待处理的值，这个操作会调用 [`runtime.chanrecv2`](https://draveness.me/golang/tree/runtime.chanrecv2) 并阻塞当前的协程，当 [`runtime.chanrecv2`](https://draveness.me/golang/tree/runtime.chanrecv2) 返回时会根据布尔值 `hb` 判断当前的值是否存在：

* 如果不存在当前值，意味着当前的管道已经被关闭；
* 如果存在当前值，会为 `v1` 赋值并清除 `hv1` 变量中的数据，然后重新陷入阻塞等待新数据；

## select 设计

C 语言的 `select` 系统调用可以同时监听多个文件描述符的可读或者可写的状态，Go 语言中的 `select` 也能够让 Goroutine 同时等待多个 Channel 可读或者可写，在多个文件或者 Channel 状态改变之前，`select` 会一直阻塞当前线程或者 Goroutine。

`select` 是与 `switch` 相似的控制结构，与 `switch` 不同的是，`select` 中虽然也有多个 `case`，但是这些 `case` 中的表达式必须都是 Channel 的收发操作。下面的代码就展示了一个包含 Channel 收发操作的 `select` 结构：

```go
func fibonacci(c, quit chan int) {
	x, y := 0, 1
	for {
		select {
		case c <- x:
			x, y = y, x+y
		case <-quit:
			fmt.Println("quit")
			return
		}
	}
}
```

上述控制结构会等待 `c <- x` 或者 `<-quit` 两个表达式中任意一个返回。无论哪一个表达式返回都会立刻执行 `case` 中的代码，当 `select` 中的两个 `case` 同时被触发时，会随机执行其中的一个。

### 现象

当我们在 Go 语言中使用 `select` 控制结构时，会遇到两个有趣的现象：

1. `select` 能在 Channel 上进行非阻塞的收发操作；
2. `select` 在遇到多个 Channel 同时响应时，会随机执行一种情况；

#### 非阻塞的收发

在通常情况下，`select` 语句会阻塞当前 Goroutine 并等待多个 Channel 中的一个达到可以收发的状态。但是如果 `select` 控制结构中包含 `default` 语句，那么这个 `select` 语句在执行时会遇到以下两种情况：

1. 当存在可以收发的 Channel 时，直接处理该 Channel 对应的 `case`；
2. 当不存在可以收发的 Channel 时，执行 `default` 中的语句；

当我们运行下面的代码时就不会阻塞当前的 Goroutine，它会直接执行 `default` 中的代码。

```go
func main() {
	ch := make(chan int)
	select {
	case i := <-ch:
		println(i)

	default:
		println("default")
	}
}

$ go run main.go
default
```

非阻塞的 Channel 发送和接收操作还是很有必要的，在很多场景下我们不希望 Channel 操作阻塞当前 Goroutine，只是想看看 Channel 的可读或者可写状态，如下所示：

```go
errCh := make(chan error, len(tasks))
wg := sync.WaitGroup{}
wg.Add(len(tasks))
for i := range tasks {
    go func() {
        defer wg.Done()
        if err := tasks[i].Run(); err != nil {
            errCh <- err
        }
    }()
}
wg.Wait()

select {
case err := <-errCh:
    return err
default:
    return nil
}
```

在上面这段代码中，我们不关心到底多少个任务执行失败了，只关心是否存在返回错误的任务，最后的 `select` 语句能很好地完成这个任务。

### 实现原理

#### 常见流程

在默认的情况下，编译器会使用如下的流程处理 `select` 语句：

1. 将所有的 `case` 转换成包含 Channel 以及类型等信息的 [`runtime.scase`](https://draveness.me/golang/tree/runtime.scase) 结构体；
2. 调用运行时函数 [`runtime.selectgo`](https://draveness.me/golang/tree/runtime.selectgo) 从多个准备就绪的 Channel 中选择一个可执行的 [`runtime.scase`](https://draveness.me/golang/tree/runtime.scase) 结构体；
3. 通过 `for` 循环生成一组 `if` 语句，在语句中判断自己是不是被选中的 `case`；

一个包含三个 `case` 的正常 `select` 语句其实会被展开成如下所示的逻辑，我们可以看到其中处理的三个部分：

```go
selv := [3]scase{}
order := [6]uint16
for i, cas := range cases {
    c := scase{}
    c.kind = ...
    c.elem = ...
    c.c = ...
}
chosen, revcOK := selectgo(selv, order, 3)
if chosen == 0 {
    ...
    break
}
if chosen == 1 {
    ...
    break
}
if chosen == 2 {
    ...
    break
}
```

展开后的代码片段中最重要的就是用于选择待执行 `case` 的运行时函数 [`runtime.selectgo`](https://draveness.me/golang/tree/runtime.selectgo)，这也是我们要关注的重点。因为这个函数的实现比较复杂， 所以这里分两部分进行执行过程：

1. 执行一些必要的初始化操作并确定 `case` 的处理顺序；
2. 在循环中根据 `case` 的类型做出不同的处理；

### 小结

1. 空的 `select` 语句会被转换成调用 [`runtime.block`](https://draveness.me/golang/tree/runtime.block) 直接挂起当前 Goroutine；
2. 如果 `select` 语句中只包含一个 `case`，编译器会将其转换成 `if ch == nil { block }; n;` 表达式；
   * 首先判断操作的 Channel 是不是空的；
   * 然后执行 `case` 结构中的内容；
3. 如果 `select` 语句中只包含两个 `case` 并且其中一个是 `default`，那么会使用 [`runtime.selectnbrecv`](https://draveness.me/golang/tree/runtime.selectnbrecv) 和 [`runtime.selectnbsend`](https://draveness.me/golang/tree/runtime.selectnbsend) 非阻塞地执行收发操作；
4. 在默认情况下会通过 [`runtime.selectgo`](https://draveness.me/golang/tree/runtime.selectgo) 获取执行 `case` 的索引，并通过多个 `if` 语句执行对应 `case` 中的代码；

在编译器已经对 `select` 语句进行优化之后，Go 语言会在运行时执行编译期间展开的 [`runtime.selectgo`](https://draveness.me/golang/tree/runtime.selectgo) 函数，该函数会按照以下的流程执行：

1. 随机生成一个遍历的轮询顺序 `pollOrder` 并根据 Channel 地址生成锁定顺序 `lockOrder`；
2. 根据 `pollOrder` 遍历所有的 `case` 查看是否有可以立刻处理的 Channel；
   1. 如果存在，直接获取 `case` 对应的索引并返回；
   2. 如果不存在，创建 [`runtime.sudog`](https://draveness.me/golang/tree/runtime.sudog) 结构体，将当前 Goroutine 加入到所有相关 Channel 的收发队列，并调用 [`runtime.gopark`](https://draveness.me/golang/tree/runtime.gopark) 挂起当前 Goroutine 等待调度器的唤醒；
3. 当调度器唤醒当前 Goroutine 时，会再次按照 `lockOrder` 遍历所有的 `case`，从中查找需要被处理的 [`runtime.sudog`](https://draveness.me/golang/tree/runtime.sudog) 对应的索引；

`select` 关键字是 Go 语言特有的控制结构，它的实现原理比较复杂，需要编译器和运行时函数的通力合作。

## defer

### 现象

#### 作用域

向 `defer` 关键字传入的函数会在函数返回之前运行。假设我们在 `for` 循环中多次调用 `defer` 关键字：

```go
func main() {
	for i := 0; i < 5; i++ {
		defer fmt.Println(i)
	}
}

$ go run main.go
4
3
2
1
0
```

运行上述代码会倒序执行传入 `defer` 关键字的所有表达式，因为最后一次调用 `defer` 时传入了 `fmt.Println(4)`，所以这段代码会优先打印 4。

```go
func main() {
    {
        defer fmt.Println("defer runs")
        fmt.Println("block ends")
    }
    
    fmt.Println("main ends")
}

$ go run main.go
block ends
main ends
defer runs
```

从上述代码的输出我们会发现，`defer` 传入的函数不是在退出代码块的作用域时执行的，它只会在当前函数和方法返回之前被调用。

#### 预计算参数

```go
func main() {
	startedAt := time.Now()
	defer fmt.Println(time.Since(startedAt))
	
	time.Sleep(time.Second)
}

$ go run main.go
0s
```

我们会发现调用 `defer` 关键字会立刻拷贝函数中引用的外部参数，所以 `time.Since(startedAt)` 的结果不是在 `main` 函数退出之前计算的，而是在 `defer` 关键字调用时计算的，最终导致上述代码输出 0s。

想要解决这个问题的方法非常简单，我们只需要向 `defer` 关键字传入匿名函数：

```go
func main() {
	startedAt := time.Now()
	defer func() { fmt.Println(time.Since(startedAt)) }()
	
	time.Sleep(time.Second)
}

$ go run main.go
1s
```

虽然调用 `defer` 关键字时也使用值传递，但是因为拷贝的是函数指针，所以 `time.Since(startedAt)` 会在 `main` 函数返回前调用并打印出符合预期的结果。

### 数据结构

[`runtime._defer`](https://draveness.me/golang/tree/runtime._defer) 结构体是延迟调用链表上的一个元素，所有的结构体都会通过 `link` 字段串联成链表。

### 执行机制

#### 堆上分配

根据 [`cmd/compile/internal/gc.state.stmt`](https://draveness.me/golang/tree/cmd/compile/internal/gc.state.stmt) 方法对 `defer` 的处理我们可以看出，堆上分配的 [`runtime._defer`](https://draveness.me/golang/tree/runtime._defer) 结构体是默认的兜底方案，当该方案被启用时，编译器会调用 [`cmd/compile/internal/gc.state.callResult`](https://draveness.me/golang/tree/cmd/compile/internal/gc.state.callResult) 和 [`cmd/compile/internal/gc.state.call`](https://draveness.me/golang/tree/cmd/compile/internal/gc.state.call)，这表示 `defer` 在编译器看来也是函数调用。

#### 栈上分配

在默认情况下，我们可以看到 Go 语言中 [`runtime._defer`](https://draveness.me/golang/tree/runtime._defer) 结构体都会在堆上分配，如果我们能够将部分结构体分配到栈上就可以节约内存分配带来的额外开销。

Go 语言团队在 1.13 中对 `defer` 关键字进行了优化，当该关键字在函数体中最多执行一次时，编译期间的 [`cmd/compile/internal/gc.state.call`](https://draveness.me/golang/tree/cmd/compile/internal/gc.state.call) 会将结构体分配到栈上并调用 [`runtime.deferprocStack`](https://draveness.me/golang/tree/runtime.deferprocStack)。

因为在编译期间我们已经创建了 [`runtime._defer`](https://draveness.me/golang/tree/runtime._defer) 结构体，所以在运行期间 [`runtime.deferprocStack`](https://draveness.me/golang/tree/runtime.deferprocStack) 只需要设置一些未在编译期间初始化的字段，就可以将栈上的 [`runtime._defer`](https://draveness.me/golang/tree/runtime._defer) 追加到函数的链表上。

#### 开放编码

Go 语言在 1.14 中通过开放编码（Open Coded）实现 `defer` 关键字，该设计使用代码内联优化 `defer` 关键的额外开销并引入函数数据 `funcdata` 管理 `panic` 的调用，该优化可以将 `defer` 的调用开销从 1.13 版本的 \~35ns 降低至 \~6ns 左右。

然而开放编码作为一种优化 `defer` 关键字的方法，它不是在所有的场景下都会开启的，开放编码只会在满足以下的条件时启用：

1. 函数的 `defer` 数量少于或者等于 8 个；
2. 函数的 `defer` 关键字不能在循环中执行；
3. 函数的 `return` 语句与 `defer` 语句的乘积小于或者等于 15 个；

## panic 和 recover

* `panic` 能够改变程序的控制流，调用 `panic` 后会立刻停止执行当前函数的剩余代码，并在当前 Goroutine 中递归执行调用方的 `defer`；
* `recover` 可以中止 `panic` 造成的程序崩溃。它是一个只能在 `defer` 中发挥作用的函数，在其他作用域中调用不会发挥作用；

### 现象

#### 跨协程失效

首先要介绍的现象是 `panic` 只会触发当前 Goroutine 的延迟函数调用，我们可以通过如下所示的代码了解该现象：

```go
func main() {
	defer println("in main")
	go func() {
		defer println("in goroutine")
		panic("")
	}()

	time.Sleep(1 * time.Second)
}

$ go run main.go
in goroutine
panic:
...
```

当我们运行这段代码时会发现 `main` 函数中的 `defer` 语句并没有执行，执行的只有当前 Goroutine 中的 `defer`。

&#x20;`defer` 关键字对应的 [`runtime.deferproc`](https://draveness.me/golang/tree/runtime.deferproc) 会将延迟调用函数与调用方所在 Goroutine 进行关联。所以当程序发生崩溃时只会调用当前 Goroutine 的延迟调用函数也是非常合理的。

#### 失效的崩溃恢复

初学 Go 语言的读者可能会写出下面的代码，在主程序中调用 `recover` 试图中止程序的崩溃，但是从运行的结果中我们也能看出，下面的程序没有正常退出。

```go
func main() {
	defer fmt.Println("in main")
	if err := recover(); err != nil {
		fmt.Println(err)
	}

	panic("unknown err")
}

$ go run main.go
in main
panic: unknown err

goroutine 1 [running]:
main.main()
	...
exit status 2
```

仔细分析一下这个过程就能理解这种现象背后的原因，`recover` 只有在发生 `panic` 之后调用才会生效。然而在上面的控制流中，`recover` 是在 `panic` 之前调用的，并不满足生效的条件，所以我们需要在 `defer` 中使用 `recover` 关键字。

#### 嵌套崩溃

Go 语言中的 `panic` 是可以多次嵌套调用的。一些熟悉 Go 语言的读者很可能也不知道这个知识点，如下所示的代码就展示了如何在 `defer` 函数中多次调用 `panic`：

```go
func main() {
	defer fmt.Println("in main")
	defer func() {
		defer func() {
			panic("panic again and again")
		}()
		panic("panic again")
	}()

	panic("panic once")
}

$ go run main.go
in main
panic: panic once
	panic: panic again
	panic: panic again and again

goroutine 1 [running]:
...
exit status 2
```

从上述程序输出的结果，我们可以确定程序多次调用 `panic` 也不会影响 `defer` 函数的正常执行，所以使用 `defer` 进行收尾工作一般来说都是安全的。

### 数据结构

`panic` 关键字在 Go 语言的源代码是由数据结构 [`runtime._panic`](https://draveness.me/golang/tree/runtime._panic) 表示的。每当我们调用 `panic` 都会创建一个如下所示的数据结构存储相关信息：

```go
type _panic struct {
	argp      unsafe.Pointer
	arg       interface{}
	link      *_panic
	recovered bool
	aborted   bool
	pc        uintptr
	sp        unsafe.Pointer
	goexit    bool
}
```

1. `argp` 是指向 `defer` 调用时参数的指针；
2. `arg` 是调用 `panic` 时传入的参数；
3. `link` 指向了更早调用的 [`runtime._panic`](https://draveness.me/golang/tree/runtime._panic) 结构；
4. `recovered` 表示当前 [`runtime._panic`](https://draveness.me/golang/tree/runtime._panic) 是否被 `recover` 恢复；
5. `aborted` 表示当前的 `panic` 是否被强行终止；

从数据结构中的 `link` 字段我们就可以推测出以下的结论：`panic` 函数可以被连续多次调用，它们之间通过 `link` 可以组成链表。

### 程序崩溃

编译器会将关键字 `panic` 转换成 [`runtime.gopanic`](https://draveness.me/golang/tree/runtime.gopanic)，该函数的执行过程包含以下几个步骤：

1. 创建新的 [`runtime._panic`](https://draveness.me/golang/tree/runtime._panic) 并添加到所在 Goroutine 的 `_panic` 链表的最前面；
2. 在循环中不断从当前 Goroutine 的 `_defer` 中链表获取 [`runtime._defer`](https://draveness.me/golang/tree/runtime._defer) 并调用 [`runtime.reflectcall`](https://draveness.me/golang/tree/runtime.reflectcall) 运行延迟调用函数；
3. 调用 [`runtime.fatalpanic`](https://draveness.me/golang/tree/runtime.fatalpanic) 中止整个程序；

### 崩溃恢复

```go
func gorecover(argp uintptr) interface{} {
	gp := getg()
	p := gp._panic
	if p != nil && !p.recovered && argp == uintptr(p.argp) {
		p.recovered = true
		return p.arg
	}
	return nil
}
```

该函数的实现很简单，如果当前 Goroutine 没有调用 `panic`，那么该函数会直接返回 `nil`，这也是崩溃恢复在非 `defer` 中调用会失效的原因。在正常情况下，它会修改 [`runtime._panic`](https://draveness.me/golang/tree/runtime._panic) 的 `recovered` 字段。

## make 和 new

* `make` 的作用是初始化内置的数据结构，也就是前面提到的切片、哈希表和 Channel
* `new` 的作用是根据传入的类型分配一片内存空间并返回指向这片内存空间的指针

### make

在编译期间的类型检查阶段，Go 语言会将代表 `make` 关键字的 `OMAKE` 节点根据参数类型的不同转换成了 `OMAKESLICE`、`OMAKEMAP` 和 `OMAKECHAN` 三种不同类型的节点，这些节点会调用不同的运行时函数来初始化相应的数据结构。

### new

编译器会在中间代码生成阶段通过以下两个函数处理该关键字：

1. [`cmd/compile/internal/gc.callnew`](https://draveness.me/golang/tree/cmd/compile/internal/gc.callnew) 会将关键字转换成 `ONEWOBJ` 类型的节点
2. [`cmd/compile/internal/gc.state.expr`](https://draveness.me/golang/tree/cmd/compile/internal/gc.state.expr) 会根据申请空间的大小分两种情况处理：
   1. 如果申请的空间为 0，就会返回一个表示空指针的 `zerobase` 变量；
   2. 在遇到其他情况时会将关键字转换成 [`runtime.newobject`](https://draveness.me/golang/tree/runtime.newobject) 函数：

[`runtime.newobject`](https://draveness.me/golang/tree/runtime.newobject) 函数会获取传入类型占用空间的大小，调用 [`runtime.mallocgc`](https://draveness.me/golang/tree/runtime.mallocgc) 在堆上申请一片内存空间并返回指向这片内存空间的指针：

```go
func newobject(typ *_type) unsafe.Pointer {
	return mallocgc(typ.size, typ, true)
}
```

## 上下文 Context

### 设计原理

在 Goroutine 构成的树形结构中对信号进行同步以减少计算资源的浪费是 [`context.Context`](https://draveness.me/golang/tree/context.Context) 的最大作用。Go 服务的每一个请求都是通过单独的 Goroutine 处理的，HTTP/RPC 请求的处理器会启动新的 Goroutine 访问数据库和其他服务。

我们可能会创建多个 Goroutine 来处理一次请求，而 [`context.Context`](https://draveness.me/golang/tree/context.Context) 的作用是在不同 Goroutine 之间同步请求特定数据、取消信号以及处理请求的截止日期。

每一个 [`context.Context`](https://draveness.me/golang/tree/context.Context) 都会从最顶层的 Goroutine 一层一层传递到最下层。[`context.Context`](https://draveness.me/golang/tree/context.Context) 可以在上层 Goroutine 执行出现错误时，将信号及时同步给下层。

在这段代码中，我们创建了一个过期时间为 1s 的上下文，并向上下文传入 `handle` 函数，该方法会使用 500ms 的时间处理传入的请求：

```go
func main() {
	ctx, cancel := context.WithTimeout(context.Background(), 1*time.Second)
	defer cancel()

	go handle(ctx, 500*time.Millisecond)
	select {
	case <-ctx.Done():
		fmt.Println("main", ctx.Err())
	}
}

func handle(ctx context.Context, duration time.Duration) {
	select {
	case <-ctx.Done():
		fmt.Println("handle", ctx.Err())
	case <-time.After(duration):
		fmt.Println("process request with", duration)
	}
}
```

因为过期时间大于处理时间，所以我们有足够的时间处理该请求，运行上述代码会打印出下面的内容：

```go
$ go run context.go
process request with 500ms
main context deadline exceeded
```

`handle` 函数没有进入超时的 `select` 分支，但是 `main` 函数的 `select` 却会等待 [`context.Context`](https://draveness.me/golang/tree/context.Context) 超时并打印出 `main context deadline exceeded`。

如果我们将处理请求时间增加至 1500ms，整个程序都会因为上下文的过期而被中止。

### 默认上下文

[`context`](https://github.com/golang/go/tree/master/src/context) 包中最常用的方法还是 [`context.Background`](https://draveness.me/golang/tree/context.Background)、[`context.TODO`](https://draveness.me/golang/tree/context.TODO)，这两个方法都会返回预先初始化好的私有变量 `background` 和 `todo`，它们会在同一个 Go 程序中被复用：

```go
func Background() Context {
	return background
}

func TODO() Context {
	return todo
}
```

从源代码来看，[`context.Background`](https://draveness.me/golang/tree/context.Background) 和 [`context.TODO`](https://draveness.me/golang/tree/context.TODO) 也只是互为别名，没有太大的差别，只是在使用和语义上稍有不同：

* [`context.Background`](https://draveness.me/golang/tree/context.Background) 是上下文的默认值，所有其他的上下文都应该从它衍生出来；
* [`context.TODO`](https://draveness.me/golang/tree/context.TODO) 应该仅在不确定应该使用哪种上下文时使用；

在多数情况下，如果当前函数没有上下文作为入参，我们都会使用 [`context.Background`](https://draveness.me/golang/tree/context.Background) 作为起始的上下文向下传递。

### 取消信号

[`context.WithCancel`](https://draveness.me/golang/tree/context.WithCancel) 函数能够从 [`context.Context`](https://draveness.me/golang/tree/context.Context) 中衍生出一个新的子上下文并返回用于取消该上下文的函数。一旦我们执行返回的取消函数，当前上下文以及它的子上下文都会被取消，所有的 Goroutine 都会同步收到这一取消信号。

[`context.WithCancel`](https://draveness.me/golang/tree/context.WithCancel) 之外，[`context`](https://github.com/golang/go/tree/master/src/context) 包中的另外两个函数 [`context.WithDeadline`](https://draveness.me/golang/tree/context.WithDeadline) 和 [`context.WithTimeout`](https://draveness.me/golang/tree/context.WithTimeout) 也都能创建可以被取消的计时器上下文 [`context.timerCtx`](https://draveness.me/golang/tree/context.timerCtx)

### 传值方法

[`context`](https://github.com/golang/go/tree/master/src/context) 包中的 [`context.WithValue`](https://draveness.me/golang/tree/context.WithValue) 能从父上下文中创建一个子上下文，传值的子上下文使用 [`context.valueCtx`](https://draveness.me/golang/tree/context.valueCtx) 类型：

```go
func WithValue(parent Context, key, val interface{}) Context {
	if key == nil {
		panic("nil key")
	}
	if !reflectlite.TypeOf(key).Comparable() {
		panic("key is not comparable")
	}
	return &valueCtx{parent, key, val}
}
```

[`context.valueCtx`](https://draveness.me/golang/tree/context.valueCtx) 结构体会将除了 `Value` 之外的 `Err`、`Deadline` 等方法代理到父上下文中，它只会响应 [`context.valueCtx.Value`](https://draveness.me/golang/tree/context.valueCtx.Value) 方法，该方法的实现也很简单：

```go
type valueCtx struct {
	Context
	key, val interface{}
}

func (c *valueCtx) Value(key interface{}) interface{} {
	if c.key == key {
		return c.val
	}
	return c.Context.Value(key)
}
```

如果 [`context.valueCtx`](https://draveness.me/golang/tree/context.valueCtx) 中存储的键值对与 [`context.valueCtx.Value`](https://draveness.me/golang/tree/context.valueCtx.Value) 方法中传入的参数不匹配，就会从父上下文中查找该键对应的值直到某个父上下文中返回 `nil` 或者查找到对应的值。

## 同步原语与锁

### 基本原语

基本原语提供了较为基础的同步功能，但是它们是一种相对原始的同步机制，在多数情况下，我们都应该使用抽象层级更高的 Channel 实现同步。

#### Mutex

Go 语言的 [`sync.Mutex`](https://draveness.me/golang/tree/sync.Mutex) 由两个字段 `state` 和 `sema` 组成。其中 `state` 表示当前互斥锁的状态，而 `sema` 是用于控制锁状态的信号量。

```go
type Mutex struct {
	state int32
	sema  uint32
}
```

上述两个加起来只占 8 字节空间的结构体表示了 Go 语言中的互斥锁。

**正常模式和饥饿模式**

在正常模式下，锁的等待者会按照先进先出的顺序获取锁。但是刚被唤起的 Goroutine 与新创建的 Goroutine 竞争时，大概率会获取不到锁，为了减少这种情况的出现，一旦 Goroutine 超过 1ms 没有获取到锁，它就会将当前互斥锁切换饥饿模式，防止部分 Goroutine 被『饿死』。

在饥饿模式中，互斥锁会直接交给等待队列最前面的 Goroutine。新的 Goroutine 在该状态下不能获取锁、也不会进入自旋状态，它们只会在队列的末尾等待。如果一个 Goroutine 获得了互斥锁并且它在队列的末尾或者它等待的时间少于 1ms，那么当前的互斥锁就会切换回正常模式。

与饥饿模式相比，正常模式下的互斥锁能够提供更好地性能，饥饿模式的能避免 Goroutine 由于陷入等待无法获取锁而造成的高尾延时。

**加锁和解锁**

互斥锁的加锁是靠 [`sync.Mutex.Lock`](https://draveness.me/golang/tree/sync.Mutex.Lock) 完成的，最新的 Go 语言源代码中已经将 [`sync.Mutex.Lock`](https://draveness.me/golang/tree/sync.Mutex.Lock) 方法进行了简化，方法的主干只保留最常见、简单的情况 — 当锁的状态是 0 时，将 `mutexLocked` 位置成 1：

```go
func (m *Mutex) Lock() {
	if atomic.CompareAndSwapInt32(&m.state, 0, mutexLocked) {
		return
	}
	m.lockSlow()
}
```

如果互斥锁的状态不是 0 时就会调用 [`sync.Mutex.lockSlow`](https://draveness.me/golang/tree/sync.Mutex.lockSlow) 尝试通过自旋（Spinnig）等方式等待锁的释放，该方法的主体是一个非常大 for 循环，这里分成几个过程：

1. 判断当前 Goroutine 能否进入自旋；
   * Goroutine 进入自旋的条件非常苛刻：
     1. 互斥锁只有在普通模式才能进入自旋；
     2. [`runtime.sync_runtime_canSpin`](https://draveness.me/golang/tree/runtime.sync_runtime_canSpin) 需要返回 `true`：
        * 运行在多 CPU 的机器上；
        * 当前 Goroutine 为了获取该锁进入自旋的次数小于四次；
        * 当前机器上至少存在一个正在运行的处理器 P 并且处理的运行队列为空；
2. 通过自旋等待互斥锁的释放；
3. 计算互斥锁的最新状态；
4. 更新互斥锁的状态并获取锁；

互斥锁的解锁过程 [`sync.Mutex.Unlock`](https://draveness.me/golang/tree/sync.Mutex.Unlock) 与加锁过程相比就很简单，该过程会先使用 [`sync/atomic.AddInt32`](https://draveness.me/golang/tree/sync/atomic.AddInt32) 函数快速解锁，这时会发生下面的两种情况：

* 如果该函数返回的新状态等于 0，当前 Goroutine 就成功解锁了互斥锁；
* 如果该函数返回的新状态不等于 0，这段代码会调用 [`sync.Mutex.unlockSlow`](https://draveness.me/golang/tree/sync.Mutex.unlockSlow) 开始慢速解锁：

```go
func (m *Mutex) Unlock() {
	new := atomic.AddInt32(&m.state, -mutexLocked)
	if new != 0 {
		m.unlockSlow(new)
	}
}
```

* 当互斥锁已经被解锁时，调用 [`sync.Mutex.Unlock`](https://draveness.me/golang/tree/sync.Mutex.Unlock) 会直接抛出异常；
* 当互斥锁处于饥饿模式时，将锁的所有权交给队列中的下一个等待者，等待者会负责设置 `mutexLocked` 标志位；
* 当互斥锁处于普通模式时，如果没有 Goroutine 等待锁的释放或者已经有被唤醒的 Goroutine 获得了锁，会直接返回；在其他情况下会通过 [`sync.runtime_Semrelease`](https://draveness.me/golang/tree/sync.runtime_Semrelease) 唤醒对应的 Goroutine；

#### RWMutex

**结构体**

[`sync.RWMutex`](https://draveness.me/golang/tree/sync.RWMutex) 中总共包含以下 5 个字段：

```go
type RWMutex struct {
	w           Mutex
	writerSem   uint32
	readerSem   uint32
	readerCount int32
	readerWait  int32
}
```

* `w` — 复用互斥锁提供的能力；
* `writerSem` 和 `readerSem` — 分别用于写等待读和读等待写：
* `readerCount` 存储了当前正在执行的读操作数量；
* `readerWait` 表示当写操作被阻塞时等待的读操作个数；

**流程**

* 调用 [`sync.RWMutex.Lock`](https://draveness.me/golang/tree/sync.RWMutex.Lock) 尝试获取写锁时；
  * 每次 [`sync.RWMutex.RUnlock`](https://draveness.me/golang/tree/sync.RWMutex.RUnlock) 都会将 `readerCount` 其减一，当它归零时该 Goroutine 会获得写锁；
  * 将 `readerCount` 减少 `rwmutexMaxReaders` 个数以阻塞后续的读操作；
* 调用 [`sync.RWMutex.Unlock`](https://draveness.me/golang/tree/sync.RWMutex.Unlock) 释放写锁时，会先通知所有的读操作，然后才会释放持有的互斥锁；

读写互斥锁在互斥锁之上提供了额外的更细粒度的控制，能够在读操作远远多于写操作时提升性能。

#### WaitGroup

**结构体**

[`sync.WaitGroup`](https://draveness.me/golang/tree/sync.WaitGroup) 结构体中只包含两个成员变量：

```go
type WaitGroup struct {
	noCopy noCopy
	state1 [3]uint32
}
```

* `noCopy` — 保证 [`sync.WaitGroup`](https://draveness.me/golang/tree/sync.WaitGroup) 不会被开发者通过再赋值的方式拷贝；
* `state1` — 存储着状态和信号量；

**接口**

[`sync.WaitGroup`](https://draveness.me/golang/tree/sync.WaitGroup) 对外暴露了三个方法 — [`sync.WaitGroup.Add`](https://draveness.me/golang/tree/sync.WaitGroup.Add)、[`sync.WaitGroup.Wait`](https://draveness.me/golang/tree/sync.WaitGroup.Wait) 和 [`sync.WaitGroup.Done`](https://draveness.me/golang/tree/sync.WaitGroup.Done)。

* [`sync.WaitGroup`](https://draveness.me/golang/tree/sync.WaitGroup) 必须在 [`sync.WaitGroup.Wait`](https://draveness.me/golang/tree/sync.WaitGroup.Wait) 方法返回之后才能被重新使用；
* [`sync.WaitGroup.Done`](https://draveness.me/golang/tree/sync.WaitGroup.Done) 只是对 [`sync.WaitGroup.Add`](https://draveness.me/golang/tree/sync.WaitGroup.Add) 方法的简单封装，我们可以向 [`sync.WaitGroup.Add`](https://draveness.me/golang/tree/sync.WaitGroup.Add) 方法传入任意负数（需要保证计数器非负）快速将计数器归零以唤醒等待的 Goroutine；
* 可以同时有多个 Goroutine 等待当前 [`sync.WaitGroup`](https://draveness.me/golang/tree/sync.WaitGroup) 计数器的归零，这些 Goroutine 会被同时唤醒；

#### Once

Go 语言标准库中 [`sync.Once`](https://draveness.me/golang/tree/sync.Once) 可以保证在 Go 程序运行期间的某段代码只会执行一次。在运行如下所示的代码时，我们会看到如下所示的运行结果：

```go
func main() {
    o := &sync.Once{}
    for i := 0; i < 10; i++ {
        o.Do(func() {
            fmt.Println("only once")
        })
    }
}

$ go run main.go
only once
```

**结构体**

每一个 [`sync.Once`](https://draveness.me/golang/tree/sync.Once) 结构体中都只包含一个用于标识代码块是否执行过的 `done` 以及一个互斥锁 [`sync.Mutex`](https://draveness.me/golang/tree/sync.Mutex)：

```go
type Once struct {
	done uint32
	m    Mutex
}
```

**接口**

[`sync.Once.Do`](https://draveness.me/golang/tree/sync.Once.Do) 是 [`sync.Once`](https://draveness.me/golang/tree/sync.Once) 结构体对外唯一暴露的方法，该方法会接收一个入参为空的函数：

* 如果传入的函数已经执行过，会直接返回；
* 如果传入的函数没有执行过，会调用 [`sync.Once.doSlow`](https://draveness.me/golang/tree/sync.Once.doSlow) 执行传入的函数：

```go
func (o *Once) Do(f func()) {
	if atomic.LoadUint32(&o.done) == 0 {
		o.doSlow(f)
	}
}

func (o *Once) doSlow(f func()) {
	o.m.Lock()
	defer o.m.Unlock()
	if o.done == 0 {
		defer atomic.StoreUint32(&o.done, 1)
		f()
	}
}
```

1. 为当前 Goroutine 获取互斥锁；
2. 执行传入的无入参函数；
3. 运行延迟函数调用，将成员变量 `done` 更新成 1；

[`sync.Once`](https://draveness.me/golang/tree/sync.Once) 会通过成员变量 `done` 确保函数不会执行第二次。

Tips:

* [`sync.Once.Do`](https://draveness.me/golang/tree/sync.Once.Do) 方法中传入的函数只会被执行一次，哪怕函数中发生了 `panic`；
* 两次调用 [`sync.Once.Do`](https://draveness.me/golang/tree/sync.Once.Do) 方法传入不同的函数只会执行第一次调传入的函数；

#### Cond

Go 语言标准库中还包含条件变量 [`sync.Cond`](https://draveness.me/golang/tree/sync.Cond)，它可以让一组的 Goroutine 都在满足特定条件时被唤醒。每一个 [`sync.Cond`](https://draveness.me/golang/tree/sync.Cond) 结构体在初始化时都需要传入一个互斥锁，我们可以通过下面的例子了解它的使用方法：

```go
var status int64

func main() {
	c := sync.NewCond(&sync.Mutex{})
	for i := 0; i < 10; i++ {
		go listen(c)
	}
	time.Sleep(1 * time.Second)
	go broadcast(c)

	ch := make(chan os.Signal, 1)
	signal.Notify(ch, os.Interrupt)
	<-ch
}

func broadcast(c *sync.Cond) {
	c.L.Lock()
	atomic.StoreInt64(&status, 1)
	c.Broadcast()
	c.L.Unlock()
}

func listen(c *sync.Cond) {
	c.L.Lock()
	for atomic.LoadInt64(&status) != 1 {
		c.Wait()
	}
	fmt.Println("listen")
	c.L.Unlock()
}

$ go run main.go
listen
...
listen
```

上述代码同时运行了 11 个 Goroutine，这 11 个 Goroutine 分别做了不同事情：

* 10 个 Goroutine 通过 [`sync.Cond.Wait`](https://draveness.me/golang/tree/sync.Cond.Wait) 等待特定条件的满足；
* 1 个 Goroutine 会调用 [`sync.Cond.Broadcast`](https://draveness.me/golang/tree/sync.Cond.Broadcast) 唤醒所有陷入等待的 Goroutine；

调用 [`sync.Cond.Broadcast`](https://draveness.me/golang/tree/sync.Cond.Broadcast) 方法后，上述代码会打印出 10 次 “listen” 并结束调用。

**结构体**

[`sync.Cond`](https://draveness.me/golang/tree/sync.Cond) 的结构体中包含以下 4 个字段：

```go
type Cond struct {
	noCopy  noCopy
	L       Locker
	notify  notifyList
	checker copyChecker
}
```

* `noCopy` — 用于保证结构体不会在编译期间拷贝；
* `copyChecker` — 用于禁止运行期间发生的拷贝；
* `L` — 用于保护内部的 `notify` 字段，`Locker` 接口类型的变量；
* `notify` — 一个 Goroutine 的链表，它是实现同步机制的核心结构；

```go
type notifyList struct {
	wait uint32
	notify uint32

	lock mutex
	head *sudog
	tail *sudog
}
```

在 [`sync.notifyList`](https://draveness.me/golang/tree/sync.notifyList) 结构体中，`head` 和 `tail` 分别指向的链表的头和尾，`wait` 和 `notify` 分别表示当前正在等待的和已经通知到的 Goroutine 的索引。

**接口**

[`sync.Cond`](https://draveness.me/golang/tree/sync.Cond) 对外暴露的 [`sync.Cond.Wait`](https://draveness.me/golang/tree/sync.Cond.Wait) 方法会将当前 Goroutine 陷入休眠状态，它的执行过程分成以下两个步骤：

1. 调用 [`runtime.notifyListAdd`](https://draveness.me/golang/tree/runtime.notifyListAdd) 将等待计数器加一并解锁；
2. 调用 [`runtime.notifyListWait`](https://draveness.me/golang/tree/runtime.notifyListWait) 等待其他 Goroutine 的唤醒并加锁：

除了将当前 Goroutine 追加到链表的末端之外，我们还会调用 [`runtime.goparkunlock`](https://draveness.me/golang/tree/runtime.goparkunlock) 将当前 Goroutine 陷入休眠，该函数也是在 Go 语言切换 Goroutine 时经常会使用的方法，它会直接让出当前处理器的使用权并等待调度器的唤醒。

[`sync.Cond.Signal`](https://draveness.me/golang/tree/sync.Cond.Signal) 和 [`sync.Cond.Broadcast`](https://draveness.me/golang/tree/sync.Cond.Broadcast) 就是用来唤醒陷入休眠的 Goroutine 的方法，它们的实现有一些细微的差别：

* [`sync.Cond.Signal`](https://draveness.me/golang/tree/sync.Cond.Signal) 方法会唤醒队列最前面的 Goroutine；
* [`sync.Cond.Broadcast`](https://draveness.me/golang/tree/sync.Cond.Broadcast) 方法会唤醒队列中全部的 Goroutine；

### ErrGroup

[`golang/sync/errgroup.Group`](https://draveness.me/golang/tree/golang/sync/errgroup.Group) 为我们在一组 Goroutine 中提供了同步、错误传播以及上下文取消的功能，我们可以使用如下所示的方式并行获取网页的数据：

```go
var g errgroup.Group
var urls = []string{
    "http://www.golang.org/",
    "http://www.google.com/",
}
for i := range urls {
    url := urls[i]
    g.Go(func() error {
        resp, err := http.Get(url)
        if err == nil {
            resp.Body.Close()
        }
        return err
    })
}
if err := g.Wait(); err == nil {
    fmt.Println("Successfully fetched all URLs.")
}
```

[`golang/sync/errgroup.Group.Go`](https://draveness.me/golang/tree/golang/sync/errgroup.Group.Go) 方法能够创建一个 Goroutine 并在其中执行传入的函数，而 [`golang/sync/errgroup.Group.Wait`](https://draveness.me/golang/tree/golang/sync/errgroup.Group.Wait) 会等待所有 Goroutine 全部返回，该方法的不同返回结果也有不同的含义：

* 如果返回错误 — 这一组 Goroutine 最少返回一个错误；
* 如果返回空值 — 所有 Goroutine 都成功执行；

#### 结构体

1. `cancel` — 创建 [`context.Context`](https://draveness.me/golang/tree/context.Context) 时返回的取消函数，用于在多个 Goroutine 之间同步取消信号；
2. `wg` — 用于等待一组 Goroutine 完成子任务的同步原语；
3. `errOnce` — 用于保证只接收一个子任务返回的错误；

```go
type Group struct {
	cancel func()

	wg sync.WaitGroup

	errOnce sync.Once
	err     error
}
```

这些字段共同组成了 [`golang/sync/errgroup.Group`](https://draveness.me/golang/tree/golang/sync/errgroup.Group) 结构体并为我们提供同步、错误传播以及上下文取消等功能。

### Semaphore

信号量是在并发编程中常见的一种同步机制，在需要控制访问资源的进程数量时就会用到信号量，它会保证持有的计数器在 0 到初始化的权重之间波动。

* 每次获取资源时都会将信号量中的计数器减去对应的数值，在释放时重新加回来；
* 当遇到计数器大于信号量大小时，会进入休眠等待其他线程释放信号；

Go 语言的扩展包中就提供了带权重的信号量 [`golang/sync/semaphore.Weighted`](https://draveness.me/golang/tree/golang/sync/semaphore.Weighted)，我们可以按照不同的权重对资源的访问进行管理，这个结构体对外也只暴露了四个方法：

* [`golang/sync/semaphore.NewWeighted`](https://draveness.me/golang/tree/golang/sync/semaphore.NewWeighted) 用于创建新的信号量；
* [`golang/sync/semaphore.Weighted.Acquire`](https://draveness.me/golang/tree/golang/sync/semaphore.Weighted.Acquire) 阻塞地获取指定权重的资源，如果当前没有空闲资源，会陷入休眠等待；
* [`golang/sync/semaphore.Weighted.TryAcquire`](https://draveness.me/golang/tree/golang/sync/semaphore.Weighted.TryAcquire) 非阻塞地获取指定权重的资源，如果当前没有空闲资源，会直接返回 `false`；
* [`golang/sync/semaphore.Weighted.Release`](https://draveness.me/golang/tree/golang/sync/semaphore.Weighted.Release) 用于释放指定权重的资源；

#### SingleFlight

[`golang/sync/singleflight.Group`](https://draveness.me/golang/tree/golang/sync/singleflight.Group) 是 Go 语言扩展包中提供了另一种同步原语，它能够在一个服务中抑制对下游的多次重复请求。一个比较常见的使用场景是：我们在使用 Redis 对数据库中的数据进行缓存，发生缓存击穿时，大量的流量都会打到数据库上进而影响服务的尾延时。

在资源的获取非常昂贵时（例如：访问缓存、数据库），就很适合使用 [`golang/sync/singleflight.Group`](https://draveness.me/golang/tree/golang/sync/singleflight.Group) 优化服务。我们来了解一下它的使用方法：

```go
type service struct {
    requestGroup singleflight.Group
}

func (s *service) handleRequest(ctx context.Context, request Request) (Response, error) {
    v, err, _ := requestGroup.Do(request.Hash(), func() (interface{}, error) {
        rows, err := // select * from tables
        if err != nil {
            return nil, err
        }
        return rows, nil
    })
    if err != nil {
        return nil, err
    }
    return Response{
        rows: rows,
    }, nil
}
```

因为请求的哈希在业务上一般表示相同的请求，所以上述代码使用它作为请求的键。当然，我们也可以选择其他的字段作为 [`golang/sync/singleflight.Group.Do`](https://draveness.me/golang/tree/golang/sync/singleflight.Group.Do) 方法的第一个参数减少重复的请求。

#### 结构体

[`golang/sync/singleflight.Group`](https://draveness.me/golang/tree/golang/sync/singleflight.Group) 结构体由一个互斥锁 [`sync.Mutex`](https://draveness.me/golang/tree/sync.Mutex) 和一个映射表组成，每一个 [`golang/sync/singleflight.call`](https://draveness.me/golang/tree/golang/sync/singleflight.call) 结构体都保存了当前调用对应的信息：

```go
type Group struct {
	mu sync.Mutex
	m  map[string]*call
}

type call struct {
	wg sync.WaitGroup

	val interface{}
	err error

	dups  int
	chans []chan<- Result
}
```

[`golang/sync/singleflight.call`](https://draveness.me/golang/tree/golang/sync/singleflight.call) 结构体中的 `val` 和 `err` 字段都只会在执行传入的函数时赋值一次并在 [`sync.WaitGroup.Wait`](https://draveness.me/golang/tree/sync.WaitGroup.Wait) 返回时被读取；`dups` 和 `chans` 两个字段分别存储了抑制的请求数量以及用于同步结果的 Channel。

#### 接口

* [`golang/sync/singleflight.Group.Do`](https://draveness.me/golang/tree/golang/sync/singleflight.Group.Do) — 同步等待的方法；
* [`golang/sync/singleflight.Group.DoChan`](https://draveness.me/golang/tree/golang/sync/singleflight.Group.DoChan) — 返回 Channel 异步等待的方法；

这两个方法在功能上没有太多的区别，只是在接口的表现上稍有不同。

## 计时器

### 数据结构

[`runtime.timer`](https://draveness.me/golang/tree/runtime.timer) 是 Go 语言计时器的内部表示，每一个计时器都存储在对应处理器的最小四叉堆中，下面是运行时计时器对应的结构体：

```go
type timer struct {
	pp puintptr

	when     int64
	period   int64
	f        func(interface{}, uintptr)
	arg      interface{}
	seq      uintptr
	nextwhen int64
	status   uint32
}
```

* `when` — 当前计时器被唤醒的时间；
* `period` — 两次被唤醒的间隔；
* `f` — 每当计时器被唤醒时都会调用的函数；
* `arg` — 计时器被唤醒时调用 `f` 传入的参数；
* `nextWhen` — 计时器处于 `timerModifiedXX` 状态时，用于设置 `when` 字段；
* `status` — 计时器的状态；

然而这里的 [`runtime.timer`](https://draveness.me/golang/tree/runtime.timer) 只是计时器运行时的私有结构体，对外暴露的计时器使用 [`time.Timer`](https://draveness.me/golang/tree/time.Timer) 结体：

```go
type Timer struct {
	C <-chan Time
	r runtimeTimer
}
```

[`time.Timer`](https://draveness.me/golang/tree/time.Timer) 计时器必须通过 [`time.NewTimer`](https://draveness.me/golang/tree/time.NewTimer)、[`time.AfterFunc`](https://draveness.me/golang/tree/time.AfterFunc) 或者 [`time.After`](https://draveness.me/golang/tree/time.After) 函数创建。 当计时器失效时，订阅计时器 Channel 的 Goroutine 会收到计时器失效的时间。

### 状态机

运行时使用状态机的方式处理全部的计时器，其中包括 10 种状态和几种操作。由于 Go 语言的计时器需要同时支持增加、删除、修改和重置等操作，所以它的状态非常复杂，目前会包含以下 10 种可能：

| 状态                   | 解释          |
| -------------------- | ----------- |
| timerNoStatus        | 还没有设置状态     |
| timerWaiting         | 等待触发        |
| timerRunning         | 运行计时器函数     |
| timerDeleted         | 被删除         |
| timerRemoving        | 正在被删除       |
| timerRemoved         | 已经被停止并从堆中删除 |
| timerModifying       | 正在被修改       |
| timerModifiedEarlier | 被修改到了更早的时间  |
| timerModifiedLater   | 被修改到了更晚的时间  |
| timerMoving          | 已经被修改正在被移动  |

上述表格已经展示了不同状态的含义，但是我们还需要展示一些重要的信息，例如状态的存在时间、计时器是否在堆上等：

* `timerRunning`、`timerRemoving`、`timerModifying` 和 `timerMoving` — 停留的时间都比较短；
* `timerWaiting`、`timerRunning`、`timerDeleted`、`timerRemoving`、`timerModifying`、`timerModifiedEarlier`、`timerModifiedLater` 和 `timerMoving` — 计时器在处理器的堆上；
* `timerNoStatus` 和 `timerRemoved` — 计时器不在堆上；
* `timerModifiedEarlier` 和 `timerModifiedLater` — 计时器虽然在堆上，但是可能位于错误的位置上，需要重新排序；

当我们操作计时器时，运行时会根据状态的不同而做出反应，所以在分析计时器时会将状态作为切入点分析其实现原理。计时器的状态机中包含如下所示的 7 种不同操作，它们分别承担了不同的职责：

* [`runtime.addtimer`](https://draveness.me/golang/tree/runtime.addtimer) — 向当前处理器增加新的计时器
* [`runtime.deltimer`](https://draveness.me/golang/tree/runtime.deltimer) — 将计时器标记成 `timerDeleted` 删除处理器中的计时器
* [`runtime.modtimer`](https://draveness.me/golang/tree/runtime.modtimer) — 网络轮询器会调用该函数修改计时器
* [`runtime.cleantimers`](https://draveness.me/golang/tree/runtime.cleantimers) — 清除队列头中的计时器，能够提升程序创建和删除计时器的性能
* [`runtime.adjusttimers`](https://draveness.me/golang/tree/runtime.adjusttimers) — 调整处理器持有的计时器堆，包括移动会稍后触发的计时器、删除标记为 `timerDeleted` 的计时器
* [`runtime.runtimer`](https://draveness.me/golang/tree/runtime.runtimer) — 检查队列头中的计时器，在其准备就绪时运行该计时器 \[

标准库中的计时器在大多数情况下是能够正常工作并且高效完成任务的，但是在遇到极端情况或者性能敏感场景时，它可能没有办法胜任，而在 10ms 的这个粒度中，作者在社区中也没有找到能够使用的计时器实现，一些使用时间轮算法的开源库也不能很好地完成这个任务。

## Channel

### 设计原理

#### 先进先出

* 先从 Channel 读取数据的 Goroutine 会先接收到数据；
* 先向 Channel 发送数据的 Goroutine 会得到先发送数据的权利；

#### 无锁管道

乐观并发控制本质上是基于验证的协议，我们使用原子指令 CAS（compare-and-swap 或者 compare-and-set）在多线程中同步数据，无锁队列的实现也依赖这一原子指令。

Channel 在运行时的内部表示是 [`runtime.hchan`](https://draveness.me/golang/tree/runtime.hchan)，该结构体中包含了用于保护成员变量的互斥锁，从某种程度上说，Channel 是一个用于同步和通信的有锁队列，使用互斥锁解决程序中可能存在的线程竞争问题是很常见的，我们能很容易地实现有锁队列。

然而锁导致的休眠和唤醒会带来额外的上下文切换，如果临界区 [6](https://draveness.me/golang/docs/part3-runtime/ch06-concurrency/golang-channel/#fn:6) 过大，加锁解锁导致的额外开销就会成为性能瓶颈。

因为目前通过 CAS 实现的无锁 Channel 没有提供先进先出的特性，所以该提案暂时也被搁浅了。

### 数据结构

Go 语言的 Channel 在运行时使用 [`runtime.hchan`](https://draveness.me/golang/tree/runtime.hchan) 结构体表示。我们在 Go 语言中创建新的 Channel 时，实际上创建的都是如下所示的结构：

```go
type hchan struct {
	qcount   uint
	dataqsiz uint
	buf      unsafe.Pointer
	elemsize uint16
	closed   uint32
	elemtype *_type
	sendx    uint
	recvx    uint
	recvq    waitq
	sendq    waitq

	lock mutex
}
```

[`runtime.hchan`](https://draveness.me/golang/tree/runtime.hchan) 结构体中的五个字段 `qcount`、`dataqsiz`、`buf`、`sendx`、`recv` 构建底层的循环队列：

* `qcount` — Channel 中的元素个数；
* `dataqsiz` — Channel 中的循环队列的长度；
* `buf` — Channel 的缓冲区数据指针；
* `sendx` — Channel 的发送操作处理到的位置；
* `recvx` — Channel 的接收操作处理到的位置；

除此之外，`elemsize` 和 `elemtype` 分别表示当前 Channel 能够收发的元素类型和大小；`sendq` 和 `recvq` 存储了当前 Channel 由于缓冲区空间不足而阻塞的 Goroutine 列表，这些等待队列使用双向链表 [`runtime.waitq`](https://draveness.me/golang/tree/runtime.waitq) 表示，链表中所有的元素都是 [`runtime.sudog`](https://draveness.me/golang/tree/runtime.sudog) 结构：

```go
type waitq struct {
	first *sudog
	last  *sudog
}
```

[`runtime.sudog`](https://draveness.me/golang/tree/runtime.sudog) 表示一个在等待列表中的 Goroutine，该结构中存储了两个分别指向前后 [`runtime.sudog`](https://draveness.me/golang/tree/runtime.sudog) 的指针以构成链表。

### 发送数据

1. 如果当前 Channel 的 `recvq` 上存在已经被阻塞的 Goroutine，那么会直接将数据发送给当前 Goroutine 并将其设置成下一个运行的 Goroutine；
2. 如果 Channel 存在缓冲区并且其中还有空闲的容量，我们会直接将数据存储到缓冲区 `sendx` 所在的位置上；
3. 如果不满足上面的两种情况，会创建一个 [`runtime.sudog`](https://draveness.me/golang/tree/runtime.sudog) 结构并将其加入 Channel 的 `sendq` 队列中，当前 Goroutine 也会陷入阻塞等待其他的协程从 Channel 接收数据；

发送数据的过程中包含几个会触发 Goroutine 调度的时机：

1. 发送数据时发现 Channel 上存在等待接收数据的 Goroutine，立刻设置处理器的 `runnext` 属性，但是并不会立刻触发调度；
2. 发送数据时并没有找到接收方并且缓冲区已经满了，这时会将自己加入 Channel 的 `sendq` 队列并调用 [`runtime.goparkunlock`](https://draveness.me/golang/tree/runtime.goparkunlock) 触发 Goroutine 的调度让出处理器的使用权；

### 接收数据

1. 如果 Channel 为空，那么会直接调用 [`runtime.gopark`](https://draveness.me/golang/tree/runtime.gopark) 挂起当前 Goroutine；
2. 如果 Channel 已经关闭并且缓冲区没有任何数据，[`runtime.chanrecv`](https://draveness.me/golang/tree/runtime.chanrecv) 会直接返回；
3. 如果 Channel 的 `sendq` 队列中存在挂起的 Goroutine，会将 `recvx` 索引所在的数据拷贝到接收变量所在的内存空间上并将 `sendq` 队列中 Goroutine 的数据拷贝到缓冲区；
4. 如果 Channel 的缓冲区中包含数据，那么直接读取 `recvx` 索引对应的数据；
5. 在默认情况下会挂起当前的 Goroutine，将 [`runtime.sudog`](https://draveness.me/golang/tree/runtime.sudog) 结构加入 `recvq` 队列并陷入休眠等待调度器的唤醒；

总结一下从 Channel 接收数据时，会触发 Goroutine 调度的两个时机：

1. 当 Channel 为空时；
2. 当缓冲区中不存在数据并且也不存在数据的发送者时；

## 调度器

### 多线程调度器

多线程调度器的主要问题是调度时的锁竞争会严重浪费资源，[Scalable Go Scheduler Design Doc](http://golang.org/s/go11sched) 中对调度器做的性能测试发现 14% 的时间都花费在 [`runtime.futex:go1.0.1`](https://draveness.me/golang/tree/runtime.futex:go1.0.1) 上，该调度器有以下问题需要解决：

1. 调度器和锁是全局资源，所有的调度状态都是中心化存储的，锁竞争问题严重；
2. 线程需要经常互相传递可运行的 Goroutine，引入了大量的延迟；
3. 每个线程都需要处理内存缓存，导致大量的内存占用并影响数据局部性；
4. 系统调用频繁阻塞和解除阻塞正在运行的线程，增加了额外开销；

### 任务窃取调度器

1. 在当前的 G-M 模型中引入了处理器 P，增加中间层；
2. 在处理器 P 的基础上实现基于工作窃取的调度器；

当前处理器本地的运行队列中不包含 Goroutine 时，调用 [`runtime.findrunnable:779c45a`](https://draveness.me/golang/tree/runtime.findrunnable:779c45a) 会触发工作窃取，从其它的处理器的队列中随机获取一些 Goroutine。

基于工作窃取的多线程调度器将每一个线程绑定到了独立的 CPU 上，这些线程会被不同处理器管理，不同的处理器通过工作窃取对任务进行再分配实现任务的平衡，也能提升调度器和 Go 语言程序的整体性能，今天所有的 Go 语言服务都受益于这一改动。

### 抢占式调度器

对 Go 语言并发模型的修改提升了调度器的性能，但是 1.1 版本中的调度器仍然不支持抢占式调度，程序只能依靠 Goroutine 主动让出 CPU 资源才能触发调度。Go 语言的调度器在 1.2 版本中引入基于协作的抢占式调度解决下面的问题

* 某些 Goroutine 可以长时间占用线程，造成其它 Goroutine 的饥饿；
* 垃圾回收需要暂停整个程序（Stop-the-world，STW），最长可能需要几分钟的时间，导致整个程序无法工作；

1.2 版本的抢占式调度虽然能够缓解这个问题，但是它实现的抢占式调度是基于协作的，在之后很长的一段时间里 Go 语言的调度器都有一些无法被抢占的边缘情况，例如：for 循环或者垃圾回收长时间占用线程，这些问题中的一部分直到 1.14 才被基于信号的抢占式调度解决。

### 数据结构

1. G — 表示 Goroutine，它是一个待执行的任务；
2. M — 表示操作系统的线程，它由操作系统的调度器调度和管理；
3. P — 表示处理器，它可以被看做运行在线程上的本地调度器；

#### G

Goroutine 是 Go 语言调度器中待执行的任务，它在运行时调度器中的地位与线程在操作系统中差不多，但是它占用了更小的内存空间，也降低了上下文切换的开销。

Goroutine 只存在于 Go 语言的运行时，它是 Go 语言在用户态提供的线程，作为一种粒度更细的资源调度单元，如果使用得当能够在高并发的场景下更高效地利用机器的 CPU。

Goroutine 在 Go 语言运行时使用私有结构体 [`runtime.g`](https://draveness.me/golang/tree/runtime.g) 表示。这个私有结构体非常复杂，总共包含 40 多个用于表示各种状态的成员变量，这里也不会介绍所有的字段，仅会挑选其中的一部分。

```go
type g struct {
	preempt       bool // 抢占信号
	preemptStop   bool // 抢占时将状态修改成 `_Gpreempted`
	preemptShrink bool // 在同步安全点收缩栈
}
```

Goroutine 与我们在前面章节提到的 `defer` 和 `panic` 也有千丝万缕的联系，每一个 Goroutine 上都持有两个分别存储 `defer` 和 `panic` 对应结构体的链表：

```go
type g struct {
	_panic       *_panic // 最内侧的 panic 结构体
	_defer       *_defer // 最内侧的延迟函数结构体
}
```

最后，我们再节选一些比较有趣或者重要的字段：

```go
type g struct {
	m              *m
	sched          gobuf
	atomicstatus   uint32
	goid           int64
}
```

* `m` — 当前 Goroutine 占用的线程，可能为空；
* `atomicstatus` — Goroutine 的状态；
* `sched` — 存储 Goroutine 的调度相关的数据；
* `goid` — Goroutine 的 ID，该字段对开发者不可见，Go 团队认为引入 ID 会让部分 Goroutine 变得更特殊，从而限制语言的并发能力；

#### M

Go 语言并发模型中的 M 是操作系统线程。调度器最多可以创建 10000 个线程，但是其中大多数的线程都不会执行用户代码（可能陷入系统调用），最多只会有 `GOMAXPROCS` 个活跃线程能够正常运行。

在默认情况下，运行时会将 `GOMAXPROCS` 设置成当前机器的核数，我们也可以在程序中使用 [`runtime.GOMAXPROCS`](https://draveness.me/golang/tree/runtime.GOMAXPROCS) 来改变最大的活跃线程数。一个四核机器会创建四个活跃的操作系统线程，每一个线程都对应一个运行时中的 [`runtime.m`](https://draveness.me/golang/tree/runtime.m) 结构体。

在大多数情况下，我们都会使用 Go 的默认设置，也就是线程数等于 CPU 数，默认的设置不会频繁触发操作系统的线程调度和上下文切换，所有的调度都会发生在用户态，由 Go 语言调度器触发，能够减少很多额外开销。

Go 语言会使用私有结构体 [`runtime.m`](https://draveness.me/golang/tree/runtime.m) 表示操作系统线程

```go
type m struct {
	g0   *g
	curg *g
	...
}
```

其中 g0 是持有调度栈的 Goroutine，`curg` 是在当前线程上运行的用户 Goroutine，这也是操作系统线程唯一关心的两个 Goroutine。

#### P

调度器中的处理器 P 是线程和 Goroutine 的中间层，它能提供线程需要的上下文环境，也会负责调度线程上的等待队列，通过处理器 P 的调度，每一个内核线程都能够执行多个 Goroutine，它能在 Goroutine 进行一些 I/O 操作时及时让出计算资源，提高线程的利用率。

因为调度器在启动时就会创建 `GOMAXPROCS` 个处理器，所以 Go 语言程序的处理器数量一定会等于 `GOMAXPROCS`，这些处理器会绑定到不同的内核线程上。

[`runtime.p`](https://draveness.me/golang/tree/runtime.p) 是处理器的运行时表示，作为调度器的内部实现，它包含的字段也非常多，其中包括与性能追踪、垃圾回收和计时器相关的字段，我们主要关注处理器中的线程和运行队列：

```go
type p struct {
	m           muintptr

	runqhead uint32
	runqtail uint32
	runq     [256]guintptr
	runnext guintptr
	...
}
```

反向存储的线程维护着线程与处理器之间的关系，而 `runqhead`、`runqtail` 和 `runq` 三个字段表示处理器持有的运行队列，其中存储着待执行的 Goroutine 列表，`runnext` 中是线程下一个需要执行的 Goroutine。

### 运行队列

Go 语言有两个运行队列，其中一个是处理器本地的运行队列，另一个是调度器持有的全局运行队列，只有在本地运行队列没有剩余空间时才会使用全局队列。

## 网络轮询器

### 设计原理

#### 堵塞 I/O

阻塞 I/O 是最常见的 I/O 模型，在默认情况下，当我们通过 `read` 或者 `write` 等系统调用读写文件或者网络时，应用程序会被阻塞：

```c
ssize_t read(int fd, void *buf, size_t count);
ssize_t write(int fd, const void *buf, size_t nbytes);
```

#### 非阻塞 I/O

第一次从文件描述符中读取数据会触发系统调用并返回 `EAGAIN` 错误，`EAGAIN` 意味着该文件描述符还在等待缓冲区中的数据；随后，应用程序会不断轮询调用 `read` 直到它的返回值大于 0，这时应用程序就可以对读取操作系统缓冲区中的数据并进行操作。进程使用非阻塞的 I/O 操作时，可以在等待过程中执行其他任务，提高 CPU 的利用率。

#### I/O 多路复用

I/O 多路复用被用来处理同一个事件循环中的多个 I/O 事件。I/O 多路复用需要使用特定的系统调用，最常见的系统调用是 [`select`](https://github.com/torvalds/linux/blob/f757165705e92db62f85a1ad287e9251d1f2cd82/fs/select.c#L722)，该函数可以同时监听最多 1024 个文件描述符的可读或者可写状态：

```c
int select(int nfds, fd_set *restrict readfds, fd_set *restrict writefds, fd_set *restrict errorfds, struct timeval *restrict timeout);
```

除了标准的 [`select`](https://github.com/torvalds/linux/blob/f757165705e92db62f85a1ad287e9251d1f2cd82/fs/select.c#L722) 之外，操作系统中还提供了一个比较相似的 `poll` 函数，它使用链表存储文件描述符，摆脱了 1024 的数量上限。

多路复用函数会阻塞的监听一组文件描述符，当文件描述符的状态转变为可读或者可写时，`select` 会返回可读或者可写事件的个数，应用程序可以在输入的文件描述符中查找哪些可读或者可写，然后执行相应的操作。

#### 多模块

为了提高 I/O 多路复用的性能，不同的操作系统也都实现了自己的 I/O 多路复用函数，例如：`epoll`、`kqueue` 和 `evport` 等。Go 语言为了提高在不同操作系统上的 I/O 操作性能，使用平台特定的函数实现了多个版本的网络轮询模块：

* [`src/runtime/netpoll_epoll.go`](https://github.com/golang/go/blob/master/src/runtime/netpoll_epoll.go)
* [`src/runtime/netpoll_kqueue.go`](https://github.com/golang/go/blob/master/src/runtime/netpoll_kqueue.go)
* [`src/runtime/netpoll_solaris.go`](https://github.com/golang/go/blob/master/src/runtime/netpoll_solaris.go)
* [`src/runtime/netpoll_windows.go`](https://github.com/golang/go/blob/master/src/runtime/netpoll_windows.go)
* [`src/runtime/netpoll_aix.go`](https://github.com/golang/go/blob/master/src/runtime/netpoll_aix.go)
* [`src/runtime/netpoll_fake.go`](https://github.com/golang/go/blob/master/src/runtime/netpoll_fake.go)

这些模块在不同平台上实现了相同的功能，构成了一个常见的树形结构。编译器在编译 Go 语言程序时，会根据目标平台选择树中特定的分支进行编译。

### 数据结构

操作系统中 I/O 多路复用函数会监控文件描述符的可读或者可写，而 Go 语言网络轮询器会监听 [`runtime.pollDesc`](https://draveness.me/golang/tree/runtime.pollDesc) 结构体的状态，它会封装操作系统的文件描述符：

```go
type pollDesc struct {
	link *pollDesc

	lock    mutex
	fd      uintptr
	...
	rseq    uintptr
	rg      uintptr
	rt      timer
	rd      int64
	wseq    uintptr
	wg      uintptr
	wt      timer
	wd      int64
}
```

该结构体中包含用于监控可读和可写状态的变量，我们按照功能将它们分成以下四组：

* `rseq` 和 `wseq` — 表示文件描述符被重用或者计时器被重置；
* `rg` 和 `wg` — 表示二进制的信号量，可能为 `pdReady`、`pdWait`、等待文件描述符可读或者可写的 Goroutine 以及 `nil`；
* `rd` 和 `wd` — 等待文件描述符可读或者可写的截止日期；
* `rt` 和 `wt` — 用于等待文件描述符的计时器；

## 系统监控

### 设计原理

守护进程是很有效的设计，它在整个系统的生命周期中都会存在，会随着系统的启动而启动，系统的结束而结束。在操作系统和 Kubernetes 中，我们经常会将数据库服务、日志服务以及监控服务等进程作为守护进程运行。

Go 语言的系统监控也起到了很重要的作用，它在内部启动了一个不会中止的循环，在循环的内部会轮询网络、抢占长期运行或者处于系统调用的 Goroutine 以及触发垃圾回收，通过这些行为，它能够让系统的运行状态变得更健康。

### 监控循环

系统监控在每次循环开始时都会通过 `usleep` 挂起当前线程，该函数的参数是微秒，运行时会遵循以下的规则决定休眠时间：

* 初始的休眠时间是 20μs；
* 最长的休眠时间是 10ms；
* 当系统监控在 50 个循环中都没有唤醒 Goroutine 时，休眠时间在每个循环都会倍增；

当程序趋于稳定之后，系统监控的触发时间就会稳定在 10ms。它除了会检查死锁之外，还会在循环中完成以下的工作：

* 运行计时器 — 获取下一个需要被触发的计时器；
* 轮询网络 — 获取需要处理的到期文件描述符；
* 抢占处理器 — 抢占运行时间较长的或者处于系统调用的 Goroutine；
* 垃圾回收 — 在满足条件时触发垃圾收集回收内存；

## 内存分配器

内存管理一般包含三个不同的组件，分别是用户程序（Mutator）、分配器（Allocator）和收集器（Collector），当用户程序申请内存时，它会通过内存分配器申请新内存，而分配器会负责从堆中初始化相应的内存区域。

内存分配是 Go 语言运行时内存管理的核心逻辑，运行时的内存分配器使用类似 TCMalloc 的分配策略将对象根据大小分类，并设计多层级的组件提高内存分配器的性能。


# K8s 入门实战

## 如何理解 Kubernetes 中的 Pod

**为了解决多应用联合运行的问题，同时还要不破坏容器的隔离，就需要在容器外面再建立一个“收纳舱”**，让多个容器既保持相对独立，又能够小范围共享网络、存储等资源，而且永远是“绑在一起”的状态。

Kubernetes 让 Pod 去编排处理容器，然后把 Pod 作为应用调度部署的**最小单位**，Pod 也因此成为了 Kubernetes 世界里的“原子”。

### 如何使用 YAML 描述 Pod

```yml
# kubectl apply -f ngx-pod.yml
# kubectl logs ngx-pod
# kubectl delete -f ngx-pod.yaml
# kubectl delete pod ngx-pod

# kubectl explain pod.spec
# kubectl explain pod.spec.containers
# kubectl explain pod.spec.containers.env

apiVersion: v1
kind: Pod
metadata:
  name: ngx-pod
  labels:
    env: demo
    owner: L2ncE

spec:
  containers:
  - image: nginx:alpine
    name: ngx
    ports:
    - containerPort: 80
```

## Job/CronJob

“离线业务”可以分为两种。一种是“**临时任务**”，跑完就完事了，下次有需求了说一声再重新安排；另一种是“**定时任务**”，可以按时按点周期运行，不需要过多干预。

对应到 Kubernetes 里，“临时任务”就是 API 对象**Job**，“定时任务”就是 API 对象**CronJob**，使用这两个对象你就能够在 Kubernetes 里调度管理任意的离线业务了。

### 如何使用 YAML 描述 Job

* apiVersion 不是 `v1`，而是 `batch/v1`。
* kind 是 `Job`，这个和对象的名字是一致的。
* metadata 里仍然要有 `name` 标记名字，也可以用 `labels` 添加任意的标签。

```yml
# kubectl apply -f job.yml

apiVersion: batch/v1
kind: Job
metadata:
  name: echo-job

spec:
  template:
    spec:
      restartPolicy: OnFailure
      containers:
      - image: busybox
        name: echo-job
        imagePullPolicy: IfNotPresent
        command: ["/bin/echo"]
        args: ["hello", "world"]
```

你会注意到 Job 的描述与 Pod 很像，但又有些不一样，主要的区别就在“spec”字段里，多了一个 `template` 字段，然后又是一个“spec”，显得有点怪。

它其实就是在 Job 对象里应用了组合模式，`template` 字段定义了一个“**应用模板**”，里面嵌入了一个 Pod，这样 Job 就可以从这个模板来创建出 Pod。

而这个 Pod 因为受 Job 的管理控制，不直接和 apiserver 打交道，也就没必要重复 apiVersion 等“头字段”，只需要定义好关键的 `spec`，描述清楚容器相关的信息就可以了，可以说是一个“无头”的 Pod 对象。

列出几个控制离线作业的重要字段：

* **activeDeadlineSeconds**，设置 Pod 运行的超时时间。
* **backoffLimit**，设置 Pod 的失败重试次数。
* **completions**，Job 完成需要运行多少个 Pod，默认是 1 个。
* **parallelism**，它与 completions 相关，表示允许并发运行的 Pod 数量，避免过多占用资源。

要注意这 4 个字段并不在 `template` 字段下，而是在 `spec` 字段下，所以它们是属于 Job 级别的，用来控制模板里的 Pod 对象。

### 如何使用 YAML 描述 CronJob

```yml
# kubectl apply -f cronjob.yml

apiVersion: batch/v1
kind: CronJob
metadata:
  name: echo-cj

spec:
  schedule: '*/1 * * * *'
  jobTemplate:
    spec:
      template:
        spec:
          restartPolicy: OnFailure
          containers:
          - image: busybox
            name: echo-cj
            imagePullPolicy: IfNotPresent
            command: ["/bin/echo"]
            args: ["hello", "world"]
```

我们还是重点关注它的 `spec` 字段，你会发现它居然连续有三个 `spec` 嵌套层次：

* 第一个 `spec` 是 CronJob 自己的对象规格声明
* 第二个 `spec` 从属于“jobTemplate”，它定义了一个 Job 对象。
* 第三个 `spec` 从属于“template”，它定义了 Job 里运行的 Pod。

## ConfigMap/Secret

应用程序有很多类别的配置信息，但从数据安全的角度来看可以分成两类：

* 一类是明文配置，也就是不保密，可以任意查询修改，比如服务端口、运行参数、文件路径等等。
* 另一类则是机密配置，由于涉及敏感信息需要保密，不能随便查看，比如密码、密钥、证书等等。

这两类配置信息本质上都是字符串，只是由于安全性的原因，在存放和使用方面有些差异，所以 Kubernetes 也就定义了两个 API 对象，**ConfigMap**用来保存明文配置，**Secret**用来保存秘密配置。

### 如何使用 YAML 描述 ConfigMap

```yml
# kubectl apply  -f cm.yml
# kubectl delete -f cm.yml

apiVersion: v1
kind: ConfigMap
metadata:
  name: info

data:
  count: '10'
  debug: 'on'
  path: '/etc/systemd'
  greeting: |
    say hello to kubernetes.
```

既然 ConfigMap 要存储数据，我们就需要用另一个含义更明确的字段“**data**”。

### 如何使用 YAML 描述 Secret

了解了 ConfigMap 对象，我们再来看 Secret 对象就会容易很多，它和 ConfigMap 的结构和用法很类似，不过在 Kubernetes 里 Secret 对象又细分出很多类，比如：

* 访问私有镜像仓库的认证信息
* 身份识别的凭证信息
* HTTPS 通信的证书和私钥
* 一般的机密信息（格式由用户自行解释）

```yml
# echo -n "123456" | base64 # MTIzNDU2
# echo -n "mysql" | base64  # bXlzcWw=

# kubectl apply  -f secret.yml
# kubectl delete -f secret.yml

apiVersion: v1
kind: Secret
metadata:
  name: user

data:
  name: cm9vdA==
  pwd: MTIzNDU2
  db: bXlzcWw=
```

### 如何使用

因为 ConfigMap 和 Secret 只是一些存储在 etcd 里的字符串，所以如果想要在运行时产生效果，就必须要以某种方式“**注入**”到 Pod 里，让应用去读取。在这方面的处理上 Kubernetes 和 Docker 是一样的，也是两种途径：**环境变量**和**加载文件**。

#### 环境变量

```yml
# kubectl apply -f env-pod.yml
# kubectl get pod
# kubectl exec -it env-pod -- sh

# in Pod:
# echo $COUNT
# echo $GREETING
# echo $USERNAME
# echo $PASSWORD

apiVersion: v1
kind: Pod
metadata:
  name: env-pod

spec:
  containers:
  - env:
      - name: COUNT
        valueFrom:
          configMapKeyRef:
            name: info
            key: count
      - name: GREETING
        valueFrom:
          configMapKeyRef:
            name: info
            key: greeting
      - name: USERNAME
        valueFrom:
          secretKeyRef:
            name: user
            key: name
      - name: PASSWORD
        valueFrom:
          secretKeyRef:
            name: user
            key: pwd

    image: busybox
    name: busy
    imagePullPolicy: IfNotPresent
    command: ["/bin/sleep", "300"]

```

“**valueFrom**”字段指定了环境变量值的来源，可以是“**configMapKeyRef**”或者“**secretKeyRef**”，然后你要再进一步指定应用的 ConfigMap/Secret 的“**name**”和它里面的“**key**”，要当心的是这个“name”字段是 API 对象的名字，而不是 Key-Value 的名字。

#### Volume

在 Pod 里挂载 Volume 很容易，只需要在“**spec**”里增加一个“**volumes**”字段，然后再定义卷的名字和引用的 ConfigMap/Secret 就可以了。要注意的是 Volume 属于 Pod，不属于容器，所以它和字段“containers”是同级的，都属于“spec”。

```yml
# kubectl apply -f vol-pod.yml
# kubectl get pod
# kubectl exec -it vol-pod -- sh

# in Pod:
# cat /tmp/cm-items/greeting
# cat /tmp/sec-items/db

apiVersion: v1
kind: Pod
metadata:
  name: vol-pod

spec:
  volumes:
  - name: cm-vol
    configMap:
      name: info
  - name: sec-vol
    secret:
      secretName: user

  containers:
  - volumeMounts:
    - mountPath: /tmp/cm-items
      name: cm-vol
    - mountPath: /tmp/sec-items
      name: sec-vol

    image: busybox
    name: busy
    imagePullPolicy: IfNotPresent
    command: ["/bin/sleep", "300"]

```

首先需要 Volume 的定义，有了 Volume 的定义之后，就可以在容器里挂载了，这要用到“**volumeMounts**”字段，正如它的字面含义，可以把定义好的 Volume 挂载到容器里的某个路径下，所以需要在里面用“**mountPath**”“**name**”明确地指定挂载路径和 Volume 的名字。

因为这种形式上的差异，以 Volume 的方式来使用 ConfigMap/Secret，就和环境变量不太一样。环境变量用法简单，更适合存放简短的字符串，而 Volume 更适合存放大数据量的配置文件，在 Pod 里加载成文件后让应用直接读取使用。

## Deployment

Pod 只能管理容器，不能管理自身，所以就出现了 Deployment，由它来管理 Pod。用来管理 Pod，实现在线业务应用的新 API 对象，就是 Deployment。

### 如何使用 YAML 描述 Deployment

```yml
# kubectl apply -f deploy.yml

apiVersion: apps/v1
kind: Deployment
metadata:
  name: ngx-dep
  labels:
    app: ngx-dep

spec:
  replicas: 2
  selector:
    matchLabels:
      app: ngx-dep

  template:
    metadata:
      labels:
        app: ngx-dep
    spec:
      containers:
      - image: nginx:alpine
        name: nginx
        ports:
        - containerPort: 80
```

Kubernetes 采用的是这种“贴标签”的方式，通过在 API 对象的“metadata”元信息里加各种标签（labels），我们就可以使用类似关系数据库里查询语句的方式，筛选出具有特定标识的那些对象。**通过标签这种设计，Kubernetes 就解除了 Deployment 和模板里 Pod 的强绑定，把组合关系变成了“弱引用”**。

## DaemonSet

Deployment 并不关心这些 Pod 会在集群的哪些节点上运行，**在它看来，Pod 的运行环境与功能是无关的，只要 Pod 的数量足够，应用程序应该会正常工作**。

这个假设对于大多数业务来说是没问题的，比如 Nginx、WordPress、MySQL，它们不需要知道集群、节点的细节信息，只要配置好环境变量和存储卷，在哪里“跑”都是一样的。

但是有一些业务比较特殊，它们不是完全独立于系统运行的，而是与主机存在“绑定”关系，必须要依附于节点才能产生价值，比如说：

* 网络应用（如 kube-proxy），必须每个节点都运行一个 Pod，否则节点就无法加入 Kubernetes 网络。
* 监控应用（如 Prometheus），必须每个节点都有一个 Pod 用来监控节点的状态，实时上报信息。
* 日志应用（如 Fluentd），必须在每个节点上运行一个 Pod，才能够搜集容器运行时产生的日志数据。
* 安全应用，同样的，每个节点都要有一个 Pod 来执行安全审计、入侵检查、漏洞扫描等工作。

这些业务如果用 Deployment 来部署就不太合适了，因为 Deployment 所管理的 Pod 数量是固定的，而且可能会在集群里“漂移”，但，实际的需求却是要在集群里的每个节点上都运行 Pod，也就是说 Pod 的数量与节点数量保持同步。

所以，Kubernetes 就定义了新的 API 对象 DaemonSet，它在形式上和 Deployment 类似，都是管理控制 Pod，但管理调度策略却不同。DaemonSet 的目标是在集群的每个节点上运行且仅运行一个 Pod，就好像是为节点配上一只“看门狗”，忠实地“守护”着节点，这就是 DaemonSet 名字的由来。

### 如何使用 YAML 描述 DaemonSet

```yml
# kubectl apply -f ds.yml

apiVersion: apps/v1
kind: DaemonSet
metadata:
  name: redis-ds
  labels:
    app: redis-ds

spec:
  selector:
    matchLabels:
      name: redis-ds

  template:
    metadata:
      labels:
        name: redis-ds

    spec:
      containers:
      - name: redis5
        image: redis:5-alpine
        ports:
        - containerPort: 6379

      tolerations:
      # this toleration is to have the daemonset runnable on master nodes
      # remove it if your masters can't run pods
      - key: node-role.kubernetes.io/master
        effect: NoSchedule
        operator: Exists
```

## Service

Service 使用了 iptables 技术，每个节点上的 kube-proxy 组件自动维护 iptables 规则，客户不再关心 Pod 的具体地址，只要访问 Service 的固定 IP 地址，Service 就会根据 iptables 规则转发请求给它管理的多个 Pod，是典型的负载均衡架构。

不过 Service 并不是只能使用 iptables 来实现负载均衡，它还有另外两种实现技术：性能更差的 userspace 和性能更好的 ipvs。

### 如何使用 YAML 描述 Service

```yml
# kubectl apply -f svc.yml
# kubectl describe svc ngx-svc

apiVersion: v1
kind: Service
metadata:
  name: ngx-svc

spec:
  selector:
    app: ngx-dep

  ports:
  - port: 80
    protocol: TCP
    targetPort: 80

  #type: ClusterIP
  type: NodePort
```

`selector` 和 Deployment/DaemonSet 里的作用是一样的，用来过滤出要代理的那些 Pod。因为我们指定要代理 Deployment，所以 Kubernetes 就为我们自动填上了 ngx-dep 的标签，会选择这个 Deployment 对象部署的所有 Pod。

Service 对象有一个关键字段“**type**”，表示 Service 是哪种类型的负载均衡。前面我们看到的用法都是对集群内部 Pod 的负载均衡，所以这个字段的值就是默认的“**ClusterIP**”，Service 的静态 IP 地址只能在集群内访问。

除了“ClusterIP”，Service 还支持其他三种类型，分别是“**ExternalName**”“**LoadBalancer**”“**NodePort**”。不过前两种类型一般由云服务商提供。

如果我们在使用命令 `kubectl expose` 的时候加上参数 `--type=NodePort`，或者在 YAML 里添加字段 `type:NodePort`，那么 Service 除了会对后端的 Pod 做负载均衡之外，还会在集群里的每个节点上创建一个独立的端口，用这个端口对外提供服务，这也正是“NodePort”这个名字的由来。

### 如何以域名的方式使用 Service

Service 对象的 IP 地址是静态的，保持稳定，这在微服务里确实很重要，不过数字形式的 IP 地址用起来还是不太方便。这个时候 Kubernetes 的 DNS 插件就派上了用处，它可以为 Service 创建易写易记的域名，让 Service 更容易使用。

namespace 的简写是“**ns**”，你可以使用命令 `kubectl get ns` 来查看当前集群里都有哪些名字空间，也就是说 API 对象有哪些分组。

Service 对象的域名完全形式是“**对象.名字空间.svc.cluster.local**”，但很多时候也可以省略后面的部分，直接写“**对象.名字空间**”甚至“**对象名**”就足够了，默认会使用对象所在的名字空间。

Kubernetes 也为每个 Pod 分配了域名，形式是“**IP 地址.名字空间.pod.cluster.local**”，但需要把 IP 地址里的 `.` 改成 `-` 。比如地址 `10.10.1.87`，它对应的域名就是 `10-10-1-87.default.pod`。

### 缺点

1. 端口数量很有限。Kubernetes 为了避免端口冲突，默认只在“30000\~32767”这个范围内随机分配，只有 2000 多个，而且都不是标准端口号，这对于具有大量业务应用的系统来说根本不够用。
2. 它会在每个节点上都开端口，然后使用 kube-proxy 路由到真正的后端 Service，这对于有很多计算节点的大集群来说就带来了一些网络通信成本，不是特别经济。
3. 它要求向外界暴露节点的 IP 地址，这在很多时候是不可行的，为了安全还需要在集群外再搭一个反向代理，增加了方案的复杂度。

## Ingress

Ingress 可以说是在七层上另一种形式的 Service，它同样会代理一些后端的 Pod，也有一些路由规则来定义流量应该如何分配、转发，只不过这些规则都使用的是 HTTP/HTTPS 协议。

### Ingress Controller

Service 本身是没有服务能力的，它只是一些 iptables 规则，**真正配置、应用这些规则的实际上是节点里的 kube-proxy 组件**。如果没有 kube-proxy，Service 定义得再完善也没有用。

同样的，Ingress 也只是一些 HTTP 路由规则的集合，相当于一份静态的描述文件，真正要把这些规则在集群里实施运行，还需要有另外一个东西，这就是 `Ingress Controller`，它的作用就相当于 Service 的 kube-proxy，能够读取、应用 Ingress 规则，处理、调度流量。

### Ingress Class

* 由于某些原因，项目组需要引入不同的 Ingress Controller，但 Kubernetes 不允许这样做；
* Ingress 规则太多，都交给一个 Ingress Controller 处理会让它不堪重负；
* 多个 Ingress 对象没有很好的逻辑分组方式，管理和维护成本很高；
* 集群里有不同的租户，他们对 Ingress 的需求差异很大甚至有冲突，无法部署在同一个 Ingress Controller 上。

所以，Kubernetes 就又提出了一个 `Ingress Class` 的概念，让它插在 Ingress 和 Ingress Controller 中间，作为流量规则和控制器的协调人，解除了 Ingress 和 Ingress Controller 的强绑定关系。

### 如何使用 YAML 描述 Ingress/Ingress Class

```yml
# kubectl create ing ngx-ing --rule="ngx.test/=ngx-svc:80" $out
# kubectl create ing ngx-ing --rule="ngx.test/=ngx-svc:80" --class=ngx-ink $out

# curl 127.1/nginx-health
# curl 127.1:8081/nginx-ready

---

apiVersion: networking.k8s.io/v1
kind: IngressClass
metadata:
  name: ngx-ink

spec:
  controller: nginx.org/ingress-controller

---

apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
  name: ngx-ing

  # customize the behaviors of nginx
  annotations:
    nginx.org/lb-method: round_robin

spec:
  ingressClassName: ngx-ink

  rules:
  - host: ngx.test
    http:
      paths:
      - path: /
        pathType: Prefix
        backend:
          service:
            name: ngx-svc
            port:
              number: 80
```

## PersistentVolume

**作为存储的抽象，PV 实际上就是一些存储设备、文件系统**，比如 Ceph、GlusterFS、NFS，甚至是本地磁盘，管理它们已经超出了 Kubernetes 的能力范围，所以，一般会由系统管理员单独维护，然后再在 Kubernetes 里创建对应的 PV。要注意的是，PV 属于集群的系统资源，是和 Node 平级的一种对象，Pod 对它没有管理权，只有使用权。

这么多种存储设备，只用一个 PV 对象来管理还是有点太勉强了，不符合“单一职责”的原则，让 Pod 直接去选择 PV 也很不灵活。于是 Kubernetes 就又增加了两个新对象，**PersistentVolumeClaim**和**StorageClass**，用的还是“中间层”的思想，把存储卷的分配管理过程再次细化。

PersistentVolumeClaim，简称 PVC，从名字上看比较好理解，就是用来向 Kubernetes 申请存储资源的。PVC 是给 Pod 使用的对象，它相当于是 Pod 的代理，代表 Pod 向系统申请 PV。一旦资源申请成功，Kubernetes 就会把 PV 和 PVC 关联在一起，这个动作叫做“**绑定**”（bind）。

但是，系统里的存储资源非常多，如果要 PVC 去直接遍历查找合适的 PV 也很麻烦，所以就要用到 StorageClass。StorageClass 的作用有点像里的 IngressClass，它抽象了特定类型的存储系统（比如 Ceph、NFS），在 PVC 和 PV 之间充当“协调人”的角色，帮助 PVC 找到合适的 PV。也就是说它可以简化 Pod 挂载“虚拟盘”的过程，让 Pod 看不到 PV 的实现细节。

### 使用 YAML 描述 PersistentVolume/PersistentVolumeClaim

```yml
# kubectl get pv
# kubectl get pvc

# kubectl exec -it host-pvc-pod -- sh
# echo aaa > /tmp/a.txt
#
# check node's /tmp/host-10m-pv

---

apiVersion: v1
kind: PersistentVolume
metadata:
  name: host-10m-pv

spec:
  storageClassName: host-test

  accessModes:
  - ReadWriteOnce
  capacity:
    storage: 10Mi

  # mkdir -p /tmp/host-10m-pv/
  hostPath:
    path: /tmp/host-10m-pv/

---

# pvc
# try to find the most suitable pv
# capacity/accessModes
apiVersion: v1
kind: PersistentVolumeClaim

metadata:
  name: host-5m-pvc

spec:

  storageClassName: host-test

  accessModes:
    - ReadWriteOnce

  resources:
    requests:
      storage: 5Mi

---

# pod
apiVersion: v1
kind: Pod
metadata:
  name: host-pvc-pod

spec:

  volumes:
  - name: host-pvc-vol
    persistentVolumeClaim:
      claimName: host-5m-pvc

  containers:
    - name: ngx-pvc-pod
      image: nginx:alpine
      ports:
      - containerPort: 80
      volumeMounts:
      - name: host-pvc-vol
        mountPath: /tmp

---

```

PVC 的内容与 PV 很像，但它不表示实际的存储，而是一个“申请”或者“声明”，spec 里的字段描述的是对存储的“期望状态”。

所以 PVC 里的 `storageClassName`、`accessModes` 和 PV 是一样的，**但不会有字段 `capacity`，而是要用 `resources.request` 表示希望要有多大的容量**。

## StatefulSet

对于“有状态应用”，多个实例之间可能存在依赖关系，比如 master/slave、active/passive，需要依次启动才能保证应用正常运行，外界的客户端也可能要使用固定的网络标识来访问实例，而且这些信息还必须要保证在 Pod 重启后不变。

所以，Kubernetes 就在 Deployment 的基础之上定义了一个新的 API 对象，名字也很好理解，就叫 StatefulSet，专门用来管理有状态的应用。

### 如何使用 YAML 描述 StatefulSet

```yml
---

apiVersion: apps/v1
kind: StatefulSet
metadata:
  name: redis-sts

spec:
  # headless svc
  serviceName: redis-svc

  replicas: 2
  selector:
    matchLabels:
      app: redis-sts

  template:
    metadata:
      labels:
        app: redis-sts
    spec:
      containers:
      - image: redis:5-alpine
        name: redis
        ports:
        - containerPort: 6379

---

apiVersion: v1
kind: Service
metadata:
  name: redis-svc

spec:
  selector:
    app: redis-sts

  # headless
  clusterIP: None

  ports:
  - port: 6379
    protocol: TCP
    targetPort: 6379

---

```

Service 发现这些 Pod 不是一般的应用，而是有状态应用，需要有稳定的网络标识，所以就会为 Pod 再多创建出一个新的域名，格式是“**Pod 名.服务名.名字空间.svc.cluster.local**”。当然，这个域名也可以简写成“**Pod 名.服务名**”。

显然，在 StatefulSet 里的这两个 Pod 都有了各自的域名，也就是稳定的网络标识。那么接下来，外部的客户端只要知道了 StatefulSet 对象，就可以用固定的编号去访问某个具体的实例了，虽然 Pod 的 IP 地址可能会变，但这个有编号的域名由 Service 对象维护，是稳定不变的。

Service 原本的目的是负载均衡，应该由它在 Pod 前面来转发流量，但是对 StatefulSet 来说，这项功能反而是不必要的，因为 Pod 已经有了稳定的域名，外界访问服务就不应该再通过 Service 这一层了。所以，从安全和节约系统资源的角度考虑，**我们可以在 Service 里添加一个字段 `clusterIP: None` ，告诉 Kubernetes 不必再为这个对象分配 IP 地址**。

## 48. 如何实现 StatefulSet 的数据持久化

为了强调持久化存储与 StatefulSet 的一对一绑定关系，Kubernetes 为 StatefulSet 专门定义了一个字段“**volumeClaimTemplates**”，直接把 PVC 定义嵌入 StatefulSet 的 YAML 文件里。这样能保证创建 StatefulSet 的同时，就会为每个 Pod 自动创建 PVC，让 StatefulSet 的可用性更高。

```yml
---

apiVersion: apps/v1
kind: StatefulSet
metadata:
  name: redis-pv-sts

spec:
  # headless svc
  serviceName: redis-pv-svc

  # pvc
  volumeClaimTemplates:
  - metadata:
      name: redis-100m-pvc
    spec:
      storageClassName: nfs-client
      accessModes:
        - ReadWriteMany
      resources:
        requests:
          storage: 100Mi

  replicas: 2
  selector:
    matchLabels:
      app: redis-pv-sts

  template:
    metadata:
      labels:
        app: redis-pv-sts
    spec:
      containers:
      - image: redis:5-alpine
        name: redis
        ports:
        - containerPort: 6379

        volumeMounts:
        - name: redis-100m-pvc
          mountPath: /data

---

apiVersion: v1
kind: Service
metadata:
  name: redis-pv-svc

spec:
  selector:
    app: redis-pv-sts

  # headless
  clusterIP: None

  ports:
  - port: 6379
    protocol: TCP
    targetPort: 6379

---
```

## Kubernetes 滚动更新

### Kubernetes 如何定义应用版本

**在 Kubernetes 里应用的版本变化就是 `template` 里 Pod 的变化**，哪怕 `template` 里只变动了一个字段，那也会形成一个新的版本，也算是版本变化。

但 `template` 里的内容太多了，拿这么长的字符串来当做“版本号”不太现实，所以 Kubernetes 就使用了“摘要”功能，用摘要算法计算 `template` 的 Hash 值作为“版本号”，虽然不太方便识别，但是很实用。

### Kubernetes 如何实现应用更新

执行命令 `kubectl apply` 来更新应用，因为改动了镜像名，Pod 模板变了，就会触发“版本更新”，然后用一个新命令：`kubectl rollout status`，来查看应用更新的状态。

“滚动更新”就是由 Deployment 控制的两个同步进行的“应用伸缩”操作，老版本缩容到 0，同时新版本扩容到指定值，是一个“此消彼长”的过程。

### Kubernetes 如何管理应用更新

Kubernetes 的“滚动更新”功能确实非常方便，不需要任何人工干预就能简单地把应用升级到新版本，也不会中断服务，不过如果更新过程中发生了错误或者更新后发现有 Bug 该怎么办呢？

要解决这两个问题，我们还是要用 `kubectl rollout` 命令。

在应用更新的过程中，你可以随时使用 `kubectl rollout pause` 来暂停更新，检查、修改 Pod，或者测试验证，如果确认没问题，再用 `kubectl rollout resume` 来继续更新。

对于更新后出现的问题，Kubernetes 为我们提供了“后悔药”，也就是更新历史，你可以查看之前的每次更新记录，并且回退到任何位置，和我们开发常用的 Git 等版本控制软件非常类似。

查看更新历史使用的命令是 `kubectl rollout history`。**想要回退到上一个版本，就可以使用命令 `kubectl rollout undo`，也可以加上参数 `--to-revision` 回退到任意一个历史版本**。

### Kubernetes 如何添加更新描述

**只需要在 Deployment 的 `metadata` 里加上一个新的字段 `annotations`**。`annotations` 字段的含义是“注解”“注释”，形式上和 `labels` 一样，都是 Key-Value，也都是给 API 对象附加一些额外的信息，但是用途上区别很大。

* `annotations` 添加的信息一般是给 Kubernetes 内部的各种对象使用的，有点像是“扩展属性”；
* `labels` 主要面对的是 Kubernetes 外部的用户，用来筛选、过滤对象的。

## 容器资源配额

**只要在 Pod 容器的描述部分添加一个新字段 `resources` 就可以了**，它就相当于申请资源的 `Claim`。

```yml
apiVersion: v1
kind: Pod
metadata:
  name: ngx-pod-resources

spec:
  containers:
  - image: nginx:alpine
    name: ngx
    ports:
    - containerPort: 80

    resources:
      requests:
        cpu: 10m
        memory: 100Mi
      limits:
        cpu: 20m
        memory: 200Mi
```

* “**requests**”，意思是容器要申请的资源，也就是说要求 Kubernetes 在创建 Pod 的时候必须分配这里列出的资源，否则容器就无法运行。
* “**limits**”，意思是容器使用资源的上限，不能超过设定值，否则就有可能被强制停止运行。

## 容器状态探针

Kubernetes 为检查应用状态定义了三种探针，它们分别对应容器不同的状态：

* **Startup**，启动探针，用来检查应用是否已经启动成功，适合那些有大量初始化工作要做，启动很慢的应用。
* **Liveness**，存活探针，用来检查应用是否正常运行，是否存在死锁、死循环。
* **Readiness**，就绪探针，用来检查应用是否可以接收流量，是否能够对外提供服务。

如果一个 Pod 里的容器配置了探针，**Kubernetes 在启动容器后就会不断地调用探针来检查容器的状态**：

* 如果 Startup 探针失败，Kubernetes 会认为容器没有正常启动，就会尝试反复重启，当然其后面的 Liveness 探针和 Readiness 探针也不会启动。
* 如果 Liveness 探针失败，Kubernetes 就会认为容器发生了异常，也会重启容器。
* 如果 Readiness 探针失败，Kubernetes 会认为容器虽然在运行，但内部有错误，不能正常提供服务，就会把容器从 Service 对象的负载均衡集合中排除，不会给它分配流量。

```yml
# kubectl explain pod.spec.containers.startupProbe
# kubectl explain pod.spec.containers.livenessProbe
# kubectl explain pod.spec.containers.readinessProbe
#
# kubectl logs ngx-pod-probe -f

---

# this cm will be mounted to /etc/nginx/conf.d
apiVersion: v1
kind: ConfigMap
metadata:
  name: ngx-conf

data:
  default.conf: |
    server {
      listen 80;
      location = /ready {
        return 200 'I am ready';
        #return 500 'I am not ready';
      }
      location / {
        default_type text/plain;
        return 200 "Nginx OK";
      }
    }

---

apiVersion: v1
kind: Pod
metadata:
  name: ngx-pod-probe

spec:
  volumes:
  - name: ngx-conf-vol
    configMap:
      name: ngx-conf

  containers:
  - image: nginx:alpine
    name: ngx
    ports:
    - containerPort: 80

    volumeMounts:
    - mountPath: /etc/nginx/conf.d
      name: ngx-conf-vol

    # probes are here

    startupProbe:
      periodSeconds: 1
      timeoutSeconds: 1
      exec:
        command: ["cat", "/var/run/nginx.pid"]
        #command: ["cat", "nginx.pid"]  # wrong pid file

    livenessProbe:
      periodSeconds: 10
      timeoutSeconds: 1
      #failureThreshold: 1
      tcpSocket:
        #port: 80
        port: 8080

    readinessProbe:
      periodSeconds: 5
      timeoutSeconds: 1
      httpGet:
        path: /ready
        port: 80

---

```

## Kubernetes 集群管理

当多团队、多项目共用 Kubernetes 的时候，为了避免这些问题的出现，我们就需要**把集群给适当地“局部化”，为每一类用户创建出只属于它自己的“工作空间”**。

**想要把一个对象放入特定的名字空间，需要在它的 `metadata` 里添加一个 `namespace` 字段**

```yml
# kubectl create ns test-ns
# kubectl run ngx --image=nginx:alpine

---

apiVersion: v1
kind: Namespace
metadata:
  name: test-ns

---

apiVersion: v1
kind: Pod
metadata:
  name: ngx
  namespace: test-ns

spec:
  containers:
  - image: nginx:alpine
    name: ngx

---

```

`kubectl apply` 创建这个对象之后，我们直接用 `kubectl get` 是看不到它的，因为默认查看的是“default”名字空间，**想要操作其他名字空间的对象必须要用 `-n` 参数明确指定**：

```sh
kubectl get pod -n test-ns
```

因为名字空间里的对象都从属于名字空间，所以在删除名字空间的时候一定要小心，一旦名字空间被删除，它里面的所有对象也都会消失。

### 资源配额

有了名字空间，我们就可以像管理容器一样，给名字空间设定配额，把整个集群的计算资源分割成不同的大小，按需分配给团队或项目使用。

**名字空间的资源配额需要使用一个专门的 API 对象，叫做 `ResourceQuota`**，因为资源配额对象必须依附在某个名字空间上，所以在它的 `metadata` 字段里必须明确写出 `namespace`（否则就会应用到 default 名字空间）。

```yml
# kubectl create ns dev-ns $out
# kubectl create quota dev-qt $out
#
# kubectl explain quota.spec
# kubectl describe -n dev-ns quota dev-qt
#
# kubectl explain limits.spec.limits
#
# kubectl run ngx --image=nginx:alpine -n dev-ns
# kubectl describe pod ngx -n dev-ns

---

apiVersion: v1
kind: Namespace
metadata:
  name: dev-ns

---

apiVersion: v1
kind: ResourceQuota
metadata:
  name: dev-qt
  namespace: dev-ns

spec:
  hard:
    requests.cpu: 10
    requests.memory: 10Gi
    limits.cpu: 10
    limits.memory: 20Gi

    requests.storage: 100Gi
    persistentvolumeclaims: 100

    pods: 100
    configmaps: 100
    secrets: 100
    services: 10
    services.nodeports: 5

    count/jobs.batch: 1
    count/cronjobs.batch: 1
    count/deployments.apps: 1

---

apiVersion: v1
kind: LimitRange
metadata:
  name: dev-limits
  namespace: dev-ns

spec:
  limits:
  - type: Container
    defaultRequest:
      cpu: 200m
      memory: 50Mi
    default:
      cpu: 500m
      memory: 100Mi
  - type: Pod
    max:
      cpu: 800m
      memory: 200Mi

---

```

它需要在 `spec` 里使用 `hard` 字段，意思就是“**硬性全局限制**”。

* CPU 和内存配额，使用 `request.*`、`limits.*`，这是和容器资源限制是一样的。
* 存储容量配额，使 `requests.storage` 限制的是 PVC 的存储总量，也可以用 `persistentvolumeclaims` 限制 PVC 的个数。
* 核心对象配额，使用对象的名字（英语复数形式），比如 `pods`、`configmaps`、`secrets`、`services`。
* 其他 API 对象配额，使用 `count/name.group` 的形式，比如 `count/jobs.batch`、`count/deployments.apps`。

在名字空间加上了资源配额限制之后，它会有一个合理但比较“烦人”的约束：要求所有在里面运行的 Pod 都必须用字段 `resources` 声明资源需求，否则就无法创建。这个时候就要用到一个**很小但很有用的辅助对象了—— `LimitRange`，简称是 `limits`，它能为 API 对象添加默认的资源配额限制**。

* `spec.limits` 是它的核心属性，描述了默认的资源限制。
* `type` 是要限制的对象类型，可以是 `Container`、`Pod`、`PersistentVolumeClaim`。
* `default` 是默认的资源上限，对应容器里的 `resources.limits`，只适用于 `Container`。
* `defaultRequest` 默认申请的资源，对应容器里的 `resources.requests`，同样也只适用于 `Container`。
* `max`、`min` 是对象能使用的资源的最大最小值。

## Metrics Server

Metrics Server 是一个专门用来收集 Kubernetes 核心资源指标（metrics）的工具，它定时从所有节点的 kubelet 里采集信息，但是对集群的整体性能影响极小，每个节点只大约会占用 1m 的 CPU 和 2MB 的内存，所以性价比非常高。

### HorizontalPodAutoscaler

它是专门用来自动伸缩 Pod 数量的对象，适用于 Deployment 和 StatefulSet，但不能用于 DaemonSet。HorizontalPodAutoscaler 的能力完全基于 Metrics Server，它从 Metrics Server 获取当前应用的运行指标，主要是 CPU 使用率，再依据预定的策略增加或者减少 Pod 的数量。

```yml
# kubectl autoscale deploy ngx-hpa-dep --min=1 --max=10 --cpu-percent=5 $out
# kubectl apply -f hpa.yml
#
# wait some minutes for hpa monitor
#
# kubectl exec -it test -- sh
# curl ngx-hpa-svc
# ab -c 10 -t 60 -n 1000000 'http://ngx-hpa-svc/'
#
# kubectl run -it test --image=httpd:alpine -- sh

---

apiVersion: apps/v1
kind: Deployment
metadata:
  name: ngx-hpa-dep

spec:
  replicas: 1
  selector:
    matchLabels:
      app: ngx-hpa-dep

  template:
    metadata:
      labels:
        app: ngx-hpa-dep
    spec:
      containers:
      - image: nginx:alpine
        name: nginx
        ports:
        - containerPort: 80

        resources:
          requests:
            cpu: 50m
            memory: 10Mi
          limits:
            cpu: 100m
            memory: 20Mi

---

apiVersion: v1
kind: Service
metadata:
  name: ngx-hpa-svc
spec:
  ports:
  - port: 80
    protocol: TCP
    targetPort: 80
  selector:
    app: ngx-hpa-dep

---

apiVersion: autoscaling/v1
kind: HorizontalPodAutoscaler
metadata:
  name: ngx-hpa

spec:
  maxReplicas: 10
  minReplicas: 2
  scaleTargetRef:
    apiVersion: apps/v1
    kind: Deployment
    name: ngx-hpa-dep
  targetCPUUtilizationPercentage: 5

---

apiVersion: v1
kind: Pod
metadata:
  name: test
spec:
  containers:
  - image: httpd:alpine
    name: test

---

```

**注意在它的** `spec` **里一定要用 `resources` 字段写清楚资源配额**，否则 HorizontalPodAutoscaler 会无法获取 Pod 的指标，也就无法实现自动化扩缩容。


# 分布式系统典型实例

## MapReduce

### 执行流程

1. 用户程序首先调用的 MapReduce 库将输入文件分成 M 个数据片度，每个数据片段的大小一般从 16MB 到 64MB（可以通过可选的参数来控制每个数据片段的大小）。然后用户程序在机群中创建大量的程序副本。
2. 这些程序副本中的有一个特殊的程序–master。副本中其它的程序都是 worker 程序，由 master 分配任务。有 M 个 Map 任务和 R 个 Reduce 任务将被分配，master 将一个 Map 任务或 Reduce 任务分配给一个空闲的 worker。
3. 被分配了 map 任务的 worker 程序读取相关的输入数据片段，从输入的数据片段中解析出 key/value pair，然后把 key/value pair 传递给用户自定义的 Map 函数，由 Map 函数生成并输出的中间 key/value pair，并缓存在内存中。
4. 缓存中的 key/value pair 通过分区函数分成 R 个区域，之后周期性的写入到本地磁盘上。缓存的 key/value pair 在本地磁盘上的存储位置将被回传给 master，由 master 负责把这些存储位置再传送给 Reduce worker
5. 当 Reduce worker 程序接收到 master 程序发来的数据存储位置信息后，使用 RPC 从 Map worker 所在主机的磁盘上读取这些缓存数据。当 Reduce worker 读取了所有的中间数据后，通过对 key 进行排序后使得具有相同 key 值的数据聚合在一起。由于许多不同的 key 值会映射到相同的 Reduce 任务上，因此必须进行排序。如果中间数据太大无法在内存中完成排序，那么就要在外部进行排序。
6. Reduce worker 程序遍历排序后的中间数据，对于每一个唯一的中间 key 值，Reduce worker 程序将这个 key 值和它相关的中间 value 值的集合传递给用户自定义的 Reduce 函数。Reduce 函数的输出被追加到所属分区的输出文件。
7. 当所有的 Map 和 Reduce 任务都完成之后，master 唤醒用户程序。在这个时候，在用户程序里的对 MapReduce 调用才返回。

### 容错

#### worker 故障

master 与 worker 之间同步心跳，对于失效的 worker，根据其类型来做进一步处理：

* Map worker 故障：由于 Map 任务将数据临时存储在本地，所以需要重新执行。
* Reduce worker 故障：由于 Reduce 任务将数据存储在全局文件系统中 ，所以不需要重新执行。

#### master 故障

MapReduce 任务重新执行

### 故障语义保证

当用户提供的 Map 和 Reduce 操作是输入确定性函数（即相同的输入产生相同的输出）时，MapReduce 的分布式实现在任何情况下的输出都和所有程序没有出现任何错误、顺序的执行产生的输出是一样的。

* Map worker 任务的原子提交：每个 Map 任务生成 R 个本地临时文件，当一个 Map 任务完成时，worker 发送一个包含 R 个临时文件名的完成消息给 master。如果 master 从一个已经完成的 Map 任务再次接收到一个完成消息，master 将忽略这个消息；
* Reduce worker 任务的原子提交：当 Reduce 任务完成时，Reduce worker 进程以原子的方式把临时文件重命名为最终的输出文件。如果同一个 Reduce 任务在多台机器上执行，针对同一个最终的输出文件将有多个重命名操作执行。MapReduce 依赖底层文件系统提供的重命名操作的原子性来保证最终的文件系统状态仅仅包含一个 Reduce 任务产生的数据。

### 存储位置优化

核心思想：本地读文件以减少流量消耗

MapReduce 的 master 在调度 Map 任务时会考虑输入文件的位置信息，尽量将一个 Map 任务调度在包含相关输入数据拷贝的机器上执行；如果上述努力失败了，master 将尝试在保存有输入数据拷贝的机器附近的机器上执行 Map 任务（例如，分配到一个和包含输入数据的机器在一个交换机里的 worker 机器上执行）。

### 备用任务

影响一个 MapReduce 的总执行时间最通常的因素是“落伍者”：在运算过程中，如果有一台机器花了很长的时间才完成最后几个 Map 或 Reduce 任务，导致 MapReduce 操作总的执行时间超过预期。

为了解决落伍者的问题，当一个 MapReduce 操作接近完成的时候，master 调度备用（backup）任务进程来执行剩下的、处于处理中状态（in-progress）的任务。无论是最初的执行进程、还是备用（backup）任务进程完成了任务，MapReduce 都把这个任务标记成为已经完成。此个机制通常只会占用比正常操作多几个百分点的计算资源。但能减少近 50% 的任务完成总时间。

### 应用场景

* 计算 URL 访问频率：Map 函数处理日志中 web 页面请求的记录，然后输出 (URL,1)。Reduce 函数把相同 URL 的 value 值都累加起来，产生 (URL, 记录总数）结果。
* 网络链接倒排：Map 函数在源页面（source）中搜索所有的链接目标（target）并输出为 (target, source)。Reduce 函数把给定链接目标（target）的链接组合成一个列表，输出 (target, list(source))。
* 倒排索引：Map 函数分析每个文档输出一个（词，文档号）的列表，Reduce 函数的输入是一个给定词的所有（词，文档号），排序所有的文档号，输出（词，list（文档号）)。所有的输出集合形成一个简单的倒排索引，它以一种简单的算法跟踪词在文档中的位置。
* 分布式排序：Map 函数从每个记录提取 key，输出 (key, record)。Reduce 函数不改变任何的值。这个运算依赖分区机制和排序属性。

## GFS

### 集群组成

除了客户端以外，一个 GFS 集群还包括一个 **Master** 节点和若干个 **Chunk Server**。它们会作为用户级进程运行在普通的 Linux 机器上。

在存储文件时，GFS 会把文件切分成若干个拥有固定长度的 Chunk（块）并存储。Master 在创建 Chunk 时会为它们赋予一个唯一的 64 位 Handle（句柄），并把它们移交给 Chunk Server，而 Chunk Server 则以普通文件的形式将每个 Chunk 存储在自己的本地磁盘上。为了确保 Chunk 的可用性，GFS 会把每个 Chunk 备份成若干个 Replica 分配到其他 Chunk Server 上。

GFS 的 Master 负责维护整个集群的元数据，包括集群的 Namespace（命名空间，即文件元数据）以及 Chunk Lease 管理、无用 Chunk 回收等系统级操作。Chunk Server 除了保存 Chunk 以外也会周期地和 Master 通过心跳信号进行通信，Master 也借此得以收集每个 Chunk Server 当前的状态，并向其发送指令。

鉴于整个集群只有一个 Master，客户端在和 GFS 集群通信时，首先会从 Master 处获取 GFS 的元数据，而实际文件的数据传输则会与 Chunk Server 直接进行，以避免 Master 成为整个系统的数据传输瓶颈；除此以外，客户端也会在一定时间内缓存 Master 返回的集群元数据。

### 元数据

GFS 集群的元数据主要包括以下三类信息：

* 文件与 Chunk 的 Namespace
* 文件与 Chunk 之间的映射关系
* 每个 Chunk Replica 所在的位置

### Chunk 的大小

GFS 选择了使用 64MB 作为 Chunk 的大小。

较大的 Chunk 主要带来了如下几个好处：

1. 降低客户端与 Master 通信的频率
2. 增大客户端进行操作时这些操作落到同一个 Chunk 上的概率
3. 减少 Master 所要保存的元数据的体积

不过，较大的 Chunk 会使得小文件占据额外的存储空间；一般的小文件通常只会占据一个 Chunk，这些 Chunk 也容易成为系统的负载热点。但正如之前所设想的需求那样，这样的文件在 Google 的场景下不是普遍存在的，这样的问题并未在 Google 中真正出现过。即便真的出现了，也可以通过提升这类文件的 Replica 数量来将负载进行均衡。

### 数据完整性

每个 Chunk 都会以 Replica 的形式被备份在不同的 Chunk Server 中，而且用户可以为 Namespace 的不同部分赋予不同的备份策略。

为了保证数据完整，每个 Chunk Server 都会以校验和的形式来检测自己保存的数据是否有损坏；在侦测到损坏数据后，Chunk Server 也可以利用其它 Replica 来恢复数据。

## Raft

### 节点类型

* `Leader`：集群内最多只会有一个 leader，负责发起心跳，响应客户端，创建日志，同步日志。
* `Candidate`：leader 选举过程中的临时角色，由 follower 转化而来，发起投票参与竞选。
* `Follower`：接受 leader 的心跳和日志同步数据，投票给 candidate。

在博士论文和实际生产系统中，其实又增加了两种身份：

* `Learner`：不具有选举权，参与日志复制过程但不计数的节点。可以作为新节点加入集群时的过渡状态以提升可用性，也可以作为一种类似于 binlog 的对 leader 日志流进行订阅的角色，比如可以参考 PingCAP 公司 tikv 和 tiflash 的架构。
* `Pre candidate`：刚刚发起竞选，还在等待 `Pre-Vote` 结果的临时状态，取决于 `Pre-Vote` 的结果，可能进化为 candidate，可能退化为 follower。

### 节点状态

每一个节点都应该有的持久化状态：

* `currentTerm`：当前任期，保证重启后任期不丢失。
* `votedFor`：在当前 term，给哪个节点投了票，值为 null 或 `candidate id`。即使节点重启，Raft 算法也能保证每个任期最多只有一个 leader。
* `log[]`：已经 committed 的日志，保证状态机可恢复。

每一个节点都应该有的非持久化状态：

* `commitindex`：已提交的最大 index。leader 节点重启后可以通过 appendEntries rpc 逐渐得到不同节点的 matchIndex，从而确认 commitIndex，follower 只需等待 leader 传递过来的 commitIndex 即可。
* `lastApplied`：已被状态机应用的最大 index。raft 算法假设了状态机本身是易失的，所以重启后状态机的状态可以通过 log\[] （部分 log 可以压缩为 snapshot) 来恢复。

leader 的非持久化状态：

* `nextindex[]`：为每一个 follower 保存的，应该发送的下一份 `entry index`；初始化为本地 last index + 1。
* `matchindex[]`：已确认的，已经同步到每一个 follower 的 `entry index`。初始化为 0，根据复制状态不断递增，

  （注：每次选举后，leader 的此两个数组都应该立刻重新初始化并开始探测）

### 任期

Raft 将时间划分成为任意不同长度的 term。term 用连续的数字进行表示。每一个 term 的开始都是一次选举，一个或多个 candidate 会试图成为 leader。如果一个 candidate 赢得了选举，它就会在该 term 担任 leader。在某些情况下，选票会被均分，即 `split vote`（例如总数为偶数节点时两个 candidate 节点各获得了两票），此时无法选出该 term 的 leader，那么在该 term 的选举超时后将会开始另一个 term 的选举。

term 在 Raft 算法中充当逻辑时钟（类似于 Lamport timestamp）的作用，这会允许服务器节点查明一些过期的信息比如过期的 leader。

每个节点都会存储当前 term 号，这一编号在整个时间内单调增长。当服务器之间通信的时候会交换当前 term 号；如果一个服务器的当前 term 号比其他人小，那么他会更新自己的 term 到较大的 term 值。如果一个 candidate 或者 leader 发现自己的 term 过期了，那么他会立即退回 follower。如果一个节点接收到一个包含过期 term 号的请求，那么它会拒绝或忽略这个请求。这实际上就是一个 Lamport 逻辑时钟的具体实现。

### 日志

* `entry`：Raft 中，将每一个事件都称为一个 entry，每一个 entry 都有一个表明它在 log 中位置的 index（之所以从 1 开始是为了方便 `prevLogIndex` 从 0 开始）。只有 leader 可以创建 entry。entry 的内容为 `<term, index, cmd>`，其中 cmd 是可以应用到状态机的操作。在 raft 组大部分节点都接收这条 entry 后，entry 可以被称为是 committed 的。
* `log`：由 entry 构成的数组，只有 leader 可以改变其他节点的 log。 entry 总是先被 leader 添加进本地的 log 数组中去，然后才发起共识请求，获得 quorum 同意后才会被 leader 提交给状态机。follower 只能从 leader 获取新日志和当前的 commitIndex，然后应用对应的 entry 到自己的状态机。

### 领导人选举

Raft 使用心跳来维持 leader 身份。任何节点都以 follower 的身份启动。 leader 会定期的发送心跳给所有的 follower 以确保自己的身份。每当 follower 收到心跳后，就刷新自己的 electionElapsed，重新计时。

（后文中，会将预设的选举超时称为 electionTimeout，而将当前经过的选举耗时称为 electionElapsed）

一旦一个 follower 在指定的时间内没有收到任何 RPC（称为 electionTimeout），则会发起一次选举。 当 follower 试图发起选举后，其身份转变为 candidate，在增加自己的 term 后， 会向所有节点发起 RequestVoteRPC 请求，candidate 的状态会一直持续直到：

* 赢得选举
* 其他节点赢得选举
* 一轮选举结束，无人胜出

选举的方式非常简单，谁能获取到多数选票 `(N/2 + 1)`，谁就成为 leader。 在一个 candidate 节点等待投票响应的时候，它有可能会收到其他节点声明自己是 leader 的心跳， 此时有两种情况：

* 该请求的 term 和自己一样或更大：说明对方已经成为 leader，自己立刻退为 follower。
* 该请求的 term 小于自己：拒绝请求并返回当前 term 以让请求节点更新 term。

为了防止在同一时间有太多的 follower 转变为 candidate 导致无法选出绝对多数， Raft 采用了随机选举超时（`randomized election timeouts`）的机制， 每一个 candidate 在发起选举后，都会随机化一个新的选举超时时间， 一旦超时后仍然没有完成选举，则增加自己的 term，然后发起新一轮选举。 在这种情况下，应该能在较短的时间内确认出 leader。 （因为 term 较大的有更大的概率压倒其他节点）

通过一个节点在一个 term 只能给一个节点投票，Raft 保证了对于给定的一个 term 最多只有一个 leader，从而避免了选举导致的 `split brain` 以确保 safety；通过不同节点每次随机化选举超时时间，Raft 在实践中（注意：并没有在理论上）避免了活锁以确保 liveness。

### 日志同步

leader 被选举后，则负责所有的客户端请求。每一个客户端请求都包含一个命令，该命令可以被作用到 RSM。

leader 收到客户端请求后，会生成一个 entry，包含 `<index, term, cmd>`，再将这个 entry 添加到自己的日志末尾后，向所有的节点广播该 entry。

follower 如果同意接受该 entry，则在将 entry 添加到自己的日志后，返回同意。

如果 leader 收到了多数的成功答复，则将该 entry 应用到自己的 RSM，之后可以称该 entry 是 committed 的。该 committed 信息会随着随后的 AppendEntries 或 Heartbeat RPC 被传达到其他节点。

Raft 保证下列两个性质：

* 如果在两个日志（节点）里，有两个 entry 拥有相同的 index 和 term，那么它们一定有相同的 cmd；
* 如果在两个日志（节点）里，有两个 entry 拥有相同的 index 和 term，那么它们前面的 entry 也一定相同。

### 选举限制

因为 leader 的强势地位，所以 Raft 在投票阶段就确保选举出的 leader 一定包含了整个集群中目前已 committed 的所有日志。

当 candidate 发送 RequestVoteRPC 时，会带上最后一个 entry 的信息。 所有的节点收到该请求后，都会比对自己的日志，如果发现自己的日志更新一些，则会拒绝投票给该 candidate。 （Pre-Vote 同理，如果 follower 认为 Pre-Candidate 没有资格的话，会拒绝 PreVote）

判断日志新旧的方式：获取请求的 entry 后，比对自己日志中的最后一个 entry。首先比对 term，如果自己的 term 更大，则拒绝请求。如果 term 一样，则比对 index，如果自己的 index 更大（说明自己的日志更长），则拒绝请求。

### 节点崩溃

如果 leader 崩溃，集群中的所有节点在 electionTimeout 时间内没有收到 leader 的心跳信息就会触发新一轮的选主。总而言之，最终集群总会选出唯一的 leader 。按论文中的说法，计算一次 RPC 耗时高达 `30～40ms` 时，`99.9%` 的选举依然可以在 `3s` 内完成，但一般一个机房内一次 RPC 只需 1ms。当然，选主期间整个集群对外是不可用的。

如果 follower 和 candidate 奔溃相对而言就简单很多，因为 Raft 所有的 RPC 都是幂等的，所以 Raft 中所有的请求，只要超时，就会无限的重试。follower 和 candidate 崩溃恢复后，可以收到新的请求，然后按照上面谈论过的追加或拒绝 entry 的方式处理请求。

### 日志压缩

Raft 的日志在正常运行期间会增长以合并更多的客户请求，但是在实际的系统中，Raft 的日志无法不受限制地增长。随着日志的增长，日志会占用更多空间，并且需要花费更多时间进行重放。如果没有某种机制可以丢弃日志中累积的过时信息，这最终将导致可用性问题。因此需要定时去做 snapshot。

snapshot 会包括：

* 状态机当前的状态。
* 状态机最后一条应用的 entry 对应的 index 和 term。
* 集群最新配置信息。
* 为了保证 exactly-once 线性化语义的去重表。

各个节点自行择机完成自己的 snapshot 即可，如果 leader 发现需要发给某一个 follower 的 nextIndex 已经被做成了 snapshot，则需要将 snapshot 发送给该 follower。注意 follower 拿到非过期的 snapshot 之后直接覆盖本地所有状态即可，不需要留有部分 entry，也不会出现 snapshot 之后还存在有效的 entry。因此 follower 只需要判断 `InstallSnapshot RPC` 是否过期即可。过期则直接丢弃，否则直接替换全部状态即可。

snapshot 可能会带来两个问题：

1. 做 snapshot 的策略？ 一般为定时或者定大小，达到阈值即做 snapshot，做完后对状态机和 raft log 进行原子性替换即可。
2. 做 snapshot 时是否还可继续提供写请求？ 一般情况下，做 snapshot 期间需要保证状态机不发生变化，也就是需要保证 snapshot 期间状态机不处理写请求。当然 raft 层依然可以去同步，只是状态机不能变化，即不能 apply 新提交的日志到状态机中而已。要想做的更好，可以对状态机采用 `copy-on-write` 的复制来不阻塞写请求。

### 禅让

有时候，会希望取消当前 leader 的管理权，比如：

* leader 节点因为运维原因需要重启；
* 有其他更适合当 leader 的节点；

直接将 leader 节点停机的话，其他节点会等待 electionTimeout 后进入选举状态， 这期间会集群会停止响应。为了避免这一段不可用的时间，可以采用禅让机制（`leadership transfer`）。

禅让的步骤为：

1. leader 停止响应客户端请求；
2. leader 向 target 节点发起一次日志同步；
3. leader 向 target 发起一次 TimeoutNowRPC，target 收到该请求后立刻发起一轮投票。

### 预投票

一个暂时脱离集群网络的节点，在重新加入集群后会干扰到集群的运行。

因为当一个节点和集群失去联系后，在等待 electionTimeout 后，它就会增加自己的 term 并发起选举， 因为联系不上其他节点，所以在 electionTimeout 后，它会继续增加自己的 term 并继续发起选举。

一段时间以后，它的 term 就会显著的高于原集群的 term。如果此后该节点重新和集群恢复了联络， 它的高 term 会导致 leader 立刻退位，并重新举行选举。

为了避免这一情形，引入了 Pre-Vote 的机制。在该机制下，一个 candidate 必须在获得了多数赞同的情形下， 才会增加自己的 term。一个节点在满足下述条件时，才会赞同一个 candidate：

* 该 candidate 的日志足够新；
* 当前节点已经和 leader 失联（electionTimeout）。

也就是说，candidate 会先发起一轮 Pre-Vote，获得多数同意后，更新自己的 term， 再发起一轮 RequestVoteRPC。

这种情形下，脱离集群的节点，只会不断的发起 Pre-Vote，而不会更新自己的 term。


# 数据密集型应用系统设计

## 前言

DDIA 在大一的时候就有前辈推荐，碍于时间一直没空阅读，恰逢最近考试复习太枯燥且不想内耗于活水的焦虑感中，正好把这本书好好读一下，也做一个阅读笔记方便未来重新思考。

> 2024.1.13

## 第一章：可靠性、可伸缩性和可维护性

### 关于数据系统的思考

数据库、消息队列、缓存等工具分属于几个差异显著的类别。虽然数据库和消息队列表面上有一些相似性但它们有迥然不同的访问模式，这意味着迥异的性能特征和实现手段。

近些年来，出现了许多新的工具。它们针对不同应用场景进行优化，因此不再适合生硬地归入传统类别。类别之间的界限变得越来越模糊，例如：数据存储可以被当成消息队列用（Redis），消息队列则带有类似数据库的持久保证（Apache Kafka）。

越来越多的应用程序有着各种严格而广泛的要求，单个工具不足以满足所有需求。进而总体工作被拆分成一系列能被单个工具高效完成的任务，并通过应用代码将它们缝合起来。

### 可靠性

造成错误的原因叫做 **故障（fault）**，能预料并应对故障的系统特性可称为 **容错（fault-tolerant）**。

注意 **故障（fault）** 不同于 **失效（failure）**。**故障** 通常定义为系统的一部分状态偏离其标准，而 **失效** 则是系统作为一个整体停止向用户提供服务。故障的概率不可能降到零，因此最好设计容错机制以防因 **故障** 而导致 **失效**。

反直觉的是，在这类容错系统中，通过故意触发来 **提高** 故障率是有意义的，例如：在没有警告的情况下随机地杀死单个进程。许多高危漏洞实际上是由糟糕的错误处理导致的，因此我们可以通过故意引发故障来确保容错机制不断运行并接受考验，从而提高故障自然发生时系统能正确处理的信心。

> 简单讲就是故意去制造一些问题看系统是否还能正常运行，如果崩溃了说明系统的可靠性还需要提升。

#### 硬件故障

硬盘的 **平均无故障时间（MTTF, mean time to failure）** 约为 10 到 50 年。因此从数学期望上讲，在拥有 10000 个磁盘的存储集群上，平均每天会有 1 个磁盘出故障。

为了减少系统的故障率，第一反应通常都是增加单个硬件的冗余度，例如：磁盘可以组建 RAID，服务器可能有双路电源和热插拔 CPU，数据中心可能有电池和柴油发电机作为后备电源，某个组件挂掉时冗余组件可以立刻接管。

如果在硬件冗余的基础上进一步引入软件容错机制，那么系统在容忍整个（单台）机器故障的道路上就更进一步了。这样的系统也有运维上的便利，例如：如果需要重启机器，单服务器系统就需要计划停机。而允许机器失效的系统则可以一次修复一个节点，无需整个系统停机。

#### 软件错误

这类错误难以预料，而且因为是跨节点相关的，所以比起不相关的硬件故障往往可能造成更多的 **系统失效**。比如失控进程会用尽一些共享资源，包括 CPU 时间、内存、磁盘空间或网络带宽。

导致这类软件故障的 BUG 通常会潜伏很长时间，直到被异常情况触发为止。虽然软件中的系统性故障没有速效药，但我们还是有很多小办法，例如：仔细考虑系统中的假设和交互；彻底的测试；进程隔离；允许进程崩溃并重启；监控并分析生产环境中的系统行为。如果系统能够提供一些保证（例如在一个消息队列中，进入与发出的消息数量相等），那么系统就可以在运行时不断自检，并在出现 **差异（discrepancy）** 时报警。

> 所以日志打点很重要，在加入打点后同时也要配置报警，这样出现问题就能在研发侧快速解决，而不是造成客诉和资损。

#### 人为错误

一项关于大型互联网服务的研究发现，运维配置错误是导致服务中断的首要原因，而硬件故障（服务器或网络）仅导致了 10-25% 的服务中断。

尽管人类不可靠，但怎么做才能让系统变得可靠？最好的系统会组合使用以下几种办法：

* 以最小化犯错机会的方式设计系统。例如，精心设计的抽象、API 和管理后台使做对事情更容易，搞砸事情更困难。但如果接口限制太多，人们就会忽略它们的好处而想办法绕开。很难正确把握这种微妙的平衡。
* 将人们最容易犯错的地方与可能导致失效的地方 **解耦（decouple）**。特别是提供一个功能齐全的非生产环境 **沙箱（sandbox）**，使人们可以在不影响真实用户的情况下，使用真实数据安全地探索和实验。
* 在各个层次进行彻底的测试，从单元测试、全系统集成测试到手动测试。
* 允许从人为错误中简单快速地恢复，以最大限度地减少失效情况带来的影响（快速回滚的能力）。
* 配置详细和明确的监控，比如性能指标和错误率。 在其他工程学科中这指的是 **遥测（telemetry）**。监控可以向我们发出预警信号，并允许我们检查是否有任何地方违反了假设和约束。
* 良好的管理实践与充分的培训 —— 一个复杂而重要的方面，但超出了本书的范围。

#### 可靠性的重要性

可靠性不仅仅是针对核电站和空中交通管制软件而言，我们也期望更多平凡的应用能可靠地运行。商务应用中的错误会导致生产力损失，而电商网站的中断则可能会导致收入和声誉的巨大损失。

在某些情况下，我们可能会选择牺牲可靠性来降低开发成本或运营成本，但我们偷工减料时，应该清楚意识到自己在做什么。

> DDIA 预言了一切

### 可伸缩性

**可伸缩性（Scalability）** 是用来描述系统应对负载增长能力的术语。但是请注意，这不是贴在系统上的一维标签：说 “X 可伸缩” 或 “Y 不可伸缩” 是没有任何意义的。相反，讨论可伸缩性意味着考虑诸如 “如果系统以特定方式增长，有什么选项可以应对增长？” 和 “如何增加计算资源来处理额外的负载？” 等问题。

#### 描述负载

负载可以用一些称为 **负载参数（load parameters）** 的数字来描述。参数的最佳选择取决于系统架构，它可能是每秒向 Web 服务器发出的请求、数据库中的读写比率、聊天室中同时活跃的用户数量、缓存命中率或其他东西。除此之外，也许平均情况对你很重要，也许你的瓶颈是少数极端场景。

**Eg. 推特发布贴文 & 主页时间线**

大体上讲，这一对操作有两种实现方式。

1. 发布推文时，只需将新推文插入全局推文集合即可。当一个用户请求自己的主页时间线时，首先查找他关注的所有人，查询这些被关注用户发布的推文并按时间顺序合并。

   ```sql
   SELECT tweets.*, users.*
     FROM tweets
     JOIN users   ON tweets.sender_id = users.id
     JOIN follows ON follows.followee_id = users.id
     WHERE follows.follower_id = current_user
   ```
2. 为每个用户的主页时间线维护一个缓存。 当一个用户发布推文时，查找所有关注该用户的人，并将新的推文插入到每个主页时间线缓存中。

方法 1 系统很难跟上主页时间线查询的负载。方法 2 的效果更好，因为发推频率比查询主页时间线的频率几乎低了两个数量级，所以在这种情况下，最好在写入时做更多的工作，而在读取时做更少的工作。

然而方法 2 的缺点是，发推现在需要大量的额外工作。平均来说，一条推文会发往约 75 个关注者，所以每秒 4.6k 的发推写入，变成了对主页时间线缓存每秒 345k 的写入。但这个平均值隐藏了用户粉丝数差异巨大这一现实，一些用户有超过 3000 万的粉丝，这意味着一条推文就可能会导致主页时间线缓存的 3000 万次写入！

推特轶事的最终转折：推特逐步转向了两种方法的混合。大多数用户发的推文会被扇出写入其粉丝主页时间线缓存中。但是少数拥有海量粉丝的用户会被排除在外。当用户读取主页时间线时，分别地获取出该用户所关注的每位名流的推文，再与用户的主页时间线缓存合并。

#### 描述性能

* 增加负载参数并保持系统资源（CPU、内存、网络带宽等）不变时，系统性能将受到什么影响？
* 增加负载参数并希望保持性能不变时，需要增加多少系统资源？

对于 Hadoop 这样的批处理系统，通常关心的是 **吞吐量（throughput）**，即每秒可以处理的记录数量，或者在特定规模数据集上运行作业的总时间。对于在线系统，通常更重要的是服务的 **响应时间（response time）**，即客户端发送请求到接收响应之间的时间。

即使不断重复发送同样的请求，每次得到的响应时间也都会略有不同。现实世界的系统会处理各式各样的请求，响应时间可能会有很大差异。因此我们需要将响应时间视为一个可以测量的数值 **分布（distribution）**，而不是单个数值。

如果想知道典型场景下用户需要等待多长时间，那么中位数是一个好的度量标准：一半用户请求的响应时间少于响应时间的中位数，另一半服务时间比中位数长。中位数也被称为第 50 百分位点，有时缩写为 p50。

响应时间的高百分位点（也称为 **尾部延迟**，即 **tail latencies**）非常重要，因为它们直接影响用户的服务体验。例如亚马逊在描述内部服务的响应时间要求时是以 99.9 百分位点为准，即使它只影响一千个请求中的一个。这是因为请求响应最慢的客户往往也是数据最多的客户，也可以说是最有价值的客户 —— 因为他们掏钱了。

百分位点通常用于 **服务级别目标（SLO, service level objectives）** 和 **服务级别协议（SLA, service level agreements）**。 SLA 可能会声明，如果服务响应时间的中位数小于 200 毫秒，且 99.9 百分位点低于 1 秒，则认为服务工作正常。这些指标为客户设定了期望值，并允许客户在 SLA 未达标的情况下要求退款。

**排队延迟（queueing delay）** 通常占了高百分位点处响应时间的很大一部分。由于服务器只能并行处理少量的事务（如受其 CPU 核数的限制），所以只要有少量缓慢的请求就能阻碍后续请求的处理，这种效应有时被称为 **头部阻塞（head-of-line blocking）** 。

在多重调用的后端服务里，高百分位数变得特别重要。即使并行调用，最终用户请求仍然需要等待最慢的并行调用完成。只需要一个缓慢的调用就可以使整个最终用户请求变慢。即使只有一小部分后端调用速度较慢，如果最终用户请求需要多个后端调用，则获得较慢调用的机会也会增加，因此较高比例的最终用户请求速度会变慢（效果称为尾部延迟放大）。

#### 应对负载的方法

人们经常讨论 **纵向伸缩**（scaling up，转向更强大的机器）和 **横向伸缩**（scaling out，将负载分布到多台小机器上）之间的对立。跨多台机器分配负载也称为 “**无共享（shared-nothing）**” 架构。可以在单台机器上运行的系统通常更简单，但高端机器可能非常贵，所以非常密集的负载通常无法避免地需要横向伸缩。现实世界中的优秀架构需要将这两种方法务实地结合，因为使用几台足够强大的机器可能比使用大量的小型虚拟机更简单也更便宜。

有些系统是 **弹性（elastic）** 的，而其他系统则是手动伸缩。如果负载 **极难预测（highly unpredictable）**，则弹性系统可能很有用，但手动伸缩系统更简单，并且意外操作可能会更少。

跨多台机器部署 **无状态服务（stateless services）** 非常简单，但将带状态的数据系统从单节点变为分布式配置则可能引入许多额外复杂度。出于这个原因，常识告诉我们应该将数据库放在单个节点上（纵向伸缩），直到伸缩成本或可用性需求迫使其改为分布式。

大规模的系统架构通常是应用特定的 —— 没有一招鲜吃遍天的通用可伸缩架构。应用的问题可能是读取量、写入量、要存储的数据量、数据的复杂度、响应时间要求、访问模式或者所有问题的大杂烩。举个例子，用于处理每秒十万个请求（每个大小为 1 kB）的系统与用于处理每分钟 3 个请求（每个大小为 2GB）的系统看上去会非常不一样，尽管两个系统有同样的数据吞吐量。

### 可维护性

我们可以，也应该以这样一种方式来设计软件：在设计之初就尽量考虑尽可能减少维护期间的痛苦，从而避免自己的软件系统变成遗留系统。为此，我们将特别关注软件系统的三个设计原则：

* 可操作性（Operability）

  便于运维团队保持系统平稳运行。
* 简单性（Simplicity）

  从系统中消除尽可能多的 **复杂度（complexity）**，使新工程师也能轻松理解系统。
* 可演化性（evolvability）

  使工程师在未来能轻松地对系统进行更改，当需求变化时为新应用场景做适配。也称为 **可扩展性（extensibility）**、**可修改性（modifiability）** 或 **可塑性（plasticity）**。

#### 可操作性

良好的可操作性意味着更轻松的日常工作，进而运维团队能专注于高价值的事情。数据系统可以通过各种方式使日常任务更轻松：

* 通过良好的监控，提供对系统内部状态和运行时行为的 **可见性（visibility）**。
* 为自动化提供良好支持，将系统与标准化工具相集成。
* 避免依赖单台机器（在整个系统继续不间断运行的情况下允许机器停机维护）。
* 提供良好的文档和易于理解的操作模型（“如果做 X，会发生 Y”）。
* 提供良好的默认行为，但需要时也允许管理员自由覆盖默认值。
* 有条件时进行自我修复，但需要时也允许管理员手动控制系统状态。
* 行为可预测，最大限度减少意外。

#### 简单性

小型软件项目可以使用简单讨喜的、富表现力的代码，但随着项目越来越大，代码往往变得非常复杂，难以理解。这种复杂度拖慢了所有系统相关人员，进一步增加了维护成本。

**复杂度（complexity）** 有各种可能的症状，例如：模块间紧密耦合、纠结的依赖关系、不一致的命名和术语、解决性能问题的 Hack、需要绕开的特例等等。

用于消除 **额外复杂度** 的最好工具之一是 **抽象（abstraction）**。一个好的抽象可以将大量实现细节隐藏在一个干净，简单易懂的外观下面。一个好的抽象也可以广泛用于各类不同应用。比起重复造很多轮子，重用抽象不仅更有效率，而且有助于开发高质量的软件。抽象组件的质量改进将使所有使用它的应用受益。

#### 可演化性

系统的需求永远不变，基本是不可能的。更可能的情况是，它们处于常态的变化中，例如：你了解了新的事实、出现意想不到的应用场景、业务优先级发生变化、用户要求新功能等。

修改数据系统并使其适应不断变化需求的容易程度，是与 **简单性** 和 **抽象性** 密切相关的：简单易懂的系统通常比复杂系统更容易修改。但由于这是一个非常重要的概念，我们将用一个不同的词来指代数据系统层面的敏捷性： **可演化性（evolvability）**。

## 第二章：数据模型与查询语言

一个复杂的应用程序可能会有更多的中间层次，比如基于 API 的 API，不过基本思想仍然是一样的：每个层都通过提供一个明确的数据模型来隐藏更低层次中的复杂性。这些抽象允许不同的人群有效地协作（例如数据库厂商的工程师和使用数据库的应用程序开发人员）。

### 关系模型与文档模型

现在最著名的数据模型可能是 SQL。它基于 Edgar Codd 在 1970 年提出的关系模型：数据被组织成 **关系**（SQL 中称作 **表**），其中每个关系是 **元组**（SQL 中称作 **行**) 的无序集合。

#### NoSQL 的诞生

“NoSQL” 这个名字让人遗憾，因为实际上它并没有涉及到任何特定的技术。最初它只是作为一个醒目的 Twitter 标签，用在 2009 年一个关于分布式，非关系数据库上的开源聚会上。好在 NoSQL 被追溯性地重新解释为 **不仅是 SQL（Not Only SQL）**。

采用 NoSQL 数据库的背后有几个驱动因素，其中包括：

* 需要比关系数据库更好的可伸缩性，包括非常大的数据集或非常高的写入吞吐量
* 相比商业数据库产品，免费和开源软件更受偏爱
* 关系模型不能很好地支持一些特殊的查询操作
* 受挫于关系模型的限制性，渴望一种更具多动态性与表现力的数据模型

在可预见的未来，关系数据库似乎可能会继续与各种非关系数据库一起使用，这种想法有时也被称为 **混合持久化（polyglot persistence）**。

#### 对象关系不匹配

如果数据存储在关系表中，那么需要一个笨拙的转换层，处于应用程序代码中的对象和表，行，列的数据库模型之间。模型之间的不连贯有时被称为 \*\*阻抗不匹配（impedance mismatch）。

**对象关系映射（ORM object-relational mapping）** 框架可以减少这个转换层所需的样板代码的数量，但是它们不能完全隐藏这两个模型之间的差异。

Eg. Linkedln 简历

整个简介可以通过一个唯一的标识符 `user_id` 来标识。像 `first_name` 和 `last_name` 这样的字段每个用户只出现一次，所以可以在 User 表上将其建模为列。但是，大多数人在职业生涯中拥有多于一份的工作，人们可能有不同样的教育阶段和任意数量的联系信息。从用户到这些项目之间存在一对多的关系，最常见的规范化表示形式是将职位，教育和联系信息放在单独的表中，对 User 表提供外键引用。

对于一个像简历这样自包含文档的数据结构而言，JSON 表示是非常合适的，可以使用面向文档的数据库（如 MongoDB）

```json
{
  "user_id": 251,
  "first_name": "Bill",
  "last_name": "Gates",
  "summary": "Co-chair of the Bill & Melinda Gates... Active blogger.",
  "region_id": "us:91",
  "industry_id": 131,
  "photo_url": "/p/7/000/253/05b/308dd6e.jpg",
  "positions": [
    {
      "job_title": "Co-chair",
      "organization": "Bill & Melinda Gates Foundation"
    },
    {
      "job_title": "Co-founder, Chairman",
      "organization": "Microsoft"
    }
  ],
  "education": [
    {
      "school_name": "Harvard University",
      "start": 1973,
      "end": 1975
    },
    {
      "school_name": "Lakeside School, Seattle",
      "start": null,
      "end": null
    }
  ],
  "contact_info": {
    "blog": "http://thegatesnotes.com",
    "twitter": "http://twitter.com/BillGates"
  }
}
```

有一些开发人员认为 JSON 模型减少了应用程序代码和存储层之间的阻抗不匹配。JSON 表示比多表模式具有更好的 **局部性（locality）**。如果在关系型示例中获取简介，那需要执行多个查询（通过 `user_id` 查询每个表），或者在 User 表与其下属表之间混乱地执行多路连接。而在 JSON 表示中，所有相关信息都在同一个地方，一个查询就足够了。

> 这里看起来好像会引起一些新的话题，例如 MongoDB 的使用场景，文档数据库在这种时候确实是比关系型数据库更加适合，但是如何把握住这个点是一个值得思考的问题。

#### 多对一和多对多的关系

简历中的会存 `region_id` ，它是以 ID，而不是纯字符串形式给出的。为什么？

如果用户界面用一个自由文本字段来输入区域和行业，那么将他们存储为纯文本字符串是合理的。另一方式是给出地理区域和行业的标准化的列表，并让用户从下拉列表或自动填充器中进行选择，其优势如下：

* 各个简介之间样式和拼写统一
* 避免歧义（例如，如果有几个同名的城市）
* 易于更新 —— 名称只存储在一个地方，如果需要更改（例如，由于政治事件而改变城市名称），很容易进行全面更新。
* 本地化支持 —— 当网站翻译成其他语言时，标准化的列表可以被本地化，使得地区和行业可以使用用户的语言来显示
* 更好的搜索 —— 例如，搜索华盛顿州的慈善家就会匹配这份简介，因为地区列表可以编码记录西雅图在华盛顿这一事实（从 “Greater Seattle Area” 这个字符串中看不出来）

使用 ID 的好处是，ID 对人类没有任何意义，因而永远不需要改变。任何对人类有意义的东西都可能需要在将来某个时候改变 —— 如果这些信息被复制，所有的冗余副本都需要更新。这会导致写入开销，也存在不一致的风险（一些副本被更新了，还有些副本没有被更新）。去除此类重复是数据库 **规范化（normalization）** 的关键思想。

在关系数据库中，通过 ID 来引用其他表中的行是正常的，因为连接很容易。在文档数据库中，一对多树结构没有必要用连接，对连接的支持通常很弱。

如果数据库本身不支持连接，则必须在应用程序代码中通过对数据库进行多个查询来模拟连接。

#### 文档数据库是否在重蹈覆辙

IBM 的信息管理系统（IMS） 的设计中使用了一个相当简单的数据模型，称为 **层次模型（hierarchical model）**，它与文档数据库使用的 JSON 模型有一些惊人的相似之处。

同文档数据库一样，IMS 能良好处理一对多的关系，但是很难应对多对多的关系，并且不支持连接。开发人员必须决定是否复制（非规范化）数据或手动解决从一个记录到另一个记录的引用。这些二十世纪六七十年代的问题与现在开发人员遇到的文档数据库问题非常相似。

那时人们提出了各种不同的解决方案来解决层次模型的局限性。其中最突出的两个是 **关系模型**（relational model，它变成了 SQL，并统治了世界）和 **网状模型**（network model，最初很受关注，但最终变得冷门）。

**网状模型**

网状模型中记录之间的链接不是外键，而更像编程语言中的指针（同时仍然存储在磁盘上）。访问记录的唯一方法是跟随从根记录起沿这些链路所形成的路径。这被称为 **访问路径（access path）**。

最简单的情况下，访问路径类似遍历链表：从列表头开始，每次查看一条记录，直到找到所需的记录。但在多对多关系的情况中，数条不同的路径可以到达相同的记录，网状模型的程序员必须跟踪这些不同的访问路径。

查询和更新数据库的代码变得复杂不灵活。无论是分层还是网状模型，如果你没有所需数据的路径，就会陷入困境。你可以改变访问路径，但是必须浏览大量手写数据库查询代码，并重写来处理新的访问路径。更改应用程序的数据模型是很难的。

**关系模型**

相比之下，关系模型做的就是将所有的数据放在光天化日之下：一个 **关系（表）** 只是一个 **元组（行）** 的集合，仅此而已。

外键约束允许对修改进行限制，但对于关系模型这并不是必选项。即使有约束，外键连接在查询时执行，而在 CODASYL 中，连接在插入时高效完成。

在关系数据库中，查询优化器自动决定查询的哪些部分以哪个顺序执行，以及使用哪些索引。如果想按新的方式查询数据，你可以声明一个新的索引，查询会自动使用最合适的那些索引。

**与文档数据库相比**

在一个方面，文档数据库还原为层次模型：在其父记录中存储嵌套记录（一对多关系，如 `positions`，`education` 和 `contact_info`），而不是在单独的表中。

但是，在表示多对一和多对多的关系时，关系数据库和文档数据库并没有根本的不同：在这两种情况下，相关项目都被一个唯一的标识符引用，这个标识符在关系模型中被称为 **外键**，在文档模型中称为 **文档引用**。该标识符在读取时通过连接或后续查询来解析。

#### 关系型数据库与文档数据库在今日的对比

支持文档数据模型的主要论据是架构灵活性，因局部性而拥有更好的性能，以及对于某些应用程序而言更接近于应用程序使用的数据结构。关系模型通过为连接提供更好的支持以及支持多对一和多对多的关系来反击。

**哪种数据模型更有助于简化应用代码**

如果应用程序中的数据具有类似文档的结构（即，一对多关系树，通常一次性加载整个树），那么使用文档模型可能是一个好主意。将类似文档的结构分解成多个表的关系技术可能导致繁琐的模式和不必要的复杂的应用程序代码。

但如果你的应用程序确实会用到多对多关系，那么文档模型就没有那么诱人了。尽管应用程序代码可以通过向数据库发出多个请求的方式来模拟连接，但这也将复杂性转移到应用程序中，而且通常也会比由数据库内的专用代码更慢。

**文档模型中的模式灵活性**

文档数据库有时称为 **无模式（schemaless）**，但这具有误导性，因为读取数据的代码通常假定某种结构 —— 即存在隐式模式，但不由数据库强制执行。一个更精确的术语是 **读时模式**（即 schema-on-read，数据的结构是隐含的，只有在数据被读取时才被解释）。

在应用程序想要改变其数据格式的情况下，这些方法之间的区别尤其明显。例如，假设你把每个用户的全名存储在一个字段中，而现在想分别存储名字和姓氏。在文档数据库中，只需开始写入具有新字段的新文档，并在应用程序中使用代码来处理读取旧文档的情况。例如：

```cpp
if (user && user.name && !user.first_name) {
  // Documents written before Dec 8, 2013 don't have first_name
  user.first_name = user.name.split(" ")[0];
}
```

另一方面，在 “静态类型” 数据库模式中，通常会执行以下 **迁移（migration）** 操作：

```sql
ALTER TABLE users ADD COLUMN first_name text;
UPDATE users SET first_name = split_part(name, ' ', 1);      -- PostgreSQL
UPDATE users SET first_name = substring_index(name, ' ', 1);      -- MySQL
```

模式变更的速度很慢，而且要求停运。大型表上运行 `UPDATE` 语句在任何数据库上都可能会很慢，因为每一行都需要重写。要是不可接受的话，应用程序可以将 `first_name` 设置为默认值 `NULL`，并在读取时再填充，就像使用文档数据库一样。

当由于某种原因（例如，数据是异构的）集合中的项目并不都具有相同的结构时，读时模式更具优势。例如，如果：

* 存在许多不同类型的对象，将每种类型的对象放在自己的表中是不现实的。
* 数据的结构由外部系统决定。你无法控制外部系统且它随时可能变化。

**查询的数据局部性**

如果应用程序经常需要访问整个文档（例如，将其渲染至网页），那么存储局部性会带来性能优势。如果将数据分割到多个表中，则需要进行多次索引查找才能将其全部检索出来，这可能需要更多的磁盘查找并花费更多的时间。

局部性仅仅适用于同时需要文档绝大部分内容的情况。数据库通常需要加载整个文档，如果只访问其中的一小部分，这对于大型文档来说是很浪费的。更新文档时，通常需要整个重写。只有不改变文档大小的修改才可以容易地原地执行。因此，通常建议保持相对小的文档，并避免增加文档大小的写入。

**文档与关系数据库的融合**

随着时间的推移，关系数据库和文档数据库似乎变得越来越相似，这是一件好事：数据模型相互补充，如果一个数据库能够处理类似文档的数据，并能够对其执行关系查询，那么应用程序就可以使用最符合其需求的功能组合。

关系模型和文档模型的混合是未来数据库一条很好的路线。

### 数据查询语言

SQL 是一种 **声明式** 查询语言，而 IMS 和 CODASYL 使用 **命令式** 代码来查询数据库。

命令式语言告诉计算机以特定顺序执行某些操作。可以想象一下，逐行地遍历代码，评估条件，更新变量，并决定是否再循环一遍。

声明式查询语言是迷人的，因为它通常比命令式 API 更加简洁和容易。但更重要的是，它还隐藏了数据库引擎的实现细节，这使得数据库系统可以在无需对查询做任何更改的情况下进行性能提升。

声明式语言往往适合并行执行。现在，CPU 的速度通过核心的增加变得更快，而不是以比以前更高的时钟速度运行。命令代码很难在多个核心和多个机器之间并行化，因为它指定了指令必须以特定顺序执行。声明式语言更具有并行执行的潜力，因为它们仅指定结果的模式，而不指定用于确定结果的算法。在适当情况下，数据库可以自由使用查询语言的并行实现。

#### Web 上的声明式查询

假设你有一个关于海洋动物的网站。用户当前正在查看鲨鱼页面，因此你将当前所选的导航项目 “鲨鱼” 标记为当前选中项目。

```html
<ul>
    <li class="selected">
        <p>Sharks</p>
        <ul>
            <li>Great White Shark</li>
            <li>Tiger Shark</li>
            <li>Hammerhead Shark</li>
        </ul>
    </li>
    <li><p>Whales</p>
        <ul>
            <li>Blue Whale</li>
            <li>Humpback Whale</li>
            <li>Fin Whale</li>
        </ul>
    </li>
</ul>
```

现在想让当前所选页面的标题具有一个蓝色的背景，以便在视觉上突出显示。使用 CSS 实现起来非常简单：

```css
li.selected > p {
  background-color: blue;
}
```

想象一下，必须使用命令式方法的情况会是如何。在 Javascript 中，使用 **文档对象模型（DOM）** API，其结果可能如下所示：

```js
var liElements = document.getElementsByTagName("li");
for (var i = 0; i < liElements.length; i++) {
    if (liElements[i].className === "selected") {
        var children = liElements[i].childNodes;
        for (var j = 0; j < children.length; j++) {
            var child = children[j];
            if (child.nodeType === Node.ELEMENT_NODE && child.tagName === "P") {
                child.setAttribute("style", "background-color: blue");
            }
        }
    }
}
```

这段 JavaScript 代码命令式地将元素设置为蓝色背景，但是代码看起来很糟糕。不仅比 CSS 更长，更难理解，而且还有一些严重的问题：如果选定的类被移除（例如，因为用户点击了不同的页面），即使代码重新运行，蓝色背景也不会被移除 - 因此该项目将保持突出显示，直到整个页面被重新加载。使用 CSS，浏览器会自动检测 `li.selected > p` 规则何时不再适用，并在选定的类被移除后立即移除蓝色背景。

在 Web 浏览器中，使用声明式 CSS 样式比使用 JavaScript 命令式地操作样式要好得多。类似地，在数据库中，使用像 SQL 这样的声明式查询语言比使用命令式查询 API 要好得多。

#### MapReduce 查询

MapReduce 既不是一个声明式的查询语言，也不是一个完全命令式的查询 API，而是处于两者之间：查询的逻辑用代码片段来表示，这些代码片段会被处理框架重复性调用。

map 和 reduce 函数在功能上有所限制：它们必须是 **纯** 函数，这意味着它们只使用传递给它们的数据作为输入，它们不能执行额外的数据库查询，也不能有任何副作用。这些限制允许数据库以任何顺序运行任何功能，并在失败时重新运行它们。

```js
db.observations.mapReduce(function map() {
        var year = this.observationTimestamp.getFullYear();
        var month = this.observationTimestamp.getMonth() + 1;
        emit(year + "-" + month, this.numAnimals);
    },
    function reduce(key, values) {
        return Array.sum(values);
    },
    {
        query: {
          family: "Sharks"
        },
        out: "monthlySharkReport"
    });
```

MongoDB 2.2 添加了一种叫做 **聚合管道** 的声明式查询语言的支持。用这种语言表述鲨鱼计数查询如下所示：

```js
db.observations.aggregate([
  { $match: { family: "Sharks" } },
  { $group: {
    _id: {
      year:  { $year:  "$observationTimestamp" },
      month: { $month: "$observationTimestamp" }
    },
    totalAnimals: { $sum: "$numAnimals" } }}
]);
```

聚合管道语言的表现力与 SQL 相当，但是它使用基于 JSON 的语法而不是 SQL。这个故事的寓意是：NoSQL 系统可能会意外发现自己只是重新发明了一套经过乔装改扮的 SQL。

### 图数据模型

一个图由两种对象组成：**顶点**（vertices，也称为 **节点**，即 nodes，或 **实体**，即 entities），和 **边**（edges，也称为 **关系**，即 relationships，或 **弧**，即 arcs）。多种数据可以被建模为一个图形。典型的例子包括：

* 社交图谱：顶点是人，边指示哪些人彼此认识。
* 公路或铁路网络：顶点是交叉路口，边线代表它们之间的道路或铁路线。

可以将那些众所周知的算法运用到这些图上：例如，汽车导航系统搜索道路网络中两点之间的最短路径。

图并不局限于同类数据：同样强大地是，图提供了一种一致的方式，用来在单个数据存储中存储完全不同类型的对象。例如，Facebook 维护一个包含许多不同类型的顶点和边的单个图：顶点表示人，地点，事件，签到；边缘表示哪些人是彼此的朋友，哪个签到发生在何处等等。

#### 属性图

可以将图存储看作由两个关系表组成：一个存储顶点，另一个存储边，头部和尾部顶点用来存储每条边；如果你想要一组顶点的输入或输出边，你可以分别通过 `head_vertex` 或 `tail_vertex` 来查询 `edges` 表。

```sql
CREATE TABLE vertices (
  vertex_id  INTEGER PRIMARY KEY,
  properties JSON
);

CREATE TABLE edges (
  edge_id     INTEGER PRIMARY KEY,
  tail_vertex INTEGER REFERENCES vertices (vertex_id),
  head_vertex INTEGER REFERENCES vertices (vertex_id),
  label       TEXT,
  properties  JSON
);

CREATE INDEX edges_tails ON edges (tail_vertex);
CREATE INDEX edges_heads ON edges (head_vertex);
```

1. 任何顶点都可以有一条边连接到任何其他顶点。没有模式限制哪种事物可不可以关联。
2. 给定任何顶点，可以高效地找到它的入边和出边，从而遍历图，即沿着一系列顶点的路径前后移动。
3. 通过对不同类型的关系使用不同的标签，可以在一个图中存储几种不同的信息。

#### Cypher 查询语言

Cypher 是属性图的声明式查询语言，为 Neo4j 图形数据库而发明。

每个顶点都有一个像 `USA` 或 `Idaho` 这样的符号名称，查询的其他部分可以使用这些名称在顶点之间创建边，使用箭头符号：`（Idaho） - [：WITHIN] ->（USA）` 创建一条标记为 `WITHIN` 的边，`Idaho` 为尾节点，`USA` 为头节点。

```cypher
CREATE
  (NAmerica:Location {name:'North America', type:'continent'}),
  (USA:Location      {name:'United States', type:'country'  }),
  (Idaho:Location    {name:'Idaho',         type:'state'    }),
  (Lucy:Person       {name:'Lucy' }),
  (Idaho) -[:WITHIN]->  (USA)  -[:WITHIN]-> (NAmerica),
  (Lucy)  -[:BORN_IN]-> (Idaho)
```

Eg. 找到所有从美国移民到欧洲的人的名字

```cypher
MATCH
  (person) -[:BORN_IN]->  () -[:WITHIN*0..]-> (us:Location {name:'United States'}),
  (person) -[:LIVES_IN]-> () -[:WITHIN*0..]-> (eu:Location {name:'Europe'})
RETURN person.name
```

1. `person` 顶点拥有一条到某个顶点的 `BORN_IN` 出边。从那个顶点开始，沿着一系列 `WITHIN` 出边最终到达一个类型为 `Location`，`name` 属性为 `United States` 的顶点。
2. `person` 顶点还拥有一条 `LIVES_IN` 出边。沿着这条边，可以通过一系列 `WITHIN` 出边最终到达一个类型为 `Location`，`name` 属性为 `Europe` 的顶点。

对于这样的 `Person` 顶点，返回其 `name` 属性。

#### SQL 中的图查询

一个人的 `LIVES_IN` 边可以指向任何类型的位置：街道、城市、地区、国家等。一个城市可以在（WITHIN）一个地区内，一个地区可以在（WITHIN）在一个州内，一个州可以在（WITHIN）一个国家内，等等。`LIVES_IN` 边可以直接指向正在查找的位置，或者一个在位置层次结构中隔了数层的位置。

在 Cypher 中，用 `WITHIN*0..` 非常简洁地表述了上述事实：“沿着 `WITHIN` 边，零次或多次”。它很像正则表达式中的 `*` 运算符。

自 SQL:1999，查询可变长度遍历路径的思想可以使用称为 **递归公用表表达式**（`WITH RECURSIVE` 语法）的东西来表示。但是，与 Cypher 相比，其语法非常笨拙。

同一个查询，用某一个查询语言可以写成 4 行，而用另一个查询语言需要 29 行，这恰恰说明了不同的数据模型是为不同的应用场景而设计的。选择适合应用程序的数据模型非常重要。

## 第三章：存储与检索

数据库如何存储我们提供的数据，以及如何在我们需要时重新找到数据。

### 驱动数据库的数据结构

> **日志（log）** 这个词通常指应用日志：即应用程序输出的描述正在发生的事情的文本。本书在更普遍的意义下使用 **日志** 这一词：一个仅追加的记录序列。它可能压根就不是给人类看的，它可以使用二进制格式，并仅能由其他程序读取。

为了高效查找数据库中特定键的值，我们需要一个数据结构：**索引（index）**。索引背后的大致思想是通过保存一些额外的元数据作为路标来帮助你找到想要的数据。如果你想以几种不同的方式搜索同一份数据，那么你也许需要在数据的不同部分上建立多个索引。

这是存储系统中一个重要的权衡：精心选择的索引加快了读查询的速度，但是每个索引都会拖慢写入速度。因为这个原因，数据库默认并不会索引所有的内容。你可以选择那些能为应用带来最大收益而且又不会引入超出必要开销的索引。

#### 散列索引

假设我们的数据存储只是一个追加写入的文件，就像前面的例子一样，那么最简单的索引策略就是：保留一个内存中的散列映射，其中每个键都映射到数据文件中的一个字节偏移量，指明了可以找到对应值的位置。当你将新的键值对追加写入文件中时，还要更新散列映射，以反映刚刚写入的数据的偏移量（这同时适用于插入新键与更新现有键）。当你想查找一个值时，使用散列映射来查找数据文件中的偏移量，**寻找（seek）** 该位置并读取该值即可。

这种简单的方法非常适合每个键的值经常更新的情况。例如，键可能是某个猫咪视频的网址（URL），而值可能是该视频被播放的次数（每次有人点击播放按钮时递增）。在这种类型的工作负载中，有很多写操作，但是没有太多不同的键 —— 每个键有很多的写操作，但是将所有键保存在内存中是可行的。

如何避免最终用完硬盘空间？一种好的解决方案是，将日志分为特定大小的 **段（segment）**，当日志增长到特定尺寸时关闭当前段文件，并开始写入一个新的段文件。然后，我们就可以对这些段进行 **压缩（compaction）**。这里的压缩意味着在日志中丢弃重复的键，只保留每个键的最近更新。

仅追加日志似乎很浪费：为什么不直接在文件里更新，用新值覆盖旧值？仅追加的设计之所以是个好的设计，有如下几个原因：

* 追加和分段合并都是顺序写入操作，通常比随机写入快得多，尤其是在磁性机械硬盘上。
* 如果段文件是仅追加的或不可变的，并发和崩溃恢复就简单多了。例如，当一个数据值被更新的时候发生崩溃，你不用担心文件里将会同时包含旧值和新值各自的一部分。
* 合并旧段的处理也可以避免数据文件随着时间的推移而碎片化的问题。

但是，散列表索引也有其局限性：

* 散列表必须能放进内存。如果你有非常多的键，那就难以处理。原则上可以在硬盘上维护一个散列映射，不幸的是硬盘散列映射很难表现优秀。它需要大量的随机访问 I/O，而后者耗尽时想要再扩充是很昂贵的，并且需要很烦琐的逻辑去解决散列冲突。
* 范围查询效率不高。例如，你无法轻松扫描 kitty00000 和 kitty99999 之间的所有键 —— 你必须在散列映射中单独查找每个键。

#### SSTables 和 LSM 树

我们可以对段文件的格式做一个简单的改变：要求键值对的序列按键排序。

我们把这个格式称为 **排序字符串表（Sorted String Table）**，简称 SSTable。我们还要求每个键只在每个合并的段文件中出现一次（压缩过程已经保证）。与使用散列索引的日志段相比，SSTable 有几个大的优势：

1. 即使文件大于可用内存，合并段的操作仍然是简单而高效的。你并排读取多个输入文件，查看每个文件中的第一个键，复制最低的键（根据排序顺序）到输出文件，不断重复此步骤，将产生一个新的合并段文件，而且它也是也按键排序的。当多个段包含相同的键时，我们可以保留最近段的值，并丢弃旧段中的值。
2. 为了在文件中找到一个特定的键，你不再需要在内存中保存所有键的索引。你仍然需要一个内存中的索引来告诉你一些键的偏移量，但它可以是稀疏的：每几千字节的段文件有一个键就足够了，因为几千字节可以很快地被扫描完。
3. 可以将这些记录分组为块（block），并在将其写入硬盘之前对其进行压缩。稀疏内存索引中的每个条目都指向压缩块的开始处。除了节省硬盘空间之外，压缩还可以减少对 I/O 带宽的使用。

**构建和维护 SSTables**

* 有新写入时，将其添加到内存中的平衡树数据结构（例如红黑树）。这个内存树有时被称为 **内存表（memtable）**。
* 当 **内存表** 大于某个阈值（通常为几兆字节）时，将其作为 SSTable 文件写入硬盘。这可以高效地完成，因为树已经维护了按键排序的键值对。新的 SSTable 文件将成为数据库中最新的段。当该 SSTable 被写入硬盘时，新的写入可以在一个新的内存表实例上继续进行。
* 收到读取请求时，首先尝试在内存表中找到对应的键，如果没有就在最近的硬盘段中寻找，如果还没有就在下一个较旧的段中继续寻找，以此类推。
* 时不时地，在后台运行一个合并和压缩过程，以合并段文件并将已覆盖或已删除的值丢弃掉。

这个方案效果很好。它只会遇到一个问题：如果数据库崩溃，则最近的写入（在内存表中，但尚未写入硬盘）将丢失。为了避免这个问题，我们可以在硬盘上保存一个单独的日志，每个写入都会立即被追加到这个日志上，就像在前面的章节中所描述的那样。这个日志没有按排序顺序，但这并不重要，因为它的唯一目的是在崩溃后恢复内存表。每当内存表写出到 SSTable 时，相应的日志都可以被丢弃。

**用 SSTables 制作 LSM 树**

这种索引结构最早由 Patrick O'Neil 等人发明，且被命名为日志结构合并树（或 LSM 树），它是基于更早之前的日志结构文件系统来构建的。基于这种合并和压缩排序文件原理的存储引擎通常被称为 LSM 存储引擎。

**性能优化**

要让存储引擎在实践中表现良好涉及到大量设计细节。例如，当查找数据库中不存在的键时，LSM 树算法可能会很慢：你必须先检查内存表，然后查看从最近的到最旧的所有的段（可能还必须从硬盘读取每一个段文件），然后才能确定这个键不存在。为了优化这种访问，存储引擎通常使用额外的布隆过滤器。

还有一些不同的策略来确定 SSTables 被压缩和合并的顺序和时间。最常见的选择是 size-tiered 和 leveled compaction。对于 sized-tiered，较新和较小的 SSTables 相继被合并到较旧的和较大的 SSTable 中。对于 leveled compaction，key （按照分布范围）被拆分到较小的 SSTables，而较旧的数据被移动到单独的层级（level），这使得压缩（compaction）能够更加增量地进行，并且使用较少的硬盘空间。

即使有许多微妙的东西，LSM 树的基本思想 —— 保存一系列在后台合并的 SSTables —— 简单而有效。即使数据集比可用内存大得多，它仍能继续正常工作。由于数据按排序顺序存储，你可以高效地执行范围查询（扫描所有从某个最小值到某个最大值之间的所有键），并且因为硬盘写入是连续的，所以 LSM 树可以支持非常高的写入吞吐量。

#### B 树

在几乎所有的关系数据库中，B 树仍然是标准的索引实现，许多非关系数据库也会使用到 B 树。

像 SSTables 一样，B 树保持按键排序的键值对，这允许高效的键值查找和范围查询。但这也就是仅有的相似之处了。B 树将数据库分解成固定大小的 **块（block）** 或 **分页（page）**，传统上大小为 4KB（有时会更大），并且一次只能读取或写入一个页面。这种设计更接近于底层硬件，因为硬盘空间也是按固定大小的块来组织的。每个页面都可以使用地址或位置来标识，这允许一个页面引用另一个页面 —— 类似于指针，但在硬盘而不是在内存中。我们可以使用这些页面引用来构建一个页面树。

一个页面会被指定为 B 树的根；在索引中查找一个键时，就从这里开始。该页面包含几个键和对子页面的引用。每个子页面负责一段连续范围的键，根页面上每两个引用之间的键，表示相邻子页面管理的键的范围（边界）。

如果要更新 B 树中现有键的值，需要搜索包含该键的叶子页面，更改该页面中的值，并将该页面写回到硬盘（对该页面的任何引用都将保持有效）。如果你想添加一个新的键，你需要找到其范围能包含新键的页面，并将其添加到该页面。如果页面中没有足够的可用空间容纳新键，则将其分成两个半满页面，并更新父页面以反映新的键范围分区。

大多数数据库可以放入一个三到四层的 B 树，所以你不需要追踪多个页面引用来找到你正在查找的页面（分支因子为 500 的 4KB 页面的四层树可以存储多达 256TB 的数据）。

**让 B 树更可靠**

B 树的基本底层写操作是用新数据覆写硬盘上的页面，并假定覆写不改变页面的位置。这与日志结构索引（如 LSM 树）形成鲜明对比，后者只追加到文件（并最终删除过时的文件）。

在固态硬盘上，由于 SSD 必须一次擦除和重写相当大的存储芯片块，所以会发生更复杂的事情，例如，如果因为插入导致页面过满而拆分页面，则需要写入新拆分的两个页面，并覆写其父页面以更新对两个子页面的引用。如果数据库在系列操作进行到一半时崩溃，那么最终将导致一个损坏的索引（例如，可能有一个孤儿页面没有被任何页面引用） 。

为了使数据库能处理异常崩溃的场景，B 树实现通常会带有一个额外的硬盘数据结构：**预写式日志**（WAL，即 write-ahead log，也称为 **重做日志**，即 redo log）。这是一个仅追加的文件，每个 B 树的修改在其能被应用到树本身的页面之前都必须先写入到该文件。当数据库在崩溃后恢复时，这个日志将被用来使 B 树恢复到一致的状态。

另外还有一个更新页面的复杂情况是，如果多个线程要同时访问 B 树，则需要仔细的并发控制 —— 否则线程可能会看到树处于不一致的状态。这通常是通过使用 **锁存器**（latches，轻量级锁）保护树的数据结构来完成。日志结构化的方法在这方面更简单，因为它们在后台进行所有的合并，而不会干扰新接收到的查询，并且能够时不时地将段文件切换为新的（该切换是原子操作）。

**B 树的优化**

* 不同于覆写页面并维护 WAL 以支持崩溃恢复，一些数据库（如 LMDB）使用写时复制方案。经过修改的页面被写入到不同的位置，并且还在树中创建了父页面的新版本，以指向新的位置。这种方法对于并发控制也很有用（MVCC）。
* 我们可以通过不存储整个键，而是缩短其大小，来节省页面空间。特别是在树内部的页面上，键只需要提供足够的信息来充当键范围之间的边界。在页面中包含更多的键允许树具有更高的分支因子，因此也就允许更少的层级（B+ 树）。
* 通常，页面可以放置在硬盘上的任何位置，如果某个查询需要按照排序顺序扫描大部分的键范围，那么这种按页面存储的布局可能会效率低下，因此，许多 B 树的实现在布局树时会尽量使叶子页面按顺序出现在硬盘上。但是，随着树的增长，要维持这个顺序是很困难的。相比之下，由于 LSM 树在合并过程中一次性重写一大段存储，所以它们更容易使顺序键在硬盘上连续存储。
* 额外的指针被添加到树中。例如，每个叶子页面可以引用其左边和右边的兄弟页面，使得不用跳回父页面就能按顺序对键进行扫描。

#### 比较 B 树和 LSM 树

尽管 B 树实现通常比 LSM 树实现更成熟，但 LSM 树由于性能特征也非常有趣。根据经验，通常 LSM 树的写入速度更快，而 B 树的读取速度更快。 LSM 树上的读取通常比较慢，因为它们必须检查几种不同的数据结构和不同压缩（Compaction）层级的 SSTables。

**LSM 树的优点**

在写入繁重的应用程序中，性能瓶颈可能是数据库可以写入硬盘的速度。在这种情况下，写放大会导致直接的性能代价：存储引擎写入硬盘的次数越多，可用硬盘带宽内它能处理的每秒写入次数就越少。

进而，LSM 树通常能够比 B 树支持更高的写入吞吐量，部分原因是它们有时具有较低的写放大（尽管这取决于存储引擎的配置和工作负载），部分是因为它们顺序地写入紧凑的 SSTable 文件而不是必须覆写树中的几个页面。这种差异在机械硬盘上尤其重要，其顺序写入比随机写入要快得多。

LSM 树可以被压缩得更好，因此通常能比 B 树在硬盘上产生更小的文件。B 树存储引擎会由于碎片化（fragmentation）而留下一些未使用的硬盘空间：当页面被拆分或某行不能放入现有页面时，页面中的某些空间仍未被使用。由于 LSM 树不是面向页面的，并且会通过定期重写 SSTables 以去除碎片。

**LSM 树的缺点**

压缩过程有时会干扰正在进行的读写操作。尽管存储引擎尝试增量地执行压缩以尽量不影响并发访问，但是硬盘资源有限，所以很容易发生某个请求需要等待硬盘先完成昂贵的压缩操作。

硬盘的有限写入带宽需要在初始写入（记录日志和刷新内存表到硬盘）和在后台运行的压缩线程之间共享。如果写入吞吐量很高，并且压缩没有仔细配置好，有可能导致压缩跟不上写入速率。在这种情况下，硬盘上未合并段的数量不断增加，直到硬盘空间用完，读取速度也会减慢，因为它们需要检查更多的段文件。

B 树的一个优点是每个键只存在于索引中的一个位置，而日志结构化的存储引擎可能在不同的段中有相同键的多个副本。在许多关系数据库中，事务隔离是通过在键范围上使用锁来实现的，在 B 树索引中，这些锁可以直接附加到树上。

#### 其他索引结构

次级索引（secondary indexes）也很常见。在关系数据库中，你可以使用 `CREATE INDEX` 命令在同一个表上创建多个次级索引，而且这些索引通常对于有效地执行联接（join）而言至关重要。

**将值存储在索引中**

索引中的键是查询要搜索的内容，而其值可以是以下两种情况之一：它可以是实际的行（文档，顶点），也可以是对存储在别处的行的引用。在后一种情况下，行被存储的地方被称为 **堆文件（heap file）**，并且存储的数据没有特定的顺序（它可以是仅追加的，或者它可以跟踪被删除的行以便后续可以用新的数据进行覆盖）。堆文件方法很常见，因为它避免了在存在多个次级索引时对数据的复制：每个索引只引用堆文件中的一个位置，实际的数据都保存在一个地方。

在某些情况下，从索引到堆文件的额外跳跃对读取来说性能损失太大，因此可能希望将被索引的行直接存储在索引中。这被称为聚集索引（clustered index）。在 **聚集索引**（在索引中存储所有的行数据）和 **非聚集索引**（仅在索引中存储对数据的引用）之间的折衷被称为 **覆盖索引（covering index）** 或 **包含列的索引（index with included columns）**，其在索引内存储表的一部分列。这允许通过单独使用索引来处理一些查询（这种情况下，可以说索引 **覆盖（cover）** 了查询）。

**多列索引**

最常见的多列索引被称为 **联合索引（concatenated index）** ，它通过将一列的值追加到另一列后面，简单地将多个字段组合成一个键（索引定义中指定了字段的连接顺序）。由于排序顺序，索引可以用来查找所有具有特定姓氏的人，或所有具有特定姓氏 - 名字组合的人。但如果你想找到所有具有特定名字的人，这个索引是没有用的。

**全文搜索和模糊索引**

到目前为止所讨论的所有索引都假定你有确切的数据，并允许你查询键的确切值或具有排序顺序的键的值范围。他们不允许你做的是搜索**类似**的键，如拼写错误的单词。这种模糊的查询需要不同的技术。

例如，全文搜索引擎通常允许搜索目标从一个单词扩展为包括该单词的同义词，忽略单词的语法变体，搜索在相同文档中的近义词，并且支持各种其他取决于文本的语言分析功能。为了处理文档或查询中的拼写错误，Lucene 能够在一定的编辑距离内搜索文本（编辑距离 1 意味着单词内发生了 1 个字母的添加、删除或替换）。

Lucene 为其词典使用了一个类似于 SSTable 的结构。这个结构需要一个小的内存索引，告诉查询需要在排序文件中哪个偏移量查找键。在 LevelDB 中，这个内存中的索引是一些键的稀疏集合，但在 Lucene 中，内存中的索引是键中字符的有限状态自动机，类似于 trie 。这个自动机可以转换成 Levenshtein 自动机，它支持在给定的编辑距离内有效地搜索单词。

其他的模糊搜索技术正朝着文档分类和机器学习的方向发展。

**在内存中存储一切**

随着 RAM 变得更便宜，每 GB 成本的论据被侵蚀了。许多数据集不是那么大，所以将它们全部保存在内存中是非常可行的，包括可能分布在多个机器上。这导致了内存数据库的发展。

某些内存中的键值存储（如 Memcached）仅用于缓存，在重新启动计算机时丢失的数据是可以接受的。但其他内存数据库的目标是持久性，可以通过特殊的硬件（例如电池供电的 RAM）来实现，也可以将更改日志写入硬盘，还可以将定时快照写入硬盘或者将内存中的状态复制到其他机器上。

除了性能，内存数据库的另一个有趣的地方是提供了难以用基于硬盘的索引实现的数据模型。例如，Redis 为各种数据结构（如优先级队列和集合）提供了类似数据库的接口。因为它将所有数据保存在内存中，所以它的实现相对简单。

### 事务处理还是分析

|   属性   |   事务处理系统 OLTP  |    分析系统 OLAP   |
| :----: | :------------: | :------------: |
| 主要读取模式 |   查询少量记录，按键读取  |    在大批量记录上聚合   |
| 主要写入模式 |  随机访问，写入要求低延时  | 批量导入（ETL）或者事件流 |
|  主要用户  | 终端用户，通过 Web 应用 | 内部数据分析师，用于决策支持 |
|  处理的数据 | 数据的最新状态（当前时间点） |   随时间推移的历史事件   |
|  数据集尺寸 |    GB \~ TB    |    TB \~ PB    |

事务处理和分析查询使用了相同的数据库。 SQL 在这方面已证明是非常灵活的：对于 OLTP 类型的查询以及 OLAP 类型的查询来说效果都很好。尽管如此，在二十世纪八十年代末和九十年代初期，企业有停止使用 OLTP 系统进行分析的趋势，转而在单独的数据库上运行分析。这个单独的数据库被称为 **数据仓库（data warehouse）**。

#### 数据仓库

数据仓库是一个独立的数据库，分析人员可以查询他们想要的内容而不影响 OLTP 操作。数据仓库包含公司各种 OLTP 系统中所有的只读数据副本。从 OLTP 数据库中提取数据（使用定期的数据转储或连续的更新流），转换成适合分析的模式，清理并加载到数据仓库中。将数据存入仓库的过程称为 **抽取 - 转换 - 加载（ETL）**。

**OLTP 数据库和数据仓库之间的分歧**

一个数据仓库和一个关系型 OLTP 数据库看起来很相似，因为它们都有一个 SQL 查询接口。然而，系统的内部看起来可能完全不同，因为它们针对非常不同的查询模式进行了优化。现在许多数据库供应商都只是重点支持事务处理负载和分析工作负载这两者中的一个，而不是都支持。

#### 星型和雪花型：分析的模式

在分析型业务中，数据模型的多样性则少得多。许多数据仓库都以相当公式化的方式使用，被称为星型模式。

事实表的每一行代表在特定时间发生的事件。如果我们分析的是网站流量，则每行可能代表一个用户的页面浏览或点击。通常情况下，事实被视为单独的事件，因为这样可以在以后分析中获得最大的灵活性。但是，这意味着事实表可以变得非常大。

事实表中的一些列是属性，事实表中的其他列是对其他表（称为维度表）的外键引用。由于事实表中的每一行都表示一个事件，因此这些维度代表事件发生的对象、内容、地点、时间、方式和原因。

这个模板的变体被称为雪花模式，其中维度被进一步分解为子维度。例如，品牌和产品类别可能有单独的表格，并且表格中的每一行都可以将品牌和类别作为外键引用，而不是将它们作为字符串存储在 表格中。雪花模式比星形模式更规范化，但是星形模式通常是首选，因为分析师使用它更简单。

### 列式存储

尽管事实表通常超过 100 列，但典型的数据仓库查询一次只会访问其中 4 个或 5 个列（ “`SELECT *`” 查询很少用于分析）。

在大多数 OLTP 数据库中，存储都是以面向行的方式进行布局的：表格的一行中的所有值都相邻存储。文档数据库也是相似的：整个文档通常存储为一个连续的字节序列。在查询时虽然有索引，但是也需要把所有的行加载到内存，这需要很长的时间。

列式存储背后的想法很简单：不要将所有来自一行的值存储在一起，而是将来自每一列的所有值存储在一起。如果每个列式存储在一个单独的文件中，查询只需要读取和解析查询中使用的那些列，这可以节省大量的工作。

列式存储布局依赖于每个列文件包含相同顺序的行。 因此，如果你需要重新组装完整的行，你可以从每个单独的列文件中获取第 23 项，并将它们放在一起形成表的第 23 行。

#### 列压缩

通常情况下，一列中不同值的数量与行数相比要小得多（例如，零售商可能有数十亿的销售交易，但只有 100,000 个不同的产品）。现在我们可以拿一个有 n 个不同值的列，并把它转换成 n 个独立的位图：每个不同值对应一个位图，每行对应一个比特位。如果该行具有该值，则该位为 1，否则为 0。

> Cassandra 和 HBase 有一个列族（column families）的概念，他们从 Bigtable 继承。然而，把它们称为列式（column-oriented）是非常具有误导性的：在每个列族中，它们将一行中的所有列与行键一起存储，并且不使用列压缩。因此，Bigtable 模型仍然主要是面向行的。

**内存带宽和矢量化处理**

对于数据仓库查询来说，一个巨大的瓶颈是从硬盘获取数据到内存的带宽。但是，这不是唯一的瓶颈。分析型数据库的开发人员还需要有效地利用内存到 CPU 缓存的带宽，避免 CPU 指令处理流水线中的分支预测错误和闲置等待，以及在现代 CPU 上使用单指令多数据（SIMD）指令来加速运算。

除了减少需要从硬盘加载的数据量以外，列式存储布局也可以有效利用 CPU 周期。例如，查询引擎可以将一整块压缩好的列数据放进 CPU 的 L1 缓存中，然后在紧密的循环（即没有函数调用）中遍历。相比于每条记录的处理都需要大量函数调用和条件判断的代码，CPU 执行这样一个循环要快得多。列压缩允许列中的更多行被同时放进容量有限的 L1 缓存。前面描述的按位 “与” 和 “或” 运算符可以被设计为直接在这样的压缩列数据块上操作。这种技术被称为矢量化处理（vectorized processing）。

#### 列式存储中的排序顺序

对每列分别执行排序是没有意义的，因为那样就没法知道不同列中的哪些项属于同一行。我们只能在明确一列中的第 k 项与另一列中的第 k 项属于同一行的情况下，才能重建出完整的行。

相反，数据的排序需要对一整行统一操作，即使它们的存储方式是按列的。数据库管理员可以根据他们对常用查询的了解，来选择表格中用来排序的列。例如，如果查询通常以日期范围为目标，例如“上个月”，则可以将 `date_key` 作为第一个排序键。这样查询优化器就可以只扫描近 1 个月范围的行了，这比扫描所有行要快得多。

按顺序排序的另一个好处是它可以帮助压缩列。如果主要排序列没有太多个不同的值，那么在排序之后，将会得到一个相同的值连续重复多次的序列。

**几个不同的排序顺序**

既然不同的查询受益于不同的排序顺序，为什么不以几种不同的方式来存储相同的数据呢？反正数据都需要做备份，以防单点故障时丢失数据。因此你可以用不同排序方式来存储冗余数据，以便在处理查询时，调用最适合查询模式的版本。

#### 写入列式存储

列式存储、压缩和排序都有助于更快地读取这些查询。然而，他们的缺点是写入更加困难。使用 B 树的就地更新方法对于压缩的列是不可能的。如果你想在排序表的中间插入一行，你很可能不得不重写所有的列文件。

幸运的是，本章前面已经看到了一个很好的解决方案：LSM 树。所有的写操作首先进入一个内存中的存储，在这里它们被添加到一个已排序的结构中，并准备写入硬盘。内存中的存储是面向行还是列的并不重要。当已经积累了足够的写入数据时，它们将与硬盘上的列文件合并，并批量写入新文件。

查询操作需要检查硬盘上的列数据和内存中的最近写入，并将两者的结果合并起来。但是，查询优化器对用户隐藏了这个细节。

#### 物化视图

数据仓库的另一个值得一提的方面是物化聚合（materialized aggregates）。如前所述，数据仓库查询通常涉及一个聚合函数，如 SQL 中的 COUNT、SUM、AVG、MIN 或 MAX。如果相同的聚合被许多不同的查询使用，可以提前缓存起来。

创建这种缓存的一种方式是物化视图（Materialized View）。在关系数据模型中，它通常被定义为一个标准（虚拟）视图：一个类似于表的对象，其内容是一些查询的结果。不同的是，物化视图是查询结果的实际副本，会被写入硬盘，而虚拟视图只是编写查询的一个捷径。当底层数据发生变化时，物化视图需要更新，因为它是数据的非规范化副本。

## 第四章：编码与演化

新旧版本的代码，以及新旧数据格式可能会在系统中同时共处。系统想要继续顺利运行，就需要保持 **双向兼容性**：

* 向后兼容 (backward compatibility)：新的代码可以读取由旧的代码写入的数据。
* 向前兼容 (forward compatibility)：旧的代码可以读取由新的代码写入的数据。

### 编码数据的格式

如果要将数据写入文件，或通过网络发送，则必须将其 **编码（encode）** 为某种自包含的字节序列（例如，JSON 文档）。 由于每个进程都有自己独立的地址空间，一个进程中的指针对任何其他进程都没有意义，所以这个字节序列表示会与通常在内存中使用的数据结构完全不同。

所以，需要在两种表示之间进行某种类型的翻译。 从内存中表示到字节序列的转换称为 **编码（Encoding）** （也称为 **序列化（serialization）** 或 **编组（marshalling）**），反过来称为 **解码（Decoding）**。

#### 语言特定的格式

* 这类编码通常与特定的编程语言深度绑定，其他语言很难读取这种数据。如果以这类编码存储或传输数据，那你就和这门语言绑死在一起了。
* 在这些库中，数据版本控制通常是事后才考虑的。因为它们旨在快速简便地对数据进行编码，所以往往忽略了前向后向兼容性带来的麻烦问题。
* Java 的内置序列化由于其糟糕的性能和臃肿的编码而臭名昭著。

因此，除非临时使用，采用语言内置编码通常是一个坏主意。

#### JSON、XML 和二进制变体

JSON，XML 和 CSV 属于文本格式，因此具有人类可读性，但存在一些微妙的问题：

* **数字（numbers）** 编码有很多模糊之处。在 XML 和 CSV 中，无法区分数字和碰巧由数字组成的字符串（除了引用外部模式）。 JSON 虽然区分字符串与数字，但并不区分整数和浮点数，并且不能指定精度。
* 无法处理大数字。例如大于 253253 的整数无法使用 IEEE 754 双精度浮点数精确表示，因此在使用浮点数（例如 JavaScript）的语言进行分析时，这些数字会变得不准确。 Twitter 有一个关于大于 253253 的数字的例子，它使用 64 位整数来标识每条推文。 Twitter API 返回的 JSON 包含了两个推特 ID，一个是 JSON 数字，另一个是十进制字符串，以解决 JavaScript 程序中无法正确解析数字的问题。
* JSON 和 XML 对 Unicode 字符串（即人类可读的文本）有很好的支持，但是它们不支持二进制数据（即不带 **字符编码 (character encoding)** 的字节序列）。二进制串是很有用的功能，人们通过使用 Base64 将二进制数据编码为文本来绕过此限制。其特有的模式标识着这个值应当被解释为 Base64 编码的二进制数据。这种方案虽然管用，但比较 Hacky，并且会增加三分之一的数据大小。

**二进制编码**

JSON 比 XML 简洁，但与二进制格式相比还是太占空间。这一事实导致大量二进制编码版本 JSON（MessagePack、BSON、BJSON、UBJSON、BISON 和 Smile 等） 和 XML（例如 WBXML 和 Fast Infoset）的出现。这些格式已经在各种各样的领域中采用，但是没有一个能像文本版 JSON 和 XML 那样被广泛采用。

所有的 JSON 的二进制编码在这方面是相似的。空间节省了一丁点（以及解析加速）是否能弥补可读性的损失，谁也说不准。

#### Thrift 与 Protocol Buffers

可以使用 Thrift **接口定义语言（IDL）** 来描述模式，如下所示：

```thrift
struct Person {
    1: required string       userName,
    2: optional i64          favoriteNumber,
    3: optional list<string> interests
}
```

Protocol Buffers 的等效模式定义看起来非常相似：

```protobuf
message Person {
    required string user_name       = 1;
    optional int64  favorite_number = 2;
    repeated string interests       = 3;
}
```

Thrift 和 Protocol Buffers 每一个都带有一个代码生成工具，它采用了类似于这里所示的模式定义，并且生成了以各种编程语言实现模式的类。

需要注意的一个细节：在前面所示的模式中，每个字段被标记为必需或可选，但是这对字段如何编码没有任何影响（二进制数据中没有任何字段指示某字段是否必须）。区别在于，如果字段设置为 `required`，但未设置该字段，则所需的运行时检查将失败，这对于捕获错误非常有用。

**字段标签和模式演变**

字段标记对编码数据的含义至关重要。你可以更改架构中字段的名称，因为编码的数据永远不会引用字段名称，但不能更改字段的标记，因为这会使所有现有的编码数据无效。

你可以添加新的字段到架构，只要你给每个字段一个新的标签号码。如果旧的代码试图读取新代码写入的数据，包括一个新的字段，其标签号码不能识别，它可以简单地忽略该字段。数据类型注释允许解析器确定需要跳过的字节数。这保持了向前兼容性：旧代码可以读取由新代码编写的记录。

只要每个字段都有一个唯一的标签号码，新的代码总是可以读取旧的数据，因为标签号码仍然具有相同的含义。唯一的细节是，如果你添加一个新的字段，你不能设置为必需。如果你要添加一个字段并将其设置为必需，那么如果新代码读取旧代码写入的数据，则该检查将失败，因为旧代码不会写入你添加的新字段。因此，为了保持向后兼容性，在模式的初始部署之后 **添加的每个字段必须是可选的或具有默认值**。

删除一个字段就像添加一个字段，只是这回要考虑的是向前兼容性。这意味着你只能删除一个可选的字段（必需字段永远不能删除），而且你不能再次使用相同的标签号码（因为你可能仍然有数据写在包含旧标签号码的地方，而该字段必须被新代码忽略）。

**数据类型和模式演变**

假设你将一个 32 位的整数变成一个 64 位的整数。新代码可以轻松读取旧代码写入的数据，因为解析器可以用零填充任何缺失的位。但是，如果旧代码读取由新代码写入的数据，则旧代码仍使用 32 位变量来保存该值。如果解码的 64 位值不适合 32 位，则它将被截断。

#### Avro

Avro 也使用模式来指定正在编码的数据的结构。 它有两种模式语言：一种（Avro IDL）用于人工编辑，一种（基于 JSON）更易于机器读取。

**动态生成的模式**

与 Protocol Buffers 和 Thrift 相比，Avro 方法的一个优点是架构不包含任何标签号码。Avro 对动态生成的模式更友善。例如，假如你有一个关系数据库，你想要把它的内容转储到一个文件中，并且你想使用二进制格式来避免前面提到的文本格式（JSON，CSV，SQL）的问题。如果你使用 Avro，你可以很容易地从关系模式生成一个 Avro 模式，并使用该模式对数据库内容进行编码，并将其全部转储到 Avro 对象容器文件中。

相比之下，如果你为此使用 Thrift 或 Protocol Buffers，则字段标签可能必须手动分配：每次数据库模式更改时，管理员都必须手动更新从数据库列名到字段标签的映射。这种动态生成的模式根本不是 Thrift 或 Protocol Buffers 的设计目标，而是 Avro 的。

**代码生成和动态类型的语言**

Thrift 和 Protobuf 依赖于代码生成：在定义了模式之后，可以使用你选择的编程语言生成实现此模式的代码。

在动态类型编程语言（如 JavaScript、Ruby 或 Python）中，生成代码没有太多意义，因为没有编译时类型检查器来满足。

Avro 为静态类型编程语言提供了可选的代码生成功能，但是它也可以在不生成任何代码的情况下使用。如果你有一个对象容器文件，你可以简单地使用 Avro 库打开它，并以与查看 JSON 文件相同的方式查看数据。该文件是自描述的，因为它包含所有必要的元数据。

#### 模式的优点

* 它们可以比各种 “二进制 JSON” 变体更紧凑，因为它们可以省略编码数据中的字段名称。
* 模式是一种有价值的文档形式，因为模式是解码所必需的，所以可以确定它是最新的。
* 维护一个模式的数据库允许你在部署任何内容之前检查模式更改的向前和向后兼容性。
* 对于静态类型编程语言的用户来说，从模式生成代码的能力是有用的，因为它可以在编译时进行类型检查。

### 数据流的类型

* 通过数据库
* 通过服务调用
* 通过异步消息传递

#### 数据库中的数据流

数据库中的一个值可能会被更新版本的代码写入，然后被仍旧运行的旧版本的代码读取。因此，数据库也经常需要向前兼容。

假设你将一个字段添加到记录模式，并且较新的代码将该新字段的值写入数据库。随后，旧版本的代码（尚不知道新字段）将读取记录，更新记录并将其写回。在这种情况下，理想的行为通常是旧代码保持新的字段不变，即使它不能被解释。

**在不同的时间写入不同的值**

对于五年前的数据来说，除非对其进行显式重写，否则它仍然会以原始编码形式存在。这种现象有时被概括为：数据的生命周期超出代码的生命周期。

将数据重写（迁移）到一个新的模式当然是可能的，但是在一个大数据集上执行是一个昂贵的事情。大多数关系数据库都允许简单的模式更改，例如添加一个默认值为空的新列，而不重写现有数据。读取旧行时，对于磁盘上的编码数据缺少的任何列，数据库将填充空值。

因此，模式演变允许整个数据库看起来好像是用单个模式编码的，即使底层存储可能包含用各种历史版本的模式编码的记录。

**归档存储**

也许你不时为数据库创建一个快照，例如备份或加载到数据仓库。在这种情况下，即使源数据库中的原始编码包含来自不同时代的模式版本的混合，数据转储通常也将使用最新模式进行编码。

由于数据转储是一次写入的，而且以后是不可变的，所以 Avro 对象容器文件等格式非常适合。这也是一个很好的机会，可以将数据编码为面向分析的列式格式，例如 Parquet。

#### 服务中的数据流：REST 与 RPC

在网络通信中，最常见的角色分为客户端和服务器。服务器通过公开 API，客户端则通过向 API 发出请求以实现连接，Web 就是利用这种方式运作的。

Web 浏览器并不是唯一的客户端类型，移动设备或桌面计算机上的应用程序也可以发出网络请求，客户端 JavaScript 程序也可以成为 HTTP 客户端。服务器返回的通常不仅仅是用于展示的 HTML，还可能包括客户端程序需要处理的编码数据，如 JSON 等。

服务器也可能是另一服务器的客户端，这种方式常用于分解大型应用程序，形成所谓的服务化架构（SOA）或微服务架构，这使得应用程序更易于更改和维护，也增强了系统的拓展性。

服务对客户端的处理也有一定的限制，这提供了一定的封装性和隔离性。我们需要考虑到服务器和客户端的旧版本和新版本需要兼容，在不同版本的服务 API 之间要保持数据编码的兼容性。

**Web 服务**

**当服务使用 HTTP 作为底层通信协议时，可称之为 Web 服务**。

有两种流行的 Web 服务方法：REST 和 SOAP。他们在哲学方面几乎是截然相反的，往往也是各自支持者之间的激烈辩论的主题。

REST 不是一个协议，而是一个基于 HTTP 原则的设计哲学。它强调简单的数据格式，使用 URL 来标识资源，并使用 HTTP 功能进行缓存控制，身份验证和内容类型协商。与 SOAP 相比，REST 已经越来越受欢迎，至少在跨组织服务集成的背景下，并经常与微服务相关。根据 REST 原则设计的 API 称为 RESTful。

SOAP Web 服务的 API 使用称为 Web 服务描述语言（WSDL）的基于 XML 的语言来描述。 WSDL 支持代码生成，客户端可以使用本地类和方法调用（编码为 XML 消息并由框架再次解码）访问远程服务。

**远程过程调用（RPC）的问题**

* 网络请求是不可预测的：请求或响应可能由于网络问题会丢失，或者远程计算机可能很慢或不可用，这些问题完全不在你的控制范围之内。
* 由于超时，返回时可能没有结果。在这种情况下，你根本不知道发生了什么。
* 如果你重试失败的网络请求，可能会发生请求实际上已经完成，只是响应丢失的情况。在这种情况下，重试将导致该操作被执行多次，除非你在协议中建立数据去重机制（**幂等性**，即 idempotence）。
* 每次调用本地函数时，通常需要大致相同的时间来执行。网络请求比函数调用要慢得多，而且其延迟也是非常可变的。
* 调用本地函数时，可以高效地将引用（指针）传递给本地内存中的对象。当你发出一个网络请求时，所有这些参数都需要被编码成可以通过网络发送的一系列字节。
* 客户端和服务可以用不同的编程语言实现，所以 RPC 框架必须将数据类型从一种语言翻译成另一种语言。这可能会变得很丑陋，因为不是所有的语言都具有相同的类型。

**RPC 的当前方向**

尽管有这样那样的问题，RPC 不会消失。使用二进制编码格式的自定义 RPC 协议可以实现比通用的 JSON over REST 更好的性能。但是，RESTful API 还有其他一些显著的优点：方便实验和调试，能被所有主流的编程语言和平台所支持，还有大量可用的工具（服务器，缓存，负载平衡器，代理，防火墙，监控，调试工具，测试工具等）的生态系统。

由于这些原因，REST 似乎是公共 API 的主要风格。 RPC 框架的主要重点在于同一组织拥有的服务之间的请求，通常在同一数据中心内。

**数据编码和 RPC 的演化**

对于可演化性，重要的是可以独立更改和部署 RPC 客户端和服务器。我们可以在通过服务进行数据流的情况下做一个简化的假设：假定所有的服务器都会先更新，其次是所有的客户端。因此，你只需要在请求上具有向后兼容性，并且对响应具有前向兼容性。

RPC 方案的前后向兼容性属性从它使用的编码方式中继承：

* Thrift、gRPC（Protobuf）等可以根据相应编码格式的兼容性规则进行演变。
* 在 SOAP 中，请求和响应是使用 XML 模式指定的。这些可以演变，但有一些微妙的陷阱。
* RESTful API 通常使用 JSON 用于响应，以及用于请求的 JSON 或 URI 编码 / 表单编码的请求参数。添加可选的请求参数并向响应对象添加新的字段通常被认为是保持兼容性的改变。

由于 RPC 经常被用于跨越组织边界的通信，所以服务的兼容性变得更加困难，因此服务的提供者经常无法控制其客户，也不能强迫他们升级。因此，需要长期保持兼容性，也许是无限期的。如果需要进行兼容性更改，则服务提供商通常会并排维护多个版本的服务 API。

关于 API 版本化应该如何工作没有一致意见。对于 RESTful API，常用的方法是在 URL 或 HTTP Accept 头中使用版本号。对于使用 API 密钥来标识特定客户端的服务，另一种选择是将客户端请求的 API 版本存储在服务器上，并允许通过单独的管理界面更新该版本选项。

#### 消息传递中的数据流

* 如果收件人不可用或过载，可以充当缓冲区，从而提高系统的可靠性。
* 它可以自动将消息重新发送到已经崩溃的进程，从而防止消息丢失。
* 避免发件人需要知道收件人的 IP 地址和端口号（这在虚拟机经常出入的云部署中特别有用）。
* 它允许将一条消息发送给多个收件人。
* 将发件人与收件人逻辑分离（发件人只是发布邮件，不关心使用者）。

然而，与 RPC 相比，差异在于消息传递通信通常是单向的：发送者通常不期望收到其消息的回复。一个进程可能发送一个响应，但这通常是在一个单独的通道上完成的。这种通信模式是异步的：发送者不会等待消息被传递，而只是发送它，然后忘记它。

**消息代理**

通常情况下，消息代理的使用方式如下：一个进程将消息发送到指定的队列或主题，代理确保将消息传递给那个队列或主题的一个或多个消费者或订阅者。在同一主题上可以有许多生产者和许多消费者。

一个主题只提供单向数据流。但是，消费者本身可能会将消息发布到另一个主题上，或者发送给原始消息的发送者使用的回复队列（允许请求 / 响应数据流，类似于 RPC）。

消息代理通常不会执行任何特定的数据模型 —— 消息只是包含一些元数据的字节序列，因此你可以使用任何编码格式。如果编码是向后和向前兼容的，你可以灵活地对发布者和消费者的编码进行独立的修改，并以任意顺序进行部署。

如果消费者重新发布消息到另一个主题，则可能需要小心保留未知字段。

## 第五章：复制

我们希望能复制数据，可能出于各种各样的原因：

* 使得数据与用户在地理上接近（从而减少延迟）
* 即使系统的一部分出现故障，系统也能继续工作（从而提高可用性）
* 伸缩可以接受读请求的机器数量（从而提高读取吞吐量）

复制的困难之处在于处理复制数据的 **变更（change）**，有三种流行的变更复制算法：**单领导者（single leader，单主）**，**多领导者（multi leader，多主）** 和 **无领导者（leaderless，无主）**。

### 领导者和追随者

每一次向数据库的写入操作都需要传播到所有副本上，否则副本就会包含不一样的数据。最常见的解决方案被称为 **基于领导者的复制（leader-based replication）** （也称 **主/从（master/slave）** 复制）

1. 其中一个副本被指定为 **领导者（leader）**，也称为 **主库（master|primary）** 。当客户端要向数据库写入时，它必须将请求发送给该 **领导者**，其会将新数据写入其本地存储。
2. 其他副本被称为 **追随者（followers）**，亦称为 **只读副本（read replicas）**、**从库（slaves）**、**备库（ secondaries）**。每当领导者将新数据写入本地存储时，它也会将数据变更发送给所有的追随者，称之为 **复制日志（replication log）**。每个跟随者从领导者拉取日志，并相应更新其本地数据库副本。
3. 当客户想要从数据库中读取数据时，它可以向领导者或任一追随者进行查询。但只有领导者才能接受写入操作（从客户端的角度来看从库都是只读的）。

这种复制模式是许多关系数据库的内置功能，基于领导者的复制并不仅限于数据库，像 Kafka 和 RabbitMQ 高可用队列这样的分布式消息代理也使用它。

#### 同步复制与异步复制

同步复制的优点是，从库能保证有与主库一致的最新数据副本。如果主库突然失效，我们可以确信这些数据仍然能在从库上找到。缺点是，如果同步从库没有响应（比如它已经崩溃，或者出现网络故障，或其它任何原因），主库就无法处理写入操作。

因此，将所有从库都设置为同步的是不切实际的：实际上，如果在数据库上启用同步复制，通常意味着其中 **一个** 从库是同步的，而其他的从库则是异步的。如果该同步从库变得不可用或缓慢，则将一个异步从库改为同步运行。这保证你至少在两个节点上拥有最新的数据副本：主库和同步从库。 这种配置有时也被称为 **半同步（semi-synchronous）**。

通常情况下，基于领导者的复制都配置为完全异步。在这种情况下，如果主库失效且不可恢复，则任何尚未复制给从库的写入都会丢失。这意味着即使已经向客户端确认成功，写入也不能保证是 **持久（Durable）** 的。然而，一个完全异步的配置也有优点：即使所有的从库都落后了，主库也可以继续处理写入。

#### 设置新从库

简单地将数据文件从一个节点复制到另一个节点通常是不够的：客户端不断向数据库写入数据，数据总是在不断地变化。

可以通过锁定数据库（使其不可用于写入）来使磁盘上的文件保持一致，但是这会违背高可用的目标。我们可以使用如下过程：

1. 在某个时刻获取主库的一致性快照。大多数数据库都具有这个功能，因为它是备份必需的。
2. 将快照复制到新的从库节点。
3. 从库连接到主库，并拉取快照之后发生的所有数据变更。这要求快照与主库复制日志中的位置精确关联。MySQL 将其称为 **二进制日志坐标（binlog coordinates）**。
4. 当从库处理完快照之后积累的数据变更，我们就说它 **赶上（caught up）** 了主库。

#### 处理节点宕机

我们的目标是，即使个别节点失效，也能保持整个系统运行，并尽可能控制节点停机带来的影响。

**从库失效：追赶恢复**

从库可以从日志中知道，在发生故障之前处理的最后一个事务。因此，从库可以连接到主库，并请求在从库断开期间发生的所有数据变更。当应用完所有这些变更后，它就赶上了主库，并可以像以前一样继续接收数据变更流。

**主库失效：故障切换**

一个从库需要被提升为新的主库，需要重新配置客户端，以将它们的写操作发送给新的主库，其他从库需要开始拉取来自新主库的数据变更。这个过程被称为 **故障切换（failover）**。

自动的故障切换过程通常由以下步骤组成：

1. 确认主库失效。大多数系统只是简单使用 **超时（Timeout）** ：节点频繁地相互来回传递消息，如果一个节点在一段时间内（例如 30 秒）没有响应，就认为它挂了。
2. 选择一个新的主库。这可以通过选举过程（主库由剩余副本以多数选举产生）来完成，或者可以由之前选定的 **控制器节点（controller node）** 来指定新的主库。主库的最佳人选通常是拥有旧主库最新数据副本的从库（以最小化数据损失）。让所有的节点同意一个新的领导者，是一个 **共识** 问题。
3. 重新配置系统以启用新的主库。客户端现在需要将它们的写请求发送给新主库。如果旧主库恢复，可能仍然认为自己是主库，而没有意识到其他副本已经让它失去领导权了。系统需要确保旧主库意识到新主库的存在，并成为一个从库。

故障切换的过程中有很多地方可能出错：

* 如果使用异步复制，则新主库可能没有收到老主库宕机前最后的写入操作。在选出新主库后，如果老主库重新加入集群，新主库在此期间可能会收到冲突的写入。最常见的解决方案是简单丢弃老主库未复制的写入，这很可能打破客户对于数据持久性的期望。
* 如果数据库需要和其他外部存储相协调，那么丢弃写入内容是极其危险的操作。一个过时的 MySQL 从库被提升为主库。数据库使用自增 ID 作为主键，因为新主库的计数器落后于老主库的计数器，所以新主库重新分配了一些已经被老主库分配掉的 ID 作为主键。这些主键也在 Redis 中使用，主键重用使得 MySQL 和 Redis 中的数据产生不一致。
* 发生某些故障时可能会出现两个节点都以为自己是主库的情况。这种情况称为 **脑裂 (split brain)**，非常危险：如果两个主库都可以接受写操作，却没有冲突解决机制，那么数据就可能丢失或损坏。
* 超时配置问题，在主库失效的情况下，超时时间越长意味着恢复时间也越长。但是如果超时设置太短，又可能会出现不必要的故障切换。

这些问题没有简单的解决方案。因此，即使软件支持自动故障切换，不少运维团队还是更愿意手动执行故障切换。

#### 复制日志的实现

实践中有好几种不同的复制方式。

**基于语句的复制**

主库记录下它执行的每个写入请求（**语句**，即 statement）并将该语句日志发送给从库，每个从库解析并执行该 SQL 语句，就像直接从客户端收到一样。

虽然听上去很合理，但有很多问题会搞砸这种复制方式：

* 任何调用 **非确定性函数（nondeterministic）** 的语句，可能会在每个副本上生成不同的值。例如，使用 `NOW()` 获取当前日期时间，或使用 `RAND()` 获取一个随机数。
* 如果语句使用了 **自增列（auto increment）**，或者依赖于数据库中的现有数据（例如，`UPDATE … WHERE <某些条件>`），则必须在每个副本上按照完全相同的顺序执行它们，否则可能会产生不同的效果。当有多个并发执行的事务时，这可能成为一个限制。
* 有副作用的语句（例如：触发器、存储过程、用户定义的函数）可能会在每个副本上产生不同的副作用，除非副作用是绝对确定性的。

有办法绕开这些问题 —— 例如，主库可以用固定的返回值替换掉任何不确定的函数调用，以便所有从库都能获得相同的值。但是由于边缘情况实在太多了，现在通常会选择其他的复制方法。

基于语句的复制在 5.1 版本前的 MySQL 中被使用到。因为它相当紧凑，现在有时候也还在用。但现在在默认情况下，如果语句中存在任何不确定性，MySQL 会切换到基于行的复制。

**传输预写式日志（WAL）**

对于覆写单个磁盘块的 B 树，每次修改都会先写入 **预写式日志（Write Ahead Log, WAL）**，以便崩溃后索引可以恢复到一个一致的状态。

该日志是包含了所有数据库写入的仅追加字节序列。可以使用完全相同的日志在另一个节点上构建副本：除了将日志写入磁盘之外，主库还可以通过网络将其发送给从库。通过使用这个日志，从库可以构建一个与主库一模一样的数据结构拷贝。

看上去这可能只是一个小的实现细节，但却可能对运维产生巨大的影响。如果复制协议允许从库使用比主库更新的软件版本，则可以先升级从库，然后执行故障切换，使升级后的节点之一成为新的主库，从而允许数据库软件的零停机升级。

**逻辑日志复制（基于行）**

复制和存储引擎使用不同的日志格式，这样可以将复制日志从存储引擎的内部实现中解耦出来。这种复制日志被称为逻辑日志（logical log），以将其与存储引擎的（物理）数据表示区分开来。

* 对于插入的行，日志包含所有列的新值。
* 对于删除的行，日志包含足够的信息来唯一标识被删除的行，这通常是主键，但如果表上没有主键，则需要记录所有列的旧值。
* 对于更新的行，日志包含足够的信息来唯一标识被更新的行，以及所有列的新值（或至少所有已更改的列的新值）。

修改多行的事务会生成多条这样的日志记录，后面跟着一条指明事务已经提交的记录。 MySQL 的二进制日志（当配置为使用基于行的复制时）使用了这种方法。

由于逻辑日志与存储引擎的内部实现是解耦的，系统可以更容易地做到向后兼容，从而使主库和从库能够运行不同版本的数据库软件，或者甚至不同的存储引擎。

**基于触发器的复制**

触发器允许你将数据更改（写入事务）发生时自动执行的自定义应用程序代码注册在数据库系统中。触发器有机会将更改记录到一个单独的表中，使用外部程序读取这个表，再加上一些必要的业务逻辑，就可以将数据变更复制到另一个系统去。

基于触发器的复制通常比其他复制方法具有更高的开销，并且比数据库内置的复制更容易出错，也有很多限制。然而由于其灵活性，它仍然是很有用的。

### 复制延迟问题

当应用程序从异步从库读取时，如果从库落后，它可能会看到过时的信息。这会导致数据库中出现明显的不一致：同时对主库和从库执行相同的查询，可能得到不同的结果，因为并非所有的写入都反映在从库中。这种不一致只是一个暂时的状态 —— 如果停止写入数据库并等待一段时间，从库最终会赶上并与主库保持一致。出于这个原因，这种效应被称为 **最终一致性**。

在正常的操作中，**复制延迟（replication lag）**，即写入主库到反映至从库之间的延迟，可能仅仅是几分之一秒，在实践中并不显眼。但如果系统在接近极限的情况下运行，或网络中存在问题时，延迟可以轻而易举地超过几秒，甚至达到几分钟。

#### 读你所写

如果用户在写入后马上就查看数据，则新数据可能尚未到达副本。对用户而言，看起来好像是刚提交的数据丢失了。

在这种情况下，我们需要 **写后读一致性（read-after-write consistency）**，也称为 **读己之写一致性（read-your-writes consistency）**。这是一个保证，如果用户重新加载页面，他们总会看到他们自己提交的任何更新。它不会对其他用户的写入做出承诺：其他用户的更新可能稍等才会看到。

* 对于用户 **可能修改过** 的内容，总是从主库读取；这就要求得有办法不通过实际的查询就可以知道用户是否修改了某些东西。例如总是从主库读取用户自己的档案，如果要读取其他用户的档案就去从库。
* 如果应用中的大部分内容都可能被用户编辑，可以使用其他标准来决定是否从主库读取。例如可以跟踪上次更新的时间，在上次更新后的一分钟内，从主库读。还可以监控从库的复制延迟，防止向任何滞后主库超过一分钟的从库发出查询。
* 客户端可以记住最近一次写入的时间戳，系统需要确保从库在处理该用户的读取请求时，该时间戳前的变更都已经传播到了本从库中。如果当前从库不够新，则可以从另一个从库读取，或者等待从库追赶上来。
* 如果你的副本分布在多个数据中心（为了在地理上接近用户或者出于可用性目的），还会有额外的复杂性。任何需要由主库提供服务的请求都必须路由到包含该主库的数据中心。

另一种复杂的情况发生在同一位用户从多个设备请求服务的时候，这种情况下可能就需要提供跨设备的写后读一致性。

在这种情况下，还有一些需要考虑的问题：

* 记住用户上次更新时间戳的方法变得更加困难，因为一个设备上运行的程序不知道另一个设备上发生了什么。需要对这些元数据进行中心化的存储。
* 如果副本分布在不同的数据中心，很难保证来自不同设备的连接会路由到同一数据中心。如果你的方法需要读主库，可能首先需要把来自该用户所有设备的请求都路由到同一个数据中心。

#### 单调读

在从异步从库读取时可能发生的异常的第二个例子是用户可能会遇到 **时光倒流（moving backward in time）**。

![](https://raw.githubusercontent.com/L2ncE/images/main/PicGo/Images20240220150714.png)

**单调读（monotonic reads）** 可以保证这种异常不会发生。这是一个比 **强一致性（strong consistency）** 更弱，但比 **最终一致性（eventual consistency）** 更强的保证。当读取数据时，你可能会看到一个旧值；单调读仅意味着如果一个用户顺序地进行多次读取，则他们不会看到时间回退，也就是说，如果已经读取到较新的数据，后续的读取不会得到更旧的数据。

实现单调读的一种方式是确保每个用户总是从同一个副本进行读取（不同的用户可以从不同的副本读取）。例如，可以基于用户 ID 的散列来选择副本，而不是随机选择副本。但是，如果该副本出现故障，用户的查询将需要重新路由到另一个副本。

#### 一致前缀读

**如果某些分区的复制速度慢于其他分区，那么观察者可能会在看到问题之前先看到答案。**

要防止这种异常，需要另一种类型的保证：**一致前缀读（consistent prefix reads）**。这个保证的意思是说：如果一系列写入按某个顺序发生，那么任何人读取这些写入时，也会看见它们以同样的顺序出现。

这是 **分区（partitioned）** 或 **分片（sharded）** 数据库中的一个特殊问题。在许多分布式数据库中，不同的分区独立运行，因此不存在 **全局的写入顺序**：当用户从数据库中读取数据时，可能会看到数据库的某些部分处于较旧的状态，而某些则处于较新的状态。

一种解决方案是，确保任何因果相关的写入都写入相同的分区，但在一些应用中可能无法高效地完成这种操作。

#### 复制延迟的解决方案

在使用最终一致的系统时，如果复制延迟增加到几分钟甚至几小时，则应该考虑应用程序的行为。如果结果对于用户来说是不好的体验，那么设计系统来提供更强的保证（例如 **写后读**）是很重要的。明明是异步复制却假设复制是同步的，这是很多麻烦的根源。

**数据库通过事务提供强大的保证**，所以应用程序可以更加简单。单节点事务已经存在了很长时间。然而在走向分布式（复制和分区）数据库时，许多系统放弃了事务，声称事务在性能和可用性上的代价太高，并断言在可伸缩系统中最终一致性是不可避免的。

### 多主复制

基于领导者的复制模型的自然延伸是允许多个节点接受写入。复制仍然以同样的方式发生：处理写入的每个节点都必须将该数据变更转发给所有其他节点。我们将其称之为 **多领导者配置**（multi-leader configuration，也称多主、多活复制，即 master-master replication 或 active/active replication）。在这种情况下，每个主库同时是其他主库的从库。

#### 多主复制的应用场景

在单个数据中心内部使用多个主库的配置没有太大意义，因为其导致的复杂性已经超过了能带来的好处。

**运维多个数据中心**

多主配置中可以在每个数据中心都有主库。在每个数据中心内使用常规的主从复制；在数据中心之间，每个数据中心的主库都会将其更改复制到其他数据中心的主库中。

![](https://raw.githubusercontent.com/L2ncE/images/main/PicGo/Images20240220161508.png)

* 性能

在多主配置中，每个写操作都可以在本地数据中心进行处理，并与其他数据中心异步复制。因此，数据中心之间的网络延迟对用户来说是透明的，这意味着感觉到的性能可能会更好。

* 容忍数据中心停机

在单主配置中，如果主库所在的数据中心发生故障，故障切换必须使另一个数据中心里的从库成为主库。在多主配置中，每个数据中心可以独立于其他数据中心继续运行，并且当发生故障的数据中心归队时，复制会自动赶上。

* 容忍网络问题

数据中心之间的通信通常穿过公共互联网，这可能不如数据中心内的本地网络可靠。单主配置对数据中心之间的连接问题非常敏感，因为通过这个连接进行的写操作是同步的。采用异步复制功能的多主配置通常能更好地承受网络问题：临时的网络中断并不会妨碍正在处理的写入。

尽管多主复制有这些优势，但也有一个很大的缺点：两个不同的数据中心可能会同时修改相同的数据，写冲突是必须解决的。

由于多主复制在许多数据库中都属于改装的功能，经常与其他数据库功能之间出现意外的反应。比如自增主键、触发器、完整性约束等都可能会有麻烦。因此，多主复制往往被认为是危险的领域，应尽可能避免。

**需要离线操作的客户端**

考虑手机，笔记本电脑和其他设备上的日历应用。无论设备目前是否有互联网连接，你需要能随时查看你的会议（发出读取请求），输入新的会议（发出写入请求）。如果在离线状态下进行任何更改，则设备下次上线时，需要与服务器和其他设备同步。

从架构的角度来看，这种设置实际上与数据中心之间的多主复制类似，每个设备都是一个 “数据中心”，而它们之间的网络连接是极度不可靠的。

**协同编辑**

我们通常不会将协作式编辑视为数据库复制问题，但它与前面提到的离线编辑用例有许多相似之处。当一个用户编辑文档时，所做的更改将立即应用到其本地副本，并异步复制到服务器和编辑同一文档的任何其他用户。

如果要保证不会发生编辑冲突，则应用程序必须先取得文档的锁定，然后用户才能对其进行编辑。如果另一个用户想要编辑同一个文档，他们首先必须等到第一个用户提交修改并释放锁定。这种协作模式相当于主从复制模型下在主节点上执行事务操作。

但是，为了加速协作，你可能希望将更改的单位设置得非常小（例如单次按键），并避免锁定。这种方法允许多个用户同时进行编辑，但同时也带来了多主复制的所有挑战，包括需要解决冲突。

#### 处理写入冲突

多主复制的最大问题是可能发生写冲突，这意味着需要解决冲突。

![](https://raw.githubusercontent.com/L2ncE/images/main/PicGo/Images20240220163415.png)

**同步与异步冲突检测**

在单主数据库中，第二个写入将被阻塞并等待第一个写入完成，或者中止第二个写入事务并强制用户重试。另一方面，在多主配置中，两个写入都是成功的，在稍后的某个时间点才能异步地检测到冲突。那时再来要求用户解决冲突可能为时已晚。

原则上，可以使冲突检测同步 - 即等待写入被复制到所有副本，然后再告诉用户写入成功。但是，通过这样做，你将失去多主复制的主要优点：允许每个副本独立地接受写入。如果你想要同步冲突检测，那么你可能不如直接使用单主复制。

**避免冲突**

由于许多的多主复制实现在处理冲突时处理得相当不好，避免冲突是一个经常被推荐的方法。

在一个用户可以编辑自己数据的应用程序中，可以确保来自特定用户的请求始终路由到同一数据中心，并使用该数据中心的主库进行读写。不同的用户可能有不同的 “主” 数据中心，但从任何一位用户的角度来看，本质上就是单主配置了。

但是，有时你可能需要更改被指定的主库，在这种情况下，冲突避免将失效，你必须处理不同主库同时写入的可能性。

**收敛至一致的状态**

* 给每个写入一个唯一的 ID（例如时间戳、长随机数、UUID 或者键和值的哈希），挑选最高 ID 的写入作为胜利者，并丢弃其他写入。如果使用时间戳，这种技术被称为 **最后写入胜利（LWW, last write wins）**。虽然这种方法很流行，但是很容易造成数据丢失。
* 为每个副本分配一个唯一的 ID，ID 编号更高的写入具有更高的优先级。这种方法也意味着数据丢失。
* 以某种方式将这些值合并在一起。
* 用一种可保留所有信息的显式数据结构来记录冲突，并编写解决冲突的应用程序代码（也许通过提示用户的方式）。

**自定义冲突解决逻辑**

* 写时执行

  只要数据库系统检测到复制更改日志中存在冲突，就会调用冲突处理程序。例如，Bucardo 允许你为此编写一段 Perl 代码。这个处理程序通常不能提示用户 —— 它在后台进程中运行，并且必须快速执行。
* 读时执行

  当检测到冲突时，所有冲突写入被存储。下一次读取数据时，会将这些多个版本的数据返回给应用程序。应用程序可以提示用户或自动解决冲突，并将结果写回数据库。例如 CouchDB 就以这种方式工作。

请注意，冲突解决通常适用于单行记录或单个文档的层面，而不是整个事务。

**什么是冲突**

两个写操作并发地修改了同一条记录中的同一个字段，并将其设置为两个不同的值。毫无疑问这是一个冲突。

其他类型的冲突可能更为微妙而难以发现。例如，考虑一个会议室预订系统：它记录谁订了哪个时间段的哪个房间。应用程序需要确保每个房间在任意时刻都只能被一组人进行预定（即不得有相同房间的重叠预订）。在这种情况下，如果为同一个房间同时创建两个不同的预订，则可能会发生冲突。即使应用程序在允许用户进行预订之前先检查会议室的可用性，如果两次预订是由两个不同的主库进行的，则仍然可能会有冲突。

#### 多主复制拓扑

![](https://raw.githubusercontent.com/L2ncE/images/main/PicGo/Images20240221114244.png)

最常见的拓扑是全部到全部，其中每个主库都将其写入发送给其他所有的主库。默认情况下 MySQL 仅支持 **环形拓扑（circular topology）**，其中每个节点都从一个节点接收写入，并将这些写入（加上自己的写入）转发给另一个节点。另一种流行的拓扑结构具有星形的形状：一个指定的根节点将写入转发给所有其他节点。星形拓扑可以推广到树。

环形和星形拓扑的问题是，如果只有一个节点发生故障，则可能会中断其他节点之间的复制消息流。拓扑结构可以重新配置为跳过发生故障的节点，但在大多数部署中，这种重新配置必须手动完成。更密集连接的拓扑结构（例如全部到全部）的容错性更好，因为它允许消息沿着不同的路径传播，可以避免单点故障。

另一方面，全部到全部的拓扑也可能有问题。一些网络链接可能比其他网络链接更快（例如由于网络拥塞），结果是一些复制消息可能 “超越” 其他复制消息。

### 无主复制

一些数据存储系统采用不同的方法，放弃主库的概念，并允许任何副本直接接受来自客户端的写入。

在一些无主复制的实现中，客户端直接将写入发送到几个副本中，而另一些情况下，由一个 **协调者（coordinator）** 节点代表客户端进行写入。但与主库数据库不同，协调者不执行特定的写入顺序。

#### 当节点故障时写入数据库

![](https://raw.githubusercontent.com/L2ncE/images/main/PicGo/Images20240221141339.png)

假设三个副本中的两个承认写入是足够的：在用户 1234 已经收到两个确定的响应之后，我们认为写入成功。客户简单地忽略了其中一个副本错过了写入的事实。

现在想象一下，不可用的节点重新联机，客户端开始读取它。节点关闭期间发生的任何写入都不在该节点上。因此，如果你从该节点读取数据，则可能会从响应中拿到陈旧的（过时的）值。

为了解决这个问题，当一个客户端从数据库中读取数据时，它不仅仅把它的请求发送到一个副本：读请求将被并行地发送到多个节点。客户可能会从不同的节点获得不同的响应，即来自一个节点的最新值和来自另一个节点的陈旧值。版本号将被用于确定哪个值是更新的。

**读修复和反熵**

* 读修复（Read repair）

  当客户端并行读取多个节点时，它可以检测到任何陈旧的响应。客户端发现副本具有陈旧值，并将新值写回到该副本。这种方法适用于读频繁的值。
* 反熵过程（Anti-entropy process）

  此外，一些数据存储具有后台进程，该进程不断查找副本之间的数据差异，并将任何缺少的数据从一个副本复制到另一个副本。与基于领导者的复制中的复制日志不同，此反熵过程不会以任何特定的顺序复制写入，并且在复制数据之前可能会有显著的延迟。

如果没有反熵过程，很少被读取的值可能会从某些副本中丢失，从而降低了持久性，因为只有在应用程序读取值时才执行读修复。

**读写的法定人数**

如果有 n 个副本，每个写入必须由 w 个节点确认才能被认为是成功的，并且我们必须至少为每个读取查询 r 个节点。只要 w + r > n，我们可以预期在读取时能获得最新的值，因为 r 个读取中至少有一个节点是最新的。遵循这些 r 值和 w 值的读写称为 **法定人数**（quorum）的读和写。你可以认为，r 和 w 是有效读写所需的最低票数。

在 Dynamo 风格的数据库中，参数 n、w 和 r 通常是可配置的。一个常见的选择是使 n 为奇数（通常为 3 或 5）并设置 w = r = (n + 1) / 2（向上取整）。但是你可以根据需要更改数字。例如，写入次数较少且读取次数较多的工作负载可以从设置 w = n 和 r = 1 中受益。这会使得读取速度更快，但缺点是只要有一个不可用的节点就会导致所有的数据库写入都失败。

#### 法定人数一致性的局限性

可以将 w 和 r 设置为较小的数字，以使 w + r ≤ n（即法定条件不满足）。在这种情况下，读取和写入操作仍将被发送到 n 个节点，但操作成功只需要少量的成功响应。

较小的 w 和 r 更有可能会读取到陈旧的数据，因为你的读取更有可能未包含具有最新值的节点。另一方面，这种配置允许更低的延迟和更高的可用性：如果存在网络中断，并且许多副本变得无法访问，则有更大的机会可以继续处理读取和写入。只有当可达副本的数量低于 w 或 r 时，数据库才变得不可写入或读取。

但是，即使在 w + r > n 的情况下，也可能存在返回陈旧值的边缘情况。这取决于实现，但可能的情况包括：

* 如果使用宽松的法定人数，w 个写入和 r 个读取有可能落在完全不同的节点上，因此 r 节点和 w 之间不再保证有重叠节点。
* 如果两个写入同时发生，不清楚哪一个先发生。在这种情况下，唯一安全的解决方案是合并并发写入。如果根据时间戳（最后写入胜利）挑选出一个胜者，则写入可能由于时钟偏差而丢失。
* 如果写操作与读操作同时发生，写操作可能仅反映在某些副本上。在这种情况下，不确定读取返回的是旧值还是新值。
* 如果写操作在某些副本上成功，而在其他节点上失败，在小于 w 个副本上写入成功。所以整体判定写入失败，但整体写入失败并没有在写入成功的副本上回滚。后续的读取仍然可能会读取这次失败写入的值。
* 如果携带新值的节点发生故障，需要从其他带有旧值的副本进行恢复，则存储新值的副本数可能会低于 w，从而打破法定人数条件。
* 即使一切工作正常，有时也会不幸地出现关于 **时序（timing）** 的边缘情况。

Dynamo 风格的数据库通常针对可以忍受最终一致性的用例进行优化。你可以通过参数 w 和 r 来调整读取到陈旧值的概率，但把它们当成绝对的保证是不明智的。

**监控陈旧度**

从运维的角度来看，监视你的数据库是否返回最新的结果是很重要的。即使应用可以容忍陈旧的读取，你也需要了解复制的健康状况。

对于基于领导者的复制，你可以将其提供给监视系统。写入是按照相同的顺序应用于主库和从库，并且每个节点对应了复制日志中的一个位置（已经在本地应用的写入数量）。通过从主库的当前位置中减去从库的当前位置，你可以测量复制延迟的程度。

然而，在无主复制的系统中，没有固定的写入顺序，这使得监控变得更加困难。而且，如果数据库只使用读修复（没有反熵过程），那么对于一个值可能会有多陈旧其实是没有限制的 - 如果一个值很少被读取，那么由一个陈旧副本返回的值可能是古老的。

#### 宽松的法定人数与提示移交

在一个大型的集群中（节点数量明显多于 n 个），网络中断期间客户端可能仍能连接到一些数据库节点，但又不足以组成一个特定的法定人数。在这种情况下，数据库设计人员需要权衡一下：

* 对于所有无法达到 w 或 r 个节点法定人数的请求，是否返回错误是更好的？
* 或者我们是否应该接受写入，然后将它们写入一些可达的节点，但不在这些值通常所存在的 n 个节点上？

后者被认为是一个 **宽松的法定人数（sloppy quorum）**：写和读仍然需要 w 和 r 个成功的响应，但这些响应可能来自不在指定的 n 个 “主” 节点中的其它节点。就好比说，如果你把自己锁在房子外面了，你可能会去敲开邻居的门，问是否可以暂时呆在他们的沙发上。

一旦网络中断得到解决，一个节点代表另一个节点临时接受的任何写入都将被发送到适当的 “主” 节点。这就是所谓的 **提示移交（hinted handoff）**。

宽松的法定人数对写入可用性的提高特别有用：只要有任何 w 个节点可用，数据库就可以接受写入。

在传统意义上，宽松的法定人数实际上并不是法定人数。它只是一个持久性的保证，即数据已存储在某处的 w 个节点。但不能保证 r 个节点的读取能看到它，除非提示移交已经完成。

**运维多个数据中心**

无主复制也适用于多数据中心操作，既然它旨在容忍冲突的并发写入、网络中断和延迟尖峰。

副本的数量 n 包括所有数据中心的节点，你可以在配置中指定每个数据中心所拥有的副本的数量。客户端的写入都会发送到所有副本，但客户端通常只等待来自其本地数据中心内的法定节点的确认，从而不会受到跨数据中心链路延迟和中断的影响。对其他数据中心的高延迟写入通常被配置为异步执行，尽管该配置仍有一定的灵活性。

Riak 将客户端和数据库节点之间的所有通信保持在一个本地的数据中心，因此 n 描述了一个数据中心内的副本数量。数据库集群之间的跨数据中心复制在后台异步发生，其风格类似于多主复制。

#### 检测并发写入

![](https://raw.githubusercontent.com/L2ncE/images/main/PicGo/Images20240221171538.png)

如果每个节点只要接收到来自客户端的写入请求就简单地覆写某个键值，那么节点就会永久地不一致，如图中的最终获取请求所示：节点 2 认为 X 的最终值是 B，而其他节点认为值是 A 。

为了最终达成一致，副本应该趋于相同的值。如何做到这一点？有人可能希望复制的数据库能够自动处理，但不幸的是，大多数的实现都很糟糕：如果你想避免丢失数据，你需要知道很多有关数据库冲突处理的内部信息。

**最后写入胜利（丢弃并发写入）**

当客户端向数据库节点发送写入请求时，两个客户端都不知道另一个客户端，因此不清楚哪一个先发送请求。事实上，说这两种情况谁先发送请求是没有意义的：既然我们说写入是 **并发（concurrent）** 的，那么它们的顺序就是不确定的。

我们可以强制进行排序。例如，可以为每个写入附加一个时间戳，然后挑选最大的时间戳作为 **“最近的”**，并丢弃具有较早时间戳的任何写入。这种冲突解决算法被称为 **最后写入胜利（LWW, last write wins）**，是 Cassandra 唯一支持的冲突解决方法。

LWW 实现了最终收敛的目标，但以 **持久性** 为代价：如果同一个键有多个并发写入，即使它们反馈给客户端的结果都是成功的（因为它们被写入 w 个副本），也只有一个写入将被保留，而其他写入将被默默地丢弃。

在类似缓存的一些情况下，写入丢失可能是可以接受的。但如果数据丢失不可接受，LWW 是解决冲突的一个很烂的选择。

在数据库中使用 LWW 的唯一安全方法是确保一个键只写入一次，然后视为不可变，从而避免对同一个键进行并发更新。例如，Cassandra 推荐使用的方法是使用 UUID 作为键，从而为每个写操作提供一个唯一的键。

**“此前发生” 的关系和并发**

如果操作 B 了解操作 A，或者依赖于 A，或者以某种方式构建于操作 A 之上，则操作 A 在操作 B 之前发生（happens before）。一个操作是否在另一个操作之前发生是定义并发含义的关键。事实上，我们可以简单地说，如果两个操作中的任何一个都不在另一个之前发生（即，两个操作都不知道对方），那么这两个操作是并发的。

只要有两个操作 A 和 B，就有三种可能性：A 在 B 之前发生，或者 B 在 A 之前发生，或者 A 和 B 并发。我们需要的是一个算法来告诉我们两个操作是否是并发的。如果一个操作发生在另一个操作之前，则后面的操作应该覆盖前面的操作，但是如果这些操作是并发的，则存在需要解决的冲突。

**捕获“此前发生”关系**

![](https://raw.githubusercontent.com/L2ncE/images/main/PicGo/Images20240221172743.png)

箭头表示哪个操作发生在其他操作之前，意味着后面的操作知道或依赖于较早的操作。在这个例子中，客户端永远不会完全拿到服务器上的最新数据，因为总是有另一个操作同时进行。但是旧版本的值最终会被覆盖，并且不会丢失任何写入。

![](https://raw.githubusercontent.com/L2ncE/images/main/PicGo/Images20240221173042.png)

服务器可以只通过查看版本号来确定两个操作是否是并发的 —— 它不需要对值本身进行解释（因此该值可以是任何数据结构）。

* 服务器为每个键维护一个版本号，每次写入该键时都递增版本号，并将新版本号与写入的值一起存储。
* 当客户端读取键时，服务器将返回所有未覆盖的值以及最新的版本号。客户端在写入前必须先读取。
* 当客户端写入键时，必须包含之前读取的版本号，并且必须将之前读取的所有值合并在一起。
* 当服务器接收到具有特定版本号的写入时，它可以覆盖该版本号或更低版本的所有值（因为它知道它们已经被合并到新的值中），但是它必须用更高的版本号来保存所有值（因为这些值与正在进行的其它写入是并发的）。

**合并并发写入的值**

合并并发值，本质上是与多主复制中的冲突解决问题相同。一个简单的方法是根据版本号或时间戳（最后写入胜利）来选择一个值，但这意味着丢失数据。

以购物车为例，一种合理的合并值的方法就是做并集。在上图中，最后的两个兄弟是 \[牛奶，面粉，鸡蛋，熏肉] 和 \[鸡蛋，牛奶，火腿]。注意牛奶和鸡蛋虽然同时出现在两个并发值里，但他们每个只被写过一次。合并的值可以是 \[牛奶，面粉，鸡蛋，培根，火腿]，不再有重复了。

然而，如果你想让人们也可以从他们的购物车中 **移除** 东西，那么把并发值做并集可能不会产生正确的结果：如果你合并了两个客户端的购物车，并且只在其中一个客户端里面移除了一个项目，那么被移除的项目将会重新出现在这两个客户端的交集结果中。为了防止这个问题，要移除一个项目时不能简单地直接从数据库中删除；相反，系统必须留下一个具有适当版本号的标记，以在兄弟合并时表明该项目已被移除。这种删除标记被称为 **墓碑（tombstone）**。

**版本向量**

当多个副本并发接受写入时，只使用单个版本号是不够的。我们还需要对 **每个副本** 使用版本号。每个副本在处理写入时增加自己的版本号，并且跟踪从其他副本中看到的版本号。这个信息指出了要覆盖哪些并发值，以及要保留哪些并发值或兄弟值。

所有副本的版本号集合称为 **版本向量（version vector）**。当读取值时，版本向量会从数据库副本发送到客户端，并且随后写入值时需要将其发送回数据库。

应用程序可能需要合并并发值。版本向量结构能够确保从一个副本读取并随后写回到另一个副本是安全的。这样做虽然可能会在其他副本上面创建数据，但只要能正确合并就不会丢失数据。

## 第六章：分区

对于非常大的数据集，或非常高的吞吐量，仅仅进行复制是不够的：我们需要将数据进行 **分区（partitions）**，也称为 **分片（sharding）**。

### 分区与复制

分区通常与复制结合使用，使得每个分区的副本存储在多个节点上。这意味着，即使每条记录属于一个分区，它仍然可以存储在多个不同的节点上以获得容错能力。

![](https://raw.githubusercontent.com/L2ncE/images/main/PicGo/Images20240222105848.png)

### 键值数据的分区

不均衡导致的高负载的分区被称为 **热点（hot spot）**。

避免热点最简单的方法是将记录随机分配给节点。这将在所有节点上平均分配数据，但是它有一个很大的缺点：当你试图读取一个特定的值时，你无法知道它在哪个节点上，所以你必须并行地查询所有的节点。

现在假设你有一个简单的键值数据模型，其中你总是通过其主键访问记录。例如，在一本老式的纸质百科全书中，你可以通过标题来查找一个条目；由于所有条目按字母顺序排序，因此你可以快速找到你要查找的条目。

#### 根据键的范围分区

一种分区的方法是为每个分区指定一块连续的键范围（从最小值到最大值），如纸质百科全书的卷。如果知道范围之间的边界，则可以轻松确定哪个分区包含某个值。

键的范围不一定均匀分布，因为数据也很可能不均匀分布。只是简单的规定每个卷包含两个字母会导致一些卷比其他卷大。

Key Range 分区的缺点是某些特定的访问模式会导致热点。 如果主键是时间戳，则分区对应于时间范围，例如，给每天分配一个分区，那么所有写入操作都会转到同一个分区（即今天的分区），这样分区可能会因写入而过载，而其他分区则处于空闲状态。

为了避免这个问题，需要使用除了时间戳以外的其他东西作为主键的第一个部分。 例如，可以在每个时间戳前添加测量名称，这样会首先按名称，然后按时间进行分区。 假设有多个测量同时运行，写入负载将最终均匀分布在不同分区上。 现在，当想要在一个时间范围内获取多个测量的值时，你需要为每个测量名称执行一个单独的范围查询。

#### 根据键的散列分区

出于分区的目的，散列函数不需要多么强壮的加密算法：例如，Cassandra 和 MongoDB 使用 MD5。一旦你有一个合适的键散列函数，你可以为每个分区分配一个散列范围（而不是键的范围），每个通过哈希散列落在分区范围内的键将被存储在该分区中。

通过使用键散列进行分区，我们失去了高效执行范围查询的能力。曾经相邻的键现在分散在所有分区中，所以它们之间的顺序就丢失了。在 MongoDB 中，如果你使用了基于散列的分区模式，则任何范围查询都必须发送到所有分区。

Cassandra 采取了折衷的策略。 Cassandra 中的表可以使用由多个列组成的复合主键来声明。键中只有第一列会作为散列的依据，而其他列则被用作 Casssandra 的 SSTables 中排序数据的连接索引。

组合索引方法为一对多关系提供了一个优雅的数据模型。在社交媒体网站上，一个用户可能会发布很多更新。如果更新的主键被选择为 `(user_id, update_timestamp)`，那么你可以有效地检索特定用户在某个时间间隔内按时间戳排序的所有更新。不同的用户可以存储在不同的分区上，对于每个用户，更新按时间戳顺序存储在单个分区上。

#### 负载偏斜与热点消除

哈希分区可以帮助减少热点。但是，它不能完全避免它们：在极端情况下，所有的读写操作都是针对同一个键的，所有的请求都会被路由到同一个分区。

在社交媒体网站上，一个拥有数百万粉丝的用户在做某事时可能会引发一场风暴。这个事件可能导致同一个键的大量写入（键可能是名人的用户 ID，或者人们正在评论的动作的 ID）。哈希策略不起作用，因为两个相同 ID 的哈希值仍然是相同的。

一个简单的方法是在主键的开始或结尾添加一个随机数。只要一个两位数的十进制随机数就可以将主键分散为 100 种不同的主键，从而存储在不同的分区中。

然而，将主键进行分割之后，任何读取都必须要做额外的工作，因为他们必须从所有 100 个主键分布中读取数据并将其合并。

### 分区与次级索引

如果只通过主键访问记录，我们可以从该键确定分区，并使用它来将读写请求路由到负责该键的分区。如果涉及次级索引，情况会变得更加复杂。次级索引通常并不能唯一地标识记录，而是一种搜索记录中出现特定值的方式，例如查找所有颜色为红色的车辆。

次级索引的问题是它们不能整齐地映射到分区。有两种用次级索引对数据库进行分区的方法：**基于文档的分区（document-based）** 和 **基于关键词（term-based）的分区**。

#### 基于文档的次级索引进行分区

每个列表都有一个唯一的 ID，称之为文档 ID，并且用文档 ID 对数据库进行分区（例如，分区 0 中的 ID 0 到 499，分区 1 中的 ID 500 到 999 等）。

你想让用户搜索汽车，允许他们通过颜色和厂商过滤，所以需要一个在颜色和厂商上的次级索引。

在这种索引方法中，每个分区是完全独立的：每个分区维护自己的次级索引，只需处理包含你正在编写的文档 ID 的分区即可。出于这个原因，**文档分区索引** 也被称为 **本地索引**。

但是，从文档分区索引中读取需要注意：除非你对文档 ID 做了特别的处理，否则没有理由将所有具有特定颜色或特定品牌的汽车放在同一个分区中。如果要搜索红色汽车，则需要将查询发送到所有分区，并合并所有返回的结果。

#### 基于关键词（Term）的次级索引进行分区

我们可以构建一个覆盖所有分区数据的 **全局索引**，而不是给每个分区创建自己的次级索引（本地索引）。但是，我们不能只把这个索引存储在一个节点上，因为它可能会成为瓶颈，违背了分区的目的。全局索引也必须进行分区，但可以采用与主键不同的分区方式。

![](https://raw.githubusercontent.com/L2ncE/images/main/PicGo/Images20240222114955.png)

来自所有分区的红色汽车在红色索引中，并且索引是分区的，首字母从 `a` 到 `r` 的颜色在分区 0 中，`s` 到 `z` 的在分区 1。

我们将这种索引称为 **关键词分区（term-partitioned）**，因为我们寻找的关键词决定了索引的分区方式。例如，一个关键词可能是：`color:red`。

关键词分区的全局索引优于文档分区索引的地方点是不需要 **分散 / 收集** 所有分区，客户端只需要向包含关键词的分区发出请求。全局索引的缺点在于写入速度较慢且较为复杂，因为写入单个文档现在可能会影响索引的多个分区。

理想情况下，索引总是最新的，写入数据库的每个文档都会立即反映在索引中。但是，在关键词分区索引中，这需要跨分区的分布式事务。

在实践中，对全局次级索引的更新通常是 **异步** 的（也就是说，如果在写入之后不久读取索引，刚才所做的更改可能尚未反映在索引中）。

### 分区再平衡

* 查询吞吐量增加，所以你想要添加更多的 CPU 来处理负载。
* 数据集大小增加，所以你想添加更多的磁盘和 RAM 来存储它。
* 机器出现故障，其他机器需要接管故障机器的责任。

所有这些更改都需要数据和请求从一个节点移动到另一个节点。 将负载从集群中的一个节点向另一个节点移动的过程称为 **再平衡（rebalancing）**。

#### 再平衡策略

有几种不同的分区分配方法。

**反面教材：hash mod N**

如果节点数量 N 发生变化，大多数键将需要从一个节点移动到另一个节点。例如，假设 hash(key) = 123456。如果最初有 10 个节点，那么这个键一开始放在节点 6 上（因为 123456 mod 10 = 6）。当你增长到 11 个节点时，键需要移动到节点 3（123456 mod 11 = 3），当你增长到 12 个节点时，需要移动到节点 0（123456 mod 12 = 0）。这种频繁的举动使得再平衡的成本过高。

**固定数量的分区**

有一个相当简单的解决方案：创建比节点更多的分区，并为每个节点分配多个分区。例如，运行在 10 个节点的集群上的数据库可能会从一开始就被拆分为 1,000 个分区，因此大约有 100 个分区被分配给每个节点。

现在，如果一个节点被添加到集群中，新节点可以从当前每个节点中 **窃取** 一些分区，直到分区再次公平分配。如果从集群中删除一个节点，则会发生相反的情况。

这种变更并不是即时的 — 在网络上传输大量的数据需要一些时间 — 所以在传输过程中，原有分区仍然会接受读写操作。

**动态分区**

对于使用键范围分区的数据库，具有固定边界的固定数量的分区将非常不便：如果出现边界错误，则可能会导致一个分区中的所有数据或者其他分区中的所有数据为空。

出于这个原因，按键的范围进行分区的数据库（如 HBase 和 RethinkDB）会动态创建分区。当分区增长到超过配置的大小时（在 HBase 上，默认值是 10GB），会被分成两个分区，每个分区约占一半的数据。与之相反，如果大量数据被删除并且分区缩小到某个阈值以下，则可以将其与相邻分区合并。此过程与 B 树顶层发生的过程类似。

动态分区的一个优点是分区数量适应总数据量。如果只有少量的数据，少量的分区就足够了，所以开销很小；如果有大量的数据，每个分区的大小被限制在一个可配置的最大值。

一个空的数据库从一个分区开始，因为没有关于在哪里绘制分区边界的先验信息。数据集开始时很小，直到达到第一个分区的分割点，所有写入操作都必须由单个节点处理，而其他节点则处于空闲状态。为了解决这个问题，HBase 和 MongoDB 允许在一个空的数据库上配置一组初始分区（这被称为 **预分割**，即 pre-splitting）。在键范围分区的情况中，预分割需要提前知道键是如何进行分配的。

**按节点比例分区**

每个节点具有固定数量的分区。在这种情况下，每个分区的大小与数据集大小成比例地增长，而节点数量保持不变，但是当增加节点数时，分区将再次变小。由于较大的数据量通常需要较大数量的节点进行存储，因此这种方法也使每个分区的大小较为稳定。

#### 运维：手动还是自动再平衡

全自动再平衡可以很方便，因为正常维护的操作工作较少。然而，它可能是不可预测的。再平衡是一个昂贵的操作，因为它需要重新路由请求并将大量数据从一个节点移动到另一个节点。如果没有做好，这个过程可能会使网络或节点负载过重，降低其他请求的性能。

这种自动化与自动故障检测相结合可能十分危险。例如，假设一个节点过载，并且对请求的响应暂时很慢。其他节点得出结论：过载的节点已经死亡，并自动重新平衡集群，使负载离开它。这会对已经超负荷的节点，其他节点和网络造成额外的负载，从而使情况变得更糟，并可能导致级联失败。

出于这个原因，再平衡的过程中有人参与是一件好事。这比全自动的过程慢，但可以帮助防止运维意外。

### 请求路由

如果我想读或写键 “foo”，需要连接哪个 IP 地址和端口号？

这个问题可以概括为 **服务发现（service discovery）** ，它不仅限于数据库。任何可通过网络访问的软件都有这个问题，特别是如果它的目标是高可用性（在多台机器上运行冗余配置）。

概括来说，这个问题有几种不同的方案:

1. 允许客户联系任何节点（例如，通过 **循环策略的负载均衡**，即 Round-Robin Load Balancer）。如果该节点恰巧拥有请求的分区，则它可以直接处理该请求；否则，它将请求转发到适当的节点，接收回复并传递给客户端。
2. 首先将所有来自客户端的请求发送到路由层，它决定了应该处理请求的节点，并相应地转发。此路由层本身不处理任何请求；它仅负责分区的负载均衡。
3. 要求客户端知道分区和节点的分配。在这种情况下，客户端可以直接连接到适当的节点，而不需要任何中介。

许多分布式数据系统都依赖于一个独立的协调服务，比如 ZooKeeper 来跟踪集群元数据，每个节点在 ZooKeeper 中注册自己，ZooKeeper 维护分区到节点的可靠映射。 其他参与者可以在 ZooKeeper 中订阅此信息。 只要分区分配发生了改变，或者集群中添加或删除了一个节点，ZooKeeper 就会通知路由层使路由信息保持最新状态。

#### 执行并行查询

通常用于分析的 **大规模并行处理（MPP, Massively parallel processing）** 关系型数据库产品在其支持的查询类型方面要复杂得多。一个典型的数据仓库查询包含多个连接，过滤，分组和聚合操作。 MPP 查询优化器将这个复杂的查询分解成许多执行阶段和分区，其中许多可以在数据库集群的不同节点上并行执行。涉及扫描大规模数据集的查询特别受益于这种并行执行。

## 第七章：事务

**事务（transaction）** 一直是简化可靠性问题的首选机制。事务是应用程序将多个读写操作组合成一个逻辑单元的一种方式。从概念上讲，事务中的所有读写操作被视作单个操作来执行：整个事务要么成功 **提交**（commit），要么失败 **中止**（abort）或 **回滚**（rollback）。

### 事务的棘手概念

2000 年以后，非关系（NoSQL）数据库开始普及。它们的目标是在关系数据库的现状基础上，通过提供新的数据模型选择并默认包含复制和分区来进一步提升。事务是这次运动的主要牺牲品：这些新一代数据库中的许多数据库完全放弃了事务，或者重新定义了这个词，描述比以前所理解的更弱得多的一套保证。

随着这种新型分布式数据库的炒作，人们普遍认为事务是可伸缩性的对立面，任何大型系统都必须放弃事务以保持良好的性能和高可用性。另一方面，数据库厂商有时将事务保证作为 “重要应用” 和 “有价值数据” 的基本要求。这两种观点都是 **纯粹的夸张**。

事实并非如此简单：与其他技术设计选择一样，事务有其优势和局限性。

#### ACID 的含义

ACID 代表 **原子性（Atomicity）**，**一致性（Consistency）**，**隔离性（Isolation）** 和 **持久性（Durability）**。

今天，当一个系统声称自己 “符合 ACID” 时，实际上能期待的是什么保证并不清楚。不幸的是，ACID 现在几乎已经变成了一个营销术语。

**原子性**

在多线程编程中，如果一个线程执行一个原子操作，这意味着另一个线程无法看到该操作的中间结果。系统只能处于操作之前或操作之后的状态，而不是介于两者之间的状态。

相比之下，ACID 的原子性并 **不** 是关于 **并发（concurrent）** 的。它并不是在描述如果几个进程试图同时访问相同的数据会发生什么情况，这种情况包含在缩写 ***I*** 中，即隔离性。

ACID 原子性的定义特征是：**能够在错误时中止事务，丢弃该事务进行的所有写入变更的能力。** 或许 **可中止性（abortability）** 是更好的术语，但本书将继续使用原子性，因为这是惯用词。

**一致性**

ACID 一致性的概念是，**对数据的一组特定约束必须始终成立**。即 **不变式（invariants）**。例如，在会计系统中，所有账户整体上必须借贷相抵。

但是，一致性的这种概念取决于应用程序对不变式的理解，应用程序负责正确定义它的事务，并保持一致性。这并不是数据库可以保证的事情：如果你写入违反不变式的脏数据，数据库也无法阻止你（一些特定类型的不变式可以由数据库检查，例如外键约束或唯一约束）。

原子性，隔离性和持久性是数据库的属性，而一致性（在 ACID 意义上）是应用程序的属性。应用可能依赖数据库的原子性和隔离属性来实现一致性，但这并不仅取决于数据库。

**隔离性**

大多数数据库都会同时被多个客户端访问。如果它们各自读写数据库的不同部分，这是没有问题的，但是如果它们访问相同的数据库记录，则可能会遇到 **并发** 问题（**竞争条件**，即 race conditions）。

ACID 意义上的隔离性意味着，**同时执行的事务是相互隔离的**：它们不能相互冒犯。传统的数据库教科书将隔离性形式化为 **可串行化（Serializability）**。

在 Oracle 中有一个名为 “可串行的” 隔离级别，但实际上它实现了一种叫做 **快照隔离（snapshot isolation）** 的功能，**这是一种比可串行化更弱的保证**。

**持久性**

**持久性** 是一个承诺，即一旦事务成功完成，即使发生硬件故障或数据库崩溃，写入的任何数据也不会丢失。

它通常包括预写日志或类似的文件，以便在磁盘上的数据结构损坏时进行恢复。在带复制的数据库中，持久性可能意味着数据已成功复制到一些节点。为了提供持久性保证，数据库必须等到这些写入或复制完成后，才能报告事务成功提交。

**完美的持久性是不存在的** ：如果所有硬盘和所有备份同时被销毁，那显然没有任何数据库能救得了你。

#### 单对象和多对象操作

许多非关系数据库并没有将这些操作组合在一起的方法。即使存在多对象 API（例如，某键值存储可能具有在一个操作中更新几个键的 multi-put 操作），但这并不一定意味着它具有事务语义：该命令可能在一些键上成功，在其他的键上失败，使数据库处于部分更新的状态。

**单对象写入**

假设你正在向数据库写入一个 20 KB 的 JSON 文档：

* 如果在发送第一个 10 KB 之后网络连接中断，数据库是否存储了不可解析的 10KB JSON 片段？
* 如果在数据库正在覆盖磁盘上的前一个值的过程中电源发生故障，是否最终将新旧值拼接在一起？
* 如果另一个客户端在写入过程中读取该文档，是否会看到部分更新的值？

存储引擎一个几乎普遍的目标是：对单节点上的单个对象（例如键值对）上提供原子性和隔离性。原子性可以通过使用日志来实现崩溃恢复，并且可以使用每个对象上的锁来实现隔离（每次只允许一个线程访问对象） 。

一些数据库也提供更复杂的原子操作，例如自增操作，这样就不再需要读取 - 修改 - 写入序列了。同样流行的是 CAS 操作，仅当值没有被其他并发修改过时，才允许执行写操作。

**多对象事务的需求**

许多分布式数据存储已经放弃了多对象事务，因为多对象事务很难跨分区实现，而且在需要高可用性或高性能的情况下，它们可能会碍事。

有一些场景中，单对象插入，更新和删除是足够的。但是许多其他场景需要协调写入几个不同的对象：

* 在关系数据模型中，一个表中的行通常具有对另一个表中的行的外键引用。多对象事务使你确保这些引用始终有效：当插入几个相互引用的记录时，外键必须是正确的和最新的，不然数据就没有意义。
* 在具有次级索引的数据库中，每次更改值时都需要更新索引。从事务角度来看，这些索引是不同的数据库对象：例如，如果没有事务隔离性，记录可能出现在一个索引中，但没有出现在另一个索引中，因为第二个索引的更新还没有发生。

这些应用仍然可以在没有事务的情况下实现。然而，**没有原子性，错误处理就要复杂得多，缺乏隔离性，就会导致并发问题**。

**处理错误和中止**

事务的一个关键特性是，如果发生错误，它可以中止并安全地重试。 ACID 数据库基于这样的哲学：如果数据库有违反其原子性，隔离性或持久性的危险，则宁愿完全放弃事务，而不是留下半成品。

错误发生不可避免，但许多软件开发人员倾向于只考虑乐观情况，而不是错误处理的复杂性。例如，像一些对象关系映射（ORM, object-relation Mapping）框架不会重试中断的事务 —— 这个错误通常会导致一个从堆栈向上传播的异常。

尽管重试一个中止的事务是一个简单而有效的错误处理机制，但它并不完美：

* 如果事务实际上成功了，但是在服务器试图向客户端确认提交成功时网络发生故障（所以客户端认为提交失败了），那么重试事务会导致事务被执行两次 —— 除非你有一个额外的去重机制。
* 如果错误是由于负载过大造成的，则重试事务将使问题变得更糟，而不是更好。为了避免这种正反馈循环，可以限制重试次数，使用指数退避算法，并单独处理与过载相关的错误。
* 仅在临时性错误（例如，由于死锁，异常情况，临时性网络中断和故障切换）后才值得重试。在发生永久性错误（例如，违反约束）之后重试是毫无意义的。
* 如果事务在数据库之外也有副作用，即使事务被中止，也可能发生这些副作用。例如，如果你正在发送电子邮件，那你肯定不希望每次重试事务时都重新发送电子邮件。如果你想确保几个不同的系统一起提交或放弃，**两阶段提交（2PC, two-phase commit）** 可以提供帮助。
* 如果客户端进程在重试中失效，任何试图写入数据库的数据都将丢失。

### 弱隔离级别

如果两个事务不触及相同的数据，它们可以安全地 **并行（parallel）** 运行，因为两者都不依赖于另一个。

隔离并没有那么简单。**可串行的隔离** 会有性能损失，许多数据库不愿意支付这个代价。因此，系统通常使用较弱的隔离级别来防止一部分，而不是全部的并发问题。

#### 读已提交

最基本的事务隔离级别是 **读已提交（Read Committed）**，它提供了两个保证：

1. 从数据库读时，只能看到已提交的数据（没有 **脏读**，即 dirty reads）。
2. 写入数据库时，只会覆盖已提交的数据（没有 **脏写**，即 dirty writes）。

**没有脏读**

设想一个事务已经将一些数据写入数据库，但事务还没有提交或中止。另一个事务可以看到未提交的数据吗？如果是的话，那就叫做 **脏读（dirty reads）**。

为什么要防止脏读，有几个原因：

* 如果事务需要更新多个对象，脏读取意味着另一个事务可能会只看到一部分更新。例如，用户看到新的未读电子邮件，但看不到更新的数量。这就是电子邮件的脏读。看到处于部分更新状态的数据库会让用户感到困惑，并可能导致其他事务做出错误的决定。
* 如果事务中止，则所有写入操作都需要回滚。如果数据库允许脏读，那就意味着一个事务可能会看到稍后需要回滚的数据，即从未实际提交给数据库的数据。想想后果就让人头大。

**没有脏写**

如果先前的写入是尚未提交事务的一部分，又会发生什么情况，后面的写入会覆盖一个尚未提交的值？这被称作 **脏写（dirty write）**。在 **读已提交** 的隔离级别上运行的事务必须防止脏写，通常是延迟第二次写入，直到第一次写入事务提交或中止为止。

![](https://raw.githubusercontent.com/L2ncE/images/main/PicGo/Images20240306212824.png)

以一个二手车销售网站为例，Alice 和 Bob 两个人同时试图购买同一辆车。购买汽车需要两次数据库写入：网站上的商品列表需要更新，以反映买家的购买，销售发票需要发送给买家。本来销售是属于 Bob 的（因为他成功更新了商品列表），但发票却寄送给了 Alice（因为她成功更新了发票表）。读已提交会防止这样的事故。

**实现读已提交**

最常见的情况是，数据库通过使用 **行锁（row-level lock）** 来防止脏写：当事务想要修改特定对象（行或文档）时，它必须首先获得该对象的锁。然后必须持有该锁直到事务被提交或中止。一次只有一个事务可持有任何给定对象的锁；如果另一个事务要写入同一个对象，则必须等到第一个事务提交或中止后，才能获取该锁并继续。这种锁定是读已提交模式（或更强的隔离级别）的数据库自动完成的。

防止脏读的一种选择是使用相同的锁，并要求任何想要读取对象的事务获取该锁，然后在读取之后立即释放该锁。这将确保在对象具有脏的、未提交的值时不会发生读取（因为在此期间，锁将由进行写入的事务持有）。

但是要求读锁的办法在实践中效果并不好。因为一个长时间运行的写入事务会迫使许多只读事务等到这个慢写入事务完成。

出于这个原因，大多数数据库对于写入的每个对象，数据库都会记住旧的已提交值，和由当前持有写入锁的事务设置的新值。当事务正在进行时，任何其他读取对象的事务都会拿到旧值。 只有当新值提交后，事务才会切换到读取新值。

#### 快照隔离和可重复读

Alice 在银行有 1000 美元的储蓄，分为两个账户，每个 500 美元。现在有一笔事务从她的一个账户转移了 100 美元到另一个账户。如果她在事务处理的过程中查看其账户余额，她可能会在收到付款之前先看到一个账户的余额（收款账户，余额仍为 500 美元），在发出转账之后再看到另一个账户的余额（付款账户，新余额为 400 美元）。对 Alice 来说，现在她的账户似乎总共只有 900 美元 —— 看起来有 100 美元已经凭空消失了。

这种异常被称为 **不可重复读（nonrepeatable read）** 或 **读取偏差（read skew）**：如果 Alice 在事务结束时再次读取账户 1 的余额，她将看到与她之前的查询中看到的不同的值（600 美元）。在读已提交的隔离条件下，**不可重复读** 被认为是可接受的：Alice 看到的帐户余额确实在阅读时已经提交了。

有些情况下，不能容忍这种暂时的不一致：

* 备份

  进行备份需要复制整个数据库，对大型数据库而言可能需要花费数小时才能完成。备份进程运行时，数据库仍然会接受写入操作。因此备份可能会包含一些旧的部分和一些新的部分。如果从这样的备份中恢复，那么不一致（如消失的钱）就会变成永久的。
* 分析查询和完整性检查

  有时，你可能需要运行一个查询，扫描大部分的数据库。这样的查询在分析中很常见，也可能是定期完整性检查。如果这些查询在不同时间点观察数据库的不同部分，则可能会返回毫无意义的结果。

**快照隔离（snapshot isolation）** 是这个问题最常见的解决方案。想法是，每个事务都从数据库的 **一致快照（consistent snapshot）** 中读取 —— 也就是说，事务可以看到事务开始时在数据库中提交的所有数据。即使这些数据随后被另一个事务更改，每个事务也只能看到该特定时间点的旧数据。

快照隔离对长时间运行的只读查询（如备份和分析）非常有用。如果查询的数据在查询执行的同时发生变化，则很难理解查询的含义。

**实现快照隔离**

从性能的角度来看，快照隔离的一个关键原则是：**读不阻塞写，写不阻塞读**。这允许数据库在处理一致性快照上的长时间查询时，可以正常地同时处理写入操作，且两者间没有任何锁争用。

数据库必须可能保留一个对象的几个不同的提交版本，因为各种正在进行的事务可能需要看到数据库在不同的时间点的状态。因为它同时维护着单个对象的多个版本，所以这种技术被称为 **多版本并发控制（MVCC, multi-version concurrency control）**。

支持快照隔离的存储引擎通常也使用 MVCC 来实现 **读已提交** 隔离级别。一种典型的方法是 **读已提交** 为每个查询使用单独的快照，而 **快照隔离** 对整个事务使用相同的快照。

表中的每一行都有一个 `created_by` 字段，其中包含将该行插入到表中的的事务 ID。此外，每行都有一个 `deleted_by` 字段，最初是空的。如果某个事务删除了一行，那么该行实际上并未从数据库中删除，而是进行标记删除。在稍后的时间，当确定没有事务可以再访问已删除的数据时，数据库中的垃圾收集过程会将所有带有删除标记的行移除，并释放其空间。

**观察一致性快照的可见性规则**

1. 在每次事务开始时，数据库列出当时所有其他（尚未提交或尚未中止）的事务清单，即使之后提交了，这些事务已执行的任何写入也都会被忽略。
2. 被中止事务所执行的任何写入都将被忽略。
3. 由具有较晚事务 ID（即，在当前事务开始之后开始的）的事务所做的任何写入都被忽略，而不管这些事务是否已经提交。
4. 所有其他写入，对应用都是可见的。

换句话说，如果以下两个条件都成立，则可见一个对象：

* 读事务开始时，创建该对象的事务已经提交。
* 对象未被标记为删除，或如果被标记为删除，请求删除的事务在读事务开始时尚未提交。

**索引和快照隔离**

索引如何在多版本数据库中工作？一种选择是使索引简单地指向对象的所有版本，并且需要索引查询来过滤掉当前事务不可见的任何对象版本。当垃圾收集删除任何事务不再可见的旧对象版本时，相应的索引条目也可以被删除。

在 CouchDB、Datomic 和 LMDB 中使用的是一种 **仅追加 / 写时拷贝（append-only/copy-on-write）** 的变体，它们在更新时不覆盖树的页面，而为每个修改页面创建一份副本。从父页面直到树根都会级联更新，以指向它们子页面的新版本。任何不受写入影响的页面都不需要被复制，并且保持不变。

使用仅追加的 B 树，每个写入事务（或一批事务）都会创建一棵新的 B 树，当创建时，从该特定树根生长的树就是数据库的一个一致性快照。没必要根据事务 ID 过滤掉对象，因为后续写入不能修改现有的 B 树；它们只能创建新的树根。但这种方法也需要一个负责压缩和垃圾收集的后台进程。

#### 防止丢失更新

如果应用从数据库中读取一些值，修改它并写回修改的值（读取 - 修改 - 写入序列），则可能会发生丢失更新的问题。如果两个事务同时执行，则其中一个的修改可能会丢失，因为第二个写入的内容并没有包括第一个事务的修改

* 增加计数器或更新账户余额（需要读取当前值，计算新值并写回更新后的值）
* 将本地修改写入一个复杂值中：例如，将元素添加到 JSON 文档中的一个列表（需要解析文档，进行更改并写回修改的文档）
* 两个用户同时编辑 wiki 页面，每个用户通过将整个页面内容发送到服务器来保存其更改，覆写数据库中当前的任何内容。

**原子写**

许多数据库提供了原子更新操作，从而消除了在应用程序代码中执行读取 - 修改 - 写入序列的需要。如果你的代码可以用这些操作来表达，那这通常是最好的解决方案。例如，下面的指令在大多数关系数据库中是并发安全的：

```sql
UPDATE counters SET value = value + 1 WHERE key = 'foo';
```

类似地，像 MongoDB 这样的文档数据库提供了对 JSON 文档的一部分进行本地修改的原子操作，Redis 提供了修改数据结构（如优先级队列）的原子操作。

原子操作通常通过在读取对象时，获取其上的排它锁来实现。以便更新完成之前没有其他事务可以读取它。这种技术有时被称为 **游标稳定性（cursor stability）**。另一个选择是简单地强制所有的原子操作在单一线程上执行。

**显式锁定**

让应用程序显式地锁定将要更新的对象。然后应用程序可以执行读取 - 修改 - 写入序列，如果任何其他事务尝试同时读取同一个对象，则强制等待，直到第一个 **读取 - 修改 - 写入序列** 完成。

例如，考虑一个多人游戏，其中几个玩家可以同时移动相同的棋子。在这种情况下，一个原子操作可能是不够的，因为应用程序还需要确保玩家的移动符合游戏规则，这可能涉及到一些不能合理地用数据库查询实现的逻辑。但你可以使用锁来防止两名玩家同时移动相同的棋子。

```sql
BEGIN TRANSACTION;
SELECT * FROM figures
  WHERE name = 'robot' AND game_id = 222
FOR UPDATE;

-- 检查玩家的操作是否有效，然后更新先前 SELECT 返回棋子的位置。
UPDATE figures SET position = 'c4' WHERE id = 1234;
COMMIT;
```

* `FOR UPDATE` 子句告诉数据库应该对该查询返回的所有行加锁。

**自动检测丢失的更新**

原子操作和锁是通过强制 **读取 - 修改 - 写入序列** 按顺序发生，来防止丢失更新的方法。另一种方法是允许它们并行执行，如果事务管理器检测到丢失更新，则中止事务并强制它们重试其 **读取 - 修改 - 写入序列**。

PostgreSQL 的可重复读，Oracle 的可串行化和 SQL Server 的快照隔离级别，都会自动检测到丢失更新，并中止惹麻烦的事务。但是，MySQL/InnoDB 的可重复读并不会检测 **丢失更新**。一些作者认为，数据库必须能防止丢失更新才称得上是提供了 **快照隔离**，所以在这个定义下，MySQL 下不提供快照隔离。

**比较并设置（CAS）**

只有当前值从上次读取时一直未改变，才允许更新发生。如果当前值与先前读取的值不匹配，则更新不起作用，且必须重试读取 - 修改 - 写入序列。

例如，为了防止两个用户同时更新同一个 wiki 页面，可以尝试类似这样的方式，只有当用户开始编辑后页面内容未发生改变时，才会更新成功：

```
-- 根据数据库的实现情况，这可能安全也可能不安全
UPDATE wiki_pages SET content = '新内容'
  WHERE id = 1234 AND content = '旧内容';
```

如果内容已经更改并且不再与 “旧内容” 相匹配，则此更新将不起作用，因此你需要检查更新是否生效，必要时重试。但是，如果数据库允许 `WHERE` 子句从旧快照中读取，则此语句可能无法防止丢失更新，因为即使发生了另一个并发写入，`WHERE` 条件也可能为真。在依赖数据库的 CAS 操作前要检查其是否安全。

**冲突解决和复制**

锁和 CAS 操作假定只有一个最新的数据副本。但是多主或无主复制的数据库通常允许多个写入并发执行，并异步复制到副本上，因此无法保证只有一个最新数据的副本。所以基于锁或 CAS 操作的技术不适用于这种情况。

这种复制数据库中的一种常见方法是允许并发写入创建多个冲突版本的值（也称为兄弟），并使用应用代码或特殊数据结构在事实发生之后解决和合并这些版本。

原子操作可以在复制的上下文中很好地工作，尤其当它们具有可交换性时（即，可以在不同的副本上以不同的顺序应用它们，且仍然可以得到相同的结果）。例如，递增计数器或向集合添加元素是可交换的操作。

#### 写入偏差与幻读

医院通常会同时要求几位医生待命，但底线是至少有一位医生在待命。在两个事务中，应用首先检查是否有两个或以上的医生正在值班；如果是的话，它就假定一名医生可以安全地休班。由于数据库使用快照隔离，两次检查都返回 2 ，所以两个事务都进入下一个阶段。Alice 更新自己的记录休班了，而 Bob 也做了一样的事情。两个事务都成功提交了，现在没有医生值班了。违反了至少有一名医生在值班的要求。

**写入偏差的特征**

可以将写入偏差视为丢失更新问题的一般化。如果两个事务读取相同的对象，然后更新其中一些对象（不同的事务可能更新不同的对象），则可能发生写入偏差。在多个事务更新同一个对象的特殊情况下，就会发生脏写或丢失更新（取决于时序）。

对于写入偏差，我们的选择更受限制：

* 由于涉及多个对象，单对象的原子操作不起作用。
* 在一些快照隔离的实现中，自动检测丢失更新对此并没有帮助。
* 某些数据库允许配置约束，然后由数据库强制执行（例如，唯一性，外键约束或特定值限制）。但是为了指定至少有一名医生必须在线，需要一个涉及多个对象的约束。大多数数据库没有内置对这种约束的支持，但是你可以使用触发器，或者物化视图来实现它们，这取决于不同的数据库。
* 如果无法使用可串行化的隔离级别，则此情况下的次优选项可能是显式锁定事务所依赖的行。在例子中，你可以写下如下的代码：

```sql
BEGIN TRANSACTION;
SELECT * FROM doctors
  WHERE on_call = TRUE
  AND shift_id = 1234 FOR UPDATE;

UPDATE doctors
  SET on_call = FALSE
  WHERE name = 'Alice'
  AND shift_id = 1234;
  
COMMIT;
```

* 和以前一样，`FOR UPDATE` 告诉数据库锁定返回的所有行以用于更新。

**写入偏差的更多例子**

* 会议室预订系统

  比如你想要规定不能在同一时间对同一个会议室进行多次的预订。当有人想要预订时，首先检查是否存在相互冲突的预订（即预订时间范围重叠的同一房间），如果没有找到，则创建会议。

  ```sql
  BEGIN TRANSACTION;

  -- 检查所有现存的与 12:00~13:00 重叠的预定
  SELECT COUNT(*) FROM bookings
  WHERE room_id = 123 AND
    end_time > '2015-01-01 12:00' AND start_time < '2015-01-01 13:00';

  -- 如果之前的查询返回 0
  INSERT INTO bookings(room_id, start_time, end_time, user_id)
    VALUES (123, '2015-01-01 12:00', '2015-01-01 13:00', 666);

  COMMIT;
  ```

  不幸的是，快照隔离并不能防止另一个用户同时插入冲突的会议。为了确保不会遇到调度冲突，你又需要可串行化的隔离级别了。
* 多人游戏

  我们使用一个锁来防止丢失更新（也就是确保两个玩家不能同时移动同一个棋子）。但是锁定并不妨碍玩家将两个不同的棋子移动到棋盘上的相同位置，或者采取其他违反游戏规则的行为。取决于你正在执行的规则类型，也许可以使用唯一约束（unique constraint），否则你很容易发生写入偏差。
* 抢注用户名

  在每个用户拥有唯一用户名的网站上，两个用户可能会尝试同时创建具有相同用户名的帐户。可以在事务检查名称是否被抢占，如果没有则使用该名称创建账户。但是像在前面的例子中那样，在快照隔离下这是不安全的。幸运的是，唯一约束是一个简单的解决办法（第二个事务在提交时会因为违反用户名唯一约束而被中止）。
* 防止双重开支

  允许用户花钱或使用积分的服务，需要检查用户的支付数额不超过其余额。可以通过在用户的帐户中插入一个试探性的消费项目来实现这一点，列出帐户中的所有项目，并检查总和是否为正值。在写入偏差场景下，可能会发生两个支出项目同时插入，一起导致余额变为负值，但这两个事务都不会注意到另一个。

**导致写入偏差的幻读**

所有这些例子都遵循类似的模式：

1. 一个 `SELECT` 查询找出符合条件的行，并检查是否符合一些要求。（例如：至少有两名医生在值班；不存在对该会议室同一时段的预定；棋盘上的位置没有被其他棋子占据；用户名还没有被抢注；账户里还有足够余额）
2. 按照第一个查询的结果，应用代码决定是否继续。（可能会继续操作，也可能中止并报错）
3. 如果应用决定继续操作，就执行写入（插入、更新或删除），并提交事务。

   这个写入的效果改变了步骤 2 中的先决条件。换句话说，如果在提交写入后，重复执行一次步骤 1 的 SELECT 查询，将会得到不同的结果。因为写入改变了符合搜索条件的行集（现在少了一个医生值班，那时候的会议室现在已经被预订了，棋盘上的这个位置已经被占据了，用户名已经被抢注，账户余额不够了）。

这些步骤可能以不同的顺序发生。例如可以首先进行写入，然后进行 SELECT 查询，最后根据查询结果决定是放弃还是提交。

在医生值班的例子中，在步骤 3 中修改的行，是步骤 1 中返回的行之一，所以我们可以通过锁定步骤 1 中的行（`SELECT FOR UPDATE`）来使事务安全并避免写入偏差。但是其他四个例子是不同的：它们检查是否 **不存在** 某些满足条件的行，写入会 **添加** 一个匹配相同条件的行。如果步骤 1 中的查询没有返回任何行，则 `SELECT FOR UPDATE` 锁不了任何东西。

这种效应：一个事务中的写入改变另一个事务的搜索查询的结果，被称为 **幻读**。快照隔离避免了只读查询中幻读，但是在像我们讨论的例子那样的读写事务中，幻读会导致特别棘手的写入偏差情况。

**物化冲突**

如果幻读的问题是没有对象可以加锁，也许可以人为地在数据库中引入一个锁对象

例如，在会议室预订的场景中，可以想象创建一个关于时间槽和房间的表。此表中的每一行对应于特定时间段（例如 15 分钟）的特定房间。可以提前插入房间和时间的所有可能组合行（例如接下来的六个月）。

现在，要创建预订的事务可以锁定（`SELECT FOR UPDATE`）表中与所需房间和时间段对应的行。在获得锁定之后，它可以检查重叠的预订并像以前一样插入新的预订。请注意，这个表并不是用来存储预订相关的信息 —— 它完全就是一组锁，用于防止同时修改同一房间和时间范围内的预订。

这种方法被称为 **物化冲突（materializing conflicts）**，因为它将幻读变为数据库中一组具体行上的锁冲突。不幸的是，弄清楚如何物化冲突可能很难，也很容易出错，并且让并发控制机制泄漏到应用数据模型是很丑陋的做法。出于这些原因，如果没有其他办法可以实现，物化冲突应被视为最后的手段。在大多数情况下。**可串行化（Serializable）** 的隔离级别是更可取的。

### 可串行化

**可串行化（Serializability）** 隔离通常被认为是最强的隔离级别。它保证即使事务可以并行执行，最终的结果也是一样的，就好像它们没有任何并发性，连续挨个执行一样。

#### 真的串行执行

数据库设计人员只是在 2007 年左右才决定，单线程循环执行事务是可行的。

两个进展引发了这个反思：

* RAM 足够便宜了，许多场景现在都可以将完整的活跃数据集保存在内存中。当事务需要访问的所有数据都在内存中时，事务处理的执行速度要比等待数据从磁盘加载时快得多。
* 数据库设计人员意识到 OLTP 事务通常很短，而且只进行少量的读写操作。相比之下，长时间运行的分析查询通常是只读的，因此它们可以在串行执行循环之外的一致快照（使用快照隔离）上运行。

串行执行事务的方法在 VoltDB/H-Store，Redis 和 Datomic 中实现。设计用于单线程执行的系统有时可以比支持并发的系统性能更好，因为它可以避免锁的协调开销。但是其吞吐量仅限于单个 CPU 核的吞吐量。为了充分利用单一线程，需要与传统形式的事务不同的结构。

**在存储过程恭封装事务**

交互式的事务方式中，应用程序和数据库之间的网络通信耗费了大量的时间。如果不允许在数据库中进行并发处理，且一次只处理一个事务，则吞吐量将会非常糟糕，因为数据库大部分的时间都花费在等待应用程序发出当前事务的下一个查询。在这种数据库中，为了获得合理的性能，需要同时处理多个事务。

出于这个原因，具有单线程串行事务处理的系统不允许交互式的多语句事务。取而代之，应用程序必须提前将整个事务代码作为存储过程提交给数据库。如果事务所需的所有数据都在内存中，则存储过程可以非常快地执行，而不用等待任何网络或磁盘 I/O。

![](https://raw.githubusercontent.com/L2ncE/images/main/PicGo/Images20240314102116.png)

**存储过程的优点和缺点**

存储过程在关系型数据库中已经存在了一段时间了，自 1999 年以来它们一直是 SQL 标准（SQL/PSM）的一部分。出于各种原因，它们的名声有点不太好。

* 在数据库中运行的代码难以管理：与应用服务器相比，它更难调试，更难以保持版本控制和部署，更难测试，并且难以集成到指标收集系统来进行监控。
* 数据库通常比应用服务器对性能敏感的多，因为单个数据库实例通常由许多应用服务器共享。数据库中一个写得不好的存储过程（例如，占用大量内存或 CPU 时间）会比在应用服务器中相同的代码造成更多的麻烦。

现代的存储过程实现放弃了 PL/SQL，而是使用现有的通用编程语言：VoltDB 使用 Java 或 Groovy，Datomic 使用 Java 或 Clojure，而 Redis 使用 Lua。

**存储过程与内存存储**，使得在单个线程上执行所有事务变得可行。由于不需要等待 I/O，且避免了并发控制机制的开销，它们可以在单个线程上实现相当好的吞吐量。

**分区**

对于写入吞吐量较高的应用，单线程事务处理器可能成为一个严重的瓶颈。

为了伸缩至多个 CPU 核心和多个节点，可以对数据进行分区。如果你可以找到一种对数据集进行分区的方法，以便每个事务只需要在单个分区中读写数据，那么每个分区就可以拥有自己独立运行的事务处理线程。

但是，对于需要访问多个分区的任何事务，数据库必须在触及的所有分区之间协调事务。存储过程需要跨越所有分区锁定执行，以确保整个系统的可串行性。由于跨分区事务具有额外的协调开销，所以它们比单分区事务慢得多，并且不能通过增加更多的机器来增加吞吐量。

事务是否可以是划分至单个分区很大程度上取决于应用数据的结构。简单的键值数据通常可以非常容易地进行分区，但是具有多个次级索引的数据可能需要大量的跨分区协调。

**串行执行小结**

在特定约束条件下，真的串行执行事务，已经成为一种实现可串行化隔离等级的可行办法。

* 每个事务都必须小而快，只要有一个缓慢的事务，就会拖慢所有事务处理。
* 仅限于活跃数据集可以放入内存的情况。很少访问的数据可能会被移动到磁盘，但如果需要在单线程执行的事务中访问，系统就会变得非常慢。
* 写入吞吐量必须低到能在单个 CPU 核上处理，如若不然，事务需要能划分至单个分区，且不需要跨分区协调。
* 跨分区事务是可能的，但是它们能被使用的程度有很大的限制。

#### 两阶段锁定

> 请注意，虽然两阶段锁定（2PL）听起来非常类似于两阶段提交（2PC），但它们是完全不同的东西。

如果两个事务同时尝试写入同一个对象，则锁可确保第二个写入必须等到第一个写入完成事务（中止或提交），然后才能继续。两阶段锁定类似，但是锁的要求更强得多。只要没有写入，就允许多个事务同时读取同一个对象。但对象只要有写入（修改或删除），就需要 **独占访问（exclusive access）** 权限：

* 如果事务 A 读取了一个对象，并且事务 B 想要写入该对象，那么 B 必须等到 A 提交或中止才能继续。
* 如果事务 A 写入了一个对象，并且事务 B 想要读取该对象，则 B 必须等到 A 提交或中止才能继续。

在 2PL 中，写入不仅会阻塞其他写入，也会阻塞读，反之亦然。快照隔离使得 **读不阻塞写，写也不阻塞读**。

**实现两阶段锁**

2PL 用于 MySQL（InnoDB）和 SQL Server 中的可串行化隔离级别，以及 DB2 中的可重复读隔离级别。

读与写的阻塞是通过为数据库中每个对象添加锁来实现的。锁可以处于 **共享模式（shared mode）** 或 **独占模式（exclusive mode）**。锁使用如下：

* 若事务要读取对象，则须先以共享模式获取锁。允许多个事务同时持有共享锁。但如果另一个事务已经在对象上持有排它锁，则这些事务必须等待。
* 若事务要写入一个对象，它必须首先以独占模式获取该锁。没有其他事务可以同时持有锁（无论是共享模式还是独占模式），所以如果对象上存在任何锁，该事务必须等待。
* 如果事务先读取再写入对象，则它可能会将其共享锁升级为独占锁。升级锁的工作与直接获得独占锁相同。
* 事务获得锁之后，必须继续持有锁直到事务结束（提交或中止）。这就是 “两阶段” 这个名字的来源：第一阶段（当事务正在执行时）获取锁，第二阶段（在事务结束时）释放所有的锁。

由于使用了这么多的锁，因此很可能会发生：事务 A 等待事务 B 释放它的锁，反之亦然。这种情况叫做 **死锁（Deadlock）**。数据库会自动检测事务之间的死锁，并中止其中一个，以便另一个继续执行。被中止的事务需要由应用程序重试。

**两阶段锁定的性能**

当一个事务需要等待另一个事务时，等待的时长并没有限制。即使你保证所有的事务都很短，如果有多个事务想要访问同一个对象，那么可能会形成一个队列，所以事务可能需要等待几个其他事务才能完成。

因此，运行 2PL 的数据库可能具有相当不稳定的延迟，可能只需要一个缓慢的事务，或者一个访问大量数据并获取许多锁的事务，就能把系统的其他部分拖慢，甚至迫使系统停机。

**谓词锁**

在会议室预订的例子中，这意味着如果一个事务在某个时间窗口内搜索了一个房间的现有预订，则另一个事务不能同时插入或更新同一时间窗口与同一房间的另一个预订 （可以同时插入其他房间的预订，或在不影响另一个预定的条件下预定同一房间的其他时间段）。

从概念上讲，我们需要一个 **谓词锁（predicate lock）**。它类似于前面描述的共享 / 排它锁，但不属于特定的对象（例如，表中的一行），它属于所有符合某些搜索条件的对象，如：

```sql
SELECT * FROM bookings
WHERE room_id = 123 AND
      end_time > '2018-01-01 12:00' AND
      start_time < '2018-01-01 13:00';
```

谓词锁限制访问，如下所示：

* 如果事务 A 想要读取匹配某些条件的对象，它必须获取查询条件上的 **共享谓词锁（shared-mode predicate lock）**。如果另一个事务 B 持有任何满足这一查询条件对象的排它锁，那么 A 必须等到 B 释放它的锁之后才允许进行查询。
* 如果事务 A 想要插入，更新或删除任何对象，则必须首先检查旧值或新值是否与任何现有的谓词锁匹配。如果事务 B 持有匹配的谓词锁，那么 A 必须等到 B 已经提交或中止后才能继续。

这里的关键思想是，谓词锁甚至适用于数据库中尚不存在，但将来可能会添加的对象（幻象）。如果两阶段锁定包含谓词锁，则数据库将阻止所有形式的写入偏差和其他竞争条件，因此其隔离实现了可串行化。

**索引范围锁**

不幸的是谓词锁性能不佳：**如果活跃事务持有很多锁，检查匹配的锁会非常耗时。** 因此，大多数使用 2PL 的数据库实际上实现了索引范围锁（index-range locking，也称为 **next-key locking**），这是一个简化的近似版谓词锁。

通过使谓词匹配到一个更大的集合来简化谓词锁是安全的。例如，如果你有在中午和下午 1 点之间预订 123 号房间的谓词锁，则锁定 123 号房间的所有时间段是安全的近似，因为任何满足原始谓词的写入也一定会满足这种更松散的近似。

在房间预订数据库中，你可能会在 `room_id` 列上有一个索引，并且 / 或者在 `start_time` 和 `end_time` 上有索引（否则前面的查询在大型数据库上的速度会非常慢）：

* 假设你的索引位于 `room_id` 上，并且数据库使用此索引查找 123 号房间的现有预订。现在数据库可以简单地将共享锁附加到这个索引项上，指示事务已搜索 123 号房间用于预订。
* 或者，如果数据库使用基于时间的索引来查找现有预订，那么它可以将共享锁附加到该索引中的一系列值，指示事务已经将 12:00\~13:00 时间段标记为用于预定。

无论哪种方式，搜索条件的近似值都附加到其中一个索引上。现在，如果另一个事务想要插入、更新或删除同一个房间和 / 或重叠时间段的预订，则它将不得不更新索引的相同部分。在这样做的过程中，它会遇到共享锁，它将被迫等到锁被释放。

这种方法能够有效防止幻读和写入偏差。索引范围锁并不像谓词锁那样精确，但是由于它们的开销较低，所以是一个很好的折衷。

如果没有可以挂载范围锁的索引，数据库可以退化到使用整个表上的共享锁。这对性能不利，因为它会阻止所有其他事务写入表格。

#### 可串行化快照隔离

串行化的隔离级别和高性能是从根本上相互矛盾的吗？

也许不是：一个称为 **可串行化快照隔离（SSI, serializable snapshot isolation）** 的算法是非常有前途的。它提供了完整的可串行化隔离级别，但与快照隔离相比只有很小的性能损失。

**悲观与乐观的并发控制**

两阶段锁是一种所谓的 **悲观并发控制机制（pessimistic）** ：它是基于这样的原则：如果有事情可能出错（如另一个事务所持有的锁所表示的），最好等到情况安全后再做任何事情。

相比之下，**串行化快照隔离** 是一种 **乐观（optimistic）** 的并发控制技术。当一个事务想要提交时，数据库检查是否有什么不好的事情发生（即隔离是否被违反）；如果是的话，事务将被中止，并且必须重试。只有可串行化的事务才被允许提交。

乐观并发控制是一个古老的想法，其优点和缺点已经争论了很长时间。如果存在很多 **争用**（contention，即很多事务试图访问相同的对象），则表现不佳，因为这会导致很大一部分事务需要中止。如果系统已经接近最大吞吐量，来自重试事务的额外负载可能会使性能变差。

但是，如果有足够的空闲容量，并且事务之间的争用不是太高，乐观的并发控制技术往往比悲观的性能要好。

顾名思义，SSI 基于快照隔离 —— 也就是说，事务中的所有读取都是来自数据库的一致性快照。与早期的乐观并发控制技术相比这是主要的区别。在快照隔离的基础上，SSI 添加了一种算法来检测写入之间的串行化冲突，并确定要中止哪些事务。

**基于过时前提的决策**

在快照隔离的情况下，原始查询的结果在事务提交时可能不再是最新的，因为数据可能在同一时间被修改。

换句话说，事务基于一个 **前提（premise）** 采取行动。之后当事务要提交时，原始数据可能已经改变 —— 前提可能不再成立。

当应用程序进行查询时，数据库不知道应用逻辑如何使用该查询结果。在这种情况下为了安全，数据库需要假设任何对该结果集的变更都可能会使该事务中的写入变得无效。 换而言之，事务中的查询与写入可能存在因果依赖。为了提供可串行化的隔离级别，如果事务在过时的前提下执行操作，数据库必须能检测到这种情况，并中止事务。

数据库如何知道查询结果是否可能已经改变？有两种情况需要考虑：

* 检测对旧 MVCC 对象版本的读取（读之前存在未提交的写入）
* 检测影响先前读取的写入（读之后发生写入）

**检测旧 MVCC 读取**

数据库需要跟踪一个事务由于 MVCC 可见性规则而忽略另一个事务的写入。当事务想要提交时，数据库检查是否有任何被忽略的写入现在已经被提交。如果是这样，事务必须中止。

为什么要等到提交？当检测到陈旧的读取时，为什么不立即中止事务？因为如果是只读事务，则不需要中止，因为没有写入偏差的风险。当事务进行读取时，数据库还不知道事务是否要稍后执行写操作。此外，事务可能在其他事务被提交的时候中止或者可能仍然未被提交，因此读取可能终究不是陈旧的。通过避免不必要的中止，SSI 保留了快照隔离从一致快照中长时间读取的能力。

**检测影响之前读取的写入**

第二种情况要考虑的是另一个事务在读取数据之后修改数据。

事务 42 和 43 都在班次 1234 查找值班医生。如果在 `shift_id` 上有索引，则数据库可以使用索引项 1234 来记录事务 42 和 43 读取这个数据的事实。（如果没有索引，这个信息可以在表级别进行跟踪）。这个信息只需要保留一段时间：在一个事务完成（提交或中止），并且所有的并发事务完成之后，数据库就可以忘记它读取的数据了。

当事务写入数据库时，它必须在索引中查找最近曾读取受影响数据的其他事务。这个过程类似于在受影响的键范围上获取写锁，但锁并不会阻塞事务直到其他读事务完成，而是像警戒线一样只是简单通知其他事务：你们读过的数据可能不是最新的啦。

事务 43 通知事务 42 其先前读已过时，反之亦然。事务 42 首先提交并成功，尽管事务 43 的写影响了 42 ，但因为事务 43 尚未提交，所以写入尚未生效。然而当事务 43 想要提交时，来自事务 42 的冲突写入已经被提交，所以事务 43 必须中止。

**可串行化快照隔离的性能**

与两阶段锁定相比，可串行化快照隔离的最大优点是一个事务不需要阻塞等待另一个事务所持有的锁。就像在快照隔离下一样，写不会阻塞读，反之亦然。这种设计原则使得查询延迟更可预测，波动更少。特别是，只读查询可以运行在一致快照上，而不需要任何锁定，这对于读取繁重的工作负载非常有吸引力。

中止率显著影响 SSI 的整体表现。例如，长时间读取和写入数据的事务很可能会发生冲突并中止，因此 SSI 要求同时读写的事务尽量短（只读的长事务可能没问题）。对于慢事务，SSI 可能比两阶段锁定或串行执行更不敏感。

## 第八章：分布式系统的麻烦

使用分布式系统与在一台计算机上编写软件有着根本的区别，主要的区别在于，有许多新颖和刺激的方法可以使事情出错。

### 故障与部分失效

在分布式系统中，尽管系统的其他部分工作正常，但系统的某些部分可能会以某种不可预知的方式被破坏。这被称为 **部分失效（partial failure）**。难点在于部分失效是 **不确定性的（nondeterministic）**：如果你试图做任何涉及多个节点和网络的事情，它有时可能会工作，有时会出现不可预知的失败。正如我们将要看到的，你甚至不知道是否成功了，因为消息通过网络传播的时间也是不确定的！

这种不确定性和部分失效的可能性，使得分布式系统难以工作。

#### 云计算与超级计算机

在超级计算机中，如果一个节点出现故障，通常的解决方案是简单地停止整个集群的工作负载。故障节点修复后，计算从上一个检查点重新开始。因此，超级计算机更像是一个单节点计算机而不是分布式系统。

如果要使分布式系统工作，就必须接受部分故障的可能性，并在软件中建立容错机制。换句话说，我们需要从不可靠的组件构建一个可靠的系统。

### 不可靠的网络

我们在本书中关注的分布式系统是无共享的系统，即通过网络连接的一堆机器。网络是这些机器可以通信的唯一途径。

**无共享** 并不是构建系统的唯一方式，但它已经成为构建互联网服务的主要方式，因为它不需要特殊的硬件，可以利用商品化的云计算服务，通过跨多个地理分布的数据中心进行冗余可以实现高可靠性。

互联网和数据中心（通常是以太网）中的大多数内部网络都是 **异步分组网络（asynchronous packet networks）**。在这种网络中，网络不能保证包什么时候到达，或者是否到达。如果你发送请求并期待响应，则很多事情可能会出错。

发送者甚至不能分辨数据包是否被发送：唯一的选择是让接收者发送响应消息，这可能会丢失或延迟。

处理这个问题的通常方法是 **超时（Timeout）**：在一段时间之后放弃等待，并且认为响应不会到达。但是，当发生超时时，你仍然不知道远程节点是否收到了请求（如果请求仍然在某个地方排队，那么即使发送者已经放弃了该请求，仍然可能会将其发送给接收者）。

#### 真实世界的网络故障

有人可能希望现在我们已经找出了使网络变得可靠的方法。但是现在似乎还没有成功。

如果网络故障的错误处理没有定义与测试，武断地讲，各种错误可能都会发生：例如，即使网络恢复，集群可能会发生 **死锁**，永久无法为请求提供服务，甚至可能会删除所有的数据。如果软件被置于意料之外的情况下，它可能会做出出乎意料的事情。

处理网络故障并不意味着容忍它们：如果你的网络通常是相当可靠的，一个有效的方法可能是当你的网络遇到问题时，简单地向用户显示一条错误信息。但是，你确实需要知道你的软件如何应对网络问题，并确保系统能够从中恢复。

#### 检测故障

许多系统需要自动检测故障节点。例如：

* 负载平衡器需要停止向已死亡的节点转发请求（从轮询列表移出，即 out of rotation）。
* 在单主复制功能的分布式数据库中，如果主库失效，则需要将从库之一升级为新主库。

不幸的是，网络的不确定性使得很难判断一个节点是否工作。在某些特定的情况下，你可能会收到一些反馈信息，明确告诉你某些事情没有成功：

* 如果你可以连接到运行节点的机器，但没有进程正在侦听目标端口（例如，因为进程崩溃），操作系统将通过发送 FIN 或 RST 来关闭并重用 TCP 连接。但是，如果节点在处理请求时发生崩溃，则无法知道远程节点实际处理了多少数据。
* 如果节点进程崩溃，但节点的操作系统仍在运行，则脚本可以通知其他节点有关该崩溃的信息，以便另一个节点可以快速接管，而无需等待超时到期。
* 如果路由器确认你尝试连接的 IP 地址不可用，则可能会使用 ICMP 目标不可达数据包回复你。

关于远程节点关闭的快速反馈很有用，但是你不能指望它。即使 TCP 确认已经传送了一个数据包，应用程序在处理之前可能已经崩溃。

相反，如果出了什么问题，你可能会在堆栈的某个层次上得到一个错误响应，但总的来说，你必须假设你可能根本就得不到任何回应。

#### 超时与无穷的延迟

如果超时是检测故障的唯一可靠方法，那么超时应该等待多久？不幸的是没有简单的答案。长时间的超时意味着长时间等待，直到一个节点被宣告死亡。短的超时可以更快地检测到故障，但有更高地风险误将一个节点宣布为失效，而该节点实际上只是暂时地变慢了。

过早地声明一个节点已经死了是有问题的：如果这个节点实际上是活着的，并且正在执行一些动作（例如，发送一封电子邮件），而另一个节点接管，那么这个动作可能会最终执行两次。

当一个节点被宣告死亡时，它的职责需要转移到其他节点，这会给其他节点和网络带来额外的负担。如果节点实际上没有死亡，只是由于过载导致其响应缓慢；这时将其负载转移到其他节点可能会导致 **级联失效**（即 cascading failure，表示在极端情况下，所有节点都宣告对方死亡，所有节点都将停止工作）。

设想一个虚构的系统，其网络可以保证数据包的最大延迟 —— 每个数据包要么在一段时间内传送，要么丢失，如果你在此时间内没有收到响应，则知道网络或远程节点不工作。

不幸的是，我们所使用的大多数系统都没有这些保证：异步网络具有无限的延迟（即尽可能快地传送数据包，但数据包到达可能需要的时间没有上限），并且大多数服务器实现并不能保证它们可以在一定的最大时间内处理请求。

**网络拥塞和排队**

* 如果多个不同的节点同时尝试将数据包发送到同一目的地，则网络交换机必须将它们排队并将它们逐个送入目标网络链路。如果传入的数据太多，队列填满，数据包将被丢弃，因此需要重新发送数据包 —— 即使网络运行良好。
* 当数据包到达目标机器时，如果所有 CPU 内核当前都处于繁忙状态，则来自网络的传入请求将被操作系统排队，直到应用程序准备好处理它为止。
* 在虚拟化环境中，正在运行的操作系统经常暂停几十毫秒，因为另一个虚拟机正在使用 CPU 内核。
* TCP 执行 **流量控制**（flow control，也称为 **拥塞避免**）。这意味着甚至在数据进入网络之前，在发送者处就需要进行额外的排队。

在公共云和多租户数据中心中，**资源被许多客户共享**，在这种环境下，你只能通过实验方式选择超时：在一段较长的时期内、在多台机器上测量网络往返时间的分布，以确定延迟的预期变化。然后，考虑到应用程序的特性，可以确定 **故障检测延迟** 与 **过早超时风险** 之间的适当折衷。

更好的一种做法是，系统不是使用配置的常量超时时间，而是连续测量响应时间及其变化（抖动），并根据观察到的响应时间分布自动调整超时时间。TCP 的超时重传机制也是以类似的方式工作。

#### 同步网络与异步网络

当你通过电话网络拨打电话时，它会建立一个电路：在两个呼叫者之间的整个路线上为呼叫分配一个固定的，有保证的带宽量。这种网络是同步的：即使数据经过多个路由器，也不会受到排队的影响，因为呼叫的 16 位空间已经在网络的下一跳中保留了下来。

电话网络中的电路与 TCP 连接有很大不同：电路是固定数量的预留带宽，在电路建立时没有其他人可以使用，而 TCP 连接的数据包 **机会性地** 使用任何可用的网络带宽。你可以给 TCP 一个可变大小的数据块。

如果数据中心网络和互联网是电路交换网络，那么在建立电路时就可以建立一个受保证的最大往返时间。但是，它们并不能这样：以太网和 IP 是 **分组交换协议**，不得不忍受排队的折磨和因此导致的网络无限延迟。

为什么数据中心网络和互联网使用分组交换？答案是，它们针对 **突发流量（bursty traffic）** 进行了优化。一个电路适用于音频或视频通话，在通话期间需要每秒传送相当数量的比特。另一方面，请求网页，发送电子邮件或传输文件没有任何特定的带宽要求 —— 我们只是希望它尽快完成。

如果想通过电路传输文件，你得预测一个带宽分配。如果你猜的太低，传输速度会不必要的太慢，导致网络容量闲置。如果你猜的太高，电路就无法建立（因为如果无法保证其带宽分配，网络不能建立电路）。因此，将电路用于突发数据传输会浪费网络容量，并且使传输不必要地缓慢。相比之下，TCP 动态调整数据传输速率以适应可用的网络容量。

### 不可靠的时钟

在分布式系统中，时间是一件棘手的事情，因为通信不是即时的：消息通过网络从一台机器传送到另一台机器需要时间。收到消息的时间总是晚于发送的时间，但是由于网络中的可变延迟，我们不知道晚了多少时间。

而且，网络上的每台机器都有自己的时钟，这是一个实际的硬件设备：通常是石英晶体振荡器。这些设备不是完全准确的，所以每台机器都有自己的时间概念，可能比其他机器稍快或更慢。可以在一定程度上同步时钟：最常用的机制是 **网络时间协议（NTP）**，它允许根据一组服务器报告的时间来调整计算机时钟。服务器则从更精确的时间源（如 GPS 接收机）获取时间。

#### 单调钟与日历时钟

**日历时钟**

日历时钟是你直观地了解时钟的依据：它根据某个日历（也称为 **挂钟时间**，即 wall-clock time）返回当前日期和时间。例如，Linux 上的 `clock_gettime(CLOCK_REALTIME)` 和 Java 中的 `System.currentTimeMillis()` 返回自 epoch（UTC 时间 1970 年 1 月 1 日午夜）以来的秒数（或毫秒），根据公历（Gregorian）日历，不包括闰秒。

**单调钟**

单调钟适用于测量持续时间（时间间隔），例如超时或服务的响应时间：Linux 上的 `clock_gettime(CLOCK_MONOTONIC)`，和 Java 中的 `System.nanoTime()` 都是单调时钟。这个名字来源于他们保证总是往前走的事实（而日历时钟可以往回跳）。

你可以在某个时间点检查单调钟的值，做一些事情，且稍后再次检查它。这两个值之间的差异告诉你两次检查之间经过了多长时间。但单调钟的绝对值是毫无意义的。

#### 时钟同步与准确性

单调钟不需要同步，但是日历时钟需要根据 NTP 服务器或其他外部时间源来设置才能有用。不幸的是，我们获取时钟的方法并不像你所希望的那样可靠或准确 —— 硬件时钟和 NTP 可能会变幻莫测。

* 计算机中的石英钟不够精确：它会 **漂移**（drifts，即运行速度快于或慢于预期）。时钟漂移取决于机器的温度。
* 如果计算机的时钟与 NTP 服务器的时钟差别太大，可能会拒绝同步，或者本地时钟将被强制重置。
* 一些 NTP 服务器是错误的或者配置错误的，报告的时间可能相差几个小时。

如果你足够在乎这件事并投入大量资源，就可以达到非常好的时钟精度。例如，针对金融机构的欧洲法规草案 MiFID II 要求所有高频率交易基金在 UTC 时间 100 微秒内同步时钟，以便调试 “闪崩” 等市场异常现象，并帮助检测市场操纵。

#### 依赖同步时钟

时钟的问题在于，虽然它们看起来简单易用，但却具有令人惊讶的缺陷：一天可能不会有精确的 86,400 秒，**日历时钟** 可能会前后跳跃，而一个节点上的时间可能与另一个节点上的时间完全不同。

**有序事件的时间戳**

![](https://raw.githubusercontent.com/L2ncE/images/main/PicGo/Images20240612210256.png)

当一个写入被复制到其他节点时，它会根据发生写入的节点上的日历时钟标记一个时间戳。在这个例子中，时钟同步是非常好的：节点 1 和节点 3 之间的偏差小于 3ms，这可能比你在实践中能预期的更好。

当节点 2 接收到这两个事件时，会错误地推断出 `x = 1` 是最近的值，而丢弃写入 `x = 2`。效果上表现为，客户端 B 的增量操作会丢失。

这种冲突解决策略被称为 **最后写入胜利（LWW）**，它在多主复制和无主数据库（如 Cassandra 和 Riak）中被广泛使用。

因此，尽管通过保留 “最近” 的值并放弃其他值来解决冲突是很诱惑人的，但是要注意，“最近” 的定义取决于本地的 **日历时钟**，这很可能是不正确的。即使用严格同步的 NTP 时钟，一个数据包也可能在时间戳 100 毫秒（根据发送者的时钟）时发送，并在时间戳 99 毫秒（根据接收者的时钟）处到达 —— 看起来好像数据包在发送之前已经到达，这是不可能的。

所谓的 **逻辑时钟（logic clock）** 是基于递增计数器而不是振荡石英晶体，对于排序事件来说是更安全的选择。逻辑时钟不测量一天中的时间或经过的秒数，而仅测量事件的相对顺序（无论一个事件发生在另一个事件之前还是之后）。相反，用来测量实际经过时间的 **日历时钟** 和 **单调钟** 也被称为 **物理时钟（physical clock）**。

**时钟读数存在置信区间**

你可能能够以微秒或甚至纳秒的精度读取机器的时钟。但即使可以得到如此细致的测量结果，这并不意味着这个值对于这样的精度实际上是准确的。实际上，大概率是不准确的。使用公共互联网上的 NTP 服务器，最好的准确度可能达到几十毫秒，而且当网络拥塞时，误差可能会超过 100 毫秒。

因此，将时钟读数视为一个时间点是没有意义的 —— 它更像是一段时间范围：例如，一个系统可能以 95% 的置信度认为当前时间处于本分钟内的第 10.3 秒和 10.5 秒之间，它可能没法比这更精确了。

**全局快照的同步时钟**

我们可以使用同步时钟的时间戳作为事务 ID 吗？如果我们能够获得足够好的同步性，那么这种方法将具有很合适的属性：更晚的事务会有更大的时间戳。当然，问题在于时钟精度的不确定性。

Spanner 以这种方式实现跨数据中心的快照隔离。它使用 TrueTime API 报告的时钟置信区间，并基于以下观察结果：如果你有两个置信区间，每个置信区间包含最早和最晚可能的时间戳，这两个区间不重叠，那么 B 肯定发生在 A 之后 —— 这是毫无疑问的。只有当区间重叠时，我们才不确定 A 和 B 发生的顺序。

为了确保事务时间戳反映因果关系，Spanner 在提交读写事务时，会故意等待置信区间长度的时间。通过这样，它可以确保任何可能读取数据的事务处于足够晚的时间，因此它们的置信区间不会重叠。为了保持尽可能短的等待时间，Spanner 需要保持尽可能小的时钟不确定性，为此，Google 在每个数据中心都部署了一个 GPS 接收器或原子钟，这允许时钟同步到大约 7 毫秒以内。

#### 进程暂停

一个节点如何知道它仍然是领导者（它并没有被别人宣告为死亡），并且它可以安全地接受写入？

一种选择是领导者从其他节点获得一个 **租约（lease）**，类似一个带超时的锁。为了保持领导地位，节点必须周期性地在租约过期前续期。如果节点发生故障，就会停止续期，所以当租约过期时，另一个节点可以接管。

可以想象，请求处理循环看起来像这样：

```java
while (true) {
  request = getIncomingRequest();
  // 确保租约还剩下至少 10 秒
  if (lease.expiryTimeMillis - System.currentTimeMillis() < 10000){
    lease = lease.renew();
  }

  if (lease.isValid()) {
    process(request);
  }
}
```

这个代码有什么问题？首先，它依赖于同步时钟：租约到期时间由另一台机器设置（例如，当前时间加上 30 秒，计算到期时间），并将其与本地系统时钟进行比较。

其次，即使我们将协议更改为仅使用本地单调时钟，也存在另一个问题：代码假定在执行剩余时间检查 `System.currentTimeMillis()` 和实际执行请求 `process(request)` 中间的时间间隔非常短。通常情况下，这段代码运行得非常快，所以 10 秒的缓冲区已经足够确保 **租约** 在请求处理到一半时不会过期。

但是，如果程序执行中出现了意外的停顿呢？例如，想象一下，线程在 `lease.isValid()` 行周围停止 15 秒，然后才继续。在这种情况下，在请求被处理的时候，租约可能已经过期，而另一个节点已经接管了领导。

* 许多编程语言运行时（如 Java 虚拟机）都有一个垃圾收集器（GC），偶尔需要停止所有正在运行的线程。这些 “**停止所有处理（stop-the-world）**”GC 暂停有时会持续几分钟！甚至像 HotSpot JVM 的 CMS 这样的所谓的 “并行” 垃圾收集器也不能完全与应用程序代码并行运行，它需要不时地停止所有处理。
* 在虚拟化环境中，可以 **挂起（suspend）** 虚拟机（暂停执行所有进程并将内存内容保存到磁盘）并恢复（恢复内存内容并继续执行）。这个暂停可以在进程执行的任何时候发生，并且可以持续任意长的时间。
* 在最终用户的设备（如笔记本电脑）上，执行也可能被暂停并随意恢复，例如当用户关闭笔记本电脑的盖子时。
* 当操作系统上下文切换到另一个线程时，或者当管理程序切换到另一个虚拟机时（在虚拟机中运行时），当前正在运行的线程可能在代码中的任意点处暂停。在虚拟机的情况下，在其他虚拟机中花费的 CPU 时间被称为 **窃取时间（steal time）**。
* 如果应用程序执行同步磁盘访问，则线程可能暂停，等待缓慢的磁盘 I/O 操作完成。在许多语言中，即使代码没有包含文件访问，磁盘访问也可能出乎意料地发生 —— 例如，Java 类加载器在第一次使用时惰性加载类文件，这可能在程序执行过程中随时发生。I/O 暂停和 GC 暂停甚至可能合谋组合它们的延迟。
* 如果操作系统配置为允许交换到磁盘（页面交换），则简单的内存访问可能导致 **页面错误（page fault）**，要求将磁盘中的页面装入内存。当这个缓慢的 I/O 操作发生时，线程暂停。如果内存压力很高，则可能需要将另一个页面换出到磁盘。
* 可以通过发送 SIGSTOP 信号来暂停 Unix 进程，例如通过在 shell 中按下 Ctrl-Z。这个信号立即阻止进程继续执行更多的 CPU 周期，直到 SIGCONT 恢复为止，此时它将继续运行。即使你的环境通常不使用 SIGSTOP，也可能由运维工程师意外发送。

所有这些事件都可以随时 **抢占（preempt）** 正在运行的线程，并在稍后的时间恢复运行，而线程甚至不会注意到这一点。

当在一台机器上编写多线程代码时，我们有相当好的工具来实现线程安全：互斥量、信号量、原子计数器、无锁数据结构、阻塞队列等等。不幸的是，这些工具并不能直接转化为分布式系统操作，因为分布式系统没有共享内存，只有通过不可靠网络发送的消息。

分布式系统中的节点，必须假定其执行可能在任意时刻暂停相当长的时间，即使是在一个函数的中间。在暂停期间，世界的其它部分在继续运转，甚至可能因为该节点没有响应，而宣告暂停节点的死亡。最终暂停的节点可能会继续运行，在再次检查自己的时钟之前，甚至可能不会意识到自己进入了睡眠。

**响应时间保证**

某些软件的运行环境要求很高，不能在特定时间内响应可能会导致严重的损失，在这些系统中，软件必须有一个特定的 **截止时间（deadline）**，如果截止时间不满足，可能会导致整个系统的故障。这就是所谓的 **硬实时（hard real-time）** 系统。

例如，如果车载传感器检测到当前正在经历碰撞，你肯定不希望安全气囊释放系统因为 GC 暂停而延迟弹出。

在系统中提供 **实时保证** 需要各级软件栈的支持：一个实时操作系统（RTOS），允许在指定的时间间隔内保证 CPU 时间的分配。库函数必须申明最坏情况下的执行时间；动态内存分配可能受到限制或完全不允许（实时垃圾收集器存在，但是应用程序仍然必须确保它不会给 GC 太多的负担）；必须进行大量的测试和测量，以确保达到保证。

所有这些都需要大量额外的工作，严重限制了可以使用的编程语言、库和工具的范围。由于这些原因，开发实时系统非常昂贵，并且它们通常用于安全关键的嵌入式设备。而且，“**实时**” 与 “**高性能**” 不一样 —— 事实上，实时系统可能具有较低的吞吐量，因为他们必须让及时响应的优先级高于一切。

对于大多数服务器端数据处理系统来说，实时保证是不经济或不合适的。因此，这些系统必须承受在非实时环境中运行的暂停和时钟不稳定性。

**限制垃圾收集的影响**

一个新兴的想法是将 GC 暂停视为一个节点的短暂计划中断，并在这个节点收集其垃圾的同时，让其他节点处理来自客户端的请求。如果运行时可以警告应用程序一个节点很快需要 GC 暂停，那么应用程序可以停止向该节点发送新的请求，等待它完成处理未完成的请求，然后在没有请求正在进行时执行 GC。这样向客户端隐藏了 GC 暂停，并降低了响应时间的高百分比。一些对延迟敏感的金融交易系统使用这种方法。

这个想法的一个变种是只用垃圾收集器来处理短命对象（这些对象可以快速收集），并定期在积累大量长寿对象（因此需要完整 GC）之前重新启动进程。一次可以重新启动一个节点，在计划重新启动之前，流量可以从该节点移开。

### 知识、真相与谎言

我们已经探索了分布式系统与运行在单台计算机上的程序的不同之处：没有共享内存，只有通过可变延迟的不可靠网络传递的消息，系统可能遭受部分失效，不可靠的时钟和处理暂停。

在分布式系统中，我们可以陈述关于行为（系统模型）的假设，并以满足这些假设的方式设计实际系统。算法可以被证明在某个系统模型中正确运行。这意味着即使底层系统模型提供了很少的保证，也可以实现可靠的行为。

但是，尽管可以使软件在不可靠的系统模型中表现良好，但这并不是可以直截了当实现的。

#### 真相由多数所定义

设想一个具有不对称故障的网络：一个节点能够接收发送给它的所有消息，但是来自该节点的任何传出消息被丢弃或延迟。即使该节点运行良好，并且正在接收来自其他节点的请求，其他节点也无法听到其响应。经过一段时间后，其他节点宣布它已经死亡，因为他们没有听到节点的消息。

在一个稍微不那么梦魇的场景中，半断开的节点可能会注意到它发送的消息没有被其他节点确认，因此意识到网络中必定存在故障。尽管如此，节点被其他节点错误地宣告为死亡，而半连接的节点对此无能为力。

第三种情况，想象一个正在经历长时间 GC STW 的节点，节点的所有线程被 GC 抢占并暂停一分钟。其他节点等待，重试，不耐烦，并最终宣布节点死亡。最后，GC 完成，节点的线程继续，好像什么也没有发生。其他节点感到惊讶，因为所谓的死亡节点突然从棺材中抬起头来，身体健康，开始和旁观者高兴地聊天。GC 后的节点最初甚至没有意识到已经经过了整整一分钟，而且自己已被宣告死亡。从它自己的角度来看，从最后一次与其他节点交谈以来，几乎没有经过任何时间。

这些故事的寓意是，节点不一定能相信自己对于情况的判断。分布式系统不能完全依赖单个节点。相反，许多分布式算法都依赖于法定人数，即在节点之间进行投票：决策需要来自多个节点的最小投票数，以减少对于某个特定节点的依赖。

最常见的法定人数是超过一半的绝对多数。多数法定人数允许系统继续工作，如果单个节点发生故障（三个节点可以容忍单节点故障；五个节点可以容忍双节点故障），系统仍然是安全的。

#### 领导者和锁

通常情况下，一些东西在一个系统中只能有一个。例如：

* 数据库分区的领导者只能有一个节点，以避免 **脑裂**。
* 特定资源的锁或对象只允许一个事务 / 客户端持有，以防同时写入和损坏。
* 一个特定的用户名只能被一个用户所注册，因为用户名必须唯一标识一个用户。

在分布式系统中实现这一点需要注意：即使一个节点认为它是 “**the choosen one**”（分区的负责人，锁的持有者，成功获取用户名的用户的请求处理程序），但这并不一定意味着有法定人数的节点同意！一个节点可能以前是领导者，但是如果其他节点在此期间宣布它死亡（例如，由于网络中断或 GC 暂停），则它可能已被降级，且另一个领导者可能已经当选。

如果一个节点继续表现为 **天选者**，即使大多数节点已经声明它已经死了，则在考虑不周的系统中可能会导致问题。这样的节点能以自己赋予的权能向其他节点发送消息，如果其他节点相信，整个系统可能会做一些不正确的事情。

如果持有租约的客户端暂停太久，它的租约将到期。另一个客户端可以获得同一文件的租约，并开始写入文件。当暂停的客户端回来时，它认为（不正确）它仍然有一个有效的租约，并继续写入文件。结果，客户的写入将产生冲突并损坏文件。

**防护令牌**

![](https://raw.githubusercontent.com/L2ncE/images/main/PicGo/Images20240620200612.png)

我们假设每次锁定服务器授予锁或租约时，它还会返回一个 **防护令牌（fencing token）**，这个数字在每次授予锁定时都会增加（例如，由锁定服务增加）。然后，我们可以要求客户端每次向存储服务发送写入请求时，都必须包含当前的防护令牌。

在图中，客户端 1 以 33 的令牌获得租约，但随后进入一个长时间的停顿并且租约到期。客户端 2 以 34 的令牌（该数字总是增加）获取租约，然后将其写入请求发送到存储服务，包括 34 的令牌。稍后，客户端 1 恢复生机并将其写入存储服务，包括其令牌值 33。但是，存储服务器会记住它已经处理了一个具有更高令牌编号（34）的写入，因此它会拒绝带有令牌 33 的请求。

#### 拜占庭故障

防护令牌可以检测和阻止无意中发生错误的节点（例如，因为它尚未发现其租约已过期）。但是，如果节点有意破坏系统的保证，则可以通过使用假防护令牌发送消息来轻松完成此操作。

我们假设节点是不可靠但诚实的：它们可能很慢或者从不响应（由于故障），并且它们的状态可能已经过时（由于 GC 暂停或网络延迟），但是我们假设如果节点它做出了回应，它正在说出 “真相”：尽其所知，它正在按照协议的规则扮演其角色。

如果存在节点可能 “撒谎”（发送任意错误或损坏的响应）的风险，则分布式系统的问题变得更困难了 —— 例如，如果节点可能声称其实际上没有收到特定的消息。这种行为被称为 **拜占庭故障（Byzantine fault）**，**在不信任的环境中达成共识的问题被称为拜占庭将军问题**。

> 拜占庭将军问题是对所谓 “两将军问题” 的泛化，它想象两个将军需要就战斗计划达成一致的情况。由于他们在两个不同的地点建立了营地，他们只能通过信使进行沟通，信使有时会被延迟或丢失（就像网络中的信息包一样）。
>
> 在这个问题的拜占庭版本里，有 n 位将军需要同意，他们的努力因为有一些叛徒在他们中间而受到阻碍。大多数的将军都是忠诚的，因而发出了真实的信息，但是叛徒可能会试图通过发送虚假或不真实的信息来欺骗和混淆他人（在试图保持未被发现的同时）。事先并不知道叛徒是谁。

当一个系统在部分节点发生故障、不遵守协议、甚至恶意攻击、扰乱网络时仍然能继续正确工作，称之为 **拜占庭容错（Byzantine fault-tolerant）**：

* 在航空航天环境中，计算机内存或 CPU 寄存器中的数据可能被辐射破坏，导致其以任意不可预知的方式响应其他节点。由于系统故障非常昂贵（例如，飞机撞毁和炸死船上所有人员，或火箭与国际空间站相撞），飞行控制系统必须容忍拜占庭故障。
* 在多个参与组织的系统中，一些参与者可能会试图欺骗或诈骗他人。在这种情况下，节点仅仅信任另一个节点的消息是不安全的，因为它们可能是出于恶意的目的而被发送的。例如，像比特币和其他区块链一样的对等网络可以被认为是让互不信任的各方同意交易是否发生的一种方式，而不依赖于中心机构（central authority）。

然而，之前讨论的那些系统中，我们通常可以安全地假设没有拜占庭式的错误。在你的数据中心里，所有的节点都是由你的组织控制的，辐射水平足够低，内存损坏不是一个大问题。制作拜占庭容错系统的协议相当复杂，而容错嵌入式系统依赖于硬件层面的支持。在大多数服务器端数据系统中，部署拜占庭容错解决方案的成本使其变得不切实际。

Web 应用程序需要预防终端用户控制的客户端（如 Web 浏览器）的恶意行为。这就是为什么输入验证，数据清洗和输出转义如此重要：例如，防止 SQL 注入和跨站点脚本。然而，我们通常不在这里使用拜占庭容错协议，而只是让服务器有权决定是否允许客户端行为。

软件中的一个 bug 可能被认为是拜占庭式的错误，但是如果你将相同的软件部署到所有节点上，那么拜占庭式的容错算法帮不到你。大多数拜占庭式容错算法要求超过三分之二的节点能够正常工作（即，如果有四个节点，最多只能有一个故障）。要使用这种方法对付 bug，你必须有四个独立的相同软件的实现，并希望一个 bug 只出现在四个实现之一中。

同样，如果一个协议可以保护我们免受漏洞，安全渗透和恶意攻击，那么这将是有吸引力的。但这也是不现实的：在大多数系统中，如果攻击者可以渗透一个节点，那他们可能会渗透所有这些节点，因为它们可能都运行着相同的软件。因此，传统机制（认证，访问控制，加密，防火墙等）仍然是抵御攻击者的主要保护措施。

**弱谎言形式**

尽管我们假设节点通常是诚实的，但值得向软件中添加防止 “撒谎” 弱形式的机制 —— 例如，由硬件问题导致的无效消息，软件错误和错误配置。这种保护机制并不是完全的拜占庭容错，因为它们不能抵挡决心坚定的对手，但它们仍然是简单而实用的步骤，以提高可靠性。例如：

* 由于硬件问题或操作系统、驱动程序、路由器等中的错误，网络数据包有时会受到损坏。通常，损坏的数据包会被内建于 TCP 和 UDP 中的校验和所俘获，但有时它们也会逃脱检测。要对付这种破坏通常使用简单的方法就可以做到，例如应用程序级协议中的校验和。
* 可公开访问的应用程序必须仔细清理来自用户的任何输入，例如检查值是否在合理的范围内，并限制字符串的大小以防止通过大内存分配的拒绝服务。
* NTP 客户端可以配置多个服务器地址。同步时，客户端联系所有的服务器，估计它们的误差，并检查大多数服务器是否对某个时间范围达成一致。只要大多数的服务器没问题，一个配置错误的 NTP 服务器报告的时间会被当成特异值从同步中排除。使用多个服务器使 NTP 更健壮（比起只用单个服务器来）。

#### 系统模型与现实

算法的编写方式不应该过分依赖于运行的硬件和软件配置的细节。这就要求我们以某种方式将我们期望在系统中发生的错误形式化。我们通过定义一个系统模型来做到这一点，这个模型是一个抽象，描述一个算法可以假设的事情。

关于时序假设，三种系统模型是常用的：

* 同步模型 **同步模型（synchronous model）** 假设网络延迟、进程暂停和和时钟误差都是受限的。这并不意味着完全同步的时钟或零网络延迟；这只意味着你知道网络延迟、暂停和时钟漂移将永远不会超过某个固定的上限。同步模型并不是大多数实际系统的现实模型，因为无限延迟和暂停确实会发生。
* 部分同步模型 **部分同步（partial synchronous）** 意味着一个系统在大多数情况下像一个同步系统一样运行，但有时候会超出网络延迟，进程暂停和时钟漂移的界限。这是很多系统的现实模型：大多数情况下，网络和进程表现良好，否则我们永远无法完成任何事情，但是我们必须承认，在任何时刻都存在时序假设偶然被破坏的事实。发生这种情况时，网络延迟、暂停和时钟错误可能会变得相当大。
* 异步模型 在这个模型中，一个算法不允许对时序做任何假设 —— 事实上它甚至没有时钟（所以它不能使用超时）。一些算法被设计为可用于异步模型，但非常受限。

进一步来说，除了时序问题，我们还要考虑 **节点失效**。三种最常见的节点系统模型是：

* 崩溃 - 停止故障 在 **崩溃停止（crash-stop）** 模型中，算法可能会假设一个节点只能以一种方式失效，即通过崩溃。这意味着节点可能在任意时刻突然停止响应，此后该节点永远消失 —— 它永远不会回来。
* 崩溃 - 恢复故障 我们假设节点可能会在任何时候崩溃，但也许会在未知的时间之后再次开始响应。在 **崩溃 - 恢复（crash-recovery）** 模型中，假设节点具有稳定的存储（即，非易失性磁盘存储）且会在崩溃中保留，而内存中的状态会丢失。
* 拜占庭（任意）故障 节点可以做（绝对意义上的）任何事情，包括试图戏弄和欺骗其他节点，如上一节所述。

对于真实系统的建模，具有 **崩溃 - 恢复故障（crash-recovery）** 的 **部分同步模型（partial synchronous）** 通常是最有用的模型。

**算法的正确性**

为了定义算法是正确的，我们可以描述它的属性。例如，排序算法的输出具有如下特性：对于输出列表中的任何两个不同的元素，左边的元素比右边的元素小。

同样，我们可以写下我们想要的分布式算法的属性来定义它的正确含义。例如，如果我们正在为一个锁生成防护令牌，我们可能要求算法具有以下属性：

* 唯一性（uniqueness） 没有两个防护令牌请求返回相同的值。
* 单调序列（monotonic sequence） 如果请求 x 返回了令牌 tx，并且请求 y 返回了令牌 ty，并且 x 在 y 开始之前已经完成，那么 tx < ty。
* 可用性（availability） 请求防护令牌并且不会崩溃的节点，最终会收到响应。

**安全性和活性**

为了澄清这种情况，有必要区分两种不同的属性：**安全（safety）属性** 和 **活性（liveness）属性**。在刚刚给出的例子中，**唯一性** 和 **单调序列** 是安全属性，而 **可用性** 是活性属性。

这两种性质有什么区别？一个试金石就是，活性属性通常在定义中通常包括 “**最终**” 一词（是的，你猜对了 —— 最终一致性是一个活性属性）。

安全通常被非正式地定义为：**没有坏事发生**，而活性通常就类似：**最终好事发生**。但其实安全和活性的实际定义是精确的和数学的：

* 如果安全属性被违反，我们可以指向一个特定的安全属性被破坏的时间点（例如，如果违反了唯一性属性，我们可以确定重复的防护令牌被返回的特定操作）。违反安全属性后，违规行为不能被撤销 —— 损失已经发生。
* 活性属性反过来：在某个时间点（例如，一个节点可能发送了一个请求，但还没有收到响应），它可能不成立，但总是希望在未来能成立（即通过接受答复）。

区分安全属性和活性属性的一个优点是可以帮助我们处理困难的系统模型。对于分布式算法，在系统模型的所有可能情况下，要求 **始终** 保持安全属性是常见的。也就是说，即使所有节点崩溃，或者整个网络出现故障，算法仍然必须确保它不会返回错误的结果（即保证安全属性得到满足）。

但是，对于活性属性，我们可以提出一些注意事项：例如，只有在大多数节点没有崩溃的情况下，只有当网络最终从中断中恢复时，我们才可以说请求需要接收响应。部分同步模型的定义要求系统最终返回到同步状态 —— 即任何网络中断的时间段只会持续一段有限的时间，然后进行修复。

**将系统模型映射到现实世界**

安全属性和活性属性以及系统模型对于推理分布式算法的正确性非常有用。然而，在实践中实施算法时，很明显系统模型是对现实的简化抽象。

例如，在崩溃 - 恢复（crash-recovery）模型中的算法通常假设稳定存储器中的数据在崩溃后可以幸存。但是，如果磁盘上的数据被破坏，或者由于硬件错误或错误配置导致数据被清除，会发生什么情况？如果服务器存在固件错误并且在重新启动时无法识别其硬盘驱动器，即使驱动器已正确连接到服务器，那又会发生什么情况？

法定人数算法依赖节点来记住它声称存储的数据。如果一个节点可能患有健忘症，忘记了以前存储的数据，这会打破法定条件，从而破坏算法的正确性。也许需要一个新的系统模型，在这个模型中，我们假设稳定的存储大多能在崩溃后幸存，但有时也可能会丢失。但是那个模型就变得更难以推理了。

算法的理论描述可以简单宣称一些事是不会发生的 —— 在非拜占庭式系统中，我们确实需要对可能发生和不可能发生的故障做出假设。然而，真实世界的实现，仍然会包括处理 “假设上不可能” 情况的代码，即使代码可能就是 `exit(666)`，实际上也就是留给运维来擦屁股。

这并不是说理论上抽象的系统模型是毫无价值的，我们可以证明算法是正确的，通过表明它们的属性在某个系统模型中总是成立的。

证明算法正确并不意味着它在真实系统上的实现必然总是正确的。但这迈出了很好的第一步，因为理论分析可以发现算法中的问题，这种问题可能会在现实系统中长期潜伏，直到你的假设（例如，时序）因为不寻常的情况被打破。理论分析与经验测试同样重要。

## 第九章：一致性与共识

分布式系统中的许多事情可能会出错。处理这种故障的最简单方法是简单地让整个服务失效，并向用户显示错误消息。如果无法接受这个解决方案，我们就需要找到容错的方法 —— 即使某些内部组件出现故障，服务也能正常运行。

### 一致性保证

大多数复制的数据库至少提供了 **最终一致性**，这意味着如果你停止向数据库写入数据并等待一段不确定的时间，那么最终所有的读取请求都会返回相同的值。换句话说，不一致性是暂时的。最终一致性的一个更好的名字可能是 **收敛（convergence）**，因为我们预计所有的副本最终会收敛到相同的值。

然而，这是一个非常弱的保证 —— 它并没有说什么时候副本会收敛。在收敛之前，读操作可能会返回任何东西或什么都没有。

具有较强保证的系统可能会比保证较差的系统具有更差的性能或更少的容错性。尽管如此，更强的保证能够吸引人，因为它们更容易用对。只有见过不同的一致性模型后，才能更好地决定哪一个最适合自己的需求。

### 线性一致性

在 **最终一致** 的数据库，如果你在同一时刻问两个不同副本相同的问题，可能会得到两个不同的答案。如果数据库可以提供只有一个副本的假象（即，只有一个数据副本），那么每个客户端都会有相同的数据视图，且不必担心复制滞后了。

这就是 **线性一致性** 背后的想法（也称为 **原子一致性（atomic consistency）**，**强一致性（strong consistency）**，**立即一致性（immediate consistency）** 或 **外部一致性（external consistency ）**）。基本的想法是让一个系统看起来好像只有一个数据副本，而且所有的操作都是原子性的。有了这个保证，即使实际中可能有多个副本，应用也不需要担心它们。

在一个线性一致的系统中，只要一个客户端成功完成写操作，所有客户端从数据库中读取数据必须能够看到刚刚写入的值。要维护数据的单个副本的假象，系统应保障读到的值是最近的、最新的，而不是来自陈旧的缓存或副本。换句话说，线性一致性是一个 **新鲜度保证（recency guarantee）**。

![](https://raw.githubusercontent.com/L2ncE/images/main/PicGo/Images20240624153740.png)

上图展示了一个关于体育网站的非线性一致例子。Alice 和 Bob 正坐在同一个房间里，都盯着各自的手机，关注着世界杯决赛的结果。在最后得分公布后，Alice 刷新页面，看到宣布了获胜者，并兴奋地告诉 Bob。Bob 难以置信地刷新了自己的手机，但他的请求路由到了一个落后的数据库副本上，手机显示比赛仍在进行。

Bob 在听到 Alice 惊呼最后得分 **之后**，点击了刷新按钮（启动了他的查询），因此他希望查询结果至少与爱丽丝一样新鲜。但他的查询返回了陈旧结果，这一事实违背了线性一致性的要求。

#### 什么使得系统线性一致

线性一致性背后的基本思想很简单：使系统看起来好像只有一个数据副本。然而实际上有更多要操心的地方。

![](https://raw.githubusercontent.com/L2ncE/images/main/PicGo/Images20240624154400.png)

每个横柱都是由客户端发出的请求，其中柱头是请求发送的时刻，柱尾是客户端收到响应的时刻。因为网络延迟变化无常，客户端不知道数据库处理其请求的精确时间 —— 只知道它发生在发送请求和接收响应之间的某个时刻。

`x` 的值最初为 `0`，客户端 C 执行写请求将其设置为 `1`。发生这种情况时，客户端 A 和 B 反复轮询数据库以读取最新值。

* 客户端 A 的第一个读操作，完成于写操作开始之前，因此必须返回旧值 `0`。
* 客户端 A 的最后一个读操作，开始于写操作完成之后。如果数据库是线性一致性的，它必然返回新值 `1`：如果在写入结束后开始读取，则读取处理一定发生在写入完成之后，因此它必须看到写入的新值。
* 与写操作在时间上重叠的任何读操作，可能会返回 `0` 或 `1` ，因为我们不知道读取时，写操作是否已经生效。这些操作是 **并发（concurrent）** 的。

这还不足以完全描述线性一致性：如果与写入同时发生的读取可以返回旧值或新值，那么可能会在写入期间看到数值在旧值和新值之间来回翻转。这个系统对 “单一数据副本” 的模拟还不是我们所期望的。

为了使系统线性一致，我们需要添加另一个约束 —— **任何一个读取返回新值后，所有后续读取（在相同或其他客户端上）也必须返回新值。**

![](https://raw.githubusercontent.com/L2ncE/images/main/PicGo/Images20240624154609.png)

在一个线性一致的系统中，我们可以想象，在 `x` 的值从 `0` 自动翻转到 `1` 的时候（在写操作的开始和结束之间）必定有一个时间点。因此，如果一个客户端的读取返回新的值 `1`，即使写操作尚未完成，所有后续读取也必须返回新值。

#### 依赖线性一致性

对于少数领域，线性一致性是系统正确工作的一个重要条件。

**锁定和领导选举**

一个使用单主复制的系统，需要确保领导者真的只有一个，而不是几个（脑裂）。一种选择领导者的方法是使用锁：每个节点在启动时尝试获取锁，成功者成为领导者。不管这个锁是如何实现的，它必须是线性一致的：所有节点必须就哪个节点拥有锁达成一致，否则就没用了。

诸如 Apache ZooKeeper 和 etcd 之类的协调服务通常用于实现分布式锁和领导者选举。它们使用一致性算法，以容错的方式实现线性一致的操作。

**约束和唯一性保证**

唯一性约束在数据库中很常见：例如，用户名或电子邮件地址必须唯一标识一个用户，而在文件存储服务中，不能有两个具有相同路径和文件名的文件。如果两个人试图同时创建一个具有相同名称的用户或文件，其中一个将返回一个错误，该要求需要线性一致性。

这种情况实际上类似于一个锁：当一个用户注册你的服务时，可以认为他们获得了所选用户名的 “锁”。该操作与 CAS 非常相似：将用户名赋予声明它的用户，前提是用户名尚未被使用。

如果想要确保银行账户余额永远不会为负数，或者不会出售比仓库里的库存更多的物品，或者两个人不会都预定了航班或剧院里同一时间的同一个位置。这些约束条件都要求所有节点都同意一个最新的值。

在实际应用中，宽松地处理这些限制有时是可以接受的（例如，如果航班超额预订，你可以将客户转移到不同的航班并为其提供补偿）。

**跨信道的时序依赖**

如果 Alice 没有惊呼得分，Bob 就不会知道他的查询结果是陈旧的。他会在几秒钟之后再次刷新页面，并最终看到最后的分数。由于系统中存在额外的信道（Alice 的声音传到了 Bob 的耳朵中），线性一致性的违背才被注意到。

计算机系统也会出现类似的情况。例如，假设有一个网站，用户可以上传照片，一个后台进程会调整照片大小，降低分辨率以加快下载速度（缩略图）。

![](https://raw.githubusercontent.com/L2ncE/images/main/PicGo/Images20240624160834.png)

如果它不是线性一致的，则存在竞争条件的风险：消息队列可能比存储服务内部的复制更快。在这种情况下，当缩放器读取图像时，可能会看到图像的旧版本，或者什么都没有。如果它处理的是旧版本的图像，则文件存储中的全尺寸图和缩略图就产生了永久性的不一致。

出现这个问题是因为 Web 服务器和缩放器之间存在两个不同的信道：文件存储与消息队列。

线性一致性并不是避免这种竞争条件的唯一方法，但它是最容易理解的。如果可以额外控制信道，不过会有额外的复杂度代价。

#### 实现线性一致的系统

让我们思考一下，如何实现一个提供线性一致语义的系统。

由于线性一致性本质上意味着 “表现得好像只有一个数据副本，而且所有的操作都是原子的”，所以最简单的答案就是，真的只用一个数据副本。但是这种方法无法容错：如果持有该副本的节点失效，数据将会丢失。

使系统容错最常用的方法是使用复制。让我们回顾几个复制方法，并比较它们是否可以满足线性一致性：

* 单主复制（可能线性一致） 在具有单主复制功能的系统中，主库具有用于写入的数据的主副本，而追随者在其他节点上保留数据的备份副本。如果从主库或同步更新的从库读取数据，它们 **可能** 是线性一致性的 。然而实际上并不是每个单主数据库都是线性一致性的，例如使用快照的设计和并发错误。 从主库读取依赖一个假设，你确切地知道领导者是谁。一个节点很可能会认为它是领导者，而事实上并非如此 —— 如果具有错觉的领导者继续为请求提供服务，可能违反线性一致性。使用异步复制，故障切换时甚至可能会丢失已提交的写入，这同时违反了持久性和线性一致性。
* 共识算法（线性一致） 共识算法与单主复制类似。然而，共识协议包含防止脑裂和陈旧副本的措施。正是由于这些细节，共识算法可以安全地实现线性一致性存储。例如，Zookeeper 和 etcd 就是这样工作的。
* 多主复制（非线性一致） 具有多主程序复制的系统通常不是线性一致的，因为它们同时在多个节点上处理写入，并将其异步复制到其他节点。因此，它们可能会产生需要被解决的写入冲突。这种冲突是因为缺少单一数据副本所导致的。
* 无主复制（也许不是线性一致的） 对于无主复制的系统，有时候人们会声称通过要求法定人数读写（w + r > n）可以获得 “强一致性”。这取决于法定人数的具体配置，以及强一致性如何定义（通常不完全正确）。 基于日历时钟的 “最后写入胜利” 冲突解决方法几乎可以确定是非线性一致的，由于时钟偏差，不能保证时钟的时间戳与实际事件顺序一致。宽松的法定人数也破坏了线性一致的可能性。即使使用严格的法定人数，非线性一致的行为也是可能的。

**线性一致性和法定人数**

直觉上严格的法定人数读写应该是线性一致性的。但是当我们有可变的网络延迟时，就可能存在竞争条件。

![](https://raw.githubusercontent.com/L2ncE/images/main/PicGo/Images20240624165903.png)

x 的初始值为 0，写入客户端通过向所有三个副本（n = 3, w = 3）发送写入将 x 更新为 `1`。客户端 A 并发地从两个节点组成的法定人群（r = 2）中读取数据，并在其中一个节点上看到新值 `1` 。客户端 B 也并发地从两个不同的节点组成的法定人数中读取，并从两个节点中取回了旧值 `0` 。

法定人数条件满足（w + r > n），但是这个执行是非线性一致的：B 的请求在 A 的请求完成后开始，但是 B 返回旧值，而 A 返回新值。

通过牺牲性能，可以使 Dynamo 风格的法定人数线性化：读取者必须在将结果返回给应用之前，同步执行读修复，并且写入者必须在发送写入之前，读取法定数量节点的最新状态。

而且，这种方式只能实现线性一致的读写；不能实现线性一致的 CAS 操作，因为它需要一个共识算法。

#### 线性一致性的代价

对多数据中心的复制而言，多主复制通常是理想的选择。我们假设每个数据中心内的网络正在工作，客户端可以访问数据中心，但数据中心之间彼此无法互相连接。

使用多主数据库，每个数据中心都可以继续正常运行：由于在一个数据中心写入的数据是异步复制到另一个数据中心的，所以在恢复网络连接时，写入操作只是简单地排队并交换。

另一方面，如果使用单主复制，则主库必须位于其中一个数据中心。任何写入和任何线性一致的读取请求都必须发送给该主库，因此对于连接到从库所在数据中心的客户端，这些读取和写入请求必须通过网络同步发送到主库所在的数据中心。

在单主配置的条件下，如果数据中心之间的网络被中断，则连接到从库数据中心的客户端无法联系到主库，因此它们无法对数据库执行任何写入，也不能执行任何线性一致的读取。它们仍能从从库读取，但结果可能是陈旧的（非线性一致）。如果应用需要线性一致的读写，却又位于与主库网络中断的数据中心，则网络中断将导致这些应用不可用。

> **网络中断迫使在线性一致性和可用性之间做出选择。**

**CAP 定理**

* 如果应用需要线性一致性，且某些副本因为网络问题与其他副本断开连接，那么这些副本掉线时不能处理请求。请求必须等到网络问题解决，或直接返回错误。（无论哪种方式，服务都 **不可用**）。
* 如果应用不需要线性一致性，那么某个副本即使与其他副本断开连接，也可以独立处理请求（例如多主复制）。在这种情况下，应用可以在网络问题解决前保持可用，但其行为不是线性一致的。

因此，不需要线性一致性的应用对网络问题有更强的容错能力。这种见解通常被称为 CAP 定理。

> CAP 有时以这种面目出现：一致性，可用性和分区容错性：三者只能择其二。这种说法很有误导性，因为网络分区是一种故障类型，所以它并不是一个选项：不管你喜不喜欢它都会发生。 在网络正常工作的时候，系统可以提供线性一致性和整体可用性。发生网络故障时，你必须在线性一致性和整体可用性之间做出选择。因此，CAP 更好的表述成：在分区时要么选择一致，要么选择可用。一个更可靠的网络需要减少这个选择，但是在某些时候选择是不可避免的。 在 CAP 的讨论中，术语可用性有几个相互矛盾的定义，形式化作为一个定理并不符合其通常的含义，所以最好避免使用 CAP。

**线性一致性和网络延迟**

虽然线性一致是一个很有用的保证，但线性一致的系统惊人的少。现代多核 CPU 上的内存甚至都不是线性一致的：如果一个 CPU 核上运行的线程写入某个内存地址，而另一个 CPU 核上运行的线程不久之后读取相同的地址，并不一定能读到第一个线程写入的值（除非使用了 **内存屏障（memory barrier）** 或 **围栏（fence）**）。

这种行为的原因是每个 CPU 核都有自己的内存缓存和存储缓冲区。默认情况下，内存访问首先走缓存，任何变更会异步写入主存。因为缓存访问比主存要快得多，所以这个特性对于现代 CPU 的良好性能表现至关重要。对多核内存一致性模型而言，CAP 定理是没有意义的：在同一台计算机中，我们通常假定通信都是可靠的。并且我们并不指望一个 CPU 核能在脱离计算机其他部分的条件下继续正常工作。牺牲线性一致性的原因是 **性能（performance）**，而不是容错。

许多分布式数据库也是如此：它们是 **为了提高性能** 而选择了牺牲线性一致性，而不是为了容错。线性一致的速度很慢 —— 这始终是事实。

如果你想要线性一致性，读写请求的响应时间至少与网络延迟的不确定性成正比。在像大多数计算机网络一样具有高度可变延迟的网络中，线性读写的响应时间不可避免地会很高。更快地线性一致算法不存在，但更弱的一致性模型可以快得多。

### 顺序保证

**顺序（ordering）** 反复出现，表明它是一个重要的基础性概念。回顾一下其它曾经出现过 **顺序** 的上下文：

* 领导者在单主复制中的主要目的就是，在复制日志中确定 **写入顺序（order of write）**。如果不存在一个领导者，则并发操作可能导致冲突。
* **可串行化** 是关于事务表现的像按 **某种先后顺序（some sequential order）** 执行的保证。它可以字面意义上地以 **串行顺序（serial order）** 执行事务来实现，或者允许并行执行，但同时防止序列化冲突来实现（通过锁或中止事务）。
* 分布式系统中使用时间戳和时钟是另一种将顺序引入无序世界的尝试，例如，确定两个写入操作哪一个更晚发生。

#### 顺序与因果顺序

**顺序** 反复出现有几个原因，其中一个原因是，它有助于保持 **因果关系（causality）**。因果关系对事件施加了一种 **顺序**：因在果之前；消息发送在消息收取之前。

如果一个系统服从因果关系所规定的顺序，我们说它是 **因果一致（causally consistent）** 的。例如，快照隔离提供了因果一致性：当你从数据库中读取到一些数据时，你一定还能够看到其因果前驱（假设在此期间这些数据还没有被删除）。

**因果顺序不是全序的**

**全序（total order）** 允许任意两个元素进行比较，所以如果有两个元素，你总是可以说出哪个更大，哪个更小。例如，自然数集是全序的：给定两个自然数，比如说 5 和 13，那么你可以告诉我，13 大于 5。

然而数学集合并不完全是全序的：`{a, b}` 比 `{b, c}` 更大吗？好吧，你没法真正比较它们，因为二者都不是对方的子集。我们说它们是 **无法比较（incomparable）** 的，因此数学集合是 **偏序的（partially ordered）** ：在某些情况下，可以说一个集合大于另一个（如果一个集合包含另一个集合的所有元素），但在其他情况下它们是无法比较的 。

全序和偏序之间的差异反映在不同的数据库一致性模型中：

* 线性一致性 在线性一致的系统中，操作是全序的：如果系统表现的就好像只有一个数据副本，并且所有操作都是原子性的，这意味着对任何两个操作，我们总是能判定哪个操作先发生。
* 因果性 如果两个操作都没有在彼此 **之前发生**，那么这两个操作是并发的。换句话说，如果两个事件是因果相关的（一个发生在另一个事件之前），则它们之间是有序的，但如果它们是并发的，则它们之间的顺序是无法比较的。这意味着因果关系定义了一个偏序，而不是一个全序：一些操作相互之间是有顺序的，但有些则是无法比较的。

根据这个定义，在线性一致的数据存储中是不存在并发操作的：必须有且仅有一条时间线，所有的操作都在这条时间线上，构成一个全序关系。

并发意味着时间线会分岔然后合并 —— 在这种情况下，不同分支上的操作是无法比较的（即并发操作）。

如果你熟悉像 Git 这样的分布式版本控制系统，那么其版本历史与因果关系图极其相似。通常，一个 **提交（Commit）** 发生在另一个提交之后，在一条直线上。但是有时你会遇到分支（当多个人同时在一个项目上工作时），**合并（Merge）** 会在这些并发创建的提交相融合时创建。

**线性一致性强于因果一致性**

任何线性一致的系统都能正确保持因果性。特别是，如果系统中有多个通信通道，线性一致性可以自动保证因果性，系统无需任何特殊操作（如在不同组件间传递时间戳）。

线性一致性确保因果性的事实使线性一致系统变得简单易懂，更有吸引力。但使系统线性一致可能会损害其性能和可用性，尤其是在系统具有严重的网络延迟的情况下（例如，如果系统在地理上散布）。出于这个原因，一些分布式数据系统已经放弃了线性一致性，从而获得更好的性能，但它们用起来也更为困难。

线性一致性并不是保持因果性的唯一途径 —— 还有其他方法。一个系统可以是因果一致的，而无需承担线性一致带来的性能折损（尤其对于 CAP 定理不适用的情况）。实际上在所有的不会被网络延迟拖慢的一致性模型中，因果一致性是可行的最强的一致性模型。而且在网络故障时仍能保持可用。

在许多情况下，看上去需要线性一致性的系统，实际上需要的只是因果一致性，因果一致性可以更高效地实现。

**捕获因果关系**

为了维持因果性，你需要知道哪个操作发生在哪个其他操作之前（**happened before**）。并发操作可以以任意顺序进行这是一个偏序，但如果一个操作发生在另一个操作之前，那它们必须在所有副本上以那个顺序被处理。因此，当一个副本处理一个操作时，它必须确保所有因果前驱的操作（之前发生的所有操作）已经被处理；如果前面的某个操作丢失了，后面的操作必须等待，直到前面的操作被处理完毕。

如果节点在发出写入 Y 的请求时已经看到了 X 的值，则 X 和 Y 可能存在因果关系。这个分析使用了那些在欺诈指控刑事调查中常见的问题：CEO 在做出决定 Y 时是否 **知道** X ？

无领导者数据存储中的因果性：为了防止丢失更新，我们需要检测到对同一个键的并发写入。因果一致性则更进一步：它需要跟踪整个数据库中的因果依赖，而不仅仅是一个键。可以推广版本向量以解决此类问题。

为了确定因果顺序，数据库需要知道应用读取了哪个版本的数据。在 SSI 的冲突检测中会出现类似的想法，如 “可串行化快照隔离” 中所述：当事务要提交时，数据库将检查它所读取的数据版本是否仍然是最新的。为此，数据库跟踪哪些数据被哪些事务所读取。

#### 序列号顺序

实际上跟踪所有的因果关系是不切实际的。在许多应用中，客户端在写入内容之前会先读取大量数据，我们无法弄清写入因果依赖于先前全部的读取内容，还是仅包括其中一部分。显式跟踪所有已读数据意味着巨大的额外开销。

但还有一个更好的方法：我们可以使用 **序列号（sequence number）** 或 **时间戳（timestamp）** 来排序事件。时间戳不一定来自日历时钟（或物理时钟，它们存在许多问题）。它可以来自一个 **逻辑时钟（logical clock）**，典型实现是使用一个每次操作自增的计数器。

它提供了一个全序关系：也就是说每个操作都有一个唯一的序列号，而且总是可以比较两个序列号，确定哪一个更大（即哪些操作后发生）。

特别是，我们可以使用 **与因果一致（consistent with causality）** 的全序来生成序列号 ：我们保证，如果操作 A 因果地发生在操作 B 前，那么在这个全序中 A 在 B 前（ A 具有比 B 更小的序列号）。并行操作之间可以任意排序。这样一个全序关系捕获了所有关于因果的信息，但也施加了一个比因果性要求更为严格的顺序。

在单主复制的数据库中，复制日志定义了与因果一致的写操作。主库可以简单地为每个操作自增一个计数器，从而为复制日志中的每个操作分配一个单调递增的序列号。如果一个从库按照它们在复制日志中出现的顺序来应用写操作，那么从库的状态始终是因果一致的（即使它落后于领导者）。

**非因果序列号生成器**

如果主库不存在（可能因为使用了多主数据库或无主数据库，或者因为使用了分区的数据库），如何为操作生成序列号就没有那么明显了。在实践中有各种各样的方法：

* 每个节点都可以生成自己独立的一组序列号。例如有两个节点，一个节点只能生成奇数，而另一个节点只能生成偶数。通常，可以在序列号的二进制表示中预留一些位，用于唯一的节点标识符，这样可以确保两个不同的节点永远不会生成相同的序列号。
* 可以将日历时钟（物理时钟）的时间戳附加到每个操作上。这种时间戳并不连续，但是如果它具有足够高的分辨率，那也许足以提供一个操作的全序关系。这一事实应用于 **最后写入胜利** 的冲突解决方法中。
* 可以预先分配序列号区块。例如，节点 A 可能要求从序列号 1 到 1,000 区块的所有权，而节点 B 可能要求序列号 1,001 到 2,000 区块的所有权。然后每个节点可以独立分配所属区块中的序列号，并在序列号告急时请求分配一个新的区块。

这三个选项都比单一主库的自增计数器表现要好，并且更具可伸缩性。它们为每个操作生成一个唯一的，近似自增的序列号。然而它们都有同一个问题：生成的序列号与因果不一致。

因为这些序列号生成器不能正确地捕获跨节点的操作顺序，所以会出现因果关系的问题：

* 如果一个节点产生偶数序列号而另一个产生奇数序列号，则偶数计数器可能落后于奇数计数器，反之亦然。如果你有一个奇数编号的操作和一个偶数编号的操作，你无法准确地说出哪一个操作在因果上先发生。
* 来自物理时钟的时间戳会受到时钟偏移的影响，这可能会使其与因果不一致。因果上晚发生的操作，却被分配了一个更早的时间戳。
* 在分配区块的情况下，某个操作可能会被赋予一个范围在 1,001 到 2,000 内的序列号，然而一个因果上更晚的操作可能被赋予一个范围在 1 到 1,000 之间的数字。这里序列号与因果关系也是不一致的。

**兰伯特时间戳**

每个节点都有一个唯一标识符，和一个保存自己执行操作数量的计数器。兰伯特时间戳就是两者的简单组合：（计数器，节点 ID）。两个节点有时可能具有相同的计数器值，但通过在时间戳中包含节点 ID，每个时间戳都是唯一的。

![](https://raw.githubusercontent.com/L2ncE/images/main/PicGo/Images20240625162416.png)

它提供了一个全序：如果你有两个时间戳，则 **计数器** 值大者是更大的时间戳。如果计数器值相同，则节点 ID 越大的，时间戳越大。

这个描述与奇偶计数器基本类似。使兰伯特时间戳因果一致的关键思想如下所示：每个节点和每个客户端跟踪迄今为止所见到的最大 **计数器** 值，并在每个请求中包含这个最大计数器值。当一个节点收到最大计数器值大于自身计数器值的请求或响应时，它立即将自己的计数器设置为这个最大值。

客户端 A 从节点 2 接收计数器值 `5` ，然后将最大值 `5` 发送到节点 1 。此时，节点 1 的计数器仅为 `1` ，但是它立即前移至 `5` ，所以下一个操作的计数器的值为 `6` 。

只要每一个操作都携带着最大计数器值，这个方案确保兰伯特时间戳的排序与因果一致，因为每个因果依赖都会导致时间戳增长。

兰伯特时间戳有时会与版本向量相混淆。虽然两者有一些相似之处，但它们有着不同的目的：版本向量可以区分两个操作是并发的，还是一个因果依赖另一个；而兰伯特时间戳总是施行一个全序。从兰伯特时间戳的全序中，你无法分辨两个操作是并发的还是因果依赖的。兰伯特时间戳优于版本向量的地方是，它更加紧凑。

**光有时间戳排序还不够**

虽然兰伯特时间戳定义了一个与因果一致的全序，但它还不足以解决分布式系统中的许多常见问题。

例如，考虑一个需要确保用户名能唯一标识用户帐户的系统。如果两个用户同时尝试使用相同的用户名创建帐户，则其中一个应该成功，另一个应该失败。

似乎操作的全序关系足以解决这一问题：如果创建了两个具有相同用户名的帐户，选择时间戳较小的那个作为胜者，并让带有更大时间戳者失败。由于时间戳上有全序关系，所以这个比较总是可行的。

这种方法适用于事后确定胜利者：当某个节点需要实时处理用户创建用户名的请求时，这样的方法就无法满足了。节点需要 **马上（right now）** 决定这个请求是成功还是失败。在那个时刻，节点并不知道是否存在其他节点正在并发执行创建同样用户名的操作。

为了确保没有其他节点正在使用相同的用户名和较小的时间戳并发创建同名账户，你必须检查其它每个节点，看看它在做什么。如果其中一个节点由于网络问题出现故障或不可达，则整个系统可能被拖至停机。

这里的问题是，只有在所有的操作都被收集之后，操作的全序才会出现。如果另一个节点已经产生了一些操作，但你还不知道那些操作是什么，那就无法构造所有操作最终的全序关系。

总之为了实现诸如用户名上的唯一约束这种东西，仅有操作的全序是不够的，你还需要知道这个全序何时会尘埃落定。如果你有一个创建用户名的操作，并且确定在全序中没有任何其他节点可以在你的操作之前插入对同一用户名的声称，那么你就可以安全地宣告操作执行成功。

#### 全序广播

如果你的程序只运行在单个 CPU 核上，那么定义一个操作全序是很容易的：可以简单认为就是 CPU 执行这些操作的顺序。但是在分布式系统中可能相当棘手。如果按时间戳或序列号进行排序，它还不如单主复制给力（如果你使用时间戳排序来实现唯一性约束，就不能容忍任何错误，因为你必须要从每个节点都获取到最新的序列号）。

单主复制通过选择一个节点作为主库来确定操作的全序，并在主库的单个 CPU 核上对所有操作进行排序。但如果吞吐量超出单个主库的处理能力，这种情况下如何扩展系统；以及，如果主库失效，如何处理故障切换。在分布式系统文献中，这个问题被称为 **全序广播（total order broadcast）** 或 **原子广播（atomic broadcast）**。

全序广播通常被描述为在节点间交换消息的协议。非正式地讲，它要满足两个安全属性：

* 可靠交付（reliable delivery） 没有消息丢失：如果消息被传递到一个节点，它将被传递到所有节点。
* 全序交付（totally ordered delivery） 消息以相同的顺序传递给每个节点。

正确的全序广播算法必须始终保证可靠性和有序性，即使节点或网络出现故障。当然在网络中断的时候，消息是传不出去的，但是算法可以不断重试，以便在网络最终修复时，消息能及时通过并送达（当然它们必须仍然按照正确的顺序传递）。

**使用全序广播**

全序广播正是数据库复制所需的：如果每个消息都代表一次数据库的写入，且每个副本都按相同的顺序处理相同的写入，那么副本间将相互保持一致（除了临时的复制延迟）。这个原理被称为 **状态机复制（state machine replication）**。

与之类似，可以使用全序广播来实现可串行化的事务：如果每个消息都表示一个确定性事务，以存储过程的形式来执行，且每个节点都以相同的顺序处理这些消息，那么数据库的分区和副本就可以相互保持一致。

全序广播的一个重要表现是，顺序在消息送达时被固化：如果后续的消息已经送达，节点就不允许将消息插入顺序中的较早位置。这个事实使得全序广播比时间戳排序更强。

考量全序广播的另一种方式是，这是一种创建日志的方式：传递消息就像追加写入日志。由于所有节点必须以相同的顺序传递相同的消息，因此所有节点都可以读取日志，并看到相同的消息序列。

全序广播对于实现提供防护令牌的锁服务也很有用。每个获取锁的请求都作为一条消息追加到日志末尾，并且所有的消息都按它们在日志中出现的顺序依次编号。序列号可以当成防护令牌用，因为它是单调递增的。在 ZooKeeper 中，这个序列号被称为 `zxid` 。

**使用全序广播实现线性一致的存储**

线性一致与全序广播两者之间有着密切的联系 。

全序广播是异步的：消息被保证以固定的顺序可靠地传送，但是不能保证消息 **何时** 被送达（所以一个接收者可能落后于其他接收者）。相比之下，线性一致性是新鲜性的保证：读取一定能看见最新的写入值。

但如果有了全序广播，你就可以在此基础上构建线性一致的存储。例如，你可以确保用户名能唯一标识用户帐户。

设想对于每一个可能的用户名，你都可以有一个带有 CAS 原子操作的线性一致寄存器。每个寄存器最初的值为空值（表示未使用该用户名）。当用户想要创建一个用户名时，对该用户名的寄存器执行 CAS 操作，在先前寄存器值为空的条件，将其值设置为用户的账号 ID。如果多个用户试图同时获取相同的用户名，则只有一个 CAS 操作会成功，因为其他用户会看到非空的值（由于线性一致性）。

你可以通过将全序广播当成仅追加日志的方式来实现这种线性一致的 CAS 操作：

1. 在日志中追加一条消息，试探性地指明你要声明的用户名。
2. 读日志，并等待你刚才追加的消息被读回。
3. 检查是否有任何消息声称目标用户名的所有权。如果这些消息中的第一条就是你自己的消息，那么你就成功了：你可以提交声称的用户名（也许是通过向日志追加另一条消息）并向客户端确认。如果所需用户名的第一条消息来自其他用户，则中止操作。

由于日志项是以相同顺序送达至所有节点，因此如果有多个并发写入，则所有节点会对最先到达者达成一致。选择冲突写入中的第一个作为胜利者，并中止后来者，以此确定所有节点对某个写入是提交还是中止达成一致。类似的方法可以在一个日志的基础上实现可串行化的多对象事务。

尽管这一过程保证写入是线性一致的，但它并不保证读取也是线性一致的 —— 如果你从与日志异步更新的存储中读取数据，结果可能是陈旧的。为了使读取也线性一致，有几个选项：

* 你可以通过在日志中追加一条消息，然后读取日志，直到该消息被读回才执行实际的读取操作。消息在日志中的位置因此定义了读取发生的时间点（etcd 的法定人数读取有些类似这种情况）。
* 如果日志允许以线性一致的方式获取最新日志消息的位置，则可以查询该位置，等待该位置前的所有消息都传达到你，然后执行读取。（这是 Zookeeper `sync()` 操作背后的思想）。
* 你可以从同步更新的副本中进行读取，因此可以确保结果是最新的（这种技术用于链式复制（chain replication））。

**使用线性一致性存储实现全序广播**

我们可以反过来，假设我们有线性一致的存储，接下来会展示如何在此基础上构建全序广播。

最简单的方法是假设你有一个线性一致的寄存器来存储一个整数，并且有一个原子 **自增并返回** 操作。或者原子 CAS 操作也可以完成这项工作。

该算法很简单：每个要通过全序广播发送的消息首先对线性一致寄存器执行 **自增并返回** 操作。然后将从寄存器获得的值作为序列号附加到消息中。然后你可以将消息发送到所有节点（重新发送任何丢失的消息），而收件人将按序列号依序传递消息。

通过自增线性一致性寄存器获得的数字形式上是一个没有间隙的序列。因此，如果一个节点已经发送了消息 4 并且接收到序列号为 6 的传入消息，则它知道它在传递消息 6 之前必须等待消息 5 。兰伯特时间戳则与之不同 —— 事实上，这是全序广播和时间戳排序间的关键区别。

实现一个带有原子性 **自增并返回** 操作的线性一致寄存器有多困难？如果事情从来不出差错，那很容易：你可以简单地把它保存在单个节点内的变量中。问题在于处理当该节点的网络连接中断时的情况，并在该节点失效时能恢复这个值。一般来说，如果你对线性一致性的序列号生成器进行过足够深入的思考，你不可避免地会得出一个共识算法。

这并非巧合：可以证明，线性一致的 CAS（或自增并返回）寄存器与全序广播都等价于 **共识** 问题。也就是说，如果你能解决其中的一个问题，你可以把它转化成为其他问题的解决方案。

### 分布式事务与共识

节点能达成一致，在很多场景下都非常重要，例如：

* 领导选举 在单主复制的数据库中，所有节点需要就哪个节点是领导者达成一致。如果一些节点无法与其他节点通信，则可能会对领导权的归属引起争议。错误的故障切换会导致两个节点都认为自己是领导者（**脑裂**）。
* 原子提交 在支持跨多节点或跨多分区事务的数据库中，一个事务可能在某些节点上失败，但在其他节点上成功。如果我们想要维护事务的原子性，我们必须让所有节点对事务的结果达成一致。这个共识的例子被称为 **原子提交（atomic commit）** 问题 。

#### 原子提交与两阶段提交

事务原子性的目的是在多次写操作中途出错的情况下，提供一种简单的语义。事务的结果要么是成功提交，都被持久化；要么是中止，都被回滚（即撤消或丢弃）。

**从单节点到分布式原子提交**

对于在单个数据库节点执行的事务，原子性通常由存储引擎实现。当客户端请求数据库节点提交事务时，数据库将使事务的写入持久化，然后将提交记录追加到磁盘中的日志里。

因此，在单个节点上，事务的提交主要取决于数据持久化落盘的 **顺序**：首先是数据，然后是提交记录。事务提交或终止的关键决定时刻是磁盘完成写入提交记录的时刻。

如果一个事务中涉及多个节点，很容易发生违反原子性的情况：提交在某些节点上成功，而在其他节点上失败：

* 某些节点可能会检测到违反约束或冲突，因此需要中止，而其他节点则可以成功进行提交。
* 某些提交请求可能在网络中丢失，最终由于超时而中止，而其他提交请求则通过。
* 在提交记录完全写入之前，某些节点可能会崩溃，并在恢复时回滚，而其他节点则成功提交。

如果某些节点提交了事务，但其他节点却放弃了这些事务，那么这些节点就会彼此不一致。而且一旦在某个节点上提交了一个事务，如果事后发现它在其它节点上被中止了，它是无法撤回的。出于这个原因，一旦确定事务中的所有其他节点也将提交，节点就必须进行提交。

事务提交必须是不可撤销的 —— 事务提交之后，无法追溯性地中止事务。一旦数据被提交，其结果就对其他事务可见，因此其他客户端可能会开始依赖这些数据。如果一个事务在提交后被允许中止，所有那些读取了 **已提交却又被追溯声明不存在数据** 的事务也必须回滚。

**两阶段提交简介**

**两阶段提交（two-phase commit）** 是一种用于实现跨多个节点的原子事务提交的算法，即确保所有节点提交或所有节点中止。2PC 在某些数据库内部使用，也以 **XA 事务** 的形式对应用可用（例如 Java Transaction API 支持）。

![](https://raw.githubusercontent.com/L2ncE/images/main/PicGo/Images20240706172404.png)

2PC 使用一个新组件：**协调者**（coordinator，也称为 **事务管理器**，即 transaction manager）。协调者通常在请求事务的相同应用进程中以库的形式实现（例如，嵌入在 Java EE 容器中），但也可以是单独的进程或服务。

正常情况下，2PC 事务以应用在多个数据库节点上读写数据开始。我们称这些数据库节点为 **参与者（participants）**。当应用准备提交时，协调者开始阶段 1 ：它发送一个 **准备（prepare）** 请求到每个节点，询问它们是否能够提交。然后协调者会跟踪参与者的响应：

* 如果所有参与者都回答 “是”，表示它们已经准备好提交，那么协调者在阶段 2 发出 **提交（commit）** 请求，然后提交真正发生。
* 如果任意一个参与者回复了 “否”，则协调者在阶段 2 中向所有节点发送 **中止（abort）** 请求。

**系统承诺**

在两阶段提交的情况下，准备请求和提交请求当然也可以轻易丢失。**2PC 又有什么不同呢？**

1. 当应用想要启动一个分布式事务时，它向协调者请求一个事务 ID。此事务 ID 是全局唯一的。
2. 应用在每个参与者上启动单节点事务，并带上这个全局事务 ID。所有的读写都是单节点事务各自完成的。如果在这个阶段出现任何问题则协调者或任何参与者都可以中止。
3. 当应用准备提交时，协调者向所有参与者发送一个 **准备** 请求，并打上全局事务 ID 的标记。如果任意一个请求失败或超时，则协调者向所有参与者发送该事务的中止请求。
4. 参与者收到准备请求时，需要确保在任意情况下都的确可以提交事务。这包括将所有事务数据写入磁盘以及检查是否存在任何冲突或违反约束。换句话说，参与者放弃了中止事务的权利，但没有实际提交。
5. 当协调者收到所有准备请求的答复时，会就提交或中止事务作出明确的决定。协调者必须把这个决定写到磁盘上的事务日志中，如果它随后就崩溃，恢复后也能知道自己所做的决定。这被称为 **提交点（commit point）**。
6. 一旦协调者的决定落盘，提交或中止请求会发送给所有参与者。如果这个请求失败或超时，协调者必须永远保持重试，直到成功为止。没有回头路：如果已经做出决定，不管需要多少次重试它都必须被执行。如果参与者在此期间崩溃，事务将在其恢复后提交 —— 由于参与者投了赞成，因此恢复后它不能拒绝提交。

因此，该协议包含两个关键的 “不归路” 点：当参与者投票 “是” 时，它承诺它稍后肯定能够提交（尽管协调者可能仍然选择放弃）；以及一旦协调者做出决定，这一决定是不可撤销的。这些承诺保证了 2PC 的原子性（单节点原子提交将这两个事件合为了一体：将提交记录写入事务日志）。

**协调者失效**

如果协调者在发送 **准备** 请求之前失败，参与者可以安全地中止事务。但是，一旦参与者收到了准备请求并投了 “是”，就不能再单方面放弃 —— 必须等待协调者回答事务是否已经提交或中止。如果此时协调者崩溃或网络出现故障，参与者什么也做不了只能等待。参与者的这种事务状态称为 **存疑（in doubt）** 的或 **不确定（uncertain）** 的。

此时可以完成 2PC 的唯一方法是等待协调者恢复。这就是为什么协调者必须在向参与者发送提交或中止请求之前，将其提交或中止决定写入磁盘上的事务日志：协调者恢复后，通过读取其事务日志来确定所有存疑事务的状态。任何在协调者日志中没有提交记录的事务都会中止。因此，2PC 的 **提交点** 归结为协调者上的常规单节点原子提交。

**三阶段提交**

两阶段提交被称为 **阻塞（blocking）**- 原子提交协议，因为存在 2PC 可能卡住并等待协调者恢复的情况。

作为 2PC 的替代方案，已经提出了一种称为 **三阶段提交（3PC）** 的算法。然而，3PC 假定网络延迟有界，节点响应时间有限；在大多数具有无限网络延迟和进程暂停的实际系统中，它并不能保证原子性。

通常，非阻塞原子提交需要一个 **完美的故障检测器（perfect failure detector）**—— 即一个可靠的机制来判断一个节点是否已经崩溃。在具有无限延迟的网络中，超时并不是一种可靠的故障检测机制，因为即使没有节点崩溃，请求也可能由于网络问题而超时。出于这个原因，2PC 仍然被使用，尽管大家都清楚存在协调者故障的问题。

#### 实践中的分布式事务

* 数据库内部的分布式事务 一些分布式数据库（即在其标准配置中使用复制和分区的数据库）支持数据库节点之间的内部事务。在这种情况下，所有参与事务的节点都运行相同的数据库软件。
* 异构分布式事务 在 **异构（heterogeneous）** 事务中，参与者是由两种或两种以上的不同技术组成的：例如来自不同供应商的两个数据库，甚至是非数据库系统（如消息队列）。跨系统的分布式事务必须确保原子提交，尽管系统可能完全不同。

**恰好一次的消息处理**

异构的分布式事务处理能够以强大的方式集成不同的系统。例如：消息队列中的一条消息可以被确认为已处理，当且仅当用于处理消息的数据库事务成功提交。这是通过在同一个事务中原子提交 **消息确认** 和 **数据库写入** 两个操作来实现的。

如果消息传递或数据库事务任意一者失败，两者都会中止，因此消息代理可能会在稍后安全地重传消息。因此即使在成功之前需要几次重试，也可以确保消息被 **有效地（effectively）** 恰好处理一次。

然而，只有当所有受事务影响的系统都使用同样的 **原子提交协议（atomic commit protocol）** 时，这样的分布式事务才是可能的。例如，假设处理消息的副作用是发送一封邮件，而邮件服务器并不支持两阶段提交：如果消息处理失败并重试，则可能会发送两次或更多次的邮件。但如果处理消息的所有副作用都可以在事务中止时回滚，那么这样的处理流程就可以安全地重试，就好像什么都没有发生过一样。

**XA 事务**

XA（**扩展架构（eXtended Architecture）** 的缩写）是跨异构技术实现两阶段提交的标准。XA 不是一个网络协议 —— 它只是一个用来与事务协调者连接的 API。

XA 假定你的应用使用网络驱动或客户端库来与 **参与者**（数据库或消息服务）进行通信。如果驱动支持 XA，则意味着它会调用 XA API 以查明操作是否为分布式事务的一部分 —— 如果是，则将必要的信息发往数据库服务器。驱动还会向协调者暴露回调接口，协调者可以通过回调来要求参与者准备、提交或中止。

事务协调者需要实现 XA API。标准没有指明应该如何实现，但实际上协调者通常只是一个库，被加载到发起事务的应用的同一个进程中。它在事务中跟踪所有的参与者，并在要求它们 **准备** 之后收集参与者的响应（通过驱动回调），并使用本地磁盘上的日志记录每次事务的决定（提交 / 中止）。

如果应用进程崩溃，或者运行应用的机器报销了，任何带有 **准备了** 但未提交事务的参与者都会在疑虑中卡死。由于协调程序的日志位于应用服务器的本地磁盘上，因此必须重启该服务器，且协调程序库必须读取日志以恢复每个事务的提交 / 中止结果。只有这样，协调者才能使用数据库驱动的 XA 回调来要求参与者提交或中止。

**从协调者故障中恢复**

理论上，如果协调者崩溃并重新启动，它应该干净地从日志中恢复其状态，并解决任何存疑事务。然而在实践中，**孤立（orphaned）** 的存疑事务确实会出现，即无论出于何种理由，协调者无法确定事务的结果（例如事务日志已经由于软件错误丢失或损坏）。这些事务无法自动解决，所以它们永远待在数据库中，持有锁并阻塞其他事务。

即使重启数据库服务器也无法解决这个问题，因为在 2PC 的正确实现中，即使重启也必须保留存疑事务的锁（否则就会冒违反原子性保证的风险）。这是一种棘手的情况。

唯一的出路是让管理员手动决定提交还是回滚事务。管理员必须检查每个存疑事务的参与者，确定是否有任何参与者已经提交或中止，然后将相同的结果应用于其他参与者。解决这个问题潜在地需要大量的人力，并且可能发生在严重的生产中断期间（不然为什么协调者处于这种糟糕的状态），并很可能要在巨大精神压力和时间压力下完成。

许多 XA 的实现都有一个叫做 **启发式决策（heuristic decisions）** 的紧急逃生舱口：允许参与者单方面决定放弃或提交一个存疑事务，而无需协调者做出最终决定。要清楚的是，这里 **启发式** 是 **可能破坏原子性（probably breaking atomicity）** 的委婉说法，因为它违背了两阶段提交的系统承诺。因此，启发式决策只是为了逃出灾难性的情况而准备的，而不是为了日常使用的。

**分布式事务的限制**

XA 事务解决了保持多个参与者（数据系统）相互一致的现实的和重要的问题，但正如我们所看到的那样，它也引入了严重的运维问题。特别来讲，这里的核心认识是：事务协调者本身就是一种数据库（存储了事务的结果），因此需要像其他重要数据库一样小心地打交道：

* 如果协调者没有复制，而是只在单台机器上运行，那么它是整个系统的失效单点（因为它的失效会导致其他应用服务器阻塞在存疑事务持有的锁上）。令人惊讶的是，许多协调者实现默认情况下并不是高可用的，或者只有基本的复制支持。
* 许多服务器端应用都是使用无状态模式开发的（受 HTTP 的青睐），所有持久状态都存储在数据库中，因此具有应用服务器可随意按需添加删除的优点。但是，当协调者成为应用服务器的一部分时，它会改变部署的性质。突然间，协调者的日志成为持久系统状态的关键部分 —— 与数据库本身一样重要，因为协调者日志是为了在崩溃后恢复存疑事务所必需的。这样的应用服务器不再是无状态的了。
* 由于 XA 需要兼容各种数据系统，因此它必须是所有系统的最小公分母。例如，它不能检测不同系统间的死锁（因为这将需要一个标准协议来让系统交换每个事务正在等待的锁的信息），而且它无法与 SSI 协同工作，因为这需要一个跨系统定位冲突的协议。
* 对于数据库内部的分布式事务（不是 XA），限制没有这么大 —— 例如，分布式版本的 SSI 是可能的。然而仍然存在问题：2PC 成功提交一个事务需要所有参与者的响应。因此，如果系统的 **任何** 部分损坏，事务也会失败。因此，分布式事务又有 **扩大失效（amplifying failures）** 的趋势，这又与我们构建容错系统的目标背道而驰。

#### 容错共识

非正式地，共识意味着让几个节点就某事达成一致。例如，如果有几个人 **同时（concurrently）** 尝试预订飞机上的最后一个座位，或剧院中的同一个座位，或者尝试使用相同的用户名注册一个帐户。共识算法可以用来确定这些 **互不相容（mutually incompatible）** 的操作中，哪一个才是赢家。

共识问题通常形式化如下：一个或多个节点可以 **提议（propose）** 某些值，而共识算法 **决定（decides）** 采用其中的某个值。在座位预订的例子中，当几个顾客同时试图订购最后一个座位时，处理顾客请求的每个节点可以 **提议** 将要服务的顾客的 ID，而 **决定** 指明了哪个顾客获得了座位。

在这种形式下，共识算法必须满足以下性质：

* 一致同意（Uniform agreement） 没有两个节点的决定不同。
* 完整性（Integrity） 没有节点决定两次。
* 有效性（Validity） 如果一个节点决定了值 `v` ，则 `v` 由某个节点所提议。
* 终止（Termination） 由所有未崩溃的节点来最终决定值。

**一致同意** 和 **完整性** 属性定义了共识的核心思想：所有人都决定了相同的结果，一旦决定了，你就不能改变主意。**有效性** 属性主要是为了排除平凡的解决方案：例如，无论提议了什么值，你都可以有一个始终决定值为 `null` 的算法，该算法满足 **一致同意** 和 **完整性** 属性，但不满足 **有效性** 属性。

如果你不关心容错，那么满足前三个属性很容易：你可以将一个节点硬编码为 “独裁者”，并让该节点做出所有的决定。但如果该节点失效，那么系统就无法再做出任何决定。事实上，这就是我们在两阶段提交的情况中所看到的：如果协调者失效，那么存疑的参与者就无法决定提交还是中止。

**终止** 属性形式化了容错的思想。它实质上说的是，一个共识算法不能简单地永远闲坐着等死 —— 换句话说，它必须取得进展。即使部分节点出现故障，其他节点也必须达成一项决定（**终止** 是一种 **活性属性**，而另外三种是 **安全属性** —— 请参阅 “[安全性和活性](https://vonng.gitbook.io/vonng/part-ii/ch8#%E5%AE%89%E5%85%A8%E6%80%A7%E5%92%8C%E6%B4%BB%E6%80%A7)”）。

共识的系统模型假设，当一个节点 “崩溃” 时，它会突然消失而且永远不会回来。（不像软件崩溃，想象一下地震，包含你的节点的数据中心被山体滑坡所摧毁，你必须假设节点被埋在 30 英尺以下的泥土中，并且永远不会重新上线）在这个系统模型中，任何需要等待节点恢复的算法都不能满足 **终止** 属性。特别是，2PC 不符合终止属性的要求。

当然如果 **所有** 的节点都崩溃了，没有一个在运行，那么所有算法都不可能决定任何事情。算法可以容忍的失效数量是有限的：事实上可以证明，任何共识算法都需要至少占总体 **多数（majority）** 的节点正确工作，以确保终止属性。多数可以安全地组成法定人数。

因此 **终止** 属性取决于一个假设，**不超过一半的节点崩溃或不可达**。然而即使多数节点出现故障或存在严重的网络问题，绝大多数共识的实现都能始终确保安全属性得到满足 —— 一致同意，完整性和有效性。因此，大规模的中断可能会阻止系统处理请求，但是它不能通过使系统做出无效的决定来破坏共识系统。

大多数共识算法假设不存在 **拜占庭式错误**。也就是说，如果一个节点没有正确地遵循协议（例如，如果它向不同节点发送矛盾的消息），它就可能会破坏协议的安全属性。克服拜占庭故障，稳健地达成共识是可能的，只要少于三分之一的节点存在拜占庭故障。

**共识算法和全序广播**

最著名的容错共识算法是 **视图戳复制（VSR, Viewstamped Replication）**，Paxos ，Raft 以及 Zab 。这些算法之间有不少相似之处，但它们并不相同。在本书中我们不会介绍各种算法的详细细节：了解一些它们共通的高级思想通常已经足够了，除非你准备自己实现一个共识系统。（可能并不明智，相当难）

大多数这些算法实际上并不直接使用这里描述的形式化模型（提议与决定单个值，并满足一致同意、完整性、有效性和终止属性）。取而代之的是，它们决定了值的 **顺序（sequence）**，这使它们成为全序广播算法，正如本章前面所讨论的那样。

请记住，全序广播要求将消息按照相同的顺序，恰好传递一次，准确传送到所有节点。如果仔细思考，这相当于进行了几轮共识：在每一轮中，节点提议下一条要发送的消息，然后决定在全序中下一条要发送的消息。

所以，全序广播相当于重复进行多轮共识（每次共识决定与一次消息传递相对应）：

* 由于 **一致同意** 属性，所有节点决定以相同的顺序传递相同的消息。
* 由于 **完整性** 属性，消息不会重复。
* 由于 **有效性** 属性，消息不会被损坏，也不能凭空编造。
* 由于 **终止** 属性，消息不会丢失。

视图戳复制，Raft 和 Zab 直接实现了全序广播，因为这样做比重复 **一次一值（one value a time）** 的共识更高效。在 Paxos 的情况下，这种优化被称为 Multi-Paxos。

**单主复制与共识**

单主复制将所有的写入操作都交给主库，并以相同的顺序将它们应用到从库，从而使副本保持在最新状态。这实际上不就是一个全序广播吗？为什么我们在单主复制里一点都没担心过共识问题呢？

答案取决于如何选择领导者。如果主库是由运维人员手动选择和配置的，那么你实际上拥有一种 **独裁类型** 的 “共识算法”：只有一个节点被允许接受写入（即决定写入复制日志的顺序），如果该节点发生故障，则系统将无法写入，直到运维手动配置其他节点作为主库。这样的系统在实践中可以表现良好，但它无法满足共识的 **终止** 属性，因为它需要人为干预才能取得 **进展**。

一些数据库会自动执行领导者选举和故障切换，如果旧主库失效，会提拔一个从库为新主库。这使我们向容错的全序广播更进一步，从而达成共识。

但是还有一个问题。我们之前曾经讨论过脑裂的问题，并且说过所有的节点都需要同意是谁领导，否则两个不同的节点都会认为自己是领导者，从而导致数据库进入不一致的状态。因此，选出一位领导者需要共识。但如果这里描述的共识算法实际上是全序广播算法，并且全序广播就像单主复制，而单主复制需要一个领导者，那么…

这样看来，要选出一个领导者，我们首先需要一个领导者。要解决共识问题，我们首先需要解决共识问题。我们如何跳出这个先有鸡还是先有蛋的问题？

**纪元编号和法定人数**

迄今为止所讨论的所有共识协议，在内部都以某种形式使用一个领导者，但它们并不能保证领导者是独一无二的。相反，它们可以做出更弱的保证：协议定义了一个 **纪元编号**（epoch number，在 Paxos 中被称为 **投票编号**，即 ballot number，在视图戳复制中被称为 **视图编号**，即 view number，以及在 Raft 中被为 **任期号码**，即 term number），并确保在每个时代中，领导者都是唯一的。

每次当现任领导被认为挂掉的时候，节点间就会开始一场投票，以选出一个新领导。这次选举被赋予一个递增的纪元编号，因此纪元编号是全序且单调递增的。如果两个不同的时代的领导者之间出现冲突（也许是因为前任领导者实际上并未死亡），那么带有更高纪元编号的领导说了算。

在任何领导者被允许决定任何事情之前，必须先检查是否存在其他带有更高纪元编号的领导者，它们可能会做出相互冲突的决定。领导者如何知道自己没有被另一个节点赶下台？回想一下在 “[真相由多数所定义](https://vonng.gitbook.io/vonng/part-ii/ch8#%E7%9C%9F%E7%9B%B8%E7%94%B1%E5%A4%9A%E6%95%B0%E6%89%80%E5%AE%9A%E4%B9%89)” 中提到的：一个节点不一定能相信自己的判断 —— 因为只有节点自己认为自己是领导者，并不一定意味着其他节点接受它作为它们的领导者。

相反，它必须从 **法定人数（quorum）** 的节点中获取选票（请参阅 “[读写的法定人数](https://vonng.gitbook.io/vonng/part-ii/ch5#%E8%AF%BB%E5%86%99%E7%9A%84%E6%B3%95%E5%AE%9A%E4%BA%BA%E6%95%B0)”）。对领导者想要做出的每一个决定，都必须将提议值发送给其他节点，并等待法定人数的节点响应并赞成提案。法定人数通常（但不总是）由多数节点组成【105】。只有在没有意识到任何带有更高纪元编号的领导者的情况下，一个节点才会投票赞成提议。

因此，我们有两轮投票：第一次是为了选出一位领导者，第二次是对领导者的提议进行表决。关键的洞察在于，这两次投票的 **法定人群** 必须相互 **重叠（overlap）**：如果一个提案的表决通过，则至少得有一个参与投票的节点也必须参加过最近的领导者选举【105】。因此，如果在一个提案的表决过程中没有出现更高的纪元编号。那么现任领导者就可以得出这样的结论：没有发生过更高时代的领导选举，因此可以确定自己仍然在领导。然后它就可以安全地对提议值做出决定。

这一投票过程表面上看起来很像两阶段提交。最大的区别在于，2PC 中协调者不是由选举产生的，而且 2PC 则要求 **所有** 参与者都投赞成票，而容错共识算法只需要多数节点的投票。而且，共识算法还定义了一个恢复过程，节点可以在选举出新的领导者之后进入一个一致的状态，确保始终能满足安全属性。这些区别正是共识算法正确性和容错性的关键。

**共识的局限性**

共识算法对于分布式系统来说是一个巨大的突破：它为其他充满不确定性的系统带来了基础的安全属性（一致同意，完整性和有效性），然而它们还能保持容错（只要多数节点正常工作且可达，就能取得进展）。它们提供了全序广播，因此它们也可以以一种容错的方式实现线性一致的原子操作（请参阅 “[使用全序广播实现线性一致的存储](https://vonng.gitbook.io/vonng/part-ii/ch9#%E4%BD%BF%E7%94%A8%E5%85%A8%E5%BA%8F%E5%B9%BF%E6%92%AD%E5%AE%9E%E7%8E%B0%E7%BA%BF%E6%80%A7%E4%B8%80%E8%87%B4%E7%9A%84%E5%AD%98%E5%82%A8)”）。

尽管如此，它们并不是在所有地方都用上了，因为好处总是有代价的。

节点在做出决定之前对提议进行投票的过程是一种同步复制。如 “[同步复制与异步复制](https://vonng.gitbook.io/vonng/part-ii/ch5#%E5%90%8C%E6%AD%A5%E5%A4%8D%E5%88%B6%E4%B8%8E%E5%BC%82%E6%AD%A5%E5%A4%8D%E5%88%B6)” 中所述，通常数据库会配置为异步复制模式。在这种配置中发生故障切换时，一些已经提交的数据可能会丢失 —— 但是为了获得更好的性能，许多人选择接受这种风险。

共识系统总是需要严格多数来运转。这意味着你至少需要三个节点才能容忍单节点故障（其余两个构成多数），或者至少有五个节点来容忍两个节点发生故障（其余三个构成多数）。如果网络故障切断了某些节点同其他节点的连接，则只有多数节点所在的网络可以继续工作，其余部分将被阻塞（请参阅 “[线性一致性的代价](https://vonng.gitbook.io/vonng/part-ii/ch9#%E7%BA%BF%E6%80%A7%E4%B8%80%E8%87%B4%E6%80%A7%E7%9A%84%E4%BB%A3%E4%BB%B7)”）。

大多数共识算法假定参与投票的节点是固定的集合，这意味着你不能简单的在集群中添加或删除节点。共识算法的 **动态成员扩展（dynamic membership extension）** 允许集群中的节点集随时间推移而变化，但是它们比静态成员算法要难理解得多。

共识系统通常依靠超时来检测失效的节点。在网络延迟高度变化的环境中，特别是在地理上散布的系统中，经常发生一个节点由于暂时的网络问题，错误地认为领导者已经失效。虽然这种错误不会损害安全属性，但频繁的领导者选举会导致糟糕的性能表现，因系统最后可能花在权力倾扎上的时间要比花在建设性工作的多得多。

有时共识算法对网络问题特别敏感。例如 Raft 已被证明存在让人不悦的极端情况【106】：如果整个网络工作正常，但只有一条特定的网络连接一直不可靠，Raft 可能会进入领导者在两个节点间频繁切换的局面，或者当前领导者不断被迫辞职以致系统实质上毫无进展。其他一致性算法也存在类似的问题，而设计能健壮应对不可靠网络的算法仍然是一个开放的研究问题。

#### 成员与协调服务

像 ZooKeeper 或 etcd 这样的项目通常被描述为 “分布式键值存储” 或 “协调与配置服务”。这种服务的 API 看起来非常像数据库：你可以读写给定键的值，并遍历键。所以如果它们基本上算是数据库的话，为什么它们要把工夫全花在实现一个共识算法上呢？是什么使它们区别于其他任意类型的数据库？

为了理解这一点，简单了解如何使用 ZooKeeper 这类服务是很有帮助的。作为应用开发人员，你很少需要直接使用 ZooKeeper，因为它实际上不适合当成通用数据库来用。更有可能的是，你会通过其他项目间接依赖它，例如 HBase、Hadoop YARN、OpenStack Nova 和 Kafka 都依赖 ZooKeeper 在后台运行。这些项目从它那里得到了什么？

ZooKeeper 和 etcd 被设计为容纳少量完全可以放在内存中的数据（虽然它们仍然会写入磁盘以保证持久性），所以你不会想着把所有应用数据放到这里。这些少量数据会通过容错的全序广播算法复制到所有节点上。正如前面所讨论的那样，数据库复制需要的就是全序广播：如果每条消息代表对数据库的写入，则以相同的顺序应用相同的写入操作可以使副本之间保持一致。

ZooKeeper 模仿了 Google 的 Chubby 锁服务【14,98】，不仅实现了全序广播（因此也实现了共识），而且还构建了一组有趣的其他特性，这些特性在构建分布式系统时变得特别有用：

* 线性一致性的原子操作

  使用原子 CAS 操作可以实现锁：如果多个节点同时尝试执行相同的操作，只有一个节点会成功。共识协议保证了操作的原子性和线性一致性，即使节点发生故障或网络在任意时刻中断。分布式锁通常以 **租约（lease）** 的形式实现，租约有一个到期时间，以便在客户端失效的情况下最终能被释放（请参阅 “[进程暂停](https://vonng.gitbook.io/vonng/part-ii/ch8#%E8%BF%9B%E7%A8%8B%E6%9A%82%E5%81%9C)”）。
* 操作的全序排序

  如 “[领导者和锁](https://vonng.gitbook.io/vonng/part-ii/ch8#%E9%A2%86%E5%AF%BC%E8%80%85%E5%92%8C%E9%94%81)” 中所述，当某个资源受到锁或租约的保护时，你需要一个防护令牌来防止客户端在进程暂停的情况下彼此冲突。防护令牌是每次锁被获取时单调增加的数字。ZooKeeper 通过全序化所有操作来提供这个功能，它为每个操作提供一个单调递增的事务 ID（`zxid`）和版本号（`cversion`）【15】。
* 失效检测

  客户端在 ZooKeeper 服务器上维护一个长期会话，客户端和服务器周期性地交换心跳包来检查节点是否还活着。即使连接暂时中断，或者 ZooKeeper 节点失效，会话仍保持在活跃状态。但如果心跳停止的持续时间超出会话超时，ZooKeeper 会宣告该会话已死亡。当会话超时时（ZooKeeper 称这些节点为 **临时节点**，即 ephemeral nodes），会话持有的任何锁都可以配置为自动释放。
* 变更通知

  客户端不仅可以读取其他客户端创建的锁和值，还可以监听它们的变更。因此，客户端可以知道另一个客户端何时加入集群（基于新客户端写入 ZooKeeper 的值），或发生故障（因其会话超时，而其临时节点消失）。通过订阅通知，客户端不用再通过频繁轮询的方式来找出变更。

在这些功能中，只有线性一致的原子操作才真的需要共识。但正是这些功能的组合，使得像 ZooKeeper 这样的系统在分布式协调中非常有用。

**将工作分配给节点**

ZooKeeper/Chubby 模型运行良好的一个例子是，如果你有几个进程实例或服务，需要选择其中一个实例作为主库或首选服务。如果领导者失败，其他节点之一应该接管。这对单主数据库当然非常实用，但对作业调度程序和类似的有状态系统也很好用。

另一个例子是，当你有一些分区资源（数据库、消息流、文件存储、分布式 Actor 系统等），并需要决定将哪个分区分配给哪个节点时。当新节点加入集群时，需要将某些分区从现有节点移动到新节点，以便重新平衡负载（请参阅 “[分区再平衡](https://vonng.gitbook.io/vonng/part-ii/ch6#%E5%88%86%E5%8C%BA%E5%86%8D%E5%B9%B3%E8%A1%A1)”）。当节点被移除或失效时，其他节点需要接管失效节点的工作。

这类任务可以通过在 ZooKeeper 中明智地使用原子操作，临时节点与通知来实现。如果设计得当，这种方法允许应用自动从故障中恢复而无需人工干预。不过这并不容易，尽管已经有不少在 ZooKeeper 客户端 API 基础之上提供更高层工具的库，例如 Apache Curator 【17】。但它仍然要比尝试从头实现必要的共识算法要好得多，这样的尝试鲜有成功记录【107】。

应用最初只能在单个节点上运行，但最终可能会增长到数千个节点。试图在如此之多的节点上进行多数投票将是非常低效的。相反，ZooKeeper 在固定数量的节点（通常是三到五个）上运行，并在这些节点之间执行其多数票，同时支持潜在的大量客户端。因此，ZooKeeper 提供了一种将协调节点（共识，操作排序和故障检测）的一些工作 “外包” 到外部服务的方式。

通常，由 ZooKeeper 管理的数据类型的变化十分缓慢：代表 “分区 7 中的节点运行在 `10.1.1.23` 上” 的信息可能会在几分钟或几小时的时间内发生变化。它不是用来存储应用的运行时状态的，后者每秒可能会改变数千甚至数百万次。如果应用状态需要从一个节点复制到另一个节点，则可以使用其他工具（如 Apache BookKeeper 【108】）。

**服务发现**

ZooKeeper、etcd 和 Consul 也经常用于服务发现 —— 也就是找出你需要连接到哪个 IP 地址才能到达特定的服务。在云数据中心环境中，虚拟机来来往往很常见，你通常不会事先知道服务的 IP 地址。相反，你可以配置你的服务，使其在启动时注册服务注册表中的网络端点，然后可以由其他服务找到它们。

但是，服务发现是否需要达成共识还不太清楚。DNS 是查找服务名称的 IP 地址的传统方式，它使用多层缓存来实现良好的性能和可用性。从 DNS 读取是绝对不线性一致性的，如果 DNS 查询的结果有点陈旧，通常不会有问题【109】。DNS 的可用性和对网络中断的鲁棒性更重要。

尽管服务发现并不需要共识，但领导者选举却是如此。因此，如果你的共识系统已经知道领导是谁，那么也可以使用这些信息来帮助其他服务发现领导是谁。为此，一些共识系统支持只读缓存副本。这些副本异步接收共识算法所有决策的日志，但不主动参与投票。因此，它们能够提供不需要线性一致性的读取请求。

**成员资格服务**

ZooKeeper 和它的小伙伴们可以看作是成员资格服务（membership services）研究的悠久历史的一部分，这个历史可以追溯到 20 世纪 80 年代，并且对建立高度可靠的系统（例如空中交通管制）非常重要【110】。

成员资格服务确定哪些节点当前处于活动状态并且是集群的活动成员。正如我们在 [第八章](https://vonng.gitbook.io/vonng/part-ii/ch8) 中看到的那样，由于无限的网络延迟，无法可靠地检测到另一个节点是否发生故障。但是，如果你通过共识来进行故障检测，那么节点可以就哪些节点应该被认为是存在或不存在达成一致。

即使它确实存在，仍然可能发生一个节点被共识错误地宣告死亡。但是对于一个系统来说，知道哪些节点构成了当前的成员关系是非常有用的。例如，选择领导者可能意味着简单地选择当前成员中编号最小的成员，但如果不同的节点对现有的成员都有谁有不同意见，则这种方法将不起作用。


# 程序数据流静态分析指北

> 本文整理自南京大学（NJU）《软件分析》，包含 Intro、IR、应用三部分。
>
> [南京大学 Static Program Analysis](https://tai-e.pascal-lab.net/lectures.html)

## Introduction

### 莱斯定理（Rice's Theorem）

> "Any non-trivial property of the behavior of programs in a r.e. language is undecidable"

不存在 *Perfect static analysis*

### Sound & Complete

| Sound           | Truth                               | Complete         |
| --------------- | ----------------------------------- | ---------------- |
| Overapproximate | All possible true program behaviors | Underapproximate |

报告范围 *Sound* 会更多，为了保证程序可靠，会尽可能多地报 bug，但是可能存在误报。*Complete* 为了确保不“冤枉”程序，会保证所报的 bug 均是确切的 bug，从而导致漏报。

几乎所有的静态分析都是 *Sound*，如图：

![](/files/dvm9blESYjagJHe5pk2u)

图中仅分析了蓝色路径会得到错误的结论 —— *Safe Cast*，而 *Sound* 需要考虑到所有的情况进而得到 *Not Safe Cast* 的结论。

***Useful Static Analysis*****&#x20;—— 在&#x20;*****Sound*****&#x20;的前提下，精度与速度间进行有效平衡。**

### Abstraction & Over-approximation

​ *abstraction* 是把具体域映射成抽象域。e.g. 抽象域可以包括：

$$
+、 -、 0、 \top\text{(unknown)}, \bot\text{(undefined)}
$$

，*over-approximation* 再对抽象域进行运算关系 (transfer functions) 与控制流 (control flow) 的近似。

![](/files/oVzYLBu6c4BcnMyGK2ok)

## Intermediate Representation

### Compiler

![](/files/PVW1fUtodjdrWOLyCJo1)

### AST vs. IR

```c
// do i = i + 1; while (a[i] < v)

IR 三地址码
1: i = i + 1
2: t1 = a[i]
3: if t1 < v goto 1
```

| AST                     | IR      |
| ----------------------- | ------- |
| 高级并贴合 grammar structure | 低级并接近汇编 |
| 依赖不同的编程语言               | 和其他语言无关 |
| 缺乏控制流信息 e.g.不能清晰看出循环    | 包含控制流信息 |

### 3-Address Code(3AC)

右侧只能有一个操作符，e.g.

```c
c = a + b + 3 => t1 = a + b; c = t1 + 3;
```

地址（*Address*）可以由以下部分组成:

* Name: a, b, c
* Constant: 3
* Compiler-generated temporary: t1

#### 常见的三地址码形式

```c
x = y bop z
x = uop y
x = y
goto L
if x goto L
if x rop y goto L

/**
x, y, z: addresses
bop: binary arithmetic or logical operation
uop: unary operation (minus, negation, casting)
L: a label to represent a program location
rop: relational operator (>, <, ==, >=, <=, etc.)
goto L: unconditional jump
if … goto L: conditional jump
**/
```

### Static Single Assignment(SSA)

* Give each definition a fresh name
* Propagate fresh name to subsequent uses
* Every variable has exactly one definition

```c
// 3AC
p = a + b
q = p - c
p = q * d
p = e - p
q = p + q

// SSA
p1 = a + b
q1 = p1 - c
p2 = q1 * d
p3 = e - p2
q2 = p3 + q1
```

### Control Flow Analysis

* Usually refer to building Control Flow Graph (CFG)
* CFG serves as the basic structure for static analysis
* The node in CFG can be an individual 3-address instruction, or (usually) a Basic Block (BB)

#### Basic Blocks(BB)

Basic blocks (BB) are maximal sequences of consecutive three-address instructions with the properties that.

* 入口为第一个指令
* 出口为最后一个指令

> 禁止中途进出

![](/files/kSOdXC61s5YUut0N4LWl)

#### Control Flow Graphs(CFG)

* The nodes of CFG are basic blocks
* There is an edge from block A to block B if and only if
  * There is a conditional or unconditional jump from the end of A to the beginning of B
  * B immediately follows A in the original order of instructions and A does not end in an unconditional jump
* It is normal to replace the jumps to instruction labels by jumps to basic blocks

## Data Flow Analysis

> How Data Flows on CFG

Data-flow analysis is to find a solution to a set of *safe-approximation-directed constraints* on the IN\[s]’s and OUT\[s]’s, for all statements.

* constraints based on semantics of statements (*transfer functions*)
* constraints based on the *flows of control*

### Notations for Constraints

#### Transfer Function

![](/files/2Xuvpo8swUKUOTJ5nWxp)

#### Control Flow

![](/files/i5J0zntqZnNH81mm3dFm)

### Applications

#### Reaching Definitions Analysis

**基本概念**

* 假定 x 有定义 d (*definition*)，如果存在一个路径，从紧随 d 的点到达某点 p，并且此路径上面没有 x 的其他定义点，则称 x 的定义 d 到达 (*reaching*) p。
* 如果在这条路径上有对 x 的其它定义，我们说变量 x 的这个定义 d 被杀死 (*killed*) 了

![](/files/6aOBLEhsKgA1d6F0PCH0)

在实践中这个算法可以用来检测未定义的变量：e.g. 每一个变量 v 在 CFG 入口会有一个虚拟定义（*dummy definition*），虚拟定义 v 到达使用 v 的某点 p 时，那么 v 可能会在定义前被使用。

**Transfer Function & Control Flow**

![](/files/qkZiwmMI9jghsJMcwx5z)

**Algorithm**

```c
OUT[entry] = NULL;
for (each basic block B(排除 entry)) {
	OUT[B] = NULL;
}
while (changes to any OUT occur) {
	for (each basic block B(排除 entry)) {
		IN[B] = Union(所有前驱 OUT[P]);
		OUT[B] = gen(B) Union (IN[B] - kill(B));
	}
}
```

e.g.

![](/files/53YSKVSUOgcAufyw50Fb)

* 首先让所有 *BB* 和入口的 *OUT* 为空。因为你不知道 *BB* 中有哪些 *D（definition）*。
* 当任意 *OUT* 发生变化，则分析出的定值可能需要继续往下流动，所需要修改各 *BB* 的 *IN* 和 *OUT*。
* 先处理 *IN*，然后再根据转移方程完成更新 *OUT*。
* 在转移方程中，*kill* 与 *gen* 相关的 bit 不会因为 *IN* 的改变而发生改变，而其它 bit 又是通过对前驱 *OUT* 取并得到的，因此其它 bit 不会发生 1 => 0 的情况。所以，*OUT* 是不断增长的，而且有上界，因此算法最后必然会停止。
* 因为 *OUT* 没有变化，不会导致任何的 *IN* 发生变化，因此 *OUT* 不变可以作为终止条件。我们称之为程序到达了不动点（*Fixed Point*）

#### Live Variables Analysis

**基本概念**

* 变量 x 在程序点 p 上的值是否会在某条从 p 出发的路径中使用
* 变量 x 在 p 上活跃，当且仅存在一条从 p 开始的路径，该路径的末端使用了 x，且路径上没有对 x 进行覆盖。

> 在被使用前，v 没有被重新定义过，即没有被 kill 过。

![](/files/S1zBi6ikvJ3KgxJbrojV)

这个算法可以用于寄存器分配。

**Transfer Function & Control Flow**

![](/files/Pb0CyG5T4y6hANGGUfT6)

**Algorithm**

```c
IN[exit] = NULL;
for (each basic block B(排除 exit)) {
	IN[B] = NULL;
}
while (changes to any IN occur) {
	for (each basic block B(排除 exit)) {
		OUT[B] = Union(所有后继 IN[S]);
		IN[B] = use(B) Union (OUT[B] - def(B));
	}
}

```

e.g.

![](/files/inB2aCkJ4OWl8z0eDR5R)

* 考虑 *BB* 及其后继 *S*。若 *S* 中，变量 *v* 被使用，那么我们就把 *v* 放到 *S* 的 IN 中，交给 *BB* 来分析。
* 在一个 *BB* 中，若变量 *v* 被使用，那么我们需要添加到我们的 IN 里。而如果 *v* 被定义，那么在其之上的语句中，*v* 都是一个非活跃变量，因为没有语句再需要使用它。
* 对于转移方程，IN 是从后继 OUT 中删去重新赋值的变量，然后并上使用过的变量。在同一个 *BB* 中，变量 *v* 的 def 先于 use ，那么实际上效果和没有 use 是一样的。

#### Available Expressions Analysis

**基本概念**

* x + y 在 p 点可用的条件：从流图入口结点到达 p 的每条路径都对 x + y 求了值，且在最后一次求值之后再没有对 x 或 y 赋值

如果一个表达式上次计算的值到这次仍然可用，我们就能直接利用其中值，而不用进行再次的计算。

**Transfer Function & Control Flow**

![](/files/VrgPs5eS1Kdaj7XC2klb)

**Algorithm**

```c
OUT[entry] = NULL;
for (each basic block B(排除 entry)) {
	OUT[B] = ALL;
}
while (changes to any OUT occur) {
	for (each basic block B(排除 entry)) {
		IN[B] = Merge(所有前驱 OUT[P]);
		OUT[B] = gen(B) Union (IN[B] - kill(B));
	}
}
```

e.g.

![](/files/mShrqZO11zjhAodVl6L5)

一开始确实无任何表达式可用，因此被初始化为空集是自然的。其它 *BB* 的 OUT 被初始化为全集是因为当 CFG 存在环时，一个空的初始化值，会让取交集阶段直接把第一次迭代的 IN 设置成 0，无法进行正确的判定了。

#### Analysis Comparison

|                   | Reaching Definitions              | Live Variables                    | Available Expressions             |
| ----------------- | --------------------------------- | --------------------------------- | --------------------------------- |
| Domain            | Definitions                       | Variables                         | Expressions                       |
| Direction         | Forward                           | Backward                          | Forward                           |
| May/Must          | May                               | May                               | Must                              |
| Boundary          | `OUT[Entry] = NULL`               | `IN[Exit] = NULL`                 | `OUT[Entry] = NULL`               |
| Initialization    | `OUT[B] = NULL`                   | `IN[B] = NULL`                    | `OUT[B] = ALL`                    |
| Transfer Function | `OUT = gen() Union (IN - kill())` | `OUT = gen() Union (IN - kill())` | `OUT = gen() Union (IN - kill())` |
| Meet              | Union                             | Union                             | Merge                             |


# MySQL 实战技巧

《MySQL 实战 45 讲》学习笔记与整理总结

## 原理篇

### 1、一条 SQL 查询语句是如何执行的

Server 端：所有存储引擎共享通用部分，相当于对各种存储引擎的统一管理，对外提供统一的接入接口和查询语言。

* 连接器：管理连接池，进行权限校验。
* 查询缓存：8.0 以后被移除。只要这个表发生了一次更新，就会全部清空所有缓存的结果，有了 Bufferpool 后，这个功能很鸡肋。
* 分析器：对 SQL 查询语法进行词法分析/语法分析。
* 优化器：基于统计信息和代价模型决策索引使用，以此生成执行计划。
* 执行器：调度存储引擎，执行具体的执行计划。

存储引擎端：

* MyISAM：不支持事务/不支持行锁，性能较低，已逐步被 Innodb 所替代。
* Innodb：支持事务/行锁等，是主流的高性能存储引擎。
* Memory：内存存储，可以用于创建内存临时表。

### 2、存储引擎中的基础日志模块

* 最初 MySQL 只有一个日志就是 binlog，记录了所有的数据变更操作，如数据的更新/插入/删除等，这种操作流水被用来做主从之间的用户操作记录同步，或者用于备份与数据的恢复。在主从复制常见下，binlog 被后端的 IO 线程同步到从服务器上，作为 relaylog 中继日志，然后再被从服务器上的 SQL 线程执行，实现主从数据的同步，但这是最外层 server 端的日志，Innodb 之所以被如此的推崇，主要是内部使用了大量的缓存操作，大幅的减少随机磁盘 IO 读写所到来的性能耗时。
* Innodb 内部大量使用的缓存设计，为了加速读性能，设计了 BufferPool，通过预读机制将数据页批量加载到内存里。当下次请求命中了 BufferPool 的数据就直接返回，而避免了磁盘随机读 IO。同时针对不在缓存中的数据写入操作都会预先写到 ChangePool 中实现写操作的异步化，减少写操作时的磁盘随机读 IO 的次数。
* 在 BufferPool 的基础上，针对数据变更操作也会将变更的数据缓存到 BufferPool 中，这就带来了脏页的问题——即磁盘文件的数据内容和内存中的不一致。此时需要一种将内存数据同步到磁盘中的机制，因此 BufferPool 中新增了 LRU 链/Flush 链等用于持续后端刷新脏页到磁盘上。
* 这种数据刷新是异步的，若服务发生了故障重启则 BufferPool 中的数据就有清空丢失的风险。为了避免数据丢失，Innodb 基于 WAL 机制和高性能的磁盘顺序写机制为服务带来 crash-safe 的能力，将数据的变更后的值都先顺序写到一个 redolog 里，用于服务重启时恢复 BufferPool 未同步到磁盘中的变更数据。
* redolog 是顺序写，本身性能很高，但为了进一步的优化性能 Innodb 引入批量组写入，即未提交的变更先不直接写到磁盘上，而只是放在一个 redobuffer 的缓存里，当事务提交时，将 redobuffer 刷到磁盘 redolog 日志文件里。
* Innodb 还支持事务的能力，那么就需要一个回滚日志去记录历史版本数据，undolog 中记录了每行数据的历史版本记录，以便做回滚操作。因此在一次数据更新中，存储引擎从磁盘中加载旧数据，然后将旧数据写到 undolog 中，之后将变更后的数据缓存在 BufferPool 并将操作记录在 redolog 和 binlog，只有两个日志都写成功了，整个更新事务才算提交成功。

### 3、事务隔离

事务隔离，本质是个多事务并发执行冲突问题，常见的有并发问题有脏写/脏读/不可重复读/幻读：

* 脏读：事务 A 对数据行做的操作还未提交，但已经被事务 B 读取了，但稍后事务 A 因执行失败而发生了回滚，但事务 B 未感知到，所使用的数据还是未提交的数据。
* 脏写：事务 A 对数据行 X 做了更新操作，事务 B 也对数据行 X 做了更新，但事务 A 发生了回滚，直接把事务 B 的更新覆盖了，产生了脏写。
* 不可重复读：事务 A 对同一行数据的读取结果不同时间值不一致。
* 幻读：特指范围或者新数据插入的问题，即范围查询时，同样的语句由于新插入了数据，导致读取到的数据集合不一致。

解决方案：

* RU 读未提交：可以读取到其他未提交事务的变更结果。
* RC 读提交：只能读取到其他提交事务的变更。
* RR 可重复读：不管有没有其他事务的提交，统一事务中的读取的内容都是一致的。
* Searalized：串行执行，无多事务并发问题。

运行时间比较长，长时间未提交的事务就可以称为长事务。长事务容易造成的影响：

* 并发情况下，数据库连接池容易被撑爆。
* 锁定太多的数据，造成大量的阻塞和锁超时。
* 执行时间长，容易造成主从延迟。

造成长事务的原因：

* 启动不当容易造成长事务：set autocommit=0 关掉了自动提交容易导致意外的长事务长链接，建议 set autocommit=1，以通过显示语句来启动事务。
* 操作的数据比较多。
* 事务中有其他非 DB 的耗时操作。

### 4、Innodb 索引模型

常见索引：

* 哈希索引：无法进行范围查询。
* 二叉树索引：数据量大时树层数高，需要多次磁盘 I/O。
* 有序数组索引：数据变更时需要大规模移动数据。
* B+ 树索引：N 叉树 + 有序数组。

主键索引 vs 非主键索引：主键索引的叶子结点是整行数据，非主键索引（二级索引）的叶子节点是主键 ID。这两种数据的组织方式就对查询实现有影响。二级索引需要先查到主键 ID，然后回表去主键索引再做一次查询才能取到具体的数据。

为什么主键 ID 通常自增：如果新插入的数据不是有序的而是任意值，就可能插入到任意的一个数据页中。若数据页已满就会触发页分裂，这将大大降低插入的性能。因此一般要求主键索引的 ID 有序递增的，以避免页分裂问题。

查询执行计划优化：索引能够大幅提升查询的性能，所以在查询时命中索引的查询才是好的查询。一般使用 Explain 对 select 语句的执行计划进行解析，以避免出现全表扫描的问题，而所谓查询优化也只是针对当前的查询语句尽可能的让其走索引查询，以此提升查询性能。

覆盖索引：普通查询通过会有回表二次查询操作，而有些索引已经包含了查询所需要的所有字段例如主键 ID，不需要再进行二次的回表查询才能拿到数据。这类查询方式就被称为覆盖索引，覆盖索引也是我们查询优化的目标，是个常见的性能优化手段。

联合索引：将多个字段的值拼接在一起形成的索引，这种联合索引本质上还是按 key 有序排列的索引，不过 key 是多个字段按照指定的顺序拼接到一起的字符串。联合索引除了支持等值匹配规则外还支持最左部分匹配，即最左侧的列在 key 的最左侧部分。在进行有序行判断时，也可以只对最左侧部分匹配的值查询索引，缩小扫描数据行的范围。

索引下推：对索引根据条件过滤后的数据行不直接去做回表，而是利用联合索引包含的其他字段提前筛选以减少回表的次数。

比如 `select * from tuser where name like '张%' and age=10 and ismale=1;`

基于 name 字段的最做前缀法则，命中 name\_age 的联合索引，但由于是模糊查询所以只能使用 name 这一个字段做过滤，但索引中还有 age 字段数据，过滤条件同样有 age 参数，就可以基于该联合索引进行索引下推，减少回表次数。

几个常见的索引使用原则：

* 全值等值匹配规则
* 最左侧列匹配规则：查询条件中没有包含联合索引全部的字段列，只有最左侧的部分列，也是可以命中该索引的。
* 最左前缀匹配原则：如果使用 like 语法来查，且查询值最左前缀值是确定的，模糊匹配符% 在右面也是可以命中索引的，但模糊匹配符在最左侧是无法命中索引的。
* 等值匹配和范围匹配索引：只有第一个范围查询字段才能命中索引。

综上：一般我们如果写 SQL 语句，都是用联合索引的最左侧的多个字段来进行等值匹配 + 范围搜索，或者是基于最左侧的部分字段来进行最左前缀模糊匹配，或者基于最左侧字段来进行范围搜索，才能使用上建立的联合索引。

### 5、全局锁和表锁：给表加个字段怎么有这么多阻碍？

全局锁：给整个数据库实例进行加锁，即整个数据库都不能再执行任何更新操作，包括数据更新语句和表结构的更新语句，进入只读状态。

全局备份：如果不加锁，数据库还在动态变更中，容易出现数据不一致。加全局锁 `flush table with read lock（FTWRL）` 就成了一个保证一致性的解决方案，但这种方案风险太大。用主库的话整个业务除了读取其他操作都停摆了，用从库的话无法从主库同步最新 binlog 的操作，造成严重主从延迟。

优化方案：`mysqldump --single transaction` 在做备份时启动一个一致性视图来完成，背后机制是 Innodb 支持的 MVCC。

表级锁：

* 表锁：做数据更新操作对整个表实例加锁，锁力度太大基本不用，除了 MyISAM 这种不支持行锁的引擎需要。
* MDL 元数据锁：对表结构进行变更时需要加 MDL 写锁，如增加列/添加索引。默认情况下，数据的增删改查都会加 MDL 读锁，MDL 读锁和写锁互斥，所以当对表结构做变更加 MDL 写锁时需要注意对数据读写的影响。如对表加索引时，会触发全表扫描耗时较长，成为一个大事务。在此期间该表的读操作需要先加 MDL 读锁，但加不上就会一直阻塞直到超时。

如何给表安全的实现在线添加索引修改字段：

1. 创建 ghost 临时新表，表结构和目标表相同。
2. 新表直接执行用户提交的 alter 语句，完成表结构更新。
3. 分批次迁移原表的全量数据到新表中，同时解析 binlog 事件日志，将数据同步期间新增的数据同步到新表里。
4. 完成数据全量同步后，再通过 rename 语句替换老表。

### 6、行锁功过：怎么减少行锁对性能的影响？

行锁：支持行数据粒度加锁，Innodb 支持但 MyISAM 不支持。对于不支持行锁的引擎来说，当对行数据做更新时需要加表级锁，业务数据更新并发度低。而 Innodb 支持行锁，业务并发度就比 MyISAM 高。这也是为什么 Innodb 能替代 MyISAM 的重要原因，因为性能更好。

两段锁协议：事务执行过程中加的行锁都必须要等到事务提交后才能释放。事务越长加锁时间也越长，其他事务的阻塞时间越长，那么加锁时就需要考虑如何尽可能缩短行的加锁时间。可以尽可能的将加锁操作放在事务的尾端，同时为了减少锁对其他事务的影响，应该将可能存在锁冲突影响锁的并发度的操作往后放。

两段锁协议下，大事务锁加的锁持有的时间长，容易导致锁冲突和死锁问题，这也是大事务的另一个弊端。

* 如何减少锁冲突：将热点数据拆分成多个片段，如一个国家的 GDP 总数拆分成多个省 GDP 之和，不同省的 GDP 只去对该省对应的行进行加锁，而不会都去竞争一个总 GDP 数据行锁。
* 如何解决死锁：设置锁超时时间/死锁检测。

### 7、事务到底是隔离还是不隔离的？

可重复读的内核是 MVCC，而 MVCC 本质是 undolog 历史版本链 + ReadView（视图）。

MVCC 就是将 Readview + undolog 快照链相结合的方式保证事务不会读到并行执行事务更新值的机制。通过 MVCC 可以判定当前版本的数据是否对当前事务可见，不可见则沿着快照链的 roll\_pointer 回溯到上一版本，直至找到可见的版本返回。因此实现了在多事务并发下的可重复读的效果。

undolog 如何记录数据的版本链：

* 例如在缓存页中执行了一个 insert 语句，那么对应的这行 undolog 内容就是主键 + 对应的 delete 操作，以实现把这次的 insert 操作回退。
* Innodb 为每行数据引入两个隐藏字段：trx\_id + roll\_pointer。trx\_id 标记操作该行的事务 ID，roll\_pointer 则代表 undolog 的回滚记录。

undolog 日志什么时候删除：当不再需要了就会删除，什么时候不需要了呢？事务提交了，不需要这些回滚日志做回滚操作了，那么回滚日志就会被删除。

为什么建议尽量不要使用长事务：长事务意味着系统里面会存在很老的事务视图。由于这些事务随时可能访问数据库里面的任何数据，所以这个事务提交之前，数据库里面它可能用到的回滚记录都必须保留，这就会导致大量占用存储空间。

Readview 机制：视图就是对当前事务对某行数据可见的版本集合，这里面有几个关键的版本号组成，首先是未提交事务的 ID 集合——记为 m\_ids。m\_ids 集合中的最小值为 m\_min\_id、最大值为 m\_max\_id，以及当前事务的 cur\_trx\_id。m\_ids 就定义了当前事务可见的版本集合。

* 小于 m\_min\_id 的，那肯定代表在创建当前事务时变更该行的事务已经提交了，因此可见。
* 大于 m\_max\_id 的，那肯定代表在创建当前事务时变更该行的事务还没创建，必然不可见。
* 在 m\_min\_id 和 m\_max\_id 之间的：
  * 先看是否是当前当前事务 ID，是的话代表是当前事务所做的变更，自然可见。
  * 若不是当前事务则判断是否在 m\_ids 集合中：
    * 在 m\_ids 集合中说明这个版本在创建前已创建但未提交，因此不可见。
    * 不在 m\_ids 中，代表已提交，可见。

可重复读和读提交各自的实现原理：

* 可重复读在事务开始时就创建了一致性读视图，且在允许过程中时读视图时不变的，所以事务只能看到在事务启动前完成提交的数据。
* 读提交是在每次语句启动前才创建读视图，所以只要在语句执行前完成提交的数据都能看到，即当前读。

### 8、普通索引和唯一索引的区别概述？

索引是对列数据的有序组织结构， 普通索引和唯一索引在定义上来说只是列数据中是否允许重复值的存在：

* 唯一索引要求值的唯一，其包含的数据连续递增，同时在插入前需要去索引中判断值是否已存在。
* 普通索引允许重复值，在插入时直接找到位置后插入数据即可。

这一点小小的是否判重的区别对于读操作影响不大，Innodb 在读数据时会先从 BufferPool 中加载，如果没有命中再去磁盘里读取，且读取的最小单位是数据页，即使是普通索引，也是读取相同的数据页，然后放在 BufferPool 里。但是对插入操作影响较大，Innodb 中有提升写性能的 change buffer。对于普通索引，因为无需唯一性判断，插入的数据会直接写到 change buffer 中，然后写操作就算是完成并结束了。但对于唯一索引则不一样，唯一性的束缚要求先做唯一性判断，因此需要先加载索引数据页到内存里，这其中就涉及到耗时的磁盘随机读 IO 操作。

第一种情况是，**这个记录要更新的目标页在内存中**。这时，InnoDB 的处理流程如下：

* 对于唯一索引来说，找到插入的位置，判断到没有冲突，插入这个值，语句执行结束；
* 对于普通索引来说，找到插入的位置，插入这个值，语句执行结束。

这样看来，普通索引和唯一索引对更新语句性能影响的差别，只是一个判断，只会耗费微小的 CPU 时间。

第二种情况是，**这个记录要更新的目标页不在内存中**。这时，InnoDB 的处理流程如下：

* 对于唯一索引来说，需要将数据页读入内存，判断到没有冲突，插入这个值，语句执行结束；
* 对于普通索引来说，则是将更新记录在 change buffer，语句执行就结束了。

因此唯一索引和普通索引最大的区别在于唯一索引有值唯一性的检测，无法使用 change buffer 来起到加速的效果，所以写多读少场景应该优先使用普通索引。但在先写后读的场景中，这个 change buffer 反而会成为多余的，因为数据肯定会读磁盘，反而增加了 change buffer 的维护成本。

InnoDB 中对于性能的提升机制主要是设计了 BufferPool 和 change buffer 两大缓存机制，BufferPool 将磁盘读转换成了内存读，大幅提升了读操作的性能。change buffer 将随机磁盘写入转换成了内存异步写入，缓存会通过后台 IO 线程写到磁盘中。

**redo log 主要节省的是随机写磁盘的 IO 消耗（转成顺序写），而 change buffer 主要节省的则是随机读磁盘的 IO 消耗。**

## 性能篇

### 1、MySQL 为什么有时候会选错索引？

要知道查询语句选择了那个索引可以使用 explain，它会输出一个具体的查询执行计划，包括查询是否走了索引、走的那个索引、使用到了那些查询条件、预估扫描了多少行等等信息。

优化查询执行计划就是做的这么一件事：让查询语句走该走的索引，只要走了索引性能一般不会太差，也能避免全表扫描等慢查询问题。所以新增的查询语句使用 explain 是个好习惯。

执行计划的分析流程有优化器负责，优化器中的分析逻辑是**统计信息 + 代价模型**

* 优化器的选择索引的目标是找到最优或者性能最好的索引。
* 那么问题就转换成了：影响一条查询语句的核心要素是什么？
  * 扫描行的数量：扫描行数越多，代表需要访问磁盘的次数越多，耗时自然越长
  * 是否有回表操作，回表的话其实扫描还需要 2 次查询。在同一数量级下，覆盖索引比回表查询性能更好。
  * 还需要考虑查询语句是否包含了排序、分组操作，有没有使用到了临时表等操作，来进行综合的判断。
* 扫描行的估算流程
  * 通过一个列中的数据的分布情况来估计列的区分度，用基数表示。基数越大代表区分度越强，相对应的就是某个值所存在的行数越少，基数越小区分度低，值的重复度高，那么包含一个值的行数就越多，扫描行自然越多。
  * 基数通过采样统计，即默认选择 N 个数据页，去统计值的分布，来代表整体统计信息。
* 基于统计信息的基础上还需要考虑代价模型，代价模型综合将排序、分组、临时表等因素都加入进来进行综合判断。

索引选错的情况：

* 采样出来的统计信息不准：如偏大导致估算扫描行时估多了，就可能选择错误的非最优索引。
  * 如何解决：
    * force index——强制索引
    * analyze table t——重新采样
* 加了 order by 语句会倾向评估排序的耗时代价要远比扫描行大，就倾向使用 order by 字段所在的索引，即使使用 order by 的索引要扫描的行数原比其他索引大。

### 2、怎么给字符串字段加索引？

1. 直接创建完整索引，这样可能比较占用空间。
2. 创建前缀索引，节省空间，但会增加查询扫描次数，并且不能使用覆盖索引。
3. 倒序存储，再创建前缀索引，用于绕过字符串本身前缀的区分度不够的问题。
4. 创建 hash 字段索引，查询性能稳定，有额外的存储和计算消耗，跟第三种方式一样，都不支持范围扫描。

### 3、为什么我的 MySQL 会“抖”一下？

利用 WAL 技术，数据库将随机写转换成了顺序写，大大提升了数据库的性能。但是，由此也带来了内存脏页的问题。脏页会被后台线程自动 flush，也会由于数据页淘汰而触发 flush，而刷脏页的过程由于会占用资源，可能会让你的更新和查询语句的响应时间长一些。

其中 flush 的效率是受到磁盘 IO 能力的限制的，通过 innodb\_io\_capacity 来控制。如果设置比实际 IO 小，那么就会导致刷脏页速度还不如产生脏页的速度。

* BufferPool 是缓存，不仅有查询缓存数据页，也有更新缓存变更数据页，这部分变更的数据页还没更新到磁盘文件中，与磁盘文件上的数据内容不一致，被称为脏页。但这部分数据肯定要写回到磁盘中。正常情况下是有异步 IO 线程持续写回。但有时候查询数据量过大或者写入操作过多导致 BufferPool 不够用了，那么就会触发大量 flush 操作刷盘。
* 什么情况会刷会脏页：
  * redolog 满了，需要强行将 redolog 做释放，并且将对应的 BufferPool 中的脏页 flush 回去。
  * BufferPool 满了：需要腾出新的空间来。
  * MySQL 关闭：全部刷回去。
  * MySQL 空闲：见缝插针刷一下

### 4、为什么表数据删掉一半，表文件大小不变？

数据行的删除并不会释放已经占有的磁盘空间，而只是将这部分空间标记为可用，即可以用来插入新的数据。如果只是删除一行数据，复用度较低，只能在原数据行范围内的数据才能插入进来。但若是整个数据页的数据都被删除了，那么整个数据页就没有数据限制，可以被任意支配。也就意味着数据的存储并非是连续的，而是存在着大量存储空洞或者碎片。这种空洞，不仅会因为删除操作引入，插入数据中的页分裂也会带来这种空洞。当数据的 ID 是随机时，容易因为某一索引页放不下而触发页分裂。

如何压缩表空间的容量：

* 对表进行真正的删除，如设置每张表都是单独的文件，将 innodb\_file\_per\_table = on，那么每张表都对应一个单独的物理文件，删除表所有数据时，只需将对应的文件删除掉就能将该部分空间回收。
* 原表存在大量的空洞，数据页之间不连续，那么可以新建表然后将当前表的数据都迁移过去让数据更加紧凑。这种重建表也是一种全表备份，表备份分为 offline 和 online。所谓离线就是在备份期间不能再对数据进行更新，另外一种是在线，即允许表的更新，online 一般是通过临时表的替换来实现的。

### 5、count(\*) 这么慢，我该怎么办？

不同存储引擎对 count 的实现是不同的，MyISAM 缓存了一个总数，直接将这个值返回（前提是没有过滤条件），而 InnoDB 是全表扫描累积计数。count 操作随着表的大小上涨，其耗时也会上涨。

InnoDB 不缓存总数的原因：InnoDB 支持事务，且 MySQL 默认隔离级别是可重复读，存在快照视图，那么不同的事务看到的数据集合就不一样。所以每次查询时都需要扫描累加。

InnoDB 针对 count 的一些优化：

* 使用最小的普通索引而非主键索引进行扫描，毕竟无论那个索引的计数结果都是一样的。
* 效率：count(字段)\<count(主键 id)\<count(1)≈count(\*)。
* 对于 count(字段)：返回的是不为 null 的行数。

如何在应用层实现更快速的 count：

* 加 Redis 缓存，每次新增或删除操作时都将修改总行数，这就带来两个问题：
  * 一致性问题，Redis 加了数但还没真正的写入到数据库中，或者写到了数据库但还没更新 Redis。
  * 不支持事务。
* 保存在 MySQL 中，通过事务来保证一致性问题。

### 6、order by 是如何工作的？

order by 字段的排序其实是可以避免排序操作的，会优先走索引，就不需要再做排序操作了。

如果没有命中索引，即无法复用索引的有序性，而原数据字段列又是无序的，MySQL 就只能执行排序操作了，而排序是个耗时的重流程，所以这也是为什么我们有时候看到执行计划中的索引选择的与预设的不符合，预设的是扫描行数少的那个索引，但优化器却选择了 order by 中字段所命中的索引。

当通过 explain 查看执行计划时若出现了 Using filesort 就表明执行了排序操作。排序是个耗时的操作，当数据量过大时，内存无法放下全部数据，就得基于磁盘进行归并排序，先将每个磁盘子文件内部排好序，再归并在一起。

排序是在一块内存区域里进行的，叫做 sort\_buffer，即 MySQL 会把排序的数据都加载这块内存区域里，再执行排序算法，具体的数据加载有两种模式：

* 将用户需要的行字段都加载进去，这样子排序完后直接选取前 N 条数据返回，这种就是全字段排序。
* 另外一种是 rowid 加载方式，只需将待排序的字段值和数据行的唯一标识 rowid 加载到 sort\_buffer 中，这种加载方式能够减少排序内存空间的占用，但缺点就是排序后还得再根据 rowid 做回表查询。

但如果内存实在不够用了，毕竟分配的 sort\_buffer 的大小是有限的（由 sort\_buffer\_size 指定），那只好开启磁盘排序了，将放不下的部分数据放在磁盘里。

排序的具体流程：在 sort\_buffer 设置字段 => 将查询条件过滤后的行数据都写入到 sort\_buffer 中（全字段排序未必需要从主键索引中取完整数据行，若覆盖索引的查询方式，可以直接从索引中取出所有所需要的字段）=> 排序 => 截断返回

### 7、如何正确地显示随机消息？

如何借助内存临时表来进行排序：

* explain 的 extra 输出的是 Using temporary & Using filesort 即通过临时表做排序操作。
* 临时表分为内存临时表和磁盘临时表，内存临时表用 memory 引擎来创建，磁盘临时表用 InnoDB 引擎创建。
* 加入临时表的排序逻辑和 order by 一样，只是在将数据放到 sort\_buffer 前会先创建一个临时表存放临时生成的中间数据值，例如基于随机数排序。数据都导入到临时表后，后续的排序流程就和之前一致了，如果使用的是内存临时表那么 rowid 排序会更有优势。

随机数排序用到了排序 + 临时表操作，耗时重，所以要避免。另外一个设计原则是可能将业务逻辑放在业务系统中做，而非数据库系统。

截断后的数量非常少怎么办：针对只需要前三的情况，使用只包含 3 个元素的最小堆（优先队列），因为不需要将全部队列都排好序后在得到前三名，只需要前三名。

若 SQL 语句是 limit 1000，如果使用优先队列算法的话，需要维护的堆的大小就是 1000 行，很容易超过了设置 sort\_buffer\_size 的大小，所以只能使用归并排序算法。

### 8、为什么这些 SQL 语句逻辑相同，性能却差异巨大？

**索引字段加了函数操作后，索引的有序性被破坏，优化器会放弃走树搜索功能。**

例 1：

* 为了找到所有 7 月的记录，给时间字段索引加了个 month 函数解析出月份，这会触发全表扫描操作。

```sql
select count(*) from t where month(create_time)=7;
```

* 原因：B+ 树索引字段默认是有序的，但函数操作后的有序性无法保证，如 MD5 等这些哈希函数输出的值完全是随机的，此时无法基于索引的有序性做二叉搜索，从而失去了快速定位的能力，只能进行全表扫描。
* 优化：用范围、值转移等方式代替函数操作，
  * 将解析月份的操作改成范围过滤，如 `month(create_time)=7 => (create_time >= '2016-7-1' and create_time<'2016-8-1')…`。

例 2：

* 隐式类型转换，其主要特点是字段值的类型与字段的类型不一致。
* `select * from tradelog where jobId=110717`，但 jobId 字段类型是 varchar。MySQL 有个潜在规则是字符串和数字做比较的话，会将字符串转换成数字。意味着这句 sql 查询语句，实际执行的是 `select * from CAST(jobId as singed int) where jobId=110717`。这个转换实际就是个函数操作，那么就转换成了在过滤字段上添加了函数操作，触发了全表扫描的问题。

例 3：

* 隐式字符编码转换，MySQL 支持多种字符类型，其中 utf8mb4 是 utf8 的超集，当字段类型和值的编码类型不一致时就会发生编码类型转换，转换方向是从小集向超集，那么如果字段类型是小集值的编码类型是超集，就会触发在字段上的编码函数转换操作，导致全表扫描。
* `select * from t1 where t1.tradeid=t2.tradeid` 等价于 `select * from t1 where convert (t1.tradeid using utf8mb4) = t2.tradeid`。需要修改 t2 的 tradeid 字段为 utf8 或者将 t1 的 tradeid 字段设置成 utf8mb4。也可以在 SQL 中将值转换成 utf8。

### 9、为什么我只查一行的语句，也执行这么慢？

本文分析影响 SQL 查询的可能因素，包括等待执行时间长、查询执行时间长。在排查时主要用到 show processlist 去查看当前 SQL 查询线程的状态。

* 等待时间长：这部分强调查询发起后到实际执行中的间隔时间，可能影响因素有等待 MDL 锁、等待行锁、等待 flush 等。
  * 等 MDL 锁：默认情况下 select 语句在执行前会先给表添加 MDL 读锁，避免在查询执行过程中表结构发生变更。表的字段或索引变更前执行器会先添加写锁，如果此时表已经加上了 MDL 写锁，那么后续的 select 查询添加 MDL 读锁时就会被锁住，需要等到释放 MDL 写锁才能继续执行查询语句。
    * 如何优化：
      * 针对加了 MDL 写锁的操作，通过 show processlist 来查看对应的执行线程，然后 kill 掉。
      * 针对字段变更或者加索引操作放在低峰期，或通过 gh-ost 工具创建。
  * 等待行锁：如 select … lock in share mode，本质是通过加锁实现当前读的效果，那么如果待加锁的语句被其他事务加了写锁，那么需要等待其他事务释放掉。如果恰好是个大事务，那么就会出现长时间等待行锁导致查询过慢。
    * 如何处理：如果大事务确认不该存在，如大面积归档数据的删除操作，可以先将该大事务关闭掉，比如 kill 线程 ID（非 kill query ID）。kill 线程 ID 背后的默认逻辑是断开对应的操作连接，连接被断开的时候会自动回滚该事务，并且释放掉对应的锁。
* 执行时间长：
  * 深度分页：如 limit 1 offset 500000，虽然返回只有一行数据，但要扫描 500000 行后才能拿到。平时要注意避免深度分页，比如限制页码的跳转，限制数据的查询范围等。如果实现必须要支持深度分页，由于页码通常是连续点击触发的，可以尽可能记住上次分页的末尾 ID，将深度分页条件加上 ID 过滤条件。
  * 回滚读：在 RR 隔离级别下，如果一个事务执行期间一行数据做了多次变更，那么在这个大事务提交前所有变更的历史记录都会在 undolog 的版本链条中，以方便实现 MVCC 的视图读。但该事务中仅能看到最老的版本（RR 可重复读），那么针对所更改行的读都需要进行回溯版本链，找到自己可见的版本返回该值，在这种多次变更版本链条较长的场景下回溯时间会很长，导致查询时间很长。

### 10、幻读是什么，幻读有什么问题？

* 幻读：多事务并发情况下由于并发写入行导致同一个事务中读取的数据集合内容出现的不一致情况。具体来说，同样的查询语句，但得到的结果集不一样，如上次获取行数是 50，下次总行数是 100 这类多了一些别的事务新增的行的现象。
  * 触发条件：在可重复读的隔离级别下，当前事务是看不到新增的行的，但如果 select 语句改成了 for update 当前读操作，虽然当前访问到的所有行都加了行锁，当每次读的时候都是当前读，会出现结果集新增了新的数据行，即出现幻读问题。幻读特指“新插入的行”。
  * 影响：
    * 破坏锁的语义上一致性：在语义上，for update 会在执行当前读的时候对所有访问到的行都加锁，但实际上只是对**当前**数据库中的满足条件的行都加锁，而满足这些条件的行集合会时刻发生变化，就会导致同一个事务中同一查询条件不同时刻结果不一样，那么实际加锁并未实现“对所有的符合条件的数据行都加上了锁”。
    * 破坏数据上的一致性：事务中锁都是两段锁协议，即在事务中任意位置加的锁，都只能在事务提交时才会被释放。binlog 中记录事务的先后顺序由提交时间决定。事务 A 虽然发起的时间早，但事务 A 最后晚于事务 B 提交，就导致记录在 binlog 中事务 A 的语句排在事务 B 后面，其实就是一种指令或执行语句重排的问题。binlog 中的事务先后顺序会决定数据回放或者从库数据同步中事务的执行顺序，最后基于 binlog 回放出来的数据与主库上会不一致（前提是 binlog 是 statement 格式）。
* 间隙锁：顾名思义是对行锁的补充，既然加锁所有的行都没法阻挡新插入的数据，那么就把数据值之间的缝隙也加上锁，若发现在这些间隙中有插入操作就锁住。间隙锁虽然解决了幻读问题，因为加的锁范围更大了，会导致并发度下降、增大死锁的概率，如并发插入的事务，先加锁后插入，就会因为相互需要同一个间隙锁，导致死锁问题。

### 11、为什么我只改一行的语句，锁这么多？

加锁规则： 两个原则 + 两个优化

* 两个原则：
  * 可重复读隔离级别下加锁的基本单位是 next-key lock，即同时加上间隙锁 + 行锁，next-key lock 是个前开后闭的区间。
  * 查找过程中访问的对象才会加锁，如果扫描的是二级索引，那么锁就加到二级索引上，如果加的主键索引，那么锁就加到主键索引上。
* 两个优化：
  * 等值查询：唯一索引中，会退化为行锁。
  * 等值查询：向右遍历到第一个不满足条件的值时，next\_key lock 就会退化为间隙锁，所以整体加的锁是: `(](](](]()`

### 12、MySQL 有哪些“饮鸩止渴”提高性能的方法？

* 慢查询性能问题的紧急处理方案
  * 索引设计的不好：gh-ost 在线上紧急创建索引。
  * SQL 语句没写好：使用 MySQL 的语句改写规则。
  * MySQL 选错了索引：使用 force index 强制走指定的索引。

## 可靠性

### 1、MySQL 是怎么保证数据不丢的？

MySQL 通过 BufferPool 缓存加速性能后通过 WAL 机制保证数据不丢，只要保证数据变更语句写入到 redolog 和 binlog 并且持久化到硬盘后就能实现故障恢复的能力。

* redolog 是伴随 BufferPool 而生的 WAL 顺序写性能加速技术的实现，将「数据页磁盘随机写」=>「内存 + redolog 日志顺序写 + 数据页后端异步磁盘随机写」。
* 在 WAL 机制下，事务提交不会等待所有数据的变更都持久化到磁盘上，而只是写到内存和 redolog、binlog 日志中就算是完成了。
* binlog 日志和 redolog 日志的视角有个核心的不同，即 binlog 日志和 redolog 日志都记录的是已完成的事务，但这两个日志认为的完成阶段是不一样的。
  * binlog 认为这些事务的变更数据也全都持久化到磁盘上了，包含那些只写到内存 + redolog 中的事务操作，但实际上这部分数据页还没持久化到磁盘上，如果发生了单机故障如重启，那么内存中未刷新到磁盘中的变更数据页就丢失了，但在 binlog 日志的视角又认为这些日志都已经完成了，也不会再关注这些已经提交的事务，所以基于 binlog 不会对这部分数据进行恢复。binlog 用途主要在于主从复制 + 数据恢复。
* redolog 也记录的那些已经提交的事务，但 redolog 知道这些事务所变更的数据页都只是写到了 BufferPool 上，还没有写到磁盘上。所以 redolog 记录的是具体的数据页的变化，当宕机后就基于 redolog 判断对应的变更是否刷到磁盘上了，若没有则恢复该部分数据页的内容。因为 redolog 保存的都是那些还没有刷到磁盘上的事务，所以基于 redolog 会引入一种性能问题：如果 redolog 满了，就需要 redolog 中对应的 BufferPool 脏页都刷盘为 redolog 腾出空间，但这种随机磁盘写是一种重耗时操作。

**总结来说，binlog 中记录的是一种逻辑上完成的事务，而 redolog 记录的是逻辑上完成在物理上未完成的事务。**

redolog 和 binlog 通过两阶段提交机制来保证事务提交的持久性，即先持久化到 redolog 中，再持久化到 binlog 中，最后在 redolog 上 commit 完成两阶段提交。同一个事务在 redolog 和 binlog 上使用 XID 作为关联。

binlog 写入机制：

* 事务是原子的，不管事务中包含了多少条 sql 语句，都是不能拆开的，必须一次性完整记录，所以 binlog 在记录事务时，会先将执行中的事务目前已经执行的语句先记录到 binlogcache 中，每个线程都有自己的 binlogcache，只有当事务提交时才将整个事务一股脑写到磁盘 binlog 日志文件上。
* 但是文件系统为了进行加速写入性能，本身也有个缓存，叫页缓存（os cache），需要写到磁盘上的数据都会先写到页缓存上，然后由页缓存的刷磁盘策略写到文件上。所以这里写入磁盘就分成了两个步骤：flush = write + fsync，write 就特指只是将数据写到缓存上，fsync 指将数据从页缓存持久化到磁盘上。
* sync\_binlog 的值决定了如何调用 fsync，所以 fsync 调用越少性能越高，该策略值影响写入性能。但 sync\_binlog 设置为 0 和 n 都存在宕机重启引发的事务丢失风险。

redolog 的写入机制：

* redolog buffer 是用来缓存每个事务的正在执行的语句。当事务提交后，事务既可以刷新到磁盘的 redolog 文件上也可以继续保留在 redolog buffer 中，其写入流程与 binlog 类似，先写到 buffer 上，然后 write 到 oscache 上，最后 fsync 到磁盘文件上：
* MySQL 的「双 1」配置：binlog 的 sync\_binlog = 1 & redolog 的 innodb\_flush\_log\_at\_trx\_commit = 1。代表每次 binlog 都直接 fsync 到磁盘上，redolog 也直接 fsync 到磁盘上。
* 两阶段提交：先写 redolog 处于 prepare，然后写 binlog，最后 redolog commit。

事务组提交机制：在每次 fsync redolog 时会尽可能的一起 fsync 多个事务，即按组来 fsync 事务。

* 为了每次 fsync 时多带上几个事务，MySQL 采取了拖时间的策略。正常的两阶段提交：写入 redolog => prepare => 写入 binlog => commit。但写入 redolog 的步骤实际是两步——先 write 后 fsync，写入 binlog 也是如此，那么 MySQL 就把写入 redolog 简化为 write，然后就处于 prepare 阶段，紧接着在 binlog 的 write 之后再去进行 redolog 的 fsync。此时 redolog 的 write 和 fsync 之间存在一个时间差，就有可能也有其他的并行事务也完成了 redolog 的 write，那么在 redolog 的 fsync 的时候就也能把其他事务的内容一起持久化到磁盘上，实现有限的 IOPS 却支撑更多的事务。
* 从上文中可以看出 write 和 fsync 之间的间隔会影响每次组提交时包含的事务数量，间隔越大那么每次批量 fsync 的事务越多，性能更好。

WAL 机制是如何提升 MySQL 写入性能的呢？

* 「数据磁页盘随机写」=>「数据页内存写 + 日志顺序写」。
* 组提交机制，大幅降低磁盘的 IOPS，以有限的 IOPS 支持更多的事务。

什么场景下可以设置 MySQL 日志的「非双 1」？通常都是对写入速度要求较高，同时可靠性要求低的场景：

* 从库延迟，加快同步主库的速度，尽量缩短与主库之间的差距。
* 业务高峰期，QPS 压力过大。
* 数据恢复或批量导入数据。

这种场景下就可以设置 sync\_binlog=1000 & binlog\_flush\_log\_at\_trx\_commit = 2 ，减少 fsync 的次数并提升数据的写入性能。

### 2、MySQL 是怎么保证主备一致的？

binlog 除了对数据进行归档外还有更重要的作用 —— 主从复制，这是 MySQL 高可用架构的基础。

主从复制的流程：主从节点建立链接后，会有后端 dump 线程持续不断的将主库的 binlog 日志同步到从节点，从节点的 io 线程收到日志数据后会记录在 relaylog 日志中，再交给专门的 sql 线程对日志中的操作进行回放，实现数据的恢复。

binlog 侧重于是逻辑上的日志，有三种格式：statement、row、mixed

* statement：记录的 sql 语句原文，但同样的 sql 语句在回放后的结果并不一定可能和主库一致，比如前面的幻读引入的数据不一致问题、比如执行计划或选择索引与主库不一样，这是日志格式上影响数据不一致的主要原因，所以线上通常不推荐 statement 格式。
* row：记录的对应数据行变更后的值，推荐线上使用，因为可以准确无误的恢复数据，但有可能日志量较大，因为要记录每一行的变更。
* mixed：是为了优化 row 格式下日志量过大的问题，将 row 和 statement 的格式混在一起，当判断不会出现不一致问题时用 statement，其他情况用 row。不常用。

如何解决双 M 的 binlog 的循环复制？通过判断 serverId 的方式中断循环

* 每行 binlog 日志上都会记录执行主库的 serverId，在同步 binlog 进行数据恢复前会先判断 serverId 是否是本机的 serverId。如果相同就不会再进行处理。当 B 收到 A 的 binlog 时新生成的 binlog 中 serverId 为 A 的 serverId。

### 3、MySQL 如何保证高可用的？

主从数据的一致性准确来说指的是最终一致性，即从库可以准确的恢复出主库的数据，但不能保证是任意时刻都和主库是同步的，存在主从延迟的问题。主从延迟在分布式架构的场景下会影响 MySQL 的高可用性，存在从库数据读的延迟、主从切换下的数据丢失等问题。

为什么有主从延迟？主从复制是有时间成本的， 这个过程包含三步：

1. 主库事务完成并写入 binlog 的时刻 T1；
2. 主库将 binlog 日志内容传输到从库上，耗时主要受到网络的影响，所以网络正常的时候这部分耗时很小，记作 T2；
3. 从库消费 relaylog 进行 sql 回放，不同数据操作的执行时间是不同的，在某些大事务场景下该步骤耗时会非常大，自然导致从库数据版本落后主库较多，该步骤的耗时也是导致主从延迟主要原因，记作 T3；

主从延迟的时间差 用 second\_behind\_master = T3-T1 = （T3-T2）+（T2-T1）表示，其中（T3-T2）就是从库消费 relaylog 的速度， 当小于主库时，一般就会出现了主从延迟问题。

为什么从库回放事务速度会小于主库？影响的因素：&#x20;

* 从库机器配置较差不如主库，当从库读 QPS 较高资源紧张情况下，会对 relaylog 的回放数据写入有影响。
  * 解决方案：做对称部署，主从机器规格相同。
* 从库压力过大：即使主从机器配置相同，但基于读写分离的策略，从库往往承担较大的读请求处理，包括离线分析任务、全量备份，这些导致从库负载过高消耗了大量的 CPU 资源，影响同步速度导致主从延迟。
  * 解决方案：
    * 一主多从部署架构：用多个从库分摊读请求的压力。
    * 通过 binlog 接入外界系统如 ES、Hadoop 等，将复杂查询或者离线统计分析功能从 MySQL 从库剥离出来，减少从库的负载。
* 大事务的影响：比如一个事务执行了 10 分钟，那么从库回放也需要 10 分钟，那么主从数据版本就会延迟 10 分钟。
* 解决方案：分类处理，如 DML 和 DDL 不同类型操作的大事务其处理方式也不同
  * DML 大事务拆成小事务：如将一次性 delete 大量数据拆成多次 detele 少量数据，分批多次提交。
  * DDL 大事务：如大表创建索引等，这类问题的解决方案就是低峰期 + gh-ost online DDL 操作。

主从延迟会对主从切换策略有明显的影响， 针对主从延迟问题有两大处理方向：

* 可靠性优先：优先保主从数据的一致性，即主从延迟不能太大，在一定合理范围内才能发生切换，在切换期间，主库存在短暂不可用的情况，因为需要等从库同步完主库的数据，把数据版本和主库保持一致。
* 可用性优先：就是不管主从是否有延迟，不管是否主从完全同步，先切到从库再说，优先保证线上系统的可用性，这种策略可能会导致切换后数据丢失的问题。
* 怎么发现数据不一致的问题：设置 binlog 为 row，可以很快因为 duplicate key error 等系统报错来快速发现，而其他格式如 statement 可能发生了不一致的问题但不会有任何报错发生。

正常情况下可靠性大于可用性，但若主库都崩了那需要考虑可用性优先了，事后再恢复收据。但不管怎样都会给业务带来有损影响， 所以从这个角度来说，主从延迟的大小对可用性有着主要影响，延迟越大系统数据恢复时间约长，可用性相对来说越低。

### 4、备库为什么会延迟好几个小时？

影响主从延迟的各类因素中存在偶发的如读高峰期压力过大、大事务、网络抖动这种因素，但这种延迟问题从库之后会很快追上来。还有类是持续性的延迟，即从库消费速度持续落后与主库，如配置不行、或者从库并发能力低，这种持续落后与主库就会导致与主库的延迟越来越高。配置不行可以提高配置，从库并发度低就考虑提升从库的并发复制能力，即从库的单线程的 sql\_thread 优化为多线程并行消费 relaylog，从而提升消费速度。

如何提升从库的并发复制能力？

1、主库是如何提升并发度的？

* 行锁，并发度较高

2、从库的并行复制需要考虑哪些问题？

* 并行执行的事务之间互不影响相互独立，避免同时修改同一行。但由于 binlog 日志中记录的事务与实际的执行发起时间不一致（事务需要在执行完后才会记录到 binlog 上，有可能操作同一行的事务先执行，但回放时变成了后执行，执行顺序与主库相反，这种发生指令重排后从库若再将这些事务并行执行，就可能发生从库回放出来的数据与主库不一致的问题。注：特指无间隙锁的幻读问题）
* 同一个事务多个更新只能由一个 sql\_thread 执行不能拆分，即从库并行的最小粒度也是事务。

3、并行复制策略：

* 按表分发：每个 sql\_thread 线程都监听一个绑定了指定表的事务队列，当 coordinator 发现事务 A 更新的表只涉及到表 A，就将其分配表 A 所绑定的事务队列上交由对应 sql\_thread 执行。若出现冲突，如一个事务需要操作多个表，而这些表中还存在未执行完成的队列那么就需要等待，直到这些表只剩下一个 worker 再操作。按表分发策略适合多表均衡的场景，但如果热点表下，其并行复制能力会再回退到单线程模型。
* MariaDB 的基于 redolog 组提交的并行复制策略：模拟主库的并行执行模式
  * 因为事务中的两段锁协议，即锁一旦加上后，只有在事务提交时才会释放，所以如果在主库可以并行执行，从库也能并行执行。
  * 在 redolog 的组中一起提交的事务，存在不会修改同一行的特性。（行锁）
  * 具体实现：
    * 针对 redolog 中同一组提交的事务都有相同的 commitid，记在 binlog 中。
    * 从库每次从 binlog 取出同一 commitid 的多个事务并分发到不同的 sql\_thread 上，并行执行。
    * 同一事务组执行完成后再去取下一事务组。
  * 缺点：
    * 一个事务组提交后才能取下一个事务组，容易被同一事务组内的大事务影响。
* 5.7 后的并行策略：对 MariaDB 的并行复制策略的优化
  * MariaDB 策略的核心是认为同一组的事务会同时处在 commit 阶段，因为这些同时 commit 的事务之间没有锁冲突，自然也不会操作同一行，满足行并行的要求。但其实同时处在 prepare 阶段的事务也同样满足该要求，因为这些表示事务中的多个更新语句都通过了锁冲突的校验，不存在行锁冲突问题，可以并行执行。那么就可以将同时 处在 prepare 的事务也进行并行，提升并发度。
  * 前面的 binlog\_group\_commit\_sync\_delay 控制着可以延迟多久才触发 fsync，增加同时 commit 的事务数量，减少写盘次数。同样类似的，该值也会「制造」同时处在 prepare 的事务数量，增加从库的并发度。

### 5、主库出问题了，从库怎么办？

一主多从有个典型问题 —— 如何进行一主多从的主备切换流程？

* 传统基于点位的主备切换：
  * 点位简单来说就是定位事务位置的坐标，由「所属的日志文件」和「日志文件中的位置」组成，通过匹配点位来确定从库需要同步的事务，当出现主库 A 故障后，会评选出新的主库 A’，然后新主库 A’ 会以故障时间作为点位开始位置，继续同步 binlog。点位同步的问题主要在于点位难以定位，可能同步给从库的事务已经在从库中，而且从库无法识别继续执行该事务，导致出现重复 key 等异常，这类错误在发生主备切换后可以设置忽略但复杂且易出错。
* 基于 GTID 的主备切换：
  * GTID 本质也是定位事务的一个点位
    * GTID：GTID 已经完成提交事务的唯一 ID，因为事务已经提交所以与事务 ID 不同，GTID 是连续的，由 {serverID:transactionId} 组成，每个 MySQL 的节点上都维护着已经提交的事务 GTID 集合。
    * 对于 GTID 可以有两种赋值方式：1、automatic 2、指定值。如果指定的值在 MySQL 节点的 GTID 集合中已经存在则忽略跳过该事务，不再执行。
    * 通过 GTID 就清晰的记录了那些事务已经提交了，无需再执行。
  * GTID 在主从切换中的应用：
    * 当发生主从切换后，新主库 A’ 会和从库的 GTID 集合做差值，找到那些主库上有的但从库还未同步的事务，然后将这些事务中最早的那个 GTID 作为开始点位，逐一同步给从库，如果从库发现 GTID 本机已经存在，就直接跳过不会报错。

### 6、读写分离有哪些坑？

一主多从优势：

* 更高的可用性和可靠性。
* 读写分离，通常业务场景都是读多写少，将读请求分摊到多个从库上，能够有效分摊主库的压力。

读写分离的实现方案：

* 客户端直连方案：少一层中间层，客户端直连从库，进行读写，查询性能好些，
* 添加一层 proxy：中间专门加一层 proxy 层，进行多个从节点的负载均衡，客户端无需感知复杂的主从架构，维护更简单。

主从延迟过期读问题：

* force master 强制走主库：将读请求强制转发到主库上，这种方案其实就失去了读写分离的均衡优势。
* sleep 延迟等待方案：针对先写后读的情况，在读前停顿一下，如停顿 1s，减少读到过期读的概率。
* 判断主从无延迟的方案：主从无延迟的时候再去读数据。若 second\_behind\_master=0 则认为主从无延迟，但误差为秒级。也可以使用主从的 GTID 集合的差集或基于主从的 Master\_log\_file & Read\_Master\_Log\_Pos 值的差来判断，精确度更高。
* semi-sync 方案：当主库的 binlog 事务提交时必须将日志同步到从节点才算整个过程完成，这里同步只是将 binlog 日志从主库网络传输到从库上，但无需等到从库回放完成，从库收到日志后，会给主库一个 ack 消息，而主库只有在收到从库的 ack 后才会给客户端返回「事务完成」的确认。semi-sync 方案还能有效提升主从架构的数据可靠性，因为与普通的异步复制不同，semi-sync 需要将日志同步到从库上才算完成，即从库有着完整的 binlog 日志记录，可以慢慢进行回放。而普通异步复制，可能存在事务还未及时同步给主库，而主库发生故障了，那这部分数据实际上就丢失了。
* 等主库的点位同步 & GTID 点位方案：在查询时我们不需要等待所有的事务都完整同步到位，只需要部分我们需要的指定的事务同步到从库就行了。对于点位同步方案就是等到指定的点位，对于 GTID 就是等到指定的 GTID，在查询的时候把 GTID 加到请求参数中，若从库一直未收到指定的 GTID 事务，有两种处理方案：超时重试或者转发主库。

### 7、如何判断一个数据库是不是出问题了？

**外部检测方法：这些方法只适合小规模或者个人 demo，线上肯定不会等到 MySQL 都无法处理请求才执行主备切换或其他干预**

* select 1 判断只能说明 MySQL 这个进程还在，但主库未必没有问题。例如使用 innodb\_thread\_concurrency 控制线程并发数，当达到上限后，接收到新请求就会进入等待状态。但其满了仍然是可以执行 select 1 的，但此时执行新的表查询是会被 block 的。
* 并发查询 vs 并发连接：
  * 并发连接只是有多个客户端，数量可以有很多，比如几千个，但这些链接未必全部都在执行，有可能已经进入阻塞状态，此时就不会再占用线程了，即不会再占用 inno\_thread\_concurrency 的线程资源了。
  * 并发查询是指活跃的 SQL 线程数，其值是对 CPU 资源能力的限制。当达到上限时，新到的请求或者处理就只能等待了。
* 查表判断：为数据库的健康检测专门新建一个表，用来做表的健康检测。这种查询可以保证查询没问题但无法侦测出更新问题，如磁盘满了或者 binlog 满了可以查询但无法更新。
* 更新判断：找个无关紧要的字段，如时间戳执行更新操作，如果能执行代表更新没问题。

**内部监控方法：这才是靠谱的方法**

* 对数据库的各种系统指标：磁盘 + CPU + 内存 + IO 都进行监控上报，有问题 P0 报警及时处理。

### 8、误删数据怎么办？

怎么删数据：

* delete 语句删除数据行，只是释放对应的数据页可被复用，但无法减少空间。delete 全表是很慢的，要写入 undo log、binlog 等，不如直接 drop table 或者 truncate table。
* drop table、truncate table 语句删除数据表。
  * 无论是删库还是删表，恢复方案都是全量备份 + binlog 日志回放。在回放时通过设置 GTID 来跳过误删的事务语句，避免重蹈覆辙。
  * 正常基于 mysqlbinlog 应用来对 binlog 做数据恢复是单线程的，如何加速呢？
    * mysqlbinlog 可以指定 database，限定只恢复指定的数据库，缩小数据范围。
    * 将待恢复的数据库临时实例伪装成一个从库，然后通过并行复制来加速恢复的过程。
    * 搭建延迟复制备库，正常来说主从延迟是问题，但巧妙利用的话也可以起到意向不到的效果，如作为一种特殊的备库，故意设置比主库延迟半天，如果主库出现问题，就可以基于延迟备库快速恢复完整的数据。
* drop database 删除整个数据库。
* rm 删除整个数据库实例。

最佳的方式：做好权限卡控，没有删表的机会。

### 9、为什么还有 kill 不掉的语句？

两种 kill 方式：

* kill query + thread Id：终止正在执行的语句。
* kill connection + thread Id：connection 可省略，断开这个线程的连接。

kill 发出后并不会达到线程马上停止的效果，而只是唤醒线程告诉它不用再继续干活了，需要终止线程了。开始「执行停止的逻辑」，接收到通知的线程就开始收尾工作，如发现自己持有 MDL 锁或者行锁就先把锁给释放了，如果发现自己事务还未提交，那就回滚下。

专业点来说：线程不是说停就停的，

* MySQL 对 kill 指定的线程会先设置其状态为 killed。
* 再给事务 session 发送终止信号，注意只是个信号。
* session 被唤醒后，继续执行，直到埋点处。
* 线程在执行过程中有很多「埋点」，到了这些埋点后才会对线程的状态进行判断，是否已经被 kill 了，如果发现线程状态被 kill，就开始启动终止退出逻辑。
* 这也就是为什么，kill 的第一步是先唤醒线程，因为不唤醒线程，就无法继续执行到埋点处。
* 语句进入线程终止逻辑，直至终止逻辑完成，整个过程完成后才会真正的退出。

如果 kill 耗时较长，可能原因有

* 终止逻辑较长，如大事务被终止，那么该事务所做的所有更新都需要回滚。
* 服务负载压力过大，导致线程长时间未执行到埋点处。

发送 kill 命令的客户端，并没有强行停止目标线程的执行，而只是设置了个状态，并唤醒对应的线程。而被 kill 的线程，需要执行到判断状态的「埋点」才会开始进入终止逻辑阶段。并且终止逻辑本身也是需要耗费时间的。

### 10、我查这么多数据，会不会把数据库内存打爆？

如果一张大表数据很多，把这张表全都加载到内存里，内存放不下，那么对该表进行全表扫描，会不会出现内存溢出的问题？

* 不会，因为 MySQL 是「边读边发」，就是服务端不会一口气读取出来全都发给客户端，实际上服务端采取的是分批发送策略，每次都不需要保存完整的结果集，每次只是查询一部分数据，放在 net\_buffer 缓存里，当缓存满了就通过网络接口发出去。紧接着继续加载下一部分数据，如此循环直至完成全表扫描。net\_buffer 是由 net\_buffer\_length 来指定的，一般 16k。
* 这种边读边发也存在问题，即如果数据接收方消费较慢，就会导致 MySQL 服务端缓存中的数据发不出去服务端执行时间较长，此时服务端的线程很多都处于 "Sending to client" 的阻塞。出现这种情况时需要查看为什么数据发不出去，是客户端太慢还是服务端的 net\_buffer 设置的太小。

全表扫描对 BufferPool 内存的影响，正常来说查询请求会先去 BufferPool 看是否有需要的数据，若有直接返回，若没有则从磁盘加载，然后缓存到 BufferPool 中，缓存命中率越高性能越好。那么全表扫描时会不会出现表中的数据都把 BufferPool 都占了的情况，而这些全表扫描的数据又是非热点数据，在内存中白白浪费内存空间？

* 不会，InnoDB 采取的是冷热分离思想，将数据链按照 7:3 的比例划分为热数据区和冷数据区，只有冷数据区的数据满足了一定的访问要求如时隔 1s 后还会被访问，才开始升级到热数据区，冷数据区主要是存放那些新加载到内存中的数据，如全表扫描的数据最先加载到内存里，然后由于之后不会再被访问，在冷数据就被访问了，而热数据区的不受影响。
* 冷热数据分离很好的解决了全表扫描的热点数据被淘汰的问题，保证在全表扫描的情况下，热点数据不受影响，仍然保持很高的命中率。

## 应用篇

### 1、到底可不可以使用 join？

join 的实现有三种方式：Index Nested - Loop Join、Simple Nested Loop Join、Block Nested Loop Join。

无论哪种方式 join 双方都是驱动表 T1 和被驱动表 T2，其本质实现都是 for 循环遍历驱动表 T1，拿驱动表的每一行数据去被驱动表 T2 上进行匹配，从 T2 中找到满足条件的行和 T1 的行组成输出的结果行，作为结果集的一部分。

**通常来说，驱动表是小表，被驱动表是大表，原因是驱动表对扫描行数或者成本的影响更大。**

* 针对驱动表的每一行数据都去被驱动表中进行匹配，在匹配过程中恰好命中了被驱动表的索引，就成为 Index Nested-Loop Join 走树搜索。
* 如果没有命中被驱动表的索引，继续用上面的算法，就是 Simple Nest Loop Join。
* 使用 join\_buffer 在内存中进行查找，就是 Block Nested Loop Join。

影响 join 的性能有那些因素：被驱动表没有索引或 join\_buffer 太小。join\_buffer 越大，每次 T1 和 T2 加载到内存中的数据越多。如果 T1 没办法一次性加载完就只能分段加载，对 T2 的扫描次数增加 & 分的块越多，全表扫描 T2 的次数越多，性能越慢。

1. 如果可以使用被驱动表的索引，join 语句还是有其优势的；
2. 不能使用被驱动表的索引，只能使用 Block Nested-Loop Join 算法，这样的语句就尽量不要使用；
3. 在使用 join 的时候，应该让小表做驱动表。

### 2、为什么临时表可以重名？

内存表 vs 临时表

* 内存表：整个表都在内存中，没有持久化到磁盘上的表，如 Memory 引擎创建的表就是内存表。
* 临时表：临时创建的表，数据既可以放在内存中也可以持久化到磁盘上，但临时表只能由创建它的 session 访问，session 结束临时表也就被删掉了，临时表会自动被回收。
  * 临时表在不同线程间不存在重名的问题，即使名称与普通表相同，但其真实的表名称中会包含线程 ID 等信息，线程间相互隔离，所以不需要考虑线程间并发的问题。
  * 临时表是自动回收的，当 session 结束的时候，临时表就被回收了，省去了人工需要的收尾和异常处理的工作。
  * 临时表也有普通表的特性，如创建索引、添加唯一键约束，定义字段的类型。
* 从以上三点特性来说，临时表特别和 Java 中的 ThreadLocal 在应用上有相似的特点：临时存在、线程内可见、线程间相互独立。

**临时表的数据只由 session 可见且自动销毁，而且是一个单独存在的表，适合用于临时存放的数据如 sort\_buffer/join\_buffer、分库分表的结果聚合等批量表数据的处理，因此临时表通常会作为复查查询的常用优化手段。**

比如，在分库分表的查询场景下，引擎需要先从各个子分区中过滤出局部数据集，然后再统一收集到一个节点上做汇总操作，如排序、分组等，这些批量临时数据行的管理就适合使用临时表，从而避免客户端手动的聚合。如果数据可以全部放到内存中那么就使用内存临时表，如果内存放不下了，那就放在磁盘里，使用 InnoDB 作为临时磁盘表的引擎。

临时表既可以系统自动创建，如在复杂查询场景下，也可以用户手动创建。

临时表是如何管理的？

* 有专门的临时表空间来存放临时表，
* 每个 session 中会维护自己的临时表列表，在 session 结束退出时，就会将列表中的每个临时表都执行销毁和回收工作。

临时表的执行被记录到日志里吗？

* 取决于 binlog 的日志格式，若是 row 则不需要记录，因为 row 下 binlog 已经存放了所有的临时表处理之后的结果逻辑，无需中间过程的回放和记录；但 statement 和 mixed 则需要记录，因此时 binlog 是记录的执行逻辑信息，即需要逐步回放执行流程，所以记录中间临时表相关的语句。
* binlog 中记录了临时表，那么在主备复制的架构下，该临时表就会被传给从库进行执行，为了避免从库单线程模型下的同名表问题，主库在写入到 binlog 中会将用户指定的名称变更为实际加入 serverId 的 table\_def\_key 这种唯一标识，这样从库即使单个线程，也不会出现重名冲突的问题。

另外需要注意的是：临时表和 threadLocal 具有同样的问题，即如果是在连接池或者线程池这种场景下，因为线程需要复用，即使执行完成后也不会退出，就不会触发临时表的回收工作，有可能这些临时表会一直存放在内存里，所以需要及时的**手动删除用户临时表**。

### 3、什么时候会使用内部临时表？

临时表可以用于 Innodb 对复杂查询的优化，即在复杂查询中，引擎会自动创建内部临时表（系统创建的临时表叫内部临时表）。

两个典型场景：

* 排序时候的 sort\_buffer 和 join、union 场景的 join\_buffer。
* 子查询场景。
* union 和 join 的区别：union 是行加起来，取行的并集，有去重。union all 类似 union，但没去重。join 是列加起来，做列属性填充。

groupby 为什么需要临时表，因为数据列是无序的，所以 MySQL 也不知道同样的值有多少行，所以只能先全部取出来，然后再统计，但能不能知道有多少行呢？可以的，如果将同样行的数据都放在一起不就行了。索引是有序的，不仅值从小到大排列，而且相同的值都聚在一起，直接遍历索引就一样可以进行聚合操作，逐个从前向后遍历，遇到新值后就先缓存累加，直到下一个新值，并且该值的聚合结果可以直接发送给客户端，无需临时表也无需额外的排序。

**这就是为什么在 groupby 的时候一定要走索引的原因，无需临时表、无需排序，性能自然高。**

### 4、都说 InnoDB 好，那还要不要使用 Memory 引擎？

Innodb 与 Memory 的区别：

* 索引类型不同：
  * Innodb 是把数据和主键索引放在一起的，是索引组织型的表。Memory 引擎是把索引和数据分开放，是堆组织类型的表。
  * 当数据位置发生改变时，Innodb 只需要修改主键索引，而 Memory 需要修改所有的索引。
  * Innodb 主键索引只需要一次查询，而 Memory 所有索引都需要两次查询。
  * Innodb 的索引是 B+ 树索引，支持范围查询。Memory 是 Hash 索引不支持范围查询。
* 锁类型不同，锁的力度不同：
  * Memory 不支持行锁，不支持变长的字符串，即使定义了 varchar 也是固定长度的 char。
  * Innodb 是磁盘表，Memory 是内存表。虽然 Memory 的读写操作全是内存操作，但是其性能未必比 Innodb 好，因为 Memory 只支持表锁，并发度太低。
  * Innodb 有 BufferPool + WAL 技术，可以基本做到和内存操作性能差不多。
* 数据持久化问题
  * Innodb 支持持久化，Memory 异常重启后数据就清空了。

Memory 内存引擎适合作为临时表：Hash 索引读更快 + 内存操作不需要读写磁盘。


# ElasticSearch 101

## 初识 ElasticSearch

**使用场景**

1. 搜索引擎：生产环境中使用最多，例如京东、淘宝、美团等使用 ES 实现高性能商品搜索，支持多条件筛选、排序和相关性排名等。
2. 日志分析与监控：ELK 作为日志分析的行业标准，是 ES 经典的使用场景。
3. 数据分析：大数据分析，通过 DSL 快速得到结果。

**与类似组件对比**

| **对比维度**       | **Elasticsearch**               | **竞品**                        | **优劣总结**                                                                 |
| -------------- | ------------------------------- | ----------------------------- | ------------------------------------------------------------------------ |
| **RDBMS**      | <p>1、高性能组合查询<br>2、适合半结构化数据</p>  | <p>1、完善的事务支持<br>2、强一致性</p>    | <p><strong>选 ES</strong>：复杂查询/分析场景<br><strong>选 RDBMS</strong>：强事务需求</p> |
| **Solr**       | <p>1、更成熟的分布式架构<br>2、近实时能力更强</p> | ES 是 Solr 是上位替代               | 新项目优先选择 ES，Solr 被淘汰。                                                     |
| **ClickHouse** | <p>1、全文检索优势<br>2、近实时响应</p>      | <p>1、超大规模聚合更快<br>2、支持二次聚合</p> | <p><strong>ES</strong>处理搜索/日志<br><strong>ClickHouse</strong>处理深度分析</p>   |

**存储场景决策树**

![](https://raw.githubusercontent.com/L2ncE/images/main/PicGo/deepseek_mermaid_20250501_2fedbe.png)

## 快速开始

仓库地址：[L2ncE/es101](https://github.com/L2ncE/es101)

克隆仓库后进入到 [docker-compose 文件](https://github.com/L2ncE/es101/blob/main/docker-es-78/docker-compose.yaml) 目录运行 `docker-compose up`。

> ⚠️ **注意**：自行下载 Docker 以及 docker-compose 工具

### Kibana 快速开始

通过 `localhost:5601` 进入：

![](https://raw.githubusercontent.com/L2ncE/images/main/PicGo/20250501164451963.png)

使用 Dev Tools：

![](https://raw.githubusercontent.com/L2ncE/images/main/PicGo/20250501164524157.png)

### Logstash 快速开始

运行 `docker-compose up` 命令后可以到日志中查看是否导入数据成功。

![](https://raw.githubusercontent.com/L2ncE/images/main/PicGo/20250501172441134.png)

### Cerebro 快速开始

通过 `localhost:9000` 进入：

![](https://raw.githubusercontent.com/L2ncE/images/main/PicGo/20250501172501845.png)

可以看到刚导入的 movies 数据。

## ElasticSearch 入门

| **概念名称**  | **描述**                                                         | **关键点 / 特点**                                                                                                                              |
| --------- | -------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------- |
| **文档**    | Elasticsearch 中的最小数据单元，以 JSON 格式存储数据。                          | <p>1、类比于关系型数据库中的一行数据。<br>2、包含实际数据字段（如 <code>title</code>, <code>content</code> 等）。</p>                                                    |
| **文档元数据** | 描述文档自身属性的系统字段，用于唯一标识和管理文档。                                     | <p>包含以下核心元数据：<br>- <code>\_id</code>：文档唯一标识符（可自定义或自动生成）。<br>- <code>\_index</code>：文档所属的索引。<br>- <code>\_version</code>：文档版本号（支持乐观锁）。</p> |
| **索引**    | 一类结构相似文档的集合，类似于关系型数据库中的「表」。                                    | 通过 Mapping 定义字段类型和属性（如文本、数值、日期等）。支持倒排索引，优化全文搜索性能。                                                                                         |
| **节点**    | Elasticsearch 集群中的一个运行实例，本质是一个 Java 进程。                        | <p>节点类型包括：<br>- <strong>主节点</strong>：管理集群状态。<br>- <strong>数据节点</strong>：存储数据。<br>- <strong>协调节点</strong>：处理请求路由。</p>                      |
| **分片**    | 索引的物理子集，用于分布式存储和计算。分片分为主分片（Primary Shard）和副本分片（Replica Shard）。 | <p><strong>主分片</strong>：数据存储和写入的基本单元，数量在索引创建时固定。<br><strong>副本分片</strong>：主分片的冗余拷贝，提供高可用和查询负载均衡。</p>                                      |

### 与关系型数据库类比

| RDBMS  | ElasticSearch |
| ------ | ------------- |
| Table  | Index         |
| Row    | Document      |
| Column | Field         |
| Schema | Mapping       |
| SQL    | DSL           |

### 健康状况

1、在 Kibana 中查询，使用命令 `GET _cluster/health`

![](https://raw.githubusercontent.com/L2ncE/images/main/PicGo/20250501183053718.png)

2、在 Cerebro 中查询

![](https://raw.githubusercontent.com/L2ncE/images/main/PicGo/20250501172501845.png)

#### 测试节点下线

![](https://raw.githubusercontent.com/L2ncE/images/main/PicGo/20250501183551380.png)

此时状态为 Yellow。

### CRUD

| **操作**     | **HTTP 方法**         | **响应码**                    | **Source 处理**                  | **幂等性**   | **备注**                                                     |
| ---------- | ------------------- | -------------------------- | ------------------------------ | --------- | ---------------------------------------------------------- |
| **Create** | `PUT` 或 `POST`      | <p>201（成功）<br>409（冲突）</p>  | 必须提供完整文档 Source                | 是（需指定 ID） | 仅当文档不存在时创建。需指定 ID（`PUT`）或自动生成（`POST`）。                     |
| **Get**    | `GET`               | <p>200（存在）<br>404（不存在）</p> | 可指定 `_source` 过滤返回字段           | 是         | 仅用于查询文档，不修改数据。                                             |
| **Index**  | `PUT` 或 `POST`      | <p>201（新建）<br>200（更新）</p>  | 必须提供完整文档 Source                | 否         | 若文档存在则替换（全量更新）。可指定 ID（`PUT`）或自动生成（`POST`）。                 |
| **Update** | `POST`（带 `_update`） | <p>200（成功）<br>404（不存在）</p> | 提供部分字段或脚本（支持 `doc` 或 `script`） | 否         | 部分更新。支持 `upsert`（不存在时插入）。需启用 `_source` 字段。默认返回更新后的 Source。 |

#### Create

**Demo1**

```json
# Req
# create document 自动生成 _id
POST users/_doc
{
	"user" : "LanLance"
}

# Resp
{
  "_index" : "users",
  "_type" : "_doc",
  "_id" : "64IRqpYBLb6gKsOx04Rm",
  "_version" : 1,
  "result" : "created",
  "_shards" : {
    "total" : 2,
    "successful" : 1,
    "failed" : 0
  },
  "_seq_no" : 0,
  "_primary_term" : 1
}
```

**Demo2**

```json
# Req
# create document 指定 ID
PUT users/_doc/1?op_type=create
{
    "user" : "LanLance_2"
}

# Resp
{
  "_index" : "users",
  "_type" : "_doc",
  "_id" : "1",
  "_version" : 1,
  "result" : "created",
  "_shards" : {
    "total" : 2,
    "successful" : 2,
    "failed" : 0
  },
  "_seq_no" : 1,
  "_primary_term" : 1
}
```

**Demo3**

```json
# Req
# create document 指定 ID；如果已经存在就报错
PUT users/_create/1
{
    "user" : "LanLance"
}

# Resp
{
  "error" : {
    "root_cause" : [
      {
        "type" : "version_conflict_engine_exception",
        "reason" : "[1]: version conflict, document already exists (current version [1])",
        "index_uuid" : "J6rYdyr6TY-H9asFHjyTwQ",
        "shard" : "0",
        "index" : "users"
      }
    ],
    "type" : "version_conflict_engine_exception",
    "reason" : "[1]: version conflict, document already exists (current version [1])",
    "index_uuid" : "J6rYdyr6TY-H9asFHjyTwQ",
    "shard" : "0",
    "index" : "users"
  },
  "status" : 409
}
```

#### Get

**Demo1**

```json
# Req
# Get 指定 ID
GET users/_doc/1

# Resp
{
  "_index" : "users",
  "_type" : "_doc",
  "_id" : "1",
  "_version" : 1,
  "_seq_no" : 1,
  "_primary_term" : 1,
  "found" : true,
  "_source" : {
    "user" : "LanLance_2"
  }
}
```

#### Index & Update

**Demo1**

```json
# Req
# Update 指定 ID (先删除，在写入)
PUT users/_doc/1
{
	"user" : "LanLance"
}

# Resp
{
  "_index" : "users",
  "_type" : "_doc",
  "_id" : "1",
  "_version" : 2,
  "result" : "updated",
  "_shards" : {
    "total" : 2,
    "successful" : 2,
    "failed" : 0
  },
  "_seq_no" : 2,
  "_primary_term" : 1
}
```

**Demo2**

```json
# Req
# Update 在原文档上增加字段
POST users/_update/1/
{
    "doc":{
        "message" : "trying out Elasticsearch"
    }
}

# Resp
{
  "_index" : "users",
  "_type" : "_doc",
  "_id" : "1",
  "_version" : 3,
  "result" : "updated",
  "_shards" : {
    "total" : 2,
    "successful" : 2,
    "failed" : 0
  },
  "_seq_no" : 3,
  "_primary_term" : 1
}
```

#### Delete

**Demo1**

```json
# Req
# Delete 指定 ID
DELETE users/_doc/1

# Resp
{
  "_index" : "users",
  "_type" : "_doc",
  "_id" : "1",
  "_version" : 4,
  "result" : "deleted",
  "_shards" : {
    "total" : 2,
    "successful" : 2,
    "failed" : 0
  },
  "_seq_no" : 4,
  "_primary_term" : 1
}
```

#### Bulk、MGet、MSearch

| **API**     | **HTTP 方法**  | **操作类型**                          | **主要用途**   | **响应码**          | **幂等性** | **性能优化点**     |
| ----------- | ------------ | --------------------------------- | ---------- | ---------------- | ------- | ------------- |
| **Bulk**    | `POST`       | 批量增删改（Create/Index/Update/Delete） | 批量写入或更新数据  | 200（整体成功，可能部分失败） | 否       | 单次请求处理大量操作    |
| **mget**    | `GET`/`POST` | 批量读取文档                            | 批量获取多个文档内容 | 200（包含每个文档状态）    | 是       | 减少网络请求次数      |
| **msearch** | `POST`       | 批量搜索请求                            | 批量执行多个搜索查询 | 200（包含每个查询结果）    | 是       | 合并多个查询请求，减少延迟 |

**功能与场景对比**

| **特性**    | **Bulk API**                  | **mget**              | **msearch**       |
| --------- | ----------------------------- | --------------------- | ----------------- |
| **核心操作**  | 增删改（CRUD）                     | 读取文档                  | 执行搜索查询            |
| **数据量优化** | 单次请求处理数千操作                    | 单次请求获取数百文档            | 单次请求合并多个复杂查询      |
| **错误处理**  | 响应中标记每个操作的 `error` 和 `status` | 响应中标记每个文档的 `found` 状态 | 每个查询独立返回状态码和结果    |
| **适用场景**  | 数据迁移、日志流写入                    | 批量加载关联数据、初始化页面        | 仪表盘批量拉取数据、跨索引聚合分析 |
| **原子性**   | 非原子（部分成功需重试）                  | 非原子                   | 非原子               |

**性能与限制对比**

| **维度**   | **Bulk API**    | **mget**             | **msearch**   |
| -------- | --------------- | -------------------- | ------------- |
| **网络开销** | 单次请求处理大量操作（高吞吐） | 减少多次 GET 请求（中吞吐）     | 合并多个查询（中高吞吐）  |
| **内存消耗** | 高（需缓存批量数据）      | 中（文档数量和大小决定）         | 高（复杂查询可能占用内存） |
| **超时风险** | 大数据量可能触发请求超时    | 大文档列表可能超时            | 复杂查询或大数据集可能超时 |
| **分片影响** | 写入压力分散到多个分片     | 读取压力分散到多个分片          | 搜索压力分散到多个分片   |
| **限制规避** | 分批提交（每批 5-15MB） | 分批查询（每批 100-1000 文档） | 控制单个查询复杂度     |

**使用**

```json
# bulk
## 执行第1次
POST _bulk
{ "index" : { "_index" : "test", "_id" : "1" } }
{ "field1" : "value1" }
{ "delete" : { "_index" : "test", "_id" : "2" } }
{ "create" : { "_index" : "test2", "_id" : "3" } }
{ "field1" : "value3" }
{ "update" : {"_id" : "1", "_index" : "test"} }
{ "doc" : {"field2" : "value2"} }

## 执行第2次
POST _bulk
{ "index" : { "_index" : "test", "_id" : "1" } }
{ "field1" : "value1" }
{ "delete" : { "_index" : "test", "_id" : "2" } }
{ "create" : { "_index" : "test2", "_id" : "3" } }
{ "field1" : "value3" }
{ "update" : {"_id" : "1", "_index" : "test"} }
{ "doc" : {"field2" : "value2"} }

# mget
GET /_mget
{
    "docs" : [
        {
            "_index" : "test",
            "_id" : "1"
        },
        {
            "_index" : "test",
            "_id" : "2"
        }
    ]
}

## URI 中指定 index
GET /test/_mget
{
    "docs" : [
        {

            "_id" : "1"
        },
        {

            "_id" : "2"
        }
    ]
}

GET /_mget
{
    "docs" : [
        {
            "_index" : "test",
            "_id" : "1",
            "_source" : false
        },
        {
            "_index" : "test",
            "_id" : "2",
            "_source" : ["field3", "field4"]
        },
        {
            "_index" : "test",
            "_id" : "3",
            "_source" : {
                "include": ["user"],
                "exclude": ["user.location"]
            }
        }
    ]
}
```

**清除数据**

```json
DELETE users
DELETE test
DELETE test2
```

### 倒排索引

倒排索引是搜索引擎和全文检索系统的核心数据结构，核心思想是通过建立「单词到文档」的映射关系从而实现 Keyword 快速定位包含该词的所有文档。例如当用户搜索「ElasticSearch」时，系统可直接通过倒排索引找到包含这一词汇的所有文档集合。

倒排索引的实现依赖于两个关键结构：**单词词典**（Term Dictionary）和**倒排列表**（Posting List）。

```mermaid
flowchart TD
    A["文档集合"] --> B("分词处理")
    B --> C{"单词词典"}
    C --> D["B+树/哈希表"] & E["倒排列表"]
    E --> F["文档ID"] & G["词频 TF"] & H["位置 Position"]
```

### Analysis & Analyzer

Analysis 是通过 Analyzer 来实现的。

#### 分词器

由三部分组成：

* Character Filters：针对原始文本处理，例如去除 html。
* Tokenizer：按照规则切分为单词。
* Token Filter：将切分的的单词进行加工，例如小写、删除 stopwords、增加同义词等。

```
Character Filters => Tokenizer => Token Filters
```

**ElasticSearch 内置分词器**

| 分词器                       | 使用场景                          | 分词逻辑                                                             |
| ------------------------- | ----------------------------- | ---------------------------------------------------------------- |
| **Standard**              | 通用文本处理，支持大多数语言（默认选择）。         | 按 Unicode 标准分词，移除标点符号，转小写，支持多语言基础处理。                             |
| **Simple**                | 快速简单分词，忽略标点符号和数字。             | 在非字母字符处分割文本，删除非字母字符，转小写（如 `Hello-World` → `["hello", "world"]`）。 |
| **Whitespace**            | 按空格严格分割，保留原始格式（如代码、特定标识）。     | 仅按空格分割，保留大小写和标点（如 `Quick-Brown` → `["Quick-Brown"]`）。            |
| **Stop**                  | 需过滤常见停用词（如英文中的“the”、“is”）的文本。 | 按非字母字符分割出连续字母词条，转小写后移除停用词（如 `The fox` → `["fox"]`）。              |
| **Keyword**               | 需精确匹配的字段（如 ID、状态码）。           | 将整个输入作为单一词条，不进行任何处理（如 `Hello World` → `["Hello World"]`）。        |
| **Pattern**               | 需自定义分隔规则（如按特定符号分割）的文本。        | 通过正则表达式（默认 `\W+`）分割文本，转小写（可自定义正则）。                               |
| **Language**（如 `english`） | 针对特定语言优化（如英文词干提取、停用词过滤）。      | 按语言规则分词，处理停用词、转小写、词干提取等（如 `running` → `["run"]`）。                |

**analyzer API**

通过 analyzer API 能够快速得到分词结果进行测试，以下提供了一些例子可以去到 Kibana 的 Dev Tools 进行使用。

```json
#standard
GET _analyze
{
  "analyzer": "standard",
  "text": "2 running Quick brown-foxes leap over lazy dogs in the summer evening."
}

#simple
GET _analyze
{
  "analyzer": "simple",
  "text": "2 running Quick brown-foxes leap over lazy dogs in the summer evening."
}

#stop
GET _analyze
{
  "analyzer": "stop",
  "text": "2 running Quick brown-foxes leap over lazy dogs in the summer evening."
}

#whitespace
GET _analyze
{
  "analyzer": "whitespace",
  "text": "2 running Quick brown-foxes leap over lazy dogs in the summer evening."
}

#keyword
GET _analyze
{
  "analyzer": "keyword",
  "text": "2 running Quick brown-foxes leap over lazy dogs in the summer evening."
}

#pattern
GET _analyze
{
  "analyzer": "pattern",
  "text": "2 running Quick brown-foxes leap over lazy dogs in the summer evening."
}

#english
GET _analyze
{
  "analyzer": "english",
  "text": "2 running Quick brown-foxes leap over lazy dogs in the summer evening."
}
```

**示例：standard 分词器的分词结果**

```json
{
  "tokens" : [
    {
      "token" : "2",
      "start_offset" : 0,
      "end_offset" : 1,
      "type" : "<NUM>",
      "position" : 0
    },
    {
      "token" : "running",
      "start_offset" : 2,
      "end_offset" : 9,
      "type" : "<ALPHANUM>",
      "position" : 1
    },
    {
      "token" : "quick",
      "start_offset" : 10,
      "end_offset" : 15,
      "type" : "<ALPHANUM>",
      "position" : 2
    },
    {
      "token" : "brown",
      "start_offset" : 16,
      "end_offset" : 21,
      "type" : "<ALPHANUM>",
      "position" : 3
    },
    {
      "token" : "foxes",
      "start_offset" : 22,
      "end_offset" : 27,
      "type" : "<ALPHANUM>",
      "position" : 4
    },
    {
      "token" : "leap",
      "start_offset" : 28,
      "end_offset" : 32,
      "type" : "<ALPHANUM>",
      "position" : 5
    },
    {
      "token" : "over",
      "start_offset" : 33,
      "end_offset" : 37,
      "type" : "<ALPHANUM>",
      "position" : 6
    },
    {
      "token" : "lazy",
      "start_offset" : 38,
      "end_offset" : 42,
      "type" : "<ALPHANUM>",
      "position" : 7
    },
    {
      "token" : "dogs",
      "start_offset" : 43,
      "end_offset" : 47,
      "type" : "<ALPHANUM>",
      "position" : 8
    },
    {
      "token" : "in",
      "start_offset" : 48,
      "end_offset" : 50,
      "type" : "<ALPHANUM>",
      "position" : 9
    },
    {
      "token" : "the",
      "start_offset" : 51,
      "end_offset" : 54,
      "type" : "<ALPHANUM>",
      "position" : 10
    },
    {
      "token" : "summer",
      "start_offset" : 55,
      "end_offset" : 61,
      "type" : "<ALPHANUM>",
      "position" : 11
    },
    {
      "token" : "evening",
      "start_offset" : 62,
      "end_offset" : 69,
      "type" : "<ALPHANUM>",
      "position" : 12
    }
  ]
}
```

### Search API

* URI Search
* Request Body Search

**示例**

```json
# URI Search
GET kibana_sample_data_ecommerce/_search?q=customer_first_name:Eddie
GET kibana*/_search?q=customer_first_name:Eddie
GET /_all/_search?q=customer_first_name:Eddie

# Request Body Search
POST kibana_sample_data_ecommerce/_search
{
	"profile": true,
	"query": {
		"match_all": {}
	}
}
```

#### 指定查询的索引

| 语法                      | 范围              |
| ----------------------- | --------------- |
| /\_search               | 所有索引            |
| /index1/\_search        | index1          |
| /index1,index2/\_search | index1 和 index2 |
| /index\*/\_search       | 以 index 开头的索引   |

#### URI Search

**示例**

```json
GET /movies/_search?q=2012&df=title&sort=year:desc&from=0&size=10&timeout=1s
{
	"profile":"true"
}
```

参数：

* q 指定查询语句，使用 Query String Syntax。
* df 指定默认字段，不指定时会对所有字段进行查询。
* sort 代表排序。
* from 和 size 用于分页。
* profile 可以查看查询是如何被执行的。

**泛查询**

```json
GET /movies/_search?q=2012
{
	"profile":"true"
}
```

未指定 `df` 参数时 ES 会搜索所有字段，可能触发跨字段匹配，性能消耗较大。分析结果如下：

```json
{  
    "type": "DisjunctionMaxQuery",  
    "description": "(title.keyword:2012 | id.keyword:2012 | year:[2012 TO 2012] | genre:2012 | @version:2012 | @version.keyword:2012 | id:2012 | genre.keyword:2012 | title:2012)"  
}
```

可以看到在所有字段进行了匹配。

**显式字段查询**

```json
GET /movies/_search?q=title:2012
{
	"profile":"true"
}
```

通过 `title:2012` 显式指定字段，精准限定搜索范围，比泛查询更高效。分析结果如下：

```json
{  
    "type": "TermQuery",  
    "description": "title:2012"  
}
```

**短语分割问题**

```json
GET /movies/_search?q=title:Beautiful Mind
{
	"profile":"true"
}
```

实际执行 `title:Beautiful OR Mind`，空格被识别为 OR 逻辑，返回包含任意词的文档。

**精确短语匹配**

```json
GET /movies/_search?q=title:"Beautiful Mind"
{
	"profile":"true"
}
```

使用双引号包裹词组，强制进行短语搜索，要求词语按顺序完整出现。

**分组查询**

```json
GET /movies/_search?q=title:(Beautiful Mind)
{
	"profile":"true"
}
```

括号实现逻辑分组，等效于 `title:Beautiful OR title:Mind`，优先执行组内操作。

**布尔运算符**

```json
GET /movies/_search?q=title:(Beautiful AND Mind)
```

显式布尔查询，要求同时包含两个词（AND 逻辑）。

**范围查询**

```json
GET /movies/_search?q=title:beautiful AND year:[2002 TO 2018%7D
```

`[2002 TO 2018}` 表示闭区间包含 2002，开区间不包含 2018（`%7D` 为 URL 编码的 `}` 符号）。

**通配符搜索**

```json
GET /movies/_search?q=title:b*
{
	"profile":"true"
}
```

`b*` 匹配以 b 开头的任意长度字符，支持 `?` 匹配单个字符，注意通配符在前端影响性能。

**模糊匹配**

```json
GET /movies/_search?q=title:beautiful~1
{
	"profile":"true"
}

GET /movies/_search?q=title:"Lord Rings"~2
{
	"profile":"true"
}
```

* `~1` 允许 1 个字符的编辑距离（拼写纠错）。
* `"Lord Rings"~2` 表示短语中允许间隔 2 个单词。

#### Request Body Search & Query DSL

通常生成环境都使用这种方法，更加强大、功能更丰富。

**示例**

```json
POST movies/_search
{
  "from":0,
  "size":10,
  "query": {
    "match": {
      "title": {
        "query": "last christmas",
        "operator": "and"
      }
    }
  }
}
```

更多 DSL 语法请参考 [官方文档](https://elasticsearch-dsl.readthedocs.io/en/latest/)。

**基本 Match 查询**

```json
POST movies/_search
{
  "query": {
    "match": {
      "title": "last christmas"
    }
  }
}
```

* 默认使用 OR 逻辑匹配分词结果。
* 自动对搜索词进行分词处理。

**精确 AND 匹配**

```json
POST movies/_search
{
  "query": {
    "match": {
      "title": {
        "query": "last christmas",
        "operator": "and"
      }
    }
  }
}
```

通过 `operator:"and"` 强制要求所有分词必须同时存在。

**短语搜索**

```json
POST movies/_search
{
  "query": {
    "match_phrase": {
      "title": {
        "query": "one love"
      }
    }
  }
}
```

* 要求词语按顺序完整出现。
* 等效于 URI Search 中的引号。

**模糊短语匹配**

```json
POST movies/_search
{
  "query": {
    "match_phrase": {
      "title": {
        "query": "one love",
        "slop": 1
      }
    }
  }
}
```

`slop` 参数允许词语间隔位置数（此处允许间隔 1 个词）。

**Query String 搜索**

```json
GET /movies/_search
{
  "query": {
    "query_string": {
      "default_field": "title",
      "query": "Beafiful AND Mind"
    }
  }
}
```

* 支持 AND/OR/NOT 布尔逻辑。

**多字段搜索**

```json
GET /movies/_search
{
  "query": {
    "query_string": {
      "fields": ["title","year"],
      "query": "2012"
    }
  }
}
```

* `fields` 数组定义多个搜索字段。
* 自动进行跨字段联合查询。

**Simple Query 搜索**

```json
GET /movies/_search
{
  "query": {
    "simple_query_string": {
      "query": "Beautiful +mind",
      "fields": ["title"]
    }
  }
}
```

* `+` 代替 AND 操作。
* 自动忽略无效语法。
* 适合直接暴露给前端搜索框使用。

**跨索引查询**

```json
POST /movies,404_idx/_search?ignore_unavailable=true
{
  "query": {
    "match_all": {}
  }
}
```

* 逗号分隔多个索引名称。
* `ignore_unavailable=true` 忽略不存在索引。

**源过滤**

```json
POST kibana_sample_data_ecommerce/_search
{
  "_source":["order_date"],
  "query": {
    "match_all": {}
  }
}
```

`_source` 过滤返回字段。

**脚本字段**

```json
GET kibana_sample_data_ecommerce/_search
{
  "script_fields": {
    "new_field": {
      "script": {
        "lang": "painless",
        "source": "doc['order_date'].value+'hello'"
      }
    }
  }
}
```

动态计算返回字段。

### Mapping

Mapping 类似数据库中的 schema 定义，用于定义字段名称、类型、相关配置。

#### 字段的数据类型

| **字段类型**   | **使用场景**                 | **底层实现**                            | **实例数据**                             |
| ---------- | ------------------------ | ----------------------------------- | ------------------------------------ |
| text       | 全文搜索（如文章内容、长文本）。         | 倒排索引（分词后存储），支持模糊匹配和相关性评分。           | "LanTech 指南 "                        |
| keyword    | 精确匹配（如 ID、状态码）、聚合和排序。    | 未分词的原始字符串，基于精确值匹配。                  | "user\_123"                          |
| 数值类型       | 范围查询（如价格、年龄）、数学运算和聚合。    | Lucene 的数值索引优化（如 `integer`、`long`）。 | 42 / 3.14                            |
| date       | 时间序列数据（如日志时间、事件时间戳）。     | 存储为长整型时间戳（毫秒级），支持时区转换。              | "2023-10-05T12:30:00Z"               |
| boolean    | 真/假状态（如开关、是否有效）。         | 布尔索引结构，仅存储 `true` 或 `false`。        | true                                 |
| binary     | 存储二进制数据（如图片、文件）。         | Base64 编码存储，不支持直接查询。                | "U29tZSBoZWxsbyB3b3JsZA=="           |
| range      | 区间查询（如价格区间、年龄区间）。        | Lucene 的数值/日期范围索引，支持 `>=`、`<=` 等操作。 | {"gte": 10, "lte": 20}               |
| object     | 嵌套 JSON 对象（如用户信息中的地址字段）。 | 内部文档结构，支持嵌套查询。                      | {"city": "Beijing", "zip": "100000"} |
| nested     | 复杂嵌套关系（如订单与多个商品的关联）。     | 独立索引的子文档，需通过 `nested` 查询访问。         | \[{"name": "book", "price": 15}]     |
| geo\_point | 地理位置数据（如经纬度）。            | 存储为坐标对，支持地理距离计算和范围查询。               | {"lat": 40.7128, "lon": -74.0060}    |
| 数组类型       | 存储多个相同类型值（如标签列表、商品分类）。   | 多值字段（无需显式声明），底层以扁平化多值形式存储。          | \["red", "blue", "green"]            |
| ip         | IP 地址存储与查询（如访问日志中的 IP）。  | 存储为 32 位或 128 位整数，支持 CIDR 范围查询。     | "192.168.1.1"                        |

#### Dynamic Mapping

在写入文档时如果索引不存在会自动创建索引，该机制使得我们不用手动定义 Mappings，但是通常不用这个，因为容易推算错误，并且 ES 禁止对有数据写入的字段修改定义。

**示例**

```json
# 插入测试数据
PUT mapping_test/_doc/1
{
    "uid" : "123",
    "isVip" : false,
    "isAdmin": "true",
    "age":19,
    "heigh":180
}

# 查看字段类型
GET mapping_test/_mapping

# Resp
{
  "mapping_test" : {
    "mappings" : {
      "properties" : {
        "age" : {
          "type" : "long"
        },
        "heigh" : {
          "type" : "long"
        },
        "isAdmin" : {
          "type" : "text",
          "fields" : {
            "keyword" : {
              "type" : "keyword",
              "ignore_above" : 256
            }
          }
        },
        "isVip" : {
          "type" : "boolean"
        },
        "uid" : {
          "type" : "text",
          "fields" : {
            "keyword" : {
              "type" : "keyword",
              "ignore_above" : 256
            }
          }
        }
      }
    }
  }
}

```

如上结果所示，Dynamic Mapping 机制会自动推断类型，同时 text 类型会新增一个 keyword 类型支持精确查找。

**控制 Dynamic Mappings**

`dynamic` 字段不同情况下的表现。

|             | true | false | strict |
| ----------- | ---- | ----- | ------ |
| 文档可索引       | YES  | YES   | NO     |
| 字段可索引       | YES  | NO    | NO     |
| Mapping 被更新 | YES  | NO    | NO     |

#### Index

`index` 用于控制字段是否被索引。

**示例**

```json
"mobile" : {
  "type" : "text",
  "index": false
}
```

**Index Options**

| **Index Options** | **记录内容**                         | **作用**                                            | **默认类型**      |
| ----------------- | -------------------------------- | ------------------------------------------------- | ------------- |
| `docs`            | 仅文档编号（doc id）                    | 仅记录文档 ID，节省空间，适用于只需文档匹配的场景                        | 非 text 类型字段默认 |
| `freqs`           | 文档编号（doc id）+ 词频（term frequency） | 记录文档 ID 和词频，支持基于频率的查询优化                           |               |
| `positions`       | 文档编号 + 词频 + 位置（position）         | 记录位置信息，支持短语查询（Phrase Query）和邻近查询（Proximity Query） | text 类型字段默认   |
| `offsets`         | 文档编号 + 词频 + 位置 + 偏移量（offset）     | 记录字符偏移量，支持高亮显示等精细文本处理                             |               |

`index_options` 用于控制是否存储文档 ID、词频、位置和偏移量等，从而影响搜索效率和功能支持（如短语查询、高亮等）。同时记录的内容越多，占用存储空间越大。

#### null\_value

* 需要对 Null 值实现搜索。
* 只有 Keyword 类型支持设定 null\_value。

**示例**

```json
"mobile" : {
  "type" : "keyword",
  "null_value": "NULL"
}
```

#### 自定义 Analyzer

**Character Filter**

**示例**

```json
POST _analyze
{
  "tokenizer":"keyword",
  "char_filter":["html_strip"],
  "text": "<b>hello world</b>"
}
```

能够将 html 的标签去除。

```json
POST _analyze
{
  "tokenizer": "standard",
  "char_filter": [
      {
        "type" : "mapping",
        "mappings" : [ "- => _"]
      }
    ],
  "text": "123-456, I-test! test-990 650-555-1234"
}
```

能够将 text 中的 `-` 替换为 `_`。

**Tokenizer**

**示例**

```json
POST _analyze
{
  "tokenizer":"path_hierarchy",
  "text":"/user/ymruan/a/b/c/d/e"
}
```

能够按照目录层级进行切分。

**Token Filter**

**示例**

```json
GET _analyze
{
  "tokenizer": "whitespace",
  "filter": ["lowercase","stop","snowball"],
  "text": ["The girls in China are playing this game!"]
}
```

* `lowercase` - 仅小写
* `stop` - 停用词过滤
* `snowball` - 词干提取

#### Index Template

用于自动设定 Mappings 和 Settings，并按照一定的规则自动匹配到新创建的索引中。

* 仅在索引被新创建时才回起作用。
* 可以设置多个模版，设置会 merge 在一起。
* 可以控制 order 的数值控制 merge 的过程。先应用 order 低的，后续高的会覆盖之前的设定。

**示例**

```json
PUT /_template/template_test
{
    "index_patterns" : ["test*"],
    "order" : 1,
    "settings" : {
    	"number_of_shards": 1,
        "number_of_replicas" : 2
    },
    "mappings" : {
    	"numeric_detection": true
    }
}
```

表示当一个新索引以 `test` 开头时，会自动将索引的分片数设置为 2，同时会自动探测数字类型。

#### Dynamic Template

动态设定字段类型。例如：

* 将所有字符串类型设定为 keyword。
* is 开头的字段都设置为 boolean。

**示例**

```json
PUT my_index
{
  "mappings": {
    "dynamic_templates": [
      {
        "strings_as_boolean": {
          "match_mapping_type": "string",
          "match": "is*",
          "mapping": {
            "type": "boolean"
          }
        }
      },
      {
        "strings_as_keywords": {
          "match_mapping_type": "string",
          "mapping": {
            "type": "keyword"
          }
        }
      }
    ]
  }
}
```

表示当字符串类型为 is 开头时设置为 boolean 类型，其余设置为 keyword 类型。

### 聚合（Aggregation）

* ElasticSearch 除了提供搜索以外，还提供了针对 ES 进行同喜分析的功能。
* 聚合是一个分析总结全套的数据，而不是寻找单个文档。
* 性能高且实时性高（不用 T+1）。

> 本节示例需要在 Kibana 中添加官方提供的 Sample flight data 样例数据。

#### Bucket

![](https://raw.githubusercontent.com/L2ncE/images/main/PicGo/20250509181031736.png)

**示例**

```json
GET kibana_sample_data_flights/_search
{
	"size": 0,
	"aggs":{
		"flight_dest":{
			"terms":{
				"field":"DestCountry"
			}
		}
	}
}

# Resp
"aggregations" : {
"flight_dest" : {
  "doc_count_error_upper_bound" : 0,
  "sum_other_doc_count" : 3187,
  "buckets" : [
	{
	  "key" : "IT",
	  "doc_count" : 2371
	},
	{
	  "key" : "US",
	  "doc_count" : 1987
	},
	// ...
	]
  }
}
```

将国家分成了桶。

#### Metric

* 基于数据集计算结果。
* 大多数是数学计算，仅输出一个值。

**示例**

```json
GET kibana_sample_data_flights/_search
{
	"size": 0,
	"aggs":{
		"flight_dest":{
			"terms":{
				"field":"DestCountry"
			},
			"aggs":{
				"avg_price":{
					"avg":{
						"field":"AvgTicketPrice"
					}
				},
				"max_price":{
					"max":{
						"field":"AvgTicketPrice"
					}
				},
				"min_price":{
					"min":{
						"field":"AvgTicketPrice"
					}
				}
			}
		}
	}
}

# Resp
// ...
{
  "key" : "IT",
  "doc_count" : 2371,
  "max_price" : {
	"value" : 1195.3363037109375
  },
  "min_price" : {
	"value" : 100.57646942138672
  },
  "avg_price" : {
	"value" : 586.9627099618385
  }
},
// ...
```

进行了平均值、最大值、最小值的计算。

#### 嵌套

**示例**

```json
GET kibana_sample_data_flights/_search
{
	"size": 0,
	"aggs":{
		"flight_dest":{
			"terms":{
				"field":"DestCountry"
			},
			"aggs":{
				"stats_price":{
					"stats":{
						"field":"AvgTicketPrice"
					}
				},
				"wather":{
				  "terms": {
				    "field": "DestWeather",
				    "size": 5
				  }
				}
			}
		}
	}
}

# Resp
// ...
{
  "key" : "IT",
  "doc_count" : 2371,
  "wather" : {
	"doc_count_error_upper_bound" : 0,
	"sum_other_doc_count" : 506,
	"buckets" : [
	  {
		"key" : "Clear",
		"doc_count" : 428
	  },
	  {
		"key" : "Sunny",
		"doc_count" : 424
	  },
	  {
		"key" : "Rain",
		"doc_count" : 417
	  },
	  {
		"key" : "Cloudy",
		"doc_count" : 414
	  },
	  {
		"key" : "Heavy Fog",
		"doc_count" : 182
	  }
	]
  },
  "stats_price" : {
	"count" : 2371,
	"min" : 100.57646942138672,
	"max" : 1195.3363037109375,
	"avg" : 586.9627099618385,
	"sum" : 1391688.585319519
  }
},
// ...
```

在使用国家分桶后再进行票价统计和 5 组最常见天气的分布。

## 深入搜索

### Term 搜索与全文搜索

| **对比维度**   | **Term 搜索**                    | **全文搜索**                             |
| ---------- | ------------------------------ | ------------------------------------ |
| **查询类型**   | 精确匹配                           | 模糊匹配                                 |
| **分析过程**   | 不分词，直接匹配索引中的词项（Term）           | 分词处理，匹配词条（Token）                     |
| **适用字段类型** | keyword、数字、日期等精确值字段            | text 类型字段                            |
| **使用场景**   | 过滤（Filter）、聚合（Aggregation）     | 自由文本搜索（如搜索框输入）                       |
| **性能特点**   | 高效，适合大数据集和实时过滤                 | 相对耗时，依赖分词和相关性计算                      |
| **查询语法**   | `{"term": {"field": "value"}}` | `{"match": {"field": "user_input"}}` |
| **倒排索引使用** | 直接定位词项的文档列表                    | 通过词条组合计算相关性得分                        |

#### Term 搜索

**示例**

1、插入数据

```json
POST /products/_bulk
{ "index": { "_id": 1 }}
{ "productID" : "XHDK-A-1293-#fJ3","desc":"iPhone" }
{ "index": { "_id": 2 }}
{ "productID" : "KDKE-B-9947-#kL5","desc":"iPad" }
{ "index": { "_id": 3 }}
{ "productID" : "JODL-X-1937-#pV7","desc":"MBP" }
```

2、Term 查询

```json
POST /products/_search
{
  "query": {
    "term": {
      "desc": {
        // "value": "iPhone"
        // "value": "iphone"
      }
    }
  }
}
```

使用 `iPhone` 不能搜索出结果，而小写的 `iphone` 可以。因为是精确查询，而原始数据在分词后的结果为 `iphone`。

```json
POST /products/_search
{
  "query": {
    "term": {
      "desc.keyword": {
        // "value": "iPhone"
        // "value": "iphone"
      }
    }
  }
}
```

此时使用 keyword 类型能成功获取数据。

3、Constant Score 转为 Filter

```json
POST /products/_search
{
  "explain": true,
  "query": {
    "constant_score": {
      "filter": {
        "term": {
          "productID.keyword": "XHDK-A-1293-#fJ3"
        }
      }
    }
  }
}
```

* 将 Query 转为 Filter，忽略 TF-IDF 计算，避免相关性算分的开销。
* Filter 可以有效利用缓存。

#### 全文搜索

Match / Match Phrase / Query String

* 索引和搜索时都会进行分词，查询字符串先传递到一个合适的分词器，然后生成一个供查询的列表。
* 查询会对每个词项逐个查询再将结果进行合并，并为每个文档生成一个算分。

| 场景            | 推荐查询类型                                    |
| ------------- | ----------------------------------------- |
| 普通全文搜索        | `match`                                   |
| 精确短语匹配        | `match_phrase`                            |
| 高级搜索（用户输入带逻辑） | `query_string`                            |
| 用户输入框 + 安全性优先 | `match` / `multi_match`，避免 `query_string` |

### 结构化搜索

结构化数据是指具有固定格式和明确字段的数据，每个字段都有特定的类型（如字符串、数字、日期等），并且数据是可预测、易于解析的。结构化搜索即对结构化数据进行搜索。

**示例**

1、插入数据

```json
DELETE products
POST /products/_bulk
{ "index": { "_id": 1 }}
{ "price" : 10,"avaliable":true,"date":"2018-01-01", "productID" : "XHDK-A-1293-#fJ3" }
{ "index": { "_id": 2 }}
{ "price" : 20,"avaliable":true,"date":"2019-01-01", "productID" : "KDKE-B-9947-#kL5" }
{ "index": { "_id": 3 }}
{ "price" : 30,"avaliable":true, "productID" : "JODL-X-1937-#pV7" }
{ "index": { "_id": 4 }}
{ "price" : 30,"avaliable":false, "productID" : "QQPX-R-3956-#aD8" }
```

2、Bool

```json
POST products/_search
{
  "query": {
    "constant_score": {
      "filter": {
        "term": {
          "avaliable": true
        }
      }
    }
  }
}
```

3、数字

```json
POST products/_search
{
  "query": {
    "term": {
      "price": 30
    }
  }
}
```

4、Range

```json
POST products/_search
{
    "query" : {
        "constant_score" : {
            "filter" : {
                "range" : {
                    "price" : {
                        "gte" : 20,
                        "lte"  : 30
                    }
                }
            }
        }
    }
}
```

### 搜索相关性

* 搜索的相关性算分，描述了一个文档和查询语句匹配的程度。ES 会对每个匹配查询条件的结果进行算分 \_score。
* 打分的本质是排序，需要把最符合用户需求的文档排在前面。ES5 之前，默认的相关性算分采用 TF-IDF，现在采用 BM 25。

#### 词频 TF

* TF 即是词在一篇文档中出现的频率。
* 度量一条查询和结果文档相关性的简单方法：将搜索中的每一个词 TF 相加。
* Stop Word 不应考虑，类似 「the」、「的」。

#### 逆文档频率 IDF

* DF：检索词在所有文档中出现的评率。
* Inverse Document Frequency：$log(全部文档数/检索词出现过的文档总数)$
* TF-IDF 的本质就是将 TF 求和变成了加权求和：$TF(X)\*IDF(X)$

#### BM 25

与 TF-IDF 相比，当一个词的 TF 无限增加时，BM 25 算分会趋于一个稳定值。

#### Boosting

Boosting 是控制相关度的一种手段。

* 当 boost > 1 时打分的相关度相对性提升。
* 当 0 < boost < 1 时打分的权重相对性降低。
* 当 boost < 0 时，贡献负分。

### 多字段多字符串查询

#### bool 查询

一个 bool 查询是一个或者多个查询子句的组合。

| 子句        | 描述                          |
| --------- | --------------------------- |
| must      | 必须匹配，贡献算分。                  |
| should    | 选择性匹配，贡献算分。                 |
| must\_not | Filter Context 查询字句，必须不能匹配。 |
| filter    | Filter Context 必须匹配，不贡献算分。  |

**示例**

1、bool 查询

```json
POST /products/_search
{
  "query": {
    "bool" : {
      "must" : {
        "term" : { "price" : "30" }
      },
      "filter": {
        "term" : { "avaliable" : "true" }
      },
      "must_not" : {
        "range" : {
          "price" : { "lte" : 10 }
        }
      },
      "should" : [
        { "term" : { "productID.keyword" : "JODL-X-1937-#pV7" } },
        { "term" : { "productID.keyword" : "XHDK-A-1293-#fJ3" } }
      ],
      "minimum_should_match" :1
    }
  }
}
```

* 子查询可以任意顺序出现。
* 可以嵌套多个查询。
* 如果 bool 查询中没有 must 条件，那么 should 中必须至少满足一条查询。

2、boost 控制查询分数

```json
POST /news/_bulk  
{ "index": { "_id": 1 }}  
{ "content":"Apple Mac" }  
{ "index": { "_id": 2 }}  
{ "content":"Apple iPad" }  
{ "index": { "_id": 3 }}  
{ "content":"Apple employee like Apple Pie and Apple Juice" }

POST news/_search
{
  "query": {
    "boosting": {
      "positive": {
        "match": {
          "content": "apple"
        }
      },
      "negative": {
        "match": {
          "content": "pie"
        }
      },
      "negative_boost": 0.5
    }
  }
}
```

此时会将苹果产品放前边，而 id 为 3 的显示在最后。

### 多字段单字符串查询

#### Disjunction Max Query

将任何与任一查询匹配的文档作为结果返回。采用字段上最匹配的评分最终评分返回。

**示例**

1、插入数据

```json
PUT /blogs/_doc/1
{
    "title": "Quick brown rabbits",
    "body":  "Brown rabbits are commonly seen."
}

PUT /blogs/_doc/2
{
    "title": "Keeping pets healthy",
    "body":  "My quick brown fox eats rabbits on a regular basis."
}
```

2、bool 测试

```json
POST /blogs/_search
{
    "query": {
        "bool": {
            "should": [
                { "match": { "title": "Brown fox" }},
                { "match": { "body":  "Brown fox" }}
            ]
        }
    }
}
```

结果可以看到 1 号文档在前，是因为 bool 会对两个进行加和平均，不符合直觉。

2、dis\_max 测试

```json
POST blogs/_search
{
    "query": {
        "dis_max": {
            "queries": [
                { "match": { "title": "Brown fox" }},
                { "match": { "body":  "Brown fox" }}
            ]
        }
    }
}
```

此时 2 号文档在前，符合直觉。

```json
POST blogs/_search
{
    "query": {
        "dis_max": {
            "queries": [
                { "match": { "title": "Quick pets" }},
                { "match": { "body":  "Quick pets" }}
            ]
        }
    }
}
```

结果还是 1 号文档在前，同时分数相同。因为最高分数的 quick 都只出现一次。但 2 号文档中有 pet ，直觉上应该 2 号文档更高，但 dis\_max 只取最大的一条。

```json
POST blogs/_search
{
    "query": {
        "dis_max": {
            "queries": [
                { "match": { "title": "Quick pets" }},
                { "match": { "body":  "Quick pets" }}
            ],
            "tie_breaker": 0.2
        }
    }
}
```

加入 tie\_breaker 后会把最匹配一条之外的分数与其值相乘，这样 2 号文档就能更高，符合直觉。

#### MultiMatch

`multi_match` 是 Elasticsearch 中一种用于在多个字段中执行全文搜索的查询方式。它扩展了 `match` 查询，允许你在多个字段上同时进行匹配。

| 类型              | 描述         | 使用场景                           |
| --------------- | ---------- | ------------------------------ |
| `best_fields`   | 匹配最佳字段（默认） | 标题或正文关键词搜索                     |
| `most_fields`   | 多字段尽量都匹配   | 多语言字段匹配                        |
| `cross_fields`  | 字段合并为整体匹配  | 姓名拆分（first\_name + last\_name） |
| `phrase`        | 短语匹配       | 查找完整短语                         |
| `phrase_prefix` | 短语前缀匹配     | 自动补全                           |
| `bool_prefix`   | 前缀词项布尔匹配   | 高效前缀搜索                         |

**示例**

1、插入数据

```json
PUT /titles
{
  "mappings": {
    "properties": {
      "title": {
        "type": "text",
        "analyzer": "english",
        "fields": {"std": {"type": "text","analyzer": "standard"}}
      }
    }
  }
}

POST titles/_bulk
{ "index": { "_id": 1 }}
{ "title": "My dog barks" }
{ "index": { "_id": 2 }}
{ "title": "I see a lot of barking dogs on the road " }
```

2、使用 most\_fields

```json
GET /titles/_search
{
   "query": {
        "multi_match": {
            "query":  "barking dogs",
            "type":   "most_fields",
            "fields": [ "title", "title.std" ]
        }
    }
}
```

结果显示 2 号文档在前，符合直觉。若不使用 MultiMatch 则会 1 号文档在前，因为 English 分词器会把 barking dogs 分为 bark 和 dog，1 号文档分数更高。

### 自然语言与查询

当处理人类自然语言时，有时尽管搜索和原文不完全匹配，但是希望搜到一些内容。

可以采取的措施：

* 归一化词元：例如消除变音符号（西语，拼音）。
* 抽取词根：消除单复数等。
* 包含同义词。
* 拼写错误处理。

#### 混合多语言的挑战

不同的索引使用不同的语言；同一个索引中，不同的字段使用不同的语言；一个文档的一个字段内混合不同的语言。

* 词干提取：以色列文档，包含了希伯来语，阿拉伯语，俄语和英文。
* 不正确的文档频率：英文为主的文章中，德文算分高（稀有）。
* 需要判断用户搜索时使用的语言。

#### 中文分词（IK）

由于生产环境中最常见的中文分词器为 IK，因此本篇也以 IK 为主。

**安装**

仓库地址：[L2ncE/es101](https://github.com/L2ncE/es101)

克隆仓库后进入到 [docker-compose 文件](https://github.com/L2ncE/es101/blob/main/docker-es-78/docker-compose.yaml) 目录，将 `docker.elastic.co/elasticsearch/elasticsearch:7.8.0` 改为 `zingimmick/elasticsearch-ik:7.8.0`。运行 `docker-compose up`。

**示例**

```json
POST _analyze
{
  "analyzer": "ik_smart",
  "text": ["剑桥分析公司多位高管对卧底记者说，他们确保了唐纳德·特朗普在总统大选中获胜"]
}
```

### Search Template

用于解耦程序和搜索 DSL。

**示例**

````json
POST _scripts/tmdb
{
  "script": {
    "lang": "mustache",
    "source": {
      "_source": [
        "title",
        "overview"
      ],
      "size": 20,
      "query": {
        "multi_match": {
          "query": "{{q}}",
          "fields": [
            "title",
            "overview"
          ]
        }
      }
    }
  }
}

POST tmdb/_search/template
{
    "id":"tmdb",
    "params": {
        "q": "basketball with cartoon aliens"
}
}```

上游可以不感知模版的变化，避免耦合。

### Index Alias

实现零停机运维。比如在进行索引重建、版本升级、滚动更新等操作时，无需中断服务。

**示例**

1、插入数据

```json
PUT movies-2019/_doc/1
{
  "name":"the matrix",
  "rating":5
}

PUT movies-2019/_doc/2
{
  "name":"Speed",
  "rating":3
}
````

2、设置别名

```json
POST _aliases
{
  "actions": [
    {
      "add": {
        "index": "movies-2019",
        "alias": "movies-latest"
      }
    }
  ]
}

POST movies-latest/_search
{
  "query": {
    "match_all": {}
  }
}
```

### Function Score Query

可以在查询结束后对每一个匹配的文档进行一系列的重新算分，根据新生成的分数进行排序。

* Weight：为每一个文档设置一个简单而不被规范化的权重。
* Field Value Factor：使用该数值来修改 `\_score`，例如将「热度」和「点赞数」作为算分的参考因素。
* Random Score： 为每一个用户使用一个不同的，随机算分结果。
* 衰减函数：以某个字段的值为标准，距离某个值越近，得分越高。
* Script Score：自定义脚本完全控制所需逻辑。

**示例**

1、插入数据

```json
DELETE blogs
PUT /blogs/_doc/1
{
  "title":   "About popularity",
  "content": "In this post we will talk about...",
  "votes":   0
}

PUT /blogs/_doc/2
{
  "title":   "About popularity",
  "content": "In this post we will talk about...",
  "votes":   100
}

PUT /blogs/_doc/3
{
  "title":   "About popularity",
  "content": "In this post we will talk about...",
  "votes":   1000000
}
```

2、使用多个参数测试

```json
POST /blogs/_search
{
  "query": {
    "function_score": {
      "query": {
        "multi_match": {
          "query":    "popularity",
          "fields": [ "title", "content" ]
        }
      },
      "field_value_factor": {
        "field": "votes",
        "modifier": "log1p" ,
        "factor": 0.1
      }
    }
  }
}
```

* 初始逻辑：$新的算分 = 老的算分 \* 投票数$
* 使用 modifier 平滑参数后：$新的算分 = 老的算分 \* log(1 + 投票数)$
* Factor 参数：$新的算分 = 老的算分 \* log(1 + factor \* 投票数)$

3、一致性随机函数

```json
POST /blogs/_search
{
  "query": {
    "function_score": {
      "random_score": {
        "seed": 911119
      }
    }
  }
}
```

* 使用场景：网站的广告需要提高展现率。
* 具体需求：让每个用户能看到不同的随机排名，但是也希望同一个用户访问时，结果的相对顺序保持一致。

4、Boost Mode 和 Max Boost

```json
POST /blogs/_search
{
  "query": {
    "function_score": {
      "query": {
        "multi_match": {
          "query":    "popularity",
          "fields": [ "title", "content" ]
        }
      },
      "field_value_factor": {
        "field": "votes",
        "modifier": "log1p" ,
        "factor": 0.1
      },
      "boost_mode": "sum",
      "max_boost": 3
    }
  }
}
```

Max Boost 可以将算分控制在一个最大值。Boost Mode 用于进行算分的数学运算。

### Suggester

实现搜索引擎中纠错的功能。原理是将文本分解为 Token 然后在索引的字典中查找相似的 Term 并返回。

四种 Suggester 的不同之处：

| Suggester 类型             | 功能与特点                                               | 适用场景                         |
| ------------------------ | --------------------------------------------------- | ---------------------------- |
| **Term Suggester**       | 对输入文本的每个词条进行纠错或建议，基于索引中的词典查找相似 Term。                | 单个词条级别的纠错和建议（如拼写错误修正）。       |
| **Phrase Suggester**     | 在 Term Suggester 基础上，考虑多个词条之间的关系（如是否共同出现、相邻程度等）。    | 多个词组成的短语级别的纠错和建议（如句子片段的修正）。  |
| **Completion Suggester** | 提供前缀匹配的自动补全功能，支持快速搜索建议（如用户输入时的提示）。                  | 快速自动补全（如搜索框输入提示）。            |
| **Context Suggester**    | 基于 Completion Suggester，增加了上下文信息的支持（如地理位置、类别等过滤条件）。 | 需要结合上下文信息的自动补全（如特定分类下的搜索提示）。 |

**示例**

1、插入数据

```json
DELETE articles

POST articles/_bulk
{ "index" : { } }
{ "body": "lucene is very cool"}
{ "index" : { } }
{ "body": "Elasticsearch builds on top of lucene"}
{ "index" : { } }
{ "body": "Elasticsearch rocks"}
{ "index" : { } }
{ "body": "elastic is the company behind ELK stack"}
{ "index" : { } }
{ "body": "Elk stack rocks"}
{ "index" : {} }
{  "body": "elasticsearch is rock solid"}
```

2、Term Suggester

几种 Suggestion Mode

* Missing：如果索引中已经存在，就不提供建议。
* Popular：推荐出现频率更加高的词。
* Always：无论是否存在，都提供建议。

```json
POST /articles/_search
{
  "size": 1,
  "query": {
    "match": {
      "body": "lucen rock"
    }
  },
  "suggest": {
    "term-suggestion": {
      "text": "lucen rock",
      "term": {
        "suggest_mode": "missing",
        "field": "body"
      }
    }
  }
}
```

3、Phrase Suggester

在 Term Suggester 的基础上增加了一些额外的逻辑。

* Max Errors：最多可以拼错的 Terms 数。
* Confidence：控制返回建议的置信度阈值。只有当建议短语的原始得分加上长度归一化后 ≥confidence 时才会被返回，默认为 1。

```json
POST /articles/_search
{
  "suggest": {
    "my-suggestion": {
      "text": "lucne and elasticsear rock hello world ",
      "phrase": {
        "field": "body",
        "max_errors":2,
        "confidence":2,
        "direct_generator":[{
          "field":"body",
          "suggest_mode":"always"
        }],
        "highlight": {
          "pre_tag": "<em>",
          "post_tag": "</em>"
        }
      }
    }
  }
}
```

4、Completion Suggester

提供了自动补全功能。对性能要求比较严苛，采用了非倒排索引的数据结构，将 Analyze 数据编码成 FST 和索引一起存放。FST 会整个加载进内存，速度很快。同时 FST 仅支持前缀查找。

```json
DELETE articles
PUT articles
{
  "mappings": {
    "properties": {
      "title_completion":{
        "type": "completion"
      }
    }
  }
}

POST articles/_bulk
{ "index" : { } }
{ "title_completion": "lucene is very cool"}
{ "index" : { } }
{ "title_completion": "Elasticsearch builds on top of lucene"}
{ "index" : { } }
{ "title_completion": "Elasticsearch rocks"}
{ "index" : { } }
{ "title_completion": "elastic is the company behind ELK stack"}
{ "index" : { } }
{ "title_completion": "Elk stack rocks"}
{ "index" : {} }
```

```json
POST articles/_search?pretty
{
  "size": 0,
  "suggest": {
    "article-suggester": {
      "prefix": "elk ",
      "completion": {
        "field": "title_completion"
      }
    }
  }
}
```

5、Context Suggester

是 Completion Suggester 的拓展，能够在搜索中加入更多的上下文信息。

可以定义两种类型的 Context：

* Category 一任意的字符串。
* Geo—地理位置信息。

实现 Context Suggester 的具体步骤：

* 定制一个 Mapping。
* 索引数据，并且为每个文档加入 Context 信息。
* 结合 Context 进行 Suggestion 查询。

```json
DELETE comments
PUT comments
PUT comments/_mapping
{
  "properties": {
    "comment_autocomplete":{
      "type": "completion",
      "contexts":[{
        "type":"category",
        "name":"comment_category"
      }]
    }
  }
}

POST comments/_doc
{
  "comment":"I love the star war movies",
  "comment_autocomplete":{
    "input":["star wars"],
    "contexts":{
      "comment_category":"movies"
    }
  }
}

POST comments/_doc
{
  "comment":"Where can I find a Starbucks",
  "comment_autocomplete":{
    "input":["starbucks"],
    "contexts":{
      "comment_category":"coffee"
    }
  }
}
```

```json
POST comments/_search
{
  "suggest": {
    "MY_SUGGESTION": {
      "prefix": "sta",
      "completion":{
        "field":"comment_autocomplete",
        "contexts":{
          "comment_category":"coffee"
        }
      }
    }
  }
}
```

## 分布式

### 节点

* 节点是一个 ElasticSearch 示例
  * 其本质就是一个 Java 进程
  * 一个机器上可以运行多个示例但生产环境推荐只运行一个
* 每一个节点都有名字，通过配置文件配置
* 每一个节点启动后都会分配一个 UID，保存在 data 目录下

#### Coordinating Node

* 处理请求的节点，叫 Coordinating Node
  * 路由请求到正确的节点，例如创建索引的请求，需要路由到 Master
* 所有节点默认都是 Coordinating Node
* 通过将其他类型设置成 False，使其成为 Dedicated Coordinating Node

#### Data Node

* 可以保存数据的节点，叫做 Data Node
  * 节点启动后，默认就是数据节点。可以设置 node.data:false 禁止
* Data Node 的职责
  * 保存分片数据。在数据扩展上起到了至关重要的作用（由 Master Node 决定如何把分片分发到数据节点上）
* 通过增加数据节点
  * 可以解决数据水平扩展和解决数据单点问题

#### Master Node

* Master Node 的职责
  * 处理创建，删除索引等请求/决定分片被分配到哪个节点 /负责索引的创建与删除
  * 维护并且更新 Cluster State
* Master Node 的最佳实践
  * Master 节点非常重要，在部署上需要考虑解决单点的问题
  * 为一个集群设置多个 Master 节点/每个节点只承担 Master 的单一角色

#### Master Eligible Nodes

* 一个集群，支持配置多个 Master Eligible 节点。这些节点可以在必要时（如 Master 节点出现故障，网络故障时）参与选主流程，成为 Master 节点
* 每个节点启动后，默认就是一个 Master Eligible 节点
  * 可以设置 node.master: false 禁止
* 当集群内第一个 Master Eligible 节点启动时候，它会将自己选举成 Master 节点

#### 选主过程

* 互相 Ping 对方，Node ld 低的会成为被选举的节点
* 其他节点会加入集群，但是不承担 Master 节点的角色。一旦发现被选中的主节点丢失，就会选举出新的 Master 节点

#### 脑裂问题

* Split-Brain，分布式系统的经典网络问题，当出现网络问题，一个节点和其他节点无法连接
  * Node 2 和 Node 3 会重新选举 Master
  * Node 1 自己还是作为 Master 组成一个集群，同时更新 Cluster State
  * 导致 2 个 Master 维护不同的 Cluster State，当网络恢复时，无法选择正确恢复

**解决方法**

* 限定选举条件，设置 quorum（仲裁），只有当 Master Eligible 节点数大于 quorum 时才能进行选举
* 7.0 后无需配置

### 分片

#### Primary Shard

* 分片是 ElasticSearch 分布式存储的基石（主分片 / 副本分片）
* 通过主分片，将数据分布在所有节点上
  * Primary Shard 可以将一份索引的数据分散在多个 Data Node 上，实现存储的水平扩展
  * 主分片数在索引创建时候指定，后续默认不能修改，如要修改需重建索引

#### Replica Shard

* 数据可用性
  * 通过引入副本分片（Replica Shard）提高数据的可用性。一旦主分片丢失，副本分片可以 Promote 成主分片。副本分片数可以动态调整。每个节点上都有完备的数据。如果不设置副本分片，一旦出现节点硬件故障，就有可能造成数据丢失
* 提升系统的读取性能
  * 副本分片由主分片（Primary Shard）同步。通过支持增加 Replica 个数，一定程度可以提高读取的吞吐量

#### 分片数的设定

* 如何规划一个索引的主分片数和副本分片数
  * 主分片数过小：例如创建了 1 个 Primary Shard 的 Index。如果该索引增长很快，集群无法通过增加节点实现对这个索引的数据扩展
  * 主分片数设置过大：导致单个 Shard 容量很小，引发一个节点上有过多分片，影响性能
  * 副本分片数设置过多，会降低集群整体的写入性能

#### 集群健康状态

```json
GET /_cluster/health

{
  "cluster_name" : "lanlance",
  "status" : "green",
  "timed_out" : false,
  "number_of_nodes" : 2,
  "number_of_data_nodes" : 2,
  "active_primary_shards" : 21,
  "active_shards" : 42,
  "relocating_shards" : 0,
  "initializing_shards" : 0,
  "unassigned_shards" : 0,
  "delayed_unassigned_shards" : 0,
  "number_of_pending_tasks" : 0,
  "number_of_in_flight_fetch" : 0,
  "task_max_waiting_in_queue_millis" : 0,
  "active_shards_percent_as_number" : 100.0
}
```

* Green：健康状态，所有的主分片和副本分片都可用
* Yellow：亚健康，所有的主分片可用，部分副本分片不可用
* Red：不健康状态，部分主分片不可用

#### 文档到分片的路由算法

* $shard = hash(routing) / 主分片数$
  * Hash 算法确保文档均匀分散到分片中
  * 默认 routing 值是文档 id
  * 可以自行制定 routing 值，与业务逻辑绑定也可以
  * 是 Primary Shard 数不能修改的根本原因

**删除一个文档的流程**

![](https://raw.githubusercontent.com/L2ncE/images/main/PicGo/20250526185743387.png)

#### 分片的内部原理

**倒排索引的不可变性**

倒排索引采用 Immutable Design，一旦生成，不可更改

不可变性，带来了的好处如下：

* 无需考虑并发写文件的问题，避免了锁机制带来的性能问题
* 一旦读入内核的文件系统缓存，便留在哪里。只要文件系统存有足够的空间，大部分请求就会直接请求内存，不会命中磁盘，提升了很大的性能
* 缓存容易生成和维护/数据可以被压缩

但坏处是如果需要让一个新的文档可以被搜索，需要重建整个索引。

**Lucene Index**

* 在 Lucene 中，单个倒排索引文件被称为 Segment。Segment 是自包含的，不可变更的。多个 Segments 汇总在一起称为 Lucene 的 Index，其对应的就是 ES 中的 Shard
* 当有新文档写入时，会生成新 Segment，查询时会同时查询所有 Segments，并且对结果汇总。Lucene 中有一个文件用来记录所有 Segments 信息，叫做 Commit Point

![](https://raw.githubusercontent.com/L2ncE/images/main/PicGo/20250526191215481.png)

**Refresh**

* 将 Index buffer 写入 Segment 的过程叫 Refresh。Refresh 不执行 fsync 操作
* Refresh 默认 1 秒发生一次，可通过 index.refresh\_interval 配置。Refresh 后数据就可以被搜索到了。这也是为什么 ElasticSearch 被称为近实时搜索
* 如果系统有大量的数据写入，那就会产生很多的 Segment
* Index Buffer 被占满时会触发 Refresh，默认值是 JVM 的 10％

**Transaction Log**

* Segment 写入磁盘的过程相对耗时，借助文件系统缓存，Refresh 时先将 Segment 写入缓存以开放查询
* 为了保证数据不会丢失，所以在 Index 文档时同时写 Transaction Log，高版本开始 Transaction Log 默认落盘。每个分片有一个 Transaction Log
* 在 ES Refresh 时 Index Buffer 被清空，Transaction log 不会清空

**Flush**

* 调用 Refresh，清空 Index Buffer
* 调用 fsync，将缓存中的 Segments 写入磁盘
* 清空 Transaction Log

默认 30 分钟调用一次，当 Transaction Log 满时（默认 512 MB）也会调用

**Merge**

* Segment 很多，需要被定期合并
  * 减少 Segments / 真正删除已经删除的文档
* ES 和 Lucene 会自动进行 Merge 操作
  * POST my\_index / \_forcemerge

#### 分布式搜索的运行机制

ElasticSearch 的搜索会分为 Query 和 Fetch 两阶段进行。

![](https://raw.githubusercontent.com/L2ncE/images/main/PicGo/imagesPasted%20image%2020250601172246.png)

**Query**

* 用户发出搜索请求到 ES 节点。节点收到请求后，会以 Coordinating 节点的身份，在 6 个主副分片中随机选择 3 个分片，发送查询请求。
* 被选中的分片执行查询，进行排序。每个分片都会返回 From+Size 个排序后的文档 Id 和排序值给 Coordinating 节点。

**Fetch**

* Coordinating Node 会将 Query 阶段从每个分片获取的排序后的文档 Id 列表重新进行排序。选取 From 到 From+Size 个文档的 Id。
* 以 multiget 请求的方式到相应的分片获取详细的文档数据。

潜在有性能不好和相关性算分不准的问题。

**解决算分不准的问题**

* 数据量不大的时候主分片数设置为 1，数据量大的时候保证文档均匀分散在各个分片上。
* 使用 DFS Query Then Fetch。会进行一次完整的相关性算法，耗费更多资源，性能不好。

#### 排序

* 排序是针对字段原始内容进行的，倒排索引无法发挥作用，需要正排索引。
* ElasticSearch 中有两种实现方法。
  * FieldData
  * Doc Values（列式存储，对 Text 类型无效）

Doc Values 和 Field Data 比较：

| 特性       | Doc Values                                    | Field Data                       |
| -------- | --------------------------------------------- | -------------------------------- |
| **存储位置** | **磁盘** (内存映射访问)                               | **堆内存 (JVM Heap)**               |
| **加载时机** | **按需加载** (惰性加载到 OS 缓存)                        | **按需构建** (首次用于聚合/排序时构建在内存中)      |
| **数据结构** | **列式存储** (按文档 ID 组织值)                         | **列式存储** (按段构建)                  |
| **适用字段** | `keyword`, `numeric`, `date`, `ip`, `boolean` | `text` (默认关闭)，其他字段类型 (已废弃)       |
| **默认启用** | **是** (对于支持它的字段类型)                            | **否** (尤其对于 `text` 字段，7.0+ 默认关闭) |
| **内存占用** | **低** (利用 OS 文件缓存，不直接占用 JVM 堆)                | **高** (直接占用 JVM 堆内存)             |
| **垃圾回收** | **无影响** (由 OS 管理缓存)                           | **显著影响** (对象在堆上，易引发 GC 压力)       |
| **适用操作** | 聚合、排序、脚本 (高效)                                 | `text` 字段聚合 (分词后的词条)             |
| **安全性**  | **高** (不易引发 OOM)                              | **低** (不当配置易导致节点 OOM)            |
| **版本趋势** | **推荐并默认**                                     | **仅限 `text` 字段聚合需求** (其他字段已弃用)   |

### 分页和遍历

#### 分布式系统中深度分页的问题

* ES 天生就是分布式的。查询信息同时数据保存在多个分片、多台机器上，ES 天生就需要满足排序的需要（按照相关性算分）。
* 当一个查询：From=990，Size =10。会在每个分片上先都获取 1000 个文档。通过 Coordinating Node 聚合所有结果。最后再通过排序选取前 1000 个文档。
* 页数越深，占用内存越多。为了避免深度分页带来的内存开销。ES 有一个设定，默认限定到 10000 个文档。

#### 使用 Search After 避免深度分页问题

* 避免深度分页的性能问题，可以实时获取下一页文档信息
  * 不支持指定页数 (From)
  * 只能往下翻
* 第一步搜索需要指定 sort，并且保证值是唯一的 (可以通过加入 id 保证唯一性)
* 然后使用上一次最后一个文档的 sort 值进行查询。

**示例**

1、插入数据

```json
POST users/_doc
{"name":"user1","age":10}
POST users/_doc
{"name":"user2","age":11}
POST users/_doc
{"name":"user2","age":12}
POST users/_doc
{"name":"user2","age":13}
```

2、执行查询

```json
POST users/_search
{
    "size": 1,
    "query": {
        "match_all": {}
    },
    "sort": [
        {"age": "desc"} ,
        {"_id": "asc"}    
    ]
}

POST users/_search
{
    "size": 1,
    "query": {
        "match_all": {}
    },
    "search_after":
        [
          10,
          "ZQ0vYGsBrR8X3IP75QqX"],
    "sort": [
        {"age": "desc"} ,
        {"_id": "asc"}    
    ]
}
```

#### Scroll API

**Scroll API** 是 Elasticsearch 为**大数据集深度遍历**设计的查询机制，通过创建**快照式上下文**（Snapshot Context）保证分页一致性，适用于离线导出、全量迁移等场景。

**示例**

```json
DELETE users
POST users/_doc
{"name":"user1","age":10}
POST users/_doc
{"name":"user2","age":20}
POST users/_doc
{"name":"user3","age":30}
POST users/_doc
{"name":"user4","age":40}

POST /users/_search?scroll=5m
{
    "size": 1,
    "query": {
        "match_all" : {
        }
    }
}

// 这条数据无法查到
POST users/_doc
{"name":"user5","age":50}

POST /_search/scroll
{
    "scroll" : "1m",
    "scroll_id" : "DXF1ZXJ5QW5kRmV0Y2gBAAAAAAAAAWAWbWdoQXR2d3ZUd2kzSThwVTh4bVE0QQ=="
}
```

**Scroll API 与 Search After 的对比**

| **特性**     | **Search After**     | **Scroll API**                   |
| ---------- | -------------------- | -------------------------------- |
| **设计目标**   | 实时深度分页（用户交互场景）       | 大数据集离线遍历（导出/迁移）                  |
| **实时性**    | 基于**当前索引状态**（实时可见变更） | **快照冻结**（创建后索引变更不可见）             |
| **内存消耗**   | 低（无服务端状态）            | 高（服务端维护上下文，占用堆内存）                |
| **分页一致性**  | 依赖 PIT 保障一致性         | 天然一致性（快照隔离）                      |
| **适用场景**   | 用户界面逐页浏览（如订单列表翻页）    | 全量数据导出、ETL 迁移、离线分析               |
| **是否支持跳页** | ❌ 仅顺序连续分页            | ❌ 仅顺序连续遍历                        |
| **资源释放**   | 无状态（客户端自主管理游标）       | 需显式删除 Scroll ID（否则超时释放）          |
| **性能开销**   | 低（分片级游标定位）           | 中（维护上下文，但比 `from/size` 高效）       |
| **最大深度**   | 仅受文档总数限制             | 同左                               |
| **推荐排序方式** | 业务字段 + `_id`（确保唯一性）  | `["_doc"]`（最高效，避免排序计算）           |
| **版本演进**   | 主流实时分页方案（结合 PIT 使用）  | 逐渐被 **Async Search** 替代（大数据异步查询） |

### 并发控制

ES 使用乐观锁进行并发控制。

#### ES 的乐观并发控制

ES 中的文档是不可变更的。如果你更新一个文档，会将就文档标记为删除，同时增加一个全新的文档。同时文档的 version 字段加 1。

**示例**

```json
DELETE products
PUT products
PUT products/_doc/1
{
  "title":"iphone",
  "count":100
}

// success
PUT products/_doc/1?if_seq_no=1&if_primary_term=1
{
  "title":"iphone",
  "count":100
}

// fail
PUT products/_doc/1?if_seq_no=1&if_primary_term=1
{
  "title":"iphone",
  "count":102
}

// success
PUT products/_doc/1?version=30000&version_type=external
{
  "title":"iphone",
  "count":100
}
```

## 数据建模

### 在 ElasticSearch 中处理关联关系

关系型数据库，一般会考虑 Normalize 数据；在 Elasticsearch，往往考虑 Denormalize 数据。

> Denormalize 的好处：读的速度变快/无需表连接/无需行锁

Elasticsearch 并不擅长处理关联关系。我们一般采用以下四种方法处理关联：

* 对象类型
* 嵌套对象 (Nested Object)
* 父子关联关系 (Parent/Child）
* 应用端关联

| 特性       | 对象类型            | 嵌套对象           | 父子关联                            | 应用端关联             |
| -------- | --------------- | -------------- | ------------------------------- | ----------------- |
| **本质**   | 默认处理 JSON 对象的方式 | 特殊的对象类型        | 同一索引内文档间的逻辑链接                   | 由应用程序维护关联         |
| **存储结构** | 对象属性扁平化存储到父文档   | 作为父文档内部的独立隐藏文档 | 父子文档独立存储在同一分片                   | 关联数据存储在不同文档或外部系统  |
| **数据边界** | 无边界，对象属性合并到父文档  | 有边界，每个嵌套对象独立存储 | 父子文档完全独立                        | 完全独立，无 ES 内部关联    |
| **查询特点** | 可能匹配不同对象的字段组合   | 确保条件匹配同一嵌套对象内部 | 需用 `has_child`、`has_parent` 等查询 | 需多次查询，先查主文档再查关联文档 |
| **更新特点** | 更新需重索引整个父文档     | 更新需重索引整个父文档    | 可单独更新子文档，不影响父文档                 | 每个文档可单独更新         |
| **适用场景** | 简单键值对对象，无需独立查询  | 对象数组需独立查询和匹配   | 子文档频繁更新或数量大                     | 关系简单、查询量小或数据分散    |
| **缺点**   | 无法精确匹配数组内对象组合   | 更新成本高，嵌套数量有限制  | 查询性能低，内存消耗大                     | 网络开销大，应用逻辑复杂      |

#### Nested Data Type

* Nested 数据类型：允许对象数组中的对象被独立索引。
* 使用 nested 和 properties 关键字，将所有数组索引到多个分隔的文档。
* 在内部，Nested 文档会被保存在两个 Lucene 文档中，在查询时做 Join 处理。

**示例**

```json
PUT my_movies
{
    "mappings": {
        "properties": {
            "actors": {
                "type": "nested",
                "properties": {
                    "first_name": {
                        "type": "keyword"
                    },
                    "last_name": {
                        "type": "keyword"
                    }
                }
            },
            "title": {
                "type": "text",
                "fields": {
                    "keyword": {
                        "type": "keyword",
                        "ignore_above": 256
                    }
                }
            }
        }
    }
}
```

若没有指定 `"type": "nested"` 时，数组数据会扁平化存储，搜索会出现不该出现的内容。

#### Parent / Child

对象和 Nested 对象每次更新都需要重新索引整个对象。

ES 提供了类似关系型数据库中 Join 的实现。可通过维护 Parent / Child 的关系，从而分离两个对象。

* 父文档和子文档是两个独立的文档。
* 更新父文档无需重新索引子文档。子文档被添加，更新或者删除也不会影响到父文档和其他的子文档。

**示例**

1、添加数据

```json
PUT my_blogs
{
  "settings": {
    "number_of_shards": 2
  },
  "mappings": {
    "properties": {
      "blog_comments_relation": {
        "type": "join",
        "relations": {
          "blog": "comment"
        }
      },
      "content": {
        "type": "text"
      },
      "title": {
        "type": "keyword"
      }
    }
  }
}

// 索引父文档
PUT my_blogs/_doc/blog2
{
  "title":"Learning Hadoop",
  "content":"learning Hadoop",
    "blog_comments_relation":{
    "name":"blog"
  }
}


// 索引子文档
PUT my_blogs/_doc/comment1?routing=blog1
{
  "comment":"I am learning ELK",
  "username":"Jack",
  "blog_comments_relation":{
    "name":"comment",
    "parent":"blog1"
  }
}

// 索引子文档
PUT my_blogs/_doc/comment2?routing=blog2
{
  "comment":"I like Hadoop!!!!!",
  "username":"Jack",
  "blog_comments_relation":{
    "name":"comment",
    "parent":"blog2"
  }
}

// 索引子文档
PUT my_blogs/_doc/comment3?routing=blog2
{
  "comment":"Hello Hadoop",
  "username":"Bob",
  "blog_comments_relation":{
    "name":"comment",
    "parent":"blog2"
  }
}
```

2、父子关系查询

```json
// 根据父文档ID查看
GET my_blogs/_doc/blog2

// 根据 Parent Id 查询
POST my_blogs/_search
{
  "query": {
    "parent_id": {
      "type": "comment",
      "id": "blog2"
    }
  }
}

// has_child 查询，返回父文档
POST my_blogs/_search
{
  "query": {
    "has_child": {
      "type": "comment",
      "query" : {
            "match": {
                "username" : "Jack"
            }
        }
    }
  }
}

// has_parent 查询，返回相关的子文档
POST my_blogs/_search
{
  "query": {
    "has_parent": {
      "parent_type": "blog",
      "query" : {
			"match": {
				"title" : "Learning Hadoop"
			}
		}
    }
  }
}
```

### 索引重建

需要索引重建的场景如下：

* 索引的 Mappings 发生变更：字段类型更改，分词器及字典更新。
* 索引的 Settings 发生变更：索引的主分片数发生改变。
* 集群内，集群间需要做数据迁移。

索引重建的方式：

* Update By Query：在现有索引上重建。
* Reindex：在其他索引上重建索引。

| 特性   | Update By Query          | Reindex                 |
| ---- | ------------------------ | ----------------------- |
| 核心操作 | 原地更新现有索引文档               | 复制数据到新索引                |
| 操作对象 | 单个索引                     | 源索引和目标索引                |
| 数据迁移 | 不支持                      | 支持                      |
| 索引结构 | 不可更改 Mapping/Settings/分片 | 可更改 Mapping/Settings/分片 |
| 主要用途 | 批量修改文档内容或删除文档            | 重建索引结构/迁移数据             |
| 性能影响 | 对原索引读写压力大                | 源索引读压力，目标索引写压力          |
| 原子性  | 非原子操作，失败需手动处理            | 目标索引独立，失败可重试            |
| 适用场景 | 字段值更新、文档删除               | 更改字段类型、分片数或跨集群迁移等       |

### 字段建模

```mermaid
flowchart LR

    字段类型["字段类型"] --> 是否要搜索及分词["是否要搜索及分词"]

    是否要搜索及分词 --> a1["是否要聚合及排序"]

    a1 --> 是否要额外的存储

```

Mapping 字段的相关设置：

* Enabled —— 设置成 false，仅做存储，不支持搜索和聚合分析（数据保存在 \_source 中）。
* Index —— 是否构建倒排索引。设置成 false，无法被搜索，但还是支持 aggregation，并出现在 \_source 中。
* Norms —— 如果字段用来过滤和聚合分析，可以关闭，节约存储。
* Doc\_values —— 是否启用 doc\_values，用于排序和聚合分析。
* Field\_data —— 如果要对 text 类型启用排序和聚合分析，fielddata 需要设置成 true。
* Store —— 默认不存储，数据默认存储在 \_source。
* Coerce —— 默认开启，是否开启数据类型的自动转换（例如，字符串转数字）。
* Multifields 多字段特性。
* Dynamic —— true/false/strict 控制 Mapping 的自动更新。

## DevOps

### 集群部署最佳实践

在生产环境中建议设置单一角色的节点。

* Dedicated master eligible nodes：负责集群状态的管理。使用低配置的 CPU，RAM 和磁盘。
* Dedicated data nodes：负责数据存储及处理客户端请求使用高配置的 CPU，RAM 和磁盘。
* Dedicated ingest nodes：负责数据处理使用高配置 CPU，中等配置的 RAM，低配置的磁盘。

从高可用 & 避免脑裂的角度出发，一般生产环境需要配置 3 台 Delicate Master Node。

#### 拓展方式

* 当磁盘容量无法满足需求时，可以增加数据节点；磁盘读写压力大时，增加数据节点。
* 当系统中有大量的复杂查询及聚合时候，增加 Coordinating 节点，增加查询的性能。

### Hot & Warm Architecture

* Hot & Warm Architecture
  * 数据通常不会有 Update 操作；适用于 Time based 索引数据（生命周期管理），同时数据量比较大的场景。
  * 引入 Warm 节点，低配置大容量的机器存放老数据，以降低部署成本。
* 两类数据节点，不同的硬件配置
  * Hot 节点（通常使用 SSD）：索引有不断有新文档写入。通常使用 SSD。
  * Warm 节点（通常使用 HDD）：索引不存在新数据的写入；同时也不存在大量的数据查询。

### 分片设计

#### 单个分片

* 7.0 开始，新创建一个索引时，默认只有一个主分片。
  * 单个分片，查询算分，聚合不准的问题都可以得以避免。
* 单个索引，单个分片时候，集群无法实现水平扩展。
  * 即使增加新的节点，无法实现水平扩展。

#### 两个分片

新增节点后，ElasticSearch 会自动进行分片的移动（Shard Rebalancing）。也就实现了水平拓展。

#### 如何设计分片数

* 当分片数＞节点数时
  * 一旦集群中有新的数据节点加入，分片就可以自动进行分配。
  * 分片在重新分配时，系统不会有 downtime。
* 多分片的好处：一索引如果分布在不同的节点，多个节点可以并行执行
  * 查询可以并行执行。
  * 数据写入可以分散到多个机器。

**如何确定主分片数**

* 从存储的物理角度看
  * 日志类应用，单个分片不要大于 50GB
  * 搜索类应用，单个分片不要超过 20GB
* 为什么要控制分片存储大小
  * 提高 Update 的性能
  * Merge 时，减少所需的资源
  * 丢失节点后，具备更快的恢复速度/便于分片在集群内 Rebalancing

**如何确定副本分片数**

* 副本是主分片的拷贝
  * 提高系统可用性：相应查询请求，防止数据丢失。
  * 需要占用和主分片一样的资源。
* 对性能的影响
  * 副本会降低数据的索引速度：有几份副本就会有几倍的 CPU 资源消耗在索引上。
  * 会减缓对主分片的查询压力，但是会消耗同样的内存资源。
    * 如果机器资源充分，提高副本数，可以提高整体的查询 QPS。

### 集群容量规划

* 一个集群总共需要多少个节点？一个索引需要设置几个分片?
  * 规划上需要保持一定的余量，当负载出现波动，节点出现丢失时，还能正常运行。
* 做容量规划时，一些需要考虑的因素
  * 机器的软硬件配置。
  * 单条文档的尺寸 / 文档的总数据量 / 索引的总数据量（Time base 数据保留的时间）/ 副本分片数。
  * 文档是如何写入的（Bulk 的尺寸)。
  * 文档的复杂度，文档是如何进行读取的（怎么样的查询和聚合）。

#### 业务性能评估

* 数据呑吐及性能需求
  * 数据写入的吞吐量，每秒要求写入多少数据？
  * 查询的吞吐量？
  * 单条查询可接受的最大返回时间？
* 了解你的数据
  * 数据的格式和数据的 Mapping。
  * 实际的查询和聚合长的是什么样的。

#### 硬件配置

* 数据节点尽量用 SSD。
* 日志类或并发查询低的场景可以用机械硬盘。
* 单节点数据控制在 2TB 以内，最高不超过 5TB。
* JVM 配置机器内存的一半，不建议超过 32G。

#### 集群扩容

* 增加 Coordinating / Ingest Node
  * 解决 CPU 和内存开销的问题。
* 增加数据节点
  * 解决存储的容量的问题。
  * 为避免分片分布不均的问题，要提前监控磁盘空间，提前清理数据或增加节点（70%）。

### 监控 & 排查 API

| **类别**         | **API 路径**                   | **核心用途**  | **关键返回字段说明**                                                             | **生产环境关注点**                                 |
| -------------- | ---------------------------- | --------- | ------------------------------------------------------------------------ | ------------------------------------------- |
| **集群健康**       | `GET /_cluster/health`       | 集群整体状态概览  | `status`(green/yellow/red), `unassigned_shards`, `active_shards_percent` | 红色状态立即处理；黄色状态检查未分配分片                        |
| **节点资源**       | `GET /_nodes/stats`          | 节点级资源监控   | `heap_used_percent`, `cpu.percent`, `disk_free_percent`                  | Heap >75% 需扩容；磁盘<20% 需清理或扩容                 |
| **索引性能**       | `GET /_cat/indices?v`        | 索引存储与文档统计 | `docs.count`, `store.size`, `pri.store.size`                             | 分片数合理性；定期清理无用索引                             |
| **慢查询定位**      | `GET /_search?pretty&q=slow` | 定位高延迟请求   | `took` 时间（需预设慢日志阈值）                                                      | 优化 `took` >1s 的查询/索引设计                      |
| **线程池积压**      | `GET /_cat/thread_pool?v`    | 线程队列监控    | `search.queue`, `write.queue`                                            | 队列>0 表示资源瓶颈，需扩容或限流                          |
| **分片分布**       | `GET /_cat/shards?v`         | 分片负载均衡检测  | `node`, `state`, `store`                                                 | 均衡分片分布；避免单节点负载过高                            |
| **任务监控**       | `GET /_tasks?detailed`       | 长任务跟踪     | `running_time_in_nanos`, `description`                                   | 监控耗时任务（如 reindex），避免阻塞集群                    |
| **磁盘水位**       | `GET /_cat/allocation?v`     | 节点磁盘使用率   | `disk.avail`, `disk.used`                                                | 预警 `disk.avail` <30% 的节点                    |
| **Segment 状态** | `GET /_cat/segments?v`       | 索引碎片监控    | `segment.count`, `size`                                                  | 过多小 segment 增加 Heap 压力；触发 `force_merge` 需谨慎 |
| **JVM 状态**     | `GET /_nodes/stats/jvm`      | 垃圾回收监控    | `young.collection_count`, `old.collection_time_in_millis`                | Young GC >2 次/秒或 Old GC >10 秒/次需调优堆大小       |

#### 关键监控指标 API 详解

1. **集群健康 (`/_cluster/health`)**
   * `status: red` 表示主分片缺失（数据丢失风险）
   * `unassigned_shards` 常见原因：节点离线或分片分配规则冲突 。
2. **节点资源 (`/_nodes/stats`)**
   * **JVM Heap 示例**：

     ```json
     "jvm": { "mem": { "heap_used_percent": 65 } }
     ```

持续>75% 需扩容或优化内存（如减少 fielddata/cache）。

3. **线程池积压 (`/_cat/thread_pool`)**

   ```bash
   node2 search 8 0 8 0 # search.queue=8 表示队列积压
   ```

`queue>0` 需扩容或优化查询并发度 。

4. **慢查询阈值配置**

   在 `elasticsearch.yml` 中设置：

   ```yaml
   index.search.slowlog.threshold.query.warn: 10s
   index.search.slowlog.threshold.query.info: 5s
   ```

#### 生产环境排查流程建议

1. **集群异常** → 用 `GET /_cluster/allocation/explain` 定位未分配分片原因。
2. **节点负载高** → 执行 `GET /_nodes/hot_threads` 抓取热点线程栈。
3. **查询延迟** → 使用 `GET /_search?profile=true` 分析执行计划。
4. **磁盘告警** → 优先清理大型临时索引或扩容冷节点 。

#### 分片没有被分配的一些原因

* INDEX\_CREATE：创建索引导致。在索引的全部分片分配完成之前，会有短暂的 Red，不一定代表有问题。
* CLUSTER\_RECOVER：集群重启阶段会有这个问题。
* INDEX\_REOPEN：Open 一个之前 Close 的索引。
* DANGLING\_INDEX\_IMPORTED：一个节点离开集群期间，有索引被删除。这个节点重新返回时，会导致问题。

#### 集群变红的常见问题与解决方法

* 集群变红，需要检查是否有节点离线。如果有，通常通过重启离线的节点可以解决问题。
* 由于配置导致的问题，需要修复相关的配置（例如错误的 box\_type，错误的副本数）。如果是测试的索引，可以直接删除。
* 因为磁盘空间限制，分片规则（ShardFiltering）引发的，需要调整规则或者增加节点。

### 读写性能优化

#### 写性能优化

目标增大写吞吐量，越高越好。

* 客户端：多线程批量写，通过性能测试确定最佳并发度。
* 服务端：
  * 使用更好的硬件，观察 CPU / IO Block。
  * 降低 IO 操作，使用 ES 自动生成文档 id。
  * 降低 CPU 和存储开销，减少不必要的分词。
  * 尽可能做到写入和分片的均衡负载，实现水平扩展。

一切优化都基于高质量的数据建模。

**Bulk、线程池和队列大小**

* 客户端
  * 单个 bulk 请求体的数据量不要太大，官方建议大约 5-15mb。
  * 写入端的 bulk 请求超时需要足够长，建议 60s 以上。
  * 写入端尽量将数据轮询打到不同节点。
* 服务器端
  * 索引创建属于计算密集型任务，应该使用固定大小的线程池来配置。来不及处理的放入队列，线程数应该配置成 CPU 核心数 +1，避免过多的上下文切换。
  * 队列大小可以适当增加，不要过大，否则占用的内存会成为 GC 的负担。

#### 读性能优化

尽可能 Denormalize 数据，从而获取最佳的性能。

* 避免查询的时候使用脚本。
* 避免使用通配符开头的正则。
* 控制聚合的数量。

**优化分片**

* 避免 Over Sharding。
* 控制单个分片的大小，查询场景尽量 20GB 以内。
* 将只读的索引进行 force merge。

### 段合并优化

ES 和 Lucene 会自动进行 Merge 操作，该操作较重，需要优化降低对系统的影响。

1. 降低分段产生的数量/频率

* 可以将 Refresh Interval 调整到分钟级别。
* 尽量避免文档的更新操作。

2. 降低最大分段大小，避免较大的分段继续参与 Merge，节省系统资源。

* `Index.merge.policy.max_merged_segment` 默认 5GB，操作此大小以后就不再参与后续的合并操作。

#### Force Merge

当 Index 不再有写入操作的时候，建议对其进行 force merge。能够提升查询速度/减少内存开销。

**最终分成几个 segments 比较合适?**

* 越少越好，最好可以 merge 成 1 个，但是 Force Merge 会占用大量的网络、IO 和 CPU。
* 如果不能在业务高峰期之前做完，就需要考虑增大最终的分段数。包括 Shard 的大小、`Index.merge.policy.max_merged_segment` 的大小。

### 缓存与内存

| 缓存类型                                | 作用域 | 主要作用                        | 触发条件               | 失效条件                          |
| ----------------------------------- | --- | --------------------------- | ------------------ | ----------------------------- |
| **Node Query Cache (Filter Cache)** | 节点级 | 缓存过滤查询（Filter）的结果（文档 ID 位图） | `filter` 上下文查询     | 索引数据变更、手动清除、段合并、LRU 淘汰        |
| **Shard Request Cache**             | 分片级 | 缓存整个搜索请求的完整结果（如聚合查询）        | `size:0` 的请求或显式启用  | 索引数据变更、手动清除、分片 Refresh、LRU 淘汰 |
| **Fielddata Cache**                 | 分片级 | 缓存字段值到文档 ID 的映射（用于排序、聚合）    | 对 `text` 字段进行聚合/排序 | 手动清除、段合并、LRU 淘汰               |

#### 管理内存的重要性

ElasticSearch 高效运维依赖于内存的合理分配。可用内存一半分配给 JVM，一半留给操作系统缓存索引文件。

内存问题可能会导致 GC 和 OOM 问题。

#### 一些常见的内存问题

* Segments 个数过多，导致 full GC。集群响应慢但没有特别多是读写，节点在持续 full GC。查看内存发现 `segments.memory` 占用很大空间。通过 force merge 段合并。
* Field data cache 过大，导致 full GC。集群响应慢但没有特别多是读写，节点在持续 full GC。查看内存发现 `fielddata.memory.size` 占用很大空间。将 `indices.fielddata.cache.size` 设小，重启节点，堆内存恢复正常。

#### Circuit Breaker

| 熔断器名称                                  | 主要功能                              | 触发条件（默认阈值）                   |
| -------------------------------------- | --------------------------------- | ---------------------------- |
| **Parent Circuit Breaker**             | 总内存保护，防止所有子熔断器总和超过节点内存            | 所有子熔断器申请内存总和 > **95% JVM 堆** |
| **Fielddata Circuit Breaker**          | 防止加载字段数据（如 `text` 字段聚合）耗尽堆内存      | 字段数据内存 > **40% JVM 堆**       |
| **Request Circuit Breaker**            | 限制单个请求（查询、聚合等）的内存使用，防止大请求压垮节点     | 单个请求内存 > **60% 父熔断器剩余内存**    |
| **In Flight Requests Circuit Breaker** | 限制传输中请求（未完成）的总内存，保护网络和内存资源        | 传输中请求总内存 > **100% JVM 堆**    |
| **Accounting Circuit Breaker**         | 跟踪分片级资源（如 Lucene 段内存），确保资源释放后及时回收 | 未释放资源 > **100% JVM 堆**       |
| **Script Compilation Circuit Breaker** | 限制一定时间窗口内脚本编译次数，防止频繁编译消耗 CPU      | **15 分钟内编译次数 > 75** (默认)     |
| **Disk Usage Circuit Breaker**         | 防止节点磁盘写满导致集群故障（触发只读保护）            | 磁盘空间 > **低水位线 (默认 85%)**     |

> 💡 注：阈值可通过配置调整（如 `indices.breaker.fielddata.limit`），触发后返回 `429 (Too Many Requests)` 错误。

## Demo

### 全文搜索

仓库地址：[L2ncE/es101](https://github.com/L2ncE/es101)

`tmdb-search` 是电影搜索项目，主要用于索引和搜索 TMDB（The Movie Database）的电影数据。

#### 项目组成部分

1. **数据处理脚本**：

* `ingest_tmdb_from_file.py`: 将 TMDB 数据导入 Elasticsearch
* `ingest_tmdb_to_appserarch.py`: 将数据导入 AppSearch（可选功能）
* `query_tmdb.py`: 搜索接口实现

2. **映射配置**：

* `mapping/english_analyzer.json`: 默认英文分析器配置
* `mapping/english_english_3_shards.json`: 3 分片的配置版本

3. **查询示例**：

* 多个针对 "Space Jam" 电影的查询示例，展示不同的查询策略

#### 快速开始

1. **启动服务**

确保已安装所需 Python 包和 Python3 环境：

```bash
pip install -r requirements.txt
```

ElasticSearch 的安装详见上方快速开始章节内容。

2. **导入数据**：

```bash
python ingest_tmdb_from_file.py
```

* 运行后会提示选择 mapping 配置。
* 选择 0 使用默认配置，或选择其他预定义配置。

3. **搜索电影**：

```bash
# 普通搜索
python query_tmdb.py

# 带高亮显示的搜索
python query_tmdb.py highlight
```

* 运行后会提示选择查询文件
* 结果会显示相关度分数和标题
* 使用 highlight 参数可以显示匹配高亮

#### 整体流程

```mermaid
graph LR
    subgraph 数据层
        TF[tmdb.json] --> IF[ingest_tmdb_from_file.py]
        M1[mapping配置] --> IF
    end

    subgraph ES索引层
        IF -->|创建索引/导入| IDX[TMDB索引]
        IDX -->|字段映射| F1[title:text+keyword]
        IDX -->|字段映射| F2[overview:text]
        IDX -->|字段映射| F3[popularity:float]
    end

    subgraph 搜索层
        QF[query/*.json文件]
        QS[query_tmdb.py]
        QF -->|加载查询DSL| QS
        QS -->|multi_match查询| IDX
        IDX -->|返回结果| QS
        QS -->|格式化输出| OUT[搜索结果]
    end

    subgraph 查询策略
        ST1[默认查询]
        ST2[带权重查询<br>title^10]
        ST3[most_fields查询]
        ST1 & ST2 & ST3 -.->|定义在| QF
    end
```

## 参考

1. <https://github.com/onebirdrocks/geektime-ELK/>
2. <https://www.elastic.co/elasticsearch>


# Agent-Harness-Engineering-A-Survey

* 原文标题：Agent Harness Engineering: A Survey
* 原始 PDF：<https://picrew.github.io/LLM-Harness/main.pdf>
* 项目页：<https://picrew.github.io/LLM-Harness/>

## 作者

Junjie Li, Xi Xiao, Yunbei Zhang, Chen Liu, Lin Zhao, Xiaoying Liao, Yingrui Ji, Janet Wang, Jianyang Gu, Yingqiang Ge, Weijie Xu, Xi Fang, Xiang Xu, Tianchen Zhao, Youngeun Kim, Tianyang Wang, Jihun Hamm, Smita Krishnaswamy, Jun Huan, Chandan K Reddy

## 摘要

大语言模型（LLM）智能体在生产环境中的快速部署揭示出一种反复出现的模式：任务执行的可靠性，与其说取决于底层模型本身，不如说取决于包裹模型的那层基础设施，也就是智能体执行 harness。本文从实践出发，对 Harness Engineering 做出系统性综述，并围绕三项主张展开。第一，智能体执行 harness 是一个独立的系统层，它的工程质量在很大程度上决定了现实世界中的可靠性。作者用从 Prompt Engineering、Context Engineering 到 Harness Engineering 的三阶段演化来展开这一判断，并进一步给出围绕成本—质量—速度三难困境、能力—控制权衡与 harness 耦合问题的跨层综合分析，以及一套立足研究缺口与生产痛点的开放问题议程。第二，作者提出 ETCLOVG 七层分类体系，由 Execution environment、Tool interface、Context management、Lifecycle/Orchestration、Observability、Verification、Governance 七层组成，在既有六组件框架基础上，把可观测性与治理提升为独立的架构关注点。第三，作者将 170+ 个开源项目映射到这一分类体系上，以揭示生态模式、覆盖空白和新兴设计原则；同时总结 OpenAI、Anthropic 与 LangChain 在生产部署中沉淀出的工程经验，以弥合实践知识与研究词汇之间的鸿沟。

**图 1：** Prompt Engineering、Context Engineering 与 Harness Engineering 的简要对比。

![](/files/DYyItFSt7yqNXiAkISPt)

## 1 引言

### 1.1 绑定约束：Harness 重于模型

围绕基于 LLM 的智能体展开的学术研究，总体上一直在研究模型本身。研究议程聚焦于模型能做什么：它是否能够跨多个步骤进行规划、可靠地调用工具、检索并压缩相关记忆，或与其他智能体协作。其隐含假设是，智能体能力主要是模型能力的函数；只要模型足够强、提示足够好，就会产生足够可靠的行为。

近期的实证证据对“仅靠更好的模型就能产生更可靠的智能体”这一假设提出了挑战。最近三项结果共同呈现出同一种模式。Bölük (2026a) 仅修改了 edit-tool 的格式及其周边的 工具 harness，在没有任何模型改动的情况下，便报告称在 15 个模型上的编程基准中取得了最高 10× 的增益。Trivedy (2026) 仅通过重构 system prompt、注入中间件上下文以及加入自验证 hooks，就把一个固定的 GPT-5.2-Codex 智能体在 Terminal-Bench 2.0 上的成绩从 52.8% 提升到 66.5%；这一 13.7 个百分点的提升完全来自基础设施层面的变化。Meta-Harness (Lee et al., 2026) 则通过自动化 harness 优化在 Terminal-Bench-2 上达到了 76.4%，并在不修改模型权重的情况下超过了所有手工工程化方法。在每个案例中，变化的变量都是执行 harness，也就是负责上下文构造、工具交互、编排、反馈与执行约束的基础设施层；模型则保持不变。这些仅由 harness 带来的增益，每一项都超过了在相同基准上通常被视为有意义模型进步的 2 到 4 个百分点提升。这不是偶然现象：决定结果的是 harness，而不是模型。

我们将这种模式称为 **binding-constraint thesis** (Bölük, 2026b)：对于在可比的前沿模型上评估的长时程任务，基准表现的方差，可能既由执行 harness 驱动，也由模型本身驱动。本文其余部分都以这一论点为论述框架。

-2 中的图 3 也从历史角度展示了同样的转变：早期系统把能力集中在单一模型循环中，而后来的系统则越来越把可靠性呈现为一个跨层基础设施问题。

### 1.2 实践者—研究者鸿沟

实践界的紧迫性与研究界的词汇体系之间存在张力。OpenAI 明确将 “Harness Engineering” 界定为围绕 Codex 智能体设计环境、约束、文档和反馈回路的学科，并在 2026 年 2 月报告称，一个小团队在五个月内构建出了约一百万行规模的内部产品，而没有人工编写生产代码 (OpenAI, 2026a)。Anthropic 关于智能体工程的一系列文章，则从相邻方向得出了同样的原则：有效的智能体应当采用简单且可检查的架构；工具接口应为智能体使用而设计，而不是直接照搬面向人类的 API；上下文应渐进式披露，而不是一次性预加载；长时间运行的工作则需要持久的交接产物和可恢复的执行基础设施 (Anthropic, 2024a; Aizawa, 2025; Anthropic Applied AI Team, 2025; Anthropic, 2025d; 2026b)。Martin Fowler 网站上的一篇文章则将 Harness Engineering 描述为“cybernetic governors for AI agents”，由前馈式指导和反馈式传感器构成，围绕 LLM 形成控制回路 (Böckeler, 2026)。

与此同时，研究共同体一直在以越来越精细的方式研究智能体系统的各个组成部分：记忆、工具使用、规划与安全性。但尚未被系统性研究的，是把这些组件整合成可靠运行系统的整套机制。由此产生了实践者—研究者鸿沟：实践者知道 harness 基础设施很重要，却缺乏解释“为什么重要”的形式化词汇，也就难以据此开展系统化改进。本文试图弥合这一鸿沟。

### 1.3 范围与贡献

本文综述聚焦于这样一个基础设施层：它包裹语言模型，用来管理长时间运行、多步骤的任务执行。我们不综述作为开发工具的智能体框架，不综述作为产品类别的智能体平台，也不综述模型能力本身，尽管这三者都会为我们的分析提供信息。图 4 总结了支撑本文后续结构的七层分类体系。

本文的贡献可归结为三项主张。

1. **主张 1（概念层面）**：基于绑定约束论点 (Bölük, 2026b)，我们认为，在现实世界中真正限制智能体可靠性的，不只是模型本身，更是 harness。最近三项结果分别展示了：编程基准上最高 10× 的 harness-only 增益、Terminal-Bench 2.0 上 +13.7 个百分点的提升，以及 Terminal-Bench-2 上 76.4% 的成绩（§1）；这些结果都超过了相同基准上典型的模型驱动增益。我们主要通过三个方面展开这一论点：三阶段工程演化（§2）、围绕成本—质量—速度三难困境、能力—控制权衡以及 harness 耦合问题的跨层综合（§11），以及一套开放问题议程（§12）。
2. **主张 2（分类层面）**：七层 ETCLOVG 分类体系将可观测性和治理视为独立层，而不是 Lifecycle Hooks 的副作用。两者各自都拥有独立的生产工具栈——可观测性一侧有 Langfuse 和 OpenTelemetry，治理一侧有权限引擎、网关与审计流水线——并且在生产部署中通常由不同团队负责。我们还将状态管理放入 Lifecycle and Orchestration 之中，因为状态本就应与读取和写入它的执行流放在一起（§2.3）。
3. **主张 3（实证层面）**：将 148+ 个开源项目映射到 ETCLOVG 上，可以看出生态在哪些地方稠密、在哪些地方稀薄，以及更早的语料集遗漏了哪些类别。这一映射是迄今为止规模最大的开源智能体执行 harness 语料集。执行、工具、生命周期与验证的覆盖较为稠密；可观测性与治理则更薄，而且更多存在于商业平台中；此外，早期语料集中缺失的三类内容——包括任务运行器、多智能体编排器以及规范驱动开发工具——如今都已成为一级类别。方法论小节（§2.4–§2.9，附录 A）也让这一编码过程具备可复现性，而该映射支撑了 §3–§9 的逐层观察。

## 2 背景与分类体系

### 2.1 智能体系统的演化

从早期的 chain-of-thought 提示到自主智能体的发展轨迹，可以理解为：实践者必须管理的工程表面在不断扩张。

**ReAct 时代（2022–2023）**。Yao et al. (2023) 将“观察—思考—行动”的循环确立为一种基础原语。早期系统只依赖极少的基础设施：一个 while 循环、一个提示模板，以及一张小型工具分发表。AutoGPT 和 BabyAGI 则通过在语言模型调用外再包裹任务队列、记忆和工具分发，展现了完全自主运行的雄心；同时，它们也让执行失控、上下文膨胀、状态丢失以及未受监控的副作用等失败模式显现为基础设施问题，而不只是提示问题 (Significant Gravitas, 2023; Nakajima, 2023)。

**工具集成与多智能体协调（2023–2024）**。Gorilla、ToolLLM 与 Toolformer 证明，工具使用能力可以通过学习或诱导获得，而不必硬编码到一个固定的 API 包装器中 (Patil et al., 2024b; Qin et al., 2024; Schick et al., 2023)。CAMEL、ChatDev、MetaGPT 与 Mixture-of-Agents 则引入了多智能体协作模式，从角色扮演式对话，到软件开发组织，再到分层式智能体聚合 (Li et al., 2023a; Qian et al., 2023a; Hong et al., 2023; Wang et al., 2024)。与此同时，SWE-bench、AgentBench、WebArena 与 GAIA 推动了评估基础设施的成熟 (Jimenez et al., 2024; Liu et al., 2023a; Zhou et al., 2024; Mialon et al., 2023)，而协议标准化则随着 Anthropic 的 MCP 和 Google 的 A2A 开始启动 (Anthropic, 2024c; Surapaneni et al., 2025)。

**harness 转向（2025–2026）**。到 2025 年，已经积累了足够多的部署经验，可以清楚地表明：决定智能体可靠性的绑定约束，是基础设施质量而不是模型质量。2026 年初的三个独立进展验证了这一转变：OpenAI 明确采用 “harness engineering” 作为一门学科；Stanford/MIT 的 Meta-Harness 表明自动化 harness 优化优于人工工程；LangChain 的 DeepAgents 则仅通过 harness 层的改动，就把 Terminal-Bench 2.0 上的成绩从 52.8% 提升到 66.5%，即提高了 13.7 个百分点，约合 26% 的相对提升 (OpenAI, 2026a; Lee et al., 2026; Trivedy, 2026)。

### 2.2 三个工程阶段

2022–2026 年这一时期揭示出一个连贯的三阶段演化：该领域“选择去工程化什么”的对象发生了变化。

**Prompt Engineering（2022–2024）**。主要杠杆是输入提示文本。实践者通过构造更好的指令、few-shot 示例和推理模板来优化系统。其工程范围较窄：优化单次模型调用所接收的一段文本输入。

**Context Engineering（2025）**。随着智能体运行时间变长，绑定约束从“输入是什么？”转向“模型在每一步应当看到哪些信息？”这一阶段聚焦于上下文管理：每一轮注入什么、如何检索并压缩记忆、如何按相关性对工具结果排序，以及如何处理上下文窗口饱和。其范围从单一输入扩展为管理流入上下文窗口的多股信息流 (Anthropic Applied AI Team, 2025)。

**Harness Engineering（2026）**。当模型已经足够有能力处理长时间运行任务时，可靠性便越来越依赖于包裹其外的基础设施层：它负责维护状态、中介工具、注入反馈、强制执行约束并验证进展。这一观察与绑定约束视角一致：长时程智能体性能，是由一个耦合的“模型—harness 系统”产生的，而不是单独由模型产生 (Bölük, 2026b)。因此，Harness Engineering 所要回答的问题是：为了让智能体系统变得可靠，必须围绕模型设计哪些治理机制、约束、反馈回路与执行控制。在我们的分类体系中，这一阶段把 ETCLOVG 的全部七层视为一个集成整体 (OpenAI, 2026a; Böckeler, 2026; LangChain, 2026b)。

每一个阶段都包含前一个阶段：Harness Engineering 包含 Context Engineering，而 Context Engineering 又包含 Prompt Engineering。这三个阶段在时间上和概念上也彼此重叠，而不是以泾渭分明的边界彼此替代。Prompt Engineering 今天仍然是 harness 实践中的活跃组成部分，而 Context Engineering 也仍在与本文所综述的 harness 层问题并行成熟。因此，我们的分期更应被理解为“边际工程努力投向何处”的转移，而不是一系列替换关系。

**图 2：** 基于 LLM 的智能体系统之 Harness Engineering 分类体系示意图。E、T、C、L 四层构成系统的结构性支柱。O 层提供系统级监控，V 层为各组件提供评估与反馈，而 G 层则在整个系统之上施加治理与安全约束。配色方案对应于 §2.3 中构建的 ETCLOVG 各层。

![](/files/XUcrNFGebMxKJ0hL9BAP)

**图 3：** 2022 至 2026 年代表性智能体执行 harness 系统时间线。该时间线展示了系统如何从早期的单循环智能体，转向更丰富的 harness 基础设施；这些基础设施横跨执行环境、工具接口、上下文与记忆管理、生命周期编排、可观测性、验证与治理。配色方案对应于 §2.3 中构建的 ETCLOVG 各层。

![](/files/Y0EJN0y06sNsnnCKYmmn)

### 2.3 ETCLOVG 七层分类体系

我们提出一个用于智能体执行 Harness Engineering 的七层分类体系，并以 ETCLOVG 这一缩写指代它，分别代表 Execution、Tooling、Context、Lifecycle、Observability、Verification、Governance。图 4 给出了紧凑的可视化地图；本小节则确定本文后续沿用的解释。

前四层描述的是 harness 的结构核心。执行（E）决定智能体代码在何处运行，以及有哪些沙箱约束界定其边界；工具（T）规定外部能力如何被描述、发现和调用；上下文（C）控制模型在短期、会话级与持久化时间尺度上能够看到什么；生命周期（L）则组织读取与写入这些状态的控制流，从单智能体循环一直延伸到多智能体以及从 Issue 到 Pull Request 的工作流。其余三层描述围绕这一核心的控制平面。可观测性（O）捕获轨迹、成本、失败与可靠性信号；验证（V）将任务与轨迹转化为评估、失败归因与回归反馈；治理（G）则通过权限、身份、策略、加固、审计与人工监督机制来约束系统行为。第 3–9 节将依次展开这七层，而第 10–12 节则综合讨论不属于任何单层的跨层权衡与开放问题。

这套分类体系有两个设计选择使其与众不同。第一，我们将可观测性提升为独立层，而不是把它视为 Lifecycle Hooks 的副作用。在生产系统中，可观测性拥有独立的工具生态（Langfuse、Arize Phoenix、OpenLLMetry）以及独立的工程实践（OpenTelemetry 埋点、成本归因、异常检测），因此值得单独处理。第二，我们将治理引入为一级层，用于覆盖安全与合规问题的完整谱系，这些问题分布在三个子层之中：模型层（guardrails、内容过滤器）、系统层（网关、代理、权限模型）以及组织层（审计、合规、human-in-the-loop 监督）。

**图 4：** 智能体执行 Harness Engineering 分类体系的细节。每个分支对应一个 ETCLOVG 层及其主要子类别；后续章节将讨论代表性系统与论文。

![](/files/K4jxC6Pp84F5lYv1o2bz)

图 4 的分支可按如下方式整理：

* **执行环境与沙箱（E，§3）**
  * 通用托管沙箱
  * 面向 computer-use 智能体的基础设施
  * 面向代码任务的专用沙箱
  * 与框架集成的运行时
  * 浏览器评测环境
  * 操作系统级权限沙箱
  * 沙箱抽象层
* **工具接口与协议（T，§4）**
  * 协议与接口标准
  * 工具描述、发现与选择
  * 工具增强训练与集成
  * 可扩展性与会话管理
* **上下文与记忆管理（C，§5）**
  * 短期活跃上下文窗口
  * 中期会话状态与跨运行持久化
  * 长期持久记忆系统
  * 长时程上下文技术
  * 上下文漂移与极限
* **生命周期与编排（L，§6）**
  * 单智能体内循环
  * 多智能体编排模式
  * 全生命周期任务流水线
* **可观测性与运维（O，§7）**
  * 追踪与监控平台
  * 面向智能体的专用运维平台
  * 成本跟踪与优化
  * 可靠性工程
  * 统一可观测性
* **验证与评估（V，§8）**
  * 任务与基准锚定
  * 执行前就绪性验证
  * 受控执行与轨迹采集
  * 多层级判断与失败归因
  * 持续回归与部署反馈
* **治理与安全（G，§9）**
  * 权限模型与身份管理
  * Lifecycle Hooks
  * 组件加固
  * 声明式宪章
  * 审计基础设施
  * 智能体安全版图

状态管理天然应归入生命周期与编排（L）之内，因为它与读取和写入状态的执行流相伴而生；而生命周期 hooks 与策略强制执行则应归入治理（G），因为它们与其他约束机制处于同一层面。

### 2.4 范围

我们使用 **agent harness** 一词的含义，比“围绕一个 LLM 的任何软件”更窄：harness 是一种经工程化设计的包装层，它通过执行基底、工具接口、上下文控制、编排、可观测性、评估反馈以及治理约束，把模型调用转化为有边界、有状态、由工具介导的任务执行 (OpenAI, 2026a; Anthropic, 2025d; LangChain, 2026b)。因此，本文的分析单位是让长时间运行的智能体行为变得可控、可检查、可恢复的基础设施，而不是基础模型或提示本身。我们划定边界的依据是功能，而非产品类别：如果一个智能体框架暴露出可复用的机制，例如有状态编排、工具路由、运行时策略 hooks 或轨迹捕获，那么它就在本文范围内；而薄薄一层模型 API 包装器、提示词库、静态数据集、通用容器运行时、向量数据库、APM 仪表板或内容过滤器，则不在范围内，除非它们被明确适配到智能体执行、状态、评估或工具使用治理之中。

### 2.5 项目收集流程

我们将该语料集构建为一项对公开记录之智能体执行 harness 工件的系统性映射，并借用系统综述的报告规范，使来源流、搜索策略与筛选过程都保持明确 (Page et al., 2021)。如图 5 所示，候选项目来自四类来源：既有综述与基准论文；可复现的 GitHub 搜索——其搜索维度覆盖名称、描述、README 文本、topics、star 数、最近活跃度与归档状态；精选项目列表与包注册表；以及引入 harness 层机制的公司工程博客或发布说明 (Meng et al., 2026; Jimenez et al., 2024; Liu et al., 2023a; Zhou et al., 2024; GitHub, 2026; OpenAI, 2026a; Anthropic, 2025d; LangChain, 2026b)。代表性查询组合了 agent harness、coding agent、LLM agent sandbox、MCP server、agent observability、agent memory、agent evaluation 与 agent governance 等术语。对于每个最终保留的候选项目，我们记录了项目名称、URL、工件类型、来源类型、可用性状态、可识别时的发布时间、可获取时的 GitHub 元数据，以及之后进行 ETCLOVG 编码所依赖的公开证据；本文版本中报告的元数据快照冻结于 2026 年 5 月 8 日。

**图 5：** 语料集构建协议。候选工件从 GitHub、论文、精选列表、包注册表以及公司工程资料中收集，然后去重、依据纳入标准进行检查，并根据公开文档映射到 ETCLOVG 各层。

![](/files/SvJIapb2isFLMdP6D3A1)

### 2.6 纳入与排除标准

当一个项目同时满足三个条件时，它会被纳入：其一，它有公开文档；其二，它实现或规定了一个具体的 harness 层机制；其三，现有证据足以将其归入至少一个 ETCLOVG 层。这包括：具有可复用编排或工具路由逻辑的智能体框架；实例化了可执行智能体环境的基准；为智能体执行打包的沙箱；以及作用于智能体状态、轨迹、动作或策略之上的记忆、可观测性、评估或治理系统。我们排除了简单的聊天机器人演示、提示词包、薄模型客户端包装器、没有智能体运行时的静态数据集或排行榜、不面向智能体的通用基础设施组件，以及那些无法从公开文档中检查其技术行为的产品页面。边界案例的判断依据是其“机制”而非“标签”：一个仓库即便名为“agent”，也不足以纳入；而一个评估或沙箱项目，只要提供了可复用的 harness 机制，就会被纳入。

### 2.7 编码规程

每个保留项目都依据七个 ETCLOVG 层进行编码，所依据的证据来自工件本身的公开材料：README、文档页面、论文、示例、发布说明，以及在必要时的仓库结构。由于许多系统跨越多个层面，编码采用多标签方式；主层标记的是对该工件最核心的机制，而次层仅在文档明确暴露出一种独立能力、而非偶然依赖时才会赋予。当前版本采用的是“单一主编码者 + 作者审计”协议，而不是正式的多编码者一致性研究，因此我们不报告 Cohen’s kappa 或类似的标注者间一致性统计量。对于模糊案例，我们在完整编码后进行了复核，并采用一条保守规则：如果公开证据没有清楚展示面向智能体的机制，则不进行层级归类。

### 2.8 语料集的局限性

这一语料集应被理解为对“可见的”智能体执行 harness 生态的地图，而不是对所有已部署智能体基础设施的普查。它偏向英语来源、在 GitHub 上可见的项目、开源工件，以及那些维护者公开了足够实现细节、使外部编码成为可能的系统。商业生产系统在其中代表性不足，除非其工程博客、文档或 SDK 暴露了相关机制；而 coding agent 基础设施则被过度代表，因为它拥有异常丰富的公开痕迹：仓库、基准、沙箱、从 issue 到 pull request 的工作流以及发布说明。层级归类反映的也是公开文档而非私有架构，因此，一个项目未出现在某层中，意味着“没有公开证据”，而不是“没有实现”。

### 2.9 汇总分析

对 170+ 个项目的映射揭示出一个广阔但不均衡的生态。执行、工具接口、生命周期编排与验证拥有最稠密的可见覆盖，因为 coding、web、terminal 与 computer-use 智能体若想真正有用，都必须具备可运行的环境、工具契约、控制回路以及可重复的评估。上下文与记忆出现在许多项目中，但往往嵌入于更大的框架内部，而不是以独立 harness 组件的形式发布。可观测性与治理在开源覆盖中则更薄，且更常通过商业平台、SDK 功能或工程文章出现，这表明运维控制成熟得晚于运行时与基准基础设施。跨层项目正在变得越来越普遍：最完整的系统会把沙箱、工具协议、编排、追踪、评估与权限控制结合在一起，这支持了本文的核心主张——Harness Engineering 是一个集成的系统问题，而不是一组彼此孤立的附加组件。

## 3 执行环境与沙箱

我们以执行环境与沙箱这一层开启逐层讨论，它是 ETCLOVG 的七大支柱中的第一项（§1 中的主张 2）。本节讨论的系统来自支持主张 3 的 170+ 项目语料。

**图 6：** 面向 LLM 智能体的执行环境与沙箱代表性工作，按本章的沙箱类别组织。

![](/files/d9R83dtqRuDe1bZRRPlV)

图中按沙箱类别归纳了代表性系统，包括：通用托管式沙箱、计算机使用型智能体基础设施、代码与仓库执行沙箱、框架集成式运行时、浏览器评测环境、OS 级权限与运行时安全，以及沙箱抽象 / 训练 / 评测。

### 3.1 范围与概念

#### 3.1.1 定义

智能体的执行环境，指的是智能体动作被实际执行的基础设施层。在 LLM 智能体的语境中，执行环境与沙箱是紧密耦合的概念。因此，生产级智能体系统几乎总是在沙箱化环境中执行动作。

#### 3.1.2 为什么沙箱在智能体时代居于核心地位

在智能体时代，沙箱并不只是从传统多租户代码执行继承而来的安全措施。它同时服务于三个不同目的，而这三者的组合，正是沙箱从一个运维细节上升为智能体执行 harness 设计中一等公民问题的原因。

第一个目的是安全性。智能体沙箱面临的挑战，超出了传统多租户代码执行的范围。LLM 生成的代码在大规模场景下既无法审计，也不可预测，因此静态审查不能作为首要防线。智能体会自主执行多步操作，所以在动作实际执行时无法依赖人工介入。提示注入攻击还会把原本无害的智能体重新利用为面向沙箱的攻击载体，从而模糊可信用户意图与恶意输入之间的边界。近期关于沙箱逃逸的实证工作表明，这些担忧并非假设，我们将定量证据留到 §3.3 再讨论。

第二个目的是可复现性。长时程智能体任务，以及衡量这些任务的评测 harness（§8），都要求能够将执行状态重置到一个已知基线。Docker 容器或 microVM 可以按需销毁并重建，而开发者工作站做不到这一点；正是这种性质，使得 SWE-bench 和 OSWorld 这类基于沙箱的评测标准变得可行。在训练阶段，当同一任务需要在并行轨迹上被重放数百次时，缺乏廉价的重置机制本身就会成为可扩展性的瓶颈。

第三个目的是 liveness，这也是最具有智能体时代特征的一点。没有沙箱时，智能体想执行的每个潜在高风险动作——例如写文件、安装依赖包或发起外部网络请求——都必须经过面向人工的显式权限提示。在规模化场景下，这会产生两种失败模式：要么用户因挫败感而放弃使用智能体，要么用户条件反射式地全部批准，从而破坏这些提示原本的安全意义。沙箱通过定义一个有边界的区域打破这一僵局：在该区域内，智能体被授权自由行动，权限控制从“逐个动作逐条发问”转变为“会话级配置”。Anthropic 报告称，在 Claude Code 中引入沙箱后，在保持安全性的同时，权限提示减少了 84%（Anthropic, 2025b）。在这个框架下，沙箱既是牢笼，也是许可证；而“许可证”这一面，正是长时程自主执行得以成立的前提。

在这三个目的中，安全性与传统沙箱共享，可复现性在智能体场景下被进一步放大，而活性则几乎是全新的需求。正是三者的结合，使我们有理由把智能体沙箱视为一个独立的研究对象，而不是容器技术的下游应用。

### 3.2 智能体沙箱的类别

2024 到 2026 年间，智能体沙箱基础设施已经从少量通用运行时，分化为若干面向不同任务类型优化的独立产品类别。我们沿着“工作负载类型 / 使用场景”的轴线，将这一版图组织为七类；对于需要在不同系统间做选择的 harness 设计者而言，这是最有信息量的视角。另一条与之正交的轴线是隔离技术，包括容器、gVisor 等用户态内核、Firecracker 与 Kata Containers 等 microVM、WebAssembly，以及 bubblewrap、Seatbelt 等 OS 级原语。我们不将这条轴线作为顶层分类，而是在各小节中将其作为设计属性讨论，因为同一种隔离原语会在不同工作负载中复用。这七类分别是：通用托管式沙箱（§3.2.1）、计算机使用型智能体基础设施（§3.2.2）、面向代码的专用沙箱（§3.2.3）、框架集成式运行时（§3.2.4）、浏览器评测环境（§3.2.5）、操作系统级权限沙箱（§3.2.6）以及沙箱抽象层（§3.2.7）。下面依次展开。

#### 3.2.1 通用托管式沙箱

通用托管式沙箱提供商业化或开源的 sandbox-as-a-service 平台，通过 API 暴露任意 OCI 容器镜像，并为未预先限定的工作负载提供 shell、文件系统、网络和解释器支持。代表性系统包括：Daytona，它从开发者环境转向智能体沙箱，冷启动创建时间低于 90ms；E2B，一个构建在 Firecracker microVM 上的智能体沙箱；Modal，一个采用 gVisor 并支持大规模自动扩缩容的 Python 平台；Northflank，同时支持 Kata Containers、Firecracker 与 gVisor；阿里巴巴的开源通用沙箱 OpenSandbox；以及 Docker 于 2025 年发布的、基于 microVM 的官方产品 Docker Sandboxes。

这一类别中的设计决策呈现出若干收敛模式：默认采用 ephemeral-by-default 语义，同时允许可选的持久会话；API 接口通常以 Python 和 TypeScript SDK 的形式暴露；并支持任意 OCI 镜像。不同系统之间的分歧主要体现在隔离强度和运维模型上。Daytona 默认使用容器隔离，并可选 Kata Containers；E2B、Modal 和 Docker Sandboxes 则默认提供 microVM 或 gVisor 隔离。Northflank 的独特之处在于允许为每个工作负载单独选择后端，这反映出业界已经认识到：不存在一种可以适配所有威胁模型的隔离原语。我们观察到一个更广泛的趋势：系统正在从共享内核的容器隔离转向专用内核的 microVM 隔离，其驱动力在于 LLM 生成代码的系统调用模式无法预先刻画，因此更强的默认隔离更具现实意义。

#### 3.2.2 计算机使用型智能体基础设施

计算机使用型智能体基础设施代表了一种独特的执行模型：智能体不是通过 API 或 shell 命令交互，而是通过模拟鼠标、键盘和屏幕观察来操作图形界面。代表性系统包括 Anthropic 的 Computer Use，它是让 Claude 直接操作桌面环境的旗舰商业实现；开源的计算机使用型基础设施 CUA；以及 OSWorld 提供的基于 VM 的环境，后者同时兼作评测 harness（§8）与参考性的 computer-use 沙箱。

这些系统会在沙箱内部打包完整或近完整的桌面环境（通常是带窗口管理器的 Xvfb，或完整 VM），并向智能体暴露像素坐标级操作和按键动作。与基于 API 的类别相比，它们的动作空间要大得多；但系统可靠性依赖于视觉 grounding，而完整操作系统带来的攻击面又要求更强的隔离，通常是 microVM 或完整 VM。相较于 §3.2.1 的托管式沙箱，computer-use 沙箱是用部署密度和启动时延来换取真实度：完整桌面环境比无头 shell 沙箱更重、更难多路复用，但它也是唯一能让智能体操作那些完全不提供 API 的应用程序的执行模型。

#### 3.2.3 面向代码的专用沙箱

面向代码的专用沙箱是轻量级环境，优化目标是代码生成、评估与数据分析，而不是通用 shell 访问。代表性系统包括 Judge0，它原本是为在线判题设计的代码评测沙箱，后来被广泛复用于编码智能体评测流水线；OpenAI Code Interpreter，它是支撑 ChatGPT Advanced Data Analysis 的生产级沙箱化 Python 环境；面向编码智能体的 shell 沙箱 sandboxed.sh；以及 langchain-sandbox，它将 Pyodide 编译到 WebAssembly，并在 Deno 运行时中执行，从而在本地无容器地沙箱化智能体生成的 Python；NVIDIA 也为客户端 agentic workflow 倡导了类似设计。

与 §3.2.1 的通用沙箱不同，这类系统会预装编译器与解释器，默认采用无状态的请求级执行，并针对高并发并行进行优化。这种设计牺牲了工作负载通用性，换来了更快的启动速度、更高的评测吞吐，以及更简单的威胁模型。一个值得注意的子趋势是：代码沙箱正从基于容器的实现转向基于 WebAssembly 的实现。WebAssembly 提供基于 capability 的安全性、确定性执行以及微秒级实例化，但代价是 Python 标准库受限、原生扩展支持较弱。

#### 3.2.4 框架集成式运行时

框架集成式运行时是打包在更大智能体框架内部的执行环境，而不是作为独立沙箱产品暴露。它们与框架的编排循环、工具注册表和提示词约定一起交付；如果不采用周边框架，就无法独立消费这些运行时。代表性系统包括 OpenHands runtime，这是一个 Docker 沙箱化环境，把 bash、IPython、Chromium 浏览器和 API server 集成进单一镜像；agent infra sandbox，一个明确的 all-in-one 设计，将 browser、shell、filesystem、MCP 和 VSCode 打包到同一环境中；以及 smolagents 的 executor 层，它在框架内部提供 local、Docker、E2B、Modal 与 WebAssembly 等多种执行实现。

这一类别的核心权衡是 bundle 还是 compose：框架集成式运行时优先保证开箱即用的能力覆盖，但代价是镜像更大、启动更慢，并与单一框架的抽象深度耦合。相比之下，§3.2.1 的通用沙箱采取的是相反路径：提供最小环境，再通过外部抽象层进行组合。从长期看，组合式方案能否取代捆绑式运行时，取决于 MCP 等标准能否降低“在运行时组装能力”相对于“在构建时打包能力”的成本惩罚。

#### 3.2.5 浏览器评测环境

浏览器评测环境同时扮演沙箱与评测 harness 的双重角色。代表性系统包括 WebArena（Zhou et al., 2024），它提供一组自托管、逼真的 Web 应用集群，并结合基于 Playwright 的交互；VisualWebArena（Koh et al., 2024），它在 WebArena 的基础上扩展了多模态视觉 grounding 任务；以及 BrowserGym（Chezelles et al., 2024）与 WorkArena（Drouin et al., 2024），它们为基于浏览器的智能体执行与基准测试提供了标准化的 gym 风格接口。

这些系统一方面提供隔离的 Web 执行环境（沙箱角色），另一方面又同时给出可复现的任务定义与自动评估（harness 角色）。这种双重功能使其区别于 §3.2.2 的 computer-use 类别：后者作用于桌面层，而不是浏览器层。它也在本节讨论的执行环境与 §8 讨论的评测基础设施之间建立了直接接口。浏览器环境还暴露出独特的威胁面：因为浏览器会摄入不可信网页内容，所以它天然适合研究针对智能体的间接提示注入与多模态红队攻击（Debenedetti et al., 2024; Greshake et al., 2023; Zhang et al., 2026a; Wei et al., 2026）。

#### 3.2.6 操作系统级权限沙箱

操作系统级权限沙箱使用操作系统原语——如 Linux 上的 bubblewrap、macOS 上的 Seatbelt，或用于系统调用过滤的 seccomp-bpf——来实现细粒度的文件系统与网络隔离，而不是使用容器、VM 或用户态内核。与容器沙箱相比，它们要轻量得多，同时依然能够提供目录级与域名级访问控制。代表性系统包括 Anthropic 的 sandbox-runtime（Anthropic, 2025g），这是一个开源 npm 包，它通过 bubblewrap 与本地 HTTP/SOCKS5 代理施加文件系统和网络 allowlist，对任意命令进行包装；Claude Code 自带的沙箱功能（Anthropic, 2025b），它消费该运行时来把 bash 工具的文件系统与网络访问限制在已配置边界内；以及 IsolateGPT（Wu et al., 2025），这是一个研究系统，通过 seccomp 与 setrlimit 对基于工具调用的 LLM 应用施加执行隔离。

这一类别的设计哲学是“权限，而不是分区”：目标不是为每次会话提供一个全新的 OS 镜像，而是为现有宿主系统提供一个被缩窄的视图，使得由提示注入触发或由幻觉生成的命令无法修改敏感文件，也无法窃取数据。Anthropic 报告称，这类边界在保持安全性的同时，让 Claude Code 的权限提示减少了 84%（Anthropic, 2025b）。这也说明了 OS 级沙箱的生产力动机：在长时程智能体执行中，权限提示本身就是一种活性失效。由于宿主内核仍被共享，OS 级权限沙箱针对对抗性代码的隔离强度弱于基于 microVM 的托管沙箱（§3.2.1）；它更适用于“提示注入是主要威胁、但代码本身并非完全对抗性”的威胁模型。

#### 3.2.7 沙箱抽象层

沙箱抽象层本身并不是沙箱，而是把多个沙箱后端统一到单一 API 之后的接口层，使 harness 能在不重写智能体代码的前提下切换执行基底。代表性系统包括 SWE-ReX（SWE-agent Team, 2024），这是 SWE-agent 团队（Yang et al., 2024）开发的运行时接口，把 Docker、AWS Fargate、Modal 和 Daytona 统一成可互换后端；smolagents（Roucher et al., 2025）的 executor 接口，它通过单一参数化的 `executor_type` 暴露 local、e2b、modal、docker、blaxel 和 wasm；以及 Kubernetes SIG Apps 下的 Agent Sandbox 项目（Kubernetes SIG Apps, 2025），它引入了 Sandbox CRD 与 controller，通过声明式 API 将 gVisor 与 Kata Containers 暴露为可插拔隔离后端。

这一类别的出现，反映出人们对执行基础设施的理解正在成熟：执行基础设施应当是可替换的。若 harness 将某个特定沙箱 API 硬编码进编排逻辑，就会把自身绑死在某家供应商易变的产品接口上；抽象层则把“智能体运行什么”与“它在哪里运行”解耦。我们观察到，研究项目（SWE-ReX）、框架内嵌接口（smolagents executors）与新兴基础设施标准（Kubernetes agent-sandbox CRD）正在趋同，这表明沙箱抽象正逐渐成为智能体执行 harness 栈中的独立层，而不再只是个别系统的一个功能。

**综合。** 在这七类系统中，可以看到三条横向趋势。第一，领域并没有收敛到单一方案，而是在隔离强度上发生分叉：托管式沙箱（§3.2.1）正在从共享内核容器迁移到专用内核 microVM，而 OS 级权限沙箱（§3.2.6）则完全放弃独立环境，转而缩窄宿主系统的可见视图。普通 Docker 容器所处的中间地带正被两端挤压；在两种极端之间做选择，反映的并不是某种普适技术偏好，而是威胁模型的差异——是要防御完全对抗性的代码，还是只需防御被提示注入劫持、但其他方面仍相对合作的代码。第二，评测稳健性正在成为一等问题：如 SandboxEscapeBench（Marchand et al., 2026）这样的容器逃逸基准，以及面向 code-interpreter 智能体的安全基准 CIBER（Ba et al., 2026），都表明当前生产环境部署的沙箱配置已经会被前沿模型部分绕过。第三，基础设施级隔离（本节讨论的内容）正在与工具调用层的语义级 / 能力级隔离分离开来。CaMeL 的控制流完整性设计（Debenedetti et al., 2025）与 Progent 的可编程权限控制（Shi et al., 2025a）就是后者的代表，它们应归入工具使用章节（§4），而不属于严格意义上的沙箱基础设施。这两条路线是互补的：基础设施沙箱约束的是动作一旦执行后的影响半径，而语义隔离约束的是哪些动作首先被允许执行。完整的智能体执行 harness 需要二者兼备。

### 3.3 威胁模型与沙箱逃逸

对智能体执行进行沙箱化，既要面对传统容器层威胁（容器逃逸、旁路信道、资源耗尽），也要面对会放大这些经典问题的智能体特有威胁类别。第一，提示注入攻击允许外部输入——例如检索得到的网页、工具返回内容或文件内容——劫持智能体行为，并发起恶意的沙箱内操作。第二，目标失配（goal misalignment）会使智能体本身把“逃出沙箱”当作一种工具性子目标来主动追求。第三，组合放大（compositional amplification）会在智能体拥有多个工具访问权限时出现：单个沙箱弱点可以沿着已集成的多种能力发生级联扩散。

关于智能体场景下的沙箱逃逸，实证证据还很有限，但已有结果令人警惕。SandboxEscapeBench（Marchand et al., 2026）在嵌套沙箱夺旗（capture-the-flag）设定下评测前沿 LLM，并报告说，针对基于 Docker 的容器，逃逸成功率在 15% 到 35% 之间，具体取决于容器配置。该基准覆盖了多种逃逸机制，包括配置错误、权限分配失误、内核漏洞，以及运行时或编排层弱点。其结果表明，即便在当前模型能力下，这一威胁也已经是现实存在的，而非纯理论风险。防御研究仍处于早期阶段。IsolateGPT（Wu et al., 2025）提出了一种面向 LLM 智能体系统的执行隔离架构，对四分之三的测试查询来说，其性能开销低于 30%，同时阻止了跨应用数据泄漏。事务型沙箱方法（Yan, 2025）则提供基于回滚的保护，报告称其额外开销约为 14.5%，且对高风险命令具有较高拦截率。LLM-in-Sandbox（Cheng et al., 2026）还提供了一个互补视角：它主张使用“最小化能力”而不是“最大化能力”的沙箱环境，以同时降低攻击面与不必要的智能体复杂性。

综合来看，攻防进展之间存在明显缺口。攻击性评测已经形成了具体且可复现的基准；而防御性工作仍然零散，分布在若干彼此孤立的原型系统中，它们在威胁模型、评测协议和基线假设上都不一致。如何构建一种面向智能体原生的运行时安全框架，在统一评测方法下系统性处理提示注入、目标失配与组合放大，是一个尚未解决的研究方向，我们将在 §12.1 再次讨论。

### 3.4 部署模式

智能体沙箱基础设施已经演化出超越最初“自托管 Docker”模式的多种部署方式。当前实践中并存三种模式。在自托管模式下，开发者直接管理沙箱基础设施，这是 OpenHands 和 SWE-agent 的默认模式。在云端（SaaS）模式下，由 sandbox-as-a-service 提供商负责基础设施，例如 E2B、Modal 与 Daytona Cloud。在混合或自带云（BYOC）模式下，智能体逻辑与沙箱执行被拆分到不同环境中；例如 OpenHands SDK 的 Local / Remote Workspace 抽象（Wang et al., 2025c），以及 E2B 与 Northflank 提供的 BYOC 方案。

这些模式的演化受两股互补力量驱动。一方面，实践者报告通常沿着时延、安全性与可扩展性三个维度来刻画部署选择（Anthropic, 2025b;f）。自托管沙箱拥有最低时延和最紧密的迭代回路，但必须承担全部运维负担；云端沙箱则在这一权衡上反向取舍，提供弹性扩展和托管安全能力，但代价是网络往返；混合模式试图在保持敏感数据本地化的同时，把执行能力委托给外部基础设施。另一方面，数据驻留、合规与可审计性等组织约束，又会推动部署走向混合架构，即便从时延与可扩展性的角度看，单一模式方案可能更优。

从目前观察到的实践来看，自托管沙箱在交互式开发与单租户场景中占主导，云端沙箱则更常见于多租户和大规模部署。混合模式正在那些“既有合规 / 数据本地化要求、又需要大规模临时执行容量”的场景中特别兴起。针对真实智能体工作负载，对这三种模式进行系统性经验比较的研究目前仍然缺失，我们将其视为 §12.1 更广义运行时议程的一部分。

### 3.5 小结

执行环境是智能体执行 harness 的物理底座：它提供安全边界、为可复现评测与训练提供重置机制，并给出一个受限区域，使长时程智能体无需对每条命令都请求人工批准即可行动。本节考察的七类系统表明，如今的设计空间已不再主要由单一隔离原语所塑造，而更多由工作负载真实度、威胁模型和运维模式共同决定。托管式沙箱与计算机使用环境强调强隔离和真实执行；代码专用环境与浏览器环境强调吞吐与评测结构；框架集成式运行时为了便利而捆绑能力；OS 级权限沙箱收窄本地权限；抽象层则让底层执行基底变得可替换。

这一设计空间反复暴露出能力、控制与成本之间的张力。高风险工作负载推动系统走向 microVM 和托管云；交互式本地工作流推动系统走向轻量级权限边界；大规模训练或评测则推动系统选择能快速重置的执行基底。因此，关于运行时防御、经济性、可移植性、捆绑还是组合式设计，以及跨层耦合的未解问题，并不是执行层的边角注脚，而会在 §12.1 重新作为整个综述范围内的开放问题出现。

## 4 工具接口与协议层（T）

工具接口与协议层（T）是 ETCLOVG 的第二根支柱（§1 中的 主张 2）。本节讨论的协议、描述方式与注册表，来自支持 主张 3 的 148+ 项目语料。

**图 7：** 面向 LLM 智能体的工具接口与协议代表性工作，按本章的工具层类别组织。

![](/files/4k1IPPzdsrYdtrVUvGGu)

图中按四类工具层问题归纳代表性工作：协议与接口标准、工具描述 / 发现 / 选择、工具增强训练与集成，以及可扩展性与会话管理。

工具接口与协议层定义了：智能体如何发现能力、如何表示可调用能力，以及如何跨越异构运行时边界执行动作。在实践中，这一层正处在两个互相竞争目标的断层线上：一方面希望通过暴露更多工具来扩大能力覆盖，另一方面又要通过保持动作空间和提示词占用足够小来维持决策质量。近期来自生产级智能体系统的工程经验反复指出，过大的工具菜单会降低可靠性、增加 token 开销，并放大规划错误（Anthropic, 2025d; OpenAI, 2026c）。

我们将这一层组织为四个相互补充的方向：协议与接口标准；工具描述、发现与选择；工具增强训练与集成；以及可扩展性与会话管理。

### 4.1 协议与接口标准

MCP 已成为编码智能体和企业智能体中最显眼的工具集成底座，它采用显式的 host-client-server 架构，并通过基于 JSON-RPC 的类型化交换来传递工具、资源和提示词（Model Context Protocol, 2025b;c;a）。MCP 的实际价值不只在于模式层面的互操作性，还在于其生态流动性：智能体构建者可以复用一个持续扩张的 server 目录，而不必为每个部署目标都实现定制连接器。

A2A 面向的是一个不同但相邻的边界。它不是把工具暴露给单个智能体进程，而是标准化黑盒化智能体应用之间的通信，包括通过 Agent Cards 进行发现、支持同步与流式交互，以及支持长时任务协作（A2A Project, 2025）。近期的协议综述通常把 MCP 与 A2A 视为互补角色：MCP 主要用于工具 / 上下文访问，A2A 则用于智能体之间的委派与协作（Ehtesham et al., 2025）。但在我们看来，更有用的组织原则并不是按供应商谱系或发布时间来分类，而是按它们跨越的“集成边界”来分类。在这个视角下，会出现四种边界：Model ↔ Function（结构化调用）、Agent ↔ External capability（运行时与工具解耦）、Agent ↔ Agent（跨进程委派），以及 Agent ↔ Repo/environment（版本控制下的策略约束）。表 1 对这一视角做了总结，也明确说明了为什么许多经常被并列比较的标准（例如 MCP vs. A2A，或 Function calling vs. OpenAPI）在同一个 harness 中其实承担的是不重叠的角色。

函数调用模式与 API 描述标准，仍然是这一层的基础构件。OpenAI 风格的 function calling 通过 JSON Schema 以及显式的调用 / 返回轮次，把工具调用操作化（OpenAI, 2026c）；OpenAPI 则提供语言无关、机器可读的 API 契约，许多智能体框架都把它作为工具生成与校验的来源（OpenAPI Initiative, 2025）。此外，像 AGENTS.md 和 AGENT.md 这样的仓库级指令文件，也为“直接在版本控制中编码工具使用方式和工作流约束”提供了轻量替代方案，从而降低了代码智能体的使用门槛（agentsmd, 2025; agentmd, 2025）。

**表 1：** 按跨越的集成边界（行）与四个与 harness 相关的能力轴（列）排列的工具 / 接口标准。✓ = 一等支持；△ = 部分支持或约定层支持；✗ = 超出范围。参考文献见周边正文。

| 边界                 | 标准                   | 传输协议（Wire） | 类型化 | 运行时发现 | 长时任务 |
| ------------------ | -------------------- | ---------- | --- | ----- | ---- |
| Model ↔ Function   | Function calling     | JSON       | ✓   | ✗     | ✗    |
| Agent ↔ Capability | MCP                  | JSON-RPC   | ✓   | ✓     | ✗    |
| Agent ↔ Capability | OpenAPI              | HTTP       | ✓   | ✓     | △    |
| Agent ↔ Agent      | A2A                  | JSON-RPC   | ✓   | ✓     | ✓    |
| Agent ↔ Agent      | ACP / ANP            | HTTP       | ✓   | ✓     | ✓    |
| Agent ↔ Repo / env | AGENTS.md / AGENT.md | Markdown   | ✗   | △     | ✗    |

### 4.2 工具描述、发现与选择

一旦协议定义了“如何发起调用”，下一个瓶颈就是“每一步应该暴露并选择哪些工具”。越来越多的工作开始研究工具文档质量、工具检索以及动态候选裁剪。EASYTOOL 研究了如何从大型工具库存中选择合适工具这一挑战（Yuan et al., 2025）。AnyTool 与 CRAFT 则致力于通过自动构建或自动改进工具使用流水线来减少人工规格说明负担（Du et al., 2024; Yuan et al., 2023）。MetaTool 这类 Benchmark 风格评估表明，不同领域、不同查询形式下，工具检索质量与调用质量之间可能存在显著分化（Huang et al., 2023）。更新近的工作，如 MCP-Zero、ToolRet 和 ToolRegistry，则更加强调“面向检索的编排能力”和“注册表质量”是决定下游智能体成功率的一等因素（Fei et al., 2025; Shi et al., 2025b; Ding, 2025）。一个密切相关的方向，是把工具选择扩展到可复用技能（skills）：此时智能体需要识别的，不再只是紧凑的 API Schema，而是相关的过程性模块。SkillRouter（Zheng et al., 2026）与 SkillRet（Cho et al., 2026）都在大规模条件下研究了这一技能选择问题，凸显出从庞大且彼此重叠的 skill 库中检索正确 skill 的必要性。

在系统层面，这些结果强化了两条设计原则。第一，“更少但更好的工具”往往优于蛮力式暴露全部工具，因为它同时降低了提示熵和规划器的分支数。第二，发现流水线必须具备自适应性：静态、全局性的工具列表无法扩展到快速演化的代码仓库，也无法适配多租户企业部署。

### 4.3 工具增强训练与集成

第三个方向把重点从运行时编排转向模型能力习得。Toolformer 展示了如何通过自监督增强，学习在生成过程中何时、以及如何插入 API 调用（Schick et al., 2023）。Gorilla 与 ToolLLM/ToolBench 沿着这一路线继续推进，引入更大的工具语料、指令微调流水线，以及面向 API 使用的执行中心监督（Patil et al., 2024b; Qin et al., 2024）。ToolkenGPT 与 CREATOR 则探索 token 级或控制器式集成，以提升调用格式的保真度与规划稳定性（Hao et al., 2023; Qian et al., 2023b）。

在生产级 harness 中，这些模型侧进展通常会与框架级运行时栈配对出现，例如 LangChain、Semantic Kernel 和 smolagents；这些框架提供 memory 抽象、路由中间件和工具适配器（LangChain, 2026c; Microsoft, 2026; HuggingFace, 2026）。编码智能体场景还暴露出第二类、更加语义化的工具：静态分析器、类型检查器、求解器支撑的验证器、证明助手，以及用于补丁等价性或故障定位的检查器。Ugare 与 Chandra（2026）把这类空间概括为 agentic code reasoning：智能体在不一定执行代码的前提下，探索仓库并推理代码行为。他们提出的半形式化推理方法位于“非结构化思维链”与“完全形式化验证”之间：它强制智能体陈述前提、追踪程序路径并导出显式结论，从而改进补丁等价性验证、故障定位与代码问答。对于工具层来说，这意味着自动化推理工具返回的应当是承载证据的工件——例如执行轨迹、证明义务、反例或结构化证书——而不应只是黑盒式的是 / 否答案。综合证据表明，工具能力是由预训练与微调信号、接口 schema 质量以及运行时选择策略共同决定的；只优化其中一个组件，收益将是有限的。

### 4.4 可扩展性与会话管理

一个反复出现的运维挑战，是长时程任务下的会话管理。有状态的工具会话能够提升连续性，但也会显著增加状态维护复杂度，尤其是在调用被并行化或被多个智能体相互委派时。常见失败模式包括：陈旧句柄、重试后工具状态不一致，以及冗长工具轨迹导致的上下文窗口饱和。因此，有效的 harness 设计需要为工具会话提供显式的生命周期控制、对注入上下文的工具信息施加边界，并提供可观测性钩子，以便把错误归因到“规划器逻辑问题”还是“接口 / 协议故障”。

最后，生态层面的版图梳理固然有助于实践者快速映射可用的协议与工具选项，但真正决定某次部署应采用何种协议与工具路由策略的，仍然必须是以 Benchmark 为依据的对比评估。

## 5 上下文与记忆管理（C）

上下文与记忆管理（C）是 Prompt Engineering、Context Engineering 与 Harness Engineering 相交汇的层面。我们将其视为 ETCLOVG 的第三个支柱（§1 中的主张 2），并从支撑主张 3 的 148+ 项目语料中抽取示例。

> **图 8：** 按本章上下文层类别组织的 LLM 智能体上下文与记忆管理代表性工作。

![](/files/iEAeDpZZWQOGBpfl787q)

这一层决定模型在执行每一步时能看到什么信息，以及知识如何跨轮次、跨会话持续存在。其核心工程问题说起来很简单：在每一步，恰到好处地给模型正确的信息，除此之外不要再多。上下文太少，智能体就缺少正确行动所需的状态；上下文太多，性能就会下降，而且这种下降是可测量的、跨模型一致的，但其机制仍未被完全理解。

本节按时间跨度来组织。5.1 说明为什么上下文需要主动管理，而不能被动累积。5.2 将 Context Engineering 界定为一门不同于 Prompt Engineering 的独立学科。5.3 至 5.5 讨论生产实践中已经浮现出的三层记忆架构：活跃上下文窗口（短期）、会话级持久化（中期）以及跨会话存储（长期）。5.6 处理智能体运行数百轮时会出现的协调问题。5.7 以尚未解决的问题收尾。

### 5.1 为什么必须对上下文做工程化处理

更大的上下文窗口并不能解决记忆问题。要理解这一点，必须先看架构。

**二次注意力成本。** Transformer 的自注意力机制会为上下文中的每一对 token 计算关系（Vaswani et al., 2017）。对于 n 个 token，就会产生 n² 个成对权重；计算与内存都随上下文长度呈二次增长。FlashAttention 和位置编码插值等工程技术可以降低常数项，但这种二次结构是架构性的。上下文长度翻倍，成本不是翻倍，而是四倍。这使得上下文窗口成为一种稀缺资源。

**U 形注意力曲线。** 即使计算资源负担得起，仅靠架构成本也无法解释模型为什么会在长上下文上失败。Liu et al. (2024) 给出了关键的实证结果：在包含 20 篇输入文档的多文档问答任务上，当相关文档位于上下文中间时，准确率比放在开头或结尾时下降 30% 以上。这种 U 形性能曲线跨模型、跨任务、跨上下文长度都成立，甚至包括专门针对长上下文训练的模型。其实际含义非常直接：信息放在哪里，与信息是否存在同样重要。一个智能体就算检索到了正确内容，但若摆放位置不佳，检索几乎没有收益。

**大规模场景下的上下文腐化（context rot）。** Hong et al. (2025) 在精心控制的条件下评估了 18 个前沿模型，包括 GPT-4.1、Claude Opus 4、Gemini 2.5 和 Qwen3，以便将输入长度的影响与任务难度区分开来。所有模型都会随着输入增长而退化。这种退化并不均匀，而且依任务而异。对于语义含混、相关段落与问题在词面上并不匹配的查询，其退化比精确匹配查询更陡峭，这指向一种复合失效：模型先是无法定位相关信息，之后即便定位到了，也无法在其上进行推理。更重要的是，这种退化在上下文窗口尚未被填满之前就已经开始。一个标称支持 200K token 的模型，可能在 50K 时就出现显著性能损失。Hong et al. (2025) 将这一现象称为上下文腐化（context rot），而它并不是边缘案例。对于任何会在多步执行中不断积累工具结果、中间推理和文件内容的智能体来说，这恰恰是其常态运行条件。

综合来看，这些结果表明，上下文管理绝不能被当成事后补救。关于纳入什么、放在哪里、何时移除的每一个决定，都会直接影响智能体的可靠性。

### 5.2 从 Prompt Engineering 到 Context Engineering

从 Prompt Engineering 转向 Context Engineering，反映的是作用范围的变化（Karpathy, 2025）。Prompt Engineering 优化的是单次模型调用中的一段基本静态文本输入。在视觉领域，同一思路又扩展到连续可学习 token：视觉提示微调（visual prompt tuning）会在冻结的视觉 Transformer patch 序列前附加一小组可训练向量，从而适配下游任务，同时只更新不到 1% 的模型参数（Jia et al., 2022; Xiao et al., 2025）。无论是文本还是视觉变体，都共享 Prompt Engineering 的核心特征：面向单一输入模态，对一次固定单调用做优化。Context Engineering 则优化多步任务中，模型在每一个推理步骤可获得的完整信息状态。Anthropic 的 Applied AI 团队将其定义为：“在 LLM 推理期间，为整理与维持最优 token 集合而采用的一整套策略，其中还包括所有可能在提示词之外落入该集合的信息”（Anthropic Applied AI Team, 2025）。由此得出的指导原则是：在每一步找到最小但高信号的 token 集合，以最大化得到期望结果的概率。本节的所有技术都围绕这一原则展开。渐进式披露会按需、即时地加载信息，而不是一开始就全部塞入。压缩会移除已经完成使命的 token。记忆检索则只拉取与当前任务最相关的记录。

**上下文包含什么。** 在一个已部署的智能体中，推理时的上下文包括系统提示词和行为指令、工具定义与 schema、先前轮次的历史、当前执行轨迹中的工具调用结果、检索到的文档或记忆记录，以及任何动态注入的工作状态。所有这些内容都在竞争同一份有限的注意力预算。Context Engineering 意味着在每一步都对这些组成部分做出明确选择，而不是任由它们无节制累积。

这一实践的成熟已经在生态中清晰可见。第一性原理手册（Kimai, 2025）和整理型综述（Meirtz, 2025）都已将 Context Engineering 视为一门独立学科。覆盖活跃上下文窗口、会话级状态与跨会话存储的三层记忆架构，已经成为主导性的组织框架。它与操作系统中的经典内存层次直接对应，既提供了直观类比，也提供了把正确技术匹配到正确时间尺度上的词汇体系。

### 5.3 短期：管理活跃上下文窗口

短期上下文管理决定了模型在单个推理步骤中，或会话内一小段连续步骤中，究竟能看到什么。这些决定对模型行为的影响最直接，而一旦做错，代价也最高。

**系统提示词校准。** 系统提示词决定了智能体的行为边界，并且会在每次调用中占用一笔固定预算。Anthropic Applied AI Team (2025) 将这一设计挑战概括为寻找合适的“高度”：过于具体的提示词会引入脆弱、维护成本高的逻辑；过于模糊的提示词则无法为模型提供足够具体的指导。在实践中，有效的系统提示词通常会被组织成边界清晰的若干部分，例如背景、指令、工具指导和输出格式，并用 XML 标签或 Markdown 标题加以分隔。推荐工作流是：先在能力最强的可用模型上使用一个最小化提示词，通过实证识别失效模式，然后只针对这些具体缺口补充定向指令。预先罗列所有边界情况，往往只会让提示词膨胀，而不会提升可靠性。

**高 token 效率的工具设计。** 工具定义是上下文中的重要消耗项。每一个 schema、名称、描述和参数类型，都会在每次调用时被注入。一个庞大的工具集合，可能在智能体读到用户请求之前，就先吞掉数万 token。生产环境中沉淀出的原则是：优先使用更少、但表达力更强的工具，而不是提供一大串狭窄工具的菜单。如果一个人类工程师都说不清某种情况下该用哪个工具，就不能指望模型做得更好。工具应该是自包含的、稳健的、用途明确的，并拥有描述性强、能发挥模型理解优势的参数名。

**即时检索与渐进式披露。** 有效的智能体不会一开始就加载所有潜在相关信息，而是维护一些轻量级标识符，例如文件路径、已保存查询和网页链接，并在任务推进过程中按需加载数据。Anthropic Applied AI Team (2025) 将这种方法称为渐进式披露。它和人类专家的工作方式很相似：我们不会把整个语料都背下来，而是建立外部索引系统并按需检索。Claude Code 在实践中采用了一种混合策略。会话开始时加载 `CLAUDE.md` 文件以提供项目上下文，而 `glob` 与 `grep` 命令则使智能体能够在需要时再定位并读取具体文件内容。这同时避开了索引陈旧问题，以及大体量 prefill 的成本。环境元数据也带有隐式信号。文件大小暗示复杂度；命名约定透露用途；时间戳则可作为相关性的代理指标。智能体可以像搭积木一样分层构建理解，并只在活跃窗口中保留当前确实需要的内容。

**面向 KV-cache 的上下文设计。** 提示缓存是生产级智能体部署中性价比最高的一项优化，而它的收益完全取决于上下文的组织方式。Manus 团队在对其生产智能体做了五轮架构迭代后，将 KV-cache 命中率称为“生产阶段 AI agent 最重要的单一指标”，并指出在 Claude Sonnet 上，缓存 token 的成本是 $0.30/MTok，而未缓存 token 是 $3.00/MTok（Manus Team, 2025）。缓存模型直接导出三条设计规则。第一，保持提示前缀稳定：系统提示词开头哪怕只差一个 token，后续所有内容的缓存都会失效。第二，把上下文视为只追加结构：修改过去的动作或观察，会因为前缀序列不同而破坏缓存复用。第三，使用确定性的序列化：JSON 序列化时不稳定的 key 顺序，会让本来相同的请求悄悄失去缓存命中。由于工具定义通常位于序列化上下文的前部，增加或删除工具，会让之后所有轮次的缓存内容失效。Manus 的解决办法是使用上下文感知状态机，在解码时屏蔽 token logits，防止模型选择不可用动作，而不是在运行时直接改动工具定义列表（Manus Team, 2025）。Anthropic 的上下文管理发布则在产品层，通过显式的 `cache_control` 断点将缓存操作化（Anthropic, 2025c）。

围绕短期管理的工具生态也在迅速增长，其中既包括覆盖 agent 调用间 KV-cache 压缩模式的生产级 skill 库（Koylan, 2025），也包括提供以 MCP 为中心的动态上下文拼装原语的基础设施项目（context-space, 2025）。

### 5.4 中期：会话状态与跨次运行持久化

中期上下文管理处理的是活跃上下文窗口与完整长期记忆系统之间的空缺地带。其具体问题是：智能体如何在一次会话的多轮之间，或者同一任务的多次运行之间，保存并恢复状态。它的工程价值很高：只要几百个 token 的结构化工作状态，就能跨越上下文重置，否则智能体会丢掉此前累积的全部进展。

**结构化记笔记。** 最简单的中期技术，是让智能体维护一个持久 notes 文件，通常是 `NOTES.md` 或 `todo.md`，在每次运行开始时读取，并在上下文被清空前更新。Anthropic Applied AI Team (2025) 用 Claude 连续数千步玩《Pokémon》的案例说明了这一点。即使没有任何关于记忆结构的明确指令，智能体也会自行绘制已探索区域的地图，维护战斗策略及其对特定对手效果的统计，并跨上下文重置追踪目标。每次清理主对话历史的压缩步骤结束后，智能体都会重新读取自己的笔记，并在不丧失连贯性的前提下，继续持续数小时的训练序列。这里的关键洞见是：记笔记把工作记忆外部化了。智能体不再依赖对话历史来承载任务状态，而是把重要内容写入外部存储，并在需要时再读回。

**基于文件的规划与任务外部化。** 结构化记笔记的一个扩展，是把完整的计划表示外部化到磁盘。像 planning-with-files（OthmanAdi, 2025）这样的工具，会为编码智能体工作流提供持久化的基于文件的规划 skill 包。任务状态、规格说明、中间结果和依赖图会在每个里程碑写入文件系统，并在下一次运行开始时按需选择性加载，从而让那些并非立即相关的内容完全绕过上下文窗口。像 Trellis（mindfold-ai, 2025）这样的框架，则把这种模式进一步扩展到项目记忆和规格说明注入：它们维护结构化项目状态，并根据当前步骤的需要选择性注入。

**跨次运行注入。** 另一种互补模式，是提炼上一次运行的关键信息，并在下一次运行开始时注入。像 claude-mem（thedotmack, 2025）这样的工具，作为插件式记忆层来工作：它们捕获会话历史，提取对未来运行最有用的信息，并把这些内容前置到下一次会话的上下文中。这样做既不需要向量数据库，也不需要图存储，却能在多次运行之间提供相当可观的连续性。社区仓库 everything-claude-code（affaan-m, 2025）在 GitHub 上拥有超过 150K stars，是这些模式最大的开放式集合，也展示了中层记忆实践在生产级编码智能体部署中的采用规模。

**中层的权衡。** 与仅依赖窗口内管理相比，中期技术能够提供明显更强的连续性，而基础设施成本却只占完整长期记忆系统的一小部分。它的局限在于：信息只是从一次运行向下一次运行前向流动，而不是从带索引的存储中按需检索；同时，这些技术对海量历史并不具备良好伸缩性。当跨次运行历史变得庞大，或者智能体需要检索的是某条特定的既往观察，而不是注入一段摘要时，中期技术就必须由接下来要讨论的长期系统来补充。

### 5.5 长期：持久记忆系统

长期记忆系统提供的是跨会话、跨任务、跨智能体实例持续存在、且可被索引与检索的存储。中期技术依赖的是摘要的前向注入，而长期系统支持任意回忆：给定一个查询，系统都能取回最相关的已存记忆，而不管这些记忆是在什么时候创建的。正是在这一层，智能体的经验开始随时间沉淀。

#### 5.5.1 基础架构

**受操作系统启发的分层记忆。** Packer et al. (2023) 提出了这一领域的关键抽象：把 LLM 的上下文窗口视作 RAM，把外部存储视作磁盘，再赋予模型显式换入换出信息的函数调用能力。它与虚拟内存的类比是精确的：就像操作系统通过透明分页让应用获得“更大内存”的错觉一样，MemGPT 通过透明的记忆管理，让 LLM 获得“更长上下文”的错觉。智能体因此可以操作远远超出物理窗口限制的上下文，并在需要时从外部存储中取回相关历史。该论文明确建立了智能体记忆与操作系统内存之间的对应关系，而这恰恰构成了后续大多数长期记忆系统的底层直觉。

**观察、反思与检索。** Park et al. (2023) 在社会模拟智能体的语境中提出了 MemoryStream 架构。MemoryStream 会把智能体的所有观察都存成自然语言记录，并附上时间戳与重要性分数。每一步中，检索模型会结合三类信号来浮出最相关的记忆：新近性、重要性（由智能体在写入时打分）以及与当前查询的相关性。第二个机制是“反思”：系统会周期性地把已存观察综合成更高层次的洞见。智能体会追问自己：最近经历中最值得深挖的问题是什么；随后取回相关记录，并生成结论，再把这些结论存回 MemoryStream。消融实验表明，这三项组成——观察、反思和检索——都会独立提升行为质量；拿掉任何一项，性能都可测量地变差。观察—反思—检索这一三元组，如今仍是智能体记忆设计的标准模板。

**动态遗忘与人格建模。** Zhong et al. (2024) 在该检索架构之上构建了 MemoryBank，并增加了两项机制。第一是受艾宾浩斯遗忘曲线启发的动态遗忘：记忆会按照指数模型随时间衰减，频繁访问会强化记忆，而彼此矛盾的记忆则会被消解。第二是分层的用户人格摘要，它们会随着新交互的累积按日更新。这两种思路——自适应记忆强度与用户建模——后来都在 Mem0 和 Honcho 中被大规模工程化落地。

#### 5.5.2 生产级记忆系统

**混合存储与产业采用。** Mem0（Chhikara et al., 2025）是目前生产智能体中部署最广泛的长期记忆层，拥有超过 1400 万次 Python 包下载、41K GitHub stars，并已原生集成进 CrewAI、Flowise 与 Langflow。它的架构组合了三类存储后端：用于语义相似搜索的向量数据库、用于关系建模的图数据库，以及用于快速事实检索的键值存储。一个基于 LLM 的抽取层会处理新交互，识别事实和偏好，并将记录路由到合适的存储中。在 LOCOMO 基准上，Mem0 的准确率比 OpenAI 原生记忆高 26%，同时所用 token 比完整上下文方案少 90%（Chhikara et al., 2025）。Amazon Web Services 在 2025 年将 Mem0 选为其 Agent SDK 的独家记忆提供方，这表明它已经从研究原型转变为生产基础设施。

**动态知识网络。** A-MEM（Xu et al., 2025）借鉴了 Zettelkasten 卡片盒知识管理系统。它不是把记忆存成扁平记录，而是为每条新记忆生成一条结构化笔记，其中包含关键词、标签、上下文描述，以及一组动态链接到语义相关记忆的关系。当加入一条新记忆时，A-MEM 不仅会存下它，还会回溯性地更新已有相关笔记的上下文与属性，使记忆图谱随着知识的积累而演化。这解决了检索式系统的一个根本局限：一条已存记忆的重要性，在写入当下未必显现出来，它的真正意义往往只有放到后来出现的信息中，才能被正确判断。

**从经验中学习。** Hindsight（Vectorize.io, 2025）认为，大多数记忆系统关注的是“记住”，而真正的需求其实是“学习”。它的 API 暴露三类操作：`retain` 用于存储新信息，`recall` 用于检索相关记忆，`reflect` 用于生成能够结合记忆与当前上下文的倾向感知响应。在内部，`retain` 会抽取时间实体、对重叠事实去重，并构建以证据为基础的整合式知识记录。Hindsight 在 LongMemEval 基准上达到了当前最优表现，这一结果还被 Virginia Tech Sanghani Center 的研究人员独立复现。

**以推理驱动的个性化。** 由 Plastic Labs 开发的 Honcho（Plastic Labs, 2025）构建的不是“关于用户事实的存储”，而是“用户模型”。一个名为 “dreaming” 的异步推理流水线会在后台持续处理既往交互，不仅提取显式事实，还会推断用户的偏好、沟通风格与不断变化的目标。其以实体为中心的数据模型支持多智能体环境：多个智能体可以与同一用户交互，而每个智能体都保有自己的观察视角，从而避免交叉污染；与此同时，共享的用户模型会从所有交互中不断累积理解。

**集体记忆。** MozillaAI 的 `cq`（MozillaAI, 2025）填补了其他系统尚未覆盖的一个空白：跨智能体实例的共享记忆。大多数长期记忆系统都是“每个智能体一份”或“每个用户一份”；当多个智能体处理相关任务时，每个实例都会独立重新发现其他实例早已学到的内容。`cq` 是一个面向共享式智能体学习的开放标准，允许整支 agent fleet 持久化、查询并确认集体知识。它的架构原生兼容 MCP，并分为三层：开发者本机保存的本地知识、团队可访问的组织共享知识，以及跨团队知识。“出错后钩子（post-error hook）”模式尤其具体：当智能体遇到错误时，`cq` 会自动查询集体知识库，从而防止同一种失败在组织内部的多个部署中被重复、独立地解决。

#### 5.5.3 学术综述与分类法

Zhang et al. (2025) 对记忆机制的综述，为这一层提供了标准的学术分类法。它区分了短期工作记忆与长期记忆，并将后者进一步分为语义、程序和情景三类。其形式化的“写入—读取—管理”循环，已经成为描述记忆系统行为的标准词汇。更新的一篇综述（Du, 2026）则在此基础上，把这一循环放入 POMDP 风格的 agent 周期中加以形式化，并提出一个覆盖记忆范围、存储格式和组合结构三维度的分类法。它总结了三种工程模式：模式 A（单体上下文）、模式 B（上下文窗口 + 检索存储）、模式 C（带学习控制策略的分层记忆）。它们大致对应于本节讨论的短期、中期与长期三层。Gao et al. (2023) 关于 RAG 的综述，则为上文所述生产记忆系统的检索层提供了检索增强生成的背景。

### 5.6 长时程技术：让智能体在 100+ 轮后仍保持连贯

前面各节分别处理单一时间尺度的问题。而长时程任务——包括大型代码库迁移、多会话研究项目和长时间自主工作流——要求系统同时协调三层记忆，并实时决定在每个时点该用哪种技术。本节讨论的，就是这种规模下浮现出的集成层技术。

**上下文压缩。** 压缩会在上下文窗口接近上限时，对其做摘要，并以累积状态的压缩表示重新启动执行。Anthropic Applied AI Team (2025) 详细描述了其中的校准挑战。摘要必须保留架构决策、尚未解决的 bug 以及实现细节，同时丢弃冗余工具输出和那些已经被后续步骤吸收的中间推理。推荐的调优流程，是先把召回做满，尽可能捕获轨迹中一切潜在相关信息，然后再通过迭代提高精确性，逐步剔除多余内容。不能反过来一开始就先追求精简。过早删掉太多信息，会让智能体失去后来真正需要的上下文，而这种损失是无法追回的。工具结果清理是最轻量的压缩形式：一旦智能体已经对某个工具输出采取了行动，完整工具输出就会被紧凑的路径引用所替换。这种做法可以持续应用，如今也已成为 Anthropic 开发者平台上的产品级特性（Anthropic, 2025c）。

**子代理上下文隔离。** 当一个任务要求对某个子主题做深入探索时，这种探索本身会产生大量中间上下文：对子任务本身很有用，但会污染编排器对整体任务的视野。子代理架构的解决办法，是把子任务分配给专用代理，每个代理都拥有一个全新的上下文窗口，然后只向编排器返回一份压缩到 1,000 到 2,000 token 的发现摘要（Anthropic, 2025e）。详细探索上下文会被隔离在子代理内部；编排器只接收蒸馏后的结果。Manus Team (2025) 描述了这一模式的进一步细化。对于只需要产出结果的简单子任务，根本不共享上下文。对于确实需要共享状态的复杂子任务，则把编排器的完整上下文传给子代理。智能体之间共享上下文成本很高：它会带来更大的 prefill，并且会消除不同系统提示词之间的 KV-cache 复用。因此，对每一种任务类型，都必须显式权衡隔离成本与协同收益。

**混合决策框架。** 生产部署不会把某一种技术一视同仁地用到所有场景，而是会根据任务结构，在执行过程的不同节点选择不同技术。Anthropic (2026c) 将这一框架概括为：预加载那些始终需要的内容；按需即时检索那些有条件才需要的内容；当窗口接近饱和时压缩历史；当某个子任务需要深入探索、而这种探索会污染编排器上下文时，生成子代理。Anthropic (2025d) 又补充了 harness 层的视角：检查点—恢复模式使智能体能够在不丢失任务状态的情况下跨过瞬时故障，而 Lifecycle Hooks 则可以在可配置阈值触发时自动执行压缩或记忆整合。这是正确的职责划分。上下文管理应当是基础设施的工作，而不是智能体自己的工作。当 harness 自动处理压缩与整合时，模型就能把精力集中在任务推理上。

### 5.7 上下文漂移与当前方法的极限

“绑定约束”视角将上下文漂移视为一种控制器侧失效模式：由于模型只能看到 harness 暴露出的状态投影，任务相关上下文的丢失或扭曲，与其说只是模型自身的问题，不如说首先是 harness 的属性（Bölük, 2026b）。上文介绍的所有技术，都在不同条件下、不同程度上有效。但没有一种真正解决了底层问题。上下文漂移——即智能体行为在长时间交互中逐步退化——仍然是这一层最困难的开放挑战。

## 6 生命周期与编排（L）

> **图 9：** 按本章的编排类别组织的 LLM 智能体生命周期与编排代表性工作。

![](/files/pq3A017vzb0sZ1DjsLzx)

生命周期与编排（L）关注智能体系统如何把一项任务贯穿于重复的模型调用、工具调用、失败、修订与交接之中。在这一层中，我们把早期框架里常被分开讨论的两类问题合并起来：一是智能体的执行流程，二是该流程所读取和写入的运维状态。对于长时间运行的任务，可靠性不仅取决于模型能否给出一个好的下一步动作，还取决于 harness 能否记住已经发生过什么、决定接下来该发生什么、从错误中恢复、协调子任务，以及在任务完成时及时停止（OpenAI, 2026b; Anthropic, 2025d; 2026c）。因此，我们将生命周期与编排视为 ETCLOVG 的第四个支柱（见 §1 中的 主张 2），并从支撑 §1 中 主张 3 的 100+ 项目语料中抽取示例。

**表 2：** 代表性编排系统，按编排层级（单智能体：§6.2，多智能体：§6.3，全生命周期流水线：§6.4）、主要编排模式与执行模型组织。GitHub stars 四舍五入到最接近的千位，记录时间为 2026 年 5 月 12 日。

| 系统或框架                                   | GitHub URL                   | GitHub Stars（k） | 小节  | 主要模式     | 执行模型  |
| --------------------------------------- | ---------------------------- | --------------: | --- | -------- | ----- |
| OpenCode (Anomaly, 2025)                | anomalyco/opencode           |             159 | 6.2 | 单循环      | 混合    |
| Claude Code (Anthropic, 2025)           | anthropics/claude-code       |             123 | 6.2 | 单循环      | 混合    |
| Gemini CLI (Google, 2025)               | google-gemini/gemini-cli     |             104 | 6.2 | 单循环      | 混合    |
| Codex CLI (OpenAI, 2025)                | openai/codex                 |              82 | 6.2 | 单循环      | 无状态回放 |
| Aider (Aider-AI, 2025)                  | aider-ai/aider               |              45 | 6.2 | 单循环      | 混合    |
| SWE-agent (SWE-agent, 2025)             | swe-agent/swe-agent          |              19 | 6.2 | 单循环      | 混合    |
| DeerFlow (Bytedance, 2026)              | bytedance/deer-flow          |              67 | 6.3 | 分层编排     | 有状态   |
| AutoGen (Microsoft, 2025)               | microsoft/autogen            |              58 | 6.3 | 分层编排     | 有状态   |
| oh-my-claudecode (Heo, 2026)            | yeachan-heo/oh-my-claudecode |              34 | 6.3 | 团队编排     | 有状态   |
| LangGraph (LangChain, 2026a)            | langchain-ai/langgraph       |              32 | 6.3 | 图式组合     | 有状态   |
| Semantic Kernel (Microsoft, 2026)       | microsoft/semantic-kernel    |              28 | 6.3 | 工作流编排    | 有状态   |
| OpenAI Agents SDK (OpenAI, 2026a)       | openai/openai-agents-python  |              26 | 6.3 | 分层编排     | 混合    |
| DeepAgents (LangChain, 2026b)           | langchain-ai/deepagents      |              23 | 6.3 | 分层编排     | 有状态   |
| Hive (Aden, 2026)                       | aden-hive/hive               |              10 | 6.3 | 图式组合     | 有状态   |
| Emdash (Action, 2026)                   | generalaction/emdash         |               4 | 6.3 | 扇出       | 混合    |
| Vibe Kanban (Bloop-AI, 2026)            | BloopAI/vibe-kanban          |              26 | 6.4 | 全生命周期流水线 | 有状态   |
| Symphony (OpenAI, 2026b)                | openai/symphony              |              24 | 6.4 | 全生命周期流水线 | 有状态   |
| GitHub Agentic Workflows (GitHub, 2026) | github/gh-aw                 |               5 | 6.4 | 全生命周期流水线 | 混合    |

这一层跨越三个组织层级。**单智能体内循环**，是一个智能体不断观察当前情境、决定动作、调用工具或生成输出，并利用结果继续推进的重复循环。**多智能体编排**，则协调多个这样的循环，通常通过给不同智能体分配不同角色，或在它们之间路由工作来实现。**全生命周期流水线**，会把这些智能体循环置于更宽泛的软件或任务工作流内部，例如从 issue 走到代码变更、测试、审查和 pull request。跨越这些层级，系统在状态维持方式上也有所不同。**无状态回放**会从记录下来的交互历史中重建运行；**有状态执行**则把运维状态存储在提示词之外，例如文件、代码仓库、数据库、任务图或服务里。许多实用系统是混合式的：既保留可回放的历史，又依赖持久化工件。

### 6.1 生命周期状态管理

生命周期状态管理关注的是：为了让一次智能体运行能够跨越轮次、会话、失败与交接继续推进，harness 需要维护哪些运维状态。这包括待处理子任务、工具结果、中间工件、仓库变更、协同元数据、会话持久化，以及 checkpoint / resume 机制。这些机制让编排从“瞬时的”变成“可持续的”：智能体能够在既有工作的基础上继续，而不是把每一步都当作彼此隔离的模型调用。

这里的核心权衡，在于无状态执行与有状态执行之间。无状态设计从既有交互历史中重建执行过程，可提升可复现性与可审计性，但随着轨迹增长，成本会越来越高。有状态设计则把执行状态持久化到文件、数据库、代码仓库、任务图或外部服务里，提升连续性与恢复能力，但同时引入一致性与调试挑战（OpenAI, 2026b; Anthropic, 2026c）。因此，许多实用 harness 会采用混合设计，将可回放的交互历史与持久化工件结合起来。

这种状态不同于 §5 的“上下文与记忆”层；后者关注的是提供给模型进行推理的信息，例如检索到的文档、记忆或对话历史。它也不同于 §7 的“可观测性”；后者关注的是日志、追踪和系统行为监控。这里的重点，是 harness 自身为了继续与协调执行而使用的运维状态，例如待处理子任务、检查点、重试、共享工件或执行状态。

### 6.2 单智能体内循环

单智能体内循环是智能体系统中的基本执行单元。一个智能体通过工具使用与环境反馈反复交互，而不存在多个智能体之间的显式协同。这个抽象支撑了交互式编码、调试与问题求解等场景。

从概念上说，单智能体循环遵循 ReAct 范式（Yao et al., 2023），将推理、行动与观察交错起来。正如 OpenAI 对 Codex 智能体循环的分析所强调的那样（OpenAI, 2026b），这类系统的行为并不只由模型决定，还取决于负责构造提示词、调用工具、管理控制流，并将工具输出反馈到后续步骤的 harness。

在这一层，主要的执行差异体现在**无状态回放**与**混合执行**之间。Codex CLI（OpenAI, 2025）是基于回放执行的最清晰示例，而实际中的编码智能体则往往把可回放的历史与文件、代码仓库或会话状态等持久化工件结合起来。因此，在表 2 中，OpenCode（Anomaly, 2025）、Claude Code（Anthropic, 2025）、Gemini CLI（Google, 2025）、Codex CLI（OpenAI, 2025）、Aider（Aider-AI, 2025）和 SWE-agent（SWE-agent, 2025）都被归为“单循环”这一主要模式。尽管这类系统很灵活，但在长时程执行中，它们会遭遇上下文碎片化、误差积累以及分解结构薄弱等问题（Anthropic, 2025d），这也推动了更具结构化的编排方式出现。

### 6.3 多智能体编排模式

多智能体编排通过组合具有专门角色的多个智能体，把单智能体循环扩展了出去。与其让一个智能体独自完成规划、执行、检查和修订，多智能体 harness 可以把这些职责分散到多个智能体或子智能体上。这种结构支持任务分解、并行探索、批评、验证与聚合，因此比孤立循环更适合复杂任务。

在这一设计空间里，出现了几种反复出现的模式。**分层编排**用一个高层控制器给智能体或子智能体分配工作，并整合它们的输出。**团队编排**则把一组具备不同命名职责的专门智能体，作为一支协同团队暴露出来。**工作流编排**把智能体和工具组织进显式阶段或控制逻辑。**扇出**会并行运行多个智能体，以探索多样化方案。**图式组合**则把智能体、工具或状态表示成交互图中的节点，使多种协调模式可以并存。这些标签与表 2 所用的主要模式类别一致。

在这一层，执行通常是**有状态**或**混合**的，因为多智能体系统必须维护协同状态、角色分配、任务图、共享工件或中间结果。Anthropic 的 planner-generator-evaluator 架构（Anthropic, 2025e）体现了一个更普遍的原则：可以把规划、执行与验证分离为显式角色，在提高分解质量和稳健性的同时，也增加了协同开销（Anthropic, 2026c）。

表 2 中的系统以不同方式实例化了这些模式。DeerFlow（Bytedance, 2026）、AutoGen（Microsoft, 2025）、OpenAI Agents SDK（OpenAI, 2026a）和 DeepAgents（LangChain, 2026b）被归为分层编排；oh-my-claudecode（Heo, 2026）被归为团队编排；LangGraph（LangChain, 2026a）和 Hive（Aden, 2026）代表图式组合；Semantic Kernel（Microsoft, 2026）代表工作流编排；Emdash（Action, 2026）则代表扇出。

### 6.4 从 Issue 到 Pull Request 的全生命周期流水线

全生命周期编排管理的是从规格说明到经验证输出的整个任务工作流。在这一层里，焦点不只是“智能体如何相互交互”，更是“harness 如何在规划、实现、验证、审查与交付之间支撑长时程执行”。

这里的核心抽象是**任务运行器（task runner）**：一种负责调度、状态持久化、重试、验证，以及在完整任务生命周期内进行迭代式改进的 harness（OpenAI, 2026a; LangChain, 2026b）。执行建立在持久化工件之上，例如代码仓库、Issue、分支、文件、测试与 Pull Request。一个典型工作流，会从 Issue 或任务规格出发，经由智能体规划、代码或工件生成、验证与人类审查，最终形成 Pull Request。

由于这些系统运行在持久化代码仓库和外部工作流之上，占主导地位的执行模型通常是**有状态**的。有些系统把持久化基础设施状态与子组件内部的类似回放或会话局部执行结合起来，因此表 2 中也使用了“混合”这一标签。Vibe Kanban（Bloop-AI, 2026）、Symphony（OpenAI, 2026b）与 GitHub Agentic Workflows（GitHub, 2026）都被归为全生命周期流水线。从概念上讲，这些系统组合了前述抽象：单智能体循环提供执行原语，多智能体模式提供协调机制，而任务运行器则把它们集成为端到端工作流，在其中由人类掌舵，由智能体执行。

## 7 可观测性与运维（O）

> **图 10：** 按本章运维类别组织的 LLM 智能体可观测性与运维代表性工作。

![](/files/bCQoopRA4Mgtsf4EcFIl)

可观测性与运维（O）是 ETCLOVG 的第五层，也是我们从“Lifecycle Hooks 的附属物”提升为独立层的两层之一（见 §1 中的主张 2）。支撑这一层的系统，来自支撑主张 3 的 148+ 项目语料。

这一层关注的是：在生产环境中，如何监控、调试并提升智能体行为的可靠性。我们认为，可观测性应被独立对待，而不应只是 Lifecycle Hooks 的附属物，因为它已经催生出一个专门的生态，包含平台、规范与工程实践。

### 7.1 追踪与监控平台

智能体可观测性的基础，是结构化轨迹收集：把每一次 LLM 调用、工具调用、检索步骤与上下文组装操作，都记录成一棵可供检查、过滤与回放的 span 树。Langfuse、Opik、Arize Phoenix 与 MLflow 都提供交互式轨迹树、延迟火焰图、token 使用拆解、成本归因仪表盘与提示词版本管理（Langfuse, 2026; Comet ML, 2026; Arize AI, 2026b; MLflow, 2026）。这些平台通常通过轻量级 SDK 包装器摄取轨迹，例如 `@Traceable` 装饰器，只需最少的代码改动就能对智能体函数调用完成埋点。

在这些平台之下，OpenTelemetry（OTel）已成为事实上的埋点标准。OTel 社区已经发布了面向生成式 AI 的语义约定，为模型名、温度、token 数量与延迟等定义了 span 属性（OpenTelemetry, 2026）。两个开源项目把这些约定真正落地：OpenLLMetry 为 LLM 提供方（OpenAI、Anthropic、Cohere）和向量数据库（Pinecone、Chroma、Weaviate）提供自动埋点库，输出标准 OTel spans 与指标（Traceloop, 2026）；由 Arize AI 维护的 OpenInference，则提供一套与 OTel 对齐的语义约定与自动埋点实现，作为 Phoenix 的配套规范（Arize AI, 2026a）。建立在 OTel 之上之后，这些库就能把智能体轨迹输送到团队原本已用于传统微服务监控的同一套后端里，例如 Prometheus、Jaeger、Grafana 与 Datadog，从而降低采用智能体专用工具的运维开销。

在更深层次的埋点上，学术界开始探索超越应用层装饰器的技术。AgentSight（Zheng et al., 2025）使用 eBPF 从应用进程外部监控智能体：它在 SSL 边界截获 TLS 加密的 LLM 流量以捕捉意图，并监控内核事件（进程创建、文件 I/O、网络调用）以捕捉动作。一个实时关联引擎会把 LLM 响应与它们触发的系统行为联系起来，而第二个“观察者”LLM 负责执行语义分析并识别风险。这种方法与框架无关、不需要改代码，CPU 开销也低于 3%。作者指出，它相较应用层工具有一个结构性优势：系统级监控无法被被攻陷或配置错误的智能体绕过，因此特别适合安全关键部署。AgentTrace（AlSayyad et al., 2026）则提出一种互补的、基于 schema 的日志框架，跨三个“表面”捕捉结构化日志：认知层面的推理步骤、计划与反思；运维层面的工具调用与 API 交互；以及情境层面的环境状态与用户输入。这三个表面被组织在统一封套之下，并可接入 OTel 进行导出。其中，认知表面与 Harness Engineering 尤其相关，因为它记录了显式推理工件、计划、反思与自我修正。这些记录为调试“错误推理”而非“系统错误”导致的失败，提供了原始材料。

### 7.2 智能体专用运维平台

通用追踪平台通常把自身抽象建立在 LLM 调用、工具调用与检索步骤之上，并把它们视作 span 级事件。智能体专用平台则在其上增加了一层抽象，用以表示智能体层面的关切：多步会话跟踪、智能体身份与角色管理、工具选择策略，以及跨会话状态交接。AgentOps SDK 引入了一个分层 span 模型，围绕智能体生命周期来组织会话、智能体、操作或任务、工作流、工具调用与 LLM 调用，而不是把这些视作相互孤立的 API 调用（AgentOps AI, 2026）。RagaAI Catalyst 面向 RAG 与多智能体工作负载，提供质量与安全仪表盘，以显露检索层与智能体层的异常（RagaAI, 2026）。Laminar 则提供一个以开源优先的智能体可观测性平台，结合了轨迹、评估与提示词管理（Laminar AI, 2026）。

学术界也开始将这些关切形式化。Moshkovich 与 Zeltyn（2025）提出了一条六阶段的 AgentOps 自动化流水线：观察、收集指标、检测异常、执行根因分析、推荐修复措施，以及自动化修复。他们还把利益相关者空间分解为四类角色：开发者、测试人员、SRE 与业务用户；每类角色都会在不同生命周期节点介入这条流水线。他们的配套实证研究（Moshkovich et al., 2025）又引入了“任务流发现（task-flow discovery）”这一可观测性原语，并通过用户研究表明：79% 的从业者认为，不确定性的执行流是智能体系统中最重要的挑战。

自然语言智能体 harness（NLAH）框架（Pan et al., 2026）及其配套开源实现，则朝另一个方向迈进：把 harness 本身视为一等研究对象。通过系统性的消融实验，作者量化了单个 harness 模块对下游智能体性能的贡献。这些模块包括工具注册表、权限系统、钩子、skills 与上下文折叠。对于可观测性的意义在于：harness 不仅是被监控的对象，也是干预的来源。每一个被消融的模块，都构成了可被可观测性流水线标记和调节的“旋钮”。

认知可观测性，则把智能体级监控进一步扩展为不仅追问“智能体做了什么”，还追问“它为什么这么做”。Watson（Rombaut et al., 2025）正式提出了这一概念：它部署一个“替身智能体（surrogate agent）”，在复现主智能体输出的同时，通过提示词归因生成逐步推理轨迹。在 MMLU 与 SWE-bench-lite 上，结合 AutoCodeRover 与 OpenHands 的评估表明，恢复出的推理轨迹既有助于人工调试，也有助于自动纠正，并能带来可测量的任务成功率提升。AgentLens（Lu et al., 2024）则通过一个三栏可视分析系统完成可视化闭环：Outline View 展示智能体轨迹，Agent View 展示带因果弧线的事件堆栈，Monitor View 则展示同步的环境回放。它把原始执行日志转化为分层结构化的行为叙事。一项有 14 名参与者的用户研究显示，在复杂分析任务上，它显著优于仅基于回放的基线。还有一些更专门化的可视化工具，例如 claude-code-reverse，会逆向分析特定编码智能体的交互链条，作为理解 harness 级行为的研究工件（Yu, 2026）。

### 7.3 成本跟踪与优化

LLM 推理按 token 计费，而智能体 harness 会放大成本暴露，因为一次面向用户的任务可能触发数十次 LLM 调用，每次都有自己的提示词组装、工具结果注入与响应生成。因此，可观测性在这里有双重要求：**跟踪**（知道 token 花在了哪里）与**优化**（用更少 token 达到同样效果）。

在跟踪方面，TensorZero 提供了一套统一的 LLMOps 栈，把网关、可观测性、实验与优化打包进单一服务，从而实现按调用与按任务的成本归因（TensorZero, 2026）。Helicone 则采取了极简做法：作为一个可直接接入的代理，几乎无需改代码，就能增加成本和延迟监控，因此特别适合那些想先获得成本可见性、但暂时不想上完整可观测性平台的团队（Helicone, 2026）。

在优化方面，FrugalGPT（Chen et al., 2023）给出了基础性表述，提出了三种降本策略：提示词自适应、LLM 近似，以及 LLM 级联。它表明，一个自适应级联可以在保持 GPT-4 性能的同时，将成本最多降低 98%，方法是把更容易的查询路由给更便宜的模型。GPTCache（Bang, 2023）则提供了一个语义缓存层：在请求真正到达 LLM 之前，先拦截重复或转述的查询，并通过 embedding 相似度检索缓存响应。路由与缓存因此共同成为生产级成本优化栈中的反复出现的构件，并且可以直接应用到 harness 级埋点中：每一次工具调用与上下文组装步骤，都可以被独立计量与路由。

QC-Opt（Shekhar et al., 2024）把路由范式进一步扩展为质量感知框架，在预算约束下联合优化模型选择、token 数量与延迟，并用一个 BertScore 预测器在不调用目标模型的前提下估计输出质量。TALE（Han et al., 2025）发现的 token 弹性现象，则揭示了一个重要细节：如果给链式思维推理设置过低的 token 预算，反而可能增加 token 消耗，因为模型会溢出预算，最终生成的 token 比一个中等预算提示词还多。在基础设施层，Dual-Pool Token-Budget Routing（Liu et al., 2026b）从服务侧处理成本问题：它把同构 vLLM 集群拆分为短上下文池与长上下文池，并根据估计 token 预算来路由请求。基于 Azure LLM Inference 轨迹与 LMSYS-Chat-1M 的评估表明，该方法可减少 31–42% 的 GPU 小时（在 A100 规模集群上折算成年节省 286 万美元）。

对于 Harness Engineering 师而言，其含义是：成本可观测性必须跨越多个层次——API 级 token 跟踪（Helicone、TensorZero）、应用级路由决策（FrugalGPT、QC-Opt），以及基础设施级资源利用率（Dual-Pool、KV-cache 占用率）。Anthropic 关于智能体编码评估中基础设施噪声的研究（Anthropic, 2026a）提供了一个值得警惕的补充：仅仅是基础设施配置，就足以让 Benchmark 分数波动 6 个百分点（p < 0.01）。这一发现表明，成本优化与评估保真度紧密缠绕在一起，因为为了节省成本而削减资源，可能会以细粒度可观测性不可见的方式悄悄损害智能体性能。

### 7.4 可靠性工程

失败恢复、checkpoint / resume、重试策略与会话恢复，都是 harness 层面的关切；它们决定了一个长时间运行的智能体能否经受住瞬时失败。对于跨越多个上下文窗口运行的智能体来说，这一点尤其尖锐，因为每一个新会话开始时，都不记得先前发生过什么（Anthropic, 2025d）。Anthropic 关于 Harness Engineering 的工作识别了长时间运行编码智能体的四种反复出现的失败模式：智能体试图“一口气”完成整个任务；智能体过早宣称项目已完成；智能体在会话之间把环境留在损坏状态；以及智能体在没有充分测试的情况下，把特性标记为完成。其两段式解决方案，并不是改进模型，而是直接通过 harness 设计来应对这些可靠性问题（Anthropic, 2025d）。方案先用一个 initializer agent 把任务分解成结构化特性列表，并建立进度跟踪工件，包括 git 仓库、进度文件和初始化脚本；随后再由 coding agent 以增量方式逐项处理，每次只做一个特性，同时留下干净的交接状态。

后续工作扩展了这一模式。Anthropic 受 GAN 启发的三智能体架构（planner、generator、evaluator）引入了 sprint contracts 与分离式自我评估，以应对智能体过度称赞自身工作的倾向（Anthropic, 2026c）。值得注意的是，作者表明 harness 的复杂度应随模型能力变化：当从 Opus 4.5 升级到 Opus 4.6 时，他们完全移除了 sprint 结构和上下文重置，把成本从 200 美元降到 125 美元，同时保持输出质量。这说明了一个一般原则：harness 的每个组件，都编码了某种关于“模型单靠自己做不到什么”的假设；而随着模型进步，这些假设会过时。

在基础设施层，Anthropic 的 Managed Agents 架构（Anthropic, 2026b）把“脑”（harness + LLM）、“手”（沙箱与工具）以及“会话”（持久化事件日志）解耦，使它们都能够被独立恢复。如果 harness 崩溃，一个新实例可以通过 `wake(sessionId)` 被重新拉起，并从会话日志中的最后一个事件继续。如果沙箱失效，harness 会把错误作为工具调用失败捕获，并重新配置一个新的沙箱。凭证存储在外部 vault 中，从不进入沙箱。这个架构把所有组件都从“宠物”（不可替代、需要人工照料的实例）转变为“牲口”（可互换、可自动重配的实例），而这正是大规模可靠运行智能体的先决条件。

学术界则提供了可靠性工程必须面对的失效模式分类法。MAST（Cemri et al., 2025）识别了多智能体系统中的 14 种失效模式，可归为系统设计问题、智能体间失配与任务验证问题三大类（κ = 0.88）。AgentErrorTaxonomy（Zhu et al., 2025）按模块（记忆、反思、规划、动作、系统）拆解失败，表明**误差传播**——某个单一根因级失败在后续决策中层层蔓延——是可靠性的核心瓶颈。他们的 AgentDebug 框架通过隔离根因而不是处理表面症状，使任务成功率相对提升了最高 26%。同时，Vinay（2025）还提出了一套面向生产级 LLM 部署的系统级 15 项隐藏失效模式分类法，包括版本漂移（模型更新悄然改变行为）、成本驱动的性能崩塌（激进优化损害质量），以及多步推理漂移（长序列中逐渐丧失一致性）。QSAF（Atta et al., 2025）则把认知退化形式化为一个六阶段生命周期，并在五个 LLM 平台上完成验证，显示幻觉输出可能会被存入持久化记忆，并在未来会话中再次被复用，从而形成跨越会话边界的自我强化退化回路。

在运行时检测方面，SentinelAgent（He et al., 2025）把多智能体交互建模为图，并利用 LLM-as-judge 在三种层级上分类异常——全局、单点与多点，同时辅以 human-in-the-loop 的策略细化。AgentFixer（Mulian et al., 2026）在 IBM 的 CUGA 生产系统上部署了 15 种验证工具，取得了 64–88% 的问题检出率，并发现 38% 的任务失败可以追溯到解析错误——这是完全可以通过确定性检查解决的失败模式。实际含义是，面向智能体的可靠性需要一套分层检测策略：用轻量级规则检查结构性失败（格式错误的工具调用、schema 违规），用统计监控跟踪性能漂移（延迟、token 使用、成本趋势），再用基于 LLM 的语义分析检测推理失败（幻觉、计划—动作不一致、过早停止）。

### 7.5 讨论：迈向统一的可观测性

**可观测性—评估鸿沟。** LangChain 调查中 89% / 52% 的落差，反映的是一种结构性断裂：轨迹收集工具与评估框架，在很大程度上是由不同社区、围绕不同接口独立发展出来的。要弥合这道鸿沟，需要更紧密的集成，例如自动从生产失败轨迹中生成回归测试，或把在线评估分数直接作为告警信号。LangChain 自身对深度智能体评估的分析（LangChain, 2025a）与 Anthropic 对基础设施噪声的量化（Anthropic, 2026a）都指出，评估与可观测性必须被视为一个单一的反馈回路，而不是彼此独立的议题。

**统一的 LLMOps 栈。** TensorZero 与 Langfuse 这类工具，正在朝着把网关、可观测性、评估与优化打包进单一平台的方向演进，以减少集成开销。NLAH 框架则更进一步，把 harness 本身做成模块化、可内省的对象，并能够通过消融实验量化每个组件的贡献。Anthropic 的 Managed Agents 架构则采取基础设施级视角，把 harness 组件虚拟化为可替换接口（`execute()`、`emitEvent()`、`getEvents()`），从而在不改变周边系统的前提下容纳未来的 harness 设计。

**“harness 即假设”原则。** harness 的每一个组件，都编码着某种关于“模型无法单独完成什么”的假设（Anthropic, 2026c）。随着模型进步，可观测性系统不仅要检测“智能体何时失败”，还要检测“哪些 harness 组件已经变成不再必要的额外负担”。理想的可观测性系统，应当包含一层**元监控**：追踪哪些干预措施（上下文重置、evaluator 反馈循环、工具限制）仍然承担关键负载，从而在模型升级的同时，也能持续简化 harness。

## 8 验证与评估（V）

验证与评估（V）是 ETCLOVG 的第六个支柱（见 §1 中的主张 2）。我们讨论的系统与 Benchmark，来自支撑主张 3 的 148+ 项目语料。

> **图 11：** 按本章“从任务到反馈”的生命周期组织的 LLM 智能体验证与评估代表性工作。

![](/files/p8Bc9Swc7wvYl8r33I6L)

### 8.1 将 harness 评估视为“任务到反馈”生命周期

一种具备 harness 意识的评估，应当把最终得分视为**模型—harness 配对**的属性，而不是单独属于模型本身的属性。这推动了两种评估协议：要么在模型之间锁定同一 harness，要么把 harness 配置变化显式地作为实验因素（Bölük, 2026b）。我们把 harness 评估组织为一个“任务到反馈生命周期”：一个结构化过程，从定义智能体被要求做什么开始，以把评估结果反馈回 harness 改进为止。图 12 总结了这个五阶段的任务到反馈生命周期。该生命周期建立在传统 LLM 评估与智能体评估之间的一个关键区别之上：传统 LLM 评估主要是在固定输入上给输出打分；而 harness 评估衡量的是一个执行 episode——任务被锚定在某个环境中，智能体随着时间与工具和状态交互，轨迹被捕获，evaluator 不仅评判最终结果，也评判通往该结果的路径（Kapoor et al., 2025; Anthropic, 2026b; LangChain, 2025b）。

推动这一生命周期视角的一个核心动机是：评估基础设施噪声会伪装成模型失败。失败的运行不仅可能反映模型局限，也可能反映工具损坏、上下文陈旧、未重置的沙箱、脆弱测试、Benchmark 歧义，或不稳定的 judge（Kapoor et al., 2025; Hu et al., 2025a; Jimenez et al., 2024）。因此，评估不应只报告一个最终分数，而应当把智能体行为转化为结构化判定、失效归因与回归反馈。

> **图 12：** harness 评估的“任务到反馈”生命周期。第 V 节把验证与评估组织成一个五阶段的质量控制回路：任务与 Benchmark 锚定、执行前就绪性验证、受控执行与轨迹捕获、多层级判定与失效归因，以及持续回归与部署反馈。图中强调，harness 评估不应只报告最终成功率，还应诊断失败来源，并把由此得到的证据反馈回系统性的 harness 改进之中。

![](/files/168ohWjxcP2itGf0LOxY)

这五阶段分解遵循了一次智能体评估运行的因果路径。在智能体能够被评估之前，Benchmark 必须规定任务、环境、工具、约束与成功标准。执行之前，必须验证设置，以免把环境或 grader 的失败误判为模型失败。执行期间，harness 必须捕获足以诊断运行的轨迹。执行之后，系统必须对结果、轨迹以及 evaluator 的可靠性做出判定，并把失败归因到可能的 harness 组件。最后，这些诊断又应反馈进回归测试与未来的 harness 修订之中。从这个视角看，评估不是终点式的打分步骤，而是围绕完整智能体 harness 的质量控制回路。

我们围绕五个阶段展开讨论：

* **任务与 Benchmark 锚定**：定义评估的对象、环境、工具、约束与成功标准（§8.2）。
* **执行前就绪性验证**：在智能体运行前检查沙箱、依赖、工具、上下文状态、权限策略、预算与 graders 是否已被正确初始化（§8.3）。
* **受控执行与轨迹捕获**：在可复现条件下运行智能体，同时记录模型输出、工具调用、状态变化、错误、重试、成本与延迟（§8.4）。
* **多层级判定与失效归因**：在结果、轨迹和 evaluator 三个层面评估运行，并在 harness 栈中定位最可能的失败来源（§8.5）。
* **持续回归与部署反馈**：把评估结果转化为后续 harness 修订可用的回归测试、监控信号与工程反馈（§8.6）。

### 8.2 阶段 1：任务与 Benchmark 锚定

第一阶段问的是：**我们在评估什么？** 对于 LLM 智能体而言，任务不只是自然语言提示词，而是一个被嵌入环境状态、可用工具、允许动作、约束、终止条件与成功标准共同定义的问题。因此，任务锚定是 harness 评估的基础：如果没有被明确规定的环境与成功条件，后续分数就无法被可靠解释。

#### 8.2.1 软件工程与终端任务

软件工程 Benchmark 是最成熟的智能体评估形式之一，因为它们把真实任务与可执行的结果验证结合了起来。SWE-bench 把每个任务锚定在真实的 GitHub issue 与代码仓库快照之中，再通过运行测试来评估生成的补丁是否解决了问题（Jimenez et al., 2024）。Terminal-Bench 则把这一思路扩展到命令行工作流：智能体通过编辑文件、运行命令、设置依赖并解释失败，与终端环境交互（Merrill et al., 2026）。这一类别的关键洞见是：强有力的结果验证器，依赖同样强有力的任务锚定。只有当仓库状态、依赖和目标成功标准被精确规定时，测试才能充当可靠的 evaluator。否则，测试失败既可能意味着补丁无效，也可能意味着环境不一致，或 Benchmark 实例定义不足。

#### 8.2.2 Web、浏览器与计算机使用任务

Web 与计算机使用 Benchmark，把任务锚定在交互式环境中，其中成功由浏览器、桌面或应用程序状态变化来定义。WebArena 引入了面向自主智能体的真实 Web 环境（Zhou et al., 2024）；VisualWebArena 加入了多模态 Web 任务（Koh et al., 2024）；BrowserGym 提供了通用的浏览器智能体研究底座（Chezelles et al., 2024）；WorkArena 则聚焦于基于 ServiceNow 的知识工作（Drouin et al., 2024）。OSWorld 进一步把评估扩展到真实计算机环境，涵盖桌面应用、文件以及多应用工作流（Xie et al., 2024）。这些 Benchmark 表明，浏览器与计算机使用评估同时是一个评估问题，也是一个环境设计问题。Benchmark 必须提供真实接口、隔离任务状态、暴露适当观察，并定义可度量的成功谓词。这就在执行环境层与评估层之间建立了直接桥梁。

#### 8.2.3 跨领域与企业工作流任务

第三组 Benchmark 测试的是更广泛的智能体能力，覆盖异构工具、环境与工作流。AgentBench 在操作系统、数据库、Web 浏览与游戏等多种场景中评估 LLM 智能体（Liu et al., 2023a）；GAIA 面向需要推理、工具使用与多模态信息访问的通用助手任务（Mialon et al., 2023）；TheAgentCompany 则模拟了工作场景下的数字劳动（Xu et al., 2024）。WorkArena 与 WorkArena++ 进一步强调企业工作流，在这里，成功取决于能否驾驭复杂软件，而不是回答孤立问题（Drouin et al., 2024; Boisvert et al., 2024）。这一 Benchmark 家族的主要贡献，在于**覆盖面**：它测试 harness 能否跨越任务类型、工具接口、状态表示、工作流与成功条件进行泛化。

### 8.3 阶段 2：执行前就绪性验证

第二阶段问的是：**这次评估能否被公平且可复现地运行？** 这一阶段在 Benchmark 排行榜中往往不可见，但对 harness 评估却至关重要。在智能体开始之前，harness 必须验证环境、工具、上下文状态、权限边界、预算约束与 evaluators 是否已正确初始化。如果缺少这一层就绪性检查，下游失败就会变得难以归因：一次失败的运行既可能由智能体导致，也可能由评估设置导致。

#### 8.3.1 环境与沙箱就绪性

环境就绪性检查的是：沙箱、代码仓库、浏览器、终端或虚拟机，是否都从一个已知基线启动。在软件工程场景中，这包括仓库快照、依赖、任务设置与测试可执行性；在浏览器与计算机使用场景中，则包括网站重置、浏览器 profile、桌面状态与服务可用性。

一些系统明确把这个问题摆到了台面上。SWE-bench 依赖可执行的仓库状态与基于测试的验证（Jimenez et al., 2024）；Terminal-Bench 用受控环境与验证逻辑打包终端任务（Merrill et al., 2026）；OSWorld 则使用真实计算机环境与基于执行的评估脚本（Xie et al., 2024）。Repo2Run 通过为代码仓库自动生成可执行 Docker 环境，直面依赖与环境配置问题（Hu et al., 2025a）；HAL 则强调标准化、成本感知的智能体评估基础设施（Kapoor et al., 2025）。这些系统共同表明：环境重置与依赖验证，本身就是测量仪器的一部分。

#### 8.3.2 工具、上下文与权限就绪性

就绪性验证是跨层的。**工具就绪性**检查 API、MCP 服务器、浏览器控制、shell 命令与文件操作是否可用，并且描述一致。**上下文就绪性**检查历史、记忆存储、scratchpad 与检索文档是否已被重置，或被有意初始化。**权限就绪性**则检查文件访问、凭证、网络访问与审批闸门，是否与 Benchmark 规格一致。

这一阶段把评估与工具层、上下文层和治理层连接在一起。如果不同运行之间工具描述发生变化，那么 Benchmark 实际上就不再测量同一个动作空间；如果记忆或上下文未被重置，智能体就可能利用泄漏状态；如果权限过于严格，一个有能力的智能体可能会因为治理原因失败；如果权限过于宽松，不安全或 exploit Benchmark 的行为又可能不会被检测出来。SWE-agent、SWE-ReX 与 OpenHands 等系统都表明：工具暴露、shell 访问、浏览器访问、执行后端与运行时接口，都不能与评估设计分开来看——智能体—计算机接口决定了哪些动作是可能的，也就决定了 Benchmark 真正在测量什么（Yang et al., 2024; SWE-agent Team, 2024; Wang et al., 2025b）。因此，一个严谨的评估 harness 应当把这些配置——包括工具注册表、上下文策略、权限策略、预算、超时、模型配置与运行时接口——都记录为评估元数据。

#### 8.3.3 Evaluator 与 Grader 就绪性

在执行开始之前，evaluator 本身也必须被验证。确定性 grader 应检查脆弱性、缺失依赖以及与环境状态的兼容性；LLM-as-Judge 的提示词、rubric 与 judge 模型应当进行版本管理；人工审计协议也应当预先定义。

在 SWE-bench 这类 Benchmark 中，可执行测试提供了可扩展的结果验证，但分数的有效性仍然依赖正确的环境设置、任务规格与测试可靠性（Jimenez et al., 2024）。对于开放式任务，LLM-as-Judge 系统需要校准、偏差缓解与一致性检查，而不能只是盲目复用一个强模型作为 grader（Zheng et al., 2023; Gu et al., 2024）。因此，evaluator 就绪性是有意义的失效归因的前提。

### 8.4 阶段 3：受控执行与轨迹捕获

第三阶段问的是：**执行过程中究竟发生了什么？** 在传统 LLM 评估中，证据可能只是一组输入—输出对；而在智能体评估中，轨迹是一条多步轨迹：模型观察状态、推理、调用工具、接收工具输出、修改环境、处理错误，并最终终止。因此，受控执行与轨迹捕获是把 Benchmark 运行转化为可诊断证据的必要条件。

#### 8.4.1 受控 Rollout 与可复现执行

一次 **Rollout** 是智能体评估的基本单元。它包括任务、模型配置、harness 配置、动作序列、中间观察、最终状态与评分结果。受控 Rollout 要固定住意外变异的关键来源，包括环境状态、工具可用性、超时、预算、权限策略与 evaluator 版本。当不可避免地仍有非确定性时，重复 Rollout 就能暴露方差，而不是把它隐藏在单一分数后面。

一些系统很好地说明了为何需要可复现执行。SWE-agent 把代码仓库导航、文件编辑与测试执行暴露为交互循环的一部分（Yang et al., 2024）。OpenHands 结合了代码编辑、命令行执行、浏览器交互、沙箱执行与 Benchmark 集成（Wang et al., 2025b）。SWE-ReX 则抽象出了本地与远程的沙箱化 shell 环境，使执行后端可以变化，而无需改变智能体逻辑（SWE-agent Team, 2024）。LangChain 进一步把深度智能体评估分解为单步验证、完整回合评估与多轮模拟（LangChain, 2025b）。这些系统共同表明，可复现性要求回放完整的“模型—工具—环境”交互，而不仅仅是重新运行一次模型调用。

#### 8.4.2 原生面向轨迹的评估

轨迹捕获把二元打分转换为因果诊断。一个原生面向轨迹的 harness 应当记录模型输出、工具调用、工具结果、环境状态变化、上下文快照、错误、重试、恢复动作、token 使用、延迟与成本。这些轨迹能够区分一些表面相似的失败：一个智能体可能根本没找到相关文件，另一个则可能找到了文件但生成了无效补丁。它们也能揭示“不理想的成功”，例如 Benchmark exploit、过量工具调用或权限违规。

HAL 把日志与轨迹视为标准化智能体评估中的一等工件，通过大规模智能体日志去检查那些最终分数可能掩盖的行为（Kapoor et al., 2025）。R2E-Gym 同样把可执行轨迹与混合验证器连接起来，既服务于测试期评估，也服务于训练期反馈（Jain et al., 2025）。对于 Harness Engineering 来说，轨迹并不是附带的调试工件，而是主要的评估数据。

#### 8.4.3 成本、延迟与资源跟踪

智能体评估不应只报告质量，还应报告达成该质量的成本。长时程智能体可能通过花费更多 token、调用更多工具、更频繁重试或使用更强模型，来提升成功率。因此，harness 级评估应当跟踪 token 使用量、模型成本、墙钟延迟、工具调用次数、重试次数与资源消耗。

评估不应只按成功率给智能体排序，而应显式展示成功率—成本—延迟前沿。HAL 明确把智能体评估框定为一种标准化、成本感知的过程（Kapoor et al., 2025），而 LangChain 的深度智能体评估指南则强调，在可复现环境中进行多粒度评估，以便比较成本—质量权衡（LangChain, 2025b）。这对于已部署的智能体服务尤其重要，因为真正的优化单元是预算和延迟约束下反复出现的工作负载。

### 8.5 阶段 4：多层级判定与失效归因

在完成受控执行与轨迹捕获之后，核心问题变成：**应该如何判定这次运行；如果它失败了，又该如何定位失败？** 我们把阶段 4 定义为一个由**多层级判定**与**失效归因**组成的联合过程。多层级判定会在三个层面评估运行：最终结果是否正确、轨迹是否高效且符合策略，以及 evaluator 本身是否可靠。随后，失效归因利用这些信号去诊断失败最可能源于哪里，例如模型、工具接口、上下文管理器、执行环境、编排循环、Benchmark 规格或 evaluator。正是这一阶段把 harness 评估与普通 Benchmark 评估区分开来：目标不只是给出一个分数，而是把执行证据转化为可执行的工程诊断。

#### 8.5.1 结果层评估

结果层评估衡量的是最终任务目标是否达成。在软件工程中，这通常通过单元测试、回归测试或面向 issue 的特定验证来实现，例如 SWE-bench（Jimenez et al., 2024）。在终端任务中，结果评估可能依赖最终文件、命令输出或任务特定检查器（Merrill et al., 2026）。在浏览器与企业工作流 Benchmark 中，它通常依赖最终环境状态，例如表单是否提交成功、工单是否更新、配置是否变更（Zhou et al., 2024; Drouin et al., 2024）。在 GAIA 这类通用助手 Benchmark 中，结果可能就是一个答案字符串或结构化响应（Mialon et al., 2023）。

结果层评估的优势，在于可扩展性与可解释性：它给出清晰的任务级成功信号，并支持排行榜比较。它的局限，则在于压缩。完整执行 episode 被压缩为一个值，从而掩盖了智能体是否以稳健、安全或高效的方式成功。因此，结果评估是必要的，但并不充分。它回答的是“任务是否完成”，而不是“harness 是否产生了一次值得信赖的执行”。

#### 8.5.2 轨迹层评估

轨迹层评估衡量的是智能体所走路径的质量。它追问的是：智能体是否选择了合适工具、是否以合理顺序组织动作、是否避免了冗余调用、是否遵守权限边界、是否能从错误中恢复、以及是否能在时间推移中保持上下文一致性。正是在这一层，智能体评估与传统输出评估最明显地分叉：即便最终答案正确，轨迹仍可能不可接受，例如智能体浪费过多 token、反复调用无关工具、违反权限，或依赖脆弱的、特定 Benchmark 的行为。

ReAct 通过把智能体行为表示为推理、行动与观察交错的步骤，为轨迹层分析提供了概念基础（Yao et al., 2023）。现代智能体 Benchmark 把这一视角延伸到了更丰富的环境中，其中每一步动作都会改变未来步骤可用的状态。WebArena、WorkArena、OSWorld 与 Terminal-Bench 都会产出这样的轨迹；其中的中间动作不是隐藏的实现细节，而是有意义的评估对象（Zhou et al., 2024; Drouin et al., 2024; Xie et al., 2024; Merrill et al., 2026）。LangChain 的评估模式同样强调单步评估与完整回合评估，因为它们让开发者能在整次运行成功或失败之前，就先检查局部决策质量（LangChain, 2025b）。

轨迹层判定对 Harness Engineering 尤其有价值，因为它提供了从失败走向修复的路径。如果智能体失败是因为选错工具，那么可能需要改进工具接口；如果它忘了先前约束，那么可能需要调整上下文层；如果它陷入循环而无法恢复，那么编排层就可能需要生命周期控制。因此，轨迹评估会把 Benchmark 结果转化为面向具体层的工程反馈。

#### 8.5.3 Evaluator 层评估

Evaluator 层评估问的是：**evaluator 本身能否被信任？** 确定性验证器，例如单元测试或基于状态的检查器，可复现、便宜，也易于跨系统比较，但它们比较狭窄，而且很难为开放式任务设计。LLM-as-Judge 系统在自然语言输出与轨迹评估方面很灵活，但它们引入了偏差、非确定性与额外成本。人工审计在含糊或安全关键的场景中仍然重要，但无法扩展到持续评估。

G-Eval 表明，基于 LLM 的 evaluator 在 NLG 评估任务上可以更好地与人工判断对齐，同时也揭示了其偏向 LLM 生成文本的倾向（Liu et al., 2023b）。MT-Bench 与 Chatbot Arena 对 LLM-as-Judge 进行了系统研究，识别出位置偏差、冗长偏差、自我增强偏差以及有限推理能力等问题（Zheng et al., 2023）。近期综述进一步指出，可靠的 LLM-as-Judge 系统需要标准化、偏差缓解、一致性检查与元评估（Gu et al., 2024）。

对于智能体 harness 来说，Evaluator 层评估并非可有可无。如果 grader 不稳定、测试具有非确定性，或 LLM judge 带有偏差，那么评估噪声就会被错误归因给智能体或模型。因此，一个稳健的 harness 应优先采用分层 grader：用确定性检查处理客观状态变化，用 LLM judge 处理语义或轨迹层评估，用人工审计处理歧义或高风险情况。Evaluator 应被视为一个待测组件，而不是系统外部不可质疑的神谕。

#### 8.5.4 跨 Harness 各层的失效归因

失效归因识别的是：在一次运行被判定之后，究竟应该修什么。一次失败的 Rollout 可能源于模型推理、工具接口设计、陈旧或过度压缩的上下文、沙箱不稳定、编排循环、Benchmark 歧义，或 evaluator 不可靠（Kapoor et al., 2025; Hu et al., 2025a; Jimenez et al., 2024; LangChain, 2025b）。结果层信号告诉我们目标是否达成；轨迹层信号揭示动作序列是否连贯、高效并符合策略；Evaluator 层信号则告诉我们这个分数本身是否可信。合在一起，这些信号支撑的是诊断，而不只是打分（Kapoor et al., 2025; LangChain, 2025b）。

在实践中，归因很少是一个单标签分类问题。模糊的任务规格可能导致智能体选错工具；过度压缩的上下文可能导致它遗忘约束；沙箱依赖问题可能触发恢复行为并增加成本；脆弱的 judge 又可能掩盖一个其实有效的解。因此，失效归因应被理解为针对完整轨迹的诊断过程，而不是附着在最终分数上的一个事后标签。阶段 4 的输出，不应只是一个判定结果，而应是一份结构化诊断，供阶段 5 反馈到回归测试与 harness 改进之中。

### 8.6 阶段 5：持续回归与部署反馈

最后一个阶段问的是：**评估结果如何驱动下一轮 harness 修订？** 智能体 harness 会持续演化：提示词、工具 schema、MCP 服务器、上下文策略、沙箱镜像、编排循环、治理规则与 judge 提示词，都会随着时间变化。持续评估会把 Benchmark 结果与生产失败转化为作用于 harness 自身的回归系统。

#### 8.6.1 面向 Harness 变更的回归评估

回归评估不仅应由模型变更触发，也应由 harness 变更触发。工具描述、上下文压缩策略、沙箱镜像、权限规则或 judge 提示词的变动，都可能改变智能体行为或评估分数。由于 harness 各组件之间会相互作用，局部改进也可能引发全局回归。

实践中的模式，是维护一套分层评估集：面向工具 schema 与确定性验证器的单元测试式检查，面向局部决策的单步测试，面向端到端完成情况的完整 Rollout 测试，以及面向长时程一致性的多轮模拟。LangChain 的深度智能体评估指南就体现了这种分层视角（LangChain, 2025b），而 Anthropic 则把 evals 视作拥有显式评分逻辑、可以在开发阶段运行的自动化测试（Anthropic, 2026b）。

#### 8.6.2 评估框架与 LLMOps 集成

通用评估框架为持续测试提供了可复用的基础设施。Promptfoo 支持提示词与 LLM 应用的回归测试；DeepEval 提供了类单元测试抽象；RAGAS 聚焦于检索增强生成评估；lm-evaluation-harness 则支持标准化语言模型评估（promptfoo contributors, 2026; Confident AI, 2026; Es et al., 2024; Biderman et al., 2024; Gao et al., 2021）。尽管这些工具并不都是为长时间运行的智能体设计的，但它们为面向回归的 harness 评估提供了积木。

评估还应与可观测性打通。生产轨迹可以转化为回归测试，评估失败也可以转化为可观测性信号。这样，监控“发生了什么”与构造“能复现并诊断这些问题的受控测试”之间，就形成了闭环。

#### 8.6.3 基于验证器与训练期的评估

一个较新的方向，是不再只把 evaluator 当作离线指标，而是把它们用作训练信号与优化目标。基于验证器的环境与类 RL 的智能体 gym，包括 R2E-Gym 与 verifiers，会把环境反馈作为奖励或验证信号，用于改进智能体策略与 scaffold（Jain et al., 2025; Prime Intellect, 2026）。这使评估从一个事后测量步骤，转变为智能体训练、测试时自适应与 scaffold 选择的主动组成部分。

Meta-Harness 则更进一步，把 harness 设计本身视为自动搜索对象（Lee et al., 2026）。在这个视角里，问题不再只是“在固定 harness 下哪个模型表现最好”，而是“哪种 harness 结构、提示词策略、工具接口或控制循环，能产生更可靠的智能体行为”。由此，评估不再是流水线的终点，而是使 harness 得以持续改进的信号。

### 8.7 总结

## 9 治理与安全（G）

> **图 13：** 按本章安全类别组织的 LLM 智能体治理与安全代表性工作。

![](/files/vZndRuWjZpHjW5eFFfel)

这一层关注的是：如何约束智能体行为、如何让其更安全，以及如何追究责任。如今，LLM 智能体会执行 shell 命令、发送邮件、提交代码、调用第三方 API；对于生产部署而言，一个核心问题是：它们应该在什么约束下行动，以及当这些约束失效时，由谁承担责任。在生产系统中，治理已经拥有自己的工具生态，包括权限引擎、策略语言、审计流水线与网关控制。这个生态不同于第 6 节与第 7 节中讨论的 Lifecycle Hooks 与可观测性基础设施，因此值得在 ETCLOVG 分类法中被视为独立层。我们围绕五种机制组织讨论：权限模型与身份管理（§9.1）、Lifecycle Hooks（§9.2）、组件加固（§9.3）、声明式宪章（§9.4）与审计基础设施（§9.5）。随后，我们把这些机制放回更广阔的智能体安全版图中定位（§9.6），并以若干开放研究方向收尾（§9.7）。

### 9.1 权限模型与身份管理

任何智能体 harness 面对的第一个治理问题，都是访问控制：智能体可以触达哪些工具、文件、网络端点与系统资源？ 与传统 RBAC 或 ABAC 相比，智能体工作负载使这个问题更困难，因为所需工具集往往依赖一项部署时尚未知的自然语言任务。因此，相关文献沿着一个粒度轴展开：部署时固定的静态边界、按每次工具调用在运行时评估的上下文策略，以及跨越本地 harness 边界的交互权限。

**静态权限边界。** 生产级编码智能体通常预先定义一个权限范围，并对范围外操作要求升级授权。Codex（OpenAI, 2025）会在带有限制文件系统与网络访问的沙箱中运行 shell 命令，而 Gemini CLI（Mullen & Salva, 2025）则把工作区范围内的文件访问与命令 allow / deny 列表结合起来。这类边界容易检查，但无法表达任务特定意图。

要超越固定规则，就必须表达依赖每次工具调用运行时上下文的约束。

**依赖上下文的权限控制。** Progent（Shi et al., 2025a）引入了一种 DSL，其谓词可作用于工具名、参数与环境状态。运行时会在每次调用前评估这些谓词，从而使最小权限策略能够随角色、用户或会话而变化。Conseca（Tsai & Bagdasarian, 2025）进一步推进了这一思路：它从可信上下文中生成一条任务特定策略，再用一个独立的确定性检查器来执行。策略生成与策略执行的分离非常关键：生成组件可以适应任务，而执行器仍然保持可审计。

前述方法都作用于单一智能体边界之内。多智能体环境又会在访问控制与智能体身份两方面带来额外挑战。

**身份管理与智能体间访问控制。** 在授予访问之前，系统必须先建立“是谁在请求”。South et al.（2025）主张智能体需要经过认证的委托：一种在 OAuth 2.0 与 OpenID Connect 之上扩展的框架，加入 User ID Tokens、Agent ID Tokens 与带作用域的 Delegation Tokens，把用户意图绑定到智能体特定权限上。这条 token 链会从人类主体一路延伸到智能体动作，形成一条可验证的责任轨迹。SAGA（Syros et al., 2025）则把这一原则扩展到多智能体场景中，采用 provider 中介架构。智能体使用加密一次性密钥注册，而短时效访问令牌会强制执行用户定义的智能体联络策略。IsolateGPT（Wu et al., 2025）采取了一种架构性方案，使用 hub-and-spoke 模型：每个第三方应用都在隔离的 spoke 中运行，拥有各自的 LLM、记忆与工具访问。spoke 之间的通信必须经过可信 hub，因此跨应用数据交换会成为显式治理决策，而不是共享上下文窗口的偶然副作用。

**凭证管理。** 智能体会经常处理 API keys、会话令牌与一次性密码。把这些凭证暴露给 LLM 上下文，会带来外泄风险。Skyvern（Skyvern-AI, 2025）体现了一种常见模式：把 secret 存在 vault 中，只向 LLM 暴露占位符，然后只在真正执行认证动作的自动化层里替换成原始值。尚未解决的问题，则是长时程会话中的 secret 生命周期管理：当 token 在轨迹中途过期或被吊销时，新凭证应如何更新，同时仍然不进入模型上下文。

**Web 层权限协同。** 上述机制作用于单个 harness 的信任边界内部，并治理单一部署。跨组织交互——例如智能体访问第三方网站，或调用由其他主体拥有的服务——则需要单一 harness 无法独立强制的协调。Marro et al.（2025）提出了 `agent-permissions.json`：一种类似 `robots.txt` 的轻量 manifest 文件。网站所有者可以声明：智能体可以操作哪些 UI 元素、速率限制、并发约束，以及哪些动作需要人工确认。这类 manifest 并没有解决认证问题，但它展示了治理如何开始跨越组织边界。

**开放挑战。** 如何设计既有表达力又易用的权限模型，仍是未解问题。策略过于严格会损害智能体效用；过于宽松又会抵消治理本身的意义。社区还没有收敛出一种可跨 harness 实现移植的标准权限规格语言。关于动作究竟应归因于人类操作员、智能体实例，还是临时任务身份，也仍存在开放问题（South et al., 2025）。

### 9.2 Lifecycle Hooks

权限模型定义的是“什么被允许”；Lifecycle Hooks 定义的是“何时触发策略检查”。许多 harness 会在智能体循环的每个阶段暴露 hook：规划前、工具调用前、工具执行后，以及响应交付前。这些 hooks 允许在不修改智能体核心推理逻辑的情况下，注入治理逻辑。图 14 把这四个 hook 放回了一次单一工具使用周期之中。

> **图 14：** 单次工具使用周期中的四个钩子点。实线框是智能体组件，虚线框是治理钩子。H1 在输入到达 LLM 之前验证输入；H2 在工具执行前验证拟议动作；H3 调解工具输出重新进入上下文时的信息流；H4 则对后果重大的动作施加用户审批门控。

![](/files/NWC3ZXejQo7UNEsdKpsR)

输入 → LLM → 工具 → 响应

* H1：输入护栏（Input GR）
* H2：动作护栏（Action GR）
* H3：执行后信息流控制（Post-exec IFC）
* H4：人在回路（Human-in-the-Loop）

**执行前钩子：输入护栏。** 输入护栏会在数据到达 LLM 之前先做验证。PromptShield（Jacob et al., 2024）与 DataSentinel（Liu et al., 2025）都训练了专门分类器，用于检测用户输入与检索内容中的 prompt injection 载荷。基于规则的检测器很严格，但也很脆弱；基于模型的检测器泛化更好，但更慢，也更容易受到自适应攻击（Andriushchenko et al., 2024; Nasr et al., 2025）。

下一个执法点，位于 LLM 提出的动作与 harness 实际执行工具之间。

**调用前钩子：输出护栏与动作验证。** 在执行工具调用前，输出护栏会检查 LLM 提出的动作。ShieldAgent（Chen et al., 2025b）把安全约束形式化为可验证谓词，并对每个动作进行检查。在多智能体系统中，ControlValve（Jha et al., 2025）会约束智能体之间的控制流图，阻止未授权的智能体跃迁，以应对某个智能体把执行劫持到另一个智能体上的控制流劫持攻击。

工具执行之后，返回数据在重新进入 LLM 上下文之前，也必须先被检查。

**执行后钩子：信息流控制与污点跟踪。** CaMeL（Debenedetti et al., 2025）实现了一种基于能力的信息流控制。每个数据值都携带元数据标签以追踪其来源，从而区分可信用户输入与不可信 Web 检索内容。一个定制解释器会强制保证：不可信数据不能影响控制流决策。CaMeL 把对可信上下文的规划，与对不可信内容的处理分离开来，以牺牲一部分灵活性为代价，在 AgentDojo（Debenedetti et al., 2024）上换取了更强的控制流 / 数据流边界。

还有一种互补的执行机制，是把人类用户纳入回路之中。

**人在回路钩子。** 包括 Codex、Gemini CLI、Cursor 与 OpenHands（Wang et al., 2025a）在内的多个广泛使用的编码智能体，都会对破坏性动作或超出范围的动作要求显式用户批准。三项设计维度决定了这些钩子的效果：验证范围（哪些动作需要批准）、告警丰富度（用户能看到多少上下文），以及复发策略（只允许一次还是始终允许）。Felt et al.（2012）发现，只有 17% 的 Android 用户在安装应用时真正注意权限对话框，且只有 3% 能正确回答自己授予了什么权限；智能体审批对话框很可能面临类似的习惯化与理解风险。频繁请求会让用户对危险动作形成反射式批准；请求太少则又会留下覆盖缺口。

一些系统把钩子视为可编程的执行基底，而不是孤立分类器。AgentSpec（Wang et al., 2026）在计划、工具调用与响应阶段，用触发器、谓词与执行动作来定义规则。AgentDoG（Liu et al., 2026a）则把焦点从单次动作分类转向轨迹级诊断，按来源、失效模式与后果组织风险。

**开放挑战。** 在当前系统中，hook API 仍然高度异构。我们尚未见到一种被广泛采用的标准，能够为第三方治理模块定义通用 hook 接口。钩子还会引入延迟，而多层堆叠钩子之间的交互效应也几乎没有被系统研究。一种可信的失效模式是：上游清洗器改变了下游检测器赖以判断的信号，最终使组合起来的流水线比单独使用任一组件时都更弱。

### 9.3 组件加固

Lifecycle Hooks 是在 harness 层面执行治理；组件加固则针对单个智能体组件——尤其是 LLM 本身及其调用的工具——的特定脆弱性进行强化。加固后的组件，会让更少恶意输入存活下来并触发治理钩子，因此 harness 也会从中受益。

**模型加固。** prompt injection 的一个根因，是 LLM 把所有输入文本都看作同等优先级，因此无法区分系统指令与不可信数据。Wallace et al.（2024）提出了一种指令层级（instruction hierarchy），训练模型让其优先遵从高权限指令（系统提示词），而不是低权限指令（用户消息、工具输出）。当低层指令与高层约束一致时，模型会学习去遵从；当二者冲突时，模型会学习忽略或拒绝。SecAlign（Chen et al., 2025a）则把同样的防御目标表述为一种偏好优化问题：在被 prompt injection 的输入、理想响应与不理想响应之间进行优化。

这些模型层防御与 harness 层钩子互为补充。更强健的模型会在推理时直接拒绝更多 injection，从而减轻下游护栏的负担。然而，单靠模型加固并不足够：它处理的是 Kim 等人分类法中的“错误指令跟随”问题，但无法防止无约束数据流或未授权动作；后两者需要系统层强制执行（Kim et al., 2026; Wei et al., 2026）。

**基于分类器的运行时加固。** 一条互补路径是在不修改智能体 LLM 本身的前提下，通过部署小型辅助分类器，在运行时筛查输入与输出来加固模型边界。Llama Guard（Inan et al., 2023）会微调一个独立模型，针对可配置安全分类法同时对用户提示词与助手响应进行分类，并返回按类别划分的标签，让 harness 可以据此路由到允许、阻断或升级处理决策。与训练期加固相比，这类分类器有两个实际优点：分类法无需重新训练智能体模型即可修改，而且同一检测器可以复用于异构 LLM 后端。代价则是 §9.2 所讨论的延迟预算：每增加一个分类器，就相当于在每次工具使用循环中多增加一次前向推理。

模型与分类器防御加固的是 LLM 边界；而工具边界则需要自己的协议层处理。

**工具加固与 MCP 安全。** Model Context Protocol（MCP）（Anthropic, 2024c）已经成为智能体与工具之间的通用接口，但它最初的规范缺乏原生安全原语。这一缺口既吸引了攻击研究，也吸引了防御研究，并触发了官方规范响应。Radosevich 与 Halloran（2025）表明，广泛使用的商业 LLM 可以被胁迫去使用 MCP 工具执行恶意代码、建立远程访问并窃取凭证。他们的 McpSafetyScanner 使用三智能体架构（黑客、审计员、监督者）系统性地发现这类漏洞。Trail of Bits（Trail of Bits, 2025）则指出，MCP 服务器甚至可以在用户真正调用工具之前就攻击客户端：它们可通过被污染的工具描述，在注册阶段影响 LLM 行为。

在防御方面，ETDI（Bhatt et al., 2025）为 MCP 扩展了带加密签名与版本控制的工具定义，确保对工具代码、schema 或权限的任何修改，都必须生成一个新的已签名版本。这能够防止一种“rug-pull”攻击：一个先前被批准的工具被悄悄更新，转而执行恶意动作。

**协议层加固。** 除了单独组件外，SAFEFLOW（Li et al., 2025）还为多智能体系统引入了协议层执行机制。它把细粒度信息流控制与事务式执行语义结合起来，使得违规工具调用或智能体间消息能够被回滚，而不会传播到共享状态中。

**供应链风险。** 智能体治理还必须处理智能体所调用的工具与软件包。Spracklen et al.（2025）分析了 16 个 LLM 的 57.6 万段代码样本，发现开源模型会以 21.7% 的比例幻觉出并不存在的软件包名。攻击者可以把这些被幻觉出来的名字注册到公共仓库上，并注入恶意代码；这种技术被称为“slopsquatting”。ETDI 的密码学签名只覆盖了 MCP 工具这一部分问题，而包管理器与检索源仍然处于多数智能体治理栈之外。

**开放挑战。** 模型加固与工具加固面对的是互补的威胁面，但目前还没有统一框架把它们连接起来。一个加固过的模型仍可能调用被攻陷的工具；一个签名工具定义，也无法阻止被 jailbreak 的模型对其进行滥用。如何把这些防御组合成一套具有可量化残余风险的连贯栈，仍是开放问题。随着 MCP 采用率上升，其安全扩展自然会成为标准化目标，但互操作性与认证机制目前仍高度不确定。

### 9.4 声明式宪章

把治理逻辑直接嵌入应用代码，会使策略变得不透明、难以审计，也难以更新。越来越多的 harness 开始把治理规则外置为声明式配置文件，最常见的是 YAML 格式。这些文件充当了智能体的机器可读宪章。

**训练期宪章：Anthropic 的 Constitutional AI。** Anthropic（Anthropic, 2026a）发布了一套按四层优先级层级组织的宪章：安全（保留人类监督）、伦理（诚实、避免伤害）、合规（Anthropic 的指导原则）与有帮助性（满足用户请求）。这套宪章区分了硬约束——例如绝对禁止提供化学、生物、放射与核（CBRN）辅助——以及操作员可以在规定边界内调整的软编码默认值。在智能体场景中，这种结构暗示了一种主体层级：Anthropic 的政策优先于操作员配置，而操作员配置又优先于终端用户偏好。

训练期宪章通过在对齐与后训练阶段塑造模型行为来发挥作用。它们在训练时影响模型行为，但部署后难以修改，也无法被 harness 独立审计。与之不同的一条路径，则是把治理规则外置为部署时配置，让 harness 能够读取、验证并执行。

**部署期宪章：基于 YAML 的治理。** AutoHarness 仓库（AIMing Lab, 2026）把治理规则编码进一个 YAML 文件，其中指定了流水线模式（core、standard 或 enhanced）、风险分类模式、允许与拒绝的工具模式、token 预算上限，以及审计日志目标位置。这种声明式风格把治理意图与执行逻辑分离开来。安全团队与合规官等非开发者利益相关者，可以在不触碰智能体代码的情况下审查并修改策略。与训练期宪章不同，YAML 文件可以用标准工具更新、版本化与 diff 审查，因此无需重新训练或重新部署模型，也能完成策略更新。

这两层在运维上有明显区别。训练期宪章修改成本高，而且往往只能通过行为探测去反推；部署期 YAML 则可被直接读取、直接 diff 审查。训练期对齐以概率方式塑造默认行为，也可能被对抗性提示词覆盖（Andriushchenko et al., 2024）；部署期规则则以 harness 检查的形式直接执行。

当策略可以归结为字面触发器时，基于模式的 YAML 规则已经足够；但真实部署通常需要依赖运行时状态的条件逻辑。当前 YAML schema 尚未标准化如下谓词：权限升级、基于主机的出口访问、会话级速率限制，或未来动作。介于自由格式 YAML 与硬编码规则之间的第三个设计点，是结构化策略 DSL。

**可编程策略语言。** Progent 的策略语言（Shi et al., 2025a）支持布尔谓词、量词与环境引用，在保持可读性的同时提供了形式化表达能力。Formal-LLM（Li et al., 2024）更进一步，把计划约束编码为下推自动机。这种形式主义可以表达顺序约束、被禁止的工具组合，以及特定步骤上的强制审批门。VeriSafeAgent（Lee et al., 2025）则把用户意图形式化为一个基于 UI 状态转移的 DSL，在执行之前验证拟议的 GUI 动作是否与用户任务一致。YAML 宪章允许歧义存在，而形式化规格则要求更高的专业能力。

**开放挑战。** 目前还没有一种被广泛采纳的智能体宪章标准 schema。每个 harness 往往都定义自己的 YAML 结构，导致策略缺乏可移植性。用于验证一份宪章是否内部一致（例如不存在互相矛盾的 allow / deny 规则）且足够完备（例如没有未处理的工具类别）的工具，在当前生态中仍然有限。训练期与部署期宪章之间的相互作用尤其缺乏研究。一个 YAML deny 规则是否能够可靠地覆盖 RLHF 已强化出的行为、以及两层在何种条件下会发生冲突，仍然是带有实际安全影响的开放问题。

### 9.5 审计基础设施

治理要求可追责性。审计基础设施记录智能体做了什么、为什么这么做，以及治理策略是否得到了遵守。这些记录支撑事后调查、监管合规与持续策略改进。

**结构化审计轨迹。** AutoHarness（AIMing Lab, 2026）会为每次工具调用输出 JSONL 记录，其中包括工具名、参数、风险分类、权限决策、执行结果、token 成本与墙钟延迟。这类结构化日志既支持实时仪表盘，也支持离线取证分析。SAGA（Syros et al., 2025）则把审计轨迹扩展到多智能体交互，通过记录智能体之间交换的加密 token，使跨信任边界的来源追踪成为可能。综合这些系统，一条可回放的审计记录至少应当包含：轨迹标识符、主体身份、工具调用、策略决策及其版本、执行结果、资源成本，以及与相关输入输出对应的完整性哈希。当前大多数系统只记录了其中一部分字段，而且很少会对记录做签名或哈希，这使得在智能体进程被攻陷后，审计轨迹容易遭到日志篡改。

除了记录本身，要在长时间运行的智能体中检测治理违规，还需要自动化分析。

**异常检测：逐动作与轨迹级。** 检测机制会沿着评估证据的粒度分化。**逐动作检测器**会把每次工具调用单独拿出来，依据某种学习得来或人工规定的风险概念进行分类：输入与输出护栏（§9.2）就属于这一范式，AgentMonitor（Naihin et al., 2023）则提供了一个轻量级的“记录并标记”实现。这一范式便宜、易审计，但无法识别那种分散在多个单步良性动作中的攻击，例如每分钟只读取一个文件的慢速外泄。**轨迹级检测器**则会联合评估动作序列。AgentAuditor（Luo et al., 2025）把行为模式匹配与基于 LLM 的整条轨迹推理结合起来。SentinelAgent（He et al., 2025）把多智能体通信建模为时序图，并标记异常交互模式。轨迹级检测能抓住多步攻击，但代价是更高延迟，以及更难本地化“到底是哪一个动作触发了警报”，从而同时增加实时干预与事后解释的复杂度。在本文调研的系统里，逐动作检查通常以内联方式部署，而轨迹级分析则异步地运行在审计日志之上。

除了安全本身，长时间运行的智能体还需要成本与资源治理。

**成本与资源审计。** 在大量循环的工作负载中，一个失控的智能体循环会很快耗尽 API 预算。2025 年版《OWASP Top 10 for LLMs》（OWASP Foundation, 2025）已经把资源耗尽单列为一种风险类别。AutoHarness（AIMing Lab, 2026）会跟踪每次调用的 token 消耗，并通过其宪章以声明式方式执行会话级预算。在多智能体架构中，成本感知治理尤其重要，因为扇出模式可能让 API 调用数呈指数增长。

**分层治理流水线。** 一个正在出现的设计模式，是把前述机制（权限、钩子、宪章与审计）整合进一条可配置的统一流水线。AutoHarness（AIMing Lab, 2026）就是这种模式的代表，其包含三个层级。这些层级逐步增加更深的检查：从“解析—风险—权限—执行—审计”循环，一路增加上下文增强、输出验证、异常评分、人工升级与形式化约束验证。层级通过 YAML 宪章以声明式方式选定，因此治理开销可以随部署风险缩放。其他生产级实践指南也暴露了类似职责，只是把它们做成特性级控制，而不是命名层级（Shavit et al., 2023）。哪一种抽象更易用，仍是一个开放的经验问题。

**开放挑战。** 在长时间运行的智能体中，审计日志会快速积累，从而带来存储、隐私与信噪比挑战。日志可能包含敏感用户数据、API key 或对话内容，这使审计完整性与数据保护要求之间产生张力。社区也缺乏标准化审计 schema，这使跨系统分析与监管报告都变得困难。

### 9.6 在智能体安全版图中定位治理

治理机制不是抽象地存在；它们回应的是一个具体威胁版图。来自开放式智能体对智能体平台的近期证据（Zhang et al., 2026b; Kim et al., 2026; Chen et al., 2026; Wei et al., 2026）进一步表明，智能体安全并不局限于 prompt injection 或单智能体 jailbreak：社会工程、协同智能体集群、凭证泄露与平台级参与度放大，都会共同塑造威胁版图。Kim et al.（2026）调研了 128 篇论文，并在智能体栈上编目出 51 种攻击方法与 60 种防御方法。Chen et al.（2026）则从软件工程视角做了补充：他们通过一个六维分类法综合了 50 篇论文，并提出了面向“安全即构造（secure-by-construction）”智能体平台的参考教义。我们借助这些框架，把治理机制与具体安全风险连接起来，并识别覆盖缺口。

**从设计维度到治理机制。** Kim et al. 提出了七个设计维度（Input Trust、Access Sensitivity、Workflow、Action、Memory、Tool、User Interface），每一个都代表着一种灵活性光谱。灵活性越大，攻击面越大；而治理的作用，就是在运行时约束这种灵活性。权限模型约束工具与动作；Lifecycle Hooks 在工作流中注入检查点；宪章把约束外置化；审计基础设施则提供反馈回路，揭示约束究竟是过松还是过紧。表 3 把治理机制映射到 Kim et al. 分类法中的七类风险（R1–R7）。

**表 3：** 治理机制与 Kim et al.（2026）风险分类法之间的映射。

| 治理机制      | 缓解或检测的风险                     |
| --------- | ---------------------------- |
| 权限模型与身份管理 | R1（不可信接口）、R5（数据泄露）、R6（未授权动作） |
| 输入护栏      | R1、R2（错误指令跟随）                |
| 输出护栏      | R2、R4（幻觉）、R5、R6              |
| 信息流控制     | R3（无约束数据流）、R5、R6             |
| 组件加固      | R1、R2、R4                     |
| 监控与审计     | R5、R6、R7（资源耗尽）               |
| 人在回路      | R2、R6                        |
| 权限分离      | R2、R3                        |
| 形式化验证     | R2、R6                        |
| 声明式宪章     | 横切项：为上述所有机制配置约束              |

**现实世界智能体中的防御缺口。** Kim et al. 针对六个智能体系统（Codex、Gemini CLI、OpenHands、Browser Use、Nanobrowser、Skyvern）的案例研究显示，没有任何一个智能体完整实现了全部防御类别。信息流控制、身份管理与形式化验证，在所有被调研系统中都缺席。六个案例中，监控都只有部分覆盖：智能体会记录工具调用，但缺少自动异常检测。AutoGPT 的案例研究则表明了后果：下游补丁也许可以修补个别症状，但上游输入验证、信息流控制与策略组合依然没有被充分规定。

**纵深防御及其局限。** Kim et al. 认为，智能体安全需要相互补充的分层防御：输入护栏作为第一道防线，输出护栏作为最后一道防线，信息流控制与监控作为持续性的运行时防护，访问控制承担认证与授权，而人在回路负责关键决策。然而，分层并非没有代价；正如 §9.2 所讨论的那样，协调不佳的多层机制可能相互干扰，而独立治理钩子的可组合性几乎还没有经过系统检验。在这个框架下，治理可以充当编排层，使不同防御机制彼此协作，而不是彼此冲突。

**作为拟议扩展的情境性安全。** Kim et al.（2026）提出了“情境性安全（contextual security）”，将其作为智能体系统中继经典 CIA 三元组（机密性、完整性、可用性）之后的候选第四安全目标。这个提法较新，而且只出现在他们的调研中；它尚未被 NIST 等标准机构或主流安全教材采纳。我们在这里将其视为一种有启发性的框架，而非既定定义。Conseca（Tsai & Bagdasarian, 2025）与 CaMeL（Debenedetti et al., 2025）都说明了一条工程方向：上下文必须被视为受治理的状态，而不是被动的提示词材料。它是否足以被提升为独立安全目标，仍未尘埃落定。

### 9.7 研究方向

表 4 总结了本节所讨论系统中的治理现状。

**表 4：** 治理特性覆盖情况。= 完全支持，= 部分支持，= 缺失。

> 说明：输入源文件中，表 4 的支持度符号以及部分系统行在文本抽取时发生了编码损坏，无法无歧义地还原为标准 Markdown 表格。以下保留原表题注与后续分析结论，并按源文本可辨识内容转写关键信息。

* 表头可辨识为：System / Perm. / Hooks / Harden. / Constit. / Audit / Multi-Ag.
* 可辨识系统包括：Codex、Gemini CLI、OpenHands、AutoHarness、Progent、CaMeL、SAGA、IsolateGPT、AgentSpec、SAFEFLOW。
* 原表呈现出的总体结论是：治理能力覆盖度整体稀疏，且不同系统在权限、钩子、组件加固、宪章、审计与多智能体治理上的支持程度明显不均衡。

表 4 中可见的这种稀疏性表明：与模型能力限制并列，治理基础设施本身往往也是安全部署的现实瓶颈。我们在此识别出八个开放研究方向；这里仅限于治理特定缺口，并与 §12 中更广泛的开放问题互相参照。

1. **标准化的策略与审计语言。** 每个 harness 都定义自己的 YAML schema 与私有 DSL，导致治理生态割裂。一个社区驱动的规范——类似 MCP（Anthropic, 2024c）对工具接口所追求的那种规范——将使可移植策略、可组合治理模块以及跨 harness 审计互操作成为可能。
2. **形式化治理保证。** 当前治理机制在形式化保证方面仍十分有限。对智能体行为的形式化验证本身还处于早期阶段（Li et al., 2024; Chen et al., 2025b; Lee et al., 2025）。将这些技术扩展到验证治理流水线的正确性，例如证明一份宪章在内部一致且覆盖了所有工具类别，仍然缺乏研究。
3. **自适应治理。** 静态策略无法预见每一种任务上下文。Conseca（Tsai & Bagdasarian, 2025）表明，LLM 生成的策略可以弥补这一缺口。机器生成治理规则的可信度，以及这些策略生成器本身又应当如何被治理，仍然是开放问题。

> **译者注：** 输入源文件在此处结束，因此 §9.7 的后续条目未出现在本次可翻译材料中。

## 10 跨领域关注点

本节汇总的关注点横跨多个 ETCLOVG 层，并通过展示分层推理在哪些地方失效来展开主张 1（即“agent harness 是一个独立系统层”，见 §1）。前面的分层章节分别隔离了执行、工具、上下文、生命周期、可观测性、评估与治理，从而可以精确描述每个设计界面。然而，在生产环境中，harness 最常失效的地方，恰恰是这些界面之间的交叉处。

**层间交互模式。** 七个层并非彼此独立。执行环境（E）会限制哪些生命周期与编排策略（L）在实践中可行；上下文管理（C）会影响评估（V）的可复现性；治理（G）则施加跨越其余所有层的身份、权限与审计约束。因此，对 harness 设计的理解不应把它当作一张可拆分组件清单，而应把它视为一种依赖结构。

**成本–质量–速度三难困境。** 更强的评估（V）、更严格的治理（G）、更丰富的可观测性（O）以及更逼真的执行环境（E），通常都会提高成本与延迟。为速度做优化往往会削弱诊断深度或安全裕度；为质量做优化又可能让日常迭代成本过高。真实系统必须决定：哪些检查要同步执行，哪些应离线运行，以及哪些失败值得触发高成本恢复路径。

**标准化与生态系统动态。** MCP、ACP 和 A2A 等协议反映出一种朝向共享接口的压力，即为工具、agent 与编排状态建立通用接口。这种压力有利于构建与具体 agent 无关的基础设施，而不是单 agent 集成；但它也把责任转移到了治理与可观测性层：一个标准化的工具调用，只有在周边 harness 能跨系统保留来源、权限、成本与失败证据时，才真正有用。

**持续存在的生态缺口。** 在整个语料中，五类缺口反复出现在层边界处：跨工具互操作性、成本归因、失败恢复、多仓库编排，以及人类—agent 交接。这些缺口构成了 §11 综合分析的动机：核心问题已不再是每一层是否“都有可用工具”，而是组合后的 harness 能否表现得像一个可靠的控制系统。

## 11 跨层综合分析

本节把 §10 的跨领域关注点收束为五种系统级效应，是主张 1（§1）的核心支撑。目的在于把讨论从“组件覆盖”转向“harness 行为”：一旦把执行、工具、上下文、编排、可观测性、评估与治理组合起来，它们之间的相互作用就会形成任何单层都无法独立解决的约束。

### 11.1 成本–质量–速度三难困境

Harness 的可靠性受到成本、质量与速度三者之间权衡的约束。更强的沙箱和更逼真的环境会提升安全性与可复现性，但也会增加启动延迟与基础设施成本；更丰富的上下文与记忆策略能够提升任务连续性，但会消耗 token 并带来检索开销；更深入的评估与可观测性有助于诊断，却会减慢迭代速度，并增加存储、标注和 Trace 处理成本。因此，生产系统不能把质量当作单一标量目标。它们必须决定：哪些风险值得付出高成本控制，哪些检查可以异步执行或放进回归测试套件，以及在 agent 生命周期的各个阶段，哪些遥测数据值得采集。

### 11.2 能力–控制权衡

能力更强的 harness 会向 agent 暴露更大的权力，但每一次权力提升都会扩大控制问题。更大的工具菜单能拓宽任务覆盖面，却也会增加选择错误与 prompt injection 的攻击面；持久化记忆有助于长时间运行的任务，但会引入来源、陈旧性与隐私风险；更宽松的沙箱使自主执行更有价值，同时也放大了未对齐或被攻陷操作的波及范围。因此，能力–控制权衡并不是附加在一个原本已经可用系统之上的安全“外挂”。它本身就是一条设计轴线，把工具 schema、上下文策略、运行时权限、身份、可审计性与人工审批连接在一起。

### 11.3 Harness 耦合问题

Harness 各层之间存在耦合，使得局部优化变得脆弱。执行环境会通过影响包可用性、重置语义、延迟与失败模式来改变评估结果；工具描述会消耗上下文预算，并塑造模型行为；只有在以同等粒度记录身份与权限状态时，可观测性 Trace 才能成为治理证据；而评估设计又会反过来影响编排，因为它会奖励某些恢复循环、惩罚另一些恢复循环。这些耦合意味着：对 harness 的变更应按“系统变更”来测试。一个 prompt、工具、memory、sandbox、verifier 或 monitor，孤立看可能有益，但与控制回路其余部分组合后，反而可能削弱整体 Rollout。耦合问题也解释了：如果不说明周边控制器，就无法把 agent 分数干净地归因于模型本身。在闭环框架下，对上下文策略、工具 schema、verifier 或恢复循环的改变，会改变控制器 C，从而改变同一模型的测量行为（Bölük, 2026b）。

### 11.4 从 Agent Frameworks 走向 Agent Platforms

生态系统正在从 Agent Framework 走向 Agent Platform。Framework 打包的是本地抽象，例如 agent、工具、memory store 和执行循环；Platform 则进一步提供跨多次运行、面向多用户的持久化工作空间、托管沙箱、身份、计费、可观测性、评估、治理以及人工交接。这一转变之所以重要，是因为长时运行的 agent 不再只是“会调用模型的程序”。它们是需要租户管理、合规、故障恢复、Trace 保留和组织归属的运营系统。因此，核心设计问题也从“我该如何构建一个 agent？”转变为“我该如何运营一整队 agent，并让它们的行为在时间尺度上始终可检查、可逆转？”

### 11.5 开放研究议程

上述跨层综合分析指向一个以 harness 作为自适应控制系统为中心的研究议程。这个领域需要：能够改变 harness 干预项而不只是改变模型权重的基准；能够跨层归因失败的 Trace 原生方法；能够在 agent、工具、沙箱、评估器与人类之间转移状态与责任的协议；以及随着模型能力提升而简化 harness 的优化方法。下一节将把这些跨层效应具体化为五个开放问题：如何加固并扩展执行环境、如何维持可靠状态、如何从 Trace 中诊断失败、如何标准化交接，以及在模型能力变化时如何让 harness 持续保持有用。

## 12 开放问题与未来方向

这里汇总的开放问题，源自主导约束命题与跨层综合分析，也是主张 1（§1）面向未来的证据部分。本节不再把七个 ETCLOVG 层视为彼此独立的组件清单，而是追问：整个 harness 在哪些地方仍缺乏足够清晰的科学刻画。核心模式是，agent harness 正在变成长时运行的控制系统，但这个领域仍然缺少成熟答案，来应对执行基底的加固、状态保持、失败诊断、责任转移，以及随着模型能力变化而更新 harness 的问题。我们把这些缺口组织为五个横跨该分类法的问题。

### 12.1 加固并扩展执行环境

执行环境正变成安全性、可扩展性与可移植性交汇的控制边界。SandboxEscapeBench 表明，前沿模型能够在现实配置下利用沙箱弱点（Marchand et al., 2026），但防御研究仍然分散在采用不同威胁模型与评估协议的系统之中（Wu et al., 2025; Yan, 2025）。与此同时，“每个任务一个容器”的模式在大规模训练与评估中面临压力：当需要并行运行数以万计的轨迹时，系统需要廉价的重置与回放；SWE-World 指向了无需 Docker 的代理环境，但其学习到的状态转移相对真实执行的保真度仍未解决（Sun et al., 2026）。即便是部署可移植性，也不是一个已经彻底解决的工程细节：基于 Docker 的沙箱继承了 Linux 内核假设，而 macOS、Windows、浏览器、桌面与混合云场景，则暴露出不同的隔离与可复现性约束。

开放问题在于，如何让运行时基底同时“可度量”且“可组合”。未来的 harness 需要：面向 prompt injection、目标错配与组合放大的通用安全评估；能够决定何时使用容器、microVM、操作系统级权限边界、完整桌面虚拟机、浏览器环境或学习型代理环境的成本模型；以及能在自托管、云端与混合部署之间保持语义一致的可移植层。将运行时“打包”进 framework（§3.2.4）还是“组合”独立沙箱抽象（§3.2.7），应被视为一个经验性的设计问题，而不是产品偏好。MCP 等标准也许能降低组合成本，但前提是工具、治理与可观测性层暴露出足够多的状态，使运行时选择保持可审计、可恢复且安全。

### 12.2 在长时运行 Agent 中维持可靠状态

最深层的上下文问题，不只是如何把更多 token 塞进 prompt，而是如何在长时间范围内让 agent 的工作状态与真实任务状态保持一致。长时运行的编码、研究与运维 agent 会反复对信息进行摘要、检索、压缩与外化；每一次这样的操作，都可能删除约束、扭曲优先级，或保留陈旧假设。近期的 Context Engineering 工作把压缩、清除工具结果、检索，以及考虑 prompt cache 的排序，当作管理有限上下文窗口的实用机制（Anthropic Applied AI Team, 2025; Anthropic, 2025c; OpenAI, 2026b）。然而，context rot 与 memory 基准表明，更长的输入和更丰富的 memory store，并不自动意味着更好的任务状态跟踪（Hong et al., 2025; Tan et al., 2025; He et al., 2026）。

因此，一个更有原则的研究议程，应把上下文管理重新表述为状态估计问题。开放问题是：我们能否刻画在每一次压缩、检索或遗忘步骤中丢失了多少与任务相关的信息，并且能否为 agent 内部状态与任务真实状态之间的偏离建立上界。近期综述将 agent memory 形式化为“写入—管理—读取”循环，并指出由策略学习得到的管理机制正在成为一个新兴机制家族（Zhang et al., 2025; Du, 2026）。未来系统需要具备：具不确定性感知的摘要、被记住事实的来源信息、矛盾处理机制、显式的新鲜度/陈旧度标记，以及恢复程序，使 agent 能从持久化工件中重建缺失状态，而不是盲目信任其自身压缩后的历史。这也意味着 memory 与 evaluation 之间应建立更紧密的联系：memory 策略的评价标准，不应只看召回准确率，还应看它们是否能够阻止多会话任务中的下游行动错误。

### 12.3 从 Agent Trace 中诊断失败

Agent 评估至今仍过于以最终分数为中心：一次运行通过或失败，最终数字就被当作模型质量的证据。对于 Harness Engineering 而言，这远远不够，因为一次失败的 Rollout 可能源自模型推理、误导性的工具 schema、沙箱配置错误、陈旧上下文、脆弱测试、基准歧义、评判器不稳定，或编排循环。Anthropic 对 agentic coding 评估的分析显示，基础设施设置能够显著改变基准分数（Anthropic, 2026a）；近期关于 agentic eval 随机性的研究则指出，单次运行的通过率可能掩盖大量方差（Bjarnason et al., 2026）。因此，评估层必须被当作一种测量仪器来研究，而不能只把它当作排行榜生成器。

下一步应是以 Trace 为原生对象的评估：Trace 应成为系统计算结果分数、轨迹质量、失败归因与回归测试的主要对象。可观测性系统已经能够捕获 span、工具调用、成本、重试、异常与中间消息（OpenTelemetry, 2026; AlSayyad et al., 2026; Koc et al., 2025），但这些 Trace 往往与评估流水线脱节。LangChain 在 2026 年的调查报告称，89% 的团队使用可观测性，而只有 52.4% 运行离线评估（LangChain, 2026a）；这一差距意味着，团队可以“看见” agent 做了什么，却没有系统性判断这些行为是否正确。未来工作应闭合这个回路：把生产环境中的异常 Trace 转换为回归用例，直接在 span 上计算轨迹指标，并把诊断信号回灌到 prompt、工具、上下文与编排的变更之中。Reflexion 已经表明，在短时任务设定下，agent 可以从自己的 Trace 中学习（Shinn et al., 2023）；如何把这一思想扩展到长时、跨多会话的 harness，仍是开放问题。

### 12.4 在 Agents、工具与人类之间建立标准交接

现代 harness 越来越多地把工作分发给规划器、subagent、工具、沙箱、评估器和人类，但这些参与者之间的接口仍然非常临时化。局部标准已经出现：MCP 标准化了工具访问，A2A 面向 agent 间通信，而 OpenTelemetry 提供了通用的 Trace 基底（Model Context Protocol, 2025b; A2A Project, 2025; OpenTelemetry, 2026; Ehtesham et al., 2025）。缺失的是一种跨层交接契约。当规划器把工作交给执行器、agent 调用工具、subagent 归还控制权，或系统把任务升级给人类时，交接中传递的不应只是文本摘要，还应包括意图、约束、权限、工件、来源、预算状态、风险等级、Trace 历史以及尚未解决的决策。

这个问题一部分是技术性的，另一部分是制度性的。OpenAI 的 Symphony 把 issue tracker 和仓库视为 agent 工作的控制平面，而 Anthropic 的长时运行 agent harness 则强调持久化进度工件与干净的交接状态（Kotliarskyi et al., 2026; Anthropic, 2025d; 2026b）。治理研究从相反方向得出相同结论：在 agent 能够安全地跨系统代表用户行动之前，必须具备 agent 身份、委托、权限清单与可审计性（South et al., 2025; Marro et al., 2025; Syros et al., 2025）。开放问题在于，如何定义既足够丰富、能支持安全与恢复，又足够简单、能被广泛采用的交接协议。这类协议应使责任显式化：是谁授权了该操作、转移了哪些状态、当前计划由哪些证据支持、接收方被允许做什么，以及控制权何时必须返回给另一名 agent 或人类。

### 12.5 随模型进步而保持 Harness 的有效性

不应默认 harness 设计会单调地走向“加更多脚手架”。每一层 wrapper、reset、verifier、planner、memory 规则与权限闸门，都编码了一种关于“模型本身无法可靠完成什么”的假设。随着模型能力变化，harness 干预项也应重新估计，而不是想当然地认为它们始终有益。按“模型 × harness”做析因评估，可以揭示某项干预是对所有模型都有帮助、只帮助特定模型家族，还是会反转模型排名（Bölük, 2026b）。Anthropic 在长时运行应用开发中报告了这一模式的一个具体版本：对某个模型有用的上下文重置，对更强模型变得不再必要，而移除这些重置可以在不降低质量的前提下降低成本（Anthropic, 2026c）。OpenAI 同样把 Harness Engineering 描述为一种“保持人类注意力、仓库状态与 agent 执行对齐”的学科，而不只是不断叠加更多脚手架（OpenAI, 2026a; 2026b）。

这又引出一个元工程议程：harness 需要具备优化并简化自身的机制。Meta-Harness 表明，prompt、工具与控制回路可以成为搜索优化目标的一部分，而不是固定由人工指定（Lee et al., 2026）；Natural-Language Agent Harnesses 则让 harness 模块显式化、可消融（Pan et al., 2026）。诸如 TensorZero、Axon 与 AgentOps 这样的生产级可观测性与成本系统，展示了预算感知 harness 运营的方向（TensorZero, 2026; harshkedia177, 2026; AgentOps AI, 2026），但研究问题并不只是成本最小化。未来系统应能识别哪些干预在因果上对质量、安全或可靠性负责；在不同 harness 变体之间运行影子模式或 A/B 测试；并在质量—延迟—成本—风险的联合约束下进行优化。一个核心风险是基准过拟合：如果 harness 只对一组狭窄基准做自我优化，它可能会变得脆弱。更持久的目标是“自适应简化”：让 harness 持续追问，在任务、工具与模型能力变化时，哪些控制仍然是必要的。

## 13 结论

本综述将 agent harness 视为一个独立的工程界面，并主张：决定真实世界 agent 可靠性上限的，不只是模型能力，更是基础设施质量。围绕这一主导约束命题，我们提出了三项主张。七层 ETCLOVG 分类法把可观测性（Observability）与治理（Governance）从 Lifecycle Hooks 中分离出来，并反映了生产团队现实中组织工具与职责归属的方式。我们把 148+ 个开源项目映射到该分类法上，形成了迄今最广泛的生态系统快照，并由此揭示出采纳模式、覆盖缺口与正在浮现的设计原则。从 Prompt Engineering 到 Context Engineering，再到 Harness Engineering 的三阶段工程演化，再结合对成本–质量–速度三难困境、能力–控制权衡以及 harness 耦合问题的跨层综合分析，本文把 harness 放入了一条更广阔的工程演进轨迹之中。

当然，我们的分析也存在局限。语料偏向英文、GitHub 可见的开源项目，并且偏向编码 agent 生态；如果能扩展到闭源生产系统以及非编码型 agent 生态，经验图景会更完整。该分类法本身也是描述性的：下一步自然工作，是把 ETCLOVG 从“仅用于分类”的框架，发展为能够真正指导 harness 设计决策的规范性框架。我们希望这篇综述能推动这一进展。

## 附录 A：完整生态系统映射

表 S1 给出了本综述使用的技术生态系统映射。该目录快照于 2026-05-08 完成核验，共包含 171 个公开条目、146 个 GitHub 条目、142 个位于项目类别中的 GitHub 条目，以及 0 个失效 URL。对于论文附录，我们只保留技术工件以及七个 ETCLOVG 层：纯阅读材料、生态地图、通用列表仓库，以及仅提供教程或手册的资源均被省略。此前单列为 reference harness implementations 的条目，依据目录标签与摘要并入其主导 ETCLOVG 层；标签与星标快照均直接复制自核验后的快照。附录 B 将进一步展开六个焦点实现。

**表 S1：** 经筛选的技术生态目录；每个工件只映射到一个主 ETCLOVG 层。

### E — 执行与沙箱（20 项；主层：E）

* `Daytona` — 目录标签：`sandbox, execution, infra`；星标：72.4k
* `NanoClaw` — 目录标签：`containers, claude-sdk, scheduling`；星标：28.6k
* `cmux` — 目录标签：`macos, workspace, browser`；星标：16.3k
* `CUA` — 目录标签：`computer-use, sandbox, infra`；星标：15.7k
* `E2B` — 目录标签：`cloud-sandbox, execution, enterprise`；星标：12.1k
* `BrowserHarness` — 目录标签：`browser, cdp, self-healing`；星标：11.0k
* `OpenSandbox` — 目录标签：`sandbox, security, runtime`；星标：10.5k
* `agent-infrasandbox` — 目录标签：`all-in-one, browser, shell`；星标：4.5k
* `Judge0` — 目录标签：`code-execution, sandbox, backend`；星标：4.2k
* `AgentSandbox` — 目录标签：`kubernetes, sandbox, stateful`；星标：2.1k
* `stakpak/agent` — 目录标签：`always-on, autonomous, ops`；星标：1.5k
* `OSS-FuzzGen` — 目录标签：`fuzzing, security, execution`；星标：1.4k
* `E2BDesktopSandbox` — 目录标签：`desktop, sandbox, computer-use`；星标：1.4k
* `Tensorlake` — 目录标签：`microvm, sandbox, orchestration`；星标：911
* `Arrakis` — 目录标签：`sandbox, microvm, snapshots`；星标：808
* `AgentScopeRuntime` — 目录标签：`runtime, sandbox, deployment`；星标：766
* `SWE-ReX` — 目录标签：`sandbox, execution, coding-agent`；星标：490
* `sandboxed.sh` — 目录标签：`self-hosted, isolation, orchestrator`；星标：416
* `Capsule` — 目录标签：`wasm, sandbox, task-runtime`；星标：281
* `terminal-bench-env` — 目录标签：`terminal, benchmark-env, sandbox`；星标：80

### T — 协议、工具接口与 Agent 契约（12 项；主层：T）

* `GitHubSpecKit` — 目录标签：`spec-driven, workflows, tooling`；星标：92.9k
* `MCPServers` — 目录标签：`mcp, servers, implementations`；星标：85.1k
* `CLI-Anything` — 目录标签：`cli, tool-use, automation`；星标：33.7k
* `AGENTS.md` — 目录标签：`spec, agent-file, instructions`；星标：21.0k
* `ModelContextProtocol` — 目录标签：`mcp, protocol, interoperability`；星标：8.0k
* `directories (rules and MCP indexes)` — 目录标签：`directories, mcp, rules`；星标：3.9k
* `LangChainMCPAdapters` — 目录标签：`mcp, adapters, integration`；星标：3.5k
* `MicrosoftMCPServers` — 目录标签：`mcp, enterprise, servers`；星标：3.1k
* `ACPX` — 目录标签：`acp, client, sessions`；星标：2.6k
* `MicrosoftLearnMCP` — 目录标签：`mcp, docs, grounding`；星标：1.6k
* `IBMMCP` — 目录标签：`mcp, clients, tooling`；星标：374
* `AGENT.md` — 目录标签：`standard, agent-file, interoperability`；星标：77

### C — 上下文与工作状态工程（9 项；主层：C）

* `claude-mem` — 目录标签：`memory, context, session`；星标：72.8k
* `SuperClaudeFramework` — 目录标签：`config, personas, workflow`；星标：22.6k
* `planning-with-files` — 目录标签：`planning, skills, persistence`；星标：20.5k
* `Agent Skills for Context Engineering` — 目录标签：`skills, context, production`；星标：15.5k
* `CCPM` — 目录标签：`planning, github-issues, parallel-execution`；星标：8.1k
* `Trellis` — 目录标签：`specs, memory, workflow`；星标：7.2k
* `OSAURUS` — 目录标签：`macos, local-first, memory`；星标：5.2k
* `holaOS` — 目录标签：`long-horizon, desktop, durable-state`；星标：4.9k
* `context-space` — 目录标签：`context, infrastructure, mcp`；星标：809

### L — Harness 架构与编排（47 项；主层：L）

* `OpenCode` — 目录标签：`terminal, coding-agent, subagents`；星标：155.8k
* `ClaudeCode` — 目录标签：`terminal, coding-agent, git-workflows`；星标：120.9k
* `GeminiCLI` — 目录标签：`terminal, coding-agent, mcp`；星标：103.3k
* `CodexCLI` — 目录标签：`terminal, coding-agent, local-execution`；星标：80.4k
* `OpenHands` — 目录标签：`coding-agent, software-engineering, repo`；星标：72.7k
* `DeerFlow` — 目录标签：`long-horizon, memory, subagents`；星标：65.4k
* `AutoGen` — 目录标签：`multi-agent, orchestration, framework`；星标：57.8k
* `OpenManus` — 目录标签：`general-agent, autonomy, workflows`；星标：56.0k
* `pi` — 目录标签：`coding-agent, runtime, monorepo`；星标：46.5k
* `aider` — 目录标签：`terminal, repo-map, testing`；星标：44.4k
* `Agno` — 目录标签：`scale, runtime, management`；星标：39.9k
* `Claude Code Plugins: Orchestration and Automation` — 目录标签：`claude-code, plugins, orchestration`；星标：34.9k
* `LangGraph` — 目录标签：`graph, workflow, runtime`；星标：31.3k
* `SemanticKernel` — 目录标签：`enterprise, orchestration, plugins`；星标：27.8k
* `OpenAI Agents SDK (Python)` — 目录标签：`sdk, handoff, workflows`；星标：25.9k
* `QwenCode` — 目录标签：`terminal, coding-agent, cli`；星标：24.2k
* `deepagents` — 目录标签：`runtime, orchestration, long-running`；星标：22.3k
* `Archon` — 目录标签：`workflow-engine, worktrees, validation`；星标：20.9k
* `Devika` — 目录标签：`assistant, planning, coding`；星标：19.5k
* `Google ADK (Python)` — 目录标签：`toolkit, deployment, evaluation`；星标：19.5k
* `SWE-agent` — 目录标签：`swe, issue-fixing, tooling`；星标：19.1k
* `PydanticAI` — 目录标签：`python, typing, schema`；星标：16.9k
* `Aperant` — 目录标签：`coding-agent, parallel, memory`；星标：14.2k
* `Eigent` — 目录标签：`desktop, cowork, productivity`；星标：13.9k
* `OpenHarness` — 目录标签：`tool-use, memory, multi-agent`；星标：12.0k
* `Superset` — 目录标签：`worktrees, desktop, parallel`；星标：10.4k
* `GitHubCopilotCLI` — 目录标签：`terminal, coding-agent, mcp`；星标：10.4k
* `Hive` — 目录标签：`harness, orchestration, runtime`；星标：10.2k
* `MicrosoftAgentFramework` — 目录标签：`multi-agent, workflows, observability`；星标：10.2k
* `OpenSWE` — 目录标签：`async, coding-agent, swe`；星标：9.7k
* `VoltAgent` — 目录标签：`typescript, platform, runtime`；星标：8.7k
* `mcp-agent` — 目录标签：`mcp, runtime, workflow`；星标：8.3k
* `Yao` — 目录标签：`single-binary, runtime, autonomous`；星标：7.5k
* `Paseo` — 目录标签：`coding-agent, daemon, multi-device`；星标：5.5k
* `1Code` — 目录标签：`coding-agent, orchestration, worktrees`；星标：5.5k
* `CloudflareAgents` — 目录标签：`platform, deployment, runtime`；星标：4.9k
* `HiClaw` — 目录标签：`multi-agent, human-in-the-loop, shared-state`；星标：4.4k
* `mini-swe-agent` — 目录标签：`minimal, swe, coding-agent`；星标：4.2k
* `oh-my-pi` — 目录标签：`terminal, lsp, subagents`；星标：4.0k
* `TinyAGI` — 目录标签：`team-orchestration, autonomous, workflows`；星标：3.6k
* `Devon` — 目录标签：`pair-programming, coding-agent, autonomous`；星标：3.4k
* `OpenClaudeCowork` — 目录标签：`desktop, ui, orchestration`；星标：3.3k
* `DockerAgent` — 目录标签：`docker, runtime, container`；星标：2.9k
* `NeMoAgentToolkit` — 目录标签：`multi-agent, optimization, toolkit`；星标：2.3k
* `Scion` — 目录标签：`multi-agent, containers, orchestration`；星标：1.4k
* `deepagentsjs` — 目录标签：`typescript, langgraph, subagents`；星标：1.2k
* `hankweave` — 目录标签：`long-horizon, runtime, checkpoints`；星标：120

### O — 可观测性与可靠性运营（15 项；主层：O）

* `Langfuse` — 目录标签：`llmops, tracing, metrics`；星标：26.7k
* `MLflow` — 目录标签：`platform, monitoring, evaluation`；星标：25.8k
* `Opik` — 目录标签：`monitoring, eval, tracing`；星标：19.2k
* `RagaAICatalyst` — 目录标签：`agentops, analytics, monitoring`；星标：16.2k
* `TensorZero` — 目录标签：`llmops, gateway, optimization`；星标：11.3k
* `ArizePhoenix` — 目录标签：`observability, tracing, evaluation`；星标：9.5k
* `OpenLLMetry` — 目录标签：`opentelemetry, instrumentation, tracing`；星标：7.1k
* `Helicone` — 目录标签：`monitoring, traffic, production`；星标：5.6k
* `AgentOpsSDK` — 目录标签：`agentops, monitoring, cost`；星标：5.5k
* `Latitude` — 目录标签：`platform, eval, observability`；星标：4.0k
* `Laminar` — 目录标签：`observability, tracing, evals`；星标：2.8k
* `AmazonBedrockAgentCoreSamples` — 目录标签：`aws, runtime, operations`；星标：2.8k
* `claude-code-reverse` — 目录标签：`trace, visualization, debugging`；星标：2.4k
* `OpenInference` — 目录标签：`spec, instrumentation, observability`；星标：953
* `FutureAGI` — 目录标签：`observability, evaluation, guardrails`；星标：843

### V — 评估 Harness 与基准（21 项；主层：V）

* `Promptfoo` — 目录标签：`eval, red-team, ci`；星标：20.9k
* `DeepEval` — 目录标签：`evaluation, framework, testing`；星标：15.2k
* `RAGAS` — 目录标签：`rag, metrics, evaluation`；星标：13.8k
* `lm-evaluation-harness` — 目录标签：`benchmark, harness, llm`；星标：12.4k
* `SWE-bench` — 目录标签：`benchmark, swe, evaluation`；星标：4.9k
* `verifiers` — 目录标签：`verifier, rl, evaluation`；星标：4.1k
* `AgentBench` — 目录标签：`benchmark, cross-domain, agent`；星标：3.4k
* `LangWatch` — 目录标签：`simulation, evaluation, testing`；星标：3.2k
* `EvalScope` — 目录标签：`benchmark, framework, llm`；星标：2.8k
* `Terminal-Bench` — 目录标签：`terminal, benchmark, long-horizon`；星标：2.2k
* `Harbor` — 目录标签：`evaluation, harness, rl-env`；星标：1.8k
* `tau2-bench` — 目录标签：`tool-use, interaction, Benchmark`；星标：1.1k
* `NeMoGym` — 目录标签：`rl-env, training, evaluation`；星标：872
* `TheAgentCompany` — 目录标签：`benchmark, workplace, multi-step`；星标：697
* `auto-harness` — 目录标签：`optimization, regression, evals`；星标：486
* `InspectEvals` — 目录标签：`inspect, eval-suite, reproducibility`；星标：480
* `SWE-BenchPro` — 目录标签：`swe, benchmark, long-horizon`；星标：371
* `AgentEvaluation` — 目录标签：`evaluation, testing, ci`；星标：360
* `WorkArena` — 目录标签：`browser, benchmark, enterprise`；星标：245
* `OpenHandsBenchmarks` — 目录标签：`openhands, eval, harness`；星标：77
* `WebArena-Verified` — 目录标签：`web-agent, benchmark, deterministic`；星标：38

### G — 护栏、安全与治理（14 项；主层：G）

* `LiteLLM` — 目录标签：`gateway, proxy, guardrails`；星标：45.9k
* `Kong` — 目录标签：`gateway, policy, infra`；星标：43.3k
* `IronClaw` — 目录标签：`security, wasm, routines`；星标：12.1k
* `PortkeyGateway` — 目录标签：`gateway, guardrails, routing`；星标：11.6k
* `CAI (Cybersecurity AI)` — 目录标签：`security, governance, framework`；星标：8.4k
* `OpenAIRealtimeAgents` — 目录标签：`realtime, orchestration, control`；星标：6.8k
* `Plano` — 目录标签：`proxy, safety, data-plane`；星标：6.4k
* `OpenAICSAgentsDemo` — 目录标签：`demo, handoffs, governance`；星标：6.3k
* `ContextForge` — 目录标签：`gateway, governance, observability`；星标：3.7k
* `Archestra` — 目录标签：`enterprise, guardrails, governance`；星标：3.6k
* `Tracecat` — 目录标签：`security, automation, policy`；星标：3.6k
* `AgentGateway` — 目录标签：`gateway, mcp, proxy`；星标：2.6k
* `Haft` — 目录标签：`governance, decisions, mcp`；星标：1.3k
* `mini-coding-agent` — 目录标签：`coding-agent, minimal, approvals`；星标：807

## 附录 B：参考 Harness 实现

表 S2 对六个用于深入比较的 reference harness implementations 进行画像。各条目聚焦于机制，而不是产品营销式摘要：每一行仅列出能被所引用的公开仓库、论文、文档或工程说明直接支持的主张。

**表 S2：** 参考 harness 实现及其文档化的 ETCLOVG 覆盖范围。

| 实现与来源                                                               | Harness 模式与层覆盖                                                                               | 对本综述的设计启示                                                                                         |
| ------------------------------------------------------------------- | -------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------- |
| Claude Code（Anthropic, 2025a; 2025b）                                | <p>终端式编码 agent；单 agent 的编辑、调试与 Git 循环。<br>层：E, T, C, L, G</p>                                | 它是高自治本地编码工作流的生产级参考实现：代码库感知 prompting、shell / tool 执行、仓库编辑以及显式权限或沙箱边界，都被视为 harness 的职责。            |
| OpenCode（Anomaly, 2025）                                             | <p>开源终端编码 agent，具备 plan / build 角色、subagent、LSP 支持以及 client-server runtime。<br>层：E, T, L</p> | 它是现代终端 agent 循环的一个可检查实现，尤其适合观察仓库中显式暴露的角色分离、subagent 委派，以及编辑器 / 工具集成。                              |
| Codex CLI（OpenAI, 2025; 2026b; 2026a）                               | <p>原生终端编码 agent；偏向无状态回放的单 agent 循环。<br>层：E, T, L, V</p>                                      | OpenAI 关于 Codex 的写作把 harness 循环说得很明确：结构化 prompt、工具调用回放、本地命令执行，以及测试或验证反馈，是共同决定 agent 行为的因素。        |
| OpenHands（Wang et al., 2025b; 2025c）                                | <p>开源软件工程 agent 平台与可组合 agent SDK。<br>层：E, T, C, L, V</p>                                     | 它是面向仓库级软件 agent 的研究级参考：平台把沙箱执行、shell / browser / tool 交互、任务状态以及面向基准的评估耦合进同一个开放系统。                 |
| SWE-agent（Yang et al., 2024; SWE-agent, 2025; SWE-agent Team, 2024） | <p>以 agent-computer interface 为中心的问题修复型编码 agent，并配备 SWE-ReX 执行后端。<br>层：E, T, L, V</p>        | 其关键贡献在于把 agent-computer interface 本身前置为研究对象：命令、观察、编辑动作、测试以及远程执行后端，都是被测量的 harness 的组成部分，而非偶然的基础设施。 |
| Symphony（OpenAI, 2026b; Kotliarskyi et al., 2026）                   | <p>面向 issue-to-execution 控制的 Codex 编排规范与任务运行器工作流。<br>层：C, L, V, G</p>                        | Symphony 把 issue tracker 与任务运行器视为持久控制平面，使分配、进度状态、验证闸门与 review 边界成为 Codex 编排中显式的一部分。               |

### 译稿说明

* 本译稿保留原论文的章节编号、图表标题与核心术语。
* Appendix A 中的超宽表已按分组条目方式保留；Appendix B 保留为表格。
* 参考文献按用户要求省略。


# 二分查找

#### 704. 二分查找

<https://leetcode.cn/problems/binary-search>

给定一个 n 个元素有序的（升序）整型数组 nums 和一个目标值 target ，写一个函数搜索 nums 中的 target，如果目标值存在返回下标，否则返回 -1。

```cpp
class Solution {
public:
    int search(vector<int>& nums, int target) {
        int left = 0;
        int right = nums.size() - 1;
        while(left < right){
            int mid = left + right >> 1;
            if(nums[mid] >= target) right = mid;
            else left = mid + 1;
        }
        if(nums[right] != target) return -1;
        return right;
    }
};
```

板子题，注意判断 `nums[right] != target` 的情况

#### 278. 第一个错误的版本

<https://leetcode.cn/problems/first-bad-version>

你是产品经理，目前正在带领一个团队开发新的产品。不幸的是，你的产品的最新版本没有通过质量检测。由于每个版本都是基于之前的版本开发的，所以错误的版本之后的所有版本都是错的。

假设你有 n 个版本 \[1, 2, ..., n]，你想找出导致之后所有版本出错的第一个错误的版本。

你可以通过调用 bool isBadVersion(version) 接口来判断版本号 version 是否在单元测试中出错。实现一个函数来查找第一个错误的版本。你应该尽量减少对调用 API 的次数。

```cpp
class Solution {
public:
    long firstBadVersion(long n) {
        long left = 1;
        long right = n;
        while(left < right){
            long mid = (left + right) >> 1;
            if(isBadVersion(mid)) right = mid;
            else left = mid + 1;
        }
        return right;
    }
};
```

与板子题基本类似，只不过左右区间以及判断条件有所不同。

#### 35. 搜索插入位置

<https://leetcode.cn/problems/search-insert-position>

给定一个排序数组和一个目标值，在数组中找到目标值，并返回其索引。如果目标值不存在于数组中，返回它将会被按顺序插入的位置。

```cpp
class Solution {
public:
    int searchInsert(vector<int>& nums, int target) {
        int left = 0;
        int right = nums.size();
        while(left < right){
            int mid = left + right >> 1;
            if(nums[mid] >= target) right = mid;
            else left = mid + 1;
        }
        return right;
    }
};
```

需要注意的是 `right = nums.size()` 并不减一，因为有可能插入的位置会在最后一个。

#### 34. 在排序数组中查找元素的第一个和最后一个位置

<https://leetcode.cn/problems/find-first-and-last-position-of-element-in-sorted-array/>

给你一个按照非递减顺序排列的整数数组 nums，和一个目标值 target。请你找出给定目标值在数组中的开始位置和结束位置。

如果数组中不存在目标值 target，返回 \[-1, -1]。

```cpp
class Solution {
public:
    vector<int> searchRange(vector<int>& nums, int target) {
        if(nums.empty()) return {-1, -1};
        int left = 0, right = nums.size() - 1;
        int i, j;
        while(left < right){
            int mid = left + right >> 1;
            if(nums[mid] >= target) right = mid;
            else left = mid + 1;
        }
        if(nums[right] != target) return {-1, -1};
        i = right;

        left = 0, right = nums.size() - 1;
        while(left < right){
            int mid = left + right + 1 >> 1;
            if(nums[mid] <= target) left = mid;
            else right = mid - 1;
        }
        j = left;

        return {i, j};
    }
};
```

和一般都二分区别不大，只是左右两种情况都要走一遍。

#### 69. x 的平方根

<https://leetcode.cn/problems/sqrtx/>

给你一个非负整数 x ，计算并返回 x 的 算术平方根 。

由于返回类型是整数，结果只保留 整数部分 ，小数部分将被 舍去 。

注意：不允许使用任何内置指数函数和算符，例如 pow(x, 0.5) 或者 x \*\* 0.5 。

```cpp
class Solution {
public:
    int mySqrt(int x) {
        int l = 0, r = x;
        while(l < r){
            int mid = l + 1ll + r >> 1;
            if(mid <= x / mid) l = mid;
            else r = mid - 1;
        }
        return r;
    }
};
```

有两处地方都有可能溢出，取了个巧。

#### 367. 有效的完全平方数

<https://leetcode.cn/problems/valid-perfect-square/>

给定一个 **正整数** `num` ，编写一个函数，如果 `num` 是一个完全平方数，则返回 `true` ，否则返回 `false` 。

```cpp
class Solution {
public:
    bool isPerfectSquare(int num) {
        int left = 0, right = num;
        while(left < right){
            int mid = left + 1ll + right >> 1;
            if(mid <= num / mid) left = mid;
            else right = mid - 1;
        }
        if (right * right != num) return false;
        return true;
    }
};
```

和上题一样，注意溢出。

#### 154. 寻找旋转排序数组中的最小值 II

<https://leetcode.cn/problems/find-minimum-in-rotated-sorted-array-ii/>

已知一个长度为 n 的数组，预先按照升序排列，经由 1 到 n 次 旋转 后，得到输入数组。例如，原数组 nums = \[0,1,4,4,5,6,7] 在变化后可能得到： 若旋转 4 次，则可以得到 \[4,5,6,7,0,1,4] 若旋转 7 次，则可以得到 \[0,1,4,4,5,6,7] 注意，数组 \[a\[0], a\[1], a\[2], ..., a\[n-1]] 旋转一次 的结果为数组 \[a\[n-1], a\[0], a\[1], a\[2], ..., a\[n-2]] 。

给你一个可能存在 重复 元素值的数组 nums ，它原来是一个升序排列的数组，并按上述情形进行了多次旋转。请你找出并返回数组中的 最小元素 。

你必须尽可能减少整个过程的操作步骤。

```cpp
class Solution {
public:
    int findMin(vector<int>& nums) {
        int l = 0, r = nums.size() - 1;
        while(l < r) {
            int mid = (l + r) / 2;
            if(nums[mid] > nums[r]) l = mid + 1;
            else if(nums[mid] < nums[r]) r = mid;
            else r--;
        }
        return nums[r];
    }
};
```

这道题需要用到二分，当 nums\[m] > nums\[j] 时： mm 一定在 左排序数组 中，即旋转点 xx 一定在 \[m + 1, j] 闭区间内，因此执行 i = m + 1； 当 nums\[m] < nums\[j] 时： mm 一定在 右排序数组 中，即旋转点 xx 一定在\[i, m] 闭区间内，因此执行 j = m； 当 nums\[m] = nums\[j] 时： 无法判断 mm 在哪个排序数组中，即无法判断旋转点 xx 在 \[i, m] 还是 \[m + 1, j] 区间中。解决方案： 执行 j = j - 1 缩小判断范围。

#### 153. 寻找旋转排序数组中的最小值

<https://leetcode.cn/problems/find-minimum-in-rotated-sorted-array/>

已知一个长度为 n 的数组，预先按照升序排列，经由 1 到 n 次 旋转 后，得到输入数组。例如，原数组 nums = \[0,1,2,4,5,6,7] 在变化后可能得到： 若旋转 4 次，则可以得到 \[4,5,6,7,0,1,2] 若旋转 7 次，则可以得到 \[0,1,2,4,5,6,7] 注意，数组 \[a\[0], a\[1], a\[2], ..., a\[n-1]] 旋转一次 的结果为数组 \[a\[n-1], a\[0], a\[1], a\[2], ..., a\[n-2]] 。

给你一个元素值 互不相同 的数组 nums ，它原来是一个升序排列的数组，并按上述情形进行了多次旋转。请你找出并返回数组中的 最小元素 。

你必须设计一个时间复杂度为  O(log n) 的算法解决此问题。

```cpp
class Solution {
public:
    int findMin(vector<int>& nums) {
        int l = 0, r = nums.size() - 1;
        while(l < r) {
            int mid = (l + r) / 2;
            if(nums[mid] > nums[r]) l = mid + 1;
            else if(nums[mid] <= nums[r]) r = mid;
        }
        return nums[r];
    }
};
```

与上一题类似，不过少一个条件判断。

#### 33. 搜索旋转排序数组

<https://leetcode.cn/problems/search-in-rotated-sorted-array/>

整数数组 nums 按升序排列，数组中的值 互不相同 。

在传递给函数之前，nums 在预先未知的某个下标 k（0 <= k < nums.length）上进行了 旋转，使数组变为 \[nums\[k], nums\[k+1], ..., nums\[n-1], nums\[0], nums\[1], ..., nums\[k-1]]（下标 从 0 开始 计数）。例如， \[0,1,2,4,5,6,7] 在下标 3 处经旋转后可能变为  \[4,5,6,7,0,1,2] 。

给你 旋转后 的数组 nums 和一个整数 target ，如果 nums 中存在这个目标值 target ，则返回它的下标，否则返回  -1 。

你必须设计一个时间复杂度为 O(log n) 的算法解决此问题。

```cpp
class Solution {
public:
    int search(vector<int>& nums, int target) {
        if(nums.empty()) return -1;
        int left = 0, right = nums.size() - 1;
        while(left < right) {
            int mid = left + right + 1 >> 1;
            if(nums[mid] >= nums[0]) left = mid;
            else right = mid - 1;
        }
        if(target >= nums[0]) left = 0;
        else left += 1, right = nums.size() - 1;
        while(left < right) {
            int mid = left + right >> 1;
            if(nums[mid] >= target) right = mid;
            else left = mid + 1;
        }
        if(nums[right] == target) return right;
        return -1;
    }
};
```

先二分找出分界点，再二分找答案。

#### 162. 寻找峰值

<https://leetcode.cn/problems/find-peak-element/>

峰值元素是指其值严格大于左右相邻值的元素。

给你一个整数数组  nums，找到峰值元素并返回其索引。数组可能包含多个峰值，在这种情况下，返回 任何一个峰值 所在位置即可。

你可以假设  nums\[-1] = nums\[n] = -∞ 。

你必须实现时间复杂度为 O(log n) 的算法来解决此问题。

```cpp
class Solution {
public:
    int findPeakElement(vector<int>& nums) {
        int l = 0, r = nums.size() - 1;
        while(l < r) {
            int mid = l + r >> 1;
            if(nums[mid] > nums[mid + 1]) r = mid;
            else l = mid + 1;
        }
        return r;
    }
};
```


# 动态规划

#### 53. 最大子数组和

<https://leetcode.cn/problems/maximum-subarray/>

给你一个整数数组 `nums` ，请你找出一个具有最大和的连续子数组（子数组最少包含一个元素），返回其最大和。子数组是数组中的一个连续部分。

```cpp
class Solution {
public:
    int maxSubArray(vector<int>& nums) {
        int res = INT_MIN;
        for(int i = 0, last = 0; i < nums.size(); i++){
            last = nums[i] + max(0, last);
            res = max(res, last);
        }
        return res;
    }
};
```

我们自己想象一个 f\[i]，f\[i] 为所有以下标 i 结尾的数组之和。在 f\[i] 中又可以分成有一个数的和有两个数的，有一个数的则就是它本身。我们再看有两个或更多数的，这些数组都含有 i ，所以这些数组可以转化成 `f[i - 1] + nums[i]`。我们将一个数和多个数的进行比较即得 `max = （f[i - 1] + nums[i], nums[i])`，将 nums\[i] 提出来即得 `res = max(f[i - 1], 0) + nums[i]`。

#### 121. 买卖股票的最佳时机

给定一个数组 prices ，它的第 i 个元素 prices\[i] 表示一支给定股票第 i 天的价格。

你只能选择 某一天 买入这只股票，并选择在 未来的某一个不同的日子 卖出该股票。设计一个算法来计算你所能获取的最大利润。

返回你可以从这笔交易中获取的最大利润。如果你不能获取任何利润，返回 0 。

```cpp
class Solution {
public:
    int maxProfit(vector<int>& prices) {
        int fit = 0;
        int minpre = INT_MAX;
        for(int i = 0; i < prices.size(); i++){
            fit = max(fit, prices[i] - minpre);
            minpre = min(minpre, prices[i]);
        }
        return fit;
    }
};
```

我们从第一天开始遍历，然后通过 minpre 来记录到达第 i 天时，之前的股票最低价，然后每次都用当天的价格减去最低价查看是否是最大的利润即可。

```cpp
class Solution {
public:
    int maxProfit(vector<int>& prices) {
        vector<vector<int>> dp(prices.size(), vector<int>(2));
        for(int i = 0; i < prices.size(); i++) {
            if(i == 0) {
                dp[i][0] = 0;
                dp[i][1] = -prices[i];
                continue;
            }
            dp[i][0] = max(dp[i - 1][0], dp[i - 1][1] + prices[i]);
            dp[i][1] = max(dp[i - 1][1], - prices[i]);
        }
        return dp[prices.size() - 1][0];
    }
};
```

用模板来写，更具普遍性。

#### 118. 杨辉三角

<https://leetcode.cn/problems/pascals-triangle/>

给定一个非负整数 \*`numRows`，\*生成「杨辉三角」的前 *`numRows`* 行。

在「杨辉三角」中，每个数是它左上方和右上方的数的和。

```cpp
class Solution {
public:
    vector<vector<int>> generate(int numRows) {
        vector<vector<int>> res;
        for(int i = 0; i < numRows; i++){
            vector<int> l(i + 1);
            l[0] = l[i] = 1;
            for(int j = 1; j < i; j++){
                l[j] = res[i - 1][j - 1] + res[i - 1][j];
            }
            res.push_back(l);
        }
        return res;
    }
};
```

模拟就完事了。

#### 70. 爬楼梯

<https://leetcode.cn/problems/climbing-stairs/>

假设你正在爬楼梯。需要 `n` 阶你才能到达楼顶。

每次你可以爬 `1` 或 `2` 个台阶。你有多少种不同的方法可以爬到楼顶呢？

```cpp
class Solution {
public:
    int climbStairs(int n) {
        int a = 1, b = 1;
        while(-- n){
            int c = a + b;
            a = b, b = c;
        }
        return b;
    }
};
```

小学奥数题，找规律就完事了。

#### 198. 打家劫舍

<https://leetcode.cn/problems/house-robber/>

你是一个专业的小偷，计划偷窃沿街的房屋。每间房内都藏有一定的现金，影响你偷窃的唯一制约因素就是相邻的房屋装有相互连通的防盗系统，如果两间相邻的房屋在同一晚上被小偷闯入，系统会自动报警。

给定一个代表每个房屋存放金额的非负整数数组，计算你 不触动警报装置的情况下 ，一夜之内能够偷窃到的最高金额。

```cpp
class Solution {
public:
    int rob(vector<int>& nums) {
        int n = nums.size();
        vector<int> f(n + 1), g(n + 1);
        // 房屋下标从 1 开始
        for(int i = 1; i <= n; i++){
            f[i] = g[i - 1] + nums[i - 1];
            g[i] = max(f[i - 1], g[i - 1]);
        }
        return max(f[n], g[n]);
    }
};
```

先分析状态表示，`f[i]` 为必选 i 的情况，`g[i]` 是必不选 i 的情况。通过状态分析可得，`f[i] = g[i - 1] + w[j]` ，`g[i] = max(f[i - 1], g[i - 1])` ，取最大即可。

#### 120. 三角形最小路径和

给定一个三角形 triangle ，找出自顶向下的最小路径和。

每一步只能移动到下一行中相邻的结点上。相邻的结点 在这里指的是 下标 与 上一层结点下标 相同或者等于 上一层结点下标 + 1 的两个结点。也就是说，如果正位于当前行的下标 i ，那么下一步可以移动到下一行的下标 i 或 i + 1 。

```cpp
class Solution {
public:
    int minimumTotal(vector<vector<int>>& triangle) {
        for(int i = triangle.size() - 2; i >= 0; i--){
            for(int j = 0; j <= i; j++){
                triangle[i][j] += min(triangle[i + 1][j], triangle[i + 1][j + 1]);
            }
        }
        return triangle[0][0];
    }
};
```

需要注意的是最后一行不要算，所以 `i = triangle.size()` 。

#### 509. 斐波那契数

<https://leetcode.cn/problems/fibonacci-number/>

斐波那契数 （通常用 F(n) 表示）形成的序列称为 斐波那契数列 。该数列由 0 和 1 开始，后面的每一项数字都是前面两项数字的和

```cpp
class Solution {
public:
    int fib(int n) {
        if(n == 0 || n == 1) return n;
        int dp_a = 0, dp_b = 1;
        for(int i = 2; i <= n; i++) {
            int dp_i = dp_a + dp_b;
            dp_a = dp_b;
            dp_b = dp_i;
        }
        return dp_b;
    }
};
```

此题用到了动态规划的思想，用到了状态转移方程，在最后使用滚动更新减少空间复杂度。

#### 322. 零钱兑换

<https://leetcode.cn/problems/coin-change/>

给你一个整数数组 coins ，表示不同面额的硬币；以及一个整数 amount ，表示总金额。

计算并返回可以凑成总金额所需的 最少的硬币个数 。如果没有任何一种硬币组合能组成总金额，返回  -1 。

你可以认为每种硬币的数量是无限的。

```cpp
class Solution {
public:
    int coinChange(vector<int>& coins, int amount) {
        vector<int> dp(amount + 1, amount + 1);
        dp[0] = 0;
        for(int i = 0; i < dp.size(); i++) {
            for(int coin : coins) {
                if(i - coin < 0) continue;
                dp[i] = min(dp[i], 1 + dp[i - coin]);
            }
        }
        return dp[amount] == amount + 1 ? -1 : dp[amount];
    }
};
```

目标金额作为变量。不过 dp 函数体现在函数参数，而 dp 数组体现在数组索引

dp 数组的定义：当目标金额为 i 时，至少需要 dp\[i] 枚硬币凑出。

#### 122. 买卖股票的最佳时机 II

<https://leetcode.cn/problems/best-time-to-buy-and-sell-stock-ii/>

给你一个整数数组 prices ，其中  prices\[i] 表示某支股票第 i 天的价格。

在每一天，你可以决定是否购买和/或出售股票。你在任何时候   最多   只能持有 一股 股票。你也可以先购买，然后在 同一天 出售。

返回 你能获得的 最大 利润  。

```cpp
class Solution {
public:
    int maxProfit(vector<int>& prices) {
        vector<vector<int>> dp(prices.size(), vector<int>(2));
        for(int i = 0; i < prices.size(); i++) {
            if(i == 0) {
                dp[i][0] = 0;
                dp[i][1] = -prices[i];
                continue;
            }
            dp[i][0] = max(dp[i - 1][0], dp[i - 1][1] + prices[i]);
            dp[i][1] = max(dp[i - 1][1], dp[i - 1][0] - prices[i]);
        }
        return dp[prices.size() - 1][0];
    }
};
```

套股票问题的框架即可。

```cpp
class Solution {
public:
    int maxProfit(vector<int>& prices) {
        int dp_i_0 = 0, dp_i_1 = INT_MIN;
        for(int i = 0; i < prices.size(); i++) {
            dp_i_0 = max(dp_i_0, dp_i_1 + prices[i]);
            dp_i_1 = max(dp_i_1, dp_i_0 - prices[i]);
        }
        return dp_i_0;
    }
};
```

空间复杂度优化版本。

#### 309. 最佳买卖股票时机含冷冻期

<https://leetcode.cn/problems/best-time-to-buy-and-sell-stock-with-cooldown/>

给定一个整数数组 prices，其中第   prices\[i]  表示第  i  天的股票价格 。​

设计一个算法计算出最大利润。在满足以下约束条件下，你可以尽可能地完成更多的交易（多次买卖一支股票）:

卖出股票后，你无法在第二天买入股票 (即冷冻期为 1 天)。 注意：你不能同时参与多笔交易（你必须在再次购买前出售掉之前的股票）。

```cpp
class Solution {
public:
    int maxProfit(vector<int>& prices) {
        vector<vector<int>> dp(prices.size(), vector<int>(2));
        for(int i = 0; i < prices.size(); i++) {
            if(i == 0) {
                dp[i][0] = 0;
                dp[i][1] = -prices[i];
                continue;
            }
            if(i == 1) {
                dp[i][0] = max(dp[i - 1][0], dp[i - 1][1] + prices[i]);
                dp[i][1] = max(dp[i - 1][1], -prices[i]);
                continue;
            }
            dp[i][0] = max(dp[i - 1][0], dp[i - 1][1] + prices[i]);
            dp[i][1] = max(dp[i - 1][1], dp[i - 2][0] -prices[i]);
        }
        return dp[prices.size() - 1][0];
    }
};
```

需要注意的是不能再第二天买入股票，因此在买入时应该吧 `i - 1` 改为 `i - 2`。

#### 714. 买卖股票的最佳时机含手续费

<https://leetcode.cn/problems/best-time-to-buy-and-sell-stock-with-transaction-fee/>

给定一个整数数组  prices，其中 prices\[i]表示第  i  天的股票价格 ；整数  fee 代表了交易股票的手续费用。

你可以无限次地完成交易，但是你每笔交易都需要付手续费。如果你已经购买了一个股票，在卖出它之前你就不能再继续购买股票了。

返回获得利润的最大值。

注意：这里的一笔交易指买入持有并卖出股票的整个过程，每笔交易你只需要为支付一次手续费。

```cpp
class Solution {
public:
    int maxProfit(vector<int>& prices, int fee) {
        vector<vector<int>> dp(prices.size(), vector<int>(2));
        for(int i = 0; i < prices.size(); i++) {
            if(i == 0) {
                dp[i][0] = 0;
                dp[i][1] = -prices[i] - fee;
                continue;
            }
            dp[i][0] = max(dp[i - 1][0], dp[i - 1][1] + prices[i]);
            dp[i][1] = max(dp[i - 1][1], dp[i - 1][0] - prices[i] - fee);
        }
        return dp[prices.size() - 1][0];
    }
};
```

需要考虑每次手续费的问题，在购买时扣除最方便。

#### 123. 买卖股票的最佳时机 III

<https://leetcode.cn/problems/best-time-to-buy-and-sell-stock-iii/>

给定一个数组，它的第 i 个元素是一支给定的股票在第 i 天的价格。

设计一个算法来计算你所能获取的最大利润。你最多可以完成   两笔   交易。

注意：你不能同时参与多笔交易（你必须在再次购买前出售掉之前的股票）。

```cpp
class Solution {
public:
    int maxProfit(vector<int>& prices) {
        int max_k = 2;
        vector<vector<vector<int>>> dp(prices.size(), vector<vector<int>>(max_k + 1, vector<int>(2)));
        for(int i = 0; i < prices.size(); i++) {
            for(int k = max_k; k > 0; k--) {
                if(i == 0) {
                    dp[i][k][0] = 0;
                    dp[i][k][1] = -prices[i];
                    continue;
                }
                dp[i][k][0] = max(dp[i - 1][k][0], dp[i - 1][k][1] + prices[i]);
                dp[i][k][1] = max(dp[i - 1][k][1], dp[i - 1][k - 1][0] - prices[i]);
            }
        }
        return dp[prices.size() - 1][max_k][0];
    }
};
```

开始考虑 k 的值，需要多一层。

#### 188. 买卖股票的最佳时机 IV

<https://leetcode.cn/problems/best-time-to-buy-and-sell-stock-iv/>

给定一个整数数组  prices ，它的第 i 个元素  prices\[i] 是一支给定的股票在第 i 天的价格。

设计一个算法来计算你所能获取的最大利润。你最多可以完成 k 笔交易。

注意：你不能同时参与多笔交易（你必须在再次购买前出售掉之前的股票）。

```cpp
class Solution {
public:
    int maxProfit(int max_k, vector<int>& prices) {
        vector<vector<vector<int>>> dp(prices.size(), vector<vector<int>>(max_k + 1, vector<int>(2)));
        if(max_k > prices.size() / 2) {
            vector<vector<int>> dp2(prices.size(), vector<int>(2));
            for(int i = 0; i < prices.size(); i++) {
                if(i == 0) {
                    dp2[i][0] = 0;
                    dp2[i][1] = -prices[i];
                    continue;
                }
                dp2[i][0] = max(dp2[i - 1][0], dp2[i - 1][1] + prices[i]);
                dp2[i][1] = max(dp2[i - 1][1], dp2[i - 1][0] - prices[i]);
            }
            return dp2[prices.size() - 1][0];
        }
        for(int i = 0; i < prices.size(); i++) {
            for(int k = max_k; k > 0; k--) {
                if(i == 0) {
                    dp[i][k][0] = 0;
                    dp[i][k][1] = -prices[i];
                    continue;
                }
                dp[i][k][0] = max(dp[i - 1][k][0], dp[i - 1][k][1] + prices[i]);
                dp[i][k][1] = max(dp[i - 1][k][1], dp[i - 1][k - 1][0] - prices[i]);
            }
        }
        return dp[prices.size() - 1][max_k][0];
    }
};
```

最复杂的情况，看似和上一题差不多，只改了 `max_k` 的大小，实际上由于 `max_k` 过大会导致超内存的情况，所以当 `max_k > prices.size() / 2` 的情况，也就是可以判断为 k 无穷大的情况时，直接将代码优化为 k 为无穷大的情况即可。

#### 198. 打家劫舍

<https://leetcode.cn/problems/house-robber/>

你是一个专业的小偷，计划偷窃沿街的房屋。每间房内都藏有一定的现金，影响你偷窃的唯一制约因素就是相邻的房屋装有相互连通的防盗系统，如果两间相邻的房屋在同一晚上被小偷闯入，系统会自动报警。

给定一个代表每个房屋存放金额的非负整数数组，计算你 不触动警报装置的情况下 ，一夜之内能够偷窃到的最高金额。

```cpp
class Solution {
public:
    int rob(vector<int>& nums) {
        vector<int> dp(nums.size() + 2);
        for(int i = nums.size() - 1; i >= 0; i--) {
            dp[i] = max(dp[i + 1], dp[i + 2] + nums[i]);
        }
        return dp[0];
    }
};
```

经典问题，找到状态转移方程即可。

#### 213. 打家劫舍 II

<https://leetcode.cn/problems/house-robber-ii/>

你是一个专业的小偷，计划偷窃沿街的房屋，每间房内都藏有一定的现金。这个地方所有的房屋都 围成一圈 ，这意味着第一个房屋和最后一个房屋是紧挨着的。同时，相邻的房屋装有相互连通的防盗系统，如果两间相邻的房屋在同一晚上被小偷闯入，系统会自动报警 。

给定一个代表每个房屋存放金额的非负整数数组，计算你 在不触动警报装置的情况下 ，今晚能够偷窃到的最高金额。

```cpp
class Solution {
public:
    int rob(vector<int>& nums) {
        if(nums.size() == 1) return nums[0];
        return max(dp(nums, 0, nums.size() - 2), dp(nums, 1, nums.size() - 1));
    }

    int dp(vector<int>& nums, int st, int ed) {
        vector<int> res(nums.size() + 2);
        for(int i = ed; i >= st; i--) {
            res[i] = max(res[i + 1], res[i + 2] + nums[i]);
        }
        return res[st];
    }
};
```

与上题类似，区别在于由于是环形数组，所以要不然选最前面要不然选最后面一个。

#### 337. 打家劫舍 III

<https://leetcode.cn/problems/house-robber-iii/>

小偷又发现了一个新的可行窃的地区。这个地区只有一个入口，我们称之为  root 。

除了  root  之外，每栋房子有且只有一个“父“房子与之相连。一番侦察之后，聪明的小偷意识到“这个地方的所有房屋的排列类似于一棵二叉树”。 如果 两个直接相连的房子在同一天晚上被打劫 ，房屋将自动报警。

给定二叉树的  root 。返回   在不触动警报的情况下  ，小偷能够盗取的最高金额  。

```cpp
/**
 * Definition for a binary tree node.
 * struct TreeNode {
 *     int val;
 *     TreeNode *left;
 *     TreeNode *right;
 *     TreeNode() : val(0), left(nullptr), right(nullptr) {}
 *     TreeNode(int x) : val(x), left(nullptr), right(nullptr) {}
 *     TreeNode(int x, TreeNode *left, TreeNode *right) : val(x), left(left), right(right) {}
 * };
 */
class Solution {
public:
    int rob(TreeNode* root) {
        vector<int> res = dp(root);
        return max(res[0], res[1]);
    }

    vector<int> dp(TreeNode* root) {
        if(!root) return {0, 0};
        vector<int> left = dp(root->left);
        vector<int> right = dp(root->right);
        int rob = root->val + left[1] + right[1];
        int notrob = max(left[0], left[1]) + max(right[0], right[1]);
        return {rob, notrob};
    }
};
```

返回一个大小为 2 的数组，res\[0] 表示不抢 root 的话，得到的最大钱数 res\[1] 表示抢 root 的话，得到的最大钱数。

#### 343. 整数拆分

<https://leetcode.cn/problems/integer-break/>

给定一个正整数 n ，将其拆分为 k 个 正整数 的和（ k >= 2 ），并使这些整数的乘积最大化。

返回 你可以获得的最大乘积 。

```cpp
class Solution {
public:
    int cuttingRope(int n) {
        vector<int> dp(n + 1);
         dp[2] = 1;
         for(int i = 3; i <= n; i++) {
             for(int j = 1; j <= i / 2; j++) {
                 dp[i] = max(dp[i], max(j * (i - j), j * dp[i - j]));
             }
         }
         return dp[n];
    }
};
```

列出状态转移方程 `dp[i] = max(dp[i], max(j * (i - j), j * dp[i - j]))` 就非常简单了。

#### 64. 最小路径和

<https://leetcode.cn/problems/minimum-path-sum/>

```cpp
class Solution {
public:
    vector<vector<int>>memo;
    int minPathSum(vector<vector<int>>& grid) {
        int m = grid.size();
        int n = grid[0].size();
        memo = vector<vector<int>>(m, vector<int>(n, -1));
        return dp(grid, m - 1, n - 1);
    }

    int dp(vector<vector<int>>& grid, int i, int j) {
        if(i == 0 && j == 0) return grid[0][0];
        if(i < 0 || j < 0) return INT_MAX;
        if(memo[i][j] != -1) return memo[i][j];
        memo[i][j] =  min(dp(grid, i - 1, j), dp(grid, i, j - 1)) + grid[i][j];
        return memo[i][j];
    }
};
```

从左上角位置 (0, 0) 走到位置 (i, j) 的最小路径和为 dp(grid, i, j)，还可以用备忘录优化一下执行效率。

#### 91. 解码方法

<https://leetcode.cn/problems/decode-ways/>

一条包含字母  A-Z 的消息通过以下映射进行了 编码 ：

'A' -> "1" 'B' -> "2" ... 'Z' -> "26" 要 解码 已编码的消息，所有数字必须基于上述映射的方法，反向映射回字母（可能有多种方法）。例如，"11106" 可以映射为：

"AAJF" ，将消息分组为 (1 1 10 6) "KJF" ，将消息分组为 (11 10 6) 注意，消息不能分组为   (1 11 06) ，因为 "06" 不能映射为 "F" ，这是由于 "6" 和 "06" 在映射中并不等价。

给你一个只含数字的 非空 字符串 s ，请计算并返回 解码 方法的 总数 。

题目数据保证答案肯定是一个 32 位 的整数。

```cpp
class Solution {
public:
    int numDecodings(string s) {
        int n = s.size();
        if(n < 1) return 0;
        vector<int>dp(n + 1);
        dp[0] = 1, dp[1] = s[0] == '0' ? 0 : 1;
        for(int i = 2; i <= n; i++) {
            char c = s[i - 1], d = s[i - 2];
            if('1' <= c && c <= '9') dp[i] += dp[i - 1];
            if(d == '1' || d == '2' && c <= '6') dp[i] += dp[i - 2];
        }
        return dp[n];
    }
};
```

#### 300. 最长递增子序列

<https://leetcode.cn/problems/longest-increasing-subsequence/>

给你一个整数数组 nums ，找到其中最长严格递增子序列的长度。

子序列   是由数组派生而来的序列，删除（或不删除）数组中的元素而不改变其余元素的顺序。例如，\[3,6,2,7] 是数组 \[0,3,1,6,2,2,7] 的子序列。

```cpp
class Solution {
public:
    int lengthOfLIS(vector<int>& nums) {
        vector<int> dp(nums.size(), 1);
        for(int i = 0; i < nums.size(); i++) {
            for(int j = 0; j < i; j++) {
                if(nums[i] > nums[j]) dp[i] = max(dp[i], dp[j] + 1);
            }
        }
        int res = 0;
        for(int i = 0; i < dp.size(); i++) res = max(res, dp[i]);
        return res;
    }
};
```

#### 72. 编辑距离

<https://leetcode.cn/problems/edit-distance/>

给你两个单词  word1 和  word2， 请返回将  word1  转换成  word2 所使用的最少操作数  。

你可以对一个单词进行如下三种操作：

插入一个字符 删除一个字符 替换一个字符

```cpp
class Solution {
public:
    vector<vector<int>> memo;
    int minDistance(string word1, string word2) {
        int n = word1.size(), m = word2.size();
        memo = vector<vector<int>>(n, vector<int>(m, -1));
        return dp(word1, n - 1, word2, m - 1);
    }

    int dp(string s1, int i, string s2, int j) {
        if(i == -1) return j + 1;
        if(j == -1) return i + 1;
        if(memo[i][j] != -1) return memo[i][j];
        if(s1[i] == s2[j]) memo[i][j] = dp(s1, i - 1, s2, j - 1);
        else memo[i][j] = minFunc(
            dp(s1, i - 1, s2, j - 1) + 1,
            dp(s1, i, s2, j - 1) + 1,
            dp(s1, i - 1, s2, j) + 1
        );
        return memo[i][j];
    }

    int minFunc(int a, int b, int c) {
        return min(a, min(b, c));
    }
};
```

#### 1143. 最长公共子序列

<https://leetcode.cn/problems/longest-common-subsequence/>

给定两个字符串  text1 和  text2，返回这两个字符串的最长 公共子序列 的长度。如果不存在 公共子序列 ，返回 0 。

一个字符串的   子序列   是指这样一个新的字符串：它是由原字符串在不改变字符的相对顺序的情况下删除某些字符（也可以不删除任何字符）后组成的新字符串。

例如，"ace" 是 "abcde" 的子序列，但 "aec" 不是 "abcde" 的子序列。 两个字符串的 公共子序列 是这两个字符串所共同拥有的子序列。

```cpp
class Solution {
public:
    int longestCommonSubsequence(string s1, string s2) {
        int m = s1.size(), n = s2.size();
        vector<vector<int>> dp(m + 1, vector<int>(n + 1));
        for(int i = 1; i <= m; i++) {
            for(int j = 1; j <= n; j++) {
                if(s1[i - 1] == s2[j - 1]) dp[i][j] = 1 + dp[i - 1][j - 1];
                else dp[i][j] = max(dp[i - 1][j], dp[i][j - 1]);
            }
        }
        return dp[m][n];
    }
};
```

#### 221. 最大正方形

<https://leetcode.cn/problems/maximal-square/>

在一个由 '0' 和 '1' 组成的二维矩阵内，找到只包含 '1' 的最大正方形，并返回其面积。

```cpp
class Solution {
public:
    int maximalSquare(vector<vector<char>>& matrix) {
        int m = matrix.size(), n = matrix[0].size();
        if(!m || !n) return 0;
        vector<vector<int>> dp(m + 1, vector<int>(n + 1));
        int res = 0;
        for(int i = 1; i <= m; i++) {
            for(int j = 1; j <= n; j++) {
                if(matrix[i - 1][j - 1] == '1') {
                    dp[i][j] = min(dp[i - 1][j - 1], min(dp[i - 1][j], dp[i][j - 1])) + 1;
                    res = max(res, dp[i][j]);
                }
            }
        }
        return res * res;
    }
};
```

#### 62. 不同路径

<https://leetcode.cn/problems/unique-paths/>

一个机器人位于一个 m x n  网格的左上角 （起始点在下图中标记为 “Start” ）。

机器人每次只能向下或者向右移动一步。机器人试图达到网格的右下角（在下图中标记为 “Finish” ）。

问总共有多少条不同的路径？

```cpp
class Solution {
public:
    vector<vector<int>> memo;
    int uniquePaths(int m, int n) {
        memo = vector<vector<int>>(m, vector<int>(n, -1));
        return dp(m - 1, n - 1);
    }

    int dp(int x, int y) {
        if(x == 0 && y == 0) return 1;
        if(x < 0 || y < 0) return 0;
        if(memo[x][y] != -1) return memo[x][y];
        memo[x][y] = dp(x - 1, y) + dp(x, y - 1);
        return memo[x][y];
    }
};
```

#### 152. 乘积最大子数组

<https://leetcode.cn/problems/maximum-product-subarray/>

给你一个整数数组 nums ，请你找出数组中乘积最大的非空连续子数组（该子数组中至少包含一个数字），并返回该子数组所对应的乘积。

测试用例的答案是一个  32-位 整数。

子数组 是数组的连续子序列。

```cpp
class Solution {
public:
    int maxProduct(vector<int>& nums) {
        int n = nums.size();
        vector<int> dp1(n);
        vector<int> dp2(n);
        dp1[0] = nums[0], dp2[0] = nums[0];
        int res = dp2[0];
        for(int i = 1; i < n; i++) {
            dp1[i] = min(dp1[i - 1] * nums[i], min(dp2[i - 1] * nums[i], nums[i]));
            dp2[i] = max(dp1[i - 1] * nums[i], max(dp2[i - 1] * nums[i], nums[i]));
            res = max(res, dp2[i]);
        }
        return res;
    }
};
```


# 哈希表

#### 217. 存在重复元素

<https://leetcode.cn/problems/contains-duplicate>

给你一个整数数组 `nums` 。如果任一值在数组中出现 **至少两次** ，返回 `true` ；如果数组中每个元素互不相同，返回 `false` 。

```cpp
class Solution {
public:
    bool containsDuplicate(vector<int>& nums) {
        unordered_set<int> Hash;
        for(auto i : nums){
            if(Hash.count(i)) return true;
            else Hash.insert(i);
        }
        return false;
    }
};
```

哈希表基础使用，若插入后还能 `count` 到就说明在数组中出现了至少两次。

#### 1. 两数之和

<https://leetcode.cn/problems/two-sum/>

给定一个整数数组 nums 和一个整数目标值 target，请你在该数组中找出 和为目标值 target 的那 两个 整数，并返回它们的数组下标。

你可以假设每种输入只会对应一个答案。但是，数组中同一个元素在答案里不能重复出现。

你可以按任意顺序返回答案。

```cpp
class Solution {
public:
    vector<int> twoSum(vector<int>& nums, int target) {
        unordered_map<int, int> Hash;
        for(int i = 0; i < nums.size(); i++){
            int need = target - nums[i];
            if(Hash.count(need)) return {Hash[need], i};
            Hash[nums[i]] = i;
        }
        return {};
    }
};
```

开一个哈希表，每过一个就把值和下标存表里，这样查询时只需要 O(1) 的时间复杂度。

#### 350. 两个数组的交集 II

<https://leetcode.cn/problems/intersection-of-two-arrays-ii/>

给你两个整数数组 nums1 和 nums2 ，请你以数组形式返回两数组的交集。返回结果中每个元素出现的次数，应与元素在两个数组中都出现的次数一致（如果出现次数不一致，则考虑取较小值）。可以不考虑输出结果的顺序。

```cpp
class Solution {
public:
    vector<int> intersect(vector<int>& nums1, vector<int>& nums2) {
        unordered_multiset<int> Hash;
        vector<int> result;
        for(int i = 0; i < nums1.size(); i++){
            Hash.insert(nums1[i]);
        }
        for(int i = 0; i < nums2.size(); i++){
            if(Hash.count(nums2[i])){
                Hash.erase(Hash.find(nums2[i]));
                result.push_back(nums2[i]);
            }
        }
        return result;
    }
};
```

不难，但需要注意我们这里使用的是 `unodered_multiset` ，这个哈希集支持有多个相同的数，并且在删除的时候使用 `Hash.find()` ，因为如果直接删除的话会把所有这个数删掉。

#### 387. 字符串中的第一个唯一字符

<https://leetcode.cn/problems/first-unique-character-in-a-string/>

给定一个字符串 `s` ，找到 *它的第一个不重复的字符，并返回它的索引* 。如果不存在，则返回 `-1` 。

```cpp
class Solution {
public:
    int firstUniqChar(string s) {
        unordered_map<char, int> Hash;
        for(int i = 0; i < s.length(); i++){
            Hash[s[i]]++;
        }
        for(int i = 0; i < s.length(); i++){
            if(Hash[s[i]] == 1) return i;
        }
        return -1;
    }
};
```

用哈希表存，然后遍历两次即可。

#### 383. 赎金信

<https://leetcode.cn/problems/ransom-note/>

给你两个字符串：ransomNote 和 magazine ，判断 ransomNote 能不能由 magazine 里面的字符构成。

如果可以，返回 true ；否则返回 false 。

magazine 中的每个字符只能在 ransomNote 中使用一次。

```cpp
class Solution {
public:
    bool canConstruct(string ransomNote, string magazine) {
        unordered_map<char, int> h;
        for(int i = 0; i < magazine.length(); i++){
            h[magazine[i]]++;
        }
        for(int i = 0; i < ransomNote.length(); i++){
            h[ransomNote[i]]--;
            if(h[ransomNote[i]] < 0) return false;
        }
        return true;
    }
};
```

用哈希表存，遍历两个字符串即可。

#### 242. 有效的字母异位词

<https://leetcode.cn/problems/valid-anagram/>

给定两个字符串 s 和 t ，编写一个函数来判断 t 是否是 s 的字母异位词。

注意：若 s 和 t 中每个字符出现的次数都相同，则称 s 和 t 互为字母异位词。

```cpp
class Solution {
public:
    bool isAnagram(string s, string t) {
        unordered_map<char, int> h1, h2;
        for(int i = 0; i < s.length(); i++){
            h1[s[i]]++;
        }
        for(int i = 0; i < t.length(); i++){
            h2[t[i]]++;
        }
        return h1 == h2;
    }
};
```

用两个哈希表来存，最后判断是否相等即可。

#### 141. 环形链表

<https://leetcode.cn/problems/linked-list-cycle/>

给你一个链表的头节点 head ，判断链表中是否有环。

如果链表中有某个节点，可以通过连续跟踪 next 指针再次到达，则链表中存在环。 为了表示给定链表中的环，评测系统内部使用整数 pos 来表示链表尾连接到链表中的位置（索引从 0 开始）。注意：pos 不作为参数进行传递 。仅仅是为了标识链表的实际情况。

如果链表中存在环 ，则返回 true 。 否则，返回 false 。

**哈希表**

```cpp
/**
 * Definition for singly-linked list.
 * struct ListNode {
 *     int val;
 *     ListNode *next;
 *     ListNode(int x) : val(x), next(NULL) {}
 * };
 */
class Solution {
public:
    bool hasCycle(ListNode *head) {
        unordered_set<ListNode*> seen;
        while (head) {
            if (seen.count(head)) return true;
            seen.insert(head);
            head = head->next;
        }
        return false;
    }
};
```

遍历链表观察是否有相同的节点，如果有就说明是环形链表。

**快慢指针**

```cpp
/**
 * Definition for singly-linked list.
 * struct ListNode {
 *     int val;
 *     ListNode *next;
 *     ListNode(int x) : val(x), next(NULL) {}
 * };
 */
class Solution {
public:
    bool hasCycle(ListNode *head) {
        if(!head || !head->next) return false;
        auto slow = head, fast = head->next;
        while (fast && fast->next) {
            slow = slow->next, fast = fast->next->next;
            if(fast == slow) return true;
        }
        return false;
    }
};
```

快的比慢的跑得快，总的遇到，要不然就是没有，直接返回 false。

#### 349. 两个数组的交集

<https://leetcode.cn/problems/intersection-of-two-arrays/>

给定两个数组 nums1 和 nums2 ，返回 它们的交集 。输出结果中的每个元素一定是 唯一 的。我们可以 不考虑输出结果的顺序 。

```cpp
class Solution {
public:
    vector<int> intersection(vector<int>& nums1, vector<int>& nums2) {
        vector<int> res;
        unordered_map<int, int> h1, h2;
        for(int i = 0; i < nums1.size(); i++){
            h1[nums1[i]]++;
        }
        for(int i = 0; i < nums2.size(); i++){
            if(h1[nums2[i]] != 0 && h2[nums2[i]] == 0){
                res.push_back(nums2[i]);
                h2[nums2[i]]++;
            }
        }
        return res;
    }
};
```

两个哈希表即可解决问题，第二个哈希表用于判断答案里是否已经有这个元素了。

#### 454. 四数相加 II

<https://leetcode.cn/problems/4sum-ii/>

给你四个整数数组 nums1、nums2、nums3 和 nums4 ，数组长度都是 n ，请你计算有多少个元组 (i, j, k, l) 能满足：

0 <= i, j, k, l < n nums1\[i] + nums2\[j] + nums3\[k] + nums4\[l] == 0

```cpp
class Solution {
public:
    int fourSumCount(vector<int>& nums1, vector<int>& nums2, vector<int>& nums3, vector<int>& nums4) {
        unordered_map<int, int> h;
        int n = nums1.size();
        for(int i = 0; i < n; i++){
            for(int j = 0; j < n; j++){
                h[nums1[i] + nums2[j]]++;
            }
        }
        int res = 0;
        for(int i = 0; i < n; i++){
            for(int j = 0; j < n; j++){
                res += h[- nums3[i] - nums4[j]];
            }
        }
        return res;
    }
};
```

用一个哈希表存两个数的和，再遍历另外两个数看是不是相反数，总和为 0 时就把哈希表中存的个数加到答案里面。

#### 169. 多数元素

<https://leetcode.cn/problems/majority-element/>

```cpp
class Solution {
public:
    int majorityElement(vector<int>& nums) {
        int len = nums.size() / 2;
        unordered_map<int, int> S;
        for(int i = 0; i < nums.size(); i++) {
            S[nums[i]]++;
            if(S[nums[i]] > len) return nums[i];
        }
        return 0;
    }
};
```

按题意使用哈希即可。

#### 128. 最长连续序列

<https://leetcode.cn/problems/longest-consecutive-sequence/>

给定一个未排序的整数数组 nums ，找出数字连续的最长序列（不要求序列元素在原数组中连续）的长度。

请你设计并实现时间复杂度为  O(n) 的算法解决此问题。

```cpp
class Solution {
public:
    int longestConsecutive(vector<int>& nums) {
        unordered_set<int> hash;
        int res = 0;
        for(int n : nums) hash.insert(n);
        for(int n : hash) {
            if(hash.count(n - 1)) continue;
            int curNum = n, curLen = 1;
            while(hash.count(curNum + 1)) {
                curNum += 1;
                curLen += 1;
            }
            res = max(res, curLen);
        }
        return res;
    }
};
```

#### 560. 和为 K 的子数组

<https://leetcode.cn/problems/subarray-sum-equals-k/>

给你一个整数数组 nums 和一个整数 k ，请你统计并返回 该数组中和为 k 的连续子数组的个数 。

```cpp
class Solution {
public:
    int subarraySum(vector<int>& nums, int k) {
        unordered_map<int, int> hash;
        int ans = 0, preSum = 0;
        hash[0] = 1;
        for(auto num : nums) {
            preSum += num;
            if(hash.count(preSum - k)) ans += hash[preSum - k];
            hash[preSum]++;
        }
        return ans;
    }
};
```

#### 718. 最长重复子数组

<https://leetcode.cn/problems/maximum-length-of-repeated-subarray/>

给两个整数数组 nums1 和 nums2 ，返回 两个数组中 公共的 、长度最长的子数组的长度 。

```cpp
class Solution {
public:
    typedef unsigned long long ULL;
    vector<ULL> ha, hb, p;
    const int P = 131;
    int m, n;
    int findLength(vector<int>& nums1, vector<int>& nums2) {
        m = nums1.size(), n = nums2.size();
        ha.resize(m + 1), hb.resize(n + 1), p.resize(m + 1);
        for(int i = 1; i <= m; i++) ha[i] = ha[i - 1] * P + nums1[i - 1];
        for(int i = 1; i <= n; i++) hb[i] = hb[i - 1] * P + nums2[i - 1];
        p[0] = 1;
        for(int i = 1; i <= m; i++) p[i] = p[i - 1] * P;
        int l = 0, r = m;
        while(l < r) {
            int mid = l + r + 1 >> 1;
            if(check(mid)) l = mid;
            else r = mid - 1;
        }
        return r;
    }

    bool check(int mid) {
        unordered_set<ULL> s;
        for(int i = mid; i <= m; i++) s.insert(get(ha, i - mid + 1, i));
        for(int i = mid; i <= n; i++) {
            if(s.count(get(hb, i - mid + 1, i))) return true;
        }
        return false;
    }

    ULL get(vector<ULL>& h, int l, int r) {
        return h[r] - h[l - 1] * p[r - l + 1];
    }
};
```


# 双指针

#### 977. 有序数组的平方

<https://leetcode.cn/problems/squares-of-a-sorted-array/comments/>

给你一个按 **非递减顺序** 排序的整数数组 `nums`，返回 **每个数字的平方** 组成的新数组，要求也按 **非递减顺序** 排序。

**暴力排序法**

```cpp
class Solution {
public:
    vector<int> sortedSquares(vector<int>& nums) {
        for(int i = 0; i < nums.size(); i++){
            nums[i] = nums[i] * nums[i];
        }
        sort(nums.begin(), nums.end());
        return nums;
    }
};
```

**双指针法**

```cpp
class Solution {
public:
    vector<int> sortedSquares(vector<int> &nums) {
        vector<int> result(nums.size(), 0);
        int k = nums.size() - 1;
        for (int i = 0, j = k; i < nums.size(), j >= i;) {
            if (nums[j] * nums[j] > nums[i] * nums[i]) {
                result[k--] = nums[j] * nums[j];
                j--;
            } else {
                result[k--] = nums[i] * nums[i];
                i++;
            }
        }
        return result;
    }
};
```

数组其实是有序的， 只不过负数平方之后可能成为最大数了。那么数组平方的最大值就在数组的两端，不是最左边就是最右边，不可能是中间。此时可以考虑双指针法了，i 指向起始位置，j 指向终止位置。定义一个新数组 result，和 nums 数组一样的大小，让 k 指向 result 数组终止位置。

#### 189. 轮转数组

<https://leetcode.cn/problems/rotate-array/>

给你一个数组，将数组中的元素向右轮转 `k` 个位置，其中 `k` 是非负数。

**暴力轮转**

```cpp
class Solution {
public:
    void rotate(vector<int>& nums, int k) {
        vector<int> result(nums.size(), 0);
        k = k % nums.size();
        for(int i = 0; i < nums.size(); i++){
            if(i >= nums.size() - k){
                result[i + k - nums.size()] = nums[i];
                continue;
            }
            result[i + k] = nums[i];
        }
        nums = result;
    }
};
```

**双指针原地算法**

```cpp
class Solution {
public:
    void rotate(vector<int>& nums, int k) {
        k = k % nums.size();
        reverse(nums.begin(), nums.end());
        reverse(nums.begin(), nums.begin() + k);
        reverse(nums.begin() + k, nums.end());
    }
};
```

先全部翻转一遍，再翻转前一段，最后翻转后一段。

#### 88. 合并两个有序数组

<https://leetcode.cn/problems/merge-sorted-array/>

给你两个按 非递减顺序 排列的整数数组 nums1 和 nums2，另有两个整数 m 和 n ，分别表示 nums1 和 nums2 中的元素数目。

请你 合并 nums2 到 nums1 中，使合并后的数组同样按 非递减顺序 排列。

注意：最终，合并后数组不应由函数返回，而是存储在数组 nums1 中。为了应对这种情况，nums1 的初始长度为 m + n，其中前 m 个元素表示应合并的元素，后 n 个元素为 0 ，应忽略。nums2 的长度为 n 。

```cpp
class Solution {
public:
    void merge(vector<int>& nums1, int m, vector<int>& nums2, int n) {
        int k = m + n - 1;
        int i = m - 1;
        int j = n - 1;
        while(j >= 0 && i >= 0){
            if(nums1[i] > nums2[j]) nums1[k--] = nums1[i--];
            else nums1[k--] = nums2[j--];
        }
        while(j >= 0) nums1[k--] = nums2[j--];
    }
};
```

因为我们要放在 nums1 里面，所以我们从后往前遍历，从最大的开始比较，一个一个放进去，最后 nums2 还有剩就全部扔进去，nums1 还有剩说明已经全部放在了正确的位置，不用管。

#### 283. 移动零

<https://leetcode.cn/problems/move-zeroes>

给定一个数组 `nums`，编写一个函数将所有 `0` 移动到数组的末尾，同时保持非零元素的相对顺序。

```cpp
class Solution {
public:
    void moveZeroes(vector<int>& nums) {
        int j = 0;
        for(int i = 0; i < nums.size(); i++){
            if(nums[i] != 0){
                nums[j++] = nums[i];
            }
        }
        for(int i = j; i < nums.size(); i++){
            nums[i] = 0;
        }
    }
};
```

i 来遍历整个数组，遇到不是 0 的就交给 j 进行填充，最后再补 0。

#### 167. 两数之和 II - 输入有序数组

<https://leetcode.cn/problems/two-sum-ii-input-array-is-sorted>

给你一个下标从 1 开始的整数数组 numbers ，该数组已按 非递减顺序排列 ，请你从数组中找出满足相加之和等于目标数 target 的两个数。如果设这两个数分别是 numbers\[index1] 和 numbers\[index2] ，则 1 <= index1 < index2 <= numbers.length 。

以长度为 2 的整数数组 \[index1, index2] 的形式返回这两个整数的下标 index1 和 index2。

```cpp
class Solution {
public:
    vector<int> twoSum(vector<int>& nums, int target) {
        int i = 0;
        int j = nums.size() - 1;
        while(i < j){
            if(nums[i] + nums[j] < target){
                i++;
            }else if(nums[i] + nums[j] > target){
                j--;
            }else{
                return {i + 1, j + 1};
            }
        }
        return {};
    }
};
```

i 在最小，j 在最大，如果大了 j 就减一，如果小了 i 就加一。这样做可以减少很多不必要情况的判断。

#### 344. 反转字符串

<https://leetcode.cn/problems/reverse-string>

编写一个函数，其作用是将输入的字符串反转过来。输入字符串以字符数组 `s` 的形式给出。

```cpp
class Solution {
public:
    void reverseString(vector<char>& s) {
        for(int i = 0, j = s.size() - 1; i < s.size() / 2; i++, j--){
            swap(s[i], s[j]);
        }
    }
};
```

无脑题，甚至直接 `reverse` 也能过。

#### 557. 反转字符串中的单词 III

<https://leetcode.cn/problems/reverse-words-in-a-string-iii>

给定一个字符串 `s` ，你需要反转字符串中每个单词的字符顺序，同时仍保留空格和单词的初始顺序。

```cpp
class Solution {
public:
    string reverseWords(string s) {
        int left = 0;
        for(int i = 0; i < s.length(); i++){
            if(s[i] == 32){
                reverse(s.begin() + left, s.begin() + i);
                left = i + 1;
            }
        }
        reverse(s.begin() + left, s.end());
        return s;
    }
};
```

遇到空格就反转之前的，最后再反转一下最后一个单词即可。`reverse` 可以用双指针实现。

#### 876. 链表的中间结点

<https://leetcode.cn/problems/middle-of-the-linked-list>

给定一个头结点为 `head` 的非空单链表，返回链表的中间结点。

如果有两个中间结点，则返回第二个中间结点。

**普通双指针**

```cpp
/**
 * Definition for singly-linked list.
 * struct ListNode {
 *     int val;
 *     ListNode *next;
 *     ListNode() : val(0), next(nullptr) {}
 *     ListNode(int x) : val(x), next(nullptr) {}
 *     ListNode(int x, ListNode *next) : val(x), next(next) {}
 * };
 */
class Solution {
public:
    ListNode* middleNode(ListNode* head) {
        int n = 0;
        auto q = head, p = head;
        while(p){
            p = p -> next;
            n++;
        }
        for(int i = 0; i < n / 2; i++){
            q = q -> next;
        }
        return q;
    }
};
```

遍历第一遍求长度，第二遍走到 n / 2 即可。

**快慢指针**

```cpp
/**
 * Definition for singly-linked list.
 * struct ListNode {
 *     int val;
 *     ListNode *next;
 *     ListNode() : val(0), next(nullptr) {}
 *     ListNode(int x) : val(x), next(nullptr) {}
 *     ListNode(int x, ListNode *next) : val(x), next(next) {}
 * };
 */
class Solution {
public:
    ListNode* middleNode(ListNode* head) {
        auto q = head, p = head;
        while(q && q->next){
            p = p->next;
            q = q->next->next;
        }
        return p;
    }
};
```

奇数情况是 q 到最后一个就不行了，偶数情况是到最后一个的下一个，也就是空。

#### 3. 无重复字符的最长子串

<https://leetcode.cn/problems/longest-substring-without-repeating-characters/>

给定一个字符串 `s` ，请你找出其中不含有重复字符的 **最长子串** 的长度。

```cpp
class Solution {
public:
    int lengthOfLongestSubstring(string s) {
        unordered_map<int, int> Hash;
        int ans = 0;
        for(int i = 0, j = 0; i < s.size(); i++){
            Hash[s[i]] ++;
            while(Hash[s[i]] > 1) Hash[s[j++]]--;
            ans = max(ans, i - j + 1);
        }
        return ans;
    }
};
```

用一个哈希表来维护，当有重复字符的时候，j 指针就往右移，直到没有重复字符，最后取 max 即可。

#### 567. 字符串的排列

<https://leetcode.cn/problems/permutation-in-string/>

给你两个字符串 s1 和 s2 ，写一个函数来判断 s2 是否包含 s1 的排列。如果是，返回 true ；否则，返回 false 。

换句话说，s1 的排列之一是 s2 的 子串 。

```cpp
class Solution {
public:
    unordered_map<int, int> h1;
    unordered_map<int, int> h2;
    bool check(int c){
        if(h1.count(c) && h1[c] == h2[c]) return true;
        return false;
    }
    bool checkInclusion(string s1, string s2) {
        for(int i = 0; i < s1.size(); i++){
            h1[s1[i]]++;
        }
        for(int i = 0, j = 0, cnt = 0; i < s2.size(); i++){
            if(check(s2[i])) cnt--;
            h2[s2[i]]++;
            if(check(s2[i])) cnt++;
            if(i - j >= s1.size()){
                if(check(s2[j])) cnt--;
                h2[s2[j]]--;
                if(check(s2[j])) cnt++;
                j++;
            }
            if(cnt == h1.size()) return true;
        }
        return false;
    }
};
```

用两个哈希表进行维护，最后比较两个哈希表相同的“柱子数”是否为 h1 的总长度即可。

```cpp
class Solution {
public:
    bool checkInclusion(string s1, string s2) {
        unordered_map<char, int> need, win;
        for(auto c : s1) need[c]++;
        int left = 0, right = 0, vl = 0;
        while(right < s2.size()) {
            char c1 = s2[right];
            right++;
            if(need.count(c1)) {
                win[c1]++;
                if(win[c1] == need[c1]) vl++;
            }
            while(vl == need.size()) {
                if(right - left == s1.size()) return true;
                char c2 = s2[left];
                left++;
                if(need.count(c2)) {
                    if(win[c2] == need[c2]) vl--;
                    win[c2]--;
                }
            }
        }
        return false;
    }
};
```

套模板即可，当窗口长度为 s1 的长度且有效位数符合时就可以返回 true。

#### 27. 移除元素

<https://leetcode.cn/problems/remove-element/>

给你一个数组 nums 和一个值 val，你需要 原地 移除所有数值等于 val 的元素，并返回移除后数组的新长度。

不要使用额外的数组空间，你必须仅使用 O(1) 额外空间并 原地 修改输入数组。

元素的顺序可以改变。你不需要考虑数组中超出新长度后面的元素。

**暴力**

```cpp
class Solution {
public:
    int removeElement(vector<int>& nums, int val) {
        int size = nums.size();
        for(int i = 0; i < size; i++){
            if(nums[i] == val) {
                for(int j = i + 1; j < size; j++){
                    nums[j - 1] = nums[j];
                }
                size--;
                i--;
            }
        }
        return size;
    }
};
```

遇到需要删除的就将后面的元素一起往前移一格即可。因为少了一个，所以 i 也需要往前一格。

**快慢指针**

```cpp
class Solution {
public:
    int removeElement(vector<int>& nums, int val) {
        int slow = 0;
        for(int fast = 0; fast < nums.size(); fast++){
            if(nums[fast] != val) {
                nums[slow++] = nums[fast];
            }
        }
        return slow;
    }
};
```

当快指针遇到需要删除的元素，慢指针不动，直到快指针指向不需要删除的数字，然后让快指针把不需删除的一个一个覆盖过去。

#### 26. 删除有序数组中的重复项

<https://leetcode.cn/problems/remove-duplicates-from-sorted-array/>

给你一个 升序排列 的数组 nums ，请你 原地 删除重复出现的元素，使每个元素 只出现一次 ，返回删除后数组的新长度。元素的 相对顺序 应该保持 一致 。

由于在某些语言中不能改变数组的长度，所以必须将结果放在数组 nums 的第一部分。更规范地说，如果在删除重复项之后有 k 个元素，那么 nums 的前 k 个元素应该保存最终结果。

将最终结果插入 nums 的前 k 个位置后返回 k 。

不要使用额外的空间，你必须在 原地 修改输入数组 并在使用 O(1) 额外空间的条件下完成。

```cpp
class Solution {
public:
    int removeDuplicates(vector<int>& nums) {
        int j = 0;
        for(int i = 0; i < nums.size(); i++){
            if(!i || nums[i] != nums[i - 1]){
                 nums[j++] = nums[i];
            }
        }
        return j;
    }
};
```

一个指在前面一个指在后面，经典双指针问题。注意特判 i = 0 的情况。

```cpp
class Solution {
public:
    int removeDuplicates(vector<int>& nums) {
        int j = 0;
        for(int i = 0; i < nums.size(); i++){
            if(nums[i] != nums[j]){
                j++;
                nums[j] = nums[i];
            }
        }
        return j + 1;
    }
};
```

更好的快慢指针做法，更加直观。

#### 844. 比较含退格的字符串

<https://leetcode.cn/problems/backspace-string-compare/>

给定 s 和 t 两个字符串，当它们分别被输入到空白的文本编辑器后，如果两者相等，返回 true 。# 代表退格字符。

注意：如果对空文本输入退格字符，文本继续为空。

```cpp
class Solution {
public:
    void changestring(string &s) {
        int slow=0;
        for(int fast = 0; fast < s.size(); fast++)
        {
            if(s[fast] != '#') s[slow++] = s[fast];
            else if(slow) slow--;
        }
        s.resize(slow);
    }

    bool backspaceCompare(string s, string t) {
        changestring(s);
        changestring(t);
        return s==t;
    }
};
```

注意使用 `resize()` 将后面无用的字符给去掉。

#### 209. 长度最小的子数组

<https://leetcode.cn/problems/minimum-size-subarray-sum/>

给定一个含有 n 个正整数的数组和一个正整数 target 。

找出该数组中满足其和 ≥ target 的长度最小的 连续子数组 \[numsl, numsl+1, ..., numsr-1, numsr] ，并返回其长度。如果不存在符合条件的子数组，返回 0 。

```cpp
class Solution {
public:
    int minSubArrayLen(int target, vector<int>& nums) {
        int res = INT_MAX;
        for(int i = 0, j = 0, sum = 0; i < nums.size(); i++){
            sum += nums[i];
            while(sum >= target){
                res = min(res, i - j + 1);
                sum -= nums[j++];
            }
        }
        if(res == INT_MAX) res = 0;
        return res;
    }
};
```

经典滑动窗口问题，注意判断 res 没有被更新的情况。

#### 904. 水果成篮

<https://leetcode.cn/problems/fruit-into-baskets/>

你正在探访一家农场，农场从左到右种植了一排果树。这些树用一个整数数组 fruits 表示，其中 fruits\[i] 是第 i 棵树上的水果 种类 。

你想要尽可能多地收集水果。然而，农场的主人设定了一些严格的规矩，你必须按照要求采摘水果：

你只有 两个 篮子，并且每个篮子只能装 单一类型 的水果。每个篮子能够装的水果总量没有限制。 你可以选择任意一棵树开始采摘，你必须从 每棵 树（包括开始采摘的树）上 恰好摘一个水果 。采摘的水果应当符合篮子中的水果类型。每采摘一次，你将会向右移动到下一棵树，并继续采摘。 一旦你走到某棵树前，但水果不符合篮子的水果类型，那么就必须停止采摘。 给你一个整数数组 fruits ，返回你可以收集的水果的 最大 数目。

```cpp
class Solution {
public:
    int totalFruit(vector<int>& fruits) {
        int res = 0;
        unordered_map<int, int> h;
        for(int i = 0, j = 0, s = 0; i < fruits.size(); i++){
            if(++h[fruits[i]] == 1) s++;
            while(s > 2){
                if(--h[fruits[j++]] == 0) s--;
            }
            res = max(res, i - j + 1);
        }
        return res;
    }
};
```

用哈希表，并且用一个变量来保存哈希表中有几个不同的水果种类，每次这个种类清空了或者存在了都会对 s 有所改变。

#### 76. 最小覆盖子串

<https://leetcode.cn/problems/minimum-window-substring/>

给你一个字符串 `s` 、一个字符串 `t` 。返回 `s` 中涵盖 `t` 所有字符的最小子串。如果 `s` 中不存在涵盖 `t` 所有字符的子串，则返回空字符串 `""` 。

```cpp
class Solution {
public:
    string minWindow(string s, string t) {
         unordered_map<char, int> hs;
         unordered_map<char, int> ht;
         for(auto c : t) ht[c]++;

         string res;
         for(int i = 0, j = 0, cnt = 0; i < s.length(); i++){
             if(++ hs[s[i]] <= ht[s[i]]) cnt++;
             while(hs[s[j]] > ht[s[j]]) hs[s[j++]]--;
             if(cnt == t.length()){
                 if(res.empty() || i - j + 1 < res.length()){
                     res = s.substr(j, i - j + 1);
                 }
             }
         }
         return res;
    }
};
```

用两个哈希表和一个 cnt 进行维护，经典滑动窗口。

```cpp
class Solution {
public:
    string minWindow(string s, string t) {
        unordered_map<char, int> need, win;
        for(auto c : t) need[c]++;
        int left = 0, right = 0, vl = 0, st = 0, len = INT_MAX;
        while(right < s.size()) {
            char c1 = s[right];
            right++;
            if(need.count(c1)) {
                win[c1]++;
                if(win[c1] == need[c1]) vl++;
            }

            while(vl == need.size()) {
                if(right - left < len) {
                    st = left;
                    len = right - left;
                }
                char c2 = s[left];
                left++;
                if(need.count(c2)) {
                    if(win[c2] == need[c2]) vl--;
                    win[c2]--;
                }
            }
        }
        return len == INT_MAX ? "" : s.substr(st, len);
    }
};
```

更好的做法，可作为滑动窗口模板。

#### 202. 快乐数

<https://leetcode.cn/problems/happy-number/>

编写一个算法来判断一个数 n 是不是快乐数。

「快乐数」  定义为：

对于一个正整数，每一次将该数替换为它每个位置上的数字的平方和。 然后重复这个过程直到这个数变为 1，也可能是 无限循环 但始终变不到 1。 如果这个过程 结果为  1，那么这个数就是快乐数。 如果 n 是 快乐数 就返回 true ；不是，则返回 false 。

```cpp
class Solution {
public:
    int get(int x){
        int res = 0;
        while(x){
            res += (x % 10) * (x % 10);
            x /= 10;
        }
        return res;
    }
    bool isHappy(int n) {
       int f = get(n), s = n;
       while(f != s){
           f = get(get(f));
           s = get(s);
       }
       return f == 1;
    }
};
```

用一个快慢指针，因为他会有循环，所以判断循环的数字是否为 1 就行。

#### 15. 三数之和

<https://leetcode.cn/problems/3sum/>

给你一个整数数组 nums ，判断是否存在三元组 \[nums\[i], nums\[j], nums\[k]] 满足 i != j、i != k 且 j != k ，同时还满足 nums\[i] + nums\[j] + nums\[k] == 0 。请

你返回所有和为 0 且不重复的三元组。

注意：答案中不可以包含重复的三元组。

```cpp
class Solution {
public:
    vector<vector<int>> threeSum(vector<int>& nums) {
        vector<vector<int>> res;
        sort(nums.begin(), nums.end());
        unordered_map<int, int> h;
        for(int i = 0; i < nums.size(); i++){
            if(i && nums[i] == nums[i - 1]) continue;
            for(int j = i + 1, k = nums.size() - 1; j < k; j++){
                if(j > i + 1 && nums[j] == nums[j - 1]) continue;
                while(j < k - 1 && nums[i] + nums[j] + nums[k - 1] >= 0) k--;
                if(nums[i] + nums[j] + nums[k] == 0){
                    res.push_back({nums[i], nums[j], nums[k]});
                }
            }
        }
        return res;
    }
};
```

首先需要把 i 给定下来，然后遍历 j 与 k 即可，需要注意的是我们需要进行去重，不然会有重复答案的出现。

#### 18. 四数之和

<https://leetcode.cn/problems/4sum/>

给你一个由 n 个整数组成的数组  nums ，和一个目标值 target 。请你找出并返回满足下述全部条件且不重复的四元组  \[nums\[a], nums\[b], nums\[c], nums\[d]] （若两个四元组元素一一对应，则认为两个四元组重复）：

0 <= a, b, c, d < n a、b、c 和 d 互不相同 nums\[a] + nums\[b] + nums\[c] + nums\[d] == target 你可以按 任意顺序 返回答案 。

```cpp
class Solution {
public:
    vector<vector<int>> fourSum(vector<int>& nums, int target) {
        vector<vector<int>> res;
        sort(nums.begin(), nums.end());
        for(int i = 0; i < nums.size(); i++){
            if(i && nums[i] == nums[i - 1]) continue;
            for(int j = i + 1; j < nums.size(); j++){
                if(j > i + 1 && nums[j] == nums[j - 1]) continue;
                for(int k = j + 1, u = nums.size() - 1; k < u; k++){
                    if(k > j + 1 && nums[k] == nums[k - 1]) continue;
                    while(k < u - 1 && ((long)nums[i] + nums[j] + nums[k] + nums[u - 1]) >= target) u--;
                    if(((long)nums[i] + nums[j] + nums[k] + nums[u]) == target) res.push_back({nums[i], nums[j], nums[k], nums[u]});
                }
            }
        }
        return res;
    }
};
```

与三数之和区别不大，就是加一层循环就行了。

#### 151. 反转字符串中的单词

<https://leetcode.cn/problems/reverse-words-in-a-string/>

给你一个字符串 s ，请你反转字符串中 单词 的顺序。

单词 是由非空格字符组成的字符串。s 中使用至少一个空格将字符串中的 单词 分隔开。

返回 单词 顺序颠倒且 单词 之间用单个空格连接的结果字符串。

注意：输入字符串 s 中可能会存在前导空格、尾随空格或者单词间的多个空格。返回的结果字符串中，单词间应当仅用单个空格分隔，且不包含任何额外的空格。

```cpp
class Solution {
public:
    string reverseWords(string s) {
        reverse(s.begin(), s.end());
        int k = 0;
        for(int i = 0; i < s.length(); i++){
            if(s[i] == ' ') continue;
            int j = i, t = k;
            while(j < s.length() && s[j] != ' ') s[t++] = s[j++];
            reverse(s.begin() + k, s.begin() + t);
            s[t++] = ' ';
            k = t, i = j;
        }
        if(k) k--;
        s.erase(s.begin() + k, s.end());
        return s;
    }
};
```

将每一个单词都放到前面，一个指针指向原本的字符串，另外一个指向最后成为结果的字符串。注意找到一个单词就要将其翻转。

#### 5. 最长回文子串

<https://leetcode.cn/problems/longest-palindromic-substring/>

给你一个字符串 s，找到 s 中最长的回文子串。

如果字符串的反序与原始字符串相同，则该字符串称为回文字符串。

```cpp
class Solution {
public:
    string longestPalindrome(string s) {
        string res = "";
        for(int i = 0; i < s.size(); i++){
            string s1 = getPalindrome(s, i, i);
            string s2 = getPalindrome(s, i, i + 1);
            res = s1.size() > res.size() ? s1 : res;
            res = s2.size() > res.size() ? s2 : res;
        }
        return res;
    }

    string getPalindrome(string s, int l, int r){
        while(l >= 0 && r < s.size() && s[l] == s[r]){
            l--, r++;
        }
        return s.substr(l + 1, r - l - 1);
    }
};
```

这道题难点在于需要从中间向外来走，还要考虑是奇数还是偶数的情况，注意 `substr` 时的边界问题。

#### 438. 找到字符串中所有字母异位词

<https://leetcode.cn/problems/find-all-anagrams-in-a-string/>

给定两个字符串  s  和 p，找到  s  中所有  p  的   异位词   的子串，返回这些子串的起始索引。不考虑答案输出的顺序。

异位词 指由相同字母重排列形成的字符串（包括相同的字符串）。

```cpp
class Solution {
public:
    vector<int> findAnagrams(string s, string p) {
        vector<int> res;
        unordered_map<char, int> need, win;
        for(auto c : p) need[c]++;
        int right = 0, left = 0, vl = 0;
        while(right < s.size()) {
            char c1 = s[right];
            right++;
            if(need.count(c1)) {
                win[c1]++;
                if(win[c1] == need[c1]) vl++;
            }
            while(right - left >= p.size()) {
                if(vl == need.size()) res.push_back(left);
                char c2 = s[left];
                left++;
                if(need.count(c2)) {
                    if(win[c2] == need[c2]) vl--;
                    win[c2]--;
                }
            }
        }
        return res;
    }
};
```

按照模板写即可，值得一提的是这里处理缩小的时候和模板有所不同，根据题型自行适配。

#### 912. 排序数组

<https://leetcode.cn/problems/sort-an-array/>

给你一个整数数组 nums，请你将该数组升序排列。

```cpp
class Solution {
public:
    vector<int> tmp;
    vector<int> sortArray(vector<int>& nums) {
        tmp = vector<int>(nums.size());
        Sort(nums, 0, nums.size() - 1);
        return nums;
    }

    void Sort(vector<int>& nums, int l, int r) {
        if(l == r) return;
        int mid = (l + r) / 2;
        Sort(nums, l, mid);
        Sort(nums, mid + 1, r);
        merge(nums, l, mid, r);
    }

    void merge(vector<int>& nums, int l, int mid, int r) {
        for(int i = l; i <= r; i++) tmp[i] = nums[i];
        int i = l, j = mid + 1;
        for (int p = l; p <= r; p++) {
            if (i == mid + 1) nums[p] = tmp[j++];
            else if (j == r + 1) nums[p] = tmp[i++];
            else if (tmp[i] > tmp[j]) nums[p] = tmp[j++];
            else nums[p] = tmp[i++];
        }
    }
};
```

用归并排序解决。

```cpp
class Solution {
public:
    vector<int> sortArray(vector<int>& nums) {
        quick_sort(nums, 0, nums.size() - 1);
        return nums;
    }

    void quick_sort(vector<int>& nums, int l, int r) {
        if(l >= r) return;
        int x = nums[l + r >> 1], i = l - 1, j = r + 1;
        while(i < j) {
            do i++; while(nums[i] < x);
            do j--; while(nums[j] > x);
            if(i < j) swap(nums[i], nums[j]);
        }
        quick_sort(nums, l, j);
        quick_sort(nums, j + 1, r);
    }
};
```

快排。

#### 493. 翻转对

<https://leetcode.cn/problems/reverse-pairs/>

给定一个数组  nums ，如果  i < j  且  nums\[i] > 2\*nums\[j]  我们就将  (i, j)  称作一个重要翻转对。

你需要返回给定数组中的重要翻转对的数量。

```cpp
class Solution {
public:
    vector<int> tmp;
    int count = 0;
    int reversePairs(vector<int>& nums) {
        tmp = vector<int>(nums.size());
        sort(nums, 0, nums.size() - 1);
        return count;
    }

    void sort(vector<int>& nums, int l, int r) {
        if(l == r) return;
        int mid = (l + r) / 2;
        sort(nums, l, mid);
        sort(nums, mid + 1, r);
        merge(nums, l, mid, r);
    }

    void merge(vector<int>& nums, int l, int mid, int r) {
        for(int i = l; i <= r; i++) tmp[i] = nums[i];
        int end = mid + 1;
        for(int i = l; i <= mid; i++) {
            while(end <= r && (long)nums[i] > (long)nums[end] * 2) end++;
            count += end - (mid + 1);
        }
        int i = l, j = mid + 1;
        for(int p = l; p <= r; p++) {
            if(i == mid + 1) nums[p] = tmp[j++];
            else if(j == r + 1) nums[p] = tmp[i++];
            else if(tmp[i] > tmp[j]) nums[p] = tmp[j++];
            else nums[p] = tmp[i++];
        }
    }
};
```

归并排序加几行代码即可。

#### 215. 数组中的第 K 个最大元素

<https://leetcode.cn/problems/kth-largest-element-in-an-array/>

给定整数数组 nums 和整数 k，请返回数组中第 k 个最大的元素。

请注意，你需要找的是数组排序后的第 k 个最大的元素，而不是第 k 个不同的元素。

你必须设计并实现时间复杂度为 O(n) 的算法解决此问题。

```cpp
class Solution {
public:
    int findKthLargest(vector<int>& nums, int k) {
        quick_sort(nums, 0, nums.size() - 1);
        return nums[nums.size() - k];
    }

    void quick_sort(vector<int>& nums, int l, int r) {
        if(l >= r) return;
        int x = nums[l + r >> 1], i = l - 1, j = r + 1;
        while(i < j) {
            do i++; while(nums[i] < x);
            do j--; while(nums[j] > x);
            if(i < j) swap(nums[i], nums[j]);
        }
        quick_sort(nums, l, j);
        quick_sort(nums, j + 1, r);
    }
};
```

#### 42. 接雨水

<https://leetcode.cn/problems/trapping-rain-water/>

给定  n 个非负整数表示每个宽度为 1 的柱子的高度图，计算按此排列的柱子，下雨之后能接多少雨水。

```cpp
class Solution {
public:
    int trap(vector<int>& height) {
        int l = 0, r = height.size() - 1;
        int l_max = 0, r_max = 0, res = 0;
        while(l < r) {
            l_max = max(l_max, height[l]);
            r_max = max(r_max, height[r]);
            if(l_max < r_max) res += l_max - height[l++];
            else res += r_max - height[r--];
        }
        return res;
    }
};
```

#### 31. 下一个排列

<https://leetcode.cn/problems/longest-common-subsequence/>

整数数组的一个 排列   就是将其所有成员以序列或线性顺序排列。

例如，arr = \[1,2,3] ，以下这些都可以视作 arr 的排列：\[1,2,3]、\[1,3,2]、\[3,1,2]、\[2,3,1] 。 整数数组的 下一个排列 是指其整数的下一个字典序更大的排列。更正式地，如果数组的所有排列根据其字典顺序从小到大排列在一个容器中，那么数组的 下一个排列 就是在这个有序容器中排在它后面的那个排列。如果不存在下一个更大的排列，那么这个数组必须重排为字典序最小的排列（即，其元素按升序排列）。

例如，arr = \[1,2,3] 的下一个排列是 \[1,3,2] 。 类似地，arr = \[2,3,1] 的下一个排列是 \[3,1,2] 。 而 arr = \[3,2,1] 的下一个排列是 \[1,2,3] ，因为 \[3,2,1] 不存在一个字典序更大的排列。 给你一个整数数组 nums ，找出 nums 的下一个排列。

必须 原地 修改，只允许使用额外常数空间。

```cpp
class Solution {
public:
    void nextPermutation(vector<int>& nums) {
        int k = nums.size() - 1;
        while(k > 0 && nums[k - 1] >= nums[k]) k--;
        if(k <= 0) reverse(nums.begin(), nums.end());
        else {
            int t = k;
            while(t < nums.size() && nums[t] > nums[k - 1]) t++;
            swap(nums[t - 1], nums[k - 1]);
            reverse(nums.begin() + k, nums.end());
        }
    }
};
```

#### 165. 比较版本号

<https://leetcode.cn/problems/compare-version-numbers/>

给你两个版本号 version1 和 version2 ，请你比较它们。

版本号由一个或多个修订号组成，各修订号由一个 '.' 连接。每个修订号由 多位数字 组成，可能包含 前导零 。每个版本号至少包含一个字符。修订号从左到右编号，下标从 0 开始，最左边的修订号下标为 0 ，下一个修订号下标为 1 ，以此类推。例如，2.5.33 和 0.1 都是有效的版本号。

比较版本号时，请按从左到右的顺序依次比较它们的修订号。比较修订号时，只需比较 忽略任何前导零后的整数值 。也就是说，修订号 1 和修订号 001 相等 。如果版本号没有指定某个下标处的修订号，则该修订号视为 0 。例如，版本 1.0 小于版本 1.1 ，因为它们下标为 0 的修订号相同，而下标为 1 的修订号分别为 0 和 1 ，0 < 1 。

返回规则如下：

如果  version1 > version2  返回  1， 如果  version1 < version2 返回 -1， 除此之外返回 0。

```cpp
class Solution {
public:
    int compareVersion(string v1, string v2) {
        for(int i = 0, j = 0; i < v1.size() || j < v2.size();) {
            int a = i, b = j;
            while(a < v1.size() && v1[a] != '.') a++;
            while(b < v2.size() && v2[b] != '.') b++;
            int m = a == i ? 0 : stoi(v1.substr(i, a - i));
            int n = b == j ? 0 : stoi(v2.substr(j, b - j));
            if(m > n) return 1;
            if(m < n) return -1;
            i = a + 1, j = b + 1;
        }
        return 0;
    }
};
```

#### 43. 字符串相乘

<https://leetcode.cn/problems/multiply-strings/>

给定两个以字符串形式表示的非负整数  num1  和  num2，返回  num1  和  num2  的乘积，它们的乘积也表示为字符串形式。

```cpp
class Solution {
public:
    string multiply(string num1, string num2) {
        int m = num1.size(), n = num2.size();
        vector<int> res(m + n, 0);
        for(int i = m - 1; i >= 0; i--) {
            for(int j = n - 1; j >= 0; j--) {
                int mul = (num1[i] - '0') * (num2[j] - '0');
                int r1 = i + j, r2 = i + j + 1;
                int sum = mul + res[r2];
                res[r2] = sum % 10;
                res[r1] += sum / 10;
            }
        }
        int i = 0;
        string str;
        while(i < res.size() && res[i] == 0) i++;
        for(; i < res.size(); i++) str.push_back('0' + res[i]);
        return str.size() == 0 ? "0" : str;
    }
};
```

#### 240. 搜索二维矩阵 II

<https://leetcode.cn/problems/search-a-2d-matrix-ii/>

编写一个高效的算法来搜索  m x n  矩阵 matrix 中的一个目标值 target 。该矩阵具有以下特性：

每行的元素从左到右升序排列。 每列的元素从上到下升序排列。

```cpp
class Solution {
public:
    bool searchMatrix(vector<vector<int>>& matrix, int target) {
       int m = matrix.size(), n = matrix[0].size();
       int i = 0, j = n - 1;
       while(i < m && j >= 0) {
           if(matrix[i][j] == target) return true;
           else if(matrix[i][j] > target) j--;
           else i++;
       }
       return false;
    }
};
```

#### 209. 长度最小的子数组

<https://leetcode.cn/problems/minimum-size-subarray-sum/>

给定一个含有  n  个正整数的数组和一个正整数 target 。

找出该数组中满足其和 ≥ target 的长度最小的 连续子数组  \[numsl, numsl+1, ..., numsr-1, numsr] ，并返回其长度。如果不存在符合条件的子数组，返回 0 。

```cpp
class Solution {
public:
    int minSubArrayLen(int target, vector<int>& nums) {
        int r = 0, l = 0, sum = 0, res = INT_MAX;
        while(r < nums.size()) {
            sum += nums[r++];
            while(sum >= target) {
                res = min(res, r - l);
                sum -= nums[l++];
            }
        }
        return res == INT_MAX ? 0 : res;
    }
};
```

#### 11. 盛最多水的容器

<https://leetcode.cn/problems/container-with-most-water/>

给定一个长度为 n 的整数数组  height 。有  n  条垂线，第 i 条线的两个端点是  (i, 0)  和  (i, height\[i]) 。

找出其中的两条线，使得它们与  x  轴共同构成的容器可以容纳最多的水。

返回容器可以储存的最大水量。

说明：你不能倾斜容器。

```cpp
class Solution {
public:
    int maxArea(vector<int>& height) {
        int l = 0, r = height.size() - 1;
        int res = INT_MIN;
        while(l < r) {
            int s = min(height[l], height[r]) * (r - l);
            res = max(res, s);
            if(height[l] < height[r]) l++;
            else r--;
        }
        return res;
    }
};
```


# 数学

#### 566. 重塑矩阵

<https://leetcode.cn/problems/reshape-the-matrix/>

在 MATLAB 中，有一个非常有用的函数 reshape ，它可以将一个 m x n 矩阵重塑为另一个大小不同（r x c）的新矩阵，但保留其原始数据。

给你一个由二维数组 mat 表示的 m x n 矩阵，以及两个正整数 r 和 c ，分别表示想要的重构的矩阵的行数和列数。

重构后的矩阵需要将原始矩阵的所有元素以相同的 行遍历顺序 填充。

如果具有给定参数的 reshape 操作是可行且合理的，则输出新的重塑矩阵；否则，输出原始矩阵。

```cpp
class Solution {
public:
    vector<vector<int>> matrixReshape(vector<vector<int>>& nums, int r, int c) {
        int n = nums.size(), m = nums[0].size();
        if (n * m != r * c) return nums;
        vector<vector<int>> res(r, vector<int>(c));
        for (int i = 0; i < n * m; i ++ )
            res[i / c][i % c] = nums[i / m][i % m];
        return res;
    }
};
```

#### 263. 丑数

<https://leetcode.cn/problems/ugly-number/>

丑数 就是只包含质因数 2、3 和 5 的正整数。

给你一个整数 n ，请你判断 n 是否为 丑数 。如果是，返回 true ；否则，返回 false 。

```cpp
class Solution {
public:
    bool isUgly(int n) {
        if(n < 1) return false;
        while(n % 2 == 0) n /= 2;
        while(n % 3 == 0) n /= 3;
        while(n % 5 == 0) n /= 5;
        if(n == 1) return true;
        return false;
    }
};
```

#### 264. 丑数 II

<https://leetcode.cn/problems/ugly-number-ii/submissions/>

```cpp
class Solution {
public:
    int nthUglyNumber(int n) {
        int p1 = 1, p2 = 1, p3 = 1;
        int pd1 = 1, pd2 = 1, pd3 = 1;
        int p = 1;
        vector<int> ugly(n + 1);
        while(p <= n) {
            int minn = min(min(pd1, pd2), pd3);
            ugly[p++] = minn;
            if(pd1 == minn) pd1 = 2 * ugly[p1++];
            if(pd2 == minn) pd2 = 3 * ugly[p2++];
            if(pd3 == minn) pd3 = 5 * ugly[p3++];
        }
        return ugly[n];
    }
};
```

我们用 p1, p2, p3 分别代表三条丑数链表上的指针，用 pd1, pd2, pd3 代表丑数链表上节点的值，用 ugly 数组记录有序链表合并之后的结果。

#### 400. 第 N 位数字

<https://leetcode.cn/problems/nth-digit/>

给你一个整数 n ，请你在无限的整数序列 \[1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, ...] 中找出并返回第 n 位上的数字。

```cpp
class Solution {
public:
    int findNthDigit(int n) {
        int digit = 1;
        long base = 1;

        while(n > 9 * base * digit) {
            n -= 9 * base * digit;
            base *= 10;
            digit++;
        }

        long val = base + (n - 1) / digit;
        int index = (n - 1) % digit;
        string s = to_string(val);
        return s[index] - '0';
    }
};
```

找数学规律，一位数有几个？1\~9 共 9 \_ 1 = 9 个。共几位？共 1 \_ 9 = 9 位。

二位数有几个？10\~99 共 9 \_ 10 = 90 个。共几位？共 2 \_ 90 = 180 位。

三位数有几个？100\~999 共 9 \_ 100 = 900 个。共几位？共 3 \_ 900 = 2700 位。

以此类推，我们可以通过这个规律推断第 n 位的数字到底是什么。所以这道题的难点在于如何把上述规律写成算法代码。

#### 470. 用 Rand7() 实现 Rand10()

<https://leetcode.cn/problems/implement-rand10-using-rand7/>

给定方法  rand7  可生成 \[1,7] 范围内的均匀随机整数，试写一个方法  rand10  生成 \[1,10] 范围内的均匀随机整数。

你只能调用  rand7()  且不能调用其他方法。请不要使用系统的  Math.random()  方法。

每个测试用例将有一个内部参数 n，即你实现的函数 rand10() 在测试时将被调用的次数。请注意，这不是传递给 rand10() 的参数。

```cpp
// The rand7() API is already defined for you.
// int rand7();
// @return a random integer in the range 1 to 7

class Solution {
public:
    int rand10() {
        int t = (rand7() - 1) * 7 + rand7();
        if(t > 40) return rand10();
        return (t - 1) % 10 + 1;
    }
};
```

#### 48. 旋转图像

<https://leetcode.cn/problems/rotate-image/>

给定一个 n × n 的二维矩阵  matrix 表示一个图像。请你将图像顺时针旋转 90 度。

你必须在 原地 旋转图像，这意味着你需要直接修改输入的二维矩阵。请不要 使用另一个矩阵来旋转图像。

```cpp
class Solution {
public:
    void rotate(vector<vector<int>>& matrix) {
        int n = matrix.size();
        for (int i = 0, j = n - 1; i < j; i ++, j --) swap(matrix[i], matrix[j]);
        for(int i = 0; i < n; i++) for(int j = i; j < n; j++) swap(matrix[i][j], matrix[j][i]);
    }
};
```


# 数据结构

#### 19. 删除链表的倒数第 N 个结点

<https://leetcode.cn/problems/remove-nth-node-from-end-of-list/>

给你一个链表，删除链表的倒数第 `n` 个结点，并且返回链表的头结点。

```cpp
/**
 * Definition for singly-linked list.
 * struct ListNode {
 *     int val;
 *     ListNode *next;
 *     ListNode() : val(0), next(nullptr) {}
 *     ListNode(int x) : val(x), next(nullptr) {}
 *     ListNode(int x, ListNode *next) : val(x), next(next) {}
 * };
 */
class Solution {
public:
    ListNode* removeNthFromEnd(ListNode* head, int n) {
        auto fake = new ListNode();
        fake->next = head;

        int cnt = 0;
        while(head){
            head = head->next;
            cnt++;
        }

        auto p = fake;
        for(int i = 0; i < cnt - n; i++){
            p = p->next;
        }
        p->next = p->next->next;
        return fake->next;
    }
};
```

难点在于找到倒数第 n 个结点，通过遍历 head 获得结点数，再从头遍历即可。值得一提的是需要用一个虚拟头结点进行操作，这样可以防止头结点被更改的问题。

#### 21. 合并两个有序链表

<https://leetcode.cn/problems/merge-two-sorted-lists/>

将两个升序链表合并为一个新的 **升序** 链表并返回。新链表是通过拼接给定的两个链表的所有节点组成的。

```cpp
/**
 * Definition for singly-linked list.
 * struct ListNode {
 *     int val;
 *     ListNode *next;
 *     ListNode() : val(0), next(nullptr) {}
 *     ListNode(int x) : val(x), next(nullptr) {}
 *     ListNode(int x, ListNode *next) : val(x), next(next) {}
 * };
 */
class Solution {
public:
    ListNode* mergeTwoLists(ListNode* l1, ListNode* l2) {
        auto dummy = new ListNode(-1), tail = dummy;
        while(l1 && l2){
            if(l1->val > l2->val){
                tail = tail->next = l2;
                l2 = l2->next;
            }else{
                tail = tail->next = l1;
                l1 = l1->next;
            }
        }
       if(l1) tail->next = l1;
       if(l2) tail->next = l2;
       return dummy->next;
    }
};
```

主要是设置一个虚拟头结点即可，然后从头到尾遍历两个链表就 OK 了。

#### 206. 反转链表

<https://leetcode.cn/problems/reverse-linked-list/>

给你单链表的头节点 `head` ，请你反转链表，并返回反转后的链表。

```cpp
/**
 * Definition for singly-linked list.
 * struct ListNode {
 *     int val;
 *     ListNode *next;
 *     ListNode() : val(0), next(nullptr) {}
 *     ListNode(int x) : val(x), next(nullptr) {}
 *     ListNode(int x, ListNode *next) : val(x), next(next) {}
 * };
 */
class Solution {
public:
    ListNode* reverseList(ListNode* head) {
         if(!head) return NULL;
         auto a = head, b = a->next;
         while(b){
             auto tmp = b->next;
             b->next = a;
             a = b;
             b = tmp;
         }
         head->next = NULL;
         return a;
    }
};
```

画图就能搞懂，主要在于 `b->next = a` 相当于指反过来，然后 a 和 b 都往后移，直到 b 为 NULL。

#### 203. 移除链表元素

<https://leetcode.cn/problems/remove-linked-list-elements/>

给你一个链表的头节点 `head` 和一个整数 `val` ，请你删除链表中所有满足 `Node.val == val` 的节点，并返回 **新的头节点** 。

```cpp
/**
 * Definition for singly-linked list.
 * struct ListNode {
 *     int val;
 *     ListNode *next;
 *     ListNode() : val(0), next(nullptr) {}
 *     ListNode(int x) : val(x), next(nullptr) {}
 *     ListNode(int x, ListNode *next) : val(x), next(next) {}
 * };
 */
class Solution {
public:
    ListNode* removeElements(ListNode* head, int val) {
        auto dummy = new ListNode(-1);
        dummy->next = head;
        for(auto p = dummy; p; p = p->next){
            auto q = p->next;
            while(q && q->val == val) q = q->next;
            p->next = q;
        }
        return dummy->next;
    }
};
```

用一个多的指针，当遇到需要删除的数字时就开始判断，判断结束后让前面的指针指向后面的那个指针即可。

#### 83. 删除排序链表中的重复元素

<https://leetcode.cn/problems/remove-duplicates-from-sorted-list/>

给定一个已排序的链表的头 `head` ， *删除所有重复的元素，使每个元素只出现一次* 。返回 *已排序的链表* 。

```cpp
/**
 * Definition for singly-linked list.
 * struct ListNode {
 *     int val;
 *     ListNode *next;
 *     ListNode() : val(0), next(nullptr) {}
 *     ListNode(int x) : val(x), next(nullptr) {}
 *     ListNode(int x, ListNode *next) : val(x), next(next) {}
 * };
 */
class Solution {
public:
    ListNode* deleteDuplicates(ListNode* head) {
        if(!head) return head;
        auto dummy = head;
        while(dummy->next){
            if(dummy->val == dummy->next->val) dummy->next = dummy->next->next;
            else dummy = dummy->next;
        }
        return head;
    }
};
```

如果值相同就跳到下下个结点，相当于把值进行了删除。

```cpp
/**
 * Definition for singly-linked list.
 * struct ListNode {
 *     int val;
 *     ListNode *next;
 *     ListNode() : val(0), next(nullptr) {}
 *     ListNode(int x) : val(x), next(nullptr) {}
 *     ListNode(int x, ListNode *next) : val(x), next(next) {}
 * };
 */
class Solution {
public:
    ListNode* deleteDuplicates(ListNode* head) {
        if(!head) return head;
        auto fast = head, slow = head;
        while(fast) {
            if(fast->val != slow->val) {
                slow->next = fast;
                slow = slow->next;
            }
            fast = fast->next;
        }
        slow->next = NULL;
        return head;
    }
};
```

更清晰的写法，使用快慢指针，若值不同时将慢指针的下一位指向快指针即可。

#### 20. 有效的括号

<https://leetcode.cn/problems/valid-parentheses/>

给定一个只包括 '('，')'，'{'，'}'，'\['，']' 的字符串 s ，判断字符串是否有效。

有效字符串需满足：

左括号必须用相同类型的右括号闭合。 左括号必须以正确的顺序闭合。 每个右括号都有一个对应的相同类型的左括号。

```cpp
class Solution {
public:
    bool isValid(string s) {
        stack<char> stk;
        for(auto c : s){
            if(c == '(' || c == '[' || c == '{') stk.push(c);
            else {
                if (stk.size() && abs(stk.top() - c) <= 2) stk.pop();
                else return false;
            }
        }
        return stk.empty();
    }
};
```

因为括号之间的 ASCII 码的差都在 2 以内，所以可以通过这个进行判断是否匹配。很简单的一个栈的问题。

#### 707. 设计链表

<https://leetcode.cn/problems/design-linked-list/>

设计链表的实现。您可以选择使用单链表或双链表。单链表中的节点应该具有两个属性：val  和  next。val  是当前节点的值，next  是指向下一个节点的指针/引用。如果要使用双向链表，则还需要一个属性  prev  以指示链表中的上一个节点。假设链表中的所有节点都是 0-index 的。

在链表类中实现这些功能：

get(index)：获取链表中第  index  个节点的值。如果索引无效，则返回-1。 addAtHead(val)：在链表的第一个元素之前添加一个值为  val  的节点。插入后，新节点将成为链表的第一个节点。 addAtTail(val)：将值为  val 的节点追加到链表的最后一个元素。 addAtIndex(index,val)：在链表中的第  index  个节点之前添加值为  val  的节点。如果  index  等于链表的长度，则该节点将附加到链表的末尾。如果 index 大于链表长度，则不会插入节点。如果 index 小于 0，则在头部插入节点。 deleteAtIndex(index)：如果索引  index 有效，则删除链表中的第  index 个节点。

```cpp
class MyLinkedList {
public:
    // 定义链表节点结构体
    struct LinkedNode {
        int val;
        LinkedNode* next;
        LinkedNode(int val):val(val), next(nullptr){}
    };

    // 初始化链表
    MyLinkedList() {
        _dummyHead = new LinkedNode(0); // 这里定义的头结点 是一个虚拟头结点，而不是真正的链表头结点
        _size = 0;
    }

    // 获取到第index个节点数值，如果index是非法数值直接返回-1， 注意index是从0开始的，第0个节点就是头结点
    int get(int index) {
        if (index > (_size - 1) || index < 0) {
            return -1;
        }
        LinkedNode* cur = _dummyHead->next;
        while(index--){ // 如果--index 就会陷入死循环
            cur = cur->next;
        }
        return cur->val;
    }

    // 在链表最前面插入一个节点，插入完成后，新插入的节点为链表的新的头结点
    void addAtHead(int val) {
        LinkedNode* newNode = new LinkedNode(val);
        newNode->next = _dummyHead->next;
        _dummyHead->next = newNode;
        _size++;
    }

    // 在链表最后面添加一个节点
    void addAtTail(int val) {
        LinkedNode* newNode = new LinkedNode(val);
        LinkedNode* cur = _dummyHead;
        while(cur->next != nullptr){
            cur = cur->next;
        }
        cur->next = newNode;
        _size++;
    }

    // 在第index个节点之前插入一个新节点，例如index为0，那么新插入的节点为链表的新头节点。
    // 如果index 等于链表的长度，则说明是新插入的节点为链表的尾结点
    // 如果index大于链表的长度，则返回空
    // 如果index小于0，则在头部插入节点
    void addAtIndex(int index, int val) {

        if(index > _size) return;
        if(index < 0) index = 0;
        LinkedNode* newNode = new LinkedNode(val);
        LinkedNode* cur = _dummyHead;
        while(index--) {
            cur = cur->next;
        }
        newNode->next = cur->next;
        cur->next = newNode;
        _size++;
    }

    // 删除第index个节点，如果index 大于等于链表的长度，直接return，注意index是从0开始的
    void deleteAtIndex(int index) {
        if (index >= _size || index < 0) {
            return;
        }
        LinkedNode* cur = _dummyHead;
        while(index--) {
            cur = cur ->next;
        }
        LinkedNode* tmp = cur->next;
        cur->next = cur->next->next;
        delete tmp;
        _size--;
    }
};
```

有空多练，经典链表操作。

#### 24. 两两交换链表中的节点

<https://leetcode.cn/problems/swap-nodes-in-pairs/>

给你一个链表，两两交换其中相邻的节点，并返回交换后链表的头节点。你必须在不修改节点内部的值的情况下完成本题（即，只能进行节点交换）。

```cpp
/**
 * Definition for singly-linked list.
 * struct ListNode {
 *     int val;
 *     ListNode *next;
 *     ListNode() : val(0), next(nullptr) {}
 *     ListNode(int x) : val(x), next(nullptr) {}
 *     ListNode(int x, ListNode *next) : val(x), next(next) {}
 * };
 */
class Solution {
public:
    ListNode* swapPairs(ListNode* head) {
        auto dummy = new ListNode();
        dummy->next = head;
        auto p = dummy;
        while(p->next != NULL && p->next->next != NULL){
            auto n1 = p->next, n2 = p->next->next;
            n1->next = n2->next;
            n2->next = n1;
            p->next = n2;
            p = n1;
        }
        return dummy->next;
    }
};
```

主要是一个画图的问题，在图上理解清楚交换的逻辑就简单了。

#### 160. 相交链表

<https://leetcode.cn/problems/intersection-of-two-linked-lists/>

给你两个单链表的头节点 headA 和 headB ，请你找出并返回两个单链表相交的起始节点。如果两个链表不存在相交节点，返回 null 。

```cpp
/**
 * Definition for singly-linked list.
 * struct ListNode {
 *     int val;
 *     ListNode *next;
 *     ListNode(int x) : val(x), next(NULL) {}
 * };
 */
class Solution {
public:
    ListNode *getIntersectionNode(ListNode *headA, ListNode *headB) {
        auto a = headA, b = headB;
        while(a != b){
            a = a ? a->next : headB;
            b = b ? b->next : headA;
        }
        return a;
    }
};
```

做法很简单，但是想到不容易，把自己的跑完就去跑另外一个链表，最后相遇的时候距离相等刚好就是相交的节点。

#### 142. 环形链表 II

<https://leetcode.cn/problems/linked-list-cycle-ii/>

给定一个链表的头节点  head ，返回链表开始入环的第一个节点。  如果链表无环，则返回  null。

如果链表中有某个节点，可以通过连续跟踪 next 指针再次到达，则链表中存在环。 为了表示给定链表中的环，评测系统内部使用整数 pos 来表示链表尾连接到链表中的位置（索引从 0 开始）。如果 pos 是 -1，则在该链表中没有环。注意：pos 不作为参数进行传递，仅仅是为了标识链表的实际情况。

```cpp
/**
 * Definition for singly-linked list.
 * struct ListNode {
 *     int val;
 *     ListNode *next;
 *     ListNode(int x) : val(x), next(NULL) {}
 * };
 */
class Solution {
public:
    ListNode *detectCycle(ListNode *head) {
        if(!head || !head->next) return NULL;
        auto s = head, f = head->next;
        while(f->next && f->next->next){
            s = s->next, f = f->next->next;
            if(s == f){
                s = head, f = f->next;
                while(s != f){
                    s = s->next;
                    f = f->next;
                }
                return s;
            }
        }
        return NULL;
    }
};
```

画图才能解决，这道题比较绕，简单说就是到相遇点之后，让一个指针回 head 那个位置，然后两个指针以相同的速度重新跑，最后相遇点就是我们的答案。

```cpp
/**
 * Definition for singly-linked list.
 * struct ListNode {
 *     int val;
 *     ListNode *next;
 *     ListNode(int x) : val(x), next(NULL) {}
 * };
 */
class Solution {
public:
    ListNode *detectCycle(ListNode *head) {
        auto p = head, q = head;
        while(q && q->next){
            p = p->next;
            q = q->next->next;
            if(p == q) break;
        }
        if(!q || !q->next) return NULL;
        p = head;
        while(p != q){
            p = p->next;
            q = q->next;
        }
        return p;
    }
};
```

更好的代码，可以看到，当快慢指针相遇时，让其中任一个指针指向头节点，然后让它俩以相同速度前进，再次相遇时所在的节点位置就是环开始的位置。

#### 232. 用栈实现队列

<https://leetcode.cn/problems/implement-queue-using-stacks/>

请你仅使用两个栈实现先入先出队列。队列应当支持一般队列支持的所有操作（push、pop、peek、empty）：

实现 MyQueue 类：

void push(int x) 将元素 x 推到队列的末尾 int pop() 从队列的开头移除并返回元素 int peek() 返回队列开头的元素 boolean empty() 如果队列为空，返回 true ；否则，返回 false 说明：

你 只能 使用标准的栈操作 —— 也就是只有  push to top, peek/pop from top, size, 和  is empty  操作是合法的。 你所使用的语言也许不支持栈。你可以使用 list 或者 deque（双端队列）来模拟一个栈，只要是标准的栈操作即可。

```cpp
class MyQueue {
public:
    stack<int> a, b;
    MyQueue() {}

    void push(int x) {
        a.push(x);
    }

    int pop() {
        while(a.size() > 1) b.push(a.top()), a.pop();
        int t = a.top();
        a.pop();
        while(b.size()) a.push(b.top()), b.pop();
        return t;
    }

    int peek() {
        while(a.size() > 1) b.push(a.top()), a.pop();
        int t = a.top();
        while(b.size()) a.push(b.top()), b.pop();
        return t;
    }

    bool empty() {
        return a.empty();
    }
};

/**
 * Your MyQueue object will be instantiated and called as such:
 * MyQueue* obj = new MyQueue();
 * obj->push(x);
 * int param_2 = obj->pop();
 * int param_3 = obj->peek();
 * bool param_4 = obj->empty();
 */
```

难点在于对 `pop()` 和 `peek` 的操作，用一个临时栈来存放最后一个值以外的元素，最后再将其打回即可。

```cpp
class MyQueue {
public:
    stack<int> s1, s2;
    MyQueue() {}

    void push(int x) {
        s2.push(x);
    }

    int pop() {
        if(s1.empty()) {
            while(!s2.empty()) {
                s1.push(s2.top());
                s2.pop();
            }
        }
        int tmp = s1.top();
        s1.pop();
        return tmp;
    }

    int peek() {
        if(s1.empty()) {
            while(!s2.empty()) {
                s1.push(s2.top());
                s2.pop();
            }
        }
        return s1.top();
    }

    bool empty() {
        return s1.empty() && s2.empty();
    }
};

/**
 * Your MyQueue object will be instantiated and called as such:
 * MyQueue* obj = new MyQueue();
 * obj->push(x);
 * int param_2 = obj->pop();
 * int param_3 = obj->peek();
 * bool param_4 = obj->empty();
 */
```

感觉更好理解。

#### 225. 用队列实现栈

<https://leetcode.cn/problems/implement-stack-using-queues/>

请你仅使用两个队列实现一个后入先出（LIFO）的栈，并支持普通栈的全部四种操作（push、top、pop 和 empty）。

实现 MyStack 类：

void push(int x) 将元素 x 压入栈顶。 int pop() 移除并返回栈顶元素。 int top() 返回栈顶元素。 boolean empty() 如果栈是空的，返回 true ；否则，返回 false 。

注意：

你只能使用队列的基本操作 —— 也就是  push to back、peek/pop from front、size 和  is empty  这些操作。 你所使用的语言也许不支持队列。  你可以使用 list （列表）或者 deque（双端队列）来模拟一个队列  , 只要是标准的队列操作即可。

```cpp
class MyStack {
public:
    queue<int> a, b;
    MyStack() {

    }

    void push(int x) {
        a.push(x);
    }

    int pop() {
        while(a.size() > 1) b.push(a.front()), a.pop();
        int t = a.front();
        a.pop();
        while(b.size()) a.push(b.front()), b.pop();
        return t;
    }

    int top() {
        while(a.size() > 1) b.push(a.front()), a.pop();
        int t = a.front();
        a.pop();
        while(b.size()) a.push(b.front()), b.pop();
        a.push(t);
        return t;
    }

    bool empty() {
        return a.empty();
    }
};

/**
 * Your MyStack object will be instantiated and called as such:
 * MyStack* obj = new MyStack();
 * obj->push(x);
 * int param_2 = obj->pop();
 * int param_3 = obj->top();
 * bool param_4 = obj->empty();
 */
```

和上题类似。有一点点逻辑的不同，看代码就懂了。

#### 1047. 删除字符串中的所有相邻重复项

<https://leetcode.cn/problems/remove-all-adjacent-duplicates-in-string/>

给出由小写字母组成的字符串  S，重复项删除操作会选择两个相邻且相同的字母，并删除它们。

在 S 上反复执行重复项删除操作，直到无法继续删除。

在完成所有重复项删除操作后返回最终的字符串。答案保证唯一。

```cpp
class Solution {
public:
    string removeDuplicates(string s) {
        string res;
        for(auto c : s){
            if(res.size() && res.back() == c) res.pop_back();
            else res += c;
        }
        return res;
    }
};
```

用栈实现即可。

#### 144. 二叉树的前序遍历

<https://leetcode.cn/problems/binary-tree-preorder-traversal/>

给你二叉树的根节点 root ，返回它节点值的 前序 遍历。

```cpp
/**
 * Definition for a binary tree node.
 * struct TreeNode {
 *     int val;
 *     TreeNode *left;
 *     TreeNode *right;
 *     TreeNode() : val(0), left(nullptr), right(nullptr) {}
 *     TreeNode(int x) : val(x), left(nullptr), right(nullptr) {}
 *     TreeNode(int x, TreeNode *left, TreeNode *right) : val(x), left(left), right(right) {}
 * };
 */
class Solution {
public:
    void traversal(TreeNode* cur, vector<int> &res){
        if(!cur) return;
        vec.push_back(cur->val);
        traversal(cur->left, res);
        traversal(cur->right, res);
    }
    vector<int> preorderTraversal(TreeNode* root) {
        vector<int> res;
        traversal(root, res);
        return res;
    }
};
```

递归即可。

```cpp
/**
 * Definition for a binary tree node.
 * struct TreeNode {
 *     int val;
 *     TreeNode *left;
 *     TreeNode *right;
 *     TreeNode() : val(0), left(nullptr), right(nullptr) {}
 *     TreeNode(int x) : val(x), left(nullptr), right(nullptr) {}
 *     TreeNode(int x, TreeNode *left, TreeNode *right) : val(x), left(left), right(right) {}
 * };
 */
class Solution {
public:
    vector<int> preorderTraversal(TreeNode* root) {
        vector<int> res;
        stack<TreeNode*> stk;
        while(root || stk.size()){
            while(root){
                res.push_back(root->val);
                stk.push(root);
                root = root->left;
            }
            root = stk.top()->right;
            stk.pop();
        }
        return res;
    }
};
```

迭代法

#### 145. 二叉树的后序遍历

<https://leetcode.cn/problems/binary-tree-postorder-traversal/>

给你一棵二叉树的根节点 root ，返回其节点值的 后序遍历 。

```cpp
/**
 * Definition for a binary tree node.
 * struct TreeNode {
 *     int val;
 *     TreeNode *left;
 *     TreeNode *right;
 *     TreeNode() : val(0), left(nullptr), right(nullptr) {}
 *     TreeNode(int x) : val(x), left(nullptr), right(nullptr) {}
 *     TreeNode(int x, TreeNode *left, TreeNode *right) : val(x), left(left), right(right) {}
 * };
 */
class Solution {
public:
    void traversal(TreeNode *cur, vector<int>& res){
        if(!cur) return;
        traversal(cur->left, res);
        traversal(cur->right, res);
        res.push_back(cur->val);
    }
    vector<int> postorderTraversal(TreeNode* root) {
        vector<int> res;
        traversal(root, res);
        return res;
    }
};
```

```cpp
/**
 * Definition for a binary tree node.
 * struct TreeNode {
 *     int val;
 *     TreeNode *left;
 *     TreeNode *right;
 *     TreeNode() : val(0), left(nullptr), right(nullptr) {}
 *     TreeNode(int x) : val(x), left(nullptr), right(nullptr) {}
 *     TreeNode(int x, TreeNode *left, TreeNode *right) : val(x), left(left), right(right) {}
 * };
 */
class Solution {
public:
    vector<int> postorderTraversal(TreeNode* root) {
        vector<int> res;
        stack<TreeNode*> stk;
        while(root || stk.size()){
            while(root){
                res.push_back(root->val);
                stk.push(root);
                root = root->right;
            }
            root = stk.top()->left;
            stk.pop();
        }
        reverse(res.begin(), res.end());
        return res;
    }
};
```

#### 94. 二叉树的中序遍历

<https://leetcode.cn/problems/binary-tree-inorder-traversal/>

给定一个二叉树的根节点 root ，返回 它的 中序 遍历 。

```cpp
/**
 * Definition for a binary tree node.
 * struct TreeNode {
 *     int val;
 *     TreeNode *left;
 *     TreeNode *right;
 *     TreeNode() : val(0), left(nullptr), right(nullptr) {}
 *     TreeNode(int x) : val(x), left(nullptr), right(nullptr) {}
 *     TreeNode(int x, TreeNode *left, TreeNode *right) : val(x), left(left), right(right) {}
 * };
 */
class Solution {
public:
    vector<int> inorderTraversal(TreeNode* root) {
        vector<int> res;
        stack<TreeNode*> stk;
        while(root || stk.size()){
            while(root){
                stk.push(root);
                root = root->left;
            }
            root = stk.top();
            stk.pop();
            res.push_back(root->val);
            root = root->right;
        }
        return res;
    }
};
```

#### 102. 二叉树的层序遍历

<https://leetcode.cn/problems/binary-tree-level-order-traversal/>

给你二叉树的根节点 root ，返回其节点值的 层序遍历 。 （即逐层地，从左到右访问所有节点）。

```cpp
/**
 * Definition for a binary tree node.
 * struct TreeNode {
 *     int val;
 *     TreeNode *left;
 *     TreeNode *right;
 *     TreeNode() : val(0), left(nullptr), right(nullptr) {}
 *     TreeNode(int x) : val(x), left(nullptr), right(nullptr) {}
 *     TreeNode(int x, TreeNode *left, TreeNode *right) : val(x), left(left), right(right) {}
 * };
 */
class Solution {
public:
    vector<vector<int>> levelOrder(TreeNode* root) {
        vector<vector<int>> res;
        queue<TreeNode*> q;
        if(root) q.push(root);

        while(q.size()){
            vector<int> level;
            int len = q.size();
            while(len--){
                auto t = q.front();
                level.push_back(t->val);
                q.pop();
                if(t->left) q.push(t->left);
                if(t->right) q.push(t->right);
            }
            res.push_back(level);
        }
        return res;
    }
};
```

宽搜一下即可。

#### 107. 二叉树的层序遍历 II

<https://leetcode.cn/problems/binary-tree-level-order-traversal-ii/>

给你二叉树的根节点 root ，返回其节点值 自底向上的层序遍历 。 （即按从叶子节点所在层到根节点所在的层，逐层从左向右遍历）

```cpp
/**
 * Definition for a binary tree node.
 * struct TreeNode {
 *     int val;
 *     TreeNode *left;
 *     TreeNode *right;
 *     TreeNode() : val(0), left(nullptr), right(nullptr) {}
 *     TreeNode(int x) : val(x), left(nullptr), right(nullptr) {}
 *     TreeNode(int x, TreeNode *left, TreeNode *right) : val(x), left(left), right(right) {}
 * };
 */
class Solution {
public:
    vector<vector<int>> levelOrderBottom(TreeNode* root) {
        vector<vector<int>> res;
        queue<TreeNode*> q;
        if(root) q.push(root);

        while(q.size()){
            vector<int> level;
            int len = q.size();
            while(len--){
                auto t = q.front();
                q.pop();
                level.push_back(t->val);
                if(t->left) q.push(t->left);
                if(t->right) q.push(t->right);
            }
            res.push_back(level);
        }
        reverse(res.begin(), res.end());
        return res;
    }
};
```

相较上一题，翻转一下即可。

#### 199. 二叉树的右视图

<https://leetcode.cn/problems/binary-tree-right-side-view/>

给定一个二叉树的 根节点 root，想象自己站在它的右侧，按照从顶部到底部的顺序，返回从右侧所能看到的节点值。

```cpp
/**
 * Definition for a binary tree node.
 * struct TreeNode {
 *     int val;
 *     TreeNode *left;
 *     TreeNode *right;
 *     TreeNode() : val(0), left(nullptr), right(nullptr) {}
 *     TreeNode(int x) : val(x), left(nullptr), right(nullptr) {}
 *     TreeNode(int x, TreeNode *left, TreeNode *right) : val(x), left(left), right(right) {}
 * };
 */
class Solution {
public:
    vector<int> rightSideView(TreeNode* root) {
        vector<int> res;
        queue<TreeNode*> q;
        if(root) q.push(root);

        while(q.size()){
            int len = q.size();
            while(len--){
                auto t = q.front();
                q.pop();
                if(!len) res.push_back(t->val);
                if(t->left) q.push(t->left);
                if(t->right) q.push(t->right);
            }
        }
        return res;
    }
};
```

用宽搜，然后找到每一排最后一个即可。

#### 104. 二叉树的最大深度

<https://leetcode.cn/problems/maximum-depth-of-binary-tree/>

给定一个二叉树，找出其最大深度。

二叉树的深度为根节点到最远叶子节点的最长路径上的节点数。

说明: 叶子节点是指没有子节点的节点。

```cpp
/**
 * Definition for a binary tree node.
 * struct TreeNode {
 *     int val;
 *     TreeNode *left;
 *     TreeNode *right;
 *     TreeNode() : val(0), left(nullptr), right(nullptr) {}
 *     TreeNode(int x) : val(x), left(nullptr), right(nullptr) {}
 *     TreeNode(int x, TreeNode *left, TreeNode *right) : val(x), left(left), right(right) {}
 * };
 */
class Solution {
public:
    int maxDepth(TreeNode* root) {
        int res = 0;
        queue<TreeNode*> q;
        if(root) q.push(root);

        while(q.size()){
            int len = q.size();
            res++;
            while(len--){
                auto t = q.front();
                q.pop();
                if(t->left) q.push(t->left);
                if(t->right) q.push(t->right);
            }
        }
        return res;
    }
};
```

一样的宽搜，每一层给结果加一即可。

```cpp
/**
 * Definition for a binary tree node.
 * struct TreeNode {
 *     int val;
 *     TreeNode *left;
 *     TreeNode *right;
 *     TreeNode() : val(0), left(nullptr), right(nullptr) {}
 *     TreeNode(int x) : val(x), left(nullptr), right(nullptr) {}
 *     TreeNode(int x, TreeNode *left, TreeNode *right) : val(x), left(left), right(right) {}
 * };
 */
class Solution {
public:
    int res = 0;
    int depth = 0;
    int maxDepth(TreeNode* root) {
        dfs(root);
        return res;
    }

    void dfs(TreeNode* root){
        if(!root) return;
        depth++;
        if(!root->left && !root->right) res = max(res, depth);
        dfs(root->left);
        dfs(root->right);
        depth--;
    }
};
```

用迭代的方法做，更有普遍性。

#### 111. 二叉树的最小深度

<https://leetcode.cn/problems/minimum-depth-of-binary-tree/>

给定一个二叉树，找出其最小深度。

最小深度是从根节点到最近叶子节点的最短路径上的节点数量。

说明：叶子节点是指没有子节点的节点。

```cpp
/**
 * Definition for a binary tree node.
 * struct TreeNode {
 *     int val;
 *     TreeNode *left;
 *     TreeNode *right;
 *     TreeNode() : val(0), left(nullptr), right(nullptr) {}
 *     TreeNode(int x) : val(x), left(nullptr), right(nullptr) {}
 *     TreeNode(int x, TreeNode *left, TreeNode *right) : val(x), left(left), right(right) {}
 * };
 */
class Solution {
public:
    int minDepth(TreeNode* root) {
        int res = 0;
        queue<TreeNode*> q;
        if(root) q.push(root);

        while(q.size()){
            int len = q.size();
            res++;
            while(len--){
                auto t = q.front();
                q.pop();
                if(!t->left && !t->right) return res;
                if(t->left) q.push(t->left);
                if(t->right) q.push(t->right);
            }
        }
        return res;
    }
};
```

在上一题的基础上，只要遇到叶子结点就返回答案。

#### 226. 翻转二叉树

<https://leetcode.cn/problems/invert-binary-tree/>

给你一棵二叉树的根节点 root ，翻转这棵二叉树，并返回其根节点。

```cpp
/**
 * Definition for a binary tree node.
 * struct TreeNode {
 *     int val;
 *     TreeNode *left;
 *     TreeNode *right;
 *     TreeNode() : val(0), left(nullptr), right(nullptr) {}
 *     TreeNode(int x) : val(x), left(nullptr), right(nullptr) {}
 *     TreeNode(int x, TreeNode *left, TreeNode *right) : val(x), left(left), right(right) {}
 * };
 */
class Solution {
public:
    TreeNode* invertTree(TreeNode* root) {
        if(!root) return NULL;
        swap(root->left, root->right);
        invertTree(root->left);
        invertTree(root->right);
        return root;
    }
};
```

递归翻转左右子树即可。

#### 101. 对称二叉树

<https://leetcode.cn/problems/symmetric-tree/>

给你一个二叉树的根节点 root ， 检查它是否轴对称。

```cpp
/**
 * Definition for a binary tree node.
 * struct TreeNode {
 *     int val;
 *     TreeNode *left;
 *     TreeNode *right;
 *     TreeNode() : val(0), left(nullptr), right(nullptr) {}
 *     TreeNode(int x) : val(x), left(nullptr), right(nullptr) {}
 *     TreeNode(int x, TreeNode *left, TreeNode *right) : val(x), left(left), right(right) {}
 * };
 */
class Solution {
public:
    bool isSymmetric(TreeNode* root) {
        if(!root) return true;
        return dfs(root->left, root->right);
    }

    bool dfs(TreeNode* left, TreeNode* right){
        if(!left && !right) return true;
        if(!left || !right || left->val != right->val) return false;
        return dfs(left->right, right->left) && dfs(left->left, right->right);
    }
};
```

爆搜左右两边，然后分别比较。

#### 222. 完全二叉树的节点个数

给你一棵 完全二叉树 的根节点 root ，求出该树的节点个数。

完全二叉树 的定义如下：在完全二叉树中，除了最底层节点可能没填满外，其余每层节点数都达到最大值，并且最下面一层的节点都集中在该层最左边的若干位置。若最底层为第 h 层，则该层包含 1\~ 2h  个节点。

```cpp
/**
 * Definition for a binary tree node.
 * struct TreeNode {
 *     int val;
 *     TreeNode *left;
 *     TreeNode *right;
 *     TreeNode() : val(0), left(nullptr), right(nullptr) {}
 *     TreeNode(int x) : val(x), left(nullptr), right(nullptr) {}
 *     TreeNode(int x, TreeNode *left, TreeNode *right) : val(x), left(left), right(right) {}
 * };
 */
class Solution {
public:
    int countNodes(TreeNode* root) {
        if(!root) return NULL;
        int left = countNodes(root->left);
        int right = countNodes(root->right);
        return left + right + 1;
    }
};
```

```cpp
/**
 * Definition for a binary tree node.
 * struct TreeNode {
 *     int val;
 *     TreeNode *left;
 *     TreeNode *right;
 *     TreeNode() : val(0), left(nullptr), right(nullptr) {}
 *     TreeNode(int x) : val(x), left(nullptr), right(nullptr) {}
 *     TreeNode(int x, TreeNode *left, TreeNode *right) : val(x), left(left), right(right) {}
 * };
 */
class Solution {
public:
    int countNodes(TreeNode* root) {
        int res = 0;
        queue<TreeNode*> q;
        if(root) q.push(root);

        while(q.size()){
            int len = q.size();
            while(len--){
                res++;
                auto t = q.front();
                q.pop();
                if(t->left) q.push(t->left);
                if(t->right) q.push(t->right);
            }
        }
        return res;
    }
};
```

两种暴力做法。

```cpp
/**
 * Definition for a binary tree node.
 * struct TreeNode {
 *     int val;
 *     TreeNode *left;
 *     TreeNode *right;
 *     TreeNode() : val(0), left(nullptr), right(nullptr) {}
 *     TreeNode(int x) : val(x), left(nullptr), right(nullptr) {}
 *     TreeNode(int x, TreeNode *left, TreeNode *right) : val(x), left(left), right(right) {}
 * };
 */
class Solution {
public:
    int countNodes(TreeNode* root) {
        if(!root) return 0;
        auto left = root->left, right = root->right;
        int ld = 0, rd = 0;
        while(left){
            left = left->left;
            ld++;
        }
        while(right){
            right =  right->right;
            rd++;
        }
        if(ld == rd) return (2 << ld) - 1;
        return countNodes(root->left) + countNodes(root->right) + 1;
    }
};
```

更快的做法，利用了完全二叉树的性质。

#### 110. 平衡二叉树

给定一个二叉树，判断它是否是高度平衡的二叉树。

本题中，一棵高度平衡二叉树定义为：

一个二叉树每个节点 的左右两个子树的高度差的绝对值不超过 1 。

```cpp
/**
 * Definition for a binary tree node.
 * struct TreeNode {
 *     int val;
 *     TreeNode *left;
 *     TreeNode *right;
 *     TreeNode() : val(0), left(nullptr), right(nullptr) {}
 *     TreeNode(int x) : val(x), left(nullptr), right(nullptr) {}
 *     TreeNode(int x, TreeNode *left, TreeNode *right) : val(x), left(left), right(right) {}
 * };
 */
class Solution {
public:
    bool ans;
    bool isBalanced(TreeNode* root) {
        ans = true;
        dfs(root);
        return ans;
    }

    int dfs(TreeNode* root){
        if(!root) return 0;
        int lh = dfs(root->left);
        int rh = dfs(root->right);
        if(abs(lh - rh) > 1) ans = false;
        return max(lh, rh) + 1;
    }
};
```

首先定义一个全局的变量保存答案，然后递归左右字数找到最深的点，取差的绝对值即可。

#### 257. 二叉树的所有路径

给你一个二叉树的根节点 root ，按 任意顺序 ，返回所有从根节点到叶子节点的路径。

叶子节点 是指没有子节点的节点。

```cpp
/**
 * Definition for a binary tree node.
 * struct TreeNode {
 *     int val;
 *     TreeNode *left;
 *     TreeNode *right;
 *     TreeNode() : val(0), left(nullptr), right(nullptr) {}
 *     TreeNode(int x) : val(x), left(nullptr), right(nullptr) {}
 *     TreeNode(int x, TreeNode *left, TreeNode *right) : val(x), left(left), right(right) {}
 * };
 */
class Solution {
public:
    vector<string> ans;
    vector<int> path;

    vector<string> binaryTreePaths(TreeNode* root) {
        if(root) dfs(root);
        return ans;
    }

    void dfs(TreeNode* root){
        path.push_back(root->val);
        if(!root->left && !root->right){
            string line = to_string(path[0]);
            for(int i = 1; i < path.size(); i++){
                line += "->" + to_string(path[i]);
            }
            ans.push_back(line);
        }else{
            if(root->left) dfs(root->left);
            if(root->right) dfs(root->right);
        }
        path.pop_back();
    }
};
```

爆搜即可。

#### 404. 左叶子之和

给定二叉树的根节点 root ，返回所有左叶子之和。

```cpp
/**
 * Definition for a binary tree node.
 * struct TreeNode {
 *     int val;
 *     TreeNode *left;
 *     TreeNode *right;
 *     TreeNode() : val(0), left(nullptr), right(nullptr) {}
 *     TreeNode(int x) : val(x), left(nullptr), right(nullptr) {}
 *     TreeNode(int x, TreeNode *left, TreeNode *right) : val(x), left(left), right(right) {}
 * };
 */
class Solution {
public:
    int res = 0;

    int sumOfLeftLeaves(TreeNode* root) {
        dfs(root);
        return res;
    }

    void dfs(TreeNode* root){
        if(!root) return;
        if(root->left){
            if(!root->left->left && !root->left->right) res += root->left->val;
        }
        dfs(root->left);
        dfs(root->right);
    }
};
```

爆搜即可。

#### 513. 找树左下角的值

给定一个二叉树的 根节点 root，请找出该二叉树的 最底层 最左边 节点的值。

假设二叉树中至少有一个节点。

```cpp
/**
 * Definition for a binary tree node.
 * struct TreeNode {
 *     int val;
 *     TreeNode *left;
 *     TreeNode *right;
 *     TreeNode() : val(0), left(nullptr), right(nullptr) {}
 *     TreeNode(int x) : val(x), left(nullptr), right(nullptr) {}
 *     TreeNode(int x, TreeNode *left, TreeNode *right) : val(x), left(left), right(right) {}
 * };
 */
class Solution {
public:
    int findBottomLeftValue(TreeNode* root) {
        int res = 0;
        queue<TreeNode*> q;
        if(root) q.push(root);

        while(q.size()){
            int len = q.size();
            vector<int> line;
            while(len--){
                auto t = q.front();
                line.push_back(t->val);
                q.pop();
                if(t->left) q.push(t->left);
                if(t->right) q.push(t->right);
            }
            res = line[0];
        }
        return res;
    }
};
```

就爱层序遍历，别的咳嗽。

#### 112. 路径总和

给你二叉树的根节点  root 和一个表示目标和的整数  targetSum 。判断该树中是否存在 根节点到叶子节点 的路径，这条路径上所有节点值相加等于目标和  targetSum 。如果存在，返回 true ；否则，返回 false 。

```cpp
/**
 * Definition for a binary tree node.
 * struct TreeNode {
 *     int val;
 *     TreeNode *left;
 *     TreeNode *right;
 *     TreeNode() : val(0), left(nullptr), right(nullptr) {}
 *     TreeNode(int x) : val(x), left(nullptr), right(nullptr) {}
 *     TreeNode(int x, TreeNode *left, TreeNode *right) : val(x), left(left), right(right) {}
 * };
 */
class Solution {
public:
    bool hasPathSum(TreeNode* root, int sum) {
        if(!root) return false;
        sum -= root->val;
        if(!root->left && !root->right) return !sum;
        return root->left && hasPathSum(root->left, sum) || root->right && hasPathSum(root->right, sum);
    }
};
```

当是叶子结点且 sum = 0 时返回真即可。

#### 654. 最大二叉树

<https://leetcode.cn/problems/maximum-binary-tree/>

给定一个不重复的整数数组  nums 。  最大二叉树   可以用下面的算法从  nums 递归地构建:

创建一个根节点，其值为  nums 中的最大值。 递归地在最大值   左边   的   子数组前缀上   构建左子树。 递归地在最大值 右边 的   子数组后缀上   构建右子树。 返回  nums 构建的 最大二叉树 。

```cpp
/**
 * Definition for a binary tree node.
 * struct TreeNode {
 *     int val;
 *     TreeNode *left;
 *     TreeNode *right;
 *     TreeNode() : val(0), left(nullptr), right(nullptr) {}
 *     TreeNode(int x) : val(x), left(nullptr), right(nullptr) {}
 *     TreeNode(int x, TreeNode *left, TreeNode *right) : val(x), left(left), right(right) {}
 * };
 */
class Solution {
public:
    TreeNode* constructMaximumBinaryTree(vector<int>& nums) {
        return dfs(nums, 0, nums.size() - 1);
    }

    TreeNode* dfs(vector<int>& nums, int l, int r){
        if(l > r) return NULL;
        int idx = l;
        for(int i = l + 1; i <= r; i++){
            if(nums[i] > nums[idx]) idx = i;
        }
        TreeNode *res = new TreeNode(nums[idx]);
        res->left = dfs(nums, l, idx - 1);
        res->right = dfs(nums, idx + 1, r);
        return res;
    }
};
```

直接按题目描述进行模拟即可。用递归函数 dfs(nums,l,r)dfs(nums,l,r) 表示对于 `[numsl,numsr][numsl,numsr]` 区间构建一棵树。具体如下：找到 `[numsl,numsr][numsl,numsr]` 区间内的最大值，记为 numsidnumsid，这个数字就是根节点的值。分别递归左右子树：dfs(nums,l,id−1)dfs(nums,l,id−1) 和 dfs(nums,id+1,r)dfs(nums,id+1,r)。

#### 617. 合并二叉树

<https://leetcode.cn/problems/merge-two-binary-trees/submissions/>

给你两棵二叉树： root1 和 root2 。

想象一下，当你将其中一棵覆盖到另一棵之上时，两棵树上的一些节点将会重叠（而另一些不会）。你需要将这两棵树合并成一棵新二叉树。合并的规则是：如果两个节点重叠，那么将这两个节点的值相加作为合并后节点的新值；否则，不为 null 的节点将直接作为新二叉树的节点。

返回合并后的二叉树。

注意: 合并过程必须从两个树的根节点开始。

```cpp
/**
 * Definition for a binary tree node.
 * struct TreeNode {
 *     int val;
 *     TreeNode *left;
 *     TreeNode *right;
 *     TreeNode() : val(0), left(nullptr), right(nullptr) {}
 *     TreeNode(int x) : val(x), left(nullptr), right(nullptr) {}
 *     TreeNode(int x, TreeNode *left, TreeNode *right) : val(x), left(left), right(right) {}
 * };
 */
class Solution {
public:
    TreeNode* mergeTrees(TreeNode* root1, TreeNode* root2) {
        if(root2) swap(root1, root2);
        if(!root1) return NULL;
        if(root2) root1->val += root2->val;
        root1->left = mergeTrees(root1->left, root2 ? root2->left : NULL);
        root1->right = mergeTrees(root1->right, root2 ? root2->right : NULL);
        return root1;
    }
};
```

首先让 root1 一定存在，接着 root2 也存在就将值相加，最后遍历左右儿子即可。

#### 98. 验证二叉搜索树

<https://leetcode.cn/problems/validate-binary-search-tree/>

给你一个二叉树的根节点 root ，判断其是否是一个有效的二叉搜索树。

有效 二叉搜索树定义如下：

节点的左子树只包含 小于 当前节点的数。 节点的右子树只包含 大于 当前节点的数。 所有左子树和右子树自身必须也是二叉搜索树。

```cpp
/**
 * Definition for a binary tree node.
 * struct TreeNode {
 *     int val;
 *     TreeNode *left;
 *     TreeNode *right;
 *     TreeNode() : val(0), left(nullptr), right(nullptr) {}
 *     TreeNode(int x) : val(x), left(nullptr), right(nullptr) {}
 *     TreeNode(int x, TreeNode *left, TreeNode *right) : val(x), left(left), right(right) {}
 * };
 */
class Solution {
public:
    bool isValidBST(TreeNode* root) {
        if(!root) return true;
        return bfs(root)[0];
    }

    vector<int> bfs(TreeNode* root){
        vector<int> res{1, root->val, root->val};
        if(root->left){
            auto t = bfs(root->left);
            if(!t[0] || t[2] >= root->val) res[0] = 0;
            res[1] = min(t[1], res[1]);
            res[2] = max(t[2], res[2]);
        }
        if(root->right){
            auto t = bfs(root->right);
            if(!t[0] || t[1] <= root->val) res[0] = 0;
            res[1] = min(t[1], res[1]);
            res[2] = max(t[2], res[2]);
        }
        return res;
    }
};
```

用一个数组存三个信息，一个是是否有问题，一个是最大值，一个是最小值，然后深搜即可。

#### 530. 二叉搜索树的最小绝对差

<https://leetcode.cn/problems/minimum-absolute-difference-in-bst/>

给你一个二叉搜索树的根节点 root ，返回 树中任意两不同节点值之间的最小差值 。

差值是一个正数，其数值等于两值之差的绝对值。

```cpp
/**
 * Definition for a binary tree node.
 * struct TreeNode {
 *     int val;
 *     TreeNode *left;
 *     TreeNode *right;
 *     TreeNode() : val(0), left(nullptr), right(nullptr) {}
 *     TreeNode(int x) : val(x), left(nullptr), right(nullptr) {}
 *     TreeNode(int x, TreeNode *left, TreeNode *right) : val(x), left(left), right(right) {}
 * };
 */
class Solution {
public:
    bool is_first = true;
    int res = INT_MAX, last;

    int getMinimumDifference(TreeNode* root) {
        dfs(root);
        return res;
    }

    void dfs(TreeNode* root){
        if(!root) return;
        dfs(root->left);
        if(is_first) is_first = false;
        else res = min(res, root->val - last);
        last = root->val;
        dfs(root->right);
    }
};
```

深搜对比一下即可，首先如果是第一次进行深搜，此时没有 last ，将 is\_first 置为 false，并写入 last 即可。通过中序遍历维护答案，

#### 236. 二叉树的最近公共祖先

<https://leetcode.cn/problems/lowest-common-ancestor-of-a-binary-tree/>

给定一个二叉树, 找到该树中两个指定节点的最近公共祖先。

百度百科中最近公共祖先的定义为：“对于有根树 T 的两个节点 p、q，最近公共祖先表示为一个节点 x，满足 x 是 p、q 的祖先且 x 的深度尽可能大（一个节点也可以是它自己的祖先）。”

```cpp
/**
 * Definition for a binary tree node.
 * struct TreeNode {
 *     int val;
 *     TreeNode *left;
 *     TreeNode *right;
 *     TreeNode(int x) : val(x), left(NULL), right(NULL) {}
 * };
 */
class Solution {
public:
    TreeNode* lowestCommonAncestor(TreeNode* root, TreeNode* p, TreeNode* q) {
        if(!root || root == p || root == q) return root;
        auto left = lowestCommonAncestor(root->left, p, q);
        auto right = lowestCommonAncestor(root->right, p, q);
        if(!left) return right;
        if(!right) return left;
        return root;
    }
};
```

左边为空，三种情况，右边为空一样的，两边都不为空，那么肯定一边一个返回根节点即可。

#### 450. 删除二叉搜索树中的节点

<https://leetcode.cn/problems/delete-node-in-a-bst/>

给定一个二叉搜索树的根节点 root 和一个值 key，删除二叉搜索树中的  key  对应的节点，并保证二叉搜索树的性质不变。返回二叉搜索树（有可能被更新）的根节点的引用。

```cpp
/**
 * Definition for a binary tree node.
 * struct TreeNode {
 *     int val;
 *     TreeNode *left;
 *     TreeNode *right;
 *     TreeNode() : val(0), left(nullptr), right(nullptr) {}
 *     TreeNode(int x) : val(x), left(nullptr), right(nullptr) {}
 *     TreeNode(int x, TreeNode *left, TreeNode *right) : val(x), left(left), right(right) {}
 * };
 */
class Solution {
public:
    TreeNode* deleteNode(TreeNode* root, int key) {
        del(root, key);
        return root;
    }

    void del(TreeNode* &root, int key){
        if(!root) return;
        if(root->val == key){
            if(!root->left && !root->right) root = NULL;
            else if(!root->left) root = root->right;
            else if(!root->right) root = root->left;
            else {
                auto p = root->right;
                while(p->left) p = p->left;
                root->val = p->val;
                del(root->right, p->val);
            }
        }
        else if(root->val > key) del(root->left, key);
        else del(root->right, key);
    }
};
```

分三种情况删除即可。

#### 86. 分隔链表

<https://leetcode.cn/problems/partition-list/>

给你一个链表的头节点 head 和一个特定值 x ，请你对链表进行分隔，使得所有 小于 x 的节点都出现在 大于或等于 x 的节点之前。

你应当 保留 两个分区中每个节点的初始相对位置。

```cpp
/**
 * Definition for singly-linked list.
 * struct ListNode {
 *     int val;
 *     ListNode *next;
 *     ListNode() : val(0), next(nullptr) {}
 *     ListNode(int x) : val(x), next(nullptr) {}
 *     ListNode(int x, ListNode *next) : val(x), next(next) {}
 * };
 */
class Solution {
public:
    ListNode* partition(ListNode* head, int x) {
        auto dummy1 = new ListNode(), dummy2 = new ListNode();
        auto p1 = dummy1, p2 = dummy2;

        while(head){
            if(head->val >= x) p2 = p2->next = head;
            else p1 = p1->next = head;
            head = head->next;
        }
        p2->next = NULL;
        p1->next = dummy2->next;
        return dummy1->next;
    }
};
```

与合并链表类似，这里需要创建两个虚拟头结点，除此之外我们需要在最后通过 p2->next = NULL 来切断他后面的指向。

#### 23. 合并 K 个升序链表

<https://leetcode.cn/problems/merge-k-sorted-lists/>

给你一个链表数组，每个链表都已经按升序排列。

请你将所有链表合并到一个升序链表中，返回合并后的链表。

```cpp
/**
 * Definition for singly-linked list.
 * struct ListNode {
 *     int val;
 *     ListNode *next;
 *     ListNode() : val(0), next(nullptr) {}
 *     ListNode(int x) : val(x), next(nullptr) {}
 *     ListNode(int x, ListNode *next) : val(x), next(next) {}
 * };
 */
class Solution {
public:
    struct Cmp {
        bool operator()(ListNode* a, ListNode* b) {
            return a->val > b->val;
        }
    };

    ListNode* mergeKLists(vector<ListNode*>& lists) {
        auto dummy = new ListNode(), p = dummy;
        priority_queue<ListNode*, vector<ListNode*>, Cmp> heap;
        for(auto h : lists) if(h) heap.push(h);

        while(heap.size()) {
            auto t = heap.top();
            heap.pop();
            p = p->next = t;
            if(t->next) heap.push(t->next);
        }
        return dummy->next;
    }
};
```

通过一个优先队列小根堆来实现，其中我们需要有一个 Cmp 比较函数，背过即可。

#### 543. 二叉树的直径

<https://leetcode.cn/problems/diameter-of-binary-tree/>

给定一棵二叉树，你需要计算它的直径长度。一棵二叉树的直径长度是任意两个结点路径长度中的最大值。这条路径可能穿过也可能不穿过根结点。

```cpp
/**
 * Definition for a binary tree node.
 * struct TreeNode {
 *     int val;
 *     TreeNode *left;
 *     TreeNode *right;
 *     TreeNode() : val(0), left(nullptr), right(nullptr) {}
 *     TreeNode(int x) : val(x), left(nullptr), right(nullptr) {}
 *     TreeNode(int x, TreeNode *left, TreeNode *right) : val(x), left(left), right(right) {}
 * };
 */
class Solution {
public:
    int res = 0;
    int diameterOfBinaryTree(TreeNode* root) {
        dfs(root);
        return res;
    }

    int dfs(TreeNode* root) {
        if(!root) return 0;
        int l = dfs(root->left);
        int r = dfs(root->right);
        res = max(res, l + r);
        return max(l, r) + 1;
    }
};
```

前序位置无法获取子树信息，所以只能让每个节点调用 dfs 函数去算子树的深度。 我们应该把计算「直径」的逻辑放在后序位置，准确说应该是放在 dfs 的后序位置，因为 dfs 的后序位置是知道左右子树的最大深度的。

#### 105. 从前序与中序遍历序列构造二叉树

<https://leetcode.cn/problems/construct-binary-tree-from-preorder-and-inorder-traversal/>

给定两个整数数组  preorder 和 inorder ，其中  preorder 是二叉树的先序遍历， inorder  是同一棵树的中序遍历，请构造二叉树并返回其根节点。

```cpp
/**
 * Definition for a binary tree node.
 * struct TreeNode {
 *     int val;
 *     TreeNode *left;
 *     TreeNode *right;
 *     TreeNode() : val(0), left(nullptr), right(nullptr) {}
 *     TreeNode(int x) : val(x), left(nullptr), right(nullptr) {}
 *     TreeNode(int x, TreeNode *left, TreeNode *right) : val(x), left(left), right(right) {}
 * };
 */
class Solution {
public:
    TreeNode* buildTree(vector<int>& preorder, vector<int>& inorder) {
        return dfs(preorder, 0, preorder.size() - 1, inorder, 0, inorder.size() - 1);
    }

    TreeNode* dfs(vector<int>& preorder, int pst, int ped, vector<int>& inorder, int ist, int ied) {
        if(pst > ped) return NULL;
        int val = preorder[pst], idx = -1;
        auto root = new TreeNode(val);
        for(int i = ist; i <= ied; i++) {
            if(inorder[i] == val) {
                idx = i;
                break;
            }
        }
        root->left = dfs(preorder, pst + 1, pst + idx - ist, inorder, ist, idx - 1);
        root->right = dfs(preorder, pst + idx - ist + 1, ped, inorder, idx + 1, ied);
        return root;
    }
};
```

注意画图来分析递归时的参数设计。

#### 155. 最小栈

<https://leetcode.cn/problems/min-stack/>

设计一个支持 push ，pop ，top 操作，并能在常数时间内检索到最小元素的栈。

实现 MinStack 类:

MinStack() 初始化堆栈对象。 void push(int val) 将元素 val 推入堆栈。 void pop() 删除堆栈顶部的元素。 int top() 获取堆栈顶部的元素。 int getMin() 获取堆栈中的最小元素。

```cpp
class MinStack {
public:
    stack<int> s;
    stack<int> t;
    MinStack() {
        t.push(INT_MAX);
    }

    void push(int val) {
        s.push(val);
        t.push(min(t.top(), val));
    }

    void pop() {
        s.pop();
        t.pop();
    }

    int top() {
        return s.top();
    }

    int getMin() {
        return t.top();
    }
};

/**
 * Your MinStack object will be instantiated and called as such:
 * MinStack* obj = new MinStack();
 * obj->push(val);
 * obj->pop();
 * int param_3 = obj->top();
 * int param_4 = obj->getMin();
 */
```

用一个辅助栈来进行实现就行。

#### 946. 验证栈序列

<https://leetcode.cn/problems/validate-stack-sequences/>

```cpp
class Solution {
public:
    bool validateStackSequences(vector<int>& pushed, vector<int>& popped) {
        stack<int> stk;
        int n = pushed.size();
        for(int i = 0, j = 0; i < n; i++) {
            stk.push(pushed[i]);
            while(!stk.empty() && stk.top() == popped[j]) {
                stk.pop();
                j++;
            }
        }
        return stk.empty();
    }
};
```

模拟一下。

#### 230. 二叉搜索树中第 K 小的元素

<https://leetcode.cn/problems/kth-smallest-element-in-a-bst/>

给定一个二叉搜索树的根节点 root ，和一个整数 k ，请你设计一个算法查找其中第 k 个最小元素（从 1 开始计数）。

```cpp
/**
 * Definition for a binary tree node.
 * struct TreeNode {
 *     int val;
 *     TreeNode *left;
 *     TreeNode *right;
 *     TreeNode() : val(0), left(nullptr), right(nullptr) {}
 *     TreeNode(int x) : val(x), left(nullptr), right(nullptr) {}
 *     TreeNode(int x, TreeNode *left, TreeNode *right) : val(x), left(left), right(right) {}
 * };
 */
class Solution {
public:
    int res = 0, idx = 0;
    int kthSmallest(TreeNode* root, int k) {
        dfs(root, k);
        return res;
    }

    void dfs(TreeNode* root, int k) {
        if(!root) return;
        dfs(root->left, k);
        idx++;
        if(idx == k) {
            res = root->val;
            return;
        }
        dfs(root->right, k);
    }
};
```

中序遍历即可。

#### 146. LRU 缓存

<https://leetcode.cn/problems/lru-cache/>

请你设计并实现一个满足   LRU (最近最少使用) 缓存 约束的数据结构。 实现 LRUCache 类： LRUCache(int capacity) 以 正整数 作为容量  capacity 初始化 LRU 缓存 int get(int key) 如果关键字 key 存在于缓存中，则返回关键字的值，否则返回 -1 。 void put(int key, int value)  如果关键字  key 已经存在，则变更其数据值  value ；如果不存在，则向缓存中插入该组  key-value 。如果插入操作导致关键字数量超过  capacity ，则应该 逐出 最久未使用的关键字。 函数 get 和 put 必须以 O(1) 的平均时间复杂度运行。

```cpp
class LRUCache {
public:
    struct Node {
        int val, key;
        Node *left, *right;
        Node(int _key, int _val): key(_key), val(_val), left(NULL), right(NULL) {}
    }*L, *R;
    unordered_map<int, Node*> hash;
    int n;

    void remove(Node* p) {
        p->left->right = p->right;
        p->right->left = p->left;
    }

    void insert(Node* p) {
        p->left = L;
        p->right = L->right;
        L->right->left = p;
        L->right = p;
    }

    LRUCache(int capacity) {
        n = capacity;
        L = new Node(-1, -1), R = new Node(-1, -1);
        L->right = R, R->left = L;
    }

    int get(int key) {
        if(hash.count(key) == 0) return -1;
        auto p = hash[key];
        remove(p);
        insert(p);
        return p->val;
    }

    void put(int key, int value) {
        if(hash.count(key)) {
            auto p = hash[key];
            p->val = value;
            remove(p);
            insert(p);
        } else {
            if(hash.size() == n) {
                auto p = R->left;
                remove(p);
                hash.erase(p->key);
                delete(p);
            }
            auto p = new Node(key, value);
            hash[key] = p;
            insert(p);
        }
    }
};

/**
 * Your LRUCache object will be instantiated and called as such:
 * LRUCache* obj = new LRUCache(capacity);
 * int param_1 = obj->get(key);
 * obj->put(key,value);
 */
```

#### 25. K 个一组翻转链表

<https://leetcode.cn/problems/reverse-nodes-in-k-group/>

给你链表的头节点 head ，每  k  个节点一组进行翻转，请你返回修改后的链表。

k 是一个正整数，它的值小于或等于链表的长度。如果节点总数不是  k  的整数倍，那么请将最后剩余的节点保持原有顺序。

你不能只是单纯的改变节点内部的值，而是需要实际进行节点交换。

```cpp
/**
 * Definition for singly-linked list.
 * struct ListNode {
 *     int val;
 *     ListNode *next;
 *     ListNode() : val(0), next(nullptr) {}
 *     ListNode(int x) : val(x), next(nullptr) {}
 *     ListNode(int x, ListNode *next) : val(x), next(next) {}
 * };
 */
class Solution {
public:
    ListNode* reverseKGroup(ListNode* head, int k) {
        if(!head) return head;
        auto a = head, b = head;
        for(int i = 0; i < k; i++) {
            if(b == NULL) return head;
            b = b->next;
        }
        auto newHead = reverseNode(a, b);
        a->next = reverseKGroup(b, k);
        return newHead;
    }

    ListNode* reverseNode(ListNode* n, ListNode* m) {
        auto a = n, b = n->next;
        while(b != m) {
            auto t = b->next;
            b->next = a;
            a = b;
            b = t;
        }
        n->next = m;
        return a;
    }
};
```

#### 103. 二叉树的锯齿形层序遍历

<https://leetcode.cn/problems/binary-tree-zigzag-level-order-traversal/>

给你二叉树的根节点 root ，返回其节点值的 锯齿形层序遍历 。（即先从左往右，再从右往左进行下一层遍历，以此类推，层与层之间交替进行）。

```cpp
/**
 * Definition for a binary tree node.
 * struct TreeNode {
 *     int val;
 *     TreeNode *left;
 *     TreeNode *right;
 *     TreeNode() : val(0), left(nullptr), right(nullptr) {}
 *     TreeNode(int x) : val(x), left(nullptr), right(nullptr) {}
 *     TreeNode(int x, TreeNode *left, TreeNode *right) : val(x), left(left), right(right) {}
 * };
 */
class Solution {
public:
    vector<vector<int>> zigzagLevelOrder(TreeNode* root) {
        vector<vector<int>> res;
        queue<TreeNode*> q;
        int cnt = 0;
        if(root) q.push(root);
        while(q.size()) {
            int len = q.size();
            vector<int> path;
            while(len--) {
                auto t = q.front();
                q.pop();
                if(t->left) q.push(t->left);
                if(t->right) q.push(t->right);
                path.push_back(t->val);
            }
            if(cnt % 2) reverse(path.begin(), path.end());
            res.push_back(path);
            cnt++;
        }
        return res;
    }
};
```

层序遍历加一个隔一行翻转即可。

#### 92. 反转链表 II

<https://leetcode.cn/problems/reverse-linked-list-ii/>

给你单链表的头指针 head 和两个整数  left 和 right ，其中  left <= right 。请你反转从位置 left 到位置 right 的链表节点，返回 反转后的链表 。

```cpp
/**
 * Definition for singly-linked list.
 * struct ListNode {
 *     int val;
 *     ListNode *next;
 *     ListNode() : val(0), next(nullptr) {}
 *     ListNode(int x) : val(x), next(nullptr) {}
 *     ListNode(int x, ListNode *next) : val(x), next(next) {}
 * };
 */
class Solution {
public:
    ListNode* reverseBetween(ListNode* head, int left, int right) {
        if(left == right || !head) return head;
        auto dummy = new ListNode();
        dummy->next = head;
        auto p = dummy;
        for(int i = 0; i < left - 1; i++) p = p->next;
        auto a = p, b = a->next, c = b->next;
        for(int i = 0; i < right -left; i++) {
            auto t = c->next;
            c->next = b;
            b = c;
            c = t;
        }
        a->next->next = c;
        a->next = b;
        return dummy->next;
    }
};
```

#### 124. 二叉树中的最大路径和

<https://leetcode.cn/problems/binary-tree-maximum-path-sum/>

路径 被定义为一条从树中任意节点出发，沿父节点-子节点连接，达到任意节点的序列。同一个节点在一条路径序列中 至多出现一次 。该路径 至少包含一个 节点，且不一定经过根节点。

路径和 是路径中各节点值的总和。

给你一个二叉树的根节点 root ，返回其 最大路径和 。

```cpp
/**
 * Definition for a binary tree node.
 * struct TreeNode {
 *     int val;
 *     TreeNode *left;
 *     TreeNode *right;
 *     TreeNode() : val(0), left(nullptr), right(nullptr) {}
 *     TreeNode(int x) : val(x), left(nullptr), right(nullptr) {}
 *     TreeNode(int x, TreeNode *left, TreeNode *right) : val(x), left(left), right(right) {}
 * };
 */
class Solution {
public:
    int res;
    int maxPathSum(TreeNode* root) {
        res = INT_MIN;
        dfs(root);
        return res;
    }

    int dfs(TreeNode* root) {
        if(!root) return 0;
        int l = max(0, dfs(root->left)), r = max(0, dfs(root->right));
        res = max(res, root->val + l + r);
        return root->val + max(l, r);
    }
};
```

#### 56. 合并区间

<https://leetcode.cn/problems/merge-intervals/>

以数组 intervals 表示若干个区间的集合，其中单个区间为 intervals\[i] = \[starti, endi] 。请你合并所有重叠的区间，并返回   一个不重叠的区间数组，该数组需恰好覆盖输入中的所有区间  。

```cpp
class Solution {
public:
    vector<vector<int>> merge(vector<vector<int>>& intervals) {
        vector<vector<int>> res;
        if(intervals.empty()) return res;
        sort(intervals.begin(), intervals.end());
        int l = intervals[0][0], r = intervals[0][1];
        for(int i = 1; i < intervals.size(); i++) {
            if(intervals[i][0] > r) {
                res.push_back({l, r});
                l = intervals[i][0], r = intervals[i][1];
            }else r = max(r, intervals[i][1]);
        }
        res.push_back({l, r});
        return res;
    }
};
```

#### 148. 排序链表

<https://leetcode.cn/problems/sort-list/>

给你链表的头结点  head ，请将其按 升序 排列并返回 排序后的链表 。

```cpp
/**
 * Definition for singly-linked list.
 * struct ListNode {
 *     int val;
 *     ListNode *next;
 *     ListNode() : val(0), next(nullptr) {}
 *     ListNode(int x) : val(x), next(nullptr) {}
 *     ListNode(int x, ListNode *next) : val(x), next(next) {}
 * };
 */
class Solution {
public:
    ListNode* sortList(ListNode* head) {
        int n = 0;
        for(auto p = head; p; p = p->next) n++;
        auto dummy = new ListNode();
        dummy->next = head;
        for(int i = 1; i < n; i *= 2) {
            auto cur = dummy;
            for(int j = 1; j + i <= n; j += 2 * i) {
                auto p = cur->next, q = p;
                for(int k = 0; k < i; k++) q = q->next;
                int l = 0, r = 0;
                while(l < i && r < i && p && q) {
                    if(p->val < q->val) cur = cur->next = p, p = p->next, l++;
                    else cur = cur->next = q, q = q->next, r++;
                }
                while(l < i && p) cur = cur->next = p, p = p->next, l++;
                while(r < i && q) cur = cur->next = q, q = q->next, r++;
                cur->next = q;
            }
        }
        return dummy->next;
    }
};
```

#### 82. 删除排序链表中的重复元素 II

<https://leetcode.cn/problems/remove-duplicates-from-sorted-list-ii/>

给定一个已排序的链表的头  head ，  删除原始链表中所有重复数字的节点，只留下不同的数字  。返回 已排序的链表  。

```cpp
/**
 * Definition for singly-linked list.
 * struct ListNode {
 *     int val;
 *     ListNode *next;
 *     ListNode() : val(0), next(nullptr) {}
 *     ListNode(int x) : val(x), next(nullptr) {}
 *     ListNode(int x, ListNode *next) : val(x), next(next) {}
 * };
 */
class Solution {
public:
    ListNode* deleteDuplicates(ListNode* head) {
        if(!head) return head;
        auto dummy = new ListNode();
        dummy->next = head;
        auto p = dummy;
        while(p->next) {
            auto q = p->next->next;
            while(q && q->val == p->next->val) q = q->next;
            if(q == p->next->next) p = p->next;
            else p->next = q;
        }
        return dummy->next;
    }
};
```

#### 2. 两数相加

<https://leetcode.cn/problems/add-two-numbers/>

给你两个   非空 的链表，表示两个非负的整数。它们每位数字都是按照   逆序   的方式存储的，并且每个节点只能存储   一位   数字。

请你将两个数相加，并以相同形式返回一个表示和的链表。

你可以假设除了数字 0 之外，这两个数都不会以 0  开头。

```cpp
/**
 * Definition for singly-linked list.
 * struct ListNode {
 *     int val;
 *     ListNode *next;
 *     ListNode() : val(0), next(nullptr) {}
 *     ListNode(int x) : val(x), next(nullptr) {}
 *     ListNode(int x, ListNode *next) : val(x), next(next) {}
 * };
 */
class Solution {
public:
    ListNode* addTwoNumbers(ListNode* l1, ListNode* l2) {
        auto dummy = new ListNode(), q = dummy;
        int t = 0;
        while(l1 || l2 || t) {
            if(l1) t += l1->val, l1 = l1->next;
            if(l2) t += l2->val, l2 = l2->next;
            q = q->next = new ListNode(t % 10);
            t /= 10;
        }
        return dummy->next;
    }
};
```

#### 8. 字符串转换整数 (atoi)

<https://leetcode.cn/problems/string-to-integer-atoi/>

请你来实现一个  myAtoi(string s)  函数，使其能将字符串转换成一个 32 位有符号整数（类似 C/C++ 中的 atoi 函数）。

函数  myAtoi(string s) 的算法如下：

读入字符串并丢弃无用的前导空格 检查下一个字符（假设还未到字符末尾）为正还是负号，读取该字符（如果有）。 确定最终结果是负数还是正数。 如果两者都不存在，则假定结果为正。 读入下一个字符，直到到达下一个非数字字符或到达输入的结尾。字符串的其余部分将被忽略。 将前面步骤读入的这些数字转换为整数（即，"123" -> 123， "0032" -> 32）。如果没有读入数字，则整数为 0 。必要时更改符号（从步骤 2 开始）。 如果整数数超过 32 位有符号整数范围 \[−231,  231 − 1] ，需要截断这个整数，使其保持在这个范围内。具体来说，小于 −231 的整数应该被固定为 −231 ，大于 231 − 1 的整数应该被固定为 231 − 1 。 返回整数作为最终结果。 注意：

本题中的空白字符只包括空格字符 ' ' 。 除前导空格或数字后的其余字符串外，请勿忽略 任何其他字符。

```cpp
class Solution {
public:
    int myAtoi(string s) {
        int n = s.size(), i = 0, flag = 1;
        long long res = 0;
        while(i < n && s[i] == ' ') i++;
        if(s[i] == '-') flag = -1, i++;
        else if(s[i] == '+') i++;
        while(i < n && s[i] >= '0' && s[i] <= '9') {
            res = res * 10 + s[i] - '0';
            if(res > INT_MAX) break;
            i++;
        }
        if((int)res != res) return flag == 1 ? INT_MAX : INT_MIN;
        return res * flag;
    }
};
```

#### 239. 滑动窗口最大值

<https://leetcode.cn/problems/sliding-window-maximum/>

给你一个整数数组 nums，有一个大小为  k  的滑动窗口从数组的最左侧移动到数组的最右侧。你只可以看到在滑动窗口内的 k  个数字。滑动窗口每次只向右移动一位。

返回 滑动窗口中的最大值 。

```cpp
class Solution {
public:
    vector<int> maxSlidingWindow(vector<int>& nums, int k) {
        deque<int> q;
        vector<int> res;
        for(int i = 0; i < nums.size(); i++) {
            if(q.size() && i - k + 1 > q.front()) q.pop_front();
            while(q.size() && nums[i] >= nums[q.back()]) q.pop_back();
            q.push_back(i);
            if(i >= k - 1) res.push_back(nums[q.front()]);
        }
        return res;
    }
};
```

#### 41. 缺失的第一个正数

<https://leetcode.cn/problems/first-missing-positive/>

```cpp
class Solution {
public:
    int firstMissingPositive(vector<int>& nums) {
        int n = nums.size();
        for(auto& x : nums) if(x != INT_MIN) x--;
        for(int i = 0; i < n; i++) while(nums[i] >= 0 && nums[i] < n && nums[i] != i && nums[i] != nums[nums[i]]) swap(nums[i], nums[nums[i]]);
        for(int i = 0; i < n; i++) if(nums[i] != i) return i + 1;
        return n + 1;
    }
};
```

#### 234. 回文链表

<https://leetcode.cn/problems/palindrome-linked-list/>

给你一个单链表的头节点 head ，请你判断该链表是否为回文链表。如果是，返回 true ；否则，返回 false 。

```cpp
/**
 * Definition for singly-linked list.
 * struct ListNode {
 *     int val;
 *     ListNode *next;
 *     ListNode() : val(0), next(nullptr) {}
 *     ListNode(int x) : val(x), next(nullptr) {}
 *     ListNode(int x, ListNode *next) : val(x), next(next) {}
 * };
 */
class Solution {
public:
    bool isPalindrome(ListNode* head) {
        auto slow = head, fast = head;
        while(fast && fast->next) {
            slow = slow->next;
            fast = fast->next->next;
        }
        if(fast) slow = slow->next;
        auto left = head, right = reverseList(slow);
        while(right) {
            if(left->val != right->val) return false;
            left = left->next;
            right = right->next;
        }
        return true;
    }

    ListNode* reverseList(ListNode* head) {
         if(!head) return NULL;
         auto a = head, b = a->next;
         while(b){
             auto tmp = b->next;
             b->next = a;
             a = b;
             b = tmp;
         }
         head->next = NULL;
         return a;
    }
};
```

#### 394. 字符串解码

<https://leetcode.cn/problems/decode-string/>

给定一个经过编码的字符串，返回它解码后的字符串。

编码规则为: k\[encoded\_string]，表示其中方括号内部的 encoded\_string 正好重复 k 次。注意 k 保证为正整数。

你可以认为输入字符串总是有效的；输入字符串中没有额外的空格，且输入的方括号总是符合格式要求的。

此外，你可以认为原始数据不包含数字，所有的数字只表示重复的次数 k ，例如不会出现像  3a  或  2\[4]  的输入。

```cpp
class Solution {
public:
    string decodeString(string s) {
        int u = 0;
        return dfs(s, u);
    }

    string dfs(string& s, int& u) {
        string res;
        while(u < s.size() && s[u] != ']') {
            if(s[u] >= 'a' && s[u] <= 'z' || s[u] >= 'A' && s[u] <= 'Z') res += s[u++];
            else if(s[u] >= '0' && s[u] <= '9') {
                int k = u;
                while(s[k] >= '0' && s[k] <= '9') k++;
                int x = stoi(s.substr(u, k - u));
                u = k + 1;
                string y = dfs(s, u);
                u++;
                while(x--) res += y;
            }
        }
        return res;
    }
};
```

#### 14. 最长公共前缀

<https://leetcode.cn/problems/longest-common-prefix/>

编写一个函数来查找字符串数组中的最长公共前缀。

如果不存在公共前缀，返回空字符串 ""。

```cpp
class Solution {
public:
    string longestCommonPrefix(vector<string>& strs) {
        string res;
        if(strs.empty()) return res;
        for(int i = 0;; i++) {
            if(i >= strs[0].size()) return res;
            auto c = strs[0][i];
            for(auto str : strs) if(i >= str.size() || c != str[i]) return res;
            res += c;
        }
        return res;
    }
};
```

#### 662. 二叉树最大宽度

<https://leetcode.cn/problems/maximum-width-of-binary-tree/>

给你一棵二叉树的根节点 root ，返回树的 最大宽度 。

树的 最大宽度 是所有层中最大的 宽度 。

每一层的 宽度 被定义为该层最左和最右的非空节点（即，两个端点）之间的长度。将这个二叉树视作与满二叉树结构相同，两端点间会出现一些延伸到这一层的 null 节点，这些 null 节点也计入长度。

题目数据保证答案将会在   32 位 带符号整数范围内。

```cpp
/**
 * Definition for a binary tree node.
 * struct TreeNode {
 *     int val;
 *     TreeNode *left;
 *     TreeNode *right;
 *     TreeNode() : val(0), left(nullptr), right(nullptr) {}
 *     TreeNode(int x) : val(x), left(nullptr), right(nullptr) {}
 *     TreeNode(int x, TreeNode *left, TreeNode *right) : val(x), left(left), right(right) {}
 * };
 */
class Solution {
public:
    int widthOfBinaryTree(TreeNode* root) {
        if(!root) return 0;
        queue<pair<TreeNode*, int>> q;
        q.push({root, 1});
        int res = 1;
        while(q.size()) {
            int len = q.size();
            int l = q.front().second, r;
            while(len--) {
                auto t = q.front();
                q.pop();
                auto t1 = t.first;
                auto p = t.second - l + 1;
                r = t.second;
                if(t1->left) q.push({t1->left, p * 2ll});
                if(t1->right) q.push({t1->right, p * 2ll + 1});
            }
            res = max(res, r - l + 1);
        }
        return res;
    }
};
```

#### 739. 每日温度

<https://leetcode.cn/problems/daily-temperatures/>

给定一个整数数组  temperatures ，表示每天的温度，返回一个数组  answer ，其中  answer\[i]  是指对于第 i 天，下一个更高温度出现在几天后。如果气温在这之后都不会升高，请在该位置用  0 来代替。

```cpp
class Solution {
public:
    vector<int> dailyTemperatures(vector<int>& t) {
        int n = t.size();
        vector<int> res(n);
        stack<int> s;
        for(int i = n - 1; i >= 0; i--) {
            while(s.size() && t[s.top()] <= t[i]) s.pop();
            res[i] = s.empty() ? 0 : (s.top() - i);
            s.push(i);
        }
        return res;
    }
};
```

#### 227. 基本计算器 II

<https://leetcode.cn/problems/basic-calculator-ii/>

给你一个字符串表达式 s ，请你实现一个基本计算器来计算并返回它的值。

整数除法仅保留整数部分。

你可以假设给定的表达式总是有效的。所有中间结果将在  \[-231, 231 - 1] 的范围内。

```cpp
class Solution {
public:
    stack<int> num;
    stack<char> op;
    int calculate(string s) {
        unordered_map<char, int> pr;
        pr['+'] = pr['-'] = 1;
        pr['*'] = pr['/'] = 2;
        for(int i = 0; i < s.size(); i++) {
            char c = s[i];
            if(c == ' ') continue;
            if(isdigit(c)) {
                int res = 0, j = i;
                while(j < s.size() && isdigit(s[j])) res = res * 10 + (s[j++] - '0');
                num.push(res);
                i = j - 1;
            } else {
                while(op.size() && pr[op.top()] >= pr[c]) eval();
                op.push(c);
            }
        }
        while(op.size()) eval();
        return num.top();
    }

    void eval() {
        int res;
        int b = num.top(); num.pop();
        int a = num.top(); num.pop();
        char c = op.top(); op.pop();
        if(c == '+') res = a + b;
        else if(c == '-') res = a - b;
        else if(c == '*') res = a * b;
        else res = a / b;
        num.push(res);
    }
};
```

#### 179. 最大数

<https://leetcode.cn/problems/largest-number/>

给定一组非负整数 nums，重新排列每个数的顺序（每个数不可拆分）使之组成一个最大的整数。

```cpp
class Solution {
public:
    string largestNumber(vector<int>& nums) {
        sort(nums.begin(), nums.end(), [](int x, int y) {
            string a = to_string(x), b = to_string(y);
            return a + b > b + a;
        });
        string res;
        for(auto num : nums) res += to_string(num);
        if(res[0] == '0') return "0";
        return res;
    }
};
```

#### 138. 复制带随机指针的链表

给你一个长度为 n 的链表，每个节点包含一个额外增加的随机指针 random ，该指针可以指向链表中的任何节点或空节点。

构造这个链表的   深拷贝。  深拷贝应该正好由 n 个 全新 节点组成，其中每个新节点的值都设为其对应的原节点的值。新节点的 next 指针和 random 指针也都应指向复制链表中的新节点，并使原链表和复制链表中的这些指针能够表示相同的链表状态。复制链表中的指针都不应指向原链表中的节点 。

例如，如果原链表中有 X 和 Y 两个节点，其中 X.random --> Y 。那么在复制链表中对应的两个节点 x 和 y ，同样有 x.random --> y 。

返回复制链表的头节点。

```cpp
/*
// Definition for a Node.
class Node {
public:
    int val;
    Node* next;
    Node* random;

    Node(int _val) {
        val = _val;
        next = NULL;
        random = NULL;
    }
};
*/

class Solution {
public:
    Node* copyRandomList(Node* head) {
        unordered_map<Node*, Node*> h;
        for(auto p = head; p; p = p->next) if(!h.count(p)) h[p] = new Node(p->val);
        for(auto p = head; p; p = p->next) {
            if(p->next) h[p]->next = h[p->next];
            if(p->random) h[p]->random = h[p->random];
        }
        return h[head];
    }
};
```

#### 61. 旋转链表

<https://leetcode.cn/problems/rotate-list/>

```cpp
/**
 * Definition for singly-linked list.
 * struct ListNode {
 *     int val;
 *     ListNode *next;
 *     ListNode() : val(0), next(nullptr) {}
 *     ListNode(int x) : val(x), next(nullptr) {}
 *     ListNode(int x, ListNode *next) : val(x), next(next) {}
 * };
 */
class Solution {
public:
    ListNode* rotateRight(ListNode* head, int k) {
        if(!head) return head;
        int n = 0;
        ListNode* tail;
        for(auto p = head; p; p = p->next) {
            tail = p;
            n++;
        }
        k %= n;
        if(!k) return head;
        auto p = head;
        for(int i = 0; i < n - k - 1; i++) p = p->next;
        tail->next = head;
        head = p->next;
        p->next = NULL;
        return head;
    }
};
```

#### 402. 移掉 K 位数字

<https://leetcode.cn/problems/remove-k-digits/>

给你一个以字符串表示的非负整数 num 和一个整数 k ，移除这个数中的 k 位数字，使得剩下的数字最小。请你以字符串形式返回这个最小的数字。

```cpp
class Solution {
public:
    string removeKdigits(string num, int k) {
        string res;
        for(int x : num) {
            while(res.size() > 0 && x < res.back() && k > 0) res.pop_back(), k--;
            res.push_back(x);
        }
        while(k > 0) res.pop_back(), k--;
        int n = res.size(), i = 0;
        while(res[i] == '0') i++;
        return n - i == 0 ? "0" : res.substr(i, n - i);
    }
};
```

#### 143. 重排链表

给定一个单链表 L 的头节点 head ，单链表 L 表示为：

L0 → L1 → … → Ln - 1 → Ln

请将其重新排列后变为：

L0 → Ln → L1 → Ln - 1 → L2 → Ln - 2 → …

不能只是单纯的改变节点内部的值，而是需要实际的进行节点交换。

```cpp
/**
 * Definition for singly-linked list.
 * struct ListNode {
 *     int val;
 *     ListNode *next;
 *     ListNode() : val(0), next(nullptr) {}
 *     ListNode(int x) : val(x), next(nullptr) {}
 *     ListNode(int x, ListNode *next) : val(x), next(next) {}
 * };
 */
class Solution {
public:
    void reorderList(ListNode* head) {
        auto mid = findMid(head);
        auto l1 = head;
        auto l2 = mid->next;
        mid->next = NULL;
        l2 = reverseList(l2);
        mergeList(l1, l2);
    }

    ListNode* findMid(ListNode* head) {
        auto slow = head, fast = head;
        while(fast && fast->next) {
            slow = slow->next;
            fast = fast->next->next;
        }
        return slow;
    }

    ListNode* reverseList(ListNode* head) {
        if(!head || !head->next) return head;
        auto a = head, b = head->next;
        while(b) {
            auto t = b->next;
            b->next = a;
            a = b;
            b = t;
        }
        head->next = NULL;
        return a;
    }

    void mergeList(ListNode* l1, ListNode* l2) {
        while (l1 && l2) {
            auto a = l1->next;
            auto b = l2->next;
            l1->next = l2;
            l1 = a;
            l2->next = l1;
            l2 = b;
        }
    }
};
```


# DFS

#### 733. 图像渲染

<https://leetcode.cn/problems/flood-fill/>

有一幅以 m x n 的二维整数数组表示的图画 image ，其中 image\[i] \[j] 表示该图画的像素值大小。

你也被给予三个整数 sr , sc 和 newColor 。你应该从像素 image\[sr] \[sc] 开始对图像进行 上色填充 。

为了完成 上色工作 ，从初始像素开始，记录初始坐标的 上下左右四个方向上 像素值与初始坐标相同的相连像素点，接着再记录这四个方向上符合条件的像素点与他们对应 四个方向上 像素值与初始坐标相同的相连像素点，……，重复该过程。将所有有记录的像素点的颜色值改为 newColor 。

最后返回 经过上色渲染后的图像 。

```cpp
class Solution {
public:
    vector<vector<int>> g;
    int dx[4] = {-1, 0, 1, 0}, dy[4] = {0, 1, 0, -1};
    void dfs(int x, int y, int color, int newColor){
        g[x][y] = newColor;
        for(int i = 0; i < 4; i++){
            int a = x + dx[i];
            int b = y + dy[i];
            if(a >= 0 && b >= 0 && a < g.size() && b < g[0].size() && g[a][b] == color){
                dfs(a, b, color, newColor);
            }
        }
    }
    vector<vector<int>> floodFill(vector<vector<int>>& image, int x, int y, int newColor) {
        g = image;
        if(g[x][y] == newColor) return g;
        dfs(x, y, g[x][y], newColor);
        return g;
    }
};
```

难点在于模拟上下左右四个方向，其他的不算困难，板子题。

#### 695. 岛屿的最大面积

<https://leetcode.cn/problems/max-area-of-island/submissions/>

给你一个大小为 m x n 的二进制矩阵 grid 。

岛屿 是由一些相邻的 1 (代表土地) 构成的组合，这里的「相邻」要求两个 1 必须在 水平或者竖直的四个方向上 相邻。你可以假设 grid 的四个边缘都被 0（代表水）包围着。

岛屿的面积是岛上值为 1 的单元格的数目。

计算并返回 grid 中最大的岛屿面积。如果没有岛屿，则返回面积为 0 。

```cpp
class Solution {
public:
    vector<vector<int>> g;
    int n, m;
    int dx[4] = {-1, 0, 1, 0}, dy[4] = {0, 1, 0, -1};

    int dfs(int x, int y){
        int res = 1;
        g[x][y] = 0;
        for(int i = 0; i < 4; i++){
            int a = x + dx[i], b = y + dy[i];
            if(a >= 0 && b >= 0 && a < n && b < m && g[a][b]){
                res += dfs(a, b);
            }
        }
        return res;
    }

    int maxAreaOfIsland(vector<vector<int>>& grid) {
        g = grid;
        n = g.size(), m = g[0].size();
        int res = 0;
        for(int i = 0; i < n; i++){
            for(int j = 0; j < m; j++){
                if(g[i][j]){
                    res = max(res, dfs(i, j));
                }
            }
        }
        return res;
    }
};
```

注意上下左右的偏移量操作。

#### 617. 合并二叉树

<https://leetcode.cn/problems/merge-two-binary-trees/>

给你两棵二叉树： root1 和 root2 。

想象一下，当你将其中一棵覆盖到另一棵之上时，两棵树上的一些节点将会重叠（而另一些不会）。你需要将这两棵树合并成一棵新二叉树。合并的规则是：如果两个节点重叠，那么将这两个节点的值相加作为合并后节点的新值；否则，不为 null 的节点将直接作为新二叉树的节点。

返回合并后的二叉树。

注意: 合并过程必须从两个树的根节点开始。

```cpp
/**
 * Definition for a binary tree node.
 * struct TreeNode {
 *     int val;
 *     TreeNode *left;
 *     TreeNode *right;
 *     TreeNode() : val(0), left(nullptr), right(nullptr) {}
 *     TreeNode(int x) : val(x), left(nullptr), right(nullptr) {}
 *     TreeNode(int x, TreeNode *left, TreeNode *right) : val(x), left(left), right(right) {}
 * };
 */
class Solution {
public:
    TreeNode* mergeTrees(TreeNode* root1, TreeNode* root2) {
        if(root2) swap(root1, root2);
        if(!root1) return NULL;
        if(root2) root1->val += root2->val;
        root1->left = mergeTrees(root1->left, root2 ? root2->left : NULL);
        root1->right = mergeTrees(root1->right, root2 ? root2->right : NULL);
        return root1;
    }
};
```

先确保 root1 肯定不为空，要不然直接返回 NULL，接着直接递归深搜左右两边即可。

#### 77. 组合

<https://leetcode.cn/problems/combinations/>

给定两个整数 `n` 和 `k`，返回范围 `[1, n]` 中所有可能的 `k` 个数的组合。

你可以按 **任何顺序** 返回答案。

```cpp
class Solution {
public:
    vector<vector<int>> ans;
    vector<int> path;
    vector<vector<int>> combine(int n, int k) {
        dfs(n, k, 1);
        return ans;
    }
    void dfs(int n, int k, int start){
        if(!k){
            ans.push_back(path);
            return;
        }
        for(int i = start; i <= n; i++){
            path.push_back(i);
            dfs(n, k - 1, i + 1);
            path.pop_back();
        }
    }
};
```

递归一下即可。

```cpp
class Solution {
public:
    vector<vector<int>> res;
    vector<int> path;
    vector<vector<int>> combine(int n, int k) {
        dfs(n, k, 1);
        return res;
    }
    void dfs(int n, int k, int start){
        if(path.size() == k) {
            res.push_back(path);
            return;
        }

        for(int i = start; i <= n; i++) {
            path.push_back(i);
            dfs(n, k, i + 1);
            path.pop_back();
        }
    }
};
```

更易懂的写法。

#### 46. 全排列

<https://leetcode.cn/problems/permutations>

给定一个不含重复数字的数组 `nums` ，返回其 *所有可能的全排列* 。你可以 **按任意顺序** 返回答案。

```cpp
class Solution {
public:
    vector<vector<int>> ans;
    vector<bool> st;
    vector<int> path;
    vector<vector<int>> permute(vector<int>& nums) {
        path = vector<int>(nums.size());
        st = vector<bool>(nums.size());
        dfs(nums, 0);
        return ans;
    }
    void dfs(vector<int>& nums, int u){
        if(u == nums.size()){
            ans.push_back(path);
            return;
        }
        for(int i = 0; i < nums.size(); i++){
            if(st[i] == false){
                st[i] = true;
                path[u] = nums[i];
                dfs(nums, u + 1);
                st[i] = false;
            }
        }
    }
};
```

直接爆搜，注意存放状态的问题。

#### 784. 字母大小写全排列

<https://leetcode.cn/problems/letter-case-permutation/>

给定一个字符串 `s` ，通过将字符串 `s` 中的每个字母转变大小写，我们可以获得一个新的字符串。

返回 *所有可能得到的字符串集合* 。以 **任意顺序** 返回输出。

```cpp
class Solution {
public:
    vector<string> ans;
    vector<string> letterCasePermutation(string s) {
        dfs(s, 0);
        return ans;
    }
    void dfs(string &s, int u){
        if(u == s.length()){
            ans.push_back(s);
            return;
        }
        dfs(s, u + 1);
        if (s[u] >= 'A')
        {
            s[u] ^= 32;
            dfs(s, u + 1);
            s[u] ^= 32;
        }
    }
};
```

异或运算直接用 `^= 32` 来切换大小写，爆搜即可。

#### 216. 组合总和 III

<https://leetcode.cn/problems/combination-sum-iii/>

找出所有相加之和为  n 的  k  个数的组合，且满足下列条件：

只使用数字 1 到 9 每个数字   最多使用一次   返回 所有可能的有效组合的列表 。该列表不能包含相同的组合两次，组合可以以任何顺序返回。

```cpp
class Solution {
public:
    vector<vector<int>> res;
    vector<int> path;

    vector<vector<int>> combinationSum3(int k, int n) {
        dfs(n, k, 1);
        return res;
    }

    void dfs(int n, int k, int start){
        if(!n) {
            if(!k) {
                res.push_back(path);
                return;
            } else return;
        } else if (k) {
            for(int i = start; i <= 9; i++){
                if(n >= i) {
                    path.push_back(i);
                    dfs(n - i, k - 1, i + 1);
                    path.pop_back();
                }
            }
        } else return;
    }
};
```

组合题必须有一个顺序，这里用 start 开始即可，注意每一层的条件判断。

#### 17. 电话号码的字母组合

<https://leetcode.cn/problems/letter-combinations-of-a-phone-number/>

给定一个仅包含数字  2-9  的字符串，返回所有它能表示的字母组合。答案可以按 任意顺序 返回。

给出数字到字母的映射如下（与电话按键相同）。注意 1 不对应任何字母。

```cpp
class Solution {
public:
    vector<string> ans;
    string strs[10] = {
        "", "", "abc", "def",
        "ghi", "jkl", "mno",
        "pqrs", "tuv", "wxyz",
    };
    vector<string> letterCombinations(string digits) {
        if(!digits.size()) return {};
        dfs(digits, 0, "");
        return ans;
    }

    void dfs(string &digits, int u, string path){
        if(u == digits.size()) {
            ans.push_back(path);
            return;
        }

        for(auto c : strs[digits[u] - '0']) {
            dfs(digits, u + 1, path + c);
        }
    }
};
```

难点在于用一个数组来存储每一个数对应的字母。

#### 39. 组合总和

<https://leetcode.cn/problems/combination-sum/>

给你一个 无重复元素 的整数数组  candidates 和一个目标整数  target ，找出  candidates  中可以使数字和为目标数  target 的 所有   不同组合 ，并以列表形式返回。你可以按 任意顺序 返回这些组合。

candidates 中的 同一个 数字可以 无限制重复被选取 。如果至少一个数字的被选数量不同，则两种组合是不同的。

对于给定的输入，保证和为  target 的不同组合数少于 150 个。

```cpp
class Solution {
public:
    vector<vector<int>> res;
    vector<int> path;

    vector<vector<int>> combinationSum(vector<int>& candidates, int target) {
        dfs(candidates, target, 0);
        return res;
    }

    void dfs(vector<int>& candidates, int target, int u){
        if(!target) {
            res.push_back(path);
            return;
        }

        if(u == candidates.size()) return;

        for(int i = 0; target >= candidates[u] * i; i++){
            dfs(candidates, target - candidates[u] * i, u + 1);
            path.push_back(candidates[u]);
        }

        for(int i = 0; target >= candidates[u] * i; i++){
            path.pop_back();
        }
    }
};
```

第一个 for 循环中的 i 为这个数被选中的次数，第二次 for 循环恢复现场。

```cpp
class Solution {
public:
    vector<vector<int>> ans;
    vector<int> path;
    vector<vector<int>> combinationSum(vector<int>& nums, int target) {
        dfs(nums, target, 0);
        return ans;
    }

    void dfs(vector<int>& nums, int target, int u) {
        if(!target) {
            ans.push_back(path);
            return;
        }else if(target < 0) return;

        for(int i = u; i < nums.size(); i++) {
            target -= nums[i];
            path.push_back(nums[i]);
            dfs(nums, target, i);
            target += nums[i];
            path.pop_back();
        }
    }
};
```

更好的解法，每次递归时的 i + 1 改成 i，相当于给树多加了一条分支。

#### 40. 组合总和 II

<https://leetcode.cn/problems/combination-sum-ii/>

给定一个候选人编号的集合  candidates  和一个目标数  target ，找出  candidates  中所有可以使数字和为  target  的组合。

candidates  中的每个数字在每个组合中只能使用   一次  。

注意：解集不能包含重复的组合。

```cpp
class Solution {
public:
    vector<vector<int>> res;
    vector<int> path;

    vector<vector<int>> combinationSum2(vector<int>& candidates, int target) {
        sort(candidates.begin(), candidates.end());
        dfs(candidates, target, 0);
        return res;
    }

    void dfs(vector<int>& candidates, int target, int u) {
        if(!target) {
            res.push_back(path);
            return;
        }

        if(u == candidates.size()) return;

        int k = u + 1;
        while(k < candidates.size() && candidates[k] == candidates[u]) k++;
        int cnt = k - u;

        for(int i = 0; target >= candidates[u] * i && cnt >= i; i++){
            dfs(candidates, target - candidates[u] * i, k);
            path.push_back(candidates[u]);
        }

        for(int i = 0; target >= candidates[u] * i && cnt >= i; i++){
            path.pop_back();
        }
    }
};
```

这里 dfs 第二个参数为 k 实际上就是直接寻找到下个不同的数，因为在循环中已经会把相同的数考虑一遍了。

```cpp
class Solution {
public:
    vector<vector<int>> res;
    vector<int> path;

    vector<vector<int>> combinationSum2(vector<int>& candidates, int target) {
        sort(candidates.begin(), candidates.end());
        dfs(candidates, target, 0);
        return res;
    }

    void dfs(vector<int>& candidates, int target, int u) {
        if(!target) {
            res.push_back(path);
            return;
        } else if(target < 0) return;

        for(int i = u; i < candidates.size(); i++){
            if(i > u && candidates[i] == candidates[i - 1]) continue;
            path.push_back(candidates[i]);
            target -= candidates[i];
            dfs(candidates, target, i + 1);
            path.pop_back();
            target += candidates[i];
        }

    }
};
```

更好的做法，通过剪枝优化。

#### 131. 分割回文串

<https://leetcode.cn/problems/palindrome-partitioning/>

```cpp
class Solution {
public:
    vector<vector<bool>> f;
    vector<vector<string>> ans;
    vector<string> path;

    vector<vector<string>> partition(string s) {
        int n = s.size();
        f = vector<vector<bool>>(n, vector<bool>(n));
        for(int j = 0; j < n; j++){
            for(int i = 0; i <= j; i++){
                if(i == j) f[i][j] = true;
                else if(s[i] == s[j]){
                    if(f[i + 1][j - 1] || i + 1 > j - 1) f[i][j] = true;
                }
            }
        }
        dfs(s, 0);
        return ans;
    }

    void dfs(string &s, int n){
        if(n == s.size()) ans.push_back(path);
        else{
            for(int i = n; i < s.size(); i++){
                if(f[n][i]) {
                    path.push_back(s.substr(n, i - n + 1));
                    dfs(s, i + 1);
                    path.pop_back();
                }
            }
        }
    }
};
```

这道题首先会通过 f 数组将所有组合是否是回文串存起来，接着进行爆搜即可。

#### 93. 复原 IP 地址

<https://leetcode.cn/problems/restore-ip-addresses/>

```cpp
class Solution {
public:
    vector<string> ans;
    vector<string> restoreIpAddresses(string s) {
        dfs(s, 0, 0, "");
        return ans;
    }

    void dfs(string &s, int u, int k, string path){
        if(u == s.size()) {
            if(k == 4) {
                path.pop_back();
                ans.push_back(path);
            }
            return;
        }

        if(k == 4) return;

        for(int i = u, t = 0; i < s.size(); i++) {
            if(i > u && s[u] == '0') break;
            t = t * 10 + s[i] - '0';
            if(t <= 255) dfs(s, i + 1, k + 1, path + to_string(t) + ".");
            else break;
        }
    }
};
```

掌握 IP 的规则进行暴搜即可。

#### 78. 子集

<https://leetcode.cn/problems/subsets/>

给你一个整数数组 nums ，数组中的元素 互不相同 。返回该数组所有可能的子集（幂集）。

解集 不能 包含重复的子集。你可以按 任意顺序 返回解集。

```cpp
class Solution {
public:
    vector<vector<int>> ans;
    vector<int> path;
    vector<vector<int>> subsets(vector<int>& nums) {
        dfs(nums, 0);
        return ans;
    }

    void dfs(vector<int>& nums, int n){
        if(n <= nums.size()) ans.push_back(path);
        if(n > nums.size()) return;

        for(int i = n; i < nums.size(); i++){
            path.push_back(nums[i]);
            dfs(nums, i + 1);
            path.pop_back();
        }
    }
};
```

如果 u 当前数字小于 nums.size() 长度，那么就保存进结果数组，如果大于的话，直接返回; i = u 开始，i < nums.size(); 递归调用下一个 dfs(i+1)。

#### 90. 子集 II

<https://leetcode.cn/problems/subsets-ii/>

给你一个整数数组 nums ，其中可能包含重复元素，请你返回该数组所有可能的子集（幂集）。

解集 不能 包含重复的子集。返回的解集中，子集可以按 任意顺序 排列。

```cpp
class Solution {
public:
    vector<vector<int>> ans;
    vector<int> path;

    vector<vector<int>> subsetsWithDup(vector<int>& nums) {
        sort(nums.begin(), nums.end());
        dfs(nums, 0);
        return ans;
    }

    void dfs(vector<int>& nums, int u) {
        if(u == nums.size()) {
            ans.push_back(path);
            return;
        }
        int k = u;
        while(k < nums.size() && nums[k] == nums[u]) k++;
        dfs(nums, k);
        for(int i = u; i < k; i++) {
            path.push_back(nums[i]);
            dfs(nums, k);
        }
        for(int i = u; i < k; i++) path.pop_back();
    }
};
```

与上题不同的是本题可以包含重复元素，为了方便处理，我们先将数组排序，这样相同元素就会排在一起。然后暴力搜索所有方案，搜索顺序是这样的：我们先枚举每个不同的数，枚举到数 x 时，我们再求出 x 的个数 k，然后我们枚举在集合中放入 0,1,2,…k 个 x，共 k + 1 种情况。当枚举完最后一个数时，表示我们已经选定了一个集合，将该集合加入答案中即可。

```cpp
class Solution {
public:
    vector<vector<int>> ans;
    vector<int> path;

    vector<vector<int>> subsetsWithDup(vector<int>& nums) {
        sort(nums.begin(), nums.end());
        dfs(nums, 0);
        return ans;
    }

    void dfs(vector<int>& nums, int u) {
        ans.push_back(path);

        for(int i = u; i < nums.size(); i++) {
            if(i > u && nums[i] == nums[i - 1]) continue;
            path.push_back(nums[i]);
            dfs(nums, i + 1);
            path.pop_back();
        }
    }
};
```

更好理解的做法，通过剪枝来进行优化。

#### 491. 递增子序列

<https://leetcode.cn/problems/non-decreasing-subsequences/>

给你一个整数数组 nums ，找出并返回所有该数组中不同的递增子序列，递增子序列中 至少有两个元素 。你可以按 任意顺序 返回答案。

数组中可能含有重复元素，如出现两个整数相等，也可以视作递增序列的一种特殊情况。

```cpp
class Solution {
public:
    vector<vector<int>> ans;
    vector<int> path;

    vector<vector<int>> findSubsequences(vector<int>& nums) {
        dfs(nums, 0);
        return ans;
    }

    void dfs(vector<int>& nums, int u) {
        if(path.size() >= 2) ans.push_back(path);
        if(u == nums.size()) return;

        unordered_set<int> S;
        for(int i = u; i < nums.size(); i++){
            if(path.empty() || path.back() <= nums[i]){
                if(S.count(nums[i])) continue;
                S.insert(nums[i]);
                path.push_back(nums[i]);
                dfs(nums, i + 1);
                path.pop_back();
            }
        }
    }
};
```

#### 47. 全排列 II

<https://leetcode.cn/problems/permutations-ii/>

给定一个可包含重复数字的序列 nums ，按任意顺序 返回所有不重复的全排列。

```cpp
class Solution {
public:
    vector<vector<int>> ans;
    vector<int> path;
    vector<bool> st;
    vector<vector<int>> permuteUnique(vector<int>& nums) {
        st = vector<bool>(nums.size());
        dfs(nums, 0);
        return ans;
    }

    void dfs(vector<int>& nums, int u) {
        if(u == nums.size()) {
            ans.push_back(path);
            return;
        }

        unordered_set<int> S;

        for(int i = 0; i < nums.size(); i++){
            if(!st[i]) {
                if(S.count(nums[i])) continue;
                S.insert(nums[i]);
                st[i] = true;
                path.push_back(nums[i]);
                dfs(nums, u + 1);
                st[i] = false;
                path.pop_back();
            }
        }
    }
};
```

用一个哈希集合来进行判重即可。

#### 79. 单词搜索

<https://leetcode.cn/problems/word-search/>

给定一个  m x n 二维字符网格  board 和一个字符串单词  word 。如果  word 存在于网格中，返回 true ；否则，返回 false 。

单词必须按照字母顺序，通过相邻的单元格内的字母构成，其中“相邻”单元格是那些水平相邻或垂直相邻的单元格。同一个单元格内的字母不允许被重复使用。

```cpp
class Solution {
public:
    bool exist(vector<vector<char>>& board, string word) {
        if(board.empty() || board[0].empty()) return false;
        int n = board.size(), m = board[0].size();
        for(int i = 0; i < n; i++) {
            for(int j = 0; j < m; j++) {
                if(dfs(board, word, 0, i, j)) return true;
            }
        }
        return false;
    }

    int dx[4] = {-1, 0, 1, 0}, dy[4] = {0, 1, 0, -1};

    bool dfs(vector<vector<char>>& board, string word, int u, int x, int y) {
        if(board[x][y] != word[u]) return false;
        if(u == word.size() - 1) return true;
        int n = board.size(), m = board[0].size();
        int t = board[x][y];
        board[x][y] = '.';
        for(int i = 0; i < 4; i++) {
            int a = x + dx[i], b = y + dy[i];
            if(a < 0 || b < 0 || a >= n || b >= m || board[a][b] == '.') continue;
            if(dfs(board, word, u + 1, a, b)) return true;
        }
        board[x][y] = t;
        return false;
    }
};
```

通过深搜，上下左右找即可。

#### 200. 岛屿数量

<https://leetcode.cn/problems/number-of-islands/>

给你一个由  '1'（陆地）和 '0'（水）组成的的二维网格，请你计算网格中岛屿的数量。

岛屿总是被水包围，并且每座岛屿只能由水平方向和/或竖直方向上相邻的陆地连接形成。

此外，你可以假设该网格的四条边均被水包围。

```cpp
class Solution {
public:
    vector<vector<char>> g;
    int dx[4] = {-1, 0, 1, 0}, dy[4] = {0, 1, 0, -1};
    int numIslands(vector<vector<char>>& grid) {
        g = grid;
        int cnt = 0;
        for(int i = 0; i < g.size(); i++) {
            for(int j = 0; j < g[i].size(); j++) {
                if(g[i][j] == '1') {
                    dfs(i, j);
                    cnt++;
                }
            }
        }
        return cnt;
    }

    void dfs(int x, int y) {
        g[x][y] = 0;
        for(int i = 0; i < 4; i++) {
            int a = x + dx[i], b = y + dy[i];
            if(a >= 0 && b >= 0 && a < g.size() && b < g[a].size() && g[a][b] == '1') dfs(a, b);
        }
    }
};
```

经典洪水算法，模版题。

#### 22. 括号生成

<https://leetcode.cn/problems/generate-parentheses/>

数字 n  代表生成括号的对数，请你设计一个函数，用于能够生成所有可能的并且 有效的 括号组合。

```cpp
class Solution {
public:
    vector<string> generateParenthesis(int n) {
        vector<string> res;
        string path;
        dfs(n, n, res, path);
        return res;
    }

    void dfs(int l, int r, vector<string>& res, string& path) {
        if(l < 0 || r < 0) return;
        if(l > r) return;
        if(l == 0 && r == 0) res.push_back(path);

        path.push_back('(');
        dfs(l - 1, r, res, path);
        path.pop_back();

        path.push_back(')');
        dfs(l, r - 1, res, path);
        path.pop_back();
    }
};
```

#### 4. 寻找两个正序数组的中位数

<https://leetcode.cn/problems/median-of-two-sorted-arrays/>

给定两个大小分别为 m 和 n 的正序（从小到大）数组  nums1 和  nums2。请你找出并返回这两个正序数组的 中位数 。

算法的时间复杂度应该为 O(log (m+n)) 。

```cpp
class Solution {
public:
    double findMedianSortedArrays(vector<int>& nums1, vector<int>& nums2) {
        int tot = nums1.size() + nums2.size();
        if (tot % 2 == 0) {
            int left = find(nums1, 0, nums2, 0, tot / 2);
            int right = find(nums1, 0, nums2, 0, tot / 2 + 1);
            return (left + right) / 2.0;
        } else return find(nums1, 0, nums2, 0, tot / 2 + 1);
    }

    int find(vector<int>& nums1, int i, vector<int>& nums2, int j, int k) {
        if (nums1.size() - i > nums2.size() - j) return find(nums2, j, nums1, i, k);
        if (k == 1) {
            if (nums1.size() == i) return nums2[j];
            else return min(nums1[i], nums2[j]);
        }
        if (nums1.size() == i) return nums2[j + k - 1];
        int si = min((int)nums1.size(), i + k / 2), sj = j + k - k / 2;
        if (nums1[si - 1] > nums2[sj - 1]) return find(nums1, i, nums2, sj, k - (sj - j));
        else return find(nums1, si, nums2, j, k - (si - i));
    }
};
```


# BFS

#### 116. 填充每个节点的下一个右侧节点指针

<https://leetcode.cn/problems/populating-next-right-pointers-in-each-node/>

给定一个 完美二叉树 ，其所有叶子节点都在同一层，每个父节点都有两个子节点。二叉树定义如下：

```cpp
struct Node {
int val;
Node *left;
Node *right;
Node *next;
}
填充它的每个 next 指针，让这个指针指向其下一个右侧节点。如果找不到下一个右侧节点，则将 next 指针设置为 NULL。
```

初始状态下，所有 next 指针都被设置为 NULL。

```cpp
/*
// Definition for a Node.
class Node {
public:
    int val;
    Node* left;
    Node* right;
    Node* next;

    Node() : val(0), left(NULL), right(NULL), next(NULL) {}

    Node(int _val) : val(_val), left(NULL), right(NULL), next(NULL) {}

    Node(int _val, Node* _left, Node* _right, Node* _next)
        : val(_val), left(_left), right(_right), next(_next) {}
};
*/

class Solution {
public:
    Node* connect(Node* root) {
        if(!root) return root;
        auto src = root;
        while(root->left){
            for(auto p = root; p; p = p->next){
                p->left->next = p->right;
                if(p->next) p->right->next = p->next->left;
                else p->right->next = 0;
            }
            root = root->left;
        }
        return src;
    }
};
```

从根节点开始宽度优先遍历，每次遍历一层，遍历时按从左到右的顺序，对于每个节点，先让左儿子指向右儿子，然后让右儿子指向下一个节点的左儿子。最后让这一层最右侧的节点指向 NULL。遍历到叶节点所在的层为止。

#### 542. 01 矩阵

<https://leetcode.cn/problems/01-matrix/>

给定一个由 0 和 1 组成的矩阵 mat ，请输出一个大小相同的矩阵，其中每一个格子是 mat 中对应位置元素到最近的 0 的距离。

两个相邻元素间的距离为 1 。

```cpp
class Solution {
public:
    vector<vector<int>> updateMatrix(vector<vector<int>>& matrix) {
        if(matrix.empty() || matrix[0].empty()) return matrix;

        int n = matrix.size(), m = matrix[0].size();
        vector<vector<int>> res(n, vector<int>(m, -1));

        typedef pair<int, int> PII;
        queue<PII> q;

        for(int i = 0; i < n; i++){
            for(int j = 0; j < m; j++){
                if(matrix[i][j] == 0){
                    q.push({i, j});
                    res[i][j] = 0;
                }
            }
        }

        int dx[4] = {-1, 0, 1, 0}, dy[4] = {0, 1, 0, -1};
        while(q.size()){
            auto t = q.front();
            q.pop();

            for(int i = 0; i < 4; i++){
                int a = t.first + dx[i], b = t.second + dy[i];
                if(a >= 0 && b >= 0 && a < n && b < m && res[a][b] == -1){
                    res[a][b] = res[t.first][t.second] + 1;
                    q.push({a, b});
                }
            }
        }
        return res;
    }
};
```

首先判空返回，然后开一个存结果的数组，全部置为-1。接着开一个队列用来跑每个格子，用两个 for 循环把为 0 的格子放到队列中，并且把结果数组的值置为 0，因为此时自己就是 0，距离自然也为 0。接下来是 BFS 宽搜，用到经典 dx、dy 。遍历上下左右四个格子，为 -1 的就是还没有进行检查的，检查完后放进队列，最后返回结果数组。

#### 994. 腐烂的橘子

<https://leetcode.cn/problems/rotting-oranges/>

在给定的 m x n 网格 grid 中，每个单元格可以有以下三个值之一：

值 0 代表空单元格； 值 1 代表新鲜橘子； 值 2 代表腐烂的橘子。 每分钟，腐烂的橘子 周围 4 个方向上相邻 的新鲜橘子都会腐烂。

返回 直到单元格中没有新鲜橘子为止所必须经过的最小分钟数。如果不可能，返回 -1 。

```cpp
class Solution {
public:
    int orangesRotting(vector<vector<int>>& g) {
        int n = g.size(), m = g[0].size();
        queue<pair<int, int>> q;
        for(int i = 0; i < n; i++){
            for(int j = 0; j < m; j++){
                if(g[i][j] == 2) q.push({i, j});
            }
        }

        int dx[4] = {-1, 0, 1, 0}, dy[4] = {0, 1, 0, -1};
        int res = 0;
        if(q.size()) res--;
        while(q.size()){
            res++;
            int cnt = q.size();
            while(cnt--){
                auto t = q.front();
                q.pop();
                for(int i = 0; i < 4; i++){
                    int a = t.first + dx[i], b = t.second + dy[i];
                    if(a >= 0 && b >= 0 && a < n && b < m && g[a][b] == 1){
                        g[a][b] = 2;
                        q.push({a, b});
                    }
                }
            }
        }

        for(int i = 0; i < n; i++){
            for(int j = 0; j < m; j++){
                if(g[i][j] == 1) return -1;
            }
        }
        return res;
    }
};
```

和上面的 01 矩阵基本类似。

#### 207. 课程表

<https://leetcode.cn/problems/course-schedule/>

你这个学期必须选修 numCourses 门课程，记为  0  到  numCourses - 1 。

在选修某些课程之前需要一些先修课程。 先修课程按数组  prerequisites 给出，其中  prerequisites\[i] = \[ai, bi] ，表示如果要学习课程  ai 则 必须 先学习课程   bi 。

例如，先修课程对  \[0, 1] 表示：想要学习课程 0 ，你需要先完成课程 1 。 请你判断是否可能完成所有课程的学习？如果可以，返回 true ；否则，返回 false 。

```cpp
class Solution {
public:
    bool canFinish(int n, vector<vector<int>>& prerequisites) {
        vector<vector<int>> g(n);
        vector<int> d(n);
        for(auto p : prerequisites) {
            int a = p[0], b = p[1];
            g[a].push_back(b);
            d[b] ++;
        }
        queue<int> q;
        for(int i = 0; i < n; i++) {
            if(!d[i]) q.push(i);
        }
        int cnt = 0;
        while(q.size()) {
            int t = q.front();
            q.pop();
            cnt ++;
            for(int i : g[t]) if(-- d[i] == 0) q.push(i);
        }
        return cnt == n;
    }
};
```

#### 958. 二叉树的完全性检验

<https://leetcode.cn/problems/check-completeness-of-a-binary-tree/>

给定一个二叉树的  root ，确定它是否是一个   完全二叉树  。

在一个   完全二叉树   中，除了最后一个关卡外，所有关卡都是完全被填满的，并且最后一个关卡中的所有节点都是尽可能靠左的。它可以包含  1  到  2h  节点之间的最后一级 h 。

```cpp
/**
 * Definition for a binary tree node.
 * struct TreeNode {
 *     int val;
 *     TreeNode *left;
 *     TreeNode *right;
 *     TreeNode() : val(0), left(nullptr), right(nullptr) {}
 *     TreeNode(int x) : val(x), left(nullptr), right(nullptr) {}
 *     TreeNode(int x, TreeNode *left, TreeNode *right) : val(x), left(left), right(right) {}
 * };
 */
class Solution {
public:
    bool isCompleteTree(TreeNode* root) {
        if(!root) return true;
        queue<TreeNode*> q;
        q.push(root);
        while(q.size()) {
            auto t = q.front();
            q.pop();
            if(!t) break;
            q.push(t->left), q.push(t->right);
        }
        while(q.size()) {
            auto t = q.front();
            q.pop();
            if(t) return false;
        }
        return true;
    }
};
```


# 位运算

#### 231. 2 的幂

<https://leetcode.cn/problems/power-of-two/>

给你一个整数 n，请你判断该整数是否是 2 的幂次方。如果是，返回 true ；否则，返回 false 。

如果存在一个整数 x 使得 n == 2x ，则认为 n 是 2 的幂次方。

```cpp
class Solution {
public:
    bool isPowerOfTwo(int n) {
        return n > 0 && (n & -n) == n;
    }
};
```

通过 `n & -n` 获得最后一位 1，在二进制中只会有一位 1，所以那个 1 转化为十进制就为 n 。

#### 191. 位 1 的个数

<https://leetcode.cn/problems/number-of-1-bits/>

编写一个函数，输入是一个无符号整数（以二进制串的形式），返回其二进制表达式中数字位数为 '1' 的个数（也被称为[汉明重量](https://baike.baidu.com/item/汉明重量)）。

```cpp
class Solution {
public:
    int hammingWeight(uint32_t n) {
        int sum = 0;
        while(n > 0){
            n = n & (n - 1);
            sum++;
        }
        return sum;
    }
};
```

通过 `n = n & (n - 1)` 每次都能消灭一个 1，直到 n 为 0 时退出即可。

#### 190. 颠倒二进制位

<https://leetcode.cn/problems/reverse-bits/>

颠倒给定的 32 位无符号整数的二进制位。

```cpp
class Solution {
public:
    uint32_t reverseBits(uint32_t n) {
        uint32_t res = 0;
        for(int i = 0; i < 32; i++){
            res = (res << 1) + (n >> i & 1);
        }
        return res;
    }
};
```

每次将 res 左移一位，值得一提的是 `res << 1` 与 `res * 2` 相等。然后取出 n 的第 i 位即可。

#### 50. Pow(x, n)

<https://leetcode.cn/problems/powx-n/>

实现 pow(x, n) ，即计算 x 的整数 n 次幂函数（即，xn ）。

```cpp
class Solution {
public:
    double myPow(double x, int n) {
        typedef long long ll;
        double res = 1;
        for(ll i = abs(ll(n)); i; i >>= 1) {
            if(i & 1) res *= x;
            x *= x;
        }
        if(n < 0) return 1 / res;
        return res;
    }
};
```

按照定义，计算 x 的 n 次方是将 nn 个 x 连乘，效率比较低，会超时。因为乘法具有结合律，考虑每次将一部分连乘批量计算好，作为最终答案的一部分。这就可以将 n 进行二进制拆分，若 n 的二进制位的第 k 位是 1，则 ans 可以乘上 x2k。 而计算 x2k，只需每次将自身做平方即可。

#### 136. 只出现一次的数字

<https://leetcode.cn/problems/single-number/>

给你一个 非空 整数数组 nums ，除了某个元素只出现一次以外，其余每个元素均出现两次。找出那个只出现了一次的元素。

你必须设计并实现线性时间复杂度的算法来解决此问题，且该算法只使用常量额外空间。

```cpp
class Solution {
public:
    int singleNumber(vector<int>& nums) {
        int res = 0;
        for(auto i : nums) res ^= i;
        return res;
    }
};
```

进行异或操作。


# 模拟

#### 59. 螺旋矩阵 II

<https://leetcode.cn/problems/spiral-matrix-ii/>

给你一个正整数 `n` ，生成一个包含 `1` 到 `n2` 所有元素，且元素按顺时针顺序螺旋排列的 `n x n` 正方形矩阵 `matrix` 。

**模拟**

```cpp
class Solution {
public:
    vector<vector<int>> generateMatrix(int n) {
        vector<vector<int>> res(n, vector<int>(n, 0));
        int sx = 0, sy = 0, offset = 1;
        int loop = n / 2, count = 1;
        while(loop --){
            int i = sx, j = sy;

            for(; j < n - offset; j++)
                res[i][j] = count++;
            for(; i < n - offset; i++)
                res[i][j] = count++;
            for(; j > sx; j--)
                res[i][j] = count++;
            for(; i > sy; i--)
                res[i][j] = count++;

            sx++, sy++, offset++;
        }
        if(n % 2 != 0) res[n / 2][n / 2] = count;
        return res;
    }
};
```

模拟即可，注意每次要更新 x, y 的起点与边界值 offset 。

**偏移量**

```cpp
class Solution {
public:
    vector<vector<int>> generateMatrix(int n) {
        vector<vector<int>> res(n, vector<int>(n, 0));
        int dx[4] = {0, 1, 0, -1}, dy[4] = {1, 0, -1, 0};
        vector<vector<bool>> st(n, vector<bool>(n, false));
        for(int i = 0, x = 0, y = 0, d = 0, count = 1; i < n * n; i++){
            res[x][y] = count++, st[x][y] = true;
            int a = x + dx[d], b = y + dy[d];
            if(a < 0 || a >= n || b < 0 || b >= n || st[a][b]){
                d = (d + 1) % 4;
                a = x + dx[d], b = y + dy[d];
            }
            x = a, y = b;
        }
        return res;
    }
};
```

模拟狗都不写。

#### 54. 螺旋矩阵

<https://leetcode.cn/problems/spiral-matrix/>

给你一个 `m` 行 `n` 列的矩阵 `matrix` ，请按照 **顺时针螺旋顺序** ，返回矩阵中的所有元素。

```cpp
class Solution {
public:
    vector<int> spiralOrder(vector<vector<int>>& matrix) {
        vector<int> res;
        int n = matrix.size(), m = matrix[0].size();
        if(!n) return res;
        vector<vector<bool>> st(n, vector<bool>(m, false));
        int dx[4] = {0, 1, 0, -1}, dy[4] = {1, 0, -1, 0};
        for(int i = 0, x = 0, y = 0, d = 0; i < n * m; i++){
            res.push_back(matrix[x][y]);
            st[x][y] = true;
            int a = x + dx[d], b = y + dy[d];
            if(a < 0 || b < 0 || a >= n || b >= m || st[a][b]){
                d = (d + 1) % 4;
                a = x + dx[d], b = y + dy[d];
            }
            x = a, y = b;
        }
        return res;
    }
};
```

使用偏移量来做，模拟？狗都不写！

#### 415. 字符串相加

<https://leetcode.cn/problems/add-strings/>

给定两个字符串形式的非负整数  num1 和 num2 ，计算它们的和并同样以字符串形式返回。

你不能使用任何內建的用于处理大整数的库（比如 BigInteger），  也不能直接将输入的字符串转换为整数形式。

```cpp
class Solution {
public:
    vector<int> add(vector<int>& A, vector<int>& B) {
        vector<int> C;
        for(int i = 0, t = 0; i < A.size() || i < B.size() || t; i++) {
            if(i < A.size()) t += A[i];
            if(i < B.size()) t += B[i];
            C.push_back(t % 10);
            t /= 10;
        }
        return C;
    }

    string addStrings(string num1, string num2) {
        vector<int> A, B;
        for(int i = num1.size() - 1; i >= 0; i--) A.push_back(num1[i] - '0');
        for(int i = num2.size() - 1; i >= 0; i--) B.push_back(num2[i] - '0');
        auto C = add(A, B);
        string c;
        for(int i = C.size() - 1; i >= 0; i--) c += to_string(C[i]);
        return c;
    }
};
```


# 剑指 Offer

#### 剑指 Offer 05. 替换空格

<https://leetcode.cn/problems/ti-huan-kong-ge-lcof/>

请实现一个函数，把字符串 s 中的每个空格替换成"%20"。

```cpp
class Solution {
public:
    string replaceSpace(string s) {
        int cnt = 0, l = s.length();
        for(int i = 0; i < l; i++){
            if(s[i] == ' ') cnt++;
        }
        s.resize(s.length() + 2 * cnt);
        for(int i = l - 1, j = s.length() - 1; i >= 0; i--){
            if(s[i] != ' '){
                s[j--] = s[i];
            }else{
                s[j--] = '0';
                s[j--] = '2';
                s[j--] = '%';
            }
        }
        return s;
    }
};
```

先把字符串开大，然后反着遍历字符串，将空格替换即可。

```cpp
class Solution {
public:
    string replaceSpace(string s) {
        string res;
        for(auto c : s) {
            if(c == ' ') {
                res.push_back('%');
                res.push_back('2');
                res.push_back('0');
            } else res.push_back(c);
        }
        return res;
    }
};
```

简单做法，非原地。

#### 剑指 Offer 58 - II. 左旋转字符串

<https://leetcode.cn/problems/zuo-xuan-zhuan-zi-fu-chuan-lcof/>

字符串的左旋转操作是把字符串前面的若干个字符转移到字符串的尾部。请定义一个函数实现字符串左旋转操作的功能。比如，输入字符串"abcdefg"和数字 2，该函数将返回左旋转两位得到的结果"cdefgab"。

```cpp
class Solution {
public:
    string reverseLeftWords(string s, int n) {
        reverse(s.begin(), s.end());
        reverse(s.begin(), s.end() - n);
        reverse(s.end() - n, s.end());
        return s;
    }
};
```

三个 `reverse` 就能解决战斗。

#### 剑指 Offer 22. 链表中倒数第 k 个节点

<https://leetcode.cn/problems/lian-biao-zhong-dao-shu-di-kge-jie-dian-lcof/>

输入一个链表，输出该链表中倒数第 k 个节点。为了符合大多数人的习惯，本题从 1 开始计数，即链表的尾节点是倒数第 1 个节点。

例如，一个链表有 6 个节点，从头节点开始，它们的值依次是 1、2、3、4、5、6。这个链表的倒数第 3 个节点是值为 4 的节点。

```cpp
/**
 * Definition for singly-linked list.
 * struct ListNode {
 *     int val;
 *     ListNode *next;
 *     ListNode(int x) : val(x), next(NULL) {}
 * };
 */
class Solutioxn {
public:
    ListNode* getKthFromEnd(ListNode* head, int k) {
        auto p1 = head, p2 = head;
        for(int i = 0; i < k; i++){
            p1 = p1->next;
        }
        while(p1){
            p1 = p1->next;
            p2 = p2->next;
        }
        return p2;
    }
};
```

首先，我们先让一个指针 p1 指向链表的头节点 head，然后走 k 步，现在的 p1，只要再走 n - k 步，就能走到链表末尾的空指针了，趁这个时候，再用一个指针 p2 指向链表头节点 head， 接下来就很显然了，让 p1 和 p2 同时向前走，p1 走到链表末尾的空指针时前进了 n - k 步，p2 也从 head 开始前进了 n - k 步，停留在第 n - k + 1 个节点上，即恰好停链表的倒数第 k 个节点上。

#### 剑指 Offer 25. 合并两个排序的链表

<https://leetcode.cn/problems/he-bing-liang-ge-pai-xu-de-lian-biao-lcof/>

输入两个递增排序的链表，合并这两个链表并使新链表中的节点仍然是递增排序的。

```cpp
/**
 * Definition for singly-linked list.
 * struct ListNode {
 *     int val;
 *     ListNode *next;
 *     ListNode(int x) : val(x), next(NULL) {}
 * };
 */
class Solution {
public:
    ListNode* mergeTwoLists(ListNode* l1, ListNode* l2) {
        auto dummy = new ListNode(), p = dummy;
        while(l1 && l2){
            if(l1->val < l2->val){
                p = p->next = l1;
                l1 = l1->next;
            }else{
                p = p->next = l2;
                l2 = l2->next;
            }
        }
        if(l1) p->next = l1;
        if(l2) p->next = l2;
        return dummy->next;
    }
};
```

和主站 21 题一样，主要在于需要一个虚拟头结点和一个尾节点，清楚这两点这道题还是非常简单的。

#### 剑指 Offer 52. 两个链表的第一个公共节点

<https://leetcode.cn/problems/liang-ge-lian-biao-de-di-yi-ge-gong-gong-jie-dian-lcof/>

输入两个链表，找出它们的第一个公共节点。

```cpp
/**
 * Definition for singly-linked list.
 * struct ListNode {
 *     int val;
 *     ListNode *next;
 *     ListNode(int x) : val(x), next(NULL) {}
 * };
 */
class Solution {
public:
    ListNode *getIntersectionNode(ListNode *headA, ListNode *headB) {
        auto a = headA, b = headB;
        while(a != b){
            if(a) a = a->next;
            else a = headB;
            if(b) b = b->next;
            else b = headA;
        }
        return a;
    }
};
```

遍历一边到头后去另一边，最后到入口处两边走的距离肯定相同。

#### 剑指 Offer II 021. 删除链表的倒数第 n 个结点

<https://leetcode.cn/problems/SLwz0R/>

给定一个链表，删除链表的倒数第 n 个结点，并且返回链表的头结点。

```cpp
/**
 * Definition for singly-linked list.
 * struct ListNode {
 *     int val;
 *     ListNode *next;
 *     ListNode() : val(0), next(nullptr) {}
 *     ListNode(int x) : val(x), next(nullptr) {}
 *     ListNode(int x, ListNode *next) : val(x), next(next) {}
 * };
 */
class Solution {
public:
    ListNode* removeNthFromEnd(ListNode* head, int n) {
        auto dummy = new ListNode();
        dummy->next = head;
        auto p = dummy, q = dummy;
        for(int i = 0; i < n + 1; i++) q = q->next;
        while(q){
            p = p->next;
            q = q->next;
        }
        p->next = p->next->next;
        return dummy->next;
    }
};
```

我们先走到链表的倒数 n + 1 这个点，再删除他的下一个结点即可。注意使用虚拟头结点。

#### 剑指 Offer II 022. 链表中环的入口节点

<https://leetcode.cn/problems/c32eOV/>

给定一个链表，返回链表开始入环的第一个节点。 从链表的头节点开始沿着 next 指针进入环的第一个节点为环的入口节点。如果链表无环，则返回  null。

为了表示给定链表中的环，我们使用整数 pos 来表示链表尾连接到链表中的位置（索引从 0 开始）。 如果 pos 是 -1，则在该链表中没有环。注意，pos 仅仅是用于标识环的情况，并不会作为参数传递到函数中。

```cpp
/**
 * Definition for singly-linked list.
 * struct ListNode {
 *     int val;
 *     ListNode *next;
 *     ListNode(int x) : val(x), next(NULL) {}
 * };
 */
class Solution {
public:
    ListNode *detectCycle(ListNode *head) {
        auto p = head, q = head;
        while(q && q->next){
            p = p->next;
            q = q->next->next;
            if(p == q) break;
        }
        if(!q || !q->next) return NULL;
        p = head;
        while(q != p){
            p = p->next;
            q = q->next;
        }
        return p;
    }
};
```

经典题，先用快慢指针直到两个点相遇，如果快指针指到空那么说明没有环。有环的话则让慢指针回到头结点，再以相同的速度一起往前走，相遇的位置则是入口节点。

#### 剑指 Offer II 078. 合并排序链表

<https://leetcode.cn/problems/vvXgSW/>

给定一个链表数组，每个链表都已经按升序排列。

请将所有链表合并到一个升序链表中，返回合并后的链表。

```cpp
/**
 * Definition for singly-linked list.
 * struct ListNode {
 *     int val;
 *     ListNode *next;
 *     ListNode() : val(0), next(nullptr) {}
 *     ListNode(int x) : val(x), next(nullptr) {}
 *     ListNode(int x, ListNode *next) : val(x), next(next) {}
 * };
 */
class Solution {
public:
    struct Cmp {
        bool operator()(ListNode *a, ListNode *b){
            return a->val > b->val;
        }
    };

    ListNode* mergeKLists(vector<ListNode*>& lists) {
        auto dummy = new ListNode(), p = dummy;
        priority_queue<ListNode*, vector<ListNode*>, Cmp> heap;
        for(auto h : lists) if(h) heap.push(h);

        while(heap.size()){
            auto t = heap.top();
            heap.pop();
            p = p->next = t;
            if(t->next) heap.push(t->next);
        }
        return dummy->next;
    }
};
```

注意构造一个 Cmp 的结构体传入优先级队列。

#### 剑指 Offer 57. 和为 s 的两个数字

<https://leetcode.cn/problems/he-wei-sde-liang-ge-shu-zi-lcof/>

输入一个递增排序的数组和一个数字 s，在数组中查找两个数，使得它们的和正好是 s。如果有多对数字的和等于 s，则输出任意一对即可。

```cpp
class Solution {
public:
    vector<int> twoSum(vector<int>& nums, int target) {
        int i = 0, j = nums.size() - 1;
        while(i < j){
            if(nums[i] + nums[j] < target) i++;
            else if(nums[i] + nums[j] > target) j--;
            else return {nums[i], nums[j]};
        }
        return {};
    }
};
```

这道题用哈希表做也是一样的，不过用快慢指针感觉更有普遍意义。

#### 剑指 Offer II 006. 排序数组中两个数字之和

<https://leetcode.cn/problems/kLl5u1/>

给定一个已按照 升序排列   的整数数组  numbers ，请你从数组中找出两个数满足相加之和等于目标数  target 。

函数应该以长度为 2 的整数数组的形式返回这两个数的下标值。numbers 的下标 从 0  开始计数 ，所以答案数组应当满足 0 <= answer\[0] < answer\[1] < numbers.length 。

假设数组中存在且只存在一对符合条件的数字，同时一个数字不能使用两次。

```cpp
class Solution {
public:
    vector<int> twoSum(vector<int>& numbers, int target) {
        int i = 0, j = numbers.size() - 1;
        while(i < j){
            if(numbers[i] + numbers[j] > target) j--;
            else if(numbers[i] + numbers[j] < target) i++;
            else return {i, j};
        }
        return {};
    }
};
```

和上一题是同样的思路，使用快慢指针即可。

#### 剑指 Offer 55 - I. 二叉树的深度

<https://leetcode.cn/problems/er-cha-shu-de-shen-du-lcof/>

输入一棵二叉树的根节点，求该树的深度。从根节点到叶节点依次经过的节点（含根、叶节点）形成树的一条路径，最长路径的长度为树的深度。

```cpp
/**
 * Definition for a binary tree node.
 * struct TreeNode {
 *     int val;
 *     TreeNode *left;
 *     TreeNode *right;
 *     TreeNode(int x) : val(x), left(NULL), right(NULL) {}
 * };
 */
class Solution {
public:
    int maxDepth(TreeNode* root) {
        if(!root) return 0;
        int l = maxDepth(root->left);
        int r = maxDepth(root->right);
        return max(l, r) + 1;
    }
};
```

分解成找左右子树的深度即可，比传统的 dfs 更简单。

#### 剑指 Offer II 103. 最少的硬币数目

<https://leetcode.cn/problems/gaM7Ch/>

给定不同面额的硬币 coins 和一个总金额 amount。编写一个函数来计算可以凑成总金额所需的最少的硬币个数。如果没有任何一种硬币组合能组成总金额，返回  -1。

```cpp
class Solution {
public:
    int coinChange(vector<int>& coins, int amount) {
        vector<int> dp(amount + 1, amount + 1);
        dp[0] = 0;
        for(int i = 0; i < dp.size(); i++){
            for(int coin : coins) {
                if(i - coin < 0) continue;
                dp[i] = min(dp[i], dp[i - coin] + 1);
            }
        }
        return dp[amount] == amount + 1 ? -1 : dp[amount];
    }
};
```

动态规划题，这里是自底向上的迭代方法来完成。

#### 剑指 Offer II 083. 没有重复元素集合的全排列

<https://leetcode.cn/problems/VvJkup/>

给定一个不含重复数字的整数数组 nums ，返回其 所有可能的全排列 。可以 按任意顺序 返回答案。

```cpp
class Solution {
public:
    vector<vector<int>> ans;
    vector<int> path;
    vector<bool> st;
    vector<vector<int>> permute(vector<int>& nums) {
        st = vector<bool>(nums.size());
        dfs(nums, 0);
        return ans;
    }

    void dfs(vector<int>& nums, int u) {
        if(u == nums.size()) {
            ans.push_back(path);
            return;
        }

        for(int i = 0; i < nums.size(); i++) {
            if(!st[i]) {
                st[i] = true;
                path.push_back(nums[i]);
                dfs(nums, u + 1);
                st[i] = false;
                path.pop_back();
            }
        }
    }
};
```

经典回溯问题，用一个存答案，一个存状态，其他的就是经典模版。

#### 剑指 Offer 53 - I. 在排序数组中查找数字 I

<https://leetcode.cn/problems/zai-pai-xu-shu-zu-zhong-cha-zhao-shu-zi-lcof/>

统计一个数字在排序数组中出现的次数。

```cpp
class Solution {
public:
    int search(vector<int>& nums, int target) {
        if(!nums.size()) return 0;
        int i, j;
        int l = 0, r = nums.size() - 1;
        while(l < r) {
            int mid = l + r >> 1;
            if(nums[mid] >= target) r = mid;
            else l = mid + 1;
        }
        if(nums[r] != target) return 0;
        i = r;
        l = 0, r = nums.size() - 1;
        while(l < r) {
            int mid = l + r + 1>> 1;
            if(nums[mid] <= target) l = mid;
            else r = mid - 1;
        }
        if(nums[l] != target) return 0;
        j = l;
        return j - i + 1;
    }
};
```

用二分分别算左右两边数字的下标，最后算差值即可。

#### 剑指 Offer II 079. 所有子集

<https://leetcode.cn/problems/TVdhkn/>

给定一个整数数组 nums ，数组中的元素 互不相同 。返回该数组所有可能的子集（幂集）。

解集 不能 包含重复的子集。你可以按 任意顺序 返回解集。

```cpp
class Solution {
public:
    vector<vector<int>> ans;
    vector<int> path;
    vector<vector<int>> subsets(vector<int>& nums) {
        dfs(nums, 0);
        return ans;
    }

    void dfs(vector<int>& nums, int u) {
        if(u == nums.size()) {
            ans.push_back(path);
            return;
        } else if(u < nums.size()) ans.push_back(path);
        else return;

        for(int i = u; i < nums.size(); i++) {
            path.push_back(nums[i]);
            dfs(nums, i + 1);
            path.pop_back();
        }
    }
};
```

注意退出的条件判断。

#### 剑指 Offer II 080. 含有 k 个元素的组合

<https://leetcode.cn/problems/uUsW3B/>

```cpp
class Solution {
public:
    vector<vector<int>> ans;
    vector<int> path;
    vector<vector<int>> combine(int n, int k) {
        dfs(n, k, 1);
        return ans;
    }

    void dfs(int n, int k, int u) {
        if(path.size() == k) {
            ans.push_back(path);
            return;
        }

        for(int i = u; i <= n; i++) {
            path.push_back(i);
            dfs(n, k, i + 1);
            path.pop_back();
        }
    }
};
```

和上一题类似，不过只需要找一层的即可。

#### 剑指 Offer II 081. 允许重复选择元素的组合

<https://leetcode.cn/problems/Ygoe9J/>

给定一个无重复元素的正整数数组  candidates  和一个正整数  target ，找出  candidates  中所有可以使数字和为目标数  target  的唯一组合。

candidates  中的数字可以无限制重复被选取。如果至少一个所选数字数量不同，则两种组合是不同的。

对于给定的输入，保证和为  target 的唯一组合数少于 150 个。

```cpp
class Solution {
public:
    vector<vector<int>> ans;
    vector<int> path;
    vector<vector<int>> combinationSum(vector<int>& candidates, int target) {
        dfs(candidates, target, 0);
        return ans;
    }

    void dfs(vector<int>& nums, int target, int u) {
        if(!target) {
            ans.push_back(path);
            return;
        } else if(target < 0) return;

        for(int i = u; i < nums.size(); i++) {
            path.push_back(nums[i]);
            target -= nums[i];
            dfs(nums, target, i);
            path.pop_back();
            target += nums[i];
        }
    }
};
```

因为数字可以重复用，所以以往的 i + 1 在这里变成 i 就行了。

#### 剑指 Offer II 082. 含有重复元素集合的组合

<https://leetcode.cn/problems/4sjJUc/>

给定一个可能有重复数字的整数数组  candidates  和一个目标数  target ，找出  candidates  中所有可以使数字和为  target  的组合。

candidates  中的每个数字在每个组合中只能使用一次，解集不能包含重复的组合。

```cpp
class Solution {
public:
    vector<vector<int>> ans;
    vector<int> path;
    vector<vector<int>> combinationSum2(vector<int>& candidates, int target) {
        sort(candidates.begin(), candidates.end());
        dfs(candidates, target, 0);
        return ans;
    }

    void dfs(vector<int>& nums, int target, int u) {
        if(!target) {
            ans.push_back(path);
            return;
        } else if(target < 0) return;

        for(int i = u; i < nums.size(); i++) {
            if(i > u && nums[i] == nums[i - 1]) continue;
            path.push_back(nums[i]);
            target -= nums[i];
            dfs(nums, target, i + 1);
            path.pop_back();
            target += nums[i];
        }
    }
};
```

和上一题不一样的点在于这里需要剪枝优化，把一样数字的给剪掉。

#### 剑指 Offer II 084. 含有重复元素集合的全排列

<https://leetcode.cn/problems/7p8L0Z/>

给定一个可包含重复数字的整数集合 nums ，按任意顺序 返回它所有不重复的全排列。

```cpp
class Solution {
public:
    vector<vector<int>> ans;
    vector<int> path;
    vector<bool> st;
    vector<vector<int>> permuteUnique(vector<int>& nums) {
        st = vector<bool>(nums.size());
        dfs(nums, 0);
        return ans;
    }

    void dfs(vector<int>& nums, int u) {
        if(u == nums.size()) {
            ans.push_back(path);
            return;
        }

        unordered_set<int> S;

        for(int i = 0; i < nums.size(); i++) {
            if(!st[i]) {
                if(S.count(nums[i])) continue;
                S.insert(nums[i]);
                path.push_back(nums[i]);
                st[i] = true;
                dfs(nums, u + 1);
                path.pop_back();
                st[i] = false;
            }
        }
    }
};
```

用了一个哈希集合来存是否有重复使用来进行剪枝。

#### 剑指 Offer II 014. 字符串中的变位词

<https://leetcode.cn/problems/MPnaiL/>

给定两个字符串 s1 和 s2，写一个函数来判断 s2 是否包含 s1 的某个变位词。

换句话说，第一个字符串的排列之一是第二个字符串的 子串 。

```cpp
class Solution {
public:
    bool checkInclusion(string s1, string s2) {
        unordered_map<char, int> need, win;
        for(auto c : s1) need[c]++;
        int right = 0, left = 0, vl = 0;
        while(right < s2.size()) {
            char c1 = s2[right++];
            if(need.count(c1)) {
                win[c1]++;
                if(win[c1] == need[c1]) vl++;
            }
            while(vl == need.size()) {
                if(right - left == s1.size()) return true;
                char c2 = s2[left++];
                if(need.count(c2)) {
                    if(win[c2] == need[c2]) vl--;
                    win[c2]--;
                }
            }
        }
        return false;
    }
};
```

滑动窗口问题，当有效位与需要的字符相同并且长度和 s1 相同是返回 true。

#### 剑指 Offer II 015. 字符串中的所有变位词

<https://leetcode.cn/problems/VabMRr/>

给定两个字符串  s  和  p，找到  s  中所有 p 的   变位词   的子串，返回这些子串的起始索引。不考虑答案输出的顺序。

变位词 指字母相同，但排列不同的字符串。

```cpp
class Solution {
public:
    vector<int> findAnagrams(string s, string p) {
        vector<int> res;
        unordered_map<char, int> need, win;
        for(auto c : p) need[c]++;
        int right = 0, left = 0, vl = 0;
        while(right < s.size()) {
            char c1 = s[right++];
            if(need.count(c1)) {
                win[c1]++;
                if(win[c1] == need[c1]) vl++;
            }
            while(right - left >= p.size()) {
                if(vl == need.size()) res.push_back(left);
                char c2 = s[left++];
                if(need.count(c2)) {
                    if(win[c2] == need[c2]) vl--;
                    win[c2]--;
                }
            }
        }
        return res;
    }
};
```

滑动窗口题，与上一题不同的是这里需要每次把左边的下标保存到数组当中。

#### 剑指 Offer II 016. 不含重复字符的最长子字符串

<https://leetcode.cn/problems/wtcaE1/>

给定一个字符串 s ，请你找出其中不含有重复字符的 最长连续子字符串 的长度。

```cpp
class Solution {
public:
    int lengthOfLongestSubstring(string s) {
        unordered_map<char, int> win;
        int left = 0, right = 0, res = 0;
        while(right < s.size()) {
            char c1 = s[right++];
            win[c1]++;
            while(win[c1] > 1) {
                char c2 = s[left++];
                win[c2]--;
            }
            res = max(res, right - left);
        }
        return res;
    }
};
```

用哈希表或者滑动窗口来做其实都可以，用滑动窗口需要改一点框架，总体来说还更简单。

#### 剑指 Offer II 017. 含有所有字符的最短字符串

<https://leetcode.cn/problems/M1oyTv/>

给定两个字符串 s 和  t 。返回 s 中包含  t  的所有字符的最短子字符串。如果 s 中不存在符合条件的子字符串，则返回空字符串 "" 。

如果 s 中存在多个符合条件的子字符串，返回任意一个。

```cpp
class Solution {
public:
    string minWindow(string s, string t) {
        unordered_map<char, int> need, win;
        for(auto c : t) need[c]++;
        int right = 0, left = 0, vl = 0, st = 0, len = INT_MAX;
        while(right < s.size()) {
            char c1 = s[right++];
            if(need.count(c1)) {
                win[c1]++;
                if(win[c1] == need[c1]) vl++;
            }
            while(vl == need.size()) {
                if(right - left < len) {
                    len = right - left;
                    st = left;
                }
                char c2 = s[left++];
                if(need.count(c2)) {
                    if(win[c2] == need[c2]) vl--;
                    win[c2]--;
                }
            }
        }
        return len == INT_MAX ? "" : s.substr(st, len);
    }
};
```

最经典的滑动窗口问题，事实上和其他的没有什么大区别，理解逻辑就懂了。

#### 剑指 Offer 63. 股票的最大利润

<https://leetcode.cn/problems/gu-piao-de-zui-da-li-run-lcof/>

假设把某股票的价格按照时间先后顺序存储在数组中，请问买卖该股票一次可能获得的最大利润是多少？

```cpp
class Solution {
public:
    int maxProfit(vector<int>& prices) {
        if(!prices.size()) return 0;
        vector<vector<int>> dp(prices.size(), vector<int>(2));
        for(int i = 0; i < prices.size(); i++) {
            if(i == 0) {
                dp[i][0] = 0;
                dp[i][1] = -prices[i];
                continue;
            }
            dp[i][0] = max(dp[i - 1][0], dp[i - 1][1] + prices[i]);
            dp[i][1] = max(dp[i - 1][1], - prices[i]);
        }
        return dp[prices.size() - 1][0];
    }
};
```

股票题中最简单的一种，直接带公式即可。

#### 剑指 Offer II 089. 房屋偷盗

<https://leetcode.cn/problems/Gu0c2T/>

一个专业的小偷，计划偷窃沿街的房屋。每间房内都藏有一定的现金，影响小偷偷窃的唯一制约因素就是相邻的房屋装有相互连通的防盗系统，如果两间相邻的房屋在同一晚上被小偷闯入，系统会自动报警。

给定一个代表每个房屋存放金额的非负整数数组 nums ，请计算   不触动警报装置的情况下 ，一夜之内能够偷窃到的最高金额。

```cpp
class Solution {
public:
    int rob(vector<int>& nums) {
        vector<int> dp(nums.size() + 2);
        for(int i = nums.size() - 1; i >= 0; i--) {
            dp[i] = max(dp[i + 1], dp[i + 2] + nums[i]);
        }
        return dp[0];
    }
};
```

经典问题，写好状态转移方程就简单了。

#### 剑指 Offer II 090. 环形房屋偷盗

<https://leetcode.cn/problems/PzWKhm/>

一个专业的小偷，计划偷窃一个环形街道上沿街的房屋，每间房内都藏有一定的现金。这个地方所有的房屋都 围成一圈 ，这意味着第一个房屋和最后一个房屋是紧挨着的。同时，相邻的房屋装有相互连通的防盗系统，如果两间相邻的房屋在同一晚上被小偷闯入，系统会自动报警 。

给定一个代表每个房屋存放金额的非负整数数组 nums ，请计算   在不触动警报装置的情况下 ，今晚能够偷窃到的最高金额。

```cpp
class Solution {
public:
    int rob(vector<int>& nums) {
        if(nums.size() == 1) return nums[0];
        return max(dp(nums, 0, nums.size() - 2), dp(nums, 1, nums.size() - 1));
    }

    int dp(vector<int>& nums, int st, int ed) {
        vector<int> res(nums.size() + 2);
        for(int i = ed; i >= st; i--) {
            res[i] = max(res[i + 1], res[i + 2] + nums[i]);
        }
        return res[st];
    }
};
```

与上一题不同的是因为最前面和最后面不能同时偷，所以选一个偷就好了。

#### 剑指 Offer 03. 数组中重复的数字

<https://leetcode.cn/problems/shu-zu-zhong-zhong-fu-de-shu-zi-lcof/>

找出数组中重复的数字。 在一个长度为 n 的数组 nums 里的所有数字都在 0 ～ n-1 的范围内。数组中某些数字是重复的，但不知道有几个数字重复了，也不知道每个数字重复了几次。请找出数组中任意一个重复的数字。

```cpp
class Solution {
public:
    int findRepeatNumber(vector<int>& nums) {
        unordered_set<int> S;
        for(int i = 0; i < nums.size(); i++) {
            if(S.count(nums[i])) return nums[i];
            S.insert(nums[i]);
        }
        return 0;
    }
};
```

用哈希集合即可。

#### 剑指 Offer 04. 二维数组中的查找

<https://leetcode.cn/problems/er-wei-shu-zu-zhong-de-cha-zhao-lcof/>

在一个 n \* m 的二维数组中，每一行都按照从左到右   非递减   的顺序排序，每一列都按照从上到下   非递减   的顺序排序。请完成一个高效的函数，输入这样的一个二维数组和一个整数，判断数组中是否含有该整数。

```cpp
class Solution {
public:
    bool findNumberIn2DArray(vector<vector<int>>& matrix, int target) {
        if(!matrix.size()) return false;
        if(!matrix[0].size()) return false;
        for(int i = 0; i < matrix.size(); i++) {
            if (func(matrix[i], target)) return true;
        }
        return false;
    }

    bool func(vector<int>& nums, int target) {
        int l = 0, r = nums.size() - 1;
        while(l < r) {
            int mid = (l + r) / 2;
            if(nums[mid] >= target) r = mid;
            else l = mid + 1;
        }
        if(nums[r] != target) return false;
        return true;
    }
};
```

二分即可。

#### 剑指 Offer 06. 从尾到头打印链表

<https://leetcode.cn/problems/cong-wei-dao-tou-da-yin-lian-biao-lcof/>

输入一个链表的头节点，从尾到头反过来返回每个节点的值（用数组返回）。

```cpp
/**
 * Definition for singly-linked list.
 * struct ListNode {
 *     int val;
 *     ListNode *next;
 *     ListNode(int x) : val(x), next(NULL) {}
 * };
 */
class Solution {
public:
    vector<int> reversePrint(ListNode* head) {
        vector<int> res;
        while(head) {
            res.push_back(head->val);
            head = head->next;
        }
        reverse(res.begin(), res.end());
        return res;
    }
};
```

从前往后打印再翻转一下即可。

#### 剑指 Offer 07. 重建二叉树

<https://leetcode.cn/problems/zhong-jian-er-cha-shu-lcof/>

输入某二叉树的前序遍历和中序遍历的结果，请构建该二叉树并返回其根节点。

假设输入的前序遍历和中序遍历的结果中都不含重复的数字。

```cpp
/**
 * Definition for a binary tree node.
 * struct TreeNode {
 *     int val;
 *     TreeNode *left;
 *     TreeNode *right;
 *     TreeNode(int x) : val(x), left(NULL), right(NULL) {}
 * };
 */
class Solution {
public:
    TreeNode* buildTree(vector<int>& preorder, vector<int>& inorder) {
        return dfs(preorder, 0, preorder.size() - 1,
        inorder, 0, inorder.size() - 1);
    }

    TreeNode* dfs(vector<int>& preorder, int pst, int ped, vector<int>& inorder, int ist, int ied) {
        if(pst > ped) return NULL;
        int pval = preorder[pst], idx = -1;
        auto root = new TreeNode(pval);
        for(int i = ist; i <= ied; i++) {
            if(inorder[i] == pval) {
                idx = i;
                break;
            }
        }
        root->left = dfs(preorder, pst + 1, pst + idx - ist, inorder, ist, idx - 1);
        root->right = dfs(preorder, pst + idx - ist + 1, ped, inorder, idx + 1, ied);
        return root;
    }
};
```

经典，注意左右递归时的条件，画图会很清晰。

#### 剑指 Offer 09. 用两个栈实现队列

用两个栈实现一个队列。队列的声明如下，请实现它的两个函数 appendTail 和 deleteHead ，分别完成在队列尾部插入整数和在队列头部删除整数的功能。(若队列中没有元素，deleteHead  操作返回 -1 )

```cpp
class CQueue {
public:
    CQueue() {}
    stack<int> s1, s2;

    void appendTail(int value) {
        s1.push(value);
    }

    int deleteHead() {
        if(s1.empty() && s2.empty()) return -1;
        if(!s2.size()) {
            while(s1.size()) {
                s2.push(s1.top());
                s1.pop();
            }
        }
        int t = s2.top();
        s2.pop();
        return t;
    }
};

/**
 * Your CQueue object will be instantiated and called as such:
 * CQueue* obj = new CQueue();
 * obj->appendTail(value);
 * int param_2 = obj->deleteHead();
 */
```

用一个栈当队列，另外一个用来辅助即可。

#### 剑指 Offer 10- I. 斐波那契数列

<https://leetcode.cn/problems/fei-bo-na-qi-shu-lie-lcof/>

写一个函数，输入 n ，求斐波那契（Fibonacci）数列的第 n 项（即 F(N)）。斐波那契数列的定义如下：

F(0) = 0,   F(1) = 1 F(N) = F(N - 1) + F(N - 2), 其中 N > 1. 斐波那契数列由 0 和 1 开始，之后的斐波那契数就是由之前的两数相加而得出。

答案需要取模 1e9+7（1000000007），如计算初始结果为：1000000008，请返回 1。

```cpp
class Solution {
public:
    int fib(int n) {
        if(n < 2) return n;
        int i_1 = 0, i_2 = 0, i_3 = 1;
        for(int i = 2; i <= n; i++) {
            i_1 = i_2;
            i_2 = i_3;
            i_3 = (i_1 + i_2) % 1000000007;
        }
        return i_3;
    }
};
```

用动态规划，斐波那契数的边界条件是 F(0)=0F(0)=0 和 F(1)=1F(1)=1。当 n>1n>1 时，每一项的和都等于前两项的和，因此有如下递推关系：

```cpp
F(n)=F(n-1)+F(n-2)
F(n)=F(n−1)+F(n−2)
```

由于斐波那契数存在递推关系，因此可以使用动态规划求解。动态规划的状态转移方程即为上述递推关系，边界条件为 F(0)F(0) 和 F(1)F(1)。

#### 剑指 Offer 10- II. 青蛙跳台阶问题

<https://leetcode.cn/problems/qing-wa-tiao-tai-jie-wen-ti-lcof/>

一只青蛙一次可以跳上 1 级台阶，也可以跳上 2 级台阶。求该青蛙跳上一个 n  级的台阶总共有多少种跳法。

答案需要取模 1e9+7（1000000007），如计算初始结果为：1000000008，请返回 1。

```cpp
class Solution {
public:
    int numWays(int n) {
        if(n < 2) return 1;
        int i_1 = 0, i_2 = 1, i_3 = 1;
        for(int i = 2; i <= n; i++) {
            i_1 = i_2;
            i_2 = i_3;
            i_3 = (i_1 + i_2) % 1000000007;
        }
        return i_3;
    }
};
```

斐波那契数列问题，和上一题一样的思路。

#### 剑指 Offer 11. 旋转数组的最小数字

<https://leetcode.cn/problems/xuan-zhuan-shu-zu-de-zui-xiao-shu-zi-lcof/>

把一个数组最开始的若干个元素搬到数组的末尾，我们称之为数组的旋转。

给你一个可能存在   重复   元素值的数组  numbers ，它原来是一个升序排列的数组，并按上述情形进行了一次旋转。请返回旋转数组的最小元素。例如，数组  \[3,4,5,1,2] 为 \[1,2,3,4,5] 的一次旋转，该数组的最小值为 1。

注意，数组 \[a\[0], a\[1], a\[2], ..., a\[n-1]] 旋转一次 的结果为数组 \[a\[n-1], a\[0], a\[1], a\[2], ..., a\[n-2]] 。

```cpp
class Solution {
public:
    int minArray(vector<int>& numbers) {
        int l = 0, r = numbers.size() - 1;
        while(l < r) {
            int mid = (l + r) / 2;
            if(numbers[mid] > numbers[r]) l = mid + 1;
            else if(numbers[mid] < numbers[r]) r = mid;
            else r--;
        }
        return numbers[r];
    }
};
```

这道题需要用到二分，当 nums\[m] > nums\[j] 时： mm 一定在 左排序数组 中，即旋转点 xx 一定在 \[m + 1, j] 闭区间内，因此执行 i = m + 1； 当 nums\[m] < nums\[j] 时： mm 一定在 右排序数组 中，即旋转点 xx 一定在\[i, m] 闭区间内，因此执行 j = m； 当 nums\[m] = nums\[j] 时： 无法判断 mm 在哪个排序数组中，即无法判断旋转点 xx 在 \[i, m] 还是 \[m + 1, j] 区间中。解决方案： 执行 j = j - 1 缩小判断范围。

#### 剑指 Offer 12. 矩阵中的路径

<https://leetcode.cn/problems/ju-zhen-zhong-de-lu-jing-lcof/>

给定一个  m x n 二维字符网格  board 和一个字符串单词  word 。如果  word 存在于网格中，返回 true ；否则，返回 false 。

单词必须按照字母顺序，通过相邻的单元格内的字母构成，其中“相邻”单元格是那些水平相邻或垂直相邻的单元格。同一个单元格内的字母不允许被重复使用。

```cpp
class Solution {
public:
    bool exist(vector<vector<char>>& board, string word) {
        if(board.empty() || board[0].empty()) return false;
        int n = board.size(), m = board[0].size();
        for(int i = 0; i < n; i++) {
            for(int j = 0; j < m; j++) {
                if(dfs(board, word, 0, i, j)) return true;
            }
        }
        return false;
    }

    int dx[4] = {-1, 0, 1, 0}, dy[4] = {0, 1, 0, -1};

    bool dfs(vector<vector<char>>& board, string word, int u, int x, int y) {
        if(board[x][y] != word[u]) return false;
        if(u == word.size() - 1) return true;
        int n = board.size(), m = board[0].size();
        int t = board[x][y];
        board[x][y] = '.';
        for(int i = 0; i < 4; i++) {
            int a = x + dx[i], b = y + dy[i];
            if(a < 0 || b < 0 || a >= n || b >= m || board[a][b] == '.') continue;
            if(dfs(board, word, u + 1, a, b)) return true;
        }
        board[x][y] = t;
        return false;
    }
};
```

通过深搜，上下左右找即可。

#### 剑指 Offer 14- I. 剪绳子

<https://leetcode.cn/problems/jian-sheng-zi-lcof/>

给你一根长度为 n 的绳子，请把绳子剪成整数长度的 m 段（m、n 都是整数，n>1 并且 m>1），每段绳子的长度记为 k\[0],k\[1]...k\[m-1] 。请问 k\[0]*k\[1]*...\*k\[m-1] 可能的最大乘积是多少？例如，当绳子的长度是 8 时，我们把它剪成长度分别为 2、3、3 的三段，此时得到的最大乘积是 18。

```cpp
class Solution {
public:
    int cuttingRope(int n) {
        vector<int> dp(n + 1);
         dp[2] = 1;
         for(int i = 3; i <= n; i++) {
             for(int j = 1; j <= i / 2; j++) {
                 dp[i] = max(dp[i], max(j * (i - j), j * dp[i - j]));
             }
         }
         return dp[n];
    }
};
```

列出状态转移方程 `dp[i] = max(dp[i], max(j * (i - j), j * dp[i - j]))` 就非常简单了。

#### 剑指 Offer 14- II. 剪绳子 II

<https://leetcode.cn/problems/jian-sheng-zi-ii-lcof/>

给你一根长度为 n 的绳子，请把绳子剪成整数长度的 m  段（m、n 都是整数，n>1 并且 m>1），每段绳子的长度记为 k\[0],k\[1]...k\[m - 1] 。请问 k\[0],k\[1]...\*k\[m - 1] 可能的最大乘积是多少？例如，当绳子的长度是 8 时，我们把它剪成长度分别为 2、3、3 的三段，此时得到的最大乘积是 18。

答案需要取模 1e9+7（1000000007），如计算初始结果为：1000000008，请返回 1。

```cpp
class Solution {
public:
    int cuttingRope(int n) {
        long long res = 1;
        if(n < 4) return n - 1;
        if(n == 4) return n;
        while(n > 4) {
            res *= 3;
            res %= 1000000007;
            n -= 3;
        }
        return (res * n) % 1000000007;
    }
};
```

用动态规划会爆范围，只能贪心，数学证明太难，建议背过即可。

#### 剑指 Offer 15. 二进制中 1 的个数

<https://leetcode.cn/problems/er-jin-zhi-zhong-1de-ge-shu-lcof/>

编写一个函数，输入是一个无符号整数（以二进制串的形式），返回其二进制表达式中数字位数为 '1' 的个数（也被称为 汉明重量).）。

```cpp
class Solution {
public:
    int hammingWeight(uint32_t n) {
        int sum = 0;
        while(n > 0) {
            n = n & (n - 1);
            sum++;
        }
        return sum;
    }
};
```

通过 `n = n & (n - 1)` 每次都能消灭一个 1，直到 n 为 0 时退出即可。

#### 剑指 Offer 16. 数值的整数次方

<https://leetcode.cn/problems/shu-zhi-de-zheng-shu-ci-fang-lcof/>

实现 pow(x, n) ，即计算 x 的整数 n 次幂函数（即，xn ）。

```cpp
class Solution {
public:
    double myPow(double x, int n) {
        typedef long long ll;
        double res = 1;
        for(ll i = abs(ll(n)); i; i >>= 1) {
            if(i & 1) res *= x;
            x *= x;
        }
        if(n < 0) return 1 / res;
        return res;
    }
};
```

按照定义，计算 x 的 n 次方是将 nn 个 x 连乘，效率比较低，会超时。因为乘法具有结合律，考虑每次将一部分连乘批量计算好，作为最终答案的一部分。这就可以将 n 进行二进制拆分，若 n 的二进制位的第 k 位是 1，则 ans 可以乘上 x2k。 而计算 x2k，只需每次将自身做平方即可。

#### 剑指 Offer 17. 打印从 1 到最大的 n 位数

<https://leetcode.cn/problems/da-yin-cong-1dao-zui-da-de-nwei-shu-lcof/>

```cpp
class Solution {
public:
    vector<int> printNumbers(int n) {
        int max = 1;
        for(int i = 0; i < n; i++) max *= 10;
        vector<int> res;
        for(int i = 1; i < max; i++) res.push_back(i);
        return res;
    }
};
```

没搞懂这道题存在的意义。

#### 剑指 Offer 18. 删除链表的节点

<https://leetcode.cn/problems/shan-chu-lian-biao-de-jie-dian-lcof/>

```cpp
/**
 * Definition for singly-linked list.
 * struct ListNode {
 *     int val;
 *     ListNode *next;
 *     ListNode(int x) : val(x), next(NULL) {}
 * };
 */
class Solution {
public:
    ListNode* deleteNode(ListNode* head, int val) {
        if(!head) return head;
        auto dummy = new ListNode();
        dummy->next = head;
        auto p = dummy;
        while(head) {
            if(head->val == val) p->next = p->next->next;
            head = head->next;
            p = p->next;
        }
        return dummy->next;
    }
};
```

用一个虚拟头结点轻松秒杀。

#### 剑指 Offer 20. 表示数值的字符串

<https://leetcode.cn/problems/biao-shi-shu-zhi-de-zi-fu-chuan-lcof/>

请实现一个函数用来判断字符串是否表示数值（包括整数和小数）。

数值（按顺序）可以分成以下几个部分：

若干空格 一个   小数   或者   整数 （可选）一个  'e'  或  'E' ，后面跟着一个   整数 若干空格 小数（按顺序）可以分成以下几个部分：

（可选）一个符号字符（'+' 或 '-'） 下述格式之一： 至少一位数字，后面跟着一个点 '.' 至少一位数字，后面跟着一个点 '.' ，后面再跟着至少一位数字 一个点 '.' ，后面跟着至少一位数字 整数（按顺序）可以分成以下几个部分：

（可选）一个符号字符（'+' 或 '-'） 至少一位数字 部分数值列举如下：

\["+100", "5e2", "-123", "3.1416", "-1E-16", "0123"] 部分非数值列举如下：

\["12e", "1a3.14", "1.2.3", "+-5", "12e+5.4"]

```cpp
class Solution {
public:
    bool isNumber(string s) {
        //去掉首尾空格
        int i = 0;
        while (i < s.size() && s[i] == ' ') i++;
        s = s.substr(i);
        while (s.back() == ' ') s.pop_back();

        bool numFlag = false;
        bool dotFlag = false;
        bool eFlag = false;
        for (int i = 0; i < s.size(); i++) {
            // 判定为数字，则标记numFlag
            if (isdigit(s[i])) numFlag = true;
            // 判定为'.'需要没出现过'.'并且没出现过'e'
            else if (s[i] == '.' && !dotFlag && !eFlag) dotFlag = true;
            // 判定为'e'，需要没出现过'e'，并且出现过数字
            else if ((s[i] == 'e' || s[i] == 'E') && !eFlag && numFlag) {
                eFlag = true;
                numFlag = false; // 'e'后面必须跟着一个整数，所以出现'e'之后就标志为false
            }
            // 判定为'+''-'符号，只能出现在第一位或者紧接'e'后面
            else if ((s[i] == '+' || s[i] == '-') && (i == 0 || s[i - 1] == 'e' || s[i - 1] == 'E')) continue;
            // 其他情况，都是非法的
            else return false;
        }
        return numFlag;
    }
};
```

详看注释。

#### 剑指 Offer 21. 调整数组顺序使奇数位于偶数前面

<https://leetcode.cn/problems/diao-zheng-shu-zu-shun-xu-shi-qi-shu-wei-yu-ou-shu-qian-mian-lcof/>

输入一个整数数组，实现一个函数来调整该数组中数字的顺序，使得所有奇数在数组的前半部分，所有偶数在数组的后半部分。

```cpp
class Solution {
public:
    vector<int> exchange(vector<int>& nums) {
        int s = 0, f = 0;
        while(f < nums.size()) {
            if(nums[f] % 2 == 1) {
                swap(nums[s], nums[f]);
                s++;
            }
            f++;
        }
        return nums;
    }
};
```

#### 剑指 Offer 24. 反转链表

<https://leetcode.cn/problems/fan-zhuan-lian-biao-lcof/>

```cpp
/**
 * Definition for singly-linked list.
 * struct ListNode {
 *     int val;
 *     ListNode *next;
 *     ListNode(int x) : val(x), next(NULL) {}
 * };
 */
class Solution {
public:
    ListNode* reverseList(ListNode* head) {
        if(!head) return head;
        auto a = head, b = head->next;
        while(b) {
            auto t = b->next;
            b->next = a;
            a = b;
            b = t;
        }
        head->next = NULL;
        return a;
    }
};
```

原地算法，经典问题。

#### 剑指 Offer 26. 树的子结构

<https://leetcode.cn/problems/shu-de-zi-jie-gou-lcof/>

```cpp
/**
 * Definition for a binary tree node.
 * struct TreeNode {
 *     int val;
 *     TreeNode *left;
 *     TreeNode *right;
 *     TreeNode(int x) : val(x), left(NULL), right(NULL) {}
 * };
 */
class Solution {
public:
    bool isSubStructure(TreeNode* A, TreeNode* B) {
        if(!A || !B) return false;
        return isSub(A, B) || isSubStructure(A->left, B) || isSubStructure(A->right, B);
    }

    bool isSub(TreeNode* A, TreeNode* B) {
        if(!B) return true;
        if(!A || A->val != B->val) return false;
        return isSub(A->left, B->left) && isSub(A->right, B->right);
    }
};
```

递归，主要有两种情况，可能是根节点开始的子树，也有可能是后继节点开始的子树，所以需要把三个或起来，其中 `isSub` 进行了对两边的递归。

#### 剑指 Offer 27. 二叉树的镜像

<https://leetcode.cn/problems/er-cha-shu-de-jing-xiang-lcof/>

```cpp
/**
 * Definition for a binary tree node.
 * struct TreeNode {
 *     int val;
 *     TreeNode *left;
 *     TreeNode *right;
 *     TreeNode(int x) : val(x), left(NULL), right(NULL) {}
 * };
 */
class Solution {
public:
    TreeNode* mirrorTree(TreeNode* root) {
        dfs(root);
        return root;
    }

    void dfs(TreeNode* root) {
        if(!root) return;

        auto t = root->left;
        root->left = root->right;
        root->right = t;

        dfs(root->left);
        dfs(root->right);
    }
};
```

用一个 dfs 函数遍历每个节点，让每个节点的左右子节点颠倒过来就行了。

#### 剑指 Offer 28. 对称的二叉树

<https://leetcode.cn/problems/dui-cheng-de-er-cha-shu-lcof/>

```cpp
/**
 * Definition for a binary tree node.
 * struct TreeNode {
 *     int val;
 *     TreeNode *left;
 *     TreeNode *right;
 *     TreeNode(int x) : val(x), left(NULL), right(NULL) {}
 * };
 */
class Solution {
public:
    bool isSymmetric(TreeNode* root) {
        if(!root) return true;
        return dfs(root->left, root->right);
    }

    bool dfs(TreeNode* left, TreeNode* right) {
        if(!left && !right) return true;
        if(!left || !right || left->val != right->val) return false;
        return dfs(left->left, right->right) && dfs(left->right, right->left);
    }
};
```

遍历两边进行比较即可。

#### 剑指 Offer 29. 顺时针打印矩阵

<https://leetcode.cn/problems/shun-shi-zhen-da-yin-ju-zhen-lcof/>

```cpp
class Solution {
public:
    vector<int> spiralOrder(vector<vector<int>>& matrix) {
        if(matrix.size() == 0) return {};
        vector<int> res;
        int n = matrix.size(), m = matrix[0].size();
        vector<vector<bool>> st(n, vector<bool>(m));
        int dx[4] = {0, 1, 0, -1}, dy[4] = {1, 0, -1, 0};
        for(int i = 0, x = 0, y = 0, d = 0; i < n * m; i++) {
            res.push_back(matrix[x][y]);
            st[x][y] = true;
            int a = x + dx[d], b = y + dy[d];
            if(a < 0 || b < 0 || a >= n || b >= m || st[a][b]) {
                d = (d + 1) % 4;
                a = x + dx[d], b = y + dy[d];
            }
            x = a, y = b;
        }
        return res;
    }
};
```

用偏移量实现，不用模拟。

#### 剑指 Offer 30. 包含 min 函数的栈

<https://leetcode.cn/problems/bao-han-minhan-shu-de-zhan-lcof/>

定义栈的数据结构，请在该类型中实现一个能够得到栈的最小元素的 min 函数在该栈中，调用 min、push 及 pop 的时间复杂度都是 O(1)。

```cpp
class MinStack {
public:
    /** initialize your data structure here. */
    stack<int> s;
    stack<int> t;
    MinStack() {
        t.push(INT_MAX);
    }

    void push(int x) {
        s.push(x);
        t.push(std::min(t.top(), x);
    }

    void pop() {
        s.pop();
        t.pop();
    }

    int top() {
        return s.top();
    }

    int min() {
        return t.top();
    }
};

/**
 * Your MinStack object will be instantiated and called as such:
 * MinStack* obj = new MinStack();
 * obj->push(x);
 * obj->pop();
 * int param_3 = obj->top();
 * int param_4 = obj->min();
 */
```

用一个辅助栈来存每次的最小值即可。

#### 剑指 Offer 32 - I. 从上到下打印二叉树

<https://leetcode.cn/problems/cong-shang-dao-xia-da-yin-er-cha-shu-lcof/>

从上到下打印出二叉树的每个节点，同一层的节点按照从左到右的顺序打印。

```cpp
/**
 * Definition for a binary tree node.
 * struct TreeNode {
 *     int val;
 *     TreeNode *left;
 *     TreeNode *right;
 *     TreeNode(int x) : val(x), left(NULL), right(NULL) {}
 * };
 */
class Solution {
public:
    vector<int> levelOrder(TreeNode* root) {
        vector<int> res;
        queue<TreeNode*> q;
        if(root) q.push(root);

        while(q.size()) {
            int len = q.size();
            while(len--) {
                auto t = q.front();
                res.push_back(t->val);
                q.pop();
                if(t->left) q.push(t->left);
                if(t->right) q.push(t->right);
            }
        }
        return res;
    }
};
```

经典层序遍历。

#### 剑指 Offer 32 - II. 从上到下打印二叉树 II

<https://leetcode.cn/problems/cong-shang-dao-xia-da-yin-er-cha-shu-ii-lcof/>

```cpp
/**
 * Definition for a binary tree node.
 * struct TreeNode {
 *     int val;
 *     TreeNode *left;
 *     TreeNode *right;
 *     TreeNode(int x) : val(x), left(NULL), right(NULL) {}
 * };
 */
class Solution {
public:
    vector<vector<int>> levelOrder(TreeNode* root) {
        vector<vector<int>> res;
        queue<TreeNode*> q;
        if(root) q.push(root);

        while(q.size()) {
            int len = q.size();
            vector<int> path;
            while(len--) {
                auto t = q.front();
                q.pop();
                path.push_back(t->val);
                if(t->left) q.push(t->left);
                if(t->right) q.push(t->right);
            }
            res.push_back(path);
        }
        return res;
    }
};
```

同样是层序遍历

#### 剑指 Offer 31. 栈的压入、弹出序列

<https://leetcode.cn/problems/zhan-de-ya-ru-dan-chu-xu-lie-lcof/>

```cpp
class Solution {
public:
    bool validateStackSequences(vector<int>& pushed, vector<int>& popped) {
        stack<int> stk;
        int n = pushed.size();
        for (int i = 0, j = 0; i < n; ++i) {
            stk.push(pushed[i]);
            while (!stk.empty() && stk.top() == popped[j]) {
                stk.pop();
                j++;
            }
        }
        return stk.empty();
    }
};
```

模拟一下。

#### 剑指 Offer 32 - III. 从上到下打印二叉树 III

<https://leetcode.cn/problems/cong-shang-dao-xia-da-yin-er-cha-shu-iii-lcof/>

```cpp
/**
 * Definition for a binary tree node.
 * struct TreeNode {
 *     int val;
 *     TreeNode *left;
 *     TreeNode *right;
 *     TreeNode(int x) : val(x), left(NULL), right(NULL) {}
 * };
 */
class Solution {
public:
    vector<vector<int>> levelOrder(TreeNode* root) {
        vector<vector<int>> res;
        queue<TreeNode*> q;
        if(root) q.push(root);
        bool flag = true;
        while(q.size()) {
            int len = q.size();
            vector<int> path;
            while(len--) {
                auto t = q.front();
                path.push_back(t->val);
                q.pop();
                if(t->left) q.push(t->left);
                if(t->right) q.push(t->right);
            }
            if(flag) {
                res.push_back(path);
                flag = false;
            } else {
                reverse(path.begin(), path.end());
                res.push_back(path);
                flag = true;
            }
        }
        return res;
    }
};
```

层序遍历问题，用一个变量来存当前应该正序输入还是倒序输入。

#### 剑指 Offer 33. 二叉搜索树的后序遍历序列

<https://leetcode.cn/problems/er-cha-sou-suo-shu-de-hou-xu-bian-li-xu-lie-lcof/>

输入一个整数数组，判断该数组是不是某二叉搜索树的后序遍历结果。如果是则返回 true，否则返回 false。假设输入的数组的任意两个数字都互不相同。

```cpp
class Solution {
public:
    bool verifyPostorder(vector<int>& postorder) {
        return check(postorder, 0, postorder.size() - 1);
    }

    bool check(vector<int>& postorder, int i, int j) {
        if(i >= j) return true;
        int root = postorder[j];
        int left = i;
        while(left < j && postorder[left] < root) left++;
        int right = left;
        while(right < j && postorder[right] > root) right++;
        if(right != j) return false;
        return check(postorder, i, left - 1) && check(postorder, left, j - 1);
    }
};
```

1、先找到根节点元素

2、根据根节点元素找到左子树元素，递归检查左子树是否是 BST

3、根据根节点元素找到右子树元素，递归检查右子树是否是 BST

#### 剑指 Offer 34. 二叉树中和为某一值的路径

<https://leetcode.cn/problems/er-cha-shu-zhong-he-wei-mou-yi-zhi-de-lu-jing-lcof/>

给你二叉树的根节点 root 和一个整数目标和 targetSum ，找出所有 从根节点到叶子节点 路径总和等于给定目标和的路径。

叶子节点 是指没有子节点的节点。

```cpp
/**
 * Definition for a binary tree node.
 * struct TreeNode {
 *     int val;
 *     TreeNode *left;
 *     TreeNode *right;
 *     TreeNode() : val(0), left(nullptr), right(nullptr) {}
 *     TreeNode(int x) : val(x), left(nullptr), right(nullptr) {}
 *     TreeNode(int x, TreeNode *left, TreeNode *right) : val(x), left(left), right(right) {}
 * };
 */
class Solution {
public:
    vector<vector<int>> res;
    vector<int> path;
    vector<vector<int>> pathSum(TreeNode* root, int target) {
        if(!root) return {};
        dfs(root, target);
        return res;
    }

    void dfs(TreeNode* root, int target) {
        path.push_back(root->val);
        target -= root->val;
        if(!root->left && !root->right) {
            if(!target) res.push_back(path);
        } else {
            if(root->left) dfs(root->left, target);
            if(root->right) dfs(root->right, target);
        }
        path.pop_back();
    }
};
```

遍历所有的可能，记得回溯。

#### 剑指 Offer 35. 复杂链表的复制

<https://leetcode.cn/problems/fu-za-lian-biao-de-fu-zhi-lcof/>

请实现 copyRandomList 函数，复制一个复杂链表。在复杂链表中，每个节点除了有一个 next 指针指向下一个节点，还有一个 random 指针指向链表中的任意节点或者 null。

```cpp
/*
// Definition for a Node.
class Node {
public:
    int val;
    Node* next;
    Node* random;

    Node(int _val) {
        val = _val;
        next = NULL;
        random = NULL;
    }
};
*/
class Solution {
public:
    Node* copyRandomList(Node* head) {
        if(!head) return NULL;
        Node* cur = head;
        unordered_map<Node*, Node*> map;
        while(cur) {
            map[cur] = new Node(cur->val);
            cur = cur->next;
        }
        cur = head;
        while(cur) {
            map[cur]->next = map[cur->next];
            map[cur]->random = map[cur->random];
            cur = cur->next;
        }
        return map[head];
    }
};
```

利用哈希表的查询特点，考虑构建 原链表节点 和 新链表对应节点 的键值对映射关系，再遍历构建新链表各节点的 next 和 random 引用指向即可。

#### 剑指 Offer 36. 二叉搜索树与双向链表

<https://leetcode.cn/problems/er-cha-sou-suo-shu-yu-shuang-xiang-lian-biao-lcof/>

输入一棵二叉搜索树，将该二叉搜索树转换成一个排序的循环双向链表。要求不能创建任何新的节点，只能调整树中节点指针的指向。

```cpp
/*
// Definition for a Node.
class Node {
public:
    int val;
    Node* left;
    Node* right;

    Node() {}

    Node(int _val) {
        val = _val;
        left = NULL;
        right = NULL;
    }

    Node(int _val, Node* _left, Node* _right) {
        val = _val;
        left = _left;
        right = _right;
    }
};
*/
class Solution {
public:
    Node* head = NULL;
    Node* pre = NULL;
    Node* treeToDoublyList(Node* root) {
        if(!root) return NULL;
        dfs(root);
        pre->right = head;
        head->left = pre;
        return head;
    }

    void dfs(Node* root) {
        if(!root) return;
        dfs(root->left);
        if(pre) pre->right = root;
        else head = root;
        root->left = pre;
        pre = root;
        dfs(root->right);
    }
};
```

1、我们定义两个指针 pre 和 head，pre 指针用于保存中序遍历的前一个节点，head 指针用于记录排序链表的头节点。

2、中序遍历二叉树，因为是中序遍历，所以遍历顺序就是双线链表的建立顺序。我们只需要在中序遍历的过程中，修改每个节点的左右指针，将零散的节点连接成双向循环链表。

#### 剑指 Offer 38. 字符串的排列

<https://leetcode.cn/problems/zi-fu-chuan-de-pai-lie-lcof/>

```cpp
class Solution {
public:
    vector<string> ans;
    vector<bool> st;
    string path;
    vector<string> permutation(string s) {
        st = vector<bool>(s.size());
        dfs(s, 0);
        return ans;
    }

    void dfs(string s, int u) {
        if(u == s.size()) {
            ans.push_back(path);
            return;
        }

        unordered_set<int> S;

        for(int i = 0; i < s.size(); i++){
            if(!st[i]) {
                if(S.count(s[i])) continue;
                S.insert(s[i]);
                st[i] = true;
                path.push_back(s[i]);
                dfs(s, u + 1);
                st[i] = false;
                path.pop_back();
            }
        }
    }
};
```

和全排列一样的思路。

#### 剑指 Offer 39. 数组中出现次数超过一半的数字

<https://leetcode.cn/problems/shu-zu-zhong-chu-xian-ci-shu-chao-guo-yi-ban-de-shu-zi-lcof/>

```cpp
class Solution {
public:
    int majorityElement(vector<int>& nums) {
        int len = nums.size() / 2;
        unordered_map<int, int> S;
        for(int i = 0; i < nums.size(); i++) {
            S[nums[i]]++;
            if(S[nums[i]] > len) return nums[i];
        }
        return 0;
    }
};
```

按题意使用哈希表即可。

#### 剑指 Offer 40. 最小的 k 个数

<https://leetcode.cn/problems/zui-xiao-de-kge-shu-lcof/>

```cpp
class Solution {
public:
    vector<int> getLeastNumbers(vector<int>& arr, int k) {
        vector<int> res;
        sort(arr.begin(), arr.end());
        for(int i = 0; i < k; i++) res.push_back(arr[i]);
        return res;
    }
};
```

建议使用快排。

#### 剑指 Offer 41. 数据流中的中位数

<https://leetcode.cn/problems/shu-ju-liu-zhong-de-zhong-wei-shu-lcof/>

```cpp
class MedianFinder {
public:
    priority_queue<int, vector<int>, less<int>> left;
    priority_queue<int, vector<int>, greater<int>> right;
    /** initialize your data structure here. */
    MedianFinder() {
    }

    void addNum(int num) {
        // 右侧堆的每一个值一定是来源于左侧堆，这样才能保证右侧堆的每一个元素都比左侧堆大
        left.push(num);
        right.push(left.top());
        left.pop();
        if (right.size() > left.size()){
            left.push(right.top());
            right.pop();
        }
    }

    double findMedian() {
        if (left.size() > right.size())
            return left.top();
        else
            return (left.top() + right.top()) / 2.0;
    }
};
```

左侧堆的数量大于等于右侧堆，等于时表示数列偶数个，中位数就是左侧数的最大值和右侧数的最小值求平均，大于时表示数列奇数个，左侧堆的 top 就是答案。

#### 剑指 Offer 19. 正则表达式匹配

<https://leetcode.cn/problems/zheng-ze-biao-da-shi-pi-pei-lcof/>

请实现一个函数用来匹配包含'. '和 \_ 的正则表达式。模式中的字符'.'表示任意一个字符，而 \_ 表示它前面的字符可以出现任意次（含 0 次）。在本题中，匹配是指字符串的所有字符匹配整个模式。例如，字符串"aaa"与模式"a.a"和"ab\*ac\*a"匹配，但与"aa.a"和"ab\*a"均不匹配。

```cpp
class Solution {
public:
    // 备忘录
    vector<vector<int>> memo;

    bool isMatch(string s, string p) {
        int m = s.size(), n = p.size();
        memo = vector<vector<int>>(m, vector<int>(n, -1));
        // 指针 i，j 从索引 0 开始移动
        return dp(s, 0, p, 0);
    }

    /* 计算 p[j..] 是否匹配 s[i..] */
    bool dp(string& s, int i, string& p, int j) {
        int m = s.size(), n = p.size();
        // base case
        if (j == n) return i == m;
        if (i == m) {
            if ((n - j) % 2 == 1) return false;
            for (; j < n - 1; j += 2) if (p[j + 1] != '*') return false;
            return true;
        }

        // 查备忘录，防止重复计算
        if (memo[i][j] != -1) return memo[i][j];
        bool res = false;
        if (s[i] == p[j] || p[j] == '.') {
            if (j < n - 1 && p[j + 1] == '*') res = dp(s, i, p, j + 2) || dp(s, i + 1, p, j);
            else res = dp(s, i + 1, p, j + 1);
        } else {
            if (j < n - 1 && p[j + 1] == '*') res = dp(s, i, p, j + 2);
            else res = false;
        }
        // 将当前结果记入备忘录
        memo[i][j] = res;
        return res;
    }
};
```

#### 剑指 Offer 42. 连续子数组的最大和

<https://leetcode.cn/problems/lian-xu-zi-shu-zu-de-zui-da-he-lcof/>

输入一个整型数组，数组中的一个或连续多个整数组成一个子数组。求所有子数组的和的最大值。

要求时间复杂度为 O(n)。

```cpp
class Solution {
public:
    int maxSubArray(vector<int>& nums) {
        int res = INT_MIN;
        for(int i = 0, last = 0; i < nums.size(); i++) {
            last = nums[i] + max(0, last);
            res = max(res, last);
        }
        return res;
    }
};
```

简单 dp。

#### 剑指 Offer 50. 第一个只出现一次的字符

<https://leetcode.cn/problems/di-yi-ge-zhi-chu-xian-yi-ci-de-zi-fu-lcof/>

在字符串 s 中找出第一个只出现一次的字符。如果没有，返回一个单空格。 s 只包含小写字母。

```cpp
class Solution {
public:
    char firstUniqChar(string s) {
        unordered_map<char, int> h;
        for(int i = 0; i < s.size(); i++) h[s[i]]++;
        for(int i = 0; i < s.size(); i++) if(h[s[i]] == 1) return s[i];
        return ' ';
    }
};
```

#### 剑指 Offer 51. 数组中的逆序对

<https://leetcode.cn/problems/shu-zu-zhong-de-ni-xu-dui-lcof/>

在数组中的两个数字，如果前面一个数字大于后面的数字，则这两个数字组成一个逆序对。输入一个数组，求出这个数组中的逆序对的总数。

```cpp
class Solution {
public:
    vector<int> tmp;
    int count = 0;
    int reversePairs(vector<int>& nums) {
        if (!nums.size()) return 0;
        tmp = vector<int>(nums.size());
        sort(nums, 0, nums.size() - 1);
        return count;
    }

    void sort(vector<int>& nums, int l, int r) {
        if(l == r) return;
        int mid = (l + r) / 2;
        sort(nums, l, mid);
        sort(nums, mid + 1, r);
        merge(nums, l, mid, r);
    }

    void merge(vector<int>& nums, int l, int mid, int r) {
        for(int i = l; i <= r; i++) tmp[i] = nums[i];
        int end = mid + 1;
        for(int i = l; i <= mid; i++) {
            while(end <= r && (long)nums[i] > (long)nums[end]) end++;
            count += end - (mid + 1);
        }
        int i = l, j = mid + 1;
        for(int p = l; p <= r; p++) {
            if(i == mid + 1) nums[p] = tmp[j++];
            else if(j == r + 1) nums[p] = tmp[i++];
            else if(tmp[i] > tmp[j]) nums[p] = tmp[j++];
            else nums[p] = tmp[i++];
        }
    }
};
```

归并排序

#### 剑指 Offer 48. 最长不含重复字符的子字符串

<https://leetcode.cn/problems/zui-chang-bu-han-zhong-fu-zi-fu-de-zi-zi-fu-chuan-lcof/?favorite=xb9nqhhg>

```cpp
class Solution {
public:
    int lengthOfLongestSubstring(string s) {
        unordered_map<char, int> h;
        int res = 0;
        for(int i = 0, j = 0; i < s.size(); i++) {
            h[s[i]]++;
            while(h[s[i]] > 1) h[s[j++]]--;
            res = max(res, i - j + 1);
        }
        return res;
    }
};
```

双指针加哈希表解决。

#### 剑指 Offer 49. 丑数

<https://leetcode.cn/problems/chou-shu-lcof/>

我们把只包含质因子 2、3 和 5 的数称作丑数（Ugly Number）。求按从小到大的顺序的第 n 个丑数。

```cpp
class Solution {
public:
    int nthUglyNumber(int n) {
        int p1 = 1, p2 = 1, p3 = 1;
        int pd1 = 1, pd2 = 1, pd3 = 1;
        vector<int> ugly(n + 1);
        int p = 1;
        while(p <= n) {
            int minn = min(min(pd1, pd2), pd3);
            ugly[p++] = minn;
            if(pd1 == minn) pd1 = 2 * ugly[p1++];
            if(pd2 == minn) pd2 = 3 * ugly[p2++];
            if(pd3 == minn) pd3 = 5 * ugly[p3++];
        }
        return ugly[n];
    }
};
```

我们用 p1, p2, p3 分别代表三条丑数链表上的指针，用 pd1, pd2, pd3 代表丑数链表上节点的值，用 ugly 数组记录有序链表合并之后的结果。

#### 剑指 Offer 47. 礼物的最大价值

<https://leetcode.cn/problems/li-wu-de-zui-da-jie-zhi-lcof/>

在一个 m\*n 的棋盘的每一格都放有一个礼物，每个礼物都有一定的价值（价值大于 0）。你可以从棋盘的左上角开始拿格子里的礼物，并每次向右或者向下移动一格、直到到达棋盘的右下角。给定一个棋盘及其上面的礼物的价值，请计算你最多能拿到多少价值的礼物？

```cpp
class Solution {
public:
    vector<vector<int>> memo;
    int maxValue(vector<vector<int>>& grid) {
        int m = grid.size();
        int n = grid[0].size();
        memo = vector<vector<int>> (m, vector<int>(n, -1));
        return dp(grid, m - 1, n - 1);
    }

    int dp(vector<vector<int>>& grid, int i, int j) {
        if(i == 0 && j == 0) return grid[0][0];
        if(i < 0 || j < 0) return INT_MIN;
        if(memo[i][j] != -1) return memo[i][j];
        memo[i][j] = max(dp(grid, i - 1, j), dp(grid, i, j - 1)) + grid[i][j];
        return memo[i][j];
    }
};
```

从左上角位置 (0, 0) 走到位置 (i, j) 的最大路径和为 dp(grid, i, j)，还可以用备忘录优化一下执行效率。

#### 剑指 Offer 46. 把数字翻译成字符串

<https://leetcode.cn/problems/ba-shu-zi-fan-yi-cheng-zi-fu-chuan-lcof/>

给定一个数字，我们按照如下规则把它翻译为字符串：0 翻译成 “a” ，1 翻译成 “b”，……，11 翻译成 “l”，……，25 翻译成 “z”。一个数字可能有多个翻译。请编程实现一个函数，用来计算一个数字有多少种不同的翻译方法。

```cpp
class Solution {
public:
    int translateNum(int num) {
        string s = to_string(num);
        int n = s.size();
        if(n < 1) return 0;
        vector<int>dp(n + 1);
        dp[0] = 1, dp[1] = 1;
        for(int i = 2; i <= n; i++) {
            char c = s[i - 1], d = s[i - 2];
            if(c >= '0' && c <= '9') dp[i] += dp[i - 1];
            if(d == '1' || d == '2' && c <= '5') dp[i] += dp[i - 2];
        }
        return dp[n];
    }
};
```

#### 剑指 Offer 44. 数字序列中某一位的数字

<https://leetcode.cn/problems/shu-zi-xu-lie-zhong-mou-yi-wei-de-shu-zi-lcof/>

数字以 0123456789101112131415…的格式序列化到一个字符序列中。在这个序列中，第 5 位（从下标 0 开始计数）是 5，第 13 位是 1，第 19 位是 4，等等。

请写一个函数，求任意第 n 位对应的数字。

```cpp
class Solution {
public:
    int findNthDigit(int n) {
        int digit = 1;
        long base = 1;

        while(n > 9 * base * digit) {
            n -= 9 * base * digit;
            base *= 10;
            digit++;
        }

        long val = base + (n - 1) / digit;
        int index = (n - 1) % digit;
        string s = to_string(val);
        return s[index] - '0';
    }
};
```

找数学规律，一位数有几个？1\~9 共 9 \_ 1 = 9 个。共几位？共 1 \_ 9 = 9 位。

二位数有几个？10\~99 共 9 \_ 10 = 90 个。共几位？共 2 \_ 90 = 180 位。

三位数有几个？100\~999 共 9 \_ 100 = 900 个。共几位？共 3 \_ 900 = 2700 位。

以此类推，我们可以通过这个规律推断第 n 位的数字到底是什么。所以这道题的难点在于如何把上述规律写成算法代码。

#### 剑指 Offer 53 - II. 0 ～ n-1 中缺失的数字

<https://leetcode.cn/problems/que-shi-de-shu-zi-lcof/>

一个长度为 n-1 的递增排序数组中的所有数字都是唯一的，并且每个数字都在范围 0 ～ n-1 之内。在范围 0 ～ n-1 内的 n 个数字中有且只有一个数字不在该数组中，请找出这个数字。

```cpp
class Solution {
public:
    int missingNumber(vector<int>& nums) {
        int l = 0, r = nums.size();
        while(l < r) {
            int mid = (l + r) / 2;
            if(nums[mid] != mid) r = mid;
            else l = mid + 1;
        }
        return l;
    }
};
```

二分就完事了。

#### 剑指 Offer 54. 二叉搜索树的第 k 大节点

<https://leetcode.cn/problems/er-cha-sou-suo-shu-de-di-kda-jie-dian-lcof/>

给定一棵二叉搜索树，请找出其中第 k 大的节点的值。

```cpp
/**
 * Definition for a binary tree node.
 * struct TreeNode {
 *     int val;
 *     TreeNode *left;
 *     TreeNode *right;
 *     TreeNode(int x) : val(x), left(NULL), right(NULL) {}
 * };
 */
class Solution {
public:
    int res = 0, rank = 0;
    int kthLargest(TreeNode* root, int k) {
        dfs(root, k);
        return res;
    }

    void dfs(TreeNode* root, int k) {
        if(!root) return;
        dfs(root->right, k);
        rank++;
        if(k == rank) {
            res = root->val;
            return;
        }
        dfs(root->left, k);
    }
};
```

逆向的中序遍历可以得到从大到小的值。

#### 剑指 Offer 43. 1 ～ n 整数中 1 出现的次数

<https://leetcode.cn/problems/1nzheng-shu-zhong-1chu-xian-de-ci-shu-lcof/>

输入一个整数 n ，求 1 ～ n 这 n 个整数的十进制表示中 1 出现的次数。

例如，输入 12，1 ～ 12 这些整数中包含 1 的数字有 1、10、11 和 12，1 一共出现了 5 次。

```cpp
class Solution {
public:
    int countDigitOne(int n) {
        long digit = 1, res = 0;
        int high = n / 10, cur = n % 10, low = 0;
        while(high != 0 || cur != 0) {
            if(cur == 0) res += high * digit;
            else if(cur == 1) res += high * digit + low + 1;
            else res += (high + 1) * digit;
            low += cur * digit;
            cur = high % 10;
            high /= 10;
            digit *= 10;
        }
        return res;
    }
};
```

背就完事了。

#### 剑指 Offer 55 - II. 平衡二叉树

<https://leetcode.cn/problems/ping-heng-er-cha-shu-lcof/>

输入一棵二叉树的根节点，判断该树是不是平衡二叉树。如果某二叉树中任意节点的左右子树的深度相差不超过 1，那么它就是一棵平衡二叉树。

```cpp
/**
 * Definition for a binary tree node.
 * struct TreeNode {
 *     int val;
 *     TreeNode *left;
 *     TreeNode *right;
 *     TreeNode(int x) : val(x), left(NULL), right(NULL) {}
 * };
 */
class Solution {
public:
    bool ans;
    bool isBalanced(TreeNode* root) {
        if(!root) return true;
        ans = true;
        dfs(root);
        return ans;
    }

    int dfs(TreeNode* root) {
        if(!root) return 0;
        int l = dfs(root->left);
        int r = dfs(root->right);
        if(abs(l - r) > 1) ans = false;
        return max(l, r) + 1;
    }
};
```

#### 剑指 Offer 56 - I. 数组中数字出现的次数

<https://leetcode.cn/problems/shu-zu-zhong-shu-zi-chu-xian-de-ci-shu-lcof/>

一个整型数组 nums 里除两个数字之外，其他数字都出现了两次。请写程序找出这两个只出现一次的数字。要求时间复杂度是 O(n)，空间复杂度是 O(1)。

```cpp
class Solution {
public:
    vector<int> singleNumbers(vector<int>& nums) {
        int ret = 0;
        for (int n : nums)
            ret ^= n;
        int div = 1;
        while ((div & ret) == 0)
            div <<= 1;
        int a = 0, b = 0;
        for (int n : nums)
            if (div & n)
                a ^= n;
            else
                b ^= n;
        return vector<int>{a, b};
    }
};
```

#### 剑指 Offer 56 - II. 数组中数字出现的次数 II

<https://leetcode.cn/problems/shu-zu-zhong-shu-zi-chu-xian-de-ci-shu-ii-lcof/>

在一个数组 nums 中除一个数字只出现一次之外，其他数字都出现了三次。请找出那个只出现一次的数字。

```cpp
class Solution {
public:
    int singleNumber(vector<int>& nums) {
        int ones = 0, twos = 0;
        vector<int>ans;
        for(int num : nums) {
            ones = ones ^ num & ~twos;
            twos = twos ^ num & ~ones;
        }
        return ones;
    }
};
```

有限自动状态机。

#### 剑指 Offer II 031. 最近最少使用缓存

<https://leetcode.cn/problems/OrIXps/>

运用所掌握的数据结构，设计和实现一个   LRU (Least Recently Used，最近最少使用) 缓存机制 。

实现 LRUCache 类：

LRUCache(int capacity) 以正整数作为容量  capacity 初始化 LRU 缓存 int get(int key) 如果关键字 key 存在于缓存中，则返回关键字的值，否则返回 -1 。 void put(int key, int value)  如果关键字已经存在，则变更其数据值；如果关键字不存在，则插入该组「关键字-值」。当缓存容量达到上限时，它应该在写入新数据之前删除最久未使用的数据值，从而为新的数据值留出空间。

```cpp
class LRUCache {
public:
    struct Node {
        int key, val;
        Node *left, *right;
        Node(int _key, int _val): key(_key), val(_val), left(NULL), right(NULL) {}
    }*L, *R;
    unordered_map<int, Node*> hash;
    int n;

    void remove(Node* p) {
        p->left->right = p->right;
        p->right->left = p->left;
    }

    void insert(Node* p) {
        p->left = L;
        p->right = L->right;
        L->right->left = p;
        L->right = p;
    }

    LRUCache(int capacity) {
        n = capacity;
        L = new Node(-1, -1), R = new Node(-1, -1);
        L->right = R, R->left = L;
    }

    int get(int key) {
        if(hash.count(key) == 0) return -1;
        auto p = hash[key];
        remove(p);
        insert(p);
        return p->val;
    }

    void put(int key, int value) {
        if(hash.count(key)) {
            auto p = hash[key];
            p->val = value;
            remove(p);
            insert(p);
        } else {
            if(n == hash.size()) {
                auto p = R->left;
                remove(p);
                hash.erase(p->key);
                delete(p);
            }
            auto p = new Node(key, value);
            insert(p);
            hash[key] = p;
        }
    }
};

/**
 * Your LRUCache object will be instantiated and called as such:
 * LRUCache* obj = new LRUCache(capacity);
 * int param_1 = obj->get(key);
 * obj->put(key,value);
 */
```


# Go CodeTop 题解

#### 3. 无重复字符的最长子串

[3. 无重复字符的最长子串 - 力扣（LeetCode）](https://leetcode.cn/problems/longest-substring-without-repeating-characters/)

使用滑动窗口的思想，用一个 Map 来存储目前遇到的字符。

```go
package main

import (
	"fmt"
	"math"
)

func lengthOfLongestSubstring(s string) int {
	if len(s) <= 0 {
		return 0
	}

	h := make(map[byte]int, 0)
	r, l, res := 0, 0, math.MinInt64

	for r < len(s) {
		c := s[r]
		r++
		h[c]++
		for h[c] > 1 {
			d := s[l]
			l++
			h[d]--
		}
		res = max(res, r-l)
	}
	if res == math.MinInt64 {
		return 0
	}
	return res
}

func main() {
	var s string
	fmt.Scanln(&s)
	fmt.Println(lengthOfLongestSubstring(s))
}
```

#### 206. 反转链表

[206. 反转链表 - 力扣（LeetCode）](https://leetcode.cn/problems/reverse-linked-list/)

```go
package main

import "fmt"

type ListNode struct {
	Val  int
	Next *ListNode
}

func reverseList(head *ListNode) *ListNode {
	if head == nil {
		return head
	}

	a, b := head, head.Next
	for b != nil {
		t := b.Next
		b.Next = a
		a = b
		b = t
	}
	head.Next = nil
	return a
}

func main() {
	head := &ListNode{
		Val: 5,
		Next: &ListNode{
			Val: 4,
			Next: &ListNode{
				Val: 3,
				Next: &ListNode{
					Val: 2,
					Next: &ListNode{
						Val:  1,
						Next: nil,
					},
				},
			},
		},
	}
	head = reverseList(head)
	for head != nil {
		fmt.Printf("%d ", head.Val)
		head = head.Next
	}
}
// 1 2 3 4 5
```

#### 146. LRU 缓存

[146. LRU 缓存 - 力扣（LeetCode）](https://leetcode.cn/problems/lru-cache/)

```go
package main

import (
	"container/list"
	"fmt"
)

type entry struct {
	key int
	val int
}

type LRUCache struct {
	capacity int
	list     *list.List
	k2N      map[int]*list.Element
}

func (c *LRUCache) Get(key int) int {
	node := c.k2N[key]
	if node == nil {
		return -1
	}
	c.list.MoveToFront(node)
	return node.Value.(entry).val
}

func (c *LRUCache) Put(key int, value int) {
	if node := c.k2N[key]; node != nil {
		node.Value = entry{key, value}
		c.list.MoveToFront(node)
		return
	}
	c.k2N[key] = c.list.PushFront(entry{key, value})
	if len(c.k2N) > c.capacity {
		delete(c.k2N, c.list.Remove(c.list.Back()).(entry).key)
	}
}

func main() {
	lru := LRUCache{3, list.New(), map[int]*list.Element{}}
	lru.Put(1, 1)
	lru.Put(2, 1)
	lru.Put(3, 1)
	lru.Put(1, 2)
	lru.Put(4, 1)
	fmt.Println(lru.Get(4), lru.Get(1))
}
// 1 2
```

#### 215. 数组中的第 K 个最大元素

[215. 数组中的第K个最大元素 - 力扣（LeetCode）](https://leetcode.cn/problems/kth-largest-element-in-an-array/)

用快速排序完成

```go
package main

import "fmt"

func quick_sort(nums []int, l, r int) {
	if l >= r {
		return
	}
	i, j, x := l, r, nums[(l+r)/2]
	for i <= j {
		for i <= r && nums[i] < x {
			i++
		}
		for j >= l && nums[j] > x {
			j--
		}
		if i <= j {
			nums[i], nums[j] = nums[j], nums[i]
			i++
			j--
		}
	}
	quick_sort(nums, l, j)
	quick_sort(nums, i, r)
}

func main() {
	nums := []int{3, 1, 2, 4, 5}
	quick_sort(nums, 0, len(nums)-1)
	fmt.Println(nums)
}
// 1 2 3 4 5
```

#### 25. K 个一组翻转链表

[25. K 个一组翻转链表 - 力扣（LeetCode）](https://leetcode.cn/problems/reverse-nodes-in-k-group/description/)

每次调用翻转链表翻转 K 个节点，接着递归下去即可。

```go
package main

import "fmt"

type ListNode struct {
	Val  int
	Next *ListNode
}

func reverseKGroup(head *ListNode, k int) *ListNode {
	if head == nil {
		return head
	}
	a, b := head, head
	for i := 0; i < k; i++ {
		if b == nil {
			return head
		}
		b = b.Next
	}
	newHead := reverseList(a, b)
	a.Next = reverseKGroup(b, k)
	return newHead
}

func reverseList(h1, h2 *ListNode) *ListNode {
	a, b := h1, h1.Next
	for b != h2 {
		t := b.Next
		b.Next = a
		a = b
		b = t
	}
	h1.Next = h2
	return a
}

func main() {
	head := &ListNode{
		Val: 5,
		Next: &ListNode{
			Val: 4,
			Next: &ListNode{
				Val: 3,
				Next: &ListNode{
					Val: 2,
					Next: &ListNode{
						Val:  1,
						Next: nil,
					},
				},
			},
		},
	}
	head = reverseKGroup(head, 2)
	for head != nil {
		fmt.Printf("%d ", head.Val)
		head = head.Next
	}
}
// 4 5 2 3 1
```

#### 15. 三数之和

[15. 三数之和 - 力扣（LeetCode）](https://leetcode.cn/problems/3sum/description/)

```go
package main

import (
	"fmt"
	"sort"
)

func threeSum(nums []int) [][]int {
	sort.Ints(nums)
	var res [][]int
	for i := 0; i < len(nums); i++ {
		if i > 0 && nums[i] == nums[i-1] {
			continue
		}
		for j, k := i+1, len(nums)-1; j < k; j++ {
			if j > i+1 && nums[j] == nums[j-1] {
				continue
			}
			for j < k-1 && nums[i]+nums[j]+nums[k-1] >= 0 {
				k--
			}
			if nums[i]+nums[j]+nums[k] == 0 {
				res = append(res, []int{nums[i], nums[j], nums[k]})
			}
		}
	}
	return res
}

func main() {
	res := threeSum([]int{-1, 0, 1, 2, -1, -4})
	fmt.Println(res)
}
// [[-1 -1 2] [-1 0 1]]
```

#### 53. 最大子数组和

[53. 最大子数组和 - 力扣（LeetCode）](https://leetcode.cn/problems/maximum-subarray/description/)

用滑动窗口的思想来做，需要在第二个 for 循环之前将 res 赋值。

```go
package main

import (
	"fmt"
	"math"
)

func maxSubArray(nums []int) int {
	r, l, sum := 0, 0, 0
	res := math.MinInt
	for r < len(nums) {
		sum += nums[r]
		r++
		res = max(res, sum)
		for sum < 0 {
			sum -= nums[l]
			l++
		}
	}
	return res
}

func main() {
	res := maxSubArray([]int{-2, 1, -3, 4, -1, 2, 1, -5, 4})
	fmt.Println(res)
}
// 连续子数组 [4,-1,2,1] 的和最大，为 6
```

#### 补充题：手撕快速排序

\[912. 排序数组 - 力扣（LeetCode）]\(<https://leetcode.cn/problems/sort-an-array/description/>

```go
package main

import "fmt"

func quick_sort(nums []int, l, r int) {
	if l >= r {
		return
	}
	i, j, x := l, r, nums[(l+r)/2]
	for i <= j {
		for i <= r && nums[i] < x {
			i++
		}
		for j >= l && nums[j] > x {
			j--
		}
		if i <= j {
			nums[i], nums[j] = nums[j], nums[i]
			i++
			j--
		}
	}
	quick_sort(nums, l, j)
	quick_sort(nums, i, r)
}

func main() {
	nums := []int{3, 1, 2, 4, 5}
	quick_sort(nums, 0, len(nums)-1)
	fmt.Println(nums)
}
// 1 2 3 4 5
```

#### 21. 合并两个有序链表

[21. 合并两个有序链表 - 力扣（LeetCode）](https://leetcode.cn/problems/merge-two-sorted-lists/description/)

```go
package main

import "fmt"

type ListNode struct {
	Val  int
	Next *ListNode
}

func mergeTwoLists(list1 *ListNode, list2 *ListNode) *ListNode {
	dummy := &ListNode{}
	p := dummy
	for list1 != nil && list2 != nil {
		if list1.Val < list2.Val {
			p.Next = list1
			p = p.Next
			list1 = list1.Next
		} else {
			p.Next = list2
			p = p.Next
			list2 = list2.Next
		}
	}
	if list1 != nil {
		p.Next = list1
	}
	if list2 != nil {
		p.Next = list2
	}
	return dummy.Next
}

func main() {
	l1 := &ListNode{
		Val: 1,
		Next: &ListNode{
			Val:  3,
			Next: nil,
		},
	}
	l2 := &ListNode{
		Val: 2,
		Next: &ListNode{
			Val:  4,
			Next: nil,
		},
	}
	newList := mergeTwoLists(l1, l2)
	for newList != nil {
		fmt.Println(newList.Val)
		newList = newList.Next
	}
}
// 1 2 3 4
```

#### 1. 两数之和

[1. 两数之和 - 力扣（LeetCode）](https://leetcode.cn/problems/two-sum/description/)

```go
package main

import "fmt"

func twoSum(nums []int, target int) []int {
	m := make(map[int]int, 0)
	for i := 0; i < len(nums); i++ {
		if _, ok := m[target-nums[i]]; ok {
			return []int{i, m[target-nums[i]]}
		} else {
			m[nums[i]] = i
		}
	}
	return []int{}
}

func main() {
	fmt.Println(twoSum([]int{2, 7, 11, 15}, 9))
}
// 0 1
```

#### 5. 最长回文子串

[5. 最长回文子串 - 力扣（LeetCode）](https://leetcode.cn/problems/longest-palindromic-substring/description/)

```go
package main

import "fmt"

func longestPalindrome(s string) string {
	res := ""
	for i := 0; i < len(s); i++ {
		s1 := match(s, i, i)
		if len(s1) > len(res) {
			res = s1
		}
		s2 := match(s, i, i+1)
		if len(s2) > len(res) {
			res = s2
		}
	}
	return res
}

func match(s string, l, r int) string {
	for l >= 0 && r < len(s) && s[l] == s[r] {
		l--
		r++
	}
	return s[l+1 : r]
}

func main() {
	fmt.Println(longestPalindrome("cbbd"))
}
// bb
```

#### 102. 二叉树的层序遍历

[102. 二叉树的层序遍历 - 力扣（LeetCode）](https://leetcode.cn/problems/binary-tree-level-order-traversal/description/)

```go
package main

import "fmt"

type TreeNode struct {
	Val   int
	Left  *TreeNode
	Right *TreeNode
}

func levelOrder(root *TreeNode) [][]int {
	res := [][]int{}
	if root == nil {
		return res
	}
	q := []*TreeNode{root}
	for len(q) > 0 {
		l := len(q)
		path := []int{}
		for i := 0; i < l; i++ {
			node := q[0]
			q = q[1:]
			path = append(path, node.Val)
			if node.Left != nil {
				q = append(q, node.Left)
			}
			if node.Right != nil {
				q = append(q, node.Right)
			}
		}
		res = append(res, path)
	}
	return res
}

func main() {
	tree := &TreeNode{
		Val: 3,
		Left: &TreeNode{
			Val: 9,
		},
		Right: &TreeNode{
			Val: 20,
			Left: &TreeNode{
				Val: 15,
			},
			Right: &TreeNode{
				Val: 7,
			},
		},
	}
	fmt.Println(levelOrder(tree))
}
// [[3] [9 20] [15 7]]
```

#### 33. 搜索旋转排序数组

[33. 搜索旋转排序数组 - 力扣（LeetCode）](https://leetcode.cn/problems/search-in-rotated-sorted-array/)

```go
package main

import "fmt"

func search(nums []int, target int) int {
	l, r := 0, len(nums)-1
	for l < r {
		mid := (l + r + 1) / 2
		if nums[mid] >= nums[0] {
			l = mid
		} else {
			r = mid - 1
		}
	}

	if target >= nums[0] {
		l = 0
	} else {
		l = r + 1
		r = len(nums) - 1
	}

	for l < r {
		mid := (l + r) / 2
		if nums[mid] >= target {
			r = mid
		} else {
			l = mid + 1
		}
	}

	if nums[r] != target {
		return -1
	}
	return r
}

func main() {
	fmt.Println(search([]int{4, 5, 6, 7, 0, 1, 2}, 0))
}
// 4
```

#### 200. 岛屿数量

[200. 岛屿数量 - 力扣（LeetCode）](https://leetcode.cn/problems/number-of-islands/)

```go
package main

import "fmt"

type Solution struct {
	dx, dy []int
	g      [][]byte
}

func numIslands(grid [][]byte) int {
	sol := Solution{
		dx: []int{0, 1, 0, -1},
		dy: []int{1, 0, -1, 0},
		g:  grid,
	}
	res := 0
	m, n := len(sol.g), len(sol.g[0])
	for i := 0; i < m; i++ {
		for j := 0; j < n; j++ {
			if sol.g[i][j] == '1' {
				sol.dfs(i, j)
				res++
			}
		}
	}
	return res
}

func (sol *Solution) dfs(x, y int) {
	sol.g[x][y] = '0'
	for i := 0; i < 4; i++ {
		a, b := x+sol.dx[i], y+sol.dy[i]
		if a >= 0 && b >= 0 && a < len(sol.g) && b < len(sol.g[0]) && sol.g[a][b] == '1' {
			sol.dfs(a, b)
		}
	}
}

func main() {
	grid := [][]byte{
		{'1', '1', '1', '1', '0'},
		{'1', '1', '0', '1', '0'},
		{'1', '1', '0', '0', '0'},
		{'0', '0', '0', '1', '0'},
	}
	fmt.Println(numIslands(grid))
}
// 2
```

#### 121. 买卖股票的最佳时机

[121. 买卖股票的最佳时机 - 力扣（LeetCode）](https://leetcode.cn/problems/best-time-to-buy-and-sell-stock/description/)

```go
package main

import "fmt"

func maxProfit(prices []int) int {
	dp := make([][]int, len(prices))
	for i := range dp {
		dp[i] = make([]int, 2)
	}

	dp[0][0] = 0
	dp[0][1] = -prices[0]

	for i := 1; i < len(prices); i++ {
		dp[i][0] = max(dp[i-1][0], dp[i-1][1]+prices[i])
		dp[i][1] = max(dp[i-1][1], -prices[i])
	}

	return dp[len(prices)-1][0]
}

func main() {
	fmt.Println(maxProfit([]int{7, 1, 5, 3, 6, 4}))
}
// 5
```

#### 20. 有效的括号

[20. 有效的括号 - 力扣（LeetCode）](https://leetcode.cn/problems/valid-parentheses/description/)

```go
package main

import (
	"fmt"
)

func isValid(s string) bool {
    stk := []rune{}
    for _, v := range s {
        if v == '(' || v == '[' || v == '{' {
            stk = append(stk, v)
        } else if len(stk) > 0 && math.Abs(float64(v) - float64(stk[len(stk) - 1])) < 3 {
            stk = stk[:len(stk) - 1]
        } else {
            return false
        }
    }
    return len(stk) == 0
}

func main() {
	fmt.Println(isValid("{}[]()"))
	fmt.Println(isValid("([)]"))
}
// ture false
```

#### 141. 环形链表

[141. 环形链表 - 力扣（LeetCode）](https://leetcode.cn/problems/linked-list-cycle/description/)

```go
package main

import "fmt"

type ListNode struct {
	Val  int
	Next *ListNode
}

func hasCycle(head *ListNode) bool {
	slow, fast := head, head
	for fast != nil && fast.Next != nil {
		fast = fast.Next.Next
		slow = slow.Next
		if fast == slow {
			return true
		}
	}
	return false
}

func main() {
	head := &ListNode{
		Val: 3,
		Next: &ListNode{
			Val: 2,
			Next: &ListNode{
				Val: 0,
				Next: &ListNode{
					Val: -4,
				},
			},
		},
	}
	head.Next.Next.Next.Next = head.Next
	fmt.Println(hasCycle(head))
}
// true
```

#### 88. 合并两个有序数组

[88. 合并两个有序数组 - 力扣（LeetCode）](https://leetcode.cn/problems/merge-sorted-array/description/)

```go
package main

import "fmt"

func merge(nums1 []int, m int, nums2 []int, n int) {
	i, j, k := m-1, n-1, m+n-1
	for i >= 0 && j >= 0 {
		if nums1[i] < nums2[j] {
			nums1[k] = nums2[j]
			j--
		} else {
			nums1[k] = nums1[i]
			i--
		}
		k--
	}
	for j >= 0 {
		nums1[k] = nums2[j]
		j--
		k--
	}
}

func main() {
	nums1 := []int{1, 2, 3, 0, 0, 0}
	nums2 := []int{2, 5, 6}
	merge(nums1, 3, nums2, 3)
	fmt.Println(nums1)
}
// 1 2 2 3 5 6
```

#### 236. 二叉树的最近公共祖先

[236. 二叉树的最近公共祖先 - 力扣（LeetCode）](https://leetcode.cn/problems/lowest-common-ancestor-of-a-binary-tree/description/)

```go
package main

import "fmt"

type TreeNode struct {
	Val   int
	Left  *TreeNode
	Right *TreeNode
}

func lowestCommonAncestor(root, p, q *TreeNode) *TreeNode {
	if root == p || root == q || root == nil {
		return root
	}
	l := lowestCommonAncestor(root.Left, p, q)
	r := lowestCommonAncestor(root.Right, p, q)
	if l == nil {
		return r
	}
	if r == nil {
		return l
	}
	return root
}

func main() {
	root := &TreeNode{
		Val:   1,
		Left:  &TreeNode{Val: 2},
		Right: nil,
	}
	res := lowestCommonAncestor(root, root, root.Left)
	fmt.Println(res.Val)
}
// 1
```

#### 46. 全排列

[46. 全排列 - 力扣（LeetCode）](https://leetcode.cn/problems/permutations/description/)

```go
package main

import "fmt"

var (
	res  [][]int
	path []int
	st   []bool
)

func permute(nums []int) [][]int {
	res = [][]int{}
	path = []int{}
	st = make([]bool, len(nums))

	dfs(nums, 0)
	return res
}

func dfs(nums []int, u int) {
	if u == len(nums) {
		tmp := make([]int, len(path)) // 构建新的slice，避免后续操作影响已保存结果
		copy(tmp, path)
		res = append(res, tmp)
		return
	}

	for i := 0; i < len(nums); i++ {
		if !st[i] {
			st[i] = true
			path = append(path, nums[i])
			dfs(nums, u+1)
			path = path[:len(path)-1]
			st[i] = false
		}
	}
}

func main() {
	nums := []int{1, 2, 3}
	res := permute(nums)
	for _, r := range res {
		fmt.Println(r)
	}
}
// [1 2 3]
// [1 3 2]
// [2 1 3]
// [2 3 1]
// [3 1 2]
// [3 2 1]
```

#### 103. 二叉树的锯齿形层序遍历

[103. 二叉树的锯齿形层序遍历 - 力扣（LeetCode）](https://leetcode.cn/problems/binary-tree-zigzag-level-order-traversal/description/)

```go
package main

import (
	"fmt"
	"slices"
)

type TreeNode struct {
	Val   int
	Left  *TreeNode
	Right *TreeNode
}

func zigzagLevelOrder(root *TreeNode) [][]int {
	res := [][]int{}
	if root == nil {
		return res
	}
	count := 1
	q := []*TreeNode{root}
	for len(q) > 0 {
		l := len(q)
		path := []int{}
		for i := 0; i < l; i++ {
			node := q[0]
			q = q[1:]
			path = append(path, node.Val)
			if node.Left != nil {
				q = append(q, node.Left)
			}
			if node.Right != nil {
				q = append(q, node.Right)
			}
		}
		if count%2 == 0 {
			slices.Reverse(path)
		}
		count++
		res = append(res, path)
	}
	return res
}

func main() {
	tree := &TreeNode{
		Val: 3,
		Left: &TreeNode{
			Val: 9,
		},
		Right: &TreeNode{
			Val: 20,
			Left: &TreeNode{
				Val: 15,
			},
			Right: &TreeNode{
				Val: 7,
			},
		},
	}
	fmt.Println(zigzagLevelOrder(tree))
}
// [[3] [20 9] [15 7]]
```

#### 92. 反转链表 II

[92. 反转链表 II - 力扣（LeetCode）](https://leetcode.cn/problems/reverse-linked-list-ii/description/)

```go
package main

import "fmt"

type ListNode struct {
	Val  int
	Next *ListNode
}

func reverseBetween(head *ListNode, left int, right int) *ListNode {
	if head == nil {
		return head
	}
	// 创建 dummy 节点
	dummy := &ListNode{Next: head}
	p := dummy
	// 移动到反转的起点位置
	for i := 0; i < left-1; i++ {
		p = p.Next
	}
	a, b := p.Next, p.Next.Next
	// 反转链表
	for i := 0; i < right-left; i++ {
		t := b.Next
		b.Next = a
		a = b
		b = t
	}
	p.Next.Next = b
	p.Next = a
	return dummy.Next
}

func main() {
	head := &ListNode{
		Val: 5,
		Next: &ListNode{
			Val: 4,
			Next: &ListNode{
				Val: 3,
				Next: &ListNode{
					Val: 2,
					Next: &ListNode{
						Val:  1,
						Next: nil,
					},
				},
			},
		},
	}
	head = reverseBetween(head, 2, 4)
	for head != nil {
		fmt.Printf("%d ", head.Val)
		head = head.Next
	}
}
// 5 2 3 4 1
```

#### 54. 螺旋矩阵

[54. 螺旋矩阵 - 力扣（LeetCode）](https://leetcode.cn/problems/spiral-matrix/description/)

```go
package main

import "fmt"

type Solution struct {
	dx []int
	dy []int
}

func spiralOrder(matrix [][]int) []int {
	s := Solution{dx: []int{0, 1, 0, -1}, dy: []int{1, 0, -1, 0}}
	m, n := len(matrix), len(matrix[0])
	st := make([][]bool, m)
	for i := 0; i < m; i++ {
		st[i] = make([]bool, n)
	}
	res := make([]int, m*n)
	x, y, d := 0, 0, 0 // 初始位置及方向
	for i := 0; i < m*n; i++ {
		res[i] = matrix[x][y]
		st[x][y] = true
		a, b := x+s.dx[d], y+s.dy[d]
		if a < 0 || a >= m || b < 0 || b >= n || st[a][b] {
			d = (d + 1) % 4 // 改变方向
			a, b = x+s.dx[d], y+s.dy[d]
		}
		x, y = a, b
	}
	return res
}

func main() {
	res := spiralOrder([][]int{
		{1, 2, 3},
		{4, 5, 6},
		{7, 8, 9},
	})
	fmt.Println(res)
}
// [1 2 3 6 9 8 7 4 5]
```

#### 23. 合并 K 个升序链表

[23. 合并 K 个升序链表 - 力扣（LeetCode）](https://leetcode.cn/problems/merge-k-sorted-lists/description/)

```go
package main

import (
	"container/heap"
	"fmt"
)

type ListNode struct {
	Val  int
	Next *ListNode
}

type hp []*ListNode

func (h hp) Len() int           { return len(h) }
func (h hp) Less(i, j int) bool { return h[i].Val < h[j].Val } // 最小堆
func (h hp) Swap(i, j int)      { h[i], h[j] = h[j], h[i] }
func (h *hp) Push(v any)        { *h = append(*h, v.(*ListNode)) }
func (h *hp) Pop() any          { a := *h; v := a[len(a)-1]; *h = a[:len(a)-1]; return v }

func mergeKLists(lists []*ListNode) *ListNode {
	h := hp{}
	for _, head := range lists {
		if head != nil {
			h = append(h, head)
		}
	}
	heap.Init(&h) // 堆化

	dummy := &ListNode{} // 哨兵节点，作为合并后链表头节点的前一个节点
	cur := dummy
	for len(h) > 0 {
		node := heap.Pop(&h).(*ListNode)
		if node.Next != nil {
			heap.Push(&h, node.Next)
		}
		cur.Next = node
		cur = cur.Next
	}
	return dummy.Next
}

func main() {
	lists := []*ListNode{
		{Val: 1, Next: &ListNode{Val: 4, Next: &ListNode{Val: 5}}},
		{Val: 1, Next: &ListNode{Val: 3, Next: &ListNode{Val: 4}}},
		{Val: 2, Next: &ListNode{Val: 6, Next: nil}},
	}
	res := mergeKLists(lists)
	for res != nil {
		fmt.Printf("%d ", res.Val)
		res = res.Next
	}
}
// 1 1 2 3 4 4 5 6
```

#### 160. 相交链表

[160. 相交链表 - 力扣（LeetCode）](https://leetcode.cn/problems/intersection-of-two-linked-lists/description/)

```go
package main

import "fmt"

type ListNode struct {
	Val  int
	Next *ListNode
}

func getIntersectionNode(headA, headB *ListNode) *ListNode {
	a, b := headA, headB
	for a != b {
		if a != nil {
			a = a.Next
		} else {
			a = headB
		}
		if b != nil {
			b = b.Next
		} else {
			b = headA
		}
	}
	return a
}

func main() {
	a := &ListNode{
		Val: 4,
		Next: &ListNode{
			Val: 1,
			Next: &ListNode{
				Val:  8,
				Next: nil,
			},
		},
	}
	b := &ListNode{Val: 3}
	b.Next = a.Next.Next
	fmt.Println(getIntersectionNode(a, b).Val)
}
// 8
```

#### 300. 最长递增子序列

[300. 最长递增子序列 - 力扣（LeetCode）](https://leetcode.cn/problems/longest-increasing-subsequence/submissions/524569158/)

```go
package main

import "fmt"

func lengthOfLIS(nums []int) int {
	dp := make([]int, len(nums))
	for i := range dp {
		dp[i] = 1
	}
	res := 0
	for i := 0; i < len(nums); i++ {
		for j := 0; j < i; j++ {
			if nums[j] < nums[i] {
				dp[i] = max(dp[i], dp[j]+1)
			}
		}
		res = max(res, dp[i])
	}
	return res
}

func main() {
	fmt.Println(lengthOfLIS([]int{0, 1, 0, 3, 2, 3}))
}
// 4
```

#### 415. 字符串相加

[415. 字符串相加 - 力扣（LeetCode）](https://leetcode.cn/problems/add-strings/description/)

```go
package main

import (
	"fmt"
	"strconv"
)

func addStrings(num1 string, num2 string) string {
	var A, B []int

	for i := len(num1) - 1; i >= 0; i-- {
		A = append(A, int(num1[i]-'0'))
	}
	for i := len(num2) - 1; i >= 0; i-- {
		B = append(B, int(num2[i]-'0'))
	}

	C := add(A, B)

	var res string
	for i := len(C) - 1; i >= 0; i-- {
		res += strconv.Itoa(C[i])
	}
	return res
}

func add(A []int, B []int) []int {
	var C []int
	t := 0

	for i := 0; i < len(A) || i < len(B) || t > 0; i++ {
		if i < len(A) {
			t += A[i]
		}
		if i < len(B) {
			t += B[i]
		}
		C = append(C, t%10)
		t /= 10
	}
	return C
}

func main() {
	fmt.Println(addStrings("123", "456"))
}
// 579
```

#### 143. 重排链表

[143. 重排链表 - 力扣（LeetCode）](https://leetcode.cn/problems/reorder-list/description/)

```go
package main

import "fmt"

type ListNode struct {
	Val  int
	Next *ListNode
}

func reorderList(head *ListNode) {
	if head == nil {
		return
	}
	mid := findMid(head)
	l1, l2 := head, mid.Next
	mid.Next = nil
	l2 = reverseList(l2)
	merge(l1, l2)
}

func findMid(head *ListNode) *ListNode {
	slow, fast := head, head
	for fast != nil && fast.Next != nil {
		slow = slow.Next
		fast = fast.Next.Next
	}
	return slow
}

func reverseList(head *ListNode) *ListNode {
	if head == nil {
		return head
	}
	a, b := head, head.Next
	for b != nil {
		t := b.Next
		b.Next = a
		a = b
		b = t
	}
	head.Next = nil
	return a
}

func merge(l1 *ListNode, l2 *ListNode) {
	for l1 != nil && l2 != nil {
		t1 := l1.Next
		t2 := l2.Next
		l1.Next = l2
		l1 = t1
		l2.Next = l1
		l2 = t2
	}
}

func main() {
	head := &ListNode{Val: 1, Next: &ListNode{Val: 2, Next: &ListNode{Val: 3, Next: &ListNode{Val: 4, Next: nil}}}}
	reorderList(head)
	for head != nil {
		fmt.Printf("%d ", head.Val)
		head = head.Next
	}
}
// 1 4 2 3
```

#### 42. 接雨水

[42. 接雨水 - 力扣（LeetCode）](https://leetcode.cn/problems/trapping-rain-water/description/)

```go
package main

import "fmt"

func trap(height []int) int {
	lh, rh, res, l, r := 0, 0, 0, 0, len(height)-1
	for l < r {
		lh = max(lh, height[l])
		rh = max(rh, height[r])
		if lh < rh {
			res += lh - height[l]
			l++
		} else {
			res += rh - height[r]
			r--
		}
	}
	return res
}

func main() {
	fmt.Println(trap([]int{4, 2, 0, 3, 2, 5}))
}
// 9
```

#### 142. 环形链表 II

[142. 环形链表 II - 力扣（LeetCode）](https://leetcode.cn/problems/linked-list-cycle-ii/)

```go
package main

import "fmt"

type ListNode struct {
	Val  int
	Next *ListNode
}

func detectCycle(head *ListNode) *ListNode {
	slow, fast := head, head
	for fast != nil && fast.Next != nil {
		fast = fast.Next.Next
		slow = slow.Next
		if slow == fast {
			break
		}
	}
	if fast == nil || fast.Next == nil {
		return nil
	}
	slow = head
	for slow != fast {
		slow = slow.Next
		fast = fast.Next
	}
	return slow
}

func main() {
	head := &ListNode{Val: 1, Next: &ListNode{Val: 2}}
	head.Next.Next = head
	fmt.Println(detectCycle(head).Val)
}
// 1
```

#### 56. 合并区间

[56. 合并区间 - 力扣（LeetCode）](https://leetcode.cn/problems/merge-intervals/description/)

```go
package main

import (
	"fmt"
	"sort"
)

func merge(intervals [][]int) (res [][]int) {
	sort.Slice(intervals, func(i, j int) bool { return intervals[i][0] < intervals[j][0] })
	for i := 0; i < len(intervals); i++ {
		if len(res) == 0 || res[len(res)-1][1] < intervals[i][0] {
			res = append(res, intervals[i])
		} else {
			res[len(res)-1][1] = max(res[len(res)-1][1], intervals[i][1])
		}
	}
	return
}

func main() {
	fmt.Println(merge([][]int{
		{1, 3},
		{2, 6},
		{8, 10},
		{15, 18},
	}))
}
// [[1 6] [8 10] [15 18]]
```

#### 124. 二叉树中的最大路径和

[124. 二叉树中的最大路径和 - 力扣（LeetCode）](https://leetcode.cn/problems/binary-tree-maximum-path-sum/description/)

```go
package main

import (
	"fmt"
	"math"
)

type TreeNode struct {
	Val   int
	Left  *TreeNode
	Right *TreeNode
}

var res int

func maxPathSum(root *TreeNode) int {
	res = math.MinInt
	dfs(root)
	return res
}

func dfs(root *TreeNode) int {
	if root == nil {
		return 0
	}
	l := max(0, dfs(root.Left))
	r := max(0, dfs(root.Right))
	res = max(res, l+r+root.Val)
	return max(l, r) + root.Val
}

func main() {
	root := &TreeNode{
		Val:   1,
		Left:  &TreeNode{Val: 2},
		Right: &TreeNode{Val: 3},
	}
	fmt.Println(maxPathSum(root))
}
// 6
```

#### 72. 编辑距离

[72. 编辑距离 - 力扣（LeetCode）](https://leetcode.cn/problems/edit-distance/)

```go
package main

import "fmt"

func minDistance(word1, word2 string) int {
	m, n := len(word1), len(word2)
	memo := make([][]int, m+1)
	for i := range memo {
		memo[i] = make([]int, n+1)
		for j := range memo[i] {
			memo[i][j] = -1
		}
	}
	return dp(word1, m-1, word2, n-1, memo)
}

func dp(word1 string, m int, word2 string, n int, memo [][]int) int {
	if m == -1 {
		return n + 1
	}
	if n == -1 {
		return m + 1
	}
	if memo[m][n] != -1 {
		return memo[m][n]
	}
	if word1[m] == word2[n] {
		memo[m][n] = dp(word1, m-1, word2, n-1, memo)
	} else {
		memo[m][n] = min(dp(word1, m-1, word2, n-1, memo)+1, min(dp(word1, m-1, word2, n, memo)+1, dp(word1, m, word2, n-1, memo)+1))
	}
	return memo[m][n]
}

func main() {
	fmt.Println(minDistance("horse", "ros"))
}
// 3
```

#### 19. 删除链表的倒数第 N 个结点

[19. 删除链表的倒数第 N 个结点 - 力扣（LeetCode）](https://leetcode.cn/problems/remove-nth-node-from-end-of-list/description/)

```go
package main

import "fmt"

type ListNode struct {
	Val  int
	Next *ListNode
}

func removeNthFromEnd(head *ListNode, n int) *ListNode {
	if head == nil {
		return nil
	}
	dummy := &ListNode{Next: head}
	p, q := dummy, dummy
	for i := 0; i < n+1; i++ {
		p = p.Next
	}
	for p != nil {
		p = p.Next
		q = q.Next
	}
	q.Next = q.Next.Next
	return dummy.Next
}

func main() {
	head := &ListNode{Val: 1, Next: &ListNode{Val: 2, Next: &ListNode{Val: 3, Next: &ListNode{Val: 4, Next: &ListNode{Val: 5, Next: nil}}}}}
	removeNthFromEnd(head, 2)
	for head != nil {
		fmt.Printf("%d ", head.Val)
		head = head.Next
	}
}
// 1 2 3 5
```

#### 93. 复原 IP 地址

[93. 复原 IP 地址 - 力扣（LeetCode）](https://leetcode.cn/problems/restore-ip-addresses/description/)

```go
package main

import (
	"fmt"
	"strconv"
	"strings"
)

var res []string

func restoreIpAddresses(s string) []string {
	res = []string{}
	dfs(s, 0, 0, "")
	return res
}

func dfs(s string, u int, k int, path string) {
	if u == len(s) {
		if k == 4 {
			path = strings.TrimSuffix(path, ".")
			res = append(res, path)
		}
		return
	}
	if k == 4 {
		return
	}

	t := 0
	for i := u; i < len(s); i++ {
		if i > u && s[u] == '0' {
			break
		}
		t = t*10 + int(s[i]-'0')
		if t > 255 {
			break
		}
		dfs(s, i+1, k+1, path+strconv.Itoa(t)+".")
	}
	return
}

func main() {
	fmt.Println(restoreIpAddresses("25525511135"))
}
// [255.255.11.135 255.255.111.35]
```

#### 1143. 最长公共子序列

[1143. 最长公共子序列 - 力扣（LeetCode）](https://leetcode.cn/problems/longest-common-subsequence/description/)

```go
package main

import (
	"fmt"
)

func longestCommonSubsequence(text1 string, text2 string) int {
	m, n := len(text1), len(text2)
	dp := make([][]int, m+1)
	for i := range dp {
		dp[i] = make([]int, n+1)
	}
	res := 0
	for i := 1; i <= m; i++ {
		for j := 1; j <= n; j++ {
			if text1[i-1] == text2[j-1] {
				dp[i][j] = dp[i-1][j-1] + 1
			} else {
				dp[i][j] = max(dp[i-1][j], dp[i][j-1])
			}
			res = max(res, dp[i][j])
		}
	}
	return res
}

func main() {
	fmt.Println(longestCommonSubsequence("abcde", "ace"))
}
// 3
```

#### 94. 二叉树的中序遍历

[94. 二叉树的中序遍历 - 力扣（LeetCode）](https://leetcode.cn/problems/binary-tree-inorder-traversal/description/)

```go
package main

import "fmt"

type TreeNode struct {
	Val         int
	Left, Right *TreeNode
}

func inorderTraversal(root *TreeNode) []int {
	var res []int
	var stack []*TreeNode

	for root != nil || len(stack) > 0 {
		for root != nil {
			stack = append(stack, root)
			root = root.Left
		}
		index := len(stack) - 1 // 栈顶
		root = stack[index]
		stack = stack[:index] // 出栈

		res = append(res, root.Val)
		root = root.Right
	}
	return res
}
func main() {
	root := &TreeNode{Val: 1, Left: nil, Right: &TreeNode{Val: 2, Left: &TreeNode{Val: 3}}}
	fmt.Println(inorderTraversal(root))
}
// 1 3 2
```

#### 82. 删除排序链表中的重复元素 II

[82. 删除排序链表中的重复元素 II - 力扣（LeetCode）](https://leetcode.cn/problems/remove-duplicates-from-sorted-list-ii/description/)

```go
package main

import "fmt"

type ListNode struct {
	Val  int
	Next *ListNode
}

func deleteDuplicates(head *ListNode) *ListNode {
	if head == nil {
		return head
	}
	dummy := &ListNode{Next: head}
	p := dummy
	for p.Next != nil {
		q := p.Next.Next
		for q != nil && q.Val == p.Next.Val {
			q = q.Next
		}
		if q == p.Next.Next {
			p = p.Next
		}
		p.Next = q
	}
	return dummy.Next
}

func main() {
	head := &ListNode{Val: 1, Next: &ListNode{Val: 2, Next: &ListNode{Val: 3, Next: &ListNode{Val: 3, Next: &ListNode{Val: 4, Next: &ListNode{Val: 4, Next: &ListNode{Val: 5, Next: nil}}}}}}}
	res := deleteDuplicates(head)
	for res != nil {
		fmt.Printf("%d ", res.Val)
		res = res.Next
	}
}
// 1 2 5
```

#### 199. 二叉树的右视图

[199. 二叉树的右视图 - 力扣（LeetCode）](https://leetcode.cn/problems/binary-tree-right-side-view/description/)

```go
package main

import "fmt"

type TreeNode struct {
	Val   int
	Left  *TreeNode
	Right *TreeNode
}

func rightSideView(root *TreeNode) []int {
	res := []int{}
	if root == nil {
		return res
	}
	q := []*TreeNode{root}
	for len(q) > 0 {
		l := len(q)
		for i := 0; i < l; i++ {
			node := q[0]
			q = q[1:]
			if node.Left != nil {
				q = append(q, node.Left)
			}
			if node.Right != nil {
				q = append(q, node.Right)
			}
			if i == l-1 {
				res = append(res, node.Val)
			}
		}
	}
	return res
}

func main() {
	tree := &TreeNode{
		Val: 3,
		Left: &TreeNode{
			Val: 9,
		},
		Right: &TreeNode{
			Val: 20,
			Left: &TreeNode{
				Val: 15,
			},
			Right: &TreeNode{
				Val: 7,
			},
		},
	}
	fmt.Println(rightSideView(tree))
}
// 3 20 7
```

#### 704. 二分查找

[704. 二分查找 - 力扣（LeetCode）](https://leetcode.cn/problems/binary-search/description/)

```go
package main

import "fmt"

func search(nums []int, target int) int {
	l, r := 0, len(nums)-1
	for l < r {
		mid := (l + r) / 2
		if nums[mid] >= target {
			r = mid
		} else {
			l = mid + 1
		}
	}
	if nums[r] != target {
		return -1
	}
	return r
}

func main() {
	fmt.Println(search([]int{-1, 0, 3, 5, 9, 12}, 9))
}
// 4
```

#### 232. 用栈实现队列

[232. 用栈实现队列 - 力扣（LeetCode）](https://leetcode.cn/problems/implement-queue-using-stacks/description/)

```go
package main

import "fmt"

type MyQueue struct {
	stk1 []int
	stk2 []int
}

func Constructor() MyQueue {
	return MyQueue{[]int{}, []int{}}
}

func (this *MyQueue) Push(x int) {
	this.stk1 = append(this.stk1, x)
}

func (this *MyQueue) Pop() int {
	this.move()
	ans := this.stk2[len(this.stk2)-1]
	this.stk2 = this.stk2[:len(this.stk2)-1]
	return ans
}

func (this *MyQueue) Peek() int {
	this.move()
	return this.stk2[len(this.stk2)-1]
}

func (this *MyQueue) Empty() bool {
	return len(this.stk1) == 0 && len(this.stk2) == 0
}

func (this *MyQueue) move() {
	if len(this.stk2) == 0 {
		for len(this.stk1) > 0 {
			this.stk2 = append(this.stk2, this.stk1[len(this.stk1)-1])
			this.stk1 = this.stk1[:len(this.stk1)-1]
		}
	}
}

func main() {
	obj := Constructor()
	obj.Push(2)
	obj.Push(3)
	fmt.Println(obj.Peek())
	obj.Pop()
	fmt.Println(obj.Peek())
}
// 2 3
```

#### 4. 寻找两个正序数组的中位数

[4. 寻找两个正序数组的中位数 - 力扣（LeetCode）](https://leetcode.cn/problems/median-of-two-sorted-arrays/)

```go
package main

import "fmt"

func findMedianSortedArrays(nums1 []int, nums2 []int) float64 {
	i, j, k, pre, cur := 0, 0, 0, 0, 0
	n1, n2 := len(nums1), len(nums2)
	m := n1 + n2
	mid := m / 2
	for k <= mid {
		pre = cur
		if i < n1 && j < n2 {
			if nums1[i] < nums2[j] {
				cur = nums1[i]
				i++
			} else {
				cur = nums2[j]
				j++
			}
		} else if i < n1 {
			cur = nums1[i]
			i++
		} else {
			cur = nums2[j]
			j++
		}
		k++
	}
	if m%2 == 0 {
		return float64(pre+cur) / 2
	}
	return float64(cur)
}

func main() {
	fmt.Println(findMedianSortedArrays([]int{1, 3}, []int{2}))
}
// 2
```

#### 148. 排序链表

[148. 排序链表 - 力扣（LeetCode）](https://leetcode.cn/problems/sort-list/description/)

```go
package main

import "fmt"

type ListNode struct {
	Val  int
	Next *ListNode
}

func sortList(head *ListNode) *ListNode {
	if head == nil || head.Next == nil {
		return head
	}
	slow, fast := head, head
	var preS *ListNode
	for fast != nil && fast.Next != nil {
		fast = fast.Next.Next
		preS = slow
		slow = slow.Next
	}
	preS.Next = nil
	l, r := sortList(head), sortList(slow)
	return merge(l, r)
}

func merge(l *ListNode, r *ListNode) *ListNode {
	dummy := &ListNode{}
	p := dummy
	for l != nil && r != nil {
		if l.Val < r.Val {
			p.Next = l
			p = p.Next
			l = l.Next
		} else {
			p.Next = r
			p = p.Next
			r = r.Next
		}
	}
	if l != nil {
		p.Next = l
	}
	if r != nil {
		p.Next = r
	}
	return dummy.Next
}

func main() {
	head := &ListNode{Val: 4, Next: &ListNode{Val: 2, Next: &ListNode{Val: 1, Next: &ListNode{Val: 3, Next: nil}}}}
	res := sortList(head)
	for res != nil {
		fmt.Printf("%d ", res.Val)
		res = res.Next
	}
}
// 1 2 3 4
```

#### 69. x 的平方根

[69. x 的平方根 - 力扣（LeetCode）](https://leetcode.cn/problems/sqrtx/description/)

```go
package main

import "fmt"

func mySqrt(x int) int {
	l, r := 0, x
	for l < r {
		mid := (l + r + 1) / 2
		if x >= mid*mid {
			l = mid
		} else {
			r = mid - 1
		}
	}
	return r
}

func main() {
	fmt.Println(mySqrt(4))
}
// 2
```

#### 8. 字符串转换整数

[8. 字符串转换整数 (atoi) - 力扣（LeetCode）](https://leetcode.cn/problems/string-to-integer-atoi/description/)

```go
// 简易版本 未截断i32
func myAtoi(s string) int {
	res, flag, i := 0, 1, 0
	for s[i] == ' ' {
		i ++
	}
	if s[i] == '-' {
		flag = -1
		i ++
	} else if s[i] == '+' {
		i ++
	}
	for i < len(s) && s[i] >= '0' && s[i] <= '9' {
		res = res * 10 + int(s[i] - '0')
		i ++
	}
	return res * flag
}
// AC 版本
func myAtoi(s string) int {
    res, flag, i := 0, 1, 0
    for i < len(s) && s[i] == ' ' {
        i ++
    }
    if i < len(s) && s[i] == '-' {
        flag = -1
        i ++
    } else if i < len(s) && s[i] == '+' {
        i ++
    }
    for i < len(s) && s[i] >= '0' && s[i] <= '9' {
        if res > (1<<31 - 1 - int(s[i]-'0')) / 10 {
            if flag == -1 {
                return -1 << 31
            }
            return 1<<31 - 1
        }
        res = res * 10 + int(s[i] - '0')
        i ++
    }
    return res * flag
}
```

#### 31. 下一个排列

[31. 下一个排列 - 力扣（LeetCode）](https://leetcode.cn/problems/next-permutation/description/)

```go
package main

import (
	"fmt"
	"slices"
)

func nextPermutation(nums []int) {
	n := len(nums) - 1
	for n > 0 && nums[n] <= nums[n-1] {
		n--
	}
	if n == 0 {
		slices.Reverse(nums)
	} else {
		t := n
		for t < len(nums) && nums[t] > nums[n-1] {
			t++
		}
		nums[n-1], nums[t-1] = nums[t-1], nums[n-1]
		slices.Reverse(nums[n:])
	}
}

func main() {
	s := []int{1, 2, 3}
	nextPermutation(s)
	fmt.Println(s)
}
// 1 3 2
```

#### 22. 括号生成

[22. 括号生成 - 力扣（LeetCode）](https://leetcode.cn/problems/generate-parentheses/description/)

```go
package main

import "fmt"

var (
	result []string
	path   string
)

func generateParenthesis(n int) []string {
	result = make([]string, 0)
	path = ""
	dfs(n, n)
	return result
}

func dfs(l, r int) {
	if l < 0 || r < 0 || l > r {
		return
	}
	if l == 0 && r == 0 {
		result = append(result, path)
		return
	}

	path = path + "("
	dfs(l-1, r)
	path = path[:len(path)-1]

	path = path + ")"
	dfs(l, r-1)
	path = path[:len(path)-1]
}

func main() {
	fmt.Println(generateParenthesis(2))
}
// [(()) ()()]
```

#### 2. 两数相加

[2. 两数相加 - 力扣（LeetCode）](https://leetcode.cn/problems/add-two-numbers/description/)

```go
package main

import "fmt"

type ListNode struct {
	Val  int
	Next *ListNode
}

func addTwoNumbers(l1 *ListNode, l2 *ListNode) *ListNode {
	dummy := &ListNode{}
	p := dummy
	t := 0
	for t != 0 || l1 != nil || l2 != nil {
		if l1 != nil {
			t += l1.Val
			l1 = l1.Next
		}
		if l2 != nil {
			t += l2.Val
			l2 = l2.Next
		}
		p.Next = &ListNode{Val: t % 10}
		p = p.Next
		t /= 10
	}
	return dummy.Next
}

func main() {
	l1 := &ListNode{1, &ListNode{3, nil}}
	l2 := &ListNode{2, &ListNode{3, nil}}
	res := addTwoNumbers(l1, l2)
	for res != nil {
		fmt.Printf("%d", res.Val)
		res = res.Next
	}
}
// 36
```

#### 70. 爬楼梯

[70. 爬楼梯 - 力扣（LeetCode）](https://leetcode.cn/problems/climbing-stairs/description/)

```go
package main

import "fmt"

var memo []int

func climbStairs(n int) int {
	memo = make([]int, n+1)
	for i := range memo {
		memo[i] = -1
	}
	return dp(n)
}

func dp(n int) int {
	if n == 1 || n == 2 {
		return n
	}
	if memo[n] != -1 {
		return memo[n]
	}
	memo[n] = dp(n-1) + dp(n-2)
	return memo[n]
}

func main() {
	fmt.Println(climbStairs(3))
}
// 3
```

#### 165. 比较版本号

[165. 比较版本号 - 力扣（LeetCode）](https://leetcode.cn/problems/compare-version-numbers/)

```go
package main

import (
	"fmt"
	"strconv"
	"strings"
)

func compareVersion(version1 string, version2 string) int {
	s1 := strings.Split(version1, ".")
	s2 := strings.Split(version2, ".")

	for i := 0; i < len(s1) || i < len(s2); i++ {
		v1, v2 := 0, 0
		if i < len(s1) {
			v1, _ = strconv.Atoi(s1[i])
		}
		if i < len(s2) {
			v2, _ = strconv.Atoi(s2[i])
		}
		if v1 > v2 {
			return 1
		}
		if v1 < v2 {
			return -1
		}
	}
	return 0
}

func main() {
	fmt.Println(compareVersion("1.1", "0.1"))
}
// 1
```

#### 239. 滑动窗口最大值

[239. 滑动窗口最大值 - 力扣（LeetCode）](https://leetcode.cn/problems/sliding-window-maximum/)

```go
package main

import (
	"fmt"
)

func maxSlidingWindow(nums []int, k int) []int {
	ans := make([]int, 0, len(nums)-k+1) // 预先分配好空间
	q := []int{}
	for i, x := range nums {
		for len(q) > 0 && nums[q[len(q)-1]] <= x {
			q = q[:len(q)-1] // 维护 q 的单调性
		}
		q = append(q, i) // 入队
		if i-q[0] >= k { // 队首已经离开窗口了
			q = q[1:]
		}
		if i >= k-1 {
			ans = append(ans, nums[q[0]])
		}
	}
	return ans
}

func main() {
	fmt.Println(maxSlidingWindow([]int{1, 3, -1, -3, 5, 3, 6, 7}, 3))
}
// 3 3 5 5 6 7
```

#### 41. 缺失的第一个正数

[41. 缺失的第一个正数 - 力扣（LeetCode）](https://leetcode.cn/problems/first-missing-positive/)

```go
package main

import (
	"fmt"
)

func firstMissingPositive(nums []int) int {
	n := len(nums)
	for i := 0; i < n; i++ {
		for nums[i] > 0 && nums[i] <= n && nums[nums[i]-1] != nums[i] {
			nums[nums[i]-1], nums[i] = nums[i], nums[nums[i]-1]
		}
	}
	for i := 0; i < n; i++ {
		if nums[i] != i+1 {
			return i + 1
		}
	}
	return n + 1
}

func main() {
	fmt.Println(firstMissingPositive([]int{3, 4, -1, 1}))
}
// 2
```

#### 322. 零钱兑换

[322. 零钱兑换 - 力扣（LeetCode）](https://leetcode.cn/problems/coin-change/description/)

```go
package main

import (
	"fmt"
)

func coinChange(coins []int, amount int) int {
	dp := make([]int, amount+1)
	for i := range dp {
		dp[i] = amount + 1
	}
	dp[0] = 0
	for i := 1; i < len(dp); i++ {
		for _, coin := range coins {
			if i >= coin {
				dp[i] = min(dp[i], dp[i-coin]+1)
			}
		}
	}
	if dp[amount] == amount+1 {
		return -1
	}
	return dp[amount]
}

func main() {
	fmt.Println(coinChange([]int{1, 2, 5}, 11))
}
// 3
```

#### 76. 最小覆盖子串

[76. 最小覆盖子串 - 力扣（LeetCode）](https://leetcode.cn/problems/minimum-window-substring/description/)

```go
package main

import (
	"fmt"
	"math"
)

func minWindow(s string, t string) string {
	win, need := map[rune]int{}, map[rune]int{}
	for _, c := range t {
		need[c]++
	}
	l, r, st, vl, length := 0, 0, 0, 0, math.MaxInt
	for r < len(s) {
		c := rune(s[r])
		r++
		if _, ok := need[c]; ok {
			win[c]++
			if win[c] == need[c] {
				vl++
			}
		}
		for vl == len(need) {
			if r-l < length {
				st = l
				length = r - l
			}
			d := rune(s[l])
			l++
			if _, ok := need[d]; ok {
				if win[d] == need[d] {
					vl--
				}
				win[d]--
			}
		}
	}

	if length == math.MaxInt {
		return ""
	}
	return s[st : st+length]
}

func main() {
	fmt.Println(minWindow("ADOBECODEBANC", "ABC"))
}
// BANC
```

#### 78. 子集

[78. 子集 - 力扣（LeetCode）](https://leetcode.cn/problems/subsets/description/)

```go
package main

import (
	"fmt"
)

var (
	res  [][]int
	path []int
)

func subsets(nums []int) [][]int {
	res = [][]int{}
	dfs(nums, 0)
	return res
}

func dfs(nums []int, u int) {
	if u == len(nums) {
		temp := make([]int, len(path))
		copy(temp, path)
		res = append(res, temp)
		return
	}

	dfs(nums, u+1)
	path = append(path, nums[u])
	dfs(nums, u+1)
	path = path[:len(path)-1]
}

func main() {
	fmt.Println(subsets([]int{1, 2, 3}))
}
// [[] [3] [2] [2 3] [1] [1 3] [1 2] [1 2 3]]
```

#### 105. 从前序与中序遍历序列构造二叉树

[105. 从前序与中序遍历序列构造二叉树 - 力扣（LeetCode）](https://leetcode.cn/problems/construct-binary-tree-from-preorder-and-inorder-traversal/description/)

```go
func buildTree(preorder []int, inorder []int) *TreeNode {
	return dfs(preorder, 0, len(preorder)-1, inorder, 0, len(inorder)-1)
}

func dfs(preorder []int, pst int, ped int, inorder []int, ist int, ied int) *TreeNode {
	if pst > ped {
		return nil
	}
	val := preorder[pst]
	var idx int
	root := &TreeNode{Val: val}
	for i := ist; i <= ied; i++ {
		if inorder[i] == val {
			idx = i
			break
		}
	}
	root.Left = dfs(preorder, pst+1, pst+idx-ist, inorder, ist, idx-1)
	root.Right = dfs(preorder, pst+idx-ist+1, ped, inorder, idx+1, ied)
	return root
}
```

#### 155. 最小栈

[155. 最小栈 - 力扣（LeetCode）](https://leetcode.cn/problems/min-stack/description/)

```go
package main

import (
	"fmt"
	"math"
)

type MinStack struct {
	stk, minS []int
}

func Constructor() MinStack {
	return MinStack{stk: []int{}, minS: []int{math.MaxInt}}
}

func (s *MinStack) Push(val int) {
	s.stk = append(s.stk, val)
	s.minS = append(s.minS, min(val, s.minS[len(s.minS)-1]))
}

func (s *MinStack) Pop() {
	s.stk = s.stk[:len(s.stk)-1]
	s.minS = s.minS[:len(s.minS)-1]
}

func (s *MinStack) Top() int {
	return s.stk[len(s.stk)-1]
}

func (s *MinStack) GetMin() int {
	return s.minS[len(s.minS)-1]
}

func main() {
	obj := Constructor()
	obj.Push(11)
	obj.Push(2)
	fmt.Println(obj.GetMin())
}
// 2
```

#### 32. 最长有效括号

[32. 最长有效括号 - 力扣（LeetCode）](https://leetcode.cn/problems/longest-valid-parentheses/description/)

```go
package main

import "fmt"

func longestValidParentheses(s string) int {
	var stack []int
	dp := make([]int, len(s)+1)
	max := 0
	for i := 0; i < len(s); i++ {
		if string(s[i]) == "(" {
			stack = append(stack, i)
		} else {
			if len(stack) == 0 {
				continue
			} else {
				lidx := stack[len(stack)-1]
				stack = stack[:len(stack)-1]
				dp[i+1] = i + 1 - lidx + dp[lidx]
			}
		}
		if dp[i+1] > max {
			max = dp[i+1]
		}
	}
	return max
}

func main() {
	fmt.Println(longestValidParentheses(")()())"))
}
// 4
```

#### 43. 字符串相乘

[43. 字符串相乘 - 力扣（LeetCode）](https://leetcode.cn/problems/multiply-strings/description/)

```go
package main

import (
	"fmt"
	"strconv"
)

func multiply(num1 string, num2 string) string {
	if num1 == "0" || num2 == "0" {
		return "0"
	}
	m, n := len(num1), len(num2)
	res := make([]int, m+n)

	for i := m - 1; i >= 0; i-- {
		for j := n - 1; j >= 0; j-- {
			mul := int(num1[i]-'0') * int(num2[j]-'0')
			r1, r2 := i+j, i+j+1
			sum := mul + res[r2]
			res[r1] += sum / 10
			res[r2] = sum % 10
		}
	}

	for i := 0; i < len(res); i++ {
		if res[i] != 0 {
			res = res[i:]
			break
		}
	}
	var result string
	for _, v := range res {
		result += strconv.Itoa(v)
	}
	return result
}

func main() {
	fmt.Println(multiply("12", "13"))
}
// 156
```

#### 151. 反转字符串中的单词

[151. 反转字符串中的单词 - 力扣（LeetCode）](https://leetcode.cn/problems/reverse-words-in-a-string/description/)

```go
package main

import (
	"fmt"
	"strings"
)

func reverseWords(s string) string {
	words := strings.Fields(s)
	for i := 0; i < len(words)/2; i++ {
		words[i], words[len(words)-1-i] = words[len(words)-1-i], words[i]
	}
	res := strings.Join(words, " ")
	return res
}

func main() {
	fmt.Println(reverseWords("the sky is blue"))
}
// blue is sky the
```

#### 129. 求根节点到叶节点数字之和

[129. 求根节点到叶节点数字之和 - 力扣（LeetCode）](https://leetcode.cn/problems/sum-root-to-leaf-numbers/)

```go
package main

import "fmt"

type TreeNode struct {
	Val         int
	Left, Right *TreeNode
}

var res int

func sumNumbers(root *TreeNode) int {
	res = 0
	dfs(root, 0)
	return res
}

func dfs(root *TreeNode, sum int) {
	if root == nil {
		return
	}
	sum = sum*10 + root.Val
	if root.Left == nil && root.Right == nil {
		res += sum
		return
	}
	dfs(root.Left, sum)
	dfs(root.Right, sum)
}

func main() {
	root := &TreeNode{
		Val:   1,
		Left:  &TreeNode{Val: 2},
		Right: &TreeNode{Val: 3},
	}
	fmt.Println(sumNumbers(root))
}
// 25
```

#### 104. 二叉树的最大深度

[104. 二叉树的最大深度 - 力扣（LeetCode）](https://leetcode.cn/problems/maximum-depth-of-binary-tree/description/)

```go
package main

import "fmt"

type TreeNode struct {
	Val         int
	Left, Right *TreeNode
}

func maxDepth(root *TreeNode) int {
	if root == nil {
		return 0
	}
	l := maxDepth(root.Left)
	r := maxDepth(root.Right)
	return max(l, r) + 1
}

func main() {
	root := &TreeNode{
		Val:   1,
		Left:  &TreeNode{Val: 2},
		Right: &TreeNode{Val: 3},
	}
	fmt.Println(maxDepth(root))
}
// 2
```

#### 101. 对称二叉树

[101. 对称二叉树 - 力扣（LeetCode）](https://leetcode.cn/problems/symmetric-tree/description/)

```go
package main

import "fmt"

type TreeNode struct {
	Val         int
	Left, Right *TreeNode
}

func isSymmetric(root *TreeNode) bool {
	return dfs(root.Left, root.Right)
}

func dfs(left, right *TreeNode) bool {
	if left == nil && right == nil {
		return true
	}
	if left == nil || right == nil {
		return false
	}
	if left.Val != right.Val {
		return false
	}
	return dfs(left.Left, right.Right) && dfs(left.Right, right.Left)
}

func main() {
	root := &TreeNode{
		Val:   1,
		Left:  &TreeNode{Val: 2},
		Right: &TreeNode{Val: 2},
	}
	fmt.Println(isSymmetric(root))
}
// true
```

#### 144. 二叉树的前序遍历

[144. 二叉树的前序遍历 - 力扣（LeetCode）](https://leetcode.cn/problems/binary-tree-preorder-traversal/description/)

```go
package main

import "fmt"

type TreeNode struct {
	Val         int
	Left, Right *TreeNode
}

func preorderTraversal(root *TreeNode) []int {
	var res []int
	var stack []*TreeNode

	for root != nil || len(stack) > 0 {
		for root != nil {
			stack = append(stack, root)
			res = append(res, root.Val)
			root = root.Left
		}
		index := len(stack) - 1 // 栈顶
		root = stack[index]
		stack = stack[:index] // 出栈
		root = root.Right
	}
	return res
}

func main() {
	root := &TreeNode{Val: 1, Left: nil, Right: &TreeNode{Val: 2, Left: &TreeNode{Val: 3}}}
	fmt.Println(preorderTraversal(root))
}
// 1 2 3
```

#### 543. 二叉树的直径

[543. 二叉树的直径 - 力扣（LeetCode）](https://leetcode.cn/problems/diameter-of-binary-tree/description/)

```go
package main

import "fmt"

type TreeNode struct {
	Val         int
	Left, Right *TreeNode
}

var res int

func diameterOfBinaryTree(root *TreeNode) int {
	res = 0
	dfs(root)
	return res
}

func dfs(root *TreeNode) {
	if root == nil {
		return
	}
	l := maxD(root.Left)
	r := maxD(root.Right)
	res = max(res, l+r)
	dfs(root.Left)
	dfs(root.Right)
}

func maxD(root *TreeNode) int {
	if root == nil {
		return 0
	}
	l := maxD(root.Left)
	r := maxD(root.Right)
	return max(l, r) + 1
}

func main() {
	root := &TreeNode{Val: 1, Left: nil, Right: &TreeNode{Val: 2, Left: &TreeNode{Val: 3}}}
	fmt.Println(diameterOfBinaryTree(root))
}
// 2
```

#### 98. 验证二叉搜索树

[98. 验证二叉搜索树 - 力扣（LeetCode）](https://leetcode.cn/problems/validate-binary-search-tree/description/)

```go
package main

import "fmt"

type TreeNode struct {
	Val         int
	Left, Right *TreeNode
}

func isValidBST(root *TreeNode) bool {
	return dfs(root, nil, nil)
}

func dfs(root, min, max *TreeNode) bool {
	if root == nil {
		return true
	}
	if max != nil && max.Val <= root.Val || min != nil && min.Val >= root.Val {
		return false
	}
	return dfs(root.Left, min, root) && dfs(root.Right, root, max)
}

func main() {
	root := &TreeNode{Val: 2, Left: &TreeNode{Val: 1}, Right: &TreeNode{Val: 3}}
	fmt.Println(isValidBST(root))
}
// true
```

#### 470. 用 Rand7() 实现 Rand10()

[470. 用 Rand7() 实现 Rand10() - 力扣（LeetCode）](https://leetcode.cn/problems/implement-rand10-using-rand7/description/)

```go
func rand10() int {
    t := (rand7() - 1) * 7 + rand7()
    if t > 40 {
        return rand10()
    }
    return (t - 1) % 10 + 1
}
```

#### 34. 在排序数组中查找元素的第一个和最后一个位置

[34. 在排序数组中查找元素的第一个和最后一个位置 - 力扣（LeetCode）](https://leetcode.cn/problems/find-first-and-last-position-of-element-in-sorted-array/description/)

```go
package main

import "fmt"

func searchRange(nums []int, target int) []int {
	if len(nums) == 0 {
		return []int{-1, -1}
	}
	l, r := 0, len(nums)-1
	for l < r {
		mid := (l + r + 1) >> 1
		if nums[mid] <= target {
			l = mid
		} else {
			r = mid - 1
		}
	}
	var i, j int
	if nums[r] == target {
		i = r
	} else {
		return []int{-1, -1}
	}
	l, r = 0, len(nums)-1
	for l < r {
		mid := (l + r) >> 1
		if nums[mid] >= target {
			r = mid
		} else {
			l = mid + 1
		}
	}
	if nums[r] == target {
		j = r
	} else {
		return []int{-1, -1}
	}
	return []int{j, i}
}

func main() {
	fmt.Println(searchRange([]int{5, 7, 7, 8, 8, 10}, 8))
}
// 3 4
```

#### 64. 最小路径和

[34. 在排序数组中查找元素的第一个和最后一个位置 - 力扣（LeetCode）](https://leetcode.cn/problems/find-first-and-last-position-of-element-in-sorted-array/description/)

```go
package main

import (
	"fmt"
	"math"
)

func minPathSum(grid [][]int) int {
	m, n := len(grid), len(grid[0])
	memo := make([][]int, m)
	for i := range memo {
		memo[i] = make([]int, n)
		for j := range memo[i] {
			memo[i][j] = -1
		}
	}
	return dp(grid, m-1, n-1, memo)
}

func dp(grid [][]int, m, n int, memo [][]int) int {
	if m == 0 && n == 0 {
		return grid[0][0]
	}
	if m < 0 || n < 0 {
		return math.MaxInt
	}
	if memo[m][n] != -1 {
		return memo[m][n]
	}
	memo[m][n] = min(dp(grid, m-1, n, memo), dp(grid, m, n-1, memo)) + grid[m][n]
	return memo[m][n]
}

func main() {
	fmt.Println(minPathSum([][]int{
		{1, 3, 1},
		{1, 5, 1},
		{4, 2, 1},
	}))
}
// 7
```

#### 48. 旋转图像

[48. 旋转图像 - 力扣（LeetCode）](https://leetcode.cn/problems/rotate-image/description/)

```go
package main

import "fmt"

func rotate(matrix [][]int) {
	m := len(matrix)
	for i, j := 0, m-1; i < j; i, j = i+1, j-1 {
		matrix[i], matrix[j] = matrix[j], matrix[i]
	}
	for i := 0; i < m; i++ {
		for j := i + 1; j < m; j++ {
			matrix[i][j], matrix[j][i] = matrix[j][i], matrix[i][j]
		}
	}
}

func main() {
	m := [][]int{{1, 2, 3}, {4, 5, 6}, {7, 8, 9}}
	rotate(m)
	fmt.Println(m)
}
// [[7 4 1] [8 5 2] [9 6 3]]
```

#### 39. 组合总数

[39. 组合总和 - 力扣（LeetCode）](https://leetcode.cn/problems/combination-sum/description/)

```go
package main

import "fmt"

var (
	res  [][]int
	path []int
)

func combinationSum(candidates []int, target int) [][]int {
	res = [][]int{}
	path = []int{}
	dfs(candidates, target, 0)
	return res
}

func dfs(candidates []int, target int, u int) {
	if target == 0 {
		tmp := make([]int, len(path))
		copy(tmp, path)
		res = append(res, tmp)
		return
	} else if target < 0 {
		return
	}

	for i := u; i < len(candidates); i++ {
		path = append(path, candidates[i])
		dfs(candidates, target-candidates[i], i)
		path = path[:len(path)-1]
	}
}

func main() {
	fmt.Println(combinationSum([]int{2, 3, 6, 7}, 7))
}
// [[2 2 3] [7]]
```

#### 113. 路径之和 II

[113. 路径总和 II - 力扣（LeetCode）](https://leetcode.cn/problems/path-sum-ii/description/)

```go
package main

import "fmt"

type TreeNode struct {
	Val         int
	Left, Right *TreeNode
}

var res [][]int
var path []int

func pathSum(root *TreeNode, targetSum int) [][]int {
	res = [][]int{}
	path = []int{}
	dfs(root, targetSum)
	return res
}

func dfs(root *TreeNode, targetSum int) {
	if root == nil {
		return
	}
	path = append(path, root.Val)
	targetSum -= root.Val
	if root.Left == nil && root.Right == nil && targetSum == 0 {
		tmp := make([]int, len(path))
		copy(tmp, path)
		res = append(res, tmp)
	}
	if root.Left != nil {
		dfs(root.Left, targetSum)
	}
	if root.Right != nil {
		dfs(root.Right, targetSum)
	}
	path = path[:len(path)-1]
}

func main() {
	root := &TreeNode{
		Val:   3,
		Left:  &TreeNode{Val: 2},
		Right: &TreeNode{Val: 1},
	}
	fmt.Println(pathSum(root, 5))
}
// [3 2]
```

#### 394. 字符串解码

[394. 字符串解码 - 力扣（LeetCode）](https://leetcode.cn/problems/decode-string/description/)

```go
package main

import (
	"fmt"
	"strings"
)

func decodeString(s string) string {
	numStack, strStack, num, result := []int{}, []string{}, 0, ""
	for _, ch := range s {
		if ch >= '0' && ch <= '9' {
			num = num*10 + int(ch-'0')
		} else if ch == '[' {
			numStack, strStack, num, result = append(numStack, num), append(strStack, result), 0, ""
		} else if ch == ']' {
			result, num = strStack[len(strStack)-1]+strings.Repeat(result, numStack[len(numStack)-1]), 0
			strStack, numStack = strStack[:len(strStack)-1], numStack[:len(numStack)-1]
		} else {
			result += string(ch)
		}
	}
	return result
}

func main() {
	fmt.Println(decodeString("3[a]2[bc]"))
}
// aaabcbc
```

#### 240. 搜索二维矩阵

[240. 搜索二维矩阵 II - 力扣（LeetCode）](https://leetcode.cn/problems/search-a-2d-matrix-ii/description/)

```go
package main

import (
	"fmt"
)

func searchMatrix(matrix [][]int, target int) bool {
	i, j := 0, len(matrix[0])-1
	for i < len(matrix) && j >= 0 {
		if matrix[i][j] > target {
			j--
		} else if matrix[i][j] < target {
			i++
		} else {
			return true
		}
	}
	return false
}

func main() {
	fmt.Println(searchMatrix([][]int{{1, 2, 3}, {4, 5, 6}, {7, 8, 9}}, 5))
}
// true
```

#### 221. 最大正方形

[221. 最大正方形 - 力扣（LeetCode）](https://leetcode.cn/problems/maximal-square/)

```go
package main

import (
	"fmt"
)

func maximalSquare(matrix [][]byte) int {
	m, n, res := len(matrix), len(matrix[0]), 0
	dp := make([][]int, m+1)
	for i := range dp {
		dp[i] = make([]int, n+1)
	}
	for i := 1; i <= m; i++ {
		for j := 1; j <= n; j++ {
			if matrix[i-1][j-1] == '1' {
				dp[i][j] = min(dp[i-1][j-1], dp[i-1][j], dp[i][j-1]) + 1
				res = max(res, dp[i][j])
			}
		}
	}
	return res * res
}

func main() {
	fmt.Println(maximalSquare([][]byte{{'1', '1', '1'}, {'1', '1', '0'}, {'0', '0', '0'}}))
}
// 4
```

#### 162. 寻找峰值

[162. 寻找峰值 - 力扣（LeetCode）](https://leetcode.cn/problems/find-peak-element/description/)

```go
package main

import (
	"fmt"
)

func findPeakElement(nums []int) int {
	l, r := 0, len(nums)-1
	for l < r {
		mid := (l + r) / 2
		if nums[mid] >= nums[mid+1] {
			r = mid
		} else {
			l = mid + 1
		}
	}
	return r
}

func main() {
	fmt.Println(findPeakElement([]int{1, 2, 3, 1}))
}
// 2
```

#### 234. 回文链表

[234. 回文链表 - 力扣（LeetCode）](https://leetcode.cn/problems/palindrome-linked-list/description/)

```go
package main

import "fmt"

type ListNode struct {
	Val  int
	Next *ListNode
}

func isPalindrome(head *ListNode) bool {
	if head == nil {
		return true
	}
	slow, fast := head, head
	for fast != nil && fast.Next != nil {
		slow = slow.Next
		fast = fast.Next.Next
	}
	if fast != nil {
		slow = slow.Next
	}
	p, q := head, reverseList(slow)
	for q != nil {
		if q.Val != p.Val {
			return false
		}
		q = q.Next
		p = p.Next
	}
	return true
}

func reverseList(head *ListNode) *ListNode {
	if head == nil {
		return head
	}
	a, b := head, head.Next
	for b != nil {
		t := b.Next
		b.Next = a
		a = b
		b = t
	}
	head.Next = nil
	return a
}

func main() {
	head := &ListNode{1, &ListNode{2, &ListNode{2, &ListNode{1, nil}}}}
	fmt.Println(isPalindrome(head))
}
// true
```

#### 112. 路径总和

[112. 路径总和 - 力扣（LeetCode）](https://leetcode.cn/problems/path-sum/description/)

```go
package main

import "fmt"

type TreeNode struct {
	Val         int
	Left, Right *TreeNode
}

var res bool

func hasPathSum(root *TreeNode, targetSum int) bool {
	res = false
	dfs(root, targetSum)
	return res
}

func dfs(root *TreeNode, targetSum int) {
	if root == nil {
		return
	}
	targetSum -= root.Val
	if root.Left == nil && root.Right == nil && targetSum == 0 {
		res = true
		return
	}
	dfs(root.Left, targetSum)
	dfs(root.Right, targetSum)
}

func main() {
	root := &TreeNode{1, &TreeNode{2, nil, nil}, &TreeNode{4, nil, nil}}
	fmt.Println(hasPathSum(root, 3))
}
// true
```

#### 14. 最长公共前缀

[14. 最长公共前缀 - 力扣（LeetCode）](https://leetcode.cn/problems/longest-common-prefix/description/)

```go
package main  
  
import "fmt"  
  
func longestCommonPrefix(strs []string) string {  
    res := ""  
    for i := 0; i < len(strs[0]); i++ {  
       c := strs[0][i]  
       for _, str := range strs {  
          if i >= len(str) || str[i] != c {  
             return res  
          }  
       }       res += string(c)  
    }    return res  
}  
  
func main() {  
    fmt.Println(longestCommonPrefix([]string{"flower", "flow", "flight"}))  
}
// fl
```

#### 128. 最长连续序列

[128. 最长连续序列 - 力扣（LeetCode）](https://leetcode.cn/problems/longest-consecutive-sequence/description/)

```go
package main

import "fmt"

func longestConsecutive(nums []int) int {
	set, res := map[int]bool{}, 0
	for _, n := range nums {
		set[n] = true
	}
	for n := range set {
		if !set[n-1] {
			num := n
			sum := 1
			for set[num+1] {
				num++
				sum++
			}
			res = max(res, sum)
		}
	}
	return res
}

func main() {
	fmt.Println(longestConsecutive([]int{100, 4, 200, 1, 3, 2}))
}
// 4
```

#### 718. 最长重复子数组

[718. 最长重复子数组 - 力扣（LeetCode）](https://leetcode.cn/problems/maximum-length-of-repeated-subarray/description/)

```go
package main

import "fmt"

func findLength(nums1 []int, nums2 []int) int {
	m, n, res := len(nums1), len(nums2), 0
	dp := make([][]int, m+1)
	for i := range dp {
		dp[i] = make([]int, n+1)
	}
	for i := 0; i < m; i++ {
		for j := 0; j < n; j++ {
			if nums1[i] == nums2[j] {
				dp[i+1][j+1] = dp[i][j] + 1
			}
			res = max(res, dp[i+1][j+1])
		}
	}
	return res
}

func main() {
	fmt.Println(findLength([]int{1, 2, 3, 2, 1}, []int{3, 2, 1, 4, 7}))
}
// 3
```

#### 169. 多数元素

[169. 多数元素 - 力扣（LeetCode）](https://leetcode.cn/problems/majority-element/)

```go
package main

import "fmt"

func majorityElement(nums []int) int {
	cnt, tg := 0, 0
	for i := 0; i < len(nums); i++ {
		if cnt == 0 {
			tg = nums[i]
			cnt++
			continue
		}
		if nums[i] != tg {
			cnt--
		} else {
			cnt++
		}
	}
	return tg
}

func main() {
	fmt.Println(majorityElement([]int{3, 2, 3}))
}
// 3
```

#### 662. 二叉树最大宽度

[662. 二叉树最大宽度 - 力扣（LeetCode）](https://leetcode.cn/problems/maximum-width-of-binary-tree/)

```go
package main

import "fmt"

type TreeNode struct {
	Val         int
	Left, Right *TreeNode
}

type Node struct {
	Node *TreeNode
	Pos  int
}

func widthOfBinaryTree(root *TreeNode) int {
	res := 0
	if root == nil {
		return res
	}
	q := []*Node{{root, 1}}
	for len(q) > 0 {
		length := len(q)
		l, r := q[0].Pos, 0
		for i := 0; i < length; i++ {
			t := q[0]
			q = q[1:]
			t1 := t.Node
			r = t.Pos
			p := r - l + 1
			if t1.Left != nil {
				q = append(q, &Node{t1.Left, p * 2})
			}
			if t1.Right != nil {
				q = append(q, &Node{t1.Right, p*2 + 1})
			}
		}
		res = max(res, r-l+1)
	}
	return res
}

func main() {
	tree := &TreeNode{
		Val:   1,
		Left:  &TreeNode{Val: 3, Left: &TreeNode{Val: 2}},
		Right: &TreeNode{Val: 2},
	}
	fmt.Println(widthOfBinaryTree(tree))
}
// 2
```

#### 122. 买卖股票的最佳时机 II

[122. 买卖股票的最佳时机 II - 力扣（LeetCode）](https://leetcode.cn/problems/best-time-to-buy-and-sell-stock-ii/description/)

```go
package main

import "fmt"

func maxProfit(prices []int) int {
	dp := make([][]int, len(prices))
	for i := range dp {
		dp[i] = make([]int, 2)
	}

	dp[0][0] = 0
	dp[0][1] = -prices[0]

	for i := 1; i < len(prices); i++ {
		dp[i][0] = max(dp[i-1][0], dp[i-1][1]+prices[i])
		dp[i][1] = max(dp[i-1][1], dp[i-1][0]-prices[i])
	}

	return dp[len(prices)-1][0]
}

func main() {
	fmt.Println(maxProfit([]int{7, 1, 5, 3, 6, 4}))
}
// 7
```

#### 62. 不同路径

[62. 不同路径 - 力扣（LeetCode）](https://leetcode.cn/problems/unique-paths/description/)

```go
package main  
  
import "fmt"  
  
func uniquePaths(m int, n int) int {  
    dp := make([][]int, m+1)  
    for i := range dp {  
       dp[i] = make([]int, n+1)  
    }    if m == 1 || n == 1 {  
       return 1  
    }  
    dp[0][1], dp[1][0] = 1, 1  
    for i := 0; i < m; i++ {  
       for j := 0; j < n; j++ {  
          if i != 0 {  
             dp[i][j] += dp[i-1][j]  
          }          if j != 0 {  
             dp[i][j] += dp[i][j-1]  
          }       }    }    return dp[m-1][n-1]  
}  
  
func main() {  
    fmt.Println(uniquePaths(3, 7))  
}
// 28
```

#### 226. 翻转二叉树

[226. 翻转二叉树 - 力扣（LeetCode）](https://leetcode.cn/problems/invert-binary-tree/)

```go
package main

import "fmt"

type TreeNode struct {
	Val         int
	Left, Right *TreeNode
}

func invertTree(root *TreeNode) *TreeNode {
	if root == nil {
		return root
	}
	t := root.Left
	root.Left = root.Right
	root.Right = t
	invertTree(root.Left)
	invertTree(root.Right)
	return root
}

func main() {
	root := &TreeNode{
		Val:   2,
		Left:  &TreeNode{Val: 1},
		Right: &TreeNode{Val: 3},
	}
	invertTree(root)
	fmt.Printf("%d %d %d", root.Val, root.Left.Val, root.Right.Val)
}
// 2 3 1
```

#### 179. 最大数

[179. 最大数 - 力扣（LeetCode）](https://leetcode.cn/problems/largest-number/description/)

```go
package main

import (
	"fmt"
	"sort"
	"strconv"
	"strings"
)

func largestNumber(nums []int) string {
	sort.Slice(nums, func(i, j int) bool {
		a, b := nums[i], nums[j]
		strA, strB := strconv.Itoa(a), strconv.Itoa(b)
		return strA+strB >= strB+strA
	})
	var res string
	for _, num := range nums {
		res += strconv.Itoa(num)
	}
	if len(res) > 0 && res[0] == '0' {
		return "0"
	}
	return res
}

func main() {
	fmt.Println(largestNumber([]int{10, 2}))
}
// 210
```

#### 152. 乘积最大的子数组

[152. 乘积最大子数组 - 力扣（LeetCode）](https://leetcode.cn/problems/maximum-product-subarray/description/)

```go
package main

import (
	"fmt"
)

func maxProduct(nums []int) int {
	dp1, dp2 := make([]int, len(nums)), make([]int, len(nums))
	dp1[0], dp2[0] = nums[0], nums[0]
	res := nums[0]
	for i := 1; i < len(nums); i++ {
		dp1[i] = max(nums[i], dp1[i-1]*nums[i], dp2[i-1]*nums[i])
		dp2[i] = min(nums[i], dp1[i-1]*nums[i], dp2[i-1]*nums[i])
		res = max(res, dp1[i])
	}
	return res
}

func main() {
	fmt.Println(maxProduct([]int{2, 3, -2, 4}))
}
// 6
```

#### 83. 删除排序链表中重复元素

[83. 删除排序链表中的重复元素 - 力扣（LeetCode）](https://leetcode.cn/problems/remove-duplicates-from-sorted-list/description/)

```go
package main

import "fmt"

type ListNode struct {
	Val  int
	Next *ListNode
}

func deleteDuplicates(head *ListNode) *ListNode {
	if head == nil {
		return head
	}
	slow, fast := head, head.Next
	for fast != nil {
		if slow.Val != fast.Val {
			slow.Next = fast
			slow = slow.Next
		}
		fast = fast.Next
	}
	slow.Next = nil
	return head
}

func main() {
	head := &ListNode{Val: 1, Next: &ListNode{Val: 2, Next: &ListNode{Val: 2, Next: nil}}}
	deleteDuplicates(head)
	for head != nil {
		fmt.Printf("%d ", head.Val)
		head = head.Next
	}
}
// 2
```

#### 695. 岛屿的最大面积

[695. 岛屿的最大面积 - 力扣（LeetCode）](https://leetcode.cn/problems/max-area-of-island/description/)

```go
package main

import "fmt"

type Solution struct {
	dx, dy []int
	g      [][]int
}

func maxAreaOfIsland(grid [][]int) int {
	sol := Solution{
		dx: []int{0, 1, 0, -1},
		dy: []int{1, 0, -1, 0},
		g:  grid,
	}
	m, n, res := len(sol.g), len(sol.g[0]), 0
	for i := 0; i < m; i++ {
		for j := 0; j < n; j++ {
			if sol.g[i][j] == 1 {
				res = max(res, sol.dfs(i, j))
			}
		}
	}
	return res
}

func (sol *Solution) dfs(x, y int) int {
	sol.g[x][y] = 0
	res := 1
	for i := 0; i < 4; i++ {
		a, b := x+sol.dx[i], y+sol.dy[i]
		for a >= 0 && b >= 0 && a < len(sol.g) && b < len(sol.g[0]) && sol.g[a][b] == 1 {
			res += sol.dfs(a, b)
		}
	}
	return res
}

func main() {
	g := [][]int{{1, 1, 0}, {1, 1, 0}, {0, 0, 0}}
	fmt.Println(maxAreaOfIsland(g))
}
// 4
```

#### 227. 基本计算器 II

[227. 基本计算器 II - 力扣（LeetCode）](https://leetcode.cn/problems/basic-calculator-ii/description/)

```go
package main

import "fmt"

func calculate(s string) int {
	s += "+"
	nums, curNum, prevOp, sum := []int{}, 0, '+', 0
	for _, ch := range s {
		if ch >= '0' && ch <= '9' {
			curNum = curNum*10 + int(ch-'0')
		} else if ch != ' ' {
			switch prevOp {
			case '+':
				nums = append(nums, curNum)
			case '-':
				nums = append(nums, -curNum)
			case '*':
				nums[len(nums)-1] *= curNum
			case '/':
				nums[len(nums)-1] /= curNum
			}
			curNum, prevOp = 0, rune(ch)
		}
	}
	for _, n := range nums {
		sum += n
	}
	return sum
}

func main() {
	fmt.Println(calculate("3+2*2"))
}
// 7
```

#### 198. 打家劫舍

[198. 打家劫舍 - 力扣（LeetCode）](https://leetcode.cn/problems/house-robber/description/)

```go
package main

import "fmt"

func rob(nums []int) int {
	dp := make([]int, len(nums)+2)
	for i := len(nums) - 1; i >= 0; i-- {
		dp[i] = max(dp[i+1], dp[i+2]+nums[i])
	}
	return dp[0]
}

func main() {
	fmt.Println(rob([]int{1, 2, 3, 1}))
}
// 4
```

#### 139. 单词拆分

[139. 单词拆分 - 力扣（LeetCode）](https://leetcode.cn/problems/word-break/)

```go
package main

import "fmt"

func wordBreak(s string, dict []string) bool {
	dp := make([]bool, len(s)+1)
	dp[0] = true
	for i := 0; i < len(s); i++ {
		for _, w := range dict {
			if !dp[i] || len(w) > len(s)-i || s[i:i+len(w)] != w {
				continue
			}
			dp[i+len(w)] = true
		}
	}
	return dp[len(s)]
}

func main() {
	fmt.Println(wordBreak("leetcode", []string{"leet", "code"}))
}
// true
```

#### 补充题：手撕堆排序

[912. 排序数组 - 力扣（LeetCode）](https://leetcode.cn/problems/sort-an-array/)

```go
package main

import "fmt"

func heapSort(nums []int) {
	n := len(nums)
	for i := n / 2; i >= 0; i-- {
		sink(nums, i, n-1)
	}
	for end := n - 1; end >= 0; end-- {
		nums[0], nums[end] = nums[end], nums[0]
		sink(nums, 0, end-1)
	}
}

func sink(heap []int, root, end int) {
	// 循环直到节点没有子节点（child直到超出数组的范围）
	for child := 2*root + 1; child <= end; child = 2*root + 1 {
		// 如果有右子节点，并且右子节点的值大于左子节点，则选取右子节点与父节点进行比较
		if child < end && heap[child] < heap[child + 1] {
			child++
		}
		// 如果父节点的值大于子节点，则满足最大堆的性质，无须交换，直接返回
		if heap[root] >= heap[child] {
			break
		}
		// 否则，交换父节点与子节点的值，继续下沉调整
		heap[root], heap[child] = heap[child], heap[root]
		root = child
	}
}

func main() {
	nums := []int{3, 2, 1, 4, 5}
	heapSort(nums)
	fmt.Println(nums)
}
// 1 2 3 4 5
```

#### 24. 两两交换链表中的节点

[24. 两两交换链表中的节点 - 力扣（LeetCode）](https://leetcode.cn/problems/swap-nodes-in-pairs/description/)

```go
package main

import "fmt"

type ListNode struct {
	Val  int
	Next *ListNode
}

func swapPairs(head *ListNode) *ListNode {
	if head == nil {
		return head
	}
	dummy := &ListNode{Next: head}
	p := dummy
	for p.Next != nil && p.Next.Next != nil {
		n1, n2 := p.Next, p.Next.Next
		n1.Next = n2.Next
		n2.Next = n1
		p.Next = n2
		p = n1
	}
	return dummy.Next
}

func main() {
	head := &ListNode{Val: 1, Next: &ListNode{Val: 2, Next: &ListNode{Val: 3, Next: &ListNode{Val: 4}}}}
	res := swapPairs(head)
	for res != nil {
		fmt.Printf("%d ", res.Val)
		res = res.Next
	}
}
// 2 1 4 3
```

#### 297. 二叉树的序列化与反序列化

[297. 二叉树的序列化与反序列化 - 力扣（LeetCode）](https://leetcode.cn/problems/serialize-and-deserialize-binary-tree/description/)

```go
type TreeNode struct {
	Val         int
	Left, Right *TreeNode
}

type Codec struct{}

func Constructor() Codec {
	return Codec{}
}

// Serializes a tree to a single string.
func (this *Codec) serialize(root *TreeNode) string {
	if root == nil {
		return "X"
	}
	return strconv.Itoa(root.Val) + "," + this.serialize(root.Left) + "," + this.serialize(root.Right)
}

// Deserializes your encoded data to tree.
func buildTree(list *[]string) *TreeNode {
	rootVal := (*list)[0]
	*list = (*list)[1:]
	if rootVal == "X" {
		return nil
	}
	Val, _ := strconv.Atoi(rootVal)
	root := &TreeNode{Val: Val}
	root.Left = buildTree(list)
	root.Right = buildTree(list)
	return root
}

func (this *Codec) deserialize(data string) *TreeNode {
	list := strings.Split(data, ",")
	return buildTree(&list)
}
```

#### 560. 和为 K 的子数组

[560. 和为 K 的子数组 - 力扣（LeetCode）](https://leetcode.cn/problems/subarray-sum-equals-k/description/)

```go
package main

import "fmt"

func subarraySum(nums []int, k int) int {
	hash := make(map[int]int)
	hash[0] = 1
	pre, res := 0, 0
	for _, n := range nums {
		pre += n
		if _, ok := hash[pre-k]; ok {
			res += hash[pre-k]
		}
		hash[pre]++
	}
	return res
}

func main() {
	fmt.Println(subarraySum([]int{1, 1, 1}, 2))
}
// 2
```

#### 209. 长度最小的子数组

[209. 长度最小的子数组 - 力扣（LeetCode）](https://leetcode.cn/problems/minimum-size-subarray-sum/description/)

```go
package main

import (
	"fmt"
	"math"
)

func minSubArrayLen(target int, nums []int) int {
	r, l, length := 0, 0, math.MaxInt
	for r < len(nums) {
		target -= nums[r]
		r++
		for target <= 0 {
			length = min(length, r-l)
			target += nums[l]
			l++
		}
	}
	if length == math.MaxInt {
		return 0
	}
	return length
}

func main() {
	fmt.Println(minSubArrayLen(7, []int{2, 3, 1, 2, 4, 3}))
}
// 2
```


# 算法设计与分析

## 选择排序

每一轮选择最小的那个 一个简单的两重循环。

![](https://picture.lanlance.cn/i/2023/06/13/64886a3de647f.png)

## 冒泡排序

也是一个两重循环，但是每次只要剩余数中的最小值。

时间复杂度为 O(n^2)

![](https://picture.lanlance.cn/i/2023/06/13/64886ac34a24f.png)

## 顺序查找

用一个 `target` 来做一个限定。

![](https://picture.lanlance.cn/i/2023/06/13/64886b282ef0f.png)

## 蛮力字符串匹配

无脑循环，如果模式串中和匹配串中的不匹配直接往前走。

![](https://picture.lanlance.cn/i/2023/06/13/64886ba8c5b3a.png)

## 最近对问题

一个简单的初中数学问题，通过计算两数距离即可，甚至可以不用开平方根简化计算。

时间复杂度为 O(n^2)

![](https://picture.lanlance.cn/i/2023/06/13/64886bee8cade.png)

## 凸包问题

> 问题：对于平面上 n 个点，找包围它们的最小凸多边形。

蛮力算法：对于每对点 p1 和 p2，判断是否所有其他点都在连接 p1 和 p2 的直线的同一侧;

算出直线 y = ax + b 即可，将所有点带到这个直线中判断符号是否相同。

时间复杂度为 O(n^3)

## 旅行商问题

值得一提的一个点是可以看到有三对不同的路线，每对路线之间仅有方向不同，因此可以把顶点排列的数量减半。

![](https://picture.lanlance.cn/i/2023/06/13/6488707dc8785.png)

## 背包问题

教材上的图就是一个简单的图表，这边比较重要的点是用穷举法解决背包问题需要 O(2^n) 的时间复杂度。他和上面的旅行商问题都是典型的 NP 困难问题。

## 深度优先查找

![](https://picture.lanlance.cn/i/2023/06/13/6488745edf4c7.png)

V 和 E 分别为图的顶点和边的数量。数据结构为栈，顶点顺序有两种。

## 广度优先查找

![](https://picture.lanlance.cn/i/2023/06/13/648874d23c558.png)

V 和 E 分别为图的顶点和边的数量。数据结构为队列，顶点顺序只有一种。

## 插入排序

每一步将一个待排序的数据插入到前面已经排好序的有序序列中，直到插完所有元素为止。

最坏时间复杂度与平均时间复杂度均为 O(n^2)，最好时间复杂度为 O(n)。

![](https://picture.lanlance.cn/i/2023/06/13/648875af59463.png)

## 拓扑排序

拓扑排序这里提供了两种解法，分别是 DFS 遍历栈和基于减治技术的算法。

![](https://picture.lanlance.cn/i/2023/06/13/648879f4e26b2.png)

减治技术会在每次迭代时，删除没有输入边的节点。

![](https://picture.lanlance.cn/i/2023/06/13/64887a013a372.png)

## 生成排列

满足最小变化要求。

![](https://picture.lanlance.cn/i/2023/06/13/64887b42eec76.png)

时间复杂度为 O(n!)，生成的排序不为字典序。

![](https://picture.lanlance.cn/i/2023/06/13/64887b8c70174.png)

## 生成子集

能够生成 2^n 个子集，也就是幂集。后一组的每一个元素都可以通过把 a(n) 添加到 {a(1) ... a(n - 1)} 中子集来获得。

![](https://picture.lanlance.cn/i/2023/06/13/64887cbb76b5b.png)

## 折半查找

经典二分问题

![](https://picture.lanlance.cn/i/2023/06/14/6489b4032fbcd.png)

## 俄式乘法

仅有折半、加倍以及相加三个操作，硬件实现非常快速。

![](https://picture.lanlance.cn/i/2023/06/14/6489b667406d6.png)

## 选择问题

![](https://picture.lanlance.cn/i/2023/06/14/6489bb45054f9.png)

![](https://picture.lanlance.cn/i/2023/06/14/6489bdbac1e05.png)

![](https://picture.lanlance.cn/i/2023/06/14/6489bdc637859.png)

## 插值查找

插值查找计算要查找的值在数组中的大致位置，然后将数组分为两部分，其中一部分必定不可能包含要查找的值，另一部分可能包含要查找的值，然后在可能包含要查找的值的那一部分中继续递归查找，直到找到要查找的值或者确定要查找的值不存在于数组中。

在数组分布不均匀的情况下，它的效率可能会比二分查找还低。

![](https://picture.lanlance.cn/i/2023/06/14/6489bfcb65885.png)

## 分治法适用条件

1. 原始可分解，且分解出来的子问题和原始问题就有相同的类型。
2. 分解出来的子问题到很小时可以很容易（在很短的时间和空间内能求解）求解。
3. 子问题的解能合并。

## 归并排序

归并排序的平均时间复杂度也是 O(nlogn)。最好和最坏情况下的时间复杂度都是 O(nlogn)。

![](https://picture.lanlance.cn/i/2023/06/14/6489c09ada546.png)

## 快速排序

平均时间复杂度也是 O(nlogn)。最好情况下，即每次都能平分数组，时间复杂度为 O(nlogn)；最坏情况下，即每次划分只能减少一个元素，时间复杂度为 O(n^2)。但是最坏情况发生的概率非常小，所以快速排序通常被认为是一种非常高效的排序算法。

![](https://picture.lanlance.cn/i/2023/06/14/6489c0e24b724.png)

![](https://picture.lanlance.cn/i/2023/06/14/6489c0fec542b.png)

## 大数乘法

![](https://picture.lanlance.cn/i/2023/06/14/6489c39f335cb.png)

## 预排序

* 检查数组中元素的唯一性

先排序再比对是否唯一，时间复杂度基于排序 O(nlogn)。

## 霍纳法则

![](https://picture.lanlance.cn/i/2023/06/14/6489c7880e1c0.png)

## 动态规划解决背包问题

![](https://picture.lanlance.cn/i/2023/06/14/6489cb378fc26.png)

## 最优二叉查找树

![](https://picture.lanlance.cn/i/2023/06/14/6489cdefeb4bd.png)

## Warshall

![](https://picture.lanlance.cn/i/2023/06/15/648ae1634c75c.png)

`R[i][j][k] == R[i][j][k - 1] || R[i][k][k - 1] && R[k][j][k - 1]`

## Floyd

![](https://picture.lanlance.cn/i/2023/06/15/648ae1cf3aa51.png)

`D[i][j] == min(D[i][j], D[i][k] + D[k][j])`

## 哈夫曼编码

二叉树左分支为 0，右分支为 1。

## P 类

![](https://picture.lanlance.cn/i/2023/06/15/648b0916539be.png)

## NP 类

![](https://picture.lanlance.cn/i/2023/06/15/648b0931d91a8.png)

![](https://picture.lanlance.cn/i/2023/06/15/648b09d91deb8.png)

![](https://picture.lanlance.cn/i/2023/06/15/648b09e83eb9d.png)


# 计算机组织与结构

## 冯诺依曼计算机

* 采用二进制形式表示数据和指令；指令由操作码和地址码组成
* 将程序和数据存放在存储器中，使计算机在工作时从存储器取出指令加以执行，自动完成计算任务：“存储程序”和“程序控制”
* 指令的执行是顺序的，即一般按照指令在存储器中存放的顺序执行，程序分支由转移指令实现
* 计算机由存储器、运算器、控制器、输入和输出设备五大基本部件组成，规定了 5 部分的基本功能

## 数的表示

* 真值：现实中真实的数值
* 机器数：计算机中用 0 和 1 数码组合表达的数值
* 定点数：固定小数点的位置表达数值的机器数
  * 定点整数：将小数点固定在机器数的最右侧表达的整数
  * 定点小数：将小数点固定在机器数的最左侧表达的小数
* 浮点数：小数点浮动表达的实数
* 无符号数：只表达 0 和正整数的定点整数
* 有符号数：表达负整数、0 和正整数的定点整数
  * 符号位需要占用一个位，常用机器数的最高位
  * 0 表示正数、1 表示负数
  * 具有原码、反码、补码、移码

### 数的机器码表示

![](https://picture.lanlance.cn/i/2023/06/27/649a54d7b6453.png)

> 掌握原码、补码、反码、移码的定义、特性、相互转换

### 浮点数的表示方法

![](https://picture.lanlance.cn/i/2023/06/27/649a554725f26.png)

### 规格化表示原则

![](https://picture.lanlance.cn/i/2023/06/27/649a5575c78bf.png)

## 存储器

### 分类

* 按存储介质分
  * 半导体存储器：用半导体器件组成的存储器
  * 磁表面存储器：用磁性材料做成的存储器
* 按存储方式分
  * 随机存储器：任何存储单元的内容都能被随机存取，且存取时间和存储单元的物理位置无关
  * 顺序存储器：只能按某种顺序来存取，存取时间和存储单元的物理位置有关
* 按存储器的读写功能分：ROM，RAM
* 按信息的可保存性分：非永久记忆，永久记忆
* 按在计算机系统中的作用分：主存、辅存、高速缓存、控制存储器

### SRAM/DRAM

* SRAM：用作小容量、高效率的内存多用作 Cache
* DRAM：用作主存，需要定期对存储矩阵所有行逐一刷新

### 存储器与 CPU 的连接方式

* CPU 对存储器进行读/写操作，首先由地址总线给出地址信号，然后要对存储器发出读操作或写操作的控制信号，最后在数据总线上进行信息交流
* 所以存储器与 CPU 之间需要
  * 地址线的连接
  * 数据线的连接
  * 控制线的连接
* 存储器芯片的容量是有限的,为了满足实际存储器的容量要求，需要对存储器进行扩展

### 存储器拓展

* 位拓展法

  只加长每个存储单元的字长，而不增加存储单元的数量
* 字拓展法

  仅增加存储单元的数量，而各单元的位数不变
* 字位同时拓展法

  既增加存储单元的数量，也加长各单元的位数

## Cache 存储器

* 在相对容量较大而速度较慢的主存与高速处理器之间设置的少量但快速的存储器
* 主要目的：提高存储器速度
* 为追求高速，包括管理在内的全部功能由硬件实现

### Cache 命中率

![](https://picture.lanlance.cn/i/2023/06/27/649a58518091b.png)

### Cache 访问效率

![](https://picture.lanlance.cn/i/2023/06/27/649a585e0f02b.png)

### Cache 结构

* Cache 的数据块称为行（线 Line，槽 Slot
* 主存的数据块称为块（Block）
* 行与块是等长的，包含 k=2w 个主存字
* Cache 由数据存储器和标签存储器组成
  * 数据存储器：高速缓存主存数据
  * 标签存储器：保存数据所在主存的地址信息

### 主存与 Cache 的地址映射

Cache 通过地址映射(mapping)的方法确定主存块与 Cache 行之间的对应关系，确定一个主存块应该存放到哪个 Cache 行中

* 全相联映射(fully associative mapping)

  可以将一个主存块存储到任意一个 Cache 行

  * 优点：命中率较高，Cache 的存储空间利用率高
  * 缺点：线路复杂，成本高，速度低
* 直接映射(direct mapping)

  将一个主存块存储到唯一的一个 Cache 行

  * 优点：硬件简单，容易实现
  * 缺点：命中率低， Cache 的存储空间利用率低
* 组相联映射(set associative mapping)

  可以将一个主存块存储到唯一的一个 Cache 组中任意一个行

  * 组间采用直接映射，组内为全相联
  * 硬件较简单，速度较快，命中率较高

### 替换问题

* 新主存块要进入 Cache，决定替换哪个原主存块
* 直接映射，只能替换唯一的一个 Cache 行
* 全相联和组相联，需要选择替换策略（算法）

1. 最不常用(LFU: least-frequently used) 替换使用次数最少的块
2. 最近最少使用法(LRU: least-recently used) 本指替换近期最少使用的块，实际实现的是替换最久没有被使用的块
3. 随机法(random) 随意选择被替换的块，不依赖以前的使用情况

## 指令系统

### 指令格式

![](https://picture.lanlance.cn/i/2023/06/27/649a5bee4b805.png)

1. 单字长二地址指令
2. 操作码字段 OP 长度为 7 位，可指定 128 条指令
3. 源寄存器和目标寄存器都是通用寄存器（可分别指定 16 个）。两个操作数均在寄存器中，所以是寄存器－寄存器型指令
4. 这种指令结构常用于算术逻辑运算类指令

### 操作码拓展技术

![](https://picture.lanlance.cn/i/2023/06/27/649a5c3b9b4cb.png)

### 常用数据寻址方式

* 隐含寻址：在指令中不明显地给出操作数的地址
* 寄存器寻址：指令中给出的操作数地址不是内存的地址单元号，而是通用寄存器的编号。即操作数不放在内存中，而是放在通用寄存器中
* 立即寻址：指令的地址字段指出的不是操作数的地址，而直接是操作数本身
* 直接寻址：在指令格式的地址字段中，直接给出操作数在内存的地址
* 寄存器间接寻址：指令中指定的寄存器中的内容不是操作数，而是操作数的地址
* 基址(寄存器相对)寻址：基址寄存器的内容加上指令中给定的形式地址(偏移量)，形成操作数的有效地址

![](https://picture.lanlance.cn/i/2023/06/27/649a5c9a03015.png)

## 中央处理器

### CPU 的基本组成

* 控制器完成对整个计算机系统操作的协调与指挥
  * 控制机器从内存中取出一条指令，并指出下一条指令在内存中的位置
  * 对指令进行译码，并产生相应的操作控制信号，送往相应的部件，启动规定的动作
  * 指挥并控制 CPU、内存与输入/输出（I/O）设备之间数据流动的方向
* 运算器是数据加工处理部件，所进行的全部操作由控制器发出的控制信号指挥
  * 执行所有的算术运算
  * 执行所有的逻辑运算，并进行逻辑测试

### 指令周期

* 一个完整的指令周期由若干机器周期：
  * 取指周期——间址周期——执行周期——中断周期
* 所有指令的第一个机器周期必为取指周期
* 一个基本的 CPU 周期包含 4 个时钟周期，对于某些 CPU 周期可以包含更多的时钟周期
* 不同指令的指令周期所包含的时钟周期个数不一定相同

### 时序信号

* 计算机的协调动作需要时间标志，而且需要采用多级时序体制。而时间标志则用时序信号来体现
* 硬布线控制器中，时序信号往往采用主状态周期-节拍电位-节拍脉冲三级体制
  * 主状态周期（指令周期）：包含若干个节拍周期，可以用一个触发器的状态持续时间来表示
  * 节拍电位（机器周期）：表示一个 CPU 周期的时间，包含若干个节拍脉冲
  * 节拍脉冲（时钟周期）：表示较小的时间单位
* 微程序控制器中，时序信号则一般采用节拍电位-节拍脉冲二级体制

![](https://picture.lanlance.cn/i/2023/06/27/649a5f3c470c4.png)

### 微指令和微程序

* 微指令
  * 一个 CPU 周期中，实现一定操作功能的一组微命令的组合
  * 微指令一般包含操作控制字段和顺序控制字段
    * 操作控制：用于发出管理和指挥全机工作的控制信号
    * 顺序控制：用于决定产生下一条微指令的地址
  * 所有的微指令都存放于控制存储器中，使用微地址访问
* 微程序
  * 能实现一条机器指令功能的多条微指令序列
  * 每条机器指令都对应着一段微程序

### 微程序控制器

微程序控制器主要构成部件

* 控制存储器（CM）
  * 存放实现全部指令系统的微指令
  * 由只读存储器构成，要求速度快，读出周期短
* 微指令寄存器

  存放由控制存储器读出的一条微指令信息

  * 微地址寄存器：决定将要访问的下一条微指令的地址
  * 微命令寄存器：保存一条微指令的操作控制字段和判别测试字段的信息
* 地址转移逻辑
  * 用于跳跃寻址微指令时，承担自动完成修改微地址的任务

### 并行处理技术

并行性（Parallelism）：

在同一时刻或是同一时间间隔内完成两种或两种以上性质相同或不相同的工作

* 同时性（Simultaneity）：同一时刻发生的并行性
* 并发性（Concurrency）：同一个时间间隔内发生的并行性

并行性的等级

* 指令内部并行：微操作之间（相容微命令信号）
* 指令级并行（ILP：Instruction Level Parallel）
* 线程级并行（TLP：Thread Level Parallel ）
* 程序级并行
* 系统级并行：分布式系统、多机系统、机群系统

### 提高并行性的技术途径

* 时间重叠（Time-interleaving）＝ 时间并行
  * 多个过程在时间上相互错开，轮流重叠地使用同一套硬件设备的各个部分
* 资源重复（Resource-replication）＝ 空间并行
  * 通过重复设置资源（尤其是硬件资源），提高性能
* 资源共享（Resource-sharing）
  * 使多个任务按一定时间顺序轮流使用同一套硬件设备

### CPU 流水线

* 流水线实际上是把一个功能部件分解成多个独立的子功能部件（一个任务也就分成了几个子任务，每个子任务由一个子功能部件完成），并依靠多个子功能部件并行工作来缩短所有任务的执行时间
* 流水线有助于提高整个程序（所有任务）的吞吐率，但并没有减少每个指令（任务）的执行时间
* 流水线各个功能段所需时间应尽量相等。否则，时间长的功能段将成为流水线的“瓶颈”

### 流水线的主要问题

* 结构相关（资源冲突）：当指令重叠执行过程中，硬件资源满足不了指令重叠执行的要求
* 数据相关（数据冲突） ：在同时执行的多条指令中，一条指令依赖前一条指令的执行结果（数据）却无法得到
* 控制相关（控制冲突）：流水线遇到分支指令或其他改变 PC 值的指令

### CPU 性能评价

![](https://picture.lanlance.cn/i/2023/06/27/649a61a5d3f7f.png)

## 总线系统

总线是构成计算机系统的互连机构，是多个系统功能部件之间进行数据传送的公共通路

### 总线的结构形态

* 内部总线：CPU 内部连接各寄存器及运算部件之间的总线
* 系统总线：CPU 同计算机系统的其他高速功能部件，如存储器、通道等互相连接的总线
* I/O 总线：中低速 I/O 设备间互相连接的总线

![](https://picture.lanlance.cn/i/2023/06/27/649a61feeef01.png)

### 总线仲裁

* 主设备(Master)：控制总线完成数据传输
* 从设备(Slave)：被动实现数据交换
* 总线仲裁：决定当前控制总线的主设备
  * 集中仲裁：中央仲裁器负责
  * 分布仲裁：比较各个主设备仲裁号决定

![](https://picture.lanlance.cn/i/2023/06/27/649a622cd3ce4.png)

## 外围设备

### 显示设备

* 像素：组成图像的最小单位，显示器上的发光点
* 分辨率：显示器所能表示的像素个数
* 分辨率＝水平点数 × 垂直点数；如 1280×1024
* 彩色显示器：每个像素由红、绿、蓝三色组成
* 如果红、绿、蓝三色都用 8 个二进制位表达（RGB），则彩色图像就具有 224（16M）种颜色

### 磁盘

* 记录面
  * 磁盘片表面
  * 一个盘片有上下两个记录面
* 磁道
  * 记录面上一系列同心圆
  * 最外圈为 0 磁道 ，依次为 1、2、……、N 磁道
  * 每个磁道的存储容量均相同
  * 不同盘片的相同磁道构成一个柱面
* 扇区
  * 同心圆上的一段磁道区域
  * 每个扇区的存储容量也相同

![](https://picture.lanlance.cn/i/2023/06/27/649a62bbbbfe0.png)

### 存储密度

* 道密度：沿磁盘半径方向单位长度上的磁道数，单位为道/英寸
* 位密度：是磁道单位长度上能记录的二进制代码位数，单位为位/英寸
* 面密度：位密度和道密度的乘积，单位为位/平方英寸

相关概念

* 道距：相邻两磁道中心线之间的距离；
* 道宽：磁化轨迹的宽度。

### 存储容量

* 存储容量=记录面数 × 每面磁道数 × 磁道容量
* 非格式化容量
  * 磁记录表面可以利用的磁化单元总数
* 格式化容量
  * 按照某种特定的记录格式所能存储信息的总量，也就是用户可以真正使用的容量。
  * 格式化容量一般是非格式化容量的 60%—70%。

### 平均存取时间

* 存取时间=找道时间+等待时间
  * 定位时间（找道时间）
    * 将磁头定位至所要求的磁道上所需的时间
  * 等待时间
    * 找道完成后，盘片将所要访问信息转到磁头下方的时间
* 平均找道时间
  * 最大与最小找道时间的平均值，约为 10\~20ms
* 平均等待时间
  * 与磁盘转速有关，是磁盘旋转一周时间的一半
  * 硬盘转速为 7200 转/分，故平均等待时间约为 4ms。

## 输入输出系统

### I/O 接口电路

* 计算机的外围(外部)设备多种多样
* 工作原理、驱动方式、信息格式、以及工作速度方面彼此差别很大
* 外设不能与 CPU 直接相连，必须经过中间电路（I/O 接口电路）再与系统相连
* I/O 接口电路是位于系统与外设间、用来协助完成数据传送和控制任务的逻辑电路

![](https://picture.lanlance.cn/i/2023/06/27/649a6392bb254.png)

### I/O 端口的编址

* I/O 端口（Port）泛指 I/O 地址，对应 I/O 接口寄存器
* 一个接口电路可以具有多个 I/O 端口，每个端口用来保存和交换不同的信息
* 数据寄存器、状态寄存器和控制寄存器占有的 I/O 地址常依次被称为数据端口、状态端口和控制端口，用于保存数据、状态和控制信息
* 输入、输出端口可以是同一个 I/O 地址
* 接口电路占用的 I/O 端口有两类编排形式
  * I/O 端口单独编址
  * I/O 端口与存储器统一编址

### CPU 对外围设备的管理方式

![](https://picture.lanlance.cn/i/2023/06/27/649a63d605a7f.png)

### CPU 和外设之间信息交换的方式

* 程序控制下的数据传送
  * 通过 CPU 执行程序中的 I/O 指令来完成传送
  * 程序查询方式
  * 程序中断方式
* 直接存储器存取 DMA 方式
  * 外设经 DMA 控制器向 CPU 申请总线，由 DMA 控制器利用系统总线完成外设和存储器间的数据传送
* 通道方式
  * 通道(I/O 处理器)管理外设，完成传送和数据处理
* 外围处理机方式
  * 通道方式的进一步发展，基本独立于主机工作

### 程序中断方式

* 处理器在执行程序过程中，被内部或外部的事件所打断，转去执行一段预先安排好的中断服务程序；服务结束后，又返回原来的断点，继续执行原来的程序
* 中断源：引起中断的事件或原因
* 例如：
  * 外设的数据传送请求
  * 系统定时请求
  * 电源掉电等故障
  * 运算出错等错误
  * 程序异常或调试请求

![](https://picture.lanlance.cn/i/2023/06/27/649a643f7469b.png)

### DMA 方式

* 克服程序控制传送的不足：
  * 外设 →CPU→ 存储器
  * 外设 ←CPU← 存储器
* 直接存储器存取 DMA：
  * 外设 → 存储器
  * 外设 ← 存储器
* 特点比较：
  * 查询传送： 简单实用，效率较低
  * 中断传送：外设主动，可与 CPU 并行工作，但每次传送需要大量额外时间开销
  * DMA 传送：CPU 释放总线，由 DMA 控制器管理，外设直接和存储器进行数据传送，适合大量、快速数据传送

## 简答题

### 第一章

#### 冯·诺依曼计算机的主要设计思想是什么？它包括哪些组成部分？

1. 采用二进制形式表示数据和指令。指令由操作码和地址码组成。
2. 将程序和数据存放在存储器中，计算机在工作时从存储器取出指令加以执行，自动完成计算任务。这就是“存储程序”和“程序控制”。
3. 指令的执行是顺序的，即一般按照指令在存储器中存放的顺序执行，程序分支由转移指令实现。
4. 计算机由存储器、运算器、控制器、输入和输出设备五大基本部件组成。

#### 如何理解计算机的概念？

计算机是一种以电子器件为基础的，不需人的直接干预，能够对各种数字化信息进行快速算术和逻辑运算的工具，是一个由硬件、软件组成的复杂的自动化设备。

1. 以电子器件为物质基础，即研究的对象是电子数字计算机
2. 不需要人的直接干预，说明具有自动化能力，其前提是存储程序
3. 处理各种数字化信息，计算机以二进制编码作为数字化编码及运算的基础
4. 具有算逻运算能力，基本运算操作是算术和逻辑运算
5. 计算机是快速工具，主要取决于两个因素：一是电子器件，二是存储程序
6. 由硬件和软件组成

#### 指令和数据均存放在内存中，计算机如何区分指令还是数据？

1. 时间上：在取指周期中，CPU 从内存读出的信息一定是指令；二执行周期中从内存读出或写入的信息一定是数据。
2. 空间上：指令一定流向控制器；而数据则是在内存（或寄存器）与运算器之间流动。
3. CPU 的性能指标有哪些？其概念是什么？

#### 把运算器和控制器合在一起称为中央处理器，简称 CPU。其性能指标主要有以下几个方面

1. 吞吐量：表示一台计算机在某一时间间隔内能够处理的信息量
2. 响应时间：表示从输入有效到系统产生响应之间的时间间隔，用时间单位来度量
3. 利用率：在给定的时间间隔内系统被实际使用的时间所占的比率，用百分比表示
4. 处理机字长：指处理机运算器中一次能够完成二进制运算的位数，如 32 位、64 位
5. 总线宽度：一般指 CPU 中运算器与存储器之间进行互联的内部总线二进制位数
6. 存储器容量：存储器中所有存储单元的总数，通常用 KB、MB、GB、TB 来表示
7. 存储器带宽：单位时间内从存储器读出的二进制数信息量，一般用字节数/秒表示
8. 主频、时钟周期：CPU 的工作节拍受主时钟控制，主时钟是不断产生固定频率的时钟，主时钟的频率 f 称为 CPU 的主频，度量单位是 MHz、GHz；主频的倒数称为 CPU 时钟周期 T，T=1/f，度量单位是 μs、ns
9. CPU 执行时间：表示 CPU 执行一般程序所占用的 CPU 时间，CPU 执行时间=CPU 时钟周期数 ×CPU 时钟周期
10. CPI：表示每条指令周期数，即执行一条指令所需的平均时钟周期数，CPI=执行某段程序所需的 CPU 时钟周期数/程序包含的指令条数
11. MIPS: Million Instructions Per Second 的缩写，表示平均每秒执行多少百万条定点指令数，MIPS=指令数/(程序执行时间 ×10^6)
12. FLOPS: Floating-pint Operations Per Second 的缩写，表示每秒执行浮点操作的次数，用来衡量机器浮点操作的性能，FLOPS=程序中的浮点操作次数/程序执行时间

#### 什么是存储容量、单元地址、数据字、指令字？

1. 存储容量：存储器所有存储单元的总数。通常用单位 KB、MB、GB、TB 等表示。
2. 单元地址：存储器是由许多存储单元组成，每个存储单元的编号称为单元地址
3. 某字代表要处理的数据，称为数据字
4. 某字为一条指令，称为指令字

#### 现代计算机系统如何进行多级划分？这种分积观点对计算机设计会产生什么影响？

1. 第一级是微程序设计级或逻辑电路级，是一个实在的硬件级，由硬件直接执行
2. 第二级是一般机器级，称为机器语言级，也是硬件级，它由微程序来解释执行
3. 第三级是操作系统级，它由操作系统程序实现
4. 第四级是汇编语言级，由汇编程序支持和执行，它给程序人员提供一种符号形式语言，以减少程序编写的复杂性
5. 第五级是高级语言级，它是面向用户的，为方便用户编写应用程序而设置的

系统分级对计算机设计产生的影响

1. 采用这种用一系列的级来组成和计算机的概念和技术，对了解计算机如何组成提供了一种好的结构和体制。
2. 用这种分级的观点来设计计算机，对保证产生一个良好的系统结构也是很有帮助的。

### 第二章

#### 在浮点数中，阶码的正负和尾数正负各代表什么含义？对实际数值的正负与大小有何影响？

1. 阶码为正，表示将尾数扩大
2. 阶码为负，表示将尾数缩小
3. 尾数的正负代表浮点数的正负

### 第三章

#### cache 与虚存的异同

* cache 与虚存的相同点
  * 出发点相同：二者都是为了提高存储系统的性能价格比而构造的分层存储体系，都力图使存储系统的性能接近高速存储器，而价格和容量接近低速存储器
  * 原理相同：都是利用了程序运行时的局部性原理把最近常用的信息块从相对慢速而大容量的存储器调入相对高速而小容量的存储器
* cache 与虚存的不同点
  * 侧重点不同：cache 主要解决主存与 CPU 的速度差异问题，虚存主要是解决存储器容量问题
  * 数据通路不同：CPU 与 cache 和主存之间均有直接访问通路，而虚存所依赖的辅存与 CPU 之间不存在直接的数据通路
  * 透明性不同：cache 对系统程序员和应用程序员均透明，虚存对系统程序员不透明，只对应用程序员透明
  * 未命中时的损失不同：主存未命中时虚存系统的性能损失要远大于 cache 未命中时的损失

#### 静态存储依靠什么存储信息？动态存储器又依赖什么原理存储信息？比较它们的优缺点

1. 静态存储器以双稳态触发器为存储信息的物理单元，依靠内部交叉反馈保存信息，速度较快，不需要动态刷新，但集成度稍低，功耗大
2. 动态存储靠电容上暂存电荷来存储信息，电容上有电荷为 1，无电荷为 0，集成度高，工号小，速度稍慢，需定时刷新

#### 说明 Cache 的地址映射作用和方法？

Cache 通过地址映射的方法确定主存块与 cache 行之间的对应关系，确定一个主存块应该存放到哪个 cache 行中。

1. 全相联映射：每个主存块可以放到任何一个 cache 块中，最灵活但实现的成本代价最大。
2. 直接映射：每个主存块只能放到一个唯一对应的 cache 块中，实现简单但 cache 利用率低。
3. 组相联映射：每个主存块唯一对应一个 cache 组，但可放到组内任何一个块中，是前两种方式的折中。

#### 说明 cache 的替换策略

cache 工作原理要求它尽量保存最新数据，必然要产生替换，直接映射的 cache，不涉及替换算法，全相连和组相联 cache，就要从允许存放新主存块的若干特定行中选取一行换出

最不经常使用（LFU）算法

* 将一段时间内被访问次数最少的那行数据换出
* 每行设置一个计数器，从 0 开始计数，每访问一次，将访行的计数器增 1。当需要替换时，将计数值最小的行换出，同时将这些行的计数器都清零
* 该算法将计数周期限定在对两次替换之间的间隔时间内，不能严格反映近期访问情况

近期最少使用（LRU）算法

* 将近期内长久未被访问过的行换出
* 每行也设置一个计数器，cache 每命中一次，命中行的计数器清零，其它各行计数器增 1。当需要替换时，将计数值最大的行换出
* 保护了刚拷贝到 cache 中的新数据行，有较高的命中率，应用广泛，但实现的硬件较复杂

随机替换

* 从特定的行位置中随机地选取一行换出
* 在硬件上容易实现，且速度也比前两种策略快
* 降低了命中率和 cache 工作效率

#### cache 的写操作策略

cache 内容是主存部分内容的拷贝，应当与主存内容保持一致

写回法

* 当 CPU 写 cache 命中时，只修改 cache 的内容，而不立即写入主存；只有当此行被换出时才写回主存
* 若写未命中，则将此块整个拷贝到 cache 后对其进行修改
* 可减少访问主存的次数，但存在不一致性的隐患
* 实现这种方法时，每个 cache 行必须配置一个修改位，以反映此行是否被 CPU 修改过

全写法

* 当写 cache 命中时，cache 与主存同时发生写修改，较好地维护了 cache 与主存内容的一致性
* 当写 cache 未命中时，直接向主存进行写入
* cache 中每行无需设置一个修改位以及相应的判断逻辑
* 缺点是降低了 cache 的功效
* 根据修改过的主存块是否放入 cache 可分为
  * 写不分配法：把所要写的字写入主存，而包括所写的字在内的块不写入 cache
  * 写分配法：把所要写的字写入主存，而包括所写的字在内的块写入 cache

写一次法

* 基于写回法并结合全写法的写策略
* 写命中与写未命中的处理方法与写回法基本相同，只是第一次写命中时要同时先写入主存
* 便于维护系统全部 cache 的一致性

#### DRAM 存储器为什么要刷新？有哪几种常用的刷新方法？

DRAM 存储器采用电容存放信息，由于电容漏电，保存信息经过一段时间会丢失，故用刷新保证信息不丢失。

常用的刷新方式有集中式刷新和分布式刷新

集中式刷新特点

* 对芯片的正常读/写周期不产生影响
* 会造成芯片“死时间”过长的问题

分散式刷新-方法 1

将每一行的刷新插入到正常的读/写周期之中，有两点缺陷

* 增加了系统周期，进而降低了系统速度
* 刷新过于频繁

分散式刷新-方法 2（异步式刷新）

是前两种方式的结合，2ms 内分散地把 128 行刷新一遍：2000μs÷128≈15.5μs，即每隔 15.5μs 刷新一行。

#### 计算机存储系统分哪几个层次？每一层次主要采用什么存储介质？其存储容量和存取速度的相对值如何变化？

分为高速 cache—主存—辅存三级层次结构，容量从小到大，速度从高到低

存储介质：

* cache SRAM
* 主存 DRAM
* 辅存 磁表面存储器

#### 段式虚拟存储器对程序员是否透明？请说明原因

虚拟管理是由软件（操作系统）和硬件共同完成，由于软件的介入，虚存对实现存储管理系统程序不透明，而段是按照程序的自然分界划分的长度可以动态改变的区域。通常，程序员把子程序、操作数和常数等不同类型的数据划分到不同的段中，并且每个程序可以有多个相同类型的段。由于分段是由程序员完成的，所以段式虚拟存储器对程序员而言不是透明的，但虚存到实存的地址映射是由系统软件辅助完成的，故对应用程序而言，段式虚拟存储器是“半透明”的。

#### 在一个进程的执行过程中，是否其所有页面都必须处在主存中？

在有虚拟存储管理的系统中，程序不是一次性装入内存才运行，所以不是所有页面都必须处在主存中，而是根据程序的局部性，有的页面在主存，有的页面在辅存。

#### 为什么在页式虚拟存储器地址变换时可以用物理页号与页内偏移量直接拼接成物理地址，而在段式虚拟存储器地址变换时必须用段起始地址与段内偏移量相加才能得到物理地址？

由于物理页与虚拟页的页面大小相同，且为 2 的整数次幂，所以页式虚拟存储器地址变换时可以用物理页号与页内偏移量直接拼接成物理地址。而段式虚拟存储器的各段大小不同，且段起始地址任意，所以必须用段起址与段内偏移量相加才能得到物理地址

#### 在虚存实现过程中，有些页面会在内存与外村之间被频繁地换入和换出，使系统效率急剧下降。这种现象称为颠簸。请解释产生颠簸的原因，并说明防止颠簸的办法。

产生的原因主要有

1. 分配的页面数太少
2. 替换策略不佳

防止颠簸的办法

1. 适当增加分配给用户程序的页面数
2. 选取 LRU 或更好的替换策略

### 第四章

#### 指令是灵活多变的，体现在哪些方面？

1. 指令格式多样
2. 寻址方式丰富
3. 指令类型多种
4. 操作码位数可随地址码个数变化而变化（扩展操作码方式）
5. 指令长度可变

#### 什么叫指令？什么叫指令系统？

指令是计算机执行某种操作的命令，也就是常说的机器指令。一台机器中所有机器指令的集合，称这台计算机的指令系统。

#### 一个较完整的指令系统应包括哪几类指令？

一个较完整的指令系统，应包括数据传送指令、算术运算指令、逻辑运算指令、程序控制指令、输入输出指令、字符串指令、特权指令等。

#### 指令的数据寻址方式有哪些？

操作数的寻址方式：形成操作数的有效地址的方法

操作数的寻址过程：把操作数的形式地址，根据寻址方式特征位变换为操作数的有效地址的过程

* 隐含寻址
  * 特点：在指令中不明显地给出操作数的地址
  * 单地址的指令格式，在指令地址字段中，没有指明第二操作数地址，而是规定累加寄存器 AC 作为第二操作数地址，AC 对单地址指令格式来说就是一个隐含地址
* 立即寻址
  * 特点：指令的地址字段指出的不是操作数的地址，而是操作数本身
  * 指令中直接给出了操作数，不需要通过访问内存来取数，因而指令执行时间很短
* 直接寻址
  * 特点：在指令格式的地址字段中，直接给出操作数在内存的地址 A
  * 指令字中的形式地址 A，就是操作数的有效地址 EA，通常把形式地址 A 又称为直接地址
  * 用 D 表示操作数，那么直接寻址的表达式为：D = (A)
* 间接寻址
  * 特点：形式地址 A 不是操作数 D 的真正地址，而是操作数地址的指示器，即：A 的内容才是操作数的有效地址
  * 有效地址 EA = (A)
* 寄存器寻址
  * 特点：操作数不放在内存中，而是放在通用寄存器中，D = R
  * 指令中给出的操作数地址不是内存的地址单元号，而是通用寄存器的编号
* 寄存器间接寻址
  * 特点：指令中指定的寄存器中的内容不是操作数，而是操作数的地址，即：EA = (R)
  * 指明的操作数是在内存中
* 偏移寻址

直接寻址和寄存器间接寻址的结合，有效地址 EA = A + (R)，要求指令中有两个地址字段，至少其中一个是显式的

* 相对寻址
  * 特点：把程序计数器 PC 的内容加上指令中给出的形式地址 A，进而形成操作数的有效地址，即：EA = A + (PC)
  * 形式地址 A 称为偏移量，其值可正可负，有效地址是当前指令地址（当前 PC 值）的一个上下范围的偏移，便于程序在内存中成块搬动
  * 相对寻址方式中，指令所提供的相对地址实质上是一种以下条指令在内存中首地址为基准位置的偏移量
* 基址寻址
  * 特点：将 CPU 中基址寄存器的内容加上指令中给定的形式地址 A，而形成操作数的有效地址，即 E = () + A
  * 基址寄存器的位数可以大于形式地址 A 的位数，从而可以扩大操作数的寻址范围
* 变址寻址
  * 特点：把 CPU 中某个变址寄存器的内容与偏移量 A 相加，形成操作数有效地址，即 EA = () + A
  * 形式地址 A 给出基准地址，给出偏移量，为重复操作的完成提供一种有效机制
* 段寻址
  * 微型机中采用了段寻址方式，其实质还是基址寻址，基地址就是 CPU 中的段寄存器
* 堆栈寻址
  * 堆栈有两种形式：寄存器堆栈和存储器堆栈
  * 存储原则：先进后出
  * 数据的存取都通过栈顶，需要一个隐式或显式的堆栈指示器（寄存器）
  * 堆栈指令：PUSH、POP

第五章

#### 简述机器指令和微指令的关系

1. 一条机器指令对应一个微程序，这个微程序是由若干条微指令序列组成的。即一条机器指令所完成的操作划分成若干条微指令来完成，由微指令进行解释和执行。
2. 从指令与微指令，程序与微程序，地址与微地址的一一对应关系来看，前者与内存储器有关，后者与控制存储器有关。
3. 每一个 CPU 周期对应一条微指令

#### 说明 CPU 中有哪些寄存器？它们的功能是什么？

* 数据缓冲寄存器（DR）
  * 暂时存放 ALU 的运算结果，或来自内存或来自 I/O 接口的一个数据字
  * 作为 ALU 运算结果和通用寄存器之间的缓冲
  * 补偿 CPU 和内存、外围设备之间在操作速度上的差别
  * 指令寄存器（IR）：保存当前正在执行的一条指令
* 程序计数器（PC）
  * PC 中存放的是下一条指令在内存中的地址
  * 程序执行前，程序的第一条指令所在的内存单元送入 PC
  * 顺序执行指令时，PC 自增
  * 遇到转移指令时，PC 的内容由转移指令来规定
  * 具有信息寄存和计数两种功能
* 数据地址寄存器（AR）
  * 保存当前 CPU 所访问的数据存储器地址
  * 采用电位-脉冲方式，即电位输入端对应数据信息位，脉冲输入端对应控制信号，在控制信号作用下，瞬时将信息打入寄存器
* 通用寄存器：当运算器需执行算术或逻辑运算时，为 ALU 提供一个工作区
* 程序状态字寄存器（PSWR）
  * 又称状态条件寄存器
  * 保存由算术运算指令和逻辑运算指令运行或测试结果建立的各种条件代码
  * 保存中断和系统工作状态等信息
  * 属于运算器的组成部分

#### RISC 机器具有什么优点，试简单论述

RISC 的三个基本要素

* 一个有限的简单的指令系统
* CPU 配备大量的通用寄存器
* 强调对指令流水线的优化

RISC 机器的特征

1. 使用等长指令，典型长度是 4 个字节（32 位）
2. 寻址方式少且简单，一般为 2、3 种，最多不超过 4 种，绝不出现存储器间接寻址方式
3. 只有取数指令和存数指令访问存储器。指令中最多出现 RS 型指令，绝不出现 SS 型指令
4. 指令集中的指令数目一般少于 100 种，指令格式一般少于 4 种
5. 指令功能简单，控制器多采用硬布线方式，以期更快的执行速度
6. 平均而言，所有指令的执行时间为一个处理时钟周期
7. 指令格式中用于指派整数寄存器的个数不少于 32 个，用于指派浮点数寄存器的个数不少于 16 个
8. 强调通用寄存器资源的优化使用
9. 支持指令流水并强调指令流水的优化使用
10. RISC 技术的复杂性在它的编译程序，因此软件系统开发时间比 CISC 机器长

#### CPU 的功能是什么？由什么组成？

中央处理器（Central Processing Unit）是计算机的核心部件，通常简称为 CPU，控制计算机自动完成取指令和执行指令任务

CPU 的功能

* 指令控制：程序的顺序控制
* 操作控制：产生各种操作信号（微操作信号）
* 时间控制：控制操作信号的有效时间（时序信号发生器）
* 数据加工：对数据进行算术运算和逻辑运算（ALU）

CPU 的基本组成

* 控制器：由程序计数器、指令寄存器、指令译码器、时序产生器（产生并发出计算机所需要的时序控制信号）和操作控制器（根据指令操作码和时序信号，产生各种操作控制信号，以便正确地建立数据通路）组成
  * 从指令 cache 中取出一条指令，并指出下一条指令在指令 cache 中的位置
  * 对指令进行译码或测试，并产生相应的操作控制信号，启动规定的动作
  * 指挥并控制 CPU、数据 cache 和输入/输出（I/O）设备之间数据流动的方向
* 运算器：由 ALU、通用寄存器、DR 和 PSWR 组成
* cache
* 芯片级总线

#### 什么是指令周期、机器周期和时钟周期？三者有何关系？

* 指令周期：CPU 从内存取出一条指令并执行完这条指令的时间总和
* CPU 周期：又称机器周期，是所有指令执行过程中的一个基准时间，通常取从内存读取一个指令字的最短时间
* 时钟周期：又称 T 周期或节拍脉冲，是处理操作的最基本单位，是机器主频的倒数，一个 CPU 周期包含个 T 周期

1 个指令周期 = 若干个 CPU 周期，1 个 CPU 周期 = 若干个 T 周期，每个指令周期内的机器周期数可以不等，每个机器周期内的时钟周期数也可以不等。

#### 简述 CPU 的不同控制方式

控制方式：控制不同操作序列时序信号的方法

* 同步控制方式：在任何情况下，已定的指令在执行时所需的 CPU 周期数和时钟周期数都固定不变
  * 完全同步控制方式：采用完全统一的、具有相同时间间隔和相同数目的节拍电位作为机器周期来运行各种不同的指令
  * 采用不定长机器周期：将大多数操作安排在一个较短的机器周期内完成，对某些时间较长的操作，则采取延长机器周期的方法来解决
  * 中央控制与局部控制结合：将大部分指令安排在固定的机器周期完成，对少数复杂指令采用另外的时序进行定时
* 异步控制方式：一般采用两条控制线，即“请求”线和“回答”线
  * 每条指令、每个操作控制信号需要多少时间就占用多少时间
  * 每条指令的指令周期可由多少不等的机器周期数组成
  * 用这种方式形成的操作控制序列没有固定的 CPU 周期数（节拍电位）或严格的时钟周期（节拍脉冲）与之同步
* 联合控制方式：同步控制和异步控制相结合的方式
  * 情况 1：大部分操作序列安排在固定的机器周期中，对某些时间难以确定的操作则以执行部件的“回答”信号作为本次操作的结束
  * 情况 2：机器周期的节拍脉冲数固定，但是各条指令周期的周期数不固定

#### 区分微命令、微操作、微指令和微程序

* 微命令：控制部件通过控制线向执行部件发出各种控制命令
* 微操作：执行部件接收微命令后所进行的操作
  * 微命令和微操作是一一对应的
  * 微命令是微操作的控制信号，微操作是微命令的操作过程
  * 微操作是执行部件中最基本的操作
  * 微操作具有互斥性和相容性
* 互斥性：不能同时或不能在同一个节拍内并行执行
* 相容性：能够同时或在同一个节拍内并行执行
* 微指令：在机器的一个 CPU 周期中，一组实现一定操作功能的微命令的组合，构成一条微指令
* 微程序：微指令序列

一个程序由多条机器指令组成，每条机器指令由多条微指令组成，微指令序列构成微程序

#### 简述硬布线控制器和微程序控制器的比较

微程序控制原理：将微操作控制信号按一定规则进行信息编码形成控制字（微指令），一条机器指令对应一段“程序”，该程序存放在控制存储器中，当机器运行时，一条又一条地读出这些微指令，从而产生全机所需要的各种操作控制信号，使相应部件执行所规定的操作。因为“程序”的执行结果是实现一条机器指令的功能，所以称为“指令的微程序”。

硬布线控制器的基本思想：把控制部件看作为产生专门固定时序控制信号的逻辑电路，逻辑电路以使用最少元件和取得最高操作速度为设计目标

1. 硬布线控制器和微程序控制器的相同之处：根据指令操作码和时序信号，产生各种控制信号，以便正确地建立各种数据通路，完成取指令和执行指令的控制
2. 硬布线控制器的优点：速度快
3. 硬布线控制器的主要缺点：一旦设计完成，不可能通过其他的修改添加新功能
4. 微程序控制的主要优点：具有规整性、灵活性、可维护性
5. 微程序控制的主要缺点：速度慢

#### 说明并比较微指令格式的种类

水平型微指令：一次能定义并执行多个并行操作微命令的微指令，格式为：控制字段 + 判别测试字段 + 下地址字段

按照控制字段的编码方法不同，水平型微指令分为三种

* 全水平型（不译码法）微指令
  * 在微指令的操作控制字段中每一个微命令都用一位信息表示，对应于一种微操作
  * 设计微指令时，选用或不选用某个微命令，只要将表示该微命令的相应位设置成 1 或 0
  * 优点：简单、直观、执行速度快，微命令的并行控制能力强，编制的微程序短
  * 缺点：微指令字比较长
* 字段译码法（编码表示法）水平型微指令
  * 把一组相斥性的微命令信号组成一个小组（即一个字段），通过小组（字段）译码器对每一个微命令信号进行译码，译码输出作为操作控制信号
  * 优点：使微指令字大大缩短
  * 缺点：由于增加了译码电路，使微程序的执行速度稍稍减慢
* 直接和译码相混合的水平型微指令
  * 把直接表示法与编码表示法混合使用，以便能综合考虑指令字长、灵活性、执行微程序速度等方面的要求
  * 在微指令中还可附设一个常数字段。该常数可作为操作数送入 ALU 运算，也可作为计数器初值用来控制微程序循环次数

垂直型微指令

* 在微指令中设置微操作码字段，采用微操作码编译法，由微操作码规定微指令的功能
* 其结构类似于机器指令的结构
* 实现一条机器指令的微程序要比水平型微指令编写的微程序长得多，它是采用较长的微程序结构去换取较短的微指令结构

水平型微指令与垂直型微指令的比较

1. 水平型微指令并行操作能力强，效率高，灵活性强，垂直型微指令则较差
2. 水平型微指令执行一条指令的时间短，垂直型微指令执行时间长
3. 由水平型微指令解释指令的微程序，有微指令字段较长而微程序短的特点。垂直型微指令则相反
4. 水平型微指令用户难以掌握，而垂直型微指令与指令比较相似，相对来说，比较容易掌握

#### 简述提高并行性的技术途径

* 时间并行：指时间重叠，让多个处理过程在时间上相互错开，轮流重叠地使用同一套硬件设备的各个部分，以加快硬件周转而赢得速度
* 空间并行：指资源重复
* 时间并行+空间并行：指时间重叠和资源重复的综合应用

#### 说明流水线中的主要问题和解决方法

流水线实际上是把一个功能部件分解成多个独立的子功能（一个任务也就分成了几个子任务，每个子任务由一个子功能部件完成），并依靠多个子功能部件并行工作来缩短所有任务的执行时间

* 资源相关：是指多条指令进入流水线后，在同一机器时钟周期内争用同一个功能部件所发生的冲突
  * 采用延迟 IF 法避开相关
  * 增设一个存储器，将指令和数据分别存放在两个存储器中
  * 采用多端口存储器结构
* 数据相关：由于多条指令的重叠处理，当后继指令所需的操作数刚好是前一指令的运算结果时，便发生数据相关冲突
  * 在流水 CPU 的运算器中设置若干运算结果缓冲寄存器，暂时保留运算结果，以便于后继指令直接使用，这称为“向前”或定向传送技术
* 控制相关：控制相关冲突是由转移指令引起的，会使流水线发生断流

延迟转移法

* 由编译程序重排指令序列来实现
* 基本思想：先执行再转移
* 在转移指令之后让已进入流水线的一条或几条没有数据相关和控制相关的有效指令先执行，而转移指令被延迟执行

转移预测法

* 用硬件方法来实现，依据指令过去的行为来预测将来的行为
* 通过使用转移取和顺序取两路指令预取队列器以及目标指令 cache，可将转移预测提前到取指阶段进行

### 第六章

#### 简述总线的基本概念和分类和特性

总线是构成计算机系统的互连机构，是多个系统功能部件之间进行数据传送的公共通路

一个单处理器系统中的总线，大致分为三类

* 内部总线：CPU 内部连接各寄存器及运算部件之间的总线
* 系统总线：CPU 同计算机系统的其他高速功能部件，如存储器、通道等互相连接的总线
* I/O 总线：中、低速 I/O 设备之间相互连接的总线

总线的特性

* 物理特性：总线的物理连接方式，包括总线的根数，总线的插头、插座的形状，引脚线的排列方式等
* 功能特性：描述总线中每一根线的功能
* 电气特性：定义每一根线上信号的传递方向及有效电平范围。送入 CPU 的信号叫输入信号（IN），从 CPU 发出的信号叫输出信号（OUT）
* 时间特性：定义了每根线在什么时间有效，规定了总线上各信号有效的时序关系

#### 比较单总线、多总线结构的性能特点

* 单总线结构是通过一组总线连接整个计算机系统的各大功能部件，即各大部件之间的所有的信息传送都通过这组总线
  * 优点是允许 I/O 设备之间或 I/O 设备与内存之间直接交换信息，只需 CPU 分配总线使用权，不需要 CPU 干预信息的交换，即总线资源是由各大共能部件分时共享的
  * 缺点是由于全部系统部件都连接在一组总线上，总线的负载很重，可能使其吞吐量达到饱和甚至不能胜任的程度，故多为小型机和微型机采用
* 多总线结构是通过桥、CPU 总线、系统总线和 I/O 总线彼此相连，各大部件的信息传送不是只通过系统总线
  * 高速、中速、低速设备连接到不同的总线上同时进行工作
  * 提高了总线的效率和吞吐量
  * 处理器结构的变化不影响高速总线

#### 简述总线集中式仲裁的分类和特点

* 链式查询方式
  * 优点：只用很少几根线就能按一定优先次序实现总线仲裁，判优方法简单，扩充设备容易
  * 缺点：对询问链的电路故障很敏感；查询链的优先级是固定的，如果优先级高的设备出现频繁的请求时，优先级较低的设备可能长期不能使用总线
* 计数器定时查询方式
  * 每次计数可以从 0 开始，也可以从上次的中止点开始
  * 计数器的初值也可用程序来设置，可以方便改变优先次序，这种灵活性以增加线数为代价
* 独立请求方式
  * 响应时间快，确定优先响应的设备所花费的时间少
  * 既可以预先固定，也可以通过程序来方便地改变优先次序，对优先次序的控制相当灵活
  * 可以用屏蔽（禁止）某个请求的办法，封锁来自无效设备的请求
  * 这种方式需增加的线数较多（N 个设备，需要 2N 根线），仲裁器

#### 区分总线的定时方式和总线数据传送模式

* 同步总线定时
  * 采用公共时钟，每个功能模块什么时候发送或接收信息都由统一时钟规定，传输频率较高
  * 同步定时适用于总线长度较短、各功能模块存取事件比较接近的情况
* 异步总线定时
  * 在异步定时协议中，后一事件出现在总线上的时刻取决于前一事件的出现，即建立在应答式或互锁机制基础上
  * 这种系统中，不需要统一的公共时钟信号，总线周期的长度是可变的
  * 异步时序的互锁关系
* 半同步总线定时
  * 在同步总线定时协定的基础上稍加改动，扩展为半同步总线定时
  * 整体上仍然采用同步操作方式，其总线周期是时钟周期的整数倍，增加一根联络信号线，由此信号决定是否需要增加时钟周期
  * 适应能力大大提升，现代的许多同步总线已扩展为半同步总线
* 周期分裂式总线定时
  * 将一个读周期分解成两个分离的传输子周期
    * 第一个子周期：主方发送地址和命令及有关信息后，立即和总线断开，供其它设备使用
    * 第二个子周期：被读的设备重新申请总线使用权后将数据通过总线发向请求数据的设备
  * 要求读数据的双方都是总线主方
  * 以硬件复杂度的提高换取总线性能的提升

总线数据传送模式

* 读、写操作
* 块传送操作
* 写后读、读修改写操作
* 广播、广集操作

### 第七章

### 第八章

#### CPU 管理外围设备有几种方式？总结每一种方式的特点。

1. 程序查询方式：CPU 的操作和外围设备的操作能够同步，且硬件结构比较简单，输入和输出控制和传输完全由 CPU 处理，降低了 CPU 的效率
2. 程序中断方式：一般适用于随机出现的服务，且一旦提出要求应立即进行，CPU 不需要对外设进行状态查询，节省了 CPU 的时间开销，但硬件结构稍复杂一些
3. 直接内存访问（DMA）方式：时间传送不需要 CPU 的中转而在内存和外设间直接传送，数据传送速度很高，传送速率仅收到内存访问时间的限制。需要更多硬件，适用于内存和高速外设之间大批数据交换的场合
4. 通道方式：可以实现对外设的统一管理和外设与内存之间的数据传送，完全将 CPU 从 I/O 控制工作中解放出来，大大提高了 CPU 的工作效率
5. 外围处理机方式：是通道方式的进一步发展，基本上独立于主机工作，结构更接近一般处理机

#### 简述查询方式和中断方式的主要异同点

* 两种方式都是以 CPU 为中心的控制方式，都需要 CPU 执行程序来进行 I/O 数据传送。
* 程序程序式控制简单，但系统效率很低，无法实现并行操作
* 中断式通过服务程序完成数据交换，实现了主机与外设的并行性

#### 比较说明中断方式与 DMA 方式的异同

1. 相同点：二者都由随机请求引起
2. 不同点
   * 中断方式通过执行处理程序进行处理，DMA 方式直接依靠硬件实现数据直传
   * 中断方式可处理复杂事件、控制中低速 I/O 操作，DMA 方式适用于简单的、高速的数据批量传送

#### CPU 响应中断应具备哪些条件？

1. CPU 接收到中断请求信号
2. CPU 允许中断
3. 一条指令执行完毕

#### 以可屏蔽中断为例，说明一次完整的中断过程主要包括哪些环节？

1. 中断请求
2. 中断判优
3. 中断响应
4. 中断服务
5. 中断返回

#### 程序中断方式的四个标志触发器的具体功能

* 准备就绪触发器（RD）：一旦设备做好依次数据的接收或发送，便发出一个设备动作完毕信号，使 RD 标志置位。在中断发送中，该标志用作为中断源触发器，简称中断触发器
* 允许中断触发器（EI）：可以通过软件设置来控制是否允许某设备发出中断请求
  * EI 为 1 时，某设备可以向 CPU 发出中断请求
  * EI 为 0 时，则不能向 CPU 发出中断请求，某中断源的中断请求被禁止
* 中断请求触发器（IR）：暂存中断请求线上由设备发出的中断请求信号。当 IR 标志位 1 时，表示设备已经发出了中断请求
* 中断屏蔽触发器（IM）：CPU 是否受理中断或批准中断的标志
  * IM 标志为 0，CPU 可以受理外界中断请求
  * IM 标志为 1，CPU 不受理外界中断请求

#### 中断服务程序入口地址的获取有哪些？

* 向量中断方式：由硬件在中断周期中完成查找中断源、中断排队与判优、获取中断服务程序的入口地址
* 查询中断方式：由软件查询中断源，获取中断服务程序的入口地址

#### 区分中断向量、中断向量表、中断向量地址和中断向量号的概念

* 中断向量：中断服务程序的入口地址
* 中断向量表：系统中所有的中断向量按顺序放在内存指定位置的一张表
* 中断向量地址：存放中断向量的单元地址
* 中断向量号：不同的中断类型指定不同的中断向量号，经过查询中断向量表可得到响应中断向量


# 计算机图形学

> 掌握计算机图形学的基本概念，理解光栅图形生成基本算法。理解三个常用直线生成算法，理解和掌握多边形的扫描转换、区域填充算法。掌握曲线的概念，曲线曲面的表示。掌握 Bezier 曲线、B 样条曲线、非均匀有理 B 样条曲线的定义和性质、算法。理解颜色的基本概念、三色学说、CIE 色度图、掌握常用的颜色模型，掌握光照相关知识，掌握几种基础光照模型，理解光线跟踪算法。熟悉 OpenGL 程序结构、基本几何元素、坐标变换和光照处理。

## 图形学概论

### 图像 vs 图形

| 图像            | 图形             |
| ------------- | -------------- |
| 简单，相机，真实的     | 复杂，由算法生成，是合成的  |
| 表示：用像素阵列表示    | 表示：几何属性 + 物理属性 |
| 像素有颜色值 (或灰度值) | 需要人来设计和构造      |

### 生成的图形

| 线框图   | 真实感图形 | 非真实感图形   |
| ----- | ----- | -------- |
| 用线条表示 | 侧重真实感 | 侧重艺术、风格化 |

### 帧缓存

作用：存储将要显示的图形信息和保存中间数据。

包括：颜色缓存和深度缓存等。

显存与 GPU 的关系，就像内存和 CPU 的关系一样。

### 图形学之父

I.E. Sutherland

## 二维集合变换

### 概念

坐标系不动，把一个图形通过平移、旋转、缩放、对称、错切变换变成新的图形。

1. 对简单图形作变换和组合，可以构成复杂图形，是造型的方法之一。
2. 三维变换的基础。

### 基本变换

#### 平移变换

![](https://picture.lanlance.cn/i/2023/12/25/65896f7c4d64e.png)

性质：只改变位置，不改变形状和大小。

#### 旋转变换

![](https://picture.lanlance.cn/i/2023/12/25/6589711ddb7ae.png)

性质：不改变大小、距离和角度，会改变朝向

#### 缩放变换

![](https://picture.lanlance.cn/i/2023/12/25/658983ce0cc11.png)

当 Sx == Sy 时为均匀缩放，反之为非均匀缩放。

性质：

![](https://picture.lanlance.cn/i/2023/12/25/658983e5d51b5.png)

#### 对称变换

关于 x 轴，y 轴，原点的对称变换是特殊的缩放变换。

![](https://picture.lanlance.cn/i/2023/12/25/658983f9b3b20.png)

#### 逆变换

正变换：从 P(x,y) 变到 P’(x’ ,y’)

逆变换：从 P’(x’ ,y’) 变回 P(x,y)

#### 复合变换

* 复合平移
* 复合旋转
* 先平移，再旋转，再缩放
* 先旋转，再平移，再缩放
* ...

性质：复合变换不满足可交换性

![](https://picture.lanlance.cn/i/2023/12/25/6589841611356.png)

### 关于参考点旋转

![](https://picture.lanlance.cn/i/2023/12/25/6589841eebbe2.png)

相对某个参考点 (xr,yr) 的旋转变换：

(1) 作平移

(2) 作相对原点的旋转

(3) 作反平移

![](https://picture.lanlance.cn/i/2023/12/25/658984272c36f.png)

### 矩阵乘向量

![](https://picture.lanlance.cn/i/2023/12/25/6589843273e98.png)

### 齐次坐标

把 n 维空间的点表示成 n + 1 维空间的点，称前者为普通坐标，后者为齐次坐标。

#### 齐次坐标不唯一

(1,3,0) 的齐次坐标是 (2,6,0,2)、(1.5,4.5,0,1.5) —— 不唯一。

#### 普通坐标与齐次坐标的关系

![](https://picture.lanlance.cn/i/2023/12/25/6589843fd0a88.png)

#### 规范化齐次坐标

h=1 时的齐次坐标成为规范化齐次坐标。

![](https://picture.lanlance.cn/i/2023/12/25/658984523180b.png)

#### 矩阵乘向量

![](https://picture.lanlance.cn/i/2023/12/25/6589845876d54.png)

## 三维图形变换

坐标系不动，点动。

特点：

1. 比 2D 增加了 z 坐标
2. 比 2D 更复杂、更真实

三维几何变换性质：

* 平移——改变位置
* 旋转——改变方向
* 缩放——改变大小和形状
* 对称
* 错切

### 平移变换

![](https://picture.lanlance.cn/i/2023/12/25/65898632cded3.png)

只改变位置，不改变大小和形状。

### 旋转变换

#### 绕三个坐标轴的旋转

绕 z 轴旋转：约定人眼在 z 轴正方向，看向原点，逆时针为正方向

#### 绕三个坐标轴正方向旋转

![](https://picture.lanlance.cn/i/2023/12/25/6589876f90114.png)

#### 绕任意轴的旋转

eg.

![](https://picture.lanlance.cn/i/2023/12/25/658987e3adf0c.png)

### 投影变换

数学上：从 n 维空间到 k(\<n) 维空间的变换。 CG：从 3D 空间到 2D 平面的变换。

物体在 3D 空间，屏幕在 2D 平面，必须作投影变换。投影变换是 3D 图形学的核心。

#### 基本概念

![](https://picture.lanlance.cn/i/2023/12/25/6589897576820.png)

投影中心：相当于人眼或相机，也称视点或观察点 投影线：相当于光线 投影面：相当于成像平面，位于 COP 和物体之间 投影线与投影面相交，在投影面上的像就是物体的投影。

#### 光学成像 vs 虚拟成像

![](https://picture.lanlance.cn/i/2023/12/26/658a700da264a.png)

#### 透视投影 vs 平行投影

![](https://picture.lanlance.cn/i/2023/12/26/658a72005a81d.png)

透视投影：COP 到投影面的距离有限 平行投影：COP 到投影面的距离无限

平行投影是透视投影的极限

#### 平行投影

![](https://picture.lanlance.cn/i/2023/12/26/658a72c460024.png)

类比太阳光

**三视图**

![](https://picture.lanlance.cn/i/2023/12/26/658a7301aa792.png)

性质：保持距离和角度不变，适用于建筑和施工图纸。

**三视图的变换公式**

![](https://picture.lanlance.cn/i/2023/12/26/658a73ea168e0.png)

**正轴测投影**

![](https://picture.lanlance.cn/i/2023/12/26/658a73a401bdc.png)

**缺点**

物体投影的大小与物体离投影面的距离无关，与人的视觉不符。

#### 透视投影

符合人的视觉特点，远小近大，更真实

性质：平行于投影面的平行线投影后仍然平行。不平行于投影面的平行线投影后汇聚于一点。

**透视投影变换公式**

![](https://picture.lanlance.cn/i/2023/12/26/658a747725a40.png)

### 坐标系

#### 建模坐标系

1. 三维坐标系，坐标在 R 上取值
2. 目的：用来定义单个物体，比如球体，长方体，圆柱体。。。
3. 每个物体都可以有自己的建模坐标系。MC 是局部坐标系，如果物体改变，MC 也随之改变。

#### 世界坐标系

1. 三维坐标系，坐标在 R 上取值
2. 目的：定义所有对象，包括物体，观察者的位置和视线、投影平面等。
3. 是全局坐标系，其它坐标系都参照 WC 来定义。

#### 观察坐标系

1. 目的：从观察者的角度描述 WC 中的所有物体。
2. 类比照相：选择拍摄的位置和方向。
3. 对同一场景，在不同视点处的观察结果不同。

#### 投影面坐标系

![](https://picture.lanlance.cn/i/2023/12/26/658a77594ad9b.png)

#### 设备坐标系

1. 设备自带的坐标系。设备不同，设备坐标系也不同。
2. 二维坐标系，取离散值，比如 1024 \* 768，1920 \* 1080。

#### 规范化设备坐标系

1. 二维坐标系，在 0≤X≤1，0≤Y≤1 取连续值。
2. 目的：为了解决应用程序的可移植性的问题，使图形处理独立于具体的输出设备。
3. 虚拟的设备坐标系。

## 多边形的扫描转换

把顶点表示转换为点阵表示

本质：给定多边形的顶点序列，确定哪些像素在多边形内部。

### 多边形的表示

| 顶点表示                        | 点阵表示                           |
| --------------------------- | ------------------------------ |
| 用顶点序列表示。                    | 用多边形内部的像素表示。                   |
| 优点：几何意义明显，直观，存储空间少，便于作几何变换。 | 优点：适用于面着色。                     |
| 缺点：不能直接进行面着色。               | 缺点：失去顶点、边界等几何信息，存储空间多，不便作几何变换。 |

### 点的包含性检验

#### 转角法

![](https://picture.lanlance.cn/i/2023/12/26/658aa39e06eb7.png)

缺点：逐点判别，计算夹角的大小和方向

#### 射线法

![](https://picture.lanlance.cn/i/2023/12/26/658aa3c6296e4.png)

看奇偶：如果交点个数是偶数（包括 0），则点在外面；否则在里面。

缺点：逐点判别，需要判断一条射线和所有边是否有交点

### 扫描线填充算法

1．求所有顶点的 MIN 和 MAX——确定多边形覆盖的扫描线条数 2．从 MIN 到 MAX，每次用一条扫描线进行填充

![](https://picture.lanlance.cn/i/2023/12/26/658aa4f008225.png)

1. 求交：依次计算该扫描线与各边的交点；
2. 排序：把所有交点按 x 值递增排序 (比如冒泡)；
3. 配对：第 1 与第 2，第 3 与第 4，每对交点确定一个小区间；
4. 填色：把小区间内的像素赋予颜色。

#### 如何避免不必要的求交

![](https://picture.lanlance.cn/i/2023/12/26/658aa5e7b3f1c.png)

#### 增量法求交点坐标

![](https://picture.lanlance.cn/i/2023/12/26/658aa94a4f868.png)

#### 扫描线的连贯性

如果没有新边加入，排序不变，无须调整

如果有新边 (第一次与扫描线相交的边) 加入，可用插入排序

#### 交点为顶点的配对

![](https://picture.lanlance.cn/i/2023/12/26/658ab8c02ba49.png)

顶点分三种：极小点，极大点，其它点。

![](https://picture.lanlance.cn/i/2023/12/26/658ab8e10f08e.png)

#### 避免填充扩大化

![](https://picture.lanlance.cn/i/2023/12/26/658ab97a9b19b.png)

### 活性边表

活性边：对一条扫描线，和扫描线有交点的边。 活性边表：对一条扫描线，把所有活性边按 x 坐标递增的顺序构成一个链表。 链表结点 = data 域 + 指针域。

![](https://picture.lanlance.cn/i/2023/12/26/658abc63a2901.png)

### 新边表

对每条扫描线建立表，用来存放与该扫描线第一次相交的边 (新边) 结点结构和活性边表相同。

![](https://picture.lanlance.cn/i/2023/12/26/658abdc210d68.png)

### 区域填充

区域：连通的像素集合。

#### 区域表示

![](https://picture.lanlance.cn/i/2023/12/26/658abf134a80f.png)

#### 区域分类

![](https://picture.lanlance.cn/i/2023/12/26/658abf2cb930c.png)

### 字符

包括字母、数字、汉字等。是特殊的图形，用于图形的注释说明等。

#### 字符编码

美国：

* 信息交换用标准代码集 (American Standard Code for Information Interchange)
* 127 个字符，8 位编码

中国：

* 汉字编码的国家标准字符集：GB 2312－80。
* 区位码：对 6763 个常用汉字，符号等编码，94 个区，94 个位，

用 94 \* 94 矩阵表示。

#### 字符表示

**点阵型**

每个字符用一个点阵表示，1 表示笔画经过，0 表示不经过

![](https://picture.lanlance.cn/i/2023/12/26/658abff293697.png)

缺点：

1. 字符有多种属性——数据量很大
2. 作变换 (比如旋转) 困难（需要操作每个像素）

压缩技术：

黑白段压缩、部件压缩、轮廓字形压缩

轮廓字形：

用直线、2/3 次 Bezier 曲线描述轮廓线，轮廓线构成区域的边界。用填充算法生成字符的点阵。优点：压缩比大，能保证字符质量。

**矢量型**

不记录点阵，而是记录字符的笔画。

把字符表示成点的序列，相邻两点构成一个矢量，字符的形状由矢量序列表示。

优点：存储空间小，变换方便，只需对端点坐标作变换

![](https://picture.lanlance.cn/i/2023/12/26/658ac0fd0610c.png)

## 基本图形生成算法

### 什么是生成

CG 是研究如何用计算机表示、生成、处理和显示图形的学科。

扫描转换：把图形数据转换成像素。

本质：给定图形，在屏幕上确定最佳逼近图形的像素。

### 直线的表示方法

![](https://picture.lanlance.cn/i/2023/12/26/658ac1c3aa6ca.png)

### 点和直线的位置关系

![](https://picture.lanlance.cn/i/2023/12/26/658ac220ec0b8.png)

#### 判别式 & 判别规则

![](https://picture.lanlance.cn/i/2023/12/26/658ac23988959.png)

### 直线的扫描转换

数学：直线是理想 (绝对直) 的，由无数个点构成，没有宽度。

计算机：有限个点，有宽度

本质：用有限的像素逼近理想直线

#### 直接计算法

![](https://picture.lanlance.cn/i/2023/12/26/658ac2781786e.png)

#### DDA 算法

![](https://picture.lanlance.cn/i/2023/12/26/658ac33dbdf59.png)

优点：增量算法

缺点：k、y 是 float，需要作浮点乘法，对 y 作舍入，不利于硬件实现

![](https://picture.lanlance.cn/i/2023/12/26/658ac73ee9b2e.png)

#### 中点画线法

![](https://picture.lanlance.cn/i/2023/12/26/658ac88570194.png)

**判别式与判别规则**

![](https://picture.lanlance.cn/i/2023/12/26/658ac8ac833fd.png)

**判别式改进 —— 用增量法计算 d**

![](https://picture.lanlance.cn/i/2023/12/26/658ac99356f19.png)

![](https://picture.lanlance.cn/i/2023/12/26/658ac9ab517e7.png)

**判别式改进 —— 用 2d**

![](https://picture.lanlance.cn/i/2023/12/26/658ac9d5ec8a6.png)

## 裁剪

裁剪：给定窗口，消除窗口以外的部分。

为什么要作裁剪：场景太大，需要显示的只是整个场景的一部分。

流程：先裁剪，再光栅化，避免无用的计算。

### 裁剪分类

1. 裁剪窗口的维数

* 2D、3D

2. 裁剪窗口的形状

* 规则 (矩形、圆)
* 不规则 (多边形、多面体)

3. 裁剪对象

* 点、线、多边形、字符

### 点裁剪

![](https://picture.lanlance.cn/i/2023/12/26/658accc01c742.png)

### 线裁剪

#### Cohen-Sutherland 算法

![](https://picture.lanlance.cn/i/2023/12/26/658ace4ab037f.png)

!\[\[Pasted image 20231226210109.png]]

![](https://picture.lanlance.cn/i/2023/12/26/658acea19d910.png)

![](https://picture.lanlance.cn/i/2023/12/26/658aceb9c8f6e.png)

![](https://picture.lanlance.cn/i/2023/12/26/658acfff8db4e.png)

优点

* 解决前两种很高效，避免了不必要的求交
* 适合大窗口和小窗口

缺点

* 求交涉及浮点运算
* 最坏的情况需要作四次裁剪

## 反走样

用离散量 (像素) 表示连续量 (图形) 引起的失真。

走样是数字化的必然产物。减少或消除走样称为反走样。

### 常见的走样

* 锯齿形或阶梯形
* 小物体、细节在被放大、丢弃、失真
* 小物体在动画中时隐时现，产生跳跃或闪烁

### 反走样技术

* 硬件：提高分辨率

增加分辨率，减轻锯齿效应，原来不能显示的细节可以显示。

4 倍帧缓存容量和 2 倍扫描时间——成本太高

* 算法

利用亮度过渡淡化锯齿效应。

### 区域采样

![](https://picture.lanlance.cn/i/2023/12/26/658ad1dd693d8.png)

![](https://picture.lanlance.cn/i/2023/12/26/658ad1f2c0193.png)

## 消隐

消除不可见的点线面，称作消隐。

在 3D 中，人从某视点观察，不可能看到一个物体的全部表面，只能看到部分的点线面。若观察多个物体，物体之间还存在彼此遮挡。

![](https://picture.lanlance.cn/i/2023/12/26/658ad2773e5c6.png)

### 消隐分类

1. 按消隐对象

* 线消隐：针对线框图，消除边
* 面消隐：针对表面图，消除面

2. 按算法所在空间分类 (Southerland)

* 物体空间 (object space)
* 图像空间 (image space)
* 二者结合

### 影响因素

几乎所有算法都会涉及远近排序

物体离视点越远，越可能被遮挡 物体离视点越近，越可能被看见——近的遮挡远的

连贯性 a. 扫描线的连贯性 b. 物体的连贯性

### 物体空间方法

![](https://picture.lanlance.cn/i/2023/12/26/658ad4c8d8e6d.png)

```c
for (每个物体)
{
把它与其它物体比较，确定可见的部分；
用适当的颜色绘制可见部分；
}
```

### 图像空间方法

1. 在 2D 空间 (屏幕坐标系) 进行
2. 处理对象：投影后的像素
3. 复杂度：n 个对象，m 个像素，需做 m \* n 次求交。

```c
for(屏幕上的每个像素)
{
比较深度，确定哪个多边形在该像素可见（离视点最近）；
绘制该像素；
}
```

相结合：先在物体空间删掉一些不可见的面，再在图像空间生成消隐图。

### 面消隐

#### 画家算法

与油画家作画类似，由远及近，远景覆盖背景。

1. 把物体按远近（或深度）排序。
2. 按由远到近的顺序依次绘制各个面（后者覆盖前者）。

#### Z-buffer 算法（深度缓存算法）

图像空间方法

![](https://picture.lanlance.cn/i/2023/12/27/658bc66735500.png)

**算法步骤**

![](https://picture.lanlance.cn/i/2023/12/27/658bc6b5ebd20.png)

**z 值计算**

增量算法

![](https://picture.lanlance.cn/i/2023/12/27/658bc71b70eea.png)

**优缺点**

优点

* 思想简单
* 物体不用排序
* z 值可用增量法求出

缺点

* 占用空间大，需要两个缓存。
* 本质上是逐点，没有充分利用连贯性。

#### 区间扫描线算法

利用扫描线的连贯性，以区间为单位确定可见区域。

对每条扫描线，计算它和所有多边形投影的交点，按交点排序，形成若干小区间。

**算法步骤**

1. 没有多边形覆盖 ——用背景色显示 。
2. 只有一个多边形覆盖——用该多边形的颜色显示 。
3. 两个或以上覆盖——比较深度，用可见多边形的颜色显示 。

**算法特点**

1. 一次可以确定一个区间
2. 不需要 z 缓存

#### Wornack（区域细分）算法

**特点**

* 图像空间方法
* 思想：分而治之
* 区域连贯性 (对比扫描线连贯性)

如果一点可见，则周围的点也可见。 如果一点不可见，则周围的点也不可见。

**多边形和窗口的关系**

* 不相交 —— 分离
* 内含
* 包围
* 相交

定义简单窗口：

1. 所有多边形都和窗口分离——窗口为背景色
2. 窗口中只包含一个多边形
3. 只有一个多边形包围窗口
4. 只有一个多边形和窗口相交
5. 有多个和窗口相交，但是，有一个包围窗口，而且离视点最近

**算法步骤**

1. 把所有多边形投影到窗口。
2. 检查窗口是否简单。如果简单，结束。
3. 否则，把窗口一分为四，检查窗口是否简单。
4. 终止条件：所有窗口简单，或者窗口只含一个像素。

#### 光线投射算法

![](https://picture.lanlance.cn/i/2023/12/27/658bd0c4766e1.png)

a. 视点和像素形成射线。 b. 射线与所有物体表面求交。 c. 取离视点最近的交点。

**算法过程**

```c
for (屏幕上的每一像素(u,v) {
	形成通过该像素(u,v)的射线；
	for (每个多边形) {
		射线与该物体表面求交；
		if 存在交点
			以最近交点的颜色显示像素(u,v)
		else
			以背景色显示像素(u,v)
	}
}
```

**光线投射法 vs Z-buffer 算法**

1. 内外循环顺序相反，所以算法复杂度类似。

Z-buffer：每次取一个多边形，作投影，对投影点计算深度值，将其与已有的深度值作比较，取最近的那个。

光线投射法：每次取一个像素，沿光线路径计算所有面的深度，取最近的那个。

2. 光线投射法不需要 Z 缓存。

## 曲线与曲面

### 曲线/曲面的分类

1. 规则

• 可用初等解析函数表示 • 直线，圆弧，椭圆，平面，球面，椭球面

2. 自由

• 无法用初等解析函数表示 • 汽车外形，飞机外形

3. 随机

• 处处连续，处处不可导 • 地图边界，海岸线，水波，超声

### 曲线和曲面的表示

#### 平面曲线的表示

![](https://picture.lanlance.cn/i/2023/12/27/658bd2de765f3.png)

点动成线：曲线可看成动点运动的轨迹，t 表示时间，角度等。直线 \[0, 1] 弯曲的结果。

#### 规范化参数区间 \[0, 1]

若 t 的区间是 \[a, b]，转换为 \[0, 1]

t’ = (t - a) / (b - a)

则 t’ => \[0,1]。

#### 第一象限内的单位圆弧

![](https://picture.lanlance.cn/i/2023/12/27/658bd3c75840c.png)

#### 直线

![](https://picture.lanlance.cn/i/2023/12/27/658bd3d7017f1.png)

#### 三类表示优缺点

* 显式表示

缺点：

1. 不能表示竖线
2. 不能表示多值或封闭曲线（如整圆）

* 隐式表示

优点：

1. 能表示任意直线、整圆
2. 将某点坐标代入隐式，可判断该点在曲线的哪一侧

缺点：

1. 由 x 难计算对应的 y

* 参数表示

优点： ![](https://picture.lanlance.cn/i/2023/12/27/658bd46358e53.png)

![](https://picture.lanlance.cn/i/2023/12/27/658bd47bba8ee.png)

### 空间曲线的表示

![](https://picture.lanlance.cn/i/2023/12/27/658bd4920b73d.png)

![](https://picture.lanlance.cn/i/2023/12/27/658bd4a00c222.png)

![](https://picture.lanlance.cn/i/2023/12/27/658bd4b123361.png)

### 参数曲面

![](https://picture.lanlance.cn/i/2023/12/27/658be1bf49912.png)

![](https://picture.lanlance.cn/i/2023/12/27/658be19387539.png)

#### 上半单位球面

![](https://picture.lanlance.cn/i/2023/12/27/658be1e9e5c2d.png)

![](https://picture.lanlance.cn/i/2023/12/27/658be20c9f22b.png)

### 平面法向量

![](https://picture.lanlance.cn/i/2023/12/27/658be23ce219d.png)

## 插值与拟合

### 插值

给定 n+1 个有序的点 Pk，k=0, 1, …, n，构造一条曲线顺序通过它们，称为对这组数据点进行插值，曲线称为插值曲线。

![](https://picture.lanlance.cn/i/2023/12/27/658bd61b7082e.png)

#### 线性插值

![](https://picture.lanlance.cn/i/2023/12/27/658bd660e044f.png)

#### 抛物线插值

![](https://picture.lanlance.cn/i/2023/12/27/658bd6772013a.png)

n+1 个点，n 次插值

![](https://picture.lanlance.cn/i/2023/12/27/658bd696ca54e.png)

### 拟合

给定一组数据点，构造一条曲线，使它在某种意义下最接近这些数据点 (未必通过)，该曲线称为拟合曲线。

![](https://picture.lanlance.cn/i/2023/12/27/658bd6b84f0eb.png)

#### 3 次参数曲线

![](https://picture.lanlance.cn/i/2023/12/27/658bd74e1eb9a.png)

为什么选 3 次

* 次数太低，不灵活。
* 次数太高，计算量大，光滑性变差。
* 可以通过若干条 3 次曲线拼接得到复杂曲线。

### 连续性

复杂曲线通常由若干曲线段拼接而成，要保证曲线段之间光滑过渡的问题。

#### 参数连续性

![](https://picture.lanlance.cn/i/2023/12/27/658bd80d91def.png)

例子

![](https://picture.lanlance.cn/i/2023/12/27/658bdfde3fbd3.png)

#### 几何连续性

![](https://picture.lanlance.cn/i/2023/12/27/658be012d6108.png)

![](https://picture.lanlance.cn/i/2023/12/27/658be0282e878.png)

![](https://picture.lanlance.cn/i/2023/12/27/658be032ccdcf.png)

## Bezier 曲线和曲面

![](https://picture.lanlance.cn/i/2023/12/27/658be3176e294.png)

![](https://picture.lanlance.cn/i/2023/12/27/658be425648d2.png)

### Bezier 曲线

![](https://picture.lanlance.cn/i/2023/12/27/658be45516902.png)

![](https://picture.lanlance.cn/i/2023/12/27/658be48491a03.png)

#### 一次 Bezier 曲线

![](https://picture.lanlance.cn/i/2023/12/27/658be4a1e882f.png)

#### 二次 Bezier 曲线

![](https://picture.lanlance.cn/i/2023/12/27/658be8fc91db1.png)

#### 三次 Bezier 曲线

![](https://picture.lanlance.cn/i/2023/12/27/658be914711cd.png)

### Bernstein 基函数的性质

![](https://picture.lanlance.cn/i/2023/12/27/658be94df3253.png)

![](https://picture.lanlance.cn/i/2023/12/27/658be9583cbb2.png)

![](https://picture.lanlance.cn/i/2023/12/27/658be9633e046.png)

![](https://picture.lanlance.cn/i/2023/12/27/658be96e552ce.png)

![](https://picture.lanlance.cn/i/2023/12/27/658be97882d5a.png)

#### 端点性质

曲线经过第一个和最后一个控制点（利用基函数的端点性质）

![](https://picture.lanlance.cn/i/2023/12/27/658be9dd80b71.png)

#### 导数性质

![](https://picture.lanlance.cn/i/2023/12/27/658bea05db391.png)

#### 二阶导性质

![](https://picture.lanlance.cn/i/2023/12/27/658bea1a7b03c.png)

#### 凸包性

![](https://picture.lanlance.cn/i/2023/12/27/658bea379e6cf.png)

#### 变差缩小性

任意直线与曲线的交点个数小于等于该直线与多边形交点的个数。

Bezier 曲线比控制多边形波动更小。

#### 对称性

![](https://picture.lanlance.cn/i/2023/12/27/658bea91aaa96.png)

### Bezier 曲线的递推

![](https://picture.lanlance.cn/i/2023/12/27/658bebbe40971.png)

### Bezier 曲线的拼接

![](https://picture.lanlance.cn/i/2023/12/27/658bebf01ea3c.png)

![](https://picture.lanlance.cn/i/2023/12/27/658bed9185356.png)

### Bezier 曲线的性质

没有局部可控性：如果改变某一个控制点的位置，整条曲线都会变化。

在 (0,1) 上，每个基函数均>0。

### Bezier 曲面

![](https://picture.lanlance.cn/i/2023/12/27/658bec3ba5da7.png)

![](https://picture.lanlance.cn/i/2023/12/27/658bec45337de.png)

## 真实感图形学

### 颜色

颜色是视觉系统对可见光的感知结果。

### 光

光是一种电磁波。

![](https://picture.lanlance.cn/i/2023/12/27/658bf17777fd7.png)

大多数彩色光不是单一波长的，而是由许多不同波长的光组合成的。

缺点：

1. 用光谱能量分布定义颜色非常复杂，难以理解。
2. 因为异谱同色，所以对人没有必要。
3. 计算机难以处理。

光谱和颜色是多对一的，不同分布的光产生的颜色感觉是一样的，两种光的分布不同但是颜色相同的现象称为异谱同色。

## 颜色模型

表示颜色的一种数学方法。颜色模型不是唯一的。

* 存储（R,G,B 值）
* 减少数据量（压缩）
* 满足显示系统的要求
* 满足印刷或打印的要求
* 方便用户理解和选择颜色

没有任何一个颜色模型能解释有关颜色的所有现象。只能近似地解释。

### 有源物体

能发出光波的物体称为有源物体，其颜色由它发出的光波决定。

### 无源物体

不发出光波的物体称为无源物体。其颜色由它吸收其它颜色的光后反射的光波决定。

定义物体的颜色：白光照射在它上面时显示的颜色。

### RGB 模型

常见的各种颜色光，都可以由红 (R)、绿 (G)、蓝 (B) 三种颜色光按不同比例混合而成。

![](https://picture.lanlance.cn/i/2023/12/27/658bf298cc6d2.png)

![](https://picture.lanlance.cn/i/2023/12/27/658bf2b6691f4.png)

#### 补色

![](https://picture.lanlance.cn/i/2023/12/27/658bf2c965580.png)

#### 数字图像表示

**黑白图像**

![](https://picture.lanlance.cn/i/2023/12/27/658bf30cedd77.png)

**8-bit 灰度图像**

只有明暗不同，没有色彩。8bit 灰度图。 共 256 种亮度

![](https://picture.lanlance.cn/i/2023/12/27/658bf3343873b.png)

**彩色图像**

![](https://picture.lanlance.cn/i/2023/12/27/658bf34530012.png)

**24-bit 彩色图像**

通常用 8bit 表示每个颜色分量。

![](https://picture.lanlance.cn/i/2023/12/27/658bf3682a667.png)

#### 优缺点

优点：

1. RGB 可以生成很多的颜色
2. 适合硬件（图像生成）（e.g., 显示器、相机、扫描仪）

缺点：

1. 不直观（用户描述颜色不方便）
2. 很难编辑（视觉对颜色的感知是非线性的）

### CMY（减色）模型

用青 (cyan)、品红 (magenta) 和黄 (yellow) 颜料按一定比例混合得到颜色的方法。

![](https://picture.lanlance.cn/i/2023/12/27/658beee57641e.png)

颜色不是来自光线的叠加，而是光线照射到颜料以后，被颜料吸收一部分后反射剩余的光线，称为 CMY 减色模型 (印刷模型)。

#### CMY 模型的表示

![](https://picture.lanlance.cn/i/2023/12/27/658bef168e87f.png)

![](https://picture.lanlance.cn/i/2023/12/27/658bef354e466.png)

![](https://picture.lanlance.cn/i/2023/12/27/658bef432c148.png)

### CMYK 模型

1. 油墨包含杂质，实际上产生一种深灰色
2. 直接加入黑色更经济

加入一种黑色 (K) ——四色打印

优点

1. CMY 可以生成很多颜色
2. 适用于印刷或打印

缺点

1. 颜色的生成方式不直观
2. 难以编辑（视觉对颜色的感知是非线性的）

### HSV 模型

* 色调 (Hue, H)：即颜色。反映颜色的基本特征。色调是最容易把颜色区分开的属性。由光的主波长决定
* 饱和度 (Saturation)：颜色的纯正 (鲜艳) 程度。单一波长的光是完全饱和的。与掺入的白光有关：掺入白光，色调不变但饱和度降低。
* 亮度 (Value)：主观亮度，人眼感受到的颜色光的强度。某一颜色的光，亮度很弱，趋于黑色；亮度很强，趋于白色。

优点

1. 可以生成很多颜色
2. 符合人的主观感受

缺点

1. 与设备相关

### 总结

1. RGB，显示设备
2. CMY，打印或印刷
3. HSV，人

## 光照模型

根据光学定律，建立数学模型，计算物体表面每个点的颜色 (或亮度)。

### 光源模型要素

1. 光源

* 形状 (点、线、面、体)
* 位置（x,y,z）
* 发光方向（角度）
* 亮度或颜色 (光谱)

2. 物体

* 形状、表面朝向、材质、颜色、透明性

3. 视点

### 点光源

发光体很小或者相距很远，可视为点光源。

### 光强度衰减

设离光源距离为 d。光强度和距离的平方成反比。

![](https://picture.lanlance.cn/i/2023/12/27/658bf48b757a4.png)

### 局部光照模型 vs 全局光照模型

| 局部光照模型            | 全局光照模型                     |
| ----------------- | -------------------------- |
| 只考虑光源直接照射。        | 不仅考虑光源直接照射，也考虑间接照射。        |
| 目的：模拟连续明暗色调、镜面高光。 | 目的：模拟多个物体之间的反射、透射、物体交相辉映等。 |

### 简单光照模型

![](https://picture.lanlance.cn/i/2023/12/27/658bf57f2a56c.png)

### Phong 模型

反射光=环境光的反射光 + 漫反射光 + 镜面反射光

![](https://picture.lanlance.cn/i/2023/12/27/658bf5a5a6dd9.png)

* 真实感图形学中第一个有影响的光照模型。
* 生成图像的真实感较好。
* 是一个经验模型。
* 环境光设为常量，没有考虑物体间相互的反射光。
* 物体像塑料，不生动，无质感

### 环境光

#### 概念

* 又称为背景光 (background light)、泛光。
* 光源不能直接照射的地方，依然有一定的亮度。
* 看成光源间接对物体的影响，是光在物体和环境之间多次反射，最终达到平衡后，弥漫在整个空间。

只考虑环境光，物体没有立体感。类比黑夜里看东西，同一物体的不同区域无差异，只有位置、大小、轮廓。

#### 特点

* 来自周围各个方向，均匀地向各个方向反射。
* 强度分布均匀，任何地方都一样，看成常数。
* 和物体的朝向、观察方向无关。
* 比如透过厚厚云层的太阳光，无影灯。

#### 计算

![](https://picture.lanlance.cn/i/2023/12/27/658bf60a7fcb8.png)

### 漫反射

发生在粗糙表面，比如墙面。

特点：漫反射光均匀地向各个方向反射

环境光 + 漫反射，颜色 (亮度) 有过渡，但无光泽。

#### Lambert 余弦定律

与入射光的亮度、入射方向、材质有关。

![](https://picture.lanlance.cn/i/2023/12/27/658bf6b55c8bd.png)

入射光线的垂直分量才对照明有作用。

#### 漫反射光强计算

设 L，N 为单位化向量。

![](https://picture.lanlance.cn/i/2023/12/27/658bf6fe42c67.png)

### 镜面反射

发生在有光泽的表面，如擦亮的金属等。

![](https://picture.lanlance.cn/i/2023/12/27/658bf73b318d2.png)

只有当人眼恰好位于反射方向，才能感知亮度；否则不能。

#### 高光

如果物体表面很光滑，且人眼在反射光线附近，那么反射光的大部分到达人眼，形成很亮的光斑。比如额头。

1. 物体表面越光滑，高光区越小，越亮。
2. 光斑的颜色由入射光颜色决定，不取决于物体的颜色。

#### 非理想镜面反射

![](https://picture.lanlance.cn/i/2023/12/27/658bf78b55f25.png)

#### 高光指数 n

![](https://picture.lanlance.cn/i/2023/12/27/658c02099d237.png)

#### Phong 镜面反射模型

![](https://picture.lanlance.cn/i/2023/12/27/658c069ea2395.png)

环境光 + 漫反射 + 镜面反射

#### 计算反射方向 R

![](https://picture.lanlance.cn/i/2023/12/27/658c06c12c234.png)

### Blinn-Phong 模型

对物体表面每个点 P，都需要计算 (L,N) 和 (V,R)。为了减少计算量，1977 年，Blinn 改进 Phong 模型。

![](https://picture.lanlance.cn/i/2023/12/27/658c06f8b4113.png)

![](https://picture.lanlance.cn/i/2023/12/27/658c0702ef4f6.png)

## 明暗处理

#### 平面绘制

1. 求多边形的法向 N
2. 任取一点 P，调用 Phong 模型计算 P 的亮度 I
3. 把 I 赋给多边形投影覆盖区域的所有像素

![](https://picture.lanlance.cn/i/2023/12/27/658c08a49645b.png)

1. 一个多边形内部所有点亮度相同
2. 相邻的多边形亮度有跳跃

缺点：马赫带效应

马赫带效应 (Mach band effect)，1868 年奥地利物理学家马赫发现。马赫带效应不是物理现象，而是心理现象。 当观察两块亮度不同的区域时，边界处的亮度对比会加强，使轮廓特别明显。

解决方法：

1. 用更多的多边形逼近曲面，使得亮度跳跃小于人眼的分辨力 —— 多边形增多，计算量增大
2. 光滑着色 —— 不增加多边形，让亮度光滑过渡。

### 双线性亮度插值（Gouraud Shader）

![](https://picture.lanlance.cn/i/2023/12/27/658c0929ed86d.png)

![](https://picture.lanlance.cn/i/2023/12/27/658c097708033.png)

![](https://picture.lanlance.cn/i/2023/12/27/658c0982d9040.png)

### Phong 明暗处理

![](https://picture.lanlance.cn/i/2023/12/27/658c0a081336c.png)

优点：

* vs Gourand——相邻多边形之间的亮度过渡更自然。
* 能较好地模拟高光。

缺点：

* 计算量比 Gouraud 要大。

### 增量算法

优点：

* vs 平面着色——生成的图形真实感较好。
* vs Phong——快速，只需计算顶点的亮度，其余点通过插值得到。

缺点：

* 马赫带效应依然存在。
* 能比较好地模拟漫反射表面，但不能正确模拟高光。

## 纹理

光照模型生成的图形，表面太光滑，颜色单一，显得不真实。

贴纹理：为物体表面添加纹理的技术。

### 纹理分类

根据表现形式

1. 颜色纹理——光滑表面

比如刨光的木材表面的木纹、墙壁表面的装饰图案。 通过改变物体表面的颜色或亮度实现。

2. 几何纹理——粗糙表面

比如橘子皮表面的褶皱。 通过改变物体表面的微观几何结构实现。

3. 过程纹理——不规则、动态

比如水波、烟雾。 表现不规则的动态变化的自然景物。

### 颜色纹理

1. 定义纹理图案：纹理坐标 (s,t)
2. 定义纹理映射：建立纹理坐标 (s,t) 和物体表面点 (x,y,z) 的对应关系，把 (s,t) 的颜色值赋给 (x,y,z)
3. 把物体投影到屏幕

![](https://picture.lanlance.cn/i/2023/12/27/658c0aa9c19ca.png)

#### 定义纹理图案

1. 离散法——用数字化图像定义。
2. 连续法——用数学函数表示。

![](https://picture.lanlance.cn/i/2023/12/27/658c0b293c873.png)

#### 建立映射

建立纹理坐标 (s,t) 和屏幕像素 (xs,ys) 的对应关系

![](https://picture.lanlance.cn/i/2023/12/27/658c0b486d01f.png)

### 凹凸纹理

对物体表面作微小扰动，从而产生凹凸不平的效果。

![](https://picture.lanlance.cn/i/2023/12/27/658c0b6d1816a.png)

#### 凹凸纹理实现

1. 对物体表面的每一个点 P(u,v)，沿该点的法向作位移 F(u,v)（对法向量作扰动），新表面位置：

![](https://picture.lanlance.cn/i/2023/12/27/658c0bad49af7.png)

2. 计算新表面的法向量

![](https://picture.lanlance.cn/i/2023/12/27/658c0bce538bb.png)

3. 计算亮度

![](https://picture.lanlance.cn/i/2023/12/27/658c0be452306.png)


# 大数据导论

## 考纲

1. 大数据概述 （1）数据概念及类型； （2）数据的组织形式及生命周期； （3）数据的使用； （4）数据的价值； （5）数据爆炸； （6）三次信息化浪潮的标志及解决问题； （7）数据产生方式的变革及影响； （8）信息科技为大数据时代提供的技术支撑； （9）大数据的基本概念； （10）大数据的 4V 特性； （11）大数据的影响； （12）大数据发展三阶段； （13）大数据基石——Google：GFS、HDFS、MapReduce、BigTable、HBase； （14）大数据体系——Hadoop 生态系统； （15）大数据产业。
2. 大数据与云计算、物联网、人工智能 （1）云计算概念； （2）云计算特点； （3）云计算优势； （4）云计算关键技术； （5）云计算部署方式； （6）云计算服务模式； （7）云计算数据中心； （8）云计算应用； （9）云计算产业； （10）物联网概念； （11）物联网关键技术； （12）物联网应用； （13）物联网产业； （14）大数据、云计算、物联网的关系； （15）人工智能概念； （16）人工智能关键技术——机器学习、深度学习及其他； （17）人工智能应用； （18）人工智能产业； （19）大数据与人工智能的关系。
3. 大数据技术 （1）数据分类； （2）数据采集方式； （3）数据源种类； （4）数据采集工具； （5）数据采集要点； （6）数据清洗的数据类型； （7）数据清洗的内容； （8）无效值和缺失值的处理方法； （9）ETL 流程； （10）传统数据存储技术：文件系统、关系数据库、数据仓库、并行数据库； （11）大数据时代的存储技术：分布式文件系统、NoSQL、NewSQL； （12）数据库架构的变革； （13）基于机器学习的数据处理与分析； （14）大数据处理分析技术类型及工具； （15）数据可视化概念； （16）数据可视化作用； （17）数据可视化工具； （18）数据安全技术； （19）隐私保护技术； （20）大数据生命周期的隐私保护模型。
4. 大数据典型行业应用 （1）推荐系统的概念； （2）推荐模型； （3）推荐方法； （4）大数据在推荐系统中的应用； （5）大数据在生物医学领域中的应用； （6）大数据在物流领域中的应用； （7）大数据在城市管理领域中的应用； （8）大数据在金融领域中的应用； （9）大数据在汽车领域中的应用。
5. 大数据安全与开放共享 （1）传统数据安全隐患； （2）大数据安全与传统数据安全的不同； （3）大数据安全隐患； （4）大数据安全问题； （5）大数据保护的基本原则； （6）大数据时代数据安全隐私保护的对策； （7）国内外保护数据安全的实践； （8）数据共享与数据开放的区别； （9）数据孤岛问题及其产生原因； （10）消除数据孤岛的意义； （11）数据共享面临的挑战及实施的举措； （12）政府开放数据的理论基础； （13）政府数据开放与政府信息公开的区别和联系； （14）国内外政府开放数据的实践与启示。

## 大数据概论

### 数据 vs 信息

数据：一种可以被鉴别的对客观事件进行记录的符号。 常见的数据类型：文本，图片，音频，视频等。

信息：与数据不同的概念，信息是较为宏观的概念，它由数据的有序排列组合而成，传达给读者某个概念方法等，而数据则是构成信息的基本单位。离散的数据没有任何实用价值。

### 数据的组织形式和生命周期

计算机系统中的数据组织形式主要有两种，即文件和数据库

* 文件：计算机系统中的很多数据都是以文件形式存在的，例如：WORD 文件、一个文本文件、一个网页文件、一个图片文件等等。
* 数据库：数据库是结构化信息或数据的有序集合，一般以电子形式存储在计算机系统中。通常由数据库管理系统 (DBMS) 来控制。

数据生命周期：是指数据从创建 ->修改 ->发布利用 ->归档/销毁的整个过程。

### 数据如何转化为信息

* 一致性检查
* 无效值和缺失值的处理
* 数据管理
* 数据分析

### 数据的价值

* 数据的价值在于可以为人们找出答案。
* 数据的价值不会因为不断被使用而削减，反而会因为不断重组而产生更大的价值。
* 各类收集来的数据都应当被尽可能长时间地保存下来，同时也应当在一定条件下与全社会分享，并产生价值。
* 数据已经具备资本的属性，可以用来创造经济价值。

### 大数据是什么

数据层面：大数据 (big data)，指无法在一定时间范围内用常规软件工具进行捕捉、警理和处理的数据集合，是需要新处理模式才能具有更强的决策力、洞察发现力和流程优化能力的海量、高增长率和多样化的信息资产。

技术层面：大数据（技术）是使用分布式技术完成海量数据的处理，以得到数据背后蕴含的价值。

### 大数据 5V 性质

* 数据体量大
* 种类多样化
* 价值密度低
* 速度快
* 数据的质量高

### 数据的生产方式

* 运营式条统阶段 —— 被动产生
* 用户原创内容阶段 —— 主动产生
* 感知式系统阶段 —— 自动产生

数据“产生方式” 的变革促成大数据时代的来临。

### 信息化浪潮

![](https://picture.lanlance.cn/i/2023/12/27/658c1dc9d8a68.png)

### 大数据带来的影响

#### 正面影响

* 科学研究
* 社会发展
* 就业市场
* 人才培养

#### 负面影响

面临挑战

* 存储能力受限 —— 存储设备不断扩容
* 传输能力受限 —— 网络带宽不断增加
* 计算能力受限 —— 计算能力不断提升

## 大数据核心技术概述

### Apache Hadoop 技术栈

* 基于 Hadoop HDFS（Hadoop Distributed File System）的分布式数据存储技术
* 基于 Hadoop YARN（Yet Another Resource Negotiator）的分布式资源调度技术
* 基于 Hadoop MapReduce 的分布式数据计算技术

Apache Hadoop 的出现具有非常重大的意义:

* 为业界提供了“第一款”企业级开源大数据分布式技术解决方案
* 从 Hadoop 开始，大数据体系逐步建成，各类大数据技术不断出现

### 大数据基石三大论文

#### GFS => Hadoop HDFS

NameNode: 负责管理 DataNode 等 SecondaryNameNode: 负责合并 NameNode 操作日志等

思想：分布式存储——解决存储容量、数据安全问题

#### BigTable => Apache HBase

BigTable 用于管理结构化数据，是稀疏的，分布式的，持久化的，多维的，排序的映射。

思想：空间换时间

#### MapReduce => Hadoop MapReduce

思想：分布式计算——解决计算效率问题

**PageRank 算法原理**

基本假设：

1. 若网页越重要，则指向它的链接越多。
2. 被重要的网页指向的网页也很重要。

### 大数据技术体系

#### 大数据发展历程

* 大数据起源 —— Hadoop 诞生
* 雅虎对 Hadoop 的优化
* Hadoop 的进一步发展
* Spark 与流式计算

#### Hadoop 的优势

* 易用性（低成本）
* 高可靠性（高容错性）
* 高效性
* 高拓展性

## 大数据与云计算、物联网、人工智能

### 云计算

* 通过网络、以服务的方式，为千家万户提供非常廉价的 IT 资源，一种商业模式。
* 超大规模计算、高可靠性和安全性、动态扩展性、虚拟化、通用性、按需服务、降低成本。

#### 云计算关键技术

* 虚拟化技术
* 分布式存储技术
* 分布式计算技术
* 多租户技术

#### 云计算部署方式和服务模式

**部署方式**

* 公有云
* 私有云
* 社区云/行业云
* 混合云

**服务模式**

* IaaS —— 提供基本的计算基础结构
* PaaS —— 提供用于开发、测试和管理应用程序的云平台
* SaaS —— 允许人们使用基于云的应用程序

#### 数据中心

* 云计算中心包括：刀片服务器、宽带网络连接、环境控制设备、监控设备以及各种安全装置等。
* 数据中心是云计算的重要载体，是云计算的温床，为云计算提供计算、存储、宽带等各种硬件资源，为各种平台、应用提供支撑环境。
* 云计算推动数据中心向虚拟化和云架构的转型，不断提高 IT 基础架构的灵活性，以降低 IT、能源和空间成本，从而让客户能够快速地提高业务敏捷性。

### 物联网

物联网 (Internet of Things，简称 IoT)，它利用局域网或互联网等通信技术把传感器、控制器、机器、人员和物等通过新的方式联在一起，形成人与物、物与物相联，实现信息化和远程管理控制。

#### 关键技术

* 识别和感知技术 —— 二维码、射频识别（RFID）、传感器
* 网络与通信技术 —— 蓝牙、5G、NFC
* 数据挖掘与融合技术 —— 云计算、大数据

#### 物联网、云计算、大数据的关系

![](https://picture.lanlance.cn/i/2023/12/28/658d1294dcdb3.png)

### 人工智能

* 人工智能（Artificial Intelligence，简称 AI），是研究、开发用于模拟、延伸和扩展人的智能的理论、方法、技术及应用系统的一门新的技术科学。
* 人工智能是计算机科学的一个分支，它企图了解智能的实质，并生产出一种新的能以与人类智能相似的方式做出反应的智能机器，该领域的研究包括：机器学习、知识图谱、自然语言处理、人机交互、计算机视觉、生物特征识别、AR/VR 等 7 个关键技术。

#### 发展历程

* AI 诞生
* 专家系统推广
* 深度学习

#### 关键技术 —— 机器学习

已知概念：模型、输入空间、输出空间。

模型是在指定的假设空间中，确定学习策略，通过优化算法去学习到的由输入到输出的映射。

**机器学习的一般过程**

![](https://picture.lanlance.cn/i/2023/12/28/658d13dabdf3c.png)

**关键技术**

* 感知机
* 激活函数
* 多层感知机
* 神经网络
* 知识图谱
* 人机交互
* 生物特征
* AR/VR
* 计算机视觉
* 自然语言处理

#### 人工智能与大数据

大数据和人工智能虽然关注点并不相同，但是却有密切的联系：一方面人工智能需要大量的数据作为“思考”和“决策的基础，为人工智能提供了强大的存储能力和计算能力；另一方面大数据也需要人工智能技术进行数据价值化操作，比如机器学习就是数据分析的常用方式。

## 大数据技术

### 大数据技术层面

* 数据采集与预处理
* 数据存储和管理
* 数据处理与分析
* 数据可视化
* 数据安全和隐私保护

### 数据采集与预处理

#### 数据分类

* 结构化数据
* 半结构化数据
* 非结构化数据

#### 数据采集

定义：数据采集，又称数据获取，是利用一种装置，从系统外部采集数据并输入到系统内部的一个接口。

过程：它通过各种技术手段把外部各种数据源产生的数据进行实时或非实时地采集，获得各种类型的结构化、半结构化以及非结构化的海量数据并加以利用。

**数据采集方式**

* 离线采集
* 实时采集
* 互联网采集

**数据采集数据源**

* 企业业务系统数据 —— MySQL 等数据库数据
* 传感器
* 日志文件
* 互联网数据 —— 网络爬虫

**数据采集要点**

* 全面性
* 多维性
* 高效性

#### 数据预处理

**数据清理**

数据清洗是指将大量原始数据中的错误信息“洗掉” ，它是发现并纠正数据文件中可识别的错误的最后一道程序，包括：一致性检查、无效值和缺失值处理等。

![](https://picture.lanlance.cn/i/2023/12/28/658d19486439a.png)

### 数据存储与管理

#### 传统数据存储技术

传统的数据存储和管理一般以结构化数据为主，文件系统和数据库是主流技术。

* 文件系统
* 关系型数据库
* 数据仓库
* 并行数据库

#### 大数据时代的存储技术

* 分布式文件系统
* NoSQL 数据库
* NewSQL 数据库

### 基于机器学习的数据分析与处理

#### 数据相关概念

A 市、B 市、C 市等市以及其情况的总和称为数据集（data set）。表格中的每一行，也就是某城市和它的情况被称为一个示例（sample/instance/example）。表格中的每一列（不包括城市），例如最高温度、最低温度，被称为特征（feature/attribute），而每一列中的具体数值，例如 36℃ 、28℃，被称为属性值（attribute value）。

其中属性值为连续数值的特征（如：最高温度）又称为连续特征，而属性值为离散数值或类别的特征（如：某时刻风向）又称为离散特征。数据集中也可能会有缺失数据（missing data），例如 B 市的某时刻风速，我们会将它视作缺失数据。

如果我们想预测城市的天气，例如是晴朗还是阴雨天，这些数据是不够的，除了特征以外，我们还需要每个城市的具体天气情况，也就是通常语境下的我们关注的结果。在机器学习中，它会被称为标签/标记（label）。

#### 方法相关概念

![](https://picture.lanlance.cn/i/2023/12/28/658d1cbeaff24.png)

#### 模型评价指标

机器学习算法常用的分类性能评价指标有：精准率/查准率 Precision、召回率/查全率 Recall、准确率 F1-score、曲线下面积 AUC 等；常用的回归性能评价指标有：均方根误差 RMSE、均方误差 MSE 等。

![](https://picture.lanlance.cn/i/2023/12/28/658d1e0c51cd5.png)

![](https://picture.lanlance.cn/i/2023/12/28/658d1e57a2dd2.png)

#### 大数据处理分析技术类型及工具

* 批处理计算 —— MapReduce、Spark
* 流计算 —— Storm、Flume、Streams
* 图计算 —— GraphX、Giraph
* 查询分析计算 —— Hive、Cassandra

### 数据可视化

数据可视化是指将大型数据集中的数据以图形图像形式表示，并利用数据分析和开发工具发现其中未知信息的处理过程。

#### 数据可视化的作用

* 观测、跟踪数据
* 分析数据
* 辅助理解数据
* 增强数据吸引力

### 数据安全与隐私保护

#### 数据安全技术

* 身份认证技术
* 防火墙技术
* 入侵检测技术
* 加密技术
* 访问控制技术

#### 隐私保护技术

如何在不泄露用户隐私的前提下，提高大数据的利用率，挖掘大数据的价值，是目前大数据研究领域的关键问题。

### 大数据技术相关工具汇总

数据采集与预处理工具：（1）传感器数据：温度计、录音机、摄像机等；（2）日志数据：Hadoop 的 Chukwa，Cloudera 的 Flume，FaceBook 的 Scribe 等；（2）互联网数据：网络爬虫；（3）企业业务系统数据：ETL 工具。

数据存储与管理工具：（1）文件系统：FAT、NTFS、VFAT、APFS 等；（2）关系数据库：Oracle、SQL Server、MySQL、DB2 等；（3）数据仓库：Hive、Pig、SparkSql 等；（4）并行数据库：Teradata、Aster、Vertica 等；（5）分布式文件系统：DFS、HDFS 等；（6）NewSQL：Spanner、Clustrix、GenieDB、VoltDB、ScaleDB、ScaleBase 等；（7）：NoSQL 数据库：BigTable、HBase、Cassandra 等。

数据分析与处理工具：（1）批处理计算：Hadoop MapReduce、Spark MapReduce；（2）流计算：Storm、Flink、Spark Streaming；（3）图计算：GraphX、Giraph、PowerGraph；（4）查询分析计算：Hive、Cassandra、Dremel、Impala。

数据可视化工具：Excel、PPT、Python（Pyecharts）、JavaScript（Echarts、D3.js）等。

## 大数据应用

### 推荐系统

推荐系统是大数据在互联网领域的典型应用，它可以通过分析用户的历史记录来了解用户的喜好，从而主动为用户推荐其感兴趣的信息，满足用户的个性化推荐需求。

#### 长尾商品

电子商务网站销售种类繁多，虽然绝大多数商品都不热门，但这些不热门的商品总数量极其庞大，所累计的总销售额将是一个可观的数字，也许会超过热门商品所带来的销售额。

#### 热门推荐 vs 个性化推荐

* 热门推荐是常用的推荐方式，广泛应用于各类网站中，如热门排行榜。但热门推荐的主要缺陷在于推荐的范围有限，所推荐的内容在一定时期内也相对固定。无法实现长尾商品的推荐。
* 个性化推荐可通过推荐系统来实现。推荐系统通过发掘用户的行为记录，找到用户的个性化需求，发现用户潜在的消费倾向，从而将长尾商品准确地推荐给需要它的用户，进而提升销量，实现用户与商家的双赢。

#### 推荐方法

* 专家推荐
* 基于统计的推荐
* 基于内容的推荐
* 协同过滤推荐 —— 利用相似用户
* 混合推荐

### 生物医学领域

* 智慧医疗
* 生物信息学

### 物流领域

* 智能物流

### 城市管理领域

* 环境保护

### 金融领域

* 信贷风险分析

## 大数据安全与数据开放共享

### 大数据安全

#### 传统数据安全隐患

* 计算机病毒
* 数据信息存储介质的损坏
* 黑客攻击

#### 大数据安全与传统数据安全的不同

传统数据安全 —— 主要面临静态安全问题 大数据安全 —— 主要面临动态安全问题

#### 大数据保护的基本原则

* 数据主权原则
* 数据保护原则
* 数据自由流通原则
* 数据安全原则

### 数据共享

#### 数据孤岛问题

数据孤岛是指在一个组织或企业内部，由于数据标准不统一、数据接口不开放、数据共享机制不健全等原因，导致不同部门、业务系统之间无法实现数据的高效流通和共享，形成一个个独立的、封闭的数据岛屿。这些数据岛屿之间缺乏有效的关联和交互，导致数据资源的低效利用和价值流失。

#### 数据共享

数据共享是指数据的拥有者将数据向其他机构和个人开放的行动，例如科研人员将实验过程中使用的数据向其他科研人员共享，以便于实验结果的可重现性。


