将事情做正确
对于只读取数据的无状态服务,出问题也没什么大不了的:你可以修复该错误并重启服务,而一切都恢复正常。像数据库这样的有状态系统就没那么简单了:它们被设计为永远记住事物(或多或少),所以如果出现问题,这种(错误的)效果也将潜在地永远持续下去,这意味着它们需要更仔细的思考【50】。
我们希望构建可靠且正确的应用(即使面对各种故障,程序的语义也能被很好地定义与理解)。约四十年来,原子性,隔离性和持久性(第7章)等事务特性一直是构建正确应用的首选工具。然而这些地基没有看上去那么牢固:例如弱隔离级别带来的困惑可以佐证(请参见“弱隔离级别”)。
事务在某些领域被完全抛弃,并被提供更好性能与可扩展性的模型取代,但更复杂的语义(例如,参阅“无领导者复制”)。一致性(Consistency) 经常被谈起,但其定义并不明确(“一致性”和第9章)。有些人断言我们应当为了高可用而“拥抱弱一致性”,但却对这些概念实际上意味着什么缺乏清晰的认识。
对于如此重要的话题,我们的理解,以及我们的工程方法却是惊人地薄弱。例如,确定在特定事务隔离等级或复制配置下运行特定应用是否安全是非常困难的【51,52】。通常简单的解决方案似乎在低并发性的情况下工作正常,并且没有错误,但在要求更高的情况下却会出现许多微妙的错误。
例如,凯尔金斯伯里(Kyle Kingsbury)的杰普森(Jepsen)实验【53】标出了一些产品声称的安全保证与其在网络问题与崩溃时的实际行为之间的明显差异。即使像数据库这样的基础设施产品没有问题,应用代码仍然需要正确使用它们提供的功能才行,如果配置很难理解,这是很容易出错的(在这种情况下指的是弱隔离级别,法定人数配置等)。
如果你的应用可以容忍偶尔的崩溃,以及以不可预料的方式损坏或丢失数据,那生活就要简单得多,而你可能只要双手合十念阿弥陀佛,期望佛祖能保佑最好的结果。另一方面,如果你需要更强的正确性保证,那么可序列化与原子提交就是久经考验的方法,但它们是有代价的:它们通常只在单个数据中心中工作(排除地理散布式架构),并限制了系统能够实现的规模与容错特性。
虽然传统的事务方法并没有走远,但我也相信在使应用正确而灵活地处理错误方面上,事务并不是最后的遗言。在本节中,我将提出一些在数据流架构中考量正确性的方式。
为数据库使用端到端的参数
应用仅仅是使用具有相对较强安全属性的数据系统(例如可序列化的事务),并不意味着就可以保证没有数据丢失或损坏。例如,如果某个应用有个Bug,导致它写入不正确的数据,或者从数据库中删除数据,那么可序列化的事务也救不了你。
这个例子可能看起来很无聊,但值得认真对待:应用会出Bug,而人也会犯错误。我在“状态,流与不可变性”中使用了这个例子来支持不可变和仅追加的数据,阉割掉错误代码摧毁良好数据的能力,能让从错误中恢复更为容易。
虽然不变性很有用,但它本身并非万灵药。让我们来看一个可能发生的,非常微妙的数据损坏案例。
正好执行一次操作
在“容错”中,我们见到了恰好一次(或等效一次)语义的概念。如果在处理消息时出现问题,你可以选择放弃(丢弃消息 —— 导致数据丢失)或重试。如果重试,就会有这种风险:第一次实际上成功了,只不过你没有发现。结果这个消息就被处理了两次。
处理两次是数据损坏的一种形式:为同样的服务向客户收费两次(收费太多)或增长计数器两次(夸大指标)都不是我们想要的。在这种情况下,恰好一次意味着安排计算,使得最终效果与没有发生错误的情况一样,即使操作实际上因为某种错误而重试。我们先前讨论过实现这一目标的几种方法。
最有效的方法之一是使操作幂等(idempotent)(参阅“幂等性”);即确保它无论是执行一次还是执行多次都具有相同的效果。但是,将不是天生幂等的操作变为幂等的操作需要一些额外的努力与关注:你可能需要维护一些额外的元数据(例如更新了值的操作ID集合),并在从一个节点故障切换至另一个节点时做好防护(参阅的“领导与锁定”)。
抑制重复
除了流处理之外,其他许多地方也需要抑制重复的模式。例如,TCP使用数据包上的序列号,在接收方将它们正确排序。并确定网络上是否有数据包丢失或重复。任何丢失的数据包都会被重新传输,而在将数据交付应用前,TCP协议栈会移除任何重复数据包。
但是,这种重复抑制仅适用于单条TCP连接的场景中。假设TCP连接是一个客户端与数据库的连接,并且它正在执行例12-1中的事务。在许多数据库中,事务是绑定在客户端连接上的(如果客户端发送了多个查询,数据库就知道它们属于同一个事务,因为它们是在同一个TCP连接上发送的)。如果客户端在发送COMMIT
之后但在从数据库服务器收到响应之前遇到网络中断与连接超时,客户端是不知道事务是否已经被提交的(图8-1)。
例12-1 资金从一个账户到另一个账户的非幂等转移
BEGIN TRANSACTION;
UPDATE accounts SET balance = balance + 11.00 WHERE account_id = 1234;
UPDATE accounts SET balance = balance - 11.00 WHERE account_id = 4321;
COMMIT;
客户端可以重连到数据库并重试事务,但现在现在处于TCP重复抑制的范围之外了。因为例12-1中的事务不是幂等的,可能会发生转了\$22而不是期望的\$11。因此,尽管例12-1是一个事务原子性的标准样例,但它实际上并不正确,而真正的银行并不会这样办事【3】。
两阶段提交(参阅“原子提交与两阶段提交(2PC)”)协议会破坏TCP连接与事务之间的1:1映射,因为它们必须在故障后允许事务协调器重连到数据库,告诉数据库将存疑事务提交还是中止。这足以确保事务只被恰好执行一次吗?不幸的是,并不能。
即使我们可以抑制数据库客户端与服务器之间的重复事务,我们仍然需要担心终端用户设备与应用服务器之间的网络。例如,如果终端用户的客户端是Web浏览器,则它可能会使用HTTP POST请求向服务器提交指令。也许用户正处于一个信号微弱的蜂窝数据网络连接中,它们成功地发送了POST,但却在能够从服务器接收响应之前没了信号。
在这种情况下,可能会向用户显示错误消息,而他们可能会手动重试。 Web浏览器警告说,“你确定要再次提交这个表单吗?” —— 用户选“是”,因为他们希望操作发生。 (Post/Redirect/Get模式【54】可以避免在正常操作中出现此警告消息,但POST请求超时就没办法了。)从Web服务器的角度来看,重试是一个独立的请求,而从数据库的角度来看,这是一个独立的事务。通常的除重机制无济于事。
操作标识符
要在通过几跳的网络通信上使操作具有幂等性,仅仅依赖数据库提供的事务机制是不够的 —— 你需要考虑端到端(end-to-end) 的请求流。 例如,你可以为操作生成一个唯一的标识符(例如UUID),并将其作为隐藏表单字段包含在客户端应用中,或通过计算所有表单相关字段的散列来生成操作ID 【3】。如果Web浏览器提交了两次POST请求,这两个请求将具有相同的操作ID。然后,你可以将该操作ID一路传递到数据库,并检查你是否曾经使用给定的ID执行过一个操作,如例12-2中所示。
例12-2 使用唯一ID来抑制重复请求
ALTER TABLE requests ADD UNIQUE (request_id);
BEGIN TRANSACTION;
INSERT INTO requests(request_id, from_account, to_account, amount)
VALUES('0286FDB8-D7E1-423F-B40B-792B3608036C', 4321, 1234, 11.00);
UPDATE accounts SET balance = balance + 11.00 WHERE account_id = 1234;
UPDATE accounts SET balance = balance - 11.00 WHERE account_id = 4321;
COMMIT;
例12-2依赖于request_id
列上的唯一约束。如果一个事务尝试插入一个已经存在的ID,那么INSERT
失败,事务被中止,使其无法生效两次。即使在较弱的隔离级别下,关系数据库也能正确地维护唯一性约束(而在“写入偏差与幻读”中讨论过,应用级别的检查-然后-插入可能会在不可序列化的隔离下失败)。
除了抑制重复的请求之外,例12-2中的请求表表现得就像一种事件日志,提示向着事件溯源的方向(参阅“事件溯源”)。更新账户余额事实上不必与插入事件发生在同一个事务中,因为它们是冗余的,而能由下游消费者从请求事件中衍生出来 —— 只要该事件被恰好处理一次,这又一次可以使用请求ID来强制执行。
端到端的原则
抑制重复事务的这种情况只是一个更普遍的原则的一个例子,这个原则被称为端到端的原则(end-to-end argument),它在1984年由Saltzer,Reed和Clark阐述【55】:
只有在通信系统两端应用的知识与帮助下,所讨论的功能才能完全地正确地实现。因而将这种被质疑的功能作为通信系统本身的功能是不可能的。 (有时,通信系统可以提供这种功能的不完备版本,可能有助于提高性能)
在我们的例子中所讨论的功能是重复抑制。我们看到TCP在TCP连接层次抑制了重复的数据包,一些流处理器在消息处理层次提供了所谓的恰好一次语义,但这些都无法阻止当一个请求超时时,用户亲自提交重复的请求。TCP,数据库事务,以及流处理器本身并不能完全排除这些重复。解决这个问题需要一个端到端的解决方案:从终端用户的客户端一路传递到数据库的事务标识符。
端到端参数也适用于检查数据的完整性:以太网,TCP和TLS中内置的校验和可以检测网络中数据包的损坏情况,但是它们无法检测到由连接两端发送/接收软件中Bug导致的损坏。或数据存储所在磁盘上的损坏。如果你想捕获数据所有可能的损坏来源,你也需要端到端的校验和。
类似的原则也适用于加密【55】:家庭WiFi网络上的密码可以防止人们窃听你的WiFi流量,但无法阻止互联网上其他地方攻击者的窥探;客户端与服务器之间的TLS/SSL可以阻挡网络攻击者,但无法阻止恶意服务器。只有端到端的加密和认证可以防止所有这些事情。
尽管低层级的功能(TCP复制抑制,以太网校验和,WiFi加密)无法单独提供所需的端到端功能,但它们仍然很有用,因为它们能降低较高层级出现问题的可能性。例如,如果我们没有TCP来将数据包排成正确的顺序,那么HTTP请求通常就会被搅烂。我们只需要记住,低级别的可靠性功能本身并不足以确保端到端的正确性。
在数据系统中应用端到端思考
这将我带回最初的论点·:仅仅因为应用使用了提供相对较强安全属性的数据系统,例如可序列化的事务,并不意味着应用的数据就不会丢失或损坏了。应用本身也需要采取端到端的措施,例如除重。
这实在是一个遗憾,因为容错机制很难弄好。低层级的可靠机制(比如TCP中的那些)运行的相当好,因而剩下的高层级错误基本很少出现。如果能将这些剩下的高层级容错机制打包成抽象,而应用不需要再去操心,那该多好呀 —— 但恐怕我们还没有找到这一正确的抽象。
长期以来,事务被认为是一个很好的抽象,我相信它们确实是很有用的。正如第7章导言中所讨论的,它们将各种可能的问题(并发写入,违背约束,崩溃,网络中断,磁盘故障)合并为两种可能结果:提交或中止。这是对编程模型而言是一种巨大的简化,但恐怕这还不够。
事务是代价高昂的,当涉及异构存储技术时尤为甚(参阅的“实践中的分布式事务”)。我们拒绝使用分布式事务是因为它开销太大,结果我们最后不得不在应用代码中重新实现容错机制。正如本书中大量的例子所示,对并发性与部分失败的推理是困难且违反直觉的,所以我怀疑大多数应用级别的机制都不能正确工作,最终结果是数据丢失或损坏。
出于这些原因,我认为探索对容错的抽象是很有价值的。它使提供应用特定的端到端的正确性属性变得更简单,而且还能在大规模分布式环境中提供良好的性能与运维特性。
强制约束
让我们思考一下在分拆数据库上下文中的正确性(correctness)。我们看到端到端的除重可以通过从客户端一路透传到数据库的请求ID实现。那么其他类型的约束呢?
我们先来特别关注一下唯一性约束 —— 例如我们在例12-2中所依赖的约束。在“约束和唯一性保证”中,我们看到了几个其他需要强制实施唯一性的应用功能例子:用户名或电子邮件地址必须唯一标识用户,文件存储服务不能包含多个重名文件,两个人不能在航班或剧院预订同一个座位。
其他类型的约束也非常类似:例如,确保帐户余额永远不会变为负数,你就不会超卖库存;或者会议室没有重复的预订。执行唯一性约束的技术通常也可以用于这些约束。
唯一性约束需要达成共识
在第9章中我们看到,在分布式环境中,强制执行唯一性约束需要共识:如果存在多个具有相同值的并发请求,则系统需要决定冲突操作中的哪一个被接受,并拒绝其他违背约束的操作。
达成这一共识的最常见方式是使单个节点作为领导,并使其负责所有决策。只要你不介意所有请求都挤过单个节点(即使客户端位于世界的另一端),只要该节点没有失效,系统就能正常工作。如果你需要容忍领导者失效,那么就又回到了共识问题(参阅“单领导者复制与共识”)。
唯一性检查可以通过对唯一性字段分区做横向扩展。例如,如果需要通过请求ID确保唯一性(如例12-2所示),你可以确保所有具有相同请求ID的请求都被路由到同一分区(参阅第6章)。如果你需要让用户名是唯一的,则可以按用户名的散列值做分区。
但异步多主复制排除在外,因为可能会发生不同主库同时接受冲突写操作的情况,因而这些值不再是唯一的(参阅“实现可线性化系统”)。如果你想立刻拒绝任何违背约束的写入,同步协调是无法避免的【56】。
基于日志消息传递中的唯一性
日志确保所有消费者以相同的顺序看见消息 —— 这种保证在形式上被称为全序广播(total order boardcast) 并且等价于共识(参见“全序广播”)。在使用基于日志的消息传递的分拆数据库方法中,我们可以使用非常类似的方法来执行唯一性约束。
流处理器在单个线程上依次消费单个日志分区中的所有消息(参阅“与传统消息传递相比的日志”)。因此,如果日志是按有待确保唯一的值做的分区,则流处理器可以无歧义地,确定性地决定几个冲突操作中的哪一个先到达。例如,在多个用户尝试宣告相同用户名的情况下【57】:
- 每个对用户名的请求都被编码为一条消息,并追加到按用户名散列值确定的分区。
- 流处理器依序读取日志中的请求,并使用本地数据库来追踪哪些用户名已经被占用了。对于所有申请可用用户名的请求,它都会记录该用户名,并向输出流发送一条成功消息。对于所有申请已占用用户名的请求,它都会向输出流发送一条拒绝消息。
- 请求用户名的客户端监视输出流,等待与其请求相对应的成功或拒绝消息。
该算法基本上与“使用全序广播实现线性一致的存储”中的算法相同。它可以简单地通过增加分区数扩展至较大的请求吞吐量,因为每个分区可以被独立处理。
该方法不仅适用于唯一性约束,而且适用于许多其他类型的约束。其基本原理是,任何可能冲突的写入都会路由到相同的分区并按顺序处理。正如“什么是冲突?”与“写入偏差与幻读”中所述,冲突的定义可能取决于应用,但流处理器可以使用任意逻辑来验证请求。这个想法与Bayou在90年代开创的方法类似【58】。
多分区请求处理
当涉及多个分区时,确保操作以原子方式执行且同时满足约束就变得很有趣了。在例12-2中,可能有三个分区:一个包含请求ID,一个包含收款人账户,另一个包含付款人账户。没有理由把这三种东西放入同一个分区,因为它们都是相互独立的。
在数据库的传统方法中,执行此事务需要跨全部三个分区进行原子提交,这实质上是将该事务嵌入一个全序,就这些分区上的所有其他事务而言。而这样就要求跨分区协调,不同的分区无法再独立地进行处理,因此吞吐量可能会受到影响。
但事实证明,使用分区日志可以达到等价的正确性而无需原子提交:
- 从账户A向账户B转账的请求由客户端提供一个唯一的请求ID,并按请求ID追加写入相应日志分区。
- 流处理器读取请求日志。对于每个请求消息,它向输出流发出两条消息:付款人账户A的借记指令(按A分区),收款人B的贷记指令(按B分区)。被发出的消息中会带有原始的请求ID。
- 后续处理器消费借记/贷记指令流,按照请求ID除重,并将变更应用至账户余额。
步骤1和步骤2是必要的,因为如果客户直接发送贷记与借记指令,则需要在这两个分区之间进行原子提交,以确保两者要么都发生或都不发生。为了避免对分布式事务的需要,我们首先将请求持久化记录为单条消息,然后从这第一条消息中衍生出贷记指令与借记指令。几乎在所有数据系统中,单对象写入都是原子性的(参阅“单对象写入),因此请求要么出现在日志中,要么就不出现,无需多分区原子提交。
如果流处理器在步骤2中崩溃,则它会从上一个存档点恢复处理。这样做时,它不会跳过任何请求消息,但可能会多次处理请求并产生重复的贷记与借记指令。但由于它是确定性的,因此它只是再次生成相同的指令,而步骤3中的处理器可以使用端到端请求ID轻松地对其除重。
如果你想确保付款人的帐户不会因此次转账而透支,则可以使用一个额外的流处理器来维护账户余额并校验事务(按付款人账户分区),只有有效的事务会被记录在步骤1中的请求日志中。
通过将多分区事务分解为两个不同分区方式的阶段,并使用端到端的请求ID,我们实现了同样的正确性属性(每个请求对付款人与收款人都恰好生效一次),即使在出现故障,且没有使用原子提交协议的情况下依然如此。使用多个不同分区方式的阶段与我们在“多分区数据处理”中讨论的想法类似(参阅“并发控制”)。
及时性与完整性
事务的一个便利属性是,它们通常是线性一致的(参阅“线性一致性”),也就是说,写入者会等到事务提交,而之后其写入立刻对所有读取者可见。
当我们把一个操作拆分为跨越多个阶段的流处理器时,却并非如此:日志的消费者在设计上就是异步的,因此发送者不会等其消息被消费者处理完。但是,客户端等待输出流中的特定消息是可能的。这正是我们在“基于日志消息传递中的唯一性”一节中检查唯一性约束时所做的事情。
在这个例子中,唯一性检查的正确性不取决于消息发送者是否等待结果。等待的目的仅仅是同步通知发送者唯一性检查是否成功。但该通知可以与消息处理的结果相解耦。
更一般地来讲,我认为术语一致性(consistency) 这个术语混淆了两个值得分别考虑的需求:
及时性(Timeliness)
及时性意味着确保用户观察到系统的最新状态。我们之前看到,如果用户从陈旧的数据副本中读取数据,它们可能会观察到系统处于不一致的状态(参阅“复制延迟问题”)。但这种不一致是暂时的,而最终会通过等待与重试简单地得到解决。
CAP定理(参阅“线性一致性的代价”)使用线性一致性(linearizability) 意义上的一致性,这是实现及时性的强有力方法。像写后读这样及时性更弱的一致性也很有用(参阅“读己之写”)也很有用。
完整性(Integrity)
完整性意味着没有损坏;即没有数据丢失,并且没有矛盾或错误的数据。尤其是如果某些衍生数据集是作为底层数据之上的视图而维护的(参阅“从事件日志导出当前状态”),这种衍生必须是正确的。例如,数据库索引必须正确地反映数据库的内容 —— 缺失某些记录的索引并不是很有用。
如果完整性被违背,这种不一致是永久的:在大多数情况下,等待与重试并不能修复数据库损坏。相反的是,需要显式地检查与修复。在ACID事务的上下文中(参阅“ACID的涵义”),一致性通常被理解为某种特定于应用的完整性概念。原子性和持久性是保持完整性的重要工具。
口号形式:违反及时性,“最终一致性”;违反完整性,“永无一致性”。
我断言在大多数应用中,完整性比及时性重要得多。违反及时性可能令人困惑与讨厌,但违反完整性的结果可能是灾难性的。
例如在你的信用卡对账单上,如果某一笔过去24小时内完成的交易尚未出现并不令人奇怪 —— 这些系统有一定的滞后是正常的。我们知道银行是异步核算与敲定交易的,而这里的及时性也并不是非常重要【3】。但果当期对账单余额与上期对账单余额加交易总额对不上(求和错误),或者出现一比向你收费但未向商家付款的交易(消失的钱),那实在是太糟糕了。这样的问题就违背了系统的完整性。
数据流系统的正确性
ACID事务通常既提供及时性(例如线性一致性)也提供完整性保证(例如原子提交)。因此如果你从ACID事务的角度来看待应用的正确性,那么及时性与完整性的区别是无关紧要的。
另一方面,对于在本章中讨论的基于事件的数据流系统而言,它们的一个有趣特性就是将及时性与完整性分开。在异步处理事件流时不能保证及时性,除非你显式构建一个在返回之前明确等待特定消息到达的消费者。但完整性实际上才是流处理系统的核心。
恰好一次或等效一次语义(参阅“容错”)是一种保持完整性的机制。如果事件丢失或者生效两次,就有可能违背数据系统的完整性。因此在面对故障时,容错消息传递与重复抑制(例如,幂等操作)对于维护数据系统的完整性是很重要的。
正如我们在上一节看到的那样,可靠的流处理系统可以在无需分布式事务与原子提交协议的情况下保持完整性,这意味着它们能潜在地实现好得多的性能与运维稳健性,在达到类似正确性的前提下。为了达成这种正确性,我们组合使用了多种机制:
- 将写入操作的内容表示为单条消息,从而可以轻松地被原子写入 —— 与事件溯源搭配效果拔群(参阅“事件溯源”)。
- 使用与存储过程类似的确定性衍生函数,从这一消息中衍生出所有其他的状态变更(参见“真的串行执行”和“作为衍生函数的应用代码”)
- 将客户端生成的请求ID传递通过所有的处理层次,从而启用端到端除重,带来幂等性。
- 使消息不可变,并允许衍生数据能随时被重新处理,这使从错误中恢复更加容易(参阅“不可变事件的优点”)
这种机制组合在我看来,是未来构建容错应用的一个非常有前景的方向。
宽松地解释约束
如前所述,执行唯一性约束需要共识,通常通过在单个节点中汇集特定分区中的所有事件来实现。如果我们想要传统的唯一性约束形式,这种限制是不可避免的,流处理也不例外。
然而另一个需要了解的事实是,许多真实世界的应用实际上可以摆脱这种形式,接受弱得多的唯一性:
- 如果两个人同时注册了相同的用户名或预订了相同的座位,你可以发送其中一个发消息道歉,并要求他们选择一个不同的用户名。这种纠正错误的变化被称为补偿性事务(compensating transaction)【59,60】。
- 如果客户订购的物品多于仓库中的物品,你可以下单补仓,并为延误向客户道歉,向他们提供折扣。实际上,这么说吧,如果在叉车在仓库中轧过了你的货物,剩下的货物比你想象的要少,那么你也是得这么做【61】。因此,既然道歉工作流无论如何已经成为你商业过程中的一部分了,那么对库存物品数目添加线性一致的约束可能就没必要了。
- 与之类似,许多航空公司都会超卖机票,打着一些旅客可能会错过航班的算盘;许多旅馆也会超卖客房,抱着部分客人可能会取消预订的期望。在这些情况下,出于商业原因而故意违反了“一人一座”的约束;当需求超过供给的情况出现时,就会进入补偿流程(退款、升级舱位/房型、提供隔壁酒店的免费的房间)。即使没有超卖,为了应对由恶劣天气或员工罢工导致的航班取消,你还是需要道歉与补偿流程 —— 从这些问题中恢复仅仅是商业活动的正常组成部分。
- 如果有人从账户超额取款,银行可以向他们收取透支费用,并要求他们偿还欠款。通过限制每天的提款总额,银行的风险是有限的。
在许多商业场景中,临时违背约束并稍后通过道歉来修复,实际上是可以接受的。道歉的成本各不相同,但通常很低(以金钱或名声来算):你无法撤回已发送的电子邮件,但可以发送一封后续电子邮件进行更正。如果你不小心向信用卡收取了两次费用,则可以将其中一项收费退款,而代价仅仅是手续费,也许还有客户的投诉……。尽管一旦ATM吐了钱,你无法直接取回,但原则上如果账户透支而客户拒不支付,你可以派催收员收回欠款…。
道歉的成本是否能接受是一个商业决策。如果可以接受的话,在写入数据之前检查所有约束的传统模型反而会带来不必要的限制,而线性一致性的约束也不是必须的。乐观写入,事后检查可能是一种合理的选择。你仍然可以在做一些挽回成本高昂的事情前确保验证发生,但这并不意味着写入数据之前必须先进行验证。
这些应用确实需要完整性:你不会希望丢失预订信息,或者由于借方贷方不匹配导致资金消失。但是它们在执行约束时并不需要及时性:如果你销售的货物多于仓库中的库存,可以在事后道歉后并弥补问题。这种做法与我们在“处理写入冲突”中讨论的冲突解决方法类似。
无协调数据系统
我们现在做了两个有趣的观察:
- 数据流系统可以维持衍生数据的完整性保证,而无需原子提交,线性一致性,或者同步跨分区协调。
- 虽然严格的唯一性约束要求及时性和协调,但许多应用实际上可以接受宽松的约束:只要整个过程保持完整性,这些约束可能会被临时违反并在稍后被修复。
总之这些观察意味着,数据流系统可以为许多应用提供无需协调的数据管理服务,且仍能给出很强的完整性保证。这种无协调(coordination-avoiding) 的数据系统有着很大的吸引力:比起需要执行同步协调的系统,它们能达到更好的性能与更强的容错能力【56】。
例如,这种系统可以使用多领导者配置运维,跨越多个数据中心,在区域间异步复制。任何一个数据中心都可以持续独立运行,因为不需要同步的跨区域协调。这样的系统时效性保证会很弱 —— 如果不引入协调它是不可能是线性一致的 —— 但它仍然可以提供有力的完整性保证。
在这种情况下,可序列化事务作为维护衍生状态的一部分仍然是有用的,但它们可以在小范围内运行,在那里它们工作得很好【8】。异构分布式事务(如XA事务)(请参阅“实践中的分布式事务”)不是必需的。同步协调仍然可以在需要的地方引入(例如在无法恢复的操作之前强制执行严格的约束),但是如果只是应用的一小部分地方需要它,没必要让所有操作都付出协调的代价。【43】。
另一种审视协调与约束的角度是:它们减少了由于不一致而必须做出的道歉数量,但也可能会降低系统的性能和可用性,从而可能增加由于宕机中断而需要做出的道歉数量。你不可能将道歉数量减少到零,但可以根据自己的需求寻找最佳平衡点 —— 既不存在太多不一致性,又不存在太多可用性问题的最佳选择。
信任但验证
我们所有关于正确性,完整性和容错的讨论都基于一些假设,假设某些事情可能会出错,但其他事情不会。我们将这些假设称为我们的系统模型(system model)(参阅“将系统模型映射到现实世界”):例如,我们应该假设进程可能会崩溃,机器可能突然断电,网络可能会任意延迟或丢弃消息。但是我们也可能假设写入磁盘的数据在执行fsync
后不会丢失,内存中的数据没有损坏,而CPU的乘法指令总是能返回正确的结果。
这些假设是相当合理的,因为大多数时候它们都是成立的,如果我们不得不经常担心计算机出错,那么基本上寸步难行。在传统上,系统模型采用二元方法处理故障:我们假设有些事情可能会发生,而其他事情永远不会发生。实际上,这更像是一个概率问题:有些事情更有可能,其他事情不太可能。问题在于违反我们假设的情况是否经常发生,以至于我们可能在实践中遇到它们。
我们已经看到,数据可能会在尚未落盘时损坏(参阅“复制与持久性”),而网络上的数据损坏有时可能规避了TCP校验和(参阅“弱谎言形式” )。也许我们应当更关注这些事情?
我过去所从事的一个应用收集了来自客户端的崩溃报告,我们收到的一些报告,只有在这些设备内存中出现了随机位翻转才解释的通。这看起来不太可能,但是如果有足够多的设备运行你的软件,那么即使再不可能发生的事也确实会发生。除了由于硬件故障或辐射导致的随机存储器损坏之外,一些病态的存储器访问模式甚至可以在没有故障的存储器中翻转位【62】 —— 一种可用于破坏操作系统安全机制的效应【63】(这种技术被称为Rowhammer)。一旦你仔细观察,硬件并不是看上去那样完美的抽象。
要澄清的是,随机位翻转在现代硬件上仍是非常罕见的【64】。我只想指出,它们并没有超越可能性的范畴,所以值得一些关注。
维护完整性,尽管软件有Bug
除了这些硬件问题之外,总是存在软件Bug的风险,这些错误不会被较低层次的网络,内存或文件系统校验和所捕获。即使广泛使用的数据库软件也有Bug:即使像MySQL与PostgreSQL这样稳健、考虑充分、久经实战考验,多年以来被许多人充分测试过的软件,就我个人所见也有Bug:比如MySQL未能正确维护唯一约束【65】,以及PostgreSQL的可序列化隔离等级存在特定的写偏差异常【66】。对于更不成熟的软件来说,情况可能要糟糕的多。
尽管在仔细设计,测试,以及审查上做出很多努力,但Bug仍然会在不知不觉中产生。尽管它们很少,而且最终会被发现并被修复,但总会有那么一段时间,这些Bug可能会损坏数据。
而对于应用代码,我们不得不假设会有更多的错误,因为绝大多数应用的代码经受的评审与测试远远无法与数据库的代码相比。许多应用甚至没有正确使用数据库提供的用于维持完整性的功能,例如外键或唯一性约束【36】。
ACID意义下的一致性(参阅“一致性”)基于这样一种想法:数据库以一致的状态启动,而事务将其从一个一致状态转换至另一个一致的状态。因此,我们期望数据库始终处于一致状态。然而,只有当你假设事务没有Bug时,这种想法才有意义。如果应用以某种错误的方式使用数据库,例如,不安全地使用弱隔离等级,数据库的完整性就无法得到保证。
不要盲目信任承诺
由于硬件和软件并不总是符合我们的理想,所以数据损坏似乎早晚不可避免。因此,我们至少应该有办法查明数据是否已经损坏,以便我们能够修复它,并尝试追查错误的来源。检查数据完整性称为审计(auditing)。
如“不可变事件的优点”一节中所述,审计不仅仅适用于财务应用程序。不过,可审计性在财务中是非常非常重要的。这种错误发生之后,需要能被检测与解决,我们都知道所有人都会认为这是合理需求。
成熟的系统同样倾向于考虑不太可能的事情出错的可能性,并管理这种风险。例如,HDFS和Amazon S3等大规模存储系统并不完全信任磁盘:它们运行后台进程持续回读文件,并将其与其他副本进行比较,并将文件从一个磁盘移动到另一个,以便降低静默损坏的风险【67】。
如果你想确保你的数据仍然存在,你必须真正读取它并进行检查。大多数时候它们仍然会在那里,但如果不是这样,你一定想尽早知道答案,而不是更晚。按照同样的原则,不时地尝试从备份中恢复是非常重要的 —— 否则当你发现备份损坏时,你可能已经遇到了数据丢失,那时候就真的太晚了。不要盲目地相信它们全都管用。
验证的文化
像HDFS和S3这样的系统仍然需要假设磁盘大部分时间都能正常工作 —— 这是一个合理的假设,但与它们始终能正常工作的假设并不相同。然而目前还没有多少系统采用这种“信任但是验证”的方式来持续审计自己。许多人认为正确性保证是绝对的,并且没有为罕见的数据损坏的可能性做过准备。我希望未来能看到更多的自我验证(self-validating) 或自我审计(self-auditing) 系统,不断检查自己的完整性,而不是依赖盲目的信任【68】。
我担心ACID数据库的文化导致我们在盲目信任技术(如事务机制)的基础上开发应用,而忽视了这种过程中的任何可审计性。由于我们所信任的技术在大多数情况下工作得很好,通常会认为审计机制并不值得投资。
但随之而来的是,数据库的格局发生了变化:在NoSQL的旗帜下,更弱的一致性保证成为常态,更不成熟的存储技术越来越被广泛使用。但是由于审计机制还没有被开发出来,尽管这种方式越来越危险,我们仍不断在盲目信任的基础上构建应用。让我们想一想如何针对可审计性而设计吧。
为可审计性而设计
如果一个事务在一个数据库中改变了多个对象,在这一事实发生后,很难说清这个事务到底意味着什么。即使你捕获了事务日志(参阅“变更数据捕获”),各种表中的插入,更新和删除操作并不一定能清楚地表明为什么要执行这些变更。决定这些变更的是应用逻辑中的调用,而这一应用逻辑稍纵即逝,无法重现。
相比之下,基于事件的系统可以提供更好的可审计性。在事件溯源方法中,系统的用户输入被表示为一个单一不可变事件,而任何其导致的状态变更都衍生自该事件。衍生可以实现为具有确定性与可重复性,因而相同的事件日志通过相同版本的衍生代码时,会导致相同的状态变更。
显式处理数据流(参阅“批处理输出的哲学”)可以使数据的来龙去脉(provenance) 更加清晰,从而使完整性检查更具可行性。对于事件日志,我们可以使用散列来检查事件存储没有被破坏。对于任何衍生状态,我们可以重新运行从事件日志中衍生它的批处理器与流处理器,以检查是否获得相同的结果,或者,甚至并行运行冗余的衍生流程。
具有确定性且定义良好的数据流,也使调试与跟踪系统的执行变得容易,以便确定它为什么做了某些事情【4,69】。如果出现意想之外的事情,那么重现导致意外事件的确切事故现场的诊断能力—— 一种时间旅行调试功能是非常有价值的。
端到端原则重现
如果我们不能完全相信系统的每个组件都不会损坏 —— 每一个硬件都没缺陷,每一个软件都没有Bug —— 那我们至少必须定期检查数据的完整性。如果我们不检查,我们就不能发现损坏,直到无可挽回地导致对下游的破坏时,那时候再去追踪问题就要难得多,且代价也要高的多。
检查数据系统的完整性,最好是以端到端的方式进行(参阅“数据库的端到端争论”):我们能在完整性检查中涵盖的系统越多,某些处理阶中出现不被察觉损坏的几率就越小。如果我们能检查整个衍生数据管道端到端的正确性,那么沿着这一路径的任何磁盘,网络,服务,以及算法的正确性检查都隐含在其中了。
持续的端到端完整性检查可以不断提高你对系统正确性的信心,从而使你能更快地进步【70】。与自动化测试一样,审计提高了快速发现错误的可能性,从而降低了系统变更或新存储技术可能导致损失的风险。如果你不害怕进行变更,就可以更好地充分演化一个应用,使其满足不断变化的需求。
用于可审计数据系统的工具
目前,将可审计性作为顶层关注点的数据系统并不多。一些应用实现了自己的审计机制,例如将所有变更记录到单独的审计表中,但是确保审计日志与数据库状态的完整性仍然是很困难的。可以通过定期使用硬件安全模块对事务日志进行签名来防止篡改,但这无法保证正确的事务一开始就能进入到日志中。
使用密码学工具来证明系统的完整性是十分有趣的,这种方式对于宽泛的硬件与软件问题,甚至是潜在的恶意行为都很稳健有效。加密货币,区块链,以及诸如比特币,以太坊,Ripple,Stellar的分布式账本技术已经迅速出现在这一领域【71,72,73】。
我没有资格评论这些技术用于货币,或者合同商定机制的价值。但从数据系统的角度来看,它们包含了一些有趣的想法。实质上,它们是分布式数据库,具有数据模型与事务机制,而不同副本可以由互不信任的组织托管。副本不断检查其他副本的完整性,并使用共识协议对应当执行的事务达成一致。
我对这些技术的拜占庭容错方面有些怀疑(参阅“拜占庭故障”),而且我发现工作证明(proof of work) 技术非常浪费(比如,比特币挖矿)。比特币的交易吞吐量相当低,尽管是出于政治与经济原因而非技术上的原因。不过,完整性检查的方面是很有趣的。
密码学审计与完整性检查通常依赖默克尔树(Merkle tree)【74】,这是一颗散列值的树,能够用于高效地证明一条记录出现在一个数据集中(以及其他一些特性)。除了炒作的沸沸扬扬的加密货币之外,证书透明性(certificate transparency) 也是一种依赖Merkle树的安全技术,用来检查TLS/SSL证书的有效性【75,76】。
我可以想象,那些在证书透明度与分布式账本中使用的完整性检查和审计算法,将会在通用数据系统中得到越来越广泛的应用。要使得这些算法对于没有密码学审计的系统同样可伸缩,并尽可能降低性能损失还需要一些工作。 但我认为这是一个值得关注的有趣领域。