在 PingCAP 的一些技术挑战

2018-06-02

事务优化

ACID 事务支持是 TiDB 的基础,也是核心竞争力。做 AP 强调性能,即使不保证强一致性至少还能用。TP 里面,做核心的交易业务,不保证事务谁敢用?涉及钱的事情,事务没做好谁敢用?虽说是 TiDB 号称是 HTAP 的数据库,如果打分,事务这块必须是 1,其它的亮点做的好是在后面加 0。如果 1 没做好,那就 0 分了。

TiDB 的事务模型是基于 Percolator 做的。模型方面能不能优化,去掉中心化时钟? 这个模型会需要从 pd 取一个全局的时间戳,这需要走一次网络交互,会增加事务延迟。设计之初我们假定,同机房内部网络来回一次就 0.5ms,拿一次 timestamp 几乎全落在 1ms 以内。但是后来发现,还是有很多场景这个迟延是很难严格保证的。比如之前我写过受到机器负载的影响,Go 在 runtime 的调度耗时,再比如,跨机房了,网络天然慢了怎么办?那么,能否在事务模型上修改,不去取 timestamp 呢?

锁冲突的处理策略能否优化呢? 现在 TiDB 的冲突处理其实是乐观锁策略,大家都会往 TiKV 存储里面写数据,直到发现冲突了,会自己 abort 掉。实际上,这种 abort 的代价是非常高的,在冲突严重的场景十分低效,相当于冲突时做了很多事情,后面这些事情不仅白做了,而且还要额外的撤销操作:写了一半的数据和锁需要清理掉。那么,我们可不可以做一个排队机制呢? 让可能冲突的事务都去排队,减少乐观锁的冲突开销。在分布式的场景下,如何识别所有节点在哪些 key 上面冲突呢?

大小事务优先级怎么做。我们假设事务有些很大,有些比较小。是不是会发生,越大的事务越可能出现冲突概率越高?这种情况下,大的事务会不会多次的失败重试,失败重试?另外,会不会大的事务锁住大量的 key,导致小的事务受影响。到底怎么样做才是更公平的呢,是否应该设置合理的大小事务的优先级策略?

隔离级别怎么做?当前 TiDB 的默认的事务隔离级别是 SI,如果想做 SSI 需要使用 select for update 语句。有没有别的方式实现?默认想做成 SSI 该如何实现,会引入哪些性能影响?还有,假设是 RC 又可以实现什么优化? 还有一些更细分的隔离级别是否需要加入考虑? 这些都是值得探讨的。

事务为什么需要两阶段提交,一阶段提交行不行?我们是否可以优化数据的分布,将事务所涉及的资源,全部都调度到一个物理的机器上面,然后执行一阶段提交呢。`整个事务实现的执行链很长,性能上还有哪些优化能做?举个例子,我曾经发现一个普通的 SQL 语句, update 带索引的列,竟然花掉了近 20ms。首先它是既需要更新索引又需要更新数据的。然后,一个普通的 update 语言,它可能需要先读后写。读需要先向 pd 拿 timestamp,走一次网络,然后向 TiKV 的 raft leader读,走一次网络。再做写的时候,做两阶段提交要走两次网络。而 raft 那边请求又还涉及,多节点的消息同步网络和写磁盘开销。突然想到,一条SQL语句,从发送给数据库到返回给客户端,整个执行流程的所有细节,是一个很好面试题。

再比如多版本数据的 GC 策略。数据 GC 是事务必须考虑的。在 MVCC 之上,不停的写入,同个条记录就会有许多版本。这些旧版本是需要清理掉的。同时,还有残留的锁也是要清理的。之前就出现过很多问题。像数据版本太多,会导致读的速度变慢,因为 rocksdb 那边要顺着 MVCC 不同版本的数据扫,直接找到需要的那个。如果版本多了,扫的 key 就变多,性能就下降。删除数据是很影响性能的,极端的场景,不停地做插入和删除,GC 的速度跟不上怎么办? 清锁的细节如何处理,异步清锁的操作。并发 GC 是否安全? 遇到大事务要读老的数据,GC 应该如何处理,如果清理,读的事务执行就要出错。不清理,如果一直有大事务会把 GC 卡住怎么办?

细节很多很多,反正都是做事务优化的人必须考虑到的。更重要的事情,是事务出了 bug 怎么办。上层业务写错了,该重试没重试,或者不该重试却重试了,把错误的数据写进去,或者数据索引不一致了。又或者,下层存储把数据写丢了,却告诉调用者成功了。做事务需要非常强大和全面的分析能力,抗压能力。因为几乎所有的问题,都可以引发事务的问题,这边做事务相关,又称背锅侠。

如何做资源隔离

这也是比较大的一个话题。TiDB 号称是一个 HTAP 混合的数据库。混合嘛,即做 AP 又做 TP,那么就有用户问了,同时在 TP 的集群上面,直接跑 AP 做数据分析,会不会影响 TP 的响应呢? 那我只能如实回答,当然会呀! TiDB 是有一定的分析能力,但是目前阶段还没有一些纯粹 AP 的引擎强大。另外,AP 会吃掉机器大量的资源,同时在执行的线上业务肯定要受到影响。

所以问题来了,怎么样调度,使得 AP 和 TP 互不影响呢?或者至少影响尽量地小。TiDB 是无状态的,可以把节点实例独立出来,AP 和 TP 分开。在 TiKV 那边,就没法在物理层面划分了。我们能不能给不同的事务类型,加上不同的优先级和资源分配策略。SQL 一个特别复杂的东西,一条 SQL 语句与另一条 SQL 语句之间,需要的计算资源几乎不可同日而语。到底需要占用多少资源,这个有没有办法量化?它会读多少条数据,写多少磁盘,吃多少 CPU 和 IO,内存占用是怎么样的,这个能否预测和计算? 如果能够预测出来,这是一个消耗资源的请求,那么能不能根据当前系统负载去做一些处理,比如限制它使用的资源量,内存不够先 dump 出去。

数据库上云是一个战略方向,好,问题来了,多租户该怎么做。我们能不能做到用户无感知,能不能只部署一套了,按 namespace 卖? 这就需要最好是懂容器,k8s 的人交流探讨。

JIT 怎么做

如何让生成的查询执行得更快呢? 查询优化是一个很大的领域,虽然 TiDB 已经做了不少工作了,但是还有更多的事情其实是空白的。就比如 JIT,直接将查询计划,生成到原生代码,这是很多数据库都已经实现了的。

我们要解决一个,如何让 Go 和 Rust 共用一份 JIT 的代码。Go 带 runtime 的语言,中间如何调用 C 或者其它语言的库。

涉及到的重构也会有点多,volcano 模型需要在代码层面做哪些调整,推和拉的不同模式会如何影响到执行效率,做向量化在 TP 里面的收益如何。

这中间的路线该如何走,需不需要使用 LLVM 呢。这都是很有意思并且具有挑战的事情。这里面自然也涉及了不少汇编和编译器方面的东西,所以,做编译器的人,也是可以转数据库的。

分析定位问题的能力

不是开玩笑,这真的是很有挑战的事情,而且对综合素质要求相当高,如果展开来说,是一个很大的话题。

监控,性能,日志,这都是很重要的环节,千万不能小瞧的环节。

怎么样做监控,怎么样分析性能,怎么样去发现问题的手段。很多情况下,程序的日志是拿不到的,那能够依赖的就只有监控了。除了直观的系统本身的监控,还有一些额外的信息,比如关于机器情况。网络,系统调用,锁,上下文切换,TCP的协议栈,内核调参各种问题。perf,火焰图,Go 语言本身的相关工具等等。程序崩溃了,需要收集哪些信息。dmsg 怎么看,OOM 问题如何查,gdb 去调 core 文件等等。

每次出错,其实都要去反思和总结,查问题的时候根据什么线索,做了什么推理,最后是用什么方式查到问题的。如果走了弯路,下一次如何避免这种弯路。这个过程中,哪些地方卡住过,缺了什么工具? 监控,日志,有哪些是该记录没有记录的。整个过程到最后要去复盘,去文档化,养成一种思维的习惯。

在这里,我们遇到过 rocksdb 的问题,我们遇到过 grpc 的问题,所有三方库,都不可能是绝对稳定的,甚至操作系统和编译器,都有可能遇到问题。生产部署时,只推荐特定的环境,为什么?是因为我们验证过。像某些特定文件系统的 bug 导致的问题我们也被坑过。

有些很难查的 bug,处理这种问题的能力非常重要。比如只有 error 日志 "fatal: morestack on g0",进程 hang 住,也不退出,也不打印栈,但是所有的 goroutine 都 block 住了。这是一个线上遇到的问题,但是概率特别低,如何抓到它?遇到这少量的线索时怎么处理呢,第一时间要检测到这种情况的发生,监控 log,进程不退出就需要 kill 掉并优先恢复服务。接着是不是应该分析最近改动了啥,如果怀疑编译器的问题,放不同版本编译出来的二进制做对比测试。如果不容易复现,可否考虑抓到线上的流量,放到模拟的环境里面回放? 最终这个定位是在某个版本 Go 编译器自身的问题。处理 panic 的 recovery 的时候,继续在里面做分配,调度器所在的 g0 崩溃了,然而没有让整个进程退出,无法调度了,于是 hang 住。

再举一个例子,系统慢了,如何定位为什么慢? 是整体慢还是部分的语句执行慢,是系统负载过高,还是某一项问题,比如 IO,或者写盘。如果是一条 SQL 语句慢,是语句本身复杂还是有热点,或者是代码优化的有问题。生成的查询计划是怎么样的,执行过程中有没有受到什么干扰。还有,如何从海量的信息里面迅速定位出来有用的那些。我们是不是可以做个 tracing 工具,将整个系统的每个步骤执行耗时记录下来,便于定位问题。关键的点是在于,工具和方法论,这是解决问题的手段。

另外,到高手阶段,大多都是 printf 的。如果还过于依赖单步调试... 那就呵呵哒。开个玩笑,这边有个同事,我们戏称亚洲第一 rust 程序员,让他review一遍代码,就是开启O4编译(一脸认真)。在大脑里面建模,把代码拆成一小块一小块的,然后人脑跑一遍,训练强大的分析能力,这才是正确的方向。

分析定位问题在这边是很有挑战的,真的。这个系统足够复杂。出问题时,涉及到的东西足够底层。

分布式的执行器

让 TiKV 完全负载存储,让 TiDB 完全负责 SQL 层的计算,逻辑上没什么毛病,这也是最开始的做法。然而真的把数据从 TiKV 进程全捞上来,到在 TiDB 进程里面做计算,这个数据走网络开销是很大的,并且计算的过程无法过滤掉不必要的数据。

移动数据不如移动计算,所以我们做了 coprocessor,做下推,把一部分计算下推到 TiKV 节点上面去做。TiKV 是分布式的,于是计算量就分摊到整个集群节点上面了。逻辑上,coprocessor 还是属于 TiDB 层面的,而物理上,它又在 TiKV 里面实现。下推以后,TiKV 那边计算与存储耦合得有点多了,倒不是指概念的耦合,而是资源分配。下推的计算量会影响到存储,grpc 网络也是一个很吃 CPU 的部分。

可是下推只是解决了一部分的问题。我们需要的,是一个真正的分布式的执行器层。

下推之后,还需要在上层做一些聚合的计算,现在操作还只能够在单个 TiDB 节点上面完成。单节点上面资源有限,比如说容易把内存打爆,或者如果有多节点,资源还可以达到更好的利用。面对实际要解决的问题:一个大的 Join 查询,把机器内存打暴了怎么办? Go 语言是一个带 GC 的语言。这 GC 语言里面做内存控制,也是一个相对麻烦事情。

分布式执行器,需要把纯粹 SQL 生成的执行计划,跟计划的执行(哦,有点绕)拆开来。这个执行计划应该是分布式的,可以被拆到其它节点上去并行执行,最后将结果汇总并返回,把多核或者说多线程的思维,进一步分散到多机器去做。但是这个引入的复杂度就不是一点了,因为跨了机器,机器挂了怎么办,木桶效最慢的一环影响整体速度怎么处理,哪些是能拆的,哪些不能拆,以及怎么拆,细节都很多。

这是一个复杂的分布式计算框架,跟 map reduce 不一样的地方,这里不是分片了暴力扫数据,shuffle 然后汇总的简单计算模型。这里可能会利用索引,利用统计信息,数据库领域几十年优化的经验,会跟分布式计算紧密地结合在一起。

把执行器逻辑层的单节点扩到多节点,开发真正的分布式执行器层,这也是一个有挑战并且很大的事情,路漫漫其修远兮。

优化器,统计信息,Join reorder

都列到这一坨了。优化器这边,从最开始非常 naive 的基于规则的优化,到后面有了基于代价的优化。再然后统计信息也做得越来越完善,越来越准确。

现在优化器这边比较稳定下来了,统计信息方面也支持自动的做 analyze,并且根据查询反馈去动态的更新。抛开学术不谈,至少在工程层面,这些问题绝对是处于技术前沿了的。

所以这就完了?不,挑战才刚开始!这里有两个问题核心,一个是数据量估算的准不估,另一个是代价算的对不对。统计信息是估算准不准的问题,而代价对不对就更难衡量。CPU 磁盘IO 内存,网络,到底分别占多少比重呢?比如一个逆序扫磁盘,跟顺序扫盘,代价就不是一个级别。比如一个场景走索引需要两次网络,而扫表要扫的区域是N条记录,N多大的时候代价是小于走网络的? 其实这些很多都是拍脑袋拍的,如何让玄学调参尽量地少一点。优化器能不能更智能一些,把一些写得比较弱智的子查询,直接转化得跟DBA优化过的一样高效。

还有就是 join reorder 一块盲区,目前没有根据代价来算。有时候为了达到最佳效果,还要手动去调整顺序。

数据分布和调度

我们专门有一个子系统,负责集群的数据分布和调度,它就是 pd。外界往往对 pd 了解的最少,其实它是非常关键的。

先说数据分布。存储在逻辑上是一个大的 kv 数据库,table 和 索引都会映射到一部分的 kv 范围。按照 kv 的 range,整个存储被划分为一个一个的 region。逻辑层面的 region,跟物理层面的节点,会形成一个映射关系。pd 做的事情,就是管理这个映射关系。假设一张表很大的情况下,它会涉及好多个 region,region 最终会散布在各个节点上面,于是请求就分散到各个节点上了。如果表很小呢?它会只包含一个 region,这个表被频繁访问,那这个 region 对就的节点就可能变成热点了。

数据分布是动态的,由 pd 调度。我们希望 region 在节点上的分布要尽量地均匀,于是流量也会分散开。但实际上呢? 不同 region 被访问的模式其实是不一样的。数据有冷有热,部分 region 可能被频繁在访问,而部分则很少被访问到。那么,调度就不能单考虑均匀,还得照顾到热点,将热点打散。

由于 region 分布不同,不同机器的磁盘空间占用可能不一样,这又是一个考虑因素。如果疯狂的往某个机器上调度,那个机器的盘也许就被写爆了,这又是要考虑到的点。

还有,要考虑到 raft group 的安全性,每个 region 默认是三副本的。那么,就要考虑单节点 down 机,或者整机架,甚至是机房的故障, region 的副本就需要交错着放。出个题目,这个可靠性的概率怎么算? 节点挂掉时,还有需要被副本之类的操作。加节点也是涉及调度。

单考虑一个因素时,问题并不复杂。但是要考虑到许多种策略时,pd 那边数据分布和调度的事情就变成了一个比较麻烦的问题。如果评估某个策略和算法是有效的?如果模拟真实的情况,去验证一个新的策略?

存储和性能

存储是一直要优化的方向,对性能的追求没有极致。从最早尝试使用 hbase 到后来决定自研分布式存储 TiKV。从最早的每一行的一个列作为一条 kv 记录,然后改到行存,每一行作为一条 kv 记录,都是在存储模型方面做过的调整和探索。

还有针对业务特点的各种细化,比如实现事务时,要写锁记录,要写 MVCC 数据。其实对不同模型的请求,写操作的特征是很不一样的。锁数据的存在时间很短,写数据成功后马上就删除,而 MVCC 数据要一直保存。于是我们利用 rocksdb 的 column family,把锁和数据放到不同的 column family,并且对应设置不同的 compaction 策略,调整不同的参数。早期都是把性能几倍几十倍的往上提升的。

业务一些细节还有很多,比如我们切分 region 按大小来,是否就合理。同样大小 64M 的 region,存放数据,跟存放索引,其实记录条数相差还是比较大的,那么同样是扫一个 region 工作量就会相差较大,这又会让多个 worker 分配工作量时划分不太均匀。比如一个 index scan,读数据读完了,读索引还是慢悠悠的做,那就会有不必要的等待。另外,不同 region 的大小又会有什么影响呢? 跟数据打散的程度会有什么关联性? 还有,不同大小对于 snapshot 或者对于 pd 的心跳方面会存在什么影响?

LSM 的删除,compaction 的代价还是挺大的,抖动也会比较影响。rocksdb 里面的各种细节参数多如牛毛,这也是需要大牛来"玄学调参"的,都是要求对里面的实现细节都了如指掌。

对于新技术的调研,这边也没有落下。就说说写放大,在 MVCC 那一层,要写入不同的版本,MVCC 是一次放大。到了 raft 那边,多副本的存在,会将写再放大几倍。再到 rocksdb 存储,其本身又是有 kv 的版本的。删除操作并不是物理删除,而是追加写一个版本。后台的 compaction 之前的“高效”写入的副作用了,尤其是要一层一层的做下去,这里面是巨大的放大。TiDB 里面设置了事务大小限制的,部分原因就是上层的事务被放大很多倍,大块写入很快就会把磁盘的 IO 耗尽。那么,如果能把 LSM 的 key 跟 value 分离开来,在 LSM 里面只存 key,写放大就会大大的减少,badger 就是这么做的,还有 rocksdb 的新 feature blobdb 也有这个。我们会测试各种不同的写入大小,64,128,256 等等,各种不同的读写模式,顺序的,随机的,去跟 rocksdb 对比,结果各有优劣吧。那么,能否让大的 kv,我们用 kv 分离的方式存,而对小的 kv 我们仍然是整体写 LSM?

我们一直都还没把 raft log 的存储独立出来。其实 raft log 存储的业务特点是很明显的,就是日志和快照。日志就是一个队列,并且是没有修改操作的。rocksdb 并不是特别优化的场景,是否可以订制化这种业务模型的存储,从而提升性能呢?


TiDB 招人很难。这篇打个招骋广告

说了这么多(发现一扯淡就收不住了),有没有动心了? 快到碗里来(内推 mkl@pingcap.com)。

TiDB