MySQL锁相关

1,213 阅读15分钟

前言

关于数据库锁,是一个很重要的知识点;

不少人在开发的时候,应该很少会注意到这些锁的问题,也很少会给程序加锁(除了库存这些对数量准确性要求极高的情况下);

一般也就听过常说的乐观锁和悲观锁,了解过基本的含义之后就没了,没有去实际的操作过,本文将简单的整理一下数据库锁的知识,希望对大家有所帮助;

引入

本文参考文章:数据库的两大神器

数据库锁

简介

在MySQL中锁看起来是很复杂的,因为有一大堆的东西和名词:排它锁,共享锁,表锁,页锁,间隙锁,意向排它锁,意向共享锁,行锁,读锁,写锁,乐观锁,悲观锁,死锁。这些名词有的博客又直接写锁的英文的简写--->X锁,S锁,IS锁,IX锁,MMVC等等之类。锁的相关知识又跟存储引擎,索引,事务的隔离级别都是关联的;

以上的一大堆锁可能很多人都只是知道一些概念,但是我们的程序在一般情况下还是可以跑得好好的。因为这些锁数据库隐式帮我们加了:

  • 对于UPDATE、DELETE、INSERT语句,InnoDB自动给涉及数据集加排他锁(X),也就是我们常说的写锁;
  • MyISAM在执行查询语句SELECT前,会自动给涉及的所有表加读锁,在执行更新操作(UPDATE、DELETE、INSERT等)前,会自动给涉及的表加写锁,这个过程并不需要用户干预

表锁和行锁

从锁的粒度我们可以分为两大类,它们各自的特点如下:

  • 表锁:开销小,加锁快;不会出现死锁;锁定力度大,发生锁冲突概率高,并发度低;
  • 行锁:开销大,加锁慢;会出现死锁;锁定力度小,发生锁冲突的概率低,并发度高;

同样,不同的存储引擎支持的锁的力度也不一样:

  • InnoDB:表锁行锁都支持(InnoDB的行锁是基于索引的 ,稍后会演示);
  • MyISAM:只支持表锁;

表锁

表锁也分为两种模式:

  • 表读锁(Table Read Lock)
  • 表写锁(Table Write Lock)
    • 读读不阻塞:当前用户读取数据,其他用户也在读取数据,不会加锁;
    • 读写阻塞:当前用户在读取数据的时候,其他用户不能修改当前用户读的数据;
    • 写写阻塞:当前用户在修改数据,其他用户不能修改当前用户正在修改的数据;

总结得到:

  1. 读读不阻塞,读写阻塞,写写阻塞
  2. 读锁和写锁是互斥的,读写操作是串行
  3. 在mysql里边,写锁是优先于读锁的

行锁

我们使用MySQL一般是使用的InnoDB引擎,上面也提到了InnoDB和MyISAM的一些区别:

  • InnoDB行锁表锁都支持,MyISAM只支持表锁;
  • InnoDB支持事务,MyISAM不支持;

InnoDB实现了以下两种类型的行锁:

  • 共享锁(s锁):允许一个事务去读一行,会阻止其他事务获取相同数据集的排他锁(读取数据的时候不允许修改)
    • 也被称为读锁:读锁是共享的,多个线程可以同时读取统一数据集,但是不允许其他线程进行修改(也就是不允许其他事务获取相同数据集的排他锁);
  • 排他锁(x锁):允许获取排他锁去做更新操作,阻止其他事务获取相通数据的共享锁和排他锁(一个事务修改数据的时候,阻止其他事务对相同数据集做更新或者查询操作);
    • 也被称为写锁:写锁是排他的,写锁会阻塞其他的写锁和读锁;

意向锁

为了允许行锁和表锁共存,实现多粒度锁机制,InnoDB还有两种内部使用的意向锁(Intention Locks),这两种意向锁都是表锁

  • 意向共享锁(IS):事务打算给数据行加共享锁,事务在给一个数据行加共享锁前必须先取得该表的IS锁;
  • 意向排他锁(IX):事务打算给数据行加排他锁,事务再给一个数据行加排他锁前必须先取得该表的IX锁;

意向锁也是数据库隐式帮我们做了,不需要程序员操心

表锁行锁测试

准备

上面我们提到了InnoDB支持行锁,但是是基于索引的情况,下面我们来实际的看一下:

  • 首先我们用客户端连接上MySQL数据库,为了测试锁的效果,我们需要打开两个或者两个以上的客户端(我打开了两个)然后创建一个数据库;

    CREATE DATABASE test CHARACTER SET utf8;
    
  • 然后我们需要建立一个表:

    CREATE TABLE `user` (
      `id` bigint(20) NOT NULL AUTO_INCREMENT,
      `username` varchar(255) NOT NULL,
      `age` int(11) NOT NULL,
      PRIMARY KEY (`id`)
    ) ENGINE=InnoDB CHARSET=utf8;
    
  • 我们简单的建了一个user表,表中有三个字段,其中id为自增主键,大家都知道主键是自带索引的,也就是聚簇索引(主键索引),其他的字段都是不带索引的。

  • mysql> show tables;
    +----------------+
    | Tables_in_test |
    +----------------+
    | user           |
    +----------------+
    1 row in set (0.01 sec)
    
  • 现在我们简单的往里面添加几条数据:

    INSERT INTO `user`(username,age) VALUES ('tom',23),('joey',22),('James',21),('William',20),('David',24);
    
    • mysql> select * from user;
      +----+----------+-----+
      | id | username | age |
      +----+----------+-----+
      |  1 | tom      |  23 |
      |  2 | joey     |  22 |
      |  3 | James    |  21 |
      |  4 | William  |  20 |
      |  5 | David    |  24 |
      +----+----------+-----+
      5 rows in set (0.00 sec)
      

测试

好的,现在前提都已经弄好了,我们可以开始测试了:

我们知道MySQL的事务是自动提交的,为了测试,我们需要把事务的自动提交关闭;

mysql> set  autocommit = 0;
Query OK, 0 rows affected (0.01 sec)

现在我们来查看一下MySQL的事务提交状态:

mysql> show VARIABLES like 'autocommit';
+---------------+-------+
| Variable_name | Value |
+---------------+-------+
| autocommit    | OFF   |
+---------------+-------+
1 row in set, 1 warning (0.04 sec)

从上面可以看出,我们把事务的自动提交已经关闭了,下面我们开始测试(打开的窗口都需要关闭事务的自动提交);

行锁测试

首先,我打开了两个窗口,分别为A和B,现在,我们两个窗口的状态都已经调整完毕(关闭事务自动提交)。我们在A窗口,输入以下语句:

mysql> begin;
Query OK, 0 rows affected (0.00 sec)

mysql> select * from user where id = 1 for update;
+----+----------+-----+
| id | username | age |
+----+----------+-----+
|  1 | tom      |  23 |
+----+----------+-----+
1 row in set (0.02 sec)

mysql>

很明显,以上语句中,打开了事务,然后执行了一条SQL语句,在select 语句后边加了 for update相当于加了排它锁(写锁),加了写锁以后,其他的事务就不能对它修改了!需要等待当前事务修改完提交之后才可以修改;

现在我们在窗口B执行相同的操作:

mysql> begin;
Query OK, 0 rows affected (0.00 sec)

mysql> select * from user where id = 1 for update;

-

注意到了吗,窗口B并没有数据出现,因为窗口A执行的时候加了排他锁,但是窗口A并没有提交事务,所以锁也没有得到释放,现在我们在窗口A提交事务:

mysql> begin;
Query OK, 0 rows affected (0.00 sec)

mysql> select * from user where id = 1 for update;
+----+----------+-----+
| id | username | age |
+----+----------+-----+
|  1 | tom      |  23 |
+----+----------+-----+
1 row in set (0.02 sec)

mysql> commit;
Query OK, 0 rows affected (0.00 sec)

同时,窗口B出现了以下情况:

mysql> begin;
Query OK, 0 rows affected (0.00 sec)

mysql> select * from user where id = 1 for update;
+----+----------+-----+
| id | username | age |
+----+----------+-----+
|  1 | tom      |  23 |
+----+----------+-----+
1 row in set (4.34 sec)

mysql>

没错,因为窗口A提交了事务,释放的排他锁,所以窗口B获取到了数据并重新为该数据添加了排他锁,所以此时你在A窗口在重复之前操作的时候还是会阻塞,因为窗口B没有提交事务,也就是没有释放排他锁;

现在,我们在窗口A执行以下语句:

mysql> begin;
Query OK, 0 rows affected (0.00 sec)

mysql> select * from user where id = 2 for update;
+----+----------+-----+
| id | username | age |
+----+----------+-----+
|  2 | joey     |  22 |
+----+----------+-----+
1 row in set (0.00 sec)

mysql>

有的同学可能会说,不对啊,我窗口B还没有提交事务,释放排他锁啊。

但是,大家注意看我的SQL语句,这次查的是id = 2的数据;

这是InnoDB的一大特性,我上面说了,InnoDB的行锁是基于索引的 ,因为此时我们的条件是基于主键的,而主键是自带索引的,所以加的是行锁,这个时候窗口A锁的是id = 2的这条数据,窗口B锁的是id = 1的这条数据,他们互不干扰;

表锁测试

现在,我们再来测试一下,没有索引,走表锁的情况;

我们上面有提过,InnoDB的行锁是基于索引,没有索引的话,锁住的就是整张表:

我们在窗口A输入执行以下操作:

mysql> begin;
Query OK, 0 rows affected (0.00 sec)

mysql> select * from user where age = 20 for update;
+----+----------+-----+
| id | username | age |
+----+----------+-----+
|  4 | William  |  20 |
+----+----------+-----+
1 row in set (0.04 sec)

mysql>

大家注意,这次的条件是使用的age,但是age是没有索引的,所以我们在B窗口执行相同的操作:

mysql> begin;
Query OK, 0 rows affected (0.00 sec)

mysql> select * from user where age = 20 for update;
-

很清楚的能看到,窗口B处于阻塞状态,我们换个条件继续执行:

mysql> begin;
Query OK, 0 rows affected (0.00 sec)

mysql> select * from user where age = 22 for update;
-

同样,尽管查询的数据换成了age = 22,但是还是会阻塞住,也就证明看不是锁的行;

我们再来试试换一个列作为条件:

mysql> begin;
Query OK, 0 rows affected (0.00 sec)

mysql> select * from user where id = 1 for update;
-

同样的结果,我们现在在A窗口提交事务,再来看一下B窗口:

A:

mysql> begin;
Query OK, 0 rows affected (0.00 sec)

mysql> select * from user where age = 20 for update;
+----+----------+-----+
| id | username | age |
+----+----------+-----+
|  4 | William  |  20 |
+----+----------+-----+
1 row in set (0.04 sec)

mysql> commit;
Query OK, 0 rows affected (0.00 sec)

B:

mysql> begin;
Query OK, 0 rows affected (0.00 sec)

mysql> select * from user where id = 1 for update;
+----+----------+-----+
| id | username | age |
+----+----------+-----+
|  1 | tom      |  23 |
+----+----------+-----+
1 row in set (0.00 sec)

mysql>

当窗口A提交事务后,也就释放了锁,这个时候窗口B获取到了锁,得到了数据,并锁住了id = 1的这一行数据;

联合索引测试

关于联合索引中,需要注意的一点就是最左匹配原则 ,说白了就是查询是否走了索引,如果走了索引,同样加的还是行锁,否则锁的还是表,下面我们来看一下。首先,我们需要把表中的username和age建一个联合索引:

mysql> create index index_username_age on user(username,age);
Query OK, 0 rows affected (0.04 sec)
Records: 0  Duplicates: 0  Warnings: 0

mysql> show index from user;
+-------+------------+--------------------+--------------+-------------+-----------+-------------+----------+--------+------+------------+---------+---------------+
| Table | Non_unique | Key_name           | Seq_in_index | Column_name | Collation | Cardinality | Sub_part | Packed | Null | Index_type | Comment | Index_comment |
+-------+------------+--------------------+--------------+-------------+-----------+-------------+----------+--------+------+------------+---------+---------------+
| user  |          0 | PRIMARY            |            1 | id          | A         |           5 |     NULL | NULL   |      | BTREE      |         |               |
| user  |          1 | index_username_age |            1 | username    | A         |           4 |     NULL | NULL   |      | BTREE      |         |               |
| user  |          1 | index_username_age |            2 | age         | A         |           5 |     NULL | NULL   |      | BTREE      |         |               |
+-------+------------+--------------------+--------------+-------------+-----------+-------------+----------+--------+------+------------+---------+---------------+
3 rows in set (0.00 sec)

mysql>

上面可以看出,我们建立联合索引成功,下面我们开始测试,首先,我们在窗口A执行以下操作:

mysql> begin;
Query OK, 0 rows affected (0.00 sec)

mysql> select * from user where username='tom' and age = 20 for update;
+----+----------+-----+
| id | username | age |
+----+----------+-----+
|  1 | tom      |  20 |
+----+----------+-----+
1 row in set (0.00 sec)

mysql>

可以看出,和我们之前的操作没啥两样,同样是打开事务进行操作,现在我们在窗口B执行以下操作:

mysql> begin;
Query OK, 0 rows affected (0.00 sec)

mysql>  select * from user where username='tom' and age = 20 for update;
-

很清楚的看到B窗口被锁住了,但是我们现在确定的是加的锁,并不知道是行锁还是表锁,没关系,我们换个条件:

mysql> begin;
Query OK, 0 rows affected (0.00 sec)

mysql>  select * from user where username='joey' and age = 22 for update;
+----+----------+-----+
| id | username | age |
+----+----------+-----+
|  2 | joey     |  22 |
+----+----------+-----+
1 row in set (0.00 sec)

mysql>

这样,我们很清楚的就能看到走的是行锁了。

只不过大家要注意联合索引的命中规则也就是最左匹配原则,我们可以试一试单独使用username作为条件看看走的什么锁,也可以看看单独使用age走的什么锁,这里就不再演示了,大家可以自行的尝试。

总结

前提:必须在事务里面

样例:select * from table where column = condition for update;

结果:

  • 当coulmn是索引列的时候,也就是查询走的索引的时候,这个时候锁的就是行(行锁);
  • 当coulmn不是索引的时候,也就是查询没走索引的时候,这个时候锁的就是整个表(表锁);

悲观锁

含义

悲观锁是从数据库层面加锁。总是假设最坏的情况,每次去拿数据的时候都认为别人会修改,所以每次在拿数据的时候都会上锁,这样别人想拿这个数据就会阻塞直到它释放锁;

例子

上面其实关于行锁和表锁的测试那里我们使用的排他锁也就是悲观锁;

select * from table where xxx  for update

在上面我们举的例子够多了,这里不再多说;

乐观锁

含义

总是假设最好的情况,每次去拿数据的时候都认为别人不会修改,所以不会上锁,但是在更新的时候会判断一下在此期间别人有没有去更新这个数据;

例子

表中有一个版本字段,第一次读的时候,获取到这个字段。处理完业务逻辑开始更新的时候,需要再次查看该字段的值是否和第一次的一样。如果一样就更新,反之拒绝。之所以叫乐观,因为这个模式没有从数据库加锁,等到更新的时候再判断是否可以更新。

update table set xxx where id = 1 and version =  1;

上面的语句就很清楚的说明了乐观锁,在对id = 1的数据进行更新的同时添加了version = 1的条件,version是当前事务开始之前查询出来的版本号,如果这个时候其他事务对id = 1的数据进行了更新会将version+1,所以如果其他事务进行了更新,这条语句是执行不成功的;

参考文章:juejin.cn/post/684490…

间隙锁GAP

当我们用范围条件检索数据而不是相等条件检索数据,并请求共享或排他锁时,InnoDB会给符合范围条件的已有数据记录的索引项加锁;对于键值在条件范围内但并不存在的记录,叫做“间隙(GAP)”。InnoDB也会对这个“间隙”加锁,这种锁机制就是所谓的间隙锁。

值得注意的是:间隙锁只会在Repeatable read隔离级别下使用~

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

Select * from  emp where empid > 100 for update;

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

InnoDB使用间隙锁的目的有两个:

  • 为了防止幻读(上面也说了,Repeatable read隔离级别下再通过GAP锁即可避免了幻读)
  • 满足恢复和复制的需要
    • MySQL的恢复机制要求:在一个事务未提交前,其他并发事务不能插入满足其锁定条件的任何记录,也就是不允许出现幻读

死锁

并发的问题就少不了死锁,在MySQL中同样会存在死锁的问题。

但一般来说MySQL通过回滚帮我们解决了不少死锁的问题了,但死锁是无法完全避免的,可以通过以下的经验参考,来尽可能少遇到死锁:

  • 1)以固定的顺序访问表和行。比如对两个job批量更新的情形,简单方法是对id列表先排序,后执行,这样就避免了交叉等待锁的情形;将两个事务的sql顺序调整为一致,也能避免死锁。
  • 2)大事务拆小。大事务更倾向于死锁,如果业务允许,将大事务拆小。
  • 3)在同一个事务中,尽可能做到一次锁定所需要的所有资源,减少死锁概率。
  • 4)降低隔离级别。如果业务允许,将隔离级别调低也是较好的选择,比如将隔离级别从RR调整为RC,可以避免掉很多因为gap锁造成的死锁。
  • 5)为表添加合理的索引。可以看到如果不走索引将会为表的每一行记录添加上锁,死锁的概率大大增大。

总结

本文介绍了MySQL数据锁以及事务的一些知识点,下面我们来总结一下;

不同的存储引擎支持的锁的力度也不一样:

  • InnoDB:表锁行锁都支持(也做了演示);
    • 当查询走的索引的时候,这个时候锁的就是行;
    • 当查询没走的索引的时候,这个时候锁的就是表;
  • MyISAM:只支持表锁;

数据库锁从锁的粒度我们可以分为两大类,它们各自的特点如下::

  • 表锁:开销小,加锁快;不会出现死锁;锁定力度大,发生锁冲突概率高,并发度低;
  • 行锁:开销大,加锁慢;会出现死锁;锁定力度小,发生锁冲突的概率低,并发度高;

悲观锁:总是假设最好的情况,每次去拿数据的时候都认为别人不会修改,所以不会上锁,但是在更新的时候会判断一下在此期间别人有没有去更新这个数据;

乐观锁:总是假设最好的情况,每次去拿数据的时候都认为别人不会修改,所以不会上锁,但是在更新的时候会判断一下在此期间别人有没有去更新这个数据;

最后

最后说一下,本文的参考文章:数据库的两大神器

大家可以去看一下原文,本人也是小菜鸡一枚,说的有问题还望大家指出来;

大家共同学习,一起进步。