{
    "componentChunkName": "component---src-templates-blog-blog-detail-tsx",
    "path": "/blog/tiflash-source-code-reading-2",
    "result": {"pageContext":{"blog":{"id":"Blogs_389","title":"TiFlash 源码阅读（二）计算层概览","tags":["TiFlash 源码阅读"],"category":{"name":"产品技术解读"},"summary":"本文将对 TiFlash 计算层进行介绍，包括架构的演进，DAGRequest 协议、dag request 在 TiFlash 侧的处理流程以及 MPP 基本原理。","body":"TiFlash 是 TiDB 的分析引擎，是 TiDB HTAP 形态的关键组件。TiFlash 源码阅读系列文章将从源码层面介绍 TiFlash 的内部实现。在上一期源码阅读中，我们介绍了 TiFlash 的存储层，本文将对 TiFlash 计算层进行介绍，包括架构的演进，DAGRequest 协议、dag request 在 TiFlash 侧的处理流程以及 MPP 基本原理。\n\n本文作者：徐飞，PingCAP 资深研发工程师\n\n\n## 背景\n\n![1.jpg](https://img1.www.pingcap.com/prod/1_d65c5b7594.jpg)\n\n上图是一个 TiDB 中 query 执行的示意图，可以看到在 TiDB 中一个 query 的执行会被分成两部分，一部分在 TiDB 执行，一部分下推给存储层（TiFlash/TiKV）执行。本文我们主要关注在 TiFlash 执行的部分。\n\n![2.jpg](https://img1.www.pingcap.com/prod/2_b96e44abde.jpg)\n\n这个是一个 TiDB 的查询 request 在 TiFlash 内部的基本处理流程，首先 Flash service 会接受到来自 TiDB 的 RPC 请求，然后会从请求里拿到 TiDB 的 plan，在 TiFlash 中我们称之为 DAGRequest，拿到 TiDB 的 plan 之后，TiFlash 需要把 TiDB 的 plan 编译成可以在 TiFlash 中执行的 BlockInputStream，最后在得到 BlockInputStream 之后，TiFlash 就会进入向量化执行的阶段。本文要讲的 TiFlash 计算层实际上是包含以上四个阶段的广义上的计算层。\n\n## TiDB + TiFlash 计算层的演进\n\n首先，我们从 API 的角度来讲一下 TiDB + TiFlash 计算层的演进过程：\n\n![3.jpg](https://img1.www.pingcap.com/prod/3_49103985db.jpg)\n\n最开始在没有引入 TiFlash 时，TiDB 是用过 Coprocessor 协议来与存储层（TiKV）进行交互的，在上图中，root executors 表示在 TiDB 中单机执行的算子，cop executors 指下推给 TiKV 执行的算子。在 TiDB + TiKV 的计算体系中，有如下几个特点：\n\n- TiDB 中的算子是在 TiDB 中单机执行的，计算的扩展性受限\n\n- TiKV 中的算子是在 TiKV 中执行的，而且 TiKV 的计算能力是可以随着 TiKV 节点数的增加而线性扩展的\n\n- 因为 TiKV 中并没有 table 的概念，Coprocessor 是以 Region 为单位的，一个 region 一个 coprocessor request\n\n- 每个 Coprocessor 都会带有一个用于 MVCC 读的 timestamp，在 TiFlash 中我们称之为 start_ts\n\n在 TiDB 4.0 中，我们首次引入了 TiFlash：\n\n![150661e864fc83c017cb31814dfdbd8.jpg](https://img1.www.pingcap.com/prod/150661e864fc83c017cb31814dfdbd8_3b6d77baa0.jpg)\n\n在引入之初，我们基本上就是只对接了现有的 Coprocessor 协议，可以看出上面这个图上之前 TiDB + TiKV 的图其实是一样的，除了存储层从 TiKV 变成了 TiFlash。但是本质上讲引入 TiFlash 之前 TiDB + TiKV 是一个面向 TP 的系统，TiFlash 在简单对接 Coprocessor 协议之后，马上发现了一些对 AP 很不友好的地方，主要有两点：\n\n- Coprocessor 是以 region 为单位的，而 TiDB 中默认 region 大小是 96 MB，这样对于一个 AP 的大表，可能会包含成千上万个 region，这导致一个 query 就会有成千上万次 RPC\n\n- 每个 Coprocessor 只读一个 region 的数据，这让存储层很多读相关的优化都用不上\n\n在发现问题之后，我们尝试对原始的 Coprocessor 协议进行改进，主要进行了两次尝试：\n\n- BatchCommands：这个是 TiDB + TiKV 体系里就有的一个改进，原理就是在发送的时候将发送给同一个存储节点的 request batch 成一个，对于 TiFlash 来说，因为只支持 Coprocessor request，所以就是把一些 Coprocessor request batch 成了一个。因为 batch 操作是发送端最底层做的，所以 batch 在一起的 Coprocessor request 并没有逻辑上的联系，所以 TiFlash 拿到 BatchCoprocessor 之后也就是每个 Coprocessor request 依次处理。所以 BatchCommands 只能解决 RPC 过多的问题。\n\n- BatchCoprocessor：这个是 TiDB + TiFlash 特有的 RPC，其想法也很简单，就是对同一个 TiFlash 节点，只发送一个 request，这个 request 里面包含了所有需要读取的 region 信息。显然这个模式不但能减少 RPC，而且存储层能一次性的看到所有需要扫描的数据，也让存储层有了更大的优化空间。\n\n尽管在引入 BatchCoprocessor 之后，Coprocessor 的两个主要缺点都得到了解决，但是因为无论是 BatchCoprocessor 还是 Coprocessor 都只是支持对单表的 query，遇到复杂 sql，其大部分工作还是需要在 root executor 上单机执行，以下面这个两表 join 的 plan 为例：\n\n![4.jpg](https://img1.www.pingcap.com/prod/4_03fdac5c49.jpg)\n\n只有 TableScan 和 Selection 部分可以在 TiFlash 中执行，而之后的 Join 和 Agg 都需要在 TiDB 执行，这显然极大的限制了计算层的扩展性。为了从架构层面解决这个问题，在 TiFlash 5.0 中，我们正式引入了 MPP 的计算架构：\n\n![5.jpg](https://img1.www.pingcap.com/prod/5_5847dc2cc8.jpg)\n\n引入 MPP 之后，TiFlash 支持的 query 部分得到了极大的丰富，对于理想情况下，root executor 直接退化为一个收集结果的 TableReader，剩下部分都会下推给 TiFlash，从而从根本上解决了 TiDB 中计算能力无法横向扩展的问题。\n\n## DAGRequest 到 BlockInputStream\n\n在 TiFlash 内部，接收到 TiDB 的 request 之后，首先会得到 TiDB 的 plan，在 TiFlash 中，称之为 DAGRequest，它是一个基于 protobuf 协议的一个定义，一些主要的部分如下：\n\n![6.jpg](https://img1.www.pingcap.com/prod/6_103a98870d.jpg)\n\n值得一提的就是 DAGRequest 中有两个 executor 相关的 field：\n\n- executors：这个是引入 TiFlash 之前的定义，其表示一个 executor 的数组，而且里面的 executor 最多就三个：一个 scan（tablescan 或者 indexscan），一个 selection，最后一个 agg/topN/limit\n\n- root_executors：显然上面那个 executors 的定义过于简单，无法描述 MPP 时的 plan，所以在引入 MPP 之后我们加了一个 root_executor 的 field，它是一个 executor 的 tree\n\n在得到 executor tree 之后，TiFlash 会进行编译，在编译的时候有一个中间数据结构是 DAGQueryBlock，TiDB 会先将 executor tree 转成 DAGQueryBlock 的tree，然后对 DAGQueryBlock 的 tree 进行后序遍历来编译。\n\nDAGQueryBlock 的定义和原始的 executor 数组很类似，一个 DAGQueryBlock 包含的 executor 如下：\n\n- SourceExecutor [Selection] [Aggregation|TopN|Limit] [Having] [ExchangeSender]\n\n其中 SourceExecutor 包含真正的 source executor 比如 tablescan 或者 exchange receiver，以及其他所有不符合上述 executor 数组 pattern 的 executor，如 join，project 等。\n\n可以看出来 DAGQueryBlock 是从 Coprocessor 时代的 executor 数组发展而来的，这个结构本身并没有太多的意义，而且也会影响很多潜在的优化，在不久的将来，应该会被移除掉。\n\n在编译过程中，有两个 TiDB 体系特有的问题需要解决：\n\n- 如何保证 TiFlash 的数据与 TiKV 的数据保持强一致性\n\n- 如何处理 Region error\n\n对于第一个问题，我们引入了 Learner read 的过程，即在 TiFlash 编译 tablescan 之前，会用 start_ts 向 raft leader 查询截止到该 start_ts 时，raft 的 index 是多少，在得到该 index 之后，TiFlash 会等自己这个 raft leaner 的 index 追上 leader 的 index。\n\n对于第二个问题，我们引入了 Remote reader 的概念，即如果 TiFlash 遇到了 region error，那么如果是 BatchCoprocessor 和 MPP request，那 TiFlash 会主动像其他 TiFlash 节点发 Coprocessor request 来拿到该 region 的数据。\n\n在把 DAGRequest 编译成 BlockInputStream 之后，就进入了向量化执行的阶段，在向量化执行的时候，有两个基本的概念：\n\n- Block：是执行期的最小数据单元，它由一个 column 的数组组成\n\n- BlockInputStream：相当于执行框架，每个 BlockInputStream 都有一个或者多个 child，执行时采用了 pull 的模型，下面是执行时的伪代码：\n\n![7.jpg](https://img1.www.pingcap.com/prod/7_ba4bf91e6b.jpg)\n\nBlockInputStream 可以分为两类：\n- 用于做计算的，例如：\n  - DMSegmentThreadInputStream：与存储交互的 InpuStream，可以简单理解为是 table scan\n  - ExchangeReceiverInputStream：从远端读数据的 InputStream\n  - ExpressionBlockInputStream：进行 expression 计算的 InputStream\n  - FilterBlockInputStream：对数据进行过滤的 InputStream\n  - ParallelAggregatingBlockInputStream：做数据进行聚合的 InputStream\n\n- 用于并发控制的，例如：\n  - UnionBlockInputStream：把多个 InputStream 合成一个 InputStream\n  - ParallelAggregatingBlockInputStream：和 Union 类似，不过还会做一个额外的数据聚合\n  - SharedQueryBlockInputStream：把一个 InputStream 扩散成多个 InputStream\n\n![8.jpg](https://img1.www.pingcap.com/prod/8_cb82929749.jpg)\n\n用于计算的 InputStream 与用于并发控制的 InputStream 最大的不同在于用于计算的 InputStream 自己不管理线程，它们只负责在某个线程里跑起来，而用于并发控制的 InputStream 会自己管理线程，如上所示，Union，ParallelAggregating 以及 SharedQuery 都会在自己内部维护一个线程池。当然有些并发控制的 InputStream 自己也会完成一些计算，比如 ParallelAggregatingBlockInputStream。\n\n## MPP\n\n在介绍完 TiFlash 计算层中基本的编译以及执行框架之后，我们重点再介绍下 MPP。\n\nMPP 在 API 层共有三个：\n\n- DispatchMPPTask：用于 TiDB 向 TiFlash 发送 plan\n\n- EstablishMPPConnectionSyncOrAsync：用于 MPP 中上游 task 向下游 task 发起读数据的请求，因为无论是读的数据量以及读的时间会比较长，所以这个 RPC 是 streaming 的 RPC\n\n- CancelMPPTask：用于 TiDB 端 cancel MPP query\n\n在运行 MPP query 的时候，首先由 TiDB 生成 MPP task，TiDB 用 DispatchMPPTask 来将 task 分发给各个 TiFlash 节点，然后 TiDB 与 TiFlash 会用 EstablishMPPConnection 来建立起各个 task 之间的连接。\n\n与 BatchCoprocessor 相比，MPP 的核心概念是 Exchange，用于 TiFlash 节点之间的数据交换，在 TiFlash 中有三种 exchange 的类型：\n\n- Broadcast：即将一份数据 broadcast 到多个目标 mpp task\n\n- HashPartition：即将一份数据用 hash partition 的方式切分成多个 partition，然后发送给目标 mpp task\n\n- PassThrough：这个与 broadcast 几乎一样，不过 PassThrough 的目标 task 只能有一个，通常用于 MPP task 给 TiDB 返回结果\n\n![9.jpg](https://img1.www.pingcap.com/prod/9_6fd8393af2.jpg)\n\n上图是 Exchange 过程中的一些关键数据结构，主要有如下几个：\n\n- 接收端 \n  - ExchangeReceiver：用于向其他 task 建立连接，接收数据并放在 result queue\n  - ExchangeReceiverInputStream：执行框架中的一个 InputStream，多个 ER Stream 共同持有一个 ExchangeReceiver，并从其 result queue 中读数据\n\n- 发送端\n  - MPPTunnel：持有 grpc streaming writer，用于将计算结果发送给其他 task，目前有三种模式\n    - Sync Tunnel：用 sync grpc 实现的 tunnel\n    - Async Tunnel：用 async grpc 实现的 tunnel\n    - Local Tunnel：对于处于同一个节点的不同 task，他们之间的 Tunnel 不走 RPC，在内存里传输数据即可。\n  - MPPTunnelSet：同一个 ExchangeSender 可能需要向多个 mpp task 传输数据，所以会有多个 MPPTunnel，这些 MPPTunnel 在一起组成一个 MPPTunnelSet\n  - StreamingDAGResponseWriter：持有 MPPTunnelSet，主要做一些发送之前的数据预处理工作\n    - 将数据 encode 成协议规定的格式\n    - 如果 Exchange Type 是 HashPartition 的话，还需要负责把数据进行 Hash partition 的切分\n  - ExchangeSenderBlockInputStream：执行框架中的一个 InputStream，持有 StreamingDAGResponseWriter，把计算的结果发送给 writer\n\n除了 Exchange，MPP 还有一个重要部分是 MPP task 的管理，与 BatchCoprocessor/Coprocessor 不同，MPP query 的多个 task 需要有一定的通信协作，所以 TiFlash 中需要有对 MPP task 的管理模块。其主要的数据结构如下：\n\n- MPPTaskManager：全局的 instance 用来管理该 TiFlash 节点上所有的 MPP task\n\n- MPPQueryTaskSet：属于同一个 query 的所有 MPP task 集合，在诸如 CancelMPPTask 时用于快速找到所有的目标 task\n\n- MPPTask：一个 MPP query 中的最基本单元，不同 MPP task 之间通过 Exchange 来交换数据\n\n以上就是 TiFlash 中 MPP 的相关实现，可以看出目前这个实现还是比较朴素的。在随后的测试和使用中，我们很快发现一些问题，主要有两个问题：\n\n第一个问题：对于一些 sql 本身很复杂，但是数据量（计算量）却不大的 query，我们发现，无论怎么增加 query 的并发，TiFlash 的 cpu 利用率始终会在 50% 以下。经过一系列的研究之后我们发现 root cause 是目前 TiFlash 的线程使用是需要时申请，结束之后即释放的模式，而频繁的线程申请与释放效率非常低，直接导致了系统 cpu 使用率无法超过 50%。解决该问题的直接思路即使用线程池，但是由于我们目前 task 使用线程的模式是非抢占的，所以对于固定大小的线程池，因为系统中没有全局的调度器，会有死锁的风险，为此我们引入了 DynamicThreadPool，在该线程池中，线程主要分为两类：\n\n- 固定线程：长期存在的线程\n\n- 动态线程：按需申请的线程，不过与之前的线程不同的是，该线程在结束当前任务之后会等一段时间，如果没有新的任务的话，才会退出\n\n第二个问题和第一个问题类似，也是线程相关的，即 TiFlash 在遇到高并发的 query 时，因为线程使用没有很好的控制，会导致 TiFlash server 遇到无法分配出线程的问题，为了解决此问题，我们必须控制 TiFlash 中同时使用的线程，在跑 MPP query 的时候，线程主要可以分为两部分：\n\n- IO 线程：主要指用于 grpc 通信的线程，在减小 grpc 线程使用方面，我们基本上是采用了业界的成熟方案，即用 async 的方式，我们实现了 async 的 grpc server 和 async 的 grpc client，大大减小了 IO 线程的使用量\n\n- 计算线程：为了控制计算线程，我们必须引入调度器，该调度器有两个最低目标：不造成死锁以及最大程度控制系统的线程使用量，最后我们在 TiFlash 里引入了 MinTSOScheduer：\n  - 完全分布式的调度器，仅依赖 TiFlash 节点自身的信息\n  - 基本的原理为 MinTSOScheduer 保证 TiFlash 节点上最小的 start_ts 对应的所有 MPP task 能正常运行。因为全局最小的 start_ts 在各个节点上必然也是最小的 start_ts，所以 MinTSOScheduer 能够保证全局至少有一条 query 能顺利运行从而保证整个系统不会有死锁，而对于非最小 start_ts 的 MPP task，则根据当前系统的线程使用情况来决定是否可以运行，所以也能达到控制系统线程使用量的目的。\n\n## 总结\n\n本文主要系统性地介绍了 TiFlash 计算层的基本概念，包括架构的演进，TiFlash 内部对 TiDB plan 的处理以及 MPP 基本原理等，以期望读者能够对 TiFlash 计算层有一个初步的了解。后续还会有一些具体实现诸如 TiFlash 表达式以及算子系统的细节介绍，敬请期待。\n\n> 体验全新的一栈式实时 HTAP 数据库，即刻注册 TiDB Cloud，在线申请 PoC 并获得专业技术支持。\n\n\n<div class=\"is-flex is-flex-direction-row is-justify-content-center\">\n  <div class=\"is-flex is-flex-direction-column\">\n    <a target=\"_blank\" class=\"button is-link mx-5\"\n       href=\"https://tidbcloud.com/free-trial?utm_source=website-zh&utm_medium=referral&utm_campaign=blog-tiflash-source-code-reading-2\"\n       referrerpolicy=\"no-referrer-when-downgrade\" style=\"background-color: #3a40e1;\">免费试用 TiDB Cloud\n    </a>\n    <div style=\"font-size:12px; text-align:center\">适用于中国出海企业和开发者</div>\n  </div>  \n<div class=\"is-flex is-flex-direction-column\">\n    <a target=\"_blank\" class=\"button is-link mx-5\"\n       href=\"https://pingcap.com/zh/product-community/\"\n       style=\"background-color: #3a40e1;\">\n      下载 TiDB 社区版\n    </a>\n  </div>\n</div>\n\n> 点击查看更多 [TiFlash 源码阅读](https://pingcap.com/zh/blog?tag=TiFlash%20%E6%BA%90%E7%A0%81%E9%98%85%E8%AF%BB)系列文章","date":"2022-05-23","author":"徐飞","fillInMethod":"writeDirectly","customUrl":"tiflash-source-code-reading-2","file":null,"relatedBlogs":[{"relatedBlog":{"body":"## 背景\n\n![1.jpeg](https://img1.www.pingcap.com/prod/1_47cc623090.jpeg)\n\n本系列会聚焦在 TiFlash 自身，读者需要有一些对 TiDB 基本的知识。可以通过这三篇文章了解 TiDB 体系里的一些概念《[说存储](https://pingcap.com/zh/blog/tidb-internal-1)》、《[说计算](https://pingcap.com/zh/blog/tidb-internal-2)》、《[谈调度](https://pingcap.com/zh/blog/tidb-internal-3)》。\n\n今天的主角 -- TiFlash 是 TiDB HTAP 形态的关键组件，它是 TiKV 的列存扩展，通过 Raft Learner 协议异步复制，但提供与 TiKV 一样的快照隔离支持。我们用这个架构解决了 HTAP 场景的隔离性以及列存同步的问题。自 5.0 引入 MPP 后，也进一步增强了 TiDB 在实时分析场景下的计算加速能力。\n\n![2.png](https://img1.www.pingcap.com/prod/2_d0081f25ad.png)\n\n上图描述了 TiFlash 整体逻辑模块的划分，通过 Raft Learner Proxy 接入到 TiDB 的 multi-raft 体系中。我们可以对照着 TiKV 来看：计算层的 MPP 能够在 TiFlash 之间做数据交换，拥有更强的分析计算能力；作为列存引擎，我们有一个 schema 的模块负责与 TiDB 的表结构进行同步，将 TiKV 同步过来的数据转换为列的形式，并写入到列存引擎中；最下面的一块，是稍后会介绍的列存引擎，我们将它命名为 DeltaTree 引擎。\n\n有持续关注 TiDB 的用户可能之前阅读过 [《TiDB 的列式存储引擎是如何实现的？》](https://zhuanlan.zhihu.com/p/164490310) 这篇文章，近期随着 [TiFlash 开源](https://pingcap.com/zh/blog/tiflash-is-open-sourced)，也有新的用户想更多地了解 TiFlash 的内部实现。这篇文章会从更接近代码层面，来介绍 TiFlash 内部实现的一些细节。\n\n这里是 TiFlash 内一些重要的模块划分以及它们对应在代码中的位置。在今天的分享和后续的系列里，会逐渐对里面的模块开展介绍。\n\n```CSS\n# TiFlash 模块对应的代码位置\n\ndbms/\n\n└── src\n\n    ├── AggregateFunctions, Functions, DataStreams # 函数、算子\n\n    ├── DataTypes, Columns, Core # 类型、列、Block\n\n    ├── IO, Common, Encryption   # IO、辅助类\n\n    ├── Debug     # TiFlash Debug 辅助函数\n\n    ├── Flash     # Coprocessor、MPP 逻辑\n\n    ├── Server    # 程序启动入口\n\n    ├── Storages\n\n    │   ├── IStorage.h           # Storage 抽象\n\n    │   ├── StorageDeltaMerge.h  # DeltaTree 入口\n\n    │   ├── DeltaMerge           # DeltaTree 内部各个组件\n\n    │   ├── Page                 # PageStorage\n\n    │   └── Transaction          # Raft 接入、Scehma 同步等。 待重构 https://github.com/pingcap/tiflash/issues/4646\n\n    └── TestUtils # Unittest 辅助类\n```\n\n\n\n## TiFlash 中的一些基本元素抽象\n\nTiFlash 这款引擎的代码是 18 年从 ClickHouse  fork。ClickHouse 为 TiFlash 提供了一套性能十分强劲的向量化执行引擎，我们将其当做 TiFlash 的单机的计算引擎使用。在此基础上，我们增加了针对 TiDB 前端的对接，MySQL 兼容，Raft 协议和集群模式，实时更新列存引擎，MPP 架构等等。虽然和原本的 Clickhouse 已经完全不是一回事，但代码自然地 TiFlash 代码继承自 ClickHouse，也沿用着 CH 的一些抽象。比如：\n\nIColumn 代表内存里面以列方式组织的数据。IDataType 是数据类型的抽象。Block 则是由多个 IColumn 组成的数据块，它是执行过程中，数据处理的基本单位。\n\n在执行过程中，Block 会被组织为流的形式，以 BlockInputStream 的方式，从存储层 “流入” 计算层。而 BlockOutputStream，则一般从执行引擎往存储层或其他节点 “写出” 数据。\n\nIStorage 则是对存储层的抽象，定义了数据写入、读取、DDL 操作、表锁等基本操作。\n\n![3.png](https://img1.www.pingcap.com/prod/3_9601351c46.png)\n\n## DeltaTree 引擎\n\n虽然 TiFlash 基本沿用了 CH 的向量化计算引擎，但是存储层最终没有沿用 CH 的 MergeTree 引擎，而是重新研发了一套更适合 HTAP 场景的列存引擎，我们称为 DeltaTree，对应代码中的 \"[StorageDeltaMerge](https://github.com/pingcap/tiflash/blob/afdd2e0ca23ccd6a19a604d90b9d75c971a3fe7c/dbms/src/Storages/StorageDeltaMerge.h#L42)\"。\n\n### DeltaTree 引擎解决的是什么问题\n\nA. 原生支持高频率数据写入，适合对接 TP 系统，更好地支持 HTAP 场景下的分析工作。\n\nB. 支持列存实时更新的前提下更好的读性能。它的设计目标是优先考虑 Scan 读性能，相对于 CH 原生的 MergeTree 可能部分牺牲写性能\n\nC. 符合 TiDB 的事务模型，支持 MVCC 过滤\n\nD. 数据被分片管理，可以更方便的提供一些列存特性，从而更好的支持分析场景，比如支持 rough set index\n\n![4.png](https://img1.www.pingcap.com/prod/4_076f886eaf.png)\n\n为什么我们说 DeltaTree 引擎具备上面特性呢🤔 ？回答这个疑问之前，我们先回顾下 CH 原生的 MergeTree 引擎存在什么问题。MergeTree 引擎可以理解为经典的 LSM Tree（Log Structured Merge Tree）的一种列存实现，它的每个 \"part 文件夹\" 对应 SSTFile（Sorted Strings Table File）。最开始，MergeTree 引擎是没有 WAL 的，每次写入，即使只有 1 条数据，也会将数据需要生成一个 part。因此如果使用 MergeTree 引擎承接高频写入的数据，磁盘上会形成大量碎片的文件。这个时候，MergeTree 引擎的写入性能和读取性能都会出现严重的波动。这个问题直到 2020 年，CH 给 MergeTree 引擎引入了 WAL，才部分缓解这个压力 [ClickHouse/8290](https://github.com/ClickHouse/ClickHouse/pull/8290)。\n\n那么是不是有了 WAL，MergeTree 引擎就可以很好地承载 TiDB 的数据了呢？还不足够。因为 TiDB 是一个通过 MVCC 实现了 Snapshot Isolation 级别事务的关系型数据库。这就决定了 TiFlash 承载的负载会有比较多的数据更新操作，而承载的读请求，都会需要通过 MVCC 版本过滤，筛选出需要读的数据。而以 LSM Tree 形式组织数据的话，在处理 Scan 操作的时候，会需要从 L0 的所有文件，以及其他层中 与查询的 key-range 有 overlap 的所有文件，以堆排序的形式合并、过滤数据。在合并数据的这个入堆、出堆的过程中， CPU 的分支经常会 miss，cache 命中也会很低。测试结果表明，在处理 Scan 请求的时候，大量的 CPU 都消耗在这个堆排序的过程中。\n\n另外，采用 LSM Tree 结构，对于过期数据的清理，通常在 level compaction 的过程中，才能被清理掉（即 Lk-1 层与 Lk 层 overlap 的文件进行 compaction）。而 level compaction 的过程造成的写放大会比较严重。当后台 compaction 流量比较大的时候，会影响到前台的写入和数据读取的性能，造成性能不稳定。\n\nMergeTree 引擎上面的三点：写入碎片、Scan 时 CPU cache miss 严重、以及清理过期数据时的 compaction ，造成基于 MergeTree 引擎构建的带事务的存储引擎，在有数据更新的 HTAP 场景下，读、写性能都会有较大的波动。\n\n### DeltaTree 的解决思路以及模块划分\n\n![5.png](https://img1.www.pingcap.com/prod/5_75cd660605.png)\n\n在看实现之前，我们来看看 DeltaTree 的疗效如何。上图是 Delta Tree 与基于 MergeTree 实现的带事务支持的列存引擎在不同数据量（Tuple number）以及不同更新 TPS (Transactions per second) 下的读 (Scan) 耗时对比。可以看到 DeltaTree 在这个场景下的读性能基本能达到后者的两倍。\n\n![6.png](https://img1.www.pingcap.com/prod/6_f6efda8833.png)\n\n那么 DeltaTree 具体面对上述问题，是如何设计的呢？\n\n首先，我们在表内，把数据按照 handle 列的 key-range，横向分割进行数据管理，每个分片称为 Segment。这样在 compaction 的时候，不同 Segment 间的数据就独立地进行数据整理，能够减少写放大。这方面与 PebblesDB[1] 的思路有点类似。\n\n另外，在每个 Segment 中，我们采用了 delta-stable 的形式，即最新的修改数据写入的时候，被组织在一个写优化的结构的末尾（[DeltaValueSpace.h](https://github.com/pingcap/tiflash/blob/afdd2e0ca23ccd6a19a604d90b9d75c971a3fe7c/dbms/src/Storages/DeltaMerge/Delta/DeltaValueSpace.h)），定期被合并到一个为读优化的结构中（[StableValueSpace.h](https://github.com/pingcap/tiflash/blob/afdd2e0ca23ccd6a19a604d90b9d75c971a3fe7c/dbms/src/Storages/DeltaMerge/StableValueSpace.h)）。Stable Layer 存放相对老的，数据量较大的数据，它不能被修改，只能被 replace。当 Delta Layer 写满之后，与 Stable Layer 做一次 Merge（这个动作称为 Delta Merge），从而得到新的 Stable Layer，并优化读性能。很多支持更新的列存，都是采用类似 delta-stable 这种形式来组织数据，比如 Apache Kudu[2]。有兴趣的读者还可以看看《Fast scans on key-value stores》[3] 的论文，其中对于如何组织数据，MVCC 数据的组织、对过期数据 GC 等方面的优劣取舍都做了分析，最终作者也是选择了 delta-main 加列存这样的形式。\n\nDelta Layer 的数据，我们通过一个 PageStorage 的结构来存储数据，Stable Layer 我们主要通过 [DTFile](https://github.com/pingcap/tiflash/blob/afdd2e0ca23ccd6a19a604d90b9d75c971a3fe7c/dbms/src/Storages/DeltaMerge/File/DMFile.h) 来存储数据、通过 PageStorage 来管理生命周期。另外还有 Segment、DeltaValueSpace、StableValueSpace 的元信息，我们也是通过 PageStorage 来存储。上面三者分别对应 DeltaTree 中 [StoragePool](https://github.com/pingcap/tiflash/blob/afdd2e0ca23ccd6a19a604d90b9d75c971a3fe7c/dbms/src/Storages/DeltaMerge/StoragePool.h#L73) 这一数据结构的 log, data 以及 meta。\n\n### PageStorage 模块\n\n![7.png](https://img1.www.pingcap.com/prod/7_3912dee0cc.png)\n\n上面提到， Delta Layer 的数据和 DeltaTree 存储引擎的一些元数据，这类较小的数据块，在序列化为字节串之后，作为 \"Page\" 写入到 PageStorage 来进行存储。PageStorage 是 TiFlash 中的一个存储的抽象组件，类似对象存储。它主要设计面向的场景是 Delta Layer 的高频读取：比如在 snapshot 上，以 PageID （或多个 PageID） 做点查的场景；以及相对于 Stable Layer 较高频的写入。PageStorage 层的 \"Page\" 数据块典型大小为数 KiB～MiB。\n\nPageStorage 是一个比较复杂的组件，今天先不介绍它内部的构造。读者可以先理解 PageStorage 至少提供以下 3 点功能：\n\n- 提供 WriteBatch 接口，保证写入 WriteBatch 的原子性\n- 提供 Snapshot 功能，可以获取一个不阻塞写的只读 view\n- 提供读取 Page 内部分数据的能力（只读选择的列数据）\n\n### 读索引 DeltaTree Index\n\n![8.png](https://img1.www.pingcap.com/prod/8_cac29a0c91.png)\n\n前面提到，在 LSM-Tree 上做多路归并比较耗 CPU，那我们是否可以避免每次读都要重新做一次呢？答案是可以的。事实上有一些内存数据库已经实践了类似的思路。具体的思路是，第一次 Scan 完成后，我们把多路归并算法产生的信息想办法存下来，从而使下一次 Scan 可以重复利用。这份可以被重复利用的信息我们称为 Delta Index，它由一棵 B+ Tree 实现。利用 Delta Index，把 Delta Layer 和 Stable Layer 合并到一起，输出一个排好序的 Stream。**Delta Index 帮助我们把 CPU bound、而且存在很多 cache miss 的 merge 操作，转化为大部分情况下一些连续内存块的 copy 操作**，进而优化 Scan 的性能。\n\n### Rough Set Index\n\n很多数据库都会在数据块上加统计信息，以便查询时可以过滤数据块，减少不必要的 IO 操作。有的将这个辅助的结构称为 KnowledgeNode、有的叫 ZoneMaps。TiFlash 参考了 InfoBright [4] 的开源实现，采用了 Rough Set Index 这个名字，中文叫粗粒度索引。\n\nTiFlash 给 SelectQueryInfo 结构中添加了一个 [MvccQueryInfo](https://github.com/pingcap/tiflash/blob/afdd2e0ca23ccd6a19a604d90b9d75c971a3fe7c/dbms/src/Storages/RegionQueryInfo.h#L52) 的结构，里面会带上查询的 key-ranges 信息。DeltaTree 在处理的时候，首先会根据 key-ranges 做 segment 级别的过滤。另外，也会从 DAGRequest 中将查询的 Filter [转化为 RSFilter](https://github.com/pingcap/tiflash/blob/afdd2e0ca23ccd6a19a604d90b9d75c971a3fe7c/dbms/src/Storages/DeltaMerge/FilterParser/FilterParser.h#L41) 的结构，并且在读取数据时，利用 RSFilter，做 ColumnFile 中数据块级别的过滤。\n\n在 TiFlash 内做 Rough Set Filter，跟一般的 AP 数据库不同点，主要在还需要考虑**粗粒度索引对** **MVCC** **正确性的影响**。比如表有三列 a、b 以及写入的版本 tso，其中 a 是主键。在 t0 时刻写入了一行 Insert (x, 100, t0)，它在 Stable VS 的数据块中。在 t1 时刻写入了一个删除标记 Delete(x, 0, t1)，这个标记存在 Delta Layer 中。这时候来一个查询 select * from T where b = 100，很显然如果我们在 Stable Layer 和 Delta Layer 中都做索引过滤，那么 Stable 的数据块可以被选中，而 Delta 的数据块被过滤掉。这时候就会造成 (x, 100, t0) 这一行被错误地返回给上层，因为它的删除标记被我们丢弃了。\n\n因此 TiFlash Delta layer 的数据块，只会应用 handle 列的索引。非 handle 列上的 Rough Set Index 主要应用于 Stable 数据块的过滤。一般情况下 Stable 数据量占 90%+，因此整体的过滤效果还不错。\n\n![9.png](https://img1.www.pingcap.com/prod/9_543f165970.png)\n\n### 代码模块\n\n下面是 DeltaTree 引擎内各个模块对应的代码位置，读者可以回忆一下前文，它们分别对应前文的哪一部分 ;)\n\n```CSS\n# DeltaTree 引擎内各模块对应的代码位置\n\ndbms/src/Storages/\n\n├── Page                   # PageStorage\n\n└── DeltaMerge\n\n    ├── DeltaMergeStore.h  # DeltaTree 引擎的定义\n\n    ├── Segment.h          # Segment\n\n    ├── StableValueSpace.h # Stable Layer\n\n    ├── Delta              # Delta Layer\n\n    ├── DeltaMerge.h       # Stable 与 Delta merge 过程\n\n    ├── File               # Stable Layer 的存储格式\n\n    ├── DeltaTree.h, DeltaIndex.h          # Delta Index \n\n    ├── Index, Filter, FilterParser        # Rough Set Filter\n\n    └── DMVersionFilterBlockInputStream.h  # MVCC Filtering\n```\n\n## 小结\n\n本篇文章主要介绍了 TiFlash 整体的模块分层，以及在 TiDB 的 HTAP 场景下，存储层 DeltaTree 引擎如何进行优化的思路。简单介绍了 DeltaTree 内组件的构成和作用，但是略去了一些细节，比如 PageStorage 的内部实现，DeltaIndex 如何构建、应对更新，TiFlash 是如何接入 multi-Raft 等问题。更多的代码阅读内容会在后面的章节中逐步展开，敬请期待。\n\n> 体验全新的一栈式实时 HTAP 数据库，即刻注册 TiDB Cloud，在线申请 PoC 并获得专业技术支持。\n\n\n<div class=\"is-flex is-flex-direction-row is-justify-content-center\">\n  <div class=\"is-flex is-flex-direction-column\">\n    <a target=\"_blank\" class=\"button is-link mx-5\"\n       href=\"https://tidbcloud.com/free-trial?utm_source=website-zh&utm_medium=referral&utm_campaign=blog-tiflash-source-code-reading-1\"\n       referrerpolicy=\"no-referrer-when-downgrade\" style=\"background-color: #3a40e1;\">\n      免费试用 TiDB Cloud\n    </a>\n    <div style=\"font-size:12px; text-align:center\">适用于中国出海企业和开发者</div>\n  </div>  \n<div class=\"is-flex is-flex-direction-column\">\n    <a target=\"_blank\" class=\"button is-link mx-5\"\n       href=\"https://pingcap.com/zh/product-community/\"\n       style=\"background-color: #3a40e1;\">\n      下载 TiDB 社区版\n    </a>\n  </div>\n</div>\n\n**相关文章**\n\n[1] [SOSP'17: PebblesDB: Building Key-Value Stores using Fragmented Log-Structured Merge Trees](https://www.cs.utexas.edu/~rak/papers/sosp17-pebblesdb.pdf)\n\n[2] [Kudu: Storage for Fast Analytics on Fast Data](https://kudu.apache.org/kudu.pdf)\n\n[3] [VLDB'17: Fast scans on key-value stores](https://vldb.org/pvldb/vol10/p1526-bocksrocker.pdf)\n\n[4] [Brighthouse: an analytic data warehouse for ad-hoc queries](https://dl.acm.org/doi/abs/10.14778/1454159.1454174)\n\n> 点击查看更多 [TiFlash 源码阅读](https://pingcap.com/zh/blog?tag=TiFlash%20%E6%BA%90%E7%A0%81%E9%98%85%E8%AF%BB)系列文章","author":"黄俊深","category":1,"customUrl":"tiflash-source-code-reading-1","fillInMethod":"writeDirectly","id":379,"summary":"TiFlash 是 TiDB 的分析引擎，是 TiDB HTAP 形态的关键组件。TiFlash 源码阅读系列文章将从源码层面介绍 TiFlash 的内部实现。本文为系列文章的第一篇，将对 TiDB HTAP 的整体形态进行介绍，并详细解析存储层 DeltaTree 引擎进行优化的设计思路以及其子模块","tags":["TiFlash 源码阅读"],"title":"TiFlash 源码阅读（一）TiFlash 存储层概览"}}]}}},
    "staticQueryHashes": ["1327623483","1820662718","3081853212","3430003955","3649515864","4265596160","63159454"]}