Java 基础体系 · 第 24/100 篇。示例统一以 Java 25 LTS 为语言和 JVM 基线;框架示例使用与其兼容的现代稳定版本。

Spring Framework 核心:IoC、Bean 生命周期、AOP、事件和事务代理

Spring 的核心不是“用注解替代 new”,而是把对象创建、依赖关系、扩展回调、横切逻辑和事务边界组织成一个可组合的运行时系统。理解这个系统,需要同时回答五个问题:

  1. 谁负责创建对象,以及对象依赖如何解析?
  2. 一个 Bean 从定义到销毁经历哪些状态?
  3. AOP 如何在不修改业务方法的情况下插入逻辑?
  4. 事件如何在 Bean 之间传播,事务事件又如何保证时机?
  5. @Transactional 为什么有时生效、有时完全不生效?

下文中的 Spring API 属于 Spring Framework 的稳定核心模型;具体 Spring Boot 版本应使用与 Java 25 兼容的版本,并以对应版本的官方文档为准。


一、IoC:把对象创建权交给容器

1.1 IoC 和 DI 的关系

IoC(Inversion of Control,控制反转)描述的是一种控制权变化:

  • 传统代码控制对象的创建、装配和生命周期;
  • Spring 容器控制这些过程;
  • 业务对象只声明自己需要什么,不负责寻找或创建依赖。

DI(Dependency Injection,依赖注入)是实现 IoC 的主要方式。一个对象通过构造器、字段或 Setter 接收依赖,而不是主动调用容器或 new 依赖对象。

例如,下面的代码把依赖创建写死在业务类中:

public final class OrderService {
    private final PaymentClient paymentClient = new PaymentClient();
}

这种写法的问题不是 new 本身,而是 OrderService 同时承担了两种职责:

  1. 处理订单业务;
  2. 决定 PaymentClient 的具体实现和生命周期。

使用构造器注入后:

@Service
public class OrderService {

    private final PaymentClient paymentClient;

    public OrderService(PaymentClient paymentClient) {
        this.paymentClient = paymentClient;
    }
}

OrderService 仍然依赖 PaymentClient,但不再负责创建它。容器根据 Bean 定义解析依赖并调用构造器。

构造器注入还使依赖关系成为对象成立的必要条件:

public OrderService(PaymentClient paymentClient) {
    this.paymentClient = Objects.requireNonNull(paymentClient);
}

如果容器无法提供 PaymentClient,应用通常会在启动阶段失败,而不是等到一次请求执行时才出现空指针异常。


1.2 容器中的核心对象:BeanDefinition、BeanFactory 和 ApplicationContext

Spring 容器至少可以从三个层面理解:

BeanDefinition:对象的“说明书”

BeanDefinition 描述如何创建一个 Bean,例如:

  • Bean 的类型;
  • 构造器或工厂方法;
  • 作用域;
  • 属性依赖;
  • 初始化和销毁方法;
  • 是否延迟初始化。

注解并不是 Bean 本身。@Component@Service@Configuration 等注解通常先被扫描器读取,再转换成容器内部的 BeanDefinition。

BeanFactory:Bean 的创建和获取引擎

BeanFactory 负责:

Object bean = beanFactory.getBean("orderService");

它需要完成:

  1. 查找 BeanDefinition;
  2. 解析依赖;
  3. 创建实例;
  4. 执行后处理器和初始化回调;
  5. 返回最终对象。

ApplicationContext:带基础设施的容器

ApplicationContext 建立在 BeanFactory 之上,额外提供:

  • 应用事件发布;
  • 国际化消息;
  • 资源加载;
  • 自动注册部分基础设施 Bean;
  • 更完整的生命周期管理。

Spring Boot 启动时创建的通常是 ApplicationContext,而不是裸 BeanFactory


1.3 依赖解析可以看成有向图

把每个 Bean 看成一个节点,把“依赖”看成一条有向边:

OrderController ──依赖──> OrderService ──依赖──> OrderRepository

记依赖图为:

G=(V,E)G=(V,E)

其中:

  • VV 是 Bean 集合;
  • EE 是依赖边;
  • 如果 ABA \rightarrow B,表示创建 AA 之前需要得到 BB

对于构造器注入,容器必须找到一个满足依赖顺序的创建过程。若图是有向无环图,可以通过拓扑排序得到创建顺序:

OrderRepository
        ↓
OrderService
        ↓
OrderController

如果存在构造器循环:

A ──> B
↑     │
└─────┘

则不存在满足“构造 A 前先得到 B,同时构造 B 前先得到 A”的普通顺序。容器会报告循环依赖,而不是凭空构造出两个完整对象。

Setter 或字段注入有时可以通过“先实例化、后注入属性”解决单例循环,但这依赖特定的单例创建机制,并不等价于所有循环都能解决。构造器循环通常无法通过这种方式绕过。


1.4 多个候选 Bean:类型、名称和限定符

当容器中只有一个 PaymentClient 时,下面的依赖通常可以唯一解析:

public OrderService(PaymentClient paymentClient) {
    this.paymentClient = paymentClient;
}

如果存在两个实现:

@Component
class AlipayClient implements PaymentClient {}

@Component
class WechatPayClient implements PaymentClient {}

则按类型注入会产生歧义。可以使用 @Primary

@Component
@Primary
class AlipayClient implements PaymentClient {}

或者使用限定符:

@Service
public class OrderService {

    private final PaymentClient paymentClient;

    public OrderService(@Qualifier("wechatPayClient")
                        PaymentClient paymentClient) {
        this.paymentClient = paymentClient;
    }
}

这里的关键不是“Spring 偏好哪一个实现”,而是依赖解析必须得到唯一结果。无法唯一决定时,启动失败比随机选择更安全。


1.5 @Bean 和组件扫描的差别

组件扫描适合注册自己控制的类:

@Service
public class OrderService {
}

@Bean 适合注册第三方类或需要显式构造过程的对象:

@Configuration
public class ClientConfiguration {

    @Bean
    public ObjectMapper objectMapper() {
        return new ObjectMapper();
    }
}

@Configuration 类中的 @Bean 方法由 Spring 处理。对于标准配置类,容器通常会确保同一个配置方法产生的 Bean 仍受容器作用域管理,而不是每次普通 Java 调用都创建一个新对象。

需要区分两种调用:

@Configuration
class Config {

    @Bean
    public A a() {
        return new A();
    }

    @Bean
    public B b() {
        return new B(a()); // 通常得到容器中的 A
    }
}

和:

class PlainConfig {

    @Bean
    public A a() {
        return new A();
    }

    @Bean
    public B b() {
        return new B(a()); // 在普通 Java 配置类中可能每次直接创建 A
    }
}

前者依赖 Spring 对配置类的增强,后者不能假设有同样的拦截行为。使用方法参数注入通常更直接:

@Bean
public B b(A a) {
    return new B(a);
}

二、Bean 生命周期:从定义到销毁

2.1 Bean 生命周期的主要阶段

以普通单例 Bean 为例,生命周期可以抽象为:

flowchart TD
    A[读取 BeanDefinition] --> B[实例化对象]
    B --> C[属性依赖注入]
    C --> D[Aware 回调]
    D --> E[初始化前 BeanPostProcessor]
    E --> F[调用初始化方法]
    F --> G[初始化后 BeanPostProcessor]
    G --> H[放入单例缓存并对外提供]
    H --> I[容器关闭]
    I --> J[销毁前回调]
    J --> K[调用销毁方法]

每一步的意义不同:

  1. 读取 BeanDefinition:容器知道“怎么创建”。
  2. 实例化:调用构造器或工厂方法。
  3. 属性注入:设置字段、Setter 或其他属性依赖。
  4. Aware 回调:如果 Bean 实现了特定接口,容器向它提供上下文信息。
  5. 初始化前处理BeanPostProcessor 可以修改或替换对象。
  6. 初始化方法:执行 @PostConstructInitializingBean 或自定义初始化方法。
  7. 初始化后处理:AOP 代理通常在这个阶段生成。
  8. 对外提供:后续依赖拿到的可能已经不是原始对象,而是代理对象。
  9. 销毁:容器关闭时执行销毁回调。

“Bean 已经构造完成”不等于“Bean 已经初始化完成”,更不等于“Bean 已经可以安全对外提供服务”。


2.2 初始化回调的顺序和用途

一个 Bean 可以使用多种初始化机制:

@Component
public class CacheWarmup implements InitializingBean {

    @PostConstruct
    void loadConfiguration() {
        System.out.println("load configuration");
    }

    @Override
    public void afterPropertiesSet() {
        System.out.println("warm up cache");
    }

    @Bean(initMethod = "start")
    public SomeComponent component() {
        return new SomeComponent();
    }
}

常见的执行关系是:

  1. @PostConstruct 所对应的处理器执行;
  2. InitializingBean.afterPropertiesSet() 执行;
  3. 自定义 initMethod 执行;
  4. 后续初始化阶段的 BeanPostProcessor 执行。

具体细节由容器和处理器实现决定,但可以稳定地把这些回调理解为“依赖注入完成后、Bean 正式可用前”的初始化阶段。

初始化方法适合:

  • 校验配置;
  • 建立连接;
  • 加载小规模本地缓存;
  • 注册需要随着 Bean 创建而建立的资源。

不适合在其中执行不可控的长时间任务。因为非懒加载单例初始化发生在应用上下文刷新过程中,初始化阻塞可能直接延长启动时间或导致启动失败。


2.3 BeanPostProcessor:容器扩展机制

BeanPostProcessor 是 Spring 大量功能的基础:

public interface BeanPostProcessor {

    default Object postProcessBeforeInitialization(
            Object bean, String beanName) {
        return bean;
    }

    default Object postProcessAfterInitialization(
            Object bean, String beanName) {
        return bean;
    }
}

处理器可以:

  • 修改 Bean;
  • 包装 Bean;
  • 返回代理;
  • 检查注解;
  • 注入额外行为。

AOP、@Autowired@PostConstruct 等功能都依赖类似的后处理器机制。

关键规则是:后处理器返回的对象可能与原对象不是同一个对象。

Object raw = new OrderService();
Object exposed = postProcessor.postProcessAfterInitialization(raw, "orderService");

如果 exposed 是代理,那么其他 Bean 注入和从容器获取的通常是 exposed,而不是 raw。因此下面的日志可能显示代理类:

Object bean = context.getBean(OrderService.class);
System.out.println(bean.getClass());

这不是 Bean 被重复创建,而是 Bean 被包装了。


2.4 作用域决定生命周期

最常见的 singleton 作用域表示:在一个 ApplicationContext 内,容器通常只管理一个 Bean 实例。

@Component
@Scope(ConfigurableBeanFactory.SCOPE_PROTOTYPE)
public class RequestBuilder {
}

prototype 表示每次向容器请求时创建新实例,但容器通常只负责创建和初始化,不负责完整管理其销毁生命周期。

其他作用域的生命周期可能与 Web 请求、会话或线程相关。作用域越短,越不能把它无条件注入到生命周期更长的单例中。例如,把请求作用域对象直接保存进单例,可能造成上下文错误或状态污染。此时通常需要代理作用域对象或使用 ObjectProvider<T> 延迟获取。


2.5 循环依赖和早期引用

单例 Bean 创建过程中,Spring 内部会维护不同阶段的缓存,目的是在某些属性注入循环中暴露“早期引用”。早期引用可能已经是代理,也可能仍是原始对象,具体取决于创建阶段和相关后处理器。

因此循环依赖存在几个风险:

  • 构造器循环通常直接失败;
  • Setter 或字段循环有时能启动,但对象可能在完全初始化前被另一方使用;
  • AOP 代理参与循环时,可能出现“注入的是代理,缓存的是原对象”或版本相关的循环依赖错误;
  • Spring Boot 默认配置可能对循环依赖采取更严格的失败策略。

更可靠的修复方式是消除环,而不是依赖容器的早期引用:

A ──> B ──> A

可以通过提取第三个职责:

A ──> C <── B

如果两个类确实需要协作,也可以把运行时查询延迟到方法执行时,但这只是改变创建时机,不能自动消除设计上的强耦合。


三、AOP:代理如何插入横切逻辑

3.1 横切关注点和连接点

日志、权限、事务、缓存、监控等逻辑往往横跨多个业务类。若直接写进每个方法,会产生重复代码和不一致行为。

AOP(Aspect-Oriented Programming,面向切面编程)把这类横切关注点抽取为切面。

几个核心术语:

  • 连接点(Join Point):可以插入额外行为的位置。
  • 切点(Pointcut):用于筛选连接点的表达式。
  • 通知(Advice):在匹配位置执行的额外逻辑。
  • 切面(Aspect):切点和通知的组合。
  • 代理(Proxy):Spring AOP 中实际承载拦截行为的对象。

Spring AOP 基于代理的方法执行连接点。它不像完整 AspectJ 那样天然拦截任意字段访问、构造器执行或静态方法调用。


3.2 JDK 动态代理和 CGLIB 子类代理

假设有接口:

public interface PaymentService {
    void pay(long orderId);
}

Spring 可以创建 JDK 动态代理:

调用方
  │
  ▼
JDK Proxy ──> PaymentServiceImpl.pay()

JDK 代理实现接口,调用方法时进入 InvocationHandler

如果没有合适接口,或配置要求使用类代理,Spring 可以使用基于字节码生成的子类代理,常见实现是 CGLIB 机制:

PaymentServiceProxy extends PaymentServiceImpl

两种方式的边界不同:

目标 JDK 代理 子类代理
代理接口方法 可以 可以
代理具体类方法 只能通过接口暴露 通常可以
final 不可以 不可以继承
final 方法 接口代理只能代理接口调用;具体实现的 final 方法不具备可覆写拦截点 不能通过覆写拦截
private 方法 不在接口调用路径上 不能被子类覆写
static 方法 不属于实例方法调用链 不能按实例覆写

不要通过 bean.getClass() == MyService.class 判断是否是原始 Bean。代理存在时,这个判断可能失败;更稳妥的方式是使用容器按类型获取,或使用 Spring 提供的代理判断工具。


3.3 一个切面的完整执行过程

下面的切面记录方法耗时:

@Aspect
@Component
public class TimingAspect {

    @Around("execution(* com.example.order..*(..))")
    public Object measure(ProceedingJoinPoint pjp) throws Throwable {
        long start = System.nanoTime();
        try {
            return pjp.proceed();
        } finally {
            long elapsed = System.nanoTime() - start;
            System.out.println(pjp.getSignature() + " took " + elapsed + " ns");
        }
    }
}

一次外部调用的过程是:

controller
   │
   ▼
OrderService 代理
   │
   ├─ 执行 Around 通知之前的代码
   │
   ├─ pjp.proceed()
   │      │
   │      ▼
   │   目标对象的业务方法
   │
   └─ 执行 Around 通知之后的代码

如果通知不调用 proceed()

@Around("execution(* com.example.order..*(..))")
public Object deny(ProceedingJoinPoint pjp) {
    throw new AccessDeniedException("denied");
}

目标方法就不会执行。通知不仅可以观察调用,还可以:

  • 修改参数;
  • 替换返回值;
  • 转换异常;
  • 提前返回;
  • 终止调用。

这也是为什么切面错误可能改变业务语义,而不仅仅是增加日志。


3.4 自调用为什么绕过代理

下面的代码经常导致事务或日志失效:

@Service
public class OrderService {

    public void outer() {
        inner();
    }

    @Transactional
    public void inner() {
        // 期望开启事务
    }
}

外部调用实际是:

调用方
  ↓
OrderService 代理.outer()
  ↓
目标对象内部 this.inner()

outer() 内部的 inner() 等价于 this.inner()。它没有再次经过代理,因此事务拦截器没有机会执行。

这不是 Spring 的事务特殊规则,而是代理模型的直接结果。

可行的结构调整是把事务边界放到另一个 Bean:

@Service
public class TransactionalOrderService {

    @Transactional
    public void inner() {
        // 事务边界
    }
}

@Service
public class OrderFacade {

    private final TransactionalOrderService transactionalOrderService;

    public OrderFacade(TransactionalOrderService transactionalOrderService) {
        this.transactionalOrderService = transactionalOrderService;
    }

    public void outer() {
        transactionalOrderService.inner(); // 跨 Bean,经过代理
    }
}

AopContext.currentProxy() 可以在特定配置下取得当前代理,但它增加了对代理上下文的耦合,并且要求调用确实处于代理链中,通常不如拆分职责清晰。


3.5 通知顺序不是代码书写顺序

多个切面同时匹配时,执行顺序由优先级决定,而不是 Java 文件中注解的排列顺序。可以使用 @Order

@Aspect
@Component
@Order(10)
class SecurityAspect {
}
@Aspect
@Component
@Order(20)
class LoggingAspect {
}

数值越小通常优先级越高。若存在事务、权限、重试和日志,顺序会改变语义。例如:

权限检查
  ↓
事务开启
  ↓
重试包装
  ↓
业务方法

和:

事务开启
  ↓
重试包装
  ↓
业务方法

并不等价。重试究竟应该重新开启事务,还是在同一事务中重复执行,是必须明确的设计问题。


四、应用事件:发布、监听和失败路径

4.1 事件模型

Spring 事件的基本参与者是:

  • ApplicationEventPublisher:发布事件;
  • 事件对象:携带事实数据;
  • @EventListenerApplicationListener:接收事件。

现代代码通常可以直接使用普通对象作为事件:

public record OrderCreated(long orderId) {
}

发布事件:

@Service
public class OrderService {

    private final ApplicationEventPublisher publisher;

    public OrderService(ApplicationEventPublisher publisher) {
        this.publisher = publisher;
    }

    public void create(long orderId) {
        // 保存订单
        publisher.publishEvent(new OrderCreated(orderId));
    }
}

监听事件:

@Component
public class OrderCreatedListener {

    @EventListener
    public void onOrderCreated(OrderCreated event) {
        System.out.println("send notification for " + event.orderId());
    }
}

默认情况下,事件监听器通常在发布线程中同步执行。于是调用链是:

create()
  ├─ 保存订单
  ├─ publishEvent()
  │    └─ listener.onOrderCreated()
  └─ create() 返回

如果监听器抛出异常,异常可能沿发布调用链返回发布方。事件不是天然的消息队列,也不自动提供持久化、重试、跨进程传输或最终一致性。


4.2 同步监听和异步监听

启用异步执行后:

@Configuration
@EnableAsync
class AsyncConfiguration {
}
@Component
class NotificationListener {

    @Async
    @EventListener
    public void onOrderCreated(OrderCreated event) {
        // 在线程池中执行
    }
}

调用链变成:

publishEvent()
  ├─ 把任务提交到 Executor
  └─ 立即返回
       └─ 线程池执行 listener

这会改变三个重要事实:

  1. 发布方不再等待监听器完成;
  2. 监听器异常不会按同步调用方式直接返回给发布方;
  3. 事务上下文、ThreadLocal、请求上下文不会自动传播到新线程。

因此异步监听器必须独立处理:

  • 线程池容量;
  • 拒绝策略;
  • 异常记录;
  • 重试;
  • 幂等;
  • 关闭时仍在执行的任务。

4.3 事务事件的时机

如果事件表示“订单已经创建”,但发布发生在事务提交之前,普通监听器可能在事务回滚前就执行:

@Transactional
public void create() {
    repository.save(order);
    publisher.publishEvent(new OrderCreated(order.id()));
    // 后续异常导致事务回滚
}

同步监听器可能已经发出了通知,但数据库记录最终不存在。这是典型的不一致。

可以使用事务事件监听器:

@Component
public class OrderNotificationListener {

    @TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT)
    public void onCommitted(OrderCreated event) {
        // 只有事务成功提交后执行
    }
}

常见阶段包括:

  • BEFORE_COMMIT:提交前;
  • AFTER_COMMIT:提交成功后;
  • AFTER_ROLLBACK:回滚后;
  • AFTER_COMPLETION:提交或回滚完成后。

AFTER_COMMIT 解决的是“不要在回滚事务上发送通知”,但它不等于可靠消息投递。进程可能在数据库提交成功后、监听器执行前崩溃,导致事件丢失。

如果需要跨进程可靠传播,通常需要事务消息、Outbox 表或消息中间件等额外机制。Outbox 的基本过程是:

同一数据库事务
  ├─ 写业务表
  └─ 写 outbox_event 表

后台投递器
  ├─ 读取未发送事件
  ├─ 投递消息
  └─ 标记已发送

投递通常仍可能重复,因此消费者必须幂等。可靠性来自“可恢复的持久化状态”,而不是 @TransactionalEventListener 这一注解本身。


五、事务代理:@Transactional 究竟做了什么

5.1 事务的抽象

Spring 不直接把事务逻辑写死在业务类中,而是通过 PlatformTransactionManager 抽象数据库或其他资源的事务操作:

public interface PlatformTransactionManager {
    TransactionStatus getTransaction(TransactionDefinition definition);
    void commit(TransactionStatus status);
    void rollback(TransactionStatus status);
}

具体实现可能是:

  • JDBC 事务管理器;
  • JPA 事务管理器;
  • JTA 事务管理器;
  • 其他资源的事务管理器。

@Transactional 只是声明事务属性,例如:

@Transactional(
    propagation = Propagation.REQUIRED,
    isolation = Isolation.READ_COMMITTED,
    timeout = 5,
    readOnly = false
)
public void createOrder() {
}

真正执行事务的是事务拦截器和事务管理器。


5.2 一次事务代理调用的步骤

假设外部调用了被 @Transactional 标记的方法:

sequenceDiagram
    participant C as 调用方
    participant P as 事务代理
    participant TM as TransactionManager
    participant T as 目标对象
    participant DB as 数据库

    C->>P: createOrder()
    P->>TM: getTransaction()
    TM->>DB: BEGIN 或加入现有事务
    P->>T: 执行业务方法
    T->>DB: INSERT / UPDATE
    T-->>P: 正常返回或抛出异常
    alt 正常返回
        P->>TM: commit()
        TM->>DB: COMMIT
        P-->>C: 返回结果
    else 满足回滚规则的异常
        P->>TM: rollback()
        TM->>DB: ROLLBACK
        P-->>C: 抛出异常
    end

对于 Propagation.REQUIRED

  • 当前线程没有事务:创建新事务;
  • 当前线程已有事务:加入现有事务;
  • 内层方法返回时通常不会独立提交外层事务。

事务资源通常绑定在线程上下文中。以 JDBC 为例,Spring 会把当前事务对应的连接绑定到当前线程,使同一事务内的数据访问复用正确连接。这个线程绑定模型解释了为什么事务不会自动跨越新线程。


5.3 默认回滚规则

常见默认规则是:

  • RuntimeException 及其子类:回滚;
  • Error:回滚;
  • 受检异常 Exception:默认不一定回滚。

例如:

@Transactional
public void pay() throws IOException {
    savePayment();
    throw new IOException("network failure");
}

如果希望受检异常也回滚:

@Transactional(rollbackFor = IOException.class)
public void pay() throws IOException {
    savePayment();
    throw new IOException("network failure");
}

反过来,也可以指定某类异常不回滚:

@Transactional(noRollbackFor = BusinessNoticeException.class)
public void process() {
}

异常是否回滚和异常是否被捕获是两个问题。下面的代码可能导致事务正常提交:

@Transactional
public void process() {
    try {
        repository.save();
        callRemote();
    } catch (RuntimeException ex) {
        log.warn("failed", ex);
    }
}

异常已经在事务代理之外不可见,代理看到的是正常返回。如果业务要求回滚,应重新抛出异常,或者显式标记回滚:

@Transactional
public void process() {
    try {
        repository.save();
        callRemote();
    } catch (RuntimeException ex) {
        TransactionAspectSupport.currentTransactionStatus()
                .setRollbackOnly();
        throw ex;
    }
}

不过更清晰的做法通常是让异常传播到事务边界,由回滚规则统一处理。


5.4 REQUIREDREQUIRES_NEWNESTED

REQUIRED

@Transactional(propagation = Propagation.REQUIRED)
public void a() {
}

当前有事务就加入,没有就创建。它适合大多数同一业务操作中的服务方法。

REQUIRES_NEW

@Transactional(propagation = Propagation.REQUIRES_NEW)
public void writeAudit() {
}

它会挂起外层事务并创建独立事务:

外层事务 T1
  ├─ 暂停 T1
  ├─ 执行内层事务 T2
  ├─ 提交或回滚 T2
  └─ 恢复 T1

这意味着审计记录可能在外层业务回滚后仍然提交。它也可能增加连接池压力:外层事务占用一个连接,内层新事务还需要另一个连接。并发较高时,连接池容量必须能够承受这种嵌套占用。

NESTED

NESTED 通常依赖底层资源支持保存点。它不是一个完全独立的事务,而是在现有事务中建立保存点:

T1
  ├─ savepoint S
  ├─ 内层失败:回滚到 S
  └─ 外层仍可继续

是否支持、具体行为取决于事务管理器和底层资源,不能仅凭注解名称推断所有环境都具有相同语义。


5.5 readOnly 和隔离级别不是强制魔法

@Transactional(readOnly = true)
public Order find(long id) {
    return repository.findById(id).orElseThrow();
}

readOnly = true 是事务属性提示。数据库驱动、ORM 和事务管理器可能据此进行优化或限制,但它不等于 Java 层面禁止所有写操作,也不能保证任何数据库都拒绝写入。

隔离级别控制并发事务之间的数据可见性,例如:

  • READ_COMMITTED:只能读到已提交数据;
  • REPEATABLE_READ:同一事务内重复读取通常保持一致;
  • SERIALIZABLE:更强隔离,但可能降低并发;
  • DEFAULT:使用数据库默认级别。

实际效果取决于数据库的并发控制实现。声明更高隔离级别不一定解决业务竞态,例如“读取库存后再扣减”仍可能需要数据库锁、原子更新或乐观锁。


5.6 事务代理失效的典型情况

自调用

this.inner();

不经过代理,事务拦截器不执行。

方法不可被代理覆写

privatestaticfinal 方法不适合作为基于子类代理的拦截目标。事务边界应放在对外暴露的、可被代理调用的方法上。

从对象内部直接调用目标实例

如果代码绕过容器:

OrderService service = new OrderService(repository);
service.create();

这个对象没有被 Spring 事务代理包装,@Transactional 不会自动生效。

异步线程中继续使用原事务

@Transactional
public void create() {
    executor.execute(() -> repository.saveOtherData());
}

新线程不会自动继承当前线程绑定的事务连接。异步任务需要自己建立事务,或者重新设计事务和消息边界。


六、把 IoC、生命周期、AOP、事件和事务串起来

下面给出一个最小的订单示例。它展示:

  • IoC 通过构造器注入;
  • 事务由代理建立;
  • 保存成功后发布领域事件;
  • 事件在事务提交后处理;
  • Bean 在初始化阶段校验配置。
package com.example.order;

import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
import org.springframework.context.ApplicationEventPublisher;
import org.springframework.stereotype.Component;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import org.springframework.transaction.event.TransactionPhase;
import org.springframework.transaction.event.TransactionalEventListener;

import jakarta.annotation.PostConstruct;

@SpringBootApplication
public class OrderApplication {
    public static void main(String[] args) {
        SpringApplication.run(OrderApplication.class, args);
    }
}

record OrderCreated(long orderId) {
}

record Order(long id, String status) {
}

interface OrderRepository {
    void save(Order order);
}

@Component
class InMemoryOrderRepository implements OrderRepository {

    @Override
    public void save(Order order) {
        System.out.println("save order " + order.id());
    }
}

@Service
class OrderService {

    private final OrderRepository repository;
    private final ApplicationEventPublisher events;

    OrderService(OrderRepository repository,
                 ApplicationEventPublisher events) {
        this.repository = repository;
        this.events = events;
    }

    @PostConstruct
    void validate() {
        System.out.println("OrderService initialized");
    }

    @Transactional
    public void create(long orderId) {
        repository.save(new Order(orderId, "CREATED"));
        events.publishEvent(new OrderCreated(orderId));
    }
}

@Component
class OrderCreatedHandler {

    @TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT)
    public void handle(OrderCreated event) {
        System.out.println("notify order " + event.orderId());
    }
}

这个示例如果使用内存仓储,不能真实演示数据库提交和回滚;要观察真实事务行为,需要把仓储替换为 JDBC、JPA 等支持事务的实现,并注册对应的事务管理器。

调用过程是:

外部调用 OrderService.create()
        │
        ▼
OrderService 的事务代理
        │
        ├─ 开启或加入事务
        ├─ 调用目标对象 create()
        │    ├─ repository.save()
        │    └─ publishEvent()
        │         └─ AFTER_COMMIT 监听器暂不执行
        ├─ 方法正常返回
        ├─ 提交事务
        └─ 执行 OrderCreatedHandler

如果 create() 在发布事件后抛出运行时异常:

@Transactional
public void create(long orderId) {
    repository.save(new Order(orderId, "CREATED"));
    events.publishEvent(new OrderCreated(orderId));
    throw new IllegalStateException("simulate failure");
}

事务会回滚,AFTER_COMMIT 监听器不会执行。若改成普通 @EventListener,监听器可能已经在事务提交前执行,因此不能把普通事件监听器当成提交成功通知。


七、端到端运行方式与验证

7.1 创建工程

使用 Spring Initializr 创建 Maven 或 Gradle 工程,选择:

  • Java 25;
  • Spring Boot 的当前稳定版本;
  • Spring Web;
  • Spring Context;
  • Spring TX;
  • 真实项目中的 JDBC 或 JPA;
  • 测试依赖。

Spring Boot 负责自动配置,例如:

  • 创建 ApplicationContext
  • 扫描启动类所在包及其子包;
  • 根据类路径上的 Starter 注册基础设施;
  • 绑定配置;
  • 注册事务、Web 和数据库相关 Bean。

如果只使用上面的内存仓储,事务管理器可能没有真实资源可管理,因此只能验证 IoC、生命周期和事件注册,不能验证数据库回滚。

7.2 启动并观察结果

启动:

./mvnw spring-boot:run

或:

./gradlew bootRun

预期至少能看到:

OrderService initialized

如果通过测试或控制器调用:

orderService.create(1001L);

内存仓储示例输出类似:

save order 1001
notify order 1001

但是 notify 是否严格发生在数据库提交之后,必须使用真实事务资源验证。验证方法包括:

  1. create() 中写入数据库;
  2. 写入后抛出 RuntimeException
  3. 检查业务表中没有该记录;
  4. 检查 AFTER_COMMIT 监听器没有执行;
  5. 改为正常返回,确认记录存在且监听器执行。

测试不应只断言日志,因为日志顺序不能证明数据库提交状态。


八、常见误解和诊断方法

8.1 “加了 @Transactional 就有事务”

诊断事务是否实际生效,需要检查调用路径:

调用方 → Spring Bean 代理 → 目标方法

而不是:

调用方 → new 出来的对象
调用方 → 同一个对象内部 this.method()

还要确认:

  • 事务管理器是否存在;
  • 数据访问是否使用同一个事务资源;
  • 方法是否匹配事务切点;
  • 是否被异常捕获;
  • 是否在异步线程中执行;
  • 是否因 readOnly、传播级别或数据库配置产生不同效果。

8.2 “事件就是可靠消息”

ApplicationEventPublisher 默认只是进程内调用机制。应用重启、进程崩溃或监听器失败都可能造成事件丢失或重复问题。需要持久化、重试和幂等时,应使用具备相应语义的消息系统或 Outbox 方案。

8.3 “Bean 构造器执行完就能使用”

构造器执行后,字段注入、@PostConstruct、代理包装等步骤可能尚未完成。不要在构造器中调用依赖当前 Bean 完整初始化状态的方法,也不要把 this 发布给外部线程或注册到外部系统。

8.4 “代理和目标对象是同一个对象”

通常不是。可以用以下方式诊断:

Object bean = context.getBean("orderService");

System.out.println(bean.getClass());
System.out.println(org.springframework.aop.support.AopUtils.isAopProxy(bean));
System.out.println(org.springframework.aop.support.AopUtils.isJdkDynamicProxy(bean));
System.out.println(org.springframework.aop.support.AopUtils.isCglibProxy(bean));

如果代理存在,应该从代理入口调用方法,不能保存原始对象引用绕过代理。

8.5 “事务回滚后数据库一定没有外部副作用”

事务只能原子地管理其覆盖的资源。HTTP 请求、邮件发送、文件写入和消息投递不一定与数据库事务共享同一个事务边界。

数据库事务成功
  ↓
调用邮件服务失败

或者:

发送邮件成功
  ↓
数据库事务回滚

都可能发生。解决方法不是盲目扩大 @Transactional,而是明确一致性要求,使用提交后事件、Outbox、补偿或幂等机制。


九、规范保证、实现细节与工程取舍

需要区分三个层次:

规范保证

Spring 的公开抽象保证了核心语义,例如:

  • Bean 由容器按定义创建;
  • @Transactional 通过事务拦截机制应用事务属性;
  • @TransactionalEventListener 根据事务阶段决定监听时机;
  • ApplicationEventPublisher 在应用上下文中传播事件。

常见实现

以下是常见但不应当当作所有实现都固定不变的细节:

  • 使用 JDK 动态代理或基于子类的代理;
  • 使用多级单例缓存处理部分早期引用;
  • 使用线程上下文绑定事务资源;
  • 使用后处理器生成代理和处理注解。

这些实现细节可能随 Spring 版本、代理配置和基础设施类型变化。

工程建议

以下属于设计建议,而不是框架强制规则:

  • 优先构造器注入;
  • 事务边界放在服务层;
  • 避免自调用依赖代理;
  • 将外部副作用放到提交后或可靠消息流程;
  • 让事件消费者具备幂等性;
  • 通过结构消除循环依赖;
  • 不把 prototype 或请求作用域对象随意保存到单例中。

掌握这些边界后,可以把一次典型调用准确还原为:

容器创建 Bean
  → 注入依赖
  → 执行初始化回调
  → 后处理器生成代理
  → 外部调用代理
  → AOP 拦截器建立事务
  → 目标方法发布事件
  → 事务提交或回滚
  → 按事件阶段执行监听器
  → 容器关闭时销毁 Bean

IoC 负责“对象从哪里来”,生命周期负责“对象何时可用”,AOP 负责“调用如何被拦截”,事件负责“事实如何在组件间传播”,事务代理负责“哪些操作在什么边界内提交或回滚”。Spring 的核心机制正是这些过程的组合,而不是任意一个注解单独产生的魔法。


系列导航与关联阅读

官方资料

本文依据 Java、Spring 与相关项目官方文档重新梳理;正文、示例与生产清单由 WR BLOG 编写。