跳转到主要内容

25 大家都得排队

有目的地选择隔离级别,在事务中处理错误与重试,事故期间读取 pg_locks,并用 SKIP LOCKED 构建任务队列。

一次迁移向 accounts 添加一个可为空的列。这是只改元数据的操作,几毫秒就能完成。它一运行,所有访问 accounts 的请求都挂起,连接池被填满,网站随即宕机。迁移中的操作本身并不慢;它只是在等待,而在它等待时,其他所有人也都在等待。

下面是在本书数据库中用三个会话复现的那次故障。migration 运行 alter table accounts add column note text,web 运行 select name from accounts where id = 42:

  pid  |    app    |        state        | wait_event | blocked_by
-------+-----------+---------------------+------------+------------
 11033 | reporting | idle in transaction | ClientRead | {}
 11046 | migration | active              | relation   | {11033}
 11061 | web       | active              | relation   | {11046}

reporting 会话在一个事务中运行了 select count(*) from accounts,然后一直没有关闭事务。迁移需要排他锁,因此要等待 reporting 会话。Web 请求只想读取一行,reporting 会话本来完全可以允许它读取,但它排在迁移后面。之后的每个请求都加入了队列。

通常读操作和写入操作不会互相阻塞(更新就是插入)。但一旦阻塞发生,队列就是全部故事。学会读懂它。

有目的地选择隔离级别

Read committed(读已提交)是默认级别,每条语句都会获得一个新的 快照;repeatable read(可重复读)让整个事务使用同一个快照;serializable(可串行化)还保证结果可以由某种一次只运行一个事务的顺序产生。只有 serializable 能排除所有异常列出了每种级别能防止什么;下面看看它们会怎样影响你的代码。(InnoDB 默认是 repeatable read:MySQL 默认是 repeatable read;Postgres 不是。)

Read committed 会丢失在应用中计算的更新

大多数应用都运行在这个级别上,而且没有问题。陷阱在于应用代码中的读改写:两个请求分别读取一个余额,在应用中减去金额,再把结果写回去。

第二次写入会悄无声息地获胜。两个事务都读到 100,tx2 等待 tx1 的行锁,然后写入根据那次过时的读取计算出的值。余额最终为 80,tx1 的消费没有报错就丢失了。
图 25-1 第二次写入会悄无声息地获胜。两个事务都读到 100,tx2 等待 tx1 的行锁,然后写入根据那次过时的读取计算出的值。余额最终为 80,tx1 的消费没有报错就丢失了。

tx1 花了 30,tx2 花了 20:总共花掉 50 个额度,余额却只减少了 20。这就是丢失更新,但没有任何错误会告诉你。把算术放进数据库:

update credits
set balance = balance - 30
where account_id = 42 and balance >= 30
returning balance;

因行被其他事务修改而等待的更新操作,会重新读取最新版本,并重新检查它的 where 条件,因此两个并发消费会依次返回 70 和 50。当逻辑无法放进一条语句时,在读取行时用 select ... for update 把它锁住。

深入阅读:真实运行一次丢失更新

两个会话都处于 read committed,并分别执行请求处理器会做的事情:

  tx1                                tx2
  begin;                             begin;
  select balance ...;  -- 100        select balance ...;  -- 100
  update credits set balance = 70
                                     update credits set balance = 80
                                       (waits on tx1's row lock)
  commit;
                                       (proceeds, overwrites 70)
                                     commit;

tx2 等待期间,pg_stat_activity 显示它在等待 Lock / transactionid,并被 tx1 阻塞。tx1 提交后,tx2 的 update 发现该行已改变,重新读取它,重新检查 where account_id = 42(仍然为真),然后把根据旧读取算出的 80 写到 tx1 的 70 之上。

动手验证 · lab 24-concurrency
select lab.run('tx1', 'update credits set balance = 70 where account_id = 42') \g /dev/null
select dblink_send_query('tx2', 'update credits set balance = 80 where account_id = 42') \g /dev/null
select pg_sleep(0.3) \g /dev/null

select lab.prove(
  'tx2''s update waits for tx1''s row lock',
  (select wait_event_type = 'Lock' and wait_event = 'transactionid'
          and pg_blocking_pids(pid) = (select array_agg(pid) from lab.activity
                                       where application_name = 'tx1')
   from lab.activity where application_name = 'tx2'));

select lab.run('tx1', 'commit') \g /dev/null
select lab.finish('tx2') as tx2_result \gset
select lab.run('tx2', 'commit') \g /dev/null

select lab.prove(
  'under read committed tx2 proceeds without error and overwrites: balance 80, a lost update',
  :'tx2_result' = 'ok'
  and (select balance from credits where account_id = 42) = 80);

完整实验脚本

让两个事务都在 repeatable read 下运行相同的顺序;tx1 提交的瞬间,tx2 就会失败,因为它想更新的行在自己的快照之后发生了变化。tx1 的 70 会保留下来。

动手验证 · lab 24-concurrency
select lab.prove(
  'under repeatable read tx2 fails with 40001 could not serialize access due to concurrent update, and tx1''s 70 stands',
  :'tx2_result' = '40001: could not serialize access due to concurrent update'
  and (select balance from credits where account_id = 42) = 70);

完整实验脚本

Repeatable read 会把这场竞态变成需要重试的错误

在 repeatable read 下,如果某行在你的快照之后被别人改过,更新会被拒绝,而不是覆盖它:同一场竞态会让 tx2 报错 could not serialize access due to concurrent update。在这个级别和 serializable 下,这个错误(SQLSTATE 40001)是正常结果,不是 bug:你的代码要重试整个事务,否则少见的竞态就会变成用户看到的错误。

Serializable 能捕获 repeatable read 漏掉的写偏差

repeatable read 仍然允许写偏差。值班表必须始终有人值班。两名工程师同时取消值班;他们各自统计当前值班人数(2 人,所以安全),然后只停用自己的行。两人都提交后,没有人值班;没有任何一行被更新两次,因此 repeatable read 看不出问题。在 serializable 下,其中一个事务会因 could not serialize access due to read/write dependencies among transactions 失败。Postgres 会记录每个事务读取过的内容,但不会阻塞任何人;当没有任何串行顺序能够解释这个结果时,它会中止其中一个事务(即使只读事务也可能看到不可能的结果)。

深入阅读:值班表上的写偏差

两行都处于启用状态;每个工程师的事务都会执行:

begin isolation level repeatable read;
select count(*) from on_call where account_id = 42 and active;  -- 2, so it's safe
-- the other transaction deactivates user 2
update on_call set active = false where account_id = 42 and user_id = 1;
commit;

读取跟踪会显示为 SIReadLock 条目,位置在 pg_locks 中。你付出的是额外开销和更多可重试失败,其中少数是误报。

动手验证 · lab 24-concurrency
select lab.prove(
  'serializable records what each transaction read as SIReadLock entries in pg_locks',
  exists (select from pg_locks l join lab.activity a using (pid)
          where a.application_name = 'tx1' and l.mode = 'SIReadLock'));

select lab.prove(
  'one transaction fails with 40001 could not serialize access due to read/write dependencies, and someone stays on call',
  :'outcome' like '%40001: could not serialize access due to read/write dependencies among transactions%'
  and (select count(*) from on_call where active) = 1);

完整实验脚本

我们的默认选择是:请求使用 read committed,报表使用 repeatable read,跨行规则使用 serializable,并配合重试。

重试整个事务

串行化失败(40001)和死锁(40P01)意味着“什么都没有发生;再试一次”。要重试整个事务,包括读取,限制尝试次数,并在每次重试前加入随机延迟。这两个代码是默认应该重试的代码;在 serializable 下,唯一性冲突(23505)或排他约束冲突(23P01)也可能表示在竞态中落败,值得再试一次。不要盲目重试其他任何错误。把副作用移出可重试的函数,否则每次尝试都会发出一封邮件;记录意图,并在提交后执行(把消息和订单放进同一个事务)。

深入阅读:Go 中的重试包装器
func withRetry(ctx context.Context, pool *pgxpool.Pool,
	fn func(pgx.Tx) error) error {
	for attempt := 0; ; attempt++ {
		opts := pgx.TxOptions{IsoLevel: pgx.Serializable}
		err := pgx.BeginTxFunc(ctx, pool, opts, fn)
		var pgErr *pgconn.PgError
		retryable := errors.As(err, &pgErr) &&
			(pgErr.Code == "40001" || pgErr.Code == "40P01")
		if retryable && attempt < 4 {
			jitter := rand.Intn(20 * (attempt + 1))
			time.Sleep(time.Duration(jitter) * time.Millisecond)
			continue
		}
		return err
	}
}

一个错误会中止整个事务

在 Postgres 中,事务内部的任何错误都会毒化事务的剩余部分:

begin;
insert into users (account_id, email, name) values (1, 'new@example.com', 'New');
insert into users (account_id, email, name) values (2, 'user1@example.com', 'Dup');
update accounts set name = name where id = 1;
commit;
ERROR:  duplicate key value violates unique constraint "users_account_id_email_key"
ERROR:  current transaction is aborted,
        commands ignored until end of transaction block
ROLLBACK

update 根本没有运行,commit 返回了 ROLLBACK:第一次插入也消失了。“插入、捕获重复错误、继续执行”一旦被包在事务中就会失效。要么预防错误(on conflict do nothing),要么使用保存点(MySQL 不同;参见错误会中止整个事务)。

动手验证 · lab 24-concurrency
select lab.prove(
  'after a duplicate key error, the next statement fails with 25P02 and COMMIT reports ROLLBACK',
  lab.run('tx1', 'begin') = 'ok'
  and lab.run('tx1', $$insert into users (account_id, email, name) values (1, 'new@example.com', 'New')$$) = 'ok'
  and lab.run('tx1', $$insert into users (account_id, email, name) values (2, 'user1@example.com', 'Dup')$$)
      = '23505: duplicate key value violates unique constraint "users_account_id_email_key"'
  and lab.run('tx1', 'update accounts set name = name where id = 1')
      = '25P02: current transaction is aborted, commands ignored until end of transaction block'
  and dblink_exec('tx1', 'commit') = 'ROLLBACK');

完整实验脚本

保存点可以把损害控制住

保存点标记了一个可以回滚到的位置,同时不会丢掉其余工作。ORM 所谓的“嵌套事务”其实是保存点(Rails 的 requires_new: true、Django 的嵌套 atomic()),每个 PL/pgSQL EXCEPTION 块也是如此。

子事务并非没有代价

每个保存点都会启动一个子事务,而执行写入的子事务会获得自己的 XID。每个后端会缓存 64 个子事务 XID;超过这个数量后缓存就会溢出,每个会话针对该事务执行的可见性检查都会退回到磁盘上的结构 pg_subtrans。副本更加脆弱:在那里,只要一个长事务旁边出现任何子事务,就可能把查找推入 pg_subtrans。2021 年这件事影响了 GitLab:在他们的副本上,长事务期间的一个保存点就足以让查询时断时续地停滞数周,直到他们从应用中移除了保存点(GitLab 的说明)。不要在循环中使用保存点,优先考虑 on conflict。

深入阅读:观察子事务缓存溢出

pg_stat_get_backend_subxact(PostgreSQL 16+)可以显示它。在一个事务中设置 64 个保存点,每个保存点后都执行一次插入:subxact_count 为 64,subxact_overflowed 为 false。再多一个:仍然是 64,但变成 true。

动手验证 · lab 24-concurrency
begin;
select format('savepoint s%s', g), format('insert into subxact_demo values (%s)', g)
from generate_series(1, 64) g \gexec

select lab.prove(
  'after 64 savepoints, each followed by an insert: 64 cached, not overflowed',
  (select (subxact_count, subxact_overflowed) = (64, false) from lab.my_subxact()));

savepoint s65;
insert into subxact_demo values (65);

select lab.prove(
  'one more: the count stops at 64 and the overflow flag flips',
  (select (subxact_count, subxact_overflowed) = (64, true) from lab.my_subxact()));
rollback;

完整实验脚本

每条语句都会锁住表

每条语句还会把它访问的每张表锁到事务结束。八种表锁模式的三个事实,几乎涵盖了每次事故:

  • 读写在表级别永远不会冲突。 行冲突是另一套系统。
  • ACCESS EXCLUSIVE 会与一切冲突,包括普通的 select。 drop、truncate 以及大多数 alter table 形式都会取得这种锁。
  • SHARE 会阻塞写入,但不会阻塞读取,这就是普通的 create index 会让表变成只读、而 create index concurrently 存在的原因。

大多数 ALTER TABLE 形式都会取得 ACCESS EXCLUSIVE

大多数 ALTER TABLE 形式都会取得 ACCESS EXCLUSIVE,即使工作本身瞬间就能完成:添加可为空的列只需微秒量级的时间,但拿到锁才是危险所在(变更模式,服务照常)。要检查一条语句,可以在事务中运行它,读取 pg_locks 中属于当前会话的行,然后回滚。

深入阅读:八种模式、冲突矩阵,以及每种 DDL 取得了什么锁

ROW SHARE 用于 select ... for update;ROW EXCLUSIVE 用于 insert、update、delete;SHARE UPDATE EXCLUSIVE 用于 vacuum、analyze、create index concurrently;SHARE ROW EXCLUSIVE 用于 create trigger 和添加外键;EXCLUSIVE 用于 refresh materialized view concurrently。

                      held: AS   RS   RE   SUE  S    SRE  E    AE
  requested
  ACCESS SHARE (AS)         .    .    .    .    .    .    .    X
  ROW SHARE (RS)            .    .    .    .    .    .    X    X
  ROW EXCL (RE)             .    .    .    .    X    X    X    X
  SHARE UPD EXCL (SUE)      .    .    .    X    X    X    X    X
  SHARE (S)                 .    .    X    X    .    X    X    X
  SHARE ROW EX (SRE)        .    .    X    X    X    X    X    X
  EXCLUSIVE (E)             .    X    X    X    X    X    X    X
  ACCESS EXCL (AE)          X    X    X    X    X    X    X    X

X 表示请求的模式会等待。在这台服务器上,add column、add constraint ... not valid 和 rename column 取得了 AccessExclusiveLock;validate constraint 和 set (fillfactor = 90) 取得了 ShareUpdateExclusiveLock;add foreign key ... not valid 在两张表上都取得了 ShareRowExclusiveLock。

动手验证 · lab 24-concurrency
-- lab.locks_taken runs a statement, reads this session's table locks from
-- pg_locks, and rolls back
select lab.prove('add column takes AccessExclusiveLock',
  lab.locks_taken('alter table projects add column note text', '{projects}') = '{"projects AccessExclusiveLock"}');
select lab.prove('validate constraint takes ShareUpdateExclusiveLock',
  lab.locks_taken('alter table projects validate constraint name_not_empty', '{projects}')
    = '{"projects ShareUpdateExclusiveLock"}');
select lab.prove('create index takes ShareLock',
  lab.locks_taken('create index on projects (created_at)', '{projects}') = '{"projects ShareLock"}');

完整实验脚本

一个等待中的锁会阻塞后面的所有人

锁像柜台前的一条单队列,会按顺序授予。与一个正在等待的请求冲突的请求,即使它们与当前已经持有的所有锁都兼容,也会排在该请求后面。这能防止饥饿,也正是一次无害的迁移会变成故障的原因。

一个等待中的锁会让每个查询停下来。迁移在等待空闲的 reporting 事务,而它之后的每个查询,包括普通读取,也都排在迁移后面等待。reporting 提交后,迁移在一毫秒内运行完,队列随之排空。
图 25-2 一个等待中的锁会让每个查询停下来。迁移在等待空闲的 reporting 事务,而它之后的每个查询,包括普通读取,也都排在迁移后面等待。reporting 提交后,迁移在一毫秒内运行完,队列随之排空。

在开头那次事故期间的 pg_locks 中,Web 查询的 AccessShareLock 显示 granted = f,尽管唯一已授予的锁也是另一个 AccessShareLock:它排在迁移后面。在生产环境中,每秒几百个请求会堆积起来,连接池耗尽,健康检查失败。reporting 事务结束后,迁移在一毫秒内运行完,队列随之排空。

动手验证 · lab 24-concurrency
select lab.prove(
  'the web SELECT waits too: its AccessShareLock is not granted, and it is blocked by the migration',
  exists (select from pg_locks where pid = :web_pid and relation = 'accounts'::regclass
          and mode = 'AccessShareLock' and not granted)
  and pg_blocking_pids(:web_pid) = array[:migration_pid]
  and (select (wait_event_type, wait_event) = ('Lock', 'relation') from lab.activity where pid = :web_pid));

完整实验脚本

防御手段是让 DDL 快速放弃。设置 set lock_timeout = '2s' 后,同一个 alter 会在两秒后因 canceling statement due to lock timeout 失败,而不是一直占着队列。两秒的流量阻塞只是一个小插曲;重试几次。然后找到持有该锁的会话:持锁的 idle in transaction 会话就是 bug,而idle_in_transaction_session_timeout会限制它能持续多久。

每次迁移都设置 lock_timeout

任何取得 ACCESS EXCLUSIVE 的语句,只要需要等待,就可能让网站宕机,因此每个迁移会话都设置 lock_timeout 并重试(规则一:lock_timeout 和重试)。

深入阅读:重现这条队列

三个会话:reporting 运行 begin; select count(*) from accounts; 后保持空闲;migration 运行 alter table accounts add column note text;web 运行 select name from accounts where id = 42。然后把 pg_locks 与 pg_stat_activity 按 pid 连接起来,条件是 relation = 'accounts'::regclass:

  pid  | application_name | locktype |        mode         | granted
-------+------------------+----------+---------------------+---------
 11033 | reporting        | relation | AccessShareLock     | t
 11046 | migration        | relation | AccessExclusiveLock | f
 11061 | web              | relation | AccessShareLock     | f
动手验证 · lab 24-concurrency
select lab.prove(
  'the migration waits for AccessExclusiveLock, blocked by reporting',
  exists (select from pg_locks where pid = :migration_pid and relation = 'accounts'::regclass
          and mode = 'AccessExclusiveLock' and not granted)
  and pg_blocking_pids(:migration_pid) = array[:reporting_pid]);

set lock_timeout = '2s';
select lab.prove(
  'with lock_timeout = 2s the migration gives up: canceling statement due to lock timeout',
  lab.try('alter table accounts add column note text')
    = '55P03: canceling statement due to lock timeout');

完整实验脚本

行锁存在行中,而不在 pg_locks 中

行锁在持有期间不会保存在 pg_locks 中(否则可能有数百万条)。加锁事务会把自己的 ID 写进该行的行头(xmax、一切都从页说起),想要冲突锁的事务会等待那个事务结束。因此,行锁等待通常会显示为一个未授予的 ShareLock,其锁定对象是 transactionid,而不是行本身。当多个会话在同一行上排队时,第一个等待者还会持有该行的重量级 tuple 锁,后面的会话则改为等待这个 tuple 锁,因此在堆积场景中,你也会看到未授予的 tuple 条目。

锁由谁取得阻塞
FOR UPDATEDELETE;会更改键列的 UPDATE;显式请求所有其他行锁
FOR NO KEY UPDATE其他任何 UPDATE;显式请求FOR UPDATE、FOR NO KEY UPDATE、FOR SHARE
FOR SHARE显式的 select ... for shareFOR UPDATE、FOR NO KEY UPDATE
FOR KEY SHARE对被引用行执行的外键检查;显式请求仅 FOR UPDATE
表 25-1 行锁存在行中,而不在 pg_locks 中

外键需要较弱的那一对锁。向项目 1 插入事件时,会在项目行上取得 FOR KEY SHARE;重命名项目(FOR NO KEY UPDATE)不会阻塞它。但如果某个会话在项目上持有 select ... for update,插入就会等待:在我们的测试中等待了 3.6 秒,直到该会话回滚。因此,除非你要删除该行或更改它的键,否则应使用 for no key update 加锁。nowait 会直接失败而不是等待;skip locked 会跳过别人持有锁的行。

深入阅读:pg_locks 中的行锁等待是什么样

会话 A 在项目 1 上持有 for update 时,插入操作的锁如下:

  app   |   locktype    |      mode       | granted | blocked_by
--------+---------------+-----------------+---------+------------
 locker | transactionid | ExclusiveLock   | t       | {}
 writer | transactionid | ExclusiveLock   | t       | {10113}
 writer | tuple         | AccessShareLock | t       | {10113}
 writer | transactionid | ShareLock       | f       | {10113}

写入者会请求对持锁者 XID 的 ShareLock:“等那个事务结束后叫醒我。”已授予的 tuple 锁就是写入者在该行队伍最前面的位子;第二个写入者会在这个锁上等待,且锁不会被授予。

动手验证 · lab 24-concurrency
set lock_timeout = '1s';
select lab.prove(
  'an explicit FOR NO KEY UPDATE does not block it either',
  lab.try($$insert into events (account_id, project_id, user_id, kind) values (9, 1, 8, 'comment')$$)
    = 'ok');

-- now tx1 holds select ... for update on project 1, and tx2 sends the same insert
select lab.prove(
  'FOR UPDATE on the project blocks the insert: an ungranted ShareLock on the locker''s transactionid',
  exists (select from pg_locks l join lab.activity a using (pid)
          where a.application_name = 'tx2' and l.locktype = 'transactionid'
            and l.mode = 'ShareLock' and not l.granted)
  and (select pg_blocking_pids(pid) from lab.activity where application_name = 'tx2')
      = (select array_agg(pid) from lab.activity where application_name = 'tx1'));

完整实验脚本

共享行锁会产生 multixact,而 multixact 也会回卷

当两个打开的事务以互不冲突的模式锁住同一行时,例如两个子插入都在同一个父行上取得 FOR KEY SHARE,Postgres 会把一个multixact 放进它的 xmax:这是一个指向成员列表的 ID。一个热门父行被许多并发子插入访问时,每次插入都会产生一个新的 multixact。multixact 也有自己的回卷问题(回卷会在任何内容损坏前停止写入):要在事务 ID 年龄旁边观察 mxid_age(datminmxid),并让插入子行的事务保持短小。

深入阅读:三个持锁者和成员空间限制

三个会话分别执行 begin,为父行 1 插入一行子数据,然后保持打开。pgrowlocks 显示父行的 xmax 中有一个 multixact,pg_get_multixact_members 则以 keysh 模式列出三个事务 ID。第三个持锁者获得了一个包含三者的新 multixact,因为 multixact 从不改变。

动手验证 · lab 24-concurrency
select lab.prove(
  'two open child inserts make the parent row''s xmax a multixact of two FOR KEY SHARE members',
  :'mx_multi' = 't'
  and (select count(*) = 2 and bool_and(mode = 'keysh')
       from pg_get_multixact_members(:'mx_two'::xid)));

select lab.prove(
  'a third locker creates a new multixact holding all three, rather than changing the old one',
  :'mx_three' <> :'mx_two'
  and (select count(*) = 3 and bool_and(mode = 'keysh')
       from pg_get_multixact_members(:'mx_three'::xid)));

完整实验脚本

autovacuum_multixact_freeze_max_age(默认 4 亿)会强制执行防回卷清理。成员区有自己的上限:超过约 10 GB 后,autovacuum 会在各处变得激进;达到约 20 GB 时,服务器拒绝创建 multixact(multixact "members" limit exceeded,Routine Vacuuming),这恰恰会阻塞那些通过外键引用繁忙父行的插入操作。对于存在这类父行的表,alter table projects set (autovacuum_multixact_freeze_max_age = 100000000) 会让它更早冻结;同时避免在应用代码中使用 for share。

解决 LWLock 等待要调整负载,而不是查询

并不是每条队列都由你主动请求的锁造成的。在高并发下,会话还会排队等待 LWLock,而你的语句根本不会提到它。轻量级锁会保护一块共享内存,持锁时间以微秒计;大量等待意味着“我们太多人同时在做同一件内部工作”,而不是“有人持有我想要的东西”。因此,修复点在负载:

  • LockManager: 热点查询锁住了太多表和索引。每个后端都有几个私有的 fast-path 槽位,可以跳过共享锁表:PostgreSQL 17 及之前是 16 个,18 默认是 64 个。
  • BufferMapping: 工作集装不进内存。
  • WALWrite: 太多微小事务提交。

深入阅读:锁管理器内部:快速路径和四个 LWLock

每个查询都会锁住规划时涉及的每个关系;共享锁表分成 16 个分区,每个分区由一个 LockManager LWLock 保护。私有的 fast-path 槽位会为弱表锁跳过共享锁表;PostgreSQL 18 会根据 max_locks_per_transaction 设置槽位数量,默认是 64 个(提交记录)。一个无法剪枝、涉及 365 个分区的查询使用了 64 个快速路径锁和 1,035 个存入共享锁表的锁(查询无法剪枝的每个分区都会增加规划时间)。删除未使用的索引,让分区查询执行剪枝;在 18 中,如果热点查询访问的关系超过 64 个,就提高 max_locks_per_transaction(这需要重启)。

动手验证 · lab 24-concurrency
-- one transaction locks 100 small tables in AccessShare mode
select lab.prove(
  'PostgreSQL 18 has more than 16 fast-path slots, at most 64 at the default max_locks_per_transaction; the rest go to the shared lock table',
  :fp_fast > 16 and :fp_fast <= 64 and :fp_fast + :fp_slow = 100);

完整实验脚本

查找一页需要哈希查找会解释 BufferMapping,多次提交共享一次刷新会解释 WALWrite。第四种等待是 BufferContent,表示很多会话都在写同一个热点页,例如不断增大的键的最后一个叶页。服务器内部还会形成两条队列:当很多会话增长同一张表时会出现关系扩展锁(Lock:extend),自 PostgreSQL 16 起它便便宜得多(提交记录);以及用于事务状态、子事务和 multixact 的 SLRU 缓存,当 pg_stat_slru 的 blks_read 持续增长时会发生颠簸。

Postgres 队列与业务写入一起提交

行锁和 skip locked 结合起来就是一个可靠的任务队列,而且比独立系统多一个决定性的优势:任务和业务写入在同一个事务中入队。回滚时,任务从未存在过。jobs 表需要一个仅覆盖排队中任务的部分索引;无论有多少完成的任务堆积起来,这个索引都能保持很小:

create index jobs_ready_idx on jobs (queue, run_at, id)
  where status = 'queued';

每个工作进程都会跳过别人持有的任务

每个工作进程都必须在不等待其他工作进程的情况下取得不同的任务:

update jobs
set status    = 'running',
    attempts  = attempts + 1,
    locked_at = now(),
    locked_by = 'worker-a'
where id = (
  select id from jobs
  where queue = 'default' and status = 'queued' and run_at <= now()
  order by run_at, id
  limit 1
  for update skip locked
)
returning id, payload, attempts;

子查询会跳过其他工作进程持有的任务,并取得它能锁住的第一项:表中有 200,000 个已完成任务时,也只读取 20 个缓冲区。当工作进程 A 持有任务 1 时,工作进程 B 立即拿到了任务 2。没有 skip locked 时,第三个工作进程会排在 A 后面,直到它的 lock_timeout 触发:每个工作进程都在同一行上排队,吞吐量降到了单个工作进程的水平。

深入阅读:表、认领计划,以及并排观察工作进程
create table jobs (
  id          bigint generated always as identity primary key,
  queue       text not null default 'default',
  payload     jsonb not null,
  status      text not null default 'queued'
              check (status in ('queued', 'running', 'done', 'failed')),
  attempts    int not null default 0,
  run_at      timestamptz not null default now(),
  locked_at   timestamptz,
  locked_by   text,
  last_error  text,
  created_at  timestamptz not null default now()
);
 Update on jobs (actual time=0.040..0.041 rows=1.00 loops=1)
   Buffers: shared hit=20
   InitPlan 1
     ->  Limit ...
           ->  LockRows ...
                 ->  Index Scan using jobs_ready_idx on jobs jobs_1 ...
动手验证 · lab 24-concurrency
select lab.prove(
  'worker-b gets the next job immediately, not worker-a''s',
  :b_job = :a_job + 1);

select lab.prove(
  'without skip locked, worker-c queues behind worker-a''s lock until its lock_timeout fires',
  lab.run('worker-c', 'set lock_timeout = ''1s''') = 'ok'
  and lab.run('worker-c', $$select id from jobs
                            where queue = 'default' and status = 'queued' and run_at <= now()
                            order by run_at, id limit 1
                            for update$$)
      = '55P03: canceling statement due to lock timeout');

完整实验脚本

短任务持有事务;长任务先提交

对于只运行几秒的任务,让事务覆盖整个任务:发生崩溃时,事务会回滚,任务也会被释放。对于运行几分钟的任务,如果事务一直不提交就会阻碍清理,先认领并提交,再处理,最后记录结果,同时让回收器处理卡在 running 状态的任务,并使用幂等的处理器。一次认领大约读取 20 页,因此在任务本身成为瓶颈之前,队列很少会限制吞吐量;我们没有测量上限,所以请测量你自己的上限。按计划删除或归档 done 任务:部分索引会忽略它们,但堆和清理不会。

深入阅读:记录结果、重试和回收器
-- success
update jobs set status = 'done', locked_at = null where id = $1;

-- failure: retry with exponential backoff, give up after 5 attempts
update jobs
set status     = case when attempts >= 5 then 'failed' else 'queued' end,
    run_at     = now() + interval '10 seconds' * power(2, attempts),
    last_error = $2,
    locked_at  = null
where id = $1;

-- the reaper
update jobs
set status = 'queued', locked_at = null, locked_by = null
where status = 'running' and locked_at < now() - interval '15 minutes';

入队事务中的 notify jobs 只会在提交后送达,因此工作进程可以 listen,而不是高频轮询。但在入队速率很高时,每个发出 notify 的提交事务都要取得一个数据库级锁来排队通知,从而把这些提交串行化。每批只通知一次,或者让工作进程以适中的间隔轮询。

按事务取得咨询锁,而不是按会话取得

要对行以外的对象实现互斥(“每次只能有一个夜间汇总任务”),咨询锁会锁住一个整数键,至于这个键代表什么由你决定。两个 cron 实例分别启动事务,并运行 select pg_try_advisory_xact_lock(hashtext('nightly-rollup')):第一个得到 t,第二个得到 f 并退出。事务范围的形式会在提交或回滚时释放,因此优先使用它,而不是会话级的 pg_advisory_lock。只要连接池中的会话还在,会话级锁就会沿着任何跳过解锁的错误路径泄漏。

深入阅读:咨询锁会以哈希后的键出现

下面的检查使用会话级的 pg_try_advisory_lock 来展示区别。在 pg_locks 中,这个锁显示为 locktype = advisory,objid 为 107030647,也就是 hashtext('nightly-rollup') 的结果。对于实体锁,两整数形式的 pg_advisory_xact_lock(1, account_id) 可以避免哈希碰撞。

动手验证 · lab 24-concurrency
select lab.prove(
  'the first cron instance gets the lock, the second does not',
  (select got from dblink('cron-a', $$select pg_try_advisory_lock(hashtext('nightly-rollup'))$$) as t(got boolean))
  and not (select got from dblink('cron-b', $$select pg_try_advisory_lock(hashtext('nightly-rollup'))$$) as t(got boolean)));

select lab.prove(
  'a transaction-scoped advisory lock is released at commit',
  lab.run('cron-a', 'begin') = 'ok'
  and (select true from dblink('cron-a', 'select pg_advisory_xact_lock(1, 42)') as t(v text))
  and not (select got from dblink('cron-b', 'select pg_try_advisory_xact_lock(1, 42)') as t(got boolean))
  and lab.run('cron-a', 'commit') = 'ok'
  and (select got from dblink('cron-b', 'select pg_try_advisory_xact_lock(1, 42)') as t(got boolean)));

完整实验脚本

死锁是顺序错误

死锁是两个事务分别等待对方持有的锁。经过 deadlock_timeout(1 秒)后,Postgres 会发现这个环并中止其中一个:

  tx1                                tx2
  ------------------------------     ------------------------------
  update accounts ... id = 1;
                                     update accounts ... id = 2;
  update accounts ... id = 2;
    (waits for tx2)
                                     update accounts ... id = 1;
                                       (waits for tx1: cycle)

tx1 收到 ERROR: deadlock detected;tx2 提交了。修复方法是一致的加锁顺序:按主键顺序锁行(先运行 select ... order by id for update,因为 where id = any($1) 不保证顺序),按固定顺序访问表,并重试 40P01。开启 log_lock_waits。

深入阅读:死锁会被计数,把计数画成图

错误的 DETAIL 会列出两个进程、各自在等待的 XID 和两条语句,这通常能直接指向以相反顺序加锁的两条代码路径。pg_stat_database.deadlocks 会统计死锁,因此把计数画成图:持续出现的少量死锁意味着热点路径中的顺序错误,而不是运气不好。开启 log_lock_waits 后,任何超过 deadlock_timeout 的锁等待都会连同阻塞者一起记录,即使没有形成环,也能捕获这些险些酿成死锁的情况。

动手验证 · lab 24-concurrency
select lab.prove(
  'exactly one of the two fails with 40P01 deadlock detected; the other gets its lock',
  :'outcome' in ('40P01: deadlock detected / ok', 'ok / 40P01: deadlock detected'));

select lab.prove(
  'pg_stat_database counts the deadlock',
  (select deadlocks from pg_stat_database where datname = current_database()) > :deadlocks_before);

完整实验脚本

找到根阻塞者

遇到卡住的情况,先找到队列根部。下面的查询会列出所有正在等待锁的会话,以及阻塞其中一个会话的每个会话;它生成了本章开头的表格,而那张表只展示了查询结果中的部分列:

select pid,
       application_name           as app,
       state,
       wait_event_type            as wait_type,
       wait_event,
       pg_blocking_pids(pid)      as blocked_by,
       date_trunc('second', now() - xact_start) as xact_age,
       left(query, 40)            as query
from pg_stat_activity
where backend_type = 'client backend'
  and (wait_event_type = 'Lock' or pid in (
        select unnest(pg_blocking_pids(pid)) from pg_stat_activity))
order by xact_start;

wait_event 在表锁上显示 relation,在行锁上显示 transactionid。根阻塞者的 blocked_by 为空,而且通常是 idle in transaction。select pg_terminate_backend(11033) 会结束它并回滚事务(pg_cancel_backend 对空闲会话不起作用)。然后修复那个让事务一直打开的问题。

事务要保持短小

本章几乎每个问题都会因长事务而恶化:它们持锁更久,延长锁队列,为死锁扩大窗口,让子事务溢出持续存在,并阻塞清理。不要让事务跨越网络调用或用户思考时间而保持打开。

本章要点

  • 读已提交会在应用的读改写中丢失更新。把算术放进 SQL。
  • repeatable read 和 serializable 会把异常变成 40001 错误;重试整个事务,把副作用放在提交之后。
  • 一个错误会中止事务。预防错误,而不是捕获错误;不要在循环中使用保存点。
  • 一个等待中的 ALTER TABLE 会阻塞该表上之后的每个查询。迁移总是设置 lock_timeout。
  • LWLock 等待意味着负载正在争用一个内部结构:调整负载。
  • for update skip locked 和部分索引可以构建一个与业务写入一起提交的任务队列。
  • 按一致的顺序加锁。发生事故时,先找到根阻塞者。

延伸阅读

表格阅读