数据库作为一种特定的软件有许多可能出错的情况:
- 数据库软件和硬件随时崩溃
- 连接数据库的客户端随时崩溃
- 与数据库的联结随时中断
- 多个客户端同时写入数据库导致的数据覆盖
- 以及各类由于边界引起的问题
为解决各种可能出错的状况,事务应运而生,事务指将应用程序的多个读、写操作绑定在一起的逻辑单元,整个事务要么全部成功,要么全部失败。
事务不一定是必要的,也不一定需要功能完全的事务机制,事务的实现也有很大的代价。
深入理解事务
几乎所有关系型数据库和部分非关系型数据库支持事务,一方面事务对于金融等高价值数据非常有意义,另一方面事务会极大的影响系统的可用性和性能。
ACID的含义
ACID:原子性(Atomicity)、一致性(Consistency)、隔离性(Isolation)、持久性(Durability)
不符合ACID的标准有时称为BASE,即基本可用(Basically Available)、软状态(Soft State)、最终一致性(Eventually Consistency)
原子性
原子性:事务中的执行全部执行或不执行
ACID中的原子性是无关并发的,仅是针对数据库、客户端、连接崩溃的问题,原子性或许称为可中止性更合适。具有原子性的事务将在失败时丢弃所有操作。
一致性
一致性:对数据的修改需要满足一定的状态约束,例如需保持转账前后总和相等
一致性的概念与原子性、隔离性和持久性有不同,一致性是应用层的要求,而其他三者更多的是数据库自身的属性,ACID中加入一致性一部分原因是为了顺口。
隔离性
隔离性:并发执行的多个事务应当相互隔离,不能互相交叉
经典的数据库教程将隔离定义为可串行化,但是实践中,可串行化会带来极大的性能损失。
持久性
持久性:保证事务提交后,即便存在硬件或软件故障,事务所写入的数据也不会消失
对于单数据库系统,持久性意味着数据写入磁盘,对于支持复制的数据库,持久性代表着数据复制到其他节点。
单对象与多对象事务操作
对单个对象和对多个对象的写入都会有事务的需求,都需要原子性和隔离性。
单对象写入
如果对于单个节点的存储引擎进行写入,原子性操作可以通过日志恢复、原子自增等操作,隔离性可以通过加锁的方式实现。
多对象事务的必要性
如果存在对于多个分区的数据进行修改,或者对多个表格进行修改时,多对象事务也有其必要性。
处理错误与中止
事务的关键特性之一:如果事务的执行发生意外,那么之后可以安全重试。
弱隔离级别
只有一个事务修改数据而另一个事务读取这些数据,或者两个事务修改相同的数据时,才会存在并发问题。而数据库的隔离时假装没有发生并发,而可串行化意味着执行结果和串行执行一致。
但是可串行化对于性能的影响过大,因此常采用较弱的隔离,即弱隔离级别。
读-提交
读-提交时最基本的隔离级别,只保证不会出现以下两种问题:
- 脏写:写入尚未提交的数据
- 脏读:读取尚未提交的数据
防止脏读
脏读指一个事务无法看到尚未提交的数据,脏读的存在可能导致以下问题:
- 数据的部分更新:例如邮件列表更新,但邮件的计数器数值未更新
- 读取到过期数据:写入数据的事务回滚,导致读取被修改数据的事务读取到回滚之前的数据
防止脏写
脏写指一个事务修改了另一个事务修改但尚未提交的数据,脏写会导致不同事务的并发写入混杂在一起
实现读-提交
脏读、脏写的解决方式:
- 脏读:通常可通过数据版本机制解决
- 脏写:通常可通过行级锁解决
快照级别隔离与可重复读
不可重复读(读倾斜):事务两次读取相同的数据返回不同的结果
解决不可重复读的最常见手段为快照级别隔离,每个事务都从数据库的一致性快照中读取
实现快照级别隔离
为解决不可重复读,数据库采用了多版本并发控制机制(MVCC),该机制类似于为解决脏读设计的数据版本机制。在采用读-提交版本级别,为每一个查询创建一个快照,而采用可重复读级别时,仅在每个事务开始时创建一个快照,并在整个事务中至运行该快照。
MVCC的基本原理:给每个事务在开始时分配一个自增的事务ID,当进行数据写入时,在行的created_by字段记录事务的ID,在进行数据删除时,在行的deleted_by字段记录事务ID(墓碑的思想)。
一致性快照的可见性规则
MVCC会为每个事务分配一个ID,还要确定事务ID的可见规则,在每个事务开始时,都会获得一个活动的事务ID集合,则可见性规则大体为:
- 其他正在活动的事务不可见
- 已经中止事务修改不可见
- 较晚的事务的修改不可见
也就是满足以下条件时,某条数据才可见:
- 事务开始之前已经提交
- 未被标记为删除,或者即便被标记为删除,也是未提交事务标记的
索引与快照级别隔离
如何令多版本数据库支持索引,方法有两种:
- 索引直接指向数据的所有版本
- 每个写入事务(或一批事务)都会创建一个新的B-tree root,代表此时的一致性快照
可重复读与命名混淆
现在不同的数据库对于可重复读的定义很混乱,对于能提供的保证也大相径庭。
防止更新丢失
更新丢失:两个事务都对于某个数据进行读-修改-写回操作,但是后写回的未能包括先写回事务的值。
典型的更新丢失场景:
- 两个事务都执行递增计数器操作,或都执行账户扣款
- 两个用户同时修改某个文档的不同部分
脏读、不可重复读是两个事务分别执行读、写操作产生的,脏写、更新丢失是两个事务都执行写操作产生的。典型的解决更新丢失的方式包括原子写操作和显示加锁。
原子写操作
数据库提供原子操作的支持,通常采用对于对象加独占锁的方式实现。
显式加锁
由应用程序控制,对需要更新的对象加锁,例如
SELECT *
FROM students
WHERE score < 60
FOR UPDATE;
其中FOR UPDATE
用于显式加锁。
自动检测更新丢失
除此之外,数据库可以令多个事务并发执行,但是如果事务管理器检测到了丢失风险则会中止事务。
原子比较和设置
数据库提供原子性的“比较并设置”操作,在更新之前先比较现在数据是否和之前读取时一致。
冲突解决与复制
对于多节点的数据库,情况更为复杂,多个节点上可能具有不同副本,会导致并发的修改数据。
写倾斜与幻读
定义写倾斜
写倾斜:两个事务基于同一种判断对于不同数据进行修改。
例如,医院至少需要一个医生值班,现在有两个医生值班并位于值班表上,两个值班医生都查询总值班医生数,并发现大于1,则都将自己从值班表上删除,最终导致表上没有医生了。
脏写、更新丢失是不同事务对同一对象更新导致的,写倾斜是不同事务对不同对象更新导致的。
更多写倾斜的例子
会议室预定系统,多人游戏等
为何产生写倾斜
通常写倾斜满足以下的模式:
- 首先查询(SELECT)所有满足条件的行
- 应用程序基于以上结果进行决策
- 执行修改、插入、删除(UPDATE、INSERT、DELETE)
而第3步中的操作将影响第1步的查询结果,也就是前提被改变了。
可以认为更新丢失、写倾斜都是由于前提被改变导致的,只不过更新丢失改变的前提是单条数据,写倾斜改变的前提是范围查询结果。
而写倾斜现象中,一个事务的写入影响了另一个事务范围查询的结果的现象,称为幻读,写倾斜可以理解为幻读的延伸。
实体化冲突
以上问题的关键在于,无法对于某个查询结果为空时进行加锁(在我本人理解中,也是无法为某个查询结果为大于多少条加锁)。
基于该思路,可以将冲突实体化,例如对于会议室预订系统,不仅仅构建一个预定记录表(字段包括房间号、预定人、时间),还要构建一个房间号与时段的组合表(字段为房间号,时间),这样在后续预定事务中,先对于后者中的单条数据加锁,再修改预定记录表。实体化冲突将幻读问题转化为数据库中具体行的冲突问题。
串行化
可串行化保证事务即便是并发执行的,最终结果也和每次运行一个事务即串行执行一致,但可串行化仍然没有被广泛使用,目前可串行化主要依赖于三种技术之一:
- 严格按照串行顺序执行(实际的串行执行)
- 两阶段锁定
- 乐观并发控制技术
实行串行执行
真的串行执行各个事务,但是这个方法是2007年之后数据库设计人员才完全确定可以实现的,这是两方面的进展推动了实际串行执行的发展:
- 内存I/O非常迅速,且内存随着成本降低迅速增大
- 数据库开发人员意识到OLTP业务执行通常非常快
为了能够优化实际串行执行,事务也需要进行一些调整,例如将事务封装为存储过程。
采用存储过程封装事务
最初,事务需要等待人类做出的决定,后来,待人类决定完由客户端程序与数据库通信,但是仍然需要等待网络的延迟。出于减少人类决定延迟以及通信延迟的需要,将整个事务过程打包为存储过程。
存储过程的优缺点
存储过程在兼容性、设计难度、管理难度上存在一定的缺陷,但是也使得内存式数据库存储在单线程上执行所有事务变得可行。
分区
如果数据分散到多个区间,如果也需要可串行化则应当特殊处理。最好的方式是令每个事务只需要在一个分区执行事务,但是这个条件对于键/值型数据库较容易实现,对于带有二级索引的数据则较为麻烦。
串行执行小结
可串行化能够采用实际串行化隔离的条件如下:
- 事务必须简短高效
- 数据集完全可加载到磁盘
- 写入吞吐量足够低,否则就需要分区
- 跨分区事务应当占比较小
两阶段加锁
思想为多个事务可以同时读取同一对象,但是如果希望修改,则必须加锁独占。
实现两阶段加锁
在读取对象时加共享锁,在修改数据前加排他锁,事务持有锁直到事务结束。由于采用了较多的锁,易于产生死锁现象。
两阶段加锁的性能
两阶段加锁相比于弱隔离级别,性能下降很大,一部分因为锁获取和释放的开销,更重要的是严重影响了并发性。
谓词锁
为了解决幻读和写倾斜,可以采用谓词锁,谓词锁不属于特定的对象,而是属于满足某个查询条件的所有对象。谓词锁作用于查询条件而非数据。
谓词锁性能不好。
索引区间锁
为了解决幻读和写倾斜,大部分2PL的数据库实际采用的是索引区间锁,在查询条件的某个具有索引的列上加锁,并且扩大加锁的范围,如果没有合适的索引加区间锁,可以回退到对于整个表加锁。区间锁的思想在于扩大谓词锁的加锁范围一定能保证达到谓词锁的效果。
可串行化的快照隔离
没仔细看,不太懂。
悲观与乐观的并发控制
基于过期的条件做决定
检测是否读取了过期的MVCC对象
检测写是否影响了之前的读
可串行化快照隔离的性能
小结
本章讨论遵循以下逻辑进行:
- 事务
- 事务的特性
- 原子性
- 隔离性
- 持久性
- 隔离级别及其解决的并发问题
- 读提交
- 脏读
- 脏写
- 可重复读
- 不可重复读(读倾斜)
- 更新丢失
- 可串行化
- 幻读
- 写倾斜
- 读提交
本人理解:
ACID中,AID是基本的特性,C更多是应用层的要求,与数据库关系相对偏小。
三种隔离级别分别解决了两个并发问题,其中一个读问题,一个写问题。
脏读和不可重复读两种并发问题很像,实际都是由于读取未提交的写入导致的,因此可以利用MVCC和一致性视图解决。
更新丢失和写倾斜具有共同的特征,都是先读取然后基于读取结果进行修改导致的并发问题,只不过更新丢失总读取的结果是单条数据的结果,而写倾斜是范围查询的结果,因此解决手段有不同,更新丢失适合采用原子操作、显式加锁解决,而写倾斜需要区间锁。
幻读与写倾斜关系很强,幻读实际上可以理解为两次范围查询的结果不一致,而写倾斜则为数据修改违反了第一次范围查询的结果。
脏写则为最为naive的并发问题,在修改数据时加锁即可避免,几乎所有数据引擎都能够解决脏写。
常用的解决并发问题的手段:MVCC,显式加锁(2PL、共享锁、排他锁、区间锁)