深浅模式
新增操作
在系统中想要完成“新增”操作,通常都绕不开三件事:
接收请求 → 写入基础信息 → 写入关联信息比如在狼群管理系统中,当管理员录入一只新狼时,除了基础信息(名字、毛色、年龄等),还可能一并登记它过去的狩猎经历。
这意味着,新增功能不只是单表插入,而是一整个完整的业务流程。
为了实现这一流程,我们通常会按照固定的思路来拆解步骤:
- 完成准备工作,引入实体类、Mapper 接口、XML 映射文件,以及用于接收请求参数的实体类。
- 保存狼的基础信息。
- 批量保存狼的狩猎经历信息。
先搭好这条主线,后续的所有细节都围绕它展开。
准备工作
新增功能的第一步,是把基础骨架搭好。
这一步没有复杂的逻辑,目标很单纯——让系统能够接收到一条完整的“狼”的信息,并具备写入数据库的能力。
在这个案例中,一只狼的数据拆成了两部分:
wolf—— 记录狼的基础信息;
sql
-- 狼基础信息表
create table wolf (
id int primary key auto_increment,
name varchar(50),
color varchar(20),
age int,
create_time datetime,
update_time datetime
);hunt_expr—— 记录它的狩猎经历信息。
sql
-- 狩猎经历表
create table hunt_expr (
id int primary key auto_increment,
wolf_id int,
region varchar(50),
begin_date date,
end_date date,
foreign key (wolf_id) references wolf(id)
);
wolf_id是关键,它用来把一只狼和它的多条经历记录关联起来。
接下来,我们需要准备好三类内容来支撑这条新增链路:
- 实体类:用于封装请求参数,并映射到数据库表;
- Mapper 与 XML:负责执行数据库插入;
- Controller / Service:承接外部请求,编排业务逻辑。
实体类如下:
java
// Wolf.java
public class Wolf {
private Integer id;
private String name;
private String color;
private Integer age;
private LocalDateTime createTime;
private LocalDateTime updateTime;
private List<HuntExpr> exprList; // 狩猎经历列表
}
// HuntExpr.java
public class HuntExpr {
private Integer id;
private Integer wolfId;
private String region;
private LocalDate beginDate;
private LocalDate endDate;
}这里的
exprList就是新增操作的关键,它能让我们在一次请求中同时带上多条狩猎经历信息。
保存数据
新增流程的核心在于两步:
- 先存基础信息
- 再批量存经历信息
关键问题在于如何拿到数据库生成的主键,并用它把经历记录一次性挂到这只狼名下。
MyBatis 提供了两种方式把主键带回来:
保存基础信息
注解方式
注解只需指定两个核心参数即可:
useGeneratedKeys = true:开启返回主键;keyProperty = "id":告诉 MyBatis 把主键写回到wolf.id字段。
java
@Options(useGeneratedKeys = true, keyProperty = "id")
@Insert("insert into wolf(name, color, age, create_time, update_time) " +
"values(#{name}, #{color}, #{age}, #{createTime}, #{updateTime})")
void insert(Wolf wolf);XML 方式
等价的 XML 方式如下
xml
<insert id="insert" useGeneratedKeys="true" keyProperty="id">
insert into wolf(name, color, age, create_time, update_time)
values(#{name}, #{color}, #{age}, #{createTime}, #{updateTime})
</insert>批量保存经历信息
基础信息插入完成后,我们就已经能拿到数据库生成的主键 ID 了:
java
Integer wolfId = wolf.getId();接下来要做的,就是把前端接收到的,对应的多条狩猎经历,一次性写进 hunt_expr 表中:
- 遍历经历列表;
- 给每条经历补上
wolfId; - 使用批量插入语句写进数据库。
如果一条条插入,每执行一次 SQL 就要与数据库交互一次,性能会很差。所以我们选择批量插入,只需要一次数据库交互,速度更快,也能保证同一批数据一起落地。
MyBatis 中最方便的方式就是使用 <foreach> 标签来动态拼接多条 values。
xml
<insert id="insertBatch">
insert into hunt_expr(wolf_id, region, begin_date, end_date)
<foreach collection="exprList" item="expr" separator="," open="values">
(#{expr.wolfId}, #{expr.region}, #{expr.beginDate}, #{expr.endDate})
</foreach>
</insert>collection:指定遍历的集合,这里对应的是请求中携带的exprList;item:遍历出来的单个元素separator:在每两条 values 之间加的分隔符open/close:控制整段拼接的前后结构,保证 SQL 语句最终形如values (...), (...), (...)。
如果经历列表为空,业务层就应该直接跳过,不要让 <foreach> 拼出空 SQL。完成这一步后,一只狼和它的全部狩猎经历就正式落进数据库了。
事务处理
到目前为止,新增操作分成了两步:
- 插入基础信息(
wolf); - 批量插入狩猎经历(
hunt_expr)。
这两步在逻辑上是一个整体。
如果第一步失败、第二步成功,就可能出现一种非常糟糕的情况——数据库里多了一些“没有对应狼的狩猎经历”。
这会导致数据不一致,也就是所谓的脏数据。
在真实业务中,这种情况是绝对不能接受的。
我们必须确保:
- 要么两步都成功;
- 要么两步都失败,数据恢复到初始状态。
要达到这种“要么全有,要么全无”的效果,就得靠——事务。
Mysql 的事务控制
事务(Transaction)是一组操作的集合,它是一个不可分割的工作单元。这些操作要么一起提交,要么一起撤销。
一旦有任何一步失败,就会触发回滚,保证数据库数据的一致性。
默认情况下,MySQL 是“自动提交模式”:
执行一条 INSERT、UPDATE 或 DELETE,就会立刻生效,相当于系统在你每条 DML(数据修改语句)后面都自动执行了 commit。
如果第二条语句执行失败,第一条已经生效了,没法再自动撤回。所以我们需要手动开启事务,把这几条操作“绑”在一起。
sql
-- 开启事务
start transaction;
-- 1. 插入基础信息
insert into wolf values (null, '灰牙', '灰色', 3, now(), now());
-- 2. 插入狩猎经历
insert into hunt_expr(wolf_id, region, begin_date, end_date)
values (last_insert_id(), '暗林', '2022-01-01', '2022-05-01'),
(last_insert_id(), '雪原', '2023-01-01', '2023-06-01');
-- 成功时提交
commit;
-- 出错时回滚
rollback;只要在同一个事务里操作,一旦其中某一步报错,执行 rollback 就能让数据“撤回”到执行前的状态,确保不会出现孤零零的一条基础记录。
Spring 中的事务控制
在 Spring 里,我们只要在 Service 方法上加一个注解,Spring 就能自动帮我们完成事务的开启与回滚:
java
@Transactional
public void save(Wolf wolf) {
wolfMapper.insert(wolf);
Integer wolfId = wolf.getId();
List<HuntExpr> exprList = wolf.getExprList();
if (exprList != null && !exprList.isEmpty()) {
exprList.forEach(expr -> expr.setWolfId(wolfId));
huntExprMapper.insertBatch(exprList);
}
}- 方法执行前,Spring 会自动开启事务;
- 所有操作正常完成,就自动提交;
- 如果中途抛出异常,就会自动回滚。
@Transactional 注解其实既可以加在方法上,也可以加在类上,甚至是接口上:
- 加在类上,表示类中所有公共方法都受事务管理;
- 加在方法上,只对这个方法生效。
在实际开发中,更推荐直接加在方法上,这样事务的范围最清晰,也更容易控制。
比如你只想让“新增狼”这个方法具备事务,而不影响其他查询或辅助操作,这样的粒度更合适。
事务调试
在开发过程中,我们经常需要确认事务是否真的生效,比如事务有没有成功开启、是否在预期的地方回滚。
这时,最直接的方式就是——打开事务日志。
properties
# 开启 Spring 事务管理的 debug 级别日志
logging.level.org.springframework.jdbc.support.JdbcTransactionManager=debug开启后,控制台会清楚地打印出事务的开启、提交、回滚等关键过程,非常直观。
一旦事务出现异常,你也能从日志中快速定位问题发生在哪一步。
除了 debug,Spring 还支持不同的日志等级。
通过调整日志等级,可以灵活控制输出信息的详细程度:
| 等级 | 说明 |
|---|---|
error | 只输出严重错误 |
warn | 输出警告和错误 |
info | 输出基本运行信息(适合生产环境) |
debug | 输出调试信息,包括事务的开启、提交与回滚 |
trace | 输出更细节的调试信息(包括调用链路,几乎所有细节) |
日常开发调试时建议使用 debug;
若要深入排查事务执行细节,比如多层调用间的传播行为,可以临时调高到 trace。
日志就是你事务是否正常工作的“放大镜”。
事务进阶
事务不仅能保证“要么全成,要么全败”,还能更灵活地控制在什么情况下回滚、事务之间怎么协作。
这主要依赖两个常用属性:
rollbackFor决定事务遇到什么异常时会回滚,适用于业务异常处理;propagation决定事务之间的协作方式,影响的是多层业务的执行边界。
rollbackFor
Spring 默认只在遇到 RuntimeException 时才会自动回滚,
如果抛出的是普通 Exception,事务并不会回滚。
在实际开发中,业务异常往往是自定义的 Exception,这就需要显式指定:
java
@Transactional(rollbackFor = Exception.class)
public void saveWolf(Wolf wolf) throws Exception {
wolfMapper.insert(wolf);
throw new Exception("模拟业务异常");
}这样,无论抛出的是运行时异常还是检查型异常,事务都会回滚。
如果你的业务中存在自定义异常,这是一个非常常用的配置。
propagation
当一个事务方法调用另一个事务方法时,Spring 需要知道它们之间该怎么协作。
最常用的就是下面两种传播行为:
REQUIRED(默认)
如果当前有事务,就加入;没有就新建。
—— 适合主业务和子业务必须“同进同退”的场景。REQUIRES_NEW
无论外层有没有事务,都新建一个事务,互不影响。
—— 常用于记录日志、补偿操作等不希望被主业务回滚影响的逻辑。
java
@Transactional(propagation = Propagation.REQUIRED)
public void addWolf(Wolf wolf) {
wolfMapper.insert(wolf);
huntExprService.saveExpr(wolf.getExprList()); // 跟主事务绑定
}
@Transactional(propagation = Propagation.REQUIRES_NEW)
public void saveLog(String message) {
logMapper.insert(message); // 独立事务,不跟随主事务回滚
}这两个配置是事务控制的重点,一旦掌握,你就能在复杂业务中精确控制事务行为。
事务进阶
事务的存在,是为了让数据库操作保持可靠和一致。四大特性 ACID 描述了事务应该达到的标准,是“为什么要有事务”;而事务的五个配置维度,则是“如何在实际开发中让事务落地、可控”。
四大特性
原子性(Atomicity)
事务是最小的执行单元,不可再分。
要么所有操作都执行成功,要么全部回滚,确保不会出现“只完成一半”的情况。
一致性(Consistency)
事务执行前后,数据必须处于合法且一致的状态。
即数据的完整性约束始终成立,不会出现孤立或不匹配的数据。
隔离性(Isolation)
多个事务并发执行时,彼此之间互不干扰。
通过控制读写的可见性,防止出现脏读、不可重复读或幻读等问题。
持久性(Durability)
一旦事务提交,结果就被永久保存到数据库中。
系统通常通过日志(如 redo log)来保证,即使服务器突然中断,也能根据日志恢复数据。
五个维度
如果说 ACID 是目标,那么这五个维度就是实现目标的“手段”。
它们由 @Transactional 注解提供,让我们能灵活地控制事务的行为。
Propagation 传播行为
传播行为决定了一个方法的事务如何与外部事务交互。Spring 默认的传播行为是 REQUIRED,也是最常用的一种。
REQUIRED(默认)
有事务就加入,没有就新建。
它让所有调用链上的方法都运行在同一个事务中,
一旦其中一个出错,整个事务都会回滚。
java
@Service
public class WolfService {
@Transactional
public void huntTogether() {
howl(); // 调用另一个带事务方法
}
@Transactional(propagation = Propagation.REQUIRED)
public void howl() {
// 共享同一个事务
}
}含义:
如果外层已有事务,就加入进去;否则新开一个。
优点是保持一致性,缺点是牵连性强,出错会全滚。
REQUIRES_NEW
每次都新建事务,与外层事务完全独立。
外层事务会被挂起,等内部事务提交或回滚后再继续执行。
内部事务失败不会影响外层事务。
java
@Transactional
public void hunt() {
recordLog(); // 即使日志记录失败,也不影响主事务提交
}
@Transactional(propagation = Propagation.REQUIRES_NEW)
public void recordLog() {
// 新开独立事务
}含义:
内外事务互不干扰,常用于“记录日志”、“发送通知”等辅助操作。
适合那些“主流程失败也要保留痕迹”的场景。
NESTED
嵌套事务,介于 REQUIRED 和 REQUIRES_NEW 之间。
在外层事务中建立一个保存点(Savepoint)。
内部事务失败,只会回滚到保存点,不会影响外层之前的操作;
但如果外层事务出错,则整体都会回滚。
java
@Transactional
public void mission() {
patrol(); // 内层嵌套事务
}
@Transactional(propagation = Propagation.NESTED)
public void patrol() {
// 在外层事务内建立保存点
}含义:
“同船不同舱”,能局部回滚,但仍受外层控制。
常用于复杂业务中需要分阶段提交的情况。
还有一些常用的:
| 类型 | 含义 | 是否新开事务 | 外层事务回滚时的行为 |
|---|---|---|---|
SUPPORTS | 外层有事务就加入,没有就非事务运行 | 否 | 无事务不回滚 |
MANDATORY | 必须存在事务,否则抛异常 | 否 | 无事务报错 |
NOT_SUPPORTED | 不允许有事务,若有则挂起 | 否 | 不参与事务 |
NEVER | 必须在无事务环境中运行,否则报错 | 否 | 无事务运行 |
Isolation 隔离级别
数据库在并发时,会遇到三种问题:
- 脏读(Dirty Read):
事务 A 读到了事务 B 尚未提交的数据。
→ 如果 B 回滚,A 就读到假数据。 - 不可重复读(Non-repeatable Read):
事务 A 在两次查询同一行数据时,结果不同。
→ 因为事务 B 在这期间修改并提交了这行数据。 - 幻读(Phantom Read):
事务 A 两次查询同一个范围的记录,却发现数量变了。
→ 因为事务 B 在期间插入或删除了新行。
SQL 标准定义了四个隔离级别(从低到高):
| 隔离级别 | 能否脏读 | 能否不可重复读 | 能否幻读 | 含义 |
|---|---|---|---|---|
| READ UNCOMMITTED | ✅ | ✅ | ✅ | 可以读取未提交数据(几乎无隔离) |
| READ COMMITTED | ❌ | ✅ | ✅ | 只能读到已提交的数据(Oracle 默认) |
| REPEATABLE READ | ❌ | ❌ | ✅ | 多次读取结果一致(MySQL 默认) |
| SERIALIZABLE | ❌ | ❌ | ❌ | 串行执行事务,隔离最强但效率最低 |
隔离越强,性能越低。
MySQL 默认是 可重复读(REPEATABLE READ),因为它能防大多数问题,性能也不错。
Timeout 超时
当一个事务执行太久,比如某条 SQL 被锁住、死循环、资源争用,就可能拖垮数据库连接。
@Transactional(timeout = 5) 表示:这个事务最多执行 5 秒,超时就自动回滚。
这个参数常用于防止死锁或大事务“霸占资源”。
事务超时的核心目标是为了——
防止一个事务长时间占用资源,导致数据库被拖慢或死锁。
也就是说,它让系统有个“止损点”:
- 超过时间没结果 → 自动回滚;
- 释放连接、锁资源,防止整个系统被卡死。
如果不显式写 timeout = 秒数,事务就会一直等、一直卡,除非数据库或连接池自己断开。
所以在一些重要的核心接口(比如支付、下单、库存扣减),通常会显式设置超时,比如 5 秒或 10 秒,防止卡住整个线程池。
readOnly 只读
在很多情况下,一个事务只是为了查询数据,
并不会做任何修改或写入。
这时我们可以在事务上加上 readOnly = true 来告诉 Spring:
“这只是读,不会改,你可以用更轻量的方式处理它。”
Spring 在接收到这个提示后,会:
- 在某些数据库驱动层启用优化,比如不加行锁、减少日志写入;
- ORM 框架(如 Hibernate)也会跳过脏检查(Dirty Checking),加快查询性能。
简单写法:
java
@Transactional(readOnly = true)
public List<Wolf> getAllWolves() {
return wolfMapper.findAll();
}含义:
只做查询,不做修改,性能更高,安全性更强。
如果你在只读事务里执行了 INSERT、UPDATE、DELETE,数据库不会阻止(除非特意配置),但这是违反设计意图的,可能导致性能异常或警告。
Rollback Rules 回滚规则
默认情况下,Spring 只会在抛出 RuntimeException(运行时异常) 或 Error 时回滚。
如果是受检异常(Checked Exception,例如 IOException、SQLException),则不会自动回滚。
这时候我们可以用 rollbackFor 明确指定哪些异常要回滚:
java
@Transactional(rollbackFor = Exception.class)
public void updateWolf() throws Exception {
// 所有异常都会触发回滚
}反过来,noRollbackFor 可以指定哪些异常不要回滚:
java
@Transactional(noRollbackFor = CustomWarnException.class)
public void saveLog() {
// 即使抛出 CustomWarnException,也不会回滚
}默认只回滚运行时异常;
想让受检异常也回滚 → 写上 rollbackFor;
想让某些异常不回滚 → 写上 noRollbackFor。

评论