图 1:本文封面,四个字母环绕数据库——它们各管一件事。
先说明属性:这是一篇经典题解析,题目出自本地历史面试资料,与任何公司的当前招聘无关。「事务的四个特性及含义」是数据库面试的必答题,人人会背 ACID 四个字母,但面试官想听的是:每个特性的语义边界画在哪里,背后靠什么机制兜底。
想象面试现场:「说说数据库事务的四个特性,各自保证什么?」
先给一句话总纲:一致性是目标,A、I、D 是手段。
四个特性不是并列的四根柱子。原子性、隔离性、持久性是数据库内核实打实提供的三种机制;一致性是这三者加上业务约束共同维持的最终状态——数据库从一个合法状态迁移到下一个合法状态。记住这层关系,后面每个特性该讲到什么深度就有谱了。
A,原子性:要么全做,要么全不做。
事务里的多个写操作是一个不可拆的整体:全部生效,或者全部回滚,不存在「扣了钱但没记账」的半截状态。兜底机制是 undo log——每次修改前先记下「改前的值」,回滚时逆序回放,把数据还原成事务开始前的样子。MySQL 官方文档把原子性相关功能列为三条:autocommit 设置、COMMIT、ROLLBACK——本质上都是「给这一组操作画出一个全有或全无的边界」。
I,隔离性:并发事务互不看见对方的中间状态。
多个事务同时跑,各自看到的数据视图互不干扰:你看不到我改了一半的数据,我也不会踩到你正在改的行。兜底机制有两套:锁,让冲突的写排队;MVCC 快照读,让读操作读一个一致的历史版本,读写不互等。隔离性强弱可调——四个隔离级别就是在「防并发异常」和「并发性能」之间换挡,级别细节这里不展开,记住「隔离性靠锁加快照,且有档位」就够答题了。
D,持久性:一旦提交,宕机也不丢。
事务提交返回成功的那一刻,数据就必须扛住断电和崩溃。兜底机制是 redo log:先写日志、再刷数据页(WAL,写前日志),宕机后靠重放日志把已提交的修改恢复出来。MySQL 官方文档把双写缓冲、innodb_flush_log_at_trx_commit 等列为持久性的相关配置——它们的共同点是把「提交成功」和「日志落盘」绑在一起。
C,一致性:前三者加业务约束的合力。
转账前后两个账户的总金额不变、库存不为负、外键不悬空——这些「合法状态」的定义一半在业务约束里(唯一键、外键、余额校验),一半靠 AID 保证执行过程不破坏它们。所以面试里被问「一致性靠什么保证」,正确答案不是某一种机制,而是「原子性、隔离性、持久性加约束共同保障」。
用一个迷你引擎把 A 和 D 跑一遍。
下面这份代码用 Node.js 实现了一个几十行的事务引擎:undo log 负责回滚、redo log 负责宕机重放,再加一个业务约束把门。用 Node.js 22.15.0 直接运行(node acid-demo.js)即可:
'use strict';
// 迷你事务引擎:用 undo/redo 两条日志演示原子性与持久性的分工
const db = { accounts: { A: 100, B: 50 } };
const undoLog = []; // 每个改动先记「改前的值」,用于回滚
const redoLog = []; // 提交时把「改后的值」落日志,宕机后重放
let committed = false;
function transfer(from, to, amount) {
undoLog.length = 0;
committed = false;
if (db.accounts[from] < amount) throw new Error('余额不足,事务拒绝执行');
undoLog.push({ key: from, before: db.accounts[from] });
db.accounts[from] -= amount;
undoLog.push({ key: to, before: db.accounts[to] });
db.accounts[to] += amount;
}
function commit() {
redoLog.push(...undoLog.map((e) => ({ key: e.key, after: db.accounts[e.key] })));
committed = true; // 先写日志再算提交成功:redo 是持久性的底
}
function rollback() {
for (let i = undoLog.length - 1; i >= 0; i--) {
const e = undoLog[i];
db.accounts[e.key] = e.before; // 逆序回放「改前的值」
}
}
// 场景一:转账中途「崩溃」,undo 把 A、B 都还原——原子性
try {
transfer('A', 'B', 30);
throw new Error('模拟中途崩溃');
} catch (e) {
rollback();
console.log(`回滚后:A=${db.accounts.A},B=${db.accounts.B}(应为 100/50)`);
}
// 场景二:提交后内存数据「丢失」,靠 redo 重放恢复——持久性
transfer('A', 'B', 30);
commit();
const crashed = { accounts: { A: 100, B: 50 } }; // 宕机后只剩旧快照
for (const e of redoLog) crashed.accounts[e.key] = e.after;
console.log(`重放 redo 恢复:A=${crashed.accounts.A},B=${crashed.accounts.B}(应为 70/80)`);
// 场景三:违反业务约束的事务根本不让执行——一致性靠约束把门
try {
transfer('A', 'B', 500);
} catch (e) {
console.log(`超额转账:${e.message},A 仍是 ${db.accounts.A}`);
}
实测输出:
回滚后:A=100,B=50(应为 100/50)
重放 redo 恢复:A=70,B=80(应为 70/80)
超额转账:余额不足,事务拒绝执行,A 仍是 70
三个场景对应三条线:undo 管「不做完就当没做过」,redo 管「做过的宕机也不丢」,约束管「不合法的根本不开始」。
图 2:四个特性的语义边界与各自的兜底机制。
常见误区与易错点。
最经典的误区是把一致性和隔离性混为一谈:隔离性管「并发时互不干扰」,一致性管「状态始终合法」,前者是后者的手段之一。其次是把原子性理解成「不失败」——原子性不保证事务成功,它保证失败时不留残骸。第三是把持久性等同于「写了磁盘」:真正的承诺绑定在提交与日志落盘的顺序上,配置成每次提交都刷盘才最稳,放宽配置就是在用持久性换性能。
这套语义在 AI 工程里同样要害:计费扣费必须原子,任务状态流转必须一致,配额变更必须扛住重启。分不清原子性与隔离性,对账系统和状态机里埋的都是雷。回到面试现场,建议的答题顺序:先抛「一致性是目标、AID 是手段」的总纲,再逐个讲语义加兜底机制,最后补一个转账实例。你被追问过「一致性和隔离性有什么区别」吗?当时怎么答的?欢迎分享。