说到数据库离不开事务这个概念,什么是事务?事物是一组操作,这些操作要么全部执行,要不全部不执行。例如汇款就是一个事务,里面包含了汇款人余额的减少和收款人余额的增加,这两件事必须保证全部都执行。
说到事务,必须了解其特性,这也是老生常谈的东西了
下面看看Mysql
为了保证事务的这些特性做了哪些努力吧。
Mysq
l内有三大日志系统:二进制日志binlog(归档日志)、事务日志redo log和回滚日志undo log。
redo log是InnoDB存储引擎所独有的,它让MySQL
拥有了崩溃恢复能力。
MySQL
中数据是以页为单位,你查询一条记录,会从硬盘把一页的数据加载出来,加载出来的数据叫数据页,会放入到 Buffer Pool
中。后续的查询都是先从 Buffer Pool
中找,没有命中再去硬盘加载,减少硬盘 IO
开销,提升性能。
更新表数据的时候,也是如此,发现 Buffer Pool
里存在要更新的数据,就直接在 Buffer Pool
里更新。然后会把“在某个数据页上做了什么修改”记录到重做日志缓存(redo log buffer
)里,接着刷盘到 redo log
文件里。
理想情况,事务一提交就会进行刷盘操作,但实际上,刷盘的时机是根据策略来进行的。
InnoDB
存储引擎为 redo log
的刷盘策略提供了 innodb_flush_log_at_trx_commit
参数,它支持三种策略:
innodb_flush_log_at_trx_commit
参数默认为 1 ,也就是说当事务提交时会调用 fsync
对 redo log 进行刷盘。另外,InnoDB
存储引擎有一个后台线程,每隔1
秒,就会把 redo log buffer
中的内容写到文件系统缓存(page cache
),然后调用 fsync
刷盘。
除了后台线程每秒1
次的轮询操作,还有一种情况,当 redo log buffer
占用的空间即将达到 innodb_log_buffer_size
一半的时候,后台线程会主动刷盘。
日志数据损失情况
innodb_flush_log_at_trx_commit=0
时,如果MySQL
挂了或宕机可能会有1
秒数据的丢失。innodb_flush_log_at_trx_commit=1
,只要事务提交成功,redo log
记录就一定在硬盘里,不会有任何数据丢失。如果事务执行期间MySQL
挂了或宕机,这部分日志丢了,但是事务并没有提交,所以日志丢了也不会有损失。innodb_flush_log_at_trx_commit=2
时, 只要事务提交成功,redo log buffer
中的内容只写入文件系统缓存(page cache
)。如果仅仅只是MySQL
挂了不会有任何数据丢失,但是宕机可能会有1
秒数据的丢失。硬盘上存储的 redo log
日志文件不只一个,而是以一个日志文件组的形式出现的,每个的redo
日志文件大小都是一样的。比如可以配置为一组4
个文件,每个文件的大小是 1GB
,整个 redo log
日志文件组可以记录4G
的内容。它采用的是环形数组形式,从头开始写,写到末尾又回到头循环写。
在个日志文件组中还有两个重要的属性,分别是 write pos、checkpoint
write pos 和 checkpoint 之间的还空着的部分可以用来写入新的 redo log 记录。因此如果 write pos 追上 checkpoint ,表示日志文件组满了,这时候不能再写入新的 redo log 记录,MySQL
得停下来,清空一些记录,把 checkpoint 推进一下。
由于MySQL
中数据是以页为单位,大小是16KB,每次事务结束后,往往一页中只有几个Byte的数据有改变,因此频繁刷盘会导致性能的下降。其次修改的数据页的位置可能在硬盘的随机位置,因此性能很差。
如果是写 redo log
,一行记录可能就占几十 Byte
,只包含表空间号、数据页号、磁盘文件偏移 量、更新值,再加上是顺序写,所以刷盘速度很快,大大提高了数据库的并发能力。
binlog
是逻辑日志,记录内容是语句的原始逻辑,类似于“给 ID=2 这一行的 c 字段加 1”。MySQL
数据库的数据备份、主备、主主、主从都离不开binlog
,需要依靠binlog
来同步数据,保证数据一致性。
binlog
日志有三种格式,可以通过binlog_format
参数指定。
SQL
原文SQL
语句了,还包含操作的具体数据(也就是SQL中会变化的部分),但是这种格式需要更大的容量来记录,比较占空间MySQL
会判断这条SQL
语句是否可能引起数据不一致,如果是,就用row
格式,否则就用statement
格式。binlog
的写入时机也非常简单,事务执行过程中,先把日志写到binlog cache
,事务提交的时候,再把binlog cache
写到binlog
文件中。
redo log
(重做日志)让InnoDB
存储引擎拥有了崩溃恢复能力。binlog
(归档日志)保证了MySQL
集群架构的数据一致性。虽然它们都属于持久化的保证,但是侧重点不同。
在执行更新语句过程,会记录redo log
与binlog
两块日志,以基本的事务为单位,redo log
在事务执行过程中可以不断写入,而binlog
只有在提交事务时才写入,所以redo log
与binlog
的写入时机不一样。
我们知道如果想要保证事务的原子性,就需要在异常发生时,对已经执行的操作进行回滚,在 MySQL 中,恢复机制是通过 回滚日志(undo log) 实现的,所有事务进行的修改都会先记录到这个回滚日志中,然后再执行相关的操作。如果执行过程中遇到异常的话,我们直接利用 回滚日志 中的信息将数据回滚到修改之前的样子即可!并且,回滚日志会先于数据持久化到磁盘上。这样就保证了即使遇到数据库突然宕机等情况,当用户再次启动数据库的时候,数据库还能够通过查询回滚日志来回滚将之前未完成的事务。
此外,undo log也是MVCC功能实现的基础。
MySQL InnoDB 引擎使用 redo log(重做日志) 保证事务的持久性,使用 undo log(回滚日志) 来保证事务的原子性。MySQL
数据库的数据备份、主备、主主、主从都离不开binlog
,需要依靠binlog
来同步数据,保证数据一致性。
下面轮到MVCC了,MVCC是Multi version concurrent control的缩写,目的是为了实现在并发过程中,读-写操作可以并发进行。
说到数据库的并发读写,可能会出现四个问题
不可重复度和幻读区别:不可重复读的重点是修改,幻读的重点在于新增或者删除。
为了解决上述全部或者部分问题,数据库设置了一些隔离级别
read-uncommited(读取未提交):允许读取尚未提交的数据,可能会导致脏读、不可重复读和幻读。
read-commited(读取已提交):允许读取已经提交的数据,避免脏读,可能会导致不可重复读和幻读。
repeatalbe-read(可重复读):对同一字段的多次读取结果是一致的,可以阻止脏读和不可重复读,但幻读仍有可能发生。
serializable(可串行化):所有的事务依次逐个执行,这样事务之间就完全不可能产生干扰,也就是说,该级别可以防止脏读、不可重复读以及幻读。
针对read-commited和repeatalbe-read两种不同的隔离级别,MVCC选择的Read View生成的时机不同。首先介绍一下MVCC 基础的实现:隐藏字段,Read View和undo log。
在内部,InnoDB
存储引擎为每行数据添加了三个隐藏字段 :
DB_TRX_ID(6字节)
:表示最后一次插入或更新该行的事务 id。此外,delete
操作在内部被视为更新,只不过会在记录头 Record header
中的 deleted_flag
字段将其标记为已删除DB_ROLL_PTR(7字节)
回滚指针,指向该行的 undo log
。如果该行未被更新,则为空DB_ROW_ID(6字节)
:如果没有设置主键且该表没有唯一非空索引时,InnoDB
会使用该 id 来生成聚簇索引这里着重注意db_trx_id和db_roll_ptr这两个字段。
ReadView主要是用来做可见性判断,里面保存了 “当前对本事务不可见的其他活跃事务”
主要有以下字段:
m_low_limit_id
:目前出现过的最大的事务 ID+1,即下一个将被分配的事务 ID。大于等于这个 ID 的数据版本均不可见。m_up_limit_id
:活跃事务列表 m_ids
中最小的事务 ID,如果 m_ids
为空,则 m_up_limit_id
为 m_low_limit_id
。小于这个 ID 的数据版本均可见m_ids
:Read View
创建时其他未提交的活跃事务 ID 列表。创建 Read View
时,将当前未提交事务 ID 记录下来,后续即使它们修改了记录行的值,对于当前事务也是不可见的。m_ids
不包括当前事务自己和已提交的事务(正在内存中)m_creator_trx_id
:创建该 Read View
的事务 ID在 InnoDB
存储引擎中 undo log
分为两种: insert undo log 和 update undo log。
以下是例子:
insert 数据
第一次修改数据
第二次修改数据
不同事务或者相同事务的对同一记录行的修改,会使该记录行的 undo log
成为一条链表,链首就是最新的记录,链尾就是最早的旧记录。
在 InnoDB
存储引擎中,创建一个新事务后,执行每个 select
语句前,都会创建一个快照(Read View),快照中保存了当前数据库系统中正处于活跃(没有 commit)的事务的 ID 号。
下面是判断是否满足可见性条件的伪代码
def fun(DB_TRX_ID, ReadView):
if DB_TRX_ID < ReadView.m_up_limit_id:
那么表明最新修改该行的事务(DB_TRX_ID)在当前事务创建快照之前就提交了,所以该记录行的值对当前事务是可见的
return;
else if DB_TRX_ID >= ReadView.m_low_limit_id:
那么表明最新修改该行的事务(DB_TRX_ID)在当前事务创建快照之后才修改该行,所以该记录行的值对当前事务不可见。
else:
if DB_TRX_ID in 活跃事务列表 ReadView.m_ids:
该记录行的值被事务 ID 为 DB_TRX_ID 的事务修改了
这个记录行的值对当前事务都是不可见的
else:
记录行对当前事务可见
return
//记录对当前事务不可见,通过链表在undo log中找到上一次更新时的Read View
return fun(DB_TRX_ID, DB_ROLL_PTR.ReadView)
在事务隔离级别 RC
和 RR
(InnoDB 存储引擎的默认事务隔离级别)下,InnoDB
存储引擎使用 MVCC
(非锁定一致性读),但它们生成 Read View
的时机却不同
每次select
查询前都生成一个Read View
(m_ids 列表),这保证了每次访问到的数据都是已经提交的数据,避免了脏读。第一次select
数据前生成一个Read View
(m_ids 列表),这保证了在多次读取的过程中,每次所看到的Read View是一样的,得到的结果是一致的,避免不可重复读。答案是仅仅只是解决了一部分。
使用MVCC的目的是增加更多的并发支持,希望读与写不会阻塞,并不会加锁。由于使用了快照技术,即使事务2在事务1执行过程中更新了数据,事务1的读操作都只是会读取快照中允许看见的数据,读取是undo log中的历史数据,事务2做出的更新操作并不会被事务1读取到,由此实现了快照读与更新操作之间的非阻塞。
当前读存在于更新操作中,在做出更新之前,读取到最新的数据,根据读取的最新的数据做出更新。因为只有读取到最新的数据,此更新操作才能正确。那么事务1在事务2做出更新操作之后,执行更新操作,那么它的当前读锁读取的数据并不是快照中的历史数据,而是事务2执行后最新的数据,这就会造成幻读的错误。
因此在使用MVCC时,如果事务中都是select操作,也就是都是快照读,那么MVCC会保证读出的数据时正确的;但是如果事务中存在更新操作,更新操作中隐含了当前读,就有可能出现幻读的情况。
解决这一问题的措施是上锁,在事务的第一个select操作中加上for update/lock in share mode锁(X/S锁),那么其他事务进行更新操作时就会阻塞,直至第一个事务释放锁。InnoDB
使用 Next-key Lock来防止这种情况。next-key lock 是 record lock 和 gap lock 的结合,锁定的是一个范围,如果查询数据为索引记录行,则只会锁定当前行,也就是说降级为 record lock。