事务 入门
Handler 把我们的 curl https://api.example.com/user/42包进 BEGIN; SELECT user; UPDATE last_seen; COMMIT;。 这些关键字看着显然 —— 才不是。COMMIT在不同隔离级别、不同引擎、跨副本、跨服务,意思都不同。 4 个 section 把画面搭起来:ACID —— 每个字母实际保证什么、哪些旋钮在弱化它;隔离级别和它们挡住的异象, 带两个事务抢最后一件库存的交互走读;MVCC —— Postgres 怎么读不加读锁、vacuum 为啥存在;以及分布式事务 —— 2PC、Saga、Raft / Paxos、和你避不开的 CAP / PACELC 权衡。 最后是速查表。
ACID —— 每个字母实际保证什么
我们的请求变成了 BEGIN; SELECT user; UPDATE last_seen; COMMIT;。 4 个字母描述这些关键字理应兑现的契约。 没有一条是绝对的 —— 每个生产系统都在拿其中几项换吞吐、延迟、或成本。
ACID —— 原子性、一致性、隔离性、持久性 —— 是 Härder 和 Reuter 1983 年 提出的,用来描述事务该是什么。「事务」这个词没了它几乎什么也不是。
A —— 原子性
BEGIN 和 COMMIT 之间的每个语句要么全生效、要么都不生效。 没有「半个事务」这种东西。如果第二个 UPDATE 失败, 第一个被回滚 —— 即便它的一部分已经写进了 WAL 或数据页。
实现:引擎改任何东西前先写撤销信息(Postgres 保留旧行版本; MySQL/InnoDB 写到单独的 undo log)。ROLLBACK 时反向回放 undo。 Crash 恢复时,恢复代码靠重放 WAL(REDO)或应用 undo(UNDO)来收尾任何在途事务 —— Mohan 1992 年的 ARIES 算法是经典参考,大多数现代引擎是它的变种。
C —— 一致性
事务把数据库从一个有效状态搬到另一个,其中「有效」由你的应用说了算。 数据库只强制你声明过的东西:CHECK 约束、外键、UNIQUE、NOT NULL。应用不变式(「每个订单至少一个行项」、 「借方总额等于贷方总额」)是你的责任 —— ACID 的 C 是最弱的字母,也是最多人高估的字母。
关键:ACID 的 C 不是 CAP 的 C。ACID-C 关于约束满足; CAP-C 关于每个副本在同一时刻看到同一值。同一个字母、完全不同的概念。
I —— 隔离性
并发事务表现得像一个个串行执行。这是最贵的承诺,所以 SQL 给你个旋钮:READ UNCOMMITTED、READ COMMITTED、REPEATABLE READ、SERIALIZABLE。 Section 02 走读每个级别实际挡住什么,更重要的是,还允许什么。
默认几乎从来不是 SERIALIZABLE。Postgres 默认READ COMMITTED;MySQL InnoDB 默认 REPEATABLE READ; Oracle 默认 READ COMMITTED。 如果你的代码没显式要 SERIALIZABLE,它跑在比教科书定义的 「隔离」弱的某种东西上。
D —— 持久性
COMMIT 一返回成功,改动就扛任何 crash —— 除非物理介质丢了。 实现:把改动写到预写日志、调 fsync、然后返回。fsync 是承重 syscall —— 它强制把页挤出内核 page cache、 过 journal、到盘片(对大多数 SSD,经 FUA / cache-flush 命令也挤出驱动器的易失缓存)。
真实系统不断弱化 D 换吞吐。Postgres 有 synchronous_commit = off ——COMMIT 不等 fsync 就返回,吞吐涨 ~10×,代价是 crash 时丢最多 600 ms 的已提交事务。 MySQL 有 innodb_flush_log_at_trx_commit = 0|1|2,一样的权衡。 多数云托管数据库默认安全档;多数自管数据库迟早会被疲惫的 DBA 调下去。
4 个都是旋钮、不是布尔
旋钮 削弱 为啥要拧 ─────────────────────────────────── ───────── ───────────────────────────── fsync = off(Postgres) A + D 做 benchmark。生产绝不。 synchronous_commit = off D 批写;能容小量丢失。 innodb_flush_log_at_trx_commit = 0 D 同上,MySQL。 READ UNCOMMITTED I 热表上跑报表查询。 NOLOCK hint(SQL Server) I 同。 异步复制 D(多副本) 已 ack 但未复制 → 可能丢。 无 FK / CHECK 约束 C 据说应用层强制。
要点。「ACID 是一份契约: 原子性(全或无,靠 WAL + undo 实现)、 一致性(声明的约束在事务前后保持;应用不变式还是你的)、 隔离性(从 READ UNCOMMITTED 到 SERIALIZABLE 的旋钮;默认从来不是 SERIALIZABLE)、 持久性(已提交 = 扛 crash,靠 fsync 实现; 被 synchronous_commit=off、异步复制、和很多别的旋钮削弱)。 知道你的 DB 旋钮在哪档,就懂了一大半。」
隔离级别和它们挡住的异象
SQL 标准定义 4 个隔离级别。每个数据库选的默认都比 serializable 弱, 事务代码路径上线上的几乎每个 bug 都能追到所选级别仍允许的某个异象。
隔离是个旋钮,控制两个事务多大程度上能重叠才会互相影响。 越强 = 越少惊喜、越多阻塞和中止。越弱 = 吞吐越好, 事故频道里越多「等下,库存怎么是 -1?」。
4 个标准级别
级别 脏读 不可重复读 幻读 丢失 写偏序
更新
───────────────── ─────── ─────────────── ──────── ───────── ─────────
READ UNCOMMITTED 允许 允许 允许 允许 允许
READ COMMITTED 挡住 允许 允许 允许* 允许
REPEATABLE READ 挡住 挡住 视情况 挡住 允许
SERIALIZABLE 挡住 挡住 挡住 挡住 挡住看起来是 5 种不同的危害,定义一下。每个都是个具体的 bug 模式、不是黑话。
5 个异象、例子定义
- 脏读。T1 读到 T2 改过但没提交的行。T2 回滚; T1 看到了一个从来不存在过的值。
READ COMMITTED及以上挡。 - 不可重复读。T1 读 row 7,同一事务里晚些再读 row 7。 T2 中间改了。T1 对「同一」行得到两个不同值。
REPEATABLE READ及以上挡。 - 幻读。T1 跑
SELECT count(*) WHERE x>5两次。 T2 中间插了个匹配的行。T1 看到不同的 count。 标准里SERIALIZABLE挡;Postgres 的REPEATABLE READ(实际是 snapshot isolation)也挡。 - 丢失更新。T1 读 X=1、T2 读 X=1、都写 X=2。 其中一个被悄悄盖掉。这是应用代码里经典的 read-modify-write 竞态。 靠拿行锁(
SELECT ... FOR UPDATE)或在单个原子UPDATE里做事来防。 - 写偏序。T1 读 X 和 Y、依据 Y 的值决定改 X。 T2 读 X 和 Y、依据 X 的值决定改 Y。谁也没盖掉谁的写 —— 但联合后的状态破了两人都在检查的不变式。只有
SERIALIZABLE抓得到。
真实数据库实际怎么做
SQL 标准是指导;引擎各走各的:
- Postgres。默认
READ COMMITTED。 它的REPEATABLE READ实际是 snapshot isolation —— 第一条语句取一个快照,整个事务都看这个快照。 它的SERIALIZABLE是Serializable Snapshot Isolation(SSI):跑在 REPEATABLE READ 之上, 加一个谓词锁追踪器,中止任何一对形成序列化环的事务里的一个。乐观 —— 只付中止的代价。 - MySQL InnoDB。默认
REPEATABLE READ。 读用 MVCC,但REPEATABLE READ下还用 gap lock 防幻读。 这在 INSERT 重的工作负载下出了名易死锁。 - Oracle。默认
READ COMMITTED。它的SERIALIZABLE也是 snapshot isolation —— 名字不改、还允许写偏序。 (SSI 之前的 Postgres 也一样。) - SQL Server。默认
READ COMMITTED带悲观锁; 能切到READ_COMMITTED_SNAPSHOT走 MVCC 行为。
解决丢失更新的正确工具
应用代码里最常见的事务 bug 模式:
-- 坏:READ COMMITTED 下丢失更新 BEGIN; SELECT balance FROM accounts WHERE id = 42; -- 返 100 -- 应用算 balance - 30 = 70 UPDATE accounts SET balance = 70 WHERE id = 42; COMMIT; -- 好(选项 1):原子,不存在读-写竞态 BEGIN; UPDATE accounts SET balance = balance - 30 WHERE id = 42; COMMIT; -- 好(选项 2):行锁持到 commit BEGIN; SELECT balance FROM accounts WHERE id = 42 FOR UPDATE; -- 安全读;T2 在这阻塞 UPDATE accounts SET balance = 70 WHERE id = 42; COMMIT; -- 好(选项 3):SERIALIZABLE + 重试循环 SET TRANSACTION ISOLATION LEVEL SERIALIZABLE; BEGIN; SELECT balance FROM accounts WHERE id = 42; UPDATE accounts SET balance = 70 WHERE id = 42; COMMIT; -- 可能 40001 失败;应用重试
新值是旧值的纯函数时选项 1 最好。应用要做决定(例如「balance < 30 拒绝」)时选项 2 最好。 检查很多不变式不想手摆锁时选项 3 最好;代价是偶尔重试。
READ COMMITTED;MySQL InnoDB 默认 REPEATABLE READ; 没人默认 SERIALIZABLE,因为它最贵。下面这些 lost-update 和 write-skew bug 已经在购物车、值班排班、银行转账代码里上线过非常多次。为啥没人默认 SERIALIZABLE
成本。Postgres SSI 乐观,但每个读还加簿记;争用下,中止重试可能主导。 2 阶段锁 SERIALIZABLE(教科书原版)更糟 —— 长持锁和死锁。 多数工作负载能用 READ COMMITTED + 关键路径上的 SELECT FOR UPDATE活下来,CPU 和争用成本少一半。这是每个引擎都做的人体工学权衡。
要点。「5 个异象、4 个级别。脏读、不可重复读、幻读、丢失更新、写偏序 —— 各被不同级别挡。Postgres 默认 READ COMMITTED;MySQL InnoDB 默认 REPEATABLE READ; 没一个默认 SERIALIZABLE。应用代码里 SELECT-然后-UPDATE 模式在 READ COMMITTED 下丢更新 —— 用原子 UPDATE、SELECT FOR UPDATE、或 SERIALIZABLE + 重试修。 写偏序只有在真正的 serializable(Postgres SSI)下才死,几乎没人默认付这个钱。」
MVCC —— Postgres 怎么读不加读锁
多版本并发控制是让读和写互相忽略的把戏。 每行带着生它的和杀它的事务 ID;每个读者带个快照说哪些事务在它开始时算「已提交」。 读 = 查可见性,不涉及锁。
悲观锁(经典做法)简单:读拿共享锁,写拿排他锁。读者挡写者、写者挡读者。 能用,但争用下吞吐崩。
MVCC 说:绝不就地删或覆盖一行。UPDATE 意思是「建新版本, 老的标记死亡」。DELETE 意思是「把这行标记死亡」。 读者跳过不该看的死版本;写者不挡读者。代价是膨胀(旧版本堆积) 和一个后台进程(vacuum)清理它们。
xmin 和 xmax —— 你看不见的两个列
每个 Postgres 行有 2 个隐藏列:
xmin—— 创建这个行版本的事务 ID。xmax—— 删除或更新这个行版本的事务 ID(还活着就是 0)。
就这。可见性是这两个数对你快照的一个计算。
-- 自己看 SELECT xmin, xmax, * FROM users WHERE id = 42; xmin | xmax | id | name | last_seen -------+------+----+-------+----------- 50001 | 0 | 42 | alice | 2026-05-26 (1 row) -- 事务 50100 UPDATE last_seen 后: xmin | xmax | id | name | last_seen -------+-------+----+-------+----------- 50001 | 50100 | 42 | alice | 2026-05-26 ← 旧版本 50100 | 0 | 42 | alice | 2026-05-27 ← 新版本
快照和可见性规则
事务(或在 READ COMMITTED 下,一条单独语句)开始时, 引擎取一个快照:大致是(xmin, xmax, in_progress[]),捕获「xmin 之下都已提交; xmax 及之上还没开始;in_progress 是现在正在跑的」。
一个行版本对你事务可见当:
- 它的
xmin在你快照 xmin 之下且不在 in_progress 列表里(你开始前就已提交),并且 - 要么
xmax = 0(还活着),要么xmax在你快照 xmax 及之上 / 在 in_progress 列表里 / 属于一个中止的事务(「删除」对你还不算)。
结果:读涉及零锁。两个并发读者不互相等;写者不挡读者。 写者仍互相拿行级排他锁,防两个写者改同一行。
代价:膨胀和 vacuum
死行版本堆积。被频繁更新的表死行可以是活行的 10 倍 —— 每次顺序扫描读死字节,每次索引查询走过墓碑。这就是膨胀, 悄悄拖性能。
VACUUM 是 GC:扫表,找 xmax在最老的当前运行快照之下的行(「没人能再看到这行」),回收空间。autovacuum 自动跑;在写重的表上你通常得调它的触发器 (autovacuum_vacuum_scale_factor、autovacuum_naptime), 因为默认假设的工作负载比你的平静。
-- 找膨胀的表
SELECT schemaname, relname,
n_live_tup, n_dead_tup,
round(n_dead_tup * 100.0 / nullif(n_live_tup + n_dead_tup, 0), 1) AS pct_dead
FROM pg_stat_user_tables
ORDER BY n_dead_tup DESC LIMIT 10;
-- 手动清(短暂阻塞)
VACUUM (VERBOSE, ANALYZE) users;
-- 也回收磁盘(重写表;拿 ACCESS EXCLUSIVE 锁)
VACUUM FULL users;VACUUM FREEZE 和回环紧急
Postgres 事务 ID 是 32 位(可用)。到 ~20 亿事务时会回环。 如果一行的 xmin 来自事务 12345 而当前事务 ID 已经回环过了它, 突然「行是未来创建的」—— 可见性破。
修法是冻结:把老的 xmin 重写成 「永远对所有人可见」的特殊标记。VACUUM 在行足够老时自动这么做 (vacuum_freeze_min_age)。如果一个写重的数据库冻结得不够快, Postgres 最终拒绝接受新写入以保护数据完整性 —— 这就是著名的事务回环紧急,放倒过大网站(Sentry、Mailchimp 等)。 监控 datfrozenxid、别关 autovacuum。
MVCC vs 其他引擎
引擎 怎么做 代价 ───────────── ──────────────────────────────────────────── ───────────── Postgres xmin/xmax 在 tuple 里;旧版本在表里 膨胀、vacuum MySQL InnoDB 旧版本在单独的 undo log Undo 增长 Oracle 旧版本在 UNDO 表空间 UNDO 管理 SQL Server 旧版本在 tempdb 里(snapshot 模式) Tempdb 增长
每家想法一样、死字节存的位置不同。Postgres 把它们放在表里的做法, 正是为啥「膨胀」是 Postgres 特定的术语 —— 其他引擎有同样的问题, 但字节住在别处。
为啥 snapshot isolation 允许写偏序
两个事务都在 BEGIN 取快照;都看到同一世界。 都改不同的行。任一行都没写-写冲突,所以 MVCC 的可见性检查抓不到。 环只在你追踪每个事务读了哪些谓词时才可见 —— 这就是 Postgres SSI 在 snapshot isolation 之上加的。SSI 维护一个读写依赖图, 中止任何环中的一个事务。
要点。「MVCC:每行有 xmin/xmax、每个事务取快照、 可见性是个计算 —— 不加读锁。代价是死行堆积(膨胀)、靠 VACUUM 清理; 欠 vacuum 的写重表会变慢,在 Postgres 上最终撞事务 ID 回环紧急。 Snapshot isolation 允许写偏序,因为没单行有写-写冲突; Postgres SSI 在上面加谓词锁环检测来抓它。」
分布式事务 —— 2PC、Saga、Raft、和 CAP 的岔路口
单节点 ACID 基本是个解决问题。事务一旦跨两个数据库、两个服务、或两个区域, 你依赖的每条保证都重新摆上桌 —— 而教科书答案(2PC)是有经验的系统人告诉你永远别用的那个。
我们的 curl 打到一个 API 服务,后者跟一个数据库讲话。 真实系统没那么幸运。这个 user-update 事务还得:经支付服务扣卡、 经邮件服务发确认邮件、给分析数据库写审计行、把 user 行复制到另一区域的读副本。 现在「commit」意思是跨 5 个参与者,不是 1 个。
2PC —— 会卡住的协议
两阶段提交是经典答案:
- 阶段 1(PREPARE)。协调者问每个参与者:「能 commit 吗?」 每个参与者把改动持久写到自己的 WAL 但不 commit;锁住受影响的行; 回「能」或「不能」。
- 阶段 2(COMMIT 或 ABORT)。都说能,协调者持久记录「commit」、 告诉所有人 commit。任一个说不能(或超时),协调者说 abort。
参与者的问题:投票同意之后、收到协调者决定之前,它处于悬而未决状态 —— 持着锁,既不能 commit、也不能 abort。如果协调者就在这时挂了,参与者永远等下去。 想要那些行的其他事务堆起来。生产 grind 住。
XA(X/Open 分布式事务标准)是穿燕尾服的 2PC。 Java EE 内置支持;Postgres 有 PREPARE TRANSACTION。 几乎没有新建系统再用 XA 做跨服务事务,因为卡住行为运维上很糟, 每次 commit 两次 round-trip 的延迟在负载下崩。
Saga —— 现代答案
Saga 把长跑的跨服务事务建模成一系列本地事务,每个都配一个补偿动作:
步骤 1:扣卡 补偿:退款 步骤 2:预留库存 补偿:释放库存 步骤 3:发确认 补偿:发取消 步骤 4:写审计行 补偿:写审计反转行 如果步骤 3 失败,逆序跑步骤 2 和 1 的补偿。 每步本地 ACID;Saga 整体最终一致。
没分布式锁、没悬而未决、没协调者卡住。代价:补偿得你写、 每步必须幂等(会重试)、整件事从外部观察者视角看不是原子的 —— 有个窗口是卡已扣但库存还没预留。
2 种风味:编排(orchestration)(中心 saga 执行器按序调每步 —— 容易理解、容易调试、单点故障)和协奏(choreography)(每个服务发事件、其他反应 —— 耦合更松、整体画面更难看清、事件循环里更容易死锁)。 多数团队从编排起步且不后悔。
Raft 和 Paxos —— 共识核心
Saga 关于跨服务工作流。共识协议关于跨副本状态。 同一问题家族、完全不同的层。
Raft(Ongaro & Ousterhout 2014)是今天的首选;Paxos(Lamport 1989)是它等价的更老形式化。 都跨 N 节点复制一个 append-only 日志:
- 一个节点是leader,多数投票选出。
- leader 收写,append 到自己本地 log,把 entry 复制给 follower。
- 一个 entry 被多数节点(quorum)持久 ack 后才算已提交。
- 每个副本上的状态机按 log 顺序应用已提交 entry。
- leader 挂了,follower 检测到超时、选新 leader。
扛< N/2 故障:3 节点扛 1、5 节点扛 2、7 节点扛 3。 代价是延迟 —— 每次写都需要一个 quorum round-trip。 etcd、Consul、CockroachDB、TiKV、Kafka 较新的 KRaft 模式都是 Raft。 Spanner 用 Paxos。ZooKeeper 用 Zab(Paxos 变种)。
CAP 和 PACELC —— 避不开的岔路
CAP 定理(Brewer 2000;Gilbert & Lynch 2002): 存在网络分区时,你必须在一致性(每个副本返回同一值) 和可用性(每个副本响应)之间选。分区期间不能两个都要。 分区愈合后,可以两个都要。
实践里,几乎每个系统大体表现得像 CP —— 分区期间宁愿停接写, 也不冒副本分歧的险。像 Cassandra 这种「AP」系统到处接写、 靠 last-write-wins 或 CRDT 之后调和;代价是两个读者在调和前可能看到不同值。
PACELC(Abadi 2012)扩了它:「如果分区,Availability vs Consistency; 否则,Latency vs Consistency」。没分区时你也在权衡 —— 每个想对每个读者可见的写,要么打 leader、要么等同步复制。 异步复制的读副本可能提供旧数据。
线性化(Linearizability)vs 可串行化(Serializability)
这两个词长得像、意思不同、几乎人人混淆:
- 可串行化关于事务:一个调度可串行化, 当它的效果等价于这些事务的某种串行顺序。时间不重要 —— 只顺序重要。
- 线性化关于分布式系统里的操作: 操作看起来在它的开始和结束之间的某一时刻一次性生效,且那时刻尊重实时顺序。 操作 A 完成后,每个后续操作 B 看到 A 的效果。
你可以只有一个不有另一个。Postgres SSI 可串行化,但单节点 Postgres 在任何分布式意义上都不是「线性化」—— 只有一个节点。 Google Spanner 两个都是:Paxos 跨副本线性化,加里面的 SSI; 用 TrueTime(GPS + 原子钟)给每个 commit 一个尊重跨星球实时序的时间戳。 这叫外部一致性,是金标准。
这对你代码意味着什么
跨服务工作流必须为你实际有的网络设计、不是你希望有的:
- 幂等 key。每个跨服务请求带 ID;接收方去重。重试变安全。
- 补偿动作。不能原子地 commit 全部,就得能撤回已经做过的。 补偿是一等业务逻辑 —— 跟正向路径同时写。
- 读自己的写一致性。用户写完立刻读, 把读路由到 leader 或绑定到一个知道他们最后写时间戳的会话上。 否则他们看旧数据然后怪你。
- 绝不在外部调用上跨越 DB 事务。你的行锁变成网络 round-trip; 一个慢的下游服务能卡住数据库。
要点。「2PC 纸上能用、生产卡住 —— 协调者故障让参与者无限期上锁。 Saga(本地事务 + 补偿 + 幂等)是现代跨服务答案。 Raft/Paxos 靠 leader + quorum 解副本状态,扛 < N/2 故障。 CAP 在分区时逼你选;PACELC 指出你没分区时也在拿延迟换一致性。 线性化关于跨系统的实时操作顺序;可串行化关于事务顺序 —— 两者正交,Spanner 靠 TrueTime 两个都做到。」
速查表
6 道值得能冷讲清楚的核心问题、5 个 code review 时一眼看出来的红旗。
走一遍 ACID —— 每个字母实际保证什么?
原子性:BEGIN 和 COMMIT 间的所有语句要么全生效、要么都不 —— 靠 WAL + undo 实现,恢复时回放(ARIES)。一致性:声明的约束(CHECK、FK、UNIQUE)在事务前后保持; 应用不变式还是你的问题。隔离性:并发事务表现得像串行 —— 从 READ UNCOMMITTED 到 SERIALIZABLE 的旋钮,默认从来不是 SERIALIZABLE。持久性:COMMIT 扛 crash —— 靠 fsync 实现, 被 synchronous_commit=off 和异步复制削弱。
REPEATABLE READ 在 Postgres 里 vs 在 SQL 标准里有何不同?
SQL 标准只要求 REPEATABLE READ 挡脏读和不可重复读 —— 幻读还允许。 Postgres 的 REPEATABLE READ 实际是 snapshot isolation: 第一条语句取一个快照,每个读都从它来,所以幻读也挡了。 这个命名是历史遗留、把所有人都搞糊涂。重要:snapshot isolation 仍允许写偏序 —— 只有 Postgres SERIALIZABLE(在上面加 SSI)抓得到。
举例说明写偏序、怎么防?
两个值班医生,不变式「至少一个在线」。T1 读 count = 2、判断可下线、 UPDATE Alice 下线。T2 读 count = 2(它自己的快照)、判断可下线、 UPDATE Bob 下线。都提交;现在零人在线。谁也没盖掉谁 —— MVCC 可见性没冲突。 防法:决策所读的行上 SELECT FOR UPDATE、或用 SERIALIZABLE (Postgres SSI 经谓词锁环检测抓到)。
MVCC 怎么让 Postgres 避开读锁、代价是什么?
每行有隐藏列 xmin(创建事务 ID)和 xmax(删除事务 ID)。 每个事务取一个快照:(xmin, xmax, in_progress[])。 一行可见,当它的 xmin 在你之前已提交、且它的 xmax 要么是零、要么在你快照之后、要么已中止。 读永不挡写;写永不挡读。代价:死行版本堆积(「膨胀」)、靠 VACUUM 清理。 欠 vacuum 的写重表会变慢;Postgres 上,搁置够久撞事务 ID 回环紧急,DB 拒写。
为啥 2PC 在现代分布式系统里被认为危险?
阻塞型故障:阶段 1 投「同意」之后、阶段 2 听到 commit/abort 决定之前, 每个参与者都处于悬而未决状态 —— 持着行锁、推进不了。 协调者就在那时挂了,参与者无限等;碰那些行的其他事务堆起来;吞吐归零。 再加上每次 commit 的 2 round-trip 延迟、和恢复卡住事务的运维痛 —— 于是换成 saga:每服务里的本地事务加补偿动作、每步幂等重试。
线性化(Linearizability)和可串行化(Serializability)的区别?
两者正交。可串行化关于事务:一个调度可串行化, 当它的效果等价于事务的某种串行顺序 —— 时间不入场。线性化关于分布式系统里的操作:每个操作在它调用和返回之间的某点原子地生效、 且那点尊重实时顺序。单节点 Postgres 可以可串行化但在任何分布式意义上不「线性化」。 Google Spanner 两个都做到(叫外部一致性), 靠 Paxos 复制加 TrueTime(GPS + 原子钟)给全球有序的 commit 时间戳。
Code review 红旗
- 应用代码里 SELECT 然后 UPDATE,不加 SELECT FOR UPDATE 也不用原子 UPDATE。READ COMMITTED 下经典丢失更新。用
UPDATE accounts SET balance = balance - 30, 或显式拿行锁。 - 为「性能」设 READ UNCOMMITTED,不测量。吞吐收益通常是想象;脏读 bug 不是。 真要给报表查询拿旧数据,在 code review 里把它命名为这样。
- Saga 没实现补偿动作。正向路径上线、补偿是「TODO」。 第一次部分失败事件三个服务同时数据腐蚀,on-call 没有可撤的东西。
- 跨服务事务用 2PC / XA。现代微服务该是 saga + 幂等步骤。 XA 带来协调者卡住、2 round-trip 延迟、运维噩梦, 换来你通常不需要的保证。
- 长跑事务跨外部 HTTP 调用持锁。你的行锁现在跟一个第三方的 p99 延迟一样长。 一个慢的下游服务能搞挂数据库 —— 看起来「DB 慢」实际是 「DB 在等别的东西」的事故常见根因。