时序数据库QuestDB是如何实现每秒140万行的写入速度?


QuestDB是一个快速开源时间序列数据库,QuestDB是一个用于时间序列,事件和分析工作负载的开源数据库,主要关注性能(https://github.com/questdb/questdb)。
 
诞生之路
它始于2012年,当时一家能源贸易公司雇用我来重建他们的实时船只跟踪系统。管理层希望我使用一个他们刚购买许可证的著名XML数据库。仅此选项就需要将生产中断大约一个星期,以获取数据。一周的停机时间是不可行的。由于没有更多的钱可以花在软件上,我转向了OpenTSDB之类的替代产品,但它们不适合我们的数据模型。目前还没有解决方案可以交付该项目。
然后,我偶然发现了Peter Lawrey的Java Chronicle库。它使用内存映射文件在2分钟内而不是一周内加载了相同的数据。除了性能方面,我还很着迷于这样一个简单的方法可以同时解决多个问题:即使在将数据提交到磁盘之前,代码与内存(而不是IO功能)交互,没有复制缓冲区的情况下,也可以进行快速写入,读取。顺便说一句,这是我第一次接触零GC Java。
但是有几个问题:

  • 首先,当时看来该库似乎无法维护。
  • 其次,它使用Java NIO而不是直接使用OS API。这增加了开销,因为它创建单个对象的唯一目的是为每个内存页面保留一个内存地址。
  • 第三,尽管NIO分配API有充分的文档记录,但发行API却没有。

因为很容易用完内存,而很难管理内存页面的释放。我决定放弃XML DB,然后开始用Java编写自定义存储引擎,类似于Java Chronicle所做的。
该引擎使用了内存映射文件,堆外内存以及用于地理空间时间序列的自定义查询系统。实施它是一种令人耳目一新的体验。在工作上,我花了几个星期而不是几年。
我离开了工作,开始独自从事QuestDB的工作。我使用Java和小的C层直接与OS API进行交互,而无需通过选择器API。尽管现有的OS API包装器可能更容易上手,但开销却增加了复杂性并损害了性能。我还希望该系统完全不使用GC。为此,我必须自己构建堆外内存管理,并且无法使用现成的库。这些年来,我不得不重写许多标准的标准,以避免产生任何垃圾。
一年后,我意识到我的最初设计确实有缺陷,必须将其丢弃。它没有读取和写入分开的概念,因此会发生脏读。无法保证存储是连续的,并且页面可以具有各种不可64位整除的大小。它也非常不适合缓存,迫使使用慢行读取而不是快速列读取和向量化读取。提交速度很慢,而且由于单独的列文件可以独立提交,因此数据容易遭到破坏。
我重新开始工作:我编写了新引擎,以允许原子和持久的多列提交,提供可重复的读取隔离,并使提交是瞬时的。为此,我将事务文件与数据文件分开。这使得可以同时提交多个列,作为对最后提交的行ID的简单更新。我还通过删除重叠的内存页面并在页面边缘逐字节写入数据来提高存储密度。
这种新方法提高了查询性能。通过预取,可以轻松地在工作线程之间拆分数据并优化CPU管道。借助Agner Fog的向量类库,它使用SIMD指令集[2]解锁了基于列的执行和附加的虚拟并行性。它使实施更多新的创新成为可能,例如我们自己的Google SwissTable版本。几周前,我们在ShowHN [5]上发布了演示服务器时,我发布了更多详细信息。该演示仍可通过在线预加载的16亿行数据集进行在线尝试。
QuestDB已部署到生产环境中,包括一家大型金融科技公司。我们一直致力于建立一个社区来吸引我们的第一批用户并收集尽可能多的反馈。详细点击黑客新闻
 
每秒140万行的写入
在项目的早期阶段,我们受益于基于向量的仅仅附加系统,因为该模型具有速度优势和简单的代码路径的优点。我们还要求行时间戳以升序存储,从而导致快速的时间序列查询而没有昂贵的索引。
我们发现该模型并不适合所有数据采集用例,例如乱序数据。尽管有几种解决方法,但我们希望在不损失我们多年构建的性能的情况下提供此功能。
我们研究了现有的方法,大多数都是以我们不满意的性能成本为代价的。像我们的整个代码库一样,我们今天提供的解决方案是从头开始构建的。花费了9个月的时间才能实现,并向该项目添加了65k行代码。
这是我们重新构建的原因,在此过程中学到的知识以及将QuestDB与InfluxDB,ClickHouse和TimescaleDB进行比较的基准。
我们的数据模型有一个致命的缺陷-如果记录与现有数据相比在时间戳上出现乱序,则记录将被丢弃。在实际的应用程序中,由于网络抖动,延迟或时钟同步问题,有效载荷数据不会像这样运行。
有可能的解决方法,例如每个数据源使用一个表或定期对表进行排序,但是对于大多数用户而言,这是不方便且不可持续的。
时间序列数据库中常用的LSM树或B树。添加树将带来以下优势:能够即时排序数据,而无需从头开始发明替代存储模型。但是,这种方法最让我们困扰的是,与将数据存储在数组中相比,每个后续读取操作都将面临性能损失。我们还将通过具有用于有序数据的存储模型和用于O3数据的存储模型来引入复杂性。
更有希望的选择是在数据到达时引入排序和合并阶段。这样,我们可以保持存储模型不变,同时动态合并数据,而有序向量作为输出落在磁盘上。
 
解决方案
我们关于如何处理O3 ingestion的想法是添加三阶段的方法:
  • 保持追加模型,直到记录无序到达
  • 在暂存区的内存中对未提交的记录进行排序
  • 在提交时对已排序的O3数据和持久数据进行协调和合并

前两个步骤简单明了且易于实现,处理仅追加数据的操作保持不变。仅当登台区域中有数据时,才会执行繁重的O3提交。这种设计的好处是输出是向量,这意味着我们基于向量的读取仍然兼容。
这种预先提交的排序和合并功能为摄取增加了一个额外的处理阶段,并伴有性能损失。尽管如此,我们还是决定探索这种方法,看看通过优化O3提交可以减少多大的损失。
为了增加对O3的支持,我们寻求了一种新颖的解决方案,该解决方案与诸如B树或基于LSM的摄取框架之类的传统方法相比,具有出乎意料的良好性能。
  • 快速复制数据

第二步的批量处理暂存区为我们提供了一个整体分析数据的独特机会。这种分析的目的是在可能的情况下完全避免物理合并,并可能避免使用快速、direct memcpy或类似的数据移动方法。我们通过优化版本的基数排序对暂存区域中的timestamp列进行排序,然后使用所得的索引并行地重新排序暂存区域中的其余列。
  • 推迟提交

虽然能够快速复制数据是一个不错的选择,但我们认为在大多数时间序列提取方案中都可以避免繁重的数据复制。假设大多数实时乱序情况是由传递机制和硬件抖动引起的,我们可以推断出时间戳分布将局部地包含在某个边界中。
例如,如果任何新的时间戳值很可能落在先前接收到的值的10秒以内,则边界为10秒,我们将此边界称为O3滞后。当时间戳值遵循此模式时,推迟提交可以有效地将O3呈现为追加操作。我们并不是将QuestDB的O3设计为仅处理 O3滞后模式,但是如果您的数据符合该模式,则会为热路径识别并确定优先级。
 
TSBS基准
我们看到时间序列基准套件 (TSBS)经常出现在有关数据库性能的讨论中,并决定我们应该提供对QuestDB和其他系统进行基准测试的功能。
TSBS是Go程序的集合,以生成数据集,然后对读写性能进行基准测试。该套件是可扩展的,因此可以包含不同的用例和查询类型,并在系统之间进行比较。
在4个线程上运行时,QuestDB比ClickHouse快1.7倍,比InfluxDB快6.4倍,比TimescaleDB快6.5倍。
有关更多详细信息,版本6.0的 GitHub发行版 包含此发行版中的新增内容和修复程序的变更日志。