PostgreSQL Internals之路 Part-III 第13章 行级锁

文摘   科技   2024-10-08 06:03   北京  

为方便大家交流,建了一个群: 数据库Hacker,纯技术交流与分享,欢迎扫码入群

Part III: 锁(Locks)

13. 行-级锁

13.1 锁设计

由于快照隔离,堆元组在读取时不必锁定。但是,决不能允许两个写事务同时修改同一行。在这种情况下,行必须被锁定,但是重量级锁并不是一个很好的选择:每个重量级锁都占用服务器共享内存的空间(几百字节,更不用说所有的支持基础设施了),而且PostgreSQL内部机制并不是为处理大量并发重量级锁而设计的。

一些数据库系统通过锁升级来解决这个问题: 如果行级锁太多,则用单个更细粒度的锁(例如,用页级或表级锁)来替代它们。它简化了实现,但是会极大地限制系统吞吐量。

在PostgreSQL中,关于某一行是否被锁定的信息只保存在其当前堆元组的头中。行级锁实际上是堆页中的属性,而不是实际的锁,并且它们不以任何方式反映在RAM中。

在更新或删除行时,行会被锁定。在这两种情况下,行的当前版本都被标记为已删除。用于此目的的属性是在xmax字段中指定的当前事务的ID,它是指示行被锁定的同一ID(加上额外的提示位)。如果事务想修改一行,但在当前版本的xmax字段中看到活动的事务ID,则必须等待该事务完成。一旦它结束,所有的锁都被释放,等待的事务就可以继续了。

这种机制允许在没有额外成本的情况下锁定尽可能多的行。

这种解决方案的缺点是其他进程不能形成队列,因为RAM不包含关于此类锁的信息。因此,仍然需要重量级锁:等待一行被释放的进程请求锁定当前忙于处理该行的事务的ID。一旦事务完成,行就再次可用。因此,重量级锁的数量与并发进程的数量成正比,而不是与被修改的行数成正比。

13.2 行级锁模式

行级锁支持四种模式[1]。其中两个实现了一次只能由一个事务获得的排他锁,而另外两个提供了多个事务同时持有的共享锁。

下边是这几种模式的兼容性矩阵:


Key ShareShareNo Key UpdateUpdate
Key Share


X
Share

XX
No Key Update
XXX
UpdateXXXX

排它模式

“更新”模式允许修改任何元组字段,甚至删除整个元组,而“无键更新”模式只允许那些不包含任何与惟一索引相关字段的更改(换句话说,外键不能受到影响)。

UPDATE命令自动选择最弱的锁定模式; 键通常保持不变,因此行通常在无键更新模式下锁定。

我们创建一个函数,使用pageinspect来显示我们感兴趣的一些元组的元信息,名为xmax字段和其它一些提示位:

CREATE FUNCTION row_locks(relname text, pageno integer)
RETURNS TABLE(
ctid tid, xmax text,
lock_only text, is_multi text,
keys_upd text, keyshr text,
shr text
)
AS $$
SELECT (pageno,lp)::text::tid,
t_xmax,
CASE WHEN t_infomask & 128 = 128 THEN 't' END,
CASE WHEN t_infomask & 4096 = 4096 THEN 't' END,
CASE WHEN t_infomask2 & 8192 = 8192 THEN 't' END,
CASE WHEN t_infomask & 16 = 16 THEN 't' END,
CASE WHEN t_infomask & 16+64 = 16+64 THEN 't' END
FROM heap_page_items(get_raw_page(relname,pageno))
ORDER BY lp;
$$ LANGUAGE sql;

现在开启表accounts上的一个事务,更新第一个account(键值不变)的balance以及第2个account的ID(键值发生改变):

=> BEGIN;
=> UPDATE accounts SET amount = amount + 100.00 WHERE id = 1;
=> UPDATE accounts SET id = 20 WHERE id = 2;

现在该页包含如下元信息:

SELECT * FROM row_locks('accounts',0LIMIT 2;
 ctid  |  xmax  | lock_only | is_multi | keys_upd | keyshr | shr
-------+--------+-----------+----------+----------+--------+-----
 (0,1) | 101866 |           |          |          |        |
 (0,2) | 101866 |           |          | t        |        |
(2 rows)

锁模式由keys_updated提示位定义。

=> ROLLBACK;

SELECT FOR命令使用同一个xmax字段用作锁属性,但在这种情况下xmax_lock_only提示位必须要设置。该位表示此元组被锁但没被删除,意指它仍是当前的元组:

=> BEGIN;
=> SELECT * FROM accounts WHERE id = 1 FOR NO KEY UPDATE;
=> SELECT * FROM accounts WHERE id = 2 FOR UPDATE;
=> SELECT * FROM row_locks('accounts',0LIMIT 2;
SELECT * FROM row_locks('accounts',0LIMIT 2;
 ctid  |  xmax  | lock_only | is_multi | keys_upd | keyshr | shr
-------+--------+-----------+----------+----------+--------+-----
 (0,1) | 101867 | t         |          |          |        |
 (0,2) | 101867 | t         |          | t        |        |
(2 rows)
==> ROLLBACK;

共享模式

当需要读取一行时,可以应用Share模式,但必须禁止另一个事务对其进行修改。键共享模式允许更新除键属性外的任何元组字段。

在所有的共享模式中,PostgreSQL核心只使用键共享(Key Share),它在检查外键时应用。因为它与无键更新独占模式兼容,所以外键检查不会干扰非键属性的并发更新。至于应用程序,它们可以使用任何它们喜欢的共享模式。

让我再次强调,简单的SELECT命令从不使用行级锁。

=> BEGIN;
=> SELECT * FROM accounts WHERE id = 1 FOR KEY SHARE;
=> SELECT * FROM accounts WHERE id = 2 FOR SHARE;

下边是堆中元组的内容:

=> SELECT * FROM row_locks('accounts',0LIMIT 2;
ctid | xmax | lock_only | is_multi | keys_upd | keyshr | shr
−−−−−−−+−−−−−−−−+−−−−−−−−−−−+−−−−−−−−−−+−−−−−−−−−−+−−−−−−−−+−−−−−
(0,1) | 134982 | t | | | t |
(0,2) | 134982 | t | | | t | t
(2 rows)

xmax_keyshr_lock提示位信息在两个操作中都有,但是您可以通过其它提示位识别[2]出共享模式。

13.3 多事务

正如我们所见, xmax字段用来表示锁的属性,它被设成已经获得的锁的事务ID。那么,如果该锁是同时被多个事务持有,这个属性该如何设置?

处理共享锁的时候,PG应用了所谓多事务(multixacts[3])。多事务是一组被分配了独立ID的事务。各个组成员的详细信息和锁模式都存储在目录PGDATA/pg_multixact下边的文件里。为获快速访问,被锁的页都在服务器的共享内存里缓存[4]起来;所有改变都记录到日志里,确保有容错能力。

多事务的ID与普通事务ID一样,都是32位长,但是他们是独立生成的。这意味着事务和多事务可以使用相同的ID。为了区别这两种事务,PG额外使用了一个提示位:xmax_is_multi。

我们通过另一个事务(键共享和非键更新模式是兼容的)来添加一个排它锁:

BEGIN;
UPDATE accounts SET amount = amount + 100.00 WHERE id = 1;
=> SELECT * FROM row_locks('accounts',0LIMIT 2;
ctid | xmax | lock_only | is_multi | keys_upd | keyshr | shr
−−−−−−−+−−−−−−−−+−−−−−−−−−−−+−−−−−−−−−−+−−−−−−−−−−+−−−−−−−−+−−−−−
(0,1) | 1 | | t | | |
(0,2) | 134982 | t | | | t | t
(2 rows)

xmax_is_multi位显示的是使用多事务ID的第一行。

无需深入到实现细节,我们使用pgrowlocks插件来展示所有行级锁的信息:

CREATE EXTENSION pgrowlocks;
SELECT * FROM pgrowlocks('accounts') \gx
−[ RECORD 1 ]−−−−−−−−−−−−−−−−−−−−−−−−−−−−−
locked_row | (0,1)
locker | 1
multi | t
xids | {134982,134983}
modes | {"Key Share","No Key Update"}
pids | {30434,30734}
−[ RECORD 2 ]−−−−−−−−−−−−−−−−−−−−−−−−−−−−−
locked_row | (0,2)
locker | 134982
multi | f
xids | {134982}
modes | {"For Share"}
pids | {30434}

这个看起来像是在查询pg_locks视图,但是pgrowlocks函数必须访问堆页,因为RAM里头包含了行级锁的信息。

=> COMMIT;
 => ROLLBACK;

因为multixact的ID是32位,计数器有限制,他们也会发生回卷,这跟普通的事务ID一样。因此,PG必须处理多事务ID的冻结:老的多事务ID被新的替代(或者用一个普通的事务ID,如果当时只有一个事务持有那把锁[5])。

但是如果普通事务ID只在xmin字段上冻结(非空的xmax意味着元组过时,很快会被移除),xmax字段将用于多事务的冻结:当前行版本可以被共享模式下的新事务重复地锁定。

冻结多事务由几个类似的服务器参数来管理:vacuum_multixact_freeze_min_age, vacuum_multixact_freeze_table_age, autovacuum_multixact_freeze_max_age, 以及 vacuum_multixact_failsafe_age。

13.4 等待队列

排它模式

行级锁只是一个属性,队列以一种非同寻常的方式来使用。当某事务尝试修改一行时,它会遵循下边的步骤[6]

  1. 如果xmax字段及提示位表明该行被不兼容的模式锁定,将会在正被修改的元组上获取排它的重量级锁。
  2. 如有必要,通过请求对xmax事务ID上的锁(或者是多个事务,如果xmax包含一个多事务ID),直到所有不兼容的锁都被释放。
  3. 将自己的ID写进元组头的xmax字段,并设置必须的提示位
  4. 如果在第1步获得有元组锁,那就释放那个锁

元组锁是另一种重量级锁,它拥有元组类型(不要与普通的行级锁混淆)。

看起来步骤1和4是冗余的,简单的等待直到所有的锁事务结束就足够了。但是,如果多个事务都在试图更新相同的一行,这些事务将会等待当前正在处理该行的事务。一旦完成,他们会发现他们自己处于获取锁该行的竞争状态,一些“不幸的”事务可能要等待无限长的时间。这种情况,被称为“资源饥饿”。

元组锁标识的是队列中的第一个事务,并确保它会在下一次获得锁。

但是您可以看到您自己。因为PG在它的操作期间获得很多不同的锁,它们每一个都体现在表pg_locks的独立的一行,我也打算基于pg_locks表创建另一个视图。它会以更简洁的形式来展示这个信息,只保留我们当前感兴趣的那些锁(与表accounts相关的那些以及事务自身那些,除了虚拟ID上的所有锁以外):

CREATE VIEW locks_accounts AS
SELECT pid,
locktype,
CASE locktype
WHEN 'relation' THEN relation::regclass::text
WHEN 'transactionid' THEN transactionid::text
WHEN 'tuple' THEN relation::regclass||'('||page||','||tuple||')'
END AS lockid,
mode,
granted
FROM pg_locks
WHERE locktype in ('relation','transactionid','tuple')
AND (locktype != 'relation' OR relation = 'accounts'::regclass)
ORDER BY 123;

我们试下第一个事务并更新一行:

BEGIN;
BEGIN
internals=*# SELECT txid_current(), pg_backend_pid();
 txid_current | pg_backend_pid
--------------+----------------
       101875 |          43514
(1 row)

internals=*# UPDATE accounts SET amount = amount + 100.00 WHERE id = 1;
UPDATE 1

事务完成了工作流中所有的4个步骤,现在持有表上的一个锁:

SELECT * FROM locks_accounts WHERE pid = 43514;
  pid  |   locktype    |  lockid  |       mode       | granted
-------+---------------+----------+------------------+---------
 43514 | relation      | accounts | RowExclusiveLock | t
 43514 | transactionid | 101875   | ExclusiveLock    | t
(2 rows)

启动第2个事务,并更新相同的行。该事务会挂起(hang),等待一个锁:

internals=# BEGIN;
BEGIN
internals=*# SELECT txid_current(), pg_backend_pid();
 txid_current | pg_backend_pid
--------------+----------------
       101876 |          48661
(1 row)

internals=*# UPDATE accounts SET amount = amount + 100.00 WHERE id = 1;


第2个事务只走到了第2步。因为这个原因,除了锁定表和它自身的ID以外,它添加了另两个锁,也反映到视图pg_locks里:在第1步获得的元组锁以及在第2步请求的第2个事务的ID锁:

SELECT * FROM locks_accounts WHERE pid = 48661;
  pid  |   locktype    |    lockid     |       mode       | granted
-------+---------------+---------------+------------------+---------
 48661 | relation      | accounts      | RowExclusiveLock | t
 48661 | transactionid | 101875        | ShareLock        | f
 48661 | transactionid | 101876        | ExclusiveLock    | t
 48661 | tuple         | accounts(0,6) | ExclusiveLock    | t
(4 rows)

第3个事务将会在第1步就被阻塞,它会尝试获取元组锁,并在这个点上停止:

BEGIN;
BEGIN
internals=*# SELECT txid_current(), pg_backend_pid();
 txid_current | pg_backend_pid
--------------+----------------
       101877 |          48719
(1 row)

internals=*# UPDATE accounts SET amount = amount + 100.00 WHERE id = 1;
SELECT * FROM locks_accounts WHERE pid = 48719;
  pid  |   locktype    |    lockid     |       mode       | granted
-------+---------------+---------------+------------------+---------
 48719 | relation      | accounts      | RowExclusiveLock | t
 48719 | transactionid | 101877        | ExclusiveLock    | t
 48719 | tuple         | accounts(0,6) | ExclusiveLock    | f
(3 rows)

第4个以及所有的后边的尝试更新同一行的事务都与第3个事务在这方面不同:它们都在等待这个相同的元组锁。

BEGIN;
BEGIN
internals=*# SELECT txid_current(), pg_backend_pid();
 txid_current | pg_backend_pid
--------------+----------------
       101878 |          48768
(1 row)

internals=*# UPDATE accounts SET amount = amount + 100.00 WHERE id = 1;
SELECT * FROM locks_accounts WHERE pid = 48768;
  pid  |   locktype    |    lockid     |       mode       | granted
-------+---------------+---------------+------------------+---------
 48768 | relation      | accounts      | RowExclusiveLock | t
 48768 | transactionid | 101878        | ExclusiveLock    | t
 48768 | tuple         | accounts(0,6) | ExclusiveLock    | f
(3 rows)


要获得当前等待的全部图景,您可以运用加锁进程的信息来扩展视图pg_stat_activity:

SELECT pid,
wait_event_type,
wait_event,
pg_blocking_pids(pid)
FROM pg_stat_activity
WHERE pid IN (43514,48661,48719,48768);

  pid  | wait_event_type |  wait_event   | pg_blocking_pids
-------+-----------------+---------------+------------------
 43514 | Client          | ClientRead    | {}
 48661 | Lock            | transactionid | {43514}
 48719 | Lock            | tuple         | {48661}
 48768 | Lock            | tuple         | {48661,48719}
(4 rows)

如果第1个事务终止了,所有的事务都会按预期工作:所有后续的事务都会往前推进,不需要插队。

更有可能的情况是第1个事务将被提交。在可重复读或者可串行化隔离级,它将导致串行化失败,因此第2个事务不得不终止[7] (所有后续的事务也不得不终止)。但是在读已提交隔离级下,修改的行将被重新读取,更新操作将会重试。

因此,第一个事务提交:

=> COMMIT;

第2个事务唤醒,成功的完成第3个和第4个:

UPDATE 1

数据库杂记
数据库技术专家,PostgreSQL ACE,SAP HANA,Sybase ASE/ASA,Oracle,MySQL,SQLite各类数据库, SAP BTP云计算技术, 以及陈式太极拳教学倾情分享。出版过三本技术图书,武术6段。
 最新文章