1.什么是事务
1.1.事务的概念
学习一个新的东西,首先我们要探究其本质概念,这是认识一个新的事物的基础也是一个优秀的学习方法,说起事务,那么我们首先要从其概念说起。
事务(Transaction),一般是指要做的或所做的事情。在计算机术语中是指访问并可能更新数据库中各种数据项的一个程序执行单元(unit)。事务通常由高级数据库操纵语言或编程语言(如SQL,C++或Java)书写的用户程序的执行所引起,并用形如begin transaction和end transaction语句(或函数调用)来界定。事务由事务开始(begin transaction)和事务结束(end transaction)之间执行的全体操作组成。
我们更加通俗的来讲一下:事务是指一组数据库操作SQL语句的集合,这些操作被视为一个不可分割的工作单元,要么全部成功执行,要么全部不执行,从而保证数据库从一个一致状态转换到另一个一致状态。说白了数据库事务就是:执行一组SQL操作,要么全成功,要么全失败,不允许成功一半失败一半去执行。
1.2.事务的特性
MySQL数据库的隔离级别是数据库事务处理中的重要概念,用于控制事务之间的相互影响,确保数据的一致性和并发性能。MySQL数据库事务有以下四大特性,这四大特性就是我们通常所说的ACID:
-
原子性(Atomicity):事务是一个原子操作单元,其对数据的修改要么全都执行,要么全都不执行。如果事务执行过程中发生错误,会被回滚(Rollback)到事务开始前的状态,就像这个事务从来没有执行过一样。
-
一致性(Consistency):事务开始前和结束后,数据库的完整性约束没有被破坏。例如在转账前后,两个账户的总金额应该保持不变。
-
隔离性(Isolation):数据库允许多个并发事务同时对其数据进行读写和修改的能力,隔离性可以防止多个事务并发执行时由于交叉执行而导致数据的不一致。MySQL提供了不同的事务隔离级别来实现不同程度的隔离。MySQL有四种隔离级别,这个大家也都知道,在后面会详细重点展开解读这四种隔离级别,彻底理解这四种隔离级别对我们日常开发有着至关重要的作用。
-
持久性(Durability):事务一旦提交,其所做的修改就会永久保存在数据库中,即使系统故障也不会丢失。MySQL通过预写式日志(WAL)等机制来保证持久性。
1.3.事务的操作
MySQL中事务的操作主要包括一下四个步骤,通过理解这四个步骤能够让我们更加清楚的知道一条SQL在MySQL内部的一个详细执行过程。
-
开始事务:使用BEGIN或START TRANSACTION语句显式开启一个事务。
-
提交事务:使用COMMIT语句提交事务,使已对数据库进行的所有修改成为永久性。
-
回滚事务:使用ROLLBACK语句回滚事务,撤销正在进行的所有未提交的修改。
-
保存点:使用SAVEPOINT可以在事务中创建标记点,以便后续回滚到特定点。
MySQL默认采用自动提交模式,即每条SQL语句都会自动成为一个事务并立即提交。可以通过设置SET AUTOCOMMIT = 0来改为手动提交模式。
1.4.事务的实现机制
MySQL InnoDB引擎通过以下机制实现事务特性:
-
redo log(重做日志):记录事务中的所有数据操作,用于保证原子性和持久性。采用"预写日志"机制,先写日志再同步到数据文件。
-
undo log(回滚日志):记录数据被修改前的状态,用于事务回滚。
-
MVCC(多版本并发控制):通过版本链实现快照读,提高并发性能。其中事务隔离级别主要通过这种方式来实现的,通过控制不同版本的数据,来实现对隔离级别的控制。
-
锁机制:包括行锁、间隙锁和Next-Key Lock等,保证隔离性。
事务的存在本质上是为了应用层服务,简化编程模型,使开发者不需要考虑各种潜在错误和并发问题。通过事务机制,MySQL能够确保数据的一致性和完整性,为复杂业务操作提供可靠支持。
2.事务的隔离级别
通过以上解读,相信我们对MySQL事务有所认识,有了这个基础我们来重点聊一下事物的隔离级别,MySQL一共有四种隔离级别,用于控制事务之间的相互影响,确保数据的一致性和并发性能。以下是MySQL支持的四种隔离级别及其特性、应用场景和实现机制的详细解析。
我们可以通过一下命令来对事物级别进行查询与设置:
1.可以通过这个命令来查询数据库的隔离级别
SELECT @@transaction_isolation;
2.可以在事务开始之前使用SET TRANSACTION命令来设置隔离级别
SET TRANSACTION ISOLATION LEVEL READ COMMITTED;
START TRANSACTION; -- 开始事务
-- 在这里执行你的事务操作
COMMIT; -- 提交事务
3.这个命令会设置当前会话的事务隔离级别,直到会话结束
SET SESSION TRANSACTION ISOLATION LEVEL REPEATABLE READ;
4.这个命令会设置全局的事务隔离级别,影响所有新的会话:
SET GLOBAL TRANSACTION ISOLATION LEVEL SERIALIZABLE;
2.1.读未提交(Read Uncommitted)
特性:顾名思义,就是允许事务读取其他事务未提交的数据(脏读)。隔离级别最低,并发性能最高,但可能导致脏读、不可重复读和幻读问题 。
问题示例:事务A读取事务B未提交的账户数据进行数据修改,若事务B回滚,事务A读取的数据无效 。我们在MySQL数据库中创建一张account表,用于演示事务之间的各种情形,表中数据如下:
我们先来查看下数据库默认的隔离级别是什么:
SELECT @@transaction_isolation;
通过结果记录来看是一个可重复度的隔离级别,那么我们首先需要设置为:读未提交(Read Uncommitted),然后向数据库添加如下记录,我们不提交事务,来观察下:
目前表中数据:
开启事务一窗口,隔离级别为:读未提交,插入一条数据
--设置事务隔离级别为:读未提交
SET TRANSACTION ISOLATION LEVEL READ UNCOMMITTED;
START TRANSACTION;
INSERT INTO account VALUES (4, 1500, "Nico");
开启事务二窗口,隔离级别为:读未提交,读取数据
很显然,我们读取到了事务一新增的id=4数据,但是这个时候如果事务一未执行COMMIT操作,进行了ROLLBACK,会出现什么情况呢?
事务一窗口,隔离级别为:读未提交,我们继续执行下ROLLBACK:
--设置事务隔离级别为:读未提交
SET TRANSACTION ISOLATION LEVEL READ UNCOMMITTED;
START TRANSACTION;
INSERT INTO account VALUES (4, 1500, "Nico");
ROLLBACK;
事务二窗口,隔离级别为:读未提交,继续读取数据:
咦!相同的事务中,执行相同的SQL语句,发现id=4不见了,在我们实际生产中,事务二读取到了事务一未提交的数据,从而产生了脏数据,这个数据可能最终数据库中是不存在但,但是事务二使用了一个不存在的数据,后果可想而知。通过上面的演示,相信大家已经完全理解了读未提交这一隔离级别。
适用场景:极少使用,仅适用于对数据一致性要求极低的场景,如临时统计。
2.2.读已提交(Read Committed)
特性:仅读取已提交的数据,避免脏读,但可能出现不可重复读和幻读。Oracle和PgSQL Server的默认级别 。
问题示例:我们使用事务二对id=2 Linda账户进行两次读取账户余额,期间事务一提交了转账操作,导致事务一两次结果不一致(不可重复读) 。
开启事务二窗口,隔离级别为:读已提交,读取数据:
id=2 Linda目前余额为1000。
开启事务一窗口,隔离级别为:读已提交,对id=2的账户余额进行更新,由原来的balance=1000,更新为balance=2000,然后执行查询id=2的数据,具体执行SQL序列为:
--设置事务隔离级别为:读已提交
SET TRANSACTION ISOLATION LEVEL READ COMMITTED;
START TRANSACTION;
UPDATE account SET balance = 2000 where id = 2;
SELECT * FROM account where id = 2;
查询结果为:
开启事务二窗口,隔离级别为:读已提交,继续读取id=2的数据:
哎!发现事务二读取出来的仍然是1000 ,就其本质就是因为事务一尚未提交更新操作,导致事务二读取不到最新的更新数据,当事务一进行COMMIT后,事务二再执行查询id=2的结果会出现什么情况?
什么情况!?是我事务二产生幻觉了嘛?怎么同样的事务中读取到的同样id的数据不一样呢?其实这就是读已提交这一隔离级别要求只能读取已提交的数据,事务一在执行提交前,事务二恰好进行了插询,事务一在提交后,事务二又进行了查询,导致出现了不一样的结果。同样新增操作也是如此。
2.3.可重复读(Repeatable Read,MySQL默认级别)
特性:这种隔离级别保证同一事务内多次读取数据结果一致,避免脏读和不可重复读,但可能发生幻读(新增数据)以及脏写(更新数据) 。
问题示例:同样我们使用id=2来进行模拟,事务二查询id=2的账户余额,事务一更新账户余额并提交,事务A再次统计时发现总额变化(幻读) 。
开启事务二窗口,隔离级别为:可重复读,先读取id=2的数据:
我们读取出来余额为:2000。
开启事务一窗口,隔离级别为:可重复读,对id=2的账户余额进行更新,由原来的balance=2000,更新为balance=5000,然后执行查询id=2的数据,这次不同的是,我们执行完更新后,立即进行提交。并在事务一窗口进行查询,具体执行SQL序列为:
--设置事务隔离级别为:可重复度
SET TRANSACTION ISOLATION LEVEL REPEATABLE READ;
START TRANSACTION;
UPDATE account SET balance = 5000 where id = 2;
COMMIT;
SELECT * FROM account where id = 2;
事务一种查询出了最新更新的结果。
开启事务二窗口,隔离级别为:可重复读,再读取id=2的数据:
仍然还是2000,事务一的操作不影响事务二的读取,正是这种机制,确保了当事务二在第一次查询id=2的时候已经将这个查询事务id与对应数据快照版本进行了一一绑定,所以及时事务一怎么去修改数据,也不会影响事务二对数据的读取,保证了数据的安全性。但是,随之也产生了新的问题,就是脏写,比如事务二也对id=2的数据进行开了更新操作,将balance更新为1000,这个时候会导致事务一对id=2数据处理的数据是无效的,导致脏数据产生,当事务一再次进行数据查询的时候,查询出来的余额是1000。
具体情形如下:
事务二窗口,隔离级别为:可重复读,修改id=2的数据,更新为1000:
UPDATE account SET balance = 1000 where id = 2;
commit;
开启事务一窗口,隔离级别为:可重复读,读取id=2的数据:
导致之前更新为5000的数据被事务二更新操作给覆盖掉了。
其实想解决这种脏写的问题办法很简单,可以再表结构里面新增加一个version标志,每次查询的时候将这个版本的version查询查询出来,每次更新只针对当前版本的数据进行更新,如果版本号不对,就进行版本号加1的操作,直到找到存在的版本号,这样就确保,每次只在最新的数据上尽心修改。
实现机制:MVCC + 间隙锁(Gap Lock)防止其他事务在范围内插入数据 。
2.4.串行化(Serializable)
特性:事务串行执行,完全避免脏读、不可重复读和幻读,但并发性能极低 。这种理解就比较简单了,就相当于我们单线程去执行某个操作,将执行的操作放在一个队列里面,排队执行,第一个完成后才能去执行第二个。
实现机制:所有SELECT语句隐式转换为SELECT ... FOR UPDATE
,加表级锁 。
我们现在通过事务一来更新id=2,将balance由1000更新为100,不提交事务,由事务二去进行读取,这时候事务二会一直阻塞,等待事务一彻底完成。
事务一窗口,隔离级别为:串行化,id=2,将balance由1000更新为100,不提交事务:
--设置事务隔离级别为:串行化
SET TRANSACTION ISOLATION LEVEL SERIALIZABLE;
START TRANSACTION;
UPDATE account SET balance = 100 where id = 2;
事务二窗口,隔离级别为:串行化,查询id=2的数据:
一直卡着,我们现在将事务一进行提交,事务二将立即查出结果,这就是串行化的情形,数据安全一直,但是效率极低。
适用场景:金融等高安全性需求,如银行转账 。
3.MySQL默认隔离级别的设计与考量
MySQL选择REPEATABLE READ作为默认隔离级别而非READ COMMITTED,主要有以下原因:
3.1.历史原因:
主从复制与binlog格式,早期MySQL的binlog格式只有STATEMENT(基于SQL语句),在READ COMMITTED隔离级别下可能出现主从不一致问题。例如:
-- 主库执行(READ COMMITTED)
BEGIN;
UPDATE account SET balance = balance - 100 WHERE id = 1; -- 原余额1000
-- 此时其他事务更新id=1的余额为500并提交
UPDATE account SET balance = balance + 100 WHERE id = 1;
COMMIT;
在READ COMMITTED下,最终余额可能是600,但基于STATEMENT的binlog复制到从库后,执行同样的语句可能得到不同的结果。
3.2.InnoDB的多版本并发控制
InnoDB的MVCC版本控制实现更适合REPEATABLE READ,每个事务看到的是事务开始时的数据快照,基于这份快照,每个数据行维护了一个快照id与数据一一对应,而非最新提交的数据。
3.3.间隙锁的引入
InnoDB在REPEATABLE READ下使用间隙锁,可以避免大部分幻读问题,这在其他数据库的REPEATABLE READ实现中是没有的。
3.4.性能与一致性的平衡
READ COMMITTED虽然并发性能更好,但在复杂事务中可能导致更多一致性问题和应用层处理负担。REPEATABLE READ提供了更好的"默认安全性"。
3.5.如何选择合适的隔离级别
选择合适的事务隔离级别需要在数据一致性和并发性能之间取得平衡,以下是各隔离级别的适用场景建议:
隔离级别 | 适用场景 | 不适用场景 |
---|---|---|
READ UNCOMMITTED | 几乎不适用,仅用于临时统计等对数据准确性要求极低的场景 | 任何需要数据准确性的业务场景 |
READ COMMITTED | 大多数OLTP系统,特别是读多写少的系统;需要高并发的Web应用 | 需要严格一致性的财务系统 |
REPEATABLE READ | 需要事务内多次读取一致的场景;MySQL默认级别;大多数业务场景 | 需要极高并发的写入密集型系统 |
SERIALIZABLE | 金融交易核心系统;需要绝对一致性的场景;分布式事务;低并发高安全需求场景 | 高并发系统;大多数常规业务场景 |
实际应用建议:
-
常规Web应用:使用默认的REPEATABLE READ,大多数情况下能提供良好的平衡。
-
高并发读取:考虑使用READ COMMITTED,可以减少锁争用,提高并发性能。
-
金融系统:对关键操作使用SERIALIZABLE,或使用REPEATABLE READ配合应用层校验。
-
报表系统:对于实时性要求不高的报表,可以使用READ UNCOMMITTED读取未提交的数据快照,但需明确知晓数据可能不准确。
-
批量处理:大数据量导入导出时,可临时降低隔离级别提高性能,完成后恢复。
4.多版本并发控制(MVCC)
InnoDB的多版本并发控制(MVCC)是实现高并发场景下处理事务的核心机制,旨在通过维护数据的多个版本,使得数据在读写操作可以并发执行二不互相阻塞。我们从原理,组件和实现流程来分下下MVVC的机制。
4.1.MVCC的基本概念与作用
MVCC(Multi-Version Concurrency Control)是一种数据库并发控制技术,它通过为数据项维护多个版本来实现非阻塞的读操作。InnoDB的MVCC主要解决两个核心问题:
- 读写冲突:允许读操作不被写操作阻塞,写操作也不阻塞读操作。
- 事务隔离:为不同隔离级别(主要是RC和RR)提供数据可见性控制 。
MVCC与锁机制协同工作,形成了"MVCC解决读写冲突,锁机制解决写写冲突"的并发控制体系。这种组合既保证了数据一致性,又提高了并发性能。
4.2.MVCC的核心实现组件
4.2.1.隐藏字段
InnoDB每行记录都包含三个系统隐藏字段:
- DB_TRX_ID(6字节):记录最近修改(插入/更新)该行的事务ID。
- DB_ROLL_PTR(7字节):回滚指针,指向该行的上一个版本(存储在undo log中)。
- DB_ROW_ID(6字节):隐藏的自增ID,当表无主键时InnoDB会自动生成聚簇索引。
这些字段对用户透明(我们实际用户是看不到的),但却是MVCC实现的基础。通过DB_ROLL_PTR可以追溯一个记录的所有历史版本。
4.2.2. undo日志
undo log是MVCC实现多版本的关键,它记录了数据修改前的状态:
- Insert undo log:仅用于事务回滚,记录插入操作的主键信息。
- Update undo log:用于事务回滚和MVCC,记录修改前的完整数据 。
InnoDB不会立即删除旧版本数据,而是通过purge线程定期清理不再需要的undo记录。这种设计使得:
- 其他事务能通过版本链访问历史数据
- 事务回滚时能恢复数据到之前状态
4.2.3.版本链
通过DB_ROLL_PTR指针,同一记录的不同版本会形成一个链表结构:
最新记录 → 旧版本1 → 旧版本2 → ... → 版本n
每个版本都记录了产生它的事务ID,通过这个链表可以找到对特定事务可见的版本。
4.2.4.Read View(读视图)
Read View是事务进行快照读时产生的"一致性视图",决定当前事务能看到哪些版本的数据。它包含四个关键信息:
- m_ids:生成ReadView时系统中活跃(未提交)的事务ID列表。
- m_up_limit_id:活跃事务中最小的事务ID。
- m_low_limit_id:系统尚未分配的下一个事务ID(即当前最大事务ID+1)。
- m_creator_trx_id:创建该ReadView的事务ID 。
通过比较数据版本的事务ID与ReadView的这些属性,可以判断该版本对当前事务是否可见。
4.3.MVCC的可见性判断规则
InnoDB通过以下算法判断某数据版本对当前事务是否可见:
- 如果数据版本的DB_TRX_ID == m_creator_trx_id,说明是当前事务自己修改的,可见。
- 如果DB_TRX_ID < m_up_limit_id,说明该版本在ReadView创建前已提交,可见。
- 如果DB_TRX_ID ≥ m_low_limit_id,说明该版本在ReadView创建后才生成,不可见。
- 如果m_up_limit_id ≤ DB_TRX_ID < m_low_limit_id,且DB_TRX_ID在m_ids中,说明该版本由未提交事务生成,不可见;否则可见。
如果不可见,则沿着版本链继续查找上一个版本,直到找到可见的版本或到达链尾。