事务(8.23)

一、事务的概念和四大特性

1、概念

  • 核心定义:针对一组操作,具有原子性,要成功都成功,要失败都失败。
  • 适用范围:只针对 DML(数据操作语言)的增、删、改操作。

DML操作涉及两个层面:内存和数据文件。

  • DML 操作先在内存中完成,修改后的数据页称为脏页,不会立即写入磁盘。

  • 事务提交前,数据仅在内存中,可以回滚恢复。

  • 事务提交后,脏页由后台线程异步刷入磁盘,实现持久化。

image-20260823213740584

2、事务的四大特性

  • 原子性 :一个事务是不可分割的整体。
  • 隔离性 :不同的事务之间的操作互相不干扰。
  • 持久性 :事务操作的结果,是写入到数据文件(磁盘)中的,具有持久性。
  • 一致性 :事务执行前后,数据必须保持一致性状态。即要么成功都成功,要失败都失败,保证数据库从一个一致性状态变换到另一个一致性状态。

二、事务异常和隔离级别

1、事务的三种异常情况

(1)脏读:在一个事务处理过程中,读取了另一个未提交事务中的数据。

  • 事务 A 修改了一条数据但尚未提交,事务 B 却读取了这条待定的数据并据此进行处理。若事务 A 随后回滚,事务 B 基于错误数据做出的操作即为脏读。
时间线 事务 A 事务 B
T1 读取余额 = 100
T2 余额改为 150(未提交)
T3 读取余额 = 150(读到了未提交的数据)
T4 回滚,余额恢复为 100
T5 基于余额 150 进行后续处理

(2)不可重复读:在同一事务内,先后两次读取同一条记录,由于中间有其他事务修改并提交了该数据,导致两次读取结果不一致。

  • 事务 T1 读取某行数据后,事务 T2 修改该行并提交;T1 再次读取同一行时,发现数据已变。重点在于同一行数据的内容发生了变化。
时间线 事务 A 事务 B
T1 读取余额 = 100
T2 读取余额 = 100
T3 余额改为 200,提交
T4 再次读取余额 = 200(与第一次读取结果不一致)

(3)幻读:在同一事务内,使用相同查询条件多次检索数据,由于其他事务插入了符合该条件的新行,导致前后两次查询返回的行数或结果集不一致。

  • 事务 A 查询“成绩 > 90”的学生有 10 人,事务 B 插入一名成绩为 95 的学生并提交;事务 A 再次查询时发现变成了 11 人,仿佛出现了“幻觉”。重点在于数据行数的变化。
时间线 事务 A 事务 B
T1 查询成绩 > 90 的学生,结果 = 10 人
T2 插入一名成绩为 95 的学生,提交
T3 再次查询成绩 > 90 的学生,结果 = 11 人(多了一条)

不可重复读 vs 幻读:

对比项 不可重复读 幻读
关注点 同一条数据的内容变化 数据条数的变化
原因 其他事务修改了数据 其他事务新增或删除了数据
读取的数据 已提交 已提交

2、事务的四种隔离级别

​ 事务的隔离级别决定了并发事务之间的隔离程度,隔离级别越低,越可能出现脏读、不可重复读、幻读等并发问题。从上往下,隔离程度越来越高,安全性越来越好,但并发效率越来越低。

(1)读未提交

最低级别。事务可以读到其他事务未提交的数据。

  • 不能防止脏读
  • 不能防止不可重复读
  • 不能防止幻读
  • 并发性能最高,但几乎不在实际中使用

(2)读已提交

事务只能读到其他事务已提交的数据。

  • 防止脏读
  • 不能防止不可重复读
  • 不能防止幻读
  • Oracle 的默认隔离级别

(3)可重复读

同一个事务内,多次读取同一行数据,结果一致。

  • 防止脏读
  • 防止不可重复读
  • 不能防止幻读(InnoDB 通过 MVCC + Next-Key Lock 大部分解决了幻读)
  • MySQL 的默认隔离级别

(4) 可串行化

最高级别。所有事务串行执行,完全隔离。

  • 防止脏读
  • 防止不可重复读
  • 防止幻读
  • 防止第二类更新丢失
  • 并发性能最低,容易出现锁竞争和超时。因此实际生产中几乎不会用这个级别,取而代之的是用乐观锁和悲观锁等机制,在保证正确性的前提下获得更好的并发性能。

总结:

隔离级别 脏读 不可重复读 幻读
读未提交 (Read Uncommitted) √ √ √
读已提交 (Read Committed) × √ √
可重复读 (Repeatable Read) × × √
可串行化 (Serializable) × × ×

拓展:

1、两类更新丢失

(1)第一类更新丢失(回滚丢失)

​ 事务 A 修改了数据,事务 B 也修改了同一数据,然后事务 A 回滚了,把事务 B 的修改也一起撤销了。

时间线 事务 A 事务 B
T1 读取余额 = 100
T2 读取余额 = 100
T3 余额改为 150,提交
T4 余额改为 70,提交
T5 发现操作有误,回滚

结果:最终余额恢复为 100,事务 B 提交的修改被连带撤销了。

(2)第二类更新丢失(覆盖丢失)

​ 两个事务都提交了,但后提交的覆盖了先提交的,导致先提交的修改丢失。

时间线 事务 A 事务 B
T1 读取余额 = 100
T2 读取余额 = 100
T3 余额改为 150,提交
T4 余额改为 70,提交

结果:最终余额变成 70,事务 A 加的 50 块钱凭空消失了。

2、乐观锁与悲观锁

实际开发中,通常不用可串行化来解决并发问题,而是用以下两种锁机制:

(1)乐观锁

不加锁,在更新时通过版本号或时间戳判断数据是否被修改过,如果被改过则更新失败,由业务层重试。

  • 适合读多写少的场景
  • 优点:不加锁,并发性能好
  • 缺点:冲突多时重试成本高

版本号机制实现流程:

  1. 数据表中增加一个 version 字段,表示数据被修改的次数
  2. 读取数据时,同时读取 version 值
  3. 更新时,判断当前 version 是否与读取时一致,一致才允许更新,并将 version + 1
  4. 如果不一致,说明数据已被其他事务修改,更新失败,由业务层决定是否重试
-- 读取数据
SELECT balance, version FROM account WHERE id = 1;

-- 更新数据(带上版本号条件)
UPDATE account SET balance = 150, version = version + 1 
WHERE id = 1 AND version = 1;

如果 UPDATE 返回的影响行数为 0,说明版本号已变,更新失败。

(2)悲观锁

操作数据前就加锁(如 SELECT ... FOR UPDATE),其他事务必须等锁释放后才能操作。

  • 适合写多的场景
  • 优点:保证数据一致性
  • 缺点:容易出现锁竞争和死锁

实现方式:

-- 查询时加排他锁,其他事务无法修改该行数据
SELECT balance FROM account WHERE id = 1 FOR UPDATE;

-- 执行更新
UPDATE account SET balance = 150 WHERE id = 1;

-- 事务提交后,锁自动释放
COMMIT;

在事务提交之前,其他事务尝试获取同一行数据的锁时会被阻塞,直到当前事务提交或回滚。

一句话总结:乐观锁 = 先操作再检查,悲观锁 = 先加锁再操作。两者都是在保证数据正确性的前提下,比可串行化获得更好的并发性能。

三、事务回滚与异常类型

默认只对 RuntimeException 及其子类、Error 及其子类回滚,而 Checked Exception(如 IOException、SQLException)默认不回滚。

  • 抛运行时异常及其子类 → **回滚 **
  • 抛检查异常(IOException、SQLException 等)→ **不回滚 **,看着就像事务失效
  • 想让检查异常也回滚:@Transactional(rollbackFor = Exception.class)
  • 异常被 try-catch 吞掉、没往外抛 → 也不回滚

四、事务的传播属性

场景:访问自己的数据走 自己的 Service → 自己的 Mapper;访问别人的数据必须先调别人的 Service,再由它去调自己的 Mapper,不能直接调别人的 Mapper。所以自己和别人的 Service 上都要加 @Transactional。

传播属性写在「被调用方 B」上,它决定 B 怎么对待 A 已有的那个事务。设 A = 调用方,B = 被调用方:

B 的传播属性(默认 REQUIRED) A 有事务时,B 怎么做 A 没有事务时,B 怎么做
REQUIRED 加入 A 的事务,一起提交 / 回滚 自己新建一个事务
REQUIRES_NEW 挂起 A 的事务,自己新建独立事务 自己新建一个事务
SUPPORTS 加入 A 的事务 不用事务,直接跑
NOT_SUPPORTED 挂起 A 的事务,自己不用事务 不用事务,直接跑
MANDATORY 加入 A 的事务 直接抛异常
NEVER 直接抛异常 不用事务,直接跑
NESTED 在 A 的事务里开嵌套子事务(保存点 Savepoint) 自己新建一个事务

记法:REQUIRED 有则加入无则新建;REQUIRES_NEW 父子互不影响;NESTED 子事务回滚只回到保存点、父事务回滚子事务跟着回滚。

五、事务什么时候失效

  • 抛的是检查异常(默认不回滚)
  • 同一个 Service 类里 A 调 B,注解写在被调用的 B 上 —— 走 this 不走代理,拦截不到
  • 异常被 catch 吞掉、没往外抛
  • 方法不是 public(或 final / static)
  • 类不是 Spring Bean(new 出来的 / 没加 @Service)
  • 数据库引擎不支持事务(MyISAM)
  • 传播属性设成 NOT_SUPPORTED / NEVER,或 MANDATORY 但外层没事务
  • 多数据源没指定 transactionManager
  • 另开线程 / @Async 异步调用
posted on 2026-09-12 11:26  冬冬咚  阅读(24)  评论(0)    收藏  举报