阶段 4 · Spring Boot 学习路线 版本基线:Spring Boot 3.2.x / JDK 17 /
MySQL 8.0 / Redis 7.x 前置依赖:阶段 3(Web 开发)
1. 导语
到阶段 3 为止,你已经能写出一个「有接口、有分层、有统一返回」的 Web
应用。但一个真实的业务系统,数据从哪来、改到哪去、怎么保证数据不错、怎么让接口快起来——这些问题的答案,全在「数据访问」这一层。
数据访问是后端最容易出事的地方,没有之一。一个团队可能把 Controller
写得再漂亮,一旦数据库连接池耗尽、事务静默失效、缓存被大促瞬间打穿,线上照样翻车。这些事故的共同点是:代码看起来能跑,单测也过,但一到生产就炸。原因往往不是语法错误,而是对「连接池、事务、缓存」这三样东西的理解停留在表面。
本篇要解决的就是这三个「一看就会、一用就错」的硬骨头:
- 连接池——HikariCP 为什么不能把
maximum-pool-size拉到 500,连接池耗尽了怎么排障。 - 事务——
@Transactional
加了为什么没生效?本篇会带你把 5
大失效场景逐个跑出「失效」的证据,而不是背结论。 - 缓存——缓存穿透、击穿、雪崩到底差在哪,以及「写时删缓存,不更新缓存」这条原则背后的并发逻辑。
学完本篇,你能独立写出一个「订单 + 扣库存 +
缓存」的完整模块:MyBatis-Plus 做 CRUD、JPA
做简单模块、事务保护一致性、Redis 扛住读流量、Flyway
管好表结构。这也是你第一次真正写出「生产级」的数据层,而不是教程里那种只有
insert 一个动作的玩具代码。
与前后阶段的关系:本篇的「事务」是阶段
6(微服务与分布式事务)的前置基础;「缓存」的高并发方案会在阶段
9(性能优化)进一步展开;而「多数据源」则为阶段 7
的读写分离落地做铺垫。
2. 学习目标与前置要求
学完你能:
- 独立配置一套生产级的 HikariCP
连接池,并能在连接池耗尽时根据日志和报错定位根因。 - 用 MyBatis-Plus 完成带「自动填充、逻辑删除、乐观锁、分页」的用户
CRUD 模块,且不使用select *、不在循环里单条 insert。 - 用 Spring Data JPA 完成一个简单模块,并能根据业务场景在 MyBatis-Plus
与 JPA 之间做出选型判断。 - 写出带事务的「订单 + 扣库存」方法,能解释 5 大
@Transactional
失效场景,并逐个用代码验证「确实没回滚」。 - 用 Spring Cache + Redis 实现 Cache Aside
缓存,能画出穿透/击穿/雪崩三者的「成因 → 方案 →
代码」链路,并用互斥锁实现防击穿。 - 用 dynamic-datasource 实现写主读从的读写分离,用 Flyway
管理表结构版本化迁移,并掌握基础的索引与慢查询排查方法。
前置依赖:
- 已完成阶段 3(Web 开发),掌握 Controller/Service/Mapper
分层;ApiResult/ErrorCode/BizException/GlobalExceptionHandler
四个公共类见阶段 1(本篇直接复用,不重复讲解)。 - 本地已装好 JDK 17、Maven、MySQL 8.0、Redis 7.x。
- 若你对 AOP 动态代理还不熟,建议先回顾阶段 3 里关于 Spring AOP
的一节——本篇第 4 章「自调用失效」正是建立在代理机制之上。
3. 环境准备
本篇依赖 MySQL 与 Redis,先确认环境可用:
# 确认版本
java -version # 期望 openjdk 17.x
mysql --version # 期望 8.0.x
redis-cli --version # 期望 7.x
# 启动 MySQL 与 Redis(Docker 方式,二选一即可)
docker run -d --name mysql8
-e MYSQL_ROOT_PASSWORD=root123456
-e MYSQL_DATABASE=shop
-p 3306:3306 mysql:8.0
docker run -d --name redis7 -p 6379:6379 redis:7
# 创建示例库(进入 mysql 容器执行)
mysql -uroot -proot123456 -e "CREATE DATABASE IF NOT EXISTS shop DEFAULT CHARACTER SET utf8mb4;"
Maven 依赖(pom.xml
核心部分,版本号务必与本文一致,避免踩 API 差异的坑):
<properties>
<java.version>17</java.version>
<spring-boot.version>3.2.5</spring-boot.version>
<mybatis-plus.version>3.5.7</mybatis-plus.version>
<dynamic-datasource.version>4.3.0</dynamic-datasource.version>
</properties>
<dependencies>
<!-- Web:提供 Controller / MVC 能力 -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
</dependency>
<!-- 校验:DTO 上的 JSR-303 注解 -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-validation</artifactId>
</dependency>
<!-- JDBC:JdbcTemplate 与 DataSource 自动装配 -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-jdbc</artifactId>
</dependency>
<!-- MyBatis-Plus:注意是 boot3 专用 starter,boot2 用的是另一个 artifact -->
<dependency>
<groupId>com.baomidou</groupId>
<artifactId>mybatis-plus-spring-boot3-starter</artifactId>
<version>${mybatis-plus.version}</version>
</dependency>
<!-- Spring Data JPA:本篇第 3 章与 MP 对比使用 -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-data-jpa</artifactId>
</dependency>
<!-- Redis + Cache 抽象:本篇第 5 章缓存使用 -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-data-redis</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-cache</artifactId>
</dependency>
<!-- 多数据源:本篇第 6 章读写分离使用 -->
<dependency>
<groupId>com.baomidou</groupId>
<artifactId>dynamic-datasource-spring-boot3-starter</artifactId>
<version>${dynamic-datasource.version}</version>
</dependency>
<!-- Flyway:本篇第 7 章数据库迁移使用 -->
<dependency>
<groupId>org.flywaydb</groupId>
<artifactId>flyway-core</artifactId>
</dependency>
<dependency>
<groupId>org.flywaydb</groupId>
<artifactId>flyway-mysql</artifactId>
</dependency>
<!-- Lombok:@Data / @Slf4j / @RequiredArgsConstructor -->
<dependency>
<groupId>org.projectlombok</groupId>
<artifactId>lombok</artifactId>
<optional>true</optional>
</dependency>
</dependencies>
主配置文件(application.yml,连接串与密码一律走环境变量,禁止硬编码提交到仓库):
spring:
datasource:
# ${DB_HOST:localhost} 表示:读环境变量 DB_HOST,未设置则用默认值 localhost
url: jdbc:mysql://${DB_HOST:localhost}:3306/shop?useSSL=false&serverTimezone=Asia/Shanghai&characterEncoding=utf8&rewriteBatchedStatements=true
username: ${DB_USER:root}
password: ${DB_PASSWORD:root123456}
driver-class-name: com.mysql.cj.jdbc.Driver
hikari:
maximum-pool-size: 20
minimum-idle: 5
connection-timeout: 30000
max-lifetime: 1800000
idle-timeout: 600000
pool-name: ShopHikariPool
data:
redis:
host: ${REDIS_HOST:localhost}
port: ${REDIS_PORT:6379}
# 生产环境必须设密码,本地开发可省略
password: ${REDIS_PASSWORD:}
timeout: 3000ms
jpa:
hibernate:
ddl-auto: none # 生产禁用自动建表,表结构交给 Flyway 管理
open-in-view: false # 关闭 OSIV,避免连接被长事务长期占用
show-sql: false
mybatis-plus:
configuration:
map-underscore-to-camel-case: true # 下划线列名自动映射到驼峰字段
log-impl: org.apache.ibatis.logging.stdout.StdOutImpl # 开发期打印 SQL,生产换 logback
global-config:
db-config:
id-type: auto # 主键自增
logic-delete-field: deleted # 逻辑删除字段(配合 @TableLogic)
logic-delete-value: 1 # 删除后置为 1
logic-not-delete-value: 0 # 未删除为 0
为什么
rewriteBatchedStatements=true?
MySQL JDBC 驱动默认不开启批量改写,saveBatch
会退化成一条条执行。加了这个参数,驱动才会把多条 INSERT 合并成
INSERT INTO ... VALUES (...),(...),(...),批量插入性能提升数倍。这是很多人「用了
saveBatch 却没变快」的根因。
项目结构(本篇新增
mapper、entity、repository、config
等数据访问层):
com.example.shop
├── controller # 接口层:UserController / OrderController
├── service # 业务层:接口 + impl
├── mapper # MyBatis-Plus Mapper 接口
├── repository # Spring Data JPA Repository 接口
├── entity # 数据库实体:User / Order / Product / Stock
├── dto # 入参对象:OrderCreateDTO / UserSaveDTO
├── vo # 出参对象:UserVO / OrderVO
├── config # 配置类:MybatisPlusConfig / CacheConfig / RedisConfig
├── exception # BizException / GlobalExceptionHandler
└── common # ApiResult / ErrorCode / 通用工具
一、数据库基础与连接池
1.1 为什么需要连接池
先想一个朴素的问题:一次数据库查询,网络和资源上到底发生了什么?
- 客户端与 MySQL 建立 TCP 连接(三次握手)。
- MySQL 服务端为该连接 分配线程与内存。
- 客户端发送 认证请求(用户名密码校验)。
- 执行 SQL,返回结果。
- 连接关闭(四次挥手),服务端 回收线程。
如果每次查询都走完这 5 步,前 3 步和最后 1 步就是纯浪费。建立一条
MySQL 连接的耗时通常在
几十毫秒到上百毫秒,而一条简单查询本身可能只要
1ms。也就是说,连接建立的开销可能比查询本身大两个数量级。
连接池的思路因此非常朴素:提前建立一批连接放在池子里,用完归还而不是关闭,下次直接复用。这就是「池化」——和线程池、对象池是同一个思想:复用昂贵资源,避免频繁创建销毁。
Spring Boot 默认集成的是 HikariCP,目前 Java
生态里性能最好、也最主流的连接池。它有两个关键设计:
- 连接是惰性创建的:池子启动时是空的,来一个请求才创建一条,直到达到
maximum-pool-size。所以「配了 20 条连接」不等于「启动就有
20 条」,而是「最多 20 条」。 - 连接超时快速失败:当池子里的连接都被占满,新请求不会无限等待,而是等到
connection-timeout就抛异常。这比「卡死」更好排查。
1.2
最小可用示例:JdbcTemplate 直连查询
在引入任何 ORM 之前,先用最底层的 JdbcTemplate
感受一下「连接池 → SQL → 结果」的完整链路。这也是理解 MyBatis 和 JPA
的底座——它们最终都是通过连接池拿连接执行 SQL。
// 最小可用:直接用 JdbcTemplate 查一行数据
@RestController
@RequiredArgsConstructor
public class HealthController {
// 构造器注入 JdbcTemplate,它底层通过 HikariCP 获取连接
private final JdbcTemplate jdbcTemplate;
@GetMapping("/db/health")
public ApiResult<String> health() {
// queryForObject 内部流程:从池借连接 -> 执行 SQL -> 归还连接
Integer one = jdbcTemplate.queryForObject("SELECT 1", Integer.class);
return ApiResult.ok("db is alive, result=" + one);
}
}
这段代码没写任何连接管理,queryForObject 会自动「借连接
→ 执行 →
还连接」。这是连接池最核心的价值:让你忘掉连接的开关,但前提是连接一定会被正确归还——后面第
1.4 节你会看到「忘归还」的灾难。
1.3 生产级 HikariCP 配置
下面是生产环境的完整配置,每个参数都有明确理由,不是复制粘贴的默认值:
spring:
datasource:
url: jdbc:mysql://${DB_HOST:localhost}:3306/shop?useSSL=false&serverTimezone=Asia/Shanghai&characterEncoding=utf8&rewriteBatchedStatements=true
username: ${DB_USER:root}
password: ${DB_PASSWORD:root123456}
hikari:
# 池中最大连接数。核心调优项,不是越大越好,见下文解释
maximum-pool-size: 20
# 池中最小空闲连接数,用于预热,避免突发流量时临时创建连接的延迟
minimum-idle: 5
# 获取连接的超时时间。超过这个时间还没拿到连接就抛 SQLTransientConnectionException
connection-timeout: 30000
# 连接最大存活时间,必须小于 MySQL 的 wait_timeout(默认 8 小时),
# 否则连接被 MySQL 主动断开后,池子还拿着一条死连接
max-lifetime: 1800000
# 空闲连接超过这个时间会被回收(但不会低于 minimum-idle)
idle-timeout: 600000
# 连接池名称,日志里能区分是哪个池,排障时非常有用
pool-name: ShopHikariPool
# 借出连接前先测试其有效性,防止拿到死连接
connection-test-query: SELECT 1
maximum-pool-size 为什么不是越大越好?
这是一个高频面试题,答案是「受两个硬约束限制」:
- 数据库端的连接数是有限的。MySQL 的
max_connections默认 151(8.0 更高一些但有限)。假设你有 10
个应用实例,每个池子配 50,那就是 500
条连接同时压向一个库——数据库的连接和线程资源会先被耗尽。 - 连接多不等于吞吐高。MySQL
处理查询的并行度受限于它的内部线程模型和磁盘/CPU。当并发连接数超过数据库「最优并发度」后,上下文切换和锁竞争的开销反而让整体变慢,进入「连接越多越慢」的拐点。
生产经验值:单实例池子大小通常在 10~30
之间起步,配合压测再调整。一个经典公式是
connections = ((core_count * 2) + effective_spindle_count),但它只是起点,最终要压测验证。
1.4 连接池耗尽:现象、根因、排障
现象:接口突然大面积变慢甚至超时,日志里刷出这样的异常:
java.sql.SQLTransientConnectionException: ShopHikariPool - Connection is not available, request timed out after 30000ms.
翻译过来就是:池子里没有空闲连接了,等了 30
秒还没等到。此时数据库本身可能负载并不高,但应用侧就是拿不到连接。
常见根因(按出现频率排序):
- 连接泄漏——拿到了连接却从没归还。最典型的是自己
dataSource.getConnection()后没在finally里
close(),或者事务执行了过长时间(长事务持锁又持连接)。 - 池子配得太小——高峰期并发超过
maximum-pool-size,连接供不应求。 - 慢 SQL 拖住连接——每个请求都执行一条 5 秒的 SQL,20
条连接很快被占满,后续请求全部排队。 - 数据库本身变慢——DB
慢导致每个连接占用时间变长,连接周转率下降,间接造成池子「看起来不够用」。
排障三步走:
第一步,确认是「泄漏」还是「不够用」。打开 Hikari 的 metrics
或看它的统计日志,关注两个指标:
// 打开 Hikari 连接池统计日志(生产可开启,性能影响极小)
@Configuration
public class HikariConfig {
// 通过日志观察 active / idle / waiting 三个值的变化趋势
}
更直接的方式是开启 Hikari 的 DEBUG 日志:
logging:
level:
com.zaxxer.hikari: DEBUG
观察日志里
Pool stats (total=20, active=20, idle=0, waiting=15)
这一行:
active=20, idle=0, waiting=15:连接全被占、15
个线程在等 → 说明池子确实满载。- 如果持续是
active=20且从不下降,而业务流量已经过去 →
高度怀疑连接泄漏,有连接借出去没还。
第二步,抓泄漏元凶。连接泄漏最典型的是长事务。用下面的 SQL 找出 MySQL
里执行时间最长的连接:
-- 找出跑得最久的连接,Time 列单位是秒
SELECT id, user, host, db, command, time, state, info
FROM information_schema.processlist
WHERE command != 'Sleep'
ORDER BY time DESC
LIMIT 20;
如果发现某条连接 time 持续增长,info
里是一个熟悉的
SQL,再回到代码里查对应的事务是不是没有及时提交/回滚。
第三步,对症下药:
- 泄漏:修正代码,确保连接在
finally
关闭;缩短事务时长,把「发短信、调外部接口」这类慢操作移出事务(见第 4
章)。 - 太小:压测后合理调大
maximum-pool-size,同时监控数据库连接数,避免把 DB
打爆。 - 慢 SQL:治本之策,看第 7 章慢查询排查。
1.5 连接泄漏:完整复现与修复
「连接泄漏」是连接池事故的头号元凶,必须亲眼看到它怎么发生、怎么修复。所谓泄漏,就是从池里拿了连接却不归还。注意:只要用
JdbcTemplate、MyBatis、JPA
这些框架,连接的开闭都是自动的;泄漏只发生在你手动碰
DataSource 的时候。
// 反例:手动从连接池取连接,却忘记关闭,造成连接泄漏
@Service
@RequiredArgsConstructor
@Slf4j
public class LeakDemoService {
// 注入 DataSource 本身(HikariCP 的实现)
private final DataSource dataSource;
public void leakConnection() {
try {
// 直接从池里"借"一条连接,绕过了框架的自动归还机制
Connection conn = dataSource.getConnection();
// 模拟业务:执行 SQL
Statement stmt = conn.createStatement();
stmt.execute("SELECT 1");
// 致命问题:这里没有 conn.close()!
// 这条连接被借走后永远不归还,池子里的连接只会越来越少
} catch (SQLException e) {
// 注意:即使进了 catch,连接也依然没有关闭,泄漏依旧发生
log.error("执行 SQL 失败", e);
}
}
}
复现方法:把池子 maximum-pool-size
临时设成 3,然后循环调用 leakConnection() 4 次:
# 第 1~3 次:正常执行(把 3 条连接全部借走且不还)
# 第 4 次:池子空了,等待 connection-timeout 后抛异常
# java.sql.SQLTransientConnectionException: ShopHikariPool - Connection is not available...
第四次请求抛异常,就是泄漏的「铁证」:不是流量大,而是连接只借不还,池子被慢慢掏空。
修复:用 try-with-resources 或 finally
确保连接一定归还:
// 修复:try-with-resources 自动关闭连接,无论正常返回还是抛异常都会归还
public void noLeak() {
// Connection 和 Statement 都实现 AutoCloseable,try 块结束自动 close
try (Connection conn = dataSource.getConnection();
Statement stmt = conn.createStatement()) {
stmt.execute("SELECT 1");
} catch (SQLException e) {
log.error("执行 SQL 失败", e);
// 连接已在 try-with-resources 里自动归还,无需手动处理
}
}
经验法则:业务代码里永远不要手动
getConnection()。让 JdbcTemplate/MyBatis/JPA
替你管理连接。必须手动拿连接的场景(如调用存储过程、执行底层
API),务必用 try-with-resources。
1.6 连接池监控与调优
连接池不是配完就完事,生产上要持续监控三个指标,判断池子是否健康。配合
Micrometer(Spring Boot Actuator 自带)可以暴露 HikariCP 的指标:
# 引入 Actuator 以暴露连接池指标
management:
endpoints:
web:
exposure:
include: health,metrics,info
metrics:
enable:
hikaricp: true
接入后通过 /actuator/metrics 查看 HikariCP
指标,关键有三个(都以 hikaricp.connections.* 开头):
| 指标 | 含义 | 健康信号 |
|---|---|---|
hikaricp.connections.active |
当前正在使用的连接数 | 长期贴近 maximum-pool-size 说明池子吃紧 |
hikaricp.connections.idle |
当前空闲连接数 | 长期为 0 说明没有缓冲,流量一涨就排队 |
hikaricp.connections.pending |
正在等待获取连接的线程数 | 长期 > 0 说明连接供不应求 |
hikaricp.connections.timeout |
获取连接超时的累计次数 | > 0 且持续增长说明曾/正在满载 |
调优三步走(结合指标判断):
- 看
pending是否长期 >
0。pending是「排队等连接」的线程数,长期大于 0
说明连接供不应求,考虑调大
maximum-pool-size(但要先确认数据库连接数上限)。 - 看
timeout
是否在增长。timeout
是「拿连接超时」的次数,只要在涨就说明高峰期池子不够用或有泄漏。 - 看
active
是否异常偏高且不回落。流量过了峰值后active
还不降下来,几乎可以断定有连接泄漏,回到 1.5 节排查。
调优的正确顺序是:先排除泄漏 → 再优化慢
SQL(缩短连接占用时间)→ 最后才考虑调大池子。很多人一上来就把池子从 20
调到 200,结果只是把压力转嫁给数据库,问题没解决还更糟。
1.7 连接池大小到底怎么定
maximum-pool-size
是连接池最常被问、也最常被配错的参数。下面给一个从「估算」到「验证」的完整方法。
第一步:用经验公式估算起点。 HikariCP
官方文档给出过一个经典公式:
connections = ((core_count * 2) + effective_spindle_count)
其中 core_count 是 CPU
核数,effective_spindle_count 是磁盘数(机械硬盘场景;SSD
场景通常取 1 或忽略)。假设一台 8 核 SSD 服务器:
connections = (8 * 2) + 1 = 17
所以 8 核机器从 17 左右起步是合理的,而不是拍脑袋写
100。但这个公式只是起点,不是终点——它假设连接都处于「活跃执行」状态,而真实业务里连接有大量时间在等网络、等锁。
第二步:压测验证。 用 JMeter / wrk
等工具对「典型读接口」逐步加压,观察两个曲线的拐点:
- 连接数从 17 慢慢往上调,看 QPS 是否随之上升;
- 当连接数继续调大、QPS
不再上升甚至下降时,就是「并发拐点」,这个值就是该业务下的最优池子大小。
第三步:留出余量。
池子大小要略高于「日常峰值并发」,给突发流量留缓冲,但不能高到把数据库连接数打爆(所有应用实例的池子之和要远小于
MySQL 的 max_connections)。
-- 查看数据库连接数上限,约束所有实例的池子总大小
SHOW VARIABLES LIKE 'max_connections';
-- 查看当前连接数
SHOW STATUS LIKE 'Threads_connected';
一句话结论:连接池大小 = 经验公式定起点 + 压测找拐点
+ 留突发余量,且「所有实例池子之和 + 预留」不能超过数据库
max_connections。它不是越大越好,也不是越小越省,而是「刚好够用并留余量」。
本章小结:连接池本质是「复用昂贵连接、快速失败」;maximum-pool-size
受数据库连接数与并发拐点双重约束;连接耗尽时先看 Hikari 的
active/idle/waiting
三值,区分「泄漏」和「不够用」,再对症处理;泄漏的根因是手动
getConnection() 不归还,修复靠
try-with-resources;池子大小用「公式定起点 + 压测找拐点 +
留余量」三步确定。
二、MyBatis-Plus 实战
2.1 为什么国内生产用
MyBatis-Plus
MyBatis 是「半自动 ORM」:SQL
全手写,框架只负责参数绑定和结果映射。它的优点是对 SQL 有 100%
控制力——复杂报表、多表关联、手写优化 SQL 都不在话下;缺点是简单 CRUD
也要手写 XML,很啰嗦。
MyBatis-Plus(以下简称 MP) 是在 MyBatis
之上的增强,解决了「简单 CRUD 还要手写」的痛点:它提供了
BaseMapper 让你一行 SQL
都不写就能完成单表增删改查,同时又保留了手写 SQL
的逃生通道(@Select 注解或
XML)。这正是它成为国内生产主流的理由:灵活性和开发效率两头都占。
本篇用 MP
完成用户模块,覆盖它最常用的六个能力:BaseMapper、LambdaQueryWrapper、分页插件、自动填充、逻辑删除、乐观锁。
2.2 Entity 与
BaseMapper:最小可用示例
先建一张用户表:
CREATE TABLE `user` (
`id` BIGINT NOT NULL AUTO_INCREMENT COMMENT '主键',
`username` VARCHAR(64) NOT NULL COMMENT '用户名',
`age` INT DEFAULT NULL COMMENT '年龄',
`email` VARCHAR(128) DEFAULT NULL COMMENT '邮箱',
`create_time` DATETIME DEFAULT NULL COMMENT '创建时间',
`update_time` DATETIME DEFAULT NULL COMMENT '更新时间',
`deleted` TINYINT NOT NULL DEFAULT 0 COMMENT '逻辑删除 0正常 1删除',
`version` INT NOT NULL DEFAULT 0 COMMENT '乐观锁版本号',
PRIMARY KEY (`id`),
UNIQUE KEY `uk_username` (`username`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='用户表';
对应的 Entity:
// 最小可用:Entity 用注解建立「字段 <-> 列」的映射
@Data
public class User {
// 主键自增。生产也可用 ASSIGN_ID 走雪花算法,本篇用自增便于演示
@TableId(type = IdType.AUTO)
private Long id;
private String username;
private Integer age;
private String email;
// fill = FieldFill.INSERT 表示插入时由框架自动填充,见 2.5 节
@TableField(fill = FieldFill.INSERT)
private LocalDateTime createTime;
@TableField(fill = FieldFill.INSERT_UPDATE)
private LocalDateTime updateTime;
// 逻辑删除标记:MP 会把 deleteById 自动改写为 update deleted=1
@TableLogic
private Integer deleted;
// 乐观锁版本号:更新时带上 version 条件,见 2.7 节
@Version
private Integer version;
}
Mapper 接口,继承 BaseMapper 后自动获得 17+ 个方法:
// 继承 BaseMapper 即拥有 selectById/insert/updateById/deleteById/selectList 等方法
@Mapper
public interface UserMapper extends BaseMapper<User> {
// 复杂 SQL 仍可在这里手写(@Select 注解或 XML),MP 不剥夺控制权
}
最小可用示例——一个查询 + 一个插入:
@RestController
@RequiredArgsConstructor
public class UserController {
private final UserMapper userMapper;
@GetMapping("/user/{id}")
public ApiResult<User> getById(@PathVariable Long id) {
// selectById:等价 SELECT * FROM user WHERE id=?
// 注意:生产不推荐 selectById 直接返回 Entity,这里仅演示最小链路
return ApiResult.ok(userMapper.selectById(id));
}
}
运行后访问 /user/1,观察控制台打印的
SQL:SELECT id,username,age,email,create_time,update_time,deleted,version FROM user WHERE id=? AND deleted=0。注意两件事:
- 自动拼了
deleted=0——这是
@TableLogic在起作用,逻辑删除字段会被 MP
自动追加到所有查询/更新条件。 - 返回的是全字段——这就是 2.8 节要批判的
select *问题。
2.3
LambdaQueryWrapper:告别魔法字符串
手写查询条件有两种写法。第一种是魔法字符串:
// 反例:列名写死在字符串里,字段一改名就静默失效
QueryWrapper<User> w = new QueryWrapper<>();
w.eq("username", name).ge("age", 18);
字符串的问题在于:username
写错了编译器不报错,重构字段名后这里悄悄失效,只能等运行时才发现。LambdaQueryWrapper
用方法引用代替字符串,让编译器帮你检查:
// 生产级:用方法引用写条件,字段名改动会被编译期发现
List<User> list = userMapper.selectList(
new LambdaQueryWrapper<User>()
.eq(User::getUsername, name) // 精确匹配
.ge(User::getAge, 18) // age >= 18
.orderByDesc(User::getCreateTime) // 按创建时间倒序
);
User::getUsername 是方法引用,MP
会解析出它对应的列名。字段改名时这里会直接编译报错,把「运行时事故」变成了「编译期错误」。这是
MP 相对原生 MyBatis 最重要的工程化提升之一。
2.4 分页插件
分页是高频需求。MP 需要先注册一个拦截器才能用
selectPage:
// 生产级:注册分页插件(同时可挂乐观锁拦截器)
@Configuration
public class MybatisPlusConfig {
@Bean
public MybatisPlusInterceptor mybatisPlusInterceptor() {
MybatisPlusInterceptor interceptor = new MybatisPlusInterceptor();
// 分页拦截器:注意 DbType 要写对,不同数据库分页方言不同
interceptor.addInnerInterceptor(new PaginationInnerInterceptor(DbType.MYSQL));
// 乐观锁拦截器:配合 @Version 使用,见 2.7 节
interceptor.addInnerInterceptor(new OptimisticLockerInnerInterceptor());
return interceptor;
}
}
分页查询示例:
// 生产级:分页查询,返回 Page 对象,禁止一次性查全表
public Page<User> pageUsers(int pageNum, int pageSize, String keyword) {
// 防御性校验:pageSize 上限,防止恶意传 pageSize=100000 拖垮数据库
if (pageNum < 1) pageNum = 1;
if (pageSize < 1 || pageSize > 100) pageSize = 20;
Page<User> page = new Page<>(pageNum, pageSize);
LambdaQueryWrapper<User> wrapper = new LambdaQueryWrapper<User>()
.like(StringUtils.hasText(keyword), User::getUsername, keyword)
.orderByDesc(User::getCreateTime);
return userMapper.selectPage(page, wrapper);
}
MP 的 selectPage 内部会执行两条 SQL:一条
SELECT COUNT(*) 算总数,一条
SELECT ... LIMIT ?,?
取当前页数据。这是「大表必须分页」的落地手段——不写分页就会一条
SQL 把整表拉进内存。
2.5
自动填充:createTime / updateTime 免手写
每个表都有 create_time /
update_time,如果在每个 insert/update 里手动
setCreateTime(now()),既啰嗦又容易漏。MP
的自动填充用一个处理器统一搞定:
// 生产级:全局自动填充处理器
@Component
public class MyMetaObjectHandler implements MetaObjectHandler {
// insert 时触发:填充创建时间和更新时间
@Override
public void insertFill(MetaObject metaObject) {
// strictInsertFill:只有当目标字段为 null 时才填充,避免覆盖业务显式传入的值
this.strictInsertFill(metaObject, "createTime", LocalDateTime.class, LocalDateTime.now());
this.strictInsertFill(metaObject, "updateTime", LocalDateTime.class, LocalDateTime.now());
}
// update 时触发:只刷新更新时间
@Override
public void updateFill(MetaObject metaObject) {
this.strictUpdateFill(metaObject, "updateTime", LocalDateTime.class, LocalDateTime.now());
}
}
配合 Entity 上的 @TableField(fill = FieldFill.INSERT) /
INSERT_UPDATE 注解(2.2 节已写),之后调用
insert 或 updateById 时,MP
会自动调用这个处理器填上时间字段,业务代码一行都不用写。
2.6 逻辑删除:删除变更新
物理删除(DELETE FROM)一旦执行,数据就找不回来了。生产上更稳妥的做法是逻辑删除:给表加一个
deleted 标记位,删除时执行
UPDATE deleted=1,查询时自动过滤
deleted=0。
MP 用 @TableLogic 注解 + 全局配置(环境准备里的
logic-delete-field: deleted)就能实现:
// 业务代码:看起来是"删除",MP 实际执行的是 UPDATE
public void removeUser(Long id) {
// 实际 SQL:UPDATE user SET deleted=1 WHERE id=? AND deleted=0
userMapper.deleteById(id);
}
观察日志会发现 deleteById 被改写成了
UPDATE。查询时所有 SQL 都会自动追加
AND deleted=0,所以「被删」的数据对业务层完全透明。
注意一个坑:逻辑删除字段要避开唯一索引。如果
username 上有唯一索引,用户 A
被逻辑删除后,再注册一个同名用户会撞唯一索引。解决办法是删除时把
username 改写成
username + "_del_" + id,或者用 deleted
与业务字段组成联合唯一索引。
2.7 乐观锁:并发更新不丢数据
问题:两个请求同时读到 version=0
的同一条记录,各自 +1
后写回,后写的人会覆盖先写的人,造成「丢失更新」。
乐观锁思路:更新时带上版本号条件,UPDATE ... SET version=version+1 WHERE id=? AND version=0。如果影响行数为
0,说明版本号已经变了(别人改过了),本次更新失败。
MP 的乐观锁只需要两步:Entity 加 @Version 注解(2.2
节已写),再注册拦截器(2.4 节已注册
OptimisticLockerInnerInterceptor)。之后调用
updateById 时 MP 自动带上版本号条件:
// 生产级:乐观锁更新。演示并发下"谁后写谁失败"的语义
public boolean updateAgeWithOptimisticLock(Long id, Integer newAge) {
// 1. 先查出当前记录,拿到 version
User user = userMapper.selectById(id);
if (user == null) {
throw new BizException(ErrorCode.USER_NOT_FOUND);
}
user.setAge(newAge);
// 2. updateById 生成的 SQL 会带上 version 条件:
// UPDATE user SET age=?, version=version+1 WHERE id=? AND version=?
// 3. 返回 false 说明 version 不匹配,有并发修改,本次更新失败
return userMapper.updateById(user) > 0;
}
乐观锁的适用场景是「冲突不多」的更新(读多写少)。如果冲突非常频繁,乐观锁会大量失败重试,反而不如悲观锁(SELECT ... FOR UPDATE)高效。
2.8 生产规范:三条红线
这一节是 MP 使用中「看着都对、上线就慢」的重灾区,务必记牢:
红线一:禁止 select *。 2.2
节的最小示例里 selectById
返回全字段,生产里这是反模式。查出来的多余字段既浪费网络传输,又可能把不该暴露的字段(如
deleted、version)返回给前端。正确做法是查询后裁剪成
VO,或用 select 指定字段:
// 生产级:查全量 + 裁剪成 VO,只返回前端需要的字段
public UserVO getUserVO(Long id) {
User user = userMapper.selectById(id);
if (user == null) {
throw new BizException(ErrorCode.USER_NOT_FOUND);
}
// VO 只暴露 username/age/email,不暴露 deleted/version
UserVO vo = new UserVO();
vo.setId(user.getId());
vo.setUsername(user.getUsername());
vo.setAge(user.getAge());
vo.setEmail(user.getEmail());
return vo;
}
字段特别多、只查几列的场景,还可以用 select 指定列:
// 生产级:只查需要的列,减少网络与内存开销
List<User> list = userMapper.selectList(
new LambdaQueryWrapper<User>()
.select(User::getId, User::getUsername) // 只查两列
.ge(User::getAge, 18));
红线二:禁止在循环里单条 insert。
下面的写法每条都开一次数据库往返,1000 条就是 1000 次网络开销:
// 反例:循环单条 insert,慢且消耗连接
for (User u : users) {
userMapper.insert(u);
}
正确做法是用 saveBatch,配合环境准备里加的
rewriteBatchedStatements=true,驱动会合并成一条多值
INSERT:
// 生产级:批量插入。需在 JDBC URL 加 rewriteBatchedStatements=true 才真正合并
public void importUsers(List<User> users) {
// saveBatch 默认每 1000 条一批(可指定 batchSize),内部走 JDBC 批处理
userService.saveBatch(users, 1000);
}
红线三:大表必须分页。 一条 selectList
没有分页条件,遇到百万级表就是一次全表扫描 +
内存打爆。所有面向用户的列表查询,必须走 2.4
节的分页。数据导出类需求也要分批游标读取,不能一次性拉全量。
2.9 完整用户模块(最小 +
生产级对照)
把上面六项能力串成一个完整的用户模块。先看最小可用版,再看生产级版,体会两者差距:
// 最小可用:能跑,但缺校验、缺异常、缺日志、Entity 直接外泄
@RestController
@RequiredArgsConstructor
public class UserController {
private final UserMapper userMapper;
@GetMapping("/users")
public List<User> list() {
return userMapper.selectList(null); // 无分页,select *,Entity 直接返回
}
}
生产级版——分层清晰、有校验、有异常、有日志、有分页:
// 生产级:Controller 只做参数接收与响应组装
@RestController
@RequiredArgsConstructor
@Slf4j
public class UserController {
private final UserService userService;
@GetMapping("/users")
public ApiResult<Page<UserVO>> page(@RequestParam(defaultValue = "1") int pageNum,
@RequestParam(defaultValue = "20") int pageSize,
@RequestParam(required = false) String keyword) {
log.info("分页查询用户, pageNum={}, pageSize={}, keyword={}", pageNum, pageSize, keyword);
return ApiResult.ok(userService.pageUsers(pageNum, pageSize, keyword));
}
@GetMapping("/users/{id}")
public ApiResult<UserVO> get(@PathVariable Long id) {
return ApiResult.ok(userService.getUserVO(id));
}
@PostMapping("/users")
public ApiResult<Long> create(@Valid @RequestBody UserSaveDTO dto) {
return ApiResult.ok(userService.createUser(dto));
}
@PutMapping("/users/{id}")
public ApiResult<Void> update(@PathVariable Long id, @Valid @RequestBody UserSaveDTO dto) {
userService.updateUser(id, dto);
return ApiResult.ok(null);
}
@DeleteMapping("/users/{id}")
public ApiResult<Void> remove(@PathVariable Long id) {
userService.removeUser(id);
return ApiResult.ok(null);
}
}
Service 实现,包含完整业务逻辑:
// 生产级:Service 实现,含校验、异常、日志、VO 裁剪
@Service
@RequiredArgsConstructor
@Slf4j
public class UserServiceImpl implements UserService {
private final UserMapper userMapper;
@Override
public Page<UserVO> pageUsers(int pageNum, int pageSize, String keyword) {
// 分页参数防御:上限 100,防止大 pageSize 拖垮 DB
if (pageNum < 1) pageNum = 1;
if (pageSize < 1 || pageSize > 100) pageSize = 20;
Page<User> page = new Page<>(pageNum, pageSize);
LambdaQueryWrapper<User> wrapper = new LambdaQueryWrapper<User>()
.like(StringUtils.hasText(keyword), User::getUsername, keyword)
.orderByDesc(User::getCreateTime);
Page<User> userPage = userMapper.selectPage(page, wrapper);
// Entity -> VO 转换,脱敏并裁剪字段
Page<UserVO> voPage = new Page<>(pageNum, pageSize, userPage.getTotal());
voPage.setRecords(userPage.getRecords().stream().map(this::toVO).toList());
return voPage;
}
@Override
public UserVO getUserVO(Long id) {
User user = userMapper.selectById(id);
if (user == null) {
throw new BizException(ErrorCode.USER_NOT_FOUND);
}
return toVO(user);
}
@Override
public Long createUser(UserSaveDTO dto) {
// 用户名唯一性校验,避免插库时才撞唯一索引
Long count = userMapper.selectCount(
new LambdaQueryWrapper<User>().eq(User::getUsername, dto.getUsername()));
if (count > 0) {
throw new BizException(40010, "用户名已存在");
}
User user = new User();
user.setUsername(dto.getUsername());
user.setAge(dto.getAge());
user.setEmail(dto.getEmail());
// createTime/updateTime 由自动填充处理器负责,这里不 set
userMapper.insert(user);
log.info("创建用户成功, id={}, username={}", user.getId(), dto.getUsername());
return user.getId();
}
@Override
public void updateUser(Long id, UserSaveDTO dto) {
User user = userMapper.selectById(id);
if (user == null) {
throw new BizException(ErrorCode.USER_NOT_FOUND);
}
user.setAge(dto.getAge());
user.setEmail(dto.getEmail());
// updateById 带上乐观锁 version 条件,并发下安全
userMapper.updateById(user);
}
@Override
public void removeUser(Long id) {
// 逻辑删除:实际执行 UPDATE deleted=1
userMapper.deleteById(id);
}
// 统一的 Entity -> VO 转换,集中管理字段裁剪
private UserVO toVO(User user) {
UserVO vo = new UserVO();
vo.setId(user.getId());
vo.setUsername(user.getUsername());
vo.setAge(user.getAge());
vo.setEmail(user.getEmail());
vo.setCreateTime(user.getCreateTime());
return vo;
}
}
关键点提示:整个模块里,createTime/updateTime
从没手动 set 过(自动填充);deleteById
不会物理删除(逻辑删除);updateById
自带乐观锁;所有列表查询都走了分页;Entity 从没直接出过 Service
层(都裁剪成了 VO)。这五条,正是「能跑」和「生产级」的分水岭。
2.10 乐观锁 vs
悲观锁:什么时候用哪个
2.7
节讲了乐观锁,这里补上它的「对手」悲观锁,并给出选型依据。两者解决同一个问题(并发更新不丢失),但思路相反:
- 乐观锁:更新时带版本号条件,冲突了失败重试。适合冲突少的场景(读多写少,比如改用户资料)。
- 悲观锁:读取时就
SELECT ... FOR UPDATE
把行锁住,别人改不了,直到事务提交。适合冲突多的场景(比如秒杀扣库存,同一行必然被并发争抢)。
// 悲观锁示例:扣库存前先锁行,保证读到的是最新库存
@Mapper
public interface StockMapper extends BaseMapper<Stock> {
// FOR UPDATE 会锁住这一行,其他事务的 FOR UPDATE 会阻塞等待
@Select("SELECT * FROM stock WHERE product_id = #{productId} FOR UPDATE")
Stock selectForUpdate(@Param("productId") Long productId);
}
// 悲观锁用法:读(锁)-> 判断 -> 改,全程持有行锁
@Transactional(rollbackFor = Exception.class)
public void deductWithPessimisticLock(Long productId, Integer qty) {
// 1. 锁住这一行,读到最新库存
Stock stock = stockMapper.selectForUpdate(productId);
if (stock == null || stock.getQuantity() < qty) {
throw new BizException(ErrorCode.STOCK_NOT_ENOUGH);
}
// 2. 更新库存(此时别的并发事务都被阻塞在步骤 1)
stock.setQuantity(stock.getQuantity() - qty);
stockMapper.updateById(stock);
}
| 维度 | 乐观锁 | 悲观锁 |
|---|---|---|
| 实现 | @Version + 版本号条件 |
SELECT ... FOR UPDATE |
| 冲突少时 | 性能好,无锁等待 | 仍有加锁开销 |
| 冲突多时 | 大量失败重试,反而慢 | 直接排队,一次成功 |
| 死锁风险 | 无 | 有(锁顺序不当会死锁) |
| 适用 | 改用户资料、改订单状态 | 扣库存、抢额度、账户余额 |
选型口诀:读多写少用乐观锁,写写争抢用悲观锁。扣库存这类「同一行必然并发争抢」的场景,悲观锁(或第
4 章的单条条件
UPDATE)比乐观锁更合适——乐观锁在高冲突下会大量重试,徒增压力。
本章小结:MP 用 BaseMapper +
LambdaQueryWrapper + 插件体系,让你在不失去 SQL
控制力的前提下大幅提升开发效率;自动填充、逻辑删除、乐观锁三件套统一由框架处理;select *、循环单条
insert、无分页全表查是三条生产红线;并发更新按冲突程度在乐观锁与悲观锁之间取舍。
三、Spring Data JPA 实战
3.1 JPA 是什么,和 MyBatis
差在哪
JPA(Jakarta Persistence API) 是一套 ORM
标准,Hibernate 是它最主流的实现,Spring Data
JPA 则在 Hibernate 之上再包了一层,提供 Repository 抽象。JPA
的核心思想是「面向对象编程,框架自动生成 SQL」:你把
Entity 和 Repository 接口写好,框架根据方法名、注解推导出 SQL
并执行。
这和 MyBatis 是两条完全相反的路线:
- MyBatis:SQL
你写,框架只做映射。可控、灵活、但啰嗦。 - JPA:SQL 框架写,你只写接口。高效、但复杂 SQL
难控制。
理解了这个本质,就不会问「哪个更好」,只会问「这个场景用哪个」。本章用一个完整的
JPA 用户模块,讲清 JPA 的核心用法,再给出选型结论。
3.2 Entity 与
Repository:最小可用示例
JPA 的 Entity 用标准 JPA 注解(注意:不是 MyBatis-Plus
的注解,两者别混用):
// 最小可用:JPA Entity,用 javax.persistence 注解(Jakarta)
@Data
@Entity
@Table(name = "jpa_user") // 与 MP 的 user 表分开,避免两套 ORM 抢同一张表
public class JpaUser {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY) // 主键自增
private Long id;
@Column(nullable = false, length = 64)
private String username;
private Integer age;
private String email;
// JPA 审计:创建/更新时间由 @EnableJpaAuditing + @EntityListeners 自动填充
@CreatedDate
private LocalDateTime createTime;
@LastModifiedDate
private LocalDateTime updateTime;
}
Repository 接口,继承 JpaRepository 后自动获得完整
CRUD:
// 继承 JpaRepository<实体, 主键类型>,即拥有 save/findById/findAll/deleteById 等
public interface JpaUserRepository extends JpaRepository<JpaUser, Long> {
}
最小可用示例:
@RestController
@RequiredArgsConstructor
public class JpaUserController {
private final JpaUserRepository repository;
@GetMapping("/jpa/users/{id}")
public JpaUser get(@PathVariable Long id) {
// findById 返回 Optional,orElseThrow 在不存在时抛异常
return repository.findById(id)
.orElseThrow(() -> new BizException(ErrorCode.USER_NOT_FOUND));
}
@PostMapping("/jpa/users")
public JpaUser create(@RequestBody JpaUser user) {
return repository.save(user); // save 同时承担 insert 和 update
}
}
3.3
方法命名查询:框架从方法名推导 SQL
Spring Data JPA
最惊艳的能力,是从方法名推导查询条件:
public interface JpaUserRepository extends JpaRepository<JpaUser, Long> {
// 方法名解析规则:find + By + 字段 + 条件 + 连接符
// 框架自动生成:SELECT * FROM jpa_user WHERE username = ?
Optional<JpaUser> findByUsername(String username);
// 生成:SELECT * FROM jpa_user WHERE age >= ?
List<JpaUser> findByAgeGreaterThanEqual(Integer age);
// 生成:SELECT * FROM jpa_user WHERE username LIKE ? ORDER BY create_time DESC
List<JpaUser> findByUsernameContainingOrderByCreateTimeDesc(String keyword);
// 生成:分页 + 排序,配合 Pageable 使用
Page<JpaUser> findByAgeBetween(Integer min, Integer max, Pageable pageable);
// 删除类查询也要用方法命名,框架会先查再删
void deleteByUsername(String username);
}
方法命名查询的关键词映射(记住常用的几个即可):
| 关键词 | 示例 | 生成的 SQL 片段 |
|---|---|---|
And / Or |
findByNameAndAge |
WHERE name=? AND age=? |
Between |
findByAgeBetween |
WHERE age BETWEEN ? AND ? |
LessThan / GreaterThanEqual |
findByAgeGreaterThanEqual |
WHERE age >= ? |
Containing |
findByUsernameContaining |
WHERE username LIKE %?% |
OrderByXxxDesc |
...OrderByCreateTimeDesc |
ORDER BY create_time DESC |
In |
findByIdIn |
WHERE id IN (?) |
关键点:方法命名查询只适合「简单、固定的条件」。一旦出现「条件可能为空的动态组合」(比如后台列表的多个筛选条件),方法名会爆炸成几十个方法,这时候就该退回到
@Query 写 JPQL,或者干脆换 MyBatis。
3.4 JPQL 与
@Query:复杂查询的逃生通道
当方法名不够用时,用 @Query 写
JPQL。JPQL 语法和 SQL
很像,但操作对象是实体和字段,不是表和列:
public interface JpaUserRepository extends JpaRepository<JpaUser, Long> {
// JPQL:注意 from 后面是实体名 JpaUser,不是表名;:name 是命名参数
@Query("SELECT u FROM JpaUser u WHERE u.username = :name")
Optional<JpaUser> findByNameWithJpql(@Param("name") String name);
// 原生 SQL 也能写,但要加 nativeQuery=true,此时面对的是真实表和列
@Query(value = "SELECT * FROM jpa_user WHERE age > :age", nativeQuery = true)
List<JpaUser> findOlderThan(@Param("age") Integer age);
// 更新/删除必须加 @Modifying,且必须在事务内执行
@Modifying
@Query("UPDATE JpaUser u SET u.age = u.age + 1 WHERE u.id = :id")
int increaseAge(@Param("id") Long id);
}
关键点:@Modifying
的更新/删除操作必须包在事务里,否则运行时会抛
TransactionRequiredException。这是 JPA
新手最容易踩的坑——save 不用手动开事务(框架自带的
SimpleJpaRepository 已经加了
@Transactional),但自定义的 @Modifying
查询必须自己加。
3.5 JPA
审计:自动填充创建/更新时间
JPA 也有类似 MP
自动填充的能力,叫审计(Auditing)。三步配置:
第一步,启动类加 @EnableJpaAuditing:
@SpringBootApplication
@EnableJpaAuditing // 开启 JPA 审计,让 @CreatedDate/@LastModifiedDate 生效
public class ShopApplication {
public static void main(String[] args) {
SpringApplication.run(ShopApplication.class, args);
}
}
第二步,Entity 字段加审计注解 + 监听器(3.2 节的 Entity 已加
@CreatedDate / @LastModifiedDate),并补充
@EntityListeners:
@Data
@Entity
@Table(name = "jpa_user")
@EntityListeners(AuditingEntityListener.class) // 注册审计监听器,关键一步
public class JpaUser {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@CreatedDate
private LocalDateTime createTime;
@LastModifiedDate
private LocalDateTime updateTime;
}
之后 save 时
createTime/updateTime
会被框架自动填充,业务代码一行不写。这和 MP 的
MetaObjectHandler 是同一个思想的不同实现。
3.6 完整 JPA 用户模块(生产级)
把上面的能力串成一个生产级模块。对比 2.9 节的 MP
版本,体会两种框架在「简单 CRUD」上的开发效率差异:
// 生产级:JPA 用户 Service
@Service
@RequiredArgsConstructor
@Slf4j
public class JpaUserService {
private final JpaUserRepository repository;
// 简单查询:一行方法命名查询搞定,无需写 SQL
public Page<JpaUser> page(int pageNum, int pageSize) {
// 防御性校验
if (pageNum < 1) pageNum = 1;
if (pageSize < 1 || pageSize > 100) pageSize = 20;
// Sort.by(Direction.DESC, "createTime") 按创建时间倒序
return repository.findAll(
PageRequest.of(pageNum - 1, pageSize, Sort.by(Sort.Direction.DESC, "createTime")));
}
public Long create(JpaUser user) {
// 唯一性校验:优先走方法命名查询
repository.findByUsername(user.getUsername()).ifPresent(u -> {
throw new BizException(40010, "用户名已存在");
});
JpaUser saved = repository.save(user);
log.info("创建 JPA 用户成功, id={}", saved.getId());
return saved.getId();
}
public void updateAge(Long id, Integer age) {
JpaUser user = repository.findById(id)
.orElseThrow(() -> new BizException(ErrorCode.USER_NOT_FOUND));
user.setAge(age);
// save 对"有主键"的实体执行 update,对"无主键"的执行 insert
repository.save(user);
}
}
3.7 MyBatis vs JPA 选型(结论)
| 维度 | MyBatis-Plus | Spring Data JPA |
|---|---|---|
| SQL 控制粒度 | 全手写,100% 可控 | 框架生成,复杂 SQL 难优化 |
| 复杂 SQL / 性能调优 | 强(手写、可看执行计划逐字调) | 弱(生成的 SQL 难干预) |
| 开发效率(简单 CRUD) | 高(BaseMapper 免写) | 更高(连 Mapper 接口都极简) |
| 动态条件查询 | 强(LambdaQueryWrapper) | 弱(方法名爆炸,得退 JPQL) |
| 学习曲线 | 平缓(会 SQL 就会用) | 陡(要懂实体状态、懒加载、N+1) |
| 国内生产主流 | 是 | 次之 |
| 适用场景 | 复杂业务、报表、大数据量 | 简单 CRUD、快速原型、内部工具 |
结论(背下来):国内生产以 MyBatis-Plus 为主,JPA
用于简单模块或快速原型,两者可以在同一个项目里混用(不同模块各用各的)。
一个常见的混用姿势是:核心交易链路用 MP(要精确控制 SQL
和事务),后台配置类的小表用 JPA(开发快、省事)。
3.8 N+1 查询问题与
@EntityGraph
JPA 一个隐蔽但致命的性能坑叫 N+1
查询。它发生在「实体有关联关系 + 懒加载」时:查 N
条主记录,又为每条记录单独发一条 SQL 查关联数据,总共 N+1 条
SQL。下面用一个「订单 + 用户」的关联演示:
// 反例:N+1 查询的温床——懒加载关联
@Data
@Entity
@Table(name = "jpa_order")
public class JpaOrder {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
private String orderNo;
// 多对一:默认 FetchType.EAGER 会 join 查询,LAZY 则懒加载
// JPA 的坑在于:@ManyToOne 默认是 EAGER(会 join),@OneToMany 默认是 LAZY(会 N+1)
@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "user_id")
private JpaUser user;
}
// 触发 N+1:查 100 条订单,再为每条订单单独查 1 次用户 = 101 条 SQL
List<JpaOrder> orders = orderRepository.findAll(); // 1 条 SQL 查订单
for (JpaOrder order : orders) {
// 每次访问 order.getUser() 都触发 1 条 SQL 查用户(懒加载)
System.out.println(order.getUser().getUsername()); // 演示用:反例教学,仅展示 N+1 现象,生产请用日志
}
解决方案一:@EntityGraph 一次性 join
出关联数据:
public interface JpaOrderRepository extends JpaRepository<JpaOrder, Long> {
// @EntityGraph 指定加载 user 关联,框架用一条 join SQL 一次性查出,避免 N+1
@EntityGraph(attributePaths = "user")
@Query("SELECT o FROM JpaOrder o")
List<JpaOrder> findAllWithUser();
}
解决方案二:JPQL 里显式 join fetch:
// join fetch 让关联数据随主查询一起加载,也是一条 SQL 搞定
@Query("SELECT o FROM JpaOrder o JOIN FETCH o.user")
List<JpaOrder> findAllWithUserByJoinFetch();
排查方法:开启
spring.jpa.show-sql=true,如果日志里「一条查询」后面跟着「一串同构的关联查询」,就是
N+1。这也是 MyBatis 相对 JPA 的一个优势——MyBatis 的 SQL
全手写,你不写就不会有隐藏的 N+1,而 JPA
的懒加载会在你「以为没查询」的地方偷偷发 SQL。
3.9 Specification:JPA
的动态条件查询
3.3 节说过,方法命名查询遇到「条件可组合」的动态查询会爆炸。JPA 用
Specification 来解决这个问题——它是 JPA 版的「动态拼接
WHERE 条件」:
// 生产级:用 Specification 动态拼接条件,替代方法名爆炸
@Service
@RequiredArgsConstructor
public class JpaUserQueryService {
private final JpaUserRepository repository;
public Page<JpaUser> search(String username, Integer minAge, Integer maxAge, int pageNum, int pageSize) {
// Specification 是函数式接口,动态拼接条件
Specification<JpaUser> spec = (root, query, cb) -> {
List<Predicate> predicates = new ArrayList<>();
// 条件非空才拼接,实现"可选筛选"
if (StringUtils.hasText(username)) {
predicates.add(cb.like(root.get("username"), "%" + username + "%"));
}
if (minAge != null) {
predicates.add(cb.greaterThanOrEqualTo(root.get("age"), minAge));
}
if (maxAge != null) {
predicates.add(cb.lessThanOrEqualTo(root.get("age"), maxAge));
}
return cb.and(predicates.toArray(new Predicate[0]));
};
// 配合分页
Page<JpaUser> page = repository.findAll(spec,
PageRequest.of(pageNum - 1, pageSize, Sort.by(Sort.Direction.DESC, "createTime")));
return page;
}
}
Specification 的缺点是代码啰嗦(要写 lambda 拼
Predicate)。对比之下,MyBatis-Plus 的
LambdaQueryWrapper
也是干同样的事,但代码更简洁、类型更安全(用方法引用而不是字符串
root.get("username"))。这也是国内生产更倾向 MP
处理动态查询的原因之一。
3.10
实体状态与脏检查:save 为什么能既 insert 又 update
3.6 节里 save 方法对「有主键」的实体做
update,对「无主键」的做 insert,这背后的机制是 JPA
的实体状态管理。理解它,很多 JPA
的「诡异行为」就都通了。
JPA 中实体有四种状态:
| 状态 | 说明 | 如何进入 |
|---|---|---|
| Transient(瞬时) | 刚 new 出来,和数据库无关 | new JpaUser() |
| Managed(托管) | 被 EntityManager 管理,变更会被同步到 DB | save() / findById() 查到 |
| Detached(游离) | 曾托管但脱离了管理(如事务结束) | 事务提交后,实体脱离管理 |
| Removed(删除) | 标记为删除,提交时执行 DELETE | delete() |
脏检查(Dirty Checking)
是最关键、也最容易踩坑的机制:托管状态的实体,只要字段被修改,事务提交时框架会自动生成
UPDATE,即使你没有显式调用 save:
// 脏检查演示:托管实体改字段,事务提交时自动 UPDATE
@Transactional(rollbackFor = Exception.class)
public void updateAgeByDirtyChecking(Long id, Integer age) {
// findById 返回的是"托管"实体,被 EntityManager 追踪
JpaUser user = repository.findById(id)
.orElseThrow(() -> new BizException(ErrorCode.USER_NOT_FOUND));
// 直接改字段,不用调 save
user.setAge(age);
// 事务提交时,脏检查发现 age 变了,自动生成 UPDATE 语句
}
这正是 JPA 和 MyBatis 最大的哲学差异:JPA
是「状态驱动」(改了托管实体就自动同步),MyBatis
是「SQL 驱动」(你必须显式调用 update 才会更新)。
脏检查的坑:如果 findById
不在事务里(比如 @Transactional
缺失),查出来的实体在返回后立即变成「游离」状态,之后的字段修改不会触发自动
UPDATE。很多人以为「改了实体属性,没调 save
也存了」,结果数据没更新——根因就是实体已经脱离了事务的托管。
选型视角:状态驱动让 JPA
在「简单场景」里写起来很爽(改个字段自动存),但在「复杂事务、批量更新」里反而带来不确定性(不知道哪个字段会触发
UPDATE);MyBatis 的 SQL
驱动则完全确定,但每个更新都要显式写。这再次印证了 3.7
节的选型结论。
本章小结:JPA 用「方法名推导 SQL」把简单 CRUD
的代码量压到最低,代价是复杂 SQL 和动态条件难控制(N+1
隐患、Specification 啰嗦);JPA 是状态驱动(脏检查自动 UPDATE),MyBatis
是 SQL 驱动(显式调用才更新);MyBatis-Plus 反其道行之,把 SQL
控制权留给开发者。选型看业务复杂度,不看个人偏好。
四、事务管理
4.1
为什么需要事务:一个订单扣库存的翻车现场
假设「下单」要两步:落订单、扣库存。没有事务的代码长这样:
// 反例:无事务,一步成功一步失败,数据就脏了
public void createOrderNoTx(OrderCreateDTO dto) {
orderMapper.insert(order); // 第 1 步:订单写库成功
int rows = stockMapper.deduct(dto.getProductId(), dto.getQty()); // 第 2 步:扣库存
if (rows <= 0) {
throw new BizException(ErrorCode.STOCK_NOT_ENOUGH); // 库存不足,抛异常
}
}
如果第 1 步成功、第 2
步抛异常,结果就是:订单表里多了一条订单,但库存没扣。用户没买到东西,系统却记了一笔账——这就是「部分成功」的数据不一致。
事务要保证的就是 ACID
四大特性,其中最要紧的是前两个:
- 原子性(Atomicity):多个操作要么全成功,要么全失败回滚。上面两步必须绑在一起。
- 一致性(Consistency):事务前后数据都满足业务规则(如库存不能为负)。
- 隔离性(Isolation):并发事务之间互不干扰(第 4.4
节详谈)。 - 持久性(Durability):事务提交后数据不丢(靠 redo
log)。
Spring 的 @Transactional
是声明式事务:你加个注解,框架用 AOP
代理在方法前后帮你开事务、提交/回滚。下面先给最小可用写法,再讲透传播行为、隔离级别和
5 大失效场景。
4.2 @Transactional
最小可用示例
// 最小可用:加 @Transactional,异常自动回滚
@Service
@RequiredArgsConstructor
public class OrderServiceImpl implements OrderService {
private final OrderMapper orderMapper;
private final StockMapper stockMapper;
@Transactional
@Override
public void createOrder(OrderCreateDTO dto) {
orderMapper.insert(Order.from(dto));
if (stockMapper.deduct(dto.getProductId(), dto.getQty()) <= 0) {
// 抛 RuntimeException,事务回滚,上面的 insert 一并撤销
throw new BizException(ErrorCode.STOCK_NOT_ENOUGH);
}
}
}
默认规则(务必记住,后面失效场景全围绕它):
- 默认只回滚
RuntimeException和
Error,受检异常(Checked
Exception)不会回滚。 - 默认传播行为是
REQUIRED,隔离级别跟随数据库默认(MySQL 是
REPEATABLE_READ)。
4.3
传播行为:事务遇到事务怎么办
传播行为回答一个问题:方法 A(有事务)调用方法
B(也标了 @Transactional),B 是「加入 A
的事务」还是「自己开一个新事务」?Spring 定义了 7 种,生产上重点记 3
种:
| 传播行为 | 说明 | 典型场景 |
|---|---|---|
REQUIRED(默认) |
有事务就加入,没有就新建 | 最常用,默认值 |
REQUIRES_NEW |
总是新开事务,挂起外层事务 | 记日志、发通知:失败不影响主事务 |
NESTED |
嵌套事务,用 SavePoint 支持部分回滚 | 部分回滚:主流程失败,但子操作已提交的部分保留 |
其余 4
种(SUPPORTS/NOT_SUPPORTED/MANDATORY/NEVER)了解即可,生产极少用。
REQUIRED 与 REQUIRES_NEW 的对比,用代码验证:
// 场景:下单主流程(REQUIRED)+ 记录操作日志(REQUIRES_NEW)
@Service
@RequiredArgsConstructor
@Slf4j
public class OrderServiceImpl implements OrderService {
private final OrderMapper orderMapper;
private final StockMapper stockMapper;
private final OperationLogService operationLogService;
@Transactional // 外层事务 REQUIRED
@Override
public void createOrder(OrderCreateDTO dto) {
orderMapper.insert(Order.from(dto));
stockMapper.deduct(dto.getProductId(), dto.getQty());
try {
// 日志用 REQUIRES_NEW:即使主事务最终回滚,日志也要保留
operationLogService.log("创建订单", dto.getRequestNo());
} catch (Exception e) {
// 日志失败不能影响下单主流程,所以这里吞掉异常
log.error("记录操作日志失败, requestNo={}", dto.getRequestNo(), e);
}
// 这里故意抛异常,让主事务回滚
if ("FORCE_FAIL".equals(dto.getRequestNo())) {
throw new BizException(ErrorCode.SYSTEM_ERROR);
}
}
}
// 日志服务:REQUIRES_NEW 保证独立事务,主事务回滚不影响它
@Service
@RequiredArgsConstructor
public class OperationLogServiceImpl implements OperationLogService {
private final OperationLogMapper logMapper;
@Transactional(propagation = Propagation.REQUIRES_NEW)
@Override
public void log(String action, String bizNo) {
OperationLog log = new OperationLog();
log.setAction(action);
log.setBizNo(bizNo);
logMapper.insert(log); // 在独立事务里提交,不随主事务回滚
}
}
验证结论:把 requestNo 传成
FORCE_FAIL,你会看到订单和库存都回滚了,但操作日志表里仍然多了一条记录——因为日志跑在
REQUIRES_NEW
的独立事务里,主事务回滚时它已经提交了。这就是「日志失败不影响主流程、主流程失败也不丢日志」的实现原理。
4.4
隔离级别:并发事务的三类读问题
隔离级别回答「两个事务并发读写同一数据,能读到什么」的问题。先理解并发会带来的三类问题:
- 脏读:事务 B 读到了事务 A
未提交的修改。A 回滚后,B
读到的是「不存在的脏数据」。 - 不可重复读:事务 B 内两次读同一行,中间 A
提交了修改,导致 B 两次读到不同的值。 - 幻读:事务 B 内两次执行同一范围查询,中间 A
插入了新行,导致 B
第二次多出几行(像出现了幻觉)。
四种隔离级别与三类问题的对应关系:
| 隔离级别 | 脏读 | 不可重复读 | 幻读 | 默认使用方 |
|---|---|---|---|---|
READ_UNCOMMITTED |
会 | 会 | 会 | 几乎不用 |
READ_COMMITTED |
不会 | 会 | 会 | Oracle / PostgreSQL |
REPEATABLE_READ |
不会 | 不会 | 部分会(InnoDB 用 MVCC + 间隙锁基本解决) | MySQL |
SERIALIZABLE |
不会 | 不会 | 不会 | 极少用,性能极差 |
关键结论(面试高频):MySQL 的默认隔离级别是
REPEATABLE_READ。 InnoDB 通过
MVCC(多版本并发控制)
解决脏读和不可重复读,通过间隙锁(Gap Lock)
基本解决幻读。
设置隔离级别的写法:
// 生产级:显式指定隔离级别(一般用默认即可,这里演示写法)
@Transactional(isolation = Isolation.REPEATABLE_READ)
public void transfer(Long fromId, Long toId, Integer amount) {
// 转账:先减后加,两行数据必须在同一隔离级别下保证一致
accountMapper.deduct(fromId, amount);
accountMapper.add(toId, amount);
}
提示:隔离级别越高,一致性越强,但并发性能越差。生产上绝大多数场景用数据库默认的
REPEATABLE_READ(MySQL)就够,不要轻易改隔离级别,除非你对并发读写有非常明确的诉求。
4.5 5
大失效场景(逐个有验证代码)
这是本篇乃至全系列的重中之重。@Transactional
加了不生效,是线上数据不一致的最高频根因。下面 5
个场景,每个都给出「会失效的代码」和「验证方法」,让你亲眼看到事务没回滚。
失效场景一:同类自调用
原因:@Transactional 靠 Spring AOP
动态代理生效。当你在 OrderController 里注入
OrderService
时,注入的其实是代理对象。外部调用
orderService.createOrder()
走的是代理,代理在方法前后织入事务逻辑。但同类内部用
this.xxx()
调用时,走的是原始对象,绕过了代理,事务自然不生效。
// 失效代码:同类自调用,事务不生效
@Service
@RequiredArgsConstructor
public class OrderServiceImpl implements OrderService {
private final OrderMapper orderMapper;
private final StockMapper stockMapper;
// 这个方法被外部调用,有事务
@Transactional
@Override
public void createOrder(OrderCreateDTO dto) {
// 自调用:走 this,绕过代理,下面这个方法的事务失效
this.doCreate(dto);
}
// 标了 @Transactional,但因为是被 this 调用的,事务不生效
@Transactional
public void doCreate(OrderCreateDTO dto) {
orderMapper.insert(Order.from(dto));
if (stockMapper.deduct(dto.getProductId(), dto.getQty()) <= 0) {
throw new BizException(ErrorCode.STOCK_NOT_ENOUGH);
}
}
}
验证:故意制造库存不足,调 createOrder
抛异常后,去数据库查 order
表——订单还在,说明 doCreate 的
@Transactional 没生效,insert 没有回滚。
修复方案(两种,推荐第一种):
// 修复一:把事务方法拆到另一个 Bean,让调用重新走代理
@Service
@RequiredArgsConstructor
public class OrderServiceImpl implements OrderService {
private final OrderCreateHelper orderCreateHelper; // 拆出去的 Bean
@Transactional
@Override
public void createOrder(OrderCreateDTO dto) {
// 跨 Bean 调用,orderCreateHelper 是代理对象,事务生效
orderCreateHelper.doCreate(dto);
}
}
@Service
public class OrderCreateHelper {
private final OrderMapper orderMapper;
private final StockMapper stockMapper;
// 构造器注入(省略 @RequiredArgsConstructor 展开)
public OrderCreateHelper(OrderMapper orderMapper, StockMapper stockMapper) {
this.orderMapper = orderMapper;
this.stockMapper = stockMapper;
}
@Transactional
public void doCreate(OrderCreateDTO dto) {
orderMapper.insert(Order.from(dto));
if (stockMapper.deduct(dto.getProductId(), dto.getQty()) <= 0) {
throw new BizException(ErrorCode.STOCK_NOT_ENOUGH);
}
}
}
// 修复二:注入自身代理,通过代理调用(需开启 exposeProxy)
// 注意:这是备选方案,代码可读性不如拆 Bean
@Service
@RequiredArgsConstructor
public class OrderServiceImpl implements OrderService {
private final OrderMapper orderMapper;
private final StockMapper stockMapper;
@Transactional
@Override
public void createOrder(OrderCreateDTO dto) {
// 拿到当前类的代理对象,走代理调用,事务生效
((OrderService) AopContext.currentProxy()).doCreate(dto);
}
@Transactional
@Override
public void doCreate(OrderCreateDTO dto) { /* 同上 */ }
}
方案二还需要在启动类加
@EnableAspectJAutoProxy(exposeProxy = true)。生产更推荐方案一「拆
Bean」,语义清晰,不依赖AopContext这种隐式机制。
失效场景二:方法不是 public
原因:Spring AOP 的代理(CGLIB 或 JDK
动态代理)只能拦截 public
方法。protected/private/默认包可见的方法,代理无法覆盖,事务失效。
// 失效代码:protected 方法,事务不生效
@Transactional
protected void doCreate(OrderCreateDTO dto) { // 不是 public,代理拦不到
orderMapper.insert(Order.from(dto));
stockMapper.deduct(dto.getProductId(), dto.getQty());
}
验证:同样制造库存不足,抛异常后查订单表,订单仍在。修复:改成
public。这条规则在 Spring 6.x / Boot 3.x
里依然成立,没有改变。
失效场景三:异常被吞
原因:事务回滚的触发条件是「异常传播到代理层」。如果方法内部用
try-catch
把异常吞掉了,代理根本不知道出了错,自然不触发回滚。
// 失效代码:异常被 try-catch 吞掉,事务不回滚
@Transactional
@Override
public void createOrder(OrderCreateDTO dto) {
try {
orderMapper.insert(Order.from(dto));
if (stockMapper.deduct(dto.getProductId(), dto.getQty()) <= 0) {
throw new BizException(ErrorCode.STOCK_NOT_ENOUGH);
}
} catch (Exception e) {
// 吞掉异常,只打日志,不往上抛——事务感知不到,不回滚
log.error("下单失败", e);
}
}
验证:库存不足时,BizException 被 catch
吞掉,方法正常返回,事务提交,订单写库成功——这就是最危险的情况,因为代码没有报错,问题被彻底隐藏。
修复方案:要么异常继续上抛,要么在 catch
里手动回滚:
// 修复一:catch 之后重新抛,让代理感知
@Transactional
@Override
public void createOrder(OrderCreateDTO dto) {
try {
// ...业务逻辑
throw new BizException(ErrorCode.STOCK_NOT_ENOUGH);
} catch (BizException e) {
log.error("下单失败", e);
throw e; // 重新抛出,触发回滚
}
}
// 修复二:手动标记回滚(适用于"异常要吞掉但事务要回滚"的特殊场景)
@Transactional
@Override
public void createOrder(OrderCreateDTO dto) {
try {
// ...业务逻辑
throw new BizException(ErrorCode.STOCK_NOT_ENOUGH);
} catch (BizException e) {
log.error("下单失败", e);
// 手动标记当前事务回滚,即使异常不继续抛也会回滚
TransactionAspectSupport.currentTransactionStatus().setRollbackOnly();
}
}
经验法则:默认让异常往上抛,别手贱吞异常。只有「日志、通知」这类失败不影响主流程的旁路操作,才允许局部
try-catch(而且这些操作本身应该放REQUIRES_NEW独立事务,如
4.3 节所示)。
失效场景四:异常类型不对(受检异常不回滚)
原因:默认回滚策略是「只回滚
RuntimeException 和
Error」。如果你抛的是受检异常(Exception
的非运行时子类,如 IOException、自定义的
CheckedException),事务不会回滚。
// 失效代码:抛受检异常,默认不回滚
@Transactional
@Override
public void createOrder(OrderCreateDTO dto) throws OrderBizException {
orderMapper.insert(Order.from(dto));
// 抛出自定义受检异常(继承 Exception,不是 RuntimeException)
throw new OrderBizException("库存不足");
}
验证:OrderBizException 继承的是
Exception(受检),方法正常声明 throws
后,异常抛到代理层,但代理发现它不是
RuntimeException,不触发回滚,订单写库成功。
修复方案:显式指定 rollbackFor:
// 修复:rollbackFor 指定受检异常也回滚(生产标配写法)
@Transactional(rollbackFor = Exception.class)
@Override
public void createOrder(OrderCreateDTO dto) throws OrderBizException {
orderMapper.insert(Order.from(dto));
throw new OrderBizException("库存不足"); // 现在会回滚
}
强烈建议:所有
@Transactional都显式写
rollbackFor = Exception.class,把「到底回滚哪些异常」这个容易记错的默认行为,变成一眼看穿的显式声明。这是生产代码的标配。
失效场景五:类没有被 Spring
管理
原因:@Transactional 是 Spring
的功能,只有被 Spring 容器管理的
Bean(@Service/@Component/@Repository
等)才会被代理。如果你 new
出来一个对象、或者忘了加注解,事务就是一句空话。
// 失效代码:自己 new 出来的对象,不在 Spring 容器里,事务失效
@Service
@RequiredArgsConstructor
public class OrderServiceImpl implements OrderService {
private final OrderMapper orderMapper;
private final StockMapper stockMapper;
@Override
public void createOrder(OrderCreateDTO dto) {
// 反例:new 一个"看起来有事务"的对象
OrderTxHelper helper = new OrderTxHelper(orderMapper, stockMapper);
helper.doCreate(dto); // helper 没被 Spring 管理,@Transactional 失效
}
}
// 这个类没加 @Service/@Component,不在容器里
public class OrderTxHelper {
private final OrderMapper orderMapper;
private final StockMapper stockMapper;
public OrderTxHelper(OrderMapper orderMapper, StockMapper stockMapper) {
this.orderMapper = orderMapper;
this.stockMapper = stockMapper;
}
@Transactional
public void doCreate(OrderCreateDTO dto) { /* ... */ }
}
验证:OrderTxHelper 是手动
new 的,Spring 容器不认识它,@Transactional
注解完全无效。抛异常后订单照常写库。修复:给
OrderTxHelper 加 @Service
并注入使用,或者把事务逻辑放回被管理的 Service。
4.6 生产级订单 +
扣库存事务(完整版)
把上面的所有正确姿势串成一个生产级事务方法:
// 生产级:订单 + 扣库存,事务保护 + 显式 rollbackFor + 幂等
@Service
@RequiredArgsConstructor
@Slf4j
public class OrderServiceImpl implements OrderService {
private final OrderMapper orderMapper;
private final StockMapper stockMapper;
private final RedisTemplate<String, Object> redisTemplate;
// 显式 rollbackFor=Exception.class,杜绝"受检异常不回滚"的隐患
@Transactional(rollbackFor = Exception.class)
@Override
public Long createOrder(OrderCreateDTO dto) {
// 1. 幂等校验:同一 requestNo 只处理一次,防重复提交
String idempotentKey = "order:submit:" + dto.getRequestNo();
if (Boolean.TRUE.equals(redisTemplate.hasKey(idempotentKey))) {
throw new BizException(40020, "请勿重复提交订单");
}
// 2. 扣库存(悲观锁:SELECT ... FOR UPDATE 保证并发下不超卖)
int deducted = stockMapper.deductWithLock(dto.getProductId(), dto.getQty());
if (deducted <= 0) {
throw new BizException(ErrorCode.STOCK_NOT_ENOUGH);
}
// 3. 落订单
Order order = Order.from(dto);
orderMapper.insert(order);
// 4. 标记幂等 key(设置过期时间,避免内存泄漏)
redisTemplate.opsForValue().set(idempotentKey, "1", 10, TimeUnit.MINUTES);
log.info("订单创建成功, orderId={}, requestNo={}", order.getId(), dto.getRequestNo());
return order.getId();
}
}
配套的扣库存 SQL(悲观锁防超卖):
@Mapper
public interface StockMapper extends BaseMapper<Stock> {
// 悲观锁:SELECT ... FOR UPDATE 锁住行,防止并发下同时读到同一库存导致超卖
@Update("UPDATE stock SET quantity = quantity - #{qty} " +
"WHERE product_id = #{productId} AND quantity >= #{qty}")
int deductWithLock(@Param("productId") Long productId, @Param("qty") Integer qty);
}
为什么不直接用
quantity >= qty
的条件更新? 这样写本身就是原子的(UPDATE
是行锁),WHERE quantity >= qty
保证不会减成负数,影响行数为 0
就是库存不足。相比「先查再改」的两步操作,单条条件 UPDATE
天然防超卖,是更优的写法。FOR UPDATE
是在「必须先查再决定」的场景(如查出来做复杂判断)才需要。
4.7 用自动化测试验证事务回滚
4.5 节的 5
个失效场景,除了「肉眼看数据库」,更工程化的做法是写集成测试,让「回滚 /
不回滚」变成可重复执行的断言。下面给一个验证「异常回滚」和「异常被吞不回滚」的对照测试:
// 集成测试:验证事务回滚语义(需 @SpringBootTest + 真实 DB 或 H2)
@SpringBootTest
@Slf4j
@RequiredArgsConstructor
class OrderTxTest {
private final OrderService orderService;
private final OrderMapper orderMapper;
@Test
@Transactional // 测试方法自带事务,结束后自动回滚,不污染数据库
void shouldRollbackWhenExceptionThrown() {
OrderCreateDTO dto = new OrderCreateDTO();
dto.setRequestNo("TEST-ROLLBACK-001");
dto.setProductId(999999L); // 不存在的商品,扣库存必然失败
dto.setQuantity(1);
// 期望:扣库存失败抛异常,整个事务回滚
assertThrows(BizException.class, () -> orderService.createOrder(dto));
// 断言:订单表里没有这条订单(证明 insert 被回滚了)
Long count = orderMapper.selectCount(
new LambdaQueryWrapper<Order>().eq(Order::getOrderNo, "TEST-ROLLBACK-001"));
assertEquals(0L, count, "异常后订单应该被回滚,但订单仍然存在");
}
}
关键点:测试类上的
@Transactional
会让每个测试方法跑在独立事务里、结束后自动回滚,这样测试不会污染真实数据库。但要验证「事务是否生效」本身时,要注意测试方法自己的事务会不会干扰被测试代码的事务——简单场景(如上面的「异常回滚」)没问题,验证「传播行为」这类复杂场景时,建议用真实提交
+ 手动清理,避免测试事务把结果回滚掉导致误判。
4.8 事务生产实践清单
把本章所有结论收敛成一份可检查的清单,写代码时逐条对照:
- 所有
@Transactional都显式写
rollbackFor = Exception.class,杜绝「受检异常不回滚」的默认坑。 - 方法必须是
public,且类必须被
Spring
管理(@Service/@Component),否则代理不生效。 - 异常向上抛,不要吞。只有「日志、通知」等旁路操作允许局部
try-catch,且应放REQUIRES_NEW独立事务。 - 避免同类自调用。需要内部调用事务方法时,拆成独立
Bean。 - 事务要短。把「发短信、调外部 HTTP、发
MQ」这类慢操作移出事务,缩短锁和连接的持有时间,降低死锁和连接耗尽风险。 - 单条条件 UPDATE 防超卖。扣库存用
UPDATE ... WHERE quantity >= qty,而不是「先查再改」,天然原子。 - 幂等。下单/支付等入口用唯一请求号(requestNo)做幂等校验,防止重复提交造成重复扣款。
- 隔离级别用默认。MySQL 的
REPEATABLE_READ能覆盖绝大多数场景,不要轻易改动。
本章小结:@Transactional 靠 AOP
代理工作,所以「绕开代理」「吞掉异常」「抛错异常类型」「类不在容器」都会让它失效;5
大失效场景要逐个写验证代码跑出「没回滚」的证据才算真正掌握;生产标配是
rollbackFor = Exception.class + 异常上抛 + 拆 Bean
避免自调用,再用集成测试把回滚语义固化成可重复执行的断言。
五、缓存(Spring Cache + Redis)
5.1
为什么要加缓存,以及缓存在哪一层
数据库是系统里最慢、也最贵的资源。一个典型的读多写少场景——比如商品详情、用户信息——如果每次都直接打数据库,高并发下数据库会先被打挂。缓存的思想是:把热点数据放到
Redis(内存)里,读请求优先命中缓存,没命中再回源数据库。内存读写的延迟是微秒级,比数据库快
3 个数量级。
缓存加在「业务层和数据库之间」这一层,核心模式叫 Cache
Aside(旁路缓存):
读:先查缓存 -> 命中直接返回;未命中查 DB -> 把结果写进缓存 -> 返回
写:先更新 DB -> 再删除缓存(不是更新缓存!)
下面 5.2 节先用最朴素的代码实现这个模式,5.3 节换成 Spring Cache
注解,5.4~5.7 节讲三大经典问题和缓存一致性。
5.2 最小可用示例:手写 Redis
缓存
不引入任何框架抽象,先用 RedisTemplate 手写一遍 Cache
Aside,看清每一步:
// 最小可用:手写 Cache Aside,读时回源、写时删缓存
@Service
@RequiredArgsConstructor
@Slf4j
public class UserServiceImpl implements UserService {
private final UserMapper userMapper;
private final RedisTemplate<String, Object> redisTemplate;
@Override
public User getUser(Long id) {
String key = "user:" + id;
// 1. 先查缓存
User user = (User) redisTemplate.opsForValue().get(key);
if (user != null) {
return user; // 命中,直接返回,不打 DB
}
// 2. 未命中,查 DB
user = userMapper.selectById(id);
// 3. 写回缓存(只有查到数据才写,查不到不写,否则会缓存穿透,见 5.5)
if (user != null) {
redisTemplate.opsForValue().set(key, user, 30, TimeUnit.MINUTES);
}
return user;
}
@Override
public void updateUser(Long id, UserSaveDTO dto) {
// 先更新 DB,再删缓存(不是更新缓存,见 5.7)
User user = userMapper.selectById(id);
user.setAge(dto.getAge());
userMapper.updateById(user);
redisTemplate.delete("user:" + id);
}
}
这段代码有两个刻意埋下的问题,正好引出后面的三大经典问题:
- 查不到数据时不写缓存——缓存穿透的漏洞(5.5
节)。 redisTemplate.delete
失败了怎么办——缓存一致性的隐患(5.7 节)。
5.3 生产级:Spring Cache
注解抽象
手写缓存有个致命缺点:每个方法都要重复「查缓存→回源→写缓存」的样板代码,业务逻辑被缓存代码淹没。Spring
Cache 用注解把这套样板收拢起来。
第一步,配置缓存管理器,指定用 Redis 作为缓存存储:
// 生产级:配置 Redis 缓存管理器 + 序列化 + 全局 TTL
@Configuration
@EnableCaching // 开启 @Cacheable 等注解
public class CacheConfig {
@Bean
public RedisCacheManager cacheManager(RedisConnectionFactory factory) {
// 用 JSON 序列化,避免默认 JDK 序列化带来的可读性差、跨语言难、必须实现 Serializable 的问题
RedisSerializer<Object> serializer = new GenericJackson2JsonRedisSerializer();
RedisCacheConfiguration config = RedisCacheConfiguration.defaultCacheConfig()
// 默认过期时间 30 分钟,防止 key 永久占用内存
.entryTtl(Duration.ofMinutes(30))
// key 加前缀,区分不同业务
.prefixCacheNameWith("shop:")
.serializeValuesWith(RedisSerializationContext.SerializationPair.fromSerializer(serializer))
// 缓存 null 值需显式开启,且要给很短的 TTL(防穿透,见 5.5)
.computePrefixWith(cacheName -> cacheName + ":");
return RedisCacheManager.builder(factory)
.cacheDefaults(config)
.build();
}
}
第二步,在 Service 方法上使用注解:
// 生产级:@Cacheable 读缓存,@CacheEvict 删缓存
@Service
@RequiredArgsConstructor
@Slf4j
public class UserServiceImpl implements UserService {
private final UserMapper userMapper;
// 查:缓存 key 为 user::123,命中直接返回,未命中执行方法体并缓存结果
@Cacheable(value = "user", key = "#id")
@Override
public UserVO getUserVO(Long id) {
log.info("缓存未命中,回源数据库查询用户 id={}", id);
User user = userMapper.selectById(id);
if (user == null) {
throw new BizException(ErrorCode.USER_NOT_FOUND);
}
return toVO(user);
}
// 更新:执行完方法体后,删除缓存 key 为 user::123 的条目
@CacheEvict(value = "user", key = "#id")
@Override
public void updateUser(Long id, UserSaveDTO dto) {
User user = userMapper.selectById(id);
user.setAge(dto.getAge());
userMapper.updateById(user);
}
// 批量删除:allEntries=true 清空整个 user 缓存区
@CacheEvict(value = "user", allEntries = true)
@Override
public void clearAllUserCache() {
// 一般用于数据全量刷新的场景
}
}
关键点提示:@Cacheable 的
key 用的是 SpEL 表达式(#id
表示方法的 id 参数)。缓存 key
的规范是「业务前缀:业务主键」,比如
user::123、order::456,这样不同业务互不干扰,也方便用
redis-cli 手动排查。
5.4 三大经典问题总览
缓存看似简单,实则三大经典问题环环相扣。先看总表,再逐个拆解:
| 问题 | 成因 | 后果 | 方案 |
|---|---|---|---|
| 缓存穿透 | 查一个根本不存在的数据(如 id=-1),缓存和 DB 都没有 |
每次请求都打穿缓存直达 DB,恶意攻击可拖垮数据库 | 空值缓存(短 TTL)/ 布隆过滤器 / 参数校验 |
| 缓存击穿 | 一个热点 key 过期瞬间,海量请求同时回源 DB | DB 瞬时压力暴涨,可能宕机 | 互斥锁 / 逻辑过期 / 热点 key 不过期 |
| 缓存雪崩 | 大量 key 同时过期,或 Redis 本身宕机 | 所有请求同时打 DB,直接打挂 | TTL 加随机值 / 多级缓存 / 限流降级 / Redis 高可用 |
记一个区分口诀:穿透是「查不存在」,击穿是「一个热点过期」,雪崩是「一堆
key
同时过期」。三者都指向同一个后果——请求打到数据库,只是触发方式不同。
5.5 缓存穿透:成因 → 方案 →
代码
成因:攻击者或异常流量反复请求一个不存在的 id(比如
user/-1)。缓存里没有这个
key,数据库里也没有这条数据,于是每次请求都穿透缓存直达数据库。如果这是恶意攻击,数据库会在毫无价值查询上被打挂。
方案一:空值缓存——查不到数据时,也在缓存里写一个「空值」并设置很短的
TTL。下次同样的请求命中空值,直接返回,不再打 DB。
// 生产级:空值缓存防穿透(核心:查不到也缓存,但 TTL 要短)
@Cacheable(value = "user", key = "#id", unless = "#result == null")
@Override
public UserVO getUserVO(Long id) {
// 参数兜底校验:id 必须大于 0,从源头挡住大部分穿透
if (id == null || id <= 0) {
throw new BizException(ErrorCode.PARAM_ERROR);
}
User user = userMapper.selectById(id);
if (user == null) {
// 查不到,手动缓存一个"空值标记",TTL 设短(如 60 秒),避免长期占用且能快速自愈
redisTemplate.opsForValue().set("user:" + id + ":null", "NULL", 60, TimeUnit.SECONDS);
throw new BizException(ErrorCode.USER_NOT_FOUND);
}
return toVO(user);
}
注意
unless = "#result == null"
的作用:方法抛异常时结果不是 null
而是异常,这里用「缓存空值标记」手动兜底更直观。空值缓存的 TTL
要短(几十秒到几分钟),这样数据如果真的被创建了,缓存能尽快「自愈」。
方案二:布隆过滤器——在缓存前加一道「可能存在」的快速判断。布隆过滤器说「不存在」就一定不存在,直接拒绝;说「存在」才放行去查缓存/DB(可能有极小误判,需回源确认)。适合
key 空间巨大、空值缓存占用过高的场景:
// 概念示意:布隆过滤器(生产用 Redisson 的 RBloomFilter)
@Service
@RequiredArgsConstructor
public class UserQueryWithBloom {
private final RedissonClient redissonClient;
private final UserMapper userMapper;
public UserVO getUserWithBloom(Long id) {
RBloomFilter<Long> filter = redissonClient.getBloomFilter("user:bloom");
// 布隆过滤器说"不存在",直接返回,绝不打 DB
if (!filter.contains(id)) {
throw new BizException(ErrorCode.USER_NOT_FOUND);
}
// 说"存在"(可能有误判),走正常查缓存 + 查 DB
return getUserVO(id);
}
}
方案三:入口参数校验——最简单也最有效的一层。id
必须是正整数、分页参数必须在合理范围内,把「明显不可能存在的请求」在
Controller 层就挡掉。
5.6 缓存击穿:成因 → 方案 →
代码
成因:某热点数据(比如爆款商品)的缓存 key
到期失效,同一瞬间海量请求都发现缓存没了,同时回源数据库。假设这个商品每秒
10 万次访问,缓存失效的一瞬间这 10
万请求全压到数据库——这就是「击穿」。
方案一:互斥锁——回源 DB
前先抢一把分布式锁,只有一个线程能回源,其余线程等待后重试读缓存。这是最经典的方案:
// 生产级:互斥锁防击穿(核心:只让一个线程回源 DB,其余重试读缓存)
@Service
@RequiredArgsConstructor
@Slf4j
public class ProductServiceImpl implements ProductService {
private final ProductMapper productMapper;
private final StringRedisTemplate redisTemplate;
@Override
public ProductVO getProduct(Long id) {
String cacheKey = "product:" + id;
String lockKey = "product:lock:" + id;
// 1. 先查缓存,命中直接返回
String json = redisTemplate.opsForValue().get(cacheKey);
if (json != null) {
return JsonUtil.fromJson(json, ProductVO.class);
}
// 2. 未命中,尝试抢互斥锁(setIfAbsent = SET NX,原子操作,只有一个线程成功)
Boolean locked = redisTemplate.opsForValue()
.setIfAbsent(lockKey, "1", 3, TimeUnit.SECONDS);
if (Boolean.TRUE.equals(locked)) {
try {
// 3. 抢到锁:双重检查,可能前面的线程已经回源写好了缓存
json = redisTemplate.opsForValue().get(cacheKey);
if (json != null) {
return JsonUtil.fromJson(json, ProductVO.class);
}
// 4. 回源 DB
Product product = productMapper.selectById(id);
if (product == null) {
throw new BizException(ErrorCode.PRODUCT_NOT_FOUND);
}
// 5. 写缓存,TTL 加随机值(同时缓解雪崩)
long ttl = 30 + ThreadLocalRandom.current().nextInt(10);
redisTemplate.opsForValue().set(cacheKey, JsonUtil.toJson(product), ttl, TimeUnit.MINUTES);
return toVO(product);
} finally {
// 6. 释放锁(用 Lua 保证"判断是自己的锁再删",简化版直接 delete)
redisTemplate.delete(lockKey);
}
} else {
// 7. 没抢到锁:短暂休眠后重试(自旋),读到缓存后返回
try {
Thread.sleep(50);
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
}
return getProduct(id); // 递归重试
}
}
}
方案二:逻辑过期——缓存 value
里存一个「逻辑过期时间」,物理上不过期(Redis key 不设 TTL
或设很长)。读取时发现逻辑时间已过期,就先返回旧值,同时异步起一个线程去回源刷新。适合「能容忍短暂读到旧数据」的场景,比互斥锁吞吐更高。
方案三:热点 key 不过期——对真正顶流的
key(如秒杀活动页)干脆不设
TTL,用后台任务或消息队列在数据变更时主动刷新。
5.7 缓存雪崩 +
缓存一致性(写时删缓存)
雪崩成因:大量 key
在同一时间过期(比如都设了 30 分钟
TTL,且是同一波流量写进去的),过期瞬间所有请求同时回源
DB,把数据库打挂。更严重的是 Redis 本身宕机,所有缓存直接失效。
雪崩方案:
- TTL 加随机值——让 key
的过期时间错开,避免同一时刻集体失效(5.6 节代码里已用
30 + random(10)演示)。 - 多级缓存——本地缓存(Caffeine)+ Redis +
DB,层层兜底。 - 限流降级——缓存/DB
都扛不住时,服务降级返回兜底数据或错误,保住核心链路。 - Redis 高可用——主从 +
哨兵/集群,避免单点宕机导致全量雪崩。
// 生产级:写缓存时 TTL 加随机值,错开过期时间
public void cacheWithJitter(String key, Object value) {
// 30 分钟 + [0, 10) 分钟随机,让一批 key 的过期时间分散开
long ttl = 30 + ThreadLocalRandom.current().nextInt(10);
redisTemplate.opsForValue().set(key, value, ttl, TimeUnit.MINUTES);
}
缓存一致性:为什么「写时删缓存」而不是「写时更新缓存」?
这是缓存领域最容易被误解的一条原则。很多人写更新逻辑时顺手「更新」缓存,觉得更省事。看下面的并发时序就明白了:
并发场景:两个写请求 A、B 同时更新同一条数据
- 方案(错):写时更新缓存
1. A 更新 DB = 10
2. B 更新 DB = 20
3. B 更新缓存 = 20
4. A 更新缓存 = 10 <- A 后完成,缓存被写成旧值 10,而 DB 是 20,永久不一致
- 方案(对):写时删缓存
1. A 更新 DB = 10,删缓存
2. B 更新 DB = 20,删缓存
3. 下一次读请求发现缓存没了,回源 DB 读到 20,写回缓存 20
「更新缓存」在并发下会把旧值写回去,导致缓存和 DB
永久不一致;而「删缓存」让下一次读请求回源拿到最新值,天然避免了这个问题。这就是
Cache Aside 模式的核心:写时删缓存,不更新缓存。
删缓存失败怎么办? 如果「更新
DB」成功、「删缓存」失败,缓存里还是旧值。生产上用两种方式兜底:
- 延迟双删:更新 DB
后先删一次缓存,延迟几百毫秒再删一次,覆盖「并发读写」的窗口期。 - 订阅 binlog 兜底(推荐):监听 MySQL 的
binlog,数据一变更就异步删缓存,即使业务代码删缓存失败也能兜底。
// 生产级:更新方法——先更新 DB,再删缓存(Cache Aside 写路径)
@Override
public void updateUser(Long id, UserSaveDTO dto) {
// 1. 更新 DB
User user = userMapper.selectById(id);
if (user == null) {
throw new BizException(ErrorCode.USER_NOT_FOUND);
}
user.setAge(dto.getAge());
user.setEmail(dto.getEmail());
userMapper.updateById(user);
// 2. 删缓存(不是更新缓存!),让下次读回源拿最新值
redisTemplate.delete("user:" + id);
}
5.8
缓存一致性兜底:延迟双删与 binlog
5.7 节讲了「写时删缓存」的原则,但有个遗留问题:「更新 DB
成功、删缓存失败」时,缓存里还是旧值。生产上有两种兜底手段。
方案一:延迟双删——更新 DB
后先删一次,延迟几百毫秒再删一次,覆盖「并发读在两次删除之间回源写了旧值」的窗口期:
// 生产级:延迟双删,兜底"删缓存失败"与"并发回源写旧值"
@Service
@RequiredArgsConstructor
@Slf4j
public class UserCacheConsistencyService {
private final UserMapper userMapper;
private final StringRedisTemplate redisTemplate;
private final ThreadPoolTaskExecutor executor; // 专门做异步延迟删除的线程池
public void updateUser(Long id, UserSaveDTO dto) {
// 1. 更新 DB
User user = userMapper.selectById(id);
user.setAge(dto.getAge());
userMapper.updateById(user);
// 2. 第一次删缓存
redisTemplate.delete("user:" + id);
// 3. 延迟 500ms 后第二次删缓存(异步执行,不阻塞主流程)
executor.execute(() -> {
try {
Thread.sleep(500);
redisTemplate.delete("user:" + id);
log.info("延迟双删完成, id={}", id);
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
}
});
}
}
方案二:订阅 binlog 兜底(更可靠,推荐)——监听 MySQL
的 binlog,数据一变更就异步删缓存。即使业务代码删缓存失败,binlog
监听器也会兜底删除。实现上常用 Canal(阿里开源)或
Debezium:
// 概念示意:Canal 监听 binlog 后,收到数据变更事件时删除缓存
// 实际接入 Canal 需要独立部署 Canal Server + 在应用里实现监听回调
public class BinlogCacheEvictHandler {
// 伪代码:Canal 回调,当 user 表发生变更时触发
public void onUserChanged(Long userId) {
// 数据已变更,无论业务代码有没有删成功,这里兜底删一次缓存
redisTemplate.delete("user:" + userId);
}
}
选型:延迟双删实现简单但有「延迟窗口期」和「删缓存仍可能失败」的局限;binlog
兜底最可靠但引入 Canal/Debezium
组件。中小项目用延迟双删,大流量核心链路用 binlog
兜底。
5.9 Spring Cache
注解进阶:@CachePut 与 @Caching
除了 @Cacheable(读)和
@CacheEvict(删),还有两个注解要认识:
@CachePut:不查缓存,总是执行方法并把结果写回缓存。用于「更新数据同时刷新缓存」的场景。但要小心——它违背「写时删缓存」原则,只在「结果确定且并发写少」的场景用。@Caching:组合多个缓存注解,一次操作多个缓存。
// @CachePut:更新后把新值写回缓存(注意与"删缓存"策略的取舍)
@CachePut(value = "user", key = "#id")
public UserVO updateAndRefreshCache(Long id, UserSaveDTO dto) {
// ...更新 DB
return getUserVO(id); // 返回的新值会被写回缓存
}
// @Caching:同时清理多个缓存
@Caching(evict = {
@CacheEvict(value = "user", key = "#id"),
@CacheEvict(value = "userList", allEntries = true) // 列表缓存全清
})
public void updateUserWithList(Long id, UserSaveDTO dto) {
// ...更新 DB,同时清掉单条缓存和列表缓存
}
取舍提醒:
@CachePut
看起来比「删缓存」更「高效」(省一次回源),但它会把 5.7
节的并发时序问题重新带回来——并发更新时可能把旧值写回缓存。默认策略仍是「写时删缓存」,@CachePut
只在确定无并发写的场景使用。
本章小结:缓存加在业务层和 DB 之间,核心是 Cache
Aside
模式;穿透/击穿/雪崩分别对应「查不存在」「热点过期」「大量同时过期」,方案分别是空值缓存/布隆过滤器、互斥锁/逻辑过期、TTL
随机值/多级缓存/高可用;写路径一律「删缓存」不「更新缓存」,删除失败要延迟双删或
binlog 兜底;@CachePut
是「更新缓存」的特例,仅在无并发写时使用。
六、多数据源与读写分离
6.1 为什么需要读写分离
当单库扛不住读流量时,最常见的架构演进是读写分离:主库(master)承担写操作,从库(slave)承担读操作,主从之间通过
binlog 异步复制数据。
这样做的收益是:读请求(通常占
80%~90%)被从库分担,主库只专注写入,整体吞吐大幅提升。代价是主从复制有延迟,刚写入的数据在从库上可能读不到(通常几十毫秒到几百毫秒)。
实现读写分离有两种方式:
- 应用层路由(本篇重点):用
dynamic-datasource在代码里用@DS
注解手动指定走主库还是从库。灵活、可控,但路由逻辑要自己写。 - 中间件层路由:用 ShardingSphere
这类中间件,对应用透明地自动路由。省心,但引入一个中间件组件。
6.2
最小可用示例:dynamic-datasource 配置
dynamic-datasource 是国人开源的多数据源框架(和
MyBatis-Plus 同作者),支持 @DS
注解在方法级别切换数据源。先配两个数据源:
# 生产级:主从两个数据源(生产密码务必走环境变量,这里演示结构)
spring:
datasource:
dynamic:
primary: master # 默认数据源是主库,写操作默认走这里
strict: false # false 表示未匹配到 @DS 时用 primary,true 则直接报错
datasource:
master:
url: jdbc:mysql://${DB_MASTER_HOST:localhost}:3306/shop?useSSL=false&serverTimezone=Asia/Shanghai
username: ${DB_MASTER_USER:root}
password: ${DB_MASTER_PASSWORD:root123456}
driver-class-name: com.mysql.cj.jdbc.Driver
slave:
url: jdbc:mysql://${DB_SLAVE_HOST:localhost}:3307/shop?useSSL=false&serverTimezone=Asia/Shanghai
username: ${DB_SLAVE_USER:root}
password: ${DB_SLAVE_PASSWORD:root123456}
driver-class-name: com.mysql.cj.jdbc.Driver
最小可用示例——用 @DS 指定数据源:
// 最小可用:@DS 切换数据源,读走从库
@Service
@RequiredArgsConstructor
public class UserServiceImpl implements UserService {
private final UserMapper userMapper;
@DS("slave") // 读操作走从库,分担主库压力
@Override
public UserVO getUserVO(Long id) {
User user = userMapper.selectById(id);
return toVO(user);
}
@DS("master") // 写操作走主库
@Override
public Long createUser(UserSaveDTO dto) {
User user = new User();
user.setUsername(dto.getUsername());
userMapper.insert(user);
return user.getId();
}
}
6.3 生产级:读写分离完整实践
生产上不会每个方法都手写 @DS,而是用「命名约定 +
AOP」统一路由。约定:读方法名以
get/list/find/query/page
开头走从库,其余走主库:
// 生产级:AOP 统一路由,按方法名前缀自动切换数据源,避免每个方法手写 @DS
@Aspect
@Component
@RequiredArgsConstructor
@Slf4j
public class ReadWriteSplittingAspect {
private final DynamicRoutingDataSource dynamicRoutingDataSource;
// 切所有 Service 方法(按你的包结构调整)
@Before("execution(* com.example.shop.service..*.*(..))")
public void route(JoinPoint joinPoint) {
String methodName = joinPoint.getSignature().getName();
// 读方法走从库,其余走主库
if (methodName.startsWith("get") || methodName.startsWith("list")
|| methodName.startsWith("find") || methodName.startsWith("query")
|| methodName.startsWith("page")) {
DynamicDataSourceContextHolder.push("slave");
} else {
DynamicDataSourceContextHolder.push("master");
}
}
@After("execution(* com.example.shop.service..*.*(..))")
public void clear(JoinPoint joinPoint) {
// 方法结束后清理,防止 ThreadLocal 数据源串到下一次调用
DynamicDataSourceContextHolder.poll();
}
}
关键点提示(都是生产事故高发区):
- 事务与数据源绑定的坑:
@Transactional
会在方法开始时从连接池取连接,此时数据源已经确定。如果事务方法内部再切数据源,是切不动的——事务一旦开启,连接就固定了。所以「事务方法」和「切换数据源」要谨慎组合。 - 主从延迟:刚写完就立刻读,从库可能还没同步到,会读到旧数据。解决:写后立即读的场景强制走主库(
@DS("master")),或接受短延迟。 - ThreadLocal
清理:DynamicDataSourceContextHolder底层是
ThreadLocal,务必在方法结束后poll()
清理,否则线程复用时数据源会串。
6.4 ShardingSphere 概念简介
当读写分离、分库分表的需求复杂到「应用层路由」难以维护时,就轮到
Apache
ShardingSphere。它是一个数据库中间件,核心能力:
- 读写分离:配置主从后,对应用透明地自动路由读写。
- 分库分表:数据按规则(如按用户 id
取模)拆分到多个库/表,解决单表数据量过大。 - 数据脱敏 / 影子库等治理能力。
# ShardingSphere 读写分离配置(概念示例,实际接入用官方 starter)
spring:
shardingsphere:
rules:
readwrite-splitting:
data-sources:
shop-rw:
write-data-source-name: master
read-data-source-names: [slave]
分库分表是「最后的手段」,复杂度极高(跨库 join、分布式事务、全局 id
都要重新设计)。先读写分离,再优化 SQL
和索引,实在不行才上分库分表——不要一上来就拆库。
6.5
事务与数据源绑定的坑(详解)
这是读写分离里最容易踩、也最难排查的坑:@Transactional
方法开启事务时,就从当前数据源拿了连接并固定下来,方法内部再切数据源是无效的。
// 反例:事务方法内切换数据源,切不动
@Service
@RequiredArgsConstructor
public class OrderServiceImpl implements OrderService {
private final OrderMapper orderMapper;
private final UserMapper userMapper;
// 外层事务:进入方法时已从 primary(master) 拿了连接
@Transactional(rollbackFor = Exception.class)
@Override
public OrderVO getOrderDetail(Long orderId) {
// 这里想读从库,但事务已经绑定了主库连接,@DS("slave") 不生效
User user = userMapper.selectById(orderId); // 实际还是走主库
return buildVO(orderMapper.selectById(orderId), user);
}
}
为什么切不动? Spring 事务管理器在
@Transactional 方法开始时调用
dataSource.getConnection(),一旦拿到连接,事务期间就绑定这条连接。dynamic-datasource
切换的是「后续用哪个数据源」,但已经拿到的主库连接不会换。所以「事务」和「读写分离」有天然的冲突:事务方法内不能切换数据源。
解决方案:
- 读操作不要开事务。纯查询方法不加
@Transactional,让它自由走从库。Spring 里只有读也能加
@Transactional(readOnly = true),但readOnly
只是提示,不解决连接绑定问题——所以查询方法干脆不加事务。 - 写后立即读走主库。刚写入的数据在从库可能还没同步(主从延迟),这类「写后读」场景强制走主库。
// 修复:读方法不加事务,走从库;写方法走主库
@Service
@RequiredArgsConstructor
public class OrderServiceImpl implements OrderService {
private final OrderMapper orderMapper;
// 纯读:不加 @Transactional,AOP 路由到 slave
@DS("slave")
public OrderVO getOrderDetail(Long orderId) {
return buildVO(orderMapper.selectById(orderId));
}
// 写:走 master,事务保护
@DS("master")
@Transactional(rollbackFor = Exception.class)
public Long createOrder(OrderCreateDTO dto) {
// ...
return order.getId();
}
}
6.6
主从延迟:写后立刻读不到怎么办
主从复制是异步的:主库写完,要经过 binlog 传输 +
从库回放,数据才在从库可见。这个延迟通常几十到几百毫秒,高峰可能到秒级。于是出现一个经典问题:用户刚提交订单,马上刷新列表,发现订单「不见了」。
三种处理方案(按推荐程度排序):
- 写后读走主库:对「写操作完成后立即读」的接口(如「提交后返回详情」),读也走主库,绕开延迟。实现上可以在「同一个用户请求内」用
ThreadLocal 标记「本次请求已写过,后续读走主库」。 - 容忍短暂不一致:对实时性要求不高的场景(如列表页),接受几百毫秒的延迟,用户下次刷新就能看到。
- 强一致读:实在要强一致,读也走主库,等于放弃读写分离的读扩展(只适合「写后必须读到」的少量接口)。
// 方案一实现:ThreadLocal 标记"本次请求写过,后续读走主库"
// 在请求入口(拦截器/Filter)初始化,写操作后置为 true,路由切面据此决定数据源
public class ReadWriteHint {
private static final ThreadLocal<Boolean> WROTE_IN_REQUEST = new ThreadLocal<>();
public static void markWrote() { WROTE_IN_REQUEST.set(true); }
public static boolean hasWrote() { return Boolean.TRUE.equals(WROTE_IN_REQUEST.get()); }
public static void clear() { WROTE_IN_REQUEST.remove(); }
}
核心认知:读写分离牺牲了一致性换吞吐。它不是「免费的性能提升」,而是「用短延迟的不一致换读扩展能力」。设计接口时要清楚每个读操作能不能容忍这个延迟,不能容忍的走主库。
6.7
从读写分离到分库分表:演进路径与边界
读写分离解决「读多写少」,但当单表数据量大到影响读写性能(通常单表几千万行、几十
GB),读写分离也无能为力,此时才轮到分库分表。理解这条演进路径,能避免「过度设计」:
单库单表 -> 读写分离(主从) -> 分库分表(ShardingSphere) -> 分布式事务(Seata)
↓ 读扛不住 ↓ 单表太大 ↓ 跨库一致性
每一步都有明确触发条件,不要跳步:
- 读写分离:读 QPS 打满主库,但单表数据量还 OK。
- 分库分表:单表数据量到千万级,即使有从库,单表的写入和索引维护也变慢。
- 分布式事务:分库后,一次操作跨多个库,单库事务(第
4 章)管不了,需要 Seata 或消息最终一致性(阶段 6 详讲)。
ShardingSphere 分库分表配置(概念示例):
# ShardingSphere 分表:orders 按 user_id 取模拆成 4 张表
spring:
shardingsphere:
rules:
sharding:
tables:
orders:
actual-data-nodes: ds0.orders_$->{0..3} # orders_0 ~ orders_3
table-strategy:
standard:
sharding-column: user_id
sharding-algorithm-name: orders-inline
sharding-algorithms:
orders-inline:
type: INLINE
props:
algorithm-expression: orders_$->{user_id % 4}
分库分表的代价(务必想清楚再上):
- 跨库 join 失效:数据拆开后,原来一条
JOIN就能查的数据,现在要拆成多次查询在应用层拼装。 - 分布式事务:跨库操作的一致性要用 Seata
等方案,复杂度陡增。 - 全局唯一
id:单表自增主键不能再用了,要换雪花算法(IdType.ASSIGN_ID)或分布式
id 服务。 - 扩容迁移复杂:数据量再涨要二次拆分,要做数据迁移。
结论:读写分离和分库分表都是「用复杂度换性能」,能用索引、缓存、SQL
优化解决的就绝不上分库分表。真到了必须拆的那一步,优先用
ShardingSphere 这类成熟中间件,别自己造轮子。
本章小结:读写分离用主库扛写、从库扛读;应用层用
dynamic-datasource + @DS(或 AOP
统一路由),中间件层用 ShardingSphere
透明路由;注意事务与数据源绑定的坑(事务内切不动数据源,读方法别加事务)、主从延迟(写后读走主库)、ThreadLocal
清理三件事;分库分表是最后手段,演进路径是「读写分离 → 分库分表 →
分布式事务」,每一步都有明确触发条件。
七、数据库迁移与 SQL 优化
7.1
为什么需要版本化迁移(Flyway)
表结构会随业务不断变化:加字段、改类型、加索引、建新表。如果没有管理手段,会出现这些乱象:
- 测试环境改了个字段,生产环境忘了改,上线直接报「Unknown
column」。 - 谁也不知道现在生产库的「正确结构」长什么样,全靠 DBA 口口相传。
- 回滚版本时,表结构不知道怎么退回去。
Flyway
解决的就是这个问题:把每次表结构变更写成一个版本化 SQL
脚本,按版本号顺序执行,用一张
flyway_schema_history
表记录哪些脚本已经跑过。好处是:表结构变更可版本控制、可追踪、可复现、可回滚(社区版需手写回滚脚本)。
7.2 最小可用示例:第一个
Flyway 脚本
Flyway 默认扫描 classpath:db/migration
目录,文件名命名规范是
V{版本号}__{描述}.sql(注意是两个下划线):
src/main/resources/db/migration/
├── V1__init_user.sql
├── V2__add_order_and_stock.sql
└── V3__add_index_on_username.sql
第一个脚本建用户表:
-- V1__init_user.sql:初始化用户表
CREATE TABLE `user` (
`id` BIGINT NOT NULL AUTO_INCREMENT COMMENT '主键',
`username` VARCHAR(64) NOT NULL COMMENT '用户名',
`age` INT DEFAULT NULL COMMENT '年龄',
`email` VARCHAR(128) DEFAULT NULL COMMENT '邮箱',
`create_time` DATETIME DEFAULT NULL COMMENT '创建时间',
`update_time` DATETIME DEFAULT NULL COMMENT '更新时间',
`deleted` TINYINT NOT NULL DEFAULT 0 COMMENT '逻辑删除 0正常 1删除',
`version` INT NOT NULL DEFAULT 0 COMMENT '乐观锁版本号',
PRIMARY KEY (`id`),
UNIQUE KEY `uk_username` (`username`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='用户表';
启动应用时,Flyway
会自动执行未执行过的脚本。关键规则(踩坑重灾区):
- 已经执行过的脚本绝不能改。Flyway 用脚本内容的
checksum
校验,改了历史脚本会直接启动报错。要改表结构,必须新建一个版本号更大的脚本。 - 脚本命名严格遵循
V{n}__{desc}.sql,n
是递增版本号,两个下划线不能少。 - 生产上
spring.jpa.hibernate.ddl-auto必须设成
none(环境准备里已设),表结构完全交给 Flyway,禁止 JPA
自动建表。
7.3 生产级:Flyway
完整配置与脚本示例
# 生产级 Flyway 配置
spring:
flyway:
enabled: true
locations: classpath:db/migration
baseline-on-migrate: true # 已有库首次接入时,以当前状态为基线,不重复执行历史脚本
validate-on-migrate: true # 执行前校验脚本 checksum,防止历史脚本被篡改
encoding: UTF-8
后两个版本脚本示例:
-- V2__add_order_and_stock.sql:新增订单表和库存表
CREATE TABLE `orders` (
`id` BIGINT NOT NULL AUTO_INCREMENT COMMENT '主键',
`order_no` VARCHAR(64) NOT NULL COMMENT '订单号',
`user_id` BIGINT NOT NULL COMMENT '下单用户',
`product_id` BIGINT NOT NULL COMMENT '商品',
`quantity` INT NOT NULL COMMENT '数量',
`amount` DECIMAL(10,2) NOT NULL COMMENT '金额',
`status` TINYINT NOT NULL DEFAULT 0 COMMENT '0待支付 1已支付 2已取消',
`create_time` DATETIME DEFAULT NULL COMMENT '创建时间',
PRIMARY KEY (`id`),
UNIQUE KEY `uk_order_no` (`order_no`),
KEY `idx_user_id` (`user_id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='订单表';
CREATE TABLE `stock` (
`id` BIGINT NOT NULL AUTO_INCREMENT COMMENT '主键',
`product_id` BIGINT NOT NULL COMMENT '商品',
`quantity` INT NOT NULL DEFAULT 0 COMMENT '库存数量',
PRIMARY KEY (`id`),
UNIQUE KEY `uk_product_id` (`product_id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='库存表';
-- V3__add_index_on_username.sql:给高频查询字段加索引
-- username 已有唯一索引 uk_username,这里给订单表的 user_id 加联合索引优化查询
ALTER TABLE `orders` ADD KEY `idx_user_status` (`user_id`, `status`);
7.4 索引基础:什么字段该建索引
索引是数据库的「目录」,让查询从「全表扫描」变成「按目录定位」。理解索引的关键是
B+ 树:MySQL InnoDB 的索引是一棵 B+
树,叶子节点有序存储数据,查询走树的高度(通常 3~4
层)就能定位到数据,复杂度从 O(n) 降到 O(log n)。
建索引的原则(背下来):
- WHERE、ORDER BY、GROUP BY、JOIN 的字段建索引。
- 区分度高的字段建索引(如
user_id、order_no)。区分度低的(如性别、status
只有几个值)建了也几乎用不上。 - 遵循最左前缀:联合索引
(a, b, c)只对
a、a,b、a,b,c的查询生效,对
b、b,c的查询不生效。 - 覆盖索引:查询的列都在索引里,就不用回表查数据,性能最好。
- 避免索引失效:对索引列做函数运算(
WHERE YEAR(create_time)=2024)、前导模糊(LIKE '%xx')、隐式类型转换(字符串列传数字)都会让索引失效,退化成全表扫描。
索引不是越多越好:每多一个索引,insert/update
就要多维护一棵树,写性能下降。所以索引要「够用就好」,只为真正高频的查询建。
7.5 禁止 select
*,指定字段查询
第 2 章提过「禁止 select *」,这里从索引角度再说一次为什么:
-- 反例:select * 可能无法用覆盖索引,还要回表
SELECT * FROM `user` WHERE username = 'alice';
-- 正例:只查需要的列,如果建了 (username, age) 覆盖索引,可避免回表
SELECT id, username, age FROM `user` WHERE username = 'alice';
select *
的两个代价:一是查了不需要的字段(网络和内存浪费);二是更难命中覆盖索引(覆盖索引要求查询列都在索引里,*
把所有列都拉上,往往要回表)。所以从 2.8
节到本节,这条红线贯穿始终。
7.6 慢查询排查:三步定位
线上出现「接口很慢」,大概率是 SQL 慢。排查三步走:
第一步,开启慢查询日志:
-- 开启慢查询日志(慢阈值 1 秒,超过就记录)
SET GLOBAL slow_query_log = 'ON';
SET GLOBAL long_query_time = 1;
-- 查看慢查询日志路径
SHOW VARIABLES LIKE 'slow_query_log_file';
第二步,用 EXPLAIN 分析执行计划:
-- EXPLAIN 看一条 SQL 的执行计划
EXPLAIN SELECT * FROM `user` WHERE username = 'alice';
关注 EXPLAIN 结果里的三个关键列:
| 列 | 关注点 |
|---|---|
type |
访问类型,从好到坏:const > eq_ref >ref > range > index >ALL。出现 ALL(全表扫描)基本要优化 |
key |
实际用到的索引,为 NULL 说明没走索引 |
rows |
预估扫描行数,越大越慢 |
第三步,对症优化:
type=ALL:说明没走索引,检查 WHERE
条件是否满足最左前缀、是否做了函数运算导致索引失效。key=NULL:没有可用索引,考虑给 WHERE 字段建索引。rows
巨大:即使走了索引,扫描行数太多也慢,考虑更精确的条件或分页。
-- 反例:对索引列做函数运算,索引失效,type=ALL
EXPLAIN SELECT * FROM `user` WHERE YEAR(create_time) = 2024;
-- 正例:改成范围查询,走索引
EXPLAIN SELECT * FROM `user`
WHERE create_time >= '2024-01-01 00:00:00' AND create_time < '2025-01-01 00:00:00';
7.7 联合索引与最左前缀(实战)
单个字段的索引好理解,联合索引(多个字段组成一个索引)才是生产中真正的难点。核心规则是最左前缀原则:联合索引
(a, b, c) 相当于建了
(a)、(a, b)、(a, b, c)
三个索引,但不包括
(b)、(b, c)、(c)。
-- 建一个联合索引:常用于"某用户 + 某状态"的组合查询
ALTER TABLE `orders` ADD KEY `idx_user_status_time` (`user_id`, `status`, `create_time`);
这个联合索引能命中下面这些查询(都满足「从最左列 user_id
开始」):
-- 能命中:最左列 user_id
SELECT * FROM orders WHERE user_id = 1001;
-- 能命中:user_id + status,前缀匹配
SELECT * FROM orders WHERE user_id = 1001 AND status = 1;
-- 能命中:user_id + status + create_time,全匹配,还能用于排序
SELECT * FROM orders WHERE user_id = 1001 AND status = 1 ORDER BY create_time DESC;
而下面这些不能命中(跳过了最左列 user_id):
-- 不能命中:直接查 status,跳过了最左列 user_id
SELECT * FROM orders WHERE status = 1;
-- 不能命中:跳过了最左列,中间的 user_id 没在条件里
SELECT * FROM orders WHERE status = 1 AND create_time > '2024-01-01';
建联合索引的心法:把「区分度高、查询里几乎总会出现」的字段放在最左,把「用于排序/范围」的字段放后面。比如订单表几乎总是按
user_id查,user_id
就是最左列的不二之选。建之前想清楚「这个索引主要服务哪类查询」,而不是无脑给每个字段都建单列索引。
7.8 覆盖索引与回表
理解了 B+ 树后,再深入一个高频优化概念:回表。
InnoDB
的二级索引(非主键索引)叶子节点存的是「索引列 + 主键
id」,而不是完整数据行。所以走二级索引查到主键后,还要再回主键索引(聚簇索引)查一次完整行——这个二次查找就叫「回表」。
覆盖索引的意思是:查询需要的列全部在索引里,就不需要回表,性能最好。
-- 建覆盖索引:把查询需要的列都放进索引
ALTER TABLE `user` ADD KEY `idx_username_age` (`username`, `age`);
-- 反例:select * 需要所有列,索引里只有 username+age,必须回表
SELECT * FROM user WHERE username = 'alice';
-- 正例:只查 username 和 age,都在索引里,无需回表(覆盖索引)
SELECT username, age FROM user WHERE username = 'alice';
用 EXPLAIN 看区别,覆盖索引的 Extra 列会显示
Using index:
EXPLAIN SELECT username, age FROM user WHERE username = 'alice';
-- Extra: Using index <- 覆盖索引命中,无需回表
EXPLAIN SELECT * FROM user WHERE username = 'alice';
-- Extra: (无 Using index) <- 需要回表查完整行
这再次呼应了 2.8 节和 7.5 节的「禁止 select
*」:select *
几乎必然回表,而「只查需要的列」有机会命中覆盖索引,两者性能差距在热点查询上能差好几倍。
7.9 Flyway 回滚策略与多环境
回滚是 Flyway
社区版的短板:它只负责「往前滚」,不自动提供「往回滚」。生产上回滚表结构有两条路:
路线一:写反向脚本(社区版常见做法)。
每个变更脚本配一个对应的反向脚本,出问题手动执行:
-- V3__add_index_on_username.sql(正向:加索引)
ALTER TABLE `orders` ADD KEY `idx_user_status` (`user_id`, `status`);
-- V3__rollback.sql(反向:删索引,出问题时手动执行,不纳入 Flyway 自动管理)
-- 注意:这个文件不要用 V 前缀命名,否则 Flyway 会把它当正向脚本执行
ALTER TABLE `orders` DROP KEY `idx_user_status`;
路线二:用 Liquibase 或 Flyway Teams 版。 Liquibase
原生支持「回滚」概念(每个 changeset 可带 rollback 语句),Flyway 的
Teams/Enterprise 版也提供了 undo 能力。如果回滚是刚需,选 Liquibase
更省事。
多环境管理:开发、测试、生产三个环境用同一套迁移脚本,但数据不同。要点是:
- 迁移脚本必须「无环境差异」:脚本里不写死任何环境相关的值(如生产库的
IP),环境差异靠配置注入。 - 生产迁移要谨慎:大表加字段/索引会锁表,生产上要选低峰期执行,或用
pt-online-schema-change(Percona 工具)做在线
DDL,避免锁表影响业务。 - 迁移前备份:任何结构变更前先备份,出问题能快速恢复。
-- 大表加索引的生产建议:用在线 DDL,避免锁表
-- MySQL 8.0 的 ALTER TABLE ... ALGORITHM=INPLACE 尽量在线执行
ALTER TABLE `orders` ADD KEY `idx_user_status` (`user_id`, `status`), ALGORITHM=INPLACE, LOCK=NONE;
一句话:Flyway
管「往前滚」很可靠,管「往回滚」要自己补反向脚本;多环境共用同一套脚本,但生产迁移要选低峰期
+ 在线 DDL + 先备份。
本章小结:Flyway
用版本化脚本让表结构变更可追踪、可复现,历史脚本不能改只能新增,回滚需自备反向脚本或改用
Liquibase;索引遵循最左前缀、区分度高、覆盖索引原则,避免函数运算/前导模糊/隐式转换让索引失效;联合索引把高频条件放最左、排序字段放后面,覆盖索引让查询免回表;慢查询排查靠「慢日志
+ EXPLAIN + 对症优化」三步,type=ALL 和
key=NULL 是两个最该警惕的信号。
5. 生产级实战项目:订单 +
扣库存 + 缓存
本章用一个小型但完整的实战项目,串起本篇 80% 的知识点:MyBatis-Plus
做数据访问、事务保护下单一致性、缓存扛住读流量、Flyway
管理表结构、读写分离分摊读压力。
项目结构
com.example.shop
├── ShopApplication.java # 启动类(含 @EnableCaching)
├── common
│ ├── ApiResult.java # 统一响应体
│ ├── ErrorCode.java # 统一错误码
│ └── JsonUtil.java # JSON 工具
├── config
│ ├── MybatisPlusConfig.java # 分页 + 乐观锁插件
│ ├── CacheConfig.java # Redis 缓存管理器
│ └── ReadWriteSplittingAspect.java # 读写分离路由
├── controller
│ └── OrderController.java
├── service
│ ├── OrderService.java
│ ├── impl/OrderServiceImpl.java
│ └── impl/ProductServiceImpl.java
├── mapper
│ ├── OrderMapper.java
│ ├── StockMapper.java
│ └── ProductMapper.java
├── entity
│ ├── Order.java
│ ├── Stock.java
│ └── Product.java
├── dto
│ └── OrderCreateDTO.java
├── vo
│ └── ProductVO.java
└── exception
├── BizException.java
└── GlobalExceptionHandler.java
公共类(与全系列一致,逐字复用)
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:统一错误码(基础码与全系列一致,本篇扩展业务码)
public enum ErrorCode {
SUCCESS(0, "success"),
PARAM_ERROR(40001, "参数错误"),
UNAUTHORIZED(40101, "未登录或登录已过期"),
FORBIDDEN(40301, "无权限访问"),
USER_NOT_FOUND(40401, "用户不存在"),
PRODUCT_NOT_FOUND(40402, "商品不存在"),
STOCK_NOT_ENOUGH(50002, "库存不足"),
DUPLICATE_SUBMIT(40901, "请勿重复提交"),
SYSTEM_ERROR(50000, "系统繁忙,请稍后重试");
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:统一业务异常
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:全局异常处理
@RestControllerAdvice
@Slf4j
public class GlobalExceptionHandler {
// 业务异常:返回业务码和提示
@ExceptionHandler(BizException.class)
public ApiResult<Void> handleBiz(BizException e) {
log.warn("业务异常: code={}, message={}", e.getCode(), e.getMessage());
return ApiResult.fail(e.getCode(), e.getMessage());
}
// 参数校验异常:返回参数错误
@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());
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);
}
}
实体与 Mapper
// entity/Order.java
@Data
public class Order {
@TableId(type = IdType.AUTO)
private Long id;
private String orderNo;
private Long userId;
private Long productId;
private Integer quantity;
private BigDecimal amount;
private Integer status; // 0待支付 1已支付 2已取消
@TableField(fill = FieldFill.INSERT)
private LocalDateTime createTime;
// 从 DTO 构建订单实体(业务对象转换)
public static Order from(OrderCreateDTO dto, Long userId) {
Order order = new Order();
order.setOrderNo(dto.getRequestNo());
order.setUserId(userId);
order.setProductId(dto.getProductId());
order.setQuantity(dto.getQuantity());
order.setStatus(0);
return order;
}
}
// entity/Stock.java
@Data
public class Stock {
@TableId(type = IdType.AUTO)
private Long id;
private Long productId;
private Integer quantity;
}
// entity/Product.java
@Data
public class Product {
@TableId(type = IdType.AUTO)
private Long id;
private String name;
private BigDecimal price;
private Integer stock;
}
// mapper/OrderMapper.java
@Mapper
public interface OrderMapper extends BaseMapper<Order> {
}
// mapper/StockMapper.java:扣库存用条件更新,天然防超卖
@Mapper
public interface StockMapper extends BaseMapper<Stock> {
@Update("UPDATE stock SET quantity = quantity - #{qty} " +
"WHERE product_id = #{productId} AND quantity >= #{qty}")
int deduct(@Param("productId") Long productId, @Param("qty") Integer qty);
}
// mapper/ProductMapper.java
@Mapper
public interface ProductMapper extends BaseMapper<Product> {
}
DTO 与 VO
// dto/OrderCreateDTO.java:入参校验
@Data
public class OrderCreateDTO {
@NotBlank(message = "请求号不能为空")
private String requestNo; // 幂等请求号,前端生成
@NotNull(message = "商品不能为空")
private Long productId;
@NotNull(message = "数量不能为空")
@Min(value = 1, message = "数量至少为 1")
@Max(value = 99, message = "数量最多为 99")
private Integer quantity;
}
// vo/ProductVO.java:出参裁剪,不暴露库存等内部字段
@Data
public class ProductVO {
private Long id;
private String name;
private BigDecimal price;
}
核心业务:订单服务(事务 +
幂等)
// service/OrderService.java
public interface OrderService {
Long createOrder(OrderCreateDTO dto);
}
// service/impl/OrderServiceImpl.java:下单核心,事务 + 幂等 + 扣库存
@Service
@RequiredArgsConstructor
@Slf4j
public class OrderServiceImpl implements OrderService {
private final OrderMapper orderMapper;
private final StockMapper stockMapper;
private final StringRedisTemplate redisTemplate;
// 事务:显式 rollbackFor,异常上抛;方法 public 且被外部代理调用,事务生效
@Transactional(rollbackFor = Exception.class)
@Override
public Long createOrder(OrderCreateDTO dto) {
// 1. 幂等校验:同一 requestNo 只处理一次(10 分钟内有效)
String idempotentKey = "order:submit:" + dto.getRequestNo();
if (Boolean.TRUE.equals(redisTemplate.hasKey(idempotentKey))) {
throw new BizException(ErrorCode.DUPLICATE_SUBMIT);
}
// 2. 扣库存:条件更新,quantity >= qty 保证不减成负数,返回 0 即库存不足
int deducted = stockMapper.deduct(dto.getProductId(), dto.getQuantity());
if (deducted <= 0) {
throw new BizException(ErrorCode.STOCK_NOT_ENOUGH);
}
// 3. 落订单
Order order = Order.from(dto, 1001L); // 1001L 示例用户 id,真实场景从登录态取
orderMapper.insert(order);
// 4. 标记幂等 key
redisTemplate.opsForValue().set(idempotentKey, "1", 10, TimeUnit.MINUTES);
log.info("订单创建成功, orderId={}, requestNo={}", order.getId(), dto.getRequestNo());
return order.getId();
}
}
核心业务:商品查询(缓存 +
防击穿)
// service/impl/ProductServiceImpl.java:商品查询,缓存 + 互斥锁防击穿
@Service
@RequiredArgsConstructor
@Slf4j
public class ProductServiceImpl {
private final ProductMapper productMapper;
private final StringRedisTemplate redisTemplate;
public ProductVO getProduct(Long id) {
String cacheKey = "product:" + id;
String lockKey = "product:lock:" + id;
// 1. 查缓存
String json = redisTemplate.opsForValue().get(cacheKey);
if (json != null) {
return JsonUtil.fromJson(json, ProductVO.class);
}
// 2. 抢互斥锁,只有一个线程回源 DB
Boolean locked = redisTemplate.opsForValue()
.setIfAbsent(lockKey, "1", 3, TimeUnit.SECONDS);
if (Boolean.TRUE.equals(locked)) {
try {
// 双重检查
json = redisTemplate.opsForValue().get(cacheKey);
if (json != null) {
return JsonUtil.fromJson(json, ProductVO.class);
}
// 回源 DB
Product product = productMapper.selectById(id);
if (product == null) {
throw new BizException(ErrorCode.PRODUCT_NOT_FOUND);
}
ProductVO vo = toVO(product);
// 写缓存,TTL 加随机值防雪崩
long ttl = 30 + ThreadLocalRandom.current().nextInt(10);
redisTemplate.opsForValue().set(cacheKey, JsonUtil.toJson(vo), ttl, TimeUnit.MINUTES);
return vo;
} finally {
redisTemplate.delete(lockKey);
}
} else {
// 没抢到锁,短暂休眠重试
try {
Thread.sleep(50);
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
}
return getProduct(id);
}
}
private ProductVO toVO(Product product) {
ProductVO vo = new ProductVO();
vo.setId(product.getId());
vo.setName(product.getName());
vo.setPrice(product.getPrice());
return vo;
}
}
控制器
// controller/OrderController.java
@RestController
@RequestMapping("/orders")
@RequiredArgsConstructor
@Slf4j
public class OrderController {
private final OrderService orderService;
private final ProductServiceImpl productService;
@PostMapping
public ApiResult<Long> create(@Valid @RequestBody OrderCreateDTO dto) {
return ApiResult.ok(orderService.createOrder(dto));
}
@GetMapping("/product/{id}")
public ApiResult<ProductVO> product(@PathVariable Long id) {
return ApiResult.ok(productService.getProduct(id));
}
}
运行步骤
# 1. 设置环境变量(生产密码务必走环境变量,不硬编码)
export DB_HOST=localhost DB_USER=root DB_PASSWORD=root123456
export REDIS_HOST=localhost REDIS_PORT=6379
# 2. 启动应用(Flyway 会自动执行 db/migration 下的建表脚本)
mvn spring-boot:run
# 3. 验证下单接口
curl -X POST http://localhost:8080/orders
-H "Content-Type: application/json"
-d '{"requestNo":"REQ-20240814-001","productId":1,"quantity":2}'
# 期望返回:{"code":0,"message":"success","data":1}
# 4. 重复提交验证幂等
curl -X POST http://localhost:8080/orders
-H "Content-Type: application/json"
-d '{"requestNo":"REQ-20240814-001","productId":1,"quantity":2}'
# 期望返回:{"code":40901,"message":"请勿重复提交"}
# 5. 验证商品缓存(第二次请求应命中缓存,日志不再打印"回源 DB")
curl http://localhost:8080/orders/product/1
运行结果验证要点:
- 第一次下单成功,订单落库、库存减少、幂等 key 写入 Redis。
- 重复提交返回
请勿重复提交,说明幂等生效。 - 商品第一次查询打印「缓存未命中」,第二次不打印,说明缓存生效。
- 把商品库存扣到 0 再下单,返回
库存不足,且订单表没有新增记录——这是事务回滚在起作用。
6. 常见坑与排错指南
| 坑 / 现象 | 原因 | 解决方案 |
|---|---|---|
接口大量超时,日志报 Connection is not available |
连接池耗尽:连接泄漏或池子太小 | 查 Hikari 的 active/idle/waiting 三值区分泄漏与不够用;泄漏则修代码确保 finally 关闭、缩短事务 |
加了 @Transactional 但异常后数据没回滚 |
同类自调用绕过代理 / 异常被吞 / 异常类型不对 / 方法非 public / 类没被 Spring 管理 |
拆 Bean 或注入自身代理;异常上抛;加rollbackFor = Exception.class;改 public;加@Service |
| 受检异常不回滚 | 默认只回滚 RuntimeException/Error | @Transactional(rollbackFor = Exception.class) |
用了 saveBatch 却没变快 |
JDBC URL 没加rewriteBatchedStatements=true,退化成单条执行 |
连接串加 rewriteBatchedStatements=true |
查询很慢,EXPLAIN 显示 type=ALL |
没走索引:没建索引 / 对索引列做函数运算 / 前导模糊 | 建索引;避免 YEAR(col) 等函数;避免LIKE '%x' |
| 缓存里读到旧数据,和 DB 不一致 | 写时「更新缓存」而非「删缓存」,并发下旧值写回 | 写时删缓存(Cache Aside),删除失败用延迟双删或 binlog 兜底 |
| 缓存 key 越积越多,内存打爆 | 缓存没设 TTL | 所有缓存设 TTL,并加随机值防雪崩 |
| 改了 Flyway 历史脚本后启动报错 | Flyway 校验 checksum,历史脚本不可改 | 新建更高版本号的脚本,不改历史 |
| 逻辑删除后无法注册同名用户 | deleted 标记位 + 唯一索引冲突 |
删除时改写唯一字段(如 username + “_del” + id),或联合唯一索引 |
| 密码明文提交到 git | 硬编码在 yml | 连接串和密码走环境变量,.env/敏感配置不入库 |
8. 总结与延伸阅读
本篇把后端最危险的三块地雷——连接池、事务、缓存——逐个拆开讲透:连接池的本质是「复用连接、快速失败」,调优受数据库连接数和并发拐点双重约束;事务的
5 大失效场景全部根源于「代理机制 +
异常语义」,必须逐个跑出证据才算掌握;缓存的三大经典问题(穿透/击穿/雪崩)各有明确的「成因
→ 方案 →
代码」链路,而一致性靠「写时删缓存」这条反直觉但正确的原则守住。再加上
MyBatis-Plus/JPA 的选型、读写分离、Flyway 迁移和 SQL
优化,你现在具备了写出「生产级数据层」的完整能力。
延伸阅读:
- MyBatis-Plus
官方文档——条件构造器、插件、自动填充的权威说明,遇到具体 API
先查这里。 - Spring
Transaction Management
官方文档——传播行为与隔离级别的第一手定义。 - Apache ShardingSphere
官方文档——分库分表、读写分离的中间件方案。 - 《高性能 MySQL(第 4 版)》——索引、查询优化、InnoDB
内部机制的经典必读。 - 美团技术博客「缓存一致性」「MySQL
索引」系列——国内一线大厂的工程实践,与本文结论互为印证。
本文为 Spring Boot 学习路线阶段 4,下一篇(阶段
5)将进入「安全与鉴权」。