卷 B · 后端与分布式 · 高频面试题
通用软件工程师 / FDE 高频面试题库 · 主线卷 B 题源:牛客网公开面经(
notes/corpus.jsonl,85 份独立面试经历帖) 题目统计:普通问答 45 · 手撕 8 · N 备选 0 · 合计 53 个条目。
难度分布(全卷,含手撕):L1 13 · L2 30 · L3 10
频次口径:
🔥×N= N = 该题在独立面经帖中的命中数(同义题合并后计),检索键写在每题正文的<!-- src: ... -->里,由tools/fill_freq.py机械回填、CI 用--check校验。低频实录·1源= 只有 1 份独立面经支撑;补充= 非面经的整理题,不伪造频次。
使用方式:题目默认折叠,点开看答案。建议按 L1 → L2 → L3 顺序刷;同级内按频次从高到低。手机端原生支持。项目类 / 场景类问题的背景已用自然语言写进题干;「追问」列出常见后续考点。
与其他卷的关系:本卷覆盖存储 → 缓存 → 消息 → 服务治理 → 容量与稳定性这条后端主线,与卷 C(操作系统)、卷 D(系统设计)正交。适合岗位:后端开发 / 全栈 / FDE(后端交付面)。语言以 Java / Go 为主,原理通用。
01 并发与线程池(9 题)
L1 🔥×14 Q01 · 线程能无限创建吗?线程池的核心参数有哪些?
答:不能无限创建:每个线程都要栈空间(MB 量级)+ 内核调度实体,上下文切换开销随线程数上升;线程数远超核数时 CPU 反而变慢。
线程池核心参数(Java ThreadPoolExecutor):① corePoolSize 核心线程数;② maximumPoolSize 最大线程数;③ keepAliveTime + unit 非核心线程存活时间;④ workQueue 任务队列(有界优于无界);⑤ threadFactory 线程工厂(要能命名);⑥ handler 拒绝策略(AbortPolicy / CallerRunsPolicy / DiscardPolicy)。
执行流程:核心未满 → 建核心线程;核心已满 → 入队;队列满 → 建非核心线程;到上限 → 拒绝。
易错:无界队列让 maximumPoolSize 永不生效(永远进队列),OOM 风险;CallerRunsPolicy 会阻塞提交方线程,用在 Web 入口需评估。
追问:① 线程池大小怎么定量?② 为什么阿里规约说不要用 Executors?③ 线程池怎么监控队列堆积?
L1 🔥×5 Q02 · 线程池的七个参数分别是什么?线上 CPU 突然飙高怎么定位?
答:七个参数(Java):corePoolSize、maximumPoolSize、keepAliveTime、unit、workQueue、threadFactory、handler。
CPU 飙高定位顺序:
top -H -p <pid>找出占 CPU 最高的线程 ID(十进制转十六进制)。jstack <pid> > /tmp/jstack.txt,用线程 ID 找到对应栈。- 看栈顶是业务死循环(如
while(true)无 sleep)、GC 线程(说明是内存问题)、还是锁自旋。
高频原因:死循环、正则回溯(ReDoS)、频繁 GC(内存不足导致)、大量序列化 / 反射、锁竞争导致自旋。
易错:只 jstack 一次就下结论——需多次采样(间隔几秒)看栈是否变化,才能区分瞬时抖动与真实热点;忘了 top -H 的线程 ID 是十进制,而 jstack 里是十六进制。
追问:① 怎么抓火焰图定位热点?② GC 线程占 CPU 怎么进一步确认?③ 什么情况下 CPU 高但负载不高?
L1 🔥×22 Q03 · 进程和线程有什么区别?线程能无限创建吗?
答:
| 进程 | 线程 | |
|---|---|---|
| 资源 | 独立的地址空间 | 共享进程的堆、全局变量、文件描述符 |
| 切换开销 | 大(要切页表 / 地址空间) | 小(切寄存器组 + 栈) |
| 通信 | 需 IPC(管道、共享内存、socket) | 直接读写共享变量 |
| 隔离性 | 强(一个崩溃不影响另一个) | 弱(一个线程崩溃整个进程崩) |
| 并行 | 多核可真正并行 | 多核也可并行(同进程内) |
不能无限创建:每个线程都要栈空间(默认 MB 量级)+ 内核调度实体,内存与切换开销随线程数线性上升;线程数远超核数时 CPU 反而变慢。
易错:说"线程比进程轻量所以可以随便创建"——忽略了内存与上下文切换成本;用多线程替代多进程来做隔离(线程共享内存,反而更容易出竞态)。
追问:① Go 的 goroutine 和线程什么关系?② 协程为什么比线程轻?③ 什么场景必须用多进程?
L2 🔥×15 Q04 · Java 里哪些容器是线程安全的?`ConcurrentHashMap` 是怎么实现的?
答:线程安全容器:ConcurrentHashMap、CopyOnWriteArrayList/Set、ConcurrentLinkedQueue、BlockingQueue 系列、ConcurrentSkipListMap、原子类。
ConcurrentHashMap(Java 8 起):不再用分段锁,改为 CAS + synchronized 锁单个桶首节点(桶空用 CAS,否则锁头节点);读操作不加锁(靠 volatile 保证可见性);size() 用 CounterCell 分片计数,避免全局锁。
易错:以为 Hashtable 更好——它是全表锁且实现老旧;ConcurrentHashMap 不允许 null key / null value(并发下无法区分"不存在"和"值为 null")。
追问:① CopyOnWriteArrayList 适合什么场景(读多写少)?② ConcurrentHashMap 的 size 为什么不精确?③ 什么场景下 ConcurrentHashMap 仍不够(复合操作)?
L2 🔥×7 Q04B · Go 的 goroutine 机制是什么?和线程有什么区别?
答:goroutine 是 Go 的轻量级并发单元,由 Go 运行时(Go runtime)调度而非 OS 线程。
与线程的区别:
- 栈可增长:goroutine 初始栈 2KB,运行时按需扩容到 1GB(OS 线程栈通常固定 1MB)。
- 切换成本低:goroutine 切换只需保存少量寄存器(几十纳秒级);线程切换是微秒级。
- 多对一调度:GMP 模型——G(goroutine)→ M(OS 线程)→ P(处理器),M:N 调度,多个 goroutine 复用少量线程。
- 通信方式:
channel显式传递("不要用共享内存通信"),另有sync.Mutex等同步原语。
易错:以为 goroutine 一定比线程快——协程切换快,但CPU 密集任务不释放线程,goroutine 多了只会争抢 P;goroutine 里做阻塞 I/O 不释放 P(需 runtime.Gosched 或异步库)。
追问:① GMP 里的 P 是什么?② channel 阻塞时会发生什么?③ goroutine 泄漏怎么排查?
L2 🔥×13 Q05 · Java 的内存模型(JMM)是什么?`volatile` 和 `synchronized` 有什么区别?
答:JMM 描述线程间共享内存的可见性、有序性、原子性;线程读写变量须经主内存,故存在指令重排序问题。
三者保证:原子性靠 synchronized / 原子类 / CAS;可见性靠 volatile / synchronized;有序性靠 volatile(禁重排)/ synchronized。
volatile vs synchronized:volatile 不保证复合操作原子性(i++ 仍不安全)但几乎零开销;synchronized 是互斥锁,三者都保证,代价是阻塞与上下文切换。
典型用法:volatile boolean running 做退出标志;单例双检锁里 instance 必须 volatile。
易错:用 volatile 替代锁——count++ 依然丢更新;双检锁忘 volatile。
追问:① 什么是 happens-before?② CAS 是什么,有什么问题(ABA)?③ final 字段的安全发布?
L3 🔥×7 Q06 · 什么是 IO 多路复用?select / poll / epoll 有什么区别?
答:IO 多路复用 = 一个线程用系统调用监听多个 fd 的就绪状态,就绪后再发起实际读写,避免一个连接一个线程。
| select | poll | epoll | |
|---|---|---|---|
| fd 数量 | 有上限(FD_SETSIZE 1024) | 无上限 | 无上限 |
| 每次调用 | 每次都要把 fd 集合从用户态拷贝到内核 | 同 select | fd 只需拷贝一次(内核保存) |
| 就绪通知 | 遍历整个集合 | 遍历整个集合 | 内核维护就绪链表,回调返回 |
| 复杂度 | O(n) | O(n) | O(1) 平均 |
epoll 优势:① 无 fd 数量限制;② 避免重复拷贝与重复遍历;③ 支持边缘触发(ET)。
易错:以为 epoll 一定"更快"——在低并发少量 fd时 select/poll 与 epoll 差距不大;ET 模式下不处理完数据会丢事件,必须循环读到 EAGAIN。
追问:① 边缘触发 vs 水平触发?② epoll_wait 为什么不会假唤醒?③ 什么场景用多线程 + epoll?
L2 🔥×7 Q07 · 零拷贝是什么?`mmap` 又是做什么的?
答:零拷贝= 减少数据在内核态与用户态之间的拷贝次数。普通 read + write 要拷 4 次(磁盘→页缓存→用户 buf→socket buf→网卡)。
实现路径:
mmap:把文件映射到进程虚拟地址,用户态直接读写页缓存,省掉"页缓存→用户 buf"这一次拷贝;但页缺失仍有开销,且可能产生脏页回写压力。sendfile:在内核内直接把页缓存拷到 socket,完全不进用户态,Linux 还支持splice/tee。- DMA:网卡直接与内存交互,进一步减少 CPU 参与。
易错:把 mmap 说成"完全没有拷贝"——仍有一次页缓存到 socket 的拷贝;mmap 适合大文件、随机读,小文件频繁 mmap 的建表开销反而更高。
追问:① mmap 的页缺失代价?② Kafka 为何用 sendfile?③ Direct I/O 和零拷贝的关系?
L2 🔥×2 Q08 · 生产者-消费者模式怎么写?要解决哪几个问题?
答:本质是线程安全的任务队列。要解决四个问题:
- 线程安全:队列的入队出队不能被并发破坏 → 用
BlockingQueue(内部ReentrantLock+Condition)而不是LinkedBlockingQueue之外的自制队列。 - 阻塞与唤醒:队列空时消费者等待、非空时生产者等待,避免忙等。
- 背压(backpressure):队列有界,满了生产者阻塞或拒绝——无界队列 = 内存泄漏。
- 优雅关闭:用
poison pill(毒丸)或shutdown标志位让消费者正常退出,否则会丢任务或线程泄漏。
易错:用 while (queue.isEmpty()) {} 忙等(烧 CPU 且有竞态);用 LinkedList + synchronized 自制(无法精准唤醒);关闭时不发毒丸导致消费者卡在 take()。
追问:① 毒丸和中断标志怎么选?② 怎么做到任务不丢不重?③ 队列满了选阻塞还是拒绝?
02 JVM 与内存(7 题)
L2 低频实录·1源 Q09 · JVM、JRE、JDK 三者有什么区别?
答:
- JVM(Java Virtual Machine):字节码执行引擎 + 运行时数据结构(堆、栈、方法区)。
- JRE(Java Runtime Environment)= JVM + 运行时类库 + 依赖,只够运行 Java 程序。
- JDK(Java Development Kit)= JRE + 编译器(
javac)+ 调试工具(jstack / jmap / jstat)+ 打包工具,够开发。
易错:说"JDK 包含 JRE,JRE 包含 JVM"时把关系讲反——JDK ⊃ JRE ⊃ JVM,方向是从大到小;JDK 11 起不再单独发布 JRE。
追问:① 为什么 Java 一次编译到处运行?② javac 到字节码再到执行经过什么?③ GraalVM 的 native image 改变了什么?
L2 🔥×3 Q10 · JVM 内存区域有哪些?Java 对象从创建到释放经历什么?
答:线程私有:程序计数器、虚拟机栈(栈帧存局部变量表、操作数栈)、本地方法栈。线程共享:堆(对象实例)、方法区 / 元空间(类元信息,JDK 8 后在本地内存)、直接内存。
对象创建流程:
- 类加载检查:常量池解析符号引用。
- 分配内存:指针碰撞(连续)/ 空闲列表(不连续)——取决于堆是否规整。
- 内存初始化:赋默认值(
0/null)。 - 执行构造方法:
<init>,设置字段值。
易错:把"栈上分配"当默认——对象引用在栈,对象本身在堆(JIT 的逃逸分析可能做标量替换使其不实际分配);说元空间在堆里——元空间在本地内存。
追问:① 对象的内存布局有哪些部分?② 什么情况下对象会直接分配在栈上?③ 堆外内存为什么要单独管理?
L2 🔥×7 Q11 · 常见 GC 算法有哪些?G1 和 CMS 有什么区别?
答:基础算法:标记-清除(有碎片);标记-整理(移动存活对象,无碎片);标记-复制(适合低存活率);分代收集(新生代复制算法频繁、老年代标记整理低频)。
CMS(并发低停顿,JDK 8):并发标记 + 标记清除 → 碎片化需 Full GC、并发期 CPU 争抢、初始/重新标记仍 STW。 G1(分区回收,JDK 9+ 默认):堆分多个 Region,每区可作年轻代或老年代;停顿可预测(分批回收);SATB 并发标记;支持 humongous 对象。
易错:说 G1 "完全无停顿"——仍有 STW,只是可预测、可设目标;说 CMS 已被移除——JDK 14 起才移除,JDK 8 之后默认 G1。
追问:① 什么是 Full GC?② G1 的 Mixed GC?③ ZGC / Shenandoah 的取舍?
L3 低频实录·1源 Q12 · 线上 full GC 问题怎么排查?
答:排查顺序:
jstat -gcutil <pid> 1000连续观察,看老年代使用率曲线与 Full GC 频率。- 确认触发原因(老年代不足 / 元空间不足 / 晋升失败 / 大对象直进老年代),开
-XX:+PrintGCDetails看 GC 日志。 jmap -dump:live,format=b,file=dump.hprof <pid>,用 MAT 看 Dominator Tree 与 Leak Suspects。- 看 Eden 增长速度,判断是"分配太快"还是"回收不掉"。
调优方向:调整堆与新老代比例、减少大对象、清理无界缓存、按延迟要求选 GC(JDK 8 可考虑 CMS,JDK 9+ 默认 G1,延迟敏感可试 ZGC)。
易错:先调参再看日志——必须先拿到 GC 日志和 dump,否则是盲调;把 Full GC 和 GC 停顿混为一谈。
追问:① 怎么区分内存泄漏和内存不足?② dump 文件太大怎么办?③ GC 参数怎么定初值?
L2 🔥×17 Q13 · 什么是内存泄漏?Java 里怎么排查?
答:内存泄漏 = 已不用的对象仍被引用,无法回收 → 内存持续增长 → 最终 OOM。OOM 是结果,泄漏是常见原因之一。
常见来源:静态集合无限增长;无界缓存(HashMap 当缓存);监听器未注销;线程池 / 连接池未关闭;ThreadLocal 未 remove()(线程复用导致值驻留);类加载器泄漏。
排查:
jstat -gcutil看老年代趋势。jmap -dump:live,format=b,file=heap.hprof <pid>。- MAT → Leak Suspects + Dominator Tree,找 biggest dominator 与引用链。
- 两次 dump 做差集,持续增长的对象才是嫌疑。
易错:dump 不加 live 会把已死对象也写进去,MAT 结论失真;只看一次"谁最大"无法区分"泄漏"与"正常占用大"。
追问:① ThreadLocal 为什么会内存泄漏?② 弱引用什么时候用?③ jmap / jstack / jstat 各自的作用?
L2 🔥×2 Q14 · 类加载过程是什么?双亲委派模型解决了什么问题?
答:加载 → 验证 → 准备 → 解析 → 初始化 → 使用 → 卸载。
双亲委派:加载请求先交给父加载器尝试,父加载器加载不了才自己加载。层次:Bootstrap(C++ 实现,加载 JDK 核心类)→ Extension → Application。
解决的问题:
- 安全:核心类(如
java.lang.Object)不会被恶意代码用自己的 ClassLoader 替换。 - 一致性:同一个类在任何环境中都由同一个加载器加载,保证
==成立。
打破双亲委派的场景:SPI(如 JDBC 驱动由线程上下文类加载器 Thread.currentThread().getContextClassLoader() 加载)、Tomcat 的 WebAppClassLoader、OSGi。
易错:说"父加载器优先所以子类加载器无法自定义同名类"——只在委派链未被打破时成立;忽略 Thread.currentThread().getContextClassLoader() 在 SPI 中的关键作用。
追问:① 哪些类由 Bootstrap 加载?② 怎么判断两个类是否"同一个类"?③ 类卸载的条件?
L3 低频实录·1源 Q15 · Java 的两个大数相乘,`Integer` / `Long` 都不够用怎么办?
答:用 java.math.BigInteger(任意精度整数)或 BigDecimal(任意精度十进制)。BigInteger 内部以 int[] 存"以 2^32 为基的数位",乘法是分块竖式,复杂度 O(n²)(Karatsuba 可到 O(n^1.585))。
工程注意:不可变,每次运算返回新对象,高频循环要复用;BigDecimal 除法可能产生无限循环小数(需 MathContext 或 setScale + RoundingMode);与 int / long 混算自动提升,但比较要用 compareTo(equals 还比 scale,1.0 ≠ 1.00)。
易错:用 equals 比较 BigDecimal;循环里反复 new BigDecimal 造成 GC 压力;BigInteger 没有 double 构造(需经 BigDecimal)。
追问:① BigDecimal 为什么用 String 构造?② 浮点精度问题怎么彻底避免?③ 怎么判断一个数是不是整数?
03 数据库与存储(9 题)
L1 🔥×4 Q16 · MySQL 有哪些索引?主键索引和二级索引有什么区别?
答:按结构:B+ 树索引(聚簇 / 二级)、哈希索引(Memory 引擎、InnoDB 自适应哈希)、全文索引(FULLTEXT)、空间索引(SPATIAL)、普通索引(INDEX,非唯一)。
主键索引 vs 二级索引:
- 主键索引(聚簇索引):叶子节点存放整行数据,所以只有一棵,决定了数据的物理存储顺序。
- 二级索引(非聚簇):叶子节点存放索引列 + 主键值;查非索引列时需要回表。
用主键的好处:① 二级索引回表次数少(主键短 → 二级索引小);② 聚簇索引按主键顺序存储,主键自增时顺序写入、页分裂少(页分裂会显著降低写性能);③ 主键非空且唯一。
易错:以为"主键索引和二级索引是并列的两棵树"——聚簇索引只有一棵,其余都是二级索引;用 UUID 作主键会导致随机插入 → 页分裂 + 二级索引膨胀 + 缓存命中率低。
追问:① 什么是回表?什么情况不回表(覆盖索引)?② 为什么用 B+ 树而不是 B 树 / 红黑树?
L1 🔥×12 Q17 · 联合索引的最左前缀原则是什么?什么情况下索引会失效?
答:最左前缀原则:联合索引 (a, b, c) 只有从最左列开始连续使用才能命中。WHERE a=1 AND c=3(跳过 b)只用到 a;WHERE b=1 完全失效。
索引失效的常见原因:
- 对索引列做函数 / 运算:
WHERE YEAR(create_time)=2026→ 改写成范围条件。 - 隐式类型转换:字符串列
phone = 138xxx(数字比较)→ 转换列 → 失效。 - 前导模糊:
LIKE '%abc'失效(LIKE 'abc%'可以)。 OR连接非索引列;!=/NOT IN。- 违反最左前缀。
易错:认为"范围查询后面的列全失效"——c 不能用于缩小扫描区间,但仍在索引里,可通过索引条件下推(ICP)减少回表。
追问:① 什么是覆盖索引?② 索引下推(ICP)是什么?③ 怎么用 EXPLAIN 看是否回表?
L1 🔥×14 Q18 · 事务有哪些特性?四种隔离级别分别解决什么问题?
答:ACID:
- 原子性:undo log 保证。
- 一致性:由其它三个 + 应用逻辑保证。
- 隔离性:锁 / MVCC 保证。
- 持久性:redo log 保证(崩溃后能恢复)。
四种隔离级别(并发问题从严重到轻微:脏读 → 不可重复读 → 幻读):
| 级别 | 脏读 | 不可重复读 | 幻读 |
|---|---|---|---|
| 读未提交 RU | ✗ | ✗ | ✗ |
| 读已提交 RC | ✓ | ✗ | ✗ |
| 可重复读 RR | ✓ | ✓ | 部分解决(间隙锁) |
| 串行化 | ✓ | ✓ | ✓ |
MySQL 默认是 RR(比标准严格,靠间隙锁 / next-key lock 在很大程度上解决幻读)。
易错:说"RR 完全解决了幻读"——MySQL 通过间隙锁在 RR 下挡住了大部分幻读,但先读后写(UPDATE)仍可能幻读(因为是当前读);说"RC 下也有间隙锁"也不准确——RC 下正常操作基本不用间隙锁(仅外键 / 唯一键检查保留),正因如此 RC 才允许幻读。
追问:① MVCC 是什么?② RC 与 RR 在锁和一致性上的差别?③ 快照读和当前读的区别?
L2 🔥×12 Q19 · MVCC 是什么?主键索引和二级索引有什么区别?
答:MVCC(多版本并发控制)= 每个事务读数据时看到某个一致性快照版本,读写互不阻塞。
实现:InnoDB 每行隐藏字段含 DB_TRX_ID(最后修改该行的事务 ID)和 DB_ROLL_PTR(指向 undo log 链)。读操作按 ReadView 里的活跃事务列表判断可见性,从最近版本回溯找到对本事务可见的版本。
可见性规则:trx_id < min_trx_id(已提交)可见;>= max_trx_id(未来事务)不可见;落在中间则看是否在活跃列表里。
锁的粒度:MVCC 只作用于快照读;UPDATE / DELETE / SELECT ... FOR UPDATE 是当前读,读最新版本并加锁。
易错:说"RC 与 RR 的区别只是加锁方式"——两者都用 MVCC + 行锁,但 RR 的快照在事务第一次读时创建(整个事务复用),RC 是每条语句都创建新 ReadView;说"MVCC 让读不加锁所以没锁冲突"——当前读仍加锁。
追问:① RC 下的 ReadView 什么时候创建?② undo log 链怎么工作?③ MVCC 在 RC 和 RR 下的可见性差别?
L1 🔥×6 Q20 · 慢查询怎么优化?SQL 优化的思路是什么?
答:定位:① 开慢查询日志(slow_query_log、long_query_time)用 mysqldumpslow 分析;② EXPLAIN 看执行计划,重点看 type(ALL 最差)、key(实际用的索引)、rows、Extra(Using filesort 是危险信号);③ EXPLAIN ANALYZE 看实际耗时。
优化:索引(给 where / join / order by 的列建索引,遵循最左前缀,避免索引列上运算与隐式转换);SQL 改写(避免 SELECT *、UNION ALL 代替 UNION、深分页用游标分页);表结构(字段类型选型、合适才分表);架构(读写分离、缓存、分库分表)。
易错:先加索引不看执行计划——可能加了但优化器仍不用;LIMIT 不带 ORDER BY 时越靠后越慢。
追问:① 深分页怎么优化?② 什么是回表,怎么避免?③ 怎么量化"优化有效"?
L2 低频实录·1源 Q21 · MySQL 有哪些锁?间隙锁和 next-key lock 是什么?
答:按粒度:表锁、行锁(Record Lock 锁索引记录)、页锁。按性质:共享锁(S)、排他锁(X)、意向锁(IS / IX,表级意向,快速判断表上是否有行锁)。行锁通过索引实现——没走索引会锁全表所有行。
间隙锁(Gap Lock):锁住索引记录之间的间隙,阻止插入(防幻读)。主要在 RR 下使用——RC 下正常操作基本禁用间隙锁(仅外键 / 唯一键检查保留),这也是 RC 允许幻读的原因之一。
Next-Key Lock = 记录锁 + 间隙锁,锁住"记录及其前面的间隙",是 RR 下的默认行锁。
易错:说"间隙锁在 RC 下完全不存在"不准确——RC 下仅在外键 / 唯一键检查等场景保留;另一个误解是"行锁 = 锁行"——实际锁的是索引,更新未走索引的字段可能锁住整表范围的行。
追问:① RC 下怎么减少死锁?② 意向锁的作用?③ 死锁怎么排查(SHOW ENGINE INNODB STATUS)?
L2 🔥×7 Q22 · 数据库分库分表怎么设计?分片键怎么选?
答:分片键选择原则:① 数据分布均匀(避免热点分片);② 查询路由可预测(大部分查询能带上分片键,否则要广播查所有分片);③ 跨片事务少。
常见分片方式:
- 取模分片(
id % N):分布均匀,但扩容要迁移大量数据(N 变了几乎全迁)→ 用一致性哈希缓解。 - 范围分片(按时间 / ID 区间):范围查询友好,但写热点(都写最新一片)。
- 一致性哈希 + 虚拟节点:扩容只迁部分数据。
配套问题:① 分布式 ID(雪花算法 / 号段模式 / 数据库自增);② 跨片分页(二次查询法:先查 ID 再回表);③ 跨片 JOIN(广播表 / 绑定表 / 异构库);④ 扩容方案(双写迁移 + 校验)。
易错:只谈"怎么分"不谈"查询会不会变慢"——若 90% 查询不带分片键,就得做分片键绑定(把分片键冗余进查询条件)或引入聚合层;忽略热点分片(如某用户数据量极大)。
追问:① 雪花算法有什么缺点?② 分库分表后怎么保证全局唯一 ID?③ 扩容期间怎么保证数据一致?
L3 补充 Q23 · 为什么用 B+ 树做索引,而不是 B 树或红黑树?
答:B+ 树胜在三点:
- 非叶节点只存索引键,扇出更大(同样页大小能放更多键)→ 树更矮(3~4 层就能存千万级数据),磁盘 IO 次数少。
- 叶子节点用链表串起来(
next指针)→ 范围查询友好,能做顺序扫描,不必回到根节点。 - 所有数据都在叶子,查询路径长度一致,性能稳定。
对比:
- B 树:非叶节点也存数据 → 扇出小 → 树更高;范围查询要中序遍历。
- 红黑树:纯内存结构,节点小但扇出只有 2,千万级数据树高 20+,磁盘场景 IO 过多。
易错:说"B+ 树查询性能比 B 树好"——单点查找两者接近,B+ 树优势在范围查询和更矮的树;说"红黑树不能做磁盘索引"不严谨(理论上可以,只是层数太高)。
追问:① 为什么索引页大小通常是 16KB?② B+ 树的插入删除怎么维护?③ 为什么说 B+ 树适合写多读少?
L3 补充 Q24 · MySQL 和 MongoDB 的区别是什么?什么时候该选哪个?
答:核心差异:MySQL 是结构化关系型(固定 schema、强事务、JOIN 丰富);MongoDB 是文档型(BSON 文档、schema 灵活、弱事务、聚合管道)。
选型:
- 选 MySQL:数据模型稳定、需要强一致事务、复杂关联查询、报表统计。
- 选 MongoDB:字段经常变动、数据结构嵌套深(一次查询拿完整文档)、写多读少、schema 演进频繁。
易错:说"MongoDB 性能一定比 MySQL 快"——取决于访问模式(是否命中索引、是否需要回表、是否单文档原子操作);忽略 MongoDB 单文档原子性带来的冗余建模优势(避免 JOIN);说"MongoDB 没有事务"——4.0+ 支持多文档事务,只是代价较高。
追问:① MongoDB 的 WiredTiger 存储引擎?② 文档建模怎么避免 JOIN?③ 什么情况下两个都用?
04 缓存与消息队列(7 题)
L1 🔥×7 Q25 · 缓存穿透、击穿、雪崩分别是什么?怎么解决?
答:
| 现象 | 原因 | 解决 | |
|---|---|---|---|
| 穿透 | 查不存在的 key,缓存和 DB 都没,每次都打到 DB | 恶意请求不存在的 ID | ① 布隆过滤器拦截;② 参数校验;③ 缓存空值(设短 TTL) |
| 击穿 | 某个热点 key 过期瞬间,大量请求同时打到 DB | 单个热点 key 失效 | ① 互斥锁 / singleflight 只放一个请求回源;② 逻辑过期(值里带过期时间,过期返回旧值 + 后台刷新);③ 热点 key 永不过期 |
| 雪崩 | 大量 key 同时失效或缓存服务宕机,流量全压 DB | 批量过期 / Redis 挂 | ① 过期时间加随机抖动;② 多级缓存;③ 熔断降级;④ 集群 + 高可用 |
易错:把"穿透"和"击穿"搞混——穿透是 key 不存在,击穿是 key 存在但刚过期;击穿只加"空值缓存"没用(key 是存在的)。
追问:① 布隆过滤器能删除吗?② 逻辑过期怎么实现?③ 缓存预热怎么做?
L1 🔥×10 Q26 · Redis 分布式锁是怎么实现的?有什么坑?
答:基本实现:SET lock_key <unique_value> NX EX <ttl>,释放时用 Lua 脚本保证"校验 value + 删除"的原子性。
必须处理三问题:① 超时释放(必须带 TTL,否则进程崩溃死锁);② 误删他人锁(value 用唯一标识,释放前比对);③ 锁续期(业务超过 TTL 要看门狗续期或设足 TTL)。
更严重的坑:主从异步复制丢锁——主节点写入锁后未同步就宕机,切主后另一客户端也能拿锁。Redlock 用多数派加锁缓解,但有"时钟漂移 / GC 停顿"争议;更稳的是 ZooKeeper / etcd(临时顺序节点 + 会话,天然 TTL + fencing token)。
易错:SETNX + EXPIRE 两条命令——中间宕机留永久锁;释放锁直接 DEL 不校验 value——会删掉别人的锁。
追问:① 什么是 fencing token?② Redlock 有什么争议?③ 什么场景不需要分布式锁?
L2 低频实录·1源 Q27 · Redis 有哪些数据结构?底层存储和持久化机制是什么?
答:常用类型:String、Hash、List、Set、ZSet、Bitmap、HyperLogLog、Geo、Stream。
底层编码:小对象用紧凑结构(listpack / ziplist / intset),超阈值转 hashtable / skiplist+dict。
持久化:RDB 周期性快照(恢复快,可能丢最后一次快照后的数据);AOF 追加写命令(everysec / always,更完整但文件大、恢复慢)。生产常两者都开。
易错:说"Redis 单线程"——命令执行是单线程(6.0 后 IO 多线程化),但后台持久化 / 过期删除是别的线程;说"AOF 一定不丢数据"——everysec 仍可能丢 1 秒数据。
追问:① bigkey / hotkey 怎么发现和处理?② Redis 为什么单线程还这么快?③ Pipeline 和事务的区别?
L2 🔥×14 Q28 · 为什么要用消息队列?MQ 有哪几种类型?
答:价值:① 解耦(生产消费方互不感知);② 异步(主流程快速返回);③ 削峰(流量高峰堆在队列里);④ 广播(一条消息多方消费)。
主要类型:
- RabbitMQ:AMQP 协议,路由灵活(交换机 / 队列 / 绑定),低延迟,适合任务分发、中小吞吐。
- Kafka:日志型,分区有序、高吞吐(百万级 TPS)、可重放(offset 保留),适合日志收集、流式计算、大数据管道。
- RocketMQ:事务消息、延迟消息、顺序消息,电商场景常用。
- Pulsar:存算分离、多租户。
易错:用 Kafka 做精确路由(复杂路由是 AMQP 的强项);忽略消息堆积——消费能力不足时队列会涨满,需要监控 lag 并扩容消费者。
追问:① Kafka 为什么快(顺序写 + 零拷贝 + 批处理)?② 消息堆积怎么排查?③ 什么时候不该引入 MQ?
L3 低频实录·1源 Q29 · 消息队列如何保证消息不丢、不重复、有序?
答:三个环节分别防:
① 生产端不丢:生产者等待 broker 的 ack(acks=all),失败重试;或用 事务消息 / 本地消息表保证"业务成功 + 消息发送"的原子性。
② Broker 不丢:多副本(Kafka min.insync.replicas + acks=all)、持久化(flush)。
③ 消费端不丢:先处理业务,成功后再 ack;处理失败则不 ack,消息重回队列。
重复消费:MQ 普遍是 at-least-once,消费者必须幂等——用唯一业务 ID + 去重表 / 状态机,或用数据库唯一索引兜底。
有序:Kafka 只保证单分区内有序——要按业务键路由到同一分区(key 哈希);全局有序只能单分区(吞吐受限)。
易错:说"Kafka 能保证 exactly-once"——只在 Kafka 内部的"读写 + 处理"闭环(事务 / EOS 模式)成立;外部副作用(发短信、写第三方)仍需业务幂等;以为"消费后再 ack 就不会重复"——处理成功但 ack 前崩溃仍会重复投递。
追问:① 本地消息表怎么实现?② 顺序消息会不会被某个消费者卡住?③ 消费幂等有哪些实现层次?
L2 低频实录·1源 Q30 · 缓存和数据库的一致性怎么保证?先更新 DB 还是先更新缓存?
答:推荐"先更新 DB,再删除缓存"(Cache-Aside):读走 缓存→DB→回填;写是 更新 DB → 删除缓存。
为什么不是"更新缓存":并发写会因写入顺序不同让缓存存旧值(线程 A 写 DB=2、B 写 DB=3 并更新缓存=3,A 再更新缓存=2 → 缓存是旧值)。删除是幂等的,重复删无害。
并发下仍可能不一致(读线程读到旧 DB 值并回填)。缓解:延迟双删、订阅 binlog(Canal / Debezium)异步删缓存(更可靠)、缓存设较短 TTL 兜底。
易错:以为"先删缓存再更新 DB"更安全——它会造成更多缓存穿透(期间读全打到 DB);不做延迟双删 / binlog 订阅就宣称"强一致"。
追问:① 缓存删除失败怎么办?② 什么场景允许短暂不一致?③ binlog 订阅方案有什么坑?
L2 低频实录·1源 Q31 · 布隆过滤器是什么?有什么用?
答:布隆过滤器是一个位数组 + 多个哈希函数的概率型数据结构,用来快速判断"一定不存在"或"可能存在"。
原理:插入时把多个哈希位置 1;查询时若任一位为 0 → 一定不存在;若全为 1 → 可能存在(可能误判)。
用途:缓存穿透防护——请求先过布隆过滤器,不存在的 key 直接返回,不打到 DB;也可用于Redis 布隆过滤器直接挡在缓存层。
特点:只能加不能删(删除会把别的元素的位清零);有假阳性率(判存在但实际不存在,会打到 DB 再回源);内存占用极低(1e6 个元素、1% 误判率约需 1.2MB)。
可删除版本:Counting Bloom Filter(用计数器替代位)、Cuckoo Filter。
易错:把布隆过滤器说成"精确判断存在"——它是概率型,一定不存在才是确定的;以为删位实现很安全——位是共享的,误删会放大假阳性。
追问:① 误判率怎么算?② 为什么需要多个哈希函数?③ 和 Redis 的 Set 相比优势在哪?
05 分布式与微服务(7 题)
L1 🔥×14 Q32 · 微服务是什么?核心构建有哪些?和 SOA 有什么区别?
答:微服务 = 按业务边界拆成多个可独立开发 / 部署 / 扩缩容的小服务,用轻量通信(HTTP / gRPC / MQ)协作。
核心构件:API 网关、服务注册与发现、配置中心、熔断降级、链路追踪、消息队列、分布式事务方案。
与 SOA 的区别:SOA 强调企业级 ESB、粗粒度、集中式编排;微服务强调去中心化、细粒度、每个服务独立数据库、轻量通信、团队自治。
易错:把微服务说成"就是拆服务"——拆分粒度与边界才是难点,拆错会得到分布式 monolith(调用链比单体还复杂);忽略"独立数据库"这一关键特征。
追问:① 怎么划分服务边界?② 微服务的代价(网络延迟、调试、分布式事务)?③ 什么时候不该拆微服务?
L2 🔥×6 Q33 · 服务注册发现、负载均衡、网关分别解决什么问题?
答:
- 注册中心:服务实例的注册与发现——实例启动时注册(IP + 端口 + 元数据),下线时注销;客户端按服务名拿到实例列表。解决"找得到谁"。实现:客户端拉取(长轮询 / 推送)或服务端推送(ZooKeeper watch / Nacos 推送)。
- 负载均衡:在多个实例间分配流量。算法:轮询、加权轮询、最少连接、一致性哈希、幂等哈希(保证同一用户落到同一实例,利于本地缓存)。
- API 网关:统一入口——路由转发、认证鉴权、限流熔断、协议转换(HTTP ↔ gRPC)、灰度路由、协议聚合。
易错:以为负载均衡只是"轮询"——实际要考虑会话亲和(同用户同实例,否则本地缓存和长连接都失效);注册中心和配置中心混淆——配置中心管配置,注册中心管实例地址。
追问:① 服务下线时客户端怎么感知?② 一致性哈希解决什么问题?③ 网关做鉴权和服务做鉴权的区别?
L1 🔥×4 Q34 · 什么是幂等性?怎么保证接口幂等?
答:幂等 = 同一个请求执行 1 次和执行 N 次,对系统状态的影响相同。
为什么需要:网络超时后客户端重试、消息队列重复投递、用户重复点击、定时任务多实例并发。
保证手段:
- 唯一 ID + 去重表:请求带
request_id,服务端先查去重表(Redis / DB 唯一索引),已处理则直接返回上次结果。 - 数据库唯一索引:最可靠的兜底(依赖 DB 约束而非应用逻辑)。
- 状态机:只允许
待支付 → 已支付单向跃迁,重复请求判断当前状态直接返回。 - 乐观锁:
UPDATE ... WHERE version = ?或WHERE status = 'INIT'。
易错:只在前端做防重(按钮置灰)——前端可绕过,服务端必须兜底;用 SETNX 加锁但锁释放了记录还在——去重记录需要保留足够长时间(覆盖最大重试窗口)。
追问:① 幂等和防重的区别?② 去重表什么时候过期?③ 幂等键怎么生成?
L2 🔥×4 Q35 · CAP 理论是什么?BASE 和最终一致性呢?
答:CAP:
- C(一致性):所有节点在同一时刻看到相同数据。
- A(可用性):请求总能在有限时间得到响应。
- P(分区容忍):网络分区时系统仍继续工作。
结论:网络分区不可避免,所以 P 必选;发生分区时只能在 C 和 A 之间二选一——这是分区期间的取舍,不是常态。分布式系统通常选 AP(如 Cassandra)或 CP(如 ZooKeeper、etcd)。
BASE:BA 基本可用 + S 软状态 + E 最终一致,是对 ACID 的柔性替代,用于不需要强一致的场景(商品库存、购物车、点赞数)。
最终一致性:允许短暂不一致,但保证在没有新更新的前提下,经过一段时间后所有副本收敛。常见实现:异步复制 + 定时对账 + 补偿。
易错:说"选了 AP 就永远没有一致性"——最终一致性也是一致性,只是保证时间不同;把 CAP 当"三选二"——CAP 的 P 不是"可以不要",而是"必须处理"。
追问:① 常见的一致性模型有哪些?② 分布式事务有哪些方案(2PC / TCC / Saga / 本地消息表)?③ 什么时候必须用强一致?
L3 🔥×3 Q36 · 分布式事务有哪些方案?各自的适用场景?
答:
| 方案 | 机制 | 适用 |
|---|---|---|
| 2PC | 协调者 ask → 投票 → commit/rollback | 同步、参与者少、强一致;但协调者单点 / 阻塞 |
| TCC | Try 冻结资源 → Confirm 提交 / Cancel 回滚 | 强一致 + 高并发,需业务改造(每个服务实现三接口) |
| Saga | 一串本地事务 + 补偿事务 | 长流程、跨多服务;无隔离性,需处理脏读 |
| 可靠消息最终一致 | 本地消息表 / 事务消息 | 最常用(订单 + 消息),对用户无感 |
| 本地事务 | 放弃分布式,各服务自治 | 弱耦合场景 |
易错:以为"用了 MQ 就是分布式事务"——普通 MQ 只能保证最终一致,且要处理重复消费;TCC 的 Try 阶段必须冻结资源(不能直接扣减),否则 Confirm 失败无法回滚。
追问:① 本地消息表怎么避免"业务成功但消息没发"?② TCC 的空回滚和悬挂问题?③ Saga 怎么保证补偿幂等?
L3 🔥×12 Q37 · 限流怎么实现?令牌桶和漏桶有什么区别?滑动窗口的时间片怎么定?
答:算法:令牌桶(固定速率放令牌,允许突发,最常用);漏桶(固定速率流出,输出恒定、不允许突发,保护下游);固定窗口计数(最简单,但有临界突刺问题);滑动窗口(切成小格滚动统计,精度高、无突刺)。
时间片大小:片数越多精度越高、开销越大。经验 1 秒切 10 格(100ms/片);片过大接近固定窗口,过小则存储与计算浪费。
分布式限流:单机限流在多实例下会被放大 N 倍。方案:① Redis + Lua 统一计数(原子);② Sentinel / 网关做集群限流。
易错:只做单机限流却宣称"系统限流 1000 QPS"——集群总量是实例数 × 1000;滑动窗口用静态数组时必须清理过期格,否则内存无限增长。
追问:① 限流和熔断降级的区别?② 单机限流 vs 分布式限流怎么选?③ 限流后返回什么?
L2 🔥×12 Q38 · 熔断和降级是什么?它们和限流有什么区别?
答:
- 限流:按流量控制入口(QPS / 并发数),超出直接拒绝或排队。
- 降级:主动关闭次要功能或返回兜底数据,保证核心链路可用(如商品详情页挂了详情接口,返回缓存或"暂时无法查看")。
- 熔断:当依赖方错误率 / 慢调用率超过阈值时,临时切断调用(直接失败),避免线程被拖垮;半开状态试探恢复。
区别:限流是事前按流量控制;熔断是事中按失败率切断;降级是事后牺牲非核心能力。三者常配合:限流保护自己 → 熔断保护依赖 → 降级保证用户体验。
熔断器三状态:Closed(正常,统计失败率)→ Open(直接失败)→ Half-Open(放少量请求试探,成功则回 Closed)。
易错:降级只是"返回 null / 错误码"——真正的降级要有兜底方案(缓存数据、默认值、静态页);熔断后不做半开探测就永远不恢复。
追问:① 熔断阈值怎么定?② 降级数据从哪来?③ 怎么避免"降级风暴"?
06 可观测性与排障(6 题)
L1 🔥×5 Q39 · 一个公网接口不可用了怎么排查?
答:按链路从外到内逐层切:
- 确认范围:全部用户还是部分?某地域 / 某版本?先看监控大盘(QPS、错误率、P99)。
- 看变更:最近有无发布 / 配置 / 数据变更——回滚是最快的止血手段。
- 分层定位:DNS / 网络(解析、连通);入口层(网关、证书过期、限流触发、连接池打满);应用层(进程存活、线程池耗尽、GC 频繁、依赖超时);依赖层(DB / Redis / MQ)。
- 抓证据:日志、traceId 串链路、
jstack/jmap/tcpdump。 - 止血:限流、降级、扩容、切流。
易错:一上来就重启——可能掩盖问题且丢失现场;只查应用日志不看依赖与入口层,而故障常在边界。
追问:① 怎么区分是应用问题还是网络问题?② 灰度期间怎么快速对比?③ 怎么复现偶发问题?
L2 🔥×16 Q40 · 项目中怎么查看日志?线上问题怎么定位?
答:日志体系:
- 分级:ERROR / WARN / INFO / DEBUG,线上默认 INFO,问题排查时临时调级别(要能动态调整)。
- 结构化:JSON 格式,字段固定(
traceId、userId、接口名、耗时),便于检索与聚合。 - 采集与检索:本地文件 → Filebeat / Fluent Bit → Elasticsearch / Loki → Kibana / Grafana。
- 链路追踪:入口生成 traceId,通过 RPC / MQ 头透传,全链路日志带上它 → 一次查询串起整条调用链。
定位流程:拿到用户反馈(时间、账号、操作)→ 按 traceId / userId 检索 → 找到失败的那一跳 → 看上下文日志与监控 → 复现。
易错:日志里不打敏感信息(密码、身份证、完整手机号、token)——合规风险;不清理的日志文件把磁盘写满导致服务不可用(要有轮转 + 保留策略)。
追问:① traceId 怎么在跨服务 / 跨 MQ 时透传?② 日志级别动态调整怎么做?③ 磁盘被日志写满怎么应急?
L2 🔥×5 Q41 · 怎么用监控指标判断服务健康?Prometheus 是抓取还是接收?
答:Prometheus 是"拉取(pull)"模型——周期性地主动去各个 target 拉取指标,服务需暴露 /metrics(由 exporter 提供)。Pushgateway / OTel Collector 是"推送(push)"模型。
三类指标:Counter(只增,如请求总数,用 rate() 求速率);Gauge(可增可减,如连接数、队列长度、内存,看水位线);Histogram(分桶,如延迟,用 histogram_quantile(0.99, ...) 算 P99,桶边界决定精度)。
数据源:应用(Micrometer / SDK)、中间件 exporter、主机(node_exporter)、容器、业务自定义。
易错:拿 CPU 使用率做核心告警——应以上 RED(Rate/Error/Duration) 为主;Histogram 桶过粗 → P99 不准。
追问:① 为什么 Prometheus 用拉取而不是推送?② Grafana 做什么?③ 告警怎么避免"狼来了"?
L2 🔥×7 Q42 · 高可用怎么理解?服务怎么做到高可用?
答:高可用= 系统在部分组件故障时仍能对外提供服务,用可用性等级衡量(如 99.9% = 月度不可用约 43 分钟)。
实现手段:
- 避免单点:多实例 + 负载均衡;服务无状态(会话外置到 Redis),才能随意扩缩容。
- 健康检查 + 自动摘除:LB 定期探活,异常实例自动下线。
- 数据层高可用:主从 / 集群、读写分离、数据备份 + 定期演练恢复。
- 故障隔离:熔断降级、限流、舱壁隔离(bulkhead),避免一个依赖拖垮全局。
- 多活 / 容灾:同城双活、异地多活(流量路由 + 数据同步);异地做延迟容忍设计。
- 降级预案:核心链路保可用,非核心功能关闭。
易错:只加机器不等于高可用——没有健康检查、数据没同步、依赖没隔离,加机器反而放大故障;备份没演练恢复等于没有备份。
追问:① 99.9% 和 99.99% 的停机时间差多少?② 同城双活和异地多活的取舍?③ 怎么演练故障(混沌工程)?
L2 🔥×17 Q43 · Docker 和 k8s 的核心概念是什么?Pod 创建的过程是怎样的?
答:容器 = 进程 + namespace 隔离(网络、PID、挂载点、IPC、UTS)+ cgroup 资源限制;Dockerfile 描述镜像构建,多层共享。
k8s 核心对象:Pod(最小调度单位,同 Pod 容器共享网络 + 存储卷);Deployment(声明式扩缩容与滚动更新);Service(稳定虚拟 IP + 负载均衡);Ingress(七层入口);ConfigMap / Secret;StatefulSet(有状态)。
Pod 创建流程:① API Server 鉴权 → 写 etcd;② Scheduler 选节点 → 创建 Binding;③ 目标节点 kubelet 调 CRI 拉镜像 → 创建 pause 容器 → 创建业务容器 → CNI 配网。
易错:Pod 是一组容器的抽象,不是"一个容器"。
追问:① containerd 相比 docker 的优势?② Pod 怎么跨节点通信?③ 镜像大怎么优化(多阶段构建、alpine)?
L3 🔥×11 Q44 · CI/CD 用的哪些?Jenkins 的交互流程是什么?
答:流程:① 触发(push / PR / 定时 / 手动);② 拉代码 + 缓存依赖;③ 静态检查(lint、类型检查、依赖漏洞扫描);④ 单元 / 集成测试;⑤ 构建镜像并推仓库(tag 带 git sha,保证可追溯);⑥ 部署(滚动 / 蓝绿 / 金丝雀)+ 健康检查;⑦ 可回滚(保留上一版本镜像)。
Jenkins:用 Jenkinsfile 定义 Pipeline(声明式 / 脚本式),结构为 agent → stages(Build / Test / Deploy)→ post(清理、通知);Jenkins 是编排器,实际构建在 agent 节点执行。
易错:把 CI/CD 等同于"自动部署"——CI 重点是快速反馈,CD 重点是安全发布(灰度 + 可回滚);环境配置漂移(测试好好的、上线炸)是常见事故源,应用配置即代码 + 容器镜像保证一致。
追问:① 蓝绿 vs 金丝雀 vs 滚动更新的取舍?② 流水线怎么加速(缓存、并行、只跑变更)?③ 怎么防止 CI 被刷爆(限流、复用 runner)?
07 后端手撕代码(8 题)
L2 🔥×14 ✍ H01 · 手撕线程池(固定核心线程 + 有界队列 + 拒绝策略)
考察点:核心线程优先、队列满后扩到最大线程、最终拒绝;shutdown 要能等待在跑的任务结束。
实现:
import threading
from queue import Queue, Empty
class ThreadPool:
def __init__(self, core=2, max_workers=4, max_queue=100):
self.core, self.max_workers = core, max_workers
self.q = Queue(maxsize=max_queue)
self.lock = threading.Lock()
self.count = 0
self._shutdown = False
def _worker(self):
while True:
task, args, kwargs, fut = self.q.get()
if task is None: # 毒丸:优雅退出
self.q.task_done()
break
try:
fut.set_result(task(*args, **kwargs))
except Exception as e: # noqa: BLE001
fut.set_exception(e)
finally:
self.q.task_done()
def submit(self, fn, *args, **kwargs):
import concurrent.futures as cf
fut = cf.Future()
with self.lock:
if self._shutdown:
raise RuntimeError("pool shut down")
if self.count < self.core:
self.count += 1
threading.Thread(target=self._worker, daemon=True).start()
elif self.q.full():
if self.count >= self.max_workers:
raise cf.RejectedExecutionError("queue full and max workers reached")
self.count += 1
threading.Thread(target=self._worker, daemon=True).start()
self.q.put((fn, args, kwargs, fut))
return fut
易错:队列满时先判断是否还能扩线程(顺序反了会误拒);任务抛异常必须捕获并设置到 Future,否则 worker 线程会死掉;关闭时要投毒丸让 worker 退出并 join。
L1 🔥×7 ✍ H02 · 手撕 LRU 缓存(哈希表 + 双向链表)
考察点:get / put 均摊 O(1);容量为 0 的边界;节点要同时维护 prev / next。
实现:
class Node:
__slots__ = ("key", "val", "prev", "next")
def __init__(self, key=0, val=0):
self.key, self.val = key, val
self.prev = self.next = None
class LRUCache:
def __init__(self, capacity: int):
self.cap = capacity
self.map = {}
self.head, self.tail = Node(), Node() # 哨兵
self.head.next, self.tail.prev = self.tail, self.head
def _remove(self, n):
n.prev.next = n.next
n.next.prev = n.prev
def _add_front(self, n):
n.next = self.head.next
n.prev = self.head
self.head.next.prev = n
self.head.next = n
def get(self, key):
if key not in self.map:
return -1
n = self.map[key]
self._remove(n); self._add_front(n) # 访问即刷新
return n.val
def put(self, key, value):
if key in self.map:
self._remove(self.map[key]); del self.map[key]
if self.cap <= 0:
return
n = Node(key, value)
self.map[key] = n
self._add_front(n)
if len(self.map) > self.cap:
lru = self.tail.prev
self._remove(lru)
del self.map[lru.key]
易错:用普通 list 做 LRU 致 O(n);忘维护 map 与链表一致;容量 0 时仍插入。
L2 🔥×6 ✍ H03 · 手撕令牌桶限流器
考察点:按时间补充令牌、容量上限、并发安全;用 time.monotonic() 避免时钟回退。
实现:
import threading
import time
class TokenBucket:
def __init__(self, rate, capacity):
self.rate = rate # 每秒生成令牌数
self.capacity = capacity # 桶容量 = 最大突发量
self.tokens = float(capacity)
self.last = time.monotonic()
self.lock = threading.Lock()
def allow(self, n=1):
with self.lock:
now = time.monotonic()
self.tokens = min(self.capacity, self.tokens + (now - self.last) * self.rate)
self.last = now
if self.tokens >= n:
self.tokens -= n
return True
return False
易错:用 time.time()(会被 NTP 回退,应用 monotonic);漏了容量上限 → 长时间空闲后一次放行无限流量;并发下未加锁导致超发。
L2 🔥×2 ✍ H04 · 手撕生产者-消费者(带优雅关闭的阻塞队列)
考察点:线程安全、阻塞唤醒、背压、优雅关闭(毒丸)。
实现:
import threading
from queue import Queue
class ProducerConsumer:
def __init__(self, maxsize=10):
self.q = Queue(maxsize=maxsize) # 有界 = 背压
self.workers = []
def produce(self, items):
for it in items:
self.q.put(it) # 队列满则阻塞
for _ in self.workers:
self.q.put(None) # 毒丸:每个 worker 一个
def consume(self, handler, n=1):
def loop():
while True:
item = self.q.get()
if item is None:
self.q.task_done()
break
try:
handler(item)
finally:
self.q.task_done()
self.workers = [threading.Thread(target=loop, daemon=True) for _ in range(n)]
for w in self.workers:
w.start()
def join(self):
self.q.join()
for w in self.workers:
w.join()
易错:用 while not q.empty(): q.get() 做消费者——空队列瞬间返回导致丢任务;毒丸数量要与 worker 数一一对应,否则 worker 收不到毒丸永久阻塞;task_done() 必须与 get() 配对,否则 join() 永久挂起。
L2 🔥×2 ✍ H05 · 手撕一致性哈希(虚拟节点)
考察点:普通取模在扩容时几乎全部数据要迁移;一致性哈希只迁移环上相邻的一段;虚拟节点解决数据倾斜。
实现:
import hashlib
class ConsistentHash:
def __init__(self, nodes=None, replicas=150):
self.replicas = replicas
self.ring = {} # hash -> node
self.nodes = []
for n in nodes or []:
self.add_node(n)
def _hash(self, key):
return int(hashlib.md5(key.encode()).hexdigest(), 16)
def add_node(self, node):
self.nodes.append(node)
for i in range(self.replicas):
self.ring[self._hash(f"{node}#{i}")] = node
def get_node(self, key):
if not self.ring:
return None
h = self._hash(key)
# 顺时针找第一个 >= h 的虚拟节点
for k in sorted(self.ring):
if k >= h:
return self.ring[k]
return self.ring[sorted(self.ring)[0]] # 回绕
易错:每次 get_node 都 sorted(self.ring) → O(n log n),应预排序并用 bisect;不做虚拟节点(默认 1 个)→ 数据严重倾斜;忘记回绕(哈希大于环上最大值时)。
L2 补充 ✍ H06 · 手撕分布式 ID 生成器(雪花算法)
考察点:位段设计(时间戳 + 机器 ID + 序列号)、时钟回拨处理、同一毫秒内自增。
实现:
import threading
import time
class Snowflake:
EPOCH = 1700000000000 # 自定义纪元毫秒
WORKER_BITS = 5
SEQ_BITS = 12
def __init__(self, worker_id):
self.worker_id = worker_id & ((1 << self.WORKER_BITS) - 1)
self.seq = 0
self.last_ts = -1
self.lock = threading.Lock()
def _now(self):
return int(time.time() * 1000)
def next_id(self):
with self.lock:
ts = self._now()
if ts == self.last_ts:
self.seq = (self.seq + 1) & ((1 << self.SEQ_BITS) - 1)
if self.seq == 0: # 序列耗尽,等下一毫秒
while ts <= self.last_ts:
ts = self._now()
elif ts < self.last_ts: # 时钟回拨
raise RuntimeError("clock moved backwards")
else:
self.seq = 0
self.last_ts = ts
return ((ts - self.EPOCH) << (self.WORKER_BITS + self.SEQ_BITS)) \
| (self.worker_id << self.SEQ_BITS) | self.seq
易错:不用统一 epoch 会浪费高位;时钟回拨不处理会生成重复 ID(应等待或用备用 worker ID);worker ID 冲突会直接产生重复 ID(必须保证唯一);序列号耗尽时自旋等待要写成循环。
L2 🔥×5 ✍ H07 · 手撕限流下的接口幂等(唯一 ID + 去重表)
考察点:请求 ID 生成、并发下的原子"查-写"、重复请求返回相同结果。
实现:
import threading
class IdempotentService:
def __init__(self):
self.results = {} # request_id -> response
self.lock = threading.Lock()
def handle(self, request_id, fn):
with self.lock:
if request_id in self.results:
return self.results[request_id] # 命中直接返回上次结果
# 真实场景:先在 DB 插入唯一索引占位,再执行业务
resp = fn()
self.results[request_id] = resp
return resp
易错:把"查-写"做成两步且不加锁——两个并发请求都查不到,都会执行;生产实现应靠 DB 唯一索引(INSERT ... ON DUPLICATE KEY)或 Redis SETNX 原子占位,而不是应用层判断;去重记录永不过期会无限增长,需按业务窗口设置 TTL。
L3 🔥×5 ✍ H08 · 手撕最小堆(前 K 个 / Top-K)
考察点:维护大小为 K 的小顶堆;复杂度 O(n log K) 而非 O(n log n) 排序。
实现:
import heapq
def top_k(nums, k):
if k <= 0:
return []
heap = []
for x in nums:
if len(heap) < k:
heapq.heappush(heap, x)
elif x > heap[0]: # 比堆里最小的还大 → 有资格进 top-k
heapq.heapreplace(heap, x)
return sorted(heap, reverse=True)
易错:用 heappushpop 顺序写反而多一次操作(应先判 x > heap[0] 再 heapreplace);k > len(nums) 时堆不满是正常的;k <= 0 要单独处理(否则 heap[0] 会 IndexError)。
低频备选(未入主表)
- 分片上传 / 断点续传:多见于文件服务专项(携程面经出现),通用后端岗频率低。
- 爬虫与登录态保持:出现在个别面经,属业务专项。
- Spring 循环依赖与自动装配:出现 1 份独立面经,且偏框架细节,未入主表。
- Netty 编解码器与处理器注册:出现 1 份独立面经,网络框架专项,未入主表。
- Go GC 与内存对齐:出现 1 份独立面经,Go 专项未成主线。