CockroachDB是一款优秀的分布式云原生数据库,但它绝不是数据黑洞也不是数据孤岛,我们的一些用户希望可以将他们的数据保存在全文检索系统中,以支持自然语言搜索, 也有一些用户希望使用大数据分析平台来运行大量查询,而不会影响生产流量, 还有一些人希望发送移动推送通知以响应数据更改,而无需自己进行簿记。
行业标准解决方案是Change Data Capture(通常缩写为CDC),即变更数据捕获。每个数据库的处理方式略有不同,但它通常看起来像一个消息流,每个消息都包含有关数据更改的信息。在CB-SQL我们称之为changefeed。
CockroachDB `CHANGEFEED`是一个或多个表发生变化数据的实时流。当SQL语句执行并更改存储的数据时,消息将发送到外部系统,我们将其称为“接收器”。执行INSERT INTO users (1, "Carl"), (2, "Petee")可能发送{"id": 1, "name": "Carl"}`和`{"id": 2, "name": "Petee"}。我们通常不会将变更数据直接发送到下游业务,这涉及到客户端驱动程序的开发调试以及调优,相反我们会把数据发送到一个消息代理(kafka或者其他的消息队列系统),下游业务可以异步消费这些数据.
挑战
建立CockroachDB changefeed的最大挑战从一开始就很明显。我们希望我们的changefeed可以横向扩展,同时我们也希望它们保持强大的事务语义。
在单节点数据库中,比如MySQL, 它维护一个binlog记录数据的变更,因此构建changefeed的工作主要是以合理的方式公开此日志.其他的数据库也类似。但是,CockroachDB具有独特的分布式架构。它存储的数据被分解为大约64MB的“ranges”。这些ranges每个都被复制成N个“副本”以获得高可用。CockroachDB事务可以涉及任何或所有这些ranges,这意味着它可以跨越集群中的任何或所有节点。这与在水平扩展其他SQL数据库时使用的“分片”设置形成对比,其中每个分片是完全独立的复制单元,并且事务不能跨越分片。然后,分片SQL群集上的changefeed只是每个分片的changefeed,通常由分片的领导者运行,如下图所示。由于每个事务完全发生在一个分片中,因此分片之间事务的相对排序并不那么值得特别关注(或者说大家很多时候不在乎这种分片之间的事务排序)。它还意味着各个分片的feeds可以完全并行化(每个分片一个Kafka主题或分区是典型的)。
图1:分片SQL数据库中的事务无法跨越分片。
由于CockroachDB事务可以使用集群中的任何range集合(考虑跨分片事务),因此事务排序要复杂得多, 如下图所示。特别是,并不总是可以将事务划分为独立的流。这里简单的答案是将每个事务放入一个流中,但我们对此并不满意。CockroachDB旨在水平扩展到大量节点,因此我们当然希望我们的changefeed也可以水平扩展。
图2:CockroachDB中的事务可以跨节点。
创新
CockroachDB中的SQL表可以跨越多个range,但该表中的每一行始终包含在一个range内。(当range变大时系统可以移动,系统将其分成两部分以及range变小并且系统将其合并到相邻range时,但这些可以单独处理。)此外,每个range都是一个独立的raft group,因此有自己的WAL,我们可以追随这个WAL。这意味着我们可以为每个SQL行生成有序的changefeed。为了实现这一目标,我们开发了一种内部机制RangeFeed,将这些变化直接从我们的raft group中推出,而不是轮询它们.
每个Row流都是独立的,这意味着我们可以水平缩放它们。使用我们的分布式SQL框架,我们将处理器放置在正在观察的数据旁边发出行更改,从而消除不必要的网络跃点。如果一个节点完成所有观看和发送,它还可以避免我们遇到的单点故障,如下图所示。
图3:CockroachDB range leader各自直接向Kafka(或其他接收器)发出changefeed。
对于许多changefeed用途,这就足够了,每条消息都可以触发移动推送通知,某些数据存储不支持事务。有序的行流对这两者都很有用。对于其他用途,这还不够; 将数据镜像到分析数据库当然不希望应用部分事务。
每个CockroachDB事务使用相同的HLC时间戳提交每一行。在每个消息中为更改的行暴露此时间戳就足以获得事务信息(按时间戳分组行集合)[1]以及总排序(按时间戳排序行)。在我们现有的事务时间戳之上构建意味着我们的changefeed与CockroachDB中的其他所有内容具有相同的可序列化保证。
那最后的问题是知道何时进行这一组或排序。如果hlc1从一个CockroachDB节点随时间发出更改的行,那么在对其进行操作之前,您需要等待多长时间才能确保其他任何节点都没有更改hlc1?
我们用一个我们称之为“resolved”的时间戳消息的概念来解决这个问题。这是一个承诺,即不会发出新的行更改,其时间戳小于或等于已解析的时间戳消息中的时间戳。这意味着上述用户可以在hlc1从每个节点[2]接收到已解决的时间戳之后进行操作>= hlc1, 如下图所示。
图4:图3中为事务发出的前几条消息的一种可能排序。
在图4中,想象两个独立的流都已经通过读取X。hlc1在其中一个stream上已经“resolved”,但在另外一个stream上没有“resolved”,所以hlc1还没有“resolved。
现在想象一下,在稍后的某些时候,消息已经被读过了Y。两个stream都已“resolved”了hlc1,所以我们知道我们已收到所有已发生的变化,包括hlc1。如果我们按时间戳对消息进行分组,我们可以恢复交易。在这种情况下,只有(B->1,C->2),承诺在hlc1。此事务现在可以发送到分析数据库。
请注意,(A->3)更改发生在hlc2,因此尚未“resolved”。这意味着changefeed用户需要继续缓存它。
我们还可以随时重建数据库的状态,包括hlc1保持每行的最新值。这甚至适用于范围和节点。在这种情况下,hlc1数据库时B=1,C=2。
最后,想象一下稍后Z读取所有消息的时间。通过相同的两个进程再次获取数据库的事务和状态。在这种情况下,交易(A->3,B->4)承诺hlc2和(C->5)承诺hlc3。在hlc3包含的数据库中A=3,B=1,C=5。请注意,hlc2如有必要,我们还可以重建数据库。
这个创新的解决方案来源于一篇论文《Naiad: A Timely Dataflow System》,其核心思想类似于TCP的接收时间窗口,CockroachDB就是基于这样的思想设计自己的CDC解决方案.
我们的工作(京东的工作)
之前章节我们比较细致的介绍了CDC方案的核心思路,但是在实践的过程中仍然充满挑战, 比如resolved timestamp的更新,任务异常,range分裂合并,增量数据追赶, 尤其是考虑到在线DDL, 在线DDL在分布式系统中考虑到数据量比较大,这个过程通常耗时比较长,并且不同于传统的数据库采用锁表的方式,它允许在DDL期间提供正常的读写服务,这样问题就会变得更加复杂.
我们在兼容MySQL协议的过程,为了兼容MySQL的生态,就需要提供类似MySQL的binlog,这个对于业务迁移到我们的系统非常重要.摆在我们前面的挑战主要有:
自动启动CDC:CockroachDB的CDC是表级别的,但是我们的很多业务拥有大量的数据库表,并且基于MySQL的生态和使用习惯,都是默认开启binlog的,因此我们需要提高用户的体验,同时每次手动启动确实耗费大量的人力,并且容易出错.有鉴于此,我们需要至少在DB层面进行配置,以便简化处理,这里的挑战是对于大部分消息队列,只能保证单分片上的数据有序性,而我们在table层面需要这种有序性才能正确的将数据变更按照事务粒度重新组织.考虑到单分片的处理能力有限,因此如何在消息队列系统和CB-SQL集群规模之间达成动态平衡是一个充满挑战的问题.
事务捕获:CockroachDB的CDC并不是事务粒度,而是行级别分散的,另外CockroachDB不相干的事务的时间顺序没有严格要求,导致从消息队列中消费的事件的时间戳是混乱的,并且可能存在重复的事件,因此首先我们需要在消费端按照事务作为最小单元重新整理这些数据,这个的主要依据就是事件携带的时间戳,相同时间戳的事件被认为是属于同一个事务的(两个不重叠的事务可以使用相同的时间戳进行提交,但它们具有纳秒精度,因此在实践中这种情况很少见).然后我们按照时间大小顺序重新排序,其中需要对重复的数据进行过滤.
在线DDL变更:我在前面的专题中已经详细的讲述了CockroachDB的在线DDL变更,在DDL变更完成之前需要进行数据回填,那么这种回填的数据就会被捕获并且送入消息队列,但是考虑到DDL变更可能会失败,失败之后就会进行反向回填,这样本质上DDL变更在CDC层面上能保证数据的最终一致性,但是对于binlog,这样是不可接受的,因此我们需要一方面截流老数据的回填送入消息队列,另一方面我们需要考虑DDL期间产生的业务数据在DDL失败之后如何回滚以保证兼容binlog的强一致属性.