我们很高兴向大家宣布,Waltz的开源版本已经正式与各位见面。Waltz 是一款分布式预写日志方案。它最初的设计目标在于为 WePay 系统提供货币交易分类账,但后来被广泛应用于各类需要序列化一致性支持的分布式系统当中。
Waltz 类似于Kafka等现有日志系统,同样能够接收/持久化/传播由大量服务生成/消费的交易数据。但与其它系统的区别在于,Wlatz 在分布式应用程序之内提供一套更加便捷的序列化一致性实现机制。它能够在将交易提交至日志之前,首先对其中的冲突进行检测。Waltz 可以充当唯一事实来源,而非普通的数据库,并由此建立级为可靠的、以日志为中心的系统架构。
背景介绍
数据库
WePay 系统一直在不断发展,旨在处理更大流量并交付更多功能。我们将大型服务拆分成多个较小的服务,以确保尽可能保障系统的可管理性。一般来讲,每一项服务都拥有自己的对应数据库。为了更好地加以隔离,服务之间原则上不共享数据库。
在遭遇网络故障、进程故障以及机器故障时,数据库之间的一致性保障就变得相当麻烦。服务通过网络进行交互,而交互通常会对双方的数据库加以更新。故障的出现,可能导致数据库之间不再一致。大多数此类不一致性问题可由守护程序线程加以修复,这些线程负责定期执行检查与修复操作。然而,总有一部分修复无法自动化完成,有时候我们也得手动介入进行处理。
除此之外,我们还需要复制数据库以建立容错能力。我们主要使用 MySQL 异步复制。当主要区域发生故障时,系统将通过故障转移把工作负载交由备用区域接管处理,以确保用户能够继续完成付款。但是,跨区域复制本身也会带来问题。主数据库中的数据库更新无法立即体现在从数据库之内。延迟始终存在,复制滞后也是一种常态。总而言之,我们无法保证主数据库拥有全部最新数据,也无法保证各数据库之间始终彼此同步。
面向流的处理机制
我们在很多场景下使用异步处理方案。我们希望推迟那些不需要即时一致性的更新。这能够让往来事务更加轻量化,同时提高响应与吞吐量水平。为了实现这一目标,我们采用了 Kafka 的流导向处理机制。服务会更新自己对应的数据库,同时将消息写入 Kafka。这种方案效果不错,但缺点是服务必须面向两套彼此独立的存储系统进行写入,也就是数据库与 Kafka。而检查与修复工作,仍然得由工作人员手动完成。
基本思路
Waltz 是我们开发出的一套预写日志方案。其中记录的日志既非来自数据库的变更数据输出结果,也不是来自应用程序的辅助输出内容。相反,它所记录的,是与系统状态转换相关的主要信息。这种方式与围绕数据库构建的典型交易系统有所不同。在这类典型用例中,数据库被作为事实来源;但在我们的新模式中,日志才是事实的来源(即主要信息),而数据库则通过日志内容派生而成(次要信息)。现在的挑战,在于如何确保数据库中的所有数据都与日志保持一致,以及如何确保日志内的所有数据在序列化方面都正确无误。
我们要如何保证数据库与日志之间的一致性?实现最终一致性相对比较简单,因为日志中的交易记录具有不可变及有序两大特性。如果应用程序以相同的顺序将交易应用于自己的数据库,那么应该能够实现确定性的结果。下面,让我们具体描述这个基本思路。
应用程序生成一条交易消息,其中包含关于预期数据变更的描述。
应用程序将其发送至 Waltz。这时,应用程序的数据库尚未更新。
Waltz 接收到该交易消息,并保存在 Waltz 日志当中。
Waltz 将该交易消息发送回应用程序。
应用程序接收到这条交易消息,并将其中的数据应用于自己的对应数据库。
下图所示,为应用程序从数据库当中读取 V=x 值,并将其更新至 V=y。
Waltz 日志当中包含所有数据变更。而对应用程序数据库的更新,则由 Waltz 发出的消息(即交易数据)负责驱动。因此,Waltz 才是主要信息的持有者,充当事实来源。服务数据库中包含的则是 Waltz 日志的派生信息,属于 Waltz 日志的一种实体化视图。
通过这种方式,应用程序将具备强大的故障承受能力。如果应用程序在第 5 步之前遭遇故障,那么交易将在 Waltz 当中持久存在,只是应用程序无法更新其对应的数据库。这时,Waltz 会向再次重新启动的应用程序发送交易消息,而应用程序则能够借此完成对数据库的更新。
这种设计使得数据的复制与共享也变得非常轻松。Waltz 允许多个客户端读取并写入同样的日志内容,亦可利用来自 Waltz 的消息制作副本。此外,相同的交易数据能够根据应用程序的需要被用于不同目的,同时不对其它应用造成任何影响。总而言之,Waltz 帮助我们将服务拆分成更多小型服务,且不会增加通信与协调的复杂性。
听起来相当不错,着眼于可能存在并发数据更新尝试的分布式环境,大家就会发现数据完整性的保障并没那么容易。可能有客户在一时冲动之下提交了某项交易。如果我们坚持保留所有消息,而无视其内容的一致性,那么就必须依靠后处理来解决其中的冲突。此外,我们可能需要利用数据库进行重复数据删除与完整性检查,拒绝不良消息,或者以某种方式向上游服务通报消息的处理状态并产生新的“清理”日志。总之,这会极大提升系统设计的复杂性,消耗更多资源并带来更高的延迟。而这些,正是现有日志系统需要克服的主要障碍所在。就是为了解决这些问题,我们才决定开发出自己的 Waltz 日志系统,希望借此防止日志受到不一致交易记录的污染。
现有日志系统中的问题
在详细介绍之前,我们首先通过日志系统所支持的简单键值存储机制,来介绍现有系统中存在的问题。
读取-修改-写入中的问题
为了让日志成为事实来源,我们必须在键值存储更新之前进行日志写入。服务会将新数据发送至日志系统,并在从日志处接收到新消息后保存键值存储的新值。假设新的数据计算自键值存储中的现有数据(读取-修改-写入),那么我们要如何保证更新内容的正确性?为了确保更新正确,我们读取的数据必须为最新数据。但问题是传输永远存在延迟,键值存储中的数据可能无法反映日志中的最新更新。
假设我们拥有一项简单的计数器服务,它负责将计数器的值存储在键值存储当中。
应用程序向该服务发送一条 INCREMENT 请求。
该服务随后读取键值存储中的现有值。
该服务将“现有值+1”发送至日志。
该服务在从日志处接收到新的消息后,对键值存储中的计数值进行更新。
但当服务同时收到另一条 INCREMENT 请求时,就会出现竞争条件。如果在服务完成第一项请求的步骤 4 之前处理第二项请求的步骤 2,则第一项请求的效果就会被第二项请求所覆盖(后写为准)。这意味着对于两项 INCREMENT 请求,该值仅增加了一次。
实现约束中的问题
在以上场景当中,大家可能觉得消息不应该是新计算得出的值,而应该是差值,例如“+1”.只要日志消息在服务中以单线程方式使用,计算器值就能够正确递增两次,因为服务会收到两条“+1”消息。没错,那么现在假设我们需要对该计数器的值进行约束,例如“计数器值不能为负”,那么新的问题又出现了。由于竞争条件的存在,该服务将无法以可靠的方式得出真实的当前值,也就意味着我们无法可靠地实现约束。
重复消息
重复消息同样是个大问题。我们当然不希望付款系统在单一购买当中重复记录付款操作。如果对日志的写入失败,则应用程序应当重试;但有时候,应用程序可能无法确定写入失败的具体位置。因此,消息可能已经或者还没有被持久保存在日志当中。该消息只能由日志系统接受一次;换句话说,系统必须为幂等。现有日志系统提出了一种简单的解决方案,即在消息当中附加唯一 ID,而后过滤掉重复内容。但是,永远保留所有唯一 ID 将成为一种巨大的负担。一般来讲,此类系统会采取保留策略,从而减少数据量。只要保留期设置得足够长,就不太可能发生漏报。但是,“不太可能”不是不可能,我们要如何严格保证这种幂等性?
我们的解决方案
面对以上问题,Waltz 采取了一种众所周知的解决办法,这就是乐观锁。
关于乐观锁
应用程序可以将乐观锁添加到交易消息当中。乐观锁由锁 ID 与模式共同组成。锁 ID 由应用程序定义,通常与现实世界中的某个实体对应,例如某笔支付或者某个账户等。但是,Waltz 并不知道这个 ID 代表什么,应用程序可以自由选择锁定的程度。Waltz 支持两种锁模式,分别为 READ 与 WRITE。READ 模式意味着当前交易基于锁 ID 所指定的实体状态,而 WRITE 模式意味着该交易可能基于实体的当前状态对其进行状态更新。
在解释乐观锁在 Waltz 中的工作原理之前,我们需要先对 Waltz 的其它一些重要概念做出说明。首先是交易 ID,然后是客户高水位标记、锁表、锁高水位标记以及锁兼容性测试。
交易 ID 是一条唯一的 64 位整数 ID,其会被分配给成功保留的交易。每一次提交新交易时,交易 ID 都会递增。交易 ID 在 Waltz 的乐观锁机制当中发挥着重要作用。
客户高水位标记是指客户应用程序应用于其数据库的最高交易 ID。
Waltz 还在内部管理着一套锁表,其基本上相当于从锁 ID 到交易 ID 的映射记录。当 WRITE 模式中出现指向该锁的交易消息时,锁表就会返回一条附带有特定锁 ID 的最新交易 ID。这就叫作锁高水位标记。(该映射实际是一套固定大小的随机数据结构,负责给出特定锁 ID 所对应的最后一次成功交易的估算交易 ID。估算得出的交易 ID 必然等于或者大于真实交易 ID。)
锁兼容性测试通过比较客户高水位标记与锁高水位标记的方式实现。对于特定锁 ID,如果客户高水位标记等于或者大于锁高水位标记,则代表锁兼容测试通过。
以下就是在 WRITE 模式下一条带有锁 ID 的消息的完整处理流程:
客户发送交易消息,其中包含客户高水位标记。
Waltz 接收这条带有锁 ID 的消息。
Waltz 查找锁表并执行锁兼容性测试。
如果测试失败,则 Waltz 拒绝该消息。
如果测试成功,Waltz 为其分配一个新的交易 ID,并将该消息写入至日志。
如果写入失败,Waltz 不更新锁表。
如果写入成功,Waltz 利用新的交易 ID 更新锁表。
锁兼容性测试失败代表着什么?这意味着客户的高水位标记低于锁的高水位标记。换言之,应用程序尚未消费掉更新了锁高水位标记的对应交易。这代表着该交易消息由陈旧数据构成,接受这条消息有可能引发冲突。
下面,我们使用乐观锁对之前讨论的竞争条件进行检测。假设两个客户同时发送具有相同写入锁的消息。我们考虑一种比较有趣的极端情况,即他们的客户高水位标记完全相同,且与锁高水位标记相兼容。这时候,其中必然有一条消息将由 Waltz 服务器首先处理。它会通过锁兼容性测试,因为这时它的客户高水位标记与锁高水位标记仍然相同。但在提交之后,锁表条目将被更新为新的交易 ID,这就使得第二条消息提交失败——因为这时锁高水位标记高于客户高水位标记。
限制与要求
在我们的用例当中,乐观锁确实起到了很好的作用。但是,这绝不是一种无所不能的解决方案。我们在应用程序设计当中,同样需要考虑到由此带来的限制与要求。
无法实现长期交易。我们必须将交易打包成单一 Waltz 消息,交易无法跨越多条消息。这并不是说交易只能被限制在单一数据操作之内,应用程序可以构造出包含多项数据操作的单一消息,而这些操作通过原子操作加以执行。在由应用程序进行消费时,这样的消息将被映射至同一 SQL 交易内执行的多个 DML 语句处。
我们要求应用程序具备像 SQL 数据库那样的交易数据存储能力。数据库充当 Waltz 交易日志的实体化视图。该应用程序利用 Waltz 的交易消息,且可能会/可能不会根据应用程序的需要将其应用于数据库。Waltz 不会对数据库架构提出任何强制性要求。应用程序可以自由定义自身架构。此外,应用程序数据库必须保存高水位标记,且该高水位标记代表着服务所消费过的最高交易 ID。
分布式系统中的其它常规问题
集群
Waltz 是一套分布式系统。一个 Waltz 集群由服务器节点、存储节点以及客户端共同组成。客户端运行在应用程序进程当中,服务器节点充当客户端与存储节点之间的代理与缓存。客户端向服务器节点发送一条交易消息,而后服务器节点将该消息写入至多个存储节点以实现持久性与容错性。在这里,我们使用 ZooKeeper 进行集群管理。服务器进程由 ZooKeeper 负责跟踪。ZooKeeper 还被用于为各个副本状态提供共享元数据存储。
分区
Waltz 日志通过分区实现可扩展性。应用程序负责控制各项交易到具体分区的分配工作。分区属于独立日志,锁按分区进行管理。
服务器节点负责协调指向存储节点的写入操作。各个服务器节点负责各自分区中的子集。每个分区只存在一个服务器节点。在服务器节点发生故障时,Waltz 会自动将该故障服务器节点的分区重新分配给剩余的服务器节点,同时启动恢复流程。此外,Waltz 还会通知客户端这一分区变更,以确保后续写入被转至正确的服务器处。
复制协议
Waltz 利用 quorum 机制实现日志复制。当大多数存储节点确认写入成功后,交易才会被提交。但是,单凭 quorum 写入并不足以支撑起一致的分布式系统。Waltz 利用 ZooKeeper 进行领导节点选举、生成唯一 ID、实现故障检测器以及元数据存储。除此之外,Waltz 还采用了与 Multi-Paxos 以及 Raft 相似的协议,旨在确保存储节点中日志的一致性。
对于各个分区,将有一套服务器被选定为分区所有者,其负责就是处理该分区内的数据读取/写入。分区所有者选举利用 ZooKeeper 实现。各存储节点以被动形式参与协议,甚至无需与 ZooKeeper 通信。存储节点的行为,取决于作为分区所有者的服务器。
我们还在 ZooKeeper 当中存储了少量关于存储状态的元数据。这些元数据将在恢复期间用到。服务器在分配分区之后,或者发生任何故障之后运行恢复流程。在此期间,来自客户端的写入请求将被阻止,直到恢复完成。成功恢复之后,大多数副本将可确保同步。Waltz 服务器会在同步当中将只写内容路由至各副本,并在后台中持续修复未同步的副本。
缺少的功能与接下来的工作
日志管理
我们目前遵循着一项重要要求,即将所有交易存储为不可变历史记录。正因为如此,我们没有设置日志保留策略,也不会删除旧有记录。同样的,我们也没有像 Kafka 那样的基于密钥日志压缩功能。我们甚至没有输入密钥的概念。但是,将所有交易记录永久存储在存储节点当中显然不够经济,我们需要开发一款能够将旧有记录归档至低成本存储载体上的工具。
主题
Waltz 没有 Kafka 当中的主题概念。Waltz 是一套单主题系统,目前对多主题的支持需求也不算迫切。我们更倾向于通过彼此独立的集群实现“主题隔离”。
工具
我们的管理工具基于 CLI。我们当然也有计划构建基于 GUI 的工具。
代理/缓存
我们正在考虑为各个区域添加代理/缓存。这样做的好处,是可以更快地交付交易数据并减少跨区域调用。
总结
我们开发出了 Waltz 这款新的开源分布式预写日志方案。除了出色的可用性与可扩展性之外,它还提供一种独特的乐观并发控制机制。Waltz 使应用程序能够在大规模分布式环境当中实现序列化一致性。目前,我们将 Waltz 以开源软件的形式发布,如果大家感兴趣,请点击此处访问我们的 Git 库。
原文链接:
https://wecode.wepay.com/posts/waltz-a-distributed-write-ahead-log
评论