关于 TiKV、TiDB、TiFlash 的一些思考

一些常见问题的思考,只代表个人见解。对于一些已经沉淀下来的知识,会被挪到专门的文章中讨论。

架构

关于 SQL on KVStore

TiKV 相关

TiKV 写入

KV 热点

如果出现热点 Key,机器会吃不消么?写热点是难以避免的。TiKV 选择按 Range 切割,但是 User Key 不跨 Region。一段区间内的写热点,会导致容量超过上限而分裂,新分裂出来的 Region 可以被调度到其他 Node 上,从而实现负载均衡。在文章中提到,可以通过预分区的方式来划分 Region。可是对于单调递增的主键,或者索引,它会永远写在最后一个 Region 上。但我认为热点 Region 未必意味着热点机器,可以先进行 Split,然后通过 Leader Transfer 给其他的 Peer,或者通过 Conf Change 直接干掉自己。我猜测这个主要取决于数据迁移的效率和中心化服务的质量,如果在 Raft Log 阶段就能检测到流量问题并分裂,那么负载有可能被分流到多个相邻的 Region 中。

TiKV 提供了 SHARD_ROW_ID_BITS 来进行打散,这类似于 Spanner 架构中提到的利用哈希解决 Append 写的思路。TiBD 提供了 AUTO_RANDOM 替代 AUTO_INCREMENT

注意,如果负载是频繁对某个特定的 key 更新,则 TS 一定也被用来计算哈希,不然热点 key 一定是在同一个 Region 内。这样一个 key 的不同版本就分布在不同的 Region 中,就不利于扫表了。因为下推到 TiKV 的请求可以理解为从 [l, r] 去扫出来所有 commit_ts <= scan_ts 的数据,这样的扫表一定是会涉及到所有的机器,性能会很差。对于点查也一样,我们始终要找一个大于 user_key + ts 的 TiKV Key,哈希分片不好 seek。特别地,如果是 SI,那还得扫 [0, scan_ts] 中有没有 Lock,这个过程也要访问多个机器。

如果在构造 key 的时候就进行分片,比如在最左边加一个 shard_id,这样 rehash 会很困难。shard_id 可以比如是通过某个特定字段哈希得到。

在 Spanner 中存在 Tablet,也就是将多个同时访问比较频繁的 Region co-locate,这些 Region 彼此之间未必是有序的,甚至可能属于不同的表。

关于 Region 大小的讨论

TiKV 的 partitioned raft kv 特性

日志和数据分离存储

在早期版本中,Raft 数据和实际的 payload 是在一起存储的。但是从 PR 开始,进行了分离。好处显而易见:

  • Raft log 和实际 payload 的写入模式不一致
    • Raft log 基本上完全是顺序 append 写
    • Raft log 理论上可以按照水位线整体删除
  • Raft log 和 payload 写入的一致性要求不一致
    payload 写入如果丢失,是可以从 Raft log 回放的,因此 payload 的引擎可以容忍一定程度的不一致。

Raft 存储

原来 TiKV 使用 RocksDB 存储 Raft Log 和相关 Meta,存在几个问题:

  1. WAL + 实际数据,需要写两次盘,产生写放大。
  2. 数据变多,Compaction 负担变大,写放大更大。层数更多,写放大更大。

因此引入了类似 bitcask 架构的 RaftEngine 来解决这个问题。RaftEngine 中每个 Region 对应一个 Memtable,数据先通过 Group Write 写入到文件中,然后再注册到 Memtable 中。在读取时从 Memtable 获取位置,再从文件中读取。因此随着 Region 日志 Apply 进度的不同,RaftEngine 在文件中会存在空洞,因此需要 rewrite。这使得存在一部分 CPU 和 IO 花费在 rewrite 逻辑上,而不能像 PolarDB 一样按照水位线直接删除。RaftEngine 这么做可以减少 fsync 的调用频率,并且充分利用文件系统 buffer 来做聚合。

此外,Raftstore 还使用 async_io 来异步落盘 Raft 日志和 Raft 状态。这样,Raftstore 线程不被 io 阻塞,能够处理更多的 Raft 相关请求和日志。需要注意,这反过来可能会加重 PeerFsm、ApplyFsm 和网络的负担,对 CPU 的要求更高。

Why RockDB?

KVEngine 上的 WAL

目前,TiKV 上还是需要开启 KVEngine 对应的 Rocksdb 的 WAL。

Titan

Titan 是类似 WiscKey 的一个实现。

database paper 2

SST 的格式

key 一般是按照 (user_key, version) 的顺序来排布的,如果按照 (version, user_key) 的顺序来排布,则可能能快速读到最新版本。比如如下排布

1
2
3
a5 a4 a3 a1 b2 b1 c3 c1
=>
a5 b2 c3 a4 a3 c3 b2 a1 b1 c1

则可以从开头就读到最新的数据。或者,可以在比较新的 SST 中按照这种形式进行存储,这样当 read tso 满足的时候,则就可以快速读。特别地,甚至可以将最新版本的数据作为 SST 的一个 extras block 多存一份。
当然,对于 TiFlash 来说,因为有 delta-stable merge 的过程,也许这种方案性能不是很好,除非也改 delta。

TiKV 内存管理

TiKV 的资源管理模型

TiKV 读取

Cache

TiKV 处理读请求对 Block Cache 要求较高,较低的 Block Cache Hit 会导致读性能倍数下滑。Block Cache 需要占用接近一半的内存,但也需要保留一部分给系统作为 Page Cache,以及处理查询时的内存。

TiKV 没有默认开启 RocksDB 的 direct io,所以理论上 block cache 和 page cache 中的内容是可能有重复的。这里使用 block cache 可能是为了避免重复解压的开销。

在 raftstore-v2 中,多个 Rocksdb 实例共享一个 cache。

Lease

Raft learner

Coprocessor

Cop 可以支持写入么?

一个合理的优化是让 Cop 能支持 update where 类型的下推。这样就能免去从 TiKV 到 TiDB 的额外一次处理的开销。当然,对于 TiKV 本身来说还是需要将数据从 Rocksdb 读出来,在写回去,从而导致缓存被刷新的问题的。

Multi Raft 相关

关于 Raft 协议本身

线性一致读

Raft learner

Raft 状态的思考

RaftLocalState 中相比 Raft 协议多包含了 last_index 和 commit。其中 commit 可以避免重启后不能立即 apply 的情况。

存储 Raft 状态和 Region 状态

TiKV 使用 Raft Engine 存储 Raft 元信息和 Raft 日志。使用 KV Engine 存 Region 信息、Region Apply 信息和具体的 KV数据。

一个 Eager 落盘导致的问题

并不是所有时候,eager 落盘都能保证正确性问题。下面就是一个例子。
前面说过,在 TiKV 的实现中有两个 engine,KVEngine 存储 KV Meta 和 KV Data,RaftEngine 存储 Raft Meta 和 Raft Data。其中有一个 Apply Snapshot 的场景会同时原子地修改这两个 Engine,但可惜这两个 Engine 无法做到原子地落盘。并且因为两个 Engine 中都存有 Meta 和 Data,所以任意的先后顺序,都会导致数据不一致。这里的解决方式是将 RaftEngine 中的的 Raft Meta 写到 KVEngine 中,称为 Snapshot Meta。写入的时候,会先写 KVEngine,再写 RaftEngine。当在两个非原子写入中间出现宕机,从而不一致的时候,会使用 KVEngine 中的 Raft Meta 替换 RaftEngine 中的 Raft Meta。

Apply Snapshot阶段开始时,它会调用 clear_meta 删除掉 KV Meta、Raft Meta 和 Raft Data,但这个删除是不应该立即落盘的,而是在 WriteBatch 里面。在这之后,还会再往 WriteBatch 中写入 Snapshot Meta 等。这些写入会被一起发送给一个 Async Write 写入。我们的错误是,在实现删除 Raft Engine 数据时,并不是写 Write Batch,而是直接写盘。在 clear_meta 之后系统又立即宕机了。这样重启恢复后,就会看到空的 Raft Meta 和 Raft Data,但 KV Meta 却还存在。这是一个 Panic 错误,因为两个 Meta 不一致了。

这样的错误是难以调查的,我们可以加日志获得重启后从磁盘中读到的结果,但仍然不知道这个结果是如何被写入的。查的方式是脑补,也就是针对这样的场景,假设在不同时刻宕机,考虑会出现什么样的持久化状态。
这里,KV Meta 的落盘信息是有的,它可能是没清就宕机了,也可能是写完新的数据之后宕机的。考量这个可以看一些 Meta 信息有没有写入,比如我们发现 Snapshot Meta 并不存在,因此说明是前一种情况。既然如此,为什么 Raft Meta 和 Data 都没了呢?只能说明是 Raft 的清早了。

当然,这里有个迷惑点,就是 KV Meta 提示当前是在 Applying Snapshot 状态,而如果我们是第一种情况的话,这个 Applying 状态应该还没有被写入。这个原因是这个实例发生了多次重启,在 T-2 次启动后 Apply Snapshot 时,KVEngine 和 RaftEngine 都落盘成功了,但是后续的流程没进行下去就重启了。所以在 T-1 次启动会重新 Apply Snapshot,但这一次甚至没到落盘就重启了,而 Snapshot Meta 是金标准。然后就是我们见到的 T 次启动的错误。这启示我们不能只通过一个元数据来判断当前集群的状态,而是要检查所有的元数据,来石锤当前状态是如何得到的。

基于共识层之上的事务

关于 Percolator 的进一步论述

Multi Raft 的思考

在一个集群中,维护多个 Raft Group,相对于 Raft 本身来说,是一个全新的挑战。

Split/Merge 和事务

Split/Merge 和 Read

Split 和 Merge 会导致 Region 发生变化,自然也可能会影响读取。主要体现在下面几个方面:

  1. 影响 Lease 本身或者 Lease 续约
  2. 推高 RegionEpoch 从而导致 ReadIndex 失败

Split/Merge 和 Apply Snapshot

Multi Raft 实现的复杂度,很大程度在处理 Split/Merge 和 Apply Snapshot 的冲突上。

Split 和 Apply Snapshot 的冲突

我们需要处理一个 Region 上的 Follower 还没有执行到分裂为 Base 和 Derived 前,一份来自 Derived 的 Snapshot 已经被发过来的情况。这会产生 Region Overlap 的问题,在一些下层存储中会导致数据损坏。一种方案是在 Base 完成分裂前根据 Epoch 拒绝掉这些 Snapshot。

Merge 和 Apply Snapshot 的冲突

Merge 过程可以简单理解为下面几步:

  1. 调度 Source 和 Target Region 的各个 Peer,让它们对齐到同一个 Store 上。
  2. Source Peer 执行 Prepare Merge。
  3. Source Peer 等待 Target Peer 追完 Source Peer 的日志。
  4. Source Peer 对 Target Peer 去 Propose Commit Merge。
  5. Target Peer 执行 Commit Merge。

可能在下面一些阶段收到 Snapshot:

  1. Prepare Merge 结束
  2. Leader 上的 Commit Merge 结束,但 Follower 上的 Commit Merge 还没有开始

Split 和 Generate Snapshot 的冲突

主要指 Split 等会改变 RegionEpoch 从而导致 Snapshot 失效。

Raft Group 和 Data Range 的对应关系

TiKV 的 partitioned raft kv 特性

Raft 到底复制什么?

Raft 日志中到底记录什么呢?可以看下面的总结:

  1. TiKV
    TiKV 中 Raft 日志分为 Admin 和 Write。Admin 基本只和 Raft 和 Region 管理有关。Raft 指的是 Raft 的成员变更,比如 Add/Remove Voter/Learner,TransferLeader 等。Region 指的是管理的 key range 的元数据变更,比如 Split、Merge、数据校验等。
    Admin 和 Write 在一起构成全序关系,这个话题之前已经展开讨论过了。
    Write 包含 Put、Delete、DeleteRange 和 IngestSST,这些都是逻辑日志,或者说是不 aware 下层 rocksdb 的。
  2. OceanBase
    OceanBase 中复制的是 clog。从文档来看,它们复制的是物理日志。通过 replay clog,能够得到同样的 log 文件,其中记录的是 redo log。
    下面来自Oceanbase 文档

    OceanBase 数据库单台物理机上启动一个 observer 进程,有几万到十万分区,所有分区同时共用一个 Clog 文件,当写入的 Clog 文件超过配置的阈值(默认为 64 MB)时,会打开新的 Clog 文件进行写入。
    OBServer 收到的某个分区 Leader 的写请求产生的 Clog、其他节点 OBServer 同步过来的 Clog(存在分区同在一个 Paxos Group),都写入 Log Buffer 中,由单个 IO 线程批量刷入 Clog 文件。

  3. PolarDB
    在《PolarFS: An Ultra-low Latency and Failure Resilient Distributed File System for Shared Storage Cloud Database》中讲得比较清楚。
    PolarDB 的存储层基于 PolarFS,计算节点共享地访问这个存储层。PolarDB 中每个数据库对应 PolarFS 中的一个卷,每个卷由若干 Chunk 组成。不同于 TiKV 的 Region,这里 Chunk 大小为 10GB,而卷的大小在 10GB 到 100TB 之间,所以它们元数据节点的调度压力会小很多,并且所有节点的元数据都可以缓存在内存中。一个 Chunck Server 管理多个 Chunk,PolarDB 通过增加 ChunkServer 的数量来平衡热点。这里我觉得 TiKV 的 multi rocks 方案可能更好,因为它允许一个 hot region 被分裂。在 PolarDB 中,一个服务器上运行多个 ChunkServer,但每个 ChunkServer 对应一个专用的 SSD,并且绑定一个专用的 CPU 核心。
    一个 Chunk 由 64KB 大小的 block 组成。PolarFS 的 Raft 日志实际复制的是这些 block 的 WAL。
  4. Kudu
    Kudu 中复制的是逻辑日志。他们的观点是这样可以实现各个 Replica 在存储格式上是解耦的。

进一步讨论:日志和选举的关系

Raft 中的领导人完全性原则要求 Leader 必须拥有所有已提交的日志,这实际上是一个比较强的约束。在 Ongaro 等人对于 MultiPaxos 的描述中,可以发现该约束是可以被消减掉的,从而选举过程可以不关注日志的完备性。
在此基础上,可以让选举体现出其他的优先级。以 Ob 的 Palf 为例,它的“一呼百应”的方案,可以始终给距离自己最“近”的节点投票。而 Raft 选举的实质是谁状态更新,谁就更容易当选。这个方案目前来看,无论是否效果最优,但确实代价比较大。

有关 Raft 的日志和选举关系的讨论,可以见 Raft 算法介绍 中的“日志和选举”章节详细讨论。

进一步讨论:日志和事务的关系

将多个分区的写入统一到一个 Raft Group 中进行复制,应该是有利于事务的。因为如果一个事务跨 Region,就会是一个分布式事务,而如果只有一个 Raft Group,那么就不会涉及到跨 Region 的问题。

Mono LSM 和 Multi LSM 的考量

这里指的是不同的 Region 的数据是否 share 一个 LSM 树。我认为如果使用 range partition,那么 multi lsm 的策略是一个非常重要的优化。

多 Region 的调度

TiKV 的做法

PD 中有一些策略:

  1. balance-leader
    目的是均衡 client 请求服务的压力
  2. balance-region
    目的是分散存储压力,防止爆盘。因此会在磁盘剩余空间充足的时候使得使用量均衡,在不充足的时候使得剩余量均衡。
  3. hot-region-scheduler
    目的是分散热点 Region
  4. location-labels

实际上这些策略不太够,还需要:

  1. balance-leader-within-table
  2. balance-region-within-table

这些 balancer 可以基于 count,也可以基于 size。

事务相关

关于 Percolator 的进一步论述

Partitioned RaftKV 相关

TiKV 的 partitioned raft kv 特性

TiFlash 相关

Raft 和 CDC

CDC 的相关背景

TiCDC 提供的是 at-least-once 语义。具体来说,当 TiKV 或者 TiCDC 集群发生故障,则可能会发送相同的 DDL 或者 DML。

从实现上,TiCDC 中一个 changefeed 表示一个同步任务,它会被拆解为若干个 Task,分配给 TiCDC 集群的各个节点上的 Capture 进程进行处理。其中,每个节点上有一个 Capture 进程负责管理集群内部调度,称为 Owner Capture。Changefeed 的相关信息会被持久化到 pd 上。

在 TiFlash 建项,以及后续一段较长的时间中,CDC 存在一些问题:

  • 并发较低,changefeed 数量受限
    导致对多表的支持比较差
  • 吞吐量受限
  • 对 DDL 的支持较差
  • 集群的 scalability 受限

CDC 的同步遵照几个时间戳:

  • Resolve TS
    所有 TiKV 节点上的 Region leader 的 ResolvedTS 的最小值,被称为 Global ResolvedTS。TiDB 集群确保 Global ResolvedTS 之前的事务都被提交了。
    容易想到,通过维护所有 Region 上的所有事务的 start_ts 的最小值就可以达到这个目的。这里从 Leader 节点获取 start_ts。

    • TiCDC 上维护的 Table ResolvedTS
      和 TiKV 节点上这张表的各个 Region 的 ResolvedTS 的最小值是相同的。
    • TiCDC 上维护的 Global ResolvedTS
      各个 TiCDC 节点上的 Processor ResolvedTS 的最小值。
      由于 TiCDC 每个节点上都会存在一个或多个 Processor,每个 Processor 又对应多个 table pipeline。
      容易看出
      1
      Table ResolvedTS >= Global ResolvedTS
  • CheckpointTS
    TiCDC 认为在这个时间戳之前的数据已经被同步到下游系统了。
    容易看出

    1
    Table ResolvedTS >= Global ResolvedTS >= Table CheckpointTS >= Global CheckpointTS
  • Barrier TS
    一个 Barrier TS 被生成后, TiCDC 会保证只有小于 Barrier TS 的数据会被复制到下游,并且保证小于 Barrier TS 的数据全部被复制到下游之前,同步任务不会再推进。

TiCDC 中的 Sorter 负责排序。它使用 PebbleDB 来暂存 KV 数据。

为什么 TiFlash 实现 HTAP 基于 Raft?

Raft 帮助我们实现:

  1. LB
  2. HA
  3. Sharding

但是 TiFlash 只通过 Raft 同步各个表的 record 部分的数据。我们不同步索引,因为不需要。我们不同步 DDL 相关结构,因为并不是所有表都存在 TiFlash 副本。取而代之的是在解析失败,或者后台任务中,定期取请求 TiKV 的 Schema。

另一种强一致的方案是基于 CDC 和 safe TS,这样的方案理论上达不到和 Raft 一样的性能。这是因为类似 CDC 的方案的 safe TS 是基于表的,而 Raft 的 applied_index 是基于 Region 的。在一些场景下,如果一个 write 涉及到多个 Region,那么为了保证原子性,需要这些 Region 上的数据全部被写完,才能前进 ts,这会影响大事务的同步效率。另外,在读取时,也需要等待 safe TS 前进之后,才能读取。而基于 Raft 的方案只需要相关的 Region 的 applied_index 前进到 ReadIndex 就可以了。另外,CDC 也只保证单表事务。

架构

为什么在 TiSpark 之外还开发 TiFlash

TiSpark 直接操作 TiKV,绕过了事务层,可能产生一致性问题。
TiSpark 没有自己的列式存储,处理分析性查询并不占优势。

副本/节点数量和延迟的关系

不是。理论上是 1 副本的性能最好,但是考虑到高可用,通常建议 2 副本。

1 副本性能最好的原因是,DeltaTree 的 Segment 的粒度要显著比 TiKV 的 region 大,因此同一个 Segment 上会存在多个 Region。

考虑存在 4 个 Region,从 A 到 D,后面的数字表示 replica id。如果只设置一个副本,其分布类似

1
2
Store1: [A0, B0]
Store2: [C0, D0]

而如果设置两个副本,其分布类似

1
2
Store1: [A0, B0, C0, D0]
Store2: [A1, B1, C1, D1]

假如一个查询同时覆盖这 4 个 region,那么一副本的情况下,Store1 和 Store2 分别扫描自己的一部分数据就行了。而两副本的情况下,则可能扫描到多余的 Region 的数据。

这里可能会有 argument,因为 TiKV 的数据格式是有序的,同一个 table 一定是有空间局部性的。假如表 t1 对应了 A 和 B 两个 Region,那么就不可能扫到 C 和 D。但需要注意几个问题:

  1. 在 Filter 的情况下,我们并不是 full table scan,而是一段区间一段区间去读。
  2. TiFlash 的 IO 单位是 Pack,一个 Pack 对应大约 8192 行数据(跟随 ClickHouse 这么设置的)。这里 Pack 是不一定和 Region 边界对齐的。

因此,如果让副本更为分散,则可能导致读出更多的 Pack。

副本/节点数量和并发的关系

副本数越多,并发能力越强?但在基于 Raft 的分区策略下,并发能力是通过合理的 Sharding 来提升的。而具体到一个副本上是可以支持大量的并发查询的,并且我们也更容易对这些查询做 Cache,当然在 AP 场景下可能有限。

当然,需要考虑小表的情况,因为小表只有一个 Region。

DDL 如何同步?

TiDB 的 DDL 的优化点:

  1. 延迟 reorg 到读
    例如 add column 的 reorg 阶段实际上不会写入默认值,而是在读的时候才返回默认值。
  2. 以新增代替变更
    例如 alter column 只会扩大列的值域,比如 int8 扩大为 int64。如果涉及缩小至于或者改变类型,则会体现为新增一个 column,然后把老的 detach 掉。
    因此新的 Schema 能够解析老的 Schema。

TiFlash 上 DDL 的特点:

  1. TiFlash 只需要同步需要表的 DDL。
  2. TiFlash 只需要同步部分 DDL 类型,诸如 add index 等 DDL 并不需要处理,更没有 reorg 过程。
  3. 尽管 TiDB 将 schema 存在 TiKV 上,但 TiKV 是 schemaless 的。所以如果 TiFlash 只从 TiKV 同步数据,就会涉及解码等工作。

因此,TiFlash 有两种 DDL 同步方式:

  1. 定期拉取(一般是 10s)并更新
    根据 TiFlash 和 TiDB 上 version 落后的情况,可以分为拉 diff 和拉全量。
    该方式已经能解决大部分 drop table 的问题了。但通过该方式无法保证当前任意时间点上的 schema 一定和 TiDB 是一致的,所以一定存在解析失败的情况。
  2. 当解析 row 失败的时候更新 schema,称为 lazy sync

在更新之后,TiFlash 会自己维护一份 schema。

这里面存在的问题主要是两种 DDL 同步方式和实际 raft log 是异步的。因为 TiDB 和 TiFlash 的特点,这个异步是可以被处理的,并且尽可能去掉全序的依赖是很多系统的设计理念,所以这种做法本身也是挺好的,但其中 corner case 很多。例如:

  1. Schema 和 row data 中的列数对不上。这种情况无论是谁缺,至少可以通过拉一次 Schema 来解决。有些场景甚至可以不拉 schema。
  2. 某个列的类型变了
  3. 一张表 drop 后,TiKV 中就无法读取该表的 schema 了。如果在 drop 前有一条 add column,但 lazy sync 又没有读到,那么 TiFlash 就看不到。所以如果后续有一条 row 写入过来,TiFlash 就会丢弃这个 column。假如这个 table 被 recover 了,那么 TiFlash 就会读不到这个 column 的数据。
  4. 在一张表对应的 DeltaMerge 实例创建前,这张表就被 drop 掉了。在此之后,row 数据到来,并导致 DeltaMerge 实例被创建。

TiFlash 的高可用

对于复制自动机的系统,高可用主要取决于选举的速度。
对于 TiFlash 来说,它不参与选举,但选举本身同样会有影响,一方面是 ReadIndex,另一方面是无主的时候无法复制日志。但除此之外,TiFlash 自身的宕机和重启也影响高可用。因为一个批量查询会被下推给 tiflash,以避免影响 TP,如果此时 TiFlash 没追上,则查询会 hang 住。所以 TiFlash 的高可用还和追日志的规模有关。

Raft 共识层

有关 Learner Peer

Raft learner

有关 Learner Read

由 Follower Read 派生出来的 Learner Read 也让 TiFlash 成为一个强一致的 HTAP。
Raft learner

有关 lock

Bypass lock 机制

关于 Percolator 的进一步论述

Read through lock 机制

关于 Percolator 的进一步论述

Raft Log 的存储

存储 – KVStore

为什么在列存前还有一个 KVStore?

在 CStore 模型中,WS 和 RS 都是列存,WS 的数据通过 Tuple Mover 被批量合并到 RS 中。体现在 TiFlash 中,WS 是 DM 中基于 PS 的 Delta 层,而 RS 是 Stable 层。

除此之外,TiFlash 还有一个 KVStore,目的是:

  1. 保存未提交的数据,并实现 Percolator 事务的部分功能
    因为只有已提交的数据才会写入行存,为了和 Apply 状态机一致,所以未提交的数据同样需要持久化,因此引入 KVStore。
  2. KVStore 管控 Apply 进度,对 DM 屏蔽了上游。DM 可以异步落盘。日志复制的架构下,上游的落盘进度不能比下游更新,因为下游更新,重放是幂等的;而上游更新,会丢数据。

为什么不将未提交的数据直接写在列存中呢?

  1. KVStore 需要负责维护 apply 状态机
    当然我们可以将这一部分作为单独的 Raft 模块,所以这不是很 solid 的理由。
  2. KVStore 不仅是一个容器,还是 Percolator 事务的执行器
    例如,它需要维护当前 Region 上的所有 Lock。在一个查询过来时,需要检查该查询是否和 Lock 冲突,并尝试 resolve lock。而在列存中维护 lock cf 会很奇怪。
  3. 这意味着要执行近乎实时的行转列
    首先,如果存一些未提交数据在 KVStore 中,然后在提交时 batch 执行行转列,有可能可以只读取一次 schema 结构,减少开销。
    其次,TiDB 中存在乐观事务和悲观事务。如果使用乐观事务,并且冲突比较大,那么很可能 TiFlash 要花费大量时间在多余的行转列上。

实际上,在后续支持大事务的实践中,我们确实会进行一部分提前的行转列。但这是处于内存的优化,并且也存在很大的局限,例如暂时无法做到跨 Region Spill。

KVStore 的落盘模式相关问题

理论上 KVStore 也可以做到独立写盘,从而使得 DM 的落盘进度不会阻塞 Raft Log 的回收。缺点是会使 KVStore 完全变成上游,写链路更长。虽然我们底层用的 PS,Compaction 相对较少,但同样有写放大。但这目前也无法实现,因为:

  1. KVStore 落盘是全量的,KVStore 和 DM 的内存操作又绑在一块。
    这导致在落盘 KVStore 前必须先落盘 DM。并且整个过程还需要加自己的锁,否则会导致数据丢失,而加锁导致阻塞 Apply。特别在一些场景下,少量的 Raft Log 就会导致 KVStore 和 DM 的落盘,严重影响读取延迟。
  2. Raftstore V1 的 Apply 落盘又是同步的。
    在 Raftstore V1 中,写入的数据可能在操作系统的 Page Cache 中,也有可能被刷入了磁盘。如果是前者,那么会在 raftlog_gc 等地方被显式地 sync。但困难在于,V1 中无法精确获得这些时刻,从而进行通知。又因为 TiFlash 的状态不能落后于 Proxy,否则 Proxy 的 applied_index 可能比 KVStore 新从而丢数据。所以这里索性当做同步落盘处理,让 TiFlash 先落盘。即使 TiKV 重放,也是幂等的。代价是我们要劫持 TiKV 所有可能写 apply state 的行为,哪怕这个写不是 sync 写。后面会介绍我的一些异步落盘的想法。

一个优化方案是解耦 KVStore 和 DM 的落盘。也就是在 DM 落盘后,再清理掉 KVStore 中的数据。这需要将 Region 中的数据拆分成 KV 对落盘,但这会失去对 KV 对做聚合的能力,从而将顺序写转换为随机写,如果写入很密集,性能也许会比较差,所以这个在功能和性能上都依赖 UniPS。

另一种方案比较简单,也就是限制由 KVStore,实际上就是 Raftstore 发起的落盘,改为由 DM 发起。但这个方案并不感知 Raft Log 的占用,可能导致它膨胀。

前面提到异步落盘 KVStore 的问题,一个思路是落盘时使用过去的状态+当前的数据。但存在一些问题:

  1. 这个“过去的状态”也需要比 DM 的落盘状态要新,所以还是要先加锁获取 KVStore 状态,再无锁落盘 DM,再用旧状态落盘 KVStore。这样不能解耦和 DM 的落盘,但能够在落盘 DM 的时候无锁已经很好了。
  2. Split/Merge 或者可能 Apply Snapshot 改变了全局状态。这样的指令在 V1 中是不能被重放的,不然新 Split 出来的 Region 可能和重启前已经被 Split 和 Persist 出来的 Region 冲突。这样就需要在处理这些 Admin 指令的时候同步等待异步的 Persist 完成。其实更简单的方式是根据之前加锁获取的状态来推断有没有执行这些 Admin。
  3. 需要让 KVStore 支持其他命令的重放。目前来看,应该存在一些 corner case。
  4. 需要让 KVStore 通知 Proxy,当前落盘的 applied_index 并不是期望的 applied_index。这实际上破坏了 TiKV 的 MultiRaft 约束,更好的方式是拒接来自 Proxy 的落盘请求,然后从 KVStore 重新主动发起一个。
  5. 落盘 KVStore 同样需要加锁,从而阻塞 Raft 层的写入。

另一种方案是过去的状态和过去的数据。比如可以在 KVStore 在落盘时,新开一个 Memtable 处理新写入。此时需要处理新 Memtable 上的 Write 可能依赖老 Memtable 上的 Default 之类的问题。这样的好处是在落盘 KVStore 的时候都不需要加锁了。但是还存在两个问题:

  1. 在这前面需要落盘 DM,当然这个锁先前说了可以去掉。
  2. 如果写入很大,那么可能在旧的 Memtable 还没写完之前,新的 Memtable 就满了。这样还是 Write Stall。

如果希望彻底和 DM 解耦,就需要想办法保存上次 DM 落盘到现在落盘 KVStore 期间被写到 DMCache 上的数据。这是困难的。

KVStore 如何处理事务

在每一次 Raftstore 的 apply 写入时,会遍历所有 write 写入,并进行事务提交,也就是将数据从 KVStore 移动到 DeltaMerge。事务提交并不一定落盘,大部分情况是写在 DeltaMerge 的 DeltaCache 中的。
如果出现事务 rollback 回滚,则 TiKV 不仅会删除掉之前写的 default 和 lock,还会写一条 Rollback 记录,它也会被写到 Write CF 中,其用途是避免同 start_ts 事务再次被发起,client 需要用新请求的 start_ts。
可以看到,因为共识层的存在,TiFlash 无需处理事务 rollback 的问题。这也是 KVStore 存在的意义之一。

KVStore 的存储格式

是否直接用 protobuf 存储 Region?

protobuf 具有的几个特性让它不适合存储 Region:

  1. 较大的 size 下性能较差
  2. 不能只读取部分数据

是否使用 flag 存储 Region Extension?

https://github.com/pingcap/tiflash/issues/8590 不建议这样做。

Raft 机制带来的内存和存储开销

有没有可能 TiFlash 自己 truncate 日志呢?理论上 Learner 不会成为 Leader 从而发送日志,也不会处理 Follower Snapshot 请求。而 Raft 协议本身就是让每个节点自己做 Snapshot 然后 truncate 日志的。

我们在云上 TiFlash 做这样的称为 Eager GC 的优化,因为云上使用的 UniPS 对内存更敏感。PageDirectory 为每个 Page 占用大约 0.5KB 的内存。另一方面,UniPS 全部受我们控制,所以相比 Raft Engine 也更好做透明的回收。透明回收小于 persisted applied_index 的所有 Entry,如果 Raftstore 会访问已经被回收的 Entry,会给一个 Panic。

TiFlash 如何处理 Raft Snapshot?

  1. raftstore 执行 apply snapshot
  2. raftstore 将 snapshot 入队 region worker
  3. TiFlash 进行 Prehandle
  4. TiFlash 执行 apply snapshot data

为什么 TiFlash 不处理 DeleteRange?

TiKV 通过 DeleteRange 来删表。TiFlash 则是通过拉取 DDL,并确保已经过了 gc safepoint 后,才会物理删除表。

需要注意的是,除了删表之外,pd 可能从 TiFlash 调度走某个 Region,这也涉及删除操作。对于这样的操作,TiFlash 就得立即响应。

在 gc 时,在 write cf 上写一个 DEL 记录,也就是所谓的 tombstone key 是比较少见的。现在的做法是在 Compaction 的时候将这些 key filter 掉。
当然 DEL lock cf 是很常见的,这通常发生在:

  • 提交事务的时候,会将乐观锁替换为 write 记录。
  • 提交悲观事务的时候,会将悲观锁覆写为乐观锁。

重放

TiFlash 在启动时,会有个 WaitRegionReady 的过程,它会尽可能等所有的 Region 重放日志,从而让它们追上进度。这增加了 TiFlash 的启动时间,但也避免后续查询频繁报错。

文章中提到了一个写省略的方案,也就是对于一个 Page,重放的时候,并不直接 Apply,而是在真的要读的时候,“通过 Log 的按 Page 索引找到需要的 Log Record”去 Apply。目的是节省刷盘的开销,以及提升踢动的速度。

存储 – Proxy

为什么 Proxy 不能静态链接

之前有一些尝试,结论是需要通过一些比较 hack 的方式进行改名。

具体来说,就是:

  • 通过 nm -D --extern-only --defined-only 去获得 proxy 所有引用的外部符号,包括 C 库啥的。
  • 然后使用 objcopy --prefix-symbols=prefix_ proxy.so new_proxy.so 给 proxy 中所有的符号都加一个前缀。
  • 然后使用 objcopy --redefine-sym "{prefix}{i}={i}" 重命名第一步中的每个符号 i

关于 Proxy 的重构

我觉得其中一个非常重要的点是测试方案的改造。测试分为几个层面:

  1. Proxy 的 ut
    因为 Proxy 的逻辑是在 TiKV 逻辑之下的,所以这个 ut 也会包含 TiKV 的一部分代码,但具体执行的代码是受到控制的。我们通过 new-mock-engine-store 中的 mock cluster 模块去做这一点。
  2. Proxy 和 TiKV 的集成测试
    这个是主要的功能性测试。因为更上层的测试引入了 TiFlash,链路更长,更难诊断问题。
    因此提出了两个方案:
    • 第一个是 mixed mode 测试的方案
    • 第二个是引入了 new-mock-engine-store 去 mock TiFlash 部分的逻辑。
  3. Proxy + TiFlash 的 CI 测试
  4. Proxy + TiFlash 的 daily run 测试

Proxy + TiKV 的 mixed mode 测试的方案:

  1. 初始状态:code base 中全部为旧 Proxy 的代码逻辑,测试只包含旧 Proxy 从 TiKV migrate 来的测试 tests/tikv。
  2. 引入 new-mock-engine-store 和 tests/proxy 作为新 Proxy 的测试框架和内容,给它们打上 compat_new_proxy 这个 feature。
  3. 在迁移的初期,我们对 code base 中的修改打上 compat_new_proxy。在测试环境中,这些逻辑只对 tests/proxy 生效,因而可以验证迁移后的逻辑,并不影响 tests/tikv。
  4. 随着迁移的进行,Proxy 特有逻辑部分的测试被慢慢转移到 tests/proxy 中;而由于我们从 TiKV 中解耦了 Proxy 的特有逻辑,也可以逐步去除 tests/tikv 中手动适配的代码,使得它们恢复在 TiKV 中原来的样子。
  5. 在迁移的后期,我们已经可以将不少模块 checkout 回 TiKV 的代码了,在这些代码中保留 compat_new_proxy 就没有必要。因此我们删除 compat_new_proxy,对于残余模块中需要保持旧 Proxy 代码逻辑的部分,打上 compat_old_proxy 这个 feature。这样带上 compat_old_proxy,我们依然可以运行 tests/tikv 测试。
  6. 迁移结束意味着 Proxy 特有逻辑被彻底被移出 TiKV。最终我们得到了 tests/proxy 用于测试 Proxy 特有逻辑,而 tests/tikv 用于测试 TiKV 原始逻辑。

Proxy + TiFlash 的 mixed mode 测试方案:这个比较简单,主要就是选择一个 TiFlash 为旧版本,另一个为新版本,测试两个 TiFlash 是否行为一致。特别地,因为 TiFlash 的查询会轮询可用的存储,所以新老节点只要有不一致,就会被发现。

存储 – 列存

为什么 TiFlash 使用 DeltaTree 作为存储

目的是为了适应频繁的更新。TiFlash 采用类似 CStore的思路,引入了 PageStorage 这个对象存储。其中针对写优化的部分称为 Delta 层,类似于 RocksDB 的 L0,存储在 PageStorage 中。针对读优化的部分称为 Stable 层,以 DTFile 文件的形式存储,但文件路径在 PageStorage 作为 External Page 的形式维护。

存储模型的进一步讨论

和 StarRocks 的比较

例如可以将 update 操作分为 delete 和 insert 操作。查询时,同时查询 delete 和 insert,并决定最终的输出。StarRocks 使用这样的方式,他们指出 Delete+Insert 这样的模式有利于下推 Filter。StarRocks 据此实现了主键模型
这里需要区分他们的更新模型,也就是一种不支持 MVCC,始终返回最新数据的模型。这种模型应该就是一种类似 LSM 的方案,在 Compaction 的时候只保留一个版本。但是在查询的时候仍然需要 merge 多个版本,并且不支持下推 filter。
主键模型的优势就是查询时不需要 merge,并且支持下推 filter 和索引。这种方式主要是将主键索引加载到内存中,对于 Update 操作,通过主键索引找到记录的位置,写一个 Delete,然后再写一个 Insert。可以发现这种方案仍然是不支持 MVCC 的,我理解如果要支持 MVCC 那么 merge 可能是必然的。
此外,主键模型对内存是有开销的,我理解这个应该不是关键问题。首先,如果数据有冷热之分,可以持久化一部分主键索引到磁盘上。其次,这个场景在大宽表有优势。

来自 TiKV 的约束

从 Raft 层接入数据导致 TiFlash 的存储层的分区会收到 TiKV Key Format 的影响。例如尽管 TiFlash 的 Segment 和 TiKV 的 Region 并不对应,Segment 远大于 Region。但它们都被映射到同一个 Key Range 上。

这就导致 TiFlash 数据的物理排列一定是根据 TiKV 的主键有序的,TiFlash 无法自行指定主键。另外 TiFlash 本身也没有二级索引。

目前来自 TiKV 的约束有:

  1. MVCC 字段
    如果要和 TiDB 一起玩,就必须要支持 MVCC,不能只保存最新的版本。
  2. Unique 的主键

DM 的 Delta 层是如何实现的?

PageStorage 先前使用 Append 写加上 GC 的方案,但带来写放大、读放大和空间放大。因为这里 GC 采用的 Copy Out 的方式,所以理论上写放大和空间放大构成一个 trade off:

  1. 如果允许更少的有效数据和更多的碎片,那么空间放大更严重
  2. 否则,写放大更严重

旧的 PageStorage 主要存在下面的问题:

  1. GC 开销很大,因为需要遍历所有的 Version 或者说 Snapshot 才能得到可以被安全删除的数据。这样会产生很多额外的遍历。
  2. 每张表一个实例,如果存在很多小表,则会产生非常多的文件,甚至会用光 fd。
  3. 冷热数据分离。因为 meta 一般会被频繁更新,而实际上存在一些比较冷的 data。这会导致冷 data 阻碍 meta 进行 gc,这样会产生空间放大。到一定程度之后,又会触发 gc,进一步加剧问题。

在 SSD 盘上,随机写和顺序写的差距不大,原因是 FTL 会将随机写转换为顺序写,所以寻址相关的开销并不是很大。尽管如此,顺序写依然存在优势,首先顺序写可以做聚合,同样的 IOPS 写入带宽是会比随机写要大很多,然后是顺序写的 gc 会更容易。此外,因为变成随机读,性能会变差。特别是对类似 Raft Log 这样的 scan 场景。

新一版本的设计,TiFlash 会通过 SpaceMap 尽量选择从已有的文件中分配一块合适的空间用来写入 blob。当 blob 被分配完毕后,多个 writer 可以并发地写自己的部分。在写入 blob 完成后,会写 WAL 记录相关元信息。在这之后就可以更新内存中的数据。

PageStorage 在内存中维护一个 PageDirectory 去根据 page_id 找到对应 Page 的位置。

PageStorage 中使用 FullGC 去移除掉 valid rate 不高的 blob file,剩余的数据会被拷贝出来组成一个新 blob file。一次 GC 之后,epoch 自增。

DM 的 Stable 层是如何实现的?

Stable 层数据是按照 DTFile 的形式存储的,且每个 DTFile 中包含多个 Pack,一个 Pack 默认是包含 8192 行数据。但是相同主键不同版本的行都会写在一个 Pack 里面,目的之一是方便构造 Min-Max 索引。

我们为每个 Pack 维护一个 Min-Max 索引,这样可以在扫描的时候比较方便地跳过某些 Pack。理论上 Pack 越小,MinMax 索引的效率越好。因为更容易有 Pack 被整个选中,或者整个拒绝。

为什么只有 Min-Max 索引?

还有其他索引没有来得及实现。

但对于 BloomFilter 这是一个例外,因为按照目前 TiFlash 的查询 Pattern,布隆过滤器在大部分场景下优化不是很大,这样的查询其实是可以下推给 TiKV 来做的。只有在一些子查询里面如果出现点查,则可能会有作用。

为什么 DM 的 Stable 只有一层?

DM 的设计目标包含优化读性能和支持 MVCC 过滤。这就导致要解决下面的场景

TiFlash 有比较多的数据更新操作,与此同时承载的读请求,都会需要通过 MVCC 版本过滤出需要读的数据。而以 LSM Tree 形式组织数据的话,在处理 Scan 操作的时候,会需要从 L0 的所有文件,以及其他层中与查询的 key-range 有 overlap 的所有文件,以堆排序的形式合并、过滤数据。在合并数据的这个入堆、出堆的过程中 CPU 的分支经常会 miss,cache 命中也会很低。测试结果表明,在处理 Scan 请求的时候,大量的 CPU 都消耗在这个堆排序的过程中。

另外,采用 LSM Tree 结构,对于过期数据的清理,通常在 level compaction 的过程中,才能被清理掉(即 Lk-1 层与 Lk 层 overlap 的文件进行 compaction)。而 level compaction 的过程造成的写放大会比较严重。当后台 compaction 流量比较大的时候,会影响到前台的写入和数据读取的性能,造成性能不稳定。

为了缓解单层带来的写放大,DM 按照 key range 分成了多个 Segment。每个 Segment 中包含自己的 Stable 和 Delta。其中 Delta 合并 Stable 会产生一个新的 Stable。

为什么 TiFlash 按 TSO 升序存储?

TiKV 的 TSO 按照逆序存,有利于找新版本。
TiFlash 因为都是处理扫表,所以逆序的收益不是很大。ClickHouse 使用升序存储,所以 TiFlash 也沿用了升序。
但这里就导致在处理 Snapshot 写入的时候,需要读完每个 row key 的所有版本,并在一个 read 调用中返回给下游的 stream。

读取

为什么 TiFlash 没有 buffer pool

对于 AP 负载,扫表的数据规模很大,Cache 起不到太大作用。

但是,如果能 cache 住一些解压后的 page 数据,对于某些返回集合比较小的查询,性能提升也还是非常明显的。

Delta Index

Delta Index,有个简单的介绍 https://tidb.net/blog/7926aa79。

总的来说,Delta Index 主要是为了减少归并多个 CF Tiny 以及 Memtable 和 DMFile 所使用的内存和 CPU(例如维护二叉堆结构所需要的比较和移动操作),从而引入的一个结构。这个结构中记录了 Delta 层中读取的顺序,以及两次 Delta 层读取操作之间,需要读取的 Stable 数据的行数。所以,它有点像是一个 DP 或者记搜,记忆化的是归并排序的结果。

容易发现,如果 Segment 发生了 range 变化,或者发生了 delta-merge,则 Delta Index 就会失效,从而要被重新构建。

需要注意的是,Delta Index 本身并不包含 MVCC 语义,因此还是需要通过它把 Delta 数据逐个读出之后,才能应用 MVCC Filter。

第一个需要考虑的场景是 delta-merge,也就是所谓的 Major Compaction。这个操作需要将 tiered 的 delta 层和 stable 层加在一起重新生成一个 stable 层。所以在这个操作之后,delta 层清空,而 Delta Index 也随之清空。注意到这个操作是对整个表的一次全量遍历,并最终要生成一个有序的流。

第二个要考虑的是普通的读取,此时,数据并不需要按照 PK 被返回,顺序的要求只是为了便于 MVCC 处理。实际上我们需要的是 PK 成组,以及组内(关于时间戳)有序。

资源管理

弹性的资源管理和存算分离

在目前的计算机架构下,进程是资源的分配单位。这就意味着如果程序对除了 CPU 之外的某个资源的需求存在很大的弹性,那么就需要将这一部分单独剥离出来。
TiFlash Cloud 中就使用了存算分离,当然还使用了 OSS 等方案,我认为是正交的设计。

内存

历史上计算层出现过不少因为查询过大导致的 OOM,计算层通过 kill query 或者 spill 的方式进行解决。但存储层目前还缺少这块。理论上存储层的开销主要分为几类:

  1. Memtable
    包含 KVStore 的 RegionData 和 DeltaTree 的 DeltaCache。
    这类场景下,OOM 主要发生在大事务场景。
  2. Cache
    主要用来服务计算节点,列存主要是扫表,所以没有做 Block cache 或者 row cache。
  3. 索引
    包含 DeltaTree 的 DeltaIndex,PageStorage 的 PageDirectory 等。
  4. Compaction 相关,比如 delte merge 等
  5. 行转列相关
  6. 系统的 Page Cache

在一些场景下,因为存储层和计算层并不互相感知,会导致存储层会被计算层的大任务干到 OOM 或者报异常。而实际上这些任务可以被 kill,stall 或者通过 kill query 抢占计算层的内存。

因此,在 TiFlash 侧实现一个统一的内存管理还是有必要的。

空指针

严格来讲避免空指针也不完全算是内存管理。但确实是工作中遇到的一个比较关键的问题。在 分布式架构和高并发相关场景 这篇文章里面讨论。

线程

IO

CPU 和 off-CPU

CPU Profiling 经验之谈

副本管理

TiFlash Cloud

快速扩容(FAP)

TiFlash 的快速新建副本(FAP)特性

使用 UniPS 替换 RaftEngine

目前 TiKV 使用 engine_traits 描述了一个可以用来作为 raftstore 的存储的 engine 所需要的接口。这些接口基本是基于 RocksDB 而抽象出来的。因此 UniPS 需要模拟出其中关键的特性,例如 WriteBatch 等。

UniPS 的性能劣于 RaftEngine,写入延迟大约是两倍。另外,scan 性能预期也比较差,但是仍有不少优化空间。

另外一点是 UniPS 目前不支持 Delete Range,所以在大批量清理 Raft 日志的时候,我们的 WriteBatch 通常会很大,从而减少

为什么 TiFlash Cloud 目前是两副本?

目前快速恢复还是实验状态。TiFlash 重启后也需要进行一些整理和追日志才能服务,可能影响 HA,这些需要时间优化。尽管如此,快速恢复依然是一个很好的特性,因为:

  1. 快速恢复在 1 wn 下,可以从本节点重启,减少 TiKV 生成 Snapshot 的负担。而这个负担在 v1 版本的 TiKV 上是比较大的。
  2. 减少宕机一个节点恢复后,集群恢复到正常 2 副本的时间。

因为基于 Raft,所以本地数据的丢失只会导致从上一个 S3 Checkpoint 开始回放。如果只有一个存储节点,会失去 HA 特性。

S3 在 TiFlash Cloud 中起到什么作用?

  1. TiFlash Cloud 会定期上传 Checkpoint 到 S3 上,Checkpoint 是一个完整的快照,可以用来做容灾。即使在存储节点宕机后,其上传的那部分数据依然可以被用来查询,可能只能用来服务 stale read?
  2. TiFlash 计算节点可以从 S3 获得数据,相比从存储节点直接获取要更为便宜。存储节点只需要提供一些比较新的数据的读取,减少压力。
  3. 快速扩容逻辑可以复用其他存储节点的数据,此时新节点并不需要从 TiKV 或者其他 TiFlash 获得全部的数据。副本迁移同理,不需要涉及全部数据的移动。

尽管如此,S3 并不是当前 TiFlash 数据的全集。本地会存在:

  1. 上传间隔时间内,还没有上传到 S3 的数据。
  2. 因为生命周期太短,在上传前就被 tombstone 的数据。
  3. 尚在内存中的数据。

S3 vs EBS

对于 S3 而言:

  1. 具备 99.999999999% 的持久性和 99.99% 的可用性。
    也就是说一天中的不可用时间大约在 9s 左右。

定价

  1. PUT/POST/LIST/COPY 0.005
  2. GET/SELECT 0.0004
  3. 存储每 GB 0.022 USD 每月

可以看到,S3 的定价相比 EBS 要便宜不少。此外,从灾备上来讲,使用 EBS 可能需要为跨 AZ 容灾付出更多的成本,而 S3 可以实现跨 AZ 容灾。

当然 S3 也有缺陷,比如访问延迟比较高。

Reference

  1. https://docs.pingcap.com/zh/tidb/stable/troubleshoot-hot-spot-issues
  2. https://www.infoq.com/articles/raft-engine-tikv-database/ RaftEngine
  3. https://www.zhihu.com/question/47544675 固态硬盘性能
  4. https://docs.pingcap.com/zh/tidb/stable/titan-overview Titan 设计
  5. Fast scans on key-value stores
  6. https://cn.pingcap.com/article/post/3946.html PingCAP 的早期技术分享