Appearance
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 与 Interceptor | Filter 是 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 的思路是:先把最常见的配置写死成默认值,只有你不满意时才覆盖。
| 约定 | 默认值 | 覆盖方式 |
|---|---|---|
| 端口 | 8080 | server.port=9090 |
| 配置文件名 | application.yml / application.properties | spring.config.name |
| 扫描范围 | 启动类所在包及其子包 | @ComponentScan(basePackages = ...) |
| JSON 序列化 | Jackson | 换 Gson / Fastjson 需手动配 Converter |
| 数据源连接池 | HikariCP | spring.datasource.type |
| 事务管理器 | DataSourceTransactionManager(单数据源) | 自己注册一个 PlatformTransactionManager |
② starter 启动器:场景化依赖 + 自动配置的组合包
一个标准 starter 通常是空 jar,它只做两件事:聚合依赖 + 触发自动配置。
| starter | 带来的能力 |
|---|---|
spring-boot-starter-web | Spring MVC + 内嵌 Tomcat + Jackson + 校验(Boot 2.3 起校验被拆出去,要单独引 validation) |
spring-boot-starter-jdbc | DataSource + JdbcTemplate + DataSourceTransactionManager |
spring-boot-starter-aop | spring-aop + aspectjweaver,@EnableAspectJAutoProxy 自动开 |
spring-boot-starter-test | JUnit5 + 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.6 | META-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 的实现家族与最容易踩的时机问题。
| 注解 | 判断内容 | 典型用途 |
|---|---|---|
@ConditionalOnClass | classpath 上有某个类 | 引了 redis 依赖才装配 RedisTemplate |
@ConditionalOnMissingClass | classpath 上没有某个类 | 两种实现互斥时选其一 |
@ConditionalOnBean | 容器里已有某类型/名称的 Bean | 有 DataSource 才配事务管理器 |
@ConditionalOnMissingBean | 容器里还没有 | 自动配置兜底的核心 |
@ConditionalOnProperty | 配置项值匹配(matchIfMissing 控制缺省行为) | 功能开关 xxx.enabled=true |
@ConditionalOnResource | 存在某个资源文件 | 有 mybatis-config.xml 才加载 |
@ConditionalOnWebApplication | 是否 Web 环境(SERVLET / REACTIVE) | Web 专属组件 |
@ConditionalOnExpression | SpEL 表达式为 true | 复合条件 #{'${a}'=='x' and ${b}} |
@ConditionalOnSingleCandidate | 某类型 Bean 只有一个候选或有 @Primary | 多数据源下选主数据源 |
@ConditionalOnJava | JDK 版本范围 | 适配不同 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 / ApplicationRunner | refresh 完成之后 | 数据预热、一次性任务(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() 里初始化):
| 组件 | 作用 | 默认实现与关键细节 |
|---|---|---|
HandlerMapping | URL → 处理器 | 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 Type | consumes 不匹配:用 @RequestBody 却没发 Content-Type: application/json |
| 406 Not Acceptable | produces 与请求 Accept 不匹配 |
| 400 Bad Request | 参数绑定失败/必填缺失/JSON 解析失败 |
RequestMappingHandlerAdapter 的执行三段式:
text
① HandlerMethodArgumentResolver(30+ 个)逐个 supportsParameter() → resolveArgument()
② 反射 invoke 目标方法(InvocableHandlerMethod)
③ HandlerMethodReturnValueHandler 处理返回值
├─ RequestResponseBodyMethodProcessor → @ResponseBody,走 MessageConverter 直接写 response
├─ ModelAndViewMethodReturnValueHandler → 传统视图
└─ 异步类型(Callable / DeferredResult)→ 交给 WebAsyncManager10. 参数绑定:五个注解的边界
| 注解 | 取值来源 | 典型用法 | 易错点 |
|---|---|---|---|
@RequestParam | query string 或 form 表单 | ?page=1、application/x-www-form-urlencoded | 默认 required=true,缺失直接 400;给默认值要写 defaultValue |
@PathVariable | URL 路径变量 | /order/{id} | 路径里含 .(如邮箱、版本号)会被当后缀截断,需用正则 {email:.+} |
@RequestBody | 请求体,走 MessageConverter | JSON / XML | 一个方法只能有一个,因为输入流只能读一次;GET 请求带 body 不规范,很多网关会丢弃 |
@ModelAttribute | 表单/query 绑定到对象 | 复杂表单 | 省略不写时,非简单类型参数默认就是它,所以 GET 多参数可以直接用对象接 |
@RequestHeader | 请求头 | @RequestHeader("X-Trace-Id") | 头名大小写不敏感 |
@CookieValue | Cookie | sessionId | — |
无注解参数的默认行为(很多人没搞清):
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 串行化 | ✅ | ✅ | ✅ | 性能极差,基本不用 |
(✅ = 已避免该问题,❌ = 仍存在)
必须说清的两个点:
- MySQL 默认是 RR,不是 RC。说"RC 是 MySQL 默认"是常见错误答案。
- 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_NEW | NESTED |
|---|---|---|
| 底层机制 | 挂起外层事务,另取一条数据库连接 | 同一条连接,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-starter | spring-boot-starter-xxx 是官方保留前缀,第三方用会误导使用者 |
依赖全部 optional 或 provided | starter 不能把自己的依赖强塞给使用方,靠 @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三个必须知道的推论:
- 挂起不释放数据库连接,外层连接仍被持有 →
REQUIRES_NEW嵌套层数越多,占用的连接数越多。连接池默认maximum-pool-size=10,循环里调REQUIRES_NEW极易把连接池耗尽并死等(表现为接口大面积超时,堆栈停在HikariPool.getConnection)。 - 挂起期间外层的锁不会释放 → 内层改同一行必死锁(见基础原理 12 的例子)。
- 事务同步器(
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 |
isolation | conn.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 后置| 维度 | Filter | Interceptor | AOP 切面 |
|---|---|---|---|
| 规范/实现 | Servlet 规范 | Spring MVC | Spring 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| 层次 | 覆盖范围 | 要点 |
|---|---|---|
@RestControllerAdvice | Controller 内抛出的异常 | 可用 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.validation | org.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 |
| 自定义校验器里注入 Bean | ConstraintValidator 由 Spring 管理(LocalValidatorFactoryBean),可以直接 @Autowired |
16. 消息转换器与 Jackson 配置
RequestResponseBodyMethodProcessor 根据 Content-Type / Accept 从 HttpMessageConverter 列表里挑一个:
| Converter | 负责 |
|---|---|
MappingJackson2HttpMessageConverter | application/json |
StringHttpMessageConverter | text/plain,默认 ISO-8859-1,中文乱码就是它 |
ByteArrayHttpMessageConverter | application/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
└─ 通过后才发真正的请求| 配置方式 | 生效位置 | 适用场景 |
|---|---|---|
@CrossOrigin | Controller / 方法级 | 少量接口开放 |
WebMvcConfigurer#addCorsMappings | MVC 层(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.x | Boot 3.x / Spring 6 |
|---|---|---|
| JDK | 8 / 11 | 最低 17 |
| 包名 | javax.servlet / javax.validation | jakarta.*,升级时全量替换 import |
| 自动配置注册文件 | spring.factories 仍可用 | 只认 AutoConfiguration.imports |
| 路径匹配 | AntPathMatcher,/user 与 /user/ 等价 | 默认 PathPatternParser,尾斜杠不再等价(setUseTrailingSlashMatch 已废弃),升级后易出 404 |
| 虚拟线程 | 无 | spring.threads.virtual.enabled=true(Boot 3.2+),IO 密集型吞吐大涨 |
| 可观测性 | Sleuth + Micrometer | Micrometer 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.TraceAutoConfigurationjson
// ===== 让 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 |
| 5 | classpath 里没有 spring-web | 整个自动配置被 ASM 阶段过滤,不抛 NoClassDefFoundError |
| 6 | 请求头已带 X-Trace-Id | 复用上游传来的,不重新生成(链路能串起来) |
| 7 | 接口抛异常 | finally 仍执行,MDC 被清理,耗时日志照常输出 |
| 8 | 并发压测后随机抽查日志 | 不出现 traceId 串号(证明 MDC 清理有效) |
| 9 | 用 Boot 3.x 工程引入只带 spring.factories 的版本 | 自动配置完全不生效,用来验证版本差异 |
| 10 | 自动配置类上误加 @ComponentScan | 观察是否把 starter 包里的测试类也注册进去,理解为什么禁止 |
🚀 变种拓展题
- 增加
@ConditionalOnMissingClass("io.micrometer.tracing.Tracer"),当业务方已接入 Micrometer Tracing 时自动让位。 - 用
@AutoConfigureBefore(DispatcherServletAutoConfiguration.class)控制顺序,并解释为什么对 Filter 注册其实没必要。 - 写一个
EnvironmentPostProcessor,在 Environment 准备阶段注入默认配置,对比它和@ConditionalOnProperty(matchIfMissing = true)的差异。 - 用
ApplicationContextRunner写自动配置的单元测试(Boot 官方测试自动配置的标准方式),覆盖上表 Case 1/2/3。 - 把能力扩展成"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 已成功 | 内层数据保留 |
| 9 | REQUIRES_NEW 执行完后,外层继续写库 | 写入仍在外层事务中(证明挂起后正确恢复) |
| 10 | 同类内部直接调用带注解的方法 | 事务不生效(切面没被触发),复现自调用失效 |
| 11 | 连续两个请求复用同一线程 | 第二个请求不受第一个的 rollbackOnly 影响(证明 clear() 有效) |
| 12 | 事务方法里起新线程写库 | 新线程拿不到 ThreadLocal 连接,独立自动提交 |
🚀 变种拓展题
- 加上
readOnly支持(conn.setReadOnly(true)),并验证它对 MySQL 只读事务的实际影响。 - 加上
isolation支持,并实现"加入已有事务时内层 isolation 冲突则抛异常"(对应 Spring 的validateExistingTransaction)。 - 实现
afterCommit回调注册表,模拟TransactionSynchronization,用它做"提交后发消息"。 - 把切面顺序调到事务外层/内层各跑一次,观察幂等切面放错层会导致什么问题。
- 用
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) 传 0 | 400(证明 ConstraintViolationException 被兜住) |
| 7 | 表单绑定类型错误(age=abc) | 400 而不是 500 |
| 8 | 抛 BizException | 业务码透传,日志是 warn 且无堆栈 |
| 9 | 抛 NullPointerException | 返回固定文案,日志有完整堆栈,响应里无堆栈信息 |
| 10 | 用 GET 访问只支持 POST 的接口 | 405 + 明确提示,而不是兜底 500 |
| 11 | 访问不存在的路径 | 走 /error,不经过 @RestControllerAdvice,确认响应结构是否需要统一 |
| 12 | Filter 里主动抛异常 | 同样不走 Advice,验证兜底方案 |
| 13 | 响应里的 traceId 与日志中的一致 | 证明 MDC 贯穿整个链路 |
🚀 变种拓展题
- 自定义
ErrorAttributes/BasicErrorController,让 404 和 Filter 异常也返回统一的R结构。 - 加国际化:错误码 +
MessageSource,根据Accept-Language返回不同语言的 message。 - 把错误码做成枚举 + 统一注册表,并写 CI 校验禁止出现重复码。
- 实现"业务异常码分段"(如 1xxxx 用户域、2xxxx 订单域),并在网关按段做监控告警。
- 用
@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 | 期望行为 |
|---|---|---|
| 1 | POST 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 抛异常则不能) |
🚀 变种拓展题
- 日志里对手机号、身份证、银行卡做脱敏,用 Jackson 的
@JsonSerialize或正则在输出前处理。 - 给日志加采样:只记录慢请求和异常请求的完整 body,正常请求只记 URI 和耗时。
- 把幂等 key 从请求头改成"用户 ID + 接口 + 业务参数 MD5",讨论两种方案的适用差异。
- 用自定义
HandlerMethodArgumentResolver实现@CurrentUser,让 Controller 不再手动调UserContext.get()。 - 把鉴权从拦截器迁移到 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() | 数据回滚,证明走代理后事务生效 |
| 3 | Trap1Service 的方法改成 private | JDK/CGLIB 代理都拦不住,事务同样失效 |
| 4 | 调用 Trap2Service.wrong() | 抛 UnexpectedRollbackException,且 main 这条也没进库 |
| 5 | 调用 fixA() | main 提交成功,sub 独立回滚 |
| 6 | 调用 fixB() | main 提交成功,sub 回滚到 savepoint |
| 7 | fixB() 换成 Spring 默认的 DataSourceTransactionManager 以外的 JPA 管理器 | NESTED 不被支持,抛异常——说明 NESTED 依赖底层 savepoint 能力 |
| 8 | Trap3Service.wrong() 在 riskCheck 抛异常 | 订单回滚但消息已发出,消费者查不到订单 |
| 9 | fixA() / fixB() 在 riskCheck 抛异常 | 消息不发送,afterCommit 根本不会执行 |
| 10 | @TransactionalEventListener 的发布点不在事务内 | 事件默认被丢弃,需要 fallbackExecution = true 才执行 |
| 11 | afterCommit 里 MQ 发送失败 | 事务已提交无法回滚,必须靠本地消息表/重试补偿 |
| 12 | 调用 Trap4Service.wrong() | main 回滚、async 那条残留,复现跨线程事务失效 |
| 13 | 异步线程里读 TransactionSynchronizationManager.isActualTransactionActive() | 返回 false |
| 14 | 异步线程里读 UserContext / MDC traceId | 读不到,需要用 TaskDecorator 显式传递 |
| 15 | Trap5Service.wrong() 并发 200 QPS,远程接口 sleep 3s | 连接池耗尽、CannotGetJdbcConnectionException |
| 16 | Trap5Service.fix() 同样压测 | 连接占用时间降到毫秒级,失败请求落 NOTIFY_FAILED 可补偿 |
🚀 变种拓展题
- 把坑 3 的方案 C 完整实现一遍:建
local_message表,写入与业务同事务,定时任务扫描 + 乐观锁更新状态 + 最大重试次数。 - 给异步线程池配
TaskDecorator,把 MDC 的 traceId 和UserContext透传到子线程,并在任务结束时清理。 - 用
TransactionSynchronization的afterCompletion(int status)实现"无论成功失败都清理缓存",对比afterCommit的差异。 - 在坑 2 的基础上加一层:外层
REQUIRED→ 中层REQUIRES_NEW→ 内层NESTED,画出连接、savepoint 和回滚边界。 - 把坑 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 隔离级别答成默认 RC | InnoDB 默认是 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 并关掉静态资源默认映射 |
| 全局异常处理器里拿不到 traceId | traceId 在 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、可观测性)