MySQL系列:行锁、死锁

作者: 阿琦 | 2021-10-05 | 阅读

   

1前言

前两天去富阳考科三科四去了,虽然已当场拿到驾照,但如果再让我来一次,那肯定不选手动挡了。

  1. 现在的车都是自动挡的
  2. 自动挡好考
  3. 练手动挡你会感觉请了个人天天来 diss 你,而且你还不能还嘴
  4. 如果没特殊需求,根本没必要考手动挡,如有意见参考第 1 条
  5. 杭州驾校有隐形收费,最好事先咨询好
  6. 没了,以上只供参考

2目录

  • 行锁
  • 行锁对性能的影响
  • 两阶段锁(2pc)
  • 死锁
  • 死锁检测
  • 如何最小化和处理死锁

3继续

上一篇:MySQL系列:全局锁、表锁

mysql 行锁是在引擎层各个引擎自己实现的MySQL系列:一条 sql 语句是如何执行的。但并不是所有的引擎都支持行锁,比如 mysql 老版本默认的 MyISAM 引擎就不支持行锁。不支持行锁意味着并发控制只能使用表锁,对于这种引擎的表,同一张表上任何时刻只能有一个更新在执行,这就会影响到业务的并发度。InnoDB 是支持行锁的,这也是取代 MyISAM 的重要原因之一。

行锁是针对数据表中行记录的锁。比如事务 A 更新了一行,而这时事务 B 也要更新同一行,那么必须等事务 A 操作完成后才能更新。

4两阶段锁

栗子,假设字段 id 是表 aqi 的主键,事务 B 的 update 语句执行时会发生什么。

这个问题的结论取决于事务 A 在执行完两条 update 语句后,持有哪些锁,以及在什么时候释放。实际上事务 B 的 update 语句会被阻塞,直到事务 A 执行 commit 之后,事务 B 才能继续执行。

  • begin 开始一个事务
  • rollback 事务回滚
  • commit 事务确认

A 持有两个记录的行锁,都是在 commit 的时候才释放的。

在 InnoDB 事务中,行锁是在需要的时候才加上的,但并不是不需要了就立刻释放,而是等到事务结束时才释放。这个就是两阶段锁协议。

看了下官方文档 8.0 版,还有个 Phantom Rows ??大概意思是当同一个查询在不同时间产生不同的行集时,Phantom Rows 就会发生在一个事务中。例如,如果 SELECT 被执行两次,但第二次返回第一次没有返回的行,则该行是“幻影”行。

完整官网说明:https://dev.mysql.com/doc/refman/8.0/en/innodb-next-key-locking.html

如果你的事务中需要锁多个行,要把最可能造成锁冲突、最可能影响并发度的锁尽量往后放。

假设,你负责实现一个电影票的在线交易业务,顾客 A 要在电影院 B 购买电影票,简化后的业务如下几点:

  1. 从顾客 A 账号余额中扣除电影票价;
  2. 给影院 B 的账号余额增加这张电影票价;
  3. 记录一条交易日志;

要完成这个交易,需要 update 两条记录,并 insert 一条记录。为了保证交易的原子性,要把这三个操作放在一个事务中。

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

但是这个设计是有缺陷的,比如:这个影院做活动,可以低价预售一年内所有的电影票,而且这个活动只做一天。于是在活动时间开始的时候,mysql 就挂了。线上问题定位常用命令服务器三连,发现 CPU 消耗接近 100%,但整个数据库每秒就执行不到 100 个事务,这里就要说死锁了。

QPS:Queries Per Second 意思是“每秒查询率”,是一台服务器每秒能够相应的查询次数,是对一个特定的查询服务器在规定时间内所处理流量多少的衡量标准。

TPS: TransactionsPerSecond 的缩写,也就是事务数/秒。它是软件测试结果的测量单位。一个事务是指一个客户机向服务器发送请求然后服务器 做出反应的过程。客户机在发送请求时开始计时,收到服务器响应后结束计时,以此来计算使用的时间和完成的事务个数,最终利用这些信息来估计得分。客户机使 用加权协函数平均方法来计算客户机的得分,测试软件就是利用客户机的这些信息使用加权协函数平均方法来计算服务器端的整体 TPS 得分。

5死锁、死锁检测

当并发系统中不同线程出现循环资源依赖,涉及的线程都在等待别的线程释放资源时,就会导致这几个线程进入无限等待的状态,称为死锁

这时候,事务 A 在等待事务 B 释放 id=2 的行锁,而事务 B 在等待事务 A 释放 id=1 的行锁。事务 A 和事务 B 都在互相等待对方的资源释放,这就进入了死锁的状态。当出现死锁以后,有两种策略:

  • 一种策略是,直接进入等待,直到超时。这个超时时间可以通过参数innodb_lock_wait_timeout来设置。
  • 第二种策略是,发起死锁检测,检测到死锁后,主动回滚死锁链条中的某个事物,让其他事物得以继续执行。将参数innodb_deadlock_delect设置为 on,表示开启这个逻辑。

在 innodb 中,innodb_lock_wait_timeout 的默认值是 50s,意味着如果采用第一个策略,当出现死锁以后,第一个被锁住的线程要过 50s 才会超时退出,然后其他线程才有可能继续执行。对于一个在线服务来说,这个等待时间往往是无法接受的。

但如果把这个时间设置成一个很小的值,又会有误伤。

所以正常情况下一般采用第二种策略 -- 主动死锁检测,而且 innodb_deadlock_detect 默认 on 。主动检测发生死锁时,是能够快速发现并进行处理的,但它也有额外负担。

按照上面第二种的话,每当一个事务被锁的时候,就要看看它所依赖的线程有没有被别人锁住,如此循环,最后判断是否出现了循环等待 -- 死锁。

那么每个新来的被堵住的线程,都要判断会不会由于自己的加入导致了死锁,这是一个时间复杂度是 O(n)的操作。

假设有 1000 个并发线程要同时更新同一行,那么死锁检测操作就是 100 万这个量级的。虽然最终检测的结果是没有死锁,但是期间要消耗大量的 CPU 资源。因此,就会看到 CPU 利用率很高,但是每秒却执行不了几个事务。

这种热点行更新导致的性能问题症结在于,死锁检测要耗费大量的 CPU 资源,解决策略有以下几种:

  1. 降低并发度
  2. 拆行,一行拆多行
  3. Server 层限流,即同一时间进入更新的线程数
  4. 关闭死锁监测(关闭的弊端是可能超时较多)

一种头痛医头的办法,就是如果你能确保这个业务一定不会出现死锁,可以临时把死锁检测关掉。 但是这种操作本身带有一定风险,因为业务设计的时候一般不会把死锁当做一个严重错误,毕竟出现死锁了,就回滚,然后通过业务重试一般就没问题了,这是业务无损的。而关掉死锁检测意味着可能会出现大量的超时,这是业务有损的。

另一个思路是控制并发度。 根据上面的分析,会发现如果并发能够控制住的话,比如同一行同时最多只能有 10 个线程在更新,那么死锁检测的成本就会很低,也就不会出现这个问题。

一个直接的想法就是,在客户端做并发控制。但你很快就会发现这个方法不太行,因为客户端很多,比如一个应用,有几百个客户端,这样即使每个客户端控制到只有 5 个并发线程,汇总到数据库服务端以后,峰值并发数也可能要达到上千或者几千。

因此,这个并发控制要做在数据库服务端。如果有中间件,可以考虑在中间件实现;如果有 DBA .. . 基本思路就是,对于相同行的更新,在进入引擎之前排队。这样在 Innodb 内部就不会有大量的死锁检测工作了。

6MySQL8.0 官方文档

官方文档InnoDB 死锁示例:https://dev.mysql.com/doc/refman/8.0/en/innodb-deadlock-example.html

首先,客户端 A 创建一个包含一行的表,然后开始一个事务。在事务中,A 通过S在共享模式下选择该行来获得该行的 锁定:

mysql> CREATE TABLE t (i INTENGINE = InnoDB;
Query OK, 0 rows affected (1.07 sec)

mysql> INSERT INTO t (i) VALUES(1);
Query OK, 1 row affected (0.09 sec)

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

mysql> SELECT * FROM t WHERE i = 1 FOR SHARE;
+------+
| i    |
+------+
|    1 |
+------+

接下来,客户端 B 开始一个事务并尝试从表中删除该行:

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

mysql> DELETE FROM t WHERE i = 1;

删除操作需要X锁。无法授予S锁定,因为它与客户端 A 持有的锁定不兼容 ,因此请求进入行和客户端 B 块的锁定请求队列。

最后,客户端 A 还尝试从表中删除该行:

mysql> DELETE FROM t WHERE i = 1;
ERROR 1213 (40001): Deadlock found when trying to get lock;
try restarting transaction

这里发生死锁是因为客户端 A 需要一个 X锁来删除该行。但是,该锁定请求无法被授予,因为客户端 B 已经有一个X锁定请求并且正在等待客户端 A 释放其S锁定。SA 持有的锁也不能 X因为 B 先前的X锁请求而升级 为锁。结果, InnoDB为其中一个客户端生成错误并释放其锁。客户端返回此错误:

ERROR 1213 (40001): Deadlock found when trying to get lock;
try restarting transaction

此时,可以授予另一个客户端的锁定请求,并从表中删除该行。

官方文档死锁检测:https://dev.mysql.com/doc/refman/8.0/en/innodb-deadlock-detection.html

当启用死锁检测(默认)时, InnoDB自动检测事务 死锁并回滚一个或多个事务以打破死锁。 InnoDB尝试选择要回滚的小事务,其中事务的大小由插入、更新或删除的行数决定。

InnoDB知道表锁 if innodb_table_locks = 1(默认)autocommit = 0,并且它上面的 MySQL 层知道行级锁。否则, InnoDB无法检测死锁,其中涉及由 MySQLLOCK TABLES 语句设置的表锁或由非存储引擎设置的锁 InnoDB。通过设置innodb_lock_wait_timeout系统变量的值来解决这些情况 。

如果Monitor 输出LATEST DETECTED DEADLOCK部分 InnoDB包含一条消息,说明TOO DEEP OR LONG SEARCH IN THE LOCK TABLE WAITS-FOR GRAPH, WE WILL BACK FOLLOWING TRANSACTION,这表明等待列表中的事务数已达到 200 的限制. 超过 200 个事务的等待列表被视为死锁,尝试检查等待列表的事务将被回滚。如果锁定线程必须查看等待列表上的事务拥有的 1,000,000 个以上的锁,也可能发生相同的错误。

禁用死锁检测: 在高并发系统上,当大量线程等待同一个锁时,死锁检测会导致速度减慢。有时,禁用死锁检测并在innodb_lock_wait_timeout 发生死锁时依赖事务回滚设置可能更有效 。可以使用该innodb_deadlock_detect 变量禁用死锁检测 。

官方文档 如何最小化和处理死锁:https://dev.mysql.com/doc/refman/8.0/en/innodb-deadlocks-handling.html

死锁是事务数据库中的一个经典问题,但它们并不危险,除非它们非常频繁以至于根本无法运行某些事务。通常,必须编写应用程序,以便它们随时准备在事务因死锁回滚时重新发出事务。

InnoDB使用自动行级锁定。即使在仅插入或删除单行的事务的情况下,也可能会遇到死锁。那是因为这些操作并不是真正的“原子”;它们会自动对插入或删除的行的(可能是多个)索引记录设置锁定。

可以使用以下技术处理死锁并降低其发生的可能性:

  • 在任何时候,发出 SHOW ENGINE INNODB STATUS以确定最近死锁的原因。这可以帮助调整应用程序以避免死锁。

  • 如果频繁的死锁警告引起关注,请通过启用该innodb_print_all_deadlocks变量来收集更广泛的调试信息 。有关每个死锁的信息,而不仅仅是最新的,都记录在 MySQL 错误日志中。完成调试后禁用此选项。

  • 如果由于死锁而失败,请随时准备重新发出事务。死锁并不危险。再试一次。

  • 保持事务小且持续时间短,以减少它们发生冲突的可能性。

  • 在进行一组相关更改后立即提交事务,以减少它们发生冲突的可能性。特别是,不要让交互式 mysql会话长时间处于打开状态并带有未提交的事务。

  • 如果使用锁定读取(SELECT ... FOR UPDATE或 SELECT ... FOR SHARE),请尝试使用较低的隔离级别,例如READ COMMITTED

  • 当修改一个事务中的多个表或同一个表中的不同行集时,每次都以一致的顺序执行这些操作。然后事务形成明确定义的队列并且不会死锁。例如,组织数据库操作到功能在应用程序中,或调用存储程序,而不是编码的多个相似序列INSERTUPDATE以及 DELETE在不同的地方语句。

  • 将精心挑选的索引添加到表中,以便查询扫描更少的索引记录并设置更少的锁。使用 EXPLAIN SELECT以确定哪些索引MySQL认为最适合查询。

  • 使用较少的锁定。如果可以允许 a SELECT从旧快照返回数据,请不要向其添加FOR UPDATEor FOR SHARE子句。在READ COMMITTED这里使用 隔离级别很好,因为同一事务中的每个一致性读取都从它自己的新快照中读取。

  • 如果没有其他帮助,请使用表级锁序列化事务。使用LOCK TABLES事务表(例如InnoDB 表)的正确方法是使用 SET autocommit = 0(not START TRANSACTION)后跟开始事务LOCK TABLES,并且UNLOCK TABLES在明确提交事务之前不调用 。例如,如果需要写入 table t1并从 table 读取 t2,可以这样做:

SET autocommit=0;
LOCK TABLES t1 WRITE, t2 READ, ...;
... do something with tables t1 and t2 here ...
COMMIT;
UNLOCK TABLES;

表级锁可防止对表的并发更新,从而避免死锁,但代价是繁忙系统的响应速度变慢。

  • 序列化事务的另一种方法是创建一个仅包含一行的辅助“信号量”表。让每个事务在访问其他表之前更新该行。这样,所有事务都以串行方式发生。请注意,InnoDB 即时死锁检测算法也适用于这种情况,因为序列化锁是行级锁。对于 MySQL 表级锁,必须使用超时方法来解决死锁。

MySQL官网死锁这块目录:https://dev.mysql.com/doc/refman/8.0/en/innodb-deadlocks.html


版权声明:本文由 阿琦 在 2021年10月05日发表。本文采用CC BY-NC-SA 4.0许可协议,非商业转载请注明出处,不得用于商业目的。
文章题目及链接:《MySQL系列:行锁、死锁》




  相关文章:


留言区:

TOP