ACID特性
- 原子性(Atomicity) : 事务是最小的执行单位,不允许分割。事务的原子性确保动作要么全部完成,要么完全不起作用;MySQL的事务支持操作的回滚,所以支持原子性;
- 一致性(Consistency): 执行事务前后,数据保持一致,例如转账业务中,无论事务是否成功,转账者和收款人的总额应该是不变的;所以我个人偏向于把它理解成一串对数据进行操作的程序执行下来,不会对数据产生不好的影响,比如凭空产生,或消失; 即在事务执行过程中,无论期间执行了多少个对数据的操作,最终的数据都应该是一个正确的结果,不会凭空的出现一些数据或少了一些数据。
- 隔离性(Isolation): 并发访问数据库时,一个用户的事务不被其他事务所干扰,各并发事务之间数据库是独立的;
- 持久性(Durability): 一个事务被提交之后。它对数据库中数据的改变是持久的,即使数据库发生故障也不应该对其有任何影响。
事务状态
- 活动的:事务对应数据库操作正在执行过程中时,我们就说是处于活动的状态。
- 部分提交的:当事务的最后一个操作执行完成,但未刷新到磁盘。
- 失败的:当事务处于活动的状态或者部分提交的状态时。可能遇到某些错误而无法继续执行,或者人为的停止事务的执行,我们就说事务处于失败的状态。
- 中止的:事务执行了半截而变为失败的状态,然后回滚,数据库恢复到事务执行前的状态,就说处于中止状态。
- 提交的:全刷新到磁盘。
隔离级别
事务操作可能会出现的数据问题
- 脏读
事务A修改了数据,但未提交,而事务B查询了事务A修改过却没有提交的数据,这就是脏读,因为事务A可能会回滚- 不可重复读
事务A 先 查询了金额,是200块钱,未提交 。事务B在事务A查询完之后,修改了金额,变成了300, 在事务A前提交了;如果此时事务A再查询一次数据,就会发现钱跟上一次查询不一致,是300,而不是200。这就是不可重复读。强调事务A对要操作的数据被别人修改了,但在不知请的情况下拿去做之前的用途- 幻读
事务A先修改了某个表的所有纪录的状态字段为已处理,未提交;事务B也在此时新增了一条未处理的记录,并提交了;事务A随后查询记录,却发现有一条记录是未处理的,很是诧异,刚刚不是全部修改为已处理嘛,以为出现了幻觉,这就是幻读
数据库中总共就4个操作: 增,删,改,查
脏读说的是事务知道了自己本不应该知道的东西,强调的动作是查询,我看到了自己不该看的东西 ; 不可重复读强调的是一个人查的时候,其他人却可以增删改, 但我却不知道数据被改了,还拿去做了之前的用途;幻读强调的是我修改了数据,等我要查的时候,却发现有我没有修改的记录,为什么,因为有其他人插了一条新的
事务隔离级别
- READ-UNCOMMITTED(读取未提交): 最低的隔离级别,允许读取尚未提交的数据变更,可能会导致脏读、幻读或不可重复读。
- READ-COMMITTED(读取已提交): 允许读取并发事务已经提交的数据,可以阻止脏读,但是幻读或不可重复读仍有可能发生。
- REPEATABLE-READ(可重复读): 对同一字段的多次读取结果都是一致的,除非数据是被本身事务自己所修改,可以阻止脏读和不可重复读,但幻读仍有可能发生。
- SERIALIZABLE(可串行化): 最高的隔离级别,完全服从 ACID 的隔离级别。所有的事务依次逐个执行,这样事务之间就完全不可能产生干扰,也就是说,该级别可以防止脏读、不可重复读以及幻读。
什么是MVCC
MVCC(Multi-Version Concurrency Control):多版本并发控制,是一种并发控制的方法,一般在数据库管理系统中,实现对数据库的并发访问,在编程语言中实现事务内存。
MVCC 在 MySQL InnoDB 中的实现主要是为了提高数据库并发性能,用更好的方式去处理读-写冲突,做到即使有读写冲突时,也能做到不加锁,非阻塞并发读。
什么是当前读和快照读
要说的再细致一些,快照读本身也是一个抽象概念,再深入研究。MVCC 模型在 MySQL 中的具体实现则是由 3 个隐式字段,undo 日志 ,Read View 等去完成的,具体可以看下面的 MVCC 实现原理
MVCC的好处
多版本并发控制(MVCC)是一种用来解决读-写冲突的无锁并发控制,也就是为事务分配单向增长的时间戳,为每个修改保存一个版本,版本与事务时间戳关联,读操作只读该事务开始前的数据库的快照。所以 MVCC 可以为数据库解决以下问题:
在并发读写数据库时,可以做到在读操作时不用阻塞写操作,写操作也不用阻塞读操作,提高了数据库并发读写的性能
同时还可以解决脏读,幻读,不可重复读等事务隔离问题,但不能解决更新丢失问题
MVCC工作原理
MVCC 的目的就是多版本并发控制,在数据库中的实现,就是为了解决读写冲突,它的实现原理主要是依赖记录中的 3个隐式字段(trx_id, roll_pointer, row_id),undo日志 ,Read View 来实现的。
三个隐藏字段
如上图,DB_ROW_ID 是数据库默认为该行记录生成的唯一隐式主键,DB_TRX_ID 是当前操作该记录的事务 ID ,而 DB_ROLL_PTR 是一个回滚指针,用于配合 undo日志,指向上一个旧版本。
insert的undo日志只在事务回滚时起作用,当事务提交后,该类型的undo日志就没用了,它占用的空间也会被回收。
在update产生的uodo日志中,只会记录一些索引列及被更新的列的信息,并不会记录所有列的信息,需要查找上个版本。
undo日志
undo log 主要分为两种:
- insert undo log
代表事务在 insert 新记录时产生的 undo log, 只在事务回滚时需要,并且在事务提交后可以被立即丢弃- update undo log
事务在进行 update 或 delete 时产生的 undo log ; 不仅在事务回滚时需要,在快照读时也需要;所以不能随便删除,只有在快速读或事务回滚不涉及该日志时,对应的日志才会被 purge 线程统一清除
purge
从前面的分析可以看出,为了实现 InnoDB 的 MVCC 机制,更新或者删除操作都只是设置一下老记录的 deleted_bit ,并不真正将过时的记录删除。
为了节省磁盘空间,InnoDB 有专门的 purge 线程来清理 deleted_bit 为 true 的记录。为了不影响 MVCC 的正常工作,purge 线程自己也维护了一个read view(这个 read view 相当于系统中最老活跃事务的 read view );如果某个记录的 deleted_bit 为 true ,并且 DB_TRX_ID 相对于 purge 线程的 read view 可见,那么这条记录一定是可以被安全清除的。
对 MVCC 有帮助的实质是 update undo log ,undo log 实际上就是存在 rollback segment 中旧记录链,它的执行流程如下:
一、 比如一个有个事务插入 persion 表插入了一条新记录,记录如下,name 为 Jerry , age 为 24 岁,隐式主键是 1,事务 ID和回滚指针,我们假设为 NULL
二、 现在来了一个事务 1对该记录的 name 做出了修改,改为 Tom
- 在事务 1修改该行(记录)数据时,数据库会先对该行加排他锁
- 然后把该行数据拷贝到 undo log 中,作为旧记录,既在 undo log 中有当前行的拷贝副本
- 拷贝完毕后,修改该行name为Tom,并且修改隐藏字段的事务 ID 为当前事务 1的 ID, 我们默认从 1 开始,之后递增,回滚指针指向拷贝到 undo log 的副本记录,既表示我的上一个版本就是它
- 事务提交后,释放锁
三、 又来了个事务 2修改person 表的同一个记录,将age修改为 30 岁
- 在事务2修改该行数据时,数据库也先为该行加锁
- 然后把该行数据拷贝到 undo log 中,作为旧记录,发现该行记录已经有 undo log 了,那么最新的旧数据作为链表的表头,插在该行记录的 undo log 最前面
- 修改该行 age 为 30 岁,并且修改隐藏字段的事务 ID 为当前事务 2的 ID, 那就是 2 ,回滚指针指向刚刚拷贝到 undo log 的副本记录
- 事务提交,释放锁
从上面,我们就可以看出,不同事务或者相同事务的对同一记录的修改,会导致该记录的undo log成为一条记录版本线性表,既链表,undo log 的链首就是最新的旧记录,链尾就是最早的旧记录(当然就像之前说的该 undo log 的节点可能是会 purge 线程清除掉,向图中的第一条 insert undo log,其实在事务提交之后可能就被删除丢失了,不过这里为了演示,所以还放在这里)
Read View 读视图
什么是 Read View,说白了 Read View 就是事务进行快照读操作的时候生产的读视图 (Read View),在该事务执行的快照读的那一刻,会生成数据库系统当前的一个快照,记录并维护系统当前活跃事务的 ID (当每个事务开启时,都会被分配一个 ID , 这个 ID 是递增的,所以最新的事务,ID 值越大)
所以我们知道 Read View 主要是用来做可见性判断的, 即当我们某个事务执行快照读的时候,对该记录创建一个 Read View 读视图,把它比作条件用来判断当前事务能够看到哪个版本的数据,既可能是当前最新的数据,也有可能是该行记录的undo log里面的某个版本的数据。
在展示之前,我先简化一下 Read View,我们可以把 Read View 简单的理解成有三个全局属性
- m_ids
用于维护 Read View 生成时刻系统 正活跃的事务 ID 列表- min_trx_id
是 m_ids列表中事务 ID 最小的 ID- max_trx_id
在生成ReadView时,系统应该分配给下一个事务的事务id值- creator_trx_id
生成该ReadView的事务的事务id
只有在对表进行改动时(Insert、update、delete)才会为事务分配唯一的事务id,否则一个事务的事务id值都是0.
- 如果被访问版本的trx_id与ReadView中的creator_trx_id相同,意味着访问自己修改过的记录,所以可以访问。
- 如果被访问版本的trx_id小于ReadView中的min_trx_id,表明已经提交,可以访问。
- 如果被访问版本的trx_id大于或等于ReadView中的max_trx_id,表明该事务在生成ReadView后才开启,所以不可访问。
- 如果被访问版本的trx_id在ReadView中的min_trx_id和max_trx_id之间,则需要判断trx_id值是否在m_ids列表,若在则不可访问。不在则可。
在Mysql中, RC与RR隔离级别很大区别是生成ReadView的时机不同。RC每次读取数据前都要生成ReadView,RR只会在第一次查询时生成。
二级索引与MVCC
只有聚簇索引记录才有trx_id和roll_pointer隐藏列,如果使用二级索引查询怎么办?
SELECT name FROM hero WHERE name = "刘备";
- 1、二级索引页中,每个页的Page Header中都有一个 PAGE_MAX_TRX_ID属性——修改该二级索引页的最大事务ID
当前事务如果要访问某个二级索引页,会判断自己的事务ID是否大于PAGE_MAX_TRX_ID,如果大于,那么该二级索引页面中的记录对当前事务都可见,否则执行2- 2、取出二级索引中的主键值进行回表,得到记录后再按照前面说的readview方式去做
MVCC与purge
为了支持MVCC,delete undo log和update undo log在事务结束后不能立刻被删除掉,而是放入一个链表中
为了支持MVCC,delete操作不会删除记录,而是将记录打上一个删除标记
MySQL会通过purge线程执行purge操作,在合适的时候将update undo log和 标记为删除的记录彻底删除,那么purge如何判断undo log和记录是否可以删除?——系统最早产生的readview都不访问这些undo log和记录时,就可以删除
事务提交时,会为每个事务生成一个no值——表示事务提交的顺序(no值越小,事务提交的越早)
Undo链表的first undo page中的Undo Log Header中有 TRX_UNDO_TRX_NO属性——一个事务提交时,会将自己的no值写入操作的undo log页的 TRX_UNDO_TRX_NO,表示事务的提交顺序
事务提交后,该事务生成的不可被重用的undo链表中的Undo日志会放入History链表,History链表中的undo log是按照其对应事物的提交顺序来排序的(所以History链表中,早提交的事务产生的undo log在前面,晚提交的事务产生的undo log在后面)
ReadView生成时,会分配一个事务no属性(为当前系统中最大的事务no值+1)
系统中所有的ReadView按照时间顺序形成一个链表
purge线程会从ReadView链表中取出最旧的一个readview,然后从History链表中取出no值较小的undo log,如果undo log的no值 < ReadView的事务no属性值,那么说明该undo log无用了,可以被清除,如果该undo log中包含了delete undo log,那么将对应的标记为删除的记录真正删除
锁
事务并发访问同一数据资源的情况主要就分为读-读、写-写和读-写三种。
- 读-读 即并发事务同时访问同一行数据记录。由于两个事务都进行只读操作,不会对记录造成任何影响,因此并发读完全允许。
- 写-写 即并发事务同时修改同一行数据记录。这种情况下可能导致脏写问题,这是任何情况下都不允许发生的,因此只能通过加锁实现,也就是当一个事务需要对某行记录进行修改时,首先会先给这条记录加锁,如果加锁成功则继续执行,否则就排队等待,事务执行完成或回滚会自动释放锁。
- 读-写 即一个事务进行读取操作,另一个进行写入操作。这种情况下可能会产生脏读、不可重复读、幻读。最好的方案是读操作利用多版本并发控制(MVCC),写操作进行加锁。
锁的粒度
按锁作用的数据范围进行分类的话,锁可以分为行级锁
和表级锁
。
- 行级锁:作用在数据行上,锁的粒度比较小。
- 表级锁:作用在整张数据表上,锁的粒度比较大。
锁的分类
为了实现读-读之间不受影响,并且写-写、读-写之间能够相互阻塞,Mysql使用了读写锁的思路进行实现,具体来说就是分为了共享锁和排它锁:
- 共享锁(Shared Locks):简称S锁,在事务要读取一条记录时,需要先获取该记录的S锁。S锁可以在同一时刻被多个事务同时持有。我们可以用select … lock in share mode;的方式手工加上一把S锁。
- 排他锁(Exclusive Locks):简称X锁,在事务要改动一条记录时,需要先获取该记录的X锁。X锁在同一时刻最多只能被一个事务持有。X锁的加锁方式有两种,第一种是自动加锁,在对数据进行增删改的时候,都会默认加上一个X锁。还有一种是手工加锁,我们用一个FOR UPDATE给一行数据加上一个X锁。
还需要注意的一点是,如果一个事务已经持有了某行记录的S锁,另一个事务是无法为这行记录加上X锁的,反之亦然。
除了共享锁
(Shared Locks)和排他锁
(Exclusive Locks),Mysql还有意向锁
(Intention Locks)。意向锁是由数据库自己维护的,一般来说,当我们给一行数据加上共享锁之前,数据库会自动在这张表上面加一个意向共享锁(IS锁)
;当我们给一行数据加上排他锁之前,数据库会自动在这张表上面加一个意向排他锁
(IX锁)。意向锁可以认为是S锁和X锁在数据表上的标识,通过意向锁可以快速判断表中是否有记录被上锁,从而避免通过遍历的方式来查看表中有没有记录被上锁,提升加锁效率。例如,我们要加表级别的X锁,这时候数据表里面如果存在行级别的X锁或者S锁的,加锁就会失败,此时直接根据意向锁就能知道这张表是否有行级别的X锁或者S锁。
InnoDB中的表级锁
InnoDB中的表级锁主要包括表级别的意向共享锁(IS锁)
和意向排他锁(IX锁)
以及自增锁(AUTO-INC锁)
。其中IS锁和IX锁在前面已经介绍过了,这里不再赘述,我们接下来重点了解一下AUTO-INC锁
。
大家都知道,如果我们给某列字段加了AUTO_INCREMENT自增属性,插入的时候不需要为该字段指定值,系统会自动保证递增。系统实现这种自动给AUTO_INCREMENT修饰的列递增赋值的原理主要是两个:
- AUTO-INC锁:在执行插入语句的时先加上表级别的AUTO-INC锁,插入执行完成后立即释放锁。如果我们的插入语句在执行前无法确定具体要插入多少条记录,比如INSERT … SELECT这种插入语句,一般采用AUTO-INC锁的方式。
- 轻量级锁:在插入语句生成AUTO_INCREMENT值时先才获取这个轻量级锁,然后在AUTO_INCREMENT值生成之后就释放轻量级锁。如果我们的插入语句在执行前就可以确定具体要插入多少条记录,那么一般采用轻量级锁的方式对AUTO_INCREMENT修饰的列进行赋值。这种方式可以避免锁定表,可以提升插入性能。
InnoDB中的行级锁
前面说过,通过MVCC可以解决脏读、不可重复读、幻读这些读一致性问题,但实际上这只是解决了普通select语句的数据读取问题。事务利用MVCC进行的读取操作称之为快照读,所有普通的SELECT语句在READ COMMITTED、REPEATABLE READ隔离级别下都算是快照读。除了快照读之外,还有一种是锁定读,即在读取的时候给记录加锁,在锁定读的情况下依然要解决脏读、不可重复读、幻读的问题。由于都是在记录上加锁,这些锁都属于行级锁。
InnoDB的行锁,是通过锁住索引来实现的,如果加锁查询的时候没有使用过索引,会将整个聚簇索引都锁住,相当于锁表了。根据锁定范围的不同,行锁可以使用记录锁(Record Locks)、间隙锁(Gap Locks)和临键锁(Next-Key Locks)的方式实现。假设现在有一张表t,主键是id。我们插入了4行数据,主键值分别是 1、4、7、10。接下来我们就以聚簇索引为例,具体介绍三种形式的行锁。
- 记录锁(Record Locks) 所谓记录,就是指聚簇索引中真实存放的数据,比如上面的1、4、7、10都是记录。
- 间隙锁(Gap Locks) 间隙指的是两个记录之间逻辑上尚未填入数据的部分,比如上述的(1,4)、(4,7)等。
同理,间隙锁就是锁定某些间隙区间的。当我们使用用等值查询或者范围查询,并且没有命中任何一个record,此时就会将对应的间隙区间锁定。例如select * from t where id =3 for update;或者select * from t where id > 1 and id < 4 for update;就会将(1,4)区间锁定。 - 临键锁(Next-Key Locks) 临键指的是间隙加上它右边的记录组成的左开右闭区间。比如上述的(1,4]、(4,7]等。
临键锁就是记录锁(Record Locks)和间隙锁(Gap Locks)的结合,即除了锁住记录本身,还要再锁住索引之间的间隙。当我们使用范围查询,并且命中了部分record记录,此时锁住的就是临键区间。注意,临键锁锁住的区间会包含最后一个record的右边的临键区间。例如select * from t where id > 5 and id <= 7 for update;会锁住(4,7]、(7,+∞)。mysql默认行锁类型就是临键锁(Next-Key Locks)。当使用唯一性索引,等值查询匹配到一条记录的时候,临键锁(Next-Key Locks)会退化成记录锁;没有匹配到任何记录的时候,退化成间隙锁。
间隙锁(Gap Locks)和临键锁(Next-Key Locks)都是用来解决幻读问题的,在已提交读(READ COMMITTED)隔离级别下,间隙锁(Gap Locks)和临键锁(Next-Key Locks)都会失效!
-
插入意向锁(Insert Intention):
- 插入意向锁是在插入一行记录操作之前设置的一种间隙锁,这个锁释放了一种插入方式的信号,亦即多个事务在相同的索引间隙插入时如果不是插入间隙中相同的位置就不需要互相等待。
- 假设有索引值4、7,几个不同的事务准备插入5、6,每个锁都在获得插入行的独占锁之前用插入意向锁各自锁住了4、7之间的间隙,但是不阻塞对方因为插入行不冲突。
- 插入意向锁目的是为了提高插入性能,多个事务,在同一个索引,同一个范围区间插入记录时,如果插入的位置不冲突,不会阻塞彼此,主要是不需要去申请排他锁。
- 并且插入意向锁之间不会阻塞,因为它们的目的也是只等待这个间隙被释放,所以插入意向锁之间没有冲突。
-
隐式锁:
一般情况下执行INSERT命令不需要生成锁(当然,如果即将插入的间隙又gap锁,那么此次INSERT操作会被阻塞,并加上插入意向锁。),因为事务id相当于是隐式锁,别的事务对这条记录加S锁或者X锁时,由于隐式锁的存在,会帮助当前事务生成锁,然后自己进入等待。- 对于聚簇索引记录来说,有一个trx_id隐藏列,如果其他事务此时想要对该记录插入S锁或者X锁,会查看trx_id是否是活跃的,活跃则等待。
- 对于二级索引记录来说,本身没trx_id隐藏列,但在二级索引页面中的Page Header部分有一个PAGE_MAX_TRX_ID属性,该属性代表对该页面做改动的最大事务id。如果该属性值小于当前最小活跃事务id,则该页面事务均已提交,否则就需要回表找到对应的聚簇索引记录,然后再重复上面做法。
-
MDL锁
MDL锁属于表级别的元数据锁,MDL锁是为了保证并发环境下元数据和表数据的结构一致性。如果有其它事务对表加了MDL锁,那么其它事务就不能对表结构进行变更,同样对于正在进行表结构变更的时候也不允许其它事务对表数据进行增删改查。
什么时候会加MDL锁
MDL读锁:在我们对表数据进行增删改查的的时候都需要对表加MDL读锁。
MDL写锁:当我们对表结构进行修改的时候会加MDL写锁。