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 @@ + + + + + + + + + + + + + + + + 深分页为什么慢 + LIMIT 1000000, 10:从头数过 1000000 条,丢弃后再取 10 条 + + + 读取后丢弃 1000000 条 + + + 返回 10 条 + 游标分页:从上一页最后一条之后开始,直接定位 + + 沿 B+ 树从根节点向下查找,只读几个节点 + + + 返回 10 条 + 200 万行的表上实测:LIMIT 1000000, 10 耗时 1321 ms,游标分页 0.13 ms,并且与翻到第几页无关。 + 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 @@ + + + + + + + + + + + + + + + + 100 GB 日志中出现次数最多的 10 个 URL(内存 4 GB) + + + 100 GB 日志 + 每行一个 URL + hash(url) % 100 + + + 文件 0 + + + HashMap 计数 + + + + 前 10 + + + + + 文件 1 + + + HashMap 计数 + + + + 前 10 + + + ⋮ + + + 文件 99 + + + HashMap 计数 + + + + 前 10 + + + + + 小顶堆 + 全局前 10 + ① 约 1 GB / 份 + ② 每次只把一份读入内存 + ③ 合并 100 × 10 个候选 + 相同的 URL 一定落在同一份中,所以每份单独统计的次数就是它的总次数 + 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 @@ + + + + + + + + + + + + + + + + 一次 RPC 调用 + + + 注册中心 + 库存服务:10.0.0.8、10.0.0.9 + + ① 订阅地址,缓存在本地 + + 启动时注册,定期发送心跳 + + 订单服务(调用方) + + inventoryService.deduct(10086, 2) + + + 代理 + 取得方法名和参数 + + + 序列化、选择实例 + 组成带请求 ID 的请求帧 + + + + 库存服务(服务方) + + + 解码请求 + 得到方法名和参数 + + + 反射调用 + InventoryServiceImpl.deduct() + + 返回 true + + + + ② 请求帧 + 请求 ID 7 + 方法 + 参数 + + ③ 响应帧 + 请求 ID 7 + 结果 + 注册中心只负责注册和发现,不转发请求;调用方按请求 ID 把响应交给对应的等待线程 + 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 @@ + + + + + + + + + + + + + + + + 秒杀:层层过滤请求 + + + 10 万个请求 + 同时到达 + + + 前端与网关 + 按钮防重复提交 + 按用户限流 + + + Redis + Lua + 判断库存和是否已购 + 原子扣减,库存 100 → 0 + + + 消息队列 + 100 条 + + + 订单服务 + 扣数据库库存 + 创建订单 + + + + 成功 + + + 其余请求 + + 直接返回“已抢完” + + + 拦截重复和超限请求 + 库存大于 0 才扣减 + 唯一索引保证一人一单 + 到达数据库的请求接近库存数量;订单最终创建失败时,要把 Redis 中的库存和购买记录恢复。 + 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 @@ + + + + + + + + + + + + + + + + 自调用不经过代理 + + + 调用方 + + + 代理对象 + 执行切面逻辑 + 例如开启、提交事务 + + + 原始对象 + + outer() + + @Transactional inner() + + outer() + + + 只有从外部经过代理的调用,切面才会生效;outer() 中的 this.inner() 直接调用原始对象,事务不生效。 + 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 @@ + + + + + + + + + + + + + + + + 三级缓存解决 A、B 的循环依赖 + 一级缓存 + 完整的 Bean + 二级缓存 + 提前暴露的对象 + 三级缓存 + 生成提前引用的工厂 + + ① 实例化 A,把 A 的工厂放入三级缓存 + + A 的工厂 + + ② 填充 A 的属性时需要 B,实例化 B + + A 的工厂 + + B 的工厂 + + ③ 填充 B 时需要 A:调用 A 的工厂, + 得到 A 的提前引用,移入二级缓存 + + A(未完成) + + B 的工厂 + + ④ B 拿到 A,完成初始化 + + B + + A(未完成) + + ⑤ A 拿到 B,完成初始化 + + A + + B + A 需要 AOP 代理时,工厂在第 ③ 步直接返回 A 的代理,B 拿到的就是最终放入容器的对象。 + 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 @@ + + + + + + + + + + + + + + + + 线程池处理任务的顺序 + 核心线程数 2,最大线程数 4,队列容量 2,依次提交 7 个各执行 1 秒的任务 + ① 线程数小于核心线程数 + 新建核心线程执行 + + + + 核心线程 + + 任务 1 + + 任务 2 + ② 核心线程已满 + 放入队列等待 + + + + 队列(容量 2) + + 任务 3 + + 任务 4 + ③ 队列已满,线程数小于 4 + 新建非核心线程执行 + + + + 非核心线程 + + 任务 5 + + 任务 6 + ④ 线程数已达 4 且队列已满 + 执行拒绝策略 + + + + 拒绝 + + 任务 7 + + 实际执行顺序:0 秒时任务 1、2、5、6 开始执行,1 秒后才轮到队列中的任务 3、4。 + 队列满后新建的线程直接执行当前提交的任务,所以后提交的任务 5、6 反而先执行。 +