前言
在数字世界的金融交易、在线购物乃至社交互动的每一次点击背后,数据库事务如同无形的精密齿轮,默默维系着数据世界的因果律。当千万级并发请求如潮水般涌入系统,开发者们总会面临这样的终极拷问:如何让数据在风暴中保持精确的舞步?为何看似简单的SELECT查询在不同场景下会呈现迥异的逻辑镜像?又是什么神秘机制让MySQL在保持ACID特性的同时,仍能迸发出惊人的并发吞吐量?
这背后是一场关于"时间"的魔术——在数据库引擎的微观世界里,每个事务都拥有独属的时空坐标系,数据版本在Undo Log的时光长河中不断衍生,而ReadView则如同智能滤镜,为每个观察者构建出逻辑自洽的宇宙切片。正是MVCC(多版本并发控制)这项源自1981年的理论,在InnoDB存储引擎中焕发出新的生命力,用版本链、事务ID、可见性判断等精妙设计,编织出一张兼顾性能与一致性的隐形大网。
本文将带您穿越事务隔离级别的表象迷雾,直抵MySQL最核心的并发控制引擎。通过逐层解剖MVCC的实现机理,揭示ReadView如何在不同隔离级别下演绎出迥异的可见性规则,最终理解为何可重复读(RR)级别既能避免幻读又暗藏玄机。这不仅是数据库内核开发者需要掌握的设计哲学,更是每一位追求极致性能的后端工程师必备的底层思维模型——当我们真正读懂了事务的"时空相对论",那些曾令人困惑的幻读现象、版本跳变问题,都将化作可预测、可掌控的代码逻辑。
1.事务基本理论
事务是什么: 事务指的是逻辑上的一组操作,组成这组操作的各个单元要么全都成功,要么全都失败。
事务作用:保证在一个事务中多次SQL操作要么全都成功,要么全都失败。
MySQL是一个服务器/客户端架构的软件,对于同一个服务器来说,可以有若干个客户端与之连接,每个客户端与服务器连接上之后,就可以称之为一个会话( Session )。我们可以同时在不同的会话里输入各种语句,这些语句可以作为事务的一部分进行处理。不同的会话可以同时发送请求,也就是说服务器可能同时在处理多个事务,这样子就会导致不同的事务可能同时访问到相同的记录。
事务的隔离性在理论上是指,在某个事务对某个数据进行访问时,其他事务应该进行排队,当该事务提交之后,其他事务才可以继续访问这个数据。但是这样子的话对性能影响太大,所以才会出现各种隔离级别,来最大限度的提升系统并发处理事务的能力,牺牲部分隔离性来提升性能。 由于在MySQL中的事务是由存储引擎实现,而且MySQL只有InnoDB支持事务。因此我们讲解InnoDB 的事务。
事务四大特性ACID
- 原子性(Atomicity): 原子性是指事务是一个不可分割的工作单位,事务中的操作要么都发生, 要么都不发生
- 一致性(Consistency): 事务前后数据的完整性必须保持一致
- 隔离性(Isolation):多个用户并发访问数据库时,一个用户的事务不能被其它用户的事务所干 扰,多个并发事务之间数据要相互隔离。隔离性由隔离级别保障!
- 持久性(Durability): 一个事务一旦被提交,它对数据库中数据的改变就是永久性的,接下来即 使数据库发生故障也不应该对其有任何影响。
事务并发问题
- 脏读:一个事务读到了另一个事务未提交的数据
- 不可重复读:一个事务读到了另一个事务已经提交(update)的数据。引发事务中的多次查询结果不 一致
- 虚读 /幻读:一个事务读到了另一个事务已经插入(insert)的数据。导致事务中多次查询的结果不一 致
隔离级别
- read uncommitted 读未提交【RU】,一个事务读到另一个事务没有提交的数据
- 存在:3个问题(脏读、不可重复读、幻读)。
- read committed 读已提交【RC】,一个事务读到另一个事务已经提交的数据
- 存在:2个问题(不可重复读、幻读)。
- 解决:1个问题(脏读)
- repeatable read:可重复读【RR】,在一个事务中读到的数据始终保持一致,无论另一个事务是 否提交
- 解决:3个问题(脏读、不可重复读、幻读)
- serializable 串行化,同时只能执行一个事务,相当于事务中的单线程
- 解决:3个问题(脏读、不可重复读、幻读)
2.事务底层原理
2.1 丢失更新问题
两个事务针对同一数据进行修改操作时会丢失更新,这个现象称之为丢失更新问题。
举例:管理者查询所有用户的存款总额,假设除了用户01和用户01之外,其他用户的存款都为0, 用户01、02各有存款1000,所以所有用户的存款总额为2000。但是在查询过程中,用户01会向用户02 进行转账操作。
2.2 解决方案
2.2.1 解决方案一:基于锁并发控制
使用基于锁的并发控制LBCC(Lock Based Concurrency Control)可以解决上述问题。
查询总额事务会对读取的行加锁,等到操作结束后再释放所有行上的锁。因为用户A的存款被锁,导致转账操作被阻塞,直到查询总额事务提交并将所有锁都释放。
这种方案比较简单粗暴,就是一个事务去读取一条数据的时候,就上锁,不允许其他事务来操作。假如当前事务只是加读锁,那么其他事务就不能有写锁,也就是不能修改数据;而假如当前事务需要加写 锁,那么其他事务就不能持有任何锁。总而言之,能加锁成功,就确保了除了当前事务之外,其他事务 不会对当前数据产生影响,所以自然而然的,当前事务读取到的数据就只能是最新的,而不会是快照数据。
2.2.1 解决方案二:基于版本并发控制MVCC
查询总额事务先读取了用户A的账户存款,然后转账事务会修改用户A和用户B账户存款,查询总额事务 读取用户B存款时不会读取转账事务修改后的数据,而是读取本事务开始时的副本数据【快照数据】。
MVCC使得普通的SELECT请求不加锁,读写不冲突,显著提高了数据库的并发处理能力。
3 MVCC原理剖析
MVCC全称叫多版本并发控制,是RDBMS常用的一种并发控制方法,用来对数据库数据进行并发访问, 实现事务。核心思想是读不加锁,读写不冲突。在读多写少的OLTP应用中,读写不冲突非常重要,极大的增加了系统的并发性能,这也是为什么几乎所有的RDBMS,都支持MVCC的原因。
MVCC实现原理关键在于数据快照,不同的事务访问不同版本的数据快照,从而实现事务下对数据的隔离级别。虽然说具有多个版本的数据快照,但这并不意味着必须拷贝数据,保存多份数据文件(这样会浪费存储空间),InnoDB通过事务的Undo日志巧妙地实现了多版本的数据快照。
MVCC 的实现依赖与Undo日志 与 Read View。
InnoDB下的表有默认字段和可见字段,默认字段是实现MVCC的关键,默认字段是隐藏的列。默认字段 最关键的两个列,一个保存了行的事务ID,一个保存了行的回滚指针。每开始新的事务,都会自动递增 产生一个新的事务id。事务开始后,生成当前事务影响行的ReadView。当查询时,需要用当前查询的事 务id与ReadView确定要查询的数据版本。
3.1 undo日志
Redo日志记录了事务的行为,可以很好地通过其对页进行“重做”操作。但是事务有时还需要进行回滚操 作,这时就需要undo。因此在对数据库进行修改时,InnoDB存储引擎不但会产生Redo,还会产生一定量的Undo。这样如果用户执行的事务或语句由于某种原因失败了,又或者用户用一条Rollback语句请求回滚,就可以利用这些undo信息将数据回滚到修改之前的样子。在多事务读取数据时,有了Undo日志 可以做到读不加锁,读写不冲突。
根据行为的不同Undo日志分为两种: Insert Undo Log 和 Update Undo Log。
Undo日志保存了记录修改前的快照。所以,对于更新和删除操作,InnoDB并不是真正的删除原来的记 录,而是设置记录的delete mark为1。因此为了解决数据Page和Undo日志膨胀问题,则需要回收机制 进行清理Undo日志。
Insert Undo Log:是在Insert操作中产生的Undo日志
Insert 操作的记录只对事务本身可见,对于其它事务此记录是不可见的,所以 Insert Undo Log 可以在事务提交后直接删除而不需要进行回收操作。如新增数据:
# 事务1:
Insert into tab_user(id,name,age,address) values (10,'麦麦',23,'beijing')
Update Undo Log :是Update或Delete 操作中产生的Undo日志
Update操作会对已经存在的行记录产生影响,为了实现MVCC多版本并发控制机制,因此Update Undo日志不能在事务提交时就删除,而是在事务提交时将日志放入指定区域,等待异步线程删除操作。
如下图所示(第一次修改):
# 事务2:
update tab_user set name='雄雄',age=18 where id=10;
当事务2使用Update语句修改该行数据时,会首先使用写锁锁定目标行,将该行当前的值复制到Undo 中,然后再真正地修改当前行的值,最后填写事务ID,使用回滚指针指向Undo中修改前的行。
当事务3进行修改与事务2的处理过程类似,如下图所示(第二次修改):
# 事务3: update tab_user set name='迪迪',age=16 where id=10;
3.2 ReadView
MVCC的核心问题就是: 判断一下版本链中的哪个版本是当前事务可见的!
- 对于使用RU隔离级别的事务来说,直接读取记录的最新版本就好了,不需要Undo log。
- 对于使用串行化隔离级别的事务来说,使用加锁的方式来访问记录,不需要Undo log。
- 对于使用RC和RR隔离级别的事务来说,需要用到undo日志的版本链。
什么是ReadView?
ReadView是张存储事务id的表,主要包含当前系统中有哪些活跃的读写事务,把它们的事务id放到一个列表中。结合Undo日志的默认字段【事务trx_id】来控制那个版本的Undo日志可被其他事务看见。
四个列:
- m_ids:表示在生成ReadView时,当前系统中活跃的读写事务id列表
- m_low_limit_id:事务id下限,表示当前系统中活跃的读写事务中最小的事务id,m_ids事务列表 中的最小事务id - m_up_limit_id:事务id上限,表示生成ReadView时,系统中应该分配给下一个事务的id值
- m_creator_trx_id:表示生成该ReadView的事务的事务id
ReadView怎么产生,什么时候生成?
- 开启事务之后,在第一次查询(select)时,生成ReadView
- RC 和 RR 隔离级别的差异本质是因为MVCC中ReadView的生成时机不同
如何判断可见性? 开启事务执行第一次查询时,首先生成ReadView,然后依据Undo日志和ReadView按照判断可见性, 按照下边步骤判断记录的版本链的某个版本是否可见。
循环判断规则如下:
- 如果被访问版本的 trx_id 属性值,小于ReadView中的事务下限id,表明生成该版本的事务在生 成 ReadView 前已经提交,所以该版本可以被当前事务访问。
- 如果被访问版本的 trx_id 属性值,等于ReadView中的 m_creator_trx_id ,可以被访问。
- 如果被访问版本的 trx_id 属性值,大于等于ReadView中的事务上限id,在生成 ReadView 后才产 生的数据,所以该版本不可以被当前事务访问。
- 如果被访问版本的 trx_id 属性值,在事务下限id和事务上限id之间,那就需要判断是不是在 m_ids 列表中。
- 如果在,说明创建 ReadView 时生成该版本的事务还是活跃的,该版本不可以被访问;
- 如果不在,说明创建 ReadView 时生成该版本的事务已经被提交,该版本可以被访问。
循环判断Undo log中的版本链某一的版本是否对当前事务可见,如果循环到最后一个版本也不可见的 话,那么就意味着该条记录对该事务不可见,查询结果就不包含该记录。
总结
- MVCC指在使用RC、RR隔离级别下,使不同事务的读-写 、 写-读 操作并发执行,提升系统性能。
- MVCC核心思想是读不加锁,读写不冲突。
- RC、RR这两个隔离级别的一个很大不同就是生成 ReadView 的时机不同:
- RC在每一次进行普通 SELECT 操作前都会生成一个 ReadView。
- RR在第一次进行普通 SELECT 操作前生成一个 ReadView ,之后的查询操作都重复这个 ReadView。
思考题: MySQL MVCC是否完全解决了幻读问题?如果没有使用什么方式解决的?