数据库事务浅析
事务要解决的问题
在真实的数据库环境中可能有各种出错的情况,例如:
- 数据库软件可能随时失效,包括正在执行写操作的过程中。
- 应用程序可能随时会崩溃,包括正处于执行某个操作的中间状态。
- 机器随时可能宕机。
业务逻辑往往有多步操作,比如 A 向 B 转账时,至少需要两个步骤,先从 A 账户中扣减金额,再给 B 账户增加金额,如果中途出现了错误,那么最后的结果可能是 A 账户钱变少了,而 B 账户却没有收到钱,这对于用户来说是不可接受的。
而要解决这种问题,数据库事务为我们提供了基本的可靠性保证,事务将应用程序的多个读、写操作捆绑在一起成为一个整体,一个事务要么成功(提交),要么失败(终止或回滚)。
事务的 ACID
事务所提供的安全保证即大家所熟知的 ACID,分别代表原子性(Atomicity)、一致性(Consistency)、隔离性(Isolation)、持久性(Duration)。
原子性(Atomicity)
事务被视为不可分割的最小单元,事务的所有操作要么全部提交成功,要么全部失败回滚。
如果事务已经终止,应用程序可以确认实质没有发生任何改变,所以可以安全地重试。
一致性(Consistency)
数据库在事务执行前后都保持一致性状态。在一致性状态下,所有事务对同一个数据的读取结果都是相同的。
这里的一致性主要是指对数据特定的预期状态,任何数据更改必须满足这些状态约束。比如对于上面转账的例子,转账前后要保证双方增减的金额相同,也可以说是系统的总金额在转账前后是一致的。
尽管数据库中可以定义约束、触发器来让数据库自身做一些一致性约束等(其实现在基本上已经不使用约束、触发器了),但是一致性更多的是要求应用层自身来维护状态一致。
隔离性(Isolation)
一个事务所做的修改在最终提交以前,对其它事务是不可见的。
大多数数据库都支持多个客户端同时访问,如果同时访问相同的记录,则可能出现数据竞争的情况。隔离性意味着并发执行的多个事务相互隔离,这意味着可以假装它是数据库上唯一运行的事务。虽然它们可以同时执行,但数据库系统要求当事务提交时,其结果与串行执行完全相同。
持久性(Duration)
事务应当保证所有成功被提交的数据修改都能够正确地被持久化,即使存在硬件故障或数据库崩溃,也能保证不丢失数据。
事实上,ACID 四种特性并不是平级关系,一致性是最基本的属性,只有实现了原子性(A)、隔离性(I)、持久性(D),才能保证一致性(C)。
实现原子性与持久性
由于写入的中间状态和系统崩溃都是无法避免的,为了保证原子性和持久性,只能在崩溃后采取补救措施,这种数据恢复操作被称为“崩溃恢复”(Crash Recovery,也有资料称作 Failure Recovery 或 Transaction Recovery)。
实现原子性主要靠的是日志,对数据库的所有更新操作都写入日志,如果事务执行中途发生系统崩溃,则可以通过回溯日志,将已经执行的操作撤销。这里的日志一般有两种分别是 redo 日志和 undo 日志,最常见的场景是,数据库系统崩溃后重启,此时数据库处于不一致的状态,必须先执行一个崩溃恢复的过程:
分析阶段(Analysis):该阶段从最后一次检查点(Checkpoint,可理解为在这个点之前所有应该持久化的变动都已安全落盘)开始扫描日志,找出所有没有 End Record 的事务,组成待恢复的事务集合,这个集合至少会包括 Transaction Table 和 Dirty Page Table 两个组成部分。
重做阶段(Redo):该阶段依据分析阶段中产生的待恢复的事务集合来重演历史(Repeat History),具体操作为:找出所有包含 Commit Record 的日志,将这些日志修改的数据写入磁盘,写入完成后在日志中增加一条 End Record,然后移除出待恢复事务集合。
回滚阶段(Undo):该阶段处理经过分析、重做阶段后剩余的恢复事务集合,此时剩下的都是需要回滚的事务,它们被称为 Loser,根据 Undo Log 中的信息,将已经提前写入磁盘的信息重新改写回去,以达到回滚这些 Loser 事务的目的。
实现隔离性
原子性仅能保证在单事务下的一致性,不能保证多事务并发操作的一致性,所以此时需要引入隔离性。要实现隔离性主要是靠加锁实现的。
现代数据库都提供了三种锁:
读锁(Read Lock,也叫作共享锁,Shared Lock,简写为 S-Lock、S 锁)
多个事务可以对同一个数据添加多个读锁,数据被加上读锁后就不能再被加上写锁,所以其他事务不能对该数据进行写入,但仍然可以读取。对于持有读锁的事务,如果该数据只有它自己一个事务加了读锁,允许直接将其升级为写锁,然后写入数据。
写锁(Write Lock,也叫作排他锁,eXclusive Lock,简写为 X-Lock、X 锁)
如果数据有加写锁,就只有持有写锁的事务才能对数据进行写入操作,数据加持着写锁时,其他事务不能写入数据,也不能施加读锁。
范围锁(Range Lock,在 MySQL 中叫间隙锁,Gap Lock)
对于某个范围直接加排他锁,在这个范围内的数据不能被写入。
在 MySQL 中还存在两种意向锁(Intention Locks),在原来的 X/S 锁之上引入了 IX/IS,IX/IS 都是表锁,用来表示一个事务想要在表中的某个数据行上加 X 锁或 S 锁。有以下两个规定:
锁的兼容关系如下:
- 任意 IS/IX 锁之间都是兼容的,因为它们只表示想要对表加锁,而不是真正加锁;
- 这里兼容关系针对的是表级锁,而表级的 IX 锁和行级的 X 锁兼容,两个事务可以对两个数据行加 X 锁。
隔离级别
ISO 和 ANSI SQL 标准制定了四种事务隔离级别:
RAED UNCOMMITED:读未提交
事务中的修改,即使没有提交,对其它事务也是可见的。
READ COMMITED:读已提交
一个事务只能读取已经提交的事务所做的修改。换句话说,一个事务所做的修改在提交之前对其它事务是不可见的。
REPEATABLE READ:可重复读
保证在同一个事务中多次读取同一数据的结果是一样的。
SERIALIZABLE:可串行化
保证事务执行结果与串行执行结果相同,这样多个事务互不干扰,不会出现并发一致性问题。
各个隔离级别能解决的并发一致性问题:
| 脏读 | 不可重复读 | 幻读 |
---|
读未提交 | ❌ | ❌ | ❌ |
读已提交 | ✅ | ❌ | ❌ |
可重复读 | ✅ | ✅ | ❌ |
可串行化 | ✅ | ✅ | ✅ |
读未提交
级别对事务涉及的数据只加写锁,会一直持续到事务结束,但完全不加读锁。由于写锁禁止其他事务施加读锁,而不是禁止其他事务读取数据,所以读未提交可能会产生脏读的问题。读已提交
写操作会施加一个持续到事务结束的写锁,读操作会施加读锁,但是读取完成会立刻释放读锁,这样子就容易产生不可重复读的问题。可重复读
级别相较于读已提交
的区别在于读操作施加的读锁会持续到事务结束,这样就不会存在不可重复读的问题。因为当对某个数据加了读锁后,其他事务要想修改该数据必须获得写锁,而原来的事务的读锁未释放,所以该写锁必须等待读事务结束后才能更新数据.可串行化
提供了强度最高的隔离性。目前大多数提供可串行化的数据库都使用了以下三种技术之一:- 使用单线程,严格按照串行顺序执行
- 两阶段加锁(这是目前大多数数据库的选择)
- 乐观并发控制技术,例如可串行化的快照隔离(PostgreSQL 9.1 之后支持),目前该技术仍需在实践中证明其性能。
参考资料
- 『浅入浅出』MySQL 和 InnoDB
- 《凤凰架构》:本地事务
- 数据库系统原理
- 《数据密集型应用系统设计》:事务