数据库基础体系 · 第 7/139 篇。文章以各产品官方稳定版本的公开语义为准;示例会明确引擎、事务与部署边界。

数据库事务完整指南:ACID、隔离级别、异常现象与正确边界

所属系列:数据库基础体系
所属模块:一、数据库共同原理
标签:数据库、事务、ACID、后端
版本与范围:以 PostgreSQL 当前稳定版本和 MySQL 8.4 官方公开语义为准

事务不是“把几条 SQL 放在一起执行”这么简单。它同时规定了:

  • 哪些修改属于同一个工作单元;
  • 并发事务之间可以看到什么;
  • 出错时哪些结果必须保留、哪些必须撤销;
  • 提交成功到底意味着什么;
  • 数据库边界之外的邮件、消息、缓存和远程调用是否也被纳入一致性保证。

理解事务,不能只背 ACID 四个字母。真正需要掌握的是:事务状态如何变化、并发调度可能产生什么结果、数据库通过什么机制限制这些结果,以及这些保证在哪个边界上失效。


一、事务究竟是什么

事务(transaction)是一组由数据库管理系统作为一个逻辑工作单元处理的操作。典型生命周期如下:

未开始
  │ BEGIN
  ▼
进行中 ───── ROLLBACK ───► 已回滚
  │
  │ COMMIT
  ▼
提交中 ───── 持久化失败 ─► 提交失败或连接异常
  │
  ▼
已提交

应用通常使用以下接口:

BEGIN;

UPDATE accounts
SET balance = balance - 100
WHERE id = 1;

UPDATE accounts
SET balance = balance + 100
WHERE id = 2;

COMMIT;

这里有两个重要前提:

  1. 两条 UPDATE 必须在同一个数据库连接和同一个事务上下文中执行。
  2. COMMIT 返回成功后,应用才可以把这组修改视为数据库已经接受。

如果两条 SQL 分别使用两个连接,即使它们在代码中紧挨着,也不是同一个事务。

1. 自动提交改变了事务边界

许多客户端默认开启自动提交(autocommit)。在这种模式下,一条独立的 INSERTUPDATEDELETE 通常就是一个事务:

执行一条语句 → 语句完成 → 自动提交

因此下面的代码不一定是一个事务:

connection.setAutoCommit(true);
execute("UPDATE accounts SET balance = balance - 100 WHERE id = 1");
execute("UPDATE accounts SET balance = balance + 100 WHERE id = 2");

如果第一条成功、第二条失败,第一条可能已经提交,转账就被拆开了。

正确的边界应当显式表达:

connection.setAutoCommit(false);
try {
    execute("UPDATE accounts SET balance = balance - 100 WHERE id = 1");
    execute("UPDATE accounts SET balance = balance + 100 WHERE id = 2");
    connection.commit();
} catch (Exception e) {
    connection.rollback();
    throw e;
} finally {
    connection.setAutoCommit(true);
}

连接池场景尤其要注意:连接归还池前必须回滚未结束事务,并恢复隔离级别、只读状态等会话属性,否则下一个请求可能继承上一个请求的事务状态。


二、ACID:四个性质分别保证什么

ACID 是四类性质的缩写:

  • Atomicity:原子性
  • Consistency:一致性
  • Isolation:隔离性
  • Durability:持久性

它们不是四种互相独立的“开关”,而是描述事务执行结果的不同方面。


三、原子性:事务要么全部生效,要么全部撤销

1. 原子性的定义

对于一个事务 TT,原子性要求:

T{全部生效, 全部不生效}T \in \{\text{全部生效},\ \text{全部不生效}\}

假设转账事务包含两个写操作:

T={A1:balance1100, A2:balance2+100}T = \{A_1: balance_1 - 100,\ A_2: balance_2 + 100\}

原子性要求最终状态只能是:

(balance1100, balance2+100)(balance_1 - 100,\ balance_2 + 100)

或者保持原状态:

(balance1, balance2)(balance_1,\ balance_2)

不能只执行其中一个。

2. 原子性不等于每条 SQL 都成功

事务内部可能先成功执行多条语句,最后一条失败:

BEGIN;

UPDATE accounts
SET balance = balance - 100
WHERE id = 1;

-- 假设这里因余额约束或死锁失败
UPDATE accounts
SET balance = balance + 100
WHERE id = 2;

正确处理通常是回滚整个事务:

ROLLBACK;

如果应用捕获异常后继续在同一个事务中执行,结果取决于数据库:

  • PostgreSQL 中,普通 SQL 错误会使当前事务进入 aborted 状态,后续 SQL 通常只能执行 ROLLBACK 或保存点相关操作;
  • MySQL 是否自动回滚整个事务取决于错误类型;很多语句错误只回滚当前语句,事务仍可能继续,因此应用不能假设“报错后数据库一定自动回滚”。

3. 保存点是事务内部的局部回滚

保存点(savepoint)允许回滚事务的一部分:

BEGIN;

INSERT INTO orders(id, user_id) VALUES (1001, 7);

SAVEPOINT before_optional_item;

INSERT INTO order_items(order_id, product_id)
VALUES (1001, 9999);

-- 如果可选商品不存在:
ROLLBACK TO SAVEPOINT before_optional_item;

-- 订单主体仍然可以继续处理
COMMIT;

ROLLBACK TO SAVEPOINT 不会结束整个事务;而 ROLLBACK 会撤销整个事务。

保存点适合“部分操作可选”的流程,但不能把一个本应原子完成的业务拆成多个可独立提交的部分。


四、一致性:从一个满足约束的状态到另一个满足约束的状态

1. 一致性不是数据库单独创造的性质

一致性(Consistency)表示事务提交后,数据库状态满足预先定义的约束和业务不变量。

例如账户余额不能为负:

CREATE TABLE accounts (
    id      bigint PRIMARY KEY,
    balance numeric(12, 2) NOT NULL CHECK (balance >= 0)
);

若事务最终提交后出现 balance < 0,就违反了这个约束。

一致性约束可能来自:

  • 数据库约束:PRIMARY KEYUNIQUEFOREIGN KEYCHECKNOT NULL
  • 触发器;
  • 事务内部逻辑;
  • 应用层业务规则;
  • 多个表之间的关系。

因此更准确的说法是:

数据库通过原子性、隔离性、约束检查等机制,帮助应用维护一致性;数据库无法自动知道所有业务不变量。

例如“同一用户最多有三个未支付订单”不是普通字段类型能表达的规则。若只在应用层先查询数量再插入,在并发下仍可能超限。

2. 一致性和一致读不是一回事

“读到一致的快照”是隔离性或 MVCC 的问题;“提交后满足业务约束”是数据库一致性问题。

一个事务可以读到一个自洽的快照,但根据这个快照做出的并发决策仍然可能破坏业务规则,这就是典型的写偏差(write skew)。


五、隔离性:并发事务之间如何互相影响

隔离性(Isolation)描述并发事务的可见性和冲突行为。

如果事务串行执行,所有结果都容易推理:

T1 完成 → T2 开始

但真实系统通常交错执行:

T1: 读取
T2: 读取
T1: 写入
T2: 写入
T1: 提交
T2: 提交

隔离级别就是在性能和可观察行为之间作出规定。

1. 调度与可串行化

把事务操作交错执行称为一个调度(schedule)。如果一个并发调度的最终效果等价于某种串行执行顺序,就称为冲突可串行化或至少具备串行化效果。

考虑两个事务:

T1: R(X), W(X)
T2: R(X), W(X)

若调度为:

T1: R(X)
T2: R(X)
T1: W(X)
T2: W(X)

两个事务都基于旧值写入,可能产生丢失更新。它不一定等价于 T1 → T2T2 → T1 的正确串行结果。


六、常见异常现象

不同数据库和隔离级别对异常的处理不同。下面先定义现象,再讨论实现差异。

1. 脏读(Dirty Read)

事务 T2 读取了事务 T1 尚未提交的数据:

初始 X = 100

T1: UPDATE X = 50       -- 未提交
T2: SELECT X → 50       -- 读到未提交值
T1: ROLLBACK

T2 看到的 50 最终不存在,因此称为脏读。

2. 不可重复读(Non-repeatable Read)

同一个事务两次读取同一行,结果不同:

T1: SELECT balance → 100

T2: UPDATE balance = 50
T2: COMMIT

T1: SELECT balance → 50

这里两次读取之间,另一个已提交事务修改了该行。

3. 幻读(Phantom Read)

同一个事务两次执行范围查询,第二次多出或少了一些满足条件的行:

-- T1
SELECT count(*) FROM orders WHERE user_id = 7;
-- 返回 3

-- T2 插入一条 user_id = 7 的订单并提交

-- T1 再次查询
SELECT count(*) FROM orders WHERE user_id = 7;
-- 返回 4

“幻影”不是某一行字段变了,而是查询谓词匹配的行集合发生了变化。

4. 丢失更新(Lost Update)

两个事务读取相同旧值,各自计算后覆盖对方结果:

初始 X = 100

T1: 读取 X = 100,计算为 90
T2: 读取 X = 100,计算为 80
T1: 写入 X = 90
T2: 写入 X = 80

最终结果为 80,T1 的更新消失。

使用原子表达式通常可以避免这种特定形式的丢失更新:

UPDATE accounts
SET balance = balance - 10
WHERE id = 1;

这里数据库直接对当前行执行减法,而不是由应用先读出余额再写回。

但如果业务逻辑是“余额足够才扣款”,还必须把条件放进同一条语句:

UPDATE accounts
SET balance = balance - 10
WHERE id = 1
  AND balance >= 10;

然后检查受影响行数是否为 1。否则“先查询余额,再更新”的两个步骤仍然存在竞态。

5. 写偏差(Write Skew)

写偏差比丢失更新更容易被忽略。两个事务修改不同的行,但共同破坏一个跨行约束。

例:值班表要求至少有一名医生在岗。

初始:

doctor_id | on_call
----------+--------
1         | true
2         | true

两个事务同时执行:

T1: 读取,发现医生 2 在岗 → 将医生 1 设置为 off
T2: 读取,发现医生 1 在岗 → 将医生 2 设置为 off

二者修改的是不同的行,因此没有直接的行写冲突,但最终变成:

doctor_id | on_call
----------+--------
1         | false
2         | false

跨行不变量被破坏。


七、隔离级别不是完全统一的四档行为

SQL 标准常用四个名称:

  1. Read Uncommitted
  2. Read Committed
  3. Repeatable Read
  4. Serializable

但标准对部分异常的最低限制并不能完全描述具体数据库的所有行为。同名隔离级别在 PostgreSQL 和 MySQL InnoDB 中并不完全等价。

1. 理想化对照

隔离级别 脏读 不可重复读 幻读 常见实现思路
Read Uncommitted 可能 可能 可能 尽量少限制
Read Committed 禁止 可能 可能 每次语句看到已提交数据
Repeatable Read 禁止 禁止 标准层面存在实现差异 事务级快照或更强锁
Serializable 禁止 禁止 禁止 效果等价于串行执行

“禁止”表示数据库不应让事务观察到该异常,不表示没有等待、回滚或序列化失败。


八、PostgreSQL 的隔离语义

以下语义针对 PostgreSQL 当前稳定版本的普通事务表和标准事务控制。

1. Read Uncommitted 实际等同于 Read Committed

PostgreSQL 接受 READ UNCOMMITTED 这个级别名称,但其实现行为等同于 READ COMMITTED,不会真正读取其他事务的未提交数据。

BEGIN TRANSACTION ISOLATION LEVEL READ COMMITTED;

2. Read Committed:每条语句获得自己的快照

PostgreSQL 的默认隔离级别是 READ COMMITTED。一个事务中的每条普通 SQL 语句开始执行时,获得一个新的快照:

  • 能看到该语句开始前已经提交的事务;
  • 看不到其他事务未提交的修改;
  • 同一事务中先后两条查询可能看到不同结果。

因此:

T1: SELECT X → 100

T2: UPDATE X = 50
T2: COMMIT

T1: SELECT X → 50

这不是 PostgreSQL 的异常,而是 READ COMMITTED 的定义。

对于 UPDATEDELETE 等写操作,如果目标行在语句开始时可见,但随后被另一个事务更新,PostgreSQL 可能等待对方结束,然后基于更新后的版本重新判断搜索条件。这也是“语句快照”和“写入当前版本”共同作用的结果。

3. Repeatable Read:事务级快照

PostgreSQL 的 REPEATABLE READ 使用事务级快照。事务中普通查询看到的内容在整个事务期间保持一致,因此不会看到已提交的新行或旧行的新版本。

BEGIN TRANSACTION ISOLATION LEVEL REPEATABLE READ;

SELECT * FROM orders WHERE user_id = 7;
-- 后续同一事务再次查询,仍基于同一快照

PostgreSQL 的实现比 SQL 标准最低要求更强:普通快照读不会出现幻读。

但是,快照一致不等于所有业务逻辑都安全。前面的“至少一名医生在岗”例子仍可能形成写偏差。若两个事务在 REPEATABLE READ 下修改互不相同的行,可能都提交。

在 PostgreSQL 中,如果两个事务对同一行产生冲突,后来的事务可能因为无法在其快照下继续完成而收到序列化相关错误,需要回滚并重试。应用必须把这类错误视为事务失败,而不是继续使用当前事务。

4. Serializable:通过 SSI 检测危险结构

PostgreSQL 的 SERIALIZABLE 基于 Serializable Snapshot Isolation(SSI)实现。事务仍可以使用快照读取,但数据库会跟踪可能形成非串行化结果的读写依赖。

当检测到危险结构时,数据库会主动中止其中一个事务,常见错误是:

could not serialize access due to ...

这不是数据库“错误地丢数据”,而是数据库拒绝接受无法证明可串行化的调度。

应用应重试整个事务:

for attempt in 1..N:
    BEGIN SERIALIZABLE
    执行业务逻辑
    COMMIT
    如果发生 serialization failure:
        ROLLBACK
        退避后重试

重试必须从 BEGIN 重新开始,不能只重试最后一条 SQL,因为事务此前读取的快照和业务决策已经失效。


九、MySQL InnoDB 的隔离语义

以下讨论以 MySQL 8.4 的 InnoDB 为主。MySQL 事务行为不能简单推广到所有存储引擎;例如非事务型存储引擎不提供同样的回滚能力。

1. 默认隔离级别是 Repeatable Read

InnoDB 默认使用 REPEATABLE READ。普通一致性读通常通过 MVCC 读取快照:

START TRANSACTION;

SELECT balance FROM accounts WHERE id = 1;
-- 同一事务后续普通 SELECT 通常继续使用一致性读视图

在一个事务中,第一次一致性读通常建立读视图,后续一致性读继续基于该视图。因此它与 PostgreSQL 的 REPEATABLE READ 都具有事务级快照特征,但底层细节和锁行为不同。

2. 一致性读和当前读必须区分

InnoDB 中常见的普通 SELECT 是一致性读,读取快照;以下语句属于当前读,会读取最新的索引记录并参与锁定:

SELECT ... FOR UPDATE;
SELECT ... FOR SHARE;
UPDATE ...;
DELETE ...;

例如:

START TRANSACTION;

SELECT balance
FROM accounts
WHERE id = 1
FOR UPDATE;

UPDATE accounts
SET balance = balance - 100
WHERE id = 1;

COMMIT;

FOR UPDATE 的含义不是“让结果永远最新”,而是在事务期间锁定匹配记录,阻止其他事务对其进行冲突修改,并使后续业务决策建立在当前版本上。

3. InnoDB 的间隙锁和临键锁

在合适的索引条件下,InnoDB 为范围上的锁定读使用记录锁、间隙锁或临键锁(next-key lock)。这些锁可以阻止其他事务在已锁定范围内插入匹配记录,从而抑制锁定读的幻读。

例如:

START TRANSACTION;

SELECT *
FROM orders
WHERE user_id = 7
FOR UPDATE;

如果 user_id 有合适索引,InnoDB 可能锁定匹配记录及相关索引间隙。实际锁范围受索引结构、查询条件、执行计划和隔离级别影响,不能仅凭 SQL 文本精确判断。

这与普通快照读不同:

SELECT * FROM orders WHERE user_id = 7;

普通快照读不等于把范围锁住,也不等于阻止其他事务插入。

4. MySQL 的 Read Committed

READ COMMITTED 下,InnoDB 普通一致性读通常为每条语句建立新的快照,因此同一事务两次查询可能看到不同的已提交结果。

同时,InnoDB 在该级别下会减少部分间隙锁使用,降低锁冲突,但也意味着范围并发行为不同。不能把 READ COMMITTED 理解为“只是比默认设置弱一点”,它会改变快照和锁定策略。

5. MySQL 的 Serializable

在 InnoDB 中,SERIALIZABLE 会使普通读更接近锁定读,减少并发,但可能增加等待和死锁。事务仍可能因为死锁、锁等待超时、连接断开等原因失败,应用仍须处理异常。


十、持久性:提交成功后,结果能否在故障后保留

1. 持久性的定义

持久性(Durability)要求:

数据库报告事务提交成功后,即使随后发生崩溃,已提交结果也应在恢复后保留。

事务提交前后的关键区别是:

事务修改
  ↓
日志记录
  ↓
提交记录被可靠处理
  ↓
向客户端报告 COMMIT 成功

数据库通常使用预写日志(WAL,Write-Ahead Logging)或 redo log:

  1. 先把恢复所需的日志写入日志系统;
  2. 再允许数据页延后写回;
  3. 崩溃恢复时,根据日志重放已提交修改,并撤销或忽略未完成修改。

因此“数据页已经写入磁盘”不是判断事务是否持久化的唯一方式。

2. 提交成功的语义依赖配置和部署

持久性不是脱离环境的绝对承诺。它受以下因素影响:

  • 数据库是否等待日志刷入稳定存储;
  • 操作系统和文件系统是否真正提供了可靠 flush;
  • 云盘或虚拟化存储是否正确实现持久化;
  • 数据库是否启用了降低同步等待的配置;
  • 主库是否已经把日志复制到副本;
  • 客户端收到响应前连接是否中断。

因此必须区分:

  • 本地提交成功:事务已被本地数据库接受并满足其提交语义;
  • 复制确认成功:提交还要求某些副本确认;
  • 业务真正完成:还可能要求消息、缓存或外部系统同步完成。

客户端在 COMMIT 后断开,可能处于不确定状态:服务端已经提交但响应没到达,也可能服务端尚未提交。此时不能简单重放一个非幂等请求,否则可能重复扣款或重复创建订单。


十一、MVCC、锁与事务隔离如何配合

事务隔离不是只靠“加锁”实现,也不是只靠 MVCC 实现。

1. MVCC 提供历史版本和快照读取

多版本并发控制(MVCC)为同一行保留不同版本或通过撤销信息重建旧版本。事务读取时根据自己的快照判断哪个版本可见。

概念上,每个版本带有创建事务和删除事务信息:

版本 V1:由 T10 创建
版本 V2:由 T20 创建,替代 V1

若读取事务开始时 T20 尚未提交,它通常继续看到 V1;若 T20 已提交,则可能看到 V2。

MVCC 的优点是普通读通常不会阻塞普通写。代价包括:

  • 需要维护旧版本;
  • 需要清理无用版本;
  • 长事务会阻止某些旧版本清理;
  • 索引、回滚段或事务 ID 管理会产生额外开销。

2. 锁保护当前写入和冲突操作

锁解决的是另一类问题:

  • 两个事务同时修改同一行;
  • 锁定读后再作业务判断;
  • 防止某个范围被插入或修改;
  • 维护唯一性和外键等约束。

例如库存扣减:

UPDATE inventory
SET stock = stock - 1
WHERE product_id = 10
  AND stock > 0;

这条语句把检查和修改放进数据库的一个原子操作中。应用应检查:

受影响行数 = 1 → 扣减成功
受影响行数 = 0 → 库存不足或商品不存在

如果写成:

SELECT stock
UPDATE stock = stock - 1

则需要使用 FOR UPDATE 或等价的并发控制,否则多个事务可能同时读到同一个库存值。

3. 死锁是隔离机制的正常副作用

事务可能形成等待环:

T1 持有 A,等待 B
T2 持有 B,等待 A

数据库通常检测到死锁后中止其中一个事务,另一个事务继续。

典型原因是两个事务以不同顺序更新相同资源:

T1: UPDATE A; UPDATE B;
T2: UPDATE B; UPDATE A;

降低风险的方法不是“永远关闭锁”,而是:

  • 对同类资源采用一致的加锁顺序;
  • 缩短事务时间;
  • 为查询建立合适索引,避免锁定超出预期的范围;
  • 对死锁错误重试整个事务;
  • 记录等待事务、持锁事务、SQL 和索引条件。

死锁重试与序列化失败重试有相同原则:回滚后从事务开始处重新执行。


十二、完整算例:为什么“先读再写”会出错

假设账户余额为 100,两个请求分别扣款 80。

错误流程

初始 balance = 100

T1: SELECT balance → 100
T2: SELECT balance → 100

T1: 应用判断 100 >= 80,准备写入 20
T2: 应用判断 100 >= 80,准备写入 20

T1: UPDATE balance = 20
T2: UPDATE balance = 20

最终 balance = 20

数据库最终只减少了 80,但业务接受了两次扣款。

方案一:原子条件更新

BEGIN;

UPDATE accounts
SET balance = balance - 80
WHERE id = 1
  AND balance >= 80;

-- 检查 row_count
COMMIT;

若第一次更新成功,余额变成 20;第二次更新的条件不再成立,受影响行数为 0。

方案二:锁定读后更新

BEGIN;

SELECT balance
FROM accounts
WHERE id = 1
FOR UPDATE;

-- 应用检查 balance >= 80

UPDATE accounts
SET balance = balance - 80
WHERE id = 1;

COMMIT;

第二个事务会在 FOR UPDATE 或后续写入处等待第一个事务结束,然后读取当前结果,再决定是否扣款。

方案三:乐观并发控制

增加版本号:

UPDATE accounts
SET balance = 20,
    version = version + 1
WHERE id = 1
  AND version = 3;

如果受影响行数为 0,说明版本已被其他事务修改,需要重新读取并重试。

这三种方案的共同点是:把“读取依据”和“提交修改”之间的并发冲突显式纳入数据库判断。


十三、完整算例:快照隔离为什么仍可能破坏业务规则

继续使用值班医生例子。约束是:

on_call(1)+on_call(2)1on\_call(1) + on\_call(2) \ge 1

在 PostgreSQL 的 REPEATABLE READ 或某些快照型实现中:

初始:医生 1 在岗,医生 2 在岗

T1 开始事务,读取快照:两人都在岗
T2 开始事务,读取快照:两人都在岗

T1 将医生 1 设置为不在岗
T2 将医生 2 设置为不在岗

T1 提交
T2 提交

最终:两人都不在岗

为什么没有冲突?

  • T1 写医生 1;
  • T2 写医生 2;
  • 没有直接写同一行;
  • 但二者都读取了对方仍在岗这一事实。

这是一个跨行读写依赖问题,不是简单的单行更新冲突。

可选解决方式包括:

方法一:串行化隔离级别

在 PostgreSQL 中:

BEGIN TRANSACTION ISOLATION LEVEL SERIALIZABLE;

SELECT count(*)
FROM doctors
WHERE on_call = true;

UPDATE doctors
SET on_call = false
WHERE id = 1;

COMMIT;

其中一个事务可能在提交时收到序列化失败。应用捕获后应重试整个事务。

方法二:锁定约束涉及的公共资源

例如固定锁定一个“值班规则”行:

BEGIN;

SELECT id
FROM scheduling_rules
WHERE rule_name = 'minimum-one-on-call'
FOR UPDATE;

SELECT count(*)
FROM doctors
WHERE on_call = true;

UPDATE doctors
SET on_call = false
WHERE id = 1;

COMMIT;

所有修改该规则的事务都先锁同一行,从而把相关操作排成顺序。

方法三:把约束建模为可直接更新的计数资源

例如维护一个在岗人数,并通过条件更新确保不会减到 0。此时必须认真处理计数和人员状态之间的原子性,不能只维护一个容易漂移的缓存字段。


十四、事务边界:数据库保证到哪里为止

“正确边界”是事务设计中最重要、也最容易被误解的部分。

1. 数据库事务通常只覆盖一个数据库资源管理器

下面的操作不因位于同一段函数中就自动具有原子性:

数据库扣款
发送 HTTP 请求
写 Redis
发送 Kafka 消息
调用另一个服务
提交数据库

数据库可以回滚自己的修改,但通常不能自动让远程 HTTP 服务撤销动作,也不能让已经发出的邮件“回滚”。

2. 跨库原子性需要额外协议

如果一个业务必须同时修改两个数据库,可能使用:

  • 两阶段提交(2PC/XA);
  • 分布式事务协调器;
  • Saga 补偿事务;
  • 可靠消息和最终一致性设计。

这些方案的语义、阻塞风险、故障恢复方式和运维复杂度不同,不能把本地事务直接扩展成分布式事务。

3. Outbox 模式解决“数据库提交与发消息”的断裂

常见做法是在同一个本地事务中写业务表和消息表:

BEGIN;

INSERT INTO orders(id, user_id, status)
VALUES (1001, 7, 'PAID');

INSERT INTO outbox_events(event_id, event_type, aggregate_id, payload)
VALUES ('evt-1001', 'OrderPaid', '1001', '{...}');

COMMIT;

后台发布器随后读取 outbox_events 并发送消息,成功后标记已发布。

这里得到的是:

  • 订单和待发送事件在同一个数据库事务中原子提交;
  • 消息发送可能重复,因此消费者必须幂等;
  • 不能把“写入 outbox”误称为“远程消息已经发送成功”。

4. 高可用和复制不自动改变本地事务语义

主库提交成功,不一定代表:

  • 所有副本都已经收到日志;
  • 所有读请求都能立即读到该提交;
  • 故障转移后一定没有最近提交的数据丢失。

复制确认级别、同步或异步复制、读写路由和故障转移策略共同决定分布式可见性。

同样,分片环境中的本地事务通常只覆盖一个分片。跨分片事务需要额外协调;把请求路由到不同分片,并不会自动获得全局 ACID。


十五、DDL、事务控制和隐式提交

事务边界还会受到数据库语句类别影响。

1. PostgreSQL

PostgreSQL 的许多 DDL 可以放在事务中:

BEGIN;

CREATE TABLE test_items(id bigint PRIMARY KEY);

ROLLBACK;

回滚后,表创建也会撤销。但具体语句仍需查阅对应版本的限制,某些操作具有特殊事务要求。

2. MySQL

MySQL 中部分 DDL 会导致隐式提交。典型的表结构变更不能简单按普通 DML 的回滚规则理解。执行 DDL 前应确认:

  • 是否会隐式提交当前事务;
  • DDL 失败时哪些部分可能已经发生;
  • 在线 DDL 的锁和并发行为;
  • 是否需要专门的变更工具和恢复方案。

因此不要把下面的代码当成跨数据库通用事务:

BEGIN;
UPDATE business_table SET status = 'READY';
ALTER TABLE business_table ADD COLUMN x INT;
ROLLBACK;

即使客户端允许执行,两个操作的原子性也不能按同一规则推断。


十六、如何选择隔离级别

隔离级别应由业务不变量和并发冲突决定,而不是单纯追求“最高”。

Read Committed

适合:

  • 单条原子更新;
  • 不要求事务内多次读取结果完全相同;
  • 通过约束、条件更新或显式锁保证关键规则。

风险是同一事务中的后续查询可能看到新提交的数据。

Repeatable Read

适合:

  • 需要事务级一致快照;
  • 长流程中多次读取必须基于同一逻辑视图;
  • 能明确处理写偏差、冲突和长事务成本。

不能假设所有跨行业务规则因此自动安全。

Serializable

适合:

  • 业务规则难以通过局部锁或原子 SQL 清楚表达;
  • 正确性优先于吞吐;
  • 应用具备完整事务重试机制。

代价是:

  • 更高的冲突概率;
  • 更多等待、回滚或序列化失败;
  • 长事务和大范围扫描可能放大冲突。

锁定读

当业务逻辑是:

读取当前值 → 根据当前值判断 → 修改相关数据

通常需要 SELECT ... FOR UPDATEFOR SHARE 或数据库对应的锁机制。但锁必须配合索引和事务边界分析,否则可能锁住过大的范围。


十七、生产中的异常处理与诊断

1. 任何失败都不要只重试最后一条 SQL

以下错误通常需要回滚并重试整个事务:

  • 死锁;
  • 锁等待超时;
  • 序列化失败;
  • 连接在提交阶段中断;
  • 数据库连接被服务端关闭。

原因是事务此前的读取结果、锁状态和业务决策已经不再可靠。

2. 区分可重试和不可重试错误

可重试错误通常是瞬时并发问题;不可重试错误可能是:

  • 唯一键冲突;
  • 外键约束失败;
  • CHECK 约束失败;
  • 参数错误;
  • 权限错误;
  • 业务条件不满足。

把所有数据库异常都无限重试,会造成请求堆积和重复副作用。

3. 诊断事务问题需要观察四类信息

事务边界

记录:

  • 事务开始时间;
  • 提交或回滚时间;
  • 事务使用的连接;
  • 自动提交状态;
  • 隔离级别;
  • 是否只读。

锁和等待

关注:

  • 谁持有锁;
  • 谁在等待;
  • 等待的对象是行、索引范围、表还是元数据;
  • SQL 是否使用预期索引;
  • 是否存在长事务。

版本和快照

MVCC 问题常表现为:

  • 长事务导致旧版本无法清理;
  • 查询看到“旧数据”;
  • 复制或清理延迟;
  • 一次大范围查询拖长事务生命周期。

提交和复制状态

需要区分:

  • 本地提交是否完成;
  • 日志是否刷盘;
  • 副本是否确认;
  • 读请求是否路由到已追上的副本。

4. 事务日志不能记录敏感数据

为了诊断事务重试和幂等问题,可以记录:

request_id
transaction_id
业务对象 ID
隔离级别
开始时间
提交/回滚结果
数据库错误码
重试次数

但应避免把密码、完整支付信息和敏感载荷直接写入日志。


十八、常见误解

误解一:COMMIT 成功就等于客户端业务完成

不一定。它只说明数据库事务达到数据库定义的提交状态。消息、缓存、搜索索引和外部服务可能仍未完成。

误解二:REPEATABLE READ 可以解决所有并发问题

不能。它主要解决快照可见性问题,不能自动保护所有跨行不变量,也不能替代唯一约束、条件更新和正确的锁设计。

误解三:使用 ORM 就自动获得事务

ORM 通常只是提供事务 API。事务仍依赖:

  • 当前数据库连接;
  • 实际存储引擎;
  • 自动提交设置;
  • 事务是否覆盖所有相关操作;
  • 异常是否触发回滚。

误解四:加了锁就没有死锁

锁能防止某些并发冲突,也可能制造循环等待。锁的顺序、范围和持有时间都必须分析。

误解五:事务越大越安全

事务越大,通常意味着:

  • 锁持有时间更长;
  • MVCC 旧版本保留更久;
  • 冲突和死锁概率更高;
  • 回滚成本更高;
  • 连接和资源被占用更久。

事务应覆盖一个真正需要原子提交的业务单元,而不是覆盖整个请求链路中所有步骤。


十九、建立事务设计的推导顺序

遇到一个并发业务问题,可以按以下顺序分析:

第一步:写出不变量

例如:

balance0balance \ge 0

或:

在岗医生数1\text{在岗医生数} \ge 1

如果说不清必须保持什么,就无法选择正确的隔离方式。

第二步:划定原子边界

明确哪些修改必须一起提交,哪些操作可以异步化,哪些外部副作用不能纳入本地事务。

第三步:列出事务读写集合

例如:

T1 读取:账户余额
T1 写入:账户余额、扣款记录

再分析两个事务是否:

  • 读取同一数据;
  • 写入同一数据;
  • 读取某数据后写入另一数据;
  • 依赖一个范围内“没有其他行”。

第四步:构造最坏交错

不要只看正常顺序,而要主动尝试:

T1 读
T2 读
T1 写
T2 写
T1 提交
T2 提交

如果能构造出违反不变量的结果,就说明当前设计不足。

第五步:选择最小但足够的机制

可能的修正包括:

  • 单条条件更新;
  • 唯一约束;
  • 外键或检查约束;
  • 锁定读;
  • 版本号;
  • 更高隔离级别;
  • 序列化失败重试;
  • outbox 或补偿流程。

第六步:定义失败语义

必须明确:

  • 哪些错误回滚;
  • 哪些错误可重试;
  • 重试是否幂等;
  • 提交响应丢失时如何查询最终状态;
  • 外部副作用如何去重或补偿。

二十、最后的边界判断

事务的核心不是“把 SQL 包在 BEGINCOMMIT 中”,而是建立一组可验证的承诺:

  1. 原子性保证一个本地事务不会只提交一半;
  2. 一致性要求提交结果满足已定义的不变量;
  3. 隔离性规定并发事务可以观察和影响什么;
  4. 持久性规定数据库报告提交成功后,故障恢复应保留什么;
  5. 隔离级别并不等价于业务正确性,尤其不能自动消除跨行写偏差;
  6. MVCC 和锁分别处理版本可见性与并发冲突,二者共同构成实际隔离行为;
  7. 事务边界通常止于一个数据库资源管理器,外部系统需要分布式事务、消息表、幂等或补偿机制;
  8. 死锁、锁超时和序列化失败是可预期的并发结果,应用必须回滚并重试完整事务。

当设计一个事务时,最可靠的问题不是“应该使用哪个隔离级别”,而是:

这个业务必须保持什么不变量?哪些操作必须原子完成?两个事务最坏情况下如何交错?数据库会阻塞、拒绝还是允许这种交错?失败后能否安全重试?

能回答这些问题,事务才真正从抽象概念变成了可验证、可诊断的工程机制。


系列导航与关联阅读

官方资料

本文依据数据库官方文档重新梳理;正文、示例与生产检查清单由 WR BLOG 编写。