{
    "componentChunkName": "component---src-templates-blog-blog-detail-tsx",
    "path": "/blog/ticdc-source-code-reading-7",
    "result": {"pageContext":{"blog":{"id":"Blogs_475","title":"TiCDC 源码阅读（七）TiCDC Sorter 模块揭秘","tags":["TiCDC"],"category":{"name":"产品技术解读"},"summary":"本文聚焦 TiCDC 的 Sorter 模块，内容包括 Sorter 的价值、Sorter 的默认排序引擎选择，以及针对 LSM 树的读、写放大，Sorter 中做出的优化。","body":"## 导读\n\n本文是 TiCDC 源码解读系列的第七篇，聚焦 TiCDC 的 Sorter 模块，主要回答以下几个问题：\n\n- 为什么在 TiCDC 中 Puller 和 Sink 中间，需要有 Sorter？\n- Sorter 的默认排序引擎是什么，以及为什么做出此选择？\n- 针对 LSM 树的读、写放大，Sorter 中做了哪些优化？\n\n## Sorter 的存在意义\n\nSorter 的上游是 Puller。回顾前面几期的内容，用户创建的一个 Changefeed 中可能捕获了多个表，这些表会被调度到若干个 TiCDC 实例上，每个 TiCDC 实例承载该 Changefeed 中的几个表。在每个表上都会有一个 Puller 实例，后者创建 KV-Client 从 TiKV 获取每个 Region 的行变更，经过一系列处理后交给下游的 Sink。\n\n显然，Sink 的吞吐上限可能是低于 KV-Client 的输出的，特别是在需要同步大存量数据到下游的情况下。因此，Sorter 的第一重意义就是作为磁盘缓冲，暂存来不及写到下游的事件。\n\nSorter 的第二重意义是排序。由于 KV-Client 以 Region 为单位从上游获取行变更事件，多个 Region 之间的事件显然是乱序的。即使在一个 Region 内部，事件也不一定完全是有序的，这是因为 KV-Client 在订阅一个 Region 时，实际上建立了两条数据流：一条通过 Observer 监听这个订阅建立之后的所有变更，另一条则扫描 TiKV 的上该 Region 的行记录，被称为“增量扫”。增量扫的必要性在于，KV-Client 发送给 TiKV 的 [ChangeDataRequest](/blog/ticdc-source-code-reading-6) 请求中携带了 CheckpointTs 作为订阅的起点，但是 TiKV 上 CheckpointTs 之后的一些变更可能已经通过 Observer 了，便无法从 Observer 中取得了，只能去行记录中扫描。增量扫的数据流和 Observer 的数据流相互独立，因此同一行的多次变更，最终发送到 TiCDC 上其顺序也可能是乱的。因此，Sorter 需要担负起这个排序的任务。\n\n在 Sorter 的上游，行变更是以行为单位进行收集的；在 Sorter 的下游，即 Sink 模块中，行变更是以事务为单位输出的。Sorter 的第三重意义就是将行变更组装成事务。在上游 TiDB 系统中，事务的实现基于 Percolator 算法；在这个算法中，事务有 CommitTs 和 StartTs，前者决定了事务的可见性，后者唯一标识一个事务。Sorter 将行变更组装回事务，在实现上是基于排序的，即将一个表内部的所有行变更，按照 CommitTs 和 StartTs 作为前缀排序，这样属于同一个事务的所有行变更就被聚合在一起了，这样就还原了上游事务的本来面貌。\n\n至此，关于 Sorter 的必要性，可以简单地总结如下：\n\n1. KV-Client 以 Region 为单位从 TiKV 获取行变更，并将他们按表分别存储在特定的位置；\n2. 存储时，同一个表内的行变更以 CommitTs 和 StartTs 为前缀排序；\n3. 排序通常应在内存中实现，必要时，需要将事件缓冲到磁盘上；\n\n## Sorter 的排序引擎\n\n### 理想中 Sorter 的能力\n\n基于前述对 Sorter 的需求描述，假设我们需要自研该组件，它大概应该是这样子的：\n\n1. 从 KV-Client 接收行变更事件，按表划分，先在内存中排序、攒批，然后在需要的时候刷到磁盘上；\n2. 排序时，以 CommitTs、StartTs 作为联合前缀，以便将相同事务的变更放到临近位置，不过需要考虑如下问题：  \n  a. 同一个事务的多条行变更，可能被分隔到多个不同文件中了；  \n  b. 多个文件的 CommitTs、StartTs 可能并不是单调递增的，在头部、尾部可能存在交叠；\n3. 为解决上面描述的问题，Sorter 提供给 Sink 的数据访问迭代器，在必要时应当归并排序这些磁盘文件；\n4. 如果要在单个 TiCDC 实例上支撑几十万个表，直接在物理上隔离它们可能会造成内存、磁盘管理的压力，似乎可以考虑在表之间共享内存缓冲和磁盘文件，那么  \n  a. 迭代器需要具备查找（Seek）某个文件内部某个表的起始位置的能力；  \n  b. 当磁盘上具备成千上万个文件时，如果对每个表发起的每次读取都需要到每个文件内部进行 Seek，会有巨大的开销，造成巨大的性能回退。因此，在磁盘文件过多时，需要考虑一次性读取出来，归并排序后重新写入磁盘，这样，不同表的内容在物理上就被分开了， 读取特定表的数据时就不需要 Seek 大量的文件了。  \n\n我们很容易发现，Log-structured merge-tree（即 LSM 树）几乎与上述能力要求完美契合，它是 RocksDB 和 Pebble 等 KV 存储引擎的核心数据结构。我们以 RocksDB 的术语简单描述它的核心内容：\n\n内存缓冲区称为 MemTable，前台的写入首先在 MemTable 中排序，在达到缓冲区大小上限时刷出到磁盘上，称为 SSTable（Sorted String Table）。所有 MemTable 和 SSTable 被划分到多个 Level 中，其中所有 MemTable 和直接从 MemTable 刷出到磁盘上的 SSTable 所在的层称为 Level-0，它们的特点是所有文件之间可能存在交叉，当迭代器需要顺序访问时，就需要在每个 Level-0 之间归并排序。\n\n如前所述，当 Level-0 的文件个数较多时，迭代器在每个文件内部去 Seek 会造成可观的开销，因此 RocksDB 会周期性地将 Level-0 的文件归并排序并重新输出。生成的新文件之间便没有任何交叉了，迭代器可以很自然地顺序访问它们。这些新文件便也不再位于 Level-0；除了 Level-0 之外的所有其他 Level，其内部的文件都是有序且不交叉的。下图展示了这一过程。\n\n![迭代示意.png](https://img1.www.pingcap.com/prod/_2048d1dce5.png)\n\nLSM 树有开源的 golang 实现 Pebble，因此我们最终选择了 Pebble 作为默认的排序引擎。\n\n## Sorter 的实现细节\n\nSorter 的实现细节中，SortEngine 位于核心的位置，它提供了 Add 方法用于接受 Puller 收到的事件，提供 FetchByTable 方法用于 Sink 拉取事件。OnResolve 方法是 Puller 和 Sink 之间的桥梁，当事件在 Sorter 内部就绪之后，会借此来告知 Sink 当前可获得的事件的上界。\n\n```\n// You can search \"type SortEngine\" in the code tree to find it.\ntype SortEngine interface {\n    // AddTable adds the table into the engine.\n    AddTable(tableID model.TableID)\n\n    // RemoveTable removes the table from the engine.\n    RemoveTable(tableID model.TableID)\n\n    // Add adds the given events into the sort engine.\n    //\n    // NOTE: it's an asynchronous interface. To get the notification of when\n    // events are available for fetching, OnResolve is what you want.\n    Add(tableID model.TableID, events ...*model.PolymorphicEvent)\n\n    // OnResolve pushes action into SortEngine's hook list, which\n    // will be called after any events are resolved.\n    OnResolve(action func(model.TableID, model.Ts))\n\n    // FetchByTable creates an iterator to fetch events from the given table.\n    // lowerBound is inclusive and only resolved events can be retrieved.\n    //\n    // NOTE: FetchByTable is always available even if IsTableBased returns false.\n    FetchByTable(tableID model.TableID, lowerBound, upperBound Position) EventIterator\n}\n```\n\n接下来，将分别从 SortEngine 的写入端和读取端来进一步考察。\n\n### Sorter 的写入端\n\n![Sorter 写入端.png](https://img1.www.pingcap.com/prod/Sorter_5954c64bdf.png)\n\n上图展示了 TiDB 如何编码一个行记录，以及 TiCDC 中是如何把一行上的变更编码写入 Sorter 的。在 TiDB 中，表 ID 和行记录 ID 会编码为 Key 的前缀，所有的列会编码到一个大的 Value，于是关系数据库表的数据便可以此形式存储到 Key-Value 存储引擎中。TiCDC 会以等价的形式从 TiKV 中接收行变更事件，之后按照前述 Sorter 的需求对包含了 TableID 和 RecordID 的 Key 再次编码，使得事件以 CommitTs、StartTs 为前缀来聚集组织。\n\n编码之后，便可以调用 API 将事件写入 Pebble 实例中；其中包含了一些非常简单、直观的攒批优化，这里就不作赘述了。\n\n### Sorter 的读取端\n\n#### 任务的生成和调度\n\nSorter 的读取端入口是 SinkManager，代码中截取了与本期内容密切相关的字段：\n\n```\n// You can search \"type SortEngine\" in the code tree to find it.\n// SinkManager is the implementation of SinkManager.\ntype SinkManager struct {\n    // sinkProgressHeap is the heap of the table progress for sink.\n    sinkProgressHeap *tableProgresses\n\n    // tableSinks is a map from tableID to tableSink.\n    tableSinks sync.Map\n    // lastBarrierTs is the last barrier ts.\n    lastBarrierTs atomic.Uint64\n\n    sinkWorkers []*sinkWorker\n    sinkTaskChan        chan *sinkTask\n\n    sinkMemQuota *memquota.MemQuota\n}\n```\n\nSinkManager 维护了 Changefeed 中每个表的进度信息，以及该 Changefeed 的一些信息。上述字段的大致意义：\n\n1. sinkProgressHeap 是一个最小堆，记录了所有表当前同步到下游的进度。因此在派发任务时，SinkManager 总是可以选择进度最落后的那些表优先进行处理；\n2. tableSinks 是一个以表 ID 为 Key 的映射，其 Value 中记录了每个表当前可以向下游同步的上界；记为每个表的 ResolvedTs。这个上界即是 SortEngine 中 OnResolve 钩子来设置的；\n3. lastBarrierTs 是该 Changefeed 中所有表能够向下游同步的上界。在对每个表生成 Sink 任务时，实际的上界即为 lastBarrierTs 和表的 ResolvedTs 取最小值得到；\n4. sinkWorkers 是 Sink 任务的执行者，sinkTaskChan 用于向它们发送 Sink 任务。任务执行完毕之后，会重新进进入 sinkProgressHeap 等待下一次调度；\n5. sinkMemQuota 是一个内存配额管理器，用于控制该 Changefeed 下所有 Sink 任务执行过程中占用的总内存。\n\n生成 Sink 任务的代码在 SinkManager 的 generateSinkTasks 方法中，细节的部分这里就不作展开了，请读者自行观察领会。\n\n#### 任务的执行\n\nSink 任务在 cdc/processor/sinkmanager/table_sink_worker.go 中的 sinkWorker.handleTask 函数中执行，这个函数比较长，处理的逻辑也比较繁琐，我们仅重点留意这两个细节：\n\n- 配合 MemQuota，在内存不足时主动放弃任务并记录进度；\n- 针对大事务拆分功能是否开启的不同情况，将攒批的事件组装后发送到下游；\n\n至于下游 Sink 的具体实现，比如对 MySQL 或者消息队列的不同处理，则由后续的源码阅读章节来深入阐述了。\n\n```\n        if usedMem >= availableMem {\n            if txnFinished { // It means this transaction is complete.\n                if w.sinkMemQuota.TryAcquire(requestMemSize) {\n                    availableMem += requestMemSize\n                }\n            } else {\n                if !w.splitTxn {\n                    w.sinkMemQuota.ForceAcquire(requestMemSize)\n                    availableMem += requestMemSize\n                } else {\n                    // NOTE: if splitTxn is true it's not required to force acquire memory.\n                    if err := w.sinkMemQuota.BlockAcquire(requestMemSize); err != nil {\n                        return errors.Trace(err)\n                    }\n                    availableMem += requestMemSize\n                }\n            }\n        }\n```\n\n这段代码展示了在获取并处理完一个事件事件之后，如果遇到了申请的内存配额耗尽的情况，应该如何抉择。如果当前事务已经组装完整了，则可以调用 MemQuota.TryAcquire 来尝试分配更多内存来继续任务。如果分配失败，则会在这个地方中断当前任务，并将这个表的最新进度写回 sinkProgressHeap 中。如果当前事务没有组装完成，则视是否开启了大事务拆分功能又有不同分支。如果未开启大事务拆分功能，则必须调用 MemQuota.ForceAcquire 来申请内存，因为事务组装完毕前无法发送到下游，从而已经申请过的内存配额便不能释放，如果调用 MemQuota.BlockAcquire，则会有潜在的死锁问题。\n\n```\n    doEmitAndAdvance := func() (err error) {\n        if len(events) > 0 {\n            task.tableSink.appendRowChangedEvents(events...)\n            events = events[:0]\n        }\n        if currTxnCommitTs == lastPos.CommitTs { // It means the current transaction is completed.\n            if lastPos.IsCommitFence() {\n                // All transactions before currTxnCommitTs are resolved.\n                err = w.advanceTableSink(task, currTxnCommitTs, committedTxnSize+pendingTxnSize)\n            } else {\n                // This means all events of the currenet transaction have been fetched, but we can't\n                // ensure whether there are more transaction with the same CommitTs or not.\n                err = w.advanceTableSinkWithBatchID(task, currTxnCommitTs, committedTxnSize+pendingTxnSize, batchID)\n            }\n            committedTxnSize = 0\n            pendingTxnSize = 0\n        } else if w.splitTxn && currTxnCommitTs > 0 {\n            // This branch will advance some complete transactions before currTxnCommitTs,\n            // and one partail transaction with `batchID`.\n            err = w.advanceTableSinkWithBatchID(task, currTxnCommitTs, committedTxnSize+pendingTxnSize, batchID)\n        } else if !w.splitTxn && lastTxnCommitTs > 0 {\n            err = w.advanceTableSink(task, lastTxnCommitTs, committedTxnSize)\n            committedTxnSize = 0\n        }\n        return\n    }\n```\n\n这段代码展示了攒批并将事务发送到下游的核心逻辑。首先调用 appendRowChangedEvents 方法将事件转移给下游 Sink，注意此时只是在下游暂存，尚无法发送。后续会通过 advanceTableSink 或 advanceTableSinkWithBatchID 函数告知下游你可以将暂存的事件中截止到某一位置的内容发出去了，此时事件才真正能流向下游外部存储，之后即可更新这个表的 Checkpoint。\n\nadvanceTableSink 和 advanceTableSinkWithBatchID 的区别是后者会携带一个 BatchID，意味着给定的 CommitTs 上，还可能存在更多的事务未发送到下游，因此不能将 Checkpoint 推进到这个 CommitTs 上。这个逻辑的必要性在于，在 TiDB 中多个事务是可能共享相同的 CommitTs 的。\n\n回到代码，在 appendRowChangedEvents 之后首先会判断当前事务是否已经组装完成。如果是，则从该事务的 StartTs 和 CommitTs 的比较（即 IsCommitFence 方法）中可以判断后续是否仍然可能有相同 CommitTs 的事务进来，并分别调用上述两个函数中的一个。如果当前事务未组装完成，且没有开启大事务拆分的功能，则只能调用 advanceTableSink 函数将进度推到上一个组装完成的事务位置。易知，如果当前事务是一个非常大的事务，则只能在内存中一直保留直到组装完毕才能发送到下游了。\n\n## Sorter 的读写放大优化\n\n### 读放大的优化\n\nSorter 的默认排序引擎是 Pebble，后者基于 LSM 树，因此先天具有读写放大的缺点。在 TiCDC 中，我们通过一些手段极大缓解了读写放大的情况。下面我们通过代码具体地观察。\n\n```\ntype tableCRTsCollector struct {\n    minTs uint64\n    maxTs uint64\n}\n\nfunc (t *tableCRTsCollector) Add(key pebble.InternalKey, value []byte) error {\n    crts := encoding.DecodeCRTs(key.UserKey)\n    if crts > t.maxTs {\n        t.maxTs = crts\n    }\n    if crts < t.minTs {\n        t.minTs = crts\n    }\n    return nil\n}\n\nfunc (t *tableCRTsCollector) Finish(userProps map[string]string) error {\n    userProps[minTableCRTsLabel] = fmt.Sprintf(\"%d\", t.minTs)\n    userProps[maxTableCRTsLabel] = fmt.Sprintf(\"%d\", t.maxTs)\n    return nil\n}\n```\n\n在创建并打开 Pebble 实例时，tableCRTsCollector 会被放入 pebble.Options 的 TablePropertyCollectors 字段中。后者会在生成磁盘文件时根据该文件中的每个 Key 来产生一些 Property，并最终记录到该文件中。在创建迭代器时，就可以通过这些 Property 过滤掉一些不需要的磁盘文件，从而在存在大量磁盘文件（特别是 Level-0 文件）的情况下，显著地减少读放大。上面代码中，我们在 Property 中记录了该文件包含的所有事件的 CommitTs 范围，并以此来过滤不需要访问的磁盘文件。\n\n### 写放大的优化\n\n```\nfunc (s *EventSorter) cleanTable(state *tableState, tableID model.TableID, upperBound engine.Position) error {\n    var start, end []byte\n\n    start = encoding.EncodeTsKey(s.uniqueID, uint64(tableID), 0)\n    toCleanNext := upperBound.Next()\n    end = encoding.EncodeTsKey(s.uniqueID, uint64(tableID), toCleanNext.CommitTs, toCleanNext.StartTs)\n\n    db := s.dbs[getDB(tableID, len(s.dbs))]\n    err := db.DeleteRange(start, end, &pebble.WriteOptions{Sync: false})\n    if err != nil {\n        return err\n    }\n\n    return nil\n}\n```\n\ncleanTable 函数展示了在一个表的 CheckpointTs 向前推进之后，残留在 Sorter 中的数据如何清理的细节，即最终会调用 Pebble 的 DeleteRange API。后者适用于以较低的代价删除一段区间，实际的数据会在 Pebble 内部进行 Compaction 的时候清理掉。这个调用不宜过于频繁，否则会极大地影响迭代器的性能，目前 TiCDC 中对于每个表会最低间隔 5s 调用一次。利用 DeleteRange 机制，可以极大地减小 TiCDC 在使用 Pebble 时潜在的写放大，在 TiCDC 整体延迟较小的情况下效果尤其优异。\n\n这两段代码都在 cdc/processor/sourcemanager/engine/pebble 这个包下面，感兴趣的读者可以进一步深究。\n\n## 总结\n\n以上就是 TiCDC 中 Sorter 模块的源码解析，限于篇幅未能覆盖全部细节，希望能为读者提供按图索骥的导览。关于 Sorter 模块目前仍有许多潜在的优化，欢迎社区的朋友参与研发与迭代，一同成长进步！\n\n","date":"2023-03-29","author":"屈鹏","fillInMethod":"writeDirectly","customUrl":"ticdc-source-code-reading-7","file":null,"relatedBlogs":[{"relatedBlog":{"body":"这一次 TiCDC 阅读系列文章将会从源码层面来讲解 TiCDC 的基本原理，希望能够帮助读者深入地了解 TiCDC 。本篇文章是这一系列文章的第一期，主要叙述了 TiCDC 的目的、架构和数据同步链路，旨在让读者能够初步了解 TiCDC，为阅读其他源码阅读文章起到一个引子的作用。\n\n## TiCDC 是什么？\n\nTiCDC 是 TiDB 生态中的一个数据同步工具，它能够将上游 TiDB集群中产生的增量数据实时的同步到下游目的地。除了可以将 TiDB 的数据同步至 MySQL 兼容的数据库之外，还提供了同步至 Kafka 和 s3 的能力，支持 canal 和 avro 等多种开放消息协议供其他系统订阅数据变更。\n\n![1.PNG](https://img1.www.pingcap.com/prod/1_3684c70859.PNG)\n\n上图描述了 TiCDC 在整个  TiDB 生态系统中的位置，它处于一个上游 TiDB 集群和下游其它数据系统的中间，充当了一个数据传输管道的角色。\n\n**TiCDC 典型的应用场景为搭建多套 TiDB 集群间的主从复制，或者配合其他异构的系统搭建数据集成服务。以下将从这两方面为大家介绍：**\n\n### 主从复制\n\n使用 TiCDC 来搭建主从复制的 TiDB 集群时，根据从集群的使用目的，可能会对主从集群的数据一致性有不同的要求。目前 TiCDC 提供了如下两种级别的数据一致性:\n\n![2.PNG](https://img1.www.pingcap.com/prod/2_70eda26c9e.PNG)\n\n- 快照一致性：通过开启 Syncpoint 功能，能够在实时的同步过程中，保证上下游集群在某个 TSO 的具备快照一致性。详细内容可以参考文档：[TiDB 主从集群的数据校验](https://docs.pingcap.com/zh/tidb/dev/upstream-downstream-diff)\n- 最终一致性：通过开启 Redo Log 功能，能够在上游集群发生故障的时候，保证下游集群的数据达到最终一致的状态。详细内容可以参考文档：[使用 Redo Log 确保数据一致性](https://docs.pingcap.com/zh/tidb/dev/replicate-between-primary-and-secondary-clusters#%E7%AC%AC-5-%E6%AD%A5%E4%BD%BF%E7%94%A8-redo-log-%E7%A1%AE%E4%BF%9D%E6%95%B0%E6%8D%AE%E4%B8%80%E8%87%B4%E6%80%A7)\n\n### 数据集成\n\n![3.PNG](https://img1.www.pingcap.com/prod/3_1cc9144622.PNG)\n\n目前 TiCDC 提供将变更数据同步至 Kafka 和 S3 的能力，用户可以使用该功能将 TiDB 的数据集成进其他数据处理系统。在这种应用场景下，用户对数据采集的实时性和支持的消息格式的多样性会由较高的要求。当前我们提供了多种可供订阅的消息格式(可以参考 [配置 Kafka](https://docs.pingcap.com/zh/tidb/dev/ticdc-sink-to-kafka#sink-uri-%E9%85%8D%E7%BD%AE-kafka))，并在最近一段时间内对该场景的同步速度做了一系列优化，读者可以从之后的文章中了解相关内容。\n\n## TiCDC 的架构\n\n![4.PNG](https://img1.www.pingcap.com/prod/4_67f2e4ea50.PNG)\n\n确保数据传输的稳定性、实时性和一致性是 TiCDC 设计的核心目标。为了实现该目标，TiCDC 采用了分布式架构和无状态的服务模式，具备高可用和水平扩展的特性。想要深入了解 CDC 的架构，我们需要先认识下面这些概念：\n\n### 系统组件\n\n- TiKV：\n  - TiKV 内部的 CDC 组件会扫描和拼装 kv change log。\n  - 提供输出 kv change logs 的接口供 TiCDC 订阅。\n- Capture：\n  - TiCDC 运行进程，多个 capture 组成一个 TiCDC 集群。\n  - 同步任务将会按照一定的调度规则被划分给一个或者多个 Capture 处理。\n\n### 逻辑概念\n\n- KV change log：TiKV 提供的隐藏大部分内部实现细节的的 row changed event，TiCDC 从 TiKV 拉取这些 Event。\n- Owner：一种 Capture 的角色，每个 TiCDC 集群同一时刻最多只存在一个 Capture 具有 Owner 身份，它负责响应用户的请求、调度集群和同步 DDL 等任务。\n- ChangeFeed：由用户启动同步任务，一个同步任务中可能包含多张表，这些表会被 Owner 划分为多个子任务分配到不同的 Capture 进行处理。\n- Processor：Capture 内部的逻辑线程，一个 Capture 节点中可以运行多个 Processor。每个 Processor 负责处理 ChangeFeed 的一个子任务。\n- TablePipeline：Processor 内部的数据同步管道，每个 TablePipeline 负责处理一张表，表的数据会在这个管道中处理和流转，最后被发送到下游。\n\n### 基本特性\n\n- 分布式：具备高可用能力，支持水平扩展。\n- 实时性：常规场景下提供秒级的同步能力。\n- 有序性：输出的数据行级别有序，并且提供 At least once 输出的保证。\n- 原子性：提供单表事务的原子性。\n\n## TiCDC 的生命周期\n\n认识了以上的基本概念之后，我们可以继续了解一下 TiCDC 的生命周期。\n\n### Owner\n\n![5.png](https://img1.www.pingcap.com/prod/5_352c474d49.png)\n\n首先，我们需要知道，TiCDC 集群的元数据都会被存储到 PD 内置的 Etcd 中。当一个 TiCDC 集群被部署起来时，每个 Capture 都会向 Etcd 注册自己的信息，这样 Capture 就能够发现彼此的存在。接着，各个 Capture 之间会竞选出一个 Owner ，Owner 选举流程在 [cdc/capture.go](https://github.com/pingcap/tiflow/blob/master/cdc/capture/capture.go#L393) 文件的 `campaignOwner` 函数内，下面的代码删除了一些错误处理逻辑和参数设置，只保留主要的流程：\n\n```go\nfor {\n        // Campaign to be the owner, it blocks until it been elected.\n        err := c.campaign(ctx)\n        ...\n        owner := c.newOwner(c.upstreamManager)\n        c.setOwner(owner)\n        ...\n        err = c.runEtcdWorker(ownerCtx, owner,...)\n        c.owner.AsyncStop()\n        c.setOwner(nil)\n}\n```\n\n每一个 Capture 进程都会调用该函数，进入一个竞选的 Loop 中，每个  Capture 都会持续不断地在竞选 Owner。同一时间段内只有一个 Capture 会当选，其它候选者则会阻塞在这个 Loop 中，直到上一个 Owner 退出就会有新的 Capture 当选。\n\n最后真正的竞选是通过在 `c.campaign(ctx)` 函数内部调用 Etcd 的 `election.Campaign` 接口实现的，Etcd 保证了同一时间只有一个 Key 能够当选为 Owner。由于 Etcd 是高可用的服务，TiCDC 借助其力量实现了天然的高可用。\n\n竞选到 Owner 角色的 Capture 会作为集群的管理者，也负责监听和响应来自用户的请求。\n\n### ChangeFeed\n\nTiCDC 集群启动完毕之后，用户即可使用 TiCDC 命令行工具或者 OpenAPI 创建 ChangeFeed (同步任务)。\n一个 ChangeFeed 被创建之后，Owner 会负责对它进行检查和初始化，然后将以表为单位将划分为多个子任务分配给集群内的 Capture 进行同步。同步任务初始化的代码在 [cdc/owner/changefeed.go](https://github.com/pingcap/tiflow/blob/master/cdc/owner/changefeed.go#L404) 文件中。该函数的主要工作为：\n\n1. 向上游查询该同步任务需要同步的表的 Schema 信息，为接下来调度器分配同步任务做准备。\n2. 创建一个 `ddlPuller` 来拉取 DDL 。因为我们需要在同步的过程中保持多个 Capture 节点上 Schema 信息的一致，并且保证 DML 与 DDL 同步顺序。所以我们选择仅由 Owner 这个拥有 ChangeFeed 所以信息的角色同步 DDL。\n3. 创建 `scheduler` ，它会负责把该同步任务拆分成多个子任务，发送给别的 Capture 进行处理。\n\nCapture 接收到 Owner 发送过来的子任务之后，就会创建出一个 Processor 来处理它接收到的子任务，Processor 会为每张表创建出一个 TablePipeline 来同步对应的表的数据。Processor 会周期性的把每个 TablePipeline 的状态和进度信息汇报给 Owner，由 Owner 来决定是否进行调度和状态更新等操作。\n总而言之，TiCDC 集群和同步任务的状态信息会在 Owner 和 Processor 之间流转，而用户需要同步的数据信息则通过 TablePipeline 这个管道传递到下游，下一个小节将会对 TablePipeline 进行讲解，理解了它，就能够理解 TiCDC 是怎么同步数据的。\n\n### TablePipeline\n\n![6.png](https://img1.www.pingcap.com/prod/6_156ee3be14.png)\n\n顾名思义，TablePipeline 是一个表数据流动和处理的管道。Processor 接收到一个同步子任务之后，会为每一张表创建出一个 TablePipeline，如上图所示，它主要由 Puller、Sorter、Mounter 和 Sink 构成。\n\n- Puller： 负责拉取对应表在上游的变更数据，它隐藏了内部大量的实现细节，包括与 TiKV CDC 模块建立 gRPC 连接和反解码数据流等。\n- Sorter： 负责对 Puller 输出的乱序数据进行排序，并且会把 Sink 来不及消费的数据进行落盘，起到一个蓄水池的作用。\n- Mounter：根据事务提交时的表结构信息解析和填充行变更，将行变更转化为 TiCDC 能直接处理的数据结构。在这里，Mounter 需要和一个叫做 SchemaStorage 的组件进行交互，这个组件在 TiCDC 内部维护了所需表的 Schema 信息，后续会有内容对这其进行讲解。\n- Sink：将 Mounter 处理过后的数据进行编解码，转化为 SQL 语句或者 Kafka 消息发送到对应下游。\n\n这种模块化的设计方式，比较有利于代码的维护和重构。值得一提的是，如果你对 TiCDC 有兴趣，希望能够让它接入到当前 CDC 还不支持的下游系统，那么只要自己编码实现一个对应的 Sink 接口，就可以达到目的。\n接下来，我们以一个具体例子的方式来讲解数据在 TiCDC 内部的流转。假设我们现在建立如下表结构：\n\n```sql\nCREATE TABLE TEST(\n   NAME VARCHAR (20)     NOT NULL,\n   AGE  INT              NOT NULL,\n   PRIMARY KEY (NAME)\n);\n\n+-------+-------------+------+------+---------+-------+\n| Field | Type        | Null | Key  | Default | Extra |\n+-------+-------------+------+------+---------+-------+\n| NAME  | varchar(20) | NO   | PRI  | NULL    |       |\n| AGE   | int(11)     | NO   |      | NULL    |       |\n+-------+-------------+------+------+---------+-------+\n```\n\n此时，在上游 TiDB 执行以下 DML：\n\n```sql\nINSERT INTO TEST (NAME,AGE)\nVALUES ('Jack',20);\n\nUPDATE TEST\nSET AGE = 25\nWHERE NAME = 'Jack';\n```\n\n下面我们就来看一看这两条 DML 会通过什么样的形式经过 TablePipeline ，最后写入下游。\n\n#### Puller 拉取数据\n上文中提到 Puller 负责与 TiKV CDC 组件建立 gPRC 连接然后拉取数据，这是 [/pipeline/puller.go](https://github.com/pingcap/tiflow/blob/master/cdc/processor/pipeline/puller.go#L67) 中的 Puller 大致的工作逻辑：\n\n```go\nn.plr = puller.New(... n.startTs, n.tableSpan(),n.tableID,n.tableName ...)\nn.wg.Go(func() error {\n   ctx.Throw(errors.Trace(n.plr.Run(ctxC)))\n   ...\n})\nn.wg.Go(func() error {\n   for {\n      select {\n      case <-ctxC.Done():\n         return nil\n      case rawKV := <-n.plr.Output():\n         if rawKV == nil {\n            continue\n         }\n         pEvent := model.NewPolymorphicEvent(rawKV)\n         sorter.handleRawEvent(ctx, pEvent)\n      }\n   }\n})\n```\n\n以上是经过简化的代码，可以看到在 `puller.New` 方法中，有两个比较重要的参数 `startTs` 和 `tableSpan()`，它们分别从时间和空间这两个维度上描述了我们想要拉取的数据范围。在 Puller 被创建出来之后，下面部分的代码分别启动了两个 goroutine，第一个负责运行 Puller 的内部逻辑，第二个则是等待 Puller 输出数据，然后把数据发给 Sorter。从 `plr.Output()` 中吐出来的数据长这个样子：\n\n```go\n// RawKVEntry notify the KV operator\ntype RawKVEntry struct {\n   OpType OpType `msg:\"op_type\"`\n   Key    []byte `msg:\"key\"`\n   // nil for delete type\n   Value []byte `msg:\"value\"`\n   // nil for insert type\n   OldValue []byte `msg:\"old_value\"`\n   StartTs  uint64 `msg:\"start_ts\"`\n   // Commit or resolved TS\n   CRTs uint64 `msg:\"crts\"`\n   ...\n}\n```\n\n所以，在上游 TiDB 写入的那两条 DML 语句，在到达 Puller 的时候会是这样这样的一个数据结构\n\n![7.png](https://img1.www.pingcap.com/prod/7_39e4d6bcc1.png)\n\n我们可以看到 Insert 语句扫描出的数据只有 value 没有 old_value，而 Update 语句则被转化为一条既有 value 又有 old_value 的行变更数据。\n\n这样这两条数据就成功的被 Puller 拉取到了 TiCDC，但是因为 TiDB 中一张表的数据会被分散到多个 Region 上，所以 Puller 会与多个 TiKV Region Leader 节点建立连接，然后拉取数据。那实际上 TiCDC 拉取到的变更数据可能是乱序的，我们需要对拉取到的所有数据进行排序才能正确的将事务按照顺序同步到下游。\n\n#### Sorter 排序\n\nTablePipeline 中的 Sorter 只是一个拥有 Sorter 名字的中转站，实际上负责对数据进行排序的是它背后的 Sorter Engine，Sorter Engine 的生命周期是和 Capture 一致的，一个 Capture 节点上的所有 Processor 会共享一个 Sorter Engine。想要了解它是怎么工作的，可以阅读 [EventSorter 接口](https://github.com/pingcap/tiflow/blob/master/cdc/sorter/sorter.go#L32)和其具体实现的相关代码。\n\n在这里，我们只需要知道数据进入 TablePipeline 中的 Sorter 后会被排序即可。假设我们现在除了上述的两条数据之外，在该表上又进行了其他的写入操作，并且该操作的数据在另外一个 Region。最终 Puller 拉到的数据如下：\n\n![8.png](https://img1.www.pingcap.com/prod/8_6fb8394f77.png)\n\n除了数据之外，我们还可以看到 `Resolved` 的事件，这是一个在 TiCDC 系统中很重要的时间标志。当 TiCDC 收到 `Resolved` 时，**可以认为小于等于这个时间点提交的数据都已经被接收了，并且以后不会再有早于这个时间点的数据再发送下来，此时 TiCDC 可以此为界限来将收到的数据同步至下游。**\n\n此外，我们可以看到拉取到的数据并不是按照 commit_ts 严格排序的，Sorter 会根据 commit_ts 将它们进行排序，最终得到如下的数据：\n\n![9.png](https://img1.www.pingcap.com/prod/9_10f6237293.png)\n\n现在排好顺序的事件就可以往下游同步了，但是在这之前我们需要先对数据做一些转换，因为此时的数据是从 TiKV 中扫描出的 key-value，它们实际上只是一堆 bytes 数据，而不是下游想要消费的消息格式。\n\n#### Mounter 解析\n\n以上的 Event 数据从 Sorter 出来之后，Mounter 会根据其对应的表的 Schema 信息将它还原成按照表结构组织的数据。\n\n```go\ntype RowChangedEvent struct {\n   StartTs  uint64\n   CommitTs uint64\n   Table    *TableName\n   ColInfos []rowcodec.ColInfo\n   Columns      []*Column\n   PreColumns   []*Column\n   IndexColumns [][]int\n   ...\n}\n```\n\n可以看到，该结构体中还原出了所有的表和列信息，并且 Columns 和 PreColumns 就对应于 value 和 old_value。当 TiCDC 拿到这些信息之后我们就可以将数据继续下发至 Sink 组件，让其根据表信息和行变更数据去写下游数据库或者生产 Kafka 消息。值得注意的是，Mounter 进行的是一项 CPU 密集型工作，当一个表中所包含的字段较多时，Mounter 会消耗大量的计算资源。\n\n#### Sink 下发数据\n\n当 `RowChangedEvent` 被下发至 Sink 组件时，它身上已经包含了充分的信息，我们可以将其转化为 SQL 或者特定消息格式的 Kafka 消息。在上文的架构图中我们可以看到有两种 Sink，一种是接入在 Table Pipeline 中的 TableSink，另外一种是 Processor 级别共用的 ProcessorSink。它们在系统中有不同的作用：\n\n- TableSink 作为一种 Table 级别的管理单位，缓存着要下发到 ProcessorSink 的数据，它的主要作用是方便 TiCDC 按照表为单位管理资源和进行调度\n- ProcessorSink 作为真实要与数据库或者 Kafka 建立连接的 Sink 负责 SQL/Kafka 消息的转换和同步\n\n我们再来看一看 ProcessorSink 到底如何转换这些行变更：\n\n- 如果下游是数据库，ProcessorSink 会根据 `RowChangedEvent` 中的 Columns 和 PreColumns 来判断它到底是一个 `Insert`、`Update` 还是 `Delete` 操作，然后根据不同的操作类型，将其转化为 SQL 语句，然后再将其通过数据库连接写入下游：\n\n```sql\n/*\n因为只有 Columns 所以是 Insert 语句。\n*/\nINSERT INTO TEST (NAME,AGE)\nVALUES ('Jack',20);\n\n/*\n因为既有 Columns 且有 PreColumns 所以是 Update 语句。\n*/\nUPDATE TEST\nSET AGE = 25\nWHERE NAME = 'Jack';\n```\n\n- 如果下游是 Kafka, ProcessorSink 会作为一个 [Kafka Producer](https://docs.confluent.io/platform/current/clients/producer.html) 按照特定的消息格式将数据发送至 Kafka。 以 [Canal-JSON](https://docs.pingcap.com/tidb/v6.0/ticdc-canal-json) 为例，我们上述的 Insert 语句最终会以如下的 JSON 格式写入 Kafka：\n\n```json\n{\n    \"id\": 0,\n    \"database\": \"test\",\n    \"table\": \"TEST\",\n    \"pkNames\": [\n        \"NAME\"\n    ],\n    \"isDdl\": false,\n    \"type\": \"INSERT\",\n    ...\n    \"ts\": 2,\n    \"sql\": \"\",\n    ...\n    \"data\": [\n        {\n            \"NAME\": \"Jack\",\n            \"AGE\": \"25\"\n        }\n    ],\n    \"old\": null\n}\n```\n\n这样，上游 TiDB 执行的 DML 就成功的被发送到下游系统了。\n\n## 结尾\n\n以上就是本文的全部内容。希望在阅读完上面的内容之后，读者能够对 TiCDC 是什么？为什么？怎么实现？这几个问题有一个基本的答案。","author":"江宗其","category":1,"customUrl":"ticdc-source-code-reading-1","fillInMethod":"writeDirectly","id":444,"summary":"本篇文章是 TiCDC 源码阅读系列文章的第一期，主要叙述了 TiCDC 的目的、架构和数据同步链路，旨在让读者能够初步了解 TiCDC，为阅读其他源码文章起到一个引子的作用。","tags":["TiCDC"],"title":"TiCDC 源码阅读（一）TiCDC 架构概览"}},{"relatedBlog":{"body":"## 内容概要\n\nTiCDC 是一款 TiDB 增量数据同步工具，通过拉取上游 TiKV 的数据变更日志，TiCDC 可以将数据解析为有序的行级变更数据输出到下游。\n\n本文是 TiCDC 源码解读的第二篇，将于大家介绍 TiCDC 的重要组成部分，TiKV 中的 CDC 模块。我们会围绕 4 个问题和 2 个目标展开。\n\n1. TiKV 中的 CDC 模块是什么？\n2. TiKV 如何输出数据变更事件流？\n3. 数据变更事件有哪些？\n4. 如何确保完整地捕捉分布式事务的数据变更事件？\n\n希望在回答完这4个问题之后，大家能：\n\n- 🔔 了解数据从 TiDB 写入到 TiKV CDC 模块输出的流程。\n- 🗝️ 了解如何完整地捕捉分布式事务的数据变更事件。\n\n在下面的内容中，我们在和这两个目标相关的地方会标记上 🔔 和 🗝️，以便提醒读者留意自己感兴趣的地方。\n\n## TiKV 中的 CDC 模块是什么？\n\n### CDC 模块的形态\n\n从代码上看，CDC 模块是 TiKV 源码的一部分，它是用 rust 写的，在 TiKV 代码库里面；从运行时上看，CDC 模块运行在 TiKV 进程中，是一个线程，专门处理 TiCDC 的请求和数据变更的捕捉。\n\n### CDC 模块的作用\n\nCDC 模块的作用有两个：\n\n1. 它负责捕捉实时写入和读取历史数据变更。这里提一下历史数据变更指已经写到 rocksdb 里面的变更。\n2. 它还负责计算 resolved ts。这个 resolved ts 是 CDC 模块里面特有的概念，形式上是一个 uint64 的 timestamp。它是 TiKV 事务变更流中的 perfect watermark，perfect watermark 的详细概念参考《Streaming System》的第三章，我们可以用 resolved ts 来告知下游，也就是 TiCDC，在 tikv 上所有 commit ts 小于 resolved ts 事务都已经完整发送了，下游 TiCDC 可以完整地处理这批事务了。\n\n### CDC 模块的代码分布\n\nCDC 模块的代码在 TiKV 代码仓库的 `compoenetns/cdc` 和 `components/resolved_ts` 模块。我们在下图中的黑框里面用红色标注了几个重点文件。\n\n在 `delegate.rs` 文件中有个同名的 `Delegate` 结构体，它可以认为是 Region 在 CDC 模块中的“委派”，负责处理这个 region 的变更数据，包括实时的 raft 写入和历史增量数据。\n\n在 `endpoint.rs` 文件中有个 `Endpoint` 结构体，它运行在 CDC 的主线程中，驱动整个 CDC 模块，上面的 delegate 也是运行在整个线程中的。\n\n`initializer.rs` 文件中的 `Initializer` 结构体负责增量扫逻辑，同时也负责 delegate 的初始化，这里的增量扫就是读取保存在 rocksdb 中的历史数据变更。\n\n`service.rs` 文件中的 `Service` 结构体，它实现了 ChagneData gRPC 服务，运行在 gRPC 的线程中，它负责 TiKV 和 TiCDC 的 RPC 交互，同时它和 `Endpoint` 中的 `Delegate` 和 `Initializer` 也会有交互，主要是接受来自它俩的数据变更事件，然后把这些事件通过 RPC 发送给 TiCDC。\n\n最后一个重要文件是 `resolver.rs`，它与上面的文件不太一样，在 resolve_ts 这个 component 中，里面的 `Resolver` 负责计算 resolved ts。\n\n![1.PNG](https://img1.www.pingcap.com/prod/1_e91f78042c.PNG)\n\n## TiKV 如何输出数据变更事件流？\n\n我们从端到端的角度完整地走一遍数据的写入和流出。下图概括了数据的流动，我们以数据保存到磁盘为界，红色箭头代表数据从 TiDB 写入 TiKV 磁盘的方向，蓝色箭头代表数据从 TiKV 磁盘流出到 TiCDC 的方向。\n\n![UML 图.jpg](https://img1.www.pingcap.com/prod/UML_bb75620add.jpg)\n\n### TiDB -> TiKV Service\n \n- txn prewrite: [Tikv::kv_prewrite(PrewriteRequest)](https://github.com/tikv/tikv/blob/v6.4.0/src/server/service/kv.rs#L242)\n- txn commit: [Tikv::kv_commit(CommitRequest)](https://github.com/tikv/tikv/blob/v6.4.0/src/server/service/kv.rs#L263)\n\n我们看下从 TiDB 指向 TiKV 的红线。我们知道数据来自 TiDB 的事务写入，对于一个正常的事务来说，TiDB 需要分两次调用 TiKV 的 gRPC 接口，分别是 kv_prewrite 和 kv_commit，对应了事务中的 prewrite 和 commit，在 request 请求中包含了要写入或者删除的 key 和它的 value，以及一些事务的元数据，比如 start ts，commit ts 等。\n\n### TiKV Service -> Txn\n\n- txn prewrite: [Storage::sched_prewrite(PrewriteRequest)](https://github.com/tikv/tikv/blob/v6.4.0/src/server/service/kv.rs#L2189-L2241)\n- txn commit: [Storage::sched_commit(CommitRequest)](https://github.com/tikv/tikv/blob/v6.4.0/src/server/service/kv.rs#L2271-L2283)\n\n我们再看从 gRPC 指向 Txn 的红线。它代表 RPC 请求从 gRPC 模块流到事务模块的这一步。这里相应的也有两个 API 的调用，分别是 `sched_prewrite` 和 `sched_commit`，在这两个 API 中，事务模块会对 request 做一些检查，比如检查 write conflict，计算 commit ts 等（事务的细节可以参考 TiKV 的源码阅读文章，在这里就先跳过了。）\n\n### Txn -> Raftstore\n\n- txn prewrite: [Engine::async_write_ext(RaftCmdRequest)](https://github.com/tikv/tikv/blob/v6.4.0/src/storage/txn/scheduler.rs#L1323)\n- txn commit: [Engine::async_write_ext(RaftCmdRequest)](https://github.com/tikv/tikv/blob/v6.4.0/src/storage/txn/scheduler.rs#L1323)\n\n事务模块到 Raftstore 的红线代表：Request 通过检查后，会被事务模块序列化成对 KV 的操作，然后被组装成 `RaftCmdRequest`。`RaftCmdRequest` 再经由 `Engine::async_commit_ext API` 被发送至 Raftstore 模块。\n\n大家可以看到 prewrite 和 commit 都是变成了 `RaftCmdRequest`，也都是通过 `Engine::async_commit_ext` 发送到 Raftstore 模块的。这说明了什么呢？它说明了到 Engine 这一层，TiDB 的请求中的事务信息已经被“抹去”了，所有的事务信息都存到了 key 和 value 里面。\n\nRaftstore 模块会将这些 key value 提交到 Raft Log 中，如果 Raft Log Commit 成功，Apply 线程会将这些 key 和 value 写入到 Rocksdb。（这里面的细节可以参考 [TiKV 的源码阅读文章](https://cn.pingcap.com/blog/?tag=TiKV%20源码阅读)，在这里就先跳过了。）\n\n### Rafstore -> CDC\n\n- RaftCmd: [CoprocessorHost::on_flush_applied_cmd_batch(Vec<RaftCmdRequest>)](https://github.com/tikv/tikv/blob/v6.4.0/components/raftstore/src/store/fsm/apply.rs#L597)\n- Txn Record: [Engine::async_snapshot()](https://github.com/tikv/tikv/blob/v6.4.0/src/server/raftkv.rs#L431)\n  \n从这里起，数据开始流出了，从 Raftstore 到 CDC 模块有两条蓝线，对应这里的两个重要的 API，分别为 `on_flush_applied_cmd_batch` 实时数据的流出，和 `async_snapshot` 历史增量数据的流出（后面会说细节）。\n  \n### CDC -> gRPC -> TiCDC\n  \n- ChangeDataEvent: [Service::event_feed() -> ChangeDataEvent](https://github.com/tikv/tikv/blob/v6.4.0/components/cdc/src/service.rs#LL201C8-L201C18)\n  \n最后就是从 CDC 模块到 TiCDC 这几条蓝线了。数据进入 CDC 模块后，经过一系列转换，组装成 Protobuf message，最后交给 gRPC 通过 ChangeData service 中的 `EventFeed` 这个 RPC 发送到下游的 TiCDC。\n  \n### CDC 模块中的数据流动\n\n![UML 图 (1).jpg](https://img1.www.pingcap.com/prod/UML_1_f57939f8fc.jpg)\n\n上图示意了数据从 Raftstore 发送到 TiCDC 模块的细节。\n  \n数据从 Raftstore 到 CDC 模块，可以分成两个阶段，对应两条链路：\n  \n- **阶段 1，增量扫**，Initializer -> Delegate。\n  \n  Initializer 从 Raftstore 拿一个 Snapshot，然后在 Snapshot 上读一些历史数据变更，读的范围有两个维度：\n  \n    1. 时间维度 `(checkpoint ts, current ts]`，checkpoint ts 可以理解成 changefeed 上的 checkpoint，current ts 代表 PD leader 上的当前时间。\n  \n    2. key 范围 `[start key, end key)`，一般为 region 的 start key 和 end key。\n  \n- **阶段 2，实时写入监听**，CdcObserver -> Delegate\n  \n  `CdcObserver` 实现对实时写入的监听。它运行在 Raftstore 的 Apply 线程中，只有在 TiCDC 对一个 Region 发起监听后才会启动运行。我们知道所有的数据都是通过 Apply 线程写入的，所以说 `CdcObserver` 能轻松地在第一时间把数据捕捉到，然后交给 `Delegate` 。\n  \n我们再看一下数据从 CDC 模块到 gRPC 的流程，大体也有两部分。第一部分是汇总增量扫和实时写入；第二部分将这些数据是从 KV 数据反序列化成包含事务信息的 Protobuf message。我们再将这些事务结构体里面的信息给提取出来，填到一个 Protobuf message 里面。\n  \n### Raftstore 和 TiCDC 的交互\n\n![UML 图 (2).jpg](https://img1.www.pingcap.com/prod/UML_2_993f60ef5a.jpg)\n\n上图是 Raftstore 和 CDC 模块的交互时序图。第一条线是 TiCDC，第二条是 CDC 线程，第三条是 Raftsotre 线程，第四条是 Apply 线程，图中每个点都是发生在线程上的一些事件，包含发消息、收消息和进程内部的处理逻辑。在这里我们重点说 Apply 线程。\n  \nApply 线程在处理 Change 这个消息的时候，它会先要把缓存在内存中的 KV 的写入给刷到 RocksDB，然后获取 RocksDB 的 Snapshot，把 Snapshot 发送给 CDC 线程。这三步是串行的，保证了 Snapshot 可以看到之前所有的写入。有了这个机制保证，我们就可以确保 CDC 模块既不漏数据，也不多数据。\n  \n## 数据变更事件有哪些？\n\n![image.png](https://img1.www.pingcap.com/prod/image_8a167cddf7.png)\n\n数据变更事件可分为两大类，第一类是 Event；第二类是 ResolvedTs。上图是 CDC Protobuf 的简化版定义，只保留了关键的 field。我们从上到下看下这个 Protobuf 定义。\n  \n`EventFeed` 定义了 TiCDC 和 TiKV 之间的消息交互方式，TiCDC 在一个 RPC 上可以发起对多个 Region 的监听，TiKV 以 `ChangeDataEvent` 形式将多个 Region 的数据变更事件发送给 TiCDC。\n  \n`Event` 代表着是 Region 级别的数据变更事件，包含了至少一个用户数据变更事件或者或者 Region 元数据的变更。它们是从单条 Raft Log 翻译得到的。我们可以注意到 `Event` 被 `repeat` 修饰了，也就是它可能包含了一个 region 多个数据变更，也可能包含多个不同 region 的数据变更。\n  \n`Entries` 包含了多个 `Row`。因为在 `oneof` 里面不能出现 `repeated` ，所以我们用 `Entries` 包装了下。\n\n![image (1).png](https://img1.www.pingcap.com/prod/image_1_f3177e681a.png)\n\n`Row` 里面的内容非常接近 TiDB 层面的数据了，它是行级别的数据变更，包含：\n1. 事务的 start ts；\n2. 事务的 commit ts；\n3. 事务写入的类型，Prewrite/Commit/Rollback；\n4. 事务对数据的操作，`op_type` ，put 覆盖写一行和 delete 删除一行；\n5. 事务写入的 key；\n6. 事务写入的 value；\n7. 该事务之前的 value，old value 在很多 CDC 协议上都会有体现，比如说 MySQL 的 maxwell 协议中的 “old” 字段。\n  \n## 如何确保完整地捕捉分布式事务的数据变更事件？\n  \n### 什么是“完整”？\n  \n我们需要定义完整是什么。在这里，“完整”的主体是 TiDB 中的事务，我们知道 TiDB 的事务会有两个写入事件，第一个是 prewrite，第二是 commit 或者 rollback。同时，TiDB 事务可能会涉及多个 key，这些有可能分布在不同的 region 上。所以，我们说“完整”地捕捉一个事务需要捕捉它涉及的**所有的 key** 和**所有的写入事件**。\n\n![UML 图 (3).jpg](https://img1.www.pingcap.com/prod/UML_3_42aee1488c.jpg)\n\n上图描绘了一个涉及了三个 key 的事务，P 代表事务的 prewrite，C 代表事务的 commit，虚线代表一次捕捉。\n  \n前面两条虚线是不“完整”的捕捉，第一条虚线漏了所有 key 的 commit 事件，第二条虚线捕捉到了 k1 和 k2 的 prewrite 和 commit，但漏了 k3 的 commit。如果我们强行认为第二条虚线是“完整”的，则会破坏事务的原子性。\n  \n最后一条虚线才是“完整”的捕捉，因为它捕捉到了所有 key 的所有写入。\n  \n### 如何确认已经“完整”？\n  \n确认“完整”的方法有很多种，最简单的办法就是--等。一般来说，只要我们等的时间足够长，比如等一轮 GC lifetime，我们也能确认完整。但是这个办法会导致 TiCDC 的 RPO 不达标。\n  \n![UML 图 (4).jpg](https://img1.www.pingcap.com/prod/UML_4_2ed55abe00.jpg)\n\n上图最后两条虚线是两次“完整”的捕捉，假如第四条线十年之后才产生的，显然它对我们来说是没有意义的。第四条虽然是“完整”的，但是不是我们想要的。所以我们需要一种机制能够尽快地告知我们已经捕捉完整了，也就是图中第三条虚线，在时间上要尽可能地靠近最后一个变更的捕捉。那这个机制的话就是前面提到的 resolved ts。\n  \n### ResolvedTs 事件及性质\n\n![image (2).png](https://img1.www.pingcap.com/prod/image_2_370f9e1f45.png)\n\nResolvedTs 在 Protobuf 中的定义比较简单，一个 Region ID 数组和一个 resolved ts。它记录了**一批** Region 中**最小的** resolved ts，会混在数据变更事件流中发送给 TiCDC。从 resolved ts 事件生成的时候开始，TiDB 集群就不会产生 commit ts 小于 resolved ts 的事务了。从而 TiCDC 收到这个事件之后，便能确认这些 Region 上的数据变更事件的完整性了。\n  \n### resolved ts 的计算\n\n![image (3).png](https://img1.www.pingcap.com/prod/image_3_abee7d0464.png)\n\nResolved ts 的计算逻辑在 resolver.rs 文件中，可以用简单三行伪代码表示：\n  \n- 第一行，它要从 PD 那边取一个 TS，称它为 `min_ts`。\n- 第二行，我们拿 `min_ts` 和 Region 中的所有 lock 的 start ts 做比较，取最小值，我们称它为 `new_resolved_ts` 。\n- 我们拿 `new_resolved_ts` 和之前的 `resolved_ts` 做比较，取最大值，这就是当前时刻的 resolved ts。因为它小于所有 lock 的 start ts，所有它一定小于这些 lock 的未来的 commit ts。同时，在没有 lcok 的时候，`min_ts` 会变成 resolved ts，也是就当前时刻 PD 上最新的 ts 将会变成 resolved ts，这确保了它有足够的实时性。\n  \n### 数据变更事件流的例子\n\n![UML 图 (5).jpg](https://img1.www.pingcap.com/prod/UML_5_d5b69856c0.jpg)\n\n上图是一个数据变更事件流的例子，也就是 gRPC EventFeed 中的 `stream ChangeDataEvent`。\n  \n例子中有三个事务和三个 resolved ts 事件：\n  \n- 第一个事务涉及了 k1 和 k2，它的 start ts 是 1， commit ts 是2。\n- 第二个事务只包含了 k1 这一个 key，它的 start ts 是 3，commit ts 是 6，注意，这个事务在事件流中出现了乱序，它的 commit 先于 prewrite 出现在这条流中。\n- 第三个事务包含了 k2 的一个事务，注意它只有一个 prewrite 事件，commit 事件还没发生，是一个正在进行中的一个事务。\n- 第一个 resolved ts 事件中的 resolved ts 是 2，代表 commit ts 小于等于 2 的事务已经完整发送，在这个例子中可以把第一个事务安全的还原出来。\n- 第二个 resolved ts 事件中的 resolved ts 是 4，这时 k1 的 commit 事件已经发送了，但是 prewrite 事件没有，4 就阻止了还原第二个事务。\n- 第三个 resolved ts 事件出现后，我们就可以还原第二个事务了。\n  \n## 结尾\n  \n以上就是本文的全部内容。希望在阅读上面的内容后，读者能知道文章开头的四个问题和了解：\n  \n- 🔔数据从 TiDB 写入到 TiKV CDC 模块输出的流程\n- 🗝️了解如何完整地捕捉分布式事务的数据变更事件","author":"沈泰宁","category":1,"customUrl":"ticdc-source-code-reading-2","fillInMethod":"writeDirectly","id":445,"summary":"本文是 TiCDC 源码解读的第二篇，将介绍 TiCDC 的重要组成部分，TiKV 中的 CDC 模块。","tags":["TiCDC"],"title":"TiCDC 源码阅读（二）TiKV CDC 模块介绍"}},{"relatedBlog":{"body":"## 内容概要\n\n[TiCDC](https://docs.pingcap.com/zh/tidb/dev/ticdc-overview) 是一款 TiDB 增量数据同步工具，通过拉取上游 TiKV 的数据变更日志，TiCDC 可以将数据解析为有序的行级变更数据输出到下游。\n\n本文是 TiCDC 源码解读的第三篇，主要内容是讲述 TiCDC 集群的启动及基本工作过程，将从如下几个方面展开：\n\n1. TiCDC Server 启动过程，以及 Server / Capture / Owner / Processor Manager 概念和关系\n2. TiCDC Changefeed 创建过程\n3. Etcd 在 TiCDC 集群中的作用\n4. Owner 和 Processor Manager 概念介绍，以及 Owner 选举和切换过程\n5. Etcd Worker 在 TiCDC 中的作用\n\n## 启动 TiCDC Server\n\n启动一个 TiCDC Server 时，使用的命令如下，需要传入当前上游 TiDB 集群的 PD 地址。\n\n```bash\ncdc server --pd=http://127.0.0.1:2379\n```\n\n它会启动一个 TiCDC Server 运行实例，并且向 PD 的 ETCD Server 写入 TiCDC 相关的元数据，具体的 Key 如下：\n\n```plain text\n/tidb/cdc/default/__cdc_meta__/capture/${capture_id}\n\n/tidb/cdc/default/__cdc_meta__/owner/${session_id}\n\n```\n\n第一个 Key 是 Capture Key，用于注册一个 TiCDC Server 上运行的 Capture 信息，每次启动一个 Capture 时都会写入相应的 Key 和 Value。\n\n第二个 Key 是 Campaign Key，每个 Capture 都会注册这样一个 Key 用于竞选 Owner。第一个写入 Owner Key 的 Capture 将成为 Owner 节点。\n\n[Server 启动](https://github.com/pingcap/tiflow/blob/v6.4.0/pkg/cmd/server/server.go#L290)，经过了解析 Server 启动参数，验证参数合法性，然后创建并且运行 TiCDC Server。[Server 运行](https://github.com/pingcap/tiflow/blob/v6.4.0/cdc/server/server.go#L256)的过程中，会启动多个运行线程。首先启动一个 Http Server 线程，对外提供 Http OpenAPI 访问能力。其次，会创建一系列运行在 Server 级别的资源，主要作用是辅助 Capture 线程运行。最重要的是创建并且运行 Capture 线程，它是 TiCDC Server 运行的主要功能提供者。\n\n![image.png](https://img1.www.pingcap.com/prod/image_d8678008a7.png)\n\n[Capture 运行](https://github.com/pingcap/tiflow/blob/v6.4.0/cdc/capture/capture.go#L292)时，首先会将自己的 Capture Information 投入到 ETCD 中。然后启动两个线程，一个运行 `ProcessorManager`，负责所有 Processor 的管理工作。另外一个运行 `campaignOwner`，其内部会负责竞选 Owner，以及运行 Owner 职责。如下所示，TiCDC Server 启动之后，会创建一个 Capture 线程，而 Capture 在运行过程中又会创建 ProcessorManager 和 Owner 两个线程，各自负责不同的工作任务。\n\n![image (1).png](https://img1.www.pingcap.com/prod/image_1_7bb32134ad.png)\n\n## 创建 TiCDC Changefeed\n\n创建 changefeed 时使用的命令如下：\n\n```plain text\ncdc changefeed create --server=http://127.0.0.1:8300 --sink-uri=\"blackhole://\" --changefeed-id=\"blackhole-test\"\n```\n\n其中的 server 参数标识了一个运行中的 TiCDC 节点，它记录了启动时候的 PD 地址。在创建 changefeed 时，server 会访问该 PD 内的 ETCD Server，写入一个 Changefeed 的元数据信息。\n```plain\n/tidb/cdc/default/default/changefeed/info/${changefeed_id}\n\n/tidb/cdc/default/default/changefeed/status/${changefeed_id}\n```\n\n- 第一个 Key 标识了一个 Changefeed，包括该 Changefeed 的各种静态元数据信息，比如 `changefeed-id`，`sink-uri`，以及一些其他标识运行时是为的数据。\n- 第二个 Key 标识了该 Changefeed 的运行时进度，主要是记录了 `Checkpoint` 和 `ResolvedTs` 的推进情况，会不断地周期性地更新。\n\n## Etcd 的作用\n\n![image (2).png](https://img1.www.pingcap.com/prod/image_2_1f5f680540.png)\n\nETCD 在整个 TiCDC 集群中承担了非常重要的元数据存储功能，它记录了 Capture 和 Changefeed 等重要信息。同时通过不断记录并且更新 Changefeed 的 Checkpoint 和 ResolvedTs，保证 Changefeed 能够稳步向前推进工作。从上图中我们可以知道，Capture 在启动的时候，自行将自己的元数据信息写入到 ETCD 中，在此之后，Changefeed 的创建，暂停，删除等操作，都是经由已经启动的 TiCDC Owner 来执行的，后者负责更新 ETCD。\n\n## Owner 选举和切换\n\n一个 TiCDC 集群中可以存在着多个 TiCDC 节点，每个节点上都运行着一个 campaignOwner 线程，负责竞选 Owner，如果竞选成功，则履行 Owner 的工作职责。集群中只有一个节点会竞选成功，然后执行 Owner 的工作逻辑，其他节点上的该线程会阻塞在竞选 Owner 上。\n\n![image (3).png](https://img1.www.pingcap.com/prod/image_3_72d9a28b48.png)\n\nTiCDC Owner 的选举过程是基于 [ETCD Election](https://etcd.io/docs/v3.3/dev-guide/api_concurrency_reference_v3/#service-election-etcdserverapiv3electionv3electionpbv3electionproto) 实现的。每个 Capture 在启动之后，会创建 [ETCD Session](https://github.com/etcd-io/etcd/blob/main/client/v3/concurrency/session.go)，然后使用该 Session，调用 [NewElection](https://github.com/etcd-io/etcd/blob/main/client/v3/concurrency/election.go#L44) 方法，创建到 Owner Key `/tidb/cdc/${ClusterID}/__cdc_meta/owner` 的竞选，然后调用 [Election.Campaign](https://github.com/etcd-io/etcd/blob/main/client/v3/concurrency/election.go#L69) 开始竞选。基本的相关代码过程如下：\n\n```go\nsess, err := concurrency.NewSession(etcdClient, ttl) // ttl is set to 10s\nif err != nil {\n    return err\n}\n\nelection := concurrency.NewElection(sess, key) // key is `/tidb/cdc/${ClusterID}/__cdc_meta/owner`\n\nif err := election.Campaign(ctx); err != nil {\n    return err\n}\n\n...\n\n```\n\n感兴趣的读者 ，可以通过 [Capture.Run](https://github.com/pingcap/tiflow/blob/master/cdc/capture/capture.go#L278) 方法作为入口，浏览这部分代码流程，加深对该过程的理解。在真实的集群运行过程中，多个 TiCDC 节点先后上线，在不同的时刻开始竞选 Owner，第一个向 ETCD 中写入 Owner Key 的实例将成为 Owner。如下图所示，TiCDC-0 在 t=1 时刻写入 Owner Key，将会成为 Owner，它在后续运行过程中如果遇到故障辞去了自己的 Ownership，那么 TiCDC-1 将会成为新的 Owner 节点。老旧的 Owner 节点重新上线，调用 `Election.Campaign` 方法重新竞选 Owner，循环往复。\n\n![image (4).png](https://img1.www.pingcap.com/prod/image_4_989a386b6e.png)\n\n## EtcdWorker 模块\n\n[EtcdWorker](https://github.com/pingcap/tiflow/blob/v6.4.0/pkg/orchestrator/etcd_worker.go) 是 TiCDC 内部一个非常重要的模块，它主要负责从 ETCD 中读取数据，映射到 TiCDC 内存中，然后驱动 Owner 和 ProcessorManager 运行。在具体的实现中，[EtcdWorker](https://etcd.io/docs/v3.2/learning/api/#watch-api) 通过调用 ETCD Watch 接口，周期性地获取到所有和 TiCDC 相关的 Key 的变化情况，然后映射到其自身维护的 `GlobalReactorState` 结构体中，其定义如下所示，其中记录了 Capture，Changefeed，Owner 等信息。\n\n```go\ntype GlobalReactorState struct {\n    ClusterID      string\n    Owner          map[string]struct{}\n    Captures       map[model.CaptureID]*model.CaptureInfo\n    Upstreams      map[model.UpstreamID]*model.UpstreamInfo\n    Changefeeds    map[model.ChangeFeedID]*ChangefeedReactorState\n    \n    ....\n}\n```\n\nOwner 和 ProcessorManager 都是一个 [Reactor 接口](https://github.com/pingcap/tiflow/blob/master/pkg/orchestrator/interfaces.go#L24)的实现，二者都借助 `GlobalReactorState` 提供的信息来推进工作进度。具体地，[Owner](https://github.com/pingcap/tiflow/blob/master/cdc/owner/owner.go#L164) 通过轮询每一个记录在 `GlobalReactorState` 中的 Changefeed，让每一个 Changefeed 都能够被稳步推进同步状态。同时也负责诸如 Pause / Resume / Remove 等和 Changefeed 的运行状态相关的工作。[ProcessorManager](https://github.com/pingcap/tiflow/blob/master/cdc/processor/manager.go#L105) 则轮询每一个 Processor，让它们能够及时更新自身的运行状态。\n\n## 总结\n\n以上就是本文的全部内容。希望读者能够理解如下几个问题：\n\n-  TiCDC Server 启动，创建 Changefeed 和 ETCD 的交互过程。\n- EtcdWorker 如何读取 ETCD 数据并且驱动 Owner 和 Processor Manager 运行。\n- TiCDC Owner 的竞选和切换过程。\n\n下一次我们将向大家介绍 TiCDC Changefeed 内部的 Scheduler 模块的工作原理。","author":"金灵","category":1,"customUrl":"ticdc-source-code-reading-3","fillInMethod":"writeDirectly","id":454,"summary":"本文是 TiCDC 源码解读的第三篇，主要内容是讲述 TiCDC 集群的启动及基本工作过程。","tags":["TiCDC"],"title":"TiCDC 源码阅读（三）TiCDC 集群工作过程解析"}},{"relatedBlog":{"body":"本文是 TiCDC 源码解读的第四篇，主要内容是讲述 TiCDC 中 Scheduler 模块的工作原理。主要内容如下：\n\n1. Scheduler 模块的工作机制\n2. 两阶段调度原理\n\n## Scheduler 模块介绍\n\nScheduler 是 Changefeed 内的一个重要模块，它主要负责两件事情：\n\n1. 将一个 Changefeed 所有需要被同步的表，分发到不同的 TiCDC 节点上进行同步工作，以达到负载均衡的目的。\n2. 维护每张表的同步进度，同时推进 Changefeed 的全局同步进度。\n\n本次介绍的 Scheduler 相关代码都在 [tiflow/cdc/scheduler/internal/v3](https://github.com/pingcap/tiflow/tree/v6.4.0/cdc/scheduler/internal/v3) 目录下，包含多个文件夹，具体如下：\n\n- Coordinator 运行在 Changefeed，是 Scheduler 的全局调度中心，负责发送表调度任务，维护全部同步状态。\n- Agent 运行在 Processor，它接收表调度任务，汇报当前节点上的表同步状态给 Coordinator。\n- Transport 是对底层 peer-2-peer 机制的封装，主要负责在 Coordinator 和 Agent 之间传递网络消息。\n- Member 主要是对集群中 Captures 状态的管理和维护。\n- Replication 负责管理每张表的同步状态。`ReplicationSet` 记录了每张表的同步信息，`ReplicationManager` 负责管理所有的 `ReplicationSet`。\n- Scheduler 实现了多种不同的调度规则，可以由 OpenAPI 触发。\n\n下面我们详细介绍 Scheduler 模块的工作过程。\n\n### 表 & 表调度任务 & 表同步单元\n\nTiCDC 的任务是以表为单位，将数据同步到下游目标节点。所以对于一张表，可以通过如下形式来表示，该数据结构即刻画了一张表当前的同步进度。\n\n```go\ntype Table struct {\n    TableID model.TableID\n    Checkpoint uint64\n    ResolvedTs uint64\n}\n```\n\nScheduler 主要是通过 `Add Table` / `Remove Table` / `Move Table` 三类表调度任务来平衡每个 TiCDC 节点上的正在同步的表数量。对于这三类任务，可以被简单地刻画为：\n\n- Add Table：「TableID, Checkpoint, CaptureID」，即在 CaptureID 所指代的 Capture 上从 Checkpoint 开始加载并且同步 TableID 所指代的表同步单元。\n- Remove Table：「TableID, CaptureID」，即从 CaptureID 所指代的 Capture 上移除 TableID 所指代的表同步单元。\n- Move Table：「TableID, Source CaptureID, Target CaptureID」，即将 TableID 所指代的表同步单元从 Source CaptureID 指代的 Capture 上挪动到 Target CaptureID 指代的 Capture 之上。\n\n表同步单元主要负责对一张表进行数据同步工作，在 TiCDC 内这由 Table Pipeline 实现。它的基本结构如下所示：\n\n![image.png](https://img1.www.pingcap.com/prod/image_91400cfa88.png)\n\n每个 Processor 开始同步一张表，即会为这张表创建一个 Table Pipeline，该过程可以分成两个部分：\n\n- 加载表：创建 Table Pipeline，分配相关的系统资源。KV-Client 从上游 TiKV 拉取数据，经由 Puller 写入到 Sorter 中，但是此时不向下游目标数据系统写入数据。\n- 复制表：在加载表的前提下，启动 Mounter 和 Sink 开始工作，从 Sorter 中读取数据，并且写入到下游目标数据系统。\n\nProcessor 实现了 [TableExecutor](https://github.com/pingcap/tiflow/blob/v6.4.0/cdc/scheduler/internal/table_executor.go) 接口，如下所示：\n\n```go\ntype TableExecutor interface {\n        // AddTable add a new table with `startTs`\n        // if `isPrepare` is true, the 1st phase of the 2 phase scheduling protocol.\n        // if `isPrepare` is false, the 2nd phase.\n        AddTable(\n                ctx context.Context, tableID model.TableID, startTs model.Ts, isPrepare bool,\n        ) (done bool, err error)\n\n        // IsAddTableFinished make sure the requested table is in the proper status\n        IsAddTableFinished(tableID model.TableID, isPrepare bool) (done bool)\n\n        // RemoveTable remove the table, return true if the table is already removed\n        RemoveTable(tableID model.TableID) (done bool)\n        // IsRemoveTableFinished convince the table is fully stopped.\n        // return false if table is not stopped\n        // return true and corresponding checkpoint otherwise.\n        IsRemoveTableFinished(tableID model.TableID) (model.Ts, bool)\n\n        // GetAllCurrentTables should return all tables that are being run,\n        // being added and being removed.\n        //\n        // NOTE: two subsequent calls to the method should return the same\n        // result, unless there is a call to AddTable, RemoveTable, IsAddTableFinished\n        // or IsRemoveTableFinished in between two calls to this method.\n        GetAllCurrentTables() []model.TableID\n\n        // GetCheckpoint returns the local checkpoint-ts and resolved-ts of\n        // the processor. Its calculation should take into consideration all\n        // tables that would have been returned if GetAllCurrentTables had been\n        // called immediately before.\n        GetCheckpoint() (checkpointTs, resolvedTs model.Ts)\n\n        // GetTableStatus return the checkpoint and resolved ts for the given table\n        GetTableStatus(tableID model.TableID) tablepb.TableStatus\n}\n```\n\n在 Changefeed 的整个运行周期中，Scheduler 都处于工作状态，Agent 利用 Processor 提供的上述接口方法实现，实际地执行表调度任务，获取到表调度任务进行的程度，以及表同步单元当前的运行状态等，以供后续做出调度决策。\n\n### Coordinator & Agent \n\nScheduler 模块由 Coordinator 和 Agent 两部分组成。Coordinator 运行在 Changefeed 内，Agent 运行在 Processor 内，Coordinator 和 Agent 即是 Changefeed 和 Processor 之间的通信接口。二者使用 [peer-2-peer](https://github.com/pingcap/tiflow/tree/v6.4.0/pkg/p2p) 框架完成网络数据交换，该框架基于 gRPC 实现。下图展示了一个有 3 个 TiCDC 节点的集群中，一个 Changefeed 的 Scheduler 模块的通信拓扑情况。可以看到，Coordinator 和 Agent 之间会交换两类网络消息，消息格式由 Protobuf 定义，源代码位于 [tiflow/cdc/scheduler/schedulepb](https://github.com/pingcap/tiflow/tree/v6.4.0/cdc/scheduler/schedulepb)。\n\n![image (1).png](https://img1.www.pingcap.com/prod/image_1_d0a656e5b9.png)\n\n- 第一类是 `Heartbeat` 消息，Coordinator 周期性地向 Agent 发送 `HeartbeatRequest`，Agent 返回相应的 `HeartbeatResponse`，该类消息主要目的是让 Coordinator 能够及时获取到所有表在不同 TiCDC 节点上的同步状态。\n- 第二类是 `DispatchTable` 消息，在有对表进行调度的需求的时候，Coordinator 向特定 Agent 发送 `DispatchTableRequest`，后者返回 `DispatchTableResponse`，用于及时同步每一张表的调度进展。\n\n下面我们从消息传递的角度，分别看一下 Coordinator 和 Agent 的工作逻辑。\n\n### Coordinator 工作过程\n\nCoordinator 会收到来自 Agent 的 `HeartbeatReponse` 和 `DispatchTableResponse` 这两类消息。Coordinator 内的 `CaptureM` 负责维护 Capture 的状态，在每次接收到 `HeartbeatResponse` 之后，都会更新自身维护的 Captures 的状态，包括每个 Capture 当前的存活状态，Capture 上当前同步的所有表信息。同时也生成新的 `HeartbeatRequest` 消息，再次发送到所有 Agents。`ReplicationM` 负责维护所有表的同步状态，它接收到 `HeartbeatResponse` 和 `DispatchTableResponse` 之后，按照消息中记录的表信息，更新自己维护的这些表对应的同步状态。`CaptureM` 提供了当前集群中存活的所有 Captures 信息，`ReplicationM` 则提供了所有表的同步状态信息，`SchedulerM` 以二者提供的信息为输入，以让每个 Capture 上的表同步单元数量尽可能均衡为目标，生成表调度任务，这些表调度任务会被 `ReplicationM` 进一步处理，生成 `DispatchTableRequest`，然后发送到对应的 Agent。\n\n![image (2).png](https://img1.www.pingcap.com/prod/image_2_c69de8f9dc.png)\n\n### Agent 工作过程\n\nAgent 会从 Coordinator 收到 `HeartbeatRequest` 和 `DispatchTableRequest` 这两类消息。对于前者，Agent 会收集当前运行在当前 TiCDC 节点上的所有表同步单元的运行状态，构造 `HeartbeatRespone`。对于后者，则通过访问 Processor 来添加或者移除表同步单元，获取到表调度任务的执行进度，构造对应的 `DispatchTableResponse`，最后发送到 Coordinator。\n\n![image (3).png](https://img1.www.pingcap.com/prod/image_3_3940de3cb3.png)\n\n### Changefeed 同步进度计算\n\n一个 changefeed 内同步了多张表。对于每张表，有 `Checkpoint` 和 `ResolvedTs` 来标识它的同步进度，Coordinator 通过 `HeartbeatResponse` 周期性地收集所有表的同步进度信息，然后就可以计算得到一个 Changefeed 的同步进度。具体计算方法如下：\n\n```go\n// AdvanceCheckpoint tries to advance checkpoint and returns current checkpoint.\nfunc (r *Manager) AdvanceCheckpoint(currentTables []model.TableID) (newCheckpointTs, newResolvedTs model.Ts) {\n    newCheckpointTs, newResolvedTs = math.MaxUint64, math.MaxUint64\n    for _, tableID := range currentTables {\n        table, ok := r.tables[tableID]\n        if !ok {\n            // Can not advance checkpoint there is a table missing.\n            return checkpointCannotProceed, checkpointCannotProceed\n        }\n        // Find the minimum checkpoint ts and resolved ts.\n        if newCheckpointTs > table.Checkpoint.CheckpointTs {\n            newCheckpointTs = table.Checkpoint.CheckpointTs\n        }\n        if newResolvedTs > table.Checkpoint.ResolvedTs {\n            newResolvedTs = table.Checkpoint.ResolvedTs\n        }\n    }\n    return newCheckpointTs, newResolvedTs\n}\n```\n\n从上面的示例代码中我们可以看出，一个 Changefeed 的 Checkpoint 和 ResolvedTs，即是它同步的所有表的对应指标的最小值。Changefeed 的 Checkpoint 的意义是，它的所有表的同步进度都不小于该值，所有时间戳小于该值的数据变更事件已经被同步到了下游；ResolvedTs 指的是 TiCDC 当前已经捕获到了所有时间戳小于该值的数据变更事件。除此之外的一个重点是，只有当所有表都被分发到 Capture 上并且创建了对应的表同步单元之后，才可以推进同步进度。\n\n以上从消息传递的角度对 Scheduler 模块基本工作原理的简单介绍。下面我们更加详细地聊一下 Scheduler 对表表度任务的处理机制。\n\n## 两阶段调度原理\n\n两阶段调度是 Scheduler 内部对表调度任务的执行原理，主要目的是降低 `Move Table` 操作对同步延迟的影响。\n\n![image (4).png](https://img1.www.pingcap.com/prod/image_4_121198aa5a.png)\n\n上图展示了将表 X 从 Agent-1 所在的 Capture 上挪动到 Agent-2 所在的 Capture 上的过程，具体如下：\n\n1. Coordinator 让 Agent-2 准备表 X 的数据。\n2. Agent-2 在准备好了数据之后，告知 Coordinator 这一消息。\n3. Coordinator 发送消息到 Agent-1，告知它移除表 X 的同步任务。\n4. Agent-1 在移除了表 X 的同步任务之后，告知 Coordinator 这一消息。\n5. Coordinator 再次发送消息到 Agent-2，开始向下游复制表 X 的数据。\n6. Agent-2 再次发送消息到 Coordinator，告知表 X 正处于复制数据到下游的状态。\n\n上述过程的重点是在将一张表从原节点上移除之前，先在目标节点上分配相关的资源，准备需要被同步的数据。准备数据的过程，往往颇为耗时，这是引起挪动表过程耗时长的主要原因。两阶段调度机制通过提前在目标节点上准备表数据，同时保证其他节点上有该表的同步单元正在向下游复制数据，保证了该表一直处于同步状态，这样可以减少整个挪动表过程的时间开销，降低对同步延迟的影响。\n\n### Replication set 状态转换过程\n\n在上文中讲述的两阶段调度挪动表的基本过程中，可以看到在 Agent-2 执行了前两步之后，表 X 在 Agent-1 和 Agent-2 的 Capture 之上，均存在表同步单元。不同点在于，Agent-1 此时正在复制表，Agent-2 此时只是加载表。\n\nCoordinator 使用 `ReplicationSet` 来跟踪一张表在多个 Capture 上的表同步单元的状态，并以此维护了该表真实的同步状态。基本定义如下：\n\n```go\n// ReplicationSet is a state machine that manages replication states.\ntype ReplicationSet struct {\n    TableID    model.TableID\n    State      ReplicationSetState\n    Primary model.CaptureID\n    Secondary model.CaptureID\n    Checkpoint tablepb.Checkpoint\n    ...\n}\n```\n\n`TableID` 唯一地标识了一张表，`State` 则记录了当前该 `ReplicationSet` 所处的状态，`Primary` 记录了当前正在复制该表的 Capture 的 ID，而 `Secondary` 则记录了当前已经加载了该表，但是尚未同步数据的 Capture 的 ID，Checkpoint 则记录了该表当前的同步状态。\n在对表进行调度的过程中，一个 `ReplicationSet` 会处于多种状态。如下图所示：\n\n![image (5).png](https://img1.www.pingcap.com/prod/image_5_511a4a80c2.png)\n\n- Absent 表示没有任何一个节点加载了该表的同步单元。\n- Prepare 可能出现在两种情况。第一种是表正处于 `Absent` 状态，调用 `Add Table` 在某一个 Capture 上开始加载该表。第二种情况是需要将正在被同步的表挪动到其他节点上，发起 `Move Table` 请求，在目标节点上加载表。\n- Commit 指的是在至少一个节点上，已经准备好了可以同步到下游的数据。\n- Replicating 指的是有且只有一个节点正在复制该表的数据到下游目标系统。\n- Removing 说明当前只有一个节点上加载了表的同步单元，并且当前正在停止向下游同步数据，同时释放该同步单元。一般发生在上游执行了 `Drop table` 的情况。在一张表被完全移除之后，即再次回到 Absent 状态。\n\n下面假设存在一张表 table-0，它在被调度时发生的各种情况。首先考虑如何将表 X 加载到 Agent-0 所在的 Capture 之上，并且向下游复制数据。\n\n![image (6).png](https://img1.www.pingcap.com/prod/image_6_13564c1c2f.png)\n\n首先 table-0 处于 `Absent` 状态，此时发起 `Add Table` 调度任务，让 `Agent-0` 从 checkpoint = 5 开始该表的同步工作，`Agent-0` 会创建相应的表同步单元，和上游 TiKV 集群中的 Regions 建立网络连接，拉取数据。当准备好了可以向下游同步的数据之后，`Agent-0` 告知 Coordinator 该表同步单元当前已经处于 `Prepared` 状态。Coordinator 会根据该消息，将该 `ReplicationSet` 从 `Prepare` 切换到 `Commit` 状态，然后发起第二条消息到 `Agent-0`，让它开始从 `checkpoint = 5` 从下游开始同步数据。当 `Agent-0` 完成相关操作，返回响应到 Coordinator 之后，Coordinator 再次更新 table-0 的 `ReplicationSet`，进入到 `Replicating` 状态。\n\n![image (7).png](https://img1.www.pingcap.com/prod/image_7_5bc98fe660.png)\n\n再来看一下移除表 table-0 的过程，如上所示。最开始正处于 `Replicating` 状态，并且在 Capture-0 上同步。Coordinator 向 `Agent-0` 发送 `Remove Table` 请求，`Agent-0` 通过 Processor 来取消该表的同步单元，释放相关的资源，待所有资源释放完毕之后，返回消息到 Coordinator，告知该表当前已经没有被同步了，同时带有最后同步的 Checkpoint。在 `Agent-0` 正在取消表的过程中，Coordinator 和 Agent-0 之间依旧有保持通过 Heartbeat 进行状态通知，Coordinator 可以及时地知道当前表 t = 0 正处于 `Removing` 状态，在后续收到表已经被完全取消的消息之后，则从 `Removing` 切换到 `Absent` 状态。\n\n最后再来看一下 `Move Table`，它本质上是先在目标节点 `Add Table`，然后在原节点上 `Remove Table`。\n\n![image (8).png](https://img1.www.pingcap.com/prod/image_8_58be2e75e5.png)\n\n如上图所示，首先假设 table-0 正在 capture-0 上被同步，处于 `Replicating` 状态，现在需要将 table-0 从 capture-0 挪动到 capture-1。首先 Coordinator 将 `ReplicationSet` 的状态从 `Replicating` 转移到 `Prepare`，同时向 `Agent-1` 发起添加 table-0 的请求，`Agent-1` 加载完了该表的同步单元之后，会告诉 Coordinator 这一消息，此时 Coordinator 会再次更新 table-0 到 `Commit` 状态。此时可以知道表 table-0 目前正在 capture-0 上被同步，在 agent-1 上也已经有了它的同步单元和可同步数据。Coordinator 再向 `Agent-0` 上发送 `Remove Table`，`Agent-0` 收到调度指示之后，停止并且释放表 table-0 的同步单元，再向 Coordinator 返回执行结果。Coordinator 在得知 capture-0 上已经没有该表的同步单元之后，将 Primary 从 capture-0 修改为 capture-1，告知 Agent-1 开始向下游同步表 table-0 的数据，Coordinator 在收到从 Agent-1 传来的响应之后，再次更新 table-0 的 状态为 `Replicating`。\n\n从上面三种调度操作中，可以看到 `Coordinator` 维护的 ReplicationSet 记录了整个调度过程中，一张表的同步状态，它由从 Agent 处收到的各种消息来驱动状态的改变。同时可以看到消息中还有 Checkpoint 和 Resolved Ts 在不断更新。Coordinator 在处理收到的 Checkpoint 和 ResolvedTs 时，保证二者均不会发生会退。\n\n## 总结\n\n以上就是本文的全部内容。希望在阅读上面的内容之后，读者能够对 TiCDC 的 Scheduler 模块的工作原理有一个基本的了解。","author":"金灵","category":1,"customUrl":"ticdc-source-code-reading-4","fillInMethod":"writeDirectly","id":458,"summary":"本文是 TiCDC 源码解读的第四篇，主要内容是讲述 TiCDC 中 Scheduler 模块的工作原理。","tags":["TiCDC"],"title":"TiCDC 源码阅读（四）TiCDC Scheduler 工作原理解析"}},{"relatedBlog":{"body":"## 内容概要\n\n本文是 TiCDC 源码解读的第五篇，本文将会介绍 TiCDC 对 DDL 的处理方式和 Filter 功能的实现（基于 [TiCDC v6.5.0 版本](https://github.com/pingcap/tiflow/tree/v6.5.0)代码） ，文章将会围绕以下 4 个问题展开。\n\n- 为什么 TiCDC 只用 Owner 节点来同步 DDL？\n- DDL 事件会对同步任务的进度有什么影响？\n- TiCDC 是怎么在内部维护表的 Schema 信息的？\n- TiCDC 的 Filter 功能是怎么实现的？\n\n希望在回答完这几个问题之后，大家能够对 TiCDC DDL 同步机制有所了解，并且能够对 Filter 模块的有比较深入的认识。\n\n## 同步架构回顾\n\n![1.png](https://img1.www.pingcap.com/prod/1_c36dcd5d72.png)\n\n第一期《[TiCDC 架构概览](https://cn.pingcap.com/blog/ticdc-source-code-reading-1)》的文章中，我们就认识到了 TiCDC 的 DML 同步流和 DDL 同步流是分开的。\n\n从上面的架构图中可以看到， DML 的同步是由 Processor 进行的，数据流从上游的 TiKV 流入经过 Processor 内的 TablePipeline ，最后被同步到下游。而 DDL 同步则是由 Owner 进行的，OwnerDDLPuller 拉取上游发生的 DDL 事件，然后在内部经过一系列的处理之后，通过 DDLSink 同步到下游。\n\n在深入认识 DDL 的处理细节之前，需要先结合以上的架构图，对下面几个实体有所了解：\n\n- OwnerSchemaStorage：由 Owner 持有，维护了当前所有表最新的 schema 信息，这些表的 schema 信息主要会被 scheduler 所使用，用来感知同步任务的表数量的变化；此外，还会被 owner 用来解析 ddlPuller 拉取到的 DDL 事件。\n- ProcessorSchemaStorage：由 Processor 持有，维护了当前所有表的多个版本的 schema 信息，这些信息会被 Mounter 用来解析行变更信息。\n- BarrierTs：由 Owner 向 Processor 发送的控制信息，它会让 Processor 把同步进度阻塞到 BarrierTs 所指定的值。TiCDC 内部有几种不同类型的 BarrierTs，为了简化叙述，本文中提到的 BarrierTs  仅表示 DDL 事件产生的 DDLBarrierTs。\n- OwnerDDLPuller：由 Owner 持有，负责拉取和过滤上游 TiDB 集群的 DDL 事件，并把它们缓存在一个队列中，等待 Owner 处理；此外，它还会维护一个 ResolvedTs，该值为上游 TiKV 发送过来的最新的 ResolvedTs，在没有 DDL 事件到来的时候，Owner 将会使用它来推进 DDLBirrierTs。\n- ProcessorDDLPuller：由 Processor 持有，负责拉取和过滤上游 TiDB 集群的 DDL 事件，然后把它们发送给 Processor 去更新 ProcessorSchemaStorage。\n- DDLSink：由 Owner 持有，负责执行 DDL 到下游。\n\n## 为什么 TiCDC 选择由 Owner 节点来同步 DDL？\n\nTiCDC 之所以选择由 Owner 来同步 DDL，是因为需要确保在同步一条 DDL 的时候： \n\n1. 所有早于该条 DDL 的行变更事件都已同步到下游。\n2. 所有晚于该条 DDL 的行变更都需要在该条 DDL 成功同步到下游之后才会继续同步。\n\n否则，就有可能出现上下游数据不一致的情况。\n\n要达到以上两个目的，就需要保证我们执行一条 DDL 之前，所有 Processor 上面的同步流都准确地停在 DDL 的 commitTs 这个时间点，等到 DDL 被成功同步到下游之后再恢复同步。\n\n在当前 TiCDC 的同步模型中，Owner 是负责发号施令的角色，它拥有整个同步任务所有表的状态信息，能够对 Processor 下达停止同步的控制指令，也能够得知同步流是否停止在恰当的时刻上。所以，为了简化 DDL 的处理逻辑，我们选择仅由 Owner 节点来进行 DDL 的同步。\n\n## DDL 事件会对同步任务的进度产生什么影响？\n\n如上文所述，在同步一条 DDL 之前，我们需要先让同步任务的数据流准确地停止在该 DDL 的 commitTs 这个时刻。这是通过 Owner 计算并向 Processor 发送 BarrierTs 实现的，接下来将会结合下面的顺序图来详细讲解该流程。\n\n![2.png](https://img1.www.pingcap.com/prod/2_78b5247537.png)\n\nOwnerDDLPuller 在 Changefeed 启动之后，就会持续监听上游 TiDB 集群发生的 DDL 事件，当接收到一条 DDL 事件之后，它会先对该条 DDL 事件进行过滤。如果该条 DDL 和 Changefeed 所需同步的表无关，则会被直接忽略，否则将会被加入到 OwnerDDLPuller 的 pendingDDL 队列中。\n\nOwner 在每轮 tick 里面都会去检查 pendingDDL 队列中是否有待执行的 DDL 事件。如果有待执行的 DDL 事件，则会取队头的 DDL 的 CommitTs 为 DDLBarrierTs。然后，通过 Etcd 向 Processor 广播这个 DDLBarrierTs，Processor 收到之后，会把该值设置为所有 TablePipeline 同步数据流的上界，当同步流前进到这个时间点之后，就需要停下来等待。\n\nOwner 在每轮 tick 内部都会检查当前 Changefeed 的 CheckpointTs 是否已经前进到 DDLBarrierTs 的值，若前进到该值，则说明该条 DDL 之前的所有 DML 事件都已经被成功同步到下游了。此时，Owner 会把 DDL 事件应用到 OwnerSchemaStorage 上，使得元数据和上游保持一致；然后，Owner 会调用 DDLSink 把该条 DDL 同步到下游。等到 DDL 成功执行之后，Owner 就会推进 DDLBarrierTs 为下一条未执行的 DDL 的 CommitTs，如果不存在未执行的 DDL 事件，那么 DDLBarrierTs 会被推进到 OwnerDDLPuller 维护的 ResolvedTs 值。这样，Processor 的同步流就能够继续向前推进。\n\n在当前的实现中，Owner 对 DDL 事件的处理逻辑主要存在 [handlerBarrier()](https://github.com/pingcap/tiflow/blob/v6.5.0/cdc/owner/changefeed.go#L748) 这个函数中，核心逻辑如下：\n\n```go\n// 检查是 checkpointTs 是否已经前进到下一条需要执行的 ddl commitTs 处。\nif !checkpointReachBarrier {\n    return barrierTs, nil\n}\n// 通过以上检查，则尝试执行 ddl。这个执行是异步的，因此不会阻塞 owner tick 的主流程。\ndone, err := c.asyncExecDDLJob(ctx, ddlJob)\nif err != nil {\nreturn 0, errors.Trace(err)\n}\nif !done {\nreturn barrierTs, nil\n}\n\n// 执行成功，则从 pendingDDL 队列中弹出该 DDL\nc.lastDDLTs = ddlResolvedTs\nc.ddlPuller.PopFrontDDL()\nnewDDLResolvedTs, _ := c.ddlPuller.FrontDDL()\n// 更新 DDLBarrierTs \nc.barriers.Update(ddlJobBarrier, newDDLResolvedTs)\n```\n\n在 Processor 侧的主要逻辑则存在于 [pushResolvedTs2Table()](https://github.com/pingcap/tiflow/blob/v6.5.0/cdc/processor/processor.go#L1102) 这个函数中，核心逻辑如下:\n\n```go\n// 这个 resolvedTs 就是上文提到的 BarrierTs\nresolvedTs := p.changefeed.Status.ResolvedTs\nschemaResolvedTs := p.schemaStorage.ResolvedTs()\nif schemaResolvedTs < resolvedTs {\n    // Do not update barrier ts that is larger than\n    // DDL puller's resolved ts.\n    // When DDL puller stall, resolved events that outputted by sorter\n    // may pile up in memory, as they have to wait DDL.\n    resolvedTs = schemaResolvedTs\n}\n// 更新每个 table 的 BarrierTs，使得它们的进度能够推进。\nif p.pullBasedSinking {\n    p.sinkManager.UpdateBarrierTs(resolvedTs)\n} else {\n    for _, table := range p.tables {\n        table.UpdateBarrierTs(resolvedTs)\n    }\n}\n```\n\n通过上述的讲解，可以较为自然的得出这个结论：任意一张表的 DDL 事件会阻塞所有表的 DML 同步进度，因此上游执行耗时较长的 DDL 或者短时间内执行大量 DDL，都容易引起同步任务的延迟上升。\n\n## TiCDC 是怎么在内部维护表的 Schema 信息的？\n\nTiCDC 对表的 Schema 信息的维护是 Changefeed 级别的，每个 Changefeed 在 Owner 节点上都会拥有一份 Schema 信息，在每个 Processor 节点上也都会有一份 Schema 信息。\n\n```go\n// Owner 节点上的 changefeed\ntype changefeed struct {\n    // 存储表的最新信息\n    schema      *schemaWrap4Owner\n}\n\n// Processor 节点上的 changefeed \ntype processor struct {\n    // 存储表的多版本信息\n    schemaStorage entry.SchemaStorage\n}\n```\n\nSchema 在 Changefeed 创建的时候被初始化，TiCDC 使用 Changefeed 的 startTs 从上游 TiKV 获取了一份 snapshot，并把 snapshot 里面所有的数据库和表信息都存储在 Schema 里。在 Changefeed 的运行过程中，TiCDC 会持续维护和更新 Schema 信息，每次有新的 DDL 事件到来的时候，TiCDC 都会把 DDL 事件应用到这个 Schema 上面，以保证 Schema 和上游 TiDB 中的 Schema 是一致的。\n\n![3.png](https://img1.www.pingcap.com/prod/3_60227ec909.png)\n\nOwner 节点上的 [Schema](https://github.com/pingcap/tiflow/blob/v6.5.0/cdc/owner/schema.go#L33) 中只保存了每张表最新的那份信息，原因在于 Owner 节点只负责 DDL 的同步，并且 TiCDC 保证了 DDL 的同步是线性有序的，它在解析下一条 DDL 的时候，只需要上一条 DDL 执行结束的时候的 Schema 信息就可以确保解析的正确性。除此之外，Scheduler 还会调用 Schema 提供的 [AllPhysicalTables()](https://github.com/pingcap/tiflow/blob/v6.5.0/cdc/owner/schema.go#L86) 方法来感知当前是否有表的增减，触发调度任务。\n\n![4.png](https://img1.www.pingcap.com/prod/4_a5e22c2052.png)\n\n而 Processor 节点上的 [SchemaStorage](https://github.com/pingcap/tiflow/blob/v6.5.0/cdc/entry/schema_storage.go#L53) 中则保存了每张表最近几个版本的信息，因为 Processor 需要负责 DML 的同步，而 DML 的同步进度是有可能落后于 Processor 节点上 DDLPuller 拉取 DDL 事件的速度的。所以，为了能够正确地解析 DML 事件，我们需要在 SchemaStorage 中维护 CheckpointTs 之后版本的表信息，而 CheckpointTs 之前的信息则可以清理掉。TiCDC 保证 CheckpointTs 之前的变更事件都已经被同步到下游，也就肯定不会再需要解析 CheckpointTs 的 DML 事件了。\n\n需要注意的是，在上游短时间发生大量的 DDL 时，SchemaStorage 需要频繁地进行更新，并且会短时间内产生多个版本的 Schema 信息，有可能造成 TiCDC 内存使用量大幅上升。\n\n## TiCDC 的 Filter 功能是怎么实现的？\n\n目前 TiCDC 的 Filter 的基本功能如下：\n\n- 同步或者忽略用户指定的库或者表\n- 过滤 TiCDC 不支持同步的 DDL 事件\n- 过滤用户指定忽略的 DDL 事件\n- 过滤用户指定忽略的行变更事件\n\nFilter 主要会被 DDLPuller、Mounter、SchemaStorage 这三者调用，用来实现以上提到的几个功能。若需要了解如何配置 Filter，可以参考文档：[Changefeed 日志过滤器](https://docs.pingcap.com/zh/tidb/stable/ticdc-filter#changefeed-%E6%97%A5%E5%BF%97%E8%BF%87%E6%BB%A4%E5%99%A8)。接下来，我们将会从源码的角度来了解 Filter 的相关实现。\n\n```go\ntype Filter interface {\n    // ShouldIgnoreDMLEvent returns true and nil if the DML event should be ignored.\n    ShouldIgnoreDMLEvent(dml *model.RowChangedEvent, rawRow model.RowChangedDatums, tableInfo *model.TableInfo) (bool, error)\n    // ShouldIgnoreDDLEvent returns true and nil if the DDL event should be ignored.\n    // If a ddl is ignored, it will applied to cdc's schema storage,\n    // but not sent to downstream.\n    ShouldIgnoreDDLEvent(ddl *model.DDLEvent) (bool, error)\n    // ShouldDiscardDDL returns true if this DDL should be discarded.\n    // If a ddl is discarded, it will neither be applied to cdc's schema storage\n    // nor sent to downstream.\n    ShouldDiscardDDL(ddlType timodel.ActionType, schema, table string) bool\n    // ShouldIgnoreTable returns true if the table should be ignored.\n    ShouldIgnoreTable(schema, table string) bool\n    // Verify should only be called by create changefeed OpenAPI.\n    // Its purpose is to verify the expression filter config.\n    Verify(tableInfos []*model.TableInfo) error\n}\n```\n\n上面即是 Filter 的接口定义，大家可以从接口中的方法名和注释就了解到 Filter 具有什么样的功能。实现 Filter 接口的结构体定义如下：\n\n```go\n// filter implements Filter.\ntype filter struct {\n   // tableFilter is used to filter in dml/ddl event by table name.\n   tableFilter tfilter.Filter\n   // dmlExprFilter is used to filter out dml event by its columns value.\n   dmlExprFilter *dmlExprFilter\n   // sqlEventFilter is used to filter out dml/ddl event by its type or query.\n   sqlEventFilter *sqlEventFilter\n   // ignoreTxnStartTs is used to filter out dml/ddl event by its starsTs.\n   ignoreTxnStartTs []uint64\n}\n```\n\n下面详细介绍一下组成 filter 的几个结构体：\n\n- tableFilter 是表库过滤器，根据用户指定的规则来同步或者过滤对应的表和库，它是通过表名和库名在 changefeed 初始化的阶段进行过滤的。如果用户在 Filter 规则中配置了只同步某张表，那么 changefeed 就只会拉取该表的变更事件。\n- dmlExprFilter 是 sql 表达式过滤器，实现了通过用户指定的 SQL 表达式来过滤对应 DML 事件的功能。该行为是在 Mounter 中进行的，它会根据用户提供的 sql 表达式来对每一行变更进行计算，过滤掉符合计算结果的行变更事件。\n- sqlEventFilter 是事件类型过滤器，它根据用户指定的事件类型来过滤符合条件的 DDL 或者 DML 事件。该行为也是在 Mounter 中进行的。\n- ignoreTxnStartTs 则是根据指定的 startTs 来过滤事件，一般不推荐用户使用。\n\n以上几个结构体内部的实现逻辑都较为简单，整个 Filter 接口的方法就是由这几个结构体提供的方法组合而成的，感兴趣的读者可以自行点进源码链接进行阅读。比较值得注意的一点是 TiCDC 对 DDL 事件的同步支持，目前 TiCDC 对 DDL 同步采用的是白名单模式，仅支持同步白名单内的 DDL 事件。因此，当接收到非白名单事件的 DDL 时，TiCDC 会直接丢弃。\n\n以上就是本文章的全部内容，希望读者看完之后能够对 TiCDC 有更深入的认识。","author":"江宗其","category":1,"customUrl":"ticdc-source-code-reading-5","fillInMethod":"writeDirectly","id":469,"summary":"本文是 TiCDC 源码阅读的第五篇，将介绍 TiCDC 对 DDL 的处理方式和 Filter 功能的实现（基于 TiCDC v6.5.0 版本代码）。","tags":["TiCDC"],"title":"TiCDC 源码阅读（五）TiCDC 对 DDL 的处理和 Filter 解析"}},{"relatedBlog":{"body":"## 导读\n\n本文是 TiCDC 源码解读的第六篇，主要是 TiCDC 中的 Puller 模块介绍，TiCDC 中的 Puller 通过创建 KV-Client 向 TiKV 发送 ChangeDataRequest 请求，在 TiCDC 中实现从 TiKV 接收变更数据功能。本期将详细分享 Puller 模块的功能实现原理，分享将从以下四个方面展开，解答 TiCDC Puller 模块的四个关键问题：  \n\n- TiCDC 中的 Puller 模块是什么？\n- Puller 如何初始化以及如何 从 TiKV 拉取数据？\n- Puller 如何处理数据变更事件？\n- Puller 如何推进拉取数据的进度？\n\n## Puller 是什么  \n\nPuller 创建一个 KV-Client 并向 TiKV 发送 ChangeDataRequest 请求，从而从 TiKV 接收变更数据。\n\n- TiKV 按照 Region 为单位，将数据变更事件和 Resolved Ts 事件发送给 TiCDC。Resolved Ts 事件按照 Region 为单位周期性地发送到 TiCDC，表明该 Region 中所有 Commit Ts 小于该 Resolved Ts 的事件都已被发送到 TiCDC。\n- Puller 从 KV-Client 接收数据，将其写入 Sorter 中，并持续推进表级别的 Resolved Ts，以标识该表当前接收数据的进度。\n\n对于 DML 事件，从 table pipeline的角度来看，pullerNode 是 table pipeline 中的一个节点，处理流程大概为  \n\n1. pullerNode 构造并初始化了一个 Puller 接口。\n2. pullerNode 驱动 Puller 向 TiKV 发送 ChangeDataRequest grpc 请求。\n3. region worker 模块则处理从 TiKV 收到的数据，将数据写到了 Puller 的 output Chan 中\n4. pullerNode 消费 outputCh 中的 RawKvEventry 数据。\n\n对于 DDL 事件，其实底层也是一个 Puller，只是拉取的数据范围不一样，其就是 DDLJobPuller 模块运行并消费 Puller 输出的事件。今天我们要讲的 Puller 就是下图的 pullerImpl、cdc client 以及其他相关模块。\n\n![Puller 示意图.png](https://img1.www.pingcap.com/prod/Puller_8b9147e493.png)\n\n### Puller 接口定义\n\n下面是 Puller 接口的定义，它主要包含两个比较重要的方法：  \n\n- Run 方法，这是一个阻塞的方法，上层调用者通过单独的 goroutine 来运行 Run 方法，来驱动 Puller 向 TiKV 发送请求，并处理 TiKV 发送过来的事件，输出到 Puller 的 output channel 中；\n- Output 方法，这个方法比较简单直观，它返回一个 channel 以供上层消费数据。\n\n```\n// Puller pull data from tikv and push changes into a buffer.\ntype Puller interface {\n        // Run the puller, continually fetch event from TiKV and add event into buffer.\n        Run(ctx context.Context) error\n        GetResolvedTs() uint64\n        Output() <-chan *model.RawKVEntry\n        Stats() Stats\n}  \n```\n\n### ChangeDataRequest 请求  \n\nChangeDataRequest 结构体定义了向 TiKV 发送请求的所有信息：  \n- CheckpointTs 表示从哪一个时间点开始同步数据。\n- RegionId 表示向 TiKV 的哪一个 region 请求变更事件。\n- ExtraOp 定义了一些请求的扩展性属性，现在使用的 old value 输出标志就是附加在这个字段上的。\n\n```\nreq := &cdcpb.ChangeDataRequest{\n   Header:       header,\n   RegionId:     regionID,\n   RequestId:    requestID,\n   RegionEpoch:  regionEpoch,\n   CheckpointTs: sri.resolvedTs,\n   StartKey:     sri.span.Start,\n   EndKey:       sri.span.End,\n   ExtraOp:      extraOp,\n   FilterLoop:   s.client.filterLoop,\n}\n```\n\n从这里可以注意到在给 TiKV 发送的请求中是没有 table 的概念的，只有 region 相关的信息，而 table pipeline 又是一个表的概念，所以当我们要从 TiKV 拉取一个表的实时数据变更时，需要在 TiCDC 侧将表的概念映射到 TiKV 的 region 中，根据[关系模型到 Key-Value 模型的映射](https://cn.pingcap.com/blog/tidb-internal-2) 这个文档 ，如 GetTableSpan 方法展示的一样，将一个 table 的数据存在 [ tr, t<r+1>) 这样一个左闭右开的区间里。\n\n```\n// GetTableSpan returns the span to watch for the specified table\nfunc GetTableSpan(tableID int64) Span {\n        tablePrefix := tablecodec.GenTablePrefix(tableID)\n        sep := byte('_')\n        recordMarker := byte('r')\n\n        var start, end kv.Key\n        // ignore index keys.\n        start = append(tablePrefix, sep, recordMarker)\n        end = append(tablePrefix, sep, recordMarker+1)\n        return Span{\n                Start: start,\n                End:   end,\n        }\n}\n```\n\n## Puller 请求流程\n\n现在我们已经知道了 TiCDC 与 TiKV grpc 请求的格式，以及怎样将 table id 映射到 kv range。下面我们来介绍 Puller 请求的整体流程，初始化 Puller 的时候，上层将 table id 转换成了 totalSpan 字段赋值给了 Puller ，从这以后，Puller 内部没有 table 的概念了，Puller 所做的事就是去捕获并输出这个 kv span 范围内的实时数据变更。\n\n在 TiCDC 代码中，pullerImpl 结构体实现了 Puller 接口，并通过 cdc client 为每一个 span 创建一个 eventSession，而 eventSession 则会启动 5 个 goroutine 来协调完成该 span 的 kv 事件拉取任务，下面我们详细介绍一下这个 5 个 goroutine。\n\n```\n        g.Go(func() error {\n                return s.dispatchRequest(ctx)\n        })\n\n        g.Go(func() error {\n                return s.requestRegionToStore(ctx, g, regionCount)\n        })\n\n        g.Go(func() error {\n              ....\n              go s.divideAndSendEventFeedToRegions(ctx, task.span, task.ts)\n              .....\n        })\n\n        g.Go(func() error {\n            go s.handleError(ctx, errInfo)\n        })\n\n        g.Go(func() error {\n                return s.regionRouter.Run(ctx)\n        })\n        s.requestRangeCh <- rangeRequestTask{span: s.totalSpan, ts: ts}\n}\n```\n\n### divideAndSendEventFeedToRegions  \n\n这个 goroutine 从 requestRangeCh 获取一个 span 信息，把 span 拆分成为 region，可以注意这里的第一个 span 是整个 totalSpan，其内部逻辑大致是：  \n\n1. 利用 regionCache 来迭代出这个 span 中覆盖的所有 region 信息\n2. 为每一个 region 生成一个 region 相关的 singleRegionInfo 任务\n3. 用 RegionRangeLock 模块来锁住这一个 region 的范围，表示这个 region 已经在被处理了，如果成功锁定，则将任务发送到下一级 chan 中，否则，重新把这个子 span 发送到 requestRangeCh 来重试失败的子 span\n\n### dispatchRequest\n\n这个 goroutine 做的事比较简单，就是从 chanel 中取出 singleRegionInfo，然后根据 region 信息获取 rpcCtx，并将 rpcCtx 赋值给 singleRegionInfo，最后再把 singleRegionInfo 任务传给 regionRouter。\n\n### regionRouter\n\nregionRouter 是一个基于 token 请求限流模块，用来限制 TiCDC 向 TiKV 发送请求的频率。  \n\nregionRouter 周期性地按 token limit 的方式把 region 任务输出到他的 output 中，默认情况下，每个 TiKV store 的 token 数是 40。在 Puller 真正向 TiKV 发送完请求后会将该 store 的 token 减 1，而在收到 TiKV 发送来的 INITIALIZED 事件后释放一个 token。\n\n### requestRegionToStore  \n\nrequestRegionToStore 从上面的 regionRoute 的 routput chan 中读取出 singleRegionInfo，并根据其携带的信息来构造 ChangeDataRequest 发送给 TiKV，然后在单独的 goroutine 中调用接收从 TiKV 发送过来的事件。同时也创建并初始化运行一个 region worker，用于处理 TiKV 发送来数据。到这里，正常流程下，一个 span 的 region 拆分、region 任务分发，以及请求的发送过程都完成了。值得注意的是每个 TiKV store 只有一个 grpc stream，建立后，ChangeDataRequest 都在这个 stream 上收发，并用同一个 region worker 处理。  \n\n### handleError  \n\n最后一个 goroutine 是来做错误处理的。\n\n## Region worker 数据处理\n\nRegion worker 对应一个 TiKV store，负责这个 TiKV store 上所有 region 的数据处理。Region worker 从自己的 input chan 中读取 grpc response，经过处理后把数据写到 Puller 的 eventChan 中。\n\nRegion worker 的启动是在它的 Run 方法中进行的：\n\n1. 首先是 initPoolHandles 向 workerPool 注册 handle，workerPool 是 TiCDC 内部实现的一个线程池，向这个线程池中注册完 handle 后就可以向 workPool 提交任务了。当 region worker 的 input chan 里堆积了大量 grpc response 且超过了一个阈值来不及处理时，region worker 会向这个线程池提交任务，以加快任务处理，提高 Puller 的吞吐。\n2. Region worker 启动一个 eventHandler goroutine 来从 inputCh 读取 grpc 的接收到的数据，这是真正数据处理的地方。\n3. Region worker 还会启动一个 goroutine 来 resolveLock，尝试解决上游 TiDB 崩溃时在 TiKV 中残留的 lock 信息，以免影响 TiKV resolved ts 的推进。\n\n### KV 事件处理  \n\n在讲 TiCDC 如何处理数据前，我们先来回顾一下 TiKV 如何捕获变更数据的，在 TiKV 侧数据的捕获分成了两个阶段：\n\n1.第一个阶段 我们称之为增量扫，这个阶段获取 Region Snapshot，读取某段时间范围内的数据更改，时间范围一般为 (start ts, current ts]。这一阶段会输出三种类型的事件，\n  - 一种是 prewrite 事件，表示发生在 start ts 到 current ts 期间的上锁记录\n  - 一种是 commited 事件，表示发生在 start ts 到 current ts 期间的提交记录，也就是 prewrite 加 commit 之后的完整结果\n  - 最后一种是 initilaized 事件，表示增量扫的过程结束了，后续不会有 committed 内容输出，TiKV 会向 TiCDC 发送 resolved ts 事件了。  \n\n2.第二个阶段是实时推流的过程，这个阶段贯穿于整个 TiCDC 连接生命周期，通过启动运行在 Raftstore Apply 线程中的 CdcObserver，实时捕捉上游写入。\n\n总的来说就是 TiKV 会汇总两个阶段的 KV 数据，通过 Grpc 发送给 TiCDC。\n\n![TiKV 汇总两个阶段数据.png](https://img1.www.pingcap.com/prod/Ti_KV_fd418eb7a6.png)\n\nTiCDC 在收到 Grpc 响应后，Grpc 响应数据被解析出来发送到了 region worker 的 inputChan 中，region worker 从 inputChan 中读出数据，并在 handleEventEntry 方法中处理上述各种类型的事件还原成完整事务：\n\n- Committed：已提交事务，直接输出到 Puller 的 event channel 中。\n- Prewrite：prewrite 事件，需要缓存到 matcher 中，key 为 startTs 和 kv key。\n- Commit：事务提交事件，从 matcher 中找到缓存的 prewrite 事件，组装成一个提交好的事务，然后输出到 eventCh 中。\n- Rollback: 事务回滚，从 matcher 的缓存中清理掉 prewriter 事件。\n- Initialized：增量扫完成，表示这个 region 可以处理了，这个时候 region worker 需要做这几件事：\n  - 设置 region initialized 状态，handleResolvedTs 方法在检测到这个标志后会输出该 region 的 resolvedTs\n  - 释放 regionRouter 的 token，region 增量扫完成了，可以允许发送别的 region 的 ChnageDataRequest 请求了\n  - 处理所有的 cachedCommit 事件，并发送到 event channel 中\n- ResolvedTs: resolved ts 会被送到 event chan 中，同时也会发送到 resolve lock goroutine 中 channel 中。\n\n## ResolvedTs 推进\n\nKV 事件经过处理后可以输出了，但是 region worker 输出到 Puller event chan 中的 resolved ts 事件还只是 region 级别的 resolved ts，而我们需要输出一个表级别的 resolved ts，更准确地说是整个 Puller span 范围的 resolved ts，来告诉下游这个 resolved ts 之前的所有 KV 数据可以处理了。这里就需要用到一个叫 frointier 模块，这个模块的接口定义比较简单，Forward 方法用来接收某个 region 的 resolved ts 值以及其 kv 范围，而 Frointier 则表示输出 Puller 级别的 resolved ts。\n\n```\ntype Frontier interface {\n        Forward(regionID uint64, span regionspan.ComparableSpan, ts uint64)\n        Frontier() uint64\n        String() string\n}\n```\n\nPuller 会在其 Run 方法中，消费由 region worker 输出到 event chan 中的数据：\n\n- 如果是 kv 事件，直接就输出到 output chan 中\n- 如果是 resolved ts，则会调用 frointer 的 Forward 的方法来调整并更新 frointier 内部维护的最小堆，重新计算并缓存所有 span 中最小的 resolved ts。Puller 最后调用 Frointier 方法拿到最小的 resolved ts，封装成 RawKvEntry 输出到 Puller 的 output chan 中。举个例子，一个 Puller 同步的表有 6 个 region，它们当前的 resolved ts 分别为 6，3，2，5，4，7，现在 TiCDC 收到了其中一个 region 的 resolved ts 事件，其值更新成了 4，变成了 6，3，4，5，4，7，则 Puller 会对外会输出一个 3 的 resolved ts 类型 RawKvEntry。\n\n## 错误处理\n\nRegion worker 在处理事件的时候会发生一些错误，同时也可能会收到一些由 TiKV 发过来的错误，比如说 region not found、not leader 等等。这些错误都会由 Puller eventSession 统一的错误处理逻辑来处理：\n\n- 首先调用 handleSingleRegionError 方法统一输出到了一个 errChan 中\n- 然后由之前提到的 Puller eventSession 中的 handleError goroutine 消费处理，handleError 处理 errChan 时，根据错误的类型来判断是否需要重新调度 region 请求。一些错误是不预期或者不可重试的，比如 DuplicateRequest，Compatibility，ClusterIdMismatch，遇到这类错误时 Puller 就直接报错，而其他可重试的错误则会调用 scheduleRegionRequest 来重新调度 region 所覆盖的 span，再一次完整的走过 eventSeesion 处理 span 的流程。\n\n## DDL 事件捕获\n\n最后，我们来了解一下 DDL 事件的拉取过程。DDL 也是存储在 TiKV 中，因此底层捕获数据的逻辑与 DML 相同，都是向 TiKV 中请求事件变更的 span。TiCDC 在 Puller 的上层定义了一个名为 DDLJobPuller 的接口。\n\n这个接口的作用类似于 pullerNode，用于驱动 Puller 数据流动。DDLJobPuller 接口具有一个 Run 方法用于拉取数据，以及一个返回 channel 的 Output 方法，用于向外暴露数据。与 DML 相比，底层 Puller 需要拉取的 span 的初始值不同，共需要拉取 3 个 span 的数据。\n\n```\nfunc GetAllDDLSpan() []Span {\n        return []Span{getDDLSpan(), getAddIndexDDLSpan(), GetTableSpan(JobTableID)}\n}\n```\n\n在 TiDB 支持 concurrent DDL 时，DDL 也被视为一个特殊表，表的 ID 为 MaxInt48 - 1，所以在这种情况下，它的 span 计算方式与 DML 保持一致，为了兼容所有的场景，TiCDC 会同时拉取这 3 个 span 的数据。\n\n## 总结\n\n以上就是本文的全部内容，希望在阅读上面的内容之后，能够对 TiCDC Puller 模块的工作原理有一个基本了解，了解以下几个要点：\n- Puller 确定需要拉取哪些 KV region 的方式\n- Puller 初始化、还原事务的操作\n- Puller 推进 resolved ts 的过程\n\n","author":"蒋建元","category":1,"customUrl":"ticdc-source-code-reading-6","fillInMethod":"writeDirectly","id":472,"summary":"TiCDC 中的 Puller 通过创建 KV-Client 向 TiKV 发送请求，在 TiCDC 中实现从 TiKV 接收变更数据功能。本期将详细分享 Puller 模块的功能实现原理。","tags":["TiCDC"],"title":"TiCDC 源码阅读（六）TiCDC Puller 模块介绍"}}]}}},
    "staticQueryHashes": ["1327623483","1820662718","3081853212","3430003955","3649515864","4265596160","63159454"]}