diff --git a/notes/学习路线 - 面试冲刺.md b/notes/学习路线 - 面试冲刺.md
index b6e0f2a6..a18f5ac2 100644
--- a/notes/学习路线 - 面试冲刺.md
+++ b/notes/学习路线 - 面试冲刺.md
@@ -70,7 +70,7 @@
参考要点
-1. JDK 1.8:table 为空先 resize(),用 (n - 1) & hash 定位桶,空桶直接放入;否则在链表或红黑树中查找,键相同就替换旧值,找不到就接在链表尾部;插入后 size 大于 threshold(容量 × 0.75,默认 12)时扩容为 2 倍;接上前链表已有 8 个节点时,数组长度小于 64 只扩容,否则转成红黑树。
+1. JDK 1.8:table 为空先 resize(),用 (n - 1) & hash 定位桶,空桶直接放入;否则头节点是 TreeNode 就在红黑树中查找或插入,是链表就逐个比较,键相同就替换旧值,找不到就接在链表尾部;插入后 size 大于 threshold(容量 × 0.75,默认 12)时扩容为 2 倍;接上前链表已有 8 个节点时,数组长度小于 64 只扩容,否则转成红黑树。
2. JDK 1.7 用分段锁,Segment 继承 ReentrantLock,默认 16 个,并发度创建后不能改变;JDK 1.8 去掉 Segment,空桶用 CAS 放入,非空时 synchronized 锁住桶的头节点,锁粒度到桶,多个线程可以一起迁移扩容;get 不加锁,靠 volatile 读到最新值。
3. ArrayList 基于动态数组,随机访问快,中间插入删除要移动元素,但一次复制一整块连续内存;LinkedList 基于双向链表,插入删除只改指针,但要先 O(N) 遍历到位置;实测 10 万个元素中间插入 1 万次,ArrayList 约 50 ms、LinkedList 约 0.8~1 秒,多数场景用 ArrayList。
@@ -95,8 +95,8 @@
参考要点
-1. 参数有核心线程数、最大线程数、空闲线程的存活时间、任务队列、线程工厂和拒绝策略;提交时线程数小于核心数就新建线程,否则放入队列,队列满且小于最大线程数时新建线程执行当前任务,再不行就执行拒绝策略;例如核心 2、最大 4、队列 2 时第 7 个任务被拒绝;不用 Executors 创建,它的队列或线程数没有上限。
-2. synchronized 由 JVM 实现(monitorenter、monitorexit,异常时也会释放锁),ReentrantLock 由 JDK 基于 AQS 实现;新版本中两者性能大致相同;ReentrantLock 等待可中断、可以设为公平锁、能绑定多个 Condition,synchronized 都不能。
+1. 参数有核心线程数、最大线程数、空闲线程的存活时间、任务队列、线程工厂和拒绝策略;提交时线程数小于核心数就新建线程,否则放入队列,队列满且小于最大线程数时新建线程执行当前任务,再不行就执行拒绝策略;例如核心 2、最大 4、队列 2 时第 7 个任务被拒绝;不用 Executors 的 newFixedThreadPool()、newSingleThreadExecutor()、newCachedThreadPool(),它们的队列或线程数没有上限,要直接创建 ThreadPoolExecutor 并使用有界队列。
+2. synchronized 由 JVM 实现(代码块编译为 monitorenter、monitorexit,方法用 ACC_SYNCHRONIZED 标志,异常时也会释放锁),ReentrantLock 由 JDK 基于 AQS 实现;新版本中在平台线程上两者性能大致相同,但 JDK 21 的虚拟线程在 synchronized 内阻塞会固定载体线程,这时要用 ReentrantLock;ReentrantLock 等待可中断、可以设为公平锁、能绑定多个 Condition,synchronized 都不能。
3. 保证可见性:写入后其它线程读取时立即可见;保证有序性:volatile 写之前、volatile 读之后的读写不会被重排序越过它,可用来发布数据(如双重校验锁单例);不保证 count++ 这类复合操作的原子性,要用锁或原子类。
@@ -122,11 +122,11 @@
1. CAS 有内存地址 V、预期旧值 A、新值 B 三个操作数,只有 V 等于 A 时才写入 B,由处理器指令保证原子性(x86 上是带 lock 前缀的 cmpxchg);ABA 指值从 A 变成 B 又变回 A,CAS 误以为没变,对无锁栈这类结构有害;用 AtomicStampedReference 给引用加版本号,每次修改加 1。
2. 无锁(Mark Word 末尾 01);轻量级锁(00):线程用 CAS 把 Mark Word 换成指向栈中 Lock Record 的指针,重入只多放一个 Lock Record;重量级锁(10):其它线程 CAS 失败时膨胀为 ObjectMonitor,先自适应自旋,仍抢不到再阻塞;JDK 15 起默认禁用偏向锁,空闲的 ObjectMonitor 会被回收,对象恢复为无锁。
-3. ThreadLocalMap 的 Entry 对 key(ThreadLocal)是弱引用、对 value 是强引用;ThreadLocal 没有其它强引用时 key 被回收变为 null,value 仍被线程的 Map 引用,线程池中的线程一直存活,value 就无法回收;用完在 finally 中调用 remove(),也能避免线程复用时读到上一个请求的值。
+3. ThreadLocalMap 的 Entry 对 key(ThreadLocal)是弱引用、对 value 是强引用;ThreadLocal 没有其它强引用时 key 被回收变为 null,value 仍被线程的 Map 引用,之后这个线程调用 set()、remove() 或没有直接命中的 get() 时可能顺带清理,线程池中的线程一直存活又没有触发清理时,value 就一直留着;用完在 finally 中调用 remove(),也能避免线程复用时读到上一个请求的值。
-算法(滑动窗口,中等):[3. 无重复字符的最长子串](https://leetcode.cn/problems/longest-substring-without-repeating-characters/),题解见[剑指 Offer 48](48.%20最长不含重复字符的子字符串.md)。
+算法(滑动窗口,中等):[3. 无重复字符的最长子串](https://leetcode.cn/problems/longest-substring-without-repeating-characters/),题解见[无重复字符的最长子串](Leetcode%20题解%20-%20双指针.md#11-无重复字符的最长子串)。
算法(链表,简单):[206. 反转链表](https://leetcode.cn/problems/reverse-linked-list/),题解见[链表反转](Leetcode%20题解%20-%20链表.md#2-链表反转)。
@@ -145,9 +145,9 @@
参考要点
-1. 线程私有:程序计数器、Java 虚拟机栈(每个方法一个栈帧,-Xss 设置大小)、本地方法栈;线程共享:堆(几乎所有对象,分新生代和老年代,-Xms、-Xmx 设置)和方法区(类信息、常量、JIT 编译的代码,JDK 8 起由本地内存中的元空间实现,含运行时常量池);NIO 还会使用堆外的直接内存;除程序计数器外都可能 OOM,两种栈还可能 StackOverflowError。
-2. 范围:CMS 只回收老年代,要配合 ParNew,G1 把堆分成等大的 Region,混合回收时新生代和老年代一起回收;碎片:CMS 用标记 - 清除,碎片多时只能 Full GC 整理,G1 把存活对象复制到空闲 Region,不产生碎片;停顿:G1 按停顿目标(默认 200 ms)优先回收价值最大的 Region,更可预测,代价是额外内存和略低的吞吐量;JDK 9 起默认 G1,JDK 14 移除了 CMS。
-3. 先看错误信息判断是哪块内存不足(Java heap space、Metaspace、direct buffer memory、unable to create native thread);用 -XX:+HeapDumpOnOutOfMemoryError 自动保存堆转储或用 jcmd 导出,再用 MAT 按 Retained Heap 找占用最多的对象及其到 GC Roots 的引用链;不该存活就是泄漏(静态集合、ThreadLocal 未 remove、资源未关闭),改代码,否则增大堆或减少一次加载的数据;慢泄漏可以定期用 GC.class_histogram 比较实例数。
+1. 线程私有:程序计数器、Java 虚拟机栈(每个方法一个栈帧,-Xss 设置大小)、本地方法栈;线程共享:堆(几乎所有对象,分新生代和老年代,-Xms、-Xmx 设置)和方法区(类的元信息和运行时常量池,JDK 8 起由本地内存中的元空间实现;字符串常量池和静态变量 JDK 7 起已移到堆中,JIT 编译的代码在单独的代码缓存中);NIO 还会使用堆外的直接内存;除程序计数器外都可能 OOM,两种栈还可能 StackOverflowError。
+2. 范围:CMS 只回收老年代,要配合 ParNew,G1 把堆分成等大的 Region,混合回收时新生代和老年代一起回收;碎片:CMS 用标记 - 清除,碎片多时只能 Full GC 整理,G1 把存活对象复制到空闲 Region,普通对象不产生碎片(Humongous 对象要连续 Region,仍可能因找不到连续空间提前触发 GC);停顿:G1 按停顿目标(默认 200 ms)优先回收价值最大的 Region,更可预测,代价是额外内存,吞吐量通常略低于 Parallel;JDK 9 起服务器级机器默认使用 G1,JDK 14 移除了 CMS。
+3. 先看错误信息判断是哪块内存不足(Java heap space、Metaspace、direct buffer memory、unable to create native thread),Metaspace 查动态生成的类和不能卸载的类加载器,unable to create native thread 查线程数和系统限制,增大堆没有用;Java heap space 时用 -XX:+HeapDumpOnOutOfMemoryError 自动保存堆转储或用 jcmd 导出,再用 MAT 按 Retained Heap 找占用最多的对象及其到 GC Roots 的引用链;不该存活就是泄漏(静态集合、ThreadLocal 未 remove、资源未关闭),改代码,否则增大堆或减少一次加载的数据;慢泄漏可以定期用 GC.class_histogram 比较实例数。
@@ -174,7 +174,7 @@
1. B+ 树的一个节点就是一个 16 KB 的页,有几百上千个子节点,树很矮(200 万行的主键索引 3 层,二叉树约 21 层),一次 I/O 读一个节点,读盘次数少;内部节点只存 key 和指针,比 B 树每页放的 key 更多,每次查找都走到叶子、I/O 次数稳定;叶子按顺序用指针相连,范围查询顺序扫描即可。
2. 聚簇索引:InnoDB 主键索引的叶子节点保存整行数据,一张表只有一个;二级索引的叶子保存索引列和主键值,查其它列时先拿到主键,再到聚簇索引查出整行,这就是回表;覆盖索引指索引中已包含查询需要的全部列(含主键),不需要回表,Extra 为 Using index。
-3. 联合索引先按第一列排序、再按第二列,所以条件要从最左列开始连续使用才能定位,与 WHERE 中的书写顺序无关,范围条件之后的列一般不能再缩小扫描范围(带等号时例外);失效情况:缺少最左列、对索引列用函数、字符串列与数字比较、LIKE 以 % 开头、OR 一侧没有索引;匹配的行太多时,优化器按成本选择全表扫描。
+3. 联合索引先按第一列排序、再按第二列,所以条件要从最左列开始连续使用才能定位,与 WHERE 中的书写顺序无关,范围条件之后的列一般不能再缩小扫描范围(带等号时例外);常见的无法用索引定位的情况:缺少最左列(8.0.13 起少数情况可跳跃扫描)、对索引列用函数(可建函数索引)、字符串列与数字比较、LIKE 以 % 开头、OR 一侧没有索引;匹配的行太多时,优化器按成本选择全表扫描。
@@ -198,8 +198,8 @@
参考要点
1. 未提交读:三种都可能发生;提交读:解决脏读;可重复读:再解决不可重复读,按 SQL 标准仍可能幻读,InnoDB 用 MVCC 和 Next-Key Locks 在很大程度上避免;可串行化:三种都解决,并发度最低。
-2. 修改时旧版本写入 Undo Log,经回滚指针连成版本链;ReadView 记录创建时未提交的事务列表、其中最小的 TRX_ID_MIN 和下一个要分配的 TRX_ID_MAX;版本的 TRX_ID 是自己的或小于 MIN 时可见,大于等于 MAX 或仍在列表中时不可见,就沿版本链找上一个版本;提交读每条语句新建 ReadView,可重复读整个事务复用第一次读时的。
-3. 快照读(普通 SELECT)用 MVCC,复用的 ReadView 看不到其它事务新插入的行;当前读(FOR UPDATE、UPDATE、DELETE)加 Next-Key Lock,即记录锁加前面的间隙锁,其它事务无法往间隙中插入,非唯一索引还要锁到下一条记录之间的间隙;同一个事务中快照读和当前读混用时仍会出现幻读。
+2. 修改时旧版本写入 Undo Log,经回滚指针连成版本链;ReadView 记录创建时未提交的事务列表、其中最小的 TRX_ID_MIN 和下一个要分配的 TRX_ID_MAX;版本的 TRX_ID 是自己的或小于 MIN 时可见,大于等于 MAX 或仍在列表中时不可见,就沿版本链找上一个版本;提交读每次快照读都新建 ReadView,可重复读在第一次快照读时创建、整个事务复用。
+3. 快照读(普通 SELECT)用 MVCC,复用的 ReadView 看不到其它事务新插入的行;当前读(FOR UPDATE、UPDATE、DELETE)加 Next-Key Lock,即记录锁加前面的间隙锁,其它事务无法往间隙中插入;非唯一索引的等值查询还要锁到下一条记录之间的间隙,唯一索引等值命中已有记录时只锁这一条;同一个事务中快照读和当前读混用时仍会出现幻读。
@@ -222,9 +222,9 @@
参考要点
-1. 找:开启慢查询日志(long_query_time 线上调到 1 秒或更低),对比 Rows_examined 与 Rows_sent,用 mysqldumpslow 或 sys.statement_analysis 按语句归类;析:用 Explain、EXPLAIN ANALYZE 看是否索引失效、回表过多、filesort、连接列无索引或在等锁;改:例如建覆盖索引后从 237 ms 降到 20 ms,再核对执行计划并评估对写入的影响。
+1. 找:开启慢查询日志(long_query_time 线上调到 1 秒或更低),对比 Rows_examined 与 Rows_sent,用 mysqldumpslow 或 sys.statement_analysis 按语句归类;析:用 Explain、EXPLAIN ANALYZE 看是否索引失效、回表过多、filesort、连接列无索引,Lock_time 大时查锁冲突和长事务;改:例如建覆盖索引后从 237 ms 降到 20 ms,再核对执行计划并评估对写入的影响。
2. undo log 由 InnoDB 写,记录旧版本,用于回滚和 MVCC;redo log 由 InnoDB 写,记录对数据页的修改,循环写,用于崩溃恢复;binlog 由 Server 层写,记录行修改或语句,追加写,用于主从复制和按时间点恢复;两阶段提交先写 redo log 进入 prepare,再写 binlog,最后 commit,崩溃后 prepare 的事务在 binlog 中有 Xid 就提交、否则回滚,保证主库数据与 binlog 一致,从库才不会不一致。
-3. 跨分片的事务要用分布式事务,连接要拆成单表查询后在应用中合并,自增主键要改用雪花算法等分布式 ID;不带分片键的查询要查所有分片,可用基因法、映射表或按另一维度再存一份;跨分片排序分页时每个分片都要返回前 N 页的数据,改用游标分页;扩容时用双写或订阅 binlog 迁移数据。
+3. 跨分片的事务要用分布式事务,跨库连接拆成单表查询后在应用中合并,或用广播表、相同的分片规则避免跨库,自增主键要改用雪花算法等分布式 ID;不带分片键的查询要查所有分片,可用基因法、映射表或按另一维度再存一份;跨分片排序分页时每个分片都要返回前 N 页的数据,改用游标分页;扩容时用双写或订阅 binlog 迁移数据。
@@ -264,7 +264,7 @@
1. STRING、LIST、SET、HASH、ZSET 五种,另有 BITMAP、HyperLogLog、GEO、Stream;ZSET 成员多时用跳表,在有序链表上加多层索引,以 1/4 的概率升层,查找和插入删除期望 O(logN);相比平衡树,插入删除只改前驱指针、不需要旋转,范围查询沿底层链表遍历,实现简单;数据都在内存中,不需要 B+ 树那样减少磁盘 I/O。
2. RDB 把某个时间点的全部数据写成二进制快照,BGSAVE fork 子进程写入,文件紧凑、恢复快,但丢失最后一次快照之后的数据(可能几分钟);AOF 追加写命令,everysec 约丢 1 秒,文件更大,需要定期重写;混合持久化让重写的基础文件用 RDB 格式;两者都开启时用 AOF 恢复。
-3. 数据在内存中,一次 GET 只查一次哈希表;单线程执行命令,不加锁、没有线程切换;I/O 多路复用让一个线程处理大量连接,数据少时用 listpack 等紧凑编码;瓶颈通常是网络和内存,实测执行一条命令约 1 微秒,CPU 时间大部分花在网络读写的系统调用上。
+3. 数据在内存中,一次 GET 只查一次哈希表;单线程执行命令,不加锁、没有线程切换;I/O 多路复用让一个线程处理大量连接,数据少时用 listpack 等紧凑编码;执行命令本身很少成为瓶颈,CPU 时间大部分花在网络读写的系统调用上,实测每条命令的用户态时间约 1 微秒,流水线减少系统调用后吞吐量约为原来的 10 倍。
@@ -288,8 +288,8 @@
参考要点
1. 穿透查的是缓存和数据库都没有的数据,用短过期的空值缓存、布隆过滤器拦截;击穿是一个热点键过期,用互斥锁只让一个请求查库重建,或逻辑过期先返回旧值、后台重建;雪崩是大量键同时过期或缓存宕机,用随机过期时间、主从故障转移、预热、本地缓存、限流和熔断。
-2. 常用 Cache Aside:读未命中时查库并写回缓存,写时先更新数据库再删除缓存;删除而不更新是防止并发写乱序,先更新库是防止删缓存后读请求写回旧值;删除失败或慢读请求写回旧值时仍会不一致,补救有过期时间兜底、延迟双删、经消息队列重试删除、订阅 binlog 删除;只能最终一致,余额、库存扣减直接读写数据库。
-3. 哨兵在主从复制的基础上自动故障转移:多数哨兵确认主节点客观下线后,选出领头哨兵,按优先级和复制偏移量选新主并切换,但只有一个主节点写入,容量受单机内存限制;Redis Cluster 把 16384 个槽分到多个主节点,每个主节点有从节点,同时解决容量和高可用;两者都是异步复制,切换时可能丢失少量写入。
+2. 常用 Cache Aside:读未命中时查库并写回缓存,写时先更新数据库、事务提交后再删除缓存;删除而不更新是防止并发写乱序,先更新库是防止删缓存后读请求写回旧值;删除失败或慢读请求写回旧值时仍会不一致,补救有过期时间兜底、延迟双删、经消息队列重试删除、订阅 binlog 删除;只能最终一致,余额、库存扣减直接读写数据库。
+3. 哨兵在主从复制的基础上自动故障转移:认为主节点下线的哨兵数达到 quorum 后确认客观下线,再选出得票过半且不少于 quorum 的领头哨兵,按优先级和复制偏移量选新主并切换,但只有一个主节点写入,容量受单机内存限制;Redis Cluster 把 16384 个槽分到多个主节点,每个主节点有从节点,同时解决容量和高可用;两者都是异步复制,切换时可能丢失少量写入。
@@ -339,9 +339,9 @@
参考要点
-1. 进程是资源分配的基本单位,线程是调度的基本单位,线程共享进程的资源,创建和切换开销小,能直接读写共享数据,进程间要用 IPC;进程互相隔离,而一个线程崩溃会终止整个进程;协程是在用户态由运行时调度的轻量级线程,切换不陷入内核、内存占用小,大量协程映射到少量线程上,适合 I/O 密集任务,同一线程上的协程不能并行。
+1. 进程是资源分配的基本单位,线程是调度的基本单位,线程共享进程的资源,创建和切换开销小,能直接读写共享数据,进程间要用 IPC;进程互相隔离,而一个线程访问非法内存触发段错误时,整个进程都会被终止;协程是在用户态由运行时调度的轻量级线程,切换不陷入内核、内存占用小,大量协程映射到少量线程上,适合 I/O 密集任务,同一线程上的协程不能并行。
2. 四个条件:互斥、占有和等待、不可抢占、环路等待,同时成立才会死锁;预防就是破坏其中一个,例如一次申请全部资源、申请不到就释放已占有的资源或给锁设过期时间;最常用的是给资源编号、按顺序申请来破坏环路等待,例如转账总是先锁 id 小的账户。
-3. select 只能监听编号小于 1024 的描述符,且会修改传入的集合;poll 用 pollfd 数组,没有这个限制,但两者每次调用都把整个集合复制进内核逐个检查,开销与描述符总数成正比,返回后还要遍历;epoll 用 epoll_ctl 注册一次,就绪时由回调放入就绪链表,epoll_wait 只返回就绪事件,1 万个描述符时 poll 每次约 1.6 ms、epoll_wait 约 0.21 µs;epoll 只能在 Linux 上使用。
+3. select 只能监听编号小于 1024 的描述符,且会修改传入的集合;poll 用 pollfd 数组,没有这个限制,但两者每次调用都把整个集合复制进内核逐个检查,开销与描述符总数成正比,返回后还要遍历;epoll 用 epoll_ctl 注册一次,就绪时由回调放入就绪链表,epoll_wait 只返回就绪事件,监听 1 万个描述符、只有 1 个就绪时,poll 每次约 1.6 ms、epoll_wait 约 0.21 µs;epoll 只能在 Linux 上使用。
@@ -361,12 +361,12 @@
1. AOP 的原理是什么?JDK 动态代理和 CGLIB 有什么区别?(见[动态代理](Spring.md#动态代理))
2. Spring 怎样解决循环依赖?(见[循环依赖](Spring.md#循环依赖))
-3. 单例模式有哪几种写法?项目中还用过哪些设计模式?(见[实现](设计模式%20%20-%20单例.md#实现)、[设计模式前言](设计模式%20-%20目录.md#一前言))
+3. 单例模式有哪几种写法?项目中还用过哪些设计模式?(见[单例的实现](设计模式%20%20-%20单例.md#实现)、[项目中的用法](设计模式%20-%20目录.md#项目中的用法))
参考要点
-1. AOP 把日志、事务等横切逻辑从业务代码中抽离,Spring 基于动态代理实现,容器中保存代理对象,调用时先执行切面逻辑再调用原对象;JDK 动态代理要求目标实现接口,代理类继承 Proxy 并实现同样的接口,经 InvocationHandler 反射调用目标;CGLIB 在运行时生成目标的子类并重写方法,final 类和 final、private 方法无法代理;Spring Boot 默认用 CGLIB。
+1. AOP 把日志、事务等横切逻辑从业务代码中抽离,Spring 基于动态代理实现,容器中保存代理对象,调用时先执行切面逻辑再调用原对象;JDK 动态代理要求目标实现接口,代理类继承 Proxy 并实现同样的接口,经 InvocationHandler 反射调用目标;CGLIB 在运行时生成目标的子类并重写方法,final 类和 final、static、private 方法无法代理;Spring Boot 默认用 CGLIB。
2. 针对字段或 Setter 注入的单例 Bean,用三级缓存提前暴露未初始化完的对象:一级存初始化完成的 Bean,二级存提前暴露的 Bean,三级存生成提前引用的工厂;A 实例化后放入工厂,注入 B 时 B 从工厂拿到 A 的提前引用,A 需要 AOP 代理时工厂提前创建代理,使 B 拿到的就是最终对象;构造器注入和原型 Bean 无法解决,Spring Boot 2.6 起默认禁止循环依赖。
3. 单例:线程不安全的懒汉式、饿汉式、方法加锁的懒汉式、双重校验锁(变量要加 volatile)、静态内部类、枚举(可防反射和反序列化);项目中常见:下单校验用责任链,运费和支付渠道用策略加简单工厂,物流回调用模板方法,支付成功后的通知用观察者,事务和分布式锁用代理(Spring AOP)。
@@ -391,9 +391,9 @@
参考要点
-1. 用 SET lock_key unique_value NX PX 30000 一条命令加锁并设置过期时间;释放时用 Lua 脚本比较 value 是自己的才删除,避免误删别人的锁;业务超时锁会提前释放,需要续期,Redisson 的看门狗每 10 秒把过期时间重置为 30 秒,并用哈希计数支持可重入;主从是异步复制,切换时锁可能丢失。
+1. 用 SET lock_key unique_value NX PX 30000 一条命令加锁并设置过期时间;释放时用 Lua 脚本比较 value 是自己的才删除,避免误删别人的锁;业务超时锁会提前释放,需要续期,Redisson 的看门狗每 10 秒把过期时间重置为 30 秒,并用哈希计数支持可重入;主从是异步复制,切换时锁可能丢失;这种情况和续期失败都会让两个客户端同时持锁,正确性要求高时用栅栏令牌或数据库的唯一约束、条件更新兜底。
2. 不丢:发送端收到确认才算成功(如 Kafka acks=all 加重试),可用与业务同事务的本地消息表;存储端持久化加多副本,消费端处理完再提交偏移量或 ack;不重复:处理完才确认意味着可能重复投递,消费要幂等,用订单号加事件类型这类业务键写去重表并与业务同事务,或用状态做条件更新;消息队列的恰好一次代替不了业务幂等。
-3. 超卖源于判断库存和扣减库存不是原子操作;数据库中可用悲观锁 SELECT ... FOR UPDATE、乐观锁版本号(冲突多时会少卖)或条件更新 UPDATE ... SET stock = stock - 1 WHERE stock > 0,条件更新不需要重试、最常用;实测先查再扣多卖了 34~62 件,三种写法都恰好卖出 100 件;秒杀时先用 Redis 的 Lua 脚本挡住大部分请求,数据库的条件更新作为最后一道防线。
+3. 超卖源于判断库存和扣减库存不是原子操作;数据库中可用悲观锁 SELECT ... FOR UPDATE、乐观锁版本号或条件更新 UPDATE ... SET stock = stock - 1 WHERE stock > 0;实测 1000 个用户抢 100 件,先查再扣多卖了 34~62 件,悲观锁和条件更新都恰好卖出 100 件;乐观锁不会超卖,但冲突多时会少卖,150 个用户抢、重试 3 次也只卖出 48~57 件;条件更新不需要重试、最常用;秒杀时先用 Redis 的 Lua 脚本挡住大部分请求,数据库的条件更新作为最后一道防线。
@@ -406,9 +406,9 @@
答题思路
-1. 页面与网关:开售前按钮置灰、提交后禁止重复点击;网关按用户、IP、接口限流,拦截刷单,被限流的请求返回 429。
+1. 页面与网关:详情页静态化后放在 CDN,开售前按钮置灰、下单地址到点才下发,提交后禁止重复点击;网关按用户、IP、接口限流,拦截刷单,被限流的请求返回 429。
2. 服务与缓存:秒杀服务独立部署;开售前把库存加载到 Redis,用 Lua 脚本原子地判断一人一单、判断库存、扣减并记录用户,没有库存直接返回已抢完。
-3. 消息队列:扣减成功的请求写入消息队列异步下单,订单服务按自己的能力消费;发送失败时回补 Redis,消费处理完再确认,靠订单表(用户 ID,商品 ID)唯一索引保证幂等。
+3. 消息队列:扣减成功的请求写入消息队列异步下单,订单服务按自己的能力消费;发送明确失败时回补 Redis,超时的消息可能已经送达,交给对账判断;消费处理完再确认,扣减数据库库存和插入订单放在同一个事务中,靠订单表(用户 ID,商品 ID)唯一索引保证幂等,重复的消息插入失败时整个事务回滚。
4. 数据库:UPDATE goods SET stock = stock - 1 WHERE id = ? AND stock > 0 兜底防超卖;Redis 库存只是闸门,以数据库为准,活动后对账,未支付的订单超时取消并回补库存。
5. 降级:依赖持续失败时熔断并返回默认结果(如详情页显示“有货”),大促时关闭评论、积分等非核心功能,积分通知延后写入消息队列,核心功能无法降级时快速失败提示稍后再试;限流用的 Redis 不可用时退回单机限流。