脏写

脏写就是两个事务没提交的状况下,都修改同一条数据,结果一个事务回滚了,把另外一个事务修改的值也给撤销了,所谓脏写就是两个事务没提交状态下修改同一个值。
并发场景下多个事务(线程)同时写一条数据,失败的事务把成功的回滚了。

脏读

脏读就是一个事务修改了一条数据的值,结果还没提交呢,另外一个事务就读到了你修改的值,然后你回滚了,人家事务再次读,就读不到了,也就是说人家事务读到了你修改之后还没提交的值,这就是脏读了。
并发场景下,一个事务(线程)读,一个事务写,但是写事务回滚了,即读事务读到了写事务回滚前的值

不可重复读

针对的是已经提交的事务修改的值,被事务A给读到了,事务A内多次查询,多次读到的是别的已经提交的事务修改过的值,这就导致不可重复读了。
并发场景下 一个读事务,多个写事务(成功)与幻读的前驱条件差不多,但是他是读具体的数据。幻读是读一批数据

幻读

幻读指的就是你一个事务用一样的SQL多次查询,结果每次查询都会发现查到了一些之前没看到过的数据
并发场景下,一个读事务,多个写事务(成功),导致读事务每次读的数据都不同

事务隔离级别

SQL标准中的四个隔离级别包括了:read uncommitted(读未提交),read committed读已提交),repeatable read(可重复读),serializable(串行化)

  1. RU

解决脏写,不允许脏写,也就是不允许两个事务同事更新一条数据。
但是还存在脏读、不可重复读、幻读的问题。

  1. RC

解决脏写和脏读,其他事务未提交的数据,看不到,提交了可以看到。
但是还存在不可重复读、幻读的问题。

  1. RR

解决脏写、脏读、不可重复读,RR隔离级别,只不过保证对同一行数据的多次查询,你不会读到不一样的值,人家已提交事务修改了这行数据的值,对你也没影响!每次读读到的数据都是一样的,但是还可能是幻读的。(MySQL的RR级别解决了幻读)

  1. serializable

事务串行起来一个一个排队执行,一旦串行,数据库的并发可能就只有几十了,一般不会设置。

查询MySQL事务级别

  1. 当前会话隔离级别

select @@tx_isolation;

  1. 系统当前隔离级别

select @@global.tx_isolation;
level的值可以是REPEATABLE READREAD COMMITTEDREAD UNCOMMITTEDSERIALIZABLE几种级别

  1. 设置当前会话隔离级别

set session transaction isolatin level repeatable read;

  1. 设置系统当前隔离级别

set global transaction isolation level repeatable read;
如果在服务器启动时想改变事务的默认隔离级别, 可以修改启动参数transaction-isolation的值, 比方说在启动服务器时指定了--transactionisolation=SERIALIZABLE, 那么事务的默认隔离级别就从原来的REPEATABLEREAD变成了SERIALIZABLE

MVCC机制

版本链

对于使用InnoDB存储引擎的表来说, 它的聚簇索引记录中都包含两个必要的隐藏列(row_id并不是必要的, 我们创建的表中有主键或者非NULL的
UNIQUE键时都不会包含row_id列) :
trx_id: 每次一个事务对某条聚簇索引记录进行改动时, 都会把该事务的事务**id**赋值给trx_id隐藏列。
roll_pointer: 每次对某条聚簇索引记录进行改动时, 都会把旧的版本写入到undo日志中, 然后这个隐藏列就相当于一个指针, 可以通过它来找到该记录修改前的信息。

  • 案例说明

现在有两个事务对一张表中的同一条记录做更新操作,每次对记录进行改动, 都会记录一条**undo**日志, 每条undo日志也都有一个roll_pointer属性(INSERT操作对应的undo日志没有该属性, 因为该记录并没有更早的版本) , 可以将这些undo日志都连起来, 串成一个链表, 所以现在的情况就像下图一样:

时间编号 事务编号
100 200
1 BEGIN;
2 BEGIN;
3 update hero set name="关羽" where number=1
4 update hero set name="张飞" where number=1
5 COMMIT;
6 update hero set name="赵云" where number=1
7 update hero set name="诸葛亮" where number=1
8 COMMIT;

image.png
对记录每次更新后, 都会将旧值放到一条undo日志中, 就算是该记录的一个旧版本, 随着更新次数的增多, 所有的版本都会被roll_pointer属性连接成一个链表, 这个链表称之为版本链, 版本链的头节点就是当前记录最新的值。 另外, 每个版本中还包含生成该版本时对应的事务id

ReadView

基本概念

使用READ COMMITTEDREPEATABLE READ隔离级别的事务, 都必须保证读到已经提交了的事务修改过的记录, 也就是说假如另一个事务已经修改了记录但是尚未提交, 是不能直接读取最新版本的记录的, 核心问题就是: 需要判断一下版本链中的哪个版本是当前事务可见的
ReadView中主要包含4个比较重要的内容

  1. m_ids: 表示在生成ReadView时,当前系统中活跃的读写事务的事务id列表
  2. min_trx_id: 表示在生成ReadView时,当前系统中活跃的读写事务中最小的事务id, 也就是m_ids中的最小值。
  3. max_trx_id: 表示生成ReadView时,当前系统中应该分配给下一个事务的id值
    注意max_trx_id并不是m_ids中的最大值, 事务id是递增分配的。 比方说现在有id为1, 2, 3这三个事务, 之后id为3的事务提交了。 那么一个新的读事务在生成ReadView时, m_ids就包括1和2, min_trx_id的值就是1, max_trx_id的值就是4。
  4. creator_trx_id表示生成该ReadView的事务的事务id

    访问过程

    在访问某条记录时, 按照下边的步骤判断记录的某个版本是否可见:

  5. 如果被访问版本的trx_id属性值等于ReadView中的creator_trx_id值, 意味着当前事务在访问它自己修改过的记录(修改者就是当前事务), 所以该版本可以被当前事务访问。

  6. 如果被访问版本的trx_id属性值小于ReadView中的min_trx_id值, 表明生成该版本的事务在当前事务生成ReadView已经提交, 所以该版本可以被当前事务访问。
  7. 如果被访问版本的trx_id属性值大于ReadView中的max_trx_id值, 表明生成该版本的事务在当前事务生成ReadView后才开启(新开的事务), 所以该版本不可以被当前事务访问。
  8. 如果被访问版本的trx_id属性值在ReadViewmin_trx_idmax_trx_id之间,那就需要判断一下trx_id属性值是不是在m_ids列表中,
    1. 如果在, 说明创建ReadView时生成该版本的事务还是活跃的, 该版本不可以被访问;
    2. 如果不在, 说明创建ReadView时生成该版本的事务已经被提交, 该版本可以被访问

如果某个版本的数据对当前事务不可见的话, 那就顺着版本链找到下一个版本的数据, 继续按照上边的步骤判断可见性, 依此类推, 直到版本链中的最后一个版本。 如果最后一个版本也不可见的话, 那么就意味着该条记录对该事务完全不可见, 查询结果就不包含该记录

READ COMMITTED 机制

每次读取数据前都生成一个ReadView,RC级别的隔离,只要数据被提交了,那就可以被访问,所以会发生不可重复读和幻读。

  • 如何避免脏读

假设,数据有一条记录,是事务id为50 的事务插入的,并且现在活跃两个事务trx_id分别为60,70,此时该记录的版本信息如图所示
image.png
现在事务B,发起UPDATE操作,更新了数据,同事修改trx_id=70,但还未提交事务,该记录的版本信息发生改变
image.png
若此时事务A,发起查询操作,则会创建以个ReadView,该ReadView里的信息为
m_ids=[60,70]
min_trx_id=60
max_trx_id=71
creator_trx_id=60
image.png
这个时候事务A发起查询,发现当前这条数据的trx_id是70。这个事务idReadViewm_ids范围内,说明在生成ReadView之前这个事务(trx_id=70)就是活跃的,是这个事务修改了这条数据的值,而且此时这个事务B还没提交,所以此时根据ReadView的机制,此时事务A是无法查到事务B修改的值B的。
接着就顺着undo log版本链条往下查找,就会找到一个原始值,发现他的trx_id是50,小于当前ReadView里的min_trx_id,说明是他生成ReadView之前,就有一个事务提交了,因此可以查到这个原始值,如下图。
image.png
注意:

  1. 如果事务B在A发起查询之前就提交了,那么m_ids列表中就不会有事务B的trx_id
  2. 如果事务A在第二次发起查询时,会重新生成一个ReadView
    1. 如果此时事务B已经提交了,那么m_ids中同样不会有B的trx_id,那么事务A就可以查询B修改后的值(不可重复读)
    2. 如果没提交,则还在m_ids列表中
  3. 如果事务B回滚了,那么事务A查询的值和B没有直接关系,避免了脏读

    REPEATABLE READ 机制

    在第一次读取数据时生成一个ReadView,之后的查询就不会重复生成了
  • 避免可不可重复度

假设,数据有一条记录,是事务id为50 的事务插入的,并且现在活跃两个事务trx_id分别为60,70,此时该记录的版本信息如图所示
image.png
现在事务A,发起查询操作,第一次查询就会生成一个ReadView,该ReadView里的信息为
m_ids=[60,70]
min_trx_id=60
max_trx_id=71
creator_trx_id=60
这个时候事务A基于这个ReadView去查这条数据,会发现这条数据的trx_id为50,是小于ReadView里的min_trx_id的,说明他发起查询之前,早就有事务插入这条数据还提交了,所以此时可以查到这条原始值的。
image.png
事务B此时更新了这条数据的值为值B,此时会修改trx_id为70,同时生成一个undo log,而且关键是事务B此时他还提交了,也就是说此时事务B已经结束了,如下图所示。
image.png
因为RR级别只会在第一次的时候生成一个ReadView,所以即使事务B已经提交,但是事务A的ReadView还是使用之前的,即事务A的ReadViewm_ids仍然包含事务B的trx_id=70(在事务A开启查询的时候,事务B当时是在运行的)
接着此时事务A去查询这条数据的值,他会惊讶的发现此时数据的trx_id是70了,而70是在ReadViewmin_trx_idmax_trx_id的范围内的,同时还在m_ids列表中,说明起事务A开启查询的时候,id为70的这个事务B还是在运行的,然后由这个事务B更新了这条数据,所以此时事务A是不能查询到事务B更新的这个值的,因此这个时候继续顺着指针往历史版本链条上去找
然后事务A顺着指针找到下面一条数据,trx_id50,是小于ReadViewmin_trx_id的,说明在他开启查询之前,就已经提交了这个事务了,所以事务A是可以查询到这个值的,此时事务A查到的是原始值,如下图
image.png

  • 如何避免幻读

避免幻读与上述流程一样,事务A在发起查询时会生成一个ReadView,记录此时系统中活跃事务的状态

  1. 如果在事务A查询过程中,有后加入进来的事务增加了记录(即执行了INSERT操作)并且提交了事务,那么事务A再次查询时,发现新增记录的trx_id大于当前ReadViewmax_trx_id,则不会读取该记录,从而避免幻读
  2. 如果在事务A查询过程中,有后加入的事务,修改来了记录值,并且提交了,同样,事务A的ReadViewmax_trx_id小于该记录的trx_id,也不会读取该记录的最新值,而是顺着版本链找一个合适的版本。

    总结

    所谓的MVCC(Multi-Version Concurrency Control, 多版本并发控制) 指的就是在使用READ COMMITTDREPEATABLE READ这两种隔离级别的
    事务在执行普通的SEELCT操作时访问记录的版本链的过程, 这样子可以使不同事务的读-写、 写-读操作并发执行, 从而提升系统性能。 READ COMMITTD、 REPEATABLE READ这两个隔离级别的一个很大不同就是: 生成ReadView的时机不同, READ COMMITTD在每一次进行普通SELECT操作前都会生成一个ReadView, 而REPEATABLE READ只在第一次进行普通SELECT操作前生成一个ReadView, 之后的查询操作都重复使用这个ReadView就好了

    所谓的锁其实是一个内存中的结构, 在事务执行前本来是没有锁的, 也就是说一开始是没有锁结构和记录进行关联的, 当一个事务想对这条记录做改动时, 首先会看看内存中有没有与这条记录关联的锁结构, 当没有的时候就会在内存中生成一个锁结构与之关联。 比方说事务T1要对这条记录做改动, 就需要生成一个锁结构与之关联 ,在锁结构里有很多信息, 但是值关注下面两个属性
    trx: 代表这个锁结构是哪个事务生成的。
    is_waiting: 代表当前事务是否在等待。
    如图所示, 当事务T1改动了这条记录后, 就生成了一个锁结构与该记录关联, 因为之前没有别的事务为这条记录加锁, 所以is_waiting属性就是false, 这个场景就称之为获取锁成功, 或者加锁成功, 然后就可以继续执行操作了。
    image.png
    在事务T1提交之前, 另一个事务T2也想对该记录做改动, 那么先去看看有没有锁结构与这条记录关联, 发现有一个锁结构与之关联后, 然后也生成了一个锁结构与这条记录关联, 不过锁结构的is_waiting属性值为true, 表示当前事务需要等待, 我们把这个场景就称之为获取锁失败, 或者加锁失败, 或者没有成功的获取到锁,
    image.png
    在事务T1提交之后, 就会把该事务生成的锁结构释放掉, 然后看看还有没有别的事务在等待获取锁, 发现了事务T2还在等待获取锁, 所以把事务T2对应的锁结构的is_waiting属性设置为false, 然后把该事务对应的线程唤醒, 让它继续执行, 此时事务T2就算获取到锁了。
    image.png
    总结一下

  3. 不加锁
    意思就是不需要在内存中生成对应的锁结构, 可以直接执行操作。

  4. 获取锁成功, 或者加锁成功
    意思就是在内存中生成了对应的锁结构, 而且锁结构的is_waiting属性为false, 也就是事务可以继续执行操作。
  5. 获取锁失败, 或者加锁失败, 或者没有获取到锁
    意思就是在内存中生成了对应的锁结构, 不过锁结构的is_waiting属性为true, 也就是事务需要等待, 不可以继续执行操作。

    共享锁和独占锁

  6. 共享锁

英文名: Shared Locks, 简称S锁。 在事务要读取一条记录时, 需要先获取该记录的S锁。

  1. 独占锁

也常称排他锁, 英文名: Exclusive Locks, 简称X锁。 在事务要改动一条记录时, 需要先获取该记录的X锁。
假如事务T1首先获取了一条记录的S锁之后, 事务T2接着也要访问这条记录:

  1. 如果事务T2想要再获取一个记录的S锁, 那么事务T2也会获得该锁, 也就意味着事务T1T2在该记录上同时持有S锁。
  2. 如果事务T2想要再获取一个记录的X锁, 那么此操作会被阻塞, 直到事务T1提交之后将S锁释放掉。
  3. 如果事务T1首先获取了一条记录的X锁之后, 那么不管事务T2接着想获取该记录的S锁还是X锁都会被阻塞, 直到事务T1提交。

所以S锁和S锁是兼容的, S锁和X锁是不兼容的, X锁和X锁也是不兼容的,

兼容性 S X
S 兼容 不兼容
X 不兼容 不兼容

锁定读的语句

  1. 对读取的记录加S锁:

    1. SELECT ... LOCK IN SHARE MODE;

    也就是在普通的SELECT语句后边加LOCK IN SHARE MODE, 如果当前事务执行了该语句, 那么它会为读取到的记录加S锁, 这样允许别的事务继续获取这些记录的S锁 , 但是不能获取这些记录的X锁 。 如果别的事务想要获取这些记录的X锁, 那么它们会阻塞, 直到当前事务提交之后将这些记录上的S锁释放掉。

  2. 对读取的记录加X锁:

    1. SELECT ... FOR UPDATE;

    也就是在普通的SELECT语句后边加FOR UPDATE, 如果当前事务执行了该语句, 那么它会为读取到的记录加X锁, 这样既不允许别的事务获取这些记录的S锁 , 也不允许获取这些记录的X锁。 如果别的事务想要获取这些记录的S锁或者X锁, 那么它们会阻塞, 直到当前事务提交之后将这些记录上的X锁释放掉。

    写操作

    平常所用到的写操作无非是DELETE、 UPDATE、 INSERT这三种:

  • DELETE:
    对一条记录做DELETE操作的过程其实是先在B+树中定位到这条记录的位置, 然后获取一下这条记录的X锁, 然后再执行delete mark操作。 也可以把这个定位待删除记录在B+树中位置的过程看成是一个获取X锁的锁定读。
  • UPDATE:
    在对一条记录做UPDATE操作时分为三种情况:
    1. 如果未修改该记录的主键值并且被更新的列占用的存储空间在修改前后未发生变化(更新字段的长度未改变), 则先在B+树中定位到这条记录的位置, 然后再获取一下记录的X锁, 最后在原记录的位置进行修改操作。 其实也可以把这个定位待修改记录在B+树中位置的过程看成是一个获取X锁的锁定读。
    2. 如果未修改该记录的主键值并且至少有一个被更新的列占用的存储空间在修改前后发生变化(更新字段的长度至少有一个改变), 则先在B+树中定位到这条记录的位置, 然后获取一下记录的X锁, 将该记录彻底删除掉(就是把记录彻底移入垃圾链表) , 最后再插入一条新记录。 这个定位待修改记录在B+树中位置的过程看成是一个获取X锁的锁定读, 新插入的记录由INSERT操作提供的隐式锁进行保护。
    3. 如果修改了该记录的主键值, 则相当于在原记录上做DELETE操作之后再来一次INSERT操作, 加锁操作就需要按照DELETE和INSERT的规则进行了。
  • INSERT:
    一般情况下, 新插入一条记录的操作并不加锁

    表级锁

    给表加的锁也可以分为共享锁(S锁) 和独占锁(X锁) :
  1. 给表加S锁:
    如果一个事务给表加了S锁, 那么:
    1. 别的事务可以继续获得该表的S锁
    2. 别的事务可以继续获得该表中的某些记录的S锁
    3. 别的事务不可以继续获得该表的X锁
    4. 别的事务不可以继续获得该表中的某些记录的X锁
  2. 给表加X锁:
    如果一个事务给表加了X锁(意味着该事务要独占这个表) , 那么:

    1. 别的事务不可以继续获得该表的S锁
    2. 别的事务不可以继续获得该表中的某些记录的S锁
    3. 别的事务不可以继续获得该表的X锁
    4. 别的事务不可以继续获得该表中的某些记录的X锁

      问题

  3. 如果需要对表整体上S锁, 首先需要确保表中所有记录的没有被加上X锁, 如果有, 需要等到事务结束释放X锁才可以对表整体上S锁。

  4. 如果想对表整体上X锁, 首先需要确保表中所有的记录没有S锁和X锁, 如果有, 需要等到全部事务结束后释放S,X锁才可以对表整体上X锁。

    意向锁

    意向共享锁

    英文名: Intention Shared Lock, 简称IS锁。 当事务准备在某条记录上加S锁时, 需要先在表级别加一个IS锁

    意向独占锁

    英文名: Intention Exclusive Lock, 简称IX锁。 当事务准备在某条记录上加X锁时, 需要先在表级别加一个IX锁。

    总结

    IS、 IX锁是表级锁, 它们的提出仅仅为了在之后加表级别的S锁和X锁时可以快速判断表中的记录是否被上锁, 以避免用遍历的方式来查看表中有没有上锁的记录, 也就是说其实IS锁和IX锁是兼容的, IX锁和IX锁是兼容的。
兼容性 X IX S IS
X 不兼容 不兼容 不兼容 不兼容
IX 不兼容 兼容 不兼容 兼容
S 不兼容 不兼容 兼容 兼容
IS 不兼容 兼容 兼容 兼容

InnoDB三种行锁

  1. Record Lock:单个行记录上的锁。
  2. Gap Lock:间隙锁,锁定一个范围,但不包括记录本身。GAP锁的目的,是为了防止同一事务的两次当前读,出现幻读的情况。
  • 案例

image.png
图中为number值为8的记录加了gap锁, 意味着不允许别的事务在number值为8的记录前边的间隙插入新记录, 其实就是number列的值(3, 8)这个区间的新记录是不允许立即插入的。 比方说有另外一个事务再想插入一条number值为4的新记录, 它定位到该条新记录的下一条记录的number值为8, 而这条记录上又有一个gap锁, 所以就会阻塞插入操作, 直到拥有这个gap锁的事务提交了之
后, number列的值在区间(3, 8)中的新记录才可以被插入。
这个gap锁的提出仅仅是为了防止插入幻影记录而提出的, 再强调一遍, gap锁的作用仅仅是为了防止插入幻影记录的而已
为了实现阻止其他事务插入number值在(20, +∞)这个区间的新记录, 我们可以给索引中的最后一条记录, 也就是number值为20的那条记录所在页面的Supremum记录加上一个gap锁, 画个图就是这
样:
image.png

  1. Next-Key Lock:1+2,锁定一个范围,并且锁定记录本身。对于行的查询,都是采用该方法,主要目的是解决幻读的问题。

next-key锁的本质就是一个正经记录锁和一个gap锁的合体, 它既能保护该条记录, 又能阻止别的事务将新记录插入被保护记录前边的间隙。

  • 案例

image.png
InnoDB对于行的查询都是采用了Next-Key Lock的算法,锁定的不是单个值,而是一个范围(GAP)。上面索引值有1,3,5,8,15,20其记录的GAP的区间如下:是一个左开右闭的空间(原因是默认主键的有序自增的特性,结合后面的例子说明)
(-∞,1],(1,3],(3,5],(5,8],(8,15],(10,+∞)
特别需要注意的是,InnoDB存储引擎还会对辅助索引下一个键值加上gap lock,该SQL语句锁定的范围是(5,8],下个键值范围是(8,11],所以插入5~11之间的值的时候都会被锁定,要求等待。即:插入5,6,7,8,9,10 会被锁住。插入非这个范围内的值都正常。