秋饼头像
Java 字节,阿里,滴滴面试 31 题全解析:基础 / 框架 / 事务 / 中间件 / 数据库缓存 / 高并发,每题都给你「考点 + 答案 + 追问」封面图

Java 字节,阿里,滴滴面试 31 题全解析:基础 / 框架 / 事务 / 中间件 / 数据库缓存 / 高并发,每题都给你「考点 + 答案 + 追问」

面试不是背书比赛,是证明你脑子里有结构。

这篇文章把大厂 Java 后端面试最常问的 31 道题按 6 个板块拆开,每题都写三件事:

  1. 面试官到底在考什么(他想要的不是定义,是你的判断)
  2. 参考答案(能背下来就能答,含代码和配置)
  3. 追问与加分项(决定你是 60 分还是 85 分的地方)

全文约 3 万字,建议先收藏,面试前一周按板块刷。


目录

板块题号高频程度
一、Java 基础Q1–Q6★★★★★ 必问
二、框架(Spring 全家桶)Q7–Q12★★★★★ 必问
三、事务Q13–Q16★★★★☆
四、中间件Q17–Q21★★★★☆
五、数据库与缓存Q22–Q26★★★★★ 必问
六、高并发Q27–Q31★★★★☆
七、答题框架与复盘清单—送给不会「表达」的你

Java 大厂面试知识体系全景图

图 1:6 大板块不是 6 个孤岛。面试官真正的套路是「用缓存击穿引出限流,再回到一致性」——所以每答一题,主动往相邻板块勾一句,分数立刻不一样。


一、Java 基础(6 题)

Q1:HashMap 的容量为什么必须是 2 的幂?扩容时发生了什么?

面试官在考什么:你是「看过八股」还是「想过为什么」。

参考答案

① 2 的幂是为了用位运算替代取模。 定位数组下标时,(n - 1) & hash 只有在 n 是 2 的幂时才等价于 hash % n(此时 n-1 的二进制全是低位 1,与运算相当于保留低 log₂n 位)。位运算比取模快一个数量级。

即使构造时传了 new HashMap(17),内部也会通过 tableSizeFor() 向上取到 32。

② 扩容时元素只有两种去处,不需要重新计算 hash。

// JDK 8 resize() 的核心逻辑
if ((e.hash & oldCap) == 0) {
    // 低位链:留在原位置 j
} else {
    // 高位链:移动到 j + oldCap
}

因为容量翻倍后,参与取模的位数只多了一位,这一位来自 hash & oldCap。所以一个桶里的元素要么原地不动,要么整体后移 oldCap——这就是 JDK 8 不用重新 hash 的原因。

③ JDK 7 与 JDK 8 的扩容差异(这是隐藏考点)

维度JDK 7JDK 8
数据结构数组 + 链表数组 + 链表 + 红黑树
插入方式头插法尾插法
并发扩容后果形成环形链表,get 时死循环 CPU 100%不会成环,但会丢数据、size 错乱
hash 扰动4 次移位异或1 次 h ^ (h >>> 16)

头插法在并发扩容时,两个线程同时 transfer,链表指针互相指向对方,就成了环。JDK 8 改尾插解决了这个问题,但 HashMap 依然不是线程安全的——多线程 put 依然会互相覆盖。并发场景请用 ConcurrentHashMap。

④ 几个常被追问的常量

  • 默认容量 16、负载因子 0.75:0.75 是空间与冲突概率的折中(源码注释里用泊松分布说明,λ=0.5 时链表长度到 8 的概率约 6×10⁻⁸)。
  • 树化阈值 8、退化阈值 6:留一个缓冲区,避免长度在 7/8 之间反复转换。
  • MIN_TREEIFY_CAPACITY = 64:链表长度到 8 但数组容量不足 64 时,优先扩容而不是树化,因为小表扩容更划算。

追问:为什么 key 和 value 都允许为 null? HashMap 允许(null key 固定在 0 号桶),因为单线程场景没有歧义。而 ConcurrentHashMap 不允许——get 返回 null 时你无法区分「key 不存在」和「value 就是 null」,在并发下 containsKey 再 get 也有竞态,所以干脆禁止。


Q2:ConcurrentHashMap 在 JDK 8 里是怎么保证线程安全的?get 为什么不用加锁?

参考答案

① 从分段锁到「CAS + synchronized 锁桶头」。

JDK 7 用 Segment 分段锁(继承 ReentrantLock,默认 16 段,并发度 16,扩容只能整段扩)。JDK 8 放弃分段,直接把锁粒度降到单个桶的头节点:

// putVal 的核心分支(简化)
if (casTabAt(tab, i, null, new Node<>(hash, key, value, null))) {
    // 桶为空:CAS 无锁写入
} else {
    synchronized (f) {   // f = tabAt(tab, i),只锁这一个桶
        // 链表遍历 / 红黑树插入
    }
}

f 的 hash 值决定桶的类型:>= 0 是普通链表节点;-1 是 ForwardingNode(正在扩容,当前线程去帮忙迁移);-2 是 TreeBin;-3 是 ReservationNode(computeIfAbsent 占位)。

② get 不加锁,靠 volatile 保证可见性。

static class Node<K,V> implements Map.Entry<K,V> {
    final int hash;
    final K key;
    volatile V val;          // 值可见
    volatile Node<K,V> next; // 链可见
}

数组本身用 volatile Node<K,V>[] table + Unsafe.getObjectVolatile 读,保证你读到的是已经发布完成的节点。因为没有「修改结构同时读」的强一致需求,get 天然是无锁的。

③ size 用类似 LongAdder 的分段计数。

baseCount + CounterCell[]:无竞争时 CAS 累加 baseCount,有竞争时按线程探针散列到不同的 CounterCell,最后 sum() 汇总。

注意:size() 返回的是弱一致值,并发写入时可能不准。所以别用 size() == 0 判断集合空(应该用 isEmpty(),它用 sumCount() <= 0L 判断)。

④ 扩容是多线程协作的。

transfer() 按步长(默认 16)划分任务,每个线程领一段迁移,迁完的桶放 ForwardingNode 做占位标记,其他线程看到就跳过或加入帮忙。这也是为什么 helpTransfer 能被 put/get 直接触发。

追问:为什么 JDK 8 的锁粒度更小反而不用 ReentrantLock 了? 因为锁的只是「一个桶」,临界区极短(几次指针操作),synchronized 有偏向锁/轻量级锁的优化,且不用手动释放,比 ReentrantLock 更轻。锁的粒度足够细时,JVM 内置锁的性能优势就体现出来了——这句话本身就是加分项。


Q3:线程池七个参数是什么?为什么是先入队再扩到最大线程数?

参考答案

public ThreadPoolExecutor(
    int corePoolSize,                   // 1 核心线程数(常驻,allowCoreThreadTimeOut 可回收)
    int maximumPoolSize,                // 2 最大线程数
    long keepAliveTime,                 // 3 非核心线程空闲存活时间
    TimeUnit unit,                      // 4 时间单位
    BlockingQueue<Runnable> workQueue,  // 5 任务队列
    ThreadFactory threadFactory,        // 6 线程工厂(一定要自定义,方便排查)
    RejectedExecutionHandler handler    // 7 拒绝策略
)

执行流程(必须按顺序说):

  1. 线程数 < core → 新建核心线程执行
  2. 线程数 ≥ core → 任务入队
  3. 队满 → 线程数 < max 则新建非核心线程
  4. 队列满且线程数 = max → 触发拒绝策略

为什么第 2 步先入队而不是直接扩线程? 创建线程的成本(栈内存 1MB、内核调度)远高于把任务放进队列。设计意图是:突发流量先用队列缓冲,队列都撑不住了才说明这是持续高负载,才值得扩线程。

代价是:如果用了无界队列(LinkedBlockingQueue 默认 Integer.MAX_VALUE),maximumPoolSize 永远不生效,任务无限堆积直到 OOM。

这也是阿里规范禁止 Executors 的原因:

方法隐患
newFixedThreadPool无界队列,OOM
newSingleThreadExecutor同上
newCachedThreadPool最大线程数 Integer.MAX_VALUE,可能创建海量线程
newScheduledThreadPool同上

正确姿势:

ThreadPoolExecutor pool = new ThreadPoolExecutor(
    16, 32, 60L, TimeUnit.SECONDS,
    new ArrayBlockingQueue<>(2000),                       // 有界
    new ThreadFactoryBuilder().setNameFormat("order-pool-%d").build(),
    new ThreadPoolExecutor.CallerRunsPolicy()             // 背压,让调用方变慢
);

线程数怎么定

  • CPU 密集:N + 1(N 为核数)
  • IO 密集:N × (1 + 等待时间 / 计算时间),经验值 2N
  • 最终答案永远是:先按公式给初值,再用压测找拐点。看三个指标:CPU 利用率、队列积压、RT 的 P99。

追问:拒绝策略有几种? AbortPolicy(默认,抛异常)、CallerRunsPolicy(调用线程执行,天然背压)、DiscardPolicy(静默丢弃)、DiscardOldestPolicy(丢最老的),以及自定义(记录日志 + 落库 + 降级返回)。

线上推荐 CallerRunsPolicy 或自定义,绝对不要用静默丢弃——你连丢了多少都不知道。


Q4:synchronized 的锁升级过程?和 ReentrantLock 怎么选?

参考答案

锁存在对象头的 Mark Word 里(64 位 JVM 下 8 字节),状态用低 2~3 位标记:

状态标记位说明
无锁001普通对象
偏向锁101Mark Word 记录线程 ID,同一线程重入零成本
轻量级锁00指向栈帧中的 Lock Record,CAS 自旋
重量级锁10指向 ObjectMonitor,涉及内核态阻塞
GC 标记11—

升级路径:无锁 → 偏向锁 → 轻量级锁 → 重量级锁,只能升不能降。

  • 偏向锁:第一个线程 CAS 写入自己的 ID,之后同一线程进来只比较 ID,连 CAS 都省了。发生竞争时撤销(需要 STW 到 safepoint),所以「高竞争场景偏向锁反而是负担」——JDK 15 起默认关闭(JEP 374),JDK 18 彻底废弃。
  • 轻量级锁:竞争时 CAS 把 Mark Word 换成指向 Lock Record 的指针,失败则自旋(自适应自旋)。
  • 重量级锁:自旋失败后膨胀为 ObjectMonitor,进入 EntryList 等待,涉及用户态/内核态切换。

对比表

维度synchronizedReentrantLock
实现层JVM 字节码 monitorenter/monitorexitJDK 层 AQS(state + CLH 队列)
释放自动(异常也会释放)必须 finally 手动 unlock
可中断❌✅ lockInterruptibly()
超时❌✅ tryLock(3, SECONDS)
公平锁❌ 非公平✅ 可选
条件变量一个 wait/notify多个 Condition,可精确唤醒
锁信息可见性jstack 可见需看 AQS 状态

怎么选:能不用锁就不用(无状态设计、CAS、ThreadLocal);必须用优先 synchronized(简单、不会忘记释放);需要超时/可中断/多条件队列/公平锁时用 ReentrantLock。

追问(2026 年的新加分项):虚拟线程下 synchronized 会怎样? 在 JDK 21~23 的虚拟线程里,synchronized 块会让虚拟线程 pin 住载体线程(carrier thread),无法卸载,吞吐骤降。JDK 24 的 JEP 491 已经解决了这个问题(synchronized 不再 pin)。但在 JDK 21 LTS 上做虚拟线程项目,必须把 synchronized 换成 ReentrantLock——这一条能直接区分「用过虚拟线程」和「只背过八股」。


Q5:volatile 保证了什么?DCL 单例为什么必须加 volatile?

参考答案

两个语义 + 一个不保证:

  1. 可见性:写操作立即刷回主存并使其他 CPU 缓存行失效(MESI 协议),读操作直接从主存读。
  2. 有序性:通过 4 种内存屏障禁止指令重排(LoadLoad / StoreStore / LoadStore / StoreLoad)。
  3. 不保证原子性:i++ 是「读-改-写」三步,volatile int i; i++ 依然线程不安全,要用 AtomicInteger 或加锁。

DCL 单例的标准写法:

public class Singleton {
    private static volatile Singleton instance;   // volatile 不能少

    public static Singleton getInstance() {
        if (instance == null) {                   // 第一次检查:避免每次都加锁
            synchronized (Singleton.class) {
                if (instance == null) {           // 第二次检查:防止重复创建
                    instance = new Singleton();
                }
            }
        }
        return instance;
    }
}

为什么必须 volatile:new Singleton() 在字节码层面是三步:

memory = allocate();      // 1. 分配内存
ctorInstance(memory);     // 2. 初始化对象
instance = memory;        // 3. 引用指向内存

2 和 3 之间没有数据依赖,可以被重排。重排后另一个线程可能在第 3 步之后、第 2 步之前进入,看到 instance != null 就直接返回——拿到一个字段全是默认值的半成品对象。加 volatile 后,写操作前插入 StoreStore 屏障,禁止这个重排。

追问:happens-before 有几条? 八条:程序顺序、监视器锁、volatile 变量、线程启动(start())、线程终止(join())、线程中断、对象终结、传递性。答题时挑 3~4 条最常用的说清楚即可,别说成「八股朗诵」。

追问:volatile 数组能让每个元素都可见吗? 不能。volatile int[] arr 只保证引用的可见性,arr[0]++ 依然不安全。


Q6:JVM 内存结构?一次 Full GC 频繁,你怎么排查?

参考答案

① 运行时数据区

区域线程存什么异常
程序计数器私有当前字节码行号唯一不会 OOM 的区域
虚拟机栈私有栈帧(局部变量表、操作数栈)StackOverflowError / OOM
本地方法栈私有native 方法同上
堆共享对象实例、数组OutOfMemoryError: Java heap space
方法区(元空间)共享类元信息、常量、静态变量Metaspace OOM
直接内存共享NIO DirectByteBufferOutOfMemoryError: Direct buffer memory

JDK 8 起方法区由元空间实现,从堆移到本地内存,受 -XX:MaxMetaspaceSize 限制。

② 对象存活判定 可达性分析,GC Roots 包括:虚拟机栈中的引用、方法区静态属性、常量引用、JNI 引用、活跃线程、同步锁对象等。不是引用计数——引用计数解决不了循环引用。

③ 分代与回收器

  • 新生代:Eden : S0 : S1 = 8 : 1 : 1,复制算法,对象年龄到 15 晋升老年代(-XX:MaxTenuringThreshold)。
  • 老年代:标记-整理 / 标记-清除。
  • G1(JDK 9 起默认):堆切成 ~2048 个 Region(1~32MB),逻辑上仍有 Eden/Survivor/Old/Humongous;用 Remembered Set 记录跨 Region 引用避免全堆扫描;用 SATB 快照在并发标记阶段保证不漏标;按「回收收益」排序,优先回收价值最高的 Region(Garbage First 的名字来源),通过 -XX:MaxGCPauseMillis 控制停顿目标。

④ 排查 Full GC 的六步

# 1. 看趋势:FGC 次数、耗时、各代占用
jstat -gcutil <pid> 1000

# 2. 看堆内对象分布,找"谁在涨"
jmap -histo:live <pid> | head -30

# 3. dump 下来分析(注意 live 会触发一次 FGC)
jmap -dump:live,format=b,file=/tmp/heap.hprof <pid>

# 4. 线程栈,看是不是死锁/大量线程阻塞
jstack <pid> > /tmp/thread.txt

# 5. GC 日志(JDK 9+ 统一日志)
-Xlog:gc*:file=/var/log/gc.log:time,level,tags:filecount=5,filesize=50m

# 6. MAT / JProfiler 分析支配树、Leak Suspects

常见根因(按出现频率排序):

根因特征处理
内存泄漏(静态集合、ThreadLocal、监听器未注销)老年代持续上涨,FGC 后降不下来MAT 找 GC Root 引用链
大对象 / 超大数组Histogram 里 byte[] 占比极高分页查询、限制附件大小
元空间泄漏(动态生成类、热部署)Metaspace 持续增长设 MaxMetaspaceSize,修 ClassLoader 泄漏
晋升过快(新生代太小 / 大对象直接进老年代)YGC 频繁且大量晋升调大新生代、-XX:PretenureSizeThreshold
显式 System.gc()(RMI、DirectByteBuffer 回收)日志里出现 System.gc()-XX:+DisableExplicitGC

加分说法:先回答「我会先确认是频繁但每次都能回收(说明分配率高,扩堆或调小对象生命周期),还是回收后仍不下降(说明泄漏,必须 dump)」。这一句就把「背八股」和「真排查过」分开了。


二、框架(Spring 全家桶,6 题)

Q7:Spring 三级缓存怎么解决循环依赖?为什么必须要三级,二级不够吗?

参考答案

// DefaultSingletonBeanRegistry 的三个 Map
singletonObjects       // 一级:成品 Bean(完全初始化 + 可能已是代理)
earlySingletonObjects  // 二级:半成品(已实例化但未填充属性)
singletonFactories     // 三级:ObjectFactory,能"按需"生产早期引用(可能是代理)

流程(A ↔ B 互相依赖):

  1. 创建 A:实例化后,把 A 的 ObjectFactory 放进三级缓存
  2. 填充 A 的属性,发现需要 B → 去创建 B
  3. 创建 B:实例化后,填充属性发现需要 A
  4. getSingleton("a"):一级没有 → 二级没有 → 三级有 factory,调用 getObject() 得到 A 的早期引用,放入二级缓存,删掉三级
  5. B 拿到 A 的引用,完成初始化,进入一级缓存
  6. 回到 A,注入 B,A 完成初始化,进入一级缓存

为什么二级不够? 关键在 AOP。

如果只有二级缓存,在第 1 步就得把「A 的早期引用」直接暴露出去。但此时 A 还没走完 BeanPostProcessor,代理对象还没生成。等 A 初始化完,真实对象和代理对象不是同一个——B 手里拿的是原始对象,容器里放的是代理对象,依赖不一致,事务/切面全失效。

三级缓存存的是 工厂,它能在被调用的那一刻调用 getEarlyBeanReference(),提前触发 AOP 创建代理。这样 B 注入的就是代理对象,和最终放进一级缓存的是同一个。

一句话总结:二级缓存存对象,三级缓存存「能生产对象的能力」,后者才能保证 AOP 场景下的引用一致性。

追问:构造器注入为什么解决不了? 构造器注入时,对象连「实例化」都没完成,没有引用可以提前暴露,工厂也无从生产。Spring 会直接抛 BeanCurrentlyInCreationException。解法:

@Service
public class AService {
    private final BService bService;
    public AService(@Lazy BService bService) { this.bService = bService; }  // 注入代理,延迟解析
}

其他解法:@Autowired(required = false) + setter、ObjectProvider<BService>、ApplicationContext.getBean()(不推荐)、重构拆出第三个类(最优)。

追问:Spring Boot 2.6 之后为什么默认禁止循环依赖? spring.main.allow-circular-references 默认 false。官方态度很明确:循环依赖是设计问题,不是框架问题。允许它存在会让启动顺序、代理逻辑变得难以推理。生产上应该拆分层,而不是打开开关。

追问:prototype 作用域的循环依赖能解决吗? 不能。原型 Bean 不缓存,每次都要新建,无法提前暴露。


Q8:Spring Bean 的完整生命周期?

参考答案(背诵版顺序)

1. 实例化(反射调用构造器)
2. 属性填充(依赖注入)
3. Aware 回调:BeanNameAware → BeanFactoryAware → ApplicationContextAware
4. BeanPostProcessor.postProcessBeforeInitialization
5. @PostConstruct → InitializingBean.afterPropertiesSet() → init-method
6. BeanPostProcessor.postProcessAfterInitialization   ← AOP 代理在这里生成
7. 使用中
8. @PreDestroy → DisposableBean.destroy() → destroy-method

几个关键判断:

问题答案
AOP 代理在哪一步生成第 6 步 postProcessAfterInitialization(AbstractAutoProxyCreator)
@PostConstruct 谁执行的CommonAnnotationBeanPostProcessor,属于第 4 步的 BPP 链
循环依赖的早期引用在哪一步产生第 1 步之后,通过 addSingletonFactory
BeanFactoryPostProcessor 和 BPP 的区别BFPP 改 BeanDefinition(早于所有 Bean 实例化);BPP 改 Bean 实例

扩展点全景

// 1. BeanFactoryPostProcessor:改元数据
@Component
public class MyBfpp implements BeanFactoryPostProcessor {
    @Override
    public void postProcessBeanFactory(ConfigurableListableBeanFactory bf) {
        BeanDefinition bd = bf.getBeanDefinition("orderService");
        bd.setScope("prototype");
    }
}

// 2. BeanPostProcessor:改实例(可返回代理)
@Component
public class MyBpp implements BeanPostProcessor {
    @Override
    public Object postProcessAfterInitialization(Object bean, String name) {
        if (bean instanceof OrderService) {
            return Proxy.newProxyInstance(/* ... */);   // 手动织入
        }
        return bean;
    }
}

// 3. SmartInitializingSingleton:所有单例创建完后执行
@Component
public class WarmUp implements SmartInitializingSingleton {
    @Override
    public void afterSingletonsInstantiated() {
        // 适合做缓存预热、本地缓存加载
    }
}

加分项:说出「SmartInitializingSingleton 是所有单例都创建完之后才执行,适合做缓存预热;而 @PostConstruct 执行时其他 Bean 可能还没就绪,不要在里面做跨 Bean 的初始化」。这种细节是真实项目踩过才知道的。


Q9:AOP 为什么会失效?列举场景和解决方案。

参考答案

Spring AOP 的本质是动态代理:调用方持有的必须是代理对象,才能进到通知逻辑。凡是绕过代理的调用,全都失效。

六大失效场景:

#场景原因解决
1同类内部 this 调用this.method() 走的是原始对象,不是代理① AopContext.currentProxy()(需 @EnableAspectJAutoProxy(exposeProxy = true))② 注入自身 ③ 拆到另一个 Bean
2private / final / static 方法CGLIB 无法覆写 final,private 不可见改成 public、非 final
3非 public 方法Spring AOP 只拦截 public(JDK 代理只能接口方法,CGLIB public/protected)改 public;或用 AspectJ 编译期织入
4自己 new 的对象没经过容器,不是代理交给 Spring 管理
5异常被内部 catch 吞掉通知拿不到异常,无法回滚别吞,或手动 TransactionAspectSupport.currentTransactionStatus().setRollbackOnly()
6异步/多线程中调用子线程里拿到的可能是原始引用,且事务上下文不传递把事务放到子线程内部,或显式传递代理

标准示例:

@Service
public class OrderService {

    @Transactional
    public void createOrder() {          // ❌ 事务不生效
        this.deductStock();              // this 是原始对象
    }

    @Transactional(propagation = Propagation.REQUIRES_NEW)
    public void deductStock() { }
}

三种修法:

// 修法 1:注入自身(Spring 4.3+ 支持)
@Service
public class OrderService {
    @Autowired @Lazy private OrderService self;

    public void createOrder() {
        self.deductStock();              // ✅ 走代理
    }
}

// 修法 2:AopContext(需要 exposeProxy = true)
@EnableAspectJAutoProxy(exposeProxy = true)
// ...
((OrderService) AopContext.currentProxy()).deductStock();

// 修法 3:拆 Bean(最推荐,架构上也更清晰)
@Service
public class StockService {
    @Transactional(propagation = Propagation.REQUIRES_NEW)
    public void deductStock() { }
}

加分项:主动说明「修法 3 最好,因为前两种都在跟框架较劲,本质是把一个本该独立的边界硬塞在一个类里」。


Q10:Spring 事务的 7 种传播行为?REQUIRES_NEW 和 NESTED 有什么区别?

参考答案

传播行为有外层事务时无外层事务时典型场景
REQUIRED(默认)加入新建绝大多数业务
REQUIRES_NEW挂起外层,新建独立事务新建记日志、发通知(失败不能影响主流程)
NESTED创建保存点(savepoint)新建子步骤失败只回滚子步骤
SUPPORTS加入不用事务查询
NOT_SUPPORTED挂起外层不用事务非核心耗时操作
MANDATORY加入抛异常强制要求调用方开事务
NEVER抛异常不用事务禁止在事务中执行

REQUIRES_NEW vs NESTED(高频)

@Transactional
public void outer() {
    insertA();
    try {
        innerService.inner();   // REQUIRES_NEW
    } catch (Exception e) {
        // 吞掉异常
    }
    // inner 已提交,outer 后续异常不会回滚 inner
}
  • REQUIRES_NEW:两个物理事务,需要两个数据库连接。内层提交后,外层回滚也不会回滚内层。用 DataSourceTransactionManager 时必须注意连接池要够(外层占一个,内层再要一个,嵌套深了容易死锁)。
  • NESTED:同一个物理事务,靠 JDBC savepoint 实现。内层回滚只回到保存点,外层继续;但外层回滚时内层一定回滚。

一句话区分:REQUIRES_NEW 是「另起炉灶」,NESTED 是「存档读档」。

加分项:事务失效的 6 个原因(和 Q9 呼应) 非 public 方法、同类自调用、异常类型不匹配(默认只回滚 RuntimeException 和 Error,受检异常要 rollbackFor = Exception.class)、多数据源未指定事务管理器、异常被 catch、类没被代理(如 new 出来的、final 类)。


Q11:Spring Boot 自动装配的原理?为什么 3.0 干掉了 spring.factories?

参考答案

① 入口注解

@SpringBootApplication
  = @SpringBootConfiguration        // 本质是 @Configuration
  + @EnableAutoConfiguration        // 自动装配开关
  + @ComponentScan                  // 扫描当前包及子包

② 自动装配链路

@EnableAutoConfiguration
  → @Import(AutoConfigurationImportSelector.class)
  → getAutoConfigurationEntry()
  → 读取 META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports(3.0+)
     或 META-INF/spring.factories(2.x)
  → 得到候选配置类全限定名列表(约 140+ 个)
  → 经 @ConditionalOnClass / @ConditionalOnMissingBean 等条件过滤
  → 注册为 BeanDefinition

③ 条件注解是灵魂

@AutoConfiguration
@ConditionalOnClass(DataSource.class)                       // 类路径有 DataSource 才生效
@ConditionalOnMissingBean(DataSource.class)                 // 用户没自己配才生效
@EnableConfigurationProperties(DataSourceProperties.class)  // 绑定 spring.datasource.*
public class DataSourceAutoConfiguration {

    @Bean
    @ConditionalOnMissingBean
    public DataSource dataSource(DataSourceProperties props) {
        return props.initializeDataSourceBuilder().build();
    }
}

「约定优于配置」的本质就是:先给一套默认值,条件满足才装配,用户自定义就退让(@ConditionalOnMissingBean)。

④ 为什么 3.0 移除 spring.factories?

原因说明
启动性能spring.factories 是一个大文件,所有扩展点混在一起,每次都要全量解析;新文件按需读取,且支持索引
可读性一个文件里混着 Listener、Initializer、AutoConfiguration,很难维护
AOT / NativeGraalVM 原生镜像需要在构建期确定装配结果,独立清单更容易做静态分析
语义清晰AutoConfiguration.imports 只放自动配置,职责单一

⑤ 自定义 starter 三步

  1. 建 xxx-spring-boot-starter 模块,引入 spring-boot-configuration-processor
  2. 写 XxxAutoConfiguration + @ConfigurationProperties
  3. 在 META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports 里写全限定名(一行一个)

Q12:MyBatis 的 #{} 和 ${} 有什么区别?一级/二级缓存有什么坑?

参考答案

① #{} vs ${}

维度#{}${}
实现PreparedStatement 占位符 ?字符串直接拼接
预编译✅ 可复用执行计划❌ 每次都是新 SQL
SQL 注入安全有风险
适用参数值表名、列名、order by 字段等标识符
<!-- ✅ 安全 -->
<select id="get" resultType="User">
    SELECT * FROM user WHERE name = #{name}
</select>

<!-- ❌ 注入:name 传 "1' OR '1'='1" 就全表泄露 -->
<select id="get" resultType="User">
    SELECT * FROM user WHERE name = '${name}'
</select>

<!-- ✅ 排序字段必须用 ${},但要做白名单校验 -->
<select id="list" resultType="User">
    SELECT * FROM user ORDER BY ${sortField} ${sortOrder}
</select>

注意:#{} 对 order by 无效(会变成 order by 'create_time',按常量排序,等于没排),所以排序字段只能 ${} + 白名单枚举校验。

② 一级缓存(SqlSession 级,默认开启)

  • 同一个 SqlSession 内相同 SQL + 参数,第二次直接走缓存
  • insert/update/delete 会清空缓存
  • 坑:与 Spring 整合后,如果没开事务,每次 Mapper 调用都是新的 SqlSession,一级缓存等于没用;如果开了事务,同一个事务内可能读到旧数据(别的线程已更新但本事务缓存未清)

③ 二级缓存(Mapper/namespace 级,需手动开启)

<cache eviction="LRU" flushInterval="60000" size="1024" readOnly="false"/>

必要条件:<cache/> + @CacheNamespace 或全局 cache-enabled=true + 实体实现 Serializable。

三大坑:

  1. 跨 namespace 脏读:OrderMapper 和 UserMapper 都缓存了关联查询结果,更新 user 表只清 UserMapper 的缓存,OrderMapper 里的旧数据还在 → 必须配 <cache-ref>,但配了又变成大范围失效
  2. 多表联查必然脏:这是二级缓存的死穴
  3. 分布式环境下各节点缓存不一致:本地缓存没有跨节点失效机制

结论:生产环境建议直接关掉二级缓存,缓存交给 Redis 统一管理(可控失效、可观测、跨节点一致)。


三、事务(4 题)

Q13:ACID 分别靠什么保证?redo log 和 binlog 怎么保证一致?

参考答案

特性实现机制说明
A 原子性undo log记录反向操作,回滚时逆序执行
C 一致性上述三者的结果,加上业务约束一致性是目的,不是手段
I 隔离性锁 + MVCC写写靠锁,读写靠 MVCC 快照
D 持久性redo log(WAL)先写日志再刷脏页,崩溃可重放

关键细节:为什么是 WAL 而不是直接写数据页? 数据页是随机写(B+Tree 分散在磁盘各处),redo log 是顺序追加写,顺序 IO 比随机 IO 快 2~3 个数量级。而且 redo log 体积小(只记物理页变更),可以先攒着批量刷。

innodb_flush_log_at_trx_commit 三档:

  • 1(默认):每次提交都 fsync → 不丢数据,性能最低
  • 0:每秒 fsync 一次 → 最多丢 1 秒
  • 2:每次提交写到 OS cache,每秒 fsync → MySQL 挂了不丢,机器断电丢 1 秒

两阶段提交(redo 与 binlog 的一致性)

1. InnoDB 写 redo log,标记 prepare
2. 写 binlog,fsync
3. InnoDB 写 redo log,标记 commit

崩溃恢复判定表(这是加分点):

redo 状态binlog 是否存在完整事务处理
prepare无 / 不完整回滚
prepare完整提交
commit—提交

如果不用两阶段提交(先写 redo 再写 binlog),在两步之间崩溃就会出现「主库有、从库没有」或者反过来的数据不一致 —— 这是主从数据不一致最经典的根因。

加分项:组提交(group commit) 多个事务的 redo fsync 合并成一次系统调用,把「每次提交都 fsync」的成本摊薄。这是 MySQL 5.7+ 在高并发下还能开 sync_binlog=1 的原因。


Q14:RR 隔离级别到底解决了幻读吗?

参考答案

这个问题必须分快照读和当前读两种情况答,答错就是 60 分和 85 分的差距。

① 快照读(普通 SELECT)靠 MVCC

  • 版本链:每行有 DB_TRX_ID(最后修改事务)和 DB_ROLL_PTR(指向 undo log 的旧版本)
  • ReadView:包含 m_ids(活跃事务 ID 列表)、min_trx_id、max_trx_id、creator_trx_id
  • 可见性判断:沿版本链找到第一个「对当前 ReadView 可见」的版本
  • RC 每次 SELECT 都生成新 ReadView;RR 只在第一次 SELECT 时生成,整个事务复用

所以 RR 下快照读天然不会幻读——你看到的是事务开始时的快照,别人插入的行你看不见。

② 当前读(SELECT ... FOR UPDATE、UPDATE、DELETE、INSERT)靠 Next-Key Lock

-- 假设 id 上有索引,值 5、10、15
SELECT * FROM t WHERE id = 10 FOR UPDATE;
-- 加锁范围:(5, 10]  → Next-Key Lock = 记录锁 + 间隙锁

Next-Key Lock = Record Lock + Gap Lock,锁住的是「记录 + 前面的间隙」,所以其他事务无法在这个间隙插入,幻读被阻止。

③ 但 RR 仍有幻读的边角场景

场景:事务 A 先快照读,事务 B 插入并提交,事务 A 再当前读——会看到 B 插入的行,两次读结果不一致。

-- A
BEGIN;
SELECT * FROM t WHERE id > 100;        -- 快照读,3 行
-- B: INSERT INTO t(id) VALUES(200); COMMIT;
SELECT * FROM t WHERE id > 100 FOR UPDATE;  -- 当前读,4 行 ← 幻读

严格来说这是「写后读」现象,不是 MVCC 的锅,而是快照读和当前读用了两套机制。RR 保证的是「同一快照内的一致性」,不是「任何两次读都一样」。

④ 大厂为什么普遍用 RC?

理由说明
减少间隙锁RC 下几乎没有 Gap Lock,大幅降低死锁概率
提高并发锁范围小
主从一致binlog 用 ROW 格式后,RC 也能安全复制
业务可控「读到最新数据」比「可重复读」更符合互联网业务预期

代价是要在业务层接受「同一事务内两次读可能不同」,用乐观锁/版本号兜底。


Q15:分布式事务有哪些方案?Seata AT 的原理是什么?

参考答案

① 方案对比表(面试直接讲这张表)

方案一致性侵入性性能适用场景
2PC / XA强低(数据库层)差(同步阻塞,锁到二阶段)传统金融、单库跨服务少
TCC最终(可做到准实时)高(写 Try/Confirm/Cancel)好资金、库存等核心链路
SAGA最终中(写正向 + 补偿)好长流程、跨多服务
本地消息表最终中好大多数业务(推荐)
事务消息(RocketMQ)最终低好已有 MQ 的异步链路
最大努力通知弱低最好支付回调、对账

② TCC 的三个坑(必须主动说)

  • 空回滚:Try 没执行(比如网络超时),Cancel 却来了 → Cancel 要能识别「Try 未执行」,直接返回成功
  • 悬挂:Cancel 比 Try 先到,之后 Try 才执行 → Try 要检查「是否已有 Cancel 记录」,有就拒绝
  • 幂等:Confirm/Cancel 可能重试多次 → 用事务控制表 + 唯一键

③ Seata AT 模式原理

AT 是「自动补偿」的两阶段:

阶段一(业务 SQL 与 undo_log 同一本地事务):
  1. 解析 SQL,查出修改前的镜像(beforeImage)
  2. 执行业务 SQL
  3. 查出修改后的镜像(afterImage)
  4. 把前后镜像写入 undo_log
  5. 注册分支事务到 TC,申请全局锁
  6. 本地事务提交(业务变更已生效!)

阶段二:
  全局提交 → 异步删除 undo_log(很快)
  全局回滚 → 用 beforeImage 生成反向 SQL 补偿,校验 afterImage 是否被改过(脏写检测)

核心是「全局锁」:SELECT ... FOR UPDATE 之前,TC 会校验该行的全局锁是否被其他全局事务持有,防止跨服务的脏写。

为什么 AT 快:阶段一本地事务直接提交,不占数据库连接和行锁到二阶段(对比 XA 要一直持有)。

AT 的边界:只支持支持本地 ACID 事务的关系型数据库;SQL 必须能被解析(不支持复杂子查询、部分函数);隔离级别默认读未提交(要看住「读隔离」需额外 SELECT FOR UPDATE)。


Q16:怎么保证「下单成功 → 发积分」的最终一致性?

参考答案

这是最高频的场景题,答「方案 + 幂等 + 补偿」三段。

方案:本地消息表 + 定时补偿

CREATE TABLE local_message (
  id           BIGINT PRIMARY KEY AUTO_INCREMENT,
  biz_id       VARCHAR(64) NOT NULL COMMENT '业务唯一键',
  topic        VARCHAR(64) NOT NULL,
  payload      TEXT NOT NULL,
  status       TINYINT NOT NULL DEFAULT 0 COMMENT '0待发送 1已发送 2消费成功 3失败',
  retry_count  INT NOT NULL DEFAULT 0,
  next_retry   DATETIME,
  created_at   DATETIME DEFAULT CURRENT_TIMESTAMP,
  UNIQUE KEY uk_biz (biz_id, topic)     -- 幂等的第一道防线
);
@Transactional   // 业务 + 消息 在同一个本地事务里
public void createOrder(OrderCmd cmd) {
    orderMapper.insert(cmd.toOrder());
    localMessageMapper.insert(LocalMessage.of(cmd.getOrderId(), "POINT_ADD", cmd));
    // 事务提交 → 消息一定落库;不会出现"订单成功消息没发"
}
@Scheduled(fixedDelay = 5000)
public void relay() {
    List<LocalMessage> list = localMessageMapper.selectPending(100);
    for (LocalMessage m : list) {
        try {
            rocketMQTemplate.syncSend(m.getTopic(), m.getPayload());
            localMessageMapper.markSent(m.getId());
        } catch (Exception e) {
            localMessageMapper.incrRetry(m.getId(), backoff(m.getRetryCount()));
        }
    }
}

消费端幂等(第二道防线)

@RocketMQMessageListener(topic = "POINT_ADD", consumerGroup = "point-cg")
public class PointListener implements RocketMQListener<String> {

    @Override
    public void onMessage(String body) {
        PointMsg msg = JSON.parseObject(body, PointMsg.class);
        // 唯一键:msg_id 或 (user_id, biz_id)
        int affected = jdbcTemplate.update(
            "INSERT INTO point_log(msg_id, user_id, amount) VALUES(?,?,?) " +
            "ON DUPLICATE KEY UPDATE msg_id = msg_id",   // 重复则影响行数为 0
            msg.getMsgId(), msg.getUserId(), msg.getAmount());

        if (affected == 0) {
            log.info("重复消息,跳过: {}", msg.getMsgId());
            return;
        }
        pointService.addPoint(msg.getUserId(), msg.getAmount());  // 同一事务
    }
}

为什么这样设计能成立:

  1. 消息不丢:业务与消息同库同事务,本地事务成功则消息必落库
  2. 消息必达:定时任务扫描 + 指数退避重试,直到成功
  3. 重复消费:唯一索引兜底,插入冲突即跳过
  4. 顺序无关:用状态机(待支付→已支付→已完成)而不是直接赋值,乱序也不会错

加分项:主动提「这套方案的代价是延迟(秒级),如果业务要求强实时,就得用 TCC 或事务消息。没有银弹,只有取舍」。面试官喜欢听取舍。


四、中间件(5 题)

Q17:Kafka 为什么这么快?

参考答案(四个层面)

① 存储:顺序写 + 分段 + 稀疏索引

  • 消息追加写入 partition 的 segment 文件(.log),纯顺序 IO,机械盘也能跑到 600MB/s+
  • 每 1GB 切一个 segment,配 .index(稀疏索引,默认每 4KB 记一条)和 .timeindex
  • 查找:二分定位 segment → 二分 index 找到最近的偏移 → 顺序扫一小段

② 传输:零拷贝(zero-copy)

传统 read + write 要 4 次拷贝、4 次上下文切换:

磁盘 → 内核页缓存 → 用户缓冲区 → socket 缓冲区 → 网卡

sendfile 只需 2 次(DMA 到页缓存,页缓存直接到网卡),CPU 拷贝次数从 2 次降到 0 次。Kafka 用 FileChannel.transferTo() 就是走这条路。

注意:开启 SSL 或压缩时零拷贝会失效(数据要进用户态处理),这是性能下降的常见原因。

③ 批量 + 压缩

  • 生产者 batch.size(默认 16KB)+ linger.ms(默认 0,建议 5~100)攒批
  • broker 端消息集(RecordBatch)整体压缩,compression.type=lz4/zstd,压缩比 3~5 倍
  • 批量发送把「N 次网络往返」变成 1 次

④ 并行:分区

Topic 分多个 partition,分散在不同 broker,生产/消费两端都并行。partition 数是 Kafka 并行的物理上限。

加分项:说出「Kafka 快不代表延迟低」。linger.ms 攒批会带来额外延迟,高吞吐和低延迟在 Kafka 里是直接冲突的,要按场景调。


Q18:Kafka 怎么保证不丢消息、不重复消费、顺序消费?

参考答案

① 不丢消息(三个环节都要管)

# 生产者:确认 + 重试 + 幂等
acks=all                          # 所有 ISR 副本都写入才返回
retries=2147483647
enable.idempotence=true           # 幂等生产者,防重试导致重复
max.in.flight.requests.per.connection=5   # 幂等开启后可到 5,仍保序

# Broker:副本 + 最小同步副本
replication.factor=3
min.insync.replicas=2             # 至少 2 个副本确认,配合 acks=all
unclean.leader.election.enable=false   # 不让落后副本当 leader(宁可不可用,不丢数据)

# 消费者:关自动提交,处理完再提交
enable.auto.commit=false

消费端正确姿势:

while (true) {
    ConsumerRecords<String, String> records = consumer.poll(Duration.ofMillis(500));
    for (ConsumerRecord<String, String> r : records) {
        handle(r);                     // 处理成功(或幂等)
    }
    consumer.commitSync();             // 处理完才提交,最多重复,不会丢
}

关键取舍:先提交后处理 = 可能丢;先处理后提交 = 可能重复。业务只能接受重复,不能接受丢,所以永远选后者 + 幂等。

② 不重复消费

Kafka 只保证 at-least-once(幂等生产者只解决生产端重复,不解决消费端)。消费端重复来自:处理完未提交 offset 就 rebalance、重启、网络超时重试。

解法就是业务幂等:

  • 唯一索引 / ON DUPLICATE KEY UPDATE
  • 状态机(只允许 待支付 → 已支付,重复的 已支付 → 已支付 直接忽略)
  • Redis SETNX msg_id(注意要设过期时间,避免内存泄漏)
  • 版本号 / CAS

③ 顺序消费

  • 生产端:同一业务 key 打到同一 partition(key = orderId),Kafka 对 key 做 hash 选分区
  • 消费端:一个 partition 只被同一消费组内的一个消费者消费(Kafka 协议保证),所以单分区内天然有序
  • 必须 max.in.flight.requests.per.connection=1(未开幂等时),否则重试会乱序
  • 消费端单线程处理,或者按 key 哈希到内存队列 + 多线程但同 key 串行

局限:全局有序只能单分区,吞吐会塌。真实业务要的是「同一订单有序」,不是「全局有序」——这句话要说出来。


Q19:RocketMQ 事务消息的原理?和 Kafka 事务有什么区别?

参考答案

① 执行流程(半消息 + 回查)

1. Producer 发送【半消息】到 Broker
2. Broker 持久化成功,返回 OK,但消息对消费者不可见(RMQ_SYS_TRANS_HALF_TOPIC)
3. Producer 执行本地事务(写订单表)
4. 根据本地事务结果,向 Broker 发送 commit / rollback
5. commit → 消息转正(写入真实 topic),消费者可见
   rollback → 删除半消息
6. 若第 4 步丢失(Producer 挂了 / 网络断),Broker 定时【回查】Producer
   (默认 60s 后开始,最多 15 次,间隔递增)
7. Producer 检查本地事务表,返回 commit / rollback / unknown
   unknown 超过最大次数 → 丢弃并告警(需人工介入)

② 为什么需要「半消息」而不是简单先发 MQ 再执行本地事务?

顺序问题
先发 MQ 后执行本地事务消息已发出,本地事务回滚 → 下游多做了(超发)
先执行本地事务后发 MQ本地提交,发消息失败 → 下游少做了(漏发)
半消息消息先「暂存不可见」,本地事务结果决定是否转正 → 二者绑定

③ 回查接口实现

@RocketMQTransactionListener
public class OrderTxListener implements RocketMQLocalTransactionListener {

    @Override
    public RocketMQLocalTransactionState executeLocalTransaction(Message msg, Object arg) {
        try {
            orderService.createOrder((OrderCmd) arg);   // 本地事务
            return RocketMQLocalTransactionState.COMMIT;
        } catch (Exception e) {
            return RocketMQLocalTransactionState.ROLLBACK;
        }
    }

    @Override
    public RocketMQLocalTransactionState checkLocalTransaction(Message msg) {
        String orderId = msg.getHeaders().get("orderId").toString();
        // 查本地事务表 / 订单表是否存在
        return orderMapper.exists(orderId)
                ? RocketMQLocalTransactionState.COMMIT
                : RocketMQLocalTransactionState.ROLLBACK;
    }
}

④ 与 Kafka 事务的区别(高频追问)

维度RocketMQ 事务消息Kafka 事务
解决的问题本地事务 + 消息发送的原子性多条消息的原子写入 + 跨 topic/partition 的 exactly-once
机制半消息 + 回查TransactionalId + 事务协调器 + 两阶段提交
语义最终一致流处理场景的 exactly-once
典型用途订单 → 库存/积分Kafka Streams 读-处理-写

一句话:Kafka 事务保证「消息之间的原子」,RocketMQ 事务消息保证「数据库与消息之间的原子」。这两个常被混为一谈。


Q20:线上消息积压了几百万条,怎么处理?

参考答案

第一步:先定位,别急着扩

现象判断处理
生产速率突增上游活动/重放限流上游,或临时扩容消费
消费速率下降下游 RT 变高(DB 慢、第三方接口超时)优先修下游,扩容只是延缓
消费者掉线频繁 rebalance、OOM、异常退出看日志/监控,修稳定性
单分区热点某个 key 过量拆分 key 或加随机后缀

第二步:应急手段(按见效速度排序)

  1. 扩消费者 —— 但并行上限 = 分区数,加到超过分区数没用。所以要先扩分区(Kafka 只能加不能减,且会打乱 key 顺序)
  2. 临时转发:写一个应急消费者,不做业务处理,只把消息搬到「新 topic(分区数 ×10)」,再用 10 倍消费者并行消费
  3. 批量消费:max.poll.records 从 500 调到 2000,配合批量写库(insert ... values (),(),())
  4. 降级:非核心消息(如日志、行为埋点)直接丢弃或落盘后补
  5. 临时扩机器:CPU/IO 打满时最直接

第三步:根治

# 消费端关键参数
max.poll.records=500          # 单次拉取量,太大导致处理超时触发 rebalance
max.poll.interval.ms=300000   # 处理超时上限,要大于最坏处理耗时
fetch.min.bytes=1             # 有数据就返回,降低延迟
  • 消费逻辑异步化(线程池处理,但注意 offset 提交时机)
  • 加本地缓存/Redis 挡读
  • 慢 SQL 优化(这是最常见的真凶)
  • 压测出单消费者吞吐,按「峰值 × 2」配置消费者数量

加分项:「积压不是问题,积压背后的下游瓶颈才是问题。如果只是扩消费者,DB 会被打挂,反而雪崩。」—— 这句话能立刻拉开差距。


Q21:注册中心怎么选?Eureka、ZooKeeper、Nacos 的区别?

参考答案

① CAP 定位

注册中心CAP一致性协议特点
EurekaAP无(节点间异步复制)自我保护机制:心跳丢失比例过高时不再剔除实例,宁可给旧列表
ZooKeeperCPZABleader 选举期间不可用(30~120s),服务发现场景不划算
NacosAP / CP 可切Distro(AP)/ Raft(CP)临时实例用 AP,持久实例用 CP
ConsulCPRaft自带健康检查、多数据中心

为什么服务发现更适合 AP? 服务发现的可用性优先级高于一致性——拿到一个稍微过期的实例列表(多调一次失败的请求,客户端重试即可),远好于「注册中心不可用导致整个集群无法发现服务」。

② 服务发现的推拉模型

组件机制
ZooKeeper客户端注册 Watcher,节点变化主动推送
Eureka客户端每 30s 拉一次增量,服务端每 60s 清理过期实例
Nacos 1.x客户端定时拉 + 服务端 UDP 推
Nacos 2.x长连接(gRPC)+ 推送,实时性最好,也大幅降低轮询压力

③ Nacos 的两个关键设计

  • 临时实例 vs 持久实例:临时实例(默认,ephemeral=true)靠心跳保活,15s 没心跳标记不健康,30s 剔除;持久实例不剔除,只标记不健康,适合数据库这类「必须能看到」的资源。
  • 命名空间 / Group / DataId 三层隔离,天然支持多环境 + 多租户。

④ 选型结论

  • 新项目、国内环境 → Nacos(注册 + 配置一体,社区活跃,Spring Cloud Alibaba 原生支持)
  • 已有 ZK 生态(Dubbo 早期)→ 可继续用,但注意 CP 带来的可用性代价
  • AWS 环境 → Eureka 或 Consul

加分项:提一句「注册中心不是越一致越好」。ZK 的 CP 在 Leader 选举时整个集群不可写,对服务发现是灾难;Nacos 把选择权交给实例类型,是更务实的设计。


五、数据库与缓存(5 题)

Q22:索引失效有哪些情况?为什么是最左前缀?

参考答案

① 八种失效场景

-- 假设有联合索引 idx_abc (a, b, c)

-- 1. 跳过最左列
WHERE b = 1 AND c = 2                 -- ❌ 失效

-- 2. 范围查询后的列失效
WHERE a = 1 AND b > 2 AND c = 3       -- ⚠️ c 用不上(a、b 可用)

-- 3. 索引列上做函数/运算
WHERE DATE(create_time) = '2026-11-04'  -- ❌
WHERE id + 1 = 100                      -- ❌
-- ✅ 改写:WHERE create_time >= '2026-11-04 00:00:00' AND create_time < '2026-11-05'

-- 4. 隐式类型转换(最常见也最隐蔽)
-- user.phone 是 varchar
WHERE phone = 13800138000             -- ❌ 数字 → 列转 double,索引失效
WHERE phone = '13800138000'           -- ✅

-- 5. 左模糊
WHERE name LIKE '%张'                 -- ❌
WHERE name LIKE '张%'                 -- ✅

-- 6. OR 连接非索引列
WHERE a = 1 OR d = 2                  -- ❌(d 无索引则全表扫)
-- ✅ 拆成 UNION 或给 d 建索引

-- 7. 不等于 / NOT IN / IS NOT NULL(视选择性与优化器判断)
WHERE status != 1                     -- ⚠️ 可能全表扫
WHERE a IS NULL                       -- ⚠️ 取决于索引是否存 NULL(InnoDB 存)

-- 8. 排序字段与索引顺序不一致
ORDER BY a, c                         -- ⚠️ 若索引是 (a,b,c),跨列排序可能 filesort

② 为什么必须最左前缀

联合索引 (a, b, c) 的 B+Tree 是按 a 排序,a 相同再按 b,b 相同再按 c 的字典序。

  • WHERE a = 1 → 在树中 a=1 的连续区间,可用
  • WHERE a = 1 AND b = 2 → 在 a=1 内 b=2 的连续区间,可用
  • WHERE b = 2 → b=2 的记录散布在整个索引里(因为 a 有多个值),无法二分,只能全扫

类比:电话簿按「姓 → 名」排序,你能按姓快速翻,也能按「姓+名」翻,但没法只按「名」翻。

③ 为什么范围查询会「断掉」后面的列

a=1 AND b>2 AND c=3:在 a=1 且 b>2 的区间里,记录是按 b 排序的,c 在这个区间内无序,所以 c 无法再走索引定位。但可以用 ICP(索引条件下推) 在存储引擎层做过滤,减少回表次数——注意 ICP 只是「减少回表」,不是「用 c 定位」。

④ 补救手段

手段作用
覆盖索引查询列全在索引里,免回表
ICP索引列条件下推到引擎层过滤
MRR回表前按主键排序,把随机 IO 变顺序 IO
索引跳跃扫描(8.0.13+)a 基数很低时,优化器可对每个 a 值分别用 b 索引,部分场景绕过最左前缀

加分项:EXPLAIN 里看到 type=ALL 但 possible_keys 有值,说明优化器算过成本后主动放弃索引(比如回表行数占比过高),这时候 FORCE INDEX 反而可能更慢。「失效」有时是优化器的正确决策。


Q23:深分页怎么优化?为什么 InnoDB 用 B+Tree 而不是 B 树或哈希?

参考答案

① 深分页的问题

SELECT * FROM orders ORDER BY id LIMIT 1000000, 20;

MySQL 会扫描 1000020 行,丢弃前 1000000 行,而且 SELECT * 每行都要回表,代价极高。

三种解法:

-- 方案 1:延迟关联(子查询只取主键,走覆盖索引)
SELECT o.* FROM orders o
INNER JOIN (SELECT id FROM orders ORDER BY id LIMIT 1000000, 20) t ON o.id = t.id;
-- 子查询在 idx_id 上完成,只回表 20 次

-- 方案 2:游标分页(最优,但只能"下一页")
SELECT * FROM orders WHERE id > 1000000 ORDER BY id LIMIT 20;

-- 方案 3:业务限制(禁止跳到第 N 页,或导出走异步任务 + 游标)

方案 2 是唯一 O(1) 的解法,也是所有 App 无限滚动的正确做法。代价是无法随机跳页——但用户真的需要跳到第 5 万页吗?

② 为什么是 B+Tree

对比结论
vs 二叉搜索树 / 红黑树树高太高(百万数据红黑树高 ~20 层 = 20 次磁盘 IO),B+Tree 三~四层就够
vs B 树B 树每个节点都存数据,扇出小、树更高;B+Tree 非叶子节点只存键,一页能放更多键,扇出更大;且叶子节点链表相连,范围查询只需一次定位 + 顺序扫
vs 哈希索引哈希只能等值查询,不支持范围、排序、最左前缀;且有哈希冲突

核心数字:InnoDB 一页 16KB,假设主键 bigint(8B)+ 指针(6B)= 14B,一页可放 ~1170 个键;三层 B+Tree 可存 1170 × 1170 × 16 ≈ 2190 万 行。查 2000 万行只要 3 次 IO——这就是 B+Tree 的意义。

③ 聚簇索引 vs 二级索引

  • 聚簇索引(主键):叶子节点存整行数据
  • 二级索引:叶子节点存主键值,查非索引列要回表(再走一次聚簇索引)

所以 SELECT * 的代价远高于 SELECT id,这也是「不要写 SELECT *」的真正原因。

加分项:提「主键建议用自增 bigint」。UUID 做主键会导致插入随机、页分裂严重、二级索引膨胀(每个二级索引叶子都要存 16B UUID 而不是 8B)。


Q24:一条 SQL 很慢,你怎么排查?

参考答案

完整链路(六步)

# 1. 确认慢 SQL(慢查询日志)
SET GLOBAL slow_query_log = ON;
SET GLOBAL long_query_time = 1;
# 分析:mysqldumpslow 或 pt-query-digest

# 2. 看执行计划
EXPLAIN SELECT ...;
EXPLAIN ANALYZE SELECT ...;   # 8.0.18+,给出实际耗时

# 3. 看优化器为什么选错(关键!)
SELECT * FROM information_schema.OPTIMIZER_TRACE\G
SET optimizer_trace = 'enabled=on';

# 4. 看真实执行耗时分布
SHOW PROFILE FOR QUERY 1;     # 或 performance_schema

# 5. 看表的统计信息
SHOW TABLE STATUS LIKE 'orders';
ANALYZE TABLE orders;         # 统计信息过期时重算

# 6. 看锁等待
SELECT * FROM performance_schema.data_locks;
SELECT * FROM performance_schema.data_lock_waits;
SHOW PROCESSLIST;

EXPLAIN 重点看四列

列关注点
type性能排序:system > const > eq_ref > ref > range > index > ALL。出现 ALL 或 index 要警惕
key / key_len实际用的索引、用了几个字节。key_len 能算出联合索引用到了第几列
rows预估扫描行数,与实际差太多说明统计信息不准
ExtraUsing filesort(排序无索引)、Using temporary(用了临时表)、Using index(覆盖索引,好)、Using index condition(ICP)

五大根因与对策

根因特征对策
没索引 / 索引失效type=ALL建索引、改写 SQL(见 Q22)
索引选错possible_keys 有但 key 空ANALYZE TABLE、FORCE INDEX、改写
回表过多rows 很大但过滤后很少覆盖索引
深分页LIMIT 偏移巨大延迟关联 / 游标(见 Q23)
锁等待执行慢但 EXPLAIN 正常查 data_lock_waits,缩短事务、降隔离级别

加分项:说出「先用 EXPLAIN ANALYZE 分清是「扫描行数多」还是「单行处理慢」」。前者是索引问题,后者可能是锁等待、磁盘 IO 抖动、或函数计算——两者的优化方向完全相反。


Q25:Redis 为什么单线程还这么快?持久化怎么选?

参考答案

① 「单线程」的准确含义

Redis 6.0 之前,命令执行是单线程的,但并非全部单线程:

  • 主线程:解析命令、执行命令、写回响应
  • bio_close_file / bio_aof_fsync / bio_lazy_free:后台线程(4.0 引入)
  • io-threads:6.0 引入,只处理网络读写和协议解析,命令执行仍是单线程

② 为什么快(六层原因)

  1. 纯内存操作,纳秒级
  2. IO 多路复用(epoll),一个线程管理上万连接
  3. 单线程无锁,没有上下文切换和竞争开销
  4. 高效数据结构:SDS(O(1) 取长度)、跳表(范围查询 O(logN))、listpack、intset
  5. RESP 协议简单,解析成本低
  6. 全局哈希表 + 渐进式 rehash,避免一次性扩容卡顿

③ 单线程的代价

  • 一个慢命令(KEYS *、大 HGETALL、SORT)会阻塞所有请求
  • 只能用一个 CPU 核,吞吐上限受单核性能限制

对策:禁用 KEYS(用 SCAN)、大 key 拆分、UNLINK 替代 DEL、集群分片。

④ 持久化三选一

方式原理优点缺点适用
RDBfork + COW,定时生成二进制快照文件小、恢复快可能丢较多数据(分钟级)冷备、主从全量同步
AOF追加写命令日志丢数据少(everysec 最多 1s)文件大、恢复慢数据敏感
混合(4.0+)AOF 重写时前半段用 RDB 格式兼顾体积与安全兼容性(旧版本读不了)推荐默认
appendonly yes
appendfsync everysec              # 生产首选;always 太慢,no 不可控
aof-use-rdb-preamble yes          # 混合持久化
save 900 1                        # RDB 兜底

⑤ 7.0 的 AOF 三段式改造

旧版 AOF 重写时,父子进程共写一个文件,需要靠 aof_rewrite_buf 缓冲增量,内存开销大。7.0 改成 base(RDB 格式)+ incr(增量 AOF)+ manifest(清单) 的多文件结构,重写时新开一个 incr 文件,不再需要大缓冲,内存和延迟都更可控。


Q26:缓存穿透、击穿、雪崩怎么解决?怎么保证缓存与数据库一致?

参考答案

① 三兄弟对比

问题定义解法
穿透查不存在的数据,请求每次都打到 DB① 缓存空值(短 TTL,如 60s)② 布隆过滤器(前置拦截,有误判无漏判)③ 参数合法性校验
击穿热点 key 过期瞬间,大量请求同时打到 DB① 互斥锁(只放一个请求回源)② 逻辑过期(不设 TTL,后台异步重建,永不阻塞)
雪崩大批 key 同时过期 或 Redis 整体宕机① TTL 加随机抖动 ② 多级缓存(Caffeine + Redis)③ 熔断降级 ④ 集群高可用

② 逻辑过期(推荐,不阻塞)

public String get(String key) {
    RedisData rd = (RedisData) redis.get(key);
    if (rd == null) {
        return null;                     // 或走互斥锁重建
    }
    if (rd.getExpireTime().isAfter(LocalDateTime.now())) {
        return rd.getData();             // 未过期,直接返回
    }
    // 已过期:尝试拿锁重建,拿不到也返回旧数据
    if (redis.setnx("lock:" + key, "1", 10, SECONDS)) {
        try {
            // 双重检查,避免重复重建
            executor.submit(() -> rebuild(key));
        } finally {
            redis.del("lock:" + key);
        }
    }
    return rd.getData();                 // 关键:返回旧数据,不阻塞
}

③ 一致性:为什么是「先更新 DB,再删缓存」

策略问题
先删缓存,再更新 DB删完、DB 还没更新时,另一个请求读旧值回填 → 长期脏数据
先更新 DB,再更新缓存两个并发更新,后写的可能先执行 → 缓存是旧值;且并发写缓存浪费
先更新 DB,再删缓存(Cache Aside)只有极小概率不一致(读请求恰好在「更新前读旧值」和「删缓存后回填」之间)

极小概率不一致的时序:

T1: 读请求 A → 缓存未命中 → 读 DB 得到 v1
T2: 写请求 B → 更新 DB 为 v2 → 删除缓存
T3: 请求 A → 把 v1 写回缓存      ← 缓存变 v1,DB 是 v2,长期不一致

解法:延迟双删(更新后删一次,500ms 后再删一次),但仍不够可靠(延迟时间难定)。终极方案是订阅 binlog:

业务只更新 DB
   ↓
Canal / Debezium 订阅 binlog
   ↓
发 MQ(带 row 变更前后镜像)
   ↓
缓存服务消费,删除对应 key(失败重试)

优点:缓存删除与业务解耦、可重试、可观测、不依赖业务代码自觉。这也是大厂的标准做法。

④ 大 key / 热 key

问题检测治理
大 key(value > 10KB 或元素 > 5000)redis-cli --bigkeys、MEMORY USAGE key拆分(user:1:profile / user:1:orders)、压缩、UNLINK 异步删
热 key(单 key QPS 极高)--hotkeys(需 LFU)、监控本地缓存、key 加随机后缀打散、读写分离

六、高并发(5 题)

Q27:设计一个秒杀系统,你会怎么做?

参考答案

先给整体图,再讲每层的取舍。

秒杀系统流量漏斗

图 2:秒杀的本质是「漏斗」,不是「抗住」。100 万请求进来,最终只有 1000 个能成为订单,中间每一层都在替数据库挡子弹。

① 分层设计

层做什么为什么
客户端按钮置灰、答题/验证码、限流挡掉脚本和无意义点击,成本最低
CDN静态资源、页面静态化动态请求减少 90%
网关令牌桶限流、黑名单、IP 限频保护后端,不区分好人坏人先限总量
应用本地缓存活动信息、Redis 预扣库存(Lua)内存级判断,QPS 可达 10 万
MQ异步下单、削峰把瞬时 5 万压成匀速 2000
DB乐观锁扣减 + 唯一索引防重最后一道防线,绝不超卖

② Redis 预扣库存(Lua 保证原子)

-- KEYS[1] = stock:sku:1001   KEYS[2] = order:dedup:{userId}:{skuId}
-- ARGV[1] = userId           ARGV[2] = 1
if redis.call('EXISTS', KEYS[2]) == 1 then
    return 2                                  -- 2 = 重复下单
end
local stock = tonumber(redis.call('GET', KEYS[1]))
if stock == nil or stock <= 0 then
    return 0                                  -- 0 = 售罄
end
redis.call('DECRBY', KEYS[1], tonumber(ARGV[2]))
redis.call('SET', KEYS[2], '1', 'EX', 3600)   -- 限购 + 幂等
return 1                                      -- 1 = 扣减成功

为什么用 Lua 而不是 DECR + 判断:GET 和 DECR 之间不是原子的,并发下会超卖。Lua 脚本在 Redis 内单线程原子执行。

③ 数据库乐观锁兜底

UPDATE stock
SET quantity = quantity - 1, version = version + 1
WHERE sku_id = 1001 AND quantity > 0;
-- 影响行数 = 0 → 扣减失败,返回售罄

用 quantity > 0 而不是 version,更简洁且天然防超卖。

④ 异步下单

MQ 消费端做「创建订单 + 扣减 DB 库存」,消费失败进重试队列,超过次数进死信队列人工处理。前端轮询/长连接查询结果(「排队中 → 抢购成功/失败」)。

⑤ 三个必答细节

  • 超卖:Redis Lua + DB 乐观锁,双保险
  • 少卖(Redis 扣了但订单创建失败):定时对账,把「Redis 已扣、DB 无订单」的库存回补
  • 防刷:限购(order:dedup:{userId}:{skuId})、设备指纹、风控名单

加分项:主动说「秒杀系统的核心指标不是 QPS,是「不超卖 + 不宕机」。为了让 1000 个成功用户拿到商品,宁可让 99.9 万用户看到「已售罄」,也不能让数据库崩掉。」


Q28:常见的限流算法有哪些?分布式限流怎么做?

参考答案

① 四种算法对比

算法原理优点缺点
固定窗口每个时间窗计数,超阈值拒绝实现最简单临界问题:窗口切换瞬间可放过 2 倍流量
滑动窗口把窗口切成 N 个小格,滚动统计平滑,解决临界问题需要存储 N 个计数
漏桶请求入桶,以固定速率流出绝对平滑,保护下游无法应对突发(即使下游有能力)
令牌桶按固定速率生成令牌,请求取令牌允许突发(桶内积累的令牌)参数需调优

面试结论:保护下游用漏桶,允许突发用令牌桶,简单场景用滑动窗口。

② 单机:Guava RateLimiter(令牌桶)

// 每秒 100 个令牌,预热 1 秒(冷启动不打死下游)
RateLimiter limiter = RateLimiter.create(100, 1, TimeUnit.SECONDS);

if (limiter.tryAcquire(200, TimeUnit.MILLISECONDS)) {
    doBusiness();
} else {
    throw new BizException("请求过于频繁");
}

③ 分布式:Redis + Lua(滑动窗口)

-- KEYS[1] = limit:{api}:{userId}   ARGV[1] = 窗口大小(ms)  ARGV[2] = 阈值
local now = redis.call('TIME')[1] * 1000 + math.floor(redis.call('TIME')[2] / 1000)
local window_start = now - tonumber(ARGV[1])

redis.call('ZREMRANGEBYSCORE', KEYS[1], 0, window_start)   -- 清理过期
local count = redis.call('ZCARD', KEYS[1])
if count >= tonumber(ARGV[2]) then
    return 0
end
redis.call('ZADD', KEYS[1], now, now .. '-' .. math.random(100000))
redis.call('PEXPIRE', KEYS[1], ARGV[1])
return 1

为什么用 ZSet:score 存时间戳,天然支持范围删除和计数。

缺点:每个请求都要写 Redis,热点 key 压力大。优化:本地令牌桶 + Redis 兜底(本地先挡 90%,超出的再问 Redis)、或用 Redis Cluster 分片。

④ 生产形态:Sentinel / 网关限流

@SentinelResource(value = "createOrder",
    blockHandler = "handleBlock",
    fallback = "handleFallback")
public Order createOrder(OrderCmd cmd) { }

public Order handleBlock(OrderCmd cmd, BlockException e) {
    throw new BizException("当前排队人数较多,请稍后重试");
}

Sentinel 的核心概念:资源 → 规则(流控/熔断/热点/系统)→ 槽链(Slot Chain)。相比 Hystrix 的线程池隔离,Sentinel 用信号量 + 统计窗口,开销更小。


Q29:分布式锁你有几种实现?为什么有人说 Redis 锁不可靠?

参考答案

① 五个版本的演进(一定要讲演进,不要直接说 Redisson)

// v1:SETNX —— 有死锁风险(进程崩溃锁永不释放)
Boolean ok = redis.setnx("lock:" + id, "1");

// v2:SET NX EX —— 加过期时间,但不原子(setnx + expire 两条命令)
// v3:SET key value NX PX 30000 —— 原子加锁
String v = UUID.randomUUID().toString();
boolean ok = "OK".equals(redis.opsForValue().setIfAbsent(key, v, 30, SECONDS));

// v4:Lua 原子释放(防误删别人的锁)
// if redis.call('get', KEYS[1]) == ARGV[1] then return redis.call('del', KEYS[1]) else return 0 end

// v5:Redisson 看门狗 —— 自动续期,解决"业务没跑完锁过期"
RLock lock = redisson.getLock("order:" + orderId);
lock.lock(30, TimeUnit.SECONDS);   // 不传 leaseTime 才启用看门狗(默认 30s 续一次)

② 看门狗的原理(高频追问)

// Redisson 加锁成功后,如果没指定 leaseTime,会启动定时任务
// 每 10s(leaseTime / 3)检查一次,若锁还持有则续期到 30s
// 底层是一段 Lua:pexpire 并校验 value == UUID:threadId

关键细节:lock.lock(30, TimeUnit.SECONDS) 显式指定了 leaseTime,看门狗不生效。只有 lock.lock()(无参)才启用。这个坑很多人踩。

③ 为什么 Redis 锁「不可靠」(Martin Kleppmann 的质疑)

经典场景:

T1: 客户端 A 拿到锁(TTL 30s)
T2: A 发生长时间 GC / 网络延迟,暂停 35s
T3: 锁自动过期
T4: 客户端 B 拿到锁,开始写数据
T5: A 恢复,以为自己还持锁,继续写  ← 数据冲突!

三种应对:

  1. fencing token:每次加锁返回递增的 token,存储层拒绝比已见过的 token 更小的写入(本质要靠存储层支持)
  2. 看门狗:减少「业务没跑完锁过期」的概率,但解决不了 GC 停顿(进程都停了,续期也停)
  3. Redlock:向 N 个独立 Redis 节点加锁,过半成功才算拿到(N=5,容忍 2 个故障)。但 Kleppmann 指出:Redlock 依赖各节点的时钟准确性,时钟跳变仍会破坏安全性;而且它解决不了上面那种「持锁者暂停」的问题

④ 我的实践观点(这是加分项)

场景方案
只是防止重复执行(如定时任务防并发)Redis 锁够用,加幂等兜底
需要绝对安全(资金、库存)用数据库行锁 / 唯一索引 / 乐观锁,别用分布式锁
需要强一致 + 高可用ZooKeeper / etcd(临时顺序节点 + session 机制,天然有租约)
大多数业务Redisson + 业务侧幂等,接受「锁只是降低概率,不是保证」

一句话收尾:「分布式锁的本质是「提高互斥概率」,不是「保证互斥」。真正需要保证的地方,必须靠存储层的约束(唯一索引、版本号、状态机)来兜底。」


Q30:接口幂等怎么设计?

参考答案

① 先明确:什么接口需要幂等

  • 查询:天然幂等
  • 新增:不幂等(重复插入)
  • 更新:SET status = 1 幂等,SET count = count + 1 不幂等
  • 删除:DELETE WHERE id = 1 幂等

② 四种实现

方案实现适用缺点
唯一索引UNIQUE KEY uk_biz(biz_id) + INSERT ... ON DUPLICATE KEY UPDATE插入类,最可靠依赖 DB,需要设计业务唯一键
Token 机制先申请 token 存 Redis,提交时 DEL 成功才处理表单提交、支付多一次请求
状态机UPDATE ... WHERE status = '待支付',影响行数为 0 则跳过订单流转需要状态设计
分布式锁SETNX order:123短时防重锁过期/失效问题(见 Q29)

③ 推荐的组合方案

@PostMapping("/order")
public Result createOrder(@RequestBody OrderCmd cmd,
                          @RequestHeader("Idempotency-Key") String idemKey) {
    // 第一道:Redis 快速拦截(挡 99% 重复)
    Boolean first = redis.opsForValue().setIfAbsent("idem:" + idemKey, "1", 24, HOURS);
    if (Boolean.FALSE.equals(first)) {
        return Result.ok(orderMapper.findByBizNo(cmd.getBizNo()));  // 返回首次结果
    }

    try {
        // 第二道:唯一索引兜底(防 Redis 宕机/过期)
        return Result.ok(orderService.create(cmd));
    } catch (DuplicateKeyException e) {
        return Result.ok(orderMapper.findByBizNo(cmd.getBizNo()));
    }
}
CREATE TABLE `orders` (
  `id`      BIGINT PRIMARY KEY AUTO_INCREMENT,
  `biz_no`  VARCHAR(64) NOT NULL COMMENT '业务单号,幂等键',
  `status`  TINYINT NOT NULL,
  UNIQUE KEY `uk_biz_no` (`biz_no`)
);

④ 三个容易漏的细节

  • 先执行还是先占位:Redis 占位成功但业务失败,要删除占位(否则用户重试被误判为重复)
  • 返回一致性:重复请求要返回首次的结果,而不是「重复提交」错误——否则前端无法区分
  • 异步接口:幂等键要贯穿到 MQ 消费端(消息体带 bizNo),不能只在 HTTP 层做

Q31:一个系统能扛多少 QPS,你怎么评估?

参考答案

① 估算(自顶向下)

日活 100 万,人均 50 次请求 → 日均 5000 万
峰值 QPS = 5000 万 / 86400 × 峰值系数(3~5) ≈ 1700 ~ 2900
再留 2 倍余量 → 目标 6000 QPS

关键认知:平均值毫无意义,要算峰值。 电商的峰值可能是均值的 10 倍以上(大促),社交类通常 3~5 倍。

② 单机能力基线(经验值,用于快速估算)

组件单机 QPS(参考)说明
Nginx(静态)3~5 万受带宽限制
Tomcat(简单接口)2000~5000复杂业务降到 200~500
Redis(单实例)8~10 万单核,pipeline 可更高
MySQL(主键查询)2000~5000写入更低,1000~3000
Kafka(单分区)10 万+顺序写

③ 压测才是唯一真相

# wrk
wrk -t8 -c1000 -d60s --latency http://api.example.com/order

# JMeter 分布式压测(注意压测机本身别成为瓶颈)
jmeter -n -t order.jmx -l result.jtl -e -o report/

看四个指标,不看平均值:

  • TPS/QPS:吞吐
  • P99 RT:99% 的请求在这个时间内返回(比平均值真实得多)
  • 错误率:超过 0.1% 就该停
  • 资源水位:CPU < 70%、连接池 < 80%、GC 停顿 < 100ms

④ 找瓶颈的顺序

CPU → 内存/GC → 磁盘 IO → 网络带宽 → 连接池 → 下游依赖 → 数据库锁

用 arthas / async-profiler 抓火焰图,看 CPU 花在哪(序列化?正则?日志?)。

⑤ 高并发的四板斧(总结句)

  1. 缓存:把读请求挡在 DB 之前(占比 90% 以上)
  2. 异步:能不同步做的都进 MQ(降低 RT,削峰)
  3. 拆分:读写分离、分库分表、微服务拆分(降低单点压力)
  4. 限流降级:主动放弃一部分请求,保住整体(这是最容易被忽视但最重要的一环)

七、答题框架与复盘清单

1. 面试答题的「三段式」

任何一道题都按这个结构说,比想到哪说到哪强 10 倍:

① 一句话结论      →  先给判断,让面试官知道你想清楚了
② 原理/机制展开    →  2~3 个关键点,每个点配一个具体例子
③ 实践与取舍       →  我在项目里怎么用的、遇到什么问题、为什么这么选

示例(被问「为什么用 Redis 不用本地缓存」):

结论:取决于一致性和数据量的要求,我们是两级都用。 原理:本地缓存 Caffeine 在 JVM 内,纳秒级,但多实例之间不一致、容量受堆限制;Redis 集中式,毫秒级,容量可扩,但多一次网络往返。 取舍:我们让 Caffeine 只存「变更极少、允许秒级延迟」的配置类数据(TTL 5 分钟),商品价格这种强一致数据只用 Redis。这样 90% 的读请求不落网络,同时保证价格不会错。

2. 被问住时的正确姿势

不要说「这个我没用过」就结束。可以说:

「这块我在生产上没直接做过,但我的理解是……(说出你能推导的部分)。如果是我来做,我会先从……入手验证。」

面试官要的是推理能力,不是百科。

3. 面试前一周的复盘清单

  • [ ] 每个板块能脱稿讲 5 分钟,不卡壳
  • [ ] 手写:单例(DCL)、线程池创建、Spring 事务自调用失效的修复、秒杀 Lua 脚本
  • [ ] 能量化:QPS、RT、数据量、优化前后的对比数字(有数字的简历才可信)
  • [ ] 准备 2 个项目难点:背景 → 你的方案 → 为什么不用别的 → 结果(带数字)
  • [ ] 反问准备 3 个:团队技术栈?这个岗位最急需解决的问题?代码 review 和上线流程?

4. 六句话总结全文

  1. HashMap 的 2 的幂、线程池的先入队、Spring 的三级缓存——三个「为什么」答不出来,说明只看过结论。
  2. 锁的粒度越细,synchronized 越有优势;虚拟线程时代要警惕 pinning。
  3. 事务失效 90% 是代理问题,不是配置问题。
  4. RR 解决了快照读的幻读,没解决「快照读 + 当前读」混合的幻读。
  5. 缓存一致性的终极方案是订阅 binlog,延迟双删只是权宜。
  6. 高并发的本质是漏斗和取舍,不是把所有请求都扛下来。

如果这篇文章帮你理清了某个点,欢迎点赞收藏。有想深挖的题(比如「G1 的 SATB 到底怎么实现」「Seata AT 的全局锁细节」),评论区告诉我,我单独写一篇拆到底。

转载自 CSDN-专业IT技术社区

原文链接:https://blog.csdn.net/qq_36021478/article/details/170027933

文章来源转载

评论

评论列表

微信小程序
QQ小程序

关于作者

点赞数:0
关注数:0
粉丝:0
文章:0
关注标签:0
加入于:--