目录
- 思维导图
- 主要内容
- 事务特性及实现
- 并发事务带来的问题
- 事务隔离级别
- MVCC
- 相关面试题
1. 思维导图
2. 主要内容
2.1.1 事务特性
事务(Transaction)是数据库系统执行过程中的一个逻辑处理单元,可由一条简单的SQL语句组成,也可以由一组复杂的SQL语句组成。在事务中的操作,要么都做修改,要么都不做。 事务特性:
- 原子性(Atomicity): 指一个数据库事务中的所有操作是不可分割的单元,只有事务中所有的数据库操作都执行成功,才算整个事务成功。事务中任何一个SQL语句执行失败,已经执行成功的SQL语句也必须撤销,数据库状态应该退回到执行事务前的状态。 比如一个转账操作,A 向 B 转账 100。这个转账操作就必须是一个原子操作,A 减去 100,B 加上 100,要么都成功,要么都回滚,不能有中间状态,任何一个SQL失败,都要回滚到执行事务前的状态。
BEGIN;
UPDATE account SET balance = balance - 100 WHERE card = "A";
UPDATE account SET balance = balance + 100 WHERE card = "B";
COMMIT;
- 一致性(Consistency): 指事务将数据库从一种状态转变为下一种一致的状态。在事务开始之前和事务结束以后,数据库的完整性约束没有被破坏。以转账为例,一致性就是:转账前后,A和B的余额加起来和转账后一致;转账后比转账前,A的余额少了100,B的余额多了100。
- 隔离性(Isolation): 每个读写事务的对象对其他事务的操作对象能相互分离,即该事务提交前对其他事务都不可见。
- 持久性(Durability): 事务一旦提交,其结果就是永久性的。即使发生宕机等故障,数据库也能将数据恢复。
2.1.2 事务特性的实现
- 原子性(Atomicity):
- 实现: 通过数据库的undo log来实现,undo log称为撤销日志或回滚日志。在一个事务中进行增删改操作时,都会记录对应的 undo log。
- 当 delete 一条数据的时候,就记录这条数据的信息,回滚的时候,insert 这条旧数据
- 当 update 一条数据的时候,就记录之前的旧值,回滚的时候,根据旧值执行 update 操作
- 当 insert 一条数据的时候,就这条记录的主键,回滚的时候,根据主键执行 delete 操作 而且 undo log 形成的版本链还用于实现多版本并发控制(MVCC),InnoDB的RC和RR隔离级别就是是基于MVCC来实现高性能事务,而且通过MVCC来避免幻读的发生。
- 实现: 通过数据库的undo log来实现,undo log称为撤销日志或回滚日志。在一个事务中进行增删改操作时,都会记录对应的 undo log。
- 一致性(Consistency):
- 实现: 需要两个层面来保证:
- 数据库层面: 数据库必须要实现AID三大特性,才有可能实现一致性。例如,原子性无法保证,显然一致性也无法保证
- 应用层面: 如果在事务里故意写出违反约束的代码,一致性还是无法保证的。应用层面应该通过代码判断数据库数据是否有效,然后决定回滚还是提交数据。
- 实现: 需要两个层面来保证:
- 隔离性(Isolation): 由锁来实现,不同的加锁方式,可以实现不同的事务隔离机制。
- 持久性(Durability): 通过数据库的redo log来实现,在更新Buffer Pool中的数据页时,会同时记录对应的 redo log,这样就算脏页没有刷盘,在MySQL宕机重启时,也可以通过 redo log 来恢复数据。
2.2 并发事务带来的问题
2.2.1 脏写
一个事务的更新操作会被另一个事务的更新操作所覆盖,从而导致数据写的不一致。
- 事务A回滚把事务B已提交的修改给覆盖了,就会造成事务B的修改丢失。InnoDB存储引擎不会发生这个问题,因为InnoDB在更新数据时加了排他锁, 这样在事务A在未完成的时候, 其他事务是无法对事务A涉及到的数据做修改并提交的
- 事务A覆盖了事务B已提交的修改,造成事务B的修改丢失。解决办法:使用乐观锁(版本号)或者悲观锁(for update)
时间线 | 事务A | 事务B |
---|---|---|
1 | BEGIN | BEGIN |
2 | 查询余额=0 | 查询余额=0 |
3 | 余额+100 | |
4 | 余额+200 | |
5 | COMMIT | |
6 | 余额=200 | |
7 | ROLLBACK | |
8 | 余额=0 | |
9 | 因为事务A回滚,事务B的改动被覆盖,数据丢失 |
2.2.2 脏读
如果一个事务A读到了另一个事务B修改过的未提交的数据,那事务A的读取就是脏读,因为事务A读取的数据是非持久性的数据。
时间线 | 事务A | 事务B |
---|---|---|
1 | BEGIN | BEGIN |
2 | 查询余额=100 | 查询余额=100 |
3 | 余额+100 | |
4 | 查询余额=200 | |
5 | ROLLBACK | |
6 | 余额=100 | |
7 | 因为事务A回滚,事务B的查询结果为脏数据 |
2.2.3 不可重复读
在没有脏读的情况下,如果一个事务多次读取同一个数据不一致,那说明发生了不可重复读的问题,也就是同一个数据无法重复读取,违反了数据库事务一致性的要求。
时间线 | 事务A | 事务B |
---|---|---|
1 | BEGIN | BEGIN |
2 | 查询余额=100 | 查询余额=100 |
3 | 余额+100 | |
4 | COMMIT | |
5 | 查询余额=200 | |
6 | 因为事务B的查询结果第一次为100 第二次为200 |
2.2.4 幻读
幻读就是一个事务用同样的条件查询,由于另一个事务新增了数据,导致看到了之前没有的数据。
时间线 | 事务A | 事务B |
---|---|---|
1 | BEGIN | BEGIN |
2 | 查询10个账户,余额=0 | 查询10个账户,余额=0 |
3 | 每个账户余额+100 | |
4 | 新增1个账户,余额=0 | |
5 | COMMIT | |
6 | 查询所有账户,发现有一个账户余额=0 | |
7 | COMMIT | |
8 | 因为事务A新增一个账户并提交,事务B同时更新所有账户余额+100,更新完后,再次查看账户,发现有个账户余额=0 |
2.3 事务隔离
2.3.1 事务隔离级别
在操作数据的事务中,不同的锁机制会产生以下几种不同的事务隔离级别,不同的隔离级别分别可以解决并发事务产生的几个问题,对应如下:
- 读未提交(Read Uncommitted):在事务 A 读取数据时,事务 B 读取数据加了共享锁,修改数据时加了排它锁。
- 读已提交(Read Committed):在事务 A 读取数据时增加了共享锁,一旦读取,立即释放锁,事务 B 读取修改数据时增加了行级排他锁,直到事务结束才释放锁。也就是说,事务 A 在读取数据时,事务 B 只能读取数据,不能修改。当事务 A 读取到数据后,事务 B 才能修改。
- 可重复读(Repeatable Read):在事务 A 读取数据时增加了共享锁,事务结束,才释放锁,事务 B 读取修改数据时增加了行级排他锁,直到事务结束才释放锁。也就是说,事务 A 在没有结束事务时,事务 B 只能读取数据,不能修改。当事务 A 结束事务,事务 B 才能修改。
- 可序列化(Serializable):在事务 A 读取数据时增加了共享锁,事务结束,才释放锁,事务 B 读取修改数据时增加了表级排他锁,直到事务结束才释放锁。可序列化解决了脏读、不可重复读、幻读等问题,但隔离级别越来越高的同时,并发性会越来越低。
隔离级别 | 脏读 | 不可重复读 | 幻读 |
---|---|---|---|
Read Uncommitted | Y | Y | Y |
Read Committed | N | Y | Y |
Repeatable Read | N | N | Y |
Serializable | N | N | N |
2.3.2 锁
- 乐观锁/悲观锁:
- 乐观锁: 某一字段作为版本号,如果更新时的版本号跟之前的版本一致,则更新,否则更新失败
- 悲观锁: 查询时在事务中使用 select xx for update 语句来获取一个排他锁,保证在该事务结束之前其他事务无法更新该数据
- 共享锁/排他锁:
- 共享锁: 允许一个事务读数据,不允许修改数据,如果其他事务要再对该行加锁,只能加共享锁
- 排他锁: 修改数据时加的锁,可以读取和修改数据,一旦一个事务对该行数据加锁,其他事务将不能再对该数据加任何锁,保证当前事务结束释放锁前,其他事物都不能读取和修改这行数据
- InnoDb 锁实现:
- 行锁是通过索引实现的:
- next-key lock: 对索引项以其之间的间隙加锁,默认用 Next-Key Lock 去锁住一个左开右闭的区间
- gap lock: 对索引项之间的间隙加锁, 普通索引上的等值查询,会一直向右遍历,最后一个值不满足等于条件时,这个区间的Next-Key Lock 降级为gap lock
- record lock: 唯一索引上的等值查询,给唯一索引加锁的时候,Next-Key Lock退化为 Record Lock。
- 唯一索引上的范围查询会访问到不满足条件的第一个值为止(包含这条记录)。
- 如果不通过索引条件检索数据,那么 InnoDB 将对表中所有的记录进行加锁,其实就是升级为表锁了
- 只在可重复读或以上隔离级别下的特定操作才会取得 gap lock 或 next-key lock,在 Select 、Update 和 Delete 时,除了基于唯一索引的查询之外,其他索引查询时都会获取 gap lock 或 next-key lock,即锁住其扫描的范围。
- 行锁是通过索引实现的:
- 意向锁:
- 是表级的锁,与行级锁互斥,主要所用于快速判断能否对表加锁成功,申请行级锁的后也会申请意向锁
2.4 MVCC
2.4.1 设计MVCC的目的和原理
- 目的: 解决并发读取的问题,同时避免用锁来提升并发读取的性能
- 原理: 在 RC、RR 这两种隔离级别下执行SELECT查询时,利用ReadView, 通过访问记录的undo 版本链查找当前事务可见的版本,而不需要加锁,这样使得不同事务的读-写操作可以并发执行,从而提升数据库的性能。
- 版本链: 由undo log形成的一个版本链条
2.4.2 ReadView
- m_ids:在生成 ReadView 时当前系统中活跃的事务的事务ID列表。
- min_trx_id:生成 ReadView 时当前系统中活跃的事务中最小的事务ID,也就是m_ids中的最小值。
- max_trx_id:生成 ReadView 时系统中分配给下一个事务的ID值,就是全局事务ID(Max Trx Id),注意并不是m_ids中的最大值。
- creator_trx_id:生成该 ReadView 的事务的事务ID。事务中只有在执行了增删改操作时才会分配一个事务ID,如果是一个只读事务,那 creator_trx_id 默认就为0。
2.4.3 undo版本链+ReadView机制
判断此版本是否可访问的依据就是用 undo log 中的 trx_id 属性值与 ReadView 中的各个属性做比较。
- 如果 trx_id 等于 creator_trx_id ,说明当前事务在访问它自己修改过的记录,所以该版本可以被当前事务访问。
- 如果 trx_id 小于 min_trx_id,说明生成该版本的事务在当前事务生成 ReadView 前已经提交,所以该版本可以被当前事务访问。
- 如果 trx_id 大于或等于max_trx_id,说明生成该版本的事务在当前事务生成 ReadView 后才开启,所以该版本不可以被当前事务访问。
- 如果 trx_id 在 min_trx_id 和 max_trx_id 之间,此时再判断一下 trx_id 是不是在 m_ids 列表中,如果在,说明创建 ReadView 时生成该版本的事务还是活跃的,该版本不可以被访问;如果不在,说明创建 ReadView 时生成该版本的事务已经被提交,该版本可以被访问。
2.4.4 总结
Read Committed 和 Repeatable Read 隔离级别的区别就是它们生成ReadView的时机不同。
Read Committed: 每次查询都生成一个新的 ReadView,不能读到别的事务未提交的修改,因此解决了 脏读 的问题。但是能读取到别的事务已提交的修改,会有 不可重复读、幻读 的问题。
Repeatable Read: 只在第一次查询前生成一个 ReadView,之后的查询都重复使用这个 ReadView。别的事务未提交、已提交、新插入的修改都读取不到,因此解决了脏读、不可重复读、幻读 的问题。这里的幻读是指查询时的读,写入时(select for update)的当前读实际用的是record lock+gap lock,锁住了区间,变相将隔离级别升级到了serializable,然后基于最新的值去做更新,更新后刷新快照读的版本,严格来说是违反了Repeatable Read的。间隙锁的引入,可能会导致同样的语句锁住更大的范围,这其实对数据库的并发性能会有一定影响。如果确定业务不需要可重复读的保证,可以将数据库隔离级别设置到Read Committed,但这可能会导致数据和 binlog
日志不一致,这时需要把 binlog
模式设置为 row或者mixed。row/mixed 模式下,binlog 记录的是每行数据的修改,就不会有 Statement
模式下的那个问题了,但日志空间会大很多。
Serializable: InnoDB 使用读写都加锁的方式来访问记录 串行化的实现采用的是读写都加锁的原理。串行化的情况下,对于同一个事务, 写会加写锁 , 读会加读锁 。当出现读写锁冲突的时候,后访问 的事务必须等前一个事务执行完成,才能继续执行。
执行 DELETE 语句或者更新主键的 UPDATE 语句并不会立即把对应的记录完全从页面中删除,而是将 delete_mask 设置为 1,做标记删除。这主要就是为MVCC服务的,因为可能有其它并发运行的事务,要通过版本链读取当前事务可见的版本。
3. 相关面试题
- MySQL 中有哪几种锁,列举一下?
- 说说 InnoDB 里的行锁实现?
- 意向锁是什么知道吗?
- MySQL 的乐观锁和悲观锁了解吗?
- MySQL 遇到过死锁问题吗,你是如何解决的? 排查死锁的一般步骤是这样的: (1)查看死锁日志 show engine innodb status; (2)找出死锁 sql (3)分析 sql 加锁情况 (4)模拟死锁案发 (5)分析死锁日志 (6)分析死锁结果 当然,这只是一个简单的流程说明,实际上生产中的死锁千奇百怪,排查和解决起来没那么简单。
- MySQL 事务的四大特性说一下?
- ACID 靠什么保证的呢?
- 事务的隔离级别有哪些?MySQL 的默认隔离级别是什么?
- 什么是幻读,脏读,不可重复读呢?
- 不同的隔离级别,在并发事务下可能会发生的问题?
- 事务的各个隔离级别都是如何实现的?
- MVCC 了解吗?怎么实现的? MVCC(Multi Version Concurrency Control),中文名是多版本并发控制,简单来说就是通过维护数 据历史版本,从而解决并发访问情况下的读一致性问题。关于它的实现,要抓住几个关键点,隐式字 段、undo 日志、版本链、快照读&当前读、Read View。
- MySQL的主从复制原理?
- 主库单binlog dump线程,读取主库上的binlog event发送给从库的I/O线程
- 从库I/O线程获取到binlog event之后将其写入到自己的Relay Log中,relay log相当于缓冲区,读写线程解耦
- 从库启动SQL线程,将Relay中的数据进行重放,完成从库的数据更新。