文章

Innodb - 有关事务的一切

终于开始聊事务了。

  1. 所谓事务
    1. 原子性Atomicity:单个事务要做的
    2. 持久性Durability:单个事务要做的
    3. 隔离性Isolation:事务之间不能相互影响
    4. 一致性Consistency:最终想达到的状态
    5. 事务语法
    6. autocomit
  2. redo log:为了提升性能的同时保证一致性
    1. 又一个pool:redo log刷盘时机
      1. redo log影响多大性能
    2. redo log结构
    3. MTR: redo log组
    4. checkpoint:开始恢复的点
      1. checkpoint时机
    5. 快速恢复
  3. undo log:为了原子性
    1. undo log格式
      1. insert’s undo log
      2. delete’s undo log
      3. update’s undo log
    2. undo log page
    3. 回滚
  4. 事务隔离级别
    1. 隔离级别
      1. 脏写 Dirty Write
      2. 脏读 Dirty Read
      3. 不可重复读 Non-Repeatable Read
      4. 幻读 Phantom
  5. MVCC
    1. READ UNCOMMITTED:不解决脏读
    2. READ COMMITTED:解决脏读,不解决可重复读
    3. READ REPEATABLE:解决不可重复读,不解决幻读
    4. SERIALIZABLE:解决幻读
    5. MVCC的本质:爱读哪个是你的自由
    6. MVCC不能管控写写
    1. 写写
    2. 读写
      1. MVCC
      2. 共享锁、独占锁(排它锁)
      3. 多粒度锁
      4. 加锁解决REPEATABLE READ?
    3. 死锁
  6. 感想

所谓事务

刚学数据库的时候,就知道事务。然而什么是事务,只能模模糊糊说出两个概念:要么不做,要么全做;ACID。说懂不很懂,说不懂又差不多懂。直到最近看到一句话:数据库的操作变更和现实世界的实际情况并不总是一致的,我们只是想办法让数据库的操作符合现实世界的状态转换

恍然大悟!

数据库已经做了一些和现实世界一致的约束,not null、unique key,甚至还有一些check语法,比如年龄恒大于0。然而,还是有一些操作会出现和现实的不一致:

  • 现实中A向B转账,A减少的同时B应该增加。现实中,这应该是个“同时发生”的操作
  • 实际上数据库里的A向B转账,A减了,再去B里增加,可能就出意外宕机了,导致A减了B也没加,就和现实不一致了。归根结底,程序在操作的时候,就算间隔再短,也还是个“分步操作”,并不能做到现实中的“同时”

所以,数据库引入了事务的概念:虽然实际操作上不能做到和现实一模一样,但是从逻辑上,数据库要模拟出现实世界的行为

比如上面的转账,数据库可能呈现三个状态:

  1. 转账前:A和B的余额都没动;
  2. 转账中:A减了,B还没加;
  3. 转账后:A减了,B加了;

现实世界只有第一种和第三种状态,不存在第二种状态,数据库就想办法不让自己停留在第二种状态

什么时候会停留在第二种状态?A扣减之后,崩了,B没加上,就停在第二种状态了。重启后,数据库会通过undo日志撤销这一状态,强行让自己回到第一种状态,从而和现实的逻辑保持一致。

为了让自己符合现实世界的状态,数据库想了想,需要保证这几条:ACID。

原子性Atomicity:单个事务要做的

这个大家都会:要么不做,要么全做。

为什么?因为现实世界有些事情是“同时”发生的,比如上面的转账。而数据库不是同时发生的,分了好几步,所以为了让自己模拟出同时的效果,就“要么不做,要么全做”。

这就是原子性。

为了达到不让数据库“停留在中间状态”的效果,数据库使用了undo日志,让只做到一半的事务回滚到开始的状态。

持久性Durability:单个事务要做的

数据写磁盘实际是先写到buffer pool,后续再写到磁盘。写了buffer pool如果崩了,等于没写。也就是说事务提交后产生的数据没有真正保存下来。

为了让数据一定保存下来,MySQL使用了redo日志:

  1. 事物的修改操作,先写redo日志;
  2. 再把数据写入buffer pool;
  3. 最后提交事务;

数据库使用redo日志让崩溃的数据库恢复到崩溃前的状态。如果崩溃前“做了”(提交了事务,只是还没有刷到磁盘),重启后也能恢复到“做了”的效果。

隔离性Isolation:事务之间不能相互影响

从A账户扣钱,分别转给B账户2元、C账户3元,现实世界里,无论这两次转账是分别进行,还是“同时”进行,都得保证A的账户被扣了5元。但是在程序里,两个线程并发操作的情况下,可能会出现:

  1. 线程1读A余额为10;
  2. 线程2读A余额为10;
  3. 线程1扣2元;
  4. 线程2扣3元;
  5. 线程1写回8元;
  6. 线程2写回7元;

最终A的余额为7元,只扣了3元,而不是5元。这是不符合现实世界的逻辑的。

所以事务有另一个要求:其他的事务不能影响到本事务的状态,就好像隔离的一样。

隔离性怎么做到?串行,一定可以做到。但为了效率,一般都不使用串行

  • 写事务和写事务隔离:加锁;
  • 写事务和读事务隔离:MVCC;

一致性Consistency:最终想达到的状态

一致,指的是和现实世界的一致。这是程序最终想达到的状态。

如果没有原子性,一致性肯定不可能达成;同理如果没有隔离性、持久性,一致性也不可能达成。但是即使A、I、D都满足了,一致性也未必达成,还需要数据库保证(not null、unique),程序员保证(余额扣减之后,要给别的账户加上)。

总之,一致性是让程序的表现和现实一致,是程序最终想要达到的效果。

它和数据库本身的事务实现好像并没有什么关系……

事务语法

开启事务:

1
BEGIN;

或者:

1
START TRANSACTION;

提交事务:

1
COMMIT;

回滚事务:

1
ROLLBACK;

但是回滚事务有点儿伤,类似于从头再来。如果能像打游戏一样不停保存,每次挂了就可以从上次存档的地方恢复了。savepoint就是存档的点:

1
SAVEPOINT <name>;

回滚到上一个存档点:

1
RELLBACK TO SAVEPOINT <name>;

游戏的存档点。存档后可劲儿造啊。

autocomit

默认情况下,如果不使用begin显式开启事务,所有的语句都是一个独立的事务。也就是说会自动提交。可以把这个变量改为false,就需要显式commit了。

redo log:为了提升性能的同时保证一致性

Innodb - Buffer Pool介绍了innodb使用buffer pool作为缓存来加快记录的读写。

buffer pool的页在修改之后成为脏页,为什么不直接刷到磁盘?

  • 每个页16KB,一次就要写16KB数据。一个page即使改动一点儿也要刷16KB,太浪费;
  • 每次刷的page不定,所以是随机IO

因此innodb 使用redo log,记录事务对数据页进行了哪些修改,然后把redo log记到磁盘上。即使buffer pool的脏页还没有持久化到磁盘上数据库就发生了崩溃,也可以根据redo log,把这些要进行的修改replay一遍,数据库又会恢复到崩溃前的状态。

为什么把redo log写到磁盘就高效?

  • redo log是一条一条顺序写的,所以是 顺序IO
  • 单条redo log占用空间小,肯定比一个page小,所以 写的少

因此redo log写得又少又快,比直接写脏页高效。

可以比较粗暴地理解为:写redo log到磁盘,就相当于“写脏页到磁盘”了。

说一千道一万,redo log的存在,本质上是因为buffer pool不愿意在更新后刷盘,如果更新后立刻刷新到磁盘上,redo log就没有存在的意义了。所以说,redo log的存在是为了提升性能,如果不考虑性能,它并不是功能上必须需要的东西

当然,如果直接写磁盘,不使用buffer pool,也不会有持久性什么事儿了。如果直接写磁盘,innodb可以保证数据一定是持久的。

又一个pool:redo log刷盘时机

事实上redo log也有类似于buffer pool的log pool,redo log也不是直接往磁盘写,而是先往log pool写,再周期性写到磁盘上。

额,又一个pool???引入redo log就是因为有个buffer pool。现在同样的问题——redo log还没从log pool写到磁盘上数据库就崩溃了怎么办?再来个redo redo log?

不需要。redo log还有一个很重要的刷盘时机是事务提交时。也就是说,事务提交完成前,redo log一定会先刷盘完成。如果redo log没刷盘就崩了,说明事务没完成就崩了。一个没完成的事务,有没有写到磁盘上都不重要

  • 如果没刷脏:相当于事务没做,符合事务的特性;
  • 如果刷脏了:相当于记录了一个半成品的事务,结果还是要被回滚掉,回到没做前的状态,所以刷盘也和没刷盘一样

所以log pool和buffer pool不一样:

  • buffer pool在事务完成后未必刷盘,所以需要redo log先刷盘,时刻准备在崩溃后恢复未刷盘的数据;
  • log pool在事务完成后一定已经刷盘了,此时就算崩溃,也无所谓了;
  • 如果事务在完成前就崩了,那刷不刷盘都不重要:如前所述。

所以redo log不需要再搞个redo redo log。不然岂不无限套娃,没完了……

redo log影响多大性能

虽然不直接刷buffer pool的脏页到磁盘而是选择写入顺序的redo log里,已经极大提升了性能。但实际上,redo log刷盘时机也可以优化。redo log默认是一个事务结束时,redo log才会刷盘,对于语句比较多的事物,有多大影响?

参考这个,如果事务里有1000条update,组成一个事务,时间能从4s降到0.2s,还是很多的。

redo log结构

redo log为什么很小?因为需要记录的东西不多:

  • type:redo log类型;
  • space id:表空间id;
  • page number:页号;
  • data:redo日志的内容;

比如有种极其简单的redo log类型,data部分是“offset、数据”,代表“在哪个表的哪个page的哪个offset写入xxx”。所以内容很小。

其他类型的redo log,比如范围修改,删除某区间内的记录,如果每条修改都对应一条redo log,太占空间了,所以有MLOG_COMP_LIST_START_DELETE/MLOG_COMP_LIST_END_DELETE类型的redo log,一个start一个end就确定了所有的范围。

redo log类型很多,具体有哪些类型,不用操心。

MTR: redo log组

数据库内的某些操作,本身必须得是原子性的,比如向B+树插入一条记录,可能涉及多条修改:页分裂、数据页插入了记录、给新的页在非叶子节点里创建一条目录项。不能说只做前两步,不做最后一步,这样的B+树是不完整的。

这种对底层页面的原子访问,就是MTR(mini transaction)。

一次插入操作实际对应好几处修改,每个修改的地方都需要redo log。为了保证MTR,这一组redo log要么都生效,要么都不生效,不能说这一组redo log“只记录了一半”,系统恢复的时候也只“恢复一半”

实际实现方式,是给redo log组的最后添加一条独立的MLOG_MULTI_REC_END类型的redo log,代表一组结束了。如果重启之后发现redo log最后没有一条这样的结束log,就放弃这一组。

而那些本身就只有一条的redo log,有标志表明他们本身就是单条的,就不需要再加一条额外的end redo log了。

checkpoint:开始恢复的点

如果数据库重启,从哪里恢复redo log?从数据库刚建立时候的空白页吗?不现实。有两个原因:

  1. 从头恢复很慢;
  2. redo log没那么大的地方,记录不了那么多redo log

redo log是循环使用的,也就是说redo log的空间写满之后,从开头接着写,覆盖掉一开始的redo log。如果覆盖了,redo log就丢了。所以 在redo log写“追尾”之前,必须得把要覆盖的那些redo log对应的脏页刷盘。如果脏页刷盘了,对应的redo log就没用了,可以被覆盖了

脏页刷盘的行为就叫checkpoint,也就是说,下次如果要恢复,从这个点开始恢复就行了,没必要从头恢复(况且头也没了……已经被覆盖了……)。checkpoint就是数据库开始恢复的起点

每个redo log对应一个编号,叫lsn(log sequence number)。刷脏之后,会产生一个checkpoint_lsn,它就是恢复的起点。

checkpoint不是savepoint。

redo log的终点在哪儿?最后一个没写满的redo log的page。

我咋感觉终点就是lsn对应的那条redo log呢?

checkpoint时机

  • sharp checkpoint:完全检查点。产生于正常关机时,所有脏页完全刷脏,所以redo log都没用了;
  • fuzzy checkpoint:部分页刷脏
    • 周期性刷脏;
    • buffer pool快满了;
    • redo log快满了;

checkpoint更多参考:

  • https://www.cnblogs.com/geaozhang/p/7341333.html

快速恢复

确定了起点终点,redo log就可以一条一条恢复数据了。但有innodb还是嫌慢,所以使用hash,把同一个表的同一个page的所有redo log放在一起执行,然后这个页面刷脏一次就行了。等等等等,反正做了很多优化。

这不是我数据同步的时候合并binlog的思路嘛 :D

undo log:为了原子性

undo log是为了撤销已有操作,撤销就是在悔棋。悔棋的前提是记得上一步、甚至上几步的状况。如果忘了三步之前是什么样子的,想悔三步棋也悔不了。所以undo log需要记录每一步做了什么操作,从而能够在想要反悔的时候顺利恢复到之前的情况

和悔棋不同的是,undo log不是什么情况都能悔棋,只能在事务操作失败后,回滚只做了一半的事务操作,是为了保证原子性。

undo,一定是对do的撤销。select这种只读语句对数据没有影响,自然不需要撤销。事务提交之后,也没法后悔了,只能在提交前撤销已经做了的操作。所以 undo log撤销的是含有增删改的事务的某些操作步骤

innodb只对拥有增删改操作的事务分配事务id,只读事务没有事务id。那些不包含增删改操作的读写事务也没有事务id。

事务id是全局的。innodb不想每次更新事务id都持久化到磁盘上,又怕崩了之后事务id丢失,所以每增加256时持久化一次,减少持久化次数下次重启的时候,把持久化的事务id加上256,崩溃时候的事务id肯定小于这个数,从而做到事务id的唯一性。innodb经常用这个套路,比如隐藏的自增主键值也是这么搞的。

很鸡贼,可以学学。

undo log格式

undo log记录的是增删改中的一种:

  • insert:插了一条记录,把它删掉就行了;
  • delete:删了一条记录,把它加回来就行了;
  • update:改了一条记录,把它改回去就行了;

所以undo log的记录有以下几个通用field:

  • type:上述三种之一;
  • undo no:undo日志的id,不是全局的,每个事务中都从0开始
  • table id:表id;
  • 主键列表:每一个构成主键的列的<存储空间大小,真实值>构成的列表。有了主键的值,就能定位这条数据了

insert’s undo log

撤销insert,就是把它删掉。上面这么多字段就够了。因为想删掉这条记录,知道它的主键就够了。

类型为TRX_UNDO_INSERT_REC

delete’s undo log

撤销delete,就是把删掉的记录再加回来。似乎在删除的时候需要把这条记录所有的列的值都记录下来,这样在恢复时才能把旧值都找回来。

如果真这么做,那在delete操作比较多的情况下,undo log岂不是相当于变相copy了一遍数据库?innodb才不会这么低效!每次delete,innodb都只是标记删除,是一种逻辑删除,并没有在物理层面把记录真的删掉

所以Innodb - 行里说到,每一行都有一个delete flag。

但是,标记为已删除的这一行并没有被加入这一页的垃圾链表。因为加入垃圾链表之后,新插入的列就有可能覆盖这个位置,导致旧行被抹掉。只要不加入垃圾链表,这一行就处于一种被删了,但又不会被抹掉的状态

事务:只有我知道你被删了,别的事务都不知道。

什么时候加入垃圾链表呢?当事务被提交之后。因为提交后的事务就不能反悔了,删了就是删了,被别人覆盖了抹掉了都无所谓,已经不需要再找回来了

事务提交之后undo log还存在吗?见后文。

delete undo log在通用field的基础上,还加了几个field:

  • trx_id:本次修改这条记录的事务的id
  • roll_pointer

这两个就是Innodb - 行里说到的行的两个隐藏列。把一个列标记删除之前,它的roll_pointer的值记录为undo log的地址(其实就是这条记录的指针指向这条undo log),同时这条undo log的指针指向前一条修改这个列的undo log。这么一来,所有对这条记录的增删改操作的undo log,倒着连成了一条串,叫做 版本链

1
记录 -> 倒数第一个undo log -> 倒数第二个undo log -> ... -> 第一个undo log(也就是insert undo log)

版本链记录着这条记录的各个版本的内容(其实是可以根据这些undo log恢复到各个版本的内容,所以相当于记录了各个版本的内容)。

类型为TRX_UNDO_DEL_MARK_REC

update’s undo log

撤销update,就是把改掉的值再改回来。

update分为两种情况:

  • 没有修改主键:需要记录下这条记录在修改前的值
  • 改了主键,其实就相当于先delete一条记录,再insert一条记录:需要先把insert的删了,再把delete的插回来。

没修改主键的情况下,update undo log 要保留被修改列的旧值,用于恢复

  • n_updated:包含各个被修改的列的<位置,旧值长度,旧值内容>
  • trx_id;
  • roll_pointer:这两列肯定也是需要的,和delete undo log一样,用于构建版本链;

类型为TRX_UNDO_UPD_EXIST_REC

修改主键的情况:

  1. 先把记录标记删除,但不加入垃圾链表。产生一个delete undo log;
  2. 再插入一条数据。产生一个insert undo log;

类型为TRX_UNDO_DEL_MARK_RECTRX_UNDO_INSERT_REC

undo log page

表空间有许多种page组成。index page放的是数据,undo log page是用来放undo log的。

undo log存储的时候分为两大类:insert undo log、update undo log(包括update undo log和delete undo log),两类log存的时候隔离开,因为insert undo log在事务结束后就可以删掉了,其他类型的undo log还要留着,MVCC会用到

很显然,只要是page,innodb都会把它们组成page链表。undo log page也是。

不同事务的undo log分别写入不同的undo log链表

回滚

一个undo log链表只保存一个事务的undo log,所以每个undo log链表都有标志,代表这个事务是否提交了。如果重启后,事务还处于活跃状态,说明崩的时候它还没有结束,这就是个半成品事务,需要回滚。按照undo log的记录全部回滚就行了。

如果说的复杂点,把redo log也掺和进来,就是:

  1. redo log把数据恢复到崩溃前的状态(把本来应该写盘但是没写盘的脏页写盘);
  2. 然而崩溃前有的事务可能只部分执行(redo log把这些部分执行的也写盘了);
  3. 所以undo log把未完成的事务回滚到事务进行前的状态;

如果把buffer pool理解为一个“有脏页就写盘”的东西,那redo log的作用就可以忽略了,只需要理解undo log的作用就行了,理解起来更简单。

参考:

  • https://www.zhihu.com/question/368847138/answer/996629614

事务隔离级别

一条数据是可能在多个事务中并发访问的。隔离性就是在说,并发的事物之间不能相互干扰。

怎么保证并发事务访问不会让数据出错?这一点了解Java并发编程应该就能给出很好的回答:加锁!

  1. 串行执行可以看做加了一个非常粗粒度的锁,导致一个线程执行的整个过程中另一个线程只能等待,直到这个线程执行完了,另一个才能执行;
  2. 尽可能细粒度地加锁,能够最大限度地提高并发度:只在必须互斥访问的时候才互斥访问,尽量不让一个线程阻塞另一个线程;

加锁就能解决并发问题。只不过锁粒度加的不一样,性能不一样罢了:

  1. 最粗暴的情况,加个互斥锁比如synchronized、ReentrantLock;
  2. 优化一点儿使用读写锁:ReentrantReadWriteLock;
  3. 或者根据实际情况使用乐观锁、CAS;

无论怎样,问题都是能解决的,正确性都是能得到保障的。

但数据库的想法更骚气——有没有可能用户会接受这么一种观念:在某些情况下,为了让并发访问的性能更高一些,即使没法完全保证正确性也是可以的?即:牺牲部分隔离性(不做到并发事务之间的完全隔离),来换取性能的提升。

可以想象为地铁安检:

  1. 100%安全的安检需要对每个人非常仔细的检查,虽然绝对安全,但为此给大家带来非常大的不便;
  2. 如果不检查那么严格,通行效率就高了,但是这就意味着有时候回不小心漏过去一些小危险品,比如水果刀,使得安全不能得到100%的保证。但也不是不能接受;
  3. 完全不检查,或者几乎没检查,通行毫无障碍,效率非常高,但是带着管制刀具也不会被查出来,安全度极大下降;

虽然人们几乎都不接受最后一种情况,但是不见得人人都会喜欢第一种情况。相反,如果第二种情况在通行效率和安全之间的权衡能得到大家的满意,它可能会成为大家的首选。

事物的隔离级别就是这样:不见得需要100%不出现并发错误、100%保证事务的隔离性。有时候为了性能牺牲点儿隔离性可能完全是可以接受的。

隔离级别

既然如此,还回到地铁的例子,那接下来要做的就是要给安全性分分级了,看看大家能接受的安全性是哪种:

  1. 城市A认为安全最重要,所以最终选择了100%安全的方案;
  2. 城市B认为有点儿小事故无所谓,所以选择了90%安全的方案,换取一定的通行效率;
  3. 城市C认为出现事故不重要,平时通行欢快才重要,所以选择了20%安全的方案;

同样地,数据库也会对隔离性划分一下等级:无关紧要的并发问题,有些人就觉得没必要保证,严重的问题,大家一致认为需要避免。

读一个数据是原子性的,不存在所谓的事务问题。所以一般举例子都是举读两个数据的例子,比如:读一个点的(x, y)坐标。理论上,读到的x和y必须都是旧值(读得早),或者都是新值(读得晚),不能说一个坐标读到了旧值,一个坐标读到了新值,这样的点不存在。

我觉得直接看MVCC解决这些问题的方式,比看这些问题更直观一些,毕竟这些问题太抽象了……

脏写 Dirty Write

一个事务修改了一个数据,还没提交;另一个事务也修改了这个数据。

比如两个事务分别要修改(1, 2)为(3, 4)和(5, 6),结果改出了一个(5, 4)。

它会发生在写-写的场景,严重性最高,因为如果不处理的话,数据就出错了。

写包裹了另一个写

脏读 Dirty Read

脏读:读到的数据是不干净的。何谓不干净?就是已经被修改了。所以脏读指的是一个事务修改了数据,还没提交;另一个事务此时读到了这个数据。那它读的其实是不准的。

比如一个事务修改(1, 2)为(3, 4),另一个线程读点的坐标,结果读到了(3, 2)这个不存在的点。

它会发生在写-读的场景,严重性次之。

写包裹了另一个读

不可重复读 Non-Repeatable Read

一个未提交事务读取了一个数据;另一个事务修改了这个数据。

比如一个线程读点(1, 2)的坐标,另一个事务修改(1, 2)为(3, 4),结果读到了(1, 4)这个不存在的点。

它会发生在读-写的场景。

读包裹了另一个写

幻读 Phantom

一个事务按照条件查到一些记录,事务没提交,再查,结果跟上次不一样了。两次看到的结果不一样,是眼花了吗?所以叫幻读。

比如一个线程读点(1, 2)的坐标,另一个事务修改(1, 2)为(3, 4),第一次读到了(1, 2),然后同一个事务里第二次读到了(3, 4)。

它会发生在读写的场景,严重性最低。

读包裹了另一个写,但是和不可重复读区别在,读到的数据都是真的,只不过两次读到的不一样。所以它严重性最低,因为都是真实数据。

有了四种隔离性问题,数据就指定了四种隔离级别,分别代表能满足不同的隔离性问题:直接看下一章。

MVCC

Multi-Version Concurrency Control,多版本并发控制。之前已经说了,记录的每次修改,都会由undo log记录下当前版本的信息。所以所有的修改记录穿成了一串,成为了版本链。

READ UNCOMMITTED:不解决脏读

每次都读版本链最新的数据,非常激进地获取新数据,以至于另一个事务还没提交,修改也被它读到了。所以会脏读。

READ COMMITTED:解决脏读,不解决可重复读

每次 读取前,给所有的事务snapshot一下,如果本次snapshot里的事务已经提交,就可以读到它的数据。否则认为读不到,继续看它的更老的(前一条undo log)历史。

所以它只能读到已提交事务的数据(要不然为啥叫READ COMMITTED),就不会产生脏读

但是两次读取,生成两个snapshot,可能第一次snapshot时另一个事务还没结束,所以第一次没读这个事务的写数据,第二次snapshot时它就结束了,所以第二次读了它的写数据。不可重复读。

READ REPEATABLE:解决不可重复读,不解决幻读

第一次 读取前,给所有事务snapshot一下,只读已提交事务的数据。所以两次读取,中间即使有事务变成已提交了,还是读不到它的数据。

也就是说,在我读的过程中,无论你怎么修改这些数据,我都读不到。所以保证了可重复读

SERIALIZABLE:解决幻读

加锁串行来访问记录,读期间别人连其他行的数据也改不了,数据自然不会幻读。

MVCC的本质:爱读哪个是你的自由

所以MVCC的本质就是——数据的任何历史版本都记下来了,愿意读哪个是你自己的选择

  • 总想读最新的,就会脏读;
  • 每次总想读已提交的最新的,不会脏读,但会不可重复读;
  • 只想读第一次时候的已提交的最新的,就能可重复读;
  • 只有串行,我读的时候别人啥也别动,连其他行也都别改,才能防止幻读。

MVCC不能管控写写

MVCC仅能处理读与写的情况:我写的时候,爱读哪个是你的自由。但是解决不了多个事务都要写同一条记录的场景,innodb加锁来保证两个事务不能交叉更新同一条记录。所以脏写是通过加锁解决的,上述四种隔离级别都能解决脏写。

  • MVCC为什么效率高?读和写不冲突,读的可以不是在写的版本。
  • MVCC为什么功能强?读哪个版本是你的自由。读的版本不一样,体现出来的功能就不一样

参考:

  • https://www.cnblogs.com/kismetv/p/10331633.html

这里的锁仅指数据库的锁。

写写

如前所述,用锁来避免写写冲突。

读写

如果Java并发见多了,第一反应应该用读写锁。或者如果读多写少,乐观锁也可以。能够避免读读阻塞。

MVCC

但是数据库里,提供了另一种思路——MVCC。即写和读不是同一个地方,不会冲突,所以完全不用加锁,性能更好。但是引入了多个版本,会消耗空间,相当于以空间换时间。同时,因为读可以采用不同的策略,能产生不同的隔离性。

共享锁、独占锁(排它锁)

数据库虽然有MVCC,但是也提供了类似于读写锁的方式解决读写问题:

  • 共享锁:Shared Lock,简称S,其实就是读锁;
  • 独占锁(排它锁):Exclusive Lock,简称X,其实就是写锁;

二者共同构成读写锁的理念。

他们就是读写锁,所以他们共享互斥的关系,完全按照读写锁去理解就行。

加读锁:

1
SELECT ... LOCK IN SHARE MODE;

加写锁:

1
SELECT ... FOR UPDATE;

多粒度锁

就像Java尽量加细粒度的锁,以让线程之间执行效率更高一样,innodb也提供了不同粒度的共享锁或者独占锁:

  • 行锁:给这一行加读锁,或写锁;
    • 行共享锁;
    • 行独占锁;
  • 表锁:给这个表加读锁,或写锁;
    • 表共享锁;
    • 表独占锁;

显然,如果有行写锁,表是不能加读锁也不能加写锁的。

但是,如果想要给表加锁,怎么才能知道这个表的某些行是否已经加了某种行锁呢?遍历?

遍历是不可能比遍历的,这辈子都不可能遍历的。

很简单,行加锁了,在表处报备一下不就行了!

所以innodb又提出了所谓的意向锁:

  • 意向共享锁:Intention Shared Lock,IS锁。给行加共享锁的时候,给表加个IS锁,报备一下;
  • 意向独占锁:Intention Exclusive Lock,IX锁。给行加独占锁的时候,给表加个IX锁,报备一下;

所以现在:

  • 想加个表共享锁,只要看看有没有表IX锁,就知道有没有行加了写锁;
  • 想加个表独占锁,只要看看有没有表IS锁或IX锁,就知道有没有行锁;

MyISAM只支持表锁,不支持行锁。

关于表锁和行锁,还可以看看:

  • https://www.cnblogs.com/itdragon/p/8194622.html

加锁解决REPEATABLE READ?

既然对于读写的情况,innodb提供了锁和MVCC两种解决方案。那锁是怎么实现MVCC类似可重复读级别的隔离呢?

innodb的人真的有意思……还用了多种解法……

  • Record Lock:行锁;
  • Gap Lock:只要给一行加了gap lock,它和它前一条记录之间的区间不能再插值。防止幻读的;
  • Next-Key Lock:Record Lock + Gap Lock,既锁住了这一行,又锁住了它前面的区间;
  • Insert Intention Lock;

具体怎么玩的,累了……

死锁

说到锁,就不得不提一下死锁。如何避免死锁,学操作系统时介绍的很详细,比如采用相同的加锁顺序等等……

感想

想不动了……明天回老家,冲冲冲~

本文由作者按照 CC BY 4.0 进行授权