9 如实看待 ACID
我们提交了三行,三次都收到 COMMIT 返回,然后杀掉服务器,再把它启动起来,结果那些行不见了。Postgres 的工作方式完全符合配置。一个会话设置 synchronous_commit = off 用这些行换来了速度,而这项取舍从头到尾都写在文档里。
ACID 常被当成一枚徽章:数据库“是 ACID 的”,所以数据就是安全的。其实它是四项彼此独立的承诺,每项都由一种具体机制兑现,都有代价,都有可以关闭它的开关,也都有它止步的边界。Postgres 支持的系统中,大多数数据丢失发生在这些边界上,而不是四项承诺的内部。
四项承诺,四种机制
下面这张表概括了整章。后文会逐行证明它。
| 字母 | 由什么保证 | 代价 | 由什么关闭 | 止于 |
|---|---|---|---|---|
| 原子性 | 行版本、pg_xact 状态 | 每次中止留下死元组 | 自动提交 | 你的事务边界 |
| 一致性 | 你的约束 | 每次写入都要检查 | 不声明约束 | 跨行规则 |
| 隔离性 | 快照、锁、SSI | 等待、重试 | 读已提交 | 你选择的级别 |
| 持久性 | 提交时刷新的 WAL | 每次提交都要刷新 | synchronous_commit、fsync、会撒谎的磁盘 | 这台机器的磁盘 |
它们彼此独立地失效,所以我们一次讲一项。
原子性:从来没有真正撤销什么
原子性意味着事务要么完整发生,要么完全不发生。你可能会想象一份撤销日志:回滚时,数据库把旧值放回去。
Postgres 没有撤销。回滚根本不碰表。
回滚会让行留在页上
每个行版本都带有创建它的事务 ID(xmin),而它是否计入结果,取决于提交日志 pg_xact 对该事务的记录(更新就是插入)。它就像支票登记簿:一张被取消的支票不会被擦掉,而是盖上 VOID 印章。
向一个保存账户余额的小型 credits 表插入三行,然后回滚。pageinspect 仍然能在页上找到这三个元组,逐字节都和插入时写入的一样,而 pg_xact_status() 会说它们的事务是 aborted。pg_xact 中的两个位是唯一把它们藏起来的东西。每个读取它们的查询都会检查这个状态并跳过它们;在清理移除它们之前,它们会一直留在那里。
回滚就是这么回事:翻转一个状态,把残局留给以后处理。这就是为什么回滚一行和回滚一千万行耗时相同。
失败的语句什么也没改变,却仍然要付出代价
原子性也适用于每条语句。给每个账户扣 10 个积分,而最后一行的账户 1000 只有 5 个积分:
什么都没有改变:999 个余额仍然是 100。但这条更新在碰到第 1000 行之前,已经写入了 999 个新行版本,而它们全都还在表里,成了死元组:
表从 6 页增长到 11 页,一条什么也没改变的语句还写入了超过 100,000 字节的 WAL。一次失败的批量更新,其 I/O 成本大致和成功更新相同,并留下相当于整张表数据量的死元组,等待 清理。一个在最后一行失败、每五分钟重试一次的批处理任务,就是一台制造膨胀的机器。
很可能失败时,应在写入前先验证(例如使用 where balance >= 10,或先执行检查查询);大规模变更要分批执行,这样失败浪费的是一个批次,而不是整张表。在事务内部,保存点可以在更细的粒度上提供同样的全有或全无效果(保存点可以把损失控制在局部)。
深入阅读:回滚和失败的更新会在页上留下什么
动手验证 · lab 08-acid
手工运行失败的更新时,死元组数量可能略低于 999:之后读取一个几乎填满的页时,可能会提前剪枝掉一些已中止的行版本(页内剪枝要等到页接近填满)。实验会保持一个更早的快照打开,从而让数量精确。第一次读取已中止事务的元组的查询还会把判定缓存为提示位 HEAP_XMIN_INVALID,后续读取者就会跳过 pg_xact 查询(读取也可能写入)。
动手验证 · lab 08-acid
提交只是一个很小的记录
那么,是什么让一个事务变成已提交?是 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
插入成功了;COMMIT 失败,什么也没有保留下来。在提交前插入发票 777,同一个事务就能顺利提交。
跨行规则对约束不可见
大多数真实业务规则都跨越多行:“免费账户最多有 3 个席位”“借方等于贷方”。check 约束只能看到正在写入的那一行,所以通常下一步是写一个触发器,统计该账户的席位数,在达到 3 个时抛出异常。
一次只处理一个请求时,它确实有效。现在,两个请求同时给一个已有 2 个席位的账户添加席位。因为另一个插入还没有提交,两个触发器各自看到的都是 2。两边都提交后,账户有了 4 个席位,而触发器从未抛出异常。
代码没有错。一个会读取其他行的规则,其保证强度不会超过所用隔离级别的保证。 有三种修复方式,按强度从高到低排列:
- 把它变成约束。“不能有两行冲突”可以用排他约束表达(排他约束让重叠变得不可能);数量上限也可以改成约束:用
seat_number表示席位编号,用check (seat_number between 1 and 3)约束它,再用unique (account_id, seat_number),就能把席位数封顶为 3。 - 让写入者在可串行化级别下运行,这样其中一个会因为可重试错误而中止。
- 在父行上串行化:在触发器开头执行
perform 1 from accounts where id = new.account_id for no key update。使用for no key update,不要使用for update:for update还会阻塞向任何表插入且引用该账户的行时所进行的外键检查,而且会一直阻塞到触发器所在的事务结束。
深入阅读:竞态触发器,以及为什么 CHECK 约束中的函数会欺骗你
触发器,以及实验用来与它制造竞态的代码:
动手验证 · lab 08-acid
CHECK 中的子查询会直接被拒绝(cannot use subquery in check constraint)。把它包进函数可以绕过解析器,但随后它会欺骗你。Postgres 假定一行的检查结果不会改变,因此不会在其他行发生变化时重新检查;之后的转储和恢复可能会因为某些行不再通过检查而失败。
在分布式系统里,“一致性”还指另一件事。在 CAP 和复制语境中,它说的是每个副本是否都返回最新写入;ACID 中的 C 说的是你的规则是否成立。一个副本完全符合 ACID 的一致性,却仍然可能落后数秒(写入之后,副本可能显示旧值)。
隔离性是一个旋钮,不是一项保证
隔离性决定你能看到并发事务的哪些影响。理想情况是,事务表现得仿佛它们是一个接一个运行的。Postgres 能提供这种效果,但要付出代价,而且默认设置更弱。下面是这个模型;有目的地选择隔离级别会从实践角度展开。
只有可串行化能排除所有异常
异常是指某个结果不可能由这些事务的任何串行顺序产生。SQL 标准用其中三种异常定义了各个级别;Berenson 等人在 1995 年指出,这份清单并不完整。Postgres 在各级别下的行为如下:
| 异常 | 读已提交 | 可重复读 | 可串行化 |
|---|---|---|---|
| 脏读:看到未提交的数据 | 从不 | 从不 | 从不 |
| 不可重复读:两次读取之间一行发生变化 | 会 | 从不 | 从不 |
| 幻读:出现新的匹配行 | 会 | 从不 | 从不 |
| 丢失更新:读取、计算、覆盖 | 会,且悄悄发生 | 错误(40001) | 错误(40001) |
| 写偏差:两边读取,各自写入不同的行 | 会 | 会 | 错误(40001) |
| 只读异常:读取者看到不可能的状态 | 会 | 会 | 错误(40001) |
- 任何级别都没有脏读。 Postgres 接受
read uncommitted,但给你的实际是读已提交。 - 读已提交每条语句取一个快照,可重复读每个事务取一个快照(快照会选定一个版本)。请求处理器需要前者。运行五条查询再把结果相加的报表,则需要后者,否则各项合计无法对上。
- 只有可串行化能捕获表格最后两行所说的异常,做法是让你重试。丢失更新和写偏差在读已提交会丢失在应用中计算的更新和可串行化能捕获可重复读漏掉的写偏差中演示。最后一种异常放在这里讲。
只读事务也可能看到不可能的状态
这是最让人意外的一种异常,因为受害者从头到尾没有写入任何东西。计费按周期运行。一行控制记录保存当前周期;费用都记录在这个周期上;月底有人通过递增周期号来关闭当前周期,报表则汇总已关闭的周期。这里有三个事务:
在可重复读级别下,一切都会成功。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
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 秒就已经提交的行也能存活。这个窗口确实存在,而且宽度正如文档所说。
所以,只在事务级别使用它,而且只用于可以丢失的写入。
深入阅读:杀掉容器不等于断电
重启日志说明了一切:
“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
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() 都是一次独立提交。若应用在两条语句之间崩溃:
sum(balance) 恰好减少了 30。30 个积分消失了,但没有任何数据库保证被破坏:每条语句都具备原子性、一致性、隔离性和持久性。出问题的是这个操作,因为没人告诉数据库它们属于同一件事。把它们包在 begin 和 commit 中,同样的崩溃会让全部操作回滚。
每个业务操作都要放进一个显式事务,并弄清你的 ORM 默认做什么。事务要保持短小:不要在其中执行 HTTP 调用,也不要在其中等待用户,因为打开的事务会持有锁,并且拖住清理。
动手验证 · lab 08-acid
两阶段提交很少是答案
当一个操作跨越两个数据库时,教科书式的答案是两阶段提交。Postgres 支持其中属于自己的一部分(prepare transaction,然后 commit prepared),但一个已准备事务不属于任何会话:如果协调器死亡,它会在重启后继续存在,持有锁,也拖住清理。这就是 max_prepared_transactions 默认值为 0 的原因。而消息代理、支付 API 或邮件服务根本不支持两阶段提交。
深入阅读:已准备事务能在崩溃后存活,它的锁也一样
SIGKILL 并重启后,transfer-42 仍然位于 pg_prepared_xacts 中,它插入的行仍然不可见,对第 1 行的更新仍然会等待它持有的锁。如果启用这一功能,就要对 pg_prepared_xacts 中存在超过一分钟的任何行设置告警(清理无法移除旧快照可能需要的内容)。
动手验证 · lab 08-acid.sh
把消息和订单放进同一个事务
这里有一个几乎每个应用都会踩到的情况。下订单会写入订单,并为其他服务发布一条“订单已下达”消息。因为涉及两个系统,无论先做哪一个,两个动作之间的崩溃都会让它们互相矛盾:订单没有任何人知道,或出现了一条关于不存在订单的消息。
解决办法是停止向两个系统写入。把消息写进一张表,并让它和订单处于同一个事务中,再由另一个进程负责投递:
现在,原子性同时覆盖了订单和消息:两者要么一起提交,要么都不提交。中继进程在同一个事务中,用for update skip locked认领一批消息,发布它们,再删除已经发送的消息。这个事务是“不要在事务中进行网络调用”这条原则唯一有意保留的例外:批次要小,并为中继角色设置 idle_in_transaction_session_timeout,这样卡住的代理就不会让事务一直打开。
如果中继在发布消息后、delete 提交前死亡,下一个中继会再次发送这些消息。这就是至少一次投递,代价是消费者要按消息 ID 去重。它总好过“消息有时会丢”。邮件和扣款也可以使用同样的形状:在事务中记录意图,提交后执行动作,并让动作可以安全地重复(重试整个事务)。
深入阅读:两个中继不会认领同一条消息
skip locked 让多个中继可以并行运行,而不会重复认领(Postgres 队列和业务写入一起提交)。实验中,两个中继同时各请求 5 条消息,会得到 10 条不同的消息。在非常高的吞吐量下,逻辑解码可以直接从 WAL 读取 outbox 插入,而不是轮询;但对大多数应用来说,轮询中继已经足够走很远。
动手验证 · lab 08-acid
本章要点
- 回滚什么也不会撤销:已中止事务的行版本会留在页上,直到清理。失败的语句仍然会写入它处理过的行和 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 页面中有本章改写的批量关账示例。