版本:Spring Boot 3.2.x / JDK 17 · 定位:理解 Spring Boot
一切「魔法」的地基
1. 导语
你写的第一段 Spring Boot 代码,大概率长这样:类上加
@SpringBootApplication,业务类上加
@Service,字段前加
private final UserMapper userMapper,再配上 Lombok 的
@RequiredArgsConstructor——然后程序就「魔法般」跑起来了。你没
new 任何对象,依赖却自动装配好了;给方法贴一个
@Transactional,事务就生效了;给类贴一个
@Aspect,日志和耗时统计就自动打印了。
这些不是黑盒魔法,它们全部建立在本篇要讲的两个地基之上:
- IoC(Inversion of
Control,控制反转):对象创建和依赖管理的控制权,从「你自己
new」反转到「容器统一装配」。依赖注入(Dependency
Injection,DI)是 IoC 最常见的落地方式。 - AOP(Aspect Oriented
Programming,面向切面编程):在不修改业务代码的前提下,横向插入公共逻辑(日志、事务、权限、限流)。@Transactional
本质就是一个 AOP 切面。
不理解这两块,你永远在「背配置」而不是「懂原理」;理解之后,后面所有阶段——自动配置、Web
开发、事务、缓存、安全——你都能一眼看穿它的实现套路。本篇会带你从零搭一个纯注解驱动的项目,讲透构造器注入为什么是生产唯一解、AOP
代理机制为什么会导致自调用失效,并手写日志切面、事件解耦、声明式事务,最后串成一个完整的用户/订单分层项目。
学完本篇,你就能回答那个最经典的问题:「给
@Transactional 的方法加上 try-catch
吞掉异常,事务为什么没回滚?」——答案不在配置里,在代理机制里。
2. 学习目标与前置要求
学完你能…
- 讲清 IoC 与 DI 的关系,并说出字段注入、Setter
注入、构造器注入三种方式的取舍,以及为什么生产环境一律用构造器注入。 - 独立写出「
@RequiredArgsConstructor+
final字段」的构造器注入代码,并用
@Autowired/@Qualifier/@Primary
解决多个候选 Bean 的歧义。 - 画出 Bean 完整生命周期,知道
@PostConstruct/@PreDestroy何时执行,以及
BeanPostProcessor为什么是 AOP 的基石。 - 写出健壮的 AOP 切面:掌握 5
种通知、execution切点表达式、环绕通知签名,以及自定义注解
@LogExecutionTime。 - 解释 AOP 失效两大场景(同类自调用、非 public
方法),能给出「JDK 动态代理 vs CGLIB」的准确区别,并知道 Spring Boot
默认用哪种。 - 用事件机制做模块解耦:
ApplicationEvent
+@EventListener,以及事务场景下必须用
@TransactionalEventListener的原因。 - 正确使用
@Transactional:讲清传播行为、隔离级别、4
个高频失效场景,并能写转账事务的自调用失效验证 Demo。
前置依赖
- 阶段 0(Java
基础):重点是注解(@interface、元注解、注解的运行时反射读取)和反射(Class、Method、Proxy)。AOP
的代理机制本质就是动态代理 + 反射,没这两个基础,第 3 章会很难啃。 - 若未掌握,建议先回看阶段 0
中「反射与动态代理」一节,再继续本篇。
本文技术栈
| 组件 | 版本 | 用途 |
|---|---|---|
| JDK | 17 | 运行环境 |
| Spring Boot | 3.2.x(文中以 3.2.5 为例) | 框架底座 |
| Maven | 3.8+ | 构建工具 |
| Lombok | 由 Spring Boot 管理 | @Data/@Slf4j/@RequiredArgsConstructor |
| H2 | 运行时 | 内存数据库,演示真实事务 |
3. 环境准备
本篇是第一个真正引入 Spring Boot
的篇章,先用官方脚手架初始化一个最小可运行工程。
3.1 初始化工程
任选一种方式:
方式 A:Spring Initializr 网页(https://start.spring.io/)
| 选项 | 取值 |
|---|---|
| Project | Maven |
| Language | Java |
| Spring Boot | 3.2.5 |
| Group | com.example |
| Artifact | demo |
| Java | 17 |
| Dependencies | Web、AOP、Validation、JDBC API、H2 Database、Lombok |
方式 B:命令行生成后解压
curl https://start.spring.io/starter.zip
-d type=maven-project
-d language=java
-d bootVersion=3.2.5
-d groupId=com.example
-d artifactId=demo
-d name=demo
-d packageName=com.example.demo
-d javaVersion=17
-d dependencies=web,aop,validation,jdbc,h2,lombok
-o demo.zip
unzip demo.zip && cd demo
3.2 核对 pom.xml
生成后确认 pom.xml 关键部分如下(版本号由
spring-boot-starter-parent
统一管理,子依赖无需手写版本):
<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 https://maven.apache.org/xsd/maven-4.0.0.xsd">
<modelVersion>4.0.0</modelVersion>
<parent>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-parent</artifactId>
<version>3.2.5</version>
<relativePath/>
</parent>
<groupId>com.example</groupId>
<artifactId>demo</artifactId>
<version>0.0.1-SNAPSHOT</version>
<name>demo</name>
<description>Spring Framework 核心:IoC 与 AOP</description>
<properties>
<java.version>17</java.version>
</properties>
<dependencies>
<!-- Web:提供 Spring MVC + 内嵌 Tomcat -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
</dependency>
<!-- AOP:提供 @Aspect / @EnableAspectJAutoProxy 支持 -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-aop</artifactId>
</dependency>
<!-- Validation:提供 JSR-303 参数校验(@NotNull 等) -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-validation</artifactId>
</dependency>
<!-- JDBC:提供 JdbcTemplate + DataSourceTransactionManager(真实事务) -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-jdbc</artifactId>
</dependency>
<!-- H2:内存数据库,跑完即丢,适合演示事务 -->
<dependency>
<groupId>com.h2database</groupId>
<artifactId>h2</artifactId>
<scope>runtime</scope>
</dependency>
<!-- Lombok:@Data / @Slf4j / @RequiredArgsConstructor -->
<dependency>
<groupId>org.projectlombok</groupId>
<artifactId>lombok</artifactId>
<scope>provided</scope>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-test</artifactId>
<scope>test</scope>
</dependency>
</dependencies>
<build>
<plugins>
<plugin>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-maven-plugin</artifactId>
</plugin>
</plugins>
</build>
</project>
3.3 初始化
schema.sql(供后续事务章节使用)
在 src/main/resources/ 下建 schema.sql,H2
启动时自动建表:
-- 用户账户表:余额字段用于演示转账事务
CREATE TABLE IF NOT EXISTS t_account (
id BIGINT PRIMARY KEY,
username VARCHAR(64) NOT NULL,
balance DECIMAL(10,2) NOT NULL DEFAULT 0
);
application.yml 配置 H2 与建表脚本:
spring:
datasource:
url: jdbc:h2:mem:demo;DB_CLOSE_DELAY=-1 # 内存库,连接断开也不清空
driver-class-name: org.h2.Driver
username: sa
password: ""
sql:
init:
mode: always # 每次启动执行 schema.sql
schema-locations: classpath:schema.sql
环境就绪后,正文各章节的示例都可在本工程里直接跑。前 1–4
章不依赖数据库,第 5 章事务需要上面这张表。
4. 正文章节
4.1 IoC 容器与依赖注入
4.1.1 什么是 IoC,它解决了什么
先看一段「没有 Spring」的代码:
// 反例:对象自己 new 依赖,控制权在自己手里
public class UserController {
// 依赖被硬编码在类内部,UserController 与 UserServiceImpl 强耦合
private UserService userService = new UserServiceImpl();
public void register(String username) {
userService.register(username);
}
}
这段代码的问题:
- 强耦合:
UserController
直接依赖具体实现UserServiceImpl。将来想换一个
UserService实现(比如换成调用 RPC 的版本),必须改
UserController源码。 - 难测试:单元测试时无法注入一个 Mock 对象,只能真跑
UserServiceImpl,还会连累它依赖的数据库。 - 生命周期失控:每个用到
UserService
的类都new
一个实例,内存里散落着大量重复对象,无法统一管理创建、初始化、销毁。
IoC
的思路:把「创建对象、装配依赖」的控制权,从代码里拿出来,交给一个容器(Container)。你不再
new,而是「声明我需要的依赖」,容器在启动时统一创建、装配、管理这些对象(在
Spring 里叫 Bean)。
// 正例:只声明依赖,不关心它从哪来、怎么创建
@RestController
public class UserController {
private final UserService userService; // 只声明「我需要 UserService」
public UserController(UserService userService) { // 由容器通过构造器注入
this.userService = userService;
}
}
DI(依赖注入)是 IoC 的落地手段:容器创建 Bean
时,发现 UserController 需要
UserService,就自动把后者「注入」进前者的构造器。IoC
是思想,DI 是具体做法,二者常被混用,你心里分清楚即可。
一句话总结:IoC
把「对象与对象之间如何连接」这件事从业务代码中剥离,交给容器统一管理,从而获得解耦、可测试、可统一治理三大收益。
4.1.2 三种注入方式对比
Spring 支持三种注入方式,生产环境的选择只有一个。
方式一:字段注入(Field Injection)—— 生产禁用
@Service
public class UserServiceImpl implements UserService {
@Autowired
private UserMapper userMapper; // 直接注入到字段
@Autowired
private CacheService cacheService;
}
字段注入是网上示例最常见的写法,但它有四个硬伤:
| 硬伤 | 说明 |
|---|---|
| 依赖可变 | final 无法与字段注入共存(final字段必须在构造器里赋值),字段随时可被改掉,破坏不可变性 |
| 难测试 | 单测时没有构造器/Setter 入口,只能用反射ReflectionTestUtils.setField 强行塞 Mock,又丑又脆弱 |
| 易 NPE | 对象创建后字段可能还没注入完就被使用(尤其在构造器里访问依赖时),容易NullPointerException |
| 隐藏依赖 | 依赖藏在字段里,一个类塞 10 个字段注入也不显眼,类膨胀了都看不出来 |
方式二:Setter 注入(Setter Injection)——
可选依赖才用
@Service
public class UserServiceImpl implements UserService {
private UserMapper userMapper;
@Autowired
public void setUserMapper(UserMapper userMapper) { // 通过 Setter 注入
this.userMapper = userMapper;
}
}
Setter 注入的问题:依赖可变(任何地方都能再调
setUserMapper 换掉),且可能漏注入(忘了加
@Autowired,字段就是
null,编译不报错,运行才炸)。它唯一合理的用途是可选依赖(这个依赖可以有默认实现、不注入也能工作),但实际生产中这种场景极少。
方式三:构造器注入(Constructor Injection)——
生产唯一解
@Service
public class UserServiceImpl implements UserService {
private final UserMapper userMapper; // final:一旦注入,不可再变
private final CacheService cacheService;
// 构造器参数就是依赖清单,一目了然
public UserServiceImpl(UserMapper userMapper, CacheService cacheService) {
this.userMapper = userMapper;
this.cacheService = cacheService;
}
}
构造器注入的四个优势,正好一一对应字段注入的四个硬伤:
- 依赖不可变:
final
字段只能在构造器里赋值一次,注入后谁也别想改。 - 天然非空:构造器要求所有参数必须传入,容器装配时发现缺依赖会直接启动失败,而不是运行到一半
NPE。 - 易测试:单测里直接
new UserServiceImpl(mockUserMapper, mockCacheService),不用任何框架。 - 依赖显式:构造器签名就是依赖清单,参数一多,你就该警惕「这个类是不是干太多事了」。
还有一个隐藏收益,第 4.1.4
节展开:构造器注入会暴露循环依赖,这反而是好事。
4.1.3 用 Lombok 省掉样板代码
手写构造器没问题,但依赖一多就啰嗦。Lombok 的
@RequiredArgsConstructor 会为所有
final 字段自动生成构造器,配合 final
字段,就是生产标准姿势:
// 生产级:构造器注入 + final 字段 + Lombok
// @RequiredArgsConstructor 等价于手写「包含所有 final 字段的构造器」
@Service
@RequiredArgsConstructor
public class UserServiceImpl implements UserService {
private final UserMapper userMapper; // final:不可变,必然注入
private final CacheService cacheService;
// Lombok 自动生成:public UserServiceImpl(UserMapper userMapper, CacheService cacheService) { ... }
}
关键点:
@RequiredArgsConstructor只对
final字段和@NonNull
字段生成构造器参数。所以「构造器注入」的完整配方是「final
字段 +@RequiredArgsConstructor」,缺一不可——字段不加
final,Lombok 就不会把它放进构造器,注入就失效了。
4.1.4
循环依赖:构造器注入「暴露」它,是好事
循环依赖:A 依赖 B,B 又依赖 A。
@Service
@RequiredArgsConstructor
public class AService {
private final BService bService; // A 依赖 B
}
@Service
@RequiredArgsConstructor
public class BService {
private final AService aService; // B 依赖 A
}
用构造器注入时,这段代码会让应用启动直接失败,报错类似:
The dependencies of some of the beans in the application context form a cycle:
┌─────┐
| aService defined in file [...]
↑ ↓
| bService defined in file [...]
└─────┘
这是好事:循环依赖 99%
是设计问题(职责划分不清),构造器注入让你在启动时就暴露它,逼你重构——把公共逻辑抽到第三个类,或者用事件/回调解耦。
对比之下,字段注入会用 Spring
的三级缓存(singletonFactories /
earlySingletonObjects)在「半成品」状态偷偷解决循环依赖,应用能正常启动,但设计缺陷被掩盖了。随着代码膨胀,这种隐式依赖会变成维护灾难。
本篇的立场:遇到循环依赖报错,不要改回字段注入「绕过」它,要重构消除它。Spring
Boot 3.x
甚至默认禁用了循环引用(spring.main.allow-circular-references=false),态度已经很明确。
4.1.5 多个候选 Bean
的歧义处理
@Autowired(含构造器注入)默认**按类型(byType)**匹配。当接口有多个实现时,容器不知道该注入哪个,会报
NoUniqueBeanDefinitionException。三种解法:
public interface UserService { void register(String username); }
// 实现一:默认实现
@Service
@Primary // 解法 2:标记为主候选,类型匹配时优先选它
public class UserServiceImpl implements UserService { ... }
// 实现二:VIP 用户的特殊实现
@Service
public class VipUserServiceImpl implements UserService { ... }
解法 1:@Qualifier
按名字指定(推荐,语义明确)
@Service
@RequiredArgsConstructor
public class VipOrderFacade {
// 指定注入 Bean 名为 "vipUserServiceImpl" 的那个实现
private final @Qualifier("vipUserServiceImpl") UserService userService;
}
解法 2:@Primary 标记默认实现(一个默认
+ 少数例外时用)
@Primary
@Service
public class UserServiceImpl implements UserService { ... }
当注入点没写 @Qualifier 时,容器默认选
@Primary 标注的实现;写了 @Qualifier
的注入点,@Qualifier 优先级更高。
解法 3:注入 List/Map
收集所有实现(策略模式常用)
@Service
@RequiredArgsConstructor
public class UserServiceRouter {
// 容器会把所有 UserService 实现收集成一个 Map,key 是 beanName
private final Map<String, UserService> userServiceMap;
public UserService pick(String type) {
return userServiceMap.getOrDefault(type, userServiceMap.get("userServiceImpl"));
}
}
选择建议:多数场景用「一个 @Primary
默认实现 + 例外处
@Qualifier」最清晰;要做插件化/策略路由时用
Map 收集。
4.1.6 最小可用 vs 生产级
先给一个最小可用示例,讲清「注入」本身:
// 最小可用:一个接口 + 一个实现 + 一个消费方,三行看穿 DI
public interface GreetingService { String greet(String name); }
@Service
public class GreetingServiceImpl implements GreetingService {
@Override
public String greet(String name) { return "Hello, " + name; }
}
@RestController
@RequiredArgsConstructor
public class GreetingController {
private final GreetingService greetingService; // 容器注入实现
@GetMapping("/greet")
public String greet(@RequestParam String name) {
return greetingService.greet(name);
}
}
再给生产级完整版(带校验、异常、日志、分层):
// 生产级:UserService 接口 + 实现,业务失败抛 BizException,由全局异常处理器统一兜底
public interface UserService {
Long register(RegisterDTO dto);
}
// DTO:入参对象,带 JSR-303 校验注解,禁止直接用 Entity 接请求
@Data
public class RegisterDTO {
@NotBlank(message = "用户名不能为空")
@Size(min = 3, max = 20, message = "用户名长度需在 3-20 之间")
private String username;
}
@Service
@Slf4j
@RequiredArgsConstructor
public class UserServiceImpl implements UserService {
// 构造器注入:依赖不可变、天然非空、易测试
private final UserMapper userMapper;
private final CacheService cacheService;
@Override
public Long register(RegisterDTO dto) {
// 生产习惯:业务校验失败抛 BizException,而不是返回 null 或 -1
if (userMapper.existsByUsername(dto.getUsername())) {
throw new BizException(40010, "用户名已存在");
}
User user = User.from(dto);
userMapper.insert(user);
cacheService.evictUserCache(user.getId()); // 写入后失效缓存,保证一致性
log.info("用户注册成功: id={}, username={}", user.getId(), dto.getUsername());
return user.getId();
}
}
4.1.7 三种注入方式对比
Demo(可运行)
用同一个场景把三种注入方式并排写出来,跑起来对比。三种方式都能让应用启动、注入也都会成功——差别不在「能不能跑」,而在「可维护性」:
// 字段注入:能跑,但依赖不可变、难测试、易 NPE、隐藏依赖
@Service
public class FieldInjectionService {
@Autowired
private UserMapper userMapper; // 依赖藏在字段里,构造器看不到,final 也加不上
}
// Setter 注入:依赖可变,@Autowired 忘加时字段就是 null,编译不报错、运行才炸
@Service
public class SetterInjectionService {
private UserMapper userMapper;
@Autowired
public void setUserMapper(UserMapper userMapper) {
this.userMapper = userMapper;
}
}
// 构造器注入:依赖不可变、非空、显式、易测试
@Service
public class ConstructorInjectionService {
private final UserMapper userMapper; // final:只赋值一次
public ConstructorInjectionService(UserMapper userMapper) {
this.userMapper = userMapper;
}
}
三种方式逐一对比:
| 维度 | 字段注入 | Setter 注入 | 构造器注入 |
|---|---|---|---|
| 依赖可变性 | 可变 | 可变 | 不可变(final) |
| 缺依赖时 | 运行才 NPE | 运行才 NPE | 启动即失败 |
| 单测注入 Mock | 需反射 | 可 Setter | 直接 new |
| 依赖是否显式 | 隐藏 | 较显式 | 构造器签名即清单 |
| 能否暴露循环依赖 | 被三级缓存掩盖 | 被掩盖 | 启动报错,逼你重构 |
Spring 官方与 IntelliJ IDEA 都会对字段注入给出 “Field injection is
not recommended” 警告。把它当成生产红线即可:字段注入和 Setter
注入都只在「理解概念」时写,生产代码一律构造器注入。
本章小结
IoC 把对象创建与依赖装配交给容器,DI
是它的落地手段;三种注入方式里,构造器注入(final
字段 +
@RequiredArgsConstructor)是生产唯一解——不可变、非空、易测试、显式依赖,还能在启动期暴露循环依赖。
4.2 Bean 配置与生命周期
4.2.1 三种配置 Bean 的方式
方式一:注解(stereotype,主流)——你只需要在类上标注,容器自动扫描注册。
| 注解 | 语义 | 附加能力 |
|---|---|---|
@Component |
通用组件 | 无 |
@Service |
业务层 | 无(语义化,便于阅读) |
@Repository |
数据访问层 | 额外提供持久层异常翻译(把各数据库厂商异常统一转成 Spring 的 DataAccessException) |
@Controller / @RestController |
Web 层 | @RestController = @Controller +@ResponseBody |
这四个注解本质相同(都是 @Component
的特化),区分它们主要为了语义清晰,让你一眼看出类的职责分层。
方式二:Java Config(@Configuration +
@Bean)——装配第三方类(你无法往别人源码里加
@Component)。
// 生产级:用 @Bean 装配第三方对象(RestTemplate 属于 Spring 但需要定制超时,不能用默认值)
@Configuration
public class BeanConfig {
// @Bean 方法返回的对象会被注册成 Bean,方法名默认就是 beanName
@Bean
public RestTemplate restTemplate() {
return new RestTemplateBuilder()
.setConnectTimeout(Duration.ofSeconds(3)) // 连接超时 3 秒
.setReadTimeout(Duration.ofSeconds(5)) // 读取超时 5 秒
.build();
}
}
@Configuration 类本身也是一个 Bean,它内部的
@Bean 方法会被容器调用并注册返回值。相比 XML,Java Config
类型安全、可断点调试、支持 IDE 跳转,是现代 Spring 的标准做法。
方式三:XML——遗留项目才用,新项目基本淘汰,本篇不展开。
4.2.2 Bean 作用域(Scope)
| 作用域 | 说明 | 生产注意 |
|---|---|---|
singleton |
默认,整个容器只有一个实例 | 无状态 Bean 用这个(绝大多数 Service/Mapper 都是) |
prototype |
每次获取都 new 一个新实例 | 慎用;且原型 Bean 的销毁回调容器不会完整执行 |
request / session |
一次 HTTP 请求 / 会话一个实例 | 仅 Web 环境,少用 |
@Scope("prototype") // 每次注入都拿新实例
@Component
public class PrototypeBean { ... }
高频生产坑:singleton 里注入
prototype,拿到的永远是同一个
@Service
@RequiredArgsConstructor
public class OrderService {
// 期望每次调用都拿到新 PrototypeBean,实际不是!
private final PrototypeBean prototypeBean;
}
原因:OrderService 是
singleton,只在创建时注入一次
PrototypeBean,之后永远持有那同一个实例。容器不会因为你调用了
prototypeBean 就重新创建。
两种正确解法:
// 解法 1:ObjectProvider 每次显式获取
@Service
@RequiredArgsConstructor
public class OrderService {
private final ObjectProvider<PrototypeBean> prototypeProvider;
public void handle() {
PrototypeBean bean = prototypeProvider.getObject(); // 每次 get 都是新实例
}
}
// 解法 2:@Lookup 方法注入(抽象方法由容器生成实现)
@Service
public abstract class OrderService2 {
@Lookup
public abstract PrototypeBean createPrototype(); // 每次调用返回新实例
public void handle() {
PrototypeBean bean = createPrototype();
}
}
实际生产中,如果发现自己在纠结 prototype
的获取,多半是设计错了——应该用
ObjectProvider,或者干脆把「有状态」的数据放进方法参数而不是
Bean 里。
4.2.3 Bean 完整生命周期
Spring Bean 从创建到销毁要经过一串回调,这是理解「为什么
@PostConstruct 能初始化、AOP
为什么能生效」的关键。完整流程:
① 实例化(调用构造器,new 出对象)
↓
② 属性填充(依赖注入,构造器注入其实在①就完成了,这里是字段/Setter 注入)
↓
③ Aware 回调(BeanNameAware → BeanFactoryAware → ApplicationContextAware,
让 Bean 拿到容器相关信息,如 beanName、容器引用)
↓
④ BeanPostProcessor # postProcessBeforeInitialization
(★ AOP 代理就是在这里完成的:容器判断该 Bean 是否需要代理,
需要则返回代理对象替代原始对象)
↓
⑤ 初始化回调
- @PostConstruct 注解的方法
- InitializingBean # afterPropertiesSet()
↓
⑥ BeanPostProcessor # postProcessAfterInitialization
↓
⑦ Bean 就绪,放入容器供使用
↓
⑧ 销毁回调
- @PreDestroy 注解的方法
- DisposableBean # destroy()
两个生产最常用的钩子:
@Service
@Slf4j
public class CacheWarmService {
// @PostConstruct:Bean 创建、依赖注入完成后执行,适合初始化缓存/加载配置
@PostConstruct
public void init() {
log.info("Bean 初始化完成,开始预热缓存");
// loadHotDataToCache();
}
// @PreDestroy:容器关闭、Bean 销毁前执行,适合释放连接、关闭线程池
@PreDestroy
public void destroy() {
log.info("Bean 即将销毁,释放资源");
// threadPool.shutdown();
}
}
关键点:
BeanPostProcessor(第 ④⑥ 步)是
AOP 的基石。Spring 就是在这里判断「这个 Bean
有没有被切面命中」,命中就用ProxyFactory
生成代理对象,把原始对象替换掉。所以你在业务代码里拿到的其实不是原始对象,而是代理对象——这一点在第
4.3 章会展开,务必先记住它的位置。
4.2.4
生命周期演示(各阶段打印日志)
下面用一个完整 Demo
把生命周期每一阶段都打出来,建议你亲手跑一遍,看日志顺序。
// 1) 实现 Aware 接口,观察容器回调
@Component
@Slf4j
public class LifecycleBean implements BeanNameAware, InitializingBean, DisposableBean {
public LifecycleBean() {
log.info("① 实例化:构造器被调用");
}
// BeanNameAware:容器把 beanName 告诉你
@Override
public void setBeanName(String name) {
log.info("③ Aware 回调:beanName = {}", name);
}
// @PostConstruct:比 InitializingBean 更推荐(不耦合 Spring 接口)
@PostConstruct
public void postConstruct() {
log.info("⑤ 初始化:@PostConstruct 执行");
}
// InitializingBean 接口回调,在 @PostConstruct 之后
@Override
public void afterPropertiesSet() {
log.info("⑤ 初始化:afterPropertiesSet 执行");
}
@PreDestroy
public void preDestroy() {
log.info("⑧ 销毁:@PreDestroy 执行");
}
@Override
public void destroy() {
log.info("⑧ 销毁:DisposableBean#destroy 执行");
}
}
// 2) 自定义 BeanPostProcessor,观察它在初始化前后的介入点
@Component
@Slf4j
public class LogBeanPostProcessor implements BeanPostProcessor {
@Override
public Object postProcessBeforeInitialization(Object bean, String beanName) {
if ("lifecycleBean".equals(beanName)) {
log.info("④ BeanPostProcessor#before 执行:beanName = {}", beanName);
}
return bean; // 必须返回 bean(或它的代理),否则容器里就没有这个对象了
}
@Override
public Object postProcessAfterInitialization(Object bean, String beanName) {
if ("lifecycleBean".equals(beanName)) {
log.info("⑥ BeanPostProcessor#after 执行:beanName = {}", beanName);
}
return bean;
}
}
启动应用,日志顺序会严格对应上面的 8 步流程。推荐用
@PostConstruct/@PreDestroy 注解而非
InitializingBean/DisposableBean
接口——注解不依赖 Spring 接口,代码更干净。
4.2.5 用 @Bean
装配一个可配置线程池(生产级)
线程池是「必须用 @Bean
装配第三方/复杂对象」的典型场景(第 4.4 章异步事件会用到它):
@Configuration
public class BeanConfig {
// 用 @ConfigurationProperties 绑定配置,避免 @Value 满天飞(类型安全 + 可校验)
@Bean
@ConfigurationProperties(prefix = "app.thread-pool")
public ThreadPoolTaskExecutor taskExecutor() {
ThreadPoolTaskExecutor executor = new ThreadPoolTaskExecutor();
executor.setCorePoolSize(4); // 核心线程数
executor.setMaxPoolSize(8); // 最大线程数
executor.setQueueCapacity(200); // 队列容量
executor.setThreadNamePrefix("app-exec-"); // 线程名前缀,排障时一眼认出
executor.setRejectedExecutionHandler(
new ThreadPoolExecutor.CallerRunsPolicy()); // 队列满时由调用线程执行,防止丢任务
return executor;
}
}
application.yml 里通过 app.thread-pool
前缀覆盖默认值(属性优先级高于代码 setter):
app:
thread-pool:
core-pool-size: 8
max-pool-size: 16
关键点:
@ConfigurationProperties
把松散命名的配置(core-pool-size)自动映射到
setCorePoolSize,比一堆@Value("${...}")
类型安全、可校验、可 IDE 跳转,生产强烈建议用它替代@Value
读配置。
4.2.6
@Configuration 的 full 与 lite 模式(隐蔽坑)
@Bean 方法写在 @Configuration
类里时,Spring 会用 CGLIB
代理这个配置类,保证「@Bean
方法里调用另一个 @Bean
方法时,返回的是容器里的同一个单例」——这叫 full
模式:
@Configuration
public class BeanConfig {
@Bean
public DataSource dataSource() { return new HikariDataSource(); }
@Bean
public JdbcTemplate jdbcTemplate() {
// 这里调用的 dataSource() 不是「再 new 一个」,而是容器里那个单例
return new JdbcTemplate(dataSource());
}
}
如果类上没标 @Configuration、只标了
@Component(即 lite 模式),则
@Bean
方法之间互相调用不会走代理,dataSource()
会被真的再执行一次、产生多个实例——行为差异极隐蔽,且不报错。
结论:配置类一律用 @Configuration,不要用
@Component 凑合。
4.2.7 @PostConstruct /
@PreDestroy 的使用注意
@PostConstruct
public void init() { ... } // 签名必须是 void 且无参数,private 也允许
@PreDestroy
public void destroy() { ... }
两个易踩的隐蔽坑:
- 方法签名:必须是
void
返回、无参数,否则注解静默失效(不报错)。 - 导包:Spring Boot 3.x(基于 Spring 6 / Jakarta EE
9+)里,@PostConstruct/@PreDestroy来自
jakarta.annotation包,不是老项目的
javax.annotation。导错包不会编译报错,但注解不生效——迁移老项目时尤其常见。
本章小结
Bean 的配置有注解、Java Config、XML 三种,新项目用「注解 + Java
Config」;生命周期 8 步里,BeanPostProcessor 是 AOP
生效的位置,@PostConstruct/@PreDestroy
是生产最常用的两个钩子;singleton 注入 prototype 拿不到新实例,要用
ObjectProvider 或 @Lookup。
4.3 AOP 切面编程
AOP
是理解「事务为什么有时会失效」的钥匙,也是本篇最重的一章。请务必吃透代理机制。
4.3.1 什么是 AOP,为什么需要它
业务代码里总有一堆「横向」逻辑:日志、耗时统计、权限校验、事务、限流。如果不用
AOP,它们会像下面这样侵入每一个方法:
// 反例:横切逻辑散落在每个方法里,重复且污染业务
public OrderVO createOrder(CreateOrderDTO dto) {
long start = System.currentTimeMillis(); // 计时开始
log.info("调用 createOrder, 参数={}", dto); // 入参日志
checkPermission(); // 权限校验
try {
Order order = orderMapper.insert(...); // 真正的业务
return OrderVO.from(order);
} catch (Exception e) {
log.error("createOrder 失败", e);
throw e;
} finally {
log.info("createOrder 耗时 {}ms", System.currentTimeMillis() - start);
}
}
每一个方法都要复制这套「计时 + 日志 +
权限」,一旦要统一改格式,就得改几百处。AOP
的思路:把这些横切逻辑抽出来,集中写在一个「切面」里,声明「在哪些方法上、什么时候、执行什么」,业务代码恢复成一行。
AOP 核心术语(先记住,后面一一对应到代码):
| 术语 | 含义 | 类比 |
|---|---|---|
| JoinPoint(连接点) | 程序执行的某个点,如方法调用 | 候选的「可以插入增强」的位置 |
| Pointcut(切点) | 匹配连接点的规则,定位「在哪些方法上增强」 | 筛选条件 |
| Advice(通知) | 增强逻辑本身,定义「做什么、何时做」 | 要插入的代码 |
| Aspect(切面) | Pointcut + Advice 的组合 | 一个完整功能模块 |
4.3.2 5 种通知(Advice)
| 通知 | 执行时机 | 能否拿到返回值 | 能否吞异常 |
|---|---|---|---|
@Before |
目标方法执行前 | 否 | 否 |
@AfterReturning |
目标方法正常返回后 | 能(returning 指定参数名) |
否 |
@AfterThrowing |
目标方法抛异常后 | 否(拿异常) | 否 |
@After |
目标方法执行后(无论正常还是异常) | 否 | 否 |
@Around |
环绕,可同时控制前后 | 能,且能替换返回值 | 能(不调 proceed() 即吞掉) |
@Around
最强大(能拿到整个执行过程,可决定是否放行、是否替换返回值),也最容易用错(忘记调
proceed()
会导致目标方法根本不被执行)。
4.3.3 切点表达式(execution)
@Around("execution(* com.example.demo.service..*.*(..))")
// └修饰符(可省)┘└──返回类型──┘└──包──┘└类┘└方法┘└参数┘
// 语法:execution([修饰符] 返回类型 包.类.方法(参数))
常用通配符:
| 符号 | 含义 | 示例 |
|---|---|---|
* |
匹配任意一个部分 | * com.example..*.*(..) |
.. |
包路径中匹配任意层级;参数中匹配任意个参数 | com.example..* 匹配 com.example下任意层级 |
(..) |
任意参数 | *(..) |
execution(* com.example.demo.service..*.*(..)) // service 包及子包下所有类的所有方法
execution(* com.example.demo.service.UserService.*(..)) // UserService 接口的所有方法
execution(* com.example.demo..*.insert*(..)) // 任意包下 insert 开头的方法
4.3.4 生产级切面:日志 +
耗时统计
先给一个最小可用的耗时切面,理解
@Around 的骨架:
@Aspect // 声明这是一个切面
@Component // 切面本身也要注册成 Bean 才能被容器管理
@Slf4j
public class CostAspect {
// 环绕通知:ProceedingJoinPoint 代表被拦截的方法
@Around("execution(* com.example.demo.service..*.*(..))")
public Object around(ProceedingJoinPoint pjp) throws Throwable {
long start = System.currentTimeMillis();
Object result = pjp.proceed(); // ★ 放行,执行目标方法;不调用它,目标方法就不执行
long cost = System.currentTimeMillis() - start;
log.info("{}.{} 耗时 {}ms",
pjp.getTarget().getClass().getSimpleName(), // 目标类名
pjp.getSignature().getName(), // 方法名
cost);
return result; // 把目标方法的结果原样返回给调用方
}
}
ProceedingJoinPoint 常用方法:
| 方法 | 作用 |
|---|---|
proceed() |
执行目标方法(可传参 proceed(args) 替换入参) |
getTarget() |
拿到被代理的原始对象 |
getSignature() |
拿到方法签名(方法名、声明类型) |
getArgs() |
拿到调用参数 |
生产级版本要处理异常记录和参数打印(避免日志把密码、token
打出去):
@Aspect
@Component
@Slf4j
public class WebLogAspect {
// 拦截 Controller 层所有方法:记录入参、耗时、异常
@Around("execution(* com.example.demo.controller..*.*(..))")
public Object log(ProceedingJoinPoint pjp) throws Throwable {
String method = pjp.getTarget().getClass().getSimpleName() + "." + pjp.getSignature().getName();
long start = System.currentTimeMillis();
try {
Object result = pjp.proceed();
// 生产注意:入参里可能有敏感字段(密码/token),脱敏后再打日志
log.info("[OK] {} 耗时 {}ms", method, System.currentTimeMillis() - start);
return result;
} catch (Throwable t) {
// 异常要打全栈,同时原样抛出,交给全局异常处理器统一转 ApiResult
log.error("[FAIL] {} 耗时 {}ms", method, System.currentTimeMillis() - start, t);
throw t;
}
}
}
4.3.5 自定义注解 +
切面(@LogExecutionTime)
execution
表达式适合「按包/类/方法名」批量拦截;若只想对标注了某个注解的方法生效,用
@annotation 切点 + 自定义注解,更精准、更显式:
// 自定义注解:标在方法上,表示「我要记录这个方法耗时」
@Target(ElementType.METHOD) // 只能标在方法上
@Retention(RetentionPolicy.RUNTIME) // 运行时保留,AOP 靠反射读取
public @interface LogExecutionTime {
String value() default ""; // 可选:给这段耗时起个业务名
}
@Aspect
@Component
@Slf4j
public class LogExecutionTimeAspect {
// @annotation(logExecutionTime):拦截所有标注了该注解的方法,
// 注解对象通过同名参数传入
@Around("@annotation(logExecutionTime)")
public Object log(ProceedingJoinPoint pjp, LogExecutionTime logExecutionTime) throws Throwable {
long start = System.currentTimeMillis();
Object result = pjp.proceed();
String label = logExecutionTime.value().isEmpty()
? pjp.getSignature().getName()
: logExecutionTime.value();
log.info("[耗时统计] {} 耗时 {}ms", label, System.currentTimeMillis() - start);
return result;
}
}
业务代码里使用:
@Service
public class OrderServiceImpl implements OrderService {
@Override
@LogExecutionTime("创建订单") // 该方法会被切面自动记录耗时
public OrderVO createOrder(CreateOrderDTO dto) {
// 业务代码里完全没有计时/日志,横切逻辑被切面接管
...
}
}
4.3.6 AOP
代理机制(本篇重点,务必吃透)
Spring AOP
不是修改你的类,而是生成一个代理对象来替代它。
你在业务代码里拿到的、注入到别的 Bean
里的,其实是这个代理对象,不是原始对象。
两种代理实现:
| 维度 | JDK 动态代理 | CGLIB |
|---|---|---|
| 原理 | 基于接口,用 java.lang.reflect.Proxy +InvocationHandler 生成一个「实现了相同接口」的代理类 |
基于继承,生成一个「目标类的子类」作为代理 |
| 前提 | 目标类必须实现接口 | 无需接口,但不能代理 final类/ final 方法 |
| 依赖 | JDK 内置 | 第三方字节码库(Spring 已内置) |
| 特点 | 只能拦截接口方法 | 能拦截非接口的 public 方法,稍快 |
Spring Boot 默认用哪种? 从 Spring Boot 2.0
起默认
proxyTargetClass=true,即:只要目标类没实现接口,或实现了接口但
Spring 判断更适合,就默认用 CGLIB。所以哪怕你的 Service
实现了接口,Spring Boot 也经常直接用 CGLIB 生成子类代理。第 4.5.5 节「非
public 方法失效」就与 CGLIB 的继承机制直接相关。
代理调用链(文字配图):
调用方(比如 Controller)
│ 调用 userService.register(...)
▼
┌───────────────────────────────────────────┐
│ 代理对象(CGLIB 生成的 UserServiceImpl 子类)│
│ register(dto) { │
│ // ① 切面前置逻辑:打印入参、开启事务 │
│ before(); │
│ // ② 反射调用真实对象的方法 │
│ Object r = target.register(dto); │
│ // ③ 切面后置逻辑:记录耗时、提交事务 │
│ after(r); │
│ return r; │
│ } │
└───────────────────────────────────────────┘
│ 持有原始对象引用(target)
▼
┌───────────────────────────────────────────┐
│ 原始对象(真正的 UserServiceImpl) │
│ register(dto) { ...业务代码... } │
└───────────────────────────────────────────┘
关键结论:切面逻辑只在「从外部调用代理对象的方法」时才触发。一旦进入原始对象内部,this.xxx()
调用的是原始对象自己,不经过代理——这就是下面「自调用失效」的根源。
4.3.7 AOP
失效场景一:同类自调用(最高频,必背)
@Service
@Slf4j
public class OrderServiceImpl implements OrderService {
@Override
public void placeOrder(Long orderId) {
// ★ 自调用:this.methodA() 调用本类另一个方法
// 这里调用的 reduceStock 不经过代理,其上的 @Transactional / 切面全部失效
this.reduceStock(orderId);
}
@LogExecutionTime("扣减库存")
public void reduceStock(Long orderId) {
// 期望:这里被切面记录耗时
// 实际:placeOrder 内部 this.reduceStock 不经过代理,切面静默失效
}
}
为什么失效:placeOrder
被外部调用时走的是代理;但进入代理后,代理把控制权交给了原始对象
target.placeOrder()。原始对象内部再调
this.reduceStock(),这里的 this
是原始对象,调用的是原始对象自己的方法,根本没经过代理,所以切面(和事务)统统不生效。
外部 → 代理.placeOrder() → 原始对象.placeOrder()
│
└→ this.reduceStock() ← 直接执行,绕过代理,切面失效
两种解决方案:
// 解法 1:拆分到另一个 Bean(推荐,从根上消除自调用)
@Service
@RequiredArgsConstructor
public class OrderServiceImpl implements OrderService {
private final StockService stockService; // 把被增强的逻辑拆到独立 Bean
@Override
public void placeOrder(Long orderId) {
stockService.reduceStock(orderId); // 跨 Bean 调用 → 走代理 → 切面/事务生效
}
}
@Service
public class StockServiceImpl implements StockService {
@LogExecutionTime("扣减库存")
@Override
public void reduceStock(Long orderId) { ... }
}
// 解法 2:注入自身代理(特殊场景兜底,慎用:耦合容器、易循环依赖)
@Service
@RequiredArgsConstructor
public class OrderServiceImpl implements OrderService {
private final OrderService self; // 注入自己(容器会把代理对象注入进来)
@Override
public void placeOrder(Long orderId) {
self.reduceStock(orderId); // 通过代理调用 → 切面/事务生效
}
@LogExecutionTime("扣减库存")
@Override
public void reduceStock(Long orderId) { ... }
}
首选解法 1。拆分 Bean
是消除自调用、也是消除循环依赖的正道;注入自身代理(self)虽然能救急,但会让类依赖容器、还可能触发循环依赖报错,只在无法拆分的特殊场景用。
4.3.8 AOP 失效场景二:非
public 方法
@Service
public class OrderServiceImpl implements OrderService {
@LogExecutionTime("私有方法")
private void internalMethod() { ... } // ★ private + 切面:不生效
}
为什么失效:Spring Boot 默认用
CGLIB,它通过继承生成子类代理。子类只能
@Override 父类的 public/protected
方法,private
方法根本无法被子类重写,代理自然拦截不到。而 @Transactional
的官方约定更严:只对 public
方法生效(protected/private 都不行)。
正确做法:需要被增强的方法一律声明为
public,并放到能被代理拦截的 Bean 边界上。
4.3.9 更多切点表达式与组合
除了 execution 和
@annotation,常用的还有:
// within:拦截某个类(及其子类)的所有方法
@Before("within(com.example.demo.service.UserServiceImpl)")
// @within:拦截「类上标注了某注解」的类的所有方法(匹配类注解,非方法注解)
@Before("@within(org.springframework.stereotype.Service)")
// 组合:&&(且)、||(或)、!(非)
@Around("execution(* com.example.demo.service..*.*(..)) && !execution(* com.example.demo.service..*.internal*(..))")
切点复用:把表达式抽成一个具名方法,多个通知共用:
@Aspect
@Component
public class WebLogAspect {
// @Pointcut 定义具名切点,供多个通知复用,避免表达式到处复制
@Pointcut("execution(* com.example.demo.controller..*.*(..))")
public void controllerLayer() {}
@Before("controllerLayer()")
public void before(JoinPoint jp) { ... } // 前置通知用 JoinPoint(没有 proceed 方法)
@Around("controllerLayer()")
public Object around(ProceedingJoinPoint pjp) throws Throwable { ... } // 环绕用 ProceedingJoinPoint
}
注意
JoinPoint与ProceedingJoinPoint
的区别:JoinPoint
只能「看」(拿参数、签名),不能「推进」方法;ProceedingJoinPoint
继承自JoinPoint,多了
proceed(),所以只有@Around
能用它,其余四种通知只能用JoinPoint。
4.3.10 代理方式如何切换
spring:
aop:
proxy-target-class: true # 默认 true:CGLIB;false 则优先 JDK 动态代理(要求接口)
// 代码方式(与上面配置等价)
@EnableAspectJAutoProxy(proxyTargetClass = true)
生产一般不用改:Spring Boot 默认 CGLIB
能覆盖「有无接口」两种情况。只在极个别「必须 JDK
代理」的边界场景(如某框架强依赖 Proxy 类)才需要调整。
4.3.11
多个切面的执行顺序(@Order)
多个切面命中同一个方法时,用 @Order
控制顺序,值越小越先执行(越靠近外层):
@Order(1) // 先执行,包在最外层
@Aspect
@Component
public class LogAspect { ... }
@Order(2) // 后执行,包在内层
@Aspect
@Component
public class PermissionAspect { ... }
这解释了为什么事务切面(Spring 内置,@Order
值很小)和你的日志切面能同时工作、互不干扰——它们是同一个代理链上的不同节点,各自负责自己的横切逻辑。
本章小结
AOP 靠「代理对象替换原始对象」实现横切增强;Spring Boot 默认用
CGLIB(继承生成子类)。切面只在外部经过代理调用时生效,因此同类自调用(this.xxx())与非
public 方法是两大失效根源——这也是后面
@Transactional 失效的同一把钥匙。
4.4 Spring 事件机制
4.4.1
为什么用事件而不是直接调用
「订单支付成功后,要发短信通知 + 加积分 +
同步数据到下游」——最直接的写法是在支付方法里依次调这三个服务:
// 反例:支付方法里串行耦合了 4 件事,加一个新动作就得改这里
@Transactional
public void pay(Long orderId) {
orderMapper.markPaid(orderId); // 核心动作:改订单状态
notifyService.sendSms(userId); // 动作 2
pointService.addPoints(userId, 100); // 动作 3
syncService.syncToDataWarehouse(orderId); // 动作 4
}
问题:核心业务与一堆「可延后、可失败重试」的动作强耦合;任何一步慢了,支付接口就慢;任何一个动作失败,可能连累支付回滚。
事件机制的思路:支付方法只做「改订单状态」这一件核心事,然后发布一个事件,谁关心谁来监听。发布者不关心有哪些监听者,新增/删除监听者都不用改发布者代码——这就是观察者模式的
Spring 落地,是模块解耦的利器。
4.4.2
基础用法:ApplicationEvent + @EventListener
// 1) 定义事件:携带业务数据
public class OrderPaidEvent extends ApplicationEvent {
private final Long orderId;
private final Long userId;
public OrderPaidEvent(Object source, Long orderId, Long userId) {
super(source); // source 是发布者对象,框架要求
this.orderId = orderId;
this.userId = userId;
}
public Long getOrderId() { return orderId; }
public Long getUserId() { return userId; }
}
// 2) 发布事件:注入 ApplicationEventPublisher
@Service
@RequiredArgsConstructor
public class OrderServiceImpl implements OrderService {
private final ApplicationEventPublisher publisher;
public void pay(Long orderId, Long userId) {
// ... 改订单状态 ...
publisher.publishEvent(new OrderPaidEvent(this, orderId, userId)); // 发布
}
}
// 3) 监听事件
@Component
@Slf4j
public class OrderPaidListener {
// @EventListener:容器会自动把匹配类型的事件传给该方法(默认同步执行)
@EventListener
public void onPaid(OrderPaidEvent event) {
log.info("收到订单支付事件,发短信通知: orderId={}", event.getOrderId());
}
}
4.4.3
事务场景的关键:@TransactionalEventListener
上面的 @EventListener
是同步立即执行的。如果发布事件发生在事务提交之前,监听器可能读到「还没落库」的数据;更糟的是,监听器抛异常会导致事务回滚,把核心业务也拖下水。
// 反例:事务未提交就同步消费,可能读到脏数据,且监听器异常会连累主事务
@EventListener
public void onPaid(OrderPaidEvent event) { ... }
正确姿势:用
@TransactionalEventListener,让监听器在事务提交后再执行(默认
AFTER_COMMIT):
@Component
@Slf4j
public class OrderPaidListener {
// 默认 phase = TransactionPhase.AFTER_COMMIT:主事务提交成功后才执行
// 好处:① 此时数据一定已落库;② 监听器异常不会让主事务回滚
@TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT)
public void onPaidAfterCommit(OrderPaidEvent event) {
log.info("事务已提交,开始发通知/加积分: orderId={}", event.getOrderId());
notifyService.sendSms(event.getUserId());
pointService.addPoints(event.getUserId(), 100);
}
// fallbackExecution = true:发布方没有事务时也能触发(否则会被忽略)
@TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT, fallbackExecution = true)
public void onPaidNoTx(OrderPaidEvent event) { ... }
}
关键点:
@TransactionalEventListener
只有在发布方处于事务中时才生效;如果发布方根本没开事务,事件会被静默丢弃(除非加
fallbackExecution = true)。这是新手最容易踩的坑。
4.4.4 异步事件
通知、积分这类「可延后」的动作,应该异步执行,别拖慢主流程。两步:开异步
+ 监听器标 @Async。
// 1) 启动类上加 @EnableAsync
@SpringBootApplication
@EnableAsync
public class DemoApplication { ... }
// 2) 监听器方法加 @Async,交给线程池异步执行
@Component
@Slf4j
public class OrderPaidListener {
// @Async:用第 4.2.5 节配置的 taskExecutor 线程池执行,不阻塞主线程
@Async
@TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT)
public void onPaidAsync(OrderPaidEvent event) {
log.info("异步发短信: orderId={}", event.getOrderId());
}
}
4.4.5
监听器的其他写法与执行顺序
@EventListener 是注解式写法,Spring
还提供接口式写法(二者可混用):
// 接口式监听器:实现 ApplicationListener<事件类型>
@Component
public class LegacyListener implements ApplicationListener<OrderPaidEvent> {
@Override
public void onApplicationEvent(OrderPaidEvent event) {
log.info("接口式监听: orderId={}", event.getOrderId());
}
}
多个监听器的执行顺序:用 @Order
控制,值越小越先执行:
@Component
public class OrderPaidListeners {
@Order(1) // 先发通知
@EventListener
public void sendNotify(OrderPaidEvent e) { ... }
@Order(2) // 再加积分
@EventListener
public void addPoints(OrderPaidEvent e) { ... }
}
条件监听:@EventListener 支持
condition,用 SpEL 过滤事件:
// 只处理 orderId 非空的事件(表达式里的 #event 指方法参数)
@EventListener(condition = "#event.orderId != null")
public void onPaid(OrderPaidEvent event) { ... }
本章小结
事件机制用「发布/订阅」解耦核心业务与可延后动作;事务内发布必须用
@TransactionalEventListener(AFTER_COMMIT),确保「数据落库后再消费、监听器异常不连累主事务」;可延后的动作再加
@Async 异步化。
4.5 声明式事务基础
4.5.1 @Transactional
的本质:一个 AOP 切面
你写的 @Transactional 其实没有「魔法」——Spring
在启动时,给所有标注了它的 Bean 生成代理,把事务逻辑织入方法前后:
外部调用 transfer(...)
│
▼
代理对象.transfer(...)
├─ 前置:TransactionInterceptor 拦截,通过 DataSourceTransactionManager 开启事务
├─ 调用:真实对象.transfer(...) ← 执行你的业务代码
└─ 后置:正常则提交(commit),抛 RuntimeException/Error 则回滚(rollback)
其中 TransactionInterceptor 就是
@Transactional 对应的
Advice,DataSourceTransactionManager
负责真正操作数据库连接(setAutoCommit(false) →
commit/rollback)。
这带来一个必然推论:第 4.3 章讲的所有 AOP
失效场景,对 @Transactional
同样成立——自调用失效、非 public
失效,本质是同一件事。
4.5.2 传播行为(Propagation)
传播行为回答:「当前已有一个事务时,被调用的方法该怎么办?」
| 传播行为 | 含义 | 生产用途 |
|---|---|---|
REQUIRED |
默认。有事务就加入,没有就新建 | 绝大多数场景,保证「要么都成功要么都回滚」 |
REQUIRES_NEW |
总是新建独立事务,挂起当前事务 | 记录日志/审计:即使主事务回滚,日志也要落库 |
NESTED |
嵌套事务,内层可独立回滚到保存点 | 批量处理:单条失败不影响整体 |
SUPPORTS |
有就加入,没有就以非事务执行 | 只读查询 |
NOT_SUPPORTED |
有事务则挂起,以非事务执行 | 长耗时操作,避免占用连接 |
MANDATORY |
必须在事务中,否则抛异常 | 强制调用方开事务 |
NEVER |
必须不在事务中,否则抛异常 | 极少用 |
// REQUIRED(默认):transfer 与它内部调用的方法共用同一个事务
@Transactional // 等价于 @Transactional(propagation = Propagation.REQUIRED)
public void transfer(Long fromId, Long toId, BigDecimal amount) {
accountMapper.deduct(fromId, amount); // 若这里抛出异常,add 也不会生效
accountMapper.add(toId, amount);
}
// REQUIRES_NEW:独立事务,不受外层回滚影响(典型:审计日志)
@Transactional(propagation = Propagation.REQUIRES_NEW)
public void saveAuditLog(String content) { ... }
4.5.3 隔离级别(Isolation)
隔离级别回答:「并发事务之间能看到多少对方的中间数据?」,本质是「性能
vs 一致性」的权衡。
| 级别 | 脏读 | 不可重复读 | 幻读 | 说明 |
|---|---|---|---|---|
READ_UNCOMMITTED |
会 | 会 | 会 | 几乎不用 |
READ_COMMITTED |
否 | 会 | 会 | 多数数据库默认(Oracle/PG) |
REPEATABLE_READ |
否 | 否 | 会 | MySQL 默认 |
SERIALIZABLE |
否 | 否 | 否 | 串行化,性能最差 |
// 生产一般显式指定,避免依赖不同数据库的默认值差异
@Transactional(isolation = Isolation.REPEATABLE_READ)
public void transfer(Long fromId, Long toId, BigDecimal amount) { ... }
4.5.4
转账事务(生产级完整示例)
用 H2 内存库 + JdbcTemplate 写一个真实可跑的转账:
@Service
@Slf4j
@RequiredArgsConstructor
public class AccountServiceImpl implements AccountService {
private final JdbcTemplate jdbcTemplate;
// 转账:扣 A + 加 B,任一步失败都整体回滚
@Override
@Transactional // 默认 REQUIRED:整个方法一个事务
public void transfer(Long fromId, Long toId, BigDecimal amount) {
// 1) 扣款:余额不足时抛异常,触发回滚
int updated = jdbcTemplate.update(
"UPDATE t_account SET balance = balance - ? WHERE id = ? AND balance >= ?",
amount, fromId, amount);
if (updated == 0) {
// 抛业务异常,事务回滚(扣款不会生效)
throw new BizException(ErrorCode.INSUFFICIENT_BALANCE);
}
// 2) 入账
jdbcTemplate.update(
"UPDATE t_account SET balance = balance + ? WHERE id = ?",
amount, toId);
log.info("转账成功: {} -> {}, 金额={}", fromId, toId, amount);
}
}
为什么 updated == 0
要抛异常:balance >= ? 这个 WHERE
条件在余额不足时更新不到任何行,update 返回
0。此时必须显式抛异常,事务才会回滚——如果吞掉继续走,就会出现「A 没扣、B
却加了」的资损事故。
4.5.5 事务失效四大场景(必背)
场景一:同类自调用(同第 4.3.7 节)
@Service
@RequiredArgsConstructor
public class AccountServiceImpl implements AccountService {
// 外部调用 createAndTransfer(),它内部调用了带 @Transactional 的 doTransfer()
public void createAndTransfer(Long fromId, Long toId, BigDecimal amount) {
this.doTransfer(fromId, toId, amount); // ★ 自调用:事务不生效!
}
@Transactional
public void doTransfer(Long fromId, Long toId, BigDecimal amount) {
// 期望有事务,实际 this 调用绕过代理,事务静默失效
}
}
验证:doTransfer
内部扣款后抛异常,你会看到扣款依然生效了(没回滚)——因为根本没开事务。
场景二:非 public 方法
@Service
public class AccountServiceImpl {
@Transactional
private void doTransfer(...) { ... } // ★ private:事务不生效
}
场景三:异常被吞
@Transactional
public void transfer(Long fromId, Long toId, BigDecimal amount) {
try {
jdbcTemplate.update("UPDATE t_account SET balance = balance - ? WHERE id = ?", amount, fromId);
int i = 1 / 0; // 抛 ArithmeticException(RuntimeException)
} catch (Exception e) {
// ★ 吞掉异常:Spring 感知不到异常,事务照常提交,数据不一致
log.error("转账异常", e);
}
}
正确做法:异常上抛(让代理感知并回滚),或在
catch 里显式标记回滚:
@Transactional
public void transfer(Long fromId, Long toId, BigDecimal amount) {
try {
...
} catch (Exception e) {
log.error("转账异常,标记回滚", e);
// 显式标记当前事务只回滚,然后(可选)把异常转成业务异常上抛
TransactionAspectSupport.currentTransactionStatus().setRollbackOnly();
throw new BizException(ErrorCode.SYSTEM_ERROR);
}
}
场景四:异常类型不对(受检异常默认不回滚)
@Transactional 默认只对
RuntimeException 和 Error
回滚,受检异常(Exception
及其非运行时子类)不回滚。
// 反例:抛出受检异常,事务不会回滚
@Transactional
public void transfer(...) throws IOException {
// 业务代码抛出 IOException(受检异常)→ 事务提交,数据不一致
}
// 正例:显式声明回滚范围,覆盖所有异常
@Transactional(rollbackFor = Exception.class)
public void transfer(...) { ... }
四个失效场景的共同根因:要么「绕过了代理」(场景一、二),要么「代理感知不到该回滚」(场景三、四)。理解了代理机制,这四个坑不用死记硬背。
4.5.6 @Transactional
的更多生产细节
readOnly 优化:只读方法标
readOnly = true,数据库可据此做优化(如路由到只读副本、跳过脏检查):
@Transactional(readOnly = true)
public UserVO getUser(Long id) { ... }
timeout 超时:防止长事务占用数据库连接:
@Transactional(timeout = 5) // 超过 5 秒强制回滚,单位秒
public void transfer(...) { ... }
REQUIRES_NEW
演示(审计日志独立提交,不受主事务回滚影响):
@Service
@RequiredArgsConstructor
public class OrderServiceImpl implements OrderService {
private final AuditService auditService;
@Transactional
public void cancelOrder(Long orderId) {
orderMapper.cancel(orderId); // 主事务:取消订单
auditService.record("订单取消"); // 独立事务:主事务回滚,日志也落库
throw new BizException(ErrorCode.SYSTEM_ERROR); // 故意让主事务回滚
// 结果:订单取消被回滚,但审计日志因 REQUIRES_NEW 已提交
}
}
@Service
public class AuditServiceImpl implements AuditService {
@Override
@Transactional(propagation = Propagation.REQUIRES_NEW)
public void record(String content) {
// 挂起外层事务,新建独立事务写审计日志
}
}
关键点:
REQUIRES_NEW
这类传播行为依赖代理拦截,所以auditService.record()
必须跨 Bean 调用;如果写成同类
this.record(),就又掉回「自调用失效」的坑里——传播行为完全不会触发。这再次印证:理解代理机制,是理解事务一切行为的钥匙。
本章小结
@Transactional 本质是 AOP
切面(TransactionInterceptor +
DataSourceTransactionManager),所以 AOP
的失效规律对它完全适用;传播行为默认
REQUIRED、隔离级别要显式指定、默认只对
RuntimeException/Error
回滚。转账示例里「扣款失败必须抛异常」是生产防资损的底线。
4.6
生产级实战:用户服务 + 日志切面 + 事件解耦 + 事务
本章把前 5 章的知识串成一个完整分层项目:用户注册(事务 +
校验)→ 发布注册事件 → 切面记录耗时 →
监听器事务提交后异步发通知。
4.6.1 项目需求与分层设计
需求:用户注册接口。注册成功后:
- 落库一条用户记录(事务);
- 切面自动打印接口耗时;
- 发布
UserRegisteredEvent事件; - 监听器在事务提交后异步发欢迎通知(解耦 +
不拖慢主流程 + 不连累主事务)。
分层(严格遵循总规范:DTO/VO/Entity 分离、接口 +
impl):
com.example.demo
├── DemoApplication.java # 启动类(@EnableAsync)
├── common
│ ├── ApiResult.java # 统一响应体
│ └── ErrorCode.java # 统一错误码
├── exception
│ ├── BizException.java # 业务异常
│ └── GlobalExceptionHandler.java # 全局异常处理
├── config
│ └── BeanConfig.java # 线程池 @Bean
├── dto
│ └── RegisterDTO.java # 入参(校验注解)
├── vo
│ └── UserVO.java # 出参(裁剪字段)
├── entity
│ └── User.java # 数据库实体
├── mapper
│ └── UserMapper.java # 数据访问(JdbcTemplate)
├── service
│ ├── UserService.java # 接口
│ └── impl
│ └── UserServiceImpl.java # 实现(构造器注入 + @Transactional)
├── controller
│ └── UserController.java
├── event
│ ├── UserRegisteredEvent.java # 事件
│ └── UserRegisteredListener.java # 监听器(@Async + @TransactionalEventListener)
└── aspect
└── WebLogAspect.java # 日志耗时切面
4.6.2
基础设施层(贯穿全系列的公共类)
这几个类是 12
篇文章的公共基础设施,类名、字段名、方法名全系列固定,逐字给出完整实现。
// common/ApiResult.java —— 统一响应体:所有 Controller 返回该结构
package com.example.demo.common;
import lombok.Data;
import org.slf4j.MDC;
@Data
public class ApiResult<T> {
private int code; // 0=成功,非 0=错误码
private String message; // 提示信息
private T data; // 业务数据
private String traceId; // 链路追踪 id
public static <T> ApiResult<T> ok(T data) {
ApiResult<T> r = new ApiResult<>();
r.setCode(ErrorCode.SUCCESS.getCode());
r.setMessage(ErrorCode.SUCCESS.getMessage());
r.setData(data);
r.setTraceId(MDC.get("traceId"));
return r;
}
public static <T> ApiResult<T> ok() {
return ok(null);
}
public static <T> ApiResult<T> fail(int code, String message) {
ApiResult<T> r = new ApiResult<>();
r.setCode(code);
r.setMessage(message);
r.setTraceId(MDC.get("traceId"));
return r;
}
public static <T> ApiResult<T> fail(ErrorCode ec) {
return fail(ec.getCode(), ec.getMessage());
}
}
// common/ErrorCode.java —— 统一错误码:基础码必须保留且数值不变
package com.example.demo.common;
public enum ErrorCode {
SUCCESS(0, "success"),
PARAM_ERROR(40001, "参数错误"),
UNAUTHORIZED(40101, "未登录或登录已过期"),
FORBIDDEN(40301, "无权限访问"),
USER_NOT_FOUND(40401, "用户不存在"),
SYSTEM_ERROR(50000, "系统繁忙,请稍后重试"),
// 本篇业务扩展
INSUFFICIENT_BALANCE(50001, "账户余额不足"),
;
private final int code;
private final String message;
ErrorCode(int code, String message) {
this.code = code;
this.message = message;
}
public int getCode() { return code; }
public String getMessage() { return message; }
}
// exception/BizException.java —— 业务异常:业务代码抛出,由全局处理器统一转 ApiResult
package com.example.demo.exception;
import com.example.demo.common.ErrorCode;
public class BizException extends RuntimeException {
private final int code;
public BizException(ErrorCode ec) {
super(ec.getMessage());
this.code = ec.getCode();
}
public BizException(int code, String message) {
super(message);
this.code = code;
}
public int getCode() { return code; }
}
// exception/GlobalExceptionHandler.java —— 全局异常处理:兜底异常对外只给模糊提示
package com.example.demo.exception;
import com.example.demo.common.ApiResult;
import com.example.demo.common.ErrorCode;
import lombok.extern.slf4j.Slf4j;
import org.springframework.web.bind.MethodArgumentNotValidException;
import org.springframework.web.bind.annotation.ExceptionHandler;
import org.springframework.web.bind.annotation.RestControllerAdvice;
@Slf4j
@RestControllerAdvice
public class GlobalExceptionHandler {
// 业务异常:code/message 直接透传给前端
@ExceptionHandler(BizException.class)
public ApiResult<Void> handleBiz(BizException e) {
log.warn("业务异常: code={}, message={}", e.getCode(), e.getMessage());
return ApiResult.fail(e.getCode(), e.getMessage());
}
// 参数校验异常(@Valid 触发):取第一条失败信息返回
@ExceptionHandler(MethodArgumentNotValidException.class)
public ApiResult<Void> handleValid(MethodArgumentNotValidException e) {
String msg = e.getBindingResult().getFieldErrors().stream()
.findFirst()
.map(f -> f.getField() + " " + f.getDefaultMessage())
.orElse(ErrorCode.PARAM_ERROR.getMessage());
log.warn("参数校验失败: {}", msg);
return ApiResult.fail(ErrorCode.PARAM_ERROR.getCode(), msg);
}
// 兜底异常:对外返回模糊提示,详细堆栈只进日志,避免泄露内部结构
@ExceptionHandler(Exception.class)
public ApiResult<Void> handleOther(Exception e) {
log.error("系统异常", e);
return ApiResult.fail(ErrorCode.SYSTEM_ERROR);
}
}
4.6.3
实体与分层对象(DTO / Entity / VO 分离)
// entity/User.java —— 数据库实体(映射 t_user 表)
package com.example.demo.entity;
import lombok.Data;
@Data
public class User {
private Long id;
private String username;
}
// dto/RegisterDTO.java —— 入参:带 JSR-303 校验注解,禁止直接接 Entity
package com.example.demo.dto;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.Size;
import lombok.Data;
@Data
public class RegisterDTO {
@NotBlank(message = "用户名不能为空")
@Size(min = 3, max = 20, message = "用户名长度需在 3-20 之间")
private String username;
}
// vo/UserVO.java —— 出参:按需裁剪字段,不暴露内部实体细节
package com.example.demo.vo;
import lombok.Data;
@Data
public class UserVO {
private Long id;
private String username;
public static UserVO from(Long id, String username) {
UserVO vo = new UserVO();
vo.setId(id);
vo.setUsername(username);
return vo;
}
}
4.6.4
数据访问层(JdbcTemplate)
// mapper/UserMapper.java —— 数据访问:本篇用 JdbcTemplate 演示(后续阶段再换 MyBatis-Plus)
package com.example.demo.mapper;
import lombok.RequiredArgsConstructor;
import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.jdbc.support.GeneratedKeyHolder;
import org.springframework.jdbc.support.KeyHolder;
import org.springframework.stereotype.Repository;
import java.sql.PreparedStatement;
import java.sql.Statement;
@Repository // @Repository:数据访问层,提供持久层异常翻译
@RequiredArgsConstructor
public class UserMapper {
private final JdbcTemplate jdbcTemplate;
public Long insert(String username) {
KeyHolder keyHolder = new GeneratedKeyHolder();
jdbcTemplate.update(con -> {
PreparedStatement ps = con.prepareStatement(
"INSERT INTO t_user(username) VALUES (?)", Statement.RETURN_GENERATED_KEYS);
ps.setString(1, username);
return ps;
}, keyHolder);
// 拿到自增主键
Number key = keyHolder.getKey();
return key == null ? null : key.longValue();
}
public boolean existsByUsername(String username) {
Integer count = jdbcTemplate.queryForObject(
"SELECT COUNT(*) FROM t_user WHERE username = ?", Integer.class, username);
return count != null && count > 0;
}
}
注意
schema.sql里要加上t_user表(第 5
章运行步骤会给出完整脚本)。
4.6.5 业务层(接口 +
impl,构造器注入 + 事务 + 事件)
// service/UserService.java —— 接口
package com.example.demo.service;
import com.example.demo.dto.RegisterDTO;
import com.example.demo.vo.UserVO;
public interface UserService {
UserVO register(RegisterDTO dto);
}
// service/impl/UserServiceImpl.java —— 实现:构造器注入 + @Transactional + 发布事件
package com.example.demo.service.impl;
import com.example.demo.dto.RegisterDTO;
import com.example.demo.event.UserRegisteredEvent;
import com.example.demo.exception.BizException;
import com.example.demo.mapper.UserMapper;
import com.example.demo.service.UserService;
import com.example.demo.vo.UserVO;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.context.ApplicationEventPublisher;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
@Slf4j
@Service
@RequiredArgsConstructor
public class UserServiceImpl implements UserService {
private final UserMapper userMapper;
private final ApplicationEventPublisher publisher; // 事件发布器
@Override
@Transactional // 注册整体在一个事务内:落库 + 发布事件要么都成功,要么都回滚
public UserVO register(RegisterDTO dto) {
// 业务校验:用户名重复抛业务异常(由全局处理器转 ApiResult)
if (userMapper.existsByUsername(dto.getUsername())) {
throw new BizException(40010, "用户名已存在");
}
Long id = userMapper.insert(dto.getUsername());
// 发布事件:监听器在「事务提交后」才异步消费,不拖慢、不连累本事务
publisher.publishEvent(new UserRegisteredEvent(this, id, dto.getUsername()));
log.info("用户注册成功: id={}, username={}", id, dto.getUsername());
return UserVO.from(id, dto.getUsername());
}
}
4.6.6 事件(解耦通知)
// event/UserRegisteredEvent.java
package com.example.demo.event;
import org.springframework.context.ApplicationEvent;
public class UserRegisteredEvent extends ApplicationEvent {
private final Long userId;
private final String username;
public UserRegisteredEvent(Object source, Long userId, String username) {
super(source);
this.userId = userId;
this.username = username;
}
public Long getUserId() { return userId; }
public String getUsername() { return username; }
}
// event/UserRegisteredListener.java —— 事务提交后 + 异步发通知
package com.example.demo.event;
import lombok.extern.slf4j.Slf4j;
import org.springframework.scheduling.annotation.Async;
import org.springframework.stereotype.Component;
import org.springframework.transaction.event.TransactionPhase;
import org.springframework.transaction.event.TransactionalEventListener;
@Slf4j
@Component
public class UserRegisteredListener {
// ① @TransactionalEventListener(AFTER_COMMIT):数据落库后才消费,异常不连累主事务
// ② @Async:走第 4.2.5 节配置的线程池,不阻塞注册接口
@Async
@TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT)
public void sendWelcomeNotice(UserRegisteredEvent event) {
// 生产里这里会对接短信/邮件/推送服务
log.info("异步发送欢迎通知: userId={}, username={}", event.getUserId(), event.getUsername());
}
}
4.6.7 切面与配置
// aspect/WebLogAspect.java —— 接口耗时 + 异常日志
package com.example.demo.aspect;
import lombok.extern.slf4j.Slf4j;
import org.aspectj.lang.ProceedingJoinPoint;
import org.aspectj.lang.annotation.Around;
import org.aspectj.lang.annotation.Aspect;
import org.springframework.stereotype.Component;
@Slf4j
@Aspect
@Component
public class WebLogAspect {
@Around("execution(* com.example.demo.controller..*.*(..))")
public Object log(ProceedingJoinPoint pjp) throws Throwable {
String method = pjp.getTarget().getClass().getSimpleName() + "." + pjp.getSignature().getName();
long start = System.currentTimeMillis();
try {
Object result = pjp.proceed();
log.info("[OK] {} 耗时 {}ms", method, System.currentTimeMillis() - start);
return result;
} catch (Throwable t) {
log.error("[FAIL] {} 耗时 {}ms", method, System.currentTimeMillis() - start, t);
throw t; // 原样抛出,交给 GlobalExceptionHandler
}
}
}
// config/BeanConfig.java —— 线程池 @Bean(给 @Async 用)
package com.example.demo.config;
import org.springframework.boot.context.properties.ConfigurationProperties;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.scheduling.concurrent.ThreadPoolTaskExecutor;
import java.util.concurrent.ThreadPoolExecutor;
@Configuration
public class BeanConfig {
@Bean
@ConfigurationProperties(prefix = "app.thread-pool")
public ThreadPoolTaskExecutor taskExecutor() {
ThreadPoolTaskExecutor executor = new ThreadPoolTaskExecutor();
executor.setCorePoolSize(4);
executor.setMaxPoolSize(8);
executor.setQueueCapacity(200);
executor.setThreadNamePrefix("app-exec-");
executor.setRejectedExecutionHandler(new ThreadPoolExecutor.CallerRunsPolicy());
return executor;
}
}
4.6.8 控制层与启动类
// controller/UserController.java
package com.example.demo.controller;
import com.example.demo.common.ApiResult;
import com.example.demo.dto.RegisterDTO;
import com.example.demo.service.UserService;
import com.example.demo.vo.UserVO;
import jakarta.validation.Valid;
import lombok.RequiredArgsConstructor;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
@RestController
@RequestMapping("/api/users")
@RequiredArgsConstructor
public class UserController {
private final UserService userService; // 构造器注入
// @Valid 触发 DTO 校验,失败抛 MethodArgumentNotValidException,由全局处理器兜底
@PostMapping("/register")
public ApiResult<UserVO> register(@Valid @RequestBody RegisterDTO dto) {
return ApiResult.ok(userService.register(dto));
}
}
// DemoApplication.java
package com.example.demo;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
import org.springframework.scheduling.annotation.EnableAsync;
@SpringBootApplication
@EnableAsync // 开启 @Async 异步支持
public class DemoApplication {
public static void main(String[] args) {
SpringApplication.run(DemoApplication.class, args);
}
}
本章小结
一个生产级模块的骨架是「Controller(@Valid +
ApiResult)→ Service 接口 + impl(构造器注入 +
@Transactional + 发布事件)→ Mapper(数据访问)→
监听器(@Async +
@TransactionalEventListener)→
切面(日志耗时)」,每一层职责单一、依赖通过构造器注入、横切逻辑交给
AOP、可延后动作交给事件。
5. 生产级实战项目
第 4.6
章已经给出完整源码,本节把它组装成一个可直接运行的工程:目录结构、配置文件、启动步骤、验证命令一应俱全。
5.1 完整目录结构
demo
├── pom.xml
└── src/main
├── java/com/example/demo
│ ├── DemoApplication.java
│ ├── common
│ │ ├── ApiResult.java
│ │ └── ErrorCode.java
│ ├── exception
│ │ ├── BizException.java
│ │ └── GlobalExceptionHandler.java
│ ├── config
│ │ └── BeanConfig.java
│ ├── dto
│ │ └── RegisterDTO.java
│ ├── vo
│ │ └── UserVO.java
│ ├── entity
│ │ └── User.java
│ ├── mapper
│ │ └── UserMapper.java
│ ├── service
│ │ ├── UserService.java
│ │ ├── AccountService.java
│ │ └── impl
│ │ ├── UserServiceImpl.java
│ │ └── AccountServiceImpl.java
│ ├── controller
│ │ ├── UserController.java
│ │ └── AccountController.java
│ ├── event
│ │ ├── UserRegisteredEvent.java
│ │ └── UserRegisteredListener.java
│ └── aspect
│ └── WebLogAspect.java
└── resources
├── application.yml
└── schema.sql
5.2 完整配置文件
src/main/resources/schema.sql(H2
启动建表,t_user 给注册用,t_account
给转账用):
-- 用户表(注册场景)
CREATE TABLE IF NOT EXISTS t_user (
id BIGINT AUTO_INCREMENT PRIMARY KEY,
username VARCHAR(64) NOT NULL UNIQUE
);
-- 账户表(转账事务场景)
CREATE TABLE IF NOT EXISTS t_account (
id BIGINT PRIMARY KEY,
username VARCHAR(64) NOT NULL,
balance DECIMAL(10,2) NOT NULL DEFAULT 0
);
src/main/resources/application.yml:
spring:
datasource:
url: jdbc:h2:mem:demo;DB_CLOSE_DELAY=-1
driver-class-name: org.h2.Driver
username: sa
password: ""
sql:
init:
mode: always
schema-locations: classpath:schema.sql
app:
thread-pool:
core-pool-size: 4
max-pool-size: 8
5.3
补充源码:转账 Controller(串起第 5 章事务知识)
第 4.6 章已给全注册链路源码,这里补一个转账
Controller,让事务也能通过 HTTP 验证(AccountService 接口 +
AccountServiceImpl 见第 4.5.4 节):
// controller/AccountController.java
package com.example.demo.controller;
import com.example.demo.common.ApiResult;
import com.example.demo.service.AccountService;
import lombok.RequiredArgsConstructor;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;
import java.math.BigDecimal;
@RestController
@RequestMapping("/api/accounts")
@RequiredArgsConstructor
public class AccountController {
private final AccountService accountService;
// 转账:余额不足时抛 BizException(INSUFFICIENT_BALANCE),全局处理器转成 ApiResult
@PostMapping("/transfer")
public ApiResult<Void> transfer(@RequestParam Long fromId,
@RequestParam Long toId,
@RequestParam BigDecimal amount) {
accountService.transfer(fromId, toId, amount);
return ApiResult.ok(null);
}
}
service/AccountService.java 接口:
package com.example.demo.service;
import java.math.BigDecimal;
public interface AccountService {
void transfer(Long fromId, Long toId, BigDecimal amount);
}
5.4 运行步骤
# 1) 启动应用
mvn spring-boot:run
# 2) 另一个终端:注册用户(触发切面 + 事务 + 事件)
curl -X POST http://localhost:8080/api/users/register
-H "Content-Type: application/json"
-d '{"username":"alice"}'
# 期望响应(ApiResult 结构,code=0 成功)
# {"code":0,"message":"success","data":{"id":1,"username":"alice"},"traceId":null}
# 3) 参数校验失败(用户名过短),验证 GlobalExceptionHandler
curl -X POST http://localhost:8080/api/users/register
-H "Content-Type: application/json"
-d '{"username":"ab"}'
# {"code":40001,"message":"username 用户名长度需在 3-20 之间","data":null,"traceId":null}
# 4) 初始化两个账户,验证转账事务
# 先通过 H2 控制台或测试代码插入:INSERT INTO t_account VALUES (1,'a',100.00),(2,'b',0.00);
curl -X POST "http://localhost:8080/api/accounts/transfer?fromId=1&toId=2&amount=50.00"
# {"code":0,"message":"success","data":null,"traceId":null}
# 5) 余额不足(amount > 100),验证事务回滚 + 业务异常
curl -X POST "http://localhost:8080/api/accounts/transfer?fromId=1&toId=2&amount=999.00"
# {"code":50001,"message":"账户余额不足","data":null,"traceId":null}
启动后观察控制台日志,应能看到切面打印的耗时日志、监听器打印的异步通知日志,验证
AOP 与事件链路都生效。
运行提示:H2
内存库每次重启会重建,测试转账前需先插入账户数据。可临时在
schema.sql里追加
INSERT INTO t_account VALUES (1,'a',100.00),(2,'b',0.00);
作为演示种子数据。
6. 常见坑与排错指南
| 坑 / 现象 | 原因 | 解决方案 |
|---|---|---|
字段 @Autowired 注入的依赖为 null,运行NPE |
字段注入在构造器之后才填充,且依赖不可变/难测试 | 改用构造器注入(final +@RequiredArgsConstructor) |
应用启动报 form a cycle 循环依赖错误 |
构造器注入暴露了 A↔︎B 循环依赖(设计问题) | 重构:抽公共类 / 事件解耦;不要改回字段注入掩盖 |
加了 @Transactional 但事务不生效、数据没回滚 |
同类自调用(this.xxx())绕过了代理 |
拆分到另一个 Bean,或注入自身代理 |
@Transactional 标在private/protected 方法上无效 |
CGLIB 靠继承重写,无法拦截非 public;事务官方只支持 public | 标在 public 方法上 |
事务里 try-catch 吞异常,数据却没回滚 |
Spring 感知不到异常,照常提交 | 异常上抛,或 setRollbackOnly() |
抛 IOException 等受检异常,事务不回滚 |
默认只对 RuntimeException/Error 回滚 |
@Transactional(rollbackFor = Exception.class) |
| singleton 里注入 prototype Bean,每次拿到的还是同一个 | 单例只在创建时注入一次 | 用 ObjectProvider#getObject() 或@Lookup |
| 事件监听器在事务提交前执行,读到脏数据 / 异常连累主事务 | 用了同步 @EventListener 而非事务监听 |
改 @TransactionalEventListener(AFTER_COMMIT) |
| 监听器收不到事件 | 发布方没有事务,且监听器没加 fallbackExecution |
加 fallbackExecution = true,或确保发布方有事务 |
| 转账时「扣款失败」却还继续入账,造成资损 | 扣款 UPDATE 返回 0 后未抛异常 | 判断影响行数,为 0 时抛 BizException |
8. 总结与延伸阅读
本篇总结
Spring Boot 的一切「魔法」,归根到底是两件事:IoC
帮你装配对象,AOP 帮你增强对象。IoC
的正确姿势是构造器注入(final +
@RequiredArgsConstructor),它带来不可变、非空、易测试,还能在启动期暴露循环依赖逼你重构;Bean
生命周期里 BeanPostProcessor 是 AOP 的落点;AOP
靠「代理对象替换原始对象」工作,Spring Boot 默认用
CGLIB(继承生成子类),这直接决定了「同类自调用」和「非 public
方法」两大失效场景;@Transactional 本质就是一个 AOP
切面,所以它的失效规律与 AOP
完全一致;事件机制(@TransactionalEventListener +
@Async)则是模块解耦的利器。把这些底层机制吃透,后续的自动配置、Web
开发、缓存、安全,你都能「看穿实现套路」而不是「背配置」。
延伸阅读
- Spring 官方文档 · Core Technologies:IoC
容器、AOP、事件、事务的权威定义,查 API 首选。https://docs.spring.io/spring-framework/reference/core.html - 《Spring 实战(第 6 版)》(Craig
Walls):循序渐进地讲透 IoC / AOP,配套 Spring Boot 3
实践。 - 《Spring 揭秘》(王福强):专攻 IoC / AOP
实现原理,讲容器和 AOP 的「为什么」。 - 《Spring Boot 编程思想(核心篇)》(小马哥):深入
Spring Framework 设计思想,适合进阶。 - Spring 官方 Reference · Declarative Transaction
Management:事务传播行为、隔离级别、失效场景的官方说明。https://docs.spring.io/spring-framework/reference/data-access/transaction.html