diff --git a/README.md b/README.md
index a06d0fb1..fbe48ae3 100644
--- a/README.md
+++ b/README.md
@@ -53,6 +53,7 @@
- [Java 并发](https://github.com/CyC2018/CS-Notes/blob/master/notes/Java%20并发.md)
- [Java 虚拟机](https://github.com/CyC2018/CS-Notes/blob/master/notes/Java%20虚拟机.md)
- [Java I/O](https://github.com/CyC2018/CS-Notes/blob/master/notes/Java%20IO.md)
+- [Spring](https://github.com/CyC2018/CS-Notes/blob/master/notes/Spring.md)
## :bulb: 系统设计
diff --git a/notes/HTTP.md b/notes/HTTP.md
index 3ec07b98..578e53e5 100644
--- a/notes/HTTP.md
+++ b/notes/HTTP.md
@@ -481,6 +481,40 @@ Session 可以存储在服务器上的文件、数据库或者内存中。也可
- Cookie 存储在浏览器中,容易被恶意查看。如果非要将一些隐私数据存在 Cookie 中,可以将 Cookie 值进行加密,然后在服务器进行解密;
- 对于大型网站,如果用户所有的信息都存储在 Session 中,那么开销是非常大的,因此不建议将所有的用户信息都存储到 Session 中。
+#### 11. JWT
+
+JSON Web Token(JWT)是一种自带签名的令牌。用户登录成功后,服务器把用户 ID、过期时间等信息签名后生成 JWT 返回给客户端,客户端之后在请求的 Authorization 首部中携带它。服务器验证签名,就能确认令牌是自己签发的、内容没有被修改,不需要像 Session 那样在服务器保存会话。
+
+JWT 由三部分组成,用 . 连接:
+
+```html
+Authorization: Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJ1aWQiOjEwMjQsInJvbGUiOiJ1c2VyIiwiZXhwIjoxNzY3MjI1NjAwfQ.35OYguRMXfGxb4cYZPiP9KDH3-Ny6vMNwrPe-lNHBBw
+```
+
+- 头部:{"alg":"HS256","typ":"JWT"} 的 Base64URL 编码,表示用 HMAC-SHA256 计算签名;
+- 载荷:{"uid":1024,"role":"user","exp":1767225600} 的 Base64URL 编码,exp 是过期时间 2026-01-01 00:00:00(UTC);
+- 签名:用只有服务器知道的密钥,对“头部.载荷”计算 HMAC-SHA256。
+
+头部和载荷只是编码而不是加密,任何人都能解码看到内容,所以载荷中不能放密码等敏感信息。签名的作用是防篡改:如果把载荷中的 role 改成 admin,没有密钥就算不出新的签名,服务器按篡改后的内容重新计算的签名与令牌中的不一致,请求会被拒绝:
+
+```text
+令牌中的签名 35OYguRMXfGxb4cYZPiP9KDH3-Ny6vMNwrPe-lNHBBw
+按篡改后的内容计算 Az4UO74FtqslT1MDLrbi7CMlKtd4KICR1jcwA7rJE4U
+```
+
+JWT 与 Session 的区别:
+
+- Session 的状态保存在服务器,删除 Session 就能让用户立即下线;集群中需要共享 Session,见 [集群下的 Session 管理](集群.md#二集群下的-session-管理);
+- JWT 的状态保存在令牌中,任何持有密钥的服务都能独立验证,适合多个服务共用登录状态;代价是令牌签发后在过期前一直有效,服务器无法主动让它失效。
+
+无法主动失效是 JWT 最常被追问的问题,常用的解决方法是双令牌:访问令牌(Access Token)的有效期很短,例如 15 分钟,每次请求都携带;刷新令牌(Refresh Token)的有效期较长,服务器记录已签发的刷新令牌,只在访问令牌过期时用它换取新的访问令牌。用户退出或者被封禁时撤销刷新令牌,已经发出的访问令牌最多 15 分钟后自然失效。需要立即失效时,可以把令牌的 ID 放入 Redis 黑名单,过期时间设为令牌的剩余有效期,但这又回到了在服务器保存状态。
+
+令牌在浏览器中的存放位置:放在 localStorage 中,页面上的任何脚本都能读取,一旦存在 XSS 漏洞,令牌就会被盗走;放在 HttpOnly 的 Cookie 中,脚本读不到,但浏览器会自动携带 Cookie,需要防范 [跨站请求伪造](攻击技术.md#二跨站请求伪造)。
+
+需要注意的是,服务器验证时要固定使用的签名算法,不能接受头部中 alg 为 none 的令牌;HMAC 的密钥要随机生成,长度不少于 256 位。
+
+JWT 只是令牌的格式,而 OAuth 2.0 是授权框架,规定第三方应用如何获得访问令牌,这个访问令牌可以用 JWT 表示。
+
### 缓存
#### 1. 优点
@@ -920,6 +954,9 @@ DELETE /idX/delete HTTP/1.1 -> Returns 404
## 参考资料
+- [RFC 7519: JSON Web Token (JWT)](https://www.rfc-editor.org/rfc/rfc7519)
+- [OWASP: JSON Web Token Cheat Sheet](https://cheatsheetseries.owasp.org/cheatsheets/JSON_Web_Token_Cheat_Sheet.html)
+- [OWASP: HTML5 Security Cheat Sheet](https://cheatsheetseries.owasp.org/cheatsheets/HTML5_Security_Cheat_Sheet.html)
- 上野宣. 图解 HTTP[M]. 人民邮电出版社, 2014.
- [MDN : HTTP](https://developer.mozilla.org/en-US/docs/Web/HTTP)
- [HTTP/2 简介](https://developers.google.com/web/fundamentals/performance/http2/?hl=zh-cn)
diff --git a/notes/Java 并发.md b/notes/Java 并发.md
index 147dc353..4bcfb2c0 100644
--- a/notes/Java 并发.md
+++ b/notes/Java 并发.md
@@ -8,6 +8,7 @@
* [实现接口 VS 继承 Thread](#实现接口-vs-继承-thread)
* [二、基础线程机制](#二基础线程机制)
* [Executor](#executor)
+ * [ThreadPoolExecutor](#threadpoolexecutor)
* [Daemon](#daemon)
* [sleep()](#sleep)
* [yield()](#yield)
@@ -24,6 +25,7 @@
* [join()](#join)
* [wait() notify() notifyAll()](#wait-notify-notifyall)
* [await() signal() signalAll()](#await-signal-signalall)
+ * [交替打印](#交替打印)
* [六、线程状态](#六线程状态)
* [新建(NEW)](#新建new)
* [可运行(RUNNABLE)](#可运行runnable)
@@ -167,6 +169,78 @@ public static void main(String[] args) {
}
```
+### ThreadPoolExecutor
+
+上面的三种 Executor 都是通过 ThreadPoolExecutor 创建的,它的构造函数有以下参数:
+
+- corePoolSize:核心线程数,核心线程空闲时也不会被回收,除非调用了 allowCoreThreadTimeOut(true);
+- maximumPoolSize:最大线程数;
+- keepAliveTime 和 unit:线程数超过核心线程数时,多出的线程空闲超过这个时间就会被回收;
+- workQueue:保存等待执行的任务的阻塞队列;
+- threadFactory:创建线程的工厂,常用来给线程起有意义的名字,方便排查问题;
+- handler:拒绝策略,线程数达到最大并且队列已满时,用来处理新提交的任务。
+
+提交一个任务时,线程池按以下顺序处理:
+
+1. 线程数小于核心线程数时,新建线程执行任务,即使其它线程是空闲的;
+2. 否则把任务放入队列;
+3. 队列已满时,如果线程数小于最大线程数,新建线程执行这个任务;
+4. 否则执行拒绝策略。
+
+例如核心线程数为 2、最大线程数为 4、队列容量为 2,依次提交 7 个各执行 1 秒的任务:
+
+
+
+```java
+ThreadPoolExecutor pool = new ThreadPoolExecutor(2, 4, 60, TimeUnit.SECONDS,
+ new ArrayBlockingQueue<>(2), new ThreadPoolExecutor.AbortPolicy());
+for (int i = 1; i <= 7; i++) {
+ try {
+ pool.execute(() -> {
+ try {
+ Thread.sleep(1000);
+ } catch (InterruptedException ignored) {
+ }
+ });
+ System.out.println("提交任务 " + i + ":线程数 " + pool.getPoolSize()
+ + ",队列中 " + pool.getQueue().size() + " 个");
+ } catch (RejectedExecutionException e) {
+ System.out.println("提交任务 " + i + ":被拒绝");
+ }
+}
+```
+
+```text
+提交任务 1:线程数 1,队列中 0 个
+提交任务 2:线程数 2,队列中 0 个
+提交任务 3:线程数 2,队列中 1 个
+提交任务 4:线程数 2,队列中 2 个
+提交任务 5:线程数 3,队列中 2 个
+提交任务 6:线程数 4,队列中 2 个
+提交任务 7:被拒绝
+```
+
+需要注意的是,任务 5、6 比先提交的任务 3、4 更早执行:队列满后新建的线程直接执行当前提交的任务,队列中的任务要等有线程空闲后才会被取出。
+
+拒绝策略:
+
+- AbortPolicy:默认策略,抛出 RejectedExecutionException;
+- CallerRunsPolicy:由提交任务的线程自己执行这个任务,提交方被拖慢,从而降低了提交速度;
+- DiscardPolicy:直接丢弃任务;
+- DiscardOldestPolicy:丢弃队列中最早的任务,再重新提交当前任务。
+
+不建议用 Executors 创建线程池,而是直接创建 ThreadPoolExecutor 并使用有界队列:
+
+- newFixedThreadPool() 和 newSingleThreadExecutor() 使用没有容量限制的 LinkedBlockingQueue,任务堆积时可能耗尽内存,最大线程数也不再起作用;
+- newCachedThreadPool() 的最大线程数为 Integer.MAX_VALUE,任务多时会创建大量线程。
+
+线程数可以按任务类型估算:
+
+- CPU 密集型:取 CPU 核数 + 1,多出的一个线程用于在某个线程因缺页等原因暂停时继续使用 CPU;
+- I/O 密集型:线程等待 I/O 时不占用 CPU,可以按 核数 × (1 + 等待时间 / 计算时间) 估算。例如 8 核 CPU,每个任务计算 10 ms、等待数据库 90 ms,约为 8 × (1 + 90 / 10) = 80 个线程。
+
+估算值只是起点,最终要通过压测确定。上线后监控活跃线程数、队列长度和拒绝次数,必要时调用 setCorePoolSize()、setMaximumPoolSize() 在运行时调整。
+
### Daemon
守护线程是程序运行时在后台提供服务的线程,不属于程序中不可或缺的部分。
@@ -678,6 +752,76 @@ before
after
```
+### 交替打印
+
+多个线程按固定顺序轮流执行是常见的手写题,例如三个线程依次打印 A、B、C,共打印 10 轮。思路是用一个共享变量记录轮到哪个线程:没轮到的线程等待,轮到的线程打印后把轮次交给下一个线程,并唤醒等待的线程。
+
+```java
+public class PrintABC {
+ private final Object lock = new Object();
+ private int turn = 0; // 轮到第几个线程:0、1、2
+
+ public void print(int id, char c, int times) throws InterruptedException {
+ for (int i = 0; i < times; i++) {
+ synchronized (lock) {
+ while (turn != id) {
+ lock.wait();
+ }
+ System.out.print(c);
+ turn = (turn + 1) % 3;
+ lock.notifyAll();
+ }
+ }
+ }
+
+ public static void main(String[] args) {
+ PrintABC p = new PrintABC();
+ for (int i = 0; i < 3; i++) {
+ int id = i;
+ new Thread(() -> {
+ try {
+ p.print(id, (char) ('A' + id), 10);
+ } catch (InterruptedException ignored) {
+ }
+ }).start();
+ }
+ }
+}
+```
+
+```text
+ABCABCABCABCABCABCABCABCABCABC
+```
+
+有两处容易写错:
+
+- 等待条件要用 while 而不是 if:线程被唤醒时不一定轮到了自己,例如和其它线程一起被 notifyAll() 唤醒,或者发生了虚假唤醒,醒来后必须重新检查条件;
+- 要用 notifyAll() 而不是 notify():notify() 只唤醒任意一个等待的线程,例如 B 打印完后唤醒的是 A,A 发现没轮到自己又继续等待,C 就再也不会被唤醒,三个线程都在等待。
+
+把上面代码中的 notifyAll() 改成 notify() 后运行 100 次,每次都在打印出几个字母后卡住;把 while 改成 if 后,输出变成了 ABAABAAB 这样的乱序。
+
+用 Semaphore 实现更直观:每个线程对应一个信号量,只有第一个线程的许可为 1。线程打印前获取自己的许可,打印后释放下一个线程的许可,就像传递接力棒:
+
+```java
+Semaphore[] s = {new Semaphore(1), new Semaphore(0), new Semaphore(0)};
+for (int i = 0; i < 3; i++) {
+ int id = i;
+ new Thread(() -> {
+ for (int k = 0; k < 10; k++) {
+ try {
+ s[id].acquire();
+ } catch (InterruptedException e) {
+ return;
+ }
+ System.out.print((char) ('A' + id));
+ s[(id + 1) % 3].release();
+ }
+ }).start();
+}
+```
+
+两个线程交替打印奇偶数也是同样的做法,把轮次改为两个即可。使用 ReentrantLock 时,可以为每个线程创建一个 Condition,打印后只唤醒下一个线程。
+
## 六、线程状态
一个线程只能处于一种状态,并且这里的线程状态特指 Java 虚拟机的线程状态,不能反映线程在特定操作系统下的状态。
@@ -1621,6 +1765,9 @@ JDK 1.6 引入了偏向锁和轻量级锁,从而让锁拥有了四个状态:
## 参考资料
+- [Object (Java SE 21)](https://docs.oracle.com/en/java/javase/21/docs/api/java.base/java/lang/Object.html)
+- Brian Goetz. Java 并发编程实战 [M]. 机械工业出版社, 2012.
+- [ThreadPoolExecutor (Java SE 21)](https://docs.oracle.com/en/java/javase/21/docs/api/java.base/java/util/concurrent/ThreadPoolExecutor.html)
- BruceEckel. Java 编程思想: 第 4 版 [M]. 机械工业出版社, 2007.
- 周志明. 深入理解 Java 虚拟机 [M]. 机械工业出版社, 2011.
- [Threads and Locks](https://docs.oracle.com/javase/specs/jvms/se6/html/Threads.doc.html)
diff --git a/notes/Java 虚拟机.md b/notes/Java 虚拟机.md
index 1739cf19..e4c91271 100644
--- a/notes/Java 虚拟机.md
+++ b/notes/Java 虚拟机.md
@@ -26,6 +26,10 @@
* [类加载器分类](#类加载器分类)
* [双亲委派模型](#双亲委派模型)
* [自定义类加载器实现](#自定义类加载器实现)
+ * [五、线上问题排查](#五线上问题排查)
+ * [内存溢出与内存泄漏](#内存溢出与内存泄漏)
+ * [CPU 占用过高](#cpu-占用过高)
+ * [死锁](#死锁)
* [参考资料](#参考资料)
@@ -738,8 +742,88 @@ public class FileSystemClassLoader extends ClassLoader {
}
```
+## 五、线上问题排查
+
+### 内存溢出与内存泄漏
+
+OutOfMemoryError 的详细信息指明了是哪一块内存不足,常见的有:
+
+- Java heap space:堆中无法再分配对象,可能是堆设置得太小,也可能是内存泄漏;
+- GC overhead limit exceeded:GC 占用了约 98% 的时间,却只回收了不到 2% 的堆,说明存活的对象几乎占满了堆;
+- Metaspace:加载的类太多,元空间超过了 MaxMetaspaceSize,常见于运行时动态生成大量的类。
+
+内存泄漏指的是对象已经不再使用,但仍然被引用着,GC 无法回收,堆内存持续增长,最终导致内存溢出。常见原因:
+
+- 静态集合只添加不清理,例如用 static Map 做缓存却没有淘汰策略;
+- 线程池中的线程使用 ThreadLocal 后没有调用 remove(),线程一直存活,值也一直无法被回收;
+- 连接、流等资源没有关闭;
+- 注册的监听器、回调没有注销。
+
+排查步骤:
+
+1. 启动参数加上 -XX:+HeapDumpOnOutOfMemoryError,发生内存溢出时自动保存堆转储,用 -XX:HeapDumpPath 指定保存位置;进程还在运行时,可以用 jcmd GC.heap_dump <文件名> 手动导出;
+2. 用 Eclipse MAT 等工具打开堆转储,找到占用内存最多的对象,查看它们到 GC Roots 的引用链,确定是谁持有了这些对象;
+3. 引用链指向不应该存活的对象(例如上面的静态集合)就是内存泄漏,需要修改代码;否则说明确实需要这么多内存,应该增大堆或者减少一次加载的数据量。
+
+例如下面的程序不断向静态列表中添加 1 MB 的数组,静态变量引用的对象属于 GC Roots,这些数组都无法被回收:
+
+```java
+public class Leak {
+ static final List CACHE = new ArrayList<>();
+
+ public static void main(String[] args) {
+ while (true) {
+ CACHE.add(new byte[1024 * 1024]);
+ }
+ }
+}
+```
+
+```text
+$ java -Xmx32m -XX:+HeapDumpOnOutOfMemoryError Leak
+java.lang.OutOfMemoryError: Java heap space
+Dumping heap to java_pid24607.hprof ...
+Heap dump file created [19154891 bytes in 0.007 secs]
+```
+
+### CPU 占用过高
+
+1. 用 top 找到 CPU 占用高的 Java 进程;
+2. 用 top -H -p 查看这个进程中每个线程的 CPU 占用,记下占用最高的线程 ID;
+3. 用 jstack 或者 jcmd Thread.print 导出所有线程的调用栈,按线程 ID 找到对应的线程;
+4. 查看这个线程正在执行的代码,常见原因有死循环、频繁 GC(这时占用 CPU 的是 GC 线程)。
+
+调用栈中的 nid 就是操作系统中的线程 ID。JDK 18 起 nid 以十进制显示,可以直接和 top 中的线程 ID 对应;更早的版本以 0x 开头的十六进制显示,需要先用 printf '%x\n' <线程 ID> 转换。JDK 11 起每个线程还带有累计占用的 CPU 时间 cpu=。例如一个执行死循环的线程:
+
+```text
+"busy-worker" #20 [26883] prio=5 os_prio=31 cpu=2148.39ms elapsed=2.15s tid=0x000000012f811200 nid=26883 runnable [0x000000016dfda000]
+ java.lang.Thread.State: RUNNABLE
+ at Busy.lambda$main$0(Busy.java:3)
+```
+
+### 死锁
+
+jstack 会自动检测死锁,在输出的末尾给出互相等待的线程,以及每个线程持有和等待的锁:
+
+```text
+Found one Java-level deadlock:
+=============================
+"t1":
+ waiting to lock monitor 0x0000000140e63bb0 (object 0x000000061fc144c8, a java.lang.Object),
+ which is held by "t2"
+
+"t2":
+ waiting to lock monitor 0x0000000141818190 (object 0x000000061fc144b8, a java.lang.Object),
+ which is held by "t1"
+```
+
+后面还会列出这两个线程的调用栈,指出它们在哪一行获取锁。避免死锁的方法见 [计算机操作系统 - 死锁](计算机操作系统%20-%20死锁.md)。
+
## 参考资料
+- [Troubleshooting Memory Leaks (Java SE 21)](https://docs.oracle.com/en/java/javase/21/troubleshoot/troubleshooting-memory-leaks.html)
+- [JDK-8268425: Show decimal nid of OSThread instead of hex format one](https://bugs.openjdk.org/browse/JDK-8268425)
+- [JDK-8200720: Print additional information in thread dump](https://bugs.openjdk.org/browse/JDK-8200720)
- 周志明. 深入理解 Java 虚拟机 [M]. 机械工业出版社, 2011.
- [Chapter 2. The Structure of the Java Virtual Machine](https://docs.oracle.com/javase/specs/jvms/se8/html/jvms-2.html#jvms-2.5.4)
- [Jvm memory](https://www.slideshare.net/benewu/jvm-memory)
diff --git a/notes/MySQL.md b/notes/MySQL.md
index 56290799..b1c17ede 100644
--- a/notes/MySQL.md
+++ b/notes/MySQL.md
@@ -11,6 +11,7 @@
* [使用 Explain 进行分析](#使用-explain-进行分析)
* [优化数据访问](#优化数据访问)
* [重构查询方式](#重构查询方式)
+ * [深分页](#深分页)
* [三、存储引擎](#三存储引擎)
* [InnoDB](#innodb)
* [MyISAM](#myisam)
@@ -202,8 +203,14 @@ Explain 用来分析 SELECT 查询语句,开发人员可以通过分析 Explai
比较重要的字段有:
- select_type : 查询类型,有简单查询、联合查询、子查询等
+- type : 访问类型,从好到差常见的有 const、eq_ref、ref、range、index、ALL。const 表示通过主键或唯一索引最多匹配一行,ref 表示通过普通索引等值匹配,range 表示索引范围扫描,index 表示扫描整个索引树,ALL 表示全表扫描
- key : 使用的索引
- rows : 扫描的行数
+- Extra : 额外信息,Using index 表示使用了覆盖索引,不需要回表;Using index condition 表示使用了索引下推;Using filesort 表示无法利用索引的顺序,需要额外排序;Using temporary 表示使用了临时表,常见于 GROUP BY 和 ORDER BY 的列不一致
+
+出现 ALL、Using filesort 或 Using temporary,并且 rows 很大时,通常需要调整索引或者改写查询。
+
+找出慢查询可以使用慢查询日志:开启 slow_query_log 后,执行时间超过 long_query_time(默认 10 秒)的语句会被记录下来,再用 mysqldumpslow 按执行时间、次数汇总分析。
### 优化数据访问
@@ -258,6 +265,54 @@ SELECT * FROM tag_post WHERE tag_id=1234;
SELECT * FROM post WHERE post.id IN (123,456,567,9098,8904);
```
+### 深分页
+
+深分页指的是偏移量很大的分页查询。例如 200 万行的订单表 orders,create_time 上有普通索引 idx_create_time,按创建时间翻到第 100001 页:
+
+```sql
+SELECT * FROM orders ORDER BY create_time LIMIT 1000000, 10;
+```
+
+MySQL 没办法直接跳到第 1000001 行,只能先找出排在前面的 1000010 行,丢弃前 1000000 行,再返回最后 10 行,偏移量越大越慢。
+
+
+
+这条查询的执行计划中 type 为 ALL、Extra 为 Using filesort,也就是全表扫描后排序。这是因为如果走 idx_create_time,读出的 1000010 条索引项都要回表取整行,产生上百万次随机读,优化器判断这样还不如全表扫描。
+
+延迟关联指的是先在索引上查出这一页的主键,再根据主键回表。子查询只用到 create_time 和 id,idx_create_time 就是覆盖索引(Extra 为 Using index),虽然仍要扫描 1000010 条索引项,但不需要回表,只有最后 10 行需要回表:
+
+```sql
+SELECT o.* FROM orders o
+JOIN (SELECT id FROM orders ORDER BY create_time LIMIT 1000000, 10) t ON o.id = t.id
+ORDER BY o.create_time;
+```
+
+游标分页(也叫书签分页)指的是记住上一页最后一行的排序值,下一页从它之后开始查。这样可以直接在索引上定位到上一页结束的位置,只读 10 行:
+
+```sql
+-- 上一页最后一行:create_time = '2025-01-12 13:46:39',id = 982321
+SELECT * FROM orders
+WHERE create_time > '2025-01-12 13:46:39'
+ OR (create_time = '2025-01-12 13:46:39' AND id > 982321)
+ORDER BY create_time, id
+LIMIT 10;
+```
+
+排序列的值可能重复,所以要再按主键排序,并把主键也作为游标的一部分,否则时间相同的行会被跳过或者重复返回。游标分页的代价是不能直接跳到任意一页,只能逐页翻,适合 App 下拉加载、按时间浏览记录这类场景。
+
+在 MySQL 8.0 上实测的结果如下(数据全部在内存中,取 5 次中最快的一次),三种写法返回的 10 行相同:
+
+| 写法 | 耗时 | 读取的数据 |
+| --- | --- | --- |
+| LIMIT 0, 10 | 0.05 ms | 10 行 |
+| LIMIT 1000000, 10 | 1321 ms | 全表 200 万行,再排序 |
+| 延迟关联 | 88 ms | 1000010 条索引项,回表 10 次 |
+| 游标分页 | 0.13 ms | 10 行 |
+
+还可以从需求上限制翻页的深度,例如只允许翻到前 100 页。Elasticsearch 默认也不允许 from + size 超过 10000,更深的翻页要用 search_after,原理与游标分页相同。
+
+需要注意的是,分页时常常还要显示总记录数。InnoDB 没有保存表的行数,因为并发的事务看到的行数可能不同,COUNT(*) 需要扫描整个索引,上面的表执行一次需要 136 ms。数据量大时可以缓存总数、显示估算值,或者只提供“下一页”。
+
## 三、存储引擎
### InnoDB
@@ -414,6 +469,10 @@ TIMESTAMP 比 DATETIME 少占用 1 字节,但是只能表示到 2038 年,需
## 参考资料
+- [MySQL 8.0: InnoDB Restrictions and Limitations](https://dev.mysql.com/doc/refman/8.0/en/innodb-restrictions-limitations.html)
+- [Elasticsearch: Paginate search results](https://www.elastic.co/docs/reference/elasticsearch/rest-apis/paginate-search-results)
+- [MySQL 8.0: EXPLAIN Output Format](https://dev.mysql.com/doc/refman/8.0/en/explain-output.html)
+- [MySQL 8.0: The Slow Query Log](https://dev.mysql.com/doc/refman/8.0/en/slow-query-log.html)
- Baron Schwartz, Peter Zaitsev, Vadim Tkachenko, 等. 高性能 MySQL[M]. 电子工业出版社, 2013.
- 姜承尧. MySQL 技术内幕: InnoDB 存储引擎 [M]. 机械工业出版社, 2011.
- [20+ 条 MySQL 性能优化的最佳经验](https://www.jfox.info/20-tiao-mysql-xing-nen-you-hua-de-zui-jia-jing-yan.html)
diff --git a/notes/Spring.md b/notes/Spring.md
new file mode 100644
index 00000000..f40ca956
--- /dev/null
+++ b/notes/Spring.md
@@ -0,0 +1,205 @@
+# Spring
+
+* [Spring](#spring)
+ * [一、IoC 与 Bean](#一ioc-与-bean)
+ * [IoC 与依赖注入](#ioc-与依赖注入)
+ * [Bean 的生命周期](#bean-的生命周期)
+ * [循环依赖](#循环依赖)
+ * [二、AOP](#二aop)
+ * [动态代理](#动态代理)
+ * [自调用](#自调用)
+ * [三、事务](#三事务)
+ * [声明式事务](#声明式事务)
+ * [传播行为](#传播行为)
+ * [事务失效](#事务失效)
+ * [四、Spring Boot 自动配置](#四spring-boot-自动配置)
+ * [参考资料](#参考资料)
+
+
+
+## 一、IoC 与 Bean
+
+### IoC 与依赖注入
+
+控制反转(Inversion of Control, IoC)指的是对象不再自己创建所依赖的对象,而是由容器创建好之后交给它。例如 OrderService 需要 OrderRepository,它不在内部 new 一个,而是声明自己需要,由 Spring 容器把创建好的对象传进来,这个过程称为依赖注入(Dependency Injection, DI)。
+
+```java
+@Service
+public class OrderService {
+ private final OrderRepository repository;
+
+ public OrderService(OrderRepository repository) { // 构造器注入
+ this.repository = repository;
+ }
+}
+```
+
+这样做的好处:
+
+- 对象只依赖接口,替换实现、在测试中注入模拟对象都不需要修改代码;
+- 对象的创建、作用域和生命周期由容器统一管理,事务、缓存这类功能也是容器在创建对象时织入的。
+
+依赖注入有构造器注入、Setter 注入和字段注入(把 @Autowired 加在字段上)三种方式。Spring 官方推荐构造器注入:依赖可以声明为 final,对象创建出来就是完整可用的,缺少依赖时启动就会失败。
+
+### Bean 的生命周期
+
+一个单例 Bean 从创建到销毁会经过以下步骤:
+
+1. 实例化:调用构造函数创建对象;
+2. 属性填充:注入依赖;
+3. Aware 回调:例如 BeanNameAware 把 Bean 的名字传给它;
+4. BeanPostProcessor 的 postProcessBeforeInitialization();
+5. 初始化:依次调用 @PostConstruct 方法、InitializingBean 的 afterPropertiesSet() 和配置的 init-method;
+6. BeanPostProcessor 的 postProcessAfterInitialization(),AOP 就是在这一步把 Bean 替换为代理对象的;
+7. 容器关闭时销毁:依次调用 @PreDestroy 方法、DisposableBean 的 destroy() 和配置的 destroy-method。
+
+让一个 Bean 实现上面所有的回调,实际运行打印的顺序如下:
+
+```text
+1 构造函数
+2 依赖注入
+3 BeanNameAware: orderService
+4 BeanPostProcessor before
+5 @PostConstruct
+6 afterPropertiesSet
+7 init-method
+8 BeanPostProcessor after
+9 @PreDestroy
+10 DisposableBean.destroy
+```
+
+### 循环依赖
+
+循环依赖指的是两个 Bean 互相依赖,例如 A 依赖 B,B 又依赖 A。
+
+对于通过字段或者 Setter 注入的单例 Bean,Spring 通过提前暴露还没有初始化完成的对象来解决。DefaultSingletonBeanRegistry 中有三个缓存:
+
+- 一级缓存 singletonObjects:初始化完成的 Bean;
+- 二级缓存 earlySingletonObjects:提前暴露的、还没有初始化完成的 Bean;
+- 三级缓存 singletonFactories:用于生成提前引用的工厂。
+
+
+
+需要三级缓存而不是两级,是因为 A 可能需要被 AOP 代理,而代理正常是在初始化的最后一步才创建的。三级缓存中的工厂被调用时,会判断 A 是否需要代理,需要的话提前创建代理返回给 B,之后初始化完成时不再重复创建。这样 B 拿到的对象与最终放入容器的对象是同一个;没有循环依赖时工厂不会被调用,代理仍按正常流程创建。
+
+以下情况无法解决,启动时抛出 BeanCurrentlyInCreationException:
+
+- 构造器注入的循环依赖:创建 A 时必须先有 B,A 还没有实例化,没有可以提前暴露的对象;
+- 原型(prototype)作用域的 Bean:每次获取都创建新对象,不会放入缓存。
+
+需要注意的是,Spring Boot 2.6 起默认禁止循环依赖,出现时直接启动失败,设置 spring.main.allow-circular-references=true 才能恢复。循环依赖通常说明职责划分有问题,更好的做法是调整依赖关系。
+
+## 二、AOP
+
+### 动态代理
+
+面向切面编程(Aspect-Oriented Programming, AOP)用于把日志、事务、权限校验这类分散在各处的逻辑从业务代码中抽离出来。Spring AOP 基于动态代理实现:容器中保存的不是原始对象,而是代理对象,调用方法时先执行代理中的切面逻辑,再调用原始对象的方法。
+
+两种代理方式:
+
+- JDK 动态代理:目标类实现了接口时可以使用,代理类实现同样的接口,只能代理接口中的方法;
+- CGLIB:在运行时生成目标类的子类,通过重写方法来代理,因此 final 类、final 方法和 private 方法无法被代理。
+
+Spring Framework 在目标类实现了接口时默认使用 JDK 动态代理;Spring Boot 默认使用 CGLIB,可以通过 spring.aop.proxy-target-class 修改。
+
+JDK 动态代理的原理可以用下面的代码说明,切面逻辑写在 InvocationHandler 中:
+
+```java
+public interface OrderService {
+ void create();
+}
+
+OrderService target = () -> System.out.println("创建订单");
+OrderService proxy = (OrderService) Proxy.newProxyInstance(
+ OrderService.class.getClassLoader(),
+ new Class>[]{OrderService.class},
+ (p, method, args) -> {
+ System.out.println("开启事务");
+ Object result = method.invoke(target, args);
+ System.out.println("提交事务");
+ return result;
+ });
+proxy.create();
+```
+
+```text
+开启事务
+创建订单
+提交事务
+```
+
+### 自调用
+
+代理只能拦截从外部经过代理的调用。在目标对象内部调用自己的另一个方法时,等价于 this.inner(),调用的是原始对象,不经过代理,切面不会生效:
+
+
+
+```java
+@Service
+public class OrderService {
+ public void outer() {
+ inner(); // 自调用,inner() 上的事务不生效
+ }
+
+ @Transactional
+ public void inner() {
+ }
+}
+```
+
+这是 @Transactional、@Async、@Cacheable 等注解失效最常见的原因。解决方法是把被调用的方法移到另一个 Bean 中,或者注入自身的代理,通过代理调用。
+
+## 三、事务
+
+### 声明式事务
+
+@Transactional 同样基于 AOP 实现:代理在方法执行前开启事务,方法正常返回时提交,抛出异常时回滚。
+
+需要注意的是,默认只有 RuntimeException 和 Error 会触发回滚,受检异常(例如 IOException)不会回滚,需要通过 rollbackFor 指定。
+
+### 传播行为
+
+传播行为指的是一个事务方法调用另一个事务方法时,事务如何传递。常用的有以下三种:
+
+- REQUIRED:默认值,当前有事务就加入,没有就新建。内外两个方法属于同一个事务,一起提交或者一起回滚;
+- REQUIRES_NEW:挂起当前事务,新建一个独立的事务,两个事务分别提交和回滚,例如无论业务成功与否都要保存的操作日志;
+- NESTED:在当前事务中创建一个保存点,内层回滚时只回滚到保存点,外层回滚时内层也一起回滚。
+
+REQUIRED 有一个常见的问题:内层方法抛出异常后,即使外层捕获了这个异常,整个事务也已经被标记为只能回滚,外层方法结束时事务回滚,并抛出 UnexpectedRollbackException。
+
+### 事务失效
+
+事务不生效的常见原因:
+
+- 自调用:同一个类中的方法互相调用,不经过代理;
+- 方法无法被代理:final 方法、static 方法和 private 方法;Spring 6.0 之前,非 public 方法上的注解也会被忽略;
+- 异常被吞掉:方法内部捕获了异常而没有重新抛出,代理认为方法正常结束,提交了事务;
+- 抛出的是受检异常,而没有配置 rollbackFor;
+- 对象不是由 Spring 管理的,例如自己 new 出来的对象;
+- 在新线程中操作数据库:事务绑定在当前线程上,其它线程中的操作不属于这个事务;
+- 数据库不支持事务,例如 MySQL 的 MyISAM 引擎。
+
+## 四、Spring Boot 自动配置
+
+Spring Boot 在 Spring 的基础上提供了自动配置和 starter:starter 把一组常用的依赖打包在一起,例如 spring-boot-starter-web 引入了 Spring MVC、内嵌的 Tomcat 和 JSON 库;自动配置则根据引入的依赖自动创建所需的 Bean,不需要手动配置就能启动一个 Web 应用。
+
+自动配置的过程:
+
+1. 启动类上的 @SpringBootApplication 包含了 @EnableAutoConfiguration;
+2. 启动时读取所有 jar 包中的 META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports 文件,得到自动配置类的列表,Spring Boot 2.7 之前写在 META-INF/spring.factories 中;
+3. 每个自动配置类都带有条件注解,条件满足时才生效。例如 @ConditionalOnClass 要求类路径上存在某个类,@ConditionalOnMissingBean 要求用户没有定义同类型的 Bean,@ConditionalOnProperty 根据配置项决定。
+
+例如引入 spring-boot-starter-jdbc 和数据库驱动后,数据源的自动配置类就会生效,按配置文件中的地址创建一个 DataSource;如果用户自己定义了 DataSource,由于 @ConditionalOnMissingBean,自动配置不再创建。因此用户的配置总是优先于自动配置。
+
+## 参考资料
+
+- [Spring Framework: Dependency Injection](https://docs.spring.io/spring-framework/reference/core/beans/dependencies/factory-collaborators.html)
+- [Spring Framework: Customizing the Nature of a Bean](https://docs.spring.io/spring-framework/reference/core/beans/factory-nature.html)
+- [Spring Framework: Container Extension Points](https://docs.spring.io/spring-framework/reference/core/beans/factory-extension.html)
+- [Spring Framework: Proxying Mechanisms](https://docs.spring.io/spring-framework/reference/core/aop/proxying.html)
+- [Spring Framework: Using @Transactional](https://docs.spring.io/spring-framework/reference/data-access/transaction/declarative/annotations.html)
+- [Spring Framework: Transaction Propagation](https://docs.spring.io/spring-framework/reference/data-access/transaction/declarative/tx-propagation.html)
+- [Spring Framework 源码:DefaultSingletonBeanRegistry](https://github.com/spring-projects/spring-framework/blob/main/spring-beans/src/main/java/org/springframework/beans/factory/support/DefaultSingletonBeanRegistry.java)
+- [Spring Boot: Creating Your Own Auto-configuration](https://docs.spring.io/spring-boot/reference/features/developing-auto-configuration.html)
+- [Spring Boot 2.6 Release Notes](https://github.com/spring-projects/spring-boot/wiki/Spring-Boot-2.6-Release-Notes)
+- [Spring Boot 2.7 Release Notes](https://github.com/spring-projects/spring-boot/wiki/Spring-Boot-2.7-Release-Notes)
diff --git a/notes/pics/deep-pagination.png b/notes/pics/deep-pagination.png
new file mode 100644
index 00000000..dfc2bf4d
Binary files /dev/null and b/notes/pics/deep-pagination.png differ
diff --git a/notes/pics/massive-topk.png b/notes/pics/massive-topk.png
new file mode 100644
index 00000000..2d340f47
Binary files /dev/null and b/notes/pics/massive-topk.png differ
diff --git a/notes/pics/rpc-call.png b/notes/pics/rpc-call.png
new file mode 100644
index 00000000..b87503f2
Binary files /dev/null and b/notes/pics/rpc-call.png differ
diff --git a/notes/pics/seckill.png b/notes/pics/seckill.png
new file mode 100644
index 00000000..6ea98b97
Binary files /dev/null and b/notes/pics/seckill.png differ
diff --git a/notes/pics/spring-aop-proxy.png b/notes/pics/spring-aop-proxy.png
new file mode 100644
index 00000000..59f571df
Binary files /dev/null and b/notes/pics/spring-aop-proxy.png differ
diff --git a/notes/pics/spring-circular.png b/notes/pics/spring-circular.png
new file mode 100644
index 00000000..6cfca540
Binary files /dev/null and b/notes/pics/spring-circular.png differ
diff --git a/notes/pics/thread-pool.png b/notes/pics/thread-pool.png
new file mode 100644
index 00000000..73b1a8be
Binary files /dev/null and b/notes/pics/thread-pool.png differ
diff --git a/notes/分布式.md b/notes/分布式.md
index 7b3373fc..1d802082 100644
--- a/notes/分布式.md
+++ b/notes/分布式.md
@@ -28,6 +28,11 @@
* [单个 Candidate 的竞选](#单个-candidate-的竞选)
* [多个 Candidate 竞选](#多个-candidate-竞选)
* [日志复制](#日志复制)
+ * [七、RPC](#七rpc)
+ * [调用过程](#调用过程)
+ * [服务发现](#服务发现)
+ * [RPC 与 HTTP](#rpc-与-http)
+ * [超时与重试](#超时与重试)
* [参考](#参考)
@@ -416,8 +421,107 @@ Leader 会周期性的发送心跳包给 Follower。每个 Follower 都设置了
发生网络分区时,少数派一侧的旧 Leader 得不到大多数节点的确认,无法提交日志,多数派一侧会选出新的 Leader。分区恢复后,旧 Leader 发现更大的任期号就会退回 Follower,它未提交的日志被覆盖,因此不会出现脑裂。
+## 七、RPC
+
+远程过程调用(Remote Procedure Call, RPC)指的是像调用本地方法一样调用另一台机器上的服务。例如订单服务调用库存服务扣减库存:
+
+```java
+boolean ok = inventoryService.deduct(10086, 2);
+```
+
+### 调用过程
+
+调用方写的是普通的方法调用,RPC 框架在背后完成了网络通信:
+
+
+
+1. inventoryService 是框架用动态代理生成的对象,调用它的方法时,代理拿到接口名、方法名和参数;
+2. 把这些信息序列化成字节,加上请求 ID 等首部组成请求帧,从注册中心获取的地址列表中按负载均衡选出一个实例,通过长连接发送;
+3. 服务方解码请求,找到对应的实现类,通过反射调用 deduct(),再把返回值按同样的方式发回;
+4. 一个连接上可能同时有多个请求在等待响应,调用方根据响应中的请求 ID 把结果交给对应的调用。
+
+去掉工程上的细节,核心只有两部分:调用方用动态代理把方法调用转成网络请求,服务方用反射把网络请求转成方法调用。
+
+```java
+// 调用方:生成接口的代理对象,调用方法时把方法名和参数发给服务方
+@SuppressWarnings("unchecked")
+static T refer(Class api, String host, int port) {
+ return (T) Proxy.newProxyInstance(api.getClassLoader(), new Class>[]{api}, (proxy, method, args) -> {
+ try (Socket socket = new Socket(host, port)) {
+ ObjectOutputStream out = new ObjectOutputStream(socket.getOutputStream());
+ out.writeUTF(method.getName());
+ out.writeObject(method.getParameterTypes());
+ out.writeObject(args);
+ out.flush();
+ return new ObjectInputStream(socket.getInputStream()).readObject();
+ }
+ });
+}
+
+// 服务方:读出方法名和参数,通过反射调用本地的实现
+static void export(Object impl, int port) throws Exception {
+ try (ServerSocket server = new ServerSocket(port)) {
+ while (true) {
+ try (Socket socket = server.accept()) {
+ ObjectInputStream in = new ObjectInputStream(socket.getInputStream());
+ Method method = impl.getClass().getMethod(in.readUTF(), (Class>[]) in.readObject());
+ Object result = method.invoke(impl, (Object[]) in.readObject());
+ ObjectOutputStream out = new ObjectOutputStream(socket.getOutputStream());
+ out.writeObject(result);
+ out.flush();
+ }
+ }
+ }
+}
+```
+
+```java
+// 服务方
+export(new InventoryServiceImpl(), 20880);
+
+// 调用方
+InventoryService inventoryService = refer(InventoryService.class, "10.0.0.8", 20880);
+inventoryService.deduct(10086, 2); // 实际执行的是服务方的 InventoryServiceImpl.deduct()
+```
+
+这段代码每次调用都新建连接,使用 Java 自带的序列化,也没有超时处理。Dubbo、gRPC 等框架在此基础上实现了连接复用、高效的序列化、服务发现、负载均衡、超时重试和熔断,设计一个 RPC 框架时也是从这几个模块考虑。
+
+### 服务发现
+
+服务的实例会扩容、缩容或者宕机,地址不能写死在调用方。注册中心(例如 Nacos、ZooKeeper)保存每个服务当前可用的实例:
+
+- 服务方启动时把自己的地址注册到注册中心,之后定期发送心跳,长时间没有心跳的实例会被删除;
+- 调用方订阅需要的服务,把地址列表缓存在本地,实例变化时由注册中心推送通知;
+- 每次调用都从本地缓存的列表中选择实例,注册中心不转发请求。
+
+因此注册中心全部宕机时,调用方仍然可以按缓存的地址继续调用,只是新上线的实例无法被发现。某个实例宕机后、被注册中心删除之前,发往它的请求会失败,需要调用方重试其它实例。
+
+### RPC 与 HTTP
+
+HTTP 是一种协议,RPC 是一种调用方式,两者不是同一层面的概念:RPC 框架可以在 TCP 上使用自定义的协议,例如 Dubbo 协议;也可以基于 HTTP,例如 gRPC 基于 HTTP/2,Spring Cloud 的 OpenFeign 用动态代理把接口调用转换成 HTTP 请求。
+
+面试中通常问的是:为什么对外接口常用 HTTP + JSON,内部服务之间却常用 RPC 框架。这是因为内部调用量大、对延迟敏感,RPC 框架在这些方面做得更好:
+
+- 序列化更紧凑:例如参数 {"skuId":10086,"count":2},JSON 需要 25 字节;Protobuf 不传字段名,只传字段编号和值,编码为 08 E6 4E 10 02,共 5 字节,其中 08 表示字段 1,E6 4E 是 10086 的变长编码,10 02 表示字段 2 的值为 2;
+- 连接复用:在少量长连接上同时发送多个请求,用请求 ID 区分响应,省去频繁建立连接的开销;
+- 服务治理:内置服务发现、负载均衡、超时重试和熔断;
+- 接口约定:用 Java 接口或者 IDL 文件定义服务,自动生成调用方的代码,参数类型在编译期就能检查。
+
+HTTP + JSON 的优势在于通用:浏览器和各种语言都能直接调用,报文可读,便于调试,适合对外开放的接口。
+
+### 超时与重试
+
+- 每次调用都要设置超时时间,否则下游变慢时,调用方的线程会一直等待,最终被耗尽;
+- 调用方超时不代表服务方没有执行:请求可能在网络中延迟,或者在服务方的线程池中排队、遇到 GC 停顿,这些时间都计入了调用方的耗时,却不在服务方记录的处理时间里,于是调用方超时了,服务方却执行成功了;
+- 因此只有幂等的接口才能自动重试,例如查询;扣库存这类写操作要带上请求 ID,由服务方去重,否则重试会扣减两次;
+- 调用链较长时,下游的超时时间应该比上游短。gRPC 会把截止时间(deadline)随请求传给服务方,超时后调用方收到 DEADLINE_EXCEEDED,服务方也可以检查调用是否已经超时,从而停止处理。
+
## 参考
+- [Apache Dubbo: Architecture](https://dubbo.apache.org/docs/v2.7/user/preface/architecture/)
+- [Protocol Buffers: Encoding](https://protobuf.dev/programming-guides/encoding/)
+- [gRPC: Core concepts](https://grpc.io/docs/what-is-grpc/core-concepts/)
+- [gRPC on HTTP/2](https://grpc.io/blog/grpc-on-http2/)
- 倪超. 从 Paxos 到 ZooKeeper : 分布式一致性原理与实践 [M]. 电子工业出版社, 2015.
- [Designing Data-Intensive Applications 中文版](https://github.com/vonng/ddia)
- [Distributed locks with Redis](https://redis.io/topics/distlock)
diff --git a/notes/系统设计基础.md b/notes/系统设计基础.md
index 84e0dc5a..4e6cfce1 100644
--- a/notes/系统设计基础.md
+++ b/notes/系统设计基础.md
@@ -6,6 +6,8 @@
* [三、扩展性](#三扩展性)
* [四、可用性](#四可用性)
* [五、安全性](#五安全性)
+ * [六、秒杀系统](#六秒杀系统)
+ * [七、海量数据处理](#七海量数据处理)
* [参考资料](#参考资料)
@@ -123,7 +125,91 @@
要求系统在应对各种攻击手段时能够有可靠的应对措施,常见的攻击手段和防范方法见 [攻击技术](攻击技术.md)。
+## 六、秒杀系统
+
+秒杀指的是少量商品在某一时刻开售,大量用户同时抢购,例如 100 件商品在开售瞬间收到 10 万个请求。难点在于:
+
+- 瞬时流量远超平时,但绝大多数请求注定失败;
+- 库存不能超卖,一个用户只能买一件;
+- 抢购不能拖垮正常的业务。
+
+解决思路是层层过滤,使最终到达数据库的请求接近库存数量:
+
+
+
+1. 前端和网关:开售前按钮置灰,提交后禁止重复点击,网关按用户和接口限流,拦截刷单请求;
+2. 库存预扣:开售前把库存加载到 Redis 中,用 Lua 脚本在 Redis 中判断并扣减库存,没有库存的请求直接返回已抢完;
+3. 异步下单:扣减成功的请求写入消息队列,订单服务按自己的处理能力消费,扣减数据库中的库存并创建订单,用户通过轮询等方式查询结果;
+4. 隔离:秒杀服务独立部署,避免影响正常的业务。
+
+预扣库存的 Lua 脚本如下:
+
+```lua
+-- KEYS[1]:库存,KEYS[2]:已购用户集合,ARGV[1]:用户 ID
+if redis.call('SISMEMBER', KEYS[2], ARGV[1]) == 1 then
+ return 2 -- 一人一单,重复购买
+end
+if tonumber(redis.call('GET', KEYS[1])) <= 0 then
+ return 1 -- 库存不足
+end
+redis.call('DECR', KEYS[1])
+redis.call('SADD', KEYS[2], ARGV[1])
+return 0 -- 抢购成功
+```
+
+Redis 执行 Lua 脚本时不会穿插执行其它命令,判断是否已购、判断库存、扣减库存和记录用户这四步作为一个整体完成。如果在应用中先 GET 库存、判断大于 0 之后再 DECR,两个请求可能同时读到库存为 1,并且都扣减成功。用 64 个并发客户端实测,库存为 100、1000 个用户各请求 2 次:Lua 脚本恰好卖出 100 件;先查询后扣减的写法卖出了 104 件,库存变成了 -4。
+
+数据库也要兜底。扣减库存时把库存大于 0 作为条件,影响的行数为 0 说明库存不足:
+
+```sql
+UPDATE goods SET stock = stock - 1 WHERE id = 1001 AND stock > 0;
+```
+
+一人一单可以用订单表上(用户 ID,商品 ID)的唯一索引保证,消息被重复消费时插入失败,也就实现了幂等。Redis 中扣减成功、但订单最终创建失败时,需要把 Redis 中的库存和购买记录恢复,并定期与数据库对账。
+
+## 七、海量数据处理
+
+这类问题的特点是数据量远大于内存,例如“100 GB 的文件,内存只有 4 GB”。常用的思路是分而治之:先把数据切成能放进内存的小块,分别处理后再合并结果。
+
+### Top K
+
+从 1 亿个整数中找出最大的 10 个:维护一个大小为 10 的小顶堆,堆顶是当前第 10 大的数。依次读入每个数,比堆顶大就替换堆顶并调整堆,最后堆中的 10 个数就是答案。时间复杂度为 O(NlogK),内存中只需保存 10 个数,数据可以一边读一边处理;而全部排序需要 O(NlogN) 的时间,还要把 400 MB 的数据都读入内存。Java 实现见 [Leetcode 题解 - 排序](Leetcode%20题解%20-%20排序.md#1-kth-element)。
+
+### 出现次数最多的 K 个
+
+100 GB 的日志文件中每行是一个 URL,内存只有 4 GB,找出出现次数最多的 10 个 URL。直接用 HashMap 统计所有 URL 会超出内存,需要先按哈希切分:
+
+
+
+1. 逐行读取,按 hash(url) % 100 写入 100 个小文件,平均每个约 1 GB;
+2. 依次把每个小文件读入内存,用 HashMap 统计每个 URL 的次数,再用小顶堆得到这个文件中出现次数最多的 10 个;
+3. 用一个小顶堆从 100 × 10 个候选中选出全局的前 10 个。
+
+这样做的关键是相同的 URL 一定落在同一个小文件中,所以每个文件单独统计出的次数就是这个 URL 的总次数。HashMap 占用的内存取决于文件中不同 URL 的数量,如果某个文件中不同的 URL 仍然太多,可以换一个哈希函数对它再次切分。
+
+### 两个大文件的交集
+
+两个文件各有 50 亿个 URL,每个 URL 64 字节,找出两个文件共同的 URL。每个文件有 320 GB,用同一个哈希函数把两个文件各切成 1000 份,每份约 320 MB。相同的 URL 一定落在编号相同的一对小文件中,只需要依次把 ai 读入 HashSet,再逐行检查 bi。
+
+如果允许少量误判,可以用 [布隆过滤器](缓存.md#缓存穿透):把 a 中的 URL 都加入布隆过滤器,再逐个检查 b 中的 URL。4 GB 内存约有 344 亿位,每个 URL 平均 6.9 位,使用 5 个哈希函数时误判率约为 3.7%;要把误判率降到 1%,每个 URL 需要约 9.6 位,共需 5.6 GB。
+
+### 去重与判断是否存在
+
+数据是整数时可以用位图,每个整数只用 1 位表示是否出现。int 共有 232 个取值,位图只需要 512 MB。
+
+例如在 2 亿个整数中找出只出现一次的数,可以给每个整数分配 2 位:00 表示没出现,01 表示出现一次,10 表示出现多次,共需 1 GB。读完所有数据后扫描位图,取出状态为 01 的数。
+
+### 外部排序
+
+100 GB 的数据排序,内存只有 1 GB:
+
+1. 分段排序:每次读入 1 GB 数据,在内存中排序后写回磁盘,得到 100 个有序的文件;
+2. 多路归并:同时打开这 100 个文件,用大小为 100 的小顶堆,每次取出最小的元素写入结果文件,再从它所在的文件读入下一个元素。
+
+如果数据的取值范围很小,例如 1 亿个 10000 到 20000 之间的整数,一共只有 10001 种取值,用长度为 10001 的数组统计每个值出现的次数,一次扫描就能完成排序,也就是计数排序。
+
## 参考资料
+- [Redis: Scripting with Lua](https://redis.io/docs/latest/develop/programmability/eval-intro/)
- 大型网站技术架构:核心原理与案例分析
- [Designing Data-Intensive Applications 中文版](https://github.com/vonng/ddia)
diff --git a/notes/计算机操作系统 - 进程管理.md b/notes/计算机操作系统 - 进程管理.md
index 36bc965b..d1cd357e 100644
--- a/notes/计算机操作系统 - 进程管理.md
+++ b/notes/计算机操作系统 - 进程管理.md
@@ -5,6 +5,7 @@
* [1. 进程](#1-进程)
* [2. 线程](#2-线程)
* [3. 区别](#3-区别)
+ * [4. 协程](#4-协程)
* [进程状态的切换](#进程状态的切换)
* [进程调度算法](#进程调度算法)
* [1. 批处理系统](#1-批处理系统)
@@ -68,6 +69,18 @@ QQ 和浏览器是两个进程,浏览器进程里面有很多线程,例如 H
线程间可以通过直接读写同一进程中的数据进行通信,但是进程通信需要借助 IPC。
+### 4. 协程
+
+协程(Coroutine)是在用户态实现的轻量级线程,由程序自己调度,而不是由操作系统内核调度。协程在等待 I/O 时主动让出执行权,运行时再把其它协程调度到同一个线程上执行。
+
+与线程相比:
+
+- 切换在用户态完成,只需要保存少量寄存器,不需要陷入内核,开销小;
+- 占用的内存少,可以同时存在大量协程,而每个线程都要预留独立的栈,例如 64 位 Linux 上 Java 线程的栈默认为 1 MB,线程数量有限;
+- 同一个线程上的协程不能并行执行,要利用多核仍然需要多个线程;协程中如果执行了阻塞的系统调用,所在的线程会被阻塞,所以协程的运行时通常把 I/O 改为非阻塞的方式。
+
+Java 21 正式引入的虚拟线程([JEP 444](https://openjdk.org/jeps/444))就是 JVM 实现的协程:虚拟线程由 JVM 调度到少量的平台线程上运行,执行阻塞 I/O 时从平台线程上卸载,让平台线程去执行其它虚拟线程。虚拟线程的创建成本很低,不需要用线程池复用,需要限制并发数时使用信号量。在 JDK 21 中,虚拟线程在 synchronized 块内阻塞时无法卸载,JDK 24 起([JEP 491](https://openjdk.org/jeps/491))基本消除了这个限制。
+
## 进程状态的切换
diff --git a/tools/diagram/src/deep-pagination.svg b/tools/diagram/src/deep-pagination.svg
new file mode 100644
index 00000000..7f168274
--- /dev/null
+++ b/tools/diagram/src/deep-pagination.svg
@@ -0,0 +1,48 @@
+
diff --git a/tools/diagram/src/massive-topk.svg b/tools/diagram/src/massive-topk.svg
new file mode 100644
index 00000000..0e3bef50
--- /dev/null
+++ b/tools/diagram/src/massive-topk.svg
@@ -0,0 +1,84 @@
+
diff --git a/tools/diagram/src/rpc-call.svg b/tools/diagram/src/rpc-call.svg
new file mode 100644
index 00000000..7bd85d9b
--- /dev/null
+++ b/tools/diagram/src/rpc-call.svg
@@ -0,0 +1,77 @@
+
diff --git a/tools/diagram/src/seckill.svg b/tools/diagram/src/seckill.svg
new file mode 100644
index 00000000..68fdfcea
--- /dev/null
+++ b/tools/diagram/src/seckill.svg
@@ -0,0 +1,72 @@
+
diff --git a/tools/diagram/src/spring-aop-proxy.svg b/tools/diagram/src/spring-aop-proxy.svg
new file mode 100644
index 00000000..8b50e5e0
--- /dev/null
+++ b/tools/diagram/src/spring-aop-proxy.svg
@@ -0,0 +1,54 @@
+
diff --git a/tools/diagram/src/spring-circular.svg b/tools/diagram/src/spring-circular.svg
new file mode 100644
index 00000000..4129417a
--- /dev/null
+++ b/tools/diagram/src/spring-circular.svg
@@ -0,0 +1,70 @@
+
diff --git a/tools/diagram/src/thread-pool.svg b/tools/diagram/src/thread-pool.svg
new file mode 100644
index 00000000..2638c5a6
--- /dev/null
+++ b/tools/diagram/src/thread-pool.svg
@@ -0,0 +1,76 @@
+