事务
定义
什么是事务? 事务是指一组操作要么全部成功,要么全部失败的执行单位。
在数据库中,一个事务通常包含一组SQL语句,系统保证这些语句作为一个整体执行。
为什么引入? 想象一下,若没有事务银行中的转账操作可能会发生:A对B转账,A这边已扣款但B却没收到。这不是亏麻了。再比如,多个线程同时操作一条数据,那么这条数据该听谁的呢?引入事务就是为了解决这些问题,保证数据的完整性,一致性和可靠性。
特性: 有四大特性:
特性 | 全称 | 含义 |
---|---|---|
A | Atomicity(原子性) | 操作不可分割,要么全成功,要么全失败 |
C | Consistency(一致性) | 执行完事务后,数据要从一个一致状态转到另一个一致状态 |
I | Isolation(隔离性) | 并发事务之间互不干扰 |
D | Durability(持久性) | 提交后的数据必须永久保存,即使系统宕机 |
并发事务
并发事务常见的问题: 了解事务的基本原理后,我们在数据库执行多个事务时,实际上是并发执行。但就像线程并发执行,又会引起很多问题。主要归为以下三类:
(1)脏读:指事务1在访问数据A时,事务2在修改数据A,此时事务1拿到的数据不是最终数据。
如何解决?只需要在事务1中写的过程加锁(可以理解为多线程编程的锁),也就是写的过程不会受到任何外部的干扰。任何事务读到的肯定是写完之后的数据了。
(2)不可重复读:虽然在写的过程加锁可以解决脏读问题,但是没说读的时候不能写啊? 假设事务1读数据A时,事务2在写数据A,事务1读到一半的时候,事务2写完了。此时事务1假设继续读下去,发现内容与前文不一致了。这就是不可重复读。
如何解决?显而易见,给读的过程也加锁。这样读写都不被干预,那么这就不管怎样都安全了吗?
(3)幻读:显然还是不安全,我们考虑一种情况,事务1,2在数据库中读写都加锁了。事务1读取数据A,事务2读取数据A,在事务2读取A时,事务1不能读取A,但它可以操纵数据库的其他的数据。假设事务1新增数据B,事务2在读完数据A后,再查询数据库有多少条数据。发现多了一条,这就是“不敢睁开眼,希望是我的幻觉”(歌词哈哈),大家理解记忆这就是幻读!(当然我理解的可能也有偏差,不过大多数文章都以新增数据举例)。
如何解决?直接彻底串行化,事务2干活的同时,事务1彻底不许做别的了。
问题 | 描述 | 举例 |
---|---|---|
脏读(Dirty Read) | 一个事务读到另一个事务尚未提交的数据 | T1更新数据,T2读取了这个数据,后来T1回滚,T2读到的是无效数据 |
不可重复读(Non-repeatable Read) | 同一事务中多次读取结果不一致 | T1两次读取某条记录,中间T2修改了这条记录 |
幻读(Phantom Read) | 同一事务中两次查询数据条数不一致 | T1读取符合条件的所有记录,T2新增了一条符合条件的数据,T1再次读取发现“多出一条” |
隔离级别 | 描述 | 会不会出现脏读 | 不可重复读 | 幻读 |
---|---|---|---|---|
READ UNCOMMITTED | 最低,不加锁 | ✅ 有 | ✅ 有 | ✅ 有 |
READ COMMITTED | 读已提交(Oracle 默认) | ❌ 无 | ✅ 有 | ✅ 有 |
REPEATABLE READ | 可重复读(MySQL 默认) | ❌ 无 | ❌ 无 | ✅ 有(InnoDB 用间隙锁避免) |
SERIALIZABLE | 串行化,最高隔离 | ❌ 无 | ❌ 无 | ❌ 无 |
代码实现
不做重点,感兴趣的可以直接在项目中练习
Connection conn = null;
try {
conn = dataSource.getConnection();
conn.setAutoCommit(false); // 开启事务
// 执行多个 SQL 语句
updateAccount1(conn);
updateAccount2(conn);
conn.commit(); // 提交事务
} catch (Exception e) {
if (conn != null) conn.rollback(); // 回滚事务
} finally {
if (conn != null) conn.close(); // 关闭连接
}
MVCC
定义
介绍完事务后,我们可以介绍一种更轻量化的解决事务并发问题的方法。讲之前简单讲一下,事务是怎么实现全不做的? 可能会有疑问,事务执行了多个操作,还差几个操作就执行完了,这个时候突发紧急情况不能做了,怎么把之前的操作取消呢。实际上可以理解为数据库已经将之前的版本的数据记录下来,将之前操作所修改的数据全部还原。这就是回滚。
什么是MVCC? MVCC -多版本并发控制,它允许事务在不加锁的情况下并发读写,通过维护数据的多个版本实现。神奇吧,竟然不用加锁也可以实现。那么它是怎样版本控制的呢?
为什么引入? 讲原理之前,先要了解之前加锁方案存在的缺陷以及MVCC的目的
加锁方案主要有以下两个问题
传统机制 | 问题 |
---|---|
读加共享锁,写加排他锁 | 读写互相阻塞,效率低下 |
高并发下,锁冲突频繁 | 会造成 锁等待、死锁、性能瓶颈 |
MVCC目的:
在 无需加锁的前提下,实现“读写分离”、高并发读操作的一致性。也就是避免锁的使用
核心机制
原理
(1)在InnoDB中,MVCC维护每一行的数据多个版本来实现。主要依靠两个字段和undo log机制。
(2)两个字段是指数据库为每条记录隐藏的维护了两个字段分别是更新此记录的事务id和回滚指针。
字段 | 含义 |
---|---|
trx_id | 插入或最后修改该行的事务ID |
roll_pointer | 指向 undo log 中上一个版本的地址,形成版本链 |
每个事务启动时,系统会分配一个全局递增的事务ID。
(3)undo log
undo log 大家可理解为旧账本,就像夫妻俩一旦吵架就要翻旧账。事务也是如此发生冲突,直接翻旧账。 具体为当一个事务更新记录时,数据库首先将未更新的记录先保存到undo log中,然后事务才可以更新记录,并将记录中的roll_pointer 指向此旧帐本对应的记录。
[当前版本]
trx_id: 15
roll_pointer --> undo log #1
[undo log #1]
trx_id: 12
roll_pointer --> undo log #2
[undo log #2]
trx_id: 9
(4)那如何通过翻旧账实现可见性呢?
这里要注意,我们在这通常针对的读操作不加锁,写操作数据库一般默认都会加锁,我们尽可能减少的是读操作的加锁。假设有一组并发的事务开始执行时,系统依次给每个事务分配递增ID。 其中当这里边的事务读取记录时,核心内容就一条即更新这条记录的事务id必须小于这一组事务id的最小值。读取时这条记录才会对事务可见。或者修改这一条记录事务id是它本身,这种情况也是可见的。其他条件,比如大于这一组事务id的最小值,说明更新这条记录的事务id在这一组事务中,由于是并发执行,所以对其不可见。此时,记录的回滚指针会指向之前的版本记录让其事务读取。如下:
判断条件 | 是否可见 | 原因 |
---|---|---|
trx_id == 当前事务ID | 是 | 当前事务自己创建或修改的记录 |
trx_id < 最小活跃事务ID | 是 | 创建该记录的事务已在当前事务启动前提交 |
trx_id ∈ 活跃事务列表 | 否 | 创建该记录的事务尚未提交 |
否则 | 否,继续通过 roll_pointer 回滚旧版本 | 找到对当前事务可见的历史版本 |
这里解释一下可能存在的疑问。 事务出现不会直接获取要读取记录的事务id字段。而是当我们在事务中查询语句执行时,才会获取更新此记录的事务ID。这种方式也叫快照读,就是说读取的时候会给记录拍照定格ReadView。不是事务出现拍照定格。
事务T1如果还没进行读取,是不会生成Read View的。当它真的去执行 SELECT 操作时,它才会拍下“当前全局事务表中的活跃事务ID快照所以 Read View 中的最大事务ID(up_limit_id)可能远远大于当前事务自己的ID
当前活跃的事务创建的 Read View 都是相同的吗?Read View 是每个事务“第一次执行快照读”时才生成的。即便两个事务同时处于“活跃”状态,它们的 Read View 也可能在不同的时刻拍下,因此内容可能不同。快照读和其他操作的区别:
操作类型 | 读写类型 | 是否生成 Read View | 能否看到未提交数据 | 是否加锁 |
---|---|---|---|---|
SELECT | 快照读(Snapshot Read) | ✅ 是(第一次读时生成) | ❌ 否(只能看到历史版本) | ❌ 否 |
SELECT ... FOR UPDATE / SELECT ... LOCK IN SHARE MODE | 当前读(Current Read) | ❌ 否 | ✅ 是(读取最新已提交或当前数据) | ✅ 是(加行锁) |
UPDATE / DELETE | 当前读 + 写操作 | ❌ 否 | ✅ 是(读取最新数据) | ✅ 是(加排他锁) |
INSERT | 写操作 | ❌ 否 | -(新数据,无历史版本) | ✅ 是(加插入意向锁) |
总结来说,本文面向面试对其基本原理做了一定梳理,希望可以帮助大家通过面试。