一、为什么需要事务?
举个例子:
A 和 B 账户各有 50 元余额,现在 A 要给 B 转账 10 元,刨除掉具体的业务逻辑和工作流程,仅考虑数据库层面的操作:A 账户-10,B 账户+10 。
如果没有事务,就只能让两个 update 操作串行执行:
// 代码片段1
run("update account set balance=balance-10 where account_id=A");
run("update account set balance=balance+10 where account_id=B");
如图1,对于这个转账操作,我们的期望是:
- 代码片段1执行前,数据库数据:A 余额 50,B 余额 50。
- 代码片段1执行后,数据库数据:A 余额 40,B 余额 60。
但是,如果执行完 A-10 后,系统崩溃了,B+10 没能得到执行。则:
- 代码片段1执行前,数据库数据:A 余额 50,B 余额50。
- 代码片段1执行后,数据库数据:A 余额 40,B 余额50。
- 在此场景下,对 A 来说,这个转账操作是成功了的,毕竟钱都扣掉了,但 B 却没有收到对应的款项,也就是说从 A 账户扣掉的 10 块钱凭空消失了。这在实际业务中是不可接受的。
要解决这个问题,就必须有一套机制,来保证 A-10 和 B+10 这两个操作同时成功,或同时失败,以确保两个 update 操作执行前后的数据的一致性、正确性和完整性。
这套机制,就是“事务”。
二、事务定义
数据库事务 是可以作为一个完整的逻辑工作单元来执行的不可分割的一组操作,这些操作要么全部执行,要么全部不执行。
- 在关系型数据库中,事务可以是一条sql语句,也可以是多条sql语句。
- 在此基础之上,为了保证这一组操作执行结果的正确性和有效性,事务又附加了一些条件,就是ACID了。
ACID 的关注点和对事务的要求如下:
一致状态,是指数据处于一种语义上的有意义且正确的状态。
隔离性还有其他的称呼,如:并发控制、可串行化、锁等。
三、事务并发问题和隔离模式
并发访问场景下,若没有采取必要的隔离措施,会存在一些读写问题,包括:
- 3 类数据读问题:脏读、不可重复读和幻读。
- 2 类数据更新问题:第一类丢失更新、第二类丢失更新。
数据库提供不同级别的事务隔离模式,解决部分或全部上述的读写问题,SQL 规范定义了四种级别的隔离模式(级别由低到高):
- Read Uncommitted(读未提交):最低的隔离级别,什么都不需要做,一个事务可以读到另一个事务未提交的结果。所有的并发事务问题都会发生。
- Read Committed(读已提交):只有在事务提交后,其更新结果才会被其他事务看见。可以解决脏读问题。
- Repeated Read(可重复读):在一个事务中,对于同一份数据的读取结果总是相同的,无论是否有其他事务对这份数据进行操作,以及这个事务是否提交。可以解决脏读、不可重复读。
- Serialization(串行化):事务串行化执行,最高隔离级别,牺牲系统的并发性,将所有事务串行执行。可以解决并发事务的所有问题。
事务的隔离级别和数据库并发性是成反比的,隔离级别越高,并发性越低。
四、ACID的实现技术
ACID 的实现技术包括:并发控制、日志管理、备份恢复、锁管理、MVCC等内容。
其中,并发控制和日志技术是核心。
4.1 并发控制技术
并发控制技术是实现原子性、一致性和隔离性的重要技术之一。并发控制的本质就是要对并发的事务实现正确又高效的调度。
从实现思想的角度看,并发控制技术分两类:
- 乐观并发控制,Optimistic Concurrency Control,OOC,事后检查。
- 悲观并发控制,Pessimistic Concurrency Control,PCC,提前预防。
从实现技术角度,并发控制机制有如下几类:
- 基于锁的并发控制机制
基于锁的并发控制机制是最常见的一种并发控制机制,事务中可能涉及到的一些锁的概念如下图:
- 基于时间戳/数据版本的并发控制
基于数据版本的并发访问控制,是通过给数据表加一个版本号或时间戳字段实现。
- 当读取数据时,将 version字段的值一同读出,数据每更新一次,对此 version 值加一。
- 当提交更新的时候,判断当前版本信息与第一次取出来的版本值大小,如果数据库表当前版本号与第一次取出来的 version 值相等,则予以更新,否则认为是过期数据,拒绝更新。
基于时间戳的并发控制类似,把版本号换成时间戳就行了。
- 基于MVCC的并发控制
MVCC(Multi-Version Concurrent Control),即多版本并发控制协议,是个行级锁的变种,它在普通读情况下避免了加锁操作,因此开销更低,同时在保证数据一致性的前提下,提供一种高并发的访问性能。
虽然不同数据库或数据库引擎对 MVCC 的实现不同,但通常都是实现非阻塞读,对于写操作只锁定必要的行:
- 第一种实现方式:将数据记录的多个版本保存在数据库中,当这些不同版本数据不再需要时,垃圾回收器回收这些记录。——PostgreSQL 和 Firebird/Interbase 采用。
- 第二种实现方式:只在数据库保存最新版本的数据,但是会在使用undo时动态重构旧版本数据。——Oracle 和 MySQL/InnoDB 采用。
4.2 日志技术
数据库的日志可以大体分为3类:binlog、redo log、undo log。
其中,binlog是Server层记录的日志, redo log 和 undo log 是数据库存储引擎层的日志。
大部分关系型数据库系统是通过 redo log 和 undo log 来实现事务的原子性、一致性和持久性,同时也用于支持数据备份和恢复:
- redo log,记录数据被修改后的值,可以用来恢复未写入 data file 的已成功事务更新的数据。redo log 又包括:redo log buffer 和 redo log file,一个写内存,一个写硬盘。
- undo log,记录数据被修改前的值,可以用来在事务失败时进行 rollback。
举个例子,假设 A=1且 B=2,某事务 T 要做 A=3 和 B=4,则
- 事务简化过程:
1.start
2.A=1——>undo log
3.set A=3
4.A=3——>redo log buffer
5.B=2——>undo log
6.set B=4
7.B=4——>redo log buffer
8.redo log buffer——>redo log file
9.commit
- undo 和 redo 日志:
// undo日志:
<T,A,1>
<T,B,2>
// redo日志:
<T,A,3>
<T,B,4>
- 数据恢复(重做、撤销)
若执行 9 时出现系统异常,则下次启动时可以通过 redo log 重做该事务。
若执行 6 时出现异常,则可以通过 undo log 撤销已经做过的修改。 - undo/redo日志
也有把 undo 和 redo 结合起来的做法,叫做 Undo/Redo 日志,在前面中的例子
Undo/Redo 日志为:
<T, A, 1, 3>
<T, B, 2, 4>