我们已经知道事务并发执行时可能带来的各种问题,最大的一个难点是:一方面要最大程度地利用数据库的并发访问,另外一方面还要确保每个用户能以一致的方式读取和修改数据,尤其是一个事务进行读取操作,另一个同时进行改动操作的情况下。

MySQL在REPEATABLE READ隔离级别实际上就基本解决了幻读问题。

怎么解决脏读、不可重复读、幻读这些问题呢?其实有两种可选的解决方案.

解决事务并发问题的方案

方案一:读操作MVCC,写操作加锁

MVCC就是通过生成一个ReadView,然后通过ReadView找到符合条件的记录版本(历史版本是由undo日志构建的),其实就像是在生成ReadView的那个时刻做了一个快照,查询语句只能读到在生成ReadView之前已提交事务所做的更改,在生成ReadView之前未提交的事务或者之后才开启的事务所做的更改是看不到的。而写操作肯定针对的是最新版本的记录,读记录的历史版本和改动记录的最新版本本身并不冲突,也就是采用MVCC时,读-写操作并不冲突。普通的SELECT语句在READ COMMITTED和REPEATABLE READ隔离级别下会使用到MVCC读取记录。在READ COMMITTED隔离级别下,一个事务在执行过程中每次执行SELECT操作时都会生成一个ReadView,ReadView的存在本身就保证了事务不可以读取到未提交的事务所做的更改,也就是避免了脏读现象;REPEATABLE READ隔离级别下,一个事务在执行过程中只有第一次执行SELECT 操作才会生成一个ReadView,之后的SELECT操作都复用这个ReadView,这样也就避免了不可重复读和很大程度上避免了幻读的问题。

利用MVCC进行的读取操作称之为一致性读,或者一致性无锁读,也称之为快照读,但是往往读取的是历史版本数据。所有普通的SELECT语句在READ COMMITTED、REPEATABLE READ隔离级别下都算是一致性读。普通的SELECT语句是指不加锁的select语句在非串行化事务隔离级别下。一致性读并不会对表中的任何记录做加锁操作,其他事务可以自由的对表中的记录做改动。很明显,采用MVCC方式的话,读-写操作彼此并不冲突,性能更高,采用加锁方式的话,读-写操作彼此需要排队执行,影响性能。一般情况下我们当然愿意采用MVCC来解决读-写操作并发执行的问题,但是业务在某些情况下,要求必须采用加锁的方式执行。

方案二:读、写操作都加锁

如果我们的一些业务场景不允许读取记录的旧版本,而是每次都必须去读取记录的最新版本,比方在银行存款的事务中,你需要先把账户的余额读出来,然后将其加上本次存款的数额,最后再写到数据库中。在将账户余额读取出来后,就不想让别的事务再访问该余额,直到本次存款事务执行完成,其他事务才可以访问账户的余额。这样在读取记录的时候也就需要对其进行加锁操作,这样也就意味着读操作和写操作也像写-写操作那样排队执行。我们说脏读的产生是因为当前事务读取了另一个未提交事务写的一条记录,如果另一个事务在写记录的时候就给这条记录加锁,那么当前事务就无法继续读取该记录了,所以也就不会有脏读问题的产生了。不可重复读的产生是因为当前事务先读取一条记录,另外一个事务对该记录做了改动之后并提交之后,当前事务再次读取时会获得不同的值,如果在当前事务读取记录时就给该记录加锁,那么另一个事务就无法修改该记录,自然也不会发生不可重复读了。我们说幻读问题的产生是因为当前事务读取了一个范围的记录,然后另外的事务向该范围内插入了新记录,当前事务再次读取该范围的记录时发现了新插入的新记录,我们把新插入的那些记录称之为幻影记录。采用加锁的方式解决幻读问题就又不太容易了,因为当前事务在第一次读取记录时那些幻影记录并不存在,所以读取的时候加锁就有点麻烦,因为并不知道给谁加锁。InnoDB中是如何解决的,我们后面会讲到。

锁定读

锁定读(Locking Reads)也称当前读,读取的是最新版本, 并且对读取的记录加锁,阻塞其他事务同时改动相同记录,避免出现安全问题。哪些是当前读呢?select lock in share mode (共享锁)、select for update (排他锁)、update (排他锁)、insert (排他锁)、delete (排他锁)、串行化事务隔离级别都是当前读。当前读这种实现方式,也可以称之为LBCC(基于锁的并发控制,Lock-Based Concurrency Control),怎么做到?

共享锁与独占锁

在使用加锁的方式解决问题时,由于既要允许读-读情况不受影响,又要使写-写、读-写或写-读情况中的操作相互阻塞,MySQL中的锁有好几类:

  • 共享锁,英文名:Shared Locks,简称S锁。在事务要读取一条记录时,需要先获取该记录的S锁。
  • 独占锁,也常称排他锁,英文名:Exclusive Locks,简称X锁。在事务要改动一条记录时,需要先获取该记录的X 锁。

假如事务T1首先获取了一条记录的S锁之后,事务E2接着也要访问这条记录:

  • 如果事务T2想要再获取一个记录的S锁,那么事务T2也会获得该锁,也就意味着事务T1和T2在该记录上同时持有S 锁。
  • 如果事务T2想要再获取一个记录的X锁,那么此操作会被阻塞,直到事务T1提交之后将S锁释放掉。

如果事务T1首先获取了一条记录的X锁之后,那么不管事务T2接着想获取该记录的S锁还是X锁都会被阻塞,直到事务T1 提交。所以我们说S锁和S锁是兼容的,S 锁和X锁是不兼容的,X锁和X锁也是不兼容的,画个表表示一下就是这样:

兼容性

S

X

S

兼容

不兼容

X

不兼容

不兼容

锁定读的SELECT语句

MySQL有两种比较特殊的SELECT语句格式:

  1. 对读取的记录加S锁:
SELECT ... LOCK IN SHARE MODE;

也就是在普通的SELECT语句后边加LOCK IN SHARE MODE,如果当前事务执行了该语句,那么它会为读取到的记录加S锁,这样允许别的事务继续获取这些记录的S锁(比方说别的事务也使用SELECT … LOCK IN SHARE MODE语句来读取这些记录),但是不能获取这些记录的X锁(比方说使用SELECT … FOR UPDATE语句来读取这些记录,或者直接修改这些记录)。

如果别的事务想要获取这些记录的X锁,那么它们会阻塞,直到当前事务提交之后将这些记录上的S锁释放掉。

对读取的记录加X锁:

SELECT ... FOR UPDATE;

也就是在普通的SELECT语句后边加FOR UPDATE,如果当前事务执行了该语句,那么它会为读取到的记录加X锁,这样既不允许别的事务获取这些记录的S锁(比方说别的事务使用SELECT … LOCK IN SHARE MODE 语句来读取这些记录),也不允许获取这些记录的X锁(比如说使用SELECT … FOR UPDATE 语句来读取这些记录,或者直接修改这些记录)。

如果别的事务想要获取这些记录的S锁或者X锁,那么它们会阻塞,直到当前事务提交之后将这些记录上的X锁释放掉。

写操作的锁

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

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

多粒度锁

我们前边提到的锁都是针对记录的,也可以被称之为行级锁或者行锁,对一条记录加锁影响的也只是这条记录而已,我们就说这个锁的粒度比较细;其实一个事务也可以在表级别进行加锁,自然就被称之为表级锁或者表锁,对一个表加锁影响整个表中的记录,我们就说这个锁的粒度比较粗。给表加的锁也可以分为共享锁(S 锁)和独占锁(X 锁)

表锁与行锁的比较

  • 锁定粒度:表锁> 行锁
  • 加锁效率:表锁> 行锁
  • 冲突概率:表锁> 行锁
  • 并发性能:表锁< 行锁
  • 加锁位置:表锁由MySQL服务层提供,行锁由InnoDB存储引擎层提供

给表加S锁

如果一个事务给表加了S锁,那么:

  • 别的事务可以继续获得该表的S锁
  • 别的事务可以继续获得该表中的某些记录的S锁
  • 别的事务不可以继续获得该表的X锁
  • 别的事务不可以继续获得该表中的某些记录的X锁

给表加X锁

如果一个事务给表加了X锁(意味着该事务要独占这个表),那么:

  • 别的事务不可以继续获得该表的S锁
  • 别的事务不可以继续获得该表中的某些记录的S锁
  • 别的事务不可以继续获得该表的X锁
  • 别的事务不可以继续获得该表中的某些记录的X锁。

意向锁

可是怎么可能平白无故的就给表加锁呢,难道没什么条件吗?答案是肯定有条件的:

  • 若想给表加S锁,得先确保表中记录没有X锁
  • 若想给表加X锁,得先确保表中记录没有X锁和S锁

但是这个怎么确保呢?难道要一行一行的遍历表中的所有数据吗?当然不是啦,聪明的大佬们想出了下面这两把锁

  • 意向共享锁(Intention Shared Lock):简称IS锁,当事务准备在某记录上加S锁时,需要先在表级别加上一个IS锁
  • 意向独占锁(Intention Exclusive Lock):简称IX锁,当事务准备在某记录上加X锁时,需要先在表级别加上一个IX锁

让我们来看下加上这两把锁之后的效果是什么样子的:

  • 当想给记录加S锁时,先给表加一个IS锁,然后再给记录加S锁
  • 当想给记录加X锁时,先给表加IX锁,然后再给记录加X锁

然后经过上面的操作之后:

  • 如果想给表加S锁,先看下表加没加IX锁,如果有的话,则表明此表中的记录有X锁,则需要等到IX锁释放掉后才可以加S锁
  • 如果想给表加X锁,先看下表加没加IS锁或者IX锁,如果有的话,则表明此表中的记录有S锁或者X锁,则需要等到IS锁或者IX锁释放掉后才可以加X锁

这几种锁的兼容性如下表:

兼容性

IS

S

IX

X

IS

Y

Y

Y

N

S

Y

Y

N

N

IX

Y

N

Y

N

X

N

N

N

N

总结一下:IS、IX锁是表级锁,它们的提出仅仅为了在之后加表级别的S锁和X锁时可以快速判断表中的记录是否被上锁,以避免用遍历的方式来查看表中有没有上锁的记录。就是说其实IS 锁和IX 锁是兼容的,IX 锁和IX 锁是兼容的。另外意向锁是InnoDB自动加的,不需用户干预。