本篇所有内容均基于MySQL5.6.43展开
1.事务简介
在 MySQL 中 Innodb 存储引擎是支持事务的,事务处理可以用来维护数据库的完整性,要么全部执行,要么全部不执行。本文从InnoDB的基础简介、关键特性、事务特性、事务分类等几方面学习和介绍InnoDB的事务的原理、实现和重要作用。
2.InnoDB基础简介
页记录
InnoDB存储引擎是基于磁盘存储的,并将其中的记录按照页的方式进行管理。因此可将其视为基于磁盘的数据库系统(Disk-base Database)。在数据库系统中,由于CPU速度与磁盘速度之间的鸿沟,基于磁盘的数据库系统通常使用缓冲池技术来提高数据库的整体性能。
缓冲池
缓冲池简单来说就是一块内存区域,通过内存的速度来弥补磁盘速度较慢对数据库性能的影响。在数据库中进行读取页的操作,首先将从磁盘读到的页存放在缓冲池中,这个过程称为将页“FIX”在缓冲池中。下一次再读相同的页时,首先判断该页是否在缓冲池中。若在缓冲池中,称该页在缓冲池中被命中,直接读取该页。否则,读取磁盘上的页。
对于数据库中页的修改操作,则首先修改在缓冲池中的页,然后再以一定的频率刷新到磁盘上。这里需要注意的是,页从缓冲池刷新回磁盘的操作并不是在每次页发生更新时触发,而是通过一种称为CheckPoint的机制刷新回磁盘。同样,这也是为了提高数据库的整体性能。
缓冲池中缓存的数据页类型有: 索引页、数据页、undo页、插入缓冲(insert buffer)、自适应哈希索引(adaptive hash index)、InnoDB存储的锁信息(lock info)、数据字典信息(data dictionary)等 。不能简单地认为,缓冲池只是缓存索引页和数据页,它们只是占缓冲池很大的一部分而已。
数据库中的缓冲池是通过 LRU(Latest Recent Used,最近最少使用)算法 来进行管理的。即最频繁使用的页在LRU列表的前端,而最少使用的页在LRU列表的尾端。当缓冲池不能存放新读取到的页时,将首先释放LRU列表中尾端的页。
缓存参数
参数 |
功能 |
innodb_buffer_pool_size |
缓存池容量大小。 |
innodb_buffer_pool_instances |
缓冲池实例数量。每个页根据哈希值平均分配到不同缓冲池实例中。这样做的好处是减少数据库内部的资源竞争,增加数据库的并发处理能力 |
3.InnoDB关键特性
3.1 插入缓冲(Insert Buffer)
数据插入原理
首先,先来了解下数据插入原理,如下图
整理数据插入的性能,如下
聚簇索引 |
聚簇索引插入方式 |
非聚簇索引 |
非聚簇索引插入方式 |
插入性能 |
存在 |
顺序插入 |
- |
- |
性能高 |
存在 |
无序插入 |
- |
- |
性能低 |
存在 |
顺序插入 |
非唯一索引 |
顺序插入 |
性能高 |
存在 |
顺序插入 |
唯一索引 |
- |
唯一索引需要随机IO检查是否唯一 |
存在 |
顺序插入 |
非唯一索引 |
无序插入 |
数据顺序排放,由于非聚簇索引无序,该部分需要随机读取,性能低 |
Insert Buffer的优点
综上总结可知,大部分时间是无法保证数据的绝对或相对的顺序插入的,因此为了提高数据插入的性能,InnoDB存储引擎通过insert Buffer这个特有功能来解决随机插入带来的性能问题,可以大幅度提高数据库中非唯一辅助索引的插入性能。
Insert Buffer的使用需要同时满足以下两个条件:
- 索引是非聚集索引、辅助索引(secondary index);
- 索引不是唯一(unique)的,因为唯一索引需要进行读库判断是否唯一
Insert Buffer的使用有以下优点:
- 减少磁盘的离散读取
- 将多次插入合并为一次操作
Insert Buffer的风险
在写密集的情况下,插入缓冲会占用过多的缓冲池内存(innodb_buffer_pool),默认最大可以占用到1/2的缓冲池内存。
Change Buffer的支持
InnoDB从1.0.x版本开始引入了Change Buffer,可将其视为Insert Buffer的升级。从这个版本开始,InnoDB存储引擎可以对DML操作——INSERT、DELETE、UPDATE都进行缓冲,他们分别是:Insert Buffer、Delete Buffer、Purge buffer。
innodb_change_buffering参数控制Change Buffer缓存支持
参数值 |
含义 |
inserts |
支持插入操作 |
deletes |
支持删除操作 |
purges |
支持update操作 |
changes |
支持插入、删除操作 |
all |
支持全部 |
none |
不开启 |
innodb_change_buffer_max_size参数控制占用缓冲池大小,默认25,即占用1/4,最大有效值50,即最大占用1/2
Insert Buffer实现原理
Insert Buffer的数据结构是一棵B+树,在最新的版本中,全局只有一棵Insert Buffer B+树,负责对所有的表的辅助索引进行Insert Buffer。而这棵B+树存放在共享表空间中,默认也就是ibdata1中。
TODO数据结构、原理分析
3.2 两次写(Double Write)
doublewrite(两次写)带给InnoDB存储引擎的是数据页的可靠性。
脏页刷盘风险
如上图,由于MySQL中数据页大小默认是16K(参数 innodb_page_size 控制),而操作系统的文件存储数据页大小、操作系统的磁盘块大小都不是一致的,因此当从缓冲池刷进磁盘时,要进行多次IO操作写入,也就是说多次刷盘才可以完成脏页写入到磁盘进行最终的持久化,这个过程不是一个原子操作,当遇到异常中断,会出现部分写入(绿色)、部分丢失(红色),造成数据不完整的问题。
工作流程
doublewrite由两部分组成,一部分为内存中的doublewrite buffer,其大小为2MB,另一部分是磁盘上共享表空间(ibdata1…n)中连续的128个页,即2个区(extent),大小也是2M。
- 当一系列机制触发数据缓冲池中的脏页刷新时,并不直接写入磁盘数据文件中,而是先拷贝至内存中的doublewrite buffer中;
- 接着从两次写缓冲区分两次写入磁盘共享表空间中(连续存储,顺序写,性能很高),每次写1MB;
- 待第二步完成后,再将doublewrite buffer中的脏页数据写入实际的各个表空间文件(离散写);(脏页数据持久化后,即进行标记对应doublewrite数据可覆盖)
写负载消耗
- double write是一个buffer, 但其实它是开在物理文件上的一个buffer, 其实也就是file, 所以它会导致系统有更多的fsync操作, 而硬盘的fsync性能是很慢的, 所以它会降低mysql的整体性能。
- doublewrite buffer写入磁盘共享表空间这个过程是连续存储,是顺序写,性能非常高,(约占写的%10),牺牲一点写性能来保证数据页的完整还是很有必要的。
监控负载消耗
Innodb_dblwr_pages_written / Innodb_dblwr_writes
doublewrite是默认开启的(innodb_doublewrite参数控制),每次脏页刷新必须要先写doublewrite,而doublewrite存在于磁盘上的是两个连续的区,每个区(1MB)由连续的页组成,一般情况下一个区最多有64个页(1024KB区块大小/16KB页大小=64页),所以一次IO写入应该可以最多写64个页。
而根据以上系统Innodb_dblwr_pages_written与Innodb_dblwr_writes的比例来看,越接近64就越说明缓冲池脏页刷盘压力大。
崩溃恢复过程
如果操作系统在将页写入磁盘的过程中发生崩溃,在恢复过程中,innodb存储引擎可以从共享表空间的doublewrite中找到该页的一个最近的副本,将其复制到表空间文件,再应用redo log,就完成了恢复过程。因为有副本所以也不担心表空间中数据页是否损坏。
问题解析
- 为何Redo Log无法恢复数据页Crash问题?
Redo Log记录了数据页修改的物理记录,但是拿不到数据页的真实数据,个人理解这里是Redo Log记录了数据页的物理操作记录,即操作页是哪个,具体偏移量操作是多少,但是没有记录数据页数据内容,就好比你记录了某个人的乘车轨迹,但是你不知道这个人的容貌、穿着
- 为何Log Write不需要Double Write来保证数据完整性?
因为Redo Log File文件的刷盘是按照512字节进行填充磁盘的,调用fsync()函数刷盘能一次IO填满磁盘块,要么成功要么失败,不存在非原子多次IO写问题,能保证完整性
3.3 自适应哈希索引(Adaptive Hash Index)
哈希查找的时间复杂度是O(1),而B+树的查找速度取决于树深度,生产环境一般为3-4层,即需要查找3-4次,索引往往都是基于B+树进行构建的,为了进一步优化查询速度,InnoDB存储引擎会自动根据访问的频率和模式来自动地为某些热点页建立哈希索引。AHI通过缓冲池的B+树页构造,建立的速度很快,而且不需要对整张表构建哈希索引。MySQL是默认开启的(innodb_adaptive_hash_index参数控制),哈希索引仅支持等值查询。
3.4 异步IO(Async IO)
为了提高磁盘操作性能,当前的数据库系统都采用异步IO(Asynchronous IO,AIO)的方式来处理磁盘操作。InnoDB存储引擎也是。AIO在较早的InnoDB版本中是通过存储引擎的代码模拟实现的,后续则提供了内核级Native AIO支持,依赖操作系统对AIO的支持,目前Windows、Linux都支持,MacOSX未提供支持。
AIO优势
- 无需等待,不需要依赖前置IO完成,可同时进行
- 可进行IO merge,减少IO损耗
例如要读取表中的三个连续的数据页(space=5,page_no=1)、(space=5,page_no=2)、(space=5,page_no=3),只发送一个IO请求读取3个page共48KB数据即可
AIO的应用
- 脏页刷新
- 磁盘写入
- 等等
3.5 刷新邻接页(Flush Neighbor Page)
其工作原理为:当刷新一个脏页时,InnoDB存储引擎会检测该页所在区(extent)的所有页,如果是脏页,那么一起进行刷新。这样做的好处显而易见,通过AIO可以将多个IO写入操作合并为一个IO操作,故该工作机制在传统机械磁盘下有着显著的优势。(innodb_flush_neighbors参数控制,默认开启)
优点
- 可以将相同区存在的脏页捎带刷盘,合并了IO操作,减少刷盘频次
缺点
- 可能将不怎么脏的页刷盘,之后立刻又脏了,相当于IO变相增多
4.事务特性
4.1 事务面临的问题
充分考虑以下问题:
- 如何规避宕机等极端情况下数据丢失、不一致问题?(原子性、持久性、一致性)
- 如何解决多个数据操作的回滚、逆操作问题?(原子性、一致性)
- 如何解决高并发情况下数据库的读写问题?(隔离性、一致性)
- 等等
综上可以梳理出的事务特性,就是我们常说的ACID,即原子性、一致性、隔离性、持久性。
4.2 ACID介绍
特性 |
内容 |
原子性 |
一个事务(transaction)中的所有操作,要么全部完成,要么全部不完成,不会结束在中间某个环节。事务在执行过程中发生错误,会被回滚(Rollback)到事务开始前的状态,就像这个事务从来没有执行过一样。 |
一致性 |
在事务开始之前和事务结束以后,数据库的完整性没有被破坏。这表示写入的资料必须完全符合所有的预设规则,这包含资料的精确度、串联性以及后续数据库可以自发性地完成预定的工作。 |
隔离性 |
数据库允许多个并发事务同时对其数据进行读写和修改的能力,隔离性可以防止多个事务并发执行时由于交叉执行而导致数据的不一致。事务隔离分为不同级别,包括读未提交(Read uncommitted)、读提交(read committed)、可重复读(repeatable read)和串行化(Serializable)。 |
持久性 |
事务处理结束后,对数据的修改就是永久的,即便系统故障也不会丢失。 |
4.3 ACID实现
MySQL中对ACID的实现如下:
特性 |
实现手段 |
原子性 |
undo log |
持久性 |
redo log |
隔离性性 |
锁、mvcc |
一致性性 |
通过原子性、持久性、隔离性的具体实现共同保证 |
4.3.1 原子性实现
MySQL是利用Innodb的undo log作为原子性的实现。
undo log名为回滚日志,是实现原子性的关键,当事务回滚时能够撤销所有已经成功执行的sql语句,他需要记录你要回滚的相应日志信息。
例如
- (1)当你delete一条数据的时候,就需要记录这条数据的信息,回滚的时候,insert这条旧数据
- (2)当你update一条数据的时候,就需要记录之前的旧值,回滚的时候,根据旧值执行update操作
- (3)当年insert一条数据的时候,就需要这条记录的主键,回滚的时候,根据主键执行delete操作
undo log记录了这些回滚需要的信息,当事务执行失败或调用了rollback,导致事务需要回滚,便可以利用undo log中的信息将数据回滚到修改之前的样子。
4.3.2 持久性实现
Redo Log架构
MySQL利用Innodb的redo log来实现持久性,在InnoDB存储引擎中,通过Force Log At Commit机制预写日志的Write-Ahead-Log(WAL)技术实现事务的持久性,即当事务提交时,必须先将该事务的所有日志记录写入redo log重做日志文件进行持久化。在某种程度上讲,在MySQL中实际存储数据的库表文件并不是最重要的,日志文件却是第一位的!
- 用户空间 在InnoDB存储引擎内存中包含Redo Log Buffer,事务操作最先在此提交,将事务信息内容写入Redo Log Buffer缓存中
- 内核空间 由于用户空间(User Space)的Redo Log Buffer最终要刷到磁盘空间(Disk Space)的Redo Log File文件存储介质,由于操作系统的内存到磁盘操作需要经过内核空间(Kernel Space)转换,因此OS Buffer是Redo Log Buffer到Redo Log File的桥梁和中介;此处需要了解的是,写文件时open方法若使用O_DIRECT标志位则会绕过操作系统层的OS Buffer直接写入,而MySQL进行写文件时,没有采纳这种零拷贝的技术,原因是使用O_DIRECT标志位则会频发发起系统调用,这也是非常消耗性能的操作,取而代之的是将Redo Log Buffer刷进OS Buffer,之后显示调用fsync()方法将缓存数据批量刷到Redo Log File文件存储介质中实现持久化
- 磁盘空间 Redo Log File文件就是最终日志持久化的存储介质
Redo Log Buffer&File对比
组成 |
持久性 |
数据结构 |
性能 |
存储位置 |
功能 |
重做日志缓存(redo log buffer) |
内存容易丢失 |
Log Block数组 |
内存操作快 |
(用户空间)InnoDB存储引擎内存 |
由于内存操作,加快了Redo Log File 写磁盘速度 |
重做日志文件(redo log file) |
持久存储 |
Log Block数组 |
磁盘操作慢 |
(磁盘空间)系统磁盘 |
由于是追加顺序写,比数据文件的随机写高效; 实现WAL技术的手段 |
- Redo Log Buffer 加快了Redo Log File写磁盘速度增加的一层内存处理逻辑,为异步写Redo Log File文件提供了可能,让Redo Log可以支持多种刷盘策略;缓存级操作可以将多次事务操作合并优化不必每次操作都进行Redo Log File的写入操作
- Redo Log File 作为实现WAL(Write-Ahead-Log)技术的手段,使得InnoDB可以支持事务的持久性
Redo Log Buffer数据结构
Log Block 即日志块。无论Redo Log Buffer还是Redo Log File都是以 Log Block 的形式存在的。
- Log Block Header
- LOG_BLOCK_HDR_NO 标记Log Block在整个Log Buffer大数组中的位置,递增、循环,第一位记录flush bit是否刷盘
- LOG_BLOCK_HDR_DATA_LEN 表示log block的占用空间大小,写满时为0x200,即512字节
- LOG_BLOCK_FIRST_REC_GROUP 表示第一个日志的偏移量,如果与LOG_BLOCK_HDR_DATA_LEN相同,说明无新日志
- LOG_BLOCK_CHECKPOINT_NO 表示写入检查点信息的位置
- Log Block Body
- REDO_LOG_TYPE 表示redo log的日志类型
- SPACE 表示表空间的ID,采用压缩的方式后,占用的空间可能小于4字节
- PAGE_NO 表示页的偏移量,同样是压缩过的
- REDO_LOG_BODY 表示每个重做日志的数据部分,恢复时会调用相应的函数进行解析。
例如insert语句和delete语句写入redo log的内容是不一样的
- Log Block Tailer 其值和LOG_BLOCK_HDR_NO值相同
LOG_BLOCK_FIRST_REC_GROUP
- 事务T1独占log block情况 当一个log block只包含一个事务T1时,LOG_BLOCK_FIRST_REC_GROUP默认从log block header之后开始,即12
- 多事务T1、T2共存一个log block情况 当一个log block包含多个事务T1、T2时LOG_BLOCK_FIRST_REC_GROUP从log block header之后开始,即12
- 多事务T1、T2跨log block存储情况 当多个事务T1、T2跨log block存储时
左侧LOG_BLOCK_FIRST_REC_GROUP从log block header之后开始,即12
右侧LOG_BLOCK_FIRST_REC_GROUP为258,从log block header 12 之后加上T1左侧未完成的246,即12+246 =258,即258开始才是T2,才是右侧log block中第一个事务
Redo Log File数据结构
Log Group
- Log Group数量 Log Group只是一个逻辑概念,可以通过show variable like 'innodb_log_files_in_group’查看Log Group中Redo Log File数量,默认为2。Log Group的数量
- Log Group构成 Redo Log File构成了一个逻辑Log Group,每个Log Group中第一个Redo Log File前4个Log Block(每个512字节)是固定依次存放Log File Header、Check Point 1、留空、Check Point 2信息的,同Log Group中的其他Redo Log File前4个Log Block不存储但是全留空。
- 尽管Redo Log File对事务日志追加顺序写到每个Log Block中,但由于每个Log Group中每个Redo Log File前4个Log Block都用来存储日志文件信息的Header、CheckPoint等,所以对于纯事务记录来说这并不是完全意义的顺序写,这里需要留意。
Check Point
CheckPoint技术解决了以下问题:
- 当数据页发生变化时,若每次更改就进行刷盘是非常耗费性能的,CheckPoint可以作为度量告知应该从哪里开始刷盘,每次刷多少页,有多少页没刷盘
- 当数据库发生异常或宕机时,可以缩短数据库的恢复时间,不需要将全部的Redo Log File进行检查和恢复,从CheckPoint开始即可
- 缓冲池不够用时,可以将脏页(Buffer缓存中修改了但没有刷盘到磁盘的数据)刷新到磁盘;
- 重做日志不可用(Redo Log是循环覆写的,容量是固定的,日志满了或者遭遇大事务无法写入等)时,可以根据CheckPoint刷新脏页。
在InnoDB存储引擎中,Checkpoint发生的时间、条件及脏页的选择等都非常复杂。而Checkpoint所做的事情无外乎是将缓冲池中的脏页刷回到磁盘。不同之处在于每次刷新多少页到磁盘,每次从哪里取脏页,以及什么时间触发Checkpoint。
CheckPoint类型 |
描述 |
Sharp Checkpoint |
发生在数据库关闭时将所有的脏页都刷新回磁盘,这是默认的工作方式,即参数innodb_fast_shutdown=1 |
Fuzzy Checkpoint |
只刷新一部分脏页,而不是刷新所有的脏页回磁盘 |
Fuzzy Checkpoint的触发情况:
触发方式 |
触发时机 |
作用 |
Master Thread Checkpoint |
定时轮做 |
差不多以每秒或每十秒的速度从缓冲池的脏页列表中刷新一定比例的页回磁盘。这个过程是异步的,即此时InnoDB存储引擎可以进行其他的操作,用户查询线程不会阻塞 |
FLUSH_LRU_LIST Checkpoint |
空闲页数量不足时,可以通过参数innodb_lru_scan_depth控制LRU列表中可用页的数量,该值默认为1024 |
InnoDB存储引擎要保证LRU列表中有足够的空闲页,若不足会将LRU列表末端页进行移除,若这些末端页中存在脏页会触发CheckPoint InnoDB1.1版本之前,检查LRU列表是否有足够空闲页是在用户的查询线程中,因此该操作会阻塞用户查询操作 InnoDB1.2版本之后,这个检查被放在了一个单独的Page Cleaner线程中进行 |
Async/Sync Flush Checkpoint |
Redo Log已满时 |
指的是重做日志文件不可用的情况,这时需要强制将一些页刷新回磁盘,而此时脏页是从脏页列表中选取的 它的作用是为了保证重做日志的循环使用的可用性 在InnoDB 1.2.x版本之前,Async Flush Checkpoint会阻塞发现问题的用户查询线程,而Sync Flush Checkpoint会阻塞所有的用户查询线程,并且等待脏页刷新完成 从InnoDB 1.2.x版本开始,这部分的刷新操作同样放入到了单独的Page Cleaner Thread中,故不会阻塞用户查询线程 |
Dirty Page too much Checkpoint |
其可由参数innodb_max_dirty_pages_pct控制,默认75,即脏页达到75%时 |
即脏页的数量太多,导致InnoDB存储引擎强制进行Checkpoint。其目的总的来说还是为了保证缓冲池中有足够可用的页 |
LSN
对于InnoDB存储引擎而言,其是通过LSN(Log Sequence Number) 来标记版本的。而LSN是8字节的数字,其单位是字节。每个页有LSN,重做日志中也有LSN,Checkpoint也有LSN。可以通过命令SHOW ENGINE INNODB STATUS查看
- Log sequence number XXXXXX
- Log flushed up to XXXXXX
- Last checkpoint at XXXXXX
Redo Log刷盘策略
- innodb_flush_log_at_trx_commit=0 事务提交时不会将log buffer中日志写入到os buffer,而是每秒写入os buffer并调用fsync()写入到log file on disk中。也就是说设置为0时是(大约)每秒刷新写入到磁盘中的,当系统崩溃,会丢失1秒钟的数据
- innodb_flush_log_at_trx_commit=1默认。 事务每次提交都会将log buffer中的日志写入os buffer并调用fsync()刷到log file on disk中。这种方式即使系统崩溃也不会丢失任何数据,但是因为每次提交都写入磁盘,IO的性能较差
- innodb_flush_log_at_trx_commit=2 每次提交都仅写入到os buffer,然后是每秒调用fsync()将os buffer中的日志写入到log file on disk
Redo Log 刷盘策略 |
性能 |
安全性 |
0 |
高 |
无法确保事务完整性,极端情况有innodb_flush_log_at_timeout(默认1)秒数据丢失 |
1 |
低 |
确保事务完整性,主从复制架构确保数据一致性必须设置sync_binlog=1和该值为1 |
2 |
较高 |
无法确保事务完整性,极端情况有innodb_flush_log_at_timeout(默认1)秒数据丢失,由于每次事务提交都刷新OS Buffer,比0策略相对安全 |
以上性能对比,0最高,2次之,1最低。最耗时的操作在调用fsync()刷磁盘操作上,2和0的区别并不大,2比0多了一步就是每次事务提交commit时顺带将Redo Log Buffer刷进OS Buffer,这其实是内存迁移操作并没有太多性能消耗。
Redo Log触发刷盘情况
重做日志在下列三种情况下会将重做日志缓冲中的内容刷新到外部磁盘的重做日志文件中:
- Master Thread每一秒将重做日志缓冲刷新到重做日志文件;
- 每个事务提交时会将重做日志缓冲刷新到重做日志文件;
- 当重做日志缓冲池剩余空间小于1/2时,重做日志缓冲刷新到重做日志文件。
Redo Log参数剖析
参数 |
功能 |
innodb_log_file_size |
该参数决定着mysql事务日志文件(ib_logfile0)的大小,即Log Group中Redo Log File大小 |
innodb_log_files_in_group |
该参数控制日志文件数。默认值为2。mysql 事务日志文件是循环覆写的 |
innodb_log_buffer_size |
该参数确保有足够大的日志缓冲区来保存脏数据在被写入到日志文件之前 |
innodb_log_group_home_dir |
在事务被提交并写入到表空间磁盘文件上之前,事务日志存储在InnoDB的redo日志文件里。为了获得最佳性能,建议分离innodb_data_home_dir和innodb_log_group_home_dir到单独的物理存储阵列上,这样可以保证IO资源不起冲突,利于服务器处理大量高并发连接 |
4.3.3 隔离性实现
事务隔离级别
关于事务隔离级别,一般分为以下四种:
事务隔离级别 |
脏读 |
不可重复读 |
幻读 |
读未提交(read-uncommitted) |
是 |
是 |
是 |
不可重复读(read-committed) |
否 |
是 |
是 |
可重复读(repeatable-read) |
否 |
否 |
是 |
串行化(serializable) |
否 |
否 |
否 |
事务隔离问题
- 脏读:事务A读取了事务B更新的数据,然后B回滚操作,那么A读取到的数据是脏数据
- 不可重复读:事务 A 多次读取同一数据,事务 B 在事务A多次读取的过程中,对数据作了更新并提交,导致事务A多次读取同一数据时,结果 不一致
- 幻读:系统管理员A将数据库中所有学生的成绩从具体分数改为ABCDE等级,但是系统管理员B就在这个时候插入了一条具体分数的记录,当系统管理员A改结束后发现还有一条记录没有改过来,就好像发生了幻觉一样,这就叫幻读
- 不可重复读的和幻读很容易混淆,不可重复读侧重于修改,幻读侧重于新增或删除。解决不可重复读的问题只需锁住满足条件的行,解决幻读需要锁表
MySQL事务隔离
MySQL利用的是锁和MVCC机制来实现隔离性的。
- MyISAM存储引擎中仅支持表级锁
- InnoDB存储引擎中支持表级锁、行级锁、意向锁、间隙锁等支持更多颗粒度的并发控制。
InnoDB存储引擎在事务隔离性上的策略
事务隔离级别 |
脏读 |
不可重复读 |
幻读 |
读未提交(read-uncommitted) |
是 |
是 |
是 |
不可重复读(read-committed) |
否 |
是(读undo日志文件最新快照数据) |
是 |
可重复读(repeatable-read) |
否 |
否(读undo日志文件最早快照数据) |
否(next key解决) |
串行化(serializable) |
否 |
否 |
否 |
Lock与Latch
- latch一般称为闩锁(轻量级的锁),因为其要求锁定的时间必须非常短。若持续的时间长,则应用的性能会非常差。在InnoDB存储引擎中,latch又可以分为mutex(互斥量)和rwlock(读写锁)。其目的是用来保证并发线程操作临界资源的正确性,并且通常没有死锁检测的机制。
- lock的对象是事务,用来锁定的是数据库中的对象,如表、页、行。并且一般lock的对象仅在事务commit或rollback后进行释放(不同事务隔离级别释放的时间可能不同)。此外,lock,正如在大多数数据库中一样,是有死锁机制的
\ |
Lock |
Latch |
对象 |
事务 |
线程 |
保护 |
数据库内容 |
内存数据结构 |
持续时间 |
整个事务过程 |
临界资源 |
模式 |
行锁、表锁、意向锁 |
读写锁、互斥量 |
死锁 |
waits for graph、time out等机制进行死锁检查与处理 |
无死锁检查与处理机制,仅通过加锁顺序保证 |
存在于 |
Lock Manager的哈希表中 |
每个数据结构的对象中 |
可通过SHOW ENGINE INNODB MUTEX命令查看Latch信息,含义如下:
名称 |
说明 |
count |
mutex被请求的次数 |
spin_waits |
spin_lock自旋锁的次数,InnoDB在latch无法获取锁时会进行自旋,若还不能获取到则进入等待状态 |
spin_rounds |
自选内部循环的总次数,每次自选的内部循环是一个随机数。spin_rounds/spin_waits表示平均每次自旋内部所需的循环次数 |
os_waits |
表示操作系统等待的次数。当spin_lock通过自旋还不能获得latch时,则会进入操作系统等待状态,等待被唤醒 |
os_yields |
进行os_thread_yield唤醒操作的次数 |
os_wait_times |
操作系统等待的时间,单位是ms |
可以看到,latch产生竞态条件时,选择了先进行自旋解决,而不是先选择会引起用户态和内核态切换的系统等待。
锁类型
从数据库操作角度可划分为读锁、写锁
锁类型 |
是否可读 |
是否可写 |
类别 |
读锁 |
可读 |
不可写 |
乐观、共享锁 |
写锁 |
不可读 |
不可写 |
悲观、排他锁 |
从颗粒度角度可以划分为表锁,行锁、间隙锁
存储引擎 |
表锁 |
行锁 |
间隙锁(next-key locking) |
MyISAM |
支持 |
不支持 |
不支持 |
Innodb |
支持 |
支持 |
支持 |
锁类型 |
开销 |
颗粒度 |
并发冲突 |
表锁 |
小 |
大 |
高 |
行锁 |
大 |
小 |
小 |
间隙锁 |
大 |
中 |
中 |
表级锁
大部分MySQL存储引擎都支持表级锁
行级锁
InnoDB存储引擎实现了如下两种标准的行级锁:
- 共享锁(S Lock),允许事务读一行数据。
- 排他锁(X Lock),允许事务删除或更新一行数据。
行级锁的排它锁和共享锁的兼容性
\ |
X |
S |
X |
不兼容 |
不兼容 |
S |
不兼容 |
兼容 |
意向锁
MySQL在Server层面支持的是表级锁,而InnoDB存储引擎支持行级锁,为了支持事务在行级上的锁和表级上的锁同时存在,使得多层次颗粒度的锁,InnoDB存储引擎提供了意向锁的实现。
- 意向锁是通过表级锁进行实现的
- 意向锁是InnoDB存储引擎层级的实现,是兼容MySQL服务器层面的表级锁和InnoDB特有支持的行级锁共同存在的桥梁
- 意向锁的加锁规则是,对最下层对象(行)加锁前,要先对其上层对象(表)进行加锁
- 意向锁相当于表操作的前置开关,其他事务通过表的意向锁的情况与自身锁的兼容性就可知是要阻塞还是执行,有时无需了解下一层加锁对象情况
网络异常,图片无法展示|
InnoDB存储引擎中锁的兼容性 - IS:意向共享锁(表级锁)
- IX:意向排他锁(表级锁)
- S:共享锁(表级锁、行级锁)
- X:排他锁(表级锁、行级锁)
锁类型 |
IS |
IX |
S |
X |
IS |
兼容 |
兼容 |
兼容 |
不兼容 |
IX |
兼容 |
兼容 |
不兼容 |
不兼容 |
S |
兼容 |
不兼容 |
兼容 |
不兼容 |
X |
不兼容 |
不兼容 |
不兼容 |
不兼容 |
通过命令SHOW ENGINE INNODB STATUS及information_schema架构下的表INNODB_TRX、INNODB_LOCKS、INNODB_LOCK_WAITS来观察锁的信息
表INNODB_TRX信息
字段名 |
说明 |
trx_id |
InnoDB存储引擎内部唯一的事务ID |
trx_state |
当前事务的状态 |
trx_started |
事务的开始时间 |
trx_requested_lock_id |
等待事务的锁ID |
trx_wait_started |
事务等待开始的时间 |
trx_weight |
事务的权重。反映一个事务修改和锁住的行数。发送死锁需要回滚时,InnoDB存储引擎会选择该值最小的进行回滚 |
trx_mysql_thread_id |
执行事务的线程ID,SHOW PROCESSLIST显示的结果 |
trx_query |
事务允许的SQL语句 |
表INNODB_LOCKS信息
字段名 |
说明 |
lock_id |
锁的ID |
lock_trx_id |
事务ID |
lock_mode |
锁的模式 |
lock_type |
锁的类型,表锁、行锁 |
lock_table |
要加锁的表 |
lock_index |
锁住的索引 |
loack_space |
锁对象的space_id |
lock_page |
事务锁定页的数量。若是表锁,则为NULL |
lock_rec |
事务锁定行的数量。若是表锁,则为NULL |
lock_data |
事务锁定记录的主键值,若是表锁,则为NULL |
表INNODB_LOCK_WAITS信息
字段名 |
说明 |
request_trx_id |
申请锁资源的事务ID |
request_lock_id |
事务的锁的ID |
blocking_trx_id |
阻塞的事务ID |
blocking_lock_id |
阻塞的锁的ID |
悲观排他锁与乐观共享锁
一致性非锁定读
在锁的兼容性一节可知,行级锁中的X排它锁会阻塞其他事务的读写操作,但是不同的事务隔离级别有不同的数据读取隔离性要求,InnoDB存储引擎通过多版本控制(Multi Version Concurrency Control,MVCC)来解决这种行级排它锁阻塞其他事务读操作的情况,InnoDB通过undo日志来进行数据回滚,因此若事务读取的数据行正在进行UPATE、DELETE事务,转而选择读取它对应的undo日志快照数据而无需阻塞等待排他锁占用事务的完成,因为undo日志是历史数据不会被修改。非锁定读会极大的提高数据库的并发性能。
非锁定一致读仅在度读已提交READ_COMMITTED和可重复读REPEATABLE_READ两种隔离级别下应用
事务隔离级别 |
快照数据读取方式 |
产生问题 |
读已提交 READ COMMITTED |
总是读取被锁定行的最新一份快照数据 |
不可重复读、幻读 |
可重复读 REPEATABLE READ |
总是读取事务开始时的行数据版本 |
幻读(这里是为了对比,InnoDB通过Next Key Lock解决了REPEATABLE READ隔离级别下的幻读问题) |
一致性锁定读
InnoDB存储引擎对于SELECT语句支持两种一致性的锁定读(locking read)操作:
SELECT…FOR UPDATE的锁类型会根据情况施加表级意向排他锁或者行级锁,具体如下:
- 无论命中或没命中主键、普通索引,只要查询到数据行则给数据行上行级排它锁(X) ,阻塞其他事务对该数据行的锁定读写,不影响无需锁定的普通读;按照InnoDB的加锁原则,会先给表增加表级意向排他锁(IX) ,因此也会阻塞表级的锁定读写,不影响无需锁定的普通表级读
- 若查询无数据返回,则不施加表锁或行级锁,其他事务可正常执行
下面是对SELECT…FOR UPDATE几种情况的测试思维导图,可以参考:
SELECT…LOCK IN SHARE MODE 的锁类型会根据情况施加 表级意向共享锁 或者 行级锁 ,具体如下:
- 无论命中或没命中主键、普通索引,只要查询到数据行则给数据行上行级意向锁(S) ,阻塞其他事务对该数据行的锁定写,不影响无需锁定的普通读或锁定的写操作;按照InnoDB的加锁原则,会先给表增加表级意向共享锁(IS) ,因此也会阻塞表级的锁定写,不影响无需锁定的普通表级读
- 若查询无数据返回,则不施加表锁或行级锁,其他事务可正常执行
下面是对SELECT…LOCK IN SHARE MODE几种情况的测试思维导图,可以参考:
锁的算法
行锁算法
行锁算法 |
描述 |
行锁(Record Lock) |
单个行记录上的锁,总是会去锁住索引记录,如果InnoDB存储引擎表在建立的时候没有设置任何一个索引,那么这时InnoDB存储引擎会使用隐式的主键来进行锁定 |
间隙锁(Gap Lock) |
锁定一个范围,但不包含记录本身 |
间隙锁+行记录锁(Gap Lock+Record Lock)(Next-Key Lock) |
锁定一个范围,并且锁定记录本身,设计的目的是为了解决幻读问题(Phantom Problem) |
锁带来问题
问题 |
描述 |
幻读 |
在READ UNCOMMITTED、READ COMMITTED事务隔离级别下会产生,REPEATABLE(Next Key锁)、SERIALIZE(串行化)隔离级别下解决 |
不可重复读 |
在REPEATABLE事务隔离级别下,读取undo日志文件事务开始阶段快照解决 |
丢失更新 |
增加排它锁进行数据锁定进行船串行化来解决 |
阻塞 |
锁存在时,其他事务会话需要等待 |
死锁 |
超时机制; 等待图(wait-for graph)锁的信息链表、事务等待链表,发现链表回路则进行事务回滚,一般回滚undo量最小的事务,死锁检测使用的是深度优先查找算法 |
其他
自增长与锁
- 为保证自增的正确性,会有锁存在
- 自增锁不是在一个事务完成后才释放,而是在完成对自增长值插入的SQL语句后立即释放
问题:唯一索引冲突后,事务回滚未插入真实数据,自增键值回滚了吗?
答案:没有回滚
外键与锁
对于外键值的插入或更新,首先需要查询父表中的记录,即SELECT父表。但是对于父表的SELECT操作,不是使用一致性非锁定读的方式,因为这样会发生数据不一致的问题,因此这时使用的是SELECT…LOCK IN SHARE MODE方式,即主动对父表加一个S锁
4.3.4 一致性实现
从数据库层面,数据库通过原子性、隔离性、持久性来保证一致性。也就是说ACID四大特性之中,C(一致性)是目的,A(原子性)、I(隔离性)、D(持久性)是手段,是为了保证一致性,数据库提供的手段。数据库必须要实现AID三大特性,才有可能实现一致性。例如,原子性无法保证,显然一致性也无法保证。
5.事务分类
这部分内容摘自《MySQL技术内幕:InnoDB存储引擎》,大部分时间我们都使用的是扁平事务,而且个人看来,根据实际的业务场景来使用事务,尽管事务分类中支持了嵌套、链式等,还是尽量保持事务操作的简洁性和易读性,减少开发和调试难度。关于事务分类深入研究可以参考Jim Gray的《事务处理:概念与技术》
5.1 扁平事务 (Flat Transactions)
5.2 带有保存点的扁平事务 (Flat Transaction With SavePoints)
5.3 链事务 (Chained Transactions)
5.4 嵌套事务 (Nested Transactions)
5.5 分布式事务 (DIstributed Transactions)
分布式事务一般是在分布式环境下的扁平事务
参考
https://www.cnblogs.com/rjzheng/p/10841031.html
https://www.runoob.com/mysql/mysql-transaction.html
https://blog.csdn.net/zhaoyangjian724/article/details/53285925
https://www.cnblogs.com/wyaokai/p/10921323.html
http://blog.itpub.net/29654823/viewspace-2147683/
https://www.cnblogs.com/geaozhang/p/7241744.html
《MySQL技术内幕:InnoDB存储引擎》第2版
《事务处理:概念与技术》Jim Gray