事务作为一个抽象层,可以将应用程序的多个读、写操作捆绑在一起成为一个逻辑操作单元,即事务中的所有读写是一个执行的整体,整个事务要么成功(提交),要么失败(中止或回滚)。无论如何,不会出现部分失败的情况。使得应用程序可以忽略数据库内部的一些复杂的并发问题,以及某些硬件、软件故障,从而简化应用层的处理逻辑,大量的错误可以转化为简单的事务中止和应用层重试。
事务所提供的安全保证即大家所熟悉的ACID。但是实际上,各家数据库所实现的ACID并不尽相同。例如,围绕着“隔离性”就存在很多含糊不清的争议。
1、A (Atomicity) 原子性
原子性很容易理解,也就是说事务里的所有操作要么全部做完,要么都不做,而不是两者之间的状态。
2、C (Consistency) 一致性
ACID的一致性主要是指对数据有特定的预期状态,任何数据更改必须满足这些状态约束(或恒等条件)。
例如现有完整性约束a+b=10,如果一个事务改变了a,那么必须得改变b,使得事务结束后依然满足a+b=10,否则事务失败。
3、I (Isolation) 隔离性
所谓的隔离性是指并发的事务之间相互隔离,不会互相影响。如果一个事务要访问的数据正在被另外一个事务修改,只要另外一个事务未提交,它所访问的数据就不受未提交事务的影响。
经典教材中把隔离定义为可串行化:可以假装它是数据库上运行的唯一事务。虽然实际上他们可能同时运行,但数据库系统要确保当事务提交时,其结果与串行执行完全相同。
例如现在有个交易是从A账户转100元至B账户,在这个交易还未完成的情况下,如果此时B查询自己的账户,是看不到新增加的100元的。
4、D (Durability) 持久性
持久性是指一旦事务提交后,它所做的修改将会永久的保存在数据库上,即使出现宕机也不会丢失。
不符合ACID标准的系统有时被冠以BASE,即基本可用性(Basically Available),软状态(Soft state),最终一致性(Eventual consistence),更一般的,我们习惯称他们只满足CAP定理。
CAP定理(CAP theorem), 又被称作 布鲁尔定理(Brewer’s theorem), 它指出对于一个分布式计算系统来说,不可能同时满足以下三点:
(1)一致性(Consistency) (所有节点在同一时间具有相同的数据)
(2)可用性(Availability) (保证每个请求不管成功或者失败都有响应)
(3)分区容错性(Partition tolerance) (系统中任意信息的丢失或失败不会影响系统的继续运作)
CAP理论的核心是:一个分布式系统不可能同时很好的满足一致性,可用性和分区容错性这三个需求,最多只能同时较好的满足两个。
因此,根据 CAP 原理将 NoSQL 数据库分成了满足 CA 原则、满足 CP 原则和满足 AP 原则三 大类:(1)CA - 单点集群,满足一致性,可用性的系统,通常在可扩展性上不太强大。
(2)CP - 满足一致性,分区容忍性的系统,通常性能不是特别高。
(3)AP - 满足可用性,分区容忍性的系统,通常可能对一致性要求低一些。
ACID和CAP中的一致性含义是不同的。
ACID中的一致性主要是指对数据有特定的预期状态,任何数据更改必须满足这些状态约束(或恒等条件)。这种一致性本质上要求应用层来维护状态一致,应用程序有责任正确定义事务来保持一致性。原子性、隔离性和持久性是数据库自身的属性,而ACID中的一致性更多是应用层的属性。
CAP中的一致性用来表示“可线性化(也称为原子一致性、强一致性等)”。其基本想法是:让一个系统看起来好像只有一个数据副本。有了这个保证,应用程序就不需要关心系统内部的多个副本了。
ACID中的原子性和隔离性主要针对客户端在同一事务中包含多个写操作,这些定义假定在一个事务中会修改多个对象(如行,文档,记录等)。这种多对象事务目的通常是为了在多个数据对象之间保持同步。
数据库不可避免的存在各种并发问题。一直以来,数据库力求试图通过事务隔离来对应用开发者隐藏内部的各种并发问题。
经典教材中把隔离定义为可串行化,可串行化隔离意味着数据库保证事务的最终执行结果与串行(一次一个,没有任何并发)执行结果相同,可以将其理解为强隔离级别。
可串行化的隔离会严重影响性能,而很多数据库却不愿牺牲性能,因而倾向于采用较弱级别(非串行化)隔离,它可以防止某些而不是全部的并发问题。
SQL 标准里定义了四个隔离级别:
(1)读未提交(Read Uncommitted):会出现脏读(Dirty Read)—— 一个事务会读到另一个事务的中间状态。
(2)读已提交(Read Committed):会出现不可重复读(Unrepeatable Read) —— 事务只会读到已提交的数据,但是在一个事务中,前后两次读取一个值得到的结果不一致。
(3)可重复读(Repeatable Read):会出现幻读(Phantom Read) —— 一个事务执行两个相同的查询语句,得到的是两个不同的结果集(数量不同)。同样的条件,第1次和第2次读出来的记录数不一样。注意是数量不一致,好像凭空多(少)了几个。
(4)可串行化(Serializable):可以找到一个事务串行执行的序列,其结果与事务并发执行的结果是一样的。
SQL 标准定义的的这四个隔离级别,只适用于基于锁的事务并发控制。后来有人写了一篇论文 A Critique of ANSI SQL Isolation Levels 来批判 SQL 标准对隔离级别的定义,并在论文里提到了一种新的隔离级别 —— 快照隔离(Snapshot Isolation,简称 SI)。在 Snapshot Isolation 下,不会出现脏读、不可重复度和幻读三种读异常。并且读操作不会被阻塞,对于读多写少的应用 Snapshot Isolation 是非常好的选择。并且,在很多应用场景下,Snapshot Isolation 下的并发事务并不会导致数据异常。所以,主流数据库都实现了 Snapshot Isolation,比如 Oracle、SQL Server、PostgreSQL、TiDB、CockroachDB(关于 MySQL 的隔离级别,可以参考https://www.jianshu.com/p/69fd2ca17cfd)。
下面将介绍几种实际中经常用到的弱隔离:
读-提交是最基本的事务隔离级别,它只提供两种保证:
(1)读数据库时,只能看到已成功提交的数据(防止“脏读”)
(2)写数据库时,只会覆盖已成功提交的数据(防止“脏写”)
假定某个事务已经完成部分数据写入,但事务尚未提交(或中止),此时如果另一个事务可以看到尚未提交的数据,那就是“脏读”。
当有如下需求时,需要防止脏读:
(1)如果事务需要更新多个对象。脏读意味着另一个事务可能会看到部分更新,而非全部。
(2)如果事务发生中止,则所有写入操作都需要回滚。脏读意味着可能会看到尚未回滚的数据,这些数据并未实际提交到数据库中。
如果两个事务同时尝试更新相同的对象,当然地,后写的操作会覆盖较早的写入。但是,如果先前的写入是尚未提交事务的一部分,此时如果被覆盖,那就是“脏写”。防止脏写的通常方式是推迟第二个写请求,直到前面的事务完成提交(或中止)。
当有如下需求时,需要防止脏写:
典型情景就是:事务需要更新多个对象,此时多个事务同时尝试更新,非常可能产生脏写,会带来非预期的错误。比如:抢购小米。
读-提交隔离非常流行,它是oracel 11g,postgreSQL,SQL Server2012以及很多其他数据库的默认配置。
防止脏写:行级锁。当事务想修改某个对象时,它必须首先获得该对象的锁,然后一直持有直到事务提交(或中止)。给定时刻,只有一个事务可以拿到特定对象的锁。如果有另一个事务尝试更新同一个对象,则必须等待,直到前面的事务完成了提交(或中止)后,才能获得锁并继续。这种锁定是由处于读-提交模式(或更强的隔离级别)数据库自动完成。
防止脏读:
(1)读锁。和前面的行级锁类似,所有试图读取该对象的事务必须先申请锁,事务完成后释放锁。但是,读锁的方式在实际中并不可行,因为运行时间较长的写事务会导致许多只读的事务等待太长时间,这会严重影响只读事务的响应延迟,且可操作性差:由于读锁,应用程序任何局部的性能问题会扩散进而影响整个应用,产生连锁反应。
(2)考虑到性能和实际情况,普遍采取的方法是:对于每个待更新的对象,数据库都会维护其旧值和当前持锁事务将要设置的新值两个版本。在事务提交之前,所有其它读操作都读取旧值;仅当写事务提交之后,才会切换到读取新值。
快照是数据存储的某一时刻的状态记录;备份则是数据存储的某一个时刻的副本。这是两种完全不同的概念。
详细介绍参考:https://www.jianshu.com/p/74007799313d
快照:一般来说,原则就是就是快照时锁定物理单元内容,并记录本次快照和上一次快照的所对应的物理地址(或者是上一层逻辑地址)的差异。因为快照仅仅记录逻辑地址和物理地址的对应关系,因此快照的速度非常快
备份:备份,则是另外一份数据副本。另外,备份又分全量备份和增量备份。增量备份类似快照,但不同的地方在于两次快照之间只记录了两层地址之间的对应关系的差异,而增量备份则把这些差异中,新增地址所对应的底层数据也复制了一份出来。
快照和备份的不同特性在于:
(1)备份的数据安全性更好:如果原始数据损坏(例如物理介质损坏,或者绕开了快照所在层的管理机制对锁定数据进行了改写),快照回滚是无法恢复出正确的数据的,而备份可以。
(2)快照的速度比备份快得多:生成快照的速度比备份速度快的多。也因为这个原因,为了回避因为备份时间带来的各种问题(例如IO占用、数据一致性等)很多备份软件是先生成快照,然后按照快照所记录的对应关系去读取底层数据来生成备份。
(3)占用空间不同:备份会占用双倍的存储空间,而快照所占用的存储空间则取决于快照的数量以及数据变动情况。极端情况下,快照可能会只占用1%不到的存储空间,也可能会占用数十倍的存储空间。(PS:不过如果同一份数据,同时做相同数量的快照和增量备份的话,备份还是会比快照占用的存储空间多得多。)
读倾斜(不可重复读):在一个事务的不同时间点看到不同值。
典型的读倾斜场景如下:
(1)备份场景:备份复制整个数据库可能需要数小时才能完成。在备份过程中,可以继续写入数据库。因此,得到的镜像中可能包含部分旧版本数据和部分新版本数据。如果从这样的备份进行恢复,最终就导致永久性的不一致。
(2)分析查询与完整性检查场景:在分析业务中,查询非常可能会扫描大半个数据库。如果这些查询在不同的时间点观察数据库,可能会返回无意义的结果。
快照级别隔离是解决上述两个问题的最常见手段。其总体做法:每个事务都从数据库的一致性快照中读取,事务一开始所看到的是最近提交的数据,即使数据随后可能被另一个事务更改,但保证每个事务都只看到该特定时间点的旧数据。
快照级别隔离对于长时间运行的只读查询(如备份和分析)非常有用。如果数据在执行查询的同时还在发生变化,那么查询结果对应的物理含义就难以厘清。而如果查询的是数据库在某时刻所冻结的一致性快照,则查询结果的含义非常明确。
(1)防止“脏写”:同读提交一样,使用写锁
(2)防止“脏读”和“不可重复读”:多版本并发控制。与读提交类似,但是更为通用。考虑到多个正在进行的事务可能会在不同的时间点查看数据库状态,所以,数据库保留了多个不同的提交版本。
关于多版本并发控制(Multi-Version Concurrency Control,MVCC),书上讲的很有问题,十分不明确,详细技术细节请参考:https://www.jianshu.com/p/8845ddca3b23(了解MVCC的概念即可,这个概念讲的不错,具体细节看不懂。。。)
1、数据库并发场景有三种,分别为:
读-读:不存在任何问题,也不需要并发控制
读-写:有线程安全问题,可能会造成事务隔离性问题,可能遇到脏读,幻读,不可重复读
写-写:有线程安全问题,可能会存在更新丢失问题,比如第一类更新丢失,第二类更新丢失
2、MVCC带来的好处是?
多版本并发控制(MVCC)是一种用来解决读-写冲突的无锁并发控制,也就是为事务分配单向增长的时间戳,为每个修改保存一个版本,版本与事务时间戳关联,读操作只读该事务开始前的数据库的快照。 所以MVCC可以为数据库解决以下问题:
(1)在并发读写数据库时,可以做到在读操作时不用阻塞写操作,写操作也不用阻塞读操作,提高了数据库并发读写的性能
(2)同时还可以解决脏读,幻读,不可重复读等事务隔离问题,但不能解决更新丢失问题
3、总体的隔离策略
总之,MVCC就是因为大牛们,不满意只让数据库采用悲观锁这样性能不佳的形式去解决读-写冲突问题,而提出的解决方案,所以在数据库中,因为有了MVCC,所以我们可以形成两个组合:
(1)MVCC + 悲观锁:MVCC解决读写冲突,悲观锁解决写写冲突
(2)MVCC + 乐观锁:MVCC解决读写冲突,乐观锁解决写写冲突
正如前面所介绍的,我们长篇累牍讨论的读提交以及快照级别隔离,其实都只是为了解决读写并发问题。而并发写同样存在很多需要解决的问题,比如:脏写、更新丢失、写倾斜。
写事务并发最著名的就是更新丢失问题,脏写只是一个特例。
更新丢失可能发生的场景比如:应用程序从数据库读取某些值,根据应用逻辑作出修改,然后写回新值(read-modify-write)。当有两个事务在同样的数据对象上执行类似操作时,由于隔离性,第二个写操作并不包括第一个事务修改后的值,最终会导致第一个事务的修改值可能会丢失。
1、原子写操作
在数据库层面,提供原子更新操作,以避免在应用层完成“read-modify-write”操作。对于支持原子写操作的数据库,这就是最好的解决方案。
原子操作通常采用对读取对象加独占锁的方式来实现,这样在更新被提交之前,不会有其他事务可以读它。
2、显式加锁
在应用层解决。如果数据库不支持内置原子操作,则防止更新丢失的方法是由应用程序显式加锁锁定待更新的对象。然后,应用程序可以执行“read-modify-write”这样的操作序列,此时如果有其它事务尝试同时读取对象,则必须等待当前正在执行的序列全部完成。
3、自动检测更新丢失
原子操作和锁都是通过强制“read-modify-write”操作序列串行执行来防止丢失更新。另一种思路则是先让他们并发执行,但如果事务管理器检测到更新丢失风险,则中止当前事务,并强制回退到安全的“read-modify-write”方式。
4、原子比较和设置
对于不支持事务的数据库,使用该操作可以避免更新丢失,即只有在上次读取的数据没有发生变化时才允许更新,如果已经发生了变化,则回退到“read-modify-wrtie”的方式。
5、冲突解决与复制
对于支持多副本的数据库,防止更新丢失还需要考虑另一个维度:由于多节点上的数据副本,不同的节点可能会并发修改数据,因此,必须采取一些额外的措施来防止更新丢失。
前面提到的加锁和原子修改都有个前提:只有一个最新的数据副本。然而对于多主节点或者无主节点的多副本数据库,由于支持多个并发写,且通常以异步方式来同步更新,所以会出现多个最新的数据副本,此时,加锁和原子比较将不再适用。往往需要依靠应用层逻辑或特定的数据结构来解决、合并多版本。
如果操作可交换(顺序无关,在不同的副本上以不同的顺序执行时仍然得到相同的结果),则原子操作在多副本情况下也可以工作。
写倾斜既不是脏写也不是更新丢失,关键区分在于它的两笔事务更新的是两个不同的对象。可以将写倾斜视为一种更广义的更新丢失问题。即如果两个事务读取相同的一组对象,然后更新其中一部分:如果不同事务更新的是同一个对象,则发生的是脏写或更新丢失;如果不同事务更新的是不同对象,则发生的是写倾斜。
《寻秦记》的逻辑与悖论。
所有写倾斜的例子都遵循以下类似的模式:
1、首先,输入一些匹配条件,即采用select查询所有满足条件的行
2、根据查询的结果,应用层代码来决定下一步的操作
3、如果应用程序决定继续执行,它将发起数据库写入,并提交事务
关键的悖论在于:第二步直接决定第三步的操作,而第三步的写入会反过来改变第二步作出决定的前提条件,这就陷入了一个矛盾之中。
解决写倾斜的唯一方法是串行化的隔离。
弱隔离级别为了实现并发高性能作出了很多妥协,它可以防止某些异常,但还需要应用开发人员手动处理其他复杂情况(比如:显式加锁)。只有可串行化的强隔离才可以防止所有这些问题。
实现可串行化隔离的三种不同方法:
如果每个事务的执行速度非常快,且每个CPU核可以满足事务的吞吐量要求,严格串行执行是一个非常简单有效的方案。(不太现实)
两阶段加锁 ≈ 防止脏写加锁+防止脏读加锁
(1)如果事务A已经读取了某个对象,此时事务B想要写入该对象,则B必须等到A提交或中止之后才能继续。以确保B不会在事务A执行的过程中去修改对象。
(2)如果事务A已经修改了对象,此时事务B想要读取该对象,则B必须等到A提交或中止之后才能继续。对于2PL,不会出现读到旧值的情况。
2PL不仅在并发写之间互斥,读取也会和修改互斥。
快照级别隔离的“读写互不干扰”,非常准确地点明了它和两阶段加锁的关键区别。
基本规则:
(1)如果事务要读取对象,必须先以共享模式获得锁。可以有多个事务同时获得一个对象的共享锁,但是如果某个事务已经获得了对象的独占锁,则所有其它事务必须等待。——读锁可以共享。
(2)如果事务要修改对象,必须以独占模式获取锁。不允许多个事务同时持有该锁。——写锁必须独占。
(3)如果事务首先读取对象,然后尝试写入对象,则需要将共享锁升级为独占锁。
事务获得锁后,一直持有锁直到事务结束。这也是名字“两阶段”的由来:在第一阶段即事务执行之前要获取锁,第二阶段即事务结束时释放锁。
首先,锁机制过多,容易出现死锁。
其次,其事务吞吐量和查询响应时间相比于其他弱隔离级别下降非常多。这主要因为:锁的获取和释放本身需要很大的开销,并且严重降低了事务的并发性。
真正的可串行化隔离可以防止写倾斜和幻读问题。但两阶段加锁并不能防止所有形式的写倾斜和幻读,为此,需要引入一种谓词锁。它的作用类似于共享/独占锁,区别在于,它并不属于某个特定的对象(如表的某一行),而是作用于满足某些搜索条件的所有查询对象。
将两阶段加锁和谓词锁结合使用,数据库可以防止所有形式的写倾斜以及其他竞争条件,才能成为真正的可串行化。
谓词锁的缺点是:性能不佳,如果活动事务中存在许多锁,那么检查匹配这些锁就会变得非常耗时。因此,绝大多数使用2PL的数据库实际上实现的是索引区间锁,本质上它是对谓词锁的简化或近似。
一种最新的算法,可以避免前面方法的大部分缺点。它秉持乐观预期的原则,允许多个事务并发执行而不互相阻塞;仅当事务尝试提交时,才检查可能的冲突,如果发现违背了串行化,则某些事务会被中止。