← 返回

最近遇到的大厂后端面试题

复制为 Markdown

金九银十,最近出去面试了几家大厂,感觉现在的强度越来越高了。整理了几道面试题,复盘一下。

题目清单:

  1. 系统设计:短链接服务
  2. 手写代码:n 个人在地图上的相遇点
  3. 理论:SSE 会话会不会把 Tomcat 线程占满
  4. 手写代码:单向链表判环(LeetCode 141 原题)
  5. 理论:Spring Bean 的生命周期
  6. 理论:Redisson 分布式锁怎么写、有什么坑
  7. 理论: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 亿个组合,比十年存量高一个数量级,够用。

综合来看,系统应该有两个地方是瓶颈:

  1. 因为短链需要长期有效,也就是短链的字符串要是唯一的,不能有重复,且每天还要生成 100 万条。那第一个瓶颈就在这里,短链生成怎么确保既高效又唯一?
  2. 读多写少的系统,读的流量是重点,尤其是亿级流量的读。这是明显的第二个瓶颈,需要上多级缓存。

短链生成发号器

短链生成一般有两个方案:

  • 对长链接做哈希(比如 MurmurHash,取前 6~7 位)。好处是不用额外的发号组件;问题是哈希会碰撞,碰撞后要加盐重新哈希,库里数据越多碰撞越频繁。长期有效的场景下不推荐。
  • 方案二:发号器发自增 ID,再 Base62 编码。比如发到 123456789,Base62 编码后就是 8M0kX。天然无碰撞,我选这个。

发号器用数据库号段模式就够:发号服务启动时去 DB 执行一次 UPDATE id_generator SET max_id = max_id + 1000,把 1000 个号领到内存里,发完再取。DB 每秒最多被打一次,可以忽略;服务重启最多浪费一个号段,无所谓。

image.png

需要注意的几点:

  • 取号和发号是彻底分离的两件事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⁶ 互质的大数。这个映射是双射,既保证无碰撞,短码看起来又是随机的。

最后落地的写链路可以是这样:

image.png

按照编号过一遍:

  1. ① 收请求。客户端 POST 长链接,服务只做参数校验。这里有一个刻意的省略:不检查这个长链接是不是已经有短链了。题目说短链之间没有映射关系,同一个长链接重复提交生成两个短码是可以接受的,省掉一次查询,也省掉"长链接字段要不要建唯一索引"的纠结(几 KB 的长链接上建索引代价不小)。
  2. ②③ 取号。走本地内存号段,绝大多数请求根本不碰 DB,取号这一段见上面的发号器时序图。
  3. ④ 编码。Base62 编码再加防遍历混淆,得到 6 位短码。这一步是纯计算,没有 IO。
  4. ⑤⑥ 落库short_code 上是唯一索引,即使发号器出了岔子发重了,这一步也会兜住——插入失败就重新取号重试。唯一索引是这套方案的最后一道防线,一定要提。
  5. ⑦⑧ 回填缓存。刚生成的短链很可能马上被访问(分享出去的第一波流量),提前回填能挡掉一次缓存未命中。
  6. ⑨ 返回短链。

注意写缓存和写数据库的顺序不能反:反过来的话,缓存写成功、落库失败,就会留下一份指向不存在记录的缓存,用户点进去回源查不到,只能等 TTL 过期才自愈。写侧 QPS 只有十几,多一次 DB 往返换一个简单可靠的顺序,值得。

状态码选 301 还是 302

跳转发什么状态码基本是必问的点:

  • 301 永久重定向:浏览器会永久缓存这个映射,下次访问短链浏览器自己直接跳,请求不到我们服务器。访问量统计会丢,以后想让短链失效也做不到。
  • 302 临时重定向:浏览器不缓存,每次访问都经过服务端,统计、风控、下线都可以做。

短链服务需要统计点击量,所以选 302。

读链路的多级缓存

每天 1 亿次的读取量,对于任何系统都是一个难点。也是面试时考的比较多的一个点。

image.png

整体过一遍:

  1. ① 收请求,拿到短码。
  2. ②③ 过布隆过滤器。布隆过滤器放在服务本地内存里,O(1) 且不产生网络调用。判定"一定不存在"的直接 404,爬虫和瞎编的短码在这里就被挡掉了,连 Redis 都不用碰。
  3. ④⑤ 查 Redis。命中就直接返回,这是热路径,绝大部分请求在这里结束。Redis 容量按峰值 QPS 的 2~3 倍留余量即可,命中率一般能在 99% 以上。
  4. ⑦⑧ 回源 MySQL。只有缓存过期、被淘汰、或者冷门短链第一次被访问才会走到这里。按短码哈希分片,查询能直接路由到单个分片。
  5. ⑨⑩ 回填缓存。回填一定要带 TTL(比如 1 天)。短链访问是典型的长尾分布,全量永久驻留内存太贵,冷链接没必要一直占着。
  6. ⑥⑪ 返回 302。两条分支最后都收敛到同一个响应:302 Location: <long_url>

需要注意的几点:

  1. 缓存防穿透。总会有不存在的短码请求打过来(爬虫或者攻击者瞎编的),每次都打穿缓存到 DB。在服务入口加一个布隆过滤器,所有真实存在的短码登记在册,不存在的短码在内存里就被挡掉。配套要注意三点:
    • 被误判放行的请求仍会穿透,DB 查不到时返回 404 并缓存空值(短 TTL)。
    • 新短码要同步进过滤器。单机内存版在多实例下每台都要有一份;常见做法是把位图放 Redis 由各实例定时拉取重建,或者用 MQ 广播增量。位图重建期间要么短暂降级为直查缓存,要么双 buffer 切换。
    • 规模要算一下。十年 36.5 亿条短码,按 1% 误判率大约 10 bit/条,位图接近 4.5GB——分摊到每台机器上是可控的,但如果内存紧张,可以只把活跃短码放进过滤器,冷链接直接落库查。
  2. 分库分表的键。这个系统的查询都按短码来,分片键就是短码本身,按短码哈希取模分片,查询都能路由到单个分片,不需要跨片查询。
  3. 点击量统计不要同步做。302 返回之后异步打点(写 MQ 再批量落库),统计链路再慢也不能拖慢跳转。这也是选 302 而不是 301 的直接原因:301 之后浏览器不再请求服务端,统计从根上就做不了。

追问布隆过滤器和缓存空值:布隆过滤器说"存在"可能是误判,但布隆过滤器说"不存在"一定可信。所以被误判放行的请求还是会把 ④ 走一遍,最后 DB 查不到只能返回 404——这类请求建议顺手缓存一个空值(短 TTL),否则同一个不存在的短码会被反复打到 DB,等于布隆过滤器白加了。

整体架构

image.png

几个常见的追问:

  • 缓存和 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,写到一半面试官直接打断说思路不对。后面考虑了一会也没找到解法,遗憾结束。

思路应该是要用区间交集,画个图就什么都明白了:

image.png

分几种情况:

  • 方向全开(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 ≤ xHighyLow ≤ yHigh,交集就非空,取 (xLow, yLow) 即可。时间 O(n),空间 O(1)。

代码:

代码

java
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、曼哈顿距离:

2、「方向 / 象限约束」相关

3、理论:SSE 会话会不会把 Tomcat 线程占满

现在智能体的流式输出普遍采用 SSE 协议,这是一个服务端主动向客户端推送的长链接协议。那针对 Tomcat 来讲,默认的线程是有限的,比如有很多个用户同时在和智能体会话,这些用户的 SSE 会话线程会不会把 Tomcat 的线程池占满呢?怎么避免?

考察线程模型。

如果是用传统的同步 Servlet 写法,handler 里循环往响应推数据,那确实是一条 SSE 长连接占一个 worker 线程,直到连接断开才释放。Tomcat 的 worker 线程池 maxThreads 默认 200,两百多个并发 SSE 连接就能打满。打满之后不只是 SSE 接口,普通请求也拿不到线程,只能排队或被拒。

两种线程模型的对比

image.png

先说一个前提: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 里用 SseEmitterDeferredResult,几千路 SSE 够扛。

解法二:Spring WebFlux + Netty。上万连接、推送频繁的场景直接上 WebFlux,底层 Netty 事件循环,少量线程管理大量连接,SSE 在响应式模型里就是返回一个 Flux<ServerSentEvent>

image.png

另外还有一些常规手段:SSE 走独立的 Tomcat 实例或端口,和普通 API 隔离线程池;加限流;水平扩容。这些能缓解,但根本问题是长连接场景不该用同步模型。

阻塞和非阻塞的区别

  • 阻塞 I/O:线程调用 read(),没有数据就一直挂起等待,期间这个线程不能干别的。一个线程同时只能处理一个连接,并发连接数受线程数限制,而线程本身也贵(每个线程 1MB 左右的栈内存)。
  • 非阻塞 I/Oread() 没有数据立刻返回,配合 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,不存在跳过去的情况。

image.png

代码

java
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 的生命周期

这题也是非常经典的八股文,之前都是背一堆名词,等真正面试的时候总是说不顺,实际给面试官的影响比较差。

建议记流程主干(流程图),再单独准备两个高频追问——每个追问都配了时序图,追问细节时能答出"谁在调用谁"是加分项。

image.png

按图里的编号过一遍:

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

主干顺序记这一句:实例化 → 填充 → Aware → 前置 → 初始化 → 后置 → 就绪 → 销毁。

Bean 创建的调用时序

image.png

  • ①②③ 容器只负责发起:getBean() 先查一级缓存(单例池),命中直接返回,没命中才走完整流程。
  • ④⑤⑥ 这三步都是 BeanFactory 直接调 Bean:实例化 → 属性填充 → Aware 回调。
  • ⑦⑧⑨ 初始化前后各夹一次 BeanPostProcessor:前置处理 → 三个初始化口子 → 后置处理。
  • ⑩⑪ AOP 代理就是在后置处理里生成的AbstractAutoProxyCreator),所以工厂返回的是代理对象。
  • 放进单例池的也是代理对象,之后所有 getBean() 拿到的都是它。
  • ⑬⑭ 容器关闭时反向销毁,顺序和初始化对称。

追问一:三级缓存怎么解开循环依赖

短答:在 ① 和 ② 之间。实例化完成后,Spring 会把一个能产出该 Bean 的 ObjectFactory 放进三级缓存提前曝光。长答看这张图:

image.png

A 和 B 互相依赖(setter 注入)的完整过程:

  1. 工厂创建 A 的实例(new A()),立刻把 A 的 ObjectFactory 放进三级缓存——这就是"提前曝光"。
  2. 给 A 填充属性时发现依赖 B,转去创建 B。
  3. B 的实例创建完,也把 ObjectFactory 放进三级缓存;B 填充属性时发现依赖 A。
  4. 转头再 getBean(A),这次三级缓存命中,通过 ObjectFactory.getObject() 拿到 A 的早期引用(同时升级到二级缓存),注入给 B。
  5. B 初始化完成进一级缓存;回到 A,A 拿到已经就绪的 B,也完成初始化进一级缓存。

两个追问点:

  • 为什么必须是三级,两级不够吗? 三级缓存里存的是 ObjectFactory(一个 lambda),只有真的发生循环依赖时才会调用它生成早期引用——这一步才决定要不要提前创建 AOP 代理。如果 A 需要代理,必须保证注入给 B 的是代理对象而不是裸对象,ObjectFactory 就是用来延迟这个决定的。
  • 什么情况解不开? 构造器注入的循环依赖。实例化都还没做完,ObjectFactory 根本没进缓存,只能靠 @Lazy 或者改成 setter 注入。

追问二:为什么 this 自调用不走 AOP

根因一句话:代理对象在 BeanPostProcessor 后置处理阶段才生成,而 Bean 内部的 this 永远指向裸对象。

image.png

  • ①②③ 外部注入的是代理对象,所以外层调用走代理,事务拦截器正常介入、正常开启事务。
  • 但方法内部 this.deductStock() 是 Java 层面的方法调用,直接落在裸对象上,不会经过代理,也就没有拦截器——deductStock() 上的 @Transactional 彻底失效。
  • 失效的精确含义值得说清:它既开不了新事务,也声明不了 REQUIRES_NEW 这类传播行为;如果外层本来就没有事务(同类方法互相调用),这段逻辑就是裸奔,异常不回滚。这是最容易在面试里答得含糊的地方。

三种解决办法,按工程上的推荐顺序:

  1. deductStock() 拆到另一个 Bean 里——跨 Bean 调用天然走代理,最干净。
  2. 注入自身代理(@Lazy 注入自己,或者从容器里 getBean)。
  3. AopContext.currentProxy()(需要开 exposeProxy = true)。

6、Redisson 分布式锁

用过分布式锁吗?简单说一下创建分布式锁的几行代码、分布式锁主要关注哪些点?

加锁的几行代码

面试官问的是"创建分布式锁的几行代码怎么写",这个要能直接写出来:

java
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 秒自动释放,不会死锁。

image.png

所以一条原则:不要显式传 leaseTime,传了看门狗就失效,等于退回原生 SET NX。

注意点

  1. 锁 key 的粒度。锁 order:pay:123 只挡一个订单,锁 order:pay 就把所有订单串行化了。粒度越细并发越高,但 key 要能准确圈住要保护的资源。
  2. 显式 leaseTime 的连锁问题。锁提前过期 → 别的线程拿到锁 → 原来的线程执行完在 finally 里 unlock,如果没有 isHeldByCurrentThread() 判断,就把别人的锁删了,结果是两个线程同时持有锁执行临界区。超卖、双写都是这么来的。
  3. 可重入。Redisson 锁底层是 Redis Hash:key 是锁名,field 是线程标识,value 是重入次数。同一线程重复加锁只是计数 +1,事务方法里嵌套加同一把锁不会死锁。
  4. 主从切换丢锁。锁写入 master 后还没同步到 slave,master 宕机、slave 升主,新 master 上没有这把锁,另一个客户端又能加锁。要更强的一致性可以用 Redisson 的 MultiLock(往多个独立实例同时加锁,多数成功才算成功)。工程上更常见的做法是业务侧兜底:数据库乐观锁、唯一约束、幂等设计。分布式锁只做第一层拦截,正确性不要全押在锁上。
  5. 加锁失败的策略。快速失败还是自旋重试看业务:秒杀类快速失败,任务类可以重试。
  6. 极端场景。长时间 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 的底层有了解吗?代理失效的场景,结合业务谈一谈。

底层分三层:

  1. 代理对象是怎么来的AbstractAutoProxyCreator 本身就是一个 BeanPostProcessor,它在 postProcessAfterInitialization 里判断当前 Bean 有没有匹配的 Advisor,有就创建代理对象并替换掉原对象(第五题时序图的 ⑨⑩ 那一步)。注意是替换、不是包一层——容器里放的是代理,而 this 仍然指向裸对象,后面所有的坑都源于这一点。
  2. 两种代理方式
    • JDK 动态代理:要求目标类实现接口,Proxy.newProxyInstance 在运行时生成一个实现同样接口的类,方法调用转发到 InvocationHandler
    • CGLIB:用 ASM 生成目标类的子类,覆写非 final 方法,调用被 MethodInterceptor 拦下。不需要接口,但代理不了 final 类和 final 方法
    • Spring Boot 2.x 起 spring.aop.proxy-target-class=true 是默认值,统一走 CGLIB,目的是避免"按实现类类型注入失败"这类问题。
  3. 调用链调用方 → 代理对象 → Advisor 链(Pointcut + Advice)→ 目标方法。事务就是这条链上的一个 Advisor:BeanFactoryTransactionAttributeSourceAdvisor + TransactionInterceptor
  4. 可以主动抛的加分点: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 实现的,自调用失效 → 限流形同虚设,接口被刷穿。

怎么修,怎么排查:

  1. 拆成两个 Bean,跨 Bean 调用天然走代理(工程上首选)。
  2. 注入自身代理:@Lazy 注入自己,或者 ApplicationContext.getBean
  3. AopContext.currentProxy()(需要开 exposeProxy = true)。
  4. 排查手段:AopUtils.isAopProxy(bean),或者打印类名看有没有 EnhancerBySpringCGLIB 后缀;代理没生成时启动日志通常会有 “is not eligible for getting processed by all BeanPostProcessors” 这类告警。

追问:Spring 事务失效的场景

这一题自认为比上一题还要重点:

image.png

第一层:代理没生效(和上一题同源)

  • this 自调用、方法非 public、final / static 方法、对象不是容器管理的 Bean——这四条前面说过,不重复。
  • 多数据源特有的一条:@Transactional 没指定 transactionManager,Spring 用了默认的事务管理器,事务开在了另一个库上。业务代码不报错,数据却没跟着回滚,排查时最容易漏。

第二层:异常没触发回滚

  • 默认只回滚 RuntimeExceptionError,受检异常不回滚。要么写 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 上这一层)。

排查四步

  1. 确认代理:AopUtils.isAopProxy(bean)
  2. 确认异常路径:日志里这个方法到底有没有抛出异常(重点看有没有 catch 吞异常)。
  3. 确认边界:logging.level.org.springframework.transaction=DEBUG,看日志是 Creating new transaction 还是 Participating in existing transaction,回滚时有没有 Initiating transaction rollback
  4. 确认环境:数据源指向对不对、表引擎是不是 InnoDB。

收尾可以背一句:“事务失效我按四层排查——代理有没有生效、异常有没有触发回滚、传播行为和边界对不对、数据源和存储引擎支不支持。”

最热文章