最近遇到的大厂后端面试题
复制为 Markdown金九银十,最近出去面试了几家大厂,感觉现在的强度越来越高了。整理了几道面试题,复盘一下。
题目清单:
- 系统设计:短链接服务
- 手写代码:n 个人在地图上的相遇点
- 理论:SSE 会话会不会把 Tomcat 线程占满
- 手写代码:单向链表判环(LeetCode 141 原题)
- 理论:Spring Bean 的生命周期
- 理论:Redisson 分布式锁怎么写、有什么坑
- 理论:Spring 的优势、IoC 和 AOP
1、系统设计:短链接服务
题目要求:把
https://xx.example.com这种长链接转成https://xx.xx/aYsN93这种短链接。约束条件:每天生成 100 万条,每天访问 1 亿以上,短链之间没有映射关系,长期有效。
系统设计题的思路一般开始要先估算容量,分析每一步的瓶颈在哪里,然后针对性地给出设计方案。
写流量:100 万条/天,平均 12 QPS,峰值按十倍算也就一百多 QPS。写入侧没什么压力。
读流量:1 亿次/天,平均 1100+ QPS,峰值可能上万。读写比 100:1,是读多写少的系统,设计重点在读取链路。
存储量:长期有效意味着数据只增不减。每天 100 万条,十年 36.5 亿条。一条记录(短码、长链接、创建时间)按 200 字节算,十年约 700GB,单库扛不住,要考虑分库分表。
短码长度:示例 aYsN93 是 6 位。用 Base62(a-z、A-Z、0-9 共 62 个字符)编码,6 位能表示 62⁶ ≈ 568 亿个组合,比十年存量高一个数量级,够用。
综合来看,系统应该有两个地方是瓶颈:
- 因为短链需要长期有效,也就是短链的字符串要是唯一的,不能有重复,且每天还要生成 100 万条。那第一个瓶颈就在这里,短链生成怎么确保既高效又唯一?
- 读多写少的系统,读的流量是重点,尤其是亿级流量的读。这是明显的第二个瓶颈,需要上多级缓存。
短链生成发号器
短链生成一般有两个方案:
- 对长链接做哈希(比如 MurmurHash,取前 6~7 位)。好处是不用额外的发号组件;问题是哈希会碰撞,碰撞后要加盐重新哈希,库里数据越多碰撞越频繁。长期有效的场景下不推荐。
- 方案二:发号器发自增 ID,再 Base62 编码。比如发到 123456789,Base62 编码后就是 8M0kX。天然无碰撞,我选这个。
发号器用数据库号段模式就够:发号服务启动时去 DB 执行一次 UPDATE id_generator SET max_id = max_id + 1000,把 1000 个号领到内存里,发完再取。DB 每秒最多被打一次,可以忽略;服务重启最多浪费一个号段,无所谓。

需要注意的几点:
- 取号和发号是彻底分离的两件事。
UPDATE id_generator SET max_id = max_id + 1000是整条链路里唯一碰 DB 的操作,而且每 1000 个号才发生一次。每天 100 万条 → 每天 1000 次 UPDATE,这个压力可以当作没有。 - 号段内的 999 次发号是纯内存自增,纳秒级,没有网络往返,也不会因为 DB 抖动阻塞写请求。
- 每个服务实例各持一段,实例之间不共享内存也不互相阻塞,扩容直接加实例就行,不需要改发号逻辑。
- 服务重启会丢掉当前号段的剩余号,最多浪费 1000 个。62⁶ 有 568 亿个号,浪费得起,不用做号段持久化。
- 段的乱序由 shuffle 或者
id × P mod 62⁶保证,见下面防爬虫遍历。
题目里"短链之间没有任何映射关系"这句话也要处理:如果按 1、2、3、4 顺序发号,生成的短码是连续的,别人 +1 就能遍历你的短链库。两个处理办法:
- 号段取回内存后先 shuffle 一遍再往外发,号段内顺序是乱的,够用。
- 讲究一点可以用
code = Base62( id × P mod 62⁶ ),P 取一个和 62⁶ 互质的大数。这个映射是双射,既保证无碰撞,短码看起来又是随机的。
最后落地的写链路可以是这样:

按照编号过一遍:
- ① 收请求。客户端 POST 长链接,服务只做参数校验。这里有一个刻意的省略:不检查这个长链接是不是已经有短链了。题目说短链之间没有映射关系,同一个长链接重复提交生成两个短码是可以接受的,省掉一次查询,也省掉"长链接字段要不要建唯一索引"的纠结(几 KB 的长链接上建索引代价不小)。
- ②③ 取号。走本地内存号段,绝大多数请求根本不碰 DB,取号这一段见上面的发号器时序图。
- ④ 编码。Base62 编码再加防遍历混淆,得到 6 位短码。这一步是纯计算,没有 IO。
- ⑤⑥ 落库。
short_code上是唯一索引,即使发号器出了岔子发重了,这一步也会兜住——插入失败就重新取号重试。唯一索引是这套方案的最后一道防线,一定要提。 - ⑦⑧ 回填缓存。刚生成的短链很可能马上被访问(分享出去的第一波流量),提前回填能挡掉一次缓存未命中。
- ⑨ 返回短链。
注意写缓存和写数据库的顺序不能反:反过来的话,缓存写成功、落库失败,就会留下一份指向不存在记录的缓存,用户点进去回源查不到,只能等 TTL 过期才自愈。写侧 QPS 只有十几,多一次 DB 往返换一个简单可靠的顺序,值得。
状态码选 301 还是 302
跳转发什么状态码基本是必问的点:
- 301 永久重定向:浏览器会永久缓存这个映射,下次访问短链浏览器自己直接跳,请求不到我们服务器。访问量统计会丢,以后想让短链失效也做不到。
- 302 临时重定向:浏览器不缓存,每次访问都经过服务端,统计、风控、下线都可以做。
短链服务需要统计点击量,所以选 302。
读链路的多级缓存
每天 1 亿次的读取量,对于任何系统都是一个难点。也是面试时考的比较多的一个点。

整体过一遍:
- ① 收请求,拿到短码。
- ②③ 过布隆过滤器。布隆过滤器放在服务本地内存里,O(1) 且不产生网络调用。判定"一定不存在"的直接 404,爬虫和瞎编的短码在这里就被挡掉了,连 Redis 都不用碰。
- ④⑤ 查 Redis。命中就直接返回,这是热路径,绝大部分请求在这里结束。Redis 容量按峰值 QPS 的 2~3 倍留余量即可,命中率一般能在 99% 以上。
- ⑦⑧ 回源 MySQL。只有缓存过期、被淘汰、或者冷门短链第一次被访问才会走到这里。按短码哈希分片,查询能直接路由到单个分片。
- ⑨⑩ 回填缓存。回填一定要带 TTL(比如 1 天)。短链访问是典型的长尾分布,全量永久驻留内存太贵,冷链接没必要一直占着。
- ⑥⑪ 返回 302。两条分支最后都收敛到同一个响应:
302 Location: <long_url>。
需要注意的几点:
- 缓存防穿透。总会有不存在的短码请求打过来(爬虫或者攻击者瞎编的),每次都打穿缓存到 DB。在服务入口加一个布隆过滤器,所有真实存在的短码登记在册,不存在的短码在内存里就被挡掉。配套要注意三点:
- 被误判放行的请求仍会穿透,DB 查不到时返回 404 并缓存空值(短 TTL)。
- 新短码要同步进过滤器。单机内存版在多实例下每台都要有一份;常见做法是把位图放 Redis 由各实例定时拉取重建,或者用 MQ 广播增量。位图重建期间要么短暂降级为直查缓存,要么双 buffer 切换。
- 规模要算一下。十年 36.5 亿条短码,按 1% 误判率大约 10 bit/条,位图接近 4.5GB——分摊到每台机器上是可控的,但如果内存紧张,可以只把活跃短码放进过滤器,冷链接直接落库查。
- 分库分表的键。这个系统的查询都按短码来,分片键就是短码本身,按短码哈希取模分片,查询都能路由到单个分片,不需要跨片查询。
- 点击量统计不要同步做。302 返回之后异步打点(写 MQ 再批量落库),统计链路再慢也不能拖慢跳转。这也是选 302 而不是 301 的直接原因:301 之后浏览器不再请求服务端,统计从根上就做不了。
追问布隆过滤器和缓存空值:布隆过滤器说"存在"可能是误判,但布隆过滤器说"不存在"一定可信。所以被误判放行的请求还是会把 ④ 走一遍,最后 DB 查不到只能返回 404——这类请求建议顺手缓存一个空值(短 TTL),否则同一个不存在的短码会被反复打到 DB,等于布隆过滤器白加了。
整体架构

几个常见的追问:
- 缓存和 DB 会不会不一致? 短链的场景比电商简单得多:映射关系只增不改。缓存不一致的前提是"同一份数据有新旧两个版本",而短链写进库就不再变,压根没有旧版本可读。如果产品要求短链能下线,那就在 DB 里加状态位(active / disabled),下线时删缓存 + 改状态,跳转时以 DB 为准做一次校验。
- 超热短链把单个 Redis 分片打爆怎么办? 按短码分片的问题就是热点会集中。缓解手段是服务本地再挂一层 Guava/Caffeine 本地缓存(几百毫秒到几秒的短 TTL),或者做热点探测,把 Top N 短码同步到所有实例的本地缓存。
- Redis 挂了怎么办? 全量请求会直接打到 MySQL,1 亿/天的量级 DB 扛不住。所以要有降级预案:布隆过滤器先挡掉一部分,DB 侧入口限流,超时快速失败(跳转降级为 503 或返回一个引导页),另外 Redis 本身要做哨兵/集群高可用。
- 同一个长链接提交两次会怎样? 会得到两个短码,这是题目"短链之间没有映射关系"允许的,不用去重。除非产品要求幂等,那就要按长链接做 hash 去重,去重表本身又成了新的热点。
- 短链能不能被遍历出来? 顺序发号确实可以被 +1 遍历,所以要 shuffle 或者做双射混淆;再配合布隆过滤器之外的入口限流,防止有人拿字典刷短码。
- 为什么不做多级缓存/全局布隆过滤器? 都属于优化项,先把"布隆 → Redis → MySQL"这条主干讲清楚,面试官追问了再往上加。
2、手写代码:n 个人在地图上的相遇点
有 n 个人,每个人一个 6 元素的数组:前两个是坐标 (x, y),后四个是方向开关,顺序为上、下、左、右,1 表示可以往这个方向走。比如
1010是能上、能左,1111是四个方向都能走。要求输出一个坐标 (x, y),表示这 n 个人可以相遇的位置。
感觉这个题目要在面试现场写出来非常困难(除非算法底子非常扎实吧。
我最开始想到的是 BFS,写到一半面试官直接打断说思路不对。后面考虑了一会也没找到解法,遗憾结束。
思路应该是要用区间交集,画个图就什么都明白了:

分几种情况:
- 方向全开(1111):整个平面都能走,对相遇点没有约束;
- 能上 + 能左(1010):相遇点的 x 不能比他大,y 不能比他小;
- 只允许一个方向:只能沿一条射线走;
- 一个方向都不允许(0000):只能原地不动,相遇点必须就是他站的位置。
这样"能不能相遇"就转成了一个数学问题:所有人的象限求交集,交集非空就能相遇,交集里任取一个点都是答案。
推导:
设相遇点为 (X, Y),对每个人 (x, y) 来说:
| 他的限制 | 对相遇点的约束 |
|---|---|
| 不能往右(right = 0) | X ≤ x |
| 不能往左(left = 0) | X ≥ x |
| 不能往上(up = 0) | Y ≤ y |
| 不能往下(down = 0) | Y ≥ y |
所以思路就变成了:
扫一遍所有人,x 方向维护一个下界 xLow(取所有下界的最大值)和一个上界 xHigh(取所有上界的最小值),y 方向同理。最后 xLow ≤ xHigh 且 yLow ≤ yHigh,交集就非空,取 (xLow, yLow) 即可。时间 O(n),空间 O(1)。
代码:
代码
public int[] findMeetingPoint(int[][] people) {
// people[i] = {x, y, up, down, left, right},方向位为 1 表示可以走
long xLow = Long.MIN_VALUE, xHigh = Long.MAX_VALUE;
long yLow = Long.MIN_VALUE, yHigh = Long.MAX_VALUE;
for (int[] p : people) {
int x = p[0], y = p[1];
int up = p[2], down = p[3], left = p[4], right = p[5];
if (right == 0) xHigh = Math.min(xHigh, x); // 他不能往右,X 不能超过他
if (left == 0) xLow = Math.max(xLow, x); // 他不能往左,X 不能小于他
if (up == 0) yHigh = Math.min(yHigh, y); // 他不能往上,Y 不能超过他
if (down == 0) yLow = Math.max(yLow, y); // 他不能往下,Y 不能小于他
}
if (xLow > xHigh || yLow > yHigh) {
return null; // 交集为空,无解
}
// 交集里任取一点,这里取左下角
return new int[]{ (int) xLow, (int) yLow };
}
补充几个相似的题目。
1、曼哈顿距离:
- Leetcode 最小化曼哈顿距离:https://leetcode.cn/problems/minimize-manhattan-distances/;
- Codeforces 博客:高维曼哈顿距离技巧:https://codeforces.com/blog/entry/57534
- OI Wiki 半平面交(这题的本质): https://oi-wiki.org/geometry/half-plane/
- cp-algorithms Manhattan distance(切比雪夫变换):https://cp-algorithms.com/geometry/manhattan-distance.html
- Codeforces 1486B Eastern Exhibition(二维曼哈顿距离中位数):https://codeforces.com/problemset/problem/1486B
- HackerRank Meeting Point(网格上的多源最短路,求最小总时间):https://www.hackerrank.com/challenges/meeting-point/problem
2、「方向 / 象限约束」相关
- LeetCode 3025 人员站位的方案数 I: https://leetcode.cn/problems/find-the-number-of-ways-to-place-people-i/
- LeetCode 3027 人员站位的方案数 II:https://leetcode.cn/problems/find-the-number-of-ways-to-place-people-ii/
- LeetCode 2940 找到 Alice 和 Bob 可以相遇的建筑:https://leetcode.cn/problems/find-building-where-alice-and-bob-can-meet/
- 众安笔试 0321(牛客):https://www.nowcoder.com/discuss/743096952216174592
3、理论:SSE 会话会不会把 Tomcat 线程占满
现在智能体的流式输出普遍采用 SSE 协议,这是一个服务端主动向客户端推送的长链接协议。那针对 Tomcat 来讲,默认的线程是有限的,比如有很多个用户同时在和智能体会话,这些用户的 SSE 会话线程会不会把 Tomcat 的线程池占满呢?怎么避免?
考察线程模型。
如果是用传统的同步 Servlet 写法,handler 里循环往响应推数据,那确实是一条 SSE 长连接占一个 worker 线程,直到连接断开才释放。Tomcat 的 worker 线程池 maxThreads 默认 200,两百多个并发 SSE 连接就能打满。打满之后不只是 SSE 接口,普通请求也拿不到线程,只能排队或被拒。
两种线程模型的对比

先说一个前提:Tomcat 8.5 之后 BIO 连接器已经移除,默认是 NIO。Acceptor 线程负责接连接,Poller 线程(Selector 事件循环)负责监听所有连接,只有执行 Servlet 业务代码时才从 worker 池分配线程。也就是说连接本身不占 worker 线程,占 worker 的是同步执行。SSE 用同步方式写,worker 就被推送逻辑一直占着不还。
对应的解法:
解法一:Servlet 3.0 异步 / Spring 的 SseEmitter。请求进来后 worker 调用 request.startAsync() 开启异步上下文,然后马上还回线程池;SSE 连接挂在那里由 Poller 看着。之后有数据要推(比如 MQ 消费者线程、定时任务线程),由业务线程调 emitter.send() 写出去,写完线程继续干别的。Spring MVC 里用 SseEmitter 或 DeferredResult,几千路 SSE 够扛。
解法二:Spring WebFlux + Netty。上万连接、推送频繁的场景直接上 WebFlux,底层 Netty 事件循环,少量线程管理大量连接,SSE 在响应式模型里就是返回一个 Flux<ServerSentEvent>。

另外还有一些常规手段:SSE 走独立的 Tomcat 实例或端口,和普通 API 隔离线程池;加限流;水平扩容。这些能缓解,但根本问题是长连接场景不该用同步模型。
阻塞和非阻塞的区别
- 阻塞 I/O:线程调用
read(),没有数据就一直挂起等待,期间这个线程不能干别的。一个线程同时只能处理一个连接,并发连接数受线程数限制,而线程本身也贵(每个线程 1MB 左右的栈内存)。 - 非阻塞 I/O:
read()没有数据立刻返回,配合 Selector(底层是 epoll 这类系统调用),一个线程可以监听成千上万个连接,哪个连接有事件就处理哪个。
框架按这两个模型分一下:
- 同步阻塞模型:传统 Spring MVC(跑在 Tomcat / Jetty / Undertow 上)。注意说的是编程模型同步,连接器底层已经是 NIO 了。
- 非阻塞/响应式模型:Netty(底层网络框架,Vert.x、WebFlux、大部分 RPC 框架都基于它)、Spring WebFlux(默认用 Netty)、Vert.x、Undertow(NIO 内核)。
回答这道题的顺序建议:先给结论(同步写法下一个连接占一个线程,但有异步方案),再讲 Tomcat 的线程角色,最后给 SseEmitter 和 WebFlux 两个方案。
4、手写代码:单向链表判环
LeetCode 141 原题,Floyd 判圈算法,也就是快慢指针。
https://leetcode.cn/problems/linked-list-cycle/description/
遍历时用哈希表记录访问过的节点,重复出现就有环,这个写法需要 O(n) 额外空间,一般不作为答案。标准解法是快慢指针:
- 慢指针 slow 每次走 1 步,快指针 fast 每次走 2 步;
- 如果链表没环,fast 会先走到 null;
- 如果有环,两个指针都会进环。进环之后 fast 相对 slow 每轮多走 1 步,两者的距离每轮缩短 1,所以至多绕环一圈,fast 一定追上 slow。速度差正好是 1,不存在跳过去的情况。

代码
public boolean hasCycle(ListNode head) {
ListNode slow = head, fast = head;
while (fast != null && fast.next != null) {
slow = slow.next; // 慢指针走 1 步
fast = fast.next.next; // 快指针走 2 步
if (slow == fast) { // 引用相等,说明在环上相遇
return true;
}
}
return false; // fast 走到头,无环
}
5、Sring Bean 的生命周期
这题也是非常经典的八股文,之前都是背一堆名词,等真正面试的时候总是说不顺,实际给面试官的影响比较差。
建议记流程主干(流程图),再单独准备两个高频追问——每个追问都配了时序图,追问细节时能答出"谁在调用谁"是加分项。

按图里的编号过一遍:
- 实例化
createBeanInstance:调用构造方法把对象 new 出来。这时它只是个普通 Java 对象,属性全是 null,还不算 Bean。 - 属性填充
populateBean:依赖注入在这一步,@Autowired的依赖在这里被设置进来。 - Aware 接口回调:如果 Bean 实现了
BeanNameAware、ApplicationContextAware这类接口,这一步回调它们,让 Bean 拿到自己的名字、拿到容器本身。 - BeanPostProcessor 前置处理
postProcessBeforeInitialization:所有注册的 BeanPostProcessor 挨个执行。 - 初始化:三个口子按固定顺序执行——
@PostConstruct注解的方法 →InitializingBean.afterPropertiesSet()→ 自定义init-method。这个顺序面试会问。 - BeanPostProcessor 后置处理
postProcessAfterInitialization:AOP 代理对象在这一步生成(由AbstractAutoProxyCreator完成)。从容器里拿到的代理 Bean,是在这一步被换成代理对象的。 - 就绪:放入单例池,正式对外服务。
- 销毁:容器关闭时执行,
@PreDestroy→DisposableBean.destroy()→destroy-method,顺序和初始化那一步对称。
主干顺序记这一句:实例化 → 填充 → Aware → 前置 → 初始化 → 后置 → 就绪 → 销毁。
Bean 创建的调用时序

- ①②③ 容器只负责发起:
getBean()先查一级缓存(单例池),命中直接返回,没命中才走完整流程。 - ④⑤⑥ 这三步都是 BeanFactory 直接调 Bean:实例化 → 属性填充 → Aware 回调。
- ⑦⑧⑨ 初始化前后各夹一次 BeanPostProcessor:前置处理 → 三个初始化口子 → 后置处理。
- ⑩⑪ AOP 代理就是在后置处理里生成的(
AbstractAutoProxyCreator),所以工厂返回的是代理对象。 - ⑫ 放进单例池的也是代理对象,之后所有
getBean()拿到的都是它。 - ⑬⑭ 容器关闭时反向销毁,顺序和初始化对称。
追问一:三级缓存怎么解开循环依赖
短答:在 ① 和 ② 之间。实例化完成后,Spring 会把一个能产出该 Bean 的 ObjectFactory 放进三级缓存提前曝光。长答看这张图:

A 和 B 互相依赖(setter 注入)的完整过程:
- 工厂创建 A 的实例(
new A()),立刻把 A 的ObjectFactory放进三级缓存——这就是"提前曝光"。 - 给 A 填充属性时发现依赖 B,转去创建 B。
- B 的实例创建完,也把
ObjectFactory放进三级缓存;B 填充属性时发现依赖 A。 - 转头再
getBean(A),这次三级缓存命中,通过ObjectFactory.getObject()拿到 A 的早期引用(同时升级到二级缓存),注入给 B。 - B 初始化完成进一级缓存;回到 A,A 拿到已经就绪的 B,也完成初始化进一级缓存。
两个追问点:
- 为什么必须是三级,两级不够吗? 三级缓存里存的是
ObjectFactory(一个 lambda),只有真的发生循环依赖时才会调用它生成早期引用——这一步才决定要不要提前创建 AOP 代理。如果 A 需要代理,必须保证注入给 B 的是代理对象而不是裸对象,ObjectFactory就是用来延迟这个决定的。 - 什么情况解不开? 构造器注入的循环依赖。实例化都还没做完,
ObjectFactory根本没进缓存,只能靠@Lazy或者改成 setter 注入。
追问二:为什么 this 自调用不走 AOP
根因一句话:代理对象在 BeanPostProcessor 后置处理阶段才生成,而 Bean 内部的 this 永远指向裸对象。

- ①②③ 外部注入的是代理对象,所以外层调用走代理,事务拦截器正常介入、正常开启事务。
- ④ 但方法内部
this.deductStock()是 Java 层面的方法调用,直接落在裸对象上,不会经过代理,也就没有拦截器——deductStock()上的@Transactional彻底失效。 - 失效的精确含义值得说清:它既开不了新事务,也声明不了
REQUIRES_NEW这类传播行为;如果外层本来就没有事务(同类方法互相调用),这段逻辑就是裸奔,异常不回滚。这是最容易在面试里答得含糊的地方。
三种解决办法,按工程上的推荐顺序:
- 把
deductStock()拆到另一个 Bean 里——跨 Bean 调用天然走代理,最干净。 - 注入自身代理(
@Lazy注入自己,或者从容器里getBean)。 AopContext.currentProxy()(需要开exposeProxy = true)。
6、Redisson 分布式锁
用过分布式锁吗?简单说一下创建分布式锁的几行代码、分布式锁主要关注哪些点?
加锁的几行代码
面试官问的是"创建分布式锁的几行代码怎么写",这个要能直接写出来:
RLock lock = redissonClient.getLock("order:pay:" + orderId);
try {
// 最多等 3 秒拿锁;不显式传 leaseTime,过期时间交给看门狗管
if (lock.tryLock(3, TimeUnit.SECONDS)) {
// 拿到锁,执行核心业务
} else {
// 没抢到,快速失败
}
} finally {
// 先确认锁是当前线程持有的,再释放
if (lock.isHeldByCurrentThread()) {
lock.unlock();
}
}
看门狗机制
原生 SET key value NX EX 30 的分布式锁有一个矛盾:过期时间设短了,业务没跑完锁就没了;设长了,客户端宕机后别人要等很久才能拿到锁。Redisson 的处理是不让你手动定这个时间:不显式指定 leaseTime 时,锁默认 30 秒过期,同时后台启动一个看门狗线程,每 10 秒(leaseTime / 3)给锁续期一次。进程还活着,锁就不会到期;进程宕机,看门狗跟着消失,锁最多 30 秒自动释放,不会死锁。

所以一条原则:不要显式传 leaseTime,传了看门狗就失效,等于退回原生 SET NX。
注意点
- 锁 key 的粒度。锁
order:pay:123只挡一个订单,锁order:pay就把所有订单串行化了。粒度越细并发越高,但 key 要能准确圈住要保护的资源。 - 显式 leaseTime 的连锁问题。锁提前过期 → 别的线程拿到锁 → 原来的线程执行完在 finally 里 unlock,如果没有
isHeldByCurrentThread()判断,就把别人的锁删了,结果是两个线程同时持有锁执行临界区。超卖、双写都是这么来的。 - 可重入。Redisson 锁底层是 Redis Hash:key 是锁名,field 是线程标识,value 是重入次数。同一线程重复加锁只是计数 +1,事务方法里嵌套加同一把锁不会死锁。
- 主从切换丢锁。锁写入 master 后还没同步到 slave,master 宕机、slave 升主,新 master 上没有这把锁,另一个客户端又能加锁。要更强的一致性可以用 Redisson 的 MultiLock(往多个独立实例同时加锁,多数成功才算成功)。工程上更常见的做法是业务侧兜底:数据库乐观锁、唯一约束、幂等设计。分布式锁只做第一层拦截,正确性不要全押在锁上。
- 加锁失败的策略。快速失败还是自旋重试看业务:秒杀类快速失败,任务类可以重试。
- 极端场景。长时间 Full GC 会让看门狗续不上期,锁照样过期。这也是业务兜底不能省的原因。
7、Spring IoC 和 AOP
Spring 的主要优势是什么?简单说说。
这一题也是经典得不能再经典的八股文。题目本身不是重点,重点是后面的追问,能答上来加分很多。
Spring 的主要优势:IoC 容器和 AOP 是两大基石,往上有:声明式事务(一个 @Transactional 替代手写的 try-catch 事务模板)、统一的异常体系(各家数据访问的 checked exception 被翻译成统一的运行时异常树)、消灭样板代码的模板类(JdbcTemplate、RestTemplate),以及完整的生态:Spring MVC、Spring Data、Spring Security、Spring Cloud。
IoC 是什么
IoC(Inversion of Control,控制反转):把对象的创建和依赖管理,从代码里反转给容器来做。
没有 IoC 时,A 依赖 B,就在 A 里面 new B(),依赖关系硬编码在代码里,换实现、换 mock 对象做测试都要改源码。有了 IoC,A 只声明需要一个 B 类型的东西,容器启动时创建好注入进来,A 不管 B 从哪来。
DI(依赖注入)是 IoC 的实现手段,面试时两个词一起说。底层是工厂 + 反射,对应第五题生命周期里的实例化和属性填充两步。好处是解耦、好测试、配置集中管理。
AOP 是什么
AOP(Aspect-Oriented Programming,面向切面编程):把遍布在各个业务方法里的通用逻辑抽出来统一织入,业务代码只关心业务。
日志、事务、权限校验这类逻辑,每个方法都写一遍就是大量重复。AOP 的做法是定义切面:切点表达式圈定哪些方法,通知定义做什么,运行时自动织入。
Spring AOP 底层是动态代理:目标类实现了接口用 JDK 动态代理(基于接口生成代理类),没实现接口用 CGLIB(基于继承生成子类),Spring Boot 2.x 之后默认统一走 CGLIB。既然是代理,就有一个对应的坑:Bean 内部 this 自调用不走代理(原因见第五题追问二),需要自调用生效的话,注入自己的代理或者拆方法。
追问:AOP 的底层有了解吗?代理失效的场景,结合业务谈一谈。
底层分三层:
- 代理对象是怎么来的。
AbstractAutoProxyCreator本身就是一个 BeanPostProcessor,它在postProcessAfterInitialization里判断当前 Bean 有没有匹配的 Advisor,有就创建代理对象并替换掉原对象(第五题时序图的 ⑨⑩ 那一步)。注意是替换、不是包一层——容器里放的是代理,而this仍然指向裸对象,后面所有的坑都源于这一点。 - 两种代理方式:
- JDK 动态代理:要求目标类实现接口,
Proxy.newProxyInstance在运行时生成一个实现同样接口的类,方法调用转发到InvocationHandler。 - CGLIB:用 ASM 生成目标类的子类,覆写非 final 方法,调用被
MethodInterceptor拦下。不需要接口,但代理不了 final 类和 final 方法。 - Spring Boot 2.x 起
spring.aop.proxy-target-class=true是默认值,统一走 CGLIB,目的是避免"按实现类类型注入失败"这类问题。
- JDK 动态代理:要求目标类实现接口,
- 调用链:
调用方 → 代理对象 → Advisor 链(Pointcut + Advice)→ 目标方法。事务就是这条链上的一个 Advisor:BeanFactoryTransactionAttributeSourceAdvisor+TransactionInterceptor。 - 可以主动抛的加分点:Spring AOP 是运行时织入(动态代理),AspectJ 是编译期 / 类加载期织入(静态)。静态织入能拦 final 方法、字段访问、构造器,性能也更好,代价是要引入编译插件或者 LTW agent。问"底层"能说出这个对比,基本就到位了。
代理失效的场景
| 场景 | 为什么失效 | 业务里的样子 |
|---|---|---|
this 自调用 |
this 是裸对象,Java 方法调用不经过代理 |
createOrder() 里调 this.deductStock(),上面的 @Transactional / @Cacheable / @Async 全部失效 |
| 方法不是 public | 代理只拦 public 方法 | 抽出来的 private 辅助方法上贴注解,纯装饰 |
| final 方法 / final 类 | CGLIB 靠继承,覆写不了 | 工具类里 final 修饰的方法上贴注解 |
| static 方法 | 不属于任何实例,代理拦不到 | static void log() 上贴自定义注解 |
| 对象不是 Bean | 没经过 BeanPostProcessor,就没有代理 | 自己 new OrderService()、在 Filter 里 new 出来的 Service |
| 注解只写在接口上 | 走 CGLIB 时按实现类解析切点,可能读不到 | 接口方法上写 @Transactional,实现类上什么都不写 |
结合业务:
- 库存扣减 + 独立事务。
createOrder()里自调用deductStock(),而deductStock()标了REQUIRES_NEW,本意是"主订单回滚了,扣减要独立提交"。实际结果是独立事务压根没开,扣减跟着主事务一起回滚——线上表现是"订单没生成,库存却被扣了"(或者反过来),非常难查。 - 缓存注解。
getUser()自调用this.loadFromDb(),loadFromDb()上的@Cacheable失效,缓存形同虚设,DB 被打爆。 - 自研的幂等 / 限流注解。这类注解大多也是 AOP 实现的,自调用失效 → 限流形同虚设,接口被刷穿。
怎么修,怎么排查:
- 拆成两个 Bean,跨 Bean 调用天然走代理(工程上首选)。
- 注入自身代理:
@Lazy注入自己,或者ApplicationContext.getBean。 AopContext.currentProxy()(需要开exposeProxy = true)。- 排查手段:
AopUtils.isAopProxy(bean),或者打印类名看有没有EnhancerBySpringCGLIB后缀;代理没生成时启动日志通常会有 “is not eligible for getting processed by all BeanPostProcessors” 这类告警。
追问:Spring 事务失效的场景
这一题自认为比上一题还要重点:

第一层:代理没生效(和上一题同源)
this自调用、方法非 public、final / static 方法、对象不是容器管理的 Bean——这四条前面说过,不重复。- 多数据源特有的一条:
@Transactional没指定transactionManager,Spring 用了默认的事务管理器,事务开在了另一个库上。业务代码不报错,数据却没跟着回滚,排查时最容易漏。
第二层:异常没触发回滚
- 默认只回滚
RuntimeException和Error,受检异常不回滚。要么写rollbackFor = Exception.class,要么手动TransactionAspectSupport.currentTransactionStatus().setRollbackOnly()。 - 异常被自己 catch 吞掉:
try { ... } catch (Exception e) { log.error("扣减失败", e); }之后正常返回,拦截器看不到异常,事务照常提交。这是线上最隐蔽的一种。 - catch 之后没重新抛出,还
return了一个正常结果,调用方以为成功。
第三层:传播行为和事务边界搞错
REQUIRES_NEW自调用失效 → 退化成同一个事务(和第一层叠加)。- 内层
REQUIRES_NEW要申请新连接,而外层事务的连接还没释放;并发一高连接池被打满,表现为大面积接口超时甚至互相等待。这个问题压测很容易暴露。 - 内层回滚后外层捕获异常继续提交 → 抛
UnexpectedRollbackException(“Transaction rolled back because it has been marked as rollback-only”)。 NESTED依赖保存点,只对DataSourceTransactionManager有效;换成 JTA 就退化成REQUIRED。- 长事务:事务方法里做 RPC、发 MQ、发短信,连接和锁长时间不释放。
第四层:环境 / 存储不支持
- 表引擎是 MyISAM:压根不支持事务,回滚语句执行了也不报错,肉眼看不出来。
- 事务里执行 DDL,MySQL 隐式提交,事务边界被切断。
@Async和@Transactional混用:事务上下文存在 ThreadLocal 里,不跨线程,两个切面的先后顺序还会影响结果。@EnableTransactionManagement没生效(比如自定义了@EnableXXX注解,却忘了在它上面 meta-annotate 上这一层)。
排查四步
- 确认代理:
AopUtils.isAopProxy(bean)。 - 确认异常路径:日志里这个方法到底有没有抛出异常(重点看有没有 catch 吞异常)。
- 确认边界:
logging.level.org.springframework.transaction=DEBUG,看日志是Creating new transaction还是Participating in existing transaction,回滚时有没有Initiating transaction rollback。 - 确认环境:数据源指向对不对、表引擎是不是 InnoDB。
收尾可以背一句:“事务失效我按四层排查——代理有没有生效、异常有没有触发回滚、传播行为和边界对不对、数据源和存储引擎支不支持。”
本站文章遵循 Apache 2.0 开源协议,转载请注明出处,商业化出版请联系本站管理员(页面底部)
鄂公网安备42098402000265号