为方便大家交流,建了一个群: 数据库Hacker,纯技术交流与分享,欢迎扫码入群
Part III: 锁(Locks)
13. 行-级锁
13.1 锁设计
由于快照隔离,堆元组在读取时不必锁定。但是,决不能允许两个写事务同时修改同一行。在这种情况下,行必须被锁定,但是重量级锁并不是一个很好的选择:每个重量级锁都占用服务器共享内存的空间(几百字节,更不用说所有的支持基础设施了),而且PostgreSQL内部机制并不是为处理大量并发重量级锁而设计的。
一些数据库系统通过锁升级来解决这个问题: 如果行级锁太多,则用单个更细粒度的锁(例如,用页级或表级锁)来替代它们。它简化了实现,但是会极大地限制系统吞吐量。
在PostgreSQL中,关于某一行是否被锁定的信息只保存在其当前堆元组的头中。行级锁实际上是堆页中的属性,而不是实际的锁,并且它们不以任何方式反映在RAM中。
在更新或删除行时,行会被锁定。在这两种情况下,行的当前版本都被标记为已删除。用于此目的的属性是在xmax字段中指定的当前事务的ID,它是指示行被锁定的同一ID(加上额外的提示位)。如果事务想修改一行,但在当前版本的xmax字段中看到活动的事务ID,则必须等待该事务完成。一旦它结束,所有的锁都被释放,等待的事务就可以继续了。
这种机制允许在没有额外成本的情况下锁定尽可能多的行。
这种解决方案的缺点是其他进程不能形成队列,因为RAM不包含关于此类锁的信息。因此,仍然需要重量级锁:等待一行被释放的进程请求锁定当前忙于处理该行的事务的ID。一旦事务完成,行就再次可用。因此,重量级锁的数量与并发进程的数量成正比,而不是与被修改的行数成正比。
13.2 行级锁模式
行级锁支持四种模式[1]。其中两个实现了一次只能由一个事务获得的排他锁,而另外两个提供了多个事务同时持有的共享锁。
下边是这几种模式的兼容性矩阵:
Key Share | Share | No Key Update | Update | |
---|---|---|---|---|
Key Share | X | |||
Share | X | X | ||
No Key Update | X | X | X | |
Update | X | X | X | X |
排它模式
“更新”模式允许修改任何元组字段,甚至删除整个元组,而“无键更新”模式只允许那些不包含任何与惟一索引相关字段的更改(换句话说,外键不能受到影响)。
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',0) LIMIT 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',0) LIMIT 2;
SELECT * FROM row_locks('accounts',0) LIMIT 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',0) LIMIT 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',0) LIMIT 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]:
如果xmax字段及提示位表明该行被不兼容的模式锁定,将会在正被修改的元组上获取排它的重量级锁。 如有必要,通过请求对xmax事务ID上的锁(或者是多个事务,如果xmax包含一个多事务ID),直到所有不兼容的锁都被释放。 将自己的ID写进元组头的xmax字段,并设置必须的提示位 如果在第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 1, 2, 3;
我们试下第一个事务并更新一行:
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