selectlab.run('tx1','update credits set balance = 70 where account_id = 42')\g/dev/nullselectdblink_send_query('tx2','update credits set balance = 80 where account_id = 42')\g/dev/nullselectpg_sleep(0.3)\g/dev/nullselectlab.prove('tx2''s update waits for tx1''s row lock',(selectwait_event_type='Lock'andwait_event='transactionid'andpg_blocking_pids(pid)=(selectarray_agg(pid)fromlab.activitywhereapplication_name='tx1')fromlab.activitywhereapplication_name='tx2'));selectlab.run('tx1','commit')\g/dev/nullselectlab.finish('tx2')astx2_result\gsetselectlab.run('tx2','commit')\g/dev/nullselectlab.prove('under read committed tx2 proceeds without error and overwrites: balance 80, a lost update',:'tx2_result'='ok'and(selectbalancefromcreditswhereaccount_id=42)=80);
selectlab.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(selectbalancefromcreditswhereaccount_id=42)=70);
在 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 会记录每个事务读取过的内容,但不会阻塞任何人;当没有任何串行顺序能够解释这个结果时,它会中止其中一个事务(即使只读事务也可能看到不可能的结果)。
深入阅读:值班表上的写偏差
两行都处于启用状态;每个工程师的事务都会执行:
beginisolationlevelrepeatableread;selectcount(*)fromon_callwhereaccount_id=42andactive;-- 2, so it's safe
-- the other transaction deactivates user 2
updateon_callsetactive=falsewhereaccount_id=42anduser_id=1;commit;
selectlab.prove('serializable records what each transaction read as SIReadLock entries in pg_locks',exists(selectfrompg_locksljoinlab.activityausing(pid)wherea.application_name='tx1'andl.mode='SIReadLock'));selectlab.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(selectcount(*)fromon_callwhereactive)=1);
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
selectlab.prove('after a duplicate key error, the next statement fails with 25P02 and COMMIT reports ROLLBACK',lab.run('tx1','begin')='ok'andlab.run('tx1',$$insertintousers(account_id,email,name)values(1,'new@example.com','New')$$)='ok'andlab.run('tx1',$$insertintousers(account_id,email,name)values(2,'user1@example.com','Dup')$$)='23505: duplicate key value violates unique constraint "users_account_id_email_key"'andlab.run('tx1','update accounts set name = name where id = 1')='25P02: current transaction is aborted, commands ignored until end of transaction block'anddblink_exec('tx1','commit')='ROLLBACK');
begin;selectformat('savepoint s%s',g),format('insert into subxact_demo values (%s)',g)fromgenerate_series(1,64)g\gexecselectlab.prove('after 64 savepoints, each followed by an insert: 64 cached, not overflowed',(select(subxact_count,subxact_overflowed)=(64,false)fromlab.my_subxact()));savepoints65;insertintosubxact_demovalues(65);selectlab.prove('one more: the count stops at 64 and the overflow flag flips',(select(subxact_count,subxact_overflowed)=(64,true)fromlab.my_subxact()));rollback;
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
selectlab.prove('add column takes AccessExclusiveLock',lab.locks_taken('alter table projects add column note text','{projects}')='{"projects AccessExclusiveLock"}');selectlab.prove('validate constraint takes ShareUpdateExclusiveLock',lab.locks_taken('alter table projects validate constraint name_not_empty','{projects}')='{"projects ShareUpdateExclusiveLock"}');selectlab.prove('create index takes ShareLock',lab.locks_taken('create index on projects (created_at)','{projects}')='{"projects ShareLock"}');
selectlab.prove('the web SELECT waits too: its AccessShareLock is not granted, and it is blocked by the migration',exists(selectfrompg_lockswherepid=:web_pidandrelation='accounts'::regclassandmode='AccessShareLock'andnotgranted)andpg_blocking_pids(:web_pid)=array[:migration_pid]and(select(wait_event_type,wait_event)=('Lock','relation')fromlab.activitywherepid=:web_pid));
防御手段是让 DDL 快速放弃。设置 set lock_timeout = '2s' 后,同一个 alter 会在两秒后因 canceling statement due to lock timeout 失败,而不是一直占着队列。两秒的流量阻塞只是一个小插曲;重试几次。然后找到持有该锁的会话:持锁的 idle in transaction 会话就是 bug,而idle_in_transaction_session_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
selectlab.prove('the migration waits for AccessExclusiveLock, blocked by reporting',exists(selectfrompg_lockswherepid=:migration_pidandrelation='accounts'::regclassandmode='AccessExclusiveLock'andnotgranted)andpg_blocking_pids(:migration_pid)=array[:reporting_pid]);setlock_timeout='2s';selectlab.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');
外键需要较弱的那一对锁。向项目 1 插入事件时,会在项目行上取得 FOR KEY SHARE;重命名项目(FOR NO KEY UPDATE)不会阻塞它。但如果某个会话在项目上持有 select ... for update,插入就会等待:在我们的测试中等待了 3.6 秒,直到该会话回滚。因此,除非你要删除该行或更改它的键,否则应使用 for no key update 加锁。nowait 会直接失败而不是等待;skip locked 会跳过别人持有锁的行。
setlock_timeout='1s';selectlab.prove('an explicit FOR NO KEY UPDATE does not block it either',lab.try($$insertintoevents(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
selectlab.prove('FOR UPDATE on the project blocks the insert: an ungranted ShareLock on the locker''s transactionid',exists(selectfrompg_locksljoinlab.activityausing(pid)wherea.application_name='tx2'andl.locktype='transactionid'andl.mode='ShareLock'andnotl.granted)and(selectpg_blocking_pids(pid)fromlab.activitywhereapplication_name='tx2')=(selectarray_agg(pid)fromlab.activitywhereapplication_name='tx1'));
selectlab.prove('two open child inserts make the parent row''s xmax a multixact of two FOR KEY SHARE members',:'mx_multi'='t'and(selectcount(*)=2andbool_and(mode='keysh')frompg_get_multixact_members(:'mx_two'::xid)));selectlab.prove('a third locker creates a new multixact holding all three, rather than changing the old one',:'mx_three'<>:'mx_two'and(selectcount(*)=3andbool_and(mode='keysh')frompg_get_multixact_members(:'mx_three'::xid)));
-- one transaction locks 100 small tables in AccessShare mode
selectlab.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>16and:fp_fast<=64and:fp_fast+:fp_slow=100);
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
selectlab.prove('worker-b gets the next job immediately, not worker-a''s',:b_job=:a_job+1);selectlab.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'andlab.run('worker-c',$$selectidfromjobswherequeue='default'andstatus='queued'andrun_at<=now()orderbyrun_at,idlimit1forupdate$$)='55P03: canceling statement due to lock timeout');
-- success
updatejobssetstatus='done',locked_at=nullwhereid=$1;-- failure: retry with exponential backoff, give up after 5 attempts
updatejobssetstatus=casewhenattempts>=5then'failed'else'queued'end,run_at=now()+interval'10 seconds'*power(2,attempts),last_error=$2,locked_at=nullwhereid=$1;-- the reaper
updatejobssetstatus='queued',locked_at=null,locked_by=nullwherestatus='running'andlocked_at<now()-interval'15 minutes';
入队事务中的 notify jobs 只会在提交后送达,因此工作进程可以 listen,而不是高频轮询。但在入队速率很高时,每个发出 notify 的提交事务都要取得一个数据库级锁来排队通知,从而把这些提交串行化。每批只通知一次,或者让工作进程以适中的间隔轮询。
按事务取得咨询锁,而不是按会话取得
要对行以外的对象实现互斥(“每次只能有一个夜间汇总任务”),咨询锁会锁住一个整数键,至于这个键代表什么由你决定。两个 cron 实例分别启动事务,并运行 select pg_try_advisory_xact_lock(hashtext('nightly-rollup')):第一个得到 t,第二个得到 f 并退出。事务范围的形式会在提交或回滚时释放,因此优先使用它,而不是会话级的 pg_advisory_lock。只要连接池中的会话还在,会话级锁就会沿着任何跳过解锁的错误路径泄漏。
selectlab.prove('the first cron instance gets the lock, the second does not',(selectgotfromdblink('cron-a',$$selectpg_try_advisory_lock(hashtext('nightly-rollup'))$$)ast(gotboolean))andnot(selectgotfromdblink('cron-b',$$selectpg_try_advisory_lock(hashtext('nightly-rollup'))$$)ast(gotboolean)));selectlab.prove('a transaction-scoped advisory lock is released at commit',lab.run('cron-a','begin')='ok'and(selecttruefromdblink('cron-a','select pg_advisory_xact_lock(1, 42)')ast(vtext))andnot(selectgotfromdblink('cron-b','select pg_try_advisory_xact_lock(1, 42)')ast(gotboolean))andlab.run('cron-a','commit')='ok'and(selectgotfromdblink('cron-b','select pg_try_advisory_xact_lock(1, 42)')ast(gotboolean)));
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。
selectlab.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'));selectlab.prove('pg_stat_database counts the deadlock',(selectdeadlocksfrompg_stat_databasewheredatname=current_database())>:deadlocks_before);