七、事务及锁的原理

事务

事务的特性

  • 原子性
  • 隔离性
  • 一致性
  • 持久性

我们把需要保证原子性、隔离性、一致性和持久性的一个或多个数据库操作称之为一个事务。

事务的使用

开启事务

BEGIN [WORK];

BEGIN 语句代表开启一个事务,后边的单词 WORK 可有可无。开启事务后,就可以继续写若干条语句,这些语句都属于刚刚开启的这个事务。

START TRANSACTION;

START TRANSACTION 语句和 BEGIN 语句有着相同的效果,都标志着开启一个事务。

提交事务

COMMIT;

手动提交。

ROLLBACK;

手动终止事务,回滚。

这里需要强调一下,ROLLBACK 语句是程序员手动的去回滚事务时才去使用的,如果事务在执行过程中遇到了某些错误而无法继续执行的话,事务自身会自动的回滚。

手动提交

1
SHOW VARIABLES LIKE 'autocommit';

默认情况下,如果不显式的使用 START TRANSACTION 或者 BEGIN 语句开启一个事务,那么每一条语句都算是一个独立的事务,这种特性称之为 事务的自动提交

如果想关闭这种自动提交的功能,可以使用下边两种方法之一:

  • 显式的的使用 START TRANSACTION 或者 BEGIN 语句开启一个事务。这样在本次事务提交或者回滚前会暂时关闭掉自动提交的功能。
  • 把系统变量 autocommit 的值设置为 OFF,就像这样: SET autocommit = OFF; 这样的话,写入的多条语句就算是属于同一个事务了,
    直到显式的写出 COMMIT 语句来把这个事务提交掉,或者显式的写出 ROLLBACK 语句来把这个事务回滚掉。

隐式提交

当使用 START TRANSACTION 或者 BEGIN 语句开启了一个事务,或者把系统变量 autocommit 的值设置为 OFF 时,事务就不会进行自动提交,
但是如果输入了某些语句之后就会悄悄提交,就像输入了 COMMIT 语句了一样,这种因为某些特殊的语句而导致事务提交的情况称为 隐式提交,这些会导致事务隐式提交的语句包括:

  • 定义修改 数据库对象的数据定义语言(Data definition language,缩写为:DDL)。数据库对象,指的就是数据库、表、视图、存储过程等等。
    当使用 CREATEALTERDROP 等语句去修改这些所谓的数据库对象时,就会隐式的提交前边语句所属于的事务。
  • 隐式使用或修改 mysql 数据库中的表:当使用 ALTER USERCREATE USERDROP USERGRANTRENAME USERSET PASSWORD 等语句时也会隐式的提交前边语句所属于的事务。
  • 事务控制或关于锁定的语句:当在一个事务还没提交或者回滚时就又使用 START TRANSACTION 或者 BEGIN 语句开启了另一个事务时,会隐式的提交上一个事务。
    或者当前的 autocommit 系统变量的值为 OFF,我们手动把它调为 ON 时,也会隐式的提交前边语句所属的事务。
    或者使用 LOCK TABLESUNLOCK TABLES 等关于锁定的语句也会隐式的提交前边语句所属的事务。
  • 加载数据的语句:比如使用 LOAD DATA 语句来批量往数据库中导入数据时,也会隐式的提交前边语句所属的事务。
  • 其它的一些语句:使用 ANALYZE TABLECACHE INDEXCHECK TABLEFLUSHLOAD INDEX INTO CACHEOPTIMIZE TABLE
    REPAIR TABLERESET 等语句也会隐式的提交前边语句所属的事务。

保存点

如果开启了一个事务,并且已经执行了很多语句,忽然发现上一条语句有点问题,只能使用 ROLLBACK 语句让数据库回滚到之前的状态。
所以 MySQL 提出了一个 保存点(英文:savepoint) 的概念,就是在事务对应的数据库语句中打几个点,在调用 ROLLBACK 语句时可以指定回滚到哪个点,而不是回到最初的原点。定义保存点的语法如下:

1
SAVEPOINT 保存点名称;

当想回滚到某个保存点时,可以使用下边这个语句(下边语句中的单词 WORKSAVEPOINT 是可有可无的):

1
ROLLBACK [WORK] TO [SAVEPOINT] 保存点名称;

不过如果 ROLLBACK 语句后边不跟随保存点名称的话,会直接回滚到事务执行之前的状态。如果想删除某个保存点,可以使用这个语句:

1
RELEASE SAVEPOINT 保存点名称;

隔离级别详解

1
2
3
4
5
-- 修改隔离级别
SET SESSION TRANSACTION ISOLATION LEVEL READ UNCOMMITTED;

-- 查看隔离级别
SELECT @@tx_isolation;

1. 未提交读(READ UNCOMMITTED)

一个事务可以读到其他事务还没有提交的数据,会出现 脏读

2. 已提交读(READ COMMITTED)

一个事务只能读到另一个已经提交的事务修改过的数据,并且其他事务每对该数据进行一次修改并提交后,该事务都能查询得到最新值,会出现 不可重复读幻读

如果一个事务先根据某些条件查询出一些记录,之后另一个事务又向表中插入了符合这些条件的记录,原先的事务再次按照该条件查询时,能把另一个事务插入的记录也读出来,这就是 幻读

3. 可重复读(REPEATABLE READ)

一个事务第一次读过某条记录后,即使其他事务修改了该记录的值并且提交,该事务之后再读该条记录时,读到的仍是第一次读到的值,
而不是每次都读到不同的数据,这就是 可重复读,这种隔离级别解决了不可重复,但是还是有可能会出现幻读。

4. 串行化(SERIALIZABLE)

以上 3 种隔离级别都允许对同一条记录同时进行 读-读读-写写-读 的并发操作,如果我们不允许读-写、写-读 的并发操作,可以使用 SERIALIZABLE 隔离级别,
这种隔离级别因为对同一条记录的操作都是串行的,所以不会出现脏读、幻读等现象。

总结

  • READ UNCOMMITTED 隔离级别下,可能发生 脏读不可重复读幻读 问题。
  • READ COMMITTED 隔离级别下,可能发生 不可重复读幻读 问题,但是不会发生 脏读 问题。
  • REPEATABLE READ 隔离级别下,可能发生 幻读 问题,不会发生 脏读不可重复读 的问题。
  • SERIALIZABLE 隔离级别下,各种问题都不会发生。

注意:这四种隔离级别是 SQL 的标准定义,不同的数据库会有不同的实现,特别需要注意的是 MySQL 在 REPEATABLE READ 隔离级别下,是可以禁止幻读问题的发生的。

MVCC 多版本并发控制

版本链

对于使用 InnoDB 存储引擎的表来说,它的 聚簇索引 记录中都包含两个必要的 隐藏列 (row_id 并不是必要的,创建的表中有主键或者非 NULL 唯一键时都不会包含 row_id 列):

  • trx_id:每次对某条记录进行改动时,都会把对应的 事务 id 赋值给 trx_id 隐藏列。
  • roll_pointer:每次对某条记录进行改动时,这个隐藏列会存一个指针,可以通过这个指针找到该记录修改前的信息。

ReadView

  • 对于使用 READ UNCOMMITTED 隔离级别的事务来说,可以直接读取记录的最新版本。
  • 对于使用 SERIALIZABLE 隔离级别的事务来说,使用 加锁 的方式来访问记录。
  • 对于使用 READ COMMITTEDREPEATABLE READ 隔离级别的事务来说,就需要用到 版本链 了,核心问题就是:需要判断一下版本链中的哪个版本是当前事务可见的。

ReadView 的组成

ReadView 中主要包含 4 个比较重要的内容:

  1. m_ids:表示在生成 ReadView 时当前系统中 活跃的 读写事务的 事务 id 列表
  2. min_trx_id:表示在生成 ReadView 时当前系统中活跃的读写事务中 最小的事务 id,也就是 m_ids 中的最小值。
  3. max_trx_id:表示生成 ReadView 时系统中应该分配给 下一个事务的 id 值
  4. creator_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。

版本可见性判断条件

有了这个 ReadView,这样在访问某条记录时,只需要按照下边的步骤判断记录的某个版本是否可见:

  • 如果被访问版本的 trx_id 属性值 等于 ReadView 中的 creator_trx_id 值,表明当前事务在访问它自己修改过的记录,所以该版本可以被当前事务访问。
  • 如果被访问版本的 trx_id 属性值 小于 ReadView 中的 min_trx_id 值,表明生成该版本的事务在当前事务生成 ReadView 前已经提交,所以该版本可以被当前事务访问。
  • 如果被访问版本的 trx_id 属性值 大于 ReadView 中的 max_trx_id 值,表明生成该版本的事务在当前事务生成 ReadView 后才开启,所以该版本不可以被当前事务访问。
  • 如果被访问版本的 trx_id 属性值在 ReadView 的 min_trx_idmax_trx_id 之间,那就需要判断一下 trx_id 属性值是不是在 m_ids 列表中,
    如果在,说明创建 ReadView 时生成该版本的事务还是活跃的,该版本不可以被访问;如果不在,说明创建 ReadView 时生成该版本的事务已经被提交,该版本可以被访问。

READ COMMITTED 的实现方式

每次 读取数据前都生成一个 ReadView。

REPEATABLE READ 的实现方式

第一次 读取数据时生成一个 ReadView。

MVCC 总结

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


读锁和写锁

  • 读锁:共享锁、Shared Locks、S 锁。
  • 写锁:排它锁、Exclusive Locks、X 锁。
是否冲突 X 锁 S 锁
X 锁 冲突 冲突
S 锁 冲突 不冲突

读操作

对于普通 SELECT 语句,InnoDB 不会加任何锁。

1
SELECT ... lock in share mode;

将查找到的数据加上一个 S 锁,允许其他事务继续获取这些记录的 S 锁,不能获取这些记录的 X 锁(会阻塞)。

1
SELECT ... for update;

将查找到的数据加上一个 X 锁,不允许其他事务获取这些记录的 S 锁 和 X 锁。

写操作

  • DELETE:删除一条数据时,先对记录加 X 锁,再执行删除操作。
  • INSERT:插入一条记录时,会先加 隐式锁 来保护这条新插入的记录在本事务提交前不被别的事务访问到。
  • UPDATE
    • 如果被更新的列,修改前后没有导致存储空间变化,那么会先给记录加 X 锁,再直接对记录进行修改。
    • 如果被更新的列,修改前后导致存储空间发生了变化,那么会先给记录加 X 锁,然后将记录删掉,再 INSERT 一条新记录。

隐式锁:一个事务插入一条记录后,还未提交,这条记录会保存本次事务 id,而其他事务如果想来读取这个记录会发现事务 id 不对应,所以相当于在插入一条记录时,隐式的给这条记录加了一把隐式锁。

行锁和表锁

行锁

  • LOCK_REC_NOT_GAP:单个行记录上的锁。
  • LOCK_GAP:间隙锁,锁定一个范围,但不包括记录本身。GAP 锁的目的,是为了防止同一事务的两次当前读,出现幻读的情况。
  • LOCK_ORDINARY:锁定一个范围,并且锁定记录本身。对于行的查询,都是采用该方法,主要目的是解决幻读的问题。

间隙锁(LOCK_CAP、GAP 锁)

READ COMMITTED 级别下

  • 查询使用主键时,只需要在主键值对应的那一个条数据加锁即可。
  • 查询使用唯一索引时,只需要对查询值所对应的唯一索引记录项和对应的聚集索引上的项加锁即可。
  • 查询使用普通索引时,会对满足条件的索引记录都加上锁,同时对这些索引记录对应的聚集索引上的项也加锁。
  • 查询没有使用索引,也只会对满足条件的记录加锁。

REPEATABLE READ 级别下

  • 查询使用主键时,与 RC 隔离级别一样。
  • 查询使用唯一索引时,与 RC 隔离级别一样。
  • 查询使用普通索引时,REPEATABLE READ 级别可以解决幻读,解决的方式就是加了 GAP 锁。
  • 查询没有使用索引时,会对表中所有的记录以及间隙加锁。

表锁

表级别的 S 锁、X 锁

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

  • 在对某个表执行 ALTER TABLEDROP TABLE 这些 DDL 语句时,其他事务对这个表执行 SELECTINSERTDELETEUPDATE 的语句会发生阻塞,或者,
    某个事务对某个表执行 SELECTINSERTDELETEUPDATE 语句时,其他事务对这个表执行 DDL 语句也会发生阻塞。
    这个过程是通过使用的 元数据锁(英文名:Metadata Locks,简称 MDL) 来实现的,并不是使用的表级别的 S 锁和 X 锁。

    • LOCK TABLES t1 READ:对表 t1 加表级别的 S 锁。
    • LOCK TABLES t1 WRITE:对表 t1 加表级别的 S 锁。

IS 锁、IX 锁

  • IS 锁:意向共享锁、Intention Shared Lock。当事务准备在某条记录上加 S 锁时,需要先在表级别 加一个 IS 锁。
  • IX 锁:意向排他锁、Intention Exclusive Lock。当事务准备在某条记录上加 X 锁时,需要先在表级 别加一个 IX 锁。

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

AUTO-INC 锁

  • 在执行插入语句时就在表级别加一个 AUTO-INC 锁,然后为每条待插入记录的 AUTO_INCREMENT 修饰的列分配递增的值,在该语句执行结束后,
    再把 AUTO-INC 锁释放掉。这样一个事务在持有 AUTO-INC 锁的过程中,其他事务的插入语句都要被阻塞,可以保证一个语句中分配的递增值是连续的。
  • 采用一个轻量级的锁,在为插入语句生成 AUTO_INCREMENT 修饰的列的值时获取一下这个轻量级锁,然后生成本次插入语句需要用到的 AUTO_INCREMENT 列的值之后,
    就把该轻量级锁释放掉,并不需要等到整个插入语句执行完才释放锁。

系统变量 innodb_autoinc_lock_mode

  • innodb_autoinc_lock_mode 值为 0:采用 AUTO-INC 锁。
  • innodb_autoinc_lock_mode 值为 2:采用轻量级锁。
  • innodb_autoinc_lock_mode 值为 1:当插入记录数不确定是采用 AUTO-INC 锁,当插入记录数确定时采用轻量级锁。

悲观锁

悲观锁用的就是数据库的行锁,认为数据库会发生并发冲突,直接上来就把数据锁住,其他事务不能修改,直至提交了当前事务。

乐观锁

乐观锁其实是一种思想,认为不会锁定的情况下去更新数据,如果发现不对劲,才不更新(回滚)。在数据库中往 往添加一个 version 字段来实现。

死锁 & 避免死锁

  • 以固定的顺序访问表和行
  • 大事务拆小,大事务更容易产生死锁
  • 在同一个事务中,尽可能做到一次锁定所需要的所有资源,减少死锁概率
  • 降低隔离级别(下下签)
  • 为表添加合理的索引

七、事务及锁的原理
https://cuilan.github.io/2020/11/08/中间件/mysql/事务及锁的原理/
作者
zhang.yan
发布于
2020年11月9日
许可协议