本地事务设计(1)-深入理解事务
正文
苛刻的数据存储系统中,很多可能出错的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 [^i]。
[^i]: 乔・海勒斯坦(Joe Hellerstein)指出,在 Härder 与 Reuter 的论文中,“ACID 中的 C” 是被 “扔进去凑缩写单词的”【7】,而且那时候大家都不怎么在乎一致性。
1.1.3 隔离性 Isolation
一个事务所做的修改在最终提交前对其他事务不可见。
大多DB都支持同时被多个客户端访问。若读、写的是不同数据,肯定没问题,但若访问相同记录,则可能会遇到并发问题。
图-1的简单案例,假设两个客户端同时增加DB中的一个计数器。这里假设DB不支持自增。每个客户端先读取当前值,加1 ,再写回新值。两次增长,计数器应从42增至44,但由于竞态条件,最终结果是43 。
ACID的隔离性意味着并发执行的多个事务相互隔离:互不交叉。传统DB教科书将隔离性定义为串行化,这意味着可以假装它是DB上运行的唯一事务。虽然实际上它们可能同时运行,但DB系统要确保当事务提交时,其结果与串行执行完全相同。
图-1:两个客户端之间的竞争状态同时递增计数器
然而实践中,由于性能问题,很少使用串行化的隔离。Oracle 11甚至不实现它,Oracle虽有个名为 “可串行的” 隔离级别,但本质上实现的快照隔离,提供了比串行化更弱的保证。
1.1.4 持久性 Durability
一旦事务提交,它对于数据的修改会持久化到DB
DB系统本质是提供一个安全可靠的地方存储数据,而不用担心丢失。持久性就是这样的承诺,保证一旦事务提交成功,即使发生硬件故障或DB崩溃,事务写入的任何数据也不会丢失。
单节点DB,持久性意味着数据已被写入非易失性存储设备,如硬盘、SSD。写入过程中,通常涉及预写日志,以便在磁盘数据损坏时可进行恢复。支持复制的DB中,持久性意味着数据已成功复制到多个节点。为实现持久性保证,DB必须等到这些写入或复制完成后,才能报告事务成功提交。完美的持久性是不存在的:若所有硬盘和所有备份同时被(人为)销毁,那DB也无能为力。
复制与持久性
历史上,持久性最早意味着写入磁带存档,后来演变为写入磁盘、SSD。最近,又代表多节点间“复制(replication)”。哪种实现更好呢?
没有一个是完美的:
- 若写盘了但机器宕机,即使数据未丢失,重启机器或将磁盘转移到其他机器之前,都无法访问数据。而基于复制的系统还能继续可用。
- 某些故障(如停电或某特定输入导致所有节点崩溃,甚至删除所有副本),内存中的数据会丢失,故内存DB,仍要写磁盘。
- 异步复制系统中,当主节点不可用,最近的写入操作可能没有及时完成同步而导致更新丢失
- 突然断电时,特别是固态硬盘,有时甚 fsync后的数据也不能保证能正确恢复。硬盘固件和软件一样可能有 bug
- 存储引擎和文件系统之间的微妙关系,可能包含难以追踪的bug,并最终导致磁盘上的文件在系统崩溃后也损坏
- 磁盘上的数据可能会在没有检测到的情况下逐渐损坏。若已持续一段时间,副本和最近的备份都可能损坏。此时需从历史备份中恢复数据
- 固态硬盘运行前四年,30%~80%固态盘会至少产生一个坏块。磁盘的坏道率较低,但整盘失效的概率更高
- 若SSD断电,可能会在几周内开始丢失数据,具体取决于温度
没有技术能提供绝对的持久性保证。只有各种降低风险的技术,包括写盘,复制到远程机器和备份。
1.2 单对象和多对象操作
ACID的原子性和隔离性主要针对客户端在同一事务中包含多个写时,DB提供的保证:
- 原子性若一系列写操作中间出错,则事务必须中止,并丢弃当前事务的所有写入。即DB免去了用户对部分失败的担忧,要么全部成功,要么全部失败的保证。
- 隔离性同时运行的事务互不干扰。如若一个事务进行多次写入,则另一个事务要么看到其全部写入结果或什么都看不到,而不该是中间的部分结果。
这些定义假设一个事务中修改多个对象(如行,文档,记录)。这种多对象事务目的通常是为了在多个数据对象之间保持同步。图-2展示一个电邮案例。
显示用户未读件数:
SELECT COUNT (*)
FROM emails
WHERE recipient_id = 2
AND unread_flag = true
但若邮件太多,查询太慢,决定用单独字段存储未读数量。每当收到一个新邮件,增加未读计数器,当邮件标记为已读,也得减少该计数器。
用户2遇到异常情况:邮件列表显示了未读消息,但计数器显示为零未读消息,因为还没更新 ^ii。隔离性将保证用户2要么同时看到新邮件和增长后的计数器,要么都看不到,而不是前后矛盾的中间结
图-2违反隔离性:一个事务读取另一个事务的未被执行的写入(脏读)
图-3说明了对原子性需求:若事务过程中出错,导致邮箱和未读计数器的内容不同步,则事务将被中止,事务将被中止,且之前插入的电子邮件将被回滚。
图-3:原子性确保发生错误时,事务先前的任何写入都会被撤消,以避免状态不一致
多对象事务要求确定知道某种方式包含哪些读写操作。关系型DB,客户端一般和DB服务器建立TCP网络连接,因而对特定的某个连接,BEGIN TRANSACTION
和 COMMIT
之间的所有内容都属于同一事务^iii
许多非关系DB不会将这些操作组合一起。即使支持多对象API(如KV存储的multi-put API 可以在一个操作中更新多个K),但这并不一定意味着它具有事务语义:该命令可能在一些键上成功,在其他的键上失败,使数据库处于部分更新的状态。
1.2.1 单对象写入
原子性和隔离性也适用单个对象更新。如若向DB写入20KB的JSON文档:
- 若发送第一个10KB后网络连接中断,DB是否只存储了无法完整解析的10KB JSON片段呢?
- 若DB正在覆盖磁盘上的前一个值的过程中电源发生故障,最终是否导致新旧值混杂
- 若另一个客户端在写入过程中读取该文档,是否会看到部分更新的内容
这些问题很让人头大,故存储引擎必备设计:对单节点、单个对象层面上提供原子性和隔离性(如 KV 对)。原子性可以通过使用日志来实现崩溃恢复(B+树),并对每个对象加锁实现隔离 。
某DB也提供高级原子操作 [^iv],如自增,这就不再需要像图-1那样执行读取 - 修改 - 写回。类似的CAS操作,即只有当前值未被其他并发修改过,才允许执行写。
[^iv]: 严格地说,原子自增(atomic increment) 这个术语在多线程编程的意义上使用了原子这个词。ACID下应该称为 隔离的(isolated) 的或 可串行的(serializable) 的增量。
这些单对象操作可有效防止多个客户端并发修改同一对象时的丢失更新。但它们不是通常意义上的事务。虽然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之外也有副作用,即使事务被中止,也可能发生这些副作用。如发送电子邮件,那你肯定不希望每次重试都重发。若想确保多个不同系统同时提交或放弃,考虑两阶段提交
- 若客户端进程在重试中也失效,没有其他人能继续负责重试,则那些写入数据都将丢失
文章转载自公众号: JavaEdge