如何通过加锁的方式实现隔离性

33 阅读19分钟

1. 如何来实现隔离性

1.1 方案一:读操作利用多版本并发控制(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,这样也就避免了不可重复读和幻读的问题。

1.2 方案二:读、写操作都采用加锁的方式。

如果我们的一些业务场景不允许读取记录的旧版本,而是每次都必须去读取记录的最新版本,比方在银行存款的事务中,你需要先把账户的余额读出来,然后将其加上本次存款的数额,最后再写到数据库中。在将账户余额读取出来后,就不想让别的事务再访问该余额,直到本次存款事务执行完成,其他事务才可以访问账户的余额。这样在读取记录的时候也就需要对其进行加锁操作,这样也就意味着操作和操作也像写-写操作那样排队执行。

对于锁的方案,最简单的策略是整个数据库只有一把互斥锁,持有锁的事务可以执行,其他的事务只能等待。但是这个策略有很明显的问题,那就是锁的粒度太粗,会导致整个数据库的并发度变为 1 。不过,我们可以进行优化,为事务所操作的每一块数据都分配一把锁,通过降低锁的粒度增加事务的并发度。同时,相对于互斥锁来说,读写锁是一个更好的选择,通过读写锁,多个事务对同一块数据的读写和写写操作会相互阻塞,但却能允许多个读操作并发进行。

1.3 对比

很明显,采用MVCC方式的话,读-写操作彼此并不冲突,性能更高,采用加锁方式的话,读-写操作彼此需要排队执行,影响性能。一般情况下我们当然愿意采用MVCC来解决读-写操作并发执行的问题,但是业务在某些特殊情况下,要求必须采用加锁的方式执行,那也是没有办法的事。

2. 多粒度锁

其实一个事务也可以在级别进行加锁,自然就被称之为表级锁或者表锁,对一个表加锁影响整个表中的记录,我们就说这个锁的粒度比较粗,我们前面提到的都是针对记录的,也可以被称之为行级锁或者行锁我们就说这个锁的粒度比较细;

  1. 行锁:锁定整个行数据,锁的粒度比较细,会出现死锁;锁的粒度比较小,发生冲突的概率低,并发度高。

  2. 表锁:锁定整个表数据,锁的粒度比较粗,不会出现死锁;锁的粒度大,发生锁冲突概率高,并发度最低。给表加的锁也可以分为共享锁S锁)和独占锁X锁

2.1 InnoDB中的表级锁

2.1.1 共享锁S锁)和独占锁X锁

在对某个表执行SELECTINSERTDELETEUPDATE语句时,InnoDB存储引擎是不会为这个表添加表级别的S锁或者X锁的。

另外,在对某个表执行一些诸如ALTER TABLEDROP TABLE这类的DDL语句时,其他事务对这个表并发执行诸如SELECTINSERTDELETEUPDATE的语句会发生阻塞,同理,某个事务中对某个表执行SELECTINSERTDELETEUPDATE语句时,在其他会话中对这个表执行DDL语句也会发生阻塞。这个过程其实是通过在server层使用一种称之为元数据锁(英文名:Metadata Locks,简称MDL)来实现的,一般情况下也不会使用InnoDB存储引擎自己提供的表级别的S锁X锁

其实这个InnoDB存储引擎提供的表级S锁或者X锁是相当鸡肋,只会在一些特殊情况下,比方说崩溃恢复过程中用到

2.1.2 意向共享锁IS锁)和意向独占锁IX锁

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

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

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

当我们在对使用InnoDB存储引擎的表的某些记录加S锁之前,那就需要先在表级别加一个IS锁,当我们在对使用InnoDB存储引擎的表的某些记录加X锁之前,那就需要先在表级别加一个IX锁IS锁IX锁的使命只是为了后续在加表级别的S锁X锁时判断表中是否有已经被加锁的记录,以避免用遍历的方式来查看表中有没有上锁的记录。

我们画个表来看一下表级别的各种锁的兼容性:

兼容性XIXSIS
X不兼容不兼容不兼容不兼容
IX不兼容兼容不兼容兼容
S不兼容不兼容兼容兼容
IS不兼容兼容兼容兼容

2.1.3 AUTO-INC锁

在使用MySQL过程中,我们可以为表的某个列添加AUTO_INCREMENT属性,之后在插入记录时,可以不指定该列的值,系统会自动为它赋上递增的值,比方说我们有一个表:

CREATE TABLE t (
    id INT NOT NULL AUTO_INCREMENT,
    c VARCHAR(100),
    PRIMARY KEY (id)
) Engine=InnoDB CHARSET=utf8;

由于这个表的id字段声明了AUTO_INCREMENT,也就意味着在书写插入语句时不需要为其赋值。

2.1.3.1 自增分类

所有的插入语句,包括: INSERT、REPLACE、INSERT…SELECT、REPLACE…SELECT,LOAD DATA等。

“Simple inserts”

指在插入前就能确定插入行数的语句,包括:INSERT、REPLACE,不包含INSERT…ON DUPLICATE KEY UPDATE这类语句。

即我们可以预先知道插入行数的插入

“Bulk inserts”

在插入前不知道行数的插入,包括:INSERT ... SELECT/REPLACE ... SELECT/LOAD DATA。比如 insert into tablename from (select …),显然子查询中的行数在插入前我们是不知道有多少行的。

“Mixed-mode inserts”

混合模式分为两种:

  1. 插入的语句有一些自增列时确定的值,一些是不确定的。

例如:MySQL官网给的例子,表t1有两个列(c1和c2),其中c1列时自增列,那么构造如下SQL语句就是混合模式:

INSERT INTO t1 (c1,c2) VALUES (1,'a'), (NULL,'b'), (5,'c'), (NULL,'d');
  1. INSERT ... ON DUPLICATE KEY UPDATE

这种语句会使用锁来为AUTO_INCREMENT列分配自增值,但是在更新阶段可能不会用这些分配的自增值。

2.1.3.2 锁模式(三种)

AUTO-INC锁可以使用innodb_autoinc_lock_mode变量来配置自增锁的算法。innodb_autoinc_lock_mode变量可以选择三种值如下表:

innodb_autoinc_lock_mode变量含义
0传统锁模式
1连续锁模式
2交错锁模式(MySQL8默认)
innodb_autoinc_lock_mode=0 传统锁模式

使用表级AUTO-INC锁,也就是在执行插入语句时就在表级别加一个AUTO-INC锁,然后为每条待插入记录的AUTO_INCREMENT修饰的列分配递增的值,在该语句执行结束后,再把AUTO-INC锁释放掉。这样一个事务在持有AUTO-INC锁的过程中,其他事务的插入语句都要被阻塞,可以保证一个语句中分配的递增值是连续的。

如果我们的插入语句在执行前不可以确定具体要插入多少条记录(无法预计即将插入记录的数量),比方说使用INSERT ... SELECT、REPLACE ... SELECT或者LOAD DATA这种插入语句,一般是使用AUTO-INC锁为AUTO_INCREMENT修饰的列生成对应的值。

需要注意一下的是,这个AUTO-INC锁的作用范围只是单个插入语句,插入语句执行完成后,这个锁就被释放了,跟之前介绍的锁在事务结束时释放是不一样的。

innodb_autoinc_lock_mode=1 连续锁模式

连续锁模式对于“Simple inserts”不会使用表级锁,而是使用一个轻量级锁来生成自增值,因为InnoDB可以提前z知道插入多少行数据。自增值生成阶段使用轻量级互斥锁来生成所有的值,而不是一直加锁直到插入完成。但是如果其他事务持有AUTO_INC锁,那么“Simple Inserts”类语句也需要等待其他事务完成才能使用轻量级锁来生成所有的自增值。这种方式可以避免锁定表,可以提升插入性能。

连续锁模式对于“bulk inserts”类语句使用AUTO_INC表级锁直到语句完成。使用表级AUTO_INC锁的语句:INSERT ... SELECT、REPLACE ... SELECT、LOAD DATA 。

当innodb_autoinc_lock_mode=1时,在语句复制格式下(BINLOG_FORMAT=STATEMENT),BINLOG中没有记录主库执行过程中获取到的所有自增值及其对应行的信息,要保证"Bulk insert"操作主从复制数据一致就必须保证语句在主库和从库执行时获取到相同自增值,而因此只能通过控制“获取连续自增值”的方式来实现,同时为避免受其他事务插入操作影响,就必须在表级别加锁且保证持有锁至语句结束。

在行复制格式下(BINLOG_FORMAT=ROW),主库BINLOG中保存有记录的所有列信息包括自增列值,因此无需通过AUTO-INC锁来保证主从数据一致。在MySQL 8.0版本前,参数BINLOG_FORMAT的默认值为STATEMENT,参数innodb_autoinc_lock_mode的默认值为1。在MySQL 8.0版本后,参数BINLOG_FORMAT的默认值被调整为ROW格式,参数innodb_autoinc_lock_mode的默认值为2。

innodb_autoinc_lock_mode=2 交错锁模式(MySQL8默认)

所有的“INSERT-LIKE”语句都不使用表级锁,而是使用轻量级互斥锁。

交错锁模式速度快、可扩展性高,但是对于基于语句复制会有问题,只能使用基于ROW复制。

之所以称为交错模式是因为并发插入场景下自增值的分配大概率是交替这来的,时刻1事务1获得自增值,时刻2事务2获得自增值,以此类推。

AUTO-INC锁与innodb_autoinc_lock_mode

2.2 InnoDB中的行级锁

行级锁是Mysql中锁定粒度最细的一种锁,表示只针对当前操作的行进行加锁。行级锁能大大减少数据库操作的冲突。其加锁粒度最小,但加锁的开销也最大。行级锁分为共享锁 和 排他锁。

行级锁的实现方式是通过给索引上的索引项加锁来实现的,也就意味着:只有通过索引条件检索数据,InnoDB才使用行级锁,否则,InnoDB将使用表锁。这一点在实际应用中特别需要注意,不然的话可能导致大量的锁冲突,从而影响引发并发性能。

在实际应用中,要特别注意 InnoDB 行锁的这一特性,不然的话,可能导致大量的锁冲突,从而影响并发性能。

  • 在不通过索引条件查询的时候,InnoDB 确实使用的是表锁,而不是行锁。
  • 由于 MySQL 的行锁是针对索引加的锁,不是针对记录加的锁,因此虽然是访问不同行的记录,但是如果是使用相同的索引键,是会出现锁冲突的。应用设计的时候要注意这一点。
  • 当表有多个索引的时候,不同的事务可以使用不同的索引锁定不同的行,另外,不论是使用主键索引、唯一索引还是普通索引,InnoDB 都会使用行锁来对数据加锁。
  • 即便在条件中使用了索引字段,但是否使用索引来检索数据是由 MySQL 通过判断不同的执行计划的代价来决定的。如果 MySQL 认为全表扫描效率更高,比如对一些很小的表,它就不会使用索引,这种情况下 InnoDB 将使用表锁,而不是行锁。因此,在分析锁冲突时,别忘了检查 SQL 的执行计划,以确认是否真正使用了索引。

2.2.1 Record Locks(记录锁):

仅仅把一条记录锁上。官方的类型名称为:LOCK_REC_NOT_GAP

record locks 分为有S锁和X锁。当一个事务获取了一条记录的S记录锁后,其他事务也可以继续获取该记录的S记录锁,但不可以继续获取X记录锁;当一个事务获取了一条记录的X记录锁后,其他事务既不可以继续获取该记录的S记录锁,也不可以继续获取X记录锁。

2.2.2 Gap Locks

MySQL在REPEATABLE READ隔离级别下是可以解决幻读问题的,解决方案有两种,可以使用MVCC方案解决,也可以采用加锁方案解决。但是在使用加锁方案解决时有个大问题,就是事务在第一次执行读取操作时,那些幻影记录尚不存在,我们无法给这些幻影记录加上Record Lock。不过这难不倒设计InnoDB的大佬,他们提出了一种称之为Gap Locks的锁,官方的类型名称为:LOCK_GAP,简称为gap锁。

比方说我们把id值为9的那条记录加一个gap锁的示意图如下:

如图中为id值为9的记录加了gap锁,意味着不允许别的事务在id值为9的记录前面的间隙插入新记录,其实就是id列的值(5, 9)这个区间的新记录是不允许立即插入的。比方说有另外一个事务再想插入一条id值为6的新记录,它定位到该条新记录的下一条记录的id值为9,而这条记录上又有一个gap锁,所以就会阻塞插入操作,直到拥有这个gap锁的事务提交了之后,id列的值在区间(5, 9)中的新记录才可以被插入。

这个gap锁的提出仅仅是为了防止插入幻影记录而提出的,虽然有共享gap锁和独占gap锁这样的说法,但是它们起到的作用都是相同的。而且如果你对一条记录加了gap锁(不论是共享gap锁还是独占gap锁),并不会限制其他事务对这条记录加记录锁或者继续加gap锁,再强调一遍,gap锁的作用仅仅是为了防止插入幻影记录的而已。

不知道大家发现了一个问题没,给一条记录加了gap锁只是不允许其他事务往这条记录前面的间隙插入新记录,那对于最后一条记录之后的间隙,也就是表中id值为20的记录之后的间隙该咋办呢?也就是说给哪条记录加gap锁才能阻止其他事务插入id值在(20, +∞)这个区间的新记录呢?这时候需要两条伪记录:

  • Infimum记录,表示该页面中最小的记录。
  • Supremum记录,表示该页面中最大的记录。

为了实现阻止其他事务插id值在(20, +∞)这个区间的新记录,我们可以给索引中的最后一条记录,也就是id值为20的那条记录所在页面的Supremum记录加上一个gap锁,画个图就是这样:

这样就可以阻止其他事务插入number值在(20, +∞)这个区间的新记录。为了大家理解方便,之后的索引示意图中都会把这个Supremum记录画出来。

2.2.3 Next-Key Locks

有时候我们既想锁住某条记录,又想阻止其他事务在该记录前面的间隙插入新记录,所以设计InnoDB的大佬们就提出了一种称之为Next-Key Locks的锁,官方的类型名称为:LOCK_ORDINARY,我们也可以简称为next-key锁。比方说我们把id值为9的那条记录加一个next-key锁的示意图如下:

next-key Lock = record lock + gap Lock 它既能保护该条记录,又能阻止别的事务将新记录插入被保护记录前面的间隙。

2.2.4 Insert Intention Locks

我们说一个事务在插入一条记录时需要判断一下插入位置是不是被别的事务加了gap锁,如果有的话,插入操作需要等待,直到拥有gap锁的那个事务提交。

但是设计InnoDB的大佬规定事务在等待的时候也需要在内存中生成一个锁结构,表明有事务想在某个间隙中插入新记录,但是现在在等待。设计InnoDB的大佬就把这种类型的锁命名为Insert Intention Locks,官方的类型名称为:LOCK_INSERT_INTENTION,我们也可以称为插入意向锁。

比方说我们把id值为9的那条记录加一个插入意向锁的示意图如下:

从图中可以看到,由于T1持有gap锁,所以T2和T3需要生成一个插入意向锁的锁结构并且处于等待状态。当T1提交后会把它获取到的锁都释放掉,这样T2和T3就能获取到对应的插入意向锁了(本质上就是把插入意向锁对应锁结构的is_waiting属性改为false),T2和T3之间也并不会相互阻塞,它们可以同时获取到number值为8的插入意向锁,然后执行插入操作。事实上插入意向锁并不会阻止别的事务继续获取该记录上任何类型的锁(插入意向锁就是这么鸡肋)。

2.2.5 隐式锁

一个事务在执行INSERT操作时,如果即将插入的间隙已经被其他事务加了gap锁,那么本次INSERT操作会阻塞,并且当前事务会在该间隙上加一个插入意向锁,否则一般情况下INSERT操作是不加锁的。那如果一个事务首先插入了一条记录(此时并没有与该记录关联的锁结构),然后另一个事务:

  • 立即使用SELECT ... LOCK IN SHARE MODE语句读取这条事务,也就是在要获取这条记录的S锁,或者使用SELECT ... FOR UPDATE语句读取这条事务或者直接修改这条记录,也就是要获取这条记录的X锁,该咋办?如果允许这种情况的发生,那么可能产生脏读问题。
  • 立即修改这条记录,也就是要获取这条记录的X锁,该咋办?如果允许这种情况的发生,那么可能产生脏写问题。

情景一:对于聚簇索引记录来说,有一个trx_id隐藏列,该隐藏列记录着最后改动该记录的事务id。那么如果在当前事务中新插入一条聚簇索引记录后,该记录的trx_id隐藏列代表的的就是当前事务的事务id,如果其他事务此时想对该记录添加S锁或者X锁时,首先会看一下该记录的trx_id隐藏列代表的事务是否是当前的活跃事务,如果是的话,那么就帮助当前事务创建一个X锁(也就是为当前事务创建一个锁结构,is_waiting属性是false),然后自己进入等待状态(也就是为自己也创建一个锁结构,is_waiting属性是true)。

情景二:对于二级索引记录来说,本身并没有trx_id隐藏列,但是在二级索引页面的Page Header部分有一个PAGE_MAX_TRX_ID属性,该属性代表对该页面做改动的最大的事务id如果PAGE_MAX_TRX_ID属性值小于当前最小的活跃事务id,那么说明对该页面做修改的事务都已经提交了,否则就需要在页面中定位到对应的二级索引记录,然后回表找到它对应的聚簇索引记录,然后再重复情景一的做法。

通过上面的叙述我们知道,一个事务对新插入的记录可以不显式的加锁(生成一个锁结构),但是由于事务id的存在,相当于加了一个隐式锁。别的事务在对这条记录加S锁或者X锁时,由于隐式锁的存在,会先帮助当前事务生成一个锁结构,然后自己再生成一个锁结构后进入等待状态。