面试官您好,事务是数据库管理系统(DBMS)执行过程中的一个逻辑单位,它通过确保一组操作的ACID特性,来保证数据的正确性和一致性。
下面我来分别介绍一下这四个特性,以及在MySQL的InnoDB引擎中,它们是如何被实现的。
Undo Log
(回滚日志)
Undo Log
是一种逻辑日志,它记录了事务所做的所有修改操作的“逆操作”。INSERT
语句时,Undo Log
就会记录一条对应的DELETE
语句;当你执行UPDATE
时,它就会记录一个“如何把数据改回去”的旧值。Undo Log
中记录的这些操作,从而将数据恢复到事务开始前的原始状态,以此来保证原子性。MVCC
和 锁
Undo Log
实现),使得不同的事务在读取数据时,能看到符合自己事务启动时间的那个“快照”版本,从而避免了读取到其他事务未提交的脏数据,实现了“无锁读”,大大提高了并发性能。Redo Log
(重做日志)
Redo Log Buffer
里。Redo Log
是物理日志,记录的是“在某个数据页的某个偏移量上,做了什么修改”,并且它是顺序写入的,速度非常快。Redo Log
被刷写到磁盘上(这个过程叫fsync
)。Redo Log
,将那些已经提交但还未写入数据文件的修改,重新执行一遍,从而恢复数据到崩溃前的正确状态,保证了持久性。这个机制也被称为WAL(Write-Ahead Logging,预写式日志)。总结一下,在InnoDB中,持久性由Redo Log
保证,原子性由Undo Log
保证,隔离性由MVCC
和锁
保证。而这三大特性,共同协作,最终保障了事务的一致性。
面试官您好,MySQL作为一个支持高并发访问的数据库系统,其并发事务处理中确实会引出一些经典的数据一致性问题。正如您所说,这些问题主要可以归结为三种:脏读(Dirty Read)、不可重复读(Non-repeatable Read)和幻读(Phantom Read)。
下面我来分别解释一下这三种问题,以及它们发生的场景。
UPDATE
语句,将工资改为8000元,但还未提交事务。UPDATE
操作并提交,库存变为8件。UPDATE
。INSERT
或DELETE
操作,而发生了数量上的增减,就像出现了“幻影”一样。SELECT * FROM employees WHERE department_id = 10;
,查出来有5个员工。SELECT * FROM employees WHERE department_id = 10;
,结果发现查出来了6个员工。为了解决这些问题,SQL标准定义了四种事务隔离级别,不同的级别能解决不同程度的并发问题,但隔离级别越高,并发性能通常也越低:
面试官您好,脏读,也就是一个事务读取到另一个事务未提交的数据,是并发事务处理中最危险、最不能容忍的一种数据不一致问题。
因为它读取到的数据,可能根本就不会在数据库中真实存在(因为另一个事务可能随时会回滚)。基于这种“幻象”数据做出的任何业务决策,都可能导致灾难性的后果。
几乎所有对数据准确性有基本要求的业务场景,都绝对不适合、也绝不能允许脏读的发生。我来举几个典型的例子,详细说明脏读的危害:
这是最经典的、绝对不能容忍脏读的场景。
UPDATE
语句,增加了10000元,但这个事务尚未提交。库存的准确性,是电商系统的生命线。
UPDATE
语句,将库存从0恢复为1,但事务尚未提交。UPDATE
语句已执行,但事务未提交。总结
这些例子都表明,脏读会让我们基于一个 “海市蜃楼”般的数据做出决策。在任何需要保证数据一致性、准确性和安全性的系统中,脏读都是不可接受的。
因此,在实践中,我们绝对不会使用会导致脏读的 “读未提交(Read Uncommitted)” 这个最低的事务隔离级别。我们至少会使用 “读已提交(Read Committed)” 级别,来从根本上杜绝脏读的发生。
面试官您好,MySQL作为一个支持高并发的数据库系统,它解决并发问题,采用的是一套多层次、相辅相成的组合拳。这套组合拳,我理解主要包含三个层面:宏观的“规则”、底层的“工具”和精巧的“优化”。
这是MySQL提供给我们的、最顶层的并发控制策略。
为了实现上述不同的隔离级别,MySQL在底层需要具体的“工具”来保证互斥和隔离。锁就是其中最基础、最强大的工具。
MyISAM
引擎主要使用表锁。SELECT ... FOR UPDATE
),锁机制会介入,确保操作的互斥性,防止多个事务同时修改同一份数据导致冲突。如果所有的并发控制都只依赖于锁,那么即使是行级锁,只要有读有写,就会频繁地发生锁等待,并发性能会受到很大影响。为了解决这个问题,InnoDB引入了极其精巧的MVCC机制。
Undo Log
),来实现 “无锁读”。所以,MySQL正是通过这三者的精妙配合,才得以在保证数据一致性的同时,提供了强大的并发处理能力。
面试官您好,事务的隔离级别是数据库为了在并发性能和数据一致性之间做出权衡,而定义的一套“规则”。SQL标准一共定义了四种隔离级别,从低到高,隔离性越来越强,但通常也意味着并发性能的下降。
下面我来从低到高逐一介绍它们,以及它们分别解决了哪些并发问题。
隔离级别 | 脏读 (Dirty Read) | 不可重复读 (Non-repeatable Read) | 幻读 (Phantom Read) |
---|---|---|---|
读未提交 | ❌ (会发生) | ❌ (会发生) | ❌ (会发生) |
读已提交 | ✅ (解决) | ❌ (会发生) | ❌ (会发生) |
可重复读 | ✅ (解决) | ✅ (解决) | ❌ (理论上会发生,但InnoDB很大程度上解决) |
可串行化 | ✅ (解决) | ✅ (解决) | ✅ (解决) |
在实际开发中,我们绝大多数情况下,都会使用数据库的默认隔离级别(对于MySQL就是可重复读),因为它在提供了非常高的数据一致性保证的同时,也通过MVCC等机制维持了良好的并发性能。
面试官您好,您提出的这个问题,其答案在MySQL的InnoDB引擎中是:看不见。
在“可重复读”这个隔离级别下,一个事务(比如事务B)一旦启动,它能看到的数据版本,就仿佛被“定格”在了它启动的那一刻。后续其他事务(比如事务A)的提交,对它来说是“不可见”的。
实现这一点的核心技术,就是MVCC(多版本并发控制),而MVCC发挥作用的关键,则在于一个叫做Read View(一致性视图) 的概念。
SELECT
查询时,InnoDB会为它创建一个Read View。m_ids
: 创建这个Read View时,当前系统中所有还未提交的、活跃的事务ID列表。min_trx_id
: 上述活跃事务ID列表中的最小事务ID。max_trx_id
: 创建这个Read View时,系统应该分配给下一个新事务的ID。creator_trx_id
: 创建这个Read View的事务自身的ID。当事务B拿着这个Read View去查询某一行数据时,它会找到这行数据最新的版本,并获取到这个版本上记录的最后一次修改它的事务ID(DB_TRX_ID
)。
然后,它会按照以下规则,来判断这个版本的数据对它是否可见:
是自己修改的吗? 如果DB_TRX_ID
等于creator_trx_id
,说明是本事务自己修改的,那可见。
是在我创建快照之后才出现的事务吗? 如果DB_TRX_ID
大于等于 max_trx_id
,说明这行数据是被一个在我创建Read View之后才开启的新事务所修改的,那么不可见。
是在我创建快照之前就已经提交的事务吗? 如果DB_TRX_ID
小于 min_trx_id
,说明修改这个数据的事务,在我创建Read View之前就已经提交了,那么可见。
是在我创建快照时,正处于活跃状态的事务吗? 如果DB_TRX_ID
在min_trx_id
和max_trx_id
之间,那么就需要去检查m_ids
这个活跃事务列表。
DB_TRX_ID
在m_ids
列表里,说明修改这行数据的事务,在我创建Read View时还是“活”的(未提交),那么不可见。DB_TRX_ID
不在m_ids
列表里,说明这个事务在我创建Read View时已经提交了,那么可见。undo log
版本链,一直往前找,直到找到一个 对它可见的“历史版本” 为止。现在我们回到您的问题:事务A提交的数据,事务B能看见吗?
DB_TRX_ID
就是事务A的ID。DB_TRX_ID
(事务A的ID)肯定大于等于事务B创建Read View时的max_trx_id
。undo log
里,找到这行数据被事务A修改之前的那个旧版本。结论:正是通过Read View这套精巧的“快照”和“可见性判断”机制,InnoDB实现了“可重复读”。一个事务一旦创建了它的Read View,就仿佛进入了一个“时空隧道”,它所能看到的世界,就被定格在了那个瞬间,不受外界后续提交事务所干扰。
面试官您好,MySQL InnoDB在“可重复读”(Repeatable Read)这个默认隔离级别下,通过MVCC(多版本并发控制)机制,在绝大多数的普通SELECT
查询中,都成功地避免了幻读。
但是,在一些特殊的读写交互场景下,幻读问题依然会暴露出来。这主要是因为一个事务中,存在两种不同的读数据方式:
SELECT
语句,就是快照读。它不加锁,通过MVCC和Read View来读取数据的“快照”版本,保证了可重复读。SELECT ... FOR UPDATE
(加X锁)SELECT ... LOCK IN SHARE MODE
(加S锁)INSERT
, UPDATE
, DELETE
这些写操作,在执行前,也需要先进行“当前读”,确保自己操作的是最新数据。幻读,就发生在一个事务中,快照读和当前读的结果不一致的时候。
假设我们有一张products
表,id
为主键,name
为商品名。现在表中有一条记录 (1, 'Apple')
。
下面我们来看两个事务的交互过程:
时间点 | 事务A (RR隔离级别) | 事务B (RR隔离级别) |
---|---|---|
T1 | BEGIN; |
|
T2 | SELECT * FROM products WHERE id = 1; -> 结果: (1, ‘Apple’) (这是快照读,创建了Read View) |
BEGIN; |
T3 | INSERT INTO products (id, name) VALUES (2, 'Orange'); COMMIT; (事务B插入了一条新数据并提交) |
|
T4 | SELECT * FROM products WHERE id = 2; -> 结果: 空 (仍然是快照读。根据T2的Read View,id=2的记录在当时还不存在,所以看不见) |
|
T5 | UPDATE products SET name = 'iPhone' WHERE id = 2; (这是当前读!它要去锁定id=2的最新记录) -> 结果: Query OK, 1 row affected. |
|
T6 | SELECT * FROM products; -> 结果: (1, ‘Apple’), (2, ‘iPhone’) (再次进行快照读。但因为本事务自己执行了写操作,MVCC规则会让它看到自己的修改) |
id=2
的记录是不存在的。UPDATE
这条它“看不见”的记录,却意外地成功了!并且数据库告诉它“有1行被影响了”。这就是典型的、在“可重复读”级别下依然会发生的幻读问题。
UPDATE
)看到的是现实(最新的数据版本,并且会加锁)。UPDATE
等当前读场景下进一步防止幻读,InnoDB引入了Next-Key Lock(临键锁),它是一种记录锁和间隙锁的结合体。SELECT * FROM products WHERE id > 0 FOR UPDATE;
,那么InnoDB不仅会锁住id=1
的记录,还会锁住id=1
到下一个记录之间的间隙,从而阻止事务B在T3时间点插入id=2
的记录。总结一下,在MySQL的可重复读级别下,虽然MVCC机制让普通的SELECT
查询避免了幻读,但只要事务中混合了“当前读”(如UPDATE
, SELECT ... FOR UPDATE
),就依然有可能暴露出幻读问题,导致数据的不一致。要完全杜绝幻读,只能使用最高的“可串行化”隔离级别。
面试官您好,“可串行化”(Serializable)作为最高级别的事务隔离级别,它的实现方式,与下面三个级别有着本质的不同。
其他三个隔离级别(读未提交、读已提交、可重复读),在处理普通的SELECT
查询时,为了提高并发性能,大多都依赖于MVCC(多版本并发控制) 来实现“无锁读”。
而 “可串行化”则彻底放弃了MVCC这条优化路径,回归到了最原始、最可靠的“加锁”方式。
正如您所说,在“可串行化”隔离级别下:
SELECT
语句,都会被隐式地转换为SELECT ... LOCK IN SHARE MODE
。“next-key锁”非常关键,这是InnoDB为了彻底解决幻读而采用的精巧设计。
SELECT
查询(比如 WHERE id > 10
)时,它不仅会给所有满足id > 10
的已有记录加上S型的记录锁。UPDATE
)或删除(DELETE
),从而解决了不可重复读。INSERT
)任何新的记录,从而完美地解决了幻读。UPDATE
或INSERT
),就必须阻塞等待,直到T1提交或回滚,释放所有S锁。总结一下,“可串行化”隔离级别是通过将所有读操作都升级为加锁操作(S型的Next-Key Lock),来强制实现事务的串行化执行。它用最高的性能代价,换取了最强的数据一致性保证。因此,在实际生产中,除非是对数据一致性要求极度苛刻、且并发量不大的特殊场景,否则我们很少会使用这个隔离级别。绝大多数情况下,InnoDB默认的“可重复读”级别已经足够健壮和高效。
面试官您好,您提出的这个问题非常好,要回答“一条UPDATE
语句是否是原子性的”,我们需要从两个不同的层面来理解“原子性”:
UPDATE
这条语句本身在执行层面,是否是一个不可分割的操作。UPDATE
语句的整个事务,是否满足“要么全做,要么全不做”的特性。我们通常在讨论数据库问题时,更关心的是第二种,即事务层面的原子性。
UPDATE
本身是原子的UPDATE
语句,我们可以认为它在数据库内部的执行是原子的。UPDATE
语句执行时,它会对自己将要修改的行记录加上排他锁(X锁)。UPDATE
的原子性由事务来保证这是我们更常讨论的、也是ACID中的原子性。它不仅关心这一条UPDATE
,更关心它所在的整个“工作单元”。
场景:一条UPDATE
语句可能只是一个复杂业务逻辑(一个事务)中的一步。比如,一个转账操作,可能包含两条UPDATE
语句:
BEGIN;
UPDATE accounts SET balance = balance - 100 WHERE user_id = 'A';
UPDATE accounts SET balance = balance + 100 WHERE user_id = 'B';
COMMIT;
如何保证? 在这个层面,原子性的保证,就依赖于两大核心机制:
a. 通过Undo Log
(回滚日志)实现“全不做的能力”
UPDATE
之前,InnoDB会先将这行数据修改前的旧值,记录到Undo Log
中。UPDATE
执行时数据库崩溃,或者业务逻辑判断需要回滚),系统就可以利用Undo Log
中记录的旧值,将数据恢复到事务开始前的状态。这就保证了“要么全不做”。b. 通过Redo Log
(重做日志)等机制,结合事务提交,实现“全做的能力”
UPDATE
并发出COMMIT
指令后,它的修改必须是永久性的。Redo Log
来保证这一点。即使在数据还未完全刷到磁盘时系统崩溃,重启后也可以通过重放Redo Log
来恢复已提交的事务,确保“全做”的结果不会丢失。所以,对于“一条UPDATE
是不是原子性的?”这个问题,我的回答是:
UPDATE
语句在执行时,不会被其他事务干扰。Undo Log
,保证了包含这条UPDATE
的整个事务,如果失败,能够完全回滚,恢复到初始状态,实现了ACID中“要么全做,要么全不做”的原子性承诺。最终,正是这种由锁、Undo Log
、Redo Log
等机制共同构建的原子性,才支撑起了数据库事务的一致性(Consistency),确保了我们的业务数据永远处于一个正确的、合乎逻辑的状态。
面试官您好,您提出的这个问题,是在数据库实践中一个非常重要的性能和稳定性考量点。滥用事务,特别是创建包含大量SQL的“大事务”或“长事务”,会给数据库系统带来一系列严重的弊端。
我通常会把这些弊端分为对数据库自身的内部影响和对整个系统架构的外部影响两大类。
1. 严重影响并发性能:锁定过多资源,引发锁冲突
2. 增加回滚成本,影响自身稳定性
UPDATE
或DELETE
操作,都记录一条 Undo Log
(回滚日志)。Undo Log
,这会占用大量的存储空间。Undo Log
。这个回滚过程本身可能会非常漫长,在此期间,数据库的相关资源也会被持续占用,对稳定性造成冲击。3. 占用宝贵的数据库连接
binlog
的主从复制架构中,一个事务的binlog
记录,只有在主库上事务完全提交之后,才会被一次性地写入。binlog
才会被传送到从库去执行。基于以上弊端,我们的核心实践原则就是:保持事务的“小而快”。
information_schema.innodb_trx
表,可以监控运行时间过长的事务,并进行告警。通过这些手段,我们就能有效地避免大事务带来的各种性能和稳定性风险。