在Go中为Clickhouse编写Postgres外部数据包装器

2020-11-21 18:30:47

Postgres(以下称为PG)是一个非常酷的数据库,具有许多不错的功能,其中一个鲜为人知的功能就是具有外部数据包装器(以下称为FDW)的功能。

Clickhouse(以下简称CH)是另一个出色的数据库,具有针对OLAP用例的完全不同的功能集。

与技术中的众多名称不同,在这种情况下,我们实际上可以从名称本身推断出一些想法。因此FDW本质上允许通过一组包装器API访问Postgres(PG)内部的外部数据源。

也就是说,您可以像访问普通PG表那样访问PG内部Mysql / SQlite / Clickhouse(任何其他数据源)表中的数据。那不是太神奇了!

需要注意的是,您可以从FDW中获得的功能范围取决于特定的实现,我们可以期望获得正常的读取支持,但可能缺少诸如下推式过滤器,聚合或联接或写入支持之类的其他功能。

鉴于存在访问其他数据存储的众多可能性,如果我们可以从Postgres内部访问Clickhouse,那会很有趣。

但更现实的是,MessageBird(我的老板)的雄心勃勃的用例之一是能够将Clickhouse连接到Looker,因为当时不存在直接集成。这虽然有些无聊,但我们决定尝试一下如果可以的话:)

MessageBird慷慨地为我们的实验开源了完整的源代码!该库位于此处,因此您也可以直接在代码中引用博客文章中提到的想法:)

已经有关于如何处理此问题的文档,Github上也提供了一些简单的示例。完整的FDW大部分都公开了代码,因此我们也可以查阅它们。请注意,由于PG的FDW API使用C语言编写,因此大多数都是用C编写的,这是有道理的。专为SQLite编写,并具有可靠的功能集,这在为Clickhouse编写功能方面给了我很多帮助。

但是,如果我们想冒险并用Go语言写一个该怎么办?好吧,考虑到CGo的存在,应该有可能。

我们可以预料,这将是微不足道的。 ;)已经有尝试在Go中制作Postgres Extensions,这提供了非常有价值的见解。

首先,我们应该熟悉PG扩展构建基础架构/ PGXS以及如何为PG编写C代码,这些至关重要,因为我们希望将C代码与Go集成在一起,并且了解构建过程的工作原理将有助于我们了解哪里我们的代码将适合。

为了编写FDW,我们必须以结构的形式提供一个入口点,其中包含指向已实现的回调函数的函数指针。由于我们要在Go中编写这些回调,因此可以查阅文档以在C中访问Go函数。允许我们将go函数导出到C代码之外的特定注释。所有重要的工作仅在这些回调中完成。现在应该可以通过Go添加PG FDW API期望的功能。

但是,C代码如何找到用Go land编写的回调函数?正确的构建模式就是答案。-buildmode = c-archive直接参考文档,使我们能够:

将列出的主软件包及其导入的所有软件包构建到C存档文件中。唯一可调用的符号将是使用cgo // export注释导出的那些函数。只需列出一个主程序包。

完善!现在,导出的Go函数在存档文件中可用了,剩下的唯一事情就是在构建过程中将存档与C代码链接起来。幸运的是,PGXS提供了一个Make变量SHLIB_LINK,可用于设置所使用的共享库。因此,我们将使用该标志来提供Go源文件的归档版本。

要真正编写一个有效的FDW,我们需要熟悉查询在PG中所经历的不同阶段以及API函数如何发挥作用.Postgres具有出色的文档,而且由于所有源代码都是开放的,因此我们可以浏览代码也一样!

要解释Postgres中的Query planner的完整内部知识,它所需要的空间比博客文章还多,因此我可能无法很好地描述它。因此,我建议您浏览一下非常出色的官方文档,还有许多其他出色的文档。网络上的参考。

我将在本文中简要说明API函数的流程。一个非常基本的计划如下所示:

+ ------------------------- + | || || GetForeignRelSize || || | + ------------ + ------------ + | | | + ------------ v ------------ + | || || GetForeignPaths || || | + ------------ + ------------ + | | | + ------------ v ------------ + | || || GetForeignPlan || || | + ------------ + ------------ + | | | + ------------ v ------------ + | || || BeginForeignScan || || | + ------------ + ------------ + | | | + ------------ v ------------ + | || || IterateForeignScan || || | + ------------------------- + | | | + ------------ v ------------ + | || || EndForeignScan || || | + ------------------------- +

我在这里省略了FDW API中的其他功能(例如ReScanForeignScan,AnalyzeForeignTable,GetForeignUpperPaths),但是可以使用这些功能完成基本的FDW。还请注意,此路径仅与读取查询有关。为了能够在外部数据库上进行写入,需要实现单独的功能。您可以在此处查看如何为我们的Clickhouse FDW实现读取路径。

要正确实现查询的读取路径需要注意的重要因素有:

GetForeignRelSize:它应用于确定外部服务器上要扫描的估计行数。但是,它也用于提取PG提出的查询中存在的限制子句,并将其传递给外部服务器(如果它可以支持它们的话)。请参见本示例。

GetForeignPlan:它应该返回计划器节点(包含查询计划的数据结构)。但是,它也用于提取可从远程/外部服务器获取的目标列,并将该信息以及限制子句,表名传递给下一阶段。

BeginForeignScan:它应该执行在外部服务器上执行扫描所需的初始化,例如:初始化外部数据库连接,形式化在外部服务器上运行的查询,并使用行迭代器初始化状态,以在下一阶段中使用。请参阅此示例。

IterateForeignScan:它应从转换为PG特定结构的外部服务器返回一行。此函数应将外部服务器特定的数据类型转换为PG列数据类型。请参见此示例。

EndForeignScan:它应该清除为查询存储的状态,例如行迭代器,应该关闭数据库连接。

这是基本FDW功能的非常详尽的概述。它通常有助于查看其他开源的FDW,以查找示例实现的想法。但这可能会有所不同,因为外部服务器可以是多种类型的服务器。通常,如果我们采用支持某些SQL方言的数据库,则最困难的事情通常是确定限制子句是否是远程安全的,这可能涉及解析完整的表达式子句然后将外部服务器数据类型转换为PG类型相对容易,但非常麻烦。

您可以研究Clickhouse FDW如何做到这一点,但请注意,由于尚未经过全面测试,因此可能容易出错。

我还将建议您了解常用的PG数据类型和约定(例如OID,Tuple,RelOptInfo),或者仅从PG源代码翻阅related.h参考。

在开发FDW时,我已经看到一些合理的想法。有些人可以帮助Go和C之间轻松互操作,不管这是一个好主意,还是有待商;的;

PG源代码中有许多内部宏,可以更轻松地访问系统缓存,列表,堆元组等,而Go的用户域无法直接调用这些宏。这是因为CGo不允许直接调用C #define宏。您可以尝试使用基础构造来模拟相同的行为,但是可能会变得冗长而繁琐。相反,一个简单的想法是定义简单的C包装函数,例如

现在可以直接在Go端使用。但是请确保将结果转换为正确的类型。

可能存在这样的情况,即直接在Go源文件中编写C代码不再可行,因为增加注释行的数量可能在一个点之后变得不全面。

也可以将C代码移到单独的文件中,然后在Go中对其进行访问。您需要在构建期间将Go代码与Go代码之外的C符号定义链接起来,并且应该可以工作。例如,可以将C代码分为头文件和源文件,并将头文件包括在转到源代码。现在CGo会自动处理目标文件的生成,请参见这里的操作。为了更好地理解不同的目标文件,请参见此处。

Go不允许将指向Go对象(如地图,切片)的指针传递给C(这很有意义,因为Go是一种垃圾收集语言)。有关更多详细信息,请参见此参考。

但是我们想在Go中维护由C代码访问的对象的状态。

One example is database connections and cursor(row iterator). As we would want to access connection as a Go variable or in a PG execution stage we would want to use the same cursor which is being initialized in upper stages (i.e planning). One trick to make this happen is to keep a map of integers => Go objects and pass that integer around as we move downstream in the query stages.This integer is always incremented in the FDW’s lifetime (in each stage) and is never reused again in calls to FDW.For example see it here.

PG FDW API还允许将内部私有信息作为不透明的void *嵌入,将在查询阶段进行传递。这是非常整洁的恕我直言,并为开发人员节省大量时间来允许记账:)您可以通过简单地提供一个void * fdw_state来在阶段间传递状态,我们可以在其中放置任何内容(从字面上看)。 Clickhouse FDW使用它来传递查询状态信息,例如提取的远程安全限制子句,表名,列名等。请参阅此参考。

由于我们要从数据库的Go驱动程序中获取结果,然后将其下推到PG的C API,因此我们需要在两者之间进行转换。通常,固定大小的整数和字符串应该很容易,但是PG具有大量的数据类型目录,例如日期时间,时区,数组等。此外,外部数据库还可以具有其自己的数据类型列表,这些数据类型可能无法正确表示为PG类型。在这种情况下,我们必须尽力而为,并且可能不得不将那些花哨的数据类型排除在外。对于用户定义的数据类型(例如可变长度字符串或数组),PG具有用于转换的输入和输出功能的良好系统。我们可以依靠这些函数将值从Go转换为C,请参见此处的操作。

这是提供的最好的功能之一,因为它将利用底层外部数据库的功能并大大减少通过网络传输的字节数。而且,这使得在PG上进行查询的方式与在foregin DB上进行查询的方式更加接近,因为否则查询将使用PG的查询计划器。

为了支持下推式过滤器,我们首先必须从查询子句中解析表达式,然后评估它们是否可以根据外部数据库来实现。我主要利用sqlite_fdw为之编写的现有代码,但进一步对其进行了调整以根据Clickhouse表达式转换表达式。

功能可以分为两部分,第一部分可以是表达式解析,第二部分可以是评估。

对于下推GROUP BY之类的聚合,必须执行deparse和评估,但我们还必须实现FDW函数,例如GetForeignUpperPaths。

为了正确理解FDW的工作原理和进一步扩展,需要了解Postgres内部和FDW API。还需要对数据库设计理论有一个简短的了解。如果团队事先没有这样的专业知识,那么这可能是在Foreign DB(例如Clickhouse)和PG之间提供生产级接口的主要瓶颈。开发人员不应该担心代码,因为它太神奇了并且他们不了解内部工作原理。

接下来,如果假设您已了解这一点,那么要保持C to Go接口调用的可管理性就存在挑战。例如,请参阅CGo性能惩罚。因此,希望通过使C代码量保持最小来保持代码库的健全。我们需要以某种方式在两者之间取得平衡。

我们可以尝试将很多代码保留在Go中(涉及C接口函数),但是在将内存块从Go传递到C时,我们也需要小心。任何Go.CString(...)都不会通过Go声明。运行时,尽管在C结构的情况下,PG运行时承诺在事务结束后立即收回由palloc分配的内存。在某些构造中,我们可能需要联系PG的HeapTuple内存分配器,并手动确保在使用后释放它们。

新的协作者将需要学习有关将C结构/数据类型转换为Go结构(反之亦然)以及将PG C函数编组/解组结果/参数的技巧。

一种非常极端的方法是,尝试通过仅在Go中将“从Go类型到C类型的解析和类型转换”和在Go中使用“ Clickhouse查询形成”来保留C中的大多数FDW阶段,但是如果团队缺乏C方面的专业知识,则可能不是一个好的选择。

此实验项目的最终结果可在此处获得。我希望其他团队会发现该代码有用。

如果这些挑战使您兴奋,则MessageBird会招聘许多具有吸引力的职位!