【阶段 4】数据访问:MyBatis-Plus、事务与缓存

141次阅读
没有评论

阶段 4 · Spring Boot 学习路线 版本基线:Spring Boot 3.2.x / JDK 17 /
MySQL 8.0 / Redis 7.x 前置依赖:阶段 3(Web 开发)


1. 导语

到阶段 3 为止,你已经能写出一个「有接口、有分层、有统一返回」的 Web
应用。但一个真实的业务系统,数据从哪来、改到哪去、怎么保证数据不错、怎么让接口快起来——这些问题的答案,全在「数据访问」这一层。

数据访问是后端最容易出事的地方,没有之一。一个团队可能把 Controller
写得再漂亮,一旦数据库连接池耗尽、事务静默失效、缓存被大促瞬间打穿,线上照样翻车。这些事故的共同点是:代码看起来能跑,单测也过,但一到生产就炸。原因往往不是语法错误,而是对「连接池、事务、缓存」这三样东西的理解停留在表面。

本篇要解决的就是这三个「一看就会、一用就错」的硬骨头:

  1. 连接池——HikariCP 为什么不能把
    maximum-pool-size 拉到 500,连接池耗尽了怎么排障。
  2. 事务——@Transactional
    加了为什么没生效?本篇会带你把 5
    大失效场景逐个跑出「失效」的证据,而不是背结论。
  3. 缓存——缓存穿透、击穿、雪崩到底差在哪,以及「写时删缓存,不更新缓存」这条原则背后的并发逻辑。

学完本篇,你能独立写出一个「订单 + 扣库存 +
缓存」的完整模块:MyBatis-Plus 做 CRUD、JPA
做简单模块、事务保护一致性、Redis 扛住读流量、Flyway
管好表结构。这也是你第一次真正写出「生产级」的数据层,而不是教程里那种只有
insert 一个动作的玩具代码。

与前后阶段的关系:本篇的「事务」是阶段
6(微服务与分布式事务)的前置基础;「缓存」的高并发方案会在阶段
9(性能优化)进一步展开;而「多数据源」则为阶段 7
的读写分离落地做铺垫。


2. 学习目标与前置要求

学完你能:

  1. 独立配置一套生产级的 HikariCP
    连接池,并能在连接池耗尽时根据日志和报错定位根因。
  2. 用 MyBatis-Plus 完成带「自动填充、逻辑删除、乐观锁、分页」的用户
    CRUD 模块,且不使用 select *、不在循环里单条 insert。
  3. 用 Spring Data JPA 完成一个简单模块,并能根据业务场景在 MyBatis-Plus
    与 JPA 之间做出选型判断。
  4. 写出带事务的「订单 + 扣库存」方法,能解释 5 大
    @Transactional
    失效场景,并逐个用代码验证「确实没回滚」。
  5. 用 Spring Cache + Redis 实现 Cache Aside
    缓存,能画出穿透/击穿/雪崩三者的「成因 → 方案 →
    代码」链路,并用互斥锁实现防击穿。
  6. 用 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 却没变快」的根因。

项目结构(本篇新增
mapperentityrepositoryconfig
等数据访问层):

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 为什么需要连接池

先想一个朴素的问题:一次数据库查询,网络和资源上到底发生了什么?

  1. 客户端与 MySQL 建立 TCP 连接(三次握手)。
  2. MySQL 服务端为该连接 分配线程与内存
  3. 客户端发送 认证请求(用户名密码校验)。
  4. 执行 SQL,返回结果。
  5. 连接关闭(四次挥手),服务端 回收线程

如果每次查询都走完这 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 为什么不是越大越好?
这是一个高频面试题,答案是「受两个硬约束限制」:

  1. 数据库端的连接数是有限的。MySQL 的
    max_connections 默认 151(8.0 更高一些但有限)。假设你有 10
    个应用实例,每个池子配 50,那就是 500
    条连接同时压向一个库——数据库的连接和线程资源会先被耗尽。
  2. 连接多不等于吞吐高。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
秒还没等到
。此时数据库本身可能负载并不高,但应用侧就是拿不到连接。

常见根因(按出现频率排序):

  1. 连接泄漏——拿到了连接却从没归还。最典型的是自己
    dataSource.getConnection() 后没在 finally
    close(),或者事务执行了过长时间(长事务持锁又持连接)。
  2. 池子配得太小——高峰期并发超过
    maximum-pool-size,连接供不应求。
  3. 慢 SQL 拖住连接——每个请求都执行一条 5 秒的 SQL,20
    条连接很快被占满,后续请求全部排队。
  4. 数据库本身变慢——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 且持续增长说明曾/正在满载

调优三步走(结合指标判断):

  1. pending 是否长期 >
    0
    pending 是「排队等连接」的线程数,长期大于 0
    说明连接供不应求,考虑调大
    maximum-pool-size(但要先确认数据库连接数上限)。
  2. timeout
    是否在增长
    timeout
    是「拿连接超时」的次数,只要在涨就说明高峰期池子不够用或有泄漏。
  3. 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
完成用户模块,覆盖它最常用的六个能力:BaseMapperLambdaQueryWrapper、分页插件、自动填充、逻辑删除、乐观锁。

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。注意两件事:

  1. 自动拼了 deleted=0——这是
    @TableLogic 在起作用,逻辑删除字段会被 MP
    自动追加到所有查询/更新条件。
  2. 返回的是全字段——这就是 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 节已写),之后调用
insertupdateById 时,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
返回全字段,生产里这是反模式。查出来的多余字段既浪费网络传输,又可能把不该暴露的字段(如
deletedversion)返回给前端。正确做法是查询后裁剪成
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);
        }
    }
}

默认规则(务必记住,后面失效场景全围绕它):

  1. 默认只回滚 RuntimeException
    Error
    ,受检异常(Checked
    Exception)不会回滚。
  2. 默认传播行为是
    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 事务生产实践清单

把本章所有结论收敛成一份可检查的清单,写代码时逐条对照:

  1. 所有 @Transactional 都显式写
    rollbackFor = Exception.class
    ,杜绝「受检异常不回滚」的默认坑。
  2. 方法必须是 public,且类必须被
    Spring
    管理
    @Service/@Component),否则代理不生效。
  3. 异常向上抛,不要吞。只有「日志、通知」等旁路操作允许局部
    try-catch,且应放 REQUIRES_NEW 独立事务。
  4. 避免同类自调用。需要内部调用事务方法时,拆成独立
    Bean。
  5. 事务要短。把「发短信、调外部 HTTP、发
    MQ」这类慢操作移出事务,缩短锁和连接的持有时间,降低死锁和连接耗尽风险。
  6. 单条条件 UPDATE 防超卖。扣库存用
    UPDATE ... WHERE quantity >= qty,而不是「先查再改」,天然原子。
  7. 幂等。下单/支付等入口用唯一请求号(requestNo)做幂等校验,防止重复提交造成重复扣款。
  8. 隔离级别用默认。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);
    }
}

这段代码有两个刻意埋下的问题,正好引出后面的三大经典问题:

  1. 查不到数据时不写缓存——缓存穿透的漏洞(5.5
    节)。
  2. 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::123order::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 本身宕机,所有缓存直接失效。

雪崩方案:

  1. TTL 加随机值——让 key
    的过期时间错开,避免同一时刻集体失效(5.6 节代码里已用
    30 + random(10) 演示)。
  2. 多级缓存——本地缓存(Caffeine)+ Redis +
    DB,层层兜底。
  3. 限流降级——缓存/DB
    都扛不住时,服务降级返回兜底数据或错误,保住核心链路。
  4. 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」成功、「删缓存」失败,缓存里还是旧值。生产上用两种方式兜底:

  1. 延迟双删:更新 DB
    后先删一次缓存,延迟几百毫秒再删一次,覆盖「并发读写」的窗口期。
  2. 订阅 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%)被从库分担,主库只专注写入
,整体吞吐大幅提升。代价是主从复制有延迟,刚写入的数据在从库上可能读不到(通常几十毫秒到几百毫秒)。

实现读写分离有两种方式:

  1. 应用层路由(本篇重点):用
    dynamic-datasource 在代码里用 @DS
    注解手动指定走主库还是从库。灵活、可控,但路由逻辑要自己写。
  2. 中间件层路由:用 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();
    }
}

关键点提示(都是生产事故高发区):

  1. 事务与数据源绑定的坑@Transactional
    会在方法开始时从连接池取连接,此时数据源已经确定。如果事务方法内部再切数据源,是切不动的——事务一旦开启,连接就固定了。所以「事务方法」和「切换数据源」要谨慎组合。
  2. 主从延迟:刚写完就立刻读,从库可能还没同步到,会读到旧数据。解决:写后立即读的场景强制走主库(@DS("master")),或接受短延迟。
  3. 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
切换的是「后续用哪个数据源」,但已经拿到的主库连接不会换。所以「事务」和「读写分离」有天然的冲突:事务方法内不能切换数据源

解决方案:

  1. 读操作不要开事务。纯查询方法不加
    @Transactional,让它自由走从库。Spring 里只有读也能加
    @Transactional(readOnly = true),但 readOnly
    只是提示,不解决连接绑定问题——所以查询方法干脆不加事务
  2. 写后立即读走主库。刚写入的数据在从库可能还没同步(主从延迟),这类「写后读」场景强制走主库。
// 修复:读方法不加事务,走从库;写方法走主库
@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 传输 +
从库回放,数据才在从库可见。这个延迟通常几十到几百毫秒,高峰可能到秒级。于是出现一个经典问题:用户刚提交订单,马上刷新列表,发现订单「不见了」

三种处理方案(按推荐程度排序):

  1. 写后读走主库:对「写操作完成后立即读」的接口(如「提交后返回详情」),读也走主库,绕开延迟。实现上可以在「同一个用户请求内」用
    ThreadLocal 标记「本次请求已写过,后续读走主库」。
  2. 容忍短暂不一致:对实时性要求不高的场景(如列表页),接受几百毫秒的延迟,用户下次刷新就能看到。
  3. 强一致读:实在要强一致,读也走主库,等于放弃读写分离的读扩展(只适合「写后必须读到」的少量接口)。
// 方案一实现: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)
  ↓ 读扛不住        ↓ 单表太大            ↓ 跨库一致性

每一步都有明确触发条件,不要跳步

  1. 读写分离:读 QPS 打满主库,但单表数据量还 OK。
  2. 分库分表:单表数据量到千万级,即使有从库,单表的写入和索引维护也变慢。
  3. 分布式事务:分库后,一次操作跨多个库,单库事务(第
    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
会自动执行未执行过的脚本。关键规则(踩坑重灾区)

  1. 已经执行过的脚本绝不能改。Flyway 用脚本内容的
    checksum
    校验,改了历史脚本会直接启动报错。要改表结构,必须新建一个版本号更大的脚本
  2. 脚本命名严格遵循 V{n}__{desc}.sqln
    是递增版本号,两个下划线不能少。
  3. 生产上 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)。

建索引的原则(背下来):

  1. WHERE、ORDER BY、GROUP BY、JOIN 的字段建索引。
  2. 区分度高的字段建索引(如
    user_id、order_no)。区分度低的(如性别、status
    只有几个值)建了也几乎用不上。
  3. 遵循最左前缀:联合索引 (a, b, c) 只对
    aa,ba,b,c 的查询生效,对
    bb,c 的查询不生效。
  4. 覆盖索引:查询的列都在索引里,就不用回表查数据,性能最好。
  5. 避免索引失效:对索引列做函数运算(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

运行结果验证要点

  1. 第一次下单成功,订单落库、库存减少、幂等 key 写入 Redis。
  2. 重复提交返回 请勿重复提交,说明幂等生效。
  3. 商品第一次查询打印「缓存未命中」,第二次不打印,说明缓存生效。
  4. 把商品库存扣到 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
优化,你现在具备了写出「生产级数据层」的完整能力。

延伸阅读:

  1. MyBatis-Plus
    官方文档
    ——条件构造器、插件、自动填充的权威说明,遇到具体 API
    先查这里。
  2. Spring
    Transaction Management
    官方文档
    ——传播行为与隔离级别的第一手定义。
  3. Apache ShardingSphere
    官方文档
    ——分库分表、读写分离的中间件方案。
  4. 《高性能 MySQL(第 4 版)》——索引、查询优化、InnoDB
    内部机制的经典必读。
  5. 美团技术博客「缓存一致性」「MySQL
    索引」系列——国内一线大厂的工程实践,与本文结论互为印证。

本文为 Spring Boot 学习路线阶段 4,下一篇(阶段
5)将进入「安全与鉴权」。

正文完
 1
评论(没有评论)