事务(8.23)
一、事务的概念和四大特性
1、概念
- 核心定义:针对一组操作,具有原子性,要成功都成功,要失败都失败。
- 适用范围:只针对 DML(数据操作语言)的增、删、改操作。
DML操作涉及两个层面:内存和数据文件。
-
DML 操作先在内存中完成,修改后的数据页称为脏页,不会立即写入磁盘。
-
事务提交前,数据仅在内存中,可以回滚恢复。
-
事务提交后,脏页由后台线程异步刷入磁盘,实现持久化。

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)乐观锁
不加锁,在更新时通过版本号或时间戳判断数据是否被修改过,如果被改过则更新失败,由业务层重试。
- 适合读多写少的场景
- 优点:不加锁,并发性能好
- 缺点:冲突多时重试成本高
版本号机制实现流程:
- 数据表中增加一个
version字段,表示数据被修改的次数 - 读取数据时,同时读取
version值 - 更新时,判断当前
version是否与读取时一致,一致才允许更新,并将version + 1 - 如果不一致,说明数据已被其他事务修改,更新失败,由业务层决定是否重试
-- 读取数据
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异步调用
浙公网安备 33010602011771号