Update 学习路线 - 零基础.md

补充 UTF-8、请求时序与归并排序等配图,重新绘制部分示意图;更新哈希表与二叉查找树推演示例,补充 Git 冲突实操命令及各节说明。
This commit is contained in:
CyC2018
2026-10-30 20:19:08 +08:00
parent bd4aaeedcf
commit e438f7a832
9 changed files with 140 additions and 108 deletions
Binary file not shown.

Before

Width:  |  Height:  |  Size: 210 KiB

After

Width:  |  Height:  |  Size: 214 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 202 KiB

After

Width:  |  Height:  |  Size: 219 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 173 KiB

After

Width:  |  Height:  |  Size: 186 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 159 KiB

After

Width:  |  Height:  |  Size: 176 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 247 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 215 KiB

After

Width:  |  Height:  |  Size: 218 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 99 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 107 KiB

+140 -108
View File
@@ -82,7 +82,7 @@
<div align="center"> <img src="pics/intro-overview.png" width="780px"/> </div><br>
1. App 只知道服务器的名字 shop.example.com,先向 DNS 服务器查询它对应的 IP 地址 203.0.113.10;
2. App 与服务器建立 TCP 连接,在连接上发送一个 HTTP 请求,内容是“用户 1001 要 1 份 17 号菜”;
2. App 与服务器建立 TCP 连接,在连接上发送一个 HTTP 请求,内容是“用户 1001 要 1 份 17 号菜(宫保鸡丁)”;
3. 服务器上运行着订单服务进程,操作系统收到网络数据后,把请求交给进程中的一个线程处理;
4. 线程执行订单程序的指令,CPU 从内存中读取数据、计算,再把结果写回内存;
5. 订单程序请求 MySQL 数据库在一个事务中完成两件事:宫保鸡丁的库存从 30 减为 29,写入订单 1024;
@@ -122,7 +122,7 @@
一个 0 或 1 称为一位(bit),8 位组成一个字节(Byte),字节是存储和传输数据的基本单位。1 个字节有 2⁸ = 256 种取值,可以表示 0~255。更大的单位按 1024 进位:1 KB = 1024 字节,1 MB = 1024 KB,1 GB = 1024 MB。
二进制和十进制一样是按位计数,只是逢二进一。例如订单号 1024 等于 2¹⁰,写成二进制是 1 后面跟 10 个 0:
二进制和十进制一样是按位计数,只是逢二进一:从右往左每一位代表 1、2、4、8、16……,例如 101 = 4 + 1 = 5。订单号 1024 等于 2¹⁰,写成二进制是 1 后面跟 10 个 0:
```bash
echo 'obase=2; 1024' | bc
@@ -132,7 +132,9 @@ echo 'obase=2; 1024' | bc
10000000000
```
二进制写起来太长,所以常用十六进制(0~9 和 a~f,逢十六进一)表示,每个十六进制数字正好对应 4 位,两个数字对应一个字节。1024 的十六进制是 0x400(前缀 0x 表示十六进制),一个字节的取值范围写成 00~ff。
macOS 自带 bc;Ubuntu 上提示找不到 bc 时,先执行 sudo apt install bc 安装。
二进制写起来太长,所以常用十六进制(0~9 和 a~f,a~f 表示 10~15,逢十六进一)表示,每个十六进制数字正好对应 4 位,两个数字对应一个字节。1024 的二进制按 4 位一组是 0100 0000 0000,十六进制是 0x400(前缀 0x 表示十六进制),一个字节的取值范围写成 00~ff。
### 整数
@@ -141,15 +143,15 @@ echo 'obase=2; 1024' | bc
- 4 个字节(32 位)的整数类型,例如 Java、C 中的 int,最大约 21 亿(2147483647);
- 8 个字节(64 位)的整数类型,例如 long,最大约 922 亿亿。
订单号 1024 只需要 11 位,用 4 个字节就够了。但订单号会一直增长,大型平台的订单总数会超过 21 亿,所以订单号通常用 8 个字节保存。超出范围时数值会“溢出”,例如 4 字节整数 2147483647 再加 1,结果变成负数 -2147483648。
订单号 1024 只需要 11 位,用 4 个字节就够了。但订单号会一直增长,大型平台的订单总数会超过 21 亿,所以订单号通常用 8 个字节保存。超出范围时数值会“溢出”,例如 Java 的 int 2147483647 再加 1,结果变成负数 -2147483648(C 语言不保证这个结果)。
负数用补码(Two's Complement)表示:把正数的各位取反再加 1。例如退款 -2850 分,在 4 个字节中是 ff ff f4 de。这样设计的好处是加法电路不用区分正负,-2850 + 2850 按二进制直接相加,进位溢出最高位后正好得到 0。4 字节共有 2³² 种取值,补码把最高位为 1 的一半分给负数,所以最大值是 2³¹ − 1 = 2147483647,最小值是 −2³¹。
负数用补码(Two's Complement)表示:把正数的各位取反再加 1。以 1 个字节的 -5 为例,5 是 00000101,取反得 11111010,加 1 得 11111011,这就是 -5。这样设计的好处是加法电路不用区分正负:00000101 + 11111011 = 1 00000000,进位溢出最高位后正好得到 0。退款 -2850 分按同样的方法得到,在 4 个字节中是 ff ff f4 de。4 字节共有 2³² 种取值,补码把最高位为 1 的一半分给负数,所以最大值是 2³¹ − 1 = 2147483647,最小值是 −2³¹。前面的溢出也由此而来:2147483647 是 0x7fffffff,加 1 得到 0x80000000,最高位变成 1,按补码解读就是 −2³¹ = −2147483648。
### 小数
宫保鸡丁的价格 28.50 元带有小数。计算机中最常用的小数格式是浮点数(Floating-Point Number),按 IEEE 754 标准用 8 个字节保存一个二进制小数。
宫保鸡丁的价格 28.50 元带有小数。计算机中最常用的小数格式是浮点数(Floating-Point Number),按 IEEE 754 标准保存二进制小数:Java、C 中的 double 占 8 个字节,float 占 4 个字节、精度更低。
问题在于很多十进制小数无法用二进制精确表示。28.5 = 16 + 8 + 4 + 0.5,写成二进制是 11100.1,可以精确保存;但 0.1 写成二进制是 0.000110011001100…,后面无限循环,只能保存一个近似值。假设这单再加打包费 1.10 元和配送费 2.20 元,用浮点数计算:
问题在于很多十进制小数无法用二进制精确表示。二进制小数点后的各位依次代表 1/2、1/4、1/8……,28.5 = 16 + 8 + 4 + 1/2,写成二进制是 11100.1,可以精确保存;0.1 却无法由有限个 1/2、1/4、1/8……相加得到,写成二进制是 0.000110011001100…,后面无限循环,只能保存一个近似值。假设这单再加打包费 1.10 元和配送费 2.20 元,用浮点数计算:
```bash
awk 'BEGIN { printf "%.17g\n", 1.10 + 2.20 }'
@@ -190,6 +192,8 @@ printf '宫保鸡丁' | od -An -tx1
4 个汉字共 12 个字节,每 3 个字节对应一个字。以“宫”为例,编号 0x5BAB 的二进制是 0101 1011 1010 1011,UTF-8 把这 16 位依次填进 1110xxxx 10xxxxxx 10xxxxxx 的空位,得到 11100101 10101110 10101011,即 e5 ae ab。每个字节开头的几位说明了它是一个字的开始还是中间,程序据此把字节流切分成一个个字。
<div align="center"> <img src="pics/intro-utf8.png" width="600px"/> </div><br>
同一段文字用不同的编码保存,字节也不同。例如用 GBK 编码,“宫保鸡丁”只有 8 个字节(b9 ac b1 a3 bc a6 b6 a1)。如果写入时用 UTF-8、读取时按 GBK 解释,12 个字节会被切成另外几个字,在 macOS 上显示为“瀹繚楦′竵”,这就是乱码。避免乱码的办法是全程使用同一种编码,现在通常统一为 UTF-8。
### 一个订单在内存中的样子
@@ -198,7 +202,7 @@ printf '宫保鸡丁' | od -An -tx1
<div align="center"> <img src="pics/intro-order-bytes.png" width="760px"/> </div><br>
- 订单号 1024 存为 00 04 00 00 00 00 00 00:低位的字节放在前面,称为小端序(Little-Endian),常见的 x86 和 ARM 处理器都是这样;高位在前的称为大端序(Big-Endian),网络传输中的整数通常用大端序;
- 订单号 1024 = 0x400,补满 8 个字节是 00 00 00 00 00 00 04 00,内存中却存为 00 04 00 00 00 00 00 00:低位的字节放在前面,称为小端序(Little-Endian),常见的 x86 和 ARM 处理器都是这样;高位在前的称为大端序(Big-Endian),网络传输中的整数通常用大端序;
- 金额 2850 等于 0x0B22,同样低位在前,存为 22 0b 00 00;
- 菜名的 12 个字节后面跟一个 00,表示字符串结束;
- 最后 7 个字节不保存数据,用于把总长度补齐到 8 的倍数,处理器按这样对齐的地址读取更快。
@@ -223,7 +227,7 @@ printf '宫保鸡丁' | od -An -tx1
<details>
<summary>参考要点</summary>
1. 1 个字节是 8 位,有 2⁸ = 256 种取值,可以表示 0~255;4 字节整数最大约 21 亿(2147483647),大平台的订单总数会超过它,超出后数值溢出变成负数;8 字节整数最大约 922 亿亿,订单号一直增长也够用。
1. 1 个字节是 8 位,有 2⁸ = 256 种取值,可以表示 0~255;4 字节整数最大约 21 亿(2147483647),大平台的订单总数会超过它,超出后数值溢出,例如 Java 的 int 会变成负数;8 字节整数最大约 922 亿亿,订单号一直增长也够用。
2. 负数用补码表示:把对应正数的每一位取反(0 变 1、1 变 0)再加 1;例如 -2850 在 4 个字节中是 ff ff f4 de;这样加法电路不用区分正负,-2850 + 2850 直接按二进制相加,进位溢出最高位后正好得到 0。
</details>
@@ -253,7 +257,7 @@ printf '宫保鸡丁' | od -An -tx1
动手:执行 printf '宫保鸡丁' | od -An -tx1 查看 4 个汉字的 UTF-8 字节,再换成自己的名字试一试;执行 awk 'BEGIN { printf "%.17g\n", 1.10 + 2.20 }',观察浮点数的误差。
预期:“宫保鸡丁”输出 e5 ae ab e4 bf 9d e9 b8 a1 e4 b8 81,每个汉字 3 个字节,换成自己的名字时字节数是汉字个数的 3 倍;awk 输出 3.3000000000000003,比 3.30 多了一点。
预期:“宫保鸡丁”输出 e5 ae ab e4 bf 9d e9 b8 a1 e4 b8 81,每个汉字 3 个字节,换成自己的名字时字节数通常是汉字个数的 3 倍;awk 输出 3.3000000000000003,比 3.30 多了一点。
明天:第 3 天,读[程序怎样运行](#三程序怎样运行)。
@@ -267,7 +271,7 @@ printf '宫保鸡丁' | od -An -tx1
- 内存(Memory):存放正在运行的程序和它用到的数据,读写快,但断电后内容丢失;
- 磁盘(Disk):包括固态硬盘(SSD)和机械硬盘,长期保存文件,断电不丢失,但比内存慢得多。
内存可以看成一长排编了号的格子,每个格子放一个字节,格子的编号称为地址(Address)。CPU 读写内存时要给出地址,例如“读地址 0x1000 开始的 8 个字节”。一台 32 GB 内存的机器约有 343 亿个这样的格子。第二章中订单的 32 个字节,就是放在连续的 32 个格子中。
内存可以看成一长排编了号的格子,每个格子放一个字节,格子的编号称为地址(Address)。CPU 读写内存时要给出地址,例如“读地址 0x1000 开始的 8 个字节”。一台 32 GB 内存的机器约有 343 亿个这样的格子。[一个订单在内存中的样子](#一个订单在内存中的样子)中订单的 32 个字节,就是放在连续的 32 个格子中。
订单程序平时以文件的形式保存在磁盘上,运行时先被复制到内存中,CPU 再从内存中逐条取出指令执行。订单数据也一样:数据库把订单永久保存在磁盘上,需要查询时读入内存处理。
@@ -277,34 +281,36 @@ printf '宫保鸡丁' | od -An -tx1
寄存器(Register)是 CPU 内部的少量存储单元,每个只能放一个数,但读写不到 1 纳秒。CPU 中还有一个程序计数器(Program Counter, PC),记录下一条要执行的指令的地址。CPU 的工作就是不断重复:按 PC 取出指令,执行,PC 指向下一条。
以扣库存为例,假设订单程序在内存中记着宫保鸡丁的库存 stock,程序中的一行 stock = stock - 1,编译后变成 4 条 ARM 指令(Ubuntu 24.04、gcc 13.3 实测,见[附录 A3](#a3-机器指令)):
以扣库存为例,假设订单程序在内存中记着宫保鸡丁的库存 stock,程序中的一行 stock = stock - 1,编译(翻译成 CPU 指令,见[编译与解释](#编译与解释))后变成 4 条 ARM 指令(Ubuntu 24.04、gcc 13.3 实测,见[附录 A3](#a3-机器指令)):
<div align="center"> <img src="pics/intro-cpu-steps.png" width="780px"/> </div><br>
1. adrp:把变量 stock 在内存中的地址放进寄存器 x1;
2. ldr:按 x1 中的地址从内存读出 stock 的值 30,放进寄存器 w0;
1. adrp:把变量 stock 所在内存页(4 KB 的一段内存)的起始地址放进寄存器 x1;
2. ldr:按 x1 中的地址加上 stock 在页内的偏移,从内存读出 stock 的值 30,放进寄存器 w0;
3. sub:w0 减 1,变成 29;
4. str:把 w0 写回内存中 stock 的位置。
一行代码要拆成“读、算、写”几步,这一点在[多线程](#线程)中很重要:两个线程可能都在第 2 步读到 30,各自减 1 后都写回 29,卖出两份菜库存却只少了一份。
CPU 每秒能执行多少条指令,主要取决于时钟频率和每个时钟周期能完成几条指令。例如 3.2 GHz 表示每秒 32 亿个时钟周期,现代 CPU 每个周期还能同时执行多条指令,所以每秒能执行几十亿条以上。
CPU 按时钟的节拍工作,每一拍称为一个时钟周期。每秒能执行多少条指令,主要取决于时钟频率和每个时钟周期能完成几条指令。例如 3.2 GHz 表示每秒 32 亿个时钟周期,现代 CPU 每个周期还能同时执行多条指令,所以每秒能执行几十亿条以上。
### 存储器的速度
CPU 很快,但数据要从内存或磁盘中取来。不同存储器的速度相差几个数量级,在 Apple M1 Pro 上实测随机读取一次数据的时间(见[附录 A4](#a4-存储器的访问延迟)):
CPU 很快,但数据要从内存或磁盘中取来。为了少等,CPU 内部有几级缓存(Cache),把最近用过的内存数据复制一份放在离 CPU 更近的地方:一级缓存最小最快,二级缓存大一些、慢一些。
不同存储器的速度相差几个数量级,在 Apple M1 Pro 上实测随机读取一次数据的时间(见[附录 A4](#a4-存储器的访问延迟))。括号中是实验反复读取的数据量:16 KB 能全部放进一级缓存;1 MB 放不进一级缓存(128 KB),但放得进二级缓存(12 MB),测到的主要是二级缓存的速度;64 MB 两级都放不下,只能读内存:
```text
存储器 随机读取一次的时间
CPU 一级缓存(16 KB) 约 1 ns
CPU 二级缓存(1 MB) 约 6 ns
内存(64 MB 范围内) 约 130 ns
SSD(读 4 KB) 约 70 μs,即 70000 ns
存储器 随机读取一次的时间
CPU 一级缓存(在 16 KB 范围内读) 约 1 ns
CPU 二级缓存(在 1 MB 范围内读) 约 6 ns
内存(在 64 MB 范围内读) 约 130 ns
SSD(每次读 4 KB) 约 70 μs,即 70000 ns
```
1 纳秒(ns)是十亿分之一秒,1 微秒(μs)等于 1000 纳秒。读一次内存大约相当于读一级缓存的 100 多倍,随机读一次 SSD 又相当于读内存的 500 倍左右。机械硬盘还要更慢:它要等盘片转到数据所在的位置,7200 转/分的硬盘平均等半圈就要 60 ÷ 7200 ÷ 2 ≈ 4.17 ms,再加上移动磁头的时间,一次随机读取要几毫秒。
为了弥补差距,CPU 内部有几级缓存(Cache),把最近用过的内存数据复制一份放在离 CPU 更近的地方;操作系统和数据库也会把常用的磁盘数据留在内存中。对订单服务来说,这意味着:
同样的道理,操作系统和数据库会把常用的磁盘数据留在内存中。对订单服务来说,这意味着:
- 能在内存中完成的操作尽量在内存中完成,例如把热门菜的信息缓存起来;
- 访问磁盘时尽量一次读一大块,而不是零散地读很多次,[数据库索引](#索引)就是按这个思路设计的。
@@ -324,9 +330,9 @@ SSD(读 4 KB) 约 70 μs,即 70000 ns
CPU 不认识这种文字,只能执行指令。把源代码变成指令有两种方式:
- 编译(Compile):运行之前由编译器一次性翻译成机器指令,生成可执行文件,例如 C、Go;
- 解释(Interpret):运行时由解释器逐条读取源代码并执行,例如 Python、JavaScript。
- 解释(Interpret):不生成可执行文件,运行时由解释器边翻译边执行,例如 Python、JavaScript。
Java 介于两者之间:先编译成与具体 CPU 无关的字节码,运行时由 Java 虚拟机解释执行,并把执行频繁的部分即时编译(JIT)成机器指令。编译型的程序启动后直接执行指令,通常更快;解释型的程序改完不用重新编译,同一份代码可以在不同的 CPU 上运行。
Java 介于两者之间:先编译成与具体 CPU 无关的字节码,运行时由 Java 虚拟机解释执行,并把执行频繁的部分即时编译(Just-In-Time, JIT)成机器指令。编译型的程序启动后直接执行指令,通常更快;解释型的程序改完不用重新编译,同一份代码可以在不同的 CPU 上运行。
可执行文件本身也是磁盘上的一串字节。例如 Ubuntu 中 ls 命令的程序文件开头 4 个字节是 7f 45 4c 46,后三个字节正是字母 ELF,表示这是 Linux 的可执行文件格式:
@@ -338,7 +344,7 @@ od -A x -t x1 -N 16 /bin/ls
000000 7f 45 4c 46 02 01 01 00 00 00 00 00 00 00 00 00
```
在 macOS 上执行同一条命令,开头是 ca fe ba be,这是 macOS 的 Mach-O 格式(一个文件中同时包含 x86 和 ARM 两种指令)。所以同一个程序要分别为不同的系统和 CPU 编译。
在 macOS 上执行同一条命令,开头是 ca fe ba be,表示这是一个多架构文件,里面装着 x86 和 ARM 两份 macOS 的 Mach-O 格式程序。不同系统的可执行文件格式不同,不同 CPU 的指令也不同,所以同一个程序要分别为不同的系统和 CPU 编译。
### 从程序到进程
@@ -364,14 +370,14 @@ od -A x -t x1 -N 16 /bin/ls
<summary>参考要点</summary>
1. Apple M1 Pro 上实测:一级缓存约 1 ns,二级缓存约 6 ns,内存约 130 ns,SSD 随机读 4 KB 约 70 μs(70000 ns);内存比一级缓存慢 100 多倍,SSD 又比内存慢约 500 倍,机械硬盘还要几毫秒;一级缓存到 SSD 相差约 7 万倍,即四五个数量级。
2. CPU 用程序计数器(PC)记住下一条指令的地址,不断重复“按 PC 取指令、执行、PC 指向下一条”;stock = stock - 1 编译成 4 条指令:adrp 取 stock 的地址,ldr 从内存读出 30 放进寄存器(CPU 内部的小存储单元),sub 减 1 得 29,str 写回内存;读、算、写分几步完成,中间可能被别的线程插进来。
3. 编译型语言在运行前由编译器一次性翻译成机器指令,例如 C、Go,启动后直接执行,通常更快;解释型语言在运行时由解释器逐条读取源代码执行,例如 Python、JavaScript,改完不用重新编译,同一份代码能在不同 CPU 上运行;Java 介于两者之间:先编译成字节码,由 Java 虚拟机解释执行,频繁执行的部分再即时编译(JIT)成机器指令。
2. CPU 用程序计数器(PC)记住下一条指令的地址,不断重复“按 PC 取指令、执行、PC 指向下一条”;stock = stock - 1 编译成 4 条指令:adrp 取 stock 所在内存页的地址,ldr 加上页内偏移从内存读出 30 放进寄存器(CPU 内部的小存储单元),sub 减 1 得 29,str 写回内存;读、算、写分几步完成,中间可能被别的线程插进来。
3. 编译型语言在运行前由编译器一次性翻译成机器指令,例如 C、Go,启动后直接执行,通常更快;解释型语言不生成可执行文件,运行时由解释器边翻译边执行,例如 Python、JavaScript,改完不用重新编译,同一份代码能在不同 CPU 上运行;Java 介于两者之间:先编译成字节码,由 Java 虚拟机解释执行,频繁执行的部分再即时编译(JIT)成机器指令。
</details>
动手:执行 od -A x -t x1 -N 16 /bin/ls 查看可执行文件开头的 16 个字节,对照[编译与解释](#编译与解释)一节说出它是哪种格式。
预期:Ubuntu 上开头是 7f 45 4c 46,后三个字节是字母 ELF,即 Linux 的可执行文件格式;macOS 上开头是 ca fe ba be,是同时包含 x86 和 ARM 两种指令的 Mach-O 格式。
预期:Ubuntu 上开头是 7f 45 4c 46,后三个字节是字母 ELF,即 Linux 的可执行文件格式;macOS 上输出的地址和间距略有不同,开头是 ca fe ba be,表示多架构文件,里面装着 x86 和 ARM 两份 Mach-O 格式的程序。
综合题:订单 1024 的金额 28.50 元和菜名“宫保鸡丁”在内存中各占几个字节、是什么样子?服务器上的订单程序从磁盘上的一个文件,到 CPU 执行扣库存的指令,经过了哪些步骤?(见[一个订单在内存中的样子](#一个订单在内存中的样子)、[CPU、内存与磁盘](#cpu内存与磁盘)、[从程序到进程](#从程序到进程))
@@ -404,9 +410,9 @@ od -A x -t x1 -N 16 /bin/ls
### 进程
上一章说过,进程是正在运行的程序。操作系统为每个进程维护一份档案,记录它的进程号、内存、打开的文件,以及暂停时 CPU 寄存器的值。
[从程序到进程](#从程序到进程)中说过,进程是正在运行的程序。操作系统为每个进程维护一份档案,记录它的进程号、内存、打开的文件,以及暂停时 CPU 寄存器的值。
一台机器上的进程远多于 CPU 核心。例如在一台 10 核的 Mac 上,`top` 显示同时有 1091 个进程(macOS 15 实测,见[附录 A5](#a5-进程线程与系统调用))。它们能“同时”运行,是因为操作系统让每个进程轮流使用 CPU 一小段时间(通常是几毫秒),时间到了就保存它的寄存器,换下一个进程运行,称为上下文切换。切换得足够快,看起来就像所有进程在同时运行,这称为并发;多个核心真正在同一时刻各运行一个进程,称为并行。
一台机器上的进程远多于 CPU 核心。例如在一台 10 核的 Mac 上,`top` 显示同时有 1091 个进程(macOS 15 实测,见[附录 A5](#a5-进程线程与系统调用))。它们能“同时”运行,是因为操作系统让每个进程轮流使用 CPU 一小段时间(通常是几毫秒)。时间到了,或者进程要等待数据,操作系统就保存它的寄存器,再恢复下一个进程的寄存器让它运行,称为上下文切换。切换得足够快,看起来就像所有进程在同时运行,这称为并发;多个核心真正在同一时刻各运行一个进程,称为并行。
进程在运行过程中会在几种状态之间切换:
@@ -424,7 +430,7 @@ od -A x -t x1 -N 16 /bin/ls
线程(Thread)是进程中的一条执行路线。一个进程可以有多个线程,每个线程有自己的程序计数器、寄存器和栈(保存函数调用过程中的临时数据),但它们共享进程的代码、数据和打开的文件。操作系统调度的基本单位是线程:一个线程等待数据库时,CPU 可以去执行另一个线程。
在实验中让订单服务同时处理 3 个下单请求,用 ps 查看,进程 7 中除了等待新连接的主线程,又多了 3 个线程(Ubuntu 24.04 实测,见[附录 A5](#a5-进程线程与系统调用)):
在实验中让订单服务同时处理 3 个下单请求,用 ps 查看,进程 7 中除了等待新连接的主线程,又多了 3 个线程(Ubuntu 24.04 实测,见[附录 A5](#a5-进程线程与系统调用))。SPID 列是线程号,主线程的线程号与进程号相同:
```text
PID SPID TTY TIME CMD
@@ -444,7 +450,7 @@ od -A x -t x1 -N 16 /bin/ls
线程虽然比进程轻,但创建和销毁仍有开销,每个线程还要占用一份栈的内存。所以实际的服务程序通常预先创建一组线程反复使用,称为线程池:请求到达时交给一个空闲的线程,处理完后线程回到池中等待下一个请求,请求多于线程数时在队列中排队。
共享内存让线程之间交换数据很方便,但也带来了问题。回到上一章扣库存的 3 条指令,两个线程同时卖最后一份宫保鸡丁时可能这样交错执行:
共享内存让线程之间交换数据很方便,但也带来了问题。回到[指令](#指令)一节扣库存的读、算、写三步,两个线程同时卖最后一份宫保鸡丁时可能这样交错执行:
```text
线程 15(用户 1001) 线程 16(用户 1002) 内存中的 stock
@@ -456,7 +462,7 @@ od -A x -t x1 -N 16 /bin/ls
写回 stock:0 0
```
两个线程都认为还有库存,卖出了两份,库存却只减了 1。解决办法是加锁(Lock):一个线程在“读、算、写”期间持有锁,另一个线程必须等它释放锁之后才能开始,从而保证这几步作为一个整体执行。数据库也用类似的机制保护库存,见[事务](#事务)。
两个线程都认为还有库存,卖出了两份,库存却只减了 1。解决办法是加锁(Lock):一个线程在“读、算、写”期间持有锁,另一个线程必须等它释放锁之后才能开始,从而保证这几步作为一个整体执行。持有锁的线程还要先判断库存大于 0 再扣减,这样线程 16 拿到锁时读到 0,就会提示售罄。数据库也用类似的机制保护库存,见[事务](#事务)。
### 系统调用
@@ -478,7 +484,7 @@ write(1, "order 1024\n", 11order 1024
) = 11
```
write 的第一个参数 1 表示标准输出(屏幕),第二个是要写的内容,返回值 11 表示写入了 11 个字节。
write 的第一个参数 1 表示标准输出(屏幕),第二个是要写的内容,返回值 11 表示写入了 11 个字节。中间的 order 1024 是 echo 真正输出到屏幕的内容,和 strace 打印的记录交错在了一起。
处理一次下单请求时,订单服务的系统调用如下(Ubuntu 24.04 实测,见[附录 A5](#a5-进程线程与系统调用)):
@@ -490,7 +496,7 @@ write 的第一个参数 1 表示标准输出(屏幕),第二个是要写
文件(File)是保存在磁盘上、有名字的一串字节。操作系统用目录把文件组织成一棵树,例如 /var/log/orders.log 表示根目录下 var 目录中 log 目录里的 orders.log 文件。
程序先用 open 打开文件,内核返回一个小整数,称为文件描述符(File Descriptor),之后的读写都用这个数指代文件。每个进程启动时默认打开 0、1、2 三个文件描述符,分别是标准输入、标准输出和标准错误。上图中 openat 返回 5,后面的 write(5, ...) 就是往这个文件写。Linux 中很多东西都用文件描述符表示,例如网络连接:accept4 返回的 4 就是与 App 之间的连接,sendto(4, ...) 把响应发给 App。
程序先用 open 打开文件(上图中实际出现的系统调用是 openat),内核返回一个小整数,称为文件描述符(File Descriptor),之后的读写都用这个数指代文件。每个进程启动时默认打开 0、1、2 三个文件描述符,分别是标准输入、标准输出和标准错误。上图中 openat 返回 5,后面的 write(5, ...) 就是往这个文件写。Linux 中很多东西都用文件描述符表示,例如网络连接:accept4 返回的 4 就是与 App 之间的连接,sendto(4, ...) 把响应发给 App。
文件适合保存日志这类只追加、按顺序读的数据。订单则需要按条件查询、多人同时修改、出错时撤销,单靠文件很难做到,所以要用[数据库](#为什么需要数据库)。
@@ -508,7 +514,7 @@ ps -ef | head -5
top
```
按 q 退出。在 macOS 上,top 第一行显示进程总数和线程总数,例如“Processes: 1091 total ... 11019 threads”;在 Linux 上可以用 `ps -T -p 进程号` 列出一个进程的所有线程。
Ubuntu 的 top 默认按 CPU 占用从高到低排序,macOS 默认按进程号排序,要用 top -o cpu。按 q 退出。在 macOS 上,top 第一行显示进程总数和线程总数,例如“Processes: 1091 total ... 11019 threads”;在 Linux 上可以用 `ps -T -p 进程号` 列出一个进程的所有线程。
进程、线程、调度和同步的细节见[计算机操作系统 - 进程管理](计算机操作系统%20-%20进程管理.md#进程与线程);系统调用与用户态、内核态见[计算机操作系统 - 概述](计算机操作系统%20-%20概述.md#系统调用);虚拟内存见[计算机操作系统 - 内存管理](计算机操作系统%20-%20内存管理.md#虚拟内存);多线程和锁在 Java 中的用法见 [Java 并发](Java%20并发.md#四互斥同步);常用的进程命令见 [Linux:进程管理](Linux.md#十进程管理)。
@@ -528,14 +534,14 @@ top
<summary>参考要点</summary>
1. 进程是资源分配的单位,有独立的内存;线程是 CPU 调度的单位,同一进程的线程共享代码、数据和打开的文件,各自只有程序计数器、寄存器和栈(保存函数调用中的临时数据);创建、切换线程比进程开销小,因为不用切换内存的映射;一个线程出错可能导致整个进程退出,一个进程崩溃不影响其它进程。
2. 上下文切换指操作系统让进程轮流使用 CPU,每个运行几毫秒,时间到了就保存它的寄存器,换下一个进程运行;并发是靠快速切换让很多进程看起来同时运行,并行是多个 CPU 核心在同一时刻真正各运行一个。
3. stock = stock - 1 分为读、算、写几条指令,两个线程卖最后一份时可能都读到 1,各自减为 0 后写回,卖出两份库存只减了 1;解决办法是加锁(Lock):一个线程在读、算、写期间持有锁,另一个线程等它释放后才能开始,让这几步作为一个整体执行。
2. 上下文切换指进程的时间片用完或要等待数据时,操作系统保存它的寄存器,恢复下一个进程的寄存器让它运行,靠它让进程轮流使用 CPU,每个运行几毫秒;并发是靠快速切换让很多进程看起来同时运行,并行是多个 CPU 核心在同一时刻真正各运行一个。
3. stock = stock - 1 分为读、算、写几条指令,两个线程卖最后一份时可能都读到 1,各自减为 0 后写回,卖出两份库存只减了 1;解决办法是加锁(Lock):一个线程在读、算、写期间持有锁,另一个线程等它释放后才能开始,让这几步作为一个整体执行;持有锁时先判断库存大于 0 再扣减,后拿到锁的线程读到 0 就提示售罄。
</details>
动手:执行 ps -ef | head -5 和 top,找出占用 CPU 最多的进程;在 macOS 的 top 第一行看进程和线程的总数,对照[进程](#进程)一节理解为什么进程数远多于 CPU 核心数。
动手:执行 ps -ef | head -5 和 top(macOS 上用 top -o cpu),找出占用 CPU 最多的进程;在 top 开头找到进程总数(macOS 在第一行,Ubuntu 在第二行的 Tasks),对照[进程](#进程)一节理解为什么进程数远多于 CPU 核心数。
预期:ps -ef 每行一个进程,PID 是进程号,PPID 是父进程号,CMD 是启动命令;top 默认按 CPU 占用从高到低排序,排在最前的就是占用最多的进程;macOS 的 top 第一行显示一千多个进程、一万多个线程(笔记实测 10 核的机器上 1091 个进程、11019 个线程),其中只有几个在运行,其余在休眠,靠轮流使用 CPU 共享少数核心。
预期:ps -ef 每行一个进程,PID 是进程号,PPID 是父进程号,CMD 是启动命令;按 CPU 排序后排在最前的就是占用最多的进程;macOS 的 top 第一行显示一千多个进程、一万多个线程(笔记实测 10 核的机器上 1091 个进程、11019 个线程),其中只有几个在运行,其余在休眠,靠轮流使用 CPU 共享少数核心。
明天:第 5 天,读[系统调用](#系统调用)、[文件](#文件)、[在终端中查看进程](#在终端中查看进程)。
@@ -558,15 +564,15 @@ top
</details>
动手:在 Linux 上执行 strace -e trace=write echo "order 1024",说明 write 的第一个参数和返回值的含义;再执行 ls -l /proc/self/fd,看看一个进程默认打开了哪些文件描述符。
动手:在 Ubuntu 上执行 strace -e trace=write echo "order 1024"(没有 strace 时用 sudo apt install strace 安装),说明 write 的第一个参数和返回值的含义;再执行 ls -l /proc/self/fd,看看一个进程默认打开了哪些文件描述符。macOS 没有 strace 和 /proc,strace 部分对照[系统调用](#系统调用)一节的输出回答,文件描述符改用 ls -l /dev/fd 查看。
预期:输出 write(1, "order 1024\n", 11) = 11:第一个参数 1 表示标准输出(屏幕),返回值 11 表示写入了 11 个字节;/proc/self/fd 中通常有 0、1、2 三个(标准输入、标准输出、标准错误,在终端中都指向 /dev/pts/0 这类终端设备),以及 ls 读取这个目录时自己打开的 3。
预期:输出 write(1, "order 1024\n", 11) = 11:第一个参数 1 表示标准输出(屏幕),返回值 11 表示写入了 11 个字节;/proc/self/fd 中通常有 0、1、2 三个(标准输入、标准输出、标准错误,在终端中都指向 /dev/pts/0 这类终端设备),以及 ls 读取这个目录时自己打开的 3;macOS 上能看到名为 0、1、2 的三个条目,分别是标准输入、标准输出和标准错误,后面的 3、4 是 ls 自己打开的。
明天:第 6 天,Linux 常用命令。
#### 第 6 天
阅读:[文件与目录的基本操作](Linux.md#文件与目录的基本操作)、[文件属性](Linux.md#文件属性)、[修改权限](Linux.md#修改权限)、[获取文件内容](Linux.md#获取文件内容)、[指令与文件搜索](Linux.md#指令与文件搜索)、[管道指令](Linux.md#八管道指令)、[查看进程](Linux.md#查看进程)。
阅读:[文件与目录的基本操作](Linux.md#文件与目录的基本操作)、[文件属性](Linux.md#文件属性)、[修改权限](Linux.md#修改权限)、[获取文件内容](Linux.md#获取文件内容)、[指令与文件搜索](Linux.md#指令与文件搜索)、[管道指令](Linux.md#八管道指令)、[查看进程](Linux.md#查看进程)、[端口与磁盘](Linux.md#端口与磁盘)、[日志分析](Linux.md#日志分析)。
选读:[文件系统](Linux.md#四文件系统)、[Bash](Linux.md#七bash)、[正则表达式](Linux.md#九正则表达式)。
@@ -596,7 +602,7 @@ top
1. 订单服务预先创建一组线程(线程池),每个请求交给一个空闲的线程,请求多于线程数时在队列中排队;
2. 线程等待数据库、网络时处于阻塞状态,CPU 转去执行其它就绪的线程,多个核心并行运行,每个核心上的线程轮流使用 CPU(上下文切换),所以 100 个请求看起来同时被处理;
3. 两个线程同时扣最后一份宫保鸡丁时,读、算、写交错执行,都读到 1、都写回 0,卖出两份库存只减 1,需要加锁,或在数据库中用带 stock > 0 条件的 UPDATE 扣减;
3. 两个线程同时扣最后一份宫保鸡丁时,读、算、写交错执行,都读到 1、都写回 0,卖出两份库存只减 1,需要加锁,让读、算、写作为一个整体执行,并在锁内先判断库存大于 0(数据库中的做法在[事务](#事务)一节讲);
4. 接收请求(accept4、recvfrom)、写日志(openat、write、close)、发送响应(sendto)都是系统调用,CPU 切换到内核态,由内核的网络协议栈和文件系统完成,订单程序自己只做计算。
</details>
@@ -627,7 +633,7 @@ hostname -I # Ubuntu
IP 地址不好记,而且服务器换了机器地址就会变,所以 App 中写的是域名(Domain Name),例如 shop.example.com。把域名换成 IP 地址的系统称为域名系统(Domain Name System, DNS)。
DNS 是一个分布在全世界的查询系统。手机先问本地的 DNS 服务器(通常是家里的路由器或运营商的服务器),它如果没有缓存答案,就从根服务器开始,依次询问负责 .com 的服务器、负责 example.com 的服务器,最终得到 IP 地址,并缓存一段时间供下次使用。
DNS 是一个分布在全世界的查询系统。手机先问本地的 DNS 服务器,通常是家里的路由器,它一般把问题转给运营商的 DNS 服务器。运营商的服务器如果没有缓存答案,就从根服务器开始,依次询问负责 .com 的服务器、负责 example.com 的服务器,最终得到 IP 地址,并缓存一段时间供下次使用。
用 dig 命令查询 www.baidu.com(macOS 实测,见[附录 A6](#a6-dns-与往返时间)):
@@ -655,7 +661,7 @@ www.a.shifen.com. 600 IN A 183.2.172.177
- 应用层:规定请求和响应的内容,例如 HTTP、DNS;
- 传输层:在两个进程之间传输数据,用端口区分进程,例如 TCP、UDP;
- 网络层:把数据从一台主机送到另一台主机,用 IP 地址寻址,由路由器逐跳转发;
- 链路层:在同一个网络中把数据交给下一站,例如以太网、Wi-Fi。
- 链路层:在同一个网络中把数据交给下一站,例如以太网、Wi-Fi,这一层的数据单位称为帧,用网卡的硬件地址(MAC 地址)找到下一站。
发送时,每一层在数据前面加上自己的首部,称为封装;接收时逐层去掉首部,交给上一层。下单请求的封装过程如下:
@@ -682,12 +688,12 @@ ping -c 4 www.baidu.com
- 重传:发送方一段时间内没有收到确认,就重新发送这部分数据;
- 流量控制和拥塞控制:根据接收方的处理能力和网络的拥堵情况调整发送速度。
通信之前,双方要先建立连接,交换各自的起始序号,这个过程需要 3 个报文,称为三次握手。下面是实验中一次下单请求在 TCP 连接上的全部报文,序号是抓包得到的真实数值(Ubuntu 24.04 实测,见[附录 A7](#a7-http-请求与-tcp-报文)):
通信之前,双方要先建立连接,交换各自的起始序号,这个过程需要 3 个报文,称为三次握手。下面是实验中一次下单请求在 TCP 连接上的主要报文(省略了响应中间的一次确认和关闭连接的报文),序号是抓包得到的真实数值(Ubuntu 24.04 实测,见[附录 A7](#a7-http-请求与-tcp-报文)):
<div align="center"> <img src="pics/intro-tcp-http.png" width="760px"/> </div><br>
1. 客户端发送 SYN,起始序号 3987857382;
2. 服务器回复 SYN + ACK,确认号 3987857383 表示收到了 SYN,同时给出自己的起始序号;
2. 服务器回复 SYN + ACK,确认号 3987857383 表示收到了 SYN(SYN 本身占一个序号,所以是起始序号加 1),同时给出自己的起始序号;
3. 客户端回复 ACK,连接建立,双方可以发送数据;
4. 客户端发送 180 字节的 HTTP 请求,序号从 3987857383 开始,服务器回复确认号 3987857563,正好是 3987857383 + 180。
@@ -705,7 +711,7 @@ TCP 适合必须完整送达的数据,例如下单请求、网页、文件下
### HTTP
超文本传输协议(HyperText Transfer Protocol, HTTP)规定了请求和响应的格式,App 与服务器之间、浏览器与网站之间都用它通信。HTTP/1.1 的报文是纯文本,可以用 curl 直接看到。实验中用一个模拟的订单服务创建订单(Ubuntu 24.04 实测,见[附录 A7](#a7-http-请求与-tcp-报文)):
超文本传输协议(HyperText Transfer Protocol, HTTP)规定了请求和响应的格式,App 与服务器之间、浏览器与网站之间都用它通信。HTTP/1.1 的报文是纯文本,可以用 curl 直接看到。实验中的订单服务是一个运行在本机 8080 端口的小程序,127.0.0.1 是代表本机的特殊地址;为了看到明文,这里用 HTTP 而不是 HTTPS。创建订单(Ubuntu 24.04 实测,见[附录 A7](#a7-http-请求与-tcp-报文)):
```bash
curl -v -H 'Content-Type: application/json' \
@@ -750,11 +756,11 @@ Content-Length: 81
{"orderId": 1024, "dish": "宫保鸡丁", "amount": "28.50", "status": "CREATED"}
```
Content-Length 是 81 而不是字符数,因为“宫保鸡丁”在 UTF-8 中占 12 个字节(见[文字](#文字))。
Content-Length 是 81 而不是字符数 73,因为“宫保鸡丁”在 UTF-8 中占 12 个字节(见[文字](#文字))。
常用的方法还有 PUT(整体替换资源)、DELETE(删除资源)等。GET 和 POST 的区别主要在用途上:
- GET 用于获取数据,参数放在 URL 中,例如 /api/orders?userId=1001,执行多少次结果都一样,浏览器和代理可以缓存它的结果;
- GET 用于获取数据,参数放在 URL 中,例如 /api/orders?userId=1001,它不修改服务器上的数据,重复执行没有副作用,浏览器和代理可以缓存它的结果;
- POST 用于提交数据、创建资源,参数放在请求体中,重复执行会创建多个订单,所以 App 要防止用户连点两次“提交订单”。
HTTP 本身不保存状态,每个请求都是独立的,服务器不会记得上一个请求来自谁。要知道请求来自用户 1001,App 在登录成功后保存服务器发给它的凭证,之后的每个请求都在首部中带上,例如 Cookie 首部或 Authorization 首部中的 Token,服务器据此识别用户。
@@ -788,6 +794,8 @@ dns 0.002281 tcp 0.011717 tls 0.041322 first_byte 0.053568
3. TLS:握手约 30 ms,这次使用的 TLS 1.2 需要两个往返,再加上证书校验等计算;
4. HTTP:发出请求到收到第一个字节约 12 ms,即一个往返时间加上服务器的处理时间。
<div align="center"> <img src="pics/intro-request-timeline.png" width="720px"/> </div><br>
整个过程约 54 ms,其中真正的服务器处理只占一小部分,大部分时间花在网络往返上。这也是“在浏览器中输入网址后发生了什么”这个问题的主线,完整的过程还包括本机获取 IP 地址、查找网关的 MAC 地址等步骤。
分层模型见[计算机网络 - 概述](计算机网络%20-%20概述.md#计算机网络体系结构);IP 地址与 NAT 见[计算机网络 - 网络层](计算机网络%20-%20网络层.md#ip-地址编址方式);TCP 的握手与可靠传输见[计算机网络 - 传输层](计算机网络%20-%20传输层.md#tcp-的三次握手);DNS 与访问网页的完整过程见[计算机网络 - 应用层](计算机网络%20-%20应用层.md#web-页面请求过程);HTTP 报文、状态码和 HTTPS 见 [HTTP](HTTP.md#请求和响应报文)。
@@ -808,7 +816,7 @@ dns 0.002281 tcp 0.011717 tls 0.041322 first_byte 0.053568
<summary>参考要点</summary>
1. IP 地址标识网络中的一台主机,相当于门牌号;端口标识主机上的一个进程,取值 0~65535,相当于房间号,例如 HTTPS 用 443;一个连接由客户端 IP、客户端端口、服务器 IP、服务器端口 4 个数共同确定,不同手机的地址或端口不同就是不同的连接,操作系统据此把数据分给对应的连接。
2. 手机先问本地 DNS 服务器(家里的路由器或运营商的服务器);它有缓存就直接回答,没有就从根服务器开始,依次询问负责 .com、负责 example.com 的服务器,最终得到 IP 地址;结果按有效期(例如 600 秒)缓存,期间可以直接使用。
2. 手机先问本地 DNS 服务器(家里的路由器,它一般转给运营商的 DNS 服务器);有缓存就直接回答,没有就从根服务器开始,依次询问负责 .com、负责 example.com 的服务器,最终得到 IP 地址;结果按有效期(例如 600 秒)缓存,期间可以直接使用。
3. 把请求送达要解决的问题很多,分层后每层只解决一类问题、使用下一层的服务,各层可以独立变化,例如手机从 Wi-Fi 切到 4G 只换了链路层;应用层规定请求和响应的内容(HTTP、DNS);传输层在两个进程之间传数据,用端口区分进程(TCP、UDP);网络层用 IP 地址把数据从一台主机送到另一台,由路由器逐跳转发;链路层在同一个网络中把数据交给下一站(以太网、Wi-Fi)。
</details>
@@ -838,9 +846,9 @@ dns 0.002281 tcp 0.011717 tls 0.041322 first_byte 0.053568
</details>
动手:用 Wireshark 或 tcpdump 抓一次 curl http://example.com 的数据包,找出三次握手的 3 个报文,对照[TCP](#tcp)一节中的图核对序号和确认号的关系。
动手:在一个终端中执行 sudo tcpdump -nn -S 'tcp port 80' 开始抓包(sudo 会要求输入当前用户的登录密码,-S 显示原始序号),在另一个终端中执行 curl http://example.com,然后回到第一个终端按 Ctrl + C 停止,找出三次握手的 3 个报文,对照[TCP](#tcp)一节中的图核对序号和确认号的关系。
预期:前 3 个报文依次是 SYN、SYN + ACK、ACK(tcpdump 中标为 [S]、[S.]、[.]):第 2 个报文的确认号等于第 1 个的序号加 1,第 3 个报文的确认号等于服务器的起始序号加 1;Wireshark 和 tcpdump 默认可能显示相对序号(例如 ack 1),tcpdump 加 -S 显示原始序号。
预期:前 3 个报文依次是 SYN、SYN + ACK、ACK(tcpdump 中标为 [S]、[S.]、[.]):第 2 个报文的确认号等于第 1 个的序号加 1,第 3 个报文的确认号等于服务器的起始序号加 1;不加 -S 时 tcpdump 显示相对序号(例如 ack 1)。
明天:第 9 天,读[HTTP](#http)、[HTTPS](#https)、[把过程连起来](#把过程连起来)。
@@ -857,7 +865,7 @@ dns 0.002281 tcp 0.011717 tls 0.041322 first_byte 0.053568
<details>
<summary>参考要点</summary>
1. GET 获取数据,参数在 URL 中,重复执行结果不变,可以缓存;POST 提交数据、创建资源,参数在请求体中,重复执行会创建多个订单;状态码:2xx 成功(200 OK、201 Created),3xx 重定向,4xx 客户端错误(404 Not Found),5xx 服务器错误。
1. GET 获取数据,参数在 URL 中,不修改服务器上的数据,重复执行没有副作用,可以缓存;POST 提交数据、创建资源,参数在请求体中,重复执行会创建多个订单;状态码:2xx 成功(200 OK、201 Created),3xx 重定向,4xx 客户端错误(404 Not Found),5xx 服务器错误。
2. HTTP 明文传输,同一 Wi-Fi 下的人或沿途的路由器能看到甚至修改内容;HTTPS 在 TCP 和 HTTP 之间加了一层 TLS:加密(协商出只有双方知道的密钥)、认证(服务器出示可信机构签发的证书)、完整性(报文被篡改能被发现);HTTP 默认端口 80,HTTPS 默认 443,HTTPS 建立连接时多了 TLS 握手的往返。
3. DNS 把域名换成 IP 地址,命中缓存约 2 ms;与服务器 TCP 三次握手,约一个往返时间(9 ms);HTTPS 再做 TLS 握手,TLS 1.2 需要两个往返,约 30 ms;发送 HTTP 请求、服务器处理后返回响应,约一个往返加处理时间(12 ms),浏览器收到后显示页面;整个过程约 54 ms,大部分花在网络往返上。
@@ -917,7 +925,7 @@ id user_id dish_id amount status created_at
### SQL
结构化查询语言(Structured Query Language, SQL)是操作关系型数据库的标准语言。在终端中执行 sqlite3 shop.db 打开(不存在时新建)一个数据库文件,然后逐条输入语句。
结构化查询语言(Structured Query Language, SQL)是操作关系型数据库的标准语言。在终端中执行 sqlite3 -header -column shop.db 打开(不存在时新建)一个数据库文件,然后逐条输入语句。-header -column 让查询结果带列名并按列对齐,和下文的输出一样;不加时每行的各列用 | 隔开。
建表时说明每一列的名称和类型:
@@ -938,18 +946,17 @@ CREATE TABLE orders (
);
```
INT 是 4 字节整数,BIGINT 是 8 字节整数,VARCHAR(20) 是最多 20 个字符的字符串,NOT NULL 表示必须填写。插入数据用 INSERT:
INT 是 4 字节整数,BIGINT 是 8 字节整数,VARCHAR(20) 是最多 20 个字符的字符串,NOT NULL 表示必须填写。这些是 MySQL 中的含义,SQLite 只检查 NOT NULL 这类约束,不限制整数的字节数和字符串的长度。插入数据用 INSERT:
```sql
INSERT INTO dishes VALUES (17, '宫保鸡丁', 2850, 30);
INSERT INTO orders VALUES (1024, 1001, 17, 2850, 'CREATED', '2026-10-29 12:30:00');
```
查询用 SELECT,WHERE 后面写条件;修改用 UPDATE:
查询用 SELECT,WHERE 后面写条件:
```sql
SELECT id, user_id, amount, status FROM orders WHERE id = 1024;
UPDATE orders SET status = 'PAID' WHERE id = 1024;
```
```text
@@ -958,6 +965,12 @@ id user_id amount status
1024 1001 2850 CREATED
```
修改用 UPDATE,执行后再查询,status 就变成了 PAID:
```sql
UPDATE orders SET status = 'PAID' WHERE id = 1024;
```
再插入一个订单号同样是 1024 的订单,数据库会拒绝,因为主键不能重复:
```text
@@ -1007,7 +1020,7 @@ SELECT * FROM orders WHERE user_id = 1001;
如果 user_id 上没有索引,数据库只能逐行检查每个订单的 user_id 是否等于 1001,这称为全表扫描。订单表有 100 万行时,就要比较 100 万次。
索引(Index)是按某一列排好序的另一份数据,每一项记录着这一列的值和对应的行在哪里,就像书末尾按拼音排好的索引页。数据库中的索引通常组织成一棵 B+ 树:每个节点是一个页(例如 4 KB 或 16 KB),里面放着几百个排好序的键,从根页开始,每一层根据键的大小选择往哪个子页走,最后到达存放数据的叶子页。
索引(Index)是按某一列排好序的另一份数据,每一项记录着这一列的值和对应的行在哪里,就像书末尾按拼音排好的索引页。数据库中的索引通常组织成一棵多路的平衡树(MySQL 用 B+ 树,本章实验用的 SQLite 用 B 树,区别见[树](#树)一节):每个节点是一个页(数据库读写磁盘的基本单位,SQLite 默认 4 KB,MySQL 默认 16 KB),里面放着几百个排好序的键,从根页开始,每一层根据键的大小选择往哪个子页走,直到找到要查的键。
在 SQLite 中可以自己生成 100 万个订单来对比。先执行 DELETE FROM orders; 清空前面插入的订单,否则订单号 1024 会与生成的订单号冲突(下面这条语句是 SQLite 的写法):
@@ -1026,7 +1039,7 @@ CREATE INDEX idx_user ON orders (user_id);
<div align="center"> <img src="pics/intro-index.png" width="780px"/> </div><br>
没有索引时查询用户 1001 的订单约 37 ms;建立索引后不到 0.1 ms。这 100 万行的索引只有 3 层,找到用户 1001 只需要读根页、中间页和一个叶子页,再按叶子页中的 10 个订单号取出整行(SQLite 3.43 实测,见[附录 A9](#a9-索引))。在 MySQL 中,EXPLAIN 显示没有索引时预计扫描 996761 行,有索引时只读 10 行,耗时从约 100 ms 降到 0.3 ms。
没有索引时查询用户 1001 的订单约 37 ms;建立索引后不到 0.1 ms。这 100 万行的索引只有 3 层,找到用户 1001 只需要读根页、中间页和一个叶子页,再按叶子页中记录的 10 个行号(这里恰好等于订单号)取出整行(SQLite 3.43 实测,见[附录 A9](#a9-索引))。在 MySQL 中,EXPLAIN 显示没有索引时预计扫描 996761 行,有索引时只读 10 行,耗时从约 100 ms 降到 0.3 ms。
EXPLAIN QUERY PLAN 可以显示 SQLite 打算怎样执行查询:
@@ -1046,6 +1059,7 @@ EXPLAIN QUERY PLAN 可以显示 SQLite 打算怎样执行查询:
```sql
BEGIN;
UPDATE dishes SET stock = stock - 1 WHERE id = 17 AND stock > 0;
-- 程序检查上一条修改了几行:0 行表示已售罄,执行 ROLLBACK,不再往下执行
INSERT INTO orders VALUES (1024, 1001, 17, 2850, 'CREATED', '2026-10-29 12:30:00');
COMMIT;
```
@@ -1056,7 +1070,7 @@ COMMIT;
不用事务时,每条语句执行完就立即生效,库存变成 29,却没有订单;用事务时,数据库发现这个事务没有提交,撤销了其中的修改,库存仍是 30。
UPDATE 语句中的 stock > 0 也很重要。库存已经为 0 时,这条语句一行也不修改,程序根据“影响了 0 行”判断已售罄,回滚事务。数据库在修改一行时会给这一行加锁,另一个事务要修改同一行必须等前一个提交或回滚,所以两个用户同时抢最后一份菜,也只有一个能扣减成功,不会出现[线程](#线程)一节中两个线程都卖出的问题。
UPDATE 语句中的 stock > 0 也很重要。库存已经为 0 时,这条语句一行也不修改,程序根据“影响了 0 行”判断已售罄,回滚事务。MySQL 在修改一行时会给这一行加锁,另一个事务要修改同一行必须等前一个提交或回滚,再按最新的库存判断 stock > 0(SQLite 更简单,同一时刻只允许一个事务写)。所以两个用户同时抢最后一份菜,也只有一个能扣减成功,不会出现[线程](#线程)一节中两个线程都卖出的问题。
事务的四个性质通常称为 ACID:
@@ -1090,9 +1104,9 @@ SQL 的更多语法见 [SQL 语法](SQL%20语法.md#七查询);事务、ACID
</details>
动手:执行 sqlite3 shop.db,按[SQL](#sql)一节建立 dishes 和 orders 两张表,插入订单 1024 和 1026,写出“我的订单”页面的 JOIN 查询。
动手:执行 sqlite3 -header -column shop.db,按[SQL](#sql)一节建立 dishes 和 orders 两张表,插入订单 1024 和 1026,写出“我的订单”页面的 JOIN 查询。
预期:需要先在 dishes 表中插入鱼香肉丝(价格 2600,编号自定,例如 18)和对应的订单 1026;JOIN 查询输出两行,1026 鱼香肉丝 2600 2026-10-29 18:05:00 在前,1024 宫保鸡丁 2850 2026-10-29 12:30:00 在后。
预期:订单 1026 用到的鱼香肉丝要先插入 dishes 表;JOIN 查询输出两行,1026 鱼香肉丝 2600 2026-10-29 18:05:00 在前,1024 宫保鸡丁 2850 2026-10-29 12:30:00 在后。
明天:第 11 天,分组与连接。
@@ -1117,7 +1131,7 @@ SQL 的更多语法见 [SQL 语法](SQL%20语法.md#七查询);事务、ACID
动手:在 SQLite 中按[182](SQL%20练习.md#182-duplicate-emails)、[175](SQL%20练习.md#175-combine-two-tables)、[181](SQL%20练习.md#181-employees-earning-more-than-their-managers)三题逐题练习:执行题目的 SQL Schema 建表并插入数据,先自己写出查询,再对照笔记的解法,做完一题再做下一题(182 和 175 都用表名 Person,后建的会覆盖先建的)。
预期:182 的结果是 a@b.com;175 的结果是 Allen Wang,City 和 State 为空,换成内连接则一行都没有;181 的结果是 Joe;182 和 175 都用表名 Person,后建的会删掉先建的,要一题建表、查询完再做下一题。
预期:182 的结果是 a@b.com;175 的结果是 Allen Wang,City 和 State 为空,换成内连接则一行都没有;181 的结果是 Joe。
明天:第 12 天,读[索引](#索引)、[事务](#事务)。
@@ -1134,7 +1148,7 @@ SQL 的更多语法见 [SQL 语法](SQL%20语法.md#七查询);事务、ACID
<details>
<summary>参考要点</summary>
1. 索引是按某一列排好序的另一份数据,组织成 B+ 树,查找时从根页往下几层就能定位,不用逐行检查(全表扫描);例如 100 万个订单按 user_id 查询,没有索引约 37 ms,建索引后不到 0.1 ms,索引只有 3 层;每插入或修改一行,相关的索引都要同步更新,还占额外的空间,所以只为经常出现在查询条件中的列建索引。
1. 索引是按某一列排好序的另一份数据,组织成多路的平衡树(MySQL 用 B+ 树,SQLite 用 B 树),查找时从根页往下几层就能定位,不用逐行检查(全表扫描);例如 100 万个订单按 user_id 查询,没有索引约 37 ms,建索引后不到 0.1 ms,索引只有 3 层;每插入或修改一行,相关的索引都要同步更新,还占额外的空间,所以只为经常出现在查询条件中的列建索引。
2. 原子性(Atomicity):事务中的修改要么全部完成,要么全部撤销;一致性(Consistency):事务前后数据都满足规则,例如库存不为负;隔离性(Isolation):并发执行的事务互不干扰,像是一个接一个执行;持久性(Durability):事务一旦提交,即使随后断电,修改也不会丢失。
3. 扣库存和写订单必须同时生效或同时不生效,否则会少了一份菜却没有订单;不用事务时,扣完库存后崩溃,库存变成 29,订单表中没有记录;用事务时,数据库发现事务没有提交,撤销其中的修改,库存仍是 30,这靠日志实现:重启时补完已提交的事务,撤销未提交的事务。
@@ -1142,7 +1156,7 @@ SQL 的更多语法见 [SQL 语法](SQL%20语法.md#七查询);事务、ACID
动手:按[索引](#索引)一节在 SQLite 中生成 100 万个订单,执行 .timer on,比较建立 idx_user 前后查询用户 1001 的订单的耗时,并用 EXPLAIN QUERY PLAN 查看执行方式的变化。
预期:先清空 orders 表(DELETE FROM orders)或换一个新的数据库文件,否则与已有的订单 1024 主键冲突,整条 INSERT 失败;建索引前查询约 37 ms,执行方式为 SCAN orders,建立 idx_user 后不到 0.1 ms(.timer 显示 real 0.000),执行方式变为 SEARCH orders USING INDEX idx_user (user_id=?),用户 1001 有 10 个订单。
预期:先清空 orders 表(DELETE FROM orders),否则与已有的订单 1024 主键冲突,整条 INSERT 失败;耗时随机器不同,第一次查询可能慢一些,多执行几次再比较;建索引前查询约 37 ms,执行方式为 SCAN orders,建立 idx_user 后不到 0.1 ms(.timer 显示 real 0.000),执行方式变为 SEARCH orders USING INDEX idx_user (user_id=?),用户 1001 有 10 个订单。
综合题:为外卖 App 设计 dishes 和 orders 两张表:写出下单(扣库存并写入订单)的 SQL,说明为什么要放在一个事务中、UPDATE 中的 stock > 0 起什么作用,以及“我的订单”页面需要在哪一列上建索引。(见[SQL](#sql)、[索引](#索引)、[事务](#事务))
@@ -1152,7 +1166,7 @@ SQL 的更多语法见 [SQL 语法](SQL%20语法.md#七查询);事务、ACID
1. dishes 表:id 为主键,另有 name、price、stock,orders 表:id 为 BIGINT 主键,另有 user_id、dish_id、amount、status、created_at,价格和金额都按分保存为整数;
2. 下单 SQL:BEGIN; UPDATE dishes SET stock = stock - 1 WHERE id = 17 AND stock > 0; INSERT INTO orders VALUES (1024, 1001, 17, 2850, 'CREATED', '2026-10-29 12:30:00'); COMMIT;
3. 两条语句放在一个事务中,要么都生效、要么都不生效,扣完库存后崩溃时数据库会撤销修改,不会出现库存少了却没有订单;
4. stock > 0 保证库存为 0 时 UPDATE 一行也不修改,程序根据影响了 0 行判断已售罄并回滚,数据库修改一行时会给它加锁,两人同时抢最后一份只有一个能扣减成功;
4. stock > 0 保证库存为 0 时 UPDATE 一行也不修改,程序根据影响了 0 行判断已售罄并回滚;MySQL 修改一行时会给它加锁(SQLite 同一时刻只允许一个事务写),两人同时抢最后一份只有一个能扣减成功;
5. “我的订单”按 user_id 查询,要在 orders 表的 user_id 列上建索引:CREATE INDEX idx_user ON orders (user_id)。
</details>
@@ -1169,7 +1183,7 @@ SQL 的更多语法见 [SQL 语法](SQL%20语法.md#七查询);事务、ACID
<div align="center"> <img src="pics/intro-array-list.png" width="780px"/> </div><br>
因为元素大小相同、位置连续,第 i 个元素的地址可以直接算出来:起始地址 + i × 8,所以按位置读取任何一个元素都只要一步,与数组多长无关。
因为元素大小相同、位置连续,下标为 i(从 0 开始)的元素地址可以直接算出来:起始地址 + i × 8,所以按位置读取任何一个元素都只要一步,与数组多长无关。
代价是插入和删除。新订单 1026 要显示在列表最前面,就得把后面所有元素依次后移一格;列表有 n 个订单,就要移动 n 次。数组的长度在创建时确定,放满之后要申请一块更大的内存,把元素全部复制过去。
@@ -1224,7 +1238,7 @@ low = 0,high = n - 1
没有找到
```
每比较一次,剩下的范围减半,n 个元素最多比较 log₂n 向下取整再加 1 次。100 万约等于 2²⁰,所以最多比较 20 次:
每比较一次,剩下的范围减半。log₂n 指的是 n 连续除以 2 多少次才剩 1,例如 log₂8 = 3,所以 n 个元素最多比较 log₂n 向下取整再加 1 次(8 个最多 4 次)。100 万约等于 2²⁰,所以最多比较 20 次:
```bash
echo 'l(1000000)/l(2)' | bc -l
@@ -1238,7 +1252,7 @@ echo 'l(1000000)/l(2)' | bc -l
### 树
有序数组查找快,但插入一个新订单要移动后面的所有元素。二叉查找树(Binary Search Tree, BST)把二分查找的过程变成了一种数据结构:每个节点最多有两个子节点,左子树中的值都比它小,右子树中的值都比它大。
有序数组查找快,但在中间插入一个元素,就要移动后面的所有元素。二叉查找树(Binary Search Tree, BST)把二分查找的过程变成了一种数据结构:每个节点最多有两个子节点,最上面的节点称为根;一个节点的左子节点及其下面的所有节点称为左子树,其中的值都比它小,右子树中的值都比它大。
上图右边是同一组下单时间构成的树,根是中间的 12:21。查找 12:30 时从根开始,比 12:21 大就往右,比 12:34 小就往左,经过的节点和二分查找比较的元素完全相同。插入新订单时,按同样的方法找到位置,挂上一个新节点即可,不用移动其它节点。
@@ -1260,9 +1274,13 @@ echo 'l(1000000)/l(2)' | bc -l
插入 2600 980 1600 2600 2850 3200 比较 3 次
```
插入排序每次只能把一个元素挪到位,数据完全打乱时,第 k 个元素平均要和左边一半的元素比较,n 个元素总共约 n²/4 次。
插入排序每次只能把一个元素挪到位,数据完全打乱时,第 k 个元素平均要和左边一半的元素比较,约 k/2 次,n 个元素总共约 (1 + 2 + … + n) / 2 ≈ n²/4 次。
归并排序(Merge Sort)换了一种思路:把数组分成两半,分别排好序,再把两个有序的部分合并。合并时只需比较两部分的第一个元素,取较小的放进结果,例如合并 [980, 2850] 和 [1600, 2600, 3200],依次取出 980、1600、2600、2850、3200,每取一个只比较一次。每一半又按同样的方法继续分下去,100 万个元素分 20 层就只剩单个元素,每一层合并共比较不到 n 次,所以总共约 n log₂n 次。
归并排序(Merge Sort)换了一种思路:把数组分成两半,分别排好序,再把两个有序的部分合并。每一半又按同样的方法继续分下去,直到只剩单个元素,单个元素本身就是有序的。合并时只需比较两部分的第一个元素,取较小的放进结果。同样以这 5 个金额为例:
<div align="center"> <img src="pics/intro-merge-sort.png" width="780px"/> </div><br>
最后一次合并 [1600, 2850] 和 [980, 2600, 3200],依次取出 980、1600、2600、2850,每取一个只比较一次,左边取完后 3200 直接放入。100 万个元素分 20 层就只剩单个元素,每一层合并共比较不到 n 次,所以总共约 n log₂n 次。
### 时间复杂度
@@ -1421,29 +1439,29 @@ sort 使用的是 O(n log n) 的算法,100 万个数排序只用了约 0.5 秒
推演:哈希表与冲突。哈希表有 8 个桶(编号 0~7),哈希函数是 订单号 % 8,冲突用拉链法处理,新元素接在链表末尾。
1. 依次放入订单号 1024、1025、1026、1030、1032、1041、1047,画出每个桶的链表;
2. 查找 1041 和 1040,各要比较几次;
3. 再放入 1048,0 号桶变成什么样。
1. 依次放入订单号 1027、1030、1035、1036、1043、1049、1050,画出每个桶的链表;
2. 查找 1043 和 1028,各要比较几次;
3. 再放入 1051,3 号桶变成什么样;把桶数扩大到 16、按 订单号 % 16 重新分配后,这 4 个订单分别在哪个桶。
<details>
<summary>参考答案</summary>
1. 先算桶号:1024 % 8 = 0,1025 % 8 = 1,1026 % 8 = 2,1030 % 8 = 6,1032 % 8 = 0(与 1024 冲突),1041 % 8 = 1(与 1025 冲突),1047 % 8 = 7。结果:
1. 先算桶号:1027 % 8 = 3,1030 % 8 = 6,1035 % 8 = 3(与 1027 冲突),1036 % 8 = 4,1043 % 8 = 3(再次冲突),1049 % 8 = 1,1050 % 8 = 2。结果:
```text
桶 0:1024 → 1032
桶 1:1025 → 1041
桶 2:1026
桶 3:空
桶 4:空
桶 0:空
桶 1:1049
桶 2:1050
桶 3:1027 → 1035 → 1043
桶 4:1036
桶 5:空
桶 6:1030
桶 7:1047
桶 7:空
```
2. 1041 % 8 = 1,到 1 号桶,比较 1025(不是)、1041(是),共 2 次;1040 % 8 = 0,到 0 号桶,比较 1024、1032 都不是,链表结束,共比较 2 次,没有找到。
2. 1043 % 8 = 3,到 3 号桶,依次比较 1027、1035、1043,共 3 次;1028 % 8 = 4,到 4 号桶,比较 1036 不是,链表结束,共比较 1 次,没有找到。
3. 1048 % 8 = 0,接在 0 号桶末尾:1024 → 1032 → 1048。7 个桶装 8 个订单,元素再多下去链表会越来越长,这时应该把桶数扩大到 16,按 订单号 % 16 重新分配。
3. 1051 % 8 = 3,接在 3 号桶末尾:1027 → 1035 → 1043 → 1051。8 个桶装了 8 个订单,平均每桶 1 个,但这组订单号恰好集中在 3 号桶,查找它们要比较好几次。扩大到 16 个桶后,1027 % 16 = 3、1043 % 16 = 3,1035 % 16 = 11、1051 % 16 = 11,长度为 4 的链表拆成了两条长度为 2 的链表。
</details>
@@ -1460,7 +1478,7 @@ sort 使用的是 O(n log n) 的算法,100 万个数排序只用了约 0.5 秒
<summary>参考要点</summary>
1. 二分查找每比较一次排除一半,时间复杂度是 O(log n);n 个元素最多比较 log₂n 向下取整再加 1 次,100 万约等于 2²⁰,最多比较 20 次;前提是数据已排好序并能按位置直接读取,所以只适用于有序数组。
2. 哈希表的元素没有顺序,不能做“12:00~12:30 的订单”这类范围查询;二叉树每个节点只有两个分支,100 万个元素至少 20 层,放在磁盘上每往下一层就读一次磁盘,20 次随机读要 1 毫秒以上;B+ 树每个节点占一个磁盘页、放几百个键,树很矮,100 万个条目只要 3 层,3 层约能容纳 2500 万个;B+ 树的数据只放在叶子页,叶子页之间用链表相连,便于范围查询。
2. 哈希表的元素没有顺序,不能做“12:00~12:30 的订单”这类范围查询;二叉树每个节点只有两个分支,100 万个元素至少 20 层,放在磁盘上每往下一层就读一次磁盘,20 次随机读要 1 毫秒以上;B+ 树这类多路树每个节点占一个磁盘页、放几百个键,树很矮,实验中 100 万个条目只要 3 层,3 层约能容纳 2500 万个;B+ 树的数据只放在叶子页,叶子页之间用链表相连,便于范围查询。
</details>
@@ -1499,28 +1517,27 @@ a 12:03 12:08 12:15 12:21 12:30 12:34 12:42 12:56
</details>
推演:二叉查找树。1. 按 12:21、12:08、12:34、12:03、12:15、12:30、12:42、12:56 的顺序把下单时间插入一棵空的二叉查找树,画出结果,并写出查找 12:56 和 12:25 经过的节点;
2. 如果按从早到晚的顺序 12:03、12:08……12:56 插入,树是什么样子,查找 12:56 要比较几次。
推演:二叉查找树。规则:从根开始,比当前节点小就往左,大就往右,走到空位置时放下。
规则:从根开始,比当前节点小就往左,大就往右,走到空位置时放下。
1. 按 12:21、12:08、12:34、12:03、12:15、12:30、12:42、12:56 的顺序把下单时间插入一棵空的二叉查找树,画出结果,并写出查找 12:56 和 12:25 经过的节点;
2. 如果按从早到晚的顺序 12:03、12:08……12:56 插入,树是什么样子,查找 12:56 要比较几次。
<details>
<summary>参考答案</summary>
1. 结果如下(右子树画在上方):
1. 结果如下:
```text
12:56
12:42
12:34
12:30
12:21
12:15
12:08
12:03
12:21
/ \
12:08 12:34
/ \ / \
12:03 12:15 12:30 12:42
\
12:56
```
即根为 12:21,左子节点 12:08(其左右为 12:03、12:15),右子节点 12:34(其左右为 12:30、12:42),12:56 是 12:42 的右子节点,树高 4 层。查找 12:56:12:21 → 12:34 → 12:42 → 12:56,比较 4 次。查找 12:25:12:21(往右)→ 12:34(往左)→ 12:30(往左,左边为空),比较 3 次,没有找到。
根为 12:21,12:56 比 12:21、12:34、12:42 都大,挂在 12:42 的右边,树高 4 层。查找 12:56:12:21 → 12:34 → 12:42 → 12:56,比较 4 次。查找 12:25:12:21(往右)→ 12:34(往左)→ 12:30(往左,左边为空),比较 3 次,没有找到。
2. 每个新元素都比已有的大,总是挂在最右边,树变成一条 8 层的“链”:12:03 → 12:08 → … → 12:56,查找 12:56 要比较 8 次,与逐个查找一样。这就是实际使用红黑树、B+ 树这类能自动保持平衡的树的原因。
@@ -1588,9 +1605,9 @@ a 12:03 12:08 12:15 12:21 12:30 12:34 12:42 12:56
<summary>答题思路</summary>
1. 按订单号查找:用哈希表,以订单号为键算出桶号,平均 O(1),100 万个订单平均比较约 1.5 次;
2. 按时间从新到旧列出最近的订单:订单按下单时间排好序保存,最新的在末尾,从末尾往前读,列出 k 个只要读 k 次,不必每次重新排序;
3. 查 12:00~12:30 的订单:用按下单时间组织的平衡二叉查找树(例如红黑树)或 B+ 树,先用 O(log n) 找到 12:00,再按从小到大的顺序往后读,超过 12:30 就停止;
4. 哈希表做不到后两种操作,因为元素没有顺序,有序数组也能用二分查找找到起点,但插入新订单要移动后面的元素,是 O(n),树中插入只要挂上一个新节点。
2. 按时间从新到旧列出最近的订单:用数组按下单顺序保存,新订单的时间总是最晚,追加到末尾(通常是 O(1))数组就自然按时间有序;从末尾往前读,列出 k 个是 O(k),不必每次重新排序;
3. 查 12:00~12:30 的订单:在同一个按时间有序的数组中,用二分查找以 O(log n) 找到第一个不早于 12:00 的订单,再往后读,超过 12:30 就停止;范围内有 k 个订单时共 O(log n + k);
4. 哈希表做不到后两种操作,因为元素没有顺序;如果要按一个不随时间递增的值排序(例如金额),新元素要插到中间,有序数组插入要移动后面的元素,是 O(n),这时改用平衡二叉查找树(例如红黑树)或 B+ 树,查找和插入都是 O(log n),同样能找到起点后顺序往后读。
</details>
@@ -1615,15 +1632,28 @@ Git 记录代码的每一次修改,可以回到任何一个历史版本,也
<details>
<summary>参考要点</summary>
1. Git 是分布式版本控制,每个人的电脑上都有一份完整的代码和历史,SVN 是集中式,只有中心服务器有一份;集中式的服务器出故障时所有人都无法工作,而且必须联网才能提交,Git 不联网也能提交;Git 新建、合并分支只需移动指针,速度很快,集中式新建分支相当于复制一份完整的代码。
1. Git 是分布式版本控制,每个人的电脑上都有完整的历史;SVN 是集中式,完整的历史只在中心服务器上,每个人只有某个版本的工作副本。SVN 不联网也能修改和撤销本地改动,但提交、查看历史要连接服务器,服务器出故障时这些操作都做不了;Git 不联网也能提交、查看历史。SVN 的分支是服务器上的廉价复制,Git 新建、合并分支只需在本地移动指针。
2. 两个分支修改了同一个文件的同一行,合并时就会冲突;Git 在文件中用 <<<<<<<、=======、>>>>>>> 标出两边的内容,把这一段改成最终想要的内容,并删掉这三行标记;然后执行 git add 标记冲突已解决,再执行 git commit 完成合并。
3. git stash 把工作区中还没提交的修改存起来,工作区回到干净的状态,可以安全地切换分支;例如在 dev 上开发到一半,要去修 master 上的 bug,先执行 git stash,修完回来用 git stash pop 恢复;未跟踪的新文件默认不储藏,需要加 -u。
</details>
动手:新建一个仓库,在两个分支上修改同一行后合并,手动解决冲突并提交,最后用 git log --graph 查看历史。
动手:在终端中依次执行下面的命令,两个分支把宫保鸡丁的价格改成不同的值后合并。前两行设置提交记录中的用户名和邮箱,只需执行一次;没有 git 命令时,Ubuntu 用 sudo apt install git 安装,macOS 按弹出的提示安装命令行工具。git switch -c dev 新建并切换到 dev 分支,git switch - 回到原来的分支(默认叫 master 或 main,取决于配置)。
预期:git merge 时提示 CONFLICT (content): Merge conflict in 文件名,文件中出现 <<<<<<< HEAD、=======、>>>>>>> 分支名 三行标记;改好后 git add、git commit,git log --graph 中两条分支线(|\ 和 |/)汇合到一个合并提交。
```bash
git config --global user.name "你的名字"
git config --global user.email "you@example.com"
mkdir shop && cd shop && git init
echo "宫保鸡丁 2850" > menu.txt
git add menu.txt && git commit -m "add menu"
git switch -c dev
echo "宫保鸡丁 2600" > menu.txt && git commit -am "price 2600"
git switch -
echo "宫保鸡丁 3000" > menu.txt && git commit -am "price 3000"
git merge dev
```
预期:git merge 提示 CONFLICT (content): Merge conflict in menu.txt,menu.txt 变成 <<<<<<< HEAD、宫保鸡丁 3000、=======、宫保鸡丁 2600、>>>>>>> dev 五行;把文件改成想保留的一行(例如宫保鸡丁 2600),执行 git add menu.txt 和 git commit -m "merge dev" 完成合并;git log --oneline --graph 中两条分支线(|\ 和 |/)汇合到这个合并提交。
明天:第 19 天,复习全篇。
@@ -1646,12 +1676,12 @@ Git 记录代码的每一次修改,可以回到任何一个历史版本,也
2. 程序:订单程序从磁盘上的可执行文件装入内存成为进程,CPU 按程序计数器逐条执行指令,扣库存的一行代码拆成读、减、写几条指令;
3. 操作系统:请求交给线程池中的一个线程,线程等数据库时阻塞、CPU 去执行别的线程,收发网络数据和写日志都通过系统调用由内核完成;
4. 网络:DNS 把 shop.example.com 解析为 203.0.113.10,TCP 三次握手(HTTPS 还有 TLS 握手)后发送 POST /api/orders,服务器返回 201 Created 和订单号 1024;
5. 数据库:在一个事务中用带 stock > 0 条件的 UPDATE 把库存从 30 减为 29,插入订单 1024 后提交,主键保证订单号不重复,行锁保证最后一份只卖出一次;
6. 数据结构:数据库的 B+ 树索引 3 层就能定位一行,按订单号查找可以用哈希表,后厨按队列先进先出做菜。
5. 数据库:在一个事务中用带 stock > 0 条件的 UPDATE 把库存从 30 减为 29,插入订单 1024 后提交,主键保证订单号不重复,MySQL 给正在修改的库存行加锁,保证最后一份只卖出一次;
6. 数据结构:数据库的索引是多路平衡树(MySQL 中是 B+ 树),实验中 3 层就能定位一行,按订单号查找可以用哈希表,后厨按队列先进先出做菜。
</details>
之后按目标选择[Java 后端](学习路线%20-%20Java%20后端.md)或[大模型应用开发](学习路线%20-%20大模型应用开发.md),两条路线都要求先掌握一门编程语言的基础语法;离面试只有两三周时,用[面试冲刺](学习路线%20-%20面试冲刺.md)复习。
之后按目标选择[Java 后端](学习路线%20-%20Java%20后端.md)或[大模型应用开发](学习路线%20-%20大模型应用开发.md)。两条路线都不讲语法:Java 后端要求会 Java,大模型应用开发的动手大多用 Python,开头的“开始之前”列出了几个自查的小程序,写不出来时先学这门语言的基础语法;离面试只有两三周时,用[面试冲刺](学习路线%20-%20面试冲刺.md)复习。
## 附录:实测
@@ -1738,6 +1768,8 @@ $ od -A x -t x1 -N 16 /bin/ls
000000 7f 45 4c 46 02 01 01 00 00 00 00 00 00 00 00 00
```
这是还没有链接的目标文件,地址和偏移都还是 0。链接后,adrp 算出 stock 所在页的地址,stock 在页内的偏移填进 ldr 和 str 的 [x1] 中(objdump -dr 可以看到这三处重定位)。
macOS 上的 /bin/ls 同时包含 x86_64 和 arm64e 两种指令,开头的 4 个字节是 ca fe ba be(多架构的 Mach-O 文件)。
### A4 存储器的访问延迟