主题
数据库事务:让一组修改成立,也让并发判断成立
给账户 A 扣 30,再给账户 B 加 30,这两步必须一起成功。把它们放进事务,可以防止“扣了没加”。但如果两个请求都先读到 A 还有 100,再各自按这个旧值写余额,仅仅加上 BEGIN 和 COMMIT,仍不保证业务正确。
事务要解决两层问题:一组修改能不能一起提交;并发事务的读和写,能不能保持业务约束。本文从这两个问题讲 ACID、隔离级别、MVCC 和锁。引擎边界固定为 PostgreSQL 18、MySQL 8.4 InnoDB。实际实验只在 PostgreSQL 18.6 的 single-user 离线模式运行,验证回滚、提交、保存点和条件更新;并发时序与锁行为按官方文档解释,未冒充实测。
ACID 分别承诺什么
| 属性 | 它解决的问题 | 应用仍要负责什么 |
|---|---|---|
| Atomicity,原子性 | 一个事务内的数据库修改作为整体提交或撤销 | 别把事务内更新和事务外更新混用;邮件、HTTP 调用不会自动回滚 |
| Consistency,一致性 | 有效事务应把数据库从满足约束的状态带到另一有效状态 | 写对业务规则,建立唯一、外键、CHECK 等约束;数据库不知道所有业务含义 |
| Isolation,隔离性 | 控制并发事务能看到什么、彼此怎样干扰 | 选合适级别,处理冲突,避免基于旧数据做无保护写入 |
| Durability,持久性 | 提交成功的变化按配置约定承受故障 | 理解刷盘、存储硬件、同步复制和备份恢复的边界 |
这里的 C 不是“所有副本立即一样”,I 也不是“每个事务独占数据库”。事务的具体保证来自引擎、隔离级别、日志与持久化配置,不能用四个字母盖过这些条件。PostgreSQL 的 WAL 要求相关日志先于数据页持久化;synchronous_commit=off 可提前确认提交,因此允许崩溃丢失最近已确认事务。事务执行完一次,不足以证明断电持久性。[1][2][3]
原子性还有引擎定义的例外。PostgreSQL 的序列 nextval 已发出的值不会因为事务回滚而收回,因此 serial/identity 主键出现空号并不代表丢了业务记录。本文实测在事务内取到序列值 1,回滚后再次取值为 2。序列提供编号,不提供“成功订单的连续计数”。[4]
先实际观察整体撤销
初始余额是 1|100、2|100:
sql
BEGIN;
UPDATE accounts SET balance = balance - 30 WHERE id = 1;
UPDATE accounts SET balance = balance + 30 WHERE id = 2;
ROLLBACK;
SELECT id, balance FROM accounts ORDER BY id;本次实际结果:
text
1|100
2|100把结束语句改为 COMMIT,实际结果变成 1|70、2|130。事务把两次写入放进一个提交边界,回滚时两次都撤销。这只验证了单会话的数据库行为,没验证中途断电,也没有外部转账动作参与。
保存点 SAVEPOINT 把事务里的一段标出来。PostgreSQL 事务遇到语句错误后通常进入失败状态,不能假装忽略错误继续提交;可以整体回滚,也可以 ROLLBACK TO 已有保存点,再继续后续合法操作。实测中先标保存点,再扣 500 触发非负余额 CHECK,回到保存点后给账户 2 加 1,最终得到 1|70、2|131。保存点不是独立提交的小事务。[5][6]
三种常见异常,要看谁在什么时刻提交
| 异常 | 一个明确的并发历史 | 危险在哪里 |
|---|---|---|
| 脏读 | T1 把余额改成 20,未提交;T2 读到 20;T1 回滚 | T2 使用了从未正式成立的数据 |
| 不可重复读 | T1 第一次读余额为 100;T2 改为 70 并提交;T1 再读变成 70 | 同一事务中,两次读同一记录不一致 |
| 幻读 | T1 查“待处理订单”得到 5 行;T2 插入匹配订单并提交;T1 重查得到 6 行 | 按条件得到的记录集合变化 |
| 序列化异常 | 两事务各自检查后修改不同记录,合起来违反全局约束 | 每次单独读都合理,合起来不等价于任何串行顺序 |
幻读关心满足条件的集合改变,不只限于 INSERT:把原来不匹配的记录更新到匹配范围,也可能改变集合。不可重复读与幻读是帮助理解现象的分类,并非穷尽所有并发错误。经典论文专门指出,仅靠这三种现象描述隔离会遗漏重要情况。[4:1][7]
四个隔离级别,先看最低语义,再看具体引擎
SQL 隔离级别通常按以下“不得出现什么”介绍。表中“可发生”表示级别没有禁止,引擎可以提供更强保证:
| 标准级别 | 脏读 | 不可重复读 | 幻读 | 序列化异常 |
|---|---|---|---|---|
| Read Uncommitted,读未提交 | 可发生 | 可发生 | 可发生 | 可发生 |
| Read Committed,读已提交 | 禁止 | 可发生 | 可发生 | 可发生 |
| Repeatable Read,可重复读 | 禁止 | 禁止 | 可发生 | 可发生 |
| Serializable,可串行化 | 禁止 | 禁止 | 禁止 | 禁止 |
“Serializable”要求已提交事务的效果等价于某种一个接一个执行的顺序,不要求引擎真的一次只运行一个事务,也不自动承诺所有外部系统的实时顺序。[4:2]
| 常见问题 | PostgreSQL 18 | MySQL 8.4 InnoDB |
|---|---|---|
| 默认级别 | Read Committed | Repeatable Read |
| 请求 Read Uncommitted | 行为映射为 Read Committed | 允许读未提交数据的低隔离行为 |
| 普通快照 SELECT 在 RC 下 | 每条命令建立自己的快照 | 每次一致性读建立新快照 |
| 普通快照 SELECT 在 RR 下 | 基于事务首次非事务控制语句建立的快照;避免幻读 | 同一事务的一致性读通常使用首次一致性读建立的快照;也能显式创建快照 |
| 需要读后更新 | 行锁、原子条件更新或 Serializable 等 | 锁定读、原子条件更新等;锁定读与普通一致性读不可混为一谈 |
| Serializable 的重要代价 | 跟踪读写依赖,可能因序列化冲突回滚 | 更强的锁定行为,含特定条件下把普通 SELECT 变为共享锁定读,可能增加等待 |
PostgreSQL RR 已经禁止幻读,但仍可能有写偏差。InnoDB RR 下普通一致性读的旧快照,与锁定读/UPDATE 使用的状态并非同一套读取规则;不能看到 REPEATABLE READ 就以为事务里每种语句都读取同一个冻结画面。[8][9][10]
没有幻读,为什么还可能把业务做错
设值班表里 A、B 都在岗,规则是至少留一人。两个事务各自看到两人在岗,于是一个让 A 下班,另一个让 B 下班。它们写不同的行,旧快照里的读也都稳定,最后却没人值班。下面是解释写偏差的假设并发时序,并非本次双会话运行记录。[7:1][4:3]
正在绘制图示…
查看图源代码
sequenceDiagram
participant T1 as 事务 T1
participant DB as 值班表 A=true B=true
participant T2 as 事务 T2
T1->>DB: 读快照,在岗人数=2
T2->>DB: 读快照,在岗人数=2
T1->>DB: 更新 A=false
T2->>DB: 更新 B=false
T1->>DB: 提交
T2->>DB: 提交
Note over DB: 若两者都可提交,最终人数=0
解决方法必须保护“至少一人在岗”这个判断。可以在共同的业务范围行上串行化修改;也可以使用 PostgreSQL Serializable,并在冲突时重试整个事务。只锁自己将要修改的那一行,仍没有覆盖另一事务检查的共同条件。具体方案还要考虑吞吐和争用,不能把“加行锁”当成万能答案。
MVCC 保存的是版本,快照决定哪个版本能被看见
MVCC 是多版本并发控制。更新发生时,引擎保留让旧读者能继续读的历史信息,再根据快照和事务状态判断可见性。PostgreSQL 主要在堆表中保留元组版本,之后由 VACUUM 等机制清理不再需要的版本;InnoDB 通过记录中的事务信息和 undo 重建旧版本。这两种实现不能互相代换。[11][12][13]
正在绘制图示…
查看图源代码
flowchart LR
U["事务 T2 更新并提交"] --> N["新版本:余额 70"]
U --> O["旧版本或重建信息:余额 100"]
S1["T1 的旧快照"] --> O
S2["提交后建立的新快照"] --> N
O --> C["没有快照再需要旧版本时<br/>才具备清理条件"]
同一事务通常能看到自己先前的写入,即使这些写入尚未提交。这里的“旧快照”主要限制其他事务的变化,不是把当前事务自己的修改也藏起来。PostgreSQL 与 InnoDB 文档都明确了这点。[4:4][9:1]
MVCC 减少普通读与写之间的直接阻塞,却不意味着没有锁。两个写者改同一行仍需协调,DDL 会拿表级锁,唯一约束检查也可能等待;显式 SELECT ... FOR UPDATE 更是在要求锁。长事务不断保留旧快照,还可能拖住历史版本清理,扩大存储和扫描成本。[14][9:2]
行锁、间隙锁、临键锁、意向锁各保护什么
先把引擎标签贴上。PostgreSQL 有行级锁及多种表级锁,但没有让应用直接套用的 InnoDB gap/next-key 锁规则;PostgreSQL Serializable 的谓词锁用于检测依赖,不是 InnoDB 那种阻挡范围内插入的间隙锁。[14:1][4:5]
| 锁或机制 | 本文对应的引擎 | 保护对象及用途 |
|---|---|---|
| 记录锁,常称行锁 | InnoDB | 锁索引记录;即使表没显式索引也有内部索引可供锁定 |
| 行级共享/更新锁 | PostgreSQL | 阻止与该行上锁模式冲突的修改或锁定读;不同模式兼容性不同 |
| 间隙锁 gap lock | InnoDB | 保护索引记录之间的空隙,主要防止往该空隙插入 |
| 临键锁 next-key lock | InnoDB | 一条记录锁加该记录之前的间隙锁,常用来保护范围 |
| 意向锁 IS/IX | InnoDB 表级 | 表明事务准备在表内持有共享/排他记录锁,让表级锁冲突检查不必遍历所有行锁 |
| Serializable 的 SIREAD 谓词锁 | PostgreSQL | 记录读范围以识别危险读写依赖,通常不直接阻塞写入,可能触发事务回滚 |
InnoDB 的间隙锁是抑制插入的锁。不同事务持有的纯间隙锁可以兼容,不能照搬普通共享/排他记录锁的直觉。意向排他锁 IX 之间也可兼容;“两事务都准备改表内一些行”并不等于“必须互斥整张表”。[15]
用索引键 10、20、30 理解临键范围:保护记录 20 的一个典型 next-key 区间是 (10,20],同时阻止插入 15 和冲突地修改 20。实际拿哪些区间,要看隔离级别、使用的索引、访问范围、唯一查找是否命中及语句种类。唯一索引的完整等值查找命中一条记录时,InnoDB 可以只锁记录、不锁前方间隙;部分唯一键条件不能自动套用这个例外。RC 下大部分搜索与扫描的间隙锁会关闭,但外键检查、重复键检查等仍有例外。[15:1][16][8:1]
因此“有索引就是行锁,没索引就是表锁”不准确。InnoDB 可能因为缺少合适索引扫描并锁住大量记录及范围,看起来几乎阻住整张表,但这与锁机制自动升级为一个表锁不是同一回事。应看实际扫描路径与锁等待信息。
先把条件与修改合成一次动作
余额扣减常见错误是“先 SELECT,应用算新余额,再无条件 UPDATE 旧计算结果”。另一事务可以在两步之间改变余额。能表达成单行原子条件更新时,先考虑这样写:
sql
UPDATE accounts
SET balance = balance - 80
WHERE id = 1 AND balance >= 80
RETURNING id;单会话实测中账户 1 只有 70,这条语句返回零行,余额仍是 70。应用必须把零影响行数作为业务分支处理,而不是只看 SQL 有没有报错。这个例子实际证明的是条件不足时不扣款;并发条件重新检查等保证来自引擎文档,未在离线模式实验。[4:6]
复杂的读后写流程可以在同一事务里先锁所需行,保持锁直到提交。取舍是锁持有期间别人会等待,期间不要发慢 HTTP 请求、等待用户输入或做长计算。跨多行的不变量仍要明确锁住什么共同范围。
死锁是等待形成了环
T1 先锁 A,T2 先锁 B;T1 再要 B,T2 再要 A。双方都不能前进。
正在绘制图示…
查看图源代码
flowchart LR
T1["T1:持有 A"] -->|等待 B| T2["T2:持有 B"]
T2 -->|等待 A| T1
PostgreSQL 与 InnoDB 都能检测死锁并中止其中一个事务,让其他事务继续;选择谁做牺牲者不能由应用假定。InnoDB 的检测可配置关闭,关闭后要依赖锁等待超时等机制。死锁检测与超时不是同一件事:等待超时也可能只是对方很慢,并没有环。[14:2][17]
| 动作 | 为什么有帮助 | 做不到什么 |
|---|---|---|
| 同类操作按统一键顺序加锁 | 减少 A→B 与 B→A 反向等待 | 不能消除所有隐藏索引、外键与其他路径的冲突 |
| 缩短事务,及时提交 | 降低锁重叠的时间 | 不能替代正确隔离 |
| 为更新条件建合适索引 | 少扫描、少锁不必要的记录 | 热点行仍是热点 |
| 记录死锁与等待信息 | 找到真正的语句、资源和顺序 | 单靠一条报错看不出完整关系 |
| 有界退避并重试完整事务 | 冲突消失后可以重新做业务判断 | 不能无限重试,更不能把外部副作用重复执行 |
PostgreSQL 的序列化失败 SQLSTATE 是 40001,死锁是 40P01。重试要包括原来的读取、判断和全部写入,不能仅把失败的最后一条 UPDATE 再发一次。另一些错误只在特定业务逻辑下适合重试,不能把所有唯一冲突都当作瞬时故障。[18]
MySQL InnoDB 死锁通常回滚整个事务;锁等待超时的回滚范围还受设置影响,默认行为不能直接等同死锁。驱动返回错误后,应用应明确结束旧事务,再按错误类别决定是否重来。[19]
最后一种情况比死锁更棘手:发送 COMMIT 后连接断了。应用不知道服务器是提交成功后回包丢失,还是根本没有提交。此时盲目重做扣款可能造成重复,应该通过业务唯一键、幂等记录和结果查询消除不确定性。事务解决数据库内部提交,应用仍需要管理客户端观察不到结果的窗口。
验证范围
本篇真实运行结果为:
text
rollback=ok commit=ok savepoint=ok conditional-debit=ok expected-error=ok sequence-gap=ok校验脚本同时检查数据库输出和预期 CHECK 错误,而非把进程退出零简单当作成功。没有测试双会话脏读、幻读、写偏差、死锁、间隙锁,也没有测试掉电持久性。那些结论分别来自以下固定版本官方资料与原始论文。single-user 模式官方就明确不提供真实进程间锁行为,不能用它补造并发结论。
参考资料
完整复现材料
前提:PostgreSQL 18.6 的 postgres/initdb、Python 3。新建可丢弃的数据目录,无其他进程使用,不连接生产数据库。将以下三个文件放在同一目录:
sh
export POSTGRES=/path/to/postgresql-18.6/bin/postgres
export PGDATA="$(mktemp -d)/data"
/path/to/postgresql-18.6/bin/initdb -D "$PGDATA" -A trust --no-locale -E UTF8
python3 verify_transactions.pytransactions.sql 含一个故意失败的扣款。run_single.py 发现 SQL ERROR 会退出 1,即使后面保存点恢复成功;verify_transactions.py 确认这是预期错误且全部结果正确,才输出成功。适配脚本只适合这些不在字符串或函数体中使用分号的固定测试,不是通用 SQL 解析器。
transactions.sql
sql
\set ON_ERROR_STOP on
CREATE TEMP TABLE accounts(id integer PRIMARY KEY,balance integer NOT NULL CHECK(balance>=0));
INSERT INTO accounts VALUES(1,100),(2,100);
\echo initial
SELECT id,balance FROM accounts ORDER BY id;
BEGIN;
UPDATE accounts SET balance=balance-30 WHERE id=1;
UPDATE accounts SET balance=balance+30 WHERE id=2;
ROLLBACK;
\echo after-rollback
SELECT id,balance FROM accounts ORDER BY id;
BEGIN;
UPDATE accounts SET balance=balance-30 WHERE id=1;
UPDATE accounts SET balance=balance+30 WHERE id=2;
COMMIT;
\echo after-commit
SELECT id,balance FROM accounts ORDER BY id;
BEGIN;
SAVEPOINT optional_step;
UPDATE accounts SET balance=balance-500 WHERE id=1;
ROLLBACK TO SAVEPOINT optional_step;
UPDATE accounts SET balance=balance+1 WHERE id=2;
COMMIT;
\echo after-savepoint-recovery
SELECT id,balance FROM accounts ORDER BY id;
\echo atomic-conditional-debit
UPDATE accounts SET balance=balance-80 WHERE id=1 AND balance>=80 RETURNING id;
SELECT id,balance FROM accounts ORDER BY id;
CREATE TEMP SEQUENCE demo_seq START 1;
BEGIN;
SELECT nextval('demo_seq') AS issued_inside_transaction;
ROLLBACK;
\echo sequence-after-rollback
SELECT nextval('demo_seq') AS next_issued;run_single.py
代码块 · 42 行 · 点击收起 / 展开
python
"""Run the deliberately simple fixtures shipped with these articles.
Input: ordinary SQL and optional psql \\set/\\pset/\\echo directives.
This is a fixture adapter, not a general SQL parser. No fixture may contain
semicolons inside quoted values, comments, function bodies, or identifiers.
"""
import os
from pathlib import Path
import re
import subprocess
import sys
sql = Path(sys.argv[1]).read_text()
lines = []
for line in sql.splitlines():
if line.startswith('\\echo '):
label = line[len('\\echo '):].replace("'", "''")
lines.append("SELECT '" + label + "';")
elif line.startswith('\\'):
continue
else:
lines.append(line)
commands = []
for part in '\n'.join(lines).split(';'):
part = part.strip()
if not part:
continue
# COPY gives legible values; it does not change the SELECT's semantics.
if re.match(r'(?i)^(SELECT|WITH)\b', part):
part = "COPY (" + part + ") TO STDOUT WITH (DELIMITER '|', NULL 'NULL')"
commands.append(part + ';\n\n')
run = subprocess.run(
[os.environ.get('POSTGRES', 'postgres'), '--single', '-j',
'-D', os.environ['PGDATA'], 'postgres'],
input=''.join(commands), text=True, capture_output=True)
print(run.stdout, end='')
print(run.stderr, end='', file=sys.stderr)
if run.returncode:
sys.exit(run.returncode)
# The stand-alone backend may recover from a SQL error and exit zero.
# An ERROR is still a failed fixture; expected failures are checked outside.
if re.search(r'\b(ERROR|FATAL|PANIC):', run.stderr):
sys.exit(1)verify_transactions.py
python
from pathlib import Path
import subprocess
import sys
here = Path(__file__).resolve().parent
runner = here.parent / 'common' / 'run_single.py'
# In a standalone copy, put run_single.py next to this verifier.
if not runner.exists():
runner = here / 'run_single.py'
p = subprocess.run([sys.executable, str(runner), str(here/'transactions.sql')],
capture_output=True, text=True)
text = p.stdout.replace('backend> ', '')
assert p.returncode == 1, (p.returncode, p.stderr)
assert 'violates check constraint "accounts_balance_check"' in p.stderr
for expected in [
'initial\n1|100\n2|100',
'after-rollback\n1|100\n2|100',
'after-commit\n1|70\n2|130',
'after-savepoint-recovery\n1|70\n2|131',
]:
assert expected in text, (expected, text)
assert '1|70\n2|131' in text
assert 'issued_inside_transaction' not in text # SELECT is emitted through COPY
assert 'sequence-after-rollback\n2' in text
print('rollback=ok commit=ok savepoint=ok conditional-debit=ok expected-error=ok sequence-gap=ok')