公司内部分享之mysql锁

325 阅读17分钟

mysql锁

这一节,我们来聊一下mysql锁的内容。mysql锁是为了协调多个用户访问同一个资源,保障并发时的一致性和有效性。

按照锁的范围划分,我们可以分为

  • 全局锁
  • 表锁
  • 行锁

全局锁

全局锁是在整个数据库加上读锁。让数据库处于只读状态,别的进程执行一下命令会阻塞住:数据更新语句(增删改查),数据定义语句(建表,表结构修改)和更新类事务的提交语句。

全局锁的语句为:

   FLUSH TABLES WITH READ LOCK;

简称 FTWRL, 解锁语句为:

   UNLOCK TABLES;

全局锁使用场景是做全库备份,让整个库只读,这听上去很危险

  • 在主库备份,会导致数据无法写入,业务停摆
  • 在从库备份,从库不能同步binlog中的日志,会导从库数据延迟。

那么为什么还要使用全局锁呢?这是为了防止数据不一致。举个栗子: 比如一个购物网站,其中有两张表 account(账户表)和order(订单表),我们下了总价100元的订单,操作步骤如下:

  • 1)account表中减去100
  • 2)order表中新增一个价值100的订单

现在我们不使用全局锁备份数据。在备份时,正好有人下了个100元的订单,那么备份出来的数据有如下几种情况:

  • 1)account表和order表同时备份到(运气真好,正常)
  • 2)account表和order表都没备份到 (下单失败,没赚也没损失,能接受)
  • 3)account表没备份,order表备份(没花钱,白赚了100元的物品,商家哭晕在厕所)
  • 4)account表备份,order表没备份(花了钱,没订单,投诉去,商家又哭晕在厕所)

也就是说如果不加锁,备份的数据会可能不会在同一个逻辑点,数据的逻辑是不一致的。那么有没有更好的备份方案呢,既可以备份且保持数据一致性,又可以不影响业务运行?还真有这样一个方案:

   使用官方自带的mysqldump工具,使用时加上--single-transaction。

使用时会在启动一个事务,来保证一致性。由于MVCC的支持,这个过程是可以正常更新的,不用停业务。

当然此方法仅支持带事务功能的存储引擎,MyISAM引擎就不支持。

小结:

全局锁目的

  • 保证数据一致性。

全局锁使用场景:

  • 在备份库时使用

语句为:

  • FLUSH TABLES WITH READ LOCK; //加锁
  • UNLOCK TABLES; // 解锁

当存储引擎支持事务时,可以使用如下工具做替换方案

  • mysqldump工具加--single-transaction

表级锁

表级锁,顾名思义就是锁住整张表,MYSQL中表锁分为两张情况:表锁和原数据锁(MDL)。

表锁

表锁又分为表读锁和表写锁。

表读锁语句:

LOCK TABLES [tablename] READ

表写锁的语句:

LOCK TABLES [tablename] WRITE

解锁语句:

UNLOCK TABLES

在还没出现更细的锁颗粒度时,我们常用来处理并发情况,但在InnerDB这种数据引擎下一般不用。

表级别的读锁和写锁的区别

我们先新建一个数据库:

CREATE TABLE `t16` (
  `id` int(11) NOT NULL AUTO_INCREMENT,
  `a` int(11) NOT NULL COMMENT '唯一索引',
  `b` int(11) NOT NULL,
  `c` int(11) NOT NULL COMMENT '普通索引',
  PRIMARY KEY (`id`),
  UNIQUE KEY `uniq_a` (`a`) USING BTREE,
  KEY `idx_c` (`c`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;

再插入一条纪录:

INSERT INTO `t16`(`a`,`b`,`c`) VALUES(1,1,1);

我们先来看一下研究一下读锁:

事务A 事务B
LOCK TABLES t16 READ;
Query OK, 0 rows affected (0.00 sec)

给表加读锁

SELECT * FROM t16;
1 row in set (0.00 sec)

正常返回结果

SELECT * FROM t16;
1 row in set (0.00 sec)

正常返回结果

INSERT INTO t16(a,b,c) VALUES(2,2,2);
ERROR 1099 (HY000): Table 't16' was locked with a READ lock and can't be updated

报错

INSERT INTO t16(a,b,c) VALUES(3,3,3);

锁住

UNLOCK TABLES;
Query OK, 0 rows affected (0.00 sec)

解锁

Query OK, 1 row affected (1 min 18.19 sec)

写入成功

我们再来研究一下写锁:

事务A 事务B
LOCK TABLES t16 WRITE;
Query OK, 0 rows affected (0.00 sec)

给表加写锁

SELECT * FROM t16;
2 row in set (0.00 sec)

正常返回结果

SELECT * FROM t16;

等待

UNLOCK TABLES;

解锁

2 rows in set (58.85 sec)

正常返回结果

LOCK TABLES t16 WRITE;
Query OK, 0 rows affected (0.00 sec)

给表加写锁

INSERT INTO t16(a,b,c) VALUES(2,2,2);
Query OK, 1 row affected (0.00 sec)

正常

INSERT INTO t16(a,b,c) VALUES(4,4,4);

锁住

UNLOCK TABLES;
Query OK, 0 rows affected (0.00 sec)

解锁

Query OK, 1 row affected (1 min 18.19 sec)

写入成功

根据上面的栗子,我们可以得出下面这个逻辑:

本事务读 本事务写 其他事务读 其他事务写
加读锁后 正常 报错 正常 等待
加写锁后 正常 正常 等待 等待

原数据锁(MDL)

MDL不需要显式使用,在访问一个表的时候会被自动加上。MDL的作用是,保证读写的正确性。你可以想象一下,如果一个查询正在遍历一个表中的数据,而执行期间另一个线程对这个表结构做变更,删了一列,那么查询线程拿到的结果跟表结构对不上,肯定是不行的。 因此,在 MySQL 5.5 版本中引入了 MDL,当对一个表做增删改查操作的时候,加 MDL 读锁;当要对表做结构变更操作的时候,加 MDL 写锁。

  • 读锁之间不互斥,因此你可以有多个线程同时对一张表增删改查。
  • 读写锁之间、写锁之间是互斥的,用来保证变更表结构操作的安全性。因此,如果有两个线程要同时给一个表加字段,其中一个要等另一个执行完才能开始执行。

我们在工作中,很多时候需要考虑MDL的存在,否则可能导致锁等待或者连接长时间打满的情况。我们来看下面的栗子:

session1 session2 session3
SELECT a,b,SLEEP(60) FROM t16;
ALTER TABLE t16 ADD COLUMN f int;
SELECT * FROM t16 WHERE id=1;
4 rows in set (4 min 0.02 sec)

4条纪录所以4分钟后返回结果

Query OK, 0 rows affected (3 min 57.35 sec)

session1执行完后立即执行

1 row in set (3 min 35.10 sec)

session2执行完后立即执行

session1中又一条慢sql需要100s返回,他导致了session2中的修改表结构的语句阻塞,而session2中的语句又导致了,其他session中的语句的阻塞。所以短时间内数据库连接很容易被打满了。那要怎么做呢?可以使用kill语句来强制结束session1或session2中的语句。 因此对于开发来说,在工作中应该尽量避免慢查询、尽量保证事务及时提交、避免大事务等,当然对于 DBA 来说,也应该尽量避免在业务高峰执行 DDL 操作。

小结:

标级锁分为表锁和原数据锁(MDL) 表级别读锁和写锁的区别为

  • 加读锁,本事务可正常读,写报错,其他事务可正常读,写等待。
  • 加写锁,本事务正常读写,其他事务读写等待。

元数据锁

  • 在修改表结构时,会加上MDL写锁。
  • 尽量不要在高峰期执行修改表结构的语句,容易引起连接打满。

行级锁

两阶段锁

** 在Innodb中,行锁是在需要时才加上,在事务提交时解锁,这就是两阶段锁的协议。**

我们知道这个设定对我们使用事务有什么帮助呢? 如果你的事务要锁多行,需要把最可能造成锁冲突的锁往后放 举个栗子: A顾客需要在电影院B买票,我们简化一个流程:

  1. A顾客账户扣钱
  2. B电影院账户加钱
  3. 插入一条日志纪录

如果单从锁影响并发的方面考虑,应该如果规划他们之间的顺序呢?

根据两阶段锁协议,不论你怎样安排语句顺序,所有的操作需要的行锁都是在事务提交的时候才释放的。所以,如果你把语句 2 安排在最后,比如按照 3、1、2 这样的顺序,那么影院账户余额这一行的锁时间就最少。这就最大程度地减少了事务之间的锁等待,提升了并发度。

共享锁和排他锁

Innodb的锁按照功能分,可以分为共享锁(读锁)和排他锁(写锁)。

  • 共享锁:允许一个事务去读一行,阻止其它事务获得相同数据集的排他锁。
  • 排他锁:允许获得排他锁的事务更新数据,阻止其它事务取得相同数据集的共享读锁和排他写锁。

对于普通 select 语句,InnoDB 不会加任何锁,事务可以通过以下语句显式给记录集加共享锁或排他锁:

  • 共享锁:select * from table_name where … lock in share mode;
  • 排他锁:select * from table_name where … for update;
  • update,delete 语句也会加上排他锁

RC隔离级别下的锁

接下来我们分析一下RC隔离级别下的锁的情况。 我们分为三种情况

  • 无索引
  • 唯一索引
  • 非唯一索引

测试表我们沿用上面的t16表,其中a唯一索引,b无索引,c非唯一索引。清空表数据,并在表中加入几条数据

truncate table t16;
insert into t16(a,b,c) values (1,1,1),(2,2,2),(3,3,3),(4,4,3);

无索引

session1 session2
set session transaction_isolation='READ-COMMITTED';/* 设置会话隔离级别为 RC*/ set session transaction_isolation='READ-COMMITTED';/* 设置会话隔离级别为 RC*/
begin; begin;
SELECT * FROM t16 WHERE b=1 for update;

正常

SELECT * FROM t16 WHERE b=3 for update;

等待

commit;

session1结束,结果正常返回

commit;

表面看来,session1只给b=1加了排他锁,实际在没有索引的清空下,他给整张表加了排他锁。下图是加锁的逻辑图:

没有索引的情况下,InnoDB 的当前读会对所有记录都加锁。所以在工作中应该特别注意 InnoDB 这一特性,否则可能会产生大量的锁冲突。

唯一索引

session1 session2
set session transaction_isolation='READ-COMMITTED';/* 设置会话隔离级别为 RC*/ set session transaction_isolation='READ-COMMITTED';/* 设置会话隔离级别为 RC*/
begin; begin;
SELECT * FROM t16 WHERE a=1 for update;

正常

SELECT * FROM t16 WHERE a=2 for update;

正常

SELECT * FROM t16 WHERE c=1 for update;

等待

commit;

session1结束,结果正常返回

commit;

session1 给了 a=1 这一行加了排他锁,在 session2 中请求其他行的排他锁时,不会发生等待;但是在 session2 中请求 a=1 这一行的排他锁时,会发生等待。看下图:

如果查询的条件是唯一索引,那么 SQL 需要在满足条件的唯一索引上加锁,并且会在对应的聚簇索引上加锁。

非唯一索引

session1 session2
set session transaction_isolation='READ-COMMITTED';/* 设置会话隔离级别为 RC*/ set session transaction_isolation='READ-COMMITTED';/* 设置会话隔离级别为 RC*/
begin; begin;
SELECT * FROM t16 WHERE c=3 for update;

正常

SELECT * FROM t16 WHERE a=2 for update;

正常

SELECT * FROM t16 WHERE a=4 for update;

等待

commit;

session1结束,结果正常返回

commit;

我们在满足条件 c=3 的数据上加了排他锁,如上面结果,就是第 3、4 行。因此第 1、2 行的数据没被锁,而 3、4 行的数据被锁了。如下图:

如果查询的条件是非唯一索引,那么 SQL 需要在满足条件的非唯一索引上都加上锁,并且会在它们对应的聚簇索引上加锁。

RR隔离级别下的锁

在研究RR隔离级别下,我们先来看一道小题目:

session1 session2
set session transaction_isolation='READ-COMMITTED';/* 设置会话隔离级别为 RC*/ set session transaction_isolation='READ-COMMITTED';/* 设置会话隔离级别为 RC*/
begin; begin;
SELECT * FROM t16 WHERE c=3 for update;

Result1

INSERT INTO t16(a,b,c) VALUES(5,5,3);
commit;
SELECT * FROM t16 WHERE c=3 for update;

Result1

commit;

Result1和Result2分别是多少?

我们经过测试Result1为:

我们经过测试Result2为:

我们发现Result2比Result1多了一行,这也就是我们所讲的幻读。

那为什么会出现幻读,我们来看下面一张图:

从图中可以看出,RC 隔离级别下,只锁住了满足 c=3 的当前行,而不会对后面的位置(或者说间隙)加锁,因此导致 session1 的写入语句能正常执行并提交。

为了解决幻读问题,RR隔离级别引入了间隙锁。

我们重新建一张表,并插入数据:

CREATE TABLE `t17` (
  `id` int(11) NOT NULL,
  `a` int(11) NOT NULL COMMENT '唯一索引',
  `b` int(11) NOT NULL,
  `c` int(11) NOT NULL COMMENT '普通索引',
  PRIMARY KEY (`id`),
  UNIQUE KEY `uniq_a` (`a`) USING BTREE,
  KEY `idx_c` (`c`)
) ENGINE=InnoDB AUTO_INCREMENT=5 DEFAULT CHARSET=utf8mb4;
INSERT INTO t17(id, a, b, c) VALUES(1,1,1,1),(2,2,2,2),(4,4,4,4),(6,6,6,4);

我们一样分为三种情况:

  • 无索引
  • 非唯一索引
  • 唯一索引

无索引

session1 session2 session3
set session transaction_isolation='REPEATABLE-READ';/* 设置会话隔离级别为 RR*/ set session transaction_isolation='REPEATABLE-READ';/* 设置会话隔离级别为 RR*/ set session transaction_isolation='REPEATABLE-READ';/* 设置会话隔离级别为 RR*/
begin; begin; begin;
SELECT * FROM t17 WHERE b=1 for update;

正常

SELECT * FROM t17 WHERE a=4 for update;

等待

INSERT INTO t17(id, a, b, c) VALUES(5,5,5,5);

等待

commit;

session1结束,结果正常返回

session1结束,结果正常返回

rollback; rollback;

其加锁的逻辑图如下:

如图,所有记录都有 X 锁,除此之外,每个 GAP 也被加上了 GAP 锁。因此这张表在执行完 select * from t17 where b=1 for update; 到 commit 之前,除了不加锁的快照读,其它任何加锁的 SQL,都会等待,如果这是线上业务表,那就是件非常恐怖的事情了。

RR 隔离级别下,非索引字段做条件的当前读不但会把每条记录都加上 X 锁,还会把每个 GAP 加上 GAP 锁。再次说明,条件字段加索引的重要性。

非唯一索引

session1 session2 session3
set session transaction_isolation='REPEATABLE-READ';/* 设置会话隔离级别为 RR*/ set session transaction_isolation='REPEATABLE-READ';/* 设置会话隔离级别为 RR*/ set session transaction_isolation='REPEATABLE-READ';/* 设置会话隔离级别为 RR*/
begin; begin; begin;
SELECT * FROM t17 WHERE c=4 for update;

正常

SELECT * FROM t17 WHERE a=4 for update;

等待

INSERT INTO t17(id, a, b, c) VALUES(5,5,5,4);

等待

commit;

session1结束,结果正常返回

session1结束,结果正常返回

rollback; rollback;

其加锁的逻辑图如下:

与 RC 隔离级别下的图相似,但是有个比较大的区别是:RR 隔离级别多了 GAP 锁。

如上图,首先需要考虑哪些位置可以插入新的满足条件 c=4 的项:

  • 由于 B+ 树索引是有序的,因此 [2,2](代表 c 和 id 的值,后面就不一一说明了)前面的记录,不可能插入 c=4 的记录了;
  • [2,2] 与 [4,4] 之间可以插入 [4,3];
  • [4,4] 与 [4,6] 之间可以插入 [4,5];
  • [4,6] 之后,可以插入的值就很多了:[4,n](其中 n>6) ;

为了保证这几个区间不会插入新的满足条件 c=4 的记录,MySQL RR 隔离级别选择了 GAP 锁,将这几个区间锁起来。 而上面,我们插入了(id=5, c=4)的数据,所以被锁住了。

唯一索引

RR 隔离级别下,非索引字段做条件的当前读不但会把每条记录都加上 X 锁,还会把每个 GAP 加上 GAP 锁。再次说明,条件字段加索引的重要性。

因此以唯一索引为条件的当前读,不会有 GAP 锁。所以 RR 隔离级别下的唯一索引当前读加锁情况与 RC 隔离级别下的唯一索引当前读加锁情况一致。这里就不再实验了。

间隙锁在非唯一索引下引起的问题

session1 session2
set session transaction_isolation='REPEATABLE-READ';/* 设置会话隔离级别为 RR*/ set session transaction_isolation='REPEATABLE-READ';/* 设置会话隔离级别为 RR*/
begin; begin;
SELECT * FROM t17 WHERE c=4 for update;

正常

INSERT INTO t17(id, a, b, c) VALUES(5,5,5,5);

等待

commit;

session1结束,结果正常返回

rollback;

我们发现插入语句竟然等待了,为什么? 因为间隙锁锁的是位置,根据上面这张图,session1的sql语句锁的范围是 “(2,无穷大]”,导致凡在这个区间都会被锁住。

所以间隙锁的引入会导致锁的范围更大,影响并发。 有很多公司使用RC隔离级别+日志ROW模式。

小结:

1)行级锁是两阶段的锁,一个事务中在需要时锁住,在commit时释放锁。

2)行级锁按照功能分为共享锁和排他锁。

3)在RC隔离级别下,会对数据加上纪录锁,但会有幻读的问题

4)在RR隔离级别下,会对数据加上纪录锁和间隙锁,解决了幻读的问题,但影响并发。

死锁

死锁产生的原因

死锁是指两个或者多个事务在同一资源上相互占用,并请求锁定对方占用的资源,从而导致恶性循环的现象。 我们使用t17表来举个栗子,看如何会产生死锁:

session1 session2
set session transaction_isolation='REPEATABLE-READ';/* 设置会话隔离级别为 RR*/ set session transaction_isolation='REPEATABLE-READ';/* 设置会话隔离级别为 RR*/
begin; begin;
SELECT * FROM t17 WHERE c=4 for update;

sql1


正常

SELECT * FROM t17 WHERE c=1 for update;

sql2


正常

INSERT INTO t17(id, a, b, c) VALUES(5,5,5,5);

sql3


等待

INSERT INTO t17(id, a, b, c) VALUES(7,7,7,1);

sql4


报死锁错误

我们来分析一下,刚开始执行sql1时 session1锁住的范围时 (2,正无穷],执行sql2后session2锁住范围时(负无穷大,2) , sql3范围在session1锁的范围内,所以等待session1锁释放,而此时,sql4又在等待session2的释放。也就是session1和sesison2相互等待各自释放。所以就成了死锁了。

死锁的解决方案:

InnoDB 中解决死锁问题有两种方式:

  • 检测到死锁的循环依赖,立即返回一个错误(这个报错内容请看下面的实验),将参数 innodb_deadlock_detect 设置为 on 表示开启这个逻辑;
  • 等查询的时间达到锁等待超时的设定后放弃锁请求。这个超时时间由 innodb_lock_wait_timeout 来控制。默认是 50 秒。

一般我们采用第一种方案,因为第二种方案等待50秒时间过长,业务上无法接受,如果把时间调少,比如1秒,又有可能会误杀一些正常的锁。但第一种方案也会造成额外的cpu开销。

降低死锁的方案:

  • 更新 SQL 的 where 条件尽量用索引;
  • 基于 primary 或 unique key 更新数据;
  • 减少范围更新,尤其非主键、非唯一索引上的范围更新;
  • 加锁顺序一致,尽可能一次性锁定所有需要行;
  • 将 RR 隔离级别调整为 RC 隔离级别。

小结

1) 死锁是指两个或者多个事务在同一资源上相互占用,并请求锁定对方占用的资源,从而导致恶性循环的现象。 2) 我们一般使用InnoDB自带的检索机制来检索是否死锁。

小练习:

有三张表,用户账户表,商品库存表,订单表。现在用户需要买商品,购买流程是

  • 用户账户扣除相应金额,如果金额不够购买失败。
  • 商品库存扣除相应库存,如果库存不够购买失败。
  • 订单表新增一个订单纪录。

我们改如何操作这些表来完成上面的购买流程,并且支持更多的并发?

参考文档