MySQL-并发教程-全-
MySQL 并发教程(全)
一、简介
就数据库而言,并发和锁定是一些最复杂的主题。当条件“刚刚好”时,通常执行快速且没有问题的查询可能会突然花费更长时间或失败并出现错误,因此锁定或争用成为一个问题。你可能会问自己,为什么锁会引起这样的问题。这一章和接下来的 11 章将试图解释这一点,以及你如何最好地处理它们。本书的最后六章通过六个案例研究,将现实场景中的信息与分析以及如何避免或减少问题结合在一起。
在这一章中,你将首先学习为什么锁是重要的,尽管它们会引起一些问题。然后将解释与事务的关系。本章的其余部分介绍了本书中如何使用示例,以及本书中用于示例的world
、sakila
和employees
数据库。
为什么需要锁?
这似乎是一个不需要锁定数据库的完美世界。然而,价格会很高,只有少数用例可以使用该数据库,而且对于 MySQL 这样的通用数据库来说,避免锁是不可能的。如果没有锁定,就不能有任何并发性。假设只允许一个到数据库的连接(你可以说它本身是一个锁,因此系统不是无锁的)——这对大多数应用来说不是很有用。
Note
通常,MySQL 中所谓的锁实际上是一个锁请求,它可以处于授权或挂起状态。
当您有几个连接同时执行查询时,您需要某种方法来确保这些连接不会互相妨碍。这就是锁进入画面的地方。你可以把锁想象成道路交通中的交通信号(图 1-1 ),它控制资源的使用以避免事故。在道路交叉路口,要保证两车不交叉,不发生碰撞。
图 1-1
数据库中的锁类似于交通灯
在数据库中,有必要确保两个查询对数据的访问不冲突。由于控制进入十字路口有不同的级别——让行、停车标志和交通灯——数据库中有不同的锁类型。
锁定级别
MySQL 中的锁有几种风格,在 MySQL 的不同级别上起作用,从用户级锁到记录锁。最高层是用户级锁,可以保护应用中的整个代码路径和数据库中的任何对象。中间是操作数据库对象的锁。这些锁包括保护表元数据的元数据锁和保护表中所有数据的表锁。用户级锁和表级锁的共同点是它们是在数据库的 SQL 层实现的。高级锁在第 6 章中讨论。
最底层是由存储引擎实现的锁。本质上,这些锁取决于您使用的存储引擎。由于 InnoDB 是 MySQL 中使用最多的存储引擎(也是默认的),本书涵盖了 InnoDB 特有的锁。InnoDB 包括记录上的锁,这是最容易理解的,以及更难的概念,如间隙锁、下一个键锁、谓词锁和插入意图锁。此外,还有互斥和信号量(这也发生在 SQL 层)。特定于 InnoDB 的锁和互斥/信号量将在第 7 章中介绍。
锁和事务
乍一看,将锁和事务的主题合并到一本关于并发性的书中似乎有些奇怪。然而,正如你将在本书中看到的几个例子一样,它们是紧密相关的。一些锁在事务期间被持有,因此理解事务如何工作以及如何监控它们是很重要的。
在使用锁时,事务隔离级别的概念也很重要。隔离级别会影响使用哪些锁以及持有这些锁的时间。
第 3 和 4 章讲述了如何监控事务,第 11 和 12 章讲述了事务如何工作、它们的影响以及事务隔离级别。
例子
整本书都有例子来帮助说明正在讨论的主题,或者设置一个你可以研究的情境。除了第 17 章和第 18 章之外,列出了重现试验所需的所有声明。一般来说,对于这些示例,您将需要不止一个连接,所以查询的提示已经被设置为在重要的时候指示哪个连接用于哪个查询。例如,Connection 1>
意味着查询应该由您的第一个连接执行。
本书中的所有例子都是在 MySQL Shell 中执行过的。为简洁起见,示例中的提示是mysql>
,除非连接很重要或者语言模式不是 SQL。然而,这些示例也可以在旧的mysql
命令行客户端上运行。
Tip
如果您不熟悉 MySQL Shell,那么它是第二代 MySQL 命令行客户端,同时支持 SQL、Python 和 JavaScript。它还带有几个内置的实用程序,包括用于管理 MySQL InnoDB 集群和传统复制拓扑的工具。有关 MySQL Shell 的介绍,请参见位于 https://dev.mysql.com/doc/mysql-shell/en/
的用户指南或查尔斯·贝尔( www.apress.com/gp/book/9781484250822
)的《?? 介绍 MySQL Shell (Apress》)一书。
此外,这本书还附带了一个 Python 模块—concurrency_book.generate
,可以导入到 MySQL Shell 中,用于重现除了最简单的例子之外的所有例子。本节的其余部分描述了如何使用 MySQL Shell 模块。这里的内容是附录 B 的摘录,其中包含该模块的更长的参考,包括如何实现您自己的示例。
Note
本质上,示例中的一些数据对于每次执行都是不同的。对于 id 和存储器地址等尤其如此。因此,当您试图重现这些示例时,不要期望在所有细节上都得到相同的结果。
concurrency_book.generate 模块的先决条件
使用本书提供的 MySQL Shell 模块的最重要要求是,您使用的是 MySQL Shell 8.0.20 或更高版本。这是一个严格的要求,因为模块主要使用shell.open_session()
方法来创建测试用例所需的连接。此方法仅在 8.0.20 版中引入。shell.open_session()
相对于mysql.get_classic_session()
和mysqlx.get_session()
的优势在于open_session()
透明地与经典的 MySQL 协议和新的 X 协议一起工作。
如果出于某种原因,您被老版本的 MySQL Shell 所困,您可以更新测试用例,以包括protocol
设置(参见附录 B 中的定义工作负载)来明确指定使用哪个协议。
还要求从 MySQL Shell 到 MySQL Server 的连接已经存在,因为模块在创建示例所需的附加连接时会使用该连接的 URI。
这些例子已经在 MySQL Server 8.0.21 上进行了测试;然而,大多数例子都适用于旧版本,有些甚至适用于 MySQL 5.7。也就是说,建议使用 MySQL Server 8.0.21 或更高版本。
安装 concurrency_book.generate 模块
要使用该模块,你需要从本书的 GitHub 库下载concurrency_book
目录下的文件(链接可以在本书的首页 www.apress.com/gp/book/9781484266519
找到)。最简单的方法是克隆存储库或使用图 1-2 所示的菜单下载包含所有文件的 ZIP 文件。
图 1-2
用于克隆或下载资源库的 GitHub 菜单
点击剪贴板图标,使用您系统的 Git 软件复制用于克隆存储库的 URL,或者使用下载 ZIP 链接下载存储库的 ZIP 文件。只要保持concurrency_book
目录下的结构,您可以自由选择任何路径作为文件的位置。对于这个讨论,假设您已经克隆了存储库或者将文件解压缩到了C:\Book\mysql-concurrency
,所以generate.py
文件在目录C:\Book\mysql-concurrency\concurrency_book\
中。
为了能够在 MySQL Shell 中导入模块,打开或创建mysqlshrc.py
文件。MySQL Shell 在四个地方搜索该文件。在 Microsoft Windows 上,路径按搜索顺序排列:
-
%PROGRAMDATA%\MySQL\mysqlsh\
-
%MYSQLSH_HOME%\shared\mysqlsh\
-
<mysqlsh binary path>\
-
%APPDATA%\MySQL\mysqlsh\
在 Linux 和 Unix 上
-
/etc/mysql/mysqlsh/
-
$MYSQLSH_HOME/shared/mysqlsh/
-
<mysqlsh binary path>/
-
$HOME/.mysqlsh/
始终搜索所有四个路径,如果在多个位置找到文件,将执行每个文件。这意味着,如果文件影响相同的变量,则最后找到的文件优先。如果你做出对你个人有意义的改变,最好的地方是在第四个位置。步骤 4 中的路径可以用环境变量MYSQLSH_USER_CONFIG_HOME
覆盖。
您需要确保mysqlshrc.py
文件将包含该模块的目录添加到 Python 搜索路径中,并且可以选择添加一个import
语句,以便在启动 MySQL Shell 时该模块可用。mysqlshrc.py
文件的一个例子是
import sys
sys.path.append('C:\\Book\\mysql-concurrency')
import concurrency_book.generate
双反斜杠用于窗口;在 Linux 和 Unix 上,不需要对分隔路径元素的斜杠进行转义。如果您没有在mysqlshrc.py
文件中包含import
,您将需要在 MySQL Shell 中执行它,然后才能使用该模块。
获取信息
该模块包括两个返回如何使用该模块的信息的方法。一个是help()
方法,提供如何使用模块的信息:
mysql-py> concurrency_book.generate.help()
还有一个show()
方法,它列出了run()
方法可以执行的工作负载和load()
方法可以加载的模式:
mysql-py> concurrency_book.generate.show()
工作负载以书中的代码清单命名,例如,名为“清单 6-1 的工作负载实现了清单 6-1 中的示例。
在开始执行工作负载之前,您需要加载一些测试数据,这个模块也可以为您完成。
加载测试数据
concurrency_book.generate
模块支持将employees
、sakila
和world
示例数据库加载到 MySQL 实例中。对于employees
数据库,您可以选择带有分区的版本。对于这本书来说,world
数据库是最重要的,其次是sakila
数据库。employees
数据库仅用于第 18 章中的案例研究。这三种模式的每一种都将在本章的后面进行更详细的描述。
Note
如果该模式存在,它将作为加载作业的一部分被删除。这实际上意味着load()
重置了模式。
您可以用load()
方法加载一个模式,该方法可以选择您想要加载的模式的名称。如果不提供模式名,系统会提示您。清单 1-1 展示了一个加载world
模式的例子。
mysql-py> concurrency_book.generate.load()
Available Schema load jobs:
===========================
# Name Description
---------------------------------------------------------------------------
1 employees The employee database
2 employees partitioned The employee database with partitions
3 sakila The sakila database
4 world The world database
Choose Schema load job (# or name - empty to exit): 4
2020-07-20 21:27:15.221340 0 [INFO] Downloading https://downloads.mysql.com/docs/world.sql.zip to C:\Users\myuser\AppData\Roaming\mysql_concurrency_book\sample_data\world.sql.zip
2020-07-20 21:27:18.159554 0 [INFO] Processing statements in world.sql
2020-07-20 21:27:27.045219 0 [INFO] Load of the world schema completed
Available Schema load jobs:
===========================
# Name Description
---------------------------------------------------------------------------
1 employees The employee database
2 employees partitioned The employee database with partitions
3 sakila The sakila database
4 world The world database
Choose Schema load job (# or name - empty to exit):
Listing 1-1Loading the world schema
load()
方法下载带有模式定义的文件,如果它还没有模式定义的话。下载的文件在微软 Windows 上存储在%APPDATA\mysql_concurrency_book\sample_data\
中,在其他平台上存储在${HOME}/.mysql_concurrency_book/sample_data/
中。如果您想要重新下载该文件,请将其从该目录中删除。
Tip
由于 MySQL Shell 的 Python 中只有相对低级的网络例程,如果您的连接速度慢或不稳定,下载 employees 数据库可能会失败。除了手动安装模式之外,还有一种选择是下载 https://github.com/datacharmer/test_db/archive/master.zip
并将其保存在sample_data
目录中。在那之后,load()
方法将获取它,并且不再尝试下载它。
如果您只想加载一个模式,您可以将名称指定为load()
的参数。例如,当调用 MySQL Shell 时,在命令行上直接给出命令来启动模式加载时,这可能特别有用
shell> mysqlsh --user=myuser --py -e "concurrency_book.generate.load('world')"
当您加载完您需要的模式后,您可以用一个空答案来回答退出。您现在已经准备好执行工作负载了。
Note
如果加载过程崩溃,抱怨文件,例如,它不是一个 ZIP 文件,那么它表明文件损坏或不完整。在这种情况下,请删除该文件,以便重新下载,或者尝试使用浏览器手动下载该文件。
执行工作负荷
您用run()
方法执行一个工作负载。如果指定已知工作负荷的名称,那么该工作负荷将立即执行。否则,将列出可用的工作负荷,并提示您输入工作负荷。在这种情况下,您可以通过数量(例如,列表 6-1 中的 15)或名称来指定工作量。使用名称时,只要至少有一个空格,Listing
和列表编号之间的空格数并不重要。当您使用提示选择工作负荷时,您可以在前一个工作负荷完成后选择另一个工作负荷。
工作负载完成后,对于几个工作负载,您将获得一个您可以进行的调查的建议列表。例如,这可以是查询示例中使用的连接持有的锁。这些调查旨在激发灵感,鼓励您使用自己的查询来探索工作负载。在例子的讨论中也使用了一些调查。清单 1-2 展示了一个使用提示符执行工作负载的例子。
mysql-py> concurrency_book.generate.run()
Available workloads:
====================
# Name Description
---------------------------------------------------------------------------
1 Listing 2-1 Example use of the metadata_locks table
2 Listing 2-2 Example of using the table_handles table
3 Listing 2-3 Using the data_locks table
...
14 Listing 5-2 Example of obtaining exclusive locks
15 Listing 6-1 A deadlock for user-level locks
...
Choose workload (# or name - empty to exit): 15
Password for connections: ********
2020-07-20 20:50:41.666488 0 [INFO] Starting the workload Listing 6-1
****************************************************
* *
* Listing 6-1\. A deadlock for user-level locks *
* *
****************************************************
-- Connection Processlist ID Thread ID Event ID
-- --------------------------------------------------
-- 1 105 249 6
-- 2 106 250 6
-- Connection 1
Connection 1> SELECT GET_LOCK('my_lock_1', -1);
+---------------------------+
| GET_LOCK('my_lock_1', -1) |
+---------------------------+
| 1 |
+---------------------------+
1 row in set (0.0003 sec)
-- Connection 2
Connection 2> SELECT GET_LOCK('my_lock_2', -1);
+---------------------------+
| GET_LOCK('my_lock_2', -1) |
+---------------------------+
| 1 |
+---------------------------+
1 row in set (0.0003 sec)
Connection 2> SELECT GET_LOCK('my_lock_1', -1);
-- Connection 1
Connection 1> SELECT GET_LOCK('my_lock_2', -1);
ERROR: 3058: Deadlock found when trying to get user-level lock; try rolling back transaction/releasing locks and restarting lock acquisition.
Available investigations:
=========================
# Query
--------------------------------------------------
1 SELECT *
FROM performance_schema.metadata_locks
WHERE object_type = 'USER LEVEL LOCK'
AND owner_thread_id IN (249, 250)
2 SELECT thread_id, event_id, sql_text,
mysql_errno, returned_sqlstate, message_text,
errors, warnings
FROM performance_schema.events_statements_history
WHERE thread_id = 249 AND event_id > 6
ORDER BY event_id
...
Choose investigation (# - empty to exit): 2
-- Investigation #2
-- Connection 3
Connection 3> SELECT thread_id, event_id, sql_text,
mysql_errno, returned_sqlstate, message_text,
errors, warnings
FROM performance_schema.events_statements_history
WHERE thread_id = 249 AND event_id > 6
ORDER BY event_id\G
*************************** 1\. row ***************************
thread_id: 249
event_id: 7
sql_text: SELECT GET_LOCK('my_lock_1', -1)
mysql_errno: 0
returned_sqlstate: NULL
message_text: NULL
errors: 0
warnings: 0
*************************** 2\. row ***************************
thread_id: 249
event_id: 8
sql_text: SELECT GET_LOCK('my_lock_2', -1)
mysql_errno: 3058
returned_sqlstate: HY000
message_text: Deadlock found when trying to get user-level lock; try rolling back transaction/releasing locks and restarting lock acquisition.
errors: 1
warnings: 0
*************************** 3\. row ***************************
thread_id: 249
event_id: 9
sql_text: SHOW WARNINGS
mysql_errno: 0
returned_sqlstate: NULL
message_text: NULL
errors: 0
warnings: 0
3 rows in set (0.0009 sec)
Available investigations:
=========================
# Query
--------------------------------------------------
...
Choose investigation (# - empty to exit):
2020-07-20 20:50:46.749971 0 [INFO] Completing the workload Listing 6-1
-- Connection 1
Connection 1> SELECT RELEASE_ALL_LOCKS();
+---------------------+
| RELEASE_ALL_LOCKS() |
+---------------------+
| 1 |
+---------------------+
1 row in set (0.0004 sec)
-- Connection 2
Connection 2> SELECT RELEASE_ALL_LOCKS();
+---------------------+
| RELEASE_ALL_LOCKS() |
+---------------------+
| 2 |
+---------------------+
1 row in set (0.0002 sec)
2020-07-20 20:50:46.749971 0 [INFO] Disconnecting for the workload Listing 6-1
2020-07-20 20:50:46.749971 0 [INFO] Completed the workload Listing 6-1
Available workloads:
====================
# Name Description
---------------------------------------------------------------------------
1 Listing 2-1 Example use of the metadata_locks table
2 Listing 2-2 Example of using the table_handles table
3 Listing 2-3 Using the data_locks table
...
Choose workload (# or name - empty to exit):
mysql-py>
Listing 1-2Executing a workload using the prompt
从这个例子中可以注意到一些事情。选择工作负载后,会要求您输入密码。这是您正在使用的 MySQL 帐户的密码。其他连接选项取自 MySQL Shell 中的session.uri
属性,但是出于安全原因,不会存储密码。如果您在一次调用run()
中执行多个工作负载,您将只被提示输入一次密码。
在开始执行工作负载时,对于工作负载使用的每个连接,在工作负载开始之前,有一个进程列表 id(从SHOW PROCESSLIST
开始)、(性能模式)线程 id 和最后事件 id 的概述:
-- Connection Processlist ID Thread ID Event ID
-- --------------------------------------------------
-- 1 105 249 6
-- 2 106 250 6
您可以使用这些 id 来执行您自己的调查查询,并且您可以使用 overview 来识别已经在concurrency_book.generate.run()
中作为工作负载实现的清单。
在工作负载执行结束时,该示例有三个查询,您可以执行这些查询来调查该示例演示的问题。您可以通过指定查询编号(一次一个查询)来执行一个或多个查询。在本书的代码清单中,调查的输出前面有一个注释,显示已经执行了哪个调查,例如
-- Investigation #2
每个工作量的调查数量从零到十多个不等。书中的列表并不总是包括所有调查的结果,因为有些是作为灵感和对问题的进一步研究而留下的。
完成调查后,提交一个空答案以退出工作量。如果您不想执行更多的工作负载,请再次提交一个空答案以退出run()
方法。
如果您只想执行一个工作负载,那么您可以将名称指定为run()
的参数。例如,当调用 MySQL Shell 时,在命令行上直接给出命令来执行工作负载时,这可能特别有用
shell> mysqlsh --user=myuser --py -e "concurrency_book.generate.run('Listing 6-1')"
本章的剩余部分描述了本书中示例使用的三种模式。
测试数据:世界模式
world
样本数据库是简单测试中最常用的数据库之一。它由三个有几百到几千行的表组成。这使它成为一个小数据集,这意味着它甚至可以很容易地用于小的测试实例。
计划
数据库由city
、country
和countrylanguage
表组成。表格之间的关系如图 1-3 所示。
图 1-3
world
数据库
country
表包含关于 239 个国家的信息,并作为来自city
和countrylanguage
表的外键的父表。数据库中总共有 4079 个城市和 984 种国家和语言的组合。
装置
您可以从 https://dev.mysql.com/doc/index-other.html
下载包含表格定义和数据的文件。在图 1-4 所示的示例数据库部分,Oracle 提供了从该页面访问多个示例数据库的权限。
图 1-4
包含示例数据库链接的表
下载的文件由一个名为world.sql.gz
或world.sql.zip
的文件组成,这取决于您选择的是 Gzip 还是 zip 链接。在这两种情况下,下载的档案包含一个文件world.sql
。数据的安装非常简单,只需执行脚本即可。
您可以从 MySQL Shell 或mysql
命令行客户端获得world.sql
。在 MySQL Shell 中,使用\source
命令加载数据:
MySQL [localhost ssl] SQL> \source world.sql
如果您使用传统的mysql
命令行客户端,请使用SOURCE
命令:
mysql> SOURCE world.sql
在这两种情况下,如果world.sql
文件不在您启动 MySQL Shell 或mysql
的目录中,请添加该文件的路径。
如果您喜欢使用 GUI,那么您也可以使用 MySQL Workbench 加载world
数据库。当连接到您想要加载world
模式的 MySQL 实例时,您点击菜单中的文件,然后点击运行 SQL 脚本,如图 1-5 所示。
图 1-5
从 MySQL Workbench 运行 SQL 脚本
这将打开一个文件浏览器,您可以在其中浏览文件。导航到保存未压缩的world.sql
文件的目录并选择它。结果是如图 1-6 所示的对话框,您可以在其中查看脚本的第一部分,并可选地设置默认的模式名和字符集。
图 1-6
MySQL Workbench 中用于检查脚本的对话框
在使用world
模式的情况下,模式名称和字符集都包含在脚本中,所以不需要(也没有效果)设置这些设置。点击运行来执行脚本。MySQL 执行脚本时,会有一个对话框显示进度信息。操作完成后,关闭对话框。可选地,您可以通过点击如图 1-7 所示的两个互相追逐的箭头来刷新侧边栏中的模式列表。
图 1-7
通过单击两个箭头刷新模式列表
虽然world
模式因为其简单性和小尺寸而非常适合于许多测试,但这也限制了它的有用性,并且有时需要稍微复杂一点的数据库。
测试数据:sakila 模式
sakila
数据库是一个真实的数据库,它包含一个电影租赁业务的模式,其中包含关于电影、库存、商店、员工和客户的信息。它添加了一个全文索引、一个空间索引、视图和存储程序,以提供一个使用 MySQL 特性的更完整的示例。数据库大小仍然非常适中,这使它适合于小型实例。
计划
sakila
数据库由 16 个表、7 个视图、3 个存储过程、3 个存储函数和 6 个触发器组成。这些表可以分为三组,客户数据、业务和库存。为了简洁起见,图中没有包括所有的列,大多数索引也没有显示。图 1-8 显示了表格、视图和存储程序的完整概览。
图 1-8
sakila
数据库概述
包含客户相关数据的表格(加上员工和商店的地址)位于左上角的区域。左下角的区域包含与业务相关的数据,右上角的区域包含关于电影和库存的信息。右下角用于视图和存储的程序。
Tip
您可以通过在 MySQL Workbench 中打开安装中包含的sakila.mwb
文件来查看整个图表(尽管格式不同)。这也是一个很好的例子,说明如何在 MySQL Workbench 中使用增强的实体关系(EER)图来记录您的模式。
由于对象的数量相对较多,所以在讨论模式时,将它们分成五组(每个表组、视图和存储例程)。第一组是客户相关数据,表格如图 1-9 所示。
图 1-9
sakila
数据库中包含客户数据的表格
有四个表包含与客户相关的数据。customer 表是主表,地址信息存储在 address、city 和 country 表中。
客户和业务组之间存在外键,在业务组中,外键从客户表指向商店表。还有四个从业务组的表到地址和客户表的外键。业务组如图 1-10 所示。
图 1-10
sakila
数据库中包含业务数据的表
业务表包含关于商店、员工、租金和付款的信息。商店和职员表有两个方向的外键,职员属于一个商店,而商店的经理是职员的一部分。租金和付款由员工处理,因此与商店间接相关,付款是为了租金。
表的业务组是与其它组关系最密切的组。staff 和 store 表有地址表的外键,而租赁和付款表引用客户。最后,租赁表有一个指向库存组中的库存表的外键。库存组的示意图如图 1-11 所示。
图 1-11
sakila
数据库中包含库存数据的表格
inventory 组中的主表是 film 表,它包含关于商店提供的电影的元数据。此外,还有一个带有标题和描述的film_text
表,带有全文索引。
电影与类别和演员表之间存在多对多的关系。最后,在业务组中有一个从库存表到商店表的外键。
这涵盖了sakila
数据库中的所有表格,但也有一些如图 1-12 所示的视图。
图 1-12
sakila
数据库中的视图
这些视图可以像报告一样使用,并且可以分为两类。film_list
、nicer_but_slower_film_list
和actor_info
视图与存储在数据库中的电影相关。第二类包含与sales_by_store
、sales_by_film_category
、staff_list
和customer_list
视图中的商店相关的信息。
为了完善数据库,还有如图 1-13 所示的存储函数和过程。
图 1-13
存储在sakila
数据库中的程序
film_in_stock()
和film_not_in_stock()
过程返回一个结果集,该结果集由给定电影和商店的库存 id 组成,基于电影是否有库存。找到的库存条目总数作为 out 参数返回。rewards_report()
程序根据上个月的最低花费生成一份报告。
get_customer_balance()
函数返回给定客户在给定日期的余额。剩下的两个函数检查一个库存 id 的状态,其中inventory_held_by_customer()
返回当前租赁该商品的客户的客户 id(如果没有客户租赁该商品,则返回NULL
),如果您想检查给定的库存 id 是否有库存,可以使用inventory_in_stock()
函数。
装置
您可以从 https://dev.mysql.com/doc/index-other.html
下载一个带有安装脚本的文件来安装sakila
模式,就像安装world
数据库一样。
下载的文件展开到一个包含三个文件的目录中,其中两个文件创建模式和数据,最后一个文件包含 MySQL Workbench 使用的格式的 ETL 图。
Note
sakila
数据库也可以通过下载employees
数据库获得;然而,本节和本书后面的例子使用了从 MySQL 主页下载的sakila
数据库的副本。
这些文件是
-
sakila-data.sql
: 填充表格所需的INSERT
语句以及触发器定义。 -
sakila-schema.sql
: 模式定义语句。
通过首先获取sakila-schema.sql
文件,然后获取sakila-data.sql
文件来安装sakila
数据库。例如,下面是使用 MySQL Shell:
MySQL [localhost+ ssl] SQL> \source sakila-schema.sql
MySQL [localhost+ ssl] SQL> \source sakila-data.sql
如果文件不在当前目录中,请添加文件的路径。
测试数据:雇员模式
employees
数据库(在 MySQL 文档下载页面上称为雇员数据;GitHub 知识库的名字是test_db
)最初是由王辅生和卡洛·扎尼奥洛创建的,是 MySQL 主页链接的最大的测试数据集。它提供了使用非分区表或对两个最大的表进行分区的选择。对于非分区版本,数据文件的总大小约为 180 MiB,而对于分区版本,约为 440 MiB。
计划
employees
数据库由六个表和两个视图组成。您可以选择再安装两个视图、五个存储函数和两个存储过程。表格如图 1-14 所示。
图 1-14
employees
数据库中的表格、视图和例程
按照今天的标准,它仍然是数据库中相对少量的数据,但是它足够大,您可以开始看到较低级别的争用,因此,它是第 18 章中用于导致信号量等待的模式。
装置
您可以下载一个包含安装所需文件的 ZIP 文件,也可以在 https://github.com/datacharmer/test_db
克隆 GitHub 库。在撰写本文时,只有一个名为 master 的分支。如果您已经下载了 ZIP 文件,它将解压到一个名为test_db-master
的目录中。
有几个文件。在 MySQL 8 中与安装employees
数据库相关的两个是employees.sql
和employees_partitioned.sql
。区别在于salaries
和titles
表是否被分区。这本书使用了非分区模式。(还有针对 MySQL 5.1 的employees_partitioned_5.1.sql
,其中不支持employees_partitioned.sql
中使用的分区方案。)
通过使用SOURCE
命令获取.dump
文件来加载数据,该命令仅在 MySQL Shell 8 . 0 . 19(由于一个错误,实际上是 8.0.20)和更高版本中受支持。转到源文件所在的目录,选择employees.sql
或employees_partitioned.sql
文件,这取决于您是否想要使用分区,例如
mysql> \source employees.sql
导入需要一点时间,并通过显示花费的时间来完成:
+---------------------+
| data_load_time_diff |
+---------------------+
| 00:02:50 |
+---------------------+
1 row in set (0.0085 sec)
或者,您可以通过获取objects.sql
文件来加载一些额外的视图和存储的例程:
mysql> \source objects.sql
当您使用concurrency_book.generate.load()
方法加载employees
模式时,objects.sql
文件总是包含在内。
现在,您已经准备好进入 MySQL 并发世界了。
摘要
本章开始了理解 MySQL 并发性的旅程,其中锁和事务是重要的主题。首先讨论了为什么需要锁以及它们存在于什么级别。然后讨论了事务必须包含在讨论中,因为一些锁在事务期间被持有,事务隔离级别影响锁的持续时间以及锁的数量。
本章的其余部分讨论了如何在本书中使用这些例子,并介绍了重现测试用例所需的三组测试数据。为了更容易加载数据和执行测试用例,还引入了 MySQL Shell 的concurrency_book.generate
模块。
在下一章,我们将讨论如何监控锁。
二、监控锁和互斥锁
监控对于了解系统中出现瓶颈的位置至关重要。您需要使用监控来确定争用的来源,并验证您所做的更改是否减少了争用。
这一章和接下来的两章概述了性能模式中的锁和互斥体监控、InnoDB 事务监控和一般事务监控。本书的其余部分展示了如何使用这些监控资源来识别和调查争用的例子。特别是第13—18章在案例研究的讨论中广泛使用了监控。
在本章中,你将学习如何监控锁和互斥体。主要资源是首先介绍的性能模式。接下来,讨论sys
模式中的现成报告。本章的后半部分涵盖了状态指标、InnoDB 锁监控和 InnoDB 互斥体监控。
Note
如果您还不知道各种锁和互斥锁是什么,请不要担心。稍后,您将通过本章中讨论的使用监控源的示例来了解这一点。
性能模式
性能模式包含除死锁之外的大多数可用锁信息的来源。您不仅可以直接使用性能模式中的锁信息;它还用于sys
模式中两个与锁相关的视图。此外,您可以使用性能模式来研究低级同步对象,如互斥体。首先,将展示如何调查元数据和表锁。
元数据和表锁
元数据锁是最普通的高级锁,支持从全局读锁到低级锁(如访问控制列表(ACL ))的各种锁。使用包含用户级锁、元数据锁等信息的metadata_locks
表来监控锁。要记录信息,必须启用wait/lock/metadata/sql/mdl
性能模式工具(在 MySQL 8 中默认启用)。后面有一个例子展示了如何启用仪器。
metadata_locks
表包含 11 列,汇总在表 2-1 中。
表 2-1
performance_schema.metadata_locks
表
列名
|
描述
|
| --- | --- |
| OBJECT_TYPE
| 持有的锁的种类,例如用于全局读锁的GLOBAL
和用于表和视图的TABLE
。附录 A 包括可能值的完整列表。 |
| OBJECT_SCHEMA
| 锁定的对象所属的架构。 |
| OBJECT_NAME
| 锁定对象的名称。 |
| COLUMN_NAME
| 对于列级锁,是锁定列的列名。 |
| OBJECT_INSTANCE_BEGIN
| 对象的内存地址。 |
| LOCK_TYPE
| 锁访问级别,如共享、独占或意图。附录 A 包括可能值的完整列表。 |
| LOCK_DURATION
| 锁保持多长时间。支持的值有STATEMENT
、TRANSACTION
和EXPLICIT
。 |
| LOCK_STATUS
| 锁的状态。除了授权和未决状态之外,它还可以显示锁请求超时、是受害者等。 |
| SOURCE
| 源代码中请求锁的位置。 |
| OWNER_THREAD_ID
| 请求锁的线程的性能架构线程 id。 |
| OWNER_EVENT_ID
| 请求锁的事件的事件 id。 |
表格的主键是OBJECT_INSTANCE_BEGIN
列。
清单 2-1 展示了一个获取表元数据锁并在metadata_locks
表中查询它的例子。有些细节对你来说会有所不同。
-- Connection Processlist ID Thread ID Event ID
-- --------------------------------------------------
-- 1 19 59 6
-- Connection 1
mysql> START TRANSACTION;
Query OK, 0 rows affected (0.0003 sec)
mysql> SELECT * FROM world.city WHERE ID = 130;
+-----+--------+-------------+-----------------+------------+
| ID | Name | CountryCode | District | Population |
+-----+--------+-------------+-----------------+------------+
| 130 | Sydney | AUS | New South Wales | 3276207 |
+-----+--------+-------------+-----------------+------------+
1 row in set (0.0005 sec)
mysql> SELECT *
FROM performance_schema.metadata_locks
WHERE OBJECT_TYPE = 'TABLE'
AND OBJECT_SCHEMA = 'world'
AND OBJECT_NAME = 'city'
AND OWNER_THREAD_ID = PS_CURRENT_THREAD_ID()\G
*************************** 1\. row ***************************
OBJECT_TYPE: TABLE
OBJECT_SCHEMA: world
OBJECT_NAME: city
COLUMN_NAME: NULL
OBJECT_INSTANCE_BEGIN: 2639965404080
LOCK_TYPE: SHARED_READ
LOCK_DURATION: TRANSACTION
LOCK_STATUS: GRANTED
SOURCE: sql_parse.cc:6162
OWNER_THREAD_ID: 59
OWNER_EVENT_ID: 10
1 row in set (0.0006 sec)
mysql> ROLLBACK;
Query OK, 0 rows affected (0.0006 sec)
Listing 2-1Example use of the metadata_locks table
这里你可以看到它是world.city
表上的一个表级锁。这是一个共享的读锁,因此其他连接可以同时获得同一个锁。
如果您想找出一个连接等待其锁请求被批准的原因,您需要查询metadata_locks
表中的一行,其中OBJECT_TYPE
、OBJECT_SCHEMA
和OBJECT_NAME
与挂起的锁相同,并且LOCK_STATUS
是GRANTED
。也就是说,要找到所有挂起锁的情况以及阻塞它们的原因,您需要一个自连接表的查询:
SELECT OBJECT_TYPE, OBJECT_SCHEMA, OBJECT_NAME,
w.OWNER_THREAD_ID AS WAITING_THREAD_ID,
b.OWNER_THREAD_ID AS BLOCKING_THREAD_ID
FROM performance_schema.metadata_locks w
INNER JOIN performance_schema.metadata_locks b
USING (OBJECT_TYPE, OBJECT_SCHEMA, OBJECT_NAME)
WHERE w.LOCK_STATUS = 'PENDING'
AND b.LOCK_STATUS = 'GRANTED';
您可以选择连接其他性能模式表,比如events_statements_current
,以获得更多关于锁等待中所涉及的连接的信息。或者,正如后面将要讨论的,对于表元数据锁,您可以使用sys.schema_table_lock_waits
视图。
一个不太常用的表是table_handles
,它保存关于打开的表句柄的信息,包括哪些表锁当前被锁定。必须启用wait/lock/table/sql/handler
性能模式仪器才能记录数据(这是默认设置)。可用的信息类似于metadata_locks
表的信息,清单 2-2 展示了一个在world.city
表上显式读锁的例子。有些细节对你来说会有所不同。
-- Connection Processlist ID Thread ID Event ID
-- --------------------------------------------------
-- 1 21 61 6
-- Connection 1
mysql> LOCK TABLE world.city READ;
Query OK, 0 rows affected (0.0004 sec)
mysql> SELECT *
FROM performance_schema.table_handles
WHERE OBJECT_SCHEMA = 'world'
AND OBJECT_NAME = 'city'
AND OWNER_THREAD_ID = PS_CURRENT_THREAD_ID()\G
*************************** 1\. row ***************************
OBJECT_TYPE: TABLE
OBJECT_SCHEMA: world
OBJECT_NAME: city
OBJECT_INSTANCE_BEGIN: 2639971828776
OWNER_THREAD_ID: 61
OWNER_EVENT_ID: 8
INTERNAL_LOCK: NULL
EXTERNAL_LOCK: READ EXTERNAL
1 row in set (0.0013 sec)
mysql> UNLOCK TABLES;
Query OK, 0 rows affected (0.0004 sec)
Listing 2-2Example of using the table_handles table
INTERNAL_LOCK
列包含 SQL 级别的锁信息,例如非 InnoDB 表上的显式表锁,而EXTERNAL_LOCK
包含存储引擎级别的锁信息,包括所有表的显式表锁。
与metadata_locks
表不同,您不能使用table_handles
表来调查锁争用(但是metadata_locks
表也包括显式的表锁,就像这个例子一样,所以您可以使用它)。
metadata_locks
和table_handles
表涉及最高级别的锁。锁粒度的下一步是拥有自己的表的数据锁。
数据锁
数据锁位于元数据锁和同步对象之间的中间级别。数据锁的特殊之处在于,它有很多种锁类型,比如记录锁、间隙锁、插入意图锁等。它们以复杂的方式相互作用,如第 7 章所述。这使得数据锁的监控表特别有用。
数据锁定信息分为两个表:
-
data_locks
: 该表包含表的细节,并记录 InnoDB 级别的锁。它显示当前持有的或待定的所有锁。 -
data_lock_waits
: 与data_locks
表一样,它显示了与 InnoDB 相关的锁,但是只显示那些等待被授予关于哪个线程正在阻塞请求的信息的锁。
您将经常组合使用这些工具来查找有关锁等待的信息。
MySQL 8 见证了锁监控表工作方式的改变。在 MySQL 5.7 和更早的版本中,信息在信息模式中的两个特定于 InnoDB 的视图中可用,INNODB_LOCKS
和INNODB_LOCK_WAITS
。主要区别在于,性能模式表被创建为与存储引擎无关,并且关于所有锁的信息总是可用的,而在 MySQL 5.7 和更早版本中,仅公开关于锁等待中涉及的锁的信息。所有的锁总是可用于研究,这使得 MySQL 8 表对于了解锁更加有用。
data_locks
表是包含每个锁的详细信息的主表。该表有 15 列,如表 2-2 所述。
表 2-2
performance_schema.data_locks
表
列名
|
描述
|
| --- | --- |
| ENGINE
| 数据的存储引擎。对于 MySQL 服务器,这将始终是 InnoDB。 |
| ENGINE_LOCK_ID
| 存储引擎使用的锁的内部 id。您不应该依赖具有特定格式的 id。 |
| ENGINE_TRANSACTION_ID
| 特定于存储引擎的事务 id。对于 InnoDB,您可以使用这个 id 连接到information_schema.INNODB_TRX
视图中的trx_id
列。您不应该依赖具有特定格式的 id,该 id 可能会在事务的持续时间内发生变化。 |
| THREAD_ID
| 发出锁定请求的线程的性能架构线程 id。 |
| EVENT_ID
| 发出锁定请求的事件的性能架构事件 id。您可以使用这个 id 来连接几个events_%
表,以找到关于是什么触发了锁请求的更多信息。 |
| OBJECT_SCHEMA
| 作为锁定请求主题的对象所在的架构。 |
| OBJECT_NAME
| 作为锁定请求主题的对象的名称。 |
| PARTITION_NAME
| 对于涉及分区的锁,是分区的名称。 |
| SUBPARTITION_NAME
| 对于涉及子分区的锁,是子分区的名称。 |
| INDEX_NAME
| 对于涉及索引的锁,是索引的名称。因为所有东西都是 InnoDB 的索引,所以索引名总是为 InnoDB 表上的记录级锁设置的。如果行被锁定,值将是PRIMARY
或GEN_CLUST_INDEX
,这取决于您是否有一个显式主键或表是否使用了隐藏聚集索引。 |
| OBJECT_INSTANCE_BEGIN
| 锁定请求的内存地址。 |
| LOCK_TYPE
| 锁定请求的级别。对于 InnoDB,可能的值是TABLE
和RECORD
。 |
| LOCK_MODE
| 使用的锁定模式。这包括它是共享锁还是排他锁,以及锁的更详细信息,例如,REC_NOT_GAP
表示记录锁,但没有间隙锁。 |
| LOCK_STATUS
| 锁是待定(WAITING
)还是已被授予(GRANTED
)。 |
| LOCK_DATA
| 关于被锁定数据的信息。例如,这可以是锁定索引记录的索引值。 |
表格的主键是(ENGINE_LOCK_ID
,ENGINE
)。
清单 2-3 显示了获取两个锁并查询data_locks
表的例子。id 和内存地址等信息会因您而异。
-- Connection Processlist ID Thread ID Event ID
-- --------------------------------------------------
-- 1 23 64 6
-- Connection 1
mysql> START TRANSACTION;
Query OK, 0 rows affected (0.0003 sec)
mysql> SELECT *
FROM world.city
WHERE ID = 130
FOR SHARE;
+-----+--------+-------------+-----------------+------------+
| ID | Name | CountryCode | District | Population |
+-----+--------+-------------+-----------------+------------+
| 130 | Sydney | AUS | New South Wales | 3276207 |
+-----+--------+-------------+-----------------+------------+
1 row in set (0.0068 sec)
mysql> SELECT *
FROM performance_schema.data_locks
WHERE THREAD_ID = PS_CURRENT_THREAD_ID()\G
*************************** 1\. row ***************************
ENGINE: INNODB
ENGINE_LOCK_ID: 2639727636640:3165:2639690712184
ENGINE_TRANSACTION_ID: 284114704347296
THREAD_ID: 64
EVENT_ID: 10
OBJECT_SCHEMA: world
OBJECT_NAME: city
PARTITION_NAME: NULL
SUBPARTITION_NAME: NULL
INDEX_NAME: NULL
OBJECT_INSTANCE_BEGIN: 2639690712184
LOCK_TYPE: TABLE
LOCK_MODE: IS
LOCK_STATUS: GRANTED
LOCK_DATA: NULL
*************************** 2\. row ***************************
ENGINE: INNODB
ENGINE_LOCK_ID: 2639727636640:1926:6:131:2639690709400
ENGINE_TRANSACTION_ID: 284114704347296
THREAD_ID: 64
EVENT_ID: 10
OBJECT_SCHEMA: world
OBJECT_NAME: city
PARTITION_NAME: NULL
SUBPARTITION_NAME: NULL
INDEX_NAME: PRIMARY
OBJECT_INSTANCE_BEGIN: 2639690709400
LOCK_TYPE: RECORD
LOCK_MODE: S,REC_NOT_GAP
LOCK_STATUS: GRANTED
LOCK_DATA: 130
2 rows in set (0.0018 sec)
mysql> ROLLBACK;
Query OK, 0 rows affected (0.0007 sec)
Listing 2-3Using the data_locks table
在本例中,查询获得了对world.city
表的插入意图(IS
)锁和一个共享(S
)记录,但没有获得值为 130 的主键的间隙锁(REC NOT_GAP
)。
data_lock_waits
表更简单,因为它只包括当前锁争用情况的基本信息,如表 2-3 所示。
表 2-3
performance_schema.data_lock_waits
表
列名
|
描述
|
| --- | --- |
| ENGINE
| 发生锁争用的存储引擎。 |
| REQUESTING_ENGINE_LOCK_ID
| 挂起锁的ENGINE_LOCK_ID
。 |
| REQUESTING_ENGINE_TRANSACTION_ID
| 挂起锁的ENGINE_TRANSACTION_ID
。 |
| REQUESTING_THREAD_ID
| 挂起锁的THREAD_ID
。 |
| REQUESTING_EVENT_ID
| 挂起锁的EVENT_ID
。 |
| REQUESTING_OBJECT_INSTANCE_BEGIN
| 挂起锁的OBJECT_INSTANCE_BEGIN
。 |
| BLOCKING_ENGINE_LOCK_ID
| 闭锁锁的ENGINE_LOCK_ID
。 |
| BLOCKING_ENGINE_TRANSACTION_ID
| 闭锁锁的ENGINE_TRANSACTION_ID
。 |
| BLOCKING_THREAD_ID
| 闭锁锁的THREAD_ID
。 |
| BLOCKING_EVENT_ID
| 闭锁锁的EVENT_ID
。 |
| BLOCKING_OBJECT_INSTANCE_BEGIN
| 闭锁锁的OBJECT_INSTANCE_BEGIN
。 |
该表没有主键。该表的主要目的是提供一种简单的方法来确定锁争用中涉及的挂起和阻塞锁请求。然后,您可以使用REQUESTING_ENGINE_TRANSACTION_ID
和BLOCKING_ENGINE_TRANSACTION_ID
列连接到data_locks
表以及其他表,以获得更多信息。一个很好的例子就是sys.innodb_lock_waits
视图。
到目前为止,已经讨论过的性能模式表是针对锁的,这些锁是执行语句的直接结果。在高并发性的情况下,还有一些较低级别的同步等待需要监控。
同步等待
同步等待是最难监控的,原因有几个。它们发生得非常频繁,通常持续时间很短,监控它们的开销很大。默认情况下,同步等待的检测也是不启用的。
同步等待分为五类:
-
cond
: 线程间使用的条件信号。 -
mutex
: 保护代码部分或其他资源的互斥点。 -
prlock
: 一个优先级读/写锁。 -
rwlock
: 读/写锁,用于限制对特定变量的并发访问,例如,用于改变gtid_mode
系统变量。 -
sxlock
: 共享-独占读/写锁。例如,目前只有 InnoDB 使用它来提高 B 树搜索的可伸缩性。
同步等待的仪器名称以wait/synch/
开头,后面是类别名称、等待所属的区域(如sql
或innodb
)以及等待的名称。例如,保护 InnoDB 双写缓冲区的互斥体名为wait/synch/mutex/innodb/dblwr_mutex
。
通过为您想要监控的仪器设置performance_schema.setup_instruments
表中的ENABLED
和可选的TIMED
列,您可以启用同步等待的仪器。此外,您需要启用events_waits_current
和可选的performance_schema.setup_consumers
中的events_waits_history
和/或events_waits_history_long
。例如,监控 InnoDB 双写缓冲区上的互斥锁
mysql> UPDATE performance_schema.setup_instruments
SET ENABLED = 'YES',
TIMED = 'YES'
WHERE NAME = 'wait/synch/mutex/innodb/dblwr_mutex';
Query OK, 1 row affected (0.0011 sec)
Rows matched: 1 Changed: 1 Warnings: 0
mysql> UPDATE performance_schema.setup_consumers
SET ENABLED = 'YES'
WHERE NAME = 'events_waits_current';
Query OK, 1 row affected (0.0005 sec)
Rows matched: 1 Changed: 1 Warnings: 0
一般来说,最好在配置文件中启用对同步工具的监控,以确保它们在 MySQL 启动时就已正确设置:
[mysqld]
performance_schema_instrument = wait/synch/mutex/innodb/dblwr_mutex=ON
performance_schema_consumer_events_waits_current = ON
然后重启 MySQL。
Caution
在生产系统上启用同步等待和相应消费者的工具时要非常小心。这样做可能会导致足够高的开销,以至于实际上会出现停机。启用的越多,开销就越高,监控干扰测量的可能性就越大,因此结论是错误的。
现在,您可以使用events_waits_%
表之一来监控等待:
-
events_waits_current
: 每个现有线程当前正在进行或上次完成的等待事件。这需要启用events_waits_current
消费者。 -
events_waits_history
: 每个现有线程的最后十个(performance_schema_events_waits_history_size
选项)等待事件。这要求除了events_waits_current
消费者之外,还要启用events_waits_history
消费者。 -
events_waits_history_long
: 全局最后 10,000 个(performance_schema_events_waits_history_long_size
选项)事件,包括不再存在的线程。这要求除了events_waits_current
消费者之外,还要启用events_waits_history_long
消费者。 -
events_waits_summary_by_account_by_event_name
: 由帐户的用户名和主机名(在性能模式中也称为参与者)分组的等待事件。 -
events_waits_summary_by_host_by_event_name
: 按触发事件的账户主机名和事件名称分组的等待事件。 -
events_waits_summary_by_instance
: 根据事件名称以及对象的内存地址(OBJECT_INSTANCE_BEGIN
)分组的等待事件。这对于具有多个实例的事件非常有用,可以监控等待是否在实例之间均匀分布。一个例子是表缓存互斥锁(wait/synch/mutex/sql/LOCK_table_cache
),每个表缓存实例(table_open_cache_instances
)有一个对象。 -
events_waits_summary_by_thread_by_event_name
: 按线程 id 和事件名分组的当前存在线程的等待事件。 -
events_waits_summary_by_user_by_event_name
: 按触发事件的账户用户名和事件名称分组的等待事件。 -
events_waits_summary_global_by_event_name
: 按事件名称分组的等待事件。此表有助于了解等待给定类型的事件所花费的时间。
考虑到同步等待通常持续的时间有多短以及遇到的频率有多高,汇总表通常对使用性能模式研究等待最有用。也就是说,由于相关的等待工具在默认情况下是不启用的,并且在监控它们时开销相对较高,所以通常 InnoDB monitor 的信号量部分或本章后面描述的SHOW ENGINE INNODB MUTEX
语句用于 InnoDB 互斥体和信号量。例外情况是当您想要调查特定的争用问题时。
使用性能模式进行锁分析的另一个有用方法是查询语句遇到的错误。
语句和错误表
性能模式包括几个表,可用于调查遇到的错误。由于由于超时或死锁而导致的获取锁的失败会触发错误,因此您可以查询与锁相关的错误,以确定哪些语句、帐户等受锁争用的影响最大。
在单个语句级别,您可以使用events_statements_current
、events_statements_history
和events_statements_history_long
来查看是否发生了任何错误或特定错误。默认情况下,前两个表是启用的,而events_statements_history_long
表要求您启用events_statements_history_long
消费者。清单 2-4 展示了一个锁等待超时的例子,以及它如何出现在events_statements_history
表中。
-- Connection Processlist ID Thread ID Event ID
-- --------------------------------------------------
-- 1 63 179 6
-- 2 64 180 6
-- Connection 1
Connection 1> START TRANSACTION;
Query OK, 0 rows affected (0.0003 sec)
Connection 1> UPDATE world.city
SET Population = Population + 1
WHERE ID = 130;
Query OK, 1 row affected (0.0011 sec)
Rows matched: 1 Changed: 1 Warnings: 0
-- Connection 2
Connection 2> SET SESSION innodb_lock_wait_timeout = 1;
Query OK, 0 rows affected (0.0003 sec)
Connection 2> START TRANSACTION;
Query OK, 0 rows affected (0.0002 sec)
Connection 2> UPDATE world.city
SET Population = Population + 1
WHERE ID = 130;
ERROR: 1205: Lock wait timeout exceeded; try restarting transaction
Connection 2> SELECT thread_id, event_id,
FORMAT_PICO_TIME(lock_time) AS lock_time,
sys.format_statement(SQL_TEXT) AS statement,
digest, mysql_errno,
returned_sqlstate, message_text, errors
FROM performance_schema.events_statements_history
WHERE thread_id = PS_CURRENT_THREAD_ID()
AND mysql_errno > 0\G
*************************** 1\. row ***************************
thread_id: 180
event_id: 10
lock_time: 271.00 us
statement: UPDATE world.city SET Popul ... Population + 1 WHERE ID = 130
digest: 3e9795ad6fc0f4e3a4b4e99f33fbab2dc7b40d0761a8adbc60abfab02326108d
mysql_errno: 1205
returned_sqlstate: HY000
message_text: Lock wait timeout exceeded; try restarting transaction
errors: 1
1 row in set (0.0016 sec)
-- Connection 1
Connection 1> ROLLBACK;
Query OK, 0 rows affected (0.0472 sec)
-- Connection 2
Connection 2> ROLLBACK;
Query OK, 0 rows affected (0.0003 sec)
Listing 2-4Example of a lock error in the statement tables
这个例子中有一些值得注意的地方。首先,锁时间只有 271 微秒,尽管在锁等待超时发生之前花了整整一秒钟。也就是说,在 InnoDB 中等待记录锁不会增加性能模式报告的锁时间,因此您不能使用它来调查记录级锁争用。
第二件事是,mysql_errno
、returned_sqlstate
和message_text
包含了返回给客户端的相同的错误信息,这使得它对于查询很有用,因为在本例中也是这样做的。第三,errors
列包含遇到的错误数量的计数。虽然计数并不说明错误的性质,但它很有用,因为与包含错误细节的列不同,错误计数器也出现在语句摘要表中,因此您可以使用它来查找哪些语句遇到了任何类型的错误。
Tip
记录应用中遇到的错误会很有用。例如,您可以使用 Splunk 之类的服务来分析应用日志,以生成显示遇到了哪些错误以及这些错误何时会成为问题的报告。
在这种情况下,一组特别重要的汇总表由汇总错误的表组成。有五个这样的表,分别按帐户、主机、线程、用户和全局分组:
mysql> SHOW TABLES FROM performance_schema LIKE '%error%';
+-------------------------------------------+
| Tables_in_performance_schema (%error%) |
+-------------------------------------------+
| events_errors_summary_by_account_by_error |
| events_errors_summary_by_host_by_error |
| events_errors_summary_by_thread_by_error |
| events_errors_summary_by_user_by_error |
| events_errors_summary_global_by_error |
+-------------------------------------------+
5 rows in set (0.0012 sec)
例如,检索锁等待超时和死锁的统计信息
mysql> SELECT *
FROM performance_schema.events_errors_summary_global_by_error
WHERE error_name IN ('ER_LOCK_WAIT_TIMEOUT',
'ER_LOCK_DEADLOCK')\G
*************************** 1\. row ***************************
ERROR_NUMBER: 1205
ERROR_NAME: ER_LOCK_WAIT_TIMEOUT
SQL_STATE: HY000
SUM_ERROR_RAISED: 4
SUM_ERROR_HANDLED: 0
FIRST_SEEN: 2020-06-28 11:33:10
LAST_SEEN: 2020-06-28 11:49:30
*************************** 2\. row ***************************
ERROR_NUMBER: 1213
ERROR_NAME: ER_LOCK_DEADLOCK
SQL_STATE: 40001
SUM_ERROR_RAISED: 3
SUM_ERROR_HANDLED: 0
FIRST_SEEN: 2020-06-27 12:06:38
LAST_SEEN: 2020-06-27 12:54:27
2 rows in set (0.0048 sec)
虽然这不能帮助您确定哪些语句遇到了错误,但它可以帮助您监控遇到错误的频率,并以此方式确定锁错误是否变得更加频繁。
Tip
从 MySQL 启动开始,所有已知的错误都会填充到events_errors_summary_global_by_error
中,即使还没有遇到错误。因此,您可以随时安全地查询特定的错误,包括使用该表从名称中查找错误号。
性能模式表中的数据是原始数据,可以是单个事件,也可以是聚合数据。通常,当您调查锁问题或监控锁问题时,更感兴趣的是确定是否有任何锁等待,或者获取花费大部分时间的等待事件的报告。对于这些信息,您需要使用sys
模式。
sys 架构
sys
模式可以被认为是视图的集合,这些视图作为关于性能模式和信息模式以及各种实用函数和过程的报告。对于这个讨论,重点是两个视图,它们获取性能模式表中的信息并返回锁对,其中一个锁由于另一个锁而不能被授予。因此,它们显示了锁等待的问题所在。这两个视图是innodb_lock_waits
和schema_table_lock_waits
。
innodb_lock_waits
视图使用性能模式中的data_locks
和data_lock_waits
视图返回 InnoDB 记录锁的所有锁等待情况。它显示诸如连接试图获取什么锁以及涉及哪些连接和查询之类的信息。如果您需要没有格式的信息,视图也以x$innodb_lock_waits
的形式存在。
schema_table_lock_waits
视图以类似的方式工作,但是使用metadata_locks
表返回与模式对象相关的锁等待。该信息在x$schema_table_lock_waits
视图中也是无格式的。
Tip
还存在一些视图,其中 x的视图相同,只是所有数据都是无格式的。这使得数据更适合处理信息的脚本和程序。
对于争用的高级视图,您还可以使用状态计数器和 InnoDB 指标。
状态计数器和 InnoDB 指标
有几个状态计数器和 InnoDB 指标提供关于锁定的信息。这些主要用于全局(实例)级别,对于检测锁问题的总体增加非常有用。
查询数据
状态计数器和 InnoDB 指标有两个来源。全局状态计数器可以在performance_schema.global_status
表中找到,或者通过SHOW GLOBAL STATUS
语句找到。InnoDB 指标可以在information_schema.INNODB_METRICS
视图中找到。
InnoDB 指标类似于全局状态变量,可以提供一些关于 InnoDB 状态的有价值的信息。NAME
列可用于按名称查询指标。在撰写本文时,有 313 个可见指标,其中 74 个默认启用。还有一个隐藏的指标是latch
指标,它控制是否收集互斥等待统计数据。度量被分组到子系统中(SUBSYSTEM
列),对于每个度量,在COMMENT
列中有一个度量测量什么的描述,以及度量的类型(计数器、值等)。)可以在TYPE
一栏看到。
一起监控所有这些指标的一个好方法是使用sys.metrics
视图。清单 2-5 展示了一个检索指标的例子。
-- Connection Processlist ID Thread ID Event ID
-- --------------------------------------------------
-- 1 27 69 6
-- Connection 1
mysql> SELECT Variable_name,
Variable_value AS Value,
Enabled
FROM sys.metrics
WHERE Variable_name LIKE 'innodb_row_lock%'
OR Variable_name LIKE 'Table_locks%'
OR Variable_name LIKE 'innodb_rwlock_%'
OR Type = 'InnoDB Metrics - lock';
+-------------------------------+--------+---------+
| Variable_name | Value | Enabled |
+-------------------------------+--------+---------+
| innodb_row_lock_current_waits | 0 | YES |
| innodb_row_lock_time | 2163 | YES |
| innodb_row_lock_time_avg | 721 | YES |
| innodb_row_lock_time_max | 2000 | YES |
| innodb_row_lock_waits | 3 | YES |
| table_locks_immediate | 330 | YES |
| table_locks_waited | 0 | YES |
| lock_deadlock_false_positives | 0 | YES |
| lock_deadlock_rounds | 37214 | YES |
| lock_deadlocks | 1 | YES |
| lock_rec_grant_attempts | 1 | YES |
| lock_rec_lock_created | 0 | NO |
| lock_rec_lock_removed | 0 | NO |
| lock_rec_lock_requests | 0 | NO |
| lock_rec_lock_waits | 0 | NO |
| lock_rec_locks | 0 | NO |
| lock_rec_release_attempts | 24317 | YES |
| lock_row_lock_current_waits | 0 | YES |
| lock_schedule_refreshes | 37214 | YES |
| lock_table_lock_created | 0 | NO |
| lock_table_lock_removed | 0 | NO |
| lock_table_lock_waits | 0 | NO |
| lock_table_locks | 0 | NO |
| lock_threads_waiting | 0 | YES |
| lock_timeouts | 1 | YES |
| innodb_rwlock_s_os_waits | 12248 | YES |
| innodb_rwlock_s_spin_rounds | 19299 | YES |
| innodb_rwlock_s_spin_waits | 6811 | YES |
| innodb_rwlock_sx_os_waits | 171 | YES |
| innodb_rwlock_sx_spin_rounds | 5239 | YES |
| innodb_rwlock_sx_spin_waits | 182 | YES |
| innodb_rwlock_x_os_waits | 26283 | YES |
| innodb_rwlock_x_spin_rounds | 774745 | YES |
| innodb_rwlock_x_spin_waits | 12666 | YES |
+-------------------------------+--------+---------+
34 rows in set (0.0174 sec)
Listing 2-5Lock metrics
innodb_row_lock_%
、lock_deadlocks
和lock_timeouts
度量是最有趣的。行锁指标显示了当前有多少锁正在等待,并统计了等待获取 InnoDB 记录锁所花费的时间(毫秒)。lock_deadlocks
和lock_timeouts
指标分别显示遇到的死锁和锁等待超时的数量。
如果遇到 InnoDB 互斥或信号量争用,那么innodb_rwlock_%
度量对于监控等待发生的速率以及等待花费了多少轮是有用的。
正如您所看到的,并非所有的指标都是默认启用的(这些都是 InnoDB 指标),所以让我们研究一下如何启用和禁用来自INNODB_METRICS
视图的指标。
配置 InnoDB 指标
可以配置 InnoDB 指标,因此您可以选择启用哪些指标,并且可以重置统计数据。您可以使用全局系统变量启用、禁用和重置指标:
-
innodb_monitor_disable
: 禁用一个或多个度量。 -
innodb_monitor_enable
: 启用一个或多个指标。 -
innodb_monitor_reset
: 重置一个或多个指标的计数器。 -
innodb_monitor_reset_all
: 重置所有统计信息,包括一个或多个度量的计数器、最小值和最大值。
可以根据需要打开和关闭指标,在INNODB_METRICS
视图的STATUS
列中找到当前状态。您可以指定指标的名称或子系统的名称,在前面加上module_
作为innodb_monitor_enable
或innodb_monitor_disable
变量的值,并且您可以使用%作为通配符。值all
作为一个特殊值影响所有指标。
Note
当您指定一个模块时,只有当没有与该模块匹配的度量时,它才会按预期工作。不能指定模块的例子有module_cpu
、module_page_track
和module_dblwr
。
清单 2-6 展示了一个启用和使用所有匹配icp%
的指标的例子(恰好是icp
–索引条件下推–子系统中的指标)。查询完指标后,使用子系统作为参数再次禁用它们。COUNT
的值取决于查询时的工作量。
-- Connection Processlist ID Thread ID Event ID
-- --------------------------------------------------
-- 1 32 74 6
-- Connection 1
mysql> SET GLOBAL innodb_monitor_enable = 'icp%';
Query OK, 0 rows affected (0.0003 sec)
mysql> SELECT NAME, SUBSYSTEM, COUNT, MIN_COUNT,
MAX_COUNT, AVG_COUNT,
STATUS, COMMENT
FROM information_schema.INNODB_METRICS
WHERE SUBSYSTEM = 'icp'\G
*************************** 1\. row ***************************
NAME: icp_attempts
SUBSYSTEM: icp
COUNT: 0
MIN_COUNT: NULL
MAX_COUNT: NULL
AVG_COUNT: 0
STATUS: enabled
COMMENT: Number of attempts for index push-down condition checks
*************************** 2\. row ***************************
NAME: icp_no_match
SUBSYSTEM: icp
COUNT: 0
MIN_COUNT: NULL
MAX_COUNT: NULL
AVG_COUNT: 0
STATUS: enabled
COMMENT: Index push-down condition does not match
*************************** 3\. row ***************************
NAME: icp_out_of_range
SUBSYSTEM: icp
COUNT: 0
MIN_COUNT: NULL
MAX_COUNT: NULL
AVG_COUNT: 0
STATUS: enabled
COMMENT: Index push-down condition out of range
*************************** 4\. row ***************************
NAME: icp_match
SUBSYSTEM: icp
COUNT: 0
MIN_COUNT: NULL
MAX_COUNT: NULL
AVG_COUNT: 0
STATUS: enabled
COMMENT: Index push-down condition matches
4 rows in set (0.0011 sec)
mysql> SET GLOBAL innodb_monitor_disable = 'module_icp';
Query OK, 0 rows affected (0.0004 sec)
Listing 2-6Using the INNODB_METRICS view
首先,使用innodb_monitor_enable
变量启用指标;然后检索这些值。除了显示的值,还有一组带_RESET
后缀的列,当您设置innodb_monitor_reset
(仅计数器)或innodb_monitor_reset_all
系统变量时,这些列会被重置。最后,指标再次被禁用。
Caution
这些指标有不同的开销,因此建议您在生产中启用指标之前先测试您的工作负载。
InnoDB 锁监控器和死锁记录
InnoDB 很久以前就有了自己的锁监控器,锁信息在 InnoDB 监控器输出中返回。默认情况下,InnoDB 监控器包含关于最新死锁以及锁等待中涉及的锁的信息。通过启用innodb_status_output_locks
选项(默认禁用),将列出所有锁;这类似于性能模式data_locks
表中的内容。
为了演示死锁和事务信息,您可以使用清单 2-7 中的步骤创建一个死锁。
-- Connection Processlist ID Thread ID Event ID
-- --------------------------------------------------
-- 1 19 66 6
-- 2 20 67 6
-- Connection 1
Connection 1> START TRANSACTION;
Query OK, 0 rows affected (0.0003 sec)
Connection 1> UPDATE world.city
SET Population = Population + 1
WHERE ID = 130;
Query OK, 1 row affected (0.0008 sec)
Rows matched: 1 Changed: 1 Warnings: 0
-- Connection 2
Connection 2> START TRANSACTION;
Query OK, 0 rows affected (0.0002 sec)
Connection 2> UPDATE world.city
SET Population = Population + 1
WHERE ID = 3805;
Query OK, 1 row affected (0.0008 sec)
Rows matched: 1 Changed: 1 Warnings: 0
Connection 2> UPDATE world.city
SET Population = Population + 1
WHERE ID = 130;
-- Connection 1
Connection 1> UPDATE world.city
SET Population = Population + 1
WHERE ID = 3805;
2020-06-27 12:54:26.833760 1 [ERROR] mysqlsh.DBError ...
ERROR: 1213: Deadlock found when trying to get lock; try restarting transaction
-- Connection 2
Query OK, 1 row affected (0.1013 sec)
Rows matched: 1 Changed: 1 Warnings: 0
Listing 2-7An example of creating a deadlock
使用SHOW ENGINE INNODB STATUS
语句生成 InnoDB 锁监控器输出。清单 2-8 显示了在执行清单 2-7 中的语句后,启用所有锁信息并生成监控器输出的示例。(清单 2-8 中使用的语句作为清单 2-7 工作负载的调查包含在concurrency_book
Python 模块中。)完整的 InnoDB monitor 输出也可以从本书的 GitHub 资源库的listing_2_8.txt
文件中获得。
-- Investigation #1
-- Connection 3
Connection 3> SET GLOBAL innodb_status_output_locks = ON;
Query OK, 0 rows affected (0.0005 sec)
-- Investigation #3
Connection 3> SHOW ENGINE INNODB STATUS\G
*************************** 1\. row ***************************
Type: InnoDB
Name:
Status:
=====================================
2020-06-27 12:54:29 0x7f00 INNODB MONITOR OUTPUT
=====================================
Per second averages calculated from the last 50 seconds
-----------------
BACKGROUND THREAD
-----------------
srv_master_thread loops: 2532 srv_active, 0 srv_shutdown, 1224 srv_idle
srv_master_thread log flush and writes: 0
----------
SEMAPHORES
----------
OS WAIT ARRAY INFO: reservation count 7750
OS WAIT ARRAY INFO: signal count 6744
RW-shared spins 3033, rounds 5292, OS waits 2261
RW-excl spins 1600, rounds 25565, OS waits 1082
RW-sx spins 2167, rounds 61634, OS waits 1874
Spin rounds per wait: 1.74 RW-shared, 15.98 RW-excl, 28.44 RW-sx
------------------------
LATEST DETECTED DEADLOCK
------------------------
2020-06-27 12:54:26 0x862c
*** (1) TRANSACTION:
TRANSACTION 296726, ACTIVE 0 sec starting index read
mysql tables in use 1, locked 1
LOCK WAIT 3 lock struct(s), heap size 1136, 2 row lock(s), undo log entries 1
MySQL thread id 20, OS thread handle 29332, query id 56150 localhost ::1 root updating
UPDATE world.city
SET Population = Population + 1
WHERE ID = 130
*** (1) HOLDS THE LOCK(S):
RECORD LOCKS space id 259 page no 34 n bits 248 index PRIMARY of table `world`.`city` trx id 296726 lock_mode X locks rec but not gap
Record lock, heap no 66 PHYSICAL RECORD: n_fields 7; compact format; info bits 0
0: len 4; hex 80000edd; asc ;;
1: len 6; hex 000000048716; asc ;;
2: len 7; hex 020000015f2949; asc _)I;;
3: len 30; hex 53616e204672616e636973636f2020202020202020202020202020202020; asc San Francisco ; (total 35 bytes);
4: len 3; hex 555341; asc USA;;
5: len 20; hex 43616c69666f726e696120202020202020202020; asc California ;;
6: len 4; hex 800bda1e; asc ;;
*** (1) WAITING FOR THIS LOCK TO BE GRANTED:
RECORD LOCKS space id 259 page no 7 n bits 248 index PRIMARY of table `world`.`city` trx id 296726 lock_mode X locks rec but not gap waiting
Record lock, heap no 44 PHYSICAL RECORD: n_fields 7; compact format; info bits 0
0: len 4; hex 80000082; asc ;;
1: len 6; hex 000000048715; asc ;;
2: len 7; hex 01000000d81fcd; asc ;;
3: len 30; hex 5379646e6579202020202020202020202020202020202020202020202020; asc Sydney ; (total 35 bytes);
4: len 3; hex 415553; asc AUS;;
5: len 20; hex 4e657720536f7574682057616c65732020202020; asc New South Wales ;;
6: len 4; hex 8031fdb0; asc 1 ;;
*** (2) TRANSACTION:
TRANSACTION 296725, ACTIVE 0 sec starting index read
mysql tables in use 1, locked 1
LOCK WAIT 3 lock struct(s), heap size 1136, 2 row lock(s), undo log entries 1
MySQL thread id 19, OS thread handle 6576, query id 56151 localhost ::1 root updating
UPDATE world.city
SET Population = Population + 1
WHERE ID = 3805
*** (2) HOLDS THE LOCK(S):
RECORD LOCKS space id 259 page no 7 n bits 248 index PRIMARY of table `world`.`city` trx id 296725 lock_mode X locks rec but not gap
Record lock, heap no 44 PHYSICAL RECORD: n_fields 7; compact format; info bits 0
0: len 4; hex 80000082; asc ;;
1: len 6; hex 000000048715; asc ;;
2: len 7; hex 01000000d81fcd; asc ;;
3: len 30; hex 5379646e6579202020202020202020202020202020202020202020202020; asc Sydney ; (total 35 bytes);
4: len 3; hex 415553; asc AUS;;
5: len 20; hex 4e657720536f7574682057616c65732020202020; asc New South Wales ;;
6: len 4; hex 8031fdb0; asc 1 ;;
*** (2) WAITING FOR THIS LOCK TO BE GRANTED:
RECORD LOCKS space id 259 page no 34 n bits 248 index PRIMARY of table `world`.`city` trx id 296725 lock_mode X locks rec but not gap waiting
Record lock, heap no 66 PHYSICAL RECORD: n_fields 7; compact format; info bits 0
0: len 4; hex 80000edd; asc ;;
1: len 6; hex 000000048716; asc ;;
2: len 7; hex 020000015f2949; asc _)I;;
3: len 30; hex 53616e204672616e636973636f2020202020202020202020202020202020; asc San Francisco ; (total 35 bytes);
4: len 3; hex 555341; asc USA;;
5: len 20; hex 43616c69666f726e696120202020202020202020; asc California ;;
6: len 4; hex 800bda1e; asc ;;
*** WE ROLL BACK TRANSACTION (2)
------------
TRANSACTIONS
------------
Trx id counter 296728
Purge done for trx's n:o < 296728 undo n:o < 0 state: running but idle
History list length 1
LIST OF TRANSACTIONS FOR EACH SESSION:
---TRANSACTION 283598406541472, not started
0 lock struct(s), heap size 1136, 0 row lock(s)
---TRANSACTION 283598406540640, not started
0 lock struct(s), heap size 1136, 0 row lock(s)
---TRANSACTION 283598406539808, not started
0 lock struct(s), heap size 1136, 0 row lock(s)
---TRANSACTION 283598406538976, not started
0 lock struct(s), heap size 1136, 0 row lock(s)
---TRANSACTION 296726, ACTIVE 3 sec
3 lock struct(s), heap size 1136, 2 row lock(s), undo log entries 2
MySQL thread id 20, OS thread handle 29332, query id 56150 localhost ::1 root
TABLE LOCK table `world`.`city` trx id 296726 lock mode IX
RECORD LOCKS space id 259 page no 34 n bits 248 index PRIMARY of table `world`.`city` trx id 296726 lock_mode X locks rec but not gap
Record lock, heap no 66 PHYSICAL RECORD: n_fields 7; compact format; info bits 0
0: len 4; hex 80000edd; asc ;;
1: len 6; hex 000000048716; asc ;;
2: len 7; hex 020000015f2949; asc _)I;;
3: len 30; hex 53616e204672616e636973636f2020202020202020202020202020202020; asc San Francisco ; (total 35 bytes);
4: len 3; hex 555341; asc USA;;
5: len 20; hex 43616c69666f726e696120202020202020202020; asc California ;;
6: len 4; hex 800bda1e; asc ;;
RECORD LOCKS space id 259 page no 7 n bits 248 index PRIMARY of table `world`.`city` trx id 296726 lock_mode X locks rec but not gap
Record lock, heap no 44 PHYSICAL RECORD: n_fields 7; compact format; info bits 0
0: len 4; hex 80000082; asc ;;
1: len 6; hex 000000048716; asc ;;
2: len 7; hex 020000015f296c; asc _)l;;
3: len 30; hex 5379646e6579202020202020202020202020202020202020202020202020; asc Sydney ; (total 35 bytes);
4: len 3; hex 415553; asc AUS;;
5: len 20; hex 4e657720536f7574682057616c65732020202020; asc New South Wales ;;
6: len 4; hex 8031fdb0; asc 1 ;;
...
-- Investigation #2
Connection 3> SET GLOBAL innodb_status_output_locks = OFF;
Query OK, 0 rows affected (0.0005 sec)
Listing 2-8The InnoDB monitor output
附录 A 包括报告各部分的概述。
靠近顶部的部分是LATEST DETECTED DEADLOCK
部分,它包括最近一次死锁所涉及的事务和锁的详细信息以及它发生的时间。如果自 MySQL 最后一次重启以来没有发生死锁,则省略这一节。第 16 章包括一个调查死锁的例子。
Note
InnoDB 监控器输出中的 deadlock 部分仅包含涉及 InnoDB 记录锁的死锁信息。对于涉及非 InnoDB 锁(如用户级锁)的死锁,没有等效的信息。
输出再往下一点,是列出 InnoDB 事务的部分TRANSACTIONS
。请注意,不持有任何锁的事务(例如,纯SELECT
查询)不包括在内。在本例中,world.city
表上有一个意向排他锁,主键等于 3805(第一个字段的记录锁信息中的 80000edd 表示值为 0xedd 的行,与十进制表示法中的 3805 相同)和 130 (80000082)的行上有排他锁。
Tip
现在,InnoDB 监控器输出中的锁信息最好从performance_schema.data_locks
和performance_schema.data_lock_waits
表中获得。然而,死锁信息仍然非常有用。
通过启用innodb_status_output
选项,您可以请求每隔 15 秒将监控器输出转储到 stderr。请注意,输出非常大,所以如果启用它,请做好错误日志快速增长的准备。InnoDB monitor 输出也很容易隐藏关于更严重问题的消息。InnoDB 还支持在某些情况下自动将监控器输出输出到错误日志中,比如当 InnoDB 很难在缓冲池中找到空闲块或者有长时间的信号量等待时。
如果您想确保记录所有死锁,您可以启用innodb_print_all_deadlocks
选项。这导致每次发生死锁时,InnoDB monitor 输出中的死锁信息都会打印到错误日志中。如果您需要调查死锁,这可能是有用的,但是建议您仅在需要时启用它,以避免错误日志变得非常大并可能隐藏其他问题。
Caution
如果启用 InnoDB 监控器的常规输出或关于所有死锁的信息,请小心。这些信息很容易隐藏错误日志中记录的重要消息。
InnoDB monitor 输出的顶部包含关于信号量等待的信息,这是最后要讨论的监控类别。
InnoDB 互斥和信号量
InnoDB 使用互斥对象(通常称为互斥锁)和信号量来保护代码路径,例如,在更新缓冲池时避免竞争情况。在 MySQL 中有三种资源可用于监控互斥体,其中两种已经遇到过。最通用的工具是性能模式中的同步等待;但是,它们在默认情况下是不启用的,启用后会导致性能问题。本节重点介绍 InnoDB 特有的另外两个资源。
Note
在 InnoDB 监控中,互斥体和信号量之间没有明显的区别。
如前一节所述,InnoDB monitor 输出包含一个信号量部分,该部分显示一些常规统计信息以及当前正在等待的信号量。清单 2-9 显示了正在等待的信号量部分的一个例子。(按需生成信号量等待并不简单,所以没有包括复制步骤。参见第 18 章,了解可能导致信号量等待的工作负载示例。)
----------
SEMAPHORES
----------
OS WAIT ARRAY INFO: reservation count 831
--Thread 28544 has waited at buf0buf.cc line 4637 for 0 seconds the semaphore:
Mutex at 000001F1AD24D5E8, Mutex BUF_POOL_LRU_LIST created buf0buf.cc:1228, lock var 1
--Thread 10676 has waited at buf0flu.cc line 1639 for 1 seconds the semaphore:
Mutex at 000001F1AD24D5E8, Mutex BUF_POOL_LRU_LIST created buf0buf.cc:1228, lock var 1
--Thread 10900 has waited at buf0lru.cc line 1051 for 0 seconds the semaphore:
Mutex at 000001F1AD24D5E8, Mutex BUF_POOL_LRU_LIST created buf0buf.cc:1228, lock var 1
--Thread 28128 has waited at buf0buf.cc line 2797 for 1 seconds the semaphore:
Mutex at 000001F1AD24D5E8, Mutex BUF_POOL_LRU_LIST created buf0buf.cc:1228, lock var 1
--Thread 33584 has waited at buf0buf.cc line 2945 for 0 seconds the semaphore:
Mutex at 000001F1AD24D5E8, Mutex BUF_POOL_LRU_LIST created buf0buf.cc:1228, lock var 1
OS WAIT ARRAY INFO: signal count 207
RW-shared spins 51, rounds 86, OS waits 35
RW-excl spins 39, rounds 993, OS waits 35
RW-sx spins 30, rounds 862, OS waits 25
Spin rounds per wait: 1.69 RW-shared, 25.46 RW-excl, 28.73 RW-sx
Listing 2-9The InnoDB monitor semaphores section
在这种情况下,第一次等待是在第buf0buf.cc
行 4637,这是指请求互斥锁的源代码文件名和行号。行号取决于你使用的版本号,编译器/平台甚至可以让行号变一个。buf0buf.cc
指的是 MySQL 8.0.21 中第 4637 行左右包含以下代码(行号是每行的前缀):
4577 /** Inits a page for read to the buffer buf_pool. If the page is
4578 (1) already in buf_pool, or
4579 (2) if we specify to read only ibuf pages and the page is not an ibuf page, or
4580 (3) if the space is deleted or being deleted,
4581 then this function does nothing.
4582 Sets the io_fix flag to BUF_IO_READ and sets a non-recursive exclusive lock
4583 on the buffer frame. The io-handler must take care that the flag is cleared
4584 and the lock released later.
4585 @param[out] err DB_SUCCESS or DB_TABLESPACE_DELETED
4586 @param[in] mode BUF_READ_IBUF_PAGES_ONLY, ...
4587 @param[in] page_id page id
4588 @param[in] page_size page size
4589 @param[in] unzip TRUE=request uncompressed page
4590 @return pointer to the block or NULL */
4591 buf_page_t *buf_page_init_for_read(dberr_t *err, ulint mode,
4592 const page_id_t &page_id,
4593 const page_size_t &page_size, ibool unzip) {
...
4637 mutex_enter(&buf_pool->LRU_list_mutex);
...
该函数试图将一个页面读入缓冲池,并在第 4637 行请求缓冲池的 LRU 列表上的互斥锁。这个互斥体是在buf0buf.cc:1228
中创建的(也可以从信号量部分看到)。所有等待都是为了同一个互斥体,但是在源代码的不同部分。因此,这意味着存在维护 InnoDB 缓冲池的最近最少使用列表的争用。(本例中的等待是在对一个将近 2 GiB 的大表执行并发查询时通过使用innodb_buffer_pool_size = 5M
创建的。)
因此,在研究信号量等待时,通常有必要参考源代码。也就是说,文件名很好地暗示了争用发生在代码的哪个部分,例如,buf0buf.cc
与缓冲池相关,而buf0flu.cc
与缓冲池刷新算法相关。
信号量部分对于查看正在进行的等待很有用,但是在监控一段时间的情况下用处不大。为此,InnoDB 互斥监控器是一个更好的选择。您可以使用SHOW ENGINE INNODB MUTEX
语句访问互斥监控器:
mysql> SHOW ENGINE INNODB MUTEX;
+--------+------------------------------+------------+
| Type | Name | Status |
+--------+------------------------------+------------+
| InnoDB | rwlock: dict0dict.cc:2455 | waits=748 |
| InnoDB | rwlock: dict0dict.cc:2455 | waits=171 |
| InnoDB | rwlock: fil0fil.cc:3206 | waits=38 |
| InnoDB | rwlock: sync0sharded_rw.h:72 | waits=1 |
| InnoDB | rwlock: sync0sharded_rw.h:72 | waits=1 |
| InnoDB | rwlock: sync0sharded_rw.h:72 | waits=1 |
| InnoDB | sum rwlock: buf0buf.cc:778 | waits=2436 |
+--------+------------------------------+------------+
7 rows in set (0.0111 sec)
文件名和行号指的是创建互斥体的位置。互斥体监控器并不是 MySQL 中最用户友好的工具,因为每个互斥体可能会出现多次,并且在不解析输出的情况下无法对等待进行求和。但是,默认情况下它是启用的,因此您可以随时使用它。
Note
SHOW ENGINE INNODB MUTEX
仅包括至少等待过一次操作系统的互斥体和读写锁信号量。
使用latch
InnoDB 度量(它是隐藏的,所以您看不到当前值)来启用和禁用互斥信息的收集。通常没有理由禁用latch
指标。
摘要
本章介绍了可用于监控和调查锁的资源。首先考虑性能模式表。有专门的表用于查询当前的元数据和数据锁请求,其中包含关于作为锁目标的对象的信息,该对象是共享锁还是排他锁,以及锁请求是否已被授予。在最低级别,也有允许您调查同步等待的表;但是,默认情况下这些功能是不启用的,并且开销很大。在粒度尺度的另一端,语句表和错误汇总表可用于调查哪些语句遇到了错误以及错误的频率。
其次,sys
模式对于调查锁等待问题也很有用,其中innodb_lock_waits
视图提供了关于正在进行的 InnoDB 数据锁等待的信息,而schema_table_lock_waits
视图提供了关于正在进行的表元数据锁等待的信息。
第三,在最高级别,状态计数器和 InnoDB 指标给出了实例上活动的概述,包括锁的使用和获取锁的失败。如果您想要更多关于 InnoDB 锁的信息,那么锁监控器提供了与性能模式中的数据锁表类似的信息,但是使用的格式不太方便,InnoDB 监控器包括最近发生的死锁的详细信息。InnoDB 监控器还包括关于信号量等待的信息,最后,InnoDB 互斥体监控器提供关于互斥体等待的统计信息。
获取锁使用信息的另一个有用方法是查看事务信息。这将在下一章考虑。
三、监控 InnoDB 事务
在前一章中,你学习了如何在相对较低的层次上找到关于锁的信息。包含更高级别的信息也很重要,因为锁的持续时间一直到事务完成。(用户锁和显式表锁除外,它们可以持续更长时间。)在 MySQL Server 中,事务的意思是 InnoDB,本章重点是监控 InnoDB 事务。
首先将介绍信息模式中的INNODB_TRX
视图。在调查正在进行的事务时,这通常是最重要的资源。关于事务的另一个信息源是 InnoDB monitor,您在前一章中也遇到了它。最后,讨论了INNODB_METRICS
和sys.metrics
视图中的指标。
信息模式 INNODB_TRX
信息模式中的INNODB_TRX
视图是关于 InnoDB 事务的最专门的信息源。它包括诸如事务何时开始、修改了多少行以及持有多少锁之类的信息。INNODB_TRX
视图也被sys.innodb_lock_waits
视图用来提供一些关于锁等待问题所涉及的事务的信息。表 3-1 汇总了表中的栏目。
表 3-1
information_schema.INNODB_TRX
视图中的列
列/数据类型
|
描述
|
| --- | --- |
| trx_id``varchar(18)
| 事务记录 id。这在引用事务或与 InnoDB 监控器的输出进行比较时非常有用。否则,id 应该被视为纯内部的,没有任何意义。该 id 仅分配给已修改数据或锁定行的事务;仅执行只读SELECT
语句的事务将有一个伪 id,如 421124985258256,如果事务开始修改或锁定记录,该 id 将会改变。 |
| trx_state``varchar(13)
| 事务的状态。这可以是RUNNING
、LOCK WAIT
、ROLLING BACK
和COMMITTING
中的一个。 |
| trx_started``datetime
| 使用系统时区启动事务的时间。 |
| trx_requested_lock_id``varchar(105)
| 当trx_state
为LOCK WAIT
时,该列显示事务正在等待的锁的 id。 |
| trx_wait_started``datetime
| 当trx_state
为LOCK WAIT
时,该列使用系统时区显示锁定等待开始的时间。 |
| trx_weight``bigint unsigned
| 根据修改的行数和持有的锁数,衡量事务完成了多少工作。这是用于确定在死锁情况下回滚哪个事务的权重。重量越大,做功越多。 |
| trx_mysql_thread_id``bigint unsigned
| 执行事务的连接的连接 id(与性能模式threads
表中的PROCESSLIST_ID
列相同)。 |
| trx_query``varchar(1024)
| 事务当前执行的查询。如果事务空闲,则查询为NULL
。 |
| trx_operation_state``varchar(64)
| 事务执行的当前操作。即使查询正在执行,这也可能是NULL
。 |
| trx_tables_in_use``bigint unsigned
| 事务使用的表的数量。 |
| trx_tables_locked``bigint unsigned
| 事务持有行锁的表的数量。 |
| trx_lock_structs``bigint unsigned
| 事务创建的锁结构的数量。 |
| trx_lock_memory_bytes``bigint unsigned
| 事务持有的锁使用的内存量(以字节为单位)。 |
| trx_rows_locked``bigint unsigned
| 事务持有的记录锁的数量。虽然被称为行锁,但它也包括索引锁。 |
| trx_rows_modified``bigint unsigned
| 事务修改的行数。 |
| trx_concurrency_tickets``bigint unsigned
| 当innodb_thread_concurrency
不为 0 时,在事务必须允许另一个事务执行工作之前,会给该事务分配innodb_concurrency_tickets
个可以使用的票证。一张票对应于访问一行。这一栏显示还剩多少票。 |
| trx_isolation_level``varchar(16)
| 用于事务的事务隔离级别。 |
| trx_unique_checks``int
| 连接是否启用了unique_checks
变量。 |
| trx_foreign_key_checks``int
| 连接是否启用了foreign_key_checks
变量。 |
| trx_last_foreign_key_error``varchar(256)
| 事务遇到的最后一个(如果有)外键错误的错误消息。 |
| trx_adaptive_hash_latched``int
| 事务是否锁定了自适应哈希索引的一部分。总共有innodb_adaptive_hash_index_parts
个零件。该列实际上是一个布尔值。 |
| trx_adaptive_hash_timeout``bigint unsigned
| 是否在多个查询中保持对自适应哈希索引的锁定。如果自适应散列索引只有一部分,并且没有争用,那么超时倒计时,当超时达到 0 时,锁被释放。当存在争用或有多个部分时,每次查询后锁总是被释放,超时值为 0。 |
| trx_is_read_only``int
| 该事务是否为只读事务。通过显式声明,事务可以是只读的,或者对于启用了autocommit
的单语句事务,InnoDB 可以检测到查询将只读取数据。 |
| trx_autocommit_non_locking``int
| 当事务是单语句非锁定的SELECT
并且autocommit
选项被启用时,该列被设置为 1。当这个列和trx_is_read_only
都为 1 时,InnoDB 可以优化事务以减少开销。 |
| trx_schedule_weight``bigint unsigned
| 竞争感知事务调度(CATS)算法分配给事务的事务权重(参见第 8 章)。该值仅对处于LOCK WAIT
状态的事务有意义。此列是在 8.0.20 中添加的。 |
从INNODB_TRX
视图获得的信息使得确定哪些事务具有最大的影响成为可能。清单 3-1 展示了启动两个可以被调查的事务的例子。
-- Connection Processlist ID Thread ID Event ID
-- --------------------------------------------------
-- 1 53 163 6
-- 2 54 164 6
-- Connection 1
Connection 1> START TRANSACTION;
Query OK, 0 rows affected (0.0002 sec)
Connection 1> UPDATE world.city SET Population = Population + MOD(ID, 2) + SLEEP(0.01);
-- Connection 2
Connection 2> SET SESSION autocommit = ON;
Query OK, 0 rows affected (0.0004 sec)
Connection 2> SELECT COUNT(*) FROM world.city WHERE ID > SLEEP(0.01);
Listing 3-1Example transactions
事务将运行 40-50 秒。当它们执行时,您可以查询INNODB_TRX
视图,如清单 3-2 所示(确切的数据取决于测试中的 id 以及您何时查询INNODB_TRX
视图)。
-- Investigation #1
-- Connection 3
Connection 3> SELECT *
FROM information_schema.INNODB_TRX
WHERE trx_mysql_thread_id IN (53, 54)\G
*************************** 1\. row ***************************
trx_id: 296813
trx_state: RUNNING
trx_started: 2020-06-27 17:46:10
trx_requested_lock_id: NULL
trx_wait_started: NULL
trx_weight: 1023
trx_mysql_thread_id: 53
trx_query: UPDATE world.city SET Population = Population + MOD(ID, 2) + SLEEP(0.01)
trx_operation_state: NULL
trx_tables_in_use: 1
trx_tables_locked: 1
trx_lock_structs: 14
trx_lock_memory_bytes: 1136
trx_rows_locked: 2031
trx_rows_modified: 1009
trx_concurrency_tickets: 0
trx_isolation_level: REPEATABLE READ
trx_unique_checks: 1
trx_foreign_key_checks: 1
trx_last_foreign_key_error: NULL
trx_adaptive_hash_latched: 0
trx_adaptive_hash_timeout: 0
trx_is_read_only: 0
trx_autocommit_non_locking: 0
trx_schedule_weight: NULL
*************************** 2\. row ***************************
trx_id: 283598406543136
trx_state: RUNNING
trx_started: 2020-06-27 17:46:10
trx_requested_lock_id: NULL
trx_wait_started: NULL
trx_weight: 0
trx_mysql_thread_id: 54
trx_query: SELECT COUNT(*) FROM world.city WHERE ID > SLEEP(0.01)
trx_operation_state: NULL
trx_tables_in_use: 1
trx_tables_locked: 0
trx_lock_structs: 0
trx_lock_memory_bytes: 1136
trx_rows_locked: 0
trx_rows_modified: 0
trx_concurrency_tickets: 0
trx_isolation_level: REPEATABLE READ
trx_unique_checks: 1
trx_foreign_key_checks: 1
trx_last_foreign_key_error: NULL
trx_adaptive_hash_latched: 0
trx_adaptive_hash_timeout: 0
trx_is_read_only: 1
trx_autocommit_non_locking: 1
trx_schedule_weight: NULL
2 rows in set (0.0008 sec)
Listing 3-2Example output of the INNODB_TRX view
第一行显示了修改数据的事务示例。在检索信息时,已经修改了 1009 行,记录锁的数量大约是现在的两倍。您还可以看到事务仍然在主动执行一个查询(一个UPDATE
语句)。
第二行是在启用了autocommit
的情况下执行的SELECT
语句的示例。由于启用了自动提交,事务中只能有一个语句(显式的START TRANSACTION
禁用自动提交)。trx_query
列显示它是一个没有任何锁定子句的SELECT COUNT(*)
查询,因此它是一个只读语句。这意味着 InnoDB 可以跳过一些事情,比如为事务准备锁定和撤销信息,从而减少事务的开销。trx_autocommit_non_locking
列被设置为 1 以反映这一点。
您应该担心哪些事务取决于系统上的预期工作负载。如果您有一个 OLAP 工作负载,预计会有相对长时间运行的SELECT
查询。对于纯 OLTP 工作负载,任何运行时间超过一秒并修改多行的事务都可能是有问题的迹象。例如,要查找超过 10 秒的事务,可以使用以下查询:
SELECT *
FROM information_schema.INNODB_TRX
WHERE trx_started < NOW() - INTERVAL 10 SECOND;
您可以选择连接其他表,比如性能模式中的threads
和events_statements_current
。清单 3-3 中显示了一个这样的例子。
-- Investigation #3
Connection 3> SELECT thd.thread_id, thd.processlist_id,
trx.trx_id, stmt.event_id, trx.trx_started,
TO_SECONDS(NOW()) -
TO_SECONDS(trx.trx_started
) AS age_seconds,
trx.trx_rows_locked, trx.trx_rows_modified,
FORMAT_PICO_TIME(stmt.timer_wait) AS latency,
stmt.rows_examined, stmt.rows_affected,
sys.format_statement(SQL_TEXT) as statement
FROM information_schema.INNODB_TRX trx
INNER JOIN performance_schema.threads thd
ON thd.processlist_id = trx.trx_mysql_thread_id
INNER JOIN performance_schema.events_statements_current stmt
USING (thread_id)
WHERE trx_started < NOW() - INTERVAL 10 SECOND\G
*************************** 1\. row ***************************
thread_id: 163
processlist_id: 53
trx_id: 296813
event_id: 9
trx_started: 2020-06-27 17:46:10
age_seconds: 25
trx_rows_locked: 2214
trx_rows_modified: 1100
latency: 25.24 s
rows_examined: 2201
rows_affected: 0
statement: UPDATE world.city SET Populati ... ion + MOD(ID, 2) + SLEEP(0.01)
*************************** 2\. row ***************************
thread_id: 164
processlist_id: 54
trx_id: 283598406543136
event_id: 8
trx_started: 2020-06-27 17:46:10
age_seconds: 25
trx_rows_locked: 0
trx_rows_modified: 0
latency: 25.14 s
rows_examined: 0
rows_affected: 0
statement: SELECT COUNT(*) FROM world.city WHERE ID > SLEEP(0.01)
2 rows in set (0.0021 sec)
Listing 3-3Querying details of old transactions
您可以连接到这些表,并选择与您的调查相关的列。
与INNODB_TRX
视图相关的是 InnoDB 监控器中的事务列表。
InnoDB 监控器
InnoDB monitor 是 InnoDB information 的一种瑞士军刀,也包含有关事务的信息。InnoDB 监控器输出中的TRANSACTIONS
部分专用于事务信息。该信息不仅包括事务列表,还包括历史列表长度。清单 3-4 显示了 InnoDB monitor 的一个摘录,其中的事务部分的示例取自INNODB_TRX
视图的前一个输出。
-- Investigation #4
Connection 3> SHOW ENGINE INNODB STATUS\G
*************************** 1\. row ***************************
Type: InnoDB
Name:
Status:
=====================================
2020-06-27 17:46:36 0x5784 INNODB MONITOR OUTPUT
=====================================
Per second averages calculated from the last 20 seconds
...
------------
TRANSACTIONS
------------
Trx id counter 296814
Purge done for trx's n:o < 296813 undo n:o < 0 state: running but idle
History list length 1
LIST OF TRANSACTIONS FOR EACH SESSION:
---TRANSACTION 283598406541472, not started
0 lock struct(s), heap size 1136, 0 row lock(s)
---TRANSACTION 283598406540640, not started
0 lock struct(s), heap size 1136, 0 row lock(s)
---TRANSACTION 283598406539808, not started
0 lock struct(s), heap size 1136, 0 row lock(s)
---TRANSACTION 283598406538976, not started
0 lock struct(s), heap size 1136, 0 row lock(s)
---TRANSACTION 296813, ACTIVE 26 sec fetching rows
mysql tables in use 1, locked 1
15 lock struct(s), heap size 1136, 2333 row lock(s), undo log entries 1160
MySQL thread id 53, OS thread handle 23748, query id 56574 localhost ::1 root User sleep
UPDATE world.city SET Population = Population + MOD(ID, 2) + SLEEP(0.01)
...
Listing 3-4Transaction information from the InnoDB monitor
TRANSACTIONS
部分的顶部显示了事务 id 计数器的当前值,后面是已经从撤销日志中清除的信息。它显示事务 id 小于 296813 的撤消日志已被清除。该清除越晚,历史列表的长度(在该部分的第三行)就越长。从 InnoDB monitor 输出中读取历史列表长度是获取历史列表长度的传统方法。在下一节中,将展示如何在用于监控目的时以更好的方式获取值。
该部分的其余部分是事务列表。注意,虽然输出是用与在INNODB_TRX
中找到的相同的两个活动事务生成的,但是事务列表只包括一个活动事务(用于UPDATE
语句的事务)。在 MySQL 5.7 和更高版本中,只读非锁定事务不包括在 InnoDB monitor 事务列表中。因此,如果需要包含所有活动的事务,最好使用INNODB_TRX
视图。
如前所述,还有一种方法可以获得历史列表的长度。为此,您需要使用 InnoDB 指标。
INNODB_METRICS 和 sys.metrics
InnoDB monitor 报告对于数据库管理员了解 InnoDB 中正在发生的事情非常有用,但是对于监控来说,它的用处就没有那么大了,因为它需要进行解析,以监控可以使用的方式获取数据。您在本章的前面已经看到了如何从information_schema.INNODB_TRX
视图中获得关于事务的信息,但是像历史列表长度这样的度量标准又如何呢?
InnoDB 指标系统包括几个指标,在information_schema.INNODB_METRICS
视图中显示关于事务的信息。这些指标都位于transaction
子系统中。清单 3-5 显示了事务度量的列表,无论它们是否默认启用,以及解释度量测量什么的简短注释。
-- Connection Processlist ID Thread ID Event ID
-- --------------------------------------------------
-- 1 56 166 6
-- Connection 1
Connection 1> SELECT NAME, COUNT, STATUS, COMMENT
FROM information_schema.INNODB_METRICS
WHERE SUBSYSTEM = 'transaction'\G
*************************** 1\. row ***************************
NAME: trx_rw_commits
COUNT: 0
STATUS: disabled
COMMENT: Number of read-write transactions committed
*************************** 2\. row ***************************
NAME: trx_ro_commits
COUNT: 0
STATUS: disabled
COMMENT: Number of read-only transactions committed
*************************** 3\. row ***************************
NAME: trx_nl_ro_commits
COUNT: 0
STATUS: disabled
COMMENT: Number of non-locking auto-commit read-only transactions committed
*************************** 4\. row ***************************
NAME: trx_commits_insert_update
COUNT: 0
STATUS: disabled
COMMENT: Number of transactions committed with inserts and updates
*************************** 5\. row ***************************
NAME: trx_rollbacks
COUNT: 0
STATUS: disabled
COMMENT: Number of transactions rolled back
*************************** 6\. row ***************************
NAME: trx_rollbacks_savepoint
COUNT: 0
STATUS: disabled
COMMENT: Number of transactions rolled back to savepoint
*************************** 7\. row ***************************
NAME: trx_rollback_active
COUNT: 0
STATUS: disabled
COMMENT: Number of resurrected active transactions rolled back
*************************** 8\. row ***************************
NAME: trx_active_transactions
COUNT: 0
STATUS: disabled
COMMENT: Number of active transactions
*************************** 9\. row ***************************
NAME: trx_on_log_no_waits
COUNT: 0
STATUS: disabled
COMMENT: Waits for redo during transaction commits
*************************** 10\. row ***************************
NAME: trx_on_log_waits
COUNT: 0
STATUS: disabled
COMMENT: Waits for redo during transaction commits
*************************** 11\. row ***************************
NAME: trx_on_log_wait_loops
COUNT: 0
STATUS: disabled
COMMENT: Waits for redo during transaction commits
*************************** 12\. row ***************************
NAME: trx_rseg_history_len
COUNT: 9
STATUS: enabled
COMMENT: Length of the TRX_RSEG_HISTORY list
*************************** 13\. row ***************************
NAME: trx_undo_slots_used
COUNT: 0
STATUS: disabled
COMMENT: Number of undo slots used
*************************** 14\. row ***************************
NAME: trx_undo_slots_cached
COUNT: 0
STATUS: disabled
COMMENT: Number of undo slots cached
*************************** 15\. row ***************************
NAME: trx_rseg_current_size
COUNT: 0
STATUS: disabled
COMMENT: Current rollback segment size in pages
15 rows in set (0.0012 sec)
Listing 3-5InnoDB metrics
related to transactions
这些指标中最重要的是trx_rseg_history_len
,它是历史列表长度。这也是默认情况下启用的唯一指标。与提交和回滚相关的指标可用于确定您拥有多少读写、只读和非锁定只读事务,以及它们提交和回滚的频率。许多回滚表明存在问题。如果您怀疑重做日志是一个瓶颈,那么可以使用trx_on_log_%
指标来衡量在事务提交期间有多少事务在等待重做日志。
Tip
使用innodb_monitor_enable
选项启用 InnoDB 指标,使用innodb_monitor_disable
禁用它们。这可以动态完成。
查询 InnoDB 指标的另一种方便的方法是使用sys.metrics
视图,其中也包括全局状态变量。清单 3-6 展示了一个使用sys.metrics
视图获取当前值以及指标是否启用的示例。
-- Connection Processlist ID Thread ID Event ID
-- --------------------------------------------------
-- 1 52 125 6
-- Connection 1
Connection 1> SELECT Variable_name AS Name,
Variable_value AS Value,
Enabled
FROM sys.metrics
WHERE Type = 'InnoDB Metrics - transaction';
+---------------------------+-------+---------+
| Name | Value | Enabled |
+---------------------------+-------+---------+
| trx_active_transactions | 0 | NO |
| trx_commits_insert_update | 0 | NO |
| trx_nl_ro_commits | 0 | NO |
| trx_on_log_no_waits | 0 | NO |
| trx_on_log_wait_loops | 0 | NO |
| trx_on_log_waits | 0 | NO |
| trx_ro_commits | 0 | NO |
| trx_rollback_active | 0 | NO |
| trx_rollbacks | 0 | NO |
| trx_rollbacks_savepoint | 0 | NO |
| trx_rseg_current_size | 0 | NO |
| trx_rseg_history_len | 16 | YES |
| trx_rw_commits | 0 | NO |
| trx_undo_slots_cached | 0 | NO |
| trx_undo_slots_used | 0 | NO |
+---------------------------+-------+---------+
15 rows in set (0.0089 sec)
Listing 3-6Using the sys.metrics view
to get the transaction metrics
这表明历史列表长度为 16,这是一个很低的值,因此撤销日志几乎没有开销。其余指标被禁用。
摘要
本章介绍了如何获取关于 InnoDB 事务的信息。详细信息的主要来源是信息模式中的INNODB_TRX
视图,其中包括诸如事务启动时间、锁定和修改的行数等细节。您可以选择连接性能模式表,以获得有关事务的更多信息。
您还可以使用 InnoDB monitor 来获取关于锁定事务的信息;但是,一般情况下,最好使用INNODB_TRX
视图。如果您正在寻找更高级别的聚合统计数据,您可以使用information_schema.INNODB_METRICS
视图或者sys.metrics
视图。最常用的指标是显示历史列表长度的trx_rseg_history_len
。
迄今为止,关于事务信息的讨论一直是关于所有事务或单个事务的汇总统计数据。如果你想更深入地了解一个事务做了什么工作,你需要使用下一章讨论的性能模式。
四、性能模式中的事务
性能模式支持 MySQL 5.7 和更高版本中的事务监控,并且在 MySQL 8 中默认启用。在性能模式中,除了与 XA 事务和保存点相关的事务细节之外,没有多少事务细节是不能从信息模式中的INNODB_TRX
视图获得的。但是,Performance Schema 事务事件的优势在于,您可以将它们与其他事件类型(如语句)相结合,以获取有关事务所做工作的信息。这是本章的主要重点。此外,性能模式提供了带有聚合统计信息的汇总表。
Transaction Events and Their Statements
性能模式中用于调查事务的主要表是事务事件表。有三个表格记录当前或最近的事务:events_transactions_current
、events_transactions_history
和events_transactions_history_long
。它们具有表 4-1 中总结的列。
表 4-1
非汇总事务事件表的列
|列/数据类型
|
描述
|
| --- | --- |
| THREAD_ID``bigint unsigned
| 执行事务的连接的性能架构线程 id。 |
| EVENT_ID``bigint unsigned
| 事件的事件 id。您可以使用事件 id 对线程的事件进行排序,或者将事件 id 作为外键与事件表之间的线程 id 一起使用。 |
| END_EVENT_ID``bigint unsigned
| 事务完成时的事件 id。如果事件 id 为NULL
,则事务仍在进行。 |
| EVENT_NAME``varchar(128)
| 事务事件名称。目前,该列的值始终为transaction
。 |
| STATE``enum
| 事务的状态。可能的值有ACTIVE
、COMMITTED
和ROLLED BACK
。 |
| TRX_ID``bigint unsigned
| 这是当前未使用的,将始终是NULL
。 |
| GTID``varchar(64)
| 事务记录的 GTID。当自动确定 GTID 时(通常),返回AUTOMATIC
。这与执行事务的连接的gtid_next
变量相同。 |
| XID_FORMAT_ID``int
| 对于 XA 事务,格式 id。 |
| XID_GTRID``varchar(130)
| 对于 XA 事务,是 gtrid 值。 |
| XID_BQUAL``varchar(130)
| 对于 XA 事务,bqual 值。 |
| XA_STATE``varchar(64)
| 对于 XA 事务,是事务的状态。这可以是ACTIVE
、IDLE
、PREPARED
、ROLLED BACK
或COMMITTED
。 |
| SOURCE``varchar(64)
| 记录事件的源代码文件和行号。 |
| TIMER_START``bigint unsigned
| 事件开始的时间,以皮秒为单位。 |
| TIMER_END``bigint unsigned
| 事件完成的时间,以皮秒为单位。如果事务尚未完成,则该值对应于当前时间。 |
| TIMER_WAIT``bigint unsigned
| 执行事件所用的总时间(皮秒)。如果事件尚未完成,则该值对应于事务处于活动状态的时间。 |
| ACCESS_MODE``enum
| 事务处于只读(READ ONLY
)还是读写(READ WRITE
)模式。 |
| ISOLATION_LEVEL``varchar(64)
| 事务的事务隔离级别。 |
| AUTOCOMMIT``enum
| 事务是否基于autocommit
选项自动提交,以及显式事务是否已经开始。可能的值是NO
和YES
。 |
| NUMBER_OF_SAVEPOINTS``bigint unsigned
| 事务中创建的保存点数。 |
| NUMBER_OF_ROLLBACK_TO_SAVEPOINT``bigint unsigned
| 事务回滚到保存点的次数。 |
| NUMBER_OF_RELEASE_SAVEPOINT``bigint unsigned
| 事务释放保存点的次数。 |
| OBJECT_INSTANCE_BEGIN``bigint unsigned
| 该字段目前未被使用,并且总是被设置为NULL
。 |
| NESTING_EVENT_ID``bigint unsigned
| 触发事务的事件的事件 id。 |
| NESTING_EVENT_TYPE``enum
| 触发事务的事件的事件类型。 |
如果您正在处理 XA 事务,那么当您需要恢复一个事务时,事务事件表是非常有用的,因为格式 id、gtrid 和 bqual 值可以直接从表中获得,这与必须解析输出的XA RECOVER
语句不同。同样,如果您使用保存点,您可以获得保存点使用情况的统计数据。除此之外,这些信息与information_schema.INNODB_TRX
视图中的信息非常相似。
作为使用events_transactions_current
表的例子,您可以启动两个事务,如清单 4-1 所示。
-- Connection Processlist ID Thread ID Event ID
-- --------------------------------------------------
-- 1 57 140 6
-- 2 58 141 6
-- Connection 1
Connection 1> START TRANSACTION;
Query OK, 0 rows affected (0.0004 sec)
Connection 1> UPDATE world.city SET Population = 5200000 WHERE ID = 130;
Connection 1> UPDATE world.city SET Population = 4900000 WHERE ID = 131;
Connection 1> UPDATE world.city SET Population = 2400000 WHERE ID = 132;
Connection 1> UPDATE world.city SET Population = 2000000 WHERE ID = 133;
-- Connection 2
Connection 2> XA START 'abc', 'def', 1;
Connection 2> UPDATE world.city SET Population = 900000 WHERE ID = 3805;
Listing 4-1Example transactions
第一个事务是更新几个城市人口的普通事务,第二个事务是 XA 事务。清单 4-2 显示了列出当前活动事务的events_transactions_current
表的输出示例。
-- Investigation #1
-- Connection 3
Connection 3> SELECT *
FROM performance_schema.events_transactions_current
WHERE state = 'ACTIVE'\G
*************************** 1\. row ***************************
THREAD_ID: 140
EVENT_ID: 8
END_EVENT_ID: NULL
EVENT_NAME: transaction
STATE: ACTIVE
TRX_ID: NULL
GTID: AUTOMATIC
XID_FORMAT_ID: NULL
XID_GTRID: NULL
XID_BQUAL: NULL
XA_STATE: NULL
SOURCE: transaction.cc:209
TIMER_START: 72081362554600000
TIMER_END: 72161455792800000
TIMER_WAIT: 80093238200000
ACCESS_MODE: READ WRITE
ISOLATION_LEVEL: REPEATABLE READ
AUTOCOMMIT: NO
NUMBER_OF_SAVEPOINTS: 0
NUMBER_OF_ROLLBACK_TO_SAVEPOINT: 0
NUMBER_OF_RELEASE_SAVEPOINT: 0
OBJECT_INSTANCE_BEGIN: NULL
NESTING_EVENT_ID: 7
NESTING_EVENT_TYPE: STATEMENT
*************************** 2\. row ***************************
THREAD_ID: 141
EVENT_ID: 8
END_EVENT_ID: NULL
EVENT_NAME: transaction
STATE: ACTIVE
TRX_ID: NULL
GTID: AUTOMATIC
XID_FORMAT_ID: 1
XID_GTRID: abc
XID_BQUAL: def
XA_STATE: ACTIVE
SOURCE: transaction.cc:209
TIMER_START: 72081766957700000
TIMER_END: 72161455799300000
TIMER_WAIT: 79688841600000
ACCESS_MODE: READ WRITE
ISOLATION_LEVEL: REPEATABLE READ
AUTOCOMMIT: NO
NUMBER_OF_SAVEPOINTS: 0
NUMBER_OF_ROLLBACK_TO_SAVEPOINT: 0
NUMBER_OF_RELEASE_SAVEPOINT: 0
OBJECT_INSTANCE_BEGIN: NULL
NESTING_EVENT_ID: 7
NESTING_EVENT_TYPE: STATEMENT
2 rows in set (0.0007 sec)
Listing 4-2Using the events_transactions_current table
第 1 行中的事务是常规事务,而第 2 行中的事务是 XA 事务。两个事务都是由一个语句启动的,这可以从嵌套事件类型中看出。如果想找到触发事务的语句,可以使用它来查询events_statements_history
表,如下所示
-- Investigation #2
Connection 3> SELECT sql_text
FROM performance_schema.events_statements_history
WHERE thread_id = 140
AND event_id = 7\G
*************************** 1\. row ***************************
sql_text: start transaction
1 row in set (0.0434 sec)
这表明由thread_id = 140
执行的事务是使用START TRANSACTION
语句开始的。因为events_statements_history
表只包括连接的最后十条语句,所以不能保证启动事务的语句仍然在历史表中。当autocommit
被禁用时,如果您正在查看一个单语句事务或第一条语句(当它仍在执行时),您将需要查询events_statements_current
表。
事务和语句之间的关系也是相反的。
给定一个事务事件 id 和线程 id,您可以使用语句事件历史和当前表来查询为该事务执行的最后十条语句。清单 4-3 显示了thread_id = 140
和事务EVENT_ID = 8
的示例(来自清单 4-2 的第 1 行),其中包含了开始事务的语句和后续语句。
-- Investigation #4
Connection 3> SET @thread_id = 140,
@event_id = 8,
@nesting_event_id = 7;
Query OK, 0 rows affected (0.0007 sec)
-- Investigation #6
Connection 3> SELECT event_id, sql_text,
FORMAT_PICO_TIME(timer_wait) AS latency,
IF(end_event_id IS NULL, 'YES', 'NO') AS current
FROM ((SELECT event_id, end_event_id,
timer_wait,
sql_text, nesting_event_id,
nesting_event_type
FROM performance_schema.events_statements_current
WHERE thread_id = @thread_id
) UNION (
SELECT event_id, end_event_id,
timer_wait,
sql_text, nesting_event_id,
nesting_event_type
FROM performance_schema.events_statements_history
WHERE thread_id = @thread_id
)
) events
WHERE (nesting_event_type = 'TRANSACTION'
AND nesting_event_id = @event_id)
OR event_id = @nesting_event_id
ORDER BY event_id DESC\G
*************************** 1\. row ***************************
event_id: 12
sql_text: UPDATE world.city SET Population = 2000000 WHERE ID = 133
latency: 384.00 us
current: NO
*************************** 2\. row ***************************
event_id: 11
sql_text: UPDATE world.city SET Population = 2400000 WHERE ID = 132
latency: 316.20 us
current: NO
*************************** 3\. row ***************************
event_id: 10
sql_text: UPDATE world.city SET Population = 4900000 WHERE ID = 131
latency: 299.30 us
current: NO
*************************** 4\. row ***************************
event_id: 9
sql_text: UPDATE world.city SET Population = 5200000 WHERE ID = 130
latency: 176.95 ms
current: NO
*************************** 5\. row ***************************
event_id: 7
sql_text: start transaction
latency: 223.20 us
current: NO
5 rows in set (0.0016 sec)
Listing 4-3Finding the last ten statements executed in a transaction
子查询(一个派生表)从events_statements_current
和events_statements_history
表中找到线程的所有语句事件。有必要包括当前事件,因为可能有正在进行的事务报表。通过作为事务的子事务或事务的嵌套事件来过滤语句(event_id = 7
)。这将包括从启动事务的语句开始的所有语句。如果有正在进行的陈述,则最多有 11 个陈述,否则最多有 10 个。
end_event_id
用于确定语句当前是否正在执行,使用event_id
对语句进行反向排序,因此最新的语句在第 1 行,最老的(START TRANSACTION
语句)在第 5 行。
这种类型的查询不仅对调查仍在执行查询的事务有用。当您遇到一个空闲事务,并且想知道该事务在被放弃之前做了什么时,它也非常有用。寻找活动事务的另一种相关方法是使用sys.session
视图,该视图使用events_transactions_current
表来包含每个连接的事务状态信息。清单 4-4 显示了一个查询活动事务的例子,不包括执行查询的连接的行。
-- Investigation #7
Connection 3> SELECT *
FROM sys.session
WHERE trx_state = 'ACTIVE'
AND conn_id <> CONNECTION_ID()\G
*************************** 1\. row ***************************
thd_id: 140
conn_id: 57
user: mysqlx/worker
db: NULL
command: Sleep
state: NULL
time: 449
current_statement: UPDATE world.city SET Population = 2000000 WHERE ID = 133
statement_latency: NULL
progress: NULL
lock_latency: 111.00 us
rows_examined: 1
rows_sent: 0
rows_affected: 1
tmp_tables: 0
tmp_disk_tables: 0
full_scan: NO
last_statement: UPDATE world.city SET Population = 2000000 WHERE ID = 133
last_statement_latency: 384.00 us
current_memory: 228.31 KiB
last_wait: NULL
last_wait_latency: NULL
source: NULL
trx_latency: 7.48 min
trx_state: ACTIVE
trx_autocommit: NO
pid: 30936
program_name: mysqlsh
*************************** 2\. row ***************************
thd_id: 141
conn_id: 58
user: mysqlx/worker
db: NULL
command: Sleep
state: NULL
time: 449
current_statement: UPDATE world.city SET Population = 900000 WHERE ID = 3805
statement_latency: NULL
progress: NULL
lock_latency: 387.00 us
rows_examined: 1
rows_sent: 0
rows_affected: 1
tmp_tables: 0
tmp_disk_tables: 0
full_scan: NO
last_statement: UPDATE world.city SET Population = 900000 WHERE ID = 3805
last_statement_latency: 49.39 ms
current_memory: 70.14 KiB
last_wait: NULL
last_wait_latency: NULL
source: NULL
trx_latency: 7.48 min
trx_state: ACTIVE
trx_autocommit: NO
pid: 30936
program_name: mysqlsh
2 rows in set (0.0422 sec)
Listing 4-4Finding active transactions with sys.session
这表明第一行中的事务已经活动了 7 分钟以上,距离上次执行查询有 449 秒(7.5 分钟)(您的值会有所不同)。last_statement
可以用来确定连接执行的最后一个查询。这是一个被放弃的事务的例子,它阻止了 InnoDB 清除它的撤销日志。放弃事务的最常见原因是数据库管理员交互地启动了一个事务,然后分心了,或者是autocommit
被禁用了,没有意识到一个事务已经启动了。
Caution
如果您禁用了autocommit
,请始终注意在工作结束时提交或回滚。一些连接器默认禁用autocommit
,所以请注意您的应用可能没有使用服务器默认设置。
您可以回滚事务以避免更改任何数据(如果您使用 MySQL Shell 脚本来重现该示例,那么在下一次调查没有答案的情况下,当按 enter 键时,这将自动完成)。对于第一次(正常)事务
-- Connection 1
Connection 1> ROLLBACK;
Query OK, 0 rows affected (0.0303 sec)
对于 XA 事务:
-- Connection 2
Connection 2> XA END 'abc', 'def', 1;
Query OK, 0 rows affected (0.0002 sec)
Connection 2> XA ROLLBACK 'abc', 'def', 1;
Query OK, 0 rows affected (0.0308 sec)
性能模式表对分析事务有用的另一种方式是使用汇总表来获得聚合数据。
Transaction Summary Tables
与可以用来获得所执行语句的报告的语句汇总表一样,也可以使用事务汇总表来分析事务的使用情况。虽然它们不像它们的对应物那样有用,但是它们确实提供了对以不同方式使用事务的连接和账户的洞察。
共有五个事务摘要表,可以按帐户、主机、线程或用户对数据进行全局分组。所有摘要也按事件名称分组,但由于目前只有一个事务事件(transaction
),所以这是一个空操作。这些桌子是
-
events_transactions_summary_global_by_event_name
: 汇总所有事务。该表中只有一行。 -
events_transactions_summary_by_account_by_event_name
: 按用户名和主机名分组的事务。 -
events_transactions_summary_by_host_by_event_name
: 按账户主机名分组的事务。 -
events_transactions_summary_by_thread_by_event_name
: 按线程分组的事务。仅包括当前存在的线程。 -
events_transactions_summary_by_user_by_event_name
: 按账户用户名部分分组的事件。
每个表都包括对事务统计信息进行分组的列和三组列:总计、读写事务和只读事务。对于这三组列中的每一组,都有事务总数以及总延迟、最小延迟、平均延迟和最大延迟。清单 4-5 显示了来自events_transactions_summary_global_by_event_name
表的数据的一个例子。
-- Connection Processlist ID Thread ID Event ID
-- --------------------------------------------------
-- 1 60 143 6
-- Connection 1
Connection 1> SELECT *
FROM performance_schema.events_transactions_summary_global_by_event_name\G
*************************** 1\. row ***************************
EVENT_NAME: transaction
COUNT_STAR: 40485
SUM_TIMER_WAIT: 90259064465300000
MIN_TIMER_WAIT: 4800000
AVG_TIMER_WAIT: 2229444500000
MAX_TIMER_WAIT: 62122342944500000
COUNT_READ_WRITE: 40483
SUM_TIMER_READ_WRITE: 90230783742700000
MIN_TIMER_READ_WRITE: 4800000
AVG_TIMER_READ_WRITE: 2228856100000
MAX_TIMER_READ_WRITE: 62122342944500000
COUNT_READ_ONLY: 2
SUM_TIMER_READ_ONLY: 28280722600000
MIN_TIMER_READ_ONLY: 9561820600000
AVG_TIMER_READ_ONLY: 14140361300000
MAX_TIMER_READ_ONLY: 18718902000000
1 row in set (0.0007 sec)
Listing 4-5The events_transactions_summary_global_by_event_name table
当您研究输出中有多少事务,尤其是读写事务时,您可能会感到惊讶。请记住,在查询 InnoDB 表时,即使您没有明确指定事务,所有事情都是事务。因此,即使一个简单的查询单行的SELECT
语句也算作一个事务。关于读写事务和只读事务之间的分布,只有当您显式地以只读方式启动事务时,性能模式才会将其视为只读
START TRANSACTION READ ONLY;
当 InnoDB 确定自动提交的单语句事务可以被视为只读事务时,它仍然会计入性能模式中的读写统计数据。
Summary
本章介绍了性能模式中与事务相关的表,并展示了如何连接到其他表。首先讨论每个事务事件占一行的三个表,events_transactions_current
、events_transactions_history
和events_transactions_history_long
,然后使用它们连接语句事件表以获得事务中最近执行的语句。最后,介绍了事务汇总表。
现在,您已经介绍了监控锁和事务的最重要的资源,是时候详细介绍锁了。首先,您将了解锁的访问级别。
五、锁的访问级别
在介绍锁的第一章中,没有提到锁是如何工作的。不管要做什么样的工作,一次只允许一个查询访问就可以实现数据库的锁定。然而,这将是非常低效的。
就像交通灯一样,另一种方法是根据将要完成的工作授予访问权限。交通灯不仅允许一辆车同时通过十字路口,也允许所有同方向行驶的车辆通过。类似地,在数据库中,您可以区分共享(读)和独占(写)访问。访问级别顾名思义。共享锁允许其他连接也获得共享锁。这是最宽松的锁访问级别。独占锁只允许一个连接获得锁。共享锁也称为读锁,排他锁也称为写锁。
Note
锁访问级别有时也称为锁类型,但由于这可能与锁粒度(有时也称为类型)相混淆,所以这里使用术语锁访问级别。
MySQL 还有一个叫做意向锁的概念,它指定了事务的意向。意向锁可以是共享的,也可以是排他的。
本章的其余部分将更详细地介绍共享锁、排他锁以及意向锁。
共享锁
当一个线程需要保护一个资源,但它不打算改变该资源时,它可以使用一个共享锁来防止其他线程改变该资源,同时仍然允许它们访问同一资源。这是最常用的访问级别。
每当一个语句从一个表中进行选择时,MySQL 将对查询中涉及的表使用一个共享锁。对于数据锁,它的工作方式不同,因为 InnoDB 在读取行时通常不获取共享锁。只有在SERIALIZABLE
事务隔离级别中明确请求共享锁时,或者工作流需要共享锁时,比如涉及外键时,才会发生这种情况。
您可以通过添加清单 5-1 中所示的FOR SHARE
或其同义词LOCK IN SHARE MODE
来显式请求查询所访问的行上的共享锁。
-- Connection Processlist ID Thread ID Event ID
-- --------------------------------------------------
-- 1 36 80 6
-- Connection 1
Connection 1> START TRANSACTION;
Query OK, 0 rows affected (0.0003 sec)
Connection 1> SELECT * FROM world.city WHERE ID = 130 FOR SHARE;
+-----+--------+-------------+-----------------+------------+
| ID | Name | CountryCode | District | Population |
+-----+--------+-------------+-----------------+------------+
| 130 | Sydney | AUS | New South Wales | 3276207 |
+-----+--------+-------------+-----------------+------------+
1 row in set (0.0047 sec)
Connection 1> SELECT object_type, object_schema, object_name,
lock_type, lock_duration, lock_status
FROM performance_schema.metadata_locks
WHERE OWNER_THREAD_ID = PS_CURRENT_THREAD_ID()
AND OBJECT_SCHEMA <> 'performance_schema'\G
*************************** 1\. row ***************************
object_type: TABLE
object_schema: world
object_name: city
lock_type: SHARED_READ
lock_duration: TRANSACTION
lock_status: GRANTED
1 row in set (0.0005 sec)
Connection 1> SELECT engine, object_schema, object_name,
lock_type, lock_mode, lock_status
FROM performance_schema.data_locks
WHERE THREAD_ID = PS_CURRENT_THREAD_ID()\G
*************************** 1\. row ***************************
engine: INNODB
object_schema: world
object_name: city
lock_type: TABLE
lock_mode: IS
lock_status: GRANTED
*************************** 2\. row ***************************
engine: INNODB
object_schema: world
object_name: city
lock_type: RECORD
lock_mode: S,REC_NOT_GAP
lock_status: GRANTED
2 rows in set (0.0005 sec)
mysql> ROLLBACK;
Query OK, 0 rows affected (0.0004 sec)
Listing 5-1Example of obtaining a shared lock
当查询metadata_locks
表时,性能模式表上的锁被排除,因为它们是针对调查查询本身而不是之前的查询。这里,在world.city
表上获取了一个共享锁以及主键(ID
列)等于 130 的记录。从值为SHARED_READ
的metadata_locks
表中的lock_type
列和第二行data_locks
的lock_mode
列中的 S 可以看出它们是共享锁。来自data_locks
的第一行的值IS
意味着它是一个共享意向锁,稍后将对此进行更详细的讨论。
虽然共享锁确实允许其他使用共享锁的查询继续进行,但是它们确实会阻止获取独占锁的尝试
独占锁
排他锁与共享锁相对应。它们确保只有被授予独占锁的线程才能在锁期间访问资源。因为排他锁用于确保一次只有一个线程在修改资源,所以它们也被称为写锁。
排他锁主要是通过数据定义语言(DDL)语句(如ALTER TABLE
)获得的,当使用数据修改语言(DML)语句(如UPDATE
和DELETE
)修改数据时也是如此。清单 5-2 中提供了获取排他锁和锁表中数据的示例。
-- Connection Processlist ID Thread ID Event ID
-- --------------------------------------------------
-- 1 38 84 6
-- Connection 1
Connection 1> START TRANSACTION;
Query OK, 0 rows affected (0.0003 sec)
Connection 1> UPDATE world.city
SET Population = Population + 1
WHERE ID = 130;
Query OK, 1 row affected (0.0028 sec)
Rows matched: 1 Changed: 1 Warnings: 0
Connection 1> SELECT object_type, object_schema, object_name,
lock_type, lock_duration, lock_status
FROM performance_schema.metadata_locks
WHERE OWNER_THREAD_ID = PS_CURRENT_THREAD_ID()
AND OBJECT_SCHEMA <> 'performance_schema'\G
*************************** 1\. row ***************************
object_type: TABLE
object_schema: world
object_name: city
lock_type: SHARED_WRITE
lock_duration: TRANSACTION
lock_status: GRANTED
*************************** 2\. row ***************************
object_type: TABLE
object_schema: world
object_name: country
lock_type: SHARED_READ
lock_duration: TRANSACTION
lock_status: GRANTED
2 rows in set (0.0008 sec)
Connection 1> SELECT engine, object_schema, object_name,
lock_type, lock_mode, lock_status
FROM performance_schema.data_locks
WHERE THREAD_ID = PS_CURRENT_THREAD_ID()\G
*************************** 1\. row ***************************
engine: INNODB
object_schema: world
object_name: city
lock_type: TABLE
lock_mode: IX
lock_status: GRANTED
*************************** 2\. row ***************************
engine: INNODB
object_schema: world
object_name: city
lock_type: RECORD
lock_mode: X,REC_NOT_GAP
lock_status: GRANTED
2 rows in set (0.0005 sec)
mysql> ROLLBACK;
Query OK, 0 rows affected (0.3218 sec)
Listing 5-2Example of obtaining exclusive locks
这个例子大部分反映了获取共享锁的例子,但是也有一些令人惊讶的地方。从data_locks
表开始,它显示了表上的一个独占插入意图(IX
)锁和一个独占(X
)记录锁。这是意料之中的。
使用metadata_locks
工作台变得更加复杂,现在有两个工作台锁,一个SHARED_WRITE
锁在city
工作台上,一个SHARED_READ
锁在country
工作台上。一个锁怎么能同时共享和写,为什么city
表上的锁在被修改时是共享的,为什么country
表上有锁?
一个SHARED_WRITE
锁告知数据被锁定用于更新,但是元数据锁本身是一个共享锁。这是因为表的元数据没有被修改,所以允许其他并发共享访问表元数据是安全的。记住,metadata_locks
表并不关心单个记录上的锁,所以从元数据的角度来看,对city
表的访问是共享的。
country
表上的元数据锁来自于city
表到country
表的外键。共享锁防止对country
元数据的修改,比如在事务仍在进行时删除外键中涉及的列。第 10 章将更详细地介绍外键对锁定的影响。
意向锁
在本章到目前为止的两个例子中,都有意向锁。那些是什么?它是一个表示 InnoDB 事务意图的锁,可以是共享的,也可以是独占的。乍一看,这似乎是不必要的复杂,但是意向锁允许 InnoDB 在不阻塞兼容操作的情况下有序地解决锁请求。细节超出了本次讨论的范围。重要的是你知道意向锁的存在,所以当你看到它们时,你知道它们来自哪里。
第 6 章从更实用的角度介绍了意向锁,第 7 章的 InnoDB 锁介绍了插入意向锁这一相关概念。
锁兼容性
锁兼容性矩阵定义了两个锁请求是否相互冲突。意向锁的引入使得这比说共享锁互相兼容,排他锁不兼容任何其他锁要复杂一点。
两个意向锁总是互相兼容的。这意味着即使一个事务有一个意向排他锁,它也不会阻止另一个事务获取一个意向锁。但是,它将阻止另一个事务将其意向锁升级为完全锁。表 5-1 显示了锁类型之间的兼容性。共享锁表示为 S,排他锁表示为 x。意向锁以 I 为前缀,因此 IS 是意向共享锁,IX 是意向排他锁。
表 5-1
InnoDB 锁兼容性
| |独占(X)
|
意图排他(九)
|
共享的
|
共享意向(IS)
|
| --- | --- | --- | --- | --- |
| 独占(X) | -什么 | -什么 | -什么 | -什么 |
| 意图排他(IX) | -什么 | ✔ | -什么 | ✔ |
| 共享 | -什么 | -什么 | ✔ | ✔ |
| 意向共享(是) | -什么 | ✔ | ✔ | ✔ |
在该表中,复选标记表示这两种锁兼容,而叉号表示这两种锁相互冲突。意向锁的唯一冲突是独占锁和共享锁。排他锁与所有其他锁冲突,包括两种意向锁类型。共享锁只与排他锁和意图排他锁冲突。
这听起来确实很简单;然而,这仅适用于两个相同类型的锁。当您开始在 InnoDB 级别包含不同的锁时,它会变得更加复杂,这将在第 8 章讨论锁争用时讨论。
这都是 MySQL 和 InnoDB 自动处理的;但是,在调查锁问题时,您需要理解这些规则。
摘要
本章讨论了 MySQL 锁的访问级别。锁可以是共享锁、排他锁、意向共享锁或意向排他锁。
共享锁用于对资源的读访问,并允许多个线程同时访问同一资源。另一方面,排他锁一次只允许一个线程访问资源,这使得更新资源是安全的。意向锁是 InnoDB 的一个概念,它允许 InnoDB 以更少的阻塞请求来解决锁请求。所有的意向锁都是互相兼容的,即使是意向排他锁,但是意向排他锁会阻塞共享锁。
在本章以及前面的章节中,您还会遇到锁如何保护不同资源(如表和记录)的示例。在下一章中,是时候学习更多关于高级锁访问类型的知识了,比如表和元数据锁。
六、高级锁类型
在上一章中,您学习了共享和独占访问级别。原则上,您可以创建一个只包含一种锁的锁系统,这种锁可以是共享的,也可以是排他的。然而,这意味着它必须在实例级工作,因此很难允许对数据进行并发读写访问。在这一章和下一章中,你将了解到根据它们所保护的资源,锁有多种类型。虽然这确实使锁定变得更加复杂,但它也允许更细粒度的锁定,从而支持更高的并发性。
本章讨论 MySQL 中的高级锁,从用户级锁开始,讨论在 MySQL 级(即存储引擎之上)处理的各种类型的锁。包括刷新锁、元数据锁、显式和隐式表锁(这是一个例外,因为它们由 InnoDB 处理)、备份锁和日志锁。
用户级锁
用户级锁是应用可以用来保护的显式锁类型,例如,工作流。它们不常使用,但是对于一些需要序列化访问的复杂任务来说,它们会很有用。所有用户锁都是排他锁,使用最长 64 个字符的名称获得。
您可以使用一组函数来操作用户级锁:
-
GET_LOCK(name, timeout)
: 通过指定锁的名称获得锁。第二个参数是以秒为单位的超时;如果在这段时间内没有获得锁,该函数将返回 0。如果获得了锁,返回值为 1。如果超时为负,该函数将无限期等待锁变为可用。 -
IS_FREE_LOCK(name)
: 检查命名锁是否可用。如果锁可用,函数返回 1,如果锁不可用,函数返回 0。 -
IS_USED_LOCK(name)
: 这是IS_FREE_LOCK()
功能的反义词。如果锁在使用中(不可用),该函数返回持有锁的连接的连接 id,如果锁不在使用中(可用),则返回NULL
。 -
RELEASE_ALL_LOCKS()
: 释放连接持有的所有用户级锁。返回值是释放的锁的数量。 -
RELEASE_LOCK(name)
: 用提供的名字解锁。如果锁被释放,返回值为 1;如果锁存在但不属于连接,返回值为 0;如果锁不存在,返回值为NULL
。
通过多次调用GET_LOCK()
可以获得多个锁。如果这样做,请注意确保所有用户以相同的顺序获得锁,否则可能会发生死锁。如果发生死锁,将返回一个ER_USER_LOCK_DEADLOCK
错误(错误代码 3058)。清单 6-1 中显示了一个这样的例子。
-- Connection Processlist ID Thread ID Event ID
-- --------------------------------------------------
-- 1 322 617 6
-- 2 323 618 6
-- Connection 1
Connection 1> SELECT GET_LOCK('my_lock_1', -1);
+---------------------------+
| GET_LOCK('my_lock_1', -1) |
+---------------------------+
| 1 |
+---------------------------+
1 row in set (0.0003 sec)
-- Connection 2
Connection 2> SELECT GET_LOCK('my_lock_2', -1);
+---------------------------+
| GET_LOCK('my_lock_2', -1) |
+---------------------------+
| 1 |
+---------------------------+
1 row in set (0.0003 sec)
Connection 2> SELECT GET_LOCK('my_lock_1', -1);
-- Connection 1
Connection 1> SELECT GET_LOCK('my_lock_2', -1);
ERROR: 3058: Deadlock found when trying to get user-level lock; try rolling back transaction/releasing locks and restarting lock acquisition.
Listing 6-1A deadlock for user-level locks
当连接 2 试图获取my_lock_1
锁时,该语句将被阻塞,直到连接 1 试图获取触发死锁的my_lock_2
锁。如果您获得多个锁,您应该准备好处理死锁。请注意,对于用户级锁,死锁不会触发事务回滚。
被授予和挂起的用户级锁可以在performance_schema.metadata_locks
表中找到,其中OBJECT_TYPE
列设置为USER LEVEL LOCK
,如清单 6-2 所示。列出的锁假设您离开了清单 6-1 中的死锁被触发时的系统。注意,有些值如OBJECT_INSTANCE_BEGIN
对您来说会有所不同,您必须更改WHERE
子句中owner_thread_id
的 id,以匹配清单 6-1 中的 id。
-- Investigation #1
-- Connection 3
Connection 3> SELECT *
FROM performance_schema.metadata_locks
WHERE object_type = 'USER LEVEL LOCK'
AND owner_thread_id IN (617, 618)\G
*************************** 1\. row ***************************
OBJECT_TYPE: USER LEVEL LOCK
OBJECT_SCHEMA: NULL
OBJECT_NAME: my_lock_1
COLUMN_NAME: NULL
OBJECT_INSTANCE_BEGIN: 2124404669104
LOCK_TYPE: EXCLUSIVE
LOCK_DURATION: EXPLICIT
LOCK_STATUS: GRANTED
SOURCE: item_func.cc:5067
OWNER_THREAD_ID: 617
OWNER_EVENT_ID: 8
*************************** 2\. row ***************************
OBJECT_TYPE: USER LEVEL LOCK
OBJECT_SCHEMA: NULL
OBJECT_NAME: my_lock_2
COLUMN_NAME: NULL
OBJECT_INSTANCE_BEGIN: 2124463901664
LOCK_TYPE: EXCLUSIVE
LOCK_DURATION: EXPLICIT
LOCK_STATUS: GRANTED
SOURCE: item_func.cc:5067
OWNER_THREAD_ID: 618
OWNER_EVENT_ID: 8
*************************** 3\. row ***************************
OBJECT_TYPE: USER LEVEL LOCK
OBJECT_SCHEMA: NULL
OBJECT_NAME: my_lock_1
COLUMN_NAME: NULL
OBJECT_INSTANCE_BEGIN: 2124463901088
LOCK_TYPE: EXCLUSIVE
LOCK_DURATION: EXPLICIT
LOCK_STATUS: PENDING
SOURCE: item_func.cc:5067
OWNER_THREAD_ID: 618
OWNER_EVENT_ID: 9
3 rows in set (0.0015 sec)
Listing 6-2Listing user-level locks
用户级锁的OBJECT_TYPE
是USER LEVEL LOCK
,锁的持续时间是EXPLICIT
,因为这取决于用户或应用是否再次释放锁。在行 1 中,具有性能模式线程 id 617 的连接已经被授予my_lock_1
锁,并且在行 3 中,线程 id 618 正在等待(待定)它被授予。线程 id 618 也具有包含在行 2 中的授权锁。一旦完成调查,记得释放锁,例如,首先在连接 1 中执行SELECT RELEASE_ALL_LOCKS()
,然后在连接 2 中执行【】(当使用 MySQL Shell concurrency_book
模块退出工作负载时,这将自动发生)。
下一级锁涉及非数据表级锁。首先要讨论的是冲水锁。
清空锁
大多数参与备份的人都熟悉刷新锁。它是在使用FLUSH TABLES
语句时获取的,并持续整个语句期间,除非您添加了WITH READ LOCK
,在这种情况下,共享(读)锁将被持有,直到该锁被显式释放。在ANALYZE TABLE
语句的结尾也会触发隐式的表刷新。刷新锁是一个表级锁。用FLUSH TABLES WITH READ LOCK
获取的读锁将在后面的显式表锁中讨论。
刷新锁的锁问题的一个常见原因是长时间运行的查询。只要存在打开表的查询,一个FLUSH TABLES
语句就不能刷新表。这意味着,如果在一个长时间运行的查询使用一个或多个被刷新的表时执行一个FLUSH TABLES
语句,那么FLUSH TABLES
语句将阻塞所有其他需要这些表的语句,直到锁的情况得到解决。
嵌入式锁受lock_wait_timeout
设置的影响。如果获得锁的时间超过lock_wait_timeout
秒,MySQL 将放弃锁。如果FLUSH TABLES
声明被扼杀,同样适用。然而,由于 MySQL 的内部原因,在长时间运行的查询完成之前,一个称为表定义缓存(TDC)版本锁的较低级别的锁不能总是被释放。 1 这意味着确保锁问题得到解决的唯一方法是终止长时间运行的查询,但是要注意,如果查询已经更改了许多行,回滚查询可能需要很长时间。
当围绕刷新锁存在锁争用时,FLUSH TABLES
语句和随后启动的查询都将状态设置为“等待表刷新”清单 6-3 展示了一个包含三个查询的例子。如果您自己正在重现这个场景(而不是使用 MySQL Shell concurrency_book
模块),那么您可以将连接 1 中的参数改为SLEEP()
,给自己更多的时间来完成这个示例。
-- Connection Processlist ID Thread ID Event ID
-- --------------------------------------------------
-- 1 375 691 6
-- 2 376 692 6
-- 3 377 693 6
-- 4 378 694 6
-- Connection 1
Connection 1> SELECT city.*, SLEEP(3) FROM world.city WHERE ID = 130;
-- Connection 2
Connection 2> FLUSH TABLES world.city;
-- Connection 3
Connection 3> SELECT * FROM world.city WHERE ID = 201;
-- Connection 4
-- Query sys.session for the three threads involved in the lock situation
Connection 4> SELECT thd_id, conn_id, state,
current_statement
FROM sys.session
WHERE current_statement IS NOT NULL
AND thd_id IN (691, 692, 693)
ORDER BY thd_id\G
*************************** 1\. row ***************************
thd_id: 691
conn_id: 375
state: User sleep
current_statement: SELECT city.*, SLEEP(3) FROM world.city WHERE ID = 130
*************************** 2\. row ***************************
thd_id: 692
conn_id: 376
state: Waiting for table flush
current_statement: FLUSH TABLES world.city
*************************** 3\. row ***************************
thd_id: 693
conn_id: 377
state: Waiting for table flush
current_statement: SELECT * FROM world.city WHERE ID = 201
3 rows in set (0.0586 sec)
Listing 6-3Example of waiting for a flush lock
该示例使用了sys.session
视图;使用performance_schema.threads
和SHOW PROCESSLIST
可以获得类似的结果。为了将输出减少到只包括与刷新锁讨论相关的查询,将WHERE
子句设置为只包括前三个连接的线程 id。
与conn_id = 375
的连接正在执行一个使用world.city
表的慢速查询(使用了一个SLEEP(3)
来确保它花费足够的时间来执行其他连接的语句)。同时,conn_id = 376
为world.city
表执行了一条FLUSH TABLES
语句。因为第一个查询仍然打开着表(一旦查询完成,它就会被释放),所以FLUSH TABLES
语句最终会等待表刷新锁。最后,conn_id = 377
试图查询表,因此必须等待FLUSH TABLES
语句。
另一种非数据表锁是元数据锁。
元数据锁
元数据锁是 MySQL 中较新的锁类型之一。它们是在 MySQL 5.5 中引入的,它们的目的是保护模式,因此当查询或事务依赖于模式不变时,它不会被改变。元数据锁在表级别工作,但是它们应该被视为独立于表锁的锁类型,因为它们不保护表中的数据。
语句和 DML 查询使用共享元数据锁,而 DDL 语句使用排他锁。当第一次使用表时,连接获取表上的元数据锁,并保持该锁直到事务结束。当持有元数据锁时,不允许其他连接更改表的模式定义。但是,执行SELECT
语句和 DML 语句的其他连接不受限制。通常关于元数据锁的最大问题是长时间运行的事务,可能是空闲的,阻止 DDL 语句开始它们的工作。
如果遇到关于元数据锁定的冲突,您会看到进程列表中的查询状态设置为“等待表元数据锁定”清单 6-4 中显示了一个包括设置查询的例子。
-- Connection Processlist ID Thread ID Event ID
-- --------------------------------------------------
-- 1 428 768 6
-- 2 429 769 6
-- Connection 1
Connection 1> START TRANSACTION;
Query OK, 0 rows affected (0.0003 sec)
Connection 1> SELECT * FROM world.city WHERE ID = 130\G
*************************** 1\. row ***************************
ID: 130
Name: Sydney
CountryCode: AUS
District: New South Wales
Population: 3276207
1 row in set (0.0006 sec)
-- Connection 2
Connection 2> OPTIMIZE TABLE world.city;
Listing 6-4Example of waiting for table metadata lock
连接 2 阻塞,当您处于这种情况时,您可以查询清单 6-5 中所示的sys.session
或类似内容。
-- Investigation #1
-- Connection 3
Connection 3> SELECT thd_id, conn_id, state,
current_statement, statement_latency,
last_statement, trx_state
FROM sys.session
WHERE conn_id IN (428, 429)
ORDER BY conn_id\G
*************************** 1\. row ***************************
thd_id: 768
conn_id: 428
state: NULL
current_statement: SELECT * FROM world.city WHERE ID = 130
statement_latency: NULL
last_statement: SELECT * FROM world.city WHERE ID = 130
trx_state: ACTIVE
*************************** 2\. row ***************************
thd_id: 769
conn_id: 429
state: Waiting for table metadata lock
current_statement: OPTIMIZE TABLE world.city
statement_latency: 26.62 s
last_statement: NULL
trx_state: COMMITTED
2 rows in set (0.0607 sec)
Listing 6-5sys.session
for the connections involved in the metadata lock
在本例中,与conn_id = 428
的连接有一个正在进行的事务,并且在前一条语句中查询了world.city
表(本例中的当前语句与下一条语句执行之前不会被清除的语句相同)。当事务仍然活跃时,conn_id = 429
已经执行了一个OPTIMIZE TABLE
语句,该语句现在正在等待元数据锁定。(是的,OPTIMIZE TABLE
不改变模式定义,但作为 DDL 语句,它仍然受元数据锁定的影响。)因为 MySQL 没有事务性 DDL 语句,所以conn_id = 429
的事务状态显示为 committed。
当导致元数据锁定的是当前或最后一条语句时,这是很方便的。在更一般的情况下,您可以使用将OBJECT_TYPE
列设置为TABLE
的performance_schema.metadata_locks
表来查找授予的和挂起的元数据锁。清单 6-6 展示了一个使用与前一个例子相同的设置的被授予和挂起的元数据锁的例子。第 14 章详细介绍了元数据锁的研究。
-- Investigation #2
Connection 3> SELECT object_type, object_schema, object_name,
lock_type, lock_duration, lock_status,
owner_thread_id
FROM performance_schema.metadata_locks
WHERE owner_thread_id IN (768, 769)
AND object_type = 'TABLE'\G
*************************** 1\. row ***************************
object_type: TABLE
object_schema: world
object_name: city
lock_type: SHARED_READ
lock_duration: TRANSACTION
lock_status: GRANTED
owner_thread_id: 768
*************************** 2\. row ***************************
object_type: TABLE
object_schema: world
object_name: city
lock_type: SHARED_NO_READ_WRITE
lock_duration: TRANSACTION
lock_status: PENDING
owner_thread_id: 769
2 rows in set (0.0010 sec)
Listing 6-6Example of metadata locks
在该示例中,由于正在进行的事务,线程 id 768(与来自sys.session
输出的conn_id = 428
相同)拥有对world.city
表的共享读锁,并且线程 id 769 在试图对该表执行 DDL 语句时正在等待锁。
完成后,确保回滚或提交连接 1 中的事务,这样OPTIMIZE TABLE
就可以完成:
-- Connection 1
Connection 1> ROLLBACK;
Query OK, 0 rows affected (0.0004 sec)
元数据锁的一个特例是用LOCK TABLES
语句显式获取的锁。
显式表锁
使用LOCK TABLES
和FLUSH TABLES WITH READ LOCK
语句获取显式表锁。使用LOCK TABLES
语句,可以获取共享锁或独占锁;FLUSH TABLES WITH READ LOCK
总是使用共享锁。这些表被锁定,直到用UNLOCK TABLES
语句显式释放它们。当FLUSH TABLES WITH READ LOCK
在没有列出任何表的情况下被执行时,全局读锁(即,影响所有表)被获取。虽然这些锁也保护数据,但在 MySQL 中它们被视为元数据锁。
除了与备份相关的FLUSH TABLES WITH READ LOCK
之外,显式表锁并不经常与 InnoDB 一起使用,因为 InnoDB 复杂的锁特性在大多数情况下都优于自己处理锁。但是,如果您真的需要锁定整个表,显式锁会很有用,因为 MySQL 检查它们非常便宜。
清单 6-7 中显示了一个连接的例子,该连接在world.country
和world.countrylanguage
表上采用显式读锁,在world.city
表上采用写锁。
-- Connection Processlist ID Thread ID Event ID
-- --------------------------------------------------
-- 1 432 772 6
-- Connection 1
Connection 1> LOCK TABLES world.country READ,
world.countrylanguage READ,
world.city WRITE;
Query OK, 0 rows affected (0.0029 sec)
Listing 6-7Using explicit table locks
当您使用显式锁时,只允许您根据请求的锁来使用您已经锁定的表。这意味着如果你获取一个读锁并试图写入表(ER_TABLE_NOT_LOCKED_FOR_WRITE
)或者如果你试图使用一个没有获取锁(ER_TABLE_NOT_LOCKED
)的表,你将得到一个错误,例如(清单 6-7 的延续)
Connection 1> UPDATE world.country
SET Population = Population + 1
WHERE Code = 'AUS';
ERROR: 1099: Table 'country' was locked with a READ lock and can't be updated
Connection 1> SELECT *
FROM sakila.film
WHERE film_id = 1;
ERROR: 1100: Table 'film' was not locked with LOCK TABLES
由于显式锁被视为元数据锁,performance_schema.metadata_locks
表中的症状和信息与隐式元数据锁相同,您也可以使用UNLOCK TABLES
语句解锁表:
Connection 1> UNLOCK TABLES;
Query OK, 0 rows affected (0.0006 sec)
另一种隐式处理的表级锁被称为表锁。
隐式表锁
当查询一个表时,MySQL 采用隐式表锁。除了刷新、元数据和显式锁之外,表锁对 InnoDB 表没有太大作用,因为 InnoDB 使用记录锁来允许对表的并发访问,只要事务不修改相同的行(粗略地说——如下一章所示——还有更多内容)。
然而,InnoDB 确实在表级别使用了意向锁的概念。由于您在研究锁问题时可能会遇到这些问题,因此有必要熟悉一下它们。正如在锁访问级别的讨论中提到的,意图锁标记了事务的意图。
对于由事务获取的锁,首先获取一个意向锁,然后如果需要的话可以升级它。这不同于不变的显式LOCK TABLES
。为了获得共享锁,事务首先获取意向共享锁,然后获取共享锁。类似地,对于排他锁,首先采用意图排他锁。意向锁定的一些示例如下:
-
一个
SELECT ... FOR SHARE
语句在被查询的表上获取一个意向共享锁。SELECT ... LOCK IN SHARE MODE
语法是同义词。 -
一个
SELECT ... FOR UPDATE
语句在被查询的表上获取一个意向排他锁。 -
一个 DML 语句(不包括
SELECT
)在修改后的表上获取一个意向排他锁。如果修改了外键列,就会在父表上获得一个意向共享锁。
可以在LOCK_TYPE
列设置为TABLE
的performance_schema.data_locks
表中找到表级锁。清单 6-8 展示了一个意向共享锁的例子。
-- Connection Processlist ID Thread ID Event ID
-- --------------------------------------------------
-- 1 446 796 6
-- 2 447 797 6
-- Connection 1
Connection 1> START TRANSACTION;
Query OK, 0 rows affected (0.0003 sec)
Connection 1> SELECT *
FROM world.city
WHERE ID = 130
FOR SHARE\G
*************************** 1\. row ***************************
ID: 130
Name: Sydney
CountryCode: AUS
District: New South Wales
Population: 3276207
1 row in set (0.0010 sec)
-- Connection 2
Connection 2> SELECT engine, thread_id, object_schema,
object_name, lock_type, lock_mode,
lock_status, lock_data
FROM performance_schema.data_locks
WHERE lock_type = 'TABLE'
AND thread_id = 796\G
*************************** 1\. row ***************************
engine: INNODB
thread_id: 796
object_schema: world
object_name: city
lock_type: TABLE
lock_mode: IS
lock_status: GRANTED
lock_data: NULL
1 row in set (0.0011 sec)
-- Connection 1
Connection 1> ROLLBACK;
Query OK, 0 rows affected (0.0004 sec)
Listing 6-8Example of an InnoDB intention shared lock
这显示了一个在world.city
表上的意向共享锁。注意,engine
被设置为INNODB
,而lock_data
为NULL
。
备用锁
备份锁是实例级锁;也就是说,它影响整个系统。它是 MySQL 8 中引入的新锁。备份锁防止可能导致备份不一致的语句,同时仍然允许其他语句与备份同时执行。目前,备份锁的主要用户是 MySQL Enterprise Backup,它与日志锁一起使用,以避免对 InnoDB 表执行FLUSH TABLES WITH READ LOCK
。被阻止的语句包括
-
创建、重命名或删除文件的语句。这些语句包括
CREATE TABLE
、CREATE TABLESPACE
、RENAME TABLE
和DROP TABLE
语句。 -
CREATE USER
、ALTER USER
、DROP USER
、GRANT
等账户管理报表。 -
不将其更改记录到重做日志中的 DDL 语句。例如,这些包括添加索引。
用LOCK INSTANCE FOR BACKUP
语句创建备份锁,用UNLOCK INSTANCE
语句释放锁。执行LOCK INSTANCE FOR BACKUP
需要BACKUP_ADMIN
权限。获取备份锁并再次释放它的一个示例是
mysql> LOCK INSTANCE FOR BACKUP;
Query OK, 0 rows affected (0.0002 sec)
mysql> UNLOCK INSTANCE;
Query OK, 0 rows affected (0.0003 sec)
Note
在编写时,使用 X 协议(通过用mysqlx_port
指定的端口或用mysqlx_socket
指定的套接字连接)时,不允许获取备份锁并释放它。尝试这样做将返回一个ER_PLUGGABLE_PROTOCOL_COMMAND_NOT_SUPPORTED
错误:ERROR: 3130: Command not supported by pluggable protocols
。
此外,与备份锁冲突的语句也会使用备份锁。由于 DDL 语句有时由几个步骤组成,例如,在新文件中重建一个表并重命名文件,备份锁可以在这些步骤之间释放,以避免阻塞LOCK INSTANCE FOR BACKUP
超过必要的时间。
备份锁可以在performance_schema.metadata_locks
表中找到,其中OBJECT_TYPE
列设置为BACKUP LOCK
。清单 6-9 展示了一个查询等待LOCK INSTANCE FOR BACKUP
持有的备份锁的例子。
-- Connection Processlist ID Thread ID Event ID
-- --------------------------------------------------
-- 1 484 851 1
-- 2 485 852 1
-- 3 486 853 1
-- Connection 1
Connection 1> LOCK INSTANCE FOR BACKUP;
Query OK, 0 rows affected (0.0004 sec)
-- Connection 2
Connection 2> OPTIMIZE TABLE world.city;
-- Connection 3
Connection 3> SELECT object_type, object_schema, object_name,
lock_type, lock_duration, lock_status,
owner_thread_id
FROM performance_schema.metadata_locks
WHERE object_type = 'BACKUP LOCK'
AND owner_thread_id IN (851, 852)\G
*************************** 1\. row ***************************
object_type: BACKUP LOCK
object_schema: NULL
object_name: NULL
lock_type: SHARED
lock_duration: EXPLICIT
lock_status: GRANTED
owner_thread_id: 851
*************************** 2\. row ***************************
object_type: BACKUP LOCK
object_schema: NULL
object_name: NULL
lock_type: INTENTION_EXCLUSIVE
lock_duration: TRANSACTION
lock_status: PENDING
owner_thread_id: 852
2 rows in set (0.0007 sec)
-- Connection 1
Connection 1> UNLOCK INSTANCE;
Query OK, 0 rows affected (0.0003 sec)
Listing 6-9Example of a conflict for the backup lock
在本例中,线程 id 为 851 的连接拥有备份锁,而线程 id 为 852 的连接正在等待它。注意LOCK INSTANCE FOR BACKUP
持有一个共享锁,而 DDL 语句请求一个意向排他锁。
与备份锁相关的是日志锁,它的引入也是为了减少备份过程中的锁定。
日志锁
当您创建备份时,您通常希望包含与备份一致的日志位置和 GTID 集的相关信息。在 MySQL 5.7 和更早的版本中,在获取这些信息时需要全局读锁。在 MySQL 8 中,引入了日志锁,允许您在不使用全局读锁的情况下读取 InnoDB 的信息,如执行的全局事务标识符(GTIDs)、二进制日志位置和日志序列号(LSN)。
日志锁防止对日志相关信息进行更改的操作。实际上,这意味着提交、FLUSH LOGS
等等。日志锁是通过查询performance_schema.log_status
表隐式获取的。它需要BACKUP_ADMIN
特权来访问表。清单 6-10 显示了log_status
表的输出示例。
-- Connection Processlist ID Thread ID Event ID
-- --------------------------------------------------
-- 1 490 857 6
-- Connection 1
Connection 1 > SELECT *
FROM performance_schema.log_status\G
*************************** 1\. row ***************************
SERVER_UUID: fcbb7afc-bdde-11ea-b95f-ace2d35785be
LOCAL: {"gtid_executed": "d2549c41-86ca-11ea-9dc7-ace2d35785be:1-351", "binary_log_file": "binlog.000002", "binary_log_position": 39348}
REPLICATION: {"channels": [{"channel_name": "", "relay_log_file": "relay-bin.000002", "relay_log_position": 39588}]}
STORAGE_ENGINES: {"InnoDB": {"LSN": 2073604726, "LSN_checkpoint": 2073604726}}
1 row in set (0.0012 sec)
Listing 6-10Example output of the log_status table
可用信息取决于实例的配置,值取决于使用情况。
对 MySQL 中主要高锁类型的回顾到此结束。
摘要
本章已经讨论了高级锁类型。这些锁大多独立于所使用的存储引擎,包括从用户级和实例级锁到表和元数据锁的一系列锁。
用户级锁可用于保护应用中的工作流,是最通用的锁类型。刷新表时会遇到刷新锁,由于低级表定义缓存(TDC)版本锁,刷新锁会导致难以诊断的问题。元数据锁保护模式对象的元数据,例如表的列定义。
有显式表锁和隐式表锁,其中隐式锁在使用 InnoDB 表时最为常见。隐式锁也包括意图锁。
在实例级别,有两种考虑到备份而开发的锁类型。备份锁防止会使备份不一致的更改,如用户和权限的更改以及某些模式的更改。日志锁是一种隐式锁,在查询performance_schema.log_status
时使用,以确保日志相关的状态值可以用最小的锁定以一致的方式获得。
除了高级锁之外,InnoDB 还有自己的一套工作在记录级的锁,这将在下一章讨论。
七、InnoDB 锁
除了 InnoDB 意向锁之外,前一章研究的锁都是 MySQL 的通用锁。InnoDB 拥有自己复杂的锁定系统,允许高度并发地访问数据。在在线事务处理(OLTP)工作负载中,基准测试表明,根据工作负载的不同,InnoDB 可以很好地处理多达 100 多个并发查询。 1 这不仅与记录级锁有关,还与低级信号量有关,后者是一个正在改进的领域,这也是 MySQL 新版本比旧版本更好地处理并发的主要原因。
Tip
MySQL 的新版本比旧版本支持更高程度的并发查询执行。在最新的 8.0.21 中,锁系统互斥被分片以减少高并发系统上的争用。
在本章中,首先将讨论 InnoDB 记录锁和 next-key 锁,然后讨论间隙锁和谓词锁。所涉及的最后一个数据级锁是自动增量锁,它对于在高并发插入时保持良好的性能也很重要。本章的最后一个主题是信号量。
记录锁和下一键锁
记录锁通常被称为行锁;但是,它不仅仅是行上的锁,因为它还包括索引和间隙锁。相关的是下一键锁。下一键锁是记录锁和记录前间隙上的间隙锁的组合。下一键锁实际上是 InnoDB 中的默认锁类型,因此在锁输出中您只会看到 S(共享)和 X(独占)。
记录锁和下一键锁通常是指 InnoDB 锁。它们是细粒度的锁,旨在锁定最少量的数据,同时仍然确保数据的完整性。
记录锁或下一键锁可以是共享的,也可以是排他的,并且只影响事务访问的行和索引。排他锁的持续时间通常是有例外的事务,例如,INSERT INTO ... ON DUPLICATE KEY
和REPLACE
语句中用于唯一性检查的删除标记的记录。对于共享锁,持续时间可以取决于第 9 和 12 章中讨论的事务隔离级别。
使用performance_schema.data_locks
表可以找到记录和下一键锁。清单 7-1 展示了一个使用二级索引CountryCode
更新world.city
表中的行的锁的例子。
-- Connection Processlist ID Thread ID Event ID
-- --------------------------------------------------
-- 1 544 919 6
-- 2 545 920 6
-- Connection 1
Connection 1> START TRANSACTION;
Query OK, 0 rows affected (0.0002 sec)
Connection 1> UPDATE world.city
SET Population = Population + 1
WHERE CountryCode = 'LUX';
Query OK, 1 row affected (0.0008 sec)
Rows matched: 1 Changed: 1 Warnings: 0
-- Connection 2
Connection 2> SELECT thread_id, event_id,
object_schema, object_name, index_name,
lock_type, lock_mode, lock_status, lock_data
FROM performance_schema.data_locks
WHERE thread_id = 919\G
*************************** 1\. row ***************************
thread_id: 919
event_id: 10
object_schema: world
object_name: city
index_name: NULL
lock_type: TABLE
lock_mode: IX
lock_status: GRANTED
lock_data: NULL
*************************** 2\. row ***************************
thread_id: 919
event_id: 10
object_schema: world
object_name: city
index_name: CountryCode
lock_type: RECORD
lock_mode: X
lock_status: GRANTED
lock_data: 'LUX', 2452
*************************** 3\. row ***************************
thread_id: 919
event_id: 10
object_schema: world
object_name: city
index_name: PRIMARY
lock_type: RECORD
lock_mode: X,REC_NOT_GAP
lock_status: GRANTED
lock_data: 2452
*************************** 4\. row ***************************
thread_id: 919
event_id: 10
object_schema: world
object_name: city
index_name: CountryCode
lock_type: RECORD
lock_mode: X,GAP
lock_status: GRANTED
lock_data: 'LVA', 2434
4 rows in set (0.0014 sec)
-- Connection 1
Connection 1> ROLLBACK;
Query OK, 0 rows affected (0.1702 sec)
Listing 7-1Example of InnoDB record locks
第一行是已经讨论过的意图排他表锁。第二行是值(' LUX ',2452)在CountryCode
索引上的 next-key 锁,其中' LUX '是在WHERE
子句中使用的国家代码,2452 是添加到非唯一二级索引的主键 id。带有ID = 2452
的城市是唯一匹配WHERE
子句的城市,主键记录(行本身)显示在输出的第三行。锁定模式是X,REC_NOT_GAP
,这意味着它是记录上的排他锁,而不是间隙上的排他锁。
什么是差距?输出的第四行显示了一个示例。间隙锁如此重要,以至于关于间隙锁的讨论被分成单独的部分。
间隙锁
间隙锁保护两条记录之间的空间。这可以在聚集索引的行中,也可以在辅助索引中。在索引页的第一条记录之前和最后一条记录之后,分别有称为下确界记录和上确界记录的伪记录。间隙锁通常是最容易引起混淆的锁类型。研究锁问题的经验是熟悉它们的最好方法。
考虑前面示例中的查询:
UPDATE world.city
SET Population = Population + 1
WHERE CountryCode = 'LUX';
该查询更改所有带有CountryCode = 'LUX'
的城市的人口。如果在事务的更新和提交之间插入一个新的城市,会发生什么情况?如果UPDATE
和INSERT
语句提交的顺序与它们执行的顺序相同,一切都没问题。但是,如果以相反的顺序提交更改,结果将会不一致,因为预计插入的行也将被更新。
这就是间隙锁发挥作用的地方。它保护插入新记录(包括从不同位置移动的记录)的空间,因此在持有间隙锁的事务完成之前,它不会被更改。如果您查看清单 7-1 中示例输出的第四行,您可以看到一个间隙锁的示例:
*************************** 4\. row ***************************
thread_id: 919
event_id: 10
object_schema: world
object_name: city
index_name: CountryCode
lock_type: RECORD
lock_mode: X,GAP
lock_status: GRANTED
lock_data: 'LVA', 2434
4 rows in set (0.0014 sec)
这是值(' LVA ',2434)的CountryCode
索引上的独占间隙锁。由于该查询请求更新所有将CountryCode
设置为“LUX”的行,间隙锁确保没有为“LUX”国家代码插入新行。国家代码“LVA”是CountryCode
索引中的下一个值,因此“勒克司”和“LVA”之间的差距受到独占锁的保护。另一方面,用CountryCode = 'LVA'
插入新城市还是有可能的。在某些地方,这被称为“记录前间隙”,这样更容易理解间隙锁是如何工作的。
间隙锁的一个特点是间隙锁不会与另一个间隙锁冲突,即使两者都是互斥的。间隙锁的目的不是防止对间隙的访问,而是专门防止将数据插入间隙。在讨论插入意图锁时,您将看到间隙锁是如何阻塞插入的。
当您使用READ COMMITTED
事务隔离级别而不是REPEATABLE READ
或SERIALIZABLE
时,间隙锁被采用的程度要小得多。
与间隙锁相关的是谓词锁。
谓词和页锁
谓词锁类似于间隙锁,但它适用于无法进行绝对排序的空间索引,因此间隙锁没有意义。对于REPEATABLE READ
和SERIALIZABLE
事务隔离级别中的空间索引,InnoDB 在用于查询或整个页面的最小边界矩形(MBR)上创建一个谓词锁,而不是间隙锁。这将通过防止对最小边框或页面内的数据进行更改来实现一致的读取。
当查询performance_schema.data_locks
表时,谓词锁将有PREDICATE
或PRDT_PAGE
,后者是一个页锁。
作为谓词锁的一个例子,考虑数据库sakila
中的address
表。其中的列location
属于几何数据类型,空间参考系统标识符(SRID)设置为 0。(MySQL 8 中需要一个 SRID 来建立空间索引。)在location
列上的索引被命名为idx_location
。清单 7-2 展示了在更新其中一个地址时如何使用谓词锁。
-- Connection Processlist ID Thread ID Event ID
-- --------------------------------------------------
-- 1 562 954 6
-- 2 563 955 6
-- Connection 1
Connection 1> START TRANSACTION;
Query OK, 0 rows affected (0.0002 sec)
Connection 1> UPDATE sakila.address
SET address = '42 Concurrency Boulevard',
district = 'Punjab',
city_id = 208,
postal_code = 40509,
location = ST_GeomFromText('POINT(75.91 31.53)', 0)
WHERE address_id = 372;
Query OK, 1 row affected (0.0008 sec)
Rows matched: 1 Changed: 1 Warnings: 0
-- Connection 2
Connection 2> SELECT engine_lock_id, thread_id, event_id,
object_schema, object_name, index_name,
lock_type, lock_mode, lock_status, lock_data
FROM performance_schema.data_locks
WHERE thread_id = 954
AND index_name = 'idx_location'\G
*************************** 1\. row ***************************
engine_lock_id: 2123429833312:1074:12:0:2123393008216
thread_id: 954
event_id: 10
object_schema: sakila
object_name: address
index_name: idx_location
lock_type: RECORD
lock_mode: S,PRDT_PAGE
lock_status: GRANTED
lock_data: infimum pseudo-record
*************************** 2\. row ***************************
engine_lock_id: 2123429833312:1074:13:0:2123393008560
thread_id: 954
event_id: 10
object_schema: sakila
object_name: address
index_name: idx_location
lock_type: RECORD
lock_mode: S,PRDT_PAGE
lock_status: GRANTED
lock_data: infimum pseudo-record
2 rows in set (0.0006 sec)
-- Connection 1
Connection 1> ROLLBACK;
Query OK, 0 rows affected (0.0435 sec)
Listing 7-2Example of predicate/page locks
更新的重要部分是改变了location
列。在data_locks
表的输出中,可以看到一个谓词页锁被占用了。
您应该知道的与记录相关的最后一种锁类型是插入意图锁。
插入意向锁
请记住,对于表锁,InnoDB 有意向锁,决定事务是以共享还是独占的方式使用表。类似地,InnoDB 在记录级别有插入意图锁。InnoDB 使用这些锁——顾名思义——和INSERT
语句向其他事务发出信号。因此,锁是在一个尚未创建的记录上(因此它是一个间隙锁),而不是在一个现有的记录上。使用插入意图锁有助于提高执行插入的并发性。
您不太可能在锁输出中看到插入意图锁,除非一个INSERT
语句正在等待一个锁被授予。您可以通过在另一个事务中创建一个间隙锁来阻止INSERT
语句完成,从而强制出现这种情况。清单 7-3 中的例子在连接 1 中创建了一个间隙锁,然后在连接 2 中试图插入一个与间隙锁冲突的行。最后,在第三个连接中,检索锁信息。
-- Connection Processlist ID Thread ID Event ID
-- --------------------------------------------------
-- 1 577 972 6
-- 2 578 973 6
-- 3 579 974 6
-- Connection 1
Connection 1> START TRANSACTION;
Query OK, 0 rows affected (0.0003 sec)
Connection 1> SELECT *
FROM world.city
WHERE ID > 4079
FOR UPDATE\G
0 rows in set (0.0007 sec)
-- Connection 2
Connection 2> START TRANSACTION;
Query OK, 0 rows affected (0.0002 sec)
Connection 2> INSERT INTO world.city
VALUES (4080, 'Darwin', 'AUS',
'Northern Territory', 146000);
-- Connection 3
Connection 3> SELECT thread_id, event_id,
object_schema, object_name, index_name,
lock_type, lock_mode, lock_status, lock_data
FROM performance_schema.data_locks
WHERE thread_id IN (972, 973)
AND object_name = 'city'
AND index_name = 'PRIMARY'\G
*************************** 1\. row ***************************
thread_id: 972
event_id: 10
object_schema: world
object_name: city
index_name: PRIMARY
lock_type: RECORD
lock_mode: X
lock_status: GRANTED
lock_data: supremum pseudo-record
*************************** 2\. row ***************************
thread_id: 973
event_id: 10
object_schema: world
object_name: city
index_name: PRIMARY
lock_type: RECORD
lock_mode: X,INSERT_INTENTION
lock_status: WAITING
lock_data: supremum pseudo-record
2 rows in set (0.0007 sec)
-- Connection 1
Connection 1> ROLLBACK;
Query OK, 0 rows affected (0.0003 sec)
-- Connection 2
Connection 2> ROLLBACK;
Query OK, 0 rows affected (0.3035 sec)
Listing 7-3Example of an insert intention lock
注意对于RECORD
锁,锁模式包括INSERT_INTENTION
——插入意图锁。在这种情况下,锁定的数据是上确界伪记录,但根据具体情况,它也可以是主键的值。如果您还记得下一个键锁的讨论,那么 X 表示下一个键锁,但是这是一个特例,因为锁位于上确界伪记录上,并且不可能锁定它,所以实际上它只是上确界伪记录之前的间隙上的间隙锁。
插入数据时需要注意的另一个锁是自动增量锁。
自动增量锁
当您将数据插入到具有自动递增计数器的表中时,有必要保护计数器,以便保证两个事务获得唯一的值。如果对二进制日志使用基于语句的日志记录,则会有进一步的限制,因为在重播语句时,将为除第一行之外的所有行重新创建自动增量值。
InnoDB 支持三种锁定模式,因此您可以根据需要调整锁定量。使用innodb_autoinc_lock_mode
选项选择锁定模式,该选项取值为 0、1 和 2,MySQL 8 中的默认值为 2。它需要重新启动 MySQL 来改变这个值。表 7-1 中总结了这些值的含义。
表 7-1
innodb_autoinc_lock_mode
选项支持的值
价值
|
方式
|
描述
|
| --- | --- | --- |
| Zero | 传统的 | MySQL 5.0 及更早版本的锁定行为。锁一直保持到语句结束,所以值是以可重复的连续顺序赋值的。 |
| one | 连续的 | 对于查询开始时行数已知的INSERT
语句,所需数量的自动增量值被分配在一个轻量级互斥体下,并且避免了自动增量锁。对于行数未知的语句,自动增量锁被获取并保持到语句结束。这是 MySQL 5.7 和更早版本的默认设置。 |
| Two | 插入纸 | 自动增量锁永远不会被占用,并发插入的自动增量值可能是交错的。只有当二进制记录被禁用或binlog_format
被设置为ROW
时,该模式才是安全的。它是 MySQL 8 中的默认值。 |
innodb_autoinc_lock_mode
的值越高,锁定越少。为此付出的代价是增加自动增量值序列中的间隙数量,以及innodb_autoinc_lock_mode = 2
交错值的可能性。除非不能使用基于行的二进制日志记录,或者对连续的自动增量值有特殊需求,否则建议使用值 2。
数据级锁的讨论到此结束,但是在讨论 MySQL 并发性时,还有一个重要的话题:互斥体和 rw 锁信号量。
互斥和读写锁信号量
在 MySQL 源代码内部,有必要保护代码路径。一个例子是保护修改缓冲池内容的代码,以避免两个线程同时修改缓冲池内容,从而可能导致冲突更改。在某种程度上,您可以将互斥锁与用户级锁进行比较,只是前者用于 MySQL 代码路径,后者用于使用 MySQL 的应用代码路径。
Note
InnoDB 在某种程度上互换使用术语互斥和信号量。例如,InnoDB 监控器中的SEMAPHORES
部分也包含互斥等待的信息,而SHOW ENGINE INNODB MUTEX
包含信号量。
不是只有 InnoDB 在 MySQL 中使用同步对象;例如,表 open cache 也由互斥体保护。然而,在大多数情况下,当您在同步对象上遇到争用问题时,这与 InnoDB 有关,因为高并发性操作的压力通常是最大的,对于 InnoDB 来说,有现成的监控工具来调查争用。因此,这里只讨论 InnoDB。
互斥体和信号量比数据锁更难研究,因为不可能在锁就位时暂停代码执行并直接研究它们。(是的,但是这需要使用调试器,比如gdb
和使用断点。)即使在性能模式中启用了同步等待,您通常也会有所欠缺,因为会有许多等待,即使是默认为 10000 行的长历史表也会很快被一个连接耗尽,如清单 7-4 所示。
-- Connection Processlist ID Thread ID Event ID
-- --------------------------------------------------
-- 1 638 1057 6
-- 2 639 1058 6
-- Connection 1
Connection 1> UPDATE performance_schema.setup_instruments
SET ENABLED = 'YES',
TIMED = 'YES'
WHERE NAME LIKE 'wait/synch/%';
Query OK, 323 rows affected (0.0230 sec)
Rows matched: 323 Changed: 323 Warnings: 0
Connection 1> UPDATE performance_schema.setup_consumers
SET ENABLED = 'YES'
WHERE NAME IN ('events_waits_current', 'events_waits_history_long');
Query OK, 2 rows affected (0.0004 sec)
Rows matched: 2 Changed: 2 Warnings: 0
-- Connection 2
Connection 2> UPDATE world.city
SET Population = Population + 1
WHERE CountryCode = 'USA';
Query OK, 274 rows affected (0.1522 sec)
Rows matched: 274 Changed: 274 Warnings: 0
-- Connection 1
Connection 1> SELECT REPLACE(event_name, 'wait/synch/', '') AS event, COUNT(*)
FROM performance_schema.events_waits_history_long
WHERE thread_id = 1058
AND event_name LIKE 'wait/synch/%'
GROUP BY event_name
WITH ROLLUP
ORDER BY COUNT(*);
+----------------------------------------------+----------+
| event | COUNT(*) |
+----------------------------------------------+----------+
| mutex/sql/MYSQL_BIN_LOG::LOCK_done | 1 |
| mutex/innodb/purge_sys_pq_mutex | 1 |
| mutex/sql/MYSQL_BIN_LOG::LOCK_sync | 1 |
| mutex/sql/MYSQL_BIN_LOG::LOCK_log | 1 |
| mutex/mysqlx/vio_shutdown | 1 |
| mutex/sql/LOCK_plugin | 1 |
| mutex/sql/LOCK_slave_trans_dep_tracker | 1 |
| mutex/sql/MYSQL_BIN_LOG::LOCK_binlog_end_pos | 1 |
| mutex/sql/MYSQL_BIN_LOG::LOCK_commit | 1 |
| mutex/sql/MYSQL_BIN_LOG::LOCK_xids | 1 |
| sxlock/innodb/rsegs_lock | 1 |
| sxlock/innodb/undo_spaces_lock | 1 |
| mutex/sql/MYSQL_BIN_LOG::LOCK_sync_queue | 2 |
| mutex/innodb/lock_sys_table_mutex | 2 |
| mutex/sql/MYSQL_BIN_LOG::LOCK_flush_queue | 2 |
| mutex/sql/Gtid_state | 2 |
| mutex/sql/LOCK_table_cache | 2 |
| mutex/sql/MYSQL_BIN_LOG::LOCK_commit_queue | 2 |
| mutex/sql/THD::LOCK_thd_query | 2 |
| mutex/innodb/undo_space_rseg_mutex | 3 |
| mutex/sql/THD::LOCK_thd_data | 3 |
| rwlock/sql/gtid_commit_rollback | 3 |
| mutex/mysys/THR_LOCK_open | 4 |
| mutex/sql/THD::LOCK_query_plan | 4 |
| mutex/innodb/flush_list_mutex | 5 |
| sxlock/innodb/index_tree_rw_lock | 5 |
| mutex/innodb/trx_undo_mutex | 274 |
| mutex/innodb/trx_sys_mutex | 279 |
| sxlock/innodb/hash_table_locks | 288 |
| sxlock/innodb/btr_search_latch | 550 |
| sxlock/innodb/lock_sys_global_rw_lock | 551 |
| sxlock/innodb/log_sn_lock | 551 |
| mutex/innodb/lock_sys_page_mutex | 554 |
| mutex/innodb/trx_mutex | 850 |
| NULL | 3950 |
+----------------------------------------------+----------+
35 rows in set (0.0173 sec)
Connection 1> UPDATE performance_schema.setup_instruments
SET ENABLED = 'NO',
TIMED = 'NO'
WHERE NAME LIKE 'wait/synch/%';
Query OK, 323 rows affected (0.0096 sec)
Rows matched: 323 Changed: 323 Warnings: 0
Connection 1> UPDATE performance_schema.setup_consumers
SET ENABLED = 'NO'
WHERE NAME IN ('events_waits_current', 'events_waits_history_long');
Query OK, 2 rows affected (0.0004 sec)
Rows matched: 2 Changed: 2 Warnings: 0
Listing 7-4Example of synchronization waits
在这个简单的例子中,请求了将近 4000 个同步对象(查询结果底部的第NULL
行)。等待的确切列表和数量会因执行和系统的不同而不同,这取决于系统的状态(比如数据是否已经在缓冲池中)和配置。如果有足够多的其他活动,这个数字可能会低得多,因为一些等待可能已经被更新的事件推出了events_waits_history_long
表。更复杂的是,后台线程也会生成等待事件,所以即使系统没有连接,也会创建等待事件。
虽然很难建立测试用例来演示单个同步对象的使用,但好消息是,作为最终用户,您最需要担心的是争用,SHOW ENGINE INNODB STATUS
和SHOW ENGINE INNODB MUTEX
语句将为您提供关于 InnoDB 互斥体和信号量争用的信息。
一般来说,你需要研究源代码来理解等待是为了什么;然而,单独考虑文件通常可以很好地指示发生争用的功能区域。表 7-2 展示了几个如何将互斥体和信号量信息提供的文件名映射到功能区的例子。源代码路径与包含 InnoDB 存储引擎实现的storage/innobase
相关。
表 7-2
互斥和信号量文件名及其功能区
|文件名
|
源代码路径
|
功能区
|
| --- | --- | --- |
| btr0sea.cc
| btr/btr0sea.cc
| 自适应哈希索引。 |
| buf0buf.cc
| buf/buf0buf.cc
| 缓冲池。 |
| buf0flu.cc
| buf/buf0flu.cc
| 缓冲池刷新算法。 |
| dict0dict.cc
| dict/dict0dict.cc
| InnoDB 数据字典。 |
| sync0sharded_rw.h
| include/sync0sharded_rw.h
| 线程的分片读写锁。 |
| hash0hash.cc
| ha/hash0hash.cc
| 用于保护哈希表。 |
| fil0fil.cc
| fil/fil0fil.cc
| 表空间内存缓存。 |
除了在头文件中实现的互斥体和信号量,一般来说,您可以通过使用文件名中 0 之前的名称(例如btr0sea.cc
中的btr
)作为目录名,然后使用文件名本身来访问源代码文件。如果你在编辑器中打开这个文件,那么在许可证和版权标题之后,你会看到一个简短的注释,描述这个文件的用途,例如来自storage/innobase/btr/btr0sea.cc
:
/** @file btr/btr0sea.cc
The index tree adaptive search
Created 2/17/1996 Heikki Tuuri
*************************************************************************/
因此,btr0sea.cc
文件在索引树上实现了自适应搜索,自适应散列索引是索引树的一部分(也是最常发生争用的地方)。
WHY INNOBASE? A BRIEF HISTORY OF INNODB
您可能会感到困惑,为什么通往 innodb 源代码的路径是使用“innobase”而不是“InnoDB”的storage/innobase/
要理解这一点,您需要深入了解 InnoDB 的历史——这非常有趣。
Innobase 是 Heikki Tuuri 在 1995 年成立的公司(没错,就是在文件storage/innobase/btr/btr0sea.cc
的评论中列出的那家),同年 MySQL 首次发布,但当时这两家公司还没有任何关系。Innobase 用于开发 InnoDB,在当时,这意味着是一个独立的产品。直到后来 MySQL 增加了对第三方存储引擎的支持,Heikki 才把 InnoDB 作为开源发布,并与 MySQL 进行了集成。
2005 年,Oracle 收购了 Innobase 和 InnoDB,这导致了一个有趣的情况,MySQL 的主要事务存储引擎(另一个使用较少的引擎是 BDB 的 Berkley DB,它也被 Oracle 收购了)由竞争对手维护。这也是为 MySQL 6 开发 Falcon 存储引擎的原因之一。然而,在这项工作完成之前,Sun Microsystems 收购了 MySQL,Oracle 又收购了 Sun Microsystems,因此在 2010 年,MySQL 和 InnoDB 终于成为了同一家公司的一部分,今天 InnoDB 和 MySQL 是由 Oracle 内部的同一部门开发的。这也意味着 Falcon 存储引擎被放弃,永远不会以正式发布(GA)状态发布。
虽然 Innobase 作为一家公司已经消失很久了,但它的名字仍然存在于 MySQL 源代码中,既存在于 InnoDB 源代码的路径中,也作为源代码中的名称。
摘要
本章介绍了 InnoDB 数据级锁以及互斥和读写锁信号量。这些锁对于支持对 InnoDB 数据的并发访问非常重要,这是 InnoDB 的优势之一。
首先,讨论了记录锁和下一键锁。在讨论 InnoDB 记录锁时,这通常就是所指的内容。next-key 锁是 InnoDB 中的默认锁,保护记录以及记录前的 gab。其次,讨论了间隙锁的概念。当提到间隙锁时,它指的是保护两个记录之间的空间,而不保护记录本身。第三,讲述了与空间索引一起使用的谓词和页锁的相关概念。
第四种和第五种是两种锁类型,您遇到的程度不会与前三种锁类型相同。插入意图锁顾名思义与插入数据结合使用,自动增量锁用于确保自动增量值被正确分配。
第六,也是最后一点,主要讨论了 InnoDB 中的互斥和读写锁信号量。这些是最复杂的锁,在很大程度上需要研究源代码。
以上是 MySQL 和 InnoDB 中可用锁的概述。下一章继续讨论当锁不能被获取时会发生什么。
八、处理锁冲突
锁的整体思想是限制对对象或记录的访问,以避免冲突的操作,从而以安全的方式同时访问对象或记录。这意味着,有时锁不能被授予。那种情况下会发生什么?这取决于请求的锁和环境。元数据(包括显式请求的表锁)和 InnoDB 锁在超时的情况下运行,对于某些锁情况,存在显式死锁检测。
使用数据库时,无法获得锁是不可避免的,理解这一点很重要。原则上,您可以使用非常粗粒度的锁并避免失败的锁,除非超时——这就是 MyISAM 存储引擎在写入并发性非常差的情况下所做的事情。然而,在实践中,为了允许写工作负载的高并发性,优选细粒度锁,这也引入了死锁的可能性。
结论是,您应该始终让您的应用准备好重试获取锁或优雅地失败。无论是显式锁还是隐式锁,这都适用。
Tip
总是准备好处理失败以获得锁。无法获得锁并不是一个灾难性的错误,通常不应该被认为是一个 bug。也就是说,正如第 9 章“减少锁定问题”中所讨论的,在开发应用时,有一些减少锁争用的技术值得考虑。
本章的其余部分将讨论当有多个对同一数据锁的请求时,InnoDB 如何选择哪个事务应该首先被授予锁请求,InnoDB 数据锁的兼容性,以及表级超时、记录级超时、InnoDB 死锁、InnoDB 互斥和信号量等待的细节。
竞争感知事务调度(CATS)
当对同一个锁有多个请求时,一个重要的决策是决定应该以什么顺序授予锁。最简单的解决方案,也是数据库中最常用的解决方案,是维护一个队列,并根据先进先出(FIFO)原则来处理请求。这也是 MySQL 5.7 及更早版本中锁的授予方式;然而在 MySQL 8 中,实现了一个新的调度算法。
新的实现基于竞争感知事务调度(CATS)算法,该算法由密歇根大学的巴尔赞·莫扎法里教授的团队开发,并由桑尼·贝恩斯与莫扎法里教授的团队(尤其是黄嘉敏)合作在 MySQL 中实现。
Tip
如果你想了解更多关于 CATS 算法的知识,那么 https://mysqlserverteam.com/contention-aware-transaction-scheduling-arriving-in-innodb-to-boost-performance/
是一个很好的起点,评论中有两篇研究论文的链接——主要论文是 http://web.eecs.umich.edu/~mozafari/php/data/uploads/pvldb_2018_sched.pdf
。另一个来源是参考手册中的 https://dev.mysql.com/doc/refman/en/innodb-transaction-scheduling.html
。
CATS 算法的工作原理是,已经持有大量锁的事务对于完成最重要,因此为了所有事务的利益,可以尽快释放它们的锁。这种方法的一个潜在缺点是,如果不断有具有许多现有锁的事务等待给定的锁,那么它们可能会使具有很少锁的事务永远得不到锁。为了防止这种情况,算法有防止饥饿的安全措施。这种保护的工作方式是在当前锁请求队列的末尾添加一个屏障,在屏障前面的所有请求都得到处理,然后再考虑后面到达的请求。
CATS 算法的主要优点是在高并发工作负载下,直到 MySQL 8.0.20,它只在 InnoDB 检测到大量锁争用时使用。在 8.0.20 中对算法进行了改进,提高了可扩展性,在information_schema.INNODB_TRX
中增加了trx_schedule_weight
列,这样就可以根据 CATS 算法查询一个处于LOCK WAIT
状态的事务当前拥有的权重。与此同时,它被改变了,所以 CATS 算法总是被使用,FIFO 算法已经退休。
InnoDB 数据锁兼容性
记住,在讨论锁访问级别兼容性时,规则相对简单。然而,确定两个 InnoDB 数据锁是否相互兼容是非常复杂的。这变得特别有趣,因为这种关系是不对称的,也就是说,一个锁可以在另一个锁存在时被允许,但反之则不行。例如,插入意图锁必须等待间隙锁,但是间隙锁不必等待插入意图锁。另一个例子(缺乏传递性)是间隙加记录锁必须等待仅记录锁,插入意图锁必须等待间隙加记录锁,但是插入意图锁不需要等待仅记录锁。
这对你意味着什么?这意味着当您调查锁争用问题时,您需要意识到锁的顺序非常重要,因此在重现该问题时,所有锁必须以相同的顺序获得。
关于 InnoDB 算法处理锁争用背后的理论以及可能导致锁不被授予的原因已经讲得够多了。当锁不能被授予时会发生什么是下一个要讨论的主题。
元数据和备份锁等待超时
当您请求刷新、元数据或备份锁时,获取锁的尝试将在lock_wait_timeout
秒后超时。默认超时为 31,536,000 秒(365 天)。您可以在全局和会话范围内动态设置lock_wait_timeout
选项,这允许您根据给定流程的特定需求调整超时。
当超时发生时,语句失败,出现一个ER_LOCK_WAIT_TIMEOUT
(错误号 1205)错误,如清单 8-1 所示。
-- Connection Processlist ID Thread ID Event ID
-- --------------------------------------------------
-- 1 647 1075 6
-- 2 648 1076 6
-- Connection 1
Connection 1> LOCK TABLES world.city WRITE;
Query OK, 0 rows affected (0.0015 sec)
-- Connection 2
Connection 2> SET SESSION lock_wait_timeout = 5;
Query OK, 0 rows affected (0.0003 sec)
Connection 2> LOCK TABLES world.city WRITE;
ERROR: 1205: Lock wait timeout exceeded; try restarting transaction
-- Connection 1
Connection 1> UNLOCK TABLES;
Query OK, 0 rows affected (0.0003 sec)
Listing 8-1Lock wait timeout for table lock request
lock_wait_timeout
的会话值设置为 5 秒,以减少连接 2 在试图获取world.city
表上的写锁时阻塞的时间。等待 5 秒钟后,返回错误,错误号设置为 1205。
lock_wait_timeout
选项的推荐设置取决于应用的要求。使用较小的值来防止锁请求长时间阻塞其他查询可能是一个优势。这通常需要您实现对锁请求失败的处理,例如,通过重试该语句。另一方面,较大的值有助于避免重试该语句。
对于FLUSH TABLES
语句,还要记住它与低级表定义缓存(TDC)版本锁交互,这可能意味着放弃该语句不允许后续查询继续进行。在这种情况下,lock_wait_timeout
的值越大越好,这样可以更清楚地了解锁的关系。
InnoDB 锁等待超时
当查询请求 InnoDB 中的记录级锁时,它会超时,类似于刷新、元数据和备份锁的超时。由于记录级锁争用比表级锁争用更常见,并且记录级锁增加了死锁的可能性,因此超时默认为 50 秒。它可以使用innodb_lock_wait_timeout
选项进行设置,该选项可以针对全局和会话范围进行设置。
当超时发生时,查询失败,并出现ER_LOCK_WAIT_TIMEOUT
错误(错误号 1205 ),就像表级锁超时一样。清单 8-2 展示了一个发生 InnoDB 锁等待超时的例子。
-- Connection Processlist ID Thread ID Event ID
-- --------------------------------------------------
-- 1 656 1087 6
-- 2 657 1088 6
-- Connection 1
Connection 1> START TRANSACTION;
Query OK, 0 rows affected (0.0002 sec)
Connection 1> UPDATE world.city
SET Population = Population + 1
WHERE ID = 130;
Query OK, 1 row affected (0.0006 sec)
Rows matched: 1 Changed: 1 Warnings: 0
-- Connection 2
Connection 2> SET SESSION innodb_lock_wait_timeout = 3;
Query OK, 0 rows affected (0.2621 sec)
Connection 2> UPDATE world.city
SET Population = Population + 1
WHERE ID = 130;
ERROR: 1205: Lock wait timeout exceeded; try restarting transaction
-- Connection 1
Connection 1> ROLLBACK;
Query OK, 0 rows affected (0.0751 sec)
Listing 8-2Example of an InnoDB lock wait timeout
在本例中,连接 2 的锁等待超时设置为 3 秒,因此没有必要等待通常的 50 秒超时。
当超时发生时,innodb_rollback_on_timeout
选项定义了事务完成的工作有多少被回滚。当innodb_rollback_on_timeout
被禁用时(默认),只有触发超时的语句被回滚。启用该选项后,整个事务将回滚。innodb_rollback_on_timeout
选项只能在全局级别配置,并且需要重启才能更改值。
Caution
处理锁等待超时是非常重要的,否则它可能会使事务带有未释放的锁。如果发生这种情况,其他事务可能无法获得它们需要的锁。因此,您需要确保重试事务的剩余部分,显式回滚事务,或者启用innodb_rollback_on_timeout
在锁等待超时时自动回滚事务。
一般情况下,建议将 InnoDB 记录级锁的超时值保持在较低水平。通常,最好降低默认值 50 秒。允许查询等待锁的时间越长,其他锁请求受影响的可能性就越大,这也可能导致其他查询停止。这也使得死锁更有可能发生。如果您禁用死锁检测(接下来将讨论),您应该为innodb_lock_wait_timeout
使用一个非常小的值,比如 1 秒或 2 秒,因为您将使用超时来检测死锁。如果没有死锁检测,也建议启用innodb_rollback_on_timeout
选项。
僵局
死锁听起来是一个非常可怕的概念,但是你不应该让这个名字吓住你。就像锁等待超时一样,死锁是高并发数据库世界中的现实。它真正的意思是锁请求之间有一个循环关系,如图 8-1 中的交通阻塞所示。解决僵局的唯一方法是强制放弃其中一个请求。从这个意义上说,死锁与锁等待超时没有什么不同。事实上,您可以禁用死锁检测,在这种情况下,其中一个锁将以锁等待超时结束。
图 8-1
交通堵塞
那么,如果不是真正需要的话,为什么会有死锁呢?因为当锁请求之间存在循环关系时会出现死锁,所以 InnoDB 可以在循环完成后立即检测到死锁。这允许 InnoDB 立即告诉用户发生了死锁,而不必等待锁等待超时。告知发生了死锁也是有用的,因为这通常提供了改进应用中数据访问的机会。因此,您应该将死锁视为朋友,而不是敌人。图 8-2 显示了两个事务查询一个导致死锁的表的例子。
图 8-2
导致死锁的两个事务的示例
在本例中,事务 1 首先用ID = 130
更新行,然后用ID = 3805
更新行。在此期间,事务 2 首先用ID = 3805
更新行,然后用ID = 130
更新行。这意味着当事务 1 试图更新ID = 3805
时,事务 2 已经锁定了该行。事务 2 也无法继续,因为它无法锁定ID = 130
,因为事务 1 已经持有该锁。这是一个简单死锁的典型例子。环锁关系也如图 8-3 所示。
图 8-3
导致死锁的锁的循环关系
在该图中,事务 1 和事务 2 持有哪个锁,请求哪个锁,以及如果没有干预,冲突如何永远无法解决,这一点很清楚。这使得它有资格成为一个僵局。
在现实世界中,死锁往往更加复杂。在这里讨论的例子中,只涉及到主键记录锁。一般来说,通常还包括二级钥匙、间隙锁和其他可能的锁类型。也可能涉及两个以上的事务。然而,原则是一样的。
Note
对于两个事务中的每一个,即使只有一个查询,也会发生死锁。如果一个查询按升序读取记录,而另一个按降序读取记录,则可能会出现死锁。
当死锁发生时,InnoDB 选择“工作最少”的事务成为受害者。这类似于在一些高可用性解决方案(如 MySQL NDB 集群)中使用的“射中另一个节点的头部”(STONITH)方法,只是这里是一个事务被“射中头部”您可以检查information_schema.INNODB_TRX
视图中的trx_weight
列,查看 InnoDB 使用的权重(完成的工作越多,权重越高)。实际上,这意味着持有最少锁的事务将被回滚。当这种情况发生时,事务中被选作牺牲品的查询失败,并返回错误ER_LOCK_DEADLOCK
(错误代码 1213),事务被回滚以释放尽可能多的锁。清单 8-3 中显示了一个发生死锁的例子。
-- Connection Processlist ID Thread ID Event ID
-- --------------------------------------------------
-- 1 659 1093 6
-- 2 660 1094 6
-- Connection 1
Connection 1> START TRANSACTION;
Query OK, 0 rows affected (0.0002 sec)
Connection 1> UPDATE world.city
SET Population = Population + 1
WHERE ID = 130;
Query OK, 1 row affected (0.0098 sec)
Rows matched: 1 Changed: 1 Warnings: 0
-- Connection 2
Connection 2> START TRANSACTION;
Query OK, 0 rows affected (0.0003 sec)
Connection 2> UPDATE world.city
SET Population = Population + 1
WHERE ID = 3805;
Query OK, 1 row affected (0.0009 sec)
Rows matched: 1 Changed: 1 Warnings: 0
Connection 2> UPDATE world.city
SET Population = Population + 1
WHERE ID = 130;
-- Connection 1
Connection 1> UPDATE world.city
SET Population = Population + 1
WHERE ID = 3805;
ERROR: 1213: Deadlock found when trying to get lock; try restarting transaction
-- Connection 2
Query OK, 1 row affected (0.1019 sec)
Rows matched: 1 Changed: 1 Warnings: 0
-- Connection 1
Connection 1> ROLLBACK;
Query OK, 0 rows affected (0.0002 sec)
-- Connection 2
Connection 2> ROLLBACK;
Query OK, 0 rows affected (0.0293 sec)
Listing 8-3Example of a deadlock
死锁甚至可以比本例中的更简单(尽管这种情况很少见,除非您显式地使用锁定SELECT
语句或者使用SERIALIZABLE
事务隔离级别)。清单 8-4 显示了仅使用一行就发生的死锁。
-- Connection Processlist ID Thread ID Event ID
-- --------------------------------------------------
-- 1 663 1097 6
-- 2 664 1098 6
-- Connection 1
Connection 1> START TRANSACTION;
Query OK, 0 rows affected (0.0004 sec)
Connection 1> SELECT * FROM world.city WHERE ID = 130 FOR SHARE;
+-----+--------+-------------+-----------------+------------+
| ID | Name | CountryCode | District | Population |
+-----+--------+-------------+-----------------+------------+
| 130 | Sydney | AUS | New South Wales | 3276207 |
+-----+--------+-------------+-----------------+------------+
1 row in set (0.0005 sec)
-- Connection 2
Connection 2> START TRANSACTION;
Query OK, 0 rows affected (0.0002 sec)
Connection 2> UPDATE world.city
SET Population = Population + 1
WHERE ID = 130;
-- Connection 1
Connection 1> UPDATE world.city
SET Population = Population + 1
WHERE ID = 130;
Query OK, 1 row affected (0.0447 sec)
Rows matched: 1 Changed: 1 Warnings: 0
-- Connection 2
ERROR: 1213: Deadlock found when trying to get lock; try restarting transaction
-- Connection 1
Connection 1> ROLLBACK;
Query OK, 0 rows affected (0.0280 sec)
-- Connection 2
Connection 2> ROLLBACK;
Query OK, 0 rows affected (0.0003 sec)
Listing 8-4A single row deadlock
在这种情况下,连接 2 成为死锁的受害者,而没有被授予记录锁。发生这种死锁是因为 InnoDB 当前不允许将连接 1 中的共享锁升级为独占锁的请求跳到连接 2 的锁请求之前。这是有可能实现的,但是因为这些场景相对较少,所以还没有实现。也就是说,如果您有外键,DML 语句可能会在外键关系中的另一个表上获取一个共享锁(另请参见第 10 章),因此,如果同一事务中的后续语句试图升级该共享锁,那么您会看到与本例中相同类型的死锁。
在大多数情况下,自动死锁检测对于避免查询延迟过长时间是非常有用的。不过死锁检测不是免费的。对于具有非常高的查询并发性的 MySQL 实例,查找死锁的成本会变得很高,您最好禁用死锁检测,这是通过将innodb_deadlock_detect
选项设置为OFF
来完成的。也就是说,在 MySQL 8.0.18 和更高版本中,死锁检测被移到了一个专用的后台线程中,从而提高了性能。
如果禁用死锁检测,建议将innodb_lock_wait_timeout
设置为一个非常低的值,比如 1 秒,以快速检测锁争用。此外,启用innodb_rollback_on_timeout
选项以确保锁被释放。
最后一种锁冲突处理发生在 InnoDB 互斥体和信号量中。
InnoDB 互斥和信号量等待
当 InnoDB 请求一个互斥或 rw 锁信号量并且不能立即获得时,它将不得不等待。因为等待发生在比数据锁更低的级别,InnoDB 将在等待时采用两种方法中的一种。它可以进入一个循环并轮询锁是否可用,或者它可以挂起线程并使其可用于其他任务。
轮询允许更快地获得锁,但它会使 CPU 线程忙碌,并且轮询会导致其他线程的 CPU 缓存失效。有三个配置选项可用于控制行为:
-
innodb_spin_wait_delay
: 轮询时,InnoDB 会计算一个介于零和innodb_spin_wait_delay
之间的随机数。这与innodb_spin_wait_pause_multiplier
相乘,以确定轮询循环中出现的PAUSE
指令的数量。PAUSE
事件的随机数用于降低缓存失效的影响。较小的值(甚至 0)可能会对具有单个共享 fast CPU 缓存的系统有所帮助。较大的值可以降低缓存失效的影响,尤其是在多 CPU 系统上。 -
innodb_spin_wait_pause_multiplier
: 与旋转等待延迟一起使用的乘数。这个选项是 MySQL 8.0.16 中新增的,是为了适应 Skylake 一代处理器中引入的PAUSE
指令的持续时间的变化而引入的。更改该值的主要用途是在与 Skylake 之前的 x86/x86-64 相比,PAUSE
指令持续时间不同的架构上使用 MySQL。在早期版本中,乘数被硬编码为 50,这也是innodb_spin_wait_pause_multiplier
的默认值。 -
innodb_sync_spin_loops
: 暂停线程前要执行的旋转循环次数。该值越低,CPU 线程可用于其他任务的速度就越快,代价是更多的上下文切换。当超过自旋循环时,操作系统等待 rw 锁计数器递增。该值越高,获得锁的速度就越快,但代价是 CPU 使用率更高。默认值为 30。
您很少需要调整这些设置;然而,在极少数情况下,它们可以在某种程度上提高性能。也就是说,如果您使用的不是最新的 MySQL 版本,升级比调整这些选项更能减少互斥/信号量争用。在所有情况下,如果您决定更改这些设置,请确保在与您的生产系统相同的体系结构和硬件配置上彻底测试性能,并且工作负载能够很好地代表您的生产工作负载。
如果不能立即获得 InnoDB 互斥或 rw 锁信号量等待,InnoDB 也会在内部注册。通过SHOW ENGINE INNODB MUTEX
显示的相关计数器将增加(虽然只显示至少有一个操作系统等待的互斥体和读写锁),如果您在等待过程中生成 InnoDB monitor 报告,它将包含在报告的SEMAPHORES
等待部分。如果等待持续超过 240 秒而没有检测到进度,InnoDB 将自动启用 InnoDB 监控器并将输出写入错误日志,以便您可以调查问题。如果在接下来的 600 秒内没有检测到任何进展,InnoDB 将关闭 MySQL 作为预防措施,因为它假设出现了无法解决的情况。在这种情况下,您将看到一个解释关闭原因的错误,例如(是的,打印的持续时间有些误导,因为它是自 240 秒触发“长信号量等待”条件以来的时间)
2020-07-05T09:30:24.151782Z 0 [ERROR] [MY-012872] [InnoDB] Semaphore wait has lasted > 600 seconds. We intentionally crash the server because it appears to be hung.
通过使服务器崩溃,InnoDB 确保了如果遇到 InnoDB 内部的错误,情况会得到解决,但代价是 MySQL 将不得不重新启动并进行崩溃恢复。由于这个原因,它被认为是最后的手段,目前,超时是不可配置的。
Note
执行CHECK TABLE
时,超时阈值增加到 7200 秒(2 小时)。
像这样的关机通常有两个原因:
-
有一个硬件或操作系统问题阻碍了 InnoDB 的进展。
-
InnoDB 中有一个错误,例如,检测不到缓慢操作的进度,或者在获取互斥体或信号量时出现死锁。
如果您遇到类似这样的关闭,请从错误日志中的 InnoDB monitor 输出中验证发生等待的位置。在某些情况下,您可以使用线程 id 来确定哪个查询导致了等待。您还应该检查系统日志,以验证硬件的健康状况,以及是否有任何操作系统级别的问题迹象。
摘要
本章概述了当不能立即获得锁时会发生什么。首先,描述了竞争感知事务调度(CATS)算法。这在 MySQL 8 中使用,允许已经持有许多锁的事务更快地获得锁请求,因此它们的锁也可以更快地再次释放。
其次,讨论了 InnoDB 数据锁的兼容性是一个非常复杂的问题,这意味着在试图重现问题时必须考虑锁的顺序。
本章的其余部分介绍了元数据、备份、InnoDB 锁等待超时、死锁、InnoDB 互斥和 rw 锁信号量等待。锁等待和死锁在高并发系统中是自然发生的,它们本身不应该成为警报的原因。主要问题是它们何时变得频繁。默认的锁等待超时也可能太长,因此减少它们并处理超时可能是一个选项。
现在您已经了解了锁是如何工作的,以及锁请求是如何失败的,您需要考虑如何减少锁定的影响,这是下一章的主题。
九、减少锁定问题
请记住,MySQL 和 InnoDB 中的锁定是提供并发访问的一种方式,通常 InnoDB 的细粒度锁定允许高度并发的工作负载。然而,如果您有过多的锁定,它将导致并发性降低和查询堆积,在最糟糕的情况下,它可能会导致应用停止工作,并导致糟糕的用户体验。
因此,当您编写应用并为其数据和访问设计模式时,记住锁是很重要的。减少锁定的策略包括添加索引、更改事务隔离级别、更改配置和抢先锁定。本章涵盖了所有这些策略。
Tip
不要被优化锁冲昏了头脑。如果只是偶尔遇到锁等待超时和死锁,通常最好重试查询或事务,而不是花时间来避免这个问题。多频繁取决于您的工作负载,但是对于许多应用来说,每小时重试几次不是问题。
事务规模和年龄
减少锁问题的一个重要策略是保持您的事务较小,并避免使事务打开的时间超过必要时间的延迟。锁问题最常见的原因是事务修改了大量的行,或者事务的活动时间超过了必要的时间。
事务的大小是事务所做的工作量,尤其是它占用的锁的数量,但是事务执行所花费的时间也很重要。正如本讨论中的一些其他主题将会提到的,您可以通过索引和事务隔离级别来部分地降低影响。然而,记住总体结果也很重要。如果您需要修改许多行,问问自己是否可以将工作分成更小的批,或者是否要求所有事情都在同一个事务中完成。也可以将一些准备工作分离出来,在主事务之外完成。
事务的持续时间也很重要。一个常见的问题是使用autocommit = 0
的连接。每次在没有活动事务的情况下执行查询(包括SELECT
)时,都会启动一个新的事务,直到执行显式的COMMIT
或ROLLBACK
、执行 DDL 语句或关闭连接时,事务才会完成。一些连接器默认禁用自动提交,所以您可能在没有意识到的情况下使用了这种模式,这可能会错误地让事务打开几个小时。
Tip
启用autocommit
选项,除非您有特定的理由禁用它。当您启用自动提交时,InnoDB 还可以为许多SELECT
查询检测出它是一个只读事务,并减少查询的开销。
另一个缺陷是在事务活动时启动事务并在应用中执行缓慢的操作。这可以是发送回用户的数据、交互式提示或文件 I/O。确保在 MySQL 中没有打开活动事务时执行这些缓慢的操作。
索引
索引减少了访问给定行所需的工作量。这样,索引是减少锁定的一个很好的工具,因为只有在执行查询时访问的记录才会被锁定。
考虑一个简单的例子,在world.city
表中查询名为 Sydney 的城市:
START TRANSACTION;
SELECT *
FROM world.city
WHERE Name = 'Sydney'
FOR SHARE;
FOR SHARE
选项用于强制查询对读取的记录使用共享锁。默认情况下,Name
列上没有索引,因此查询将执行全表扫描来查找结果中需要的行。没有索引,有 4103 个记录锁(其中 24 个锁在主键的上确界伪记录上),如清单 9-1 所示。
-- Connection Processlist ID Thread ID Event ID
-- --------------------------------------------------
-- 1 697 1143 6
-- 2 698 1144 6
-- Connection 1
Connection 1> START TRANSACTION;
Query OK, 0 rows affected (0.0002 sec)
Connection 1> SELECT ID, Name, CountryCode, District
FROM world.city
WHERE Name = 'Sydney'
FOR SHARE;
+-----+--------+-------------+-----------------+
| ID | Name | CountryCode | District |
+-----+--------+-------------+-----------------+
| 130 | Sydney | AUS | New South Wales |
+-----+--------+-------------+-----------------+
1 row in set (0.0034 sec)
-- Connection 2
Connection 2> SELECT index_name, lock_type,
lock_mode, COUNT(*)
FROM performance_schema.data_locks
WHERE object_schema = 'world'
AND object_name = 'city'
AND thread_id = 1143
GROUP BY index_name, lock_type, lock_mode;
+------------+-----------+-----------+----------+
| index_name | lock_type | lock_mode | COUNT(*) |
+------------+-----------+-----------+----------+
| NULL | TABLE | IS | 1 |
| PRIMARY | RECORD | S | 4103 |
+------------+-----------+-----------+----------+
2 rows in set (0.0323 sec)
-- Connection 1
Connection 1> ROLLBACK;
Query OK, 0 rows affected (0.0005 sec)
Listing 9-1Record locks without an index on the Name column
如果在Name
列上添加一个索引,锁计数将减少到总共三个记录锁,如清单 9-2 所示。
-- Connection Processlist ID Thread ID Event ID
-- --------------------------------------------------
-- 1 699 1145 6
-- 2 700 1146 6
-- Connection 1
Connection 1> ALTER TABLE world.city
ADD INDEX (Name);
Query OK, 0 rows affected (1.5063 sec)
Records: 0 Duplicates: 0 Warnings: 0
Connection 1> START TRANSACTION;
Query OK, 0 rows affected (0.0003 sec)
Connection 1> SELECT ID, Name, CountryCode, District
FROM world.city
WHERE Name = 'Sydney'
FOR SHARE;
+-----+--------+-------------+-----------------+
| ID | Name | CountryCode | District |
+-----+--------+-------------+-----------------+
| 130 | Sydney | AUS | New South Wales |
+-----+--------+-------------+-----------------+
1 row in set (0.0004 sec)
-- Connection 2
Connection 2> SELECT index_name, lock_type,
lock_mode, COUNT(*)
FROM performance_schema.data_locks
WHERE object_schema = 'world'
AND object_name = 'city'
AND thread_id = 1145
GROUP BY index_name, lock_type, lock_mode;
+------------+-----------+---------------+----------+
| index_name | lock_type | lock_mode | COUNT(*) |
+------------+-----------+---------------+----------+
| NULL | TABLE | IS | 1 |
| Name | RECORD | S | 1 |
| PRIMARY | RECORD | S,REC_NOT_GAP | 1 |
| Name | RECORD | S,GAP | 1 |
+------------+-----------+---------------+----------+
4 rows in set (0.0011 sec)
-- Connection 1
Connection 1> ROLLBACK;
Query OK, 0 rows affected (0.0004 sec)
Connection 1> ALTER TABLE world.city
DROP INDEX Name;
Query OK, 0 rows affected (0.3288 sec)
Records: 0 Duplicates: 0 Warnings: 0
Listing 9-2Record locks with an index on the Name column
另一方面,更多的索引提供了更多访问相同行的方法,这可能会增加死锁的数量。
记录访问顺序
确保您尽可能多地以相同的顺序访问不同事务的记录。在第 8 章讨论的死锁例子中,导致死锁的原因是两个事务以相反的顺序访问行。如果它们以相同的顺序访问这些行,就不会出现死锁。当您访问不同表中的记录时,这也适用。
确保相同的访问顺序绝非易事。当您执行连接并且优化器为两个查询决定不同的连接顺序时,甚至可能发生不同的访问顺序。如果不同的连接顺序导致过多的锁问题,您可以考虑使用优化器提示来告诉优化器更改连接顺序 1 ,但是在这种情况下,您当然也应该考虑查询性能。
事务隔离级别
InnoDB 支持几种事务隔离级别。不同的隔离级别有不同的锁需求:特别是REPEATABLE READ
和SERIALIZABLE
比READ COMMITTED
需要更多的锁。
READ COMMITTED
事务隔离级别可以从两个方面帮助解决锁定问题。使用的间隙锁要少得多,并且在 DML 语句期间被访问但未被修改的行在语句完成后会再次释放它们的锁。对于REPEATABLE READ
和SERIALIZABLE
,锁仅在事务结束时释放。
Note
人们常说READ COMMITTED
事务隔离级别不采用间隙锁。这是一个神话,是不正确的。虽然使用的间隙锁要少得多,但仍然需要一些。例如,这包括 InnoDB 检查外键和唯一键约束的情况,以及发生页面分割的情况。
考虑一个例子,其中使用CountryCode
列将查询限制在一个国家,名为 Sydney 的城市的人口发生了变化。这可以通过以下查询来完成:
START TRANSACTION;
UPDATE world.city
SET Population = 5000000
WHERE Name = 'Sydney'
AND CountryCode = 'AUS';
在Name
列上没有索引,但是在CountryCode
上有一个。因此,更新需要扫描部分CountryCode
索引。清单 9-3 展示了一个在REPEATABLE READ
事务隔离级别执行查询的例子。
-- Connection Processlist ID Thread ID Event ID
-- --------------------------------------------------
-- 1 701 1149 6
-- 2 702 1150 6
-- Connection 1
Connection 1> SET SESSION transaction_isolation = 'REPEATABLE-READ';
Query OK, 0 rows affected (0.2697 sec)
Connection 1> START TRANSACTION;
Query OK, 0 rows affected (0.0007 sec)
Connection 1> UPDATE world.city
SET Population = 5000000
WHERE Name = 'Sydney'
AND CountryCode = 'AUS';
Query OK, 1 row affected (0.0024 sec)
Rows matched: 1 Changed: 1 Warnings: 0
-- Connection 2
Connection 2> SELECT index_name, lock_type,
lock_mode, COUNT(*)
FROM performance_schema.data_locks
WHERE object_schema = 'world'
AND object_name = 'city'
AND thread_id = 1149
GROUP BY index_name, lock_type, lock_mode;
+-------------+-----------+---------------+----------+
| index_name | lock_type | lock_mode | COUNT(*) |
+-------------+-----------+---------------+----------+
| NULL | TABLE | IX | 1 |
| CountryCode | RECORD | X | 14 |
| PRIMARY | RECORD | X,REC_NOT_GAP | 14 |
| CountryCode | RECORD | X,GAP | 1 |
+-------------+-----------+---------------+----------+
4 rows in set (0.0102 sec)
-- Connection 1
Connection 1> ROLLBACK;
Query OK, 0 rows affected (0.0730 sec)
Connection 1> SET SESSION transaction_isolation = @@global.transaction_isolation;
Query OK, 0 rows affected (0.0004 sec)
Listing 9-3The locks held in the REPEATABLE READ transaction isolation level
在每个CountryCode
索引和主键上有 14 个记录锁,在CountryCode
索引上有一个间隙锁。将这与在清单 9-4 中所示的READ COMMITTED
事务隔离级别中执行查询后持有的锁进行比较。
-- Connection Processlist ID Thread ID Event ID
-- --------------------------------------------------
-- 1 703 1153 6
-- 2 704 1154 6
-- Connection 1
Connection 1> SET SESSION transaction_isolation = 'READ-COMMITTED';
Query OK, 0 rows affected (0.0003 sec)
Connection 1> START TRANSACTION;
Query OK, 0 rows affected (0.0002 sec)
Connection 1> UPDATE world.city
SET Population = 5000000
WHERE Name = 'Sydney'
AND CountryCode = 'AUS';
Query OK, 1 row affected (0.0014 sec)
Rows matched: 1 Changed: 1 Warnings: 0
-- Connection 2
Connection 2> SELECT index_name, lock_type,
lock_mode, COUNT(*)
FROM performance_schema.data_locks
WHERE object_schema = 'world'
AND object_name = 'city'
AND thread_id = 1153
GROUP BY index_name, lock_type, lock_mode;
+-------------+-----------+---------------+----------+
| index_name | lock_type | lock_mode | COUNT(*) |
+-------------+-----------+---------------+----------+
| NULL | TABLE | IX | 1 |
| CountryCode | RECORD | X,REC_NOT_GAP | 1 |
| PRIMARY | RECORD | X,REC_NOT_GAP | 1 |
+-------------+-----------+---------------+----------+
3 rows in set (0.0035 sec)
-- Connection 1
Connection 1> ROLLBACK;
Query OK, 0 rows affected (0.0780 sec)
Connection 1> SET SESSION transaction_isolation = @@global.transaction_isolation;
Query OK, 0 rows affected (0.0003 sec)
Listing 9-4The locks held in the READ-COMMITTED transaction isolation level
在这里,记录锁减少为每个索引和主键上的一个锁。没有间隙锁。
并非所有工作负载都可以使用READ COMMITTED
事务隔离级别。如果您必须让SELECT
语句在同一事务中多次执行时返回相同的结果,或者让不同的查询对应于同一时间点,那么您必须使用REPEATABLE READ
或SERIALIZABLE
。但是,在许多情况下,降低隔离级别是一个选项,您可以为不同的事务选择不同的隔离级别。如果您正在从 Oracle DB 迁移应用,那么您已经在使用READ COMMITTED
,并且您也可以在 MySQL 中使用它。
配置
直接影响锁定的配置选项并不多,但是熟悉那些确实存在的选项是有好处的,特别是因为有些选项会影响互斥和信号量争用的级别。本节介绍了将资源分成多个分区、禁用 InnoDB 自适应散列索引以及限制写锁的数量。
资源划分
互斥和信号量争用是由许多线程同时使用同一资源引起的。减少争用的一个简单而强大的方法是将一个资源分成多个部分,这正是对 InnoDB 缓冲池、InnoDB 自适应散列索引和表开放缓存所做的。表 9-1 显示了控制一个资源分成多少个实例的配置选项。
表 9-1
控制资源实例数量的配置选项
|[计]选项
|
缺省值
|
描述
|
| --- | --- | --- |
| innodb_adaptive_hash_index_parts
| eight | 自适应哈希索引的分区数。分区在索引之上。 |
| innodb_buffer_pool_instances
| 1 或 8 | 缓冲池分成多少部分。如果缓冲池的总大小小于 1gb,缺省值为 1,否则为 8,除非是在 32 位 Windows 系统上。 |
| table_open_cache_instances
| Sixteen | 表打开缓存的部分数。 |
Note
对于所有三个选项,它们都需要重启 MySQL 来更改值。
对于 InnoDB 缓冲池,默认的实例数量取决于平台和缓冲池的大小。如果总大小小于 1gb,默认值为 1,否则为 8。对于 32 位 Windows,缺省值为 1.3 GiB 以下的 1;否则,每个实例为 128 MiB。最大实例数为 64。
Note
你可能也听说过metadata_locks_hash_instances
选项。这在 MySQL 5.7 中被否决,在 MySQL 8.0.13 中被删除。这是因为元数据锁的实现发生了变化,使得该选项变得不必要。
如果一个资源的多个分区有助于减少争用,那么增加分区的数量似乎是显而易见的。但是,它比这更复杂,因为更多的分区也会引入开销,所以这是一个在减少资源闩锁争用上平衡这种开销的问题。在极端情况下,数据库只执行一个并发查询,通常每个资源只有一个分区会更好。“通常”是因为对于大型表开放缓存,当需要从缓存中逐出表时,多个分区有助于使最近最少使用(LRU)算法更有效。
一般来说,分区的数量不应该大于 CPU 核心的数量。也就是说,默认值是一个很好的起点,最后,您需要结合系统和工作负载进行测试,以验证最佳设置。对于自适应散列索引,您甚至需要完全禁用它。
禁用 InnoDB 自适应散列索引
自适应散列索引特性在 InnoDB 中自动工作。如果 InnoDB 检测到您正在频繁使用二级索引,并且启用了自适应散列索引,它将动态构建最常用值的散列索引。哈希索引以独占方式存储在缓冲池中,因此当您重新启动 MySQL 时不会持久化。如果 InnoDB 检测到内存可以更好地用于将更多页面加载到缓冲池中,它将丢弃部分散列索引。这就是所谓的自适应索引的含义:InnoDB 将努力使它适应您的查询。
理论上,自适应哈希索引是一个双赢的局面。您获得了拥有散列索引的优势,而无需考虑需要为哪些列添加它,并且内存使用都是自动处理的。但是,启用它会产生开销,而且并非所有工作负载都能从中受益。事实上,对于某些工作负载,开销可能会变得非常大,以至于出现严重的性能问题,在这种情况下,更改哈希分区的数量没有任何帮助。
工作数据集不适合缓冲池的部分越大,对辅助索引的更改就越多,用于过滤的辅助索引就越少,禁用自适应散列索引就越有可能使您受益。自适应散列索引是一个问题的情况通常通过互斥体上的大量等待和实现自适应散列索引搜索的btr0sea.cc
文件中的 rw 锁信号量表现出来。
如果您遇到自适应散列索引成为瓶颈,您可以使用innodb_adaptive_hash_index
选项启用或禁用该特性。请注意,虽然您可以动态地启用和禁用该特性,但是禁用自适应散列索引会从缓冲池中驱逐所有散列索引,并且在重新启用索引时需要一段预热时间。因此,在复制设置中,首先在一个副本中禁用自适应哈希索引是值得的,并在系统范围内禁用它之前,监控您的应用是否从更改中受益。如果您需要在读写复制副本上重新启用自适应哈希索引,请考虑故障切换到另一个仍启用该功能的复制副本,以便在重新启用的复制副本经历预热期间,应用受影响较小。
Tip
如果要禁用自适应哈希索引,请首先在单个复制副本上禁用,这样,当您需要重新启用该功能时,可以避免所有复制副本都经历预热期。
将讨论的最后一个配置选项允许您降低元数据写锁的优先级。
降低元数据写锁的优先级
默认情况下,如果一个表有两个元数据锁定请求,一个是读请求,另一个是写请求,那么写请求具有优先权。这通常没问题,因为写入比读取更具侵入性,所以在大多数情况下,最好给它们优先级,这样它们可以尽快完成。
然而,在外键的情况下,这种方法可能会遇到问题。当对具有外键的表执行 DDL 时,该语句请求父表上的共享元数据锁。如果您有针对父表持有写锁的持续事务,那么子表上的 DDL 语句将永远无法继续,即使子表从未被使用过。因此,您需要某种方法让 MySQL 停止运行,并允许读取元数据锁定请求继续进行。
您可以使用max_write_lock_count
选项来实现这一点,该选项的取值介于 1 和系统支持的最大整数之间。默认值是支持的最大值。每次max_write_lock_count
锁被授予后,MySQL 会优先考虑一些读锁。这有助于确保读锁请求不会被饿死。
更改max_write_lock_count
的值时需要小心,因为太低的值会导致带写锁的事务——记住它们是排他锁——需要太长时间才能完成。当写事务未完成时,它们的锁会阻止其他事务继续进行。因为您可以动态地更改max_write_lock_count
,所以请密切关注系统,并准备好恢复更改,如果它导致的副作用比治疗更糟糕的话。
抢先锁定
将讨论的最后一个策略是抢先锁定。如果您有一个执行多个查询的复杂事务,在某些情况下,执行一个SELECT ... FOR UPDATE
或SELECT ... FOR SHARE
查询来锁定您知道在事务中稍后会用到的记录可能是一种优势。另一个有用的情况是,确保对于不同的任务以相同的顺序访问行。
抢先锁定对于减少死锁的频率特别有效。一个缺点是,你最终会持有更长时间的锁。总的来说,抢占式锁定是一种应该谨慎使用的策略,但是在正确的情况下,它可以有效地防止死锁。
摘要
本章研究了减少锁的影响的策略,这些策略包括减少锁的数量和保持多长时间,以改变配置来减少锁的影响。
最重要的是,不要持有不必要的锁,也不要持有超过需要的时间。减少事务的大小和完成事务所需的时间是减少锁争用的两种最有效的方法。此外,通过适当地选择索引,可以减少给定语句所需的锁的数量。类似地,事务隔离级别会影响锁的数量及其持续时间,而READ COMMITTED
事务隔离级别是减少锁影响的常见选择。
对于死锁,在整个应用中尽可能以相同的顺序访问记录是很重要的。确保这一点的一个选择是先发制人的锁定,尽管这应该谨慎使用,因为它增加了持有锁的持续时间。
最后,更改配置以减少锁的影响。如果在缓冲池、自适应散列索引或表开放缓存上有互斥争用,可以对资源进行分区,或者对于自适应散列索引,可以完全禁用该特性。对于由于外键而请求元数据读锁的 DDL 语句,在读锁被赋予优先级之前,限制将被授予的写锁的数量也是有用的。
本章介绍了索引和外键对锁定的影响。下一章将更详细地讨论这些话题。
十、索引和外键
在前一章中,你学习了索引和外键如何影响锁定。这是一个值得深入探讨的话题,因为理解这些影响很重要。
本章的第一部分研究了主索引、次索引、升序索引、降序索引和唯一索引如何影响锁定。第二部分介绍外键以及它们如何影响 DML 和 DDL 语句的锁定。
索引
简而言之,索引提供了访问给定记录的捷径,从而减少了检查的记录数量。这对锁的数量有积极的影响,因为只有被访问的行会被锁定。这是您在上一章中看到的,当时一个索引被添加到了表world.city
的Name
列中,用于在Name
列上过滤的查询。当连接表时,索引变得特别重要,因为没有索引,被访问的行数是连接表中行数的乘积。
Note
由于 MySQL 8 中对散列连接的支持,您可能认为索引不那么重要了。虽然这在一定程度上适用于非锁定语句,但对于使用锁的语句来说,情况就不那么如此了,因为过度锁定会导致锁等待和死锁。正如在第 11 章中所讨论的,锁也会消耗缓冲池中的内存,所以更多的锁意味着用于缓存数据的内存更少。类似地,减少被访问的行数也会减少缓冲池中的页面周转,从而提高缓冲池命中率。
本节首先讨论主索引和辅助索引的使用,然后讨论升序索引和降序索引,最后讨论唯一索引。
主索引与辅助索引
访问行最有效的方法是通过它的主键,因为这样可以确保只访问受语句影响的行。例如,考虑清单 10-1 ,它在world.city
的Name
列上添加了一个二级索引,并更新了城市名称悉尼的人口。
-- Connection Processlist ID Thread ID Event ID
-- --------------------------------------------------
-- 1 713 1171 6
-- 2 714 1172 6
-- Connection 1
Connection 1> ALTER TABLE world.city
ADD INDEX (Name);
Query OK, 0 rows affected (1.3916 sec)
Records: 0 Duplicates: 0 Warnings: 0
Connection 1> START TRANSACTION;
Query OK, 0 rows affected (0.0002 sec)
Connection 1> UPDATE world.city
SET Population = 5000000
WHERE Name = 'Sydney';
Query OK, 1 row affected (0.0007 sec)
Rows matched: 1 Changed: 1 Warnings: 0
-- Connection 2
Connection 2> SELECT index_name, lock_type,
lock_mode, lock_data
FROM performance_schema.data_locks
WHERE object_schema = 'world'
AND object_name = 'city'
AND thread_id = 1171\G
*************************** 1\. row ***************************
index_name: NULL
lock_type: TABLE
lock_mode: IX
lock_data: NULL
*************************** 2\. row ***************************
index_name: Name
lock_type: RECORD
lock_mode: X
lock_data: 'Sydney ', 130
*************************** 3\. row ***************************
index_name: PRIMARY
lock_type: RECORD
lock_mode: X,REC_NOT_GAP
lock_data: 130
*************************** 4\. row ***************************
index_name: Name
lock_type: RECORD
lock_mode: X,GAP
lock_data: 'Syktyvkar ', 3660
4 rows in set (0.0006 sec)
-- Connection 1
Connection 1> ROLLBACK;
Query OK, 0 rows affected (0.0625 sec)
Connection 1> ALTER TABLE world.city
DROP INDEX Name;
Query OK, 0 rows affected (0.4090 sec)
Records: 0 Duplicates: 0 Warnings: 0)
Listing 10-1Updating row by non-unique secondary index
尽管更新只影响一行,但是有三个排他的记录级锁,一个是记录,另一个是索引Name
上的间隙锁,还有一个是使用主键的行记录锁。
另一方面,如果您执行相同的更新,但是使用主键访问行,那么只需要主键上的记录锁,如清单 10-2 所示。
-- Connection Processlist ID Thread ID Event ID
-- --------------------------------------------------
-- 1 719 1180 6
-- 2 720 1181 6
-- Connection 1
Connection 1> ALTER TABLE world.city
ADD INDEX (Name);
Query OK, 0 rows affected (1.1499 sec)
Records: 0 Duplicates: 0 Warnings: 0
Connection 1> SELECT ID
FROM world.city
WHERE Name = 'Sydney';
+-----+
| ID |
+-----+
| 130 |
+-----+
1 row in set (0.0004 sec)
Connection 1> START TRANSACTION;
Query OK, 0 rows affected (0.0002 sec)
Connection 1> UPDATE world.city
SET Population = 5000000
WHERE ID = 130;
Query OK, 1 row affected (0.0027 sec)
Rows matched: 1 Changed: 1 Warnings: 0
-- Connection 2
Connection 2> SELECT index_name, lock_type,
lock_mode, lock_data
FROM performance_schema.data_locks
WHERE object_schema = 'world'
AND object_name = 'city'
AND thread_id = 1180\G
*************************** 1\. row ***************************
index_name: NULL
lock_type: TABLE
lock_mode: IX
lock_data: NULL
*************************** 2\. row ***************************
index_name: PRIMARY
lock_type: RECORD
lock_mode: X,REC_NOT_GAP
lock_data: 130
2 rows in set (0.0007 sec)
-- Connection 1
Connection 1> ROLLBACK;
Query OK, 0 rows affected (0.0410 sec)
Connection 1> ALTER TABLE world.city
DROP INDEX Name;
Query OK, 0 rows affected (0.3257 sec)
Records: 0 Duplicates: 0 Warnings: 0
Listing 10-2Updating row by the primary index
在这种情况下,主键值(ID
列)首先在更新行的事务之外获得,然后主键值用于UPDATE
语句的WHERE
子句中。结果是持有的唯一记录级锁是值为 130 的主键上的独占锁。
Caution
在事务之外确定主键值确实有可能在获取主键值和执行更新之间改变数据。因此,您应该仅将此作为一个示例。
除非需要更改表中的所有行,否则应该使用索引来访问这些行。即使对于全表更新,如果不要求自动应用更改,那么对于大型表,在由主键上的范围定义的相对较小的批次中应用更改是有利的。
Tip
如果按函数过滤,减少检查行数的一个好方法是添加一个函数索引(在 MySQL 8.0.13 和更高版本中可用)。或者,在 MySQL 5.7 和更高版本中,您可以添加一个带有索引的生成列。
除了访问的行数之外,还有更多关于索引和锁定的内容。降序索引和随后的惟一索引也可以减少锁的数量。
升序与降序索引
MySQL 8 支持降序索引,这可以提高按降序访问行的性能。您可以使用升序索引来按降序访问行,因此在这种情况下使用降序索引的主要好处是不会在页面中来回跳转。这是否意味着在锁定时选择索引的顺序没有好处?
当您以与存储索引记录相反的顺序使用索引时,您将付出很小的代价,因为您需要在搜索开始时锁定间隙。清单 10-3 显示了在现有人口在 100 万到 200 万之间的三个人口最多的城市增加 10%的人口时,使用升序索引时持有的锁。
-- Connection Processlist ID Thread ID Event ID
-- --------------------------------------------------
-- 1 836 1363 6
-- Connection 1
Connection 1> ALTER TABLE world.city
ADD INDEX (Population);
Query OK, 0 rows affected (1.1838 sec)
Records: 0 Duplicates: 0 Warnings: 0
Connection 1> START TRANSACTION;
Query OK, 0 rows affected (0.0005 sec)
Connection 1> UPDATE world.city
SET Population = Population * 1.10
WHERE Population BETWEEN 1000000 AND 2000000
ORDER BY Population DESC
LIMIT 3;
Query OK, 3 rows affected (0.0014 sec)
Rows matched: 3 Changed: 3 Warnings: 0
-- Investigation #1
-- Connection 2
Connection 2> SELECT index_name, lock_type,
lock_mode, lock_data
FROM performance_schema.data_locks
WHERE object_schema = 'world'
AND object_name = 'city'
AND lock_type = 'RECORD'
AND thread_id = 1363
ORDER BY index_name, lock_data DESC;
+------------+-----------+---------------+---------------+
| index_name | lock_type | lock_mode | lock_data |
+------------+-----------+---------------+---------------+
| Population | RECORD | X,GAP | 2016131, 3018 |
| Population | RECORD | X | 1987996, 936 |
| Population | RECORD | X | 1977246, 2824 |
| Population | RECORD | X | 1975294, 3539 |
| PRIMARY | RECORD | X,REC_NOT_GAP | 936 |
| PRIMARY | RECORD | X,REC_NOT_GAP | 3539 |
| PRIMARY | RECORD | X,REC_NOT_GAP | 2824 |
+------------+-----------+---------------+---------------+
7 rows in set (0.0008 sec)
Listing 10-3Updating rows in descending order by ascending index
正如所料,主键和Population
索引上有三个锁,每个更新的行有一个锁。这是最理想的了。使用升序索引的锁的代价是,在人口为 2016131 且主键设置为 3018 的索引记录上还有一个间隙锁。
Tip
InnoDB 总是将主键附加到非唯一的二级索引的末尾,因此很容易从索引记录转到该行。这样做的原因是,InnoDB 根据聚集索引以及聚集索引使用的显式主键来组织行。
关于这个例子中的锁,还有两件事需要注意。首先,如果您从索引的末尾(人口最多的城市)进行更新,那么您将看到上确界伪记录上的记录锁,而不是间隙锁和区间的高人口末尾。这是因为上确界伪记录不是真正的记录,所以对它的记录锁定实际上只是对它之前的间隙的锁定。其次,所涉及的确切锁类型取决于WHERE
子句,因此如果您更改或删除WHERE
子句,您可能会在二级索引上看到额外的间隙锁。这些额外的间隙锁也将出现在使用降序索引的同一示例中。
如果使用降序索引,除了间隙锁之外,锁的列表是相同的。清单 10-4 展示了一个这样的例子。
-- Connection Processlist ID Thread ID Event ID
-- --------------------------------------------------
-- 1 843 1374 6
-- Connection 1
Connection 1> ALTER TABLE world.city
ADD INDEX (Population DESC);
Query OK, 0 rows affected (0.8885 sec)
Records: 0 Duplicates: 0 Warnings: 0
Connection 1> START TRANSACTION;
Query OK, 0 rows affected (0.0006 sec)
Connection 1> UPDATE world.city
SET Population = Population * 1.10
WHERE Population BETWEEN 1000000 AND 2000000
ORDER BY Population DESC
LIMIT 3;
Query OK, 3 rows affected (0.0021 sec)
Rows matched: 3 Changed: 3 Warnings: 0
-- Investigation #1
-- Connection 2
Connection 2> SELECT index_name, lock_type,
lock_mode, lock_data
FROM performance_schema.data_locks
WHERE object_schema = 'world'
AND object_name = 'city'
AND lock_type = 'RECORD'
AND thread_id = 1374
ORDER BY index_name, lock_data DESC;
+------------+-----------+---------------+---------------+
| index_name | lock_type | lock_mode | lock_data |
+------------+-----------+---------------+---------------+
| Population | RECORD | X | 1987996, 936 |
| Population | RECORD | X | 1977246, 2824 |
| Population | RECORD | X | 1975294, 3539 |
| PRIMARY | RECORD | X,REC_NOT_GAP | 936 |
| PRIMARY | RECORD | X,REC_NOT_GAP | 3539 |
| PRIMARY | RECORD | X,REC_NOT_GAP | 2824 |
+------------+-----------+---------------+---------------+
6 rows in set (0.0008 sec)
Listing 10-4Updating rows in descending order by descending index
结论是,对数据进行降序访问时,降序索引的主要好处是更有序的数据访问的性能提高,而不是减少锁定。然而,也就是说,如果在更新或删除数据时有许多递减范围的扫描,那么您也将受益于更少的间隙锁。
另一种减少锁定的索引类型是唯一索引。
唯一索引
与等效的非唯一索引相比,唯一索引的主要目的是添加每个值只允许出现一次的约束。因此,从表面上看,除了已经提到的以外,唯一索引似乎与锁的讨论没有什么关系。但是,InnoDB 知道对于给定的权益条件,最多只能存在一条记录(除了值为NULL
的记录),因此可以利用这一点来减少所需的锁定数量。
例如,考虑两个表,_tmp_city1
和_tmp_city2
,包含来自world.city
表的相同行子集。_tmp_city1
表在Name
列上有一个非唯一索引,而_tmp_city2
表在该列上有一个唯一索引。然后使用Name
列上的条件更新一行。清单 10-5 显示了这一点。
-- Connection Processlist ID Thread ID Event ID
-- --------------------------------------------------
-- 1 736 1209 6
-- 2 737 1210 6
-- Connection 1
Connection 1> DROP TABLE IF EXISTS world._tmp_city1;
Query OK, 0 rows affected (0.0643 sec)
Note (code 1051): Unknown table 'world._tmp_city1'
Connection 1> CREATE TABLE world._tmp_city1
SELECT *
FROM world.city
WHERE CountryCode = 'AUS';
Query OK, 14 rows affected (1.3112 sec)
Records: 14 Duplicates: 0 Warnings: 0
Connection 1> ALTER TABLE world._tmp_city1
ADD PRIMARY KEY (ID),
ADD INDEX (Name);
Query OK, 0 rows affected (2.5572 sec)
Records: 0 Duplicates: 0 Warnings: 0
Connection 1> START TRANSACTION;
Query OK, 0 rows affected (0.0002 sec)
Connection 1> UPDATE world._tmp_city1
SET Population = 5000000
WHERE Name = 'Sydney';
Query OK, 1 row affected (0.0007 sec)
Rows matched: 1 Changed: 1 Warnings: 0
-- Connection 2
Connection 2> DROP TABLE IF EXISTS world._tmp_city2;
Query OK, 0 rows affected (0.1361 sec)
Note (code 1051): Unknown table 'world._tmp_city2'
Connection 2> CREATE TABLE world._tmp_city2
SELECT *
FROM world.city
WHERE CountryCode = 'AUS';
Query OK, 14 rows affected (0.8276 sec)
Records: 14 Duplicates: 0 Warnings: 0
Connection 2> ALTER TABLE world._tmp_city2
ADD PRIMARY KEY (ID),
ADD UNIQUE INDEX (Name);
Query OK, 0 rows affected (2.4895 sec)
Records: 0 Duplicates: 0 Warnings: 0
Connection 2> START TRANSACTION;
Query OK, 0 rows affected (0.0003 sec)
Connection 2> UPDATE world._tmp_city2
SET Population = 5000000
WHERE Name = 'Sydney';
Query OK, 1 row affected (0.0005 sec)
Rows matched: 1 Changed: 1 Warnings: 0
Listing 10-5The difference between non-unique and unique secondary indexes
当事务仍在进行时,您可以检查每个连接持有的锁。对于连接 1,清单 10-6 中显示了记录锁,这是清单 10-5 中执行的工作负载的调查号 1。
-- Investigation #1
-- Connection 3
Connection 3> SELECT index_name, lock_mode, lock_data
b FROM performance_schema.data_locks
WHERE object_schema = 'world'
AND lock_type = 'RECORD'
AND thread_id = 1209\G
*************************** 1\. row ***************************
index_name: Name
lock_mode: X
lock_data: 'Sydney ', 130
*************************** 2\. row ***************************
index_name: PRIMARY
lock_mode: X,REC_NOT_GAP
lock_data: 130
*************************** 3\. row ***************************
index_name: Name
lock_mode: X,GAP
lock_data: 'Townsville ', 142
3 rows in set (0.0094 sec)
Listing 10-6The record locks for Connection 1 (investigation number 1)
根据前面示例的经验,主键上的记录锁以及索引上的记录和间隙锁都是这种情况。对于连接 2,只有两个锁存在,如清单 10-5 中调查编号 2 的清单 10-7 中的输出所示。
-- Investigation #2
Connection 3> SELECT index_name, lock_mode, lock_data
FROM performance_schema.data_locks
WHERE object_schema = 'world'
AND lock_type = 'RECORD'
AND thread_id = 1210\G
*************************** 1\. row ***************************
index_name: Name
lock_mode: X,REC_NOT_GAP
lock_data: 'Sydney ', 130
*************************** 2\. row ***************************
index_name: PRIMARY
lock_mode: X,REC_NOT_GAP
lock_data: 130
2 rows in set (0.0006 sec)
Listing 10-7The record locks for Connection 2 (investigation number 2)
这里只需要二级索引和主键上的两个记录锁。
Tip
关于 InnoDB 中有无唯一索引的各种语句所使用的锁的完整列表,请参见 https://dev.mysql.com/doc/refman/en/innodb-locks-set.html
。
最后,回滚并删除测试表:
-- Connection 1
Connection 1> ROLLBACK;
Query OK, 0 rows affected (0.0714 sec)
Connection 1> DROP TABLE world._tmp_city1;
Query OK, 0 rows affected (0.7642 sec)
-- Connection 2
Connection 2> ROLLBACK;
Query OK, 0 rows affected (0.1038 sec)
Connection 2> DROP TABLE world._tmp_city2;
Query OK, 0 rows affected (1.4438 sec)
与可用于减少锁定的唯一键不同,外键将添加锁,这将在下面讨论。
外键
外键是确保数据库中各表之间数据一致性的强大工具。然而,它们有一个缺点,即为了提供这种安全性,需要在父表和子表上附加锁。
所需的额外锁取决于外键关系的父表还是子表被更改,以及哪些列被更改。对于元数据锁,共享锁在大多数情况下是在外键关系所涉及的表上获得的。向外键关系的父表中插入时是一个例外;在这种情况下,不会对子表使用共享元数据锁。
在 InnoDB 级别,如果包含在外键中的列被修改,则在与外键列的新值的关系的另一端的表上设置一个共享记录级锁,并为该表设置一个意向共享锁。无论是否违反了外键,都会发生这种情况。如果没有外键列被更改,即使该列用于过滤,InnoDB 也不会获取任何额外的锁。
为了理解这对你的影响,有必要考虑几个例子。它们都使用sakila.inventory
表,该表有两个外键指向film
和store
表。同时,它还是来自film_rental
表的外键的父表。如图 10-1 所示。
图 10-1
sakila.inventory
表及其外键关系
在该图中,只包括主键和外键中涉及的列。首先讨论一个更新库存表中的行的例子,然后讨论一个 DDL 语句。
DML 语句
作为由 DML 语句的外键引起的锁的一个例子,考虑一个将电影从一个商店移动到另一个商店的UPDATE
语句。更新是通过主键进行的,清单 10-8 中显示了一个这样的例子。
-- Connection Processlist ID Thread ID Event ID
-- --------------------------------------------------
-- 1 814 1329 6
-- Connection 1
Connection 1> START TRANSACTION;
Query OK, 0 rows affected (0.0002 sec)
Connection 1> UPDATE sakila.inventory
SET store_id = 1
WHERE inventory_id = 4090;
Query OK, 1 row affected (0.0008 sec)
Rows matched: 1 Changed: 1 Warnings: 0
Listing 10-8Updating a row in a table with foreign keys relationships
由于外键的原因,这个简单的单个表和行更新需要跨越sakila
数据库中大量表的许多锁。清单 10-9 显示了由语句引起的 InnoDB 锁(这是调查编号 1)。
-- Investigation #1
-- Connection 2
Connection 2> SELECT object_schema, object_name, lock_type,
index_name, lock_mode, lock_data
FROM performance_schema.data_locks
WHERE thread_id = 1329\G
*************************** 1\. row ***************************
object_schema: sakila
object_name: inventory
lock_type: TABLE
index_name: NULL
lock_mode: IX
lock_data: NULL
*************************** 2\. row ***************************
object_schema: sakila
object_name: inventory
lock_type: RECORD
index_name: PRIMARY
lock_mode: X,REC_NOT_GAP
lock_data: 4090
*************************** 3\. row ***************************
object_schema: sakila
object_name: store
lock_type: TABLE
index_name: NULL
lock_mode: IS
lock_data: NULL
*************************** 4\. row ***************************
object_schema: sakila
object_name: store
lock_type: RECORD
index_name: PRIMARY
lock_mode: S,REC_NOT_GAP
lock_data: 1
4 rows in set (0.0102 sec)
Listing 10-9The InnoDB data locks caused by the UPDATE statement
在这种情况下,InnoDB 在store
表上获取一个意向共享锁,并在主键设置为 1 的记录上获取一个共享锁。因为UPDATE
语句不会改变film_id
列的值,所以film
表上没有锁。
对于元数据锁,从清单 10-10 (调查编号 2)中可以看出,它更加复杂。
-- Investigation #2
Connection 2> SELECT object_type, object_schema, object_name,
column_name, lock_type, lock_duration
FROM performance_schema.metadata_locks
WHERE owner_thread_id = 1329
ORDER BY object_type, object_schema, object_name,
column_name, lock_type\G
*************************** 1\. row ***************************
object_type: SCHEMA
object_schema: sakila
object_name: NULL
column_name: NULL
lock_type: INTENTION_EXCLUSIVE
lock_duration: TRANSACTION
*************************** 2\. row ***************************
object_type: TABLE
object_schema: sakila
object_name: customer
column_name: NULL
lock_type: SHARED_READ
lock_duration: TRANSACTION
*************************** 3\. row ***************************
object_type: TABLE
object_schema: sakila
object_name: film
column_name: NULL
lock_type: SHARED_READ
lock_duration: TRANSACTION
*************************** 4\. row ***************************
object_type: TABLE
object_schema: sakila
object_name: inventory
column_name: NULL
lock_type: SHARED_WRITE
lock_duration: TRANSACTION
*************************** 5\. row ***************************
object_type: TABLE
object_schema: sakila
object_name: payment
column_name: NULL
lock_type: SHARED_WRITE
lock_duration: TRANSACTION
*************************** 6\. row ***************************
object_type: TABLE
object_schema: sakila
object_name: rental
column_name: NULL
lock_type: SHARED_WRITE
lock_duration: TRANSACTION
*************************** 7\. row ***************************
object_type: TABLE
object_schema: sakila
object_name: staff
column_name: NULL
lock_type: SHARED_READ
lock_duration: TRANSACTION
*************************** 8\. row ***************************
object_type: TABLE
object_schema: sakila
object_name: store
column_name: NULL
lock_type: SHARED_READ
lock_duration: TRANSACTION
8 rows in set (0.0007 sec)
Listing 10-10The metadata locks caused by the UPDATE statement
您不会总是在sakila
模式上看到INTENTION_EXCLUSIVE
锁,所以您的结果可能只包括七个表级元数据锁。
这表明在film
和store
表上有一个SHARED_READ
锁,在rental
表上有一个SHARED_WRITE
,这是讨论到目前为止所预期的。但是,还有几个元数据锁。额外的锁是因为rental
表的库存外键是ON UPDATE CASCADE
。这使得元数据锁级联到rental
表的外键关系。这个例子告诉我们,对于外键,尤其是级联关系,需要注意元数据锁的数量会迅速增加。
最后,回滚事务:
-- Connection 1
Connection 1> ROLLBACK;
Query OK, 0 rows affected (0.1104 sec)
DDL 语句
当您对一个带有外键的表执行 DDL 语句时,会为修改后的表的每个父表和子表获取一个SHARED_UPGRADABLE
元数据锁。清单 10-11 中显示了一个这样的例子。
-- Connection Processlist ID Thread ID Event ID
-- --------------------------------------------------
-- 1 820 1340 6
-- 2 821 1341 6
-- Connection 1
Connection 1> OPTIMIZE TABLE sakila.inventory;
-- Connection 2
Connection 2> SELECT object_name, lock_type, lock_duration
FROM performance_schema.metadata_locks
WHERE owner_thread_id = 1340
AND object_type = 'TABLE';
+---------------+-------------------+---------------+
| object_name | lock_type | lock_duration |
+---------------+-------------------+---------------+
| inventory | SHARED_NO_WRITE | TRANSACTION |
| film | SHARED_UPGRADABLE | STATEMENT |
| rental | SHARED_UPGRADABLE | STATEMENT |
| store | SHARED_UPGRADABLE | STATEMENT |
| #sql-8490_334 | EXCLUSIVE | STATEMENT |
+---------------+-------------------+---------------+
5 rows in set (0.0014 sec)
Listing 10-11Performing DDL on a table with foreign key relations
在这种情况下,rental
表上的级联外键不会导致进一步的元数据锁定,因为没有要级联的更新。#sql-8490_334
表是OPTIMIZE TABLE
语句的构建表,其名称取决于mysqld
进程的 id 和执行该语句的连接的进程列表 id。
结论是,虽然外键对于确保数据一致性非常重要,但在高并发性工作负载中,由于额外的锁定(以及在约束验证上花费的时间),外键可能会成为瓶颈。但是,不要在默认情况下忽略外键,因为这会危及数据的完整性,而且它们对于记录表之间的关系也很有用;本章前面的图是 MySQL Workbench 根据外键自动生成的。
Caution
不要因为额外的锁定而忽略外键,因为它们是确保数据一致性所必需的。如果它们对您的工作负载来说过于昂贵,您将需要确保您的应用中的数据一致性,这绝不是一项微不足道的任务。如果没有足够好的数据完整性约束,最终可能会向用户返回无效数据。
摘要
本章深入探讨了索引和外键对锁定的影响。索引有助于减少锁定,而外键增加了锁定。
使用的索引越有选择性,访问的行数就越少,也就是说锁定越少。因此,主键访问是最佳的,其次是唯一索引。使用与访问行顺序相同的索引进行访问也会有所帮助。
对于外键,它们需要额外的锁来维护数据完整性。当修改外键中包含的列时,InnoDB 会在外键另一端的行上添加一个共享锁,并为表设置一个意向共享锁。此外,在大多数情况下,外键关系中涉及的表上都采用共享元数据锁。对于级联外键,元数据锁也会级联。
讨论到此结束,主要焦点是锁。不过,硬币还有另外一面,事务。事务不仅与并发直接相关,而且与锁相关。下一章从一般的事务开始。
十一、事务
事务是报表的老大哥。它们将多个更改组合在一起,无论是在单个语句中还是在几个语句中,因此它们作为一个单元被应用或放弃。大多数情况下,事务只是事后的想法,只是在需要将几个语句一起应用时才考虑。这不是考虑事务的好方法。它们对于确保数据完整性非常重要,如果使用不当,会导致严重的性能问题。
本章首先讨论什么是事务和 ACID 概念,然后通过回顾事务对锁和性能的影响,继续讨论为什么需要从性能的角度认真对待事务。最后,讨论了组提交特性如何提高高并发系统的性能。
事务和 ACID
从最简单的角度来看,事务是一个包含一个或多个语句的容器。然而,这是一种过于简单化的观点,因为事务也有其自身的属性。最重要的是,它是实现原子性、一致性、隔离性和持久性 ( 酸)的主要工具。本节将详细介绍这四个属性。
原子数
原子性的概念意味着事务中的所有更改要么被提交,要么被回滚。这就是容器的概念发挥作用的地方,因此所有语句都被视为一个工作单元——或者换句话说,一个事务是原子的。
原子性为什么重要的经典例子是两个银行账户之间的金融事务。从付款人的账户中提取一笔金额,并将其插入收款人的账户。如果没有原子性,你可能最终会把钱取出来,但永远不会插入,从而让双方中的一方赔钱。事务是原子保证,如果钱被提取,接收者也收到他们账户上的钱。
一致性
数据库满足一致性属性意味着有适当的检查来确保如果事务成功提交,那么数据是一致的。一致性的含义很大程度上是由业务逻辑定义的。例如,您不能为不存在的实体创建银行帐户。在数据库内部,约束(包括外键)的工作是确保数据的一致性。通过使用事务,由于其原子行为,即使约束在第二步或后面的步骤中失败,也可以恢复(回滚)整个事务,并且数据库保持其原始和一致的状态。
在某些数据库中,约束检查可以推迟到提交事务时进行。如果您考虑这样一个例子,不允许您拥有一个不存在的实体的银行帐户,延迟约束允许您首先创建帐户,然后在同一事务中注册拥有该帐户的实体。延迟约束主要用于循环关系,如添加必须具有默认组成员的组,但由于成员必须属于某个组,因此在添加组及其初始成员时,有必要暂时放松约束。
Note
延迟约束的使用是一个有争议的话题,可以认为它违反了关系数据库理论的原则。例如,C.J. Date 认为约束必须在语句边界得到满足; https://www.brcommunity.com/articles.php?id=b065b
见。
InnoDB 不支持延迟约束,但支持通过显式禁用foreign_key_checks
变量来禁用外键检查,该变量可在全局和会话范围内更改。
Tip
在已经确保数据一致的情况下执行大容量装载时,禁用外键检查会很有用。
InnoDB 不能禁用唯一键约束检查,因为unique_checks
选项仅指示不需要检查;InnoDB 在某些情况下仍然会这样做。(顺便说一下,NDBCluster
确实将一些约束检查推迟到提交时。)
隔离
隔离属性是连接事务和锁定的纽带。两个事务是独立的,这意味着它们不会干扰彼此的数据视图。隔离是在数据内容级别;两个并发事务在性能和锁定方面仍然可能相互干扰。与大多数数据库系统一样,MySQL 使用锁来实现隔离,InnoDB 有事务隔离级别的概念来定义隔离的含义。下一章将更详细地讨论事务隔离级别。
持久性
数据是持久的意味着数据更改不会丢失。在 MySQL 中,这仅适用于提交的数据或准备阶段事务的 XA 事务。InnoDB 通过重做日志和二进制日志(如果启用,这是 MySQL 8 中的默认设置)在本地级别实现持久性,并使用内部 XA 事务来确保两个日志之间的一致性。
Note
XA 事务是一个允许分布式事务的特性,例如,跨两个系统或在 MySQL 内部对 InnoDB 和二进制日志一起提交或回滚进行更改。它通过一个事务管理器和一个或多个资源管理器(例如,一个数据库)来工作。有关 MySQL 和 XA 事务的更多信息,请参见 https://dev.mysql.com/doc/refman/en/xa.html
。
只有当innodb_flush_log_at_trx_commit
和sync_binlog
都设置为 1(默认值)时,提交才保证是持久的。为了确保本地系统崩溃时的持久性,您还必须确保二进制日志事件已经复制到至少一个副本。MySQL 组复制或 MySQL InnoDB 集群是实现这一点的最佳方式。
Tip
复制超出了本书的范围。关于 MySQL 组复制和 InnoDB 集群的介绍,参见,例如,查尔斯·贝尔(Apress)的介绍 InnoDB 集群(https://www.apress.com/gp/book/9781484238844
)。
事务的影响
如果您将事务视为用于分组查询的容器,那么事务可能看起来是一个简单的概念。然而,重要的是要理解,因为事务为查询组提供原子性,所以事务活动的时间越长,与查询相关联的资源被占用的时间就越长,并且事务中完成的工作越多,需要的资源就越多。提交事务之前一直在使用的查询使用了哪些资源?主要的两个是锁和撤销日志。
Tip
InnoDB 支持比读写事务开销更低的只读事务。对于自动提交的单语句事务,InnoDB 将尝试自动确定该语句是否是只读的。对于多语句事务,可以在启动时明确指定它是只读事务:START TRANSACTION READ ONLY;
锁
当查询执行时,它获取锁,并且当您使用默认的事务隔离级别–REPEATABLE READ
时,所有锁都被保留,直到事务被提交或回滚。当您使用READ COMMITTED
事务隔离级别时,一些锁可能会被释放,但至少那些涉及已更改记录的锁会被保留。锁本身就是一种资源,但是它也需要内存来存储关于锁的信息。对于正常的工作负载来说,您可能不认为这有什么了不起,但是巨大的事务最终会使用如此多的内存,以至于事务失败,并出现ER_LOCK_TABLE_FULL
错误:
ERROR: 1206: The total number of locks exceeds the lock table size
从错误日志中记录的警告消息可以看出(更简短地说),锁所需的内存来自缓冲池。因此,持有的锁越多、时间越长,可用于缓存数据和索引的内存就越少。
Caution
因为使用了所有的锁内存而中止一个事务是四重打击。首先,更新足够多的行以使用足够多的锁内存来触发错误需要一些时间。那项工作被浪费了。第二,由于所需更改的数量,回滚事务可能需要很长时间。第三,当锁内存被使用时,InnoDB 实际上处于只读模式(一些小的事务是可能的),并且直到回滚完成后锁内存才被释放。第四,缓冲池中几乎没有空间来缓存数据和索引。
该错误之前,错误日志中有一条警告,指出超过 67%的缓冲池用于锁或自适应哈希索引:
2020-06-08T10:47:11.415127Z 10 [Warning] [MY-011958] [InnoDB] Over 67 percent of the buffer pool is occupied by lock heaps or the adaptive hash index! Check that your transactions do not set too many row locks. Your buffer pool size is 7 MB. Maybe you should make the buffer pool bigger? Starting the InnoDB Monitor to print diagnostics, including lock heap and hash index sizes.
该警告之后是 InnoDB monitor 的定期重复输出,因此您可以确定哪些事务是罪魁祸首。
一种在事务中经常被忽略的锁类型是元数据锁。当一个语句查询一个表时,会获取一个共享的元数据锁,并且该元数据锁会一直保持到事务结束。当一个表上有一个元数据锁时,任何连接都不能对该表执行任何 DDL 语句——包括OPTIMIZE TABLE
。如果一个 DDL 语句被一个长时间运行的事务阻塞,它将依次阻塞所有使用该表的新查询。第 14 章将展示一个调查此类问题的例子。
当事务处于活动状态时,锁被持有。但是,即使事务已经通过撤消日志完成,它仍然会产生影响。
撤消日志
如果您选择回滚事务,则还必须根据需要存储事务期间所做的更改。这很容易理解。更令人惊讶的是,即使一个事务没有进行任何更改,也会使来自其他事务的撤销信息保留下来。当事务需要读视图(一致快照)时会发生这种情况,当使用REPEATABLE READ
事务隔离级别时,在事务持续期间就是这种情况。读取视图意味着无论其他事务是否更改数据,事务都将返回与事务开始时间相对应的行数据。为了能够实现这一点,有必要保留在事务生命周期中发生变化的行的旧值。具有读视图的长时间运行的事务是导致大量撤销日志的最常见原因,在 MySQL 5.7 和更早的版本中,这可能意味着 ibdata1 文件变得很大。(在 MySQL 8 中,撤消日志总是存储在单独的可以被截断的撤消表空间中。)
Tip
READ COMMITTED
事务隔离级别不太容易出现大的撤销日志,因为读取视图只在语句持续期间维护。
撤消日志的活动部分的大小在历史列表长度中测量。历史列表长度是尚未清除撤消日志的已提交事务的数量。这意味着您不能使用历史列表长度来衡量行更改的总量。它告诉您的是在执行查询时必须考虑的变更链表中有多少个旧行单元(每个事务一个单元)。这个链表越长,找到每一行的正确版本的代价就越大。最后,如果您有一个很大的历史列表,它会严重影响所有查询的性能。
Note
历史列表长度的问题是使用逻辑备份工具创建大型数据库备份的最大问题之一,例如使用单个事务获得一致备份的mysqlpump
和mysqldump
。如果在备份过程中提交了许多事务,备份可能会导致历史列表变得非常长。
什么构成了一个大的历史列表长度?这方面没有严格的规则,只是越小越好。通常,当列表有几千到一百万个事务时,性能问题就开始出现了,但是当历史列表很长时,它成为瓶颈的点取决于撤消日志中提交的事务和工作负载。
当不再需要最旧的部件时,InnoDB 会在后台自动清除历史列表。有两个选项可以控制清洗,也有两个选项可以影响清洗无法进行时会发生什么。这些选项包括
-
innodb_purge_batch_size
: 每批清除的撤消日志页数。该批次在清除线程之间划分。该选项不应在生产系统上更改。默认值为 300,有效值介于 1 和 5000 之间。 -
innodb_purge_threads
: 并行使用的清除线程数。如果数据更改跨越多个表,那么更高的并行度会很有用。另一方面,如果所有更改都集中在少数几个表上,则首选低值。更改清除线程的数量需要重启 MySQL。默认值为 4,有效值介于 1 和 32 之间。 -
innodb_max_purge_lag
: 当历史列表长度大于innodb_max_purge_lag
的值时,会给更改数据的操作增加一个延迟,以降低历史列表的增长速度,但代价是语句延迟增加。默认值为 0,这意味着永远不会添加延迟。有效值为 0–4294967295。 -
innodb_max_purge_lag_delay
: 当历史列表长度大于innodb_max_purge_lag
时,可以添加到 DML 查询的最大延迟。
通常没有必要更改这些设置;但是,在特殊情况下,它可能是有用的。如果清除线程跟不上,您可以尝试根据被修改的表的数量来更改清除线程的数量;修改的表越多,清除线程就越有用。当您更改清除线程的数量时,从更改前的基线开始监控效果非常重要,这样您就可以看到更改是否带来了改进。
最大清除延迟选项可用于降低修改数据的 DML 语句的速度。当写入仅限于特定的连接,并且延迟不会导致创建额外的写入线程以保持相同的吞吐量时,此功能非常有用。
群组提交
请记住,为了使 InnoDB 中的 D in ACID(耐久性)为真,您需要将innodb_flush_log_at_trx_commit
和sync_binlog
设置保持为默认值 1,以便事务所做的更改作为提交的一部分被同步到磁盘。虽然这对于确保您在崩溃时不会丢失已确认提交的更改非常有用,但从成本效益的角度来看,这确实是有代价的,因为磁盘的刷新性能常常会成为瓶颈。
存在组提交功能是为了减少这种性能影响,方法是稍微延迟提交,并对延迟期间发生的所有提交(即名称)进行分组,同时将它们刷新到磁盘。本质上,组提交牺牲了一点延迟来获得更高的吞吐量。在数据更改具有高并发性的系统中,当使用sync_binlog = 1
时,组提交可以极大地提高性能。
使用两个配置选项来控制组提交功能:
-
binlog_group_commit_sync_delay
: 等待更多事务提交的延迟时间(毫秒)。允许的值为 0–1000000,默认值为 0。值越大,一起提交的事务就越多,因此fsync()
调用就越少。 -
binlog_group_commit_sync_no_delay_count
: 在完成组提交之前,组提交队列中允许的最大事务数。如果该选项设置为大于 0 的值,提交的次数可能会比binlog_group_commit_sync_delay
设置的次数更多。值为 0 意味着可以对无限数量的提交进行排队。允许的值为 0–1000000,默认值为 0。
如果您可以接受提交事务时的小延迟,建议增加binlog_group_commit_sync_delay
以降低刷新速率。原则上,该值越大,吞吐量就越大,但是您当然应该考虑您的工作负载的最大可接受的提交延迟增加量。您可以使用binlog_group_commit_sync_no_delay_count
来避免每个组提交中的事务数量变得过大。
如果您启用了复制,那么增加binlog_group_commit_sync_delay
也会对副本产生积极的性能影响,因为一起提交的事务越多,用于并行复制的LOGICAL_CLOCK
算法(slave_parallel_type
选项)就变得越有效。(如果有binlog_transaction_dependency_tracking = WRITESET
,效果会小一些,因为事务可以跨组提交并行化。)您必须在复制源上设置binlog_group_commit_sync_delay
,以提高副本上的并行复制性能。
摘要
事务是数据库中的一个重要概念。它们有助于确保您可以将更改作为一个单元应用到几行,并且可以选择是应用更改还是回滚更改。
本章开始讨论什么是事务和 ACID 概念。ACID 代表原子性、一致性、隔离性和持久性,事务直接参与确保前三个属性,部分代表持久性。在并发环境中,隔离很有意思,因为它可以确保您可以安全地并发执行多个事务。在 MySQL 中,隔离是通过锁定实现的。
下一节将讨论为什么了解事务是如何被使用的很重要。虽然它们本身可以被认为是更改的容器,但锁会一直保持到事务被提交或回滚,并且它们可以阻止撤消日志被清除。锁和大量撤消日志都会影响查询的性能,即使它们不是在导致大量锁或大量撤消日志的事务中执行的。锁使用来自缓冲池的内存,因此可用于缓存数据和索引的内存较少。根据历史列表长度来衡量,大量的撤销日志意味着在 InnoDB 执行语句时必须考虑更多的行版本。
最后,讨论了组提交的概念。当在每次提交时将更改刷新到磁盘时,可以使用组提交特性通过一起完成几次提交来减少fsync()
调用的数量。组复制的一个好的副作用是它可以提高并行复制的LOGICAL_CLOCK
算法的性能。
在本章中,我们多次提到了事务隔离级别的概念。下一章将更详细地介绍四个受支持的事务隔离级别是如何工作的,以及每个级别如何影响锁定。
十二、事务隔离级别
在前一章中,你学习了隔离是事务的一个重要属性。事实证明,要回答两个事务是否被隔离并不那么简单,因为答案取决于所需的隔离程度。隔离程度是通过事务隔离级别定义的。
InnoDB 支持 SQL:1992 标准 1 定义的四个事务隔离级别,它们的隔离程度依次为:SERIALIZABLE
、REPEATABLE READ
、READ COMMITTED
和READ UNCOMMITTED
。可重复读取事务隔离级别是默认的。本章将逐一介绍这些隔离级别,并讨论它们是如何工作的以及它们对锁定的影响。
为了比较在不同的事务隔离级别中更新行时使用的锁,将使用一个更新斯洛伐克布拉迪斯拉发区城市的示例。在斯洛伐克的world.city
表格中有三个城市:
mysql> SELECT ID, Name, District
FROM world.city
WHERE CountryCode = 'SVK';
+------+------------+--------------------+
| ID | Name | District |
+------+------------+--------------------+
| 3209 | Bratislava | Bratislava |
| 3210 | Košice | Východné Slovensko |
| 3211 | Prešov | Východné Slovensko |
+------+------------+--------------------+
3 rows in set (0.0032 sec)
UPDATE
语句可以使用CountryCode
上的索引将搜索范围缩小到三个城市,然后使用District
上的非索引过滤器来查找与该地区匹配的城市。这将有助于暴露SERIALIZABLE
、REPEATABLE READ
和READ COMMITTED
事务隔离级别的不同数量的锁。另外,对于SERIALIZABLE
和REPEATBLE READ
,将使用使用SELECT
语句的测试。
在每个示例之后,您需要回滚事务以将数据库返回到其原始状态:
-- Connection 1
Connection 1> ROLLBACK;
Query OK, 0 rows affected (0.3022 sec)
也就是说,您已经准备好探索四个事务隔离级别了。
可序列化
SERIALIZABLE
隔离级别是最严格的。除了启用了autocommit
的SELECT
语句(并且没有启动显式事务)之外,所有语句都使用锁。对于SELECT
报表,这相当于添加了FOR SHARE
。这确保了事务的所有方面都是可重复的,但也意味着事务隔离级别占用了大多数锁。清单 12-1 展示了一个由SELECT
语句获取的锁的例子。
-- Connection Processlist ID Thread ID Event ID
-- --------------------------------------------------
-- 1 967 1560 6
-- 2 968 1561 6
-- Connection 1
Connection 1> SET transaction_isolation = 'SERIALIZABLE';
Query OK, 0 rows affected (0.0007 sec)
Connection 1> START TRANSACTION;
Query OK, 0 rows affected (0.0003 sec)
Connection 1> SELECT ID, Name, Population
FROM world.city
WHERE CountryCode = 'SVK'
AND District = 'Bratislava';
+------+------------+------------+
| ID | Name | Population |
+------+------------+------------+
| 3209 | Bratislava | 448292 |
+------+------------+------------+
1 row in set (0.0006 sec)
-- Connection 2
Connection 2> SELECT index_name, lock_type,
lock_mode, lock_data
FROM performance_schema.data_locks
WHERE object_schema = 'world'
AND object_name = 'city'
AND lock_type = 'RECORD'
AND thread_id = 1560
ORDER BY index_name, lock_data DESC;
+-------------+-----------+---------------+-------------+
| index_name | lock_type | lock_mode | lock_data |
+-------------+-----------+---------------+-------------+
| CountryCode | RECORD | S,GAP | 'SVN', 3212 |
| CountryCode | RECORD | S | 'SVK', 3211 |
| CountryCode | RECORD | S | 'SVK', 3210 |
| CountryCode | RECORD | S | 'SVK', 3209 |
| PRIMARY | RECORD | S,REC_NOT_GAP | 3211 |
| PRIMARY | RECORD | S,REC_NOT_GAP | 3210 |
| PRIMARY | RECORD | S,REC_NOT_GAP | 3209 |
+-------------+-----------+---------------+-------------+
7 rows in set (0.0009 sec)
-- Connection 1
Connection 1> ROLLBACK;
Query OK, 0 rows affected (0.0003 sec)
Listing 12-1Read locking in the SERIALIZABLE transaction isolation level
该查询使用辅助索引,并最终锁定所有被读取记录的主键和CountryCode
记录。此外,在斯洛伐克的最后一个索引记录之后有一个缺口锁定。所有的锁都是共享锁。
清单 12-2 考虑使用一个UPDATE
语句来更新被检查行的子集。
-- Connection Processlist ID Thread ID Event ID
-- --------------------------------------------------
-- 1 971 1567 6
-- 2 972 1568 6
-- Connection 1
Connection 1> SET SESSION TRANSACTION ISOLATION LEVEL SERIALIZABLE;
Query OK, 0 rows affected (0.0004 sec)
Connection 1> START TRANSACTION;
Query OK, 0 rows affected (0.0002 sec)
Connection 1> UPDATE world.city
SET Population = Population * 1.10
WHERE CountryCode = 'SVK'
AND District = 'Bratislava';
Query OK, 1 row affected (0.0006 sec)
Rows matched: 1 Changed: 1 Warnings: 0
-- Connection 2
Connection 2> SELECT index_name, lock_type,
lock_mode, lock_data
FROM performance_schema.data_locks
WHERE object_schema = 'world'
AND object_name = 'city'
AND lock_type = 'RECORD'
AND thread_id = 1567
ORDER BY index_name, lock_data DESC;
+-------------+-----------+---------------+-------------+
| index_name | lock_type | lock_mode | lock_data |
+-------------+-----------+---------------+-------------+
| CountryCode | RECORD | X,GAP | 'SVN', 3212 |
| CountryCode | RECORD | X | 'SVK', 3211 |
| CountryCode | RECORD | X | 'SVK', 3210 |
| CountryCode | RECORD | X | 'SVK', 3209 |
| PRIMARY | RECORD | X,REC_NOT_GAP | 3211 |
| PRIMARY | RECORD | X,REC_NOT_GAP | 3210 |
| PRIMARY | RECORD | X,REC_NOT_GAP | 3209 |
+-------------+-----------+---------------+-------------+
7 rows in set (0.0007 sec)
-- Connection 1
Connection 1> ROLLBACK;
Query OK, 0 rows affected (0.0576 sec)
Listing 12-2Locking in the SERIALIZABLE transaction isolation level
该语句更新一个城市(用ID = 3209
),但是在主键和CountryCode
索引上持有所有三个斯洛伐克城市的锁,并且在最后一个索引记录之后持有一个间隙锁。
还要注意这里如何使用SET TRANSACTION
语句来设置事务隔离级别,而不是设置transaction_isolation
变量。这两种方法可以互换,尽管SET SESION TRANSACTION
也支持设置它是只读还是读写事务。如果您使用既没有GLOBAL
也没有SESSION
作用域的SET TRANSACTION
语句,那么它只适用于下一个事务。
这个事务隔离级别不经常使用,但是在研究锁定问题或处理 XA 事务时会很有用。除了使用锁的SELECT
语句之外,隔离级别与接下来讨论的REPEATABLE READ
相同。
可重复读
REPETABLE READ
隔离级别是 InnoDB 中的默认设置。顾名思义,它确保了如果您重复一个 read 语句,那么将返回相同的结果。这也称为一致读取,它是通过称为快照的读取视图实现的。快照是在事务中执行第一条语句时建立的,或者如果用START TRANSACTION
给定了WITH CONSISTENT SNAPSHOT
修饰符,则在事务开始时建立。
一致快照的一个重要副作用是,可以进行非锁定读取,同时仍然可以重复检索相同的数据。这扩展到包括所有 InnoDB 表,因此对不同的表执行多个语句会返回对应于同一时间点的数据。具有一致快照的REPEATABLE READ
事务隔离级别也允许使用mysqlpump
和mysqldump
等工具创建在线一致逻辑备份。
虽然REPEATABLE READ
有一些很好的隔离属性,但不像SERIALIZABLE
那样具有侵入性,仍然有很高的锁定级别。清单 12-3 显示了在斯洛伐克布拉迪斯拉发地区选择城市的例子。
-- Connection Processlist ID Thread ID Event ID
-- --------------------------------------------------
-- 1 973 1571 6
-- 2 974 1572 6
-- Connection 1
Connection 1> SET transaction_isolation = 'REPEATABLE-READ';
Query OK, 0 rows affected (0.0004 sec)
Connection 1> START TRANSACTION;
Query OK, 0 rows affected (0.0003 sec)
Connection 1> SELECT ID, Name, Population
FROM world.city
WHERE CountryCode = 'SVK'
AND District = 'Bratislava';
+------+------------+------------+
| ID | Name | Population |
+------+------------+------------+
| 3209 | Bratislava | 448292 |
+------+------------+------------+
1 row in set (0.0006 sec)
-- Connection 2
Connection 2> SELECT index_name, lock_type,
lock_mode, lock_data
FROM performance_schema.data_locks
WHERE object_schema = 'world'
AND object_name = 'city'
AND lock_type = 'RECORD'
AND thread_id = 1571
ORDER BY index_name, lock_data DESC;
0 rows in set (0.0006 sec)
-- Connection 1
Connection 1> ROLLBACK;
Query OK, 0 rows affected (0.0004 sec)
Listing 12-3Read locking in the REPEATABLE READ transaction isolation level
在这种情况下,不持有锁,这是SERIALIZABLE
和REPEATABLE READ
事务隔离级别之间的重要区别。清单 12-4 展示了它如何寻找UPDATE
语句。
-- Connection Processlist ID Thread ID Event ID
-- --------------------------------------------------
-- 1 975 1574 6
-- 2 976 1575 6
-- Connection 1
Connection 1> SET transaction_isolation = 'REPEATABLE-READ';
Query OK, 0 rows affected (0.0004 sec)
Connection 1> START TRANSACTION;
Query OK, 0 rows affected (0.0002 sec)
Connection 1> UPDATE world.city
SET Population = Population * 1.10
WHERE CountryCode = 'SVK'
AND District = 'Bratislava';
Query OK, 1 row affected (0.0007 sec)
Rows matched: 1 Changed: 1 Warnings: 0
-- Connection 2
Connection 2> SELECT index_name, lock_type,
lock_mode, lock_data
FROM performance_schema.data_locks
WHERE object_schema = 'world'
AND object_name = 'city'
AND lock_type = 'RECORD'
AND thread_id = 1574
ORDER BY index_name, lock_data DESC;
+-------------+-----------+---------------+-------------+
| index_name | lock_type | lock_mode | lock_data |
+-------------+-----------+---------------+-------------+
| CountryCode | RECORD | X,GAP | 'SVN', 3212 |
| CountryCode | RECORD | X | 'SVK', 3211 |
| CountryCode | RECORD | X | 'SVK', 3210 |
| CountryCode | RECORD | X | 'SVK', 3209 |
| PRIMARY | RECORD | X,REC_NOT_GAP | 3211 |
| PRIMARY | RECORD | X,REC_NOT_GAP | 3210 |
| PRIMARY | RECORD | X,REC_NOT_GAP | 3209 |
+-------------+-----------+---------------+-------------+
7 rows in set (0.0010 sec)
-- Connection 1
Connection 1> ROLLBACK;
Query OK, 0 rows affected (0.3036 sec)
Listing 12-4Locking in the REPEATABLE READ transaction isolation level
这七把锁与SERIALIZABLE
中的锁是一样的。
对于一致快照,您需要注意的一个重要警告是,它们仅适用于读取。这意味着,如果您从表中读取数据,然后另一个事务提交对行的更改,以便它们与第一个事务中使用的过滤器相匹配,那么第一个事务将能够修改这些行,然后,它们将被包括在快照中。清单 12-5 显示了一个这样的例子。如果您想查看持有的锁,可以进行两种调查。
-- Connection Processlist ID Thread ID Event ID
-- --------------------------------------------------
-- 1 977 1578 6
-- 2 978 1579 6
-- Connection 1
Connection 1> SET transaction_isolation = 'REPEATABLE-READ';
Query OK, 0 rows affected (0.0005 sec)
Connection 1> START TRANSACTION;
Query OK, 0 rows affected (0.0004 sec)
Connection 1> SELECT ID, Name, Population
FROM world.city
WHERE CountryCOde = 'BHS';
+-----+--------+------------+
| ID | Name | Population |
+-----+--------+------------+
| 148 | Nassau | 172000 |
+-----+--------+------------+
1 row in set (0.0014 sec)
-- Connection 2
Connection 2> START TRANSACTION;
Query OK, 0 rows affected (0.0004 sec)
Connection 2> INSERT INTO world.city
VALUES (4080, 'Freeport', 'BHS',
'Grand Bahama', 50000);
Query OK, 1 row affected (0.0022 sec)
Connection 2> COMMIT;
Query OK, 0 rows affected (0.0983 sec)
-- Connection 1
Connection 1> SELECT ID, Name, Population
FROM world.city
WHERE CountryCOde = 'BHS';
+-----+--------+------------+
| ID | Name | Population |
+-----+--------+------------+
| 148 | Nassau | 172000 |
+-----+--------+------------+
1 row in set (0.0006 sec)
Connection 1> UPDATE world.city
SET Population = Population * 1.10
WHERE CountryCOde = 'BHS';
Query OK, 2 rows affected (0.0012 sec)
Rows matched: 2 Changed: 2 Warnings: 0
Connection 1> SELECT ID, Name, Population
FROM world.city
WHERE CountryCOde = 'BHS';
+------+----------+------------+
| ID | Name | Population |
+------+----------+------------+
| 148 | Nassau | 189200 |
| 4080 | Freeport | 55000 |
+------+----------+------------+
2 rows in set (0.0006 sec)
Listing 12-5Consistent reads mixed with DML
当 Connection 1 第一次查询巴哈马的所有城市(CountryCode = 'BHS'
)时,只返回拿骚市。然后,连接 2 为自由港市插入一行,并提交其事务。当 Connection 1 重复它的SELECT
语句时,仍然只返回 Nassau。目前为止一切顺利。这是对一致读取功能的期望。然而,当 Connection 1 将巴哈马所有城市的人口增加 10%时,有两行被更新,随后的SELECT
显示 Freeport 现在是 Connection 1 的 read 视图的一部分。
这种行为不是 bug!这是因为在REPEATABLE READ
事务隔离级别中读取是非锁定的。如果想要避免这种行为,要么需要使用FOR SHARE
子句显式请求 Connection 1 中的共享锁,要么需要更改为SERIALIZABLE
事务隔离级别。
一致读取的代价是相对大量的锁,并且 InnoDB 必须维护数据的多个版本。请记住,从对撤消日志的讨论中可以看出,只要在给定的事务之前还有一个已启动的读视图,那么就必须保留事务的撤消日志,并且跟踪相同行的版本的成本很高。
如果您不需要一致的读取,那么READ COMMITTED
事务隔离级别是一个不错的选择。
已提交读取
如果您习惯于 Oracle DB 或 PostgreSQL 等其他关系数据库系统,那么您可能一直在使用READ COMMITTED
事务隔离级别。MySQL 中的NDBCluster
存储引擎也使用了READ COMMITTED
。这种隔离级别很受欢迎,因为它为许多工作负载提供了足够强的隔离,并且与REPEATABLE READ
和SERIALIZABLE
隔离级别相比,它减少了锁定。
从REPEATABLE READ
到READ COMMITTED
的主要区别是
-
READ COMMITTED
不支持一致读取(尽管单个语句仍然返回一致的结果)。因为读取视图的生命周期只有语句的生命周期,所以 InnoDB 可以更快地清除旧的撤销日志。这一优势对于长时间运行的事务最为显著。 -
一旦评估了
WHERE
子句,DML 语句对已检查但未修改的记录所采取的锁就会被释放。 -
READ COMMITTED
将仅在检查外键和唯一键约束以及分页时使用间隙锁。当 InnoDB 页面接近满,必须在页面中间插入一条记录,或者现有记录增长,因此页面中不再有空间时,就会发生页面分割。 -
对于使用非索引列解析的
WHERE
子句,半一致读取功能允许事务使用行的最后提交值来匹配过滤器,即使该行被锁定。
缺少间隙锁意味着可能会出现所谓的幻像行。当同一个语句在同一个事务中执行两次时,会出现幻像行,即使对于像SELECT ... FOR SHARE
这样的锁定语句,也会返回不同的行。
The Illusive Gap Lock
在 MySQL 5.7 和 pre-GA MySQL 8 中,出现了一个更加困难的 MySQL bug。在复制设置中使用 XA 事务时,副本上会随机出现仅由复制流量引起的锁定等待超时和死锁。当复制源上没有任何锁定问题时,这怎么可能呢?
这期杂志由几部分组成。首先,在 MySQL 5.7 和更高版本中,XA 事务在准备时被写入二进制日志,并且它们可能不会按照准备时的顺序提交,这意味着即使在单线程复制中,副本也可能同时打开多个写事务。
其次,这个问题主要是通过实施基于行的复制和始终使用READ COMMITTED
事务隔离级别来解决的。然而,非常令人困惑的是,偶尔——看似随机——副本上仍然会有锁冲突。最终证明是页面分割导致的间隙锁才是罪魁祸首。源和副本上不会同时发生页拆分,因此副本上可能会有额外的锁。在 5.7.22 和 8.0.4 版本中,通过在 XA 事务到达准备阶段时释放复制线程获取的间隙锁,最终解决了这个问题。
如果您尝试在READ COMMITTED
事务隔离级别中使用循环的UPDATE
语句示例,您将会看到它如何比以前使用更少的锁。如清单 12-6 所示。
-- Connection Processlist ID Thread ID Event ID
-- --------------------------------------------------
-- 1 980 1582 6
-- 2 981 1583 6
-- Connection 1
Connection 1> SET transaction_isolation = 'READ-COMMITTED';
Query OK, 0 rows affected (0.0002 sec)
Connection 1> START TRANSACTION;
Query OK, 0 rows affected (0.0002 sec)
Connection 1> UPDATE world.city
SET Population = Population * 1.10
WHERE CountryCode = 'SVK'
AND District = 'Bratislava';
Query OK, 1 row affected (0.0007 sec)
Rows matched: 1 Changed: 1 Warnings: 0
-- Connection 2
Connection 2> SELECT index_name, lock_type,
lock_mode, lock_data
FROM performance_schema.data_locks
WHERE object_schema = 'world'
AND object_name = 'city'
AND lock_type = 'RECORD'
AND thread_id = 1582
ORDER BY index_name, lock_data DESC;
+-------------+-----------+---------------+-------------+
| index_name | lock_type | lock_mode | lock_data |
+-------------+-----------+---------------+-------------+
| CountryCode | RECORD | X,REC_NOT_GAP | 'SVK', 3209 |
| PRIMARY | RECORD | X,REC_NOT_GAP | 3209 |
+-------------+-----------+---------------+-------------+
2 rows in set (0.0008 sec)
-- Connection 1
Connection 1> ROLLBACK;
Query OK, 0 rows affected (0.0754 sec)
Listing 12-6Locking in the READ COMMITTED transaction isolation level
鉴于SERIALIZABLE
和REPEATABLE READ
隔离级别持有七个记录和间隙锁,READ COMMITTED
只持有一个CountryCode
索引锁和一个主键锁——虽然有一段时间,被检查的其他索引和行记录都持有锁,但它们在输出时又被释放了。这大大降低了锁等待和死锁的可能性。
READ COMMITTED
隔离级别的一个不太为人所知的特性是半一致读取,它允许语句使用列的最后提交值来与它的WHERE
子句进行比较。如果确定该行不受该语句的影响,则即使另一个事务持有锁,也不会发生锁冲突。如果行将被更新,条件将被重新评估,并且一个锁防止冲突的改变。清单 12-7 显示了一个这样的例子。如果您想查看持有的锁,可以进行两种调查。
-- Connection Processlist ID Thread ID Event ID
-- --------------------------------------------------
-- 1 986 1592 6
-- 2 987 1593 6
-- Connection 1
Connection 1> SET transaction_isolation = 'READ-COMMITTED';
Query OK, 0 rows affected (0.0004 sec)
Connection 1> START TRANSACTION;
Query OK, 0 rows affected (0.0004 sec)
Connection 1> UPDATE world.city
SET Population = Population * 1.10
WHERE Name = 'San Jose'
AND District = 'Southern Tagalog';
Query OK, 1 row affected (0.0106 sec)
Rows matched: 1 Changed: 1 Warnings: 0
-- Connection 2
Connection 2> SET transaction_isolation = 'READ-COMMITTED';
Query OK, 0 rows affected (0.0004 sec)
Connection 2> START TRANSACTION;
Query OK, 0 rows affected (0.0065 sec)
Connection 2> UPDATE world.city
SET Population = Population * 1.10
WHERE Name = 'San Jose'
AND District = 'Central Luzon';
Query OK, 1 row affected (0.0060 sec)
Rows matched: 1 Changed: 1 Warnings: 0
Listing 12-7READ COMMITTED Semi-consistent reads
两个事务都更新名为 San Jose 的城市,但是在不同的地区。无论是Name
还是District
列都没有被索引。即使第二个事务使用Name = 'San Jose' AND District = 'Southern Tagalog'
检查了行,也没有锁冲突,因为基于地区的事务决定它不会更新行。但是,如果将索引添加到Name
列,那么将会出现锁冲突,因此该特性目前对于大型表的价值有限。
从这次讨论中,你可能会得到这样的印象:READ COMMITTED
总是比REPEATABLE READ
表现得更好。这是最合乎逻辑的结论,然而事情往往比这更复杂。需要注意的是,为了启动一个读视图,需要使用trx_sys
互斥体(wait/synch/mutex/innodb/trx_sys_mutex
性能模式工具),并且由于READ COMMITTED
为每条语句启动了一个新的读视图,它将比REPEATABLE READ
更频繁地获取trx_sys
互斥体。最后,如果你有很多快速的事务和语句,你可能会在READ COMMITTED
中结束在互斥体上的大量活动,这将成为一个严重的瓶颈,你最好使用REPEATABLE READ
。对于长期运行的事务和报表,天平向READ COMMITTED
倾斜。
Tip:
如果你有兴趣看一些显示trx_sys
互斥体如何影响性能的基准测试,请看 http://dimitrik.free.fr/blog/archives/2015/02/mysql-performance-impact-of-innodb-transaction-isolation-modes-in-mysql-57.html
。
未提交读取
最后一个事务隔离级别是READ UNCOMMITTED
。顾名思义,使用此隔离级别的事务被允许读取尚未提交的数据;这也叫做脏读。这听起来很危险,而且在大多数情况下,这是绝对不可行的。然而,在一些特殊情况下,它可能是有用的。除了脏读之外,行为与READ COMMITTED
相同。READ UNCOMMITTED
相对于READ COMMITTED
的主要优势是 InnoDB 永远不需要保存多个版本的数据来完成查询。
READ UNCOMMITTED
的主要用途是在只需要近似值的情况下,用于批量插入,以及用于您想看一眼另一个事务做了什么更改的调查。只需要近似值的一个例子是 InnoDB 使用READ UNCOMMITTED
计算索引统计。对于批量插入,MySQL Shell 的并行表数据导入特性(JavaScript 中的util.importTable()
或 Python 中的util.import_table()
)会在批量加载期间切换到READ UNCOMMITTED
。
摘要
本章研究了 InnoDB 支持的四个事务隔离级别。最严格的隔离级别是SERIALIZABLE
,它为除自动提交单语句SELECT
事务之外的所有语句加锁。REPEATABLE READ
隔离级别支持非锁定读取,但支持一致读取。有一个问题是,如果事务更新了在 read 视图建立后提交的行,那么更新的行将被添加到视图中。
下一个级别是READ COMMITTED
,它放弃一致读取,并且总是包括所有提交的行。它确实为幻像行打开了大门,但是另一方面,READ COMMITTED
需要更少的锁,并且持有它们的时间更短,这样就大大降低了锁冲突的可能性。最后一个隔离级别是READ UNCOMMITTED
,其行为类似于READ COMMITTED
,但允许脏读,即读取尚未提交的更改。
这就结束了本书的理论部分。剩下的几章通过六个案例研究,第一章分析了一个涉及冲水锁的问题。
十三、案例研究:清空锁
锁定问题是性能问题的常见原因之一,其影响可能非常严重。在最坏的情况下,查询可能会失败,连接会堆积起来,因此无法建立新的连接。因此,了解如何调查锁定问题并修复这些问题非常重要。
本章和后面的章节将讨论六类锁问题:
-
清空锁
-
元数据和模式锁
-
记录级锁,包括间隙锁
-
僵局
-
外键
-
旗语
除了外键案例研究之外,每一类锁都使用不同的技术来确定锁争用的原因。当您阅读示例时,您应该记住,可以使用类似的技术来调查与示例不完全匹配的锁问题。对于前四个案例研究(第 13 至 16 章),讨论分为六个部分:
-
症状:这些症状使您能够识别所遇到的锁问题的种类。
-
设置:如果你想亲自尝试,这包括设置锁定问题的步骤。因为锁争用需要多个连接,所以提示符,例如
Connection 1>
,用于告诉哪个连接应该用于哪个语句。如果您希望在调查过程中获得的信息不会比在真实案例中获得的更多,那么您可以跳过这一部分,在完成调查后再回头查看。 -
解决方案:如何解决即时锁定问题,从而最大限度地减少由此导致的停机。
-
预防:讨论如何减少遇到问题的机会。这与第 9 章中关于减少锁定问题的讨论密切相关。
外键和信号量的最后两个案例研究遵循类似的模式。
说够了,首先要讨论的锁类别是刷新锁,这是最难研究的锁问题之一。
症状
flush lock 问题的主要症状是数据库陷入停滞,所有使用部分或全部表的新查询都要等待 flush lock。要寻找的迹象包括:
-
新查询的查询状态是“等待表刷新”这可能发生在所有新查询中,也可能只发生在访问特定表的查询中。
-
越来越多的连接被创建。
-
最终,由于 MySQL 失去连接,新的连接会失败。新连接收到的错误为
ER_CON_COUNT_ERROR
:ERROR 1040 (HY000): Too many connections
。(在 8.0.19 或更早版本中使用 X 协议时,错误为MySQL Error 5011: Could not open session
。) -
至少有一个查询的运行时间晚于最早的刷新锁请求。
-
进程列表中可能会有一个
FLUSH TABLES
语句,但并不总是这样。 -
当
FLUSH TABLES
语句等待lock_wait_timeout
时,出现ER_LOCK_WAIT_TIMEOUT
错误:ERROR: 1205: Lock wait timeout exceeded; try restarting transaction
。因为lock_wait_timeout
的默认值是 365 天,所以只有在超时时间减少的情况下,这种情况才有可能发生。 -
如果您使用默认模式集连接到
mysql
命令行客户端,那么在您到达提示符之前,连接可能会挂起。如果在连接打开的情况下更改默认模式,也会发生同样的情况。提示如果您使用
-A
选项启动客户端,禁用收集自动完成信息,则不会出现mysql
命令行客户端阻塞的问题。更好的解决方案是使用 MySQL Shell,它以一种不会因刷新锁而阻塞的方式获取自动完成信息。
如果您看到这些症状,是时候了解是什么导致了锁定问题。
原因
当一个连接请求刷新一个表时,它要求关闭对该表的所有引用,这意味着没有活动查询可以使用该表。因此,当刷新请求到达时,它必须等待所有使用要刷新的表的查询完成。请注意,除非您明确指定要刷新哪些表,否则必须完成的只是查询,而不是整个事务。显然,所有表都被刷新的情况是最严重的,例如由于FLUSH TABLES WITH READ LOCK
,因为这意味着所有活动查询必须在 flush 语句可以继续之前完成。
当等待刷新锁成为一个问题时,这意味着有一个或多个查询阻止了FLUSH TABLES
语句获得刷新锁。由于FLUSH TABLES
语句需要一个排他锁,因此它会阻止后续查询获取它们需要的共享锁。
在备份过程需要刷新所有表并获得读锁以创建一致备份的情况下,此问题经常出现。
当FLUSH TABLES
语句超时或被终止,但后续查询没有继续进行时,可能会出现一种特殊情况。出现这种情况是因为低级表定义缓存(TDC)版本锁没有被释放。这种情况可能会引起混淆,因为后续查询仍在等待表刷新的原因并不明显。当一个ANALYZE TABLE
语句触发被分析的一个或多个表的隐式刷新时,也会发生类似的情况。
设置
将要调查的锁定情况涉及三个连接(不包括用于调查的连接)。第一个连接执行慢速查询,第二个连接使用读锁刷新所有表,最后一个连接执行快速查询。这些语句如清单 13-1 所示。
-- Connection Processlist ID Thread ID Event ID
-- --------------------------------------------------
-- 1 668 1106 6
-- 2 669 1107 6
-- 3 670 1108 6
-- Connection 1
Connection 1> SELECT city.*, SLEEP(3600) FROM world.city WHERE ID = 130;
-- Connection 2
Connection 2> FLUSH TABLES WITH READ LOCK;
-- Connection 3
Connection 3> SELECT * FROM world.city WHERE ID = 3805;
Listing 13-1Triggering flush lock contention
在第一个查询中使用SLEEP(3600)
意味着您有一个小时(3600 秒)来执行另外两个查询并执行调查。如果您想停止锁定情况,您可以终止查询:
-- Investigation #6
-- Connection 4
Connection 4> KILL 668;
Query OK, 0 rows affected (0.0004 sec)
你现在可以开始调查了。
调查
对刷新锁的调查要求您查看实例上运行的查询列表。与其他锁争用不同,没有性能模式表或 InnoDB monitor 报告可用于直接查询阻塞查询。
清单 13-2 显示了使用sys.session
视图的输出示例。使用获取查询列表的替代方法将产生类似的结果。线程和连接 id 以及语句延迟会有所不同。
-- Investigation #1
-- Connection 4
Connection 4> SELECT thd_id, conn_id, state,
current_statement,
statement_latency
FROM sys.session
WHERE command = 'Query'\G
*************************** 1\. row ***************************
thd_id: 1106
conn_id: 668
state: User sleep
current_statement: SELECT city.*, SLEEP(3600) FROM world.city WHERE ID = 130
statement_latency: 1.48 min
*************************** 2\. row ***************************
thd_id: 1107
conn_id: 669
state: Waiting for table flush
current_statement: FLUSH TABLES WITH READ LOCK
statement_latency: 1.44 min
*************************** 3\. row ***************************
thd_id: 1108
conn_id: 670
state: Waiting for table flush
current_statement: SELECT * FROM world.city WHERE ID = 3805
statement_latency: 1.41 min
*************************** 4\. row ***************************
thd_id: 1105
conn_id: 667
state: NULL
current_statement: SELECT thd_id, conn_id, state, ... on WHERE command = 'Query'
statement_latency: 40.63 ms
4 rows in set (0.0419 sec)
Listing 13-2Investigating flush lock contention using sys.session
输出中有四个查询。默认情况下,sys.session
和sys.processlist
视图根据执行时间以降序对查询进行排序。这使得调查类似围绕刷新锁的争用这样的问题变得容易,在查找原因时,查询时间是要考虑的主要因素。
您开始寻找FLUSH TABLES
语句(稍后将讨论没有FLUSH TABLES
语句的情况)。在这种情况下,那就是thd_id = 1107
(第二排)。注意,FLUSH
语句的状态是“等待表刷新”然后查找已经运行了较长时间的查询。在这种情况下,只有一个查询:带有thd_id = 1106
的查询。这是阻止FLUSH TABLES WITH READ LOCK
完成的查询。通常,可能有不止一个查询。
剩下的两个查询是被FLUSH TABLES WITH READ LOCK
阻塞的查询和获取输出的查询。前三个查询一起构成了一个长时间运行的查询阻塞一个FLUSH TABLES
语句的典型例子,该语句又阻塞了其他查询。
您还可以从 MySQL Workbench 获取进程列表,在某些情况下,还可以从您的监控解决方案中获取(MySQL Enterprise Monitor 就是一个例子)。在 MySQL Workbench 中,您可以通过选择导航器中的管理选项卡来使用客户端连接报告,如图 13-1 所示。
图 13-1
导航到客户端连接报告
您可以通过在管理部分选择客户端连接来打开报告。
该报告从performance_schema.threads
表中获取进程信息,用performance_schema.session_connect_attrs
表上的LEFT JOIN
获取程序名。您可以选择是否过滤掉后台线程以及休眠连接,MySQL Workbench 允许您更改排序,而无需重新执行生成报告的语句。或者,您也可以刷新报告。图 13-2 显示了本案例研究的一个例子。
图 13-2
显示 MySQL Workbench 中的客户端连接
您不能选择要包括哪些列,并且为了使文本可读,屏幕截图中只包括报告的一部分。 Id 列对应sys.session
输出中的conn_id
,线程(靠近中间)对应thd_id
。完整的截图作为figure_13_2_workbench_flush_lock.png
收录在本书的 GitHub 知识库中。
类似 MySQL Workbench 和 MySQL Enterprise Monitor 中的报告的一个优点是,它们使用现有的连接来创建报告。在锁问题导致所有连接都被使用的情况下,使用监控解决方案获得查询列表是非常宝贵的。
如前所述,FLUSH TABLES
语句可能并不总是出现在查询列表中。仍然有查询等待刷新表的原因是低级 TDC 版本锁。调查的原则保持不变,但它似乎令人困惑。清单 13-3 显示了这样一个例子,使用相同的设置,但是在调查之前终止了执行 flush 语句的连接(如果您交互地执行它,Ctrl+C 可以在 MySQL Shell 中的连接执行FLUSH TABLES WITH READ LOCK
中使用)。
-- Investigation #7
Connection 4> KILL 669;
Query OK, 0 rows affected (0.0004 sec)
-- Investigation #1
Connection 4> SELECT thd_id, conn_id, state,
current_statement,
statement_latency
FROM sys.session
WHERE command = 'Query'\G
*************************** 1\. row ***************************
thd_id: 1106
conn_id: 668
state: User sleep
current_statement: SELECT city.*, SLEEP(3600) FROM world.city WHERE ID = 130
statement_latency: 3.88 min
*************************** 2\. row ***************************
thd_id: 1108
conn_id: 670
state: Waiting for table flush
current_statement: SELECT * FROM world.city WHERE ID = 3805
statement_latency: 3.81 min
*************************** 3\. row ***************************
thd_id: 1105
conn_id: 667
state: NULL
current_statement: SELECT thd_id, conn_id, state, ... on WHERE command = 'Query'
statement_latency: 39.53 ms
3 rows in set (0.0406 sec)
Listing 13-3Flush lock contention without a FLUSH TABLES statement
这种情况与前一种情况相同,只是没有了FLUSH TABLES
语句。在这种情况下,查找等待时间最长且状态为“等待表刷新”的查询运行时间超过该查询等待时间的查询会阻止 TDC 版本锁被释放。在这种情况下,这意味着thd_id = 668
是阻塞查询。
一旦您确定了问题和涉及的主要查询,您需要决定如何处理该问题。
解决方案
解决这个问题有两个层次。首先,您需要解决查询不执行的直接问题。其次,你需要努力避免将来出现这种问题。本小节将讨论即时解决方案,下一小节将考虑如何减少问题发生的几率。
要解决眼前的问题,您可以选择等待查询完成或开始终止查询。如果您可以在刷新锁争用正在进行时重定向应用以使用另一个实例,那么通过让长时间运行的查询完成,您也许能够让这种情况自行解决。如果在那些正在运行或等待的查询中有数据更改查询,在这种情况下,您确实需要考虑在所有查询完成后,它是否会使系统保持一致的状态。一种选择是以只读模式继续在不同的实例上执行读取查询。
Tip
如果长时间运行的查询是一个缺少 join 子句的流氓查询,它可能需要很长时间才能完成。这本书的作者经历了一个运行了几个月的查询。当决定是否等待时,您希望尝试估计查询将花费多长时间。一个好的选择是使用EXPLAIN FOR CONNECTION <processlist id>
命令来检查长时间运行的查询的查询计划。
如果您决定终止查询,您可以尝试终止FLUSH TABLES
语句。如果这行得通,这是最简单的解决方案。然而,正如所讨论的那样,这并不总是有帮助的,在这种情况下,唯一的解决方案是终止那些阻止FLUSH TABLES
语句完成的查询。如果长时间运行的查询看起来像失控的查询,并且执行它们的应用/客户端不再等待它们,那么您可能想要杀死它们,而不是试图首先杀死FLUSH TABLES
语句。
在终止查询时,一个重要的考虑因素是有多少数据被更改。对于一个纯粹的SELECT
查询(不涉及存储的例程),那总是没什么,从所做工作的角度来看,杀死它是安全的。然而,对于INSERT
、UPDATE
、DELETE
和类似的查询,如果查询被终止,则更改的数据必须回滚。回滚更改通常比一开始就进行更改需要更长的时间,所以如果有很多更改,请准备好等待很长时间才能回滚。您可以使用information_schema.INNODB_TRX
视图,通过查看trx_rows_modified
列来估计完成的工作量。如果有大量工作要回滚,通常最好让查询完成。
Caution
当 DML 语句被终止时,它所做的工作必须回滚。回滚通常比创建变更花费更长的时间,有时甚至更长。如果你考虑终止一个长时间运行的 DML 语句,你需要考虑到这一点。
当然,最理想的情况是完全防止问题发生。
预防
刷新锁争用的发生是因为长时间运行的查询和一个FLUSH TABLES
语句的组合。因此,为了防止这个问题,你需要看看你能做些什么来避免这两种情况同时出现。
查找、分析和处理长时间运行的查询超出了本书的范围。然而,一个特别有趣的选项是使用max_execution_time
系统变量和MAX_EXECUTION_TIME(N)
优化器提示为SELECT
语句支持的查询设置超时,这是防止查询失控的一个好方法。一些连接器还支持超时查询。
Tip
为了避免长时间运行的SELECT
查询,您可以配置max_execution_time
选项或者设置MAX_EXECUTION_TIME(N)
优化器提示。这将使SELECT
语句在指定的时间段后超时,并有助于防止类似刷新锁等待的问题。
无法阻止某些长时间运行的查询。这可能是一项报告作业、构建缓存表或其他必须访问大量数据的任务。在这种情况下,您能做的最好的事情就是尽量避免它们运行,同时也有必要刷新表。一种选择是将长时间运行的查询安排在不同于需要刷新表的时间运行。另一种选择是让长时间运行的查询在不同于需要刷新表的作业的实例上运行。
需要刷新表的一个常见任务是进行备份。在 MySQL 8 中,可以通过使用备份和日志锁来避免这个问题。例如,MySQL Enterprise Backup (MEB)在版本 8.0.16 和更高版本中执行此操作,因此 InnoDB 表永远不会被刷新。或者,您可以在使用率较低的时段执行备份,这样潜在的冲突会更低,或者您甚至可以在系统处于只读模式时执行备份,从而完全避免FLUSH TABLES WITH READ LOCK
。
摘要
本章研究了一种情况,一个长时间运行的查询阻止了一个FLUSH TABLES WITH READ LOCK
语句获取刷新锁,从而阻止了以后开始执行的查询。像这样的情况是最难调查的,因为从性能模式中的锁表得不到任何帮助。相反,您需要查看进程列表,查找比FLUSH TABLES
语句更早的查询,或者,如果不存在,查找等待刷新锁时间最长的连接。
在大多数情况下,您可以选择等待长时间运行的查询完成,或者终止它以解决问题。终止查询是否可接受取决于查询的目的以及事务进行了多少更改。为了防止这个问题,您可以尝试分离任务,以便长时间运行的查询和FLUSH TABLE
语句不会同时执行,或者它们在不同的 MySQL 实例上执行。对于SELECT
语句,您还可以使用max_execution_time
选项或MAX_EXECUTION_TIME(N)
优化器开关来自动终止长时间运行的查询。
另一种经常引起混淆的锁类型是元数据锁。一个涉及元数据锁定的案例研究将在下一章讨论。
十四、.案例研究:元数据和模式锁
在 MySQL 5.7 和更早的版本中,元数据锁经常是混淆的来源。问题是谁持有元数据锁并不明显。在 MySQL 5.7 中,元数据锁的检测被添加到性能模式中,而在 MySQL 8.0 中,它是默认启用的。启用该工具后,就可以很容易地确定是谁阻塞了试图获取锁的连接。本章将通过一个例子来说明元数据锁定的情况,并对其进行分析。首先讨论症状。
症状
元数据锁争用的症状类似于刷新锁争用的症状。在典型的情况下,会有一个长时间运行的查询或事务、一个等待元数据锁的 DDL 语句,并且可能会有查询堆积起来。要注意的症状如下:
-
DDL 语句和可能的其他查询都停留在“等待表元数据锁定”状态。
-
查询可能会堆积如山。等待中的查询都使用同一个表。(如果有多个表的 DDL 语句在等待元数据锁,则可能有不止一组查询在等待。)
-
当 DDL 语句已经等待
lock_wait_timeout
时,出现一个ER_LOCK_WAIT_TIMEOUT
错误:ERROR: 1205: Lock wait timeout exceeded; try restarting transaction
。由于lock_wait_timeout
的默认值是 365 天,只有在超时时间减少的情况下,这种情况才有可能发生。 -
有一个长时间运行的查询或长时间运行的事务。在后一种情况下,事务可能处于空闲状态,或者正在执行一个不使用 DDL 语句所作用的表的查询。
使这种情况变得潜在混乱的是最后一点:可能没有任何长时间运行的查询是导致锁问题的明确候选。那么,元数据锁争用的原因是什么呢?
原因
请记住,元数据锁的存在是为了保护模式定义(以及与显式锁一起使用)。只要事务处于活动状态,模式保护就会一直存在,因此当事务查询表时,元数据锁定将持续到事务结束。因此,您可能看不到任何长时间运行的查询。事实上,持有元数据锁的事务可能根本不做任何事情。
简而言之,元数据锁的存在是因为一个或多个连接可能依赖于给定表的模式不变,或者它们已经使用LOCK TABLES
或FLUSH TABLES WITH READ LOCK
语句显式锁定了该表。
设置
元数据锁定的示例调查使用了三个连接,就像上一章中的示例一样。第一个连接正在进行事务处理,第二个连接尝试向事务处理使用的表添加索引,第三个连接尝试对同一个表执行查询。这些查询如清单 14-1 所示。
-- Connection Processlist ID Thread ID Event ID
-- --------------------------------------------------
-- 1 713 1181 6
-- 2 714 1182 6
-- 3 715 1183 6
-- Connection 1
Connection 1> START TRANSACTION;
Query OK, 0 rows affected (0.0003 sec)
Connection 1> SELECT * FROM world.city WHERE ID = 3805\G
*************************** 1\. row ***************************
ID: 3805
Name: San Francisco
CountryCode: USA
District: California
Population: 776733
1 row in set (0.0007 sec)
Connection 1> SELECT Code, Name FROM world.country WHERE Code = 'USA'\G
*************************** 1\. row ***************************
Code: USA
Name: United States
1 row in set (0.0005 sec)
-- Connection 2
Connection 2> ALTER TABLE world.city ADD INDEX (Name);
-- Connection 3
Connection 3> SELECT * FROM world.city WHERE ID = 130;
Listing 14-1Triggering metadata lock contention
此时,可以开始调查了。这种情况不会自行解决(除非你的lock_wait_timeout
值很低,或者你准备等一年),所以你有足够的时间。当您想要解决阻塞时,您可以开始终止连接 2 中的ALTER TABLE
语句,以避免修改世界。城市餐桌。然后提交或回滚连接 1 中的事务。
调查
如果启用了wait/lock/metadata/sql/mdl
性能模式工具(MySQL 8 中的默认设置),那么调查元数据锁定问题就很简单了。您可以使用性能模式中的metadata_locks
表来列出授予的和挂起的锁。然而,获得锁情况摘要的一个更简单的方法是使用 sys 模式中的schema_table_lock_waits
视图。
作为一个例子,考虑在清单 14-2 中可以看到的元数据锁定等待问题,其中涉及三个连接。选择了WHERE
子句,以便只包含该调查感兴趣的行。
-- Investigation #1
-- Connection 4
Connection 4> SELECT thd_id, conn_id, state,
current_statement,
statement_latency
FROM sys.session
WHERE command = 'Query' OR trx_state = 'ACTIVE'\G
*************************** 1\. row ***************************
thd_id: 1181
conn_id: 713
state: NULL
current_statement: SELECT Code, Name FROM world.country WHERE Code = 'USA'
statement_latency: NULL
*************************** 2\. row ***************************
thd_id: 1182
conn_id: 714
state: Waiting for table metadata lock
current_statement: ALTER TABLE world.city ADD INDEX (Name)
statement_latency: 26.68 s
*************************** 3\. row ***************************
thd_id: 1183
conn_id: 715
state: Waiting for table metadata lock
current_statement: SELECT * FROM world.city WHERE ID = 130
statement_latency: 24.68 s
*************************** 4\. row ***************************
thd_id: 1180
conn_id: 712
state: NULL
current_statement: SET @sys.statement_truncate_le ... ('statement_truncate_len', 64)
statement_latency: 50.42 ms
4 rows in set (0.0530 sec)
Listing 14-2A metadata lock wait issue
两个连接正在等待元数据锁定(在world.city
表上)。还包括第三个连接(conn_id = 713
),它是空闲的,可以从语句延迟的NULL
中看到(在 8.0.18 之前和 8.0.21 之后的一些版本中,您可能还会看到当前语句是NULL
)。在这种情况下,查询列表仅限于具有活动查询或活动事务的查询,但通常您会从完整的进程列表开始。然而,为了便于关注重要的部分,输出被过滤。
一旦您知道存在元数据锁定问题,您可以使用sys.schema_table_lock_waits
视图来获取关于锁定争用的信息。清单 14-3 显示了与刚才讨论的过程列表相对应的输出示例。
-- Investigation #3
Connection 4> SELECT *
FROM sys.schema_table_lock_waits\G
*************************** 1\. row ***************************
object_schema: world
object_name: city
waiting_thread_id: 1182
waiting_pid: 714
waiting_account: root@localhost
waiting_lock_type: EXCLUSIVE
waiting_lock_duration: TRANSACTION
waiting_query: ALTER TABLE world.city ADD INDEX (Name)
waiting_query_secs: 128
waiting_query_rows_affected: 0
waiting_query_rows_examined: 0
blocking_thread_id: 1181
blocking_pid: 713
blocking_account: root@localhost
blocking_lock_type: SHARED_READ
blocking_lock_duration: TRANSACTION
sql_kill_blocking_query: KILL QUERY 713
sql_kill_blocking_connection: KILL 713
*************************** 2\. row ***************************
object_schema: world
object_name: city
waiting_thread_id: 1183
waiting_pid: 715
waiting_account: root@localhost
waiting_lock_type: SHARED_READ
waiting_lock_duration: TRANSACTION
waiting_query: SELECT * FROM world.city WHERE ID = 130
waiting_query_secs: 126
waiting_query_rows_affected: 0
waiting_query_rows_examined: 0
blocking_thread_id: 1181
blocking_pid: 713
blocking_account: root@localhost
blocking_lock_type: SHARED_READ
blocking_lock_duration: TRANSACTION
sql_kill_blocking_query: KILL QUERY 713
sql_kill_blocking_connection: KILL 713
*************************** 3\. row ***************************
object_schema: world
object_name: city
waiting_thread_id: 1182
waiting_pid: 714
waiting_account: root@localhost
waiting_lock_type: EXCLUSIVE
waiting_lock_duration: TRANSACTION
waiting_query: ALTER TABLE world.city ADD INDEX (Name)
waiting_query_secs: 128
waiting_query_rows_affected: 0
waiting_query_rows_examined: 0
blocking_thread_id: 1182
blocking_pid: 714
blocking_account: root@localhost
blocking_lock_type: SHARED_UPGRADABLE
blocking_lock_duration: TRANSACTION
sql_kill_blocking_query: KILL QUERY 714
sql_kill_blocking_connection: KILL 714
*************************** 4\. row ***************************
object_schema: world
object_name: city
waiting_thread_id: 1183
waiting_pid: 715
waiting_account: root@localhost
waiting_lock_type: SHARED_READ
waiting_lock_duration: TRANSACTION
waiting_query: SELECT * FROM world.city WHERE ID = 130
waiting_query_secs: 126
waiting_query_rows_affected: 0
waiting_query_rows_examined: 0
blocking_thread_id: 1182
blocking_pid: 714
blocking_account: root@localhost
blocking_lock_type: SHARED_UPGRADABLE
blocking_lock_duration: TRANSACTION
sql_kill_blocking_query: KILL QUERY 714
sql_kill_blocking_connection: KILL 714
4 rows in set (0.0041 sec)
Listing 14-3Finding metadata lock contention
输出显示有四种查询等待和阻塞的情况。这可能令人惊讶,但它确实发生了,因为涉及到几个锁,并且有一系列等待。每一行都是一对等待和阻塞连接。输出使用“pid”作为进程列表 id,这与早期输出中使用的连接 id 相同。这些信息包括锁是什么、等待连接的详细信息、阻塞连接的详细信息以及可用于终止阻塞查询或连接的两个查询。
第三行显示了等待自身的进程列表 id 714。这听起来像是一个僵局,但事实并非如此。原因是ALTER TABLE
首先获取了一个可以升级的共享锁,然后试图获取正在等待的独占锁。因为没有关于哪个现有锁实际上阻塞了新锁的明确信息,所以该信息最终被包括在内。
第四行显示SELECT
语句正在等待进程列表 id 714,即ALTER TABLE
。这就是当 DDL 语句需要一个独占锁时,连接会开始堆积的原因,所以它会阻塞对共享锁的请求。
第一行和第二行揭示了锁争用的潜在问题。进程列表 id 713 阻塞了其他两个连接,这表明这是阻塞 DDL 语句的罪魁祸首。因此,当您调查类似这样的问题时,请查找正在等待被另一个连接阻塞的独占元数据锁的连接。如果输出中有大量的行,您还可以查找导致最多阻塞的连接,并以此为起点。清单 14-4 展示了一个如何做到这一点的例子。
-- Investigation #4
Connection 4> SELECT *
FROM sys.schema_table_lock_waits
WHERE waiting_lock_type = 'EXCLUSIVE'
AND waiting_pid <> blocking_pid\G
*************************** 1\. row ***************************
object_schema: world
object_name: city
waiting_thread_id: 1182
waiting_pid: 714
waiting_account: root@localhost
waiting_lock_type: EXCLUSIVE
waiting_lock_duration: TRANSACTION
waiting_query: ALTER TABLE world.city ADD INDEX (Name)
waiting_query_secs: 678
waiting_query_rows_affected: 0
waiting_query_rows_examined: 0
blocking_thread_id: 1181
blocking_pid: 713
blocking_account: root@localhost
blocking_lock_type: SHARED_READ
blocking_lock_duration: TRANSACTION
sql_kill_blocking_query: KILL QUERY 713
sql_kill_blocking_connection: KILL 713
1 row in set (0.0025 sec)
-- Investigation #5
Connection 4> SELECT blocking_pid, COUNT(*)
FROM sys.schema_table_lock_waits
WHERE waiting_pid <> blocking_pid
GROUP BY blocking_pid
ORDER BY COUNT(*) DESC;
+--------------+----------+
| blocking_pid | COUNT(*) |
+--------------+----------+
| 713 | 2 |
| 714 | 1 |
+--------------+----------+
2 rows in set (0.0023 sec)
Listing 14-4Looking for the connection causing the metadata lock block
第一个查询寻找对独占元数据锁的等待,其中阻塞进程列表 id 不是它本身。在这种情况下,这会立即导致主块争用。第二个查询确定每个进程列表 id 触发的阻塞查询的数量。这可能不像这个例子中显示的那么简单,但是使用这里显示的查询将有助于缩小锁争用的范围。
一旦确定了锁争用的来源,就需要确定事务正在做什么。在这种情况下,锁争用的根源是进程列表 id 为 713 的连接。回到进程列表输出,您可以看到在这种情况下它没有做任何事情:
*************************** 1\. row ***************************
thd_id: 1181
conn_id: 713
state: NULL
current_statement: SELECT Code, Name FROM world.country WHERE Code = 'USA'
statement_latency: NULL
这个连接做了什么来获取元数据锁?没有涉及world.city
表的当前语句这一事实表明该连接有一个活动的事务打开。在这种情况下,事务是空闲的(如statement_latency = NULL
所示),但也可能有一个与world.city
表上的元数据锁无关的查询正在执行。无论哪种情况,您都需要确定事务在当前状态之前正在做什么。为此,您可以使用性能模式和信息模式。清单 14-5 展示了一个调查事务状态和最近历史的例子。
-- Investigation #6
Connection 4> SELECT *
FROM information_schema.INNODB_TRX
WHERE trx_mysql_thread_id = 713\G
*************************** 1\. row ***************************
trx_id: 284186648310752
trx_state: RUNNING
trx_started: 2020-08-06 19:57:33
trx_requested_lock_id: NULL
trx_wait_started: NULL
trx_weight: 0
trx_mysql_thread_id: 713
trx_query: NULL
trx_operation_state: NULL
trx_tables_in_use: 0
trx_tables_locked: 0
trx_lock_structs: 0
trx_lock_memory_bytes: 1136
trx_rows_locked: 0
trx_rows_modified: 0
trx_concurrency_tickets: 0
trx_isolation_level: REPEATABLE READ
trx_unique_checks: 1
trx_foreign_key_checks: 1
trx_last_foreign_key_error: NULL
trx_adaptive_hash_latched: 0
trx_adaptive_hash_timeout: 0
trx_is_read_only: 0
trx_autocommit_non_locking: 0
trx_schedule_weight: NULL
1 row in set (0.0010 sec)
-- Investigation #7
Connection 4> SELECT *
FROM performance_schema.events_transactions_current
WHERE thread_id = 1181\G
*************************** 1\. row ***************************
THREAD_ID: 1181
EVENT_ID: 8
END_EVENT_ID: NULL
EVENT_NAME: transaction
STATE: ACTIVE
TRX_ID: NULL
GTID: AUTOMATIC
XID_FORMAT_ID: NULL
XID_GTRID: NULL
XID_BQUAL: NULL
XA_STATE: NULL
SOURCE: transaction.cc:209
TIMER_START: 456761974401600000
TIMER_END: 457816781775400000
TIMER_WAIT: 1054807373800000
ACCESS_MODE: READ WRITE
ISOLATION_LEVEL: REPEATABLE READ
AUTOCOMMIT: NO
NUMBER_OF_SAVEPOINTS: 0
NUMBER_OF_ROLLBACK_TO_SAVEPOINT: 0
NUMBER_OF_RELEASE_SAVEPOINT: 0
OBJECT_INSTANCE_BEGIN: NULL
NESTING_EVENT_ID: 7
NESTING_EVENT_TYPE: STATEMENT
1 row in set (0.0010 sec)
-- Investigation #8
Connection 4> SELECT event_id, current_schema, sql_text
FROM performance_schema.events_statements_history
WHERE thread_id = 1181
AND nesting_event_id = 8
AND nesting_event_type = 'TRANSACTION'\G
*************************** 1\. row ***************************
event_id: 9
current_schema: NULL
sql_text: SELECT * FROM world.city WHERE ID = 3805
*************************** 2\. row ***************************
event_id: 10
current_schema: NULL
sql_text: SELECT Code, Name FROM world.country WHERE Code = 'USA'
2 rows in set (0.0010 sec)
-- Investigation #9
Connection 4> SELECT attr_name, attr_value
FROM performance_schema.session_connect_attrs
WHERE processlist_id = 713
ORDER BY attr_name;
+-----------------+-----------------+
| attr_name | attr_value |
+-----------------+-----------------+
| _client_license | GPL |
| _client_name | libmysqlxclient |
| _client_version | 8.0.21 |
| _os | Win64 |
| _pid | 27832 |
| _platform | x86_64 |
| _thread | 31396 |
| program_name | mysqlsh |
+-----------------+-----------------+
8 rows in set (0.0007 sec)
Listing 14-5Investigating a transaction
第一个查询使用信息模式中的INNODB_TRX
视图。例如,它显示事务开始的时间,因此您可以确定它已经活动了多长时间。如果决定回滚事务,那么trx_rows_modified
列对于了解事务更改了多少数据也很有用。注意,InnoDB 所谓的 MySQL 线程 id(trx_mysql_thread_id
列)实际上是连接 id。
第二个查询使用性能模式中的events_transactions_current
表来获取更多的事务信息。您可以使用timer_wait
列来确定事务的年龄。该值以皮秒为单位,因此使用FORMAT_PICO_TIME()
函数可以更容易地理解该值:
mysql> SELECT FORMAT_PICO_TIME(1054807373800000) AS Age;
+-----------+
| Age |
+-----------+
| 17.58 min |
+-----------+
1 row in set (0.0006 sec)
如果您使用的是 MySQL 8.0.15 或更早版本,请使用sys.format_time()
函数。
第三个查询使用events_statements_history
表来查找之前在事务中执行的查询。nesting_event_id
列被设置为来自events_transactions_current
表输出的event_id
的值,而nesting_event_type
列被设置为匹配一个事务。这确保了只返回正在进行的事务的子事件。结果由event_id
(语句的)排序,按照执行的顺序得到语句。默认情况下,events_statements_history
表将包含最多十个最新的连接查询。
在这个例子中,调查显示事务执行了两个查询:一个从world.city
表中选择,另一个从world.country
表中选择。这是导致元数据锁争用的第一个查询。
第四个查询使用session_connect_attrs
表来查找连接提交的属性。并非所有客户端和连接器都提交属性,或者它们可能被禁用,因此这些信息并不总是可用。当属性可用时,它们有助于找出违规事务是从哪里执行的。在这个例子中,您可以看到连接来自 MySQL Shell ( mysqlsh
)。
调查完问题后,您可以回滚进程列表 id 713 的事务。这将导致执行ALTER TABLE
,因此如果您想让模式保持在本例之前的状态,还应该再次删除Name
索引:
-- Connection 1
Connection 1> ROLLBACK;
Query OK, 0 rows affected (0.0006 sec)
-- Connection 2
Query OK, 0 rows affected (35 min 34.2938 sec)
Records: 0 Duplicates: 0 Warnings: 0
-- Connection 3
+-----+--------+-------------+-----------------+------------+
| ID | Name | CountryCode | District | Population |
+-----+--------+-------------+-----------------+------------+
| 130 | Sydney | AUS | New South Wales | 3276207 |
+-----+--------+-------------+-----------------+------------+
1 row in set (35 min 31.1277 sec)
-- Connection 2
Connection 2> ALTER TABLE world.city DROP INDEX Name;
Query OK, 0 rows affected (0.1890 sec)
Records: 0 Duplicates: 0 Warnings: 0
解决方案
对于元数据锁争用,您基本上有两种选择来解决问题:完成阻塞事务或终止 DDL 语句。要完成阻塞事务,您需要提交或回滚它。如果您终止连接,将触发事务回滚,因此您需要考虑需要回滚多少工作。为了提交事务,您必须找到执行连接的位置,并以这种方式提交它。您不能提交由不同连接拥有的事务。
终止 DDL 语句将允许其他查询继续进行,但从长远来看,如果锁被一个已放弃但仍处于活动状态的事务持有,这并不能解决问题。对于持有元数据锁的被放弃的事务,可以选择终止 DDL 语句和与被放弃的事务的连接。这样,您可以避免 DDL 语句在事务回滚时继续阻塞后续查询。回滚完成后,您可以重试 DDL 语句。
预防
避免元数据锁争用的关键是避免长时间运行的事务,因为您需要为事务使用的表执行 DDL 语句。例如,当您知道没有长时间运行的事务时,可以执行 DDL 语句。您还可以将lock_wait_timeout
选项设置为一个较低的值,这将使 DDL 语句在lock_wait_timeout
秒后放弃。虽然这不能避免锁问题,但它通过避免 DDL 语句停止其他查询的执行来缓解这个问题。然后,您可以找到根本原因,而不必担心大部分应用无法工作。
您还可以致力于缩短事务的活动时间。如果不要求所有操作都作为一个原子单元来执行,一种选择是将一个大的事务分成几个较小的事务。您还应该确保在事务处于活动状态时,您没有进行交互工作、文件 I/O、向最终用户传输数据等,从而确保事务不会保持不必要的长时间打开。
长时间运行事务的一个常见原因是应用或客户端根本不提交或回滚事务。禁用autocommit
选项时,这种情况尤其容易发生。当autocommit
被禁用时,任何查询——即使是普通的只读SELECT
语句——都会在没有活动事务的情况下启动一个新事务。这意味着一个看似无辜的查询可能会启动一个事务,如果开发者不知道autocommit
被禁用,那么开发者可能不会考虑显式结束事务。在 MySQL Server 中默认情况下,autocommit
设置是启用的,但是一些连接器默认情况下禁用它。
摘要
在本章中,您研究了一种情况,其中一个被放弃的事务导致一个ALTER TABLE
语句阻塞,随后阻止了同一表上其他查询的执行。确定争用原因的关键是基于performance_schema.metadata_locks
表的sys.schema_table_lock_waits
视图。由于等待和阻塞锁请求对的数量会很快增加,您可能希望筛选行,例如,查找等待独占锁的请求,或者您可以聚合信息以查找阻塞请求最多的连接。
解决方案是提交或回滚事务,或者终止等待排他元数据锁的 DDL 语句。您也可以选择终止事务和 DDL 语句,如果事务必须回滚许多更改,这将非常有用。防止查询堆积的一个好方法是对lock_wait_timeout
使用一个较低的值,如果遇到锁等待超时,就重试 DDL 语句。
在下一章中,您将分析 InnoDB 记录锁定请求超时的情况。
事务、案例研究:记录级锁
记录锁争用是最常遇到的,但通常也是最不具干扰性的,因为默认的锁等待超时只有 50 秒,所以不存在查询堆积的可能性。也就是说,在某些情况下——正如将要展示的那样——记录锁会导致 MySQL 嘎然而止。本章将从总体上研究 InnoDB 记录锁定问题,更详细地研究锁定等待超时问题。对死锁细节的研究将推迟到下一章。
症状
InnoDB 记录锁争用的症状通常非常微妙,不容易识别。在严重的情况下,您会得到锁等待超时或死锁错误,但在许多情况下,可能没有直接的症状。更确切地说,症状是查询比正常情况下慢。这可能从慢几分之一秒到慢很多秒不等。
对于存在锁等待超时的情况,您将看到类似于以下示例中的ER_LOCK_WAIT_TIMEOUT
错误:
ERROR: 1205: Lock wait timeout exceeded; try restarting transaction
当查询比没有锁争用时要慢时,最有可能检测到问题的方法是通过监控,要么使用类似于 MySQL Enterprise Monitor 中的查询分析器,要么使用sys.innodb_lock_waits
视图检测锁争用。图 15-1 显示了查询分析器中的一个查询示例。在讨论记录锁争用的调查时,将使用sys
模式视图。该图在本书的 GitHub 知识库中以figure_15_1_quan.png
的形式提供。
图 15-1
查询分析器中检测到的锁争用示例
在图中,请注意查询的延迟图是如何在接近周期结束时增加,然后又突然下降的。规范化查询的右侧还有一个红色图标,该图标表示查询返回了错误。在这种情况下,错误是锁等待超时,但是从图中看不到。规范化查询左侧的环形图还显示了一个红色区域,指示查询的查询响应时间索引 1 有时被认为很差。顶部的大图显示了一个小的下降,表明实例中有足够多的问题导致实例的性能普遍下降。
还有几个实例级指标显示实例发生了多少锁定。这对于监控一段时间内的一般锁争用非常有用。清单 15-1 使用sys.metrics
视图显示了可用的指标。
mysql> SELECT Variable_name,
Variable_value AS Value,
Enabled
FROM sys.metrics
WHERE Variable_name LIKE 'innodb_row_lock%'
OR Type = 'InnoDB Metrics - lock';
+-------------------------------+--------+---------+
| Variable_name | Value | Enabled |
+-------------------------------+--------+---------+
| innodb_row_lock_current_waits | 0 | YES |
| innodb_row_lock_time | 480628 | YES |
| innodb_row_lock_time_avg | 1219 | YES |
| innodb_row_lock_time_max | 51066 | YES |
| innodb_row_lock_waits | 394 | YES |
| lock_deadlock_false_positives | 0 | YES |
| lock_deadlock_rounds | 193790 | YES |
| lock_deadlocks | 0 | YES |
| lock_rec_grant_attempts | 218 | YES |
| lock_rec_lock_created | 0 | NO |
| lock_rec_lock_removed | 0 | NO |
| lock_rec_lock_requests | 0 | NO |
| lock_rec_lock_waits | 0 | NO |
| lock_rec_locks | 0 | NO |
| lock_rec_release_attempts | 7522 | YES |
| lock_row_lock_current_waits | 0 | YES |
| lock_schedule_refreshes | 193790 | YES |
| lock_table_lock_created | 0 | NO |
| lock_table_lock_removed | 0 | NO |
| lock_table_lock_waits | 0 | NO |
| lock_table_locks | 0 | NO |
| lock_threads_waiting | 0 | YES |
| lock_timeouts | 193 | YES |
+-------------------------------+--------+---------+
23 rows in set (0.0089 sec)
Listing 15-1InnoDB lock metrics
对于这个讨论,innodb_row_lock_%
和lock_timeouts
指标是最有趣的。三个时间变量以毫秒为单位。可以看到有 193 个锁等待超时,这本身并不一定是一个问题(至少您需要考虑这些超时发生了多长时间)。您还可以看到有 394 次锁不能被立即授予(innodb_row_lock_waits
)并且等待时间超过 51 秒(innodb_row_lock_time_max
)。当锁争用的总体水平增加时,您将看到这些指标也在增加。
甚至比手动监控指标更好的是,确保您的监控解决方案记录指标,并可以在时间序列图中绘制它们。图 15-2 显示了为图 15-1 中发现的同一事件绘制的指标示例。
图 15-2
InnoDB 行锁指标的时间序列图
图表显示了锁定的总体增加。锁等待的数量有两个阶段,随着锁等待的增加,然后再次下降。行锁定时间图显示了类似的模式。这是间歇性锁定问题的典型迹象。
原因
InnoDB 在行数据、索引记录、间隙和插入意图锁上使用共享锁和排他锁。当有两个事务试图以冲突的方式访问数据时,一个查询将不得不等待,直到所需的锁可用。简而言之,可以同时允许两个对共享锁的请求,但是一旦有了独占锁,任何连接都不能在同一个记录上获得锁。
由于排他锁最有可能导致锁争用,因此通常 DML 查询会更改导致 InnoDB 记录锁争用的数据。另一个来源是SELECT
语句通过添加FOR SHARE
(或LOCK IN SHARE MODE
或FOR UPDATE
子句来进行抢先锁定。
设置
这个示例只需要两个连接来设置正在研究的场景,第一个连接有一个正在进行的事务,第二个连接试图更新第一个连接持有锁的行。因为等待 InnoDB 锁的默认超时是 50 秒,所以您可以选择增加第二个连接的超时时间,这将会阻塞,以便您有更多的时间来执行调查。设置如清单 15-2 所示。
-- Connection Processlist ID Thread ID Event ID
-- --------------------------------------------------
-- 1 738 1219 6
-- 2 739 1220 6
-- Connection 1
Connection 1> START TRANSACTION;
Query OK, 0 rows affected (0.0002 sec)
Connection 1> UPDATE world.city
SET Population = 5000000
WHERE ID = 130;
Query OK, 1 row affected (0.0248 sec)
Rows matched: 1 Changed: 1 Warnings: 0
-- Connection 2
Connection 2> SET SESSION innodb_lock_wait_timeout = 3600;
Query OK, 0 rows affected (0.0004 sec)
Connection 2> START TRANSACTION;
Query OK, 0 rows affected (0.0002 sec)
Connection 2> UPDATE world.city SET Population = Population * 1.10 WHERE CountryCode = 'AUS';
Listing 15-2Triggering InnoDB record lock contention
在本例中,连接 2 的锁等待超时设置为 3600 秒,以便您有一个小时的时间来调查问题。Connection 2 的START TRANSACTION
不是必需的,但是允许您在完成后回滚两个事务,以避免对数据进行更改。
调查
调查记录锁与调查元数据锁非常相似。您可以查询性能模式中的data_locks
和data_lock_waits
表,它们将分别显示原始锁数据和挂起的锁。还有一个sys.innodb_lock_waits
视图,它查询两个表来寻找一个被另一个阻塞的锁对。
Note
MySQL 8 中新增了data_locks
和data_lock_waits
表。在 MySQL 5.7 和更早的版本中,信息模式中有两个相似的表,分别名为INNODB_LOCKS
和INNODB_LOCK_WAITS
。使用innodb_lock_waits
视图的一个优点是它在不同的 MySQL 版本上工作是一样的(但是在 MySQL 8 中有一些额外的信息)。
在大多数情况下,使用innodb_lock_waits
视图开始调查是最容易的,并且只在需要时深入性能模式表。清单 15-3 显示了锁等待情况下innodb_lock_waits
的输出示例。
-- Investigation #1
-- Connection 3
Connection 3> SELECT * FROM sys.innodb_lock_waits\G
*************************** 1\. row ***************************
wait_started: 2020-08-07 18:04:56
wait_age: 00:00:16
wait_age_secs: 16
locked_table: `world`.`city`
locked_table_schema: world
locked_table_name: city
locked_table_partition: NULL
locked_table_subpartition: NULL
locked_index: PRIMARY
locked_type: RECORD
waiting_trx_id: 537516
waiting_trx_started: 2020-08-07 18:04:56
waiting_trx_age: 00:00:16
waiting_trx_rows_locked: 2
waiting_trx_rows_modified: 0
waiting_pid: 739
waiting_query: UPDATE world.city SET Populati ... 1.10 WHERE CountryCode = 'AUS'
waiting_lock_id: 2711671601760:1923:7:44:2711634704240
waiting_lock_mode: X,REC_NOT_GAP
blocking_trx_id: 537515
blocking_pid: 738
blocking_query: NULL
blocking_lock_id: 2711671600928:1923:7:44:2711634698920
blocking_lock_mode: X,REC_NOT_GAP
blocking_trx_started: 2020-08-07 18:04:56
blocking_trx_age: 00:00:16
blocking_trx_rows_locked: 1
blocking_trx_rows_modified: 1
sql_kill_blocking_query: KILL QUERY 738
sql_kill_blocking_connection: KILL 738
1 row in set (0.0805 sec)
Listing 15-3Retrieving lock information from the innodb_lock_waits view
根据列名的前缀,输出中的列可以分为五个部分。这些群体是
-
wait_
: 这些列显示了关于锁等待时间的一些一般信息。 -
locked_
: 这些列显示了从模式到索引以及锁类型的锁。 -
waiting_
: 这些列显示等待授予锁的事务的详细信息,包括查询和请求的锁模式。 -
blocking_
: 这些列显示阻塞锁请求的事务的详细信息。注意,在这个例子中,阻塞查询是NULL
。这意味着在生成输出时事务是空闲的。即使列出了阻塞查询,该查询也可能与存在争用的锁没有任何关系——除了该查询是由持有锁的同一事务执行的。 -
sql_kill_
: 这两列提供了可用于终止阻塞查询或连接的KILL
查询。
Note
列blocking_query
是阻塞事务当前执行的查询(如果有的话)。这并不意味着查询本身必然会导致锁请求阻塞。
blocking_query
列为NULL
的情况是常见情况。这意味着阻塞事务当前没有执行查询。这可能是因为它在两个查询之间。如果这段时间很长,则表明应用正在做理想情况下应该在事务之外完成的工作。更常见的情况是,事务没有执行查询,因为它被遗忘了,要么是在交互会话中,人们忘记了结束事务,要么是在应用流中,不能确保事务被提交或回滚。
解决方案
解决方案取决于锁等待的程度。如果有几个查询的锁等待时间很短,那么让受影响的查询等待锁变得可用也是可以接受的。请记住,锁是为了确保数据的完整性,所以锁本身不是问题。只有当锁对性能造成重大影响或者导致查询失败到无法重试的程度时,锁才会成为问题。
如果锁定情况持续很长时间——特别是如果阻塞事务已经被放弃——您可以考虑终止阻塞事务。和往常一样,如果阻塞事务执行了大量工作,您需要考虑回滚可能会花费大量时间。
对于由于锁等待超时错误而失败的查询,应用应该重试它们。请记住,默认情况下,锁等待超时仅回滚超时发生时正在执行的查询。事务的其余部分与查询前一样。因此,处理超时失败可能会使未完成的事务带有自己的锁,这可能会导致进一步的锁问题。是只回滚查询还是回滚整个事务由innodb_rollback_on_timeout
选项控制。
Caution
处理锁等待超时是非常重要的,否则它可能会使事务带有未释放的锁。如果发生这种情况,其他事务可能无法获得它们需要的锁。
预防
防止重大的记录级锁争用主要遵循第 9 章“减少锁定问题”中讨论的指导方针概括一下讨论,减少锁等待争用的方法主要是减少事务的大小和持续时间,使用索引来减少被访问的记录的数量,并可能将事务隔离级别切换到READ COMMITTED
来更早地释放锁并减少间隙锁的数量。
摘要
在本章中,我们讨论了一个关于 InnoDB 记录锁的案例研究。症状是,一个本应很快的查询需要很长时间才能完成。确定锁争用中涉及哪些连接的关键是使用sys.innodb_lock_waits
视图,该视图直接显示等待和阻塞连接的信息。要了解更多细节,您可以深入到性能模式中的data_locks
和data_lock_waits
表中。
解决方案取决于锁等待的程度。如果它们很短且不频繁,您可以忽略它们,让等待的查询等待锁请求变得可用。如果锁等待是由运行时间过长的查询或被遗忘的事务引起的,您可能需要终止有问题的查询或连接,但要考虑回滚更改。为了防止将来出现这种问题,请努力减少事务的大小和持续时间,检查索引,并考虑READ COMMITTED
事务隔离级别。
在下一个案例研究中,将研究一个相关的问题,其中两个事务有一个循环锁等待图——通常称为死锁。
十六、案例研究:死锁
数据库管理员最担心的锁问题之一是死锁。这一部分是因为它的名字,另一部分是因为它们不像讨论的其他锁问题那样总是会导致错误。然而,与其他锁定问题相比,没有什么特别担心死锁的。相反,它们导致错误意味着您能更快地知道它们,并且锁问题会自行解决。
本章设置了一个死锁场景,并完成了一项调查,以从 InnoDB monitor 输出中的死锁信息反向工作,从而确定死锁中涉及的事务。
症状
症状很明显。死锁的受害者接收到一个错误,并且lock_deadlocks
InnoDB 度量增加。将返回给 InnoDB 选择作为受害者的事务的错误是ER_LOCK_DEADLOCK
:
ERROR: 1213: Deadlock found when trying to get lock; try restarting transaction
这个指标对于观察死锁发生的频率非常有用。跟踪lock_deadlocks
的值的一种简便方法是使用sys.metrics
视图:
mysql> SELECT *
FROM sys.metrics
WHERE Variable_name = 'lock_deadlocks'\G
*************************** 1\. row ***************************
Variable_name: lock_deadlocks
Variable_value: 2
Type: InnoDB Metrics - lock
Enabled: YES
1 row in set (0.0096 sec)
或者,您可以使用性能模式中的events_errors_summary_global_by_error
表并查询ER_LOCK_DEADLOCK
错误:
mysql> SELECT *
FROM performance_schema.events_errors_summary_global_by_error
WHERE error_name = 'ER_LOCK_DEADLOCK'\G
*************************** 1\. row ***************************
ERROR_NUMBER: 1213
ERROR_NAME: ER_LOCK_DEADLOCK
SQL_STATE: 40001
SUM_ERROR_RAISED: 5
SUM_ERROR_HANDLED: 0
FIRST_SEEN: 2020-08-01 13:09:29
LAST_SEEN: 2020-08-07 18:28:20
1 row in set (0.0083 sec)
但是请注意,这包括所有返回错误 1213 的死锁情况,而不考虑锁的类型,而lock_deadlocks
指标只包括 InnoDB 死锁。
您还可以检查 InnoDB 监控器输出中的LATEST DETECTED DEADLOCK
部分,例如,通过执行SHOW ENGINE INNODB STATUS
。这将显示上一次死锁发生的时间,因此您可以使用它来判断死锁发生的频率。如果您启用了innodb_print_all_deadlocks
选项,错误锁将有许多死锁信息的输出。在讨论了死锁的原因和设置之后,“调查”一节将详细介绍死锁的 InnoDB 监控器输出。
原因
死锁是由两个或多个事务以不同的顺序获得锁引起的。每个事务最终都持有另一个事务需要的锁。该锁可以是记录锁、间隙锁、谓词锁或插入意图锁。图 16-1 显示了一个触发死锁的循环依赖的例子。
图 16-1
触发死锁的循环锁依赖关系
图中显示的死锁是由于表主键上的两个记录锁造成的。这是可能发生的最简单的死锁之一。如图所示,在调查死锁时,循环可能比这更复杂。
设置
这个例子使用了两个连接作为上一章的例子,但是这一次两个连接都在连接 1 阻塞之前进行了更改,直到连接 2 错误地回滚其更改。连接 1 用 10%更新澳大利亚及其城市的人口,而连接 2 用达尔文市的人口更新澳大利亚人口并添加城市。这些语句如清单 16-1 所示。
-- Connection Processlist ID Thread ID Event ID
-- --------------------------------------------------
-- 1 762 1258 6
-- 2 763 1259 6
-- Connection 1
Connection 1> START TRANSACTION;
Query OK, 0 rows affected (0.0005 sec)
Connection 1> UPDATE world.city SET Population = Population * 1.10 WHERE CountryCode = 'AUS';
Query OK, 14 rows affected (0.0016 sec)
Rows matched: 14 Changed: 14 Warnings: 0
-- Connection 2
Connection 2> START TRANSACTION;
Query OK, 0 rows affected (0.0005 sec)
Connection 2> UPDATE world.country SET Population = Population + 146000 WHERE Code = 'AUS';
Query OK, 1 row affected (0.2683 sec)
Rows matched: 1 Changed: 1 Warnings: 0
-- Connection 1
Connection 1> UPDATE world.country SET Population = Population * 1.10 WHERE Code = 'AUS';
-- Connection 2
Connection 2> INSERT INTO world.city VALUES (4080, 'Darwin', 'AUS', 'Northern Territory', 146000);
ERROR: 1213: Deadlock found when trying to get lock; try restarting transaction
-- Connection 1
Query OK, 1 row affected (0.1021 sec)
Rows matched: 1 Changed: 1 Warnings: 0
-- Connection 2
Connection 2> ROLLBACK;
Query OK, 0 rows affected (0.0003 sec)
-- Connection 1
Connection 1> ROLLBACK;
Query OK, 0 rows affected (0.0545 sec)
Listing 16-1Triggering an InnoDB deadlock
关键是这两个事务更新了city
和country
表,但是顺序相反。设置通过显式回滚这两个事务来完成,以确保表保持不变。
调查
分析死锁的主要工具是 InnoDB monitor 输出中有关最新检测到的死锁的信息部分。如果您启用了innodb_print_all_deadlocks
选项(默认情况下为OFF
,您还可以从错误日志中获得死锁信息;然而,信息是相同的,因此它不改变分析。
死锁信息包含描述死锁和结果的四个部分。这些零件是
-
当死锁发生时。
-
死锁中涉及的第一个事务的信息。
-
死锁所涉及的第二个事务的信息。
-
哪个事务被回滚。当
innodb_print_all_deadlocks
启用时,该信息不包括在错误日志中。
两个事务的编号是任意的,主要目的是能够引用一个事务或另一个事务。包含事务信息的两个部分是最重要的部分。它们包括事务处于活动状态的时间长度、关于事务大小的一些统计信息(根据所使用的锁和撤销日志条目等)、正在阻塞等待锁的查询,以及关于死锁中所涉及的锁的信息。
锁信息不像使用data_locks
和data_lock_waits
表以及sys.innodb_lock_waits
视图时那么容易解释。然而,一旦你尝试进行几次分析,这并不太难。
Tip
在测试系统中故意创建一些死锁,并研究由此产生的死锁信息。然后通过信息来确定死锁发生的原因。因为您知道查询,所以更容易解释锁数据。
对于这个死锁调查,考虑清单 16-2 中显示的 InnoDB 监控器的死锁部分。清单相当长,行也很宽,所以信息也可以在本书的 GitHub 存储库中作为listing_16_2_deadlock.txt
获得,所以您可以在自己选择的文本编辑器中打开输出。
-- Investigation #1
-- Connection 3
Connection 3> SHOW ENGINE INNODB STATUS\G
*************************** 1\. row ***************************
...
------------------------
LATEST DETECTED DEADLOCK
------------------------
2020-08-07 20:08:55 0x9f0
*** (1) TRANSACTION:
TRANSACTION 537544, ACTIVE 0 sec starting index read
mysql tables in use 1, locked 1
LOCK WAIT 6 lock struct(s), heap size 1136, 30 row lock(s), undo log entries 14
MySQL thread id 762, OS thread handle 10344, query id 3282590 localhost ::1 root updating
UPDATE world.country SET Population = Population * 1.10 WHERE Code = 'AUS'
*** (1) HOLDS THE LOCK(S):
RECORD LOCKS space id 1923 page no 14 n bits 1272 index CountryCode of table `world`.`city` trx id 537544 lock_mode X locks gap before rec
Record lock, heap no 603 PHYSICAL RECORD: n_fields 2; compact format; info bits 0
0: len 3; hex 415554; asc AUT;;
1: len 4; hex 800005f3; asc ;;
*** (1) WAITING FOR THIS LOCK TO BE GRANTED:
RECORD LOCKS space id 1924 page no 5 n bits 120 index PRIMARY of table `world`.`country` trx id 537544 lock_mode X locks rec but not gap waiting
Record lock, heap no 16 PHYSICAL RECORD: n_fields 17; compact format; info bits 0
0: len 3; hex 415553; asc AUS;;
1: len 6; hex 0000000833c9; asc 3 ;;
2: len 7; hex 02000001750a3c; asc u <;;
3: len 30; hex 4175737472616c6961202020202020202020202020202020202020202020; asc Australia ; (total 52 bytes);
4: len 1; hex 05; asc ;;
5: len 26; hex 4175737472616c696120616e64204e6577205a65616c616e6420; asc Australia and New Zealand ;;
6: len 5; hex 80761f2400; asc v $ ;;
7: len 2; hex 876d; asc m;;
8: len 4; hex 812267c0; asc "g ;;
9: len 2; hex cf08; asc ;;
10: len 5; hex 80055bce00; asc [ ;;
11: len 5; hex 8005fecf00; asc ;;
12: len 30; hex 4175737472616c6961202020202020202020202020202020202020202020; asc Australia ; (total 45 bytes);
13: len 30; hex 436f6e737469747574696f6e616c204d6f6e61726368792c204665646572; asc Constitutional Monarchy, Feder; (total 45 bytes);
14: len 30; hex 456c69736162657468204949202020202020202020202020202020202020; asc Elisabeth II ; (total 60 bytes);
15: len 4; hex 80000087; asc ;;
16: len 2; hex 4155; asc AU;;
*** (2) TRANSACTION:
TRANSACTION 537545, ACTIVE 0 sec inserting
mysql tables in use 1, locked 1
LOCK WAIT 4 lock struct(s), heap size 1136, 2 row lock(s), undo log entries 2
MySQL thread id 763, OS thread handle 37872, query id 3282591 localhost ::1 root update
INSERT INTO world.city VALUES (4080, 'Darwin', 'AUS', 'Northern Territory', 146000)
*** (2) HOLDS THE LOCK(S):
RECORD LOCKS space id 1924 page no 5 n bits 120 index PRIMARY of table `world`.`country` trx id 537545 lock_mode X locks rec but not gap
Record lock, heap no 16 PHYSICAL RECORD: n_fields 17; compact format; info bits 0
0: len 3; hex 415553; asc AUS;;
1: len 6; hex 0000000833c9; asc 3 ;;
2: len 7; hex 02000001750a3c; asc u <;;
3: len 30; hex 4175737472616c6961202020202020202020202020202020202020202020; asc Australia ; (total 52 bytes);
4: len 1; hex 05; asc ;;
5: len 26; hex 4175737472616c696120616e64204e6577205a65616c616e6420; asc Australia and New Zealand ;;
6: len 5; hex 80761f2400; asc v $ ;;
7: len 2; hex 876d; asc m;;
8: len 4; hex 812267c0; asc "g ;;
9: len 2; hex cf08; asc ;;
10: len 5; hex 80055bce00; asc [ ;;
11: len 5; hex 8005fecf00; asc ;;
12: len 30; hex 4175737472616c6961202020202020202020202020202020202020202020; asc Australia ; (total 45 bytes);
13: len 30; hex 436f6e737469747574696f6e616c204d6f6e61726368792c204665646572; asc Constitutional Monarchy, Feder; (total 45 bytes);
14: len 30; hex 456c69736162657468204949202020202020202020202020202020202020; asc Elisabeth II ; (total 60 bytes);
15: len 4; hex 80000087; asc ;;
16: len 2; hex 4155; asc AU;;
*** (2) WAITING FOR THIS LOCK TO BE GRANTED:
RECORD LOCKS space id 1923 page no 14 n bits 1272 index CountryCode of table `world`.`city` trx id 537545 lock_mode X locks gap before rec insert intention waiting
Record lock, heap no 603 PHYSICAL RECORD: n_fields 2; compact format; info bits 0
0: len 3; hex 415554; asc AUT;;
1: len 4; hex 800005f3; asc ;;
*** WE ROLL BACK TRANSACTION (2)
Listing 16-2Example of the information for a detected deadlock
死锁发生在 2020 年 8 月 7 日,服务器时区 20:08:55。您可以使用此信息来查看该信息是否与用户报告的死锁相同。
有趣的部分是两个事务的信息。您可以看到,事务 1 正在用Code = 'AUS'
更新国家的人口:
UPDATE world.country SET Population = Population * 1.10 WHERE Code = 'AUS'
事务 2 试图插入一个新的城市:
INSERT INTO world.city VALUES (4080, 'Darwin', 'AUS', 'Northern Territory', 146000)
这是一个死锁涉及多个表的情况。虽然这两个查询在不同的表上工作,但它本身并不能证明涉及到更多的查询,因为外键可以触发一个查询在两个表上获取锁。不过在本例中,Code
列是country
表的主键,唯一涉及的外键是从city
表的CountryCode
列到country
表的Code
列(显示这是留给使用world
示例数据库的读者的一个练习)。所以两个查询不太可能自己死锁。
Note
在 MySQL 8.0.17 和更早的版本中,死锁信息包含的有关锁的信息更少。如果您仍在使用早期版本,升级将使调查死锁变得更加容易。
接下来要观察的是正在等待什么锁。事务 1 等待对country
表的主键的排他锁:
RECORD LOCKS space id 1924 page no 5 n bits 120 index PRIMARY of table `world`.`country` trx id 537544 lock_mode X locks rec but not gap waiting
主键的值可以在该信息后面的信息中找到。由于 InnoDB 包含了与记录相关的所有信息,这看起来有点让人不知所措。因为它是主键记录,所以包含整行。这有助于理解行中的数据,特别是如果主键本身不包含这些信息,但是当您第一次看到它时,可能会感到困惑。country
表的主键是表的第一列,所以它是记录信息的第一行,包含锁请求的主键的值:
0: len 3; hex 415553; asc AUS;;
InnoDB 以十六进制表示法包含该值,但也试图将其解码为一个字符串,因此这里很明显该值是“AUS”,这并不奇怪,因为它也在查询的WHERE
子句中。这并不总是那么明显,所以您应该总是确认锁输出的值。您还可以从信息中看到,该列在索引中是按升序排序的。
事务 2 等待对city
表的CountryCode
索引的插入意图锁:
RECORD LOCKS space id 1923 page no 14 n bits 1272 index CountryCode of table `world`.`city` trx id 537545 lock_mode X locks gap before rec insert intention waiting
您可以看到锁定请求在记录之前包含一个间隙。在这种情况下,锁信息更简单,因为CountryCode
索引中只有两列,即CountryCode
列和主键(ID
列),因为CountryCode
索引是非唯一的二级索引。指标有效(CountryCode
、ID
),记录前间隙值如下:
0: len 3; hex 415554; asc AUT;;
1: len 4; hex 800005f3; asc ;;
这表明CountryCode
的值是“AUT ”,这并不奇怪,因为当按字母升序排序时,它是“AUS”之后的下一个值。ID
列的值是十六进制值 0x5f3,十进制值是 1523。如果您查询带有CountryCode = AUT
的城市,并按照CountryCode
索引的顺序对它们进行排序,您可以看到ID = 1523
是找到的第一个城市:
-- Investigation #3
Connection 3> SELECT *
FROM world.city
WHERE CountryCode = 'AUT'
ORDER BY CountryCode, ID
LIMIT 1;
+------+------+-------------+----------+------------+
| ID | Name | CountryCode | District | Population |
+------+------+-------------+----------+------------+
| 1523 | Wien | AUT | Wien | 1608144 |
+------+------+-------------+----------+------------+
1 row in set (0.2673 sec)
目前为止,一切顺利。因为事务正在等待这些锁,所以当然可以推断出另一个事务持有锁。在 8.0.18 及更高版本中,InnoDB 包含了两个事务持有的锁的完整列表;在早期版本中,InnoDB 只为其中一个事务显式地包含这个查询,所以您需要确定事务还执行了哪些其他查询。
根据现有的信息,你可以做出一些有根据的猜测。例如,INSERT
语句被CountryCode
索引上的间隙锁阻塞。使用条件CountryCode = 'AUS'
的查询就是一个使用该间隙锁的查询示例。死锁信息还包括关于拥有事务的两个连接的信息,这些信息可能对您有所帮助:
MySQL thread id 762, OS thread handle 10344, query id 3282590 localhost ::1 root updating
MySQL thread id 763, OS thread handle 37872, query id 3282591 localhost ::1 root update
您可以看到这两个连接都是使用root@localhost
帐户建立的。如果您确保每个应用和角色有不同的用户,该帐户可以帮助您缩小执行事务的用户范围。
如果连接仍然存在,您还可以使用性能模式中的events_statements_history
表来查找连接执行的最新查询。这可能不是死锁所涉及的那些人,这取决于该连接是否被用于更多的查询,但是仍然可以提供该连接用途的线索。如果连接不再存在,原则上您可以在events_statements_history_long
表中找到查询,但是您需要将“MySQL 线程 id”(连接 ID)映射到 Performance Schema 线程 ID,这是很难做到的。另外,events_statements_history_long
消费者在默认情况下是不启用的。
在这种特殊情况下,两个连接仍然存在,除了回滚事务之外,它们没有做任何事情。清单 16-3 展示了如何找到事务中涉及的查询。请注意,在实际情况下,查询可能会返回比这里显示的更多的行,因为不可能在event_id
上添加过滤器。
-- Investigation #4
Connection 3> SELECT sql_text, nesting_event_id,
nesting_event_type, mysql_errno,
IFNULL(error_name, '') AS error,
message_text
FROM performance_schema.events_statements_history
LEFT OUTER JOIN performance_schema.events_errors_summary_global_by_error
ON error_number = mysql_errno
WHERE thread_id = PS_THREAD_ID(762)
AND event_id > 6
ORDER BY event_id\G
*************************** 1\. row ***************************
sql_text: start transaction
nesting_event_id: NULL
nesting_event_type: NULL
mysql_errno: 0
error:
message_text: NULL
*************************** 2\. row ***************************
sql_text: UPDATE world.city SET Population = Population * 1.10 WHERE CountryCode = 'AUS'
nesting_event_id: 8
nesting_event_type: TRANSACTION
mysql_errno: 0
error:
message_text: Rows matched: 14 Changed: 14 Warnings: 0
*************************** 3\. row ***************************
sql_text: UPDATE world.country SET Population = Population * 1.10 WHERE Code = 'AUS'
nesting_event_id: 8
nesting_event_type: TRANSACTION
mysql_errno: 0
error:
message_text: Rows matched: 1 Changed: 1 Warnings: 0
*************************** 4\. row ***************************
sql_text: rollback
nesting_event_id: 8
nesting_event_type: TRANSACTION
mysql_errno: 0
error:
message_text: NULL
4 rows in set (0.0016 sec)
-- Investigation #5
Connection 3> SELECT sql_text, nesting_event_id,
nesting_event_type, mysql_errno,
IFNULL(error_name, '') AS error,
message_text
FROM performance_schema.events_statements_history
LEFT OUTER JOIN performance_schema.events_errors_summary_global_by_error
ON error_number = mysql_errno
WHERE thread_id = PS_THREAD_ID(763)
AND event_id > 6
ORDER BY event_id\G
*************************** 1\. row ***************************
sql_text: start transaction
nesting_event_id: NULL
nesting_event_type: NULL
mysql_errno: 0
error:
message_text: NULL
*************************** 2\. row ***************************
sql_text: UPDATE world.country SET Population = Population + 146000 WHERE Code = 'AUS'
nesting_event_id: 8
nesting_event_type: TRANSACTION
mysql_errno: 0
error:
message_text: Rows matched: 1 Changed: 1 Warnings: 0
*************************** 3\. row ***************************
sql_text: INSERT INTO world.city VALUES (4080, 'Darwin', 'AUS', 'Northern Territory', 146000)
nesting_event_id: 8
nesting_event_type: TRANSACTION
mysql_errno: 1213
error: ER_LOCK_DEADLOCK
message_text: Deadlock found when trying to get lock; try restarting transaction
*************************** 4\. row ***************************
sql_text: SHOW WARNINGS
nesting_event_id: NULL
nesting_event_type: NULL
mysql_errno: 0
error:
message_text: NULL
*************************** 5\. row ***************************
sql_text: rollback
nesting_event_id: NULL
nesting_event_type: NULL
mysql_errno: 0
error:
message_text: NULL
5 rows in set (0.0010 sec)
Listing 16-3Finding the queries involved in the deadlock
注意,对于连接 id 763(第二个事务),包含了 MySQL 错误号,第三行将其设置为 1213——这是一个死锁。当遇到错误时,MySQL Shell 自动执行一个SHOW WARNINGS
语句,即第 4 行中的语句。还要注意,嵌套事件是事务 2 的ROLLBACK
的NULL
,而不是事务 1 的ROLLBACK
。这是因为死锁触发了整个事务的回滚(所以事务 2 的ROLLBACK
没有做任何事情)。
死锁是由事务 1 首先更新city
表的填充,然后更新country
表的填充触发的。事务 2 首先更新了country
表的人口,然后试图将一个新的城市插入到city
表中。这是两个工作流以不同顺序更新记录的典型例子,因此容易出现死锁。
总结调查,它包括两个步骤:
-
分析来自 InnoDB 的死锁信息,以确定死锁中涉及的锁,并获得尽可能多的关于连接的信息。
-
使用其他来源(如性能模式)来查找有关事务中查询的更多信息。通常有必要分析应用以获得查询列表。
现在您已经知道是什么触发了死锁,那么解决这个问题需要什么呢?
解决方案
死锁是最容易解决的锁情况,因为 InnoDB 会自动选择一个事务作为受害者并回滚它。在前面讨论的死锁中,事务 2 被选为受害者,这可以从死锁输出中看出:
*** WE ROLL BACK TRANSACTION (2)
这意味着对于事务 1,没有什么可做的。事务 2 回滚后,事务 1 可以继续并完成其工作。
对于事务 2,InnoDB 已经回滚了整个事务,所以您需要做的就是重试该事务。记住再次执行所有查询,而不是依赖第一次尝试时返回的值;否则,您可能会使用过时的值。
Tip
时刻准备处理死锁和锁等待超时。对于死锁或当事务在锁等待超时后回滚时,请重试整个事务。对于仅回滚查询的锁等待超时,重试查询可能会增加延迟。
如果死锁相对很少发生,您实际上不需要做更多的事情。死锁是生活中的现实,所以不要因为遇到一些死锁而惊慌。如果死锁造成了重大影响,您需要考虑进行一些更改来防止某些死锁。
预防
减少死锁与减少记录锁争用非常相似,只是在整个应用中以相同的顺序获取锁非常重要。建议再次阅读关于减少锁定问题的第 9 章:
-
通过将大型事务分成几个较小的事务,并添加索引以减少锁的数量,来减少每个事务所做的工作。
-
如果事务隔离级别适合于您的应用来减少锁的数量和它们被持有的时间,那么可以考虑使用它。
-
确保事务只在尽可能短的时间内保持开放。
-
以相同的顺序访问记录,如果需要的话,可以通过执行
SELECT ... FOR UPDATE
或SELECT ... FOR SHARE
查询来抢占锁。
减少死锁的要点是减少锁的数量和持有锁的时间,并以相同的顺序获取锁。
摘要
在这个案例研究中,死锁是通过模拟一个更新一个国家中所有城市人口的工作负载,然后更新该国家的人口而产生的。同时,另一个连接将一个新城市添加到同一个国家,但首先更新该国的人口,然后插入新城市。这是一个经典的例子,说明了为什么使用相同表但顺序相反的两个不同工作流会出现死锁。
主要使用 InnoDB 监控器输出的LATEST DETECTED DEADLOCK
部分来调查死锁。由此可以看出涉及到哪些连接、它们执行的最后一条语句、它们持有的锁以及它们正在等待的锁。此外,带有语句历史的性能模式表用于查找事务中涉及的确切语句;但是,通常情况下,您没有这种能力,必须分析应用来确定所涉及的语句。
发生死锁时的好消息是,它会通过回滚其中一个事务来自动解决自身问题,以便另一个事务可以继续。然后,您必须重试受害者事务。如果您有太多的死锁,关键是要减少锁的数量和它们的持续时间,并确保您在不同的任务中以相同的顺序使用锁。
在下一章中,您将研究外键引起锁争用的情况。
十七、案例研究:外键
最难调查的锁争用情况之一发生在涉及外键的时候,因为您可以对不同的表内容查询相同的锁。本案例研究调查了一个由于外键而导致元数据和 InnoDB 记录锁定的例子。由于症状和原因与第章第 14 节讨论元数据锁的案例研究和第章第 15 节讨论 InnoDB 记录锁的案例研究相同,因此在本讨论中省略。
设置
这个案例研究比前几个更复杂,没有一个简单的方法可以自行重现。但是,MySQL Shell 的concurrency_book
模块中的Listing
17-1
工作负载将允许您重现争用。工作负载由五个连接组成:
-
更新
sakila.customer
表的两个连接,以这种方式,总是有一个事务正在进行,并且在表上有一个元数据和记录锁。在COMMIT
之前有一个休眠,以确保持续时间足够长,以避免竞争情况。休眠的持续时间可以在工作负载执行期间进行配置。 -
一个在
sakila.inventory
表上执行ALTER TABLE
的连接。这个用lock_wait_timeout = 1
。 -
一个更新
sakila.film_category
表的连接。 -
一个更新
sakila.category
表的连接。这个用的是innodb_lock_wait_timeout = 1
。
当您执行工作负载时,在输入密码之后,您将被要求输入测试的运行时间,以及更新sakila.customer
表的两个连接应该休眠多长时间来提交它们的事务。睡眠被指定为乘以 0.1 秒的因子。
Note
测试不具有确定性,因为即使重现相同的问题,您也应该预期会看到不同的数据。
测试开始后,将显示各种监控输出,以便您可以调查问题。
清单 17-1 显示了工作负载的一个示例执行的部分输出(遇到的锁的确切数量和各种指标的值将因执行而异)。完整的输出可以在本书的 GitHub 库的listing_17-1.txt
中找到。本章剩余部分的讨论中也会用到输出的几个部分。
Specify the number of seconds to run for (10-3600) [15]:
Specify the sleep factor (0-30) [15]:
-- Connection Processlist ID Thread ID Event ID
-- --------------------------------------------------
-- 1 462 792 6
-- 2 463 793 6
-- 3 464 794 6
-- 4 465 795 6
-- 5 466 796 6
mysql> SELECT error_number, error_name, sum_error_raised
FROM performance_schema.events_errors_summary_global_by_error
WHERE error_name IN ('ER_LOCK_WAIT_TIMEOUT', 'ER_LOCK_DEADLOCK');
+--------------+----------------------+------------------+
| error_number | error_name | sum_error_raised |
+--------------+----------------------+------------------+
| 1205 | ER_LOCK_WAIT_TIMEOUT | 310 |
| 1213 | ER_LOCK_DEADLOCK | 12 |
+--------------+----------------------+------------------+
...
mysql> UPDATE sakila.category SET name = IF(name = 'Travel', 'Exploring', 'Travel') WHERE category_id = 16;
ERROR: 1205: Lock wait timeout exceeded; try restarting transaction
mysql> ALTER TABLE sakila.inventory FORCE;
ERROR: 1205: Lock wait timeout exceeded; try restarting transaction -- Metrics reported by rate collected during the test:
time,innodb_row_lock_time,innodb_row_lock_waits,lock_deadlocks,lock_timeouts
2020-08-02 14:17:12.168000,0.0,0.0,0.0,0.0
2020-08-02 14:17:13.180000,0.0,0.0,0.0,0.0
2020-08-02 14:17:14.168000,0.0,1.0121457489878543,0.0,0.0
2020-08-02 14:17:15.177000,0.0,0.0,0.0,0.0
2020-08-02 14:17:16.168000,2019.1725529767912,1.0090817356205852,0.0,1.0090817356205852
2020-08-02 14:17:17.169000,0.0,0.0,0.0,0.0
2020-08-02 14:17:18.180000,1541.0484668644908,0.0,0.0,0.9891196834817014
2020-08-02 14:17:19.180000,0.0,0.0,0.0,0.0
2020-08-02 14:17:20.168000,0.0,0.0,0.0,0.0
2020-08-02 14:17:21.180000,0.0,0.0,0.0,0.0
2020-08-02 14:17:22.168000,82.99595141700405,2.0242914979757085,0.0,0.0
2020-08-02 14:17:23.179000,0.0,0.0,0.0,0.0
2020-08-02 14:17:24.180000,1997.0029970029973,0.9990009990009991,0.0,0.9990009990009991
2020-08-02 14:17:25.179000,0.0,0.0,0.0,0.0
2020-08-02 14:17:26.182000,2115.6530408773683,0.9970089730807579,0.0,0.9970089730807579
2020-08-02 14:17:27.180000,0.0,0.0,0.0,0.0
2020-08-02 14:17:28.168000,0.0,0.0,0.0,0.0
2020-08-02 14:17:29.180000,0.0,0.0,0.0,0.0
2020-08-02 14:17:30.168000,66.80161943319838,2.0242914979757085,0.0,0.0
mysql> SELECT error_number, error_name, sum_error_raised
FROM performance_schema.events_errors_summary_global_by_error
WHERE error_name IN ('ER_LOCK_WAIT_TIMEOUT', 'ER_LOCK_DEADLOCK');
+--------------+----------------------+------------------+
| error_number | error_name | sum_error_raised |
+--------------+----------------------+------------------+
| 1205 | ER_LOCK_WAIT_TIMEOUT | 317 |
| 1213 | ER_LOCK_DEADLOCK | 12 |
+--------------+----------------------+------------------+
...
2020-08-02 14:17:30.664018 0 [INFO] Stopping the threads.
2020-08-02 14:17:33.818122 0 [INFO] Completing the workload Listing 17-1
2020-08-02 14:17:33.820075 0 [INFO] Disconnecting for the workload Listing 17-1
2020-08-02 14:17:33.820075 0 [INFO] Completed the workload Listing 17-1
Listing 17-1Locks and foreign keys
首先,提示运行时和睡眠因素的问题。对于这个讨论,默认值将起作用,但是鼓励您尝试其他设置来进行您自己的测试。特别的,将睡眠因子降低到 8 或更低会使ALTER TABLE
开始成功,你会看到ER_LOCK_DEADLOCK
计数器递增;这是一个元数据死锁。
Note
由于 MySQL Shell 中的并发工作负载并不完全是线程安全的,因此有时有必要重试测试。
其次,打印一些初始监控信息。在测试结束时执行相同的监控,因此您可以获得关于发生的错误数量和其他指标的信息。测试结束时的监控信息还包括 CSV 格式的信息,您可以将其复制到电子表格中,例如,为其创建图表。
否则,输出包含关于元数据锁和锁等待的信息,以及经历锁等待超时的语句。
讨论
调查通常会经历几个步骤。首先,将涵盖应用和监控记录的错误。其次,讨论锁指标。第三,介绍了元数据锁,最后讨论了 InnoDB 锁争用。
错误和高级监控
您可能注意到的第一件事是应用遇到了错误;在这种情况下,它们是锁等待超时。在现实世界中,您可能不会像在本案例研究中那样直接获得错误。如果您不处理这些错误,您可能会看到应用出错,甚至崩溃。务必处理错误,最好记录错误,这样您就可以跟踪应用遇到的问题,并且可以使用 Splunk 1 等日志分析器来分析错误的频率。本案例研究中的错误示例包括
mysql> UPDATE sakila.category SET name = IF(name = 'Travel', 'Exploring', 'Travel') WHERE category_id = 16;
ERROR: 1205: Lock wait timeout exceeded; try restarting transaction
mysql> ALTER TABLE sakila.inventory FORCE;
ERROR: 1205: Lock wait timeout exceeded; try restarting transaction
您还可以查看您的监控,其中应该包括与图 17-1 所示类似的关于 InnoDB 锁等待的信息,即 InnoDB 锁等待的次数、InnoDB 锁等待超时的次数以及在测试期间测量的 InnoDB 锁时间(毫秒)。
图 17-1
InnoDB 锁等待指标
当前等待是直接测量,而锁超时和锁时间是与前一次测量的差异。当前等待和锁定超时使用左侧的 y 轴,并由条形表示,而锁定时间使用右侧的 y 轴显示在折线图中。x 轴是测试开始的时间。
从图中可以看到锁定问题是间歇性的,您可以使用它来确定问题发生的时间。如果您有一个监控解决方案,允许您查看在给定的时间间隔内哪些查询正在运行,那么您可以使用它来调查导致锁等待的工作负载。支持这一点的监控解决方案的例子有 MySQL Enterprise Monitor(也称为 MEM) 2 、Solarwinds 数据库性能监控器(DPM,以前的 VividCortex) 3 和 Percona 监控和管理(PMM)。 4
锁定指标
通过监控锁指标,可以很容易地发现锁争用增加的时段,并且值得进一步讨论这些指标。对于 InnoDB 来说,监控锁等待很简单,就像刚才显示的那样,但是不幸的是,对于元数据锁来说,没有可以轻松给出相同信息的度量标准。您可以使用性能模式中的错误统计信息来跟踪锁等待超时和死锁的数量:
mysql> SELECT error_number, error_name, sum_error_raised
FROM performance_schema.events_errors_summary_global_by_error
WHERE error_name IN ('ER_LOCK_WAIT_TIMEOUT', 'ER_LOCK_DEADLOCK');
+--------------+----------------------+------------------+
| error_number | error_name | sum_error_raised |
+--------------+----------------------+------------------+
| 1205 | ER_LOCK_WAIT_TIMEOUT | 310 |
| 1213 | ER_LOCK_DEADLOCK | 12 |
+--------------+----------------------+------------------+
...
mysql> SELECT error_number, error_name, sum_error_raised
FROM performance_schema.events_errors_summary_global_by_error
WHERE error_name IN ('ER_LOCK_WAIT_TIMEOUT', 'ER_LOCK_DEADLOCK');
+--------------+----------------------+------------------+
| error_number | error_name | sum_error_raised |
+--------------+----------------------+------------------+
| 1205 | ER_LOCK_WAIT_TIMEOUT | 317 |
| 1213 | ER_LOCK_DEADLOCK | 12 |
+--------------+----------------------+------------------+
这表明在这个测试中,有七次锁等待超时,没有死锁。这个信息的问题是,它没有告诉您是元数据锁、InnoDB 锁还是第三种锁类型经历了锁等待超时或死锁。也就是说,由于 InnoDB 有自己的锁等待超时和死锁统计数据,您可以通过减去这两个统计数据来获得非 InnoDB 锁的数量。清单 17-2 中显示了测试的 InnoDB 锁统计数据。
mysql> SELECT Variable_name, Variable_value
FROM sys.metrics
WHERE Variable_name IN (
'innodb_row_lock_current_waits',
'lock_row_lock_current_waits',
'innodb_row_lock_time',
'innodb_row_lock_waits',
'lock_deadlocks',
'lock_timeouts'
);
+-------------------------------+----------------+
| Variable_name | Variable_value |
+-------------------------------+----------------+
| innodb_row_lock_current_waits | 0 |
| innodb_row_lock_time | 409555 |
| innodb_row_lock_waits | 384 |
| lock_deadlocks | 0 |
| lock_row_lock_current_waits | 0 |
| lock_timeouts | 188 |
+-------------------------------+----------------+
...
mysql> SELECT Variable_name, Variable_value
FROM sys.metrics
WHERE Variable_name IN (
'innodb_row_lock_current_waits',
'lock_row_lock_current_waits',
'innodb_row_lock_time',
'innodb_row_lock_waits',
'lock_deadlocks',
'lock_timeouts'
)
+-------------------------------+----------------+
| Variable_name | Variable_value |
+-------------------------------+----------------+
| innodb_row_lock_current_waits | 1 |
| innodb_row_lock_time | 417383 |
| innodb_row_lock_waits | 392 |
| lock_deadlocks | 0 |
| lock_row_lock_current_waits | 1 |
| lock_timeouts | 192 |
+-------------------------------+----------------+
Listing 17-2The InnoDB lock statistics for the test
在这里,您可以看到在总共七个ER_LOCK_WAIT_TIMEOUT
错误中,总共有四个 InnoDB 锁等待超时(lock_timeouts
状态计数器),因此您可以得出结论,有三个非 InnoDB 锁等待超时。在这个案例研究中,这些都是元数据锁定等待超时。
元数据锁争用
实际上,如果您能在锁争用正在进行时捕捉到它,这是最好的,如前面的案例研究所示。在这个例子的输出中,有来自performance_schema.metadata_locks
表以及schema_table_lock_waits
和innodb_lock_waits sys
模式视图的输出。metadata_locks
表突出显示了元数据锁定的范围,这可以从清单 17-3 中看出。
mysql> SELECT object_name, lock_type, lock_status,
owner_thread_id, owner_event_id
FROM performance_schema.metadata_locks
WHERE object_type = 'TABLE'
AND object_schema = 'sakila'
ORDER BY owner_thread_id, object_name, lock_type\G
*************************** 1\. row ***************************
object_name: category
lock_type: SHARED_READ
lock_status: GRANTED
owner_thread_id: 792
owner_event_id: 9
*************************** 2\. row ***************************
object_name: film
lock_type: SHARED_READ
lock_status: GRANTED
owner_thread_id: 792
owner_event_id: 9
*************************** 3\. row ***************************
object_name: film_category
lock_type: SHARED_WRITE
lock_status: GRANTED
owner_thread_id: 792
owner_event_id: 9
*************************** 4\. row ***************************
object_name: category
lock_type: SHARED_WRITE
lock_status: GRANTED
owner_thread_id: 793
owner_event_id: 9
*************************** 5\. row ***************************
object_name: film
lock_type: SHARED_READ
lock_status: GRANTED
owner_thread_id: 793
owner_event_id: 9
*************************** 6\. row ***************************
object_name: film_category
lock_type: SHARED_WRITE
lock_status: GRANTED
owner_thread_id: 793
owner_event_id: 9
*************************** 7\. row ***************************
object_name: address
lock_type: SHARED_READ
lock_status: GRANTED
owner_thread_id: 794
owner_event_id: 10
*************************** 8\. row ***************************
object_name: customer
lock_type: SHARED_WRITE
lock_status: GRANTED
owner_thread_id: 794
owner_event_id: 10
*************************** 9\. row ***************************
object_name: inventory
lock_type: SHARED_READ
lock_status: GRANTED
owner_thread_id: 794
owner_event_id: 10
*************************** 10\. row ***************************
object_name: payment
lock_type: SHARED_WRITE
lock_status: GRANTED
owner_thread_id: 794
owner_event_id: 10
*************************** 11\. row ***************************
object_name: rental
lock_type: SHARED_WRITE
lock_status: GRANTED
owner_thread_id: 794
owner_event_id: 10
*************************** 12\. row ***************************
object_name: staff
lock_type: SHARED_READ
lock_status: GRANTED
owner_thread_id: 794
owner_event_id: 10
*************************** 13\. row ***************************
object_name: store
lock_type: SHARED_READ
lock_status: GRANTED
owner_thread_id: 794
owner_event_id: 10
*************************** 14\. row ***************************
object_name: address
lock_type: SHARED_READ
lock_status: GRANTED
owner_thread_id: 795
owner_event_id: 10
*************************** 15\. row ***************************
object_name: customer
lock_type: SHARED_WRITE
lock_status: GRANTED
owner_thread_id: 795
owner_event_id: 10
*************************** 16\. row ***************************
object_name: inventory
lock_type: SHARED_READ
lock_status: PENDING
owner_thread_id: 795
owner_event_id: 10
*************************** 17\. row ***************************
object_name: payment
lock_type: SHARED_WRITE
lock_status: GRANTED
owner_thread_id: 795
owner_event_id: 10
*************************** 18\. row ***************************
object_name: rental
lock_type: SHARED_WRITE
lock_status: GRANTED
owner_thread_id: 795
owner_event_id: 10
*************************** 19\. row ***************************
object_name: staff
lock_type: SHARED_READ
lock_status: GRANTED
owner_thread_id: 795
owner_event_id: 10
*************************** 20\. row ***************************
object_name: store
lock_type: SHARED_READ
lock_status: GRANTED
owner_thread_id: 795
owner_event_id: 10
*************************** 21\. row ***************************
object_name: #sql-35e8_1d2
lock_type: EXCLUSIVE
lock_status: GRANTED
owner_thread_id: 796
owner_event_id: 9
*************************** 22\. row ***************************
object_name: film
lock_type: SHARED_UPGRADABLE
lock_status: GRANTED
owner_thread_id: 796
owner_event_id: 9
*************************** 23\. row ***************************
object_name: inventory
lock_type: EXCLUSIVE
lock_status: PENDING
owner_thread_id: 796
owner_event_id: 9
*************************** 24\. row ***************************
object_name: inventory
lock_type: SHARED_UPGRADABLE
lock_status: GRANTED
owner_thread_id: 796
owner_event_id: 9
*************************** 25\. row ***************************
object_name: rental
lock_type: SHARED_UPGRADABLE
lock_status: GRANTED
owner_thread_id: 796
owner_event_id: 9
*************************** 26\. row ***************************
object_name: store
lock_type: SHARED_UPGRADABLE
lock_status: GRANTED
owner_thread_id: 796
owner_event_id: 9
Listing 17-3The metadata locks found during the test
两个挂起的锁(第 16 和 23 行)用于customer
表上的ALTER TABLE
和其中一个UPDATE
语句。
在收集该输出的时间点,仅 5 个线程就有 26 个被授予或挂起的元数据锁。所有语句只查询一个表(从技术上讲,ALTER TABLE
还有第二个表——在本例中是名为#sql-35e8_1d2
的表,但这是用于重建inventory
表的临时表名)。按表名对锁进行分组,您可以看到包括临时表在内的 11 个表都有元数据锁(这些数字可能与前面的输出不一致,因为它们不是在完全相同的时间生成的):
mysql> SELECT object_name, COUNT(*)
FROM performance_schema.metadata_locks
WHERE object_type = 'TABLE'
AND object_schema = 'sakila'
GROUP BY object_name
ORDER BY object_name;
+---------------+----------+
| object_name | COUNT(*) |
+---------------+----------+
| #sql-35e8_1d2 | 1 |
| address | 2 |
| category | 2 |
| customer | 2 |
| film | 3 |
| film_category | 2 |
| inventory | 4 |
| payment | 2 |
| rental | 3 |
| staff | 2 |
| store | 3 |
+---------------+----------+
所有这些表受到影响的原因是,sakila
模式大量使用外键。图 17-2 显示了这些表及其外键关系。在该图中,只包括属于表主键或外键的列。
图 17-2
测试中表之间的关系
为了找到导致ALTER TABLE
锁定等待超时的语句,最简单的方法是使用第 14 章中讨论的sys.schema_table_lock_waits
视图。这些步骤留给读者作为练习。冲突的语句是customer
表上的更新和inventory
表上的ALTER TABLE
。
InnoDB 锁争用
当您考虑上一节中表之间的外键关系时,很容易得出这样的结论,即category
表上的UPDATE
语句的锁等待超时也是由于从inventory
表上的ALTER TABLE
级联的元数据锁造成的。然而,在做出这样的结论之前,你必须小心谨慎,研究事实——而在这种情况下,结论是错误的。
如果查看来自metadata_locks
表的信息,可以看到没有一个挂起的锁是针对category
表的:
*************************** 16\. row ***************************
object_name: inventory
lock_type: SHARED_READ
lock_status: PENDING
owner_thread_id: 795
owner_event_id: 10
...
*************************** 23\. row ***************************
object_name: inventory
lock_type: EXCLUSIVE
lock_status: PENDING
owner_thread_id: 796
owner_event_id: 9
这是这里的关键信息。虽然模式知识很重要,但是您应该从查看锁等待信息开始,然后使用模式知识来理解为什么会发生锁,而不是试图根据模式知识猜测可能存在什么锁。
前面讨论的监控确实显示存在 InnoDB 锁等待超时,清单 17-4 中的sys.innodb_lock_waits
输出显示了哪些是冲突的锁和语句。
mysql> SELECT * FROM sys.innodb_lock_waits\G
*************************** 1\. row ***************************
wait_started: 2020-08-02 14:17:13
wait_age: 00:00:02
wait_age_secs: 2
locked_table: `sakila`.`category`
locked_table_schema: sakila
locked_table_name: category
locked_table_partition: None
locked_table_subpartition: None
locked_index: PRIMARY
locked_type: RECORD
waiting_trx_id: 535860
waiting_trx_started: 2020-08-02 14:17:13
waiting_trx_age: 00:00:02
waiting_trx_rows_locked: 1
waiting_trx_rows_modified: 0
waiting_pid: 463
waiting_query: UPDATE sakila.category SET name = IF(name = 'Travel', 'Exploring', 'Travel') WHERE category_id = 16
waiting_lock_id: 2711671600928:1795:4:282:2711634698920
waiting_lock_mode: X,REC_NOT_GAP
blocking_trx_id: 535859
blocking_pid: 462
blocking_query: None
blocking_lock_id: 2711671600096:1795:4:282:2711634694976
blocking_lock_mode: S,REC_NOT_GAP
blocking_trx_started: 2020-08-02 14:17:13
blocking_trx_age: 00:00:02
blocking_trx_rows_locked: 5
blocking_trx_rows_modified: 2
sql_kill_blocking_query: KILL QUERY 462
sql_kill_blocking_connection: KILL 462
1 row in set (0.0017 sec)
Listing 17-4The InnoDB lock waits during the test
锁争用发生在category
表的主键上,进程列表 id 462 是阻塞连接。该连接在输出时是空闲的,因此您需要使用性能模式语句历史表或监控解决方案中的查询分析,或者研究应用或它们的组合来确定事务执行了哪些查询。在这种情况下,它是对film_category
表的更新(为了可读性而格式化):
UPDATE sakila.film_category
SET category_id = IF(category_id = 7, 16, 7)
WHERE film_id = 64;
这导致对category
表的锁定的原因是在film_category
和category
表中的category_id
列之间有一个外键,所以当进程列表 id 为 463 的连接试图更新与连接 462 更新的 id 相同的category
表中的行时,它将阻塞,直到 462 被提交或回滚。
解决方案和预防措施
第 14 和 15 章中讨论的解决方案和预防措施也适用于涉及外键的情况。这意味着首先避免这些问题的最有效方法是避免长时间运行的事务,作为解除元数据锁等待的快速方法,您可以终止请求排他锁的 DDL 语句。
Note
外键是元数据锁比 InnoDB 记录锁更大的问题,因为后者只影响外键中使用的列。
当存在外键时,将lock_wait_timeout
保持在一个较低的值会特别有用,这样可以避免在等待所有请求被批准时,跨许多表请求或长时间持有大量元数据锁定请求。这可能与减少max_write_lock_count
的值相结合,以避免延迟通过繁忙表上的外键请求的共享元数据锁的请求。(减少max_write_lock_count
不会改变这个案例研究。)
如果由于外键而出现锁争用的严重问题,一种可能是将保持数据一致性的责任转移到应用中。但是,您应该意识到,这确实会在 MySQL 级别上移除保险,以保持数据的一致性(ACID 中的 C ),因此不建议这样做。也就是说,在某些情况下,这可能是避免高并发系统中过度锁定的唯一方法。
Caution
虽然在应用中处理外键关系有助于减少数据库中的锁定,但是要小心,因为这也会削弱一致性保证。
此外,还存在一些通用的解决方案,它们并不专门针对外键:
-
如果锁被持有的时间太长,例如,由于一个被放弃的事务,可以考虑终止阻塞的事务,但是要记住考虑必须回滚的更改的数量。
-
请记住,处理查询因锁等待超时而失败的事务,这样事务就不会保留在失败的语句之前获取的锁。
-
考虑一下你能做些什么来减少事务的持续时间和规模。
-
使用索引来减少被访问的记录数量。
-
考虑一下
READ COMMITTED
事务隔离级别,如果它适合您的应用的话。
摘要
本章通过一个案例研究了由外键引起的同步元数据锁和 InnoDB 锁争用。主要讨论点是锁如何传播到查询所使用的表之外的其他表。对于元数据锁来说尤其如此,而对于 InnoDB 记录锁来说影响较小,因为只有在涉及到用于外键的列时才会使用额外的锁。
调查锁问题的原理与不涉及外键时的原理相同;然而,由于涉及的锁的数量,这更加困难;对于示例中的元数据锁,performance_schema.metadata_locks
返回了 26 个锁请求。因此,使用sys
模式视图来帮助分析特别有用。
除了减少锁问题的常用方法之外,对于元数据锁,您可以考虑将较低的lock_wait_timeout
与相对较低的max_write_lock_count
值相结合。另一个对元数据和 InnoDB 锁都有帮助的选项是将保证外键一致性的责任留给应用;但是,如果你这样做的话要非常小心,因为它不会像 MySQL 处理它时那样提供强有力的保证。
还有一个案例研究,它涵盖了 InnoDB 中存在信号量等待的情况,这将在下一章中讨论。
十八、案例研究:信号量
互斥和信号量争用是您可能遇到的最难以捉摸的争用类型之一,因为除了极端情况,您不会直接注意到任何问题。相反,这种争用往往会导致整体延迟增加和吞吐量降低,很难确定具体原因。然后,出乎意料的是,您可能已经超过了一个负载阈值,这种争用导致您的服务器突然停止工作。
本章通过一个案例来研究自适应散列索引 rw-semaphore 上的争用。但是,请注意,信号量争用根据发生争用的互斥体或信号量的不同而不同,解决它所需的调查也不同。在高严重性的情况下,您可能还会发现多个信号量等待同时发生争用。
症状
注意到 InnoDB 互斥体或信号量存在争用的两种最常见的方法是通过 InnoDB monitor 和innodb_rwlock_%
InnoDB metrics。
例如,在 InnoDB monitor 输出中,您会在靠近输出顶部的SEMAPHORES
部分看到正在进行的等待
----------
SEMAPHORES
----------
OS WAIT ARRAY INFO: reservation count 77606
--Thread 19304 has waited at btr0sea.ic line 122 for 0 seconds the semaphore:
S-lock on RW-latch at 00000215E6DC12F8 created in file btr0sea.cc line 202
a writer (thread id 11100) has reserved it in mode exclusive
number of readers 0, waiters flag 1, lock_word: 0
Last time read locked in file btr0sea.ic line 122
Last time write locked in file G:\ade\build\sb_0-39697839-1592332179.68\mysql-8.0.21\storage\innobase\btr\btr0sea.cc line 1197
--Thread 26128 has waited at btr0sea.ic line 92 for 0 seconds the semaphore:
X-lock on RW-latch at 00000215E6DC12F8 created in file btr0sea.cc line 202
a writer (thread id 11100) has reserved it in mode exclusive
number of readers 0, waiters flag 1, lock_word: 0
Last time read locked in file btr0sea.ic line 122
Last time write locked in file G:\ade\build\sb_0-39697839-1592332179.68\mysql-8.0.21\storage\innobase\btr\btr0sea.cc line 1197
OS WAIT ARRAY INFO: signal count 93040
RW-shared spins 18200, rounds 38449, OS waits 20044
RW-excl spins 22345, rounds 1121445, OS waits 38469
RW-sx spins 3684, rounds 100410, OS waits 2886
Spin rounds per wait: 2.11 RW-shared, 50.19 RW-excl, 27.26 RW-sx
等待的时间越长,问题就越严重。
您可能还会在监控中注意到,rw-lock 等待的次数很高,在高负载时可能会达到峰值。在一个真实的例子中,自适应散列索引存在严重的争用,在很长一段时间内,每秒钟有数万次操作系统等待。
原因
问题是对共享资源的请求,比如对自适应散列索引的访问,到达的速度比它们能够被处理的速度要快。这些资源在源代码中使用互斥锁和读写锁进行保护。争用表明要么您已经达到了您的工作负载所使用的 MySQL 版本的并发限制,要么您需要将资源分成更多的部分或类似的部分。
设置
随意再现信号量争用可能很难做到。系统中可用的 CPU 越多,就越有可能产生遇到信号量等待的工作负载。
本章讨论的输出是在带有八个 CPU 的笔记本电脑上使用Listing
8-1
工作负载生成的,缓冲池的默认大小设置为 128 MiB。如果您试图重现这种情况,那么您可能需要试验连接的数量。该脚本提示您将这些默认设置为 1 个读写线程,并将每个剩余 CPU 的一个连接用于只读连接。
Note
MySQL Shell 的会话对象并不完全是线程安全的,即使每个线程都有自己的会话。因此,有必要尝试几次测试。该问题在具有多个读写连接的 Microsoft Windows 上尤其明显。
您还可以尝试更改缓冲池的大小。还有一个选项是在提交事务时减少刷新,如果您的磁盘刷新性能较差,这尤其有用:
SET GLOBAL innodb_flush_log_at_trx_commit = 0,
GLOBAL sync_binlog = 0;
Caution
减少刷新在测试系统上是没问题的,但是您不应该在生产系统上这样做,因为您可能会在崩溃的情况下丢失提交的事务。
运行更长时间也可以增加至少看到一次争用的机会。
测试还允许您在测试之前请求重新启动 MySQL,并且您可以选择是否删除测试创建的索引。重新启动 MySQL 可以让您看到从一个冷的 InnoDB 缓冲池开始的区别(尽管工作负载确实会自己预热缓冲池)。
Note
从测试中重新启动 MySQL 只有在您已经在管理进程下启动 MySQL 的情况下才有效。例如,当您在 Microsoft Windows 上使用mysqld_safe
或在 Linux 上通过systemd
将 MySQL 作为一项服务启动时,就会发生这种情况。
如果您想要多次运行测试,告诉测试不要删除它的索引可能是一个优势,因为这允许测试在下一次执行时跳过创建。
清单 18-1 中显示了一个执行测试用例的例子。执行的完整输出包含在文件listing_18-1.txt
中,该文件可从本书的 GitHub 存储库中获得。
Specify the number of read-write connections (0-31) [1]:
Specify the number of read-only connections (1-31) [7]:
Specify the number of seconds to run for (1-3600) [10]:
Restart MySQL before executing the test? (Y|Yes|N|No) [No]:
Delete the test specific indexes after executing the test? (Y|Yes|N|No) [Yes]:
2020-07-25 15:56:33.928772 0 [INFO] Adding 1 index to the dept_emp table
2020-07-25 15:56:43.238872 0 [INFO] Adding 1 index to the employees table
2020-07-25 15:56:54.202735 0 [INFO] Adding 1 index to the salaries table
2020-07-25 15:57:47.050114 0 [INFO] Warming up the InnoDB buffer pool.
2020-07-25 15:58:04.543354 0 [INFO] Waiting 2 seconds to let the monitoring collect some information before starting the test.
2020-07-25 15:58:06.544765 0 [INFO] Starting the work connections.
2020-07-25 15:58:07.556126 0 [INFO] Completed 10%
…
-- Total mutex and rw-semaphore waits during test:
+----------------+-------+
| File:Line | Waits |
+----------------+-------+
| btr0sea.cc:202 | 13368 |
+----------------+-------+
-- Total execution time: 25.685603 seconds
2020-07-25 15:58:34.374196 0 [INFO] Dropping indexes on the dept_emp table.
2020-07-25 15:58:35.651209 0 [INFO] Dropping indexes on the employees table.
2020-07-25 15:58:36.344171 0 [INFO] Dropping indexes on the salaries table.
Listing 18-1Semaphore waits
注意,在这个例子的开始,有五个关于如何运行测试的信息提示。
当测试重现该问题时,您将从 InnoDB monitor 输出中看到一个或多个SEMAPHORES
部分的输出,最后会生成一些诊断数据。这些数据包括
-
测试期间每秒收集的 RW-lock 指标。这是以 CSV 格式打印的,因此您可以将其复制到电子表格中并绘制出来。
-
测试期间每秒收集的自适应哈希索引指标。这也是以 CSV 格式打印的。
-
InnoDB 缓冲池中的总页数以及年轻或不年轻的比率。
-
测试结束时的
INSERT BUFFER AND ADAPTIVE HASH INDEX
部分。 -
测试期间互斥和 rw 信号量等待的总数。
只读工作负载由employees
数据库中三个表之间的连接组成,其中包括大量的二级索引查找:
SELECT dept_name, MIN(salary) min_salary,
AVG(salary) AS avg_salary, MAX(salary) AS max_salary
FROM employees.departments
INNER JOIN employees.dept_emp USING (dept_no)
INNER JOIN employees.salaries USING (emp_no)
WHERE dept_emp.to_date = '9999-01-01'
AND salaries.to_date = '9999-01-01'
GROUP BY dept_no
ORDER BY dept_name;
读写工作负载从employees.employees
表中随机选择一个姓氏,并给所有姓该姓氏的员工加薪。对于占位符,步骤如下
SELECT last_name
FROM employees.employees
WHERE emp_no = ?;
SELECT emp_no, salary, from_date + INTERVAL 1 DAY
FROM employees.employees
INNER JOIN employees.salaries USING (emp_no)
WHERE employees.last_name = ?
AND to_date = '9999-01-01';
# For each employee found in the previous query,
# execute the insert and update:
INSERT INTO employees.salaries
VALUES (?, ?, ?, '9999-01-01');
UPDATE employees.salaries
SET to_date = ?
WHERE emp_no = ? AND to_date = '9999-01-01';
这意味着employees
数据库中的数据被修改。您不需要在每次测试之间重新加载数据,但是如果您希望返回原始数据,您可能希望在完成测试后重置它。
表中添加了三个索引,以确保存在导致争用的必要辅助索引(请记住,自适应散列索引仅用于辅助索引):
ALTER TABLE employees.dept_emp
ADD INDEX idx_concurrency_book_0 (dept_no, to_date);
ALTER TABLE employees.employees
ADD INDEX idx_concurrency_book_1 (last_name, first_name);
ALTER TABLE employees.salaries
ADD INDEX idx_concurrency_book_2 (emp_no, to_date, salary);
除非您请求保留索引,否则在测试结束时会再次删除索引。
最后,为了避免在测试期间过早驱逐读入缓冲池的页面,在测试期间,旧块时间被设置为 0:
SET GLOBAL innodb_old_blocks_time = 0;
这有助于将缓冲池置于比其他情况下更高的压力之下,从而更有可能再现争用。测试结束时,变量被设置回 1000(默认值)。
Note
测试将花费比您指定的运行时间更长的时间,因为运行时间只在每个查询循环开始时检查。因此,循环中所有挂起的查询都将完成。
现在已经确定了导致问题的工作负载,是时候开始调查了。
调查
当您遇到信号量争用时,第一个访问的端口通常是您的监控系统,在那里您可以获得争用的概况。虽然您可以自己查询指标,但是信号量等待往往是波动的,您很可能会在没有争用的时段看到问题,并且只在最繁忙的时段或执行特定工作负载时看到问题。通过查看图表中的指标,可以更容易地确定何时发生争用。
本节讨论如何监控innodb_rwlock_%
指标、InnoDB 监控器的SEMAPHORES
部分、InnoDB 互斥监控器,以及如何确定工作负载。
InnoDB 读写锁指标
一种选择是从查看来自information_schema.INNODB_METRICS
或sys.metrics
的innodb_rwlock_%
指标开始调查。有三组指标:共享、共享独占和独占读写锁。每个组有三个指标:旋转等待数、旋转轮数和操作系统等待数。测试结束时的 CSV 输出包括共享组和独占组的指标。(本研究对共享独占读写锁不感兴趣。)图 18-1 显示了一个共享读写锁的度量标准示例,该示例在 x 轴上绘制了进入测试的时间。
图 18-1
共享读写锁的等待和旋转次数
在这里,旋转等待的次数在测试过程中几乎是恒定的,但是旋转循环的次数(图中的顶线)在测试进行到大约 7 秒时显著增加。这也会导致操作系统等待的次数增加。操作系统等待跳转意味着旋转等待的旋转圈数超过innodb_sync_spin_loops
(默认为 30)。
专用 rw 锁的图片与此类似,只是旋转圈数要高得多,如图 18-2 所示。
图 18-2
独占读写锁的等待和旋转次数
虽然很难看出,因为旋转轮的数量使旋转和操作系统等待相形见绌,但它们确实遵循与共享读写锁相同的模式,等待的绝对数量大约是共享锁的两倍。引起关注的等待次数取决于您的工作负载,您的并发查询越多,通常等待次数就越多。您应该特别注意操作系统等待,因为当等待时间太长以至于线程被挂起时,操作系统等待会增加。
InnoDB 监控器和互斥监控器
当您确定发生争用的时间后,您需要确定争用是针对哪个读写锁(可能有多个读写锁)。有两个主要工具可以确定发生争用的位置,其中第一个工具是 InnoDB monitor。除非您已经启用了它,所以它会自动输出到错误日志,或者争用非常严重,以至于信号量等待时间超过了 240 秒,否则您需要在遇到争用时捕获您的系统。清单 18-2 显示了测试中 InnoDB 监控器输出的SEMAPHORES
部分的示例。
mysql> SHOW ENGINE INNODB STATUS\G
...
----------
SEMAPHORES
----------
OS WAIT ARRAY INFO: reservation count 36040
--Thread 35592 has waited at btr0sea.ic line 92 for 0 seconds the semaphore:
X-lock on RW-latch at 000001BD277CCFF8 created in file btr0sea.cc line 202
a writer (thread id 25492) has reserved it in mode exclusive
number of readers 0, waiters flag 1, lock_word: 0
Last time read locked in file btr0sea.ic line 122
Last time write locked in file G:\ade\build\sb_0-39697839-1592332179.68\mysql-8.0.21\storage\innobase\include\btr0sea.ic line 92
--Thread 27836 has waited at btr0sea.ic line 92 for 0 seconds the semaphore:
X-lock on RW-latch at 000001BD277CCFF8 created in file btr0sea.cc line 202
a writer (thread id 25492) has reserved it in mode exclusive
number of readers 0, waiters flag 1, lock_word: 0
Last time read locked in file btr0sea.ic line 122
Last time write locked in file G:\ade\build\sb_0-39697839-1592332179.68\mysql-8.0.21\storage\innobase\include\btr0sea.ic line 92
--Thread 25132 has waited at btr0sea.ic line 92 for 0 seconds the semaphore:
X-lock on RW-latch at 000001BD277CCFF8 created in file btr0sea.cc line 202
a writer (thread id 25492) has reserved it in mode exclusive
number of readers 0, waiters flag 1, lock_word: 0
Last time read locked in file btr0sea.ic line 122
Last time write locked in file G:\ade\build\sb_0-39697839-1592332179.68\mysql-8.0.21\storage\innobase\include\btr0sea.ic line 92
--Thread 22512 has waited at btr0sea.ic line 92 for 0 seconds the semaphore:
X-lock on RW-latch at 000001BD277CCFF8 created in file btr0sea.cc line 202
a writer (thread id 25492) has reserved it in mode exclusive
number of readers 0, waiters flag 1, lock_word: 0
Last time read locked in file btr0sea.ic line 122
Last time write locked in file G:\ade\build\sb_0-39697839-1592332179.68\mysql-8.0.21\storage\innobase\include\btr0sea.ic line 92
--Thread 22184 has waited at btr0sea.ic line 122 for 0 seconds the semaphore:
S-lock on RW-latch at 000001BD277CCFF8 created in file btr0sea.cc line 202
a writer (thread id 25492) has reserved it in mode exclusive
number of readers 0, waiters flag 1, lock_word: 0
Last time read locked in file btr0sea.ic line 122
Last time write locked in file G:\ade\build\sb_0-39697839-1592332179.68\mysql-8.0.21\storage\innobase\include\btr0sea.ic line 92
--Thread 32236 has waited at btr0sea.ic line 92 for 0 seconds the semaphore:
X-lock on RW-latch at 000001BD277CCFF8 created in file btr0sea.cc line 202
a writer (thread id 25492) has reserved it in mode exclusive
number of readers 0, waiters flag 1, lock_word: 0
Last time read locked in file btr0sea.ic line 122
Last time write locked in file G:\ade\build\sb_0-39697839-1592332179.68\mysql-8.0.21\storage\innobase\include\btr0sea.ic line 92
OS WAIT ARRAY INFO: signal count 68351
RW-shared spins 9768, rounds 21093, OS waits 11109
RW-excl spins 13012, rounds 669111, OS waits 24669
RW-sx spins 16, rounds 454, OS waits 15
Spin rounds per wait: 2.16 RW-shared, 51.42 RW-excl, 28.38 RW-sx
...
Listing 18-2The SEMAPHORES section of the InnoDB monitor
在这个例子中,所有的等待都是在btr0sea.cc
的第 202 行中创建的信号量(行号可能会根据平台和 MySQL 版本的不同而不同,例如,在 Linux 上,8.0.21 的行号是 201)。如果您查看文件storage/innobase/btr/btr0sea.cc
中 MySQL 8.0.21 的源代码,那么第 202 行的代码是
186 /** Creates and initializes the adaptive search system at a database start.
187 @param[in] hash_size hash table size. */
188 void btr_search_sys_create(ulint hash_size) {
189 /* Search System is divided into n parts.
190 Each part controls access to distinct set of hash buckets from
191 hash table through its own latch. */
192
193 /* Step-1: Allocate latches (1 per part). */
194 btr_search_latches = reinterpret_cast<rw_lock_t **>(
195 ut_malloc(sizeof(rw_lock_t *) * btr_ahi_parts, mem_key_ahi));
196
197 for (ulint i = 0; i < btr_ahi_parts; ++i) {
198 btr_search_latches[i] = reinterpret_cast<rw_lock_t *>(
199 ut_malloc(sizeof(rw_lock_t), mem_key_ahi));
200
201 rw_lock_create(btr_search_latch_key, btr_search_latches[i],
202 SYNC_SEARCH_SYS);
203 }
...
这是自适应散列索引的代码,因此这证明了自适应散列索引就是争用的地方。(它还显示了第 201 行和第 202 行是针对同一条语句的,因此 Microsoft Windows 和 Linux 之间的行号差异在于选择语句的第一行还是最后一行作为 rw 锁的创建。)
您还可以使用互斥监控器来统计哪些锁是最常经历等待的锁。测试结束时互斥监控器的输出示例如下
mysql> SHOW ENGINE INNODB MUTEX;
+--------+----------------------------+-------------+
| Type | Name | Status |
+--------+----------------------------+-------------+
| InnoDB | rwlock: fil0fil.cc:3206 | waits=11 |
| InnoDB | rwlock: dict0dict.cc:1035 | waits=12 |
| InnoDB | rwlock: btr0sea.cc:202 | waits=7730 |
| InnoDB | rwlock: btr0sea.cc:202 | waits=934 |
| InnoDB | rwlock: btr0sea.cc:202 | waits=5445 |
| InnoDB | rwlock: btr0sea.cc:202 | waits=889 |
| InnoDB | rwlock: btr0sea.cc:202 | waits=9076 |
| InnoDB | rwlock: btr0sea.cc:202 | waits=13608 |
| InnoDB | rwlock: btr0sea.cc:202 | waits=1050 |
| InnoDB | rwlock: hash0hash.cc:171 | waits=4 |
| InnoDB | sum rwlock: buf0buf.cc:778 | waits=86 |
+--------+----------------------------+-------------+
11 rows in set (0.0008 sec)
如果您定期创建 mutex monitor 报告,您可以对等待进行汇总,并按文件和行号进行分组,然后监控等待发生的时间和地点的差异。(本书作者不知道有任何现成的监控解决方案可以做到这一点。)对于这个例子,测试本身计算每个文件的等待次数和行号,这将主要显示对第 202 行btr0sea.cc
的等待(记住行号取决于确切的版本和编译器/平台):
-- Total mutex and rw-semaphore waits during test:
+----------------+-------+
| File:Line | Waits |
+----------------+-------+
| btr0sea.cc:202 | 13368 |
+----------------+-------+
您将看到的最有可能的其他文件和行是hash0hash:171
(对于 Windows 上的 8.0.21 或 Linux 上的 8.0.21 的第 170 行),它与 InnoDB 的散列表实现相关。这表明 InnoDB monitor 输出中的信号量等待都是针对第 202 行btr0sea.cc
的,这不是巧合。
确定工作量
调查的最后一步是确定导致争用的工作负载。这也是最困难的任务。最好是您有一个监控解决方案,它收集关于执行的查询的信息,并为它们聚集统计数据。通过这样的监控,您可以直接看到执行了哪些查询,这可以帮助您确定是什么导致了争用。如果您没有在争用期间执行的查询的访问监控数据,您可以尝试使用sys.session
或带有语句信息的性能模式表(threads
、events_statements_current
、events_statements_history
和events_statements_history_long
)来监控查询。还有一个选项是使用sys
模式中的statement_performance_analyzer()
过程,它获取events_statements_summary_by_digest
表的两个快照并计算差异,然后返回一个或多个报告,显示关于在两个快照之间执行的查询的信息。
Tip
sys
模式中的statement_performance_analyzer()
过程可以用来生成一个“穷人的查询分析器”,在两个快照之间执行查询。文档和示例见 https://dev.mysql.com/doc/refman/en/sys-statement-performance-analyzer.html
。
这听起来很容易,但实际操作起来,并不那么简单。即使有良好的监控,也几乎不可能确定哪些查询是问题所在。在真实的生产系统中,峰值可能超过每秒 100000 个查询,每分钟超过 10000 个唯一查询摘要。试图找到引起这些冲突的查询组合就像大海捞针一样容易。
如果幸运的话,您可以根据满意的互斥体和信号量来猜测您正在寻找哪种查询。在这种情况下,争用的是专用于辅助索引的自适应哈希索引。因此,您知道感兴趣的查询必须使用辅助索引,并且查询执行的索引查找和索引修改次数越多,它就越有可能成为问题的一部分。在这种情况下,只读查询使用了两个辅助索引,这可以从清单 18-3 所示的查询计划中看出。
EXPLAIN
SELECT dept_name, MIN(salary) min_salary,
AVG(salary) AS avg_salary, MAX(salary) AS max_salary
FROM employees.departments
INNER JOIN employees.dept_emp USING (dept_no)
INNER JOIN employees.salaries USING (emp_no)
WHERE dept_emp.to_date = '9999-01-01'
AND salaries.to_date = '9999-01-01'
GROUP BY dept_no
ORDER BY dept_name
*************************** 1\. row ***************************
id: 1
select_type: SIMPLE
table: departments
partitions: NULL
type: index
possible_keys: PRIMARY,dept_name
key: PRIMARY
key_len: 16
ref: NULL
rows: 9
filtered: 100
Extra: Using temporary; Using filesort
*************************** 2\. row ***************************
id: 1
select_type: SIMPLE
table: dept_emp
partitions: NULL
type: ref
possible_keys: PRIMARY,dept_no,idx_concurrency_book_0
key: idx_concurrency_book_0
key_len: 19
ref: employees.departments.dept_no,const
rows: 9
filtered: 100
Extra: Using index
*************************** 3\. row ***************************
id: 1
select_type: SIMPLE
table: salaries
partitions: NULL
type: ref
possible_keys: PRIMARY,idx_concurrency_book_2
key: idx_concurrency_book_2
key_len: 7
ref: employees.dept_emp.emp_no,const
rows: 1
filtered: 100
Extra: Using index
3 rows in set, 1 warning (0.0009 sec)
Listing 18-3The query plan for the read-only query in the test
dept_emp
和salaries
表上的连接都是分别使用二级索引idx_concurrency_book_0
和idx_concurrency_book_2
来执行的。同样,读写连接执行的查询使用辅助索引;这是留给读者验证的一个练习。
调查完成后,您需要决定如何处理争议。
解决方案和预防措施
与之前的案例研究不同,通常没有直接的方法来解决和预防该问题。相反,您需要测试和验证各种可能的系统更改的效果。因此,解决方案和预防措施部分合并在一起。
-
完全禁用自适应哈希索引
-
增加分区的数量
-
在挂起线程之前增加旋转圈数
-
将工作负载拆分到不同的副本
这些选项将在本节的剩余部分讨论。
禁用自适应哈希索引
对于自适应散列索引的争用,最直接的解决方案是禁用该特性。在这样做之前,您需要考虑报告的争用是否真的是一个性能问题。记住,互斥和信号量等待本身并不是问题的标志;事实上,它们是 MySQL 的自然组成部分。一旦不能立即满足请求,自旋等待计数器就增加。如果查询在请求完成之前只等待几轮,这不一定是个问题。您可以查看每次等待的平均旋转次数,并使用它来估计等待时间。如图 18-3 所示。
图 18-3
共享和独占读写锁的平均每次等待旋转次数
该图显示,对于独占锁,平均而言,每次等待花费 80 到 100 轮等待。这很重要,因为每次轮询之间都有延迟(选项innodb_spin_wait_delay
和innodb_spin_wait_pause_multiplier
)。此外,默认情况下,在 30 轮之后(innodb_sync_spin_loops
选项),InnoDB 会暂停线程,使其可用于其他目的,这使得再次唤醒查询的成本更高。对于共享锁,平均不到五个,因此更易于管理。
您还应该考虑使用自适应散列索引查找行和保存 B 树搜索的频率。与 B 树搜索相比,哈希索引查找速度更快,因此自适应哈希索引可以完成的搜索越多,开销就越大。InnoDB 有两个指标来跟踪 hash 索引的使用频率和 B 树的访问频率。此外,还有其他六个与自适应哈希索引相关的指标,但这些指标在默认情况下是禁用的(这些值包括测试之前完成的工作,因此会有所不同):
mysql> SELECT variable_name, variable_value AS value, enabled
FROM sys.metrics
WHERE type = 'InnoDB Metrics - adaptive_hash_index'
ORDER BY variable_name;
+------------------------------------------+----------+---------+
| variable_name | value | enabled |
+------------------------------------------+----------+---------+
| adaptive_hash_pages_added | 0 | NO |
| adaptive_hash_pages_removed | 0 | NO |
| adaptive_hash_rows_added | 0 | NO |
| adaptive_hash_rows_deleted_no_hash_entry | 0 | NO |
| adaptive_hash_rows_removed | 0 | NO |
| adaptive_hash_rows_updated | 0 | NO |
| adaptive_hash_searches | 51488882 | YES |
| adaptive_hash_searches_btree | 10904682 | YES |
+------------------------------------------+----------+---------+
8 rows in set (0.0097 sec)
这表明散列索引已经完成了超过 5100 万次搜索(adaptive_hash_searches
),而使用 B 树需要的搜索不到 1100 万次。这给出了一个命中率
82.5%的命中率可能看起来不错,但是对于自适应散列索引来说,这可能(取决于工作负载)偏低。请记住,散列索引还会占用缓冲池中的内存。如果禁用自适应散列索引,该内存可用于缓存 B 树索引。您还需要考虑指标覆盖多长时间,以及散列索引的有用性是否有波动。对于后者,监控软件中的图表是查看一段时间内数据的好方法。图 18-4 显示了一个基于测试期间收集的指标的示例。
图 18-4
测试期间的自适应散列索引搜索指标
这里您可以看到,最初,自适应散列索引是有效的,大多数搜索都是使用散列索引完成的。然而,在测试的第 6 秒,adaptive_hash_searches
指标开始直线下降,在 9 秒标记之后,直到测试接近结束,它没有超过每秒 250 个匹配。您还可以看到,这两者的总和在这段时间内比开始时低得多,这可能是由于争用导致整体查询性能下降。但是,您需要使用其他来源来确认是否是这种情况;这是作为练习留下的。
或者,您可以直接绘制命中率,如图 18-5 所示。
图 18-5
测试期间的自适应哈希索引命中率
这清楚地表明,最初,自适应散列索引非常有效,但随后就变得无用了。基于此,似乎有必要禁用自适应散列索引,例如,可以通过将innodb_adaptive_hash_index
设置为OFF
或0
来做到这一点
SET GLOBAL innodb_adaptive_hash_index = OFF;
Query OK, 0 rows affected (0.1182 sec)
虽然您可以测试动态禁用散列索引,但是请注意,一旦您这样做,缓冲池中的散列索引就会被清除,因此如果您稍后重新启用该特性,您将需要重新构建散列。对于大型实例,自适应散列索引可能会使用 25 GiB 或更多内存,因此需要一段时间来重建。因此,当您在生产系统中禁用自适应散列索引时,您可能希望保持副本处于启用状态,这样,如果禁用innodb_adaptive_hash_index
导致性能下降,您可以故障切换到副本。
Tip
最后,对于信号量争用问题,您将需要使用基准或在类似生产的环境中测试,或者通过使用不同设置的副本来验证您的更改的效果。虽然可以像在本讨论中一样对影响进行一些估计,但是相关部分之间的相互作用是复杂的,在测量之前,您无法确定总体影响。
虽然禁用自适应哈希索引是一个简单的解决方案,但是您可以考虑其他一些更改,这些更改可能允许您至少部分地继续使用自适应哈希索引。
增加哈希索引部分的数量
如果争用是由太多的连接命中同一个散列分区引起的,那么一个选项是增加自适应散列索引被分割成的部分的数量。这是通过innodb_adaptive_hash_index_parts
选项完成的。没有直接的方法来确定增加散列索引部分的数量是否有帮助,尽管您可以查看 InnoDB monitor 输出中的INSERT BUFFER AND ADAPTIVE HASH INDEX
部分,并查看每个部分中缓冲区的大小和数量,例如
mysql> SHOW ENGINE INNODB STATUS\G
...
-------------------------------------
INSERT BUFFER AND ADAPTIVE HASH INDEX
-------------------------------------
Ibuf: size 1, free list len 13, seg size 15, 0 merges
merged operations:
insert 0, delete mark 0, delete 0
discarded operations:
insert 0, delete mark 0, delete 0
Hash table size 34679, node heap has 1 buffer(s)
Hash table size 34679, node heap has 3880 buffer(s)
Hash table size 34679, node heap has 1 buffer(s)
Hash table size 34679, node heap has 1 buffer(s)
Hash table size 34679, node heap has 1 buffer(s)
Hash table size 34679, node heap has 1 buffer(s)
Hash table size 34679, node heap has 1 buffer(s)
Hash table size 34679, node heap has 1 buffer(s)
0.00 hash searches/s, 0.00 non-hash searches/s
...
这是测试结束时的输出,您可以看到它主要是正在使用的部件之一(哪个部件可能会因您而异)。因此,在这种情况下,添加更多的散列索引部分可能没有帮助。在具有许多索引的更实际的生产使用中,您更有可能从更多的部件中受益。
其他解决方案
本节前面已经讨论过,这是一个问题,因此许多自旋等待被转换为操作系统等待。特别是如果您没有使用所有的 CPU,您可以考虑增加innodb_sync_spin_loops
选项的值,以允许 InnoDB 持续轮询 rw 锁是否可用。这可以减少上下文切换的次数和总等待时间。
最后,您可以考虑将查询分为受益于自适应散列索引的查询和没有受益于自适应散列索引的查询,并将每组查询指向不同的副本。这样,您可以在启用了自适应哈希索引功能的副本上执行受益于该功能的查询,而在禁用了自适应哈希索引功能的副本上执行那些没有受益的查询。这显然主要是针对只读任务的解决方案。
摘要
本案例研究调查了自适应散列索引上信号量争用的一个例子。症状包括由 InnoDB monitor 的innodb_rwlock_%
InnoDB metrics 和SEMAPHORES
部分报告的等待次数增加,这是由于太多的查询需要对相同闩锁进行冲突访问。
与之前的大多数案例研究相比,本案例研究的设置更加复杂,并且使用 MySQL Shell 的concurrency_book
模块可以轻松再现。本章的工作负载提示测试的各种设置,因此您可以尝试为您的系统调整测试。
调查开始时使用innodb_rwlock_%
指标来确定争用何时成为问题。您可以查看原始指标和每次旋转等待的旋转圈数。然后使用 InnoDB 监控器和 mutex 监控器来确定自适应散列索引上的争用位置。最后,讨论了如何确定导致争用的工作负载。
当处理互斥和信号量争用时,解决方案通常不简单且不确定。对于自适应散列索引,最直接的选择是禁用它,但是在这之前,您需要考虑该特性的整体有效性,包括命中率。一种替代方法是将散列索引分成更多部分;然而,这只有在争用影响到几个现有分区时才有效。其他解决方案包括增加允许的旋转循环数量,以减少 InnoDB 暂停轮询的频率,以及使用具有不同配置的多个读取副本。
这就结束了 MySQL 并发世界的旅程,重点是锁和事务。请记住,熟能生巧,对于本书中讨论的话题来说尤其如此。本书的其余部分由两个附录组成,其中附录 A 包含性能模式表、InnoDB monitor 等的各种参考。附录 B 是 MySQL Shell 的concurrency_book
模块的参考。
祝您继续 MySQL 并发性能之旅好运。
【推荐】编程新体验,更懂你的AI,立即体验豆包MarsCode编程助手
【推荐】凌霞软件回馈社区,博客园 & 1Panel & Halo 联合会员上线
【推荐】抖音旗下AI助手豆包,你的智能百科全书,全免费不限次数
【推荐】博客园社区专享云产品让利特惠,阿里云新客6.5折上折
【推荐】轻量又高性能的 SSH 工具 IShell:AI 加持,快人一步
· DeepSeek “源神”启动!「GitHub 热点速览」
· 微软正式发布.NET 10 Preview 1:开启下一代开发框架新篇章
· 我与微信审核的“相爱相杀”看个人小程序副业
· C# 集成 DeepSeek 模型实现 AI 私有化(本地部署与 API 调用教程)
· DeepSeek R1 简明指南:架构、训练、本地部署及硬件要求