【数据库事务与锁机制】- 事务隔离的实现

事实上在数据库引擎的实现中并不能实现完全的事务隔离,比如串行化。这种事务隔离方式虽然是比较理想的隔离措施,但是会对并发性能产生比较大的影响,所以在MySQL中事务的默认隔离级别是 REPEATABLE READS(可重复读),下面我们展开讨论一下MySQL对数据库隔离性的实现。

MySQL 事务隔离性的实现

在MySQL InnoDB (下称MySQL)中实现事务的隔离性是通过锁实现的,大家知道在并发场景下我常用的隔离和一致性措施往往是通过锁实现,所以锁也是数据库系统常用的一致性措施。

MySQL锁的分类

我们主要讨论InnoDB 锁的实现,但是也有必要简单了解MySQL中其他数据库引擎对锁的实现。整体来说MySQL 中可以分为三种锁的类型 表锁、行锁、页锁,其中使用表锁的是 MyISAM引擎,支持行锁的是 InnoDB 引擎,同时InnoDB也支持表锁,BDB 支持页锁(不是太了解)。

表锁 table-level locking

表级别的锁顾名思义就是加锁的维度是表级别的,是给一个表上锁,这种锁的特点是 开销小,加锁快;不会出现死锁;锁定粒度大,发生锁冲突的概率最高,但是并发度也是最低的,表级锁更适合于以查询为主,只有少量按索引条件更新数据的应用。

MySQL 表锁的使用

在MySQL中使用表锁比较简单,可以通过 LOCK TABLE 语句对一张表进行加锁,如下:

# 加锁
LOCK TABLE T_XXXXXXXXX;
# 解锁
UNLOCK TABLES;

加锁和解锁的语法

LOCK TABLES
    tbl_name [[AS] alias] lock_type
    [, tbl_name [[AS] alias] lock_type] ...
 
 
lock_type: {
    READ [LOCAL]
  | [LOW_PRIORITY] WRITE
}
 
UNLOCK TABLES

需要注意的是 LOCK TABLE 是指当前会话的锁,也就是通过 LOCK TABLE 显示的为当前会话获取表锁,作用是防止其他会话在需要互斥访问时修改表的数据,会话只能为其自身获取或释放锁。一个会话无法获取另一会话的锁,也不能释放另一会话持有的锁。同时 LOCK TABLE 不单单可以获取一个表的锁,也可以是一个视图,对于视图锁定,LOCK TABLES将视图中使用的所有基本表添加到要锁定的表集合中,并自动锁定它们。

LOCK TABLES 在获取新锁之前,隐式释放当前会话持有的所有表锁
UNLOCK TABLES显式释放当前会话持有的所有表锁

LOCK TABLE 语句有两个比较重要的参数 lock_type 它可以容许你指定加锁的模式,是读锁还是写锁,也就是 READ LOCK 和 WRITE LOCK。

  • READ 锁
    读锁的特点是 持有锁的会话可以读取表但不能写入表,多个会话可以同时获取READ该表的锁
  • WRITE 锁
    持有锁的会话可以读取和写入表,只有持有锁的会话才能访问该表。在释放锁之前,没有其他会话可以访问它,保持锁定状态时,其他会话对表的锁定请求将阻塞
    WRITE锁通常比READ锁具有更高的优先级,以确保尽快处理更新。这意味着,如果一个会话获取了一个READ锁,然后另一个会话请求了一个WRITE锁,则随后的 READ锁请求将一直等待,直到请求该WRITE锁的会话已获取并释放了该锁

通过上面对表锁的简单介绍我们引出两个比较重要的信息,就是读锁和写锁,那么答案就浮出水面,在表级别的锁中其实MySQL是通过 共享读锁,和排他写锁来实现隔离性的,下面我们减少共享读锁和排他写锁。

共享读锁(Table Read Lock)

共享锁又称为读锁,简称S锁,顾名思义,共享锁就是多个事务对于同一数据可以共享一把锁,都能访问到数据,但是只能读不能修改

对MyISAM表的读操作,不会阻塞其他用户对同一表的读请求,但会阻塞对同一表的写请求;也即当一个session给表加读锁,其他session也可以继续读取该表,但所有更新、删除和插入将会阻塞,直到将表解锁。MyISAM引擎在执行select时会自动给相关表加读锁,在执行update、delete和insert时会自动给相关表加写锁

独占写锁(Table Write Lock)

排他锁又称为写锁,简称X锁,顾名思义,排他锁就是不能与其他所并存,如一个事务获取了一个数据行的排他锁,其他事务就不能再获取该行的其他锁,包括共享锁和排他锁,但是获取排他锁的事务是可以对数据就行读取和修改

独占写锁也被称之为排他写锁,MyISAM表的写操作,则会阻塞其他用户对同一表的读和写操作;MyISAM表的读操作与写操作之间,以及写操作之间是串行的。也即当一个session给表加写锁,其他session所有读取、更新、删除和插入将会阻塞,直到将表解锁

共享锁和独占锁的兼容性

锁类型 独占锁 共享锁
独占锁 不兼容 不兼容
共享锁 不兼容 兼容

行锁 Row -level locking

在MySQL中 支持行锁的引擎是InnoDB,所以我们这里我们指的行锁主要是说InnoDB的行锁。
InnoDB锁的实现和Oracle非常类似,提供一致性的非锁定读、行级锁支持。行级锁没有相关额外的开销,并可以同时得到并发性和一致性。

lock与latch

Latch一般称为闩锁(轻量级的锁),因为其要求锁定的时间必须非常短。若持续的时间长,则应用的性能会非常差。在InnoDB中,latch又可以分为mutex(互斥量)和rwlock(读写锁)。其目的是用来保证并发线程操作临界资源的正确性,并且通常没有死锁检测的机制。

Lock的对象是事务,用来锁定的是数据库中的对象,如表、页、行。并且一般lock的对象仅在事务commit或rollback后进行释放(不同事务隔离级别释放的时间可能不同)。

lock与latch的比较

latch可以通过命令SHOW ENGINE INNODB MUTEX查看,Lock可以通过命令SHOW ENGINE INNODB STATUS及information_schema架构下的表INNODB_TRX、INNODB_LOCKS、INNODB_LOCK_WAITS来查看。

和 上面表锁中讲的一样 MySQL 行锁也是通过 共享锁和独占锁(排他锁)实现的,所以关于这两种锁的概述就不过多简绍。

InnoDB还支持多粒度(granular)锁定,允许事务同时存在行级锁和表级锁,这种种额外的锁方式,称为意向锁(Intention Lock)。意向锁是将锁定的对象分为多个层次,意向锁意味着事务希望在更细粒度(fine granularity)上进行加锁

如果对最下层(最细粒度)的对象上锁,那么首先需要对粗粒度的对象上锁,意向锁为表级锁,不会阻塞除全表扫描以外的任何请求。设计目的主要是为了在一个事务中揭示下一行将被请求的锁类型。两种意向锁。

  • 意向共享锁(IS Lock),事务想要获得一张表中某几行的共享锁
  • 意向排他锁(IX Lock),事务想要获得一张表中某几行的排他锁

表级意向锁与行级锁的兼容性

下面命令或表都可以查看当前锁的请求

SHOW FULL PROCESSLIST;
SHOW ENGINE INNODB STATUS;
SELECT * FROM information_schema.INNODB_TRX;
SELECT * FROM information_schema.INNODB_LOCKS;
SELECT * FROM information_schema.INNODB_LOCK_WAITS;

一致性非锁定读

一致性的非锁定读(consistent nonlocking read)是指InnoDB通过行多版本控制(multi versioning)的方式来读取当前执行时间数据库中行的数据。如果读取的行正在执行DELETE或UPDATE操作,这时不会去等待行上锁的释放。而是去读取行的一个快照数据(之前版本的数据)。

一个行记录多个快照数据,一般称这种技术为行多版本技术。由此带来的并发控制,称之为多版本并发控制(Multi Version Concurrency Control,MVCC)。

之所以称为非锁定读,因为不需要等待访问的行上X锁的释放。实现方式是通过undo段来完成。而undo用来在事务中回滚数据,快照数据本身没有额外的开销,也不需要上锁,因为没有事务会对历史数据进行修改操作。非锁定读机制极大地提高了数据库的并发性。在不同事务隔离级别下,读取的方式不同,并不是在每个事务隔离级别下都是采用非锁定的一致性读。此外,即使都是使用非锁定的一致性读,但是对于快照数据的定义也不相同。在事务隔离级别READ COMMITTED和REPEATABLE READ下,InnoDB使用非锁定的一致性读。但对快照数据的定义不相同。在READ COMMITTED事务隔离级别下,对于快照数据,非一致性读总是读取被锁定行的最新一份快照数据。而在REPEATABLE READ事务隔离级别下,对于快照数据,非一致性读总是读取事务开始时的行数据版本。

自增长与锁

自增长在数据库中是非常常见的一种属性,也是首选的主键方式。在InnoDB的内存结构中,对每个含有自增长值的表都有一个自增长计数器(auto-increment counter)。

插入操作会依据这个自增长的计数器值加1赋予自增长列。这个实现方式称做AUTO-INC Locking,采用了一种特殊的表锁机制,为了提高插入的性能,锁不是在一个事务完成后才释放,而是在完成对自增长值插入的SQL语句后立即释放。
因此InnoDB提供了一种轻量级互斥量的自增长实现机制,大大提高了自增长值插入的性能。同时提供了一个参数innodb_autoinc_lock_mode来控制自增长的模式,该参数的默认值为1。了解其实现之前,先对自增长的插入进行分类,如下表:

参数innodb_autoinc_lock_mode的说明

InnoDB中自增长的实现和MyISAM不同,MyISAM存储引擎是表锁设计,自增长不用考虑并发插入的问题。如果主从分别使用InnoDB和MyISAM时,必须考虑这种情况。

另外,在InnoDB存中,自增长值的列必须是索引,同时必须是索引的第一个列。如果不是第一个列会抛出异常,而MyISAM没有这个问题。

外键和锁

外键主要用于引用完整性的约束检查。InnoDB对于一个外键列,如果没有显式地对这个列加索引,会自动对其加一个索引,可以避免表锁。而Oracle不会自动添加索引,需要手动添加,可能会产生死锁问题。

对于外键值的插入或更新,首先需要查询(select)父表中的记录。但是select父表操作不是使用一致性非锁定读,因为这会导致数据不一致的问题,因此这时使用的是SELECT…LOCK IN SHARE MODE方式,即主动对父表加一个S锁。如果这时父表上已经加了X锁,子表上的操作会被阻塞。如下表:

行锁的3种算法

InnoDB有如下3种行锁的算法

  • Record Lock:单个行记录上的锁。总去锁住索引记录,如果表没有设置任何索引,会使用隐式的主键来进行锁定

  • Gap Lock:间隙锁,锁定一个范围,但不包含记录本身

  • Next-Key Lock:Gap Lock+Record Lock,锁定一个范围,并且锁定记录本身。行的查询采用这种锁定算法

例如一个索引有10,11,13和20这四个值,那么该索引可能被Next-Key Locking的区间为

采用Next-Key Lock的锁定技术称为Next-Key Locking。其设计的目的是为了解决幻读问题(Phantom Problem)。Next-Key Lock是谓词锁(predict lock)的一种改进。还有previous-key locking技术。同样上述的索引10、11、13和20,若采用previous-key locking技术,那么锁定的区间为

当查询的索引含有唯一属性时,会对Next-Key Lock进行优化。对聚集索引,将其降级为Record Lock。对辅助索引,将对下一个键值加上gap lock,即对下一个键值的范围为加锁
Gap Lock的作用是为了阻止多个事务将记录插入到同一范围内,而这会产生导致幻读问题,用户可以通过以下两种方式来显式地关闭Gap Lock

  • 将事务的隔离级别设置为READ COMMITTED
  • 将参数innodb_locks_unsafe_for_binlog设置为1

上述设置破坏了事务的隔离性,并且对于replication,可能会导致主从数据的不一致。此外,从性能上来看,READ COMMITTED也不会优于默认的事务隔离级别READ REPEATABLE。

解决幻读问题

幻读问题是指在同一事务下,连续执行两次同样的范围查询操作,得到的结果可能不同

Next-Key Locking的算法就是为了避免幻读问题。对于上述的SQL语句,其锁住的不是单个值,而是对(2,+∞)这个范围加了X锁。因此任何对于这个范围的插入不允许,从而避免了幻读问题。Next-Key Locking机制在应用层还可以实现唯一性的检查。例如:

select * from table_name where col = xxx LOCK IN SHARE MODE;

如果用户通过索引查询一个值,并对该行加上一个SLock,那么即使查询的值不在,其锁定的也是一个范围,因此若没有返回任何行,那么新插入的值一定是唯一的。如果此时有多个事务并发操作,那么这种唯一性检查机制也不会存在问题。因为这时会导致死锁,只有一个事务的插入操作会成功,而其余的事务会抛出死锁的错误。

通过Next-Key Locking实现应用程序的唯一性检查:

总结

以上我们简单简绍了MySQL 如何通过锁机制实现对事务的隔离,也简绍了一些实现这些所的算法,如果对细节比较感兴趣的同学可以参考 官方文档 中对InnoDB 的详细简绍。

(完)