Update 学习路线 - 零基础.md

补充练习的参考要点和动手的预期结果,调整补码和字节序的说明。
This commit is contained in:
CyC2018
2026-10-30 02:57:58 +08:00
parent e4066172f2
commit 2b81871118
+295 -8
View File
@@ -71,7 +71,6 @@
* [参考资料](#参考资料)
<!-- GFM-TOC -->
适合没有学过计算机课程的读者,不需要会编程。全篇用一次手机下单的例子,讲数据怎样表示、程序怎样运行、操作系统做什么、请求怎样通过网络送达、订单怎样存进数据库,以及常用的数据结构。文中的命令可以在 macOS 和 Ubuntu 的终端中直接运行,每章末尾链接了讲得更深入的笔记。
## 一、总览:一次下单经过了什么
@@ -91,7 +90,7 @@
### 学习安排
每天约 1 小时,读完当天的小节后做章末的练习。
每天约 1 小时,读完当天的小节后做章末的练习,答完展开参考要点核对,动手的结果对照预期。
| 天 | 阅读 | 练习 |
| :-: | --- | --- |
@@ -144,7 +143,7 @@ echo 'obase=2; 1024' | bc
订单号 1024 只需要 11 位,用 4 个字节就够了。但订单号会一直增长,大型平台的订单总数会超过 21 亿,所以订单号通常用 8 个字节保存。超出范围时数值会“溢出”,例如 4 字节整数 2147483647 再加 1,结果变成负数 -2147483648。
负数用补码(Two's Complement)表示:把正数的各位取反再加 1。例如退款 -2850 分,在 4 个字节中是 ff ff f4 de。这样设计的好处是加法电路不用区分正负,-2850 + 2850 按二进制直接相加,进位溢出最高位后正好得到 0。
负数用补码(Two's Complement)表示:把正数的各位取反再加 1。例如退款 -2850 分,在 4 个字节中是 ff ff f4 de。这样设计的好处是加法电路不用区分正负,-2850 + 2850 按二进制直接相加,进位溢出最高位后正好得到 0。4 字节共有 2³² 种取值,补码把最高位为 1 的一半分给负数,所以最大值是 2³¹ − 1 = 2147483647,最小值是 −2³¹。
### 小数
@@ -199,7 +198,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 处理器都是这样;
- 订单号 1024 存为 00 04 00 00 00 00 00 00:低位的字节放在前面,称为小端序(Little-Endian),常见的 x86 和 ARM 处理器都是这样;高位在前的称为大端序(Big-Endian),网络传输中的整数通常用大端序;
- 金额 2850 等于 0x0B22,同样低位在前,存为 22 0b 00 00;
- 菜名的 12 个字节后面跟一个 00,表示字符串结束;
- 最后 7 个字节不保存数据,用于把总长度补齐到 8 的倍数,处理器按这样对齐的地址读取更快。
@@ -221,8 +220,20 @@ printf '宫保鸡丁' | od -An -tx1
1. 1 个字节能表示多少种取值?订单号为什么通常用 8 个字节保存?(见[二进制与字节](#二进制与字节)、[整数](#整数))
2. 负数在计算机中怎样表示?什么是补码?(见[整数](#整数))
<details>
<summary>参考要点</summary>
1. 1 个字节是 8 位,有 2⁸ = 256 种取值,可以表示 0~255;4 字节整数最大约 21 亿(2147483647),大平台的订单总数会超过它,超出后数值溢出变成负数;8 字节整数最大约 922 亿亿,订单号一直增长也够用。
2. 负数用补码表示:把对应正数的每一位取反(0 变 1、1 变 0)再加 1;例如 -2850 在 4 个字节中是 ff ff f4 de;这样加法电路不用区分正负,-2850 + 2850 直接按二进制相加,进位溢出最高位后正好得到 0。
</details>
动手:执行 echo 'obase=2; 1024' | bc 和 echo 'obase=16; 2850' | bc,把订单号和金额换成二进制和十六进制,再对照[整数](#整数)一节说出 4 字节整数的最大值为什么是 2147483647。
预期:1024 的二进制是 10000000000(1 后面跟 10 个 0),2850 的十六进制是 B22(即 0x0B22);4 字节共 32 位、2³² 种取值,补码把一半分给负数,0 和正数占另一半,所以最大值是 2³¹ − 1 = 2147483647。
明天:第 2 天,读[小数](#小数)、[文字](#文字)、[一个订单在内存中的样子](#一个订单在内存中的样子)。
#### 第 2 天
自测:
@@ -231,8 +242,21 @@ printf '宫保鸡丁' | od -An -tx1
2. UTF-8 中一个汉字占几个字节?为什么会出现乱码?(见[文字](#文字))
3. 什么是大端和小端?常见的 x86、ARM 处理器用哪一种?(见[一个订单在内存中的样子](#一个订单在内存中的样子))
<details>
<summary>参考要点</summary>
1. float、double 是二进制浮点数,0.1、1.10 这类小数在二进制中无限循环,只能存近似值;1.10 + 2.20 得到 3.3000000000000003,与 3.30 不相等,大量订单累加对账时会差一分钱;金额应以分为单位存成整数(28.50 元存为 2850),或用 MySQL 的 DECIMAL、Java 的 BigDecimal 这类十进制定点数。
2. UTF-8 中常用汉字占 3 个字节,“宫保鸡丁”共 12 个字节;乱码是因为写入和读取用了不同的编码,例如按 UTF-8 写入、按 GBK 读取,字节被切成另外几个字,显示为“瀹繚楦′竵”;全程统一使用 UTF-8 即可避免。
3. 小端序(Little-Endian)把多字节数据的低位字节放在前面,大端序(Big-Endian)把高位字节放在前面;例如 8 字节的 1024,小端序是 00 04 00 00 00 00 00 00,大端序是 00 00 00 00 00 00 04 00;x86 和 ARM 处理器都用小端序。
</details>
动手:执行 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 多了一点。
明天:第 3 天,读[程序怎样运行](#三程序怎样运行)。
## 三、程序怎样运行
### CPU、内存与磁盘
@@ -336,10 +360,34 @@ od -A x -t x1 -N 16 /bin/ls
2. CPU 是怎样执行指令的?一行 stock = stock - 1 为什么不是一步完成的?(见[指令](#指令))
3. 编译型语言和解释型语言有什么区别?Java 属于哪一种?(见[编译与解释](#编译与解释))
<details>
<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)成机器指令。
</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 格式。
综合题:订单 1024 的金额 28.50 元和菜名“宫保鸡丁”在内存中各占几个字节、是什么样子?服务器上的订单程序从磁盘上的一个文件,到 CPU 执行扣库存的指令,经过了哪些步骤?(见[一个订单在内存中的样子](#一个订单在内存中的样子)、[CPU、内存与磁盘](#cpu内存与磁盘)、[从程序到进程](#从程序到进程))
<details>
<summary>答题思路</summary>
1. 金额按分保存为整数 2850,占 4 个字节,2850 = 0x0B22,按小端序(低位字节在前)存为 22 0b 00 00;
2. 菜名按 UTF-8 编码,4 个汉字各 3 个字节,共 12 个字节(e5 ae ab ……),后面跟一个 00 表示字符串结束;
3. 订单程序平时是磁盘上的可执行文件,Linux 上是开头为 7f 45 4c 46 的 ELF 文件;
4. 启动时操作系统把文件中的指令和初始数据装入内存,分配进程号(PID),程序变成进程;
5. CPU 按程序计数器从内存中逐条取出指令执行,扣库存一行代码是 adrp、ldr、sub、str 4 条指令:读出 30、减 1、写回 29。
</details>
明天:第 4 天,读[为什么需要操作系统](#为什么需要操作系统)、[进程](#进程)、[线程](#线程)。
## 四、操作系统
### 为什么需要操作系统
@@ -442,7 +490,7 @@ write 的第一个参数 1 表示标准输出(屏幕),第二个是要写
文件(File)是保存在磁盘上、有名字的一串字节。操作系统用目录把文件组织成一棵树,例如 /var/log/orders.log 表示根目录下 var 目录中 log 目录里的 orders.log 文件。
程序先用 open 打开文件,内核返回一个小整数,称为文件描述符(File Descriptor),之后的读写都用这个数指代文件。上图中 openat 返回 5,后面的 write(5, ...) 就是往这个文件写。Linux 中很多东西都用文件描述符表示,例如网络连接:accept4 返回的 4 就是与 App 之间的连接,sendto(4, ...) 把响应发给 App。
程序先用 open 打开文件,内核返回一个小整数,称为文件描述符(File Descriptor),之后的读写都用这个数指代文件。每个进程启动时默认打开 0、1、2 三个文件描述符,分别是标准输入、标准输出和标准错误。上图中 openat 返回 5,后面的 write(5, ...) 就是往这个文件写。Linux 中很多东西都用文件描述符表示,例如网络连接:accept4 返回的 4 就是与 App 之间的连接,sendto(4, ...) 把响应发给 App。
文件适合保存日志这类只追加、按顺序读的数据。订单则需要按条件查询、多人同时修改、出错时撤销,单靠文件很难做到,所以要用[数据库](#为什么需要数据库)。
@@ -476,8 +524,21 @@ top
2. 什么是上下文切换?并发和并行有什么区别?(见[进程](#进程))
3. 两个线程同时扣减库存为什么会出错?怎样避免?(见[线程](#线程))
<details>
<summary>参考要点</summary>
1. 进程是资源分配的单位,有独立的内存;线程是 CPU 调度的单位,同一进程的线程共享代码、数据和打开的文件,各自只有程序计数器、寄存器和栈(保存函数调用中的临时数据);创建、切换线程比进程开销小,因为不用切换内存的映射;一个线程出错可能导致整个进程退出,一个进程崩溃不影响其它进程。
2. 上下文切换指操作系统让进程轮流使用 CPU,每个运行几毫秒,时间到了就保存它的寄存器,换下一个进程运行;并发是靠快速切换让很多进程看起来同时运行,并行是多个 CPU 核心在同一时刻真正各运行一个。
3. stock = stock - 1 分为读、算、写几条指令,两个线程卖最后一份时可能都读到 1,各自减为 0 后写回,卖出两份库存只减了 1;解决办法是加锁(Lock):一个线程在读、算、写期间持有锁,另一个线程等它释放后才能开始,让这几步作为一个整体执行。
</details>
动手:执行 ps -ef | head -5 和 top,找出占用 CPU 最多的进程;在 macOS 的 top 第一行看进程和线程的总数,对照[进程](#进程)一节理解为什么进程数远多于 CPU 核心数。
预期:ps -ef 每行一个进程,PID 是进程号,PPID 是父进程号,CMD 是启动命令;top 默认按 CPU 占用从高到低排序,排在最前的就是占用最多的进程;macOS 的 top 第一行显示一千多个进程、一万多个线程(笔记实测 10 核的机器上 1091 个进程、11019 个线程),其中只有几个在运行,其余在休眠,靠轮流使用 CPU 共享少数核心。
明天:第 5 天,读[系统调用](#系统调用)、[文件](#文件)、[在终端中查看进程](#在终端中查看进程)。
#### 第 5 天
选读:[系统调用](计算机操作系统%20-%20概述.md#系统调用)、[进程通信](计算机操作系统%20-%20进程管理.md#进程通信)。
@@ -488,8 +549,21 @@ top
2. 进程之间有哪些通信方式?(见[进程](#进程))
3. 什么是文件描述符?(见[文件](#文件))
<details>
<summary>参考要点</summary>
1. 用户态是普通程序的运行状态,只能执行计算、读写自己内存这类普通指令;内核态是操作系统内核的运行状态,可以执行所有指令、直接操作磁盘和网卡;读写文件要操作磁盘,普通程序没有这个权限,要通过系统调用请内核代办:CPU 切换到内核态执行内核代码,完成后切回用户态;这样由内核统一分配硬件、隔离进程,避免程序之间互相冲突。
2. 每个进程有独立的虚拟内存,不能直接读写别的进程的数据,要借助操作系统提供的进程间通信方式:管道、共享内存和网络连接(套接字);网络连接还能跨机器使用,订单服务和 MySQL 就通过网络连接交换数据。
3. 文件描述符是程序打开文件时内核返回的一个小整数,之后的读写都用它指代这个文件;例如 openat 返回 5,write(5, ...) 就是写这个文件;网络连接也用文件描述符表示,accept4 返回的 4 就是与 App 之间的连接。
</details>
动手:在 Linux 上执行 strace -e trace=write echo "order 1024",说明 write 的第一个参数和返回值的含义;再执行 ls -l /proc/self/fd,看看一个进程默认打开了哪些文件描述符。
预期:输出 write(1, "order 1024\n", 11) = 11:第一个参数 1 表示标准输出(屏幕),返回值 11 表示写入了 11 个字节;/proc/self/fd 中通常有 0、1、2 三个(标准输入、标准输出、标准错误,在终端中都指向 /dev/pts/0 这类终端设备),以及 ls 读取这个目录时自己打开的 3。
明天:第 6 天,Linux 常用命令。
#### 第 6 天
阅读:[文件与目录的基本操作](Linux.md#文件与目录的基本操作)、[文件属性](Linux.md#文件属性)、[修改权限](Linux.md#修改权限)、[获取文件内容](Linux.md#获取文件内容)、[指令与文件搜索](Linux.md#指令与文件搜索)、[管道指令](Linux.md#八管道指令)、[查看进程](Linux.md#查看进程)。
@@ -502,10 +576,33 @@ top
2. 怎样查看某个端口被哪个进程占用?(见[端口与磁盘](Linux.md#端口与磁盘))
3. 怎样统计日志中出现次数最多的 10 个 IP?(见[日志分析](Linux.md#日志分析))
<details>
<summary>参考要点</summary>
1. 755 对应 rwxr-xr-x:r、w、x 分别表示读、写、执行,权值为 4、2、1,三位数字依次是拥有者、所属群组和其他人的权限;7 = 4 + 2 + 1,拥有者可读、写、执行;5 = 4 + 1,群组和其他人只能读和执行。
2. 用 ss -lntp 列出正在监听的 TCP 端口和对应的进程,或用 lsof -i :8080 查看某一个端口;例如输出 users:(("java",pid=911,fd=4)) 表示 8080 端口被进程号 911 的 java 进程占用。
3. awk '{print $1}' access.log | sort | uniq -c | sort -rn | head -10;awk 取每行第 1 列的 IP,sort 让相同的 IP 挨在一起,uniq -c 计数,sort -rn 按次数从大到小排,head -10 取前 10 个。
</details>
动手:用 cut、sort 和 uniq 统计 /etc/passwd 中每种登录 shell 的用户数。
预期:命令为 cut -d: -f7 /etc/passwd | sort | uniq -c,每行输出“用户数 shell”,例如 Ubuntu 24.04 的 Docker 镜像中是 16 个 /usr/sbin/nologin、2 个 /bin/bash、1 个 /bin/sync;uniq 只合并相邻的重复行,所以要先 sort;macOS 的 /etc/passwd 开头有 # 注释行,可以先用 grep -v '^#' 去掉。
综合题:午餐高峰时,订单服务同时收到 100 个下单请求:操作系统怎样让它们同时被处理?两个请求同时扣最后一份宫保鸡丁时会出什么问题?订单程序写日志、回复 App 时,哪些步骤要由内核完成?(见[进程](#进程)、[线程](#线程)、[系统调用](#系统调用))
<details>
<summary>答题思路</summary>
1. 订单服务预先创建一组线程(线程池),每个请求交给一个空闲的线程,请求多于线程数时在队列中排队;
2. 线程等待数据库、网络时处于阻塞状态,CPU 转去执行其它就绪的线程,多个核心并行运行,每个核心上的线程轮流使用 CPU(上下文切换),所以 100 个请求看起来同时被处理;
3. 两个线程同时扣最后一份宫保鸡丁时,读、算、写交错执行,都读到 1、都写回 0,卖出两份库存只减 1,需要加锁,或在数据库中用带 stock > 0 条件的 UPDATE 扣减;
4. 接收请求(accept4、recvfrom)、写日志(openat、write、close)、发送响应(sendto)都是系统调用,CPU 切换到内核态,由内核的网络协议栈和文件系统完成,订单程序自己只做计算。
</details>
明天:第 7 天,读[IP 地址与端口](#ip-地址与端口)、[域名与 DNS](#域名与-dns)、[分层](#分层)。
## 五、计算机网络
### IP 地址与端口
@@ -707,8 +804,21 @@ dns 0.002281 tcp 0.011717 tls 0.041322 first_byte 0.053568
2. DNS 怎样把域名解析成 IP 地址?(见[域名与 DNS](#域名与-dns))
3. 网络为什么要分层?各层分别解决什么问题?(见[分层](#分层))
<details>
<summary>参考要点</summary>
1. IP 地址标识网络中的一台主机,相当于门牌号;端口标识主机上的一个进程,取值 0~65535,相当于房间号,例如 HTTPS 用 443;一个连接由客户端 IP、客户端端口、服务器 IP、服务器端口 4 个数共同确定,不同手机的地址或端口不同就是不同的连接,操作系统据此把数据分给对应的连接。
2. 手机先问本地 DNS 服务器(家里的路由器或运营商的服务器);它有缓存就直接回答,没有就从根服务器开始,依次询问负责 .com、负责 example.com 的服务器,最终得到 IP 地址;结果按有效期(例如 600 秒)缓存,期间可以直接使用。
3. 把请求送达要解决的问题很多,分层后每层只解决一类问题、使用下一层的服务,各层可以独立变化,例如手机从 Wi-Fi 切到 4G 只换了链路层;应用层规定请求和响应的内容(HTTP、DNS);传输层在两个进程之间传数据,用端口区分进程(TCP、UDP);网络层用 IP 地址把数据从一台主机送到另一台,由路由器逐跳转发;链路层在同一个网络中把数据交给下一站(以太网、Wi-Fi)。
</details>
动手:执行 dig www.baidu.com 和 ping -c 4 www.baidu.com,找出解析得到的 IP 地址、记录的有效期和往返时间。
预期:dig 的 ANSWER SECTION 中,www.baidu.com 是 www.a.shifen.com 的别名(CNAME),A 记录给出 IP 地址(笔记实测 183.2.172.177),有效期 600 秒;ping 显示往返时间,笔记在家庭宽带上实测平均约 9.7 ms,具体数字随网络和地区不同。
明天:第 8 天,读[TCP](#tcp)、[UDP](#udp)。
#### 第 8 天
选读:[TCP 的三次握手](计算机网络%20-%20传输层.md#tcp-的三次握手)、[TCP 的四次挥手](计算机网络%20-%20传输层.md#tcp-的四次挥手)。
@@ -719,8 +829,21 @@ dns 0.002281 tcp 0.011717 tls 0.041322 first_byte 0.053568
2. TCP 为什么需要三次握手,两次为什么不行?(见[TCP](#tcp))
3. TCP 和 UDP 有什么区别?各适合什么场景?(见[UDP](#udp))
<details>
<summary>参考要点</summary>
1. 编号:给每个字节编上序号,接收方按序号重新排序、去掉重复;确认:接收方回复确认号,表示这个序号之前的数据都收到了;重传:一段时间内没收到确认就重新发送;流量控制和拥塞控制:按接收方的处理能力和网络的拥堵情况调整发送速度。
2. 双方都要确认对方能收到自己的数据,并告诉对方自己的起始序号:客户端的 SYN 和服务器的 ACK 确认了客户端到服务器的方向,服务器的 SYN 和客户端的 ACK 确认了反方向,中间两个合并成 SYN + ACK,共 3 个报文;只握手两次时,服务器不知道客户端是否收到了自己的起始序号,网络中滞留的旧 SYN 到达时,服务器还会误以为有新连接而白白分配资源。
3. TCP 面向连接、可靠、按顺序,开销较大,适合必须完整送达的数据,例如下单请求、网页、文件下载;UDP 不建立连接,不确认、不重传,开销小、延迟低,适合偶尔丢一点也没关系的场景,例如视频通话、游戏中的位置同步和一问一答的 DNS 查询。
</details>
动手:用 Wireshark 或 tcpdump 抓一次 curl http://example.com 的数据包,找出三次握手的 3 个报文,对照[TCP](#tcp)一节中的图核对序号和确认号的关系。
预期:前 3 个报文依次是 SYN、SYN + ACK、ACK(tcpdump 中标为 [S]、[S.]、[.]):第 2 个报文的确认号等于第 1 个的序号加 1,第 3 个报文的确认号等于服务器的起始序号加 1;Wireshark 和 tcpdump 默认可能显示相对序号(例如 ack 1),tcpdump 加 -S 显示原始序号。
明天:第 9 天,读[HTTP](#http)、[HTTPS](#https)、[把过程连起来](#把过程连起来)。
#### 第 9 天
选读:[HTTP 状态码](HTTP.md#三http-状态码)、[Web 页面请求过程](计算机网络%20-%20应用层.md#web-页面请求过程)。
@@ -731,10 +854,34 @@ dns 0.002281 tcp 0.011717 tls 0.041322 first_byte 0.053568
2. HTTP 和 HTTPS 有什么区别?(见[HTTPS](#https))
3. 在浏览器中输入网址到看到页面,中间发生了什么?(见[把过程连起来](#把过程连起来))
<details>
<summary>参考要点</summary>
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,大部分花在网络往返上。
</details>
动手:用[把过程连起来](#把过程连起来)一节中带 -w 参数的 curl 命令访问几个常用的网站,比较 DNS、TCP、TLS 各阶段的耗时。
预期:每个网站输出一行 dns、tcp、tls、first_byte 4 个依次增大的累计秒数,相邻两项相减得到各阶段耗时;笔记访问百度时 DNS 约 2 ms、TCP 约 9 ms、TLS 约 30 ms、首字节约 12 ms,通常 TLS 握手最慢,服务器离得越远,TCP、TLS 阶段越长。
综合题:从点击“提交订单”到 App 显示“下单成功”:按 DNS、TCP、HTTP 的顺序,说出每一步发送了什么、发给了谁,以及哪些步骤要花一个往返时间。(见[一次下单经过了什么](#一总览一次下单经过了什么)、[TCP](#tcp)、[把过程连起来](#把过程连起来))
<details>
<summary>答题思路</summary>
1. DNS:App 向本地 DNS 服务器查询 shop.example.com,得到 203.0.113.10,一问一答,有缓存时直接使用缓存;
2. TCP:App 向 203.0.113.10 的 443 端口发送 SYN,服务器回复 SYN + ACK,App 再回复 ACK,三次握手花一个往返时间;
3. HTTPS 还要在 TCP 连接上做 TLS 握手,协商密钥并校验服务器的证书,TLS 1.2 再花两个往返;
4. HTTP:App 发送 POST /api/orders,请求体是 {"userId":1001,"dishId":17,"quantity":1},服务器上的订单服务处理后返回 201 Created 和订单号 1024,花一个往返加上服务器的处理时间;
5. App 收到响应后显示“下单成功”,之后的请求复用已建立的连接,省去握手的往返。
</details>
明天:第 10 天,读[为什么需要数据库](#为什么需要数据库)、[表](#表)、[SQL](#sql)。
## 六、数据库
### 为什么需要数据库
@@ -819,7 +966,14 @@ Runtime error: UNIQUE constraint failed: orders.id (19)
MySQL 中对应的报错是 Duplicate entry '1024' for key 'orders.PRIMARY'。这类约束由数据库保证,即使程序有错误,也不会写入两个相同的订单号。
“我的订单”页面要显示菜名,而菜名在 dishes 表中,这时用 JOIN 把两张表按 dish_id 连接起来。假设用户 1001 晚上又点了一份 26.00 元的鱼香肉丝(订单 1026),按下单时间从新到旧列出:
“我的订单”页面要显示菜名,而菜名在 dishes 表中,这时用 JOIN 把两张表按 dish_id 连接起来。假设用户 1001 晚上又点了一份 26.00 元的鱼香肉丝(订单 1026):
```sql
INSERT INTO dishes VALUES (18, '鱼香肉丝', 2600, 20);
INSERT INTO orders VALUES (1026, 1001, 18, 2600, 'CREATED', '2026-10-29 18:05:00');
```
按下单时间从新到旧列出:
```sql
SELECT o.id, d.name, o.amount, o.created_at
@@ -855,7 +1009,7 @@ SELECT * FROM orders WHERE user_id = 1001;
索引(Index)是按某一列排好序的另一份数据,每一项记录着这一列的值和对应的行在哪里,就像书末尾按拼音排好的索引页。数据库中的索引通常组织成一棵 B+ 树:每个节点是一个页(例如 4 KB 或 16 KB),里面放着几百个排好序的键,从根页开始,每一层根据键的大小选择往哪个子页走,最后到达存放数据的叶子页。
在 SQLite 中可以自己生成 100 万个订单来对比(下面这条语句是 SQLite 的写法):
在 SQLite 中可以自己生成 100 万个订单来对比。先执行 DELETE FROM orders; 清空前面插入的订单,否则订单号 1024 会与生成的订单号冲突(下面这条语句是 SQLite 的写法):
```sql
WITH RECURSIVE n(i) AS (SELECT 1 UNION ALL SELECT i + 1 FROM n WHERE i < 1000000)
@@ -928,8 +1082,20 @@ SQL 的更多语法见 [SQL 语法](SQL%20语法.md#七查询);事务、ACID
1. 为什么用数据库保存订单,而不是直接写文件?(见[为什么需要数据库](#为什么需要数据库))
2. 什么是主键?再插入一个相同主键的订单会怎样?(见[SQL](#sql))
<details>
<summary>参考要点</summary>
1. 直接写文件时,查询要从头读到尾,多个线程同时写会互相覆盖,扣完库存还没写订单就崩溃时数据对不上,改中间一行也很麻烦;数据库用 SQL 按条件查询,用索引加快查找,用锁处理并发修改,用事务保证一组修改全做或全不做,崩溃后能恢复。
2. 主键(Primary Key)是唯一标识一行的列,例如 orders 表的订单号 id;再插入一个 1024 号订单会被数据库拒绝,SQLite 报 UNIQUE constraint failed,MySQL 报 Duplicate entry '1024' for key 'orders.PRIMARY';即使程序有错误,数据库也不会写入重复的订单号。
</details>
动手:执行 sqlite3 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 在后。
明天:第 11 天,分组与连接。
#### 第 11 天
阅读:[函数](SQL%20语法.md#十二函数)、[分组](SQL%20语法.md#十三分组)、[子查询](SQL%20语法.md#十四子查询)、[连接](SQL%20语法.md#十五连接)。
@@ -941,7 +1107,19 @@ SQL 的更多语法见 [SQL 语法](SQL%20语法.md#七查询);事务、ACID
1. WHERE 和 HAVING 有什么区别?(见[分组](SQL%20语法.md#十三分组))
2. 内连接和外连接有什么区别?(见[连接](SQL%20语法.md#十五连接))
动手:在 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 建表并插入数据,先自己写出查询,再对照笔记的解法。
<details>
<summary>参考要点</summary>
1. WHERE 过滤行,在分组之前执行;HAVING 过滤分组,在 GROUP BY 之后执行,可以用 COUNT 这类汇总结果作条件,例如 GROUP BY Email HAVING COUNT(*) >= 2 找出重复的邮件地址。
2. 内连接只返回两张表中按连接条件能对上的行;外连接还保留没有关联的行,左外连接保留左表的所有行,右表中对不上的列填 NULL(空值),例如列出所有顾客及其订单时,没有订单的顾客 b 也会出现,order_id 为 NULL;外连接分为左外连接、右外连接和全外连接。
</details>
动手:在 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,后建的会删掉先建的,要一题建表、查询完再做下一题。
明天:第 12 天,读[索引](#索引)、[事务](#事务)。
#### 第 12 天
@@ -953,10 +1131,34 @@ SQL 的更多语法见 [SQL 语法](SQL%20语法.md#七查询);事务、ACID
2. 事务的 ACID 分别指什么?(见[事务](#事务))
3. 扣库存和创建订单为什么要放在一个事务中?程序崩溃时会怎样?(见[事务](#事务))
<details>
<summary>参考要点</summary>
1. 索引是按某一列排好序的另一份数据,组织成 B+ 树,查找时从根页往下几层就能定位,不用逐行检查(全表扫描);例如 100 万个订单按 user_id 查询,没有索引约 37 ms,建索引后不到 0.1 ms,索引只有 3 层;每插入或修改一行,相关的索引都要同步更新,还占额外的空间,所以只为经常出现在查询条件中的列建索引。
2. 原子性(Atomicity):事务中的修改要么全部完成,要么全部撤销;一致性(Consistency):事务前后数据都满足规则,例如库存不为负;隔离性(Isolation):并发执行的事务互不干扰,像是一个接一个执行;持久性(Durability):事务一旦提交,即使随后断电,修改也不会丢失。
3. 扣库存和写订单必须同时生效或同时不生效,否则会少了一份菜却没有订单;不用事务时,扣完库存后崩溃,库存变成 29,订单表中没有记录;用事务时,数据库发现事务没有提交,撤销其中的修改,库存仍是 30,这靠日志实现:重启时补完已提交的事务,撤销未提交的事务。
</details>
动手:按[索引](#索引)一节在 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 个订单。
综合题:为外卖 App 设计 dishes 和 orders 两张表:写出下单(扣库存并写入订单)的 SQL,说明为什么要放在一个事务中、UPDATE 中的 stock > 0 起什么作用,以及“我的订单”页面需要在哪一列上建索引。(见[SQL](#sql)、[索引](#索引)、[事务](#事务))
<details>
<summary>答题思路</summary>
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 行判断已售罄并回滚,数据库修改一行时会给它加锁,两人同时抢最后一份只有一个能扣减成功;
5. “我的订单”按 user_id 查询,要在 orders 表的 user_id 列上建索引:CREATE INDEX idx_user ON orders (user_id)。
</details>
明天:第 13 天,读[数据结构与算法](#七数据结构与算法)、[数组](#数组)、[链表](#链表)。
## 七、数据结构与算法
数据结构(Data Structure)指的是数据在内存中的组织方式,算法(Algorithm)是解决问题的步骤。同样是在 100 万个订单中找一个,组织方式不同,要做的工作可能相差几万倍。
@@ -1104,6 +1306,13 @@ sort 使用的是 O(n log n) 的算法,100 万个数排序只用了约 0.5 秒
1. 数组和链表有什么区别?各适合什么场景?(见[链表](#链表))
<details>
<summary>参考要点</summary>
1. 数组连续存放,按位置读取一步完成(地址 = 起始地址 + i × 元素大小),但插入、删除要移动后面的元素;链表的节点分散存放,每个节点记着下一个节点的地址,已知位置时插入、删除只改两个地址,但读第 n 个要从头走 n 步;按位置读取、遍历多时用数组(相邻元素会一起读进 CPU 缓存,实际用得更多),频繁在头部插入、删除时用链表。
</details>
推演:反转链表。链表 1024 → 1025 → 1026 → null,head 指向 1024。只修改节点中“下一个”的地址,把它反转成 1026 → 1025 → 1024 → null。用三个变量 prev、cur、next,写出每一步之后各变量的值和每个节点指向谁。
规则:开始时 prev = null,cur = head。重复以下 4 步直到 cur 为 null:next = cur 的下一个;cur 的下一个改为 prev;prev = cur;cur = next。最后 head = prev。
@@ -1123,12 +1332,21 @@ sort 使用的是 O(n log n) 的算法,100 万个数排序只用了约 0.5 秒
</details>
明天:第 14 天,读[栈和队列](#栈和队列)。
#### 第 14 天
自测:
1. 栈和队列有什么区别?各举一个使用的例子。(见[栈和队列](#栈和队列))
<details>
<summary>参考要点</summary>
1. 栈是后进先出,只在同一端放入和取出,像一摞盘子;队列是先进先出,一端放入、另一端取出,像排队;栈的例子:检查括号是否匹配,函数调用时保存每一层的临时数据;队列的例子:后厨按下单顺序做菜,先下单的订单 1024 比 1025 先做。
</details>
推演:用栈检查括号是否匹配。App 发出的请求体是 JSON,例如 {"items":[{"dishId":17}]},服务器解析时要检查括号是否成对。
用栈分别检查 “({[]})” 和 “([)]” 是否匹配,写出每读入一个字符后栈中的内容(栈底在左)。
@@ -1186,12 +1404,21 @@ sort 使用的是 O(n log n) 的算法,100 万个数排序只用了约 0.5 秒
</details>
明天:第 15 天,读[哈希表](#哈希表)。
#### 第 15 天
自测:
1. 哈希表的原理是什么?哈希冲突怎样解决?(见[哈希表](#哈希表))
<details>
<summary>参考要点</summary>
1. 哈希表用哈希函数把键换算成数组中的位置(桶),例如 8 个桶时订单号 % 8 就是桶号,查找 1024 时算出 0,直接去 0 号桶找;不同的键算出同一个桶号称为哈希冲突,例如 1032 % 8 也是 0;拉链法让每个桶挂一个链表,冲突的元素接在后面,查找时逐个比较;元素多了就把桶数扩大一倍重新分配,100 万个订单、约 100 万个桶时平均只比较 1.48 次。
</details>
推演:哈希表与冲突。哈希表有 8 个桶(编号 0~7),哈希函数是 订单号 % 8,冲突用拉链法处理,新元素接在链表末尾。
1. 依次放入订单号 1024、1025、1026、1030、1032、1041、1047,画出每个桶的链表;
@@ -1220,6 +1447,8 @@ sort 使用的是 O(n log n) 的算法,100 万个数排序只用了约 0.5 秒
</details>
明天:第 16 天,读[二分查找](#二分查找)、[树](#树)。
#### 第 16 天
自测:
@@ -1227,6 +1456,14 @@ sort 使用的是 O(n log n) 的算法,100 万个数排序只用了约 0.5 秒
1. 二分查找的时间复杂度是多少?100 万个元素最多比较几次?(见[二分查找](#二分查找))
2. 数据库索引为什么用 B+ 树,而不用二叉树或哈希表?(见[树](#树))
<details>
<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+ 树的数据只放在叶子页,叶子页之间用链表相连,便于范围查询。
</details>
推演:二分查找。8 个订单按下单时间排好序,放在数组 a 中,下标从 0 开始。分别查找 12:42 和 12:25 下的订单,写出每一步的 low、high、mid、a[mid] 以及接下来往哪边找。
```text
@@ -1289,14 +1526,25 @@ a 12:03 12:08 12:15 12:21 12:30 12:34 12:42 12:56
</details>
明天:第 17 天,读[排序](#排序)、[时间复杂度](#时间复杂度)。
#### 第 17 天
自测:
1. 常见的时间复杂度有哪些?O(n log n) 和 O(n²) 的排序在 100 万个元素上差多少?(见[时间复杂度](#时间复杂度))
<details>
<summary>参考要点</summary>
1. 常见的有 O(1)(哈希表查找)、O(log n)(二分查找)、O(n)(逐个查找)、O(n log n)(归并排序)、O(n²)(插入排序);排序 100 万个元素,归并排序实测约 1867 万次比较,插入排序约 2500 亿次;按每秒 10 亿次比较估算,前者约 0.02 秒,后者约 250 秒,相差上万倍。
</details>
动手:按[时间复杂度](#时间复杂度)一节的命令生成 100 万个打乱的数,用 time sort -n 计时排序。
预期:time 显示排序 100 万个数约 0.5 秒(笔记在 macOS 上实测,Ubuntu 上约 0.2 秒),sorted.txt 中的数从 1 依次排到 1000000。
推演:插入排序。5 个订单的金额(分)依次是 2600、3200、980、2850、1600。用插入排序从小到大排序,写出每一轮插入后的数组和这一轮比较的次数。
规则:从第 2 个元素开始,每轮取出一个元素,从右往左与已排好的元素比较,比它大的元素右移一格,遇到不比它大的元素或到达最左边时放下。
@@ -1336,6 +1584,18 @@ a 12:03 12:08 12:15 12:21 12:30 12:34 12:42 12:56
综合题:外卖 App 要支持三种操作:按订单号查找订单、按时间从新到旧列出最近的订单、查出 12:00~12:30 之间的订单。分别用什么数据结构?时间复杂度各是多少?(见[哈希表](#哈希表)、[二分查找](#二分查找)、[树](#树))
<details>
<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),树中插入只要挂上一个新节点。
</details>
明天:第 18 天,Git。
## 八、Git
Git 记录代码的每一次修改,可以回到任何一个历史版本,也让多人在各自的分支上修改同一个项目,再合并到一起。后面的路线中写的代码都应该放进 Git 仓库。
@@ -1352,8 +1612,21 @@ Git 记录代码的每一次修改,可以回到任何一个历史版本,也
2. 合并分支时出现冲突怎样解决?(见[冲突](Git.md#冲突))
3. git stash 有什么作用?(见[储藏(Stashing)](Git.md#储藏stashing))
<details>
<summary>参考要点</summary>
1. Git 是分布式版本控制,每个人的电脑上都有一份完整的代码和历史,SVN 是集中式,只有中心服务器有一份;集中式的服务器出故障时所有人都无法工作,而且必须联网才能提交,Git 不联网也能提交;Git 新建、合并分支只需移动指针,速度很快,集中式新建分支相当于复制一份完整的代码。
2. 两个分支修改了同一个文件的同一行,合并时就会冲突;Git 在文件中用 <<<<<<<、=======、>>>>>>> 标出两边的内容,把这一段改成最终想要的内容,并删掉这三行标记;然后执行 git add 标记冲突已解决,再执行 git commit 完成合并。
3. git stash 把工作区中还没提交的修改存起来,工作区回到干净的状态,可以安全地切换分支;例如在 dev 上开发到一半,要去修 master 上的 bug,先执行 git stash,修完回来用 git stash pop 恢复;未跟踪的新文件默认不储藏,需要加 -u。
</details>
动手:新建一个仓库,在两个分支上修改同一行后合并,手动解决冲突并提交,最后用 git log --graph 查看历史。
预期:git merge 时提示 CONFLICT (content): Merge conflict in 文件名,文件中出现 <<<<<<< HEAD、=======、>>>>>>> 分支名 三行标记;改好后 git add、git commit,git log --graph 中两条分支线(|\ 和 |/)汇合到一个合并提交。
明天:第 19 天,复习全篇。
## 九、下一步
### 练习
@@ -1362,8 +1635,22 @@ Git 记录代码的每一次修改,可以回到任何一个历史版本,也
回到开头的[全景图](#一总览一次下单经过了什么),不看正文,按图中的 6 个步骤讲一遍下单的全过程,讲不清的地方回到对应的章节。
预期:能不看正文按顺序讲完 6 步:DNS 解析出 203.0.113.10,建立 TCP 连接并发送 HTTP 请求,内核把请求交给订单服务进程中的一个线程,CPU 执行指令并读写内存,数据库在一个事务中把库存从 30 减为 29 并写入订单 1024,服务器返回 HTTP 响应后 App 显示下单成功。
综合题:从按下“提交订单”到 App 显示“下单成功”,按数据、程序、操作系统、网络、数据库、数据结构六个方面,各说出两三件发生的事。(见[一次下单经过了什么](#一总览一次下单经过了什么)及各章)
<details>
<summary>答题思路</summary>
1. 数据:订单号 1024 用 8 字节整数按小端序保存,金额 28.50 元存为整数 2850 分以避免浮点误差,菜名按 UTF-8 编码占 12 个字节;
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 层就能定位一行,按订单号查找可以用哈希表,后厨按队列先进先出做菜。
</details>
之后按目标选择[Java 后端](学习路线%20-%20Java%20后端.md)或[大模型应用开发](学习路线%20-%20大模型应用开发.md),两条路线都要求先掌握一门编程语言的基础语法;离面试只有两三周时,用[面试冲刺](学习路线%20-%20面试冲刺.md)复习。
## 附录:实测