# 1.什么是事务
首先说一下什么是事务。
事务(Transaction)指一个操作,由多个步骤组成,要么全部成功,要么全部失败。
比如我们常用的转账功能,假设A账户向B账号转账,那么涉及两个操作:
- 从 A 账户扣钱。
- 往 B 账户加入等量的钱。
因为是独立的两个操作,所以可能有一个成功,一个失败的情况。但是因为在这种场景下,不能存在从 A 账户扣钱成功,往 B 账户加入等量钱失败这种情况,要么同时成功,要么同时失败(一个失败需要回滚),即必须要保证事务。
# 2.生活中处处可见事务
事务不止存在于数据库中,生活中处处存在事务,只要是涉及多个步骤来完成一件事情时,就涉及到事务。
比如彩礼三金和结婚是一个事务,南方给了价值几十万的彩礼和三金,女方会答应如期将女儿嫁出。如果女方毁约,一般会如数退还彩礼三金。如果遇到蛮横无理的女方,那么就破坏了事务,男方会采取法律或特殊手段要回彩礼三金,强制达到事务。
再如菜市场买东西,一手交钱一手交货。
购买机票到最后完成乘机或退还机票,2021 年春节因疫情尚未结束倡导就地过年,出现大面积免手续费退还机票的事情。
网购下单到满意确收货或不满意退款退货等等。
# 3.数据库事务四大特性
什么是数据库事务?
数据库事务(Database Transaction)是指对数据库的一系列操作组成的逻辑工作单元。
并非任意的数据库操作序列都是数据库事务。数据库管理系统(DBMS)在写入或更新资料的过程中,为保证交易(Transaction)正确可靠,必须具备四个特性,这四个特性通常称为 ACID 特性。
- 原子性(Atomicity)
事务作为一个整体被执行,包含在其中的对数据库的操作要么全部被执行,要么都不执行。
- 一致性(Consistency)
一致性确保事务将数据库从一种一致的状态转换为另一种一致的状态。换句话说,事务在执行前后,数据库必须满足一些预定义的一致性规则,如约束、触发器、级联等。如果事务执行后数据库不满足这些规则,整个事务将被回滚。
- 隔离性(Isolation)
多个事务并发执行时,一个事务的执行不应影响其他事务的执行。
- 持久性(Durability)
已被提交的事务对数据库的修改应该永久保存在数据库中。
我们还是用上面“A账户向B账号汇钱”的例子来说明如何通过数据库事务保证数据的正确性。
熟悉关系型数据库事务的都知道从帐户 A 转帐到账户 B 需要 6 个操作。
1 从 A 账户中把余额读出来(500)
2 对 A 账户做减法操作(500-100)
3 把结果写回 A 账号中(400)
4 从 B 账户中把余额读出来(500)
5 对 B 账户做加法操作(500+100)
6 把结果写回 B 账户中(600)
原子性:保证1-6所有过程要么都执行,要么都不执行。一旦在执行某一步骤的过程中发生问题,就需要执行回滚操作。 假如执行到第五步的时候,B账户突然不可用(比如被注销),那么之前的所有操作都应该回滚到执行事务之前的状态。
一致性:在转账之前,A和B的账户中共有500+500=1000元钱。在转账之后,A和B的账户中共有400+600=1000元。也就是说,数据的状态在执行该事务操作之后从一个状态改变到了另外一个状态,两个状态数据总额是一致的,不能凭空变多或变少。
隔离性:在 A 向 B 转账的整个过程中,只要事务还没有提交(commit),查询 A 账户和 B 账户的时候,两个账户里面的钱的数量都不会有变化。如果在 A 给 B 转账的同时,有另外一个事务执行了 C 给 B 转账的操作,那么当两个事务都结束的时候,B 账户里面的钱应该是 A 转给 B 的钱加上 C 转给 B 的钱再加上自己原有的钱。
持久性:一旦转账成功(事务提交),两个账户的里面的钱就会真的发生变化(会把数据写入数据库做持久化保存)。
事务是个好多西,因为它符合我们的预期。但是很多场景下,很难保证事务,或者说保证事务需要付出很大的成本。此时需要我们权衡利弊,设计出低成本又符合实际应用场景的方案。
MySQL InnoDB 引擎通过什么技术来保证事务的这四个特性的呢?
- 持久性是通过 redo log (重做日志)来保证的。
- 原子性是通过 undo log(回滚日志) 来保证的。
- 隔离性是通过 MVCC(多版本并发控制) 或锁机制来保证的。
- 一致性则是通过持久性+原子性+隔离性来保证。
# 4.事务并发问题
在数据库操作中,为了有效保证事务并发读写数据的正确性,提出了事务隔离级别。
数据库会被广大用户共享访问,那么在数据库并发操作过程中可能会出现一些不确定的情况。
- 脏读(Dirty Read)
读取未提交数据。
A 事务读取 B 事务尚未提交的数据,此时如果 B 事务发生错误并执行回滚操作,那么 A 事务读取到的数据就是脏数据。
- 不可重复读(Non-repeatable Read)
前后多次读取,数据内容不一致。
A 事务在 B 事务开始前读和 B 事务结束后读的数据不一样,因为数据被事务 B 给修改了。
- 幻读(Phantom Read)
一个行出现在查询结果集中,但不在较早查询的结果集中。
事务 A 在读取某个范围内的记录时,事务 B 在该范围内插入了新记录,事务 A 再次读取该范围内的记录时,会产生幻行。
幻读比不可重复读取更难防范,因为锁定第一个查询结果集中的所有行并不能阻止导致幻像出现的更改。
为了解决上面的问题,于是有了事务隔离。
# 5.事务隔离级别
事务隔离有多个级别,每个隔离级别都有不同的特点和能力,以解决并发访问数据库时可能出现的不同问题。
SQL:1992 标准定义了四个隔离级别及其解决的问题。
- 读未提交(Read Uncommitted)
允许脏读、不可重复读和幻读。
最低的隔离级别,事务可以读取其他事务尚未提交的数据,虽然拥有超高的并发处理能力及很低的系统开销,但很少用于实际应用,因为可能导致数据不一致性。
- 读已提交(Read Committed)
不允许脏读,但允许不可重复读和幻读。
事务只能读取已经提交的数据,避免了脏读问题,但可能导致不可重复读和幻读。
这是大多数数据库系统的默认隔离级别(如 Oracle 和 SQL Server),但不是 MySQL 的默认。
- 可重复读(Repeatable Read)
不允许脏读、不可重复读,但允许幻读。
事务在整个事务期间保持一致的快照,其他事务的修改不会影响正在运行的事务,从而防止不可重复读问题。
这是 MySQL InnoDB 默认的事务隔离级别。
- 串行化(Serializable)
解决所有事务并发问题。
最高的隔离级别,通过强制事务排序,使之不可能相互冲突,从而防止所有并发问题。
虽然这个隔离级别可以解决上面提到的所有并发问题,由于事务是串行执行,所以效率会大大下降,应用程序的性能会急剧降低。最直观的体现就是,当数据库隔离级别设置为串行化后,A 事务在未提交之前,B 事务对 A 事务数据的操作都会被阻塞。通常不会使用这个隔离级别,我们需要其他机制来解决这些问题:比如乐观锁和悲观锁。
下面表格总结了事务并发问题和四大隔离级别的关系。
隔离级别 | 脏读 | 不可重复读 | 幻读 |
---|---|---|---|
读未提交 | ✓ | ✓ | ✓ |
读已提交 | x | ✓ | ✓ |
可重复读 | x | x | ✓ |
串行化 | x | x | x |
每个隔离级别都在一定程度上解决了事务并发问题,但隔离级别越高,并发性能越低,因为更高级别的隔离通常需要更多的锁和资源开销。因此,在选择隔离级别时,需要根据应用场景平衡一致性和性能,选择合适的隔离级别。
这四种隔离级别具体是如何实现的呢?
- 对于「读未提交」隔离级别的事务来说,因为可以读到未提交事务修改的数据,所以直接读取最新的数据就好了。
- 对于「读已提交」和「可重复读」隔离级别的事务来说,它们是通过多版本并发控制(MVCC)来实现的。
- 对于「串行化」隔离级别的事务来说,通过加读写锁的方式来避免并行访问。
# 6.MVCC
# 6.1 简介
MVCC(Multi-Version Concurrency Control)是多版本并发控制,以乐观锁为理论基础。通过对数据行的多个版本管理来实现数据库的并发控制。这样我们就可以通过比较版本号决定数据是否显示出来,读取数据的时候不需要加锁也可以保证事务的隔离效果,以此提高数据库并发性能。
MVCC 的实现没有固定的规范,每个数据库都会有不同的实现方式。
MySQL 中 InnoDB 采用了 MVCC 来实现“读已提交“和“可重复读”两个隔离级别。其他两个隔离级别和 MVCC 不兼容,因为“读未提交”总是读取最新的数据行, 不需要进行版本控制,而“串行化”则会对所有读取的行加锁。
# 6.2 原理
MVCC 的核心实现主要基于两部分:版本链和 Read View。
为了方便描述,首先我们创建一个表book,就三个字段,分别是主键book_id, 名称book_name, 库存stock。然后向表中插入一些数据:
INSERT INTO book VALUES(1, '数据结构', 100);
INSERT INTO book VALUES(2, 'C++指南', 100);
INSERT INTO book VALUES(3, '精通Java', 100);
# 版本链
对于使用 InnoDB 存储引擎的表,其聚簇索引记录中包含了两个重要的隐藏列:
- 事务ID(DB_TRX_ID):每当事务对聚簇索引中的记录进行修改时,都会把当前事务的事务id记录到DB_TRX_ID中。
- 回滚指针(DB_ROLL_PTR):每当事务对聚簇索引中的记录进行修改时,都会把该记录的旧版本记录到undo日志中,通过DB_ROLL_PTR这个指针可以用来获取该记录旧版本的信息。
如果在一个事务中多次对记录进行修改,则每次修改都会生成undo日志,并且这些undo日志通过DB_ROLL_PTR指针串联成一个版本链,版本链的头结点是该记录最新的值,尾结点是事务开始时的初始值。
例如,我们在表book中做以下修改:
BEGIN;
UPDATE book SET stock = 200 WHERE id = 1;
UPDATE book SET stock = 300 WHERE id = 1;
COMMIT;
那么id=1的记录此时的版本链就如下图所示:
# Read View(读视图)
Read View 是实现 MVCC 的关键部分。它用于管理事务的可见性,以确保每个事务在读取数据时只看到在事务开始之前已经提交的数据版本。
读已提交和可重复读的区别就在于它们生成 ReadView 的策略不同。
大家可以把 Read View 理解成一个数据快照,就像相机拍照那样,定格某一时刻的风景。“读已提交"是在每个语句执行前都会重新生成一个 Read View,而“可重复读“是启动事务时生成一个 Read View,然后整个事务期间都在用这个 Read View。
要理解 Read View 需要知道 Read View 中四个重要字段的作用。
- m_ids :指的是在创建 Read View 时,当前数据库中「活跃事务」的事务 id 列表,注意是一个列表,“活跃事务”指的就是,启动了但还没提交的事务。
- min_trx_id :指的是在创建 Read View 时,当前数据库中「活跃事务」中事务 id 最小的事务,也就是 m_ids 的最小值。
- max_trx_id :这个并不是 m_ids 的最大值,而是创建 Read View 时当前数据库中应该给下一个事务的 id 值,也就是全局事务中最大的事务 id 值 + 1;
- creator_trx_id :指的是创建该 Read View 的事务的事务 id。
在创建 Read View 后,我们可以将记录中的 trx_id 划分为三种情况:
一个事务去访问记录的时候,除了自己的更新记录总是可见之外,还有这几种情况: (1)如果记录的 trx_id 值小于 Read View 中的 min_trx_id 值,表示这个版本的记录是在创建 Read View 前已经提交的事务生成的,所以该版本的记录对当前事务可见。 (2)如果记录的 trx_id 值大于等于 Read View 中的 max_trx_id 值,表示这个版本的记录是在创建 Read View 后才启动的事务生成的,所以该版本的记录对当前事务不可见。 (3)如果记录的 trx_id 值在 Read View 的 min_trx_id 和 max_trx_id 之间,需要判断 trx_id 是否在 m_ids 列表中:
- 如果记录的 trx_id 在 m_ids 列表中,表示生成该版本记录的活跃事务依然活跃着(还没提交事务),所以该版本的记录对当前事务不可见。
- 如果记录的 trx_id 不在 m_ids 列表中,表示生成该版本记录的活跃事务已经被提交,所以该版本的记录对当前事务可见。
这种通过版本链来 Read View 控制并发事务访问同一个记录时的行为就叫 MVCC(多版本并发控制)。
# 6.3 举例说明
最后我们来举个例子让我们更好理解上面的内容。
比如我们有如下表: 现在有一个事务id是60的执行如下语句并提交:
update user set name = '强哥1' where id = 1;
此时 undo log 存在版本链如下:
提交事务id是60的记录后,接着有一个事务id为100的事务,修改name=强哥2,但是事务还没提交。则此时的版本链是:
此时另一个事务发起 select 语句查询 id=1 的记录,因为 trx_ids 当前只有事务 id 为 100 的,所以该条记录不可见,继续查询下一条,发现 trx_id=60 的事务号小于 min_trx_id,则可见,直接返回结果“强哥1”。
那这时候我们把事务id为100的事务提交了,并且新建了一个事务id为110也修改id为1的记录name=强哥3,并且不提交事务。这时候版本链就是:
这时候之前那个 select 事务又执行了一次查询,要查询 id 为 1 的记录。
如果你是“读已提交”隔离级别,这时候你会重新生成一个 Read View,那你的活动事务列表中的值就变了,变成了[110]。按照上的说法,你去版本链通过 trx_id 对比查找到合适的结果就是“强哥2”。
如果你是“可重复读”隔离级别,这时候你的 Read View 还是第一次 select 时候生成的,所以查询结果是“强哥1”。所以第二次查询结果和第一次一样,所以叫可重复读。
也就是说“读已提交”隔离级别下的事务在每次查询开始都会生成一个独立的 Read View,而“可重复读”隔离级别则在第一次读的时候生成一个 Read View,之后读都复用之前的 Read View。
这就是 MySQL InnoDB 的 MVCC,通过版本链,实现多版本管理,可并发读写,通过 Read View 生成策略的不同实现不同的隔离级别。
# 6.4 小结
所谓的 MVCC 指的就是在使用 READ COMMITTD 、REPEATABLE READ 这两种隔离级别的事务在执行普通的 SEELCT 操作时访问记录的版本链的过程,这样子可以使不同事务的读写操作并发执行,从而提升系统性能。
在 MySQL 中, READ COMMITTED 和 REPEATABLE READ 隔离级别的的一个非常大的区别就是它们生成 ReadView 的时机不同。在 READ COMMITTED 中每次查询都会生成一个实时的 ReadView,做到保证每次提交后的数据是处于当前的可见状态。而 REPEATABLE READ 中,在当前事务第一次查询时生成当前的 ReadView,并且当前的 ReadView 会一直沿用到当前事务提交,以此来保证可重复读。
# 7.更新丢失
事务并发时,不仅存在读的问题,还有可能存在更新丢失的情况。
更新丢失(Update Lost)指更新结果被其他事务覆盖。
两个事务同时读取相同数据并分别修改后,一个事务的修改覆盖了另一个事务的修改。这是因为系统没有执行任何锁操作,因此并发事务没有被隔离开来。
第一类更新丢失(回滚丢失)。
比如 A 事务对某一列 +1,B 事务对某一列 +2。A 事务事务提交后,B 事务回滚了,导致 A 事务更新丢失。
第二类更新丢失(提交丢失)。
比如 A 事务对某一列 +1,B 事务对某一列 +2,A B 事务执行完成后正常预期结果是某一列值被 +3,但是 B 事务的结果覆盖了 A 事务,导致结果只被 +2,A 事务的更新丢失了。
SQL 标准并未提及更新丢失的问题,所以不同隔离级别下是否会存在更新丢失的问题,不同数据库厂商实现有所不同。
比如 SQL Server 和 PostgreSQL 在 Repeatable Read 隔离级别下不会出现更新丢失。
但对于 MySQL 在 Repeatable Read 隔离级别下会出现更新丢失,需要额外加锁来避免此问题。
MySQL 可以通过以下办法避免更新丢失。
- 提升隔离级别至串行化(Serializable)
- 使用乐观锁,比如版本号的 CAS(Compare And Swap)
- 使用悲观锁,比如排他锁(X 锁)SELECT xxx FOR UPDATE
# 8.数据库事务的使用
对于单条 SQL 语句,数据库系统自动将其作为一个事务执行,这种事务被称为隐式事务。
要手动把多条SQL语句作为一个事务执行,使用 BEGIN 开启一个事务,使用 COMMIT 提交一个事务,这种事务被称为显式事务,例如,把上述的转账操作作为一个显式事务。
BEGIN;
UPDATE accounts SET balance = balance - 100 WHERE id = 1;
UPDATE accounts SET balance = balance + 100 WHERE id = 2;
COMMIT;
很显然多条 SQL 语句要想作为一个事务执行,就必须使用显式事务。
COMMIT 是指提交事务,即试图把事务内的所有 SQL 所做的修改永久保存。如果 COMMIT 语句执行失败了,整个事务也会失败。
有些时候,我们希望主动让事务失败。这时可以用 ROLLBACK 回滚事务,整个事务会失败。
BEGIN;
UPDATE accounts SET balance = balance - 100 WHERE id = 1;
UPDATE accounts SET balance = balance + 100 WHERE id = 2;
ROLLBACK;
数据库事务是由数据库系统保证的,我们只需要根据业务逻辑使用它就可以。
# 参考文献
ISO/IEC 9075:1992, the Database Language SQL (opens new window)
15.7.2.1 Transaction Isolation Levels - MySQL (opens new window)
Re: does repeatable read prevent lost update with pessimistic (opens new window)
Prevent lost updates with high transaction isolation levels - stackoverflow.com (opens new window)
MySQL Repeatable Read isolation level and Lost Update phenomena (opens new window)
数据库事务隔离级别 - 博客园 (opens new window)