目录
一、数据库并发的场景
1. 读-读并发
2. 读-写并发
3. 写-写并发
二、多版本并发控制( MVCC )
2.1.MVCC的核心思想
2.2.MVCC的优势
2.3.MVCC的工作原理
2.4.MVCC的应用场景
三、理解MVCC
3.1. 3个记录隐藏字段
3.2.undo日志
4.快照的概念
5.Read View
6.隔离级别RR与RC的本质区别
一、数据库并发的场景
在数据库管理系统中,并发控制是一个至关重要的方面,特别是在处理多个事务同时访问和修改同一数据时。你提到的三种并发场景(读-读、读-写、写-写)分别有不同的处理方式和潜在问题。下面是对这些场景的详细讨论以及解决读-写并发的一些方法:
1. 读-读并发
- 描述:当多个事务同时读取同一数据时,通常不会发生冲突,因为读取操作不会改变数据的状态。
- 问题:不存在线程安全问题,因此不需要特别的并发控制。
2. 读-写并发
- 描述:一个事务在读取数据的同时,另一个事务在修改该数据。
- 问题:
- 脏读:一个事务读取了另一个事务尚未提交的更改。
- 不可重复读:一个事务在不同时间点读取同一数据时,由于另一个事务的修改,两次读取的数据不一致。
- 幻读:一个事务读取了某些行后,另一个事务插入了新行,然后第一个事务再次读取时看到了这些新行。
- 解决方法:
- 锁机制:通过加锁来确保读取和写入操作之间的同步。
- 多版本并发控制(MVCC):为每个事务提供数据的快照,从而避免脏读、不可重复读和幻读。
3. 写-写并发
- 描述:多个事务同时尝试修改同一数据。
- 问题:
- 第一类更新丢失(回滚丢失):一个事务回滚时覆盖了另一个已提交事务的更改。
- 第二类更新丢失(覆盖丢失):一个事务提交时覆盖了另一个已提交事务的更改。
- 解决方法:
- 锁机制:使用排他锁(写锁)来确保只有一个事务可以修改数据。
- 版本控制:通过版本号来跟踪和解决冲突,确保数据的一致性。
读-读并发不需要进行并发控制,写-写并发实际也就是对数据进行加锁,这里最值得讨论的是读-写并发,读-写并发是数据库当中最高频的场景,在解决读-写并发时不仅需要考虑线程安全问题,还需要考虑并发的性能问题。
二、多版本并发控制( MVCC )
多版本并发控制(MVCC,Multi-Version Concurrency Control)确实是一种高效的并发控制方法,它特别适用于解决读-写冲突,同时无需使用传统的锁机制。以下是对MVCC的详细解释:
2.1.MVCC的核心思想
- 事务ID分配:
- MVCC为每个事务分配一个单向增长的事务ID(通常称为XID或TID),这个ID用于标识事务的新旧顺序。ID越大,代表事务越新。
- 版本保存:
- 当数据被修改时,MVCC会保存数据的一个新版本,并将这个版本与修改该数据的事务ID关联起来。
- 这样,每个数据项都会有一个或多个版本,每个版本都对应一个特定的事务ID。
- 快照读:
- 读操作只会读取该事务开始前的数据库快照,即读取数据时,MVCC会提供一个在该事务开始时的数据状态的快照。
- 这意味着,即使其他事务在读取过程中对数据进行了修改,读操作也不会受到影响,因为它读取的是事务开始时的数据快照。
2.2.MVCC的优势
- 提高并发性能:
- MVCC允许读写操作并发执行,而无需相互等待。
- 这大大提高了数据库的并发性能,特别是在高并发场景下。
- 解决事务隔离问题:
- 通过维护数据的多个版本,MVCC可以确保每个事务都能看到一个一致性的数据视图。
- 这有助于避免脏读、幻读和不可重复读等事务隔离问题。
2.3.MVCC的工作原理
MVCC的工作原理通常基于以下几个关键组件:
- 隐藏字段:
- 在数据库中,每行数据通常会包含一些隐藏字段,用于存储与该行数据相关的版本信息。
- 这些隐藏字段可能包括创建该版本数据的事务ID、删除该版本数据的事务ID(如果适用)等。
- 读视图:
- 当事务执行读操作时,MVCC会为该事务创建一个读视图。
- 读视图包含了当前系统中活跃的事务ID集合以及用于判断数据版本可见性的其他信息。
- 可见性判断:
- 当事务读取数据时,MVCC会根据读视图和数据的版本信息来判断该版本数据对当前事务是否可见。
- 具体判断逻辑通常涉及比较数据版本的事务ID与当前系统中活跃的事务ID集合之间的关系。
2.4.MVCC的应用场景
MVCC在多种数据库场景中都有广泛的应用,包括但不限于:
- 高并发的在线事务处理(OLTP)系统:
- MVCC能够提供高级别的并发性能,确保系统在高负载下仍然保持响应迅速。
- 数据库作为服务(DBaaS)提供商:
- 云服务提供商通常使用MVCC来支持多租户架构,允许多个用户同时对数据库进行操作而不互相影响。
- 大数据分析和报表系统:
- 在执行复杂的数据分析和生成报表时,MVCC可以确保读取操作不会受到写操作的影响,从而提供一致的数据视图。
- 内容管理系统(CMS)和博客平台:
- 这些应用需要处理大量并发的内容读取和更新操作,利用MVCC,编辑者可以同时编辑内容而不会相互冲突。
- 社交网络和实时通信平台:
- 这些平台需要处理大量的用户状态更新和消息传递,MVCC提供了一种高效的方式来管理并发更新,同时保证数据的一致性。
三、理解MVCC
理解MVCC 需要知道三个前提知识:
- 3个记录隐藏字段
- undo 日志
- Read View (在后面我们进行讲述)
3.1. 3个记录隐藏字段
数据库表中的每条记录都会有如下3个隐藏字段:
- DB_TRX_ID:
- 大小:6字节
- 功能:存储创建或最近一次修改本条记录的事务ID。这个字段对于MVCC至关重要,因为它允许InnoDB确定哪个版本的记录对当前事务是可见的。
- DB_ROW_ID:
- 大小:6字节
- 功能:隐含的自增ID,也称为隐藏主键。如果数据表没有定义主键,InnoDB会使用这个字段来生成一个聚簇索引。聚簇索引是基于数据行实际存储顺序的索引,它使得数据访问更加高效。
- DB_ROLL_PTR:
- 大小:7字节
- 功能:回滚指针,指向这条记录的上一个版本(通常存储在undo log中)。这个字段是InnoDB实现事务回滚功能的关键。当事务需要回滚时,InnoDB可以通过跟随这个指针来找到记录之前的版本,并将其恢复。
此外,还有删除flag隐藏字段:
- 删除flag:
- 功能:用于标记记录是否被逻辑删除(即软删除)。
- 在InnoDB中,当记录被“删除”时,实际上并不会立即从磁盘上移除,而是会设置一个删除标志。这样,当事务回滚或需要查询历史数据时,可以方便地找到这些被标记为删除的记录。同时,在数据持久化时,InnoDB会根据这个标志来决定是否需要将记录写入磁盘。
- 所以mysql中的删除并不代表真的删除,只是是删除flag变了,然后当数据需要进行刷盘持久化时,通过查看这个flag字段来进行选择性刷盘。
需要注意的是,这些隐藏字段是InnoDB存储引擎的内部实现细节,用户通常无法直接访问或修改它们。它们对于确保数据库的正确性、一致性和高效性至关重要。
例如下面的一个学生表,表中包含学生的姓名和年龄。如下:
CREATE TABLE student ( name VARCHAR(20) NOT NULL, age INT NOT NULL
);
desc student;
当向表中插入一条记录后(假设插入的这条SQL对应的事务ID是9),该记录不仅包含name和age字段,还包含三个隐藏字段。如下:
- 因为这是插入的第一条记录,所以隐式主键DB_ROW_ID字段填的就是1。
- 由于这条记录是新插入的,没有历史版本,所以回滚指针DB_ROLL_PTR的值设置为null。
- MVCC重点需要的就是这三个隐藏字段,实际还有其他隐藏字段,只不过没有画出。
3.2.undo日志
MySQL的三大日志
- redo log(重做日志):
- 作用:在MySQL崩溃后进行数据恢复,确保数据的持久性。
- 原理:记录了对数据的物理修改操作(如页的更新),当事务提交时,这些修改可能还没有被持久化到磁盘上。如果此时发生崩溃,redo log可以用于重做这些修改,从而恢复数据。
- bin log(二进制日志):
- 作用:用于主从数据备份时的数据同步,确保数据的一致性。
- 原理:记录了所有对数据库执行过的写操作(如INSERT、UPDATE、DELETE等),这些操作以逻辑的形式存储,可以用于数据恢复、复制和审计等。
- undo log(回滚日志):
- 作用:对已经执行的操作进行回滚,确保事务的原子性。
- 原理:在事务执行过程中,每当对数据进行修改时,都会生成相应的undo日志。这些日志记录了修改前的数据状态,以便在事务回滚或MVCC中提供旧版本的数据。
undo日志与MVCC
- MVCC:多版本并发控制,是MySQL InnoDB存储引擎实现高并发读写的一种技术。
- undo日志在MVCC中的作用:
- 当一个事务读取数据时,InnoDB会根据当前事务的开始时间和数据的版本信息来确定哪个版本的数据对当前事务是可见的。
- 如果需要读取旧版本的数据(例如,由于其他事务正在修改同一行数据),InnoDB会通过undo日志来找到并读取该数据的旧版本。
- 在事务回滚时,InnoDB也会使用undo日志来恢复数据到事务开始前的状态。
缓冲区与磁盘刷新
- MySQL会为上述三大日志在内存中开辟对应的缓冲区,用于存储日志相关的信息。
- 当缓冲区中的数据量达到一定程度或满足其他条件时,MySQL会将缓冲区中的数据刷新到磁盘上,以确保数据的持久性和安全性。
4.快照的概念
现在有一个事务ID为10的事务,要将刚才插入学生表中的记录的学生姓名改为“李四”:
- 因为是要进行写操作,所以需要先给该记录加行锁。
- 修改前,先将该行记录拷贝到undo log中,此时undo log中就有了一行副本数据。
- 然后再将原始记录中的学生姓名改为“李四”,并将该记录的DB_TRX_ID改为10,回滚指针DB_ROLL_PTR设置成undo log中副本数据的地址,从而指向该记录的上一个版本。
- 最后当事务10提交后释放锁,这时最新的记录就是学生姓名为“李四”的那条记录。
此时我们就有了一个基于链表记录的历史版本链,而undo log中的一个个的历史版本就称为一个个的快照。
说明:
- 所谓的回滚实际就是用undo log中的历史数据覆盖当前数据,而所谓的创建保存点就可以理解成是给某些版本做了标记,让我们可以直接用这些版本数据来覆盖当前数据。
- 这种技术实际就是基于版本的写时拷贝,当需要进行写操作时先将最新版本拷贝一份到undo log中,然后再进行写操作,和父子进程为了保证独立性而进行的写时拷贝是类似的
insert和delete的记录如何维护版本链?
- 删除记录并不是真的把数据删除了,而是先将该记录拷贝一份放入undo log中,然后将该记录的删除flag隐藏字段设置为1,这样回滚后该记录的删除flag隐藏字段就又变回0了,相当于删除的数据又恢复了。
- 新插入的记录是没有历史版本的,但是一般为了回滚操作,新插入的记录也需要拷贝一份放入undo log中,只不过被拷贝到undo log中的记录的删除flag隐藏字段被设置为1,这样回滚后就相当于新插入的数据就被删除了。
当前读 VS 快照读
在前面我们讨论了update,delete,insert形成版本链的方式,那么select怎么形成版本链呢?
首先,select不会对数据做任何修改,所以,为select维护多版本,没有意义!
不过,此时对于select有个问题值得被讨论就是:select读取,是读取最新的版本呢?还是读取历史版本?
- 当前读:读取最新的记录,就叫做当前读。例如使用:select * from 表名 lock in share mode(共享锁)进行当前读。
- 快照读:读取历史版本,就叫做快照读。一般我们使用的类似于select * from 表名都是快照读,如果没有快照就进行当前读。
所以事务在进行增删查改的时候,并不是都需要进行加锁保护的:
- 事务对数据进行增删改的时候,操作的都是最新记录,即进行的是当前读,所以需要进行加锁保护。
- 事务在进行select查询的时候,既可能是当前读也可能是快照读,如果是当前读,那也需要进行加锁保护,但如果是快照读,那就不需要加锁,因为历史版本不会被修改,也就是可以并发执行,这提高了效率,这也就是MVCC的意义所在。
而select查询时应该进行当前读还是快照读,则是由隔离级别决定的,在读未提交和串行化隔离级别下,进行的都是当前读,而在读提交和可重复读隔离级别下,既可能进行当前读也可能进行快照读。
undo log中的版本链何时才会被清除?
- 在undo log中形成的版本链不仅仅是为了进行回滚操作,其他事务在执行过程中也可能读取版本链中的某个版本,也就是快照读。
- 因此,只有当某条记录的最新版本已经修改并提交,并且此时没有其他事务与该记录的历史版本有关了,这时该记录在undo log中的版本链才可以被清除。
说明:
- 对于新插入的记录来说,没有其他事务会访问它的历史版本,因此新插入的记录在提交后就可以将undo log中的版本链清除了。
- 因此版本链在undo log中可能会存在很长时间,尤其是有其他事务和这个版本链相关联的时候,但这也没有坏处,这说明它是一个热数据。
5.Read View
- 事务在进行快照读操作时会生成读视图Read View,在该事务执行快照读的那一刻,会生成数据库系统当前的一个快照,记录并维护系统当前活跃的事务ID。
- Read View在MySQL源码中就是一个类,本质是用来进行可见性判断的,当事务对某个记录执行快照读的时候,MySQL会对该事务创建一个Read View,根据这个Read View来判断,当前事务能够看到该记录的哪个版本的数据,既可能是当前最新的数据,也有可能是该行记录的undo log 里面的某个版本的数据。
ReadView类的源码简化版如下:
class ReadView {// 省略...
private:/** 高水位:大于等于这个ID的事务均不可见*/trx_id_t m_low_limit_id;/** 低水位:小于这个ID的事务均可见 */trx_id_t m_up_limit_id;/** 创建该 Read View 的事务ID*/trx_id_t m_creator_trx_id;/** 创建视图时的活跃事务id列表*/ids_t m_ids;/** 配合purge,标识该视图不需要小于m_low_limit_no的UNDO LOG,* 如果其他视图也不需要,则可以删除小于m_low_limit_no的UNDO LOG*/trx_id_t m_low_limit_no;/** 标记视图是否被关闭*/bool m_closed;// 省略...
};
部分成员说明:
- m_ids: 一张列表,记录Read View生成时刻,系统中活跃的事务ID。
- m_up_limit_id: 记录m_ids列表中事务ID最小的ID。
- m_low_limit_id: 记录Read View生成时刻,系统尚未分配的下一个事务ID。
- m_creator_trx_id: 记录创建该Read View的事务的事务ID。
我们在实际读取数据版本链的时候,是能读取到每一个版本对应的事务ID的,即:当前记录的DB_TRX_ID ,那么我们现在手里面有的东西就有,当前快照读的ReadView和 版本链中的某一个记录的DB_TRX_ID 。
所以现在的问题就是,当前快照读应不应该读到当前版本记录,下面的图能够解释这个问题。
由于事务ID是单向增长的,因此根据Read View中的m_up_limit_id和m_low_limit_id,可以将事务ID分为三个部分:
- 事务ID小于m_up_limit_id的事务,一定是生成Read View时已经提交的事务,因为m_up_limit_id是生成Read View时刻系统中活跃事务ID中的最小ID,因此事务ID比它小的事务在生成Read View时一定已经提交了。
- 事务ID大于等于m_low_limit_id的事务,一定是生成Read View时还没有启动的事务,因为m_low_limit_id是生成Read View时刻,系统尚未分配的下一个事务ID。
- 事务ID位于m_up_limit_id和m_low_limit_id之间的事务,在生成Read View时可能正处于活跃状态,也可能已经提交了,这时需要通过判断事务ID是否存在于m_ids中来判断该事务是否已经提交。
判断部分:
- 一个事务在进行读操作时,只应该看到自己或已经提交的事务所作的修改,因此我们可以根据Read View来判断当前事务能否看到另一个事务所作的修改。
- 版本链中的每个版本的记录都有自己的DB_TRX_ID,即创建或最近一次修改该记录的事务ID,因此可以依次遍历版本链中的各个版本,通过Read View来判断当前事务能否看到这个版本,如果不能则继续遍历下一个版本。
源码策略如下(这个函数被调用的在一个循环中,这个循环从最新的历史版本开始向后遍历undo log里面的所有历史版本):
bool changes_visible(trx_id_t id, const table_name_t& name) const MY_ATTRIBUTE((warn_unused_result))
{ut_ad(id > 0);//1、事务id小于m_up_limit_id(已提交)或事务id为创建该Read View的事务的id,则可见if (id < m_up_limit_id || id == m_creator_trx_id) {return(true);}check_trx_id_sanity(id, name);//2、事务id大于等于m_low_limit_id(生成Read View时还没有启动的事务),则不可见if (id >= m_low_limit_id) {return(false);}//3、事务id位于m_up_limit_id和m_low_limit_id之间,并且活跃事务id列表为空(即不在活跃列表中),则可见else if (m_ids.empty()) {return(true);}const ids_t::value_type* p = m_ids.data();//4、事务id位于m_up_limit_id和m_low_limit_id之间,如果在活跃事务id列表中则不可见,如果不在则可见return (!std::binary_search(p, p + m_ids.size(), id));
}
说明: 使用该函数时将版本的DB_TRX_ID传给参数id,该函数是Read View类里面的一个成员函数,其作用就是根据Read View,判断当前事务能否看到这个版本。
6.隔离级别RR与RC的本质区别
我们通过下面的实验现象来理解RR与RC隔离级别
现象演示1
主要操作以及执行顺序:
事务A操作 | 事务A描述 | 事务B操作 | 事务B描述 |
---|---|---|---|
begin | 开启事务 | begin | 开启事务 |
- | - | 快照读 | 快照读查询select * from account |
update account set balance=1789.7 where name=‘张三’; | 更新 balance=1789.7 | - | - |
commit | 提交事务 | - | - |
- | - | 快照读 | select * from account(此时读取的是事务A提交后的数据) |
- | - | 当前读 | select * from user lock in share mode(获取当前数据的最新快照,并锁定以防止其他事务修改) |
启动两个终端,将隔离级别都设置为可重复读,并查看此时银行用户表中的数据。如下:
在两个终端各自启动一个事务,在左终端中的事务操作之前,先让右终端中的事务查看一下表中的信息。如下:
左终端中的事务对表中的信息进行修改并提交,右终端中的事务看不到修改后的数据。如下:
在右终端中使用select ... lock in share mode命令进行当前读,可以看到表中的数据确实是被修改了,只是右终端中的事务看不到而已。如下:
现象演示2
主要操作以及执行顺序:
事务A操作 | 事务A描述 | 事务B描述 | 事务B操作 |
---|---|---|---|
begin | 开启事务 | 开启事务 | begin |
select * from account | 快照读 | 快照读 | select * from account |
update account set balance=789.6 where id=7; | 更新 balance=789.6 | - | - |
commit | 提交事务 | - | - |
- | - | 快照读(读取事务A提交后的数据) | select * from account |
如果修改一下SQL的执行顺序,在两个终端各自启动一个事务后,直接先让左终端中的事务对表中的信息进行修改并提交,然后再让右终端中的事务进行查看,这时右终端中的事务就直接看到了修改后的数据。如下:
这是为什么呢?
因为Read View的形成时机,是在第一次进行快照读的时候形成的
实验1中,右边的终端在左边的修改提交之前进行了快照读,形成了Read View,于是这个Read View认为左边终端中的事务,是和它一起并发运行的事务,它不应该看到左边终端中修改提交后的数据。
实验二中,左边终端的修改没有提交之前,右边的终端在一直没有形成Read View,直到左边的事务结束以后,右边的终端才开始进行快照读,形成Read View,但是这个时候左边的事务已经结束了,于是这个刚形成的Read View就会判定左边终端中的事务是一个已经运行完毕的事务,其内容可以被看到。
RR与RC的本质区别
RR与RC的本质区别于Read View生成时机!
- 在RR级别下,事务第一次进行快照读时会创建一个Read View,将当前系统中活跃的事务记录下来,此后再进行快照读时就会直接使用这个Read View进行可见性判断,因此当前事务看不到第一次快照读之后其他事务所作的修改。
- 而在RC级别下,事务每次进行快照读时都会创建一个Read View,然后根据这个Read View进行可见性判断,因此每次快照读时都能读取到被提交了的最新的数据。
RR级别下快照读只会创建一次Read View,所以RR级别是可重复读的,而RC级别下每次快照读都会创建新的Read View,所以RC级别是不可重复读的。