Skip to content

事务处理


新增操作

在系统中想要完成“新增”操作,通常都绕不开三件事:

接收请求 → 写入基础信息 → 写入关联信息

比如在狼群管理系统中,当管理员录入一只新狼时,除了基础信息(名字、毛色、年龄等),还可能一并登记它过去的狩猎经历。
这意味着,新增功能不只是单表插入,而是一整个完整的业务流程。

为了实现这一流程,我们通常会按照固定的思路来拆解步骤:

  1. 完成准备工作,引入实体类、Mapper 接口、XML 映射文件,以及用于接收请求参数的实体类。
  2. 保存狼的基础信息。
  3. 批量保存狼的狩猎经历信息。

先搭好这条主线,后续的所有细节都围绕它展开。

准备工作

新增功能的第一步,是把基础骨架搭好。
这一步没有复杂的逻辑,目标很单纯——让系统能够接收到一条完整的“狼”的信息,并具备写入数据库的能力。

在这个案例中,一只狼的数据拆成了两部分:

  • 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 就是新增操作的关键,它能让我们在一次请求中同时带上多条狩猎经历信息。

保存数据

新增流程的核心在于两步:

  1. 先存基础信息
  2. 再批量存经历信息

关键问题在于如何拿到数据库生成的主键,并用它把经历记录一次性挂到这只狼名下。

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 表中:

  1. 遍历经历列表;
  2. 给每条经历补上 wolfId
  3. 使用批量插入语句写进数据库。

如果一条条插入,每执行一次 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。完成这一步后,一只狼和它的全部狩猎经历就正式落进数据库了。

事务处理

到目前为止,新增操作分成了两步:

  1. 插入基础信息(wolf);
  2. 批量插入狩猎经历(hunt_expr)。

这两步在逻辑上是一个整体
如果第一步失败、第二步成功,就可能出现一种非常糟糕的情况——数据库里多了一些“没有对应狼的狩猎经历”。

这会导致数据不一致,也就是所谓的脏数据

在真实业务中,这种情况是绝对不能接受的。
我们必须确保:

  • 要么两步都成功;
  • 要么两步都失败,数据恢复到初始状态。

要达到这种“要么全有,要么全无”的效果,就得靠——事务。

Mysql 的事务控制

事务(Transaction)是一组操作的集合,它是一个不可分割的工作单元。这些操作要么一起提交,要么一起撤销
一旦有任何一步失败,就会触发回滚,保证数据库数据的一致性。

默认情况下,MySQL 是“自动提交模式”:
执行一条 INSERTUPDATEDELETE,就会立刻生效,相当于系统在你每条 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 四大特性

谈到事务,必须理解它的四个核心特性,也就是经典的 ACID 模型。
这是事务可靠性的基础,也是面试中最常被问到的知识点之一。

  • 原子性确保成败一体;
  • 一致性保证状态不乱;
  • 隔离性应对并发冲突;
  • 持久性保证结果可信。

原子性(Atomicity)
事务是最小的执行单元,不可再分。
要么所有操作都执行成功,要么全部回滚,就像一根绳上的珠子,要掉就全掉。

一致性(Consistency)
事务执行前后,数据必须处于合法且一致的状态。
比如插入一只狼的基础信息和经历,要么两者都存在,要么都不存在,不能只剩一半。

隔离性(Isolation)
多个事务并发执行时,彼此之间互不干扰。
就算有多个用户同时插入、修改,也不能让操作互相“踩”到。

持久性(Durability)
事务一旦提交,数据就被永久写入数据库。
即使系统突然宕机,事务的结果也不会丢失。

评论