这篇文章是ddia第七章的阅读笔记。
0x00 Intro
在一个系统中有很多可能出错的地方。
为了提高系统的可靠性,需要好好处理那些可能的错误。
事务(transaction)就是一个首选的机制。
这篇文章里主要看看事务。
0x01. Transaction
首先来看看,什么是事务,以及事务能给我们提供什么。
1.1 ACID的含义
事务所提供的安全保证就是熟知的ACID:
- 原子性:Atomicity
- 一致性:Consistency
- 隔离性:Isolation
- 持久性:Durability
但是好多数据库所实现的ACID并不相同,声称兼容ACID,但是我们不能确定它到底保证了什么。
不符合ACID的系统有时又被称为BASE,即:
- 基本可用性:Basically Available
- 软状态:Soft state
- 最终一致性:Eventual Consistency
BASE唯一可以确定的是,它不是ACID。
接下来逐个看看,ACID是什么。
1.1.1 Atomicity
原子,在计算机的不同领域内有一些差异。
首先在多线程编程中,有原子操作的概念。
这意味着,如果一个线程正在执行一个原子操作,那么其他的线程是看不到这个操作的中间结果的。
要么是这个操作之前的状态,要么是之后的状态。
而在ACID中的原子性,并不在乎多个操作的并发,它没有描述多个线程试图访问相同的数据会发生什么。
这其实是隔离性定义的。
ACID中的原子性,描述了一个事务中的多个写操作可能发生的情况。
如果事务成功,那么这个事务的所有写操作都执行成功;否则全部失败,部分成功的也要丢弃。
因此,ACID中原子性的保证是:在出错时终止事务,并将部分成功的写入全部丢弃。
1.1.2 Consistency
同样,一致性在不同的场景中有不同的含义:
- 在复制中的副本一致性和异步复制模型中,有最终一致性问题(Problems with Replication Lag);
- 在动态分区再平衡中,有一致性哈希(Consistent Hashing);
- 在CAP理论中,一致性用来表示线性化(Linearizability);
- 而在ACID中,一致性主要指数据库处于应用程序所期待的预期状态(good state)。
处于应用程序所期待的预期状态,这意味着为了达到一致性,需要应用程序的配合,而不仅仅是数据库的责任。
因为预期状态的定义,需要应用程序来确定。
有些状态在数据库看来完全合理,但在应用程序看来却不一定符合它的预期。
符合一致性,就是说如果某个事务是从一个有效的状态开始的,那么事务结束后,结果依然是有效的。
有效的,就是符合应用程序的预期。
可以看到,一致性更多的是应用程序的属性,而不像原子性、隔离性和持久性一样是数据库的属性。
1.1.3 Isolation
数据库的相同数据被多个客户端同时访问,就可能因为带来竞争条件而有问题。
比如下面的一个计数器的例子:
由于竞争条件,本来应该是44,结果却是43。
ACID中的隔离性意味着并发执行的多个事务相互隔离,互不交叉。
隔离性也被定义成可串行化(serializability),就是并行执行的结果和串行执行的结果一样。
1.1.4 Durability
数据库的本质就是提供一个安全可靠的地方来存储数据而不用担心数据丢失。
持久性的保证就是,一旦事务提交成功,事务所写的数据不会消失。
对于单个节点,数据写入非易失性存储设备就满足了持久性。
但是对于支持复制的多节点数据库,持久性意味着数据成功复制到多个节点。
1.2 Single-Object and Multi-Object Operations
在ACID的原子性和隔离性中,涉及到的都是同一个事务的多个写操作。
原子性保证这些写操作要么都成功要么都失败,而隔离性保证在这些写操作的执行过程中其余的事物看不到中间的执行状态。
为什么需要在同一个事务中进行多个写操作?
为了状态在多个对象中进行同步。
比如下图的电子邮件的例子:
一个事务读取了另一个事务的中间结果,导致数据不一致。
隔离性可以避免这种问题。
1.2.1 单对象写入
原子性和隔离性也适用于但对象的写入,比如一个20K的json文件,原子性和隔离性保证了数据不会部分写入以及其余事务读取部分数据。
对于但对象,数据库还可以提供高级的原子操作,比如原子自增,这就不需要read-modify-write了。
还可以添加原子比较-设置操作,compare-and-set,CAS操作。
这可以成为轻量级事务,一般来说,事务针对的是多个对象。
1.2.2 错误处理与中止
当对多个对象进行操作的时候,就可能会出现各种各样的并发问题。
ACID数据库对于这些错误的处理理念就是,如果违反了原子性、隔离性和持久性,那就放弃整个事务。
放弃之后然后重试,安全的重试机制。
但重试也有一些问题:
- 如果事务执行成功,但是返回时失败,那么重试就会导致重复执行;
- 如果由于系统超负荷导致失败,那么重试会使情况更糟;
- 临时性错误可以重试,但是永久性错误(比如违反约束)重试了也毫无意义;
- 如果在数据库外事务还有副作用,事务中止但是副作用已生效(比如发邮件),简单重试就不可行,可以使用两阶段提交;
- 客户端进程在重试过程中也发生失败,没有其他人继续负责重试,则那些待写入的数据可能会丢失。
0x02 Weak Isolation Levels
多个事务对同一个对象有读写操作时,就可能引入竞争条件而导致并发问题。
数据库通过事务隔离来对应用开发者隐藏内部的各种并发问题。
但隔离会影响性能,所以实现了较弱的隔离。
这些弱隔离可能会带来一些错误。
所以要深入了解一下各种弱隔离。
2.1 Read Committed
读提交是最基本的事务隔离级别,保证:
- 读数据库时,只看到已成功提交的数据(防止脏读);
- 写数据库时,只会覆盖已成功提交的数据(防止脏写)。
2.1.1 No Dirty Reads
什么是脏读:
如果事务已经部分写入,但是还没提交,那么另一个事务是否可以看到尚未提交的数据呢?是的话,就是脏读。
比如:
在用户1提交之前,用户2看到的还是2。
2.1.2 No Dirty Writes
两个事务同时更新相同的对象,那么后写入的会覆盖前面的。
如果先写入的事务尚未提交,那么后写入的是否会覆盖?是的话就是脏写。
读提交可以推迟第二个写直到第一个提交或中止。
比如下面的问题:
先写的Alice事务尚未提交,Bob就覆盖了,导致问题出现。
2.1.3 Implementing Read Committed
读提交是大多数数据库的默认配置。
为了防止脏写,可以使用行级锁。
为了防止脏读,也可以使用锁,但是读锁不太可行。
大多数数据库通过保存新旧两个版本的数据来避免脏读。
在事务提交之前,所以其他读操作都读取旧值;仅当写事务提交之后,才会切换到读取新值。
2.2 Snapshot Isolation and Repeatable Read
读提交避免不了下面的问题:
Alice在两个账户里分别500美元,然后从一个账户转账100到另一个账户。
在提交转账请求和银行执行转账请求之间,Alice读取了账户1,发现是500美元,然后在转账完成之后读取账户2,发现是400美元,少了100。
这种异常现象就是不可重复读(nonrepeatable read)或读倾斜(read skew)。
这种情况在读提交语义下是可接受的,因为Alice所看到的确实是账户当时的最新值。
不过如果Alice过一会再读的话就不会出现这个问题。
在一些场景中,是不能容忍这种暂时的不一致的:
- 备份场景:长达数小时的备份过程中可能会有额外的数据更新,如果备份中包含新旧数据,那就导致了不一致;
- 分析查询与完整性检查场景:分析查询与完整性检查都需要对一定时刻的数据进行读取并读取很长时间。
解决方案就是快照级别的隔离。想法就是,每个事务都从数据库的一致性快照中读取,事务一开始所看到的是最近提交的数据,即使数据随后可能被另一事务更改,但保证每个事务都只看到该特定时间点的旧数据。
MySQL的InnoDB,PostgreSQL,Oracle,SQL Server都支持快照级别的隔离。
2.2.1 实现快照级别的隔离
和防止脏读的实现类似,为了多个事务读取不同时间点的数据,需要保留多个版本。
这就是多版本并发控制(Multi-Version Concurrency Control,MVCC)。
典型的做法是,在读提交级别下,对每个不同的查询单独创建一个快照,而快照级别隔离则是使用一个快照来运行整个事务。
下面的例子给出了基于MVCC的快照级别隔离的实现:
- 一个唯一的、单调递增的事务ID;
- 数据都有一个写入者的事务ID和一个删除者的事务ID(初始为空)。
2.2.2 一致性快照的可见性规则
由于有多个事务的多个版本数据,因此需要有一个规则来判断一个具体的事务可以看到哪些版本的数据:
- 事务开始的时刻,创建该对象的事务已经完成提交;
- 对象没有被标记为删除;或者标记了,但删除事务在当前事务开始时还没有提交。
其余的版本都是不可见的。
2.2.3 索引与快照级别隔离
那么,怎么支持索引呢?
一种方案是索引指向所有的版本,然后通过一定的规则过滤当前事务不可见的版本。
为了提升性能,可以将同一个对象的不同版本放在一个内存页面上。
CouchDB、DatomIC和MDB使用了另一种方法。主体是B-tree,但采用了追加/写时复制的技术,需要更新时,不会修改现有的页面,而是创建一个新的修改副本,然后让父节点(或递归到root)指向新创建的节点。
这种方式下,每个写入事务都会创建一个新的B-tree root节点,代表一个快照。这就没有必要使用事务ID了,不过需要后台进程来进行压缩和垃圾回收。
2.2.4 可重复读与命名
Oracle叫做可串行化。
PostgreSQL和MySQL叫做可重复读。
IBM DB2的可重复读其实就是可串行化级别隔离。
2.3 Preventing Lost Updates
接下来看一个更进一步的问题。
读提交和快照级别的隔离涉及到的是只读事务遇到并发写,没有两个写事务并发(脏写只是写并发的一个特例)。
当两个写事务并发时,可能会出现更新丢失问题。
出现更新丢失问题的场景:读取,修改,然后写回(read-modify-write)。比如:
- 递增计数器,或更新账户余额(读取当前值,计算新值并写回);
- 对复杂对象的一部分内容进行修改;
- 两个用户同时编辑wiki页面。
对这个问题有多个解决方案。
2.3.1 原子写操作
可以在数据库中支持原子写操作,避免应用层的读取修改写回。
通常是通过读取对象独占锁的方式来实现。
另一种方式是所有的原子操作在单线程上执行。
不过基于对象关系(ORM)框架很容易产生不安全的读取修改写回的应用层代码。
2.3.2 显式加锁
如果数据库不支持原子操作的话,可以在应用层对待更新的对象显式加锁。
这样当有其他事物要对相同的对象更新时必须等到前一个事务完成。
2.3.3 自动监测更新丢失
原子操作和显式加锁都是强制“读取-修改-写回”操作序列串行化来防止更新丢失的。
还有一种思路就是先让它们并行执行,当检测到更新丢失风险后,中止点前事务,退回到安全的读取、修改和写回操作。
这种方式的一个优点是可以通过快照级别隔离来高效地检测更新是否丢失。
但是不是所有的数据库都支持,PostgreSQL、Oracle都支持自动监测更新丢失,而MySQL的InnoDB却不支持。
2.3.4 原子比较和设置
CAS:Compare and Set。
只有在上次读取的数据没有发生变化的时候才会更新;如果已经发生了变化,就退回到“读取-修改-写回”的方式。
2.3.5 冲突解决与复制
如果支持复制,那么不同节点可能并发修改数据。
原子操作和加锁的前提是只有一个最新的数据副本,但是对于多主节点和无主节点的多副本数据库,通常支持多个并发写,并以异步的方式来同步更新。
这时原子操作和加锁就不适用了。
如果并发写发生冲突,可以将冲突保存起来,来避免更新丢失。
对于最后写入获胜(Last Write Wins)来说,会丢失数据。
2.4 Write Skew and Phantoms
多事务并发写还可能带来更微妙的问题,就是写倾斜和幻读。
比如下面的例子就是一个写倾斜:
两个医生同时查询当前是否有人值班,有的话就自己请假。
但是写倾斜导致最终没有一个人值班。
2.4.1 什么是写倾斜
这不是脏写,也不是更新丢失,因为这里更新的是两个不同的对象。
这是一个广义的更新丢失问题。
两个事务读取相同的一组对象,然后更新其中一部分,不同的事务可能更新不同的对象,就可能发生写倾斜。
防止写倾斜,需要更严格的隔离级别,即可串行化隔离。
2.4.2 更多的写倾斜
还有更多的例子:
- 会议室预定;
- 多人游戏;
- 声明同一个用户名。
2.4.3 为何产生写倾斜
写倾斜的模式:
- 使用SELECT查询所有满足条件的行;
- 根据查询结果来决定下一步操作;
- 如果继续执行,很可能是数据写入。
如果一个事务执行完第2步,在第3步执行前,另一个事务进行了修改,导致第2步的条件不再成立了,那么第3步的写入就会有问题。
在一个事务中的写入改变了另一个事务的查询结果,叫做幻读。
快照级别隔离可以防止只读事务的幻读,但是对于读写事务,就无能为力了。
2.4.4 实体化冲突
实体化冲突的想法是,如果查询结果中没有对象,那么加锁也不可行。可以实例化一些对象,然后就可以加锁了。
比如会议室预定,构造一个时间-房间表,每一行对应一个房间特定时间的记录。
0x03 Serializability
可串行化(Serializability)隔离是最强的隔离级别。
先来看看,什么是可串行化隔离。
3.1 Actual Serial Execution
解决并发问题的最好办法是避免并发。
这就是实际串行执行,比如redis。
3.1.1 采用存储过程封装事务
将一个行为定义成一个存储过程,然后封装成事务。
不过这依赖用户的输入,如果用户反应很慢或长时间不输入,就会导致事务耗时特别长,导致吞吐量低。
比如在redis中,可以定义一个lua脚本传到redis中执行,就是一个存储过程。
但是不推荐使用存储过程。
3.1.2 分区
串行执行可行,但是性能有限制。
可以通过分区来扩展多个核和CPU。
然后每个分区都可以启动一个执行线程,每个线程进行串行化处理。
不过对于跨区的事务,就需要分区之间的协调了。
3.2 两阶段加锁
两阶段加锁(two-phase locking,2PL)是一种广泛的串行化算法。
加锁可以防止脏写。
两阶段加锁类似,多个事务可以同时读一个对象,但是只要有写操作,就需要获取独占锁来访问:
- 如果事务A读取了某个对象,事务B想要修改这个对象,那么B必须等待A提交或中止;
- 如果事务A已经修改了某个对象,事务B想要读取,B必须等待A提交或中止。
快照级别隔离,是“读写互不干扰”,而两阶段加锁,是“读写互斥”。
3.2.1 实现两阶段加锁
MySQL的InnoDB的可串行化隔离实现了2PL。
可以通过读写锁来实现:
- 读锁可以共享,读取的时候需要获取读锁;
- 写锁只能独占,修改数据需要获取写锁;
- 如果先获取读锁,然后想写入,就需要升级成写锁;
- 事务获取锁之后,一直锁定到事务结束。
两阶段:事务执行前先获取锁,事务结束时释放锁。
3.2.2 两阶段加锁的性能
性能差是两阶段加锁的主要特点。
因为只要获取独占锁,其余的事务必须等待。
3.2.3 谓词锁
可串行化隔离需要避免写倾斜和幻读。
以会议室预定为例,如果一个事务在查询某个房间某个时间的预定情况,那么另一个事务肯定是不能修改这个房间在这个时间的预定的。但是可以修改其余房间的预定情况和这个房间其余时间的预定情况。
可以通过谓词锁(predicate locks)来实现。
谓词锁和前面的共享/独占锁类似,只不过谓词锁不属于特定的对象,而是属于满足某些条件的所有对象:
SELECT * FROM bookings
WHERE room_id = 1234 AND
end_time > '2020-07-23 12:00:00' AND
start_time < '2020-07-23 16:00:00';
甚至对象不存在也在谓词锁的作用之内。
谓词锁与两阶段加锁结合使用,隔离可以变得真正可串行化。
3.2.4 索引区间锁
但是谓词锁性能不佳。
索引区间锁的想法是,扩大加锁的范围。
比如上面的查询使得满足房间号和时间限制的对象才加锁,那么可以扩大到指定房间的所有时间段来加锁。
这样其余事务进行查询时,肯定会使用到某个索引,如果和前面已经加锁的产生冲突,就必须等待。
3.3 serializable Snapshot Isolation
串行化隔离性能太差了。
主要是只要有写操作就加锁。
这是一种悲观锁。
为了提高性能,可以使用乐观锁。
这就是可串行化的快照隔离(Serializable Snapshot Isolation,SSI)的想法。
3.3.1 悲观与乐观的并发控制
两阶段加锁是一种悲观并发控制。
可串行化的快照隔离是一种乐观并发控制。这时,如果可能发生冲突,事务会继续执行而不是中止,当确实发生冲突了,才会中止并重试。
SSI基于快照隔离,在快照隔离的基础上,SSI增加了算法来检测冲突。
在写倾斜和幻读中,基于查询条件来执行下一步,但是可能另一个事务的修改改变了查询条件。
所以数据库需要知道一个事务是否会改变另一个事务的查询结果,这样可以判断是否会发生冲突。
3.3.2 检测是否读取了过期的MVCC对象
快照隔离通常使用MVCC来实现。
在读取快照时,会忽略创建快照时尚未提交的事务写入。比如:
3.3.3 检测写是否影响了之前的读
在读数据后,另一个事务修改了数据:
3.3.4 可串行化快照隔离的性能
与两阶段加锁相比,可串行化快照隔离不需要事务等待其他事务的锁。
SSI更能容忍那些执行缓慢的事务。
0x04 Summary
隔离级别的要点:
Isolation Level | Dirty Read | Non-repeatable read | Phantom Read |
---|---|---|---|
READ UNCOMMITTED | Possible | Possible | Possible |
READ COMMITTED | Not Possible | Possible | Possible |
REPEATABLE READ | Not Possible | Not Possible | Possible |
SERIALIZABLE | Not Possible | Not Possible | Not Possible |
一些问题的解决方法:
Problem | Solution |
---|---|
Dirty Read | Read Committed |
Dirty Write | Lock |
Read Skew (Non-repeatable read) | Snapshot Isolation and Repeatable Read MVCC |
Lost Updates | Snapshot Isolation and Repeatable Read MVCC |
Write Skew | Serializability |
Phantoms | Serializability |
实现可串行化隔离的三种方式:
- 严格串行化执行
- 两阶段加锁(2PL)
- 可串行化的快照隔离(SSI)