
这篇笔记我断断续续攒了大半年。起因很简单Spring Boot 用得挺溜注解一贴、配置一写项目就起来了但一旦碰到循环依赖报错、事务莫名失效、接口交给第三方被刷就只能去搜索引擎里捞答案捞完就忘忘了再捞。后来下了狠心把 Spring 的底层源码啃了一遍——从 Bean 生命周期、三级缓存到 AOP 代理、MVC 请求链路边读边手写了一个迷你 Spring 框架总算把之前那些“玄学问题”变成“可推理的问题”。这篇 Spring 笔记就是把这一路的思考、拆解和踩坑记录整理出来覆盖容器、AOP、Boot 整合、Security、MVC 手写源码和面试题适合正在用 Spring 但想搞懂原理的开发者也适合准备面试时想系统过一遍核心机制的读者。内容我会尽量用大白话讲有些地方会直接贴代码和配置方便你照着验证。1. 为什么要反复啃Spring源码几个让我开窍的问题先聊点虚的。很多人觉得 Spring 源码离日常开发太远框架已经封装好了会用就行。我不太同意这个说法。Spring 不像工具库它决定了你项目的整体架构风格Bean 怎么创建、请求怎么路由、事务怎么管理、安全怎么控制全是 Spring 在背后替你决策。如果你不理解这些决策的规则出了问题就只能靠猜。我真正开始啃源码是因为被几个问题卡住了这些场景你应该也遇到过应用启动时报BeanCurrentlyInCreationException说存在循环依赖但我明明看到别人用Lazy就解决了这俩差别到底在哪。Transactional加在 Service 的私有方法上运行时报错或者干脆没事务效果换个位置又好了。自定义了一个BeanPostProcessor结果它处理不到某些 Bean排查半天才发现是执行顺序的问题。用 Spring Cloud 做微服务Feign 调用偶尔失败看日志发现是序列化器没被 Spring 管理导致远程调用拿不到正确参数。这些问题如果不看源码你只能背上“Spring 就是这么设计的”“这是约定”之类的结论下次换个场景照样踩坑。但当你把AnnotationConfigApplicationContext的启动流程读一遍把这些现象背后的容器逻辑搞清楚就会发现它们其实是同一个问题的不同侧面——Bean 的生命周期管理。还有一个让我开窍的点读源码不是为了把每行代码都背下来而是要理解 Spring 的作者在面临一个需求时是怎么做取舍的。比如三级缓存的设计为什么不是二级为什么 Spring Boot 2.6 之后默认关掉循环依赖这些取舍背后都有明确的历史背景和设计意图。理解这些你写代码的时候就会下意识地避开框架不推荐的使用方式而不是等报错再去搜解决方案。简单梳理一下 Spring 家族的核心模块也方便后面展开模块核心职责对应这篇笔记的章节spring-core / beansIoC 容器、Bean 定义与生命周期第 2 章spring-aop面向切面、动态代理第 3 章spring-boot自动装配、起步依赖、Actuator 监控第 4 章spring-security认证、授权、过滤器链第 5 章spring-web / mvcDispatcherServlet、请求映射第 5 章spring-aiAI 应用脚手架、ChatClient、A2A第 7 章这个表格不是让你背模块清单而是帮你建立一张地图当你遇到一个问题时知道该去哪个模块里找答案。下面我们正式进入容器部分。2. Spring容器与Bean生命周期IoC不是配置管理2.1 从一行注解开始Bean的完整一生很多教程把 IoC 解释成“控制反转”说人话就是把对象的创建权从你手里交给容器。这个说法没错但容易让人误以为 Spring 只是帮你 new 对象。实际上容器做的工作远不止创建对象它要负责一个 Bean 从“定义”到“可用”再到“销毁”的全过程中间任何一环都可以被扩展和干预。一个 Bean 进入容器第一站是变成BeanDefinition。无论是 XML 里的bean、配置类里的Bean还是类上的Component最终都会被解析成一个BeanDefinition对象里面记录了类的全限定名、作用域、是否懒加载、初始化方法、属性值等元信息。这里有个细节Spring 扫描注解用的是ClassPathBeanDefinitionScanner它只认spring-framework包下的注解吗不是它认的是你指定的includeFilters和excludeFilters。Component、Service、Repository、Controller之所以能被识别是因为ComponentScan默认配了一个AnnotationTypeFilter而这些注解上又都标了Component。这也是为什么你自定义一个注解并加上Component就能被自动扫描到这是个非常实用的扩展点。接下来是合并BeanDefinition把父定义里的公共属性合并到子定义上然后进入实例化阶段。实例化不等于创建一个可用的 Bean它只是调用构造器得到一个原始对象。Spring 在实例化时会根据Autowired构造器、Value参数等信息推断用哪个构造方法如果只有一个构造方法直接用它如果有多个且没有标注Primary就会触发参数解析这个过程比想象中复杂也是面试喜欢问的点。实例化之后是属性填充也就是依赖注入。Autowired、Resource、Value都是在AutowiredAnnotationBeanPostProcessor或CommonAnnotationBeanPostProcessor里处理的它们属于InstantiationAwareBeanPostProcessor的子类。注意这里已经不是简单的“给字段赋值”Spring 会先解析出需要注入的InjectionMetadata再逐一处理。字段注入和 setter 注入在这个阶段完成而构造器注入早在实例化阶段就做了。完成依赖注入后Bean 会经历各种Aware回调比如BeanNameAware、BeanFactoryAware、ApplicationContextAware。然后才是BeanPostProcessor的postProcessBeforeInitialization。这里容易搞混顺序我踩过坑画一张顺序就清楚了实例化构造器属性填充依赖注入Aware 回调postProcessBeforeInitializationInitializingBean/PostConstruct/ init-methodpostProcessAfterInitialization放入单例池最后一步postProcessAfterInitialization里通常会做 AOP 代理所以最终放进单例池的可能是 Bean 的代理对象。这也是为什么在方法内部用this调用另一个Transactional方法时不生效——你拿到的this是原始对象不是代理对象。我之前调试一个内存泄漏问题就是因为自定义了一个BeanPostProcessor持有了 ApplicationContext导致上下文无法被回收。解决方案是在postProcessAfterInitialization里用完即释放引用。这种问题不读源码根本定位不到。2.2 三级缓存循环依赖到底怎么破循环依赖是 Spring 面试绕不开的题也被很多人当成“背诵题”。但只要你真正理解了三级缓存就会发现它其实是一个延迟决策的经典案例。先看现象A 依赖 BB 又依赖 A。在默认单例非懒加载的情况下创建 A 时发现需要 B就去创建 BB 创建时又发现需要 A此时 A 还没创建完就形成死循环。Spring 解决这个问题的思路很简单先把“半成品” A 暴露出来让 B 能拿到 A 的引用B 创建完成后A 再继续完成自己的初始化。那 Spring 是怎么保存“半成品”的用三个 Map也就是三级缓存一级缓存singletonObjects存放完全初始化好的成品 Bean。二级缓存earlySingletonObjects存放提前暴露的早期 Bean 引用可能是原始对象也可能是代理对象。三级缓存singletonFactories存放ObjectFactory它是一个工厂可以在必要时生成早期对象。创建 A 之后Spring 会把一个ObjectFactory放入三级缓存这个工厂的执行逻辑是如果 A 需要被 AOP 代理就返回代理对象否则返回原始对象。当 B 需要注入 A 时Spring 从三级缓存里拿到这个工厂调用它得到 A 的早期引用放入二级缓存然后把这个引用注入给 B。B 创建完后A 继续执行初始化最后 A 的完整对象被放入一级缓存同时把二三级缓存里 A 对应的条目清掉。那为什么是三级而不是二级核心在于 AOP 代理的生成时机。如果只有二级缓存意味着 B 拿到 A 的引用时必须立刻生成代理。但这时候 A 可能还不需要代理——只有当 A 被切面覆盖时才需要。Spring 想让代理的决策尽可能晚等到 A 真正完成初始化、明确知道自己是否需要增强之后再做决定。三级缓存里的ObjectFactory就是一个“延迟决策”的载体B 可以先拿到原始引用等 A 创建完如果确实需要代理再通过BeanPostProcessor生成代理对象并替换。这样既解决了循环引用又不浪费不必要的代理开销。不过注意一个大前提三级缓存解决的是“单例、非懒加载、基于 setter/字段注入”的循环依赖。如果是构造器注入A 在实例化阶段就需要 B此时 A 连半成品都还没暴露出来三级缓存也救不了。这也是常说“构造器注入可以暴露循环依赖设计问题”的原因。Spring Boot 2.6 之后默认禁止循环依赖启动时直接报错就是在用框架规则逼开发者重构掉这种坏味道。我个人的建议是不要为了能用三级缓存而故意写循环依赖能通过设计消除就消除比如把相互依赖的类拆开、使用Lazy或引入中间层。3. AOP与动态代理Spring的事务和拦截是怎么长出来的3.1 代理对象是怎么创建的Proxy Factory的两种姿势Spring AOP 的核心不是“切面表达式怎么写”而是“代理对象怎么来”。你去翻源码最终会发现所有 AOP 代理都绕不开ProxyFactory这个类它才是真正干活的角色。Aspect注解、EnableAspectJAutoProxy、事务的Transactional底层都是通过创建ProxyFactory来生成代理的只是入口和配置不同。ProxyFactory要做的事可以概括成三步收集 Advisor匹配 Pointcut然后创建代理。Advisor 是“通知 切点”的组合通知就是Before、After、Around这些方法体切点就是execution(...)表达式描述的位置。Spring 在创建 AOP 代理前会把容器里所有Advisor都拿出来逐个用切点去匹配目标类的方法能匹配上的方法才需要拦截匹配不上的直接放行。接下来是代理方式的选择。Spring 支持 JDK 动态代理和 CGLIB判断逻辑我直接给你对称着列出来维度JDK 动态代理CGLIB要求目标类必须实现接口不需要接口可以代理普通类生成方式运行时通过 Proxy.newProxyInstance运行时生成目标类的子类性能创建快每次调用走 InvocationHandler创建稍慢但调用性能好限制只能代理接口方法final 类和方法无法代理从 Spring Boot 2.x 开始默认用 CGLIB 生成代理甚至在接口存在时也优先 CGLIB这是为了统一行为避免接口代理和类代理混用时出现不一致。CGLIB 生成的代理类是目标类的子类所以目标类不能是 final被代理的方法也不能是 final 或 static否则代理不生效。还有一个我经常被问到的点JDK 动态代理和CGLIB哪个更好没有绝对答案。如果你的类都基于接口JDK 代理够用如果类层次复杂、不需要接口抽象CGLIB 更直接。实际项目里绝大多数情况你不需要关心这个因为 Spring Boot 已经帮你选好了。你需要关心的是为什么Configuration类里的Bean方法调用另一个Bean方法时返回的是同一个实例因为Configuration类本身被 CGLIB 代理了方法调用会先经过拦截器去单例池里找找不到才创建。这也是为什么Configuration和Component在代理行为上不同——Component类不会被 CGLIB 增强里面的Bean方法每次调用都会重新执行。3.2 事务注解失效一个让我排查三天的教训事务失效是最高频的 Spring 生产问题之一我印象最深的一次是帮同事排查一个订单服务Transactional加在 Service 类上方法里先更新数据库然后调远程接口远程接口超时事务却没有回滚。最终定位到根因是事务方法内部用this调了另一个事务方法触发了自调用问题。Transactional之所以能生效是因为 Spring 在 Bean 初始化阶段为它创建了一个事务代理。代理会拦截方法调用在方法执行前开启事务执行后提交或回滚。但如果你在类内部用this.xxx()调用同类的方法调用的是原始对象的方法而不是代理对象的方法事务拦截器根本没机会介入自然就没有事务效果。要解决这类失效有几个方向把需要事务的方法拆分到另一个 Bean 里通过注入的代理对象调用。在类内部注入ApplicationContext或使用AopContext.currentProxy()拿到当前代理再调用。用TransactionTemplate编程式事务彻底绕开代理问题。除了自调用事务失效的常见场景还包括方法不是 public 导致的代理不可见Spring 默认只代理 public 方法异常被 catch 吞掉导致事务感知不到异常异常类型不是 RuntimeException 且没有配置 rollbackFor类没有被 Spring 管理比如 new 出来的数据库引擎不支持事务比如 MySQL 的 MyISAM。这些我都整理成一张速查表放在第 7 章的面试题部分这里不展开。还有一个关于事务传播机制的点值得说。很多人背得出REQUIRED、REQUIRES_NEW、NESTED的语义但不知道它们在实际场景里的表现。REQUIRED是默认值如果当前有事务就直接加入没有就新建REQUIRES_NEW是挂起当前事务新建一个独立事务适合记录日志这种“无论如何都要写成功”的场景NESTED则是保存点事务内层回滚不影响外层适合批量处理中局部失败不整体回滚的需求。我实际用NESTED的场景是导入 Excel一行数据解析失败我希望跳过这行继续导入而不是整个文件都回滚。最后提一个排查事务问题的技巧开启 Spring 的日志输出把org.springframework.transaction的级别调到 DEBUG你会看到它输出的Getting transaction for、Completing transaction等日志这些日志能让你直观感受到事务拦截器到底有没有介入调用链。4. Spring Boot与MyBatis整合多商户跨境商城项目的落地经验4.1 从零搭建工程多数据源与分库分表看到热搜词里有“spring boot mybatis 的 java 开源多商户跨境商城源码下载”刚好这块我做过完整项目可以分享点实战经验。多商户商城和普通单商户系统最大的区别在于数据隔离和权限边界每个商户有自己独立的商品、订单、结算数据不同的商户可能有不同的佣金规则、结算周期、支付通道。如果你把所有商户的数据混在一张表里那就要靠tenant_id来做逻辑隔离如果商户量大、数据增长快就要考虑分库分表。Spring Boot MyBatis 整合本身并不复杂核心依赖就两个mybatis-spring-boot-starter和数据源驱动。配置上我建议先把mapper-locations、type-aliases-package、map-underscore-to-camel-case这几项设置好spring: datasource: url: jdbc:mysql://localhost:3306/mall?useUnicodetruecharacterEncodingutf8serverTimezoneAsia/Shanghai username: root password: 123456 driver-class-name: com.mysql.cj.jdbc.Driver mybatis: mapper-locations: classpath:mapper/**/*.xml type-aliases-package: com.example.mall.domain configuration: map-underscore-to-camel-case: true cache-enabled: false多数据源场景下我用的方案是AbstractRoutingDataSourceThreadLocal实现动态数据源切换。具体做法是自定义一个DynamicDataSource继承AbstractRoutingDataSource重写determineCurrentLookupKey方法从ThreadLocal里拿当前线程要用的数据源标识。再配合一个DataSource(slave)之类的注解用 AOP 切到读写分离写操作走主库读操作走从库。这里有个坑AbstractRoutingDataSource在初始化的时候只会连接默认数据源其他数据源是懒加载的所以你的连接池参数要对每个数据源分别设置否则容易出现连接超时。分库分表的话如果你的数据量还没到单表千万级别我不建议一上来就上 ShardingSphere那会让排查问题复杂度上升一个量级。可以先按商户 ID 做水平分表比如order_0、order_1在 MyBatis 拦截器里统一改写表名。拦截器需要实现Interceptor在Executor执行 SQL 前拿到BoundSql替换其中的表名占位符。这种方式轻量、可控适合中小商户量。等到单表确实撑不住再考虑引入 ShardingSphere它支持标准的分片策略配置也能和 Spring Boot 无缝整合。4.2 接口暴露给第三方单独服务还是放业务服务里热搜词里有个问题问得特别好“spring boot 对外提供的接口给第三方应该放在哪里是单独的服务还是放在对应的业务里”我直接给结论如果只是给内部几个合作方提供接口放在原有服务里加一套独立 Controller 就行如果你打算把接口开放成对外产品要对接大量第三方开发者那就必须拆成独立网关层或独立服务。放在原服务里的做法核心挑战是安全隔离。第三方接口和你内部接口不能混用同一套权限体系否则第三方能调用到内部接口就是事故。我的建议是单独建一个api包Controller 路径统一以/open/v1/开头。用独立的拦截器或 Spring Security 过滤链针对/open/**做单独的验签逻辑。接口签名用 AppId AppSecret请求参数按字典序拼接加时间戳防重放加 nonce 防重复请求。对开放接口做独立的限流策略避免某个第三方把整个服务拖垮。拆成独立服务的做法适合接口吞吐量大、需要独立扩容、业务方和开放接口方经常互相影响的场景。独立服务的好处是隔离性强、可以按需部署多实例、故障不影响核心交易链路坏处是数据访问变复杂需要走 RPC 或消息队列不能直接查库。我踩过的坑是签名的时效性。刚开始只做了参数签名没有做时间戳校验结果有人把请求报文原样重放造成重复下单。后来加上时间戳 5 分钟有效期的校验同时配合幂等键——第三方每次请求带一个requestId服务端用 Redis 存已处理的请求 ID重复请求直接返回第一次的结果。这两个措施组合起来基本能挡掉绝大多数重放攻击。说到监控Spring Boot 的 Actuator 是现成的利器。加了spring-boot-starter-actuator依赖后暴露/actuator/health、/actuator/metrics这些端点就能拿到 JVM 内存、线程池、HTTP 请求耗时等基础指标。生产环境我习惯配合 Micrometer 把指标推到 Prometheus再用 Grafana 画大盘。这样第三方接入方调用量异常上涨、RT 突然变高时你能第一眼看到。注意不要把/actuator全部暴露到公网只暴露health和prometheus其他端点走内网访问否则会有信息泄露风险。5. Spring Security与Spring MVC安全与请求链路5.1 Security的核心过滤器链与OAuth2Spring Security 刚上手的人容易懵因为它的概念太多过滤器链、认证管理器、UserDetailsService、SecurityContext、OAuth2、JWT……但其实它的核心模型非常简洁所有请求先经过一条过滤器链过滤器链里有一个或多个SecurityFilterChain每个链决定“哪些请求需要认证、走哪种认证方式”。FilterChainProxy是整个过滤链的入口它本身是一个Servlet Filter被注册到容器里。当请求进来时FilterChainProxy会遍历所有SecurityFilterChain用requestMatcher判断当前请求属于哪条链然后执行链上的过滤器。Spring Boot 自动配置里默认注册的那条链包含AuthorizationFilter、UsernamePasswordAuthenticationFilter、AnonymousAuthenticationFilter等十几个过滤器。如果你想加自定义逻辑比如验证码校验、租户上下文注入正确做法是实现一个OncePerRequestFilter然后通过SecurityFilterChain的配置把它插在指定位置Bean SecurityFilterChain filterChain(HttpSecurity http) throws Exception { http .csrf(csrf - csrf.disable()) .authorizeHttpRequests(auth - auth .requestMatchers(/public/**).permitAll() .requestMatchers(/admin/**).hasRole(ADMIN) .anyRequest().authenticated() ) .addFilterBefore(new TenantFilter(), UsernamePasswordAuthenticationFilter.class); return http.build(); }addFilterBefore和addFilterAfter是定位过滤器位置的常用手段但注意如果你插的自定义 Filter 没有设置Ordered或没有实现OncePerRequestFilter可能会被执行两次。这个我踩过坑过滤器里做了一个 Redis 缓存查询结果每次请求查了两次后来才发现是因为继承了GenericFilterBean而不是OncePerRequestFilter。再说 OAuth2 和 JWT。现在做前后端分离项目最常用的套路是登录成功后给用户签发 JWT后续请求在Authorization头带上Bearer token服务端用过滤器解析 token、校验签名和有效期然后把用户信息放进SecurityContext。Spring Security 5 之后内置了 OAuth2 资源服务器支持只需要写一个oauth2ResourceServer配置就能接入 JWT 校验不用自己写解析逻辑。我做多商户商城时认证这块用的是 OAuth2 密码模式搭配自定义UserDetailsService每个商户下的用户有不同的角色和权限。Security 的授权模型天然支持这种“租户 角色 权限”的结构只需要在hasAuthority的权限字符串里带上前缀比如tenant:1:order:view。这种方式比单纯用hasRole粒度更细也更好扩展。5.2 Spring MVC从URL到响应的完整旅程Spring MVC 面试题里最经典的一道是“一个 HTTP 请求从进入到返回经历了什么”这道题考察的其实就是 DispatcherServlet 的九大组件。前端控制器 DispatcherServlet 收到请求后流程是这样通过HandlerMapping找到处理请求的HandlerExecutionChain也就是 Controller 方法 拦截器链表。通过HandlerAdapter适配执行这个 Handler。为什么不直接调用因为 Handler 的类型不一定是方法可能是HttpRequestHandler或Servlet适配器统一了解析参数、执行、返回的接口。参数解析器HandlerMethodArgumentResolver把请求参数绑定到方法参数上比如PathVariable、RequestBody。执行 Handler拿到返回值。HandlerMethodReturnValueHandler处理返回值比如加了ResponseBody就写 JSON。如果抛异常交给HandlerExceptionResolver典型的是RestControllerAdvice就是在这个环节生效的。渲染视图或直接写出响应最终返回给客户端。理解了这个链路你就能解释很多现象。比如为什么RequestBody只能用在 POST 且 content-type 是 JSON 的请求上因为MappingJackson2HttpMessageConverter会在HttpMessageConverter接口的实现里读取请求体。为什么 Controller 方法可以写几乎任何类型的参数因为 Spring 内置了几十种HandlerMethodArgumentResolver每种解析器负责一类参数。有一次生产环境出现一个诡异问题Controller 方法接收RequestParam Integer page但前端传了字符串abc接口直接 500。排查半天发现是全局异常处理器里没有捕获MethodArgumentTypeMismatchException导致错误信息直接返回给了客户端既暴露了内部细节又没有友好的提示。后来我在RestControllerAdvice里统一处理了这几种异常这个问题才算彻底解决。你可以把MethodArgumentTypeMismatchException、MissingServletRequestParameterException、HttpMessageNotReadableException这几个加进全局异常处理这是提升接口健壮性的最低成本做法。另外Spring MVC 的跨域处理也在这条链路上。跨域配置本质就是给响应加Access-Control-Allow-*头Spring 提供了两种方式Controller 上加CrossOrigin或者全局配置CorsRegistry。但注意一旦接入了 Spring Security跨域配置必须和 Security 的cors()配合否则 Security 的过滤器会先拦截 OPTIONS 预检请求导致跨域配置不生效。6. 手写一个迷你Spring源码阅读与面试准备的最短路径6.1 最小实现清单与模块划分如果你觉得自己读源码读不进去我强烈建议你手写一个迷你版 Spring。不用写得多完整只要能管理 Bean、支持注解扫描、能注入依赖、能生成 AOP 代理就足够了。这个过程会逼你把“知道”变成“做到”效果比看十篇源码解析都强。我写的迷你 Spring 分了这几个模块注解包Component、Autowired、Qualifier、Configuration、Bean、Transactional、Aspect、Before、Around。容器包AnnotationConfigApplicationContext、BeanDefinition、BeanFactory、SingletonBeanRegistry。扫描器扫描指定包路径下的类解析注解生成BeanDefinition。依赖注入器在属性填充阶段通过反射给字段赋值。AOP 包ProxyFactory、Advisor、MethodInterceptor。事务包事务管理器 事务代理逻辑。写完你会发现Spring 的代码布局其实是清晰的BeanDefinitionRegistry管定义BeanFactory管创建ApplicationContext是对外门面聚合了工厂、资源加载、事件发布等功能。你自己实现一遍这个门面模式就再也不会把BeanFactory和ApplicationContext搞混了。手写时的代码量大概在 2000 行以内核心难点不在某个具体功能而在“顺序感”。Spring 的容器启动顺序非常讲究先注册配置类再扫描 BeanDefinition然后执行BeanFactoryPostProcessor最后实例化非懒加载单例 Bean。如果你一开始就让扫描和实例化同时进行循环依赖处理起来会特别别扭。6.2 手写过程中的几个关键卡点我在手写过程中遇到的最大的坑是循环依赖。第一版我用了最简单的递归创建结果遇到 A 依赖 B、B 依赖 A 时直接栈溢出。后来我把“半成品”暴露机制加进来用一个 Map 保存早期引用问题才解决。这个过程让我真正理解了为什么三级缓存里的第三级要用ObjectFactory——当时我用二级缓存直接把对象暴露出去导致后续 AOP 增强不生效因为代理是在 Bean 初始化完成后才生成的而早期引用的原始对象已经被注入到别的 Bean 里了。只有用工厂延迟生成代理才可能在创建完成后把新代理替换进单例池。第二个卡点是 AOP 代理的替换时机。代理对象生成后不能重新放入三级缓存而是要替换掉单例池里已经存在的对象。Spring 的做法是在postProcessAfterInitialization阶段生成代理然后getSingleton返回时把代理放回一级缓存。我一开始没处理这个替换导致容器里存在两个 A 对象一个是原始对象一个是代理对象其他 Bean 依赖注入时拿到的引用不一致。排查了很久才发现是单例池的覆盖逻辑写错了。第三个卡点更隐蔽字段注入的循环依赖和构造器注入的循环依赖表现完全不同。字段注入时A 的实例已经创建只是属性没填可以提前暴露构造器注入时A 还没new出来根本没有对象可以暴露。这也解释了一开始我提的问题为什么 Spring 构造器注入无法解决循环依赖。自己跑一遍代码比看十遍理论都深刻。我用 IDEA 社区版的过程中还有一个实用技巧社区版没有内置 Spring Initializr 插件创建 Spring Boot 项目不方便。可以去start.spring.io网站上生成项目压缩包再导入 IDEA 社区版依赖照常拉取功能完全不影响。如果你的电脑里没有 Spring Framework 5.3.41 的源码包直接在 Maven 仓库搜spring-framework对应版本的源码 jar 下载后附加到 IDEA 里阅读源码时跳转会顺畅很多。7. 高频面试题复盘真题背后的源码原理7.1 一组必问问题与参考答案面试题这个东西背答案是背不完的关键是把每个问题背后的原理串起来。这里我整理一组 Spring 高频面试题每道题我都给一个“面试官真正想听什么”的视角面试题考察点回答要点Autowired和Resource的区别依赖注入原理Autowired是 Spring 提供的按类型注入配合Qualifier按名称Resource是 JSR-250 标准默认按名称注入找不到再按类型BeanFactory和ApplicationContext的区别容器体系BeanFactory是底层容器ApplicationContext是高级容器增加了事件、国际化、资源加载、AOP 集成等能力Spring 单例 Bean 是否线程安全并发原理单例 Bean 本身没有状态线程不安全源于成员变量推荐用无状态 Bean 或 ThreadLocal 保存状态Spring 事务失效的场景有哪些AOP 代理原理自调用、非 public 方法、异常被吞、try/catch 包裹、rollbackFor 没有包含自定义异常、方法被 final 修饰循环依赖为什么需要三级缓存容器生命周期一级存成品、二级存早期引用、三级存工厂三级工厂实现代理延迟生成二级则无法解决 AOP 场景Spring 中怎么保证 Bean 创建的线程安全同步机制单例池通过synchronized保证同一时刻只有一个实例创建DefaultSingletonBeanRegistry里有双重检查逻辑Spring Boot 自动装配的原理扩展机制EnableAutoConfiguration通过AutoConfigurationImportSelector加载META-INF/spring.factories里的配置类Configuration和Component的区别代理机制Configuration类被 CGLIB 增强Bean方法受代理管控保证单例Component不增强Bean方法每次调用都新建这些题目不是让你死记硬背而是希望你理解它们背后的共同底层逻辑Bean 生命周期 AOP 代理 自动配置。答的时候如果能从源码角度展开面试官的好感度会明显提升。比如事务失效那道题你直接回答“this调用绕过代理因为Transactional的增强是在BeanPostProcessor后置处理器阶段通过动态代理生成的”这就比列一堆场景强很多。7.2 Spring AI新方向该怎么学热搜词里出现了 spring ai、spring ai agent、A2A spring、dify 工作流转成 spring ai java 代码这些内容。这个方向确实新但学习路线并不神秘。Spring AI 的定位是把 AI 能力变成 Spring 生态里的标准组件让你像写 Web 接口一样写 AI 应用。它的核心抽象是ChatClient用起来有点像RestClientChatClient chatClient ChatClient.builder(chatModel).build(); String answer chatClient.prompt() .user(帮我总结一下这篇笔记的核心内容) .call() .content();这个库里最重要的事情是统一了各家模型提供商的 API 差异。你换一家模型只需要换对应的ChatModel实现业务代码几乎不用动。Spring AI Alibaba 更进一步把阿里系模型和框架能力封装得更贴近国内开发者的使用习惯比如对百炼平台的模型有现成的 starter。如果你想把 Dify 之类平台上的工作流转成 Java 代码Spring AI 也提供了对应的编排抽象。不过我的建议是别急着把工作流里的每个节点都翻译成代码先弄清楚你的工作流里哪些是不变的核心业务逻辑哪些是可以被大模型替换的弹性部分。翻译代码不算难难的是你把一套拖拽式流程固化到代码仓库后后续怎么迭代和测试。学习 Spring AI 的路径我个人推荐按这个顺序来先跑通一个最简单的ChatClient调用理解 prompt、model、response 三个核心对象然后玩Message的角色体系也就是 user、system、assistant 消息这是对话记忆的基础接着是工具调用 Function Calling让模型能调你的 Java 方法最后再探索 Agent 和 A2A。这里的 A2A 是 Agent 之间的通信协议想深入的话可以直接看 Spring AI 官方文档里的 agent 模块里面有示例代码。8. 踩坑清单与调试技巧汇总最后这部分我把这些年实际操作里总结出来的避坑经验统一整理成一个速查清单按场景分类方便你在项目里照着排查。场景典型坑解决思路Bean 创建BeanCurrentlyInCreationException优先用设计消除循环依赖非必要时用Lazy延迟注入AOP 代理接口代理和 CGLIB 混用导致类型转换异常Spring Boot 2.x 默认 CGLIB遇到代理对象强制转换目标类型失败时检查是否切点没匹配事务方法内 this 调用导致事务失效拆 Bean、注入代理对象或使用TransactionTemplateMVC 参数数字/日期类型转换异常未处理全局异常处理器捕获MethodArgumentTypeMismatchException多数据源连接池参数只配了一个源每个数据源单独设置连接池参数通过AbstractRoutingDataSource管理开放接口缺少重放防护时间戳 随机 nonce Redis 幂等键组合校验Redis 事务误用Transactional包裹 Redis 操作Redis 不具备事务回滚语义使用 Lua 脚本或编程式管道MyBatis分页插件和自定义拦截器顺序冲突为 PaginationInterceptor 设置比自定义拦截器更低的 order确保分页最后的 count 查询不被改写Actuator管理端点暴露到公网只暴露 health、prometheus其他走内网日志调优事务和 SQL 日志混在一起难排查分区配置logging.level.org.springframework.transaction和mybatis为 DEBUG关于日志调优我可以多讲一句。排查事务问题时光看业务日志很难判断事务边界因为日志里没有标记。我通常同时打开org.springframework.jdbc.datasource.DataSourceTransactionManager和org.springframework.transaction.interceptor.TransactionInterceptor的 DEBUG 输出配合 MyBatis 的 SQL 日志就能串起来看事务开启、SQL 执行、异常触发、事务回滚每一步都有时间戳。这套组合在分布式排查里也很有用——先通过日志确定事务边界再进入代码定位业务异常最后才看是数据库锁还是代码逻辑的问题。还有一个调试器的技巧在DefaultSingletonBeanRegistry.getSingleton里打条件断点条件是 beanName 等于你要调试的 Bean 名然后跟着断点走一遍创建流程。这是我自己觉得理解 Spring 内部机制最快的路径比反复读源码高效得多。你在 IDE 里打断点时记得开启“条件断点”功能不然会陷入大量无关调用。回到开头说的Spring 源码并不难难的是你愿不愿意花一个周末单步调试一段 IoC 启动流程。我之前总觉得框架的东西懂了就行直到自己手写了一遍迷你版 Spring才把很多“我以为”变成了“我确定”。这篇笔记如果能帮你少走点弯路那目的就达到了。