一些常见问题的思考,这些问题是我瞎想出来的。
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 数量的讨论
较小的 Region 的好处:
- 每个 Region 中较低的并发
- 更加快速的调度
较大的 Region 的好处:
- Placement Driver 的压力变小
- CompactLog、Heartbeat 等网络开销变小
Raft 存储
原来 TiKV 使用 RocksDB 存储 Raft Log 和相关 Meta,存在几个问题:
- WAL + 实际数据,需要写两次盘。
- 数据变多,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 的要求更高。
日志和数据分离存储
Titan
Titan 的思路是将 RocksDB 中的 value 拿出来存,减少 Compaction 对 CPU 和 IO 的开销,但会带来空间放大。并且数据局部性差,所以范围查询性能较差。
Titan 将这些大 value 有序地存放在一些 blob file 中,并且保存了 value 对应的 user key 用来反查 RocksDB。反查的原因是 blob file 本身需要 gc,所以要通过 user key 来查询是否过期,这会带来一些写放大。
Titan 有两种 gc 策略:
- 定时 rewrite blob file
监听每次 Compaction 事件,从而维护每个 blob 文件中无效数据的大小。每次重写 invalid 率最高的几个文件,并更新回 RocksDB。旧的文件需要确保不再有 Snapshot 引用才可被删除。 - 在 LSM-tree compaction 的时候同时进行 blob 文件的重写
也就是在 Compaction 的同时写到一份新的 blob 文件中。因为不需要的 kv 会在 Compaction 的时候被过滤掉,也就相当于自动完成了 gc。
这种方案要求 blob 文件也需要伴随着 SST 进行分层,从而带来写放大。并且也有不小的空间放大。因此,该策略只对最下面两层生效。
TiKV 读取
TiDB 支持多种读取方式,例如最近 Peer、Leader、Follower、Learner、自适应等多种模式,这些依赖于 Follower Read,在这之前都需要从 Raft Leader 读取。
TiKV 处理读请求对 Block Cache 要求较高,较低的 Block Cache Hit 会导致读性能倍数下滑。Block Cache 需要占用接近一般的内存,但也需要保留一部分给系统作为 Page Cache,以及处理查询时的内存。
不同的压缩方式,对 CPU 的压力不同。
共识层和事务层的关系
TiKV 的共识层在事务层之下。在事务 Commit 之前的很多数据也会被复制到多数节点上,这产生了一些写放大。但也需要注意其带来的好处:
- 共识层实际为 Percolator 提供了类似 BigTable 的存储。
首先提供了外部一致性。
然后提供了 PUT default/PUT lock 和 PUT write/DEL lock 的原子性写入。
当然,这里要先读后写,可能会有 Write Skew。 - 共识层本身也可以作为一个 Raw KV 对外服务。
- Percolator 事务的定序依赖于存储于共识层上 lock 中的时间戳。
当然这也存在一个 argue 点,因为 Raft Log 本身也是 total order 的。看起来会有一些冗余。
特别地,在 CDC 服务和 TiFlash 中,我们实际上不会处理未 Commit 的数据。
Multi Raft 相关
Raft 状态的思考
RaftLocalState 中相比 Raft 协议多包含了 last_index 和 commit。其中 commit 可以避免重启后不能立即 apply 的情况。
Multi Raft 的思考
Split/Merge 和 Apply Snapshot
Multi Raft 实现的复杂度,很大程度在处理 Split/Merge 和 Apply Snapshot 的冲突上。
Partitioned RaftKV 相关
和 Mono-store RaftKV 的兼容性问题
新架构简化了 Snapshot 的生成和注入流程:
- 在生成时,只需要对当前 Region 对应的 RocksDB 做一个 Snapshot 就行。这个 Snapshot 包含的数据可以新于 Raft Local State。
- 在注入时,只需要重命名 RocksDB 文件夹即可。不需要处理 range overlap 的问题。因此不需要引入单线程的 region worker。
因此 Mono-store RaftKV 需要处理下列问题:
- RocksDB 数据和 Raft 状态不一致。
- Snapshot 的 Range 可能和其他本地 Region Overlap。
不光是 Snapshot,在 Partitioned RaftKV 中,Region Peer 之间也可能互相 Overlap。所幸这个场景只会出现在 BatchSplit 和调度 Peer 发生冲突的情况下。
在新架构中,Apply 的落盘也实现了异步化,现在下层引擎可以选择在任意时刻落盘数据,并且在落盘完毕后通知 raftstore。这对 TiFlash 来说是一件好事,我们可以由此来让 KVStore 的落盘不再阻塞。
采用更大的 Region 的性能影响
- 可以采用 Parallel Raft 的方式实现并行 Apply。
- 单个 Region 的 Apply 压力会增大,但是下层 RocksDB 的负担减轻了。相比于单个实例的 RocksDB,新架构的层数更少,并且并发写入也更少。后续还可以尝试支持多盘部署。
另一个考量点是如果集群中出现很多小表,那么大 Region 的效果不能完全展示:
- 因为编码的问题,table 编码不相邻的表不能被合并到同一个 Region 中。
- 相邻的 table 合并会给 TiFlash 带来不少问题。例如如果给一些小表添加 TiFlash 副本,并且这个小表被合并到一个大 Region 中,那么发来的 Snapshot 可能非常大,并且包含了大量 TiFlash 不需要的数据。此外,TiFlash 本身的存储引擎也需要做出调整。
我觉得可能 Spanner 的架构会更好一点。也就是说:
- 依然是一个 Region 一个 Raft Group,但这个 Region 不再和某个 Key range 绑定。
- 一个 Region 下可以被调度多个 Key range。例如有局部性的 Key range 可以被调度在一起,或者处于打散负载的目的可以将 Key range 进行随机的分布。
TiFlash 相关
架构
为什么 TiFlash 实现 HTAP 基于 Raft?
Raft 帮助我们实现:
- LB
- HA
- Sharding
但是 TiFlash 只通过 Raft 同步各个表的 record 部分的数据。我们不同步索引,因为不需要。我们不同步 DDL 相关结构,因为并不是所有表都存在 TiFlash 副本。取而代之的是在解析失败,或者后台任务中,定期取请求 TiKV 的 Schema。
为什么在 TiSpark 之外还开发 TiFlash
TiSpark 直接操作 TiKV,绕过了事务层,可能产生一致性问题。
TiSpark 没有自己的列式存储,处理分析性查询并不占优势。
TiFlash 是副本越多越好么?
不是。理论上是 1 副本的性能最好,但是考虑到高可用,通常建议 2 副本。
1 副本性能最好的原因是,DeltaTree 的 Segment 的粒度要显著比 TiKV 的 region 大,因此同一个 Segment 上会存在多个 Region。
考虑存在 4 个 Region,从 A 到 D,如果只设置一个副本,其分布类似
1 | Store1: [A0, B0] |
而如果设置两个副本,其分布类似
1 | Store1: [A0, B0, C0, D0] |
假如一个查询同时覆盖这 4 个 region,那么一副本的情况下,Store1 和 Store2 分别扫描自己的一部分数据就行了。而两副本的情况下,则可能扫描到多余的 Region 的数据。
有一些人还会觉得副本数越多,并发能力越强。但在基于 Raft 的分区策略下,并发能力是通过合理的 Sharding 来提升的。而具体到一个副本上是可以支持大量的并发查询的,并且我们也更容易对这些查询做 Cache,当然在 AP 场景下可能有限。
存储
为什么在列存前还有一个 KVStore?
在 CStore 模型中,WS 和 RS 都是列存,WS 的数据通过 Tuple Mover 被批量合并到 RS 中。体现在 TiFlash 中,WS 是 DM 中基于 PS 的 Delta 层,而 RS 是 Stable 层。
TiFlash 使用 KVStore,目的是:
- 保存未提交的数据。
因为只有已提交的数据才会写入行存,为了和 Apply 状态机一致,所以未提交的数据同样需要持久化,因此引入 KVStore。 - KVStore 管控 Apply 进度,对 DM 屏蔽了上游。DM 可以异步落盘。日志复制的架构下,上游的落盘进度不能比下游更新,因为下游更新,重放是幂等的;而上游更新,会丢数据。
理论上 KVStore 也可以做到独立写盘,从而使得 DM 的落盘进度不会阻塞 Raft Log 的回收。缺点是会使 KVStore 完全变成上游,写链路更长。虽然我们底层用的 PS,Compaction 相对较少,但同样有写放大。但这目前也无法实现,因为:
- KVStore 落盘是全量的,KVStore 和 DM 的内存操作又绑在一块。
这导致在落盘 KVStore 前必须先落盘 DM。并且整个过程还需要加自己的锁,否则会导致数据丢失,而加锁导致阻塞 Apply。特别在一些场景下,少量的 Raft Log 就会导致 KVStore 和 DM 的落盘,严重影响读取延迟。 - Raftstore V1 的 Apply 落盘又是同步的。
在 Raftstore V1 中,写入的数据可能在操作系统的 Page Cache 中,也有可能被刷入了磁盘。如果是前者,那么会在 raftlog_gc 等地方被显式地 sync。但困难在于,V1 中无法精确获得这些时刻,从而进行通知。又因为 TiFlash 的状态不能落后于 Proxy,否则 Proxy 的 applied_index 可能比 KVStore 新从而丢数据。所以这里索性当做同步落盘处理,让 TiFlash 先落盘。代价是我们要劫持 TiKV 所有可能写 apply state 的行为,哪怕这个写不是 sync 写。后面会介绍我的一些异步落盘的想法。
一个优化方案是解耦 KVStore 和 DM 的落盘。也就是在 DM 落盘后,再清理掉 KVStore 中的数据。这需要将 Region 中的数据拆分成 KV 对落盘,但这会失去对 KV 对做聚合的能力,从而将顺序写转换为随机写,如果写入很密集,性能也许会比较差,所以这个在功能和性能上都依赖 UniPS。
另一种方案比较简单,也就是限制由 KVStore,实际上就是 Raftstore 发起的落盘,改为由 DM 发起。但这个方案并不感知 Raft Log 的占用,可能导致它膨胀。
前面提到异步落盘 KVStore 的问题,一个思路是落盘时使用过去的状态+当前的数据。但存在一些问题:
- 这个“过去的状态”也需要比 DM 的落盘状态要新,所以还是要先加锁获取 KVStore 状态,再无锁落盘 DM,再用旧状态落盘 KVStore。这样不能解耦和 DM 的落盘,但能够在落盘 DM 的时候无锁已经很好了。
- Split/Merge 或者可能 Apply Snapshot 改变了全局状态。这样的指令在 V1 中是不能被重放的,不然新 Split 出来的 Region 可能和重启前已经被 Split 和 Persist 出来的 Region 冲突。这样就需要在处理这些 Admin 指令的时候同步等待异步的 Persist 完成。其实更简单的方式是根据之前加锁获取的状态来推断有没有执行这些 Admin。
- 需要让 KVStore 支持其他命令的重放。目前来看,应该存在一些 corner case。
- 需要让 KVStore 通知 Proxy,当前落盘的 applied_index 并不是期望的 applied_index。这实际上破坏了 TiKV 的 MultiRaft 约束,更好的方式是拒接来自 Proxy 的落盘请求,然后从 KVStore 重新主动发起一个。
- 落盘 KVStore 同样需要加锁,从而阻塞 Raft 层的写入。
另一种方案是过去的状态和过去的数据。比如可以在 KVStore 在落盘时,新开一个 Memtable 处理新写入。此时需要处理新 Memtable 上的 Write 可能依赖老 Memtable 上的 Default 之类的问题。这样的好处是在落盘 KVStore 的时候都不需要加锁了。但依然,在这前面需要落盘 DM。
如果希望彻底和 DM 解耦,就需要想办法保存上次 DM 落盘到现在落盘 KVStore 期间被写到 DMCache 上的数据。这是困难的。
Raft 机制带来的内存和存储开销
有没有可能 TiFlash 自己 truncate 日志呢?理论上 Learner 不会成为 Leader 从而发送日志,也不会处理 Follower Snapshot 请求。而 Raft 协议本身就是让每个节点自己做 Snapshot 然后 truncate 日志的。
我们在云上 TiFlash 做这样的优化,因为云上使用的 UniPS 对内存更敏感。PageDirectory 为每个 Page 占用大约 0.5KB 的内存。另一方面,UniPS 全部受我们控制,所以相比 Raft Engine 也更好做透明的回收。我们透明回收小于 persisted applied_index 的所有 Entry,如果 Raftstore 会访问已经被回收的 Entry,会给一个 Panic。
为什么 TiFlash 使用 DeltaTree 作为存储
目的是为了适应频繁的更新。我们采用类似 CStore的思路,引入了 PageStorage 这个对象存储。其中针对写优化的部分称为 Delta 层,类似于 RocksDB 的 L0,存储在 PageStorage 中。针对读优化的部分称为 Stable 层,以 DTFile 文件的形式存储,但文件路径在 PageStorage 作为 External Page 的形式维护。
DM 的 Delta 层是如何实现的?
PageStorage 先前使用 Append 写加上 GC 的方案,但带来写放大、读放大和空间放大。
在 SSD 盘上,随机写和顺序写的差距不大,原因是 FTL 会将随机写转换为顺序写,所以寻址相关的开销并不是很大。尽管如此,顺序写依然存在优势,首先顺序写可以做聚合,同样的 IOPS 写入带宽是会比随机写要大很多,然后是顺序写的 gc 会更容易。此外,因为变成随机读,性能会变差。特别是对类似 Raft Log 这样的 scan 场景。
新一版本的设计,TiFlash 会通过 SpaceMap 尽量选择从已有的文件中分配一块合适的空间用来写入 blob。当 blob 被分配完毕后,多个 writer 可以并发地写自己的部分。在写入 blob 完成后,会写 WAL 记录相关元信息。在这之后就可以更新内存中的数据。
为什么 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 没有 buffer pool
对于 AP 负载,扫表的数据规模很大,Cache 起不到太大作用。
TiFlash Cloud
快速扩容 What & why?
目的:
- 减少 TiKV 生成、传输和 TiFlash 接收、转换 Snapshot 的开销。
- 利用如 S3 的特性,减少无效数据的传递。
- 提高副本迁移,特别是单副本迁移的效率。
要点:
- 使用 PageStorage 替换 RaftEngine。
- 副本选择和由 Learner 管理的副本创建。
- 注入数据。
- 对旧版本数据的清理。
使用 UniPS 替换 RaftEngine
UniPS 的性能劣于 RaftEngine,写入延迟大约是两倍。但是仍有不少优化空间。
为什么 TiFlash Cloud 目前还是两副本?
目前快速恢复还是实验状态。TiFlash 重启后也需要进行一些整理和追日志才能服务,可能影响 HA,这些需要时间优化。尽管如此,快速恢复依然是一个很好的特性,因为:
- 快速恢复在 1 wn 下,可以从本节点重启,减少 TiKV 生成 Snapshot 的负担。而这个负担在 v1 版本的 TiKV 上是比较大的。
- 减少宕机一个节点恢复后,集群恢复到正常 2 副本的时间。
S3 在 TiFlash Cloud 中起到什么作用?
- TiFlash Cloud 会定期上传 Checkpoint 到 S3 上,Checkpoint 是一个完整的快照,可以用来做容灾。即使在存储节点宕机后,其上传的那部分数据依然可以被用来查询。
- TiFlash 计算节点可以从 S3 获得数据,相比从存储节点直接获取要更为便宜。此外,如果存储节点承受过多的读请求,对存算分离的性能有影响。
- 快速扩容逻辑可以复用其他存储节点的数据,此时新节点并不需要从 TiKV 或者其他 TiFlash 获得全部的数据。副本迁移同理,不需要涉及全部数据的移动。
尽管如此,S3 并不是当前 TiFlash 数据的全集。本地会存在:
- 上传间隔时间内,还没有上传到 S3 的数据。
- 因为生命周期太短,在上传前就被 tombstone 的数据。
- 尚在内存中的数据。
因为基于 Raft,所以本地数据的丢失只会导致从上一个 S3 Checkpoint 开始回放。如果只有一个存储节点,会失去 HA 特性。
对于 S3 而言:
- 具备 99.999999999% 的持久性和 99.99% 的可用性。
也就是说一天中的不可用时间大约在 9s 左右。
定价:
- PUT/POST/LIST/COPY 0.005
- GET/SELECT 0.0004
- 存储 每 GB 0.022 USD 每月
一些元设计
我们往往在异步线程中预处理一些对象,并最后将它们 link 到主干上,或者从主干上 unlink 对象,并最后 gc 掉。如果这中间发生重启,那么这些对象就会游离在存储中。如何区分被 unlink 但尚未被回收的对象,和刚被创建但还没有被 link 的对象呢?这里的通用思路是在重启后对比主干和存储中的对象,所有不出现在主干中的对象就需要被删掉。然后依赖重放来解决第一种情况。
Reference
- https://docs.pingcap.com/zh/tidb/stable/troubleshoot-hot-spot-issues
- https://www.infoq.com/articles/raft-engine-tikv-database/ RaftEngine
- https://www.zhihu.com/question/47544675 固态硬盘性能
- https://docs.pingcap.com/zh/tidb/stable/titan-overview Titan 设计
- Fast scans on key-value stores