事务 入门
COMMIT 在不同隔离级别、不同引擎、跨过网络,意思都不同。 30 张图、3 个主张:ACID 的每个字母都是旋钮, 而且早有人替你拧低了;你撞上哪种异象,由你数据库出厂的级别决定;以及跨过网络,整份契约要用往返重新谈。
COMMIT 到底承诺了什么
4 个字母本该说清 BEGIN 和 COMMIT 买到了什么。这 4 个没有一个是承诺。每个都是旋钮,而每个数据库出厂时都拧低了其中几个。
原子性和持久性是同一套机制,一起看。碰任何数据页之前,引擎先把描述这次改动的记录追加到预写日志。日志就是事务本身;数据页只是它的缓存,什么时候写都行。
下面两个事务交错在一条日志上。正在追加的那条记录就是时钟停的地方;一旦 T1 的 COMMIT 记录落盘,它身后 T1 的一切都持久了。播一遍:
注意数据页始终没有落盘 —— 它只在内存里变。持久性只在 COMMIT 记录那一刻到来,别处都不 —— 一次顺序 fsync 让整个事务活下来,这就是日志只追加、数据页不必的原因。T2 在同一条日志上有两条记录、没有 commit 记录,所以对恢复来说 T2 从没发生过。
这就是恢复依赖的不变式:一个事务生效,当且仅当它的 commit 记录在盘上。把崩溃标记沿日志往回拖,看重做集和撤销集重新洗牌:
在处,T1 的两次更新在盘上、它的 COMMIT 不在,所以幸存的 3 次更新全被撤销 —— 客户端从没收到确认,它认为什么都没发生是对的。,同样这两次更新改为重做。ARIES(Mohan,1992)就是这条规则,加上让恢复自己崩了还能重启的记账。
持久性的价钱是每次提交一次 fsync,而这次 fsync要够到哪里,决定了天花板。下面每一级台阶都是上一级再加一个字节必须抵达的地方,已经付过的台阶保持点亮。走下去:
值得背下来的是到:本地 NVMe 上一次 fsync 是 50 µs, 网络盘上是 700 µs,而更便宜的那几级已经含在里面了。同样的代码、同样的查询,单连接能提交的量差 14 倍 —— 每秒 20,000 对 1,429。那一级往上都不是数据库问题,是存储问题,SQL 怎么调都调不掉。
于是有人去拧旋钮。synchronous_commit = off 让COMMIT 在刷盘前就返回,而 PostgreSQL 最多晚 3 个wal_writer_delay 周期刷 —— 默认就是 600 ms。把提交速率拉高,看这个窗口被填满:
在时,窗口里装着3,000 个应用被告知「已提交」的事务。它们悄悄地失败:没有报错、没有回滚、日志里没有一行 —— 重启后那些行就是不在。这和崩溃是两种失败,而它是会传到客户那里的那一种。
C 是最被高估的字母。数据库只强制你声明过的东西—— CHECK、外键、UNIQUE、NOT NULL—— 别的一概不管。把转账拉过 120 让声明过的约束开火,再切成只写一条腿:
两种失败长得完全不一样。CHECK会拒掉这条语句:一个错误码、一个回滚的事务、一段总有人会看的栈。被打破的和什么都不抛,因为a + b = 200 从没对谁声明过。ACID 的 C 是你的不变式在事务前后都成立;引擎只帮你管你写下来的那些。
于是 4 个字母凑齐了。下面每个旋钮都在拿其中一个换吞吐、延迟或省事。走一遍旋钮,看每个拧低了哪个字母:
其中两个是默认值而不是决定。PostgreSQL 出厂在 READ COMMITTED, MySQL InnoDB 在 REPEATABLE READ, 所以只要你的代码没显式要更强的,你 ACID 里的 I已经被拧低了 —— 而且和持久性那些旋钮不同,没人是故意拧的。下一节讲的就是这个旋钮。
5 种异象,以及各自死在哪一级
隔离性是那个旋钮:它规定两个并发事务能看见对方多少。有 5 样东西会出错;每一样都是具体的 bug,每一样死在不同的档位上。
5 个的形状是一样的:两个事务、一个时钟、一样本不该被看见的东西。本节每张图都是这幅画 —— 两条泳道装着各自发出的操作,再加一个你用图下轨道走的时钟。时钟身后,已经发生的保持点亮。
先看最便宜的。T1 读了一行,而T2 写过它但没提交,随后 T2 回滚。按播放走一遍:
注意 T1 读到的不是过期值。它读到的是一个从未存在过的值,数据库任何已提交状态里都没有 —— 不管往前还是往后,都不存在某个时刻它据以行动的那个答案是真的。READ UNCOMMITTED 之上的所有级别都挡住它,而 PostgreSQL 连你要 READ UNCOMMITTED 时也挡住,因为它压根没这个级别,会不声不响给你 READ COMMITTED。
READ COMMITTED 买到的正好只有这个。每条语句拿一个新快照,所以一个事务里同一行读两次, 可能拿回两个不同的值:
注意什么都不会报错。T1 只是拿同一行两个互相矛盾的读数在算 —— 报表页脚跟自己的明细对不上就是这么来的,余额校验通过了但余额早就动了也是这么来的。REPEATABLE READ 的修法是整个事务取一个快照,而不是每条语句一个。
同一个问题还有第二种形状,标准把它算作另一种危害:不是某行变了,而是某行凭空出现在T1 已经数过的范围里。走一遍时钟:
标准的 REPEATABLE READ 放过幻读, 因为读锁只能加在已经存在的行上。快照没有这个限制 —— 所以 PostgreSQL 的 REPEATABLE READ(其实是快照隔离)挡住了它名字说自己允许的幻读。 InnoDB 走的是另一条路到同一个地方:在索引区间上加间隙锁。
接下来是真正会上线的那个,而且它住在应用代码里、不在 SQL 里。两个事务读同一个余额,各自在读到的数上减,再各自把结果写回去:
两次提交都成功,两个调用方都被告知取款完成了。这一行留着 90,本该是 60, 而少掉的 30 在任何地方都没报过错。这就是更新丢失 —— 应用代码里最常见的事务 bug, 也最容易由「读出对象、改一个字段、保存」的 ORM 写法引进来。
最后一个微妙到能完整地穿过快照隔离。两个事务读同样的两行,各自断定自己那次写是安全的,然后各写各的那一行:
因为它们写的是不同的行,没有写-写冲突可供 MVCC 抓:每次写单看都合法,不变式只被这一对合起来打破。要看见这个,得跟踪每个事务读了什么,而不是写了什么 —— 这正是可串行化快照隔离加在上面的东西,也正是更弱的级别做不到的。
5 种异象、4 个级别、3 个对级别含义各执一词的引擎。走一遍级别,再在下面换引擎:
分歧才是承重的部分。在 上,PostgreSQL 不是挡住更新丢失,而是拿 SQLSTATE 40001 把你中止掉、指望你有重试循环; InnoDB 在同一级别、同一种异象上,默默放这次写过去,因为加锁读取的是最新已提交行、不是快照。一个大声失败、一个悄悄失败,而应用代码一模一样。
所以修法是选写法、不是选级别。下面 4 种都是从同一个 100里取走 30 和 10,同一个引擎、你手上已有的那一级;只有第一种会弄丢其中一笔。
-- 1 先读再写,余额 → 90 T1: SELECT bal FROM acct WHERE id = 1; -- 100 T2: SELECT bal FROM acct WHERE id = 1; -- 100 T1: UPDATE acct SET bal = 70 WHERE id = 1; T2: UPDATE acct SET bal = 90 WHERE id = 1; -- 2 原子 UPDATE,余额 → 60 T1: UPDATE acct SET bal = bal - 30 WHERE id = 1; T2: UPDATE acct SET bal = bal - 10 WHERE id = 1; -- 3 FOR UPDATE,余额 → 60 T1: SELECT bal FROM acct WHERE id = 1 FOR UPDATE; T2: SELECT bal FROM acct WHERE id = 1 FOR UPDATE; -- T2 在这里等 T1 提交,然后读到 70 -- 4 SERIALIZABLE,余额 → 60 BEGIN ISOLATION LEVEL SERIALIZABLE; SELECT bal FROM acct WHERE id = 1; UPDATE acct SET bal = 70 WHERE id = 1; COMMIT; -- 输家抛 40001,整段重试
差别在于减法在哪里做。写法 1在应用里做,再把一个字面量发过去;另外 3 种要么把减法本身发过去、要么让第二个读者等、要么让第二个提交者失败。切换它们,读每种留下的余额:
只有第一种是错的,而且错在最容易通过评审的地方:它读出来、在应用里算、再写一个字面量回去。另外 3 种都不让两个事务从同一个起始值开始算 —— 一种压根不读,一种把行锁握到提交,一种把输家中止掉重来。
最后这种不是白来的。如果一次提交以概率 p 冲突,尝试次数服从几何分布,落地一次提交要花 1/(1−p) 次。把冲突概率拉高:
在 时,每次提交要 2 次尝试、一半有效的活白干;到 ,10 次尝试、90% 白干。 Ports 和 Grittner 在一个读多的基准上测到 PostgreSQL 的 SSI 与纯快照隔离只差几个百分点 —— 开销不在记账,在中止,而中止率是你负载的属性、不是引擎的。
一次读取怎么做到不加锁
悲观锁很简单,而且在竞争下会塌:读者挡住写者、写者挡住读者。 MVCC 把锁换成了算术。
规则是:永不覆盖一行。UPDATE 造一个新版本、把旧的标记为死;DELETE 只做标记。于是每个版本带两个隐藏列 ——xmin,造它的事务;xmax,杀它的事务,还活着时是 0。
画在事务 id 轴上,一个版本就是一段区间:从它的 xmin 活到xmax。你的快照是一条竖线,它穿过的那个版本就是你读到的。拖着它横穿过去:
注意这活有多轻。没有锁、没有等待、不用和任何写者协调 —— 一次读就是每个版本两次整数比较,两个在不同快照上读同一行的事务落在不同的版本上,谁也不知道对方存在。
这两次比较就是全部规则,而且能写成一条循环断言:一个版本可见,当造它的事务在你的快照之前提交且杀它的没有。移动快照,再改一下写它那个事务干了什么:
把写者设成还在跑,即便 xmin 在快照之下,这个版本也消失:算数的是提交、不是写。设成已中止, 它就永久消失 —— 回滚过的事务就是这样在盘上留下任何快照(现在的、未来的)都无权看见的版本。
永远不会有人看见它们,而它们还在那儿。这就是 MVCC 的账单:每次更新一个死版本, 和那个活的挤在同一个 8 KiB 页里。把更新次数拉高、把页填满:
因为顺序扫描每一个都要读,这笔账落在每个查询上,不只是写。在时,表里 1 个活行、27 个死版本, 扫描为同一个答案付了 28 倍的字节。这就是膨胀:它持续拖慢吞吐、从不报错,所以是被延迟曲线发现的、不是被日志发现的。
VACUUM 是回收器,它有一条不能破的规矩:一个版本只有在没有任何在跑的快照还可能要它时才能走。把水平线往回拖,把可回收集清空:
因为水平线是最老那个还在跑的快照,一个连接卡在 idle in transaction 就替整个集群钉住了它身后的每个死版本。 autovacuum 照跑,发现没有它被允许释放的东西,表还是照长。这就是 idle_in_transaction_session_timeout 存在的原因,也是「忘了关事务的应用」属于存储事故、而不是连接泄漏的原因。
还有第二个时钟,而且更难。事务 id 是 32 位,每个只能看见它身后 231 个 id,所以可用空间是有限的。把它花掉:
看最后两条门槛是怎么一起到的。PostgreSQL 在autovacuum_freeze_max_age(默认 2 亿)强制 autovacuum,在时开始告警,剩 300 万时干脆停机,也不让它绕回去。每秒 5,000 个写事务的话,4,000 万个 id 就是 2.2 小时:告警窗口是一次交班,不是一个迭代。
每个 MVCC 引擎都欠同一笔账,区别只在死版本存哪儿 —— 挨着还是离开活行。走一遍这 4 个:
PostgreSQL 把它们留在表里,所以「膨胀」是 PostgreSQL 的词汇。 InnoDB 和 Oracle 放在 undo log 里,症状就变成 undo 空间涨大,以及长读者跑输它时 Oracle 的ORA-01555 snapshot too old。SQL Server 放进tempdb。机制处处相同,把它压垮的东西也处处相同:一个开太久的读者。
当事务跨过一张网络
单机 ACID 基本是个已解决的问题。事务一旦跨两个数据库、两个服务、或两个 region,你依赖的每条保证都重新摆上桌 —— 而教科书答案正是运维告诉你千万别上线的那个。
我们这个请求要扣款、锁库存、发邮件、写审计行。4 个系统、一次「提交」。一个协调者先问所有人、再告诉所有人,也就是两个网络往返换掉一次fsync。
两个阶段:PREPARE,每个参与者把自己的改动持久化并投票;然后决定发回去。播一遍:
注意投一张赞成票要付什么。投过票的参与者已经把改动写进自己的日志、拿齐了所有需要的锁,而它既不能自己提交也不能自己中止 —— 它在等一条消息。在 PostgreSQL 里这个状态叫 prepared transaction, 它除了钉住那些行,还钉住 §03 里那条 vacuum 水平线。
这个窗口就是全部反对意见。把协调者的崩溃沿协议拖动,找出参与者自己救不了自己的那段区间:
还没人投票之前超时是安全的:什么都没持久,大家一起中止。决定持久之后,恢复重放它就行。在和之间,没有任何单方面的安全选择, 所以参与者一直握着锁直到有人干预 —— 而所有想要那些行的事务都排在它们后面。
于是现代系统放弃跨服务的原子性,用补偿把它买回来:每一步是本地事务,每一步都有逆操作。选哪一步失败:
没有分布式锁、没有存疑状态 —— 也没有原子性。在扣款和它的补偿之间,真真切切有一段时间,外部世界能看到一个付了钱什么都没买到的客户。 Saga 不消除这个窗口;它把窗口弄短、并把它变成你的责任,而补偿是业务逻辑,得和正向路径同时写出来。
它还让每一步都可重试,也就意味着每一步都必须幂等。重试这次扣款,分别带和不带接收方能去重的键:
没有这个键,超时后的重试和一个新请求长得一模一样,客户每试一次就被扣一次。这个键不是优化 —— 它是让「至少一次」投递可以拿来盖东西的前提,而且它属于 API 的第 1 版,因为后加就意味着要对账那些没带键时扣掉的钱。
Saga 协调服务;复制协调副本,那里的答案是多数派。选组的大小和挂掉几个:
法定人数是 ⌊n/2⌋+1,所以容忍挂 2 个、容忍挂 3 个。偶数买不到东西:6 个节点也只容忍 2 个,却每次写都多付一个副本。这就是野外集群规模都是奇数的全部原因。
代价是延迟,而决定它的是地理、不是代码。移动远端副本, 从第二近的那个上读出提交延迟:
注意一个近的跟随者不构成法定人数 —— leader 还需要第二个赞成,所以它付的是第二近的那个往返,法定人数之外的那些一分钱不花。当远端副本时,每次提交要 70 ms、单个串行写者的上限是每秒 14 次提交,全程没有任何磁盘参与。Spanner 付掉这笔,还加上约 2ε (大概 10 ms)的 commit wait,好让它的时间戳全球有序。
而网络裂开时,多数派规则替你做决定。把分区拖过整个组,再切换少数派那侧被允许做什么:
这就是 CAP,而且它只是关于少数派那一侧的选择:在 时一侧还能提交,另一侧必须在拒绝写入和分叉之间选。PACELC 补上大家忘掉的那半 —— 否则,在完全没有分区时,你每允许一次读走异步副本,仍然在拿延迟换一致性。这两笔交易通常都在配置文件里做掉,做的人当时在优化 p99。
速查
3 个值得张口就答的问题,以及评审里要盯的 5 样东西。
可串行化和线性一致有什么区别?
它们是正交的。把调度摆着不动、换问的是哪一个,读两个判定:
可串行化是事务的性质:整个调度对得上某个串行顺序,定义里没有时间。线性一致是单个操作的性质:每个操作在调用和返回之间某一点生效,而这些点尊重真实时间。过期副本是可串行化而非线性一致;写偏斜两个都不是。 Spanner 两个都买 —— 副本间 Paxos、副本内 SSI、TrueTime 给提交排序 —— 并把结果叫外部一致性。
这段代码该用哪个隔离级别?
几乎从来不是答案。决定它的是代码在干什么, 下面 4 种形状里有 3 种根本不用改级别:
只有最后一种 —— 跨行、又没人能逐行加锁的不变式 —— 才需要SERIALIZABLE,而且得配重试循环。
- 先
SELECT再UPDATE,不加FOR UPDATE。 更新丢失:InnoDB 上无声无息,PostgreSQL 上是 40001。 - 跨一次 HTTP 调用还开着的事务。 行锁跟第三方 p99 一样长,快照钉住 vacuum 水平线。
- 补偿写着 TODO 的 saga。 第一次部分失败就让 3 个服务不一致,而手上没有可撤销的东西。
- 跑在 XA 上的跨服务事务。 协调者挂一次,每个参与者都握着锁卡住。
- 为「性能」设的
READ UNCOMMITTED。 在 PostgreSQL 上它什么都不做。
那一次提交端到端要多少钱?
取决于你让它跑多远,而下图把这一页讲的两半加了起来 —— §01 的磁盘和 §04 的法定人数: