第二阶段:开发框架(吃饭的家伙)

第二阶段:开发框架(吃饭的家伙)

定位: 你简历每个项目都在用 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 核心原理

你简历的使用场景: 所有项目的核心框架

面试官心理: “你说你用了 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 面向切面编程

小白讲解

想象你开了一家连锁餐厅,每家分店都要做这些事:

  1. 开门前检查卫生
  2. 营业中卖饭
  3. 关门后盘点库存

如果每家分店都自己写“检查卫生”和“盘点库存”的代码,就重复了。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,而是自己实现动态代理?

三个原因:

  1. 易用性:AspectJ 需要单独的 ajc 编译器和构建配置,Spring AOP 只需加依赖和注解,零侵入
  2. 够用原则:企业开发 80% 的 AOP 需求(事务、日志、权限、缓存)都是方法级拦截,Spring AOP 足够
  3. 容器集成: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 表达式去匹配包名和方法名,只需要在目标方法上加一个自定义注解,切面就自动生效。这意味着:

  1. 精确控制哪些方法需要增强(只有加了注解的才增强)
  2. 不依赖包结构(方法在哪个包无所谓)
  3. 业务代码只需加一行注解,零侵入
  4. 注解可以携带参数(如操作描述、限流 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 下是怎么解决幻读的?

两种机制配合:

  1. 快照读(普通 SELECT):通过 MVCC(多版本并发控制),事务第一次查询时生成 Read View,后续查询复用同一 Read View,始终读取事务开始时的快照数据,不受其他事务插入的影响。
  2. 当前读(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);
}

作用:

  1. Spring 会设置 JDBC Connection 为只读,数据库可做优化
  2. 配合读写分离(如 ShardingSphere、动态数据源)会自动路由到从库 ← 最大价值
  3. 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 提交
能否多个 可以多个 只能有一个
底层解析器 RequestParamMethodArgumentResolver RequestResponseBodyMethodProcessor(走 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 嵌套校验 对象字段

⚠️ 坑:@NotNull vs @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) → 能拿到 ✅

⚠️ 三个坑(必考):

  1. 只对类继承有效,对接口实现无效 —— 类实现带注解的接口,拿不到注解
  2. 只对类有效,对方法/字段无效 —— 子类重写父类方法,拿不到父类方法上的注解
  3. 只在 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,干两件事:

  1. 服务注册发现(黄页电话簿):每个微服务启动时到 Nacos 登记“我叫 order-service,住在 192.168.1.10:8080”,消费方需要调用时查黄页获取地址
  2. 动态配置管理(小区公告栏):配置统一放 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?

三个核心原因:

  1. 降低资源消耗: HTTP 短连接频繁创建/销毁,大量心跳请求导致 CPU 和文件描述符消耗高;gRPC 基于长连接,连接复用,资源消耗低
  2. 提升实时性: 1.x 长轮询有 30 秒延迟,2.x gRPC 流式推送毫秒级到达
  3. 统一协议: 2.x 统一用 gRPC 做注册发现和配置推送,简化了实现

Q8:Nacos 怎么做配置灰度发布?

我们生产环境用的是 Group 隔离 + 配置开关 的组合方式。

假设有 10 台机器(192.168.1.101 ~ 110),要把超时时间从 3000ms 改成 5000ms:

  1. 在 Nacos 的 prod 命名空间下创建 GRAY_GROUP,复制 DEFAULT_GROUP 的配置并修改 timeout=5000
  2. 灰度机器(101、102)的 application.yml 配置 group: GRAY_GROUP,其余 8 台仍读 DEFAULT_GROUP
  3. 配置里同时埋一个开关 feature.new-timeout.enabled,GRAY 组设 true,DEFAULT 组设 false
  4. 代码用 @RefreshScope + @Value 监听开关,true 走新逻辑,false 走旧逻辑
  5. 观察 30 分钟没问题 → 把 GRAY_GROUP 配置同步到 DEFAULT_GROUP → 全部生效
  6. 出问题?改回 enabled=false 秒级回滚,不用重新部署

如果只是临时验证一个小改动,也可以用 Nacos 控制台自带的 Beta 发布——直接指定灰度 IP 列表(如 192.168.1.101,192.168.1.102),不用改代码也不用改 Group。

Q9:@RefreshScope 的原理是什么?配置变更后 Bean 是怎么更新的?

@RefreshScope 会使 Bean 被代理,代理对象持有一个 RefreshScopedProxyBean。当 Nacos 配置变更时:

  1. Spring Cloud 收到 RefreshEvent 事件
  2. ContextRefresher 调用 Environment.getPropertySources() 更新配置源
  3. 广播 EnvironmentChangeEvent,所有 @ConfigurationProperties 的 Bean 自动重新绑定
  4. @RefreshScope 的 Bean 被标记为 stale(过期)
  5. 下次访问该 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 规则拦截(流控超限、熔断打开、系统限流),参数为 BlockException
  • fallback:处理业务代码抛出的异常(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:

  1. Dashboard 修改规则 → 调用 Nacos DataSource 写入 Nacos 配置中心
  2. Nacos 配置变更 → 推送到所有应用实例的 ReadableDataSource
  3. 应用监听到配置变更 → 重新加载规则到内存
  4. 应用重启 → 从 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 的区别?

  1. 按需加载: Java SPI 一次性加载所有实现类,即使不用也会加载(浪费资源);Dubbo SPI 按需加载,用到哪个加载哪个
  2. IOC 支持: Dubbo SPI 支持依赖注入,加载实现类时自动注入其依赖的其他扩展点
  3. AOP 包装: Dubbo SPI 支持 Wrapper 类自动包装,类似 Spring AOP,可以在扩展点前后加逻辑
  4. 自适应扩展: @Adaptive 注解让接口在运行时根据 URL 参数动态选择实现类
  5. 配置格式: Java SPI 是纯类名,Dubbo SPI 是 key=类名,可以按 key 获取

Q4:Dubbo 的 Failover 容错策略为什么不适合非幂等操作?

Failover 在调用失败后会自动切换到其他 Provider 重试。如果操作是非幂等的(如新增记录),第一次调用可能已经成功写入数据库,只是网络超时导致 Consumer 认为失败,重试就会写入重复数据。

非幂等操作应使用 Failfast 策略——只调用一次,失败直接抛异常,由业务层决定是否手动重试。

Q5:Dubbo 3.0 的 Triple 协议解决了什么问题?

  1. 跨语言互通: Dubbo 协议是私有二进制协议,其他语言无法直接调用;Triple 基于 gRPC,Go/Python/Node 等语言可以直接通过 gRPC 调用
  2. 网关穿透: Dubbo 协议走 TCP,无法穿透标准 HTTP 网关;Triple 走 HTTP/2,可以穿透 Nginx/K8s Ingress
  3. 流式调用: Dubbo 协议只支持请求-响应模式;Triple 支持 Server Stream / Client Stream / 双向流,适合实时推送、大文件传输等场景
  4. 生态对接: 可以直接和 gRPC 生态(Envoy、Istio)集成

Q6:Dubbo 服务调用超时了怎么排查?

  1. 确认超时位置: 看 Consumer 端日志,是 timeout 还是其他异常
  2. 看 Provider 端日志: 方法是否真的执行慢,还是根本没收到请求
  3. 网络排查: telnet Provider_IP Provider_Port 检查网络连通性
  4. 线程池满排查: Provider 端 Dubbo 线程池可能满了(日志会有 Thread pool is EXHAUSTED),查看 dubbo.protocol.threads 配置
  5. GC 排查: Provider 或 Consumer 可能 Full GC 导致 STW,看 GC 日志
  6. 合理设置超时: Consumer timeout 应大于 Provider timeout + 网络延迟

Q7:Dubbo 怎么做服务降级?

三种方式:

  1. mock 属性: @DubboReference(mock = "return null")——Provider 不可用时返回 null
  2. mock 类: 实现 UserServiceMock 类,自定义降级逻辑(返回缓存数据/默认值)
  3. 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) │
                   └──────────────┘

处理流程说明:

  1. 请求到达 Gateway,RoutePredicate 匹配路由规则
  2. 匹配成功后,请求依次经过 GatewayFilter(前置)→ GlobalFilter → GatewayFilter(后置)
  3. GlobalFilter 和 GatewayFilter 合并后按 @Order 排序执行
  4. 最后由 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 中,改了需要重启。生产环境需要动态路由:

  1. 配合 Nacos: 路由配置存在 Nacos,监听配置变更自动刷新路由表
  2. 数据库 + 内存: 路由规则存数据库,启动时加载到内存,修改后通过接口触发刷新
  3. 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 怎么解决跨域问题?

两种方式:

  1. 全局配置(推荐): 在 spring.cloud.gateway.globalcors 中配置,对所有路由生效
  2. ** 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 支持集群部署:

  1. 多 TC 节点: 多个 Seata Server 注册到 Nacos,微服务通过 Nacos 发现 TC 集群
  2. 共享存储: 所有 TC 节点共享同一个数据库(存储事务日志),保证状态一致
  3. 负载均衡: 微服务端通过 seata.service.vgroup-mapping 将事务组映射到 TC 集群,TC 间通过 Redis/DB 共享数据
  4. 故障转移: 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 分词 + 中文搜索