知其然,知其所以然

总结一下自己多年来对MySQL的相关知识,做个梳理。

本文用到的MySQL版本:5.7.22

为什么要锁

我们开的的各式各样系统中,系统运行需要CPU、内存、I/O、磁盘等等资源。但除了硬资源外,还有最为重要的软资源:数据

当人们访问操作我们的系统时,其实归根是对数据的查看与生产。那么对于同一份数据,如果多个用户同时对它查看、修改时会出现什么问题呢?这必然会带来竞争,而为了控制并发的读取、修改数据会对数据造成的不一致、错乱等问题,数据库引入了锁的机制。

当然锁的问题解决了并发访问数据的问题,而不可避免的会对系统的性能产生负面影响。总结一下各种锁的使用场景方便在实践中更好的运用它从而提升系统性能。

锁的种类

我们日常开发中用到最多的存储引擎是Innodb 与 MyISAM两种,而 Innodb 现在更多是首选,因此主要是对 Innodb 的说明,MyISAM 跟多是作为一个对比的角色。

MySQL的锁可以按照多种方式进行划分,这里使用最常用的两种方式进行划分:粒度与使用方式。

需要特别说明的是:乐观锁与悲观锁并不是数据库中实现的锁机制,是需要我们自己去实现的。它是一种思想,我们不仅仅可以用在MySQL中,其它地方有需要的也可以用到。而像悲观锁它也是利用数据库现有的机制进行实现的。下面先根据不同分类对说明相关概念。

按使用方式

乐观锁 机制采取了更加宽松的加锁机制。悲观锁大多数情况下依靠数据库的锁机制实现,以保证操作最大程度的独占性。但随之而来的就是数据库性能的大量开销,特别是对长事务而言,这样的开销往往无法承受。相对悲观锁而言,乐观锁更倾向于开发运用。

悲观锁 具有强烈的独占和排他特性。它指的是对数据被外界(包括本系统当前的其他事务,以及来自外部系统的事务处理)修改持保守态度,因此,在整个数据处理过程中,将数据处于锁定状态。悲观锁的实现,往往依靠数据库提供的锁机制(也只有数据库层提供的锁机制才能真正保证数据访问的排他性,否则,即使在本系统中实现了加锁机制,也无法保证外部系统不会修改数据)。

按粒度

表级锁 是MySQL中锁定粒度最大的一种锁,表示对当前操作的整张表加锁,它实现简单,资源消耗较少,被大部分MySQL引擎支持。最常使用的MyISAM与InnoDB都支持表级锁定。表级锁分为表共享读锁与表独占写锁。

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

页级锁 是MySQL中锁定粒度介于行级锁和表级锁中间的一种锁。表级锁速度快,但冲突多,行级冲突少,但速度慢。所以取了折衷的页级,一次锁定相邻的一组记录。BDB支持页级锁。

这里需要说明的是,悲观锁是一种思想,它的实现是使用了 共享锁与排他锁来实现的。因此悲观锁本身并不是MySQL实现的锁机制,它是我们造出来的一个概念。

另外,我看到很多文章在讲悲观锁时,只说排他锁是悲观锁机制,没有说共享锁是什么机制,而我认为共享锁也属于悲观锁,具体原因往后看。

InnoDB中加锁

InnoDB 实现了两种类型的行锁,共享锁(S)与排他锁(X)。然后由于 InnoDB引擎又支持表级锁,所以它内部又有意向共享锁(IS)与意向排他锁(IX)。这两种表锁,都是InnoDB内部自动处理,换句话说我们写代码是无法控制也不需要控制的。我们能控制的是S与X锁。

为MySQL加锁

在日常操作中,UPDATEINSERTDELETE InnoDB会自动给涉及的数据集加排他锁,一般的 SELECT 一般是不加任何锁的。我们可以使用以下方式显示的为 SELECT 加锁。

  • 共享锁:select * from table_name where id =10 lock in share mode;
  • 排他锁:select * from table_name where id=10 for update;

那么什么时候该用共享锁什么时候用排他锁呢?
一般来讲,共享锁主要用在需要数据依存关系时来确认某行记录是否存在,并确保没有人对这个记录进行UPDATE或者DELETE操作(包括加锁的事物也只能读)。简单说就是大家都可以读数据,但是无法修改(更新或者删除),因此我认为::共享锁也是悲观锁::的一种实现。
排他锁是与共享锁相对应,自身加排他锁的事物能够自己发起修改操作,其它事物无法再对该数据加共享或者排他锁。

这里需要注意上面说到的一点,由于InnoDB引擎是行锁,不管我们在这条数据上加了共享锁还是排他锁,简单的select语句依然可以使用的,因为默认在InnoDB中select是不加锁的。

这里还有一点,上图中我们写到一个 间隙锁,这是什么东西?比如:当我们用范围条件而不是相等条件检索数据,并请求共享或排他锁时,InnoDB会给符合条件的已有数据的索引项加锁;对于键值在条件范围内但并不存在的记录,叫做“间隙(GAP)”,InnoDB也会对这个“间隙”加锁,这种锁机制就是所谓的间隙锁(Next-Key锁)。它存在的主要目的有一个是为了解决幻读问题,因为RR作为InnoDB的默认事物隔离级别,是存在幻读问题的,而我们在实际操作中确没有出现,就是因为这里做了处理。

关于乐观锁是如何加锁的,这个不同系统有不同的实现,简单来说,对每一个数据维护一个版本号,每次读取时把版本号读取出来,更新时版本号+1。然后更新时将读取的版本号作为条件,如果有其它事物更新了,那么必然会导致版本号变化,因为本次更新不会成功。这种机制最大程度的保证了并发。

查看锁情况

1
2
3
4
5
6
7
8
9
10
11
mysql> show status like 'innodb_row_lock%';
+-------------------------------+--------+
| Variable_name | Value |
+-------------------------------+--------+
| Innodb_row_lock_current_waits | 0 |
| Innodb_row_lock_time | 218276 |
| Innodb_row_lock_time_avg | 18189 |
| Innodb_row_lock_time_max | 51058 |
| Innodb_row_lock_waits | 12 |
+-------------------------------+--------+
5 rows in set (0.05 sec)

上面的语句能够展示当前系统锁的情况,当系统锁争用比较严重的时候,Innodb_row_lock_waitsInnodb_row_lock_time_avg 的值会比较高。上面的数据是由于我做实验导致的。大家可以检查下自己的系统。

InnoDB什么时候会锁表

我们常常说InnoDB是行锁,但是这里介绍一下它锁表的情况。

InnoDB行锁是通过索引上的索引项来实现的,这一点MySQL与Oracle不同,后者是通过在数据中对相应数据行加锁来实现的。InnoDB这种行锁实现特点意味者:只有通过索引条件检索数据,InnoDB才会使用行级锁,否则,InnoDB将使用表锁!
在实际应用中,要特别注意InnoDB行锁的这一特性,不然的话,可能导致大量的锁冲突,从而影响并发性能。

这里我们实际演示一下,一来让大家了解如何测试锁情况,二来这样用例子也许更容易说明问题。表结构如下:

1
2
3
4
5
6
7
Create Table: CREATE TABLE `tb_user` (
`id` int(11) unsigned NOT NULL AUTO_INCREMENT,
`name` varchar(50) NOT NULL DEFAULT '',
`age` mediumint(9) NOT NULL DEFAULT '1',
`money` int(11) NOT NULL DEFAULT '0',
PRIMARY KEY (`id`)
) ENGINE=InnoDB AUTO_INCREMENT=8 DEFAULT CHARSET=utf8

我们看当where条件不是索引时,如果加了排他锁,对这个表其它行记录也不能再加排他锁了,这明显就是锁住了整个表。而如果条件是索引字段,则它只会对where条件指定的行数据加锁,另一个事务可以对其它行数据加锁。

有些文章说只有表没有索引才会锁表,通过上面的实验,我觉得是不准确的。

总结

  • 悲观锁与乐观锁是一种思想,而不是数据库锁机制的实现;
  • InnoDB的行销是基于索引实现的,如果不通过索引访问数据,InnoDB会使用表锁;
  • 虽然根据标准InnoDB的默认事务隔离级别RR是存在幻读,但是通过间隙锁以及MVCC解决了幻读的问题;
  • 间隙锁的存在会导致并发插入问题,尽量减少范围查询;
  • InnoDB的索引设计非常重要。