Skip to content

Day15 Spring Boot + Spring 事务 + Spring MVC ​

📖 基础知识讲解【基础原理 + 高级深挖知识点】 ​

基础原理 ​

1. 三层全景:一次 HTTP 请求是怎么穿过这三块的 ​

Day14 讲的是容器内部(Bean 怎么造出来),Day15 讲的是容器对外:请求怎么进来、事务怎么包住业务、Boot 怎么把这一切自动配好。

text
浏览器 / App
    │  POST /order/create   Content-Type: application/json
    ▼
Servlet 容器(内嵌 Tomcat,Boot 启动时自己 new 出来的)
    │  Filter 链(Servlet 规范,拿得到 ServletRequest,拿不到 Controller 方法)
    ▼
DispatcherServlet(Spring MVC 的唯一入口,前端控制器)
    │  ① HandlerMapping   根据 URL + Method 找到 HandlerMethod
    │  ② HandlerInterceptor.preHandle(Spring 规范,拿得到 handler 方法对象)
    │  ③ HandlerAdapter   解析参数 → 反射调用 → 处理返回值
    ▼
Controller(@RestController)
    │  AOP 代理层:@Validated → 幂等切面 → 日志切面 → @Transactional
    ▼
Service(事务边界在这一层,不在 Controller)
    │  TransactionInterceptor 开启事务:从数据源拿 Connection,setAutoCommit(false)
    │  Connection 绑定到 ThreadLocal(TransactionSynchronizationManager)
    ▼
Mapper / Repository → 同一个 Connection → MySQL
    │
    ▼ 业务方法正常返回 → commit;抛 RuntimeException → rollback
返回值 → HttpMessageConverter(Jackson)序列化为 JSON → 写回 response

三个必须记牢的边界:

边界结论
Filter 与 InterceptorFilter 是 Servlet 规范,由 Tomcat 调用,在 DispatcherServlet 之外;Interceptor 是 Spring 规范,在 DispatcherServlet 之内
事务边界事务应该在 Service 层。放 Controller 会把参数校验、序列化、RPC 全圈进事务,连接被长时间占用
代理边界事务、@Async、缓存注解全部靠 AOP 代理,没走代理(自调用、new 出来的对象)一律失效

2. Spring Boot 的三件事:约定、starter、内嵌容器 ​

Spring Boot 不是新框架,它是 Spring 之上的一层「装配脚手架」,核心只干三件事。

① 约定大于配置(Convention over Configuration)

传统 Spring 配一个 Web + JDBC 工程要写几百行 XML:DispatcherServlet 注册、视图解析器、数据源、事务管理器、SqlSessionFactory……Boot 的思路是:先把最常见的配置写死成默认值,只有你不满意时才覆盖。

约定默认值覆盖方式
端口8080server.port=9090
配置文件名application.yml / application.propertiesspring.config.name
扫描范围启动类所在包及其子包@ComponentScan(basePackages = ...)
JSON 序列化Jackson换 Gson / Fastjson 需手动配 Converter
数据源连接池HikariCPspring.datasource.type
事务管理器DataSourceTransactionManager(单数据源)自己注册一个 PlatformTransactionManager

② starter 启动器:场景化依赖 + 自动配置的组合包

一个标准 starter 通常是空 jar,它只做两件事:聚合依赖 + 触发自动配置。

starter带来的能力
spring-boot-starter-webSpring MVC + 内嵌 Tomcat + Jackson + 校验(Boot 2.3 起校验被拆出去,要单独引 validation)
spring-boot-starter-jdbcDataSource + JdbcTemplate + DataSourceTransactionManager
spring-boot-starter-aopspring-aop + aspectjweaver,@EnableAspectJAutoProxy 自动开
spring-boot-starter-testJUnit5 + Mockito + AssertJ + spring-test
mybatis-spring-boot-starter第三方 starter,命名规范是 xxx-spring-boot-starter(官方才用 spring-boot-starter-xxx 前缀)

③ 内嵌容器:war 变 jar,部署方式彻底改变

text
传统:打 war 包 → 丢进 Tomcat 的 webapps → 一个 Tomcat 跑多个应用 → 一个应用 OOM 全挂
Boot:打可执行 jar → java -jar app.jar → 应用自己 new 一个 Tomcat → 进程级隔离,天然适配容器化

面试加分表述:「Spring Boot 解决的是装配成本,不是运行时能力。它底层还是 Spring 那套 refresh() 流程,只是把『该注册哪些 Bean』这个决策,从开发者手写 XML,变成了『按 classpath 上有什么 + 配置文件里写了什么』自动推导。理解这一点之后,看自动配置源码就只剩两个关键词:ImportSelector 和 @Conditional。」

3. @SpringBootApplication 拆解:三个注解各做了什么 ​

java
@SpringBootConfiguration   // = @Configuration,标记这是配置类(Full 模式,proxyBeanMethods 默认 true)
@EnableAutoConfiguration   // 开启自动装配,核心
@ComponentScan(excludeFilters = {
    @Filter(type = CUSTOM, classes = TypeExcludeFilter.class),
    @Filter(type = CUSTOM, classes = AutoConfigurationExcludeFilter.class)
})
public @interface SpringBootApplication { }

再往下拆一层,@EnableAutoConfiguration 本身也是组合注解:

java
@AutoConfigurationPackage                        // @Import(AutoConfigurationPackages.Registrar.class)
@Import(AutoConfigurationImportSelector.class)   // 真正干活的
public @interface EnableAutoConfiguration {
    Class<?>[] exclude() default {};
    String[] excludeName() default {};
}
注解职责面试易错点
@SpringBootConfiguration就是 @Configuration,只是加了个语义标记,方便测试框架定位主配置类一个工程只应有一个,@SpringBootTest 会向上找它
@ComponentScan扫启动类所在包及子包两个 excludeFilters 的作用:AutoConfigurationExcludeFilter 保证自动配置类不会被组件扫描重复注册(否则它会绕过 @Conditional 直接生效)
@AutoConfigurationPackage把启动类所在包名存进容器MyBatis 的 @MapperScan、JPA 的实体扫描默认都读这个包名,所以启动类放错包,Mapper 就扫不到
@Import(AutoConfigurationImportSelector)读取候选自动配置类清单并筛选它是 DeferredImportSelector,比普通 @Import 晚执行,这是 @ConditionalOnMissingBean 能让用户配置优先的根本原因

三个常被问的变形:

java
// ① 排除某个自动配置(第三方 starter 配错了,想关掉它)
@SpringBootApplication(exclude = DataSourceAutoConfiguration.class)

// ② 启动类不在根包时,必须手动指定扫描范围
@SpringBootApplication(scanBasePackages = {"com.xx.biz", "com.xx.common"})

// ③ 拆开写(想换掉默认的 bean 名生成策略等场景)
@Configuration
@EnableAutoConfiguration
@ComponentScan(nameGenerator = FullyQualifiedAnnotationBeanNameGenerator.class)
public class Application { }

4. 自动装配链路:从注解到一个 Bean 落地 ​

⚠️ 版本关键点(面试高频,答错直接掉档):

版本自动配置清单文件说明
Boot 1.x ~ 2.6META-INF/spring.factories,key 为 EnableAutoConfiguration老教程只讲这个
Boot 2.7新增 META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports,老文件仍兼容但废弃过渡版本,两种都能读
Boot 3.x只认 .imports 文件,spring.factories 里的自动配置彻底失效升级 Boot 3 后第三方老 starter 自动配置「静默不生效」的根因

.imports 文件格式极简,一行一个全限定类名,无需 \ 续行:

text
org.springframework.boot.autoconfigure.jdbc.DataSourceAutoConfiguration
org.springframework.boot.autoconfigure.web.servlet.DispatcherServletAutoConfiguration

完整链路(七步):

text
① @Import(AutoConfigurationImportSelector)
      ↓ 它实现 DeferredImportSelector,由 ConfigurationClassParser 延迟到最后处理
② getCandidateConfigurations()
      ↓ ImportCandidates.load() 扫描所有 jar 的 .imports 文件(Boot 2.7 前是 SpringFactoriesLoader)
      ↓ 得到 150+ 个候选自动配置类的全限定名(纯字符串,还没加载类)
③ removeDuplicates()                 多个 jar 重复声明时去重
④ getExclusions()                    处理 exclude / excludeName / spring.autoconfigure.exclude
⑤ filter() → AutoConfigurationImportFilter
      ↓ OnClassCondition / OnBeanCondition / OnWebApplicationCondition 三个过滤器
      ↓ 用 ASM 只读字节码判断 @ConditionalOnClass,不触发类加载(关键!)
      ↓ 150+ 个 → 剩 30 个左右
⑥ fireAutoConfigurationImportEvents() 发事件,Actuator 的 conditions 端点靠它记录决策过程
⑦ 剩下的类被当作 @Configuration 解析 → 内部 @Bean 方法逐个评估 @ConditionalOnMissingBean
      ↓ 注册 BeanDefinition → 后续 refresh() 的第 ⑪ 步统一实例化

一个自动配置类长什么样(以数据源为例,精简):

java
@AutoConfiguration                                       // Boot 2.7+ 的新注解,= @Configuration + 排序元数据
@ConditionalOnClass({DataSource.class, EmbeddedDatabaseType.class})
@ConditionalOnMissingBean(type = "io.r2dbc.spi.ConnectionFactory")
@EnableConfigurationProperties(DataSourceProperties.class)
public class DataSourceAutoConfiguration {

    @Configuration(proxyBeanMethods = false)
    @ConditionalOnMissingBean(DataSource.class)           // 用户自己配了 DataSource 就不生效
    @ConditionalOnProperty(name = "spring.datasource.type",
                           havingValue = "com.zaxxer.hikari.HikariDataSource",
                           matchIfMissing = true)         // 没配就默认 Hikari
    static class Hikari {
        @Bean
        HikariDataSource dataSource(DataSourceProperties properties) { ... }
    }
}

面试加分表述:「自动配置的三个设计精髓:① 用 ASM 读字节码判断类是否存在,避免 NoClassDefFoundError;② 用 DeferredImportSelector 延后执行,保证用户的 @Bean 先注册;③ 用 @ConditionalOnMissingBean 退让,让自动配置永远是『兜底』而不是『强制』。这三点合起来才叫『约定大于配置』——约定可被覆盖,否则就是枷锁。」

5. 条件装配:@ConditionalOnXxx 家族与判断时机 ​

Day14 讲过 @Conditional 的接口层面,这里补 Boot 的实现家族与最容易踩的时机问题。

注解判断内容典型用途
@ConditionalOnClassclasspath 上有某个类引了 redis 依赖才装配 RedisTemplate
@ConditionalOnMissingClassclasspath 上没有某个类两种实现互斥时选其一
@ConditionalOnBean容器里已有某类型/名称的 Bean有 DataSource 才配事务管理器
@ConditionalOnMissingBean容器里还没有自动配置兜底的核心
@ConditionalOnProperty配置项值匹配(matchIfMissing 控制缺省行为)功能开关 xxx.enabled=true
@ConditionalOnResource存在某个资源文件有 mybatis-config.xml 才加载
@ConditionalOnWebApplication是否 Web 环境(SERVLET / REACTIVE)Web 专属组件
@ConditionalOnExpressionSpEL 表达式为 true复合条件 #{'${a}'=='x' and ${b}}
@ConditionalOnSingleCandidate某类型 Bean 只有一个候选或有 @Primary多数据源下选主数据源
@ConditionalOnJavaJDK 版本范围适配不同 JDK 的实现

⚠️ 时机问题(这是真正的面试分水岭):

text
@ConditionalOnClass / OnProperty  →  只看 classpath 和配置,静态信息,任何时候判断结果都一样 → 安全
@ConditionalOnBean / OnMissingBean →  看"评估那一刻容器里有没有",强依赖注册顺序 → 危险
场景结果
@ConditionalOnMissingBean 写在自动配置类里✅ 正确。自动配置由 DeferredImportSelector 最后处理,用户的 Bean 一定先注册完
@ConditionalOnMissingBean 写在普通 @Component 上❌ 行为随机。普通组件之间没有确定顺序,结果取决于扫描顺序
两个自动配置类互相用 @ConditionalOnMissingBean 抢同一类型❌ 必须用 @AutoConfigureBefore / @AutoConfigureAfter 明确先后

排查工具(比死记强一百倍):

bash
# ① 启动参数打印完整的条件评估报告
java -jar app.jar --debug
# 输出 Positive matches(生效的)/ Negative matches(没生效及原因)/ Exclusions

# ② Actuator 端点,线上排查用
management.endpoints.web.exposure.include=conditions,beans,configprops,env
# GET /actuator/conditions  →  看某个自动配置为什么没生效

6. SpringApplication.run() 启动流程 ​

text
new SpringApplication(主类)
 ├─ ① 推断 WebApplicationType:classpath 有 DispatcherServlet → SERVLET
 │                             只有 WebFlux → REACTIVE;都没有 → NONE(定时任务/消费者应用)
 ├─ ② 加载 ApplicationContextInitializer(spring.factories,这部分 Boot 3 仍用 spring.factories)
 ├─ ③ 加载 ApplicationListener
 └─ ④ 推断主类(deduceMainApplicationClass,靠栈轨迹找 main 方法所在类)

run()
 ├─ ⑤ StopWatch 开始计时 + 获取 SpringApplicationRunListeners(EventPublishingRunListener)
 │     → starting 事件
 ├─ ⑥ prepareEnvironment()
 │     → 创建 Environment、加载 application.yml(ConfigDataEnvironmentPostProcessor)
 │     → 绑定命令行参数、激活 Profile
 │     → environmentPrepared 事件(@ConfigurationProperties 之外最早能改配置的时机)
 ├─ ⑦ printBanner()
 ├─ ⑧ createApplicationContext()
 │    → SERVLET 则创建 AnnotationConfigServletWebServerApplicationContext
 ├─ ⑨ prepareContext()
 │     → 执行 Initializer、注册主类为 BeanDefinition、设置 BeanNameGenerator
 │     → contextPrepared / contextLoaded 事件
 ├─ ⑩ refreshContext()  ★ 这里才进入 Day14 的 refresh() 十二步
 │     → invokeBeanFactoryPostProcessors 解析 @SpringBootApplication,触发自动装配
 │     → onRefresh() 被 Web 子类重写:createWebServer(),内嵌 Tomcat 在这里被创建
 │     → finishRefresh() → 启动 Tomcat 端口监听 → ServletWebServerInitializedEvent
 ├─ ⑪ afterRefresh() + started 事件(ApplicationStartedEvent)
 ├─ ⑫ callRunners():ApplicationRunner / CommandLineRunner 按 @Order 执行
 └─ ⑬ ready 事件(ApplicationReadyEvent)→ 此时才算"完全可对外提供服务"
启动回调触发时机适合干什么
@PostConstruct单个 Bean 初始化时本 Bean 的字段校验、本地缓存初始化
SmartInitializingSingleton所有单例创建完,容器还没完全 ready需要遍历全部同类型 Bean 的注册中心类逻辑
CommandLineRunner / ApplicationRunnerrefresh 完成之后数据预热、一次性任务(ApplicationRunner 拿到的是封装好的 ApplicationArguments)
ApplicationReadyEvent最后一步注册到服务发现、开始消费 MQ(早于这个时机注册会收到流量但依赖还没好)

面试加分表述:「我们线上踩过一个真坑:在 @PostConstruct 里就把实例注册到注册中心,结果容器还没 ready 流量就打进来了,少量请求命中了还没初始化完的缓存。改成监听 ApplicationReadyEvent 之后再注册,问题消失。所以这几个回调时机的差异不是八股,是真实的上线事故来源。」

7. Boot 的配置加载链与 Profile ​

属性源优先级、@Value 与 @ConfigurationProperties 的选型已在 Day14 深挖 16 讲透,这里只补 Boot 特有的文件加载链。

① 配置文件的查找位置(优先级从高到低,后加载的覆盖先加载的)

text
① file:./config/{profile}/      jar 同级 config 子目录的 profile 目录
② file:./config/                jar 同级 config 目录      ← 容器化部署最常用,挂 ConfigMap 进来
③ file:./                       jar 同级目录
④ classpath:/config/
⑤ classpath:/                   打在包里的 application.yml

② Profile:多环境隔离

yaml
# application.yml  —— 公共配置
spring:
  profiles:
    active: dev            # 本地默认 dev,线上用 --spring.profiles.active=prod 覆盖
    include: common,mq     # 无条件追加的 profile(不建议用,Boot 2.4+ 推荐 group)
    group:
      prod: prod,mq-prod,cache-prod   # 激活 prod 自动带上另外两个
---                        # 同文件多文档块(yml 用 ---,properties 用 #---)
spring:
  config:
    activate:
      on-profile: dev      # Boot 2.4 前写 spring.profiles: dev,已废弃
server:
  port: 8080
能力写法
Bean 级别的环境隔离@Profile("prod") 标在 @Component / @Bean 上
导入外部配置文件spring.config.import=optional:file:/etc/app/ext.yml(Boot 2.4+,比 @PropertySource 更合适,因为它参与 Boot 的加载链)
导入配置中心spring.config.import=nacos:xxx.yml —— Boot 2.4+ 之后 bootstrap.yml 不再默认启用,需引 spring-cloud-starter-bootstrap 或改用 config.import
条件化配置块spring.config.activate.on-profile / on-cloud-platform

③ 加载时机:为什么 @Value 在 BeanFactoryPostProcessor 里经常取不到值

text
EnvironmentPostProcessor(ConfigDataEnvironmentPostProcessor)  ← 配置文件在这里被读进 Environment
        ↓   此时容器里还没有任何业务 Bean
BeanFactoryPostProcessor(含 PropertySourcesPlaceholderConfigurer 的注册)
        ↓
BeanPostProcessor(AutowiredAnnotationBeanPostProcessor 解析 @Value)  ← 占位符在这里才被替换

所以:自定义 BFPP 里不要用 @Value 字段,要用 EnvironmentAware 拿 environment.getProperty()——因为 BFPP 自身实例化得太早,占位符解析器还没就位。

8. Spring MVC 请求全链路与九大组件 ​

完整链路(含异常分支):

text
① Tomcat 收到请求 → Filter 链 → DispatcherServlet.service() → doDispatch()
② checkMultipart()         是 multipart 请求则用 MultipartResolver 包装成 MultipartHttpServletRequest
③ getHandler()             遍历 HandlerMapping 列表,返回 HandlerExecutionChain(handler + 拦截器数组)
                           没找到 → noHandlerFound → 404(NoHandlerFoundException)
④ getHandlerAdapter()      按 supports() 匹配适配器,Controller 方法对应 RequestMappingHandlerAdapter
⑤ 拦截器 preHandle()       正序执行;任一个返回 false → 倒序触发已执行过的 afterCompletion → 直接返回
⑥ ha.handle()              参数解析 → 反射 invoke → 返回值处理(@ResponseBody 在这步就把 JSON 写出去了)
⑦ 拦截器 postHandle()      逆序执行(此时 @ResponseBody 的响应体已写出,改不动了)
⑧ processDispatchResult()  有异常 → HandlerExceptionResolver 链;有视图名 → ViewResolver → render()
⑨ 拦截器 afterCompletion() 逆序执行,无论成功失败都会走(适合清理 ThreadLocal、打总耗时)

九大组件(都在 DispatcherServlet.onRefresh() → initStrategies() 里初始化):

组件作用默认实现与关键细节
HandlerMappingURL → 处理器RequestMappingHandlerMapping(注解)、BeanNameUrlHandlerMapping、SimpleUrlHandlerMapping(静态资源)。是 List,按 @Order 顺序匹配
HandlerAdapter适配并执行处理器RequestMappingHandlerAdapter(99% 的场景)、HttpRequestHandlerAdapter(静态资源)、SimpleControllerHandlerAdapter(老式 Controller 接口)
HandlerExceptionResolver异常 → 响应ExceptionHandlerExceptionResolver(@ExceptionHandler)、ResponseStatusExceptionResolver(@ResponseStatus)、DefaultHandlerExceptionResolver(框架级异常转状态码)
ViewResolver视图名 → View前后端分离下几乎用不到;@ResponseBody 根本不走视图解析
HttpMessageConverter请求体/返回值 ↔ 对象严格说不属于九大组件,它挂在 RequestMappingHandlerAdapter 上。MappingJackson2HttpMessageConverter 负责 JSON
MultipartResolver文件上传解析Boot 默认 StandardServletMultipartResolver(Servlet 3.0 原生),不是老的 CommonsMultipart
LocaleResolver国际化区域解析AcceptHeaderLocaleResolver,读 Accept-Language
ThemeResolver主题(已过时)Spring 6 已移除,答题时提一句能体现版本敏感度
RequestToViewNameTranslator没返回视图名时按 URL 推导极少用
FlashMapManager重定向传参redirect: 场景下跨请求传一次性数据,底层存 Session

兜底机制:initStrategies() 对每个组件先按类型找用户注册的 Bean,找不到就读 DispatcherServlet.properties(在 spring-webmvc jar 里)加载默认实现。这就是为什么什么都不配也能跑。

9. 请求是怎么精确匹配到方法的 ​

RequestMappingHandlerMapping 实现了 InitializingBean,启动时扫描所有 @Controller / @RequestMapping 类,把每个方法解析成 RequestMappingInfo 存进 MappingRegistry(一个大 Map)。

text
RequestMappingInfo = 以下条件的组合(全部满足才匹配)
 ├─ PatternsRequestCondition   路径:/order/{id}
 ├─ RequestMethodsCondition    方法:GET / POST
 ├─ ParamsRequestCondition     params = "type=1"
 ├─ HeadersRequestCondition    headers = "X-Env=gray"
 ├─ ConsumesRequestCondition   consumes = "application/json"  → 匹配请求的 Content-Type
 └─ ProducesRequestCondition   produces = "application/json"  → 匹配请求的 Accept

多个候选同时匹配时,按精确度排序:/order/detail(精确) > /order/{id}(变量) > /order/*(单层通配) > /order/**(多层通配)。两个同等精确度的映射会在启动时抛 IllegalStateException: Ambiguous mapping,不会拖到运行期。

状态码真实原因
404路径没匹配上(或静态资源映射把请求吃了)
405 Method Not Allowed路径匹配上了但 HTTP 方法不对 —— 说明 URL 是对的,别再查路由
415 Unsupported Media Typeconsumes 不匹配:用 @RequestBody 却没发 Content-Type: application/json
406 Not Acceptableproduces 与请求 Accept 不匹配
400 Bad Request参数绑定失败/必填缺失/JSON 解析失败

RequestMappingHandlerAdapter 的执行三段式:

text
① HandlerMethodArgumentResolver(30+ 个)逐个 supportsParameter() → resolveArgument()
② 反射 invoke 目标方法(InvocableHandlerMethod)
③ HandlerMethodReturnValueHandler 处理返回值
     ├─ RequestResponseBodyMethodProcessor   → @ResponseBody,走 MessageConverter 直接写 response
     ├─ ModelAndViewMethodReturnValueHandler → 传统视图
     └─ 异步类型(Callable / DeferredResult)→ 交给 WebAsyncManager

10. 参数绑定:五个注解的边界 ​

注解取值来源典型用法易错点
@RequestParamquery string 或 form 表单?page=1、application/x-www-form-urlencoded默认 required=true,缺失直接 400;给默认值要写 defaultValue
@PathVariableURL 路径变量/order/{id}路径里含 .(如邮箱、版本号)会被当后缀截断,需用正则 {email:.+}
@RequestBody请求体,走 MessageConverterJSON / XML一个方法只能有一个,因为输入流只能读一次;GET 请求带 body 不规范,很多网关会丢弃
@ModelAttribute表单/query 绑定到对象复杂表单省略不写时,非简单类型参数默认就是它,所以 GET 多参数可以直接用对象接
@RequestHeader请求头@RequestHeader("X-Trace-Id")头名大小写不敏感
@CookieValueCookiesessionId—

无注解参数的默认行为(很多人没搞清):

java
// ① 简单类型(String、包装类型、枚举、Date…)→ 等价于 @RequestParam,但 required=false
public Result list(String keyword, Integer page) { }

// ② 复杂对象 → 等价于 @ModelAttribute,按字段名从 query/form 里逐个绑定
public Result list(QueryDTO dto) { }        // ?name=x&page=1  能绑上

// ③ 复杂对象 + JSON 请求体 → 必须加 @RequestBody,否则字段全是 null(高频踩坑)
public Result save(@RequestBody OrderDTO dto) { }

// ④ Servlet 原生类型由 ServletRequestMethodArgumentResolver 直接注入
public Result upload(HttpServletRequest request, MultipartFile file) { }

编译参数名丢失问题:@RequestParam 不写 value 时,靠反射拿参数名。JDK 8 需编译时加 -parameters(Spring Boot 的 maven 插件默认已加),否则会报 Name for argument of type [java.lang.String] not specified。自己搭的 Maven 工程或用 Lombok 的 @Builder 构造函数时最容易中招。

11. Spring 事务基础:三大接口、ACID 与隔离级别 ​

① Spring 事务抽象的三大接口(这是「Spring 只是事务的搬运工」的根据)

接口职责对应 @Transactional 的部分
PlatformTransactionManager怎么开/提交/回滚(策略)由 transactionManager 属性指定
TransactionDefinition事务的要求(传播、隔离、超时、只读、回滚规则)propagation、isolation、timeout、readOnly、rollbackFor
TransactionStatus当前事务的运行时状态(是否新事务、是否有保存点、是否已标记回滚)setRollbackOnly() 就是操作它

常见实现:DataSourceTransactionManager(JDBC / MyBatis)、JpaTransactionManager、JtaTransactionManager(XA 分布式)、ReactiveTransactionManager(WebFlux,接口都不同)。

关键认知:Spring 自己不实现事务,它只是把 connection.setAutoCommit(false) / commit() / rollback() 这几个 JDBC 动作,用 AOP 包装成了声明式写法。所以「数据库引擎不支持事务(MyISAM)→ @Transactional 静默失效」就很好理解了:命令发下去了,引擎不执行而已。

② ACID 与隔离级别(⚠️ 这里是本日最容易答错的地方)

特性含义由谁保证(MySQL InnoDB)
原子性 A要么全成要么全败undo log
一致性 C数据满足业务约束由 AID + 业务约束共同保证
隔离性 I并发事务互不干扰MVCC + 锁
持久性 D提交后不丢redo log(两阶段提交 + innodb_flush_log_at_trx_commit)
隔离级别脏读不可重复读幻读谁的默认值
READ_UNCOMMITTED 读未提交❌❌❌几乎没人用
READ_COMMITTED 读已提交✅❌❌Oracle / PostgreSQL / SQL Server 的默认值;国内很多互联网公司也会把 MySQL 手动调到 RC(减少间隙锁、降低死锁)
REPEATABLE_READ 可重复读✅✅⚠️ 快照读避免,当前读靠间隙锁MySQL InnoDB 的默认值
SERIALIZABLE 串行化✅✅✅性能极差,基本不用

(✅ = 已避免该问题,❌ = 仍存在)

必须说清的两个点:

  1. MySQL 默认是 RR,不是 RC。说"RC 是 MySQL 默认"是常见错误答案。
  2. RR 下幻读并没有被完全消灭:
    • 快照读(普通 SELECT)靠 MVCC 读同一个 ReadView,看不到新插入的行 → 不会幻读;
    • 当前读(SELECT ... FOR UPDATE、UPDATE、DELETE)读的是最新数据,靠 间隙锁 Gap Lock / Next-Key Lock 锁住区间阻止插入 → 才真正解决幻读;
    • 经典反例:事务 A 快照读没看到某行,于是 INSERT 时却撞上唯一键冲突;或 A 先快照读再 UPDATE,UPDATE 把新行也改了,再读就"看见"了 —— 这就是 RR 下仍能观察到的幻读现象。

③ @Transactional 的隔离级别是怎么下发的

java
@Transactional(isolation = Isolation.READ_COMMITTED)

DataSourceTransactionManager.doBegin() 里调用 DataSourceUtils.prepareConnectionForTransaction(),本质是执行 connection.setTransactionIsolation(...),改的是这条连接的隔离级别,不是全库。

⚠️ 三个坑:

  • 连接池复用时,用完必须还原。Hikari 要配 spring.datasource.hikari.transaction-isolation 或依赖 Spring 的 resetConnectionAfterTransaction 逻辑,自己拿连接改隔离级别后不还原会污染后续请求;
  • Isolation.DEFAULT(默认值)= 不调用 set 方法,直接用数据库的默认级别;
  • MySQL 8 的 useLocalSessionState=true 等驱动参数会影响是否真的发出 SET 语句,排查时记得看真实的 SQL 日志。

12. 事务传播行为:七种取值 + 实战效果矩阵 ​

Day14 深挖 6 已列过七种取值的定义,这里不重复背概念,重点给「外层有事务 / 没事务」两种情况下的实际行为,以及最容易出事的 UnexpectedRollbackException。

传播行为外层无事务外层有事务一句话用途
REQUIRED(默认)新建事务加入外层(共用同一个连接和回滚边界)99% 的业务方法
REQUIRES_NEW新建事务挂起外层,新开连接新事务日志/流水必须落库,主流程失败也要留痕
NESTED新建事务外层事务里打 savepoint子步骤允许单独失败,但主流程回滚它必须一起滚
SUPPORTS非事务执行加入外层纯查询方法
NOT_SUPPORTED非事务执行挂起外层,自己非事务跑大批量只读统计,避免占着事务连接
MANDATORY抛 IllegalTransactionStateException加入外层工具方法强制要求调用方必须有事务
NEVER非事务执行抛异常明确禁止在事务中调用(如长耗时任务)

REQUIRES_NEW vs NESTED 的本质差异(高频追问):

维度REQUIRES_NEWNESTED
底层机制挂起外层事务,另取一条数据库连接同一条连接,conn.setSavepoint()
连接数占 2 条连接(外层挂起但不释放)只占 1 条
内层失败且被捕获内层独立回滚,外层可以照常提交回滚到 savepoint,外层可以继续并提交
外层失败内层已提交的数据保留内层跟着一起回滚(savepoint 随外层一起消失)
内层能否看到外层未提交的数据看不到(两条连接,受隔离级别限制)看得到(同一连接同一事务)
支持度所有事务管理器仅 JDBC(DataSourceTransactionManager),JPA/Hibernate 支持有限
死锁风险⚠️ 高:内层改外层已锁住的行 → 自己把自己锁死无(同一事务)
java
// ⚠️ REQUIRES_NEW 死锁的真实案例
@Transactional
public void outer(Long id) {
    orderMapper.updateStatus(id, 1);   // 外层事务对该行加了排他锁,尚未提交
    self.inner(id);                     // 新事务、新连接
}
@Transactional(propagation = Propagation.REQUIRES_NEW)
public void inner(Long id) {
    orderMapper.updateRemark(id, "x"); // 等外层释放锁 → 外层在等 inner 返回 → 死锁,直到超时
}

UnexpectedRollbackException:最容易懵的异常

java
@Transactional                       // 外层
public void outer() {
    try {
        inner();                     // REQUIRED:加入的是同一个事务!
    } catch (Exception e) {
        log.error("内层失败,忽略", e); // 以为自己"兜住"了
    }
    orderMapper.insert(...);         // 以为能提交
}                                    // 提交时抛 UnexpectedRollbackException:
                                     // Transaction silently rolled back because it has been marked as rollback-only

原因:REQUIRED 下内层和外层是同一个物理事务。内层抛异常时,事务拦截器把 TransactionStatus 标记成 rollbackOnly=true;外层捕获了异常以为没事,提交时发现标记位已经被置上,只能整体回滚并抛这个异常告知调用方「你以为提交了,其实回滚了」。

三种正确解法:

方案适用场景
内层改成 REQUIRES_NEW 或 NESTED内层确实应该允许独立失败
内层不抛异常,改成返回值表示失败内层是可降级的非核心逻辑
外层不要 catch,让异常传播出去内层失败本就该让整体回滚(最常见)

13. 回滚规则:rollbackFor 与受检异常 ​

text
默认回滚规则(DefaultTransactionAttribute.rollbackOn):
    RuntimeException 及其子类   → 回滚
    Error                      → 回滚
    Exception(受检异常)       → ❌ 不回滚,照常提交

这个默认值来自 EJB 的历史约定(受检异常被视为"业务可预期的结果"),但在国内的业务代码里几乎总是错的。

java
// ❌ IOException 是受检异常,数据不回滚
@Transactional
public void save() throws IOException { ... }

// ✅ 团队规范:所有 @Transactional 统一写 rollbackFor
@Transactional(rollbackFor = Exception.class)

// ✅ 反向排除:某个异常明确不回滚(如"重复提交"直接当成功)
@Transactional(rollbackFor = Exception.class, noRollbackFor = DuplicateSubmitException.class)

// ✅ 异常被 catch 住但仍要回滚时的手动写法
try {
    riskOp();
} catch (BizException e) {
    TransactionAspectSupport.currentTransactionStatus().setRollbackOnly();
    return Result.fail(e.getMessage());
}

面试加分表述:「我们在 CI 里加了一条 ArchUnit / 自定义 PMD 规则:凡是标了 @Transactional 但没写 rollbackFor 的方法直接构建失败。因为这类问题的特点是平时跑测试完全正常,只在抛受检异常的边缘分支才暴露,靠 Code Review 根本盯不住。」

✨ 高级深挖知识点 ​

1. 自动配置的顺序与覆盖:谁先谁后决定了谁生效 ​

@ConditionalOnMissingBean 的语义是「容器里没有我才上」,它的正确性完全依赖评估顺序。Boot 用三层机制保证顺序确定。

机制作用
DeferredImportSelector让所有自动配置类整体晚于用户的 @Configuration 和 @Component 处理
@AutoConfigureOrder自动配置类之间的粗排序(值小的先),默认 0
@AutoConfigureBefore / @AutoConfigureAfter声明式的相对顺序,比数字更稳定
java
@AutoConfiguration(after = DataSourceAutoConfiguration.class)   // Boot 2.7+ 推荐写在注解属性里
@ConditionalOnBean(DataSource.class)
@ConditionalOnMissingBean(PlatformTransactionManager.class)
public class MyTxAutoConfiguration { ... }

排序结果的落点:AutoConfigurationSorter 先按字母排(保证可重现),再按 @AutoConfigureOrder,最后按 Before/After 做拓扑排序。

覆盖自动配置的四种手段(按推荐度):

手段说明
① 改配置项能用配置解决就别写代码,如 spring.jackson.date-format
② 自己注册同类型 Bean利用 @ConditionalOnMissingBean 自然退让
③ @SpringBootApplication(exclude = Xxx.class)整个自动配置类不要
④ spring.autoconfigure.exclude=com.x.XxxAutoConfiguration配置化排除,适合按环境开关

⚠️ 排查三连:自动配置「没生效」时按顺序查 —— ① --debug 看 Negative matches 的原因;② 确认 jar 里有 .imports 文件(Boot 3 下老 starter 的 spring.factories 不再被读);③ 确认自动配置类没有被 @ComponentScan 扫到(被扫到会绕过 @Conditional 顺序保证,行为变随机)。

2. 写一个规范的 starter:目录、命名与四条铁律 ​

text
my-spring-boot-starter/                 ← 空壳,只聚合依赖(可选,小团队常合并成一个模块)
my-spring-boot-starter-autoconfigure/   ← 真正的自动配置代码
 └─ src/main/
     ├─ java/com/x/MyAutoConfiguration.java
     │        com/x/MyProperties.java
     │        com/x/MyService.java
     └─ resources/META-INF/
         ├─ spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports   ← Boot 2.7+
         ├─ spring.factories                                                           ← 兼容 Boot ≤2.6 时保留
         └─ spring-configuration-metadata.json   ← IDE 配置项自动提示(由 configuration-processor 生成)
铁律原因
命名用 xxx-spring-boot-starterspring-boot-starter-xxx 是官方保留前缀,第三方用会误导使用者
依赖全部 optional 或 providedstarter 不能把自己的依赖强塞给使用方,靠 @ConditionalOnClass 适配
每个 Bean 都要 @ConditionalOnMissingBean让使用方永远能覆盖
提供 xxx.enabled 开关@ConditionalOnProperty(prefix="xxx", name="enabled", matchIfMissing=true),出问题能一键关
java
@AutoConfiguration
@ConditionalOnClass(RedisTemplate.class)
@ConditionalOnProperty(prefix = "idempotent", name = "enabled", matchIfMissing = true)
@EnableConfigurationProperties(IdempotentProperties.class)
public class IdempotentAutoConfiguration {

    @Bean
    @ConditionalOnMissingBean
    public IdempotentAspect idempotentAspect(StringRedisTemplate redis, IdempotentProperties props) {
        return new IdempotentAspect(redis, props);
    }
}
xml
<!-- 生成配置元数据,IDE 里写 yml 才有补全和文档 -->
<dependency>
  <groupId>org.springframework.boot</groupId>
  <artifactId>spring-boot-configuration-processor</artifactId>
  <optional>true</optional>
</dependency>

面试加分表述:「我们内部 starter 的版本升级踩过一次大坑:某个 Bean 没加 @ConditionalOnMissingBean,有个业务方自己也配了同名 Bean,升级后直接启动失败。后来定了规范——starter 里所有对外暴露的 Bean 必须可覆盖、必须可开关,并且把 spring-configuration-metadata.json 作为交付物之一,让使用方在 IDE 里就能看到每个配置项的含义和默认值。」

3. 可执行 jar 的原理:java -jar 到底执行了什么 ​

text
app.jar
├─ META-INF/MANIFEST.MF
│    Main-Class: org.springframework.boot.loader.JarLauncher   ← JVM 真正启动的类
│    Start-Class: com.x.Application                            ← 你的启动类
├─ BOOT-INF/classes/      ← 你的字节码和配置文件
├─ BOOT-INF/lib/*.jar     ← 所有依赖(jar in jar,标准 ClassLoader 读不了)
└─ org/springframework/boot/loader/**   ← spring-boot-loader,解压进来的
text
java -jar app.jar
  → JarLauncher.main()
  → 创建 LaunchedURLClassLoader(Boot 3.2+ 为 LaunchedClassLoader),它能读 jar 中嵌套的 jar
  → 反射调用 Start-Class 的 main()
  → SpringApplication.run()
问题答案
为什么需要自定义 ClassLoader?Java 标准规范不支持 jar 嵌套 jar,必须自己实现资源查找
为什么不能直接 java -cp app.jar com.x.Application?依赖都在 BOOT-INF/lib,标准类加载器找不到,必现 ClassNotFoundException
BOOT-INF/lib 里的 jar 为什么不压缩(stored)?便于通过内存映射直接定位,提高启动期的类读取效率
怎么在容器里加速启动?用 spring-boot-maven-plugin 的 layers 分层 + Docker 分层缓存;依赖层不变就不重新推送
怎么免解压看配置?unzip -p app.jar BOOT-INF/classes/application.yml

4. 内嵌容器:创建时机、定制与优雅停机 ​

创建时机:ServletWebServerApplicationContext 重写了 onRefresh()(Day14 refresh 十二步的第 ⑨ 步),在里面调 createWebServer()。注意这时业务 Bean 还没全部实例化,真正开始监听端口是在 finishRefresh()。所以「端口已监听但依赖没就绪」的窗口期是存在的,这也是要用 ApplicationReadyEvent 注册服务的原因。

容器适用场景切换方式
Tomcat默认,生态最全—
Undertow内存占用低、NIO 性能好排除 spring-boot-starter-tomcat,引 spring-boot-starter-undertow
Jetty长连接 / WebSocket 场景同上

定制方式三种(优先级从高到低):

java
// ① 配置项(最优先用这个)
// server.tomcat.threads.max=400  server.tomcat.accept-count=200  server.max-http-request-header-size=16KB

// ② WebServerFactoryCustomizer:配置项覆盖不到的能力
@Component
public class TomcatCustomizer implements WebServerFactoryCustomizer<TomcatServletWebServerFactory> {
    @Override
    public void customize(TomcatServletWebServerFactory factory) {
        factory.addConnectorCustomizers(c -> c.setProperty("relaxedQueryChars", "[]{}|"));
    }
}

// ③ 直接注册 ServletWebServerFactory Bean(彻底接管,会让自动配置退让)

关键参数与常见事故:

参数含义与坑
server.tomcat.threads.max(默认 200)同步阻塞模型下它就是并发上限。下游变慢时线程被打满 → 整个应用无响应,与 CPU 无关
server.tomcat.accept-count(默认 100)等待队列,满了之后新连接直接被拒(Connection refused)
server.tomcat.max-connections(8192)最大连接数,长连接场景要调
server.servlet.context-path改了之后所有 URL 带前缀,网关/健康检查路径要同步改
server.shutdown=graceful + spring.lifecycle.timeout-per-shutdown-phase=30s优雅停机:收到 SIGTERM 后不再接新请求,等存量请求跑完再退出。K8s 滚动发布必配,否则发布期间必有 5xx

虚拟线程(Boot 3.2 + JDK 21):

properties
spring.threads.virtual.enabled=true

开启后 Tomcat 用虚拟线程处理请求,阻塞 IO 不再占用平台线程,吞吐大幅提升。⚠️ 两个前提:① 代码里不能有 synchronized 包住的阻塞 IO(会 pin 住载体线程);② ThreadLocal 仍然可用但线程池式的 ThreadLocal 复用假设不再成立,不要再依赖"线程池里的线程会被复用"这个前提做缓存。

5. 事务源码链路:一行注解背后的九步 ​

text
① 启动期:@EnableTransactionManagement(Boot 由 TransactionAutoConfiguration 自动开)
     → 注册 BeanFactoryTransactionAttributeSourceAdvisor
         ├─ pointcut : TransactionAttributeSourcePointcut  (方法上有 @Transactional 就匹配)
         └─ advice   : TransactionInterceptor
② AnnotationAwareAspectJAutoProxyCreator(BPP)在初始化后阶段给目标类生成代理
③ 运行期调用 → 进入 TransactionInterceptor.invoke()
④ getTransactionAttribute():解析 @Transactional 得到 TransactionAttribute(= TransactionDefinition)
     → 查找顺序:目标类的方法 → 目标类 → 接口方法 → 接口(所以标在接口上在 CGLIB 下不可靠)
⑤ determineTransactionManager():按 value/transactionManager 名字或类型找事务管理器
⑥ createTransactionIfNecessary() → AbstractPlatformTransactionManager.getTransaction(def)
     ├─ isExistingTransaction()? → 走传播行为分支(加入 / 挂起 / 新建 / 抛异常 / savepoint)
     └─ doBegin():DataSourceTransactionManager
            ├─ dataSource.getConnection()
            ├─ conn.setAutoCommit(false)            ★ 事务真正开始
            ├─ conn.setTransactionIsolation(...)     (isolation != DEFAULT 时)
            └─ TransactionSynchronizationManager.bindResource(dataSource, connectionHolder)
                   → 存进 ThreadLocal<Map<Object, Object>>,这是"同线程共用连接"的根本
⑦ invocation.proceed():执行真正的业务方法
     → MyBatis 的 SqlSessionTemplate 通过 DataSourceUtils.getConnection(ds) 从 ThreadLocal 拿到同一条连接
       (这就是为什么 Mapper 不用传事务对象也能在同一事务里)
⑧ 异常 → completeTransactionAfterThrowing()
     ├─ txAttr.rollbackOn(ex) 为 true  → rollback(新事务则真回滚;嵌套则回滚到 savepoint;
     │                                   加入的事务只置 rollbackOnly 标记)
     └─ 为 false → 照常 commit(受检异常不回滚就发生在这里)
⑨ 正常返回 → commitTransactionAfterReturning() → conn.commit()
     → cleanupAfterCompletion():解绑 ThreadLocal、恢复 autoCommit、归还连接给连接池

由这条链路能直接推导出的结论(比死记"失效场景"强):

现象从链路里的哪一步推出
自调用失效第 ③ 步根本没进去,this.xxx() 不经过代理
private / final / static 失效第 ② 步代理无法覆写这些方法
换线程失效第 ⑥ 步的连接绑在 ThreadLocal,新线程拿不到
自己 new 的对象失效不是容器管理的 Bean,没有第 ② 步
MyISAM 失效第 ⑥/⑨ 步命令发了,存储引擎不执行
多数据源下事务错乱第 ⑤ 步选错了 transactionManager,绑定的是另一个 DataSource 的连接

6. 事务的挂起与恢复:到底挂起了什么 ​

REQUIRES_NEW / NOT_SUPPORTED 都会"挂起"外层事务。挂起的动作在 AbstractPlatformTransactionManager.suspend():

text
suspend()
 ├─ ① doSuspend():把 ThreadLocal 里当前 dataSource 对应的 ConnectionHolder 解绑并取出
 ├─ ② 取出并清空当前线程的同步器列表、事务名、只读标记、隔离级别
 └─ ③ 全部打包成 SuspendedResourcesHolder,塞进新事务的 TransactionStatus 里

resume()(新事务结束后)
 └─ 把 SuspendedResourcesHolder 里的东西原样 bind 回 ThreadLocal

三个必须知道的推论:

  1. 挂起不释放数据库连接,外层连接仍被持有 → REQUIRES_NEW 嵌套层数越多,占用的连接数越多。连接池默认 maximum-pool-size=10,循环里调 REQUIRES_NEW 极易把连接池耗尽并死等(表现为接口大面积超时,堆栈停在 HikariPool.getConnection)。
  2. 挂起期间外层的锁不会释放 → 内层改同一行必死锁(见基础原理 12 的例子)。
  3. 事务同步器(TransactionSynchronization)也会被挂起 → afterCommit 回调归属的是各自的事务,不会串。

7. 事务边界设计:长事务、RPC 与"提交后再做事" ​

这是真实项目里最值钱的一块,也是面试区分度最高的地方。

反面教材:

java
// ❌ 典型长事务:事务里干了四件不该干的事
@Transactional(rollbackFor = Exception.class)
public void createOrder(OrderDTO dto) {
    validate(dto);                        // ① 参数校验(不需要事务)
    Long uid = userRpc.getUser(dto.getUid());   // ② RPC:外部超时 3s,事务连接白占 3s
    orderMapper.insert(...);              // ③ 真正需要事务的部分
    stockRpc.deduct(...);                 // ④ RPC:它成功了,本地回滚时它回不来 → 数据不一致
    mqProducer.send(new OrderCreatedEvent());   // ⑤ 事务还没提交就发消息 → 下游查不到订单
    fileService.upload(dto.getAttach());  // ⑥ 上传大文件,事务被拉长到秒级
}

长事务的四个真实危害:

危害表现
连接池耗尽事务持有连接直到提交,QPS 一上来就 Connection is not available
锁等待与死锁行锁持有时间变长,并发更新同一行时大量 Lock wait timeout exceeded
主从延迟大事务一次性产生大量 binlog,从库重放不过来
undo log 膨胀MySQL 的 purge 线程无法清理活跃事务的 undo,磁盘涨、查询变慢

正确拆分:

java
// ✅ 外层编排方法不加事务
public void createOrder(OrderDTO dto) {
    validate(dto);
    UserVO user = userRpc.getUser(dto.getUid());    // RPC 放事务外
    Long orderId = orderTxService.saveOrder(dto, user);  // 事务只包 DB 操作,毫秒级
    // 事务已提交,这里再发消息
    mqProducer.send(new OrderCreatedEvent(orderId));
}

@Transactional(rollbackFor = Exception.class, timeout = 3)
public Long saveOrder(OrderDTO dto, UserVO user) {
    orderMapper.insert(...);
    orderItemMapper.batchInsert(...);
    return order.getId();
}

"提交后再做事"的三种标准写法:

java
// ① 事务同步器(最底层,无需事件机制)
TransactionSynchronizationManager.registerSynchronization(
    new TransactionSynchronization() {
        @Override public void afterCommit() { mqProducer.send(event); }
        @Override public void afterCompletion(int status) { /* 提交或回滚都会走 */ }
    });

// ② 事务事件监听(推荐,解耦)—— Day14 深挖 8 讲过机制,这里是它最重要的落地场景
@TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT)
public void onOrderCreated(OrderCreatedEvent e) { mqProducer.send(e); }

// ③ Boot 3.1+ 的工具方法
TransactionSynchronizationManager.registerSynchronization(...);   // 同 ①

⚠️ @TransactionalEventListener(AFTER_COMMIT) 的三个坑:

  • 回调里再写数据库默认不生效(事务已提交,新的写操作没有事务)→ 需要 @Transactional(propagation = REQUIRES_NEW);
  • 没有事务时,AFTER_COMMIT 的监听器根本不执行(除非设 fallbackExecution = true),本地单测容易误判;
  • 回调默认同步执行在提交后的同一线程,里面做耗时操作仍会拖慢接口。

跨服务一致性的边界:本地事务只能保证单库。跨服务要么用本地消息表 / 事务消息(最终一致,推荐),要么上 Seata AT/TCC;绝不能指望 @Transactional 回滚别人的服务。

8. 编程式事务 TransactionTemplate:缩小事务范围的利器 ​

声明式事务的粒度是方法,当一个方法里只有三行需要事务时,为了拆分而新建一个 Bean 很啰嗦。这时用编程式事务。

java
@Service
@RequiredArgsConstructor
public class OrderService {

    private final TransactionTemplate transactionTemplate;   // Boot 自动配置好了,直接注入

    public void createOrder(OrderDTO dto) {
        UserVO user = userRpc.getUser(dto.getUid());         // 事务外

        Long orderId = transactionTemplate.execute(status -> {   // 事务内,范围精确可见
            try {
                orderMapper.insert(...);
                itemMapper.batchInsert(...);
                return order.getId();
            } catch (BizException e) {
                status.setRollbackOnly();                     // 手动回滚,不靠异常
                return null;
            }
        });

        mqProducer.send(...);                                // 事务外
    }
}
对比维度声明式 @Transactional编程式 TransactionTemplate
侵入性低(一行注解)高(代码里显式包一层)
粒度方法级代码块级,想多小就多小
失效风险有(自调用、非 public、换线程)没有,不走代理
异常控制靠 rollbackFor靠 setRollbackOnly(),更直观
可读性事务边界"隐身",容易被后人破坏事务边界一眼可见
适用场景绝大多数 CRUD热点接口、事务内外混杂 RPC、循环里逐条提交
java
// 自定义传播行为的写法
TransactionTemplate newTx = new TransactionTemplate(transactionManager);
newTx.setPropagationBehavior(TransactionDefinition.PROPAGATION_REQUIRES_NEW);
newTx.setTimeout(3);

面试加分表述:「我们有个下单接口 P99 高达 800ms,排查发现事务里夹了两次 RPC。改造分两步:先用 TransactionTemplate 把事务收缩到只包两条 insert,P99 降到 120ms;再把『发消息』从事务内挪到 @TransactionalEventListener(AFTER_COMMIT),顺手修掉了下游偶发『订单查不到』的时序 bug。所以事务优化的核心口诀就一句:事务里只放数据库操作,别放网络调用。」

9. readOnly、timeout、isolation 的真实作用 ​

这三个属性经常被当"配上去就有用"的魔法,实际上各有明确边界。

属性真正发生了什么不要误解的地方
readOnly = true① Spring 设 conn.setReadOnly(true);② Hibernate/JPA 会关闭脏检查和快照,性能收益明显;③ MySQL 会把事务标记为只读,跳过事务 ID 分配,减少开销;④ 读写分离中间件(ShardingSphere、MyCat)据此把 SQL 路由到从库不是"写操作会报错"的保险。纯 MyBatis + 单库场景下,写操作可能照样执行成功(取决于驱动与引擎行为),别指望它防写
timeout = 3把超时写进 ConnectionHolder 的 deadline,在每次执行 SQL 前由 DataSourceUtils.applyTimeout() 转成 statement.setQueryTimeout()它限制的是单条 SQL 的执行时间,不是方法总耗时。事务里夹了 5 秒的 RPC,timeout=3 管不着;超时后抛 TransactionTimedOutException
isolationconn.setTransactionIsolation(),只影响这一条连接对 PROPAGATION_REQUIRED 加入已有事务的情况,内层声明的 isolation 会被忽略(Spring 默认 validateExistingTransaction=false 时静默忽略,开启后会抛异常)
java
// ✅ 查询方法的标准写法:只读 + 短超时
@Transactional(readOnly = true, timeout = 5)
public List<OrderVO> page(Query q) { ... }

// ⚠️ 单纯一条查询其实不需要事务,加了反而多占一次连接的事务开销;
//    只在"多条查询要求同一快照"或"需要路由到从库"时才加

10. 事务与锁:悲观锁、乐观锁与重试的顺序陷阱 ​

方案写法事务配合要点
悲观锁select ... for update必须在事务内,否则锁随语句结束立即释放,等于没加
乐观锁update t set v = v+1, version = version+1 where version = #{v}靠返回的影响行数判断,失败后必须在新事务里重试
分布式锁Redis / ZooKeeper锁必须在事务外层:先拿锁 → 开事务 → 提交 → 释放锁

⚠️ 最经典的两个顺序陷阱:

java
// ❌ 陷阱一:事务在外、锁在内 → 锁释放了事务还没提交,别的线程进来读到旧值,超卖照旧
@Transactional
public void deduct(Long id) {
    lock.lock();
    try { stockMapper.deduct(id); } finally { lock.unlock(); }   // 这里解锁,commit 还没发生
}

// ✅ 正确:锁在外、事务在内
public void deduct(Long id) {
    lock.lock();
    try { stockTxService.deductInTx(id); }   // 内部方法带 @Transactional,返回时已提交
    finally { lock.unlock(); }
}

// ❌ 陷阱二:@Retryable 和 @Transactional 标在同一个方法上
@Retryable(value = OptimisticLockException.class, maxAttempts = 3)
@Transactional
public void update() { ... }
// 两个切面的顺序决定一切:如果 Retry 在事务内层,重试时事务已被标记 rollbackOnly,
// 重试多少次都白费,最后还是抛 UnexpectedRollbackException。
// ✅ 解法:Retry 必须在事务外层(Retry 切面 @Order 更小),或把事务方法拆到另一个 Bean 再重试。

Day14 深挖讲过「幂等/锁切面要在事务外层」,这里是同一条原则在重试场景的延伸:凡是"失败后要重来一次"的切面,都必须包在事务之外。

11. 多数据源与分布式事务边界 ​

java
// ① 两个数据源 + 两个事务管理器
@Bean @Primary
@ConfigurationProperties("spring.datasource.order")
public DataSource orderDs() { return DataSourceBuilder.create().build(); }

@Bean @Primary
public PlatformTransactionManager orderTx(@Qualifier("orderDs") DataSource ds) {
    return new DataSourceTransactionManager(ds);
}

@Bean
public PlatformTransactionManager logTx(@Qualifier("logDs") DataSource ds) {
    return new DataSourceTransactionManager(ds);
}

// ② 使用时必须显式指定,否则永远走 @Primary 那个,表现为"事务加了但没回滚"
@Transactional(transactionManager = "logTx", rollbackFor = Exception.class)
public void writeLog() { ... }
方案一致性适用场景代价
两个 @Transactional 串行调用❌ 无保证日志等允许不一致的场景无
ChainedTransactionManager(已废弃)⚠️ 弱(最后一个提交失败,前面的已提交)遗留系统已 deprecated,不要在新项目用
JTA / XA(JtaTransactionManager)✅ 强传统金融系统、单体多库性能差、运维重,互联网业务基本不用
Seata AT✅ 最终一致微服务跨库跨服务需要额外 TC 集群 + undo_log 表
本地消息表 / RocketMQ 事务消息✅ 最终一致下单扣库存、发通知要写补偿和幂等,但最轻量,最常用

面试加分表述:「多数据源场景下我们踩过一个隐蔽坑:从库的事务管理器没指定,@Transactional 默认用了 @Primary 的主库管理器,于是『绑定的是主库连接、SQL 却走了从库数据源』,事务对从库那条 SQL 完全没约束。排查靠的是打开 DataSourceTransactionManager 的 DEBUG 日志,看它到底 bind 了哪个 DataSource。」

12. DispatcherServlet 的注册与初始化 ​

Boot 里没有 web.xml,DispatcherServlet 是这样进来的:

text
DispatcherServletAutoConfiguration
├── DispatcherServletConfiguration
│   └── @Bean DispatcherServlet dispatcherServlet()        // 创建实例,读 spring.mvc.* 配置
└── DispatcherServletRegistrationConfiguration
    └── @Bean DispatcherServletRegistrationBean            // 注册到 Servlet 容器,默认映射 "/"
            path = server.servlet.path (默认 "/")
            loadOnStartup = spring.mvc.servlet.load-on-startup (默认 -1,懒加载)
关键点说明
默认映射 / 而非 /*/ 是"默认 Servlet"语义,优先级最低,静态资源仍可被容器默认 Servlet 处理
loadOnStartup = -1第一个请求才初始化 MVC 九大组件,所以首个请求明显慢。压测前必须预热,或设 spring.mvc.servlet.load-on-startup=1
initStrategies()DispatcherServlet.onRefresh() 中调用,按九大组件逐个 initXxx(),先找容器里的 Bean,找不到就读 DispatcherServlet.properties 兜底
多 DispatcherServlet想给 /api/** 单独一套 MVC 配置时,手动再注册一个 ServletRegistrationBean,各自持有独立子容器

Filter 的注册与排序(面试常问"怎么控制多个过滤器顺序"):

java
@Bean
public FilterRegistrationBean<TraceFilter> traceFilter() {
    FilterRegistrationBean<TraceFilter> bean = new FilterRegistrationBean<>(new TraceFilter());
    bean.addUrlPatterns("/*");
    bean.setOrder(Ordered.HIGHEST_PRECEDENCE);   // 数字越小越靠外层
    return bean;
}
// @WebFilter + @ServletComponentScan 也能注册,但无法控制顺序,生产不推荐
// 实现 Ordered 接口 / 标 @Order 对 @Component 方式注册的 Filter 同样生效

常见排序:CharacterEncodingFilter(最外)→ 链路追踪 → 跨域 CorsFilter → Spring Security 过滤器链 → 业务 Filter → DispatcherServlet。CORS 必须排在 Security 前面,否则预检请求会先被鉴权拦掉。

13. Filter、Interceptor、AOP 三层对比 ​

text
请求 ──▶ Filter(Servlet 规范,容器级)
          └─▶ DispatcherServlet
                └─▶ Interceptor.preHandle(Spring MVC 级)
                      └─▶ HandlerAdapter → 参数解析 → AOP 切面
                            └─▶ Controller 方法
                      ◀─ Interceptor.postHandle(返回 ModelAndView 后、渲染前)
          ◀─ Interceptor.afterCompletion(渲染后,异常也会走)
     ◀─ Filter 后置
维度FilterInterceptorAOP 切面
规范/实现Servlet 规范Spring MVCSpring AOP(动态代理)
能否拿到 Spring Bean需手动注入,注册时机早易踩坑✅ 本身就是 Bean✅
能否拿到 Handler 方法与注解❌ 只有 URL✅ HandlerMethod 可读方法注解✅ 直接拿 JoinPoint
能否拿到解析后的参数对象❌ 只有原始流❌ 还没解析✅ 已经是 POJO
能否修改请求/响应流✅ 可包装 Request/Response❌❌
对静态资源/非 Controller 请求✅ 生效取决于映射,默认也会拦静态资源❌ 不生效
典型用途编码、跨域、压缩、XSS 清洗、读 body 缓存登录态、权限、限流、埋点、traceId 清理日志、事务、缓存、重试、幂等
异常能否被 @ControllerAdvice 捕获❌ 不能(在 DispatcherServlet 之外)✅ 能✅ 能

面试加分表述:「选型口诀是『要流用 Filter,要注解用 Interceptor,要参数用 AOP』。最容易踩的是第三行:在 Filter 里抛异常,全局异常处理器完全兜不住,响应会退化成容器默认的 HTML 错误页,所以 Filter 内部必须自己 try-catch 并手写 JSON 响应。」

14. 异常处理全链路 ​

text
Controller 抛异常
   └─▶ DispatcherServlet 捕获 → processHandlerException()
         └─▶ HandlerExceptionResolverComposite 按序遍历:
             ① ExceptionHandlerExceptionResolver  ← @ExceptionHandler / @RestControllerAdvice
             ② ResponseStatusExceptionResolver    ← @ResponseStatus / ResponseStatusException
             ③ DefaultHandlerExceptionResolver    ← Spring 内置异常 → 标准状态码
         └─▶ 都没处理 → 异常继续抛给容器 → 转发 /error → BasicErrorController
层次覆盖范围要点
@RestControllerAdviceController 内抛出的异常可用 basePackages、assignableTypes 限定范围;多个 Advice 用 @Order 排序;精确匹配优先于父类匹配
ResponseStatusException临时抛特定状态码比自定义异常类轻量,适合一次性场景
BasicErrorController兜底 /error,含 Filter 异常、404通过 ErrorAttributes 定制返回字段;server.error.include-message=always 才会带 message
ErrorPageFilter内嵌容器下把异常转成 error 分发Filter 抛的异常最终也走到 /error,但拿不到 Handler 信息
java
@RestControllerAdvice
public class GlobalExceptionHandler {

    @ExceptionHandler(MethodArgumentNotValidException.class)   // @RequestBody 校验失败
    @ResponseStatus(HttpStatus.BAD_REQUEST)
    public R<Void> handleValid(MethodArgumentNotValidException e) {
        String msg = e.getBindingResult().getFieldErrors().stream()
                .map(f -> f.getField() + ":" + f.getDefaultMessage())
                .collect(Collectors.joining("; "));
        return R.fail(400, msg);
    }

    @ExceptionHandler(BizException.class)
    public R<Void> handleBiz(BizException e) {
        log.warn("业务异常 code={} msg={}", e.getCode(), e.getMessage());   // 业务异常不打 error 栈
        return R.fail(e.getCode(), e.getMessage());
    }

    @ExceptionHandler(Exception.class)
    public R<Void> handleOther(Exception e) {
        log.error("系统异常", e);
        return R.fail(500, "系统繁忙,请稍后重试");    // 绝不把堆栈返给前端
    }
}

⚠️ 三个高频坑:① 全局异常处理器吞掉了异常,导致 @Transactional 以为方法正常返回而提交事务——异常处理器在事务切面外层,所以事务其实已经回滚完了,这一条是误解;真正会出问题的是在 Service 内部自己 try-catch 不往外抛。② @ExceptionHandler(Exception.class) 把 BindException、404 一起兜住,丢失语义。③ 返回体类型与统一响应包装 ResponseBodyAdvice 冲突,导致二次包装。

15. 参数校验体系:@Valid 与 @Validated 的边界 ​

对比项@Valid(JSR-303 标准)@Validated(Spring 扩展)
来源javax/jakarta.validationorg.springframework.validation.annotation
支持分组❌✅ @Validated(Create.class)
能标在类上做方法级校验❌✅ 标在类上 + 参数上加约束注解,走 AOP 校验
支持嵌套校验✅(标在字段上递归)字段嵌套仍要用 @Valid
java
// ① 参数位置决定抛什么异常 —— 这是面试最爱追问的一点
@PostMapping("/user")
public R<Void> create(@RequestBody @Validated(Create.class) UserDTO dto) { ... }
// 校验失败 → MethodArgumentNotValidException

@PostMapping("/form")
public R<Void> form(@Validated UserDTO dto) { ... }        // 表单/Query 绑定
// 校验失败 → BindException(是 MethodArgumentNotValidException 的父类)

@GetMapping("/detail")
public R<Void> detail(@RequestParam @Min(1) Long id) { ... }  // 类上需加 @Validated
// 校验失败 → ConstraintViolationException(注意:它不是 BindException 家族,要单独处理)
java
// ② 分组 + 嵌套 + 自定义校验
public interface Create {}
public interface Update {}

@Data
public class UserDTO {
    @Null(groups = Create.class) @NotNull(groups = Update.class)
    private Long id;

    @NotBlank(message = "用户名不能为空")
    @Length(min = 2, max = 20)
    private String name;

    @Valid @NotEmpty                 // 嵌套集合:必须加 @Valid 才会递归校验元素
    private List<AddressDTO> addresses;

    @Phone                           // 自定义注解
    private String mobile;
}

@Target(FIELD) @Retention(RUNTIME)
@Constraint(validatedBy = PhoneValidator.class)
public @interface Phone {
    String message() default "手机号格式不正确";
    Class<?>[] groups() default {};
    Class<? extends Payload>[] payload() default {};
}

public class PhoneValidator implements ConstraintValidator<Phone, String> {
    @Override
    public boolean isValid(String v, ConstraintValidatorContext ctx) {
        return v == null || v.matches("^1[3-9]\\d{9}$");   // null 交给 @NotNull 管,职责分离
    }
}
⚠️ 坑说明与解法
Boot 2.3+ 校验失效spring-boot-starter-web 不再内置 validation,需手动引 spring-boot-starter-validation
只报第一个错 / 全部报出默认是全部收集;想快速失败配 Hibernate Validator 的 failFast(true)
ConstraintViolationException 没被统一处理它走的不是 BindingResult,必须在 Advice 里单独加一个 handler
自定义校验器里注入 BeanConstraintValidator 由 Spring 管理(LocalValidatorFactoryBean),可以直接 @Autowired

16. 消息转换器与 Jackson 配置 ​

RequestResponseBodyMethodProcessor 根据 Content-Type / Accept 从 HttpMessageConverter 列表里挑一个:

Converter负责
MappingJackson2HttpMessageConverterapplication/json
StringHttpMessageConvertertext/plain,默认 ISO-8859-1,中文乱码就是它
ByteArrayHttpMessageConverterapplication/octet-stream
ResourceHttpMessageConverter文件下载
java
@Bean
public Jackson2ObjectMapperBuilderCustomizer jacksonCustomizer() {
    return builder -> builder
        // ① Long 转 String,解决前端 JS 精度丢失(超过 2^53 的雪花 ID 末尾变 0)
        .serializerByType(Long.class, ToStringSerializer.instance)
        .serializerByType(Long.TYPE, ToStringSerializer.instance)
        // ② 统一时间格式
        .simpleDateFormat("yyyy-MM-dd HH:mm:ss")
        .serializers(new LocalDateTimeSerializer(
                DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss")))
        // ③ 空值策略:null 字段不输出(注意会影响前端"字段消失"的判断)
        .serializationInclusion(JsonInclude.Include.NON_NULL)
        // ④ 反序列化遇到未知字段不报错
        .featuresToDisable(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES);
}

面试加分表述:「我们线上出过一次『订单 ID 最后两位变成 00』的问题,根因是雪花算法生成的 19 位 Long 超过了 JS Number.MAX_SAFE_INTEGER,前端解析时精度丢失。全局加 ToStringSerializer 修掉的同时,也约定了『对外 ID 一律字符串』这条接口规范。」

17. CORS 预检与三种配置方式的优先级 ​

text
浏览器判定为"非简单请求"时(PUT/DELETE、自定义 header、application/json)
   └─▶ 先发 OPTIONS 预检
         ├─ 服务端必须返回 Access-Control-Allow-Origin / -Methods / -Headers
         └─ 通过后才发真正的请求
配置方式生效位置适用场景
@CrossOriginController / 方法级少量接口开放
WebMvcConfigurer#addCorsMappingsMVC 层(DispatcherServlet 内)全局配置,但在 Filter 之后
CorsFilter(FilterRegistrationBean)Filter 层,最靠外和 Spring Security 共存时必须用这种
java
@Bean
public FilterRegistrationBean<CorsFilter> corsFilter() {
    CorsConfiguration cfg = new CorsConfiguration();
    cfg.setAllowCredentials(true);
    cfg.addAllowedOriginPattern("*");   // ⚠️ allowCredentials=true 时不能用 addAllowedOrigin("*")
    cfg.addAllowedHeader("*");
    cfg.addAllowedMethod("*");
    cfg.setMaxAge(3600L);               // 预检结果缓存 1 小时,减少 OPTIONS 请求
    UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource();
    source.registerCorsConfiguration("/**", cfg);
    FilterRegistrationBean<CorsFilter> bean =
            new FilterRegistrationBean<>(new CorsFilter(source));
    bean.setOrder(Ordered.HIGHEST_PRECEDENCE);
    return bean;
}
⚠️ 坑原因
配了 CORS 还是报跨域预检 OPTIONS 被登录拦截器/Security 拦掉,返回 401/403 而非 200
allowCredentials=true + allowedOrigin="*"规范禁止,Boot 2.4+ 直接启动报错,改用 allowedOriginPatterns
前端拿不到自定义响应头需 cfg.addExposedHeader("X-Trace-Id")
网关和应用都加了 CORS响应头出现两个 Access-Control-Allow-Origin,浏览器仍报错,只在一处配

18. WebMvcConfigurer 与 @EnableWebMvc 的生死线 ​

java
// ✅ 正确:扩展默认配置
@Configuration
public class WebConfig implements WebMvcConfigurer {
    @Override public void addInterceptors(InterceptorRegistry registry) { ... }
    @Override public void addArgumentResolvers(List<HandlerMethodArgumentResolver> r) { ... }
}

// ❌ 危险:加上 @EnableWebMvc 后,WebMvcAutoConfiguration 的
//    @ConditionalOnMissingBean(WebMvcConfigurationSupport.class) 不再成立,
//    整个 Boot MVC 自动配置失效 → 静态资源 404、Jackson 自定义丢失、
//    消息转换器回到最原始的一套、/error 页面异常
@Configuration
@EnableWebMvc
public class WebConfig implements WebMvcConfigurer { ... }
常用扩展点用途
addInterceptors注册拦截器,注意 excludePathPatterns 要排掉登录/静态//error
addResourceHandlers自定义静态资源映射(如本地上传目录)
addArgumentResolvers自定义参数解析器,例如 @CurrentUser 直接注入登录用户
extendMessageConverters调整转换器顺序(add 会覆盖默认,extend 才是追加)
addFormatters注册 Converter,解决 String → LocalDate 绑定
configurePathMatch统一加 API 前缀:addPathPrefix("/api", c -> c.isAnnotationPresent(RestController.class))
java
// 自定义参数解析器:Controller 直接写 @CurrentUser User user
public class CurrentUserResolver implements HandlerMethodArgumentResolver {
    @Override public boolean supportsParameter(MethodParameter p) {
        return p.hasParameterAnnotation(CurrentUser.class) && p.getParameterType() == User.class;
    }
    @Override public Object resolveArgument(MethodParameter p, ModelAndViewContainer m,
                                            NativeWebRequest req, WebDataBinderFactory f) {
        return UserContext.get();   // 由拦截器在 preHandle 里塞进 ThreadLocal
    }
}

19. 异步请求:Callable、DeferredResult 与 SSE ​

同步模式下 Tomcat 线程从进入到响应全程被占住;异步模式让 Servlet 线程尽早归还,提高吞吐。

方式谁来完成适用场景
Callable<T>Spring 用 AsyncTaskExecutor 另起线程执行耗时计算、简单异步化
DeferredResult<T>任意外部线程调用 setResult()长轮询、等 MQ 回调、等第三方通知
WebAsyncTask<T>同 Callable,额外支持超时/错误回调需要自定义超时处理
SseEmitter服务端持续 push消息推送、AI 流式输出
StreamingResponseBody直接写 OutputStream大文件下载、日志流
java
@GetMapping("/poll")
public DeferredResult<R<Msg>> poll() {
    DeferredResult<R<Msg>> dr = new DeferredResult<>(30_000L, R.fail(204, "no message"));
    dr.onTimeout(() -> log.info("长轮询超时"));
    dr.onCompletion(() -> registry.remove(dr));
    registry.add(dr);                   // 其它线程收到消息后 dr.setResult(...)
    return dr;
}

@Bean
public WebMvcConfigurer asyncConfigurer() {
    return new WebMvcConfigurer() {
        @Override public void configureAsyncSupport(AsyncSupportConfigurer c) {
            c.setDefaultTimeout(30_000);
            c.setTaskExecutor(mvcAsyncExecutor());   // ⚠️ 不配的话默认 SimpleAsyncTaskExecutor,不复用线程
        }
    };
}
⚠️ 异步三大坑解法
ThreadLocal(traceId、登录态、事务同步)丢失用 TaskDecorator 把主线程上下文复制过去;或改用 TransmittableThreadLocal
拦截器 postHandle 不按预期执行异步请求要实现 AsyncHandlerInterceptor#afterConcurrentHandlingStarted,清理逻辑放这里
异步线程里抛异常 @ControllerAdvice 不生效Callable/DeferredResult 内部异常会被包装回主流程,但 @Async 另起的线程不会,需配 AsyncUncaughtExceptionHandler

⚠️ 事务 + 异步的致命组合:@Transactional 方法里调 @Async 方法,新线程不共享事务(事务绑在 ThreadLocal 上),主事务回滚了异步分支照样执行完。要"提交后再异步",用深挖 7 的 TransactionSynchronization.afterCommit。

20. 请求体只能读一次与文件上传 ​

java
// ❌ 在 Filter 里 request.getInputStream() 读了 body 打日志
//    → Controller 的 @RequestBody 拿到空流,报 "Required request body is missing"
// ✅ 用 ContentCachingRequestWrapper 包装后放行
public class BodyLogFilter extends OncePerRequestFilter {
    @Override
    protected void doFilterInternal(HttpServletRequest req, HttpServletResponse resp,
                                    FilterChain chain) throws ServletException, IOException {
        ContentCachingRequestWrapper wrapped = new ContentCachingRequestWrapper(req);
        ContentCachingResponseWrapper wrappedResp = new ContentCachingResponseWrapper(resp);
        try {
            chain.doFilter(wrapped, wrappedResp);
        } finally {
            // ⚠️ getContentAsByteArray 必须在 chain 之后调,之前调是空的(缓存在读取时才填充)
            log.info("body={}", new String(wrapped.getContentAsByteArray(), UTF_8));
            wrappedResp.copyBodyToResponse();   // ⚠️ 忘了这行,响应体会丢失
        }
    }
}

OncePerRequestFilter 保证一次请求只执行一次(forward/include 时普通 Filter 可能被重复调用),自定义 Filter 优先继承它。

文件上传关键配置与坑:

yaml
spring:
  servlet:
    multipart:
      max-file-size: 10MB        # 单文件
      max-request-size: 50MB     # 整个请求(多文件时看这个)
      file-size-threshold: 2KB   # 超过就落磁盘,不占内存
⚠️ 坑说明
超限异常被全局处理器漏掉抛的是 MaxUploadSizeExceededException,而且发生在 Controller 之前,需单独 handler;Tomcat 还有 maxSwallowSize 会直接断连接
上传后文件变 0 字节MultipartFile 的临时文件在请求结束后被删除,异步线程里再用就空了,必须先 transferTo 或读成字节数组
@RequestBody 与 MultipartFile 混用不能同时用,JSON 字段要改成 @RequestPart("data") @Valid Dto dto
Filter 里读了 body 导致表单参数丢失multipart 请求不要用 ContentCachingRequestWrapper 缓存,按 Content-Type 跳过

21. 测试切片与事务回滚 ​

注解启动范围速度用途
纯 @Test + Mockito不启 Spring 容器最快Service 单元逻辑
@WebMvcTest(XxxController.class)只装 MVC 层(Controller、Advice、转换器、拦截器),Service 需 @MockBean快Controller 参数绑定/校验/异常
@DataJpaTest / @MybatisTest只装数据层 + 内嵌库中SQL/映射验证
@SpringBootTest全量容器慢集成链路、事务传播验证
java
@WebMvcTest(OrderController.class)
class OrderControllerTest {
    @Autowired MockMvc mockMvc;
    @MockBean OrderService orderService;   // 必须 mock,否则容器里找不到 Bean 直接启动失败

    @Test
    void 参数校验失败应返回400() throws Exception {
        mockMvc.perform(post("/order")
                        .contentType(MediaType.APPLICATION_JSON)
                        .content("{\"amount\":-1}"))
               .andExpect(status().isBadRequest())
               .andExpect(jsonPath("$.code").value(400))
               .andDo(print());
    }
}
java
@SpringBootTest
@Transactional     // ⚠️ 测试类上的 @Transactional 默认测试结束自动回滚
class OrderServiceTest {

    @Test
    @Rollback(false)          // 想保留数据时显式关掉
    void 下单应扣减库存() { ... }

    @Test
    @Commit                   // 等价写法
    void 需要真实提交的场景() { ... }
}

⚠️ 测试事务三个反直觉点:① 测试方法自带事务 → 被测方法里的 REQUIRES_NEW 行为和生产不同,验证传播行为时必须去掉测试类上的 @Transactional;② 测试事务不提交 → afterCommit 回调永远不触发,验证"提交后发消息"要用 @Commit 或 TestTransaction 手动控制;③ 异步/多线程代码在测试事务里看不到数据,因为数据还没提交。

22. Boot 3 / Spring 6 在这三块的变化 ​

变化点Boot 2.xBoot 3.x / Spring 6
JDK8 / 11最低 17
包名javax.servlet / javax.validationjakarta.*,升级时全量替换 import
自动配置注册文件spring.factories 仍可用只认 AutoConfiguration.imports
路径匹配AntPathMatcher,/user 与 /user/ 等价默认 PathPatternParser,尾斜杠不再等价(setUseTrailingSlashMatch 已废弃),升级后易出 404
虚拟线程无spring.threads.virtual.enabled=true(Boot 3.2+),IO 密集型吞吐大涨
可观测性Sleuth + MicrometerMicrometer Observation 统一 metrics + tracing
AOT / 原生镜像实验性spring-boot-maven-plugin 内置 AOT,可编译 GraalVM 原生镜像,启动降到几十毫秒
HTTP 客户端RestTemplate(维护模式)推荐 RestClient(Spring 6.1,同步但 fluent)/ WebClient
@ConstructorBinding需显式标注单构造器时可省略

面试加分表述:「我们升级 Boot 3 时真正花时间的不是 javax → jakarta(IDE 能批量改),而是三件事:一是第三方 starter 还在用 spring.factories,必须等上游升级或自己补一份 .imports;二是尾斜杠匹配策略变化导致一批老接口 404,靠网关统一归一化路径兜住;三是 AOP 代理默认改成 CGLIB 后,@Transactional 标在非 public 方法上彻底失效的问题被放大,借机做了一轮全局扫描。」

❓ 高频面试题【基础简答 + 连环追问链 + 精简口述答案 + 项目关联话术】 ​

题 1:Spring Boot 自动装配的原理是什么?怎么自定义一个 starter? ​

精简口述答案(90 秒版)

入口是 @SpringBootApplication 里的 @EnableAutoConfiguration,它由两部分组成:@AutoConfigurationPackage 把主类所在包注册为扫描根,@Import(AutoConfigurationImportSelector.class) 负责加载自动配置类。

AutoConfigurationImportSelector 实现的是 DeferredImportSelector,会延后到所有普通配置类解析完才执行,这样用户自己定义的 Bean 先注册,后面的 @ConditionalOnMissingBean 才能正确判断"用户有没有自己配"。

它的核心动作是:从所有 jar 的 META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports(Boot 2.7 之前是 META-INF/spring.factories)里读出候选自动配置类 → 去重 → 按 spring.autoconfigure.exclude 排除 → 用 AutoConfigurationImportFilter 配合 ASM 字节码扫描快速过滤 @ConditionalOnClass 不满足的(不加载类,所以快)→ 剩下的交给 ConfigurationClassPostProcessor 当普通配置类解析,逐个跑 @Conditional 判断。

自定义 starter 就是反过来做这件事:写一个 XxxAutoConfiguration,用 @ConditionalOnMissingBean 留出覆盖口子,用 @EnableConfigurationProperties 绑定配置,最后把全类名写进 .imports 文件。

连环追问链

追问答题要点
为什么必须是 DeferredImportSelector?普通 ImportSelector 在配置类解析过程中立即执行,那时用户的 @Bean 还没注册完,@ConditionalOnMissingBean 会误判成"没有",导致自动配置的默认 Bean 覆盖用户的
spring.factories 和 .imports 区别?前者是 properties 格式、一行多值靠逗号加反斜杠,解析慢且易写错;后者一行一个全类名,Boot 2.7 引入、3.x 只认它。升级时第三方 starter 没跟进就会自动配置全失效
@ConditionalOnClass 填的类不存在,为什么不报 NoClassDefFoundError?两道保险:① 外层 filter 用 ASM 读字节码里的注解元数据,不触发类加载;② 注解值以 Class 形式写在常量池,只做字符串比较
怎么排查某个自动配置为什么没生效?启动加 --debug 看 CONDITIONS EVALUATION REPORT 的 Negative matches,或访问 /actuator/conditions
starter 命名规范?官方是 spring-boot-starter-xxx,第三方必须是 xxx-spring-boot-starter,避免侵占官方命名空间
自动配置类能加 @ComponentScan 吗?❌ 绝对不行。starter 的包不在用户主类扫描路径下,扫描会越界甚至重复注册用户 Bean,应该用 @Import 显式引入

✅ 项目关联话术

「我们把公司内部的链路追踪、统一异常、脱敏日志封装成了一个 trace-spring-boot-starter。设计时守了三条:① 所有 Bean 都带 @ConditionalOnMissingBean,业务方可以随时覆盖;② 提供 trace.enabled=false 一键关闭,上线灰度时靠它兜底;③ 写了 additional-spring-configuration-metadata.json,业务方在 IDE 里写配置有自动补全和注释。接入成本从原来的『拷贝三个类 + 改 XML』降到了加一行依赖。」

题 2:说一下 SpringApplication.run() 做了什么? ​

精简口述答案(90 秒版)

分两段:构造 SpringApplication 时做了四件事——推断应用类型(看 classpath 有没有 DispatcherServlet、WebFlux 决定 SERVLET / REACTIVE / NONE)、加载 ApplicationContextInitializer、加载 ApplicationListener、反向推断主类。

run() 方法大致十三步,骨架是:启动计时与 SpringApplicationRunListeners(发 starting 事件)→ 准备 Environment(读 properties/yml/命令行,发 environmentPrepared,这是外部配置生效的时点,也是 EnvironmentPostProcessor 的切入点)→ 打印 Banner → 根据应用类型创建上下文 → prepareContext(执行 Initializer、注册主类为 BeanDefinition,发 contextPrepared、contextLoaded)→ refreshContext(),也就是 Day14 讲的 refresh 十二步,Web 场景在 onRefresh() 里创建并启动内嵌 Tomcat → afterRefresh → 发 started 事件 → 执行 ApplicationRunner / CommandLineRunner → 发 ready 事件。

一句话串起来:run() 是外壳,负责环境准备和生命周期广播;真正的 Bean 创建还是 Day14 那套 refresh(),Boot 只是在前后各加了一层。

连环追问链

追问答题要点
内嵌 Tomcat 在哪一步启动?refresh() 的第 ⑨ 步 onRefresh(),由 ServletWebServerApplicationContext 重写,创建 WebServer 但端口此时就已监听;真正开始接收请求是 finishRefresh() 里的 WebServerStartStopLifecycle#start
为什么说"Bean 还没初始化完端口就通了"是风险?若有耗时的 CommandLineRunner(如预热缓存),端口已通但依赖未就绪,K8s 探针会判定健康而导流进来。解法:用 /actuator/health/readiness 作就绪探针,而不是 TCP 探针
四种启动后回调怎么选?@PostConstruct(单个 Bean 初始化)< InitializingBean < ApplicationRunner/CommandLineRunner(容器就绪后,可注入任何 Bean,只执行一次)< ApplicationReadyEvent(最晚,适合发"服务上线"通知)
怎么在 Environment 准备好之后、Bean 创建之前改配置?实现 EnvironmentPostProcessor 并注册到 .imports/spring.factories,典型用于从配置中心拉配置、解密数据库密码
启动慢怎么排查?① --debug 看自动配置耗时;② spring-boot-starter-actuator 的 /actuator/startup(BufferingApplicationStartup)给出每一步耗时火焰图;③ 常见元凶是大量 @PostConstruct 里做远程调用、以及扫描包过宽

✅ 项目关联话术

「我们一个服务启动要 90 秒,用 /actuator/startup 抓出来发现两个大头:一是 @ComponentScan 配在了 com 这一层,扫了整个依赖树;二是有三个 Bean 在 @PostConstruct 里串行拉配置中心。前者收窄包路径省了 25 秒,后者改成 ApplicationRunner 里并行加载又省了 30 秒。这件事之后我们把『启动耗时』也加进了发布看板。」

题 3:Spring 事务的传播行为有哪些?@Transactional 在什么情况下会失效? ​

精简口述答案(90 秒版)

传播行为一共 7 种,按"外层有事务时怎么办"分三类记:支持类(REQUIRED 加入、SUPPORTS 有就用没有就不用、MANDATORY 必须有否则抛异常)、不支持类(REQUIRES_NEW 挂起外层开新事务、NOT_SUPPORTED 挂起外层以无事务执行、NEVER 有事务就抛异常)、嵌套类(NESTED 用 savepoint 做子事务,内层回滚不影响外层,但外层回滚会带着内层一起回)。默认是 REQUIRED。

失效场景的根因只有一条:Spring 事务靠 AOP 代理实现,一旦调用没走代理,或者异常没被切面感知,事务就失效。具体展开是六类:① 同类内部方法直接调用(this.xxx() 不走代理);② 方法非 public(CGLIB 代理下 private/final/static 无法被重写增强);③ 异常被自己 try-catch 吞掉没抛出;④ 抛的是受检异常而没配 rollbackFor(默认只回滚 RuntimeException 和 Error);⑤ 类没被 Spring 管理,或数据库引擎是 MyISAM 不支持事务;⑥ 多线程 / 异步,事务绑在 ThreadLocal 上不跨线程。

连环追问链

追问答题要点
自调用失效怎么解决?① 注入自己(@Lazy 避免循环依赖)调代理;② ((XxxService) AopContext.currentProxy()).method(),需开 @EnableAspectJAutoProxy(exposeProxy = true);③ 最推荐:拆到另一个 Bean,让事务边界和类边界对齐
REQUIRES_NEW 和 NESTED 区别?前者是两个独立事务 + 两条连接,外层回滚内层已提交的不受影响;后者是同一事务 + savepoint,一条连接,外层回滚内层必然回滚。要"日志无论如何都留下"用 REQUIRES_NEW,要"子步骤可局部重试"用 NESTED
内层 REQUIRED 抛异常被外层 catch 了,为什么外层也报错?内层加入的是同一个事务,异常触发时切面已把 rollbackOnly = true 标在 TransactionStatus 上;外层 catch 后想提交,AbstractPlatformTransactionManager 发现标记直接抛 UnexpectedRollbackException: Transaction rolled back because it has been marked as rollback-only
那怎么做到"内层失败外层继续"?内层改 REQUIRES_NEW 或 NESTED,或者干脆不要让内层抛到外层去
事务失效的原理层面怎么讲更深?TransactionInterceptor → invokeWithinTransaction → createTransactionIfNecessary → DataSourceUtils 把连接绑进 TransactionSynchronizationManager 的 ThreadLocal。只要这条链没走到,后面 Mapper 拿到的就是自动提交的新连接
怎么从机制上避免漏配 rollbackFor?定义 @BizTransactional 组合注解固化 rollbackFor = Exception.class,并在 CI 里加 ArchUnit/自定义扫描规则,禁止裸用 @Transactional

✅ 项目关联话术

「我们线上出过一次典型事故:下单方法里 catch 了库存服务抛的异常想『降级继续』,结果整个请求抛 UnexpectedRollbackException,订单反而全失败。定位后把库存扣减改成了 REQUIRES_NEW + 显式失败标记,外层根据标记决定走降级还是整体回滚。复盘后我们立了一条规范:只要在事务里 catch 异常,就必须明确说明内层传播行为,Code Review 强制检查这一点。」

题 4:事务隔离级别有哪些?MySQL 默认是什么?RR 真的解决幻读了吗? ​

精简口述答案(90 秒版)

四个级别对应三个问题:READ_UNCOMMITTED 什么都防不住(脏读/不可重复读/幻读);READ_COMMITTED 解决脏读;REPEATABLE_READ 再解决不可重复读;SERIALIZABLE 全解决但退化成串行,基本不用。

MySQL InnoDB 默认是 REPEATABLE_READ,这点和 Oracle、PostgreSQL、SQL Server 不一样,它们默认是 READ_COMMITTED。Spring 的 Isolation.DEFAULT 表示跟随数据库,所以 Spring 项目接 MySQL 默认跑在 RR。

RR 下幻读分两种情况看:快照读(普通 select)靠 MVCC 的一致性视图,整个事务只读同一个 Read View,不会看到别的事务新插入的行,幻读被消除;当前读(select ... for update、update、delete)读的是最新版本,InnoDB 用 Next-Key Lock(记录锁 + 间隙锁)锁住范围来阻止插入。所以严格说 RR 下"快照读 + 当前读混用"仍可能出现幻读,比如先快照读没查到,再 insert 报主键冲突,或者先快照读、后 update 把别人插入的行也更新了,这之后的快照读就"突然看见"了这一行。

连环追问链

追问答题要点
MVCC 是怎么实现的?每行隐藏列 DB_TRX_ID(最后修改事务 ID)、DB_ROLL_PTR(指向 undo log 的回滚指针)。读时拿 Read View(活跃事务列表)沿 undo log 版本链往回找第一个可见版本
RC 和 RR 在 Read View 上的区别?RC 每次 select 都生成新 Read View(所以能读到别人新提交的,即不可重复读);RR 只在第一次 select 时生成一次,整个事务复用
为什么互联网公司常把 MySQL 改成 RC?① RR 的间隙锁范围大,并发写冲突和死锁多;② 主从复制在 RC + ROW 格式下已无主备不一致问题(RR 当年是为 statement 格式服务的);③ 业务一致性更多靠乐观锁/幂等保证,不依赖数据库级别
Spring 里改 isolation 要注意什么?① 只对新开的事务生效,REQUIRED 加入已有事务时被忽略;② 某些连接池(如 Druid 配了 defaultTransactionIsolation)或中间件会覆盖;③ 改隔离级别会在连接归还时产生一次额外的 reset
幻读和不可重复读怎么区分?不可重复读针对同一行的 update,幻读针对结果集的 insert/delete;前者靠行锁解决,后者必须靠间隙锁或 MVCC

✅ 项目关联话术

「我们核心交易库保持 MySQL 默认的 RR,但扣减库存这类高并发写路径不依赖隔离级别,而是用 update stock set num = num - 1 where id = ? and num >= 1 这种带条件的原子更新 + 判断影响行数,既避免了间隙锁死锁,也不需要 for update。做压测对比过:RR + for update 的 TPS 只有原子更新方案的三分之一左右。」

题 5:一个请求进入 Spring MVC 后的完整流程是怎样的? ​

精简口述答案(90 秒版)

核心在 DispatcherServlet#doDispatch,九步:

① checkMultipart 判断是否文件上传,是就包装成 MultipartHttpServletRequest;② getHandler 遍历 HandlerMapping,RequestMappingHandlerMapping 根据 RequestMappingInfo(path、method、params、headers、consumes、produces 六个维度)匹配出 HandlerExecutionChain,匹配不到返回 404,路径对但方法不对返回 405;③getHandlerAdapter 找适配器,注解控制器对应 RequestMappingHandlerAdapter;④ 执行所有拦截器的 preHandle,任一返回 false 就中断并倒序执行已通过拦截器的 afterCompletion;⑤ handle 真正调用方法,内部先由 HandlerMethodArgumentResolver 解析参数(@RequestBody 走 HttpMessageConverter 反序列化,Content-Type 不支持报 415),再反射调用,返回值由 HandlerMethodReturnValueHandler 处理(@ResponseBody 再走转换器序列化,Accept 不匹配报 406);⑥ postHandle;⑦ processDispatchResult 渲染视图,前后端分离下返回值已直接写进响应体,这步基本空转;⑧ 有异常则交给 HandlerExceptionResolverComposite;⑨ 倒序执行 afterCompletion,无论成功失败都会执行,所以 ThreadLocal 清理必须放这里。

连环追问链

追问答题要点
九大组件有哪些?HandlerMapping、HandlerAdapter、HandlerExceptionResolver、ViewResolver、MultipartResolver、LocaleResolver、ThemeResolver、RequestToViewNameTranslator、FlashMapManager,由 initStrategies() 初始化,容器里没有就读 DispatcherServlet.properties 兜底
URL 映射关系什么时候建立的?RequestMappingHandlerMapping 实现 InitializingBean,在 afterPropertiesSet 时扫描所有带 @Controller/@RequestMapping 的 Bean,把方法注册进 MappingRegistry。所以运行时动态改映射需要操作 MappingRegistry
为什么会出现 400/415/406?400 参数绑定或类型转换失败;415 请求的 Content-Type 没有对应的 Converter;406 服务端产出的类型不在客户端 Accept 里
postHandle 为什么有时不执行?方法抛异常时直接跳到异常处理,postHandle 被跳过;异步请求也会跳过。清理逻辑一律放 afterCompletion
DispatcherServlet 为什么默认映射 / 而不是 /*?/ 是"默认 Servlet"语义、优先级最低,静态资源能先被容器默认 Servlet 命中;/* 会抢走一切,包括 JSP
第一个请求特别慢是为什么?loadOnStartup 默认 -1,DispatcherServlet 懒加载,首个请求才 initStrategies。压测前预热,或配 spring.mvc.servlet.load-on-startup=1

✅ 项目关联话术

「我们在网关侧统计 P99 时发现每次发版后第一分钟毛刺特别明显,排查下来一部分是 JIT 未预热,另一部分就是 DispatcherServlet 懒加载。后来在发布流程里加了『实例启动后先自打 50 次健康接口再挂上负载』的预热步骤,首分钟 P99 从 1.8s 降到 300ms 左右。」

题 6:过滤器、拦截器、AOP 有什么区别?登录校验和接口耗时统计分别该用哪个? ​

精简口述答案(90 秒版)

执行顺序是 Filter → DispatcherServlet → Interceptor → AOP → Controller,返回时反向。

能力上三者递进:Filter 属于 Servlet 规范,最靠外,能拿到也能包装原始的请求/响应流,但拿不到将要执行的方法信息;Interceptor 属于 Spring MVC,能拿到 HandlerMethod,也就是能读方法上的注解,但参数还没解析;AOP 是 Spring 代理层,能拿到解析完成的参数对象和返回值,但只对 Spring Bean 生效,拿不到 Request/Response。

所以选型口诀是「要流用 Filter,要注解用 Interceptor,要参数用 AOP」。登录校验用拦截器最合适——需要读方法上的 @NoAuth/@RequiresRole 注解来决定放不放行,还要用 excludePathPatterns 排除登录接口;接口耗时统计两种都行:想统计包含反序列化在内的完整耗时用 Filter 或 Interceptor,想精确统计业务方法执行耗时并记录入参用 AOP。

还有一个关键差异:Filter 里抛出的异常不会被 @RestControllerAdvice 捕获,因为它发生在 DispatcherServlet 之外,最终退化成 /error 或容器默认错误页。

连环追问链

追问答题要点
怎么控制多个 Filter 的顺序?用 FilterRegistrationBean#setOrder,数字越小越靠外;@WebFilter 无法控制顺序,生产不推荐
为什么推荐继承 OncePerRequestFilter?forward/include 时普通 Filter 可能被重复调用;它内部用 request attribute 做标记,保证一次请求只走一次
拦截器里怎么拿到方法上的自定义注解?if (handler instanceof HandlerMethod hm) { hm.getMethodAnnotation(RequiresRole.class) },注意静态资源请求的 handler 是 ResourceHttpRequestHandler,要先判类型
ThreadLocal 在哪清理?afterCompletion,它在异常路径下也会执行。线程池复用场景下不清理会串号,是最经典的生产事故之一
AOP 切面和事务切面谁先执行?由 @Order 决定,事务切面默认 Ordered.LOWEST_PRECEDENCE,所以自定义切面默认在事务外层。幂等、分布式锁、重试这类切面必须在外层,日志打印在内外都可以
三者都能做鉴权,为什么实际用 Spring Security?它本身就是一条 Filter 链(FilterChainProxy),把认证、授权、CSRF、会话管理拆成十几个有序 Filter,比自己写拦截器可扩展性强得多

✅ 项目关联话术

「我们的请求处理链是这么分层的:最外层 Filter 做 traceId 生成和请求体缓存(ContentCachingRequestWrapper),中间拦截器做登录态解析和权限校验、把用户信息放进 ThreadLocal 并在 afterCompletion 清理,最内层 AOP 切面按 @BizLog 注解记录入参出参和耗时。踩过的最大的坑是早期把 ThreadLocal 清理写在 postHandle,结果接口抛异常时没清,Tomcat 线程复用后下一个请求读到了上一个用户的身份——这个 bug 让我们养成了『ThreadLocal 必须在 finally 或 afterCompletion 清理』的习惯。」

题 7:@RequestParam、@PathVariable、@RequestBody 有什么区别?为什么 @RequestBody 只能有一个? ​

精简口述答案(90 秒版)

它们对应取值的三个不同位置:@PathVariable 取 URL 路径片段(/user/{id});@RequestParam 取 query string 或表单(application/x-www-form-urlencoded);@RequestBody 取整个请求体并交给 HttpMessageConverter 反序列化,通常是 JSON。另外两个是 @RequestHeader 和 @CookieValue。

底层统一由 HandlerMethodArgumentResolver 链处理:RequestParamMethodArgumentResolver、PathVariableMethodArgumentResolver 走的是 WebDataBinder + Converter 做类型转换;RequestResponseBodyMethodProcessor 走的是消息转换器。

@RequestBody 只能有一个,原因是 Servlet 的 InputStream 是单向流,读完就到末尾,第二个解析器再读只会拿到空数据。需要接多个对象时,要么合并成一个外层 DTO,要么用 @RequestPart 配合 multipart。同理,在 Filter 里打印了 body 又不做缓存包装,Controller 就会报 "Required request body is missing"。

不加任何注解时:简单类型(String、基本类型及包装类)在有 -parameters 编译参数时按 @RequestParam 处理,否则参数名拿不到会报错;复杂 POJO 按 ModelAttribute 处理,从 query/表单逐字段绑定,不会解析 JSON——这是"前端传了 JSON 后端全是 null"的最常见原因。

连环追问链

追问答题要点
required = false 和 defaultValue 区别?配了 defaultValue 会隐式把 required 置为 false;int 这类原始类型不能 required=false,缺参会抛 500 的类型转换异常,必须用 Integer
消息转换器是怎么选的?按 Content-Type(入)和 Accept + 返回值类型(出)遍历 canRead/canWrite,第一个匹配的胜出。顺序可通过 extendMessageConverters 调整,用 configureMessageConverters 会覆盖全部默认的
Long 类型 ID 传到前端精度丢失怎么办?JS Number 只有 53 位安全整数,19 位雪花 ID 会被截断。全局配 ToStringSerializer,或在字段上标 @JsonSerialize(using = ToStringSerializer.class)
日期参数绑定失败怎么处理?请求体内的日期靠 Jackson(@JsonFormat 或全局 JavaTimeModule);query/表单里的日期走的是 Converter 体系,要用 @DateTimeFormat 或注册全局 Converter<String, LocalDate>,两套机制完全独立,这点很容易混
为什么要加 -parameters 编译参数?Class 文件默认不保留方法参数名,Spring 只能拿到 arg0。Boot 的 maven 插件已默认开启,自己搭的工程要手动配,否则 @RequestParam 不写 value 会报 "Name for argument of type [java.lang.String] not specified"
怎么让 Controller 直接拿到当前登录用户?自定义 HandlerMethodArgumentResolver + @CurrentUser 注解,从 ThreadLocal 取,避免每个方法都写一遍解析代码

✅ 项目关联话术

「我们定过一条接口规范:查询统一用 GET + @RequestParam/@PathVariable,写操作统一用 POST + @RequestBody,不允许混用。起因是早期有个接口既收 query 又收 body,前端 SDK 和网关的参数透传规则都要特殊处理,排查一个『参数偶尔丢失』的问题花了半天。另外对外 ID 一律序列化成字符串,这是被雪花 ID 精度丢失坑过之后加上的硬性规定。」

题 8:全局异常处理和参数校验在项目里怎么落地? ​

精简口述答案(90 秒版)

异常处理分三层:第一层用 @RestControllerAdvice 统一处理 Controller 抛出的异常,按 BizException(业务异常,打 warn,返回业务码)、校验异常族、Exception 兜底(打 error,返回"系统繁忙",绝不把堆栈返给前端)分别写 handler;第二层是 Spring 内置的 ResponseStatusExceptionResolver 和 DefaultHandlerExceptionResolver,负责把 405、415 这类框架异常映射成标准状态码;第三层是 /error 的 BasicErrorController 兜底,它覆盖 Filter 抛的异常和 404。

校验上,@Valid 是 JSR-303 标准注解,@Validated 是 Spring 扩展,区别在只有 @Validated 支持分组和类级别的方法参数校验。实际用法:DTO 上写约束注解并用分组区分新增/修改,@RequestBody 参数标 @Validated(Create.class),嵌套对象字段上加 @Valid 才会递归。

关键是三种位置抛的异常类型不同,必须都兜住:@RequestBody 抛 MethodArgumentNotValidException,表单绑定抛 BindException,方法参数上的单个约束(如 @RequestParam @Min(1))抛 ConstraintViolationException,最后这个不属于 BindException 家族,漏了就会变成 500。

连环追问链

追问答题要点
多个 @RestControllerAdvice 顺序怎么定?用 @Order;同一个 Advice 内部按异常类型的继承距离选最精确的 handler,不是按声明顺序
Filter 里抛异常为什么不走全局异常处理?@ExceptionHandler 由 DispatcherServlet 的 processHandlerException 触发,Filter 在它外层。解法:Filter 内部自己 try-catch 并手写 JSON 响应,或统一在最外层包一个异常处理 Filter
Boot 2.3 之后校验注解突然不生效?spring-boot-starter-web 移除了内置的 validation 依赖,要手动引 spring-boot-starter-validation
怎么做统一响应体包装?ResponseBodyAdvice + @RestControllerAdvice,但要排除掉已是 R 类型的返回值和 String 返回值(StringHttpMessageConverter 不走 Jackson,包装后会报类型转换异常)
自定义校验注解怎么注入 Spring Bean?ConstraintValidator 由 LocalValidatorFactoryBean 创建,本身是 Spring 管理的,直接 @Autowired 即可,可用于"校验用户名是否已存在"这种需要查库的场景
校验失败只想返回第一个错误?配 Validation.byProvider(HibernateValidator.class).configure().failFast(true);或在 handler 里只取 getFieldErrors().get(0)

✅ 项目关联话术

「我们的统一异常体系有三个细节是踩坑后加的:① 业务异常用 log.warn 且不打堆栈,系统异常用 log.error 打完整堆栈——早期全打 error,日志量和告警噪音都爆了;② 响应里带上 traceId,前端报错截图就能直接定位到链路日志;③ 兜底 handler 永远返回固定文案,因为曾经有个 SQL 异常的 message 把表名和字段名泄露到了前端。校验这边最有价值的是分组,新增时 id 必须为空、修改时 id 必须有值,这一条规则直接用 @Null(groups=Create) + @NotNull(groups=Update) 表达,省掉了大量手写 if。」

题 9:事务提交后再发 MQ 消息,怎么保证不丢、不重、不提前? ​

精简口述答案(90 秒版)

先说为什么不能直接在事务方法里发:@Transactional 方法体执行完还没提交,此刻发出去的消息,消费者可能比生产者先读到库,查不到数据;更糟的是如果后续提交失败回滚了,消息已经发出去无法撤回,直接数据不一致。

三种解法,一致性逐级增强:

① TransactionSynchronizationManager.registerSynchronization 的 afterCommit 回调,或者更优雅的 @TransactionalEventListener(phase = AFTER_COMMIT)。保证"提交后才发",但发送失败消息就丢了,适合允许丢失的通知类消息。

② 本地消息表:在同一个事务里把消息写进 local_message 表,提交后异步线程扫表发送、发送成功改状态,定时任务补偿未发送的。消息和业务数据同库同事务,不会丢,代价是多一张表和一个调度。

③ RocketMQ 事务消息:先发半消息(消费者不可见)→ 执行本地事务 → 提交或回滚半消息 → 超时未响应时 MQ 回查本地事务状态。原理和本地消息表一样,只是把可靠性托管给了 MQ。

消费侧无论哪种方案都必须幂等,因为上述所有方案保证的都是"至少一次"。常用做法是用业务唯一键建唯一索引,或 Redis setnx 做消费去重。

连环追问链

追问答题要点
@TransactionalEventListener 有哪些坑?① 默认只在有事务时触发,无事务场景要配 fallbackExecution = true;② AFTER_COMMIT 回调里再写库默认不会提交(事务已结束),需要 @Transactional(propagation = REQUIRES_NEW);③ 默认同步执行,回调耗时会算进接口响应时间
afterCommit 里抛异常会怎样?事务已经提交,异常不会导致回滚,只会向上抛给调用方。所以回调里必须自己 try-catch + 降级(落库待补偿),否则会出现"数据已提交但接口报错"的诡异现象
为什么不能用 @Async 解决?@Async 只保证异步,不保证在提交之后执行;而且新线程不共享事务上下文,主事务回滚了异步分支照样跑完
本地消息表怎么防止消息积压和重复扫描?状态 + 下次重试时间做索引,指数退避;超过最大重试次数转人工;扫表用分片或 select ... limit 配合乐观更新抢占
跨服务的强一致怎么做?Seata AT 模式(靠 undo_log 自动反向补偿)或 TCC(手写 Try/Confirm/Cancel)。但互联网业务更常见的是最终一致 + 对账兜底,不追求强一致
消费者幂等的几种实现?① 业务唯一键唯一索引,捕获 DuplicateKeyException 当成功;② Redis setnx + 过期时间(注意要在事务提交后才算消费成功);③ 状态机,只允许特定状态流转

✅ 项目关联话术

「我们订单服务一开始用的是 afterCommit 发消息,上线后发现发送失败率虽然只有万分之几,但对应的履约单就是不生成,只能人工补。后来改成本地消息表:下单事务里顺带插一条消息记录,独立线程扫表发送,失败按 1s/5s/30s 退避重试,超过 5 次告警。改完之后『消息丢失』这类工单归零,代价只是多了一张表。对一致性要求没那么高的场景——比如发优惠券到期提醒——我们还是用 @TransactionalEventListener,没必要上重武器。」

题 10:跨域配了为什么还是报错?拦截器里加的响应头为什么不生效? ​

精简口述答案(90 秒版)

跨域失败最常见的三个根因:

① 预检请求被拦截了。非简单请求(PUT/DELETE、Content-Type: application/json、带自定义 header)浏览器会先发 OPTIONS,这个请求不带业务 token,被登录拦截器或 Spring Security 拦掉返回 401/403,浏览器就报跨域。解法是拦截器里对 OPTIONS 方法直接放行,或者把 CORS 做成 CorsFilter 放在 Filter 链最外层。

② 配置层级搞错了。WebMvcConfigurer#addCorsMappings 生效在 DispatcherServlet 内部,排在 Spring Security 的过滤器链之后,和 Security 共存时必须改用 CorsFilter 并设 Ordered.HIGHEST_PRECEDENCE。

③ allowCredentials = true 同时 allowedOrigins = "*",这是 CORS 规范明确禁止的组合,Boot 2.4+ 直接启动报错,要改成 allowedOriginPatterns。

至于"拦截器里 response.setHeader 不生效",原因是响应已经提交(committed)。Controller 用 @ResponseBody 返回时,消息转换器已经把数据写进输出流并可能触发了 flush,此时响应头早已发出,postHandle 里再改就没用了。要加响应头应该放在 Filter 的前置逻辑或 preHandle,或者用 ResponseBodyAdvice#beforeBodyWrite(它在写出之前执行)。

连环追问链

追问答题要点
简单请求的判定条件?方法是 GET/HEAD/POST,且 Content-Type 只能是 text/plain、multipart/form-data、application/x-www-form-urlencoded,且无自定义头。满足就不发预检
怎么减少 OPTIONS 请求的开销?设 maxAge(如 3600),浏览器会缓存预检结果;或让接口退化成简单请求
前端拿不到自定义响应头(比如分页总数)?跨域下默认只暴露 6 个基本头,需要 addExposedHeader("X-Total-Count")
网关和应用都配了 CORS 会怎样?响应里出现两个 Access-Control-Allow-Origin,浏览器判定非法仍然报错。只能在一处配,通常统一收口到网关
response.isCommitted() 什么时候变 true?输出流被 flush、或写入量超过缓冲区(Tomcat 默认 8KB)、或显式调用 sendError/sendRedirect 之后
拦截器里想改响应体怎么办?改不了,拦截器拿不到返回值对象。要改用 ResponseBodyAdvice(拿到的是序列化前的对象)或 ContentCachingResponseWrapper(拿到字节)

✅ 项目关联话术

「我们踩过一个很迷惑的线上问题:本地跨域都正常,上了测试环境就失败。最后发现是网关和应用各配了一套 CORS,响应头重复了。处理方式是定规范——跨域只在网关配置,应用层统一删掉,同时在联调 checklist 里加了一条『先看 OPTIONS 请求的状态码,是 401 就是鉴权问题而不是跨域配置问题』,这一条帮团队省了很多无效排查。」

题 11:事务方法里调用远程接口有什么问题?事务边界该怎么设计? ​

精简口述答案(90 秒版)

问题是长事务。事务从 getConnection 开始到 commit 结束,整段时间连接都被独占。里面夹一次 RPC,连接占用时间就从几毫秒变成几百毫秒甚至超时等待。危害有四层递进:① 连接池耗尽,池里十几个连接全在等网络,其它请求拿不到连接直接报 CannotGetJdbcConnectionException;② 行锁长时间不释放,并发写相互阻塞,死锁和锁等待超时增加;③ undo log 和 MVCC 版本链变长,拖慢其它事务的快照读;④ 数据不一致,远程调用成功了但本地事务回滚,对方的状态改不回来。

设计原则就一条:事务只包数据库操作,把 RPC、MQ、文件 IO、耗时计算全部挪到事务外。具体三种手法:

  • 调用在前:先查询/校验/远程取数据,拿到结果后再开一个短事务只做写入;
  • 调用在后:用 afterCommit 回调或本地消息表,提交后再触发远程动作;
  • 实在要在中间:把事务拆成"前置事务 + RPC + 后置事务"两段,用状态机标记中间态,再配补偿任务。

另外要配套的是:给事务加 timeout、把方法粒度的 @Transactional 改成编程式 TransactionTemplate 精确包住最小代码块、以及在监控上对长事务做告警。

连环追问链

追问答题要点
怎么发现线上的长事务?① MySQL information_schema.innodb_trx 查 trx_started 超过阈值的;② SHOW ENGINE INNODB STATUS 看长事务和锁等待;③ APM 工具看 span 里事务区间包住了 HTTP/RPC span;④ 连接池监控(Druid/HikariCP)的活跃连接数长期偏高
TransactionTemplate 相比注解的优势?边界精确到代码块而不是整个方法,不受自调用失效影响,可以在一个方法里开多段短事务,还能拿到 TransactionStatus 手动 setRollbackOnly。代价是侵入业务代码、可读性略差
事务里加了 timeout = 3 就能防长事务吗?❌ 不能。timeout 转成的是 statement.setQueryTimeout(),只限制单条 SQL 的执行时间,对事务里夹的 RPC 耗时完全无约束
那 RPC 超时怎么兜?靠 RPC 框架自己的连接/读取超时 + 熔断降级,并且必须配置;没配超时的 HTTP 客户端是长事务最常见的源头
拆成多个短事务后一致性怎么保证?用状态机 + 幂等 + 补偿任务,接受最终一致;需要强一致才考虑 Seata/TCC
还有哪些东西不该放进事务?发 MQ、发邮件短信、写 Redis(回滚不了)、调第三方支付、生成大文件、循环里逐条远程调用

✅ 项目关联话术

「我们有个服务凌晨批量任务一跑,白天的在线接口就间歇性报『拿不到数据库连接』。排查发现批量任务的方法上挂着 @Transactional,方法里循环一万次、每次都调一个远程风控接口,单个事务持续好几分钟,Hikari 的 10 个连接全被占着。改造做了三件事:① 把远程调用全部提到事务外,先批量拿风控结果;② 用 TransactionTemplate 按 500 条一批开短事务写库;③ 给批量任务单独配了一个小的数据源连接池,和在线流量物理隔离。改完单次任务耗时从 6 分钟降到 40 秒,在线接口的连接等待告警也彻底消失了。」

✍️ 手撕代码【核心实现 + 边界 Case 自测清单 + 变种拓展题】 ​

题 1:手写一个规范的 starter(自动配置 + 条件装配 + 配置绑定) ​

考察点:自动配置类的四条铁律、@ConditionalOnMissingBean 为什么能让用户覆盖、.imports 与 spring.factories 的版本差异。

java
// ===== 模块一:xxx-spring-boot-autoconfigure =====

// ① 配置属性类:前缀化、带默认值
@Data
@ConfigurationProperties(prefix = "trace")
public class TraceProperties {
    /** 是否开启链路追踪 */
    private boolean enabled = true;
    /** traceId 在请求头中的名字 */
    private String headerName = "X-Trace-Id";
    /** 慢请求阈值(毫秒),超过则打 warn */
    private long slowThresholdMs = 1000L;
}

// ② 核心能力:不依赖 Spring,纯 POJO,便于单测和非 Boot 环境复用
public class TraceFilter extends OncePerRequestFilter {
    private final TraceProperties props;
    public TraceFilter(TraceProperties props) { this.props = props; }

    @Override
    protected void doFilterInternal(HttpServletRequest req, HttpServletResponse resp,
                                    FilterChain chain) throws ServletException, IOException {
        String traceId = req.getHeader(props.getHeaderName());
        if (traceId == null || traceId.isEmpty()) {
            traceId = UUID.randomUUID().toString().replace("-", "");
        }
        MDC.put("traceId", traceId);                 // 日志模板里用 %X{traceId} 输出
        resp.setHeader(props.getHeaderName(), traceId);
        long start = System.currentTimeMillis();
        try {
            chain.doFilter(req, resp);
        } finally {
            long cost = System.currentTimeMillis() - start;
            if (cost > props.getSlowThresholdMs()) {
                logger.warn("slow request uri={} cost={}ms", req.getRequestURI(), cost);
            }
            MDC.clear();                             // ⚠️ 必须清理,线程池会复用线程
        }
    }
}

// ③ 自动配置类:四条铁律全部体现
@AutoConfiguration                                    // Boot 2.7+ 的新注解,等价 @Configuration(proxyBeanMethods = false)
@EnableConfigurationProperties(TraceProperties.class) // 铁律1:绑定配置,不要让用户手动 @Bean
@ConditionalOnWebApplication(type = Type.SERVLET)     // 铁律2:声明生效环境
@ConditionalOnClass(OncePerRequestFilter.class)       // 铁律3:类不存在时整个配置静默跳过
@ConditionalOnProperty(prefix = "trace", name = "enabled",
                       havingValue = "true", matchIfMissing = true)  // 提供开关,默认开
public class TraceAutoConfiguration {

    @Bean
    @ConditionalOnMissingBean                    // 铁律4:留出覆盖口子
    public FilterRegistrationBean<TraceFilter> traceFilter(TraceProperties props) {
        FilterRegistrationBean<TraceFilter> bean =
                new FilterRegistrationBean<>(new TraceFilter(props));
        bean.addUrlPatterns("/*");
        bean.setOrder(Ordered.HIGHEST_PRECEDENCE);     // 最外层,保证所有日志都有 traceId
        return bean;
    }
}
text
# ===== 注册文件(Boot 2.7+ / 3.x)=====
# 路径:src/main/resources/META-INF/spring/
#       org.springframework.boot.autoconfigure.AutoConfiguration.imports
com.example.trace.TraceAutoConfiguration

# ===== 兼容 Boot 2.7 之前(两个文件可以同时存在)=====
# 路径:src/main/resources/META-INF/spring.factories
org.springframework.boot.autoconfigure.EnableAutoConfiguration=\
  com.example.trace.TraceAutoConfiguration
json
// ===== 让 IDE 有自动补全:
//      src/main/resources/META-INF/additional-spring-configuration-metadata.json
{
  "properties": [
    {
      "name": "trace.enabled",
      "type": "java.lang.Boolean",
      "description": "是否开启链路追踪。",
      "defaultValue": true
    },
    {
      "name": "trace.slow-threshold-ms",
      "type": "java.lang.Long",
      "description": "慢请求阈值(毫秒),超过则输出 warn 日志。",
      "defaultValue": 1000
    }
  ]
}

🔍 边界 Case 自测清单

#Case期望行为
1业务方只加依赖,不写任何配置Filter 自动生效,响应头带 X-Trace-Id
2业务方配 trace.enabled=false容器里没有 TraceFilter,/actuator/conditions 能看到 Negative match
3业务方自己定义了同名 FilterRegistrationBean用户的生效,自动配置的被跳过(@ConditionalOnMissingBean)
4在非 Web 工程(NONE 类型)里引入 starter不报错、不注册任何 Bean
5classpath 里没有 spring-web整个自动配置被 ASM 阶段过滤,不抛 NoClassDefFoundError
6请求头已带 X-Trace-Id复用上游传来的,不重新生成(链路能串起来)
7接口抛异常finally 仍执行,MDC 被清理,耗时日志照常输出
8并发压测后随机抽查日志不出现 traceId 串号(证明 MDC 清理有效)
9用 Boot 3.x 工程引入只带 spring.factories 的版本自动配置完全不生效,用来验证版本差异
10自动配置类上误加 @ComponentScan观察是否把 starter 包里的测试类也注册进去,理解为什么禁止

🚀 变种拓展题

  1. 增加 @ConditionalOnMissingClass("io.micrometer.tracing.Tracer"),当业务方已接入 Micrometer Tracing 时自动让位。
  2. 用 @AutoConfigureBefore(DispatcherServletAutoConfiguration.class) 控制顺序,并解释为什么对 Filter 注册其实没必要。
  3. 写一个 EnvironmentPostProcessor,在 Environment 准备阶段注入默认配置,对比它和 @ConditionalOnProperty(matchIfMissing = true) 的差异。
  4. 用 ApplicationContextRunner 写自动配置的单元测试(Boot 官方测试自动配置的标准方式),覆盖上表 Case 1/2/3。
  5. 把能力扩展成"starter + @EnableTrace 注解二选一",用 @Import(TraceConfiguration.class) 实现,并说明哪种更适合内部中间件。

题 2:手写声明式事务(连接绑定 + 传播行为 + savepoint) ​

考察点:事务为什么必须靠 ThreadLocal 绑定连接、REQUIRED/REQUIRES_NEW/NESTED 的实现差异、rollbackOnly 标记从哪来。

java
// ===== ① 注解 =====
@Target(ElementType.METHOD)
@Retention(RetentionPolicy.RUNTIME)
public @interface MyTransactional {
    Propagation propagation() default Propagation.REQUIRED;
    Class<? extends Throwable>[] rollbackFor() default {};
}

enum Propagation { REQUIRED, REQUIRES_NEW, NESTED, NOT_SUPPORTED }

// ===== ② 连接持有者:模拟 TransactionSynchronizationManager =====
public class ConnectionHolder {
    private static final ThreadLocal<Connection> CONN = new ThreadLocal<>();
    /** 事务嵌套层数,REQUIRED 加入时 +1,只有归零才真正提交 */
    private static final ThreadLocal<Integer> DEPTH = ThreadLocal.withInitial(() -> 0);
    /** 全局回滚标记,对应 Spring 的 rollbackOnly */
    private static final ThreadLocal<Boolean> ROLLBACK_ONLY = ThreadLocal.withInitial(() -> false);

    public static Connection get()             { return CONN.get(); }
    public static void bind(Connection c)      { CONN.set(c); }
    public static Connection unbind()          { Connection c = CONN.get(); CONN.remove(); return c; }
    public static boolean hasTransaction()     { return CONN.get() != null; }

    public static int incDepth()               { int d = DEPTH.get() + 1; DEPTH.set(d); return d; }
    public static int decDepth()               { int d = DEPTH.get() - 1; DEPTH.set(d); return d; }
    public static void markRollbackOnly()      { ROLLBACK_ONLY.set(true); }
    public static boolean isRollbackOnly()     { return ROLLBACK_ONLY.get(); }
    public static void clear() { CONN.remove(); DEPTH.remove(); ROLLBACK_ONLY.remove(); }
}
java
// ===== ③ 事务切面:核心逻辑 =====
@Aspect
@Component
public class MyTransactionAspect {

    private final DataSource dataSource;
    public MyTransactionAspect(DataSource ds) { this.dataSource = ds; }

    @Around("@annotation(tx)")
    public Object around(ProceedingJoinPoint pjp, MyTransactional tx) throws Throwable {
        return switch (tx.propagation()) {
            case REQUIRED      -> required(pjp, tx);
            case REQUIRES_NEW  -> requiresNew(pjp, tx);
            case NESTED        -> nested(pjp, tx);
            case NOT_SUPPORTED -> notSupported(pjp);
        };
    }

    /** REQUIRED:有就加入(只加深度,不开新事务),没有就新建 */
    private Object required(ProceedingJoinPoint pjp, MyTransactional tx) throws Throwable {
        if (ConnectionHolder.hasTransaction()) {
            ConnectionHolder.incDepth();
            try {
                return pjp.proceed();
            } catch (Throwable e) {
                if (needRollback(e, tx)) {
                    ConnectionHolder.markRollbackOnly();   // ⚠️ 关键:内层只打标记,不提交不回滚
                }
                throw e;
            } finally {
                ConnectionHolder.decDepth();
            }
        }
        return doInNewTransaction(pjp, tx);
    }

    /** REQUIRES_NEW:挂起外层连接,用一条全新连接跑,结束后恢复 */
    private Object requiresNew(ProceedingJoinPoint pjp, MyTransactional tx) throws Throwable {
        Connection suspended = ConnectionHolder.unbind();   // 挂起
        try {
            return doInNewTransaction(pjp, tx);
        } finally {
            if (suspended != null) {
                ConnectionHolder.bind(suspended);           // 恢复,保证外层后续 SQL 仍在原事务
            }
        }
    }

    /** NESTED:复用同一条连接,用 savepoint 做局部回滚 */
    private Object nested(ProceedingJoinPoint pjp, MyTransactional tx) throws Throwable {
        if (!ConnectionHolder.hasTransaction()) {
            return doInNewTransaction(pjp, tx);             // 外层无事务时退化为 REQUIRED
        }
        Connection conn = ConnectionHolder.get();
        Savepoint sp = conn.setSavepoint("SP_" + System.nanoTime());
        try {
            Object r = pjp.proceed();
            conn.releaseSavepoint(sp);                      // 成功:释放,不提交(跟随外层)
            return r;
        } catch (Throwable e) {
            if (needRollback(e, tx)) {
                conn.rollback(sp);                          // 只回滚到保存点,外层事务仍存活
            }
            throw e;
        }
    }

    /** NOT_SUPPORTED:挂起事务,以自动提交方式执行 */
    private Object notSupported(ProceedingJoinPoint pjp) throws Throwable {
        Connection suspended = ConnectionHolder.unbind();
        try {
            return pjp.proceed();
        } finally {
            if (suspended != null) ConnectionHolder.bind(suspended);
        }
    }

    /** 真正开启一个物理事务 */
    private Object doInNewTransaction(ProceedingJoinPoint pjp, MyTransactional tx) throws Throwable {
        Connection conn = dataSource.getConnection();
        boolean autoCommit = conn.getAutoCommit();
        conn.setAutoCommit(false);
        ConnectionHolder.bind(conn);
        try {
            Object result = pjp.proceed();
            if (ConnectionHolder.isRollbackOnly()) {
                conn.rollback();
                // 对应 Spring 的 UnexpectedRollbackException:内层标记了回滚,外层却想提交
                throw new IllegalStateException(
                        "Transaction rolled back because it has been marked as rollback-only");
            }
            conn.commit();
            return result;
        } catch (Throwable e) {
            conn.rollback();
            throw e;
        } finally {
            conn.setAutoCommit(autoCommit);   // 归还前复原,否则污染连接池里的连接
            ConnectionHolder.clear();
            conn.close();                     // 连接池场景下是归还而非真正关闭
        }
    }

    /** 回滚规则:默认只回滚 RuntimeException / Error,配了 rollbackFor 则扩大范围 */
    private boolean needRollback(Throwable e, MyTransactional tx) {
        if (e instanceof RuntimeException || e instanceof Error) return true;
        for (Class<? extends Throwable> c : tx.rollbackFor()) {
            if (c.isAssignableFrom(e.getClass())) return true;
        }
        return false;
    }
}
java
// ===== ④ Mapper 必须从 ConnectionHolder 取连接,否则事务不生效 =====
public class UserDao {
    private final DataSource ds;
    public UserDao(DataSource ds) { this.ds = ds; }

    private Connection conn() throws SQLException {
        // 对应 Spring 的 DataSourceUtils.getConnection(dataSource)
        Connection c = ConnectionHolder.get();
        return c != null ? c : ds.getConnection();   // 无事务时才新开(并自动提交)
    }

    public void insert(String name) throws SQLException {
        try (PreparedStatement ps = conn().prepareStatement("insert into t_user(name) values(?)")) {
            ps.setString(1, name);
            ps.executeUpdate();
        }   // ⚠️ 注意这里不能 close connection,它属于事务,由切面统一关闭
    }
}

🔍 边界 Case 自测清单

#Case期望行为
1单层事务,方法正常返回提交,数据落库
2单层事务,抛 RuntimeException回滚,数据不落库
3单层事务,抛受检异常且未配 rollbackFor提交(与 Spring 默认行为一致)
4外层 REQUIRED 调内层 REQUIRED,内层抛异常被外层 catch外层提交时抛 "marked as rollback-only",数据全无
5外层 REQUIRED 调内层 REQUIRES_NEW,内层抛异常被外层 catch内层回滚、外层照常提交
6外层 REQUIRED 调内层 NESTED,内层抛异常被外层 catch内层回滚到 savepoint,外层提交,只有外层数据落库
7外层回滚,内层 NESTED 已成功内层数据一起回滚(证明共用一个物理事务)
8外层回滚,内层 REQUIRES_NEW 已成功内层数据保留
9REQUIRES_NEW 执行完后,外层继续写库写入仍在外层事务中(证明挂起后正确恢复)
10同类内部直接调用带注解的方法事务不生效(切面没被触发),复现自调用失效
11连续两个请求复用同一线程第二个请求不受第一个的 rollbackOnly 影响(证明 clear() 有效)
12事务方法里起新线程写库新线程拿不到 ThreadLocal 连接,独立自动提交

🚀 变种拓展题

  1. 加上 readOnly 支持(conn.setReadOnly(true)),并验证它对 MySQL 只读事务的实际影响。
  2. 加上 isolation 支持,并实现"加入已有事务时内层 isolation 冲突则抛异常"(对应 Spring 的 validateExistingTransaction)。
  3. 实现 afterCommit 回调注册表,模拟 TransactionSynchronization,用它做"提交后发消息"。
  4. 把切面顺序调到事务外层/内层各跑一次,观察幂等切面放错层会导致什么问题。
  5. 用 AopContext.currentProxy() 和"注入自己"两种方式修复 Case 10,比较可读性与循环依赖风险。

题 3:统一响应体 + 全局异常 + 参数校验 + traceId 透传 ​

考察点:ResponseBodyAdvice 的时机、三种校验异常的区分、String 返回值的经典坑。

java
// ===== ① 统一响应体 =====
@Data
@AllArgsConstructor(access = AccessLevel.PRIVATE)
public class R<T> {
    private int code;
    private String message;
    private T data;
    private String traceId;
    private long timestamp;

    public static <T> R<T> ok(T data) {
        return new R<>(0, "success", data, MDC.get("traceId"), System.currentTimeMillis());
    }
    public static <T> R<T> fail(int code, String msg) {
        return new R<>(code, msg, null, MDC.get("traceId"), System.currentTimeMillis());
    }
}

// ===== ② 业务异常:带错误码,不打堆栈(构造时不填充栈轨迹,性能更好)=====
@Getter
public class BizException extends RuntimeException {
    private final int code;
    public BizException(int code, String message) {
        super(message, null, false, false);   // writableStackTrace = false
        this.code = code;
    }
}
java
// ===== ③ 自动包装返回值:免去每个方法都写 R.ok() =====
@RestControllerAdvice(basePackages = "com.example.controller")
public class ResponseWrapAdvice implements ResponseBodyAdvice<Object> {

    @Override
    public boolean supports(MethodParameter returnType, Class converterType) {
        // ⚠️ 坑:String 返回值走 StringHttpMessageConverter,包装成 R 会抛 ClassCastException
        //      所以要么在这里排除,要么在 beforeBodyWrite 里手动序列化成 JSON 字符串
        return !returnType.hasMethodAnnotation(NoWrap.class)
                && returnType.getParameterType() != R.class;
    }

    @Override
    public Object beforeBodyWrite(Object body, MethodParameter returnType,
                                  MediaType type, Class converterType,
                                  ServerHttpRequest req, ServerHttpResponse resp) {
        if (body instanceof String s) {
            // String 特殊处理:手动转 JSON 并改 Content-Type
            resp.getHeaders().setContentType(MediaType.APPLICATION_JSON);
            return JsonUtil.toJson(R.ok(s));
        }
        return R.ok(body);
    }
}
java
// ===== ④ 全局异常处理:三类校验异常必须全覆盖 =====
@Slf4j
@RestControllerAdvice
@Order(Ordered.LOWEST_PRECEDENCE)
public class GlobalExceptionHandler {

    /** @RequestBody + @Validated 校验失败 */
    @ExceptionHandler(MethodArgumentNotValidException.class)
    @ResponseStatus(HttpStatus.BAD_REQUEST)
    public R<Void> handleBodyValid(MethodArgumentNotValidException e) {
        return R.fail(400, joinFieldErrors(e.getBindingResult()));
    }

    /** 表单 / Query 绑定校验失败(BindException 是上面那个的父类,要放在后面) */
    @ExceptionHandler(BindException.class)
    @ResponseStatus(HttpStatus.BAD_REQUEST)
    public R<Void> handleBind(BindException e) {
        return R.fail(400, joinFieldErrors(e.getBindingResult()));
    }

    /** 方法参数上的单个约束:@RequestParam @Min(1),不属于 BindException 家族 */
    @ExceptionHandler(ConstraintViolationException.class)
    @ResponseStatus(HttpStatus.BAD_REQUEST)
    public R<Void> handleConstraint(ConstraintViolationException e) {
        String msg = e.getConstraintViolations().stream()
                .map(v -> v.getPropertyPath() + ":" + v.getMessage())
                .collect(Collectors.joining("; "));
        return R.fail(400, msg);
    }

    /** 业务异常:warn 级别,不打堆栈 */
    @ExceptionHandler(BizException.class)
    public R<Void> handleBiz(BizException e) {
        log.warn("biz error code={} msg={}", e.getCode(), e.getMessage());
        return R.fail(e.getCode(), e.getMessage());
    }

    /** 框架异常:给出语义明确的提示,而不是统一 500 */
    @ExceptionHandler(HttpRequestMethodNotSupportedException.class)
    @ResponseStatus(HttpStatus.METHOD_NOT_ALLOWED)
    public R<Void> handle405(HttpRequestMethodNotSupportedException e) {
        return R.fail(405, "不支持的请求方法:" + e.getMethod());
    }

    @ExceptionHandler(MaxUploadSizeExceededException.class)
    public R<Void> handleUpload(MaxUploadSizeExceededException e) {
        return R.fail(413, "文件超出大小限制");
    }

    /** 兜底:打完整堆栈,但绝不把内部信息返给前端 */
    @ExceptionHandler(Exception.class)
    @ResponseStatus(HttpStatus.INTERNAL_SERVER_ERROR)
    public R<Void> handleOther(Exception e) {
        log.error("system error", e);
        return R.fail(500, "系统繁忙,请稍后重试");
    }

    private String joinFieldErrors(BindingResult br) {
        return br.getFieldErrors().stream()
                .map(f -> f.getField() + ":" + f.getDefaultMessage())
                .collect(Collectors.joining("; "));
    }
}
java
// ===== ⑤ 使用:Controller 极度干净 =====
@RestController
@RequestMapping("/user")
@Validated                                  // 开启方法参数级校验
@RequiredArgsConstructor
public class UserController {
    private final UserService userService;

    @GetMapping("/{id}")
    public UserVO detail(@PathVariable @Min(1) Long id) {
        return userService.detail(id);      // 直接返回 VO,由 Advice 包装成 R
    }

    @PostMapping
    public Long create(@RequestBody @Validated(Create.class) UserDTO dto) {
        return userService.create(dto);
    }

    @GetMapping("/raw")
    @NoWrap                                  // 需要原始响应体时跳过包装
    public Map<String, Object> raw() { return Map.of("ok", true); }
}

🔍 边界 Case 自测清单

#Case期望行为
1正常返回 POJO自动包成 {code:0, data:{...}, traceId:"..."}
2返回值类型是 String返回合法 JSON 而不是 ClassCastException
3方法已经返回 R 类型不二次包装
4标了 @NoWrap原样返回
5@RequestBody 校验失败400 + 字段级错误信息
6@RequestParam @Min(1) 传 0400(证明 ConstraintViolationException 被兜住)
7表单绑定类型错误(age=abc)400 而不是 500
8抛 BizException业务码透传,日志是 warn 且无堆栈
9抛 NullPointerException返回固定文案,日志有完整堆栈,响应里无堆栈信息
10用 GET 访问只支持 POST 的接口405 + 明确提示,而不是兜底 500
11访问不存在的路径走 /error,不经过 @RestControllerAdvice,确认响应结构是否需要统一
12Filter 里主动抛异常同样不走 Advice,验证兜底方案
13响应里的 traceId 与日志中的一致证明 MDC 贯穿整个链路

🚀 变种拓展题

  1. 自定义 ErrorAttributes / BasicErrorController,让 404 和 Filter 异常也返回统一的 R 结构。
  2. 加国际化:错误码 + MessageSource,根据 Accept-Language 返回不同语言的 message。
  3. 把错误码做成枚举 + 统一注册表,并写 CI 校验禁止出现重复码。
  4. 实现"业务异常码分段"(如 1xxxx 用户域、2xxxx 订单域),并在网关按段做监控告警。
  5. 用 @WebMvcTest + MockMvc 为上表 Case 5/6/7/10 各写一条测试,体会测试切片的速度优势。

题 4:Filter + Interceptor 组合实现日志、鉴权与接口幂等 ​

考察点:请求体只能读一次、ThreadLocal 清理时机、幂等锁与事务的先后顺序。

java
// ===== ① Filter:缓存请求体 + 记录出入参 =====
@Slf4j
public class RequestLogFilter extends OncePerRequestFilter {

    @Override
    protected boolean shouldNotFilter(HttpServletRequest req) {
        String uri = req.getRequestURI();
        // multipart 不缓存,否则大文件直接把内存打爆
        return uri.startsWith("/actuator") || uri.startsWith("/static")
                || (req.getContentType() != null
                    && req.getContentType().startsWith("multipart/"));
    }

    @Override
    protected void doFilterInternal(HttpServletRequest req, HttpServletResponse resp,
                                    FilterChain chain) throws ServletException, IOException {
        ContentCachingRequestWrapper  wReq  = new ContentCachingRequestWrapper(req);
        ContentCachingResponseWrapper wResp = new ContentCachingResponseWrapper(resp);
        long start = System.currentTimeMillis();
        try {
            chain.doFilter(wReq, wResp);
        } finally {
            // ⚠️ 必须在 chain 之后取:缓存是在流被读取时才填充的
            log.info("uri={} cost={}ms req={} resp={}",
                    req.getRequestURI(),
                    System.currentTimeMillis() - start,
                    new String(wReq.getContentAsByteArray(), StandardCharsets.UTF_8),
                    new String(wResp.getContentAsByteArray(), StandardCharsets.UTF_8));
            wResp.copyBodyToResponse();   // ⚠️ 漏掉这行,客户端收到空响应体
        }
    }
}
java
// ===== ② Interceptor:鉴权 + 用户上下文 =====
@Component
public class AuthInterceptor implements HandlerInterceptor {

    @Override
    public boolean preHandle(HttpServletRequest req, HttpServletResponse resp, Object handler) {
        // 预检请求直接放行,否则浏览器会把 401 报成跨域错误
        if (HttpMethod.OPTIONS.matches(req.getMethod())) return true;
        // 静态资源等非 HandlerMethod 直接放行
        if (!(handler instanceof HandlerMethod hm)) return true;
        // 方法或类上标了 @NoAuth 则跳过
        if (hm.hasMethodAnnotation(NoAuth.class)
                || hm.getBeanType().isAnnotationPresent(NoAuth.class)) return true;

        String token = req.getHeader("Authorization");
        User user = tokenService.parse(token);
        if (user == null) throw new BizException(401, "未登录或登录已过期");

        RequiresRole role = hm.getMethodAnnotation(RequiresRole.class);
        if (role != null && !user.hasRole(role.value())) {
            throw new BizException(403, "无权限");
        }
        UserContext.set(user);     // 放进 ThreadLocal,供自定义参数解析器使用
        return true;
    }

    @Override
    public void afterCompletion(HttpServletRequest req, HttpServletResponse resp,
                                Object handler, Exception ex) {
        UserContext.clear();       // ⚠️ 必须在这里清理:异常路径下 postHandle 不会执行
    }
}
java
// ===== ③ 幂等切面:注意必须在事务外层 =====
@Aspect
@Component
@Order(Ordered.HIGHEST_PRECEDENCE + 10)   // 数字小 = 更外层,确保包住事务切面
@RequiredArgsConstructor
public class IdempotentAspect {

    private final StringRedisTemplate redis;

    @Around("@annotation(idem)")
    public Object around(ProceedingJoinPoint pjp, Idempotent idem) throws Throwable {
        HttpServletRequest req = ((ServletRequestAttributes)
                RequestContextHolder.currentRequestAttributes()).getRequest();
        String key = "idem:" + req.getHeader("X-Idempotent-Key");

        Boolean first = redis.opsForValue()
                .setIfAbsent(key, "1", idem.expire(), TimeUnit.SECONDS);
        if (!Boolean.TRUE.equals(first)) {
            throw new BizException(429, "请求正在处理中,请勿重复提交");
        }
        try {
            return pjp.proceed();
        } catch (Throwable e) {
            redis.delete(key);   // ⚠️ 失败要释放,否则用户永远无法重试
            throw e;
        }
        // ✅ 成功时不删 key,靠过期时间兜住短时间内的重复提交
    }
}
java
// ===== ④ 注册:顺序即优先级 =====
@Configuration
@RequiredArgsConstructor
public class WebConfig implements WebMvcConfigurer {

    private final AuthInterceptor authInterceptor;

    @Bean
    public FilterRegistrationBean<RequestLogFilter> requestLogFilter() {
        FilterRegistrationBean<RequestLogFilter> b =
                new FilterRegistrationBean<>(new RequestLogFilter());
        b.addUrlPatterns("/*");
        b.setOrder(Ordered.HIGHEST_PRECEDENCE + 100);   // 在 traceId Filter 之后
        return b;
    }

    @Override
    public void addInterceptors(InterceptorRegistry registry) {
        registry.addInterceptor(authInterceptor)
                .addPathPatterns("/**")
                .excludePathPatterns("/login", "/error", "/actuator/**",
                                     "/static/**", "/doc.html", "/v3/api-docs/**");
    }
}

🔍 边界 Case 自测清单

#Case期望行为
1POST JSON 请求日志打出完整 body,Controller 的 @RequestBody 仍能正常接收
2忘记 copyBodyToResponse()客户端收到空响应体——用来证明这行的必要性
3文件上传请求被 shouldNotFilter 跳过,内存不暴涨
4接口抛异常afterCompletion 执行,UserContext 被清理
5连续两个请求命中同一个 Tomcat 线程第二个请求读不到第一个的用户信息(无串号)
6跨域预检 OPTIONS直接放行,返回 200 而不是 401
7访问静态资源handler 不是 HandlerMethod,不触发鉴权
8标了 @NoAuth 的接口无 token 也能访问
9同一幂等 key 并发两次请求一个成功,一个返回 429
10幂等请求执行失败key 被删除,用户可立即重试
11幂等切面和事务切面顺序调反(幂等在内层)事务回滚了但 key 还在 → 复现"重试被拒绝"的 bug
12拦截器抛 BizException能被 @RestControllerAdvice 捕获(对比 Filter 抛异常则不能)

🚀 变种拓展题

  1. 日志里对手机号、身份证、银行卡做脱敏,用 Jackson 的 @JsonSerialize 或正则在输出前处理。
  2. 给日志加采样:只记录慢请求和异常请求的完整 body,正常请求只记 URI 和耗时。
  3. 把幂等 key 从请求头改成"用户 ID + 接口 + 业务参数 MD5",讨论两种方案的适用差异。
  4. 用自定义 HandlerMethodArgumentResolver 实现 @CurrentUser,让 Controller 不再手动调 UserContext.get()。
  5. 把鉴权从拦截器迁移到 Spring Security 的 Filter 链,对比两者在"OPTIONS 放行""异常处理"上的差异。

题 5:事务疑难场景复现与修复(五个经典坑的最小可运行版本) ​

题目:写一个 TxTrapService,用最小代码分别复现并修复下面五个线上真实踩过的事务坑,每个坑都要有"错误写法 + 现象 + 修复写法"三段。

核心实现

java
// ========== 坑 1:自调用导致事务失效 ==========
@Service
public class Trap1Service {

    @Resource
    private UserDao userDao;

    // ❌ 错误写法:外部方法没有事务,内部调用走的是 this,不经过代理
    public void outerWrong() {
        insertAndThrow();           // this.insertAndThrow(),@Transactional 完全失效
    }

    @Transactional(rollbackFor = Exception.class)
    public void insertAndThrow() {
        userDao.insert("trap1");
        throw new RuntimeException("boom");
    }

    // ✅ 修复方案 A:注入自己的代理(Boot 2.6+ 需要显式开启循环引用或用 @Lazy)
    @Lazy
    @Resource
    private Trap1Service self;

    public void outerFixA() {
        self.insertAndThrow();      // 走代理,事务生效
    }

    // ✅ 修复方案 B:AopContext(需要 @EnableAspectJAutoProxy(exposeProxy = true))
    public void outerFixB() {
        ((Trap1Service) AopContext.currentProxy()).insertAndThrow();
    }

    // ✅ 修复方案 C(最推荐):把事务方法拆到另一个 Bean,让调用天然跨代理
    //    见下面的 Trap1Helper
}

@Service
public class Trap1Helper {
    @Resource
    private UserDao userDao;

    @Transactional(rollbackFor = Exception.class)
    public void insertAndThrow() {
        userDao.insert("trap1");
        throw new RuntimeException("boom");
    }
}
java
// ========== 坑 2:内层吞异常 + REQUIRED,导致 UnexpectedRollbackException ==========
@Service
public class Trap2Service {

    @Resource private Trap2Inner inner;
    @Resource private UserDao userDao;

    // ❌ 错误写法:inner 是 REQUIRED,加入外层事务,抛异常后把事务标记成 rollbackOnly;
    //    外层 catch 住异常继续提交 → commit 时发现 rollbackOnly=true,抛 UnexpectedRollbackException
    @Transactional(rollbackFor = Exception.class)
    public void wrong() {
        userDao.insert("main");
        try {
            inner.subTask();
        } catch (Exception e) {
            log.warn("子任务失败,忽略", e);   // 以为忽略了,其实整个事务已经注定回滚
        }
    }

    // ✅ 修复方案 A:子任务独立事务,用 REQUIRES_NEW 挂起外层
    @Transactional(rollbackFor = Exception.class)
    public void fixA() {
        userDao.insert("main");
        try {
            inner.subTaskNewTx();     // REQUIRES_NEW:自己回滚,不污染外层
        } catch (Exception e) {
            log.warn("子任务失败,已独立回滚", e);
        }
    }

    // ✅ 修复方案 B:允许部分回滚,用 NESTED(savepoint)
    @Transactional(rollbackFor = Exception.class)
    public void fixB() {
        userDao.insert("main");
        try {
            inner.subTaskNested();    // NESTED:回滚到 savepoint,外层继续
        } catch (Exception e) {
            log.warn("子任务回滚到 savepoint", e);
        }
    }

    // ✅ 修复方案 C:子任务不需要事务,直接去掉 @Transactional,异常由外层决定
}

@Service
public class Trap2Inner {
    @Resource private UserDao userDao;

    @Transactional(rollbackFor = Exception.class)
    public void subTask() { userDao.insert("sub"); throw new RuntimeException("sub fail"); }

    @Transactional(propagation = Propagation.REQUIRES_NEW, rollbackFor = Exception.class)
    public void subTaskNewTx() { userDao.insert("sub"); throw new RuntimeException("sub fail"); }

    @Transactional(propagation = Propagation.NESTED, rollbackFor = Exception.class)
    public void subTaskNested() { userDao.insert("sub"); throw new RuntimeException("sub fail"); }
}
java
// ========== 坑 3:事务未提交就发 MQ / 发通知 ==========
@Service
public class Trap3Service {

    @Resource private OrderDao orderDao;
    @Resource private MqProducer mq;
    @Resource private ApplicationEventPublisher publisher;

    // ❌ 错误写法:send 在 commit 之前执行,消费者可能查不到这条订单;
    //    若后续抛异常,订单回滚了但消息已发出 → 数据不一致
    @Transactional(rollbackFor = Exception.class)
    public void wrong(Order order) {
        orderDao.insert(order);
        mq.send("order.created", order.getId());
        riskCheck(order);            // 这里抛异常,消息已经出去了
    }

    // ✅ 修复方案 A:TransactionSynchronizationManager 注册 afterCommit 回调
    @Transactional(rollbackFor = Exception.class)
    public void fixA(Order order) {
        orderDao.insert(order);
        riskCheck(order);
        TransactionSynchronizationManager.registerSynchronization(
            new TransactionSynchronization() {
                @Override
                public void afterCommit() {
                    mq.send("order.created", order.getId());
                }
            });
    }

    // ✅ 修复方案 B:事务事件(本质同上,但代码更干净,推荐)
    @Transactional(rollbackFor = Exception.class)
    public void fixB(Order order) {
        orderDao.insert(order);
        riskCheck(order);
        publisher.publishEvent(new OrderCreatedEvent(order.getId()));
    }
}

@Component
public class OrderEventListener {

    @Resource private MqProducer mq;

    // phase 默认就是 AFTER_COMMIT;注意:监听方法默认同步执行,发送失败会把异常抛给调用方(但事务已提交)
    @TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT)
    public void onCreated(OrderCreatedEvent event) {
        mq.send("order.created", event.getOrderId());
    }
}

// ✅ 修复方案 C(最终一致):本地消息表 —— 消息行和业务数据同事务落库,
//    定时任务扫描未发送的消息行并投递,发送成功后更新状态,天然支持重试。
java
// ========== 坑 4:异步线程里丢事务 / 丢上下文 ==========
@Service
public class Trap4Service {

    @Resource private UserDao userDao;
    @Resource private Trap4Async asyncBean;

    // ❌ 错误写法 1:以为子线程能加入外层事务 —— 事务是 ThreadLocal 绑定的,子线程拿不到连接,
    //    userDao 会从连接池取新连接,自成一个自动提交事务,外层回滚它也不回滚
    @Transactional(rollbackFor = Exception.class)
    public void wrong() {
        userDao.insert("main");
        asyncBean.asyncInsert();     // 另一个线程,事务上下文丢失
        throw new RuntimeException("boom");   // main 回滚,async 那条留下了
    }

    // ❌ 错误写法 2:@Async + @Transactional 叠在同一个方法上,顺序依赖代理嵌套,
    //    容易出现"事务在异步外层"的误解,不要这样写。

    // ✅ 修复方案:异步任务自带独立事务,并且只在外层事务提交后才触发
    @Transactional(rollbackFor = Exception.class)
    public void fix() {
        userDao.insert("main");
        TransactionSynchronizationManager.registerSynchronization(
            new TransactionSynchronization() {
                @Override
                public void afterCommit() { asyncBean.asyncInsertWithOwnTx(); }
            });
    }
}

@Component
public class Trap4Async {

    @Resource private UserDao userDao;

    @Async("bizExecutor")
    public void asyncInsert() { userDao.insert("async"); }

    // 异步方法内部再调一个带事务的 Bean,保证异步任务自己是一个完整事务单元
    @Async("bizExecutor")
    public void asyncInsertWithOwnTx() { txHelper.doInsert(); }

    @Resource private Trap4TxHelper txHelper;
}
java
// ========== 坑 5:长事务 + 事务里调远程接口 ==========
@Service
public class Trap5Service {

    @Resource private OrderDao orderDao;
    @Resource private RemoteClient remote;
    @Resource private Trap5Tx tx;

    // ❌ 错误写法:RPC 放在事务内,远程超时 3s,事务就占着连接和行锁 3s;
    //    高并发下连接池被打满、锁等待超时、死锁概率飙升
    @Transactional(rollbackFor = Exception.class)
    public void wrong(Order order) {
        orderDao.insert(order);
        remote.notifyWarehouse(order);    // 慢 + 不可回滚
        orderDao.updateStatus(order.getId(), "NOTIFIED");
    }

    // ✅ 修复写法:事务只包 DB 操作,RPC 挪到事务外,用状态机 + 重试补偿
    public void fix(Order order) {
        tx.saveOrder(order);                                   // 事务 1:只写库
        try {
            remote.notifyWarehouse(order);                     // 事务外:慢调用
            tx.markNotified(order.getId());                    // 事务 2:只写库
        } catch (Exception e) {
            tx.markNotifyFailed(order.getId());                // 失败落状态,交给定时补偿
        }
    }
}

@Service
public class Trap5Tx {
    @Resource private OrderDao orderDao;

    @Transactional(rollbackFor = Exception.class)
    public void saveOrder(Order order) { orderDao.insert(order); }

    @Transactional(rollbackFor = Exception.class)
    public void markNotified(Long id) { orderDao.updateStatus(id, "NOTIFIED"); }

    @Transactional(rollbackFor = Exception.class)
    public void markNotifyFailed(Long id) { orderDao.updateStatus(id, "NOTIFY_FAILED"); }
}

边界 Case 自测清单

#Case期望行为
1调用 Trap1Service.outerWrong()数据留在库里,证明自调用事务失效
2调用 outerFixA() / outerFixB()数据回滚,证明走代理后事务生效
3Trap1Service 的方法改成 privateJDK/CGLIB 代理都拦不住,事务同样失效
4调用 Trap2Service.wrong()抛 UnexpectedRollbackException,且 main 这条也没进库
5调用 fixA()main 提交成功,sub 独立回滚
6调用 fixB()main 提交成功,sub 回滚到 savepoint
7fixB() 换成 Spring 默认的 DataSourceTransactionManager 以外的 JPA 管理器NESTED 不被支持,抛异常——说明 NESTED 依赖底层 savepoint 能力
8Trap3Service.wrong() 在 riskCheck 抛异常订单回滚但消息已发出,消费者查不到订单
9fixA() / fixB() 在 riskCheck 抛异常消息不发送,afterCommit 根本不会执行
10@TransactionalEventListener 的发布点不在事务内事件默认被丢弃,需要 fallbackExecution = true 才执行
11afterCommit 里 MQ 发送失败事务已提交无法回滚,必须靠本地消息表/重试补偿
12调用 Trap4Service.wrong()main 回滚、async 那条残留,复现跨线程事务失效
13异步线程里读 TransactionSynchronizationManager.isActualTransactionActive()返回 false
14异步线程里读 UserContext / MDC traceId读不到,需要用 TaskDecorator 显式传递
15Trap5Service.wrong() 并发 200 QPS,远程接口 sleep 3s连接池耗尽、CannotGetJdbcConnectionException
16Trap5Service.fix() 同样压测连接占用时间降到毫秒级,失败请求落 NOTIFY_FAILED 可补偿

🚀 变种拓展题

  1. 把坑 3 的方案 C 完整实现一遍:建 local_message 表,写入与业务同事务,定时任务扫描 + 乐观锁更新状态 + 最大重试次数。
  2. 给异步线程池配 TaskDecorator,把 MDC 的 traceId 和 UserContext 透传到子线程,并在任务结束时清理。
  3. 用 TransactionSynchronization 的 afterCompletion(int status) 实现"无论成功失败都清理缓存",对比 afterCommit 的差异。
  4. 在坑 2 的基础上加一层:外层 REQUIRED → 中层 REQUIRES_NEW → 内层 NESTED,画出连接、savepoint 和回滚边界。
  5. 把坑 5 的补偿改成"状态机 + 延迟队列",讨论幂等键的设计以及重复通知如何去重。

⚠️ 当日踩坑实录 ​

❌ 坑✅ 解决方案
自己写的 starter 放在 META-INF/spring.factories 里,升级 Boot 3 后完全不生效Boot 3 彻底移除了 spring.factories 的自动配置支持,改用 META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports,一行一个全限定类名;要兼容 2.7 就两个文件都保留
把 starter 的自动配置类加上 @ComponentScan 或和主程序放同包,导致重复装配自动配置类不要写 @ComponentScan,也不要和业务代码同包;只通过 .imports 注册,用 @EnableConfigurationProperties + @Bean 显式声明
starter 里 @Bean 直接覆盖了用户自己定义的同名 Bean所有对外暴露的 Bean 都加 @ConditionalOnMissingBean,让用户自定义优先;自动配置本身在用户配置之后才解析,这是设计如此
@ConditionalOnMissingBean 时而生效时而不生效条件判断依赖注册顺序,必须用 @AutoConfigureAfter / @AutoConfigureBefore 固定顺序;同时注意它只能看到"已注册的 Bean 定义",不要在普通 @Configuration 上用它
配置项在 IDE 里没有自动提示加 spring-boot-configuration-processor 依赖,编译后生成 META-INF/spring-configuration-metadata.json
@ConfigurationProperties 的字段一直是 null要么类上加 @EnableConfigurationProperties,要么该类本身是 Bean;另外属性名要满足松散绑定规则,traceEnabled 对应 trace-enabled
打出来的 jar 用 java -cp 跑不起来,报找不到主类Boot 可执行 jar 是嵌套结构(BOOT-INF/classes、BOOT-INF/lib),标准 ClassLoader 不认;必须 java -jar,由 JarLauncher 用自定义 ClassLoader 加载
application.yml 和 application-dev.yml 同一个 key,以为 profile 的不生效profile 专属配置优先级高于默认配置;但命令行参数 --key=value 和环境变量优先级更高,排查时用 /actuator/env 看最终生效值
@Transactional 加了但完全没回滚排查四件事:① 是不是自调用(没走代理)② 方法是不是 private/final/static ③ 异常是不是被 catch 吞了 ④ 抛的是不是受检异常(默认只回滚 RuntimeException 和 Error)
抛了自定义受检异常,数据库却提交了显式写 @Transactional(rollbackFor = Exception.class),这应该成为团队强制规范
内层方法 catch 了异常,外层提交时抛 UnexpectedRollbackException内层是 REQUIRED 时和外层共用一个物理事务,内层异常会把事务标记 rollbackOnly;要么内层改 REQUIRES_NEW/NESTED,要么内层去掉事务注解
用 REQUIRES_NEW 后出现死锁/锁等待超时外层事务持有行锁且未提交,内层新事务去改同一行必然等锁;要么避免同一行,要么改成 NESTED,要么重新设计事务边界
NESTED 在 JPA 环境报不支持NESTED 依赖 JDBC savepoint,JpaTransactionManager 默认不支持;只有 DataSourceTransactionManager 这类支持 savepoint 的才行
以为 readOnly = true 能禁止写操作它只是给底层驱动和 ORM 的优化提示(MySQL 下会跳过 Hibernate 脏检查、让从库路由生效),并不保证抛异常;真要只读得靠数据库账号权限
@Transactional(timeout = 5) 对慢 HTTP 调用没用timeout 只作用于事务内的数据库操作,RPC 阻塞不受控;这也是"事务里不要调远程接口"的原因
MySQL 隔离级别答成默认 RCInnoDB 默认是 REPEATABLE READ,RC 是 Oracle/PostgreSQL/SQL Server 的默认值
以为 RR 完全解决了幻读RR 下快照读靠 MVCC 看不到新行,当前读(for update、lock in share mode、update/delete)靠间隙锁阻止插入;但"先快照读再当前读"的组合仍能观察到幻读现象
事务里发 MQ,消费者偶发查不到数据消息在 commit 之前就发出去了;改成 afterCommit 回调、@TransactionalEventListener(AFTER_COMMIT) 或本地消息表
@TransactionalEventListener 的监听方法一次都没执行发布点不在事务上下文里时事件默认被丢弃,需要 fallbackExecution = true;另外监听方法默认同步执行,别在里面做慢操作
@Async 方法里的 DB 操作没跟着外层事务回滚事务上下文是 ThreadLocal,跨线程必丢;异步任务要自带独立事务,并在 afterCommit 里触发
异步线程里 traceId / 用户上下文丢失给线程池配 TaskDecorator,在 decorate 里复制 MDC 和 ThreadLocal,任务结束时清理
@Retryable 和 @Transactional 叠在同一个方法上,重试时数据状态错乱重试必须在事务外层:让 @Retryable 的方法去调另一个 Bean 的 @Transactional 方法,否则第一次失败的事务已经 rollbackOnly,重试在同一事务里只会继续失败
@RequestBody 接收到的字段全是 null检查 Content-Type 是否 application/json、是否有无参构造和 setter、字段名是否匹配(或加 @JsonProperty),以及是不是前面有 Filter 已经读过流
一个方法上写了两个 @RequestBody请求体是只读一次的流,第二个必然失败;多个参数就封装到同一个 DTO
@RequestParam 收不到参数,报 "Name for argument not specified"编译时没开 -parameters,参数名被擦除;显式写 @RequestParam("name") 或在 maven-compiler-plugin 里加编译参数
在 Filter 里 getInputStream() 读了 body,Controller 的 @RequestBody 就空了用 ContentCachingRequestWrapper 包装并把 wrapper 传给 doFilter;注意必须先 doFilter 之后才能拿到缓存内容
用 ContentCachingResponseWrapper 后客户端收到空响应体必须在 finally 里调 copyBodyToResponse() 把缓存回写到真实响应流
文件上传接口被日志 Filter 拖垮,内存暴涨在 shouldNotFilter 里跳过 multipart/form-data,或只记录文件名与大小
@Validated 加了但校验不生效校验 @RequestBody 用 @Valid/@Validated 标在参数上;校验方法参数(@RequestParam、Service 方法)必须在类上加 @Validated,走的是 AOP 而不是参数解析器
参数校验异常捕获不全三种异常要分别处理:@RequestBody → MethodArgumentNotValidException、表单对象 → BindException、方法参数 → ConstraintViolationException
@ExceptionHandler 捕获不到 Filter 里抛的异常@RestControllerAdvice 只覆盖 DispatcherServlet 内部;Filter 异常要靠 ErrorPageFilter 或自己在 Filter 里 try-catch 后手写 JSON
配了 @ExceptionHandler(Exception.class) 后 404 返回变成 500精确匹配优先于宽泛匹配,但 NoHandlerFoundException 默认不抛(走 BasicErrorController);要接管得先配 spring.mvc.throw-exception-if-no-handler-found=true 并关掉静态资源默认映射
全局异常处理器里拿不到 traceIdtraceId 在 Filter 里放进 MDC,异常处理器里用 MDC.get("traceId") 取;注意一定要在 finally 里 MDC.clear(),否则线程复用会串号
统一响应体包装后 Swagger / Actuator 接口全坏了ResponseBodyAdvice 的 supports() 里按包名或注解过滤,排除 springfox、actuator、String 返回值等情况
返回 String 类型被包装后报类型转换异常StringHttpMessageConverter 拿到的是对象而非字符串;在 beforeBodyWrite 里对 String 单独序列化成 JSON 字符串返回
跨域配了 @CrossOrigin 还是报 CORS 错误预检 OPTIONS 请求可能被自定义 Filter 或 Security 直接拦掉了;放行 OPTIONS,并确认 allowCredentials=true 时不能用 allowedOrigins("*")(要用 allowedOriginPatterns)
拦截器里 response.setHeader() 不生效响应已经提交(body 写完)之后改 header 无效;跨域头要在 Filter 层(CorsFilter)设置,且该 Filter 的 order 要足够靠前
加了 @EnableWebMvc 之后静态资源 404、Jackson 配置全失效@EnableWebMvc 会让 Boot 的 WebMvcAutoConfiguration 失效;Boot 项目要扩展 MVC 就只实现 WebMvcConfigurer,不要加这个注解
自定义消息转换器被默认的覆盖用 extendMessageConverters 调整顺序或直接改 MappingJackson2HttpMessageConverter 的 ObjectMapper,比 configureMessageConverters(会清空默认列表)更安全
异步 Controller(DeferredResult/Callable)里拿不到 ThreadLocal 上下文异步分发后换了线程,拦截器的 postHandle/afterCompletion 时机也变了;需要实现 AsyncHandlerInterceptor 并在 afterConcurrentHandlingStarted 里处理
单元测试里 @SpringBootTest 启动太慢只测 Controller 用 @WebMvcTest + @MockBean,只测 DAO 用 @DataJpaTest/@MybatisTest,按需做测试切片
测试里数据没插进去/插进去了没清理@Transactional 标在测试类上默认测试后回滚;需要真实提交就加 @Commit 或 @Rollback(false),但要自己负责清理

📌 当日复盘任务 ​

Spring Boot 自动装配

  • [ ] 能完整复述自动装配七步链路:@SpringBootApplication → @EnableAutoConfiguration → @Import(AutoConfigurationImportSelector) → 读 .imports → 去重/排除 → 条件过滤 → 注册 Bean 定义
  • [ ] 说清 Boot 2.7 之前 / 2.7 / 3.x 三个阶段注册文件的差异,以及为什么要换
  • [ ] 列出 @ConditionalOnXxx 家族至少 6 个注解,并说明各自的判断时机
  • [ ] 手写一个规范 starter(-spring-boot-starter 命名 + @ConditionalOnMissingBean + @ConfigurationProperties + metadata.json)并在另一个项目里引入验证
  • [ ] 解释 @AutoConfigureAfter 为什么对 @ConditionalOnMissingBean 的结果有决定性影响

启动流程与配置

  • [ ] 能讲出 SpringApplication.run() 的主干步骤,以及四种启动回调(ApplicationContextInitializer、ApplicationListener、ApplicationRunner、CommandLineRunner)的执行时机
  • [ ] 说清 Boot 可执行 jar 的嵌套结构和 JarLauncher 的作用,解释为什么不能用 java -cp
  • [ ] 背出配置来源优先级前 5 名,并用 /actuator/env 实际验证一次覆盖关系
  • [ ] 说明内嵌容器是怎么被自动配置出来的,以及换成 Undertow / Jetty 要改什么

Spring 事务

  • [ ] 能画出声明式事务的完整链路:代理 → TransactionInterceptor → getTransaction → doBegin → 业务 → commit/rollback
  • [ ] 默认 @Transactional 失效的四种原因能脱口而出,并分别写出修复代码
  • [ ] 七种传播行为的"有无外层事务"矩阵能完整填出
  • [ ] 能说清 REQUIRES_NEW 与 NESTED 在连接、savepoint、挂起、回滚边界上的区别
  • [ ] 复现一次 UnexpectedRollbackException 并用三种方案修掉
  • [ ] 说清 MySQL 默认隔离级别,以及 RR 下快照读/当前读与间隙锁对幻读的影响
  • [ ] 说清 readOnly、timeout、isolation 的真实作用边界和失效场景
  • [ ] 能给出"事务里发 MQ"的三种正确做法,并说明各自的一致性强度
  • [ ] 能解释事务边界设计原则:事务只包 DB、RPC 挪到事务外、@Retryable 在事务外层

MVC 请求链路

  • [ ] 能完整复述 doDispatch 的九步流程和九大组件的职责
  • [ ] 说清 HandlerMapping 怎么用 RequestMappingInfo 匹配,以及 405/415/406 分别在哪一步产生
  • [ ] 说清 @RequestParam、@PathVariable、@RequestBody 的解析器差异,以及 @RequestBody 为什么只能有一个
  • [ ] 能对比 Filter / Interceptor / AOP 的作用域、执行顺序和异常可捕获性,并给出选型结论
  • [ ] 说清 WebMvcConfigurer 与 @EnableWebMvc 的区别,解释后者为什么会"搞坏"Boot 项目
  • [ ] 能解释请求体只读一次的原因,并用 ContentCachingRequestWrapper 实际跑通一次日志记录

异常、校验与跨域

  • [ ] 写出统一响应体 + 全局异常 + 参数校验 + traceId 的完整骨架,并通过自测清单里的边界 Case
  • [ ] 区分参数校验的三种异常类型,并各写一个 @ExceptionHandler
  • [ ] 说清 @RestControllerAdvice 的覆盖边界(为什么接不到 Filter 异常)
  • [ ] 完整讲一遍 CORS 预检流程,以及三种配置方式的优先级和踩坑点

测试与版本

  • [ ] 用 @WebMvcTest 写一个 Controller 切片测试,用 @MockBean 替掉 Service
  • [ ] 验证测试类上 @Transactional 的默认回滚行为,再用 @Rollback(false) 对比
  • [ ] 整理 Boot 3 / Spring 6 的关键变化(JDK 17 基线、jakarta.*、spring.factories 移除、AOT/Native、RestClient、可观测性)