第一部分:Java 基础与并发

一、集合框架

1. ArrayList 和 LinkedList 的区别?各自适合什么场景?

回答:

ArrayList 底层是动态数组,内存连续,支持随机访问,时间复杂度 O(1),但插入删除需要移动元素,是 O(n)。

LinkedList 底层是双向链表,内存不连续,插入删除只需改变指针,是 O(1),但随机访问需要遍历,是 O(n)。

场景选择:

  • ArrayList 适合读多写少、需要频繁随机访问的场景
  • LinkedList 适合频繁在头尾插入删除的场景,比如实现队列

实际开发中,ArrayList 用得更多,因为现代 CPU 对连续内存有缓存优化,即使插入删除也比 LinkedList 快。


2. HashMap 的 put 流程是怎样的?什么时候扩容?

回答:

HashMap 的 put 流程分这几步:

  1. 计算 hash:对 key 的 hashCode 进行扰动处理(高 16 位异或低 16 位),减少碰撞
  2. 定位桶:用 (n-1) & hash 计算数组下标
  3. 处理碰撞
    • 如果桶为空,直接放入新节点
    • 如果桶不为空,判断 key 是否相等(先比 hash,再比 equals)
    • 相等则覆盖 value
    • 不相等则挂到链表尾部(JDK8 是尾插法)
  4. 树化判断:链表长度超过 8 且数组长度超过 64,转为红黑树
  5. 扩容判断:元素个数超过 容量 × 负载因子(默认 0.75),触发扩容

扩容时机:

  • size > capacity * loadFactor 时扩容为原来的 2 倍
  • 扩容需要 rehash,所以最好预估容量,避免频繁扩容

3. HashMap 链表转红黑树的阈值为什么是 8?退化阈值为什么是 6?

回答:

选择 8 是基于泊松分布的概率计算。在理想的 hash 分布下,链表长度达到 8 的概率只有千万分之六,属于极端情况。这时候转红黑树能把查询从 O(n) 优化到 O(log n)。

退化阈值选 6 而不是 8,是为了避免频繁转换。如果都是 8,那在 8 附近增删元素会导致链表和红黑树反复转换,浪费性能。中间留个缓冲区(7),增加稳定性。

补充一点:转红黑树还有个前提是数组长度要大于 64,否则优先扩容而不是树化,因为扩容能更有效地减少碰撞。


4. HashMap 在多线程下会出现什么问题?

回答:

HashMap 是线程不安全的,多线程下有两个主要问题:

1. 数据丢失

  • 两个线程同时 put,计算到同一个桶位置
  • 线程 A 判断为空准备插入,被挂起
  • 线程 B 也判断为空,插入成功
  • 线程 A 恢复后直接覆盖,B 的数据丢失

2. JDK7 的死循环(JDK8 已修复)

  • JDK7 扩容用头插法,多线程下可能形成环形链表
  • get 操作会死循环,CPU 飙升到 100%

解决方案:

  • 使用 ConcurrentHashMap:分段锁(JDK7)或 CAS + synchronized(JDK8)
  • 使用 Collections.synchronizedMap():全表锁,性能较差

5. ConcurrentHashMap 的实现原理?JDK7 和 JDK8 有什么区别?

回答:

JDK7 的实现:分段锁(Segment)

  • 把数据分成 16 个段,每个段是一个小的 HashMap
  • 每个段有独立的锁,不同段可以并发访问
  • 并发度最高 16,扩容时锁粒度较大

JDK8 的实现:CAS + synchronized

  • 取消分段锁,改为对每个桶头节点加锁
  • 空桶用 CAS 插入,非空桶用 synchronized 锁头节点
  • 锁粒度更细,并发度等于桶数量
  • 同样支持链表转红黑树

为什么 JDK8 改用 synchronized?

  • JVM 对 synchronized 做了大量优化(偏向锁、轻量级锁)
  • 实际性能不比 ReentrantLock 差
  • 代码更简洁,内存占用更少

二、多线程与并发

6. 创建线程有哪几种方式?

回答:

严格来说只有两种方式创建线程:

1. 继承 Thread 类

class MyThread extends Thread {
    public void run() { ... }
}
new MyThread().start();

2. 实现 Runnable 接口

new Thread(() -> { ... }).start();

Callable + FutureTask、线程池等本质上还是通过这两种方式创建。

推荐使用 Runnable/Callable + 线程池,原因:

  • Java 单继承,继承 Thread 就不能继承其他类了
  • 线程池可以复用线程,避免频繁创建销毁的开销
  • 便于统一管理和监控

7. 线程池的 7 个核心参数分别是什么?

回答:

new ThreadPoolExecutor(
    corePoolSize,      // 核心线程数,即使空闲也不回收
    maximumPoolSize,   // 最大线程数
    keepAliveTime,     // 非核心线程的空闲存活时间
    TimeUnit,          // 时间单位
    workQueue,         // 任务队列
    threadFactory,     // 线程工厂,可以自定义线程名
    handler            // 拒绝策略
);

任务提交流程:

  1. 线程数 < corePoolSize → 创建核心线程执行
  2. 核心线程满 → 放入 workQueue
  3. 队列满 → 创建非核心线程(不超过 maximumPoolSize)
  4. 线程数达到 maximumPoolSize 且队列满 → 执行拒绝策略

记忆口诀:先核心,再队列,后扩展,都满了就拒绝。


8. 线程池的拒绝策略有哪些?怎么选择?

回答:

JDK 提供 4 种内置策略:

策略 行为 适用场景
AbortPolicy 抛出 RejectedExecutionException 默认策略,需要调用方感知并处理
CallerRunsPolicy 由提交任务的线程执行 不允许丢弃,可以接受延迟
DiscardPolicy 静默丢弃 允许丢弃,不需要通知
DiscardOldestPolicy 丢弃队列最老任务 只关心最新数据

实际开发中,我一般自定义拒绝策略:

  • 记录日志报警
  • 持久化到数据库或 MQ,后续补偿处理
  • 返回兜底结果

9. 核心线程数设置多少合适?

回答:

要根据任务类型来定:

CPU 密集型(计算为主)

  • 公式:核心线程数 = CPU 核数 + 1
  • 多一个是为了防止偶发的缺页中断等导致线程阻塞

IO 密集型(网络/磁盘 IO 为主)

  • 公式:核心线程数 = CPU 核数 × 2CPU 核数 / (1 - 阻塞系数)
  • 阻塞系数一般 0.8~0.9

实际开发中,这只是起点,还需要:

  • 压测调优,观察 CPU 利用率和响应时间
  • 结合业务特点,比如下游依赖的响应时间
  • 预留 buffer,不要打满 CPU

10. synchronized 和 ReentrantLock 的区别?

回答:

对比项 synchronized ReentrantLock
实现层面 JVM 层面,字节码指令 API 层面,Java 代码
锁释放 自动释放 必须手动 unlock
中断响应 不支持 支持 lockInterruptibly
超时获取 不支持 支持 tryLock(timeout)
公平锁 只有非公平 支持公平/非公平
条件变量 只有一个 wait/notify 可以多个 Condition

选择建议:

  • 简单场景优先 synchronized,JVM 已优化得很好
  • 需要高级功能(超时、中断、多条件)时用 ReentrantLock
  • 用 ReentrantLock 一定要在 finally 里 unlock

11. volatile 关键字的作用?能保证原子性吗?

回答:

volatile 有两个作用:

1. 保证可见性

  • 写 volatile 变量时,会把工作内存的值刷新到主内存
  • 读 volatile 变量时,会从主内存重新加载
  • 一个线程修改后,其他线程立即可见

2. 禁止指令重排序

  • 通过内存屏障实现
  • 经典场景:双重检查锁定的单例模式

但是,volatile 不能保证原子性!

比如 count++ 实际上是三步操作:读取、加 1、写入。volatile 只保证每一步的可见性,不保证三步的原子性。

要保证原子性,可以用:

  • synchronized
  • AtomicInteger 等原子类
  • Lock

三、JVM

12. JVM 内存结构分哪几块?

回答:

JVM 运行时数据区分为 5 块:

线程私有:

  1. 程序计数器:记录当前线程执行的字节码行号
  2. 虚拟机栈:存储局部变量表、操作数栈、方法出口等
  3. 本地方法栈:为 Native 方法服务

线程共享:
4. :对象实例和数组,GC 的主要区域
5. 方法区:类信息、常量、静态变量(JDK8 后叫元空间,用本地内存)

常见问题区分:

  • StackOverflowError → 栈溢出,递归太深
  • OutOfMemoryError: Java heap space → 堆溢出,对象太多
  • OutOfMemoryError: Metaspace → 元空间溢出,类太多

13. 哪些对象可以作为 GC Roots?

回答:

GC Roots 是垃圾回收的起点,从这些根出发能到达的对象都是存活的。

可以作为 GC Roots 的对象:

  1. 虚拟机栈中引用的对象(局部变量表)
  2. 方法区中静态属性引用的对象
  3. 方法区中常量引用的对象
  4. 本地方法栈中 JNI 引用的对象
  5. 同步锁持有的对象
  6. JVM 内部引用(基本类型对应的 Class 对象、常驻异常等)

记忆技巧:想想哪些对象是"活着的代码"正在使用的——栈里的、静态的、常量的、Native 的。


14. CMS 和 G1 的区别?

回答:

对比项 CMS G1
目标 最短停顿时间 可预测的停顿时间
内存布局 传统分代(新生代、老年代) Region 化,逻辑分代
回收算法 标记-清除 标记-整理(Region 间复制)
碎片问题 有,需要 Full GC 整理 无,每次回收都整理
并发阶段 初始标记→并发标记→重新标记→并发清除 初始标记→并发标记→最终标记→筛选回收

选择建议:

  • 堆内存 < 8G,可以用 CMS
  • 堆内存 > 8G,推荐 G1
  • JDK9 开始,G1 是默认收集器,CMS 已被标记废弃

15. 什么情况下会发生 Full GC?

回答:

Full GC 会回收整个堆,停顿时间长,要尽量避免:

  1. 老年代空间不足:大对象或长期存活对象太多
  2. 元空间不足:加载的类太多
  3. 显式调用 System.gc():建议禁用
  4. CMS 并发失败:并发回收速度赶不上分配速度
  5. 晋升失败:Young GC 时老年代放不下晋升对象

优化方向:

  • 合理设置堆大小和各区比例
  • 避免创建过大对象
  • 减少内存泄漏
  • 选择合适的 GC 收集器

第二部分:框架与微服务

一、Spring 核心

16. Spring IOC 是什么?解决了什么问题?

回答:

IOC(控制反转)是一种设计思想,把对象的创建和依赖管理交给容器,而不是在代码里 new。

传统方式:

// 紧耦合,UserService 依赖具体的 UserDaoImpl
public class UserService {
    private UserDao userDao = new UserDaoImpl();
}

IOC 方式:

// 松耦合,依赖由容器注入
@Service
public class UserService {
    @Autowired
    private UserDao userDao;  // 容器注入,可以是任何实现
}

解决的问题:

  1. 解耦:类不需要知道依赖的具体实现
  2. 便于测试:可以注入 Mock 对象
  3. 统一管理:对象的生命周期、配置、AOP 增强都由容器管理

17. Spring Bean 的生命周期是怎样的?

回答:

Bean 的生命周期可以分为四个阶段:

1. 实例化(Instantiation)

  • 通过反射创建 Bean 实例
  • 此时是个空对象,属性还没填充

2. 属性填充(Populate)

  • 注入依赖的其他 Bean
  • 注入配置的属性值

3. 初始化(Initialization)

  • 调用 Aware 接口方法(BeanNameAware、ApplicationContextAware 等)
  • 调用 BeanPostProcessor 的 postProcessBeforeInitialization
  • 调用 @PostConstruct 标注的方法
  • 调用 InitializingBean.afterPropertiesSet()
  • 调用自定义的 init-method
  • 调用 BeanPostProcessor 的 postProcessAfterInitialization(AOP 代理在这里生成)

4. 销毁(Destruction)

  • 调用 @PreDestroy 标注的方法
  • 调用 DisposableBean.destroy()
  • 调用自定义的 destroy-method

面试时画个流程图更清晰。


18. Spring 如何解决循环依赖?三级缓存各自存什么?

回答:

Spring 通过三级缓存解决 setter 注入的循环依赖:

// 一级缓存:完整的单例 Bean
Map<String, Object> singletonObjects

// 二级缓存:早期暴露的 Bean(已实例化,未填充属性)
Map<String, Object> earlySingletonObjects

// 三级缓存:Bean 工厂,用于生成早期引用
Map<String, ObjectFactory<?>> singletonFactories

解决流程(A 依赖 B,B 依赖 A):

  1. 创建 A,实例化后放入三级缓存(工厂)
  2. 填充 A 的属性,发现依赖 B
  3. 创建 B,实例化后放入三级缓存
  4. 填充 B 的属性,发现依赖 A
  5. 从三级缓存获取 A 的早期引用,放入二级缓存
  6. B 初始化完成,放入一级缓存
  7. A 继续填充,获取到完整的 B
  8. A 初始化完成,放入一级缓存

为什么要三级而不是二级?

  • 三级缓存存的是工厂,可以延迟创建代理对象
  • 如果 A 需要被 AOP 代理,在第 5 步会通过工厂生成代理对象

19. 构造器注入为什么不能解决循环依赖?

回答:

因为构造器注入时,Bean 还没有实例化,没法放入缓存提前暴露。

setter 注入: 先 new 出空对象 → 放入缓存 → 再填充属性

构造器注入: 必须先准备好所有构造参数 → 才能 new 对象

如果 A 的构造器依赖 B,B 的构造器依赖 A,就形成了死锁:

  • 创建 A 需要先创建 B
  • 创建 B 需要先创建 A
  • 都没法先实例化

解决方案:

  1. 改用 setter 注入
  2. 使用 @Lazy 延迟加载其中一个
  3. 重新设计,消除循环依赖(推荐)

20. @Transactional 失效的场景有哪些?

回答:

事务失效是高频坑点,常见场景:

1. 方法不是 public

  • Spring AOP 默认只代理 public 方法

2. 自调用(同类方法调用)

public void methodA() {
    this.methodB();  // 失效!this 不是代理对象
}
@Transactional
public void methodB() { ... }
  • 解决:注入自己或使用 AopContext

3. 异常被吞掉

@Transactional
public void method() {
    try {
        // 业务代码
    } catch (Exception e) {
        log.error("出错了");  // 异常被吞,事务不回滚
    }
}

4. 异常类型不对

  • 默认只回滚 RuntimeException 和 Error
  • 检查异常不回滚,需要配置 rollbackFor = Exception.class

5. 数据库引擎不支持

  • MyISAM 不支持事务,要用 InnoDB

6. 没有被 Spring 管理

  • Bean 没有 @Service 等注解

二、MyBatis

21. MyBatis 的 #{} 和 ${} 有什么区别?

回答:

对比项 #{} ${}
处理方式 预编译,参数占位符 字符串拼接
SQL 注入 安全,自动转义 不安全,可能被注入
性能 可复用预编译 SQL 每次都要编译
使用场景 传递参数值 动态表名、列名、排序

举例:

-- #{} 编译后
SELECT * FROM user WHERE id = ?

-- ${} 编译后(假设传入 1 OR 1=1)
SELECT * FROM user WHERE id = 1 OR 1=1  -- SQL 注入!

原则:能用 #{} 就不用 ${},必须用 ${} 时要做好参数校验。


22. MyBatis 一级缓存和二级缓存的区别?

回答:

一级缓存(SqlSession 级别)

  • 默认开启,同一个 SqlSession 内有效
  • 同一个查询多次执行,第二次直接返回缓存
  • SqlSession 关闭或执行增删改后失效

二级缓存(Mapper 级别)

  • 需要手动开启,跨 SqlSession 有效
  • 以 namespace 为单位,同一个 Mapper 共享
  • SqlSession 关闭后数据才进入二级缓存

坑点:

  • 一级缓存在分布式环境下可能导致脏读
  • 二级缓存如果跨 Mapper 关联查询可能读到脏数据
  • 生产环境建议用 Redis 做分布式缓存,关闭 MyBatis 缓存

第三部分:数据库与中间件

一、MySQL

23. 索引的数据结构是什么?为什么用 B+ 树?

回答:

MySQL InnoDB 的索引使用 B+ 树

为什么不用其他结构:

结构 问题
哈希表 只支持等值查询,不支持范围查询
二叉搜索树 可能退化成链表,高度不可控
红黑树 高度较高,大数据量时磁盘 IO 多
B 树 非叶子节点也存数据,叶子节点没有链表

B+ 树的优势:

  1. 矮胖:非叶子节点只存键,能存更多索引,树更矮,IO 更少
  2. 范围查询高效:叶子节点形成双向链表,范围查询直接遍历
  3. 查询稳定:所有查询都要走到叶子节点,性能稳定

补充数据:3 层的 B+ 树大约能存储 2000 万行数据,绝大多数查询只需要 3 次磁盘 IO。


24. 什么情况下索引会失效?

回答:

这是高频题,我总结了 8 种常见情况:

1. 最左前缀不满足

  • 联合索引 (a, b, c),查询条件没有 a 则索引失效

2. 对索引列做函数操作

WHERE YEAR(create_time) = 2024  -- 失效
WHERE create_time >= '2024-01-01'  -- 走索引

3. 隐式类型转换

-- phone 是 varchar
WHERE phone = 13800138000  -- 失效,数字会转成字符串
WHERE phone = '13800138000'  -- 走索引

4. LIKE 左模糊

WHERE name LIKE '%张'  -- 失效
WHERE name LIKE '张%'  -- 走索引

5. OR 连接非索引列

-- 只有 a 有索引
WHERE a = 1 OR b = 2  -- 失效

6. 范围查询后的列

-- 联合索引 (a, b, c)
WHERE a > 1 AND b = 2  -- b 走不了索引

7. NOT IN、!=、<>

  • 某些情况优化器认为全表扫描更快

8. 数据分布不均

  • 优化器估算后认为全表扫描更快

25. 聚簇索引和非聚簇索引的区别?什么是回表?

回答:

聚簇索引(主键索引)

  • 叶子节点存储的是完整的行数据
  • 一个表只能有一个聚簇索引
  • InnoDB 按主键聚集数据

非聚簇索引(二级索引)

  • 叶子节点存储的是主键值
  • 一个表可以有多个二级索引

什么是回表?
通过二级索引查询时,先找到主键值,再用主键去聚簇索引查完整数据,这个过程叫回表。

如何避免回表?
使用覆盖索引:查询的列都在索引中,不需要回表。

-- 假设有联合索引 (name, age)
SELECT name, age FROM user WHERE name = '张三'  -- 覆盖索引,不回表
SELECT * FROM user WHERE name = '张三'  -- 需要回表

26. MySQL 的四种隔离级别分别是什么?

回答:

隔离级别 脏读 不可重复读 幻读 说明
READ UNCOMMITTED 能读到未提交数据
READ COMMITTED 只能读已提交数据
REPEATABLE READ ✓(部分) MySQL 默认级别
SERIALIZABLE 串行执行

MySQL 默认是 REPEATABLE READ,并通过 MVCC + 间隙锁解决大部分幻读。

三种问题的解释:

  • 脏读:读到其他事务未提交的数据
  • 不可重复读:同一事务内两次读取结果不同(被其他事务 UPDATE)
  • 幻读:同一事务内两次查询结果集不同(被其他事务 INSERT)

27. MVCC 原理是什么?

回答:

MVCC(多版本并发控制)让读写互不阻塞,实现高并发。

核心机制:

  1. 隐藏列:每行有 trx_id(事务 ID)和 roll_pointer(指向 undo log)
  2. undo log:保存历史版本,形成版本链
  3. Read View:事务快照,决定能看到哪些版本

Read View 包含:

  • m_ids:创建时所有活跃事务 ID
  • min_trx_id:最小活跃事务 ID
  • max_trx_id:下一个要分配的事务 ID
  • creator_trx_id:创建该 Read View 的事务 ID

可见性判断:

  • trx_id < min_trx_id → 可见(事务已提交)
  • trx_id >= max_trx_id → 不可见(事务在快照后开启)
  • trx_idm_ids 中 → 不可见(事务还未提交)
  • 否则 → 可见

RC 和 RR 的区别:

  • RC:每次 SELECT 都创建新的 Read View
  • RR:只在第一次 SELECT 时创建,后续复用

二、Redis

28. Redis 五种数据结构及使用场景

回答:

类型 底层实现 典型场景
String SDS 缓存、计数器、分布式锁
Hash ziplist / hashtable 对象存储(用户信息)
List quicklist 消息队列、最新列表
Set intset / hashtable 去重、交集并集(共同好友)
ZSet ziplist / skiplist + dict 排行榜、延迟队列

补充高级类型:

  • HyperLogLog:基数统计(UV),12KB 存储亿级数据,误差 0.81%
  • Bitmap:位图,签到、在线状态
  • GEO:地理位置,附近的人

29. 缓存穿透、缓存雪崩、缓存击穿分别是什么?怎么解决?

回答:

1. 缓存穿透

  • 问题:查询不存在的数据,缓存和数据库都没有,每次都打到 DB
  • 场景:恶意攻击,大量请求不存在的 ID
  • 解决
    • 布隆过滤器:快速判断数据是否存在
    • 缓存空值:查不到也缓存,设置短过期时间

2. 缓存雪崩

  • 问题:大量缓存同时过期,请求全部打到 DB
  • 场景:缓存批量设置相同过期时间
  • 解决
    • 过期时间加随机值,打散过期时间
    • 热点数据永不过期,后台异步更新
    • 限流降级,保护数据库

3. 缓存击穿

  • 问题:热点 key 过期瞬间,大量请求打到 DB
  • 场景:秒杀商品详情页
  • 解决
    • 互斥锁:只允许一个请求去查 DB 并回填
    • 热点数据永不过期

30. Redis 分布式锁怎么实现?有什么问题?

回答:

基本实现(SETNX + 过期时间):

SET lock_key unique_value NX PX 30000
  • NX:只在 key 不存在时设置
  • PX:设置毫秒级过期时间
  • unique_value:唯一标识,防止误删别人的锁

释放锁(Lua 脚本保证原子性):

if redis.call('GET', KEYS[1]) == ARGV[1] then
    return redis.call('DEL', KEYS[1])
end
return 0

存在的问题:

  1. 锁超时问题:业务没执行完锁就过期了

    • 解决:Redisson 看门狗机制,自动续期
  2. 主从同步延迟:主节点加锁成功,同步前主节点挂了,从节点变主节点,锁丢失

    • 解决:RedLock(红锁),向多个独立 Redis 实例加锁
  3. 可重入问题:同一线程多次加锁

    • 解决:Redisson 支持可重入锁,用 Hash 记录线程和重入次数

31. Redisson 看门狗机制是什么?

回答:

看门狗(WatchDog)是 Redisson 解决锁超时问题的机制。

工作原理:

  1. 加锁时不指定过期时间,默认 30 秒(lockWatchdogTimeout)
  2. 后台启动定时任务,每 10 秒(30/3)检查一次
  3. 如果锁还被持有,就重置过期时间为 30 秒
  4. 释放锁或线程挂掉,定时任务停止,锁自动过期

代码示例:

RLock lock = redissonClient.getLock("myLock");
lock.lock();  // 不指定时间,启用看门狗
try {
    // 业务逻辑
} finally {
    lock.unlock();
}

注意:如果指定了过期时间 lock.lock(10, TimeUnit.SECONDS),看门狗不会启动!


三、RabbitMQ

32. RabbitMQ 的交换机类型有哪些?

回答:

类型 路由规则 场景
Direct 精确匹配 routing key 点对点,指定队列
Fanout 忽略 routing key,广播到所有绑定队列 广播通知
Topic 通配符匹配(* 一个词,# 多个词) 按主题分类
Headers 匹配消息头 很少用

示例:

  • Topic 交换机,routing key 为 order.created.vip
  • 队列 A 绑定 order.# → 收到
  • 队列 B 绑定 order.created.* → 收到
  • 队列 C 绑定 order.canceled.* → 收不到

33. 如何保证消息不丢失?

回答:

消息可能在三个地方丢失,需要分别处理:

1. 生产者 → MQ(生产端丢失)

  • confirm 机制:消息持久化到磁盘后返回 ack
  • 配置:publisher-confirm-type: correlated
  • 没收到 ack 就重试或记录日志

2. MQ 自身(Broker 丢失)

  • 交换机持久化durable = true
  • 队列持久化durable = true
  • 消息持久化deliveryMode = 2

3. MQ → 消费者(消费端丢失)

  • 手动 ACK:消费成功后手动确认
  • 配置:acknowledge-mode: manual
  • 消费失败:nack 重回队列或进死信队列

一句话总结:生产端 confirm + 持久化 + 消费端手动 ACK。


34. 如何保证消息不重复消费?

回答:

MQ 不保证消息只投递一次,所以消费端必须做幂等

常见幂等方案:

1. 唯一 ID + 去重表

// 消费前检查
if (dedupMapper.exists(messageId)) {
    return;  // 已处理,跳过
}
// 处理业务
// 写入去重表
dedupMapper.insert(messageId);

2. 数据库唯一约束

  • 利用主键或唯一索引,重复插入会失败

3. 状态机

  • 订单状态:待支付 → 已支付 → 已发货
  • 只有特定状态才允许流转,否则跳过

4. Redis 原子操作

Boolean success = redisTemplate.opsForValue()
    .setIfAbsent(messageId, "1", 1, TimeUnit.HOURS);
if (!success) {
    return;  // 已处理
}

35. 消息积压怎么处理?

回答:

紧急处理:

  1. 增加消费者数量:临时扩容消费端
  2. 批量消费:一次拉取多条消息处理
  3. 跳过非关键消息:如果是日志类消息,可以直接丢弃

根因分析:

  • 消费速度 < 生产速度:优化消费逻辑、增加消费者
  • 消费者挂了:检查消费者健康状态、报警机制
  • 下游依赖慢:优化下游或做异步处理

长期优化:

  • 消费端使用线程池并行处理
  • 拆分队列,按业务分流
  • 监控告警,提前发现

第四部分:架构与设计

36. CAP 理论是什么?

回答:

CAP 指分布式系统中三个特性最多只能同时满足两个:

  • C(Consistency)一致性:所有节点看到的数据一致
  • A(Availability)可用性:每个请求都能得到响应
  • P(Partition tolerance)分区容错:网络分区时系统仍能运行

为什么只能三选二?
网络分区是必然会发生的,所以 P 必须保证,只能在 C 和 A 之间选择:

  • CP:保证一致性,可能拒绝服务(如 ZooKeeper)
  • AP:保证可用性,可能数据不一致(如 Eureka)

实际应用中,通常选择 BASE(最终一致性):

  • 基本可用
  • 软状态
  • 最终一致

37. 分布式事务有哪些解决方案?

回答:

方案 一致性 复杂度 性能 场景
2PC 强一致 数据库事务
TCC 强一致 资金交易
SAGA 最终一致 长事务流程
本地消息表 最终一致 异步场景
MQ 事务消息 最终一致 异步解耦

我项目中用的是本地消息表模式:

  1. 业务操作和消息写入在同一个本地事务
  2. 后台定时扫描未发送的消息
  3. 消费端处理成功后更新消息状态
  4. 需要做好幂等处理

38. 如何设计一个高并发系统?

回答:

高并发设计遵循几个核心原则:

1. 缓存

  • 多级缓存:本地缓存 → Redis → 数据库
  • 热点数据预热

2. 异步

  • MQ 解耦非核心流程
  • 异步写入、批量处理

3. 池化

  • 连接池、线程池复用资源

4. 分离

  • 读写分离、动静分离、冷热分离

5. 拆分

  • 服务拆分(微服务)
  • 数据拆分(分库分表)

6. 限流降级

  • 限流保护系统
  • 熔断快速失败
  • 降级保证核心功能

具体到我的短链项目:

  • 布隆过滤器拦截无效请求
  • Redis 缓存热点短链
  • MQ 异步写入访问日志
  • 分布式锁控制并发创建

39. 常用的设计模式及场景?

回答:

创建型:

  • 单例模式:Spring Bean 默认单例、线程池
  • 工厂模式:Spring BeanFactory、日志工厂

结构型:

  • 代理模式:Spring AOP、MyBatis Mapper
  • 装饰器模式:Java IO 流

行为型:

  • 策略模式:支付方式选择、多渠道发送
  • 模板方法模式:JdbcTemplate、RestTemplate
  • 观察者模式:Spring 事件机制

我项目中用到的:

  • 策略模式:邮件系统多通道适配,抽象发送接口,不同通道不同实现
  • 模板方法:定义发送流程骨架,子类实现具体发送逻辑

第五部分:短链接项目深度问答

项目整体

40. 简单介绍一下你的短链接项目?

回答:

这是一个短链接服务系统,主要解决长链接不便分享、无法追踪访问数据的问题。

核心功能:

  1. 短链生成:长链接转换为 6 位短码
  2. 短链跳转:访问短链 302 重定向到原始 URL
  3. 访问统计:记录 PV、UV、访问来源、设备分布等

技术架构:

  • 后端:Spring Boot + MyBatis-Plus
  • 缓存:Redis(缓存 + 分布式锁 + HyperLogLog)
  • 消息队列:RabbitMQ(异步统计)
  • 防穿透:Guava 布隆过滤器

核心设计:

  • 分布式锁防止同一 URL 并发创建多个短码
  • 布隆过滤器 + 缓存 + DB 三级读取
  • MQ 解耦统计写入,保证跳转链路快速响应

41. 短链创建的完整流程是什么?

回答:

短链创建流程分 7 步:

  1. 加分布式锁:用 originalUrl.hashCode() 作为锁 key,防止同一 URL 并发创建
  2. 二次校验:持锁后查数据库,如果已存在直接返回
  3. 生成短码:时间戳 + 机器 ID + 序列号生成唯一 ID,Base62 编码为 6 位短码
  4. 布隆过滤器检查:如果短码"可能存在",重新生成
  5. 写入数据库:保存短链映射关系
  6. 更新布隆过滤器:加入新短码
  7. 写入 Redis 缓存:缓存原始 URL,过期时间加随机抖动

为什么这么设计:

  • 分布式锁 + 二次校验:保证幂等性
  • 布隆过滤器:快速排除冲突短码
  • 随机过期时间:防止缓存雪崩

42. 短链跳转的完整流程是什么?

回答:

跳转流程分 5 步,核心是快速返回

  1. 格式校验:正则检查短码是否为 6 位字母数字
  2. 布隆过滤器:判断短码是否可能存在,不存在直接返回 404
  3. 查 Redis 缓存:命中直接拿到原始 URL
  4. 缓存未命中查 DB:查数据库并回填缓存
  5. 异步发送 MQ:构建访问日志消息发送到队列
  6. 302 重定向:立即返回重定向响应

关键设计:

  • 布隆过滤器前置拦截,减少无效请求
  • 统计走 MQ 异步,不阻塞跳转响应
  • 跳转响应时间要控制在 50ms 以内

技术细节

43. 为什么短链创建要用分布式锁?

回答:

为了防止同一 URL 并发创建多个短码

场景举例:

  1. 用户 A 请求创建短链,URL 是 https://example.com
  2. 同时用户 B 也请求创建,URL 也是 https://example.com
  3. 两个请求都查询数据库发现不存在
  4. 两个请求都生成新短码并插入
  5. 结果:同一个 URL 有了两个短码

加锁后:

  • 锁 key 用 originalUrl.hashCode()
  • 同一 URL 的请求串行执行
  • 第二个请求拿到锁后,二次查询发现已存在,直接返回

为什么用 hashCode?

  • URL 可能很长,用 hashCode 缩短锁 key
  • hash 碰撞只会影响性能,不影响正确性

44. Redisson 分布式锁的参数怎么设置的?

回答:

lock.tryLock(5, 10, TimeUnit.SECONDS);
// 参数1:等待获取锁的最大时间 5 秒
// 参数2:锁的自动释放时间 10 秒

为什么等待 5 秒?

  • 高并发时可能多个请求竞争同一把锁
  • 等待 5 秒可以让请求排队获取锁
  • 避免直接失败影响用户体验

为什么超时 10 秒?

  • 防止死锁,比如进程崩溃没释放锁
  • 10 秒足够完成业务逻辑
  • 如果业务确实需要更长时间,可以用看门狗自动续期

实际生产中:如果不指定超时时间,Redisson 会启用看门狗,默认 30 秒超时并自动续期。


45. 布隆过滤器误判了怎么办?

回答:

布隆过滤器的特性是:

  • 判断不存在,则一定不存在
  • 判断可能存在,则需要进一步确认

误判的影响:

  • 创建短码时误判:重新生成短码,多循环一次,性能损耗可接受
  • 跳转时误判:多查一次数据库,查不到返回 404

所以误判不会导致错误结果,只会多一次 DB 查询。

控制误判率:

BloomFilter.create(
    Funnels.stringFunnel(StandardCharsets.UTF_8),
    1000000,  // 预期元素数量
    0.001     // 误判率 0.1%
);

如果误判率升高:

  • 元素数量超过预期容量,需要重建过滤器
  • 或者使用 Redis 的 Bloom Filter 模块,支持动态扩容

46. 缓存和数据库如何保持一致?

回答:

我采用的是 Cache Aside 策略,允许短时间不一致。

读流程:

  1. 先查缓存,命中直接返回
  2. 未命中查数据库
  3. 回填缓存,设置过期时间

写流程(创建短链):

  1. 先写数据库
  2. 再写缓存

为什么不先写缓存?

  • 如果数据库写入失败,缓存里就有脏数据

会不会不一致?

  • 短链系统是只增不改的场景
  • 短链创建后不会修改原始 URL
  • 所以不存在更新不一致的问题

如果是修改场景,一般用延迟双删:

  1. 先删除缓存
  2. 更新数据库
  3. 延迟一段时间后再删缓存

47. 为什么用 HyperLogLog 统计 UV?

回答:

UV 是独立访客数,需要对访问 IP 去重。

如果用 Set:

  • 每个短链的访问 IP 都存一个 Set
  • 100 万 IP 大约占用 50MB 内存
  • 短链数量多的话内存扛不住

HyperLogLog 的优势:

  • 固定只占 12KB 内存
  • 可以统计亿级基数
  • 误差率约 0.81%,UV 统计可接受

代码实现:

// 总 UV
stringRedisTemplate.opsForHyperLogLog().add(uvKey, ip);

// 今日 UV
String todayUvKey = UV_KEY_PREFIX + shortCode + ":" + LocalDate.now();
stringRedisTemplate.opsForHyperLogLog().add(todayUvKey, ip);

缺点:

  • 只能估算基数,不能获取具体元素
  • 有一定误差,对精确统计不适用

48. 访问统计为什么走 MQ 异步?

回答:

因为跳转链路要尽量短

如果同步统计:

  1. 查询原始 URL
  2. 写入访问日志表
  3. 更新 Redis PV
  4. 更新 HyperLogLog UV
  5. 302 重定向

统计操作涉及数据库写入和多次 Redis 操作,可能耗时 50-100ms,用户会明显感知到延迟。

异步后:

  1. 查询原始 URL
  2. 发送 MQ 消息(1-2ms)
  3. 302 重定向

统计操作放到消费端异步处理:

  • 跳转响应时间从 100ms 降到 20ms
  • 消费端处理慢不影响用户体验
  • MQ 还能起到削峰作用

49. MQ 消费失败怎么处理?

回答:

我用的是手动 ACK + 重回队列

try {
    // 处理业务逻辑
    channel.basicAck(deliveryTag, false);  // 成功确认
} catch (Exception e) {
    channel.basicNack(deliveryTag, false, true);  // 失败重回队列
}

存在的问题:

  • 如果消息一直处理失败,会无限重试
  • 可能造成消息积压

改进方案:

  1. 设置重试上限:消息头记录重试次数
  2. 死信队列:超过重试上限进入死信队列
  3. 人工处理:监控死信队列,报警后人工处理
// 改进后的消费逻辑
int retryCount = getRetryCount(message);
if (retryCount >= 3) {
    // 进入死信队列
    channel.basicReject(deliveryTag, false);
} else {
    // 重试
    channel.basicNack(deliveryTag, false, true);
}

50. 如果让你优化这个项目,你会怎么做?

回答:

1. 布隆过滤器优化

  • 当前是本地内存,启动时全量加载
  • 改用 Redis Bloom Filter 模块,支持分布式、持久化

2. MQ 幂等优化

  • 当前消费可能重复
  • 增加去重表,消费前检查消息 ID 是否已处理

3. 分库分表

  • 短链表和访问日志表数据量大
  • 按短码 hash 分表,按时间分库

4. 限流熔断

  • 增加 Sentinel 限流
  • 热点短链保护,防止单个短链流量打爆

5. 监控完善

  • 接入 Prometheus + Grafana
  • 监控 QPS、响应时间、缓存命中率、队列积压

第六部分:邮件推送中心项目深度问答

项目整体

51. 简单介绍一下邮件推送中心项目?

回答:

这是一个邮件推送服务,支持模板化发送和定时发送。

核心功能:

  1. 模板管理:创建、编辑邮件模板,支持变量占位符
  2. 即时发送:创建任务后立即发送
  3. 定时发送:指定时间发送
  4. 失败重试:发送失败自动重试
  5. 发送记录:记录每次发送的结果

技术架构:

  • 后端:Spring Boot + MyBatis-Plus
  • 消息队列:RabbitMQ
  • 定时任务:Spring @Scheduled
  • 邮件通道:阿里云 DirectMail

核心设计:

  • 任务队列解耦,接口快速响应
  • 定时扫描支持延迟发送
  • 失败重试机制保证可靠性

52. 为什么要把发送拆成"创建任务 + 队列消费"?

回答:

这是异步解耦的设计。

如果同步发送:

  1. 接收请求
  2. 渲染模板
  3. 调用邮件通道发送(可能耗时 1-3 秒)
  4. 返回响应

接口响应时间长,用户体验差。如果通道超时,还会影响调用方。

异步后:

  1. 接收请求
  2. 渲染模板,创建发送任务,落库
  3. 任务 ID 入队
  4. 立即返回任务编号

好处:

  • 接口响应快,用户立即得到反馈
  • 发送失败可以重试,不影响调用方
  • 队列削峰,应对突发流量
  • 便于扩展消费端并行度

53. 即时发送和定时发送在流程上有什么区别?

回答:

即时发送:

  1. 创建任务,状态为"待发送"
  2. 立即将任务 ID 投递到 MQ
  3. 消费端处理发送

定时发送:

  1. 创建任务,状态为"待发送",记录 scheduledTime
  2. 不入队,只落库
  3. 定时扫描器每 10 秒查询一次
  4. 查到"待发送 + 定时发送 + 已到期"的任务
  5. 将到期任务投递到 MQ

代码区分:

if (request.getSendType() == 1) {  // 即时发送
    rabbitTemplate.convertAndSend(EXCHANGE, ROUTING_KEY, task.getId());
}
// 定时发送不做任何操作,等待扫描器处理

54. 消费端如何做到幂等?

回答:

当前实现用的是状态判断

// 如果已经是成功或失败终态,直接跳过
if (task.getStatus() == 2 || task.getStatus() == 3) {
    return;
}

存在的问题:

  • 如果消息在"发送中"状态时重复投递,可能重复发送
  • 比如:消费端处理完但没来得及 ACK 就崩了,消息重回队列

更完善的方案:

1. 乐观锁

// 用版本号控制
UPDATE send_task SET status = 1, version = version + 1 
WHERE id = ? AND version = ?
// 更新影响行数为 0 说明被其他消费者处理了

2. 唯一约束

// 发送记录表对 (task_id, send_time) 加唯一索引
// 重复插入会失败

3. 分布式锁

String lockKey = "email:send:" + taskId;
if (redisTemplate.opsForValue().setIfAbsent(lockKey, "1", 1, TimeUnit.HOURS)) {
    // 获取锁成功,处理发送
} else {
    // 已被其他消费者处理
}

55. 失败重试的策略是怎么设计的?

回答:

当前是等待重试 + 定时扫描的模式:

失败时:

if (task.getRetryCount() < MAX_RETRY) {
    task.setRetryCount(task.getRetryCount() + 1);
    task.setStatus(4);  // 等待重试
} else {
    task.setStatus(3);  // 最终失败
}

定时扫描(每 20 秒):

// 查询等待重试的任务
wrapper.eq(SendTask::getStatus, 4);
List<SendTask> tasks = sendTaskMapper.selectList(wrapper);
for (SendTask task : tasks) {
    task.setStatus(0);  // 重置为待发送
    sendTaskMapper.updateById(task);
    rabbitTemplate.convertAndSend(..., task.getId());  // 重新入队
}

为什么不立即重试?

  • 立即重试可能遇到相同问题(通道故障)
  • 拉开间隔可以等待故障恢复
  • 避免短时间大量重试放大压力

可优化点:

  • 指数退避:第 1 次 20 秒,第 2 次 40 秒,第 3 次 80 秒
  • 错误分类:网络超时可重试,参数错误不重试

56. 模板变量替换有什么边界问题?

回答:

当前用的是简单字符串替换:

for (Map.Entry<String, String> entry : variables.entrySet()) {
    result = result.replace("${" + entry.getKey() + "}", entry.getValue());
}

存在的问题:

1. 变量缺失

  • 模板有 ${name} 但没传 name 变量
  • 结果:邮件内容包含 ${name} 占位符

2. 特殊字符

  • 变量值包含 HTML 标签或脚本
  • 可能导致 XSS 或显示异常

3. 性能问题

  • 变量很多时,多次 replace 效率低

改进方案:

  1. 变量校验:解析模板必填变量,和传入变量对比
  2. HTML 转义:对变量值做 escape 处理
  3. 模板引擎:使用 FreeMarker 或 Thymeleaf

57. 如果扩展到多邮件通道,怎么设计?

回答:

使用策略模式进行抽象:

1. 定义统一接口

public interface EmailSender {
    void send(String toEmail, String subject, String content) throws Exception;
    String getChannelCode();  // 通道标识
}

2. 多通道实现

@Component
public class AliyunEmailSender implements EmailSender {
    public String getChannelCode() { return "aliyun"; }
    public void send(...) { /* 阿里云实现 */ }
}

@Component
public class TencentEmailSender implements EmailSender {
    public String getChannelCode() { return "tencent"; }
    public void send(...) { /* 腾讯云实现 */ }
}

3. 策略选择器

@Component
public class EmailSenderSelector {
    @Autowired
    private List<EmailSender> senders;
    
    private Map<String, EmailSender> senderMap;
    
    @PostConstruct
    public void init() {
        senderMap = senders.stream()
            .collect(Collectors.toMap(EmailSender::getChannelCode, s -> s));
    }
    
    public EmailSender select(String channelCode) {
        return senderMap.getOrDefault(channelCode, defaultSender);
    }
}

4. 支持降级

  • 主通道失败自动切换备用通道
  • 配置权重实现灰度发布

58. 邮件通道密钥如何安全管理?

回答:

当前问题:
密钥写在配置文件里,存在安全风险。

改进方案:

1. 环境变量

aliyun:
  access-key-id: ${ALIYUN_ACCESS_KEY_ID}
  access-key-secret: ${ALIYUN_ACCESS_KEY_SECRET}

2. 配置中心加密

  • Nacos 配置加密
  • Apollo 密文存储

3. 密钥管理服务

  • 阿里云 KMS
  • HashiCorp Vault

4. 权限控制

  • 生产环境配置文件只有运维能访问
  • 审计日志记录密钥使用

第七部分:场景设计题

59. 如果让你设计一个高并发短链系统,QPS 要达到 10 万,你会怎么设计?

回答:

10 万 QPS 需要从多个层面优化:

1. 多级缓存

  • 本地缓存(Caffeine):热点短链缓存在 JVM
  • Redis 集群:分布式缓存层
  • 减少 Redis 访问和网络开销

2. 读写分离

  • 跳转是读操作,创建是写操作
  • 读写比 100:1,重点优化读链路

3. 分库分表

  • 短链表按短码 hash 分表
  • 访问日志表按时间分表

4. 集群部署

  • 服务无状态化,水平扩展
  • Nginx 负载均衡

5. 异步化

  • 统计完全异步
  • MQ 集群保证吞吐

6. 限流降级

  • 单短链限流,防止热点打爆
  • 统计可降级,保证核心跳转

架构图:

用户 → CDN → Nginx 集群 → 服务集群 → 本地缓存 → Redis 集群 → MySQL 集群
                                     ↓
                                MQ 集群 → 统计服务

60. 短链接被恶意刷量怎么办?

回答:

识别恶意流量:

  1. 单 IP 高频访问:同一 IP 短时间大量请求
  2. UA 异常:无 UA 或 UA 明显是爬虫
  3. 访问模式异常:凌晨流量暴增

防护方案:

1. 限流

// 单 IP 限流:每分钟最多 100 次
String limitKey = "ratelimit:" + ip;
Long count = redisTemplate.opsForValue().increment(limitKey);
if (count == 1) {
    redisTemplate.expire(limitKey, 1, TimeUnit.MINUTES);
}
if (count > 100) {
    throw new RateLimitException("请求过于频繁");
}

2. 验证码

  • 检测到异常访问,返回验证页面
  • 验证通过后才真正跳转

3. IP 黑名单

  • 识别到恶意 IP 加入黑名单
  • 请求先过黑名单检查

4. 统计隔离

  • 可疑流量单独标记
  • 不计入正常统计
Logo

有“AI”的1024 = 2048,欢迎大家加入2048 AI社区

更多推荐