深入理解MySQL锁

锁的分类及特性

数据库锁定机制简单来说,就是数据库为了保证数据的一致性,而使各种共享资源在被并发访问时变得有序所设计的一种规则。

对于任何一种数据库来说都需要有相应的锁定机制,所以 MySQL 自然也不能例外。

MySQL 数据库由于其自身架构的特点,存在多种数据存储引擎,每种存储引擎所针对的应用场景特点都不太一样。

为了满足各自特定应用场景的需求,每种存储引擎的锁定机制都是为各自所面对的特定场景而优化设计,所以各存储引擎的锁定机制也有较大区别。

MySQL 各存储引擎使用了三种类型(级别)的锁定机制:

1 、表级锁定(table-level)

表级别的锁定是 MySQL 各存储引擎中最大颗粒度的锁定机制。该锁定机制最大的特点是实现逻辑非常简单,带来的系统负面影响最小。

所以获取锁和释放锁的速度很快。由于表级锁定一次会将整个表锁定,所以可以很好的避免困扰我们的死锁问题。

当然,锁定颗粒度大所带来最大的负面影响就是出现锁定资源争用的概率也会最高,致使并大度大打折扣。

使用表级锁定的主要是 MyISAM,MEMORY,CSV 等一些非事务性存储引擎。 

MySQL 的表级锁有两种模式:

  • 表共享读锁(Table Read Lock)
  • 表独占写锁(Table Write Lock)
MyISAM 在执行查询语句(SELECT)前,会自动给涉及的所有表加读锁,在执行更新操作(UPDATE、DELETE、INSERT等)前,会自动给涉及的表加写锁。
这个过程并不需要用户干预,因此,用户一般不需要直接用 LOCK TABLE 命令给 MyISAM 表显式加锁。

显示锁sql语句

共享读锁:lock table tableName read

独占写锁:lock table tableName write

同时加多锁:lock table t1 write,t2 read

批量解锁:unlock tables

MyISAM 表锁优化建议

由于锁定级别是不可能改变的了,所以我们首先需要尽可能让锁定的时间变短,然后就是让可能并发进行的操作尽可能的并发。

  • 查询表级锁争用情况
mysql> show status like 'table%';
+----------------------------+-------+
| Variable_name              | Value |
+----------------------------+-------+
| Table_locks_immediate      | 348   |
| Table_locks_waited         | 0     |
| Table_open_cache_hits      | 0     |
| Table_open_cache_misses    | 1     |
| Table_open_cache_overflows | 1     |
+----------------------------+-------+
5 rows in set (0.00 sec)
  1. Table_locks_immediate产生表级锁定的次数。
  2. Table_locks_waited出现表级锁定争用而发生等待的次数;此值越高则说明存在着越严重的表级锁争用情况。
  3. Table_open_cache_hits:打开表缓存查找的命中数。
  4. Table_open_cache_misses:打开表缓存查找的未命中数。
  5. Table_open_cache_overflows:打开表缓存溢出数。

Table_locks_immediate值和Table_locks_waited是从系统启动后开始记录,出现一次对应的事件则数量加 1。如果这里的 Table_locks_waited 状态值比较高,那么说明系统中表级锁定争用现象比较严重,就需要进一步分析为什么会有较多的锁定资源争用了。

  • 缩短锁定时间

唯一的办法就是让我们的 Query 执行时间尽可能的短

  1. 尽量减少大的复杂 Query,将复杂 Query 分拆成几个小的 Query 分布进行。
  2. 尽可能的建立足够高效的索引,让数据检索更迅速。
  3. 尽量让 MyISAM 存储引擎的表只存放必要的信息,控制字段类型。
  4. 利用合适的机会优化 MyISAM 表数据文件。
  • 分离能并行的操作

MyISAM 存储引擎有一个控制是否打开 Concurrent Insert 功能的参数选项:

concurrent_insert=2,无论 MyISAM 表中有没有空洞,都允许在表尾并发插入记录。
concurrent_insert=1,如果 MyISAM 表中没有空洞(即表的中间没有被删除的行),MyISAM 允许在一个进程读表的同时,另一个进程从表尾插入记录。这也是 MySQL 的默认设置。
concurrent_insert=0,不允许并发插入

可以利用 MyISAM 存储引擎的并发插入特性,来解决应用中对同一表查询和插入的锁争用。

例如,将 concurrent_insert 系统变量设为 2,总是允许并发插入;同时,通过定期在系统空闲时段执行 OPTIMIZE TABLE 语句来整理空间碎片,收回因删除记录而产生的中间空洞。
  • 合理利用读写优先级
  1. 通过执行命令 SET LOW_PRIORITY_UPDATES=1,使该连接读比写的优先级高,如果我们的系统是一个以读为主,可以设置此参数,如果以写为主,则不用设置。
  2. 通过指定 INSERT、UPDATE、DELETE 语句的 LOW_PRIORITY 属性,降低该语句的优先级。
  3. MySQL 也提供了一种折中的办法来调节读写冲突,即给系统参数 max_write_lock_count 设置一个合适的值,当一个表的读锁达到这个值后,MySQL 就暂时将写请求的优先级降低,给读进程一定获得锁的机会。
  4. 需要长时间运行的查询操作,也会使写进程“饿死”,尽量避免出现长时间运行的查询操作,不要总想用一条 SELECT 语句来解决问题,因为这种看似巧妙的 SQL 语句,往往比较复杂,执行时间较长。
  5. 多表级联。事务涉及多个表,比较复杂的关联查询,很可能引起死锁,造成大量事务回滚,这种情况若能一次性锁定事务涉及的表,从而可以避免死锁、减少数据库因事务回滚带来的开销。

2、行级锁定(row-level)

行级锁定最大的特点就是锁定对象的颗粒度很小,也是目前各大数据库管理软件所实现的锁定颗粒度最小的。

由于锁定颗粒度很小,所以发生锁定资源争用的概率也最小,能够给予应用程序尽可能大的并发处理能力而提高一些需要高并发应用系统的整体性能。

虽然能够在并发处理能力上面有较大的优势,但是行级锁定也因此带来了不少弊端。

由于锁定资源的颗粒度很小,所以每次获取锁和释放锁需要做的事情也更多,带来的消耗自然也就更大了。

此外,行级锁定也最容易发生死锁。使用行级锁定的主要是 InnoDB 存储引擎。

3、页级锁定(page-level)

页级锁定是 MySQL 中比较独特的一种锁定级别,在其他数据库管理软件中也并不是太常见。

页级锁定的特点是锁定颗粒度介于行级锁定与表级锁之间,所以获取锁定所需要的资源开销,以及所能提供的并发处理能力也同样是介于上面二者之间。另外,页级锁定和行级锁定一样,会发生死锁。

在数据库实现资源锁定的过程中,随着锁定资源颗粒度的减小,锁定相同数据量的数据所需要消耗的内存数量是越来越多的,实现算法也会越来越复杂。

不过,随着锁定资源颗粒度的减小,应用程序的访问请求遇到锁等待的可能性也会随之降低,系统整体并发度也随之提升。使用页级锁定的主要是 BerkeleyDB 存储引擎。


InnoDB 的锁定模式实际上可以分为四种:

  1. 共享锁(S)
  2. 排他锁(X)
  3. 意向共享锁(IS)
  4. 意向排他锁(IX)

意向锁是 InnoDB 自动加的,不需用户干预:

  1. 对于 UPDATE、DELETE 和 INSERT 语句,InnoDB 会自动给涉及数据集加排他锁(X)。
  2. 对于普通 SELECT 语句,InnoDB 不会加任何锁。

事务可以通过以下语句显示给记录集加共享锁或排他锁:

共享锁(S):SELECT * FROM table_name WHERE ... LOCK IN SHARE MODE
排他锁(X):SELECT * FROM table_name WHERE ... FOR UPDATE

用 SELECT ... IN SHARE MODE 获得共享锁,主要用在需要数据依存关系时来确认某行记录是否存在,并确保没有人对这个记录进行 UPDATE 或者 DELETE 操作。

但是如果当前事务也需要对该记录进行更新操作,则很有可能造成死锁,对于锁定行记录后需要进行更新操作的应用,应该使用 SELECT... FOR UPDATE 方式获得排他锁。

  • InnoDB 行锁实现方式

InnoDB 行锁是通过给索引上的索引项加锁来实现的,只有通过索引条件检索数据,InnoDB 才使用行级锁,否则,InnoDB 将使用表锁。

实际例子来加以说明:

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

当我们用范围条件而不是相等条件检索数据,并请求共享或排他锁时,InnoDB 会给符合条件的已有数据记录的索引项加锁。

对于键值在条件范围内但并不存在的记录,叫做“间隙(GAP)”,InnoDB 也会对这个“间隙”加锁,这种锁机制就是所谓的间隙锁(Next-Key锁)。

假如 emp 表中只有 101 条记录,其 empid 的值分别是 1,2,...,100,101,下面的 SQL:

mysql> select * from emp where empid > 100 for update;

这是一个范围条件的检索,InnoDB 不仅会对符合条件的 empid 值为 101 的记录加锁,也会对 empid 大于 101(这些记录并不存在)的“间隙”加锁。

InnoDB 使用间隙锁的目的:

  1. 防止幻读,以满足相关隔离级别的要求(关于事务的隔离级别)。对于上面的例子,要是不使用间隙锁,如果其他事务插入了 empid 大于 100 的任何记录,那么本事务如果再次执行上述语句,就会发生幻读。
  2. 为了满足其恢复和复制的需要。很显然,在使用范围条件检索并锁定记录时,即使某些不存在的键值也会被无辜的锁定,而造成在锁定的时候无法插入锁定键值范围内的任何数据。在某些场景下这可能会对性能造成很大的危害。

通过索引实现锁定的方式带来的性能隐患

  1. 当 Query 无法利用索引的时候,InnoDB 会放弃使用行级别锁定而改用表级别的锁定,造成并发性能的降低。
  2. 当 Query 使用的索引并不包含所有过滤条件的时候,数据检索使用到的索引键所指向的数据可能有部分并不属于该 Query 的结果集的行列,但是也会被锁定,因为间隙锁锁定的是一个范围,而不是具体的索引键。
  3. 当 Query 在使用索引定位数据的时候,如果使用的索引键一样但访问的数据行不同的时候(索引只是过滤条件的一部分),一样会被锁定。

在实际应用开发中,尤其是并发插入比较多的应用,我们要尽量优化业务逻辑,尽量使用相等条件来访问更新数据,避免使用范围条件。还要特别说明的是,InnoDB 除了通过范围条件加锁时使用间隙锁外,如果使用相等条件请求给一个不存在的记录加锁,InnoDB 也会使用间隙锁。

  • 死锁

在 InnoDB 的事务管理和锁定机制中,有专门检测死锁的机制,会在系统中产生死锁之后的很短时间内就检测到该死锁的存在。

当 InnoDB 检测到系统中产生了死锁之后,InnoDB 会通过相应的判断来选这产生死锁的两个事务中较小的事务来回滚,而让另外一个较大的事务成功完成。

如果大量事务因无法立即获得所需的锁而挂起,会占用大量计算机资源,造成严重性能问题,甚至拖跨数据库。我们通过设置合适的锁等待超时阈值,可以避免这种情况发生。

通常来说,死锁都是应用设计的问题,通过调整业务流程、数据库对象设计、事务大小,以及访问数据库的 SQL 语句,绝大部分死锁都可以避免。

避免死锁的常用方法

  1. 在应用中,如果不同的程序会并发存取多个表,应尽量约定以相同的顺序来访问表,这样可以大大降低产生死锁的机会。
  2. 在程序以批量方式处理数据的时候,如果事先对数据排序,保证每个线程按固定的顺序来处理记录,也可以大大降低出现死锁的可能。
  3. 在事务中,如果要更新记录,应该直接申请足够级别的锁,即排他锁,而不应先申请共享锁,更新时再申请排他锁,因为当用户申请排他锁时,其他事务可能又已经获得了相同记录的共享锁,从而造成锁冲突,甚至死锁。
  4. 在 REPEATABLE-READ 隔离级别下,如果两个线程同时对相同条件记录用 SELECT...FOR UPDATE 加排他锁,在没有符合该条件记录情况下,两个线程都会加锁成功,程序发现记录尚不存在,就试图插入一条新记录,如果两个线程都这么做,就会出现死锁。这种情况下,将隔离级别改成 READ COMMITTED,就可避免问题。
  5. 当隔离级别为 READ COMMITTED 时,如果两个线程都先执行 SELECT...FOR UPDATE,判断是否存在符合条件的记录,如果没有,就插入记录。此时,只有一个线程能插入成功,另一个线程会出现锁等待,当第一个线程提交后,第二个线程会因主键重出错,但虽然这个线程出错了,却会获得一个排他锁。这时如果有第三个线程又来申请排他锁,也会出现死锁。对于这种情况,可以直接做插入操作,然后再捕获主键重异常,或者在遇到主键重错误时,总是执行 ROLLBACK 释放获得的排他锁。
  • 什么时候使用表锁

对于 InnoDB 表,在绝大部分情况下都应该使用行级锁,因为事务和行锁往往是我们之所以选择 InnoDB 表的理由。

事务需要更新大部分或全部数据,表又比较大,如果使用默认的行锁,不仅这个事务执行效率低,而且可能造成其他事务长时间锁等待和锁冲突,这种情况下可以考虑使用表锁来提高该事务的执行速度。

事务涉及多个表,比较复杂,很可能引起死锁,造成大量事务回滚。这种情况也可以考虑一次性锁定事务涉及的表,从而避免死锁、减少数据库因事务回滚带来的开销。

在 InnoDB 下,使用表锁要注意以下两点:

  1. 使用 LOCK TABLES 虽然可以给 InnoDB 加表级锁,但必须说明的是,表锁不是由 InnoDB 存储引擎层管理的,而是由其上一层──MySQL Server 负责的。仅当 autocommit=0(不自动提交,默认是自动提交的)、InnoDB_table_locks=1(默认设置)时,InnoDB 层才能知道 MySQL 加的表锁,MySQL Server 也才能感知 InnoDB 加的行锁。这种情况下,InnoDB 才能自动识别涉及表级锁的死锁,否则,InnoDB 将无法自动检测并处理这种死锁。
  2. 在用 LOCK TABLES 对 InnoDB 表加锁时要注意,要将 AUTOCOMMIT 设为 0,否则 MySQL 不会给表加锁。事务结束前,不要用 UNLOCK TABLES 释放表锁,因为 UNLOCK TABLES 会隐含地提交事务。COMMIT 或 ROLLBACK 并不能释放用 LOCK TABLES 加的表级锁,必须用 UNLOCK TABLES 释放表锁。

正确的方式见如下语句,例如,如果需要写表 t1 并从表 t 读,可以按如下做:

SET AUTOCOMMIT=0;
LOCK TABLES t1 WRITE, t2 READ, ...;
[do something with tables t1 and t2 here];
COMMIT;
UNLOCK TABLES;
  • InnoDB 行锁优化建议

合理利用 InnoDB 的行级锁定,做到扬长避短

  1. 尽可能让所有的数据检索都通过索引来完成,从而避免 InnoDB 因为无法通过索引键加锁而升级为表级锁定。
  2. 合理设计索引,让 InnoDB 在索引键上面加锁的时候尽可能准确,尽可能的缩小锁定范围,避免造成不必要的锁定而影响其他 Query 的执行。
  3. 尽可能减少基于范围的数据检索过滤条件,避免因为间隙锁带来的负面影响而锁定了不该锁定的记录。
  4. 尽量控制事务的大小,减少锁定的资源量和锁定时间长度。
  5. 在业务环境允许的情况下,尽量使用较低级别的事务隔离,以减少 MySQL 因为实现事务隔离级别所带来的附加成本。

由于 InnoDB 的行级锁定和事务性,所以肯定会产生死锁,减少死锁产生概率:

  1. 类似业务模块中,尽可能按照相同的访问顺序来访问,防止产生死锁。
  2. 在同一个事务中,尽可能做到一次锁定所需要的所有资源,减少死锁产生概率。
  3. 对于非常容易产生死锁的业务部分,可以尝试使用升级锁定颗粒度,通过表级锁定来减少死锁产生的概率。

可以通过检查 InnoDB_row_lock 状态变量来分析系统上的行锁的争夺情况:

mysql> show status like 'InnoDB_row_lock%';
+-------------------------------+-------+
| Variable_name                 | Value |
+-------------------------------+-------+
| Innodb_row_lock_current_waits | 0     |
| Innodb_row_lock_time          | 0     |
| Innodb_row_lock_time_avg      | 0     |
| Innodb_row_lock_time_max      | 0     |
| Innodb_row_lock_waits         | 0     |
+-------------------------------+-------+
5 rows in set (0.03 sec)

InnoDB 的行级锁定状态变量不仅记录了锁定等待次数,还记录了锁定总时长,每次平均时长,以及最大时长,此外还有一个非累积状态量显示了当前正在等待锁定的等待数量。

对各个状态量的说明如下:

  • InnoDB_row_lock_current_waits当前正在等待锁定的数量。
  • InnoDB_row_lock_time从系统启动到现在锁定总时间长度。
  • InnoDB_row_lock_time_avg每次等待所花平均时间。
  • InnoDB_row_lock_time_max从系统启动到现在等待最常的一次所花的时间。
  • InnoDB_row_lock_waits系统启动后到现在总共等待的次数。

当等待次数(InnoDB_row_lock_time_avg)很高,而且每次等待时长也不小的时候,我们就需要分析系统

通过设置 InnoDB Monitors 来进一步观察发生锁冲突的表、数据行等,并分析锁争用的原因

mysql> create table innodb_table(a INT) engine=InnoDB;
Query OK, 0 rows affected (0.02 sec)

mysql> show engine InnoDB status;
2019-09-22 02:54:49 2b329beea700 INNODB MONITOR OUTPUT
=====================================
Per second averages calculated from the last 60 seconds
-----------------
BACKGROUND THREAD
-----------------
srv_master_thread loops: 4 srv_active, 0 srv_shutdown, 229150 srv_idle
srv_master_thread log flush and writes: 229153
----------
SEMAPHORES
----------
OS WAIT ARRAY INFO: reservation count 5
OS WAIT ARRAY INFO: signal count 5
Mutex spin waits 2, rounds 30, OS waits 0
RW-shared spins 6, rounds 180, OS waits 5
RW-excl spins 0, rounds 0, OS waits 0
Spin rounds per wait: 15.00 mutex, 30.00 RW-shared, 0.00 RW-excl
------------
TRANSACTIONS
------------
Trx id counter 4881
Purge done for trx's n:o < 4878 undo n:o < 0 state: running but idle
History list length 3
LIST OF TRANSACTIONS FOR EACH SESSION:
---TRANSACTION 0, not started
MySQL thread id 79, OS thread handle 0x2b329beea700, query id 719 localhost root init
show engine InnoDB status
--------
FILE I/O
--------
I/O thread 0 state: waiting for completed aio requests (insert buffer thread)
I/O thread 1 state: waiting for completed aio requests (log thread)
I/O thread 2 state: waiting for completed aio requests (read thread)
I/O thread 3 state: waiting for completed aio requests (write thread)
Pending normal aio reads: 0, aio writes: 0,
 ibuf aio reads: 0, log i/o's: 0, sync i/o's: 0
Pending flushes (fsync) log: 0; buffer pool: 0
188 OS file reads, 77 OS file writes, 38 OS fsyncs
0.00 reads/s, 0 avg bytes/read, 0.40 writes/s, 0.20 fsyncs/s
-------------------------------------
INSERT BUFFER AND ADAPTIVE HASH INDEX
-------------------------------------
Ibuf: size 1, free list len 0, seg size 2, 0 merges
merged operations:
 insert 0, delete mark 0, delete 0
discarded operations:
 insert 0, delete mark 0, delete 0
Hash table size 34649, node heap has 0 buffer(s)
0.00 hash searches/s, 0.28 non-hash searches/s
---
LOG
---
Log sequence number 1660995
Log flushed up to   1660995
Pages flushed up to 1660995
Last checkpoint at  1660995
0 pending log writes, 0 pending chkp writes
21 log i/o's done, 0.07 log i/o's/second
----------------------
BUFFER POOL AND MEMORY
----------------------
Total memory allocated 17170432; in additional pool allocated 0
Dictionary memory allocated 63841
Buffer pool size   1023
Free buffers       839
Database pages     184
Old database pages 0
Modified db pages  0
Pending reads 0
Pending writes: LRU 0, flush list 0, single page 0
Pages made young 0, not young 0
0.00 youngs/s, 0.00 non-youngs/s
Pages read 172, created 12, written 51
0.00 reads/s, 0.07 creates/s, 0.27 writes/s
Buffer pool hit rate 1000 / 1000, young-making rate 0 / 1000 not 0 / 1000
Pages read ahead 0.00/s, evicted without access 0.00/s, Random read ahead 0.00/s
LRU len: 184, unzip_LRU len: 0
I/O sum[0]:cur[0], unzip sum[0]:cur[0]
--------------
ROW OPERATIONS
--------------
0 queries inside InnoDB, 0 queries in queue
0 read views open inside InnoDB
Main thread process no. 1841, id 47496355522304, state: sleeping
Number of rows inserted 0, updated 0, deleted 0, read 0
0.00 inserts/s, 0.00 updates/s, 0.00 deletes/s, 0.00 reads/s

mysql> drop table innodb_table;
Query OK, 0 rows affected (0.01 sec)
  • 查看死锁、解除锁

查询是否锁表

show OPEN TABLES where In_use > 0;

查询进程(如果您有 SUPER 权限,您可以看到所有线程。否则,您只能看到您自己的线程)

show processlist

杀死进程 id(就是上面命令的 id 列)

kill id

查看下在锁的事务

SELECT * FROM INFORMATION_SCHEMA.INNODB_TRX;

杀死进程 id(就是上面命令的 trx_mysql_thread_id 列)

kill 线程ID

查看当前的事务

SELECT * FROM INFORMATION_SCHEMA.INNODB_TRX;

查看当前锁定的事务

SELECT * FROM INFORMATION_SCHEMA.INNODB_LOCKS;

查看当前等锁的事务

SELECT * FROM INFORMATION_SCHEMA.INNODB_LOCK_WAITS;

总的来说,MySQL 这三种锁的特性可大致归纳如下:

  • 表级锁:开销小,加锁快;不会出现死锁;锁定粒度大,发生锁冲突的概率最高,并发度最低。
  • 行级锁:开销大,加锁慢;会出现死锁;锁定粒度最小,发生锁冲突的概率最低,并发度也最高。
  • 页面锁:开销和加锁时间界于表锁和行锁之间;会出现死锁;锁定粒度界于表锁和行锁之间,并发度一般。

适用:从锁的角度来说,表级锁更适合于以查询为主,只有少量按索引条件更新数据的应用,如 Web 应用。

而行级锁则更适合于有大量按索引条件并发更新少量不同数据,同时又有并发查询的应用,如一些在线事务处理(OLTP)系统。 

编辑于 2019-09-22

文章被以下专栏收录