转载

PostgreSQL 锁解密

锁机制在 PostgreSQL 里非常重要 (对于其他现代的 RDBMS 也是如此)。对于数据库应用程序开发者(特别是那些涉及到高并发代码的程序员),需要对锁非常熟悉。对于某些问题,锁需要被重点关注与检查。大部分情况,这些问题跟死锁或者数据不一致有关系,基本上都是由于对 Postgres 的锁机制不太了解导致的。虽然锁机制在 Postgres 内部很重要,但是文档缺非常缺乏,有时甚至还是错误的,与文档所指出的结果不一致。我会告诉你精通 Postgres 的锁机制需要知道的一切,要知道对锁了解的越多,解决与锁相关的问题就会越快。

文档里都说了些什么?

Postgres 有 3 种锁机制: 表级锁, 行级锁和 建议性锁。 表级和行级的锁可以是显式的也可以是隐式的。建议性锁一般是显式的。显式的锁由显式的用户请求(通过特殊的查询)获取,隐式的锁是通过标准的 SQL 命令来获取。

除了 表级行级 的锁,还有 页级共享/排除锁, 用于控制对共享缓存池里表页的访问。在一行数据被读取或者更新后,这些锁会立即被释放。应用程序开发者通常不需要关注页级的锁。

锁机制会不时的变动,所以我们这里只针对 Postgres 9.x 的版本。9.1 和 9.2 基本上是差不多的,9.3 和 9.4 跟它们有些区别,主要涉及行级锁。

表级锁

大多数的表级锁是由内置的 SQL 命令获得的,但他们也可以通过锁命令来明确获取。可使用的表级锁包括:

  • 访问共享( ACCESS SHARE) - SELECT 命令可在查询中引用的表上获得该锁 一般规则是所有的查询中只有读表才获取此锁。

  • 行共享( ROW SHARE) - SELECT FOR UPDATE 和 SELECT FOR SHARE 命令可在目标表上获得该锁(以及查询中所有引用的表的访问共享锁)

  • 行独占( ROW EXCLUSIVE) - UPDATE、INSERT 和 DELETE 命令在目标表上获得该锁(以及查询中所有引用的表的访问共享锁)。 一般规则是所有修改表的查询获得该锁。

  • 共享更新独占( SHARE UPDATE EXCLUSIVE) - VACUUM(不含FULL),ANALYZE,CREATE INDEX CONCURRENTLY,和一些 ALTER TABLE 的命令获得该锁。

  • 共享( SHARE) - CREATE INDEX 命令在查询中引用的表上获得该锁。

  • 共享行独占( SHARE ROW EXCLUSIVE) - 不被任何命令隐式获取。

  • 排他( EXCLUSIVE) - 这个锁模式在事务获得此锁时只允许读取操作并行。它不能由任何命令隐式获取

  • 访问独占( ACCESS EXCLUSIVE) - ALTER TABLE,DROP TABLE,TRUNCATE,REINDEX,CLUSTER 和 VACUUM FULL 命令在查询中引用的表上获得该锁。此锁模式是 LOCK 命令的默认模式。

重要的是要知道,所有这些锁都是 表级 ,即使它们名称里有 ROW )字。

每个锁模式的最重要的信息是与彼此冲突的模式列表。在同一时间同一个表中,2 个事务不能同时保持相冲突的锁模式。事务永远不会与自身发生冲突。 非冲突的锁可以支持多事务并发。同样重要的是要知道有的模式和自身冲突。一些锁模式在获得后会持续到事务结束。但如果锁是在建立一个保存点后获得,保存点回滚后锁会被立刻释放。 下面的表格展示了哪些模式是互相冲突的:

PostgreSQL 锁解密

行级锁

在 Postgres 9.1 和 9.2 有两种行级锁模式,但在 Postgres 9.3 和 9.4 有四种行级锁模式。

Postgres 不会记住修改的行在内存中的任何信息,所以一次锁定的行的数目没有限制。然而,锁定一行可能会导致磁盘写入,例如, SELECT FOR UPDATE 修改选定的行并标记它们锁定,所以会导致磁盘写入。

Postgres 9.1 和 9.2 中的行级锁

在这两种版本中,只有 2 种行级锁:排他或共享锁。当行更新或删除时,会自动获得排他行级锁。行级锁不阻止数据查询,它们只阻止同一行写入。 排他行级锁可由 SELECT FOR UPDATE 命令明确 获得,即使行没有实际更改。

共享行级锁可由 SELECT FOR SHARE 命令 获得。一个共享锁并不阻止其他事务获取同样的共享锁。然而, 当任何其他事务持有共享锁时, 事务的更新、删除或排他锁都 不被允许

Postgres 9.3 和 9.4 中的行级锁

在 Postgres 9.3 和 9.4 中有四种类型的行级锁:

  • 更新(FOR UPDATE)- 这种模式导致 SELECT 读取的行的更新被锁定。这可以防止它们被其他事务锁定,修改或删除。即尝试 UPDATE、DELETE、SELECT FOR UPDATE、SELECT FOR NO KEY UPDATE、SELECT FOR SHARE 或 SELECT FOR KEY SHARE 的其他事务将被阻塞。删除一行,更新一些列也可以获得到此种锁模式(目前的列集是指那些具有唯一索引,并且可被用作外键 - 但将来这可能会改变 )。

  • 无键更新( FOR NO KEY UPDATE) - 这种模式与 FOR UPDATE 相似,但是更弱 - 它不会阻塞SELECT FOR KEY SHARE 锁模式。它通过不获取更新锁的 UPDATE 命令获得。

  • 共享(FOR SHARE)- 这种模式与无键更新锁类似,除了它可以获取共享锁(非排他)。一个共享锁阻止其他事务在这些行上进行 UPDATE,DELETE,SELECT FOR UPDATE 或 SELECT FOR NO KEY UPDATE 操作,但并不阻止它们进行 SELECT FOR SHARE 或 SELECT FOR KEY SHARE。

  • 键共享 FOR KEY SHARE - 行为类似于共享,但该锁是较弱的:阻止了 SELECT FOR UPDATE,但不阻止 SELECT FOR NO KEY UPDATE。一个键共享锁阻止其他事务进行 DELETE 或任何更改该键值的 UPDATE,但不妨碍任何其他 UPDATE、SELECT FOR NO KEY UPDATE、SELECT FOR SHARE 或者SELECT FOR KEY SHARE。

行级锁冲突:

PostgreSQL 锁解密

劝告锁

Postgres提供创建具有应用定义的锁的方法,这些被称为劝告锁(advisory locks),因为系统并不支持其使用,其取决于应用对锁的正确使用。

Postgres中有两种途径可以获得一个劝告锁:会话层级或事务层级。一旦在会话层级获得劝告锁,会一直保持到被显式释放或会话结束。不同于标准的锁请求,会话层级的劝告锁请求并不遵守事务语义:事务被回滚后锁也会随着回滚保持着,同样地即使调用锁的事务之后失败了,解锁请求仍然是有效的。一个锁可以被拥有它的进程多次获取;对于每个完成的锁请求,在锁被真正释放前一定要有一个对应的解锁请求。

另一方面,事务层级的锁请求表现得更像普通的锁请求:它们在事务结束时会自动释放,并且没有显式的解锁操作。对于短暂地使用劝告锁,这种特性通常比会话层级更方便。可以想见,会话层级与事务层级请求同一个劝告锁标识符会互相阻塞。如果一个会话已经有了一个劝告锁,它再请求时总会成功的,即使其他会话在等待此锁;不论保持现有的锁和新的请求是会话层级还是事务层级,都是这样。文档中可以找到操作劝告锁的完整函数列表。

这里有几个获取事务层级劝告锁的例子(pg_locks是系统视图,文章之后会说明。它存有事务保持的表级锁和劝告锁的信息):

启动第一个psql会话,开始一个事务并获取一个劝告锁:

-- Transaction 1 BEGIN; SELECT pg_advisory_xact_lock(1); -- Some work here

现在启动第二个psql会话并在同一个劝告锁上执行一个新的事务:

-- Transaction 2 BEGIN; SELECT pg_advisory_xact_lock(1); -- This transaction is now blocked

在第三个psql会话里我们可以看下这个锁现在的情况:

SELECT * FROM pg_locks;-- Only relevant parts of output    locktype    | database | relation | page | tuple | virtualxid | transactionid | classid | objid | objsubid | virtualtransaction |  pid  |        mode         | granted |fastpath---------------+----------+----------+------+-------+------------+---------------+---------+-------+----------+--------------------+-------+---------------------+---------+----------     advisory   |    16393 |          |      |       |            |               |       0 |     1 |        1 | 4/36               |  1360 | ExclusiveLock       | f       | f     advisory   |    16393 |          |      |       |            |               |       0 |     1 |        1 | 3/186              | 14340 | ExclusiveLock       | t       | f
-- Transaction 1 COMMIT; -- This transaction now released lock, so Transaction 2 can continue

我们同样可以调用获取锁的非阻塞方法,这些方法会尝试去获取锁,并返回true(如果成功了)或者false(如果无法获取锁)。

-- Transaction 1 BEGIN; SELECT pg_advisory_xact_lock(1); -- Some work here
-- Transaction 2 BEGIN; SELECT pg_try_advisory_xact_lock(1) INTO vLockAcquired; IF vLockAcquired THEN -- Some work ELSE -- Lock not acquired END IF;
-- Transaction 1 COMMIT;

现在练习一下。。。

监控锁

所有活动事务持有的监控锁的基本配置即为系统视图 pg_locks。这个视图为每个可加锁的对象、已请求的锁模式和相关事务包含一行记录。非常重要的一点是,pg_locks 持有内存中被跟踪的锁的信息,所以它不显示行级锁!(译注:据查以前的文档,有关行级锁的信息是存在磁盘上,而非内存)这个视图显示表级锁和劝告锁。如果一个事务在等待一个行级锁,它通常在视图中显示为在等待该行级锁的当前所有者的固定事务 ID。这使得调试行级锁更为困难。事实上,在任何地方你都看不到行级锁,直到有人阻塞了持有此锁的事务(然后你在 pg_locks 表里可以看到一个被上锁的元组)。pg_locks 是可读性欠佳的视图(不是很人性化),所以我们来让显示锁定信息的视图更好接受些:

-- View with readable locks info and filtered out locks on system tables CREATE VIEW active_locks AS SELECT clock_timestamp(), pg_class.relname, pg_locks.locktype, pg_locks.database,  pg_locks.relation, pg_locks.page, pg_locks.tuple, pg_locks.virtualtransaction,  pg_locks.pid, pg_locks.mode, pg_locks.granted FROM pg_locks JOIN pg_class ON pg_locks.relation = pg_class.oid WHERE relname !~ '^pg_' and relname <> 'active_locks'; -- Now when we want to see locks just type SELECT * FROM active_locks; 

现在我们有了做实验的游乐场。。。

简单示例

我们创建一些用于练习的表:

CREATE TABLE parent (   id serial NOT NULL PRIMARY KEY,   name text NOT NULL);      CREATE TABLE child (   id serial NOT NULL PRIMARY KEY,   parent_id int4 NOT NULL,   name text NOT NULL,  CONSTRAINT child_parent_fk FOREIGN KEY (parent_id) REFERENCES parent(id) );

并尝试一些简单的事务,看看锁是什么样的:

BEGIN; SELECT * FROM active_locks; -- There are no active locks yet  clock_timestamp | relname | locktype | database | relation | page | tuple | virtualtransaction | pid | mode | granted -----------------+---------+----------+----------+----------+------+-------+--------------------+-----+------+--------- (0 rows) INSERT INTO parent (name) VALUES ('Parent 1'); SELECT * FROM active_locks;       clock_timestamp       |    relname    | locktype | database | relation | page | tuple | virtualtransaction | pid  |       mode       | granted ----------------------------+---------------+----------+----------+----------+------+-------+--------------------+------+------------------+---------  2015-04-12 13:43:02.896+02 | parent_id_seq | relation |    16393 |    16435 |      |       | 3/150              | 9000 | AccessShareLock  | t  2015-04-12 13:43:02.896+02 | parent        | relation |    16393 |    16437 |      |       | 3/150              | 9000 | RowExclusiveLock | t (2 rows) COMMIT; SELECT * FROM active_locks;  clock_timestamp | relname | locktype | database | relation | page | tuple | virtualtransaction | pid | mode | granted -----------------+---------+----------+----------+----------+------+-------+--------------------+-----+------+--------- (0 rows)

我们可以看到在 parent 表里插入一行后,我们获得了 parent 表上的行独占锁。parent_id_seq 是 parent 表的主键序列。由于这种关系被选中(如表),我们获得了访问共享锁。

咱们试着往 child 表里插点东西;

BEGIN; INSERT INTO child (parent_id, name) VALUES (1, 'Child 1 Parent 1'); SELECT * FROM active_locks;       clock_timestamp      |   relname    | locktype | database | relation | page | tuple | virtualtransaction | pid  |       mode       | granted ---------------------------+--------------+----------+----------+----------+------+-------+--------------------+------+------------------+---------  2015-04-12 13:50:48.17+02 | parent_pkey  | relation |    16393 |    16444 |      |       | 3/152              | 9000 | AccessShareLock  | t  2015-04-12 13:50:48.17+02 | parent       | relation |    16393 |    16437 |      |       | 3/152              | 9000 | RowShareLock     | t  2015-04-12 13:50:48.17+02 | child_id_seq | relation |    16393 |    16446 |      |       | 3/152              | 9000 | AccessShareLock  | t  2015-04-12 13:50:48.17+02 | child        | relation |    16393 |    16448 |      |       | 3/152              | 9000 | RowExclusiveLock | t (4 rows) COMMIT;

现在的情况就有趣多了。我们可以看到 parent 表上增加的行共享锁。我们看不到的是,这个插入同样获得了 parent 表上引用行的行级共享锁。并行执行两个事务我们就可以看到它了:

-- Transaction 1 BEGIN; INSERT INTO child (parent_id, name) VALUES (1, 'Child 2 Parent 1');
-- Transaction 2 BEGIN; DELETE FROM parent WHERE id = 1;

现在开始第三个会话,看看我们的锁怎么样了:

SELECT * FROM active_locks;       clock_timestamp       |   relname    | locktype | database | relation | page | tuple | virtualtransaction | pid  |        mode         | granted ----------------------------+--------------+----------+----------+----------+------+-------+--------------------+------+---------------------+---------  2015-04-12 14:18:35.005+02 | parent_pkey  | relation |    16393 |    16444 |      |       | 4/32               | 4428 | RowExclusiveLock    | t  2015-04-12 14:18:35.005+02 | parent       | relation |    16393 |    16437 |      |       | 4/32               | 4428 | RowExclusiveLock    | t  2015-04-12 14:18:35.005+02 | parent_pkey  | relation |    16393 |    16444 |      |       | 3/153              | 9000 | AccessShareLock     | t  2015-04-12 14:18:35.005+02 | parent       | relation |    16393 |    16437 |      |       | 3/153              | 9000 | RowShareLock        | t  2015-04-12 14:18:35.005+02 | child_id_seq | relation |    16393 |    16446 |      |       | 3/153              | 9000 | AccessShareLock     | t  2015-04-12 14:18:35.005+02 | child        | relation |    16393 |    16448 |      |       | 3/153              | 9000 | RowExclusiveLock    | t  2015-04-12 14:18:35.005+02 | parent       | tuple    |    16393 |    16437 |    0 |     1 | 4/32               | 4428 | AccessExclusiveLock | t (7 rows)

DELETE 查询被阻塞了,等待事务 1 完成。我们可以看到它在元组 1 上获得了一个锁。但是如果我们看到所有的锁都是准许的(granted=t),为什么 DELETE 查询被阻塞了?这两个事务在任何关系上的锁都没有同步过。事实上,如果一个事务在某些行上持有一个锁,第二个事务请求这个锁,第二个事务会尝试获取持有此锁的事务上的共享锁。当第一个事务完成时,第二个事务将继续。这是可能的,因为每个事务都持有它自身的排他锁。我们可以看看 pg_locks 视图,这是输出(只有部分是重要的):

   locktype    | database | relation | page | tuple | virtualxid | transactionid | classid | objid | objsubid | virtualtransaction |  pid  |        mode         | granted |fastpath---------------+----------+----------+------+-------+------------+---------------+---------+-------+----------+--------------------+-------+---------------------+---------+----------  transactionid |          |          |      |       |            |           707 |         |       |          | 3/153              |  9000 | ExclusiveLock       | t       | f  transactionid |          |          |      |       |            |           707 |         |       |          | 4/32               |  4428 | ShareLock           | f       | f  transactionid |          |          |      |       |            |           708 |         |       |          | 4/32               |  4428 | ExclusiveLock       | t       | f

我们可以看到事务 707(pid 9000)和事务 708(pid 4428)拥有它们事务标识上的排他锁,事 务708 获得了事务 707 上的共享锁。

现在,最有趣的样本。我们可以玩玩更新子表但不实际改变任何父表与相关的东西(在这个案例中是parent_id列)。

BEGIN;  UPDATE child SET name = 'My new name' WHERE id = 1;  SELECT * FROM active_locks;   clock_timestamp   |  relname   | locktype | database | relation | page | tuple | virtualtransaction | pid  |   mode   | granted----------------------------+------------+----------+----------+----------+------+-------+--------------------+------+------------------+---------  2015-04-14 09:05:42.713+02 | child_pkey | relation |    16393 |    16455 |  |   | 3/183      | 3660 | RowExclusiveLock | t  2015-04-14 09:05:42.713+02 | child  | relation |    16393 |    16448 |  |   | 3/183      | 3660 | RowExclusiveLock | t (2 rows)  UPDATE child SET name = 'My new name' WHERE id = 1;  SELECT * FROM active_locks;   clock_timestamp   |   relname   | locktype | database | relation | page | tuple | virtualtransaction | pid  |   mode   | granted----------------------------+-------------+----------+----------+----------+------+-------+--------------------+------+------------------+---------  2015-04-14 09:05:45.765+02 | parent_pkey | relation |    16393 |    16444 |  |   | 3/183      | 3660 | AccessShareLock  | t  2015-04-14 09:05:45.765+02 | parent  | relation |    16393 |    16437 |  |   | 3/183      | 3660 | RowShareLock | t  2015-04-14 09:05:45.765+02 | child_pkey  | relation |    16393 |    16455 |  |   | 3/183      | 3660 | RowExclusiveLock | t  2015-04-14 09:05:45.765+02 | child   | relation |    16393 |    16448 |  |   | 3/183      | 3660 | RowExclusiveLock | t (4 rows)  COMMIT; 

这是非常有趣的,最重要的是要记住。我们可以看到,我们正在执行的 UPDATE 查询不会触及任何与父表相关的东西。第一次执行后,我们可以看到,只有 child 表包含表级锁。行级锁也是如此。只有 child 表的行有更新锁。这是 Postgres 中的优化。如果锁管理器可以从第一个查询中发现外键没有改变(没有被更新查询提及或被设置为相同的值),它不会锁定父表。但在第二个查询它会像文档描述的那样处理(它将锁定 parent 表为行共享锁定模式和涉及的行为分享模式)。这是非常危险的,因为它会导致最危险的和最难找到的死锁。我们可以在事务开头使用显式锁定以避免它。Postgres 9.1 和 9.2 的特性不同于 9.3 和 9.4。不同之处与行级锁相关。9.3 和 9.4 将会在 parent 表上获得较弱的键共享锁模式。这种锁定模式不与无键更新模式冲突,所以能被两个事务并行获取。这种要好很多,所以 9.3 和 9.4 死锁的概率也会更低。

防止死锁的最好方式,是当我们意识到它们可能在两个事务之间发生时,去按一定顺序获取行级锁(例如主键排序)和首先获取最严格的锁。对前文所述的 Postgres 锁定优化留个心眼,显式锁定有时是避免死锁的唯一途径。

一旦在 Postgres 发生死锁,可以通过中止一个参与死锁的事务来消除。准确预测哪个事务会被中止很难,也不应依赖于此。

摘要

对 Postgres 中锁的工作机制铭记于心非常重要。在高并发环境中死锁可能无法避免,但重要的是要知道如何发现、监控并解决它们。即使所有事情都照书而行也不一定能解决所有潜在的锁问题,但会减少它们并使其易于解决。表级锁可以通过 pg_locks 系统视图查看,但行级锁不行,所以这让调试锁更为困难,所以我希望这在 Postgres 的未来版本中变得可能。

正文到此结束
Loading...