在PostgreSQL中锁定:3.锁定其他对象

我们已经讨论过对象级别的一些 (尤其是关系上的锁),以及行级别的锁 ,它们与对象锁的关系以及等待队列,这并不总是诚实的。

今天我们有一个大杂烩。 让我们从死锁开始(实际上,我上次要讨论死锁 ,但是那篇文章花了很长的时间),然后我们将介绍其余的对象锁,并在最后讨论谓词锁

死锁


使用锁时,可能出现死锁 (或死锁 )情况。 当一个事务试图捕获另一个事务已经捕获的资源,而另一个事务试图捕获第一个事务捕获的资源时,就会发生这种情况。 如下图左图所示:实线箭头表示已捕获的资源,虚线箭头表示尝试捕获已占用的资源。

通过构建期望图可以方便地可视化死锁。 为此,我们删除特定资源并仅保留事务,并指出正在等待哪个事务。 如果图形具有轮廓(从顶部可以通过箭头到达)-这是一个死锁。



当然,死锁不仅可能发生在两个事务上,而且可能发生在更大的数目上。

如果发生死锁,其中涉及的事务将无能为力-它们将无限期等待。 因此,所有DBMS和PostgreSQL也自动跟踪死锁。

但是,检查需要付出一定的努力,无论何时请求新的锁定,我都不想这样做(毕竟,死锁非常少见)。 因此,当进程尝试捕获锁而无法捕获该锁时,它将进入队列并进入睡眠状态,但会通过deadlock_timeout参数中指定的值(默认值为1秒)启动计时器。 如果资源较早释放,那么很好,我们保存了验证。 但是,如果在deadlock_timeout之后等待继续进行,则等待过程将被唤醒并启动检查。

如果检查(包括构造期望图并在其中搜索轮廓)没有显示出死锁,则该过程将继续休眠-现在已经结束了。

在评论的较早部分,我没责备我什么也没说,因为它对任何运算符都起作用,并且避免了无限长的等待:关于lock_timeout参数的操作:如果在指定的时间内无法获得锁定,该语句将以lock_not_available错误结束。 不应将它与statement_timeout参数混淆,后者会限制语句的总执行时间,无论它期望锁定还是仅执行作业。

如果检测到死锁,则其中一个事务(大多数情况下是发起检查的事务)被强制终止。 在这种情况下,由它捕获的锁将被释放,其余事务可以继续工作。

死锁通常意味着应用程序设计不正确。 有两种方法可以检测到这种情况:首先,消息将出现在服务器日志中,其次,pg_stat_database.deadlocks的值将增加。

死锁示例


死锁的常见原因是表中行被锁定的顺序不同。
一个简单的例子。 第一笔交易打算从第一个帐户向第二个帐户转移100卢布。 为此,她首先减少了第一计数:

=> BEGIN; => UPDATE accounts SET amount = amount - 100.00 WHERE acc_no = 1; 
 UPDATE 1 

同时,第二笔交易打算从第二个帐户向第一个帐户转帐10卢布。 她从减少第二个计数开始:

 | => BEGIN; | => UPDATE accounts SET amount = amount - 10.00 WHERE acc_no = 2; 
 | UPDATE 1 

现在,第一个交易正在尝试增加第二个帐户,但是发现该行已被锁定。

 => UPDATE accounts SET amount = amount + 100.00 WHERE acc_no = 2; 

然后,第二笔交易尝试增加第一个帐户,但也被阻止。

 | => UPDATE accounts SET amount = amount + 10.00 WHERE acc_no = 1; 

周期性的期望永远不会结束。 一秒钟之后,无法访问资源的第一个事务将启动死锁检查并断开服务器。

 ERROR: deadlock detected DETAIL: Process 16477 waits for ShareLock on transaction 530695; blocked by process 16513. Process 16513 waits for ShareLock on transaction 530694; blocked by process 16477. HINT: See server log for query details. CONTEXT: while updating tuple (0,2) in relation "accounts" 

现在,第二笔交易可以继续。

 | UPDATE 1 
 | => ROLLBACK; 

 => ROLLBACK; 

执行此类操作的正确方法是按相同顺序阻塞资源。 例如,在这种情况下,您可以按帐户编号的升序阻止帐户。

两个更新命令的死锁


有时,您似乎会陷入僵局,似乎应该不会出现僵局。 例如,将SQL命令视为原子命令是方便且熟悉的,但是采用UPDATE-此命令在更新行时会阻塞行。 这不会立即发生。 因此,如果一个命令以一种顺序更新行,而另一种以另一种顺序更新,则它们可能会死锁。

不太可能出现这种情况,但是仍然可以解决。 为了回放,我们将在数量列上创建索引,以数量降序构建:

 => CREATE INDEX ON accounts(amount DESC); 

为了有时间了解发生了什么,我们将编写一个函数,该函数会增加传输的值,但要缓慢,缓慢地持续一秒钟:

 => CREATE FUNCTION inc_slow(n numeric) RETURNS numeric AS $$ SELECT pg_sleep(1); SELECT n + 100.00; $$ LANGUAGE SQL; 

我们还需要pgrowlocks扩展。

 => CREATE EXTENSION pgrowlocks; 

第一条UPDATE命令将更新整个表。 执行计划很明显-顺序扫描:

 | => EXPLAIN (costs off) | UPDATE accounts SET amount = inc_slow(amount); 
 | QUERY PLAN | ---------------------------- | Update on accounts | -> Seq Scan on accounts | (2 rows) 

由于表格页面上各行的版本按总和的升序排列(恰好是我们所添加的行数),因此它们将以相同的顺序进行更新。 我们开始进行更新。

 | => UPDATE accounts SET amount = inc_slow(amount); 

同时,在另一个会话中,我们将禁止使用顺序扫描:

 || => SET enable_seqscan = off; 

在这种情况下,调度程序决定对以下UPDATE语句使用索引扫描:

 || => EXPLAIN (costs off) || UPDATE accounts SET amount = inc_slow(amount) WHERE amount > 100.00; 
 || QUERY PLAN || -------------------------------------------------------- || Update on accounts || -> Index Scan using accounts_amount_idx on accounts || Index Cond: (amount > 100.00) || (3 rows) 

第二行和第三行属于该条件,并且由于索引是按降序构建的,因此这些行将以相反的顺序进行更新。

我们启动下一个更新。

 || => UPDATE accounts SET amount = inc_slow(amount) WHERE amount > 100.00; 

快速浏览一下表格页面,表明第一个操作员已经设法更新了第一行(0,1),第二个操作符-最后一行(0,3):

 => SELECT * FROM pgrowlocks('accounts') \gx 
 -[ RECORD 1 ]----------------- locked_row | (0,1) locker | 530699 <-  multi | f xids | {530699} modes | {"No Key Update"} pids | {16513} -[ RECORD 2 ]----------------- locked_row | (0,3) locker | 530700 <-  multi | f xids | {530700} modes | {"No Key Update"} pids | {16549} 

再过一秒钟。 第一个操作员更新了第二行,第二个操作员希望这样做,但不能。

 => SELECT * FROM pgrowlocks('accounts') \gx 
 -[ RECORD 1 ]----------------- locked_row | (0,1) locker | 530699 <-  multi | f xids | {530699} modes | {"No Key Update"} pids | {16513} -[ RECORD 2 ]----------------- locked_row | (0,2) locker | 530699 <-    multi | f xids | {530699} modes | {"No Key Update"} pids | {16513} -[ RECORD 3 ]----------------- locked_row | (0,3) locker | 530700 <-  multi | f xids | {530700} modes | {"No Key Update"} pids | {16549} 

现在,第一条语句想要更新表的最后一行,但是第二条已经将其锁定。 这是僵局。

事务之一被中止:

 || ERROR: deadlock detected || DETAIL: Process 16549 waits for ShareLock on transaction 530699; blocked by process 16513. || Process 16513 waits for ShareLock on transaction 530700; blocked by process 16549. || HINT: See server log for query details. || CONTEXT: while updating tuple (0,2) in relation "accounts" 

另一个完成执行:

 | UPDATE 3 

有关检测和防止死锁的有趣细节,可以在README锁管理器中找到

这些都与死锁有关,我们继续进行其余的对象锁。



非关系锁


当您想要锁定与PostgreSQL 无关的资源时,将使用对象锁定。 这样的资源几乎可以是任何东西:表空间,订阅,模式,角色,枚举数据类型...大致来说,可以在系统目录中找到的所有内容。

让我们看一个简单的例子。 我们开始事务并在其中创建一个表:

 => BEGIN; => CREATE TABLE example(n integer); 

现在,让我们看看pg_locks中出现了什么类型的对象锁:

 => SELECT database, (SELECT datname FROM pg_database WHERE oid = l.database) AS dbname, classid, (SELECT relname FROM pg_class WHERE oid = l.classid) AS classname, objid, mode, granted FROM pg_locks l WHERE l.locktype = 'object' AND l.pid = pg_backend_pid(); 
  database | dbname | classid | classname | objid | mode | granted ----------+--------+---------+--------------+-------+-----------------+--------- 0 | | 1260 | pg_authid | 16384 | AccessShareLock | t 16386 | test | 2615 | pg_namespace | 2200 | AccessShareLock | t (2 rows) 

要了解此处到底阻止了什么,您需要查看三个字段:数据库,类ID和对象ID。 让我们从第一行开始。

数据库是锁定资源所属的数据库的OID。 在我们的情况下,此列为零。 这意味着我们正在处理不属于任何特定基础的全局对象。

Classid包含pg_class的OID,它与确定资源类型的系统目录表的名称相对应。 在我们的例子中,pg_authid,即角色是资源(用户)。

Objid包含系统目录表中由classid指示给我们的OID。

 => SELECT rolname FROM pg_authid WHERE oid = 16384; 
  rolname --------- student (1 row) 

因此,我们正在从中阻止学生角色。

现在让我们处理第二行。 指示数据库,这是我们连接到的测试数据库。

Classid指向包含模式的pg_namespace表。

 => SELECT nspname FROM pg_namespace WHERE oid = 2200; 
  nspname --------- public (1 row) 

因此,公共架构被阻止。

因此,我们看到在创建对象时,所有者角色和创建对象的方案被阻止(在共享模式下)。 这是合乎逻辑的:否则,有人可以在事务尚未完成时删除角色或架构。

 => ROLLBACK; 

关系扩展锁


当关系中(即表,索引,实例化视图中的)行数增加时,PostgreSQL可以使用现有页面中的可用空间进行插入,但是显然,在某些时候,您必须添加新页面。 实际上,它们被添加到相应文件的末尾。 这被理解为扩大关系

为了防止两个进程同时急于添加页面,此过程由类型为extend的特殊锁保护。 清理索引时使用相同的锁,以便其他进程无法在扫描期间添加页面。

当然,无需等待事务结束即可释放此锁。

以前,表格一次只能扩展一页。 当多个进程同时插入行时,这会引起问题,因此,在PostgreSQL 9.6中,几个页面一次添加到了表中(与等待锁定的进程数量成比例,但不超过512)。

页面锁定


页面级锁仅在这种情况下适用(谓词锁除外,这将在后面讨论)。

GIN索引使您可以加快复合值的搜索速度,例如,文本文档中的单词(或数组中的元素)。 首先近似地,这样的索引可以表示为规则的B树,其中不存储文档本身,而是存储这些文档的各个单词。 因此,在添加新文档时,必须大力重建索引,将文档中包含的每个单词引入其中。

为了提高性能,GIN索引具有延迟插入功能,该功能由fastupdate存储选项启用。 首先将新单词快速添加到无序的待处理列表中,并在一段时间后,将已累积的所有内容移至主索引结构。 节省是由于不同的文档可能包含重复的单词。

为了避免多个进程同时从等待列表移至主索引,在传输过程中以独占模式阻止了索引元页。 这不会阻止在正常模式下使用索引。

咨询锁


与其他锁(例如关系锁)不同,咨询锁从不自动设置,而是由应用程序开发人员管理。 例如,如果应用程序出于某种目的需要阻塞逻辑而不适合普通锁的标准逻辑,则使用它们很方便。

假设我们有一个条件资源,它不与任何数据库对象相对应(我们可以使用诸如SELECT FOR或LOCK TABLE之类的命令来阻止它)。 您需要为其提供数字标识符。 如果资源具有唯一名称,则一个简单的选择是从中获取哈希码:

 => SELECT hashtext('1'); 
  hashtext ----------- 243773337 (1 row) 

这是我们捕获锁的方式:

 => BEGIN; => SELECT pg_advisory_lock(hashtext('1')); 

和往常一样,锁信息在pg_locks中可用:

 => SELECT locktype, objid, mode, granted FROM pg_locks WHERE locktype = 'advisory' AND pid = pg_backend_pid(); 
  locktype | objid | mode | granted ----------+-----------+---------------+--------- advisory | 243773337 | ExclusiveLock | t (1 row) 

为了使锁真正起作用,其他进程在访问资源之前也必须获得一个锁。 应用程序显然应确保遵守此规则。

在上面的示例中,锁定一直有效,直到会话结束为止,而不是像往常一样在事务结束之前。

 => COMMIT; => SELECT locktype, objid, mode, granted FROM pg_locks WHERE locktype = 'advisory' AND pid = pg_backend_pid(); 
  locktype | objid | mode | granted ----------+-----------+---------------+--------- advisory | 243773337 | ExclusiveLock | t (1 row) 

必须明确发布:

 => SELECT pg_advisory_unlock(hashtext('1')); 

在所有情况下,都有大量的功能可用于使用咨询锁:

  • pg_advisory_lock_shared对待共享锁,
  • pg_advisory_xact_lock(和pg_advisory_xact_lock_shared)将获得一个锁,直到事务结束为止,
  • pg_try_advisory_lock(以及pg_try_advisory_xact_lock和pg_try_advisory_xact_lock_shared)不希望收到锁,但是如果不能立即获得该锁,则返回假值。

除了上一篇文章中列出的那些功能外,尝试函数集还提供了另一种不等待锁定的方法。

谓词锁


谓词锁定一词很早就出现了,它是在早期的DBMS中首次尝试基于锁定实现完全隔离的(级别是Serializable,尽管当时还没有SQL标准)。 然后遇到的问题是,即使阻塞所有读取和更改的行也无法提供完全隔离:在相同选择条件下,表中可能会出现行,从而导致幻像 (请参见隔离文章 )。 。

谓词锁的想法是阻止谓词,而不是行。 如果在条件a > 10的条件下执行查询时,谓词a > 10被阻止,则不会将新的行添加到该条件下的表中,并且将避免产生幻像。 问题在于,在一般情况下,这是一项计算难题。 在实践中,只能对具有非常简单形式的谓词进行求解。

在PostgreSQL中,除了基于快照的现有隔离之外,Serializable层的实现方式也不同。 谓词锁定一词仍然存在,但其含义已发生根本变化。 实际上,此类“锁”不会阻止任何内容,而是用于跟踪事务之间的数据依赖性。

事实证明,基于图像的隔离允许记录不一致异常和仅读取事务异常 ,但是其他异常是不可能的。 要了解我们正在处理列出的两个异常之一,我们可以分析事务之间的依赖关系并在其中找到某些模式。

我们对两种类型的依赖项感兴趣:

  • 一个事务读取一行,然后被另一事务更改(RW相关性),
  • 一个事务修改另一个事务然后读取的行(WR依赖项)。

可以使用现有的常规锁来跟踪WR相关性,但是仅需另外跟踪RW相关性即可。

我再说一遍:尽管有名称,但谓词锁不会阻止任何内容。 相反,当提交事务时,将执行检查,并且如果检测到可能指示异常的“不良”依赖关系序列,则事务中断。

让我们看看谓词锁的安装是如何发生的。 为此,请创建一个具有足够多行和一个索引的表。

 => CREATE TABLE pred(n integer); => INSERT INTO pred(n) SELECT gn FROM generate_series(1,10000) g(n); => CREATE INDEX ON pred(n) WITH (fillfactor = 10); => ANALYZE pred; 

如果查询是通过顺序扫描整个表执行的,则谓词锁定将设置在整个表上(即使不是所有行都处于过滤条件下)。

 | => SELECT pg_backend_pid(); 
 | pg_backend_pid | ---------------- | 12763 | (1 row) 

 | => BEGIN ISOLATION LEVEL SERIALIZABLE; | => EXPLAIN (analyze, costs off) | SELECT * FROM pred WHERE n > 100; 
 | QUERY PLAN | ---------------------------------------------------------------- | Seq Scan on pred (actual time=0.047..12.709 rows=9900 loops=1) | Filter: (n > 100) | Rows Removed by Filter: 100 | Planning Time: 0.190 ms | Execution Time: 15.244 ms | (5 rows) 

任何谓词锁始终在一种特殊的SIReadLock(可序列化隔离读取)模式下捕获:

 => SELECT locktype, relation::regclass, page, tuple FROM pg_locks WHERE mode = 'SIReadLock' AND pid = 12763; 
  locktype | relation | page | tuple ----------+----------+------+------- relation | pred | | (1 row) 

 | => ROLLBACK; 

但是,如果使用索引扫描执行查询,则情况会更好。 如果我们谈论B树,那么就足以在读取的表行和索引的扫描叶页上设置锁定-这样,我们不仅阻止特定值,还阻止读取的整个范围。

 | => BEGIN ISOLATION LEVEL SERIALIZABLE; | => EXPLAIN (analyze, costs off) | SELECT * FROM pred WHERE n BETWEEN 1000 AND 1001; 
 | QUERY PLAN | ------------------------------------------------------------------------------------ | Index Only Scan using pred_n_idx on pred (actual time=0.122..0.131 rows=2 loops=1) | Index Cond: ((n >= 1000) AND (n <= 1001)) | Heap Fetches: 2 | Planning Time: 0.096 ms | Execution Time: 0.153 ms | (5 rows) 

 => SELECT locktype, relation::regclass, page, tuple FROM pg_locks WHERE mode = 'SIReadLock' AND pid = 12763; 
  locktype | relation | page | tuple ----------+------------+------+------- tuple | pred | 3 | 236 tuple | pred | 3 | 235 page | pred_n_idx | 22 | (3 rows) 

您可能会注意到一些困难。

首先,为读取的行的每个版本创建一个单独的锁,但是可能会有很多这样的版本。 系统中的谓词锁定总数受参数值max_pred_locks_per_transaction × max_connections的乘积限制(默认值分别为64和100)。 此类锁的内存是在服务器启动时分配的; 尝试超过此数字将导致错误。

因此,对于谓词锁(仅适用于它们!),使用级别增加 。 在PostgreSQL 10之前,代码中存在一些硬性限制,从它开始,您可以通过提高级别来控制参数。 如果每行的行版本锁的数量大于max_pred_locks_per_page则将这些锁替换为一页级别的锁。 这是一个例子:

 => SHOW max_pred_locks_per_page; 
  max_pred_locks_per_page ------------------------- 2 (1 row) 

 | => EXPLAIN (analyze, costs off) | SELECT * FROM pred WHERE n BETWEEN 1000 AND 1002; 
 | QUERY PLAN | ------------------------------------------------------------------------------------ | Index Only Scan using pred_n_idx on pred (actual time=0.019..0.039 rows=3 loops=1) | Index Cond: ((n >= 1000) AND (n <= 1002)) | Heap Fetches: 3 | Planning Time: 0.069 ms | Execution Time: 0.057 ms | (5 rows) 

而不是三个元组锁,我们看到一种页面类型:

 => SELECT locktype, relation::regclass, page, tuple FROM pg_locks WHERE mode = 'SIReadLock' AND pid = 12763; 
  locktype | relation | page | tuple ----------+------------+------+------- page | pred | 3 | page | pred_n_idx | 22 | (2 rows) 

类似地,如果与单个关系相关联的页面锁的数量超过max_pred_locks_per_relation ,则将这些锁替换为一个关系级锁。

没有其他级别:谓词锁仅针对关系,页面或行版本捕获,并且始终使用SIReadLock模式。

当然,锁级别的增加不可避免地导致以下事实:大量事务将错误地导致序列化错误,结果,系统的吞吐量将下降。 在这里,您需要在内存消耗和性能之间寻求平衡。

第二个困难是在使用索引进行的各种操作中(例如,由于插入新行时索引页的分裂),覆盖读取范围的纸张页数可能会发生变化。 但这要考虑到:

 => INSERT INTO pred SELECT 1001 FROM generate_series(1,1000); => SELECT locktype, relation::regclass, page, tuple FROM pg_locks WHERE mode = 'SIReadLock' AND pid = 12763; 
  locktype | relation | page | tuple ----------+------------+------+------- page | pred | 3 | page | pred_n_idx | 211 | page | pred_n_idx | 212 | page | pred_n_idx | 22 | (4 rows) 

 | => ROLLBACK; 

顺便说一句,谓词锁并不总是在事务完成后立即删除,因为需要它们来跟踪多个事务之间的依赖关系。 但是无论如何,它们都是自动管理的。

并非PostgreSQL中的所有索引类型都支持谓词锁。 以前,只有B树可以夸耀它,但是在PostgreSQL 11中,情况有所改善:将哈希索引,GiST和GIN添加到列表中。 如果使用索引访问,并且该索引不适用于谓词锁,则整个索引都将锁定到该锁。 当然,这也会增加虚假交易中断的次数。

总之,我注意到谓词锁的使用存在一个限制,即为了确保完全隔离, 所有事务必须在可序列化级别上工作。 如果事务使用不同级别,则它根本不会设置(并检查)谓词锁。

按照传统,我将在谓词锁上保留指向README的链接,您可以从中开始研究源代码。

待续

Source: https://habr.com/ru/post/zh-CN465263/


All Articles