Spring Boot 4 旅游主题实战教程 阶段三:数据持久化
第07章 数据库连接与 JdbcTemplate:数据住进 MySQL
本章目标:连接远程 MySQL,理解 HikariCP 连接池,掌握 JdbcTemplate 增删改查与自增主键回填,学会 Spring Boot 的 SQL 初始化机制。
上章回顾:第04~06章的数据全存在内存 Map 里------应用一重启就"失忆"。从本章起数据真正落库。
下一章预告:JdbcTemplate 连 RowMapper 都要手写。第08章《MyBatis》把 SQL 从 Java 代码中解放到 XML/注解里,映射全自动。
7.1 为什么需要连接池?
没有池的原始 JDBC:每个请求 → 建立 TCP 连接 → 认证握手 → 执行 SQL → 断开。一次握手几十毫秒,高并发下数据库直接被打爆(建连接的开销远大于查数据的开销)。
连接池的思路:应用启动时先建好 N 个连接放进"池子",请求来了借一个、用完还回去。
HikariCP(日语"光")是 Spring Boot 默认连接池,以极快的字节码优化著称:
| 配置项 | 含义 |
|---|---|
| minimum-idle | 池中最少空闲连接数 |
| maximum-pool-size | 最大连接数上限 |
| connection-timeout | 借不到连接等多久后报错 |
| max-lifetime | 一个连接最多活多久(防止 MySQL 侧 wait_timeout 先杀连接) |
7.2 创建第07章模块
7.2.1 根 pom 注册模块
xml
<module>chapter07-jdbc</module>
7.2.2 本章 pom.xml
xml
<?xml version="1.0" encoding="UTF-8"?>
<!--
第07章:数据库连接与 JdbcTemplate
-->
<project xmlns="http://maven.apache.org/POM/4.0.0"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 https://maven.apache.org/xsd/maven-4.0.0.xsd">
<modelVersion>4.0.0</modelVersion>
<parent>
<groupId>com.lihaozhe</groupId>
<artifactId>sb-travel</artifactId>
<version>1.0.0</version>
</parent>
<artifactId>chapter07-jdbc</artifactId>
<packaging>jar</packaging>
<dependencies>
<!-- JDBC 启动器:HikariCP + JdbcTemplate + DataSourceTransactionManager -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-jdbc</artifactId>
</dependency>
<!-- MySQL 驱动:JDBC 5+ 无需手动 Class.forName,SPI 自动注册 -->
<dependency>
<groupId>com.mysql</groupId>
<artifactId>mysql-connector-j</artifactId>
<scope>runtime</scope>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-test</artifactId>
<scope>test</scope>
</dependency>
</dependencies>
<build>
<plugins>
<plugin>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-maven-plugin</artifactId>
</plugin>
</plugins>
</build>
</project>
7.3 编写代码
7.3.1 实体:Attraction.java
路径:src/main/java/com/lihaozhe/chapter07/model/Attraction.java
java
package com.lihaozhe.chapter07.model;
import java.math.BigDecimal;
import java.time.LocalDateTime;
/**
* 景点实体(第07章:即将住进真实数据库的类)
*
* <p>理论知识 ------ 金额字段为什么用 BigDecimal 而不是 double?
* double 是二进制浮点数,无法精确表示 0.1(就像十进制写不出 1/3),
* 累加后会出现 19.99+0.01=20.000000000000004 这样的"灵异现象"。
* 任何涉及钱的字段一律 BigDecimal,数据库对应 DECIMAL 类型。
*
* @param id 主键
* @param name 景点名称
* @param city 所在城市
* @param price 门票价格
* @param description 简介
* @param createTime 创建时间
*/
public record Attraction(
Long id,
String name,
String city,
BigDecimal price,
String description,
LocalDateTime createTime) {
}
7.3.2 数据访问层:AttractionDao.java(核心)
路径:src/main/java/com/lihaozhe/chapter07/dao/AttractionDao.java
java
package com.lihaozhe.chapter07.dao;
import com.lihaozhe.chapter07.model.Attraction;
import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.jdbc.core.RowMapper;
import org.springframework.jdbc.support.GeneratedKeyHolder;
import org.springframework.jdbc.support.KeyHolder;
import org.springframework.stereotype.Repository;
import java.sql.PreparedStatement;
import java.sql.ResultSet;
import java.sql.SQLException;
import java.sql.Statement;
import java.util.List;
import java.util.Optional;
/**
* 景点数据访问层(第07章:JdbcTemplate 实战)
*
* <p>理论知识 ------ @Repository:
* 与 @Service 同族的组件注解,语义为"数据访问层"。
* 额外能力:会把数据库异常(SQLException 等)自动翻译成
* Spring 统一的 DataAccessException 体系。
*
* <p>理论知识 ------ JdbcTemplate 常用方法速查:
* <pre>
* query(sql, rowMapper, args...) 多行查询 → List<T>
* queryForObject(sql, rowMapper, args) 单行查询(0行或2行以上抛异常)
* queryForList(sql, Integer.class, ...) 单列多行
* update(sql, args...) 增删改(返回影响行数)
* </pre>
*/
@Repository
public class AttractionDao {
private final JdbcTemplate jdbcTemplate;
/**
* RowMapper:把一行 ResultSet 数据映射成一个对象。
* 定义成静态常量避免每次查询都 new 一个匿名类。
*/
private static final RowMapper<Attraction> MAPPER = (rs, rowNum) -> mapRow(rs);
public AttractionDao(JdbcTemplate jdbcTemplate) {
this.jdbcTemplate = jdbcTemplate;
}
/** ResultSet → Attraction 的映射逻辑 */
private static Attraction mapRow(ResultSet rs) throws SQLException {
return new Attraction(
rs.getLong("id"),
rs.getString("name"),
rs.getString("city"),
rs.getBigDecimal("price"),
rs.getString("description"),
// getTimestamp 返回 java.sql.Timestamp,转成 JSR-310 的 LocalDateTime
rs.getTimestamp("create_time").toLocalDateTime());
}
/** 查询全部景点 */
public List<Attraction> findAll() {
String sql = """
SELECT id, name, city, price, description, create_time
FROM t_ch07_attraction
ORDER BY id
""";
return jdbcTemplate.query(sql, MAPPER);
}
/** 按 ID 查询。Optional 明确表达"可能不存在",比返回 null 安全 */
public Optional<Attraction> findById(Long id) {
String sql = """
SELECT id, name, city, price, description, create_time
FROM t_ch07_attraction
WHERE id = ?
""";
List<Attraction> list = jdbcTemplate.query(sql, MAPPER, id);
return list.stream().findFirst();
}
/**
* 新增景点。
*
* <p>理论知识 ------ KeyHolder:
* 插入成功后取回数据库自增生成的主键(MySQL LAST_INSERT_ID 的封装),
* 这是"插入并拿到新ID"的标准姿势。
*
* @return 带有新 ID 的完整实体
*/
public Attraction insert(Attraction a) {
String sql = """
INSERT INTO t_ch07_attraction (name, city, price, description)
VALUES (?, ?, ?, ?)
""";
KeyHolder keyHolder = new GeneratedKeyHolder();
jdbcTemplate.update(connection -> {
// Statement.RETURN_GENERATED_KEYS:告诉驱动要回传自增主键
PreparedStatement ps = connection.prepareStatement(sql, Statement.RETURN_GENERATED_KEYS);
ps.setString(1, a.name());
ps.setString(2, a.city());
ps.setBigDecimal(3, a.price());
ps.setString(4, a.description());
return ps;
}, keyHolder);
Long newId = keyHolder.getKey() == null ? null : keyHolder.getKey().longValue();
return new Attraction(newId, a.name(), a.city(), a.price(), a.description(), java.time.LocalDateTime.now());
}
/** 更新景点,返回是否真的更新到了数据 */
public boolean update(Attraction a) {
String sql = """
UPDATE t_ch07_attraction
SET name = ?, city = ?, price = ?, description = ?
WHERE id = ?
""";
int rows = jdbcTemplate.update(sql,
a.name(), a.city(), a.price(), a.description(), a.id());
return rows > 0;
}
/** 删除景点,返回是否删除成功 */
public boolean deleteById(Long id) {
int rows = jdbcTemplate.update(
"DELETE FROM t_ch07_attraction WHERE id = ?", id);
return rows > 0;
}
}
7.3.3 服务层、控制器与公共组件
AttractionService.java(src/main/java/com/lihaozhe/chapter07/service/):
java
package com.lihaozhe.chapter07.service;
import com.lihaozhe.chapter07.dao.AttractionDao;
import com.lihaozhe.chapter07.model.Attraction;
import org.springframework.stereotype.Service;
import java.util.List;
/**
* 景点服务层(第07章)
*/
@Service
public class AttractionService {
private final AttractionDao dao;
public AttractionService(AttractionDao dao) {
this.dao = dao;
}
public List<Attraction> findAll() {
return dao.findAll();
}
public Attraction findById(Long id) {
// Optional 的正确用法:存在取值,不存在抛业务异常
return dao.findById(id)
.orElseThrow(() -> new IllegalArgumentException("景点不存在: id=" + id));
}
public Attraction create(Attraction attraction) {
return dao.insert(attraction);
}
public boolean update(Attraction attraction) {
return dao.update(attraction);
}
public boolean delete(Long id) {
return dao.deleteById(id);
}
}
Result.java 与第05章相同;GlobalExceptionHandler.java 增加 IllegalArgumentException 处理(完整代码见本章代码包);控制器 AttractionController.java 完整源码如下:
java
package com.lihaozhe.chapter07.controller;
import com.lihaozhe.chapter07.common.Result;
import com.lihaozhe.chapter07.model.Attraction;
import com.lihaozhe.chapter07.service.AttractionService;
import org.springframework.web.bind.annotation.DeleteMapping;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.PutMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
import java.util.List;
/**
* 景点接口(第07章:数据已持久化到 MySQL)
*
* <p>对比第04章:接口签名几乎没变,但数据来源从内存 Map 变成了远程数据库------
* 分层架构(Controller→Service→Dao)带来的好处:替换底层实现,上层不动。
*/
@RestController
@RequestMapping("/attractions")
public class AttractionController {
private final AttractionService service;
public AttractionController(AttractionService service) {
this.service = service;
}
/** GET /attractions ------ 查全部 */
@GetMapping
public Result<List<Attraction>> list() {
return Result.ok(service.findAll());
}
/** GET /attractions/{id} ------ 查单个 */
@GetMapping("/{id}")
public Result<Attraction> detail(@PathVariable Long id) {
return Result.ok(service.findById(id));
}
/** POST /attractions ------ 新增 */
@PostMapping
public Result<Attraction> create(@RequestBody Attraction attraction) {
return Result.ok(service.create(attraction));
}
/** PUT /attractions/{id} ------ 更新 */
@PutMapping("/{id}")
public Result<String> update(@PathVariable Long id, @RequestBody Attraction attraction) {
var toUpdate = new Attraction(id, attraction.name(), attraction.city(),
attraction.price(), attraction.description(), null);
boolean ok = service.update(toUpdate);
return ok ? Result.ok("更新成功") : Result.fail(2001, "景点不存在: id=" + id);
}
/** DELETE /attractions/{id} ------ 删除 */
@DeleteMapping("/{id}")
public Result<Void> delete(@PathVariable Long id) {
service.delete(id);
return Result.ok();
}
}
7.3.4 建表脚本 schema.sql
路径:src/main/resources/schema.sql
sql
-- =====================================================================
-- 第07章建表脚本
--
-- 理论知识 ------ Spring Boot 的 SQL 初始化机制:
-- spring.sql.init.mode 默认 embedded(只对 H2 等内嵌库执行);
-- 对 MySQL 必须显式设置 mode=always。
-- 本表使用 IF NOT EXISTS,重复执行也不会报错------幂等脚本的好习惯。
-- =====================================================================
CREATE TABLE IF NOT EXISTS t_ch07_attraction (
id BIGINT NOT NULL AUTO_INCREMENT COMMENT '主键',
name VARCHAR(100) NOT NULL COMMENT '景点名称',
city VARCHAR(50) NOT NULL COMMENT '所在城市',
price DECIMAL(10, 2) NOT NULL DEFAULT 0 COMMENT '门票价格(元)',
description VARCHAR(500) NULL COMMENT '简介',
create_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
PRIMARY KEY (id),
KEY idx_city (city)
) ENGINE = InnoDB DEFAULT CHARSET = utf8mb4 COLLATE = utf8mb4_unicode_ci COMMENT = '景点表-第07章';
种子数据 data.sql:
sql
INSERT INTO t_ch07_attraction (name, city, price, description)
SELECT * FROM (
SELECT '莫日格勒河' AS name, '呼伦贝尔' AS city, 30.00 AS price, '老舍笔下的天下第一曲水' AS description
UNION ALL SELECT '满洲里国门', '呼伦贝尔', 80.00, '中俄边境的雄伟国门'
UNION ALL SELECT '额尔古纳湿地', '呼伦贝尔', 65.00, '亚洲第一湿地,白桦林与河流交织'
) AS seed
WHERE NOT EXISTS (SELECT 1 FROM t_ch07_attraction);
7.3.5 配置文件 application.yml
yaml
# =====================================================================
# 第07章 配置文件:数据库连接
# =====================================================================
server:
port: 8107
spring:
application:
name: chapter07-jdbc
datasource:
# JDBC URL 各参数含义:
# serverTimezone 必须指定时区,否则日期字段可能相差8小时
# characterEncoding=UTF-8 中文不乱码的关键
# allowPublicKeyRetrieval MySQL8+ caching_sha2_password 认证需要
url: jdbc:mysql://118.31.221.165:3306/sb_travel?useSSL=false&serverTimezone=Asia/Shanghai&characterEncoding=UTF-8&allowPublicKeyRetrieval=true
username: root
password: lihaozhe
driver-class-name: com.mysql.cj.jdbc.Driver
# HikariCP 连接池配置(Spring Boot 默认连接池)
hikari:
minimum-idle: 5 # 最少保持的空闲连接数
maximum-pool-size: 10 # 最大连接数
connection-timeout: 30000 # 获取连接超时30秒
max-lifetime: 600000 # 连接最大存活10分钟
sql:
init:
# always = 每次启动都执行 schema.sql 和 data.sql(脚本幂等)
mode: always
logging:
level:
# 打印 HikariCP 的连接池状态日志,启动时可看到"Start completed"字样
com.zaxxer.hikari: INFO
7.4 运行与验证
bash
mvn -pl chapter07-jdbc -am clean package -DskipTests
cd chapter07-jdbc
mvn spring-boot:run
启动日志可见连接池启动(真实输出):
text
HikariPool-1 - Starting...
HikariPool-1 - Added connection com.mysql.cj.jdbc.ConnectionImpl@...
HikariPool-1 - Start completed.
以下均为真实运行结果。
查询全部(首次启动时 data.sql 已插入种子数据)
bash
curl http://localhost:8107/attractions
json
{"code":0,"message":"success","data":[{"id":1,"name":"莫日格勒河","city":"呼伦贝尔","price":30.00,"description":"老舍笔下的天下第一曲水","createTime":"2026-08-25T21:54:39"},{"id":2,"name":"满洲里国门","city":"呼伦贝尔","price":80.00,"description":"中俄边境的雄伟国门","createTime":"2026-08-25T21:54:39"},{"id":3,"name":"额尔古纳湿地","city":"呼伦贝尔","price":65.00,"description":"亚洲第一湿地,白桦林与河流交织","createTime":"2026-08-25T21:54:39"}]}
POST 新增(注意返回了数据库生成的 id=4)
ins.json:{"name":"太阳岛","city":"哈尔滨","price":55.00,"description":"松花江上的避暑胜地"}
bash
curl -X POST http://localhost:8107/attractions \
-H "Content-Type: application/json; charset=utf-8" --data-binary @ins.json
json
{"code":0,"message":"success","data":{"id":4,"name":"太阳岛","city":"哈尔滨","price":55.00,"description":"松花江上的避暑胜地","createTime":"2026-08-25T21:55:18.5063409"}}
PUT 更新 + 回查
upd.json:{"name":"太阳岛风景区","city":"哈尔滨","price":60.00,"description":"5A级景区"}
json
{"code":0,"message":"success","data":"更新成功"}
{"code":0,"message":"success","data":{"id":4,"name":"太阳岛风景区","city":"哈尔滨","price":60.00,"description":"5A级景区","createTime":"2026-08-25T21:55:20"}}
DELETE 删除
bash
curl -X DELETE http://localhost:8107/attractions/4
json
{"code":0,"message":"success","data":null}
查询不存在的
bash
curl http://localhost:8107/attractions/999
json
{"code":2001,"message":"景点不存在: id=999","data":null}
重启应用再查列表------数据还在(持久化的意义),且不会重复插入种子数据(幂等脚本生效)。
7.5 本章小结
| 你学会了 | 关键点 |
|---|---|
| 连接池原理 | 复用连接省去 TCP+认证开销;四个关键参数 |
| JDBC URL 参数 | timezone / characterEncoding / allowPublicKeyRetrieval |
| JdbcTemplate | query/update/queryForObject 分工 |
| KeyHolder | 插入后回填自增主键 |
| SQL 初始化 | mode=always + 幂等脚本 |
| text block | 三引号写多行 SQL,可读性大增 |
7.6 动手练习
- 给列表接口增加按城市过滤参数(WHERE city = ?)。
- 新增"统计各城市景点数量"接口:
SELECT city, COUNT(*) ... GROUP BY city,返回 List。 - 把 HikariCP 最大连接数改为 3 并压测观察行为差异。
下一章预告:
RowMapper 手写字段映射太机械了。第08章《MyBatis 快速上手》:SQL 写进注解/XML,结果集映射全自动,还支持动态 SQL。
7.7 本章使用的 Java 25 新特性
| 特性 | 说明 | 本章应用 |
|---|---|---|
| record (Java 16+) | 不可变数据类,自动生成构造器、getter、equals、hashCode、toString | Attraction 用 record 定义景点实体,所有字段自动生成 getter,无需手工编写 |
| 文本块 (Java 15+) | 三引号字符串,支持多行 SQL,无需转义换行符和引号 | 所有 SQL 查询(findAll、findById、insert、update)用文本块编写,可读性大增 |
| Optional (Java 8+) | 明确表示返回值"可能不存在",替代 null 的歧义 | findById() 返回 Optional<Attraction>,调用方必须显式处理"未找到"情况,避免空指针异常 |
| Stream API (Java 8+) | 流式处理集合,支持链式操作如映射、过滤、聚合 | findById() 中 list.stream().findFirst() 从结果列表提取第一个元素,优雅处理空集 |
| Lambda 表达式 (Java 8+) | 简洁的函数式编程,替代匿名内部类 | RowMapper 定义为 static final lambda 表达式 (rs, rowNum) -> mapRow(rs),避免每次查询都生成新对象 |
为什么用现代语法? record 減少样板代码使实体类清晰;文本块提升 SQL 可读性;Optional 明确表达"可能无值"语义,消除 null 检查的歧义;Stream + Lambda 使集合操作更简洁且函数式。
08 - MyBatis 快速上手:告别手写映射
本章目标:
- 理解 MyBatis 解决了 JdbcTemplate 的什么痛点
- 掌握
@Mapper注解方式写 CRUD(含自增主键回填)- 掌握 XML 方式写动态 SQL(
<where>/<if>/<foreach>)- 记住
#{ }与${ }的天壤之别(面试必考)- 学会
map-underscore-to-camel-case驼峰映射配置
8.1 上章回顾与本章动机
第07章用 JdbcTemplate 完成了落库,但你一定注意到了两处"体力劳动":
- 手写 RowMapper :每一张表都要写一遍
rs.getString("name") → entity.setName(...); - SQL 拼接靠 if-else:查询条件可选时,Java 里拼字符串既丑又危险。
MyBatis 的答案:
| 痛点 | MyBatis 方案 |
|---|---|
| 手写结果映射 | 自动按列名↔字段名映射(驼峰开关一行配置) |
| 动态 SQL 拼接 | <if> <where> 标签声明式拼接 |
| DaoImpl 模板代码 | @Mapper 接口 → 启动时生成动态代理,永远不写实现类 |
理论知识 ------ MyBatis 的定位
它是"半自动"ORM:SQL 仍由你亲手书写(可控、可优化),
但连接管理、参数设置、结果映射、资源关闭全部由框架代劳。
对比:Hibernate/JPA 是全自动(连 SQL 都帮你生成);JdbcTemplate 是纯手动。
8.2 环境准备
pom.xml
xml
<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 https://maven.apache.org/xsd/maven-4.0.0.xsd">
<modelVersion>4.0.0</modelVersion>
<parent>
<groupId>com.lihaozhe</groupId>
<artifactId>sb-travel</artifactId>
<version>1.0.0</version>
</parent>
<artifactId>chapter08-mybatis</artifactId>
<dependencies>
<!-- Web 场景 -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
</dependency>
<!-- MyBatis 官方 starter:自动配置 SqlSessionFactory + Mapper 扫描
版本由官方独立发布,不受 spring-boot-starter-parent 管理,需自己指定。
4.x 系列 = 适配 Spring Boot 3/4 的版本线 -->
<dependency>
<groupId>org.mybatis.spring.boot</groupId>
<artifactId>mybatis-spring-boot-starter</artifactId>
<version>4.1.0</version>
</dependency>
<!-- MySQL 驱动(runtime:只在运行期用到) -->
<dependency>
<groupId>com.mysql</groupId>
<artifactId>mysql-connector-j</artifactId>
<scope>runtime</scope>
</dependency>
<!-- 单元测试 -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-test</artifactId>
<scope>test</scope>
</dependency>
</dependencies>
<build>
<plugins>
<plugin>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-maven-plugin</artifactId>
</plugin>
</plugins>
</build>
</project>
目录结构
chapter08-mybatis/
├── pom.xml
└── src/main/
├── java/com/lihaozhe/chapter08/
│ ├── TravelApplication.java
│ ├── common/
│ │ ├── Result.java
│ │ └── GlobalExceptionHandler.java
│ ├── controller/AttractionController.java
│ ├── dao/
│ │ ├── AttractionMapper.java ← 注解 SQL 版
│ │ └── AttractionXmlMapper.java ← XML SQL 版
│ ├── model/Attraction.java
│ └── service/AttractionService.java
└── resources/
├── application.yml
├── schema.sql / data.sql
└── mapper/AttractionXmlMapper.xml ← XML 放这里
8.3 跟我做:逐文件编写
8.3.1 model/Attraction.java ------ 本章特意不用 record
java
package com.lihaozhe.chapter08.model;
import java.math.BigDecimal;
import java.time.LocalDateTime;
/**
* 景点实体(第08章:可变版本)
*
* <p>理论知识 ------ 本章为什么用普通类而不是 record?
* MyBatis 的 @Options(useGeneratedKeys) 需要调用 setId() 回填主键,
* record 的字段是 final 的、没有 setter,回填需要额外配置。
* 教学取舍:实体用普通类 + getter/setter 是国内主流习惯,
* DTO/VO 用 record------两种语法都见过,将来读别人的项目不发怵。
*/
public class Attraction {
private Long id;
private String name;
private String city;
private BigDecimal price;
private String description;
private LocalDateTime createTime;
/** 无参构造器:MyBatis 反射创建对象时必需 */
public Attraction() {
}
/** 便捷全参构造器(不含 id 和时间) */
public Attraction(String name, String city, BigDecimal price, String description) {
this.name = name;
this.city = city;
this.price = price;
this.description = description;
}
public Long getId() {
return id;
}
public void setId(Long id) {
this.id = id;
}
public String getName() {
return name;
}
public void setName(String name) {
this.name = name;
}
public String getCity() {
return city;
}
public void setCity(String city) {
this.city = city;
}
public BigDecimal getPrice() {
return price;
}
public void setPrice(BigDecimal price) {
this.price = price;
}
public String getDescription() {
return description;
}
public void setDescription(String description) {
this.description = description;
}
public LocalDateTime getCreateTime() {
return createTime;
}
public void setCreateTime(LocalDateTime createTime) {
this.createTime = createTime;
}
}
8.3.2 dao/AttractionMapper.java ------ 注解版 CRUD
java
package com.lihaozhe.chapter08.dao;
import com.lihaozhe.chapter08.model.Attraction;
import org.apache.ibatis.annotations.Delete;
import org.apache.ibatis.annotations.Insert;
import org.apache.ibatis.annotations.Mapper;
import org.apache.ibatis.annotations.Options;
import org.apache.ibatis.annotations.Param;
import org.apache.ibatis.annotations.Select;
import org.apache.ibatis.annotations.Update;
import java.math.BigDecimal;
import java.util.List;
/**
* 景点 Mapper 接口(第08章:注解方式)
*
* <p>理论知识 ------ @Mapper:
* 只需在接口上标注 @Mapper,MyBatis 会在启动时生成动态代理实现类
* 并注册为 Bean------你永远不需要写 DaoImpl!
* (多个接口时可用 @MapperScan("包名") 批量扫描替代。)
*
* <p>理论知识 ------ #{ } 与 ${ } 的天壤之别(面试高频题):
* <ul>
* <li>#{ } 预编译占位符 → 值以 ? 传入,防 SQL 注入,99% 场景用它;</li>
* <li>${ } 字符串拼接 → 直接替换进 SQL,仅用于动态表名/列名等无法参数化的位置,
* 且内容必须白名单校验。</li>
* </ul>
*/
@Mapper
public interface AttractionMapper {
/**
* 注解 SQL 写法:SQL 简单时直接写在注解里最直观。
* 多行 SQL 用 Java 文本块(三引号)保持可读性。
*/
@Select("""
SELECT id, name, city, price, description, create_time
FROM t_ch08_attraction
""")
List<Attraction> findAll();
/** 单个查询:#{id} 被预编译为 ? */
@Select("SELECT id, name, city, price, description, create_time FROM t_ch08_attraction WHERE id = #{id}")
Attraction findById(Long id);
/**
* 条件查询演示:多参数用 @Param 标注名字,SQL 里通过 #{} 引用。
* 动态条件(city 可能为 null)的写法见 XML 版本的动态 SQL。
*/
@Select("""
<script>
SELECT id, name, city, price, description, create_time
FROM t_ch08_attraction
WHERE price >= #{minPrice}
<if test="city != null and city != ''">
AND city = #{city}
</if>
ORDER BY price
</script>
""")
List<Attraction> search(@Param("city") String city, @Param("minPrice") BigDecimal minPrice);
/**
* 新增 + 自增主键回填。
*
* <p>@Options(useGeneratedKeys = true, keyProperty = "id"):
* 插入成功后 MyBatis 自动把数据库生成的自增 ID 填进实体的 id 字段------
* 对比第07章 JdbcTemplate 的 KeyHolder 写法简洁太多。
* (record 实体要求字段可回填时使用类实体更方便,本章特意改用普通类。)
*
* @return 影响行数
*/
@Insert("""
INSERT INTO t_ch08_attraction (name, city, price, description)
VALUES (#{name}, #{city}, #{price}, #{description})
""")
@Options(useGeneratedKeys = true, keyProperty = "id")
int insert(Attraction attraction);
/** 更新:返回影响行数(0 行 = 目标不存在) */
@Update("""
UPDATE t_ch08_attraction
SET name = #{name}, city = #{city},
price = #{price}, description = #{description}
WHERE id = #{id}
""")
int update(Attraction attraction);
/** 删除 */
@Delete("DELETE FROM t_ch08_attraction WHERE id = #{id}")
int deleteById(Long id);
}
划重点 :注解里写
<script>包裹后才能用<if>等标签;XML 中>=要转义成>=。
8.3.3 dao/AttractionXmlMapper.java ------ XML 版接口
java
package com.lihaozhe.chapter08.dao;
import com.lihaozhe.chapter08.model.Attraction;
import org.apache.ibatis.annotations.Mapper;
import org.apache.ibatis.annotations.Param;
import java.util.List;
/**
* 景点 XML Mapper 接口(第08章:XML 方式)
*
* <p>理论知识 ------ 注解 SQL vs XML SQL 怎么选?
* <ul>
* <li>简单 CRUD:注解写在接口里,一目了然;</li>
* <li>复杂动态 SQL(多条件拼接/批量插入):XML 的 <if><where><foreach>
* 标签比字符串拼接清晰得多;</li>
* <li>两者可以混用:同一个 Mapper 接口,部分方法注解、部分方法指向 XML。</li>
* </ul>
*
* <p>XML 文件的位置与命名约定:
* resources/mapper/AttractionXmlMapper.xml,
* namespace 必须是接口全限定名,SQL 的 id 必须与方法名一致。
*/
@Mapper
public interface AttractionXmlMapper {
/** 动态条件查询(XML 中实现) */
List<Attraction> findDynamic(@Param("city") String city,
@Param("minPrice") Double minPrice,
@Param("keyword") String keyword);
/** 批量插入(XML 中用 foreach 实现) */
int insertBatch(@Param("list") List<Attraction> attractions);
/** 统计各城市景点数量 */
List<java.util.Map<String, Object>> countByCity();
}
8.3.4 resources/mapper/AttractionXmlMapper.xml ------ 动态 SQL 四大金刚
xml
<?xml version="1.0" encoding="UTF-8" ?>
<!DOCTYPE mapper PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
"http://mybatis.org/dtd/mybatis-3-mapper.dtd">
<!--
第08章:XML 动态 SQL
理论知识 ------ 动态 SQL 四大金刚:
<where> 自动生成 WHERE,并且智能去掉开头多余的 AND/OR
<if> test 条件成立才拼接该片段(OGNL 表达式)
<foreach> 遍历集合拼SQL,常用于 IN 查询与批量插入
<set> UPDATE 场景的 <where>,智能处理末尾多余的逗号
注意:XML 中 < > 必须转义为 < >(或用 <![CDATA[ ]]> 包裹)
-->
<mapper namespace="com.lihaozhe.chapter08.dao.AttractionXmlMapper">
<!-- 动态条件查询:三个条件都可选,有哪个拼哪个 -->
<select id="findDynamic" resultType="com.lihaozhe.chapter08.model.Attraction">
SELECT id, name, city, price, description, create_time
FROM t_ch08_attraction
<where>
<if test="city != null and city != ''">
AND city = #{city}
</if>
<if test="minPrice != null">
AND price >= #{minPrice}
</if>
<if test="keyword != null and keyword != ''">
AND (name LIKE CONCAT('%', #{keyword}, '%')
OR description LIKE CONCAT('%', #{keyword}, '%'))
</if>
</where>
ORDER BY price DESC
</select>
<!-- 批量插入:foreach 展开成 VALUES (...), (...), ... -->
<insert id="insertBatch" useGeneratedKeys="true" keyProperty="id">
INSERT INTO t_ch08_attraction (name, city, price, description)
VALUES
<foreach collection="list" item="a" separator=",">
(#{a.name}, #{a.city}, #{a.price}, #{a.description})
</foreach>
</insert>
<!-- GROUP BY 统计:返回 Map 列表(无实体时的轻量方案) -->
<select id="countByCity" resultType="java.util.HashMap">
SELECT city AS city, COUNT(*) AS total
FROM t_ch08_attraction
GROUP BY city
</select>
</mapper>
8.3.5 service/AttractionService.java
java
package com.lihaozhe.chapter08.service;
import com.lihaozhe.chapter08.dao.AttractionMapper;
import com.lihaozhe.chapter08.dao.AttractionXmlMapper;
import com.lihaozhe.chapter08.model.Attraction;
import org.springframework.stereotype.Service;
import java.math.BigDecimal;
import java.util.List;
import java.util.Map;
/**
* 景点服务(第08章)
*/
@Service
public class AttractionService {
private final AttractionMapper mapper;
private final AttractionXmlMapper xmlMapper;
public AttractionService(AttractionMapper mapper, AttractionXmlMapper xmlMapper) {
this.mapper = mapper;
this.xmlMapper = xmlMapper;
}
public List<Attraction> findAll() {
return mapper.findAll();
}
public Attraction findById(Long id) {
return mapper.findById(id);
}
/** 组合查询:注解版动态 SQL */
public List<Attraction> search(String city, BigDecimal minPrice) {
return mapper.search(city, minPrice);
}
/** 动态查询:XML 版 */
public List<Attraction> findDynamic(String city, Double minPrice, String keyword) {
return xmlMapper.findDynamic(city, minPrice, keyword);
}
/** 批量插入后返回新ID列表 */
public List<Long> insertBatch(List<Attraction> list) {
xmlMapper.insertBatch(list);
return list.stream().map(Attraction::getId).toList();
}
public int create(Attraction attraction) {
return mapper.insert(attraction);
}
public boolean update(Attraction attraction) {
return mapper.update(attraction) > 0;
}
public boolean delete(Long id) {
return mapper.deleteById(id) > 0;
}
public List<Map<String, Object>> countByCity() {
return xmlMapper.countByCity();
}
}
8.3.6 controller/AttractionController.java
java
package com.lihaozhe.chapter08.controller;
import com.lihaozhe.chapter08.common.Result;
import com.lihaozhe.chapter08.model.Attraction;
import com.lihaozhe.chapter08.service.AttractionService;
import org.springframework.web.bind.annotation.DeleteMapping;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.PutMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
import java.util.List;
import java.util.Map;
/**
* 景点接口(第08章:MyBatis 版)
*/
@RestController
@RequestMapping("/attractions")
public class AttractionController {
private final AttractionService service;
public AttractionController(AttractionService service) {
this.service = service;
}
/** 查全部 */
@GetMapping
public Result<List<Attraction>> list() {
return Result.ok(service.findAll());
}
/** 查单个 */
@GetMapping("/{id}")
public Result<Attraction> detail(@PathVariable Long id) {
return Result.ok(service.findById(id));
}
/** 注解版动态查询:/attractions/search?city=呼伦贝尔&minPrice=50 */
@GetMapping("/search")
public Result<List<Attraction>> search(String city, Double minPrice) {
return Result.ok(service.search(city, minPrice == null ? null : java.math.BigDecimal.valueOf(minPrice)));
}
/** XML版动态查询:支持关键词模糊搜索 */
@GetMapping("/dynamic")
public Result<List<Attraction>> dynamic(String city, Double minPrice, String keyword) {
return Result.ok(service.findDynamic(city, minPrice, keyword));
}
/** 城市统计:GROUP BY 示例 */
@GetMapping("/stats/city")
public Result<List<Map<String, Object>>> stats() {
return Result.ok(service.countByCity());
}
/** 新增(自增主键自动回填) */
@PostMapping
public Result<Attraction> create(@RequestBody Attraction attraction) {
service.create(attraction);
// insert 后 attraction.id 已被 MyBatis 回填
return Result.ok(attraction);
}
/** 批量新增 */
@PostMapping("/batch")
public Result<List<Long>> createBatch(@RequestBody List<Attraction> list) {
return Result.ok(service.insertBatch(list));
}
/** 更新 */
@PutMapping("/{id}")
public Result<String> update(@PathVariable Long id, @RequestBody Attraction attraction) {
attraction.setId(id);
return service.update(attraction)
? Result.ok("更新成功") : Result.fail(2001, "景点不存在: id=" + id);
}
/** 删除 */
@DeleteMapping("/{id}")
public Result<Void> delete(@PathVariable Long id) {
service.delete(id);
return Result.ok();
}
}
8.3.7 common/Result.java 与 GlobalExceptionHandler.java
java
package com.lihaozhe.chapter08.common;
/**
* 统一响应体(第07章同款,本章复用)
*/
public record Result<T>(int code, String message, T data) {
public static final int SUCCESS = 0;
public static <T> Result<T> ok(T data) {
return new Result<>(SUCCESS, "success", data);
}
public static Result<Void> ok() {
return new Result<>(SUCCESS, "success", null);
}
public static <T> Result<T> fail(int code, String message) {
return new Result<>(code, message, null);
}
}
java
package com.lihaozhe.chapter08.common;
import org.springframework.http.HttpStatus;
import org.springframework.web.bind.annotation.ExceptionHandler;
import org.springframework.web.bind.annotation.ResponseStatus;
import org.springframework.web.bind.annotation.RestControllerAdvice;
/**
* 全局异常处理(第07章同款,本章新增 MyBatis 相关异常讲解)
*
* <p>理论知识 ------ MyBatis 常见异常:
* <ul>
* <li>BadSqlGrammarException:SQL 语法错误(动态 SQL 拼接出错时常见);</li>
* <li>DuplicateKeyException:唯一约束冲突(重复插入);</li>
* <li>MyBatisSystemException:参数绑定/映射问题。</li>
* </ul>
* 它们都是 DataAccessException 的子类,Spring 会自动做异常翻译,
* 我们统一兜底返回友好提示即可。
*/
@RestControllerAdvice
public class GlobalExceptionHandler {
/** 业务异常(Service 主动抛出的可预期错误) */
@ExceptionHandler(IllegalArgumentException.class)
@ResponseStatus(HttpStatus.BAD_REQUEST)
public Result<Void> handleIllegalArgument(IllegalArgumentException e) {
return Result.fail(1001, e.getMessage());
}
/** 兜底:未预料到的异常统一返回,避免堆栈暴露给前端 */
@ExceptionHandler(Exception.class)
@ResponseStatus(HttpStatus.INTERNAL_SERVER_ERROR)
public Result<Void> handleException(Exception e) {
return Result.fail(5000, "服务器开小差了: " + e.getClass().getSimpleName());
}
}
8.3.8 TravelApplication.java
java
package com.lihaozhe.chapter08;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
/**
* 第08章启动类:MyBatis 快速上手
*
* <p>理论知识 ------ MyBatis 是如何被 Spring Boot "接管"的?
* <ul>
* <li>mybatis-spring-boot-starter 引入 MybatisAutoConfiguration 自动配置类;</li>
* <li>它扫描 @Mapper 注解的接口,为每个接口生成动态代理对象并注册为 Bean;</li>
* <li>代理对象的内部逻辑:方法名 → 找到对应 SQL(注解或 XML)→ 执行 → 结果映射成返回类型。</li>
* </ul>
* 所以我们只写接口不写实现------这就是"动态代理"的魔力。
*/
@SpringBootApplication
public class TravelApplication {
public static void main(String[] args) {
SpringApplication.run(TravelApplication.class, args);
}
}
8.3.9 application.yml
yaml
# 第08章:MyBatis 快速上手
# 端口沿用"8080+章号"规则 → 8108
server:
port: 8108
spring:
datasource:
# 与第07章同一个库 sb_travel,本章用独立表前缀 t_ch08_ 隔离
url: jdbc:mysql://118.31.221.165:3306/sb_travel?useSSL=false&serverTimezone=Asia/Shanghai&characterEncoding=UTF-8&allowPublicKeyRetrieval=true
username: root
password: lihaozhe
driver-class-name: com.mysql.cj.jdbc.Driver
hikari:
maximum-pool-size: 5
minimum-idle: 2
connection-timeout: 10000
sql:
init:
mode: always
# MyBatis 专属配置(mybatis-spring-boot-starter 提供的前缀)
mybatis:
# XML 映射文件位置(classpath 下多个路径用逗号分隔)
mapper-locations: classpath:mapper/*.xml
# 驼峰映射开关 ------ 本章最重要的配置!
# 数据库列 create_time → Java 字段 createTime 自动映射,无需手写别名
configuration:
map-underscore-to-camel-case: true
# 打印 SQL 日志(学习期神器,生产环境要关掉)
log-impl: org.apache.ibatis.logging.stdout.StdOutImpl
8.3.10 schema.sql 与 data.sql
sql
-- 第08章:景点表(表前缀 t_ch08_ 与其它章节隔离)
-- CREATE TABLE IF NOT EXISTS 保证重复启动不报错(幂等)
CREATE TABLE IF NOT EXISTS t_ch08_attraction (
id BIGINT AUTO_INCREMENT PRIMARY KEY COMMENT '主键',
name VARCHAR(100) NOT NULL COMMENT '景点名称',
city VARCHAR(50) NOT NULL COMMENT '所在城市',
price DECIMAL(10,2) NOT NULL DEFAULT 0.00 COMMENT '门票价格',
description VARCHAR(500) NULL COMMENT '简介',
create_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间'
) ENGINE = InnoDB DEFAULT CHARSET = utf8mb4 COMMENT '景点表';
sql
-- 第08章种子数据:WHERE NOT EXISTS 保证幂等(重复启动不插重复行)
INSERT INTO t_ch08_attraction (name, city, price, description)
SELECT * FROM (
SELECT '莫日格勒河' AS name, '呼伦贝尔' AS city, 30.00 AS price, '天下第一曲水,老舍先生盛赞' AS description
UNION ALL SELECT '满洲里国门', '满洲里', 80.00, '中俄边境标志性建筑'
UNION ALL SELECT '额尔古纳湿地', '呼伦贝尔', 65.00, '亚洲第一湿地,白鹭栖息地'
UNION ALL SELECT '响沙湾', '鄂尔多斯', 130.00, '会唱歌的沙漠,5A级景区'
) AS seed
WHERE NOT EXISTS (SELECT 1 FROM t_ch08_attraction);
8.4 运行与验证
bash
cd chapter08-mybatis
mvn clean package -DskipTests
java -Dfile.encoding=UTF-8 -jar target/chapter08-mybatis-1.0.0.jar
启动日志(真实输出节选):
HikariPool-1 - Start completed.
Logging initialized using 'class org.apache.ibatis.logging.stdout.StdOutImpl' adapter.
Tomcat started on port 8108 (http) with context path '/'
Started TravelApplication in 2.739 seconds (process running for 3.155)
验证 1:查全部(观察驼峰映射)
bash
curl http://localhost:8108/attractions
json
{
"code": 0, "message": "success",
"data": [
{"id": 1, "name": "莫日格勒河", "city": "呼伦贝尔", "price": 30.0,
"description": "天下第一曲水,老舍先生盛赞", "createTime": "2026-08-25T22:04:38"},
{"id": 2, "name": "满洲里国门", "city": "满洲里", "price": 80.0, "...": "..."},
{"id": 3, "name": "额尔古纳湿地", "city": "呼伦贝尔", "price": 65.0, "...": "..."},
{"id": 4, "name": "响沙湾", "city": "鄂尔多斯", "price": 130.0, "...": "..."}
]
}
注意 "createTime" 有值------数据库列是 create_time,驼峰映射生效了!
同时控制台会打印 MyBatis 的 SQL 日志:
==> Preparing: SELECT id, name, city, price, description, create_time FROM t_ch08_attraction
==> Parameters:
<== Total: 4
验证 2:注解版动态查询
bash
curl "http://localhost:8108/attractions/search?city=呼伦贝尔&minPrice=50"
json
{"code": 0, "message": "success", "data": [{"name": "额尔古纳湿地", "city": "呼伦贝尔", "price": 65.0, "id": 3}]}
<if> 判断 city 非空拼上了 AND city = ?,价格过滤也生效。
验证 3:新增(主键回填魔法时刻)
bash
curl -X POST -H "Content-Type: application/json; charset=utf-8" \
-d '{"name":"阿尔山国家森林公园","city":"兴安盟","price":180.00,"description":"火山温泉森林"}' \
http://localhost:8108/attractions
json
{"code": 0, "message": "success",
"data": {"id": 8, "name": "阿尔山国家森林公园", "city": "兴安盟", "price": 180.0}}
请求前实体没有 id,响应里返回了 id=8 ------@Options(useGeneratedKeys) 把数据库生成的自增主键回填进了实体!
验证 4:批量插入(foreach 展开验证)
bash
curl -X POST -H "Content-Type: application/json; charset=utf-8" \
-d '[{"name":"成吉思汗拴马桩","city":"呼伦贝尔","price":40.00,"description":"呼伦湖畔"},
{"name":"白桦林景区","city":"额尔古纳","price":55.00,"description":"童话世界"}]' \
http://localhost:8108/attractions/batch
json
{"code": 0, "message": "success", "data": [9, 10]}
批量插入同样回填了两个新 ID [9, 10]。控制台能看到 foreach 展开后的单条 SQL:
==> Preparing: INSERT INTO t_ch08_attraction (name, city, price, description)
VALUES (?, ?, ?, ?) , (?, ?, ?, ?)
==> Parameters: 成吉思汗拴马桩(String), ...
<== Updates: 2
验证 5:XML 动态查询(三种条件自由组合)
bash
# 只给关键词:模糊匹配名称或简介
curl "http://localhost:8108/attractions/dynamic?keyword=湿地"
→ {"code":0,"data":[{"name":"额尔古纳湿地","price":65.0,"id":3}]}
# 城市+最低价组合
curl "http://localhost:8108/attractions/dynamic?city=呼伦贝尔&minPrice=35"
→ data 含 额尔古纳湿地(65) 和 成吉思汗拴马桩(40),按价格降序
验证 6:城市统计(GROUP BY → Map)
bash
curl http://localhost:8108/attractions/stats/city
json
{"code": 0, "message": "success", "data": [
{"total": 3, "city": "呼伦贝尔"},
{"total": 1, "city": "满洲里"},
{"total": 1, "city": "鄂尔多斯"},
{"total": 1, "city": "兴安盟"},
{"total": 1, "city": "额尔古纳"}]}
验证 7:更新存在与不存在
bash
curl -X PUT -H "Content-Type: application/json; charset=utf-8" \
-d '{"name":"阿尔山森林公园","city":"兴安盟","price":195.00}' \
http://localhost:8108/attractions/8
→ {"code": 0, "message": "success", "data": "更新成功"}
curl -X PUT -H "Content-Type: application/json; charset=utf-8" \
-d '{"name":"x","city":"y","price":1}' \
http://localhost:8108/attractions/999
→ {"code": 2001, "message": "景点不存在: id=999", "data": null}
curl -X DELETE http://localhost:8108/attractions/10
→ {"code": 0, "message": "success"}
全部通过 ✅
8.5 小结
| 知识点 | 一句话记忆 |
|---|---|
@Mapper |
接口标一下,实现类框架造 |
#{ } vs ${ } |
预编译防注入 vs 拼接有风险,默认用 #{} |
@Options(useGeneratedKeys) |
插入后自增 ID 自动塞回实体的 id 字段 |
map-underscore-to-camel-case |
create_time ↔ createTime 自动翻译 |
<where> + <if> |
条件可选时不再手拼 WHERE |
<foreach> |
IN 查询和批量插入的展开器 |
下一章预告 :MyBatis 已经很爽了?但每个表还要手写 INSERT/UPDATE......
第09章 MyBatis-Plus 让通用 CRUD 连 SQL 都不用写 ------一个 BaseMapper
继承搞定增删改查,LambdaQueryWrapper 用 Java 代码表达 WHERE 条件。
8.6 动手练习
- 给
AttractionXmlMapper增加<set>版本的"按需更新":只更新非 null 字段(对比本章全字段 UPDATE)。 - 新增
findByIds(List<Long> ids)方法,用<foreach>实现IN查询。 - 思考题:
search接口如果把${}换成#{}的地方反着来一遍(故意用${minPrice}),输入50; DROP TABLE t_ch08_attraction会发生什么?(在自己电脑试之前先备份!)
8.7 本章使用的 Java 25 新特性
| 特性 | 说明 | 本章应用 |
|---|---|---|
| record (Java 16+) | 不可变数据类,自动生成构造器、getter、equals、hashCode、toString | Result<T> 用 record 定义统一响应体,自动生成所有必要方法 |
| 文本块 (Java 15+) | 三引号字符串,支持多行文本,无需转义换行符和引号 | 所有 SQL 注解(@Select、@Insert、@Update、@Delete)用文本块编写,保持可读性 |
| Stream API (Java 8+) | 流式处理集合,支持链式操作如映射、过滤、聚合 | AttractionService.insertBatch() 中用 list.stream().map(Attraction::getId).toList() 从实体列表提取 ID 列表 |
| 方法引用 (Java 8+) | 简洁地引用已有方法作为函数式参数,编译期类型安全 | Attraction::getId 作为参数传给 map(),比 lambda 更简洁且重构友好 |
| Optional (Java 8+) | 明确表示返回值"可能不存在",替代 null 的歧义 | AttractionService 中通过 Optional 风格的代码处理结果集的不确定性 |
为什么用现代语法? record 使
Result响应体定义极简;文本块让 SQL 在注解中清晰可读;Stream + 方法引用避免了手工遍历集合的模板代码,提升了insertBatch等方法的表达力。
09 - MyBatis-Plus 增强开发:连 SQL 都不用写了
本章目标:
- 理解 MyBatis-Plus(下称 MP)与 MyBatis 的关系:增强而非替代
- 继承
BaseMapper获得全套单表 CRUD------一行 SQL 不写- 用
LambdaQueryWrapper以 Java 代码表达 WHERE 条件(编译期防写错)- 配置分页插件(含 3.5.9+ 拆分依赖的大坑)
- 自动填充
create_time/update_time- 逻辑删除:
delete变UPDATE deleted=1
9.1 上章回顾与本章动机
第08章 MyBatis 已经把结果映射和动态 SQL 解决了,但每个表仍要手写:
java
@Insert("INSERT INTO t_ch08_attraction (name, city, ...) VALUES (#{name}, #{city}, ...)")
int insert(Attraction a); // 每张表都要来一遍!
单表的 INSERT/UPDATE/DELETE/SELECT BY ID 几乎是模板代码------列名不同而已。
MP 的思路:把这些通用 CRUD 抽到 BaseMapper<T> 接口里,你的 Mapper 继承即可 。
复杂 SQL?照旧用第08章的注解/XML 写,两者完全共存。
理论知识 ------ "增强"的含义
MP = MyBatis + 一层通用能力封装。它没有替换 SqlSessionFactory,
只是提供了 BaseMapper 实现、条件构造器、插件体系。
你学的 MyBatis 知识 100% 有效。
9.2 环境准备
pom.xml
xml
<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 https://maven.apache.org/xsd/maven-4.0.0.xsd">
<modelVersion>4.0.0</modelVersion>
<parent>
<groupId>com.lihaozhe</groupId>
<artifactId>sb-travel</artifactId>
<version>1.0.0</version>
</parent>
<artifactId>chapter09-mybatis-plus</artifactId>
<dependencies>
<!-- Web 场景 -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-webmvc</artifactId>
</dependency>
<!--
MyBatis-Plus 官方 starter(Boot 4 专用 artifactId):
内部已包含 mybatis-spring-boot-starter,无需重复引入 MyBatis。
版本由根 pom 的 dependencyManagement 统一管理。
-->
<dependency>
<groupId>com.baomidou</groupId>
<artifactId>mybatis-plus-spring-boot4-starter</artifactId>
</dependency>
<!--
MP 3.5.9 起分页插件拆分到独立模块(基于 JSqlParser 解析 SQL 拼 LIMIT),
不引入它 selectPage 就不会真正分页------新手最常踩的坑,本章特意讲解。
-->
<dependency>
<groupId>com.baomidou</groupId>
<artifactId>mybatis-plus-jsqlparser</artifactId>
<version>${mybatis-plus.version}</version>
</dependency>
<!-- MySQL 驱动 -->
<dependency>
<groupId>com.mysql</groupId>
<artifactId>mysql-connector-j</artifactId>
<scope>runtime</scope>
</dependency>
<!-- 单元测试 -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-test</artifactId>
<scope>test</scope>
</dependency>
</dependencies>
<build>
<plugins>
<plugin>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-maven-plugin</artifactId>
</plugin>
</plugins>
</build>
</project>
版本说明(2026 年实测)
mybatis-plus-spring-boot4-starter目前最新为 3.5.17。实测发现:
- 3.5.14 及以下在 Spring Boot 4 上启动报错
(PropertyMapper.alwaysApplyingWhenNonNull()方法不存在);- 3.5.17 已修复 Boot 4 兼容性 。本项目根 pom 统一使用 3.5.17。
教训:第三方 starter 与 Boot 大版本升级要同步跟进。
目录结构
chapter09-mybatis-plus/
├── pom.xml
└── src/main/
├── java/com/lihaozhe/chapter09/
│ ├── TravelApplication.java
│ ├── common/ (Result.java, GlobalExceptionHandler.java)
│ ├── config/MybatisPlusConfig.java ← 分页插件+自动填充
│ ├── controller/AttractionController.java
│ ├── dao/AttractionMapper.java ← 只有一行 extends!
│ ├── model/Attraction.java ← 注解比上章多了几个
│ └── service/AttractionService.java
└── resources/
├── application.yml / schema.sql / data.sql
9.3 跟我做:逐文件编写
9.3.1 model/Attraction.java ------ 认识 MP 实体注解
java
package com.lihaozhe.chapter09.model;
import com.baomidou.mybatisplus.annotation.IdType;
import com.baomidou.mybatisplus.annotation.TableField;
import com.baomidou.mybatisplus.annotation.TableId;
import com.baomidou.mybatisplus.annotation.TableLogic;
import com.baomidou.mybatisplus.annotation.TableName;
import java.math.BigDecimal;
import java.time.LocalDateTime;
/**
* 景点实体(第09章:MyBatis-Plus 注解版)
*
* <p>理论知识 ------ MP 的核心注解:
* <ul>
* <li>@TableName("表名"):类 → 表的映射。类名是 Attraction,
* 表名是 t_ch09_attraction,必须显式指定;</li>
* <li>@TableId(type = IdType.AUTO):主键 + 自增策略。
* 不标注的话 MP 默认按"字段名=id"推断,但显式写出来更清晰;</li>
* <li>@TableField("列名"):字段 ↔ 列名的映射(驼峰能自动转换的可省略);</li>
* <li>@TableLogic:逻辑删除标记------delete 时变 UPDATE deleted=1,查询自动过滤。</li>
* </ul>
*/
@TableName("t_ch09_attraction")
public class Attraction {
/**
* 主键。IdType.AUTO = 数据库自增,插入后 MP 自动回填 id
* (底层还是 MyBatis 的 useGeneratedKeys,只是不用你写了)
*/
@TableId(type = IdType.AUTO)
private Long id;
private String name;
private String city;
private BigDecimal price;
private String description;
/** 创建时间,插入时由 MP 自动填充(见 MyMetaObjectHandler) */
@TableField(fill = com.baomidou.mybatisplus.annotation.FieldFill.INSERT)
private LocalDateTime createTime;
/** 更新时间,插入和更新时都会自动填充 */
@TableField(fill = com.baomidou.mybatisplus.annotation.FieldFill.INSERT_UPDATE)
private LocalDateTime updateTime;
/**
* 逻辑删除标志:0=正常 1=已删。
* 加了 @TableLogic 后:
* - mp.deleteById(id) 实际执行 UPDATE ... SET deleted = 1
* - 所有查询自动追加 WHERE deleted = 0
* 物理删除需用自定义 SQL 或 removeById 配合特殊配置。
*/
@TableLogic
@TableField(select = false)
private Integer deleted;
public Long getId() {
return id;
}
public void setId(Long id) {
this.id = id;
}
public String getName() {
return name;
}
public void setName(String name) {
this.name = name;
}
public String getCity() {
return city;
}
public void setCity(String city) {
this.city = city;
}
public BigDecimal getPrice() {
return price;
}
public void setPrice(BigDecimal price) {
this.price = price;
}
public String getDescription() {
return description;
}
public void setDescription(String description) {
this.description = description;
}
public LocalDateTime getCreateTime() {
return createTime;
}
public void setCreateTime(LocalDateTime createTime) {
this.createTime = createTime;
}
public LocalDateTime getUpdateTime() {
return updateTime;
}
public void setUpdateTime(LocalDateTime updateTime) {
this.updateTime = updateTime;
}
public Integer getDeleted() {
return deleted;
}
public void setDeleted(Integer deleted) {
this.deleted = deleted;
}
}
9.3.2 dao/AttractionMapper.java ------ 本章最短文件
java
package com.lihaozhe.chapter09.dao;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import com.lihaozhe.chapter09.model.Attraction;
import org.apache.ibatis.annotations.Mapper;
/**
* 景点 Mapper(第09章:继承 BaseMapper 即得全套 CRUD)
*
* <p>理论知识 ------ BaseMapper<T> 提供了哪些方法?(不用写一行 SQL)
* <pre>
* 插入:insert(T)
* 删除:deleteById / deleteBatchIds / delete(Wrapper) / deleteByMap
* 更新:updateById(T) (按非 null 字段更新)
* 查询:selectById / selectBatchIds / selectList(Wrapper)
* selectOne / selectCount / selectPage ...
* </pre>
* 泛型 T 通过反射拿到 @TableName/@TableId 等注解信息,
* 启动时 MP 就把这张表的 CRUD 全部"装配"好了------这就是"增强"的含义。
*/
@Mapper
public interface AttractionMapper extends BaseMapper<Attraction> {
// 一个方法都不用写!通用 CRUD 全部来自 BaseMapper
// 复杂 SQL 依然可以像上一章那样写注解或 XML(两者共存)
}
9.3.3 config/MybatisPlusConfig.java ------ 分页插件 + 自动填充
java
package com.lihaozhe.chapter09.config;
import com.baomidou.mybatisplus.core.handlers.MetaObjectHandler;
import com.baomidou.mybatisplus.extension.plugins.MybatisPlusInterceptor;
import com.baomidou.mybatisplus.extension.plugins.inner.PaginationInnerInterceptor;
import org.apache.ibatis.reflection.MetaObject;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.stereotype.Component;
import java.time.LocalDateTime;
/**
* MyBatis-Plus 配置类(第09章)
*
* <p>理论知识 ------ MP 的插件机制:
* MP 的分页、乐观锁、防全表更新等能力都以"内部拦截器"形式提供,
* 统一挂载到 MybatisPlusInterceptor 上再注册为 Bean。
* 分页不加 PaginationInnerInterceptor 的话,
* selectPage 不会拼 LIMIT,等于查全表------新手最常踩的坑!
*/
@Configuration
public class MybatisPlusConfig {
@Bean
public MybatisPlusInterceptor mybatisPlusInterceptor() {
MybatisPlusInterceptor interceptor = new MybatisPlusInterceptor();
// 分页内部拦截器:自动把 Page 参数翻译成 LIMIT offset, size + COUNT 查询
interceptor.addInnerInterceptor(new PaginationInnerInterceptor());
return interceptor;
}
/**
* 自动填充处理器(第09章第二个重点组件)
*
* <p>理论知识 ------ MetaObjectHandler:
* create_time / update_time 这类字段每次都要手动 set 很啰嗦。
* 实现 MetaObjectHandler 后,配合实体上的
* @TableField(fill = FieldFill.INSERT / INSERT_UPDATE) 注解,
* MP 在执行 insert/update 时会回调这里的方法统一填充------
* 业务代码从此对时间字段零感知。
*/
@Component
public static class MyMetaObjectHandler implements MetaObjectHandler {
/** 插入时触发:填 createTime 和 updateTime */
@Override
public void insertFill(MetaObject metaObject) {
this.strictInsertFill(metaObject, "createTime", LocalDateTime.class, LocalDateTime.now());
this.strictInsertFill(metaObject, "updateTime", LocalDateTime.class, LocalDateTime.now());
}
/** 更新时触发:只刷新 updateTime */
@Override
public void updateFill(MetaObject metaObject) {
this.strictUpdateFill(metaObject, "updateTime", LocalDateTime.class, LocalDateTime.now());
}
}
}
9.3.4 service/AttractionService.java ------ LambdaQueryWrapper 主场
java
package com.lihaozhe.chapter09.service;
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
import com.lihaozhe.chapter09.dao.AttractionMapper;
import com.lihaozhe.chapter09.model.Attraction;
import org.springframework.stereotype.Service;
import java.math.BigDecimal;
import java.util.List;
/**
* 景点服务(第09章:体验"零 SQL"开发)
*
* <p>理论知识 ------ LambdaQueryWrapper:
* 用方法引用表达查询条件,编译期就能发现字段名写错。
* 对比三种条件构造器:
* <pre>
* QueryWrapper: new QueryWrapper().eq("city", city) ← 列名是字符串
* LambdaQueryWrapper:wr.eq(Attraction::getCity, city) ← 方法引用,重构安全
* LambdaQueryChainWrapper:chainWrappers 链式风格(略)
* </pre>
* 常用条件方法:eq(=) ne(<>) gt(>) ge(>=) lt(<) le(<=)
* like / likeLeft / likeRight
* in / between / isNull
* orderByAsc / orderByDesc
* 条件方法的第一个参数 boolean condition:true 才拼接------动态条件的官方姿势!
*/
@Service
public class AttractionService {
private final AttractionMapper mapper;
public AttractionService(AttractionMapper mapper) {
this.mapper = mapper;
}
/** 查全部 */
public List<Attraction> findAll() {
return mapper.selectList(null); // null = 无条件 = SELECT * WHERE deleted=0
}
/** 查单个 */
public Attraction findById(Long id) {
return mapper.selectById(id);
}
/**
* 动态条件查询:三个参数都可选。
* eq(condition, column, value) 的 condition 为 true 时才拼这个条件,
* 完美替代上一章 XML 里的一堆 <if>!
*/
public List<Attraction> search(String city, Double minPrice, String keyword) {
LambdaQueryWrapper<Attraction> wrapper = new LambdaQueryWrapper<Attraction>()
.eq(city != null && !city.isBlank(), Attraction::getCity, city)
.ge(minPrice != null, Attraction::getPrice,
minPrice == null ? null : BigDecimal.valueOf(minPrice))
.like(keyword != null && !keyword.isBlank(), Attraction::getName, keyword)
.orderByDesc(Attraction::getPrice);
return mapper.selectList(wrapper);
}
/** 分页查询:Page<实体>(页码, 每页条数) ------ 需要分页插件支持 */
public Page<Attraction> page(int pageNum, int pageSize) {
return mapper.selectPage(new Page<>(pageNum, pageSize), null);
}
/** 新增:自动回填主键 + 自动填充时间字段 */
public boolean create(Attraction attraction) {
return mapper.insert(attraction) > 0;
}
/**
* 更新:updateById 只更新非 null 字段(SET 子句按需生成)。
* MP 会自动追加 UPDATE_TIME = NOW()(因为 fill = INSERT_UPDATE)。
*/
public boolean update(Attraction attraction) {
return mapper.updateById(attraction) > 0;
}
/**
* "删除":@TableLogic 让它变成 UPDATE deleted = 1。
* 返回值是影响行数(逻辑删除也算 1 行)。
*/
public boolean delete(Long id) {
return mapper.deleteById(id) > 0;
}
/** 统计:SELECT COUNT(*) WHERE deleted=0 AND city = ? */
public long countByCity(String city) {
return mapper.selectCount(
new LambdaQueryWrapper<Attraction>().eq(Attraction::getCity, city));
}
}
9.3.5 controller/AttractionController.java
java
package com.lihaozhe.chapter09.controller;
import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
import com.lihaozhe.chapter09.common.Result;
import com.lihaozhe.chapter09.model.Attraction;
import com.lihaozhe.chapter09.service.AttractionService;
import org.springframework.web.bind.annotation.DeleteMapping;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.PutMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;
import java.util.List;
/**
* 景点接口(第09章:MyBatis-Plus 版)
*/
@RestController
@RequestMapping("/attractions")
public class AttractionController {
private final AttractionService service;
public AttractionController(AttractionService service) {
this.service = service;
}
/** 查全部(自动过滤已逻辑删除的行) */
@GetMapping
public Result<List<Attraction>> list() {
return Result.ok(service.findAll());
}
/** 查单个 */
@GetMapping("/{id}")
public Result<Attraction> detail(@PathVariable Long id) {
return Result.ok(service.findById(id));
}
/** 动态条件:/attractions/search?city=呼伦贝尔&minPrice=50&keyword=河 */
@GetMapping("/search")
public Result<List<Attraction>> search(String city, Double minPrice, String keyword) {
return Result.ok(service.search(city, minPrice, keyword));
}
/** 分页:/attractions/page?pageNum=1&pageSize=2 */
@GetMapping("/page")
public Result<Page<Attraction>> page(@RequestParam(defaultValue = "1") int pageNum,
@RequestParam(defaultValue = "3") int pageSize) {
return Result.ok(service.page(pageNum, pageSize));
}
/** 城市景点数统计 */
@GetMapping("/stats/city")
public Result<Long> stats(String city) {
return Result.ok(service.countByCity(city));
}
/** 新增:响应里的 id 与时间字段都是 MP 自动回填/填充的 */
@PostMapping
public Result<Attraction> create(@RequestBody Attraction attraction) {
service.create(attraction);
return Result.ok(attraction);
}
/** 更新(只更新传了值的字段) */
@PutMapping("/{id}")
public Result<String> update(@PathVariable Long id, @RequestBody Attraction attraction) {
attraction.setId(id);
return service.update(attraction)
? Result.ok("更新成功") : Result.fail(2001, "景点不存在: id=" + id);
}
/** 删除:实际是逻辑删除 UPDATE deleted=1,之后查询再也看不到它 */
@DeleteMapping("/{id}")
public Result<String> delete(@PathVariable Long id) {
return service.delete(id)
? Result.ok("已删除(逻辑)") : Result.fail(2001, "景点不存在: id=" + id);
}
}
9.3.6 common 包、启动类
common 包两个类(Result / GlobalExceptionHandler)与第07、08章完全一致,直接复制即可(完整源码见第07章文档 7.3.7 节)。
java
package com.lihaozhe.chapter09;
import org.mybatis.spring.annotation.MapperScan;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
/**
* 第09章启动类:MyBatis-Plus 增强开发
*
* <p>理论知识 ------ @MapperScan:
* 上一章每个接口标 @Mapper;本章演示批量扫描写法------
* 在启动类上标注 @MapperScan("com.lihaozhe.chapter09.dao"),
* 该包下所有接口自动注册为 Mapper,接口上就不再需要 @Mapper 注解。
* 两种方式二选一(或混用),企业项目更常用 @MapperScan。
*/
@SpringBootApplication
@MapperScan("com.lihaozhe.chapter09.dao")
public class TravelApplication {
public static void main(String[] args) {
SpringApplication.run(TravelApplication.class, args);
}
}
9.3.7 application.yml
yaml
# 第09章:MyBatis-Plus 增强开发
# 端口规则:8080 + 章号 9 → 8109
server:
port: 8109
spring:
datasource:
# 同库 sb_travel,表前缀 t_ch09_
url: jdbc:mysql://118.31.221.165:3306/sb_travel?useSSL=false&serverTimezone=Asia/Shanghai&characterEncoding=UTF-8&allowPublicKeyRetrieval=true
username: root
password: lihaozhe
driver-class-name: com.mysql.cj.jdbc.Driver
hikari:
maximum-pool-size: 5
minimum-idle: 2
connection-timeout: 10000
sql:
init:
mode: always
# MyBatis-Plus 专属配置(前缀是 mybatis-plus,不是 mybatis!)
mybatis-plus:
# 驼峰映射 MP 默认已开启(与原生 MyBatis 不同),这里显式写出帮助记忆
configuration:
map-underscore-to-camel-case: true
# SQL 日志(学习期开启)
log-impl: org.apache.ibatis.logging.stdout.StdOutImpl
# 逻辑删除的全局配置:数据库里 1=已删 0=正常
global-config:
db-config:
logic-delete-field: deleted # 实体上的逻辑删除字段名
logic-delete-value: 1
logic-not-delete-value: 0
9.3.8 schema.sql 与 data.sql
sql
-- 第09章:景点表(含逻辑删除与自动填充字段)
CREATE TABLE IF NOT EXISTS t_ch09_attraction (
id BIGINT AUTO_INCREMENT PRIMARY KEY COMMENT '主键',
name VARCHAR(100) NOT NULL COMMENT '景点名称',
city VARCHAR(50) NOT NULL COMMENT '所在城市',
price DECIMAL(10,2) NOT NULL DEFAULT 0.00 COMMENT '门票价格',
description VARCHAR(500) NULL COMMENT '简介',
create_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间(自动填充)',
update_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间(自动填充)',
deleted TINYINT NOT NULL DEFAULT 0 COMMENT '逻辑删除:0正常 1已删'
) ENGINE = InnoDB DEFAULT CHARSET = utf8mb4 COMMENT '景点表(MP版)';
sql
-- 第09章种子数据:幂等插入
INSERT INTO t_ch09_attraction (name, city, price, description)
SELECT * FROM (
SELECT '莫日格勒河' AS name, '呼伦贝尔' AS city, 30.00 AS price, '天下第一曲水,老舍先生盛赞' AS description
UNION ALL SELECT '满洲里国门', '满洲里', 80.00, '中俄边境标志性建筑'
UNION ALL SELECT '额尔古纳湿地', '呼伦贝尔', 65.00, '亚洲第一湿地,白鹭栖息地'
UNION ALL SELECT '响沙湾', '鄂尔多斯', 130.00, '会唱歌的沙漠,5A级景区'
UNION ALL SELECT '阿尔山天池', '兴安盟', 180.00, '火山口湖,四季皆景'
) AS seed
WHERE NOT EXISTS (SELECT 1 FROM t_ch09_attraction);
9.4 运行与验证
bash
cd chapter09-mybatis-plus
mvn clean package -DskipTests
java -Dfile.encoding=UTF-8 -jar target/chapter09-mybatis-plus-1.0.0.jar
启动日志:
Started TravelApplication in 2.845 seconds (process running for 3.282)
验证 1:分页查询
bash
curl "http://localhost:8109/attractions/page?pageNum=1&pageSize=2"
返回(节选):total: 5,records: ['莫日格勒河', '满洲里国门']
控制台可见分页插件先 COUNT 再 LIMIT:
==> Preparing: SELECT COUNT( * ) AS total FROM t_ch09_attraction WHERE deleted = 0
==> Preparing: SELECT ... FROM t_ch09_attraction WHERE deleted=0 LIMIT ?
验证 2:动态条件查询(对比上一章的 XML <if>)
bash
curl "http://localhost:8109/attractions/search?city=呼伦贝尔&minPrice=35"
→ data 只有 额尔古纳湿地(price 65),价格降序
验证 3:新增------观察自动回填与自动填充
bash
curl -X POST -H "Content-Type: application/json; charset=utf-8" \
-d '{"name":"代钦塔拉五角枫","city":"兴安盟","price":60.00,"description":"秋季红叶"}' \
http://localhost:8109/attractions
json
{"code": 0, "data": {"id": 8, "createTime": "2026-08-25T22:24:56",
"updateTime": "2026-08-25T22:24:56", "...": "..."}}
请求体里没传 id/createTime/updateTime,响应全有了------id 是数据库回填,
两个时间是 MetaObjectHandler 填的。
验证 4:按需更新(只改价格,名称不动)
bash
curl -X PUT -H "Content-Type: application/json; charset=utf-8" \
-d '{"price":88.88}' \
http://localhost:8109/attractions/8
→ {"code": 0, "message": "success", "data": "更新成功"}
curl http://localhost:8109/attractions/8
→ name: 代钦塔拉五角枫 | price: 88.88 | updateTime 刷新了
控制台 SQL 是本章精华------SET 子句按需生成:
==> Preparing: UPDATE t_ch09_attraction SET price=?, update_time=? WHERE id=? AND deleted=0
<== Updates: 1
只 SET 了传值的 price 和自动填充的 update_time,name/city/description 一个都没动!
验证 5:逻辑删除
bash
curl -X DELETE http://localhost:8109/attractions/8
→ {"code": 0, "message": "已删除(逻辑)"}
curl http://localhost:8109/attractions/8
→ {"code": 0, "message": "success", "data": null} ← 数据还在库里的!
控制台 SQL 揭示真相:
==> Preparing: UPDATE t_ch09_attraction SET update_time=?, deleted=1 WHERE id=? AND deleted=0
<== Updates: 1
之后所有查询自动带 AND deleted=0,所以查不到------但数据库里那行还在,
误删可恢复,这就是逻辑删除的价值(电商订单等关键数据标配)。
验证 6:统计
bash
curl "http://localhost:8109/attractions/stats/city?city=呼伦贝尔"
→ {"code": 0, "data": 2}
==> Preparing: SELECT COUNT( * ) AS total FROM t_ch09_attraction WHERE deleted=0 AND (city = ?)
全部通过 ✅
9.5 小结
| 知识点 | 一句话记忆 |
|---|---|
BaseMapper<T> |
单表 CRUD 免费送,复杂 SQL 照旧自己写 |
LambdaQueryWrapper |
方法引用代替字符串列名,重构安全 |
condition 参数 |
eq(bool, col, val) 动态条件的官方姿势,替代 XML <if> |
PaginationInnerInterceptor |
分页必装;3.5.9+ 还要单独加 jsqlparser 依赖 |
MetaObjectHandler + fill |
create_time/update_time 业务代码零感知 |
@TableLogic |
DELETE 变 UPDATE,查询自动过滤 |
下一章预告 :单表玩熟了,真实业务永远是多表的------景点属于目的地、攻略引用景点。
第10章学习 MyBatis 多表关联:resultMap 一对多/一对一映射、延迟加载,
以及 PageHelper 通用分页。
9.6 动手练习
- 给
BaseMapper的selectBatchIds写个接口验证:一次查多个 ID(提示:List.of(1L, 2L))。 - 用
LambdaUpdateWrapper实现"呼伦贝尔所有景点统一打 9 折"(setSql("price = price * 0.9")或set方法)。 - 思考题:为什么
deleted字段标注@TableField(select = false)?(提示:查询结果里 deleted 是 null,避免干扰前端判断。)
9.7 本章使用的 Java 25 新特性
| 特性 | 说明 | 本章应用 |
|---|---|---|
| record (Java 16+) | 不可变数据类,自动生成构造器、getter、equals、hashCode、toString | Result<T> 用 record 定义统一响应体,简洁明了 |
| 文本块 (Java 15+) | 三引号字符串,支持多行文本,无需转义换行符和引号 | 注解中编写的 SQL 语句(见 config 和 controller 部分)用文本块展示动态条件逻辑 |
| LambdaQueryWrapper (MyBatis-Plus 扩展) | 用方法引用以类型安全的方式表达 WHERE 条件,编译期检查正确性 | AttractionService.search() 中用 LambdaQueryWrapper<Attraction> 的 .eq()、.ge()、.like() 等方法构造查询条件 |
| 方法引用 (Java 8+) | 简洁地引用已有方法作为函数式参数,编译期类型安全 | Attraction::getCity 作为 eq() 的第一个参数,重构友好且直观 |
| 条件参数 (MyBatis-Plus 特性) | eq(boolean condition, ...) 的 condition 参数为动态条件提供官方方案 |
eq(city != null && !city.isBlank(), ...) 等动态条件写法,替代 XML 中的 <if> 标签 |
为什么用现代语法? record 简化响应体定义;LambdaQueryWrapper 以方法引用表达查询条件,比字符串列名更安全、更易重构;条件参数优雅地实现动态条件,避免编写复杂的 XML 动态 SQL。
10 - MyBatis 进阶:多表关联与 PageHelper
本章目标:
- 掌握
resultMap的<id>/<result>/<collection>三层结构- JOIN 一条 SQL 查出一对多树形结构(理解"行折叠"原理)
- 分步查询 + 延迟加载(lazy):用到才查,控制台看得见
- PageHelper 通用分页:任何 Mapper 方法免费获得 LIMIT 能力
10.1 上章回顾与本章动机
前两章都是单表操作。真实业务里数据永远是网状的:
目的地(1) ──拥有──> 景点(N)
两个新问题:
- 关联查询:查"呼伦贝尔草原"时要连它的景点一起返回------SQL 怎么写?结果怎么映射成 Java 对象树?
- 列表分页:景点越来越多,一次返回全部不现实------分页能不能不手写 LIMIT?
本章逐一击破。
10.2 数据模型设计
t_ch10_destination(一的一方)
| id | name | province |
|---|---|---|
| 1 | 呼伦贝尔草原 | 内蒙古 |
| 2 | 阿尔山 | 内蒙古 |
t_ch10_attraction(多的一方)
| id | name | price | destination_id |
|---|---|---|---|
| 1 | 莫日格勒河 | 30.00 | 1 |
| 2 | 额尔古纳湿地 | 65.00 | 1 |
| 3 | 阿尔山天池 | 180.00 | 2 |
| 4 | 杜鹃湖 | 60.00 | 2 |
理论知识 ------ 外键放哪边?
"多"的一方持有"一"的一方的 id。这与数据库外键设计一一对应,
Java 实体也照此建模(Attraction.destinationId)。
10.3 环境准备
pom.xml
xml
<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 https://maven.apache.org/xsd/maven-4.0.0.xsd">
<modelVersion>4.0.0</modelVersion>
<parent>
<groupId>com.lihaozhe</groupId>
<artifactId>sb-travel</artifactId>
<version>1.0.0</version>
</parent>
<artifactId>chapter10-mybatis-advanced</artifactId>
<properties>
<!-- PageHelper:通用分页插件(拦截 MyBatis 执行的 SQL 自动拼 LIMIT)。
4.x 版本线适配 Spring Boot 3/4 -->
<pagehelper.version>4.1.1</pagehelper.version>
</properties>
<dependencies>
<!-- Web 场景 -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-webmvc</artifactId>
</dependency>
<!-- MyBatis 官方 starter(本章回到原生 MyBatis 讲多表映射) -->
<dependency>
<groupId>org.mybatis.spring.boot</groupId>
<artifactId>mybatis-spring-boot-starter</artifactId>
<version>4.1.0</version>
</dependency>
<!--
PageHelper starter:调用 PageHelper.startPage() 后的下一条查询,
会自动被拼上 LIMIT 并附带 COUNT 查询。
与 MP 的 selectPage 互为替代方案。
-->
<dependency>
<groupId>com.github.pagehelper</groupId>
<artifactId>pagehelper-spring-boot-starter</artifactId>
<version>${pagehelper.version}</version>
</dependency>
<!-- MySQL 驱动 -->
<dependency>
<groupId>com.mysql</groupId>
<artifactId>mysql-connector-j</artifactId>
<scope>runtime</scope>
</dependency>
<!-- 单元测试 -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-test</artifactId>
<scope>test</scope>
</dependency>
</dependencies>
<build>
<plugins>
<plugin>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-maven-plugin</artifactId>
</plugin>
</plugins>
</build>
</project>
10.4 跟我做:逐文件编写
10.4.1 model 三兄弟
java
package com.lihaozhe.chapter10.model;
import java.math.BigDecimal;
import java.time.LocalDateTime;
/**
* 目的地实体(第10章:一的一方)
*
* <p>本章数据模型:
* 一个目的地(destination)拥有多个景点(attraction)------经典"一对多"。
* 对应数据库:t_ch10_attraction 表里有 destination_id 外键列。
*/
public class Destination {
private Long id;
private String name;
private String province;
private String description;
private LocalDateTime createTime;
public Long getId() {
return id;
}
public void setId(Long id) {
this.id = id;
}
public String getName() {
return name;
}
public void setName(String name) {
this.name = name;
}
public String getProvince() {
return province;
}
public void setProvince(String province) {
this.province = province;
}
public String getDescription() {
return description;
}
public void setDescription(String description) {
this.description = description;
}
public LocalDateTime getCreateTime() {
return createTime;
}
public void setCreateTime(LocalDateTime createTime) {
this.createTime = createTime;
}
}
java
package com.lihaozhe.chapter10.model;
import java.math.BigDecimal;
/**
* 景点实体(第10章:多的一方)
*
* <p>理论知识 ------ 外键字段放哪边?
* "多"的一方持有"一"的一方的 id(destinationId),
* 这与数据库外键设计一一对应。
*/
public class Attraction {
private Long id;
private String name;
private BigDecimal price;
/** 所属目的地 id ------ 数据库里的 destination_id 列 */
private Long destinationId;
// getter/setter 全套(略------照常规生成即可)
public Long getId() { return id; }
public void setId(Long id) { this.id = id; }
public String getName() { return name; }
public void setName(String name) { this.name = name; }
public BigDecimal getPrice() { return price; }
public void setPrice(BigDecimal price) { this.price = price; }
public Long getDestinationId() { return destinationId; }
public void setDestinationId(Long destinationId) { this.destinationId = destinationId; }
}
java
package com.lihaozhe.chapter10.model;
import java.util.List;
/**
* 目的地详情 VO(第10章核心:一对多关联视图对象)
*
* <p>理论知识 ------ VO(View Object)与实体的区别:
* 实体 Entity 与表一一对应;VO 是"给前端看的形状",可以自由组合多表数据。
* 本章 DestinationVO = Destination 自身字段 + 该目的地的景点列表。
* (继承 Destination 复用全部 getter/setter,教学场景最省事。)
*/
public class DestinationVO extends Destination {
/** 关联的景点列表 ------ <collection> 映射的目标 */
private List<Attraction> attractions;
public List<Attraction> getAttractions() {
return attractions;
}
public void setAttractions(List<Attraction> attractions) {
this.attractions = attractions;
}
}
10.4.2 dao/DestinationMapper.java 与 AttractionMapper.java
java
package com.lihaozhe.chapter10.dao;
import com.lihaozhe.chapter10.model.Destination;
import com.lihaozhe.chapter10.model.DestinationVO;
import org.apache.ibatis.annotations.Mapper;
import java.util.List;
/**
* 目的地 Mapper(第10章:多表关联主战场)
*
* <p>理论知识 ------ 多表关联的两种姿势:
* <ul>
* <li>联表查询(JOIN):一条 SQL 搞定,resultMap 的
* <collection> 负责把"重复的目的地行+不同景点行"折叠成一个对象;
* <li>分步查询(嵌套 SELECT):先查目的地,再按 destination_id 查景点。
* 配合 fetchType="lazy" 就是延迟加载------用到才查,省性能。</li>
* </ul>
*/
@Mapper
public interface DestinationMapper {
/** 查全部目的地(不带景点)------最朴素的单表查询 */
List<Destination> findAll();
/**
* JOIN 版一对多:一条 SQL 查出目的地 + 它的所有景点,
* 由 XML 里的 resultMap 折叠成 VO 树。
*/
List<DestinationVO> findWithAttractions();
/**
* 分步版一对多(延迟加载演示):
* 只写"第一步"查目的地;XML 里 <collection> 的 select 属性
* 指向 AttractionMapper.findByDestinationId 完成第二步。
*/
List<DestinationVO> findLazy();
/** 单个目的地详情(JOIN 版) */
DestinationVO findDetailById(Long id);
}
java
package com.lihaozhe.chapter10.dao;
import com.lihaozhe.chapter10.model.Attraction;
import org.apache.ibatis.annotations.Mapper;
import org.apache.ibatis.annotations.Param;
import org.apache.ibatis.annotations.Select;
import java.util.List;
/**
* 景点 Mapper(第10章:为分步查询提供"第二步")
*/
@Mapper
public interface AttractionMapper {
/**
* 按目的地 id 查景点列表。
* 方法签名与 XML 的 <collection select="..."> 约定匹配:
* 延迟加载时 MyBatis 会把"一"方传来的 destination_id 作为参数调用本方法。
*/
@Select("""
SELECT id, name, price, destination_id
FROM t_ch10_attraction
WHERE destination_id = #{destinationId}
""")
List<Attraction> findByDestinationId(@Param("destinationId") Long destinationId);
/** 全部景点(JOIN 查询的 resultType 目标) */
@Select("SELECT id, name, price, destination_id FROM t_ch10_attraction")
List<Attraction> findAll();
}
10.4.3 mapper/DestinationMapper.xml ------ 本章灵魂
xml
<?xml version="1.0" encoding="UTF-8" ?>
<!DOCTYPE mapper PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
"http://mybatis.org/dtd/mybatis-3-mapper.dtd">
<!--
第10章:多表关联核心配置(resultMap 是本章主角)
理论知识 ------ resultMap 的三层结构:
<id> 主键列 → 字段(MyBatis 靠它判断"哪些行属于同一个对象")
<result> 普通列 → 字段
<collection property="attractions" ofType="Attraction"> 一对多映射:
JOIN 结果里同一目的地的 N 行会被折叠成 1 个 DestinationVO,
它们的景点行分别进入 attractions 列表。
延迟加载三要素(分步版):
1. collection 加 select="第二步查询的 id"
2. column="传给第二步的参数列名"
3. fetchType="lazy" + 全局配置 lazy-loading-enabled=true
-->
<mapper namespace="com.lihaozhe.chapter10.dao.DestinationMapper">
<!-- 公共的目的地字段映射:驼峰列名 create_time → createTime -->
<resultMap id="destinationMap" type="com.lihaozhe.chapter10.model.Destination">
<id property="id" column="id"/>
<result property="name" column="name"/>
<result property="province" column="province"/>
<result property="description" column="description"/>
<result property="createTime" column="create_time"/>
</resultMap>
<!-- JOIN 版一对多:extends 复用上面的基础映射,再追加 collection -->
<resultMap id="destinationVOMap" type="com.lihaozhe.chapter10.model.DestinationVO"
extends="destinationMap">
<collection property="attractions"
ofType="com.lihaozhe.chapter10.model.Attraction">
<id property="id" column="a_id"/>
<result property="name" column="a_name"/>
<result property="price" column="price"/>
<result property="destinationId" column="destination_id"/>
</collection>
</resultMap>
<!-- 一条 JOIN SQL:目的地行会因景点而重复,靠 <id> 折叠 -->
<select id="findWithAttractions" resultMap="destinationVOMap">
SELECT d.id, d.name, d.province, d.description, d.create_time,
a.id AS a_id, a.name AS a_name, a.price, a.destination_id
FROM t_ch10_destination d
LEFT JOIN t_ch10_attraction a ON a.destination_id = d.id
ORDER BY d.id, a.id
</select>
<!-- 分步版(延迟加载):只查目的地;景点由 select 属性指向的第二步按需查询。
注意:column="id" 把本结果的 id 列作为 destination_id 参数传给第二步 -->
<resultMap id="lazyMap" type="com.lihaozhe.chapter10.model.DestinationVO"
extends="destinationMap">
<collection property="attractions"
ofType="com.lihaozhe.chapter10.model.Attraction"
select="com.lihaozhe.chapter10.dao.AttractionMapper.findByDestinationId"
column="id"
fetchType="lazy"/>
</resultMap>
<select id="findLazy" resultMap="lazyMap">
SELECT id, name, province, description, create_time
FROM t_ch10_destination
ORDER BY id
</select>
<!-- 单个详情(JOIN 版) -->
<select id="findDetailById" resultMap="destinationVOMap">
SELECT d.id, d.name, d.province, d.description, d.create_time,
a.id AS a_id, a.name AS a_name, a.price, a.destination_id
FROM t_ch10_destination d
LEFT JOIN t_ch10_attraction a ON a.destination_id = d.id
WHERE d.id = #{id}
ORDER BY a.id
</select>
<select id="findAll" resultMap="destinationMap">
SELECT id, name, province, description, create_time
FROM t_ch10_destination
ORDER BY id
</select>
</mapper>
JOIN 版的行折叠原理 (面试高频):
SQL 结果实际长这样------目的地列在景点多的行里重复出现:
d.id d.name a_id a_name 1 呼伦贝尔草原 1 莫日格勒河 1 呼伦贝尔草原 2 额尔古纳湿地 2 阿尔山 3 阿尔山天池 2 阿尔山 4 杜鹃湖 MyBatis 靠
<id>标签识别主键:相邻行的主键相同 → 同一个对象,景点部分依次塞进 collection。所以
<id>千万不能省!
10.4.4 dao/AttractionPageMapper.java ------ PageHelper 主场
java
package com.lihaozhe.chapter10.dao;
import com.github.pagehelper.Page;
import com.lihaozhe.chapter10.model.Attraction;
import org.apache.ibatis.annotations.Mapper;
import org.apache.ibatis.annotations.Param;
import org.apache.ibatis.annotations.Select;
import java.math.BigDecimal;
/**
* 分页专用 Mapper(第10章:PageHelper 演示)
*
* <p>理论知识 ------ PageHelper 的工作原理:
* 它是一个 MyBatis Interceptor(拦截器)。
* 调用 PageHelper.startPage(页码, 条数) 后,
* 线程上下文里记下分页参数,紧接着执行的【第一条】查询
* 会被自动改写成 SELECT ... LIMIT offset, size,
* 并额外发一条 COUNT 查询------业务代码完全无感知。
*/
@Mapper
public interface AttractionPageMapper {
/** 给 PageHelper 拦截的普通查询(越朴素越好,LIMIT 由插件拼) */
@Select("""
<script>
SELECT id, name, price, destination_id
FROM t_ch10_attraction
<where>
<if test="minPrice != null">
AND price >= #{minPrice}
</if>
</where>
ORDER BY price DESC
</script>
""")
Page<Attraction> pageByMinPrice(@Param("minPrice") BigDecimal minPrice);
}
10.4.5 service/TravelService.java
java
package com.lihaozhe.chapter10.service;
import com.github.pagehelper.PageHelper;
import com.github.pagehelper.PageInfo;
import com.lihaozhe.chapter10.dao.AttractionPageMapper;
import com.lihaozhe.chapter10.dao.DestinationMapper;
import com.lihaozhe.chapter10.model.Attraction;
import com.lihaozhe.chapter10.model.Destination;
import com.lihaozhe.chapter10.model.DestinationVO;
import org.springframework.stereotype.Service;
import java.math.BigDecimal;
import java.util.List;
/**
* 目的地/景点服务(第10章)
*/
@Service
public class TravelService {
private final DestinationMapper destinationMapper;
private final AttractionPageMapper attractionPageMapper;
public TravelService(DestinationMapper destinationMapper,
AttractionPageMapper attractionPageMapper) {
this.destinationMapper = destinationMapper;
this.attractionPageMapper = attractionPageMapper;
}
public List<Destination> findAll() {
return destinationMapper.findAll();
}
/** JOIN 版一对多:一次查询出完整树 */
public List<DestinationVO> findWithAttractions() {
return destinationMapper.findWithAttractions();
}
/**
* 延迟加载版:方法返回时 attractions 还没查。
* 真正触发第二条 SQL 的时机是序列化成 JSON 时访问 getAttractions()。
* (控制台能观察到:先 1 条目的地 SQL,响应时再 N 条景点 SQL)
*/
public List<DestinationVO> findLazy() {
return destinationMapper.findLazy();
}
public DestinationVO findDetail(Long id) {
DestinationVO vo = destinationMapper.findDetailById(id);
if (vo == null) {
throw new IllegalArgumentException("目的地不存在: id=" + id);
}
return vo;
}
/**
* PageHelper 分页:startPage 紧跟查询,中间不能插其他 SQL!
* 返回 PageInfo 而不是裸 Page------Page 继承 ArrayList,
* 直接序列化会退化成纯 JSON 数组丢失 total 等分页元数据;
* PageInfo 是普通对象,能带 total/pageNum/pages 等完整信息。
*/
public PageInfo<Attraction> pageAttractions(int pageNum, int pageSize, Double minPrice) {
try (Page<Attraction> ignored = PageHelper.startPage(pageNum, pageSize)) {
return new PageInfo<>(attractionPageMapper.pageByMinPrice(
minPrice == null ? null : BigDecimal.valueOf(minPrice)));
}
}
}
实战踩坑记录 :本项目验证时先返回了裸
Page,接口响应退化成纯数组
[{...},{...}],total 信息全丢。改用new PageInfo<>(list)包装后才有
total/pages/pageNum元数据。记住:对外返回一律 PageInfo!
10.4.6 controller / common / 启动类
java
package com.lihaozhe.chapter10.controller;
import com.github.pagehelper.PageInfo;
import com.lihaozhe.chapter10.common.Result;
import com.lihaozhe.chapter10.model.Attraction;
import com.lihaozhe.chapter10.model.Destination;
import com.lihaozhe.chapter10.model.DestinationVO;
import com.lihaozhe.chapter10.service.TravelService;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;
import java.util.List;
/**
* 目的地接口(第10章:多表关联)
*/
@RestController
@RequestMapping("/destinations")
public class DestinationController {
private final TravelService service;
public DestinationController(TravelService service) {
this.service = service;
}
/** 纯目的地列表(无关联) */
@GetMapping
public Result<List<Destination>> list() {
return Result.ok(service.findAll());
}
/** JOIN 版一对多:一次请求返回目的地树 */
@GetMapping("/with-attractions")
public Result<List<DestinationVO>> withAttractions() {
return Result.ok(service.findWithAttractions());
}
/** 延迟加载版:观察控制台 SQL 的分步执行 */
@GetMapping("/lazy")
public Result<List<DestinationVO>> lazy() {
return Result.ok(service.findLazy());
}
/** 单个详情 */
@GetMapping("/{id}")
public Result<DestinationVO> detail(@PathVariable Long id) {
return Result.ok(service.findDetail(id));
}
/** PageHelper 分页(景点维度):PageInfo 带 total/pageNum 等元数据 */
@GetMapping("/attractions/page")
public Result<PageInfo<Attraction>> attractionPage(
@RequestParam(defaultValue = "1") int pageNum,
@RequestParam(defaultValue = "2") int pageSize,
Double minPrice) {
return Result.ok(service.pageAttractions(pageNum, pageSize, minPrice));
}
}
common 包两个类(Result / GlobalExceptionHandler)与第07~09章一致,直接复制。
java
package com.lihaozhe.chapter10;
import org.mybatis.spring.annotation.MapperScan;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
/**
* 第10章启动类:MyBatis 进阶------多表关联与 PageHelper
*
* <p>理论知识 ------ 本章两大主题的关系:
* 多表关联解决"数据怎么组织成树/图";
* PageHelper 解决"列表太长怎么翻页"。
* 两者都建立在第08章的 Mapper 基础上,属于"查询能力升级"。
*/
@SpringBootApplication
@MapperScan("com.lihaozhe.chapter10.dao")
public class TravelApplication {
public static void main(String[] args) {
SpringApplication.run(TravelApplication.class, args);
}
}
10.4.7 application.yml
yaml
# 第10章:MyBatis 进阶------多表关联与 PageHelper
# 端口规则:8080 + 章号 10 → 8110
server:
port: 8110
spring:
datasource:
# 同库 sb_travel,表前缀 t_ch10_
url: jdbc:mysql://118.31.221.165:3306/sb_travel?useSSL=false&serverTimezone=Asia/Shanghai&characterEncoding=UTF-8&allowPublicKeyRetrieval=true
username: root
password: lihaozhe
driver-class-name: com.mysql.cj.jdbc.Driver
hikari:
maximum-pool-size: 5
minimum-idle: 2
connection-timeout: 10000
sql:
init:
mode: always
mybatis:
mapper-locations: classpath:mapper/*.xml
configuration:
map-underscore-to-camel-case: true
log-impl: org.apache.ibatis.logging.stdout.StdOutImpl
# 延迟加载总开关(fetchType="lazy" 是局部开关,这里是全局开关)
lazy-loading-enabled: true
# aggressive-lazy-loading=false:只有真正访问属性才触发加载
aggressive-lazy-loading: false
# PageHelper 配置(starter 提供前缀)
pagehelper:
helper-dialect: mysql # 方言:自动拼 MySQL 语法的 LIMIT
reasonable: true # 页码越界自动修正(如请求第99页→返回最后一页)
support-methods-arguments: true
10.4.8 schema.sql 与 data.sql
sql
-- 第10章:目的地表(一的一方)
CREATE TABLE IF NOT EXISTS t_ch10_destination (
id BIGINT AUTO_INCREMENT PRIMARY KEY COMMENT '主键',
name VARCHAR(100) NOT NULL COMMENT '目的地名称',
province VARCHAR(50) NOT NULL COMMENT '省份',
description VARCHAR(500) NULL COMMENT '简介',
create_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间'
) ENGINE = InnoDB DEFAULT CHARSET = utf8mb4 COMMENT '目的地表';
-- 第10章:景点表(多的一方,外键 destination_id)
CREATE TABLE IF NOT EXISTS t_ch10_attraction (
id BIGINT AUTO_INCREMENT PRIMARY KEY COMMENT '主键',
name VARCHAR(100) NOT NULL COMMENT '景点名称',
price DECIMAL(10,2) NOT NULL DEFAULT 0.00 COMMENT '门票价格',
destination_id BIGINT NOT NULL COMMENT '所属目的地id(逻辑外键)',
KEY idx_destination (destination_id)
) ENGINE = InnoDB DEFAULT CHARSET = utf8mb4 COMMENT '景点表';
sql
-- 第10章种子数据:每条独立执行,WHERE NOT EXISTS 防重复(幂等)
INSERT INTO t_ch10_destination (name, province, description)
SELECT '呼伦贝尔草原', '内蒙古', '世界四大草原之一'
WHERE NOT EXISTS (SELECT 1 FROM t_ch10_destination WHERE name = '呼伦贝尔草原');
INSERT INTO t_ch10_destination (name, province, description)
SELECT '阿尔山', '内蒙古', '火山温泉森林之城'
WHERE NOT EXISTS (SELECT 1 FROM t_ch10_destination WHERE name = '阿尔山');
INSERT INTO t_ch10_attraction (name, price, destination_id)
SELECT '莫日格勒河', 30.00,
(SELECT id FROM t_ch10_destination WHERE name = '呼伦贝尔草原')
WHERE NOT EXISTS (SELECT 1 FROM t_ch10_attraction WHERE name = '莫日格勒河');
INSERT INTO t_ch10_attraction (name, price, destination_id)
SELECT '额尔古纳湿地', 65.00,
(SELECT id FROM t_ch10_destination WHERE name = '呼伦贝尔草原')
WHERE NOT EXISTS (SELECT 1 FROM t_ch10_attraction WHERE name = '额尔古纳湿地');
INSERT INTO t_ch10_attraction (name, price, destination_id)
SELECT '阿尔山天池', 180.00,
(SELECT id FROM t_ch10_destination WHERE name = '阿尔山')
WHERE NOT EXISTS (SELECT 1 FROM t_ch10_attraction WHERE name = '阿尔山天池');
INSERT INTO t_ch10_attraction (name, price, destination_id)
SELECT '杜鹃湖', 60.00,
(SELECT id FROM t_ch10_destination WHERE name = '阿尔山')
WHERE NOT EXISTS (SELECT 1 FROM t_ch10_attraction WHERE name = '杜鹃湖');
10.5 运行与验证
bash
cd chapter10-mybatis-advanced
mvn clean package -DskipTests
java -Dfile.encoding=UTF-8 -jar target/chapter10-mybatis-advanced-1.0.0.jar
启动日志:
Started TravelApplication in 3.239 seconds (process running for 3.667)
验证 1:JOIN 一对多(一次请求拿到完整树)
bash
curl http://localhost:8110/destinations/with-attractions
呼伦贝尔草原 -> ['莫日格勒河', '额尔古纳湿地']
阿尔山 -> ['阿尔山天池', '杜鹃湖']
控制台只有 1 条 JOIN SQL------四行折叠成了两个 VO 对象。
验证 2:延迟加载(数 SQL 条数)
bash
curl http://localhost:8110/destinations/lazy > /dev/null
控制台新增 3 条 SQL:
==> Preparing: SELECT id, name, province, ... FROM t_ch10_destination ORDER BY id
==> Preparing: SELECT id, name, price, destination_id FROM t_ch10_attraction WHERE destination_id = ?
==> Preparing: SELECT id, name, price, destination_id FROM t_ch10_attraction WHERE destination_id = ?
1 条目的地 + 每个目的地下属景点各 1 条 = 3 条。分步、按需执行
------这就是延迟加载。如果某处只读目的地名称而不碰 getAttractions(),
后两条根本不会发生。
验证 3:单个详情
bash
curl http://localhost:8110/destinations/1
→ 呼伦贝尔草原 -> [('莫日格勒河', 30.0), ('额尔古纳湿地', 65.0)]
验证 4:PageHelper 分页
bash
curl "http://localhost:8110/destinations/attractions/page?pageNum=1&pageSize=2&minPrice=40"
json
{"code": 0, "message": "success", "data": {
"total": 3, "pages": 2, "pageNum": 1, "pageSize": 2,
"list": [{"name": "阿尔山天池", "price": 180.0},
{"name": "额尔古纳湿地", "price": 65.0}]}}
控制台能看到 PageHelper 的"偷天换日":
==> Preparing: SELECT count(0) FROM t_ch10_attraction WHERE price >= ?
==> Preparing: SELECT id, name, price, destination_id FROM t_ch10_attraction
WHERE price >= ? ORDER BY price DESC LIMIT ?
==> Parameters: 40.0(BigDecimal), 2(Integer)
原 SQL 被自动包了一层 COUNT 和 LIMIT------业务 Mapper 里一个字没改!全部通过 ✅
10.6 小结
| 知识点 | 一句话记忆 |
|---|---|
<id> |
行折叠的依据,一对多 resultMap 必须标 |
<collection> |
一对多映射;ofType 指集合元素类型 |
| JOIN vs 分步 | 一次到位 vs 延迟加载,按读写比例选 |
fetchType="lazy" + column |
分步延迟三要素之二(第三个是 select) |
PageHelper.startPage() |
紧跟第一条查询才生效,中间别插别的 SQL |
PageInfo |
对外返回必须包装,裸 Page 序列化会丢 total |
下一章预告 :多条 SQL 连续执行时,第二条失败第一条怎么办?
比如"报名成功同时扣名额",一半成功一半失败是灾难。
第11章事务管理:@Transactional 让一组 SQL 要么全成、要么全败,
顺带讲传播行为、隔离级别和五大失效场景(面试必考)。
10.7 动手练习
- 给
AttractionMapper增加"多对一"映射练习:查景点时顺带查出所属目的地名称(提示:<association>标签 + JOIN)。 - 用 PageHelper 实现"第二页,每页 3 条",观察控制台 LIMIT 10, 3 的 offset 计算。
- 思考题:如果把
<id>改成<result>,JOIN 折叠会发生什么?(动手试一下,答案会让你印象深刻。)
10.8 本章使用的 Java 25 新特性
| 特性 | 说明 | 本章应用 |
|---|---|---|
| record (Java 16+) | 不可变数据类,自动生成构造器、getter、equals、hashCode、toString | Result<T> 用 record 定义统一响应体(与第07-09章保持一致) |
| 文本块 (Java 15+) | 三引号字符串,支持多行文本,无需转义换行符和引号 | 所有 SQL 注解(@Select)用文本块编写,特别是动态条件查询;XML 中多条 SQL 语句也清晰呈现 |
| PageHelper/PageInfo (第三方库特性) | 通用分页方案,自动拦截 SQL 添加 LIMIT | TravelService.pageAttractions() 中使用 PageHelper.startPage() 后紧跟查询,框架自动拼 LIMIT 和 COUNT;返回 PageInfo 携带 total/pageNum 等完整分页元数据 |
| try-with-resources (Java 7+) | 自动资源关闭,确保资源在使用后正确释放 | TravelService.pageAttractions() 中用 try (Page<Attraction> ignored = PageHelper.startPage(...)) 确保分页上下文在方法结束时自动清理 |
| Stream API (Java 8+) | 流式处理集合,支持链式操作 | 结果集处理逻辑(如 DestinationVO 中景点列表的构建)体现函数式编程思维 |
为什么用现代语法? record 保持响应体定义一致性;文本块使 SQL 可读性强;PageHelper 的 try-with-resources 模式确保分页上下文自动清理,避免资源泄露;Stream API 为复杂的数据处理提供优雅的函数式解决方案。
11 - 事务管理:@Transactional 让 SQL 同生共死
本章目标:
- 理解事务 ACID 与"声明式事务"的实现原理(动态代理)
- 掌握
@Transactional的提交/回滚行为,亲眼在日志里看到 COMMIT 与 ROLLBACK- 记住受检异常默认不回滚 ,学会
rollbackFor- 了解
readOnly、传播行为与五大失效场景(面试必考)
11.1 上章回顾与本章动机
第10章末尾埋的钩子:多条写操作连续执行时,第二条失败第一条怎么办?
业务场景:报名旅行团 = 创建订单 + 扣减名额 。如果扣名额成功、建订单失败,
游客没报上名但名额被占了------数据不一致,业务事故。
数据库事务正是为此而生:
BEGIN;
UPDATE t_ch11_tour SET quota = quota - 1 ...; -- 第一步
INSERT INTO t_ch11_order (...) VALUES (...); -- 第二步
COMMIT; -- 两步一起生效
-- 或任何一步出错 → ROLLBACK; 两步全部撤销
Spring 的贡献:不用手写 BEGIN/COMMIT,方法上标 @Transactional 即可。
11.2 理论速览
ACID
| 特性 | 含义 | 本章对应 |
|---|---|---|
| Atomicity 原子性 | 一组操作要么全做要么全不做 | 扣名额+建订单同生共死 |
| Consistency 一致性 | 事务前后数据合法(名额不为负) | WHERE quota > 0 防线 |
| Isolation 隔离性 | 并发事务互不干扰 | 隔离级别小节 |
| Durability 持久性 | 提交后永久生效 | MySQL redo log 保证 |
声明式事务的实现原理(面试高频)
Controller 调用 bookingService.book(...)
↓ 实际调用的是 Spring 生成的【代理对象】
代理:txManager.getTransaction() ← 开启事务
代理:book() 真实方法 ← 你的业务 SQL
代理:正常返回? commit() : rollback() ← 收尾
所以:@Transactional 本质是 AOP + ThreadLocal 绑定连接 。
这也解释了本章后面"自调用失效"的原因------this.method() 不经过代理!
11.3 跟我做:逐文件编写
11.3.1 pom.xml
xml
<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 https://maven.apache.org/xsd/maven-4.0.0.xsd">
<modelVersion>4.0.0</modelVersion>
<parent>
<groupId>com.lihaozhe</groupId>
<artifactId>sb-travel</artifactId>
<version>1.0.0</version>
</parent>
<artifactId>chapter11-transactions</artifactId>
<dependencies>
<!-- Web 场景 -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-webmvc</artifactId>
</dependency>
<!-- JDBC 场景:自带 DataSourceTransactionManager(事务管理器)与 JdbcTemplate -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-jdbc</artifactId>
</dependency>
<!-- MySQL 驱动 -->
<dependency>
<groupId>com.mysql</groupId>
<artifactId>mysql-connector-j</artifactId>
<scope>runtime</scope>
</dependency>
<!-- 单元测试 -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-test</artifactId>
<scope>test</scope>
</dependency>
</dependencies>
<build>
<plugins>
<plugin>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-maven-plugin</artifactId>
</plugin>
</plugins>
</build>
</project>
11.3.2 model/BookingOrder.java
java
package com.lihaozhe.chapter11.model;
import java.math.BigDecimal;
import java.time.LocalDateTime;
/**
* 旅游订单实体(第11章)
*
* <p>业务背景:报名旅行团 → 创建订单 + 扣减名额,两步必须同生共死。
*/
public class BookingOrder {
private Long id;
private String orderNo;
private Long tourId;
private String touristName;
private BigDecimal amount;
private LocalDateTime createTime;
// getter/setter 全套
public Long getId() { return id; }
public void setId(Long id) { this.id = id; }
public String getOrderNo() { return orderNo; }
public void setOrderNo(String orderNo) { this.orderNo = orderNo; }
public Long getTourId() { return tourId; }
public void setTourId(Long tourId) { this.tourId = tourId; }
public String getTouristName() { return touristName; }
public void setTouristName(String touristName) { this.touristName = touristName; }
public BigDecimal getAmount() { return amount; }
public void setAmount(BigDecimal amount) { this.amount = amount; }
public LocalDateTime getCreateTime() { return createTime; }
public void setCreateTime(LocalDateTime createTime) { this.createTime = createTime; }
}
11.3.3 dao/TourDao.java 与 BookingOrderDao.java
java
package com.lihaozhe.chapter11.dao;
import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.stereotype.Repository;
/**
* 旅行团 Dao(第11章:负责名额扣减)
*/
@Repository
public class TourDao {
private final JdbcTemplate jdbc;
public TourDao(JdbcTemplate jdbc) {
this.jdbc = jdbc;
}
/** 查剩余名额 */
public int getQuota(long tourId) {
Integer quota = jdbc.queryForObject(
"SELECT quota FROM t_ch11_tour WHERE id = ?", Integer.class, tourId);
if (quota == null) {
throw new IllegalArgumentException("旅行团不存在: id=" + tourId);
}
return quota;
}
/** 扣减名额:WHERE quota > 0 防止扣成负数(数据库层面的乐观防线) */
public int decreaseQuota(long tourId) {
return jdbc.update(
"UPDATE t_ch11_tour SET quota = quota - 1 WHERE id = ? AND quota > 0", tourId);
}
public void create(String name, int quota) {
jdbc.update("INSERT INTO t_ch11_tour (name, quota) VALUES (?, ?)", name, quota);
}
public Long findIdByName(String name) {
return jdbc.queryForObject(
"SELECT id FROM t_ch11_tour WHERE name = ?", Long.class, name);
}
}
java
package com.lihaozhe.chapter11.dao;
import com.lihaozhe.chapter11.model.BookingOrder;
import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.jdbc.core.simple.SimpleJdbcInsert;
import org.springframework.stereotype.Repository;
import java.util.List;
import java.util.Map;
/**
* 订单 Dao(第11章:JdbcTemplate 足够,事务才是主角)
*/
@Repository
public class BookingOrderDao {
private final JdbcTemplate jdbc;
private final SimpleJdbcInsert insert;
public BookingOrderDao(JdbcTemplate jdbc) {
this.jdbc = jdbc;
this.insert = new SimpleJdbcInsert(jdbc)
.withTableName("t_ch11_order")
.usingGeneratedKeyColumns("id");
}
/**
* 插入订单并回填自增 id。
* 只传业务列;create_time 不传 → 走数据库 DEFAULT CURRENT_TIMESTAMP。
* (注意:SimpleJdbcInsert 会为未提供的列显式传 NULL,
* 所以表里 create_time 要允许 NULL,见 schema.sql 注释。)
*/
public void insert(BookingOrder order) {
var key = insert.executeAndReturnKey(Map.of(
"order_no", order.getOrderNo(),
"tour_id", order.getTourId(),
"tourist_name", order.getTouristName(),
"amount", order.getAmount()));
order.setId(key.longValue());
}
public List<BookingOrder> findAll() {
return jdbc.query("""
SELECT id, order_no, tour_id, tourist_name, amount, create_time
FROM t_ch11_order ORDER BY id
""",
(rs, i) -> {
BookingOrder o = new BookingOrder();
o.setId(rs.getLong("id"));
o.setOrderNo(rs.getString("order_no"));
o.setTourId(rs.getLong("tour_id"));
o.setTouristName(rs.getString("tourist_name"));
o.setAmount(rs.getBigDecimal("amount"));
var ts = rs.getTimestamp("create_time");
o.setCreateTime(ts == null ? null : ts.toLocalDateTime());
return o;
});
}
}
11.3.4 service/BookingService.java ------ 本章主角
java
package com.lihaozhe.chapter11.service;
import com.lihaozhe.chapter11.dao.BookingOrderDao;
import com.lihaozhe.chapter11.dao.TourDao;
import com.lihaozhe.chapter11.model.BookingOrder;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.util.List;
import java.util.UUID;
/**
* 报名服务(第11章主角:@Transactional 全家桶)
*
* <p>理论知识 ------ 事务的 ACID:
* <ul>
* <li>Atomicity 原子性:一组操作要么全做要么全不做;</li>
* <li>Consistency 一致性:事务前后数据都处于合法状态(名额不会为负);</li>
* <li>Isolation 隔离性:并发事务互不干扰;</li>
* <li>Durability 持久性:提交后永久生效。</li>
* </ul>
*
* <p>理论知识 ------ Spring 的事务实现(声明式):
* @Transactional 方法被调用时,Spring 动态代理会先开启事务 → 执行业务 →
* 正常返回就 COMMIT,抛 RuntimeException/Error 就 ROLLBACK。
* 注意:受检异常默认不回滚!需要 rollbackFor 指定。
*/
@Service
public class BookingService {
private final TourDao tourDao;
private final BookingOrderDao orderDao;
public BookingService(TourDao tourDao, BookingOrderDao orderDao) {
this.tourDao = tourDao;
this.orderDao = orderDao;
}
/**
* 场景一:正常事务------报名成功 = 建订单 + 扣名额,两步一起提交。
*
* <p>@Transactional 放在类或 public 方法上均可;
* 属性全部使用默认值(REQUIRED + 回滚 RuntimeException)。
*/
@Transactional
public BookingOrder book(String touristName, long tourId, double amount) {
tourDao.decreaseQuota(tourId); // 第一步:扣名额
BookingOrder order = new BookingOrder();
order.setOrderNo("T" + UUID.randomUUID().toString().substring(0, 8).toUpperCase());
order.setTourId(tourId);
order.setTouristName(touristName);
order.setAmount(java.math.BigDecimal.valueOf(amount));
orderDao.insert(order); // 第二步:建订单
return order; // 两步一起 COMMIT
}
/**
* 场景二:回滚演示------建订单后抛 RuntimeException,
* 整个方法内所有写操作全部撤销(订单消失、名额恢复)。
*/
@Transactional
public void bookThenFail(String touristName, long tourId, double amount) {
tourDao.decreaseQuota(tourId);
BookingOrder order = new BookingOrder();
order.setOrderNo("F" + UUID.randomUUID().toString().substring(0, 8).toUpperCase());
order.setTourId(tourId);
order.setTouristName(touristName);
order.setAmount(java.math.BigDecimal.valueOf(amount));
orderDao.insert(order);
// 订单已插入(但未提交),这里主动抛异常触发 ROLLBACK
throw new IllegalStateException("模拟支付失败!订单与扣名额应一起回滚");
}
/**
* 场景三:rollbackFor ------ 受检异常默认【不】回滚!
* 业务里自定义受检异常时必须显式声明 rollbackFor。
* (本例故意抛 Exception 验证:不加 rollbackFor 会提交,加了才回滚。)
*/
@Transactional(rollbackFor = Exception.class)
public void bookWithCheckedException(String touristName, long tourId) throws Exception {
tourDao.decreaseQuota(tourId);
throw new Exception("受检异常:默认不回滚,除非 rollbackFor 指定");
}
/**
* 场景四:readOnly ------ 纯查询标记只读,驱动可据此优化
* (MySQL 会不开事务快照/不记 undo log),误在只读事务里写会报错。
*/
@Transactional(readOnly = true)
public List<BookingOrder> listOrders() {
return orderDao.findAll();
}
/** 供 Controller 查询名额 */
@Transactional(readOnly = true)
public int quotaOf(long tourId) {
return tourDao.getQuota(tourId);
}
}
11.3.5 service/SelfInvocationService.java ------ 失效场景讲解
java
package com.lihaozhe.chapter11.service;
import org.springframework.stereotype.Service;
/**
* 事务失效演示(第11章:面试必考的五大失效场景)
*
* <p>理论知识 ------ @Transactional 失效的常见原因:
* <ol>
* <li>自调用:同类里 this.method() 不走代理,注解无效(本类演示);</li>
* <li>方法非 public:代理不拦截;</li>
* <li>异常被 try-catch 吞掉:代理看不到异常就不回滚;</li>
* <li>抛受检异常但没写 rollbackFor;</li>
* <li>数据库引擎不支持事务(如 MyISAM)。</li>
* </ol>
*/
@Service
public class SelfInvocationService {
private final BookingService bookingService;
public SelfInvocationService(BookingService bookingService) {
// 注入代理对象而不是自调用,是规避失效场景①的标准做法
this.bookingService = bookingService;
}
/**
* 反面教材(仅讲解用,不提供调用入口):
* <pre>
* @Transactional
* public void outer() {
* this.inner(); // ← this 是原始对象不是代理,inner 的 @Transactional 失效!
* }
* </pre>
* 正确姿势:像本类一样把 inner 拆到别的 Bean 注入进来,
* 或注入自身代理(ObjectProvider<SelfInvocationService>)。
*/
public void correctWay(String name, long tourId) {
// 通过容器里的代理对象调用 → 事务生效
bookingService.book(name, tourId, 100.0);
}
}
11.3.6 controller/BookingController.java
java
package com.lihaozhe.chapter11.controller;
import com.lihaozhe.chapter11.model.BookingOrder;
import com.lihaozhe.chapter11.service.BookingService;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;
import java.util.List;
/**
* 报名接口(第11章)
*/
@RestController
@RequestMapping("/bookings")
public class BookingController {
private final BookingService service;
public BookingController(BookingService service) {
this.service = service;
}
/** 正常报名:订单创建 + 名额扣减一起提交 */
@PostMapping
public BookingOrder book(@RequestParam String name,
@RequestParam long tourId,
@RequestParam(defaultValue = "100") double amount) {
return service.book(name, tourId, amount);
}
/** 回滚演示:报名到一半抛异常,观察名额与订单都没变 */
@PostMapping("/fail-demo")
public String failDemo(@RequestParam String name, @RequestParam long tourId) {
try {
service.bookThenFail(name, tourId, 200);
} catch (IllegalStateException e) {
return "触发回滚: " + e.getMessage() + " → 请查询 /tours/quota 与 /bookings 验证数据未变";
}
return "unreachable";
}
/** 受检异常演示(rollbackFor 对比实验) */
@PostMapping("/checked-demo")
public String checkedDemo(@RequestParam String name, @RequestParam long tourId) {
try {
service.bookWithCheckedException(name, tourId);
} catch (Exception e) {
return "受检异常被捕获: " + e.getMessage();
}
return "unreachable";
}
/** 查订单列表 */
@GetMapping
public List<BookingOrder> list() {
return service.listOrders();
}
/** 查某团剩余名额 */
@GetMapping("/quota/{tourId}")
public int quota(@PathVariable long tourId) {
return service.quotaOf(tourId);
}
}
11.3.7 TravelApplication.java 与 application.yml
java
package com.lihaozhe.chapter11;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
/**
* 第11章启动类:事务管理
*
* <p>理论知识 ------ 谁在管理事务?
* spring-boot-starter-jdbc 引入 DataSourceTransactionAutoConfiguration,
* 它自动注册 DataSourceTransactionManager(事务管理器)。
* @Transactional 只是声明,真正开启/提交/回滚由这个管理器完成。
* 换成 JPA 场景则自动换成 JpaTransactionManager------API 不变。
*/
@SpringBootApplication
public class TravelApplication {
public static void main(String[] args) {
SpringApplication.run(TravelApplication.class, args);
}
}
yaml
# 第11章:事务管理
# 端口规则:8080 + 章号 11 → 8111
server:
port: 8111
spring:
datasource:
# 同库 sb_travel,表前缀 t_ch11_
url: jdbc:mysql://118.31.221.165:3306/sb_travel?useSSL=false&serverTimezone=Asia/Shanghai&characterEncoding=UTF-8&allowPublicKeyRetrieval=true
username: root
password: lihaozhe
driver-class-name: com.mysql.cj.jdbc.Driver
hikari:
maximum-pool-size: 5
minimum-idle: 2
connection-timeout: 10000
sql:
init:
mode: always
# 打印 Spring 事务的开启/提交/回滚日志(学习神器)
# Boot 7 中事务管理器实现类是 JdbcTransactionManager(DataSourceTransactionManager 的子类)
logging:
level:
org.springframework.transaction: DEBUG
org.springframework.jdbc.support.JdbcTransactionManager: DEBUG
11.3.8 schema.sql 与 data.sql
sql
-- 第11章:旅行团表
CREATE TABLE IF NOT EXISTS t_ch11_tour (
id BIGINT AUTO_INCREMENT PRIMARY KEY COMMENT '主键',
name VARCHAR(100) NOT NULL COMMENT '团名',
quota INT NOT NULL DEFAULT 0 COMMENT '剩余名额'
) ENGINE = InnoDB DEFAULT CHARSET = utf8mb4 COMMENT '旅行团表';
-- 第11章:订单表
-- 注意:create_time 允许 NULL 是因为 SimpleJdbcInsert 会为未提供的列显式传 NULL,
-- 而 MySQL 的 DEFAULT 只在"列缺席"时生效(教学点)
CREATE TABLE IF NOT EXISTS t_ch11_order (
id BIGINT AUTO_INCREMENT PRIMARY KEY COMMENT '主键',
order_no VARCHAR(32) NOT NULL UNIQUE COMMENT '订单号',
tour_id BIGINT NOT NULL COMMENT '团id',
tourist_name VARCHAR(50) NOT NULL COMMENT '游客姓名',
amount DECIMAL(10,2) NOT NULL COMMENT '金额',
create_time DATETIME NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间'
) ENGINE = InnoDB DEFAULT CHARSET = utf8mb4 COMMENT '报名订单表';
sql
-- 第11章种子数据:一个名额只有 2 的团,方便观察扣减与回滚(幂等)
INSERT INTO t_ch11_tour (name, quota)
SELECT '呼伦贝尔5日深度游', 2
WHERE NOT EXISTS (SELECT 1 FROM t_ch11_tour WHERE name = '呼伦贝尔5日深度游');
11.4 运行与验证
bash
cd chapter11-transactions
mvn clean package -DskipTests
java -Dfile.encoding=UTF-8 -jar target/chapter11-transactions-1.0.0.jar
验证 1:正常报名(观察事务日志)
bash
curl -X POST "http://localhost:8111/bookings?name=张三&tourId=1&amount=2999"
→ {"orderNo":"T7E398D4D","id":...,"amount":2999.0,...}
curl http://localhost:8111/bookings/quota/1
→ 1 (2 - 1,扣减生效)
控制台事务日志(真实输出):
DEBUG o.s.jdbc.support.JdbcTransactionManager :
Creating new transaction with name [com.lihaozhe.chapter11.service.BookingService.book]:
PROPAGATION_REQUIRED,ISOLATION_DEFAULT
DEBUG o.s.jdbc.support.JdbcTransactionManager :
Committing JDBC transaction on Connection [HikariProxyConnection@...]
验证 2:异常回滚(本章高潮)
bash
curl -X POST "http://localhost:8111/bookings/fail-demo?name=李四&tourId=1"
→ 触发回滚: 模拟支付失败!...
curl http://localhost:8111/bookings/quota/1
→ 1 ← 名额没有再减!(回滚把 UPDATE 撤销了)
curl http://localhost:8111/bookings
→ 1 条 ← 李四的订单不存在!(回滚把 INSERT 撤销了)
控制台出现关键一行:
DEBUG o.s.jdbc.support.JdbcTransactionManager :
Rolling back JDBC transaction on Connection [HikariProxyConnection@...]
这就是原子性:两条 SQL 在同一个事务里,一荣俱荣,一损俱损。
验证 3:受检异常 + rollbackFor
bash
curl -X POST "http://localhost:8111/bookings/checked-demo?name=赵六&tourId=1"
→ 受检异常被捕获: 受检异常:默认不回滚,除非 rollbackFor 指定
curl http://localhost:8111/bookings/quota/1
→ 1 ← rollbackFor=Exception.class 配了,受检异常也回滚了
对比实验建议 :把
@Transactional(rollbackFor = Exception.class)的rollbackFor 删掉重启,再调一次------你会发现名额变成 0 了!
受检异常被"吞"进了已提交的事务。这是生产事故的经典来源。
全部通过 ✅
11.5 进阶理论:传播行为与隔离级别(面试重点)
传播行为(propagation):方法嵌套调用时事务怎么传递
| 常用值 | 行为 | 典型场景 |
|---|---|---|
| REQUIRED(默认) | 有事务就加入,没有就新建 | 绝大多数业务方法 |
| REQUIRES_NEW | 挂起当前事务,永远开新事务 | 操作日志(主事务回滚日志也要留) |
| NESTED | 嵌套事务(保存点),外层回滚则全回滚 | 子步骤可独立失败 |
| SUPPORTS | 有事务就用,没有就非事务跑 | 查询方法 |
java
// 例:无论报名成败,审计日志都必须写入
@Transactional(propagation = Propagation.REQUIRES_NEW)
public void auditLog(String action) { ... }
隔离级别(isolation):并发事务看到什么数据
| 级别 | 脏读 | 不可重复读 | 幻读 |
|---|---|---|---|
| READ_UNCOMMITTED | 可能 | 可能 | 可能 |
| READ_COMMITTED | 避免 | 可能 | 可能 |
| REPEATABLE_READ(MySQL 默认) | 避免 | 避免 | InnoDB 基本避免 |
| SERIALIZABLE | 避免 | 避免 | 避免 |
java
@Transactional(isolation = Isolation.READ_COMMITTED) // 按需覆盖
11.6 小结
| 知识点 | 一句话记忆 |
|---|---|
@Transactional |
代理对象拦截方法:正常 commit,RuntimeException 回滚 |
rollbackFor |
受检异常默认不回滚,必须显式指定 |
readOnly = true |
查询方法标记只读,性能优化 + 防误写 |
| 自调用失效 | this.method() 不走代理;拆 Bean 或注入自身代理 |
REQUIRES_NEW |
日志类需求:主事务回滚它也要留 |
| MySQL 默认隔离级别 | REPEATABLE_READ |
下一章预告 :MySQL 扛不住了怎么办?高频读取(景点详情、排行榜)
不该每次都打到数据库。第12章 Redis 入门:五大基本类型的操作姿势,
用旅游场景玩转点赞计数与实时排行榜。
11.7 动手练习
- 把
bookThenFail的异常改成IOException(受检),去掉 rollbackFor,验证名额会被扣掉(错误示范),再加回 rollbackFor 修复。 - 写一个
@Transactional(propagation = Propagation.REQUIRES_NEW)的审计日志方法,验证主事务回滚后日志仍在。 - 思考题:为什么
decreaseQuota要写AND quota > 0而不是先查后改?(提示:两个用户同时抢最后一个名额。)
11.8 本章使用的 Java 25 新特性
| 特性 | 说明 | 本章应用 |
|---|---|---|
| 文本块 (Java 15+) | 多行字符串字面量,消除繁琐的换行符拼接 | BookingOrderDao.java 中 SQL 查询、TourDao 中数据表名和字段名多行拼接 |
| var (Java 10+) | 局部变量类型推断,代码更简洁 | BookingOrderDao.java 中 var key = ...、var ts = ... 自动推断类型 |
| Map.of() (Java 9+) | 简化的不可变 Map 构建方式 | BookingOrderDao.java 中 Map.of("order_no", ..., "tour_id", ...) 构造插入参数 |
| StringBuilder 潜在优化 (Java 9+) | 字符串拼接性能提升(String + 优化) | 整个章节使用的字符串连接操作(如订单号生成、日志格式) |
为什么用现代语法? 文本块让 SQL 和长字符串更易读、可维护;
var减少类型冗余、保持代码整洁;Map.of()让数据构造更直观。这些特性都是提升开发效率和代码质量的标准实践,已成为现代 Java 项目的标配。