跳转到主要内容

9 如实看待 ACID

Postgres 中 ACID 每个字母保证什么、背后的机制、代价、如何关闭,以及它究竟止于哪里。

我们提交了三行,三次都收到 COMMIT 返回,然后杀掉服务器,再把它启动起来,结果那些行不见了。Postgres 的工作方式完全符合配置。一个会话设置 synchronous_commit = off 用这些行换来了速度,而这项取舍从头到尾都写在文档里。

ACID 常被当成一枚徽章:数据库“是 ACID 的”,所以数据就是安全的。其实它是四项彼此独立的承诺,每项都由一种具体机制兑现,都有代价,都有可以关闭它的开关,也都有它止步的边界。Postgres 支持的系统中,大多数数据丢失发生在这些边界上,而不是四项承诺的内部。

四项承诺,四种机制

下面这张表概括了整章。后文会逐行证明它。

字母由什么保证代价由什么关闭止于
原子性行版本、pg_xact 状态每次中止留下死元组自动提交你的事务边界
一致性你的约束每次写入都要检查不声明约束跨行规则
隔离性快照、锁、SSI等待、重试读已提交你选择的级别
持久性提交时刷新的 WAL每次提交都要刷新synchronous_commit、fsync、会撒谎的磁盘这台机器的磁盘
表 9-1 四项承诺,四种机制

它们彼此独立地失效,所以我们一次讲一项。

原子性:从来没有真正撤销什么

原子性意味着事务要么完整发生,要么完全不发生。你可能会想象一份撤销日志:回滚时,数据库把旧值放回去。

Postgres 没有撤销。回滚根本不碰表。

回滚会让行留在页上

每个行版本都带有创建它的事务 ID(xmin),而它是否计入结果,取决于提交日志 pg_xact 对该事务的记录(更新就是插入)。它就像支票登记簿:一张被取消的支票不会被擦掉,而是盖上 VOID 印章。

向一个保存账户余额的小型 credits 表插入三行,然后回滚。pageinspect 仍然能在页上找到这三个元组,逐字节都和插入时写入的一样,而 pg_xact_status() 会说它们的事务是 aborted。pg_xact 中的两个位是唯一把它们藏起来的东西。每个读取它们的查询都会检查这个状态并跳过它们;在清理移除它们之前,它们会一直留在那里。

回滚就是这么回事:翻转一个状态,把残局留给以后处理。这就是为什么回滚一行和回滚一千万行耗时相同。

失败的语句什么也没改变,却仍然要付出代价

原子性也适用于每条语句。给每个账户扣 10 个积分,而最后一行的账户 1000 只有 5 个积分:

update credits set balance = balance - 10;
ERROR:  new row for relation "credits" violates check constraint
        "credits_balance_check"
DETAIL:  Failing row contains (1000, -5).

什么都没有改变:999 个余额仍然是 100。但这条更新在碰到第 1000 行之前,已经写入了 999 个新行版本,而它们全都还在表里,成了死元组:

 pages | tuple_count | dead_tuple_count
-------+-------------+------------------
    11 |        1000 |              999

表从 6 页增长到 11 页,一条什么也没改变的语句还写入了超过 100,000 字节的 WAL。一次失败的批量更新,其 I/O 成本大致和成功更新相同,并留下相当于整张表数据量的死元组,等待 清理。一个在最后一行失败、每五分钟重试一次的批处理任务,就是一台制造膨胀的机器。

很可能失败时,应在写入前先验证(例如使用 where balance >= 10,或先执行检查查询);大规模变更要分批执行,这样失败浪费的是一个批次,而不是整张表。在事务内部,保存点可以在更细的粒度上提供同样的全有或全无效果(保存点可以把损失控制在局部)。

深入阅读:回滚和失败的更新会在页上留下什么
begin;
insert into credits values (5001, 50), (5002, 50), (5003, 50);
select pg_current_xact_id();   -- 854733
rollback;

select lp, t_xmin from heap_page_items(get_raw_page('credits', 5))
where t_xmin = '854733';
 lp | t_xmin
----+--------
 77 | 854733
 78 | 854733
 79 | 854733
动手验证 · lab 08-acid
begin;
insert into credits values (5001, 50), (5002, 50), (5003, 50);
select pg_current_xact_id() as aborted_xid \gset
rollback;

select lab.prove(
  'after rollback, all three row versions are still on the page',
  (select count(*) = 3
   from heap_page_items(get_raw_page('credits', (pg_relation_size('credits') / 8192 - 1)::int))
   where t_xmin::text = :'aborted_xid'));

select lab.prove(
  'pg_xact says their transaction aborted',
  pg_xact_status(:'aborted_xid') = 'aborted');

完整实验脚本

手工运行失败的更新时,死元组数量可能略低于 999:之后读取一个几乎填满的页时,可能会提前剪枝掉一些已中止的行版本(页内剪枝要等到页接近填满)。实验会保持一个更早的快照打开,从而让数量精确。第一次读取已中止事务的元组的查询还会把判定缓存为提示位 HEAP_XMIN_INVALID,后续读取者就会跳过 pg_xact 查询(读取也可能写入)。

动手验证 · lab 08-acid
-- s1 holds a snapshot older than the failed UPDATE, so nothing prunes early.
call lab.on('s1', 'begin isolation level repeatable read');
select x as _ from dblink('s1', 'select 1') as t(x int) \gset

select lab.prove(
  'the failed UPDATE left 999 dead row versions behind',
  (select dead_tuple_count = 999 from pgstattuple('credits')));

select lab.prove(
  'the failed UPDATE grew the table from 6 to 11 pages',
  :pages_before = 6 and pg_relation_size('credits') / 8192 = 11);

select lab.prove(
  'the failed UPDATE still wrote over 100,000 bytes of WAL',
  lab.my_wal() - :wal_before > 100000);

select lab.prove(
  'no query sees them',
  (select count(*) = 0 from credits where account_id > 5000));
select lab.prove(
  'the charge failed on its last row, and all 999 other rows are unchanged',
  (select count(*) filter (where balance = 100) = 999 from credits));

完整实验脚本

提交只是一个很小的记录

那么,是什么让一个事务变成已提交?是 WAL 记录很小,页镜像却不是中的 34 字节 COMMIT 记录:写入它,让 WAL 刷过它,在 pg_xact 中把事务标记为已提交,然后让它变得可见。刷新返回的那一刻,就是提交发生的时刻。刷新后一微秒服务器崩溃,恢复会重放这条记录,提交仍然成立;早一微秒,事务就从未发生过(恢复只做重做)。回滚的 ABORT 记录甚至不会刷新:没有提交记录的事务本来就会被视为已中止。

一致性是由你写出的部分

C 是四个字母中最特殊的一个。原子性、隔离性和持久性是数据库负责提供的东西。一致性意味着“事务把数据从一个有效状态带到另一个有效状态”,而“有效”由你定义:not null、check、unique、外键和排他约束(有目的地选择约束是它们的归宿)。Postgres 只会严格执行你声明的规则,不会多执行一条。

约束按语句触发,或在提交时触发

Postgres 在写入每一行时检查大多数约束,并在每条语句结束时检查外键。声明为 deferrable initially deferred 的唯一约束、主键、外键或排他约束会改为等到 COMMIT 时才检查,因此一个事务可以先插入发票明细,再插入发票。如果发票始终没有出现,COMMIT 就会失败。(check 和 not null 不能延迟检查。)

所以 COMMIT 可能失败。延迟约束、串行化失败和连接丢失都可能在这里暴露;而在之前已经出现过任何错误后,COMMIT 会悄悄返回 ROLLBACK(一个错误会中止整个事务)。忽略 commit 返回结果的代码会告诉用户数据已经保存,实际却没有。

深入阅读:一次失败的 COMMIT
create table invoice_lines (
  invoice_id bigint not null
    references invoices (id) deferrable initially deferred,
  amount     int not null
);

begin;
insert into invoice_lines values (777, 10);   -- no invoice 777: fine, for now
commit;
INSERT 0 1
ERROR:  insert or update on table "invoice_lines" violates foreign key
        constraint "invoice_lines_invoice_id_fkey"
DETAIL:  Key (invoice_id)=(777) is not present in table "invoices".

插入成功了;COMMIT 失败,什么也没有保留下来。在提交前插入发票 777,同一个事务就能顺利提交。

跨行规则对约束不可见

大多数真实业务规则都跨越多行:“免费账户最多有 3 个席位”“借方等于贷方”。check 约束只能看到正在写入的那一行,所以通常下一步是写一个触发器,统计该账户的席位数,在达到 3 个时抛出异常。

一次只处理一个请求时,它确实有效。现在,两个请求同时给一个已有 2 个席位的账户添加席位。因为另一个插入还没有提交,两个触发器各自看到的都是 2。两边都提交后,账户有了 4 个席位,而触发器从未抛出异常。

代码没有错。一个会读取其他行的规则,其保证强度不会超过所用隔离级别的保证。 有三种修复方式,按强度从高到低排列:

  1. 把它变成约束。“不能有两行冲突”可以用排他约束表达(排他约束让重叠变得不可能);数量上限也可以改成约束:用 seat_number 表示席位编号,用 check (seat_number between 1 and 3) 约束它,再用 unique (account_id, seat_number),就能把席位数封顶为 3。
  2. 让写入者在可串行化级别下运行,这样其中一个会因为可重试错误而中止。
  3. 在父行上串行化:在触发器开头执行 perform 1 from accounts where id = new.account_id for no key update。使用 for no key update,不要使用 for update:for update 还会阻塞向任何表插入且引用该账户的行时所进行的外键检查,而且会一直阻塞到触发器所在的事务结束。

深入阅读:竞态触发器,以及为什么 CHECK 约束中的函数会欺骗你

触发器,以及实验用来与它制造竞态的代码:

create function seats_limit() returns trigger language plpgsql as $$
begin
  if (select count(*) from seats where account_id = new.account_id) >= 3 then
    raise exception 'account % already has 3 seats', new.account_id;
  end if;
  return new;
end $$;
动手验证 · lab 08-acid
insert into seats values (1, 1), (1, 2);

call lab.on('s1', 'begin');
call lab.on('s1', 'insert into seats values (1, 3)');
call lab.on('s2', 'begin');
call lab.on('s2', 'insert into seats values (1, 4)');
call lab.on('s1', 'commit');
call lab.on('s2', 'commit');

select lab.prove(
  'two concurrent inserts each counted 2 seats, so the trigger let both in: 4 seats',
  (select count(*) = 4 from seats where account_id = 1));

完整实验脚本

CHECK 中的子查询会直接被拒绝(cannot use subquery in check constraint)。把它包进函数可以绕过解析器,但随后它会欺骗你。Postgres 假定一行的检查结果不会改变,因此不会在其他行发生变化时重新检查;之后的转储和恢复可能会因为某些行不再通过检查而失败。

在分布式系统里,“一致性”还指另一件事。在 CAP 和复制语境中,它说的是每个副本是否都返回最新写入;ACID 中的 C 说的是你的规则是否成立。一个副本完全符合 ACID 的一致性,却仍然可能落后数秒(写入之后,副本可能显示旧值)。

隔离性是一个旋钮,不是一项保证

隔离性决定你能看到并发事务的哪些影响。理想情况是,事务表现得仿佛它们是一个接一个运行的。Postgres 能提供这种效果,但要付出代价,而且默认设置更弱。下面是这个模型;有目的地选择隔离级别会从实践角度展开。

只有可串行化能排除所有异常

异常是指某个结果不可能由这些事务的任何串行顺序产生。SQL 标准用其中三种异常定义了各个级别;Berenson 等人在 1995 年指出,这份清单并不完整。Postgres 在各级别下的行为如下:

异常读已提交可重复读可串行化
脏读:看到未提交的数据从不从不从不
不可重复读:两次读取之间一行发生变化会从不从不
幻读:出现新的匹配行会从不从不
丢失更新:读取、计算、覆盖会,且悄悄发生错误(40001)错误(40001)
写偏差:两边读取,各自写入不同的行会会错误(40001)
只读异常:读取者看到不可能的状态会会错误(40001)
表 9-2 只有可串行化能排除所有异常

只读事务也可能看到不可能的状态

这是最让人意外的一种异常,因为受害者从头到尾没有写入任何东西。计费按周期运行。一行控制记录保存当前周期;费用都记录在这个周期上;月底有人通过递增周期号来关闭当前周期,报表则汇总已关闭的周期。这里有三个事务:

T1: add a charge         T2: close period 1       T3: report
-----------------------  -----------------------  -----------------------
begin;
insert into charges
  select current_period
  ... (reads period 1)
                         begin;
                         update billing_period
                           set current_period
                             = 2;
                         commit;
                                                  begin;
                                                  select current_period;
                                                    -> 2: 1 is closed
                                                  select count(*),
                                                    sum(amount)
                                                    from charges
                                                    where period = 1;
                                                    -> 3, 600
                                                  commit;
commit;

在可重复读级别下,一切都会成功。T3 报告说周期 1 最终有 3 笔费用,总额为 600。随后 T1 把自己的费用提交到周期 1;此时周期 1 已经有 4 笔费用,总额为 700。没有任何串行顺序能产生这个结果:如果 T1 在 T2 之前运行,报表就会包含它的费用;如果在 T2 之后运行,这笔费用就会落到周期 2。

在 serializable 下,报表仍然得到相同的 3 笔费用,但 T1 的提交会失败,并提示 could not serialize access due to read/write dependencies among transactions。T1 重试,读取到周期 2,费用也就落在了应该在的地方。没有任意两个事务写入同一行,所以只有会追踪读取的隔离级别才能捕获它。

深入阅读:可串行化如何在不阻塞任何人的情况下捕获它
动手验证 · lab 08-acid
call lab.run_billing('repeatable read');
select lab.prove(
  'then T1 commits fine',
  lab.try_on('s1', 'commit') = 'ok');
select lab.prove(
  'and the closed period 1 now has 4 charges, 700',
  (select count(*) = 4 and sum(amount) = 700 from charges where period = 1));

call lab.run_billing('serializable');
select lab.prove(
  'but now T1''s commit fails with a serialization error',
  lab.try_on('s1', 'commit') like 'could not serialize access%');

完整实验脚本

Postgres 用可串行化快照隔离(serializable snapshot isolation)实现可串行化级别(Ports 和 Grittner,2012)。每个事务都在一个快照上运行,与可重复读完全相同,同时还会把它读取的内容记录为不阻塞的 SIReadLock 条目。当一个事务写入并发事务读取过的东西时,就形成了读写依赖。每个串行化异常都需要一个“枢轴”:它既有传入依赖,又有传出依赖的事务。Postgres 发现这种情况时,会以 SQLSTATE 40001 中止一个事务。没有人被阻塞,但有人必须重试。这里 T1 就是枢轴:报表在 T1 添加费用前读取了周期 1 的费用,这是进入 T1 的依赖;T1 读取了之后被 T2 改动的周期,这是离开 T1 的依赖。被取消的是 T1,错误消息也明确这样说:Reason code: Canceled on identification as a pivot, during commit attempt.

这套记账比较粗:它可能跟踪整页或整个关系,而不是具体行,因此会有误报;代价确实存在,但通常不算大。对于长时间运行的报表,begin isolation level serializable read only deferrable 会等待,直到它能取得一个不可能参与任何异常的快照,然后在不进行追踪的情况下运行,也就不再会因这类串行化异常而失败。

持久性就是一次 fsync

持久性意味着已提交的事务能在崩溃后存活:提交记录在 COMMIT 返回前已经到达磁盘。提交意味着刷新已经测量过这项代价:每次提交都要一次 fsync;在刷新耗时 7.7 ms 的磁盘上,单个客户端每秒大约只能提交 130 次。现在我们故意把它破坏掉。

synchronous_commit = off 会丢失一个窗口

设置 synchronous_commit = off 后,COMMIT 会在记录刷新前返回,因此一次崩溃最多可以带走大约 600 ms 内已经确认的提交(synchronous_commit = off 可能丢失约 600 ms 内的提交)。风险是丢失,绝不是损坏。微妙之处在于:事务在持久化之前就已经可见。其他会话可以读取它的行,根据这些行采取行动,甚至发送相关邮件,然后崩溃会把这些行抹掉。

一次真实崩溃带走了三笔已确认的提交

本章实验会启动自己的服务器,这样就能杀掉它。实验把 wal_writer_delay 设为 10 秒(最大值),以扩大这个窗口,在 synchronous_commit = off 下提交三行,并确认它们的 WAL 仍然只在共享内存中。然后向容器发送 SIGKILL:不会有关闭检查点,也不会有最后一次刷新。重启时,恢复会重放到有效 WAL 的末尾,执行 select count(*) from crash_test 得到 0。

三笔已经确认的提交,就这样消失了。用默认的 synchronous_commit = on 做同样的插入,再执行同样的杀进程操作,重启后会看到三行都回来。把 wal_writer_delay 设为 1 秒时,在杀进程前超过 3 秒就已经提交的行也能存活。这个窗口确实存在,而且宽度正如文档所说。

所以,只在事务级别使用它,而且只用于可以丢失的写入。

深入阅读:杀掉容器不等于断电

重启日志说明了一切:

LOG:  database system was not properly shut down; automatic recovery in progress
LOG:  redo starts at 0/1796688
LOG:  invalid record length at 0/1796758: expected at least 24, got 0
LOG:  redo done at 0/17966E0 ...

“got 0” 表示有效 WAL 的末尾,不是错误。实验知道提交仍在内存中,因为 pg_current_wal_lsn()(WAL 已写出的距离)落后于 pg_current_wal_insert_lsn()(WAL 已生成的距离)。

杀掉容器杀掉了 Postgres,而宿主机的内核仍在运行。Postgres 已经通过 write() 交给操作系统、但还没有用 fsync() 强制刷到磁盘的内容,仍然停留在内核的页缓存中,并因此存活下来。这就是为什么实验要检查 WAL 在杀进程前甚至还没有被写出。

真正的断电还会带走页缓存,随后也会带走磁盘自己的缓存。要测试这一点,需要拔电源,或使用可以硬重置的虚拟机,以及为此设计的工具(文档指向 diskchecker.pl)。我们的测试展示的是 COMMIT 返回与 WAL 离开 Postgres 内存之间的窗口。低于这条线的部分属于操作系统和硬件的职责,而它们也可能在这里失效。

动手验证 · lab 08-acid.sh
prove "three async commits returned, and their WAL was still only in shared memory" $lost_ok
prove "after SIGKILL and restart, all three async-committed rows are gone" \
  holds "select count(*) = 0 from crash_test"
prove "after SIGKILL and restart, all three sync-committed rows are there" \
  holds "select count(*) = 3 from crash_test where how = 'sync'"
prove "after SIGKILL, async commits older than 3 x wal_writer_delay survive" \
  holds "select count(*) = 2 from crash_test where id in (4, 5)"

完整实验脚本

fsync = off 和 full_page_writes = off 只能用于 CI

两个设置会移除整台服务器的持久性。fsync = off 会停止让 Postgres 强制把任何东西写入磁盘,电源故障可能让数据文件和 WAL 彼此不一致,造成文档所说的“unrecoverable data corruption”(不可恢复的数据损坏)。full_page_writes = off 会丢弃用于修复撕裂页的页镜像(检查点后的第一次变化会记录整页),只有在不会撕裂 8 KB 写入的存储上才安全,例如 ZFS。两者都会让基准测试看起来很漂亮。把它们用于一次性的 CI 数据库,除此之外不要使用。

fsync 只有在磁盘诚实的时候才可靠

fsync 只有在磁盘说“完成”时确实说了实话才有用。许多消费级磁盘在写入自己的易失缓存后就确认写入,而断电会把该缓存抹掉。使用带掉电保护的存储,在该卷上运行 pg_test_fsync,并对快得超过硬件物理能力的刷新保持怀疑(时间都花在哪里)。

还要保持数据校验和开启(从 PostgreSQL 18 起,这是 initdb 的默认设置)。磁盘或控制器翻转的一位会变成错误,而不是静默地产生错误的行。校验和能检测损坏;备份负责修复它(有校验和时,连读取也会写 WAL解释了它们的 WAL 成本)。

深入阅读:操作系统撒了谎,以及校验和升级陷阱

2018 年,Postgres 开发者发现,在 Linux 上失败的 fsync() 可能只报告一次错误,随后却把脏页标记为干净;于是重试会在数据尚未到达磁盘时“成功”。从那以后,Postgres 会把数据文件上的 fsync 失败当作崩溃(PANIC),并从 WAL 恢复,而不是相信重试;这一改动随 PostgreSQL 12 发布,并回移到了当时仍受支持的版本(Fsync Errors)。

pg_upgrade 要求校验和设置一致,因此,把一个没有校验和的旧集簇迁移到全新的 18 集簇,需要使用 initdb --no-data-checksums,或者先在旧集簇离线时用 pg_checksums 启用校验和。校验和只保护数据页,不保护内存或内部结构。

这里持久,不等于处处持久

前面讲的所有内容都只涉及一台机器的磁盘。使用默认的异步复制时,提交会在主库刷新完成后返回,副本稍后才收到它。在这段时间内故障切换,用户已经看到成功的提交就会消失,而新的主库仍然完全一致(异步是默认设置,而且可能丢失提交)。同步复制会用每次提交一次网络往返的代价关闭这个窗口(同步提交的代价是一次往返)。

在事故替你决定之前,先用一个数字决定:你最多能承受丢失多少笔已经确认的提交。

ACID 止于一个数据库中的一个事务

上面的每项保证都有同一个边界:一个数据库中的一个事务。你的业务操作却很少尊重这个边界。

自动提交会把一个操作拆成两个

从账户 1 向账户 2 转移 30 个积分,是一个业务操作。如果不写 begin,而是直接写两条语句,那就是两个事务;许多 ORM 默认就是这样工作的:每个 save() 都是一次独立提交。若应用在两条语句之间崩溃:

update credits set balance = balance - 30 where account_id = 1;
-- the process dies here
update credits set balance = balance + 30 where account_id = 2;

sum(balance) 恰好减少了 30。30 个积分消失了,但没有任何数据库保证被破坏:每条语句都具备原子性、一致性、隔离性和持久性。出问题的是这个操作,因为没人告诉数据库它们属于同一件事。把它们包在 begin 和 commit 中,同样的崩溃会让全部操作回滚。

每个业务操作都要放进一个显式事务,并弄清你的 ORM 默认做什么。事务要保持短小:不要在其中执行 HTTP 调用,也不要在其中等待用户,因为打开的事务会持有锁,并且拖住清理。

动手验证 · lab 08-acid
call lab.on('s1', 'update credits set balance = balance - 30 where account_id = 1');
-- The app crashes here, before its second statement runs.
select dblink_disconnect('s1') = 'OK' as gone \gset
select lab.prove(
  'two autocommit statements, a crash between them: 30 credits vanished',
  (select sum(balance) = :total_before - 30 from credits));

-- ... restore, then the same with an explicit transaction:
call lab.on('s1', 'begin');
call lab.on('s1', 'update credits set balance = balance - 30 where account_id = 1');
select dblink_disconnect('s1') = 'OK' as gone \gset
select lab.prove(
  'one explicit transaction, the same crash: nothing changed',
  (select sum(balance) = :total_before from credits)
  and (select balance = 100 from credits where account_id = 1));

完整实验脚本

两阶段提交很少是答案

当一个操作跨越两个数据库时,教科书式的答案是两阶段提交。Postgres 支持其中属于自己的一部分(prepare transaction,然后 commit prepared),但一个已准备事务不属于任何会话:如果协调器死亡,它会在重启后继续存在,持有锁,也拖住清理。这就是 max_prepared_transactions 默认值为 0 的原因。而消息代理、支付 API 或邮件服务根本不支持两阶段提交。

深入阅读:已准备事务能在崩溃后存活,它的锁也一样
begin;
insert into crash_test values (6, 'prepared');
update crash_test set how = 'locked by a prepared transaction' where id = 1;
prepare transaction 'transfer-42';

SIGKILL 并重启后,transfer-42 仍然位于 pg_prepared_xacts 中,它插入的行仍然不可见,对第 1 行的更新仍然会等待它持有的锁。如果启用这一功能,就要对 pg_prepared_xacts 中存在超过一分钟的任何行设置告警(清理无法移除旧快照可能需要的内容)。

动手验证 · lab 08-acid.sh
crash
prove "after SIGKILL and restart, the prepared transaction is still in pg_prepared_xacts" \
  holds "select count(*) = 1 from pg_prepared_xacts where gid = 'transfer-42'"
prove "it still holds its row lock: an update of row 1 gives up waiting" \
  fails_with "lock timeout" -c "set lock_timeout = '500ms'" -c "update crash_test set how = 'x' where id = 1"
prove "it holds back vacuum: a row deleted after it was prepared stays dead, not removed" \
  holds "select dead_tuple_count >= 1 from pgstattuple('crash_test')"

完整实验脚本

把消息和订单放进同一个事务

这里有一个几乎每个应用都会踩到的情况。下订单会写入订单,并为其他服务发布一条“订单已下达”消息。因为涉及两个系统,无论先做哪一个,两个动作之间的崩溃都会让它们互相矛盾:订单没有任何人知道,或出现了一条关于不存在订单的消息。

解决办法是停止向两个系统写入。把消息写进一张表,并让它和订单处于同一个事务中,再由另一个进程负责投递:

with o as (
  insert into orders (account_id, total_cents) values (42, 1999)
  returning id, account_id, total_cents
)
insert into outbox (topic, payload)
select 'order.placed',
       jsonb_build_object('order_id', id, 'account_id', account_id,
                          'total_cents', total_cents)
from o;

现在,原子性同时覆盖了订单和消息:两者要么一起提交,要么都不提交。中继进程在同一个事务中,用for update skip locked认领一批消息,发布它们,再删除已经发送的消息。这个事务是“不要在事务中进行网络调用”这条原则唯一有意保留的例外:批次要小,并为中继角色设置 idle_in_transaction_session_timeout,这样卡住的代理就不会让事务一直打开。

如果中继在发布消息后、delete 提交前死亡,下一个中继会再次发送这些消息。这就是至少一次投递,代价是消费者要按消息 ID 去重。它总好过“消息有时会丢”。邮件和扣款也可以使用同样的形状:在事务中记录意图,提交后执行动作,并让动作可以安全地重复(重试整个事务)。

深入阅读:两个中继不会认领同一条消息
begin;
select id, topic, payload from outbox
order by id limit 100
for update skip locked;
-- publish each message, and wait for the broker's acknowledgment
delete from outbox where id = any ($1);   -- the ids you published
commit;

skip locked 让多个中继可以并行运行,而不会重复认领(Postgres 队列和业务写入一起提交)。实验中,两个中继同时各请求 5 条消息,会得到 10 条不同的消息。在非常高的吞吐量下,逻辑解码可以直接从 WAL 读取 outbox 插入,而不是轮询;但对大多数应用来说,轮询中继已经足够走很远。

动手验证 · lab 08-acid
select lab.prove(
  'every order has its message, and a rolled-back order has neither',
  (select count(*) = 2 from orders) and (select count(*) = 2 from outbox)
  and not exists (select 1 from orders o
                  where not exists (select 1 from outbox m
                                    where (m.payload ->> 'order_id')::bigint = o.id)));

select lab.prove(
  'two relays with FOR UPDATE SKIP LOCKED claim disjoint batches of 5',
  (select count(*) = 10 and count(distinct id) = 10 from claimed));

select lab.prove(
  'the relay that died after publishing left its batch in the outbox: it gets sent again',
  (select count(*) = 5 from outbox where id in (select id from claimed where relay = 's2')));

完整实验脚本

本章要点

  • 回滚什么也不会撤销:已中止事务的行版本会留在页上,直到清理。失败的语句仍然会写入它处理过的行和 WAL,并留下膨胀。
  • 事务在提交记录刷新完成的那一刻提交。检查每一次 COMMIT 的结果。
  • 一致性只包含你声明的规则。读取其他行的检查存在竞态;应使用约束、可串行化级别,或锁住父行。
  • 读未提交实际上是读已提交。只有可串行化能捕获写偏差和只读异常,而且需要重试。
  • synchronous_commit = off 可能丢失约 600 ms 内已经可见且已确认的提交。按事务使用它,而且只用于可以丢失的数据。
  • 生产环境永远不要使用 fsync = off 或 full_page_writes = off。使用带掉电保护的存储,并保持校验和开启。
  • 一次提交只在一台机器上持久。使用异步副本时,故障切换可能丢失已确认的提交;在事故替你做决定前,先选定可以丢多少。
  • 每个业务操作都放进一个显式事务。要通知另一个系统,就在同一个事务中写入 outbox 行,再由中继投递。

延伸阅读

  • Transaction isolation,PostgreSQL 文档。各隔离级别的确切保证,包括可串行化级别的只读可延迟模式。
  • Reliability,PostgreSQL 文档。持久提交对磁盘、缓存和控制器的要求,以及测试方法。
  • Asynchronous commit,PostgreSQL 文档。synchronous_commit = off 的风险窗口,使用开发者自己的表述说明。
  • A Critique of ANSI SQL Isolation Levels,Berenson、Bernstein、Gray、Melton、O’Neil 和 O’Neil,SIGMOD 1995。指出标准异常清单不完整,并定义快照隔离的论文。
  • Serializable Snapshot Isolation in PostgreSQL,Ports 和 Grittner,PVLDB 2012。由实现者说明 Postgres 的可串行化级别如何工作。SSI wiki 页面中有本章改写的批量关账示例。

表格阅读