卡桑德拉柜台专栏:理论上不错,实践中危险

2020-06-12 02:00:22

Apache Cassandra值得信赖,可以在互联网级别进行扩展,并且可以无限制地进行扩展。这就是为什么在巧妙的实时中,我们使用Cassandra来持久存储消息。即便如此,基于我们在系统中维护的容量,我们一直都知道它存在局限性。

在2020年初发生了超过三个晚上的一系列事件后,发生了一些事情,严重扰乱了我们的卡桑德拉安装。添加更多节点,甚至增加节点大小的存量解决方案影响很小。卡桑德拉失去我们的信任了吗?

这些是我们如何挖掘出罪魁祸首的细节,作为卡桑德拉的柜台柱子。在生产中大规模使用计数器的警示故事。对理论的信任如何会在实践中造成严重破坏,以及我们如何恢复对卡桑德拉的信任。

我们巧妙地使用Cassandra来持久存储消息和某些消息元数据。它非常适合以下用例:集群是全局分布和复制,支持高写吞吐量,并在每个查询的基础上为应用程序提供对查询一致性级别的良好控制。

根据CAP定理,以这种方式使用Cassandra符合AP系统的模型。也就是说,在系统可能因为网络问题而被分区期间,系统保持可用,但代价是在其被分区期间的一致性。

我们还使用Cassandra存储各种其他实体,即使它们不一定需要该解决方案的性能或容量,因为它有助于将我们需要操作的移动部件数量降至最低。

在我们讨论计数器专栏之前,我们先来了解一下持久存储操作在Cassandra中是如何工作的。最终,了解这一点帮助我们弄清了导致停机的原因。

在Cassandra中,持久存储的单位是单元,单元被组织成宽行或分区。分区属于列族,这是表的Cassandra名称。任何更新操作都会导致单元被插入,并且所有单元更新都是使用在所有存储位置一致解释的查询时间戳,使用上次写入成功(LWW)策略进行的。

这意味着所有的upsert操作都是往返的,并且可以在给定单元的所有存储位置上以任何顺序应用任何更新组合,最终得到一致的结果。

换句话说,整个Cassandra数据库是一种无冲突的复制数据类型(CRDT),其中的原语操作是对单个单元格执行插入和删除操作。以这种方式理解存储模型是了解其关于网络问题、写入故障、存储故障等的属性和行为的关键。

然而,这个模型意味着有很多Cassandra不能满足的用例,因为它本质上最终是一致的,并且只能在逐个细胞的基础上进行。(实际上有一种情况是,当涉及多个单元的操作属于同一分区时,可以依赖它们以原子方式进行;但这不会改变一致性模型的本质。)。例如,涉及多个分区或多个列族的事务是不可能的。

Cassandra使用Paxos引入了一些有限的事务支持,与默认的存储和更新语义相比,这些操作引入了相当大的开销和复杂性,更不用说脆弱性了。

Cassandra还引入了计数器列,作为一种支持某种更新原子性的方式,这种方式比使用Paxos便宜得多。计数器列是可以包含整数值的列,其基元操作是递增和递减。

众所周知,您可以将计数器实现为CRDT,其中递增/递减操作是原语操作和交换操作。理论上,如果您有相同的放置和分发机制,并应用最终一致的更新,那么您应该会得到一个非常可用的分布式计数器。它将具有卡桑德拉为其常规存储模型提供的AP弹性和性能。

至少,当我们在Aply采用计数器列作为存储模型的一部分时,我们是这么想的。Cassandra的一个限制是它不能有效地计算分区。对分区中的单元格进行计数的查询在查询范围内的单元格数量为O(N)。我们需要一种O(1)方法来计算某些分区,作为存储模型中与推送设备注册相关的某些记录的分片策略的一部分。

最明显的约束是计数器列只能存在于完全由计数器列组成的表中。这意味着您不能简单地将计数器列添加到表中来表示任何给定分区中的单元格数量-您需要创建一个新表,并在该表中为第一个表中的每个分区创建一个分区。那是不方便的,但并不是一件令人惊叹的事。我们不需要计数器精确,如果两个表的主键空间相同,您将希望永远不会出现任何给定分区对两个表的可用性不相等的情况。

下一个约束是,一旦声明为计数器表(即声明包含至少一个计数器列),就永远不能删除表。再说一次,这很不方便,但并不是一件令人惊叹的事情。

最后,计数器模型的一大令人失望之处在于计数器更新操作不是幂等的。这正在失去主存储模型中更新的主要吸引力之一。

尽管如此,我们知道我们不需要计数器是精确的-只需要近似值和O(1)-所以我们接受了限制并继续进行。依赖计数器列的功能已投入生产使用约2年。

生产警报提醒我们,Cassandra查询在全球范围内有很大一部分查询超时。每个区域的多个Cassandra实例被固定在100%的CPU上,所有的生产集群都会受到影响。这一中断影响了我们的主要实时消息服务。时间是21点22分。

尽管大多数消息处理都是在没有与Cassandra层交互的情况下进行的,但是在某些操作(例如,在给定位置首次使用应用程序,以及持久化某些访问令牌)中也有数据库查找。这些业务的失败严重影响了一些客户。

我们在事件应对中的第一个行动是试图确定影响的程度。我们可以通过改变交通方向来解决这个问题吗?我们还需要了解潜在的原因。由于影响是在卡桑德拉层,并影响到所有地区和所有集群,重定向不会有任何帮助。

另一个典型的事件干预,以扩大能力,也没有提供给我们。Cassandra集群可以扩展,但不能实时扩展。我们关于入站请求的统计数据没有显示任何单个请求类型达到峰值,因此没有明显的方法来抑制流量的来源。事实上,这甚至不是很明显的问题是由外部负荷引起的。

我们认为扩展Cassandra是我们唯一可以应对的方式,希望将容量扩展到处理额外负载所需的程度。就在我们要这么做的时候,21点32分,负荷下降得和开始时一样快,服务恢复了正常。装载量激增的原因仍然是一个谜。

在接下来的一天里,我们做了所有我们能想到的事情来找出原因。我们进行了更新、维修和拖网统计,但没有确凿证据表明有任何问题。我们在水平和垂直方向上都进行了扩展,并假设我们将有足够的能力来处理未来类似的峰值。

那一天,在第一起事件发生整整24小时后,同样的事情也发生了。在所有地区的多个实例中,CPU负载均为100%。再一次,我们查看了指标,但没有看到需求激增。同样,10分钟后,负荷又平息了下来。

在接下来的24小时内,我们向位于Cassandra之上的API层添加了检测,准备从受影响的实例捕获DTrace日志,并再次垂直扩展到可用的最大实例和卷容量。21点22分,货物又来了。有了这个额外的容量,它并没有达到群集的最大容量。我们还掌握了查明原因所需的数据。

泄露的是针对单个客户帐户的单个操作的大量请求。每个请求都会导致多个单独的Cassandra查询,但包括对计数器表的更新。

过多的查询似乎来自应用程序部分非常积极的重试策略。任何需要几秒钟以上才能完成的查询都会重试。这导致一旦负载达到特定阈值,就会产生数百万个请求。

我们系统中其他地方的一个错误意味着,适用于每个外部API请求的速率限制并不适用于此特定请求,从而允许非常高的请求速率到达Cassandra层。有问题的应用程序包含一大批机顶盒设备,这些设备被编程为每天在同一时间向系统重新注册。

然而,潜在的悬而未决的问题是:为什么负载-即通过重试进行放大之前-开始达到Cassandra查询响应时间显著降低的地步?

每个API请求都会导致多个单独的Cassandra upsert操作和一个计数器列操作。我们怀疑计数器列可能是问题的原因,因为我们没有经历过来自任何其他请求的类似负载,即使在更高的速率下,也没有涉及计数器列。

我们为该数据重新设计了Cassandra布局,并更改了分片策略,使其完全不使用计数器列。我们实施了这些更改,并在几天后进行了部署。对系统负载的影响是巨大的。以下是从第一起事件到解决问题这几天的图表:

取消计数器列操作将Cassandra实例上的负载减少了两个数量级。这是一个非常令人惊讶的结果,因为计数器在其实现中被宣传为类似于CRDT,并且在理论上应该具有与常规Cassandra存储模型相当的性能特征。

但是,情况变得更糟了。进一步的研究表明,计数器列甚至不是最终一致的(https://aphyr.com/posts/294-jepsen-cassandra#Counters).。事实上,它们似乎没有任何使它们成为构建可预测分布式系统的有用原语的属性。

在巧妙的情况下,我们建立了可扩展的系统,并且在规模上是可预测的。我们将了解如何构建分布式系统的理论,存在哪些权衡,以及为了能够对其行为进行推理和声明,组件的哪些属性是合乎需要的。

这个警示故事似乎是关于在Apache Cassandra中使用列计数器的危险。但实际上,我们学到的教训是,理论和实践是不同的。随着规模的扩大,伴随着经典的、理论的关注,实际的考虑也随之而来。您必须考虑负载、容量、超出容量时会发生什么,以及当服务没有以我们预期的方式响应时对等系统如何反应。

此外,没有任何东西是孤立地构建的;我们所做的一切都建立在较低层的系统上,这些系统在设计中的某个地方埋藏着自己的理论与实践之间的权衡。在某个时候,肯定会有一些意想不到的行为。

与希腊神话中的女祭司卡桑德拉不同,精力充沛的实时不是做预言的生意,无论人们相信与否。仅仅是期望实践总是不辜负理论可能是危险的。哦,还有,不要用卡桑德拉的台柱。一次也没有。

有关计数器列的说明,请参阅DataStax网站上的这篇文章:Cassandra 2.1中的新功能:更好地实现计数器列