第二阶段:开发框架(吃饭的家伙)
第二阶段:开发框架(吃饭的家伙)
定位: 你简历每个项目都在用 Spring Boot + Spring Cloud Alibaba + MyBatis-Plus,面试官会从这里开始问。这是你的“基本功”区域,必须烂熟于心。
本文档覆盖: Spring Boot 核心 → Spring Cloud Alibaba 全家桶 → MyBatis-Plus → RocketMQ → Redisson → ElasticSearch
学习策略: 先理解原理(为什么这么设计),再记面试题(面试怎么问),最后动手验证(写代码跑一遍)
📖 本文档名词速查(看到不认识的缩写,先查这里)
完整的白话解释、生活类比、面试话术见
00-名词速查手册.md。 下面只列本文档用到的名词,按出现频率排序。
| 缩写 / 术语 | 英文全称 | 一句话说明 | 出处 |
|---|---|---|---|
| Bean | Bean(豆子) | Spring 容器管理的对象,Spring 里的一切都是 Bean | 第6章 |
| Nacos | Naming and Configuration Service | 阿里开源的注册中心 + 配置中心二合一 | 第7章 |
| AOP | Aspect Oriented Programming | 面向切面:把日志、事务这类横切逻辑抽出来统一处理 | 第6章 |
| QPS | Queries Per Second | 每秒请求数(系统能扛多少流量) | 第8章 |
| Dubbo | Dubbo | 阿里开源的 RPC 框架(后归 Apache) | 第7章 |
| Sentinel | Sentinel | 阿里开源的流量治理组件(限流/熔断/降级) | 第7章 |
| HTTP | HyperText Transfer Protocol | 超文本传输协议,Web 通信的基础 | 第13章 |
| ThreadLocal | ThreadLocal(线程本地) | 每个线程一份独立副本,线程间互不影响 | 第4章 |
| Seata | Simple Extensible Autonomous Transaction Architecture | 阿里开源的一站式分布式事务框架,支持 AT/TCC/Saga/XA | 第1章 |
| JSON | JavaScript Object Notation | 轻量级数据交换格式({} 和 []) | 第13章 |
| RocketMQ | RocketMQ | 阿里开源的消息队列,事务消息是它的强项 | 第2章 |
| RT | Response Time | 响应时间,一个请求多久返回 | 第8章 |
| Gateway | API Gateway | 网关,所有请求的统一入口(鉴权/路由/限流) | 第7章 |
| AT | Automatic Transaction | Seata 的无侵入模式:自动帮你生成反向 SQL,你只写业务代码 | 第1章 |
| Consumer | Consumer(消费者) | 收消息的一方 | 第2章 |
| gRPC | Google RPC | Google 出的 RPC 框架,用 HTTP/2 + Protobuf,性能好 | 第7章 |
| Redisson | Redisson | Redis 的 Java 客户端,封装了分布式锁等高级功能 | 第7章 |
| SPI | Service Provider Interface | 服务发现机制:让框架能找到你的实现类 | 第6章 |
| Arthas | Arthas | 阿里开源的 Java 诊断工具,线上排查神器 | 第8章 |
| API | Application Programming Interface | 应用程序接口,别人能调用你的功能 | 第13章 |
| JVM | Java Virtual Machine | Java 虚拟机,让 Java 代码“一次编译到处运行”的那个东西 | 第4章 |
| MyBatis-Plus | MyBatis-Plus | MyBatis 的增强工具,不用写简单 SQL | 第5章 |
| XML | eXtensible Markup Language | 可扩展标记语言(老式配置/数据格式) | 第13章 |
| TCC | Try-Confirm-Cancel | 业务层面的两阶段:先 Try 冻结资源,Confirm 真正扣,Cancel 释放 | 第1章 |
| MQ | Message Queue | 消息队列,存消息的“中转站”,让两个服务不必同时在线 | 第2章 |
| IoC | Inversion of Control | 控制反转:对象的创建权交给框架,你不再自己 new | 第6章 |
| MVCC | Multi-Version Concurrency Control | 多版本并发控制:同一行数据保留多个版本,读不加锁 | 第5章 |
| SkyWalking | SkyWalking | 国产 APM,分布式链路追踪 | 第8章 |
| Prometheus | Prometheus | 时序数据库 + 监控系统,拉模式采集 | 第8章 |
| Next-Key Lock | Next-Key Lock(临键锁) | 行锁 + 间隙锁,InnoDB 默认的行锁算法 | 第5章 |
💡 为什么要有这张表?
技术文档最大的阅读障碍不是原理难,而是缩写不认识 —— 看到“2PC 在数据库层加锁” 这句话,如果不知道 2PC 是什么,整段就废了。
这张表保证你在任何位置遇到不认识的词,都能当场查到,不用跳出文档。 想知道“为什么是这样”“面试怎么答”,再点进手册看完整版。
目录
- 一、Spring Boot 核心原理
- 二、Spring Cloud Alibaba 全家桶
- 三、MyBatis-Plus
- 四、RocketMQ(简历项目四直接用了)
- 五、Redisson 分布式锁(简历写了)
- 六、ElasticSearch(简历写了全文检索)
- 七、面试高频考点速查表
一、Spring Boot 核心原理
你简历的使用场景: 所有项目的核心框架
面试官心理: “你说你用了 6 年 Spring Boot,那说说自动配置是怎么实现的?”
1.1 IoC 容器与依赖注入
小白讲解
把 IoC 容器想象成一个大厨房的食材柜:
- 没有 IoC 的世界: 你想吃红烧肉,自己去买菜、洗肉、切肉、炒肉——每个对象自己
new依赖 - 有 IoC 的世界: 你只需要喊一声“我要五花肉”,厨房管理员(IoC 容器)就把五花肉递给你——对象不自己创建依赖,由容器注入
IoC = 控制反转:对象的创建权从“你自己 new”反转给了“容器”。
DI = 依赖注入:IoC 的具体实现方式,容器主动把依赖“注射”进对象。
代码示例
// ==================== 没有 IoC ====================
// UserController 自己 new UserService,强耦合
public class UserController {
private UserService userService = new UserServiceImpl(); // 写死了
}
// ==================== 有 IoC ====================
// UserController 不关心 UserService 怎么来的
@Component
public class UserController {
@Autowired // 容器,给我注入一个 UserService
private UserService userService;
}
@Service
public class UserServiceImpl implements UserService {
@Autowired
private UserMapper userMapper; // 容器,再给我注入一个 Mapper
}
三种注入方式对比
| 方式 | 写法 | 优点 | 缺点 |
|---|---|---|---|
| 字段注入 | @Autowired private UserService service; |
简洁 | 不能脱离容器单独测试、循环依赖隐患 |
| 构造器注入 ✅ | 构造方法加 @Autowired |
可脱离容器测试、显式依赖、避免循环依赖 | 代码稍长 |
| Setter 注入 | setter 方法加 @Autowired |
可选依赖 | 容易忘记调用 setter |
// ✅ 推荐:构造器注入(Spring 4.3+ 单构造器可省略 @Autowired)
@Component
@RequiredArgsConstructor // Lombok,自动生成构造器
public class UserController {
private final UserService userService; // final 保证不可变
}
面试题
Q1:@Autowired 和 @Resource 的区别?
| 维度 | @Autowired | @Resource |
|---|---|---|
| 来源 | Spring | JSR-250(JDK 标准) |
| 匹配方式 | 先按类型,找不到再按名称 | 先按名称,找不到再按类型 |
| required | 支持 @Autowired(required=false) |
不支持 |
| 推荐场景 | Spring 项目通用 | 需要指定 bean 名称时 |
Q2:@Autowired 注入同一个接口的多个实现类怎么办?
// 场景:UserService 有两个实现类
@Service("orderUserService")
public class OrderUserServiceImpl implements UserService {}
@Service("vipUserService")
public class VipUserServiceImpl implements UserService {}
// 方式 1:@Qualifier 指定 bean 名称
@Autowired
@Qualifier("vipUserService")
private UserService userService;
// 方式 2:变量名与 bean 名称一致
@Autowired
private UserService vipUserService; // 自动匹配 vipUserService
// 方式 3:@Primary 标注首选实现
@Primary
@Service
public class VipUserServiceImpl implements UserService {}
Q3:Spring Bean 的生命周期?
完整生命周期(5 大阶段):
1. 实例化(Instantiation)
→ 构造方法被调用,对象在内存中创建出来
→ 类似:房子打地基
2. 属性赋值(Populate Properties)
→ @Autowired / @Value 注入依赖
→ 类似:往房子里搬家具
3. 初始化(Initialization)
→ BeanNameAware / BeanFactoryAware 等回调
→ BeanPostProcessor.postProcessBeforeInitialization
→ @PostConstruct 注解方法
→ InitializingBean.afterPropertiesSet
→ 自定义 init-method
→ BeanPostProcessor.postProcessAfterInitialization
→ 类似:装修完做最后验收
4. 使用(In Use)
→ Bean 存在于单例池中,被其他组件使用
5. 销毁(Destruction)
→ @PreDestroy 注解方法
→ DisposableBean.destroy
→ 自定义 destroy-method
→ 类似:拆迁前搬走
记忆口诀:实例化 → 属性赋值 → 初始化 → 使用 → 销毁(造 → 装 → 验 → 用 → 拆)
1.2 AOP 面向切面编程
小白讲解
想象你开了一家连锁餐厅,每家分店都要做这些事:
- 开门前检查卫生
- 营业中卖饭
- 关门后盘点库存
如果每家分店都自己写“检查卫生”和“盘点库存”的代码,就重复了。AOP 的思路是:把横切关注点(卫生检查、盘点)抽出来,在合适的时机自动执行,核心业务只管卖饭。
横切关注点 = 多个业务都需要的通用逻辑(日志、事务、权限、缓存)
AOP 术语大白话翻译:
- 切面(Aspect) = 卫生检查规范(一个类)
- 切入点(Pointcut) = 哪些方法需要检查卫生(表达式匹配)
- 通知(Advice) = 检查卫生的时机(开门前?关门后?还是出了问题?)
- 织入(Weaving) = Spring 帮你把卫生检查逻辑塞到业务方法里
五种通知类型
@Aspect
@Component
public class LogAspect {
// 切入点:匹配 service 包下所有方法
@Pointcut("execution(* com.example.service..*.*(..))")
public void servicePointcut() {}
// 1. 前置通知:方法执行前
@Before("servicePointcut()")
public void before(JoinPoint jp) {
System.out.println("【前置】执行: " + jp.getSignature().getName());
}
// 2. 后置通知:方法执行后(无论正常或异常都执行)
@After("servicePointcut()")
public void after(JoinPoint jp) {
System.out.println("【后置】结束: " + jp.getSignature().getName());
}
// 3. 返回通知:方法正常返回后
@AfterReturning(pointcut = "servicePointcut()", returning = "result")
public void afterReturning(JoinPoint jp, Object result) {
System.out.println("【返回】结果: " + result);
}
// 4. 异常通知:方法抛出异常后
@AfterThrowing(pointcut = "servicePointcut()", throwing = "ex")
public void afterThrowing(JoinPoint jp, Exception ex) {
System.out.println("【异常】" + ex.getMessage());
}
// 5. 环绕通知:最强大,可以控制是否执行目标方法
@Around("servicePointcut()")
public Object around(ProceedingJoinPoint pjp) throws Throwable {
long start = System.currentTimeMillis();
try {
Object result = pjp.proceed(); // 执行目标方法
return result;
} finally {
System.out.println("【环绕】耗时: " + (System.currentTimeMillis() - start) + "ms");
}
}
}
JDK 动态代理 vs CGLIB(面试必问)
| 维度 | JDK 动态代理 | CGLIB 代理 |
|---|---|---|
| 原理 | 基于接口,生成实现接口的代理类 | 基于继承,生成目标类的子类 |
| 要求 | 目标类必须实现接口 | 目标类不能是 final 类 |
| 性能 | 创建快,执行稍慢 | 创建慢,执行快 |
| Spring 默认 | 有接口用 JDK | 无接口用 CGLIB |
| Spring Boot 2.x | 默认 CGLIB(spring.aop.proxy-target-class=true) |
同左 |
// JDK 动态代理原理(简化版)
public class JdkProxy implements InvocationHandler {
private Object target;
public Object createProxy(Object target) {
this.target = target;
return Proxy.newProxyInstance(
target.getClass().getClassLoader(),
target.getClass().getInterfaces(),
this
);
}
@Override
public Object invoke(Object proxy, Method method, Object[] args) throws Throwable {
System.out.println("【前置】日志记录");
Object result = method.invoke(target, args); // 执行目标方法
System.out.println("【后置】日志记录");
return result;
}
}
// CGLIB 原理(简化版)
public class CglibProxy implements MethodInterceptor {
public Object createProxy(Class<?> targetClass) {
Enhancer enhancer = new Enhancer();
enhancer.setSuperclass(targetClass); // 继承目标类
enhancer.setCallback(this);
return enhancer.create();
}
@Override
public Object intercept(Object obj, Method method, Object[] args,
MethodProxy proxy) throws Throwable {
System.out.println("【前置】日志记录");
Object result = proxy.invokeSuper(obj, args); // 调用父类方法
System.out.println("【后置】日志记录");
return result;
}
}
面试题
Q1:同一个切面里五种通知的执行顺序?
正常情况:
@Around(前半段) → @Before → 目标方法 → @AfterReturning → @After → @Around(后半段)
异常情况:
@Around(前半段) → @Before → 目标方法(抛异常) → @AfterThrowing → @After → @Around(异常传播)
Q2:为什么 @Transactional 注解在 private 方法上不生效?
因为 Spring AOP 使用动态代理,代理类只能拦截 public 方法(JDK 代理基于接口,接口方法默认 public;CGLIB 通过子类重写,private 方法不可重写)。如果需要对 private 方法加事务,可以使用 AspectJ(字节码级织入)。
1.3 AspectJ 深度讲解(面试加分项)
小白理解:Spring AOP vs AspectJ 到底什么关系?
先说一个很多教程没讲清的核心事实:
你平时写的 @Aspect、@Before、@Around 这些注解——
❌ 它们不是 Spring AOP 自己发明的
✅ 它们是 AspectJ 定义的注解语法(叫 @AspectJ 风格)
Spring 做的事情:
1. 借用了 AspectJ 的注解语法(让你写切面更方便)
2. 但底层还是用自己的动态代理来实现(不是 AspectJ 的编译器)
类比:
Spring AOP = 你用普通话(AspectJ 语法)写了一份菜单,但后厨用的是自己的做法(动态代理)
AspectJ = 连菜单带后厨都是自己的一套(自己的语法 + 自己的编译器 ajc)
三者关系一图看清
┌──────────────────────────────────────────────────────┐
│ AOP 世界 │
│ │
│ ┌─────────────────┐ ┌────────────────────────┐ │
│ │ Spring AOP │ │ AspectJ │ │
│ │ │ │ │ │
│ │ 实现:动态代理 │ │ 实现:字节码织入 │ │
│ │ 织入:运行时 │ │ 织入:编译时/加载时 │ │
│ │ 范围:只能拦截 │ │ 范围:方法/字段/构造器 │ │
│ │ Bean 的 │ │ 都能拦截 │ │
│ │ public 方法 │ │ │ │
│ │ │ │ │ │
│ │ 借用了 AspectJ │ │ 有自己的 .aj 语法 │ │
│ │ 的 @AspectJ │ │ 也有 @AspectJ 注解 │ │
│ │ 注解语法 │ │ │ │
│ └────────┬────────┘ └───────────┬────────────┘ │
│ │ │ │
│ │ @AspectJ 注解语法 │ │
│ └──────────┬───────────────┘ │
│ │ │
│ ┌───────▼───────┐ │
│ │ @Aspect │ │
│ │ @Pointcut │ ← 两边都能用的语法 │
│ │ @Before/@Around│ │
│ └───────────────┘ │
└──────────────────────────────────────────────────────┘
AspectJ 的三种织入方式(面试重点)
Spring AOP 只有运行时代理一种方式。AspectJ 有三种,这是面试重点区分:
方式一:编译时织入(Compile-Time Weaving, CTW)
你写的 .java 源码
│
▼
AspectJ 编译器 (ajc) ← 代替 javac
│ (ajc 在编译时就找到切面,直接把增强代码插入到 .class 字节码里)
▼
增强后的 .class 文件(已经包含切面代码)
│
▼
JVM 运行(运行时零开销,因为代码早就织入好了)
配置方式: Maven 插件
<plugin>
<groupId>org.codehaus.mojo</groupId>
<artifactId>aspectj-maven-plugin</artifactId>
<version>1.14.0</version>
<configuration>
<complianceLevel>1.8</complianceLevel>
<source>1.8</source>
<target>1.8</target>
<showWeaveInfo>true</showWeaveInfo>
</configuration>
<executions>
<execution>
<goals>
<goal>compile</goal> <!-- 编译时织入 -->
<goal>test-compile</goal>
</goals>
</execution>
</executions>
</plugin>
方式二:编译后织入(Post-Compile Weaving, PCW)
你写的 .java 源码
│
▼
javac 编译 → 普通 .class 文件
│
▼
AspectJ 织入器 (ajc -inpath) ← 对已编译的 .class 做增强
│ (适合第三方 jar 包,你拿不到源码但想加切面)
▼
增强后的 .class 文件
场景: 你想给第三方库(如某个 jar 包里的方法)加日志,但没有源码 → 先正常编译,再用 ajc 织入。
方式三:加载时织入(Load-Time Weaving, LTW)
JVM 启动
│
▼
类加载器加载 .class 文件
│
▼
AspectJ 织入代理 (Java Agent) ← 在类加载的那一刻做增强
│ (读取 aop.xml 配置,找到切面,织入到正在加载的类)
▼
增强后的类被 JVM 加载(运行时零代理开销)
配置方式: JVM 参数 + aop.xml
# JVM 启动参数
java -javaagent:aspectjweaver.jar -jar your-app.jar
<!-- src/main/resources/META-INF/aop.xml -->
<aspectj>
<weaver options="-verbose -showWeaveInfo">
<!-- 只织入 service 包下的类 -->
<include within="com.example.service..*"/>
</weaver>
<aspects>
<!-- 指定要使用的切面 -->
<aspect name="com.example.aspect.LogAspect"/>
</aspects>
</aspectj>
三种织入方式对比
| 维度 | CTW(编译时) | PCW(编译后) | LTW(加载时) |
|---|---|---|---|
| 织入时机 | 编译 .java → .class | 对已有 .class 织入 | JVM 加载 class 时 |
| 工具 | ajc 编译器 | ajc -inpath | aspectjweaver.jar (Java Agent) |
| 需要源码 | ✅ 需要 | ❌ 不需要(适合第三方 jar) | ❌ 不需要 |
| 运行时开销 | 零(代码已编入) | 零 | 极小(类加载时一次性开销) |
| 配置复杂度 | 中(Maven 插件) | 中 | 高(JVM 参数 + aop.xml) |
| 适用场景 | 自己的项目想用 AspectJ 全功能 | 增强第三方库 | 不想改编译过程,运行时灵活控制 |
| Spring 支持 | 支持 | 支持 | 支持(@EnableLoadTimeWeaving) |
AspectJ 原生 .aj 语法(了解即可)
除了 @AspectJ 注解风格,AspectJ 还有自己的原生语法(.aj 文件),功能更强:
// LogAspect.aj — 注意文件后缀是 .aj,不是 .java
public aspect LogAspect {
// 切入点:和注解风格一样
pointcut serviceMethod():
execution(* com.example.service..*.*(..));
// 前置通知
before(): serviceMethod() {
System.out.println("【前置】" + thisJoinPoint.getSignature());
}
// 后置通知
after(): serviceMethod() {
System.out.println("【后置】" + thisJoinPoint.getSignature());
}
// 环绕通知
Object around(): serviceMethod() {
long start = System.currentTimeMillis();
try {
return proceed(); // proceed 相当于 pjp.proceed()
} finally {
System.out.println("耗时: " + (System.currentTimeMillis() - start) + "ms");
}
}
}
@AspectJ 注解风格 vs .aj 原生语法:
| 维度 | @AspectJ 注解 | .aj 原生语法 |
|---------|------------------------|------------------------|
| 文件类型 | .java(普通 Java 文件) | .aj(AspectJ 专用文件) |
| 编译器 | javac 就能编译 | 必须 ajc 编译 |
| IDE 支持 | 全面支持 | 需装 AJDT 插件 |
| 功能 | 方法级拦截 | 方法/字段/构造器全支持 |
| 学习成本 | 低(和写 Spring 一样) | 高(新语法) |
结论:99% 的场景用 @AspectJ 注解就够了。.aj 语法了解即可,
面试能说出"AspectJ 有自己的 .aj 语法,功能更强但需要 ajc 编译器"就行。
AspectJ 比 Spring AOP 多能做什么?(面试核心区分点)
这是面试官最想听到的——Spring AOP 做不到但 AspectJ 能做到的事:
1. 拦截字段读写(Spring AOP 做不到)
// AspectJ 可以拦截字段的读取和赋值
@Aspect
public class FieldAspect {
// 拦截 User.name 字段的读取
@Before("get(* com.example.model.User.name)")
public void beforeGetName() {
System.out.println("有人在读取 name 字段");
}
// 拦截 User.name 字段的赋值
@Before("set(* com.example.model.User.name) && args(newName)")
public void beforeSetName(String newName) {
System.out.println("有人在设置 name = " + newName);
}
}
2. 拦截构造方法(Spring AOP 做不到)
@Aspect
public class ConstructorAspect {
// 拦截 User 的构造方法调用
@Before("call(com.example.model.User.new(..))")
public void beforeNewUser() {
System.out.println("有人在 new User()");
}
// 拦截 User 构造方法执行
@After("execution(com.example.model.User.new(..))")
public void afterUserInit() {
System.out.println("User 对象创建完成");
}
}
3. 拦截 private/protected 方法(Spring AOP 做不到)
@Aspect
public class PrivateMethodAspect {
// Spring AOP 拦截不了 private 方法,但 AspectJ 可以
@Before("execution(* com.example.service.UserService.privateMethod(..))")
public void beforePrivateMethod() {
System.out.println("private 方法被调用了");
}
}
4. 拦截静态方法调用(Spring AOP 受限)
@Aspect
public class StaticMethodAspect {
// 拦截 Math.random() 的调用
@Before("call(* java.lang.Math.random())")
public void beforeRandom() {
System.out.println("有人在调用 Math.random()");
}
}
Pointcut 表达式大全(面试必背)
Spring AOP 用的是 AspectJ 的切入点表达式语法,这些表达式面试经常考:
execution(最常用,匹配方法执行)
// 完整语法:
execution(修饰符? 返回类型 包名.类名.方法名(参数类型) 异常?)
// 实例:
execution(public * com.example.service.UserService.*(..))
// ↳ public 修饰,任意返回类型,UserService 类的所有方法,任意参数
execution(* com.example.service..*.*(..))
// ↳ 任意修饰符和返回类型,service 包及子包下所有类的所有方法
execution(* save*(..))
// ↳ 所有以 save 开头的方法
execution(* *(String, ..))
// ↳ 第一个参数是 String 的方法(.. 表示后面可以有任意个参数)
execution(* *(..) throws java.io.IOException)
// ↳ 声明抛出 IOException 的方法
其他切入点指示符
// ── target:匹配目标对象类型(运行时类型)
@After("target(com.example.service.UserService)")
// ↳ 目标对象是 UserService 类型(包括子类)
// ── within:匹配类级别(编译时类型)
@Before("within(com.example.service..*)")
// ↳ service 包及子包下所有类的方法
// ── @within:匹配类上有特定注解
@Before("@within(org.springframework.stereotype.Service)")
// ↳ 类上标了 @Service 的所有方法
// ── @annotation:匹配方法上有特定注解
@Around("@annotation(com.example.annotation.MyLog)")
// ↳ 方法上标了 @MyLog 注解
// ⭐ 这个最实用!自定义注解 + AOP = 灵活的日志/权限/缓存控制
// ── @args:匹配方法参数上有特定注解
@Before("@args(com.example.annotation.Validated)")
// ↳ 方法参数上标了 @Validated 注解
// ── bean:Spring 扩展的,匹配 Bean 名称
@After("bean(userService)")
// ↳ 名叫 userService 的 Bean 的所有方法
// ── this:匹配代理对象类型
@Before("this(com.example.service.UserService)")
// ↳ 代理对象是 UserService 类型
组合切入点
@Pointcut("execution(* com.example.service..*.*(..))")
public void serviceLayer() {}
@Pointcut("execution(* com.example.controller..*.*(..))")
public void controllerLayer() {}
// && 与:同时满足
@Before("serviceLayer() && args(String, ..)")
public void serviceWithStringParam() {}
// || 或:满足其一
@Around("serviceLayer() || controllerLayer()")
public void logAll() {}
// ! 非:取反
@Before("serviceLayer() && !execution(* com.example.service.UserService.delete*(..))")
public void logExceptDelete() {}
execution vs within vs target vs this 的区别(高频面试题)
这四个最容易混,一张表搞定:
假设有:
interface UserService { void save(); }
class UserServiceImpl implements UserService { public void save() {} }
Spring 创建的代理对象 proxy(JDK 代理,实现了 UserService 接口)
| 指示符 | 匹配什么 | 上面例子的匹配结果 | 类比 |
|---|---|---|---|
execution |
方法签名(静态,看代码长什么样) | execution(* UserServiceImpl.save(..)) ✅ 能匹配 |
“这个方法签名长这样吗?” |
within |
类的包路径(静态) | within(com.example..UserServiceImpl) ✅ |
“这个类在哪个包下?” |
target |
目标对象的运行时类型 | target(UserService) ✅(代理背后的目标对象是 UserServiceImpl) |
“被代理的那个对象是什么类型?” |
this |
代理对象的类型 | this(UserService) ✅ / this(UserServiceImpl) ❌(JDK 代理不是 UserServiceImpl) |
“代理对象本身是什么类型?” |
关键区别:
execution / within → 看的是"代码怎么写的"(静态,编译时就能确定)
target / this → 看的是"运行时对象是什么类型"(动态)
this vs target 的区别:
this = 代理对象的类型(proxy)
target = 被代理的目标对象类型(target)
用 JDK 代理时:this 可能不等于 target(代理只实现了接口,不是目标类的子类)
用 CGLIB 代理时:this 通常等于 target(代理是目标类的子类)
实战:自定义注解 + AOP 实现操作日志(简历项目可用)
这是面试和实际工作中最常用的 AOP 模式——自定义注解 + 环绕通知:
// 1. 自定义注解
@Target(ElementType.METHOD)
@Retention(RetentionPolicy.RUNTIME)
public @interface OperationLog {
String value() default ""; // 操作描述
}
// 2. 切面实现
@Aspect
@Component
@Slf4j
public class OperationLogAspect {
@Autowired
private OperationLogService logService;
@Around("@annotation(operationLog)")
public Object around(ProceedingJoinPoint pjp, OperationLog operationLog) throws Throwable {
// —— 前置:记录操作信息 ——
String methodName = pjp.getSignature().getName();
String className = pjp.getTarget().getClass().getSimpleName();
String args = Arrays.toString(pjp.getArgs());
String operator = SecurityContextHolder.getContext().getAuthentication().getName();
log.info("操作开始 | {} | {}.{} | 参数: {}", operationLog.value(), className, methodName, args);
long start = System.currentTimeMillis();
Object result = null;
String status = "成功";
String errorMsg = null;
try {
result = pjp.proceed(); // 执行目标方法
return result;
} catch (Throwable e) {
status = "失败";
errorMsg = e.getMessage();
throw e; // 异常继续抛出,不影响原有逻辑
} finally {
long cost = System.currentTimeMillis() - start;
// —— 后置:异步保存日志 ——
OperationLogEntity logEntity = new OperationLogEntity();
logEntity.setOperator(operator);
logEntity.setOperation(operationLog.value());
logEntity.setMethod(className + "." + methodName);
logEntity.setArgs(args);
logEntity.setStatus(status);
logEntity.setErrorMsg(errorMsg);
logEntity.setCostTime(cost);
logEntity.setOperateTime(LocalDateTime.now());
// 异步保存,不影响主流程
CompletableFuture.runAsync(() -> logService.save(logEntity));
log.info("操作结束 | {} | {} | 耗时: {}ms", operationLog.value(), status, cost);
}
}
}
// 3. 使用:在 Controller 方法上加注解
@RestController
@RequestMapping("/api/user")
public class UserController {
@OperationLog("创建用户")
@PostMapping("/create")
public Result create(@RequestBody UserDTO dto) {
return userService.create(dto);
}
@OperationLog("删除用户")
@DeleteMapping("/{id}")
public Result delete(@PathVariable Long id) {
return userService.delete(id);
}
}
实战:自定义注解 + AOP 实现接口限流(简历项目可用)
// 1. 限流注解
@Target(ElementType.METHOD)
@Retention(RetentionPolicy.RUNTIME)
public @interface RateLimit {
int qps() default 100; // 每秒允许的请求数
int timeout() default 500; // 获取令牌超时时间(毫秒)
String message() default "请求过于频繁,请稍后重试";
}
// 2. 限流切面(基于 Redis + Lua 或 Guava RateLimiter)
@Aspect
@Component
public class RateLimitAspect {
private final Map<String, RateLimiter> limiters = new ConcurrentHashMap<>();
@Around("@annotation(rateLimit)")
public Object around(ProceedingJoinPoint pjp, RateLimit rateLimit) throws Throwable {
String key = pjp.getSignature().toLongString();
RateLimiter limiter = limiters.computeIfAbsent(key,
k -> RateLimiter.create(rateLimit.qps()));
if (!limiter.tryAcquire(1, rateLimit.timeout(), TimeUnit.MILLISECONDS)) {
throw new BusinessException(rateLimit.message());
}
return pjp.proceed();
}
}
// 3. 使用
@RestController
public class LoginController {
@RateLimit(qps = 10, message = "登录接口限流,每秒最多10次")
@PostMapping("/login")
public Result login(@RequestBody LoginDTO dto) {
return authService.login(dto);
}
}
Spring 中启用 AspectJ 的三种方式
方式一:Spring AOP + @AspectJ 注解(默认方式,99% 的人在用)
// 启动类或配置类
@Configuration
@EnableAspectJAutoProxy // ← 开启 @AspectJ 注解支持(Spring Boot 默认已开启)
// @EnableAspectJAutoProxy(proxyTargetClass = true) // 强制使用 CGLIB
// @EnableAspectJAutoProxy(exposeProxy = true) // 暴露代理对象,可在类内通过 AopContext 获取
public class AppConfig {}
// 然后正常写 @Aspect 切面就行,Spring 用动态代理实现
方式二:Spring + AspectJ LTW(加载时织入)
@Configuration
@EnableLoadTimeWeaving // ← 开启 AspectJ 加载时织入
public class AppConfig {}
// JVM 参数:-javaagent:aspectjweaver.jar
// 还需要 META-INF/aop.xml 配置文件
方式三:纯 AspectJ(CTW,不依赖 Spring)
完全使用 AspectJ 编译器,不依赖 Spring 的代理机制。适合对性能要求极高或需要拦截字段/构造器的场景。
AspectJ 面试题
Q1:Spring AOP 和 AspectJ 的区别?(高频)
| 维度 | Spring AOP | AspectJ |
|---|---|---|
| 实现方式 | 动态代理(JDK/CGLIB) | 字节码织入(ajc 编译器) |
| 织入时机 | 运行时 | 编译时(CTW) / 编译后(PCW) / 加载时(LTW) |
| 性能 | 有代理方法调用开销 | 零运行时开销(代码已编入字节码) |
| 功能范围 | 仅方法级(public),仅 Spring Bean | 方法 + 字段 + 构造器 + 静态方法,任意类 |
| 拦截 private | ❌ 不支持 | ✅ 支持 |
| 拦截字段读写 | ❌ 不支持 | ✅ 支持 |
| 拦截构造方法 | ❌ 不支持 | ✅ 支持 |
| 自调用问题 | ❌ 内部调用不经过代理 | ✅ 直接织入字节码,无此问题 |
| 使用复杂度 | 低(注解 + Spring 管理) | 高(需 ajc 编译器 / Java Agent) |
| 适用场景 | 企业应用开发(日志/事务/权限) | 对性能要求高、需要深层拦截的场景 |
Q2:Spring 为什么不直接用 AspectJ,而是自己实现动态代理?
三个原因:
- 易用性:AspectJ 需要单独的 ajc 编译器和构建配置,Spring AOP 只需加依赖和注解,零侵入
- 够用原则:企业开发 80% 的 AOP 需求(事务、日志、权限、缓存)都是方法级拦截,Spring AOP 足够
- 容器集成:Spring AOP 与 IoC 容器深度集成,切面本身也是 Bean,可以注入依赖;AspectJ 的切面由 ajc 管理,不在 Spring 容器中
Q3:Spring AOP 存在“自调用问题”,AspectJ 为什么没有?
Spring AOP 的自调用问题:
@Service
public class OrderService {
public void methodA() {
this.methodB(); // ❌ this 是目标对象本身,不是代理对象
// 不经过代理,methodB 的 @Transactional 失效
}
@Transactional
public void methodB() {}
}
AspectJ 没有这个问题:
因为 AspectJ 是直接把增强代码"写"进了 methodB 的字节码里,
methodB 这个方法本身就是被增强过的,不管谁调用它都会执行增强逻辑。
不存在"代理对象 vs 目标对象"的区分。
Spring AOP 的修复方式(不是真正修复,是绕过):
// 方式1:注入自己 @Service public class OrderService { @Autowired private OrderService self; // 注入的是代理对象 public void methodA() { self.methodB(); // ✅ 通过代理调用 } } // 方式2:暴露代理对象 @EnableAspectJAutoProxy(exposeProxy = true) // 在代码中获取当前代理对象 ((OrderService) AopContext.currentProxy()).methodB();
Q4:AspectJ 的三种织入方式分别什么场景用?
| 方式 | 场景 | 例子 |
|---|---|---|
| CTW(编译时) | 自己的项目,想要 AspectJ 全功能且运行时零开销 | 微服务项目想拦截 private 方法和字段访问 |
| PCW(编译后) | 增强第三方 jar 包,没有源码 | 给某个开源库的方法加监控埋点 |
| LTW(加载时) | 不想改编译过程,但运行时需要灵活控制 | 应用部署后动态调整织入范围(改 aop.xml 即可) |
Q5:execution 和 within 的区别?
execution匹配的是方法签名(修饰符、返回类型、包名、类名、方法名、参数),粒度更细within匹配的是类所在的包路径,粒度更粗,只能匹配到类级别execution(* com.example.service.UserService.save(..))能精确到某个类的某个方法within(com.example.service.UserService)只能匹配到 UserService 类,不能区分哪个方法
Q6:this 和 target 的区别?什么时候两者不一样?
this匹配的是代理对象的类型target匹配的是目标对象(被代理的对象)的类型- 使用 JDK 动态代理时:代理对象只实现了接口,不是目标类的子类,所以
this(目标类)匹配不到,this(接口)能匹配到- 使用 CGLIB 代理时:代理对象是目标类的子类,
this(目标类)也能匹配到- 结论:面试时说“this 看代理对象类型,target 看目标对象类型,JDK 代理时 this 和 target 可能不同”
Q7:@annotation 注解切入点为什么最常用?
因为它最灵活、最精确。你不需要写复杂的 execution 表达式去匹配包名和方法名,只需要在目标方法上加一个自定义注解,切面就自动生效。这意味着:
- 精确控制哪些方法需要增强(只有加了注解的才增强)
- 不依赖包结构(方法在哪个包无所谓)
- 业务代码只需加一行注解,零侵入
- 注解可以携带参数(如操作描述、限流 QPS),切面直接读取
常见应用:操作日志(@OperationLog)、接口限流(@RateLimit)、数据权限(@DataPermission)、分布式锁(@DistributedLock)、缓存(@Cacheable)
Q8:Spring AOP 中 exposeProxy = true 有什么用?
开启后,Spring 会把当前代理对象存入 ThreadLocal,在业务代码中可以通过
AopContext.currentProxy()获取当前代理对象。主要用于解决自调用问题——类内部 A 方法调用 B 方法时,通过((MyService) AopContext.currentProxy()).methodB()确保调用经过代理,使 B 方法上的 @Transactional 等注解生效。副作用:每层代理都增加一次 ThreadLocal 操作,有微小性能开销;且只有最外层的代理会被暴露,多层代理嵌套时可能拿不到正确的代理对象。
1.4 自动配置原理(Spring Boot 的灵魂)
小白讲解
Spring Boot 的自动配置就像一个智能管家:
- 传统 Spring:你告诉管家“我要个碗、我要个筷子、我要个勺子”——写一堆 XML
- Spring Boot:管家看了一眼厨房,发现有大米,自动给你盛了饭——根据类路径下有什么 jar 包,自动配置对应的 Bean
核心入口: @SpringBootApplication = @SpringBootConfiguration + @EnableAutoConfiguration + @ComponentScan
自动配置执行流程
1. 启动类 @SpringBootApplication
2. @EnableAutoConfiguration
3. 通过 @Import(AutoConfigurationImportSelector.class) 导入选择器
4. AutoConfigurationImportSelector.selectImports()
5. 读取 META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports 文件
(Spring Boot 2.7 之前是 spring.factories)
6. 拿到一堆 AutoConfiguration 类的全限定名
7. 逐个检查 @Conditional 条件
→ @ConditionalOnClass:类路径有这个类吗?没有就跳过
→ @ConditionalOnBean:容器里有这个 Bean 吗?没有就跳过
→ @ConditionalOnProperty:配置文件里有这个属性吗?
8. 条件满足 → 创建对应 Bean → 注册到容器
条件不满足 → 跳过
代码示例:自己写一个 Starter
my-spring-boot-starter/
├── pom.xml
└── src/main/java/com/example/autoconfig/
├── HelloProperties.java # 配置属性绑定
├── HelloService.java # 核心服务
├── HelloAutoConfiguration.java # 自动配置类
└── resources/META-INF/
└── spring/
└── org.springframework.boot.autoconfigure.AutoConfiguration.imports
// 1. 配置属性绑定
@ConfigurationProperties(prefix = "hello")
@Data
public class HelloProperties {
private String name = "World";
private int times = 1;
}
// 2. 核心服务
public class HelloService {
private HelloProperties properties;
public HelloService(HelloProperties properties) {
this.properties = properties;
}
public String sayHello() {
return "Hello, " + properties.getName() + "!".repeat(properties.getTimes());
}
}
// 3. 自动配置类
@AutoConfiguration
@ConditionalOnProperty(prefix = "hello", name = "enabled", havingValue = "true", matchIfMissing = true)
@EnableConfigurationProperties(HelloProperties.class)
public class HelloAutoConfiguration {
@Bean
@ConditionalOnMissingBean(HelloService.class) // 用户没自己定义才创建
public HelloService helloService(HelloProperties properties) {
return new HelloService(properties);
}
}
// 4. imports 文件内容
// resources/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports
// 内容:com.example.autoconfig.HelloAutoConfiguration
// 5. 使用方 application.yml
// hello:
// name: 朱宏晖
// times: 3
// 6. 使用方直接注入
@Autowired
private HelloService helloService; // 不需要任何配置,直接可用
面试题
Q1:Spring Boot 自动配置的原理是什么?
启动类
@SpringBootApplication中的@EnableAutoConfiguration通过@Import导入AutoConfigurationImportSelector,该类调用selectImports()方法,从META-INF/spring/...AutoConfiguration.imports(2.7+)或spring.factories(2.7 之前)文件中加载所有自动配置类的全限定名。然后 Spring Boot 根据@Conditional系列注解(@ConditionalOnClass、@ConditionalOnBean、@ConditionalOnProperty等)判断每个配置类是否应该生效。只有条件满足的配置类才会被注册到 IoC 容器中,从而实现“引入什么依赖就自动配置什么功能”。
Q2:如何禁用某个自动配置?
// 方式 1:启动类排除
@SpringBootApplication(exclude = {DataSourceAutoConfiguration.class})
public class App {}
// 方式 2:配置文件
spring.autoconfigure.exclude=org.springframework.boot.autoconfigure.jdbc.DataSourceAutoConfiguration
// 方式 3:@ConditionalOnProperty 控制开关
hello.enabled=false
Q3:@Conditional 系列注解有哪些?
| 注解 | 条件 |
|---|---|
@ConditionalOnClass |
类路径存在指定类 |
@ConditionalOnMissingClass |
类路径不存在指定类 |
@ConditionalOnBean |
容器中存在指定 Bean |
@ConditionalOnMissingBean |
容器中不存在指定 Bean |
@ConditionalOnProperty |
配置文件有指定属性 |
@ConditionalOnWebApplication |
是 Web 应用 |
@ConditionalOnNotWebApplication |
不是 Web 应用 |
@ConditionalOnExpression |
SpEL 表达式为 true |
1.5 事务管理 @Transactional(简历面试必问)
小白讲解
事务就是要么全做,要么全不做。想象银行转账:
- A 扣 100 块 → B 加 100 块
- 如果 A 扣了钱后系统崩溃了,B 没收到钱——这 100 块就消失了
- 事务保证:A 扣和 B 加要么同时成功,要么同时失败
ACID 四大特性
| 特性 | 含义 | 例子 |
|---|---|---|
| Atomicity 原子性 | 不可分割,要么全做要么全不做 | 转账扣款和加款是一个整体 |
| Consistency 一致性 | 事务前后数据状态一致 | A+B 总额不变 |
| Isolation 隔离性 | 并发事务互不干扰 | 两人同时转账不影响对方 |
| Durability 持久性 | 提交后永久保存 | 断电不丢数据 |
@Transactional 底层原理
调用方 → 代理对象 → TransactionInterceptor(拦截器)
↓
1. 获取事务管理器 PlatformTransactionManager
2. 调用 getTransaction() 开启/加入事务(根据传播行为)
3. 将数据库连接绑定到 ThreadLocal(DataSourceUtils)
↓
4. 执行目标方法(业务代码)
↓
5a. 正常返回 → commit()
5b. 抛出异常 → 判断是否匹配回滚规则 → rollback()
核心组件:
TransactionInterceptor:实现了MethodInterceptor,是事务的入口拦截器PlatformTransactionManager:事务管理器,如DataSourceTransactionManager(JDBC)、JpaTransactionManager(JPA)TransactionSynchronizationManager:通过ThreadLocal将数据库连接与当前线程绑定,保证同一事务中的多个 DAO 操作使用同一个 Connection
七种传播行为(面试重点 + 必须会举例)
什么是传播行为?
传播行为解决的问题是: 当方法 A(有事务)调用方法 B 时,方法 B 应该用 A 的事务,还是自己开一个新事务,还是不开事务?
@Service
public class OrderService {
@Autowired
private UserService userService;
@Transactional // 外层方法有事务
public void createOrder() {
orderMapper.insert(order); // 步骤1:写订单
userService.updateUser(); // 步骤2:更新用户(用哪种传播行为?)
pointService.addPoints(); // 步骤3:加积分(用哪种传播行为?)
}
}
七种传播行为详解
① REQUIRED(默认,最常用)
含义: 当前有事务就加入,没有就新建一个。
@Service
public class UserService {
@Transactional(propagation = Propagation.REQUIRED) // 默认就是这个
public void updateUser() {
userMapper.update(user);
}
}
场景一:外层有事务 → 加入外层事务
OrderService.createOrder() ← 开启事务 T1
├── insertOrder() ← 使用 T1
├── UserService.updateUser() ← 加入 T1(不新开事务)
└── addPoints() ← 使用 T1
← T1 提交(三个操作一起成功或一起失败)
场景二:外层无事务 → 自己新建一个
UserController.query() ← 无事务
└── UserService.updateUser() ← 新建事务 T1
← T1 提交
适用场景: 绝大多数业务方法。比如“创建订单”需要同时写订单表、扣库存、加积分,这三个操作必须在一个事务中,任何一个失败全部回滚。
面试关键句: “REQUIRED 是默认传播行为,有事务加入、没事务新建,保证方法始终在事务中执行。”
② REQUIRES_NEW(独立事务,常用)
含义: 无论当前有没有事务,都新开一个独立事务。如果当前有事务,先把当前事务挂起,等新事务结束后再恢复。
@Service
public class LogService {
@Transactional(propagation = Propagation.REQUIRES_NEW)
public void saveLog(String action) {
logMapper.insert(new Log(action));
}
}
场景:订单失败也要记日志
@Service
public class OrderService {
@Autowired
private LogService logService;
@Transactional // 事务 T1
public void createOrder() {
try {
orderMapper.insert(order); // 写订单
inventoryService.deduct(); // 扣库存(可能失败)
} catch (Exception e) {
// T1 即将回滚,但日志必须保存下来!
logService.saveLog("订单创建失败: " + e.getMessage());
// saveLog 用 REQUIRES_NEW → 新开事务 T2,独立于 T1
// T1 回滚不影响 T2,日志成功保存
throw e; // 重新抛出,T1 回滚
}
}
}
执行时序:
T1 开始 → insertOrder → deduct 失败
→ T1 挂起 → T2 开始 → saveLog → T2 提交
→ T1 恢复 → T1 回滚
结果:订单没写入(回滚),日志保存了(独立提交)
适用场景:
- 日志记录:主业务失败也要留痕
- 审计记录:操作失败也要记录谁操作了什么
- 通知发送:消息发送失败不应阻止主业务回滚
- 性能监控:记录方法耗时,不应被业务事务回滚
⚠️ 注意事项:
- REQUIRES_NEW 会占用两个数据库连接,连接池小时可能死锁
- 外层事务挂起后,新事务和旧事务是完全独立的,互相不可见
面试关键句: “REQUIRES_NEW 总是开启独立事务,外层事务会被挂起。适合日志、审计等即使主业务失败也需要保存的场景。”
③ NESTED(嵌套事务,常用)
含义: 当前有事务就在当前事务内创建一个**保存点(Savepoint)**的子事务;没有就新建一个(等同于 REQUIRED)。子事务回滚不影响父事务,但父事务回滚会连带子事务一起回滚。
@Service
public class PointService {
@Transactional(propagation = Propagation.NESTED)
public void addPoints(Long userId, int points) {
pointMapper.insert(new Point(userId, points));
}
}
场景:批量发积分,部分失败不影响整体
@Service
public class BatchService {
@Autowired
private PointService pointService;
@Transactional // 父事务 T1
public void batchAddPoints(List<Long> userIds) {
for (Long userId : userIds) {
try {
pointService.addPoints(userId, 100); // 子事务
} catch (Exception e) {
log.warn("用户{}积分发放失败: {}", userId, e.getMessage());
// 子事务回滚到保存点,但父事务 T1 不受影响,继续循环
}
}
// 所有成功的都保留,失败的跳过
}
}
执行时序:
T1 开始
→ 用户1:Savepoint S1 → addPoints → 成功 → 释放 S1
→ 用户2:Savepoint S2 → addPoints → 失败 → 回滚到 S2(只回滚用户2的操作)
→ 用户3:Savepoint S3 → addPoints → 成功 → 释放 S3
T1 提交(用户1和3的积分生效,用户2的被回滚)
如果 T1 最终回滚 → 所有子事务(用户1、3)也一起回滚
NESTED vs REQUIRES_NEW 对比:
| 维度 | NESTED | REQUIRES_NEW |
|---|---|---|
| 事务数量 | 1个事务,用保存点实现 | 2个独立事务 |
| 数据库连接 | 1个 | 2个 |
| 子事务回滚 → 父事务 | ❌ 不影响 | ❌ 不影响 |
| 父事务回滚 → 子事务 | ✅ 一起回滚 | ❌ 不影响(已提交的不会撤销) |
| 数据库支持 | 需要 JDBC 驱动支持 Savepoint | 所有数据库都支持 |
| 适用场景 | 部分失败容错(批量操作) | 完全独立的操作(日志/审计) |
面试关键句: “NESTED 基于数据库 Savepoint 实现嵌套子事务,子事务回滚不影响父事务,但父事务回滚会连带子事务。适合批量操作中部分失败容错。”
④ SUPPORTS(跟随模式)
含义: 有事务就加入,没有就以非事务方式运行。
@Service
public class QueryService {
@Transactional(propagation = Propagation.SUPPORTS)
public List<Order> queryOrders() {
return orderMapper.selectList(null);
}
}
适用场景: 纯查询方法,不强制需要事务,但如果调用方有事务就复用(可以读到未提交的数据)。
面试关键句: “SUPPORTS 是佛系传播行为,有事务就用,没事务也无所谓,适合纯查询方法。”
⑤ NOT_SUPPORTED(挂起模式)
含义: 以非事务方式运行,如果当前有事务就挂起。
@Service
public class ReportService {
@Transactional(propagation = Propagation.NOT_SUPPORTED)
public void generateBigReport() {
// 生成超大报表,耗时很长
// 不需要事务,挂起外层事务释放连接,避免长时间占用
reportMapper.queryMillionRows();
}
}
适用场景:
- 耗时极长的操作(大报表生成、批量数据导出),不需要事务保护,避免长时间占用数据库连接
- 调用外部系统(HTTP/RPC),避免外部长时间不返回导致数据库连接被占满
面试关键句: “NOT_SUPPORTED 挂起当前事务,以非事务方式执行,适合长时间运行或调用外部系统的场景,避免数据库连接被长时间占用。”
⑥ MANDATORY(强制事务)
含义: 必须在事务中运行。如果当前没有事务,直接抛出
IllegalTransactionStateException。
@Service
public class TransferService {
@Transactional(propagation = Propagation.MANDATORY)
public void transfer(Long from, Long to, BigDecimal amount) {
// 转账操作必须在事务中,否则数据不一致
accountMapper.deduct(from, amount);
accountMapper.add(to, amount);
}
}
适用场景: 核心写操作,强制要求调用方必须开启事务,防止误用。相当于一个断言——“你必须给我事务,否则我不干活”。
面试关键句: “MANDATORY 强制要求在事务中运行,没事务就报错,用于保护核心写操作不被误调用。”
⑦ NEVER(禁止事务)
含义: 以非事务方式运行。如果当前有事务,直接抛出
IllegalTransactionStateException。
@Service
public class ExternalService {
@Transactional(propagation = Propagation.NEVER)
public void callThirdPartyAPI() {
// 调用第三方支付接口
// 如果在事务中调用,第三方超时会导致数据库连接长时间被占用
// 所以禁止在事务中调用
}
}
适用场景: 调用外部不可控的接口(第三方 API),禁止在事务中执行,防止外部超时拖垮数据库连接池。
面试关键句: “NEVER 禁止在事务中运行,有事务就报错,用于保护数据库连接不被外部调用拖垮。”
七种传播行为总结速记表
| 传播行为 | 外层有事务 | 外层无事务 | 适用场景 |
|---|---|---|---|
| REQUIRED(默认) | 加入当前事务 | 新建事务 | 绝大多数业务方法 |
| REQUIRES_NEW | 挂起当前,新建独立事务 | 新建事务 | 日志/审计(主业务失败也要保存) |
| NESTED | 在当前事务内建保存点子事务 | 新建事务 | 批量操作部分失败容错 |
| SUPPORTS | 加入当前事务 | 非事务运行 | 纯查询方法 |
| NOT_SUPPORTED | 挂起当前,非事务运行 | 非事务运行 | 耗时操作/调用外部系统 |
| MANDATORY | 加入当前事务 | 抛异常 | 核心写操作强制要求事务 |
| NEVER | 抛异常 | 非事务运行 | 禁止在事务中调用的外部接口 |
面试记忆口诀:
- REQUIRED = 顺其自然(有就加入,没有就自己开)
- REQUIRES_NEW = 独立自主(不管你,我自己开)
- NESTED = 寄人篱下(在你家开个铺,你塌了我跟着塌,我塌了你没事)
- SUPPORTS = 随遇而安(有就用,没有也行)
- NOT_SUPPORTED = 我不需要(你有我也不用,给我挂起)
- MANDATORY = 必须有(没事务我就报错)
- NEVER = 绝不能有(有事务我就报错)
事务隔离级别详解(面试必考 + 必须能举例)
为什么要隔离?
多个事务并发执行时,如果不加隔离,会出现三种问题:
| 问题 | 一句话解释 | 严重程度 |
|---|---|---|
| 脏读 | 读到了别人还没提交的数据,结果别人回滚了 | 🔴 最严重 |
| 不可重复读 | 同一事务中两次读同一行数据,结果不一样(别人改了并提交了) | 🟡 中等 |
| 幻读 | 同一事务中两次范围查询,结果条数不一样(别人新增/删除了并提交了) | 🟡 中等 |
① 脏读(Dirty Read)—— 读到了“假数据”
定义: 事务 A 读到了事务 B 尚未提交的修改,如果 B 回滚了,A 读到的就是从未存在过的“脏数据”。
模拟场景:
时间线 事务A(查询余额) 事务B(转账)
─────────────────────────────────────────────────
T1 BEGIN
T2 BEGIN
T3 UPDATE account SET balance = balance - 100 WHERE id = 1;
T4 SELECT balance FROM account WHERE id = 1;
→ 读到 900(事务B还没提交!)
T5 ROLLBACK(转账失败,回滚)
T6 → 余额实际上是 1000,但 A 读到的是 900
→ A 基于这个错误数据做了业务决策(比如判断余额<1000就发警告)
→ 警告发了,但余额其实没变 ← 脏读!
代码模拟:
// 事务A:查询余额(READ_UNCOMMITTED 隔离级别下会出现脏读)
@Transactional(isolation = Isolation.READ_UNCOMMITTED)
public void checkBalance() {
Integer balance = accountMapper.getBalance(1L); // T4: 读到 900(脏数据)
if (balance < 1000) {
alertService.sendLowBalanceAlert(); // 发了不该发的警告
}
}
// 事务B:转账(还没提交就回滚了)
@Transactional(isolation = Isolation.READ_UNCOMMITTED)
public void transfer() {
accountMapper.deduct(1L, 100); // T3: 扣了100但没提交
// 模拟业务异常
throw new RuntimeException("转账失败"); // T5: 回滚,余额恢复1000
}
危害: 基于不存在的数据做决策,比如给用户发了不该发的通知、做了不该做的扣款。
解决: 提升隔离级别到 READ_COMMITTED 及以上。
② 不可重复读(Non-Repeatable Read)—— 同一行数据两次读不一样
定义: 事务 A 两次读取同一行数据,中间事务 B 修改并提交了这行数据,导致 A 两次读到的值不同。
模拟场景:
时间线 事务A(查询余额) 事务B(修改余额)
─────────────────────────────────────────────────
T1 BEGIN
T2 SELECT balance FROM account WHERE id = 1;
→ 读到 1000(第一次读)
T3 BEGIN
T4 UPDATE account SET balance = 500 WHERE id = 1;
T5 COMMIT(修改已提交)
T6 SELECT balance FROM account WHERE id = 1;
→ 读到 500(第二次读,变了!)
→ 同一事务中两次读同一行结果不同 ← 不可重复读!
代码模拟:
@Transactional(isolation = Isolation.READ_COMMITTED) // Oracle/PostgreSQL 默认
public void checkBalanceTwice() {
Integer balance1 = accountMapper.getBalance(1L); // 第一次读:1000
// ... 一些业务处理 ...
Integer balance2 = accountMapper.getBalance(1L); // 第二次读:500(被别的事务改了)
// balance1 != balance2,业务逻辑可能出错
if (balance1.equals(balance2)) {
// 本以为余额没变,结果变了
doSomething(); // 执行了不该执行的逻辑
}
}
危害: 同一事务中基于多次读取的数据做比较/计算时,结果不可靠。
解决: 提升隔离级别到 REPEATABLE_READ 及以上。
③ 幻读(Phantom Read)—— 范围查询条数变了
定义: 事务 A 两次执行相同的范围查询,中间事务 B 新增或删除了符合条件的记录并提交,导致 A 两次查询的结果条数不同。
模拟场景:
时间线 事务A(统计订单数) 事务B(新增订单)
─────────────────────────────────────────────────
T1 BEGIN
T2 SELECT COUNT(*) FROM orders WHERE amount > 100;
→ 10 条(第一次查)
T3 BEGIN
T4 INSERT INTO orders(amount) VALUES(200);
T5 COMMIT(新增已提交)
T6 SELECT COUNT(*) FROM orders WHERE amount > 100;
→ 11 条(第二次查,多了1条"幻影"记录)
→ 同一事务中范围查询条数变了 ← 幻读!
代码模拟:
@Transactional(isolation = Isolation.READ_COMMITTED)
public void countBigOrders() {
int count1 = orderMapper.countByAmount(100); // 第一次:10条
// ... 业务处理 ...
int count2 = orderMapper.countByAmount(100); // 第二次:11条(别的事务插入了一条)
// count1 != count2,统计结果不一致
}
不可重复读 vs 幻读的区别:
| 维度 | 不可重复读 | 幻读 |
|---|---|---|
| 针对操作 | UPDATE/DELETE(修改已有行) | INSERT/DELETE(新增/删除行) |
| 针对粒度 | 同一行数据内容变了 | 范围查询的条数变了 |
| 解决方式 | 行级锁(共享锁) | 间隙锁(Gap Lock)/ MVCC |
解决: MySQL InnoDB 的 REPEATABLE_READ 通过 MVCC + Next-Key Lock 基本解决了幻读问题。
四种隔离级别总览
| 隔离级别 | 脏读 | 不可重复读 | 幻读 | 性能 | 说明 |
|---|---|---|---|---|---|
| READ_UNCOMMITTED | ⚠️ 可能 | ⚠️ 可能 | ⚠️ 可能 | 最高 | 几乎不用,能看到未提交数据 |
| READ_COMMITTED | ✅ 不可能 | ⚠️ 可能 | ⚠️ 可能 | 较高 | Oracle/PostgreSQL 默认,每次读都获取最新已提交数据 |
| REPEATABLE_READ | ✅ 不可能 | ✅ 不可能 | ✅ InnoDB基本不会 | 中等 | MySQL 默认,MVCC 保证同一事务多次读结果一致 |
| SERIALIZABLE | ✅ 不可能 | ✅ 不可能 | ✅ 不可能 | 最低 | 完全串行化,事务排队执行,性能差 |
Spring 中设置隔离级别:
@Transactional(isolation = Isolation.READ_COMMITTED)
public void queryOrder() {}
Spring 隔离级别枚举与数据库对应关系:
| Spring Isolation | 对应数据库级别 | 说明 |
|---|---|---|
DEFAULT |
使用数据库默认 | MySQL → REPEATABLE_READ,Oracle → READ_COMMITTED |
READ_UNCOMMITTED |
READ UNCOMMITTED | 最低隔离 |
READ_COMMITTED |
READ COMMITTED | Oracle/PG 默认 |
REPEATABLE_READ |
REPEATABLE READ | MySQL 默认 |
SERIALIZABLE |
SERIALIZABLE | 最高隔离 |
MySQL InnoDB 如何解决幻读?(面试加分项)
MySQL InnoDB 在 REPEATABLE_READ 级别下,通过两个机制基本解决了幻读:
1. 快照读(普通 SELECT)—— MVCC 解决
-- 普通查询走 MVCC,读到的是事务开始时的快照
SELECT * FROM orders WHERE amount > 100; -- 即使别的事务插入了新数据,这里读到的还是旧快照
原理: 每行数据维护多个版本(undo log),事务第一次查询时生成一个 Read View(读视图),后续相同查询复用这个 Read View,保证多次读取结果一致。
2. 当前读(加锁查询)—— Next-Key Lock 解决
-- 加锁查询走当前读,会对查询范围加 Next-Key Lock(Record Lock + Gap Lock)
SELECT * FROM orders WHERE amount > 100 FOR UPDATE; -- 锁住 amount > 100 的范围
-- 此时别的事务想 INSERT amount=200 的记录会被阻塞,直到锁释放
Next-Key Lock = Record Lock(行锁)+ Gap Lock(间隙锁):
- Record Lock:锁住已有行的索引
- Gap Lock:锁住索引之间的“间隙”,防止新数据插入
- 合在一起就是 Next-Key Lock,锁住一个左开右闭区间
⚠️ 注意: 快照读和当前读混用时仍可能出现“幻读”现象:
@Transactional // REPEATABLE_READ
public void demo() {
// 快照读:读到 10 条
List<Order> orders1 = orderMapper.selectList(...); // 10条
// 当前读:读到 11 条(别的事务插入了一条已提交)
Order order = orderMapper.selectByIdForUpdate(999L); // 能查到新插入的
// 再快照读:还是 10 条
List<Order> orders2 = orderMapper.selectList(...); // 10条
// orders1 和 orders2 一致(MVCC),但 selectByIdForUpdate 看到了新数据 → 混用导致的不一致
}
@Transactional 失效的 8 种场景(面试必背)
// ❌ 1. 方法非 public(动态代理只能拦截 public)
@Transactional
private void updateOrder() {} // 不生效
// ❌ 2. 同类内部方法调用(this 调用不经过代理)
@Service
public class OrderService {
public void methodA() {
this.methodB(); // 直接 this 调用,不经过代理,事务失效
}
@Transactional
public void methodB() {}
}
// ✅ 修复方式1:注入自己
// @Autowired private OrderService self; self.methodB();
// ✅ 修复方式2:使用 AopContext.currentProxy()
// ((OrderService) AopContext.currentProxy()).methodB();
// ✅ 修复方式3:将 methodB 拆到另一个 Service 中
// ❌ 3. 异常被 catch 吞掉
@Transactional
public void createOrder() {
try {
// 业务逻辑
} catch (Exception e) {
log.error("出错了", e); // 异常被吞,事务不回滚
}
}
// ✅ 修复:catch 后手动 throw 或手动 TransactionAspectSupport.currentTransactionStatus().setRollbackOnly()
// ❌ 4. 抛出非 RuntimeException(默认只回滚 RuntimeException 和 Error)
@Transactional
public void createOrder() throws Exception {
throw new Exception("检查异常"); // 不回滚!
}
// ✅ 修复:@Transactional(rollbackFor = Exception.class)
// ❌ 5. 数据库不支持事务(如 MyISAM 引擎)
// MySQL InnoDB 支持事务,MyISAM 不支持
// ❌ 6. 传播行为配置不当(如 NOT_SUPPORTED)
@Transactional
public void createOrder() {
logService.saveLog(); // saveLog 配了 NOT_SUPPORTED → 挂起当前事务,无事务执行
}
// ❌ 7. Bean 没有被 Spring 管理(没加 @Service 等)
// 普通 new 出来的对象,不经过 Spring 代理,事务不生效
// ❌ 8. 多线程调用(不同线程不在同一事务)
@Transactional
public void createOrder() {
new Thread(() -> {
orderMapper.insert(order); // 新线程,不在当前事务中
}).start();
}
// 原因:Spring 事务通过 ThreadLocal 绑定数据库连接,不同线程拿不到同一个连接
@Transactional 失效场景深度剖析(结合资产托管项目)
上面 8 种场景是“清单式”速记,面试时能背出来算及格。但要拿高分,需要讲清失效的本质和解决方案。本节按“本质 → 场景剖析(错误代码 + 正确代码 + 简历案例)→ 解决方案”展开。
一、失效的本质:两个前提条件同时满足,事务才生效
@Transactional 生效需要同时满足两个前提:
┌─────────────────────────────────────────────────────────┐
│ 前提①:调用必须经过 AOP 代理对象 │
│ ─────────────────────────────────────────────────── │
│ 调用方 ──► 代理对象(Proxy) ──► 目标对象(Target) │
│ ↑ │
│ TransactionInterceptor 在这里开启/提交/回滚事务 │
│ │
│ ❌ 破坏方式:this 自调用、非 public 方法、final 方法、 │
│ 对象不是 Spring Bean(new 出来的) │
└─────────────────────────────────────────────────────────┘
┌─────────────────────────────────────────────────────────┐
│ 前提②:异常能传播到代理层(或线程上下文一致) │
│ ─────────────────────────────────────────────────── │
│ TransactionInterceptor 靠 try-catch 目标方法的异常 │
│ 来判断"提交"还是"回滚": │
│ │
│ try { │
│ 目标方法执行 │
│ 正常返回 → commit() │
│ } catch (Throwable ex) { │
│ 判断 ex 是否匹配 rollbackFor → rollback() │
│ throw ex; ← 异常继续往外抛 │
│ } │
│ │
│ ❌ 破坏方式:catch 后不抛出、抛出不匹配的类型、 │
│ 在子线程里执行(ThreadLocal 拿不到连接) │
└─────────────────────────────────────────────────────────┘
一句话记忆:
Spring 事务 = AOP 代理拦截 + ThreadLocal 绑定数据库连接
只要「调用没走代理」或「异常没传出来/线程不对」,事务就失效
ThreadLocal 绑定连接的原理(这是理解多线程失效的关键):
// Spring 事务核心:TransactionSynchronizationManager
public abstract class TransactionSynchronizationManager {
// ★ 用 ThreadLocal 保存"当前线程的事务资源"(数据库连接)
private static final ThreadLocal<Map<Object, Object>> resources =
new NamedThreadLocal<>("Transactional resources");
// 开启事务时:把连接绑定到当前线程
public static void bindResource(Object key, Object value) {
Map<Object, Object> map = resources.get();
if (map == null) { map = new HashMap<>(); resources.set(map); }
map.put(key, value); // key = DataSource, value = ConnectionHolder
}
// 执行 SQL 时:从当前线程取连接(MyBatis 就是这么拿的)
public static Object getResource(Object key) {
Map<Object, Object> map = resources.get();
return map == null ? null : map.get(key);
}
}
/*
* 流程:
* ① 事务开启 → 从连接池拿一个 Connection → 设置 autoCommit=false
* → 绑定到当前线程的 ThreadLocal
* ② 执行 Mapper 方法 → MyBatis 通过 DataSourceUtils.getConnection()
* → 从 ThreadLocal 拿到同一个 Connection → 所有 SQL 共用一个连接 = 同一个事务
* ③ 方法结束 → 提交/回滚 → 归还连接 → 清空 ThreadLocal
*
* ★ 关键结论:ThreadLocal 是线程隔离的!
* 新开的线程拿不到主线程绑定的 Connection
* → 新线程里的 SQL 用另一个连接(自动提交)→ 不受主线程事务控制
*/
二、十大失效场景逐一剖析(含简历案例)
场景 1:方法非 public(你提到的重点)
原理: Spring AOP 的代理机制有方法可见性限制。
- JDK 动态代理:基于接口,只能代理接口里的 public 方法
- CGLIB 代理:基于子类继承重写,无法重写 private/final/static 方法(private 子类根本看不见)
@Service
public class InstructionService {
// ❌ 失效:private 方法,代理对象无法拦截
@Transactional
private void updateBalance(Instruction inst) {
instructionMapper.updateStatus(inst.getId(), "PAID");
// 如果下一行抛异常,上面这条 update 不会回滚!
throw new RuntimeException("余额不足");
}
// ❌ 同样失效:final 方法,CGLIB 无法重写
@Transactional
public final void cancelInstruction(Long id) {
instructionMapper.updateStatus(id, "CANCELLED");
}
// ❌ 同样失效:static 方法,不属于对象,无法代理
@Transactional
public static void batchUpdate(List<Long> ids) { }
}
为什么 “不生效” 而不是 “报错”? Spring 在解析 @Transactional 时,如果方法不是 public,直接跳过(AnnotationTransactionAttributeSource 里的 allowPublicMethodsOnly() 返回 true 时非 public 方法返回 null),不创建事务增强器——所以静默失效,这是它坑的地方。
✅ 解决方案:
@Service
public class InstructionService {
// ✅ 方案:改成 public,且不能用 final 修饰
@Transactional(rollbackFor = Exception.class)
public void updateBalance(Instruction inst) {
instructionMapper.updateStatus(inst.getId(), "PAID");
balanceMapper.deduct(inst.getAccountId(), inst.getAmount());
// 抛异常 → 两条更新一起回滚 ✅
}
}
// 如果确实不希望暴露成 public(比如只想内部用),
// 用「包级私有 + 拆到另一个类」或「编程式事务」:
@Service
public class InstructionServiceImpl implements InstructionService {
@Autowired
private TransactionTemplate transactionTemplate;
@Override
public void cancelInstruction(Long id) {
// 编程式事务:不受方法可见性限制
transactionTemplate.execute(status -> {
try {
instructionMapper.updateStatus(id, "CANCELLED");
return true;
} catch (Exception e) {
status.setRollbackOnly(); // 手动标记回滚
throw e;
}
});
}
}
简历案例(资产托管指令复核):
/**
* 资产托管:指令复核通过并出款
* 涉及两个库表更新,必须同成功同失败
*/
@Service
public class InstructionAuditService {
// ✅ public + rollbackFor + 超时控制
@Transactional(rollbackFor = Exception.class, timeout = 30)
public void auditAndPay(Long instructionId, String auditor) {
Instruction inst = instructionMapper.selectById(instructionId);
if (!"REVIEWING".equals(inst.getStatus())) {
throw new BizException("指令状态不允许复核");
}
// ① 更新指令状态为已审核
instructionMapper.updateStatus(instructionId, "APPROVED", auditor);
// ② 扣减头寸(资金)
positionMapper.deductPosition(inst.getAccountId(), inst.getAmount());
// ③ 记录审计日志
auditLogMapper.insert(buildLog(inst, auditor));
// 任一步抛异常 → 三步全部回滚,不会出现"状态改了但钱没扣"的账实不符
}
}
场景 2:同类内部方法调用(自调用)
原理: this.methodB() 里的 this 是目标对象而不是代理对象,绕过了代理 → 事务拦截器不执行。
@Service
public class InstructionService {
// ❌ 失效:this 是目标对象,不是代理
public void batchCancel(List<Long> ids) {
for (Long id : ids) {
this.cancelOne(id); // ← 直接调用本类方法,事务失效
}
}
@Transactional(rollbackFor = Exception.class)
public void cancelOne(Long id) {
instructionMapper.updateStatus(id, "CANCELLED");
throw new RuntimeException("模拟异常"); // 不会回滚!
}
}
✅ 三种解决方案:
// 方案1(推荐):注入自己(Spring 支持循环依赖注入代理对象)
@Service
public class InstructionService {
@Autowired
private InstructionService selfProxy; // 注入的是代理对象
public void batchCancel(List<Long> ids) {
for (Long id : ids) {
selfProxy.cancelOne(id); // ✅ 走代理,事务生效
}
}
@Transactional(rollbackFor = Exception.class)
public void cancelOne(Long id) { /* ... */ }
}
// 方案2:AopContext.currentProxy()(需开启 expose-proxy)
@EnableAspectJAutoProxy(exposeProxy = true) // 启动类或配置类上加
public class InstructionService {
public void batchCancel(List<Long> ids) {
for (Long id : ids) {
((InstructionService) AopContext.currentProxy()).cancelOne(id);
}
}
}
// ⚠️ 缺点:强耦合 Spring AOP API,且侵入业务代码,不如方案1清晰
// 方案3(最优):拆分成两个 Service(符合单一职责,也最自然)
@Service
public class InstructionBatchService { // 批处理服务
@Autowired
private InstructionService instructionService; // 注入另一个 Bean,天然是代理
public void batchCancel(List<Long> ids) {
for (Long id : ids) {
instructionService.cancelOne(id); // ✅ 跨 Bean 调用,事务生效
}
}
}
@Service
public class InstructionService {
@Transactional(rollbackFor = Exception.class)
public void cancelOne(Long id) { /* ... */ }
}
⚠️ 注意: 方案1、2 中,如果外层 batchCancel 也有 @Transactional,内层 cancelOne 默认 REQUIRED 会加入外层事务——此时内层异常抛到外层,整个大事务回滚(10 条全部回滚)。如果想“每条独立事务,单条失败不影响其他”,要用 REQUIRES_NEW:
@Transactional(propagation = Propagation.REQUIRES_NEW, rollbackFor = Exception.class)
public void cancelOne(Long id) { /* 每条指令独立事务 */ }
场景 3:异常被 catch 吞掉(你提到的重点)
原理: TransactionInterceptor 通过捕获目标方法抛出的异常来决定回滚。如果异常在方法内部被 catch 且未重新抛出,拦截器认为“方法正常返回” → 提交事务。
@Service
public class InstructionService {
// ❌ 失效:异常被吞,事务提交
@Transactional(rollbackFor = Exception.class)
public void createInstruction(InstructionDTO dto) {
try {
instructionMapper.insert(buildInstruction(dto));
int i = 1 / 0; // 模拟异常
positionMapper.freeze(dto.getAmount());
} catch (Exception e) {
log.error("创建指令失败", e); // ← 只是打了日志,异常没往外抛
// 结果:insert 被提交了!脏数据入库
}
}
}
✅ 四种解决方案(按推荐度排序):
// ✅ 方案1(最推荐):catch 后重新抛出,让事务回滚,异常交给全局异常处理器
@Transactional(rollbackFor = Exception.class)
public void createInstruction(InstructionDTO dto) {
try {
instructionMapper.insert(buildInstruction(dto));
positionMapper.freeze(dto.getAmount());
} catch (Exception e) {
log.error("创建指令失败,指令号:{}", dto.getInstructionNo(), e);
throw new BizException("创建指令失败:" + e.getMessage()); // ★ 重新抛出
}
}
// 上层由 @RestControllerAdvice 统一捕获 → 返回友好错误给前端
// ✅ 方案2:catch 后手动标记回滚(适合"吞掉异常但也要回滚"的场景)
@Transactional(rollbackFor = Exception.class)
public void createInstruction(InstructionDTO dto) {
try {
instructionMapper.insert(buildInstruction(dto));
positionMapper.freeze(dto.getAmount());
} catch (Exception e) {
log.error("创建指令失败", e);
// ★ 手动标记仅回滚,方法正常返回(不抛异常)
TransactionAspectSupport.currentTransactionStatus().setRollbackOnly();
// 适用场景:批量任务中单条失败不影响整体流程,但本条要回滚
}
}
// ⚠️ 注意:标记后如果方法正常结束,Spring 会抛出 UnexpectedRollbackException
// 除非调用方也容忍——所以这个方案要小心,谨慎使用
// ✅ 方案3:把异常转成 Spring 能识别的 RuntimeException
catch (Exception e) {
throw new BizException("创建失败", e); // BizException 继承 RuntimeException
}
// ✅ 方案4(特殊场景):内层方法用 REQUIRES_NEW 独立事务
// 内层自己回滚,异常不污染外层
简历案例(资产托管:批量指令拆分):
/**
* 日终清算:批量拆分指令,要求"单条失败不影响其他指令,但失败的要记录"
*/
@Transactional(rollbackFor = Exception.class)
public BatchSplitResult batchSplit(List<Instruction> instructions) {
BatchSplitResult result = new BatchSplitResult();
for (Instruction inst : instructions) {
try {
splitOne(inst); // 内层 REQUIRES_NEW 独立事务
result.addSuccess(inst.getId());
} catch (Exception e) {
// ★ 这里 catch 是安全的:因为 splitOne 是 REQUIRES_NEW 独立事务
// 它自己已经回滚了,不会污染外层
log.error("指令拆分失败,id={}", inst.getId(), e);
result.addFailure(inst.getId(), e.getMessage());
}
}
// 外层事务只负责把"处理结果"入库
batchRecordMapper.insert(result.toRecord());
return result;
}
@Transactional(propagation = Propagation.REQUIRES_NEW, rollbackFor = Exception.class)
public void splitOne(Instruction inst) {
// 单条指令的拆分逻辑,独立事务,失败只回滚自己
instructionMapper.updateStatus(inst.getId(), "SPLITTED");
splitDetailMapper.batchInsert(doSplit(inst));
}
场景 4:异常类型不匹配(rollbackFor)
原理: Spring 默认只在抛出 RuntimeException 和 Error 时回滚。受检异常(Checked Exception,如 IOException、SQLException、自定义的非 Runtime 异常)默认不回滚。
// ❌ 失效:抛出受检异常,默认不回滚
@Transactional
public void importInstructions(File file) throws IOException {
List<Instruction> list = parseExcel(file); // 可能抛 IOException
instructionMapper.batchInsert(list);
// 如果后续抛 IOException → 事务提交!已插入的数据留在库里
}
// ❌ 失效:自定义异常继承 Exception(非 RuntimeException)
public class BizException extends Exception { } // ← 受检异常
@Transactional
public void create() throws BizException {
throw new BizException("业务异常"); // 不回滚!
}
✅ 解决方案:
// ✅ 方案1:显式指定 rollbackFor(团队规范:所有 @Transactional 都要写)
@Transactional(rollbackFor = Exception.class)
public void importInstructions(File file) throws IOException { /* ... */ }
// ✅ 方案2:自定义业务异常继承 RuntimeException(推荐,避免到处写 rollbackFor)
public class BizException extends RuntimeException {
private final int code;
public BizException(String message) { super(message); this.code = 500; }
public BizException(int code, String message) { super(message); this.code = code; }
}
// ✅ 方案3:全局 AOP 统一设置 rollbackFor(团队级方案,一劳永逸)
// 自定义注解,替代原生 @Transactional
@Target({ElementType.METHOD, ElementType.TYPE})
@Retention(RetentionPolicy.RUNTIME)
@Transactional(rollbackFor = Exception.class) // ★ 预设 rollbackFor
public @interface BizTransactional {
Propagation propagation() default Propagation.REQUIRED;
int timeout() default 30;
boolean readOnly() default false;
}
// 使用:@BizTransactional ← 团队统一用这个,不会再漏 rollbackFor
面试话术: “我们团队踩过这个坑:自定义的业务异常继承了 Exception 而不是 RuntimeException,导致 @Transactional 不回滚。后来做了两件事:① 所有业务异常统一继承 RuntimeException;② 封装了 @BizTransactional 注解,内部预设 rollbackFor = Exception.class,团队强制用这个而不是原生注解——从规范上杜绝这类问题。”
场景 5:多线程调用(你提到的重点)
原理: 事务上下文(数据库连接)存在 ThreadLocal 里,线程隔离。新线程拿不到主线程的 Connection,只能用新连接(自动提交)→ 不受主线程事务控制。
// ❌ 失效:子线程的 SQL 不在主线程事务里
@Service
public class InstructionService {
@Transactional(rollbackFor = Exception.class)
public void batchProcess(List<Instruction> list) {
// 主线程:这条在主事务里
instructionMapper.updateStatus(list.get(0).getId(), "PROCESSING");
// 子线程:完全独立,不受事务控制
CompletableFuture.runAsync(() -> {
list.forEach(inst -> {
instructionMapper.updateStatus(inst.getId(), "DONE");
// ⚠️ 这些更新是自动提交的,主线程回滚也不影响它们
});
});
throw new RuntimeException("主线程异常");
// 结果:主线程的 PROCESSING 回滚了,但子线程的 DONE 已经提交 → 数据不一致
}
}
⚠️ 更隐蔽的问题:子线程还可能死锁/连接耗尽
主线程持有事务连接(未提交,持锁)
↓
子线程执行 SQL,需要拿新的连接
↓
如果子线程要操作的行被主线程锁住(比如同一条指令记录)
↓
子线程等待主线程释放锁,主线程等待子线程执行完
↓
★ 死锁!而且主线程事务连接不释放,连接池可能被耗尽
✅ 五种解决方案(详见下一节的专题讨论):
// 方案速览(详细代码见下文"多线程事务专题")
// ① 能同步就别异步(最简单,优先考虑)
// ② TransactionSynchronization 回调:主线程事务提交后再执行异步逻辑
// ③ 子线程用 REQUIRES_NEW 独立事务(接受最终一致性)
// ④ TransactionTemplate 在子线程内开启独立事务
// ⑤ RocketMQ 事务消息:异步 + 最终一致性(你的社区平台项目就是这么做的)
场景 6:多数据源未指定事务管理器(你提到的重点,简历强相关)
原理: 当项目里有多个数据源时,Spring 容器里就有多个 PlatformTransactionManager。@Transactional 不写 transactionManager 时用的是默认(Primary)那个——如果你的 SQL 走的是另一个数据源,事务管理器管的是错误的连接,等于没事务。
// 典型场景(你的资产托管项目:Oracle → GaussDB 迁移期间双数据源并存)
@Configuration
public class DataSourceConfig {
@Bean
@Primary // 主数据源:GaussDB(新库)
public DataSource gaussDataSource() { return createGaussDataSource(); }
@Bean
public DataSource oracleDataSource() { return createOracleDataSource(); } // 老库
// 事务管理器也要配多个
@Bean
@Primary // 默认事务管理器
public PlatformTransactionManager gaussTxManager(
@Qualifier("gaussDataSource") DataSource ds) {
return new DataSourceTransactionManager(ds);
}
@Bean
public PlatformTransactionManager oracleTxManager(
@Qualifier("oracleDataSource") DataSource ds) {
return new DataSourceTransactionManager(ds);
}
}
@Service
public class MigrationService {
// ❌ 失效:没指定 transactionManager,用的是默认的 gaussTxManager
// 但 SQL 走的是 Oracle(老库),管和用的不是同一个连接 → 事务失效
@Transactional(rollbackFor = Exception.class)
public void migrateInstruction(Long id) {
oracleInstructionMapper.updateStatus(id, "MIGRATED"); // 走 Oracle
oracleDetailMapper.insert(detail); // 走 Oracle
throw new RuntimeException("迁移失败");
// 结果:两条 Oracle 更新都提交了,没有回滚!
}
// ✅ 正确:显式指定事务管理器
@Transactional(transactionManager = "oracleTxManager", rollbackFor = Exception.class)
public void migrateInstruction(Long id) {
oracleInstructionMapper.updateStatus(id, "MIGRATED");
oracleDetailMapper.insert(detail);
throw new RuntimeException("迁移失败");
// ✅ 回滚,因为事务管理器管的就是 Oracle 的连接
}
}
⚠️ 动态数据源(AbstractRoutingDataSource)的坑:
// 动态数据源 + 事务的经典陷阱
@Service
public class DynamicTxService {
// ❌ 危险:事务开启时连接已经确定了,之后再切换数据源无效!
@Transactional(rollbackFor = Exception.class)
public void crossDataSourceOperate() {
DynamicContext.setDataSource("oracle"); // 想切到 Oracle
oracleMapper.update(a);
// ⚠️ 事务在方法开始时就已经从默认数据源(GaussDB)拿了连接并绑定 ThreadLocal
// 这里切换数据源对"已绑定的连接"无效,SQL 仍然走 GaussDB!
DynamicContext.setDataSource("gauss");
gaussMapper.update(b);
throw new RuntimeException();
// 结果:只有 GaussDB 的操作回滚了,Oracle 的操作在另一个连接上已提交
}
}
// 原因(重要原理):
// ① @Transactional 方法执行前,TransactionInterceptor 先开启事务
// → 调用 DataSourceTransactionManager.doBegin()
// → 从数据源拿一个 Connection 并绑定到 ThreadLocal
// ② 之后再调用 DynamicContext.setDataSource() 只改了路由 key
// → 但 MyBatis 执行 SQL 时通过 DataSourceUtils.getConnection()
// → 从 ThreadLocal 里拿到的是事务开始时绑定的那个连接(不会重新路由!)
// ★ 结论:事务一旦开启,数据源就锁定了,事务内无法切换数据源
// ✅ 解决方案1:拆成两个方法,各自指定事务管理器(推荐)
@Service
public class CrossDataSourceService {
@Transactional(transactionManager = "oracleTxManager", rollbackFor = Exception.class)
public void updateOracle(Instruction inst) {
oracleMapper.update(inst);
}
@Transactional(transactionManager = "gaussTxManager", rollbackFor = Exception.class)
public void updateGauss(Instruction inst) {
gaussMapper.update(inst);
}
// 外层编排(无事务或用分布式事务)
public void syncOperate(Instruction inst) {
updateOracle(inst); // 独立事务1
updateGauss(inst); // 独立事务2
// ⚠️ 注意:这是两个独立本地事务,不是原子操作!
// 如果 updateGauss 失败,updateOracle 已提交 → 数据不一致
// 需要补偿机制或分布式事务
}
}
// ✅ 解决方案2:用分布式事务(Seata / Atomikos / JTA)
// 见本文档 2.5 节 Seata 部分
// ✅ 解决方案3:业务上规避——避免跨库事务
// 迁移期间的临时方案:双写 + 对账补偿
简历案例(Oracle → GaussDB 迁移期间的方案):
/**
* 资产托管:信创迁移期间的双写方案
*
* 背景:迁移过渡期 Oracle 和 GaussDB 并存,需要保证两边数据一致
* 难点:跨库无法用本地事务保证原子性
* 方案:本地事务 + 事务同步器 + 补偿任务
*/
@Service
public class DualWriteService {
/**
* 双写:先写 GaussDB(主库,强一致),事务提交后再异步写 Oracle(从库,最终一致)
*/
@Transactional(transactionManager = "gaussTxManager", rollbackFor = Exception.class)
public void saveInstruction(Instruction inst) {
// ① 主库写入(在主事务内,强一致)
gaussInstructionMapper.insert(inst);
gaussPositionMapper.deduct(inst.getAccountId(), inst.getAmount());
// ② 注册事务同步回调:主事务提交成功后才写 Oracle
TransactionSynchronizationManager.registerSynchronization(
new TransactionSynchronization() {
@Override
public void afterCommit() {
// ★ 主事务已确认提交,这里异步同步到 Oracle
// 失败不影响主流程,由补偿任务兜底
CompletableFuture.runAsync(() -> {
try {
oracleInstructionMapper.insert(inst);
} catch (Exception e) {
log.error("Oracle 同步失败,记录待补偿,id={}", inst.getId(), e);
compensateLogMapper.insert(buildCompensateLog(inst));
}
});
}
}
);
// 主事务回滚 → afterCommit 不执行 → 不会写 Oracle ✅
}
}
/**
* 补偿任务:定时扫描待补偿日志,重试同步(最终一致性)
*/
@Component
public class CompensateJob {
@Scheduled(cron = "0 */5 * * * ?")
public void retryFailedSync() {
List<CompensateLog> pending = compensateLogMapper.selectPending(100);
pending.forEach(log -> {
try {
oracleInstructionMapper.insert(log.getPayload());
compensateLogMapper.markSuccess(log.getId());
} catch (Exception e) {
compensateLogMapper.incrRetryCount(log.getId()); // 重试次数+1
// 超过阈值告警,人工介入
}
});
}
}
面试话术: “我们在 Oracle 到 GaussDB 的迁移期遇到过多数据源事务问题。最初是 @Transactional 没指定 transactionManager,默认用了 GaussDB 的事务管理器,但 SQL 走的是 Oracle,等于没事务——这个坑很隐蔽,因为代码看起来完全正常。
解决方案分三层:① 所有涉及非默认数据源的方法显式指定 transactionManager;② 需要跨库一致性的场景,用’主库本地事务 + TransactionSynchronization.afterCommit() 回调 + 补偿任务’保证最终一致,而不是硬上分布式事务(性能代价大);③ 动态数据源场景下特别注意——事务一旦开启,数据源就锁定了,事务内切数据源无效,必须拆成多个方法各自指定事务管理器。”
场景 7:传播行为配置错误
@Service
public class InstructionService {
@Transactional(rollbackFor = Exception.class)
public void createInstruction(InstructionDTO dto) {
instructionMapper.insert(buildInstruction(dto));
// ❌ 日志方法用了 NOT_SUPPORTED → 挂起当前事务,以非事务方式执行
auditLogService.saveLog();
throw new RuntimeException();
// 结果:insert 回滚了,但日志已提交(如果这是你想要的就没问题,
// 如果不想要日志留存就是配置错了)
}
}
@Service
public class AuditLogService {
// NOT_SUPPORTED:挂起当前事务,非事务执行
@Transactional(propagation = Propagation.NOT_SUPPORTED)
public void saveLog() { /* 插入审计日志 */ }
}
⚠️ 常见误区:以为 REQUIRES_NEW 能让内层异常不影响外层
@Transactional(rollbackFor = Exception.class)
public void outer() {
mapper.insert(a);
try {
innerService.inner(); // REQUIRES_NEW
} catch (Exception e) {
log.error("内层失败", e); // catch 住了
}
// 外层继续
}
@Service
public class InnerService {
@Transactional(propagation = Propagation.REQUIRES_NEW, rollbackFor = Exception.class)
public void inner() {
throw new RuntimeException(); // 内层独立回滚
}
}
// ✅ 这个是对的:内层回滚,外层 catch 后继续,外层事务不受影响
// ⚠️ 但反过来不行:如果内层 REQUIRED(默认),内层异常会标记整个事务 rollback-only
// → 外层提交时抛 UnexpectedRollbackException
⚠️ 另一个坑:REQUIRED 内层异常被外层 catch → UnexpectedRollbackException
@Transactional(rollbackFor = Exception.class)
public void outer() {
mapper.insert(a);
try {
innerService.inner(); // 默认 REQUIRED,加入外层事务
} catch (Exception e) {
log.error("忽略内层异常", e);
}
// 方法正常返回 → 尝试 commit
// ❌ 抛 UnexpectedRollbackException!
// 因为内层异常已经把事务标记为 rollback-only,
// 外层却想正常提交,Spring 发现矛盾就报错
}
// ✅ 解决:要么不 catch(让异常传播出去),要么内层用 REQUIRES_NEW
场景 8:数据库/表引擎不支持事务
-- MySQL 的 MyISAM 引擎不支持事务
CREATE TABLE t_log (id BIGINT, content VARCHAR(500)) ENGINE = MyISAM;
-- 即使加了 @Transactional,MyISAM 表的操作也不会回滚
-- ✅ 检查引擎
SHOW TABLE STATUS WHERE Name = 't_instruction';
-- 确认 Engine = InnoDB
-- ✅ 修改引擎
ALTER TABLE t_log ENGINE = InnoDB;
面试补充: “MySQL 里 CREATE TABLE、ALTER TABLE、DROP TABLE、TRUNCATE 这类 DDL 语句会隐式提交事务(implicit commit),即使在事务里执行也无法回滚。所以不要在事务方法中做 DDL 操作。”
场景 9:Bean 不是 Spring 管理的对象
// ❌ 失效:自己 new 出来的对象,不经过 Spring 容器,没有代理
public class InstructionController {
@PostMapping("/create")
public void create() {
InstructionService service = new InstructionService(); // ← new 出来的
service.createInstruction(dto); // 事务不生效!
}
}
// ✅ 正确:由 Spring 注入
@RestController
public class InstructionController {
@Autowired
private InstructionService instructionService; // Spring 代理对象
}
⚠️ 其他同类情况:
- 工具类里用
static方法调用 Mapper,事务不生效 - 对象被
final修饰的类(CGLIB 无法继承) - 使用
@Bean但没交给 Spring 管理(如 XML 配置遗漏)
场景 10:事务方法内做了“不可回滚”的操作
@Transactional(rollbackFor = Exception.class)
public void processInstruction(Long id) {
instructionMapper.updateStatus(id, "PROCESSING");
// ⚠️ 这些操作事务回滚不了!
restTemplate.postForObject("http://bank-api/notify", req, String.class); // 远程调用
redisTemplate.opsForValue().set("inst:" + id, "PROCESSING"); // Redis
kafkaTemplate.send("instruction-topic", event); // 发消息
fileService.uploadToOss(file); // 文件存储
smsService.send(phone, "指令已处理"); // 发短信
throw new RuntimeException();
// 结果:MySQL 回滚了,但远程调用已发生、Redis 已写入、消息已发出、短信已发送
// → 数据不一致(经典的"事务边界"问题,详见下文专题)
}
// ✅ 解决:事务提交后再执行这些操作(TransactionSynchronization)
@Transactional(rollbackFor = Exception.class)
public void processInstruction(Long id) {
instructionMapper.updateStatus(id, "PROCESSING");
TransactionSynchronizationManager.registerSynchronization(
new TransactionSynchronization() {
@Override
public void afterCommit() {
// ★ 事务确认提交后才执行这些副作用操作
kafkaTemplate.send("instruction-topic", event);
smsService.send(phone, "指令已处理");
}
}
);
}
专题一:多线程事务问题与解决方案(重点)
这是面试的高频深挖点,也是实际开发中最容易出错的地方。
问题本质回顾
Spring 事务的三个"线程相关"事实:
① 数据库连接通过 ThreadLocal 绑定到线程
② ThreadLocal 是线程隔离的,子线程拿不到父线程的资源
(严格说 InheritableThreadLocal 可以,但 Spring 用的是普通 ThreadLocal)
③ 事务的提交/回滚由"开启事务的那个线程"决定
推论:
· 子线程的数据库操作不在父线程事务里(可能已提交,父线程回滚也拉不回来)
· 子线程如果要事务,必须自己开启(独立事务)
· 跨线程无法保证原子性(要实现就得用分布式事务或最终一致性)
解决方案 1:能同步就别异步(优先考虑)
// ❌ 原本:为了"快"用了并行流,结果事务失效
@Transactional(rollbackFor = Exception.class)
public void batchUpdate(List<Instruction> list) {
list.parallelStream().forEach(inst -> { // ← 并行流底层是 ForkJoinPool!
instructionMapper.updateStatus(inst.getId(), "DONE");
});
// ⚠️ parallelStream 用的公共线程池,事务上下文完全丢失
}
// ✅ 改成串行(如果数据量不大,串行可能更快——因为省去了线程切换和连接开销)
@Transactional(rollbackFor = Exception.class)
public void batchUpdate(List<Instruction> list) {
for (Instruction inst : list) {
instructionMapper.updateStatus(inst.getId(), "DONE");
}
// 或者用批量 SQL,性能更好:
instructionMapper.batchUpdateStatus(ids, "DONE"); // 一条 SQL 搞定
}
// ⚠️ 重要提醒:并行流 parallelStream 也会丢失事务上下文!
// 很多人以为只有 new Thread 才有问题,其实 parallelStream 一样
决策建议: 事务内的批量操作,优先用批量 SQL(batchInsert/batchUpdate)而不是多线程并行。原因:① 不破坏事务;② 减少连接占用;③ 减少线程切换开销;④ 代码简单。
解决方案 2:TransactionSynchronization 回调(推荐,保证时序)
适用场景: 主流程必须在事务内完成,异步操作(发消息、通知、清理)在事务确认提交后再执行。
@Service
public class InstructionService {
@Transactional(rollbackFor = Exception.class)
public void approveInstruction(Long id) {
Instruction inst = instructionMapper.selectById(id);
// ① 事务内:核心数据变更(强一致)
instructionMapper.updateStatus(id, "APPROVED");
positionMapper.deduct(inst.getAccountId(), inst.getAmount());
auditLogMapper.insert(buildLog(inst));
// ② 注册回调:事务提交成功后才执行
TransactionSynchronizationManager.registerSynchronization(
new TransactionSynchronization() {
@Override
public void afterCommit() {
// ★ 只有事务成功提交才会走到这里
// 适合:发消息、发通知、清理缓存、调用外部系统
CompletableFuture.runAsync(() -> {
kafkaTemplate.send("instruction-approved", buildEvent(inst));
notifyDownstream(inst);
});
}
@Override
public void afterCompletion(int status) {
// status = STATUS_COMMITTED / STATUS_ROLLED_BACK / STATUS_UNKNOWN
if (status == STATUS_ROLLED_BACK) {
log.warn("指令审批事务已回滚,取消后续处理,id={}", id);
// 可以在这里做回滚后的补偿(如释放预占的资源)
}
}
}
);
// 事务回滚 → afterCommit 不执行 → 不会误发消息 ✅
}
}
TransactionSynchronization 的回调时机:
事务开始
↓
业务方法执行(SQL 操作)
↓
commit 前 → beforeCommit()
↓
实际提交 → 数据库 commit
↓
提交成功 → afterCommit() ★ 发消息/通知放这里
↓
最终 → afterCompletion(status) ★ 无论成功失败都执行,做清理
↓
(如果回滚)
↓
回滚完成 → afterCompletion(STATUS_ROLLED_BACK)
⚠️ 注意: registerSynchronization 必须在事务激活状态下调用(即 @Transactional 方法内),否则抛 IllegalStateException: Transaction synchronization is not active。安全写法:
// 先判断是否真的在事务里
if (TransactionSynchronizationManager.isSynchronizationActive()) {
TransactionSynchronizationManager.registerSynchronization(sync);
} else {
// 不在事务里,直接执行(或按需处理)
doAfterCommitLogic();
}
解决方案 3:子线程用独立事务(TransactionTemplate)
适用场景: 子线程确实需要事务保证(自己的原子性),但不要求和主线程原子一致。
@Service
public class InstructionService {
@Autowired
@Qualifier("gaussTxManager")
private PlatformTransactionManager txManager;
@Transactional(rollbackFor = Exception.class)
public void processBatch(List<Long> ids) {
// 主线程事务:记录批次状态
batchMapper.updateStatus(batchId, "PROCESSING");
// 子线程:每个线程用自己的独立事务
List<CompletableFuture<Void>> futures = ids.stream()
.map(id -> CompletableFuture.runAsync(() -> {
// ★ 子线程内用 TransactionTemplate 开启独立事务
TransactionTemplate template = new TransactionTemplate(txManager);
template.setPropagationBehavior(TransactionDefinition.PROPAGATION_REQUIRES_NEW);
template.execute(status -> {
try {
instructionMapper.updateStatus(id, "PROCESSING");
doHeavyCalculation(id); // 耗时计算
instructionMapper.updateStatus(id, "DONE");
return true;
} catch (Exception e) {
status.setRollbackOnly(); // 子线程事务回滚
log.error("处理失败,id={}", id, e);
return false;
}
});
}, taskExecutor)) // ★ 用自定义线程池,别用默认
.collect(Collectors.toList());
// 等待所有子任务完成
CompletableFuture.allOf(futures.toArray(new CompletableFuture[0])).join();
}
}
⚠️ 关键点:
- 子线程必须显式指定事务管理器(多数据源场景)
- 用
REQUIRES_NEW,确保是独立事务 - 必须用自定义线程池(
ThreadPoolTaskExecutor),不要用CompletableFuture默认的 ForkJoinPool(线程数 = CPU 核数,且可能被其他任务共用导致阻塞)
// 线程池配置(必须)
@Configuration
public class ExecutorConfig {
@Bean("instructionExecutor")
public Executor instructionExecutor() {
ThreadPoolTaskExecutor executor = new ThreadPoolTaskExecutor();
executor.setCorePoolSize(8);
executor.setMaxPoolSize(16);
executor.setQueueCapacity(500);
executor.setThreadNamePrefix("inst-");
// ★ 重要:拒绝策略,防止队列满时 OOM
executor.setRejectedExecutionHandler(new ThreadPoolExecutor.CallerRunsPolicy());
// ★ 重要:传递上下文(TraceId、用户信息),否则子线程日志链路断了
executor.setTaskDecorator(new ContextCopyTaskDecorator());
executor.initialize();
return executor;
}
}
// 上下文传递(SkyWalking TraceId / 用户信息)
public class ContextCopyTaskDecorator implements TaskDecorator {
@Override
public Runnable decorate(Runnable runnable) {
RequestAttributes attrs = RequestContextHolder.getRequestAttributes();
Map<String, String> mdc = MDC.getCopyOfContextMap();
return () -> {
try {
if (attrs != null) RequestContextHolder.setRequestAttributes(attrs);
if (mdc != null) MDC.setContextMap(mdc);
runnable.run();
} finally {
RequestContextHolder.resetRequestAttributes();
MDC.clear();
}
};
}
}
解决方案 4:@Async + REQUIRES_NEW(简洁但要注意坑)
@Service
public class NotifyService {
// @Async 让方法在独立线程执行,REQUIRES_NEW 让它有独立事务
@Async("instructionExecutor")
@Transactional(propagation = Propagation.REQUIRES_NEW, rollbackFor = Exception.class)
public void asyncNotify(Long instructionId) {
// ⚠️ @Async 和 @Transactional 同时用时的坑:
// ① @Async 必须在 @Transactional 之前(AOP 顺序)
// ② 异步方法的异常无法通过返回值捕获,必须自己 try-catch
// ③ 调用方拿不到异常,需要额外的结果记录机制
try {
notificationMapper.insert(buildNotify(instructionId));
restTemplate.postForObject(bankApiUrl, req, String.class);
} catch (Exception e) {
log.error("异步通知失败", e);
// 记录失败,由补偿任务重试
failedNotifyMapper.insert(buildFailedRecord(instructionId, e));
}
}
}
// 调用方
@Transactional(rollbackFor = Exception.class)
public void approve(Long id) {
instructionMapper.updateStatus(id, "APPROVED");
notifyService.asyncNotify(id); // 异步,不阻塞主事务
}
// ⚠️ 注意:asyncNotify 是异步的,主事务提交时它可能还没执行完
// 如果主事务回滚,异步任务可能已经执行 → 需要用方案2的 afterCommit 来保证时序
⚠️ @Async 的失效场景(和 @Transactional 类似):
- 同类内部调用(this.xxx())
- 非 public 方法
- 没在启动类加
@EnableAsync - 返回值不是
void或Future
解决方案 5:RocketMQ 事务消息(最终一致性,你的社区平台项目)
适用场景: 跨服务、跨库的最终一致性。这是你的项目四(社区平台)采用过的方案。
/**
* 资产托管:指令出款,需要通知账务系统(跨服务)
* 要求:指令状态和账务记录最终一致
*/
@Service
public class InstructionPayService {
@Autowired
private RocketMQTemplate rocketMQTemplate;
/**
* 发送半事务消息:
* 1. 先发 half 消息到 MQ(此时消费者看不见)
* 2. 执行本地事务
* 3. 根据本地事务结果 commit/rollback 消息
*/
public void payInstruction(Long instructionId) {
String transactionId = UUID.randomUUID().toString();
// 发送事务消息
rocketMQTemplate.sendMessageInTransaction(
"instruction-pay-topic",
MessageBuilder.withPayload(buildPayEvent(instructionId))
.setHeader("txId", transactionId)
.build(),
instructionId // 传给本地事务执行器的参数
);
}
/**
* 本地事务执行器:MQ 会回调这个方法
*/
@RocketMQTransactionListener
class PayTransactionListener implements RocketMQLocalTransactionListener {
// ① 执行本地事务
@Override
@Transactional(rollbackFor = Exception.class) // ★ 本地事务照常用
public RocketMQLocalTransactionState executeLocalTransaction(Message msg, Object arg) {
Long instructionId = (Long) arg;
try {
// 本地事务:更新指令状态 + 扣减头寸
instructionMapper.updateStatus(instructionId, "PAID");
positionMapper.deduct(inst.getAccountId(), inst.getAmount());
// ★ 关键:本地事务和消息状态在同一事务里?不行!
// 解决:额外记录一张"事务消息表",和本地事务同库同事务
txMessageMapper.insert(buildTxMsg(msg, "COMMIT"));
return RocketMQLocalTransactionState.COMMIT; // 提交消息
} catch (Exception e) {
log.error("本地事务失败", e);
return RocketMQLocalTransactionState.ROLLBACK; // 回滚消息
}
}
// ② 事务回查(MQ 没收到确认时回调,防止本地事务成功但消息状态丢失)
@Override
public RocketMQLocalTransactionState checkLocalTransaction(Message msg) {
String txId = msg.getHeaders().get("txId", String.class);
// 查事务消息表,判断本地事务是否成功
TxMessage txMsg = txMessageMapper.selectByTxId(txId);
if (txMsg == null) {
return RocketMQLocalTransactionState.ROLLBACK;
}
return "COMMIT".equals(txMsg.getStatus())
? RocketMQLocalTransactionState.COMMIT
: RocketMQLocalTransactionState.ROLLBACK;
}
}
}
/**
* 消费端:幂等消费(你的项目四写了幂等键)
*/
@Component
@RocketMQMessageListener(topic = "instruction-pay-topic", consumerGroup = "accounting-group")
public class PayEventConsumer implements RocketMQListener<PayEvent> {
@Override
public void onMessage(PayEvent event) {
// ★ 幂等:先查是否处理过
String idempotentKey = "pay:" + event.getInstructionId();
if (!idempotentCache.tryLock(idempotentKey)) {
log.info("消息已处理,跳过,key={}", idempotentKey);
return;
}
// 幂等键:userId + activityId + date(你简历项目四的设计)
try {
accountingService.recordPayment(event);
} catch (Exception e) {
log.error("消费失败,等待重试", e);
throw e; // 抛异常让 MQ 重试
}
}
}
方案对比与选型(面试要能说清楚):
| 方案 | 原子性 | 性能 | 复杂度 | 适用场景 |
|---|---|---|---|---|
| ① 同步串行 | ✅ 强一致 | 低 | ⭐ | 数据量小、能接受耗时(首选) |
| ② afterCommit 回调 | ✅ 主流程强一致,后续最终一致 | 高 | ⭐⭐ | 事务提交后发消息/通知(最常用) |
| ③ TransactionTemplate 独立事务 | ❌ 各线程独立 | 高 | ⭐⭐⭐ | 批量处理,单条失败不影响整体 |
| ④ @Async + REQUIRES_NEW | ❌ 独立 | 高 | ⭐⭐⭐ | 简单异步,能接受时序不保证 |
| ⑤ RocketMQ 事务消息 | ❌ 最终一致 | 高 | ⭐⭐⭐⭐ | 跨服务、跨库(你的项目四) |
| ⑥ Seata 分布式事务 | ✅ 强一致(有代价) | 中 | ⭐⭐⭐⭐⭐ | 必须强一致的跨库场景(少用) |
面试话术: “多线程事务问题的本质是 ThreadLocal 线程隔离——事务上下文存在 ThreadLocal 里,子线程拿不到父线程的连接。我们的处理原则分三层:
第一,能同步就别异步。事务内的批量操作优先用批量 SQL 串行执行,很多人用 parallelStream 想提速,结果事务直接失效,而且批量 SQL 往往比多线程更快(省去了线程切换和额外的连接开销)。
第二,如果确实需要异步,用 TransactionSynchronization.afterCommit() 回调——核心数据在事务内保证强一致,发消息、发通知这些副作用操作等事务确认提交后再异步执行,这样事务回滚就不会误发消息。
第三,跨服务的场景用 RocketMQ 事务消息保证最终一致,消费端做幂等(我们用的幂等键是 userId + activityId + date)。
另外补充一个容易忽略的点:子线程必须用自定义线程池,并且要配置 TaskDecorator 传递 TraceId 和用户上下文,否则 SkyWalking 链路会断,日志也没法排查。”
专题二:长事务问题与事务边界(重点)
面试高频: “你们怎么处理大事务/长事务?” “事务边界怎么划分?” 这是区分初级和高级的重要问题。
一、什么是长事务?
定义: 执行时间长(通常 > 1 秒)、涉及操作多、持有锁时间长的事务。
// ❌ 典型的长事务(反例,集合了所有坏味道)
@Transactional(rollbackFor = Exception.class) // 没设 timeout,可能跑几分钟
public SettlementResult dailySettlement(Date bizDate) {
// ① 大量查询(几万条指令),事务期间一直占着连接
List<Instruction> instructions = instructionMapper.selectByDate(bizDate); // 3万条
// ② 循环里调远程接口(每个 200ms × 3万次 = 100 分钟!)
for (Instruction inst : instructions) {
BankAccount account = bankApiClient.queryAccount(inst.getAccountNo()); // 远程调用
// ...
}
// ③ 循环里做复杂计算(CPU 密集,但完全不需要在事务里)
for (Instruction inst : instructions) {
BigDecimal result = complexCalculation(inst); // 耗时计算
instructionMapper.updateResult(inst.getId(), result);
}
// ④ 事务里写文件、发消息、发邮件
fileService.generateReport(instructions); // 生成报表文件(IO)
kafkaTemplate.send("settlement-done", result); // 发消息
mailService.send("日终清算完成"); // 发邮件
// ⑤ 最后才提交
return result;
}
// 整个方法可能执行几十分钟,期间:
// · 数据库连接被独占(连接池可能被耗尽)
// · 3万条指令记录被行锁(其他业务改不了这些指令)
// · 主从延迟飙升(大事务要等主库提交完才同步)
// · undo log / binlog 暴涨
二、长事务的六大危害
┌─────────────────────────────────────────────────────────┐
│ 危害①:数据库连接长期占用 → 连接池耗尽 │
│ ────────────────────────────────────────────────── │
│ 事务期间 Connection 一直被占着不归还 │
│ HikariCP 默认 10 个连接 → 10 个长事务就占满 │
│ → 其他请求拿不到连接 → 整个系统响应变慢甚至不可用 │
│ → 报错:HikariPool-1 - Connection is not available, │
│ request timed out after 30000ms │
└─────────────────────────────────────────────────────────┘
┌─────────────────────────────────────────────────────────┐
│ 危害②:锁持有时间长 → 锁等待、死锁 │
│ ────────────────────────────────────────────────── │
│ InnoDB 的行锁在事务提交时才释放 │
│ 长事务 = 长时间持锁 → 其他事务等待 → 锁等待超时 │
│ → ERROR 1205 (HY000): Lock wait timeout exceeded │
│ → 严重时死锁:ERROR 1213 Deadlock found │
└─────────────────────────────────────────────────────────┘
┌─────────────────────────────────────────────────────────┐
│ 危害③:主从延迟 │
│ ────────────────────────────────────────────────── │
│ 大事务要等主库提交完成后才同步 binlog 到从库 │
│ 一个跑 30 分钟的事务 → 从库延迟 30 分钟 │
│ → 读写分离架构下,用户读到半小时前的旧数据 │
└─────────────────────────────────────────────────────────┘
┌─────────────────────────────────────────────────────────┐
│ 危害④:undo log 膨胀 → 回滚段压力大 │
│ ────────────────────────────────────────────────── │
│ MVCC 需要保留 undo log 直到没有事务需要它 │
│ 长事务"看得见"很老的版本 → 老 undo 无法 purge │
│ → undo 表空间暴涨(可能几十 GB)→ 磁盘压力 │
│ → history list length 飙升,影响所有查询性能 │
└─────────────────────────────────────────────────────────┘
┌─────────────────────────────────────────────────────────┐
│ 危害⑤:回滚代价巨大 │
│ ────────────────────────────────────────────────── │
│ 跑了 30 分钟的事务失败 → 回滚可能要 1 小时 │
│ (回滚是单线程的,通常比正向执行更慢) │
│ → 期间表被锁,业务完全不可用 │
└─────────────────────────────────────────────────────────┘
┌─────────────────────────────────────────────────────────┐
│ 危害⑥:binlog _cache / 网络传输压力 │
│ ────────────────────────────────────────────────── │
│ MySQL 事务未提交时 binlog 先写缓存 │
│ 事务很大 → 超过 binlog_cache_size → 写临时文件 │
│ → 磁盘 IO 增加 │
└─────────────────────────────────────────────────────────┘
⚠️ 血泪教训(真实场景): 一条 UPDATE 没加索引条件,锁了全表 + 事务不提交 → 整个表的写操作全部阻塞 → 线上所有相关业务超时。
三、长事务的排查方法
-- ① 查看当前运行中的长事务(MySQL 5.7+)
SELECT
trx_id,
trx_started, -- 事务开始时间
TIMESTAMPDIFF(SECOND, trx_started, NOW()) AS duration_sec, -- 已运行时长
trx_state,
trx_rows_locked, -- 锁定的行数
trx_rows_modified, -- 修改的行数
trx_query -- 正在执行的 SQL
FROM information_schema.innodb_trx
WHERE TIMESTAMPDIFF(SECOND, trx_started, NOW()) > 10 -- 超过 10 秒
ORDER BY duration_sec DESC;
-- ② 查看锁等待
SELECT * FROM performance_schema.data_lock_waits;
-- ③ 查看死锁日志
SHOW ENGINE INNODB STATUS; -- 看 LATEST DETECTED DEADLOCK 部分
-- ④ 查看 history list length(undo 堆积)
SHOW ENGINE INNODB STATUS; -- 看 TRANSACTIONS 部分的 History list length
-- 正常 < 1000,超过 10 万说明有长事务未提交
应用层排查(你们的 SkyWalking 就能做):
// 方式1:AOP 切面监控事务耗时,超过阈值告警(推荐)
@Aspect
@Component
@Slf4j
public class TransactionMonitorAspect {
private static final long SLOW_TX_THRESHOLD_MS = 1000; // 1 秒算慢事务
@Around("@annotation(org.springframework.transaction.annotation.Transactional)")
public Object monitor(ProceedingJoinPoint pjp) throws Throwable {
long start = System.currentTimeMillis();
String method = pjp.getSignature().toShortString();
try {
return pjp.proceed();
} finally {
long cost = System.currentTimeMillis() - start;
if (cost > SLOW_TX_THRESHOLD_MS) {
// ★ 慢事务告警(接 Prometheus 或日志告警)
log.warn("[慢事务告警] method={}, cost={}ms, 事务期间可能持锁", method, cost);
}
}
}
}
// 方式2:Spring Boot 配置事务超时兜底
spring:
transaction:
default-timeout: 30 # 全局默认事务超时 30 秒(防止无限长事务)
四、长事务的优化方案(重点)
核心原则:事务边界要“刚刚好”——只包含必须原子性的操作
❌ 错误的事务边界(把所有事都塞进事务):
┌─────────────────────── @Transactional ───────────────────────┐
│ 查询数据 → 远程调用 → 复杂计算 → 写DB → 写文件 → 发消息 → 发邮件 │
└──────────────────────────────────────────────────────────────┘
整个过程几十分钟,全程持锁占连接
✅ 正确的事务边界(只包住必须的写操作):
查询数据(事务外)
↓
远程调用(事务外)
↓
复杂计算(事务外)
↓
┌─── @Transactional ───┐
│ 写DB(只包含写操作) │ ← 几十毫秒
└──────────────────────┘
↓
写文件 → 发消息 → 发邮件(事务外,afterCommit 回调)
优化方案 1:把非数据库操作移出事务
// ❌ 优化前:所有操作都在事务里
@Transactional(rollbackFor = Exception.class)
public void processInstruction(Long id) {
Instruction inst = instructionMapper.selectById(id); // 查询
AccountInfo account = bankApi.queryAccount(inst.getAccount()); // 远程调用 200ms
BigDecimal fee = calculateFee(inst, account); // 计算 50ms
instructionMapper.updateFee(id, fee); // 写DB
kafkaTemplate.send("topic", buildEvent(inst)); // 发消息
reportService.generate(inst); // 生成报表
}
// ✅ 优化后:事务只包住写操作
public void processInstruction(Long id) {
// ① 查询、远程调用、计算 —— 全部移到事务外
Instruction inst = instructionMapper.selectById(id);
AccountInfo account = bankApi.queryAccount(inst.getAccount());
BigDecimal fee = calculateFee(inst, account);
// ② 事务内只做数据库写(毫秒级)
doUpdateFee(id, fee);
// ③ 副作用操作放 afterCommit
TransactionSynchronizationManager.registerSynchronization(
new TransactionSynchronization() {
@Override
public void afterCommit() {
kafkaTemplate.send("topic", buildEvent(inst));
reportService.generate(inst);
}
}
);
}
// ★ 事务方法:只包住必须的写操作,设置超时
@Transactional(rollbackFor = Exception.class, timeout = 5) // 5 秒超时
public void doUpdateFee(Long id, BigDecimal fee) {
instructionMapper.updateFee(id, fee);
instructionMapper.updateStatus(id, "FEE_CALCULATED");
}
优化方案 2:大批量拆小批(你的日终清算案例)
// ❌ 优化前:一个事务处理 3 万条(长事务)
@Transactional(rollbackFor = Exception.class)
public void dailySettlement(Date bizDate) {
List<Instruction> all = instructionMapper.selectByDate(bizDate); // 3万条
for (Instruction inst : all) {
splitAndSettle(inst); // 都在一个大事务里
}
}
// 耗时 30+ 分钟,全程持锁
// ✅ 优化后:分批 + 每批独立小事务
public void dailySettlement(Date bizDate) {
int pageSize = 500;
long total = instructionMapper.countByDate(bizDate);
int totalPages = (int) Math.ceil((double) total / pageSize);
for (int page = 1; page <= totalPages; page++) {
// 每批一个独立事务(REQUIRES_NEW),失败只重试本批
List<Long> ids = instructionMapper.selectIdsByDate(bizDate, page, pageSize);
try {
settleOneBatch(ids);
} catch (Exception e) {
log.error("第 {} 批清算失败,记录待重试", page, e);
failedBatchMapper.insert(buildFailedBatch(bizDate, page, ids));
}
}
}
@Transactional(propagation = Propagation.REQUIRES_NEW,
rollbackFor = Exception.class, timeout = 30) // 每批 30 秒超时
public void settleOneBatch(List<Long> ids) {
List<Instruction> batch = instructionMapper.selectByIds(ids);
List<SplitDetail> details = new ArrayList<>();
for (Instruction inst : batch) {
details.addAll(doSplit(inst)); // 纯计算,无 IO
}
// ★ 批量写入(一条 SQL,比逐条快几十倍)
splitDetailMapper.batchInsert(details);
instructionMapper.batchUpdateStatus(ids, "SETTLED");
}
你们的真实优化(简历项目一):
/**
* 日终清算优化(简历亮点:30+ 分钟 → 3 分钟以内)
*
* 原始方案:Oracle 存储过程逐条处理,单事务跑 30+ 分钟
* 优化方案:
* ① 拆分规则从 Oracle 存储过程迁移到 Spark SQL 并行计算
* ② 中间结果从 Oracle 表改为 HBase(LSM 树,高吞吐写入)
* ③ RowKey 前缀(业务日期 + 指令ID)支持范围查询
* ④ 大事务拆成小批次,避免长事务持锁
*/
@Service
public class SettlementService {
public SettlementResult settle(Date bizDate) {
// ① Spark 分布式计算(事务外,不占数据库连接)
Dataset<Row> splitResult = sparkSession.sql(
"SELECT /*+ MAPJOIN(account) */ i.id, i.amount, a.rate, " +
" split_amount(i.amount, a.rate) AS split_amt " +
"FROM instruction i JOIN account_dim a ON i.account_no = a.account_no " +
"WHERE i.biz_date = '" + bizDate + "'"
).repartition(col("biz_date")); // ★ 按业务日期分区,避免 Shuffle 倾斜
// ② 批量写 HBase(高吞吐,不在事务里)
splitResult.foreachPartition(partition -> {
while (partition.hasNext()) {
hBaseDao.batchPut(convert(partition.next()));
}
});
// ③ 小批量更新指令状态(每批独立短事务)
batchUpdateStatus(bizDate, 500);
}
}
优化方案 3:设置合理的事务超时(兜底保护)
// ✅ 显式设置 timeout,防止事务无限运行
@Transactional(rollbackFor = Exception.class, timeout = 30) // 30 秒
public void updateInstruction(Instruction inst) { }
// ✅ 全局默认超时(兜底)
@Configuration
public class TxConfig {
@Bean
public PlatformTransactionManager transactionManager(DataSource ds) {
DataSourceTransactionManager txm = new DataSourceTransactionManager(ds);
txm.setDefaultTimeout(30); // 全局默认 30 秒
return txm;
}
}
// ⚠️ 注意:timeout 是"从事务开始到最后的 SQL 执行"的时间
// 纯查询耗时不计入(因为不涉及数据库交互)
// 超时后抛 TransactionTimedOutException 并回滚
优化方案 4:只读查询用 readOnly(性能提升)
// ✅ 查询方法加 readOnly = true
@Transactional(readOnly = true)
public List<Instruction> queryInstructions(QueryDTO dto) {
return instructionMapper.selectByCondition(dto);
}
/**
* readOnly 的作用:
* ① Spring 会设置 Connection.setReadOnly(true)
* → MySQL 驱动可能走从库(读写分离时)
* ② Hibernate/MyBatis 会跳过脏检查、flush
* → 性能提升
* ③ MySQL 5.6+ InnoDB 下,只读事务可以去掉事务ID分配开销
* → 减少 undo log 生成
* ④ 最重要的作用:**语义声明**,告诉维护者"这个方法不会写"
*
* ⚠️ 注意:readOnly=true 不是"不能写"的强制约束
* 如果里面执行了 INSERT/UPDATE,MySQL 驱动下照样能写成功
* 它是优化提示(hint),不是硬性限制
*/
优化方案 5:用编程式事务精细控制边界
适用场景: 事务边界需要动态决定、或者只包住方法的一部分逻辑。
@Service
public class InstructionService {
@Autowired
private TransactionTemplate transactionTemplate;
/**
* 编程式事务:只把"必须的写操作"包在事务里
*/
public void complexProcess(InstructionDTO dto) {
// ① 事务外:查询、校验、远程调用、计算
Instruction inst = instructionMapper.selectById(dto.getId());
validate(dto);
BankAccount account = bankApi.queryAccount(inst.getAccountNo());
BigDecimal amount = calculate(dto, account);
// ② 事务内:只包住数据库写操作
transactionTemplate.execute(status -> {
try {
instructionMapper.updateAmount(inst.getId(), amount);
positionMapper.deduct(inst.getAccountId(), amount);
auditLogMapper.insert(buildLog(inst, amount));
return true;
} catch (Exception e) {
status.setRollbackOnly();
throw new BizException("处理失败", e);
}
});
// 事务边界精确,只占连接几十毫秒
// ③ 事务外:副作用
kafkaTemplate.send("topic", buildEvent(inst));
}
}
// 配置 TransactionTemplate(支持 timeout、隔离级别等)
@Bean
public TransactionTemplate transactionTemplate(PlatformTransactionManager txm) {
TransactionTemplate template = new TransactionTemplate(txm);
template.setTimeout(30);
template.setIsolationLevel(TransactionDefinition.ISOLATION_READ_COMMITTED);
template.setPropagationBehavior(TransactionDefinition.PROPAGATION_REQUIRED);
return template;
}
声明式 vs 编程式怎么选:
| 声明式(@Transactional) | 编程式(TransactionTemplate) | |
|---|---|---|
| 优点 | 简洁、无侵入、可读性好 | 边界精确、灵活、不受方法可见性限制 |
| 缺点 | 只能作用于整个方法、自调用失效 | 代码侵入、样板代码多 |
| 适用 | 90% 的场景 | 事务边界需要精细控制、批量循环中的单条事务 |
专题三:事务编码规范(团队约定)
面试问“你们团队对事务有什么规范”时的完整答案,也是 Code Review 的检查清单。
一、注解使用规范
// ✅ 推荐:使用团队封装的 @BizTransactional(预设 rollbackFor = Exception.class)
@BizTransactional
public void createInstruction(InstructionDTO dto) { }
// 团队封装(一劳永逸解决漏写 rollbackFor 的问题)
@Target({ElementType.METHOD, ElementType.TYPE})
@Retention(RetentionPolicy.RUNTIME)
@Transactional(rollbackFor = Exception.class, timeout = 30) // ★ 预设
public @interface BizTransactional {
Propagation propagation() default Propagation.REQUIRED;
int timeout() default 30;
boolean readOnly() default false;
String transactionManager() default ""; // 多数据源时必填
}
/*
* 规范要点:
* ① 强制 rollbackFor = Exception.class(用封装注解保证)
* ② 强制设置 timeout(默认 30 秒,避免长事务)
* ③ 查询方法加 readOnly = true
* ④ 多数据源场景必须显式指定 transactionManager
* ⑤ 不写 propagation 就用默认 REQUIRED,需要改时写注释说明为什么
*/
二、方法可见性与调用规范
✅ 规范:
① 事务方法必须是 public(非 public 静默失效)
② 事务方法不能用 final / static(CGLIB 无法代理)
③ 禁止同类内部 this 调用事务方法
→ 需要调用就注入自己,或拆到另一个 Service
④ 事务方法所在类必须被 Spring 管理(@Service 等)
⑤ 禁止在 Controller 层加 @Transactional
→ 事务应该在 Service 层,Controller 只做参数校验和视图渲染
三、事务边界规范(最重要)
★ 事务内【只做】必须的原子性数据库写操作
❌ 事务内【禁止】做这些(都是长事务元凶):
① 远程调用(HTTP/gRPC/Dubbo) → 网络延迟不可控,可能几秒
② 大量查询(> 1000 条) → 移到事务外,或用分页
③ 复杂计算 / 循环处理 → 移到事务外
④ 文件 IO / 上传下载 → 移到事务外
⑤ 发送消息(MQ) → 用 afterCommit 回调
⑥ 发送短信 / 邮件 / 推送 → 用 afterCommit 回调
⑦ 操作 Redis / 其他非事务资源 → 注意回滚不了
⑧ 加分布式锁后的耗时操作 → 锁粒度要小,事务要短
⑨ sleep / 等待 → 绝对禁止
⑩ DDL 语句(CREATE/ALTER/TRUNCATE) → 隐式提交,事务失效
✅ 这些操作放事务外或 afterCommit 回调里
判断标准(一句话): “这段逻辑如果失败,需要和其他数据库操作一起回滚吗?” 需要 → 放事务内;不需要 → 放事务外。
四、批量处理规范
/*
* 规范:
* ① 禁止在单个事务里处理大批量数据(> 1000 条)
* ② 用分批 + 每批独立事务(REQUIRES_NEW)
* ③ 优先用批量 SQL(batchInsert/batchUpdate)而不是循环单条
* ④ 批量操作加进度记录和失败重试机制
* ⑤ 批量任务加幂等控制(防止重复执行)
*/
// ✅ 标准批量处理模板
public BatchResult processBatch(Date bizDate) {
BatchResult result = new BatchResult();
int pageSize = 500;
int page = 1;
while (true) {
List<Long> ids = mapper.selectPendingIds(bizDate, page, pageSize);
if (CollectionUtils.isEmpty(ids)) break;
try {
// 每批独立事务
processOneBatch(ids);
result.addSuccess(ids.size());
} catch (Exception e) {
log.error("批次处理失败,page={}", page, e);
result.addFailure(ids.size(), e.getMessage());
// 记录失败批次,支持后续重试
failedBatchMapper.insert(buildFailedBatch(bizDate, page, ids));
}
page++;
}
return result;
}
@BizTransactional(propagation = Propagation.REQUIRES_NEW, timeout = 30)
public void processOneBatch(List<Long> ids) {
// 单批处理,事务短小
mapper.batchUpdateStatus(ids, "PROCESSED");
}
五、幂等与重试规范
/*
* 规范:
* ① 所有写操作接口必须考虑幂等(网络重试、消息重复、用户重复点击)
* ② 幂等键设计:业务维度唯一标识
* 你的项目四(社区平台):userId + activityId + date
* 资产托管:instructionNo(指令号唯一)
* ③ 用数据库唯一索引做最终兜底(最可靠)
* ④ 幂等校验要在事务内的第一步
*/
@BizTransactional
public void processPayment(PaymentRequest req) {
// ① 幂等校验(事务内,靠唯一索引兜底)
String idempotentKey = req.getUserId() + ":" + req.getActivityId() + ":" + req.getDate();
try {
idempotentRecordMapper.insert(new IdempotentRecord(idempotentKey));
} catch (DuplicateKeyException e) {
log.info("重复请求,直接返回,key={}", idempotentKey);
return; // 已处理过,直接返回
}
// ② 业务逻辑
orderMapper.insert(buildOrder(req));
inventoryMapper.deduct(req.getSkuId(), req.getQuantity());
}
六、Code Review 检查清单
事务相关 Code Review Checklist:
□ @Transactional 是否指定了 rollbackFor = Exception.class?
□ 是否设置了合理的 timeout?
□ 查询方法是否加了 readOnly = true?
□ 多数据源场景是否指定了 transactionManager?
□ 事务方法是否是 public?(非 public 静默失效)
□ 事务方法是否被 final / static 修饰?
□ 是否存在同类内部 this 调用事务方法?
□ 事务方法内是否有远程调用、文件 IO、发消息、发短信?
□ 事务方法内是否有大循环或大量查询?
□ catch 块是否正确处理(重新抛出或 setRollbackOnly)?
□ 自定义异常是否继承 RuntimeException?
□ 批量操作是否做了分批和独立事务?
□ 是否有长事务风险(预估执行时间 > 1 秒)?
□ 异步/多线程操作是否考虑了事务上下文丢失?
□ 幂等性是否考虑(重试、重复消费)?
七、监控与告警规范
# 建议接入的监控项
监控项:
1. 慢事务告警:
阈值: 执行时间 > 1s
方式: AOP 切面 + 日志/Prometheus
2. 数据库连接池:
监控: HikariCP 活跃连接数、等待线程数
告警: 活跃连接 > 80% 持续 1 分钟
3. 数据库长事务:
监控: information_schema.innodb_trx 中 duration > 10s 的事务
方式: 定时巡检 Job
4. 锁等待:
监控: performance_schema.data_lock_waits
告警: 存在等待 > 5s 的锁
5. 事务回滚率:
监控: 回滚次数 / 总事务数
告警: 回滚率 > 5%(可能是业务异常或 bug)
专题四:线上事务失效排查实战
区别于前三个专题: 前面讲“怎么写对”,这一节讲“上线后发现不对,怎么查“。这是线上问题排查能力,也是你简历里”故障定位从小时级降到分钟级“的具体体现。 你的优势: 简历项目三写了 SkyWalking + Prometheus/Grafana 可观测性体系,技能清单第 7 条写了 JVM 调优和 OOM/CPU 排查经验(Arthas)——这些工具正好是排查事务问题的利器。
一、排查总览:从现象到定位的四层思路
发现异常现象(数据不一致 / 接口变慢 / 报错)
│
▼
┌───────────────────────────────────────────────────────┐
│ 第①层:确认事务到底有没有生效 │
│ ─────────────────────────────────────────────────── │
│ 问题:方法执行时到底开没开事务? │
│ 手段:· Arthas watch TransactionInterceptor │
│ · 开启 Spring 事务 DEBUG 日志 │
│ · 代码里打印 autocommit / 当前事务名 │
│ 判据:没看到 "Creating new transaction" → 事务没生效 │
└───────────────────────────────────────────────────────┘
│ 已确认开了事务,但结果仍不对
▼
┌───────────────────────────────────────────────────────┐
│ 第②层:确认事务边界和行为是否符合预期 │
│ ─────────────────────────────────────────────────── │
│ 问题:传播行为对不对?有没有被挂起?是不是独立事务? │
│ 手段:· Arthas watch doBegin 看 TransactionDefinition │
│ · 日志看 "Participating"/"Suspending" 关键字 │
│ 判据:看到 "Suspending current transaction" → 事务被挂起│
└───────────────────────────────────────────────────────┘
│ 事务行为正常,但数据仍不一致 / 性能有问题
▼
┌───────────────────────────────────────────────────────┐
│ 第③层:数据库层看锁、看事务、看连接 │
│ ─────────────────────────────────────────────────── │
│ 问题:有没有长事务?锁等待?连接够不够? │
│ 手段:· information_schema.innodb_trx │
│ · performance_schema.data_lock_waits │
│ · SHOW ENGINE INNODB STATUS(死锁日志) │
│ · 连接池监控(HikariCP/Druid) │
└───────────────────────────────────────────────────────┘
│ 数据库层正常
▼
┌───────────────────────────────────────────────────────┐
│ 第④层:链路追踪定位到具体代码 │
│ ─────────────────────────────────────────────────── │
│ 手段:· SkyWalking 看调用链,找出耗时最长的 Span │
│ · Arthas trace 看方法内部每个调用的耗时 │
│ · 慢 SQL 日志 / Druid 监控 │
└───────────────────────────────────────────────────────┘
二、手段 1:Arthas 动态追踪(最强力,不停机排查)
简历技能清单第 7 条写了“具备 JVM 调优和 OOM/CPU 排查经验”,Arthas 是最好的佐证。面试官问“线上怎么排查”时,答 Arthas 会很加分。
技巧 1:确认类是不是代理对象(判断事务能否生效的前提)
# 查看类的详细信息,看是否被 Spring 代理
sc -d com.company.custody.service.InstructionService
# 关键看 class-info 这一行:
# ✅ CGLIB 代理:...InstructionService$$EnhancerBySpringCGLIB$$a1b2c3d4
# ✅ JDK 代理: com.sun.proxy.$Proxy123
# ❌ 原始类: com.company.custody.service.InstructionService(没被代理!)
#
# 如果是原始类 → 事务必然失效,检查:
# ① 类上有没有 @Service / @Component
# ② 是否被 final 修饰
# ③ 是否通过 new 创建的
# ④ 有没有被 @Transactional 的方法(没有被代理的类说明没有任何 AOP 增强)
技巧 2:trace 看调用链里有没有事务拦截器
# 追踪方法调用链,输出每一层的耗时
trace com.company.custody.service.InstructionService createInstruction '#cost>10'
# 输出示例(正常情况,能看到事务拦截器):
# `---ts=2026-09-01 20:30:00;thread_name=http-nio-8080-exec-1;id=1a;is_daemon=true
# `---[152.345ms] com.company.custody.service.InstructionService:createInstruction()
# +---[0.052ms] org.springframework.transaction.interceptor.TransactionInterceptor:invoke()
# | `---[0.031ms] org.springframework.jdbc.datasource.DataSourceTransactionManager:doBegin()
# +---[120.123ms] com.company.custody.mapper.InstructionMapper:insert()
# +---[25.678ms] org.springframework.jdbc.datasource.DataSourceTransactionManager:doCommit()
#
# ★ 判据:链路里【有 TransactionInterceptor】→ 走了代理,事务生效
# 链路里【没有 TransactionInterceptor】→ 没走代理,事务失效!
# (自调用、非 public、final 方法都会是这个表现)
技巧 3:watch 监控事务的开启/提交/回滚(最常用)
# ① 监控事务开启:能看到事务名、传播行为、隔离级别
watch org.springframework.jdbc.datasource.DataSourceTransactionManager doBegin \
'{params[1].getName(), "propagation=" + params[1].getPropagationBehavior(), "isolation=" + params[1].getIsolationLevel(), "timeout=" + params[1].getTimeout()}' -x 3
# 输出示例:
# method=doBegin location=AtExit
# ts=2026-09-01 20:30:00
# result=@ArrayList[
# @String[com.company.custody.service.InstructionService.createInstruction],
# @String[propagation=0], ← 0=REQUIRED, 3=REQUIRES_NEW, 4=NOT_SUPPORTED
# @String[isolation=-1], ← -1=使用数据库默认
# @String[timeout=30], ← 超时 30 秒
# ]
# 传播行为常量对照(上面打印的是数字):
# 0=REQUIRED 1=SUPPORTS 2=MANDATORY 3=REQUIRES_NEW
# 4=NOT_SUPPORTED 5=NEVER 6=NESTED
# ② 监控提交
watch org.springframework.jdbc.datasource.DataSourceTransactionManager doCommit 'params[1]' -x 2
# ③ 监控回滚(★ 重点:如果这里被触发,说明确实回滚了)
watch org.springframework.jdbc.datasource.DataSourceTransactionManager doRollback 'params[1]' -x 2
# ④ 更上层:监控 TransactionInterceptor(能看到方法名和事务属性)
watch org.springframework.transaction.interceptor.TransactionInterceptor invoke \
'{params[0].getMethod().getName(), params[0].getTransactionAttribute()}' -x 3
# ⑤ 组合监控:同时看开启和提交/回滚(判断事务是否完整执行)
watch org.springframework.jdbc.datasource.DataSourceTransactionManager \
'{doBegin, doCommit, doRollback}' -x 2
技巧 4:tt 时空隧道(事后回放,适合偶发问题)
# 记录事务开启的调用(保留最近的 N 次)
tt -t org.springframework.jdbc.datasource.DataSourceTransactionManager doBegin -n 100
# 查看记录列表
tt -l
# 回放某次调用,看详细参数
tt -i 1000 -w 'params[1].getName()'
tt -i 1000 -w 'params[1].getPropagationBehavior()'
# ★ 适用场景:偶发的"有时候没回滚",用 tt 记录后逐个回放,
# 对比正常和异常时的事务定义有什么不同
技巧 5:代码内直接验证(最快的自检手段)
/**
* 在怀疑的地方加一段临时代码,打印事务状态
* ★ 这是排查"事务到底生没生效"最快的方法
*/
@Transactional(rollbackFor = Exception.class)
public void createInstruction(InstructionDTO dto) {
// ① 打印是否真的在事务里
boolean inTx = TransactionSynchronizationManager.isActualTransactionActive();
log.info("是否在事务中: {}", inTx); // 期望 true
// ② 打印当前事务名
String txName = TransactionSynchronizationManager.getCurrentTransactionName();
log.info("当前事务名: {}", txName); // 期望是方法全限定名
// ③ ★ 最直接的判据:打印连接的 autocommit
Connection conn = DataSourceUtils.getConnection(dataSource);
log.info("autocommit = {}", conn.getAutoCommit());
// ✅ 事务生效:autocommit = false(Spring 开启事务时会设为 false)
// ❌ 事务失效:autocommit = true(连接还是自动提交模式,每条 SQL 独立提交)
// ④ 打印当前绑定到线程的连接(判断是否同一个连接)
Object holder = TransactionSynchronizationManager.getResource(dataSource);
log.info("事务资源: {}", holder); // 不为 null 说明连接已绑定
// ⑤ 打印隔离级别
Integer isolation = TransactionSynchronizationManager.getCurrentTransactionIsolationLevel();
log.info("隔离级别: {}", isolation);
// ... 业务逻辑
}
⚠️ 生产注意: 这类调试代码用完要删掉,或用 @Profile("dev") 控制只在非生产环境生效。
三、手段 2:开启 Spring 事务 DEBUG 日志
# application.yml(生产环境临时开启,排查完记得关掉)
logging:
level:
org.springframework.transaction: DEBUG # 事务拦截器
org.springframework.jdbc.datasource.DataSourceTransactionManager: DEBUG # 事务管理器
org.mybatis.spring.SqlSessionUtils: DEBUG # MyBatis 连接获取
org.springframework.transaction.interceptor: TRACE # 最详细(可选)
关键日志与含义对照表(背下来,看日志就知道发生了什么):
| 日志关键字 | 含义 | 说明 |
|---|---|---|
Creating new transaction with name [...] |
开启了新事务 | ✅ 正常,事务生效 |
Participating in existing transaction |
加入了已有事务 | 内层 REQUIRED/SUPPORTS,和外层同一个事务 |
Participating transaction failed - marking existing transaction as rollback-only |
内层异常,标记整体回滚 | ⚠️ 外层再提交会抛 UnexpectedRollbackException |
Suspending current transaction |
挂起了当前事务 | ⚠️ 出现这个说明用了 NOT_SUPPORTED 或 REQUIRES_NEW |
Creating nested transaction with name [...] |
创建嵌套事务(Savepoint) | NESTED 传播行为 |
Acquired Connection [...] for JDBC transaction |
获取连接 | 能看到连接的 hashcode,判断是否同一个连接 |
Switching JDBC Connection [...] to manual commit |
关闭自动提交 | ✅ 关键!看到这行说明事务真的接管了连接 |
Initiating transaction commit |
准备提交 | |
Committing JDBC transaction on Connection [...] |
提交事务 | ✅ |
Initiating transaction rollback |
准备回滚 | |
Rolling back JDBC transaction on Connection [...] |
回滚事务 | |
Releasing JDBC Connection [...] after transaction |
释放连接回池 | |
Should roll back transaction [...] according to rollback rule |
按规则判断需要回滚 | |
Should commit transaction [...] despite exception |
异常但不回滚(不匹配 rollbackFor) | ❌ 这是 rollbackFor 配错的典型信号 |
★ 最重要的判据:
情况1:方法执行完,日志里【完全没有】任何事务相关输出
→ 事务根本没生效!检查:非 public / 自调用 / final / 非 Spring Bean
情况2:看到 "Switching JDBC Connection to manual commit"
→ ✅ 事务已生效,连接被接管
情况3:抛异常后看到 "Should commit transaction despite exception"
→ ❌ rollbackFor 配置问题,异常类型不匹配,不会回滚
情况4:看到 "Suspending current transaction"
→ 事务被挂起(NOT_SUPPORTED 或 REQUIRES_NEW),注意是否符合预期
生产环境安全开启方式(不重启):
# 用 Arthas 动态调整日志级别,无需重启!(生产排查神器)
# ① 查看当前 logger 信息
logger --name org.springframework.transaction
# ② 修改日志级别为 DEBUG
logger --name org.springframework.transaction --level DEBUG
logger --name org.springframework.jdbc.datasource.DataSourceTransactionManager --level DEBUG
# ③ 排查完改回
logger --name org.springframework.transaction --level INFO
# ★ 优势:不用改配置、不用重启、不影响其他功能,排查完即时恢复
四、手段 3:数据库层排查
-- ① 查看当前活跃事务(★ 最常用)
SELECT
trx_id,
trx_mysql_thread_id, -- 对应的 MySQL 线程 ID
trx_started, -- 事务开始时间
TIMESTAMPDIFF(SECOND, trx_started, NOW()) AS duration_sec,
trx_state, -- RUNNING / LOCK WAIT / ROLLING BACK
trx_rows_locked, -- 锁定的行数
trx_rows_modified, -- 修改的行数
trx_isolation_level, -- 隔离级别
LEFT(trx_query, 100) AS trx_query -- 正在执行的 SQL(可能是 NULL=空闲事务)
FROM information_schema.innodb_trx
ORDER BY trx_started ASC; -- 最老的事务排前面
-- 关键观察点:
-- · trx_state = 'LOCK WAIT' → 有锁等待
-- · duration_sec 很大 → 长事务
-- · trx_query = NULL 但 trx_started 很老 → 事务开了但没执行 SQL(典型的代码问题!)
-- ⚠️ 这种最危险:连接被占着,锁没释放,但什么也没做
-- ② 查看锁等待关系(MySQL 8.0)
SELECT
waiting_pid AS 等待线程,
waiting_query AS 等待的SQL,
blocking_pid AS 阻塞线程,
blocking_query AS 阻塞的SQL,
wait_age AS 等待时长
FROM sys.innodb_lock_waits;
-- MySQL 5.7 写法
SELECT * FROM information_schema.innodb_lock_waits;
-- ③ 查看死锁(最近一次)
SHOW ENGINE INNODB STATUS\G
-- 找 LATEST DETECTED DEADLOCK 部分,能看到:
-- · 两个事务各自的 SQL
-- · 各自持有和等待的锁
-- · 最终哪个被回滚
-- ④ 查看连接数和使用情况
SHOW STATUS LIKE 'Threads_connected'; -- 当前连接数
SHOW STATUS LIKE 'Max_used_connections'; -- 历史最大连接数
SHOW PROCESSLIST; -- 查看所有连接及其执行的 SQL
-- ★ 看有没有大量 Sleep 状态的连接 → 可能是事务没提交,连接未释放
-- ⑤ 查看 undo 堆积(长事务的副作用)
SHOW ENGINE INNODB STATUS\G
-- 找 TRANSACTIONS 部分的 "History list length"
-- 正常 < 1000;> 10万 说明有长事务阻塞了 undo 清理
-- ⑥ 确认事务相关配置
SELECT @@autocommit; -- 会话级自动提交(应用连接池里通常设为 1,由 Spring 控制)
SELECT @@transaction_isolation; -- 隔离级别
SHOW VARIABLES LIKE 'innodb_lock_wait_timeout'; -- 锁等待超时(默认 50 秒)
⚠️ 一个关键判据:SHOW PROCESSLIST 里大量 Sleep 连接
场景:应用报 "Connection is not available, request timed out"
排查:SHOW PROCESSLIST → 发现大量 Sleep 状态、Time 很大的连接
原因:事务开启了但迟迟不提交(长事务 / 事务里做了耗时操作 / 代码抛异常但连接没释放)
→ 连接既不工作也不释放 → 连接池耗尽
定位:
① 找到 Sleep 很久的连接 ID
② 查 information_schema.innodb_trx 看对应的事务在做什么
③ 结合 SkyWalking / Arthas 定位到具体代码
五、手段 4:APM 链路追踪(你的 SkyWalking 经验)
SkyWalking 排查事务问题的用法:
① 看 Span 耗时分布,找出慢在哪
链路:POST /api/instruction/create
├─ SpringMVC (2ms)
├─ InstructionService.createInstruction (1520ms) ← 慢!
│ ├─ SELECT instruction (1200ms) ← 慢 SQL
│ ├─ INSERT instruction (15ms)
│ └─ SELECT account (300ms)
└─ MySQL 总共 (1515ms)
② 事务方法耗时 = 连接占用时间 ≈ 持锁时间
★ 如果事务方法 Span 耗时长 → 长事务风险
→ 你的"慢事务告警"就是基于这个指标
③ 结合日志 TraceId
SkyWalking 的 TID 可以关联到应用日志
→ 一个 TraceId 就能看到:请求链路 + 事务日志 + SQL 执行
④ 配置告警规则(你的项目三有配置经验)
# SkyWalking 告警规则示例(alarm-settings.yml)
rules:
# 慢事务告警(用 endpoint 响应时间间接监控)
endpoint_percent_rule:
metrics-name: endpoint_percent
threshold: 1000 # 响应时间 > 1000ms
op: ">"
period: 10 # 10 分钟窗口
count: 3 # 连续 3 次
silence-period: 5
message: 端点 {name} 响应时间超过 1 秒,可能存在长事务风险
# 数据库慢查询告警(事务里的慢 SQL)
database_access_rule:
metrics-name: database_access
threshold: 500
op: ">"
period: 10
count: 5
message: 数据库访问缓慢 {name}
六、手段 5:连接池监控
# HikariCP 监控配置(暴露给 Prometheus)
spring:
datasource:
hikari:
maximum-pool-size: 20
minimum-idle: 5
connection-timeout: 30000 # 获取连接超时 30 秒
leak-detection-threshold: 10000 # ★ 连接泄漏检测:超过 10 秒未归还就打日志
register-mbeans: true # 暴露 JMX 指标
management:
endpoints:
web:
exposure:
include: health,metrics,prometheus
metrics:
tags:
application: ${spring.application.name}
关键监控指标:
| 指标 | 含义 | 告警阈值 |
|---|---|---|
hikaricp_connections_active |
活跃连接数 | > 80% 最大连接数 |
hikaricp_connections_pending |
等待连接的线程数 | > 0 持续 1 分钟 |
hikaricp_connections_idle |
空闲连接数 | 长期为 0 说明连接紧张 |
hikaricp_connections_usage |
连接使用时长 | > 1 秒说明有长事务 |
hikaricp_connections_leak |
连接泄漏(需开启 leak-detection) | > 0 |
★ 连接泄漏检测(leak-detection-threshold)是排查长事务的神器:
开启后,如果某个连接被借出超过 10 秒未归还,HikariCP 会打印警告日志:
Apparent connection leak detected for connection ConnectionID:1
client connectionId: 12345
client host: 192.168.1.100
client application name: MySQLConnector/J
Stack trace:
at com.company.custody.mapper.InstructionMapper.selectByDate(InstructionMapper.java:45)
at com.company.custody.service.SettlementService.dailySettlement(SettlementService.java:88)
★ 直接给出持连接最长的代码位置!定位长事务/连接泄漏极其高效
Druid 监控(如果你项目用的是 Druid):
spring:
datasource:
druid:
stat-view-servlet:
enabled: true
url-pattern: /druid/*
filter:
stat:
enabled: true
slow-sql-millis: 1000 # 慢 SQL 阈值
log-slow-sql: true # 记录慢 SQL
# ★ Druid 的 SQL 监控能看到:
# · 每条 SQL 的执行次数、耗时
# · 事务方法内的 SQL 是否在同一个连接上(看连接 ID)
七、六个实战排查剧本(CASE)
面试价值最高的一节。 面试官问“线上遇到过事务问题吗”,能按“现象→排查→定位→解决→复盘”讲一个完整案例,比背十个知识点都有说服力。
CASE 1:数据不一致——事务没回滚(最常见)
现象:
运营反馈:某笔指令状态显示"已出款",但账户头寸没扣减。
代码里这两步明明写在同一个 @Transactional 方法里。
排查过程:
第①步:确认事务有没有开启
→ 开 DEBUG 日志(用 Arthas 动态开,不重启)
→ 执行该操作,看日志输出
日志结果:
Creating new transaction with name [...auditAndPay]
Acquired Connection [conn1] for JDBC transaction
Switching JDBC Connection [conn1] to manual commit ← 事务开启了 ✅
Initiating transaction commit
Committing JDBC transaction on Connection [conn1] ← 提交了!
⚠️ 关键发现:日志显示"提交"而不是"回滚"
→ 说明方法正常返回了,异常根本没传到事务拦截器
第②步:看代码,找异常去哪了
→ 发现代码里有个 try-catch 把异常吞了:
try {
instructionMapper.updateStatus(id, "PAID");
positionMapper.deduct(accountId, amount);
} catch (Exception e) {
log.error("扣减头寸失败", e); ← 只打了日志
}
→ 头寸扣减失败被 catch,方法正常返回 → 事务提交
→ 结果:状态改成"已出款",但钱没扣 → 账实不符
第③步:确认修复方案
定位结论: catch 吞异常导致事务未回滚(场景 3)
解决方案:
// ❌ 修复前
@Transactional(rollbackFor = Exception.class)
public void auditAndPay(Long id, BigDecimal amount) {
try {
instructionMapper.updateStatus(id, "PAID");
positionMapper.deduct(accountId, amount); // 这步失败
} catch (Exception e) {
log.error("扣减头寸失败", e); // 吞掉异常
}
}
// ✅ 修复后
@BizTransactional
public void auditAndPay(Long id, BigDecimal amount) {
instructionMapper.updateStatus(id, "PAID");
positionMapper.deduct(accountId, amount); // 失败直接抛出
// 由全局异常处理器 @RestControllerAdvice 统一捕获返回
}
// 兜底:加事后对账任务(资金系统必备)
@Component
public class ReconciliationJob {
/**
* 定时对账:扫描"已出款但头寸未扣减"的异常数据并告警
* ★ 资金类系统不能只靠事务,必须有对账兜底
*/
@Scheduled(cron = "0 0 2 * * ?")
public void check() {
List<DiffRecord> diffs = reconciliationMapper.findStatusPositionDiff();
if (!diffs.isEmpty()) {
alertService.sendUrgent("发现账实不符记录 " + diffs.size() + " 条");
diffs.forEach(d -> diffRecordMapper.insert(d)); // 落库供人工处理
}
}
}
复盘(面试要说): “这次事故让我们意识到两点:① catch 异常必须重新抛出,团队后来统一用 @BizTransactional 并把’catch 不抛’列进 Code Review 红线;② 资金系统不能只依赖事务,我们加了每日对账任务作为兜底,即使事务出问题也能通过告警发现,不至于让错误数据长期存在。”
CASE 2:连接池耗尽——长事务
现象:
凌晨日终清算期间,系统大面积报错:
HikariPool-1 - Connection is not available, request timed out after 30000ms
其他所有业务(查询、下单)全部超时,持续约 30 分钟。
排查过程:
第①步:看连接池监控(Grafana)
→ hikaricp_connections_active = 20(打满上限)
→ hikaricp_connections_pending = 50+(大量线程在等连接)
→ 确认是连接被占满
第②步:看 MySQL 谁在占连接
mysql> SHOW PROCESSLIST;
→ 发现 20 个连接全是 Sleep 状态,Time 从 100 到 1500 秒不等
第③步:查这些连接在做什么事务
mysql> SELECT trx_id, trx_started, TIMESTAMPDIFF(SECOND, trx_started, NOW()) AS dur,
trx_state, trx_rows_locked, LEFT(trx_query,50)
FROM information_schema.innodb_trx ORDER BY trx_started;
→ 发现最老的事务已经运行 1500+ 秒(25 分钟)
→ trx_query = NULL(当前没执行 SQL,但事务没提交)
→ 定位到是日终清算任务
第④步:定位代码
→ 看 SkyWalking 该时段的链路,SettlementService.dailySettlement 耗时 28 分钟
→ 看代码:一个大事务里循环处理 3 万条指令,还调了外部接口
定位结论: 长事务(专题二的危害①)—— 大事务持连接 30 分钟,连接池被打满
解决方案:
// ❌ 修复前:一个大事务包揽所有
@Transactional(rollbackFor = Exception.class)
public void dailySettlement(Date bizDate) {
List<Instruction> all = mapper.selectByDate(bizDate); // 3 万条
for (Instruction inst : all) {
BankAccount acc = bankApi.queryAccount(inst.getAccount()); // 远程调用
// ... 计算 + 更新
}
}
// ✅ 修复后:三重优化
public void dailySettlement(Date bizDate) {
// ① 拆分规则迁到 Spark SQL 并行计算(事务外,不占连接)
Dataset<Row> result = sparkSession.sql(SPLIT_SQL).repartition(col("biz_date"));
result.foreachPartition(p -> hBaseDao.batchPut(convert(p)));
// ② 分批更新状态,每批独立短事务
batchUpdateWithSmallTx(bizDate, 500);
}
@BizTransactional(propagation = Propagation.REQUIRES_NEW, timeout = 30)
public void updateOneBatch(List<Long> ids) {
mapper.batchUpdateStatus(ids, "SETTLED"); // 单批 < 1 秒
}
同时加了监控和兜底:
# ① 连接池泄漏检测(能直接报出持连接的代码位置)
spring:
datasource:
hikari:
leak-detection-threshold: 10000 # 10 秒未归还就告警
maximum-pool-size: 30 # 适当调大
# ② 全局事务超时兜底
spring:
transaction:
default-timeout: 30
结果: 清算耗时 30+ 分钟 → 3 分钟以内(你简历里的量化成果),连接池不再被打满。
复盘话术: “这次事故的核心教训是长事务会拖垮整个系统——连接被独占导致所有业务不可用,影响面远大于清算任务本身。我们做了三件事:① 业务优化,拆分规则迁到 Spark 并行计算,大事务拆成小批独立事务;② 配置兜底,全局事务超时 30 秒 + 连接泄漏检测 10 秒;③ 监控告警,慢事务和连接池活跃数都接入 Grafana。后来这类问题在发生前就能被发现。”
CASE 3:死锁
现象:
日志报错:
Deadlock found when trying to get lock; try restarting transaction
SQL: UPDATE instruction SET status = ? WHERE id = ?
偶发,每天几次,业务重试后能成功。
排查过程:
第①步:拿死锁日志
mysql> SHOW ENGINE INNODB STATUS\G
→ 找 "LATEST DETECTED DEADLOCK" 部分:
*** (1) TRANSACTION:
TRANSACTION 12345, ACTIVE 5 sec starting index read
UPDATE instruction SET status='PAID' WHERE id=100
*** (1) HOLDS THE LOCK(S):
RECORD LOCKS ... index PRIMARY ... id=100
*** (1) WAITING FOR THIS LOCK:
RECORD LOCKS ... index PRIMARY ... id=200 ← 等 id=200
*** (2) TRANSACTION:
UPDATE instruction SET status='CANCELLED' WHERE id=200
*** (2) HOLDS THE LOCK(S): id=200
*** (2) WAITING FOR THIS LOCK: id=100 ← 等 id=100
★ 典型的"交叉更新"死锁:
事务1 先改 100 再改 200
事务2 先改 200 再改 100
→ 互相等待 → 死锁
第②步:定位代码
→ 批量更新指令状态的方法,循环顺序不确定
→ 入参 ids 是从前端传的,顺序随机
定位结论: 批量更新顺序不一致导致的死锁
解决方案:
// ❌ 修复前:按传入顺序更新(顺序随机 → 交叉加锁 → 死锁)
@BizTransactional
public void batchUpdateStatus(List<Long> ids, String status) {
for (Long id : ids) {
mapper.updateStatus(id, status); // 顺序不确定!
}
}
// ✅ 修复后:三重措施
@BizTransactional(timeout = 10)
public void batchUpdateStatus(List<Long> ids, String status) {
// ① ★ 按 id 排序,保证加锁顺序一致(消除交叉加锁)
List<Long> sortedIds = ids.stream().sorted().collect(Collectors.toList());
// ② 改用批量 SQL(一次加锁,减少锁持有时间和死锁概率)
mapper.batchUpdateStatus(sortedIds, status);
}
// ③ 加重试机制(兜底,死锁是偶发的,重试能自愈)
@Retryable(value = {DeadlockLoserDataAccessException.class},
maxAttempts = 3, backoff = @Backoff(delay = 100, multiplier = 2))
public void updateWithRetry(List<Long> ids, String status) {
batchUpdateStatus(ids, status);
}
死锁的通用预防原则:
① 固定加锁顺序(批量操作按主键排序)★ 最有效
② 缩小事务范围(减少持锁时间)
③ 降低隔离级别(RR → RC,减少间隙锁)
④ 避免交叉更新(不同业务按相同顺序访问资源)
⑤ 加重试机制(死锁无法完全避免,重试是必要的兜底)
⑥ 设置合理的锁等待超时(innodb_lock_wait_timeout)
CASE 4:多数据源事务失效
现象:
Oracle → GaussDB 迁移期间,发现某次迁移任务失败后,
Oracle 库里的数据是"半截"的:主表更新了,明细表没更新。
代码看着有 @Transactional。
排查过程:
第①步:Arthas 看事务到底用的哪个事务管理器
watch org.springframework.jdbc.datasource.DataSourceTransactionManager doBegin \
'{params[1].getName(), target.dataSource}' -x 3
→ 输出显示:target.dataSource 是 gaussDataSource
→ 但代码里的 Mapper 走的是 Oracle!
第②步:看方法定义
@Transactional(rollbackFor = Exception.class)
public void migrateInstruction(Long id) {
oracleInstructionMapper.updateStatus(id, "MIGRATED"); // 走 Oracle
oracleDetailMapper.insert(detail); // 走 Oracle
}
→ 没指定 transactionManager!
→ 默认用的是 @Primary 的 gaussTxManager(管 GaussDB 的连接)
→ 但 SQL 打在 Oracle 上 → 两条 SQL 各自自动提交 → 没有事务!
第③步:为什么会"半截"
→ 第一条 Oracle SQL 执行完自动提交了
→ 第二条失败 → 只回滚不了第一条
定位结论: 多数据源未指定 transactionManager(场景 6)
解决方案:
// ✅ 显式指定事务管理器
@Transactional(transactionManager = "oracleTxManager", rollbackFor = Exception.class)
public void migrateInstruction(Long id) {
oracleInstructionMapper.updateStatus(id, "MIGRATED");
oracleDetailMapper.insert(detail);
}
// ✅ 团队规范:封装注解,强制要求指定事务管理器
@Target(ElementType.METHOD)
@Retention(RetentionPolicy.RUNTIME)
@Transactional(rollbackFor = Exception.class, timeout = 30)
public @interface OracleTx {
Propagation propagation() default Propagation.REQUIRED;
}
// 使用:@OracleTx ← 内部已绑定 oracleTxManager,不会再忘
排查要点总结: 多数据源场景下,排查事务问题的第一步就是确认事务管理器管的是哪个数据源——用 Arthas watch doBegin 时把 target.dataSource 打出来,一眼就能看出对不对。
CASE 5:异步任务数据不一致
现象:
指令审核通过后,通知下游系统的消息发出去了,
但主库里指令状态还是"待审核"。
下游系统按"已审核"处理了,主库却没变 → 数据不一致。
排查过程:
第①步:看代码
@BizTransactional
public void approve(Long id) {
instructionMapper.updateStatus(id, "APPROVED");
notifyService.sendNotify(id); // 这个方法里有 @Async
}
@Async
public void sendNotify(Long id) {
Instruction inst = mapper.selectById(id);
kafkaTemplate.send("topic", buildEvent(inst));
}
第②步:分析时序
→ @Async 让 sendNotify 在另一个线程执行
→ 主线程事务还没提交(方法还在执行),异步线程可能已经查了数据并发出消息
→ 竞态条件:异步线程读到的是未提交的数据(或旧数据)
→ 更糟的情况:主线程事务后来回滚了,但消息已经发出 → 下游收到了"不存在"的审核
第③步:复现验证
→ 在 approve 方法里加 Thread.sleep(1000) 放大竞态窗口,稳定复现
定位结论: 异步操作与事务提交的时序问题(专题一方案 2 的反面案例)
解决方案:
// ✅ 用 TransactionSynchronization 保证时序
@BizTransactional
public void approve(Long id) {
Instruction inst = instructionMapper.selectById(id);
instructionMapper.updateStatus(id, "APPROVED");
positionMapper.deduct(inst.getAccountId(), inst.getAmount());
// ★ 注册回调:事务确认提交后才发消息
TransactionSynchronizationManager.registerSynchronization(
new TransactionSynchronization() {
@Override
public void afterCommit() {
// 此时数据已确定提交,异步发消息是安全的
notifyService.asyncSendNotify(id);
}
}
);
// 事务回滚 → afterCommit 不执行 → 不会误发消息 ✅
}
// 异步方法(不再直接调用)
@Async("notifyExecutor")
public void asyncSendNotify(Long id) {
Instruction inst = mapper.selectById(id); // 读到的一定是已提交的数据
kafkaTemplate.send("instruction-topic", buildEvent(inst));
}
排查要点: 遇到“消息发了但数据没变”或“数据变了但消息没发”,第一时间怀疑事务与异步的时序。检查异步操作是否放在 afterCommit 里。
CASE 6:UnexpectedRollbackException
现象:
日志报错:
org.springframework.transaction.UnexpectedRollbackException:
Transaction rolled back because it has been marked as rollback-only
但外层方法明明 catch 了内层异常,逻辑上应该继续才对。
排查过程:
第①步:看代码
@BizTransactional
public void outer() {
mapper.insert(a);
try {
innerService.inner(); // 默认 REQUIRED
} catch (Exception e) {
log.error("内层异常,忽略", e); // catch 住了
}
// 方法正常返回,期望提交
}
@Service
public class InnerService {
@BizTransactional
public void inner() {
throw new RuntimeException();
}
}
第②步:分析原理
→ inner() 是 REQUIRED,【加入】outer 的事务(同一个事务!)
→ inner() 抛异常 → Spring 把【这个共享的事务】标记为 rollback-only
→ outer() catch 了异常,继续往下走
→ outer() 方法正常返回 → 事务拦截器尝试 commit
→ Spring 发现:这个事务已被标记 rollback-only,却要提交 → 矛盾!
→ 抛 UnexpectedRollbackException 并回滚
★ 本质:内外层【共享同一个事务】,内层说"必须回滚",
外层却想"正常提交",Spring 只能报错
定位结论: REQUIRED 内层异常被外层 catch(场景 7)
解决方案:
// ✅ 方案1:内层用 REQUIRES_NEW(独立事务,内层回滚不影响外层)★ 推荐
@Service
public class InnerService {
@BizTransactional(propagation = Propagation.REQUIRES_NEW)
public void inner() {
mapper.insert(b);
throw new RuntimeException(); // 只回滚自己的事务
}
}
// outer() catch 后继续,最终正常提交 ✅
// ✅ 方案2:外层不 catch,让异常传播(如果业务上内层失败就该整体失败)
@BizTransactional
public void outer() {
mapper.insert(a);
innerService.inner(); // 不 catch
// 异常传播 → 整体回滚,符合预期
}
// ✅ 方案3:内层用 NESTED(Savepoint 机制,子事务回滚不影响父事务)
@BizTransactional(propagation = Propagation.NESTED)
public void inner() { }
// ⚠️ 需要数据库支持 Savepoint,且要在同一个物理事务里
// ✅ 方案4:内层不用事务(如果内层操作不需要原子性)
public void inner() {
// 不加 @Transactional,异常就是普通异常,不会标记事务
}
排查要点: 看到 UnexpectedRollbackException,一定是“内层标记了 rollback-only 但外层想提交”。排查方向:找内层的事务传播行为,看是不是 REQUIRED 却被外层 catch 了。
八、事务有效性的自动化验证(把问题拦在上线前)
高级做法:写测试验证事务真的生效,而不是靠人工 Code Review。
方式 1:单元测试验证回滚行为
@SpringBootTest
@Transactional // ★ 测试类加事务,测试完自动回滚,不污染数据
@Rollback
public class InstructionTransactionTest {
@Autowired
private InstructionService instructionService;
@Autowired
private InstructionMapper instructionMapper;
/**
* 验证:业务异常时数据确实回滚
*/
@Test
public void testRollbackOnException() {
InstructionDTO dto = buildTestDTO();
Long id = dto.getId();
// ① 执行会抛异常的操作
assertThrows(BizException.class, () -> {
instructionService.createInstruction(dto);
});
// ② 验证数据库里没有这条记录(确实回滚了)
Instruction saved = instructionMapper.selectById(id);
assertNull(saved, "事务应回滚,数据库中不应存在该记录");
// ③ 验证关联表也没插入
assertEquals(0, positionMapper.countByAccountId(dto.getAccountId()));
}
/**
* 验证:自调用场景事务失效(反例测试,确保团队知道这个坑)
*/
@Test
public void testSelfInvocationTransactionNotWork() {
// 这个测试会失败,用于证明自调用事务失效
// 实际项目中不会写这种测试,这里仅作演示
}
/**
* 验证:REQUIRES_NEW 的独立事务行为
*/
@Test
public void testRequiresNewIndependent() {
// 外层抛异常,内层的 REQUIRES_NEW 应该已经提交
assertThrows(RuntimeException.class, () -> {
outerService.outerWithRequiresNew();
});
// 验证:内层(日志表)的数据已提交,不受外层回滚影响
assertNotNull(auditLogMapper.selectLatest());
}
}
方式 2:集成测试 + Testcontainers(更真实)
@Testcontainers
@SpringBootTest
public class TransactionIntegrationTest {
@Container
static MySQLContainer<?> mysql = new MySQLContainer<>("mysql:8.0")
.withDatabaseName("test")
.withUsername("test")
.withPassword("test");
@DynamicPropertySource
static void configure(DynamicPropertyRegistry registry) {
registry.add("spring.datasource.url", mysql::getJdbcUrl);
// ...
}
@Test
public void testTransactionInRealDatabase() {
// 在真实数据库上验证事务行为
// 能覆盖:数据库引擎、隔离级别、锁行为等内存数据库测不出来的场景
}
}
方式 3:架构守护测试(ArchUnit,防回归)
/**
* 用 ArchUnit 在 CI 里自动检查代码规范,防止有人写出事务失效的代码
* ★ 这是团队级的预防措施,把规范变成自动化检查
*/
@AnalyzeClasses(packages = "com.company.custody")
public class TransactionArchTest {
/**
* 规则1:@Transactional 方法必须是 public
*/
@ArchTest
static final ArchRule transactionalMethodsMustBePublic =
methods().that().areAnnotatedWith(Transactional.class)
.should().bePublic()
.because("@Transactional 在非 public 方法上静默失效");
/**
* 规则2:@Transactional 方法不能被 final 修饰
*/
@ArchTest
static final ArchRule transactionalMethodsNotFinal =
methods().that().areAnnotatedWith(Transactional.class)
.should().notBeFinal()
.because("final 方法无法被 CGLIB 代理,事务失效");
/**
* 规则3:禁止在 Controller 层使用 @Transactional
*/
@ArchTest
static final ArchRule noTransactionalInController =
noClasses().that().resideInAPackage("..controller..")
.should().beAnnotatedWith(Transactional.class)
.andShould().haveOnlyPrivateConstructors()
.because("事务应该在 Service 层,不在 Controller 层");
/**
* 规则4:禁止使用原生 @Transactional,必须用 @BizTransactional
* (保证 rollbackFor 和 timeout 不漏配)
*/
@ArchTest
static final ArchRule mustUseBizTransactional =
noClasses().should().beAnnotatedWith(Transactional.class)
.because("团队统一使用 @BizTransactional(预设 rollbackFor 和 timeout)");
}
九、预防体系:把问题拦在上线前
┌─────────────────────────────────────────────────────┐
│ 五道防线 │
├─────────────────────────────────────────────────────┤
│ ① 编码规范 → @BizTransactional 封装 + 编码规范文档 │
│ ② 静态检查 → ArchUnit 架构测试 + SonarQube 规则 │
│ ③ 自动化测试 → 事务回滚的单元/集成测试 │
│ ④ Code Review → 15 项事务检查清单 │
│ ⑤ 线上监控 → 慢事务/长事务/锁等待/回滚率告警 │
└─────────────────────────────────────────────────────┘
监控告警清单(建议接入 Prometheus + Grafana)
| 监控项 | 数据源 | 告警阈值 | 说明 |
|---|---|---|---|
| 慢事务 | AOP 切面埋点 | P95 > 1s | 自定义切面统计 @Transactional 方法耗时 |
| 长事务 | innodb_trx 定时巡检 |
duration > 10s | 定时 Job 扫描,发现即告警 |
| 连接泄漏 | HikariCP leak-detection | > 0 | 直接给出持连接的代码栈 |
| 连接池水位 | hikaricp_connections_active |
> 80% | 提前预警,避免打满 |
| 锁等待 | performance_schema.data_lock_waits |
等待 > 5s | 可能是死锁前兆 |
| 死锁次数 | SHOW ENGINE INNODB STATUS |
出现即告警 | 偶发也要关注 |
| 事务回滚率 | AOP 埋点统计 | > 5% | 突增说明有 bug |
| Undo 堆积 | History list length | > 100000 | 长事务导致 |
定时巡检 Job 示例
/**
* 数据库事务巡检:定时扫描长事务、锁等待,发现问题立即告警
* ★ 把"事后排查"变成"事前发现"
*/
@Component
@Slf4j
public class TransactionInspectorJob {
@Autowired
private JdbcTemplate jdbcTemplate;
@Autowired
private AlertService alertService;
/**
* 每 1 分钟巡检一次长事务
*/
@Scheduled(fixedDelay = 60000)
public void inspectLongTransaction() {
String sql = """
SELECT trx_id, trx_mysql_thread_id, trx_started,
TIMESTAMPDIFF(SECOND, trx_started, NOW()) AS dur,
trx_rows_locked, trx_rows_modified, LEFT(trx_query, 200) AS query
FROM information_schema.innodb_trx
WHERE TIMESTAMPDIFF(SECOND, trx_started, NOW()) > 10
ORDER BY trx_started
""";
List<Map<String, Object>> longTxs = jdbcTemplate.queryForList(sql);
if (!longTxs.isEmpty()) {
longTxs.forEach(tx -> {
log.warn("[长事务告警] 已运行 {}s, 锁行 {}, 改行 {}, SQL: {}",
tx.get("dur"), tx.get("trx_rows_locked"),
tx.get("trx_rows_modified"), tx.get("query"));
});
alertService.send("发现 " + longTxs.size() + " 个超过 10 秒的长事务");
}
}
/**
* 巡检锁等待
*/
@Scheduled(fixedDelay = 60000)
public void inspectLockWait() {
List<Map<String, Object>> waits = jdbcTemplate.queryForList(
"SELECT * FROM sys.innodb_lock_waits WHERE wait_age_secs > 5");
if (!waits.isEmpty()) {
alertService.send("发现锁等待超过 5 秒:" + waits.size() + " 条");
}
}
}
慢事务 AOP 埋点(接 Prometheus)
/**
* 事务耗时监控切面:统计每个事务方法的耗时、提交/回滚次数
* ★ 接 Prometheus 后能在 Grafana 上看到趋势,提前发现劣化
*/
@Aspect
@Component
@Slf4j
public class TransactionMetricsAspect {
private static final long SLOW_THRESHOLD_MS = 1000;
@Autowired
private MeterRegistry meterRegistry;
@Around("@annotation(org.springframework.transaction.annotation.Transactional) " +
"|| @annotation(com.company.common.annotation.BizTransactional)")
public Object monitor(ProceedingJoinPoint pjp) throws Throwable {
String method = pjp.getSignature().toShortString();
Timer.Sample sample = Timer.start(meterRegistry);
long start = System.currentTimeMillis();
boolean success = false;
try {
Object result = pjp.proceed();
success = true;
return result;
} finally {
long cost = System.currentTimeMillis() - start;
// ① 记录 Prometheus 指标(Grafana 看趋势)
sample.stop(Timer.builder("transaction.duration")
.tag("method", method)
.tag("status", success ? "commit" : "rollback")
.register(meterRegistry));
// ② 慢事务立即告警
if (cost > SLOW_THRESHOLD_MS) {
log.warn("[慢事务] method={}, cost={}ms, 可能持锁并占用连接", method, cost);
}
}
}
}
十、排查速查卡(打印贴工位)
┌──────────────────────────────────────────────────────────┐
│ 事务问题排查速查卡 │
├──────────────────────────────────────────────────────────┤
│ 【确认事务是否生效】 │
│ arthas> sc -d com.xxx.XxxService │
│ 看有没有 $$EnhancerBySpringCGLIB$$ │
│ arthas> trace com.xxx.XxxService method │
│ 看链路里有没有 TransactionInterceptor │
│ 应用内> log.info(TransactionSynchronizationManager │
│ .isActualTransactionActive()) │
│ 应用内> log.info(conn.getAutoCommit()) │
│ false=事务生效 true=事务失效 │
│ │
│ 【看事务行为】 │
│ arthas> watch ...DataSourceTransactionManager doBegin │
│ '{params[1].getName(), params[1] │
│ .getPropagationBehavior()}' -x 3 │
│ propagation: 0=REQUIRED 3=REQUIRES_NEW │
│ 4=NOT_SUPPORTED 6=NESTED │
│ 日志 > logger --name org.springframework.transaction │
│ --level DEBUG │
│ Creating new transaction → 开事务 │
│ Participating in existing → 加入 │
│ Suspending current → 挂起(注意!) │
│ Should commit ... despite → 异常不回滚(配置错) │
│ │
│ 【数据库层】 │
│ SELECT * FROM information_schema.innodb_trx -- 活跃事务 │
│ SELECT * FROM sys.innodb_lock_waits -- 锁等待 │
│ SHOW ENGINE INNODB STATUS -- 死锁日志 │
│ SHOW PROCESSLIST -- 连接状态 │
│ 大量 Sleep + Time 大 → 长事务占连接 │
│ │
│ 【典型症状 → 原因】 │
│ 数据不一致,日志显示 commit → catch 吞异常 │
│ 连接池耗尽 → 长事务(Hikari leak-detection 定位) │
│ UnexpectedRollbackException → REQUIRED 内层异常被外层catch │
│ 多数据源半截数据 → 没指定 transactionManager │
│ 消息发了数据没变 → 异步未用 afterCommit │
│ 批量更新偶发失败 → 交叉加锁死锁(按主键排序解决) │
└──────────────────────────────────────────────────────────┘
事务专题面试题汇总
| # | 题目 | 难度 |
|---|---|---|
| 1 | @Transactional 失效的场景有哪些? | ⭐⭐⭐⭐⭐ |
| 2 | 为什么非 public 方法上的 @Transactional 不生效? | ⭐⭐⭐⭐ |
| 3 | 同类内部调用为什么事务失效?怎么解决? | ⭐⭐⭐⭐⭐ |
| 4 | catch 了异常事务还会回滚吗?怎么正确处理? | ⭐⭐⭐⭐⭐ |
| 5 | 为什么要加 rollbackFor = Exception.class? | ⭐⭐⭐⭐ |
| 6 | 多线程下事务为什么失效?怎么解决? | ⭐⭐⭐⭐⭐ |
| 7 | parallelStream 会不会导致事务失效?为什么? | ⭐⭐⭐⭐⭐ |
| 8 | 多数据源下事务怎么指定?动态数据源有什么坑? | ⭐⭐⭐⭐⭐ |
| 9 | 事务开启后还能切换数据源吗?为什么? | ⭐⭐⭐⭐⭐ |
| 10 | TransactionSynchronization 的回调时机?有什么用? | ⭐⭐⭐⭐ |
| 11 | 什么是长事务?有什么危害? | ⭐⭐⭐⭐ |
| 12 | 长事务导致连接池耗尽怎么排查? | ⭐⭐⭐⭐⭐ |
| 13 | 怎么优化长事务?事务边界怎么划分? | ⭐⭐⭐⭐⭐ |
| 14 | 事务里调用远程接口有什么问题?怎么解决? | ⭐⭐⭐⭐⭐ |
| 15 | 事务提交后发消息怎么保证时序? | ⭐⭐⭐⭐⭐ |
| 16 | readOnly = true 有什么作用?是强制只读吗? | ⭐⭐⭐⭐ |
| 17 | 声明式事务和编程式事务怎么选? | ⭐⭐⭐⭐ |
| 18 | 你们团队对事务有什么编码规范? | ⭐⭐⭐⭐ |
| 19 | 大批量数据处理怎么设计事务? | ⭐⭐⭐⭐⭐ |
| 20 | REQUIRED 内层异常被外层 catch 会怎样? | ⭐⭐⭐⭐⭐ |
| 21 | 事务超时 timeout 是包含整个方法执行时间吗? | ⭐⭐⭐ |
| 22 | 跨库/跨服务事务怎么保证一致性? | ⭐⭐⭐⭐⭐ |
| 23 | 线上怎么确认 @Transactional 到底有没有生效? | ⭐⭐⭐⭐⭐ |
| 24 | Arthas 怎么排查事务问题?常用命令有哪些? | ⭐⭐⭐⭐⭐ |
| 25 | Spring 事务 DEBUG 日志有哪些关键字段?怎么解读? | ⭐⭐⭐⭐ |
| 26 | UnexpectedRollbackException 怎么排查和定位? | ⭐⭐⭐⭐⭐ |
| 27 | 线上死锁怎么排查?怎么预防? | ⭐⭐⭐⭐⭐ |
| 28 | 线上发现数据不一致,怎么排查是不是事务问题? | ⭐⭐⭐⭐⭐ |
| 29 | 怎么预防事务问题?有哪些监控和自动化手段? | ⭐⭐⭐⭐ |
| 30 | 连接池耗尽,怎么定位是不是长事务导致的? | ⭐⭐⭐⭐⭐ |
| 31 | 怎么验证事务真的生效(测试方法)? | ⭐⭐⭐⭐ |
开放题回答框架:你们项目在事务上踩过什么坑?
推荐回答(结合你的简历):
“印象最深的有三个:
第一个是多数据源事务失效。 我们在 Oracle 到 GaussDB 的迁移期,两个数据源并存。有段代码
@Transactional没指定transactionManager,默认用了 GaussDB 的事务管理器,但里面的 SQL 走的是 Oracle——管和用的不是同一个连接,等于完全没事务。这个坑特别隐蔽,代码看起来完全正常,是不小心对账的时候发现的。后来我们要求所有涉及非默认数据源的方法必须显式指定transactionManager。另外还发现一个原理问题:事务一旦开启,数据源就锁定了,因为连接在方法开始前就绑定到了 ThreadLocal,事务内再切换数据源对这个连接无效——所以跨库操作必须拆成多个方法各自指定事务管理器。第二个是长事务导致连接池耗尽。 日终清算最初是一个大事务处理 3 万条指令,跑 30 多分钟,期间数据库连接一直被占着,其他业务拿不到连接,系统响应变慢。我们做了两件事优化:① 把拆分规则从 Oracle 存储过程迁到 Spark SQL 并行计算,中间结果写 HBase;② 大事务拆成小批次,每批 500 条一个独立事务,失败了只重试本批。优化后从 30 多分钟降到 3 分钟以内。
第三个是事务边界问题。 早期有段代码在事务里调银行接口发消息,事务回滚了但消息已经发出去了,造成数据不一致。后来我们定了规范:事务里只做必须的原子性数据库写操作,远程调用、发消息、发文件这些副作用操作一律移到事务外,需要保证时序的用
TransactionSynchronization.afterCommit()回调——这样事务回滚就不会误发消息。同时我们封装了@BizTransactional注解,内部预设rollbackFor = Exception.class和timeout = 30,从规范上杜绝漏写的问题,Code Review 也有对应的检查清单。“
为什么这么答好: 三个坑分别覆盖了原理层(ThreadLocal + 事务管理器)、性能层(长事务优化)、规范层(事务边界 + 团队规范),每个都有真实场景 + 具体解法 + 量化结果,展现的是系统性思考而不是背题。
面试题
Q1:@Transactional 注解的底层原理是什么?
Spring 基于 AOP 实现,调用事务方法时实际调用的是代理对象的方法。代理在方法执行前:通过
TransactionInterceptor调用TransactionManager开启事务 → 执行目标方法 → 正常返回则提交、异常则回滚。核心入口是TransactionInterceptor(实现了MethodInterceptor),底层依赖PlatformTransactionManager(如DataSourceTransactionManager)来管理 JDBC 连接的commit()和rollback()。事务通过TransactionSynchronizationManager使用ThreadLocal将数据库连接绑定到当前线程。
Q2:@Transactional(rollbackFor = Exception.class) 为什么要加 rollbackFor?
Spring 事务默认只在抛出
RuntimeException或Error时回滚。如果业务代码抛出的是受检异常(如IOException、SQLException),默认不会回滚。通过rollbackFor = Exception.class可以让所有异常都触发回滚。
Q3:说说七种传播行为,各自适用什么场景?
- REQUIRED(默认):有事务加入,没有就新建。适合绝大多数业务方法。
- REQUIRES_NEW:总是新开独立事务,外层挂起。适合日志/审计——即使主业务回滚,日志也要保存。
- NESTED:基于 Savepoint 的嵌套子事务,子回滚不影响父,父回滚连带子。适合批量操作中部分失败容错。
- SUPPORTS:有事务就加入,没有就非事务运行。适合纯查询方法。
- NOT_SUPPORTED:非事务运行,有事务就挂起。适合耗时操作,避免长时间占用连接。
- MANDATORY:必须在事务中运行,没有就报错。用于保护核心写操作。
- NEVER:禁止在事务中运行,有事务就报错。用于调用外部接口防止连接被占满。
Q4:REQUIRES_NEW 和 NESTED 有什么区别?
维度 REQUIRES_NEW NESTED 事务数量 两个独立事务 一个事务 + 保存点 数据库连接 占用 2 个 占用 1 个 子事务提交后父事务回滚 子事务不回滚 子事务跟着回滚 子事务回滚 父事务不受影响 父事务不受影响 底层实现 新开 Connection,独立 commit/rollback JDBC Savepoint 简单记忆:REQUIRES_NEW 是“两个完全独立的人”,NESTED 是“父子关系——子出事不影响父,父出事全家遭殃”。
Q5:脏读、不可重复读、幻读分别是什么?怎么解决?
- 脏读:读到了别人未提交的数据,别人回滚后这数据就不存在了。解决:提升到 READ_COMMITTED。
- 不可重复读:同一事务中两次读同一行,中间别人修改并提交了,结果不同。解决:提升到 REPEATABLE_READ。
- 幻读:同一事务中两次范围查询,中间别人新增/删除了记录,结果条数不同。解决:MySQL InnoDB 的 REPEATABLE_READ 通过 MVCC(快照读)+ Next-Key Lock(当前读)基本解决。
Q6:MySQL 默认隔离级别是什么?为什么不用 SERIALIZABLE?
MySQL InnoDB 默认是 REPEATABLE_READ。不用 SERIALIZABLE 是因为 SERIALIZABLE 完全串行化执行事务,性能极差,并发度极低。InnoDB 在 REPEATABLE_READ 级别下通过 MVCC + Next-Key Lock 已经解决了脏读、不可重复读和大部分幻读问题,在数据一致性和性能之间取得了最佳平衡。大多数业务场景下 REPEATABLE_READ 就够用了。
Q7:MySQL InnoDB 在 REPEATABLE_READ 下是怎么解决幻读的?
两种机制配合:
- 快照读(普通 SELECT):通过 MVCC(多版本并发控制),事务第一次查询时生成 Read View,后续查询复用同一 Read View,始终读取事务开始时的快照数据,不受其他事务插入的影响。
- 当前读(SELECT … FOR UPDATE / UPDATE / DELETE):通过 Next-Key Lock(Record Lock + Gap Lock)锁住查询范围,阻止其他事务在范围内插入新数据。
⚠️ 但快照读和当前读混用时仍可能出现不一致(不严格叫“幻读”),所以不是 100% 完全消除。
Q8:你在项目里怎么用的事务?说一下传播行为的实际应用。
参考回答(结合简历项目): “在神州数码托管系统里,创建银行指令时需要同时写指令表、更新头寸表、记录操作日志。核心操作用默认的 REQUIRED 保证原子性。但操作日志用 REQUIRES_NEW 单独开事务——这样即使指令创建失败回滚了,失败日志也能保存下来用于事后排查。另外批量导入指令时,每条指令的导入用 NESTED 传播行为,单条失败回滚到保存点不影响其他指令,导入完成后统一汇总成功/失败条数。”
1.6 全局异常处理与配置体系
全局异常处理
@RestControllerAdvice // @ControllerAdvice + @ResponseBody
@Slf4j
public class GlobalExceptionHandler {
// 处理业务异常
@ExceptionHandler(BusinessException.class)
public Result<Void> handleBusiness(BusinessException e) {
log.warn("业务异常: {}", e.getMessage());
return Result.fail(e.getCode(), e.getMessage());
}
// 处理参数校验异常
@ExceptionHandler(MethodArgumentNotValidException.class)
public Result<Void> handleValid(MethodArgumentNotValidException e) {
String msg = e.getBindingResult().getFieldErrors().stream()
.map(f -> f.getField() + ": " + f.getDefaultMessage())
.collect(Collectors.joining("; "));
return Result.fail(400, msg);
}
// 兜底:处理所有未捕获异常
@ExceptionHandler(Exception.class)
public Result<Void> handleAll(Exception e) {
log.error("系统异常", e);
return Result.fail(500, "系统繁忙,请稍后重试");
}
}
多环境配置
# application.yml(公共配置)
spring:
profiles:
active: dev # 默认激活 dev 环境
# application-dev.yml(开发环境)
server:
port: 8080
spring:
datasource:
url: jdbc:mysql://localhost:3306/dev_db
# application-prod.yml(生产环境)
server:
port: 80
spring:
datasource:
url: jdbc:mysql://prod-mysql:3306/prod_db
# 启动时切换环境
java -jar app.jar --spring.profiles.active=prod
面试题
Q1:@Configuration 和 @Component 的区别?
@Configuration标注的类是“全模式配置类”(proxyBeanMethods=true),Spring 会用 CGLIB 生成代理类,保证@Bean方法调用时返回的是容器中的单例 Bean。@Component是“轻量模式”(proxyBeanMethods=false),@Bean方法之间的调用会创建新对象。
@Configuration // 全模式:myBean() 返回容器中的单例
public class AppConfig {
@Bean
public A a() { return new A(b()); } // b() 返回容器中已有的 B
@Bean
public B b() { return new B(); }
}
1.7 Spring 常用注解全景详解(★ 面试高频,必背)
本章定位: 前面 1.1~1.5 和四个专题是按“原理主题”组织的(讲 IoC 原理、AOP 原理、事务原理)。 本章换一个维度——按“注解”组织,把 Spring/Spring Boot 常用注解逐个拆开讲清楚: 它是什么、有哪些属性、怎么用、有什么坑、面试怎么问。
为什么单独开一章? 面试官问注解的方式和问原理完全不同。问原理是“讲讲 Spring 事务的实现”, 问注解是“@Autowired 和 @Resource 有什么区别““@Transactional 加在 private 方法上生效吗” “@Async 为什么没生效”——这类题靠背原理答不圆,必须逐个注解记牢。 而且这类题有个特点:答错就是硬伤,因为它们都是“你每天都在写”的东西。
你的简历关联: 技能清单写的是 “Spring Boot / Spring Cloud”,项目一至四全是 Spring Boot 应用。 面试官默认你对这些注解烂熟于心,问得会比普通候选人更细(比如追问
@Configuration的proxyBeanMethods属性、@Async的线程池默认行为)。
1.7.0 全景:Spring 注解到底有多少,怎么记
先给一张全景图,避免“学了一堆注解但脑子里是散的”。
┌──────────────────────────────────────────────────────────────────────────┐
│ Spring / Spring Boot 注解全景 │
├──────────────────────────────────────────────────────────────────────────┤
│ │
│ ① 【Bean 声明】把对象交给 Spring 管 │
│ @Component @Service @Repository @Controller @RestController │
│ @Bean @Configuration @Scope @Lazy @Import │
│ │
│ ② 【依赖注入】把对象取出来用 ★ 1.7.1 │
│ @Autowired @Resource @Inject @Qualifier @Primary @Value │
│ │
│ ③ 【事务控制】保证数据一致 ★ 1.7.2 │
│ @Transactional @EnableTransactionManagement │
│ │
│ ④ 【生命周期】创建前后、销毁前做点事 │
│ @PostConstruct @PreDestroy @DependsOn @Order │
│ │
│ ⑤ 【配置绑定】把 yml/properties 读进来 │
│ @ConfigurationProperties @PropertySource @Profile │
│ │
│ ⑥ 【条件装配】满足条件才创建 Bean(Boot 自动配置的基石) │
│ @Conditional @ConditionalOnClass @ConditionalOnMissingBean │
│ @ConditionalOnProperty @ConditionalOnBean @ConditionalOnWebApplication│
│ │
│ ⑦ 【Web 层】接请求、返响应 │
│ @RequestMapping @GetMapping @PostMapping @RequestParam │
│ @PathVariable @RequestBody @ResponseBody @ResponseStatus │
│ @ControllerAdvice @ExceptionHandler @CrossOrigin │
│ │
│ ⑧ 【参数校验】入参合法性 │
│ @Valid @Validated @NotNull @NotBlank @Size @Pattern │
│ │
│ ⑨ 【AOP 切面】横切逻辑 │
│ @Aspect @Pointcut @Before @After @AfterReturning │
│ @AfterThrowing @Around @EnableAspectJAutoProxy │
│ │
│ ⑩ 【异步与定时】 │
│ @Async @EnableAsync @Scheduled @EnableScheduling │
│ │
│ ⑪ 【Boot 启动与自动配置】 │
│ @SpringBootApplication @SpringBootConfiguration │
│ @EnableAutoConfiguration @AutoConfigurationPackage │
│ @SpringBootTest @AutoConfigureBefore/After/Order │
│ │
│ ⑫ 【测试】 │
│ @SpringBootTest @MockBean @Test @BeforeEach │
│ │
└──────────────────────────────────────────────────────────────────────────┘
【记忆技巧:按“Bean 的一生”串起来】
不要死记硬背,按一个对象从生到死的过程串:
声明 Bean ──→ 注入依赖 ──→ 初始化 ──→ 使用中 ───→ 销毁
│ │ │ │ │
@Component @Autowired @PostConstruct 切面/AOP @PreDestroy
@Service @Resource @Async
@Bean @Value @Transactional
@Configuration @Scheduled
│
└─ 条件:@Conditional 系列(决定"要不要声明")
└─ 配置:@ConfigurationProperties(决定"属性从哪来")
└─ 作用域:@Scope @Lazy(决定"创建几个、什么时候创建")
【面试时的分层回答法】
被问“你熟悉哪些 Spring 注解”,不要像报菜名一样背,按上面的分层说 45 个类别,
每类举 23 个并带一个坑点,这才是高手答法。示例见 1.7.12 的 Q1。
1.7.1 ★ 依赖注入注解(面试必考,区分度最高)
面试官最爱问的三连: ①
@Autowired和@Resource有什么区别? ② 你项目里用哪种注入方式?为什么?(答“字段注入”直接扣分) ③ 一个接口有多个实现类,怎么指定注入哪一个?
1.7.1.1 先理清:注入这件事到底是谁在做
很多人用了几年注解,说不清“注入”是谁执行的。先补这个底层认知:
Spring 的依赖注入 = 注解声明"我要什么" + 后置处理器"帮你塞进去"
┌─────────────────────────────────────────────────────────────┐
│ @Autowired → AutowiredAnnotationBeanPostProcessor 处理 │
│ @Resource → CommonAnnotationBeanPostProcessor 处理 │
│ @Inject → AutowiredAnnotationBeanPostProcessor 处理 │
│ (和 @Autowired 走同一套逻辑) │
└─────────────────────────────────────────────────────────────┘
关键结论:
★ @Autowired 和 @Resource 是【两套完全不同的处理逻辑】,
不是"一个换了个马甲"。这决定了它们的行为差异(下面详讲)。
时序(Bean 创建过程中的注入时机):
实例化(new) → 属性填充(populateBean,★注入在这里) → 初始化 → 放入单例池
│
└─ 由 InstantiationAwareBeanPostProcessor
的 postProcessProperties() 完成
1.7.1.2 @Autowired 详解(Spring 自家,最常用)
基本信息:
package org.springframework.beans.factory.annotation;
@Target({ElementType.CONSTRUCTOR, ElementType.METHOD,
ElementType.PARAMETER, ElementType.FIELD, ElementType.ANNOTATION_TYPE})
@Retention(RetentionPolicy.RUNTIME)
@Documented
public @interface Autowired {
boolean required() default true; // 唯一属性
}
核心要点:
| 要点 | 说明 |
|---|---|
| 出处 | Spring 自家(org.springframework.beans.factory.annotation) |
| 注入顺序 | 先 byType,再 byName(重点,见下方流程) |
required |
默认 true:找不到就抛 NoSuchBeanDefinitionException;设为 false 则注入 null |
| 可用位置 | 字段、构造器、setter 方法、普通方法、方法参数 |
| 支持集合 | 可以注入 List<T> / Map<String,T>,把同类型所有实现都收进来(做策略模式很好用) |
注入流程(面试要能口述):
@Autowired 注入的完整查找流程
─────────────────────────────────────────────
① 按【类型】去容器里找(byType)
│
├─ 找到 0 个 → required=true 抛异常;required=false 注入 null
│
├─ 找到 1 个 → 直接注入 ✅(最常见)
│
└─ 找到多个 → 进入第 ② 步(关键分支)
│
② 按【字段名/属性名】去匹配 Bean 的名字(byName)
│
├─ 名字能对上唯一一个 → 注入它
│
└─ 名字也对不上(或匹配到多个)→ 继续第 ③ 步
│
③ 看有没有 @Primary / @Priority
│
├─ 有 @Primary → 注入被标记的那个
│
└─ 没有 → 看有没有 @Qualifier
│
├─ 有 @Qualifier → 按指定的名字注入
│
└─ 都没有 → 抛 NoUniqueBeanDefinitionException ❌
面试话术: “@Autowired 默认按类型注入,当找到多个同类型的 Bean 时, 会退化成按名称匹配(用字段名去匹配 Bean 名);如果名称也匹配不上, 就看有没有
@Primary或@Qualifier,都没有就抛NoUniqueBeanDefinitionException。”
代码示例:
@Service
public class InstructionService {
// ① 字段注入(最常见,但不推荐,见 1.7.1.7)
@Autowired
private InstructionMapper instructionMapper;
// ② 构造器注入(★ Spring 官方推荐)
private final PositionMapper positionMapper;
@Autowired // ★ 只有一个构造器时,这个注解可以省略
public InstructionService(PositionMapper positionMapper) {
this.positionMapper = positionMapper;
}
// ③ setter 注入
private RiskMapper riskMapper;
@Autowired
public void setRiskMapper(RiskMapper riskMapper) {
this.riskMapper = riskMapper;
}
// ④ required = false:找不到不报错,注入 null(使用前要判空)
@Autowired(required = false)
private AuditService auditService; // 审计服务可能没配置
// ⑤ 注入集合:把 InstructionHandler 接口的所有实现都收进来
// ★ 这是做"策略模式"的标准写法(你项目里的指令类型分发可以用)
@Autowired
private List<InstructionHandler> handlers; // 所有实现,按 @Order 排序
@Autowired
private Map<String, InstructionHandler> handlerMap; // key=Bean名, value=实现
}
Map<String, T> 注入的实用场景(策略模式):
// 定义策略接口
public interface InstructionHandler {
String getType(); // "BUY" / "SELL" / "TRANSFER"
void handle(Instruction ins);
}
// 多个实现
@Component("buyHandler")
public class BuyHandler implements InstructionHandler { ... }
@Component("sellHandler")
public class SellHandler implements InstructionHandler { ... }
// 使用:注入 Map,按类型分发,加新类型只需加一个 @Component,不用改这里
@Service
public class InstructionDispatcher {
@Autowired
private Map<String, InstructionHandler> handlerMap;
public void dispatch(Instruction ins) {
InstructionHandler handler = handlerMap.get(ins.getType() + "Handler");
if (handler == null) throw new BizException("不支持的指令类型");
handler.handle(ins);
}
}
面试加分点: 能说出“用
@Autowired注入Map/List实现策略模式, 符合开闭原则,新增策略不用改分发逻辑”——这比单纯背书强很多。
1.7.1.3 @Resource 详解(JSR-250 标准,Java 自带)
基本信息:
package jakarta.annotation; // ★ 注意:不是 Spring 的包!(Spring Boot 3 用 jakarta,2.x 用 javax)
@Target({ElementType.TYPE, ElementType.FIELD, ElementType.METHOD})
@Retention(RetentionPolicy.RUNTIME)
public @interface Resource {
String name() default ""; // 指定 Bean 名称
String lookup() default "";
Class<?> type() default Object.class;
// ... 还有 authenticationType、shareable、mappedName、description
}
核心要点:
| 要点 | 说明 |
|---|---|
| 出处 | JDK 标准(JSR-250),不是 Spring 的 → 换框架也能用(低耦合) |
| 注入顺序 | 先 byName,再 byType(★ 和 @Autowired 正好相反,这是最核心的区别) |
| 属性 | 有 name 和 type,可以精确指定 |
| required | 没有 required 属性 → 找不到就抛异常,没法“忽略” |
| 可用位置 | 字段、setter 方法(不支持构造器注入、不支持参数) |
注入流程(对比记忆):
@Resource 注入流程
─────────────────────────────────────────────
① 如果指定了 name → 只按 name 找,找不到直接报错(★ 不会退化为 byType)
│
② 如果没指定 name(默认用字段名/属性名)→ 先 byName
│
├─ 按名字找到 → 注入 ✅
│ ⚠️ 注意:找到后还要校验类型是否匹配,不匹配会报错
│
└─ 按名字找不到 → 退化 byType
│
├─ 找到 1 个 → 注入
└─ 找到多个 → 抛 NoUniqueBeanDefinitionException(★ 此时没有 @Primary 兜底逻辑差异,见下)
⚠️ 重要细节(很多人答错):
@Resource在 byType 找到多个时,不会走@Primary的兜底逻辑吗? 实际上 Spring 的CommonAnnotationBeanPostProcessor最终会调用resolveDependency,@Primary 仍然生效。但@Qualifier对@Resource不生效——指定名字请用@Resource(name="xxx"),不要混用@Qualifier。 面试时说“@Resource 指定名称用 name 属性,不要配 @Qualifier“即可。
代码示例:
@Service
public class TradeService {
// ① 默认:先按字段名 "tradeMapper" 找,找不到再按 TradeMapper 类型找
@Resource
private TradeMapper tradeMapper;
// ② 指定 name:只按名字找,找不到报错
@Resource(name = "tradeMapperMaster")
private TradeMapper tradeMapper;
// ③ 指定 type
@Resource(type = TradeMapper.class)
private TradeMapper mapper;
// ④ ❌ 错误:@Resource 不支持构造器注入(编译期不报错,但不会注入)
// @Resource
// public TradeService(TradeMapper m) { ... } // 无效!
// ⑤ ❌ 错误:@Resource 没有 required 属性
// @Resource(required = false) // 编译错误
}
1.7.1.4 @Inject 详解(JSR-330,用得最少)
package jakarta.inject; // 需要额外引入 javax.inject / jakarta.inject 依赖
@Target({ElementType.METHOD, ElementType.CONSTRUCTOR, ElementType.FIELD})
@Retention(RetentionPolicy.RUNTIME)
public @interface Inject {}
| 要点 | 说明 |
|---|---|
| 出处 | JSR-330 标准,需要额外引依赖(jakarta.inject:jakarta.inject-api) |
| 注入顺序 | 先 byType,再 byName(和 @Autowired 一致) |
| required | 没有 required 属性(和 @Resource 一样) |
| 配合 | 多实现时用 @Named("xxx")(等价于 @Qualifier),@Primary 用 @Singleton 无关 |
@Service
public class DemoService {
@Inject // 等价于 @Autowired
private FooMapper fooMapper;
@Inject
@Named("barMapper") // 等价于 @Qualifier("barMapper")
private BarMapper barMapper;
}
面试立场: “@Inject 实际项目里基本不用,因为它需要额外引依赖, 而功能上又和 @Autowired 几乎一样,没有引入的必要。知道它是 JSR-330 标准、 行为接近 @Autowired 就够了。”
1.7.1.5 ★★★ @Autowired vs @Resource vs @Inject 三方对比(面试必背)
这是本章最高频的一道题,没有之一。建议背下这张表。
| 对比维度 | @Autowired |
@Resource |
@Inject |
|---|---|---|---|
| 出处 | Spring 自家 | JDK 标准 (JSR-250) | JDK 标准 (JSR-330) |
| 包路径 | org.springframework...annotation |
jakarta.annotation |
jakarta.inject |
| 需要额外依赖 | 否 | 否(JDK 自带) | 是 |
| 默认注入方式 | 先 byType,再 byName | 先 byName,再 byType | 先 byType,再 byName |
| 是否支持构造器注入 | ✅ 支持 | ❌ 不支持 | ✅ 支持 |
| 是否支持方法参数注入 | ✅ 支持 | ❌ 不支持 | ✅ 支持 |
required=false(找不到不报错) |
✅ 有 | ❌ 没有 | ❌ 没有 |
| 指定 Bean 名称 | 配合 @Qualifier("xxx") |
自带 name 属性 |
配合 @Named("xxx") |
是否支持 @Primary |
✅ | ✅ | ✅ |
| 是否支持集合注入 | ✅ | ❌ | ✅ |
| 处理类 | AutowiredAnnotationBeanPostProcessor |
CommonAnnotationBeanPostProcessor |
同 @Autowired |
★ 面试标准答案(可直接口述,30 秒版):
“这三个都能做依赖注入,核心区别有三点:
第一,来源不同。
@Autowired是 Spring 自带的,@Resource和@Inject是 Java 官方规范(JSR-250 / JSR-330)。所以用@Resource对框架的耦合更低, 换掉 Spring 也能用——但实际项目很少换,所以这个优势不明显。第二,注入顺序相反,这是最关键的。
@Autowired默认按类型找, 找到多个才退化成按名称;@Resource默认按名称找,找不到才退化成按类型。 举个实际会踩的坑:一个接口有两个实现masterMapper和slaveMapper, 我用@Autowired注入字段名写mapper,会报“找到 2 个 Bean”; 但如果字段名写成masterMapper,就能注入成功——这就是 byName 兜底在起作用。 而@Resource反过来,字段名写对就直接命中,效率更高也更直观。第三,能力范围不同。
@Autowired功能最全:支持构造器注入、支持required=false、支持注入List/Map集合。@Resource不支持构造器注入, 也没有required属性。我个人的习惯是: 构造器注入用
@Autowired(可以省略不写), 字段注入用@Resource(按名字找更明确,IDE 也不会报黄色警告), 需要精确指定时用@Resource(name="xxx")或@Qualifier。“
追问 1:为什么 IDEA 在字段上用 @Autowired 会报黄色警告?
“IDEA 提示的是 ‘Field injection is not recommended’, Spring 官方也不推荐字段注入,原因有四个(见 1.7.1.7): ① 依赖可以是 final 的(构造器注入支持),更安全; ② 避免循环依赖在启动期才暴露的问题(构造器注入启动就失败,字段注入要运行时才发现); ③ 字段注入的依赖无法在单元测试中手动传入(必须启动 Spring 容器); ④ 字段注入隐藏了类的依赖,类可能依赖过多导致违反单一职责——构造器参数一多就明显了。”
追问 2:那 @Resource 字段注入不也一样吗?为什么没警告?
“IDEA 只对 Spring 自家的
@Autowired做这项检查,@Resource作为 JDK 标准注解 不在检查范围内。但从工程角度,构造器注入仍是最优选择,和用哪个注解无关。”
1.7.1.6 @Qualifier 与 @Primary(多实现的两种解法)
当一个接口有多个实现时,有两种指定方式:
方式一:@Qualifier —— 消费方指定(我要哪个)
public interface DataSource { }
@Component("masterDs") // Bean 名 = masterDs
public class MasterDataSource implements DataSource { }
@Component("slaveDs") // Bean 名 = slaveDs
public class SlaveDataSource implements DataSource { }
@Service
public class QueryService {
@Autowired
@Qualifier("masterDs") // ★ 指定要名字为 masterDs 的那个
private DataSource dataSource;
}
方式二:@Primary —— 提供方指定(默认用我)
@Component
@Primary // ★ 标记"默认首选",冲突时优先选我
public class MasterDataSource implements DataSource { }
@Component
public class SlaveDataSource implements DataSource { }
@Service
public class QueryService {
@Autowired
private DataSource dataSource; // 直接注入,拿到的是 MasterDataSource
}
对比与选型:
@Qualifier |
@Primary |
|
|---|---|---|
| 立场 | 消费方决定 | 提供方决定 |
| 使用场景 | 不同地方需要不同实现 | 有一个“默认实现”,绝大多数场景用默认 |
| 优先级 | 更高(@Qualifier 优先于 @Primary) |
|
| 典型例子 | 读写分离:不同的 Service 注入 master/slave | 多套支付渠道,默认走微信支付 |
优先级记忆:
@Qualifier>@Primary> 按字段名匹配 > 报错。 即:如果同时用了@Qualifier和存在@Primary,听@Qualifier的。
结合你简历项目的实例(资产托管多数据源):
// 项目一资产托管:托管行接口有多个实现(工行/建行/中行),默认走工行
@Component("icbcAdapter")
@Primary // 默认用工行
public class IcbcBankAdapter implements BankAdapter { ... }
@Component("ccbAdapter")
public class CcbBankAdapter implements BankAdapter { ... }
// 路由服务:按托管行动态选择,用 Map 注入
@Service
public class BankAdapterRouter {
@Autowired
private Map<String, BankAdapter> adapterMap; // key = icbcAdapter / ccbAdapter
public BankAdapter route(String bankCode) {
return adapterMap.get(bankCode.toLowerCase() + "Adapter");
}
}
1.7.1.7 ★ 三种注入方式对比 + 为什么构造器注入是最优解
三种方式代码对照:
// ① 字段注入(Field Injection)—— ❌ 不推荐
@Service
public class OrderService {
@Autowired
private OrderMapper orderMapper;
@Autowired
private StockMapper stockMapper;
}
// ② Setter 注入(Setter Injection)—— 🟡 可选(适合可选依赖)
@Service
public class OrderService {
private OrderMapper orderMapper;
@Autowired
public void setOrderMapper(OrderMapper orderMapper) {
this.orderMapper = orderMapper;
}
}
// ③ 构造器注入(Constructor Injection)—— ✅ Spring 官方推荐
@Service
public class OrderService {
private final OrderMapper orderMapper; // ★ 可以是 final
private final StockMapper stockMapper;
// 只有一个构造器时,@Autowired 可省略(Spring 4.3+)
public OrderService(OrderMapper orderMapper, StockMapper stockMapper) {
this.orderMapper = orderMapper;
this.stockMapper = stockMapper;
}
}
★ 构造器注入的四大优势(面试要能背出来):
| # | 优势 | 说明 |
|---|---|---|
| 1 | 依赖不可变 | 字段可以声明为 final,对象创建后依赖不可被修改,线程安全 |
| 2 | 依赖不为 null | 构造器执行完,依赖必然已注入(字段注入在极端场景下可能是 null,如测试绕过容器) |
| 3 | 循环依赖早暴露 | 构造器注入的循环依赖在启动阶段就抛错,而不是运行到某个请求才发现 |
| 4 | 便于单元测试 | 不需要启动 Spring 容器,new OrderService(mockMapper) 即可测试 |
对比表:
| 维度 | 字段注入 | Setter 注入 | 构造器注入 |
|---|---|---|---|
| 代码简洁度 | ⭐⭐⭐ 最简洁 | ⭐ 啰嗦 | ⭐⭐ |
| 依赖可变性 | 可变 | 可变(随时 set) | 不可变(final) |
| 循环依赖 | 允许(三级缓存隐藏问题) | 允许 | 启动即报错 |
| 单元测试 | 需启动容器 | 可手动 set | 直接 new |
| 依赖过多时 | 不明显(隐患) | 不明显 | 编译期就看出参数太多 |
| 官方推荐 | ❌ | 🟡(可选依赖) | ✅ |
★ 面试标准答案(口述版):
“我项目里优先用构造器注入,配合 Lombok 的
@RequiredArgsConstructor, 代码量其实和字段注入差不多。主要原因是四个:一是依赖可以声明成
final,保证不可变和线程安全; 二是避免 NPE,构造器执行完依赖一定存在; 三是循环依赖能提前暴露——字段注入的循环依赖被三级缓存“偷偷”解决了, 启动时不报错,但代码设计其实有问题;构造器注入会让 Spring 启动直接失败, 逼着你去优化设计; 四是单测友好,不用启动容器就能 new 出来测。如果是可选依赖(比如某个监控组件,没配就降级),我会用
@Autowired(required = false)配合 setter 注入,或者用ObjectProvider延迟获取。“
Lombok 简化(实际项目写法):
@Service
@RequiredArgsConstructor // ★ 为所有 final 字段生成构造器
public class InstructionService {
private final InstructionMapper instructionMapper;
private final PositionService positionService;
private final RiskCheckClient riskCheckClient;
// 不需要手写构造器,也不需要写 @Autowired
// Lombok 生成的构造器 + Spring 4.3+ 的"单构造器自动注入"规则 = 零注解注入
}
⚠️ 注意一个坑:
@RequiredArgsConstructor只对final字段和@NonNull字段生成构造器参数。如果字段没加final,不会被包含进去, 运行时就是 null——这是新人常见的 NPE 来源。
1.7.1.8 循环依赖与三级缓存(注入时绕不开的话题)
详细的三级缓存原理见 1.1 节,这里只讲和注入方式相关的部分。
循环依赖的三种情况:
| 注入方式 | 循环依赖能否被解决 | 原因 |
|---|---|---|
| 字段注入 / Setter 注入 | ✅ 能(三级缓存提前暴露对象引用) | |
| 构造器注入 | ❌ 不能,启动抛 BeanCurrentlyInCreationException |
|
| prototype 作用域 | ❌ 不能(不进缓存) |
为什么构造器循环依赖无解?
A 的构造器要 B → 去找 B
B 的构造器要 A → 去找 A
A 还在"实例化中"(构造器都没执行完),没法提前暴露引用
→ 死锁,Spring 直接抛错
而字段注入:
A 先 new 出来(构造器执行完,但属性还没填)→ 把 A 的引用放进三级缓存
→ 填 A 的属性时发现要 B → new B → 填 B 的属性时发现要 A
→ 从三级缓存拿到 A 的早期引用 → B 完成 → A 完成 ✅
★ 面试话术:
“Spring 用三级缓存解决循环依赖,但只能解决字段注入和 setter 注入的循环依赖。 构造器注入的循环依赖是无解的,因为对象在构造器执行完之前无法暴露引用, 会直接抛
BeanCurrentlyInCreationException。但我认为这不是构造器注入的缺点,反而是优点——它把设计问题在启动阶段就暴露出来了。 遇到这种情况,正确的做法不是换成字段注入绕过,而是重构: ① 把公共逻辑抽到第三个类;② 用
@Lazy延迟加载其中一方; ③ 用事件驱动(ApplicationEvent)解耦;④ 实在不行用ObjectProvider延迟获取。“
三种解法代码:
// 解法一:@Lazy 延迟加载(最小改动)
@Service
public class ServiceA {
private final ServiceB serviceB;
public ServiceA(@Lazy ServiceB serviceB) { // ★ 注入的是代理对象,真正用的时候才创建
this.serviceB = serviceB;
}
}
// 解法二:ObjectProvider 延迟获取
@Service
public class ServiceA {
@Autowired
private ObjectProvider<ServiceB> serviceBProvider;
public void doSomething() {
ServiceB b = serviceBProvider.getIfAvailable(); // 用到才取
b.execute();
}
}
// 解法三:事件驱动解耦(最优雅)
@Service
public class ServiceA {
@Autowired
private ApplicationEventPublisher publisher;
public void doSomething() {
publisher.publishEvent(new OrderCreatedEvent(orderId)); // 不直接依赖 B
}
}
@Component
class ServiceB {
@EventListener
public void onOrderCreated(OrderCreatedEvent e) { ... }
}
1.7.1.9 注入相关的 8 个坑(实战血泪)
| # | 坑 | 现象 | 解法 |
|---|---|---|---|
| 1 | 静态字段注入不生效 | static 字段永远是 null |
见下方代码,用 @PostConstruct 或非静态 |
| 2 | new 出来的对象注入为 null | new XxxService() 拿到的对象,字段全 null |
必须从容器取,不能 new |
| 3 | 字段注入 + 单元测试 = NPE | 单测里 new 对象,依赖没注入 | 改构造器注入 |
| 4 | @Resource 用在构造器上 |
静默失效,参数为 null | 用 @Autowired 或省略 |
| 5 | 多实现没指定 → 启动报错 | NoUniqueBeanDefinitionException |
@Qualifier / @Primary / 字段名对齐 |
| 6 | @Autowired(required=false) 后没判空 |
NPE | 用前判空或 Optional |
| 7 | prototype 注入 singleton | prototype 失效,永远是同一个对象 | @Lookup / ObjectProvider |
| 8 | Lombok @RequiredArgsConstructor 忘加 final |
依赖为 null | 检查字段是否 final |
坑 1:静态字段注入(经典)
@Component
public class SmsUtil {
@Autowired
private static SmsClient smsClient; // ❌ 注入不进去,永远是 null
public static void send(String phone) {
smsClient.send(phone); // ❌ NPE
}
}
原因: Spring 的依赖注入是基于“对象实例”的,静态字段属于“类”, 不属于任何实例,Spring 不会也不应该去注入静态字段。
正确写法:
@Component
public class SmsUtil {
private static SmsClient staticClient; // 静态副本
@Autowired
private SmsClient smsClient; // 实例字段,正常注入
@PostConstruct // ★ 初始化后赋值给静态字段
public void init() {
staticClient = smsClient;
}
public static void send(String phone) {
staticClient.send(phone);
}
}
// 或者更推荐:别用静态方法,改成 Spring Bean 注入使用
@Service
public class SmsService {
@Autowired
private SmsClient smsClient;
public void send(String phone) { smsClient.send(phone); }
}
坑 7:prototype 注入 singleton(很多人不知道)
@Component
@Scope("prototype") // 声明为多例
public class TaskContext { }
@Service
public class TaskService {
@Autowired
private TaskContext taskContext; // ⚠️ 永远是同一个实例!prototype 失效
}
原因: singleton Bean 在创建时注入一次依赖,之后不再重新注入,
所以多例的 TaskContext 只被注入了一次,表现为单例。
解法:
// 解法一:ObjectProvider(推荐)
@Autowired
private ObjectProvider<TaskContext> provider;
public void run() {
TaskContext ctx = provider.getObject(); // 每次都是新实例 ✅
}
// 解法二:@Lookup 方法注入
@Lookup
public TaskContext getTaskContext() { return null; } // Spring 会重写这个方法
// 解法三:直接 applicationContext.getBean()(不优雅,但直白)
1.7.1.10 依赖注入面试题(18 题)
| # | 题目 | 难度 | 要点 |
|---|---|---|---|
| 1 | @Autowired 和 @Resource 的区别? |
⭐⭐⭐⭐⭐ | 出处、注入顺序(byType/byName 相反)、能力范围(构造器/required/集合) |
| 2 | @Autowired 的注入流程是怎样的?找到多个怎么办? |
⭐⭐⭐⭐ | byType → byName → @Primary → @Qualifier → 抛异常 |
| 3 | @Resource 的注入流程?和 @Autowired 顺序为什么相反? |
⭐⭐⭐⭐ | byName → byType |
| 4 | 一个接口多个实现类,怎么指定注入哪个? | ⭐⭐⭐⭐ | @Qualifier(消费方)/ @Primary(提供方),@Qualifier 优先级高 |
| 5 | @Qualifier 和 @Primary 同时存在听谁的? |
⭐⭐⭐ | @Qualifier 优先 |
| 6 | 为什么推荐构造器注入? | ⭐⭐⭐⭐⭐ | final 不可变 / 不为 null / 循环依赖早暴露 / 单测友好 |
| 7 | 字段注入有什么缺点? | ⭐⭐⭐⭐ | 可变为非 final、隐藏依赖、单测需容器、循环依赖被隐藏 |
| 8 | 构造器循环依赖能解决吗?为什么? | ⭐⭐⭐⭐ | 不能。构造器没执行完无法暴露引用 |
| 9 | Spring 三级缓存解决的是什么循环依赖? | ⭐⭐⭐⭐ | 字段/setter 注入的循环依赖;prototype 也不行 |
| 10 | 静态字段为什么注入不进去?怎么解决? | ⭐⭐⭐⭐ | 静态属于类不属于实例;用 @PostConstruct 赋值或改实例方法 |
| 11 | @Autowired 的 required=false 有什么用? |
⭐⭐⭐ | 找不到 Bean 不报错,注入 null(可选依赖) |
| 12 | @Autowired 注入 List/Map 是什么效果?有什么用? |
⭐⭐⭐⭐ | 收集同类型所有实现;用于策略模式,符合开闭原则 |
| 13 | prototype 的 Bean 注入到 singleton 里,还是多例吗? | ⭐⭐⭐⭐ | 不是,只注入一次。用 ObjectProvider / @Lookup |
| 14 | @Inject 和 @Autowired 的区别? |
⭐⭐⭐ | @Inject 是 JSR-330,需额外依赖,无 required,功能几乎一样 |
| 15 | 只有一个构造器时 @Autowired 能省略吗? |
⭐⭐⭐ | 能(Spring 4.3+) |
| 16 | @Resource 能用在构造器上吗? |
⭐⭐⭐ | 不能,静默失效 |
| 17 | 怎么注入一个可选依赖(没有就降级)? | ⭐⭐⭐⭐ | @Autowired(required=false) + 判空 / ObjectProvider.getIfAvailable() |
| 18 | 循环依赖有哪些解法? | ⭐⭐⭐⭐ | 重构(最优)/ @Lazy / ObjectProvider / 事件驱动 |
1.7.2 ★ 事务注解 @Transactional 详解
本节与 1.5 节的分工: 1.5 节讲事务原理(传播行为怎么实现、隔离级别解决什么问题、失效场景怎么排查); 本节讲注解本身(有哪些属性、默认值是什么、怎么正确配置、有哪些配置陷阱)。 面试时两节要结合起来答——原理 + 配置细节都答出来才是满分。
1.7.2.1 注解定义与全部属性(一张表背下来)
package org.springframework.transaction.annotation;
@Target({ElementType.TYPE, ElementType.METHOD})
@Retention(RetentionPolicy.RUNTIME)
@Inherited
@Documented
public @interface Transactional {
@AliasFor("transactionManager")
String value() default ""; // 事务管理器别名
@AliasFor("value")
String transactionManager() default ""; // 指定事务管理器
String[] label() default {}; // 事务标签(5.3+,用于监控分类)
Propagation propagation() default Propagation.REQUIRED; // 传播行为
Isolation isolation() default Isolation.DEFAULT; // 隔离级别
int timeout() default TransactionDefinition.TIMEOUT_DEFAULT; // 超时秒数,默认 -1(不超时)
String timeoutString() default ""; // 超时(支持占位符)
boolean readOnly() default false; // 只读事务
Class<? extends Throwable>[] rollbackFor() default {}; // ★ 触发回滚的异常类型
String[] rollbackForClassName() default {}; // 同上,字符串形式
Class<? extends Throwable>[] noRollbackFor() default {}; // ★ 不触发回滚的异常
String[] noRollbackForClassName() default {}; // 同上,字符串形式
}
12 个属性全解:
| 属性 | 默认值 | 作用 | 实战建议 |
|---|---|---|---|
value / transactionManager |
"" |
指定用哪个事务管理器(多数据源必配) | 多数据源时必须显式指定,否则可能用错库 |
propagation |
REQUIRED |
传播行为(7 种) | 90% 场景用默认;记日志/审计用 REQUIRES_NEW |
isolation |
DEFAULT |
隔离级别(5 种) | 一般不动,用数据库默认(MySQL 默认 RR) |
timeout |
-1(不超时) |
事务超时秒数 | 建议显式设置(如 30),防止长事务拖垮连接池 |
readOnly |
false |
只读事务 | 纯查询方法设 true,Spring 会优化(MySQL 路由从库) |
rollbackFor |
{} |
★ 触发回滚的异常类型 | ★ 强烈建议显式写 rollbackFor = Exception.class |
noRollbackFor |
{} |
不触发回滚的异常 | 用于“某些异常不算失败”的场景(如业务异常要提交) |
label |
{} |
事务标签,给监控用 | 少见 |
1.7.2.2 ★★★ rollbackFor 陷阱:默认只回滚 RuntimeException(必考)
这是 @Transactional 最大的坑,也是面试区分度最高的一题。
默认行为:
Spring 默认只在抛出【RuntimeException】或【Error】时回滚事务;
抛出【受检异常(Checked Exception)】时,事务照常提交!
源码依据(DefaultTransactionAttribute):
public boolean rollbackOn(Throwable ex) {
// 只处理 RuntimeException 和 Error
return (ex instanceof RuntimeException || ex instanceof Error);
}
// 即:rollbackFor 为空时,等价于 rollbackFor = {RuntimeException.class, Error.class}
灾难现场演示:
@Service
public class OrderService {
@Autowired
private OrderMapper orderMapper;
@Autowired
private StockMapper stockMapper;
// ❌ 危险:抛出受检异常,事务不会回滚!
@Transactional
public void createOrder(OrderDTO dto) throws Exception {
orderMapper.insert(dto); // ① 插入订单
stockMapper.deduct(dto.getSkuId()); // ② 扣库存
if (stock < 0) {
throw new Exception("库存不足"); // ⚠️ 受检异常 → 事务提交!订单留下,库存扣了
}
}
}
结果: 订单插进去了,库存也扣了,但业务上是失败的 → 数据不一致。
✅ 正确写法(团队必须形成肌肉记忆):
// ✅ 写法一:显式指定 Exception.class(推荐,团队规范)
@Transactional(rollbackFor = Exception.class)
public void createOrder(OrderDTO dto) throws Exception { ... }
// ✅ 写法二:自定义业务异常继承 RuntimeException(更推荐)
public class BizException extends RuntimeException { ... }
@Transactional(rollbackFor = Exception.class) // 双保险
public void createOrder(OrderDTO dto) {
if (stock < 0) throw new BizException("库存不足"); // 继承 RuntimeException,必定回滚
}
★ 面试标准答案(口述版):
“
@Transactional默认只对RuntimeException和Error回滚, 受检异常(Checked Exception)不回滚。这个默认值其实挺反直觉的, 因为 Java 里受检异常反而代表”预期内的、需要处理的错误情况“。Spring 这么设计据说是参照了 EJB 的约定——EJB 默认也是受检异常不回滚。 但实践中这个默认值非常容易踩坑,所以我们团队的编码规范是: 所有
@Transactional必须显式写rollbackFor = Exception.class, 并且这条已经配成了检查规则(ArchUnit 单测 + Code Review 必查项)。另外我们自定义的业务异常
BizException是继承RuntimeException的, 这样即使有人忘了写rollbackFor,业务异常也一定能触发回滚——双重保险。“
异常继承关系图(搞清楚哪些会回滚):
Throwable
├── Error ✅ 默认回滚(如 OutOfMemoryError)
│ └── ...
└── Exception
├── RuntimeException ✅ 默认回滚
│ ├── NullPointerException ✅
│ ├── IllegalArgumentException ✅
│ ├── IllegalStateException ✅
│ └── BizException(自定义) ✅ ← 自定义业务异常应继承这里
│
└── 受检异常(Checked) ❌ 默认【不】回滚!
├── IOException ❌
├── SQLException ❌
├── TimeoutException ❌
└── Exception(直接继承) ❌ ← 最常见的新手写 throws Exception
1.7.2.3 传播行为速查(详细原理见 1.5 节「七种传播行为」)
七种传播行为,这里给出速查表 + 一句话记忆 + 典型场景,详细举例见 1.5 节。
| 传播行为 | 一句话记忆 | 当前有事务 | 当前无事务 | 典型场景 |
|---|---|---|---|---|
REQUIRED(默认) |
有就加入,没有就新建 | 加入 | 新建 | 绝大多数业务方法 |
SUPPORTS |
有就用,没有就算了 | 加入 | 非事务执行 | 查询方法(可有可无) |
MANDATORY |
必须有,没有就报错 | 加入 | 抛异常 | 强制要求被事务方法调用 |
REQUIRES_NEW |
必须开新的,挂起老的 | 挂起旧的,新建 | 新建 | 独立事务:日志/审计/流水 |
NOT_SUPPORTED |
不用事务,挂起老的 | 挂起 | 非事务执行 | 大批量查询、发 MQ 前避免长事务 |
NEVER |
必须没有,有就报错 | 抛异常 | 非事务执行 | 强制非事务(极少用) |
NESTED |
嵌套子事务(保存点) | 设保存点,可部分回滚 | 新建 | 主流程失败但子流程要保留 |
REQUIRED vs REQUIRES_NEW vs NESTED(面试最爱对比):
场景:方法 A 调用方法 B,A 已有事务
【REQUIRED】B 加入 A 的事务
A 异常 → A、B 全部回滚
B 异常 → 异常传到 A,A、B 全部回滚(同一个事务)
【REQUIRES_NEW】B 开新事务,A 的事务被挂起
B 异常 → 只回滚 B,A 捕获异常后【可以】继续提交
A 异常 → 只回滚 A,B【已经独立提交,不回滚】
【NESTED】B 在 A 的事务里设保存点
B 异常 → 回滚到保存点,只回滚 B,A 继续
A 异常 → A、B 全部回滚(B 是 A 的一部分)
★ 典型场景:操作日志必须留痕(REQUIRES_NEW 的经典用法)
@Service
public class InstructionService {
@Transactional(rollbackFor = Exception.class)
public void submitInstruction(Instruction ins) {
try {
instructionMapper.insert(ins); // 主业务
riskCheck(ins); // 风控校验(可能失败)
} catch (Exception e) {
// ★ 主业务失败,但操作日志必须记录下来(合规要求)
logService.saveLog(ins.getId(), "FAILED", e.getMessage());
throw e; // 继续抛出,主事务回滚
}
}
}
@Service
public class LogService {
// ★ REQUIRES_NEW:独立事务,外层回滚不影响它提交
@Transactional(propagation = Propagation.REQUIRES_NEW, rollbackFor = Exception.class)
public void saveLog(Long id, String status, String msg) {
logMapper.insert(...);
}
}
面试加分: “这里必须用
REQUIRES_NEW,因为如果用默认的REQUIRED, 日志会跟着主事务一起回滚,就留不下痕了。金融类系统操作日志是合规要求, 即使业务失败也要记录’谁在什么时候试图做什么、失败了’。”
⚠️ REQUIRES_NEW 的坑:自调用失效
@Service
public class FooService {
@Transactional
public void methodA() {
methodB(); // ❌ 自调用,REQUIRES_NEW 失效!不会开新事务
}
@Transactional(propagation = Propagation.REQUIRES_NEW)
public void methodB() { }
}
原因:自调用不走代理,注解不生效。解法见 1.5 节(注入自己 / AopContext / 拆类)。
1.7.2.4 隔离级别(速查,详细见 1.5 节「事务隔离级别详解」)
public enum Isolation {
DEFAULT(-1), // 用数据库默认(MySQL = REPEATABLE_READ)
READ_UNCOMMITTED(1), // 读未提交 → 脏读、不可重复读、幻读都有
READ_COMMITTED(2), // 读已提交 → 解决脏读(Oracle/SQL Server 默认)
REPEATABLE_READ(4), // 可重复读 → 解决脏读、不可重复读(MySQL 默认)
SERIALIZABLE(8); // 串行化 → 全解决,性能最差
}
| 隔离级别 | 脏读 | 不可重复读 | 幻读 | 性能 | 默认数据库 |
|---|---|---|---|---|---|
| READ_UNCOMMITTED | ❌ 有 | ❌ 有 | ❌ 有 | 最高 | — |
| READ_COMMITTED | ✅ 无 | ❌ 有 | ❌ 有 | 高 | Oracle、PG、SQL Server |
| REPEATABLE_READ | ✅ 无 | ✅ 无 | ⚠️ InnoDB 靠 MVCC+间隙锁基本解决 | 中 | MySQL |
| SERIALIZABLE | ✅ 无 | ✅ 无 | ✅ 无 | 最低 | — |
实战建议:
“隔离级别我基本不显式配置,用数据库的默认(MySQL 的 RR)。 因为这个配置和数据库强相关,写死在代码里反而降低可移植性。 真要改也是在数据源层面统一配置。面试时我更关注的是 高并发下用乐观锁/悲观锁/Redis 分布式锁来解决超卖,而不是调隔离级别。”
1.7.2.5 timeout、readOnly 与事务管理器
timeout:防止长事务拖垮连接池
// 超过 30 秒强制回滚(默认 -1 表示永不超时,★ 生产环境危险)
@Transactional(timeout = 30, rollbackFor = Exception.class)
public void batchProcess() { ... }
⚠️ 注意:
timeout的计时是从事务开始算的,包括其中所有 SQL 执行时间。 超时后抛TransactionTimedOutException,事务回滚。 生产建议:所有事务都显式设置 timeout,否则一个慢 SQL 可能长时间占用连接。
readOnly:只读事务优化
@Transactional(readOnly = true) // ★ 纯查询方法标注
public List<Instruction> queryList(Long accountId) {
return instructionMapper.selectByAccount(accountId);
}
作用:
- Spring 会设置 JDBC Connection 为只读,数据库可做优化
- 配合读写分离(如 ShardingSphere、动态数据源)会自动路由到从库 ← 最大价值
- Hibernate 下会关闭 flush,避免脏检查
⚠️ 坑:
readOnly=true的方法里如果有写操作,MySQL 会报错Connection is read-only。所以只读方法里绝不能有 INSERT/UPDATE。
transactionManager:多数据源必配(你项目一多数据源场景)
@Configuration
public class DataSourceConfig {
@Bean
public PlatformTransactionManager masterTxManager(@Qualifier("masterDs") DataSource ds) {
return new DataSourceTransactionManager(ds);
}
@Bean
public PlatformTransactionManager slaveTxManager(@Qualifier("slaveDs") DataSource ds) {
return new DataSourceTransactionManager(ds);
}
}
@Service
public class InstructionService {
// ★ 多数据源时必须显式指定,否则用默认的(按 @Primary 或名字匹配)
@Transactional(transactionManager = "masterTxManager", rollbackFor = Exception.class)
public void create(Instruction ins) { ... }
}
⚠️ 多数据源事务的真相: 单个
@Transactional只能管一个数据源。 跨库一致性必须上分布式事务(Seata / 消息最终一致),见10-数据一致性文档。
1.7.2.6 @EnableTransactionManagement
@SpringBootApplication
@EnableTransactionManagement // ★ Spring Boot 中其实可以省略(自动配置已开启)
public class Application { }
// 完整属性
@EnableTransactionManagement(
proxyTargetClass = true, // true=CGLIB 代理(默认);false=JDK 动态代理
mode = AdviceMode.PROXY, // PROXY=代理模式(默认);ASPECTJ=编译期织入
order = Ordered.LOWEST_PRECEDENCE // 拦截器执行顺序
)
| 属性 | 说明 | 建议 |
|---|---|---|
proxyTargetClass |
true = CGLIB(可代理无接口的类);false = JDK 动态代理(必须有接口) |
Spring Boot 默认 true,不要改 |
mode |
PROXY(运行期代理)/ ASPECTJ(编译期织入,可解决自调用) |
默认 PROXY 即可 |
order |
多个 AOP 切面的执行顺序 | 默认最低优先级,事务在最后执行 |
面试点: “Spring Boot 里
@EnableTransactionManagement是可以省略的, 因为TransactionAutoConfiguration已经自动开启了。但显式写上更清晰, 也让读代码的人知道这个项目用了注解事务。”
1.7.2.7 ★ @Transactional 该加在哪一层(争议题)
问题: 事务注解加在 Controller / Service / Mapper?
| 位置 | 评价 | 说明 |
|---|---|---|
| Controller | ❌ 不推荐 | 事务范围过大(含参数校验、视图渲染);且 Controller 通常不处理业务逻辑 |
| Service | ✅ 推荐 | 业务方法通常对应一个完整的业务操作,事务边界与业务边界一致 |
| Mapper | ❌ 不推荐 | 粒度太细,一个业务操作涉及多次 DB 操作,无法保证整体原子性 |
★ 面试标准答案:
“加在 Service 层,而且通常在 Service 的方法上而不是类上。
原因是事务的边界应该和业务操作的边界一致:Controller 负责接收请求和参数校验, 不该承担事务;Mapper 是单表操作,粒度太细——一个下单操作要插订单、扣库存、 写流水,如果事务加在 Mapper 上就变成三个独立事务,无法保证原子性。
另外我倾向于加在方法上而不是类上:类级别会让所有方法(包括纯查询方法) 都开事务,白白消耗连接池资源。纯查询方法我会单独标注
@Transactional(readOnly = true),配合读写分离可以路由到从库。“
进阶:事务方法里不要做的事(长事务优化)
// ❌ 错误示范:远程调用、发 MQ、大循环都在事务里
@Transactional(rollbackFor = Exception.class)
public void submitInstruction(Instruction ins) {
instructionMapper.insert(ins);
riskClient.check(ins); // ❌ 远程调用 500ms,事务不释放连接
mqProducer.send(msg); // ❌ 发消息,可能超时
for (int i = 0; i < 10000; i++) {
detailMapper.insert(...); // ❌ 大批量插入,事务巨大
}
fileService.upload(file); // ❌ 文件上传,几秒
}
// ✅ 正确示范:事务只包住 DB 操作,耗时操作外提
public void submitInstruction(Instruction ins) {
// ① 事务外:先做远程校验(不占用 DB 连接)
RiskResult risk = riskClient.check(ins);
if (!risk.passed()) throw new BizException("风控不通过");
// ② 事务内:只做核心 DB 操作
doSaveInTx(ins);
// ③ 事务外:发消息、上传文件(异步化更好)
mqProducer.send(buildMsg(ins));
}
@Transactional(rollbackFor = Exception.class)
public void doSaveInTx(Instruction ins) {
instructionMapper.insert(ins);
positionMapper.update(...);
}
⚠️ 自调用警告: 上面
submitInstruction调用doSaveInTx是自调用, 事务不会生效!必须注入自身代理或用 AopContext,或者把doSaveInTx拆分到另一个 Service。 详细见 1.5 节的十大失效场景。
1.7.2.8 事务注解失效速查(10 种,详见 1.5 节)
| # | 失效场景 | 一句话原因 |
|---|---|---|
| 1 | 方法不是 public |
JDK/CGLIB 代理限制,private/protected 不生效 |
| 2 | 方法被 final / static 修饰 |
无法被重写,CGLIB 代理失效 |
| 3 | 同一个类内自调用 | 不走代理对象,注解被忽略 |
| 4 | 异常被 catch 吞掉 |
事务拦截器没感知到异常,正常提交 |
| 5 | 抛出受检异常 | 默认不回滚(见 1.7.2.2) |
| 6 | 类没被 Spring 管理(没 @Service) |
根本没进容器 |
| 7 | 数据库引擎不支持(MyISAM) | 底层不支持事务 |
| 8 | 传播行为配错(如 NOT_SUPPORTED) |
主动挂起了事务 |
| 9 | 多线程调用 | 事务上下文绑定在 ThreadLocal,跨线程丢失 |
| 10 | 用了错误的代理方式 | 如强制 JDK 代理但类无接口 |
详细剖析和排查手法见 1.5 节和专题四(含 Arthas 排查实战)。
1.7.2.9 事务注解面试题(16 题)
| # | 题目 | 难度 | 要点 |
|---|---|---|---|
| 1 | @Transactional 默认回滚哪些异常?受检异常回滚吗? |
⭐⭐⭐⭐⭐ | 只回滚 RuntimeException + Error;受检异常不回滚 |
| 2 | 为什么默认不回滚受检异常?怎么解决? | ⭐⭐⭐⭐ | 参照 EJB 约定;显式 rollbackFor = Exception.class |
| 3 | @Transactional 有哪些属性?常用哪几个? |
⭐⭐⭐⭐ | propagation / isolation / timeout / readOnly / rollbackFor / transactionManager |
| 4 | REQUIRED 和 REQUIRES_NEW 的区别? |
⭐⭐⭐⭐⭐ | 加入 vs 新建+挂起;外层回滚时内层是否回滚 |
| 5 | NESTED 和 REQUIRES_NEW 的区别? |
⭐⭐⭐⭐ | 保存点 vs 独立事务;外层回滚时 NESTED 一起回滚 |
| 6 | 什么时候用 REQUIRES_NEW? |
⭐⭐⭐⭐ | 操作日志、审计流水——失败也要留痕 |
| 7 | readOnly=true 有什么作用? |
⭐⭐⭐ | 只读优化 + 读写分离路由到从库 |
| 8 | timeout 默认是多少?生产要注意什么? |
⭐⭐⭐⭐ | 默认 -1(永不超时);必须显式设置 |
| 9 | 事务注解加在哪一层?为什么? | ⭐⭐⭐⭐ | Service 层方法上;事务边界 = 业务边界 |
| 10 | 事务方法里能不能调用远程接口? | ⭐⭐⭐⭐ | 能但不应该,会导致长事务占连接;应外提 |
| 11 | 类上加 @Transactional 和方法上加有什么区别? |
⭐⭐⭐ | 类上:所有 public 方法都加事务;方法上优先级更高 |
| 12 | 多数据源下事务注解要注意什么? | ⭐⭐⭐⭐ | 必须指定 transactionManager;单事务只能管一个库 |
| 13 | @EnableTransactionManagement 能省略吗? |
⭐⭐⭐ | Spring Boot 里能(自动配置) |
| 14 | proxyTargetClass 是什么?默认多少? |
⭐⭐⭐ | CGLIB vs JDK 代理;Boot 默认 true |
| 15 | noRollbackFor 用在什么场景? |
⭐⭐⭐ | 某些业务异常不算失败,仍要提交 |
| 16 | 事务注解在 private 方法上生效吗?为什么? | ⭐⭐⭐⭐ | 不生效,代理机制限制(CGLIB 无法重写 private) |
1.7.3 Bean 声明与生命周期注解
1.7.3.1 @Component 四兄弟:只是“语义不同”吗?
很多人以为 @Service / @Repository / @Controller 只是给 @Component 换了个名字,
“功能完全一样,只是为了可读性”。这个说法只对一半。
// 四者源码对比
@Target(ElementType.TYPE)
@Retention(RetentionPolicy.RUNTIME)
@Component // ★ 都被 @Component 标注,所以都能被扫描
public @interface Service { } // 没有额外属性
@Component
public @interface Repository { } // 没有额外属性
@Component
public @interface Controller { } // 没有额外属性
@Controller
@ResponseBody // ★ 多了 @ResponseBody
public @interface RestController { }
共同点: 都能被 @ComponentScan 扫描,都会注册为 Spring Bean。
真正的区别(面试要答出来):
| 注解 | 额外能力 | 说明 |
|---|---|---|
@Component |
无 | 通用组件,不属于任何分层时用 |
@Service |
目前无(语义层) | 标注业务逻辑层;Spring 官方说“未来可能加语义” |
@Repository |
✅ 异常转译 | 捕获数据访问异常,转成 Spring 统一的 DataAccessException |
@Controller |
✅ MVC 映射 | 被 RequestMappingHandlerMapping 识别,处理 HTTP 请求 |
@RestController |
✅ @Controller + @ResponseBody |
返回值直接写 HTTP body(JSON) |
★ @Repository 的异常转译(大部分人不知道):
// 没有 @Repository:抛的是 MyBatis/JDBC 原生异常
public class UserDao {
public void insert(User u) {
throw new org.apache.ibatis.exceptions.PersistenceException(...); // 具体DB异常
}
}
// 有 @Repository:异常被转译为 Spring 统一异常体系
@Repository
public class UserDao {
public void insert(User u) {
throw new org.springframework.dao.DataIntegrityViolationException(...); // Spring 统一异常
}
}
原理: PersistenceExceptionTranslationPostProcessor 这个后置处理器
会给所有 @Repository 标注的类织入一个切面,捕获原生异常并转译。
面试价值: 能讲出“@Repository 有异常转译能力,其他三个没有“, 说明你看过源码,不是背八股。
⚠️ 补充: 用 MyBatis 时,Mapper 接口是通过
MapperScannerConfigurer注册的, 即使不加@Repository也有异常转译(MyBatis-Spring 自己处理了)。 但 JPA 的 Dao 实现类必须加。
1.7.3.2 @Bean vs @Component(高频对比题)
| 维度 | @Component |
@Bean |
|---|---|---|
| 标注位置 | 类上 | 方法上(方法必须在一个配置类里) |
| 创建对象 | Spring 通过反射调用构造器 | 你自己写创建逻辑(方法体) |
| 适用场景 | 自己写的类 | 第三方库的类(你没法改源码加注解) |
| 灵活性 | 低(只能 new) | 高(可以设属性、调初始化方法、条件判断) |
| 典型案例 | @Service class OrderService |
RedisTemplate、DataSource、线程池 |
代码对比:
// @Component:改不了创建逻辑,Spring 直接 new
@Component
public class OrderService {
@Autowired
private OrderMapper mapper;
}
// @Bean:完全掌控创建过程 ★ 第三方组件的唯一选择
@Configuration
public class RedisConfig {
@Bean
public RedisTemplate<String, Object> redisTemplate(RedisConnectionFactory factory) {
RedisTemplate<String, Object> template = new RedisTemplate<>();
template.setConnectionFactory(factory);
// ★ 自定义序列化器(这个 @Component 做不到)
Jackson2JsonRedisSerializer<Object> serializer =
new Jackson2JsonRedisSerializer<>(Object.class);
template.setKeySerializer(new StringRedisSerializer());
template.setValueSerializer(serializer);
template.setHashKeySerializer(new StringRedisSerializer());
template.afterPropertiesSet();
return template;
}
@Bean
public ThreadPoolExecutor bizExecutor() {
// ★ 可以传自定义参数、用自定义拒绝策略
return new ThreadPoolExecutor(
8, 16, 60L, TimeUnit.SECONDS,
new LinkedBlockingQueue<>(2000),
new ThreadFactoryBuilder().setNameFormat("biz-pool-%d").build(),
new ThreadPoolExecutor.CallerRunsPolicy()
);
}
}
★ 面试标准答案:
“核心区别是控制权的粒度:
@Component是”我告诉 Spring 这个类归你管“, Spring 用反射 new 一个出来,创建过程我不干预;@Bean是“我创建好对象交给你管”, 创建逻辑完全由我写。所以实际项目里:自己写的业务类用
@Component及其衍生注解, 第三方的组件(DataSource、RedisTemplate、线程池、RocketMQ Producer) 只能用@Bean——因为那些类的源码我改不了,没法在人家类上加@Component。另一个区别是
@Bean可以做条件化创建和复杂初始化,比如根据配置决定 用 Redis 还是本地缓存,这种逻辑用@Component写不出来。“
1.7.3.3 @Scope:Bean 的作用域
@Target({ElementType.TYPE, ElementType.METHOD})
@Retention(RetentionPolicy.RUNTIME)
public @interface Scope {
@AliasFor("value")
String scopeName() default "";
String value() default ""; // 作用域名称
ScopedProxyMode proxyMode() default ScopedProxyMode.DEFAULT; // 代理模式
}
五种作用域(Spring 标准):
| 作用域 | 说明 | 创建时机 | 适用 |
|---|---|---|---|
singleton(默认) |
整个容器一个实例 | 容器启动时(非懒加载) | 无状态组件:Service、Dao |
prototype |
每次获取都新建 | 每次 getBean() |
有状态对象:上下文、Builder |
request |
每个 HTTP 请求一个 | 请求开始 | Web 请求上下文 |
session |
每个会话一个 | 会话建立 | 用户登录信息 |
application |
每个 ServletContext 一个 | 应用启动 | 全局配置 |
@Component
@Scope("prototype") // 或 @Scope(ConfigurableBeanFactory.SCOPE_PROTOTYPE)
public class TaskContext {
private String taskId;
private Map<String, Object> attributes = new HashMap<>(); // 有状态
}
@Component
@Scope(value = "session", proxyMode = ScopedProxyMode.TARGET_CLASS) // ★ Web 作用域常配代理
public class UserSession {
private Long userId;
}
★ 关键坑 1:singleton 里注入 prototype(失效问题)
已在 1.7.1.9 坑 7 讲过,这里补充原理:
@Component
@Scope("prototype")
public class TaskContext { }
@Service
public class TaskService {
@Autowired
private TaskContext ctx; // ❌ 只注入一次,永远是同一个实例
}
原因:singleton Bean 只在创建时注入一次依赖,之后不再重新注入。
解法:ObjectProvider / @Lookup / proxyMode。
// 解法:ScopedProxyMode.TARGET_CLASS —— 注入代理对象,每次调用都取新的
@Component
@Scope(value = "prototype", proxyMode = ScopedProxyMode.TARGET_CLASS)
public class TaskContext { }
@Service
public class TaskService {
@Autowired
private TaskContext ctx; // ✅ 注入的是代理,每次方法调用都路由到新实例
}
★ 关键坑 2:singleton 的线程安全问题
@Component // 默认 singleton:全局一个实例
public class CounterService {
private int count = 0; // ❌ 有状态字段!多线程共享,线程不安全
public void increment() {
count++; // ❌ 非原子操作,并发下数据错乱
}
}
面试必答: “Spring 的 singleton Bean 是无状态设计的前提下的—— 即 Bean 里不能有可变的成员变量(或者说只能有配置类、其他 Bean 这类只读依赖)。 如果确实要存状态,用
ThreadLocal、方法局部变量,或者改成prototype。”这个题几乎必问,因为很多候选人写了几年 Spring 都没想过“我的 Service 是单例, 我在里面定义了个 List 成员变量会怎样”。
1.7.3.4 @Lazy:延迟初始化
@Component
@Lazy // 容器启动时不创建,第一次使用时才创建
public class HeavyService {
public HeavyService() {
// 假设初始化很耗时(加载大字典、建连接)
}
}
@Service
public class OrderService {
@Autowired
@Lazy // ★ 注入时注入代理对象,真正调用时才初始化
private HeavyService heavyService;
}
三个用途:
| 用途 | 说明 |
|---|---|
| 1. 加快启动速度 | 不常用的重型组件延迟加载 |
| 2. 解决循环依赖 | 见 1.7.1.8(注入代理,打破死锁) |
| 3. 延迟创建昂贵资源 | 连接池、大缓存 |
⚠️ 注意:
@Lazy解决循环依赖是“治标不治本”,本质是延迟了问题。 正确的做法还是重构代码,去掉循环依赖。
1.7.3.5 @PostConstruct / @PreDestroy:生命周期回调
package jakarta.annotation; // JDK 标准注解(JSR-250)
@PostConstruct // 在【构造器 → 依赖注入完成】之后执行
@PreDestroy // 在 Bean 销毁之前执行(容器关闭时)
执行顺序(★ 面试画图题):
Bean 的完整生命周期
─────────────────────────────────────────────────────────
① 实例化 new XxxService() (调用构造器)
↓
② 属性填充 @Autowired / @Resource 注入依赖
↓
③ Aware 回调 BeanNameAware / BeanFactoryAware / ApplicationContextAware
↓
④ 前置处理 BeanPostProcessor.postProcessBeforeInitialization()
↓
⑤ 初始化 @PostConstruct ★ 这里
↓ InitializingBean.afterPropertiesSet()
↓ 自定义 init-method(@Bean(initMethod="..."))
↓
⑥ 后置处理 BeanPostProcessor.postProcessAfterInitialization()
↓ ★ AOP 代理在这一步生成!
↓
⑦ Bean 就绪 放入单例池,可被使用
↓
⑧ 销毁 @PreDestroy ★ 这里
DisposableBean.destroy()
自定义 destroy-method
代码示例:
@Component
public class DictCache {
@Autowired
private DictMapper dictMapper;
private Map<String, String> cache;
@PostConstruct // ★ 依赖注入完成后,加载字典到缓存
public void init() {
log.info("开始加载数据字典...");
List<Dict> dicts = dictMapper.selectAll();
this.cache = dicts.stream()
.collect(Collectors.toMap(Dict::getCode, Dict::getName));
log.info("数据字典加载完成,共 {} 条", cache.size());
}
@PreDestroy // ★ 容器关闭时清理资源
public void destroy() {
log.info("清理字典缓存");
cache.clear();
}
}
三种初始化方式对比:
| 方式 | 出处 | 耦合 | 执行顺序 |
|---|---|---|---|
@PostConstruct |
JSR-250 标准 | 低(注解) | 最先 |
InitializingBean.afterPropertiesSet() |
Spring 接口 | 高(实现 Spring 接口) | 次之 |
@Bean(initMethod = "init") |
配置指定 | 最低(外部配置) | 最后 |
推荐: 用
@PostConstruct,因为它是 JDK 标准,对 Spring 无侵入; 而且写在方法上,一眼能看出“这是初始化方法”。
@DependsOn:强制初始化顺序
@Component
@DependsOn("dictCache") // ★ 保证 dictCache 先初始化
public class BizService {
@Autowired
private DictCache dictCache; // 此时 dictCache 已完成初始化
}
@Order:控制执行顺序
@Component
@Order(1) // 数字越小优先级越高
public class FirstFilter implements Filter { }
@Component
@Order(2)
public class SecondFilter implements Filter { }
// 典型场景:多个 Handler 组成责任链
public interface InstructionHandler {
boolean handle(Instruction ins);
}
@Component @Order(1)
class ValidateHandler implements InstructionHandler { } // 先校验
@Component @Order(2)
class RiskCheckHandler implements InstructionHandler { } // 再风控
@Component @Order(3)
class PersistHandler implements InstructionHandler { } // 最后落库
// 注入 List 时会按 @Order 排序
@Autowired
private List<InstructionHandler> handlers; // [Validate, RiskCheck, Persist]
⚠️ 注意:
@Order只影响同一类型 Bean 在集合中的排序和部分 AOP 切面顺序, 不会影响 Bean 的创建顺序(创建顺序用@DependsOn)。这是常见误解。
1.7.3.6 Bean 与生命周期面试题(14 题)
| # | 题目 | 难度 | 要点 |
|---|---|---|---|
| 1 | @Component、@Service、@Repository、@Controller 的区别? |
⭐⭐⭐⭐ | 都被 @Component 标注;@Repository 有异常转译;@Controller 处理请求 |
| 2 | @Repository 有什么特殊能力? |
⭐⭐⭐⭐ | 异常转译为 DataAccessException(PersistenceExceptionTranslationPostProcessor) |
| 3 | @Bean 和 @Component 的区别? |
⭐⭐⭐⭐⭐ | 类上 vs 方法上;第三方类只能用 @Bean;@Bean 可自定义创建逻辑 |
| 4 | Spring Bean 的作用域有哪些?默认是哪个? | ⭐⭐⭐⭐ | singleton(默认)/prototype/request/session/application |
| 5 | singleton Bean 线程安全吗? | ⭐⭐⭐⭐⭐ | 取决于是否有状态;无状态才安全;有状态用 ThreadLocal/prototype |
| 6 | singleton 里注入 prototype 会怎样? | ⭐⭐⭐⭐ | prototype 失效,只注入一次;用 ObjectProvider/@Lookup/proxyMode |
| 7 | Bean 的生命周期是怎样的? | ⭐⭐⭐⭐ | 实例化→属性填充→Aware→@PostConstruct→初始化→AOP代理→就绪→@PreDestroy |
| 8 | @PostConstruct 在什么时候执行? |
⭐⭐⭐⭐ | 构造器执行 + 依赖注入完成之后 |
| 9 | 三种初始化方式的区别和执行顺序? | ⭐⭐⭐ | @PostConstruct > InitializingBean > initMethod |
| 10 | @Lazy 有什么用? |
⭐⭐⭐ | 加快启动、解决循环依赖、延迟创建昂贵资源 |
| 11 | @Order 控制的是什么顺序? |
⭐⭐⭐ | 同类 Bean 在集合中的排序;不控制创建顺序(那是 @DependsOn) |
| 12 | 怎么保证 Bean A 在 Bean B 之前初始化? | ⭐⭐⭐ | @DependsOn |
| 13 | Bean 销毁时怎么释放资源? | ⭐⭐⭐ | @PreDestroy / DisposableBean / @Bean(destroyMethod) |
| 14 | AOP 代理在生命周期的哪一步生成? | ⭐⭐⭐⭐ | postProcessAfterInitialization(初始化之后) |
1.7.4 配置绑定注解
1.7.4.1 @Configuration 与 proxyBeanMethods(★ 高频坑)
@Target(ElementType.TYPE)
@Retention(RetentionPolicy.RUNTIME)
@Component // ★ @Configuration 本身也是 @Component
public @interface Configuration {
@AliasFor(annotation = Component.class)
String value() default "";
boolean proxyBeanMethods() default true; // ★★★ 关键属性
}
proxyBeanMethods 的两种模式:
| 模式 | proxyBeanMethods |
行为 | 启动速度 |
|---|---|---|---|
| Full 模式(默认) | true |
配置类被 CGLIB 代理,@Bean 方法调用返回容器单例 |
慢(要生成代理) |
| Lite 模式 | false |
不代理,@Bean 方法调用就是普通方法调用,new 新对象 |
快 |
代码演示(★ 面试必考):
@Configuration // 默认 proxyBeanMethods = true(Full 模式)
public class AppConfig {
@Bean
public A a() {
return new A(b()); // ★ 调用 b(),返回的是【容器中的单例 B】
}
@Bean
public B b() {
return new B();
}
}
// 测试
A a = context.getBean(A.class);
B b = context.getBean(B.class);
System.out.println(a.getB() == b); // ✅ true —— 同一个对象(CGLIB 拦截了 b() 调用)
@Configuration(proxyBeanMethods = false) // ★ Lite 模式
public class AppConfig {
@Bean
public A a() {
return new A(b()); // ⚠️ 普通方法调用,每次 new 一个新 B
}
@Bean
public B b() {
return new B();
}
}
// 测试
A a = context.getBean(A.class);
B b = context.getBean(B.class);
System.out.println(a.getB() == b); // ❌ false —— 两个不同的 B 对象!
★ 面试标准答案:
“
@Configuration默认是 Full 模式(proxyBeanMethods=true), Spring 会用 CGLIB 生成代理子类,拦截@Bean方法的调用。 这样在一个@Bean方法里调用另一个@Bean方法时, 会先从容器里查有没有,有就返回容器里的单例,保证 Bean 的单例性。如果设成
false(Lite 模式),就不生成代理,方法调用变成普通 Java 调用, 每次都 new 一个新对象,可能破坏单例。什么时候用 Lite 模式? 当
@Bean方法之间没有互相调用依赖时, 设成false可以跳过 CGLIB 代理,提升启动速度。 Spring Boot 2.2 之后,自动配置类基本都改成了proxyBeanMethods = false, 就是因为这个优化——自动配置类里的@Bean通常互相独立。我们项目的实践: 自己写的配置类保持默认(true), 除非确认没有方法间调用且对启动速度有极致要求。“
⚠️ Lite 模式下如何正确注入依赖(推荐写法):
@Configuration(proxyBeanMethods = false)
public class AppConfig {
// ✅ 推荐:用方法参数注入,而不是调用方法(Lite 模式下更清晰、更快)
@Bean
public A a(B b) { // ★ Spring 会自动从容器找 B 注入
return new A(b);
}
@Bean
public B b() {
return new B();
}
}
加分点: “用方法参数注入比调用
@Bean方法更好, 一是 Lite 模式下也能保证拿到容器里的 Bean, 二是依赖关系更清晰(看方法签名就知道依赖什么), 三是启动更快(不需要 CGLIB 代理)。”
1.7.4.2 @ComponentScan:扫描范围控制
@Configuration
@ComponentScan(
basePackages = "com.company.custody", // 扫描根包
excludeFilters = {
@ComponentScan.Filter(
type = FilterType.REGEX,
pattern = "com.company.custody.exclude.*"
)
},
includeFilters = { ... },
useDefaultFilters = true // 是否扫描 @Component/@Service/... 默认 true
)
public class AppConfig { }
Spring Boot 的默认行为:
@SpringBootApplication // 内含 @ComponentScan
public class Application { }
// 默认扫描【启动类所在包及其子包】
⚠️ 经典坑: 启动类放在
com.company.app, 但有个组件放在com.company.util,扫描不到! 必须显式加@ComponentScan(basePackages = "com.company")或调整包结构。
1.7.4.3 @Value:单值注入
@Component
public class SmsConfig {
// ① 基本用法:${} 从配置文件取值
@Value("${sms.access-key}")
private String accessKey;
// ② 带默认值:配置不存在时用默认值(冒号后)
@Value("${sms.timeout:3000}")
private Integer timeout;
// ③ 注入 SpEL 表达式:#{} ★ 注意和 ${} 的区别
@Value("#{T(java.lang.Math).random() * 100}")
private double randomValue;
// ④ SpEL 引用其他 Bean 的属性
@Value("#{redisConfig.host}:#{redisConfig.port}")
private String redisAddress;
// ⑤ 注入系统属性、环境变量
@Value("${java.home}")
private String javaHome;
@Value("${JAVA_HOME:}") // 环境变量
private String envJavaHome;
// ⑥ 注入数组/List(逗号分隔)
@Value("${sms.white-list:13800138000,13900139000}")
private List<String> whiteList;
// ⑦ 注入 Map(需要 SpEL)
@Value("#{${sms.retry-counts:{1:3,2:5}}}")
private Map<String, Integer> retryCounts;
}
${} vs #{} (★ 必考区别):
${...} |
#{...} |
|
|---|---|---|
| 名称 | 属性占位符 | SpEL 表达式 |
| 作用 | 从配置文件(application.yml、环境变量)取值 |
执行表达式(运算、调方法、引用 Bean) |
| 来源 | Environment |
Spring Expression Language |
| 示例 | ${server.port:8080} |
#{systemProperties['user.name']} |
| 默认值 | ${key:defaultValue} |
#{expr ?: 'default'} |
| 能否嵌套 | 可以:#{'${key}'} |
可以:#{${key}} |
// 组合用法
@Value("#{${app.thresholds:{low:10, high:100}}}") // 先 ${} 取串,再 #{} 解析成 Map
private Map<String, Integer> thresholds;
⚠️ @Value 的坑:
| 坑 | 说明 | 解法 |
|---|---|---|
| 静态字段注入不了 | 同 @Autowired |
用 @PostConstruct 赋值 |
| 配置不存在且无默认值 | 启动失败 Could not resolve placeholder |
加默认值 :xxx |
| List 注入需要逗号分隔 | @Value("${list}") 配置要写 a,b,c |
或用 @ConfigurationProperties |
| 松散绑定不支持 | @Value 不支持 user-name ↔ userName |
用 @ConfigurationProperties |
| 无类型安全校验 | 配错了运行时才报错 | 用 @ConfigurationProperties + 校验 |
1.7.4.4 @ConfigurationProperties:批量绑定(推荐)
@Component
@ConfigurationProperties(prefix = "sms.aliyun") // ★ 前缀
@Validated // ★ 支持 JSR-303 校验
@Data // Lombok:需要 setter
public class SmsProperties {
@NotBlank(message = "accessKey 不能为空")
private String accessKey;
@NotBlank
private String secretKey;
@NotNull
@Min(1000)
private Integer timeout = 3000; // 默认值
private List<String> whiteList = new ArrayList<>();
private Retry retry = new Retry(); // 嵌套对象
@Data
public static class Retry {
private Integer maxAttempts = 3;
private Long backoffMs = 1000L;
}
}
# application.yml
sms:
aliyun:
access-key: LTAI5txxxxxxxx # ★ 松散绑定:access-key ↔ accessKey
secret-key: xxxxxxxxxxxx
timeout: 5000
white-list: # 支持 List
- 13800138000
- 13900139000
retry: # 嵌套对象
max-attempts: 5
backoff-ms: 2000
启用方式(三种):
// 方式一:配置类上加 @ConfigurationProperties + @Component
@Component
@ConfigurationProperties(prefix = "sms.aliyun")
public class SmsProperties { }
// 方式二:在配置类上用 @EnableConfigurationProperties(推荐,更清晰)
@Configuration
@EnableConfigurationProperties(SmsProperties.class)
public class SmsConfig { }
@ConfigurationProperties(prefix = "sms.aliyun") // 不需要 @Component
public class SmsProperties { }
// 方式三:@Bean 方式(适合第三方类)
@Configuration
public class SmsConfig {
@Bean
@ConfigurationProperties(prefix = "sms.aliyun")
public SmsProperties smsProperties() {
return new SmsProperties();
}
}
★ @Value vs @ConfigurationProperties 对比(面试必考):
| 维度 | @Value |
@ConfigurationProperties |
|---|---|---|
| 注入粒度 | 单个值,逐个字段加 | 批量,一次绑定一组 |
| 松散绑定 | ❌ 不支持 | ✅ 支持(user-name / userName / USER_NAME) |
| 类型安全 | ❌ 无,运行时才发现 | ✅ 有,支持嵌套对象、List、Map、枚举 |
| 校验 | ❌ 无 | ✅ 支持 @Validated + JSR-303 |
| 复杂类型 | ❌ List/Map 要手写解析 | ✅ 原生支持 |
| 元数据/IDE 提示 | ❌ 无 | ✅ 配 spring-configuration-metadata 有提示 |
| 适用场景 | 偶尔取一两个值 | 一组相关配置(推荐) |
面试标准答案: “我优先用
@ConfigurationProperties,只在取一两个零散值时用@Value。 主要原因是@ConfigurationProperties支持松散绑定、类型安全、JSR-303 校验, 配置写错了启动就报错,而不是运行到那行代码才炸。 另外它支持嵌套对象和集合,配置类结构化之后可读性也好很多。我们项目里每个中间件(Redis、RocketMQ、OSS、短信)都有一组配置, 全部用
@ConfigurationProperties封装成 XxxProperties 类, 这样配置集中、可校验、可复用。“
1.7.4.5 @PropertySource、@Profile、@Import
@PropertySource:加载指定的 properties 文件
@Configuration
@PropertySource(value = "classpath:custom.properties", encoding = "UTF-8")
public class CustomConfig { }
// 多个文件 + 允许不存在
@PropertySource(value = {
"classpath:default.properties",
"classpath:override.properties"
}, ignoreResourceNotFound = true) // 文件不存在也不报错
public class MultiConfig { }
// ★ 注意:默认只支持 .properties,不支持 .yml
// 要支持 yml 需要自定义 PropertySourceFactory(少见)
@Profile:环境隔离
@Configuration
public class DataSourceConfig {
@Bean
@Profile("dev") // 只在 dev 环境生效
public DataSource devDataSource() {
return new HikariDataSource(devConfig());
}
@Bean
@Profile("prod") // 只在 prod 生效
public DataSource prodDataSource() {
return new HikariDataSource(prodConfig());
}
@Bean
@Profile("!prod") // ★ 非 prod 环境生效(取反)
public MockService mockService() {
return new MockService(); // 开发环境用 Mock 替代真实调用
}
}
# 激活环境
spring:
profiles:
active: dev
典型用法:开发环境 Mock 掉第三方调用(你项目里可以用)
// 真实实现(生产)
@Service
@Profile("prod")
public class RealBankAdapter implements BankAdapter { ... }
// Mock 实现(开发/测试,避免每次都调真实托管行接口)
@Service
@Profile({"dev", "test"})
public class MockBankAdapter implements BankAdapter { ... }
@Import:导入配置
@Configuration
@Import({RedisConfig.class, MqConfig.class}) // ① 导入其他配置类
public class AppConfig { }
@Configuration
@Import(MyImportSelector.class) // ② 实现 ImportSelector,动态决定导入哪些
public class AppConfig { }
@Configuration
@Import(MyImportBeanDefinitionRegistrar.class) // ③ 手动注册 BeanDefinition(MyBatis 的 Mapper 扫描就用这个)
public class AppConfig { }
面试加分: “
@Import的第三种用法ImportBeanDefinitionRegistrar是很多框架的基础——比如 MyBatis 的@MapperScan就是通过它动态注册 Mapper 接口的 BeanDefinition,因为这些接口没有实现类,没法用普通方式注册。”
1.7.4.6 配置注解面试题(12 题)
| # | 题目 | 难度 | 要点 |
|---|---|---|---|
| 1 | @Configuration 的 proxyBeanMethods 是什么? |
⭐⭐⭐⭐⭐ | true=Full模式(CGLIB代理,@Bean 方法调用返回单例);false=Lite模式(不代理,每次new) |
| 2 | Full 模式和 Lite 模式怎么选? | ⭐⭐⭐⭐ | 有 @Bean 方法间调用用 true;互相独立用 false(启动更快) |
| 3 | @Configuration 和 @Component 的区别? |
⭐⭐⭐⭐ | 前者是后者的派生;@Configuration 默认被 CGLIB 代理 |
| 4 | @Value 和 @ConfigurationProperties 的区别? |
⭐⭐⭐⭐⭐ | 单值 vs 批量;松散绑定、类型安全、JSR303 校验 |
| 5 | ${} 和 #{} 的区别? |
⭐⭐⭐⭐ | 属性占位符 vs SpEL 表达式 |
| 6 | @Value 能注入 List 吗? |
⭐⭐⭐ | 可以,逗号分隔字符串;复杂结构用 @ConfigurationProperties |
| 7 | 配置不存在时 @Value 会怎样? |
⭐⭐⭐ | 启动失败;加 :默认值 兜底 |
| 8 | 什么是松散绑定? | ⭐⭐⭐ | access-key ↔ accessKey ↔ ACCESS_KEY 都能绑定 |
| 9 | @Profile 怎么用? |
⭐⭐⭐ | 按环境激活 Bean;支持 !prod 取反 |
| 10 | @PropertySource 支持 yml 吗? |
⭐⭐⭐ | 默认不支持(只支持 properties),需自定义 Factory |
| 11 | @Import 有哪几种用法? |
⭐⭐⭐⭐ | 导入配置类 / ImportSelector / ImportBeanDefinitionRegistrar |
| 12 | 怎么让自定义配置在 IDE 里有提示? | ⭐⭐⭐ | 加 spring-configuration-metadata.json(或引入配置处理器依赖) |
1.7.5 条件装配注解(Spring Boot 自动配置的基石)
本节价值: 讲完条件注解,你就能回答“为什么引入 starter 就能自动生效” “为什么我自己写了个 RedisTemplate 就不自动配置了”这类进阶问题。 这是从“会用 Boot”到“懂 Boot”的分水岭。
1.7.5.1 @Conditional:条件装配的源头
@Target({ElementType.TYPE, ElementType.METHOD})
@Retention(RetentionPolicy.RUNTIME)
@Documented
public @interface Conditional {
Class<? extends Condition>[] value(); // ★ 传入 Condition 实现类
}
Condition 接口:
@FunctionalInterface
public interface Condition {
/**
* @param context 条件上下文(能拿到 BeanFactory、Environment、ClassLoader 等)
* @param metadata 被注解类/方法的元数据
* @return true = 满足条件,创建 Bean;false = 跳过
*/
boolean matches(ConditionContext context, AnnotatedTypeMetadata metadata);
}
自定义条件示例(按操作系统选择实现):
// ① 自定义 Condition
public class OnLinuxCondition implements Condition {
@Override
public boolean matches(ConditionContext context, AnnotatedTypeMetadata metadata) {
String os = context.getEnvironment().getProperty("os.name");
return os != null && os.toLowerCase().contains("linux");
}
}
// ② 使用
@Configuration
public class OsConfig {
@Bean
@Conditional(OnLinuxCondition.class) // ★ Linux 环境才创建
public FilePathResolver linuxResolver() {
return new LinuxPathResolver();
}
@Bean
@Conditional(OnWindowsCondition.class)
public FilePathResolver windowsResolver() {
return new WindowsPathResolver();
}
}
1.7.5.2 @Conditional 家族全表(Spring Boot 扩展)
Spring Boot 在 @Conditional 基础上扩展了一堆开箱即用的条件注解,
这些是自动配置的核心。全部要认识:
| 注解 | 判断条件 | 典型用途 |
|---|---|---|
@ConditionalOnClass |
类路径存在指定类 | 引入 Redis 依赖才配置 RedisTemplate |
@ConditionalOnMissingClass |
类路径不存在指定类 | 没有某依赖时才用兜底实现 |
@ConditionalOnBean |
容器中存在指定 Bean | 有了 DataSource 才配 JdbcTemplate |
@ConditionalOnMissingBean |
容器中不存在指定 Bean | ★ 最重要:用户没自定义才用默认 |
@ConditionalOnSingleCandidate |
容器中恰好一个(或有 Primary 的多个) | 要求唯一候选 Bean |
@ConditionalOnProperty |
配置属性满足条件 | xxx.enabled=true 才启用 |
@ConditionalOnResource |
资源文件存在 | 有 logback.xml 才配日志 |
@ConditionalOnWebApplication |
是 Web 应用 | Servlet/Reactive 环境区分 |
@ConditionalOnNotWebApplication |
非 Web 应用 | 命令行应用 |
@ConditionalOnExpression |
SpEL 表达式为 true | 复杂组合条件 |
@ConditionalOnJava |
JDK 版本匹配 | 按 Java 版本选择实现 |
@ConditionalOnWarDeployment |
WAR 包部署 | 区分 jar/war |
逐个代码示例:
@Configuration
public class DemoAutoConfiguration {
// ① 类路径有 RedisOperations 类才生效(即引入了 spring-boot-starter-data-redis)
@Bean
@ConditionalOnClass(RedisOperations.class)
public RedisTemplate<String, Object> redisTemplate(RedisConnectionFactory factory) {
return new RedisTemplate<>();
}
// ② ★ 用户没自己定义 ObjectMapper 时,才用默认的("约定优于配置"的关键)
@Bean
@ConditionalOnMissingBean
public ObjectMapper objectMapper() {
return new ObjectMapper();
}
// ③ 配置了 my.feature.enabled=true 才生效;matchIfMissing=true 表示没配也生效
@Bean
@ConditionalOnProperty(
prefix = "my.feature",
name = "enabled",
havingValue = "true",
matchIfMissing = true // ★ 没配置时默认生效
)
public FeatureService featureService() {
return new FeatureService();
}
// ④ 容器里有 DataSource 才创建 JdbcTemplate(有依赖才有这个 Bean)
@Bean
@ConditionalOnBean(DataSource.class)
public JdbcTemplate jdbcTemplate(DataSource dataSource) {
return new JdbcTemplate(dataSource);
}
// ⑤ 只在 Web 环境生效
@Bean
@ConditionalOnWebApplication(type = ConditionalOnWebApplication.Type.SERVLET)
public WebMvcConfigurer webConfigurer() {
return new MyWebMvcConfigurer();
}
// ⑥ SpEL 表达式组合条件
@Bean
@ConditionalOnExpression("'${my.mode}' == 'cluster' and ${my.node-count} > 1")
public ClusterService clusterService() {
return new ClusterService();
}
// ⑦ JDK 版本条件
@Bean
@ConditionalOnJava(range = ConditionalOnJava.Range.EQUAL_OR_NEWER, value = JavaVersion.SEVENTEEN)
public ModernImpl modernImpl() {
return new ModernImpl(); // Java 17+ 用新实现
}
// ⑧ 资源文件存在
@Bean
@ConditionalOnResource(resources = "classpath:my-license.key")
public LicenseService licenseService() {
return new LicenseService();
}
}
1.7.5.3 ★ @ConditionalOnMissingBean:自动配置的精髓
这是理解 Spring Boot “约定优于配置”的钥匙。
问题: Spring Boot 默认帮我配了一个 ObjectMapper,
但我想自定义时间格式,怎么办?
答案: 我自己定义一个 ObjectMapper Bean,Boot 的自动配置就自动退让。
// Spring Boot 源码里的自动配置(简化)
@Configuration
public class JacksonAutoConfiguration {
@Bean
@ConditionalOnMissingBean // ★★★ 关键:容器里没有 ObjectMapper 我才创建
public ObjectMapper jacksonObjectMapper(Jackson2ObjectMapperBuilder builder) {
return builder.createXmlMapper(false).build();
}
}
// 我自己的配置 —— 一旦出现,Boot 的自动配置就跳过(因为不再是 @ConditionalOnMissingBean)
@Configuration
public class MyJacksonConfig {
@Bean
public ObjectMapper objectMapper() {
ObjectMapper mapper = new ObjectMapper();
mapper.setDateFormat(new SimpleDateFormat("yyyy-MM-dd HH:mm:ss"));
mapper.setSerializationInclusion(JsonInclude.Include.NON_NULL);
return mapper;
}
}
★ 面试标准答案(口述版):
“Spring Boot 自动配置的核心是
@ConditionalOnMissingBean—— “用户没配,我才配;用户配了,我就让位”。比如 Boot 源码里的
JacksonAutoConfiguration上有@ConditionalOnMissingBean,意思是“容器里没有 ObjectMapper 我才创建默认的”。 所以我只要自己定义一个 ObjectMapper Bean,Boot 的自动配置就会跳过, 用我的。这就是“约定优于配置”的实现方式。这个设计的精妙之处在于:用户不需要任何额外操作(不用 exclude、不用改配置), 只要按自己的需求声明一个 Bean,就自动覆盖了框架的默认行为。
我们项目里自定义过 ObjectMapper(统一时间格式、null 不序列化)、 自定义过线程池(覆盖默认的 SimpleAsyncTaskExecutor), 都是用这个机制实现的。“
⚠️ 一个经典坑:顺序问题
// ❌ 可能不生效:用户的配置类在自动配置类【之前】被处理
@Configuration
public class MyConfig {
@Bean
public ObjectMapper objectMapper() { ... }
}
如果用户的 @Bean 在自动配置类之前被解析,那么自动配置执行时
确实能看到用户的 Bean(因为 BeanDefinition 已注册),所以通常没问题。
但如果用户配置类没被扫描到(不在启动类包下),就会出现“我的配置没生效”。
排查技巧: 启动时加
--debug,看ConditionEvaluationReport, 能看到每个自动配置类“为什么生效/为什么不生效”。java -jar app.jar --debug # 输出: # Positive matches: (生效的自动配置) # Negative matches: (没生效的 + 原因) # JacksonAutoConfiguration: # Did not match: # - @ConditionalOnMissingBean found existing bean 'objectMapper'
1.7.5.4 自动配置的执行顺序
// 控制自动配置类之间的顺序(★ 自己写 starter 时必须掌握)
@Configuration
@AutoConfigurationAfter(DataSourceAutoConfiguration.class) // 在 XX 之后执行
@AutoConfigurationBefore(JacksonAutoConfiguration.class) // 在 XX 之前执行
@AutoConfigureOrder(Ordered.HIGHEST_PRECEDENCE) // 指定优先级
public class MyAutoConfiguration { }
区别:
@AutoConfigureBefore/After:只用于自动配置类之间的顺序@Order/Ordered:控制同类 Bean 在集合中的排序、AOP 切面顺序@DependsOn:控制 Bean 的创建顺序
为什么顺序重要?
// 场景:我的自动配置依赖 DataSource 已经存在
@Configuration
@AutoConfigurationAfter(DataSourceAutoConfiguration.class) // ★ 保证 DataSource 先配好
@ConditionalOnBean(DataSource.class) // 否则这个条件可能判断不到
public class MyDaoAutoConfiguration {
@Bean
public MyDao myDao(DataSource ds) { return new MyDao(ds); }
}
1.7.5.5 自己写一个 Starter(实战,★ 加分项)
面试能讲出“我写过一个自定义 starter”,是实打实的加分项。
结构:
my-spring-boot-starter/
├── pom.xml
└── src/main/java/com/company/starter/
├── SmsProperties.java # 配置属性类
├── SmsService.java # 业务服务
├── SmsAutoConfiguration.java # 自动配置类
└── resources/META-INF/spring/
└── org.springframework.boot.autoconfigure.AutoConfiguration.imports # ★ Spring Boot 3.x
① 配置属性类:
@ConfigurationProperties(prefix = "sms.aliyun")
@Data
public class SmsProperties {
private String accessKey;
private String secretKey;
private Integer timeout = 3000;
}
② 业务服务:
public class SmsService {
private final SmsProperties properties;
public SmsService(SmsProperties properties) {
this.properties = properties;
}
public void send(String phone, String content) {
// 调用阿里云 SDK
}
}
③ 自动配置类:
@Configuration
@EnableConfigurationProperties(SmsProperties.class) // 启用配置绑定
@ConditionalOnClass(SmsService.class) // 类路径有 SmsService
@ConditionalOnProperty(prefix = "sms.aliyun", name = "enabled", havingValue = "true", matchIfMissing = true)
public class SmsAutoConfiguration {
@Bean
@ConditionalOnMissingBean // ★ 用户没自定义才创建
public SmsService smsService(SmsProperties properties) {
return new SmsService(properties);
}
}
④ 注册自动配置:
# Spring Boot 2.7+ / 3.x:META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports
com.company.starter.SmsAutoConfiguration
# Spring Boot 2.6 及更早:META-INF/spring.factories
org.springframework.boot.autoconfigure.EnableAutoConfiguration=\
com.company.starter.SmsAutoConfiguration
⑤ 使用方:
# application.yml
sms:
aliyun:
access-key: LTAI5txxx
secret-key: xxxxx
timeout: 5000
@Service
public class NotifyService {
@Autowired
private SmsService smsService; // ★ 直接注入,无需任何配置类
}
面试话术: “我们项目把一些公共能力(短信、OSS、分布式锁)封装成了内部 starter, 各业务线只要引入依赖 + 配几个 yml 属性就能用。 核心就是
@Configuration+@ConditionalOnMissingBean+@EnableConfigurationProperties三件套,再在AutoConfiguration.imports里注册自动配置类。”
1.7.5.6 条件装配面试题(10 题)
| # | 题目 | 难度 | 要点 |
|---|---|---|---|
| 1 | @Conditional 是什么?有什么用? |
⭐⭐⭐⭐ | 满足条件才创建 Bean;自动配置的基石 |
| 2 | @ConditionalOnMissingBean 的作用?为什么重要? |
⭐⭐⭐⭐⭐ | 用户没定义才创建默认 Bean → “约定优于配置”的实现 |
| 3 | 为什么我自定义了 ObjectMapper,Boot 默认的就不生效了? | ⭐⭐⭐⭐⭐ | JacksonAutoConfiguration 上有 @ConditionalOnMissingBean |
| 4 | @ConditionalOnClass 和 @ConditionalOnBean 区别? |
⭐⭐⭐⭐ | 类路径有类 vs 容器中有 Bean |
| 5 | @ConditionalOnProperty 的 matchIfMissing 是什么? |
⭐⭐⭐ | 没配置该属性时是否默认生效 |
| 6 | 怎么排除某个自动配置类? | ⭐⭐⭐ | @SpringBootApplication(exclude=...) 或 spring.autoconfigure.exclude |
| 7 | 怎么查看哪些自动配置生效了/没生效? | ⭐⭐⭐⭐ | 启动加 --debug,看 ConditionEvaluationReport |
| 8 | Spring Boot 3 的自动配置文件和 2.x 有什么不同? | ⭐⭐⭐⭐ | 2.7+ 用 AutoConfiguration.imports,之前用 spring.factories |
| 9 | 自己写过 starter 吗?讲讲结构 | ⭐⭐⭐⭐ | Properties + Service + AutoConfiguration + imports 文件 |
| 10 | @AutoConfigureBefore 和 @Order 区别? |
⭐⭐⭐ | 前者:自动配置类间顺序;后者:Bean 集合排序/切面顺序 |
1.7.6 Web 层注解(Spring MVC)
1.7.6.1 @Controller vs @RestController
@Controller
@ResponseBody // ★ @RestController = @Controller + @ResponseBody
public @interface RestController {
String value() default "";
}
@Controller |
@RestController |
|
|---|---|---|
| 返回值处理 | 默认返回视图名(需配合视图解析器) | 返回值直接序列化成 JSON 写入响应体 |
| 适用场景 | 传统 MVC(返回 JSP/Thymeleaf 页面) | RESTful API(前后端分离,你项目全是这种) |
| 想返回 JSON | 方法上加 @ResponseBody |
不需要(已默认) |
@Controller
@RequestMapping("/page")
public class PageController {
@GetMapping("/index")
public String index() {
return "index"; // 返回视图名,渲染 index.html
}
@GetMapping("/data")
@ResponseBody // ★ 加了这个才返回 JSON
public User data() {
return new User();
}
}
@RestController
@RequestMapping("/api/instruction")
public class InstructionController {
@GetMapping("/{id}")
public Result<Instruction> get(@PathVariable Long id) {
return Result.ok(instructionService.getById(id)); // 自动转 JSON
}
}
1.7.6.2 @RequestMapping 及其变体
@Target({ElementType.TYPE, ElementType.METHOD})
@Retention(RetentionPolicy.RUNTIME)
public @interface RequestMapping {
String name() default "";
String[] value() default {}; // 路径(可多个)
String[] path() default {}; // value 的别名
RequestMethod[] method() default {}; // HTTP 方法
String[] params() default {}; // 请求参数条件
String[] headers() default {}; // 请求头条件
String[] consumes() default {}; // Content-Type 条件
String[] produces() default {}; // Accept 条件(响应类型)
}
五种快捷变体(推荐用这些,语义更清晰):
| 注解 | 等价写法 | HTTP 方法 | 语义 |
|---|---|---|---|
@GetMapping |
@RequestMapping(method = GET) |
GET | 查询 |
@PostMapping |
@RequestMapping(method = POST) |
POST | 新增 |
@PutMapping |
@RequestMapping(method = PUT) |
PUT | 全量更新 |
@DeleteMapping |
@RequestMapping(method = DELETE) |
DELETE | 删除 |
@PatchMapping |
@RequestMapping(method = PATCH) |
PATCH | 部分更新 |
@RestController
@RequestMapping("/api/instruction")
public class InstructionController {
// ① 基本用法
@GetMapping("/{id}")
public Result<Instruction> get(@PathVariable Long id) { ... }
// ② 多路径映射
@GetMapping({"/list", "/query"})
public Result<List<Instruction>> list() { ... }
// ③ ★ 精确控制请求/响应类型(解决中文乱码和 415 错误)
@PostMapping(value = "/submit",
consumes = MediaType.APPLICATION_JSON_VALUE, // 只接受 application/json
produces = MediaType.APPLICATION_JSON_UTF8_VALUE) // 响应 JSON + UTF-8
public Result<Long> submit(@RequestBody InstructionDTO dto) { ... }
// ④ 请求参数条件(同路径按参数区分)
@GetMapping(value = "/search", params = "type=quick")
public Result<List<Instruction>> quickSearch() { ... }
@GetMapping(value = "/search", params = "type=advanced")
public Result<List<Instruction>> advancedSearch() { ... }
// ⑤ 请求头条件(API 版本控制)
@GetMapping(value = "/info", headers = "API-Version=2")
public Result<Info> infoV2() { ... }
}
1.7.6.3 参数绑定四兄弟(★ 必考区分)
| 注解 | 从哪取值 | 典型 URL / 请求 |
|---|---|---|
@PathVariable |
URL 路径 | /user/{id} → id |
@RequestParam |
URL 查询参数 | /user?id=1 或表单 |
@RequestBody |
请求体 JSON | POST 的 JSON body |
@RequestHeader |
请求头 | Authorization: xxx |
@CookieValue |
Cookie | JSESSIONID=xxx |
@RestController
@RequestMapping("/api/user")
public class UserController {
// ① @PathVariable:取 URL 路径片段
@GetMapping("/{id}/orders/{orderId}")
public Result<Order> getOrder(
@PathVariable Long id,
@PathVariable("orderId") Long orderId) { // ★ 名字不一致时显式指定
...
}
// ② @RequestParam:取查询参数
@GetMapping("/list")
public Result<List<User>> list(
@RequestParam(defaultValue = "1") Integer page, // ★ 有默认值 → 非必传
@RequestParam(defaultValue = "10") Integer size,
@RequestParam(required = false) String keyword) { // ★ 非必传
...
}
// ③ @RequestBody:取 JSON 请求体(只能有一个)
@PostMapping
public Result<Long> create(@RequestBody @Valid UserDTO dto) {
...
}
// ④ @RequestHeader:取请求头(鉴权常用)
@GetMapping("/profile")
public Result<User> profile(
@RequestHeader("Authorization") String token,
@RequestHeader(value = "X-Trace-Id", required = false) String traceId) {
...
}
// ⑤ @CookieValue
@GetMapping("/cart")
public Result<Cart> cart(@CookieValue("JSESSIONID") String sessionId) { ... }
}
★ 面试对比:@RequestParam vs @RequestBody
@RequestParam@RequestBody数据来源 URL 查询串 / 表单 HTTP 请求体 Content-Type 任意(通常是 form) 必须是 application/json 适用场景 简单参数、GET 查询 复杂对象、POST/PUT 提交 能否多个 可以多个 只能有一个 底层解析器 RequestParamMethodArgumentResolverRequestResponseBodyMethodProcessor(走 HttpMessageConverter)
⚠️ 常见坑:
// 坑 1:@RequestParam 默认必传,不传报 400
@GetMapping("/list")
public Result list(@RequestParam String keyword) { } // 不传 keyword → 400 错误
// 解法:required = false 或给 defaultValue
// 坑 2:@RequestBody 接收不到,报 400 或字段全 null
// 原因:① 没加 Content-Type: application/json
// ② DTO 字段名和 JSON 对不上(用 @JsonProperty 解决)
// ③ 没有无参构造器 / 没有 setter
// 坑 3:一个方法里两个 @RequestBody
public Result submit(@RequestBody A a, @RequestBody B b) { } // ❌ 报错!流只能读一次
// 解法:合并成一个 DTO
// 坑 4:@PathVariable 名字对不上
@GetMapping("/{userId}")
public Result get(@PathVariable Long id) { } // ❌ 路径是 userId,参数是 id
// 解法:@PathVariable("userId") Long id 或 参数名改成 userId(需 -parameters 编译参数)
1.7.6.4 @ResponseBody 原理:消息转换器
请求/响应的转换链路
──────────────────────────────────────────────
请求 → HttpMessageConverter 读(JSON → 对象)→ Controller 方法参数
响应 → Controller 返回值 → HttpMessageConverter 写(对象 → JSON)→ HTTP 响应
默认注册的转换器(按优先级):
① ByteArrayHttpMessageConverter byte[]
② StringHttpMessageConverter String
③ MappingJackson2HttpMessageConverter ★ 对象 ↔ JSON(Jackson)
④ ResourceHttpMessageConverter 文件资源
⑤ ... 其他(XML、Protobuf 等)
★ 面试点:如何统一处理 Long 精度丢失问题(前端 JS 的 Number 只有 53 位)
@Configuration
public class JacksonConfig {
@Bean
@ConditionalOnMissingBean
public ObjectMapper objectMapper(Jackson2ObjectMapperBuilder builder) {
ObjectMapper mapper = builder.createXmlMapper(false).build();
// ★ Long 类型序列化成 String,避免前端精度丢失
SimpleModule module = new SimpleModule();
module.addSerializer(Long.class, ToStringSerializer.instance);
module.addSerializer(Long.TYPE, ToStringSerializer.instance);
mapper.registerModule(module);
// 时间格式统一
mapper.setDateFormat(new SimpleDateFormat("yyyy-MM-dd HH:mm:ss"));
// null 不序列化
mapper.setSerializationInclusion(JsonInclude.Include.NON_NULL);
return mapper;
}
}
💡 简历关联: 项目一资产托管的指令 ID、金额字段用雪花算法生成(19 位 Long), 前端 JS 直接接收会精度丢失(后 3-4 位变成 000), 用上面的方案统一序列化成 String 解决。这是真实的踩坑经验,面试讲出来很加分。 (如果你项目里没遇到,可以讲“我知道这个坑,预防性地做了处理”)
1.7.6.5 ★ @Valid vs @Validated(高频区分题)
| 维度 | @Valid |
@Validated |
|---|---|---|
| 出处 | JDK 标准(JSR-303/380) | Spring 提供(对 @Valid 的增强) |
| 分组校验 | ❌ 不支持 | ✅ 支持(groups 属性) |
| 嵌套校验 | ✅ 支持(字段上加 @Valid) |
✅ 支持 |
| 使用位置 | 方法参数、字段、构造器 | 类上、方法参数(不能用在字段上!) |
| 校验顺序 | — | ✅ 支持 @GroupSequence 指定顺序 |
① 基本用法(入参校验):
@Data
public class InstructionDTO {
@NotNull(message = "指令ID不能为空")
private Long id;
@NotBlank(message = "证券代码不能为空") // ★ String 用 @NotBlank(@NotNull 不校验空串)
@Size(max = 20, message = "证券代码长度不能超过20")
private String securityCode;
@NotNull(message = "数量不能为空")
@Min(value = 1, message = "数量必须大于0")
private BigDecimal quantity;
@DecimalMin(value = "0.01", message = "金额必须大于0")
private BigDecimal amount;
@Pattern(regexp = "^[BUY|SELL]$", message = "指令类型只能是BUY或SELL")
private String type;
@Email(message = "邮箱格式不正确")
private String email;
// ★ 嵌套校验:必须加 @Valid,否则不会校验内部对象
@Valid
@NotNull(message = "账户信息不能为空")
private AccountDTO account;
}
@RestController
public class InstructionController {
// ★ @RequestBody 前加 @Validated(或 @Valid)触发校验
@PostMapping("/submit")
public Result<Long> submit(@RequestBody @Validated InstructionDTO dto) {
return Result.ok(instructionService.submit(dto));
}
// ★ 非 @RequestBody 的参数校验:必须在【类上】加 @Validated
@GetMapping("/{id}")
public Result<Instruction> get(@PathVariable @Min(1) Long id) { ... }
}
② 分组校验(@Validated 独有,★ 高频):
// 定义分组(空接口即可)
public interface CreateGroup { } // 新增时的校验组
public interface UpdateGroup { } // 更新时的校验组
@Data
public class InstructionDTO {
@Null(groups = CreateGroup.class, message = "新增时ID必须为空") // 新增时 ID 必须为空
@NotNull(groups = UpdateGroup.class, message = "更新时ID不能为空") // 更新时 ID 必填
private Long id;
@NotBlank(groups = {CreateGroup.class, UpdateGroup.class})
private String securityCode;
}
@RestController
@Validated // ★ 类上加,配合方法参数的分组
public class InstructionController {
@PostMapping
public Result create(@RequestBody @Validated(CreateGroup.class) InstructionDTO dto) { ... }
@PutMapping
public Result update(@RequestBody @Validated(UpdateGroup.class) InstructionDTO dto) { ... }
}
③ 校验失败的处理(全局异常处理器):
@RestControllerAdvice // ★ @ControllerAdvice + @ResponseBody
@Slf4j
public class GlobalExceptionHandler {
// ① 处理 @RequestBody 校验失败
@ExceptionHandler(MethodArgumentNotValidException.class)
public Result<Void> handleValidException(MethodArgumentNotValidException e) {
// ★ 提取第一个错误信息返回(也可以全部返回)
String msg = e.getBindingResult().getFieldErrors().stream()
.map(f -> f.getField() + ": " + f.getDefaultMessage())
.collect(Collectors.joining(", "));
log.warn("参数校验失败: {}", msg);
return Result.fail(400, msg);
}
// ② 处理 @RequestParam / @PathVariable 校验失败(类上加了 @Validated 时抛这个)
@ExceptionHandler(ConstraintViolationException.class)
public Result<Void> handleConstraintViolation(ConstraintViolationException e) {
String msg = e.getConstraintViolations().stream()
.map(v -> v.getPropertyPath() + ": " + v.getMessage())
.collect(Collectors.joining(", "));
return Result.fail(400, msg);
}
// ③ 处理业务异常
@ExceptionHandler(BizException.class)
public Result<Void> handleBizException(BizException e) {
log.warn("业务异常: {}", e.getMessage());
return Result.fail(e.getCode(), e.getMessage());
}
// ④ 兜底:未知异常(★ 不要暴露堆栈给前端)
@ExceptionHandler(Exception.class)
public Result<Void> handleException(Exception e) {
log.error("系统异常", e);
return Result.fail(500, "系统繁忙,请稍后重试");
}
}
★ 常用校验注解速查:
| 注解 | 作用 | 适用类型 |
|---|---|---|
@Null / @NotNull |
必须为 null / 不为 null | 任意 |
@NotBlank |
非 null 且 trim 后非空 | String |
@NotEmpty |
非 null 且非空(长度 > 0) | String、Collection、Map、数组 |
@Size(min, max) |
长度/大小在范围内 | String、Collection、数组 |
@Min / @Max |
数值范围 | 数字、BigDecimal |
@DecimalMin / @DecimalMax |
小数范围 | BigDecimal、String |
@Range |
范围(Hibernate 扩展) | 数字、String |
@Pattern |
正则匹配 | String |
@Email |
邮箱格式 | String |
@Past / @Future |
时间在过去/未来 | Date、LocalDateTime |
@AssertTrue / @AssertFalse |
必须为 true/false | Boolean |
@Valid |
嵌套校验 | 对象字段 |
⚠️ 坑:
@NotNullvs@NotBlank@NotNull只检查null,空串""会通过! 校验 String 必须用@NotBlank(或@NotEmpty)。这是最常见的校验失效原因。
1.7.6.6 @ControllerAdvice(全局处理三大用法)
@ControllerAdvice // 拦截所有 @Controller
public @interface ControllerAdvice {
String[] value() default {}; // 限定包路径
String[] basePackages() default {}; // 限定包
Class<?>[] assignableTypes() default {}; // 限定的 Controller 类型
Class<? extends Annotation>[] annotations() default {}; // 限定带某注解的 Controller
}
三大用途:
// 用途一:全局异常处理(最常用,见 1.7.6.5)
@ExceptionHandler(Exception.class)
public Result handle(Exception e) { ... }
// 用途二:全局数据绑定(所有 Controller 都能拿到)
@ModelAttribute("currentUser")
public User currentUser() {
return SecurityUtils.getCurrentUser(); // 所有视图都能用 ${currentUser}
}
// 用途三:全局数据预处理
@InitBinder
public void initBinder(WebDataBinder binder) {
// ★ 统一处理日期格式(表单提交的字符串 → Date)
binder.registerCustomEditor(Date.class, new CustomDateEditor(
new SimpleDateFormat("yyyy-MM-dd HH:mm:ss"), true));
// ★ 防止 XSS / 去掉首尾空格
binder.registerCustomEditor(String.class, new StringTrimmerEditor(true));
}
1.7.6.7 其他 Web 注解
// ① @CrossOrigin:跨域
@RestController
@CrossOrigin(origins = "http://localhost:8080", maxAge = 3600) // 类级别
public class ApiController {
@GetMapping("/data")
@CrossOrigin(origins = "*") // 方法级别(优先级高)
public Result data() { ... }
}
// 更推荐:统一配置(不用每个 Controller 加)
@Configuration
public class CorsConfig implements WebMvcConfigurer {
@Override
public void addCorsMappings(CorsRegistry registry) {
registry.addMapping("/**")
.allowedOriginPatterns("*")
.allowedMethods("GET", "POST", "PUT", "DELETE", "OPTIONS")
.allowedHeaders("*")
.allowCredentials(true) // ★ 允许带 Cookie(此时 origins 不能用 *)
.maxAge(3600);
}
}
// ② @ResponseStatus:指定响应状态码
@PostMapping("/create")
@ResponseStatus(HttpStatus.CREATED) // 返回 201 而不是默认 200
public Result create() { ... }
// 自定义异常的状态码
@ResponseStatus(code = HttpStatus.FORBIDDEN, reason = "没有权限")
public class NoPermissionException extends RuntimeException { }
// ③ @RequestPart:文件上传(比 @RequestParam 更强,支持 JSON + 文件混合)
@PostMapping("/upload")
public Result upload(
@RequestPart("file") MultipartFile file,
@RequestPart("meta") @Valid FileMetaDTO meta) { // ★ 同一请求既有文件又有 JSON
...
}
// ④ @SessionAttribute / @RequestAttribute
@GetMapping("/cart")
public Result cart(@SessionAttribute("userId") Long userId) { ... }
1.7.6.8 Web 层面试题(14 题)
| # | 题目 | 难度 | 要点 |
|---|---|---|---|
| 1 | @Controller 和 @RestController 的区别? |
⭐⭐⭐⭐⭐ | 后者 = @Controller + @ResponseBody |
| 2 | @RequestMapping 有哪些属性? |
⭐⭐⭐ | value/method/params/headers/consumes/produces |
| 3 | @RequestParam 和 @RequestBody 的区别? |
⭐⭐⭐⭐⭐ | 查询参数 vs 请求体;能否多个;Content-Type 要求 |
| 4 | @PathVariable 和 @RequestParam 的区别? |
⭐⭐⭐ | URL 路径 vs 查询串 |
| 5 | 一个方法能写两个 @RequestBody 吗?为什么? |
⭐⭐⭐⭐ | 不能,请求体流只能读一次 |
| 6 | @Valid 和 @Validated 的区别? |
⭐⭐⭐⭐⭐ | @Validated 支持分组校验;@Valid 支持字段嵌套 |
| 7 | 怎么校验 @RequestParam 参数? |
⭐⭐⭐⭐ | 类上加 @Validated + 参数上加校验注解 |
| 8 | 嵌套对象怎么校验? | ⭐⭐⭐⭐ | 字段上加 @Valid |
| 9 | @NotNull 和 @NotBlank 的区别? |
⭐⭐⭐⭐ | @NotNull 不校验空串;String 用 @NotBlank |
| 10 | 校验失败怎么统一返回? | ⭐⭐⭐⭐ | @RestControllerAdvice + @ExceptionHandler(MethodArgumentNotValidException) |
| 11 | @ControllerAdvice 有哪几种用法? |
⭐⭐⭐⭐ | 全局异常处理 / 全局数据绑定 / 全局数据预处理 |
| 12 | 怎么处理跨域? | ⭐⭐⭐ | @CrossOrigin(局部)/ WebMvcConfigurer(全局,推荐) |
| 13 | 前端收到 Long 类型精度丢失怎么办? | ⭐⭐⭐⭐ | 序列化成 String(ToStringSerializer)/ 后端返回 String |
| 14 | @ResponseStatus 有什么用? |
⭐⭐⭐ | 指定 HTTP 响应状态码 |
1.7.7 AOP 注解
详细的 AOP 原理(JDK/CGLIB 代理选择、织入时机)见 1.2 节, AspectJ 的深度内容见 1.3 节。本节聚焦注解用法与执行顺序。
1.7.7.1 六种注解与五种通知
| 注解 | 类型 | 作用 | 能否阻止方法执行 |
|---|---|---|---|
@Aspect |
切面声明 | 标注一个类是切面 | — |
@Pointcut |
切点定义 | 定义“在哪些地方切入” | — |
@Before |
前置通知 | 方法执行前 | ❌(只能抛异常阻止) |
@After |
后置通知(finally) | 方法执行后(无论成功失败都执行) | ❌ |
@AfterReturning |
返回通知 | 方法成功返回后 | ❌ |
@AfterThrowing |
异常通知 | 方法抛异常后 | ❌ |
@Around |
环绕通知 | 包住整个方法 | ✅ 能(不调 proceed 就不执行) |
1.7.7.2 ★ 执行顺序(面试必考,务必记牢)
正常情况(无异常):
┌─────────────────────────────────────┐
│ @Around(前半段) │ ①
│ ┌───────────────────────────────┐ │
│ │ @Before │ │ ②
│ │ ┌─────────────────────────┐ │ │
│ │ │ 目标方法执行 │ │ │ ③
│ │ └─────────────────────────┘ │ │
│ │ @AfterReturning │ │ ④ (成功才执行)
│ │ @After │ │ ⑤ (finally,总会执行)
│ └───────────────────────────────┘ │
│ @Around(后半段) │ ⑥
└─────────────────────────────────────┘
顺序:Around前 → Before → 目标方法 → AfterReturning → After → Around后
异常情况(抛异常):
顺序:Around前 → Before → 目标方法(抛异常)
→ @AfterThrowing → @After → (Around 后半段不执行,除非捕获异常)
⚠️ 关键:
· @AfterReturning 不执行(没成功返回)
· @AfterThrowing 执行
· @After 仍然执行(等价于 finally)
· @Around 后半段不执行(异常向上抛)
但如果 @Around 里 try-catch 了,后半段执行,且 @AfterThrowing 不会触发!
★ 记忆口诀:
“环绕包最外,Before 先进门,After 最后走;成功 Returning,失败 Throwing,After 总执行。”
多切面时的顺序:
// 用 @Order 控制(数字越小越靠外)
@Aspect
@Component
@Order(1) // ★ 数字小 = 在外层 = 先执行 Before,后执行 After
public class LogAspect { }
@Aspect
@Component
@Order(2)
public class AuthAspect { } // 在内层
// 执行顺序:
// LogAspect@Around前 → LogAspect@Before → AuthAspect@Around前 → AuthAspect@Before
// → 目标方法
// → AuthAspect@AfterReturning → AuthAspect@After → AuthAspect@Around后
// → LogAspect@AfterReturning → LogAspect@After → LogAspect@Around后
洋葱模型记忆:
@Order小的在外层,像洋葱一样包着里面的。
1.7.7.3 切点表达式(execution 语法)
// 完整语法
execution(修饰符? 返回类型 包名.类名.方法名(参数) 异常?)
// 可省略 ★必填 ★必填 ★必填 ★必填 可省略
// 常用示例
@Pointcut("execution(public * com.company.custody.service.*.*(..))")
// │ │ │ └────────── 包名 ─────────┘ │ │ └─ .. 任意参数
// │ │ └ 任意返回值 │ └ 任意方法
// │ └ public 方法 └ 所有类
// └ execution 表达式
// ① 匹配 service 包下所有类的所有方法
@Pointcut("execution(* com.company.custody.service.*.*(..))")
public void servicePointcut() { }
// ② 匹配 service 包【及其子包】
@Pointcut("execution(* com.company.custody.service..*.*(..))") // ★ 注意是两个点
public void serviceWithSub() { }
// ③ 匹配所有以 save 开头的方法
@Pointcut("execution(* com.company..*.save*(..))")
// ④ 匹配指定注解的方法(★ 自定义注解做日志最常用)
@Pointcut("@annotation(com.company.common.annotation.OperateLog)")
// ⑤ 匹配所有 @RestController 类下的方法
@Pointcut("@within(org.springframework.web.bind.annotation.RestController)")
// ⑥ 组合(&& || !)
@Pointcut("execution(* com.company..service.*.*(..)) && @annotation(operateLog)")
其他指示器(了解):
| 指示器 | 作用 |
|---|---|
execution |
匹配方法执行(最常用) |
@annotation |
匹配方法上有指定注解 |
@within |
匹配类上有指定注解的所有方法 |
within |
匹配指定类型内的所有方法 |
args |
匹配参数类型 |
@args |
匹配参数上有指定注解 |
bean |
匹配 Bean 名称(Spring 特有) |
target / this |
匹配目标对象 / 代理对象类型 |
1.7.7.4 ★ @Around 的三个坑
@Around("servicePointcut()")
public Object around(ProceedingJoinPoint pjp) throws Throwable {
// 坑 1:忘记调用 proceed() → 目标方法不执行!
// 坑 2:忘记 return proceed() 的结果 → 返回值变成 null!
// 坑 3:吞掉异常 → 事务不回滚(因为切面在事务外层时,异常传不到事务拦截器)
long start = System.currentTimeMillis();
try {
Object result = pjp.proceed(); // ★ 必须调用,且返回值
return result; // ★ 必须 return
} catch (Throwable t) {
// ❌ 错误示范:catch 后不抛出
// log.error("出错了", t);
// return null; // ← 异常被吞,事务不回滚!
// ✅ 正确:记录后重新抛出
log.error("方法执行异常: {}", pjp.getSignature(), t);
throw t;
} finally {
log.info("耗时: {}ms", System.currentTimeMillis() - start);
}
}
⚠️ 坑 3 详解(事务不回滚的经典原因): 如果 AOP 切面的
@Around吞掉了异常,而事务拦截器在切面内层, 那么事务拦截器根本感知不到异常,就会正常提交 → 事务失效。 这是“@Transactional 失效“的第 11 种隐藏场景,1.5 节没列,这里补充。
1.7.7.5 实战:三个常用切面(可直接用在你项目)
① 操作日志切面(★ 你项目一资产托管的合规审计可以用)
// 自定义注解
@Target(ElementType.METHOD)
@Retention(RetentionPolicy.RUNTIME)
@Documented
public @interface OperateLog {
String module() default ""; // 模块名
String operation() default ""; // 操作描述
boolean saveParams() default true; // 是否记录参数
}
// 切面
@Aspect
@Component
@Slf4j
public class OperateLogAspect {
@Autowired
private OperateLogMapper logMapper;
@Pointcut("@annotation(com.company.common.annotation.OperateLog)")
public void logPointcut() { }
@Around("logPointcut()")
public Object around(ProceedingJoinPoint pjp) throws Throwable {
MethodSignature signature = (MethodSignature) pjp.getSignature();
Method method = signature.getMethod();
OperateLog annotation = method.getAnnotation(OperateLog.class);
long start = System.currentTimeMillis();
boolean success = true;
String errorMsg = null;
try {
return pjp.proceed(); // ★ 执行目标方法
} catch (Throwable t) {
success = false;
errorMsg = t.getMessage();
throw t; // ★ 必须抛出,不能吞
} finally {
// ★ 无论成功失败都记录(合规要求:失败的操作也要留痕)
saveLog(annotation, pjp, success, errorMsg, System.currentTimeMillis() - start);
}
}
private void saveLog(OperateLog ann, ProceedingJoinPoint pjp,
boolean success, String errorMsg, long cost) {
try {
SysOperateLog log = new SysOperateLog();
log.setModule(ann.module());
log.setOperation(ann.operation());
log.setMethod(pjp.getSignature().toShortString());
log.setOperator(SecurityUtils.getCurrentUserId()); // 当前操作人
log.setSuccess(success);
log.setErrorMsg(errorMsg);
log.setCostMs(cost);
if (ann.saveParams()) {
log.setParams(JSON.toJSONString(pjp.getArgs())); // ⚠️ 注意敏感字段脱敏
}
log.setOperateTime(LocalDateTime.now());
logMapper.insert(log);
} catch (Exception e) {
log.error("保存操作日志失败", e); // ★ 日志保存失败不能影响主业务
}
}
}
// 使用
@Service
public class InstructionService {
@OperateLog(module = "指令管理", operation = "提交指令")
@Transactional(rollbackFor = Exception.class)
public Long submit(InstructionDTO dto) { ... }
}
⚠️ 重要: 日志记录方法如果用
REQUIRES_NEW(见 1.7.2.3), 就能保证即使主业务回滚,日志也留得下来——合规系统的刚需。
② 接口耗时监控切面
@Aspect
@Component
@Slf4j
public class CostTimeAspect {
// 所有 Controller 的方法
@Pointcut("execution(* com.company..controller..*(..))")
public void controllerPointcut() { }
@Around("controllerPointcut()")
public Object around(ProceedingJoinPoint pjp) throws Throwable {
long start = System.currentTimeMillis();
String method = pjp.getSignature().toShortString();
try {
return pjp.proceed();
} finally {
long cost = System.currentTimeMillis() - start;
// ★ 慢接口告警(超过 1 秒)
if (cost > 1000) {
log.warn("慢接口: {} 耗时 {}ms, 参数: {}", method, cost,
Arrays.toString(pjp.getArgs()));
} else {
log.info("{} 耗时 {}ms", method, cost);
}
// 上报 Prometheus(配合你项目三的监控体系)
Metrics.timer("api.cost", "method", method).record(cost, TimeUnit.MILLISECONDS);
}
}
}
③ 权限校验切面 + 数据脱敏切面
// 权限校验
@Aspect
@Component
public class AuthAspect {
@Before("@annotation(com.company.common.annotation.RequirePermission)")
public void checkPermission(JoinPoint jp) {
MethodSignature sig = (MethodSignature) jp.getSignature();
RequirePermission ann = sig.getMethod().getAnnotation(RequirePermission.class);
if (!SecurityUtils.hasPermission(ann.value())) {
throw new BizException(403, "没有操作权限");
}
}
}
// 数据脱敏(返回前处理)
@Aspect
@Component
public class DesensitizeAspect {
@AfterReturning(pointcut = "execution(* com.company..service.*.query*(..))",
returning = "result")
public void desensitize(Object result) {
if (result instanceof Result) {
Object data = ((Result<?>) result).getData();
if (data instanceof UserInfo) {
UserInfo u = (UserInfo) data;
u.setIdCard(DesensitizeUtil.idCard(u.getIdCard())); // 身份证脱敏
u.setPhone(DesensitizeUtil.phone(u.getPhone())); // 手机号脱敏
}
}
}
}
1.7.7.6 AOP 注解面试题(10 题)
| # | 题目 | 难度 | 要点 |
|---|---|---|---|
| 1 | 五种通知的执行顺序? | ⭐⭐⭐⭐⭐ | Around前→Before→方法→AfterReturning→After→Around后 |
| 2 | 抛异常时哪些通知还会执行? | ⭐⭐⭐⭐ | @After 一定执行;@AfterThrowing 执行;@AfterReturning 不执行 |
| 3 | @Around 有什么作用?和其他通知的区别? |
⭐⭐⭐⭐ | 唯一能控制目标方法是否执行、能修改返回值的通知 |
| 4 | @Around 忘记调用 proceed() 会怎样? |
⭐⭐⭐⭐ | 目标方法不执行 |
| 5 | @Around 吞掉异常会有什么后果? |
⭐⭐⭐⭐⭐ | 事务不回滚(异常传不到事务拦截器) |
| 6 | 多个切面怎么控制顺序? | ⭐⭐⭐⭐ | @Order,数字越小越靠外层 |
| 7 | execution 表达式怎么写? |
⭐⭐⭐ | execution(* 包名..*.*(..)) |
| 8 | 怎么匹配“带某注解的方法”? | ⭐⭐⭐⭐ | @annotation(全限定名) |
| 9 | AOP 自调用为什么会失效? | ⭐⭐⭐⭐⭐ | 不走代理对象;解法:注入自身 / AopContext / 拆类 |
| 10 | 你用 AOP 做过什么? | ⭐⭐⭐⭐ | 操作日志、耗时监控、权限校验、数据脱敏 |
1.7.8 异步与定时任务注解
1.7.8.1 ★ @Async:异步执行(三大坑)
基本用法:
@Configuration
@EnableAsync // ★ 必须开启(1)
public class AsyncConfig { }
@Service
public class NotifyService {
@Async // ★ 方法上加(2)
public void sendSms(String phone) {
log.info("发送短信, 线程: {}", Thread.currentThread().getName());
// 耗时操作...
}
// 带返回值:用 CompletableFuture
@Async
public CompletableFuture<String> asyncTask() {
String result = doSomething();
return CompletableFuture.completedFuture(result);
}
}
★ 三大坑(面试必考,答出来就是加分):
坑 1:默认线程池是 SimpleAsyncTaskExecutor——每次新建线程,不复用!
默认行为:
· 每次 @Async 调用都 new 一个线程
· 没有队列、没有最大线程数限制(实际上是 Integer.MAX_VALUE)
· 高并发下会创建大量线程 → OOM 风险
这在生产环境是灾难性的。
坑 2:自调用失效(和 @Transactional 一样)
@Service
public class OrderService {
public void createOrder() {
sendNotify(); // ❌ 自调用,@Async 不生效,仍是同步执行
}
@Async
public void sendNotify() { ... }
}
坑 3:异常被吞(不配 AsyncUncaughtExceptionHandler 就看不到)
@Async
public void task() {
throw new RuntimeException("出错了"); // ⚠️ 无声无息,日志里找不到
}
// 原因:异常发生在另一个线程,主线程的 try-catch 捕获不到
// 解法:配置 AsyncUncaughtExceptionHandler
✅ 正确配置(生产级):
@Configuration
@EnableAsync
@Slf4j
public class AsyncConfig implements AsyncConfigurer {
// ① 自定义线程池(★ 解决坑 1)
@Bean("bizExecutor") // ★ 起个名字,@Async("bizExecutor") 引用
public Executor bizExecutor() {
ThreadPoolTaskExecutor executor = new ThreadPoolTaskExecutor();
executor.setCorePoolSize(8); // 核心线程数
executor.setMaxPoolSize(16); // 最大线程数
executor.setQueueCapacity(2000); // 队列容量
executor.setKeepAliveSeconds(60); // 空闲线程存活时间
executor.setThreadNamePrefix("biz-async-"); // ★ 线程名前缀(排查必备)
executor.setRejectedExecutionHandler(
new ThreadPoolExecutor.CallerRunsPolicy()); // ★ 拒绝策略:让调用方自己执行(降级)
// ★ 关键:允许核心线程超时 + 等待任务完成后再关闭(优雅停机)
executor.setAllowCoreThreadTimeOut(true);
executor.setWaitForTasksToCompleteOnShutdown(true);
executor.setAwaitTerminationSeconds(30);
executor.initialize();
return executor;
}
// ② 异常处理器(★ 解决坑 3)
@Override
public AsyncUncaughtExceptionHandler getAsyncUncaughtExceptionHandler() {
return (ex, method, params) -> {
log.error("异步任务执行异常, method={}, params={}",
method.getName(), Arrays.toString(params), ex);
// 可以接入告警
};
}
}
// 使用:指定线程池
@Service
public class NotifyService {
@Async("bizExecutor") // ★ 指定用哪个线程池(不指定用默认的)
public void sendSms(String phone) { ... }
}
★ 面试标准答案(口述版):
“
@Async有三个必须注意的坑:第一,默认线程池有问题。 Spring 默认用的是
SimpleAsyncTaskExecutor, 它每次调用都新建一个线程,既不复用也不限流,高并发下可能把系统打爆。 所以我们项目一定会自定义线程池,配置核心线程数、队列、拒绝策略, 并且给线程起个有辨识度的名字(比如biz-async-),排查问题时能一眼认出来。第二,自调用失效。 和
@Transactional一样,@Async也是基于 AOP 代理的, 同一个类里方法调方法不生效。我会把异步方法拆到单独的 Service 里。第三,异常会被吞。 异步任务的异常发生在另一个线程, 主流程的 try-catch 抓不到,而且默认连日志都不打。 我们会实现
AsyncConfigurer配置AsyncUncaughtExceptionHandler, 把异常打到日志并接告警。另外补充一点:
@Async方法如果有返回值,要用CompletableFuture包装, 直接返回其他类型是拿不到结果的。“
⚠️ 补充坑 4:事务上下文丢失
@Async方法在新线程执行,原线程的ThreadLocal(包括事务上下文、 用户上下文SecurityContext)都会丢失。 如果异步方法里需要用户信息,要显式传参,或配置SecurityContext的跨线程传播策略。
1.7.8.2 @Scheduled:定时任务
@Configuration
@EnableScheduling // ★ 必须开启
public class ScheduleConfig { }
@Component
@Slf4j
public class SyncTask {
// ① cron 表达式(最灵活)
@Scheduled(cron = "0 0 2 * * ?") // 每天凌晨 2 点
public void dailySync() { ... }
// ② fixedRate:固定频率(上次【开始】后隔多久执行下一次)
@Scheduled(fixedRate = 60000) // 每 60 秒(不管上次用了多久)
public void fixedRateTask() { ... }
// ③ fixedDelay:固定延迟(上次【结束】后隔多久执行下一次)
@Scheduled(fixedDelay = 60000) // 上次完成后再等 60 秒
public void fixedDelayTask() { ... }
// ④ initialDelay:首次执行延迟
@Scheduled(initialDelay = 10000, fixedRate = 60000)
public void delayedTask() { ... } // 启动 10 秒后首次执行,之后每 60 秒
// ⑤ 支持配置化(★ 推荐,不用改代码)
@Scheduled(cron = "${task.sync.cron:0 0 2 * * ?}")
public void configableTask() { ... }
}
★ fixedRate vs fixedDelay(必考区别):
fixedRate = 5000(每 5 秒一次,按开始时间算):
0s ──────── 5s ──────── 10s ──────── 15s
[任务A:耗时2s] [任务B:耗时7s] ...
↑ 10s 时该启动了,但 B 还没结束 → 任务会堆积/排队
fixedDelay = 5000(结束后再等 5 秒):
0s ──2s──(等5s)──7s ──14s──(等5s)──19s
[任务A] [任务B:耗时7s]
↑ 上次结束后才计时,不会堆积
★ 选择:
· 要求"严格周期性"(如心跳上报)→ fixedRate
· 要求"不能并发执行"(如数据同步)→ fixedDelay ★ 更安全
★ 坑 1:默认是单线程的(所有定时任务共用一个线程)
默认行为:
Spring 的 @Scheduled 默认用一个单线程的 ScheduledThreadPoolExecutor
→ 多个定时任务【串行执行】
→ 一个任务卡住,其他全部延迟!
现象:
· 任务 A 每 5 秒跑一次,某次卡了 60 秒
· 任务 B、C 在这 60 秒内全部不执行
✅ 解决:配置线程池
@Configuration
@EnableScheduling
public class ScheduleConfig implements SchedulingConfigurer {
@Override
public void configureTasks(ScheduledTaskRegistrar taskRegistrar) {
taskRegistrar.setScheduler(taskExecutor());
}
@Bean
public Executor taskExecutor() {
return Executors.newScheduledThreadPool(10, // ★ 10 个线程
new ThreadFactoryBuilder().setNameFormat("schedule-pool-%d").build());
}
}
★ 坑 2:分布式下多实例重复执行(★ 生产必考)
场景:应用部署了 3 个实例
问题:@Scheduled 任务会在【3 台机器上同时执行】→ 数据重复处理!
解法:
① 分布式锁(Redisson / ShedLock)—— 谁抢到锁谁执行
② 用 XXL-Job / Elastic-Job 等分布式调度框架 ★ 推荐
③ 只让一个实例跑(用配置或 K8s 的单例部署)—— 不推荐,有单点问题
④ 任务里做幂等(兜底,必做)
// 解法一:Redisson 分布式锁(轻量)
@Scheduled(cron = "0 0/5 * * * ?")
public void syncTask() {
RLock lock = redissonClient.getLock("schedule:syncTask");
// ★ 尝试加锁,最多等 0 秒,锁 10 分钟后自动释放
if (lock.tryLock(0, 10, TimeUnit.MINUTES)) {
try {
doSync();
} finally {
lock.unlock();
}
} else {
log.info("其他实例正在执行,本次跳过");
}
}
// 解法二:ShedLock(专门解决这个问题的库)
@Scheduled(cron = "0 */5 * * * ?")
@SchedulerLock(name = "syncTask", lockAtMostFor = "10m", lockAtLeastFor = "1m")
public void syncTask() { ... }
面试加分(你项目三零碳云可以用): “我们项目的定时任务用的是 XXL-Job, 因为它有可视化控制台、支持分片广播、失败重试、执行日志, 比
@Scheduled更适合生产。@Scheduled我只在一些简单的、 幂等的、不重要的本地任务上用。”
1.7.8.3 异步与定时面试题(10 题)
| # | 题目 | 难度 | 要点 |
|---|---|---|---|
| 1 | @Async 默认线程池是什么?有什么问题? |
⭐⭐⭐⭐⭐ | SimpleAsyncTaskExecutor:每次新建线程、不复用、不限流 |
| 2 | @Async 自调用会生效吗? |
⭐⭐⭐⭐ | 不会,AOP 代理限制 |
| 3 | @Async 的异常怎么捕获? |
⭐⭐⭐⭐ | 配置 AsyncUncaughtExceptionHandler;返回值用 CompletableFuture |
| 4 | @Async 方法怎么拿返回值? |
⭐⭐⭐ | CompletableFuture |
| 5 | 怎么指定 @Async 用哪个线程池? |
⭐⭐⭐⭐ | @Async("beanName") |
| 6 | @Async 会丢失什么上下文? |
⭐⭐⭐⭐ | ThreadLocal(事务上下文、SecurityContext) |
| 7 | fixedRate 和 fixedDelay 的区别? |
⭐⭐⭐⭐ | 按开始时间算 vs 按结束时间算 |
| 8 | @Scheduled 默认是单线程吗?有什么问题? |
⭐⭐⭐⭐⭐ | 是单线程;一个任务卡住全部延迟;配 SchedulingConfigurer |
| 9 | 分布式部署下定时任务怎么避免重复执行? | ⭐⭐⭐⭐⭐ | 分布式锁(Redisson/ShedLock)/ XXL-Job / 幂等 |
| 10 | 优雅停机时异步任务怎么处理? | ⭐⭐⭐ | setWaitForTasksToCompleteOnShutdown(true) + awaitTerminationSeconds |
1.7.9 Spring Boot 启动与自动配置注解
1.7.9.1 @SpringBootApplication 三合一拆解(★ 必考)
@Target(ElementType.TYPE)
@Retention(RetentionPolicy.RUNTIME)
@SpringBootConfiguration // ① 本质是 @Configuration
@EnableAutoConfiguration // ② 开启自动配置
@ComponentScan // ③ 组件扫描
public @interface SpringBootApplication {
Class<?>[] exclude() default {}; // 排除自动配置类
String[] excludeName() default {};
String[] scanBasePackages() default {}; // 扫描包路径
Class<?>[] scanBasePackageClasses() default {};
boolean proxyBeanMethods() default true;
...
}
逐个拆解:
① @SpringBootConfiguration
@Configuration // ★ 就是 @Configuration 的派生注解
public @interface SpringBootConfiguration { }
// 意义:标注这是 Boot 的主配置类
// ⚠️ 注意:一个应用【只能有一个】@SpringBootConfiguration(通常就是启动类)
② @EnableAutoConfiguration(核心)
@AutoConfigurationPackage // 见下
@Import(AutoConfigurationImportSelector.class) // ★ 导入自动配置选择器
public @interface EnableAutoConfiguration {
Class<?>[] exclude() default {};
String[] excludeName() default {};
}
工作流程:
@EnableAutoConfiguration
↓ @Import
AutoConfigurationImportSelector
↓ 读取
META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports
(Spring Boot 2.7+ / 3.x)
或 META-INF/spring.factories(2.6 及更早)
↓ 加载所有自动配置类的全限定名(约 140+ 个)
DataSourceAutoConfiguration
RedisAutoConfiguration
WebMvcAutoConfiguration
JacksonAutoConfiguration
...
↓ 逐个执行 @Conditional 条件判断
★ 只实例化满足条件的配置类
③ @ComponentScan
@ComponentScan(
excludeFilters = {
@Filter(type = FilterType.CUSTOM, classes = TypeExcludeFilter.class),
@Filter(type = FilterType.CUSTOM, classes = AutoConfigurationExcludeFilter.class)
}
)
// ★ 默认扫描【启动类所在包及其子包】
⚠️ 经典坑(前面提过,这里强调): 启动类在
com.company.app,组件在com.company.util→ 扫描不到! 解决:把启动类放到最外层包(com.company),或显式指定scanBasePackages。
④ @AutoConfigurationPackage
@Import(AutoConfigurationPackages.Registrar.class)
public @interface AutoConfigurationPackage { }
// 作用:把启动类所在包【注册为自动配置包】
// 用途:比如 JPA 的 Entity 扫描、MyBatis 的 Mapper 扫描,默认扫这个包
1.7.9.2 排除自动配置的三种方式
// 方式一:注解属性
@SpringBootApplication(exclude = {DataSourceAutoConfiguration.class})
public class Application { }
// 方式二:yml 配置(★ 推荐,不用改代码)
spring:
autoconfigure:
exclude:
- org.springframework.boot.autoconfigure.jdbc.DataSourceAutoConfiguration
- org.springframework.boot.autoconfigure.data.redis.RedisAutoConfiguration
// 方式三:条件(通过配置关闭)
# 比如关闭 Redis 自动配置
spring:
redis:
host: ... # 不配就不会自动配置(因为 @ConditionalOnProperty)
典型场景: 项目不用数据库,但引入了带 JDBC 的 starter → 启动报 “Failed to configure a DataSource” → 排除
DataSourceAutoConfiguration。
1.7.9.3 SPI 机制的演进(2.6 → 2.7 → 3.x)
| Spring Boot 版本 | 自动配置文件 | 说明 |
|---|---|---|
| 2.6 及更早 | META-INF/spring.factories |
一个文件配所有(key-value) |
| 2.7+ | META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports |
新增,每行一个全限定类名 |
| 3.x | 同上(AutoConfiguration.imports) |
spring.factories 中自动配置的部分已废弃 |
# 旧格式:META-INF/spring.factories
org.springframework.boot.autoconfigure.EnableAutoConfiguration=\
com.company.MyAutoConfiguration,\
com.company.OtherAutoConfiguration
# 新格式:META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports
com.company.MyAutoConfiguration
com.company.OtherAutoConfiguration
面试点: “2.7 之后改成新格式,主要是为了提高加载效率 (不用解析 properties)、减少冲突(一个文件只干一件事), 同时也是为 Spring Boot 3 的模块化做准备。”
1.7.9.4 测试注解
@SpringBootTest( // ★ 启动完整 Spring 容器
classes = Application.class,
webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT, // 随机端口启动 Web 环境
properties = {"my.config=test"} // 覆盖配置
)
class OrderServiceTest {
@Autowired
private OrderService orderService;
@MockBean // ★ 用 Mock 替换容器中的 Bean
private RemoteClient remoteClient; // 第三方服务 Mock 掉,不真调
@BeforeEach
void setUp() { ... }
@Test
void testCreateOrder() {
// 打桩
when(remoteClient.query(any())).thenReturn(mockResult);
// 测试
Long id = orderService.createOrder(dto);
assertThat(id).isNotNull();
}
}
// ★ 更快的选择:只加载部分 Bean(不用 @SpringBootTest 启动全部)
@ExtendWith(SpringExtension.class)
@ContextConfiguration(classes = {OrderService.class, MockConfig.class})
class FastOrderServiceTest { ... }
// 事务回滚测试(★ 测完自动回滚,不污染数据库)
@SpringBootTest
@Transactional // ★ 测试方法结束后自动回滚
class DaoTest { }
// 切片测试(只加载 Web 层)
@WebMvcTest(OrderController.class) // ★ 只加载 Controller 相关,快
class OrderControllerTest {
@MockBean
private OrderService orderService; // Service 用 Mock
@Autowired
private MockMvc mockMvc; // 模拟 HTTP 请求
}
1.7.9.5 Boot 注解面试题(10 题)
| # | 题目 | 难度 | 要点 |
|---|---|---|---|
| 1 | @SpringBootApplication 由哪三个注解组成? |
⭐⭐⭐⭐⭐ | @SpringBootConfiguration + @EnableAutoConfiguration + @ComponentScan |
| 2 | @SpringBootConfiguration 和 @Configuration 的关系? |
⭐⭐⭐ | 前者是后者的派生注解 |
| 3 | 自动配置的原理是什么? | ⭐⭐⭐⭐⭐ | @EnableAutoConfiguration → AutoConfigurationImportSelector → 读 imports 文件 → 按 @Conditional 筛选 |
| 4 | 自动配置类在哪个文件里声明? | ⭐⭐⭐⭐ | 2.7+:AutoConfiguration.imports;2.6-:spring.factories |
| 5 | 怎么排除某个自动配置? | ⭐⭐⭐⭐ | exclude 属性 / spring.autoconfigure.exclude |
| 6 | @ComponentScan 默认扫描哪里? |
⭐⭐⭐⭐ | 启动类所在包及其子包 |
| 7 | 组件扫描不到怎么办? | ⭐⭐⭐⭐ | 启动类移到外层包 / 指定 scanBasePackages |
| 8 | @AutoConfigurationPackage 的作用? |
⭐⭐⭐ | 注册启动类所在包为“自动配置包”(JPA/MyBatis 扫描基准) |
| 9 | @MockBean 的作用? |
⭐⭐⭐ | 用 Mock 对象替换容器中的 Bean |
| 10 | 测试时怎么避免污染数据库? | ⭐⭐⭐ | 测试类加 @Transactional 自动回滚 |
1.7.10 ★ 注解底层原理(面试加分项)
前面都在讲“注解怎么用”,这一节讲“注解本身是怎么工作的”。 答出这部分内容,面试官会觉得你“不只是会写代码,还懂为什么”。 这是从 P6 到 P7 的区分点之一。
1.7.10.1 Java 元注解(5 个)
元注解 = “注解的注解”,用来定义注解的行为。
@Target(ElementType.METHOD) // ① 能用在哪
@Retention(RetentionPolicy.RUNTIME) // ② 保留到什么时候
@Documented // ③ 是否生成到 javadoc
@Inherited // ④ 是否可被继承
@Repeatable(Filters.class) // ⑤ 是否可重复使用(Java 8+)
public @interface MyAnnotation { }
① @Target:能用在哪里
| 值 | 可用位置 |
|---|---|
TYPE |
类、接口、枚举、注解 |
FIELD |
字段(包括枚举常量) |
METHOD |
方法 |
PARAMETER |
方法参数 |
CONSTRUCTOR |
构造器 |
LOCAL_VARIABLE |
局部变量 |
ANNOTATION_TYPE |
注解(元注解) |
PACKAGE |
包 |
TYPE_PARAMETER |
泛型参数(Java 8+) |
TYPE_USE |
类型使用(Java 8+) |
// 对比记忆(注意 @Autowired 和 @Resource 的 Target 差异)
@Target({CONSTRUCTOR, METHOD, PARAMETER, FIELD, ANNOTATION_TYPE})
public @interface Autowired { } // ★ 支持构造器、参数
@Target({TYPE, FIELD, METHOD})
public @interface Resource { } // ❌ 不支持构造器、参数(这就是 @Resource 不能构造器注入的原因!)
💡 面试加分: 能说出“@Resource 不支持构造器注入,是因为它的
@Target里没有CONSTRUCTOR“——这是从根上解释,比背结论强得多。
② @Retention:保留策略(★ 最重要,必考)
| 策略 | 保留到 | 能否反射读取 | 典型用例 |
|---|---|---|---|
SOURCE |
源码期(编译后丢弃) | ❌ | @Override、@SuppressWarnings、Lombok 注解 |
CLASS |
字节码期(class 文件里有,JVM 不加载) | ❌ | 字节码增强工具(AspectJ 编译期织入) |
RUNTIME |
运行期(JVM 加载,可反射读取) | ✅ | ★ Spring 所有注解 |
@Retention(RetentionPolicy.SOURCE)
public @interface Override { } // 只给编译器看,javac 后就没了
@Retention(RetentionPolicy.RUNTIME)
public @interface Autowired { } // ★ 运行时要反射解析,所以必须 RUNTIME
★ 面试问答:
Q:Spring 的注解为什么必须是
@Retention(RUNTIME)? A:因为 Spring 是在运行时通过反射去读取类/方法/字段上的注解信息的。 如果是SOURCE,编译后注解就没了;如果是CLASS,注解在 class 文件里 但 JVM 加载时不保留,Class.getAnnotations()读不到。 只有RUNTIME才能让 JVM 把注解信息保留到运行期,反射才能拿到。Q:那
@Override为什么用SOURCE? A:因为它只是给编译器做检查用的(检查方法是否真的重写了父类方法), 编译通过之后就没用了,留着只会浪费空间。
③ @Inherited:可被继承(★ 有坑)
@Inherited
@Retention(RetentionPolicy.RUNTIME)
public @interface MyAnnotation { }
@MyAnnotation
public class Parent { }
public class Child extends Parent { }
// Child.class.getAnnotation(MyAnnotation.class) → 能拿到 ✅
⚠️ 三个坑(必考):
- 只对类继承有效,对接口实现无效 —— 类实现带注解的接口,拿不到注解
- 只对类有效,对方法/字段无效 —— 子类重写父类方法,拿不到父类方法上的注解
- 只在
getAnnotation()时生效,getDeclaredAnnotation()拿不到继承的
// 坑 2 演示
class Parent {
@MyAnnotation
public void method() { }
}
class Child extends Parent {
@Override
public void method() { } // 重写了,没有注解
}
// Child.class.getMethod("method").getAnnotation(MyAnnotation.class) → null ❌
// 这就是 Spring 的 @Transactional 方法重写后要重新加注解的原因之一
// (Spring 其实做了额外处理:会去父类找,但那是 Spring 自己的逻辑,不是 @Inherited 的功劳)
④⑤ @Documented / @Repeatable
// @Documented:生成 javadoc 时包含该注解(无实际功能影响)
@Documented
public @interface MyAnnotation { }
// @Repeatable:同一位置可重复使用(Java 8+)
@Repeatable(Schedules.class)
public @interface Schedule {
String cron();
}
public @interface Schedules {
Schedule[] value();
}
// 使用
@Schedule(cron = "0 0 2 * * ?")
@Schedule(cron = "0 0 14 * * ?") // ✅ 可以重复(配合 @Scheduled 时需要注意)
public void task() { }
1.7.10.2 @AliasFor:Spring 的注解属性别名
Spring 4.2 引入,Spring 独有(JDK 没有),用于让注解属性互相别名。
public @interface AliasFor {
@AliasFor("attribute") // ★ 自己就用自己,互为别名
String value() default "";
@AliasFor("value")
String attribute() default "";
Class<? extends Annotation> annotation() default Annotation.class;
}
两种用法:
用法一:同一注解内的属性互为别名
public @interface RequestMapping {
@AliasFor("path")
String[] value() default {}; // ★ value 和 path 是别名
@AliasFor("value")
String[] path() default {};
}
// 使用:两种写法等价
@RequestMapping("/api/user")
@RequestMapping(path = "/api/user")
// ⚠️ 规则:互为别名的两个属性,值必须一致,否则报错
// @RequestMapping(value = "/a", path = "/b") ❌ 启动报错:属性冲突
用法二:为元注解的属性设置别名(★ 派生注解的核心)
@Target(ElementType.METHOD)
@Retention(RetentionPolicy.RUNTIME)
@RequestMapping(method = RequestMethod.GET) // ★ 元注解
public @interface GetMapping {
@AliasFor(annotation = RequestMapping.class, attribute = "path")
String[] value() default {}; // ★ 值传给元注解的 path
@AliasFor(annotation = RequestMapping.class, attribute = "method")
RequestMethod[] method() default {}; // 通常不用
}
// 使用
@GetMapping("/api/user")
// 等价于 @RequestMapping(value = "/api/user", method = RequestMethod.GET)
★ 这就是 @GetMapping 等派生注解的实现原理!
1.7.10.3 派生注解(组合注解)原理
@GetMapping("/api/user")
│
├─ 注解本身:@GetMapping
└─ 元注解:@RequestMapping(method = GET)
│
└─ @Target @Retention @Documented ...
Spring 用 MergedAnnotations(Spring 5.2+)解析:
· 递归读取注解及其所有元注解
· 把 @AliasFor 声明的属性值进行传递和合并
· 最终得到一个"合并后的注解视图"
MergedAnnotation<RequestMapping> merged =
MergedAnnotations.from(method).get(RequestMapping.class);
String[] paths = merged.getStringArray("path"); // "/api/user"
RequestMethod m = merged.getEnum("method", RequestMethod.class); // GET
注解演进史(了解即可):
| 版本 | API | 特点 |
|---|---|---|
| Spring 4.x | AnnotatedElementUtils |
支持元注解查找 |
| Spring 5.0 | MergedAnnotations 引入 |
更好的性能和 API |
| Spring 5.2+ | MergedAnnotations 重写 |
★ 现在用的,支持递归元注解、@AliasFor 完整解析 |
1.7.10.4 实战:自定义组合注解(★ 加分项)
需求: 团队规范要求所有事务方法必须
① 写 rollbackFor = Exception.class
② 记录操作日志
③ 设置超时
与其让每个人手写三个注解,不如封装成一个:
// ★ 自定义组合注解:一个顶三个
@Target({ElementType.METHOD, ElementType.TYPE})
@Retention(RetentionPolicy.RUNTIME)
@Documented
@Transactional(
rollbackFor = Exception.class, // ★ 统一指定回滚异常,避免有人忘写
timeout = 30 // ★ 统一超时
)
@OperateLog // ★ 自动记录操作日志
public @interface BizTransactional {
// 暴露部分属性,允许使用时覆盖
@AliasFor(annotation = Transactional.class, attribute = "propagation")
Propagation propagation() default Propagation.REQUIRED;
@AliasFor(annotation = Transactional.class, attribute = "readOnly")
boolean readOnly() default false;
@AliasFor(annotation = Transactional.class, attribute = "transactionManager")
String transactionManager() default "";
}
// 使用(简洁 + 规范统一)
@Service
public class InstructionService {
@BizTransactional // ✅ 自动带上了 rollbackFor + timeout + 日志
public Long submit(InstructionDTO dto) { ... }
@BizTransactional(readOnly = true) // 覆盖默认属性
public List<Instruction> query(Long accountId) { ... }
}
面试话术: “我们团队把事务规范固化成了
@BizTransactional组合注解, 在注解里统一声明rollbackFor = Exception.class和timeout = 30, 这样就不怕有人漏写导致受检异常不回滚了。 这是用技术手段保证规范落地,比靠 Code Review 口头提醒靠谱得多。”
⚠️ 注意: 组合注解上的
@Transactional能被 Spring 的事务拦截器识别, 因为 Spring 用MergedAnnotations递归查找元注解,只要注解链上有@Transactional就生效(不要求直接标注)。
1.7.10.5 注解 vs XML:演进与取舍
Spring 配置方式的演进
──────────────────────────────────────────────
Spring 1.x → XML 配置(一切皆 XML)
<bean id="userService" class="...">
<property name="dao" ref="userDao"/>
</bean>
Spring 2.x → 注解 + XML 混合(引入 @Component @Autowired)
Spring 3.x → JavaConfig(@Configuration @Bean)+ @ComponentScan
Spring 4.x → 条件注解 @Conditional
Spring Boot → 自动配置(约定优于配置,零 XML)
| XML | 注解 | JavaConfig | |
|---|---|---|---|
| 位置 | 独立文件 | 源码里 | 独立配置类 |
| 侵入性 | 无(改配置不改代码) | 有(要改源码) | 无 |
| 优点 | 集中管理、改完不用重编译 | 简洁、直观、就近原则 | 类型安全、可编程 |
| 缺点 | 冗长、无类型检查、易写错 | 分散、改了要重编译 | 不如注解直观 |
| 适用 | 早期项目 / 需要频繁改的配置 | 业务代码(主流) | 第三方组件装配 |
面试立场: “现在主流是注解 + JavaConfig 混合: 业务类用注解(就近原则,看代码就知道依赖什么), 第三方组件和需要条件判断的用
@Configuration+@Bean。 XML 基本只在老项目里见到了,但原理要知道—— 因为 Spring 的很多源码(比如 Dubbo、老版 MyBatis)还在用 XML 解析。”
1.7.10.6 注解原理面试题(10 题)
| # | 题目 | 难度 | 要点 |
|---|---|---|---|
| 1 | @Retention 有哪几种?Spring 注解用哪种?为什么? |
⭐⭐⭐⭐⭐ | SOURCE/CLASS/RUNTIME;Spring 用 RUNTIME(要反射读取) |
| 2 | @Target 是什么? |
⭐⭐⭐ | 限定注解可用位置;@Resource 不支持构造器就是因为 Target 不含 CONSTRUCTOR |
| 3 | @Inherited 有什么坑? |
⭐⭐⭐⭐ | 只对类继承有效;对接口实现、方法重写无效 |
| 4 | 注解的三种保留策略各举一个例子 | ⭐⭐⭐⭐ | SOURCE=@Override;CLASS=AspectJ;RUNTIME=Spring 注解 |
| 5 | @AliasFor 是什么?有什么用? |
⭐⭐⭐⭐ | Spring 独有;注解属性别名,派生注解的基础 |
| 6 | @GetMapping 是怎么实现的? |
⭐⭐⭐⭐ | 元注解 @RequestMapping(method=GET) + @AliasFor 属性传递 |
| 7 | Spring 怎么解析注解上的注解(元注解)? | ⭐⭐⭐⭐ | MergedAnnotations(Spring 5.2+)递归解析 |
| 8 | 自定义过组合注解吗?举例子 | ⭐⭐⭐⭐ | @BizTransactional = @Transactional(rollbackFor) + @OperateLog |
| 9 | 组合注解上的 @Transactional 会生效吗? | ⭐⭐⭐⭐ | 会,Spring 递归查找元注解 |
| 10 | 注解和 XML 怎么选? | ⭐⭐⭐ | 业务用注解,第三方组件用 JavaConfig,XML 基本淘汰 |
1.7.11 一页纸速查表(打印贴墙)
┌────────────────────────────────────────────────────────────────────────────┐
│ Spring 注解一页纸速查 │
├────────────────────────────────────────────────────────────────────────────┤
│ │
│ 【注入】 │
│ @Autowired byType→byName,Spring 自家,支持构造器/required/集合 │
│ @Resource byName→byType,JDK 标准,不支持构造器/required │
│ @Qualifier 消费方指定 Bean(优先级 > @Primary) │
│ @Primary 提供方标记默认实现 │
│ ★ 推荐构造器注入(final/不为null/循环依赖早暴露/单测友好) │
│ │
│ 【事务】 │
│ @Transactional(rollbackFor = Exception.class, ★ 必写!默认只回滚 │
│ propagation = REQUIRED, RuntimeException │
│ isolation = DEFAULT, │
│ timeout = 30, ★ 默认 -1 不超时 │
│ readOnly = false) │
│ REQUIRED(默认) / REQUIRES_NEW(独立事务·日志) / NESTED(保存点) │
│ ★ 失效 10 种:非public、final/static、自调用、catch吞异常、受检异常、 │
│ 未被Spring管理、MyISAM、传播行为配错、多线程、代理方式错 │
│ │
│ 【Bean】 │
│ @Component/@Service/@Repository(异常转译)/@Controller/@RestController │
│ @Bean 方法上,第三方组件唯一选择 │
│ @Configuration proxyBeanMethods=true(Full/CGLIB) false(Lite/快) │
│ @Scope singleton(默认)/prototype/request/session │
│ @Lazy @PostConstruct @PreDestroy @DependsOn @Order │
│ │
│ 【配置】 │
│ @Value("${key:default}") 单值,无松散绑定/校验 │
│ @ConfigurationProperties(prefix) 批量,支持松散绑定/嵌套/JSR303 ★推荐 │
│ @Profile("dev") @PropertySource @Import │
│ │
│ 【条件装配】 │
│ @ConditionalOnClass 类路径有 │
│ @ConditionalOnMissingBean 容器没有(★"用户没配我才配") │
│ @ConditionalOnProperty 配置满足(matchIfMissing) │
│ @ConditionalOnBean 容器有 │
│ │
│ 【Web】 │
│ @RestController = @Controller + @ResponseBody │
│ @GetMapping/PostMapping/PutMapping/DeleteMapping/PatchMapping │
│ @PathVariable 路径 / @RequestParam 查询串 / @RequestBody 请求体(只能1个) │
│ @RequestHeader 头 / @CookieValue Cookie │
│ @Valid(JDK,嵌套) vs @Validated(Spring,分组) │
│ @NotNull(不校验空串!) → String 用 @NotBlank │
│ @RestControllerAdvice + @ExceptionHandler 全局异常处理 │
│ │
│ 【AOP】顺序 │
│ Around前 → Before → 目标方法 → AfterReturning → After → Around后 │
│ 异常时:AfterThrowing + After 仍执行,AfterReturning 不执行 │
│ ★ Around 吞异常 → 事务不回滚! │
│ │
│ 【异步/定时】 │
│ @Async 默认 SimpleAsyncTaskExecutor(每次new线程!) → 必配线程池 │
│ 自调用失效 / 异常被吞(配 AsyncUncaughtExceptionHandler) │
│ @Scheduled 默认单线程!fixedRate(按开始) vs fixedDelay(按结束) │
│ 分布式多实例重复执行 → Redisson锁 / XXL-Job │
│ │
│ 【Boot】 │
│ @SpringBootApplication = @SpringBootConfiguration │
│ + @EnableAutoConfiguration │
│ + @ComponentScan(启动类所在包) │
│ 自动配置:AutoConfiguration.imports(2.7+) ← spring.factories(2.6-) │
│ │
│ 【元注解】 │
│ @Target(位置) @Retention(SOURCE/CLASS/RUNTIME★) @Documented │
│ @Inherited(仅类继承,方法/接口无效) @Repeatable │
│ @AliasFor Spring 独有,属性别名,派生注解基础 │
│ │
└────────────────────────────────────────────────────────────────────────────┘
1.7.12 本章(Spring 注解)面试题总汇总
本章共 104 题,按难度分级。带 ★ 的是“答错就扣分”的核心题。
1.7.12.1 顶级必背(20 题,⭐⭐⭐⭐⭐)
| # | 题目 | 出处 |
|---|---|---|
| 1 | @Autowired 和 @Resource 的区别? |
1.7.1.5 |
| 2 | @Transactional 默认回滚哪些异常?受检异常回滚吗? |
1.7.2.2 |
| 3 | 为什么推荐构造器注入?字段注入有什么缺点? | 1.7.1.7 |
| 4 | @Bean 和 @Component 的区别? |
1.7.3.2 |
| 5 | @Configuration 的 proxyBeanMethods 是什么? |
1.7.4.1 |
| 6 | @Value 和 @ConfigurationProperties 的区别? |
1.7.4.4 |
| 7 | @ConditionalOnMissingBean 的作用?为什么重要? |
1.7.5.3 |
| 8 | @Controller 和 @RestController 的区别? |
1.7.6.1 |
| 9 | @RequestParam 和 @RequestBody 的区别? |
1.7.6.3 |
| 10 | @Valid 和 @Validated 的区别? |
1.7.6.5 |
| 11 | REQUIRED 和 REQUIRES_NEW 的区别? |
1.7.2.3 |
| 12 | 五种通知的执行顺序? | 1.7.7.2 |
| 13 | @Around 吞掉异常会有什么后果? |
1.7.7.4 |
| 14 | @Async 默认线程池是什么?有什么问题? |
1.7.8.1 |
| 15 | 分布式部署下定时任务怎么避免重复执行? | 1.7.8.2 |
| 16 | @SpringBootApplication 由哪三个注解组成? |
1.7.9.1 |
| 17 | 自动配置的原理是什么? | 1.7.9.1 |
| 18 | @Retention 有哪几种?Spring 注解用哪种?为什么? |
1.7.10.1 |
| 19 | singleton Bean 线程安全吗? | 1.7.3.3 |
| 20 | @Component/@Service/@Repository/@Controller 的区别? |
1.7.3.1 |
1.7.12.2 重点(44 题,⭐⭐⭐⭐)
| # | 题目 | # | 题目 |
|---|---|---|---|
| 21 | @Autowired 的注入流程?找到多个怎么办? |
43 | @Order 控制的是什么顺序? |
| 22 | @Resource 的注入流程? |
44 | 配置不存在时 @Value 会怎样? |
| 23 | 一个接口多个实现类怎么指定注入哪个? | 45 | 什么是松散绑定? |
| 24 | 构造器循环依赖能解决吗?为什么? | 46 | @Import 有哪几种用法? |
| 25 | Spring 三级缓存解决的是什么循环依赖? | 47 | 为什么自定义 ObjectMapper 后 Boot 默认的不生效? |
| 26 | 静态字段为什么注入不进去?怎么解决? | 48 | @ConditionalOnClass 和 @ConditionalOnBean 区别? |
| 27 | @Autowired 注入 List/Map 是什么效果? |
49 | 怎么查看哪些自动配置生效了? |
| 28 | prototype 注入 singleton 还是多例吗? | 50 | Spring Boot 3 的自动配置文件和 2.x 有什么不同? |
| 29 | 循环依赖有哪些解法? | 51 | 自己写过 starter 吗?讲讲结构 |
| 30 | @Repository 有什么特殊能力? |
52 | @RequestMapping 有哪些属性? |
| 31 | Spring Bean 的作用域有哪些? | 53 | @PathVariable 和 @RequestParam 的区别? |
| 32 | Bean 的生命周期是怎样的? | 54 | 一个方法能写两个 @RequestBody 吗? |
| 33 | @PostConstruct 在什么时候执行? |
55 | 怎么校验 @RequestParam 参数? |
| 34 | 三种初始化方式的区别和执行顺序? | 56 | 嵌套对象怎么校验? |
| 35 | AOP 代理在生命周期的哪一步生成? | 57 | @NotNull 和 @NotBlank 的区别? |
| 36 | 为什么默认不回滚受检异常?怎么解决? | 58 | 校验失败怎么统一返回? |
| 37 | NESTED 和 REQUIRES_NEW 的区别? |
59 | @ControllerAdvice 有哪几种用法? |
| 38 | 什么时候用 REQUIRES_NEW? |
60 | 前端收到 Long 类型精度丢失怎么办? |
| 39 | readOnly=true 有什么作用? |
61 | 抛异常时哪些通知还会执行? |
| 40 | timeout 默认是多少?生产要注意什么? |
62 | 多个切面怎么控制顺序? |
| 41 | 事务注解加在哪一层?为什么? | 63 | @Async 自调用会生效吗? |
| 42 | 事务方法里能不能调用远程接口? | 64 | @Async 的异常怎么捕获? |
1.7.12.3 基础(40 题,⭐⭐⭐)
| # | 题目 | # | 题目 |
|---|---|---|---|
| 65 | @Qualifier 和 @Primary 同时存在听谁的? |
85 | @CrossOrigin 怎么用? |
| 66 | @Autowired 的 required=false 有什么用? |
86 | @ResponseStatus 有什么用? |
| 67 | @Inject 和 @Autowired 的区别? |
87 | @Around 忘记 proceed() 会怎样? |
| 68 | 只有一个构造器时 @Autowired 能省略吗? |
88 | execution 表达式怎么写? |
| 69 | @Resource 能用在构造器上吗? |
89 | 怎么匹配“带某注解的方法”? |
| 70 | 怎么注入一个可选依赖? | 90 | AOP 自调用为什么会失效? |
| 71 | 字段注入有什么缺点? | 91 | 你用 AOP 做过什么? |
| 72 | Full 模式和 Lite 模式怎么选? | 92 | @Async 方法怎么拿返回值? |
| 73 | @Configuration 和 @Component 的区别? |
93 | 怎么指定 @Async 用哪个线程池? |
| 74 | ${} 和 #{} 的区别? |
94 | @Async 会丢失什么上下文? |
| 75 | @Value 能注入 List 吗? |
95 | @Scheduled 默认是单线程吗? |
| 76 | @Profile 怎么用? |
96 | 优雅停机时异步任务怎么处理? |
| 77 | @PropertySource 支持 yml 吗? |
97 | @SpringBootConfiguration 和 @Configuration 的关系? |
| 78 | 怎么让自定义配置在 IDE 里有提示? | 98 | 怎么排除某个自动配置? |
| 79 | @EnableTransactionManagement 能省略吗? |
99 | @ComponentScan 默认扫描哪里? |
| 80 | proxyTargetClass 是什么?默认多少? |
100 | 组件扫描不到怎么办? |
| 81 | noRollbackFor 用在什么场景? |
101 | @AutoConfigurationPackage 的作用? |
| 82 | 事务注解在 private 方法上生效吗? | 102 | @MockBean 的作用? |
| 83 | @Lazy 有什么用? |
103 | 测试时怎么避免污染数据库? |
| 84 | @DependsOn 的作用? |
104 | @Inherited 有什么坑? |
1.7.12.4 ★ 开放性大题(3 道,练口述)
大题 1:「说说你熟悉的 Spring 注解」
❌ 错误答法(报菜名):
"我用过 @Autowired、@Service、@Transactional、@RequestMapping..."
✅ 正确答法(分层 + 带坑点,控制在 2 分钟):
"我按功能分几类说:
第一类是依赖注入。@Autowired 和 @Resource 最常用,
区别是注入顺序相反——@Autowired 默认按类型,@Resource 默认按名称。
我项目里构造器注入用 @Autowired,字段注入用 @Resource,
因为按名字找更直观,IDEA 也不会报警告。
第二类是事务。@Transactional,这个有个必须注意的坑:
它默认只回滚 RuntimeException,受检异常不回滚,
所以我们团队规范是必须显式写 rollbackFor = Exception.class,
我还把它固化成了组合注解 @BizTransactional,从技术上保证不会漏。
第三类是 Bean 声明。@Component 及其衍生,
还有 @Configuration + @Bean 用来装配第三方组件。
@Configuration 有个 proxyBeanMethods 属性,
默认 true 会走 CGLIB 代理保证 @Bean 方法调用返回单例,
设成 false 能提升启动速度,Boot 的自动配置类都设成了 false。
第四类是 Web 层。@RestController、@GetMapping、
参数绑定的 @PathVariable/@RequestParam/@RequestBody,
还有校验用的 @Validated——它能做分组校验,@Valid 不行。
第五类是异步和定时。@Async 和 @Scheduled,
这两个都有坑:@Async 默认线程池每次新建线程,必须自定义;
@Scheduled 默认单线程,而且分布式部署会重复执行,
我们生产用的是 XXL-Job。
另外我还用过条件注解做过内部 starter,
核心是 @ConditionalOnMissingBean 实现'用户没配我才配'。"
大题 2:「@Transactional 为什么失效了?你怎么排查?」
回答框架(四步法):
第一步:确认是否走了代理
· Arthas 看 class-info:有没有 $$EnhancerBySpringCGLIB$$ 或 $Proxy
· 或者 trace 方法调用链:有没有 TransactionInterceptor
· 没有代理 → 检查:有没有 @Service?是不是 final?是不是自调用?方法是不是 public?
第二步:检查注解配置
· rollbackFor 写了吗?抛的是不是受检异常?
· 异常是不是被 catch 吞了(包括被 AOP 切面吞了)?
· 传播行为对不对(配了 NOT_SUPPORTED 就主动不用事务)?
第三步:检查运行环境
· 数据库引擎是 InnoDB 吗(MyISAM 不支持事务)?
· 多数据源是不是用错了 transactionManager?
· 是不是多线程调用(事务上下文在 ThreadLocal,跨线程丢失)?
第四步:上手段定位
· 开 DEBUG 日志看事务开启/提交/回滚
· Arthas watch 监控 DataSourceTransactionManager 的 doBegin/doCommit/doRollback
· 用 tt 记录偶发问题的调用,回放对比
(详细的排查命令见【专题四:线上事务失效排查实战】)
大题 3:「讲一个你踩过的 Spring 相关的坑」(STAR 结构)
【Situation 情境】
项目一资产托管系统,指令提交接口偶发"扣了库存但订单没创建"的数据不一致。
【Task 任务】
我负责排查并修复这个问题。
【Action 行动】
1. 先加日志,发现异常确实抛了,但事务没回滚
2. 看代码发现:@Transactional 没写 rollbackFor,
而抛的是自定义的 BizException extends Exception(受检异常)
→ 默认不回滚,事务正常提交
3. 修复:改成 BizException extends RuntimeException,
并且全局搜索所有 @Transactional,统一加上 rollbackFor = Exception.class
4. 为防止再犯:封装 @BizTransactional 组合注解,
内部固化 rollbackFor = Exception.class + timeout = 30
5. 加了一道防线:T+1 对账任务,比对订单表和库存流水,发现差异告警
【Result 结果】
修复后该类问题归零。更重要的是把规范固化到了代码层面
(组合注解 + ArchUnit 单测校验),不依赖人的自觉性。
这个组合注解后来推广到了团队其他项目。
1.7.12.5 复习检查清单
背完本章后,对照检查能不能做到:
□ 能说出 @Autowired 和 @Resource 的 5 点区别(含注入顺序)
□ 能说出构造器注入的 4 个优势
□ 能画出 @Autowired 的注入查找流程
□ 能背出 @Transactional 的 6 个常用属性及默认值
□ 能说出 rollbackFor 的坑及解决方案
□ 能区分 REQUIRED / REQUIRES_NEW / NESTED
□ 能说出事务失效的 10 种场景
□ 能说出 @Configuration 的 Full/Lite 模式区别
□ 能说出 @Value 和 @ConfigurationProperties 的 6 点区别
□ 能说出 @ConditionalOnMissingBean 的作用
□ 能说出 @Valid 和 @Validated 的区别(分组 vs 嵌套)
□ 能背出 AOP 五种通知的执行顺序(正常 + 异常)
□ 能说出 @Async 的三大坑
□ 能说出 @Scheduled 的两大坑(单线程 + 分布式重复)
□ 能拆开 @SpringBootApplication 的三个注解
□ 能说出 @Retention 三种策略及 Spring 用哪种
□ 能说出 @Inherited 的坑
□ 能讲出自已踩过的至少一个 Spring 坑(STAR)
□ 被追问三轮还能答得上来
□ 能默画 1.7.0 的注解全景图(至少 5 个分类)
本章小结: 注解是 Spring 最表层的东西,但恰恰是面试最容易翻车的地方—— 因为面试官默认“你每天都在写,不可能不会”。 越是基础的,答错代价越大。
复习建议:先背 1.7.11 的一页纸速查表(这是骨架), 再过 1.7.12.1 的 20 道五星题(这是血肉), 最后用 1.7.12.4 的三道大题练口述(这是临场发挥)。 重点是每个注解都要能说出一个坑,这才是区分度。
二、Spring Cloud Alibaba 全家桶
你简历的使用场景: 零碳能源云平台微服务底座(Nacos + Sentinel),项目四社区平台(Spring Cloud + RocketMQ)
面试官心理: “你说搭建了微服务底座,Nacos 注册发现原理讲讲?Sentinel 限流怎么做的?”
2.1 Nacos 服务注册与配置中心
小白讲解
Nacos = Naming and Configuration Service,干两件事:
- 服务注册发现(黄页电话簿):每个微服务启动时到 Nacos 登记“我叫 order-service,住在 192.168.1.10:8080”,消费方需要调用时查黄页获取地址
- 动态配置管理(小区公告栏):配置统一放 Nacos 管理,改了配置不用重启服务,自动推送
服务注册原理
服务提供者启动流程:
1. 服务启动 → Nacos Client 发起注册请求
2. Nacos Server 收到 → 写入注册表(ConcurrentHashMap)
3. Server 返回成功 → Client 开启心跳任务(5 秒一次)
4. Client 定时发送心跳 → Server 更新实例最后心跳时间
5. 超过 15 秒无心跳 → Server 标记为不健康
6. 超过 30 秒无心跳 → Server 从注册表移除
服务消费者流程:
1. 启动时拉取服务列表 → 本地缓存
2. 定时(10 秒)拉取最新服务列表 → 更新本地缓存
3. 调用时从本地缓存获取实例列表 → 负载均衡选一个
临时实例 vs 持久实例
| 维度 | 临时实例(默认) | 持久实例 |
|---|---|---|
| 注册方式 | Client 主动注册 | Server 端 API 注册 |
| 心跳 | Client 定时发 | Server 主动探测(TCP HTTP) |
| 下线 | 30 秒无心跳自动移除 | 需主动删除 |
| 适用 | 微服务应用 | 数据库、缓存等基础设施 |
代码示例
# application.yml
spring:
cloud:
nacos:
discovery:
server-addr: 127.0.0.1:8848
namespace: dev # 命名空间隔离
group: DEFAULT_GROUP
config:
server-addr: 127.0.0.1:8848
file-extension: yaml # 配置文件后缀
namespace: dev
// 服务注册(自动完成,加注解即可)
@SpringBootApplication
@EnableDiscoveryClient
public class OrderServiceApp {
public static void main(String[] args) {
SpringApplication.run(OrderServiceApp.class, args);
}
}
// 服务发现 + 负载均衡调用
@RestController
public class OrderController {
@LoadBalanced // 开启负载均衡
@Bean
public RestTemplate restTemplate() {
return new RestTemplate();
}
@Autowired
private RestTemplate restTemplate;
@GetMapping("/order/{id}")
public String getOrder(@PathVariable Long id) {
// 用服务名替代 IP:Port,Ribbon 自动负载均衡
return restTemplate.getForObject(
"http://user-service/user/" + id, String.class);
}
}
配置动态刷新
@RestController
@RefreshScope // 配置变更后自动刷新
public class ConfigController {
@Value("${order.timeout:3000}")
private int timeout; // Nacos 改了配置,这里自动更新
@GetMapping("/timeout")
public int getTimeout() {
return timeout;
}
}
面试题
Q1:Nacos 的注册表是怎么存储的?
Nacos Server 使用
ConcurrentHashMap双层结构存储注册表:
- 外层 Key =
namespace@@group@@serviceName(如public@@DEFAULT_GROUP@@order-service)- 外层 Value = 内层
ConcurrentHashMap- 内层 Key =
ip:port,Value =Instance对象(包含 IP、端口、权重、元数据、心跳时间等)使用
ConcurrentHashMap保证并发安全,读多写少场景下性能好。Nacos 还通过 CopyOnWrite 和异步队列实现服务变更通知。
Q2:Nacos 是 AP 还是 CP?
Nacos 默认是 AP 模式(临时实例),通过 Distro 协议实现最终一致。也支持切换为 CP 模式(持久实例),通过 Raft 协议实现强一致。
AP 模式:服务节点数据不需要强一致,可用性优先(注册快、查询快) CP 模式:配置数据需要强一致,一致性优先
微服务场景一般用 AP(可用性比强一致更重要),配置管理用 CP(配置不能出错)。
Nacos 配置层级模型(面试重点)
Nacos 配置管理采用三层隔离模型:
Namespace(命名空间)—— 环境隔离(dev / test / prod)
└── Group(分组)—— 业务/项目隔离(DEFAULT_GROUP / ORDER_GROUP / PAY_GROUP)
└── Data ID(配置集)—— 具体配置文件(order-service.yaml / order-service-dev.yaml)
└── 配置内容(YAML / Properties / JSON)
命名空间设计建议:
| 命名空间 | 用途 | 说明 |
|---|---|---|
dev |
开发环境 | 开发人员共用,配置随意改 |
test |
测试环境 | 测试团队使用,配置需审批 |
prod |
生产环境 | 生产配置,严格权限控制 |
public(默认) |
公共配置 | 所有环境共享的配置 |
Data ID 命名规则:
${prefix}-${spring.profiles.active}.${file-extension}
# 示例:
order-service-dev.yaml → 开发环境配置
order-service-prod.yaml → 生产环境配置
order-service.yaml → 默认配置(无 profile 时加载)
配置加载优先级(从高到低):
1. ${prefix}-${profile}.${extension} # 精确匹配 profile
2. ${prefix}.${extension} # 无 profile 的默认配置
3. application.yml(本地) # 本地配置兜底
Nacos 集群架构
┌─────────────────────────────┐
│ Nacos Cluster │
│ │
┌───────────────┼──────────┬──────────┬────────┼───────────────┐
│ │ │ │ │ │
▼ ▼ ▼ ▼ ▼ ▼
┌────────┐ ┌────────┐ ┌────────┐ ┌────────┐ ┌────────┐ ┌────────┐
│ Node 1 │◄──►│ Node 2 │◄►│ Node 3 │◄►│ Node 4 │◄►│ Node 5 │ │ MySQL │
│(Leader)│ │(Follower│ │(Follower│ │(Follower│ │(Follower│ │ (集群) │
│ Raft │ │ Raft │ │ Raft │ │ Distro│ │ Distro│ │ │
└────────┘ └────────┘ └────────┘ └────────┘ └────────┘ └────────┘
│ │ │ │ │
│ Raft 协议(CP) │ Distro 协议(AP) │
│ 配置数据 / 持久实例 │ 临时服务实例 │
│ 过半写成功才算写入 │ 各节点独立处理,异步同步 │
└──────────────────────────└──────────┴───────────┘
两种数据同步协议对比:
| 维度 | Distro(AP) | Raft(CP) |
|---|---|---|
| 数据类型 | 临时实例(服务注册) | 配置数据 / 持久实例 |
| 一致性 | 最终一致 | 强一致 |
| 选举 | 无 Leader,各节点平等 | 有 Leader,过半选举 |
| 写入 | 任意节点可写,异步同步 | 只有 Leader 可写,过半确认 |
| 可用性 | 节点挂了不影响注册 | Leader 挂了需重新选举(短暂不可用) |
| 场景 | 服务发现(可用性优先) | 配置管理(一致性优先) |
Nacos 2.x 长连接机制
Nacos 2.x 用 gRPC 长连接替代了 1.x 的 HTTP 短连接:
| 维度 | Nacos 1.x(HTTP) | Nacos 2.x(gRPC) |
|---|---|---|
| 连接方式 | HTTP 短连接 + 定时心跳 | gRPC 长连接(双向流) |
| 心跳 | Client 每 5 秒发 HTTP 心跳 | 连接保持即代表存活,无需心跳 |
| 配置变更 | Client 每 30 秒长轮询拉取 | Server 主动推送(毫秒级) |
| 资源消耗 | 连接频繁创建销毁,CPU 高 | 长连接复用,资源消耗低 |
| 故障感知 | 15-30 秒才发现实例下线 | 连接断开立即感知(秒级) |
// Nacos 2.x 自动使用 gRPC,无需额外配置
// 只需确保依赖版本 >= 2.x
spring:
cloud:
nacos:
discovery:
server-addr: 127.0.0.1:8848
# 2.x 自动走 gRPC(端口 9848,由 8848+1000 自动计算)
实战:多环境配置隔离
# bootstrap.yml(必须用 bootstrap,优先级高于 application)
spring:
application:
name: order-service
profiles:
active: dev # 当前环境
cloud:
nacos:
config:
server-addr: 192.168.1.100:8848
namespace: ${NACOS_NAMESPACE:dev} # 环境变量注入命名空间 ID
group: ORDER_GROUP
file-extension: yaml
# 共享配置(多个微服务共用的配置)
shared-configs:
- data-id: common-db.yaml # 数据库公共配置
group: COMMON_GROUP
refresh: true # 支持动态刷新
- data-id: common-redis.yaml # Redis 公共配置
group: COMMON_GROUP
refresh: true
# 扩展配置(优先级低于主配置)
extension-configs:
- data-id: order-special.yaml
group: ORDER_GROUP
refresh: true
配置加载优先级(从高到低):
主配置 (order-service-dev.yaml)
> 扩展配置 (extension-configs,按数组顺序,后面的优先级高)
> 共享配置 (shared-configs)
> application.yml(本地)
Nacos 配置灰度发布(面试高频)
什么是灰度发布?
灰度发布 = 改了配置后,先让一部分机器生效,观察没问题再逐步扩大到全部机器。
类比:不是一次性把灯全打开,而是先开一盏,看看会不会跳闸,没问题再全开。
假设你有 10 台机器(IP: 192.168.1.101 ~ 110),要把超时时间从 3000ms 改成 5000ms:
- 不灰度:直接改 Nacos 配置 → 10 台同时生效 → 如果新配置有问题,10 台全炸
- 灰度:先只让 101、102 两台生效 → 观察 30 分钟没问题 → 再推给全部 10 台 → 有问题只有 2 台受影响
方式一:Nacos 自带 Beta 发布(最简单,不改代码)
在 Nacos 控制台操作:
1. 进入配置管理 → 编辑配置
2. 修改超时时间: 3000 → 5000
3. 不点"发布",点"Beta 发布"
4. 填入灰度 IP: 192.168.1.101, 192.168.1.102
5. 点击确认
此时:
192.168.1.101 → 收到新配置 (timeout=5000) ← 灰度机器
192.168.1.102 → 收到新配置 (timeout=5000) ← 灰度机器
192.168.1.103~110 → 仍然用旧配置 (timeout=3000) ← 正式机器
6. 观察 30 分钟,101 和 102 运行正常
7. 点击"正式发布" → 全部 10 台生效
适用场景:临时验证某个配置改动,不需要改代码,操作完即走。
方式二:Group 隔离(最常用,适合多环境管理)
在 Nacos 中创建两个 Group,灰度机器和正式机器读不同 Group:
Namespace: prod
├── Group: DEFAULT_GROUP → 正式配置(8 台机器读这里)
│ └── order-service.yaml → timeout: 3000
└── Group: GRAY_GROUP → 灰度配置(2 台机器读这里)
└── order-service.yaml → timeout: 5000
正式机器的 application.yml:
spring:
cloud:
nacos:
config:
server-addr: 192.168.1.50:8848
namespace: prod
group: DEFAULT_GROUP # 正式组
data-id: order-service.yaml
灰度机器的 application.yml(只改 group):
spring:
cloud:
nacos:
config:
server-addr: 192.168.1.50:8848
namespace: prod
group: GRAY_GROUP # 灰度组
data-id: order-service.yaml
灰度流程:
1. 在 Nacos 中创建 GRAY_GROUP,复制 DEFAULT_GROUP 的配置
2. 修改 GRAY_GROUP 中的 timeout: 3000 → 5000
3. 192.168.1.101 和 102 重启(或热刷新)→ 读取 GRAY_GROUP 配置
4. 其他 8 台不受影响,仍然读 DEFAULT_GROUP
5. 验证 OK → 把 GRAY_GROUP 的配置同步到 DEFAULT_GROUP
6. 101 和 102 改回 DEFAULT_GROUP
适用场景:灰度周期较长(几天甚至几周),需要独立管理灰度配置。
方式三:配置开关 + 代码判断(最灵活,秒级回滚)
在 Nacos 配置里加一个开关,代码里根据开关决定走新逻辑还是旧逻辑:
Nacos 配置 order-service.yaml:
feature:
new-timeout:
enabled: false # 默认关闭,所有机器走旧逻辑
timeout-ms: 5000 # 新超时时间
代码监听配置变更:
@RestController
@RefreshScope
public class OrderConfig {
@Value("${feature.new-timeout.enabled:false}")
private boolean newTimeoutEnabled;
@Value("${feature.new-timeout.timeout-ms:3000}")
private int newTimeoutMs;
@Value("${order.timeout:3000}")
private int defaultTimeout;
// 对外暴露当前生效的超时时间
public int getEffectiveTimeout() {
return newTimeoutEnabled ? newTimeoutMs : defaultTimeout;
}
}
灰度流程:
1. 所有 10 台机器 (101~110) 启动时读取 enabled=false → 全部走旧逻辑 (3000ms)
2. 在 Nacos 控制台修改配置:
feature.new-timeout.enabled: false → true
↓
Nacos 2.x 通过 gRPC 长连接实时推送到全部 10 台机器
↓
@RefreshScope 自动刷新,newTimeoutEnabled 变成 true
3. 此时所有机器都走新逻辑 (5000ms)
如果只想灰度 2 台?结合方式二:
- GRAY_GROUP 的配置: enabled=true
- DEFAULT_GROUP 的配置: enabled=false
- 101、102 读 GRAY_GROUP → 走新逻辑
- 103~110 读 DEFAULT_GROUP → 走旧逻辑
4. 出问题了?在 Nacos 把 enabled 改回 false → 秒级回滚,不用重新部署
适用场景:需要代码层面控制新旧逻辑切换,且要求秒级回滚能力。
三种方式对比
| 维度 | Beta 发布 | Group 隔离 | 配置开关 |
|---|---|---|---|
| 改代码 | 不需要 | 不需要 | 需要提前埋开关 |
| 操作方式 | 控制台点按钮 | 改机器配置的 group | 改 Nacos 配置项 |
| 灰度粒度 | 按 IP | 按机器组 | 全量或按 Group 组合 |
| 回滚速度 | 改回旧配置正式发布 | 切回 DEFAULT_GROUP | 改 false,秒级 |
| 适用场景 | 临时验证 | 长周期灰度 | 新旧逻辑切换 |
| 生产推荐 | ⭐⭐ | ⭐⭐⭐ | ⭐⭐⭐ |
生产环境最常见的组合:Group 隔离 + 配置开关。Group 控制哪些机器是灰度,开关控制新旧逻辑切换,两者配合既能灰度又能秒级回滚。
组合实战:订单超时时间灰度调整
业务场景:原订单超时时间 3000ms,新需求要改成 5000ms + 新的失败重试逻辑。不能一次性切,先灰度 2 台验证。
第一步:Nacos 控制台创建两份配置
# Data ID: order-service.yaml
# Group: DEFAULT_GROUP → 8 台正式机器读这个
order:
timeout: 3000
retry:
enabled: false
maxAttempts: 1
feature:
new-timeout-logic:
enabled: false # 正式机器:新逻辑关闭
# Data ID: order-service.yaml
# Group: GRAY_GROUP → 2 台灰度机器读这个
order:
timeout: 5000
retry:
enabled: true
maxAttempts: 3
feature:
new-timeout-logic:
enabled: true # 灰度机器:新逻辑开启
两个 Data ID 相同,但 Group 不同——Nacos 允许同名配置存在于不同 Group 中。
第二步:机器配置(决定读哪个 Group)
# === 192.168.1.101、192.168.1.102(灰度机器)application.yml ===
spring:
cloud:
nacos:
config:
server-addr: 192.168.1.50:8848
namespace: prod
group: GRAY_GROUP # ← 关键:读灰度配置
file-extension: yaml
# === 192.168.1.103 ~ 110(正式机器)application.yml ===
spring:
cloud:
nacos:
config:
server-addr: 192.168.1.50:8848
namespace: prod
group: DEFAULT_GROUP # ← 关键:读正式配置
file-extension: yaml
第三步:业务代码(根据开关走新旧逻辑)
@RestController
@RefreshScope // Nacos 配置变更后自动重建 Bean,timeout 和 enabled 实时更新
public class OrderController {
@Value("${order.timeout:3000}")
private int timeout;
@Value("${order.retry.enabled:false}")
private boolean retryEnabled;
@Value("${order.retry.maxAttempts:1}")
private int maxAttempts;
@Value("${order.feature.new-timeout-logic.enabled:false}")
private boolean newLogicEnabled; // 灰度开关
@GetMapping("/order/{id}")
public Result<Order> queryOrder(@PathVariable Long id) {
if (newLogicEnabled) {
// —— 新逻辑(灰度机器走这里)——
// 超时 5000ms + 失败重试 3 次
return orderService.queryWithRetry(id, timeout, maxAttempts);
} else {
// —— 旧逻辑(正式机器走这里)——
// 超时 3000ms + 不重试
return orderService.query(id, timeout);
}
}
}
第四步:灰度发布流程
时间轴 操作 效果
─────────────────────────────────────────────────────────────────────────
T+0min Nacos 控制台创建 GRAY_GROUP 配置 灰度配置就绪
T+0min 101、102 机器启动(配了 group: GRAY_GROUP) 2 台读到新配置
T+0min 103~110 机器不受影响 8 台仍用旧配置
↓
T+0~30min 观察 101、102 的日志、监控、错误率 灰度验证
↓
情况A:没问题 ✅
→ 把 GRAY_GROUP 的配置同步到 DEFAULT_GROUP
→ 8 台正式机器秒级收到推送,新逻辑全部生效
→ 灰度完成
↓
情况B:有问题 ❌(比如错误率上升)
→ 把 GRAY_GROUP 里 enabled 改成 false
→ 101、102 秒级切回旧逻辑
→ 不用重启、不用重新部署、不用改代码
→ 排查问题后再次开启
第五步:如果灰度验证通过,全量切换
在 Nacos 控制台把 DEFAULT_GROUP 的配置改成和 GRAY_GROUP 一样:
# DEFAULT_GROUP / order-service.yaml(修改后)
order:
timeout: 5000 # 3000 → 5000
retry:
enabled: true # false → true
maxAttempts: 3 # 1 → 3
feature:
new-timeout-logic:
enabled: true # false → true
8 台正式机器通过 Nacos 长连接秒级收到推送,@RefreshScope 自动重建 Bean,newLogicEnabled 变成 true——全量生效。
为什么用组合而不是单独用一种?
- 单用 Group 隔离:灰度机器和正式机器配置不同,但代码里没有开关,切回旧逻辑要改配置再推送,不够快
- 单用配置开关:所有机器读同一份配置,改
enabled=true后 10 台同时生效,没有灰度过程- 组合用:Group 控制灰度范围(先 2 台),开关控制逻辑切换(秒级开关),两者配合 = 既安全又快速
代码监听配置变更(通用)
无论哪种方式,代码侧都可以监听配置变更做自定义处理:
// 方式1:@NacosConfigListener(Nacos 原生注解)
@NacosConfigListener(dataId = "order-service.yaml", groupId = "ORDER_GROUP")
public void onConfigChange(String config) {
log.info("配置变更: {}", config);
// 重新初始化连接池等
refreshDataSource();
}
// 方式2:@RefreshScope + @Value(Spring Cloud 原生,最常用)
@RestController
@RefreshScope // 配置变更后自动重建 Bean
public class OrderController {
@Value("${order.timeout:3000}")
private int timeout; // Nacos 配置变更后,这个值会自动更新
@GetMapping("/order/{id}")
public Result getOrder(@PathVariable Long id) {
// 直接用最新的 timeout 值,无需重启
return orderService.query(id, timeout);
}
}
// 方式3:ConfigService 主动监听(最底层,灵活度最高)
@PostConstruct
public void initListener() throws NacosException {
ConfigService configService = NacosFactory.createConfigService(
"192.168.1.50:8848"
);
configService.addListener("order-service.yaml", "ORDER_GROUP",
new Listener() {
@Override
public void receiveConfigInfo(String config) {
log.info("收到配置变更: {}", config);
// 手动解析 JSON 并刷新本地缓存
refreshLocalConfig(config);
}
@Override
public Executor getExecutor() {
return Executors.newSingleThreadExecutor();
}
}
);
}
面试题
Q3:Nacos 和 Eureka 的区别?
| 维度 | Nacos | Eureka |
|---|---|---|
| CAP | AP + CP 可切换 | AP |
| 注册方式 | Client 推送 + Server 推拉 | Client 推送 |
| 健康检查 | 心跳 + TCP/HTTP 探测 | 心跳 |
| 配置中心 | 支持 | 不支持 |
| 一致性协议 | Distro(AP) + Raft(CP) | P2P 复制 |
| 社区活跃度 | 活跃(阿里维护) | 停更(2.x 后闭源) |
Q4:Nacos 集群中一个节点宕机了会怎样?
分两种情况:
临时实例(Distro/AP): 每个节点都保存全量数据,任意节点宕机不影响注册和发现。Client 会自动重连其他健康节点。
配置数据(Raft/CP): 如果宕机的是 Follower,不影响读写(Leader 还在)。如果宕机的是 Leader,Raft 会触发重新选举(随机超时机制,150-300ms),选举期间集群短暂不可写(约 1-2 秒),但读不受影响。选举完成后恢复。
生产环境建议至少 3 个节点,保证 Raft 协议的过半机制可用(3 节点容忍 1 个宕机,5 节点容忍 2 个)。
Q5:Nacos 的配置变更怎么推送到客户端?
Nacos 1.x: 客户端通过长轮询(Long Polling)拉取配置。客户端发起 HTTP 请求,Server 端 hold 住 30 秒,如果期间配置没变就 30 秒后返回空,如果变了立即返回变更的 Data ID。客户端再发请求拉取具体配置内容。
Nacos 2.x: 使用 gRPC 长连接,Server 端配置变更后直接通过 gRPC 流推送到客户端,延迟从 30 秒降到毫秒级。
Q6:Namespace、Group、Data ID 怎么设计?
- Namespace: 按环境隔离,dev / test / prod / uat
- Group: 按业务线或项目隔离,ORDER_GROUP / PAY_GROUP / COMMON_GROUP
- Data ID: 按微服务隔离,
服务名-profile.后缀,如order-service-prod.yaml公共配置(数据库、Redis 等所有服务共用的)放 COMMON_GROUP,通过
shared-configs引用。
Q7:Nacos 2.x 为什么用 gRPC 替代 HTTP?
三个核心原因:
- 降低资源消耗: HTTP 短连接频繁创建/销毁,大量心跳请求导致 CPU 和文件描述符消耗高;gRPC 基于长连接,连接复用,资源消耗低
- 提升实时性: 1.x 长轮询有 30 秒延迟,2.x gRPC 流式推送毫秒级到达
- 统一协议: 2.x 统一用 gRPC 做注册发现和配置推送,简化了实现
Q8:Nacos 怎么做配置灰度发布?
我们生产环境用的是 Group 隔离 + 配置开关 的组合方式。
假设有 10 台机器(192.168.1.101 ~ 110),要把超时时间从 3000ms 改成 5000ms:
- 在 Nacos 的 prod 命名空间下创建
GRAY_GROUP,复制DEFAULT_GROUP的配置并修改 timeout=5000- 灰度机器(101、102)的
application.yml配置group: GRAY_GROUP,其余 8 台仍读DEFAULT_GROUP- 配置里同时埋一个开关
feature.new-timeout.enabled,GRAY 组设 true,DEFAULT 组设 false- 代码用
@RefreshScope + @Value监听开关,true 走新逻辑,false 走旧逻辑- 观察 30 分钟没问题 → 把 GRAY_GROUP 配置同步到 DEFAULT_GROUP → 全部生效
- 出问题?改回
enabled=false秒级回滚,不用重新部署如果只是临时验证一个小改动,也可以用 Nacos 控制台自带的 Beta 发布——直接指定灰度 IP 列表(如 192.168.1.101,192.168.1.102),不用改代码也不用改 Group。
Q9:@RefreshScope 的原理是什么?配置变更后 Bean 是怎么更新的?
@RefreshScope会使 Bean 被代理,代理对象持有一个RefreshScopedProxyBean。当 Nacos 配置变更时:
- Spring Cloud 收到
RefreshEvent事件ContextRefresher调用Environment.getPropertySources()更新配置源- 广播
EnvironmentChangeEvent,所有@ConfigurationProperties的 Bean 自动重新绑定@RefreshScope的 Bean 被标记为 stale(过期)- 下次访问该 Bean 时,代理对象检测到过期 → 销毁旧实例 → 创建新实例(用最新配置值)
注意:
@RefreshScope只对@ConfigurationProperties和@Value生效,@NacosConfigListener是 Nacos 自己的监听机制,不走 Spring Cloud 的 Refresh 流程。
2.2 Sentinel 流控熔断降级(简历项目三直接写了)
小白讲解
Sentinel 就像地铁站的客流控制系统:
- 流控(限流):地铁站高峰期限制每分钟进多少人——超过阈值就排队等候或直接拒绝
- 熔断:某条线路频繁出故障,暂时关掉这条线让大家改乘——下游服务异常率达到阈值,暂时停止调用
- 降级:高峰期关掉部分非核心功能(如关掉电梯只走楼梯)——非核心功能超时或异常时返回兜底数据
- 热点参数限流:某个大V的帖子流量太大,单独限制——针对特定参数值限流
- 系统自适应限流:根据整台机器的 CPU、Load 自动调整——地铁站根据整体拥挤程度自动限流
核心概念
| 概念 | 说明 | 类比 |
|---|---|---|
| 资源(Resource) | 需要保护的代码/方法/接口 | 地铁入口 |
| 规则(Rule) | 流控/熔断/降级/系统规则 | 限流策略 |
| 槽位(Slot) | SlotChain 处理链,每个槽负责一类功能 | 安检流程的每道关卡 |
| 上下文(Context) | 调用链上下文,标识一次调用入口 | 乘客的行程信息 |
| Entry | 资源访问的入口,每次访问创建 | 进站刷卡 |
Sentinel 处理链(SlotChain)——核心架构
一个请求进来,依次经过这些 Slot(每个 Slot 负责一件事):
请求 → ┌──────────────────────────────────────────────────────┐
│ NodeSelectorSlot → 构建 Context 树(调用链路) │
│ ClusterBuilderSlot → 构建集群节点(统计来源维度的数据) │
│ StatisticSlot → 实时统计 QPS / RT / 异常数 │
│ AuthoritySlot → 黑白名单授权检查 │
│ SystemSlot → 系统自适应限流(CPU/Load) │
│ FlowSlot → 流控规则检查(QPS / 线程数) │
│ DegradeSlot → 熔断降级检查(慢调用/异常) │
└──────────────────────┬───────────────────────────────┘
│
通过 → 执行业务方法
拦截 → 抛出 BlockException
一、流控规则(FlowRule)——最常用
1.1 两种限流阈值类型
// ─── 方式一:QPS 限流(每秒请求数)───
// 场景:保护接口不被打爆,适合突发流量
FlowRule qpsRule = new FlowRule();
qpsRule.setResource("queryOrder");
qpsRule.setGrade(RuleConstant.FLOW_GRADE_QPS); // 按 QPS 统计
qpsRule.setCount(100); // 阈值 100 QPS
// 含义:queryOrder 接口每秒最多允许 100 次请求
// ─── 方式二:线程数限流 ───
// 场景:保护线程池不被打满,适合慢调用场景
FlowRule threadRule = new FlowRule();
threadRule.setResource("createOrder");
threadRule.setGrade(RuleConstant.FLOW_GRADE_THREAD); // 按线程数统计
threadRule.setCount(10); // 阈值 10 个线程
// 含义:createOrder 同时执行的线程数不超过 10
// 区别:QPS 限流看"请求频率",线程数限流看"并发占用"
// 如果接口 RT 很短(5ms),QPS=200 也只占 1 个线程
// 如果接口 RT 很长(5s),QPS=2 就可能占 10 个线程
QPS 限流 vs 线程数限流 怎么选?
接口 RT 短(< 100ms)→ QPS 限流(频率控制即可,线程不会堆积)
接口 RT 长(> 1s) → 线程数限流(防止线程池被慢调用占满)
不确定 → QPS 限流(Sentinel 默认)
实际项目中:
查询接口 → QPS 限流
写入/计算密集型接口 → 线程数限流
1.2 三种流控行为(ControlBehavior)——面试重点
// ─── 行为一:直接拒绝(快速失败,默认)───
// 超过阈值的请求直接抛出 FlowException
FlowRule directRule = new FlowRule();
directRule.setResource("queryOrder");
directRule.setGrade(RuleConstant.FLOW_GRADE_QPS);
directRule.setCount(100);
directRule.setControlBehavior(RuleConstant.CONTROL_BEHAVIOR_DEFAULT); // 0 = 直接拒绝
// 效果:第 101 个请求直接被拒绝,返回 "系统繁忙"
// 场景示例:支付接口 QPS 限流 500
// QPS <= 500 → 正常处理
// QPS > 500 → 直接返回 "支付系统繁忙,请稍后重试"
// 适合:大多数接口,简单粗暴有效
// ─── 行为二:预热(Warm Up,冷启动)───
// 系统刚启动时,缓存还没热,直接放大量请求会打垮 DB
// 预热模式:从 阈值/3 开始, gradually 上升到设定阈值
FlowRule warmUpRule = new FlowRule();
warmUpRule.setResource("queryOrder");
warmUpRule.setGrade(RuleConstant.FLOW_GRADE_QPS);
warmUpRule.setCount(100);
warmUpRule.setControlBehavior(RuleConstant.CONTROL_BEHAVIOR_WARM_UP); // 1 = 预热
warmUpRule.setWarmUpPeriodSec(10); // 预热时长 10 秒
// 效果:
// 0-3 秒:阈值 = 100/3 ≈ 33 QPS
// 3-7 秒:阈值逐渐上升
// 10 秒后:阈值 = 100 QPS
// 场景示例:应用启动后的秒杀活动
// 服务刚启动,Redis 缓存为空,DB 压力大
// 预热模式让 QPS 从 33 慢慢升到 100,给缓存填充时间
// 适合:需要预热的场景(缓存冷启动、DB 连接池初始化)
// ─── 行为三:匀速排队(漏桶算法)───
// 请求不会直接拒绝,而是排队匀速通过
FlowRule queueRule = new FlowRule();
queueRule.setResource("createOrder");
queueRule.setGrade(RuleConstant.FLOW_GRADE_QPS);
queueRule.setCount(100);
queueRule.setControlBehavior(RuleConstant.CONTROL_BEHAVIOR_RATE_LIMITER); // 2 = 匀速排队
queueRule.setMaxQueueingTimeMs(5000); // 最大排队时间 5 秒
// 效果:
// 瞬时来 500 个请求 → 不拒绝,排队匀速通过
// 每秒处理 100 个 → 500 个需要 5 秒处理完
// 超过 5 秒还没轮到的请求 → 才被拒绝
// 场景示例:消息消费、批量任务提交
// 突发 1000 条消息涌入 → 不拒绝,匀速消费
// 适合:可以容忍延迟但不能丢数据的场景(削峰填谷)
三种流控行为对比:
行为 | 超出阈值的请求 | 适用场景 | 类比
─────────────────────────────────────────────────────────────
直接拒绝 | 立即返回错误 | 大多数接口保护 | 地铁限流,满了不让进
预热 | 初期限流,后期放开 | 冷启动/缓存预热 | 地铁刚开门,慢慢放人进
匀速排队 | 排队等待,超时才拒 | 削峰填谷/消息消费 | 排队安检,匀速通过
1.3 流控来源(limitApp)——针对调用方限流
// 场景:order-service 同时被 app-web 和 app-admin 调用
// 需求:app-web 限流 200 QPS,app-admin 限流 50 QPS
FlowRule webRule = new FlowRule();
webRule.setResource("queryOrder");
webRule.setLimitApp("app-web"); // 只针对 app-web 来源
webRule.setCount(200);
FlowRule adminRule = new FlowRule();
adminRule.setResource("queryOrder");
adminRule.setLimitApp("app-admin"); // 只针对 app-admin 来源
adminRule.setCount(50);
FlowRule defaultRule = new FlowRule();
defaultRule.setResource("queryOrder");
defaultRule.setLimitApp("default"); // 其他来源
defaultRule.setCount(100);
// 来源标识:调用方在请求头中传递(Sentinel 通过 RequestOriginParser 解析)
@Component
public class CustomOriginParser implements RequestOriginParser {
@Override
public String parseOrigin(HttpServletRequest request) {
return request.getHeader("app-name"); // 从请求头获取来源标识
}
}
1.4 流控效果——关联限流和链路限流
// ─── 关联限流:A 被限流时,限制 B ───
// 场景:查询接口和写入接口操作同一个表
// 写入接口慢了 → 限制查询接口,保护 DB
FlowRule relationRule = new FlowRule();
relationRule.setResource("queryOrder"); // 限流的目标
relationRule.setStrategy(RuleConstant.STRATEGY_RELATE); // 关联模式
relationRule.setRefResource("createOrder"); // 关联的资源
relationRule.setCount(100);
// 含义:当 createOrder 的 QPS 超过 100 时,限制 queryOrder
// ─── 链路限流:只限制从某个入口进来的调用 ───
// 场景:queryOrder 被 /api/order 和 /api/admin 两个入口调用
// 只想限制 /api/order 的调用
FlowRule chainRule = new FlowRule();
chainRule.setResource("queryOrder");
chainRule.setStrategy(RuleConstant.STRATEGY_CHAIN); // 链路模式
chainRule.setRefResource("api_order"); // 入口资源名
chainRule.setCount(100);
// 含义:只有从 api_order 入口调用 queryOrder 时才限流
二、熔断降级规则(DegradeRule)——面试重点
2.1 三种熔断策略
// ─── 策略一:慢调用比例(RT)───
// 场景:下游服务变慢,不能让所有请求都等
DegradeRule slowRule = new DegradeRule();
slowRule.setResource("queryOrder");
slowRule.setGrade(CircuitBreakerStrategy.SLOW_REQUEST_RATIO.getType()); // 慢调用比例
slowRule.setCount(500); // 最大容忍 RT = 500ms(超过 500ms 算慢调用)
slowRule.setSlowRatioThreshold(0.5); // 慢调用比例阈值 = 50%
slowRule.setMinRequestAmount(5); // 至少 5 个请求才统计
slowRule.setStatIntervalMs(10000); // 统计窗口 10 秒
slowRule.setTimeWindow(10); // 熔断时长 10 秒
// 触发条件:10 秒内至少 5 个请求,且慢调用(RT > 500ms)比例超过 50%
// 触发效果:熔断 10 秒,期间所有请求直接返回降级
// 场景示例:你简历中的托管系统调用清算接口
// 清算接口正常 RT = 200ms
// 某天清算系统性能下降,RT 飙到 800ms
// 10 秒内来了 20 个请求,12 个超过 500ms(60% > 50%)
// → 熔断 10 秒,不再调用清算接口,返回 "清算服务繁忙"
// ─── 策略二:异常比例 ───
// 场景:下游服务报错率飙升
DegradeRule errorRatioRule = new DegradeRule();
errorRatioRule.setResource("queryOrder");
errorRatioRule.setGrade(CircuitBreakerStrategy.ERROR_RATIO.getType()); // 异常比例
errorRatioRule.setCount(0.5); // 异常比例阈值 50%
errorRatioRule.setMinRequestAmount(5); // 至少 5 个请求
errorRatioRule.setStatIntervalMs(10000); // 统计窗口 10 秒
errorRatioRule.setTimeWindow(10); // 熔断 10 秒
// 触发条件:10 秒内至少 5 个请求,异常比例超过 50%
// 触发效果:熔断 10 秒
// 场景示例:调用第三方天气 API
// 平时成功率 99.9%
// 天气服务挂了,10 秒内 20 个请求 12 个报错(60% > 50%)
// → 熔断 10 秒,期间返回缓存的天气数据
// ─── 策略三:异常数 ───
// 场景:对错误零容忍的核心接口
DegradeRule errorCountRule = new DegradeRule();
errorCountRule.setResource("paymentCallback");
errorCountRule.setGrade(CircuitBreakerStrategy.ERROR_COUNT.getType()); // 异常数
errorCountRule.setCount(3); // 异常数阈值 3
errorCountRule.setMinRequestAmount(5);
errorCountRule.setStatIntervalMs(60000); // 统计窗口 60 秒
errorCountRule.setTimeWindow(30); // 熔断 30 秒
// 触发条件:60 秒内至少 5 个请求,异常数达到 3 个
// 触发效果:熔断 30 秒
// 场景示例:支付回调接口
// 60 秒内报错 3 次 → 直接熔断 30 秒
// 适合:错误敏感接口,少量错误就熔断保护
2.2 熔断器三种状态详解
满足熔断条件
(慢调用比例/异常比例/异常数 达标)
┌──────────────┐ ──────────────────────────────→ ┌──────────────┐
│ CLOSED │ │ OPEN │
│ (关闭状态) │ │ (打开状态) │
│ │ ←────────────────────────────── │ │
│ 正常放行 │ 半开探测成功 → 恢复 CLOSED │ 直接拒绝 │
│ 统计指标 │ │ 执行降级 │
└──────────────┘ └──────┬───────┘
↑ │
│ 熔断超时(timeWindow)
│ │
│ ▼
│ ┌──────────────┐
│ 探测请求成功 → CLOSED │ HALF_OPEN │
└─────────────────────────────────────────── │ (半开状态) │
│ │
│ 放行 1 个 │
│ 探测请求 │
└──────┬───────┘
│
探测请求失败
│
▼
回到 OPEN
(重新计时)
三种状态详解:
CLOSED(关闭):
- 正常放行所有请求
- 持续统计 RT、异常数、QPS
- 当指标达到熔断条件时 → 切换到 OPEN
OPEN(打开):
- 直接拒绝所有请求(不调用下游)
- 执行降级逻辑(fallback)
- 等待 timeWindow 秒后 → 切换到 HALF_OPEN
HALF_OPEN(半开):
- 只放行 1 个探测请求
- 探测成功 → 切换回 CLOSED(恢复正常)
- 探测失败 → 切换回 OPEN(重新计时等待)
- 目的:试探下游是否恢复,避免盲目放开导致二次崩溃
2.3 熔断恢复的实际时序示例
时间轴示例:慢调用比例熔断(阈值50%,RT>500ms算慢调用,熔断10秒)
T=0s 请求1 RT=200ms ✅
T=1s 请求2 RT=600ms ⚠️ 慢调用
T=2s 请求3 RT=800ms ⚠️ 慢调用
T=3s 请求4 RT=550ms ⚠️ 慢调用
T=4s 请求5 RT=700ms ⚠️ 慢调用
→ 5个请求,4个慢调用(80% > 50%)
→ 触发熔断!状态:CLOSED → OPEN
T=4s~14s 熔断期间(10秒)
请求6~20 → 全部直接拒绝,返回降级数据
不实际调用下游
T=14s 熔断超时 → 状态:OPEN → HALF_OPEN
放行 1 个探测请求
请求21 RT=300ms ✅ 探测成功
→ 状态:HALF_OPEN → CLOSED,恢复正常
如果 T=14s 探测请求 RT=900ms ⚠️
→ 探测失败
→ 状态:HALF_OPEN → OPEN,再熔断 10 秒
三、blockHandler vs fallback——面试必问
// @SentinelResource 有两个降级处理属性,容易混淆
@SentinelResource(
value = "queryOrder",
blockHandler = "queryOrderBlockHandler", // 处理 Sentinel 规则触发的拦截
fallback = "queryOrderFallback" // 处理业务代码抛出的异常
)
@GetMapping("/order/{id}")
public Result<Order> queryOrder(@PathVariable Long id) {
return Result.success(orderService.getById(id));
}
// ─── blockHandler:处理 Sentinel 拦截 ───
// 触发条件:流控、熔断、系统规则被触发
// 异常类型:BlockException(FlowException / DegradeException 等)
// 方法签名:与原方法一致,最后多一个 BlockException 参数
public Result<Order> queryOrderBlockHandler(Long id, BlockException ex) {
if (ex instanceof FlowException) {
return Result.fail(429, "请求太频繁,请稍后重试");
}
if (ex instanceof DegradeException) {
return Result.fail(503, "服务降级中,请稍后重试");
}
return Result.fail(503, "系统繁忙");
}
// ─── fallback:处理业务异常 ───
// 触发条件:业务代码抛出异常(非 BlockException)
// 异常类型:Throwable
// 方法签名:与原方法一致,最后多一个 Throwable 参数
public Result<Order> queryOrderFallback(Long id, Throwable e) {
log.error("查询订单异常, id={}", id, e);
// 返回兜底数据(缓存/默认值)
Order defaultOrder = new Order();
defaultOrder.setId(id);
defaultOrder.setStatus("未知");
return Result.success(defaultOrder);
}
blockHandler vs fallback 对比:
维度 | blockHandler | fallback
──────────────────────────────────────────────────────────────
触发条件 | Sentinel 规则拦截 | 业务代码抛异常
异常类型 | BlockException | Throwable(非 BlockException)
典型场景 | QPS 超限、熔断打开 | NPE、DB超时、远程调用异常
返回内容 | 提示性信息("系统繁忙") | 兜底数据(缓存/默认值)
优先级 | 高(先检查规则) | 低(规则通过后才执行业务)
执行顺序:
请求进来
→ FlowSlot 检查流控 → 不通过 → blockHandler
→ DegradeSlot 检查熔断 → 不通过 → blockHandler
→ 都通过 → 执行业务方法
→ 业务方法抛异常 → fallback
→ 业务方法正常 → 返回结果
特殊情况:如果同时配置了 blockHandler 和 fallback
→ 先判断是否被 Sentinel 拦截
→ 被拦截走 blockHandler(不走 fallback)
→ 没被拦截但业务报错 → 走 fallback
还有一个 exceptionsToIgnore 属性:
@SentinelResource(
value = "queryOrder",
fallback = "queryOrderFallback",
exceptionsToIgnore = {BusinessException.class} // 这些异常不触发 fallback
)
→ 如果业务抛出 BusinessException,不走 fallback,直接抛给上层
四、热点参数限流(ParamFlowRule)——实战常用
// 场景:查询商品详情接口,大部分商品 QPS 正常
// 但某个爆款商品(如 iPhone)QPS 飙升,需要单独限制
@SentinelResource(value = "queryProduct", blockHandler = "queryProductBlockHandler")
@GetMapping("/product/{id}")
public Result<Product> queryProduct(@PathVariable Long id) {
return Result.success(productService.getById(id));
}
// 热点参数限流规则
ParamFlowRule rule = new ParamFlowRule("queryProduct")
.setParamIdx(0) // 限流参数索引(第 0 个参数 = id)
.setGrade(RuleConstant.FLOW_GRADE_QPS)
.setCount(50); // 默认 QPS 50
// 对特定参数值设置单独的阈值
ParamFlowItem item = new ParamFlowItem();
item.setObject("10086"); // 商品 ID = 10086(爆款)
item.setClassType(String.class.getName());
item.setCount(10); // 爆款商品 QPS 只允许 10
rule.setParamFlowItemList(Collections.singletonList(item));
ParamFlowRuleManager.loadRules(Collections.singletonList(rule));
// 限流处理
public Result<Product> queryProductBlockHandler(Long id, BlockException ex) {
return Result.fail(429, "商品 " + id + " 查询太频繁");
}
热点参数限流效果:
/product/1001 → QPS 50(默认阈值)
/product/1002 → QPS 50(默认阈值)
/product/10086 → QPS 10(特例,爆款商品单独限流)
/product/10087 → QPS 10(可以配多个特例)
适用场景:
- 商品详情页(爆款单独限流)
- 用户主页(大V单独限流)
- 文章详情(热文单独限流)
五、系统自适应限流(SystemRule)——全局保底
// 场景:不关心单个接口,根据整机指标自动限流
// Sentinel 会根据系统 Load、CPU 使用率、入口 QPS、入口 RT 自动判断
SystemRule cpuRule = new SystemRule();
cpuRule.setHighestCpuUsage(0.8); // CPU 使用率超过 80% 触发限流
SystemRule loadRule = new SystemRule();
loadRule.setHighestSystemLoad(5.0); // 系统 Load1 超过 5 触发(Linux)
SystemRule qpsRule = new SystemRule();
qpsRule.setQps(1000); // 入口总 QPS 超过 1000 触发
SystemRule rtRule = new SystemRule();
rtRule.setAvgRt(200); // 入口平均 RT 超过 200ms 触发
SystemRule threadRule = new SystemRule();
threadRule.setMaxThread(100); // 入口总线程数超过 100 触发
SystemRuleManager.loadRules(Arrays.asList(cpuRule, loadRule, qpsRule, rtRule, threadRule));
系统自适应限流的特点:
1. 不针对单个资源,而是针对整个应用入口
2. 多维度指标组合判断(不是单一指标)
3. 适合作为"最后一道防线"——前面的流控/熔断都没拦住时的兜底
4. 仅支持 Linux(Load1 指标依赖 /proc/loadavg)
实际使用建议:
- 生产环境必须配 CPU 限流(0.75~0.85)
- 不要只依赖系统规则,应与接口级流控配合使用
六、授权规则(AuthorityRule)——黑白名单
// 场景:某些接口只允许内部服务调用,外部 IP 禁止访问
AuthorityRule rule = new AuthorityRule();
rule.setResource("internalApi");
rule.setStrategy(AuthorityConstant.AUTH_WHITE); // 白名单模式
rule.setLimitApp("app-web,app-admin"); // 只允许 app-web 和 app-admin 调用
// AUTH_WHITE = 白名单(只允许列表中的来源)
// AUTH_BLACK = 黑名单(禁止列表中的来源)
AuthorityRuleManager.loadRules(Collections.singletonList(rule));
七、Sentinel 控制台 + Nacos 持久化(生产标配)
# application.yml
spring:
cloud:
sentinel:
transport:
dashboard: localhost:8080 # Sentinel 控制台地址
port: 8719 # 客户端与控制台通信端口
eager: true # 立即与控制台建立连接
datasource: # 规则持久化到 Nacos
flow:
nacos:
server-addr: localhost:8848
dataId: ${spring.application.name}-flow-rules
groupId: SENTINEL_GROUP
rule-type: flow # 规则类型
degrade:
nacos:
server-addr: localhost:8848
dataId: ${spring.application.name}-degrade-rules
groupId: SENTINEL_GROUP
rule-type: degrade
param-flow:
nacos:
server-addr: localhost:8848
dataId: ${spring.application.name}-param-flow-rules
groupId: SENTINEL_GROUP
rule-type: param-flow
生产环境架构:
┌─────────────┐ 推送规则 ┌──────────────┐
│ Sentinel │ ───────────────→ │ 应用实例 1 │
│ Dashboard │ │ 应用实例 2 │
│ (可视化配置) │ ←─────────────── │ 应用实例 N │
└──────┬──────┘ 上报监控数据 └──────────────┘
│ │
│ 规则持久化 │ 本地缓存规则
▼ │
┌─────────────┐ │
│ Nacos │ ──── 规则变更推送 ──────────→│
│ (规则存储) │ │
└─────────────┘ │
工作流程:
1. 运维在 Dashboard 配置流控/熔断规则
2. 规则持久化到 Nacos
3. Nacos 推送到所有应用实例
4. 应用实例上报监控数据到 Dashboard
5. 应用重启后从 Nacos 拉取规则(不会丢失配置)
八、完整实战示例(结合简历项目)
// 场景:零碳能源云平台的设备数据查询接口
// 需求:
// 1. 默认 QPS 限制 200
// 2. 某些高频设备单独限流
// 3. 下游 TDengine 慢了自动熔断
// 4. 熔断期间返回缓存数据
@RestController
@RequestMapping("/api/device")
@Slf4j
public class DeviceController {
@Autowired
private DeviceService deviceService;
@Autowired
private RedisTemplate<String, Object> redisTemplate;
/**
* 查询设备实时数据
* - 流控:QPS 200,热点设备单独限流
* - 熔断:慢调用比例(RT > 1s 算慢调用,比例 > 50% 熔断 10s)
* - 降级:返回 Redis 缓存的最后一次数据
*/
@SentinelResource(
value = "queryDeviceData",
blockHandler = "queryDeviceDataBlockHandler",
fallback = "queryDeviceDataFallback"
)
@GetMapping("/data/{deviceId}")
public Result<DeviceData> queryDeviceData(
@PathVariable String deviceId,
@RequestParam(required = false) String metric) {
return Result.success(deviceService.queryRealtimeData(deviceId, metric));
}
/**
* Sentinel 规则拦截处理(流控/熔断触发)
*/
public Result<DeviceData> queryDeviceDataBlockHandler(
String deviceId, String metric, BlockException ex) {
if (ex instanceof FlowException) {
log.warn("设备数据查询被限流, deviceId={}", deviceId);
return Result.fail(429, "查询过于频繁,请稍后重试");
}
if (ex instanceof DegradeException) {
log.warn("设备数据查询被熔断, deviceId={}", deviceId);
// 熔断时返回缓存数据
DeviceData cached = (DeviceData) redisTemplate.opsForValue()
.get("device:data:" + deviceId);
if (cached != null) {
return Result.success(cached);
}
return Result.fail(503, "数据服务暂时不可用");
}
return Result.fail(503, "系统繁忙");
}
/**
* 业务异常兜底处理
*/
public Result<DeviceData> queryDeviceDataFallback(
String deviceId, String metric, Throwable e) {
log.error("设备数据查询异常, deviceId={}", deviceId, e);
// 返回缓存兜底
DeviceData cached = (DeviceData) redisTemplate.opsForValue()
.get("device:data:" + deviceId);
if (cached != null) {
return Result.success(cached);
}
// 缓存也没有,返回默认值
DeviceData defaultData = new DeviceData();
defaultData.setDeviceId(deviceId);
defaultData.setStatus("unknown");
defaultData.setTimestamp(System.currentTimeMillis());
return Result.success(defaultData);
}
}
// ─── 规则初始化 ───
@Configuration
public class SentinelRuleConfig {
@PostConstruct
public void initRules() {
// 1. 流控规则:QPS 200,直接拒绝
FlowRule flowRule = new FlowRule();
flowRule.setResource("queryDeviceData");
flowRule.setGrade(RuleConstant.FLOW_GRADE_QPS);
flowRule.setCount(200);
flowRule.setControlBehavior(RuleConstant.CONTROL_BEHAVIOR_DEFAULT);
// 2. 熔断规则:慢调用比例
DegradeRule degradeRule = new DegradeRule();
degradeRule.setResource("queryDeviceData");
degradeRule.setGrade(CircuitBreakerStrategy.SLOW_REQUEST_RATIO.getType());
degradeRule.setCount(1000); // RT > 1000ms 算慢调用
degradeRule.setSlowRatioThreshold(0.5); // 慢调用比例 > 50%
degradeRule.setMinRequestAmount(5); // 至少 5 个请求
degradeRule.setStatIntervalMs(10000); // 10 秒统计窗口
degradeRule.setTimeWindow(10); // 熔断 10 秒
// 3. 热点参数限流:高频设备单独限制
ParamFlowRule paramRule = new ParamFlowRule("queryDeviceData")
.setParamIdx(0) // deviceId 是第 0 个参数
.setGrade(RuleConstant.FLOW_GRADE_QPS)
.setCount(200); // 默认 QPS 200
// 高频设备限流 20 QPS
ParamFlowItem hotItem = new ParamFlowItem();
hotItem.setObject("DEV-HOT-001");
hotItem.setClassType(String.class.getName());
hotItem.setCount(20);
paramRule.setParamFlowItemList(Collections.singletonList(hotItem));
// 加载规则
FlowRuleManager.loadRules(Collections.singletonList(flowRule));
DegradeRuleManager.loadRules(Collections.singletonList(degradeRule));
ParamFlowRuleManager.loadRules(Collections.singletonList(paramRule));
}
}
面试题
Q1:Sentinel 和 Hystrix 的区别?(高频)
| 维度 | Sentinel | Hystrix |
|---|---|---|
| 隔离策略 | 信号量隔离(线程计数) | 线程池隔离 / 信号量隔离 |
| 熔断策略 | 慢调用比例 / 异常比例 / 异常数 | 仅异常比例 |
| 限流策略 | QPS / 线程数 + 预热 + 排队 | 不支持限流(仅熔断) |
| 实时统计 | 滑动窗口(LeapArray) | 滑动窗口 |
| 动态规则 | 支持(Nacos/ZK/Apollo 推送) | 不支持(配置文件写死) |
| 控制台 | 丰富(可视化 + 实时监控) | 简陋(Hystrix Dashboard) |
| 系统自适应 | 支持(CPU/Load/RT) | 不支持 |
| 热点参数限流 | 支持 | 不支持 |
| 降级方式 | blockHandler + fallback | fallback |
| 社区状态 | 活跃(阿里维护) | 停更 |
Q2:Sentinel 滑动窗口原理?(高频)
Sentinel 用
LeapArray实现滑动窗口。核心思想:把时间轴切成多个等长的 Bucket(样本窗口),每个 Bucket 记录该时间段的请求数、成功数、异常数、RT 等。统计时取当前时间往前推一个统计窗口大小的所有 Bucket,聚合计算。例如:统计窗口 = 1 秒,样本窗口 = 200ms → 1 秒被分成 5 个 Bucket。统计时取当前时间所在的 Bucket + 前面 4 个 Bucket 聚合。随着时间推移,窗口平滑滑动,避免了固定窗口的边界突刺问题(固定窗口在窗口切换瞬间可能放行 2 倍阈值的请求)。
固定窗口的问题(窗口边界突刺):
|---窗口1(0-1s)---|---窗口2(1-2s)---|
阈值=100 阈值=100
如果 0.9s 来 100 个 + 1.1s 来 100 个
→ 两个窗口都没超阈值,但 0.2 秒内来了 200 个请求!
滑动窗口解决:
|---|---|---|---|---|
0 0.2 0.4 0.6 0.8 1.0
0.9s 时统计:取 0~1s 的 5 个 Bucket 聚合 = 100
1.1s 时统计:取 0.2~1.2s 的 5 个 Bucket 聚合
= 0.2~1.0 的 80 + 1.0~1.2 的 100 = 180 > 100 → 限流!
Q3:blockHandler 和 fallback 的区别?(高频)
blockHandler:处理 Sentinel 规则拦截(流控超限、熔断打开、系统限流),参数为BlockExceptionfallback:处理业务代码抛出的异常(NPE、超时、远程调用异常等),参数为Throwable- 执行优先级:先检查 Sentinel 规则 → 被拦走 blockHandler → 通过后执行业务 → 业务异常走 fallback
- 实践:blockHandler 返回提示信息(“系统繁忙”),fallback 返回兜底数据(缓存/默认值)
Q4:Sentinel 的三种流控行为分别什么场景用?
| 行为 | 原理 | 场景 |
|---|---|---|
| 直接拒绝 | 超阈值直接拒绝 | 大多数接口,快速失败 |
| 预热 | 从阈值/3 逐渐升到阈值 | 冷启动(缓存未热/连接池未初始化) |
| 匀速排队 | 漏桶算法,排队匀速通过 | 削峰填谷(消息消费/批量任务) |
预热的原理:系统刚启动时处理能力低(缓存冷、JIT 未编译),直接放开阈值会打垮系统。预热模式让 QPS 从
threshold / coldFactor(默认 coldFactor=3)开始,在 warmUpPeriodSec 内逐渐升到 threshold。
Q5:Sentinel 熔断器的三种状态怎么流转?
- CLOSED → OPEN:统计窗口内指标(慢调用比例/异常比例/异常数)达到阈值,且满足最小请求数
- OPEN → HALF_OPEN:熔断超时(timeWindow 秒)后自动进入半开状态
- HALF_OPEN → CLOSED:放行 1 个探测请求,成功则恢复正常
- HALF_OPEN → OPEN:探测请求失败,重新进入熔断
关键点:HALF_OPEN 只放行 1 个探测请求,目的是试探下游是否恢复,避免盲目放开导致二次崩溃。
Q6:Sentinel 信号量隔离和 Hystrix 线程池隔离的区别?
Hystrix 线程池隔离:
每个资源用独立的线程池调用
优点:资源之间完全隔离,一个资源耗尽不影响其他
缺点:线程切换开销大;线程池数量多,资源占用高
Sentinel 信号量隔离:
通过计数器统计并发线程数,超限拒绝
优点:无线程切换开销,性能好
缺点:不能实现异步调用(在同一线程执行)
实际选择:
对性能要求高 → Sentinel 信号量(推荐,默认)
需要严格隔离 → Hystrix 线程池(或 Sentinel + 线程池隔离)
慢调用多的场景 → 线程池隔离(避免阻塞工作线程)
Q7:Sentinel 规则持久化到 Nacos 的原理?
Sentinel 默认将规则存储在内存中,应用重启后规则丢失。生产环境通过
ReadableDataSource和WritableDataSource将规则持久化到 Nacos:
- Dashboard 修改规则 → 调用 Nacos DataSource 写入 Nacos 配置中心
- Nacos 配置变更 → 推送到所有应用实例的
ReadableDataSource- 应用监听到配置变更 → 重新加载规则到内存
- 应用重启 → 从 Nacos 拉取规则恢复
配置方式:在
application.yml中配置spring.cloud.sentinel.datasource指定 Nacos 的 dataId、groupId 和 rule-type。
Q8:Sentinel 热点参数限流的原理和使用场景?
原理:
ParamFlowSlot在流控检查时,不仅统计资源维度的 QPS,还按参数值分别统计。使用ParamFlowStatistic为每个参数值维护独立的计数器(基于 LRU Cache,避免参数值过多导致内存溢出)。场景:
- 商品详情页:大部分商品 QPS 正常,爆款商品单独限流
- 用户主页:大 V 的主页流量远超普通用户
- 文章详情:热文单独限流,避免影响其他文章的正常访问
2.3 Dubbo RPC 框架(简历技能清单写了)
小白讲解
Dubbo 像你叫外卖:
- HTTP 调用 = 你亲自跑出去买(慢、开销大)
- Dubbo RPC = 你跟餐厅建立了专线电话,直接说“来份红烧肉”(快、像本地方法调用)
RPC(Remote Procedure Call)= 远程过程调用,让你调用远程方法像调用本地方法一样自然。
Dubbo 调用流程
┌──────────┐ ┌──────────────────┐ ┌──────────┐
│ Consumer │ ──1. │ Registry(Nacos) │ 1.──→ │ Provider │
│ (消费方) │ 注册 │ (注册中心) │ 注册 │ (提供方) │
│ │ ←──2. │ │ ──2→ │ │
│ │ 订阅 │ │ │ │
│ │ └──────────────────┘ │ │
│ │ ───────────3. 直连调用(TCP)─────────→│ │
│ │ ←──────────4. 返回结果──────────────│ │
└──────────┘ ┌──────────┘
Dubbo 架构全貌
Dubbo 不只是“调远程方法”,它有一套完整的微服务治理体系:
┌─────────────────────┐
│ Registry (Nacos) │
│ 注册中心 │
└──────┬───────┬──────┘
1.注册 │ │ 2.订阅
▼ ▼
┌──────────────────────────────────────────────────────────┐
│ Consumer (消费方) │
│ │
│ ┌─────────┐ ┌──────────┐ ┌──────────┐ ┌───────────┐ │
│ │ Proxy │→│ Cluster │→│ Router │→│LoadBalance│ │
│ │ 代理层 │ │ 集群容错 │ │ 路由规则 │ │ 负载均衡 │ │
│ └─────────┘ └──────────┘ └──────────┘ └───────────┘ │
│ │ │ │
│ ▼ ▼ │
│ ┌─────────┐ ┌──────────┐ ┌──────────┐ ┌───────────┐ │
│ │ Filter │→│ Protocol │→│Exchange │→│ Transport │ │
│ │ 过滤器链 │ │ 协议层 │ │ 交换层 │ │ 传输层 │ │
│ └─────────┘ └──────────┘ └──────────┘ └───────────┘ │
│ │
│ Proxy: 生成接口代理,让远程调用像本地方法 │
│ Cluster: 多个 Provider 时的容错策略 │
│ Router: 从多个 Provider 中筛选出可调用的 │
│ LoadBalance: 从可调用列表中选一个 │
│ Filter: 调用前后做扩展(日志/监控/限流/鉴权) │
│ Protocol: 封装调用格式(Dubbo/Triple/REST) │
│ Exchange: 请求-响应模式封装 │
│ Transport: 网络传输(Netty/MINA) │
└──────────────────────────────────────────────────────────┘
服务暴露(Provider 端)完整流程
1. Spring 启动 → 解析 @DubboService 注解
2. ServiceConfig.export() 触发暴露
3. 将接口信息封装成 URL(dubbo://192.168.1.10:20880/com.example.UserService?version=1.0.0)
4. 创建 Invoker(可执行体)
5. 通过 Protocol(默认 DubboProtocol)启动 Netty Server 监听端口
6. 将服务元数据注册到 Nacos(URL → Nacos Registry)
7. Exporter 缓存 Invoker,等待 Consumer 调用
服务引用(Consumer 端)完整流程
1. Spring 启动 → 解析 @DubboReference 注解
2. ReferenceConfig.get() 触发引用
3. 从 Nacos 订阅服务地址列表(可能返回多个 Provider URL)
4. 对每个 URL 创建一个 Invoker(远程代理)
5. 通过 Cluster 将多个 Invoker 包装成一个(容错 + 负载均衡)
6. 通过 ProxyFactory 创建接口代理对象
7. 注入到 @DubboReference 字段,调用时像本地方法一样使用
代码示例
// ============ Provider:服务提供方 ============
// 1. 接口定义(API 模块,Provider 和 Consumer 共享)
public interface UserService {
User getUser(Long id);
List<User> listUsers(UserQuery query);
}
// 2. 实现 + 暴露服务
@DubboService(
version = "1.0.0", // 版本号(灰度发布用)
timeout = 3000, // 超时 3 秒
retries = 2, // 重试 2 次(不含首次调用)
loadbalance = "roundrobin" // 负载均衡策略
)
public class UserServiceImpl implements UserService {
@Override
public User getUser(Long id) {
return userMapper.selectById(id);
}
@Override
public List<User> listUsers(UserQuery query) {
return userMapper.selectList(query);
}
}
// ============ Consumer:服务消费方 ============
@RestController
public class OrderController {
@DubboReference(
version = "1.0.0",
timeout = 3000,
retries = 2,
check = false, // 启动时不检查 Provider 是否可用
mock = "return null" // Provider 不可用时返回 null(降级)
)
private UserService userService;
@GetMapping("/order/{id}")
public Order createOrder(@PathVariable Long id) {
User user = userService.getUser(id); // 看起来是本地调用,实际是远程 TCP
return orderService.createOrder(user);
}
}
Dubbo 六种负载均衡策略
// 在 @DubboService 或 @DubboReference 上指定 loadbalance
@DubboReference(loadbalance = "leastactive")
private UserService userService;
| 策略 | 名称 | 原理 | 适用场景 |
|---|---|---|---|
| Random(默认) | random |
按权重随机 | 大多数场景,权重均匀时约等于轮询 |
| RoundRobin | roundrobin |
按权重轮询 | 机器性能相近,请求均匀分配 |
| LeastActive | leastactive |
选活跃数最少的(处理最少的) | 机器性能不同,快的多处理 |
| ConsistentHash | consistenthash |
一致性哈希,相同参数总是发到同一 Provider | 需要会话保持(同一用户路由到同一节点) |
| ShortestResponse | shortestresponse |
选响应时间最短的(Dubbo 2.7+) | 对延迟敏感的场景 |
| P2C | p2c |
随机选两个,挑更好的那个(Dubbo 3.0+) | 大规模集群,避免全节点扫描 |
// 权重配置(Provider 端)
@DubboService(weight = 100) // 机器性能好,权重高
public class UserServiceImpl implements UserService { }
@DubboService(weight = 50) // 机器性能差,权重低
public class UserServiceImpl implements UserService { }
// 100:50 = 2:1 的比例分配请求
Dubbo 六种集群容错策略
@DubboReference(cluster = "failover") // 指定容错策略
private UserService userService;
| 策略 | 名称 | 行为 | 适用场景 |
|---|---|---|---|
| Failover(默认) | failover |
失败自动切换到其他 Provider 重试 | 读操作(幂等),默认重试 2 次 |
| Failfast | failfast |
快速失败,只调用一次,异常直接抛出 | 非幂等写操作(新增记录) |
| Failsafe | failsafe |
失败忽略,不抛异常,返回空结果 | 写日志、发通知等不重要的操作 |
| Failback | failback |
失败后异步重试,不阻塞调用方 | 消息通知等最终一致性场景 |
| Forking | forking |
并行调用多个 Provider,一个成功就返回 | 实时性要求极高的读操作 |
| Broadcast | broadcast |
逐个调用所有 Provider,任一失败就算失败 | 刷新所有节点的本地缓存 |
Failover 流程(默认):
调用 Provider A → 失败
→ 自动切换到 Provider B → 失败
→ 自动切换到 Provider C → 成功 → 返回结果
(retries=2 表示最多重试 2 次,加上首次共 3 次调用)
⚠️ 注意:Failover 只适合幂等操作!非幂等操作重试会导致重复写入!
Dubbo SPI 机制(面试核心)
Dubbo 有一套自己的 SPI(Service Provider Interface),比 Java SPI 更强大:
| 对比维度 | Java SPI | Dubbo SPI |
|---|---|---|
| 加载方式 | 一次性全加载 | 按需加载(用到哪个加载哪个) |
| 配置文件 | META-INF/services/接口全名 |
META-INF/dubbo/接口全名 |
| 内容格式 | 实现类全名 | key=实现类全名 |
| IOC 注入 | 不支持 | 支持(自动注入依赖) |
| AOP 包装 | 不支持 | 支持(自动包装 Wrapper) |
| 自适应 | 不支持 | 支持(@Adaptive 运行时动态选择) |
# META-INF/dubbo/org.apache.dubbo.rpc.Protocol
dubbo=org.apache.dubbo.rpc.protocol.dubbo.DubboProtocol
triple=org.apache.dubbo.rpc.protocol.tri.TripleProtocol
rest=org.apache.dubbo.rpc.protocol.rest.RestProtocol
// Dubbo SPI 使用方式
Protocol protocol = ExtensionLoader.getExtensionLoader(Protocol.class)
.getExtension("dubbo"); // 按 key 获取指定实现
// 自适应扩展(运行时根据 URL 参数决定用哪个实现)
@Adaptive("protocol")
public interface Protocol {
// URL 中 protocol=dubbo 就用 DubboProtocol
// URL 中 protocol=triple 就用 TripleProtocol
}
Dubbo Filter 过滤器链
Dubbo 的很多功能(监控、限流、日志、鉴权)都通过 Filter 实现:
// 自定义 Filter:记录每次 RPC 调用耗时
@Activate(group = {CommonConstants.PROVIDER, CommonConstants.CONSUMER}) // 生产者和消费者都生效
public class TimingFilter implements Filter, BaseFilter {
@Override
public Result invoke(Invoker<?> invoker, Invocation invocation) throws RpcException {
long start = System.currentTimeMillis();
try {
return invoker.invoke(invocation); // 放行,继续执行下一个 Filter
} finally {
long elapsed = System.currentTimeMillis() - start;
String service = invocation.getServiceName();
String method = invocation.getMethodName();
log.info("[Dubbo] {}.{} 耗时 {}ms", service, method, elapsed);
// 上报到监控系统
Metrics.timer("dubbo.rpc.duration", "service", service, "method", method)
.record(elapsed, TimeUnit.MILLISECONDS);
}
}
}
# META-INF/dubbo/org.apache.dubbo.rpc.Filter
timing=com.example.filter.TimingFilter
Dubbo 内置 Filter(面试常问):
| Filter | 作用 |
|---|---|
ExceptionFilter |
统一异常处理 |
TimeoutFilter |
超时日志记录 |
MonitorFilter |
监控数据采集 |
ContextFilter |
传递 RPC 上下文(traceId 等) |
AccessLogFilter |
访问日志记录 |
TokenFilter |
Token 鉴权 |
TpsLimitFilter |
TPS 限流 |
Dubbo Triple 协议(Dubbo 3.0 核心特性)
Dubbo 3.0 引入了 Triple 协议,基于 HTTP/2 + gRPC:
| 对比 | Dubbo 协议(2.x) | Triple 协议(3.x) |
|---|---|---|
| 传输层 | TCP | HTTP/2 |
| 序列化 | Hessian2(私有二进制) | Protobuf / Hessian2 |
| 跨语言 | 需要各语言 SDK | 原生 gRPC 互通(Go/Python/Node) |
| 流式调用 | 不支持 | 支持(Unary / Server Stream / Client Stream / BiStream) |
| 穿透性 | 不能穿防火墙/网关 | HTTP/2 可穿透标准网关 |
// Triple 流式调用示例
public interface UserService {
// 普通调用(Unary)
User getUser(Long id);
// 服务端流(Server Stream):一次请求,多次响应
void streamUsers(UserQuery query, StreamObserver<User> responseObserver);
// 客户端流(Client Stream):多次请求,一次响应
StreamObserver<User> batchSave(StreamObserver<Result> responseObserver);
// 双向流(BiStream):多次请求多次响应
StreamObserver<User> chat(StreamObserver<Message> responseObserver);
}
Dubbo 异步调用
// 方式一:CompletableFuture(Dubbo 2.7+ 推荐)
@DubboReference
private UserService userService;
public void asyncCall() {
// 异步调用,不阻塞当前线程
CompletableFuture<User> future = userService.getUserAsync(1L);
// 做其他事情...
doSomethingElse();
// 需要结果时再获取
future.thenAccept(user -> {
System.out.println("拿到结果: " + user);
});
// 组合多个异步调用
CompletableFuture<User> userFuture = userService.getUserAsync(1L);
CompletableFuture<Order> orderFuture = orderService.getOrderAsync(1L);
CompletableFuture.allOf(userFuture, orderFuture)
.thenRun(() -> {
User user = userFuture.join();
Order order = orderFuture.join();
// 两个都完成了
});
}
// 方式二:接口直接返回 CompletableFuture
public interface UserService {
CompletableFuture<User> getUserAsync(Long id);
}
application.yml 完整配置
dubbo:
application:
name: order-service
version: 1.0.0
registry:
address: nacos://192.168.1.100:8848
parameters:
namespace: dev
group: DUBBO_GROUP
protocol:
name: tri # Triple 协议(Dubbo 3.x)
port: 50051 # -1 表示随机端口
threads: 200 # 线程池大小
provider:
timeout: 3000 # 全局超时
retries: 0 # Provider 端不重试(Consumer 端控制)
loadbalance: leastactive # 全局负载均衡策略
filter: timing,accesslog # 全局 Filter
consumer:
timeout: 3000
retries: 2 # 读操作重试 2 次
check: false # 启动不检查
loadbalance: roundrobin
面试题
Q1:Dubbo 和 Feign 的区别?
| 维度 | Dubbo | Feign |
|---|---|---|
| 协议 | TCP(Dubbo 协议)/ HTTP/2(Triple) | HTTP |
| 性能 | 高(二进制序列化 + 长连接) | 中(HTTP + JSON) |
| 使用 | @DubboReference |
@FeignClient |
| 适用 | 内部服务间高频调用 | 对外 API、低频调用 |
| 生态 | Spring Cloud Alibaba | Spring Cloud Netflix |
| 功能 | SPI 扩展、集群容错、负载均衡、异步 | 仅声明式 HTTP 调用 |
| 跨语言 | Triple 协议支持 | 理论上支持(HTTP) |
Q2:Dubbo 支持哪些序列化方式?
| 序列化 | 性能 | 兼容性 | 说明 |
|---|---|---|---|
| Hessian2(默认) | 中 | 好 | 跨语言,Dubbo 默认 |
| Kryo | 高 | 一般 | Java 专用,最快 |
| FST | 高 | 一般 | Java 专用,比 Kryo 稍慢 |
| JSON | 低 | 好 | 可读性好,调试方便 |
| Protobuf | 高 | 好 | 跨语言,Triple 协议默认 |
Q3:Dubbo SPI 和 Java SPI 的区别?
- 按需加载: Java SPI 一次性加载所有实现类,即使不用也会加载(浪费资源);Dubbo SPI 按需加载,用到哪个加载哪个
- IOC 支持: Dubbo SPI 支持依赖注入,加载实现类时自动注入其依赖的其他扩展点
- AOP 包装: Dubbo SPI 支持 Wrapper 类自动包装,类似 Spring AOP,可以在扩展点前后加逻辑
- 自适应扩展:
@Adaptive注解让接口在运行时根据 URL 参数动态选择实现类- 配置格式: Java SPI 是纯类名,Dubbo SPI 是
key=类名,可以按 key 获取
Q4:Dubbo 的 Failover 容错策略为什么不适合非幂等操作?
Failover 在调用失败后会自动切换到其他 Provider 重试。如果操作是非幂等的(如新增记录),第一次调用可能已经成功写入数据库,只是网络超时导致 Consumer 认为失败,重试就会写入重复数据。
非幂等操作应使用 Failfast 策略——只调用一次,失败直接抛异常,由业务层决定是否手动重试。
Q5:Dubbo 3.0 的 Triple 协议解决了什么问题?
- 跨语言互通: Dubbo 协议是私有二进制协议,其他语言无法直接调用;Triple 基于 gRPC,Go/Python/Node 等语言可以直接通过 gRPC 调用
- 网关穿透: Dubbo 协议走 TCP,无法穿透标准 HTTP 网关;Triple 走 HTTP/2,可以穿透 Nginx/K8s Ingress
- 流式调用: Dubbo 协议只支持请求-响应模式;Triple 支持 Server Stream / Client Stream / 双向流,适合实时推送、大文件传输等场景
- 生态对接: 可以直接和 gRPC 生态(Envoy、Istio)集成
Q6:Dubbo 服务调用超时了怎么排查?
- 确认超时位置: 看 Consumer 端日志,是 timeout 还是其他异常
- 看 Provider 端日志: 方法是否真的执行慢,还是根本没收到请求
- 网络排查:
telnet Provider_IP Provider_Port检查网络连通性- 线程池满排查: Provider 端 Dubbo 线程池可能满了(日志会有
Thread pool is EXHAUSTED),查看dubbo.protocol.threads配置- GC 排查: Provider 或 Consumer 可能 Full GC 导致 STW,看 GC 日志
- 合理设置超时: Consumer timeout 应大于 Provider timeout + 网络延迟
Q7:Dubbo 怎么做服务降级?
三种方式:
mock属性:@DubboReference(mock = "return null")——Provider 不可用时返回 nullmock类: 实现UserServiceMock类,自定义降级逻辑(返回缓存数据/默认值)- Sentinel 集成: Dubbo + Sentinel,在 Consumer 端按 QPS/RT/异常率自动熔断降级
2.4 Spring Cloud Gateway(简历项目三提到了)
小白讲解
Gateway 是微服务的前台保安:
- 所有外部请求先到 Gateway
- Gateway 根据 URL 路由到对应微服务
- 可以在网关做鉴权、限流、日志
Gateway 核心架构
客户端请求
│
▼
┌──────────────────────────────────────────────────────────────┐
│ Spring Cloud Gateway │
│ │
│ ┌─────────────┐ ┌─────────────┐ ┌─────────────┐ │
│ │ RoutePredicate│ → │ GatewayFilter│ → │ GatewayFilter│ │
│ │ Route信息 │ │ (前置Filter) │ │ (后置Filter) │ │
│ │ 匹配路由 │ │ 改请求头等 │ │ 改响应头等 │ │
│ └─────────────┘ └─────────────┘ └─────────────┘ │
│ │ │ │
│ ▼ ▼ │
│ ┌─────────────────────────────────────────────────────┐ │
│ │ DispatcherHandler (核心分发器) │ │
│ └──────────────────────┬──────────────────────────────┘ │
│ │ │
│ ▼ │
│ ┌─────────────────────────────────────────────────────┐ │
│ │ Global Filter (全局过滤器) │ │
│ │ ┌──────────┐ ┌──────────┐ ┌──────────┐ ┌─────────┐│ │
│ │ │鉴权Filter │→│限流Filter│→│日志Filter │→│转发Filter││ │
│ │ └──────────┘ └──────────┘ └──────────┘ └─────────┘│ │
│ └─────────────────────────────────────────────────────┘ │
│ │ │
│ ▼ │
│ Proxy Filter │
│ │ │
└─────────────────────────┼─────────────────────────────────────┘
│ HTTP (负载均衡)
▼
┌──────────────┐
│ 后端微服务 │
│ (order-service│
│ pay-service) │
└──────────────┘
处理流程说明:
- 请求到达 Gateway,
RoutePredicate匹配路由规则 - 匹配成功后,请求依次经过
GatewayFilter(前置)→GlobalFilter→GatewayFilter(后置) GlobalFilter和GatewayFilter合并后按@Order排序执行- 最后由
NettyRoutingFilter将请求转发到后端微服务
核心概念
| 概念 | 说明 | 类比 |
|---|---|---|
| Route | 路由规则(= Predicate + Filter + URI) | 访客指引牌 |
| Predicate | 断言(匹配条件),匹配成功才走路由 | 访客身份检查 |
| Filter | 过滤器(修改请求/响应) | 安检 + 登记 |
| GlobalFilter | 全局过滤器(所有路由都执行) | 门口必检项 |
Predicate 断言大全(面试必背)
spring:
cloud:
gateway:
routes:
- id: order-service
uri: lb://order-service
predicates:
# === 路径匹配 ===
- Path=/api/order/** # 路径匹配(最常用)
# === HTTP 方法 ===
- Method=GET,POST # 只允许 GET 和 POST
# === 请求头 ===
- Header=X-Request-Source,\d+ # 请求头存在且值匹配正则
# === 请求参数 ===
- Query=token # 必须有 token 参数
- Query=userId,\d+ # userId 参数值为数字
# === Host 匹配 ===
- Host=*.example.com # 域名匹配
# === IP 匹配 ===
- RemoteAddr=192.168.1.0/24 # IP 段匹配
# === 时间匹配 ===
- After=2025-01-01T00:00:00+08:00[Asia/Shanghai] # 某时间后生效
- Before=2025-12-31T23:59:59+08:00[Asia/Shanghai] # 某时间前生效
- Between=2025-01-01T00:00:00+08:00[Asia/Shanghai],2025-12-31T23:59:59+08:00[Asia/Shanghai]
# === Cookie 匹配 ===
- Cookie=session,abc123 # Cookie 名为 session 且值匹配正则
# === 权重匹配(金丝雀发布)===
- Weight=group1,90 # 90% 流量走这条路由
权重路由实战(灰度发布):
routes:
# 90% 流量走 v1 版本
- id: order-service-v1
uri: lb://order-service-v1
predicates:
- Path=/api/order/**
- Weight=order-group,90
# 10% 流量走 v2 版本(灰度)
- id: order-service-v2
uri: lb://order-service-v2
predicates:
- Path=/api/order/**
- Weight=order-group,10
Filter 过滤器大全
内置 GatewayFilter(路由级别)
routes:
- id: order-service
uri: lb://order-service
filters:
# === 路径操作 ===
- StripPrefix=1 # /api/order/list → /order/list(去掉第一层)
- PrefixPath=/api # /order/list → /api/order/list(加前缀)
- RewritePath=/api/(?<segment>.*), /$\{segment} # 正则重写路径
# === 请求头操作 ===
- AddRequestHeader=X-Source,Gateway # 加请求头
- RemoveRequestHeader=X-Internal # 删请求头
- SetRequestHeader=X-Version,2.0 # 改请求头
# === 响应头操作 ===
- AddResponseHeader=X-Response-Time,500 # 加响应头
- RemoveResponseHeader=X-Internal # 删响应头
# === 请求参数操作 ===
- AddRequestParameter=source,gateway # 加请求参数
# === 状态码操作 ===
- SetStatus=200 # 强制设置响应状态码
# === 重试 ===
- name: Retry
args:
retries: 3 # 重试次数
statuses: BAD_GATEWAY,GATEWAY_TIMEOUT # 哪些状态码才重试
methods: GET # 只对 GET 重试
# === 限流(需要配合 RequestRateLimiter)===
- name: RequestRateLimiter
args:
redis-rate-limiter.replenishRate: 100 # 令牌填充速率 100/s
redis-rate-limiter.burstCapacity: 200 # 令牌桶容量 200
key-resolver: "#{@ipKeyResolver}" # 限流 key(按 IP)
# === 熔断(配合 CircuitBreaker)===
- name: CircuitBreaker
args:
name: orderCircuitBreaker
fallbackUri: forward:/fallback # 熔断后转发到降级接口
自定义全局过滤器(实战核心)
/**
* 鉴权过滤器 —— 所有请求必须携带有效 Token
* 全局过滤器对所有路由生效
*/
@Component
@Order(-100) // 数字越小优先级越高(先执行)
public class AuthGlobalFilter implements GlobalFilter, Ordered {
@Autowired
private RedisTemplate<String, String> redisTemplate;
// 白名单路径(不需要鉴权的)
private static final List<String> WHITELIST = List.of(
"/api/auth/login",
"/api/auth/register",
"/api/auth/refresh"
);
@Override
public Mono<Void> filter(ServerWebExchange exchange, GatewayFilterChain chain) {
ServerHttpRequest request = exchange.getRequest();
String path = request.getURI().getPath();
// 1. 白名单放行
if (WHITELIST.stream().anyMatch(path::startsWith)) {
return chain.filter(exchange);
}
// 2. 获取 Token
String token = request.getHeaders().getFirst("Authorization");
if (StringUtils.isBlank(token) || !token.startsWith("Bearer ")) {
return unauthorized(exchange, "缺少认证信息");
}
token = token.substring(7);
// 3. 校验 Token(从 Redis 获取)
String userId = redisTemplate.opsForValue().get("token:" + token);
if (userId == null) {
return unauthorized(exchange, "Token 已过期");
}
// 4. 将用户信息透传给下游服务
ServerHttpRequest mutatedRequest = request.mutate()
.header("X-User-Id", userId)
.header("X-Request-Id", UUID.randomUUID().toString()) // 链路追踪 ID
.build();
return chain.filter(exchange.mutate().request(mutatedRequest).build());
}
@Override
public int getOrder() {
return -100; // 最高优先级(最先执行)
}
private Mono<Void> unauthorized(ServerWebExchange exchange, String msg) {
ServerHttpResponse response = exchange.getResponse();
response.setStatusCode(HttpStatus.UNAUTHORIZED);
response.getHeaders().setContentType(MediaType.APPLICATION_JSON);
String body = "{\"code\":401,\"message\":\"" + msg + "\"}";
DataBuffer buffer = response.bufferFactory()
.wrap(body.getBytes(StandardCharsets.UTF_8));
return response.writeWith(Mono.just(buffer));
}
}
/**
* 日志过滤器 —— 记录每个请求的方法、路径、状态码、耗时
*/
@Component
@Order(-1) // 低优先级(后执行),这样能记录到最终状态码
public class LoggingGlobalFilter implements GlobalFilter, Ordered {
private static final Logger log = LoggerFactory.getLogger(LoggingGlobalFilter.class);
@Override
public Mono<Void> filter(ServerWebExchange exchange, GatewayFilterChain chain) {
ServerHttpRequest request = exchange.getRequest();
String method = request.getMethod().name();
String path = request.getURI().getPath();
String clientIp = request.getRemoteAddress().getAddress().getHostAddress();
long startTime = System.currentTimeMillis();
return chain.filter(exchange).doFinally(signalType -> {
long duration = System.currentTimeMillis() - startTime;
HttpStatusCode statusCode = exchange.getResponse().getStatusCode();
log.info("[Gateway] {} {} {} {} {}ms (IP: {})",
method, path, statusCode, statusCode.value(), duration, clientIp);
});
}
@Override
public int getOrder() {
return -1;
}
}
Gateway + Sentinel 限流(生产标配)
/**
* 网关层 Sentinel 限流配置
* 比应用层限流更早拦截,保护后端所有微服务
*/
@Configuration
public class GatewaySentinelConfig {
@PostConstruct
public void init() {
// 按 API 路径限流
GatewayFlowRule rule1 = new GatewayFlowRule("order-service_route");
rule1.setResourceMode(SentinelGatewayConstants.RESOURCE_MODE_ROUTE_ID);
rule1.setGrade(RuleConstant.FLOW_GRADE_QPS);
rule1.setCount(500); // QPS 500
// 按参数限流(热点参数)
GatewayFlowRule rule2 = new GatewayFlowRule("order-service_route");
rule2.setResourceMode(SentinelGatewayConstants.RESOURCE_MODE_ROUTE_ID);
rule2.setGrade(RuleConstant.FLOW_GRADE_QPS);
rule2.setCount(100);
rule2.setParamItem(new GatewayParamItem()
.setParseStrategy(SentinelGatewayConstants.PARAM_PARSE_STRATEGY_URL_PARAM)
.setFieldName("productId")); // 按 productId 参数限流
GatewayRuleManager.loadRules(List.of(rule1, rule2));
}
// 自定义限流响应
@PostConstruct
public void initBlockHandler() {
BlockRequestHandler handler = (exchange, t) -> {
Map<String, Object> result = new HashMap<>();
result.put("code", 429);
result.put("message", "请求过于频繁,请稍后重试");
return ServerResponse.status(HttpStatus.TOO_MANY_REQUESTS)
.contentType(MediaType.APPLICATION_JSON)
.body(BodyInserters.fromValue(result));
};
GatewayCallbackManager.setBlockHandler(handler);
}
}
网关统一降级接口
/**
* 熔断降级统一处理
* 当后端服务不可用时,CircuitBreaker 会转发到 /fallback
*/
@RestController
public class FallbackController {
@RequestMapping("/fallback")
public Result<Object> fallback(ServerWebExchange exchange) {
String path = exchange.getRequest().getURI().getPath();
return Result.fail(503, "服务暂时不可用,请稍后重试。路径: " + path);
}
// 不同服务可以有不同的降级逻辑
@RequestMapping("/fallback/order")
public Result<Object> orderFallback() {
return Result.fail(503, "订单服务繁忙,请稍后重试");
}
@RequestMapping("/fallback/pay")
public Result<Object> payFallback() {
return Result.fail(503, "支付服务繁忙,请稍后重试");
}
}
完整 application.yml 配置
spring:
cloud:
gateway:
# 路由配置
routes:
- id: order-service
uri: lb://order-service
predicates:
- Path=/api/order/**
filters:
- StripPrefix=1
- AddRequestHeader=X-Gateway,cloud-gateway
- name: CircuitBreaker
args:
name: orderCircuitBreaker
fallbackUri: forward:/fallback/order
- id: pay-service
uri: lb://pay-service
predicates:
- Path=/api/pay/**
filters:
- StripPrefix=1
- name: CircuitBreaker
args:
name: payCircuitBreaker
fallbackUri: forward:/fallback/pay
- id: user-service
uri: lb://user-service
predicates:
- Path=/api/user/**
filters:
- StripPrefix=1
# 全局跨域配置
default-filters:
- name: CORS
globalcors:
cors-configurations:
'[/**]':
allowed-origins: "*"
allowed-methods: "*"
allowed-headers: "*"
allow-credentials: false
max-age: 3600
# 服务发现配置
discovery:
locator:
enabled: true # 自动根据服务名创建路由
lower-case-service-id: true # 服务名转小写
# Sentinel 网关限流配置
spring.cloud.sentinel:
transport:
dashboard: 127.0.0.1:8858
filter:
enabled: false # 关闭普通 Web 限流,只走网关限流
scg:
fallback:
mode: response
response-status: 429
response-body: '{"code":429,"message":"请求过于频繁"}'
面试题
Q1:Gateway 和 Zuul 的区别?
| 维度 | Gateway | Zuul 1.x |
|---|---|---|
| 框架 | Spring WebFlux | Servlet |
| 模型 | 非阻塞(Netty) | 阻塞 |
| 性能 | 高 | 中 |
| 社区 | Spring 官方维护 | Netflix 停更 |
| 长连接 | 支持(WebSocket) | 不支持 |
| 限流 | 内置 RequestRateLimiter(Redis 令牌桶) | 需自己实现 |
| 编程模型 | 响应式(Mono/Flux) | 传统同步 |
Q2:Gateway 的 GlobalFilter 和 GatewayFilter 有什么区别?
| 维度 | GatewayFilter | GlobalFilter |
|---|---|---|
| 作用范围 | 只对配置了该 Filter 的 Route 生效 | 对所有 Route 都生效 |
| 配置方式 | 在 routes 的 filters 中声明 | 实现 GlobalFilter 接口 + @Component |
| 典型用途 | 路径重写、添加请求头等路由级操作 | 鉴权、日志、限流等全局操作 |
| 执行顺序 | 按声明顺序 | 按 getOrder() 返回值排序 |
| 合并执行 | 两者合并后统一按 Order 排序执行 | 同左 |
Q3:Gateway 为什么用 WebFlux 而不用传统 MVC?
Gateway 作为所有请求的入口,需要处理极高的并发。传统 Spring MVC 基于 Servlet(一个请求一个线程),在高并发下线程数会成为瓶颈。
WebFlux 基于 Reactor + Netty,使用少量线程处理大量连接(非阻塞 I/O)。一个线程可以服务成百上千的请求,不因某个后端服务慢而阻塞线程,天然适合网关场景。
Q4:Gateway 怎么做动态路由?
默认路由配置在 yml 中,改了需要重启。生产环境需要动态路由:
- 配合 Nacos: 路由配置存在 Nacos,监听配置变更自动刷新路由表
- 数据库 + 内存: 路由规则存数据库,启动时加载到内存,修改后通过接口触发刷新
- Redis: 路由规则存 Redis,监听 Key 变更刷新
// 动态路由核心:覆盖 RouteDefinitionRepository
@Component
public class NacosRouteDefinitionRepository implements RouteDefinitionRepository {
@Override
public Flux<RouteDefinition> getRouteDefinitions() {
// 从 Nacos 读取路由配置
return Flux.fromIterable(loadRoutesFromNacos());
}
// Nacos 配置变更时自动刷新
@NacosConfigListener(dataId = "gateway-routes.yaml")
public void onRoutesChange(String config) {
List<RouteDefinition> routes = parseRoutes(config);
// 通知 Gateway 刷新路由表
routeDefinitionWriter.delete(Mono.just("")).subscribe();
routes.forEach(r -> routeDefinitionWriter.save(Mono.just(r)).subscribe());
}
}
Q5:Gateway 怎么解决跨域问题?
两种方式:
- 全局配置(推荐): 在
spring.cloud.gateway.globalcors中配置,对所有路由生效- ** CorsFilter:** 注册一个 CorsWebFilter Bean
注意:网关配了跨域后,后端微服务就不要再配跨域了,否则会出现
Access-Control-Allow-Origin重复的问题。
Q6:Gateway 调用后端服务超时怎么设置?
spring:
cloud:
gateway:
httpclient:
connect-timeout: 3000 # 连接超时 3 秒
response-timeout: 30s # 响应超时 30 秒
# 也可以在具体路由上设置
routes:
- id: order-service
uri: lb://order-service
metadata:
response-timeout: 5000 # 该路由响应超时 5 秒
connect-timeout: 2000 # 该路由连接超时 2 秒
2.5 Seata 分布式事务(简历项目可能涉及,面试必问)
小白讲解
单体应用一个数据库,一个 @Transactional 搞定。微服务多个数据库,跨服务的 @Transactional 管不了别人的数据库——需要分布式事务。
生活类比:
你去银行转账,银行内部系统拆成了三个微服务:
- 账户服务(账户库):扣你的钱
- 流水服务(流水库):记一笔转账流水
- 通知服务(通知库):发短信通知
扣钱成功了,流水也记了,但通知服务挂了——你的钱扣了但没收到短信。Seata 就是保证这三个操作要么全成功,要么全回滚。
Seata 三大角色
┌──────────────────────────────────────────────────────┐
│ TC (Transaction Coordinator) │
│ 事务协调器(独立部署) │
│ │
│ - 维护全局事务和分支事务的状态 │
│ - 驱动全局事务提交或回滚 │
│ - 独立部署,所有 TM 和 RM 都连这个 │
└──────────┬───────────────────────────────┬────────────┘
│ │
1.开启全局事务 3.注册分支事务
2.获取 XID 4.报告分支状态
│ │
▼ ▼
┌──────────────────┐ ┌──────────────────┐
│ TM (Transaction │ │ RM (Resource │
│ Manager) │ │ Manager) │
│ 事务管理器 │ │ 资源管理器 │
│ │ │ │
│ - 决定全局事务 │ │ - 管理本地事务 │
│ 开始/提交/回滚 │ │ - 注册分支事务 │
│ - 通常在发起方 │ │ - 报告事务状态 │
│ 服务中 │ │ - 通常在参与方 │
└──────────────────┘ │ 服务中 │
│ │
│ 每个数据库 = 1个RM │
└──────────────────┘
完整调用流程:
1. TM 向 TC 申请开启全局事务 → TC 生成全局唯一的 XID
2. XID 通过服务调用链(Dubbo/Feign 的 Header)传播到下游服务
3. 下游服务的 RM 检测到 XID,将本地事务注册为分支事务
4. 各分支执行本地事务并提交(AT 模式一阶段直接提交)
5. TM 收到所有分支的执行结果,向 TC 发起全局提交或回滚
6. TC 通知所有 RM 执行第二阶段(提交删除 undo_log / 回滚用 undo_log 反向补偿)
四种模式对比
| 模式 | 原理 | 侵入性 | 性能 | 一致性 | 适用场景 |
|---|---|---|---|---|---|
| AT(默认) | 两阶段:一阶段拦截 SQL 自动记录 undo_log;二阶段成功删 log、失败用 undo_log 回滚 | 无侵入(只需加注解) | 高(一阶段直接提交) | 最终一致 | 大多数业务(推荐首选) |
| TCC | Try-Confirm-Cancel,三个接口由业务自己实现 | 高(需写三套接口) | 高 | 最终一致 | 需要细粒度控制(如资金扣减) |
| SAGA | 长事务,每步有正向操作和补偿动作 | 中(需写补偿逻辑) | 高 | 最终一致 | 流程长的业务(如订单流程) |
| XA | 数据库 XA 协议,两阶段提交 | 无侵入 | 低(长时间锁资源) | 强一致 | 强一致要求高 |
AT 模式详解(面试重点)
AT(Auto Transaction)是 Seata 默认模式,最大的优点是业务无感知——你只需要加一个 @GlobalTransactional 注解,Seata 自动帮你处理。
AT 模式一阶段流程
1. 拦截业务 SQL
↓
2. 解析 SQL → 查询变更前数据(before image)
SELECT * FROM account WHERE id = 1 → before: {id:1, balance:1000}
↓
3. 执行业务 SQL
UPDATE account SET balance = balance - 100 WHERE id = 1
↓
4. 查询变更后数据(after image)
SELECT * FROM account WHERE id = 1 → after: {id:1, balance:900}
↓
5. 将 before image + after image + SQL 信息保存到 undo_log 表
INSERT INTO undo_log (branch_id, xid, table_name, before_image, after_image) VALUES (...)
↓
6. 向 TC 注册分支事务,并申请全局锁(防止其他全局事务同时修改该行)
↓
7. 本地事务提交(undo_log 和业务数据在同一个本地事务中一起提交)
↓
8. 向 TC 报告分支事务状态
AT 模式二阶段流程
=== 全局提交(所有分支都成功)===
1. TC 通知所有 RM 提交
2. RM 异步删除 undo_log 记录(非常快,几乎不耗时)
3. 释放全局锁
=== 全局回滚(有分支失败)===
1. TC 通知所有 RM 回滚
2. RM 拿到 XID 和 Branch ID,找到对应的 undo_log
3. 根据 undo_log 的 after_image 校验当前数据是否被修改过
- 如果当前数据 == after_image → 数据没被其他事务改过,安全回滚
- 如果当前数据 != after_image → 数据被改过了(脏写),需要人工介入
4. 根据 before_image 反向生成补偿 SQL
UPDATE account SET balance = 1000 WHERE id = 1
5. 执行补偿 SQL + 删除 undo_log
6. 释放全局锁
AT 模式代码示例
// ============ 发起方(TM)============
@Service
public class OrderService {
@DubboReference
private StorageService storageService; // 库存服务(另一个数据库)
@DubboReference
private AccountService accountService; // 账户服务(另一个数据库)
@GlobalTransactional // ← 只需加这一个注解!
public void createOrder(Long userId, Long productId, Integer count) {
// 1. 创建订单(order-db)
orderMapper.insert(buildOrder(userId, productId, count));
// 2. 扣减库存(storage-db,远程调用)
storageService.decrease(productId, count);
// 3. 扣减余额(account-db,远程调用)
accountService.decrease(userId, count * 100);
// 如果第 3 步抛异常 → 第 1、2 步自动回滚!
// 如果第 2 步超时 → 第 1 步自动回滚,第 3 步不执行
}
}
// ============ 参与方(RM)============
@Service
public class AccountServiceImpl implements AccountService {
@Autowired
private AccountMapper accountMapper;
@Override
public void decrease(Long userId, Integer money) {
// 普通本地事务即可,Seata 自动将其注册为分支事务
accountMapper.decreaseBalance(userId, money);
// 这里不需要 @Transactional,也不需要 @GlobalTransactional
// Seata 通过数据源代理自动拦截 SQL,记录 undo_log
}
}
-- 每个参与全局事务的数据库都需要 undo_log 表
CREATE TABLE `undo_log` (
`id` BIGINT(20) NOT NULL AUTO_INCREMENT,
`branch_id` BIGINT(20) NOT NULL COMMENT '分支事务ID',
`xid` VARCHAR(100) NOT NULL COMMENT '全局事务ID',
`context` VARCHAR(128) NOT NULL COMMENT '上下文',
`rollback_info` LONGBLOB NOT NULL COMMENT '回滚信息(before+after image)',
`log_status` INT(11) NOT NULL COMMENT '状态',
`log_created` DATETIME NOT NULL COMMENT '创建时间',
`log_modified` DATETIME NOT NULL COMMENT '修改时间',
PRIMARY KEY (`id`),
UNIQUE KEY `ux_undo_log` (`xid`, `branch_id`)
) ENGINE = InnoDB COMMENT = 'Seata AT模式 undo_log 表';
TCC 模式详解
AT 模式虽然无侵入,但有局限:只能处理数据库操作,如果事务中有调用第三方接口(如银行转账 API),AT 管不了。这时需要 TCC。
TCC = Try - Confirm - Cancel
Try 阶段:预留资源(不真正执行)
→ 冻结余额:UPDATE account SET balance = balance - 100, frozen = frozen + 100 WHERE id = 1
Confirm 阶段:确认执行(Try 成功后)
→ 扣减冻结:UPDATE account SET frozen = frozen - 100 WHERE id = 1
Cancel 阶段:取消执行(Try 失败后)
→ 解冻:UPDATE account SET balance = balance + 100, frozen = frozen - 100 WHERE id = 1
// TCC 接口定义
@LocalTCC // 标记为 TCC 模式
public interface AccountTccAction {
@TwoPhaseBusinessAction(
name = "decreaseAccount",
commitMethod = "confirm",
rollbackMethod = "cancel"
)
@BusinessActionContextParameter(paramName = "userId")
@BusinessActionContextParameter(paramName = "money")
boolean tryDecrease(BusinessActionContext ctx);
boolean confirm(BusinessActionContext ctx);
boolean cancel(BusinessActionContext ctx);
}
// TCC 实现
@Service
public class AccountTccActionImpl implements AccountTccAction {
@Override
public boolean tryDecrease(BusinessActionContext ctx) {
Long userId = ctx.getActionContext("userId", Long.class);
Integer money = ctx.getActionContext("money", Integer.class);
// Try:冻结金额,不真正扣减
int rows = accountMapper.freezeBalance(userId, money);
if (rows == 0) {
throw new RuntimeException("余额不足"); // 抛异常 → 触发 Cancel
}
return true;
}
@Override
public boolean confirm(BusinessActionContext ctx) {
Long userId = ctx.getActionContext("userId", Long.class);
Integer money = ctx.getActionContext("money", Integer.class);
// Confirm:扣减冻结金额
accountMapper.deductFrozenBalance(userId, money);
return true;
}
@Override
public boolean cancel(BusinessActionContext ctx) {
Long userId = ctx.getActionContext("userId", Long.class);
Integer money = ctx.getActionContext("money", Integer.class);
// Cancel:解冻(把冻结的金额加回余额)
accountMapper.unfreezeBalance(userId, money);
return true;
}
}
TCC 三大问题(面试必问):
| 问题 | 说明 | 解决方案 |
|---|---|---|
| 空回滚 | Try 还没执行,Cancel 就被调用了(网络超时导致 TM 以为失败) | 记录事务状态表,Cancel 时检查 Try 是否执行过 |
| 幂等 | Confirm/Cancel 可能被重试调用 | 记录事务状态表,已执行过的直接返回 true |
| 悬挂 | Cancel 先于 Try 执行(超时回滚后,延迟的 Try 才到达) | 记录事务状态表,Try 前检查是否已 Cancel |
// TCC 三大问题解决:事务状态表
@Service
public class AccountTccActionImpl implements AccountTccAction {
@Autowired
private TccTransactionLogMapper tccLogMapper;
@Override
public boolean tryDecrease(BusinessActionContext ctx) {
String xid = ctx.getXid();
// 1. 防悬挂:检查是否已经 Cancel 过
if (tccLogMapper.isCancelled(xid)) {
return false; // 已经 Cancel 了,拒绝执行 Try
}
// 2. 防幂等:检查是否已经 Try 过
if (tccLogMapper.isTried(xid)) {
return true; // 已经 Try 过了,直接返回
}
// 3. 执行 Try 逻辑
accountMapper.freezeBalance(userId, money);
// 4. 记录 Try 状态
tccLogMapper.insertLog(xid, "TRY");
return true;
}
@Override
public boolean confirm(BusinessActionContext ctx) {
String xid = ctx.getXid();
// 防幂等
if (tccLogMapper.isConfirmed(xid)) {
return true;
}
accountMapper.deductFrozenBalance(userId, money);
tccLogMapper.updateStatus(xid, "CONFIRM");
return true;
}
@Override
public boolean cancel(BusinessActionContext ctx) {
String xid = ctx.getXid();
// 防空回滚:检查 Try 是否执行过
if (!tccLogMapper.isTried(xid)) {
// Try 没执行过,记录 Cancel 状态防止悬挂
tccLogMapper.insertLog(xid, "CANCEL");
return true; // 空回滚,直接返回
}
// 防幂等
if (tccLogMapper.isCancelled(xid)) {
return true;
}
accountMapper.unfreezeBalance(userId, money);
tccLogMapper.updateStatus(xid, "CANCEL");
return true;
}
}
SAGA 模式详解
SAGA 适用于长事务(执行时间很长的事务,如一个订单流程涉及多个步骤,每个步骤可能调用外部 API)。
SAGA 模式:每一步都有正向操作和补偿操作
正向流程:
T1(创建订单) → T2(扣库存) → T3(扣余额) → T4(发通知)
如果 T3 失败,反向补偿:
C2(加回库存) → C1(取消订单)
(T4 没执行所以不需要补偿)
注意:SAGA 没有全局锁,中间状态是可见的(其他事务能看到部分执行的结果)
// SAGA 使用状态机引擎定义流程
// saga.json 配置文件
{
"Name": "createOrderSaga",
"States": [
{
"Name": "createOrder",
"Type": "ServiceTask",
"ServiceName": "orderService",
"ServiceMethod": "create",
"CompensateState": "compensateOrder"
},
{
"Name": "deductStorage",
"Type": "ServiceTask",
"ServiceName": "storageService",
"ServiceMethod": "decrease",
"CompensateState": "compensateStorage"
},
{
"Name": "deductAccount",
"Type": "ServiceTask",
"ServiceName": "accountService",
"ServiceMethod": "decrease",
"CompensateState": "compensateAccount"
}
]
}
全局锁机制(AT 模式核心)
AT 模式通过全局锁来防止多个全局事务同时修改同一行数据导致脏写:
事务 A(XID-A) 事务 B(XID-B)
│ │
│ 1.执行 UPDATE account │
│ SET balance=900 │
│ WHERE id=1 │
│ │
│ 2.向 TC 申请全局锁 │
│ key = account:1 │
│ TC: 锁不存在,加锁成功 │
│ │ 3.执行 UPDATE account
│ │ SET balance=800
│ │ WHERE id=1
│ │
│ │ 4.向 TC 申请全局锁
│ │ key = account:1
│ │ TC: 锁已被 XID-A 持有!
│ │ → B 等待重试(默认 10 次,每次 30ms)
│ │
│ 5.全局事务提交 │
│ → 释放全局锁 │
│ │ 6.锁释放后,B 获取到锁
│ │ → B 继续执行
全局锁 vs 本地锁:
| 维度 | 全局锁 | 本地锁(数据库行锁) |
|---|---|---|
| 持有者 | TC(事务协调器) | 数据库引擎 |
| 粒度 | 行级(表名:主键值) | 行级 |
| 作用 | 防止多个全局事务脏写 | 防止本地事务并发修改 |
| 释放时机 | 全局事务提交/回滚后 | 本地事务提交/回滚后 |
Seata Server 部署配置
# application.yml(Seata Server)
seata:
config:
type: nacos # 配置中心
nacos:
server-addr: 127.0.0.1:8848
group: SEATA_GROUP
namespace: seata
data-id: seataServer.properties
registry:
type: nacos # 注册中心
nacos:
server-addr: 127.0.0.1:8848
group: SEATA_GROUP
namespace: seata
cluster: default
store:
mode: db # 事务日志存储方式(file/db/redis)
db:
datasource: druid
url: jdbc:mysql://127.0.0.1:3306/seata
user: root
password: root
# 微服务端配置(application.yml)
seata:
enabled: true
application-id: ${spring.application.name}
tx-service-group: default_tx_group # 事务组(要和 Seata Server 一致)
service:
vgroup-mapping:
default_tx_group: default # 映射到集群名
registry:
type: nacos
nacos:
server-addr: 127.0.0.1:8848
group: SEATA_GROUP
namespace: seata
config:
type: nacos
nacos:
server-addr: 127.0.0.1:8848
group: SEATA_GROUP
namespace: seata
面试题
Q1:Seata 的 AT 模式和 TCC 模式怎么选?
| 维度 | AT 模式 | TCC 模式 |
|---|---|---|
| 侵入性 | 无(加注解即可) | 高(需实现 Try/Confirm/Cancel) |
| 适用场景 | 纯数据库操作 | 涉及外部 API / 需要细粒度控制 |
| 性能 | 一阶段直接提交,性能好 | 一阶段预留资源,性能好 |
| 隔离性 | 写隔离(全局锁) | 业务自己控制 |
| 开发量 | 极小 | 大(三套接口 + 空回滚/幂等/悬挂处理) |
| 推荐 | 首选,90% 场景够用 | 资金类、需要冻结操作的场景 |
Q2:AT 模式为什么一阶段就提交本地事务?不会脏读吗?
AT 模式一阶段提交是为了减少资源锁定时间——传统 XA 模式一阶段不提交,会长时间持有数据库锁,性能差。
AT 提交后,其他本地事务确实可以读到已提交的数据(读未提交隔离级别下的“脏读”问题),但 Seata 通过全局锁保证:其他全局事务无法修改这条数据(写隔离),只有非事务操作或本地事务能读到中间状态。
如果需要强读隔离,可以用
@GlobalTransactional(lockRetryInterval = ...)+SELECT FOR UPDATE(Seata 会检查全局锁)。
Q3:AT 模式如果 undo_log 表的数据被删了怎么办?
undo_log 是 AT 模式回滚的依据。如果二阶段回滚时找不到 undo_log,Seata 会记录告警日志并跳过该分支的回滚(因为数据可能已经被其他方式修正)。生产环境要确保 undo_log 表和业务表在同一个数据库、同一个本地事务中写入,保证原子性。
Q4:Seata 的 XID 是怎么在微服务间传播的?
XID(全局事务 ID)格式为
IP:PORT:TRANSACTION_ID。传播方式取决于 RPC 框架:
- Dubbo: Seata 通过 Dubbo Filter(
SeataFilter)将 XID 放入RpcContext.getContext()的 Attachment 中,Consumer → Provider 自动传播- Feign: Seata 通过 Feign Interceptor(
SeataFeignClientInterceptor)将 XID 放入 HTTP Header(TX_XID),请求时自动传播- Spring Cloud Gateway: 需要配置 Gateway Filter 传播 XID Header
下游服务通过
RootContext.bind(xid)绑定 XID,后续操作自动加入同一全局事务。
Q5:Seata 高可用怎么保证?
TC(事务协调器)是单点风险。Seata 支持集群部署:
- 多 TC 节点: 多个 Seata Server 注册到 Nacos,微服务通过 Nacos 发现 TC 集群
- 共享存储: 所有 TC 节点共享同一个数据库(存储事务日志),保证状态一致
- 负载均衡: 微服务端通过
seata.service.vgroup-mapping将事务组映射到 TC 集群,TC 间通过 Redis/DB 共享数据- 故障转移: TC 节点宕机后,微服务自动从 Nacos 获取其他健康节点
Q6:分布式事务和消息最终一致性(RocketMQ 事务消息)怎么选?
| 维度 | Seata | RocketMQ 事务消息 |
|---|---|---|
| 一致性 | 强一致(AT/TCC)或最终一致(SAGA) | 最终一致 |
| 性能 | 有全局锁,性能有损耗 | 无锁,性能好 |
| 复杂度 | 中等(需部署 TC) | 低(RocketMQ 自带) |
| 适用 | 同步场景(必须等所有操作完成) | 异步场景(允许延迟一致) |
| 典型案例 | 转账、下单扣库存 | 注册发邮件、下单发消息 |
选择建议: 如果业务要求“立即看到结果”用 Seata;如果“晚几秒也没关系”用 MQ 事务消息。你的简历项目用了 RocketMQ 事务消息做活动报名,这是合理的选择——报名结果不需要即时反馈。
三、MyBatis-Plus
你简历的使用场景: 所有项目的 ORM 层
面试官心理: “你简历写了 @BatchSize 解决 N+1,讲讲 N+1 是什么?怎么解决的?”
3.1 核心使用
小白讲解
MyBatis-Plus = MyBatis + 增强插件。MyBatis 是手写 SQL 的 ORM,MyBatis-Plus 在此基础上:
- 不用写基本 CRUD(自动生成)
- 条件构造器(链式写 WHERE 条件)
- 分页插件(加一行配置自动分页)
- 逻辑删除(改个字段标记删除,不真删)
代码示例
// 实体类
@Data
@TableName("t_user")
public class User {
@TableId(type = IdType.ASSIGN_ID) // 雪花算法 ID
private Long id;
private String name;
private Integer age;
private String email;
@TableField(fill = FieldFill.INSERT) // 自动填充
private LocalDateTime createTime;
@TableField(fill = FieldFill.INSERT_UPDATE)
private LocalDateTime updateTime;
@TableLogic // 逻辑删除字段
@TableField("is_deleted")
private Integer deleted;
}
// Mapper:继承 BaseMapper,自动拥有 CRUD
@Mapper
public interface UserMapper extends BaseMapper<User> {
// BaseMapper 自带方法:
// insert(user), deleteById(id), updateById(user), selectById(id)
// selectList(null), selectPage(page, wrapper) 等
}
// Service:继承 IService
@Service
public class UserServiceImpl extends ServiceImpl<UserMapper, User> implements UserService {
// IService 自带:save(), saveBatch(), getById(), list(), page() 等
}
条件构造器
// QueryWrapper(字符串列名)
QueryWrapper<User> wrapper = new QueryWrapper<>();
wrapper.eq("name", "张三") // name = '张三'
.ge("age", 18) // AND age >= 18
.like("email", "@gmail") // AND email LIKE '%@gmail%'
.orderByDesc("create_time")
.last("LIMIT 10"); // 最后拼接 SQL
List<User> users = userMapper.selectList(wrapper);
// LambdaQueryWrapper(推荐!编译时检查列名,防写错)
LambdaQueryWrapper<User> lambdaWrapper = new LambdaQueryWrapper<>();
lambdaWrapper.eq(User::getName, "张三")
.ge(User::getAge, 18)
.like(User::getEmail, "@gmail")
.orderByDesc(User::getCreateTime);
List<User> users = userMapper.selectList(lambdaWrapper);
分页插件
// 配置分页插件
@Configuration
public class MybatisPlusConfig {
@Bean
public MybatisPlusInterceptor mybatisPlusInterceptor() {
MybatisPlusInterceptor interceptor = new MybatisPlusInterceptor();
interceptor.addInnerInterceptor(
new PaginationInnerInterceptor(DbType.MYSQL));
return interceptor;
}
}
// 使用分页
Page<User> page = new Page<>(1, 10); // 第 1 页,每页 10 条
Page<User> result = userMapper.selectPage(page,
new LambdaQueryWrapper<User>().ge(User::getAge, 18));
result.getRecords(); // 当前页数据
result.getTotal(); // 总记录数
result.getPages(); // 总页数
result.getCurrent(); // 当前页码
3.2 N+1 查询问题(简历直接写了 @BatchSize)
小白讲解
N+1 = 查 1 次主表 + N 次子表。就像你取快递:
- N+1 问题:你有 10 个快递,跑到快递柜 10 次每次取 1 个——10+1=11 次往返
- 解决方案:一次取 10 个——1+1=2 次往返
代码示例
// ==================== N+1 问题 ====================
// 场景:查询 10 个订单,每个订单要查对应的用户信息
List<Order> orders = orderMapper.selectList(null); // 查 1 次订单
for (Order order : orders) {
User user = userMapper.selectById(order.getUserId()); // 查 N 次用户
}
// 总 SQL:1 + 10 = 11 条
// ==================== 解决方案 1:@BatchSize(你简历用的)====================
@TableName("t_order")
public class Order {
private Long id;
private Long userId;
// 加 @BatchSize 自动按批次查询
@TableField(exist = false)
@BatchSize(size = 100) // 每批 100 个 userId
private User user;
}
// MyBatis-Plus 看到 @BatchSize 后:
// SELECT * FROM t_user WHERE id IN (id1, id2, ..., id100) -- 批量查
// 总 SQL:1 + 1 = 2 条(如果订单数 <= 100)
// ==================== 解决方案 2:手写 JOIN ====================
@Mapper
public interface OrderMapper extends BaseMapper<Order> {
@Select("SELECT o.*, u.name as user_name FROM t_order o " +
"LEFT JOIN t_user u ON o.user_id = u.id " +
"WHERE o.id = #{id}")
OrderVO selectOrderWithUser(@Param("id") Long id);
}
// ==================== 解决方案 3:关联查询 + ResultMap ====================
// 使用 @Results 或 XML resultMap 做一对一/一对多
面试题
Q1:什么是 N+1 问题?有什么危害?
N+1 问题是指查询 N 条主记录后,对每条记录单独查询关联数据,产生 1(主表)+ N(关联表)= N+1 次 SQL。危害是数据库连接频繁、网络往返多、查询慢。在列表查询 N=1000 时就是 1001 条 SQL,严重影响性能。
Q2:@BatchSize 的底层原理是什么?
@BatchSize是 Hibernate 的注解,MyBatis-Plus 也有类似机制。原理是当遍历关联对象时,收集主表的外键 ID,积累到size个后发一条IN查询批量获取,然后填充回对应的主对象。比如@BatchSize(size=100)+ 1000 条订单 = 10 条IN查询 + 1 条主查询 = 11 条 SQL,远少于 1001 条。
Q3:MyBatis-Plus 的逻辑删除原理?
在实体类字段加
@TableLogic,删除操作时 MyBatis-Plus 自动将DELETE改写为UPDATE SET is_deleted=1,查询时自动追加WHERE is_deleted=0。对开发者透明,代码不感知。
Q4:MyBatis 的一级缓存和二级缓存?
| 维度 | 一级缓存 | 二级缓存 |
|---|---|---|
| 范围 | SqlSession(方法级) | namespace(跨 SqlSession) |
| 默认 | 开启 | 需手动开启 |
| 失效 | insert/update/delete/commit/close | 同上 |
| 分布式 | 不适用 | 需分布式缓存(如 Redis) |
注意: Spring Boot 中默认每次请求一个 SqlSession,一级缓存基本无效。生产环境一般不用 MyBatis 二级缓存(粒度粗、易脏读),而是用 Redis 做业务缓存。
四、RocketMQ(简历项目四直接用了)
你简历的使用场景: 埃森哲社区平台——RocketMQ 事务消息保障分布式事务最终一致性
面试官心理: “你说用了事务消息,和普通消息有什么区别?事务消息怎么保证一致性?”
4.1 核心概念
小白讲解
RocketMQ 像一个快递分拣中心:
- Producer = 寄件人(发消息的)
- Consumer = 收件人(收消息的)
- Broker = 分拣中心(存消息的)
- NameServer = 快递公司总部(告诉寄件人分拣中心在哪)
- Topic = 寄往哪个城市
- Queue = 城市下的不同分区
- Tag = 快件类型(文件、电子产品)
- Group = 寄件人分组(同组共同消费消息)
与 Kafka/RabbitMQ 对比
| 维度 | RocketMQ | Kafka | RabbitMQ |
|---|---|---|---|
| 语言 | Java | Scala/Java | Erlang |
| 单机 TPS | 10 万+ | 百万级 | 万级 |
| 延迟 | ms 级 | ms 级 | μs 级 |
| 事务消息 | 支持 | 不支持 | 插件支持 |
| 顺序消息 | 支持 | 分区内有序 | 队列有序 |
| 消息回溯 | 支持(按时间) | 不支持 | 不支持 |
| 适用 | 金融/电商 | 大数据/日志 | 企业集成 |
4.2 事务消息(简历写了,面试必问)
小白讲解
普通消息有个致命问题:
场景:下单 → 扣库存 → 发消息 → 加积分
问题:如果"发消息"和"扣库存"是两步操作:
1. 扣库存成功
2. 发消息失败(网络抖动)
→ 库存扣了,积分没加 → 数据不一致
事务消息解决了这个问题:先发"半消息" → 执行本地事务 → 根据结果提交/回滚半消息
事务消息流程
┌──────────┐ ┌──────────┐
│ Producer │ ──1. 发送半消息────────────→ │ Broker │
│ │ ←─2. 半消息发送成功────────── │ │
│ │ │ (半消息 │
│ │ ──3. 执行本地事务(扣库存)─→ │ 对消费 │
│ │ │ 者不可见)│
│ │ │ │
│ │ ──4. 提交/回滚半消息────→ ──→│ │
│ │ (本地事务成功→提交 │ │
│ │ 本地事务失败→回滚) │ │
└──────────┘ └──────────┘
异常情况:如果第 4 步没到(网络问题)
→ Broker 定期回查 Producer:"第 3 步的本地事务到底成功没有?"
→ Producer 实现 checkLocalTransaction() 查询本地事务状态
→ 返回 COMMIT / ROLLBACK / UNKNOWN
代码示例
// 事务消息生产者
@Component
public class OrderTransactionProducer {
@Autowired
private TransactionMQProducer producer;
@PostConstruct
public void init() throws MQClientException {
producer.setTransactionListener(new TransactionListener() {
@Override
public LocalTransactionState executeLocalTransaction(Message msg, Object arg) {
// 步骤 3:执行本地事务
try {
String orderId = (String) arg;
orderService.deductStock(orderId); // 扣库存
return LocalTransactionState.COMMIT_MESSAGE;
} catch (Exception e) {
return LocalTransactionState.ROLLBACK_MESSAGE;
}
}
@Override
public LocalTransactionState checkLocalTransaction(MessageExt msg) {
// 步骤 4(异常回查):检查本地事务是否成功
String orderId = msg.getKeys();
Order order = orderService.getById(orderId);
if (order != null && order.getStatus() == OrderStatus.PAID) {
return LocalTransactionState.COMMIT_MESSAGE;
}
return LocalTransactionState.ROLLBACK_MESSAGE;
}
});
producer.start();
}
// 发送事务消息
public void sendOrderMessage(String orderId) {
Message msg = new Message("order_topic", orderId.getBytes());
producer.sendMessageInTransaction(msg, orderId);
}
}
面试题
Q1:RocketMQ 事务消息和 Kafka 事务有什么区别?
RocketMQ 事务消息解决的是本地事务 + 消息发送的原子性(确保本地事务成功消息才可见)。Kafka 事务解决的是多个消息的原子性写入(一批消息要么全成功要么全失败)。本质不同:RocketMQ 是“事务+消息”,Kafka 是“消息+事务”。
Q2:消息消费幂等怎么保证?(简历写了“幂等键 userId+activityId+date”)
@RocketMQMessageListener(topic = "activity_reward", consumerGroup = "reward_group")
public class RewardConsumer implements RocketMQListener<String> {
@Override
@Transactional
public void onMessage(String message) {
RewardMsg msg = JSON.parseObject(message, RewardMsg.class);
// 幂等键 = userId + activityId + date
String idempotentKey = msg.getUserId() + ":" + msg.getActivityId() + ":" + msg.getDate();
// 1. Redis SETNX 判重(快速拦截)
Boolean isNew = redis.opsForValue().setIfAbsent(
"idempotent:" + idempotentKey, "1", 24, TimeUnit.HOURS);
if (!isNew) return; // 已处理过,跳过
try {
// 2. 数据库唯一索引兜底
rewardService.grant(msg.getUserId(), msg.getActivityId());
} catch (DuplicateKeyException e) {
// 唯一索引冲突 → 已处理过
}
}
}
Q3:RocketMQ 怎么保证消息不丢失?
| 环节 | 措施 |
|---|---|
| Producer | 同步发送 + 重试(retryTimesWhenSendFailed) |
| Broker | 同步刷盘 + 主从复制 |
| Consumer | 手动 ACK(消费成功才返回 CONSUME_SUCCESS) |
五、Redisson 分布式锁(简历写了)
你简历的使用场景: 社区平台——Redis + Lua 预减库存、DCL 缓存击穿
面试官心理: “分布式锁怎么实现的?Redis SETNX 有什么问题?Redisson 怎么解决的?”
5.1 分布式锁演进
从 SETNX 到 Redisson
// ==================== 第一代:SETNX(有问题)====================
// 问题 1:加锁后程序崩溃 → 锁永远不释放(死锁)
// 问题 2:锁过期了但业务没执行完 → 别人拿到锁,自己还在执行 → 并发问题
// 问题 3:不可重入(同一线程不能再次获取同一把锁)
Boolean locked = redis.opsForValue()
.setIfAbsent("lock:order:123", "value", 30, TimeUnit.SECONDS);
// ==================== 第二代:SETNX + 唯一值 + Lua 删除(解决误删)====================
// 加锁时设置唯一值(UUID),删除时用 Lua 保证"判断+删除"原子性
String lockValue = UUID.randomUUID().toString();
redis.opsForValue().setIfAbsent("lock:order:123", lockValue, 30, TimeUnit.SECONDS);
// 删除锁(Lua 保证原子性)
String luaScript =
"if redis.call('get', KEYS[1]) == ARGV[1] then " +
" return redis.call('del', KEYS[1]) " +
"else return 0 end";
redis.execute(new DefaultRedisScript<>(luaScript, Long.class),
Collections.singletonList("lock:order:123"), lockValue);
// ==================== 第三代:Redisson(推荐!全部解决)====================
RLock lock = redissonClient.getLock("lock:order:123");
try {
lock.lock(30, TimeUnit.SECONDS); // 加锁,30 秒自动过期
// 业务逻辑
} finally {
lock.unlock();
}
Redisson 看门狗(WatchDog)机制
问题:锁设 30 秒过期,但业务执行了 40 秒怎么办?
Redisson 解决方案:看门狗自动续期
1. lock.lock(30, TimeUnit.SECONDS) 加锁,TTL = 30 秒
2. 启动 WatchDog 定时任务,每 10 秒(TTL/3)检查一次
3. 如果当前线程仍持有锁 → 续期到 30 秒
4. 如果当前线程已释放锁或宕机 → 停止续期,锁到期自动释放
→ 业务执行多久都不会死锁
Redisson 可重入锁原理
// 同一线程可以多次获取同一把锁(可重入)
RLock lock = redissonClient.getLock("myLock");
lock.lock(); // 第一次加锁:count = 1
lock.lock(); // 第二次加锁:count = 2(可重入)
lock.unlock(); // 第一次解锁:count = 1
lock.unlock(); // 第二次解锁:count = 0,真正释放锁
底层用 Redis Hash 结构:
{lockName: {threadId: count}},count 记录重入次数。
面试题
Q1:Redis 做分布式锁有什么问题?Redisson 怎么解决?
| 问题 | SETNX | Redisson |
|---|---|---|
| 死锁 | 崩溃后锁不释放 | 默认 30 秒自动过期 |
| 误删 | 删了别人的锁 | Lua 脚本判断唯一值再删 |
| 不可重入 | 同线程再获取失败 | Hash 结构记录重入次数 |
| 自动续期 | 无 | WatchDog 定时续期 |
| 公平性 | 非公平 | 支持公平锁 |
Q2:RedLock 算法是什么?有什么争议?
RedLock 由 Redis 作者提出:在 N 个独立 Redis 实例上加锁,超过半数(N/2+1)成功才算加锁成功。用于解决单点 Redis 主从切换丢锁问题。
争议:Martin Kleppmann 指出 RedLock 依赖时钟同步,如果某节点时钟跳变可能导致锁失效。生产环境一般用 Zookeeper 或单 Redis + 容忍少量误差。
Q3:你简历写了 Redis + Lua 预减库存,说说原理?
-- 预减库存 Lua 脚本(原子操作)
local key = KEYS[1] -- 库存 key
local stock = tonumber(ARGV[1]) -- 要减的数量
local current = tonumber(redis.call('get', key) or '0')
if current >= stock then
redis.call('decrby', key, stock) -- 预扣减
return 1 -- 成功
else
return 0 -- 库存不足
end
用 Lua 保证“检查库存 + 扣减”的原子性,利用 Redis 单线程模型避免并发超卖。预减库存后异步发 MQ 真正下单,如果下单失败再回滚库存。
六、ElasticSearch(简历写了全文检索)
你简历的使用场景: 社区平台——搭建全文检索引擎,支持内容审核与敏感词过滤
面试官心理: “ES 倒排索引了解吗?跟 MySQL like 有什么区别?”
6.1 核心概念
小白讲解
ES 搜索和 MySQL LIKE 的区别:
- MySQL LIKE ‘%关键词%’ = 翻每本书从头到尾找这个关键词(慢)
- ES = 先建了一个目录,告诉你“关键词在第 3 页、第 5 页、第 8 页”(快)
这个目录就叫倒排索引。
| MySQL | ElasticSearch |
|---|---|
| Database | Index(索引) |
| Table | Mapping(类型,7.x 后废弃) |
| Row | Document(文档 JSON) |
| Column | Field(字段) |
| Schema | Mapping |
倒排索引原理
正排索引(MySQL):
文档 ID → 内容
1 → "Java 是最好的语言"
2 → "Python 也不错"
3 → "Java 和 Python 都很好"
倒排索引(ES):
关键词 → 文档 ID 列表
"Java" → [1, 3]
"Python" → [2, 3]
"语言" → [1]
"不错" → [2]
搜索 "Java" → 直接查倒排表 → 返回 [1, 3]
搜索 "Java Python" → 交集 → 返回 [3]
分词器
// 原文:"我是Java开发工程师"
// 标准分词器(对中文不友好):"我", "是", "Java", "开", "发", "工", "程", "师"
// IK 分词器(推荐中文):"我", "是", "Java", "开发", "工程师"
// IK 分词模式
// ik_smart:粗粒度——"Java开发工程师" → "Java", "开发工程师"
// ik_max_word:细粒度——"Java", "开发", "工程师"
代码示例(Spring Data ES)
// 文档实体
@Document(indexName = "article")
@Data
public class Article {
@Id
private String id;
@Field(type = FieldType.Text, analyzer = "ik_max_word")
private String title;
@Field(type = FieldType.Text, analyzer = "ik_max_word")
private String content;
@Field(type = FieldType.Keyword)
private String author;
@Field(type = FieldType.Date)
private LocalDateTime createTime;
}
// Repository
public interface ArticleRepository extends ElasticsearchRepository<Article, String> {
List<Article> findByTitleOrContent(String title, String content);
}
// 复杂查询
@Service
public class ArticleSearchService {
@Autowired
private ElasticsearchRestTemplate esTemplate;
public SearchHits<Article> search(String keyword, int page, int size) {
NativeSearchQuery query = new NativeSearchQueryBuilder()
.withQuery(QueryBuilders.multiMatchQuery(keyword, "title", "content")
.type(MatchQuery.Type.BEST_FIELDS) // 取最高分
.tieBreaker(0.3f)) // 其他字段加权
.withSort(SortBuilders.scoreSort().order(SortOrder.DESC)) // 按相关度排序
.withPageable(PageRequest.of(page, size))
.build();
return esTemplate.search(query, Article.class);
}
}
面试题
Q1:ES 为什么比 MySQL like 快?
ES 使用倒排索引:先对文档分词建词项→文档ID映射表,搜索时直接查词项对应的文档ID列表,O(1) 复杂度。MySQL like ‘%xxx%’ 是全表扫描 O(n),且不走索引。
Q2:ES 是如何保证集群高可用的?
| 机制 | 说明 |
|---|---|
| 分片 | 索引分成多个 Shard,分布在不同节点 |
| 副本 | 每个 Shard 有副本,主挂备上 |
| 选举 | Master 节点宕机后自动选新 Master |
| 脑裂 | minimum_master_nodes 防脑裂(7.x 后自动) |
Q3:ES 深度分页问题?
ES 默认只支持
from + size <= 10000的分页。深分页时 ES 要在所有分片上取from + size条数据合并排序,内存和性能开销巨大。解决方案:search_after(基于上一页最后一条的排序值)或scroll(游标方式)。
七、面试高频考点速查表
Spring Boot 高频
| 考点 | 一句话答案 |
|---|---|
| 自动配置原理 | 读 imports 文件 + @Conditional 条件装配 |
| @Transactional 失效场景 | 非public/内部调用/异常被吞/非RuntimeException |
| @Autowire vs @Resource | 先类型 vs 先名称 |
| Bean 生命周期 | 实例化→属性赋值→初始化→使用→销毁 |
| 循环依赖怎么解决 | 三级缓存(singletonObjects/earlySingletonObjects/singletonFactories) |
| @Configuration vs @Component | 全模式(CGLIB代理) vs 轻量模式 |
| AOP 用什么代理 | 有接口 JDK,无接口 CGLIB;Boot 默认 CGLIB |
| @Transactional 的 rollbackFor | 默认只回滚 RuntimeException,受检异常不回滚 → 必须显式写 rollbackFor = Exception.class |
| @Async 默认线程池 | SimpleAsyncTaskExecutor 每次新建线程、不限流 → 必须自定义线程池 |
| @Scheduled 两大坑 | 默认单线程(任务互相阻塞);分布式多实例重复执行 → 分布式锁 / XXL-Job |
| @Valid vs @Validated | @Validated 支持分组校验;@Valid 支持字段嵌套校验 |
| @ConditionalOnMissingBean | “用户没配我才配” → 约定优于配置的实现(自定义 Bean 自动覆盖默认) |
| @Retention 三策略 | SOURCE(编译丢弃) / CLASS(进 class 不加载) / RUNTIME(Spring 注解,可反射) |
| @SpringBootApplication 组成 | @SpringBootConfiguration + @EnableAutoConfiguration + @ComponentScan |
💡 注解的完整讲解(12 个小节 + 104 道面试题 + 一页纸速查表)见 1.7 Spring 常用注解全景详解。
Spring Cloud Alibaba 高频
| 考点 | 一句话答案 |
|---|---|
| Nacos AP/CP | 默认 AP(Distro),持久实例 CP(Raft) |
| Nacos 注册表存储 | 双层 ConcurrentHashMap |
| Sentinel 限流算法 | 滑动窗口 + 漏桶 + 令牌桶 |
| Sentinel 熔断状态 | Closed→Open→HalfOpen |
| Dubbo vs Feign | TCP/二进制 vs HTTP/JSON |
| Gateway 核心 | Route + Predicate + Filter |
| Seata 四模式 | AT(默认)/TCC/SAGA/XA |
MyBatis-Plus 高频
| 考点 | 一句话答案 |
|---|---|
| N+1 问题 | 查1次主表+N次子表,用@BatchSize或JOIN解决 |
| @BatchSize 原理 | 积累ID批量IN查询 |
| 逻辑删除 | @TableLogic,DELETE改UPDATE |
| 分页原理 | PaginationInnerInterceptor 拦截加 LIMIT |
| 一级缓存 | SqlSession 级,Boot 中基本无效 |
| 二级缓存 | namespace 级,生产不用(用Redis) |
MQ 高频
| 考点 | 一句话答案 |
|---|---|
| RocketMQ 事务消息 | 半消息→本地事务→提交/回滚+回查 |
| 消息幂等 | Redis SETNX + DB 唯一索引 |
| 消息不丢失 | 同步发送+同步刷盘+手动ACK |
| 消息顺序 | 同key到同queue,单线程消费 |
| 消息堆积 | 扩Consumer + 临时Topic转发 |
| RocketMQ vs Kafka | 事务消息/顺序/回溯 vs 高吞吐/日志 |
Redis 分布式锁高频
| 考点 | 一句话答案 |
|---|---|
| SETNX 问题 | 死锁/误删/不可重入/无续期 |
| Redisson 看门狗 | TTL/3 间隔自动续期 |
| 可重入原理 | Hash 结构存 threadId+count |
| Redis+Lua 预减库存 | 原子操作检查+扣减 |
| RedLock | N/2+1 实例加锁,有时钟争议 |
ElasticSearch 高频
| 考点 | 一句话答案 |
|---|---|
| 倒排索引 | 词项→文档ID列表,O(1)查找 |
| 分词器 | ik_max_word(细)/ik_smart(粗) |
| 深度分页 | search_after/scroll 替代 from+size |
| ES vs MySQL | 全文检索快 vs 事务/关联强 |
8 道综合场景题(结合你的简历)
题 1
问: 你的零碳平台用 Nacos + Sentinel,如果 Nacos 挂了,服务还能调用吗?
能。Nacos Client 会缓存服务列表到本地文件(
~/nacos/naming/),Nacos Server 挂了后 Consumer 使用本地缓存的地址继续调用。只是无法注册新服务和获取最新列表。Sentinel 规则同理——Client 本地缓存规则,Server 挂了用最后一版规则。
题 2
问: @Transactional 标注的方法 A 内部调用方法 B(也有 @Transactional),B 的事务生效吗?
不生效。因为
this.B()是目标对象直接调用,不经过代理。需要:①注入自己self.B();②AopContext.currentProxy().B();③将 B 拆到另一个 Service。
题 3
问: 你简历写了 DCL 双重检查锁,为什么必须加 volatile?
// DCL 单例
public class Singleton {
private static volatile Singleton instance; // 必须 volatile
public static Singleton getInstance() {
if (instance == null) { // 第一次检查
synchronized (Singleton.class) {
if (instance == null) { // 第二次检查
instance = new Singleton(); // 非原子操作!
}
}
}
return instance;
}
}
new Singleton()分三步:①分配内存 → ②初始化对象 → ③引用指向内存。如果不加 volatile,指令重排序可能变成 ①→③→②,其他线程在第一次检查时看到 instance 非 null(但还没初始化完),返回了一个半成品对象。
题 4
问: RocketMQ 事务消息的回查机制,如果 Producer 一直不回复怎么办?
Broker 默认回查 15 次(
transactionCheckMax=15),每次间隔 60 秒。超过次数后 Broker 主动回滚该半消息(标记为 ROLLBACK),避免半消息一直堆积。
题 5
问: Sentinel 的匀速排队模式和直接拒绝模式分别适合什么场景?
- 直接拒绝:突发流量保护场景,如秒杀,超出的请求直接返回 429
- 匀速排队:削峰填谷场景,如消息消费,请求匀速通过,利用漏桶算法。适合处理能力稳定但请求有突刺的场景
题 6
问: 你用 Redis + Lua 预减库存,如果 Redis 和 MySQL 数据不一致怎么办?
预减库存只是“锁定”,不是真正扣减。流程是:①Redis Lua 预减 → ②发 MQ → ③Consumer 异步真正扣 MySQL 库存。如果 Consumer 失败,MQ 重试,最终一致。如果 Redis 挂了,降级到 DB 扣减(限流保护)。关键:Redis 是缓存的“可用额度”,MySQL 是“真实库存”,两者通过 MQ 最终一致。
题 7
问: Spring Boot 启动时自动配置了那么多 Bean,会不会很慢?
不会。自动配置类虽然多(200+),但每个都有
@Conditional条件判断。只有类路径存在对应依赖时才真正创建 Bean。比如没引 Redis 依赖,RedisAutoConfiguration的@ConditionalOnClass(RedisOperations.class)不满足,直接跳过,不会实例化。
题 8
问: 你简历写了 MyBatis-Plus 的 @BatchSize,说说从 N+1 到批量查询的 SQL 变化?
N+1 模式:
SELECT * FROM order WHERE ...(1 条)+ 循环SELECT * FROM user WHERE id = ?(N 条)= N+1 条 SQL。 @BatchSize 模式:SELECT * FROM order WHERE ...(1 条)+SELECT * FROM user WHERE id IN (?, ?, ..., ?)(N/size 条)= 1 + N/size 条 SQL。假设 N=1000、size=100,从 1001 条降到 11 条。
学习资源汇总
| 技术 | 官方文档 | 推荐视频 |
|---|---|---|
| Spring Boot | https://docs.spring.io/spring-boot/docs/current/reference/html/ | 黑马 BV1Lq4y1s7HQ |
| Spring Framework | https://docs.spring.io/spring-framework/reference/ | 尚硅谷 BV1P4y1S7W9b |
| Spring Cloud Alibaba | https://sca.aliyun.com/zh-cn/ | 黑马 BV1Lb4y1A7Hq |
| Nacos | https://nacos.io/zh-cn/docs/v2/quickstart/quick-start.html | 官方文档足够 |
| Sentinel | https://sentinelguard.io/zh-CN/docs/introduction.html | 官方文档足够 |
| Dubbo | https://dubbo.apache.org/zh/docs3-v2/java-sdk/quick-start/ | 官方文档足够 |
| MyBatis-Plus | https://baomidou.com/ | 尚硅谷 BV1cE411M7r8 |
| RocketMQ | https://rocketmq.io/zh/docs/ | 黑马 BV1Lb4y1A7Hq |
| Redisson | https://redisson.org/ | 官方文档 + GitHub |
| ElasticSearch | https://www.elastic.co/guide/en/elasticsearch/reference/current/ | 黑马 BV1Lb4y1A7Hq |
动手练习清单
□ 练习 1:手写一个 Spring Boot Starter(自动配置 + @Conditional)
□ 练习 2:写 @Transactional 各种失效场景的测试用例,验证确实不回滚
□ 练习 3:搭一个 Nacos + Sentinel + Gateway 的三服务微服务 Demo
□ 练习 4:用 MyBatis-Plus 写 N+1 问题 Demo,对比 @BatchSize 前后的 SQL 数量
□ 练习 5:写一个 RocketMQ 事务消息的完整 Demo(含回查)
□ 练习 6:用 Redisson 实现分布式锁,故意让业务 sleep 超过 TTL,观察 WatchDog 续期
□ 练习 7:用 Spring Data ES 建索引 + IK 分词 + 中文搜索