者:Kuba Łopuszański 编译:徐轶韬
在InnoDB Data Locking –第1部分“简介”中,我们通过同时编辑电子表格的比喻描述了锁能够解决的难题。虽然通过比喻可以获得直观的感觉,但是我们需要将解决方案与现实进行匹配。在这篇文章中,将讨论我们之前看到的语句如何映射到InnoDB的表,行,锁,锁队列等实际情况,例如“ Alice请求对文件A的读取访问,但必须等待Basil首先释放其写权限”。
之前的帖子中使用的比喻应该是这样的:
为了使我的故事与现实生活办公室中发生的事情相似,它涉及“Alice计划阅读文件A”(译为“客户端A执行一个事务,执行该事务需要从取得数据库A的共享锁开始”),这在InnoDB中实际上不会发生,因为事务不能锁定整个数据库,这样太糟糕了。为了实现更高的并行度,在InnoDB中,事务通常需要在单个行的级别上进行更细粒度的访问。为了方便进行说明,在这里我们暂且假设锁定的是数据库。
什么是数据库“锁”?
当我理解数据库术语时,我发现非常困惑的一件事,“锁”一词在数据库中的含义与在编程中的含义不同。 在编程中,如果您具有“锁”,则它是存储在内存中某个地址下的单个对象,然后有多个线程尝试“锁定”它并成功或等待成功。因此,每个资源有一个锁,“锁”的动作是线程执行的操作,您可以使用调试器来捕获它发生的瞬间,但是没有内存对象(除了调用堆栈)显式记录给定线程尝试或成功获得锁的事实。 在InnoDB中,以上概念称为“闩(shuan)锁”,用于将“锁”一词重新用于其他用途。在InnoDB的锁系统中,“锁”实际上更像是“通过特定事务请求对特定资源的特定种类的访问权的请求”。因此,对于特定资源,如果有许多事务请求访问它,则可能存在数百个“锁”,如果单个事务需要使用不同的锁模式来访问它,那么对于单个事务也可能有多个锁。“锁”可以等待,也可以被授予,并记录对给定资源的给定事务的访问权限。您可以将其视为纸质表格,必须提交文件才能获得许可,该文件在某些官员的抽屉中等待批准印章并最终被授予,并充当证明您的权利的证书。
<who?, what?, how?, granted?>
例如:一个锁系统可以同时包含以下与单个资源(表report的row#2)有关的锁
<transaction#3305, row#2 of table `report`, shared, granted >
<transaction#3305, row#2 of table `report`, exclusive, granted >
<transaction#3306, row#2 of table `report`, exclusive, waiting >
显示建模的好处之一是知道谁请求内存中的对象,您可以通过查看那些对象来检查情况。可以通过performance_schema.data_locks表查看InnoDB引擎中活动事务创建的所有锁:
> SELECT
ENGINE_TRANSACTION_ID as trx_id,
LOCK_DATA as row,
OBJECT_NAME as `table`,
LOCK_MODE,
LOCK_STATUS
FROM performance_schema.data_locks WHERE LOCK_TYPE='RECORD';
+--------+-----+--------+-----------+-------------+
| trx_id | row | table | LOCK_MODE | LOCK_STATUS |
+--------+-----+--------+-----------+-------------+
| 3305 | 2 | report | S | GRANTED |
| 3305 | 2 | report | X | GRANTED |
| 3306 | 2 | report | X | WAITING |
+--------+-----+--------+-----------+-------------+
我会在后面的“记录锁”部分解释LOCK_MODE列各种值的意思。现在S和X对应于共享和排他就足够了。
(如果您开始怀疑在另一个表中使用锁来保护对表的访问,那么让我来安慰您:这不是一个真正的InnoDB表。有一些魔术使它看起来像一个表,但它实际上是扫描服务器内存中的实际底层数据结构,并将它们呈现为整齐的行)
实际上,这些只是显式锁-出于性能原因,InnoDB避免显式表示访问权限,该访问权限可以从行本身的状态隐式推导出。您会看到,每当事务修改一行时,它都会在行的标题中添加自己的ID,以标识它是最后一个修改它的对象–如果该事务仍未提交,则意味着它仍对该记录具有独占访问权限(它必须修改它,并且只有在提交时才释放“两阶段锁”中的锁),而不会浪费用于显式存储此信息的空间。这种隐式锁不会显示在performance_schema.data_locks中 (这将需要对撤消日志进行扫描以识别所有隐式锁)。创建隐式锁的最常见原因是一项INSERT
操作:成功插入的行在其他事务提交之前对其他事务不可见,并且常见的情况是单个事务插入许多行,因此不创建显式锁对于新插入的行的成本会更低,只是隐式地假定插入事务具有对所有行的独占访问权,因为其ID写入其标题中。如第3部分“死锁”中所述,正确建模和监视谁在等待谁很重要,因此,每当锁系统识别出隐式锁可能是另一个事务必须等待的原因时,它将隐式锁转换为显式锁,以便可以正确地分析,监视,报告等。这被称为隐式到显式转换,并且在语义上没有任何改变–它只是更改了锁的表示。
如前所述,在InnoDB中,大多数锁发生在行的粒度上。这增加了并行的机会,因为多个事务可以同时处理不相交的行,并且服务器仍然可以假装一个事务以可序列化的顺序发生在另一个事务之后。还有表级锁,可让您锁定整个表。由于InnoDB与服务器集成的方式,这些情况很少见。InnoDB位于服务器下方,服务器也具有自己的锁机制,大多数时间InnoDB甚至都不知道事务已锁表,因为它发生在服务器。坦白说,如果现在我们要谈谈表锁,我会有些不知所措:从某种意义上说,它们比记录锁要简单得多,另一方面,InnoDB和服务器协调对表的访问的方式会对事件的理解更加复杂。特别是performance_schema.data_locks不报告服务器本身维护的锁。因此,在默认配置下,您会看到一些令人困惑的事情,例:
# CONFUSING EXAMPLE, DO NOT COPY&PASTE TO YOUR APPLICATION!
con1> BEGIN;
Query OK, 0 rows affected (0.00 sec)
con1> INSERT INTO t1 VALUES (123);
Query OK, 1 row affected (0.01 sec)
# DO NOT DO THIS IN YOUR APPLICATION!:
con1> LOCK TABLES t READ;
Query OK, 0 rows affected (0.00 sec)
您可能希望事务已锁定表t,但是看不到任何锁:
con2> SELECT * FROM performance_schema.data_locks;
Empty set (0.00 sec)
您甚至看不到事务!
con2> SELECT * FROM information_schema.innodb_trx;
Empty set (0.00 sec)
这是因为默认情况下,如果您对表执行LOCK TABLES操作,服务器将提交当前事务。实际上,即使我们自己没有发出COMMIT指令,任何其他客户端都可以看到我们插入的行,这意味着事务被认为已经提交了:
con2> SELECT * FROM test.t1;
+-----+
| id |
+-----+
| 123 |
+-----+
1 row in set (0.00 sec)
“LOCK TABLES
机制”与“事务机制”有所不同,默认行为是先完成其中一项,然后再开始使用另一项。您可以想想LOCK TABLES
+UNLOCK TABLES
作为一个包围的区域,你可以用同样的方法考虑BEGIN
+ COMMIT
。但是,默认情况下,您无法将两者交错。
您可以通过查询Performance_schema.metadata_locks(请注意名称中的“ meta”)来验证LOCK TABLES t READ工作是否正常:
con2> SELECT LOCK_TYPE,LOCK_STATUS,OWNER_THREAD_ID
FROM performance_schema.metadata_locks
WHERE OBJECT_NAME='t' AND OBJECT_TYPE="TABLE";
+------------------+-------------+-----------------+
| LOCK_TYPE | LOCK_STATUS | OWNER_THREAD_ID |
+------------------+-------------+-----------------+
| SHARED_READ_ONLY | GRANTED | 53 |
+------------------+-------------+-----------------+
1 row in set (0.00 sec)
MySQL是服务器和InnoDB是完全分开的架构,我不想假装我对服务器内部非常了解。因此,我只是说这个表显示了服务器获取的锁,阻止了其他客户端尝试修改表:
con3> insert into test.t values (10);
⌛
将等待,您可以通过以下方式进行验证:
con2> SELECT LOCK_TYPE,LOCK_STATUS,OWNER_THREAD_ID
FROM performance_schema.metadata_locks
WHERE OBJECT_NAME='t' AND OBJECT_TYPE="TABLE";
+------------------+-------------+-----------------+
| LOCK_TYPE | LOCK_STATUS | OWNER_THREAD_ID |
+------------------+-------------+-----------------+
| SHARED_READ_ONLY | GRANTED | 53 |
| SHARED_WRITE | PENDING | 54 |
+------------------+-------------+-----------------+
但是请注意,这与InnoDB Lock System无关。实际上,InnoDB目前不知道正在进行的事务:
con2> SELECT * FROM information_schema.innodb_trx;
Empty set (0.00 sec)
一旦您尝试在con1中开始事务,便完成了LOCK TABLES
…
con1> BEGIN;
Query OK, 0 rows affected (0.00 sec)
con3中的插入将继续执行并成功执行:
con3> insert into test.t values (10);
Query OK, 1 row affected (3 min 19.60 sec)
这3分钟是我在输入INSERT到con3之后将BEGIN输入到con1所花的时间
因此,用BEGIN隐启动事务可以式地UNLOCK TABLES
。实际上,默认情况下,如果开始处理事务,就会处理锁定表。
这里需要注意的另一件事是,con3在发出INSERT之前不需要使用任何LOCK TABLES语句,但是防止con3在con1释放表之前执行操作的机制起了作用。这意味着,参与这种服务器级表锁机制是强制性的、隐式的,不可避免的。
另外,请注意,INSERT
语句所需的锁类型为SHARED_WRITE,这听起来可能令人困惑,直到现在我们通常将“共享”与“读取”等同,“独占”与“写入”等同。此处的正确解释是,同一表中可能有多个事务编辑行,如果他们每个人都想写,只要他们写到不同的行中,他们就可以彼此共享对表的访问。因此,它是同时“共享”(在表级别)和“写入”(在行级别)的。
然而,SHARED_WRITE与SHARED_READ_ONLY冲突也是合理的,因为CON1想阻止整个表的任何写操作。这样的SHARED_READ_ONLY的名称已“共享”,因为它可以与希望SHARED_READ_ONLY的其他事务兼容,因为它们的利益一致:他们都希望防止修改。
好的,上面是服务器维护的锁,但是本系列博客文章旨在讨论InnoDB的锁系统。我们如何在InnoDB中创建表级锁?
文档https://dev.mysql.com/doc/refman/8.0/en/lock-tables.html中提到的技巧是禁止使用会隐式导致表解锁的BEGIN(及其同义词START TRANSACTION)。相反,我们将禁用autocommit,这样就隐式地表明我们所做的一切都是事务的一部分。我们将在事务内部,而不必显式地启动它。
mysql> SET autocommit = 0;
Query OK, 0 rows affected (0.00 sec)
mysql> LOCK TABLE t READ, t1 WRITE;
Query OK, 0 rows affected (0.04 sec)
mysql> SELECT OBJECT_NAME,LOCK_TYPE,LOCK_STATUS,OWNER_THREAD_ID
FROM performance_schema.metadata_locks
WHERE OBJECT_SCHEMA='test' AND OBJECT_TYPE="TABLE";
+-------------+----------------------+-------------+-----------------+
| OBJECT_NAME | LOCK_TYPE | LOCK_STATUS | OWNER_THREAD_ID |
+-------------+----------------------+-------------+-----------------+
| t | SHARED_READ_ONLY | GRANTED | 49 |
| t1 | SHARED_NO_READ_WRITE | GRANTED | 49 |
+-------------+----------------------+-------------+-----------------+
2 rows in set (0.00 sec)
mysql> SELECT thread_id,processlist_id
FROM performance_schema.threads
WHERE thread_id=49;
+-----------+----------------+
| thread_id | processlist_id |
+-----------+----------------+
| 49 | 8 |
+-----------+----------------+
1 row in set (0.00 sec)
mysql> SELECT trx_id FROM information_schema.innodb_trx
WHERE trx_mysql_thread_id=8;
+--------+
| trx_id |
+--------+
| 3851 |
+--------+
mysql> SELECT
ENGINE_TRANSACTION_ID as trx_id,
OBJECT_NAME as `table`,
INDEX_NAME,
LOCK_DATA,
LOCK_MODE,
LOCK_STATUS
FROM performance_schema.data_locks;
+--------+-------+------------+-----------+-----------+-------------+
| trx_id | table | INDEX_NAME | LOCK_DATA | LOCK_MODE | LOCK_STATUS |
+--------+-------+------------+-----------+-----------+-------------+
| 3851 | t | NULL | NULL | S | GRANTED |
| 3851 | t1 | NULL | NULL | X | GRANTED |
+--------+-------+------------+-----------+-----------+-------------+
2 rows in set (0.00 sec)
因此,我们现在有了一个活动的InnoDB事务(InnoDB中的ID为3851),该事务在InnoDB中拥有显式表锁,该表锁与相应的服务器线程(ID为49)所持有的锁相对应。是的,这些表被锁定了两次:在服务器和InnoDB级别:
当您试图读取或修改表的一部分时,可以获得InnoDB表锁。这种情况经常发生。没有任何锁表或利用autocommit。
假设我们开始一个全新的事务,并插入新行:
mysql> BEGIN;
Query OK, 0 rows affected (0.00 sec)
mysql> INSERT INTO t VALUES (200);
Query OK, 1 row affected (0.00 sec)
为了能够尝试将任何内容插入表t中,此事务将需要获得对该表的特定权限:
mysql> SELECT
ENGINE_TRANSACTION_ID as trx_id,
OBJECT_NAME as `table`,
INDEX_NAME,
LOCK_DATA,
LOCK_MODE,
LOCK_STATUS
FROM performance_schema.data_locks;
+--------+-------+------------+-----------+-----------+-------------+
| trx_id | table | INDEX_NAME | LOCK_DATA | LOCK_MODE | LOCK_STATUS |
+--------+-------+------------+-----------+-----------+-------------+
| 3852 | t | NULL | NULL | IX | GRANTED |
+--------+-------+------------+-----------+-----------+-------------+
1 row in set (0.00 sec)
InnoDB的IX对应于我们之前看到的服务器层的SHARED_WRITE。继续此示例,假设此事务执行对t1的读取:
mysql> SELECT * FROM t1;
+-----+
| id |
+-----+
| 123 |
+-----+
1 row in set (0.00 sec)
mysql> SELECT
ENGINE_TRANSACTION_ID as trx_id,
OBJECT_NAME as `table`,
INDEX_NAME,
LOCK_DATA,
LOCK_MODE,
LOCK_STATUS
FROM performance_schema.data_locks;
+--------+-------+------------+-----------+-----------+-------------+
| trx_id | table | INDEX_NAME | LOCK_DATA | LOCK_MODE | LOCK_STATUS |
+--------+-------+------------+-----------+-----------+-------------+
| 3852 | t | NULL | NULL | IX | GRANTED |
+--------+-------+------------+-----------+-----------+-------------+
1 row in set (0.00 sec)
令人惊讶的是,在这种情况下,InnoDB没有采取表级锁。因为行是非锁定选择,所以行也没有被锁定,并且查询在服务器级别得到了保护:
mysql> SELECT OBJECT_NAME,LOCK_TYPE,LOCK_STATUS,OWNER_THREAD_ID
FROM performance_schema.metadata_locks
WHERE OBJECT_SCHEMA='test' AND OBJECT_TYPE="TABLE";
+-------------+--------------+-------------+-----------------+
| OBJECT_NAME | LOCK_TYPE | LOCK_STATUS | OWNER_THREAD_ID |
+-------------+--------------+-------------+-----------------+
| t | SHARED_WRITE | GRANTED | 49 |
| t1 | SHARED_READ | GRANTED | 49 |
+-------------+--------------+-------------+-----------------+
2 rows in set (0.00 sec)
如果尝试通过执行锁定选择(SELECT...FOR SHARE/UPDATE
)来锁定表的一部分以进行读取,则行为会有所不同,例如:
mysql> SELECT * FROM t1 FOR SHARE;
+-----+
| id |
+-----+
| 123 |
+-----+
1 row in set (0.00 sec)
mysql> SELECT
ENGINE_TRANSACTION_ID as trx_id,
OBJECT_NAME as `table`,
INDEX_NAME,
LOCK_DATA,
LOCK_MODE,
LOCK_STATUS
FROM performance_schema.data_locks AND LOCK_TYPE="TABLE";
+--------+-------+------------+------------------------+-----------+-------------+
| trx_id | table | INDEX_NAME | LOCK_DATA | LOCK_MODE | LOCK_STATUS |
+--------+-------+------------+------------------------+-----------+-------------+
| 3852 | t | NULL | NULL | IX | GRANTED |
| 3852 | t1 | NULL | NULL | IS | GRANTED |
+--------+-------+------------+------------------------+-----------+-------------+
4 rows in set (0.00 sec)
这次,由于我们试图读取和锁定表t1的一部分,因此要求提供IS。
当我们尝试在表级别指定所需的访问权限时,可以将“整个表”与“表的一部分”区分开来。您可以想象以下组合:
(这些名称(X,S,IX,IS)是InnoDB谈论表锁的方式)
让我们花点时间找出哪些锁请求彼此兼容,哪些锁请求不能同时授予,就像我们自己设计一样。访问权限之间的兼容性关系可以简洁地总结为一个兼容性矩阵,其中一行表示您可能想要请求的每个访问权限,另一个事务已经持有的每个可能访问权限对应一列:
让我们用⌛(必须等待新请求)和✅(可以继续进行新请求)来说明上述矩阵。
显然X似乎与其他任何东西都不兼容。S似乎与其他S和IS兼容,但是它不能应付另一个线程进行的修改,即使对表的一小部分也是如此,因此它与IX冲突。
IX是否应与其他IX或IS冲突?不,拥有这种精细系统的全部目的是允许对表进行同时修改。当然,我们必须以某种方式确保两个事务不会修改冲突的行,但是当它们尝试请求访问单个行时,可以在较低的粒度级别上进行处理。请求IX的所有事务都是“允许将来请求访问行的权限”。这种“寻求许可”听起来可能很愚蠢,但至少有两个目的:
可以想象到一种不同的设计,其中不存在意图锁(IS和IX),每当事务尝试锁定单个行时,它首先必须检查是否存在冲突的S或X表锁,并且每次都要检查。事务尝试X或S要锁定一个表,它首先必须检查是否有任何冲突的记录级锁。预先指定意图的好处之一是可以减少死锁(或更快地释放死锁)。另一个原因是,如果你认为设计与“首先检查是否有现有的记录级锁”,你就会意识到你可能想要缓存这个问题的答案,避免昂贵的查找,最大程度地减少更新此信息的同步工作,并以某种方式报告正在发生的事情,……您最终将得到一些等效的IS和IX锁(或者至少要跟踪它们的“计数”)。
因此,我们最终得到以下兼容性矩阵:
(我将AUTO_INC锁放到了这个矩阵中,以使它完整,供以后参考。我希望您现在有足够的直觉来理解为什么AUTO_INC 锁必须与S冲突,以及为什么它与IX略有不同,因为它与自身冲突。剧透:AUTO_INC时在表末尾插入一行和通过自增键分配)
请注意,此矩阵具有对称的属性:如果A与B发生冲突,那么B也与A发生冲突。当处理记录级锁时,我们将看到一个没有此属性的矩阵。
从另一个角度看表锁,假设扩大到任意嵌套层次作用域(数据中心> >数据库> >表> >分区索引> >行> >字段),试图找出一种系统,可以锁定这些范围,发现冲突。例如,当其他人试图对整个数据库进行快照时,我想删除一个分区?如何对其进行建模,以跟踪正在发生的事情,并判断某人是否应该等待?我们的想法是人们只有在他们获得了所有以上级别的IX或IS(分别)之后,允许在给定的较低级别请求X或S锁。因此,要删除一个分区,显然需要X直接访问它,但是首先需要IX访问表,IX访问数据库,IX访问数据中心。如果有人想要对数据库进行快照,他们需要直接访问数据库,也就是访问数据中心。快速检测到数据库级别上S和IX之间的冲突,必须有人等待。在InnoDB中,这个层次结构只有两层:表和行。(实际上,如果你发现这个“嵌套范围”比喻有帮助,那么你可能会喜欢从另一个角度看待“行前面的GAP”也是一个范围, S,GAP 和 X,GAP 锁 是在GAP级别的 S 锁 , INSERT_INTENTION 锁是GAP级别的IX 锁。
它们和其他任何东西都不一样。有许多特殊情况的代码和逻辑提高插入大量的行的性能。您可能会认为我在本系列中所写的内容并不一定适用于它们,除非我这样说过。对于初学者,它们通常根本无法获取——在递增期间获取一个保护序列计数器的短期锁存器,并尽快释放。它们可能会在语句结束时被释放,而不是保存到事务结束时。更多细节请参见InnoDB中的AUTO_INCREMENT处理https://dev.mysql.com/doc/refman/8.0/en/innodb-auto-increment-handling.html。
如前所述,InnoDB中的大多数锁定活动都发生在记录级别,但是我发现InnoDB表锁更容易解释,因为可能的锁定模式更少(仅5种:X,S,IS,IX,AUTO_INC)和冲突关系是对称的,这能够更容易理解必要的概念。
InnoDB是一个巨大的软件,因此一定要谈论正在发生的事情的某种抽象,而不是淹没细节。因此,请您原谅我的过分简化:我们将想象索引中的一行只是轴上的一个点。也就是说,每个索引都被建模为一个单独的轴,如果按索引的升序列出各行,则沿该轴从左到右会得到一些离散的点集:
mysql> SELECT * FROM t;
+----+
| id |
+----+
| 5 |
| 10 |
| 42 |
+----+
3 rows in set (0.00 sec)
可以概念化为:
--(5)---(10)-----(42)---> id
我们的心理形象应由它们之间的点和间隙组成:
--( the gap before the row #5
5 the row #5 itself
)---( the gap before the row #10
10 the row #10 itself
)-----( the gap before the row #42
42 the row #42 itself
)---> the gap before infinity
最右边的间隙是特殊的,因为它不在任何实际行之前。您可以想象一个伪记录“无限”,它比任何其他记录都大,因此最右边的间隙是“伪记录之前”。(实际上,不是简单化的InnoDB,此问题发生在每个数据页内:有时我们需要讨论特定页面上最后一条记录之后的间隙。从概念上讲,该间隙与第一个记录上的间隙相同但是,我们处于无法访问下一页的情况,需要以某种方式在这个空白上进行交谈/识别/操作,因此InnoDB中的每个页面都有一个最高的伪记录。一种普遍的误解是“最高伪记录”标记了整个索引的结尾。不,索引的每个叶子中都有一个)
即使不了解InnoDB之类的数据库如何运行,我们也可以猜测,有时该操作仅涉及记录,有时涉及记录之前的间隙,而在其他时候,我们需要访问记录和间隙。一种建模的方法是将记录和间隙视为可以独立锁定的两种不同资源。当前的InnoDB实现采用不同的方法:每个点只有一个资源,但是您可以请求多种访问权限,而访问权限则指定您是否需要行,间隙或两个部分。这样做的好处之一是针对需要两者的最常见情况进行了优化。
当前,InnoDB中定义了许多不同的访问权限,这些权限在Performance_schema.data_locks.lock_mode列中通过使用以下文字表示:
上面的列表是一个实现细节,将来可能会更改。剩下的想法是,有许多“锁定模式”和一组规则来决定模式A中的访问请求是否必须等待对模式B中的资源进行访问的事务完成。可以通过与此类似的矩阵来给出:
注意事项:
再次说明:这些是实施细节,将来的版本中可能会更改。重要的是要认识到,你可以有一个数据库引擎更复杂的访问权限不是简单地读取和写入以及它们之间的冲突关系可以是任意的(甚至不对称或传递的)。
这篇文章已经相当长了,而且是底层的,但是如果您想了解更多的实现细节,则可能需要阅读一篇辅助文章(我将在稍后发表)。
或者,您可以跳过这些底层内容,而直接转到下一篇有关死锁检测的文章,该文章也将很快发布。
感谢您使用MySQL!
感谢您关注“MySQL解决方案工程师”!
本文分享自 MySQL解决方案工程师 微信公众号,前往查看
如有侵权,请联系 cloudcommunity@tencent.com 删除。
本文参与 腾讯云自媒体同步曝光计划 ,欢迎热爱写作的你一起参与!