diff --git a/notes/pics/intro-array-list.png b/notes/pics/intro-array-list.png
index 405f4326..b57e4ba6 100644
Binary files a/notes/pics/intro-array-list.png and b/notes/pics/intro-array-list.png differ
diff --git a/notes/pics/intro-cpu-steps.png b/notes/pics/intro-cpu-steps.png
index debc1095..c74f3f82 100644
Binary files a/notes/pics/intro-cpu-steps.png and b/notes/pics/intro-cpu-steps.png differ
diff --git a/notes/pics/intro-index.png b/notes/pics/intro-index.png
index a115ee38..ed5c1b4d 100644
Binary files a/notes/pics/intro-index.png and b/notes/pics/intro-index.png differ
diff --git a/notes/pics/intro-layers.png b/notes/pics/intro-layers.png
index 8a85cb7c..696d0a47 100644
Binary files a/notes/pics/intro-layers.png and b/notes/pics/intro-layers.png differ
diff --git a/notes/pics/intro-merge-sort.png b/notes/pics/intro-merge-sort.png
new file mode 100644
index 00000000..0c64c716
Binary files /dev/null and b/notes/pics/intro-merge-sort.png differ
diff --git a/notes/pics/intro-overview.png b/notes/pics/intro-overview.png
index d0fd349f..56a2af4f 100644
Binary files a/notes/pics/intro-overview.png and b/notes/pics/intro-overview.png differ
diff --git a/notes/pics/intro-request-timeline.png b/notes/pics/intro-request-timeline.png
new file mode 100644
index 00000000..6825d8d3
Binary files /dev/null and b/notes/pics/intro-request-timeline.png differ
diff --git a/notes/pics/intro-utf8.png b/notes/pics/intro-utf8.png
new file mode 100644
index 00000000..844c9f0e
Binary files /dev/null and b/notes/pics/intro-utf8.png differ
diff --git a/notes/学习路线 - 零基础.md b/notes/学习路线 - 零基础.md
index 7bf987f1..faebfcb7 100644
--- a/notes/学习路线 - 零基础.md
+++ b/notes/学习路线 - 零基础.md
@@ -82,7 +82,7 @@
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。每个字节开头的几位说明了它是一个字的开始还是中间,程序据此把字节流切分成一个个字。
+
+
同一段文字用不同的编码保存,字节也不同。例如用 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
-- 订单号 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
参考要点
-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。
@@ -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-机器指令)):
-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
参考要点
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)成机器指令。
动手:执行 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
参考要点
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 就提示售罄。
-动手:执行 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
-动手:在 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 切换到内核态,由内核的网络协议栈和文件系统完成,订单程序自己只做计算。
@@ -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-报文)):
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,即一个往返时间加上服务器的处理时间。
+
+
整个过程约 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
参考要点
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)。
@@ -838,9 +846,9 @@ dns 0.002281 tcp 0.011717 tls 0.041322 first_byte 0.053568
-动手:用 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
参考要点
-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);
-没有索引时查询用户 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
-动手:执行 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
参考要点
-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)。
@@ -1169,7 +1183,7 @@ SQL 的更多语法见 [SQL 语法](SQL%20语法.md#七查询);事务、ACID
-因为元素大小相同、位置连续,第 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 个金额为例:
+
+
+
+最后一次合并 [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 个订单分别在哪个桶。
参考答案
-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 的链表。
@@ -1460,7 +1478,7 @@ sort 使用的是 O(n log n) 的算法,100 万个数排序只用了约 0.5 秒
参考要点
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+ 树的数据只放在叶子页,叶子页之间用链表相连,便于范围查询。
@@ -1499,28 +1517,27 @@ a 12:03 12:08 12:15 12:21 12:30 12:34 12:42 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 要比较几次。
+推演:二叉查找树。规则:从根开始,比当前节点小就往左,大就往右,走到空位置时放下。
-规则:从根开始,比当前节点小就往左,大就往右,走到空位置时放下。
+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. 结果如下(右子树画在上方):
+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
答题思路
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),同样能找到起点后顺序往后读。
@@ -1615,15 +1632,28 @@ Git 记录代码的每一次修改,可以回到任何一个历史版本,也
参考要点
-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。
-动手:新建一个仓库,在两个分支上修改同一行后合并,手动解决冲突并提交,最后用 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 层就能定位一行,按订单号查找可以用哈希表,后厨按队列先进先出做菜。
-之后按目标选择[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 存储器的访问延迟