开启掘金成长之旅!这是我参与「掘金日新计划 · 12 月更文挑战」的第32天,点击查看活动详情
[论文阅读]FlightTracker-Facebook跨网在线存储读一致性优化(4)
第46篇,终于到论文正文,这个章节继续FlightTracker实现细节,FT论文非常好读,本章节我仔细编排翻译了论文。值得细读。比如FlightTracker不使用quorum一致性协议,而是使用单轮quorum设计方案,为什么?其实下文有明确答案。 论文见:www.usenix.org/conference/…
5. FlightTracker服务的实现
有状态的FlightTracker服务(图4)的API极其简单,仅由两个操作组成。如图7所示,网络请求在开始时调用getMergedWrites(user),以获得用户会话的RYW Ticket;客户端库在用新造的Ticket进行数据库写入后调用appendWrite(user, ticket),并且只有在数据存储写入和appendWrite都成功的情况下才向应用程序确认写入。为了减少歧义,我们用 "数据写入 "来指代数据存储操作,用 "元数据写入 "来指代FlightTracker操作。
FlightTracker有以下要求:
• 高吞吐量。FlightTracker受制于所有底层数据存储的全部写入吞吐量,因为每一次数据写入都会导致向FlightTracker写入元数据。它的有效复制系数低于像TAO这样的全局复制的存储,因为大多数写入的数据只存储在写入用户的区域。它的读取吞吐量与网络请求的数量成正比。
• 低延时。在FlightTracker中记录元数据之前,数据写入不会被确认,因此FlightTracker增加了应用程序可见的写入延时。
• 高可用性。FlightTracker的不可用性意味着客户丧失可用性或丧失RYW的一致性。FlightTracker的解耦性质使我们能够让一些用例失败(可用但不一致),而其他用例失败(不可用)。
• 持久性。传递给appendWrite的Ticket应该被包含在getMergedWrites中,即使在机器出现故障的情况下。FlightTracker使用单轮quorum方案,该方案不提供原子性,因为失败的或正在进行的appendWrite调用是可以看到的。
FlightTracker的工作集相对较小,因为FlightTracker在合并Ticket的时候会对其进行压缩(§ 4.2)。更为可实践的是,如果查询只被路由到最多60s的副本,那么写入60个以上的元数据是安全的,可以压缩掉。我们的数据存储跟踪他们自己的复制流的滞后性,绝大多数(>99.99%)的服务器都不超过60s的滞后性。
5.1 复制
我们将FlightTracker实现为一个基于quorum的存储。我们静态地确定副本的数量N;FlightTracker客户端向所有副本广播appendWrite,当W个副本确认写入时,就认为它成功了;getMergedWrites查询联系R个副本,并将检索到的Ticket加入。
这个普通的单轮quorum协议,R + W > N足够提供所需的正确性保证,因为R+W>N保证了FlightTracker的安全性,所以R中至少有一个副本。之前追加的Ticket将在读和写的阙值之间返回重叠。合并后通过join()从所有R副本中读取结果,确保了持久性:Ticket将被包含在最终的读取结果中。
FlightTracker不需要保证原子性。回顾一下,考虑到Ticket封装以及Ticket如何被用作下限,我们可以安全地在Ticket中包含额外的写元数据而不违反整体RYW的一致性(第4节)。如果元数据写入未能到达W FlightTracker的代表处或仍在进行中,FlightTracker可以安全地将其纳入getMergedWrites的结果中。此外,如果数据写入成功,但其元数据写入FlightTracker失败,我们认为这种写入是 "未确认的成功",即,数据存储客户端错误地将数据写入应用程序。应用程序开发人员并不期望看到失败的数据写入,但知道如何处理它们,如果它们真的出现了。由于Facebook的许多应用都是建立在最终一致的存储上,应用习惯于读取更新鲜的写入(例如,从其他应用)。因此,只要不经常出现,不被承认的成功是可以接受的。
我们将FlightTracker实现为一个基于quorum的存储。我们静态地确定副本的数量N;FlightTracker客户端向所有副本广播appendWrite,当W个副本确认写入时,就认为它成功了;getMergedWrites查询联系R个副本,并将检索到的Ticket加入。所以对上图的回答是,这在FT client SDK中完成,因为appendWrite是广播的,而Read的时候,可能未能达到N的quorum需求,被称之为in process,没关系,直接纳入getMergedWrites中即可(是安全的)。
例如,假设我们有一个FlightTracker的部署 N = W = 3 且 R = 1,W5和W6数据写入成功,而写元数据W5失败,正在写元数据W6。三个FlightTracker副本的状态是 {W5,W6},{W6},{},* 。第一次元数据读取可能会返回Ticket {W5,W6},随后的元数据读取可能会返回{} 。这是被允许的:W6还没有完成,所以RYW还不适用;W5,虽然在数据库中被写入,但已经向应用程序返回了一个错误,因此应用程序不应该期望有任何可见性。
FlightTracker的默认设置是一个区域-本地的quorum,因为Facebook将登录的用户引向一个区域。我们利用区域安置来确保FlightTracker访问的低延迟。由于FlightTracker有一个小的工作集,我们选择将所有的东西都存储在内存中,并调整N的冗余度。根据经验,我们发现N=3在低延迟和足够的冗余之间提供了一个很好的权衡(§8)
我们将用户ID静态地映射到逻辑分片上,这些分片动态地放在每个FlightTracker副本中。分片的放置考虑到了负载平衡,并涵盖了故障检测。一些用例使用了非用户会话ID和跨区域配额(§7.3)。
5.2 Failure tolerance
机器故障是必须处理的最常见的故障。我们的策略还可以处理网络问题和分片移动过程中的覆盖空白。我们利用全局压缩约束,在Flight- Tracker机器获得新的分片分配或动态分片移动后恢复弹性。FlightTracker在分片上线后的前60秒内进行 "预热",接受所有的写操作,但不提供读操作。被拒绝的读取会在热的副本上重试。
5.3 Fail closed vs. Fail open
Ajoux等人[10]指出的挑战之一是确保一致性机制提供净用户利益。例如,即使getMergedWrites失败了,一些应用程序也希望继续进行网络请求。鉴于我们可以选择在每个查询的基础上干净地失败打开,很难争论统一的失败关闭政策。如果FlightTracker是完全可靠的,那么即使采用故障开放策略,也会有很少的不一致,所以故障关闭没有好处;另一方面,如果FlightTracker不是完全可靠的,那么喜欢可用性的用例就会受到故障关闭的损害。未来的一个选择是为这些用例限制故障关闭的速度,并将所有可能违反RYW的故障开放升级到工程师。
由于当数据写入成功但相应的appendWrite失败时,会向应用程序报告一个错误,因此FlightTracker的写入可用性与数据存储的可用性相同。FlightTracker是内存中的,并且是区域性的,所以它比我们的底层持久性数据存储有更高的写入可用性(§ 8);它对应用程序可见的写入可用性影响很小。虽然现在不那么重要了,但在早期推出时,失败开放的选项对降低风险至关重要。
6 包含Ticket的读
一旦FlightTracker将Ticket附加到查询中,数据存储就有责任确保Ticket所标识的每一次写入都反映在查询结果中。
实现包含Ticket的读取的一般模式是,数据存储(客户端或服务器)过滤Ticket中的写内容,以确定其相关性,然后根据其本地状态检查它们是否已经被应用。通常,Ticket中的写入与读取无关(例如,对MEDIA节点的写入与读取Alice的TRUSTED用户无关),或者已经被复制并包含在本地,在这种情况下,Ticket不会改变结果,读取可以在本地提供。
在不常见的情况下,一些写入可能是重要的,但却丢失了,也就是说,本地数据可能是陈旧的,数据存储使用更昂贵的非本地行动来修复查询结果。每一个步骤的具体策略都取决于查询语义、Ticket中写的编码方式以及本地可用的信息。
6.1 按相关性过滤
过滤对于最细化的写代表来说效果最好,比如每个键的版本。相比之下,时间戳定义了一个写入集,包括Facebook所有数据系统历史的连续前缀,它永远无法被过滤。
对于数据库特定的编码,当数据存储忽略了Ticket中其他数据库的写入时,就会发生粗略的过滤。通过Ticket或数据存储配置中的静态信息,如TAO对象类型(如USER或ENJOYS)或数据库表来过滤也是快速而简单的。对于包含键的Ticket表示,我们可以根据查询参数(如所需的节点ID)进一步过滤写入。这对点查询和简单的范围查询(如TAO)非常有效,对一些索引也有效。
类型和查询参数的过滤可以在客户端完成,这就避免了在大多数查询中甚至包括一个Ticket。我们将这种操作称为裁剪,并将其整合到我们支持的所有数据存储的客户端库中。
6.2 包容性检查
纳入FlightTracker的系统自己提供最终的一致性,大部分使用异步复制。绝大多数的写入是以低延迟交付的,所以大多数写入都是在检查时包括的。
对于数据库复制和按键分片的缓存,我们按照写的顺序进行复制(即按照每个分片的txn_id顺序)。复制流指针是识别本地存储中包含的写的集合的一种简洁方式。例如,如果最新复制的记录是8983,那么txn_id |-> 8980的W3写被包括在内,但txn_id |-> 8985的W4却没有。
缓存缺失可以从复制指针的前面取值,所以一个单一的低水位标记对于高粒度的包含检查是不够的。TAO维护着一个key->txn_id(safe)的映射,它可以识别一个特定的缓存条目何时包含了比本地低水位标志更新的写入。txn_id(safe)记录了上游源在服务缓存缺失时的复制位置,而不一定是key被更新的事务。例如,如果一个低水位标记为8983的缓存发生了缓存缺失,并从复制到9000的数据库中获取了W4中的边缘,它将有一个边缘的缓存条目txn_id(safe) |-> 9000;如果Ticket有W4甚至{shardX : txn_id |-> 9000},缓存现在能够在本地为边缘提供点读服务。这个异常映射对于确保包含Ticket的查询仍然可以成为缓存的点击率是至关重要的。
6.3 全局索引的相关性和包容性
对于全局索引来说,相关性和包含性检查都要困难得多。例如,一个让我们通过名字找到媒体节点的索引,将使用一个可变的数据属性而不是节点的键来进行分区。
虽然在这种情况下,将索引属性包含在Ticket中是可行的,但我们避免这种做法。它在没有解决所有索引的问题的情况下,使Ticket变得臃肿,因为被索引的属性可能来自图中相邻的节点或边。它要求写入者了解所有的索引模式,并且不能扩展到处理扇入的情况,即一次写入会影响大量的索引行。在第3.4节的例子中,我们想回答诸如 "返回同样喜欢某首歌的受信任用户的列表 "这样的查询,图索引系统实现了一个由huser.id、media.id、list(trusted_user.id)i图元组成的有序列表。检查像W3(它扩展了TRUSTS边以包括Bob和Alice之间的 "音乐")这样的写法是否与索引服务器相关或本地应用,需要Alice ENJOYS的MEDIA节点列表。如果我们采取这种方法,这个列表很容易使Ticket膨胀。
我们拒绝的另一个方案是忽略索引的相关性检查,只关注包容性。这就需要通过索引更新管道来剽窃所有碎片的复制水印信息,或许可以使用像Occult[41]那样的压缩矢量时钟方案来避免在数百万个复制流中进行确定性合并。为了区分缺乏新的更新和复制中的陈旧的值,每个流都需要心跳,这导致了冷分片和小索引的大量开销。
我们的解决方案是,从写到索引更新管道采取的行动中建立一个反转索引。我们将其存储在一个名为FlightTracker-ReverseIndex(FT-RI)的有状态组件中。我们以W3和相交索引为例,描述了图8所示的索引更新管道和FT-RI之间的互动关系。根据W3的类型和索引模式,FT-RI确定W3可能影响的索引,并最初假定它可能影响这些索引中的每一条记录。当W3通过更新管道时,该管道的每个阶段都会通知FT-RI它将在某些或所有索引中过滤掉W3。我们还要求更新管道将W3的元数据一直传播到索引叶服务器,除非W3被完全过滤掉。更新管道确定W3仅对交叉索引中形式为h17, media.id, 42i的索引行重要,并通知FT-RI。当叶子应用由于W3而产生的索引行更新时,它通知FT-RI其服务器标识符和索引状态的部分被更新。这样一来,FT-RI就缩小了W3可能影响的索引和读取查询的范围。
如图9所示,为了对包含Ticket的索引读取进行相关性过滤和包含性检查,客户库首先将查询和Ticket发送给FT-RI。然后,FT-RI返回可能是相关的(因为它们没有被报告为过滤)和尚未包含的(因为它们仍然在索引叶中缺失)的写入子集。
客户端在查询被发送到索引之前会咨询FT-RI,因此缺失的写入物集可能包括假阳性。假阴性会导致违反RYW,所以必须避免。为了在不引入任何假阴性的情况下将假阳性率降到最低,FT-RI返回了一张从写到可能缺失的物理服务器的地图。客户端对照查询执行计划检查这一信息,以防陈旧的服务器实际上没有被查阅。它也可以用来做智能复制的选择,并且只重试查询的陈旧部分。
FT-RI积累了一组关于写的无可辩驳的事实,因此其内部状态是一个CRDT。它利用与FlightTracker(§5)相同的单轮quorum协议进行复制。FT-RI也分享了许多相同的基础设施,并被部署为一个仅有RAM的区域服务。
6.4 处理local staleness的策略
本节介绍了当本地数据存储中可能缺少一个潜在的相关写时,获得正确结果的方法。我们的方法分为两类:当Ticket列举单个写入时,数据存储可以从上游请求数据,并为下一个读者缓存结果;如果Ticket包含一个连续的前缀,例如在compaction后,我们通常只重新评估查询(在不同的副本上或在以后的时间),因为请求连续的前缀是昂贵的或不可能。在生产中,除了索引修复,我们使用下面的每一种策略。
延迟和重试。 当我们意识到一个数据存储是陈旧的,一个简单的选择就是稍后再试。这种策略本身是不够的,但它可以作为第一次尝试,以减少成本更高的策略的频率。
复制的选择。 数据存储的复制是为了获得读取能力。当一个副本过期时,我们可以联系另一个最新的副本,特别是如果它在附近。这种策略可能会导致相关的故障,如雷鸣般的群组,所以我们只在低容量的工作负载或缓存后面使用它。
一致性缺失。 当Ticket通过键来识别单个写入时,保存每个键的版本(§ 4.1)的缓存可以很容易地确定哪些数据项目是过时的。他们可以使用正常的失误处理逻辑,从上游源头获取有关失误写入的数据,并将Ticket传递给他们,以递归地确保可见性。
即使是客户端的热对象缓存也可以类似地采取一致性失误,否则就会依赖TTL来获得新鲜的 数据。这是我们对读取热点的容忍度的组成部分(§ 6.5)。
索引绕过(re-materialization)。 索引系统可以选择返回到规范化数据的源头来回答一个读取查询,尽管这是一个昂贵的选择。我们的很多索引都是按需物化的,所以这种后备功能已经被经常使用了。
读取修复。 读取修复是在一个Ticket的写入中寻找可能与索引谓词匹配的部分,使用对非索引存储(如TAO)的点查询来评估完整的谓词,然后相应地修复索引的结果。读取修复可以降低复杂性和延迟性。考虑到第3.4节中的例子,我们想找到鲍勃的信任用户,他们也喜欢歌曲55。如果边17上的W3 , TRUSTS , 42在Ticket中,并且读修复库看到节点42(Alice)从TAO中ENJOYS Song 55,它将节点42添加到结果集中。
索引读取修复并不能完全避免后续查询的额外通信,比如缓存中的一致性缺失,但它避免了未来跨区域调用的需要。
FT-RI过滤掉了我们不需要修复的写。如图9所示,在查询索引和读修复之前,客户端库查询FT-RI,以找到鲍勃的哪些相关写还没有被应用。我们最初在没有FT-RI的情况下测试了读修复,将用户在过去60秒内进行的每一次写入都视为未确定的。列表相交索引的FT-RI对读取修复所要检查的边的平均数量只有很小的影响,但它为最坏的情况提供了一个巨大的减少。
读取修复有其局限性。首先,某些具有聚合功能的索引不能被读取修复。例如,如果一个in- dex查询只返回交集的大小,那么读修复库将不知道在结果中应用了哪些写。幸运的是,我们绝大多数的在线in- dex使用都会返回可以被修复的集合结果。其次,对于复杂的索引,比如需要穿越图中3个以上的跳数的索引,或者有大扇形的索引,客户端的读修复可能会重复更新管道和索引叶的转换和处理逻辑,导致额外的复杂度。上述挑战与数据库界的递延增量视图维护中遇到的挑战相似[23, 24, 47, 58]。
索引修复。 通过同步调用索引更新逻辑来修复索引是比较复杂的,但是避免了客户端读修复的许多限制。我们还没有探索过这个选项。
6.5 处理热点
处理关键对象是社交网络工作负载的主要挑战之一[10]。在我们的重读工作负载中,读热点比写热点更值得关注。像TAO这样的缓存通过存储更多的本地数据副本来处理读取热点,包括在客户端。缓存查询的包含Ticket的读取是可以缓存的,从而保持了这种热点容忍度。我们不能总是对修复后的索引结果进行缓存,但为执行修复而获取的数据总是可以在本地进行缓存。
除了整个系统的热点对象外,FlightTracker也有自己的热点:由于FlightTracker是按会话ID分片的,它的热点模式与下层数据存储不同。这些热点更可能是由于用户触发的操作,如批处理或来自自定义会话(§ 7.3)。为了缓解写入热点,FlightTracker的客户端库对元数据进行分批写入,而不必担心牺牲写入的可用性,因为FlightTracker的所有写入都是无冲突的。在FlightTracker服务器端,我们主动检测频繁访问的会话。对于产生许多网络请求并因此导致许多元数据读取的热点会话,我们将这些元数据读取凝聚成短时间的桶,并对桶中的所有读取做出相同的响应。这些策略消除了热点作为FlightTracker的一个重要错误来源(第8节)。