同时对接A系统和B系统,一个请求从A系统发到我,我再去请求B系统,但是A系统要求同步返回结果,而B系统是异步返回结果的,这种情况怎么设计比较好?主要是如何满足A系统的同步返回的要求
许多应用让用户提交一些数据,然后查看提交的内容。如客户DB中的记录或某主题的评论。提交新数据必须发送到主节点,但当用户读数据时,可能从【从节点】读取。这对读密集和偶尔写入的负载很合适。但异步复制则有问题,如图-3:若用户在写后马上查看数据,则新数据可能尚未到达副本。对用户而言,看起来好像是刚提交的数据丢了,用户会不高兴。此时,需“写后读一致性(read-after-write consistency)”,也称读写一致性(read-your-writes consistency)。该机制保证:若用户重新加载页面,他们总能看到自己最近提交的更新。但对其他用户没有任何保证,这些用户的更新可能会在稍后才能刷新看到。主从复制实现 写后读一致性若用户访问:可能会被修改的内容,读主否则,读从这要求实际查询前,就得考虑内容是否可能会被修改。如社交网络上的用户个人资料信息通常只能由用户本人编辑,而其他人无法编辑。简单规则:从主节点读取用户自己的档案,在从节点读取其他用户的档案。若应用大部分内容都可能被用户编辑,则上面方案就没啥用,因为大部分内容都读主节点,导致丧失读操作的扩展性。就得考虑其他标准来决定是否读主。如跟踪最近更新时间,若更新后1min 内,则总是读主节点。并监控从节点的复制延迟程度,避免对任意比主节点滞后超过一分钟的从节点发出查询。客户端还可记住最近更新时的时间戳,并附带在读请求中,据此,系统可确保对该用户提供读服务时,都应该至少包含了该时间戳的更新。若当前从节点不够新,可读另一个从节点或一直等待从节点直到收到最近的更新。时间戳可以是逻辑时间戳(指示写入顺序的日志序列号)或实际系统时钟。若副本分布在多IDC(如考虑与用户的地理接近及高可用性),会更复杂。必须先把请求路由到主节点所在IDC(该IDC可能离用户很远)。若同一用户从多个设备请求服务,如桌面浏览器和移动APP,就更复杂了。这时,可能就需提供跨设备的写后读一致性,即若用户在某设备输入一些信息,然后在另一个设备查看,则应该看到刚输入的信息。此时,还需考虑:记住用户上次更新时间戳的方法实现更困难,因为一台设备上运行的程序不知道另一台设备上发生啥。元数据需要一个中心存储,做到全局共享若副本分布在多IDC,无法保证来自不同设备的连接会路由到同一IDC。如用户台式计算机使用家庭宽带连接,而移动设备使用蜂窝数据网络,则设备的网络路线可能完全不同。若方案要求必须读主,则首先要确保来自不同设备的请求路由到同一IDC。
该案例违反因果律。 想象先生和夫人之间的对话:这两句之间有因果关系:夫人听到先生的问题并回答该问题。想象第三者老王在通过从节点听对话。 夫人说的内容是从一个延迟很低的从节点读取,但先生所说的内容,从节点的延迟要大的多,如图-5,于是该观察者会听到:对观察者来说,看起来好像夫人在先生发问前就回答了问题。分片来防止这种异常,需要新类型的保证:一致前缀读(consistent prefix reads),若一系列写入按某个顺序发生,那么任何人读取这些写入时,也会看见它们以同样的顺序出现。这是分片数据库中的特殊问题。若数据库总以相同顺序写入,则读总会看到一致的序列,不会发生这种异常。许多分布式数据库中,不同分片独立运行,因此不存在全局写入顺序。这就导致,当用户从DB读数据时,可能会看到DB某些部分处于较旧状态,某些处于较新状态。解决方案确保任何具有因果顺序关系的写人都交给一个完成,但该方案实际的实现效率大打折扣 。
前面异步复制读异常的第二个案例,出现用户数据向后回滚的怪状。若用户从不同【从节点】多次读取,就可能这样。如图-4显示用户2345两次进行相同查询:首先查询延迟很小的从节点然后是延迟较大的从节点若用户刷新网页,而每个请求被路由到一个随机的服务器,这种情况是很有可能的。第一个查询返回最近由用户1234添加的评论,但第二个查询不返回任何东西,因为滞后的从节点还没有拉取写入的内容。效果上相比第一个查询,第二个查询是在更早的时间点来观察系统。若第一个查询未返回任何内容,则问题不大,因为用户2345可能不知道用户1234最近添加了评论但若用户2345先看见用户1234的评论,然后又看到它消失,则对用户2345,就会感觉头大单调读保证这种异常不会发生。这是比强一致性(strong consistency)弱,但比最终一致性强的保证。当读取数据时,可能会看到一个旧值;单调读取仅意味着若一个用户顺序多次读取,则不会看到时间后退,即若先前读取到较新的数据,后续读取不会得到更旧数据。实现单调读取的一种方案:确保每个用户总从同一副本读取(不同用户可读不同副本)。如基于用户ID散列选择副本,而非随机选择副本。但若该副本失败,用户的查询将需重新路由到另一个副本。
复制延迟的案例容忍节点故障只是使用复制的一个原因。其它原因包括:可扩展性,采用多节点处理更多请求低延迟,让副本在地理位置上更接近用户主从复制要求所有写请求都主节点处理,从节点只能处理。读多写少场景,这是不错的选择:创建多个从节点,将读请求分散到所有的从节点,从而减轻主节点的负载,并允许向最近的副本发送读请求。这种可伸缩结构下,只需添加更多从节点,就能提高读请求的服务吞吐量。但这只适于异步复制,若试图同步复制到所有从节点,则单节点故障或网络中断将使整个系统无法写入。且节点越多,故障概率越高,所以完全同步的配置很不可靠。最终一致性若应用正好从一个异步的从节点读取时,而该从节点落后于主节点,它可能会看到过期数据,导致数据库中不一致:由于并非所有写入都反映在从节点,若同时对主、从节点发起相同查询,可能得到不同结果。这种不一致只是暂时的状态,若停止写DB,并等待一段时间,从节点最终会赶上并与主节点保持一致。不只有NoSQL数据库是最终一致的:关系型数据库中的异步复制追随者也有相同的特性。“最终”一词故意含糊不清,理论上,副本落后的程度无限制。正常操作中,主节点和从节点上完成写操作之间的时间延迟(复制滞后)可能不足1s,这样的滞后,在实践中通常不会导致太大影响。但若系统在接近极限情况下运行或网络存在问题,延迟可轻松超过几秒甚至几分钟。
RC 和 快照隔离 级别可防止某些竞争条件,但并非全部。一些棘手案例,如写偏斜 和 幻读,会发现可悲情况:隔离级别难理解,且不同DB实现不一(如RR含义天差地别)若检查应用层代码很难判断特定隔离级别下是否安全,尤其是大型系统,无法预测各种并发无检测竞争条件的好工具。理论上,静态分析可能有所帮助,但更多技术还没法实际应用。并发问题测试也很难,一切取决于时机而这些还不是新问题,1970s引入了较弱隔离级别以来一直这样。研究人员的答案都很简单:使用可串行化隔离级别!可串行化隔离是最强隔离级别。保证即使事务可以并发执行,但最终结果和串行执行一样。因此数据库保证,若事务在单独运行时正常运行,则它们在并发运行时仍正确,即DB能防止所有可能的竞争条件。若可串行化比弱隔离级别好得多,那为何没啥人用?支持可串行化DB都使用如下三种技术之一:严格串行顺序执行事务两阶段锁定(2PL, two-phase locking),几十年来几乎唯一可行选择乐观并发控制技术,如可串行化快照隔离本文主要在单节点DB下讨论这些技术。3.1 真的串行执行避免并发最简单方法就是完全不并发:即在一个线程上按序执行事务。这完全回避了检测、防止事务冲突。看着很直接的想法,但DB设计人员在 2007 年才确信,单线程循环执行事务可行。若多线程并发在过去的30年中被认为是获得良好性能的关键所在,那么究竟是什么改变致使单线程执行?如下两个进展促使我们重新审视:RAM越来越便宜,许多场景现在都能将完整活动数据集加载到内存。当事务所需数据都在内存,事务处理的执行速度要比等待数据从磁盘加载时快得多。数据库设计人员意识到 OLTP 事务通常执行很快,而且只产生少量读写操作。相比之下,长时间运行的分析查询通常只读,可在一致性快照(使用快照隔离)上运行,而不需要运行在串行主循环里串行执行事务的方法在 VoltDB/H-Store,Redis 和 Datomic 中实现。单线程执行的系统有时可以比支持并发的系统效率更好,尤其是可以避免锁开销。但吞吐量上限为单 CPU 核吞吐量。为充分利用单线程,需要与传统形式的事务做出不同调整。3.1.1 存储过程中封装事务DB早期阶段,采用事务包含整个用户操作流程。如预订机票涉及多阶段(搜索路线,票价和可用座位,决定行程,在行程的某航班订座,输入乘客信息,最后付款)。DB设计者认为,若整个过程是个事务,则它就可被原子化执行。但人类做出决定并回应的速度缓慢。若DB事务需等待用户输入,则DB需支持潜在的大量并发事务,其中大部分是空闲的。大多DB不能高效完成这项工作,因此几乎所有的 OLTP 应用程序都避免在事务中等待交互式的用户输入,以此来保持事务简短。在 Web 上,这意味着事务在同一个 HTTP 请求中被提交,而不会跨越多个请求。一个新的 HTTP 请求就开始一个新事务。即使已经将人为交互从关键路径中排除,事务仍以交互式客户端 / 服务器风格执行,一次一个请求语句。应用程序提交查询,读取结果,可能根据第一个查询的结果进行另一个查询,依此类推。查询和结果在应用程序代码(在一台机器上运行)和数据库服务器(在另一台机器上)之间来回发送。在这种交互式的事务方式中,应用程序和数据库之间的网络通信耗费了大量的时间。如果不允许在数据库中进行并发处理,且一次只处理一个事务,则吞吐量将会非常糟糕,因为数据库大部分的时间都花费在等待应用程序发出当前事务的下一个查询。在这种数据库中,为了获得合理的性能,需同时处理多个事务。因此,采用单线程串行执行的系统不支持交互式的多语句事务。应用程序必须提前将整个事务代码作为存储过程提交给DB。这些方法差异如图-9。将事务所需数据都加载到内存,则存储过程可更快执行,而无需等待网络或磁盘I/O。3.1.2 存储过程的优缺点存储过程在关系型DB已存在一段时间,自 1999 年以来一直是 SQL 标准(SQL/PSM)一部分,但名声有点不好:每个DB厂商都有自己的存储过程语言(Oracle的PL/SQL,SQL Server的T-SQL,PostgreSQL的PL/pgSQL 等)。这些语言并未跟上通用编程语言发展,所以看起来丑陋过时,而且缺乏大多数编程语言中能找到的库的生态在DB中运行代码难以管理:与应用服务器相比,更难调试,更难保持版本控制和部署,更难测试,难集成到指标收集系统来进行监控DB通常比应用服务器对性能敏感,因为单个数据库实例通常由许多应用服务器共享。DB中一个写得不好的存储过程(如占用大量内存或 CPU 时间)会比在应用服务器中相同的代码造成更多的麻烦但这些问题都能克服。现代的存储过程实现放弃了 PL/SQL,而是使用现有的通用编程语言:VoltDB 使用 Java 或 Groovy,Datomic 使用 Java 或 Clojure,而 Redis 使用 Lua。存储过程与内存存储使得在单个线程上执行所有事务变得可行。由于不需要等待 I/O,且避免了并发控制机制的开销,它们可以在单个线程上实现相当好的吞吐量。VoltDB 还使用存储过程进行复制:但不是将事务的写入结果从一个节点复制到另一个节点,而是在每个节点上执行相同的存储过程。因此 VoltDB 要求存储过程是 确定性的(在不同的节点上运行时,它们必须产生相同的结果)。举个例子,如果事务需要使用当前的日期和时间,则必须通过特殊的确定性 API 来实现。3.1.3 分区串行执行所有事务使并发控制更简单,但DB事务吞吐量被限制为单机单核速度。虽然只读事务能使用快照隔离在其它地方执行,但对写入吞吐量较高应用,单线程事务处理器可能成为一个严重瓶颈。为伸缩至多个CPU核和多个节点,可对数据分区,VoltDB 支持这样做。若找到一种对数据集分区方法,以便每个事务只需在单分区中读写数据,则每个分区就能拥有自己独立运行的事务处理线程。此时,可为每个分区指派一个独立CPU核,则 DB 事务吞吐量就能与 CPU 核数保持线性伸缩。但对跨分区的任何事务,DB必须在涉及的所有分区之间协调事务。存储过程需跨越所有分区加锁执行,以确保整个系统可串行化。由于跨分区事务具有额外协调开销,所以它们比单分区事务慢得多。 VoltDB 报告的吞吐量大约是每秒 1000 个跨分区写入,比单分区吞吐量低几个数量级,并且不能通过增加更多的机器来扩展性能。事务是否可以是划分至单个分区很大程度上取决于应用数据的结构。简单KV数据通常可以非常容易地进行分区,但是具有多个次级索引的数据可能需要大量跨分区协调。3.1.4 小结满足如下特定约束条件,串行执行事务可实现串行化隔离:事务简短高效,只要有一个缓慢事务,就会拖慢影响所有其它事务性能仅限于活跃数据集完全能放入内存的case。很少访问的数据可能会被移动到磁盘,但万一需要在单线程事务中访问,就会拖累系统 1。写吞吐量必须低到能在单 CPU 核处理,否则需要分区,事务划分至单个分区,最好无需跨分区协调事务跨分区事务虽然也能支持,但比例必须很小若事务需访问不在内存中的数据,最佳实践可能是中止事务,异步将数据提取到内存,同时继续处理其他事务,然后在数据加载完毕时重启事务,这被称为 反缓存(anti-caching)。 ↩︎
苛刻的数据存储系统中,很多可能出错的case:数据库软件、硬件可能随时失效(包括正在执行写操作的过程中)应用程序可能随时崩溃(包括一系列操作的中间某步)网络中断可能会意外切断数据库与应用的连接,或数据库之间的连接。多个客户端可能同时写入DB,导致数据覆盖客户端可能读到无意义的、部分更新的数据客户端之间由于边界条件竞争所引入的各种奇怪问题为实现高可靠,系统必须处理这些问题。但完善容错机制工作量巨大,要仔细考虑所有可能出错的事情,并充分测试。十年来,事务一直是简化这些问题的首选机制。事务将应用程序的多个读、写操作组合成一个逻辑单元。即事务中的读、写操作是个执行的整体:整个事务要么成功(提交),要么失败(中止或回滚)。若失败,程序可安全地重试。如此,便无需再担心部分失败的情况,应用层的错误处理就简单很多。也许你觉得事务就这么简单了,但细究起来也许不止于此。事务不是先天存在的;它是为简化应用层的编程模型而人为创造的。通过事务,应用程序可忽略某些潜在的错误和复杂的并发问题,因为DB会替应用处理好(称之为安全保证,safety guarantees)。并非所有应用都需要事务,有时可弱化事务处理或完全放弃事务(如为获得更高性能或更高可用性)。一些安全相关属性也可能会避免引入事务。如何判断是否需要事务?先要确切理解事务能为我们提供什么安全保障及其代价。本文将研究许多出错案例,并探索DB防范这些问题的算法和设计。尤其是并发控制领域,深入讨论各种竞争条件及DB的隔离级别。本文同时适用于单机DB与分布式DB。1 深入理解事务目前几乎所有关系型DB和一些非关系DB都支持事务。大多遵循IBM System R(第一个SQL数据库)在1975年的设计。50年来,尽管一些细节实现变化,但总体思路大同小异。MySQL、PostgreSQL、Oracle 和 SQL Server 等DB中的事务支持与 System R 极为相似。2000年后,NoSQL普及,目标在关系DB现状上,通过提供新数据模型和内置的复制和分区改进传统的关系模型。然而,事务成了这变革的受害者:新一代DB完全放弃事务或重新定义,即替换为比以前弱得多的保证。随新型分布式DB炒作,人们普遍认为事务是可扩展性的对立面,大型系统都必须放弃事务以获得更高性能和高可用性。但另一方面,还有一些DB厂商坚称事务是 “关键应用” 和 “高价值数据” 所必备的重要功能。这两种观点都有些夸张。事务有其优势和局限性。为理解事务权衡,来看看正常运行和各种极端case,看看事务到底能给我们什么。1.1 ACID到底意味着什么事务所提供的安全保证即ACID:原子性(Atomicity)一致性(Consistency)隔离性(Isolation)持久性(Durability)它由 TheoHärder 和 Andreas Reuter 于 1983 年为精确描述DB的容错机制。但实际上不同DB的 ACID 实现不尽相同。仅隔离性含义就有很多争议。当一个系统声称自己 “兼容ACID” 时,实际上能提供什么保证并不清楚。ACID现在几乎已经变成一个营销术语。不符合ACID的系统有时被称为BASE:基本可用性(Basically Available)软状态(Soft State)最终一致性(Eventual consistency)听起来比 ACID 还含糊不清,BASE唯一能确定的是 “它不是 ACID”,此外没有承诺任何东西。1.1.1 原子性-Actomicity事务是一个原子操作单元,其对数据的修改,要么全都执行,要么全都不执行。这个术语在计算机不同领域意味着相似但却微妙的差异。多线程编程中,若某线程执行一个原子操作,这意味着其它线程无法看到该操作的中间结果。系统只能处于操作前或操作后的状态,而非两者之间状态。而ACID的原子性并并不关系到多个操作的并发。它并未描述多个线程试图同时访问相同的数据会怎样,后者其实由ACID的隔离性所定义。ACID原子性其实描述客户端发起一个包含多个写操作的请求时可能发生的情况。如在完成部分写入后,系统就发生诸如进程崩溃,网络中断,磁盘变满或违反某种完整性约束。把多个写操作纳入到一个原子事务,万一出现这些故障而导致无法完成最终提交,则事务会中止,且DB须丢弃或撤销那些局部完成的更改。若无原子性,当多个更新操作中间发生错误,就得知道哪些更改已生效,哪些未生效,这寻找过程会很麻烦。或许应用程序可以重试,但情况类似,并且可能导致重复更新或错误的结果。原子性大大简化了这个问题:若事务已中止,应用程序可确定它没有改变任何东西,所以应用能安全重试。因此,ACID的原子性的定义特征:出错时中止事务,并将部分完成的写入全部丢弃。 或许 可中止性(abortability)是更恰当的术语。1.1.2 一致性在事务开始和完成时,数据都必须保持一致状态。这意味着所有相关的数据规则都必须应用于事务的修改,以保持完整性。事务结束时,所有内部数据结构(如B树索引或双向链表)也都必须正确。一致性在不同场景有着不同含义:副本一致性及异步复制模型,引出最终一致性问题一致性哈希,是某些系统用于动态分区再平衡的一种策略CAP定理中,一致性一词用于表示线性化ACID中,一致性指DB在处于应用程序期待的“预期状态”ACID一致性主要是对数据有特定的预期状态,任何数据更改必须满足这些状态约束(或恒等条件)。例账单系统中,所有账户必须借贷相抵。若某事务从一个有效的状态开始,且事务处理期间任何写操作都没有违背约束,则最后结果依然符合有效状态。这种一致性本质要求应用层来维护状态一致,应用程序负责正确定义事务来保持一致性。这不是DB能保证的:即若你提供的数据违背恒等条件,DB也很难检测进而阻止该操作。DB 能完成针对某些特定类型的恒等约束检查,如外键约束或唯一性约束。但主要还是靠应用程序定义数据的有效/无效状态,DB 主要还是负责存储。原子性,隔离性和持久性是DB 本身属性,而ACID的一致性更多是应用层的属性。应用可能借助DB的原子性和隔离属性来达到一致性,但一致性本身并不源于DB。因此,字母C其实不应属于ACID 1。1.1.3 隔离性 Isolation一个事务所做的修改在最终提交前对其他事务不可见。大多DB都支持同时被多个客户端访问。若读、写的是不同数据,肯定没问题,但若访问相同记录,则可能会遇到并发问题。图-1的简单案例,假设两个客户端同时增加DB中的一个计数器。这里假设DB不支持自增。每个客户端先读取当前值,加1 ,再写回新值。两次增长,计数器应从42增至44,但由于竞态条件,最终结果是43 。ACID的隔离性意味着并发执行的多个事务相互隔离:互不交叉。传统DB教科书将隔离性定义为串行化,这意味着可以假装它是DB上运行的唯一事务。虽然实际上它们可能同时运行,但DB系统要确保当事务提交时,其结果与串行执行完全相同。然而实践中,由于性能问题,很少使用串行化的隔离。Oracle 11甚至不实现它,Oracle虽有个名为 “可串行的” 隔离级别,但本质上实现的快照隔离,提供了比串行化更弱的保证。1.1.4 持久性 Durability一旦事务提交,它对于数据的修改会持久化到DBDB系统本质是提供一个安全可靠的地方存储数据,而不用担心丢失。持久性就是这样的承诺,保证一旦事务提交成功,即使发生硬件故障或DB崩溃,事务写入的任何数据也不会丢失。单节点DB,持久性意味着数据已被写入非易失性存储设备,如硬盘、SSD。写入过程中,通常涉及预写日志,以便在磁盘数据损坏时可进行恢复。支持复制的DB中,持久性意味着数据已成功复制到多个节点。为实现持久性保证,DB必须等到这些写入或复制完成后,才能报告事务成功提交。完美的持久性是不存在的:若所有硬盘和所有备份同时被(人为)销毁,那DB也无能为力。没有技术能提供绝对的持久性保证。只有各种降低风险的技术,包括写盘,复制到远程机器和备份。1.2 单对象和多对象操作ACID的原子性和隔离性主要针对客户端在同一事务中包含多个写时,DB提供的保证:原子性若一系列写操作中间出错,则事务必须中止,并丢弃当前事务的所有写入。即DB免去了用户对部分失败的担忧,要么全部成功,要么全部失败的保证。隔离性同时运行的事务互不干扰。如若一个事务进行多次写入,则另一个事务要么看到其全部写入结果或什么都看不到,而不该是中间的部分结果。这些定义假设一个事务中修改多个对象(如行,文档,记录)。这种多对象事务目的通常是为了在多个数据对象之间保持同步。图-2展示一个电邮案例。显示用户未读件数:SELECT COUNT (*) FROM emails WHERE recipient_id = 2 AND unread_flag = true但若邮件太多,查询太慢,决定用单独字段存储未读数量。每当收到一个新邮件,增加未读计数器,当邮件标记为已读,也得减少该计数器。用户2遇到异常情况:邮件列表显示了未读消息,但计数器显示为零未读消息,因为还没更新 2。隔离性将保证用户2要么同时看到新邮件和增长后的计数器,要么都看不到,而不是前后矛盾的中间结果。图-3说明了对原子性需求:若事务过程中出错,导致邮箱和未读计数器的内容不同步,则事务将被中止,事务将被中止,且之前插入的电子邮件将被回滚。1.2.1 单对象写入原子性和隔离性也适用单个对象更新。如若向DB写入20KB的JSON文档:若发送第一个10KB后网络连接中断,DB是否只存储了无法完整解析的10KB JSON片段呢?若DB正在覆盖磁盘上的前一个值的过程中电源发生故障,最终是否导致新旧值混杂若另一个客户端在写入过程中读取该文档,是否会看到部分更新的内容这些问题很让人头大,故存储引擎必备设计:对单节点、单个对象层面上提供原子性和隔离性(如 KV 对)。原子性可以通过使用日志来实现崩溃恢复(B+树),并对每个对象加锁实现隔离 。某DB也提供高级原子操作 4,如自增,这就不再需要像图-1那样执行读取 - 修改 - 写回。类似的CAS操作,即只有当前值未被其他并发修改过,才允许执行写。这些单对象操作可有效防止多个客户端并发修改同一对象时的丢失更新。但它们不是通常意义上的事务。虽然CAS及其他单一对象操作有时被称为 “轻量级事务”,甚至出于营销目的被称为 “ACID”,但存在误导。事务通常针对的是多个对象,将多个操作聚合为一个执行单元的机制。1.2.2 多对象事务的必要性许多分布式数据存储不支持多对象事务,因为多对象事务很难跨分区实现,且在高可用性或高性能情况下也碍事。但分布式数据库中实现事务,并没有什么原理障碍。但是否需要多对象事务?是否可能只用KV数据模型和单对象操作就能满足应用需求呢?确有一些场景,单对象插入、更新和删除就够了。但很多其他场景要求协调写入几个不同的对象:关系数据模型中,表中的某行可能是另一个表中的外键。类似的,图数据模型中,顶点有着到其他顶点的多个边。多对象事务用以确保这些外键引用始终有效:当插入几个相互引用的记录时,保证外键总是正确、最新,否则数据更新就毫无意义。文档数据模型,若待更新的字段都在同一文档,则可视为单个对象,此时无需多对象事务。但缺join功能的文档DB会鼓励非规范化。当更新这种非规范化数据时,如图-2,就需一次更新多个文档。事务就能有效防止非规范化数据出现不同步带有二级索引的DB(除了纯粹KV存储系统以外几乎都有),每次更改值时都需同步更新索引。事务角度,这些索引是不同的DB对象:如若无事务隔离,记录可能出现在一个索引中,但没有出现在另一个索引中,因为第二个索引的更新还没发生这些应用即使没有事务支持,或许仍可工作。但无原子性保证,错误处理就复杂多了,缺乏隔离性,就会导致并发问题。1.2.3 处理错误和中止事务的一大关键特性,若出错,中止所有操作,之后可安全重试。ACID DB基于此理念:若DB存在违反原子性、隔离性或持久性的风险,则完全放弃事务,而非部分放弃。但并非所有系统都遵循这理念。如无主节点复制的数据存储会在 “尽力而为” 基础上尝试多做点。可概括理解为为:DB已尽其所能,但万一遇到错误,系统不会撤销已完成的操作,此时需应用程序责任从错误中恢复。错误无法避免,但我们倾向于只考虑正常case,而忽略错误处理。如Rails ActiveRecord和 Django这类ORM框架,事务异常时不会重试而只是简单抛堆栈信息,用户虽然得到错误提示,但所有之前的输入都被丢弃了。这肯定不该发生,中止的重点就是允许安全重试。重试中止的事务虽是个简单有效的错误处理机制,但不完美:若事务实际已执行成功,但返回给客户端的消息在网络传输时故障(所以对客户端来说,事务是失败的),则重试就会导致重复执行,此时需额外的应用层级去重机制若错误由高负载导致,则重试事务将更糟。可设置重试次数阈值,如指数回退,并处理过载问题临时性故障(如死锁,网络中断和节点故障切换)导致的错误需要重试。但发生个永久性故障(如违反约束),则重试毫无意义若事务在DB之外也有副作用,即使事务被中止,也可能发生这些副作用。如发送电子邮件,那你肯定不希望每次重试都重发。若想确保多个不同系统同时提交或放弃,考虑两阶段提交若客户端进程在重试中也失效,没有其他人能继续负责重试,则那些写入数据都将丢失乔・海勒斯坦(Joe Hellerstein)指出,在 Härder 与 Reuter 的论文中,“ACID 中的 C” 是被 “扔进去凑缩写单词的”【7】,而且那时候大家都不怎么在乎一致性。 ↩︎可以说邮件应用中的错误计数器并不是什么特别重要的问题。但换种方式来看,你可以把未读计数器换成客户账户余额,把邮件收发看成支付交易。 ↩︎这并不完美。若TCP连接中断,则事务必须中止。假定中断发生在客户端请求提交之后,但在服务器确认提交完成前,则客户端最后并不知道事务是否已完成提交。为解决该问题,事务管理器可定义一个唯一的事务标识符来逻辑上绑定一组写操作,且该事务标识符独立于TCP连接。 ↩︎严格地说,原子自增(atomic increment) 这个术语在多线程编程的意义上使用了原子这个词。ACID下应该称为 隔离的(isolated) 的或 可串行的(serializable) 的增量。 ↩︎
本系列文章描述了DB并发控制的黯淡:2PL虽保证了串行化,但性能和扩展不好性能良好的弱隔离级别,但易出现各种竞争条件(丢失更新,写倾斜,幻读串行化的隔离级别和高性能就是相互矛盾的吗?也许不是,一个称为可串行化快照隔离(SSI, serializable snapshot isolation)算法很有前途。提供完整的可串行化保证,而性能与快照隔离相比只有很小性能损失。 SSI在 2008 年首次被提出,如今既用于单节点DB(PostgreSQL9.1后的可串行化)和分布式DB(FoundationDB)。由于 SSI 与其他并发控制机制相比还很年轻,还在实践中证明自己。3.3.1 悲观锁、乐观锁两阶段锁是一种 悲观锁机制(pessimistic) ,其设计原则:若有操作可能出错(如与其他事务发生锁冲突),则直接放弃,等待直到绝对安全。和多线程编程中的互斥锁一致。某种意义上,串行执行是很悲观的:事务期间,每个事务对整个DB(或DB的一个分区)持有互斥锁,我们只能假定每笔事务执行够快、短时持锁,来稍微弥补悲观色彩相比之下,串行化快照隔离 是一种 乐观锁。如若存在潜在冲突,也不阻止事务,而是继续执行事务,寄希望于一切平安。而当事务想提交时(只有可串行化的事务才被允许提交。),DB会检查是否冲突(即违反隔离性原则):若是,则中止事务并重试。乐观锁是古老的想法,其优缺点争论已久。若存在很多冲突,则性能不佳,大量事务需中止。若系统已接近最大吞吐量,重试的额外负载会使系统性能更差。但若系统有足够性能提升空间,且事务之间争用不大,乐观锁比悲观锁更高效。可交换的原子操作能减少争用:如若多个事务同时增加某计数器,则应用增量的顺序(只要计数器不在同一个事务中读取)就无关紧要,所以并发增量可全部应用且无需冲突。SSI基于快照隔离,即事务中的所有读取都基于DB的一致性快照(参阅本文的快照隔离、可重复读),这和早期乐观锁的主要区别。在快照隔离基础上,SSI新增一种算法检测写入之间的串行化冲突,并确定要中止哪些事务。3.3.2 基于过期条件来决策讨论写倾斜时,有一种场景:事务先从DB读一些数据,根据查询结果决定采取后续操作,如修改数据。但快照隔离下,数据可能在查询期间就已被其他事务修改,导致原事务在提交时决策的依据信息已变。即事务基于某些前提而行动,事务开始时条件成立,如目前有两名医生正在值班,当事务提交时,数据可能已改变,前提已不再成立。当应用执行查询时(如当前有多少医生在值班),DB本身不知道应用会如何使用该查询结果。为了安全,DB假定对该结果集的变更都可能会使该事务中的写无效。 即事务中的查询与写可能存在因果依赖关系。为提供可串行化隔离,DB必须检测事务是否会修改其它事务的查询结果,并在此情况下中止写事务。DB如何知道查询结果是否已变?可分为如下case:读取是否作用于一个(即将)过期的MVCC对象(读取之前已经有未提交的写入)检查写是否影响即将完成的读取(读取后,又有新写入)3.3.3 检测旧MVCC读取为防止这种异常,DB需跟踪一个事务由于MVCC可见性规则而被忽略的其它事务写。当事务提交时,DB会检查是否存在被忽略的写现在已被提交的,若是,则当前事务必须中止。为何要等到提交?当检测到读旧值,为何不立即中止事务43,考虑如下场景:若事务43是只读事务,则无需中止,因为无写倾斜风险当事务43读DB 时,DB还不知道事务是否要稍后执行写操作此外,事务42可能在事务43提交时,被中止或仍处于未被提交,因此读取的并非旧值通过避免不必要的中止,SSI可高效支持那些需在一致性快照中运行很长时间的读事务。3.3.4 检测写是否影响之前的读读取数据后,另一个事务修改了数据:3.3.5 性能许多工程细节会影响算法实际效果。如一个需权衡考虑的是跟踪事务的读、写的粒度:若DB详细跟踪每个事务的操作(细粒度),确实能准确确定哪些事务需中止,但记录元数据的开销可能也很大而跟踪速度更快时(粗粒度),可能导致更多不必要的事务中止有的case读过期数据不会造成太大影响:这还是完全取决于具体场景,有时可确信执行结果都是可串行化的,PostgreSQL 使用该理论减少不必要的中止。相比于2PL,可串行化快照隔离最大优点:事务无需阻塞等待其它事务所持有的锁。这和快照隔离一样,读写不互相阻塞。这使查询延迟更稳定、可预测。尤其是只读查询可运行在一致快照,无需任何锁,对读密集系统友好。相比于串行执行,可串行化快照隔可突破单CPU核吞吐量限制:FoundationDB将检测到的串行化冲突分布在多台机器,从而提高吞吐量。即使数据可能跨多台机器分区,事务也能在保证可串行化隔离等级同时,读写多个分区中的数据。事务中止率会显著影响SSI性能。如长时间读、写数据的事务很可能会发生冲突并中止,因此SSI要求读写型事务尽量短(但只读的长事务则没问题)。总体上,对慢事务,SSI比2PL或串行执行更能容忍。
事务作为抽象层,允许应用忽略DB 内部一些复杂并发问题和某些硬件、软件故障,简化应用层的处理逻辑:事务中止(transaction abort),而应用仅需重试。对复杂访问模式,事务可大大减少需要考虑的潜在错误情景数量。如没有事务,各种错误情况(进程崩溃,网络中断,停电,磁盘已满,意外并发)意味着数据可能各种不一致。如非规范化的数据可能很容易与源数据不同步。没有事务处理,就很难推断复杂的交互访问可能对数据库造成影响。隔离等级要点:脏读客户端读到另一客户端尚未提交的写。读已提交 或更强的隔离级别可防止脏写一个客户端覆写了另一客户端尚未提交的写。几乎所有事务实现都可防止读倾斜(不可重复读)同一个事务中,客户端在不同时间点看见数据库不同值。快照隔离 用于解决这问题,允许事务从某特定时间点的一致性快照中读数据,MVCC实现更新丢失两个客户端同时执行 读取 - 修改 - 写入。其中一个写操作,在没有合并另一个写入变更情况下,直接覆盖了另一个写结果。所以导致数据丢失。快照隔离的一些实现可自动防止这种异常,而另一些实现则需手动锁定(SELECT FOR UPDATE)写倾斜一个事务读取一些东西,根据它所看值决定,并将该决定写数据库。但写时,该决定的前提不再true。只有可串行化隔离才能防止幻读事务读取某些符合查询条件的对象,同时另一客户端写,改变了先前查询结果。快照隔离可防简单的幻读,但写倾斜的幻读需特殊处理,如采用索引范围锁定。弱隔离级别可防止上述的一些异常,但还得应用程序开发人员手动处理其它复杂 case,如显式加锁。只有可串行化隔离级别能防所有这些问题,有三种不同实现方案:严格串行执行事务若每个事务的执行很快,且单CPU核即可满足事务吞吐要求,这是简单有效的选择2PL数十年来,一直是可串行化的标准实现,但许多应用考虑性能而放弃使用之可串行化快照隔离(SSI)最新算法,避免先前方案的大部分缺点。使用乐观锁机制,允许事务并发执行而不互相阻塞。仅当事务提交时,才检查可能的冲突,若发现违背串行化,则中止事务
多个事务并发写相同对象时,会出现脏写、更新丢失两种竞争条件。为避免数据不一致,可:借助DB内置机制或通过显式加锁以执行原子写操作。但这还不是并发写可能导致的全部问题。2.4.1 值班程序医院通常会同时要求几个医生待命,前提是至少有一位医生在待命。医生可放弃他们的班次(如若自己生病了),只要至少有一个同事在这天的班中继续工作。Alice、Bob两位值班医生都病了,所以他们都决定请假。但他们恰在同一时刻点击调班按钮每笔事务总先检查是否至少有两名医生目前在值班。若是,则有一名医生可安全离开去休班。由于DB使用快照隔离,两次检查都返回2,所以两事务都进入下一阶段:Alice更新自己的记录为休班Bob也更新自己的记录两个事务都成功提交,最后结果是无医生值班,显然违反了至少有一名医生得值班的业务需求。2.4.2 写倾斜这种异常即写倾斜,不是脏写、丢失更新。这俩事务更新的是两个不同对象(Alice 和 Bob 各自值班记录)。这里发生的冲突不是那么明显,但显然也是竞态:若两个事务串行,则第二个医生就不能歇班。异常行为只有在事务并发时才可能。可将写倾斜视为【广义的丢失更新】。即若两事务读取相同一组对象,然后更新其中一部分:不同事务,更新不同对象,则可能发生写倾斜不同事务,更新同一对象,则可能脏写或丢失更新很多方法可防止丢失更新。但对写倾斜,方案更受限:由于涉及多对象,单对象的原子操作无效基于快照隔离来实现自动检测丢失更新也有问题:PostgreSQL可重复读,MySQL/InnoDB 可重复读,Oracle可串行化或SQL Server快照隔离级别中,都不支持自动检测写倾斜。自动防止写倾斜要求真正的可串行化隔离某些DB支持自定义约束,然后由DB强制执行(如唯一性,外键约束或特定值限制)。但为指定至少有一名医生必须在线,涉及多个对象的约束,大多DB都未内置这种约束,但你可使用触发器或物化视图来实现类似约束若无法使用可串行化,则次优方案可能是显式锁定事务依赖的行:BEGIN TRANSACTION; SELECT * FROM doctors WHERE on_call = TRUE # 告诉DB锁定返回的所有结果行,以用于更新 AND shift_id = 1234 FOR UPDATE; UPDATE doctors SET on_call = FALSE WHERE name = 'Alice' AND shift_id = 1234; COMMIT;2.4.3 写倾斜case理解到写倾斜的本质后,容易注意到更多case:会议室预订系统不能在同一时间对同一会议室进行多次预订。当有人想要预订时,首先检查是否存在相互冲突的预订(即预订时间范围重叠的同一房间),若无,则创建会议(参阅示例-2)1例-2 会议室预订系统,避免重复预订(在快照级别隔离下不安全)BEGIN TRANSACTION; -- 检查所有现存的与 12:00~13:00 重叠的预定 SELECT COUNT(*) FROM bookings WHERE room_id = 123 AND end_time > '2015-01-01 12:00' AND start_time < '2015-01-01 13:00'; -- 若之前的查询返回 0 INSERT INTO bookings(room_id, start_time, end_time, user_id) VALUES (123, '2015-01-01 12:00', '2015-01-01 13:00', 666); COMMIT;快照级别隔离无法防止并发用户预订同一会议室。为避免预订冲突,需可串行化隔离级别。多人游戏例-1中,使用一个锁来防止丢失更新(即两个玩家不能同时移动同一数字)。但锁不妨碍玩家将两个不同数字移动到棋盘的相同位置或其他违反游戏规则的行为。可能需更多约束,否则很容易发生写倾斜。抢注用户名在每个用户拥有唯一用户名的网站上,两个用户可能会尝试同时创建具有相同用户名的帐户。可采用事务检查名称是否被抢占,若无,则使用该名称创建账户。但和之前案例类似,快照隔离下不安全。但唯一约束是简单方案(第二个事务在提交时会因为违反用户名唯一约束而被中止)防止双重开支支付或积分服务一般需检查用户的支付数额不超过余额。可通过在用户帐户中插入一个临时支出项目,列出帐户中的所有项目,并检查总和是否为正值。由于写倾斜,可能发生两个支出项目同时插入,两个交易都不超额,但一起会导致余额变为负值。2.4.4 导致写倾斜的幻读所有这些案例都遵循类似模式: