本来想的题目是《24 研0 小硕实习有感》或者《对实习的一些思考》,但突然想到每天下午陪伴我coding最多的时楼下的瑞幸和霸王茶姬,于是就起了这样一个无厘头的标题。

在去年打完ob之后,就萌生了在硕士入学之前来工业界看看的想法,不过当时接近年底,绝大多数公司都停止了招聘,唯一的一次面试也因为我的草草准备而被拷打的汗流浃背,最后挂在了二面。

在过年的时候,想着手里也没有事,就想着先推进一下毕设,大致想了三个方案:

一是跟本校老师水个AI早点弄完后面去实习/科研,不过这个方案很快就被我pass掉了,本校很多AI的老师还在发一些中文期刊,或者不知名EI,跟着做毕设想来体验是灾难级的。 第二个是跟着组里做科研,然后把科研的一部分工作当做毕设,当时我导想向量存储/vdb方向试试水,然后就安排我和师兄接触学习一下,一段时间后感觉并不是很感兴趣,再加上被某篇顶会的代码结结实实喂了坨大的,遂有点抵触 第三个是想自己搞点好玩的,像是分布式fs,或者lsm-tree,当时leveldb已经比较熟了,源码基本都翻过一遍了,想自己造个玩玩。遂跟导说了一下,询问了一下组里分布式fs方向有没有合适的课题,然后表明了自己想复现篇lsm-tree经典论文的意愿。商讨了一下最终确定复现一下wisckey。 我毕设一直抱着just for fun的态度,最初选择了一个比较激进的技术路线,即rewrite leveldb in rust,然后在此基础上复现wisckey,最终用typst撰写毕业论文。后续找到了学长传下来的latex模板,导致这个计划泡汤了1/3,最终重写了leveldb的大多流程,算上单测代码量在1w行左右,实现了一个简单的键值分离。

现在要我说的话,不足就是整个系统不够精简。leveldb非常精简,堪称学习lsm-tree的最佳实践,但出于学习目的的话,控制一下代码规模,只实现最核心的部分,则可以更高效的学习并尽可能减少浪费在dirty work上的精力。

毕设的coding部分完成的差不多以后,就开始着手找实习了,当时有朋友在一家做时序数据库的初创,说体验各方面的都还算不错,再加上临近毕业,时间紧张,于是面试完就直接来了。详细可以看这一篇:

Fischer:24届数据库实习经验贴 130 赞同 · 28 评论 文章 入职刚好赶上了公司开始Rewirte It In Rust(比较搞笑的是,我笔试题还是用c++写的,来的时候还带了两本C++的书,想着借此机会恶补一下C++)。Rust版只有老板写的一个大框架,可以称得上是“新建文件夹”了。因此首要目标就是让数据库成为一个数据库,我的第一个任务是基于Datafusion构建一套从Sql Parser -> Planner -> Executor的SQL Engine,实际上和445 lab3或者Ob初赛的内容相似。外加一些Catalog Schema等基础结构的定义。Rust这块生态优势确实很明显,靠着sqlparser库+datafusion摇轮椅,parser和select planner可以快速解决掉。不过总的来说这一部分工作还是比较繁杂且枯燥的,当时偶尔会和同事和群友吐槽“每天都是最想离职的一天”。

为了不变成流水账,后续内容就简单罗列几个有意思的:

Prepared Statement

这一部分的工作可以概括为对于数据库前端的优化。

简单来说prepared statement是对sql进行预编译,缓存plan,随后替换掉占位符,生成完整的plan进行执行。通过prepared statement,基本上干掉了parser,换来了大约20-30%的性能提升,并且将CPU从打满的状态降低至接近一半。

rust中比较常见的一个并发缓存库是Moka,但moka的问题是只支持clone,无论是insert/get/remove。moka官方对此的解释是如果需要减少拷贝,那么就存储Arc,因此其只适合于静态缓存(如缓存一些Read Only的Block)。而在prepared statement的场景下,get之后需要填充占位符,这时候Arc就比较尴尬了,需要clone之后deref。因此最终放弃了Moka,直接使用Dashmap存储,这样可以只进行一次clone并且不需要 deref。正常情况下,一次query结束后,会清除掉cache,因此我们只需要加一个定时任务来定期扫描整个Dashmap,清除掉失败的query即可(填充了占位符但是没有正确执行)

MemTable 优化

MemTable的优化主要分两部分,一是并发数据结构的性能问题,二是一个用于加速的二级索引。首先简单介绍一下数据模型,首先数据进行分区存储,每一个partition对应一个MemTable,在时序场景下,会对同一台设备(可用partition key代表)按时间进行数据采集,为了方便后续查询,会按timestamp来有序存储,最终形成的数据布局就是[pk@ASC,ts@ASC]。

而对应的数据结构最初是RwLock<BTreeMap<PartitionKey,RwLock»的这样一个嵌套体 ,其中WriteBuffer存储了一个PartitionKey对应的数据,按列式存储。

先说说并发问题,很久之前看了LevelDB,觉得并发跳表强无敌,加上之前看到了一个ShardedLock的库,于是将原本的RwLock替换为crossbeam::SkipMap,RwLock替换为crossbeam::ShardedLock ,但是基本没有性能提升,大概不超过5%。之后搁置了一段时间,后来有一天比较闲,看到了达坦的一篇文章:

无锁数据结构 最后一点就是有关无锁数据结构的使用。在Xline性能分析中我们发现,一些无锁数据结构并不像它们宣称的那样是其他需要锁定的数据结构的直接替代,这些无锁数据结构的使用反而堆系统性能造成负面影响。 第一个例子就是 DashMap ,它是 HashMap 的一个并发实现。但是在实际测试中,我们发现 DashMap 遍历效率很低,甚至比 HashMap 要慢了一个数量级,原因是 DashMap 内部使用的 Arc 导致解引用的效率会变低很多。 第二个例子就是对于 crossbeam中 SkipMap 的使用, SkipMap 虽然支持无锁并发操作,但是与 RwLock 相比,它的单线程插入和删除性能还是要慢了一倍以上,因为即使均摊时间复杂度相同, BTreeMap 树高度更低和对于缓存更加友好的特性使它的性能会更好。

作者:达坦科技DatenLord 链接:https://zhuanlan.zhihu.com/p/698368881 来源:知乎 著作权归作者所有。商业转载请联系作者获得授权,非商业转载请注明出处。 再加上之前写过一个小的benchmark,发现RwLock在有些场景下会优于DashMap,于是便又进行了重构,大概实现了两版:分别是使用crossbeam::ShardedLock和parking_lot:RwLock,自排序结构老老实实用了BTreeMap。这两版分别有大约20%和 70%的性能提升。

parking_lot不会处理锁中毒,但鉴于原本也是unwrap掉,因此最终选择了parking_lot

二级索引

在目前的数据模型中,insert一条数据需要找到MemTable中Pk对应WriteBuffer,因此会有一个get_or_create的操作,即在BTreeMap当中搜索,而MemTable中通常会存在多个Pk,这个搜索过程就会比较复杂耗时较高。

针对该情况,额外构建了一个HashMap,并使用parking_lot::RwLock保证并发安全,和BTreeMap中指向同一个WriteBuffer,这样插入时,只需要使用HashMap来搜索,复杂度降低至 ,而在BuildReader时继续使用原本的BTreeMap,来保证顺序,并且后续的点查也可以使用这个HashMap来优化。其实是很简单的一个思想,只需要多存几个 Arc,但从火焰图上看大概干掉了20%的开销

最终,prepared statement + parking_lot::RwLock + 二级索引实现了写入性能的翻倍,数值上提升了一个数量级,个人还是比较满意这个结果的。

一些杂活

除此之外还有一些杂活,属于比较重要但是并不是那么有意思的,比如说基于Datafusion完成读路径,使用sqllogictest-rs构建了一套end-to-end的测试框架,有点像Bustub lab3的测试框架,还有兼容InfluxDB行协议等等。总的来说比较的平淡,属于是横向扩展,并没有纵向的提升,这里就不展开了。

对我来说,实习阶段最大的提升有两部分,一是读代码,二是如何思考并测试系统的正确性与稳定性。

在我来的时候,项目基本只有几百行,之前也未接触过时序数据库,最初每天做的就是去翻看各家友商的代码,研究一个功能如何实现。Datafusion的官方文档非常残废,提供的帮助几乎约等于无。流程上就是看一看友商的代码,看看他们是怎么用的,然后点进Datafusion的源码,看一看内部实现和对应的ut。此外还会追一些Datafusion的新功能,就会去翻一翻对应的pr,或者去docs.rs搜一搜,随后直接跳到源码看看ut。

在上面这些过程中,自然而然地看了大量的代码,无论是友商的,还是Datafusion的,看的多了也就轻车熟路了,现在能够快读缕清一段代码功能和调用链路。

在做 Lab 时,只需要关心将我写的代码输入给测试框架是否能拿到正确的结果,即 assert_eq(y, f(x))。实习时,构建f(x)的责任到了我身上,就需要自己去思考预期的结果是什么,结果都有哪些可能,测试代码能够覆盖到系统的哪些部分,新的举动会引起覆盖率的怎样变化等等。end-to-end则还要稍微复杂一些,需要站在用户的角度,面对完整的系统来思考输入可能会触发哪些操作。

还有一个比较大的差别就是对性能的关注,垃圾代码是实打实的会对系统造成负面影响,之前写过一个比较搞笑的ParquetReader,主要负责 IO 但大概 60% 的时间花费在了CPU计算上,其结果立刻就反映到了Compaction的效率上。这几个月下来,火焰图也算是轻车熟路了。

小结

从本质来说,lab,实习,科研中的 coding 部分并没有什么不同,简单来说都是将自己所想用代码表述出来,只不过可能更加需要关注稳定性和性能。这几个月的实习也让我在毕设/科研中更加得心应手。后续我可能再也用不到Datafusion,甚至有可能再也不接触数据库。但是读写代码的能力,对代码的品味,以及性能分析和优化的手段这些后续是可以带到任何地方的,也会让我后续的科研和工作都持续受益。

毕业前的几个月实习/科研/毕设三线作战有点汗流浃背了,基本上只能完成公司/导师布置的任务,没怎么有自己的思考,最近也没怎么看过paper,后续可能还是专注在一件事上会比较好

最后打个小广告,有想来实习的欢迎找我内推。工作内容就是Rust时序数据库开发,需要熟悉C++/Rust其中一种,懂点db懂点分布式,做过 445/824 或者有一些等价的项目。待遇还有福利什么的都不错,10-6-5 不加班比较的wlb。而且在成都生活成本比较低,吃的住的质量都比较好而且开销低。可以直接后台私信我或者发送简历到 yxy05203744@gmail.com