文章目录
前言
事务的目的是提高系统的可靠性,它会牺牲掉一些可用性和性能。由于存在许多可能出错的情况,数据系统需要有完善的容错机制。事务技术一直是简化这些问题的首选机制。事务中的所有读写是一个执行的整体,整个事务要么成功(提交),要么失败(中止或回滚)。如果失败,应用程序可以安全地重试,而不担心部分失败的情况,这样,应用层的错误处理就简单许多。事务的一个目的是简化应用层的编程模型。
如何判断是否需要引入事务?
首先需要理解事务能够提供哪些安全性保证,背后的代价是什么。事务既不是不可实现,也不是必须实现。
本章内容适用于单节点和分布式场景。
深入理解事务
目前几乎所有的关系数据库和一些非关系数据库都支持事务处理。实现有所不同,但事务的概念没有变化。
一部分非关系(NoSQL)数据库,它们的目标是改进传统的关系模型。而事务在这种变革中成为了受害者:很多新一代的数据库完全放弃了事务支持,或者将其中心定义,替换为比以前弱得多的保证。
事务有优势也有局限性。
ACID含义
原子性(Atomicity),一致性(Consisitency),隔离性(Isolation),持久性(Durability)。
实际上,各家数据库所实现的ACID并不相同,存在许多含糊不清的争议。
不符合ACID标准的系统有时被冠以BASE:基本可用性(Basically Available),软状态(soft state),最终一致性(Eventual consistency)。BASE更加模棱两可,唯一可以确定的是"它不是ACID",除此之外几乎没有承诺任何东西。
原子性
原子性在不同领域有不同的含义。如在多线程编程中,如果某线程执行一个原子操作,这意味着其他线程无法看到该操作的中间结果。ACID的原子性与多个操作的并发性无关(并发性实际与隔离性有关)。
ACID原子性描述了客户端发起一个包含多个写操作的请求时可能发生的情况。它定义的特征是:出错时中止事务,并将部分完成的写入全部丢弃。
一致性
一致性非常重要,在不同场景有不同含义。ACID的一致性主要是指对数据有特定的预期状态,任何数据更改必须满足这些状态约束(或者恒等条件)。例如订单系统,用户支付金额应该和商品价格保持平衡。如果某事务从一个有效状态开始,并且事务中没有违背约束,那么最后结果依然符合有效状态。这种一致性本质上要求应用层来维护这一状态(或恒等),这不是数据库可以保证的事情,它更多的是应用层属性。因此字母C其实并不应该属于ACID。Joe Hellerstein曾表示,字母C只是为了使ACID这个缩略词听起来更为顺口,当时并非觉得这是件很重要的事情。
隔离性
ACID中的隔离性意味着并发执行的多个事务相互隔离,它们不能相互交叉。有的教材将隔离定义为可串行化,这意味着可以假装它是数据库上运行的唯一事务,虽然实际上它们可能同时运行,但数据库系统确保当事务提交时,其结果与串行执行完全相同。
实践中,由于性能问题很少使用串行隔离。Oracle11g没有实现串行化,Oracle虽然声称"串行化"功能,但实际是快照隔离,快照隔离比串行化更弱的保证。
持久性
数据库本质上是提供一个安全可靠的地方来存储数据而不同担心数据丢失等问题。持久性就是这样的承诺,它保证一旦事务提交成功,即使存在硬件故障或数据库崩溃,事务所写入的任何数据也不会消失。数据库只有在某些冗余机制(WAL,复制等)完成后,才会报告事务提交成功。实际不存在完美的持久性,例如所有数据一起损坏丢失,那么数据库也无能为力。
单对象与多对象事务操作
ACID中的原子性和隔离性主要针对客户端在同一事务中包含多个写操作时,数据库所提供的保证。原子性和隔离性假定在一个事务中会修改多个对象。这种多对象事务的目的是为了在多个数据对象之间保持同步。
单对象写入
原子性和隔离性同样适用于单个对象的更新。以下三个问题(假设向数据库写入20KB的JSON文档):
- 如果发送第一个10KB后网络中断,数据库是否只存储了10KB的片段呢?
- 如果数据库在覆盖磁盘现有数据时发生电源故障,最终是否时新旧值混杂在一起?
- 如果另一个客户端在写入过程中读取该文档,是否会看到部分更新的文档内容?
存储引擎设计中,基于日志恢复来实现原子性,对每个对象加锁的方式来实现隔离。有些数据库还提供原子比较-设置操作(CAS),CAS有时候被称为轻量级事务,甚至ACID。这具有一定的误导性,通常意义上的事务是针对多个对象,将多个操作聚合为一个逻辑单元。
多对象事务的必要性
许多分布式数据存储系统不支持多对象事务,原因是因为当出现跨分区时,多对象事务非常难以正确实现(并非不能实现,不存在原理上的限制)。同时在高可用或者极致性能的场景下也会带来很多负面影响。
是否所有应用都需要多对象事务?
这就要看单对象的插入更新删除 是否能满足应用需求。有几种情况:
- 关系模型中,表中某行可能是另一个表中的外键。更新改行,可能需要同时更新其他表。
- 带有二级索引的数据库。从事务的角度看,索引就是不同的数据库对象,没有事务隔离,就会出现部分索引更新
没有事务支持,上层可能可以工作,但会有一些问题存在,如没有原子性,错误处理会异常复杂,缺少事务隔离会引发并发问题。
处理错误与中止
事务的一个关键特性时,如果发生了意外,所有操作中止,之后可以安全地重试。ACID数据库基于这样的一个理念:如果存在违反原子性、隔离性、持久性的风险,则完全放弃整个事务,而不是部分放弃。
并不是所有的系统都遵循上述理念。如无主节点复制的数据存储会在尽力而为
的基础上尝试多做些工作,此时需要应用程序负责从错误中进行恢复。
那么事务是否应该在失败时主动进行重试呢?
重试中止的事务是一个简单有效的错误处理机制,但也存在问题:如果事务已经执行成功,但返回结果时网络发生问题,就会导致重复执行,此时需要额外的应用层重复数据删除极致。如果错误是由于系统超符合所导致,则重试会导致系统变得更糟糕(所有重试机制都有这个问题)。临时性故障所导致的错误可以重试,永久性故障重试无意义。如果事务中还带有其他附加操作(发电子邮件),则重试会重试这些附加操作。如果重试操作也失败,则数据会丢失(如何确定重试次数?)。
弱隔离级别
问题来源
如果两个事务操作的是不同的数据,即不存在数据依赖关系,则它们可以安全地并行执行。当某个事务修改数据而另一个事务同时要读取/写入该数据,才会引发并发问题(引入竞争条件)。
隔离性是为了让应用层代码更容易实现,简化问题。
可串行化的隔离会严重影响性能,因为更多倾向于采用较弱的隔离级别,它可以防止某些但非全部的并发问题。财务数据通常需要ACID系统,然而实际情况是许多流行的关系数据库系统(声称支持ACID)采用的也是弱级别隔离。
以下介绍多种弱隔离级别具体实现。
读-提交
读-提交是最基本的事务隔离级别,它只提供以下两个保证:
- 读数据库时,只能看到已成功提交的数据。防止脏读。
- 写数据库时,只会覆盖已成功提交的数据。防止脏写。
某些数据库甚至只保证防止脏写,称为读-未提交,只防脏写,不防脏读。
防止脏读
脏读
假定某事务已经完成部分数据写入,但尚未提交(或中止),此时另一事务能否看到尚未提交的数据呢?如果是的话,就是脏读。
读-提交的隔离级别必须做到防止脏读。当有以下需求时,需要防止脏读:
1.如果事务需要更新多个对象,脏读意味着另一个事务可能会看到部分更新,会导致用户困惑。
2.如果事务发生中止,所有操作都需要回滚。如果发生脏读,用户可能会看到被回滚的数据,此时用户如果继续操作订单,可能产生难以预测的后果。
防止脏写
脏写
如果两个事务同时尝试更新相同的对象,可以想像,后写的操作会覆盖较早的写入。但如果先前的写入是尚未提交事务的一部分,是否被覆盖?如果是,那就是脏写。
通常的方式是推迟第二个写请求,直到前面的事务完成提交(串行化)。
防止脏写是为了应对一下问题:
如果事务需要更新多个对象,脏写会带来非预期的错误结果。如售票系统,Alice,Bob试图购买同一个座位,完成购买会更新两个信息:票务信息,出票信息。此时可能发生的一个情况是:票务信息显示Alice,出票信息是Bob。这就造成了事故
读-提交隔离也有不能处理的情况:如计数器自增的竞争情况(如同时有两个命令set count=42+1),即使串行化,仍然会导致错误的结果。在应用层采用count=count+1可以很简单解决这个问题。
实现读-提交
读提交隔离非常流行。它是Oracle11g、PostgreSQL、SQL Server 2012、MemSQL以及许多其他数据库的默认配置。
数据库通常使用行级锁
防止脏写。
为什么不使用读锁
?读锁的意思是读取数据时先获取和写锁相同的行级锁。这种方式存在严重的性能问题,局部的性能问题会有扩散/连锁效应。因此大多数数据库都采取同时维护旧值和当前持锁事务将要设置的新值两个版本。在事务提交前,都是读取旧值。(作者写书时唯一还使用读锁的:IBM DB2/Microsoft SQL Server。多用于银行)
快照隔离与可重复度
读-提交防止脏读和脏写,但它存在不可重复读的问题。
不可重复读
Alice有两个账户,各500元。现进行两个事务操作:转账(账户1转500元到账户2),然后读取账户余额。在转账事务过程中,读余额事务可能会看到:账户1内500元(写事务还未提交),账户2内1000元(写事务已提交),读取到了非一致性数据。随后再执行读事务,将看不到这种破坏一致性的数据。不可重复读(nonrepeatable read)又称读倾斜(read skew,奇怪的名字)。读倾斜在读-提交隔离的语义下可以接受。“倾斜”在这里指时间异常。这种不一致在这里时可以接受的,但也有不可接受的情况:1.备份场景,会导致数据永久性不一致。2.分析查询与完整性检查场景。
快照级别隔离是解决上述问题最常见的手段。总体想法是:每个事务都从数据库的一致性快照中读取。快照隔离对于长时间运行的只读查询非常有用。目前PostgreSQL,MySQL的InnoDB存储引擎,Oracle,SQL Server等都已支持。
实现快照隔离
快照隔离使用写锁
防止脏写,读取时不需要加锁(快照隔离关键点)。数据库保留了对象多个不同的提交版本,这种技术称为多版本并发控制(Multi-Version Concurrency Control ,MVCC).如果是为了提供读-提交隔离,则只需要保留两个版本:当前版本和未提交版本,所以支持快照隔离的存储引擎可以直接用MVCC实现读-提交隔离。
MVCC的工作过程(更新数据为例):
- 分配一个单调递增的id标记事务,id_100
- 将行数据标记为删除,并记录delete by id_100
- 新建一个新行数据,并记录by id_100
- 当确定没有其他事务引用标记为删除的数据时,执行垃圾回收。
有了这个机制,再重新审视转账例子(假设当前最新事务ID为id_99):
- 原有数据,账户A内500元(by id_30),账户B内500(by id_10)。
- 进行转账事务,分配事务id_100。
- Alice查看两个账户金额。由于id_100事务尚未提交完成,因此要求读取的数据的写入事务id<=99.所以返回的两个账户金额都会是500元。
- 事务id_100提交完成。再次查询账户金额,能看到转账完成后的数据。
一致性快照可见性规则
快照隔离需要有清晰的数据可见性规则。
定义数据的可见性规则:
- 每笔事务开始时,记录当前还未完成的事务id,这些事务id的部分写入数据全部不可见。
- 中止的事务数据全部不可见
- 大于当前事务id的数据不可见
- 其他所有数据均可见
数据可见的两条原则: - 事务开始时,创建该数据的事务已完成
- 对象没有被标记为删除;或已经标记为删除,但标记的事务还没有完成提交。
索引与快照级别隔离
这种多版本数据库如何支持索引?当有多个版本存在时是否需要同时更新索引?
PostgreSQL将同一对象的不同版本放在一个内存页面,这样就避免了更新索引。
CouchDB/Datomic/LMDB使用另一种方式。它们索引结构时b-tree,但采用了追加/写时复制技术。当需要更新时,不修改当前索引,而是创建一个新的修改副本,拷贝必要的内容。这种追加式b-tree,每有一个写入事务,都会创建一个新的b-tree root,代表该时刻的一致性快照。
可重复读与命名混淆
快照级别隔离就是为了解决不可重复读的问题,所以它对只读事务特别有效。
具体到实现,Oracle称之为可串行化,PostfreSQL和MySQL称为可重复读。IBM DB2的可重复读实则是可串行化级别隔离。
防止更新丢失
读-提交和快照级别隔离都是为了解决只读事务遇到并发写时可以看到什么。
更新丢失,即当同一条记录同时有2个更新事务(读-修改-写回)时,由于隔离性最终会导致第一个事务结果丢失。
并发写冲突是一个普遍问题,目前有多种可行的解决方案。
原子写操作
许多数据库提供原子更新操作,以避免在应用层完成"读-修改-写回":
update table set c=c+1 where key=‘foo’;
这条更新命令如果在应用层分三步执行,将会有产生严重的并发问题。这是一条警示:业务层代码如果要使用"读-修改-写回"操作,最后一步写回时,应当使用数据库的"读-修改-写回"操作。例如更新状态:
不要使用:update table set status=1 where id=1;
应该使用(CAS):update table set status=1 where id=1 and status=0;
原子写操作如果可行,那么就是解决并发写冲突的最佳方式。
原子写操作通常采用对读取对象加独占锁的方式实现,在更新被提交前,其他事务不能读取该对象。这种技术有时也称游标稳定性
。另一种实现方式是强制所有的原子操作都在单线程上执行。
显示加锁
这是指在应用层显式锁定待更新对象。原子写操作没有业务逻辑/规则,有时候还不够,需要在应用层加锁。
自动检测更新丢失
原子操作和显式锁都是通过强制"读-修改-写回"操作序列串执行来防止丢失更新。另一种思路是先让他们并发执行,但如果事务管理器检测到了更新丢失风险,则会中止当前事务,并强制回退到安全的"读-修改-写回"方式。
该方法的优点是数据库可以借助快照级别隔离来高效地执行检查。一般的数据库都可以检测何时发生了更新丢失,但MySQL的InnoDB的可重复读不支持检测更新丢失。
原子比较和设置(CAS)
在不提供事务支持的数据库中,有时会支持CAS操作。使用该操作可以避免更新丢失,即只有在上次读取的数据没有发生变化时才允许更新;如果已经发生变化,则回退到"读-修改-写回"方式。
例如:update table set status=1 where id=1 and status=0;
如果执行失败,则需要应用层再次检查并进行必要的重试。但如果是运行在某个旧快照上,仍然可能造成更新丢失。
冲突解决与复制
加锁和原子修改都有一个前提:只有一个最新的数据副本。对于多主节点或无主节点多副本数据库,通常采用异步方式来同步数据,所以会出现多个最新的数据副本。此时加锁/原子修改都不适用。
最后写入获胜LWW是许多多副本数据库的默认冲突解决方法,很容易造成更新丢失。
写倾斜与幻读
写倾斜案例:
医院需要确保有至少一位医生在岗,Alice和Bob两位值班医生同时点击请假按钮,此时对于快照隔离数据库,会先检查是否有医生在岗,结果是Alice和Bob都在岗。接下来写入Alice和Bob的请假申请。结果是医院没有一位医生在岗了。
这种情况就是写倾斜,它既不是脏写,也不是更新丢失。两笔事务更新的是两个不同的对象,他们具有某种竞争状态。如果事务时串行执行的,则不会发生写倾斜。
写倾斜问题具有类似的业务模式:
- 首先select查询所有满足条件的行。
- 根据查询结果,应用层代码来决定下一步的操作。
- 如果应用层决定继续执行,将发起数据库写入并提交事务(insert/delete/update).
如果步骤3会改变步骤1的查询结果,则极易发生写倾斜。
这种在一个事务中的写入改变了另一个事务查询结果的现象,称为幻读。快照级别隔离可以防止只读事务中的幻读,不能解决读-写事务中的写倾斜问题。写倾斜一般是业务层逻辑引入的竞争条件,涉及多个对象。而如果是真正的串行化则可以防止写倾斜。
应对写倾斜
有两种方式:对事务写入结果加锁(select for update),再查询可以保证事务安全。如果查询结果判定条件是"不存在某些记录",则加锁方式不可行,因为不存在记录,也就无法加锁。解决这种情况的方法是物化冲突(实体化冲突)。
物化冲突的例子:现有一会议室预订系统,预定流程为:查询会议室在t时刻没有使用记录,然后插入预定记录。显然当两个预定请求同时发生时,会发生写倾斜。物化冲突的方法是,预先生成t时刻的会议室预订记录,当有真的预定请求时,再将预定信息写入。这样由于存在预定记录,可以方便加锁,从而避免写倾斜。
物化冲突看起来是利用了数据库的特性,实则是在应用层解决问题的思路,预先的物化记录数据结构完全是由应用层逻辑决定。一般不会使用物化冲突的方法。推荐使用串行化的方法。
串行化
前述弱隔离级别存在的问题:
-
隔离级别难以理解,不同数据库的实现不一致,承诺的安全级别具有差异。
-
应用层难以判断在特定的隔离级别下是否安全
-
缺乏检测竞争状况的工具。
弱隔离级别存在的这些问题一直存在。解决这个问题的方法:采用可串行化隔离。
可串行化隔离通常被认为是最强的隔离级别。它保证即使事务可能会并行执行,但最终的结果与串行执行结果相同。事务在单独运行时表现正确,则并发运行结果仍然正确。数据库可以防止所有可能的竞争条件。
目前提供可串行化数据库都使用以下三种技术之一: -
严格按照串行顺序执行
-
两阶段锁定
-
乐观并发控制技术
实际串行执行
解决并发问题最直接的方法是避免并发。但这是最近才兴起的解决方式,原因是新情况:
- 内存越来越便宜。把所有内容加载到内存当中,事务的执行速度很快
- OLTP通常执行很快,只产生少量的读写操作
VoltDB/H-Store,redis,Datomic采用串行方式执行事务。单线程执行有时可能会比并发的效率更高,因为单线程避免了锁开销和线程切换。单线程的吞吐量上线是单个cpu核的吞吐量。为了充分利用单线程,事务也需要作出相应的调整。
采用存储过程封装事务
多语句(交互式)事务,大量时间话费在应用程序与数据库之间的网络通信。如果不允许事务并发,那么多语句事务会造成数据库吞吐量非常低。因此,采用单线程串行执行的系统往往不支持交互式的多语句事务。应用程序必须提交整个事务代码作为存储过程打包发送到数据库。
存储过程的优缺点:
- 没有通用统一的存储过程的编程语言,或者标准。语义落后,缺乏函数库
- 在数据库运行的代码难以管理,调试困难
- 数据库对性能的要求很高。一个设计不好的存储过程会带来很大的麻烦。
最新的存储过程已有一些通用的语言。VoltDB使用java/groovy,Datomic使用java/clojure,Redis使用lua。
存储过程和内存式数据存储使得单线程执行所有事务变得可行,可以获得不错的吞吐量。
分区
串行执行所有事务的吞吐量被限制在单机单个CPU核。为了扩展到多个cpu核核多节点,可以对数据进行分区
。但是随之带来的问题是,如果一个事务涉及多个分区,分区之间的协调开销会严重影响性能。因此分区是否合适与业务数据结构有关,通关简单的键-值数据比较容易切分,其他复杂的数据或者业务就不适合使用分区。
串行化条件
当满足以下约束条件时,串行执行事务可以实现串行化隔离:
- 事务必须简单而高效,否则一个缓慢事务会影响到所有其他事务的执行性能
- 仅限活动数据集完全可以加载到内存的场景。否则事务将不符合上一条
- 写入吞吐量必须足够低(小于单个cpu吞吐量)
- 如果使用分区,跨分区事务占比必须很小
两阶段加锁
近三十年,数据库只有一种被广泛使用的串行化算法:两阶段加锁,two-phase locking,2PL。SSPL:strong strict two-phase locking.严重的两阶段锁。
两阶段锁不是两阶段提交(2PC)。
2PL的特征是:多个事务可以同时读取同一对象,但只要出现任何写操作,则必须加锁以独占访问。
实现2PL
目前,2PL已经用于MySQL(InnoDB)和SQL Server中的"可串行化隔离",以及DB2的"可重复读隔离"。
此时数据库的每个对象有一个读写锁来隔离读写操作,该锁拥有两种状态:共享模式和独占模式。
基本用法:
- 如果事务读取对象,必须获得共享锁,如果此时锁的状态时独占模式,则必须等待。
- 如果事务要修改对象,必须获得独占锁。如果此时锁被其他事务持有(不论是处于共享还是独占),则必须等待
- 如果事务先读取再写入,则先获得共享锁再升级为独占锁。
- 事务获得锁以后,一直持有到事务结束。两阶段的含义:第一阶段即事务执行之前获得锁,第二阶段即事务结束时释放锁。
由于事务常常涉及多个对象,因此2PL极易造成死锁。数据库会自动检测事务之间的死锁情况,并强制中止其中一个以打破僵局,一个事务可以执行,而另一个被中止的事务则需要由应用层重试。
2PL性能
2PL的主要缺点:事务吞吐量和查询相应时间比其他弱隔离级别下降非常多。主要原因是降低了事务的并发性,另外锁开销也有影响。2PL模式下数据库访问延迟也具有很大不确定性,因为事务可能由于等待锁导致执行缓慢,事务的执行缓慢可能会迅速蔓延,导致整个数据库性能下降。
谓词锁
在"写倾斜与幻读"问题中,有会议室预订的例子。预定的条件是"不存在预定记录",不存在记录当然不能加普通的锁,此时需要新的锁,称谓词锁(属性谓词锁)。它的作用方式类似于独占/共享锁,而区别在于,它不属于特定的对象,而是作用域满足某些搜索条件的所有查询对象。
基本用的:
- 事务想要读取满足某个条件的对象,首先要获得查询条件的共享谓词锁。如果有另一个事务正持有任何一个匹配对象的互斥锁,则必须等待互斥锁释放。
- 如果事务想要插入更新删除任何对象,则必须首先检查所有旧值和新值是否与现有的任何谓词锁匹配(即冲突),如果有另一事务持有这样的谓词锁,那么必须等待谓词锁释放。
可以看出谓词锁可以保护尚不存在的记录。将2PL与谓词锁结合使用,数据库可以防止所有性质的写倾斜以及其他竞争条件,隔离变得真正可串行化。
索引区间锁
谓词锁的问题是:性能极差。因此大多数使用2PL的数据库事实上实现的是索引区间锁(next-key locking),本质是对谓词锁的简化。简化方式是将其保护的对象扩大化。扩大的原则是找出查询条件中的索引,而忽略非索引字段的条件。这是一种较好的折衷方案。这也说明任何查询最好都附带有索引,尽量少在事务当中使用范围查询,如果必须使用,则要小心查询条件,特别是对于不存在某些记录这类查询,防止锁的范围扩大化。
可串行化的快照隔离(趋势)
串行执行性能差,弱级别隔离性能尚可容易你发各种边界条件。最近有一种称为可串行化的快照隔离(serializable snapshot isolation ,SSI)。
2PL是一种典型的悲观并发控制机制,它默认某些操作会引发错误,所以会不断获取锁释放锁,即使并不存在并发的竞争事务。而SSI是一种乐观并发控制,它寄希望于不会发生并发竞争事务,当事务提交时,数据库才检查是否确实发生了冲突,如果是则中止事务并重试(类似于CAS机制)。
乐观并发控制有自身的优缺点。如果冲突很多,则性能不佳,大量事务中止,由于有自动重试机制,会导致严重的性能下降。但如果事务之间竞争不大,则效率很高。
SSI基于快照隔离,事务的所有读取操作都基于一致性快照,在快照隔离的基础上,SSI新增了相关算法来检测写入之间的串行化冲突从而决定中止哪些事务。
总结
防止 | 读-未提交 | 读提交 | 快照隔离 | 串行化 |
---|---|---|---|---|
脏写 | 1 | 1 | 1 | 1 |
脏读 | 0 | 1 | 1 | 1 |
读倾斜(不可重复读) | 0 | 0 | 1 | 1 |
更新丢失 | 0 | 0 | 0.5 | 1 |
写倾斜 | 0 | 0 | 0 | 1 |
幻读 | 0 | 0 | 0.5 | 1 |
快照隔离可以处理一部分的更新丢失问题。
快照隔离可以防止简单的幻读问题,而写倾斜造成的幻读则只能由串行化解决。