Merge pull request #1539 from CyC2018/polish-notes-1104

Update notes
This commit is contained in:
CyC2018
2026-11-04 22:40:58 +08:00
committed by GitHub
25 changed files with 501 additions and 140 deletions
+2 -2
View File
@@ -7,7 +7,7 @@
* [四、使用场景](#四使用场景)
* [五、实现原理](#五实现原理)
* [六、镜像与容器](#六镜像与容器)
* [七、Kubernetes](#七kubernetes) 🔥
* [七、Kubernetes](#七kubernetes) 🔥🔥
* [附录:实测](#附录实测)
* [参考资料](#参考资料)
<!-- GFM-TOC -->
@@ -257,7 +257,7 @@ depends_on 默认只保证依赖的容器先启动,不等它能提供服务;
- 宿主机上能运行,容器中不能:常见原因是容器中缺少依赖的库或命令(Alpine 使用 musl 而不是 glibc)、镜像的 CPU 架构与机器不一致(arm64 与 amd64)、程序连接的 localhost 指向了容器自己、文件权限不同;
- Docker 守护进程重启:默认情况下守护进程退出时会停止所有容器,开启 live-restore 后容器在守护进程重启期间继续运行;需要自动恢复的容器用 --restart unless-stopped 启动。
## 七、Kubernetes 🔥
## 七、Kubernetes 🔥🔥
Docker 解决的是单台机器上怎样运行容器。服务有几十个、部署在几十台机器上时,还需要决定每个容器放在哪台机器上、挂了谁来重启、怎样扩容、怎样不停机升级,这就是 Kubernetes(K8s)做的事情。
+16 -2
View File
@@ -258,7 +258,7 @@ TRACE 用于追踪路径,服务器会将通信路径返回给客户端。
- 307 Temporary Redirect:临时重定向,与 302 的含义类似,但是 307 要求浏览器不会把重定向请求的 POST 方法改成 GET 方法;
- 308 Permanent Redirect:永久重定向,与 301 的含义类似,但不允许改变请求方法。
301 和 302 的区别也体现在缓存上:没有缓存指令时,301 默认可以缓存,浏览器之后直接跳到新地址(Edge 156 实测,见[附录 A13](#a13-301-与-302-的缓存)),搜索引擎也会改为收录新地址,适合域名迁移;302 默认不缓存,每次都先访问旧地址,适合登录后跳回原页面这类临时跳转。
301 和 302 的区别也体现在缓存上:没有缓存指令时,301 默认可以缓存,浏览器之后直接跳到新地址(Edge 156 实测,见[附录 A13](#a13-301-与-302-的缓存)),搜索引擎也会改为收录新地址,适合域名迁移;302 默认不缓存,每次都先访问旧地址,适合登录后跳回原页面这类临时跳转。需要注意的是,服务器无法撤回浏览器已经缓存的 301,在缓存失效或者浏览器重新确认之前,改了服务器的配置也不一定生效,所以不确定是否永久迁移时先用 302,或者给 301 加上 Cache-Control: max-age 限定缓存的时长。
需要注意的是,RFC 9110 出于历史原因允许用户代理在 301、302 重定向时把 POST 方法改成 GET 方法,大多数浏览器也是这么做的,但只改 POST,PUT、DELETE 保持不变;303 则把 GET、HEAD 以外的方法都改为 GET。如果不希望方法被改变,应该使用 307 或者 308。
@@ -638,7 +638,7 @@ Cache-Control: public
max-age 指令出现在响应报文中,表示响应在多少秒内是新鲜的,例如 max-age=600 表示缓存 10 分钟之内可以直接使用。
max-age 指令出现在请求报文中,表示客户端希望得到已缓存时间不超过这个秒数的响应(请求中的指令只是建议,缓存可以不遵守),例如刷新页面时浏览器发送 max-age=0,要求缓存先向服务器确认;强制刷新时发送的则是 no-cache。
max-age 指令出现在请求报文中,表示客户端希望得到已缓存时间不超过这个秒数的响应(请求中的指令只是建议,缓存可以不遵守),例如刷新页面时浏览器对页面本身发送 max-age=0,要求缓存先向服务器确认,页面中还在有效期内的脚本、图片则直接使用缓存,不再确认;强制刷新时页面和其中的资源都带 no-cache(Edge 156 实测,见[附录 A2](#a2-强缓存与协商缓存))。
```http
Cache-Control: max-age=31536000
@@ -1084,6 +1084,8 @@ TLS 1.2 还支持 RSA 密钥交换:客户端生成 48 字节的随机预主密
RSA 密钥交换没有前向安全:攻击者录下通信,日后拿到服务器的私钥,就能解密录下的预主密钥,进而解密全部通信。ECDHE 的私钥在握手后就丢弃,证书私钥只用来签名,泄露后也无法还原以前的会话密钥。因此 TLS 1.3 删除了 RSA 密钥交换,完整握手只能使用 (EC)DHE 这类前向安全的方式;2026 年 7 月发布的 RFC 10015 进一步要求 TLS 1.2 的客户端不再提供、服务器不再选择 RSA 密钥交换的密码套件。
Chrome 131、OpenSSL 3.5 起,TLS 1.3 握手默认提供 X25519MLKEM768,它把 X25519 的 ECDHE 与后量子算法 ML-KEM-768 组合起来,防止现在录下的通信将来被量子计算机解密,是否选用由服务器决定。它的密钥同样每次握手临时生成,仍然前向安全。
#### 3. Finished 与会话恢复
Finished 报文的内容是用主密钥对此前全部握手报文的哈希值计算出的校验值(TLS 1.2 中为 12 字节)。握手的前几个报文是明文,攻击者可以篡改,例如删掉 ClientHello 中安全的密码套件,迫使双方使用弱算法,称为降级攻击。双方各自根据自己收发的报文计算校验值,报文被改过,两边的结果就对不上,握手失败。
@@ -1320,6 +1322,18 @@ if not c or c["exp"] < time.time() or c["redirect_uri"] != q.get("redirect_uri")
/favicon.ico 无条件请求 -> 200
```
另一个页面引用 style.css、logo.png 和 app.js,响应首部都带 Cache-Control: max-age=3600。用 Edge 156 的开发者协议打开页面后,依次普通刷新(Page.reload)和强制刷新(Page.reload,ignoreCache 为 true),服务器收到的请求:
```text
普通刷新 / Cache-Control: max-age=0, If-Modified-Since -> 304
强制刷新 / Cache-Control: no-cache, Pragma: no-cache -> 200
强制刷新 /style.css Cache-Control: no-cache, Pragma: no-cache -> 200
强制刷新 /logo.png Cache-Control: no-cache, Pragma: no-cache -> 200
强制刷新 /app.js Cache-Control: no-cache, Pragma: no-cache -> 200
```
普通刷新时,三个子资源都没有发出请求。
### A3 跨域
页面在 127.0.0.1:8001,接口在 127.0.0.1:8002。/plain 不返回 Access-Control-Allow-Origin;/cors 返回它,但预检时不返回 Access-Control-Allow-Methods;/full 的预检允许 PUT;/star 返回 Access-Control-Allow-Origin: *。用无头模式的 Edge 打开页面,页面中 fetch 的结果和接口服务器的日志:
+18 -3
View File
@@ -223,7 +223,7 @@ String s6 = "bbb";
System.out.println(s5 == s6); // true
```
Java 6 及以前,String Pool 中的字符串放在永久代,Java 7 起移到了堆中(各区域的划分见 [方法区](Java%20虚拟机.md#方法区))。这是因为永久代的空间有限,在大量使用字符串的场景下会导致 OutOfMemoryError 错误。
Java 6 及以前,String Pool 中的字符串放在永久代,Java 7 起移到了堆中(各区域的划分见 [方法区](Java%20虚拟机.md#方法区))。永久代的大小有上限,大量调用 intern() 的程序容易出现 OutOfMemoryError: PermGen space;移到堆中也是逐步去掉永久代的一步,Java 8 用元空间取代了永久代。
移到堆中之后,池中没有相等的字符串时,intern() 不再像 Java 6 那样把字符串复制到永久代,而是在池中记录堆中这个对象的引用,所以运行时拼接出的 "order1024" 调用 intern() 返回的就是它自己(JDK 21 实测,见[附录 A2](#a2-字符串的存储))。
@@ -744,6 +744,8 @@ public int hashCode() {
实际编写时可以直接调用 Objects.hash(x, y, z),它按同样的方式计算,只是初始值为 1。
JDK 中常见类型的 hashCode() 也很简单:Integer 直接返回值本身,Long 返回高 32 位与低 32 位的异或,String 按 s[0]×31^(n−1) + s[1]×31^(n−2) + … + s[n−1] 计算,算出后保存在 hash 字段中。
没有覆盖时,Object 的 hashCode() 并不是对象的内存地址。HotSpot 在第一次调用时用当前线程的 xorshift 随机数生成器得到一个 31 位的值,写入对象头的 Mark Word(见 [对象的创建与内存布局](Java%20虚拟机.md#对象的创建与内存布局)),之后对象即使被 GC 移动,哈希值也不变。生成方式由 -XX:hashCode 选择,JDK 21 的默认值为 5,即上面的随机数方案,改为 2 时所有对象的哈希值都是 1(见[附录 A7](#a7-hashcode-的生成与比较顺序))。
### toString()
@@ -1386,7 +1388,7 @@ getMethod()、getField() 只能找到 public 成员,包括继承的;getDecla
## 八、异常
Throwable 可以用来表示任何可以作为异常抛出的类,分为两种:Error 和 Exception。其中 Error 用来表示 JVM 无法处理的错误,例如 OutOfMemoryError、StackOverflowError,程序一般不捕获,编译器也不要求捕获或声明。Exception 分为两种:
Throwable 可以用来表示任何可以作为异常抛出的类,分为两种:Error 和 Exception。其中 Error 表示严重的问题,例如 OutOfMemoryError、StackOverflowError,它也是 Throwable 的子类,语法上可以捕获,但 Javadoc 说明一般的应用不应尝试捕获它,编译器也不要求捕获或声明。Exception 分为两种:
- 受检异常:例如 IOException、SQLException,需要用 try...catch... 语句捕获并进行处理,或者用 throws 声明抛出,否则编译不通过,这类异常通常可以恢复;
- 非受检异常:RuntimeException 及其子类(按 JLS §11.1.1,Error 及其子类也属于非受检异常),是程序运行时错误,例如除 0 会引发 ArithmeticException,访问 null 的成员会引发 NullPointerException。编译器不强制处理这类异常,通常应该修改代码来避免,而不是捕获之后恢复。
@@ -1400,7 +1402,7 @@ try、catch、finally 的执行顺序和返回值有以下规则(JDK 21 实测
- try 中发生异常时,跳过 try 中剩下的语句,执行匹配的 catch,再执行 finally;catch 中有 return 时,先计算返回值,执行完 finally 再返回;
- 返回值在执行 finally 之前就已经保存:返回基本类型时,finally 修改这个变量不影响返回值;返回引用时保存的是引用,finally 修改它指向的对象,调用方能看到修改;
- finally 中的 return 会覆盖 try、catch 中的返回值,还会吞掉 try 中抛出的异常,用 -Xlint:finally 编译时会给出警告,不应该这样写;
- finally 不执行的情况:执行到 finally 之前调用了 System.exit()、进程被 kill -9 或 JVM 崩溃;JVM 退出时仍在运行的守护线程也不会执行 finally。
- finally 不执行的情况:执行到 finally 之前调用了 System.exit()、进程被 kill -9 或 JVM 崩溃。JVM 因 System.exit() 或 kill(SIGTERM)退出时,先运行关闭钩子,钩子运行完 JVM 就终止,其它仍在运行的线程不论是否为守护线程,都不保证执行到 finally;最后一个非守护线程结束、JVM 正常退出时,仍在运行的守护线程同样不保证执行到 finally(JDK 21 实测,见[附录 A6](#a6-finally-的执行顺序与返回值))。
```java
static int primitive() {
@@ -1896,6 +1898,19 @@ $ javap -c -p Fin(primitive() 节选)
System.exit(0) 之后的 finally 没有输出。字节码中,0~7 是正常返回的路径:x = 1 存入槽 0,返回值复制到槽 1,执行 finally 中的 x = 2,再返回槽 1 中的 1;8~12 是发生异常时的路径,其中 9~10 是同一段 finally 代码的另一份副本。
其它线程中的 finally:主线程启动一个非守护线程和一个守护线程,两者都 sleep 60 秒并带有 finally,再注册一个打印“关闭钩子”的关闭钩子,然后分别调用 System.exit(0) 和用 kill 发送 SIGTERM:
```text
$ java F exit
调用 System.exit(0)
关闭钩子
$ java F & sleep 1.5; kill $!
main 结束,等待 kill
关闭钩子
```
两个线程的 finally 都没有执行。
### A7 hashCode 的生成与比较顺序
```java
+3 -3
View File
@@ -42,7 +42,7 @@
#### 1. Set
- TreeSet:基于红黑树实现,支持有序性操作,例如根据一个范围查找元素的操作。但是查找效率不如 HashSet,HashSet 查找的时间复杂度为 O(1),TreeSet 则为 O(logN);
- TreeSet:基于红黑树实现,支持有序性操作,例如根据一个范围查找元素的操作。但是查找效率不如 HashSet,HashSet 查找的平均时间复杂度为 O(1),TreeSet 最坏也是 O(logN);
- HashSet:基于哈希表实现,内部就是一个 HashMap,元素作为键、值是同一个占位对象,所以去重靠键的 hashCode() 和 equals()。支持快速查找,但不支持有序性操作,也不保证遍历的顺序;
- LinkedHashSet:具有 HashSet 的查找效率,并且内部使用双向链表维护元素的插入顺序。
@@ -68,7 +68,7 @@
上面的 ArrayList、LinkedList、HashMap 等常用容器都不是线程安全的。需要在多个线程之间共享时,线程安全的容器主要有以下三类:
- 同步容器:Vector、Hashtable,以及用 Collections.synchronizedList()、synchronizedMap() 包装出来的容器,所有方法共用一把锁,同一时刻只有一个线程能访问,遍历这类组合操作仍要在外面加锁(见 [ArrayList 的替代方案](#4-替代方案));
- 同步容器:Vector、Hashtable,以及用 Collections.synchronizedList()、synchronizedMap() 包装出来的容器,所有方法共用一把锁,同一时刻只有一个线程能访问,但遍历、先 containsKey() 再 put() 这类组合操作仍要在外面加锁,或者改用 putIfAbsent() 这类原子方法(见 [ArrayList 的替代方案](#4-替代方案));
- 写时复制容器:CopyOnWriteArrayList、CopyOnWriteArraySet,写操作复制整个数组,读操作不加锁,适合读多写少的场景;
- 并发容器:ConcurrentHashMap、ConcurrentSkipListMap(按键排序)、ConcurrentLinkedQueue,以及 ArrayBlockingQueue 等阻塞队列(见 [BlockingQueue](Java%20并发.md#blockingqueue)),用 CAS 和更细粒度的锁让多个线程同时访问。
@@ -152,7 +152,7 @@ private static final int DEFAULT_CAPACITY = 10;
以 JDK 8 的源码为例,添加元素时使用 ensureCapacityInternal() 方法来保证容量足够,如果不够时,需要使用 grow() 方法进行扩容,新容量的大小为 `oldCapacity + (oldCapacity >> 1)`,即 oldCapacity+oldCapacity/2。其中 oldCapacity >> 1 需要取整,所以新容量大约是旧容量的 1.5 倍(oldCapacity 为偶数就是 1.5 倍,为奇数就是 1.5 倍减 0.5)。例如依次添加元素时,容量的变化为 0、10、15、22、33(JDK 21 实测,见[附录 A1](#a1-arraylist-的扩容与常见陷阱))。JDK 21 的 grow() 改为调用 ArraysSupport.newLength() 计算新容量,增长的规则相同。
扩容操作需要调用 `Arrays.copyOf()` 把原数组整个复制到新数组中,这个操作代价很高,因此最好在创建 ArrayList 对象时就指定大概的容量大小,减少扩容操作的次数。
扩容操作需要调用 `Arrays.copyOf()` 把原数组整个复制到新数组中,这个操作代价很高,因此最好在创建 ArrayList 对象时就指定大概的容量大小,减少扩容操作的次数。由于每次扩容为 1.5 倍,复制的元素总数与元素个数成正比,在尾部 add() 的均摊时间仍是 O(1)(Javadoc 中的 amortized constant time),均摊分析见[算法分析](算法%20-%20算法分析.md#5-均摊分析)。
以下是 JDK 8 的源码:
+41 -11
View File
@@ -19,7 +19,7 @@
* [四、互斥同步](#四互斥同步)
* [synchronized](#synchronized)
* [ReentrantLock](#reentrantlock)
* [比较](#比较)
* [比较](#比较) 🔥
* [使用选择](#使用选择)
* [Java 中的锁](#java-中的锁) 🔥
* [五、线程之间的协作](#五线程之间的协作)
@@ -47,9 +47,9 @@
* [十、Java 内存模型](#十java-内存模型)
* [主内存与工作内存](#主内存与工作内存)
* [内存间交互操作](#内存间交互操作)
* [内存模型三大特性](#内存模型三大特性) 🔥🔥
* [内存模型三大特性](#内存模型三大特性)
* [先行发生原则](#先行发生原则)
* [volatile](#volatile)
* [volatile](#volatile) 🔥🔥
* [十一、线程安全](#十一线程安全) 🔥🔥
* [不可变](#不可变)
* [互斥同步](#互斥同步)
@@ -59,9 +59,9 @@
* [自旋锁](#自旋锁)
* [锁消除](#锁消除)
* [锁粗化](#锁粗化)
* [轻量级锁](#轻量级锁) 🔥
* [轻量级锁](#轻量级锁)
* [偏向锁](#偏向锁)
* [锁升级的过程](#锁升级的过程)
* [锁升级的过程](#锁升级的过程) 🔥
* [十三、多线程开发良好的实践](#十三多线程开发良好的实践)
* [附录:实测](#附录实测)
* [A1 三种锁在读多写少时的耗时](#a1-三种锁在读多写少时的耗时)
@@ -92,6 +92,7 @@
* [A26 CompletableFuture 的回调线程与异常](#a26-completablefuture-的回调线程与异常)
* [A27 traceId 随任务传递](#a27-traceid-随任务传递)
* [A28 字符串字面量作锁](#a28-字符串字面量作锁)
* [A29 线程池中等待自己的子任务](#a29-线程池中等待自己的子任务)
* [参考资料](#参考资料)
<!-- GFM-TOC -->
@@ -377,8 +378,12 @@ Spring Boot 3.2 起设置 spring.threads.virtual.enabled=true(JDK 21 及以上
JDK 自带的阻塞队列都不能修改容量,ThreadPoolExecutor 也不能替换队列。队列长度要可调时,开源的动态线程池(例如 Hippo4j、DynamicTp)复制一份 LinkedBlockingQueue 并把容量改为可修改,也可以新建一个线程池替换旧的。
用 Spring 的 ThreadPoolTaskExecutor 时,setCorePoolSize()、setMaxPoolSize()、setKeepAliveSeconds() 会转给底层的线程池,setQueueCapacity() 只在初始化时生效。Spring Boot 自动配置的执行器默认核心线程 8 个、队列容量为 Integer.MAX_VALUE,队列几乎不会满,最大线程数也就不起作用,要按需要配置 spring.task.execution.pool.queue-capacity。
不同业务最好使用各自的线程池(线程池隔离),例如导出报表变慢时只占满自己的线程,不影响下单。
任务中也不要同步等待提交到同一个线程池的子任务。例如只有 2 个线程的线程池同时执行 2 个父任务,每个父任务再提交一个子任务并调用 get() 等待:2 个线程都在等,子任务只能排在队列中,4 个任务都无法完成,这称为线程饥饿死锁(JDK 21 实测,见[附录 A29](#a29-线程池中等待自己的子任务))。父子任务要使用不同的线程池,或者用 thenCompose() 这类回调组合,不阻塞线程。
线程池的队列在内存中,进程崩溃时队列中和正在执行的任务都会丢失。不能丢的任务要先写入数据库或消息队列,执行完再标记完成,重启后重新执行未完成的,因此任务要幂等。
线程池的核心结构并不复杂,常被要求手写一个简化版:固定数量的工作线程循环从阻塞队列中取任务执行,队列满了或者线程池已经关闭时拒绝新任务。
@@ -730,7 +735,7 @@ public static void main(String[] args) {
```
### 比较
### 比较 🔥
#### 1. 锁的实现
@@ -1632,7 +1637,7 @@ Java 内存模型定义了 8 个操作来完成主内存和工作内存的交互
- lock:取得一个对象关联的锁,使它被一条线程独占;
- unlock:释放这把锁,释放后其它线程才可以取得它。
### 内存模型三大特性 🔥🔥
### 内存模型三大特性
#### 1. 原子性
@@ -1773,7 +1778,7 @@ Thread 对象的 start() 方法调用先行发生于此线程的每一个动作
此外,java.util.concurrent 的包文档还规定了一批先行发生关系,例如提交任务到线程池之前的操作先行发生于任务开始执行,Future 代表的计算先行发生于其它线程从 get() 取得结果之后的操作。
### volatile
### volatile 🔥🔥
volatile 修饰的变量有两项保证:
@@ -1977,7 +1982,7 @@ UPDATE product SET stock = stock - 1, version = version + 1 WHERE id = 1 AND ver
- 读多写少、冲突少时用乐观锁,没有加锁和阻塞的开销;
- 写多、冲突频繁时用悲观锁,否则乐观锁会不断失败重试,反而浪费 CPU;
- 读取和提交之间隔着用户操作时用乐观锁,例如打开编辑页面几分钟后才保存,版本号随页面返回、保存时再比较;
- 读取和提交之间隔着用户操作时用乐观锁,例如打开编辑页面几分钟后才保存,版本号随页面返回、保存时再比较,版本不一致时不能自动重试,否则会覆盖别人刚保存的修改,应提示用户刷新后重新修改;
- 一次 CAS 只能更新一个变量,一条带版本号的 UPDATE 只保护这一行,涉及多个资源时仍然要加锁或使用事务。
秒杀这类同一行冲突激烈的扣库存,用版本号时有库存也会大量失败,更适合把判断写进条件(`WHERE stock > 0`),见[防止超卖的几种做法](系统设计基础.md#防止超卖的几种做法)。
@@ -2167,6 +2172,8 @@ pool.execute(wrap(() -> log.info("traceId={} 发送短信", TRACE_ID.get())));
需要注意的是,只有被包装的任务才带着上下文。CompletableFuture 中没有指定线程池的 thenRunAsync() 这类方法在 ForkJoinPool.commonPool() 中执行,没有被包装,这一步的上下文会丢失(附录中读到的是 null),用 TTL 时还可能读到之前某个请求的值;挂上 TTL 的 Java Agent 后,公共池中的任务也能正确传递。
JDK 24 起公共池的线程创建时不再继承 InheritableThreadLocal,不包装任务、只换成 TTL 时读到的是 null,上下文同样丢失。
#### 3. 可重入代码(Reentrant Code)
这种代码也叫做纯代码(Pure Code),可以在代码执行的任何时刻中断它,转而去执行另外一段代码(包括递归调用它本身),而在控制权返回后,原来的程序不会出现任何错误。
@@ -2221,7 +2228,7 @@ public static String concatString(String s1, String s2, String s3) {
上一节的示例代码中连续的 append() 方法就属于这类情况。如果虚拟机探测到有这样的一串零碎的操作都对同一个对象加锁,将会把加锁的范围扩展(粗化)到整个操作序列的外部。对于上一节的示例代码就是扩展到第一个 append() 操作之前直至最后一个 append() 操作之后,这样只需要加锁一次就可以了。
### 轻量级锁 🔥
### 轻量级锁
JDK 1.6 引入了偏向锁,加上此前已有的轻量级锁,锁拥有了四个状态:无锁状态(unlocked)、偏向锁状态(biasable)、轻量级锁状态(lightweight locked)和重量级锁状态(inflated)。
@@ -2253,7 +2260,7 @@ JDK 1.6 引入了偏向锁,加上此前已有的轻量级锁,锁拥有了四
需要注意的是,偏向锁在 JDK 15 中被默认禁用并废弃(JEP 374),JDK 18 起相关参数已不再生效。
### 锁升级的过程
### 锁升级的过程 🔥
synchronized 设计锁升级,是因为重量级锁的代价在阻塞和唤醒:抢不到锁的线程要通过系统调用挂起、再被唤醒,涉及用户态与内核态的切换和上下文切换。而大部分锁在同一时刻只有一个线程使用,一次 [CAS](#1-cas) 就能加锁,没有必要付出这个代价。所以 JVM 先用代价低的实现,出现竞争后再换成代价高的实现。JDK 21 中锁的状态依次为:
@@ -3456,6 +3463,29 @@ waited 449 ms
警告只针对第 5 行的 synchronized (LOCK_I),第 6 行的 synchronized (LOCK_S) 没有警告。
### A29 线程池中等待自己的子任务
macOS 15,Apple M1 Pro,JDK 21.0.13。父任务把子任务提交到同一个 2 个线程的线程池,再同步等待子任务的结果;对照组的子任务提交到另一个线程池:
```java
ExecutorService pool = Executors.newFixedThreadPool(2);
CountDownLatch bothStarted = new CountDownLatch(2); // 保证两个父任务都已占住线程
Callable<String> parent = () -> {
bothStarted.countDown(); bothStarted.await();
Future<String> child = pool.submit(() -> "child"); // 子任务提交到同一个线程池
return "parent+" + child.get(); // 同步等待子任务
};
Future<String> p1 = pool.submit(parent), p2 = pool.submit(parent);
p1.get(3, TimeUnit.SECONDS); // 超时后打印活跃线程数和队列长度
```
```text
3 秒后仍未完成:活跃线程 2,队列中 2 个任务
父子任务分开的线程池:parent+child parent+child
```
运行 3 次结果相同。
## 参考资料
- [Object (Java SE 21)](https://docs.oracle.com/en/java/javase/21/docs/api/java.base/java/lang/Object.html)
+8 -3
View File
@@ -628,7 +628,11 @@ Major GC 的含义不统一,有的资料指只回收老年代(例如 CMS 的
#### 4. 动态对象年龄判定
虚拟机并不是永远要求对象的年龄必须达到 MaxTenuringThreshold 才能晋升老年代。以 Serial 为例,每次 Minor GC 结束时,从年龄最小的对象开始累加 Survivor 中各个年龄对象的大小,当累加到某个年龄时总和超过了 Survivor 空间的一半,就取这个年龄和 MaxTenuringThreshold 中较小的值作为新的晋升阈值,下一次 Minor GC 时年龄大于或等于该值的对象直接进入老年代。这是为了在 Survivor 快要放不下时让较老的对象先晋升,给刚出生的对象留出空间。G1 和 Parallel 的阈值也会动态调整,计算方式不同。
虚拟机并不是永远要求对象的年龄必须达到 MaxTenuringThreshold 才能晋升老年代。以 Serial 为例,每次 Minor GC 结束时,从年龄最小的对象开始累加 Survivor 中各个年龄对象的大小,当累加到某个年龄时总和超过了 Survivor 空间的一半,就取这个年龄和 MaxTenuringThreshold 中较小的值作为新的晋升阈值,下一次 Minor GC 时年龄大于或等于该值的对象直接进入老年代。这是为了在 Survivor 快要放不下时让较老的对象先晋升,给刚出生的对象留出空间。
G1 用同样的累加方法,只是比较的是按新生代的目标大小算出的 Survivor 容量乘以 TargetSurvivorRatio(默认 50%)。
Parallel 默认开启自适应调节,综合 Minor GC 与 Full GC 各自的时间开销,以及 Survivor 的空间压力(发生溢出,或者算出的目标大小超过上限),逐次增减阈值,计算方式不同。
例如 Survivor 为 4 MB,一半是 2 MB,每次 Minor GC 都有约 768 KB 的对象存活下来。第 3 次 Minor GC 后年龄 1、2、3 的对象累加到 2.3 MB,阈值从 15 变为 3,之后每次 Minor GC 都把年龄为 3 的对象移到老年代,Survivor 中再也没有年龄 4 的对象(JDK 21 实测,见[附录 A16](#a16-动态对象年龄判定))。
@@ -1110,9 +1114,10 @@ CPU 和负载都不高、接口却变慢时,线程多半在等待。多导出
- BLOCKED,停在同一把 synchronized 锁上:找到持有这把锁的线程在做什么;
- TIMED_WAITING (parking),停在 HikariPool.getConnection 这类获取连接的方法中:连接池已经耗尽,要找的是拿着连接不还或执行太慢的线程,原因在数据库一侧时扩大连接池没有用;
- RUNNABLE,停在 socket 读取上(例如 MySQL 驱动的 ReadAheadInputStream.read):在等数据库或下游服务返回,状态是 RUNNABLE 却不占 CPU。
- RUNNABLE,停在 socket 读取上(例如 MySQL 驱动的 ReadAheadInputStream.read):在等数据库或下游服务返回,状态是 RUNNABLE 却不占 CPU;
- WAITING 或 TIMED_WAITING (parking),停在 ThreadPoolExecutor.getTask 中队列的 take 或 poll 上:这是线程池中没有任务可做的空闲线程,属于正常现象,排查时先排除。
连接池耗尽时常常是后两种同时出现:少数线程拿着连接、在数据库中等待行锁,其余线程在等连接(JDK 21、HikariCP 5.1 实测,见[附录 A23](#a23-等待连接池时的线程栈))。数据库一侧找到持有锁的源头事务的方法见[慢查询的定位与优化](MySQL.md#2-分析原因)。
连接池耗尽时常常是等连接和 socket 读取这两种同时出现:少数线程拿着连接、在数据库中等待行锁,其余线程在等连接(JDK 21、HikariCP 5.1 实测,见[附录 A23](#a23-等待连接池时的线程栈))。数据库一侧找到持有锁的源头事务的方法见[慢查询的定位与优化](MySQL.md#2-分析原因)。
### 死锁
+90 -10
View File
@@ -8,10 +8,10 @@
* [索引的使用条件](#索引的使用条件) 🔥🔥
* [索引优化](#索引优化) 🔥
* [索引失效](#索引失效) 🔥
* [二、查询性能优化](#二查询性能优化) 🔥🔥
* [二、查询性能优化](#二查询性能优化)
* [一条 SQL 的执行过程](#一条-sql-的执行过程)
* [使用 Explain 进行分析](#使用-explain-进行分析)
* [慢查询的定位与优化](#慢查询的定位与优化)
* [慢查询的定位与优化](#慢查询的定位与优化) 🔥🔥
* [优化数据访问](#优化数据访问)
* [重构查询方式](#重构查询方式)
* [深分页](#深分页)
@@ -54,6 +54,8 @@
* [A14 找到持有锁的源头事务](#a14-找到持有锁的源头事务)
* [A15 EXPLAIN ANALYZE 的 loops](#a15-explain-analyze-的-loops)
* [A16 按自增 id 轮询漏掉的行](#a16-按自增-id-轮询漏掉的行)
* [A17 自增空洞、分组与 COUNT 的索引](#a17-自增空洞分组与-count-的索引)
* [A18 分批删除的加锁范围](#a18-分批删除的加锁范围)
* [参考资料](#参考资料)
<!-- GFM-TOC -->
@@ -134,6 +136,8 @@ InnoDB 的 B+Tree 索引分为两种:
二级索引的叶子节点保存主键而不是行的地址,这是因为页分裂、合并时行会移动到别的页,保存主键就不用随之修改二级索引;代价是多一次回表,并且主键越长,每个二级索引都越大。所以主键一般用较短的自增整数,自增还能让新行总是追加到最后一页,不会因为在中间插入而频繁分裂页,自增主键与 UUID 主键的实测对比见 [分布式 ID](分布式.md#八分布式-id)。
需要注意的是,自增值分配出去后不会归还,事务回滚、INSERT IGNORE 遇到唯一键冲突都会留下空洞,所以自增主键只保证递增、不保证连续(MySQL 8.0 实测,见[附录 A17](#a17-自增空洞分组与-count-的索引))。8.0 起自增计数器的最大值写入 redo log,正常重启后不会像 5.7 那样回退到表中现有的最大值;异常退出时还没刷盘的部分仍可能被重新分配。
#### 2. 哈希索引
哈希索引能以 O(1) 时间进行等值查找,但是失去了有序性:
@@ -301,7 +305,7 @@ SELECT user_id, status, create_time, id FROM orders WHERE user_id = 1001;
对应的写法:
- 把函数改写为范围:`create_time >= '2025-01-12' AND create_time < '2025-01-13'`,type 变为 range;不能改写 SQL 时,MySQL 8.0.13 起可以建函数索引 `INDEX ((DATE(create_time)))`,原来的查询就能按 ref 查找(见[附录 A2](#a2-索引的执行计划));
- 字符串列用字符串比较:`order_no = '7007'`,type 变为 ref。这是因为 '7007'、' 7007'、'7007a' 转换成数字后都等于 7007,没法按索引的顺序查找;反过来,整数列与字符串比较(`id = '1001'`)仍然走主键,因为被转换的是常量而不是列;
- 字符串列用字符串比较:`order_no = '7007'`,type 变为 ref。这是因为 '7007'、' 7007'、'7007a' 转换成数字后都等于 7007,没法按索引的顺序查找;反过来,整数列与字符串比较(`id = '1001'`)仍然走主键,因为被转换的是常量而不是列。两张表关联时,关联列的字符集或类型不一致也是这样,被转换的那一侧用不上索引,例如 latin1 的列与 utf8mb4 的列关联,latin1 一侧的表作被驱动表时只能扫描整个索引(见[附录 A17](#a17-自增空洞分组与-count-的索引)),所以关联列要用相同的类型、字符集和排序规则;
- 只在右边模糊匹配:`LIKE '7007%'`,type 为 range;按关键词搜索可以改用全文索引或 Elasticsearch;
- OR 两侧的列都有索引时,可以分别扫描两个索引再合并结果,type 为 index_merge。它同样按成本选择,例如 20 万行的表上 a = 5 OR b = 7 匹配约 2000 行时用 index_merge,改为 a = 5 OR b < 10、匹配约 2 万行时就扫描全表(见[附录 A2](#a2-索引的执行计划))。
@@ -324,7 +328,7 @@ WHERE user_id = 5 AND status > 0 AND create_time > '2025-01-05' -- key_len = 5
需要注意的是,是否使用索引是优化器按成本估算决定的。走二级索引的每一行都要回表,匹配的行越多回表越多,超过一定比例后全表扫描反而更快。这个比例并不固定(早期版本按 30% 判断,现在由代价估算决定):`create_time > '2025-01-23'` 匹配 9.9 万行,走 idx_create_time;`create_time > '2025-01-02'` 匹配 191 万行,就选择了全表扫描。IN 也一样,`user_id IN` 5 个值、1 万个值时都走索引,2 万个值时就改为全表扫描。因此同一种写法有时走索引有时不走,可能是因为匹配的数据量不同,也可能是统计信息不准(见[分析原因](#2-分析原因))。
## 二、查询性能优化 🔥🔥
## 二、查询性能优化
### 一条 SQL 的执行过程
@@ -358,7 +362,7 @@ Explain 用来查看优化器选定的执行计划,语句本身并不执行,
- ref:与索引比较的是常量(const)还是其它表的列;
- rows:估算的扫描行数;
- filtered:按其余条件过滤后剩下的行占 rows 的百分比,也是估算值;
- Extra:额外信息,Using where 表示存储引擎返回的行还要在 Server 层按 WHERE 条件过滤,走了索引时也可能出现(见[附录 A7](#a7-慢查询的定位与优化));Using index 表示使用了覆盖索引,不需要回表;Using index condition 表示使用了索引下推,只按索引列做范围查询、没有其它条件可下推时也会出现,这时并不减少回表;Using filesort 表示无法利用索引的顺序,需要额外排序;Using temporary 表示使用了临时表,常见于 GROUP BY 的列无法利用索引的顺序,或者 GROUP BY 和 ORDER BY 的列不一致。
- Extra:额外信息,Using where 表示存储引擎返回的行还要在 Server 层按 WHERE 条件过滤,走了索引时也可能出现(见[附录 A7](#a7-慢查询的定位与优化));Using index 表示使用了覆盖索引,不需要回表;Using index condition 表示使用了索引下推,只按索引列做范围查询、没有其它条件可下推时也会出现,这时并不减少回表;Using filesort 表示无法利用索引的顺序,需要额外排序;Using temporary 表示使用了临时表,常见于 GROUP BY 的列无法利用索引的顺序,或者 GROUP BY 和 ORDER BY 的列不一致。MySQL 8.0 的 GROUP BY 不再隐式排序,分组列用不上索引、又没有 ORDER BY 时,Extra 只有 Using temporary,不再有 Using filesort;分组列是索引的最左前缀、只求下一列的 MIN() 或 MAX() 时,优化器可以用松散索引扫描,Extra 为 Using index for group-by,每组只读一条索引(见[附录 A17](#a17-自增空洞分组与-count-的索引))。
出现 ALL、Using filesort 或 Using temporary,并且 rows 远多于需要的行时,通常需要调整索引或者改写查询。
@@ -387,9 +391,9 @@ Explain 只显示估算值,MySQL 8.0.18 起可以用 EXPLAIN ANALYZE 实际执
(cost=166757 rows=177198) (actual time=11.8..216 rows=86400 loops=1)
```
输出从内向外读:范围扫描估算 177198 行,实际读了 86400 行,用了 216 ms,几乎是全部的耗时。actual time 的两个数字分别是返回第一行和全部行的时间(毫秒)。loops 是这一步执行的次数,大于 1 时 time 和 rows 都是每次的平均值,例如连接中被驱动表显示 rows=2.5 loops=100,实际共返回 250 行(MySQL 8.0 实测,见[附录 A15](#a15-explain-analyze-的-loops))。怎样优化这条语句见下一节。
输出从内向外读:范围扫描估算 177198 行,实际读了 86400 行,用了 216 ms,几乎是全部的耗时。估算约为实际的 2 倍,原因可能在 InnoDB 的估算方法:叶子层最多读 10 个页,读不完就按上一层的页数推算,跨过的层数较多、结果又不精确时把结果乘 2(源码注释说这种算法容易低估),但不超过全表行数的一半。估算偏高时,优化器可能更早改用全表扫描。actual time 的两个数字分别是返回第一行和全部行的时间(毫秒)。loops 是这一步执行的次数,大于 1 时 time 和 rows 都是每次的平均值,例如连接中被驱动表显示 rows=2.5 loops=100,实际共返回 250 行(MySQL 8.0 实测,见[附录 A15](#a15-explain-analyze-的-loops))。怎样优化这条语句见下一节。
### 慢查询的定位与优化
### 慢查询的定位与优化 🔥🔥
排查慢查询一般分三步:先找出哪些语句慢,再分析为什么慢,最后修改并验证效果。
@@ -468,6 +472,10 @@ do {
} while rows_affected > 0
```
需要注意的是,可重复读下 DELETE 会给扫描到的每一行加锁,不论它是否满足条件。create_time 没有索引时,每批都按主键从头扫描,最后一轮已经没有要删的行,却要扫描并锁住整张表,期间新消息的修改和插入都被阻塞(MySQL 8.0 实测,见[附录 A18](#a18-分批删除的加锁范围))。
所以条件列要有索引;没有索引时可以按主键区间分批(WHERE id > ? AND id <= ?),每批只锁一个区间,或者在读已提交下执行,不满足条件的行判断后就释放锁。
#### 2. 分解大连接查询
将一个大连接查询分解成对每一个表进行一次单表查询,然后在应用程序中进行关联,这样做的好处有:
@@ -534,7 +542,7 @@ Elasticsearch 同样限制翻页的深度,默认不允许 from + size 超过 1
需要注意的是,分页时常常还要显示总记录数。InnoDB 没有保存表的行数,因为并发的事务看到的行数可能不同,COUNT(*) 需要扫描整个索引,上面的表执行一次约需 130 ms。数据量大时可以缓存总数、显示估算值,或者只提供“下一页”。
COUNT(*) 和 COUNT(1) 在 InnoDB 中的处理方式相同,都统计行数,优化器会选最小的二级索引来扫描,上面的表用的是 idx_create_time;COUNT(列) 只统计这一列不为 NULL 的行,要扫描包含这一列的索引(见[附录 A7](#a7-慢查询的定位与优化))。
COUNT(*) 和 COUNT(1) 在 InnoDB 中的处理方式相同,都统计行数,优化器会选最小的二级索引来扫描,“最小”指索引定义的长度(各列的字节长度之和)最短,不看索引实际占了多少页,上面的表用的是 idx_create_time;COUNT(列) 只统计这一列不为 NULL 的行,要扫描包含这一列的索引(见[附录 A7](#a7-慢查询的定位与优化))。
## 三、存储引擎
@@ -714,7 +722,7 @@ ShardingSphere 等中间件负责按分片键路由 SQL、改写分页条件和
连接由从服务器发起:I/O 线程告诉主服务器从 binlog 的哪个位置开始发送(使用全局事务 ID,即 GTID 时,告诉它已经执行过哪些事务),之后由 dump 线程主动推送新的 binlog,Canal 这类同步工具也是这样伪装成从库的。
同步到 Elasticsearch 等下游时,按自增 id 或 updated_at 定时轮询会漏数据:后分配 id 的事务可能先提交,updated_at 记录的也是语句执行的时刻而不是提交的时刻。例如事务 A 插入了 id 为 1 的行还没提交,B 插入 id 为 2 的行并提交,轮询读到 2 并记下位置,A 提交后按 id > 2 或者按 updated_at 都拉不到 1(MySQL 8.0 实测,见[附录 A16](#a16-按自增-id-轮询漏掉的行))。事务在提交阶段才写入 binlog,订阅端按 binlog 的位置继续读,不会因为行的 id 或 updated_at 小于已读到的最大值而漏掉它。
同步到 Elasticsearch 等下游时,按自增 id 或 updated_at 定时轮询会漏数据:后分配 id 的事务可能先提交,updated_at 记录的也是语句执行的时刻而不是提交的时刻。例如事务 A 插入了 id 为 1 的行还没提交,B 插入 id 为 2 的行并提交,轮询读到 2 并记下位置,A 提交后按 id > 2 或者按 updated_at 都拉不到 1(MySQL 8.0 实测,见[附录 A16](#a16-按自增-id-轮询漏掉的行))。事务在提交阶段才写入 binlog,订阅端按 binlog 的位置继续读,不会因为行的 id 或 updated_at 小于已读到的最大值而漏掉它。轮询还看不到物理删除的行,只能改成软删除并按 updated_at 轮询,前面说的漏读依然存在,所以同步一般订阅 binlog。
MySQL 8.0.27 起 replica_parallel_workers 默认为 4,从服务器上实际是一个协调线程读取中继日志,再把事务分给 4 个工作线程并行重放,以减小复制延迟。哪些事务可以并行,由主库写在 binlog 中的依赖信息决定:8.0 默认(binlog_transaction_dependency_tracking 为 COMMIT_ORDER)只让主库上同时处于提交阶段的事务并行,主库并发低、事务一个接一个提交时,从库上仍是串行重放;设为 WRITESET 后还按修改的行判断,能让更多互不冲突的事务并行(写集为空或不完整、修改没有主键和唯一键的表或外键中的父表时,仍按提交顺序判断),8.4 删除了这个参数,固定使用 WRITESET。
@@ -731,7 +739,7 @@ MySQL 8.0.27 起 replica_parallel_workers 默认为 4,从服务器上实际是
- 大多数业务读多写少,读请求可以分散到多个从服务器上,主服务器只处理写入;
- 统计、报表这类耗时的查询放在从服务器上,不占用主服务器的 CPU 和 I/O。
读写分离可以由代理(例如 ProxySQL)转发,对应用透明但多了一跳网络,也可以在应用中选择数据源(例如 ShardingSphere-JDBC)。同一个事务中的读一般也发到主库,否则读不到本事务刚写入的数据。
读写分离可以由代理(例如 ProxySQL)转发,对应用透明但多了一跳网络,也可以在应用中选择数据源(例如 ShardingSphere-JDBC)。同一个事务中的读一般也发到主库,否则读不到本事务刚写入的数据。在应用中用注解切换数据源时,切换要在事务取得连接之前完成,通常让切换数据源的切面排在事务切面之前:Spring 的事务一般在开始时就取得连接并绑定到线程,之后再切换不起作用。
<div align="center"> <img src="pics/mysql-rw-split.png" width="760px"/> </div><br>
@@ -747,6 +755,8 @@ MySQL 默认是异步复制,主服务器提交事务后不等从服务器,
- 写入后记下这个事务的 GTID,读从库前先执行 WAIT_FOR_EXECUTED_GTID_SET,等从库执行完这个事务后,在新开始的事务或快照中读取,超时就改读主库;
- 监控从库的延迟,延迟过大的从库暂时不分配读请求。
“写入后短时间内”跨了请求时,可以在写入后把用户的标记存入 Redis 或 Cookie,有效期略大于平时的复制延迟,标记存在时读主库。这是经验做法,延迟突然变大时标记已经过期,仍可能读到旧数据,要严格保证时用等待 GTID 的方法。
使用缓存时,未命中的读请求还可能把从库中的旧值写回缓存,见 [缓存一致性](缓存.md#缓存一致性)。
异步复制时主库宕机,已经提交、还没有发给从库的事务在切换后就丢失了。半同步复制让至少一个从库把 binlog 写入中继日志并刷盘后,主库才返回提交成功。默认的 AFTER_SYNC 在主库的存储引擎提交之前等待确认,等待期间主库上的其它会话也看不到这个事务;改为 AFTER_COMMIT 时先提交再等待,其它会话可能已经读到一个主库宕机后会丢失的事务。半同步复制并不保证从库已经执行完,读从库仍可能读到旧数据;等待超时后主库会退回异步复制。
@@ -1346,6 +1356,76 @@ A: COMMIT
表中: (1, 07:30:55.516700) (2, 07:30:55.528103)
```
### A17 自增空洞、分组与 COUNT 的索引
MySQL 8.0.44,Apple M1 Pro。
自增值:表 ai(id INT PRIMARY KEY AUTO_INCREMENT, code VARCHAR(10) UNIQUE),插入 'a';开事务插入 'b' 后回滚;INSERT IGNORE 再插入 'a';最后插入 'c':
```text
SELECT id, code FROM ai ORDER BY id -> ((1, 'a'), (4, 'c'))
```
分组:表 g 有 2 万行和索引 idx_ab(a, b),c 列没有索引:
```text
EXPLAIN SELECT c, COUNT(*) FROM g GROUP BY c -> key None | Extra Using temporary
EXPLAIN SELECT a, MIN(b) FROM g GROUP BY a -> key idx_ab | Extra Using index for group-by
```
COUNT(*):表 cnt3 有 5 万行,k_name(name VARCHAR(255)) 的值都是 'x',k_b(b1 BIGINT, b2 BIGINT):
```text
n_leaf_pages (('PRIMARY', 138), ('k_b', 83), ('k_name', 39))
EXPLAIN SELECT COUNT(*) FROM cnt3 -> key k_b key_len 18
```
k_name 实际只有 39 个叶子页,但定义长度为 1022 字节,优化器选了定义更短的 k_b。
关联列的字符集:o1.no 为 utf8mb4、o2.no 为 latin1,两列都有索引,各 3000 行:
```text
EXPLAIN SELECT * FROM o1 JOIN o2 ON o1.no = o2.no WHERE o1.id = 5
o1 const PRIMARY rows 1
o2 index no rows 3000 Using where; Using index
EXPLAIN SELECT * FROM o2 JOIN o1 ON o1.no = o2.no WHERE o2.id = 5
o2 const PRIMARY rows 1
o1 ref no rows 1 Using where; Using index
```
o2 作被驱动表时,o2.no 要先转换成 utf8mb4 再比较,只能扫描整个索引;o1 作被驱动表时,被转换的是从驱动表取出的值,o1.no 上的索引可以正常查找。
### A18 分批删除的加锁范围
MySQL 8.0.44,Apple M1 Pro。表 messages(id INT AUTO_INCREMENT PRIMARY KEY, create_time DATETIME, content VARCHAR(100)),前 6000 行是 2026-01 的旧消息,后 4000 行是 2026-10 的新消息。每批在一个事务中执行下面的语句,提交前从 performance_schema.data_locks 统计记录锁的个数,并用另一个会话修改 id = 9000 的新消息、插入一条新消息(innodb_lock_wait_timeout = 1):
```sql
DELETE FROM messages WHERE create_time < '2026-08-01' LIMIT 1000;
```
```text
create_time 没有索引,可重复读(EXPLAIN key = NULL)
第 1 批:删除 1000 行,记录锁 1002 个;修改 id=9000 成功;插入新消息成功
第 7 批:删除 0 行,记录锁 4011 个;修改 id=9000 报 1205 锁等待超时;插入新消息报 1205 锁等待超时
create_time 没有索引,读已提交
第 1 批:删除 1000 行,记录锁 1000 个;修改 id=9000 成功;插入新消息成功
第 7 批:删除 0 行,记录锁 0 个;修改 id=9000 成功;插入新消息成功
create_time 有索引,可重复读(EXPLAIN key = idx_ct)
第 1 批:删除 1000 行,记录锁 2001 个;修改 id=9000 成功;插入新消息成功
第 7 批:删除 0 行,记录锁 1560 个;修改 id=9000 成功;插入新消息成功
```
有索引时第 7 批的记录锁加在已删除、还没有被 purge 清理的索引记录上;删完后等待 20 秒,purge 清理完这些记录再执行同一条语句,只剩 2 个记录锁。
按主键区间分批(create_time 没有索引,可重复读),语句改为 DELETE FROM messages WHERE id > ? AND id <= ? AND create_time < '2026-08-01':
```text
id 在 (0, 1000]:删除 1000 行,记录锁 1002 个;修改 id=9000 成功;插入新消息成功
id 在 (9000, 10000]:删除 0 行,记录锁 1004 个;修改 id=9000 成功;插入新消息报 1205 锁等待超时
```
最后一个区间到达表尾,锁住了最后一行之后的间隙,这一批执行期间新消息的插入要等待。
## 参考资料
- [MySQL 8.0: InnoDB Restrictions and Limitations](https://dev.mysql.com/doc/refman/8.0/en/innodb-restrictions-limitations.html)
+5 -1
View File
@@ -374,6 +374,10 @@ ZREVRANK rank:likes article:1001 -- 第几名,从 0 开始
分值相同时,Redis 按成员名的字典序排列,而业务上通常要求先达到这个分数的排在前面。可以把时间编进分值:分值 = 点赞数 × 10<sup>10</sup> + (10<sup>10</sup> − 达到这个点赞数的秒级时间戳)。例如 article:1001 和 article:1002 都是 520 个赞,时间戳分别为 1793000000 和 1793000300,分值为 5208207000000 和 5208206999700,先达到的 article:1001 排在前面。需要注意的是,分值是双精度浮点数,只能精确表示 2<sup>53</sup> 以内的整数,这种写法下点赞数不能超过约 90 万,更大时要缩短时间部分的位数。
成员上千万时,一个有序集合就成了[大 Key](缓存.md#大-key-与热-key),按 100 万个约 105 MB 估算,1000 万个约 1 GB。可以按分数段拆成多个有序集合,名次等于分数更高的各段的成员数之和,加上在本段中的名次,加分跨段时把成员移到新的段。
只需要显示“超过了约百分之多少的用户”时,可以按分数区间计数,用户换分时把旧区间的计数减 1、新区间的加 1,不必把每个成员都放进有序集合。同一区间内谁高谁低无从得知,结果是近似值,区间分得越细越准。
排行榜被大量用户同时查看时,前 10 名这个键会成为热点。前几名的变化不需要实时反映给每个用户,可以在应用的本地缓存中保存 1 秒,大部分请求不再访问 Redis,见 [多级缓存](缓存.md#多级缓存)。
### 其它
@@ -650,7 +654,7 @@ Redis 用一个线程执行所有命令,仍然很快,原因主要有以下
Redis 6.0 加入的 I/O 多线程(io-threads,默认关闭)分担的也是这部分开销:多个 I/O 线程并行读取、解析请求和写回响应,命令仍由主线程逐条执行,不会有两个线程同时修改一个键。6.0 和 7.x 默认只把写回交给 I/O 线程,读取和解析还要打开 io-threads-do-reads,并且主线程分发完任务后要忙等所有 I/O 线程完成。
8.0 重新实现后,每个 I/O 线程有自己的事件循环,负责普通客户端的读取、解析和写回;命令仍由主线程执行,主从复制、订阅等特殊连接的读写也由主线程处理(8.8 起主从复制的连接也交给 I/O 线程)。8.0 的 io-threads 把主线程也算作一个,例如 io-threads 4 表示主线程加 3 个 I/O 线程。
8.0 重新实现后,每个 I/O 线程有自己的事件循环,负责普通客户端的读取、解析和写回;命令仍由主线程执行,主从复制、订阅、MONITOR 和阻塞中的连接这类特殊连接的读写也由主线程处理(8.6 起主从复制的连接在完成全量同步后也交给 I/O 线程)。8.0 的 io-threads 把主线程也算作一个,例如 io-threads 4 表示主线程加 3 个 I/O 线程。
<div align="center"> <img src="pics/redis-io-threads.png" width="760px"/> </div><br>
+29 -1
View File
@@ -40,6 +40,7 @@
* [A12 MyBatis 的结果映射、重载与二级缓存](#a12-mybatis-的结果映射重载与二级缓存)
* [A13 注解声明 Bean 与集合注入](#a13-注解声明-bean-与集合注入)
* [A14 Spring MVC 的映射与参数解析](#a14-spring-mvc-的映射与参数解析)
* [A15 @Async 的代理类型](#a15-async-的代理类型)
* [参考资料](#参考资料)
<!-- GFM-TOC -->
@@ -66,6 +67,8 @@ public class OrderService {
- 对象只依赖接口,替换实现、在测试中注入模拟对象都不需要修改代码;
- 对象的创建、作用域和生命周期由容器统一管理,事务、缓存这类功能也是容器在创建对象时织入的。
Spring 的容器用两个接口表示:BeanFactory 是根接口,负责创建、装配和获取 Bean,单独使用普通的 BeanFactory 时默认用到才创建;平时使用的 ApplicationContext 继承了它,增加了自动注册 BeanPostProcessor、发布事件、国际化等功能,启动时就创建全部非懒加载的单例。官方文档建议没有特别的理由时都使用 ApplicationContext。
容器通过注解知道要创建哪些对象:类上标注 @Component 或者它的派生注解 @Service、@Repository、@Controller,启动时由组件扫描发现;第三方库中的类无法修改源码,就在 @Configuration 类中写一个返回该对象的方法,并标注 @Bean。
@Service 等派生注解本身标注了 @Component(称为元注解),所以自定义的注解标上 @Component 也能声明 Bean。
@@ -79,7 +82,7 @@ public class OrderService {
- @Autowired 是 Spring 的注解,先按类型查找,找到多个时依次看 @Primary、字段名或参数名、@Priority(Spring 6.1 及以前 @Priority 先于名称),仍然无法确定就报错,可以用 @Qualifier 指定 Bean 的名称;
- @Resource 是 Jakarta EE(原 JSR-250)的注解,指定了 name 时按名称查找,否则先按字段名查找,找不到再按类型查找。它只能用在字段和 Setter 上,不能用在构造器上。
例如容器中有两个 PayService 类型的 Bean,名称分别是 alipay 和 wechat:
Bean 的名称默认是 @Bean 的方法名,或者 @Component 类的类名首字母小写(类名前两个字母都大写时保持原样,例如 URLService),也可以在注解中指定。例如容器中有两个 PayService 类型的 Bean,名称分别是 alipay 和 wechat:
```java
@Resource PayService wechat; // 注入名称为 wechat 的 Bean
@@ -212,6 +215,8 @@ public class CounterController {
Spring Framework 在目标类实现了接口时默认使用 JDK 动态代理;Spring Boot 从 2.0 起默认使用 CGLIB,可以通过 spring.aop.proxy-target-class 修改。Boot 这样做是为了减少类型转换的问题:JDK 代理只实现接口,一个实现了接口的 Bean 被别处按实现类注入时,会因为代理的类型不匹配而启动失败(BeanNotOfRequiredTypeException)。目标本身是 JDK 代理(例如 MyBatis 的 Mapper)时,生成的仍是 JDK 代理。
需要注意的是,只标了 @Async 的 Bean,代理由 @EnableAsync 单独创建,不受这个配置影响:Spring Boot 3 中实现了接口的这类 Bean 仍是 JDK 代理,按实现类注入同样会启动失败,要设置 @EnableAsync(proxyTargetClass = true)(Spring Boot 3.5 实测,见[附录 A15](#a15-async-的代理类型))。Spring Boot 4 起它也跟随 spring.aop.proxy-target-class。
动态代理在运行时生成代理类,不必像[静态代理](设计模式%20-%20代理.md)那样为每个接口手写,同一段增强逻辑可以复用到不同的类上。JDK 动态代理的原理可以用下面的代码说明,切面逻辑写在 InvocationHandler 中:
```java
@@ -1249,6 +1254,29 @@ POST /orders(JSON) 200 {"id":1024,"userId":1001}
POST /orders(text/plain) 415 Content-Type 'text/plain;charset=UTF-8' is not supported
```
### A15 @Async 的代理类型
Spring Boot 3.5.16、JDK 21,默认配置(spring.aop.proxy-target-class 为 true)。AsyncServiceImpl 实现了接口 AsyncService,方法上标了 @Async,配置类上标 @EnableAsync,先启动一次打印各个 Bean 的代理类型,再另外启动一次,在另一个 Bean 中按实现类注入它:
```java
public interface AsyncService { void run(); }
@Service("asyncService") public static class AsyncServiceImpl implements AsyncService {
@Async public void run() { }
}
@Autowired AsyncServiceImpl asyncService; // 按实现类注入
```
```text
@EnableAsync
asyncService JDK targetClass=false jdk.proxy2.$Proxy56 advisors=[AsyncAnnotationAdvisor]
STARTUP FAILED: org.springframework.beans.factory.BeanNotOfRequiredTypeException: Bean named 'asyncService' is expected to be of type 'demo.Services$AsyncServiceImpl' but was actually of type 'jdk.proxy2.$Proxy56'
@EnableAsync(proxyTargetClass = true)
asyncService CGLIB targetClass=true demo.Services$AsyncServiceImpl$$SpringCGLIB$$0 advisors=[AsyncAnnotationAdvisor]
```
同一个应用中,@Transactional、@Cacheable 的 Bean 都是 CGLIB 代理。换成 Spring Boot 4.0.9、不改配置时,@Async 的 Bean 也是 CGLIB 代理。
## 参考资料
- [Spring Framework: Dependency Injection](https://docs.spring.io/spring-framework/reference/core/beans/dependencies/factory-collaborators.html)
Binary file not shown.

Before

Width:  |  Height:  |  Size: 149 KiB

After

Width:  |  Height:  |  Size: 126 KiB

+7 -2
View File
@@ -105,7 +105,8 @@ SET lock_key unique_value NX PX 30000
- 释放锁:value 设置为客户端的唯一标识,释放锁时先判断 value 是否为自己设置的再删除,避免误删其它客户端的锁。判断和删除要原子地完成,通常放在一个 [Lua 脚本](Redis.md#lua-脚本)中(Redis 8.4 起也可以用下文的 DELEX):分成 GET 和 DEL 两条命令时,GET 确认是自己的锁之后、DEL 执行之前,锁可能恰好过期并被其它客户端拿到,DEL 删掉的就是别人的锁;
- 锁续期:如果业务执行时间超过了过期时间,锁会被提前释放;过期时间太长,持有者崩溃后其它客户端又要等很久。过期时间通常取业务正常耗时的几倍,耗时难以估计时使用后台线程定时延长过期时间,例如下文 Redisson 的看门狗;
- 主从切换:Redis 的主从复制是异步的,如果主节点加锁后还未同步到从节点就宕机,从节点经[故障转移](Redis.md#十二sentinel)晋升为主节点之后锁就丢失了。
- 主从切换:Redis 的主从复制是异步的,如果主节点加锁后还未同步到从节点就宕机,从节点经[故障转移](Redis.md#十二sentinel)晋升为主节点之后锁就丢失了;
- 内存淘汰:锁的键带有过期时间,内存满时 allkeys-* 和 volatile-* 这类淘汰策略都可能在到期之前把它删掉,存放锁的 Redis 要用 noeviction,这时内存满了加锁会报错,要当作获取锁失败处理,见[数据淘汰策略](Redis.md#七数据淘汰策略)。
<div align="center"> <img src="pics/redis-lock-release.png" width="760px"/> </div><br>
@@ -678,6 +679,8 @@ gRPC 选择 HTTP/2 是因为它的 [多路复用](HTTP.md#二进制分帧层):
需要注意的是,MySQL 的 UUID() 生成的是基于时间的 v1,时间戳的低位在最前面,短时间内连续生成的值几乎是递增的,接近顺序插入:同样插入 100 万行用时 5.1 秒、数据 164 MB,多出的部分主要来自 36 个字符的长度。
RFC 9562(2024)定义的 UUIDv7 把 48 位的毫秒时间戳放在最前面,除去版本和变体位,其余的位可以是随机数,也可以放更细的时间或计数器,按字节比较大体随时间递增,存成 BINARY(16) 作主键时新值基本都插入到末尾,规范也建议能用 v7 时不用 v1。JDK 26 起可以用 UUID.ofEpochMillis(System.currentTimeMillis()) 生成,但同一毫秒内的多个值不保证递增,需要严格递增时可以用 uuid-creator 这类带计数器的库,由同一个生成器实例生成的值才保证递增。
号段模式要避免取下一段时卡住发号。美团 Leaf 为每个业务准备两个号段缓冲区,当前号段发出 10% 后就异步把下一段取到另一个缓冲区,当前号段用完时直接切换;下一段还没取回时短暂等待,仍然没有就返回错误。号段长度一般按高峰 QPS 的若干分钟设置,美团建议取 600 倍,即 10 分钟的用量,这样数据库宕机时还能继续发号一段时间。
### 雪花算法
@@ -745,7 +748,9 @@ public synchronized long nextId() {
- 处理中:期间到达的重复请求返回“处理中”(例如 HTTP 409),让客户端稍后再查;过期时间要长于处理的最长耗时,崩溃后等它过期就能重试;
- 已完成:保存第一次的结果,保留到上游不再重试之后,例如微信支付的回调失败后会在约 24 小时内反复重发。记录中还可以保存请求内容的摘要,同一个幂等键带着不同的内容再次提交时直接报错(Idempotency-Key 首部的 IETF 草案建议返回 422),而不是回放第一次的结果。
业务已经提交、记录还没改为“已完成”时崩溃,重试仍会再执行一次,所以业务写入本身仍要靠唯一约束或状态条件兜底,调用外部服务时让下游按同一个业务键去重。
业务已经提交、记录还没改为“已完成”时崩溃,重试仍会再执行一次,所以业务写入本身仍要靠唯一约束或状态条件兜底,调用外部服务时让下游按同一个业务键去重。用注解加切面实现时,切面要包在事务的外面,事务提交之后再把记录改为“已完成”;切面在事务里面时,记录改为“已完成”之后事务仍可能回滚,重试就被挡掉了。
数据库的提交本身也可能结果未知:COMMIT 发出后连接断开,事务可能已经提交,也可能没有,MySQL Connector/J 这时抛出 SQLState 为 08007(Transaction resolution unknown)的异常。重连后要按业务键查询确认,或者让重试本身是幂等的。
实现幂等首先要定义什么算同一个请求,也就是幂等键怎么来。以用户狂点“提交订单”为例:
+153 -17
View File
@@ -13,7 +13,7 @@
* [幻觉](#幻觉)
* [推理模型](#推理模型)
* [二、提示词与上下文](#二提示词与上下文)
* [提示词](#提示词)
* [提示词](#提示词) 🔥
* [结构化输出](#结构化输出)
* [上下文工程](#上下文工程)
* [三、检索增强生成](#三检索增强生成) 🔥🔥
@@ -31,13 +31,14 @@
* [长上下文与 Agentic RAG](#长上下文与-agentic-rag)
* [GraphRAG](#graphrag)
* [多模态 RAG](#多模态-rag)
* [Text2SQL](#text2sql)
* [四、Agent](#四agent)
* [Agent 是什么](#agent-是什么) 🔥🔥
* [工具调用](#工具调用)
* [Agent 循环](#agent-循环)
* [Agent 与工作流](#agent-与工作流)
* [意图识别与路由](#意图识别与路由)
* [规划](#规划) 🔥
* [规划](#规划)
* [记忆](#记忆) 🔥
* [多轮对话](#多轮对话)
* [MCP](#mcp) 🔥
@@ -88,6 +89,7 @@
* [A31 注意力分数的缩放](#a31-注意力分数的缩放)
* [A32 语义缓存的相似度](#a32-语义缓存的相似度)
* [A33 模型生成 SQL 的权限限制](#a33-模型生成-sql-的权限限制)
* [A34 Text2SQL 的口径与语义层](#a34-text2sql-的口径与语义层)
* [参考资料](#参考资料)
<!-- GFM-TOC -->
@@ -288,7 +290,7 @@ GQA 减少的是缓存和读取的数据量,注意力的计算量基本不变
几个参数同时设置时,顺序也有影响:vLLM 和 Transformers 先除以温度,再做 Top-k、Top-p;llama.cpp 默认最后才应用温度。温度不改变排序,但会改变 Top-p 留下几个 Token,例如上面的分布在温度 0.5、Top-p 0.8 时,先做温度只留下 2 个(0.75 + 0.15),后做温度留下 3 个。
请求中不传采样参数时,生效的往往是模型自带的 generation_config.json,例如 vLLM 默认读取它,Qwen2.5-7B-Instruct 中设的是温度 0.7、Top-p 0.8、Top-k 20。温度设为 0 时,vLLM 按贪心解码处理;Transformers 则要设置 do_sample=False。
请求中不传采样参数时,生效的往往是模型自带的 generation_config.json,例如 vLLM 默认读取它,Qwen2.5-7B-Instruct 中设的是温度 0.7、Top-p 0.8、Top-k 20,以及压低重复内容的 repetition_penalty 1.05。温度设为 0 时,vLLM 按贪心解码处理;Transformers 则要设置 do_sample=False。
抽取字段、生成代码、调用工具等需要稳定结果的场景使用较低的温度,写作类场景可以适当调高。
@@ -365,6 +367,8 @@ DPO 的损失只取决于两个回答的概率之差,需要注意的是:
如果 4 个回答全对或全错,优势都是 0,这道题对训练没有贡献,所以题目的难度要适中。数学、代码这类能自动判断对错的任务可以直接用规则打分,不需要奖励模型。DeepSeekMath 中的 GRPO 仍使用训练出的奖励模型,DeepSeek-R1 对推理任务改用规则打分,原因是神经网络的奖励模型在大规模强化学习中容易被模型钻空子(Reward Hacking),对通用的对话数据仍使用奖励模型。
规则打分同样会被钻空子。例如以测试通过为奖励训练写代码的模型时,模型可能修改测试文件,或者在断言执行之前调用 sys.exit(0),让测试进程以 0 退出、看起来全部通过。所以测试和打分代码要放在模型改不到的地方,并按测试的实际结果而不是进程的退出码判分。
GRPO 把 KL 散度直接加在损失中,概率之比的限制与 PPO 相同。PPO 把每个 Token 的 KL 惩罚加在奖励中,强化学习最大化的是累计奖励,回答越长累计的惩罚越多,DeepSeek-R1 的作者认为这可能抑制回答变长;GRPO 的 KL 项不进入优势的计算。R1 的训练中还每 400 步把参考模型换成最新的策略模型,让模型在长时间训练中能继续探索。
代价是每道题要生成一组回答,DeepSeekMath 中每题采样 64 个,DeepSeek-R1 中每题 16 个,生成的开销很大。
@@ -405,7 +409,7 @@ RAG、微调和提示词解决的是不同的问题:
通常先尝试提示词,再考虑 RAG,最后才考虑微调。微调需要准备训练数据,基座模型升级后要重新训练,而且写入参数的知识难以更新,也无法给出来源。
用微调灌输新知识的效果也不好。有实验表明,SFT 数据中包含模型预训练时不知道的事实时,这些样本学得明显更慢,而一旦被学会,模型编造答案的倾向随之增加。在这项闭卷问答的实验中,负面影响主要出现在训练后期,表现为过拟合,按验证集提前停止训练、去掉模型不知道的样本,或者把它们的答案改为“不知道”,都能减轻。
用微调灌输新知识的效果也不好。有实验表明(全参数微调),SFT 数据中包含模型预训练时不知道的事实时,这些样本学得明显更慢,而一旦被学会,模型编造答案的倾向随之增加。在这项闭卷问答的实验中,负面影响主要出现在训练后期,表现为过拟合,按验证集提前停止训练、去掉模型不知道的样本,或者把它们的答案改为“不知道”,都能减轻。
LIMA 只用 1000 条精选的样本微调 65B 的模型就得到了很好的对话效果,作者据此提出假说:知识和能力几乎都在预训练中学到,对齐主要教会模型回答时用什么格式。
@@ -543,7 +547,7 @@ FP8 有两种编码:E4M3(4 位指数、3 位尾数)最大 448,E5M2(5
- 每行(每个输出通道)一个系数:好一些,但这一行的最大值正好是这个离群值,同一行的其它权重仍然全部变成 0;
- 每组一个系数:离群值只影响它所在的组,另一组的权重得以保留。实际常用 128 个一组,每组多存一个 FP16 的缩放系数,平均每个权重多 0.125 位,7B 模型约多 0.12 GB。
把 Qwen2.5-7B 28 层中的全部线性层按不同的方式量化,在一段约 6600 个 Token 的中文技术文本上测量困惑度(越低越好):
把 Qwen2.5-7B 28 层中的全部线性层按不同的方式量化,在一段约 6600 个 Token 的中文技术文本上测量困惑度(越低越好)。困惑度(Perplexity)是模型给文本中每个 Token 的概率取负对数、求平均后再取指数,可以理解为模型平均在多少个候选之间犹豫;分词方式不同的模型之间不能直接比较:
<div align="center"> <img src="pics/quant-ppl.png" width="700px"/> </div><br>
@@ -655,7 +659,7 @@ Agent 的幻觉除了回答错误,还会出现在工具调用的过程中。
## 二、提示词与上下文
### 提示词
### 提示词 🔥
对话接口的输入是一组带角色的消息:系统(system)消息设定模型的身份和规则,用户(user)消息是具体的请求,助手(assistant)消息是模型之前的回答,工具(tool)消息是工具的执行结果。
@@ -686,6 +690,8 @@ Agent 的幻觉除了回答错误,还会出现在工具调用的过程中。
提示词和代码一样需要版本管理。提示词、模型版本、工具定义和采样参数共同决定了输出,最好作为一个整体编号发布,线上记录每次请求用的是哪个版本,新版本先灰度给一小部分用户,与旧版本对比线上指标,出问题时整体回滚;比较时把模型固定到具体的版本,一次只改一处,否则分不清变化来自提示词还是模型。
灰度按用户分流而不是按请求,否则同一段多轮对话中前后两轮用的版本不同,效果分不开。
### 结构化输出
程序需要解析模型的输出时,要求模型按照 JSON Schema 返回结果。JSON Schema 是用 JSON 描述数据格式的规范,写明有哪些字段、各自的类型、哪些必填以及取值范围,例如 {"type": "object", "properties": {"temp": {"type": "integer"}}, "required": ["temp"]} 要求输出一个带整数字段 temp 的对象。只在提示词中要求输出 JSON 是不够的,模型仍可能多说一句解释、漏掉字段或者少写一个括号。
@@ -704,6 +710,8 @@ Agent 的幻觉除了回答错误,还会出现在工具调用的过程中。
不按 Schema 约束顺序的 JSON 模式也可能出现这个问题,例如 Tam 等(2024)用 gpt-3.5-turbo-0125 的实验中,JSON 模式的输出全都把 answer 写在 reason 前面,没有先写推理步骤,拼接单词末尾字母(Last Letter)任务的平均准确率只有 1.78%;这项研究的提示词设计存在争议,不能据此认定 JSON 模式必然降低准确率(arXiv 2408.02442)。
把解释字段放在前面,针对的是不带思考的模型。推理模型在输出之前已经思考过,OpenAI 的文档认为不必再让它逐步推理;Claude 的文档还把 JSON 中的 reasoning、thinking 这类字段列为拒答的常见原因,需要说明时改为要求简短的理由或依据(以官方最新文档为准)。
#### 2. 输出无法解析时
不支持约束解码时,模型的输出可能无法解析。最常见的是 JSON 前后多了内容,例如包在 ```` ```json ```` 代码块里;其次是输出达到 max_tokens 被截断(接口返回的 finish_reason 为 length),以及语法合法但不符合 Schema,例如枚举值写成了同义词、金额写成“59 元”这样的字符串。
@@ -906,6 +914,8 @@ Word、HTML、Markdown 文件本身记录了标题样式、表格和列表,按
- Embedding 模型有最大长度,bge-small-zh-v1.5 最多 512 个 Token,超出的部分直接被截断,800 字的中文约 680 个 Token;
- 放进提示词的内容越少,成本越低,引用的来源也越精确。
换成最多 8192 个 Token 的模型虽然不会截断,第一条仍然成立。例如用 jina-embeddings-v2-small 在 LongEmbed 的英文长文本检索任务上,把文本截到 8192 个 Token 后,按 512 个 Token 切片比整段编码的 nDCG@10 平均相对提高约 24%(arXiv 2409.04701)。
但切片也不能太小。例如“工作满 1 年不满 10 年的”和“每年 5 天”被切到两段,单独召回哪一段都无法回答问题。切片太大,向量不够准确,无关的内容还会占用上下文。
#### 2. 常用做法
@@ -1084,6 +1094,8 @@ embedding 由 text 经 Embedding 模型计算得到,bge-small-zh-v1.5 输出 5
文档修改后,按文档 ID 和内容的哈希值找出变化的文档,删除它的全部旧切片再写入新切片,不需要重建整个索引;只追加新切片会让旧内容继续被召回。只是权限或版本状态变化时,更新切片上的元数据即可,不必重新计算向量。文档的原文和元数据一般以关系数据库为准,向量库按切片 ID 幂等写入,失败时重试,并定期对账两边的数据。要写入向量库的切片可以在修改文档的同一个事务中记一条同步记录,由后台读取后写入向量库,做法同[本地消息表](分布式.md#本地消息表),文档写成功、向量写失败时有记录可以重试。
库存、价格这类变化频繁、要求准确的数据以业务系统为准,回答时用工具查询,知识库主要保存说明类、变化较慢的内容。有有效期的内容,例如促销规则,在元数据中记下生效和失效时间,检索时按问题涉及的时间过滤,问的是现在的规则就用当前时间。
哈希只能发现被修改的文档,整篇源文件被删除时,要靠文档系统的删除事件,或者定期拿源系统的全部文档 ID 与库中比对,删掉多出来的切片。
删旧切片和写新切片不是一次完成的,期间查询可能缺少这篇文档或新旧内容并存;在意这一点时,先写入带新版本号的切片,再把元数据中的当前版本切过去,最后删除旧切片。另外,Milvus 默认的一致性级别是 Bounded,默认容忍最近约 5 秒内写入的数据不可见;查询必须看到刚才的写入时指定 Strong,只需要读到同一个客户端自己的写入时可以用 Session,代价是查询要等待数据可见。
@@ -1242,7 +1254,7 @@ RAG 需要分别评测检索和生成,这样才能定位问题出在哪一步
#### 3. 评测集
指标要在一批固定的问题上计算。问题优先从真实的用户问题中抽样,不够时让模型根据文档生成,再人工改写措辞,因为模型生成的问题往往照抄原文的词语,会高估向量检索的效果。除了常见问题,还要包含资料中没有答案的问题、需要多个切片的问题和新旧版本冲突的问题。没有答案的问题,标准答案标为“资料中没有”,回答不知道算对,编出答案算错;它和有答案的问题被误拒要一起统计,两者的取舍与[相似度阈值](#4-相似度阈值)相同。
指标要在一批固定的问题上计算。问题优先从真实的用户问题中抽样,不够时让模型根据文档生成,再人工改写措辞,因为模型生成的问题往往照抄原文的词语,会让检索的评测结果偏高,尤其容易让按词匹配的 BM25 受益,向量检索和混合检索也会受影响。除了常见问题,还要包含资料中没有答案的问题、需要多个切片的问题和新旧版本冲突的问题。没有答案的问题,标准答案标为“资料中没有”,回答不知道算对,编出答案算错;它和有答案的问题被误拒要一起统计,两者的取舍与[相似度阈值](#4-相似度阈值)相同。
每个问题标注标准答案和回答所需的原文片段,而不是切片 ID,这样改变切片方式之后标注仍然可用,切片一节的实验就是这样判断是否找到的。几十到几百个问题就可以比较不同的方案(差距只有几道题时要结合逐题的结果判断,比较方法见 Agent 的[评测集与线上指标](#4-评测集与线上指标)),每次调整解析、切片、模型或参数,以及知识库中新增大量文档之后,都重新评测;线上发现的错误回答持续补充进来,避免评测集与真实的问题分布脱节。
@@ -1308,6 +1320,51 @@ GraphRAG 在建索引时让模型从每个切片中抽取实体和关系,组
检索到图片之后,生成用的是多模态模型时,可以把图片直接放进上下文让它读图回答;只能用文本模型时,就只能使用图片的文字描述,模型并没有看到图片本身。
### Text2SQL
“10 月的销售额是多少”这类问题要对表中的数据做筛选和汇总,答案不在任何一段文字里,把表的内容切片检索也算不出来。自然语言转 SQL(Text-to-SQL,常写作 Text2SQL)指的是让模型把问题写成 SQL,由程序执行,再把结果交给模型组织成回答,或者直接展示成表格。
主要的难点有两个:
- 表结构对应:问题中的“品类”“支付用户”对应哪张表的哪一列、表之间怎样关联,表很多时还要先选出相关的表,只把它们的结构放进提示词;
- 业务口径:“销售额”算不算已退款的订单、“客单价”除以订单数还是用户数,表结构里看不出来。
BIRD 基准中,GPT-4 只看表结构时执行准确率为 34.88%,再给出口径和取值含义这类外部知识后为 54.89%(arXiv 2305.03111)。
例如订单表 orders 有金额 amount 和状态 status,明细表 order_items 有品类 category、单价和数量,字段都带注释,用 6 个 2026 年 10 月的统计问题测试 Qwen2.5-7B-Instruct(见[附录 A34](#a34-text2sql-的口径与语义层))。
只给表结构时答对 1 个:3 个问题的口径错了,例如销售额把已退款、已取消和待支付的订单也加了进去,得到 1290 而不是 620;另外 2 个用了表中不存在的列,执行报错。在提示词中写明 6 个指标的口径后,6 个都答对。
口径写在提示词里,换一个应用、一张报表就可能出现另一种算法。语义层(Semantic Layer)把指标的计算方法和可以分组的维度统一定义好,模型只输出要查哪个指标、按什么分组、查哪个月,由代码或引擎生成 SQL,例如 dbt 的 MetricFlow、Cube。
上面的例子改成这种方式后,模型对“10 月每个品类的销售额”输出:
```json
{"metric": "销售额", "group_by": ["品类"], "month": "2026-10"}
```
代码按“销售额”的定义生成 SQL,按品类分组时用明细的单价乘数量,只算已支付的订单:
```sql
SELECT i.category, SUM(i.price * i.quantity)
FROM orders o JOIN order_items i ON i.order_id = o.id
WHERE o.status = 'PAID' AND o.created_at >= '2026-10-01' AND o.created_at < '2026-11-01'
GROUP BY i.category;
```
6 个问题也都答对。其中一次模型输出的 JSON 没有给“销售额”加引号,带着解析错误重试一次后改对,用[约束解码](#1-约束解码)可以避免这类问题。
表之间的关联也交给语义层。订单关联到明细后,订单上的金额随明细重复出现,例如订单 1001 有 3 条明细,直接对 orders.amount 求和时它的 300 元被算了 3 次,10 月的销售额变成 1220。MetricFlow 按事先声明的实体关系选择关联路径,并限制这种一对多的扇出(fan-out)关联。
语义层的代价是要先建模、持续维护,而且只能查已经定义的指标和维度。定义之外的问题要先补充定义,临时的查询也可以退回由模型或分析师直接写 SQL。
其它常用的做法:
- 表结构带上字段说明和取值的含义,例如 status 的每个取值代表什么;
- 把验证过的“问题—SQL”存下来,按问题的相似度检索几条放进提示词作为示例;
- SQL 执行出错时把错误信息回传给模型修改,结果为空或明显不合理时向用户确认口径;
- 用只能读取指定视图的账号执行,并限制执行时间和返回的行数,见[限制模型能造成的后果](#2-限制模型能造成的后果)。
## 四、Agent
### Agent 是什么 🔥🔥
@@ -1439,6 +1496,8 @@ String run(String question) {
什么时候结束由模型判断,但模型可能一直不停,或者反复调用同一个工具,所以代码中必须限制最大步数,见下文的可靠性与评测。
代码中的 tools 就是工具的注册表:以工具名为键,保存发给模型的定义和执行它的函数,请求时把其中的定义放进 tools 参数,模型返回调用后按名字找到函数执行,新增一个工具只要注册一项。
execute 不抛出异常,而是把错误写成文字作为工具结果返回,模型看到后可以修正参数、换一个工具或者向用户说明。下面是简化的写法,省略了参数校验和临时性错误的重试,实际应用中超时、限流这类错误先由代码重试,仍然失败或者需要模型处理的错误才作为工具结果返回,见[容错](#1-容错):
```java
@@ -1528,7 +1587,7 @@ for (CompletableFuture<Message> f : results) {
阈值要在验证集上扫一遍再定:阈值越高,自动路由的比例越低,准确率一般越高。例如上面 Embedding 相似度方法的置信度阈值从 0.5 提到 0.7,自动路由从 70% 降到 48%,准确率从 90.5% 升到 96.6%,其余转入兜底流程。按兜底流程能承接的量和可以接受的错误率选一个点,分错代价高的类别(例如会进入退款流程的)可以单独设更高的阈值。
阈值的效果还取决于置信度是否可靠:7B 模型分错的 3 条中有 2 条的置信度高于 0.93,阈值几乎拦不住。大模型的输出概率往往过于自信,可以改用多次采样看结论是否一致,或者在类别中加入“不确定”,让模型自己选择。
阈值的效果还取决于置信度是否可靠:7B 模型分错的 3 条中有 2 条的置信度高于 0.93,阈值几乎拦不住。大模型的输出概率往往过于自信,部分模型在 RLHF 等后训练之后校准明显变差,可以在验证集上做温度缩放,把过于集中的概率摊平,或者多次采样看结论是否一致;让模型自己报置信度,或者在类别中加一个“不确定”让模型自己选,效果都难以保证,要在验证集上检验。
路由的准确率要靠评测集来衡量和守住:
@@ -1538,7 +1597,7 @@ for (CompletableFuture<Message> f : results) {
- 线上转人工、用户改口重说的会话说明路由可能错了,定期抽查后加入评测集;
- 修改类别定义、提示词或者模型之后,在同一个评测集上回归,确认没有让其它类别变差。
### 规划 🔥
### 规划
规划要解决的是一个任务分几步、按什么顺序做、做错了怎么办。常见的几种方式的区别在于:推理是否与行动交替,是否先给出完整的计划,是否同时探索多条思路,失败后是否总结经验再试。
@@ -1720,7 +1779,7 @@ Agent 运行到一半时进程可能重启,用户也可能隔天才回来。
进程重启后,按会话 ID 读出消息列表和任务状态,根据最后一条消息决定从哪里继续:
- 最后一条是用户消息,或者上一轮的工具调用都有了结果:重新调用一次模型即可;
- 有工具调用还没有对应的结果(一次回复中有多个调用时,可能只保存了其中一部分的结果):说明工具执行到一半中断了。查询类的工具直接重新执行;退款、改地址这类修改数据的工具,以工具调用的 ID 作为幂等键,先查询这次操作是否已经生效,再决定是否执行,避免重复退款。
- 有工具调用还没有对应的结果(一次回复中有多个调用时,可能只保存了其中一部分的结果):说明工具执行到一半中断了。查询类的工具直接重新执行;退款、改地址这类修改数据的工具,以工具调用的 ID 作为幂等键(这条带工具调用的回复已经保存,ID 不会变),先查询这次操作是否已经生效,再决定是否执行,避免重复退款。
用户隔天回来时,会话的短期记忆可能已经过期,前一天的完整对话也不应该再放进上下文。做法是会话结束时生成一份摘要,连同未完成的待办一起保存;新会话开始时放入长期记忆和上次会话的摘要。例如用户前一天说“税号我待会儿发你”,第二天只发来一串税号,Agent 根据上次的待办就知道这是订单 1024 的发票,不需要用户重新解释。
@@ -1873,7 +1932,7 @@ MCP 也有代价。全量加载时,每个服务端的工具定义都会进入
Anthropic 还介绍过一种做法:把 MCP 工具生成为代码文件,Agent 按需读取工具的定义、编写代码调用,中间结果留在执行环境中,不经过模型,例子中的 Token 消耗从 15 万降到 2000。
另外,第三方服务端的工具描述和返回结果可能包含恶意指令,应该像安装软件一样只使用可信的服务端。MCP 规范仍在快速演进,本节对照 2026-07-28 版,细节以官方最新版本为准。
另外,第三方服务端的工具描述、服务端说明(instructions,客户端可能把它放进系统提示词)和返回结果都可能包含恶意指令,应该像安装软件一样只使用可信的服务端。MCP 规范仍在快速演进,本节对照 2026-07-28 版,细节以官方最新版本为准。
### Skills 🔥
@@ -2201,6 +2260,8 @@ checkpoint 什么时候写入由 durability 参数决定:默认的 async 在
Spring AI 是 Spring 生态中的 AI 集成层,作用类似 Spring Data 之于数据库:用统一的 ChatClient 访问不同厂商的模型,用 @Tool 把 Java 方法注册为工具,用 Advisor(类似拦截器)加入对话记忆、RAG 等功能,用 entity() 把模型输出转换为 Java 对象,同时提供向量库的统一接口和 MCP 的客户端、服务端支持。
本节按 Spring AI 2.0 编写,它要求 Spring Boot 4;Spring Boot 3 的项目使用 Spring AI 1.1。两者在工具调用和记忆上有差别:1.1 中工具调用的循环由 ChatModel 内部执行,没有默认的调用次数上限,记忆 Advisor 的默认顺序也不同,以官方升级说明为准。
```java
class OrderTools {
@Tool(description = "按订单号查询订单状态和运单号。用户询问订单、发货情况时使用")
@@ -2297,11 +2358,15 @@ tool 退款申请已提交,单号 R88
有副作用的操作要额外处理:
- 幂等:修改数据的工具超时,操作可能已经执行,只是响应没有返回;模型重复调用、从 checkpoint 恢复时节点重新执行,也会让同一个操作执行两次。所以这类工具要用幂等键去重,重试时沿用同一个键,例如同一个订单只能有一条进行中的退款,见[幂等](分布式.md#九幂等)。幂等键由程序在调用前生成并随任务保存,不让模型生成;参数变了就是新的操作,换新的键并重新确认。框架的自动重试(例如 LangGraph 节点的 RetryPolicy)也是重跑整个节点,所以自动重试的节点应当没有副作用,或者副作用是幂等的;
- 幂等:修改数据的工具用幂等键去重,重试时沿用同一个键,例如同一个订单只能有一条进行中的退款,见[幂等](分布式.md#九幂等);
- 人工确认:转账、删除数据、对外发送信息等操作由代码校验权限,执行前暂停,把操作内容展示给人确认,实现见 [checkpoint 与人工介入](#2-checkpoint-与人工介入),待确认的操作和参数保存在服务端,确认请求只带操作 ID 和决定,同一个操作只能被确认执行一次(例如原子地把状态从待确认改为执行中),避免重复提交或确认的内容被替换;
- 补偿:多步操作中后面的步骤失败、前面的需要撤销时,执行对应的补偿操作,例如取消已提交的退款申请,思路与 [Saga](分布式.md#saga) 相同。只补偿已确认成功的步骤,下游既不支持幂等、也查询不到结果的步骤标为结果未知,转人工或对账,不自动重试,也不自动补偿;
- 隔离:执行代码的工具放在沙箱中运行,并记录审计日志。
同一个操作可能执行两次:工具超时时操作可能已经执行,只是响应没有返回;模型会重复调用,从 checkpoint 恢复时节点会重新执行,框架的自动重试(例如 LangGraph 节点的 RetryPolicy)也是重跑整个节点。所以自动重试的节点应当没有副作用,或者副作用是幂等的。
幂等键由程序生成,不让模型生成,要标识一次业务操作,例如退款申请号;参数变了就是新的操作,换新的键并重新确认。键要在执行工具之前写入存储,否则工具执行之后、checkpoint 保存之前进程崩溃,恢复时会生成新的键,去重就失效了,LangGraph 默认的 async 写入就有这个窗口,见 [checkpoint 与人工介入](#2-checkpoint-与人工介入)。
沙箱一般要限制可写的路径(工作目录和必要的临时目录等)、网络(默认拒绝或按域名白名单放行)、CPU 和内存,并由调用方计时、超时后终止。密钥不放进沙箱,可以由沙箱外的代理在转发请求时加上,沙箱里的代码即使被诱导也读不到密钥。
常见的隔离机制有三种:共享宿主机内核的容器,配合 seccomp 限制可用的系统调用;gVisor 在用户态实现 Linux 的内核接口,容器中的程序不直接调用宿主机内核;Firecracker 这类轻量虚拟机有独立的内核,借助硬件虚拟化隔离。隔离效果取决于配置,开销取决于实现和工作负载,按要防范的风险选择。
@@ -2361,7 +2426,7 @@ checks:
- 给出参考:把工具的返回结果或者标准答案交给裁判,要求以它为准;
- 把评分标准拆成具体的、可以逐项判断的问题,例如“是否给出了运单号”“位置是否与物流接口返回的一致”,而不是笼统地打 1~10 分;
- 裁判使用与被评测的模型不同系列的模型,并允许输出“无法判断”;
- 用一小批人工标注的样本检查裁判与人的一致率,一致率足够高之后再大规模使用,之后定期抽查。
- 用一小批人工标注的样本检查裁判与人的一致率,一致率足够高之后再大规模使用,之后定期抽查,失败和正常的样本要分别统计,因为线上的会话大多是正常的,总是判通过的裁判一致率也会很高。
#### 4. 评测集与线上指标
@@ -2380,7 +2445,7 @@ checks:
选模型时可以先参考公开的基准(例如测多学科选择题的 MMLU、测修复真实 GitHub issue 的 SWE-bench),但还要用自己的业务评测集验证:题目可能已经进了训练数据,按 GSM8K 新出一套同类的题时,部分模型的准确率下降了多达 8 个百分点(GSM1k);题目本身也可能有错,估计 MMLU 约有 6.49% 的题有错(MMLU-Redux)。
选型时先按硬条件筛选:数据能否交给外部服务、许可是否允许商用,上下文和输出长度是否够用,是否支持需要的工具调用和结构化输出;再在业务评测集上比较剩下几个模型的效果、延迟和每次任务的费用。
选型时先按硬条件筛选:数据能否交给外部服务、许可是否允许商用(同一系列不同尺寸的许可也可能不同,例如 Qwen2.5 的 7B 用 Apache 2.0,3B 只允许研究和评估,以模型发布页的许可为准),上下文和输出长度是否够用,是否支持需要的工具调用和结构化输出;再在业务评测集上比较剩下几个模型的效果、延迟和每次任务的费用。
服务商的模型名还可能改指新的模型,旧模型也会下线,例如 DeepSeek 的 deepseek-chat 先后对应过 11 个底层模型,阿里云百炼的正式版模型在启动下线后 3 个月停止服务。服务商提供固定版本的模型名(例如带日期的快照)时,生产环境尽量用它,换模型前在同一批用例上逐条对比(以各服务商的最新文档为准)。
@@ -2416,7 +2481,7 @@ data: [DONE]
```
- 每条事件由若干行“字段: 值”组成,以空行结束,data 是事件的内容(一条事件有多行 data 时按换行拼接),id 是事件的编号,event 是事件的类型(省略时为 message);
- 以冒号开头的行是注释,客户端会忽略,常用作心跳,防止中间的代理因为连接长时间空闲而断开;
- 以冒号开头的行是注释,客户端会忽略,常用作心跳,防止中间的代理因为连接长时间空闲而断开,间隔要小于代理的读超时,例如 Nginx 的 proxy_read_timeout 默认 60 秒,计的是两次读之间的间隔;
- 浏览器的 EventSource 断线后会自动重连,并在请求首部 Last-Event-ID 中带上最后收到的事件编号,服务端可以据此从断点继续发送。EventSource 只能发 GET 请求、不能带请求体,对话接口用 POST 发送问题时,前端通常用 fetch 读取流,这时重连和 Last-Event-ID 都要自己实现;
- OpenAI 兼容的接口用 data: [DONE] 表示生成结束。
@@ -2560,7 +2625,8 @@ Token 消耗增长过快时,先按步骤和工具汇总每一次调用的输
推理优化的手段按解决的问题分为几类:
- 每一步都要读一遍权重和 KV Cache:用[量化](#量化)、[GQA、MLA](#自回归生成与-kv-cache) 缩小要读的数据,用 [FlashAttention](#2-计算量与-flashattention) 减少注意力计算中的显存读写;
- 每一步都要读一遍权重和 KV Cache:用[量化](#量化)、[GQA、MLA](#自回归生成与-kv-cache) 缩小要读的数据;
- 注意力的中间矩阵要读写显存:用 [FlashAttention](#2-计算量与-flashattention) 分块计算,不把中间矩阵写回显存,收益主要在预填充和长上下文,解码时读写的大部分仍是 KV Cache;
- 显卡同时处理的请求太少:批处理、连续批处理、PagedAttention;
- 重复计算相同的开头:[前缀缓存](#5-前缀缓存);
- 只能逐个 Token 解码:投机解码(Speculative Decoding),小模型先连续猜几个 Token,大模型读一遍权重就能同时验证:设大、小模型给某个猜测的概率分别为 p、q,它以 min(1, p / q) 的概率被接受;第一个被拒绝的位置改从 max(0, p − q) 归一化后的分布重新采样,丢弃后面的猜测。这样输出的分布与大模型单独生成相同,适合并发不高的场景。
@@ -2614,7 +2680,7 @@ vLLM 的调度器维护运行和等待两个队列。每一步处理的 Token
128 56 GB 70.2 GB
```
vLLM 默认使用 92% 的显存(--gpu-memory-utilization)。在 24 GB 的显卡上,扣除权重后剩不到 7.9 GB,再扣除激活值等运行时开销,同时只能处理十几个满 8K 的请求;80 GB 的显卡上可以处理 100 多个。KV Cache 不够时,vLLM 抢占最晚加入的请求:释放它的 KV Cache,放回等待队列的最前面,之后重新做预填充,延迟明显增加,监控中的抢占次数(vllm:num_preemptions)持续增长。想用同样的显卡服务更多请求,可以减小最大上下文长度、使用更多显卡,或者对权重和 KV Cache 进行[量化](#量化)。
vLLM 默认使用 92% 的显存(--gpu-memory-utilization)。在 24 GB 的显卡上,扣除权重后剩不到 7.9 GB,再扣除激活值等运行时开销,同时只能处理十几个满 8K 的请求;80 GB 的显卡上可以处理 100 多个。KV Cache 不够时,vLLM 抢占最晚加入的请求:释放它的 KV Cache,放回等待队列的最前面,之后重新做预填充,延迟明显增加,监控中的抢占次数(Prometheus 中的 vllm:num_preemptions_total)持续增长。想用同样的显卡服务更多请求,可以减小最大上下文长度、使用更多显卡,或者对权重和 KV Cache 进行[量化](#量化)。
模型一张卡放不下时用张量并行,把每层的权重矩阵切到多张卡上,每层的注意力和前馈网络算完后各要在卡间做一次 all-reduce 汇总结果,所以一般只在 NVLink 高速互联的一台机器内使用,跨机器时再按层切分(流水线并行)。
@@ -2708,6 +2774,8 @@ OpenAI 的官方 SDK 默认也是这样处理的:对 408、409、429 和 5xx
熔断用于避免每个请求都先等主模型超时再切换。主模型在一段时间内的失败率超过阈值时,熔断器打开,之后的请求直接走备用模型,过一段时间再放行少量请求试探主模型是否恢复。限流、降级和熔断的原理见[系统设计基础](系统设计基础.md#限流降级与熔断)。
统计失败率时要区分错误的类型:短暂的速率限制(429)按 retry-after 等待后重试,一般不计入模型失败,否则突发的限流就可能让熔断器打开,持续限流、重试耗尽时再决定是否切换;流式响应可能在返回 200 之后才在流中报错,要按流中的错误事件判断;判断慢调用要看首 Token 的时间和之后每个 Token 的间隔,回答长本来就慢,按总耗时会误判。
#### 4. 流式输出中途断开
处理方式取决于断开时已经输出了什么:
@@ -3925,6 +3993,70 @@ OK SELECT /*+ MAX_EXECUTION_TIME(100) */ SLEEP(1) FROM v_orders LIMIT 1 -> ((
前三条和最后三条由 bi 执行:bi 读不到原表的手机号,也不能删除;它可以把自己的会话设为只读,再改回读写。中间两条由 root 执行,说明以 WITH 开头的语句也可以删除数据,只检查语句开头的关键字拦不住。最后一条的 SLEEP 返回 1,表示执行到 100 毫秒时被中断。
### A34 Text2SQL 的口径与语义层
Qwen2.5-7B-Instruct,float16 加载到 MPS,贪心解码(Transformers 5.20.2、PyTorch 2.8.0);SQL 在 MySQL 8.0.44 上执行,Apple M1 Pro。表结构和数据:
```sql
CREATE TABLE orders (
id INT PRIMARY KEY,
user_id INT COMMENT '用户 ID',
amount DECIMAL(10,2) COMMENT '订单实付金额',
status VARCHAR(16) COMMENT '订单状态:UNPAID 待支付,PAID 已支付,REFUNDED 已退款,CANCELLED 已取消',
created_at DATETIME COMMENT '下单时间'
);
CREATE TABLE order_items (
id INT PRIMARY KEY,
order_id INT COMMENT '订单 ID',
category VARCHAR(16) COMMENT '商品品类',
price DECIMAL(10,2) COMMENT '单价',
quantity INT COMMENT '数量'
);
-- orders:1001 用户 1 300 元 PAID 10-03、1002 用户 2 120 元 PAID 10-08、1003 用户 1 200 元 PAID 10-12、
-- 1004 用户 3 80 元 UNPAID 10-20、1005 用户 4 500 元 REFUNDED 10-25、1006 用户 2 90 元 CANCELLED 10-28、
-- 1007 用户 5 260 元 PAID 09-30、1008 用户 3 150 元 PAID 11-01
-- order_items:1001 有服装 100 × 1、服装 50 × 2、食品 100 × 1;1002 食品 60 × 2;1003 数码 200 × 1;
-- 1004 食品 40 × 2;1005 数码 500 × 1;1006 服装 90 × 1;1007 服装 130 × 2;1008 食品 150 × 1
```
三种做法的系统提示词:A 为“根据下面的表结构,把用户的问题写成一条 MySQL 查询,只输出 SQL”加上面的建表语句;B 在 A 之后加上指标口径;C 只列出可用的指标和维度,要求输出 {"metric", "group_by", "month"} 格式的 JSON,由代码按指标的定义生成 SQL,JSON 解析失败时带着错误重试一次。B 的指标口径:
```text
- 销售额:已支付订单(status = 'PAID')的 amount 之和,已退款、已取消、待支付的订单不计入;
- 按品类统计销售额时,用 order_items 的 price × quantity 求和(同样只算已支付订单),不要把 orders.amount 关联到明细后求和;
- 支付用户数:有已支付订单的不同用户数;
- 客单价:销售额 ÷ 支付用户数;
- 退款率:已退款订单数 ÷(已支付订单数 + 已退款订单数);
- 销量:已支付订单中 order_items.quantity 之和。
```
执行结果与标准答案比较(比率写成百分数也算对),A 的输出(节选):
```text
标准答案:销售额 620,品类销售额 数码 200、服装 200、食品 220,支付用户 2,客单价 310,退款率 0.25,销量 7
[错] 销售额 SELECT SUM(o.amount) FROM orders o WHERE YEAR(o.created_at) = 2026 AND MONTH(o.created_at) = 10 -> 1290
[错] 品类销售额 SELECT o.category, SUM(oi.price * oi.quantity) FROM order_items oi JOIN orders o ... GROUP BY o.category -> ERR 1054
[对] 支付用户数 SELECT COUNT(DISTINCT user_id) FROM orders WHERE status = 'PAID' AND ... -> 2
[错] 客单价 SELECT AVG(amount) FROM orders WHERE YEAR(created_at) = 2026 AND MONTH(created_at) = 10 -> 215
[错] 退款率 SELECT COUNT(*) / SUM(CASE WHEN status = 'UNPAID' THEN 1 ELSE 0 END) FROM orders WHERE ... AND status = 'REFUNDED' -> NULL
[错] 销量 SELECT COUNT(*) FROM order_items WHERE YEAR(created_at) = 2026 AND MONTH(created_at) = 10 -> ERR 1054
A 只给表结构:1/6
B 表结构加指标口径:6/6
```
C 的输出(节选):
```text
[对] 销售额 第 1 次输出 {"metric": 销售额, "group_by": [], "month": "2026-10"},解析失败:Expecting value: line 1 column 12 (char 11)
重试后 {"metric": "销售额", "group_by": [], "month": "2026-10"} -> 620
[对] 品类销售额 {"metric": "销售额", "group_by": ["品类"], "month": "2026-10"} -> 数码 200、服装 200、食品 220
[对] 客单价 {"metric": "客单价", "group_by": [], "month": "2026-10"}
SELECT SUM(o.amount) / COUNT(DISTINCT o.user_id) FROM orders o WHERE o.status = 'PAID' AND ... -> 310
C 语义层:6/6
```
把 orders.amount 关联到明细后求和,10 月已支付订单的结果为 1220,按品类为服装 600、食品 420、数码 200。
## 参考资料
- [Attention Is All You Need](https://arxiv.org/abs/1706.03762)
@@ -3955,6 +4087,7 @@ OK SELECT /*+ MAX_EXECUTION_TIME(100) */ SLEEP(1) FROM v_orders LIMIT 1 -> ((
- [Chain-of-Thought Prompting Elicits Reasoning in Large Language Models](https://arxiv.org/abs/2201.11903)
- [Zhao T Z, et al. Calibrate Before Use: Improving Few-Shot Performance of Language Models. arXiv:2102.09690](https://arxiv.org/abs/2102.09690)
- [OpenAI: Reasoning best practices](https://developers.openai.com/api/docs/guides/reasoning-best-practices)
- [Claude Docs: Refusals and fallback](https://platform.claude.com/docs/en/build-with-claude/refusals-and-fallback)
- [Efficient Guided Generation for Large Language Models](https://arxiv.org/abs/2307.09702)
- [Tam Z R, et al. Let Me Speak Freely? A Study on the Impact of Format Restrictions on Performance of Large Language Models. arXiv:2408.02442](https://arxiv.org/abs/2408.02442)
- [Farquhar S, et al. Detecting hallucinations in large language models using semantic entropy. Nature, 2024](https://www.nature.com/articles/s41586-024-07421-0)
@@ -3975,6 +4108,7 @@ OK SELECT /*+ MAX_EXECUTION_TIME(100) */ SLEEP(1) FROM v_orders LIMIT 1 -> ((
- [Sentence-BERT: Sentence Embeddings using Siamese BERT-Networks](https://arxiv.org/abs/1908.10084)
- [Qu Y, et al. RocketQA: An Optimized Training Approach to Dense Passage Retrieval for Open-Domain Question Answering. arXiv:2010.08191](https://arxiv.org/abs/2010.08191)
- [Moreira G, et al. NV-Retriever: Improving text embedding models with effective hard-negative mining. arXiv:2407.15831](https://arxiv.org/abs/2407.15831)
- [Günther M, et al. Late Chunking: Contextual Chunk Embeddings Using Long-Context Embedding Models. arXiv:2409.04701](https://arxiv.org/abs/2409.04701)
- [Precise Zero-Shot Dense Retrieval without Relevance Labels](https://arxiv.org/abs/2212.10496)
- [Weller O, et al. When do Generative Query and Document Expansions Fail? arXiv:2309.08541](https://arxiv.org/abs/2309.08541)
- [RAGAS: Automated Evaluation of Retrieval Augmented Generation](https://arxiv.org/abs/2309.15217)
@@ -4174,3 +4308,5 @@ OK SELECT /*+ MAX_EXECUTION_TIME(100) */ SLEEP(1) FROM v_orders LIMIT 1 -> ((
- [vLLM: Speculative Decoding](https://docs.vllm.ai/en/latest/features/speculative_decoding/)
- [vLLM 源码:v1/core/sched/scheduler.py](https://github.com/vllm-project/vllm/blob/main/vllm/v1/core/sched/scheduler.py)
- [Ollama FAQ](https://docs.ollama.com/faq)
- [Li J, et al. Can LLM Already Serve as A Database Interface? A BIg Bench for Large-Scale Database Grounded Text-to-SQLs. arXiv:2305.03111](https://arxiv.org/abs/2305.03111)
- [dbt: Joins(MetricFlow)](https://docs.getdbt.com/docs/build/join-logic)
+2 -2
View File
@@ -772,7 +772,7 @@ ThreadLocal 重点看它的数据结构、内存泄漏的原因,以及线程
<summary>参考要点</summary>
1. 白色是还没访问到的对象,标记结束时仍为白色的就是垃圾,灰色是已访问但引用的对象还没检查完,黑色是引用都已检查过;从 GC Roots 开始把对象置灰,不断取出灰色对象扫描它的引用,直到没有灰色对象;漏标:并发标记时,用户线程让已经是黑色的对象引用了白色对象,同时删除了从灰色对象到它的所有引用,这个白色对象不会再被访问,标记结束后被当成垃圾回收;CMS 用增量更新,写入引用时写屏障把卡标记为脏,重新标记时重新扫描;G1 用原始快照(SATB),删除或覆盖引用前写屏障记录旧值并当作存活。
2. 经过多次 Minor GC 后年龄达到 MaxTenuringThreshold;动态年龄判定(以 Serial 为例,G1 和 Parallel 的计算方式不同):Minor GC 结束时从小到大累加 Survivor 中各年龄对象的大小,超过 Survivor 一半时,取这个年龄和 MaxTenuringThreshold 中较小的值作为新阈值,下一次 Minor GC 时大于或等于它的对象晋升;Minor GC 时 Survivor 放不下的存活对象直接进入老年代;大对象直接进入老年代(Serial 的 PretenureSizeThreshold,G1 中超过 Region 一半的 Humongous 对象)。
2. 经过多次 Minor GC 后年龄达到 MaxTenuringThreshold;动态年龄判定(以 Serial 为例;G1 的累加方法相同、比较的 Survivor 容量不同,Parallel 按自适应策略调整):Minor GC 结束时从小到大累加 Survivor 中各年龄对象的大小,超过 Survivor 一半时,取这个年龄和 MaxTenuringThreshold 中较小的值作为新阈值,下一次 Minor GC 时大于或等于它的对象晋升;Minor GC 时 Survivor 放不下的存活对象直接进入老年代;大对象直接进入老年代(Serial 的 PretenureSizeThreshold,G1 中超过 Region 一半的 Humongous 对象)。
3. 调用 System.gc(),规范上只是建议,HotSpot 默认会执行一次 Full GC,-XX:+DisableExplicitGC 可以禁用;老年代空间不足,例如大对象、长期存活的对象不断进入,或者空间分配担保失败,G1 中是混合回收来不及释放、复制时找不到空闲 Region,或 Humongous 对象找不到连续的 Region;JDK 7 及以前永久代不足,JDK 8 起元空间达到阈值(日志原因为 Metadata GC Threshold)时 Serial 和 Parallel 进行 Full GC,G1 先扩大元空间并启动并发标记卸载类,扩大失败才 Full GC;CMS 发生 Concurrent Mode Failure。
</details>
@@ -1953,7 +1953,7 @@ RPC 一节对比了它和 HTTP 的取舍。雪花算法要注意时钟回拨;
动手:阅读[附录 A2](消息队列.md#a2-重复消费),说出消费者在哪一步被杀死导致了重复扣款,再按[接收端的可靠性](消息队列.md#接收端的可靠性)一节的方法设计去重。
预期:消费者完成 3 笔扣款后、自动提交偏移量(默认每 5 秒)之前被 kill -9,组的偏移量仍为空,约 45 秒后重启的消费者又收到这 3 条消息、重复扣款,去重方法是在同一个本地事务中先插入去重表(键如 order:1024:pay)再扣款,重复消息报 1062 后回滚并直接确认。
预期:消费者完成 3 笔扣款后、自动提交偏移量之前被 kill -9,组还没有提交过偏移量;auto.offset.reset 为 earliest 时,重启的消费者又收到这 3 条消息、重复扣款,默认的 latest 则从末尾开始,这 3 条不会再被自动读到;去重方法是在同一个本地事务中先插入去重表(键如 order:1024:pay)再扣款,重复消息报 1062 后回滚并直接确认。
算法(动态规划,中等):[322. 零钱兑换](https://leetcode.cn/problems/coin-change/),题解见[找零钱的最少硬币数](Leetcode%20题解%20-%20动态规划.md#4-找零钱的最少硬币数)。
+14 -1
View File
@@ -39,6 +39,19 @@
每天先读完阅读范围,再不看笔记回答自测题,答完展开参考要点核对;动手练习对照预期的结果。自测答得上来、动手结果与预期一致、算法题提交通过,这一天就完成了。选读可以跳过。
AI 应用岗的面试也常问后端和计算机基础,最常见的是 MySQL 索引、分布式锁、WebSocket 与服务器推送事件(Server-Sent Events, SSE)、限流和熔断。没有后端基础的读者,还要学 Java 后端路线中的这些天:
- MySQL 的索引与查询优化:[第 25 天](学习路线%20-%20Java%20后端.md#第-25-天b-树与索引的类型)、[第 26 天](学习路线%20-%20Java%20后端.md#第-26-天索引的使用)、[第 27 天](学习路线%20-%20Java%20后端.md#第-27-天查询性能优化);
- 缓存的三种问题与一致性:[第 34 天](学习路线%20-%20Java%20后端.md#第-34-天缓存的位置与三种问题)、[第 35 天](学习路线%20-%20Java%20后端.md#第-35-天缓存一致性与一致性哈希);
- TCP 的连接管理、WebSocket 与 SSE:[第 36 天](学习路线%20-%20Java%20后端.md#第-36-天tcp-的连接管理)、[第 40 天](学习路线%20-%20Java%20后端.md#第-40-天缓存websocket-与跨域);
- 进程与线程:[第 44 天](学习路线%20-%20Java%20后端.md#第-44-天进程与线程);
- 设计模式:[第 52 天](学习路线%20-%20Java%20后端.md#第-52-天设计模式);
- 分布式锁、幂等与消息队列的可靠性:[第 54 天](学习路线%20-%20Java%20后端.md#第-54-天分布式锁)、[第 56 天](学习路线%20-%20Java%20后端.md#第-56-天rpc分布式-id-与幂等)、[第 57 天](学习路线%20-%20Java%20后端.md#第-57-天消息队列的作用与可靠性);
- 限流、熔断与秒杀:[第 60 天](学习路线%20-%20Java%20后端.md#第-60-天性能与可用性)、[第 61 天](学习路线%20-%20Java%20后端.md#第-61-天秒杀与抢红包);
- 用 Java 的读者再加 HashMap 和线程池:[第 7 天](学习路线%20-%20Java%20后端.md#第-7-天hashmap)、[第 9 天](学习路线%20-%20Java%20后端.md#第-9-天线程与线程池)。
投递大模型算法、Agent 算法岗位时,训练相关的题会问得更深,例如损失函数怎样计算、手写多头注意力,要再读[进阶篇](大模型应用%20-%20进阶.md),并把其中的代码自己写一遍。
## 二、开始之前
本路线不讲语法。动手部分大多用 Python,开始前先确认能独立写出下面 5 个程序:
@@ -479,7 +492,7 @@ Embedding 把文本变成向量,相似度检索靠它完成。向量索引回
1. 检索看 Recall@K(相关切片有多少在前 K 个中)、Precision@K(前 K 个中相关的比例)、MRR(第一个相关结果排名倒数的平均)和 NDCG(分级相关、按排名打折),K 增大时召回率不降、精确率往往下降,放进提示词的切片还要看精确率和端到端正确率;生成看忠实度(结论能否在资料中找到依据)和答案相关性(是否回应了问题),通常由模型裁判拆成小步骤打分,两者要一起看。
2. 按数据流动的顺序从前往后查:解析、切片、检索、重排、生成,前面的错误后面无法弥补;每步分别看解析结果与原文是否一致、答案是否完整在一个切片中、相关切片在两路检索和重排前后的排名、直接给正确资料能否答对;也可以先做最后一步,区分问题在检索还是生成。
3. 优先从真实用户问题中抽样,不够时用模型生成再人工改写措辞,避免照抄原文高估向量检索;包含没有答案、需要多个切片和新旧版本冲突的问题,没有答案的问题回答不知道算对、编出答案算错;标注标准答案和所需的原文片段而不是切片 ID,线上的错误回答持续补充进来。
3. 优先从真实用户问题中抽样,不够时用模型生成再人工改写措辞,避免照抄原文的词语让检索的评测结果偏高;包含没有答案、需要多个切片和新旧版本冲突的问题,没有答案的问题回答不知道算对、编出答案算错;标注标准答案和所需的原文片段而不是切片 ID,线上的错误回答持续补充进来。
</details>
+1 -1
View File
@@ -29,7 +29,7 @@
适合两三周内就要面试、已经学过这些内容的读者,每天 1.5~2 小时,覆盖约 40 个核心考点,最后 1 天是大模型应用。应聘大模型应用岗位的读者,用[大模型应用开发](学习路线%20-%20大模型应用开发.md)每天的自测题复习。
每天先不看笔记,把 3 个自测题各口述 1~2 分钟,再展开参考要点核对,答得不完整的读链接的小节;然后做两道算法题,每题限时 30 分钟。
每天先不看笔记,把 3 个自测题各口述 1~2 分钟,再展开参考要点核对,答得不完整的读链接的小节;然后做两道算法题,每题限时 30 分钟,按面试的顺序作答:先用一两句说出暴力解和它的复杂度,再说瓶颈在哪、怎样优化,最后写出优化后的代码。
应聘前端、C++、客户端或测试开发岗位时,可以跳过第 1~4 天和第 12 天的 Spring 部分(Android 和以 Java 为主的测试开发保留第 1、4 天),把时间用在这些岗位更常问的小节:
+6 -6
View File
@@ -10,12 +10,12 @@
* [脏读](#脏读)
* [不可重复读](#不可重复读)
* [幻读](#幻读)
* [三、封锁](#三封锁) 🔥🔥
* [三、封锁](#三封锁)
* [封锁粒度](#封锁粒度)
* [封锁类型](#封锁类型)
* [封锁协议](#封锁协议)
* [MySQL 隐式与显式锁定](#mysql-隐式与显式锁定)
* [MySQL 中的锁](#mysql-中的锁)
* [MySQL 中的锁](#mysql-中的锁) 🔥
* [四、隔离级别](#四隔离级别) 🔥🔥
* [未提交读(READ UNCOMMITTED)](#未提交读read-uncommitted)
* [提交读(READ COMMITTED)](#提交读read-committed)
@@ -34,7 +34,7 @@
* [七、关系数据库设计理论](#七关系数据库设计理论)
* [函数依赖](#函数依赖)
* [异常](#异常)
* [范式](#范式) 🔥
* [范式](#范式)
* [八、ER 图](#八er-图)
* [实体的三种联系](#实体的三种联系)
* [表示出现多次的关系](#表示出现多次的关系)
@@ -145,7 +145,7 @@ InnoDB 在可串行化以下的隔离级别都不会自动避免这种先读后
产生并发不一致性问题的主要原因是破坏了事务的隔离性,解决方法是通过并发控制来保证隔离性。并发控制可以通过封锁来实现,但是封锁操作需要用户自己控制,相当复杂。数据库管理系统提供了事务的隔离级别,让用户以一种更轻松的方式处理并发一致性问题。
## 三、封锁 🔥🔥
## 三、封锁
### 封锁粒度
@@ -262,7 +262,7 @@ SELECT ... FOR SHARE; -- MySQL 8.0 之前写作 LOCK IN SHARE MODE
SELECT ... FOR UPDATE;
```
### MySQL 中的锁
### MySQL 中的锁 🔥
按粒度从大到小,MySQL 中的锁主要有以下几种:
@@ -529,7 +529,7 @@ SELECT c FROM t WHERE c BETWEEN 10 and 20 FOR UPDATE;
- 删除异常:删除一个信息,那么也会丢失其它信息,例如删除了 `课程-1` 需要删除第一行和第三行,那么 `学生-1` 的信息就会丢失;
- 插入异常:例如想要插入一个学生的信息,如果这个学生还没选课,那么就无法插入。
### 范式 🔥
### 范式
范式理论是为了解决以上提到的四种异常。
+24 -5
View File
@@ -352,7 +352,7 @@ acks=all 要配合 min.insync.replicas(默认为 1)使用,否则 ISR 只
生产者没有收到确认就会重试,这可能让同一条消息写入两次。Kafka 3.0 起默认开启幂等生产者(3.0.0、3.1.0 因缺陷未生效):broker 给每个生产者分配一个 ID,生产者给发往每个分区的消息编上序号,broker 发现序号重复就丢弃。后文查看 .log 文件时,每个批次都带有 producerId: 1001 和 baseSequence 字段。幂等只保证单个分区内、生产者自身重试时不重复,应用自己重发的消息不能去重;要让写入多个分区的消息同时成功或失败,需要使用事务,消费者设置 isolation.level=read_committed 后只读取已提交事务中的消息。
消费者处理完消息后提交偏移量,下次从提交的位置继续读取。默认每 5 秒自动提交一次,已经处理但还没提交时崩溃,重启后就会重复消费。例如消费者读到 3 条扣款消息后被 kill -9,这时组的偏移量仍然为空;它在 session.timeout.ms(默认 45 秒)之后被移出消费者组,重新启动的消费者又收到了这 3 条消息(见[附录 A2](#a2-重复消费))。因此接收端必须做到幂等,方法见 [接收端的可靠性](#接收端的可靠性)。反过来,如果先提交偏移量再处理,处理失败的消息就丢失了。
消费者处理完消息后提交偏移量,下次从提交的位置继续读取。默认每 5 秒自动提交一次,已经处理但还没提交时崩溃,重启后可能重复消费。例如消费者读到 3 条扣款消息后被 kill -9,这时组还没有提交过偏移量;它在 session.timeout.ms(默认 45 秒)之后被移出消费者组,重新启动的消费者从哪里开始由 auto.offset.reset 决定:设为 earliest 时从最早的消息开始,又收到了这 3 条消息;默认的 latest 从末尾开始,这 3 条不会再被自动读到(Kafka 4.4 实测,见[附录 A2](#a2-重复消费))。新的消费者组第一次订阅,或者已提交的偏移量对应的消息已被删除时,也按这个配置决定。因此接收端必须做到幂等,方法见 [接收端的可靠性](#接收端的可靠性)。反过来,如果先提交偏移量再处理,处理失败的消息就丢失了。
消费者用线程池并行处理时也会遇到这个问题。提交偏移量 N 表示 N 之前的消息都已经处理完。例如一次取回了偏移量 100~109,线程已经处理完 105~109,100~104 还在处理中,这时提交 110,进程崩溃后 100~104 就不会再被消费。所以要关闭自动提交(它可能早于处理完成),只手动提交连续处理完的位置,这里是 100,代价是重启后 105~109 会被重复消费。
@@ -425,21 +425,40 @@ $ ...(同上) acks=all batch.size=0 linger.ms=0
### A2 重复消费
消费者组 billing 使用默认的自动提交,读到 3 条消息后立即 kill -9,45 秒后再启动一个同组的消费者:
Kafka 4.4.0,本地 3 个节点,JDK 21。每种 auto.offset.reset 各用一个新的主题(1 个分区)和新的消费者组:先启动消费者,等 8 秒让它完成分区分配、确定初始位置,再生产 3 条消息;消费者开启自动提交,为了保证被 kill -9 时还没有提交过偏移量,把提交间隔设为 60 秒,生产、消费和 kill -9 都在启动后约 10 秒内完成;50 秒后再启动一个同组的消费者。两组的第一个消费者都收到了 3 条消息,说明生产时它已经就绪:
```bash
kafka-console-consumer.sh --bootstrap-server $B --topic $T --group $G \
--command-property auto.offset.reset=latest --command-property auto.commit.interval.ms=60000
```
```text
== auto.offset.reset=latest
第一个消费者:
扣款 order:1024
扣款 order:1025
扣款 order:1026
(kill -9)
重新启动的消费者:
kill -9 后组的偏移量:
PARTITION CURRENT-OFFSET LOG-END-OFFSET LAG
0 - 3 -
50 秒后重新启动的消费者:
(没有收到消息)
== auto.offset.reset=earliest
第一个消费者:
扣款 order:1024
扣款 order:1025
扣款 order:1026
kill -9 后组的偏移量:
PARTITION CURRENT-OFFSET LOG-END-OFFSET LAG
0 - 3 -
50 秒后重新启动的消费者:
扣款 order:1024
扣款 order:1025
扣款 order:1026
Processed a total of 3 messages
```
CURRENT-OFFSET 为“-”表示组在这个分区上还没有提交过偏移量。
### A3 消费积压
主题 points-events 有 3 个分区、3 个副本。用 kafka-console-producer.sh 写入 3 万条带 key 的消息(key 为 997 个用户 ID),消费者组 points 用 kafka-console-consumer.sh --max-messages 3000 消费 3000 条后退出,再查看积压;之后同时启动 4 个同组的消费者,查看每个消费者分到的分区数:
+7 -3
View File
@@ -478,7 +478,9 @@ WHERE id = 70001 AND user_id = 1001 AND status = 0 AND valid_end > NOW();
抽中限量的奖品后,再用与秒杀相同的 Lua 脚本扣减这个奖品的库存。扣减失败说明奖品已经发完,这时应降级为“谢谢参与”或兜底奖品,而不是重新抽一次。这是因为重新抽相当于把一等奖的 1% 按比例分给其它奖品,二等奖的实际概率会从 9% 变为 9 ÷ 99 ≈ 9.09%,发出的奖品超出预算。
每人每天的抽奖次数可以在 Redis 中用 INCR 计数并按天设置过期时间,返回值超过上限就拒绝。“每人最多中一次大奖”这类限制也按降级处理,因此实际中奖率会低于配置值,上线前可以用大量模拟统计各奖品的频率来核对。
每人每天的抽奖次数可以在 Redis 中用 INCR 计数并按天设置过期时间,返回值超过上限就拒绝。“每人最多中一次大奖”这类限制也按降级处理,因此实际中奖率会低于配置值。
核对抽奖的代码时,可以把 0~9999 每个随机数都跑一遍,统计每个奖品落到的格数,应当正好等于权重;用随机模拟的话,一等奖差一格(0.01%)这样的边界错误,按 3 倍标准差估算约需 900 万次才能分辨出来。上线后再按天统计各奖品实际发放的数量,与配置比较。
#### 3. 抢红包
@@ -546,6 +548,8 @@ WHERE id = 8001 AND remain_num > 0 AND remain_amount >= 276;
应用在脚本执行成功之后、消息发出之前宕机时,这条领取只记录在 Redis 中。因此红包抢完或过期后,要用 Redis 中的领取记录与数据库对账,补发缺失的消息。例如每条消息都投递两次、其中一个用户的消息全部丢失:18 条消息插入 9 条、冲突 9 条,对账补发后剩余金额为 0、领取记录 10 条,合计正好 100 元(MySQL 8.0 实测,见[附录 A16](#a16-并发抢红包与异步落库))。
需要注意的是,这种对账只能补回应用宕机丢失的消息。Redis 主节点在复制之前宕机、从节点被提升时,已经返回成功的领取在 Redis 中也可能没有了,以 Redis 的领取记录为准的对账发现不了。执行脚本后在同一个连接上再执行 WAIT,等至少一个从节点确认后才返回成功,只能缩小这个窗口(见 [Sentinel](Redis.md#十二sentinel));资金类的场景也可以不在缓存中预扣,把同一个红包的请求排队后在数据库事务中扣减。
红包一般有有效期,例如 24 小时。到期后先在 Redis 中停止领取(例如删除金额列表,之后的脚本 LPOP 取不到金额,直接返回已抢完),再对账并补发缺失的消息,确认这些领取都已写入数据库后,把数据库中的剩余金额退回给发送者。顺序反过来时,对账之后仍可能有人抢到,退回的金额就多了。
## 七、海量数据处理
@@ -636,7 +640,7 @@ Base62 区分大小写,code 列要用区分大小写的排序规则。MySQL 8.
短链读多写少,热门链接的访问又很集中,适合用 Redis 缓存短码到长链接的映射。不存在的短码会穿透到数据库,可以缓存空值或者使用布隆过滤器,见 [缓存穿透](缓存.md#缓存穿透)。点击统计不要在跳转时同步写数据库,而是把点击事件写入消息队列,异步汇总。
设置了有效期的短链,跳转时先判断 expire_at,过期的不再跳转,返回失效提示;写缓存时让过期时间与 expire_at 对齐(例如 SET ... EXAT 时间戳),后台任务再按 expire_at 索引分批删除过期的记录。
设置了有效期的短链,跳转时先判断 expire_at,过期的不再跳转,返回失效提示;写缓存时让过期时间与 expire_at 对齐(例如 SET ... EXAT 时间戳),后台任务再按 expire_at 索引分批删除过期的记录。用哈希或随机方法生成、靠唯一约束判重的短码,删掉记录后就可能再分配给新的链接,旧短信、海报上的短链会跳到别人的地址,所以删除时保留短码本身(例如只清空长链接),不再分配。
## 九、Feed 流 🔥
@@ -1287,7 +1291,7 @@ CREATE TABLE conv_member (
服务端的限制主要是:
- 文件描述符:每个连接占用一个,要调大进程能打开的描述符数,在 shell 中用 ulimit -n,由 systemd 启动的服务不继承登录 shell 的设置,默认软限制 1024、硬限制 524288(systemd 240 起),要在 unit 文件中设置 LimitNOFILE,见 [epoll](Socket.md#epoll);
- 文件描述符:每个连接占用一个,要调大进程能打开的描述符数,在 shell 中用 ulimit -n,由 systemd 启动的服务不继承登录 shell 的设置,默认软限制 1024、硬限制 524288(systemd 240 起),要在 unit 文件中设置 LimitNOFILE,Java 进程则会在启动时把软限制提到硬限制(HotSpot 的 MaxFDLimit 默认开启),见 [epoll](Socket.md#epoll);
- 内存:10 万个空闲连接,Java 进程的内存增加约 120 MB,内核中每个套接字约 4 KB,合计每个连接约 5 KB,100 万个连接约需 5 GB;
- CPU 和带宽:取决于消息量,空闲的连接只需要处理心跳。
+7 -3
View File
@@ -106,7 +106,7 @@ CPU 为了解决运算速度与主存访问速度不匹配的问题,引入了
内容分发网络(Content Delivery Network, CDN)是一种互连的网络系统,它利用更靠近用户的服务器从而更快更可靠地将 HTML、CSS、JavaScript、音乐、图片、视频等静态资源分发给用户。
用户请求通过 DNS 被调度到最近的 CDN 节点,节点未命中缓存时再回源站获取。
用户请求通过 DNS 被调度到最近的 CDN 节点,节点未命中缓存时再回源站获取。调度一般靠 CNAME:网站把域名用 CNAME 指向 CDN 提供的域名,由 CDN 的调度系统按查询来源的位置返回较近的节点,例子见 [域名系统](计算机网络%20-%20应用层.md#域名系统) 中 www.jd.com 的解析结果。
CDN 主要有以下优点:
@@ -114,6 +114,10 @@ CDN 主要有以下优点:
- 通过部署多台服务器,从而提高系统整体的带宽性能;
- 多台服务器可以看成是一种冗余机制,从而具有高可用性。
静态资源更新后,节点上可能还缓存着旧版本。常用的做法是在文件名中带上版本或内容的哈希,例如 app.3f2a9c.js,并设置很长的缓存时间,内容一变文件名就变;入口的 HTML 用 no-cache,每次向源站确认,见 [强缓存与协商缓存](HTTP.md#5-强缓存与协商缓存)。也可以调用 CDN 的刷新接口让节点上的旧缓存失效,但一次刷新大量文件会让回源请求突然增多,AWS CloudFront 的文档也推荐用带版本号的文件名更新内容。
按用户生成的内容、带 Cache-Control: private 的响应和实时变化的接口不适合由 CDN 缓存。缓存键中包含查询参数时,与内容无关的参数(例如统计来源的 from=campaign)要去掉,否则同一个文件会被缓存成很多份,命中率下降。
<div align="center"> <img src="pics/cdn.png" width="760px"/> </div><br>
## 四、缓存问题 🔥🔥
@@ -153,7 +157,7 @@ Redis 8 起内置了布隆过滤器(BF.RESERVE、BF.ADD、BF.EXISTS),Redis
解决方案:
- 互斥锁:缓存未命中时用 `SET lock:product:1024 <随机值> NX EX 3` 抢锁,抢到的请求先再查一次缓存(可能别人已经写回),仍然没有才查询数据库并写回缓存,最后释放锁(释放时要确认锁仍是自己的,见 [分布式锁](分布式.md#redis-的-set-nx-指令));没抢到的请求等待一小段时间(例如 10 毫秒)后重新读缓存。锁的过期时间要长于重建缓存的耗时,否则锁会在重建期间过期,其它请求再次抢到锁并回源,例如锁 100 毫秒、重建 300 毫秒时,100 个并发请求回源 4 次(见[附录 A5](#a5-缓存击穿的三种做法));耗时难以估计时可以给锁续期,见[分布式锁](分布式.md#redis-的-set-nx-指令);
- 互斥锁:缓存未命中时用 `SET lock:product:1024 <随机值> NX EX 3` 抢锁,抢到的请求先再查一次缓存(可能别人已经写回),仍然没有才查询数据库并写回缓存,最后释放锁(释放时要确认锁仍是自己的,见 [分布式锁](分布式.md#redis-的-set-nx-指令));没抢到的请求等待一小段时间(例如 10 毫秒)后重新读缓存,等待的总时长要有上限,超过后返回默认值或报错,否则持锁的请求崩溃时,其它请求要一直等到锁过期。锁的过期时间要长于重建缓存的耗时,否则锁会在重建期间过期,其它请求再次抢到锁并回源,例如锁 100 毫秒、重建 300 毫秒时,100 个并发请求回源 4 次(见[附录 A5](#a5-缓存击穿的三种做法));耗时难以估计时可以给锁续期,见[分布式锁](分布式.md#redis-的-set-nx-指令);
- 逻辑过期:键不设置过期时间,而是在值中保存一个过期时间。读到已经过期的值时,抢到锁的请求启动后台线程重建缓存,所有请求都直接返回旧值,不等待。这些键不会被 volatile 开头的淘汰策略淘汰,不再需要时要主动删除。
<div align="center"> <img src="pics/cache-breakdown-lock-vs-logical.png" width="760px"/> </div><br>
@@ -249,7 +253,7 @@ Redis 8 起内置了布隆过滤器(BF.RESERVE、BF.ADD、BF.EXISTS),Redis
- 用 redis-cli --bigkeys 或者 --memkeys 扫描,它们使用 SCAN 命令遍历,不会阻塞服务器;用 MEMORY USAGE 查看单个键占用的内存;
- 拆分:例如把一个大的 HASH 按字段的哈希值拆成 16 个小 HASH,user:1001:fav:0 到 user:1001:fav:15,读写字段 f 时访问第 hash(f) % 16 个;大的字符串可以压缩,或者把文件放到对象存储,Redis 中只保存地址;
- 删除用 UNLINK,并开启 lazyfree-lazy-expire 和 lazyfree-lazy-eviction,让过期和淘汰也在后台释放内存;读取集合时用 HSCAN、SSCAN 分批读取,不要一次读出所有元素。
- 删除用 UNLINK,并开启 lazyfree-lazy-expire、lazyfree-lazy-eviction 和 lazyfree-lazy-server-del,让过期、淘汰以及 SET、RENAME 覆盖旧值时的删除也在后台释放内存;读取集合时用 HSCAN、SSCAN 分批读取,不要一次读出所有元素。
热 Key 的问题是请求集中到同一个节点,集群中其它节点再多也无法分担。处理方法:
+22 -1
View File
@@ -262,7 +262,15 @@ INSERT INTO payment VALUES (1025, 50); -- 同样等待事务 1
读 MySQL 8.0 的死锁日志时,两个事务的情况下 (2) 是最后开始等待、使等待关系形成环的事务,(1) 是它在等的事务;(1) HOLDS THE LOCK(S) 列出的就是 (2) 在等的那把锁,反之亦然。
每个事务只显示当时正在执行的语句,持有的锁可能是更早的语句加的,例如附录 A3 中两个事务显示的都是 INSERT,间隙锁却来自之前的 SELECT ... FOR UPDATE。
每个事务只显示当时正在执行的语句,持有的锁可能是更早的语句加的,例如附录 A3 中两个事务显示的都是 INSERT,间隙锁却来自之前的 SELECT ... FOR UPDATE。要找出更早的语句,可以按日志中的 MySQL thread id 查 performance_schema.events_statements_history,它默认为每个连接保留最近 10 条语句(MySQL 8.0 实测,见[附录 A3](#a3-先查后插的间隙锁死锁)):
```sql
SELECT h.EVENT_ID, h.SQL_TEXT
FROM performance_schema.events_statements_history h
JOIN performance_schema.threads t ON t.THREAD_ID = h.THREAD_ID
WHERE t.PROCESSLIST_ID = 4136 -- 死锁日志中的 MySQL thread id
ORDER BY h.EVENT_ID;
```
lock_mode X locks gap before rec 是间隙锁,后面再有 insert intention 的是插入意向锁,在 data_locks 中分别显示为 X,GAP 和 X,GAP,INSERT_INTENTION。
@@ -354,6 +362,19 @@ INSERT INTO payment VALUES (1025, 50)
ERROR 1062 (23000) at line 1: Duplicate entry '1024' for key 'payment.PRIMARY'
```
死锁发生后,按事务 2 的 MySQL thread id 查 events_statements_history(history_size 为 10,这个 consumer 默认开启),能看到它在 INSERT 之前执行的 SELECT ... FOR UPDATE(节选,省略了客户端连接时执行的语句):
```text
*** (2) TRANSACTION:
MySQL thread id 4136, OS thread handle 6157561856, query id 4812446 localhost root update
INSERT INTO payment VALUES (1025, 50)
EVENT_ID SQL_TEXT
3 BEGIN
5 SELECT * FROM payment WHERE order_id = 1025 FOR UPDATE
6 INSERT INTO payment VALUES (1025, 50)
```
执行 SET GLOBAL innodb_print_all_deadlocks = ON 后,再执行一遍可重复读的例子,错误日志中同样记录了两个事务持有的间隙锁和等待的插入意向锁(lock_mode X locks gap before rec insert intention waiting)。
### A4 死锁线程的状态与 CPU 时间
@@ -254,6 +254,8 @@ Linux 的进程状态和僵尸进程、孤儿进程见 [Linux](Linux.md#进程
- 同步:多个进程因为合作产生的直接制约关系,使得进程有一定的先后执行关系;
- 互斥:多个进程在同一时刻只有一个进程能进入临界区。
实现互斥的锁在拿不到时有两种等法:互斥锁让线程阻塞、让出 CPU,锁释放后再被唤醒;自旋锁让线程在原地循环检查,省去了阻塞和唤醒的切换,但一直占着 CPU,只适合锁的持有时间很短、并且有多个 CPU 核的情况。Java 中的自旋见[自旋锁](Java%20并发.md#自旋锁)。
### 3. 信号量
信号量(Semaphore)是一个整型变量,可以对其执行 down 和 up 操作,也就是常见的 P 和 V 操作。
@@ -711,6 +713,8 @@ static void *worker(void *arg) {
return NULL;
}
// main 中:
setvbuf(stdout, NULL, _IONBF, 0); // 关闭 stdout 的缓冲,否则输出重定向到文件时,子进程 _exit() 前的 printf 不会写出
printf("主线程 pid=%d tid=%d\n", getpid(), gettid());
pid_t c = fork();
if (c == 0) { counter++; printf("子进程 pid=%d tid=%d\n", getpid(), gettid()); _exit(0); }
waitpid(c, NULL, 0);
+2
View File
@@ -175,6 +175,8 @@ TIME_WAIT 出现在主动关闭的一方,本身是正常的状态,数量多
服务器主动关闭时 TIME_WAIT 留在服务器上,例如响应后关闭的 HTTP 短连接。这些连接共用监听端口,靠客户端的地址和端口区分,不消耗本地端口,只占少量内存;总数超过 tcp_max_tw_buckets 时,新进入 TIME_WAIT 的连接会被直接销毁。Nginx 这类代理既是服务器又向上游发起连接,排查时先确认是哪个方向的连接、哪一方先关闭。
服务重启时,留在服务器上的 TIME_WAIT 连接仍占着监听的端口,重新 bind() 会报 Address already in use,所以服务端程序一般在 bind() 之前设置 SO_REUSEADDR。Linux 要求先后两次 bind() 都设置了它,服务端每次启动时都设置即可。它只允许与非监听状态的旧连接共用同一个本地地址和端口,不能让两个套接字同时监听同一个地址和端口,那是 SO_REUSEPORT 的作用(socket(7))。
各状态的连接数可以这样统计:
```bash
+3 -1
View File
@@ -145,10 +145,12 @@ IMAP 协议中客户端和服务器上的邮件保持同步,如果不手动删
1. 浏览器解析 URL,得到协议 https、主机名 `www.example.com`、端口(省略时 https 为 443)和路径 /,各部分的含义见 [URL](HTTP.md#url),然后检查缓存,有未过期的缓存时直接使用,见 [缓存](HTTP.md#缓存);
2. DNS 解析主机名 `www.example.com`,得到 IP 地址,路径和参数不参与解析;
3. 与服务器进行 TCP 三次握手,HTTPS 还要进行 TLS 握手;
4. 发送 HTTP 请求。请求通常先到达负载均衡或[反向代理](集群.md#3-反向代理服务器)(例如 Nginx),再转发给某台应用服务器,应用服务器查询缓存或数据库后生成响应。如果响应是重定向,例如只输入 baidu.com 时浏览器补上协议后访问,得到 301 和 `Location: https://www.baidu.com/`(见[附录 A3](#a3-省略协议时的重定向)),浏览器对新地址重新走一遍前面的步骤。域名在浏览器的 HSTS 记录或预加载列表中时,输入的 http 地址在浏览器内部就改为 https,不会发出这次明文请求,见 [HTTPS](HTTP.md#六https) 中关于 HSTS 的一段;
4. 发送 HTTP 请求。请求通常先到达负载均衡或[反向代理](集群.md#3-反向代理服务器)(例如 Nginx),再转发给某台应用服务器,应用服务器查询缓存或数据库后生成响应。如果响应是重定向,例如访问 baidu.com 得到 301 和 `Location: https://www.baidu.com/`(见[附录 A3](#a3-省略协议时的重定向)),浏览器对新地址重新走一遍前面的步骤;
5. 浏览器解析 HTML 构建 DOM 树,解析 CSS 构建 CSSOM,两者合成渲染树,再计算每个元素的位置和大小(布局),最后绘制到屏幕上。解析中遇到 CSS、JavaScript、图片等外部资源时另发请求(HTTP/2 下同一服务器上的资源可以在一个连接上并发请求,见 [HTTP/2.0](HTTP.md#八http20)),不带 async 或 defer 的普通脚本(type 不是 module)会暂停 HTML 的解析,直到脚本下载并执行完;
6. 连接保持一段时间供后续请求复用,空闲后关闭,见 [短连接与长连接](HTTP.md#1-短连接与长连接)。
只输入 baidu.com 这类不带协议的地址时,现在的 Chrome、Firefox 和 iOS 上的 Safari 先补上 https 访问,连接失败才退回 http。域名在浏览器的 HSTS 记录或预加载列表中时,输入的 http 地址在浏览器内部就改为 https,不会发出明文请求,也不会退回 http,见 [HTTPS](HTTP.md#六https) 中关于 HSTS 的一段。
以下是访问该网址时各阶段的耗时(curl 实测,见[附录 A1](#a1-访问网页各阶段的耗时)):
<div align="center"> <img src="pics/url-timeline.png" width="760px"/> </div><br>
+1 -1
View File
@@ -186,7 +186,7 @@ java.lang.IllegalArgumentException: Cannot reflectively create enum objects
该实现在多次序列化和反序列化之后,不会得到多个实例。这是因为枚举常量序列化时只写入它的名字,反序列化时用 Enum.valueOf() 按名字找回已有的常量,这个过程不能自定义。而其它实现要实现 Serializable,反序列化时每次都会创建一个新对象,需要提供 readResolve() 方法返回已有的实例来替换它;transient 只决定字段是否参与序列化,不能保证实例唯一。
这几种写法中,《Effective Java》推荐单元素的枚举,它同时挡住了反射和反序列化;枚举不能继承其它类,单例需要继承某个父类时,用静态内部类实现。
这几种写法中,《Effective Java》认为单元素的枚举通常是实现单例的最好方式,它同时挡住了反射和反序列化;枚举不能继承其它类,单例需要继承某个父类时,用静态内部类实现。
### 示例
- 日志类;
+36 -61
View File
@@ -9,71 +9,11 @@
- Strategy 接口定义了一个算法族,它们都实现了 behavior() 方法;
- Context 是使用到该算法族的类,其中的 doSomething() 方法会调用 behavior(),setStrategy(Strategy) 方法可以动态地改变 strategy 对象,即可以在运行时更换 Context 使用的算法。
下图按后文的鸭子示例画出:Duck 是 Context,QuackBehavior 是 Strategy,quack()、performQuack() 和 setQuackBehavior() 分别对应 behavior()、doSomething() 和 setStrategy()。
下图按后文的运费示例画出:Order 是 Context,FreightStrategy 是 Strategy,fee()、freight() 和 setFreightStrategy() 分别对应 behavior()、doSomething() 和 setStrategy()。
<div align="center"> <img src="pics/dp-strategy.png" width="760px"/> </div><br>
### 实现
设计一个鸭子,它可以动态地改变叫声。这里的算法族是鸭子的叫声行为。
```java
public interface QuackBehavior {
void quack();
}
```
```java
public class Quack implements QuackBehavior {
@Override
public void quack() {
System.out.println("quack!");
}
}
```
```java
public class Squeak implements QuackBehavior{
@Override
public void quack() {
System.out.println("squeak!");
}
}
```
```java
public class Duck {
private QuackBehavior quackBehavior;
public void performQuack() {
if (quackBehavior != null) {
quackBehavior.quack();
}
}
public void setQuackBehavior(QuackBehavior quackBehavior) {
this.quackBehavior = quackBehavior;
}
}
```
```java
public class Client {
public static void main(String[] args) {
Duck duck = new Duck();
duck.setQuackBehavior(new Squeak());
duck.performQuack();
duck.setQuackBehavior(new Quack());
duck.performQuack();
}
}
```
```text
squeak!
quack!
```
#### 1. 用策略替代 if-else
@@ -132,6 +72,41 @@ public class PickupFreight implements FreightStrategy {
}
```
Order 是 Context,持有一个运费策略,计算运费时交给它;用户改选配送方式时,换一个策略对象即可:
```java
public class Order {
private FreightStrategy freightStrategy;
public void setFreightStrategy(FreightStrategy freightStrategy) {
this.freightStrategy = freightStrategy;
}
public int freight(int kg) {
return freightStrategy.fee(kg);
}
}
```
```java
public class Client {
public static void main(String[] args) {
Order order = new Order();
order.setFreightStrategy(new StandardFreight());
System.out.println(order.freight(3));
order.setFreightStrategy(new ExpressFreight()); // 用户改选加急
System.out.println(order.freight(3));
}
}
```
```text
16
37
```
同样的结构也适用于支付渠道、消息推送渠道等场景,只是各实现类的内容换成对应的处理逻辑。
#### 2. 与简单工厂和 Spring 容器结合