diff --git a/notes/HTTP.md b/notes/HTTP.md index 2d61a243e..97ddc5cf0 100644 --- a/notes/HTTP.md +++ b/notes/HTTP.md @@ -510,7 +510,7 @@ Authorization: Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJ1aWQiOjEwMjQsInJvb JWT 与 Session 的区别: - Session 的状态保存在服务器,删除 Session 就能让用户立即下线;集群中需要共享 Session,见 [集群下的 Session 管理](集群.md#二集群下的-session-管理); -- JWT 的状态保存在令牌中,任何持有密钥的服务都能独立验证,适合多个服务共用登录状态;代价是令牌签发后在过期前一直有效,服务器无法主动让它失效。 +- JWT 的状态保存在令牌中,任何持有密钥的服务都能独立验证(用 HS256 时持有密钥也就能签发,只需要验证的服务应改用 RS256 等非对称算法,只拿公钥),适合多个服务共用登录状态;代价是令牌签发后在过期前一直有效,服务器无法主动让它失效。 无法主动失效是 JWT 最常被追问的问题,常用的解决方法是双令牌:访问令牌(Access Token)的有效期很短,例如 15 分钟,每次请求都携带;刷新令牌(Refresh Token)的有效期较长,服务器记录已签发的刷新令牌,只在访问令牌过期时用它换取新的访问令牌。用户退出或者被封禁时撤销刷新令牌,已经发出的访问令牌最多 15 分钟后自然失效: @@ -675,7 +675,7 @@ If-Modified-Since: Wed, 21 Oct 2015 07:28:00 GMT 浏览器使用缓存分为两种情况: -- 强缓存:缓存还在 max-age(或 Expires)规定的有效期内,直接使用,不发送请求; +- 强缓存:缓存还在 max-age(或 Expires)规定的有效期内,直接使用,不发送请求;两者都没有时,浏览器可以按 Last-Modified 估算有效期,例如取距上次修改时间的 10%; - 协商缓存:缓存已经过期,或者响应中有 no-cache,浏览器带上 If-None-Match 或 If-Modified-Since 向服务器确认,资源没有变化就返回 304,只传输首部,浏览器继续使用本地的缓存。

@@ -944,7 +944,7 @@ HTTPS 并不是新协议,而是在 HTTP 和 TCP 之间加了一层 SSL(Secur 通过使用 SSL,HTTPS 具有了加密(防窃听)、认证(防伪装)和完整性保护(防篡改)。 -例如同一个登录请求 POST /login,表单为 user=alice&password=123456,分别用 HTTP 和 HTTPS 发给 shop.example,同时抓包(OpenSSL 3.0 实测,见[附录 A8](#a8-http-与-https-的抓包对比)):HTTP 的报文中可以直接读到请求行和密码;HTTPS 的报文中找不到 /login 和 password,只能看到域名 shop.example。这是因为客户端在 ClientHello 的 SNI(Server Name Indication)扩展中以明文告诉服务器要访问哪个域名,服务器据此选择证书。需要注意的是,HTTPS 保护的是报文的内容,访问了哪个 IP 和域名、传输了多少数据,仍然可以被网络中的设备看到。 +例如同一个登录请求 POST /login,表单为 user=alice&password=123456,分别用 HTTP 和 HTTPS 发给 shop.example,同时抓包(OpenSSL 3.0 实测,见[附录 A8](#a8-http-与-https-的抓包对比)):HTTP 的报文中可以直接读到请求行和密码;HTTPS 的报文中找不到 /login 和 password,只能看到域名 shop.example。这是因为未启用加密 ClientHello(Encrypted Client Hello, ECH)时,客户端在 ClientHello 的 SNI(Server Name Indication)扩展中以明文告诉服务器要访问哪个域名,服务器据此选择证书。需要注意的是,HTTPS 保护的是报文的内容,访问了哪个 IP、传输了多少数据,仍然可以被网络中的设备看到,未启用 ECH 时域名也是如此。

@@ -1014,7 +1014,7 @@ TLS 1.3 的套件名称只包含后两项,例如访问 www.jd.com 时为 TLS_A 证书中的签名是 CA 用自己的私钥对证书内容(域名、公钥、有效期、签发者等)的哈希值计算出来的。客户端用上一级证书中的公钥验证签名:证书内容只要被改动一个字节,哈希值就会变化,签名验证就会失败。例如把 baidu.com 证书中的 CN 改成 baidu.con,再用中间证书验证,结果为 certificate signature failure(OpenSSL 3.0 实测,见[附录 A9](#a9-证书验证))。 -根证书是信任的起点,它是自签名的,无法再向上验证,因此不在握手中发送,而是预装在操作系统或浏览器的信任列表中。客户端沿证书链逐级验证,链的最上端必须是信任列表中的根证书。除了签名,客户端还要检查证书是否在有效期内、是否已被吊销,以及访问的域名是否在证书的 Subject Alternative Name 中,例如 baidu.com 的证书列出了 53 个域名,其中的 *.baidu.com 匹配 www.baidu.com。 +根证书是信任的起点,它是自签名的,无法再向上验证,因此通常不在握手中发送,而是预装在操作系统或浏览器的信任列表中。客户端沿证书链逐级验证,链的最上端必须是信任列表中的根证书。除了签名,客户端还要检查证书是否在有效期内、是否已被吊销,以及访问的域名是否在证书的 Subject Alternative Name 中,例如 baidu.com 的证书列出了 53 个域名,其中的 *.baidu.com 匹配 www.baidu.com。 证书在有效期内也可能被提前吊销,例如私钥泄露。查询方式有两种,证书中可以写明相应的地址(baidu.com 的证书两者都有,见[附录 A9](#a9-证书验证)):证书吊销列表(Certificate Revocation List, CRL)是 CA 定期发布的全部被吊销证书的序列号;在线证书状态协议(Online Certificate Status Protocol, OCSP)只查询这一张证书。每次连接都去问 CA 既慢又暴露用户的访问记录,所以服务器可以在握手时附带 CA 签过名的 OCSP 响应,称为 OCSP Stapling;浏览器也大多改为定期下载厂商汇总的吊销列表。 @@ -1043,7 +1043,7 @@ TLS 1.2 要 2 个 RTT 之后才能发送 HTTP 请求。TLS 1.3 把 ECDHE 公钥 - ClientKeyExchange:客户端的 ECDHE 公钥,同样是 32 字节; - ChangeCipherSpec 和 Finished:通知对方之后的报文都已加密,然后发送第一个加密的报文 Finished。 -客户端验证证书,再用证书中的公钥验证 ServerKeyExchange 中的签名,确认 ECDHE 公钥确实来自证书的持有者。之后双方用自己的私钥和对方的公钥算出相同的预主密钥(pre-master secret),再把预主密钥和两个随机数一起输入伪随机函数,得到 48 字节的主密钥,最后由主密钥派生出加密和完整性校验用的会话密钥。 +客户端验证证书,再用证书中的公钥验证 ServerKeyExchange 中的签名,确认 ECDHE 公钥确实来自证书的持有者。之后双方用自己的私钥和对方的公钥算出相同的预主密钥(pre-master secret),再把预主密钥和两个随机数一起输入伪随机函数(启用扩展主密钥时两个随机数换成从 ClientHello 到 ClientKeyExchange 这些握手报文的哈希,OpenSSL 3 默认启用),得到 48 字节的主密钥,最后由主密钥派生出加密和完整性校验用的会话密钥。 #### 2. 密钥交换 @@ -1124,7 +1124,7 @@ HTTP/2.0 将报文分成 HEADERS 帧和 DATA 帧,它们都是二进制格式

-在通信过程中,只会有一个 TCP 连接存在,它承载了任意数量的双向数据流(Stream)。 +在通信过程中,客户端与同一个服务器通常只建立一个 TCP 连接,它承载了任意数量的双向数据流(Stream)。 - 每个数据流(Stream)都有一个唯一标识符,用于承载双向信息(RFC 7540 定义的优先级信号已被 RFC 9113 废弃,需要时改用 RFC 9218 的 Priority 首部); - 消息(Message)是与逻辑请求或响应对应的完整的一系列帧; diff --git a/notes/Java IO.md b/notes/Java IO.md index f5fee689e..5f468e909 100644 --- a/notes/Java IO.md +++ b/notes/Java IO.md @@ -455,7 +455,7 @@ int interestSet = SelectionKey.OP_READ | SelectionKey.OP_WRITE; int num = selector.select(); ``` -使用 select() 来监听到达的事件,它会一直阻塞直到有至少一个事件到达。 +使用 select() 来监听到达的事件,它会阻塞到至少一个通道就绪,或者被 wakeup() 或线程中断唤醒,因此返回值可能为 0。 #### 4. 获取到达的事件 diff --git "a/notes/Java \345\237\272\347\241\200.md" "b/notes/Java \345\237\272\347\241\200.md" index be68c41df..0ce7cb0cd 100644 --- "a/notes/Java \345\237\272\347\241\200.md" +++ "b/notes/Java \345\237\272\347\241\200.md" @@ -157,7 +157,7 @@ public final class String } ``` -value 数组被声明为 final,这意味着 value 数组初始化之后就不能再引用其它数组。并且 String 内部没有改变 value 数组的方法,因此可以保证 String 不可变。 +value 数组被声明为 final,这只意味着 value 初始化之后不能再引用其它数组,数组中的内容仍然可以修改。真正保证 String 不可变的是:value 为 private,String 内部没有改变 value 数组的方法,也不把它交给外部代码。 ### 不可变的好处 @@ -1358,7 +1358,7 @@ getMethod()、getField() 只能找到 public 成员,包括继承的;getDecla ## 八、异常 -Throwable 可以用来表示任何可以作为异常抛出的类,分为两种:Error 和 Exception。其中 Error 用来表示 JVM 无法处理的错误,例如 OutOfMemoryError、StackOverflowError,程序一般不捕获。Exception 分为两种: +Throwable 可以用来表示任何可以作为异常抛出的类,分为两种:Error 和 Exception。其中 Error 用来表示 JVM 无法处理的错误,例如 OutOfMemoryError、StackOverflowError,程序一般不捕获,编译器也不要求捕获或声明。Exception 分为两种: - 受检异常:例如 IOException、SQLException,需要用 try...catch... 语句捕获并进行处理,或者用 throws 声明抛出,否则编译不通过,这类异常通常可以恢复; - 非受检异常:RuntimeException 及其子类,是程序运行时错误,例如除 0 会引发 ArithmeticException,访问 null 的成员会引发 NullPointerException。编译器不强制处理这类异常,通常应该修改代码来避免,而不是捕获之后恢复。 diff --git "a/notes/Java \345\256\271\345\231\250.md" "b/notes/Java \345\256\271\345\231\250.md" index ce83f9fa7..20c40b01b 100644 --- "a/notes/Java \345\256\271\345\231\250.md" +++ "b/notes/Java \345\256\271\345\231\250.md" @@ -347,7 +347,7 @@ List list = new CopyOnWriteArrayList<>(); #### 1. 读写分离 -写操作先加锁,把当前数组复制一份,在副本上修改,最后把 volatile 字段 array 指向副本;加锁是为了防止两个写操作各自复制同一个旧数组,后完成的覆盖先完成的。已经发布的数组不再被修改,所以读操作直接读当前的数组,不需要加锁。JDK 21 中写操作改为用 synchronized 加锁,过程相同。 +写操作先加锁,把当前数组复制一份,在副本上修改,最后把 volatile 字段 array 指向副本;加锁是为了防止两个写操作各自复制同一个旧数组,后完成的覆盖先完成的。已经发布的数组不再被修改,所以读操作直接读当前的数组,不需要加锁。JDK 9 起写操作改为用 synchronized 加锁,过程相同。 迭代器遍历的是创建时的数组快照:遍历期间可以修改列表,不会抛出 ConcurrentModificationException,但看不到这些修改,也不支持通过迭代器 remove(JDK 21 实测,见[附录 A8](#a8-copyonwritearraylist-的迭代器))。 diff --git "a/notes/Java \345\271\266\345\217\221.md" "b/notes/Java \345\271\266\345\217\221.md" index 6d8ac3f9f..065ed3d1f 100644 --- "a/notes/Java \345\271\266\345\217\221.md" +++ "b/notes/Java \345\271\266\345\217\221.md" @@ -89,6 +89,8 @@ * [A23 拒绝策略的执行线程与 Future](#a23-拒绝策略的执行线程与-future) * [A24 volatile 的自增与机器码](#a24-volatile-的自增与机器码) * [A25 线程池空闲线程的状态与 submit()](#a25-线程池空闲线程的状态与-submit) + * [A26 CompletableFuture 的回调线程与异常](#a26-completablefuture-的回调线程与异常) + * [A27 traceId 随任务传递](#a27-traceid-随任务传递) * [参考资料](#参考资料) @@ -235,7 +237,7 @@ Executor 把任务的提交和执行分开,提交者只管提交任务,不 - CachedThreadPool:有空闲线程时复用,没有时为任务新建一个线程,线程空闲 60 秒后被回收; - FixedThreadPool:所有任务只能使用固定大小的线程; -- SingleThreadExecutor:相当于大小为 1 的 FixedThreadPool。 +- SingleThreadExecutor:相当于大小为 1 的 FixedThreadPool,但不能再被调大线程数。 ```java public static void main(String[] args) { @@ -303,7 +305,7 @@ pool.shutdown(); // 不关闭的话,核心线程一直存活,程序不会 需要注意的是,任务 5、6 比先提交的任务 3、4 更早执行:队列满后新建的线程直接执行当前提交的任务,队列中的任务要等有线程空闲后才会被取出。 -先放入队列、队列满了才增加线程,是因为线程比排队的任务昂贵得多:核心线程承担平时的负载,短时间的高峰由队列缓冲,队列也放不下说明负载持续超出了处理能力,才临时增加线程,高峰过后再减少。Tomcat 的线程池则反过来,线程数没有达到最大值时先增加线程,让等待 I/O 的请求尽快开始处理。 +先放入队列、队列满了才增加线程,是因为线程比排队的任务昂贵得多:核心线程承担平时的负载,短时间的高峰由队列缓冲,队列也放不下说明负载持续超出了处理能力,才临时增加线程,高峰过后再减少。Tomcat 的线程池则反过来:没有空闲线程、线程数又没到最大值时,它的队列在 offer() 中返回 false,线程池当作队列已满而先增加线程,让等待 I/O 的请求尽快开始处理。 核心线程数为 0 时,任务直接进入队列,execute() 发现没有线程时只创建一个。例如核心线程数为 0、最大线程数为 4、使用无界队列时,提交 3 个任务只有 1 个线程逐个执行(JDK 21 实测,见[附录 A7](#a7-线程池的异常与参数调整))。 @@ -347,6 +349,8 @@ ThreadPoolExecutor pool = new ThreadPoolExecutor(2, 4, 60, TimeUnit.SECONDS, - CPU 密集型:取 CPU 核数 + 1,多出的一个线程用于在某个线程因缺页等原因暂停时继续使用 CPU; - I/O 密集型:线程等待 I/O 时不占用 CPU,可以按 核数 × (1 + 等待时间 / 计算时间) 估算。例如 8 核 CPU,每个任务计算 10 ms、等待数据库 90 ms,约为 8 × (1 + 90 / 10) = 80 个线程。 +核数取 availableProcessors() 的值,JDK 10 起在容器中按 CPU 配额计算,也可以用 -XX:ActiveProcessorCount 指定。 + 也可以从吞吐量估算:同时在执行的任务数 = 每秒到达的任务数 × 每个任务的耗时(利特尔法则)。例如每秒 200 个请求,每个耗时 100 ms,平均有 200 × 0.1 = 20 个任务同时在执行,线程数至少为 20,再按高峰留出余量。 CPU 或下游资源(例如数据库连接池)饱和后,再加线程也不能提高吞吐量。 @@ -355,7 +359,7 @@ CPU 或下游资源(例如数据库连接池)饱和后,再加线程也不 I/O 密集、估出的线程数成百上千时,JDK 21 可以改用虚拟线程(Executors.newVirtualThreadPerTaskExecutor(),每个任务一个,不池化),对下游的并发数用 [Semaphore](#semaphore) 限制。 -估算值只是起点,最终要通过压测确定。上线后用 getActiveCount()、getQueue().size() 等方法监控活跃线程数和队列长度,在拒绝策略中统计拒绝次数,必要时调用 setCorePoolSize()、setMaximumPoolSize() 在运行时调整,参数放在配置中心、变更时调用这两个方法就是动态线程池。需要注意的是,JDK 9 起 setCorePoolSize() 的新值大于最大线程数时抛出 IllegalArgumentException,两者都要调大时先调最大线程数(JDK 21 实测,见[附录 A7](#a7-线程池的异常与参数调整));LinkedBlockingQueue 的容量是 final 字段,队列长度要可调时只能自己实现队列。 +估算值只是起点,最终要通过压测确定。上线后用 getActiveCount()、getQueue().size() 等方法监控活跃线程数和队列长度,在拒绝策略中统计拒绝次数,必要时调用 setCorePoolSize()、setMaximumPoolSize() 在运行时调整,参数放在配置中心、变更时调用这两个方法就是动态线程池。需要注意的是,JDK 9 起 setCorePoolSize() 的新值大于最大线程数时抛出 IllegalArgumentException,两者都要调大时先调最大线程数;调小核心线程数后,多出的线程要再空闲满 keepAliveTime 才退出,调大时只按队列中排队的任务数补建线程(JDK 21 实测,见[附录 A7](#a7-线程池的异常与参数调整));LinkedBlockingQueue 的容量是 final 字段,队列长度要可调时只能自己实现队列。 不同业务最好使用各自的线程池(线程池隔离),例如导出报表变慢时只占满自己的线程,不影响下单。 @@ -1194,8 +1198,11 @@ public class CountdownLatchExample { ExecutorService executorService = Executors.newCachedThreadPool(); for (int i = 0; i < totalThread; i++) { executorService.execute(() -> { - System.out.print("run.."); - countDownLatch.countDown(); + try { + System.out.print("run.."); + } finally { + countDownLatch.countDown(); // 任务抛出异常也要减 1 + } }); } countDownLatch.await(); @@ -1379,17 +1386,19 @@ pool.shutdown(); 需要注意的是: -- 带 Async 的方法不传入线程池时,任务在 ForkJoinPool.commonPool() 中执行,它的线程数默认为 CPU 核数 − 1,并且被并行流等代码共用,阻塞的 I/O 任务应该使用自己的线程池(commonPool 并行度小于 2 时改为每个任务新建一个线程,见[附录 A18](#a18-completablefuture-的默认线程)); -- 异常不会自动抛出,要用 exceptionally、handle 处理,否则只有调用 join() 时才会得到包装后的 CompletionException; -- 远程调用要设置超时:JDK 9 起 orTimeout() 超时后以 TimeoutException 结束,可以接 exceptionally 降级,但它不中断正在执行的任务,所以 HTTP 客户端、数据库驱动本身也要设置超时(JDK 21 实测,见[附录 A21](#a21-优先级队列与超时)); +- 带 Async 的方法不传入线程池时,任务在 ForkJoinPool.commonPool() 中执行,它的线程数默认为 CPU 核数 − 1,并且被并行流等代码共用,阻塞的 I/O 任务应该使用自己的线程池(commonPool 并行度小于 2 时改为每个任务新建一个线程,见[附录 A18](#a18-completablefuture-的默认线程));不带 Async 的回调通常由完成上一步的线程执行,也可能由其他调用 get()、join() 等方法的线程执行,注册时上一步已完成则由注册的线程直接执行,所以不要依赖回调所在的线程; +- 异常不会自动抛出,要用 exceptionally、handle 处理,否则 get()、join() 分别抛出包装后的 ExecutionException、CompletionException,exceptionally 收到的异常在任务中抛出时被包装成 CompletionException,要用 getCause() 取原始异常,而 orTimeout() 的 TimeoutException 没有包装(JDK 21 实测,见[附录 A26](#a26-completablefuture-的回调线程与异常)); +- 远程调用要设置超时:JDK 9 起 orTimeout() 超时后以 TimeoutException 结束,可以接 exceptionally 降级,但它和 cancel() 一样不中断正在执行的任务,所以 HTTP 客户端、数据库驱动本身也要设置超时(JDK 21 实测,见[附录 A21](#a21-优先级队列与超时)); - thenApply 的函数返回普通的值,thenCompose 的函数返回另一个 CompletableFuture,用来串联两个异步调用,避免得到 CompletableFuture<CompletableFuture<T>>。 ### BlockingQueue java.util.concurrent.BlockingQueue 接口有以下阻塞队列的实现: -- FIFO 队列:LinkedBlockingQueue、ArrayBlockingQueue(固定长度); -- 优先级队列:PriorityBlockingQueue。 +- FIFO 队列:ArrayBlockingQueue(固定长度,入队出队共用一把锁)、LinkedBlockingQueue(入队出队各一把锁,默认容量 Integer.MAX_VALUE); +- 优先级队列:PriorityBlockingQueue; +- 延迟队列:DelayQueue,元素到期后才能取出; +- 直接交接:SynchronousQueue,不保存元素,put() 要等另一个线程 take()。 提供了阻塞的 take() 和 put() 方法:如果队列为空 take() 将阻塞,直到队列中有内容;如果队列为满 put() 将阻塞,直到队列有空闲位置。 @@ -2042,7 +2051,38 @@ try { } ``` -子线程读不到父线程设置的 ThreadLocal 值,因为值存在父线程自己的 Map 中。InheritableThreadLocal 在 new Thread() 时把父线程的值复制给子线程(复制的是引用),但只复制这一次,而线程池中的线程会被反复使用。例如主线程先后设置 alice 和 bob,每次都向只有一个线程的线程池提交任务,两次读到的都是 alice(JDK 21 实测,见[附录 A5](#a5-inheritablethreadlocal-与线程池))。需要把上下文传给线程池中的任务时,可以在提交任务时显式传递,或者使用阿里开源的 TransmittableThreadLocal,它在提交任务时捕获当前的值,执行任务前设置,执行后恢复。 +子线程读不到父线程设置的 ThreadLocal 值,因为值存在父线程自己的 Map 中。InheritableThreadLocal 在 new Thread() 时把父线程的值复制给子线程(复制的是引用),但只复制这一次,而线程池中的线程会被反复使用。例如主线程先后设置 alice 和 bob,每次都向只有一个线程的线程池提交任务,两次读到的都是 alice(JDK 21 实测,见[附录 A5](#a5-inheritablethreadlocal-与线程池))。 +线程池中的任务同样读不到提交者的值。例如过滤器把链路追踪的 traceId 放进请求线程的 ThreadLocal(日志框架的 MDC 也存在 ThreadLocal 中),业务代码把发短信交给线程池,任务读的是工作线程自己的 Map,得到 null 或者之前某个任务没清理的值,这条日志就和请求对不上了。 + +解决办法是让任务带着上下文走:提交时在调用者线程中捕获,执行前在工作线程中设置,执行后在 finally 中恢复工作线程原来的值: + +```java +static final ThreadLocal TRACE_ID = new ThreadLocal<>(); + +static Runnable wrap(Runnable task) { + String captured = TRACE_ID.get(); // 提交时,在调用者线程中捕获 + return () -> { + String backup = TRACE_ID.get(); // 工作线程原来的值 + TRACE_ID.set(captured); // 执行前设置 + try { + task.run(); + } finally { + if (backup == null) TRACE_ID.remove(); else TRACE_ID.set(backup); // 恢复 + } + }; +} + +pool.execute(wrap(() -> log.info("traceId={} 发送短信", TRACE_ID.get()))); +``` + +最后恢复而不是直接 remove(),是因为任务也可能在调用者线程中执行(例如 CallerRunsPolicy),直接 remove() 会把调用者自己的 traceId 清掉(JDK 21 实测,见[附录 A27](#a27-traceid-随任务传递))。 + +每个提交点都手写 wrap() 容易漏,一般在线程池上统一处理: + +- Spring 的 ThreadPoolTaskExecutor 用 setTaskDecorator() 设置一个 TaskDecorator,在 decorate() 中写同样的逻辑,经这个线程池执行的任务(包括 @Async 方法)都会被包装;MDC 用 MDC.getCopyOfContextMap() 捕获、MDC.setContextMap() 设置; +- 把 traceId 存进阿里开源的 TransmittableThreadLocal(TTL)而不是普通 ThreadLocal,再用 TtlExecutors 包装线程池,由它完成捕获、设置和恢复;MDC 不会随之传递,需要另外处理。 + +需要注意的是,只有被包装的任务才带着上下文。CompletableFuture 中没有指定线程池的 thenRunAsync() 这类方法在 ForkJoinPool.commonPool() 中执行,这一步读不到请求的 traceId(附录中读到的是 null)。 #### 3. 可重入代码(Reentrant Code) @@ -2380,6 +2420,18 @@ p.setCorePoolSize(8); ThreadPoolExecutor ps = new ThreadPoolExecutor(5, 10, 60, TimeUnit.SECONDS, new SynchronousQueue<>()); for (int i = 0; i < 15; i++) ps.execute(() -> sleep(1000)); // 捕获 RejectedExecutionException 并计数 + +ThreadPoolExecutor p1 = new ThreadPoolExecutor(10, 10, 2, TimeUnit.SECONDS, new LinkedBlockingQueue<>()); +p1.prestartAllCoreThreads(); +p1.setCorePoolSize(2); // 0.5 s 和 3 s 时查看线程数 + +ThreadPoolExecutor p2 = new ThreadPoolExecutor(2, 10, 60, TimeUnit.SECONDS, new LinkedBlockingQueue<>()); +p2.prestartAllCoreThreads(); +p2.setCorePoolSize(6); // 队列为空 + +ThreadPoolExecutor p3 = new ThreadPoolExecutor(2, 10, 60, TimeUnit.SECONDS, new LinkedBlockingQueue<>()); +for (int i = 0; i < 5; i++) p3.execute(() -> release.await()); // 2 个在执行、3 个排队,省略了 try/catch +p3.setCorePoolSize(6); ``` ```text @@ -2396,9 +2448,12 @@ future.get() 抛出 java.lang.IllegalStateException: submit 中的异常 最大线程数为 4 时 setCorePoolSize(8):IllegalArgumentException 先 setMaximumPoolSize(8) 再 setCorePoolSize(8):core=8 max=8 核心 5、最大 10、SynchronousQueue,提交 15 个 1 s 的任务:线程数 10,被拒绝 5 个 +keepAliveTime 2 s,核心线程数 10 → 2:0.5 s 时线程数 10,3 s 时线程数 2 +队列为空时核心线程数 2 → 6:线程数 2 +队列中 3 个任务时核心线程数 2 → 6:线程数 5 ``` -JDK 8 的 setCorePoolSize() 只检查新值是否小于 0,JDK 9 起增加了与最大线程数的比较(见两个版本的 ThreadPoolExecutor.java)。 +JDK 8 的 setCorePoolSize() 只检查新值是否小于 0,JDK 9 起增加了与最大线程数的比较(见两个版本的 ThreadPoolExecutor.java)。调小核心线程数时 setCorePoolSize() 只中断空闲线程,被唤醒的线程回到 getTask() 后仍用 poll(keepAliveTime) 等待,超时才退出;调大时最多补建 min(增量, 队列长度) 个线程,上例增量为 4、队列中 3 个,所以补建 3 个。 ### A8 重排序与 volatile @@ -3131,6 +3186,86 @@ execute() 收到 FutureTask submit() 返回 FutureTask,get() = 3 ``` +### A26 CompletableFuture 的回调线程与异常 + +JDK 21.0.13,Apple M1 Pro。pool 是 2 个线程的线程池,线程名以 biz- 开头;下面省略了 try/catch 和 sleep() 的定义: + +```java +// 回调线程 +CompletableFuture.completedFuture("x").thenApply(v -> Thread.currentThread().getName()).join(); +CompletableFuture.supplyAsync(() -> { sleep(200); return "y"; }, pool) + .thenApply(v -> Thread.currentThread().getName()).join(); + +// 异常的包装 +CompletableFuture bad = CompletableFuture.supplyAsync(() -> { throw new IllegalStateException("boom"); }, pool); +bad.get(); // 捕获后打印异常类型和 getCause() +bad.join(); +CompletableFuture.supplyAsync(() -> { if (true) throw new RuntimeException("down"); return "ok"; }, pool) + .exceptionally(e -> "降级:" + e.getClass().getSimpleName()).join(); +CompletableFuture.supplyAsync(() -> { sleep(500); return "ok"; }, pool) + .orTimeout(100, TimeUnit.MILLISECONDS) + .exceptionally(e -> e.getClass().getSimpleName() + ", getCause()=" + e.getCause()).join(); + +// cancel(true) 是否中断任务 +CompletableFuture running = CompletableFuture.runAsync(() -> { + try { Thread.sleep(500); finished.set(true); } catch (InterruptedException e) { interrupted.set(true); } +}, pool); +sleep(100); +running.cancel(true); // 600 ms 后查看两个标志 +``` + +```text +thenApply 的执行线程:上一步已完成时在 main;未完成时在 biz-666 +get():ExecutionException(cause=IllegalStateException) +join():CompletionException(cause=IllegalStateException) +exceptionally 收到:CompletionException +orTimeout 后 exceptionally 收到:TimeoutException, getCause()=null +cancel(true) 返回 true,任务被中断 = false,任务照常执行完 = true +``` + +多次运行结果相同(biz- 后的编号每次不同)。任务中抛出的异常由 completeThrowable() 包装成 CompletionException,超时则直接 completeExceptionally(new TimeoutException());CompletableFuture 的 cancel() 文档写明 mayInterruptIfRunning 参数没有作用。 + +### A27 traceId 随任务传递 + +JDK 21.0.13,Apple M1 Pro。TRACE_ID 是普通 ThreadLocal,wrap() 为正文中的包装器,wrapRemove() 在 finally 中直接 remove();pool 只有一个线程 pool-1,之前一个没有清理的任务在其中留下了 t-old: + +```java +for (String id : new String[]{"t-alice", "t-bob"}) { + TRACE_ID.set(id); + pool.submit(() -> log("不包装:发送短信")).get(); + pool.submit(wrap(() -> log("包装:发送短信"))).get(); + CompletableFuture.runAsync(wrap(() -> log("包装:第一步")), pool) + .thenRunAsync(() -> log("commonPool:第二步")) + .thenRunAsync(wrap(() -> log("包装:第三步")), pool).join(); + TRACE_ID.remove(); +} +pool.submit(() -> log("任务结束后工作线程残留")).get(); + +TRACE_ID.set("t-carol"); +wrap(() -> {}).run(); // 调用者自己执行任务 +log("调用者自己执行 wrap 后"); +wrapRemove(() -> {}).run(); +log("调用者自己执行 wrapRemove 后"); +``` + +```text +[pool-1] traceId=t-old 不包装:发送短信 +[pool-1] traceId=t-alice 包装:发送短信 +[pool-1] traceId=t-alice 包装:第一步 +[ForkJoinPool.commonPool-worker-1] traceId=null commonPool:第二步 +[pool-1] traceId=t-alice 包装:第三步 +[pool-1] traceId=t-old 不包装:发送短信 +[pool-1] traceId=t-bob 包装:发送短信 +[pool-1] traceId=t-bob 包装:第一步 +[ForkJoinPool.commonPool-worker-1] traceId=null commonPool:第二步 +[pool-1] traceId=t-bob 包装:第三步 +[pool-1] traceId=t-old 任务结束后工作线程残留 +[main] traceId=t-carol 调用者自己执行 wrap 后 +[main] traceId=null 调用者自己执行 wrapRemove 后 +``` + +第三步的 wrap() 在 main 线程构造调用链时就执行了,所以捕获到的是请求的值;工作线程在任务结束后恢复为原来的 t-old。 + ## 参考资料 - [Object (Java SE 21)](https://docs.oracle.com/en/java/javase/21/docs/api/java.base/java/lang/Object.html) @@ -3176,6 +3311,8 @@ submit() 返回 FutureTask,get() = 3 - [assembler_x86.cpp | OpenJDK jdk21u](https://github.com/openjdk/jdk21u/blob/master/src/hotspot/cpu/x86/assembler_x86.cpp) - [TransmittableThreadLocal](https://github.com/alibaba/transmittable-thread-local) - [Thread (Java SE 21)](https://docs.oracle.com/en/java/javase/21/docs/api/java.base/java/lang/Thread.html) +- [TaskDecorator (Spring Framework)](https://docs.spring.io/spring-framework/docs/current/javadoc-api/org/springframework/core/task/TaskDecorator.html) +- [Logback Manual: Mapped Diagnostic Context](https://logback.qos.ch/manual/mdc.html) - [JDK 21 源码:jvm.cpp](https://github.com/openjdk/jdk21u/blob/master/src/hotspot/share/prims/jvm.cpp) - [Java SE 6 Performance White Paper](https://www.oracle.com/java/technologies/javase/6performance.html) - [biasedLocking.cpp | OpenJDK jdk8u](https://github.com/openjdk/jdk8u/blob/master/hotspot/src/share/vm/runtime/biasedLocking.cpp) diff --git "a/notes/Java \350\231\232\346\213\237\346\234\272.md" "b/notes/Java \350\231\232\346\213\237\346\234\272.md" index c86774fc2..37250a2b0 100644 --- "a/notes/Java \350\231\232\346\213\237\346\234\272.md" +++ "b/notes/Java \350\231\232\346\213\237\346\234\272.md" @@ -57,6 +57,7 @@ * [A19 GC 时间的占比](#a19-gc-时间的占比) * [A20 线程栈中的 CPU 时间](#a20-线程栈中的-cpu-时间) * [A21 堆以外的内存](#a21-堆以外的内存) + * [A22 容器中的默认堆](#a22-容器中的默认堆) * [参考资料](#参考资料) @@ -83,7 +84,7 @@

-可以通过 -Xss 这个虚拟机参数来指定每个线程的 Java 虚拟机栈内存大小,默认值与平台有关,x86-64 的 Linux 上为 1 MB,ARM64 的 Linux 和 macOS(Apple 芯片)上为 2 MB(-XX:+PrintFlagsFinal 中的 ThreadStackSize,见[附录 A1](#a1-默认的线程栈大小) 和[附录 A10](#a10-直接内存与线程数的溢出) 中的 stacksize): +可以通过 -Xss 这个虚拟机参数来指定每个线程的 Java 虚拟机栈内存大小,默认值与平台有关,x86-64 的 Linux 上为 1 MB,ARM64 的 Linux 上为 2040 KB,macOS(Apple 芯片)上为 2 MB(-XX:+PrintFlagsFinal 中的 ThreadStackSize,见[附录 A1](#a1-默认的线程栈大小) 和[附录 A10](#a10-直接内存与线程数的溢出) 中的 stacksize): ```bash java -Xss2M HackTheJava @@ -145,7 +146,7 @@ Class 文件中的常量池(编译器生成的字面量和符号引用)会 在 JDK 1.4 中新引入了 NIO 类,它可以使用 Native 函数库直接分配堆外内存,然后通过 Java 堆里的 DirectByteBuffer 对象作为这块内存的引用进行操作。这样能在一些场景中显著提高性能,因为避免了在堆内存和堆外内存来回拷贝数据。 -直接内存不属于 JVM 规范定义的运行时数据区域,但同样会耗尽:它的上限由 -XX:MaxDirectMemorySize 设置,没有设置时与最大堆相同,超出时抛出 OutOfMemoryError,见 [内存溢出与内存泄漏](#内存溢出与内存泄漏)。 +直接内存不属于 JVM 规范定义的运行时数据区域,但同样会耗尽:它的上限由 -XX:MaxDirectMemorySize 设置,没有设置时与最大堆相同,超出时抛出 OutOfMemoryError,见 [内存溢出与内存泄漏](#内存溢出与内存泄漏)。堆外内存不由 GC 直接回收,DirectByteBuffer 对象被回收后,由它关联的 Cleaner 释放对应的堆外内存。 ### 堆和栈的区别 @@ -403,7 +404,7 @@ STW 无法完全消除,但可以缩短:CMS、G1 把耗时最长的标记与 以上是 JDK 8 中 HotSpot 虚拟机的 7 个垃圾收集器,连线表示垃圾收集器可以配合使用。OpenJDK 21 中保留了 Serial、Serial Old、Parallel Scavenge、Parallel Old 和 G1,CMS 和 ParNew 已经移除,另外新增了 ZGC 和 Shenandoah 这两个以并发为主的收集器(Oracle JDK 不包含 Shenandoah)。 - 单线程与多线程:单线程指的是垃圾收集器只使用一个线程,而多线程使用多个线程,多个 GC 线程同时工作称为并行(Parallel); -- 串行与并发:串行指的是垃圾收集器与用户程序交替执行,这意味着在执行垃圾收集的时候需要停顿用户程序;并发指的是垃圾收集器和用户程序同时执行。图中除了 CMS 和 G1 之外,其它垃圾收集器都是以串行的方式执行。 +- 独占与并发:独占指的是垃圾收集时停顿用户程序(Stop The World),垃圾收集器与用户程序交替执行;并发指的是垃圾收集器和用户程序同时执行。图中除了 CMS 和 G1 之外,其它垃圾收集器的整个收集过程都需要停顿用户程序。 评价收集器主要看停顿时间、吞吐量和内存占用,三者难以兼顾。一般先用默认的 G1;批处理等只看吞吐量的任务用 Parallel;堆只有约 100 MB 以内或只有 1 个处理器时用 Serial;要求停顿在毫秒以下、堆又很大时用 ZGC。运行中的应用用的是哪个收集器,用 `jcmd VM.flags` 查看,输出中的 -XX:+UseG1GC 就是 G1(见[附录 A6](#a6-默认的收集器与-g1-的-region-大小))。 @@ -499,7 +500,7 @@ Region 不区分固定的用途,一个 Region 回收后可以再作为 Eden、 - 初始标记:老年代的占用超过阈值(-XX:InitiatingHeapOccupancyPercent,初始为 45%,之后 G1 根据标记所需的时间自适应调整)时,在下一次新生代回收中顺带完成(Concurrent Start),标记 GC Roots 直接关联的对象,需要停顿; - 并发标记:与用户线程同时运行,找出老年代中所有存活的对象,期间新生代回收照常进行; -- 最终标记(Remark):短暂停顿,处理并发标记期间用户线程修改引用留下的记录,完成标记,并直接回收完全空的 Region; +- 最终标记(Remark):短暂停顿,处理并发标记期间用户线程修改引用留下的记录,完成标记,再处理软、弱引用并卸载不再使用的类,直接回收完全空的 Region; - 清理与混合回收:清理(Cleanup)停顿根据各 Region 的回收价值决定是否进入回收阶段;之后的多次混合回收(Mixed)除了新生代,再挑选一部分垃圾最多的老年代 Region,把其中存活的对象复制出去,每次回收的数量受停顿时间目标(MaxGCPauseMillis,默认 200 ms)限制。 例如一个 128 MB 堆的程序,GC 日志中一个完整的周期依次是 Pause Young (Concurrent Start)、并发标记、Pause Remark、Pause Cleanup、Pause Young (Prepare Mixed) 和 Pause Young (Mixed)。整个标记周期从开始到 Cleanup 约 13.5 ms,期间还穿插了 4 次普通的新生代回收;Remark 和 Cleanup 的停顿分别只有约 0.1 ms 和 0.03 ms(JDK 21 实测,见[附录 A9](#a9-g1-的标记周期与安全点))。 @@ -573,7 +574,7 @@ CMS 和 G1 的并发标记与用户线程同时运行。标记过程用三种颜 - 增量更新(CMS):写入引用字段时,写屏障把对象所在的卡标记为脏,重新标记时重新扫描脏卡中的对象,order 被再次检查,从而找到 addr; - 原始快照(Snapshot At The Beginning, SATB,G1):删除或覆盖引用之前,写屏障把旧值记录下来并当作存活,执行 user.addr = null 时 addr 被记录下来。代价是标记期间才变成垃圾的对象要等下一个周期才能回收。 -所以重新标记(Remark)的停顿不能省略,并发标记期间记录下来的对象要在用户线程暂停时处理完。CMS 要重新扫描 GC Roots 和所有脏卡,用户线程修改的引用越多停顿越长;G1 只需处理 SATB 记录的对象,停顿通常更短。 +所以重新标记(Remark)的停顿不能省略,并发标记期间记录下来的对象要在用户线程暂停时处理完。CMS 要重新扫描 GC Roots 和所有脏卡,用户线程修改的引用越多停顿越长;G1 不必重新扫描脏卡,只需处理 SATB 记录的对象,再完成引用处理、类卸载等收尾工作,停顿通常更短。 ## 三、内存分配与回收策略 @@ -615,9 +616,9 @@ G1 的日志中三者分别记为 Pause Young (Normal)、Pause Young (Mixed) 和 #### 5. 空间分配担保 -在发生 Minor GC 之前,虚拟机先检查老年代最大可用的连续空间是否大于新生代所有对象总空间,如果条件成立的话,那么 Minor GC 可以确认是安全的。这是因为最坏的情况是新生代的对象全部存活,Survivor 放不下的都要进入老年代。 +这一步针对 Serial 和 Parallel。在发生 Minor GC 之前,虚拟机先检查老年代最大可用的连续空间是否大于新生代所有对象总空间,如果条件成立的话,那么 Minor GC 可以确认是安全的。这是因为最坏的情况是新生代的对象全部存活,Survivor 放不下的都要进入老年代。 -如果不成立,虚拟机会继续检查老年代最大可用的连续空间是否大于历次晋升到老年代对象的平均大小,如果大于就进行 Minor GC,否则进行 Full GC。JDK 6 Update 24 之前还需要 HandlePromotionFailure 参数允许担保失败才会冒险进行 Minor GC,之后该参数不再起作用。 +如果不成立,虚拟机会继续检查老年代最大可用的连续空间是否大于历次晋升到老年代对象的平均大小,如果大于就进行 Minor GC,否则进行 Full GC。JDK 6 Update 24 之前还需要 HandlePromotionFailure 参数允许担保失败才会冒险进行 Minor GC,之后该参数不再起作用。G1 没有这一步,复制时找不到空闲的 Region 就是 Evacuation Failure,见 [Full GC 的触发条件](#full-gc-的触发条件)。 ### Full GC 的触发条件 @@ -764,7 +765,7 @@ public static void main(String[] args) { 接口中不可以使用静态语句块,但仍然有类变量初始化的赋值操作,因此接口与类一样都会生成 <clinit\>() 方法。但接口与类不同的是,执行接口的 <clinit\>() 方法不需要先执行父接口的 <clinit\>() 方法。只有当父接口中定义的变量使用时,父接口才会初始化。另外,接口的实现类在初始化时一般也不会执行接口的 <clinit\>() 方法,例外是接口中声明了非 abstract、非 static 的方法(default 方法或 JDK 9 起的 private 方法)时,实现类初始化之前要先初始化这个接口。 -虚拟机会保证一个类的 <clinit\>() 方法在多线程环境下被正确的加锁和同步,如果多个线程同时初始化一个类,只会有一个线程执行这个类的 <clinit\>() 方法,其它线程都会阻塞等待,直到活动线程执行 <clinit\>() 方法完毕。如果在一个类的 <clinit\>() 方法中有耗时的操作,就可能造成多个线程阻塞,在实际过程中此种阻塞很隐蔽。 +虚拟机会保证一个类的 <clinit\>() 方法在多线程环境下被正确的加锁和同步,如果多个线程同时初始化一个类,只会有一个线程执行这个类的 <clinit\>() 方法,其它线程都会阻塞等待,直到活动线程执行 <clinit\>() 方法完毕。如果在一个类的 <clinit\>() 方法中有耗时的操作,就可能造成多个线程阻塞,在实际过程中此种阻塞很隐蔽。更隐蔽的是两个类的 <clinit\>() 互相用到对方、又被两个线程同时初始化时,两个线程会永久等待,jstack 中它们的状态是 RUNNABLE,显示 waiting on the Class initialization monitor,不会报告死锁(JDK 21 实测)。 ### 类初始化时机 @@ -776,7 +777,7 @@ public static void main(String[] args) { - 调用 Class.forName("Order"),或者使用 java.lang.reflect 包的方法对类进行反射调用的时候,如果类没有进行初始化,则需要先触发其初始化,而 Order.class 和 Class.forName("Order", false, loader) 只取得 Class 对象,不会触发,见 [反射](Java%20基础.md#七反射); - 当初始化一个类的时候,如果发现其父类还没有进行过初始化,则需要先触发其父类的初始化; - 当虚拟机启动时,用户需要指定一个要执行的主类(包含 main() 方法的那个类),虚拟机会先初始化这个主类; -- 当使用 JDK 1.7 的动态语言支持时,如果一个 java.lang.invoke.MethodHandle 实例最后的解析结果为 REF_getStatic、REF_putStatic、REF_invokeStatic 的方法句柄,并且这个方法句柄所对应的类没有进行过初始化,则需要先触发其初始化; +- 当使用 JDK 1.7 的动态语言支持时,如果一个 java.lang.invoke.MethodHandle 实例最后的解析结果为 REF_getStatic、REF_putStatic、REF_invokeStatic、REF_newInvokeSpecial 的方法句柄,并且这个方法句柄所对应的类没有进行过初始化,则在第一次调用它时需要先触发其初始化; - 当一个接口声明了非 abstract、非 static 的方法(default 方法或 JDK 9 起的 private 方法)时,如果它的实现类发生了初始化,要先初始化这个接口。例如 Impl 同时实现了带 default 方法的 WithDefault 和不带 default 方法的 NoDefault,new Impl() 时依次输出“WithDefault 初始化”“Impl 初始化”,NoDefault 没有被初始化(JDK 21 实测)。 #### 2. 被动引用 @@ -979,7 +980,7 @@ OutOfMemoryError 的详细信息指明了是哪一块内存不足,常见的有 以上几类要按各自的原因检查,下面是最常见的 Java heap space 的排查步骤: -1. 启动参数加上 -XX:+HeapDumpOnOutOfMemoryError,发生内存溢出时自动保存堆转储,用 -XX:HeapDumpPath 指定保存位置;进程还在运行时,可以用 jcmd GC.heap_dump <文件名> 手动导出; +1. 启动参数加上 -XX:+HeapDumpOnOutOfMemoryError,发生内存溢出时自动保存堆转储,用 -XX:HeapDumpPath 指定保存位置。它只对虚拟机报告的溢出(例如 Java heap space、Metaspace)生效,并且只转储第一次,直接内存和线程数的溢出不会生成堆转储;进程还在运行时,可以用 `jcmd GC.heap_dump <文件名>` 手动导出; 2. 用 Eclipse MAT 等工具打开堆转储,按 Retained Heap(对象被回收后能一起释放的内存)排序找到占用最多的对象,查看它们到 GC Roots 的引用链,确定是谁持有了这些对象; 3. 引用链指向不应该存活的对象(例如上面的静态集合)就是内存泄漏,需要修改代码;否则说明确实需要这么多内存,应该增大堆或者减少一次加载的数据量。 @@ -1041,7 +1042,11 @@ Heap dump file created [19154891 bytes in 0.007 secs] 堆太小时,日志中反复出现 Pause Full (G1 Compaction Pause),6 次 Full GC 后的占用从约 27 MB 涨到 53 MB:缓存越来越大,每次 Full GC 能腾出的空间越来越少,很快又满了。 -堆设多大,先看 Full GC 之后堆的占用,这是长期存活的数据量,堆要在它之上留出余量,余量越小 GC 越频繁;定下初值后压测,看停顿和 GC 时间的占比是否达标。-Xmx 也不能等于容器的内存,进程还有元空间、线程栈、直接内存、代码缓存和 GC 自己的数据结构,例如 4 GB 堆的 G1 进程,GC 的数据结构就提交了约 135 MB(JDK 21 实测,见[附录 A21](#a21-堆以外的内存)),总和超过容器的限制时进程会被 OOM Killer 杀掉。 +堆设多大,先看 Full GC 之后堆的占用,这是长期存活的数据量,堆要在它之上留出余量,余量越小 GC 越频繁;定下初值后压测,看停顿和 GC 时间的占比是否达标。-Xmx 也不能等于容器的内存,进程还有元空间、线程栈、直接内存、代码缓存和 GC 自己的数据结构,例如 4 GB 堆的 G1 进程,GC 的数据结构就提交了约 135 MB(JDK 21 实测,见[附录 A21](#a21-堆以外的内存))。 + +容器中,JVM 默认开启 -XX:+UseContainerSupport(JDK 10 起;cgroup v2 需要 JDK 15、11.0.16 或 8u372 起),把容器的内存限制当作物理内存,按 MaxRAMPercentage 算出最大堆,例如限制 1 GiB 时为 256 MiB。内存不到约 250 MiB 时改为取一半(MinRAMPercentage,默认 50%),调 MaxRAMPercentage 不起作用(JDK 21 实测,见[附录 A22](#a22-容器中的默认堆))。 + +常见做法是不写 -Xmx,用 MaxRAMPercentage 按比例设堆,但比例不能太高。例如限制 1 GiB、比例 75% 时堆最大 768 MiB,堆外只剩 256 MiB,要放下元空间、线程栈、代码缓存、GC 数据结构和直接内存;直接内存没有设上限时可以和堆一样大。例如堆用到 700 MiB、ByteBuffer.allocateDirect 申请的直接内存 300 MiB,再加上约 100 MiB 的其它堆外内存,合计约 1.1 GiB,堆还没到上限,进程已经超过了限制。这时 JVM 不会抛出 OutOfMemoryError,而是被内核的 OOM Killer 杀掉(退出码 137,见 [内存溢出与内存泄漏](#内存溢出与内存泄漏))。所以要用 -XX:MaxDirectMemorySize 给 NIO 的直接缓冲区设上限,压测时加 -XX:NativeMemoryTracking=summary,用 `jcmd VM.native_memory summary` 看各部分的占用(输出见[附录 A21](#a21-堆以外的内存)),再定堆的比例。 判断 GC 是不是瓶颈,看停顿时间占运行时间的比例和最长停顿。jstat -gcutil 5000 每 5 秒输出一次,GCT 列是累计的停顿秒数,两次之差除以间隔就是这段时间的占比。-Xlog:gc 日志中以 Pause 开头的行记录一次停顿,例如 Pause Young (Normal) (G1 Evacuation Pause) 24M->1M(64M) 0.542ms 依次是回收的类型、原因、回收前后堆的占用、堆的大小和停顿时长;Concurrent 开头的行是并发执行的耗时,不计入停顿。例如 64 MB 的堆中老年代已用 92%,5 秒内新生代回收了 1403 次,20% 的时间都在停顿(JDK 21 实测,见[附录 A19](#a19-gc-时间的占比))。 @@ -1675,6 +1680,29 @@ $ jcmd VM.native_memory summary scale=MB 直接内存记在 Other 中。macOS 上 NMT 把线程栈按 -Xss(2 MB)整段记为已提交,Linux 上只统计实际用到的页,数值小得多。这个程序几乎不加载类,元空间不到 1 MB,实际的服务加载的类多得多,元空间也大得多。 +### A22 容器中的默认堆 + +JDK 21.0.13,Apple M1 Pro。容器感知只在 Linux 上生效,这里用 -XX:MaxRAM 模拟 JVM 看到的内存:容器中 JVM 把内存限制当作物理内存,与 MaxRAM 进入同一段堆大小的计算。 + +```text +$ java -XX:+PrintFlagsFinal -version | grep RAMPercentage + double InitialRAMPercentage = 1.562500 + double MaxRAMPercentage = 25.000000 + double MinRAMPercentage = 50.000000 +$ java -XX:MaxRAM=1g -XX:+PrintFlagsFinal -version | grep ' MaxHeapSize ' + size_t MaxHeapSize = 268435456 +``` + +| JVM 看到的内存 | 默认的最大堆 | MaxRAMPercentage=75 | +| --- | --- | --- | +| 200 MiB | 104 MiB | 104 MiB | +| 256 MiB | 128 MiB | 192 MiB | +| 512 MiB | 128 MiB | 384 MiB | +| 1 GiB | 256 MiB | 768 MiB | +| 2 GiB | 512 MiB | 1536 MiB | + +256 MiB 和 512 MiB 都是 128 MiB:内存的一半不小于默认的最小值(约 125 MiB)时,最大堆取 25% 与这个最小值中较大的一个。 + ## 参考资料 - [Troubleshooting Memory Leaks (Java SE 21)](https://docs.oracle.com/en/java/javase/21/troubleshoot/troubleshooting-memory-leaks.html) diff --git "a/notes/Leetcode \351\242\230\350\247\243 - \344\272\214\345\210\206\346\237\245\346\211\276.md" "b/notes/Leetcode \351\242\230\350\247\243 - \344\272\214\345\210\206\346\237\245\346\211\276.md" index 4b9be940e..bcf3e7709 100644 --- "a/notes/Leetcode \351\242\230\350\247\243 - \344\272\214\345\210\206\346\237\245\346\211\276.md" +++ "b/notes/Leetcode \351\242\230\350\247\243 - \344\272\214\345\210\206\346\237\245\346\211\276.md" @@ -89,7 +89,7 @@ public int binarySearch(int[] nums, int key) { ```text nums = {0, 1, 2}, key = 1 l m h -0 1 2 nums[m] >= key +0 1 3 nums[m] >= key 0 0 1 nums[m] < key 1 1 1 nums[m] >= key 1 1 1 nums[m] >= key diff --git "a/notes/Leetcode \351\242\230\350\247\243 - \345\212\250\346\200\201\350\247\204\345\210\222.md" "b/notes/Leetcode \351\242\230\350\247\243 - \345\212\250\346\200\201\350\247\204\345\210\222.md" index 8118df1ba..a48f15a1c 100644 --- "a/notes/Leetcode \351\242\230\350\247\243 - \345\212\250\346\200\201\350\247\204\345\210\222.md" +++ "b/notes/Leetcode \351\242\230\350\247\243 - \345\212\250\346\200\201\350\247\204\345\210\222.md" @@ -579,16 +579,6 @@ public int lengthOfLIS(int[] nums) { } ``` -使用 Stream 求最大值会导致运行时间过长,可以改成以下形式: - -```java -int ret = 0; -for (int i = 0; i < n; i++) { - ret = Math.max(ret, dp[i]); -} -return ret; -``` - 以上解法的时间复杂度为 O(N2),可以使用二分查找将时间复杂度降低为 O(NlogN)。 定义一个 tails 数组,其中 tails[i] 存储所有长度为 i + 1 的递增子序列中最小的末尾元素。对于一个元素 x, diff --git "a/notes/Leetcode \351\242\230\350\247\243 - \346\216\222\345\272\217.md" "b/notes/Leetcode \351\242\230\350\247\243 - \346\216\222\345\272\217.md" index c574574c8..ce8b6ce84 100644 --- "a/notes/Leetcode \351\242\230\350\247\243 - \346\216\222\345\272\217.md" +++ "b/notes/Leetcode \351\242\230\350\247\243 - \346\216\222\345\272\217.md" @@ -65,7 +65,7 @@ public int findKthLargest(int[] nums, int k) { 快速选择:平均时间复杂度 O(N),空间复杂度 O(1)。 -第 k 大的元素就是升序排序后下标为 N - k 的元素。partition() 与快速排序的[切分](算法%20-%20排序.md#2-切分)相同,返回切分元素的最终位置 j,它左边的元素都不大于它,右边的都不小于它。j 等于目标下标时直接得到答案;j 小于目标下标时只需要在右半边继续切分,否则在左半边,每次只处理一边,平均下来总的比较次数是 N + N / 2 + N / 4 + ... ≈ 2N。 +第 k 大的元素就是升序排序后下标为 N - k 的元素。partition() 与快速排序的[切分](算法%20-%20排序.md#2-切分)相同,返回切分元素的最终位置 j,它左边的元素都不大于它,右边的都不小于它。j 等于目标下标时直接得到答案;j 小于目标下标时只需要在右半边继续切分,否则在左半边,每次只处理一边。每次恰好对半分时比较次数为 N + N / 2 + N / 4 + ... ≈ 2N;随机输入下找中位数平均约 3.4N,仍是线性的。 以 [3, 2, 1, 5, 6, 4]、k = 2 为例(为便于对照没有打乱),两轮切分就找到了答案: diff --git "a/notes/Leetcode \351\242\230\350\247\243 - \346\220\234\347\264\242.md" "b/notes/Leetcode \351\242\230\350\247\243 - \346\220\234\347\264\242.md" index 29600e69c..155a05f13 100644 --- "a/notes/Leetcode \351\242\230\350\247\243 - \346\220\234\347\264\242.md" +++ "b/notes/Leetcode \351\242\230\350\247\243 - \346\220\234\347\264\242.md" @@ -27,6 +27,7 @@ * [13. 分割字符串使得每个部分都是回文数](#13-分割字符串使得每个部分都是回文数) * [14. 数独](#14-数独) * [15. N 皇后](#15-n-皇后) + * [16. 括号生成](#16-括号生成) * [附录:实测](#附录实测) * [A1. 岛屿数量的递归深度](#a1-岛屿数量的递归深度) @@ -644,6 +645,8 @@ backtracking(当前选择): [岛屿数量](#2-矩阵中的连通分量数目)的标记表示格子已经处理过,不再撤销;[在矩阵中寻找字符串](#3-在矩阵中寻找字符串)的标记表示格子在当前路径上,换一条路径时要撤销。 +以 N 个互不相同的元素为例:排列有 N! 个,每个复制 O(N),总时间 O(N·N!);子集有 2N 个,总时间 O(N·2N)。这两类问题的额外空间是递归深度 O(N)(不计结果)。 + ### 1. 数字键盘组合 17\. Letter Combinations of a Phone Number (Medium) @@ -1400,6 +1403,51 @@ private void backtracking(int row) { } ``` +### 16. 括号生成 + +22\. Generate Parentheses (Medium) + +[Leetcode](https://leetcode.com/problems/generate-parentheses/description/) / [力扣](https://leetcode-cn.com/problems/generate-parentheses/description/) + +```text +Input: n = 3 +Output: ["((()))","(()())","(())()","()(())","()()()"] +``` + +从左到右逐个放括号,每一步只有两种选择,用两条规则剪枝: + +- 左括号用了不到 n 个时,才能放左括号; +- 右括号的个数少于左括号时,才能放右括号,这样任何前缀中右括号都不会多于左括号。 + +放满 2n 个字符时左右括号各 n 个,得到的就是合法的组合。例如 n = 2 时,第一个字符只能是左括号,第二个字符分出 "((" 和 "()" 两支,之后各自只剩一种放法,得到 "(())" 和 "()()"。 + +```java +public List generateParenthesis(int n) { + List combinations = new ArrayList<>(); + backtracking(new StringBuilder(), 0, 0, n, combinations); + return combinations; +} + +private void backtracking(StringBuilder prefix, int open, int close, int n, List combinations) { + if (prefix.length() == 2 * n) { + combinations.add(prefix.toString()); + return; + } + if (open < n) { + prefix.append('('); + backtracking(prefix, open + 1, close, n, combinations); + prefix.deleteCharAt(prefix.length() - 1); + } + if (close < open) { + prefix.append(')'); + backtracking(prefix, open, close + 1, n, combinations); + prefix.deleteCharAt(prefix.length() - 1); + } +} +``` + +满足这两条规则的前缀总能补成合法的组合,搜索不会走进死路。结果个数是卡特兰数 C(2n, n) / (n + 1),n = 3 时 5 个,n = 8 时 1430 个;每个结果长 2n,总时间 O(n·C(2n, n) / (n + 1)),约为 O(4n / √n)。 + ## 附录:实测 ### A1. 岛屿数量的递归深度 diff --git "a/notes/Leetcode \351\242\230\350\247\243 - \346\240\221.md" "b/notes/Leetcode \351\242\230\350\247\243 - \346\240\221.md" index 014a46074..8c7a67330 100644 --- "a/notes/Leetcode \351\242\230\350\247\243 - \346\240\221.md" +++ "b/notes/Leetcode \351\242\230\350\247\243 - \346\240\221.md" @@ -906,6 +906,8 @@ public TreeNode lowestCommonAncestor(TreeNode root, TreeNode p, TreeNode q) { } ``` +每个节点最多访问一次,时间复杂度 O(N);递归深度最多为树的高度 H,空间复杂度 O(H),树退化成链时最坏为 O(N)。 + ### 6. 从有序数组中构造二叉查找树 108\. Convert Sorted Array to Binary Search Tree (Easy) diff --git a/notes/Linux.md b/notes/Linux.md index 433d8a5b9..18f41ef74 100644 --- a/notes/Linux.md +++ b/notes/Linux.md @@ -1276,7 +1276,7 @@ $ ps -eo pid,ppid,stat,comm | awk '$3 ~ /Z/' ### CPU 占用高 -top 的前几行是整机概况。load average 是最近 1、5、15 分钟内处于可运行(R)或等待磁盘 I/O(D)状态的平均任务数,持续高于 CPU 核数说明有任务在排队。%Cpu(s) 一行中 us 是用户态时间占比,sy 是内核态,wa 是 CPU 空闲但有 I/O 未完成的时间占比,id 是空闲:us 高多是应用自身在计算;wa 高时还要用 iostat 看磁盘的延迟和队列,不能直接断定瓶颈在磁盘。 +top 的前几行是整机概况。load average 是最近 1、5、15 分钟内处于可运行(R)或等待磁盘 I/O(D)状态的平均任务数,持续高于 CPU 核数说明有任务在排队;负载高而 CPU 使用率不高时,多是大量线程处于 D 状态在等待 I/O(见 [Java 虚拟机 - CPU 占用过高](Java%20虚拟机.md#cpu-占用过高))。%Cpu(s) 一行中 us 是用户态时间占比,sy 是内核态,wa 是 CPU 空闲但有 I/O 未完成的时间占比,id 是空闲:us 高多是应用自身在计算;wa 高时还要用 iostat 看磁盘的延迟和队列,不能直接断定瓶颈在磁盘。 先用 top 找到占用 CPU 最多的进程,再用 top -H 查看这个进程中的线程: diff --git a/notes/MySQL.md b/notes/MySQL.md index a8bb4366e..525c9d035 100644 --- a/notes/MySQL.md +++ b/notes/MySQL.md @@ -307,7 +307,7 @@ WHERE user_id = 5 AND status > 0 AND create_time > '2025-01-05' -- key_len = 5 另外,范围条件带等号(>=、<=、BETWEEN)时,后面的列还能参与确定扫描的起点和终点。例如 user_id BETWEEN 1001 AND 1006 AND status = 2 的 key_len 为 5,关闭索引下推后读了 100 个索引项;改为 user_id > 1000 AND user_id < 1007 后 key_len 为 4,要读 120 个(见[附录 A6](#a6-联合索引的扫描范围))。起点和终点之间的索引项仍然要逐条判断 status。 -需要注意的是,是否使用索引是优化器按成本估算决定的。走二级索引的每一行都要回表,匹配的行越多回表越多,超过一定比例后全表扫描反而更快:`create_time > '2025-01-23'` 匹配 9.9 万行,走 idx_create_time;`create_time > '2025-01-02'` 匹配 191 万行,就选择了全表扫描。IN 也一样,`user_id IN` 5 个值、1 万个值时都走索引,2 万个值时就改为全表扫描。因此同一种写法有时走索引有时不走,可能是因为匹配的数据量不同,也可能是统计信息不准(见[分析原因](#2-分析原因))。 +需要注意的是,是否使用索引是优化器按成本估算决定的。走二级索引的每一行都要回表,匹配的行越多回表越多,超过一定比例后全表扫描反而更快。这个比例并不固定(早期版本按 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-分析原因))。 ## 二、查询性能优化 🔥🔥 @@ -331,7 +331,7 @@ Explain 用来查看优化器选定的执行计划,语句本身并不执行, - id 和 table:每张表一行,有子查询、UNION 时 id 不同;连接查询中 id 相同的几行按 MySQL 读取的顺序排列,上面的是驱动表; - select_type:查询类型,有简单查询、联合查询、子查询等; -- type:访问类型,从好到差常见的有 system、const、eq_ref、ref、range、index、ALL。system 表示表中只有一行(InnoDB 表上一般见不到),const 表示通过主键或唯一索引最多匹配一行,eq_ref 表示连接时用被驱动表的主键或唯一索引匹配,ref 表示通过普通索引等值匹配,range 表示索引范围扫描,index 表示扫描整个索引树,ALL 表示全表扫描; +- type:访问类型,从好到差常见的有 system、const、eq_ref、ref、range、index、ALL。system 表示表中只有一行(InnoDB 表上一般见不到),const 表示通过主键或唯一索引最多匹配一行,eq_ref 表示连接时用被驱动表的主键或唯一索引匹配,ref 表示通过普通索引等值匹配,range 表示索引范围扫描,index 表示按索引顺序扫描整个索引树(扫描的是二级索引、Extra 中又没有 Using index 时,每一行还要回表,可能比 ALL 更慢),ALL 表示全表扫描; - possible_keys 和 key:可以使用的索引和实际使用的索引。possible_keys 中有索引而 key 为 NULL,说明优化器认为走索引的代价更高; - key_len:使用的索引长度,联合索引可以据此算出用到了前几列,见 [索引失效](#索引失效)。可以为 NULL 的列多 1 字节,变长的列多 2 字节,例如可以为 NULL 的 utf8mb4 列 order_no VARCHAR(32),key_len 为 32 × 4 + 2 + 1 = 131; - ref:与索引比较的是常量(const)还是其它表的列; @@ -401,7 +401,7 @@ Count: 2 Time=0.70s (1s) Lock=0.00s (0s) Rows=0.5 (1), root[root]@localhost - 没有走索引,或者只用到了联合索引的一部分:见 [索引失效](#索引失效); - 走了索引但回表太多:改为覆盖索引,或者用 [深分页](#深分页) 中的延迟关联; -- 出现 Using filesort、Using temporary:让排序和分组的列利用索引的顺序,见 [索引列的顺序](#3-索引列的顺序); +- 出现 Using filesort、Using temporary:让排序和分组的列利用索引的顺序,见 [索引列的顺序](#3-索引列的顺序)。filesort 在排序缓冲中排序,缓冲最大为 sort_buffer_size(默认 256 KB),放不下时分块排序后写入临时文件再归并,所以先让索引满足排序,再考虑调大这个参数; - 连接查询中被驱动表的连接列没有索引:外层的每一行都要扫描一遍被驱动表,应在连接列上建索引;MySQL 8.0.18 起这种情况改用哈希连接,但仍要读完整张被驱动表; - Lock_time 大,或者同一条语句的耗时忽长忽短:通常是在等待行锁或元数据锁,见 [MySQL 中的锁](数据库系统原理.md#mysql-中的锁); - 统计信息不准导致选错了索引:执行 ANALYZE TABLE 更新统计信息,必要时用 FORCE INDEX 指定索引; @@ -499,6 +499,8 @@ LIMIT 10; 排序列的值可能重复,所以要再按主键排序,并把主键也作为游标的一部分,否则时间相同的行会被跳过或者重复返回。游标分页的代价是不能直接跳到任意一页,只能逐页翻,适合 App 下拉加载、按时间浏览记录这类场景。必须跳页的场景用延迟关联,并限制翻页的深度,例如只允许翻到前 100 页。 +需要注意的是,条件写成行构造器 (create_time, id) > ('2025-01-12 13:46:39', 982321) 时结果相同,但 MySQL 8.0 不对它做范围扫描:type 为 index,从索引开头逐行回表,读了 100 万行才找到这 10 行,耗时约 7 s(见[附录 A5](#a5-深分页的耗时))。 + 三种写法返回的 10 行相同,读取的数据和耗时如下(MySQL 8.0,数据全部在内存中,取 5 次中最快的一次,见[附录 A5](#a5-深分页的耗时)),作为对比,LIMIT 0, 10 只需 0.05 ms:

@@ -938,6 +940,15 @@ COUNT(*) 130.38 ms 按 (create_time, id) 排序后第 1000000 行为 id = 982321、create_time = '2025-01-12 13:46:39',这就是游标分页示例中使用的游标。 +游标条件改用行构造器时(MySQL 8.0.44,执行 5 次)返回的 10 个 id 相同,执行计划与耗时如下: + +```text +WHERE (create_time, id) > ('2025-01-12 13:46:39', 982321) 7156 ms +EXPLAIN: type=index, key=idx_create_time, Extra=Using where +EXPLAIN ANALYZE: Index scan on orders using idx_create_time (actual rows=1e+6) +OR 写法: type=range, Index range scan on orders using idx_create_time (actual rows=10) +``` + ### A6 联合索引的扫描范围 orders 表中 user_id 有 10 万个不同的值,每个用户 20 行,同一个用户的订单 status 相同(user_id % 4)。 diff --git a/notes/Redis.md b/notes/Redis.md index 1387a75f7..5807861db 100644 --- a/notes/Redis.md +++ b/notes/Redis.md @@ -736,9 +736,9 @@ Redis Cluster 不需要哨兵,节点之间通过集群总线(端口号加 10 1. 其它节点超过 cluster-node-timeout 没有收到 7002 的回复,各自把它标记为 PFAIL(疑似下线); 2. 某个节点通过 Gossip 得知多数主节点都认为 7002 疑似下线,把它标记为 FAIL 并广播; 3. 7002 的从节点按复制偏移量排名,数据最新的等待最短(500~1000 毫秒加上排名 × 1 秒),最先发起选举; -4. 从节点把纪元加 1 后请求投票,每个主节点在一个纪元中只投一票,得到多数主节点的票后接管 7002 的槽。 +4. 从节点把纪元加 1 后请求投票,每个主节点在一个纪元中只投一票,得到多数主节点的票后接管 7002 的槽。所以集群至少要有 3 个主节点,只有 2 个时一个宕机,剩下的一个得不到多数票。 -需要注意的是,主从之间是异步复制,主节点宕机前刚写入、还没有复制到从节点的数据会丢失,所以 Redis Cluster 不保证[强一致性](分布式.md#一致性)。网络分区时,少数派一侧的主节点超过 cluster-node-timeout 后拒绝写入,所以它在这之前接受的写入可能丢失。 +需要注意的是,主从之间是异步复制,主节点宕机前刚写入、还没有复制到从节点的数据会丢失,所以 Redis Cluster 不保证[强一致性](分布式.md#一致性)。网络分区时,少数派一侧的主节点超过 cluster-node-timeout 后拒绝写入,所以它在这之前接受的写入可能丢失;默认配置下读请求也会被拒绝(cluster-allow-reads-when-down 默认为 no)。 扩容时要把一部分槽迁移到新节点。迁移以键为单位进行,期间一个槽的键一部分在源节点、一部分在目标节点,客户端靠 ASK 重定向找到它们。以把槽 5712 从 7002 迁移到 7003 为例(Redis 7.4 实测,见[附录 A13](#a13-槽迁移与-ask-重定向)): diff --git a/notes/pics/agent-cs-design.png b/notes/pics/agent-cs-design.png new file mode 100644 index 000000000..8916fa974 Binary files /dev/null and b/notes/pics/agent-cs-design.png differ diff --git a/notes/pics/order-close-race.png b/notes/pics/order-close-race.png new file mode 100644 index 000000000..b8be4b096 Binary files /dev/null and b/notes/pics/order-close-race.png differ diff --git a/notes/pics/order-state-machine.png b/notes/pics/order-state-machine.png new file mode 100644 index 000000000..84c77390d Binary files /dev/null and b/notes/pics/order-state-machine.png differ diff --git "a/notes/\345\210\206\345\270\203\345\274\217.md" "b/notes/\345\210\206\345\270\203\345\274\217.md" index 996d5fea4..01dfa3ddb 100644 --- "a/notes/\345\210\206\345\270\203\345\274\217.md" +++ "b/notes/\345\210\206\345\270\203\345\274\217.md" @@ -91,7 +91,7 @@ COMMIT; SETNX(SET if Not eXists)命令在键不存在时写入并返回 1,键已经存在时什么也不做并返回 0。它和数据库的唯一索引类似,可以用这个键是否存在来判断是否处于锁定状态。 -EXPIRE 命令可以为键设置过期时间,从而避免了释放锁失败的问题。但是 SETNX 和 EXPIRE 是两条命令,不具有原子性,如果执行完 SETNX 之后进程崩溃,那么这个锁就永远不会过期。因此应该使用 SET 命令的 NX 和 PX 选项一次性完成加锁和设置过期时间: +EXPIRE 命令可以为键设置过期时间,从而避免了释放锁失败的问题。但是 SETNX 和 EXPIRE 是两条命令,不具有原子性,如果执行完 SETNX 之后进程崩溃,那么这个锁就永远不会过期。因此应该使用 SET 命令的 NX 和 PX 选项一次性完成加锁和设置过期时间(Redis 2.6.12 起官方也把 SETNX 视为弃用): ```text SET lock_key unique_value NX PX 30000 @@ -159,6 +159,8 @@ UPDATE inventory SET stock = 97, fence = 8 WHERE sku_id = 10086 AND fence <= 8; RedLock 使用的随机值不是递增的,算法本身无法提供栅栏令牌;ZooKeeper 则可以使用加锁时创建的有序节点的序号或者事务 ID(zxid),它们都是单调递增的。 +Redisson 已把 RedLock 标为弃用,改为推荐 RLock 加上主从同步检查;需要令牌时用 RFencedLock,它在加锁时对一个计数键执行 INCR,返回值作为令牌。这个计数键和锁在同一个主节点上,主从复制是异步的,故障转移后计数可能回退,令牌不再严格递增,所以它同样不能完全排除旧持有者的写入。 + ### ZooKeeper 的有序节点 #### 1. ZooKeeper 抽象模型 @@ -318,12 +320,14 @@ Saga 没有预留资源的 Try 阶段,中间状态对其它事务可见,因

-实践中常在事务提交后立即异步发送一次,定时任务只转发超过一定时间仍未发送的消息,作为兜底;已发送的消息定期删除或归档。 +实践中常在事务提交后立即异步发送一次,定时任务只转发超过一定时间仍未发送的消息,作为兜底;已发送的消息定期删除或归档。消息表很大、扫描开销高时,也可以用 Canal 等工具读取 binlog,消息一写入就转发,但同样可能重复发送。 由于重新转发会导致消息被重复投递,积分服务要按订单号加事件类型去重,实现幂等,做法见 [接收端的可靠性](消息队列.md#接收端的可靠性)。订单服务的事务已经提交,无法回滚,积分服务执行失败时只能不断重试或者人工介入处理。所以它适合发券、加积分这类能接受最终一致、失败后可以重试的后续操作。 RocketMQ 的事务消息把本地消息表的工作交给了消息队列:生产者先发送一条对消费者不可见的半消息,再执行本地事务,然后根据执行结果通知 Broker 提交或者回滚这条消息。如果 Broker 长时间没有收到通知,会主动回查生产者的本地事务状态。 +先发半消息,是为了先确认 Broker 可用,并让 Broker 一直持有这条消息:之后生产者无论在哪一步崩溃,Broker 都能通过回查得到结果。回查返回未知时会继续回查,默认最多 15 次,超过后半消息被转入系统主题 TRANS_CHECK_MAX_TIME_TOPIC,不再投递,需要人工处理。 +

### 几种做法的对比 @@ -530,7 +534,7 @@ Raft 与 Paxos 达成的结果是等价的,区别在于对 Leader 的依赖程 - Paxos 中任何一个 Proposer 都可以发起提议,选出一个主 Proposer 只是性能上的优化(也用来避免上文的活锁)。Multi-Paxos 中日志的每个位置各自独立地确定,新的主节点还要先找回自己缺少的日志; - Raft 中选主是协议的一部分,只有 Leader 能追加日志,日志只从 Leader 流向 Follower,投票规则又保证当选的节点包含所有已提交的日志,新 Leader 不需要从其它节点补日志,因此更容易理解和实现。代价是所有写入都要经过 Leader,选举期间不能写入。 -ZooKeeper 使用的 ZAB 协议同样以 Leader 为中心,纪元编号(epoch)的作用相当于 Raft 的任期号。 +ZooKeeper 使用的 ZAB 协议同样以 Leader 为中心,纪元编号(epoch)的作用相当于 Raft 的任期号。不同的是恢复方式:ZAB 选出见过最大 zxid 的节点当 Leader,新 Leader 先同步日志,使包括自己在内的大多数节点状态一致,再开始处理新的写请求;Raft 不设单独的同步阶段,靠追加日志时的一致性检查边复制边覆盖。ZAB 还假设节点之间是 TCP 这样的先进先出通道。 ## 七、RPC 🔥 diff --git "a/notes/\345\244\247\346\250\241\345\236\213\345\272\224\347\224\250.md" "b/notes/\345\244\247\346\250\241\345\236\213\345\272\224\347\224\250.md" index 7721fbb8c..ae72e83d0 100644 --- "a/notes/\345\244\247\346\250\241\345\236\213\345\272\224\347\224\250.md" +++ "b/notes/\345\244\247\346\250\241\345\236\213\345\272\224\347\224\250.md" @@ -53,6 +53,7 @@ * [推理服务部署](#推理服务部署) * [稳定性](#稳定性) * [安全](#安全) + * [设计一个客服 Agent](#设计一个客服-agent) * [附录:实测](#附录实测) * [A1 Embedding 相似度与 BM25](#a1-embedding-相似度与-bm25) * [A2 提示词对工具调用的影响](#a2-提示词对工具调用的影响) @@ -131,7 +132,7 @@ Token 数决定了两件事: 2. 向量依次经过几十个结构相同的层,Qwen2.5-7B 有 28 层。每层先用注意力从前文收集信息,再用前馈网络(Feed-Forward Network, FFN)对每个位置单独加工; 3. 最后一个位置的向量经过线性层,得到词表中每个 Token 的分数(logits),再经过 softmax 转为概率。 -每个子层都带有残差连接和归一化。残差连接把子层的输入直接加到输出上,使几十层的网络也能稳定训练;归一化把每个向量的数值缩放到稳定的范围。最初的 Transformer 在残差相加之后做归一化,目前的大模型大多改为在每个子层之前做(Pre-Norm),残差的主干上不再经过归一化,几十层叠加时训练更稳定,并使用只按均方根缩放、计算更简单的 RMSNorm,Qwen2 系列就是这样。 +每个子层都带有残差连接和归一化。残差连接把子层的输入直接加到输出上,使几十层的网络也能稳定训练;归一化把每个向量的数值缩放到稳定的范围。这里用的是 LayerNorm 一类的方法,只用一个 Token 自己向量的各个维度计算统计量,而不像 BatchNorm 那样依赖同一批中的其它样本,所以不受批大小和序列长度影响,训练和推理时的计算也相同。最初的 Transformer 在残差相加之后做归一化,目前的大模型大多改为在每个子层之前做(Pre-Norm),残差的主干上不再经过归一化,几十层叠加时训练更稳定,并使用只按均方根缩放、计算更简单的 RMSNorm,Qwen2 系列就是这样。 三种结构的注意力有两处不同。编码器中每个 Token 都能看到全部 Token;解码器的自注意力带有因果掩码,只能看到前面的 Token,见下文的注意力机制。编码器-解码器结构的解码器每层还多一个交叉注意力(Cross-Attention):Q 来自解码器,K 和 V 来自编码器最后一层的输出,生成每个词时都可以查阅整句原文。 @@ -225,7 +226,7 @@ Q = x W_Q (n, 3584) 降低这部分开销有两类做法。一类让每个 Token 只看一部分 Token,或者改写 softmax,结果与标准注意力不同:例如 Mistral 7B 的滑动窗口注意力中,每层的每个 Token 只看前 4096 个 Token;线性注意力用核函数代替 softmax,利用矩阵乘法的结合律把复杂度降为 O(n)。另一类结果不变,只减少显存的读写,即 FlashAttention。 -FlashAttention 把计算分块放进 GPU 的片上缓存完成,n × n 的分数矩阵从不写回显存,再用在线 softmax 逐块累积,结果与标准注意力完全相同。它减少的是显存的读写而不是计算量,原理和数值例子见[进阶:计算量与 FlashAttention](大模型应用%20-%20进阶.md#2-计算量与-flashattention)。 +FlashAttention 把计算分块放进 GPU 的片上缓存完成,n × n 的分数矩阵从不写回显存,再用在线 softmax 逐块累积,结果在数学上与标准注意力相同,不是近似。它减少的是显存的读写而不是计算量,原理和数值例子见[进阶:计算量与 FlashAttention](大模型应用%20-%20进阶.md#2-计算量与-flashattention)。 ### 自回归生成与 KV Cache @@ -1800,7 +1801,7 @@ Skill 与系统提示词、Agent 的区别: Skill 中的规范只能引导,不能保证模型一定遵守,必须满足的规则还要用脚本或钩子检查,见 [Harness 的引导与反馈](#2-引导与反馈)。模型也不一定会加载 Skill,描述不够贴切时可能直接动手;部署、提交这类有副作用、必须按流程走的 Skill,可以设为只允许用户用斜杠命令(例如 /deploy)显式调用,Claude Code 中在 frontmatter 写 disable-model-invocation: true,它的描述也不再放进目录。 -Agent 是否使用某个 Skill,完全取决于它的 description,所以要像测试[意图识别与路由](#意图识别与路由)一样测试它: +除了用户用斜杠命令直接调用,模型自动选用 Skill 时,是根据目录中的名称和 description 判断它与当前任务是否相关,所以要像测试[意图识别与路由](#意图识别与路由)一样测试它: - 触发测试:准备约 20 条真实的提问,一半应该触发、一半不应该触发。不应该触发的重点选关键词相近但需求不同的,例如“把周报模板翻译成英文”不应该触发生成周报的 Skill。每条运行 3 次左右计算触发率,并留出一部分提问不参与修改,检查改进是否适用于新的说法; - 效果测试:同一批任务分别在有和没有这个 Skill(或者使用旧版本)的情况下运行,能用代码判断的项由脚本检查,其余由人或模型裁判比较,同时记录 Token 消耗和耗时。 @@ -2537,6 +2538,59 @@ OpenAI 的官方 SDK 默认也是这样处理的:对 408、409、429 和 5xx 每次工具调用都记录审计日志:谁发起的任务、调用了哪些工具、参数和结果是什么、谁确认了操作,出了问题可以追溯。 +### 设计一个客服 Agent + +“设计一个智能客服 Agent”这类题要把前面的机制组合成一个能上线的系统,下面仍以前文的电商客服为例,通用的答题顺序见[系统设计题的思路](系统设计基础.md#十三系统设计题的思路)。 + +#### 1. 需求和约束 + +先和面试官确认范围。假设要做的是电商 App 内的售后客服: + +- 功能:查询订单和物流,回答退换货政策和商品问题,办理退款和改地址,处理不了的创建工单或转人工; +- 规模:平时每天 10 万次咨询,大促期间约为平时的 10 倍(假设); +- 体验:用户发出消息后很快能看到进展,不能对着空白等完多轮调用; +- 安全:用户只能查自己的订单,退款金额不能超过实付金额,资金操作必须由用户确认。 + +按高峰小时占全天流量的 6% 计算,平时高峰约 10 万 × 6% ÷ 3600 ≈ 1.7 次咨询/秒,大促约 17 次/秒。每次咨询按前文的例子调用 3 次模型,假设共约 6 秒,由利特尔法则,大促高峰同时在进行的咨询约 17 × 6 ≈ 100 个。模型服务的并发和 Token 限额要按这个量申请或部署,见[大模型服务的高并发](系统设计基础.md#大模型服务的高并发)。 + +多轮调用让首个字出现得晚,所以要把“正在查询订单”这类进展推给前端,见[推送 Agent 的执行过程](#2-推送-agent-的执行过程)。成本也要在设计时估算:按[一次 Agent 任务的费用](#2-一次-agent-任务的费用)中的例子,每天 10 万次对话约 82~270 元,差别在于前缀缓存是否命中;实际的提示词和工具结果更长,费用会成倍增加。 + +#### 2. 架构 + +

+ +- 接入层:从登录会话中取出 user_id,工具都用它鉴权,不作为参数让模型填写,见[限制模型能造成的后果](#2-限制模型能造成的后果); +- 意图路由:退款、改地址这类涉及资金、步骤固定的请求交给工作流,咨询类交给 Agent,置信度低时反问澄清,见[意图识别与路由](#意图识别与路由); +- 工作流:退款由代码按校验、生成申请、用户确认、执行的顺序处理,模型只负责从对话中提取订单号和原因,见 [Agent 与工作流](#agent-与工作流); +- Agent:一个 ReAct 循环,工具有查询订单、查询物流、检索知识库和创建工单,限制最大步数; +- 知识库:退换货政策和商品资料切片后建立索引,用混合检索加重排,见[检索增强生成](#三检索增强生成); +- 短期记忆:摘要、最近几轮和任务状态(例如当前在处理的订单)存放在 Redis 中,见[短期记忆](#1-短期记忆)。 + +长期记忆在这个场景中要克制。订单、地址、退款进度以业务系统为准,每次用工具实时查询,不写进记忆,否则记忆中的旧地址会和数据库冲突;值得跨会话保存的只有少量偏好,例如“希望下午送货”,写入的方法见[长期记忆的写入](#2-长期记忆的写入)。 + +#### 3. 关键取舍 + +用框架还是自研。Agent 循环本身不到二十行,工作量在业务工具、权限、上下文组装和评测上,这些框架替代不了。Java 项目可以用 Spring AI 统一模型接口和注册工具,并关闭自动执行,由应用控制每一轮:每次发给模型的消息完全可控,便于保持前缀稳定以提高缓存命中率、在执行前校验权限和记录审计日志。退款流程要暂停等待用户确认、中断后能恢复,状态存在数据库中即可;分支和人工节点多起来时,再换 LangGraph 这类状态图框架,见[怎样选择](#4-怎样选择)。 + +单 Agent 还是多 Agent。在线客服对延迟和成本敏感,查订单和查物流前后依赖,单个 Agent 加路由就够了,见[适用场景](#3-适用场景)。退款单独成流程主要是为了隔离权限:咨询 Agent 没有退款工具,即使被提示词注入也发起不了退款。转人工相当于一次转交(Handoff),把摘要和任务状态一起交给人工客服,用户不必重复描述。 + +上下文怎么管理。每次调用按规则和工具定义、摘要、最近几轮、任务状态、本轮工具结果的顺序组装:不变的部分放在最前面,提高前缀缓存的命中率;工具结果只保留模型需要的字段,例如物流只返回最新的几条轨迹;历史超出预算时压缩成摘要,见[上下文工程](#上下文工程)。 + +做成工具还是 Skill。要查询数据或产生副作用的能力做成工具,例如查订单、建工单;只说明怎么处理一类问题的内容,例如价保的计算规则、换货的话术,按意图放进对应的提示词。这类说明多到每次都放进去太占上下文时,再像 [Skills](#skills) 那样平时只放名称和描述,需要时读取正文。订单查询要同时给客服和内部运营的助手使用时,可以做成 [MCP](#mcp) 服务端,一次实现、多处接入。 + +#### 4. 上线 + +上线前先有评测集:从历史客服会话中挑出几十条真实任务,按意图分层,既有应该退款的,也有超过期限、应该拒绝的;退款这类写操作检查数据库的最终状态,而不是 Agent 回复了什么,查询和问答检查回复是否与工具结果、知识库一致;路由的准确率单独评测,见[评测](#2-评测)。 + +权限逐步放开。先只上线查询类,退款类先由 Agent 生成处理建议、人工客服确认后执行;小流量灰度时对比转人工率和满意度,没有变差再扩大范围。 + +监控与成本: + +- 记录每一步的模型调用和工具调用,监控任务完成率、转人工率、平均步数、触发最大步数的次数、TTFT 和每次咨询的 Token,见[评测集与线上指标](#4-评测集与线上指标)和[可观测](#5-可观测); +- 查物流这类简单问题交给更小的模型,限制步数和输出长度,保持前缀稳定以提高缓存命中率,见[减少 Token 消耗](#4-减少-token-消耗)。 + +降级按层设计:主模型限流或出错时切到备用模型,见[限流、降级与熔断](#3-限流降级与熔断);备用模型也不可用时,咨询类退回关键词规则和常见问题的固定答案,再转人工;退款的校验、确认和执行不依赖模型,用户仍可以从订单页直接申请。大促前按估算的并发压测一次,确认限流和降级能按预期触发。 + ## 附录:实测 以下实验的环境为 Apple M1 Pro(32 GB)、macOS,PyTorch 与 Hugging Face Transformers,模型从 Hugging Face 下载到本地。 diff --git "a/notes/\345\255\246\344\271\240\350\267\257\347\272\277 - Java \345\220\216\347\253\257.md" "b/notes/\345\255\246\344\271\240\350\267\257\347\272\277 - Java \345\220\216\347\253\257.md" index b9b5a65c1..dd3f69eff 100644 --- "a/notes/\345\255\246\344\271\240\350\267\257\347\272\277 - Java \345\220\216\347\253\257.md" +++ "b/notes/\345\255\246\344\271\240\350\267\257\347\272\277 - Java \345\220\216\347\253\257.md" @@ -81,6 +81,7 @@ * [第 64 天:Docker](#第-64-天docker) * [第 65 天:Kubernetes](#第-65-天kubernetes) * [第 66 天:系统设计题的思路与即时通讯](#第-66-天系统设计题的思路与即时通讯) + * [第 67 天:订单与支付](#第-67-天订单与支付) @@ -180,9 +181,10 @@ | [61](#第-61-天秒杀与抢红包) | 秒杀与抢红包 | 系统设计基础:秒杀系统 | 76. 最小覆盖子串 | | [62](#第-62-天海量数据短链与-feed-流) | 海量数据、短链与 Feed 流 | 系统设计基础:海量数据处理~Feed 流 | 25. K 个一组翻转链表 | | [63](#第-63-天高并发系统的设计) | 高并发系统的设计 | 系统设计基础:高并发系统的设计 | 23. 合并 K 个升序链表 | -| [64](#第-64-天docker) | Docker | Docker:解决的问题~镜像与容器 | 4. 寻找两个正序数组的中位数 | -| [65](#第-65-天kubernetes) | Kubernetes | Docker:Kubernetes | 124. 二叉树中的最大路径和 | -| [66](#第-66-天系统设计题的思路与即时通讯) | 系统设计题的思路与即时通讯 | 系统设计基础:系统设计题的思路~即时通讯 | 72. 编辑距离 | +| [64](#第-64-天docker) | Docker | Docker:解决的问题~镜像与容器 | 32. 最长有效括号 | +| [65](#第-65-天kubernetes) | Kubernetes | Docker:Kubernetes | 4. 寻找两个正序数组的中位数 | +| [66](#第-66-天系统设计题的思路与即时通讯) | 系统设计题的思路与即时通讯 | 系统设计基础:系统设计题的思路~即时通讯 | 124. 二叉树中的最大路径和 | +| [67](#第-67-天订单与支付) | 订单与支付 | 系统设计基础:订单与支付 | 72. 编辑距离 | ## 四、Java 基础 @@ -1636,7 +1638,7 @@ Web 页面请求过程把前几天的内容串了起来。攻击技术中 XSS、 预期:maps 中 r-xp 的可执行文件区域是代码段,之后 rw-p 的是数据段和 BSS,[heap] 紧随其后,1 MB 的 malloc 和共享库在中间的映射区,[stack] 在最高处,再运行一次起始地址都变,上下顺序不变。 -算法(回溯与搜索,中等):[22. 括号生成](https://leetcode.cn/problems/generate-parentheses/)。 +算法(回溯与搜索,中等):[22. 括号生成](https://leetcode.cn/problems/generate-parentheses/),题解见[括号生成](Leetcode%20题解%20-%20搜索.md#16-括号生成)。 明天:第 48 天,I/O 多路复用(Socket:I/O 模型~I/O 复用)。 @@ -1968,7 +1970,7 @@ RPC 一节对比了它和 HTTP 的取舍。雪花算法要注意时钟回拨; 1. 消息队列只保证单个队列(Kafka 的分区)内有序;发送端按业务 Key(如订单号)把同一业务的消息发到同一个队列;接收端一个队列只由一个线程顺序消费,Kafka 中一个分区只分给组内一个消费者,RabbitMQ 用单个消费者或单活跃消费者;生产者重试可能让后发的消息先写入,Kafka 默认开启的幂等生产者用序号避免了这种乱序;分区数改变后同一 key 可能落到别的分区,要预先规划分区数。 2. 先区分生产突增(峰值后可自然消化)还是消费变慢(下游变慢、反复重试、频繁重平衡),先修复消费变慢的原因,失败消息转入死信队列;扩容消费者(Kafka 普通消费者组、RocketMQ 4.x 中超过分区或队列数的消费者会空闲),或批量写库、线程池并行(同一 key 交给同一线程);分区不够时转发到分区更多的新主题,不重要的消息降级跳过;积压接近保留时间(Kafka 默认 7 天)前先转存。 -3. 下单时发一条 30 分钟后投递的延迟消息,到期后消费者用条件更新取消订单(WHERE status = 'UNPAID'),重复或晚到的消息不会取消已支付的订单,要求高的业务再用低频扫描兜底;RocketMQ 4.x 有 30m 延迟级别,5.x 可指定任意时间,RabbitMQ 用 TTL 加死信队列;也可以用 Redis 有序集合,分值为到期时间,后台线程取到期成员,多实例用 ZREM 返回 1 决定由谁处理。 +3. 下单时发一条 30 分钟后投递的延迟消息,到期后消费者用条件更新取消订单(WHERE status = 'UNPAID'),重复或晚到的消息不会取消已支付的订单,要求高的业务再用低频扫描兜底;用户已付款、回调还没到时本地仍是待支付,所以取消前要先向支付平台关单,平台返回已支付就按支付成功处理;RocketMQ 4.x 有 30m 延迟级别,5.x 可指定任意时间,RabbitMQ 用 TTL 加死信队列;也可以用 Redis 有序集合,分值为到期时间,后台线程取到期成员,多实例用 ZREM 返回 1 决定由谁处理。 @@ -2161,7 +2163,7 @@ RPC 一节对比了它和 HTTP 的取舍。雪花算法要注意时钟回拨; 预期:多阶段构建的镜像只含 JRE 和 jar,约 236 MB,在 Maven 镜像中单阶段构建并运行的镜像约 680 MB,多出了 JDK、Maven 和下载的依赖。 -算法(二分查找,困难):[4. 寻找两个正序数组的中位数](https://leetcode.cn/problems/median-of-two-sorted-arrays/),题解见[两个有序数组的中位数](Leetcode%20题解%20-%20二分查找.md#7-两个有序数组的中位数)。 +算法(栈和队列,困难):[32. 最长有效括号](https://leetcode.cn/problems/longest-valid-parentheses/),题解见[最长有效括号](Leetcode%20题解%20-%20动态规划.md#3-最长有效括号)。 明天:第 65 天,Kubernetes(Docker:Kubernetes)。 @@ -2186,13 +2188,13 @@ Kubernetes 重点看 Pod 的创建过程、Service 和 Ingress 的作用,以 -算法(二叉树,困难):[124. 二叉树中的最大路径和](https://leetcode.cn/problems/binary-tree-maximum-path-sum/)。 +算法(二分查找,困难):[4. 寻找两个正序数组的中位数](https://leetcode.cn/problems/median-of-two-sorted-arrays/),题解见[两个有序数组的中位数](Leetcode%20题解%20-%20二分查找.md#7-两个有序数组的中位数)。 明天:第 66 天,系统设计题的思路与即时通讯(系统设计基础:系统设计题的思路~即时通讯)。 ### 第 66 天:系统设计题的思路与即时通讯 -最后两章是开放的设计题。点赞系统演示了回答的顺序:先问清需求、估算规模,再从简单方案出发找瓶颈;即时通讯的重点是消息怎样做到不丢、不重、不乱序,以及一台机器能维持多少个长连接。 +这两章是开放的设计题。点赞系统演示了回答的顺序:先问清需求、估算规模,再从简单方案出发找瓶颈;即时通讯的重点是消息怎样做到不丢、不重、不乱序,以及一台机器能维持多少个长连接。 阅读:《系统设计基础》从[系统设计题的思路](系统设计基础.md#十三系统设计题的思路)读到[即时通讯](系统设计基础.md#十四即时通讯)。 @@ -2215,6 +2217,35 @@ Kubernetes 重点看 Pod 的创建过程、Service 和 Ingress 的作用,以 预期:第二次 INSERT 报错 ERROR 1062 Duplicate entry '...' for key 'video_like.PRIMARY';INSERT IGNORE 不报错,影响 0 行;EXPLAIN 中 type 为 ref,按 user_id 等值查找,扫描的行数约等于这个用户点过赞的视频数。 +算法(二叉树,困难):[124. 二叉树中的最大路径和](https://leetcode.cn/problems/binary-tree-maximum-path-sum/)。 + +明天:第 67 天,订单与支付(系统设计基础:订单与支付)。 + +### 第 67 天:订单与支付 + +本章把条件更新、延迟消息和幂等用在订单与支付上,难点是订单服务和支付平台之间没有共同的事务。超时关单的三步和关单与付款同时发生的时序图要细读;对账是前面各环节都漏掉时的兜底;转账一节回到数据库的行锁,可以和秒杀中防止超卖的条件更新对照着看。 + +阅读:《系统设计基础》的[订单与支付](系统设计基础.md#十五订单与支付)。 + +自测: + +1. 订单超时未支付要自动关闭,关单时用户恰好付了款,怎样保证两边一致?(见[超时关单](系统设计基础.md#超时关单)) +2. 支付回调重复到达或并发到达时,怎样保证只发一次货?(见[支付回调与重复支付](系统设计基础.md#支付回调与重复支付)) +3. A 给 B 转账的同时 B 给 A 转账,怎样避免死锁,又不让余额扣成负数?(见[转账与余额扣减](系统设计基础.md#转账与余额扣减)) + +
+参考要点 + +1. 关单以支付平台为准,分三步:先用条件更新把订单从待支付改为关单中,影响 0 行就按当前状态处理;再调用平台的关单接口,成功或订单已关闭时改为已关闭,在同一个事务中回补库存;关单失败时查单,已支付就按支付成功处理(与回调走同一段代码),订单不存在可以关闭,结果不明保持关单中,由扫描任务稍后重做;只改本地状态的话,用户打开旧的支付页面仍能付款;下单时把同一个截止时间作为 time_expire 传给平台,否则平台默认 7 天内都能支付;回调在关单中到达时仍然有效。 +2. 先验签,核对商户号、订单号、金额与本地订单一致,并确认交易状态是支付成功,再把三件事放在同一个事务中:以交易号为主键插入去重表,重复的通知报 1062 后回滚;用 UPDATE ... WHERE status IN ('UNPAID', 'CLOSING') 改为已支付,影响 1 行才继续;写入发货消息;重复的通知也要应答成功,否则平台会继续重发;先查状态再更新的写法,同一笔通知并发到达 10 次时会发 10 次货。 +3. 按账户 ID 从小到大加锁,两个方向的转账都先锁 id 小的行,就不会互相等待(实验中每 480 笔的死锁从约 70~100 次降为 0),只有涉及同一账户的转账在这一行的锁上排队,不是串行化;余额判断写在条件中:UPDATE account SET balance = balance - 100 WHERE id = 1 AND balance >= 100,影响 0 行说明余额不足、回滚,先读出余额在应用中算好再写回会互相覆盖;客户端生成的请求号作主键,死锁回滚或超时重试也只成功一次;每次变动写流水,用期初余额加流水合计核对当前余额。 + +
+ +动手:阅读[附录 A23](系统设计基础.md#a23-超时关单与支付回调)的输出,说明只改本地状态的写法为什么会出现平台已付、本地已关,先向平台关单、失败只等回调的写法又为什么会有订单停在待支付。 + +预期:只改本地状态的写法,到期时把订单改为已关闭,但平台上的交易仍然可以支付,用户在关单前后付款时就出现平台已付、本地已关(300 轮中二百多轮),只能退款;先向平台关单、失败只等回调的写法,关单因用户已付款而失败后只等回调,回调又有 10% 丢失,订单就停在待支付(300 轮中二十多轮),要靠查单或对账补上;按三步的写法关单失败时查单,300 轮全部一致。 + 算法(动态规划,困难):[72. 编辑距离](https://leetcode.cn/problems/edit-distance/),题解见[编辑距离](Leetcode%20题解%20-%20动态规划.md#2-编辑距离)。 综合题:设计一个秒杀系统:从页面、网关、服务、缓存、消息队列到数据库,每一层分别做什么来扛住流量、防止超卖?出现故障时怎样降级?(见[秒杀系统](系统设计基础.md#六秒杀系统)、[读路径的逐层分流](系统设计基础.md#读路径的逐层分流)、[限流、降级与熔断](系统设计基础.md#限流降级与熔断)) diff --git "a/notes/\345\255\246\344\271\240\350\267\257\347\272\277 - \345\244\247\346\250\241\345\236\213\345\272\224\347\224\250\345\274\200\345\217\221.md" "b/notes/\345\255\246\344\271\240\350\267\257\347\272\277 - \345\244\247\346\250\241\345\236\213\345\272\224\347\224\250\345\274\200\345\217\221.md" index f5b984546..d1f6c03ac 100644 --- "a/notes/\345\255\246\344\271\240\350\267\257\347\272\277 - \345\244\247\346\250\241\345\236\213\345\272\224\347\224\250\345\274\200\345\217\221.md" +++ "b/notes/\345\255\246\344\271\240\350\267\257\347\272\277 - \345\244\247\346\250\241\345\236\213\345\272\224\347\224\250\345\274\200\345\217\221.md" @@ -130,7 +130,7 @@ Token 是模型处理文本的单位,也是计费和上下文长度的单位 1. 公式为 softmax(QKT / √d) V:Q 与各 K 点积得到分数,softmax 成权重后对 V 加权求和;分量独立、均值 0、方差 1 时点积的方差为 d,维度大时 softmax 几乎集中到一个 Token 上,梯度接近 0;除以 √d 使方差恢复为 1。 2. 单头注意力中每个 Token 只有一组权重,只能按一种标准挑选信息;多头把 Q、K、V 切成 h 份分别计算,拼接后乘 WO,不同的头可以关注指代、相邻词等不同关系;每头维度为 d / h,计算量与单头基本相同。 -3. 标准注意力的计算量为 O(n2d),n × n 的分数矩阵占 O(n2) 显存,长上下文时显存读写成为瓶颈;FlashAttention 分块在 GPU 片上缓存中计算,分数矩阵不写回显存,用在线 softmax 逐块累积;结果与标准注意力完全相同,省的是显存读写而不是计算量。 +3. 标准注意力的计算量为 O(n2d),n × n 的分数矩阵占 O(n2) 显存,长上下文时显存读写成为瓶颈;FlashAttention 分块在 GPU 片上缓存中计算,分数矩阵不写回显存,用在线 softmax 逐块累积;结果在数学上与标准注意力相同,不是近似,省的是显存读写而不是计算量。 diff --git "a/notes/\345\276\256\346\234\215\345\212\241.md" "b/notes/\345\276\256\346\234\215\345\212\241.md" index b9f1ce981..692d0f6d6 100644 --- "a/notes/\345\276\256\346\234\215\345\212\241.md" +++ "b/notes/\345\276\256\346\234\215\345\212\241.md" @@ -411,7 +411,7 @@ Spring Cloud Alibaba 中常用 Sentinel 实现:把接口或者一段代码定 AT 与 XA 两阶段提交的区别在于锁持有的时间:XA 的第一阶段只准备、不提交,行锁一直持有到第二阶段;AT 的第一阶段就提交了本地事务、释放行锁,只靠全局锁阻止其它全局事务修改这些行,所以并发更高。代价有两个: - 其它事务在第二阶段之前就能读到 98,即默认的全局隔离级别是读未提交,需要读已提交时用 SELECT ... FOR UPDATE 等待全局锁; -- 全局锁只对 Seata 管理的事务有效,其它代码直接改了这些行时,回滚前发现当前值与后镜像不一致,只能人工处理。 +- 全局锁只对 Seata 管理的事务有效,其它代码直接改了这些行时,回滚前发现当前值既不等于后镜像也不等于前镜像,Seata 就拒绝自动回滚,只能人工处理。 ## 八、链路追踪 diff --git "a/notes/\346\224\273\345\207\273\346\212\200\346\234\257.md" "b/notes/\346\224\273\345\207\273\346\212\200\346\234\257.md" index 81b240c2b..52ab4759c 100644 --- "a/notes/\346\224\273\345\207\273\346\212\200\346\234\257.md" +++ "b/notes/\346\224\273\345\207\273\346\212\200\346\234\257.md" @@ -134,9 +134,9 @@ http://www.examplebank.com/withdraw?account=AccountName&amount=1000&for=PayeeNam #### 1. 检查 Referer 首部字段 -Referer 首部字段位于 HTTP 报文中,用于标识请求来源的地址。检查这个首部字段并要求请求来源的地址在同一个域名下,可以极大地防止 CSRF 攻击。 +Referer 首部字段位于 HTTP 报文中,用于标识请求来源的地址。检查这个首部字段并要求请求来源的地址在同一个域名下,可以极大地防止 CSRF 攻击。浏览器发送跨站的 POST 等请求时还会带上 Origin 首部,只含协议、域名和端口,服务器一般优先检查 Origin,没有时再看 Referer。网页脚本不能把这两个首部改成其它站点的地址(Referer 只能改为同源的地址或者不发送)。 -这种办法简单易行,仅需要在关键访问处增加一步校验。局限在于它依赖浏览器发送 Referer:网站可以用 Referrer-Policy 让浏览器不发送或只发送部分地址,部分隐私插件也会去掉它,请求中没有 Referer 时,放行会留下漏洞,拒绝又会误伤正常用户。因此它通常和 Origin 首部的检查一起,作为 Token 之外的补充。 +这种办法简单易行,仅需要在关键访问处增加一步校验。局限在于它依赖浏览器发送 Referer:网站可以用 Referrer-Policy 让浏览器不发送或只发送部分地址,部分隐私插件也会去掉它,请求中没有 Referer 时,放行会留下漏洞,拒绝又会误伤正常用户。两个首部都没有时,应拒绝修改数据的请求,除非 Token 等其它校验通过,所以它通常作为 Token 之外的补充。 #### 2. 添加校验 Token @@ -144,7 +144,7 @@ Referer 首部字段位于 HTTP 报文中,用于标识请求来源的地址。 #### 3. 设置 Cookie 的 SameSite 属性 -SameSite=Strict 表示跨站请求不携带 Cookie,Lax 则只允许顶级导航的 GET 请求携带。Chrome、Edge 对没有设置 SameSite 的 Cookie 按 Lax 处理,Firefox、Safari 没有这样做,因此需要显式设置。设置为 Lax 时,上面例子中图片发出的请求不会带上 Cookie,但点击链接这类顶级导航的 GET 请求仍然会带,所以修改数据的接口不能用 GET。 +SameSite=Strict 表示跨站请求不携带 Cookie,Lax 则只允许顶级导航的 GET 请求携带。Chrome、Edge 对没有设置 SameSite 的 Cookie 按 Lax 处理,Firefox、Safari 没有这样做,因此需要显式设置。设置为 Lax 时,上面例子中图片发出的请求不会带上 Cookie,但点击链接这类顶级导航的 GET 请求仍然会带,所以修改数据的接口不能用 GET。需要注意的是,SameSite 按站点判断,a.example.com 发往 b.example.com 的请求不算跨站,子域名不完全可控时仍要配合 Token 或 Origin 检查。 #### 4. 输入验证码 diff --git "a/notes/\346\266\210\346\201\257\351\230\237\345\210\227.md" "b/notes/\346\266\210\346\201\257\351\230\237\345\210\227.md" index 4b254e13c..ebaec52c6 100644 --- "a/notes/\346\266\210\346\201\257\351\230\237\345\210\227.md" +++ "b/notes/\346\266\210\346\201\257\351\230\237\345\210\227.md" @@ -211,12 +211,12 @@ RabbitMQ 的队列默认没有长度上限,积压的消息占用内存和磁 延迟消息指的是发送后不立即投递,等到指定时间才能被消费的消息。典型的场景是订单 30 分钟未支付自动取消:下单时发送一条 30 分钟后投递的消息,到时消费者检查订单,仍未支付就取消。相比[定时任务扫描全表](分布式.md#定时扫描与延迟消息),延迟消息只处理到期的订单。 -延迟消息也可能丢失、晚到或重复,所以取消时以订单的当前状态为条件:UPDATE orders SET status = 'CANCELLED' WHERE id = 1024 AND status = 'UNPAID',支付回调同样带状态条件,取消后才到的支付成功要退款;要求高的业务再用低频扫描兜底。 +延迟消息也可能丢失、晚到或重复,所以取消时以订单的当前状态为条件:UPDATE orders SET status = 'CANCELLED' WHERE id = 1024 AND status = 'UNPAID',支付回调同样带状态条件,取消后才到的支付成功要退款;要求高的业务再用低频扫描兜底。取消前先向支付平台关单,用户就不能在取消后再付款,见[超时关单](系统设计基础.md#超时关单)。 各消息队列的支持情况: - RocketMQ 4.x 只支持 18 个固定的延迟级别:1s 5s 10s 30s 1m 2m 3m 4m 5m 6m 7m 8m 9m 10m 20m 30m 1h 2h,消息先存入内部的定时主题,每个级别一个队列,同一队列中先写入的先到期,只需检查队头; -- RocketMQ 5.x 改用时间轮,支持指定任意的投递时间,默认最长 24 小时,精度为秒级; +- RocketMQ 5.x 改用时间轮,支持指定任意的投递时间,默认精度为 1 秒;最长时长由配置决定,开源 5.5.1 中经 Proxy 发送的默认 1 天、Broker 默认 3 天,超出时拒绝写入(以官方最新文档为准); - RabbitMQ 常用的做法是给消息设置过期时间(TTL),过期后转入死信交换机,由死信队列的消费者处理。过期的消息到达队头才会被转出,前面 30 分钟的消息会挡住后面已到期的 1 分钟的消息,所以每种延迟时长单独建一个队列;4.4 起仲裁队列也支持延迟投递(以官方最新文档为准); - Kafka 不支持延迟消息,需要自己实现,例如按延迟时长分成几个主题,消费者读到未到期的消息就暂停等待。 @@ -255,10 +255,12 @@ Kafka 把消息保存为按分区追加的日志,消息被消费后不会删 | --- | --- | --- | --- | | 存储 | 分区日志,按保留时间删除,与是否被消费无关 | 队列,消息被确认后删除 | 所有主题的消息顺序写入同一个 CommitLog,每个队列另有消费索引,按保留时间删除 | | 消费 | 消费者拉取,提交偏移量,可以把偏移量调回去重新消费 | Broker 主动推送给消费者,用 prefetch 限制未确认的消息数 | 消费者按队列拉取(Push 模式用长轮询实现),可以按时间重置消费位置 | -| 路由 | 生产者按 key 选择分区 | 交换机按路由键把消息投递到一个或多个队列,规则灵活 | 主题加 Tag 过滤 | +| 路由 | 生产者按 key 选择分区 | 交换机按类型和绑定规则把消息投递到一个或多个队列:direct 精确匹配,topic 按通配符匹配,fanout 广播给所有绑定的队列,headers 按消息首部匹配 | 主题加 Tag 过滤 | | 业务功能 | 本身不支持延迟消息,死信只有共享组支持(4.4 起) | 死信交换机、TTL、优先级队列,4.4 起仲裁队列支持延迟消息 | 延迟消息、事务消息、消费重试和死信队列 | | 适用场景 | 日志、埋点、数据同步、流处理等吞吐量大的场景 | 消息量不大、路由规则复杂的业务消息 | 订单、支付等需要事务消息、延迟消息和重试的业务消息 | +Kafka 选择拉模式,是因为推送的速率由 Broker 决定,需要额外的流控(例如 RabbitMQ 的 prefetch)才能匹配消费者的处理速度;拉取时消费者按自己的速度读取,一次可以拉走当前位置之后的一批消息。没有新消息时,拉取请求在 Broker 上等待数据到达(长轮询),避免空转。 + RabbitMQ 也提供了只追加的 Stream 类型,消息确认后不删除,可以重复读取。 选型时先看业务需要什么: @@ -273,7 +275,7 @@ Redis 也常被用作轻量的消息队列,但可靠性不如专门的消息 - Pub/Sub 不保存消息,发布时没有订阅者或者订阅者断线,消息就丢了,实测没有订阅者时 PUBLISH 返回 0; - List 用 LPUSH 和 BRPOP 实现队列,消息取出后就从 List 中删除,消费者处理时崩溃就丢了,也不支持多个消费者组; -- Stream(Redis 5.0 起)支持消费者组和确认:XREADGROUP 读出的消息进入待确认列表,XACK 之后才移除,消费者崩溃时其它消费者可以用 XAUTOCLAIM 接管它没有确认的消息(见[附录 A4](#a4-redis-stream-的确认))。 +- Stream(Redis 5.0 起)支持消费者组和确认:XREADGROUP 读出的消息进入待确认列表,XACK 之后才移除,消费者崩溃时其它消费者可以用 XCLAIM(6.2 起也可用 XAUTOCLAIM)接管它没有确认的消息(见[附录 A4](#a4-redis-stream-的确认))。 即使使用 Stream,数据也保存在内存中,默认配置下持久化和主从复制都是异步的,宕机时可能丢失最近写入的消息,能保存的消息量也受内存限制。消息量不大、能接受少量丢失时可以用 Redis,否则用专门的消息队列。 @@ -295,7 +297,7 @@ Kafka 4.2 起提供共享组(Share Group):一个分区的消息可以分 ### 副本与可靠性 -每个分区有多个副本,分布在不同的 broker 上,其中一个是 Leader,负责处理读写,其余的 Follower 不断从 Leader 拉取数据。与 Leader 保持同步的副本集合称为 ISR(In-Sync Replicas),Follower 超过 replica.lag.time.max.ms(默认 30 秒)没有追上 Leader 就会被移出 ISR。ISR 中所有副本都已写入的位置称为高水位(High Watermark, HW),消费者只能读到高水位之前的消息。Kafka 4.0 起完全移除了 ZooKeeper,Leader 选举等元数据由内置的 KRaft 控制器管理,它基于 [Raft](分布式.md#六raft) 协议实现。 +每个分区有多个副本,分布在不同的 broker 上,其中一个是 Leader,负责处理读写,其余的 Follower 不断从 Leader 拉取数据。与 Leader 保持同步的副本集合称为 ISR(In-Sync Replicas),Follower 超过 replica.lag.time.max.ms(默认 30 秒)没有追上 Leader 就会被移出 ISR。ISR 中所有副本都已写入的位置称为高水位(High Watermark, HW),消费者只能读到高水位之前的消息。Kafka 4.0 起完全移除了 ZooKeeper,Leader 选举等元数据由内置的 KRaft 控制器管理,它基于 [Raft](分布式.md#六raft) 协议实现。分区数据的复制没有用 Raft 的多数派:多数派要有 2f+1 个副本,才能在 f 个副本故障后保住已提交的消息并选出新 Leader;Kafka 的消息要写入 ISR 中的全部副本才算提交,提交时 ISR 有 f+1 个副本,损失其中 f 个仍能保住这条消息。代价是提交要等 ISR 中最慢的副本;故障后能否继续写入还取决于下文的 min.insync.replicas。 例如创建 3 个分区、3 个副本的主题,然后用 kill -9 停掉 broker 3(3 个节点的 KRaft 集群实测): @@ -324,7 +326,7 @@ Follower 落后几条消息时仍在 ISR 中,Leader 宕机后它可以成为 acks=all 要配合 min.insync.replicas 使用,否则 ISR 只剩 Leader 一个时,acks=all 就退化成了 acks=1。min.insync.replicas 设为 2 时,ISR 少于 2 个副本,broker 就拒绝 acks=all 的写入。上面停掉 broker 3 之后 ISR 还有 2 个副本,写入正常;把 min.insync.replicas 改为 3 再写入,生产者重试几次后报错 NotEnoughReplicasException;改用 acks=1 时写入成功,但高水位不前进,停掉的 broker 回到 ISR 后消费者才能读到这条消息(Kafka 4.4 实测,见[附录 A8](#a8-mininsyncreplicas-与-acks))。常用的配置是 3 个副本、min.insync.replicas=2、acks=all,一个副本故障时既不丢消息,也不停止写入。 -生产者没有收到确认就会重试,这可能让同一条消息写入两次。Kafka 3.0 起默认开启幂等生产者:broker 给每个生产者分配一个 ID,生产者给发往每个分区的消息编上序号,broker 发现序号重复就丢弃。后文查看 .log 文件时,每个批次都带有 producerId: 1001 和 baseSequence 字段。幂等只保证单个分区内不重复;要让写入多个分区的消息同时成功或失败,需要使用事务,消费者设置 isolation.level=read_committed 后只读取已提交事务中的消息。 +生产者没有收到确认就会重试,这可能让同一条消息写入两次。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-重复消费))。因此接收端必须做到幂等,方法见 [接收端的可靠性](#接收端的可靠性)。反过来,如果先提交偏移量再处理,处理失败的消息就丢失了。 diff --git "a/notes/\347\256\227\346\263\225 - \346\216\222\345\272\217.md" "b/notes/\347\256\227\346\263\225 - \346\216\222\345\272\217.md" index da871a17b..da0d046e6 100644 --- "a/notes/\347\256\227\346\263\225 - \346\216\222\345\272\217.md" +++ "b/notes/\347\256\227\346\263\225 - \346\216\222\345\272\217.md" @@ -299,7 +299,7 @@ private int partition(T[] nums, int l, int h) { ### 3. 性能分析 -快速排序是原地排序,不需要辅助数组,但是递归调用需要辅助栈。 +快速排序是原地排序,不需要辅助数组,但是递归调用需要辅助栈:期望深度为 O(logN),最坏为 N;先递归较短的一侧、较长的一侧改为循环,可以保证深度不超过 logN。 快速排序最好的情况下是每次都正好将数组对半分,这样递归调用次数才是最少的。这种情况下比较次数为 CN=2CN/2+N,复杂度为 O(NlogN)。 @@ -387,7 +387,7 @@ public T select(T[] nums, int k) { 堆中某个节点的值总是大于等于或小于等于其子节点的值,并且堆是一棵完全二叉树。前者称为大顶堆,后者称为小顶堆,下文以大顶堆为例。 -堆可以用数组来表示,这是因为堆是完全二叉树,而完全二叉树很容易就存储在数组中。位置 k 的节点的父节点位置为 k/2,而它的两个子节点的位置分别为 2k 和 2k+1。这里不使用数组索引为 0 的位置,是为了更清晰地描述节点的位置关系。 +堆可以用数组来表示,这是因为堆是完全二叉树,而完全二叉树很容易就存储在数组中。位置 k 的节点的父节点位置为 k/2,而它的两个子节点的位置分别为 2k 和 2k+1。这里不使用数组索引为 0 的位置,是为了更清晰地描述节点的位置关系;从下标 0 开始存储时,子节点为 2k+1 和 2k+2,父节点为 (k-1)/2。

diff --git "a/notes/\347\263\273\347\273\237\350\256\276\350\256\241\345\237\272\347\241\200.md" "b/notes/\347\263\273\347\273\237\350\256\276\350\256\241\345\237\272\347\241\200.md" index 202dfb70f..8d30358b9 100644 --- "a/notes/\347\263\273\347\273\237\350\256\276\350\256\241\345\237\272\347\241\200.md" +++ "b/notes/\347\263\273\347\273\237\350\256\276\350\256\241\345\237\272\347\241\200.md" @@ -15,6 +15,7 @@ * [十二、权限系统](#十二权限系统) * [十三、系统设计题的思路](#十三系统设计题的思路) * [十四、即时通讯](#十四即时通讯) + * [十五、订单与支付](#十五订单与支付) * [附录:实测](#附录实测) * [参考资料](#参考资料) @@ -1265,6 +1266,116 @@ CREATE TABLE conv_member ( 管理这么多连接要用 epoll 这类 [I/O 复用](Socket.md#二io-复用):实测中一个线程就管理了全部 10 万个连接,每个连接一个线程的话需要 10 万个线程。 +## 十五、订单与支付 + +订单与支付的难点不在并发量,而在于订单服务和支付平台之间没有共同的事务:用户在支付平台付款,订单服务只能通过回调和查单得知结果,回调会重复、迟到或丢失,超时关单又可能恰好和付款同时发生。设计的目标是每一笔钱最终都对得上:付了款的订单要么发货、要么退款,没付款的订单一定关闭。 + +以用户 1001 在 10:00 下单的订单 1024 为例,金额 59 元,30 分钟内未支付自动关闭。 + +### 状态机与条件更新 + +订单的每次变化都是状态机上的一条边,只能从规定的前置状态转换过来: + +

+ +每条边写成一条带前置状态的条件更新,影响 1 行才算转换成功,才执行这条边的后续动作,例如发货、回补库存: + +```sql +UPDATE orders SET status = 'PAID', pay_txn = 'wx1024' +WHERE id = 1024 AND status IN ('UNPAID', 'CLOSING'); +``` + +支付回调、关单任务、用户取消可能同时修改同一个订单。条件更新在行锁的保护下一次完成判断和修改,同一时刻只有一个能成功,其余的影响 0 行,按订单的当前状态处理即可。原理与[防止超卖的条件更新](#防止超卖的几种做法)相同,重复执行影响 0 行,也就实现了[幂等](分布式.md#九幂等)。 + +关单中(CLOSING)是一个中间状态,表示已经决定关单、还在等支付平台的结果。终态说明事情已经结束,中间态则记录了“做到一半”,后续的处理都依赖它: + +- 发起支付的接口用条件更新记下“已发起支付”(条件是待支付),看到关单中就不再向平台下单; +- 关单任务调用平台后崩溃,扫描任务找到停在关单中超过几分钟的订单,重新执行关单; +- 回调在关单中到达时仍然有效,订单改为已支付。 + +### 超时关单 + +到期触发用延迟消息加扫描兜底:下单时发送一条 30 分钟后投递的消息,再用一个低频的定时任务扫描超时仍未关闭的订单,补上丢失的消息,两者的取舍见[定时扫描与延迟消息](分布式.md#定时扫描与延迟消息)。关单消息要和订单一起成功,可以在同一个本地事务中写入[本地消息表](分布式.md#本地消息表),或者使用事务消息,否则会出现订单已创建、关单消息却没有发出的情况。 + +关单的难点是到期时用户可能正在付款。如果只改本地状态(status 从 UNPAID 改为 CLOSED),支付平台上的这笔交易仍然可以支付,用户在 10:30 之后打开旧的支付页面照样能付款,结果是平台已收款、本地已关闭,只能退款。所以关单要以支付平台为准,分三步: + +1. 用条件更新把订单从待支付改为关单中。影响 0 行时查看当前状态:已支付或已关闭就结束,仍是关单中(上一次执行到一半)就继续下一步; +2. 调用支付平台的关单接口,关单可以重复调用,返回成功或订单已关闭时,把订单从关单中改为已关闭,在同一个事务中回补库存。用户从未发起过支付的订单,平台上没有这笔交易,直接关闭; +3. 关单失败时查单:平台返回已支付,就按支付成功处理,与回调走同一段代码;返回订单不存在,说明平台上没有这笔交易,可以关闭:即使下单请求迟到、平台稍后才建单,交易的截止时间也已经过了,用户无法再付款(截止时间见下文的 time_expire);结果不明时保持关单中,由扫描任务稍后从第 1 步重新执行。 + +

+ +上图是最容易出错的情况:用户 10:29:58 付款,回调因网络延迟还没到,关单任务 10:30:00 执行,平台返回订单已支付(微信支付 V2 的错误码 ORDERPAID,文档要求“当作已支付的正常交易”),查单拿到交易号后订单改为已支付;10:30:05 回调到达时,这个交易号已经处理过,不会再发一次货。反过来,平台先关单成功,用户就不能再付款。两种先后顺序都收敛到一致的状态。 + +300 轮随机交错的实验中(回调 10% 丢失),只改本地状态的写法有 260 轮平台已付、本地已关;先向平台关单、失败只等回调的写法有 22 轮停在待支付,要靠查单或对账补上;按上面三步的写法 300 轮全部一致,都没有重复发货(MySQL 8.0 实测,见[附录 A23](#a23-超时关单与支付回调))。 + +下单时还要把同一个截止时间传给支付平台,例如微信支付的 time_expire 设为 10:30,超过后平台拒绝支付。不传时平台默认 7 天内都可以支付,本地 30 分钟的超时就挡不住用户付款。 + +### 支付回调与重复支付 + +支付结果有两个来源:支付平台主动发送的回调,以及订单服务主动查单。回调可能丢失,平台也要求不能只依赖回调,所以用户回到订单页、关单之前和定时任务都会查单。 + +回调会重复发送,例如微信支付在 5 秒内没有收到成功应答,会在约 24 小时内按逐渐拉长的间隔重发,最多 15 次。处理时先验签,再核对通知中的商户号、订单号和金额与本地订单一致,并确认交易状态是支付成功(例如支付宝只有 TRADE_SUCCESS、TRADE_FINISHED 表示已付款,关单、退款也会发通知),然后把去重记录和条件更新放在同一个事务中: + +```sql +BEGIN; +INSERT INTO pay_notify (transaction_id, order_id, amount) +VALUES ('wx1024', 1024, 5900); -- 交易号为主键,重复的通知报 1062,回滚后应答成功 +UPDATE orders SET status = 'PAID', pay_txn = 'wx1024' +WHERE id = 1024 AND status IN ('UNPAID', 'CLOSING'); -- 影响 1 行才写发货消息 +INSERT INTO outbox (order_id, event, ref) VALUES (1024, 'SHIP', 'wx1024'); +COMMIT; +``` + +同一笔通知并发到达 10 次,先查状态再更新的写法每轮发货 10 次,上面的写法每轮 1 次(见[附录 A23](#a23-超时关单与支付回调))。重复的通知也要应答成功,否则平台会继续重发。 + +重复支付指一个订单被付了两次钱,常见的原因有两种: + +- 用户连续点击支付按钮:每次都用订单号 1024 作为商户订单号向平台下单,平台上只有一笔交易,只能付一次;每次点击都换一个新单号的话,平台上就有多笔都能付款的交易。微信支付也要求再次发起支付时使用原单号; +- 用户先后用两个渠道付款,例如微信和支付宝各付了一次:第二笔回调到达时,订单已被第一笔改为已支付,条件更新影响 0 行,交易号又与订单记录的不同,这时记下这笔交易并发起退款。 + +退款的幂等键是退款单号,例如 R1024-1。超时后重试必须沿用原退款单号,平台保证同一个退款单号只退一笔,换一个新单号可能退两次;部分退款每次用新的退款单号。申请成功只表示平台已受理,退款是否成功以退款回调或查询结果为准。 + +### 对账 + +回调、查单和关单都可能因为程序缺陷或长时间故障漏掉一些订单,对账是最后的兜底:每天(T+1)下载支付平台前一天的交易账单,本地也取同一渠道、同一天支付成功的订单,按订单号逐笔核对,结果分为四类: + +- 双方都是支付成功:对平,金额或交易号不一致时也算差异,例如订单 1029 在支付宝和微信各付了一次,订单号相同、交易号不同; +- 平台成功、本地未成功:例如订单 1025 在本地已关闭,补发货或者退款。是否支付过要看本地保留的交易号,而不是订单的当前状态,已进入退款中的订单也付过款,退款用退款账单另外核对; +- 账单中有、本地没有:排查本地数据是否丢失; +- 本地成功、账单中没有:排查订单处理的逻辑缺陷,例如把没有验签的通知当成了支付成功。 + +核对用一条按订单号的外连接查询就能完成(见[附录 A25](#a25-冻结与对账))。能确定的差异自动处理,其余的转人工;同一类差异反复出现,说明前面的环节有缺陷,要修复程序,而不是每天靠对账补。 + +### 转账与余额扣减 + +转账要满足三点:余额不能扣成负数,钱的总数不能变,同一个请求不能执行两次。以用户 1 给用户 2 转 100 元为例,两个账户在同一个数据库中时,用一个事务完成: + +```sql +BEGIN; +INSERT INTO transfer (request_id, from_id, to_id, amount, status) +VALUES ('req-7f3a', 1, 2, 100, 'DONE'); -- 请求号为主键,重复提交报 1062 +SELECT balance FROM account WHERE id = 1 FOR UPDATE; -- 先锁 id 小的账户 +SELECT balance FROM account WHERE id = 2 FOR UPDATE; +UPDATE account SET balance = balance - 100 +WHERE id = 1 AND balance >= 100; -- 影响 0 行说明余额不足,回滚 +UPDATE account SET balance = balance + 100 WHERE id = 2; +INSERT INTO account_flow (request_id, account_id, amount, balance_after) +VALUES ('req-7f3a', 1, -100, 900), ('req-7f3a', 2, 100, 1100); +COMMIT; +``` + +每一部分解决一个问题: + +- 余额判断写在 UPDATE 的条件中:先读出余额、在应用中算好再写回,两个事务会读到同一个旧值,后写的覆盖先写的。4 个账户共 4000 元、8 个线程并发转账 480 笔,这种写法的余额总和变成了 8566、4398、10265,条件更新始终是 4000(MySQL 8.0 实测,见[附录 A24](#a24-转账)); +- 按 ID 从小到大加锁:A 给 B 转账的同时 B 给 A 转账,各自先锁付款方,就会互相等待而死锁,过程见 [MySQL 死锁](计算机操作系统%20-%20死锁.md#2-mysql-死锁)。InnoDB 回滚其中一个,重试后余额仍然正确,但上面的实验中每 480 笔出现约 70~100 次死锁(多次运行),按 ID 加锁后为 0 次。这并不是把转账串行化,只有涉及同一个账户的转账才在这一行的锁上排队; +- 请求号:由客户端生成,重试时不变,死锁回滚或超时后重试也只会成功一次。同一请求号并发提交 5 次,1 次成功,4 次报 1062。需要注意的是,余额不足时整个事务回滚,请求号也没有留下,要求同一请求号的结果固定时,失败结果要另外记录; +- 流水:每次余额变动写一条流水,记下金额和变动后的余额。余额是流水累加的结果,对账时检查“期初余额 + 流水合计 = 当前余额”,绕过流水直接修改余额就会被查出来。 + +钱要转到外部时,例如提现到银行卡、转给另一个数据库中的账户,就无法在一个事务中完成,结果可能要等很久才返回。这时先冻结:可用余额减 300、冻结金额加 300(条件是可用余额不少于 300,影响 0 行就不发起转出),这笔钱不会再被其它请求花掉;外部返回成功后扣减冻结金额,返回失败则解冻、加回可用余额。冻结、扣减、解冻对应 [TCC](分布式.md#tcc) 的 Try、Confirm、Cancel,提现单号是主键,与冻结在同一个事务中写入,重复的请求不会再冻结一次;之后每一步都以提现单的状态为条件更新,重复的回调只扣一次(见[附录 A25](#a25-冻结与对账))。 + +结果不明(超时、处理中)时既不扣减也不解冻,用原单号查询,查询结果明确为失败后才能解冻或者换新单号重试,否则可能转出两笔。微信支付的商家转账文档也要求接口返回异常时不要立即更换单号重试。 + ## 附录:实测 环境为 Apple M1 Pro、macOS,Redis 7.4.1 使用默认配置,客户端为 redis-py。 @@ -2372,6 +2483,206 @@ TCP: inuse 0 orphan 0 tw 0 alloc 200026 mem 1535 +----+-------------+---------+------------+-------+---------------+---------+---------+------+------+----------+-------------+ ``` +### A23 超时关单与支付回调 + +环境:MySQL 8.0.44(默认的可重复读),Python 3 + PyMySQL,Apple M1 Pro。库 shop 中的表 orders(id, user_id, amount, status, pay_txn, expire_at, pay_started)、pay_notify(transaction_id 主键, order_id, amount)、outbox(id, order_id, event, ref)。支付平台用内存中的桩代替:只有未支付(NOTPAY)的交易才能付款或关单,关单可以重复调用,已支付时关单返回 ORDERPAID,没有这笔交易时关单和查单都返回 ORDER_NOT_EXIST,已过 time_expire 的交易不能付款。 + +每轮新建一个待支付订单,用户在 0~20 ms 内的随机时刻付款,回调在付款后 0~20 ms 到达,10% 的回调丢失;关单任务在 0~20 ms 内的随机时刻执行。三种写法使用同一组随机时刻。回调和关单的写法: + +```python +def handle_notify(c, oid, txn, amount): # 支付回调,查单成功时也调用它 + cur = c.cursor() + try: + cur.execute("INSERT INTO pay_notify VALUES (%s, %s, %s)", (txn, oid, amount)) + except pymysql.err.IntegrityError: + c.rollback(); return "dup" # 重复的通知,直接应答成功 + n = cur.execute("UPDATE orders SET status = 'PAID', pay_txn = %s " + "WHERE id = %s AND status IN ('UNPAID', 'CLOSING')", (txn, oid)) + if n == 1: + cur.execute("INSERT INTO outbox (order_id, event, ref) VALUES (%s, 'SHIP', %s)", (oid, txn)) + else: # 已关闭或已被另一笔交易支付:记下待退款 + cur.execute("SELECT pay_txn FROM orders WHERE id = %s", (oid,)) + if cur.fetchone()[0] != txn: + cur.execute("INSERT INTO outbox (order_id, event, ref) VALUES (%s, 'REFUND', %s)", (oid, txn)) + c.commit(); return "ok" + +def close_local_only(c, p, oid): # A 只改本地状态 + c.cursor().execute("UPDATE orders SET status = 'CLOSED' WHERE id = %s AND status = 'UNPAID'", (oid,)) + c.commit() + +def close_platform_first(c, p, oid): # B 先向平台关单,失败时等回调 + if p.close(oid) == "OK": + c.cursor().execute("UPDATE orders SET status = 'CLOSED' WHERE id = %s AND status = 'UNPAID'", (oid,)) + c.commit() + +def close_full(c, p, oid): # C 关单中 + 平台关单 + 失败查单 + cur = c.cursor() + n = cur.execute("UPDATE orders SET status = 'CLOSING' WHERE id = %s AND status = 'UNPAID'", (oid,)) + c.commit() + if n == 0: + cur.execute("SELECT status FROM orders WHERE id = %s", (oid,)) + st0 = cur.fetchone()[0]; c.commit() + if st0 != "CLOSING": + return # 已支付或已关闭;停在关单中的继续执行 + cur.execute("SELECT pay_started FROM orders WHERE id = %s", (oid,)) + started = cur.fetchone()[0]; c.commit() + if not started or p.close(oid) in ("OK", "ORDERCLOSED"): # 从未发起支付的直接关闭 + cur.execute("UPDATE orders SET status = 'CLOSED' WHERE id = %s AND status = 'CLOSING'", (oid,)) + if cur.rowcount == 1: + cur.execute("INSERT INTO outbox (order_id, event) VALUES (%s, 'RESTOCK')", (oid,)) + c.commit() + else: + st, txn = p.query(oid) + if st == "SUCCESS": + handle_notify(c, oid, txn, 5900) + elif st == "ORDER_NOT_EXIST": # 平台上没有这笔交易,迟到的下单也已过 time_expire + cur.execute("UPDATE orders SET status = 'CLOSED' WHERE id = %s AND status = 'CLOSING'", (oid,)) + if cur.rowcount == 1: + cur.execute("INSERT INTO outbox (order_id, event) VALUES (%s, 'RESTOCK')", (oid,)) + c.commit() +``` + +最后一行模拟关单任务改为关单中后崩溃:2001 崩溃前已在平台关单成功,2002 在崩溃期间被支付且回调丢失,2003 从未发起支付,2004 发起过支付但平台下单失败,2005 的下单请求在关单之后才到达平台,扫描任务对它们重新执行 close_full;之后平台为 2005 建单,桩按已过 time_expire 拒绝付款。300 轮实验中的订单都已发起支付(pay_started = 1);多次运行时 A、B 两行的数字因线程调度相差几轮。重复回调的对照写法先 SELECT 订单状态,为 UNPAID 时再执行不带条件的 UPDATE 并写发货消息;10 个线程用 Barrier 同时开始。原始输出: + +```text +300 轮,用户在 0~20 ms 内付款,回调在付款后 0~20 ms 到达、10% 丢失,关单任务在 0~20 ms 内执行 +写法 一致 已付已关 已付未付 发货>1 +A 只改本地状态 40 260 0 0 +B 先向平台关单,失败等回调 278 0 22 0 +C 关单中 + 平台关单 + 失败查单 300 0 0 0 + +先查后改:每轮发货记录 [10, 10, 10, 10, 10] +去重表 + 条件更新:每轮发货记录 [1, 1, 1, 1, 1] +2005 迟到的下单之后用户付款: 被平台拒绝 +停在关单中的订单重新执行: ((2001, 'CLOSED'), (2002, 'PAID'), (2003, 'CLOSED'), (2004, 'CLOSED'), (2005, 'CLOSED')) ((2001, 'RESTOCK'), (2002, 'SHIP'), (2003, 'RESTOCK'), (2004, 'RESTOCK'), (2005, 'RESTOCK')) +``` + +“已付已关”指平台已支付、本地已关闭,只能退款;“已付未付”指平台已支付、本地仍是待支付,要靠查单或对账补上。随机时刻只是为了制造交错,各写法之间的相对结果有意义,比例的绝对值不代表真实系统。 + +### A24 转账 + +环境同 A23。表 account(id, balance, frozen)、transfer(request_id 主键, from_id, to_id, amount, status)、account_flow(request_id, account_id, amount, balance_after,(request_id, account_id) 唯一)。4 个账户各 1000,8 个线程各做 60 次随机转账(随机选两个账户,金额 1~300),遇到死锁(1213)时回滚并重试同一笔。三种写法: + +```python +def read_write(cur, rid, a, b, x): # 先读后写 + cur.execute("SELECT balance FROM account WHERE id = %s", (a,)); ba = cur.fetchone()[0] + if ba < x: return "short" + cur.execute("SELECT balance FROM account WHERE id = %s", (b,)); bb = cur.fetchone()[0] + cur.execute("UPDATE account SET balance = %s WHERE id = %s", (ba - x, a)) + cur.execute("UPDATE account SET balance = %s WHERE id = %s", (bb + x, b)) + return "ok" + +def cond_update(cur, rid, a, b, x): # 条件更新,先付款方后收款方 + if cur.execute("UPDATE account SET balance = balance - %s WHERE id = %s AND balance >= %s", (x, a, x)) == 0: + return "short" + cur.execute("UPDATE account SET balance = balance + %s WHERE id = %s", (x, b)) + return "ok" + +def sorted_lock(cur, rid, a, b, x): # 请求号 + 按 ID 加锁 + 条件更新 + 流水 + cur.execute("INSERT INTO transfer VALUES (%s, %s, %s, %s, 'DONE')", (rid, a, b, x)) + for i in sorted((a, b)): + cur.execute("SELECT balance FROM account WHERE id = %s FOR UPDATE", (i,)) + if cur.execute("UPDATE account SET balance = balance - %s WHERE id = %s AND balance >= %s", (x, a, x)) == 0: + return "short" + cur.execute("UPDATE account SET balance = balance + %s WHERE id = %s", (x, b)) + cur.execute("INSERT INTO account_flow (request_id, account_id, amount, balance_after) " + "SELECT %s, id, IF(id = %s, -%s, %s), balance FROM account WHERE id IN (%s, %s)", (rid, a, x, x, a, b)) + return "ok" +``` + +返回 ok 时提交,余额不足时回滚。最后用同一个请求号 req-7f3a 并发提交 5 次;再跑一次带流水的写法,人为给账户 3 加 50 元而不写流水,执行对账查询: + +```sql +SELECT a.id, a.balance, 1000 + COALESCE(SUM(f.amount), 0) AS expected +FROM account a LEFT JOIN account_flow f ON f.account_id = a.id +GROUP BY a.id, a.balance HAVING a.balance <> expected; +``` + +原始输出(先读后写同样会死锁,因为两条 UPDATE 也是先付款方后收款方): + +```text +4 个账户各 1000,8 个线程各转 60 笔(金额 1~300),死锁时重试,各 3 次 +写法 成功 余额不足 死锁 总和 最小余额 +先读后写 468 12 105 8566 1847 +条件更新,先付款方 416 64 81 4000 558 +按 ID 排序加锁 + 条件更新 424 56 0 4000 375 +先读后写 463 17 101 4398 390 +条件更新,先付款方 409 71 69 4000 222 +按 ID 排序加锁 + 条件更新 417 63 0 4000 43 +先读后写 462 18 85 10265 894 +条件更新,先付款方 428 52 81 4000 37 +按 ID 排序加锁 + 条件更新 420 60 0 4000 433 + +同一请求号并发提交 5 次: ['成功', '重复 1062', '重复 1062', '重复 1062', '重复 1062'] 余额 ((1, 900), (2, 1100)) +先排序加锁(带流水) 447 33 0 4000 135 +改余额不写流水后,对账查出: ((3, 185, Decimal('135')),) +``` + +### A25 冻结与对账 + +环境同 A23。账户 1 的可用余额为 1000,提现单 w-1 冻结 300 元:先插入提现单,重复的请求报 1062 时回滚整个事务,返回已有的提现单;冻结影响 0 行时同样回滚,不发起转出。银行的成功回调按提现单状态扣减冻结金额: + +```python +def freeze(wid, x): + try: + cur.execute("INSERT INTO withdraw VALUES (%s, 1, %s, 'PROCESSING')", (wid, x)) + except pymysql.err.IntegrityError as e: + c.rollback(); return f"重复请求 {e.args[0]}" + n = cur.execute("UPDATE account SET balance = balance - %s, frozen = frozen + %s " + "WHERE id = 1 AND balance >= %s", (x, x, x)) + if n == 0: + c.rollback(); return "余额不足,回滚" + c.commit(); return "已冻结,发起转出" + +def on_success(wid, x): + n = cur.execute("UPDATE withdraw SET status = 'SUCCESS' WHERE id = %s AND status = 'PROCESSING'", (wid,)) + if n == 1: + cur.execute("UPDATE account SET frozen = frozen - %s WHERE id = 1", (x,)) + c.commit(); return f"提现单影响 {n} 行" +``` + +```text +w-1 冻结 300: 已冻结,发起转出 +冻结后 balance 700 frozen 300 withdraw (('w-1', 'PROCESSING'),) +w-1 重复提交: 重复请求 1062 +重复提交后 balance 700 frozen 300 withdraw (('w-1', 'PROCESSING'),) +w-2 冻结 5000: 余额不足,回滚 +余额不足后 balance 700 frozen 300 withdraw (('w-1', 'PROCESSING'),) +成功回调 1: 提现单影响 1 行 +成功回调 2: 提现单影响 0 行 +两次回调后 balance 700 frozen 0 withdraw (('w-1', 'SUCCESS'),) +``` + +对账在 mysql 客户端中执行。orders 加上渠道 channel 和支付时间 paid_at 两列,本地有 1020(10 月 28 日支付)和 1024~1029,其中 1025 已关闭,1028 支付后正在退款,1029 先用支付宝付款、微信又付了一次且回调丢失;10 月 29 日的微信支付账单 bill(out_trade_no, transaction_id, amount, trade_state) 中有 1024、1025、1026、1028、1029,1026 的金额与本地不同。是否支付过看保留的交易号 pay_txn,而不是订单的当前状态,否则退款中的 1028 会被误判;查找“本地成功、账单无”时本地只取同一渠道、同一天支付的订单,1020 不会被误报: + +```sql +SELECT b.out_trade_no, o.id AS local_id, b.amount AS bill_amount, o.amount AS local_amount, o.pay_txn, + CASE WHEN o.id IS NULL THEN '账单有、本地无' + WHEN o.pay_txn IS NULL THEN '平台成功、本地未成功' + WHEN o.pay_txn <> b.transaction_id THEN '交易号不符' + WHEN b.amount <> o.amount THEN '金额不符' ELSE '对平' END AS result +FROM bill b LEFT JOIN orders o ON o.id = b.out_trade_no +UNION ALL +SELECT NULL, o.id, NULL, o.amount, o.pay_txn, '本地成功、账单无' +FROM orders o LEFT JOIN bill b ON b.out_trade_no = o.id +WHERE o.pay_txn IS NOT NULL AND o.channel = 'wxpay' + AND o.paid_at >= '2026-10-29' AND o.paid_at < '2026-10-30' + AND b.out_trade_no IS NULL; +``` + +```text ++--------------+----------+-------------+--------------+---------+------------------------+ +| out_trade_no | local_id | bill_amount | local_amount | pay_txn | result | ++--------------+----------+-------------+--------------+---------+------------------------+ +| 1024 | 1024 | 5900 | 5900 | wx1024 | 对平 | +| 1025 | 1025 | 3900 | 3900 | NULL | 平台成功、本地未成功 | +| 1026 | 1026 | 1100 | 1200 | wx1026 | 金额不符 | +| 1028 | 1028 | 4200 | 4200 | wx1028 | 对平 | +| 1029 | 1029 | 3300 | 3300 | ali1029 | 交易号不符 | +| NULL | 1027 | NULL | 2500 | wx1027 | 本地成功、账单无 | ++--------------+----------+-------------+--------------+---------+------------------------+ +``` + ## 参考资料 - [Redis: Scripting with Lua](https://redis.io/docs/latest/develop/programmability/eval-intro/) @@ -2398,6 +2709,15 @@ TCP: inuse 0 orphan 0 tw 0 alloc 200026 mem 1535 - [Circuit Breaker pattern(Azure 架构中心)](https://learn.microsoft.com/en-us/azure/architecture/patterns/circuit-breaker) - [阿里云 PTS:线上业务压测的核心要素](https://help.aliyun.com/zh/pts/performance-test-pts-2-0/product-overview/core-elements-for-online-business-stress-testing) - [ListParts - Amazon S3 API Reference](https://docs.aws.amazon.com/AmazonS3/latest/API/API_ListParts.html) +- [微信支付:JSAPI 支付开发指引](https://pay.weixin.qq.com/doc/v3/merchant/4012791870) +- [微信支付:JSAPI 支付常见问题](https://pay.weixin.qq.com/doc/v3/merchant/4012791869) +- [微信支付:支付成功回调通知](https://pay.weixin.qq.com/doc/v3/merchant/4012791861) +- [微信支付:支付回调和查单实现指引](https://pay.weixin.qq.com/doc/v3/merchant/4012075249) +- [微信支付:订单退款开发指引](https://pay.weixin.qq.com/doc/v3/merchant/4013071031) +- [微信支付 V2:关闭订单](https://pay.weixin.qq.com/doc/v2/merchant/4011935216) +- [微信支付:商家转账](https://pay.weixin.qq.com/doc/v3/merchant/4012716434) +- [支付宝:异步通知说明](https://opendocs.alipay.com/open/00dn78) +- [How to Minimize and Handle Deadlocks(MySQL 8.0 参考手册)](https://dev.mysql.com/doc/refman/8.0/en/innodb-deadlocks-handling.html) - [Uploading objects in Amazon S3](https://docs.aws.amazon.com/AmazonS3/latest/userguide/upload-objects.html) - [MinIO 源码 cmd/api-errors.go、cmd/object-api-errors.go](https://github.com/minio/minio/tree/master/cmd) - [Nginx ngx_http_core_module:client_max_body_size](https://nginx.org/en/docs/http/ngx_http_core_module.html) diff --git "a/notes/\347\274\223\345\255\230.md" "b/notes/\347\274\223\345\255\230.md" index 66fc42142..76234faac 100644 --- "a/notes/\347\274\223\345\255\230.md" +++ "b/notes/\347\274\223\345\255\230.md" @@ -206,7 +206,7 @@ Redis 8 起内置了布隆过滤器(BF.RESERVE、BF.ADD、BF.EXISTS),Redis - 为缓存设置过期时间作为兜底,需要注意过期时间从这次写回缓存时算起,慢读请求很晚才写回旧值时,旧值最长还会保留一个完整的过期时间; - 延迟双删:更新数据库并删除缓存之后,隔一段时间再删除一次,清掉慢读请求写回的旧值; - 删除失败时重试:把要删除的键发到消息队列,由消费者删除,失败就重新投递; -- 订阅 binlog 删除缓存:Canal 这类组件伪装成 MySQL 的从库读取 binlog,发现某一行被修改就删除对应的缓存键,失败时重试,业务代码中不再需要删除缓存,手工执行的 SQL 也能覆盖,代价是多部署一套组件; +- 订阅 binlog 删除缓存:Canal 这类组件伪装成 MySQL 的从库读取 binlog,发现某一行被修改就删除对应的缓存键,失败时重试,业务代码中不再需要删除缓存,手工执行的 SQL 也能覆盖,代价是多部署一套组件,而且和直接删除一样挡不住在删除之后才写回的旧值; - 写回缓存时检查期间是否有过删除:Facebook 的 Memcache 在缓存未命中时发给读请求一个租约(lease)令牌,写回时要带上令牌,如果这期间键被删除,令牌就会失效,旧值写不进缓存。 延迟双删的第二次删除不能让写请求同步等待,通常交给线程池或者延迟消息执行。间隔要大于读请求从查询数据库到写回缓存的耗时:附录 A4 中读请求最多停顿 5 毫秒,间隔取 50 毫秒时没有留下旧值;间隔太短清不掉旧值,太长则旧值在缓存中停留更久。第二次删除也可能失败,同样要重试,最后靠过期时间兜底。 @@ -321,7 +321,7 @@ public class LRUCache extends LinkedHashMap { @Override protected boolean removeEldestEntry(Map.Entry eldest) { - return size() > maxSize; // 每次 put 之后调用,返回 true 时删除最久未访问的元素 + return size() > maxSize; // 插入新键之后调用,返回 true 时删除最久未访问的元素 } } ``` diff --git "a/notes/\350\256\241\347\256\227\346\234\272\346\223\215\344\275\234\347\263\273\347\273\237 - \350\277\233\347\250\213\347\256\241\347\220\206.md" "b/notes/\350\256\241\347\256\227\346\234\272\346\223\215\344\275\234\347\263\273\347\273\237 - \350\277\233\347\250\213\347\256\241\347\220\206.md" index c1ef3c4f4..2a147595a 100644 --- "a/notes/\350\256\241\347\256\227\346\234\272\346\223\215\344\275\234\347\263\273\347\273\237 - \350\277\233\347\250\213\347\256\241\347\220\206.md" +++ "b/notes/\350\256\241\347\256\227\346\234\272\346\223\215\344\275\234\347\263\273\347\273\237 - \350\277\233\347\250\213\347\256\241\347\220\206.md" @@ -116,7 +116,7 @@ fork 调用一次、返回两次:父进程中返回子进程的 PID,子进 Go 和 Java 虚拟线程把大量协程映射到运行时创建的少量线程(通常与 CPU 核数相当)上,称为 M:N 调度;Python 的 asyncio 则在一个线程的事件循环中轮流执行所有协程。大多数协程是协作式调度的,只在让出点切换,例如等待 I/O、等待锁、sleep;一个协程长时间计算而不让出,就会一直占住所在的线程。 -Go 的 GMP 模型是一个典型实现:G 是 goroutine,M 是系统线程,P 是执行 Go 代码需要的处理器,数量默认等于可用的 CPU 核数。每个 P 有自己的就绪队列,M 绑定一个 P 后从队列中取 G 执行,队列空了就从其它 P 的队列中偷取一半。Go 1.14 起运行时还会用信号打断长时间运行的 goroutine(异步抢占),避免没有函数调用的循环一直占住线程。 +Go 的 GMP 模型是一个典型实现:G 是 goroutine,M 是系统线程,P 是执行 Go 代码需要的处理器,数量默认等于可用的 CPU 核数(Go 1.25 起在 Linux 上还会参考 cgroup 的 CPU 限额)。每个 P 有自己的就绪队列,M 绑定一个 P 后从队列中取 G 执行,队列空了就从其它 P 的队列中偷取一半。Go 1.14 起运行时还会用信号打断长时间运行的 goroutine(异步抢占),避免没有函数调用的循环一直占住线程。 与线程相比: @@ -128,7 +128,7 @@ Go 的 GMP 模型是一个典型实现:G 是 goroutine,M 是系统线程,P Java 21 正式引入的虚拟线程([JEP 444](https://openjdk.org/jeps/444))就是 JVM 实现的协程:虚拟线程由 JVM 调度到少量的平台线程(称为载体线程,carrier thread)上运行,执行阻塞 I/O 时从平台线程上卸载,让平台线程去执行其它虚拟线程。调度器是一个工作窃取的 ForkJoinPool,平台线程数默认等于 CPU 核数,JDK 21 不按时间片抢占虚拟线程。 -虚拟线程卸载时,它的栈帧以栈块对象(stack chunk)的形式保存在堆中,等 I/O 就绪后再挂载到某个平台线程上继续执行,所以阻塞的虚拟线程只占用与调用深度相当的堆内存,不占用平台线程。虚拟线程的创建成本很低,不需要用线程池复用,需要限制并发数时使用信号量。在 JDK 21 中,虚拟线程在 synchronized 块内阻塞时无法卸载,会固定(pinning)在载体线程上,所以锁内有阻塞操作时改用 ReentrantLock(见 [Java 并发](Java%20并发.md#使用选择));JDK 24 起([JEP 491](https://openjdk.org/jeps/491))基本消除了这个限制。 +虚拟线程卸载时,它的栈帧以栈块对象(stack chunk)的形式保存在堆中,等 I/O 就绪后再挂载到某个平台线程上继续执行,所以阻塞的虚拟线程只占用与调用深度相当的堆内存,不占用平台线程。虚拟线程的创建成本很低,不需要用线程池复用,需要限制并发数时使用信号量。在 JDK 21 中,虚拟线程在 synchronized 块内阻塞时无法卸载,会固定(pinning)在载体线程上。读写文件、Object.wait() 同样会占住载体线程,但调度器会临时增加平台线程来补偿;pinning 没有补偿,载体线程被占满后其它虚拟线程都无法执行,所以锁内有阻塞操作时改用 ReentrantLock(见 [Java 并发](Java%20并发.md#使用选择));JDK 24 起([JEP 491](https://openjdk.org/jeps/491))基本消除了这个限制。

diff --git "a/notes/\350\256\276\350\256\241\346\250\241\345\274\217 - \345\215\225\344\276\213.md" "b/notes/\350\256\276\350\256\241\346\250\241\345\274\217 - \345\215\225\344\276\213.md" index b1dbf698f..c466d6dfc 100644 --- "a/notes/\350\256\276\350\256\241\346\250\241\345\274\217 - \345\215\225\344\276\213.md" +++ "b/notes/\350\256\276\350\256\241\346\250\241\345\274\217 - \345\215\225\344\276\213.md" @@ -38,7 +38,7 @@ public class Singleton { 线程不安全问题主要是由于 uniqueInstance 被实例化多次,采取直接实例化 uniqueInstance 的方式就不会产生线程不安全问题:静态变量在类初始化时赋值,类的初始化由 JVM 加锁且只执行一次。 -但是直接实例化的方式也丢失了延迟实例化带来的节约资源的好处。 +但是直接实例化的方式也丢失了延迟实例化带来的节约资源的好处。实例在类初始化时创建,不一定在程序启动时;访问类中其它非常量静态成员或通过反射(例如 Class.forName())触发类初始化,都会使实例早于第一次调用 getUniqueInstance() 创建。 ```java private static Singleton uniqueInstance = new Singleton(); diff --git "a/notes/\351\235\242\345\220\221\345\257\271\350\261\241\346\200\235\346\203\263.md" "b/notes/\351\235\242\345\220\221\345\257\271\350\261\241\346\200\235\346\203\263.md" index e6a88a75d..b33fe24d8 100644 --- "a/notes/\351\235\242\345\220\221\345\257\271\350\261\241\346\200\235\346\203\263.md" +++ "b/notes/\351\235\242\345\220\221\345\257\271\350\261\241\346\200\235\346\203\263.md" @@ -283,7 +283,7 @@ Company *-- DepartmentB ### 关联关系 (Association) -表示不同类对象之间有关联,这是一种静态关系,与运行过程的状态无关,在最开始就可以确定。因此也可以用 1 对 1、多对 1、多对多这种关联关系来表示。比如学生和学校就是一种关联关系,一个学校可以有很多学生,但是一个学生只属于一个学校,因此这是一种多对一的关系,在运行开始之前就可以确定。 +表示不同类对象之间有结构上的联系,通常体现为一个类把另一个类作为成员长期持有。关联可以用 1 对 1、多对 1、多对多来表示数量关系。比如学生和学校就是一种关联关系,一个学校可以有很多学生,但是一个学生只属于一个学校,因此这是一种多对一的关系。