事务的特性 - ACID
一、 事务的特性 - ACID
ACID 是衡量一个数据库管理系统(DBMS)可靠性的四个关键特性,它们共同保证了数据库操作即使在系统故障或并发操作的情况下,也能保持数据的完整性和一致性。
-
原子性 (Atomicity)
- 定义: 事务是一个不可分割的工作单元。事务中的所有操作要么全部成功执行,要么全部失败回滚(就像什么都没发生过一样)。不存在部分成功的情况。
- 目标: 确保数据库状态从一个一致性状态原子地转移到另一个一致性状态。
- 通俗理解: “All or Nothing”。例如银行转账,不能只扣钱不加钱,或者只加钱不扣钱。
-
一致性 (Consistency)
- 定义: 事务执行前后,数据库必须处于一致性状态。这意味着事务必须遵守数据库定义的所有完整性约束(如主键约束、外键约束、唯一约束、检查约束、业务规则等)。
- 目标: 保证数据的正确性和有效性。
- 关键点:
- 事务的执行不能破坏数据库的完整性规则。
- 原子性、隔离性、持久性是实现一致性的手段。一致性是事务的最终目标。
- 一致性依赖于应用逻辑和数据库约束的定义。数据库系统负责检查定义的约束,但业务逻辑的约束需要程序员在代码中保证。
- 通俗理解: 转账前后,两个账户的总金额必须保持不变(业务规则约束)。
-
隔离性 (Isolation)
- 定义: 多个事务并发执行时,一个事务的执行不应影响其他事务的执行。每个事务都感觉不到有其他事务在同时执行。
- 目标: 防止并发事务因交叉执行而导致数据不一致。
- 关键问题: 完全隔离会影响性能。因此,数据库系统通常提供不同的隔离级别(Isolation Level)来在一致性和性能之间做权衡。常见问题包括:
- 脏读 (Dirty Read): 事务A读到了事务B未提交的修改。如果B回滚了,A读到的就是无效数据。
- 不可重复读 (Non-repeatable Read): 事务A在同一个事务内两次读取同一数据,结果不一致(因为事务B在中间修改并提交了该数据)。
- 幻读 (Phantom Read): 事务A在同一个事务内两次执行相同的查询,返回的结果集行数不一致(因为事务B在中间插入或删除了满足查询条件的行并提交了)。
- 通俗理解: 两个用户同时操作同一账户,彼此感觉不到对方的存在,操作结果互不影响(理想情况)。
-
持久性 (Durability)
- 定义: 一旦事务成功提交,它对数据库所做的修改就是永久性的。即使系统发生故障(如断电、崩溃),提交的数据也不会丢失。
- 目标: 确保提交的事务结果可靠地存储在持久性存储介质(通常是磁盘)上。
- 通俗理解: 转账成功后,钱确实到账了,服务器重启后钱也不会消失。
二、 如何实现 ACID 特性?
数据库管理系统通过复杂的机制来实现这些特性,核心组件包括:
-
原子性和持久性的关键: 日志 (Logging) - 尤其是 Write-Ahead Logging (WAL)
- Write-Ahead Logging (WAL) 原则: 在将数据页的修改写回磁盘之前,必须先将描述这个修改的日志记录写入持久化的日志文件。
- Redo Log (重做日志):
- 记录事务提交后对数据所做的物理修改(例如:将Page X的Offset Y的值从A改成B)。
- 作用:在系统崩溃后重启时,重放(Redo)所有已提交事务的修改,确保持久性。即使数据页本身还没来得及写回磁盘,也能根据Redo Log恢复。
- Undo Log (撤销日志):
- 记录事务修改前的数据旧值(例如:修改Page X的Offset Y之前的值是A)。
- 作用:
- 在事务回滚时,利用Undo Log将数据恢复到修改前的状态,实现原子性(回滚操作)。
- 在实现某些隔离级别(如读已提交)时,为活动事务提供一致性视图。
- 检查点 (Checkpoint):
- 定期将内存中的脏页(已修改但未写回磁盘的数据页)刷新到磁盘,并记录一个检查点标记。
- 作用:缩短崩溃恢复时间。恢复时只需要从最后一个检查点之后的日志开始重做或撤销。
-
隔离性的关键: 并发控制机制 (Concurrency Control)
- 锁机制 (Locking): 最常用的方法。
- 共享锁 (S Lock / Read Lock): 允许其他事务读,但不允许写。用于读取数据。
- 排他锁 (X Lock / Write Lock): 既不允许其他事务读,也不允许写。用于修改(插入、更新、删除)数据。
- 锁协议: 如两阶段锁协议 (2PL - Two-Phase Locking)。事务分为两个阶段:
- 增长阶段 (Growing Phase): 可以不断获得锁,但不能释放任何锁。
- 缩减阶段 (Shrinking Phase): 可以释放锁,但不能再获得任何新锁。
- 严格的2PL能保证可串行化(最高隔离级别),但可能导致死锁。现代数据库使用变种(如Strict 2PL, Rigorous 2PL)并配合死锁检测/超时机制。
- 多版本并发控制 (MVCC - Multi-Version Concurrency Control): 另一种流行且高效的机制(被PostgreSQL, MySQL InnoDB, Oracle等广泛采用)。
- 核心思想:当数据被修改时,保留其旧版本(快照)。读操作可以访问事务开始时存在的数据快照(或特定时间点的快照),而不需要阻塞写操作;写操作创建新版本。
- 优点:读操作通常不会被写操作阻塞,大大提高了读并发性能。
- 实现:需要维护数据的多个版本,通过版本号或时间戳来识别哪个版本对哪个事务可见。需要垃圾回收机制清理不再需要的旧版本。
- 不同隔离级别通过控制可见性规则(哪个版本对哪个事务可见)来实现。
- 锁机制 (Locking): 最常用的方法。
-
隔离级别的实现
- 不同的隔离级别通过调整锁的粒度/持有时间(锁机制)或调整快照的可见性规则(MVCC)来实现:
- 读未提交 (Read Uncommitted): 可以读到未提交的数据(脏读)。通常不实现任何读锁或读取最新的(可能未提交的)版本。
- 读已提交 (Read Committed):
- 锁机制: 读操作获取共享锁,但在读取完成后(甚至语句执行完)立即释放。这可能导致不可重复读(两次读之间锁被释放,数据被其他事务修改)。
- MVCC: 每个语句都看到在该语句开始执行时所有已提交的数据。一个事务内不同语句可能看到不同快照。
- 可重复读 (Repeatable Read):
- 锁机制: 读操作获取共享锁,并且持有直到事务结束。这可以防止不可重复读(因为锁一直占着,别人改不了),但可能导致幻读(锁通常只加在现有记录上,新插入的记录没有锁)。
- MVCC: 事务在整个过程中都看到在事务开始时所有已提交的数据快照。可以防止不可重复读,但标准的快照读无法防止幻读(新插入的行在快照中不存在)。一些数据库(如MySQL InnoDB)通过Next-Key Locking(间隙锁)在RR级别也防止了幻读。
- 可串行化 (Serializable):
- 锁机制: 最严格的锁协议(如严格2PL),通常涉及范围锁,不仅锁住现有记录,还锁住可能插入新记录的空隙(Gap Lock),彻底防止幻读。性能开销最大。
- MVCC: 有时通过类似锁的机制(如谓词锁)或在快照隔离基础上进行冲突检测(如PostgreSQL的可串行化快照隔离SSI)来实现真正的可串行化。
- 不同的隔离级别通过调整锁的粒度/持有时间(锁机制)或调整快照的可见性规则(MVCC)来实现:
三、 具体案例说明:银行转账
场景
用户A (账户ID: 1001, 余额: 1000元) 向用户B (账户ID: 2002, 余额: 500元) 转账200元。
事务操作 (伪SQL)
START TRANSACTION; -- 开始事务-- 操作1: 检查A账户余额 >= 200 (业务规则/一致性约束)
SELECT balance FROM accounts WHERE account_id = 1001 FOR UPDATE; -- 假设使用锁机制,加排他锁准备修改-- 操作2: 从A账户扣除200元
UPDATE accounts SET balance = balance - 200 WHERE account_id = 1001;-- 操作3: 向B账户增加200元
UPDATE accounts SET balance = balance + 200 WHERE account_id = 2002;COMMIT; -- 提交事务
-- 或者 ROLLBACK; -- 如果发生错误(如余额不足),则回滚事务
ACID 特性如何体现
-
原子性 (Atomicity):
- 如果所有步骤成功,
COMMIT
生效,A扣款200和B加款200同时发生。 - 如果在
操作2
之后、操作3
之前系统崩溃(或余额不足检查失败),整个事务会被回滚。操作2
的扣款效果会被撤销(利用Undo Log),A的余额恢复为1000元,就像转账从未开始过一样。不会出现A的钱扣了但B没收到的情况。
- 如果所有步骤成功,
-
一致性 (Consistency):
- 转账前: A(1000) + B(500) = 1500元。
- 转账后: A(800) + B(700) = 1500元。总和保持不变(业务规则约束)。
- 事务保证了账户余额不能为负数(
操作1
检查balance >= 200
)。如果A余额只有150元,操作1
检查失败,事务回滚,数据库状态保持不变(A=150, B=500),满足余额>=0的约束。 - 外键约束(如果accounts表有owner_id关联到users表)也会在更新时被检查。
-
隔离性 (Isolation):
- 假设在事务执行
操作1
给A账户加了排他锁(FOR UPDATE
)直到事务结束(可重复读或可串行化级别)。 - 此时另一个事务C试图查询或修改A账户的余额:
- 如果C是读操作(
SELECT balance ...
),在读已提交
级别下,如果C在A事务提交前读,会等待锁释放(锁机制)或读到旧值(MVCC),避免了脏读。 - 如果C是写操作(
UPDATE ... WHERE account_id = 1001
),必须等待A事务释放锁。这防止了A在转账过程中余额被其他事务修改,导致不可重复读或数据错误。
- 如果C是读操作(
- 如果没有隔离性,C可能在A扣款后但B加款前读到A的余额(800元),而此时B的余额还是500元,总和暂时是1300元(不一致状态),或者C可能同时修改A的余额造成错误。
- 假设在事务执行
-
持久性 (Durability):
- 一旦用户点击“确认转账”,事务成功
COMMIT
。 - 即使服务器在
COMMIT
成功后立刻断电崩溃,当服务器重启时,数据库会使用Redo Log重新执行(Redo)这个事务对A和B账户的更新操作。 - 用户再次登录后,会看到A账户800元,B账户700元。转账结果永久保存。
- 一旦用户点击“确认转账”,事务成功
并发问题示例(如果隔离性不足)
- 脏读 (假设隔离级别为读未提交):
- 事务A执行
操作2
(UPDATE A SET balance=800),但尚未提交。 - 事务B读取A的余额,得到800元。
- 事务A因某种原因回滚(余额恢复为1000)。
- 事务B基于读到的“800元”做了错误决策(比如认为A有钱,批准了另一笔贷款)。
- 事务A执行
- 不可重复读 (假设隔离级别为读已提交且使用锁机制):
- 事务B第一次读A余额:
SELECT balance ...
(得到1000元),然后释放读锁。 - 事务A执行转账:
UPDATE A SET balance=800
并提交。 - 事务B在同一个事务内第二次读A余额:
SELECT balance ...
(得到800元)。两次读取结果不一致。
- 事务B第一次读A余额:
- 幻读 (假设隔离级别为可重复读且使用锁机制,但未加间隙锁):
- 事务B查询余额小于600的账户:
SELECT * FROM accounts WHERE balance < 600
(返回B账户2002,余额500)。 - 事务A执行给新账户C(初始余额0)开户并存入100元(
INSERT INTO accounts ... balance=100
)并提交。 - 事务B在同一个事务内再次查询
balance < 600
,现在返回了账户2002 和 账户C。多出了一条记录(幻影行)。
- 事务B查询余额小于600的账户:
总结
事务的ACID特性是数据库可靠性的基石。通过日志机制(WAL, Redo, Undo) 主要保证原子性和持久性;通过并发控制机制(锁、MVCC) 和隔离级别配置来保证隔离性;而一致性则是最终目标,需要数据库系统提供的原子性、隔离性、持久性机制以及应用程序正确实现的业务逻辑共同来保证。银行转账案例清晰地展示了这些特性在实际应用中的必要性和运作方式。理解ACID及其实现机制对于设计和开发健壮、可靠的数据库应用至关重要。