数据库 ACID
数据库 ACID
简介
ACID 是关系型数据库事务处理的四个核心属性:原子性(Atomicity)、一致性(Consistency)、隔离性(Isolation)和持久性(Durability)。这四个属性共同保证了即使在并发写入、系统崩溃、网络分区等极端条件下,数据库中的数据依然正确可靠。
理解 ACID 不仅要知道每个字母的含义,更要明白在并发写入和故障恢复场景下,这四个属性如何协同保证数据正确性。ACID 不是孤立的概念,它们之间相互依赖:没有持久性,原子性在崩溃后无法保证;没有隔离性,并发事务的一致性会被破坏。
ACID 的现实意义
在以下场景中,ACID 是业务正确性的底线:
- 金融转账:A 向 B 转 1000 元,要么都成功,要么都失败,不能出现 A 扣了钱但 B 没收到
- 电商下单:扣库存 + 创建订单 + 记录流水,三步必须全部成功或全部回滚
- 库存扣减:并发扣减库存时不能超卖,隔离性保证每个事务看到一致的数据
- 系统崩溃:断电后重启,已提交的事务不能丢失,未提交的事务不能残留
四大属性详解
原子性(Atomicity)
原子性保证事务中的所有操作要么全部成功,要么全部回滚,不存在部分完成的中间状态。就像化学中的原子一样,事务是最小的执行单位,不可分割。
InnoDB 实现机制:Undo Log(回滚日志)
原子性的实现原理:
事务开始
↓
修改数据页前 → 写入 Undo Log(记录旧值)
↓
修改 Buffer Pool 中的数据页
↓
提交 → 标记 Undo Log 为可清理
回滚 → 根据 Undo Log 恢复旧值
Undo Log 示例:
操作:UPDATE accounts SET balance = 800 WHERE id = 1
Undo Log 记录:{trx_id=100, page_no=5, offset=120, old_value=1000}
回滚时:根据 Undo Log 将 balance 恢复为 1000-- 原子性演示
CREATE TABLE accounts (
id INT PRIMARY KEY,
name VARCHAR(50),
balance DECIMAL(12, 2) NOT NULL
);
INSERT INTO accounts VALUES (1, 'Alice', 1000), (2, 'Bob', 500);
-- 转账事务:A 向 B 转 300 元
START TRANSACTION;
-- 步骤 1:扣减 A 的余额
UPDATE accounts SET balance = balance - 300 WHERE id = 1;
-- 步骤 2:增加 B 的余额
UPDATE accounts SET balance = balance + 300 WHERE id = 2;
-- 如果步骤 2 失败(如 B 的账户被冻结):
ROLLBACK;
-- Undo Log 确保 A 的余额被恢复为 1000
-- 全部成功才提交
COMMIT;一致性(Consistency)
一致性保证事务执行前后,数据库从一个一致状态转换到另一个一致状态。一致性是最复杂的一个属性,因为它不仅依赖数据库的约束机制(主键、外键、CHECK、唯一约束),还依赖业务逻辑的正确性。
关键点:一致性不是数据库单方面能保证的,它是数据库约束和业务逻辑共同作用的结果。
-- 数据库层面的约束保证一致性
CREATE TABLE orders (
order_id BIGINT PRIMARY KEY,
customer_id BIGINT NOT NULL,
amount DECIMAL(12, 2) NOT NULL CHECK (amount > 0),
status VARCHAR(20) NOT NULL CHECK (status IN ('pending', 'paid', 'shipped', 'cancelled')),
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (customer_id) REFERENCES customers(id)
);
-- NOT NULL 约束:确保必要字段不为空
-- CHECK 约束:确保金额为正数、状态为合法值
-- FOREIGN KEY 约束:确保客户存在
-- 唯一约束:确保订单号不重复
-- 业务层面的逻辑也需要保证一致性
-- 例如:转账时检查余额是否足够
START TRANSACTION;
SELECT balance FROM accounts WHERE id = 1; -- 查询余额
-- 应用层判断:如果余额 < 转账金额,则回滚
UPDATE accounts SET balance = balance - 300 WHERE id = 1;
COMMIT;隔离性(Isolation)
隔离性保证并发事务之间互不干扰。SQL 标准定义了四种隔离级别,从低到高:
| 隔离级别 | 脏读 | 不可重复读 | 幻读 | 性能 |
|---|---|---|---|---|
| READ UNCOMMITTED | 可能 | 可能 | 可能 | 最高 |
| READ COMMITTED | 不可能 | 可能 | 可能 | 高 |
| REPEATABLE READ | 不可能 | 不可能 | 可能* | 中 |
| SERIALIZABLE | 不可能 | 不可能 | 不可能 | 最低 |
*InnoDB 在 RR 级别通过 MVCC + Next-Key Lock 在很大程度上防止了幻读。
-- 查看当前隔离级别
SELECT @@transaction_isolation;
-- 设置隔离级别
SET SESSION TRANSACTION ISOLATION LEVEL READ COMMITTED;
SET SESSION TRANSACTION ISOLATION LEVEL REPEATABLE READ;
SET SESSION TRANSACTION ISOLATION LEVEL SERIALIZABLE;
-- 脏读演示(READ UNCOMMITTED 级别)
-- 会话 A:
SET SESSION TRANSACTION ISOLATION LEVEL READ UNCOMMITTED;
START TRANSACTION;
UPDATE accounts SET balance = 800 WHERE id = 1; -- 修改但未提交
-- 会话 B:
SELECT balance FROM accounts WHERE id = 1; -- 读到 800(脏读!)
-- 如果会话 A 回滚,会话 B 读到的就是无效数据
-- 不可重复读演示(READ COMMITTED 级别)
-- 会话 B:
SET SESSION TRANSACTION ISOLATION LEVEL READ COMMITTED;
START TRANSACTION;
SELECT balance FROM accounts WHERE id = 1; -- 第一次读:1000
-- 会话 A:
UPDATE accounts SET balance = 800 WHERE id = 1;
COMMIT;
-- 会话 B:
SELECT balance FROM accounts WHERE id = 1; -- 第二次读:800(不可重复读!)
-- 可重复读演示(REPEATABLE READ 级别)
-- 会话 B:
SET SESSION TRANSACTION ISOLATION LEVEL REPEATABLE READ;
START TRANSACTION;
SELECT balance FROM accounts WHERE id = 1; -- 第一次读:1000
-- 会话 A:
UPDATE accounts SET balance = 800 WHERE id = 1;
COMMIT;
-- 会话 B:
SELECT balance FROM accounts WHERE id = 1; -- 第二次读:1000(可重复读!)
COMMIT; -- 下次事务开始才会读到新值持久性(Durability)
持久性保证事务一旦提交,修改永久保存,即使系统崩溃也不丢失。
InnoDB 实现机制:Redo Log(重做日志)+ Doublewrite Buffer
持久性的实现原理(WAL 机制):
事务提交
↓
1. 将修改写入 Redo Log Buffer(内存)
↓
2. 根据 innodb_flush_log_at_trx_commit 设置刷盘:
- = 1:每次提交都 fsync Redo Log(最安全,默认)
- = 2:每次提交写入 OS 缓存,每秒 fsync
- = 0:每秒写入并 fsync
↓
3. 返回提交成功
↓
4. 后台线程异步将脏页刷入数据文件
崩溃恢复流程:
1. 从最近 Checkpoint 开始,重放 Redo Log(前滚)
2. 回滚未提交的事务(根据 Undo Log)
3. 数据恢复到一致状态-- 持久性关键配置
SHOW VARIABLES LIKE 'innodb_flush_log_at_trx_commit';
-- 1:每次提交都刷 Redo Log 到磁盘(推荐生产环境)
-- 2:每次提交写入 OS 缓存,每秒刷盘(可接受丢 1 秒)
-- 0:每秒刷盘(可接受丢 1 秒)
SHOW VARIABLES LIKE 'sync_binlog';
-- 1:每次提交都刷 Binlog 到磁盘(推荐生产环境)
-- 0:由 OS 决定何时刷盘
-- "双一"配置:最安全的持久性保证
-- innodb_flush_log_at_trx_commit = 1
-- sync_binlog = 1
-- 代价:每次提交需要两次 fsync,吞吐量下降约 20-30%应用层事务管理
C# 中使用事务保证 ACID
// EF Core 中使用事务
using var context = new AppDbContext();
using var transaction = await context.Database.BeginTransactionAsync(
IsolationLevel.RepeatableRead);
try
{
var accountA = await context.Accounts
.Where(a => a.AccountId == "A")
.FirstOrDefaultAsync();
if (accountA.Balance < 1000)
{
await transaction.RollbackAsync();
throw new InvalidOperationException("余额不足");
}
accountA.Balance -= 1000;
var accountB = await context.Accounts
.Where(a => a.AccountId == "B")
.FirstOrDefaultAsync();
accountB.Balance += 1000;
await context.SaveChangesAsync();
await transaction.CommitAsync();
}
catch (Exception ex)
{
await transaction.RollbackAsync();
// 记录日志,可能需要告警
_logger.LogError(ex, "转账事务失败");
throw;
}
// 分布式事务:跨多个数据库/服务
// 方案 1:2PC(两阶段提交)— 强一致但性能差
// 方案 2:TCC(Try-Confirm-Cancel)— 业务侵入大但灵活
// 方案 3:Saga — 最终一致性,适合长事务
// 方案 4:消息队列 + 本地消息表 — 最常用分布式场景下的 ACID 挑战
在微服务和分库分表架构下,单机 ACID 无法覆盖跨服务的事务一致性。
分布式事务方案对比:
方案 | 一致性 | 性能 | 复杂度 | 适用场景
-------------|--------|------|--------|----------
2PC | 强一致 | 低 | 低 | 跨库事务
TCC | 最终 | 高 | 高 | 金融转账
Saga | 最终 | 高 | 中 | 订单流程
消息队列 | 最终 | 高 | 中 | 异步解耦
Seata AT | 最终 | 高 | 低 | 通用场景
BASE 理论:
BA (Basically Available) — 基本可用
S (Soft State) — 软状态
E (Eventually Consistent) — 最终一致性
CAP 理论:
C (Consistency) — 一致性
A (Availability) — 可用性
P (Partition tolerance) — 分区容错
三者只能同时满足两个,分布式系统必须满足 P,因此只能选 CP 或 APMVCC 实现原理
InnoDB MVCC 详解
-- MVCC(Multi-Version Concurrency Control)是 InnoDB 实现并发控制的核心机制
-- 通过隐藏列、Undo Log 版本链和 ReadView 实现无锁读
-- 1. 每行数据有两个隐藏列:
-- trx_id — 最后修改该行的事务 ID
-- roll_pointer — 指向 Undo Log 中该行的上一个版本
-- 2. Undo Log 版本链
-- 每次修改都会在 Undo Log 中保留旧版本
-- 通过 roll_pointer 形成版本链
-- 3. ReadView — 事务可见性判断
-- 活跃事务列表:生成 ReadView 时所有活跃事务的 ID 集合
-- 判断规则:
-- - trx_id < min_trx_id → 可见(事务已提交)
-- - trx_id >= max_trx_id → 不可见(事务在 ReadView 之后开始)
-- - min_trx_id <= trx_id < max_trx_id → 不在活跃列表中则可见
-- RC vs RR 的 ReadView 差异:
-- RC:每次 SELECT 都生成新 ReadView(总能看到最新已提交数据)
-- RR:只在第一次 SELECT 时生成 ReadView(整个事务看到一致快照)
-- 演示 MVCC 效果
-- 事务 A
BEGIN;
SELECT balance FROM accounts WHERE id = 1; -- balance = 1000
-- 事务 B(此时提交)
BEGIN;
UPDATE accounts SET balance = 900 WHERE id = 1; -- 扣减 100
COMMIT;
-- 事务 A 再次查询(RR 级别)
SELECT balance FROM accounts WHERE id = 1; -- 仍然看到 1000(快照读)
-- 事务 A 当前读
SELECT balance FROM accounts WHERE id = 1 FOR UPDATE; -- 看到 900(加锁读)
UPDATE accounts SET balance = balance + 50 WHERE id = 1;
SELECT balance FROM accounts WHERE id = 1; -- 看到 950事务隔离级别深入对比
-- 四种隔离级别的具体表现
-- 1. READ UNCOMMITTED — 脏读
-- 事务 A
BEGIN;
UPDATE accounts SET balance = 900 WHERE id = 1; -- 未提交
-- 事务 B(READ UNCOMMITTED)
BEGIN;
SELECT balance FROM accounts WHERE id = 1; -- 读到 900(脏数据!)
-- 事务 A 回滚
ROLLBACK;
-- 事务 B 读到的 900 是脏数据(实际不存在)
-- 2. READ COMMITTED — 不可重复读
-- 事务 A
BEGIN;
SELECT balance FROM accounts WHERE id = 1; -- 1000
-- 事务 B
BEGIN;
UPDATE accounts SET balance = 900 WHERE id = 1;
COMMIT;
-- 事务 A 再次查询
SELECT balance FROM accounts WHERE id = 1; -- 900(同一事务两次读取结果不同)
-- 3. REPEATABLE READ — 幻读(理论上可能,InnoDB 通过 Next-Key Lock 防止)
-- 事务 A
BEGIN;
SELECT * FROM accounts WHERE balance > 500; -- 返回 3 行
-- 事务 B
BEGIN;
INSERT INTO accounts (id, balance) VALUES (10, 800);
COMMIT;
-- 事务 A 再次查询
SELECT * FROM accounts WHERE balance > 500; -- 仍然返回 3 行(Next-Key Lock 防止了幻读)
-- 但以下场景可能产生幻读:
-- 事务 A
BEGIN;
SELECT * FROM accounts WHERE id = 5; -- 不存在
-- 事务 B
INSERT INTO accounts (id, balance) VALUES (5, 1000);
COMMIT;
-- 事务 A
UPDATE accounts SET balance = 999 WHERE id = 5; -- 更新成功!
SELECT * FROM accounts WHERE id = 5; -- 能看到 id=5(幻读)
-- 4. SERIALIZABLE — 完全串行化
-- 所有读操作都加共享锁,写操作加排他锁
-- 性能最差,极少使用
SET SESSION TRANSACTION ISOLATION LEVEL SERIALIZABLE;事务日志机制
Redo Log 与 WAL
WAL(Write-Ahead Logging)原则:
先写日志,再写数据
Redo Log 写入流程:
1. 修改数据页(在 Buffer Pool 中)
2. 写入 Redo Log Buffer
3. 事务提交时,Redo Log 刷盘(innodb_flush_log_at_trx_commit)
4. 数据页异步刷盘(Checkpoint 时)
Redo Log 配置:
innodb_log_file_size — 单个日志文件大小
innodb_log_files_in_group — 日志文件数量
innodb_log_buffer_size — 日志缓冲区大小
innodb_flush_log_at_trx_commit:
0 — 每秒刷盘(性能最好,可能丢失 1 秒数据)
1 — 每次提交刷盘(最安全,默认值)
2 — 每次提交写 OS 缓存,每秒刷盘(折中)Undo Log 与回滚
Undo Log 作用:
1. 事务回滚 — 撤销未提交的修改
2. MVCC — 提供数据的历史版本供快照读
3. 崩溃恢复 — 回滚未完成的事务
Undo Log 存储结构:
- 存放在 Undo 表空间(MySQL 8.0 支持独立 Undo 表空间)
- 按事务 ID 组织
- 过期 Undo Log 由 purge 线程回收
Undo Log 与性能:
- 大事务产生大量 Undo Log
- 长事务导致 Undo Log 无法回收(表膨胀)
- 监控:SELECT * FROM information_schema.innodb_trx;
-- 查看 Undo 空间使用情况
SELECT tablespace_name, file_name, current_size
FROM information_schema.innodb_undo_tablespaces;Checkpoint 与崩溃恢复
Checkpoint 作用:
1. 缩短崩溃恢复时间(只需要重放 Checkpoint 之后的 Redo Log)
2. 刷脏页到磁盘
Sharp Checkpoint — 刷新所有脏页(关闭数据库时)
Fuzzy Checkpoint — 异步刷新部分脏页(运行中)
Checkpoint 触发条件:
1. innodb_max_dirty_pages_pct — 脏页比例超过阈值
2. Redo Log 空间不足
3. 定时刷新(innodb_flush_log_at_timeout)
4. 执行 FLUSH TABLES WITH READ LOCK
崩溃恢复流程:
1. 读取最新的 Checkpoint LSN
2. 从 Checkpoint 开始重放 Redo Log(前进)
3. 回滚未完成的事务(后退,使用 Undo Log)
4. 恢复完成后数据库可用死锁处理
死锁检测与排查
-- 开启死锁检测
SET GLOBAL innodb_deadlock_detect = ON;
-- 设置锁等待超时
SET GLOBAL innodb_lock_wait_timeout = 50; -- 50 秒
-- 查看最近一次死锁信息
SHOW ENGINE INNODB STATUS;
-- 在 LATEST DETECTED DEADLOCK 部分查看:
-- - 事务 1 等待的锁
-- - 事务 2 等待的锁
-- - 持有锁和等待锁的资源信息
-- 死锁排查 SQL
SELECT * FROM information_schema.INNODB_LOCK_WAITS;
SELECT * FROM information_schema.INNODB_LOCKS;
-- MySQL 8.0
SELECT * FROM performance_schema.data_lock_waits;
SELECT * FROM performance_schema.data_locks;
-- 常见死锁场景:
-- 1. 两个事务以不同顺序更新同一组行
-- 事务 A: UPDATE t1 SET ... WHERE id=1; UPDATE t1 SET ... WHERE id=2;
-- 事务 B: UPDATE t1 SET ... WHERE id=2; UPDATE t1 SET ... WHERE id=1;
-- 解决:统一更新顺序(按 id 升序)
-- 2. 索引不同导致锁顺序不一致
-- 事务 A: UPDATE t SET ... WHERE idx_a = 'x'; -- 走 idx_a 索引
-- 事务 B: UPDATE t SET ... WHERE idx_b = 'y'; -- 走 idx_b 索引
-- 解决:确保更新走相同索引
-- 3. 外键检查导致死锁
-- 解决:考虑在应用层保证引用完整性,不用外键
-- 4. INSERT ... ON DUPLICATE KEY UPDATE 死锁
-- 多个并发事务插入相同唯一键
-- 解决:使用 INSERT IGNORE 或先 SELECT 再 INSERT死锁避免策略
-- 1. 按固定顺序访问表和行
-- 2. 保持事务简短(减少持有锁的时间)
-- 3. 使用低隔离级别(如 READ COMMITTED)
-- 4. 为查询使用合适的索引(减少锁定的行数)
-- 5. 设置合理的锁等待超时
-- 应用层死锁重试
public async Task<bool> TransferWithRetryAsync(
int fromId, int toId, decimal amount, int maxRetries = 3)
{
for (int i = 0; i < maxRetries; i++)
{
try
{
using var transaction = await _context.Database.BeginTransactionAsync();
// 执行转账逻辑...
await _context.SaveChangesAsync();
await transaction.CommitAsync();
return true;
}
catch (DbUpdateException ex) when (ex.InnerException is SqlException sqlEx
&& sqlEx.Number == 1205) -- 死锁错误码
{
_logger.LogWarning(ex, "死锁发生,重试第 {Retry} 次", i + 1);
await Task.Delay(100 * (i + 1)); // 递增等待
}
}
return false;
}优点
缺点
总结
ACID 是关系型数据库的基石,理解每个属性背后的实现机制(Undo Log、Redo Log、MVCC、锁)远比记住四个字母重要。在实际项目中,选择合适的隔离级别、控制事务粒度、避免长事务,才是用好 ACID 的关键。
关键知识点
- InnoDB 通过 Undo Log 实现原子性,通过 Redo Log 实现持久性
- MySQL 默认隔离级别是可重复读(RR),使用 MVCC + Next-Key Lock 防止幻读
- READ COMMITTED 级别下每次 SELECT 都生成新的 ReadView,REPEATABLE READ 级别下只在首次 SELECT 生成
- 分布式场景下,BASE 理论是 ACID 的补充,强一致性和最终一致性需要权衡
项目落地视角
- 事务粒度要尽量小:一个事务内不要做 RPC 调用、文件上传等耗时操作
- 隔离级别选择:大多数 Web 应用使用 READ COMMITTED 即可,减少锁冲突
- 长事务监控:定期排查
information_schema.innodb_trx中的长事务 - 分库分表后,跨库事务通过消息队列 + 本地事务表实现最终一致性
常见误区
- 认为开了事务就一定安全:如果业务逻辑本身有漏洞(如先查后改的竞态条件),事务也无法保证一致性
- 混淆一致性和隔离性:一致性是业务规则层面的约束,隔离性是并发事务之间的可见性规则
- 过度使用串行化隔离级别:串行化虽然最安全,但性能损失巨大,应优先通过优化索引和 SQL 避免并发问题
- 认为只读查询不需要关注隔离级别:READ UNCOMMITTED 下只读事务也会读到脏数据
进阶路线
- 深入学习 InnoDB 的 MVCC 实现原理:ReadView 机制、Undo Log 版本链
- 研究分布式事务方案:Seata、DTM 等框架的 AT/TCC/Saga 模式
- 了解数据库崩溃恢复流程:Checkpoint → Redo → Undo 的恢复流程
- 学习 Spanner/CockroachDB 等分布式数据库如何实现分布式 ACID
适用场景
- 金融交易、库存扣减等要求强一致性的核心业务
- 订单创建、状态流转等需要原子性保证的多步操作
- 多用户并发读写同一份数据的 OLTP 场景
落地建议
- 为核心业务表设计合理的约束(唯一索引、外键、CHECK 约束)配合事务使用
- 事务中只做数据库操作,外部调用放在事务之外
- 设置死锁检测
innodb_deadlock_detect=ON和合理的innodb_lock_wait_timeout
排错清单
- 检查
SHOW ENGINE INNODB STATUS中的 LATEST DETECTED DEADLOCK 部分 - 查询
information_schema.innodb_trx找出运行时间最长的事务 - 确认应用连接池配置是否合理,连接泄漏会导致事务无法释放
复盘问题
- 当前业务使用的隔离级别是什么?是否有场景需要调整?
- 最近一次线上死锁是什么原因?是否通过索引优化或调整 SQL 顺序解决了?
- 是否存在跨服务调用放在事务内部导致长事务的问题?
