Summary

Xata 公司在基于 PostgresSQL 开发自己的分布式数据库,出于 Zig 和 C 语言以及 PostgreSQL 的 API 有更好的互操作性的考虑,他们选择了 Zig 而非当红炸子鸡语言 Rust。他们的博客文章中对 pgzx 进行了介绍。让我们来看下他们对 Zig 和 Rust 语言的对比,以及 pgzx —— 一个支持用 Zig 语言来开发 PG 插件的框架。

Xata 是一个基于 PostgreSQL 的 Serverless 数据平台,在 PostgreSQL 之上提供全文搜索、向量相似性搜索和文件/图像等小文件存储等功能。根据官网的介绍,Xata 希望从根本上简化开发人员处理数据的方式 —— “数据库不仅仅可以在表中存储行和列,我们希望提供一站式的多模存储,并提供一个可与您喜爱的工具配合使用的互联数据平台。”

3 月 21 日,在 Xata 的发布周,Xata 的工程师发布了 pgzx 。pgzx,是一个使用 Zig 编程语言创建 Postgres 扩展的框架,提供一组基础库(例如错误处理、内存分配器、Wrapper 包装)以及构建和开发环境,简化了与 Postgres 代码库的集成。在介绍文章中,项目的主要开发者,Tudor Golubenco 和 Steffen Siering 介绍了该拓展的有趣的功能,以及他们为什么选择 Zig 来进行开发。相对于大家已经熟悉的 pgrx(基于 Rust 的创建 Postgres 扩展的框架),它有什么优势?

用 Zig 语言开发类似于 Citus 的分布式数据库

Xata 的工程师一直在做一个新的 Postgres 项目,该项目还没有真正的名称,内部称其为 “Xata 的分布式 Postgres 行动”(Xata's take on distributed Postgres)。这个项目还处于早期阶段,但是已经有了开源计划。它与 Citus 有点相似,不过,基于过去几年运行 Xata 所学到的知识,团队做了一些不同的选择。

在项目之初,Xata 团队考虑了三种潜在的实现方向:

  1. 作为外部代理,类似于 Vitess 对 MySQL 的处理方式。
  2. 作为一个 Postgres 扩展,类似于 Citus。
  3. 作为 Postgres 的一个分支,类似于 Greenplum。

对第一种选项,他们显然更有经验,因为这就是目前的 Xata 代理的工作方式,所以也就很自然的倾向于这种方式。可以使用 Postgres 的网络协议,解析传入的查询并深入理解它们,并且通过两阶段提交创建分布式事务。然而,经过思考,他们发现利用现有的 Postgres 代码将会带来更多的选择,最好能复用那些非常复杂的部分。考虑到项目的长期愿景,加上也不想维护一个分支,最终决定做一个类似于 Citus 的 Postgres 拓展。

在编程语言的选择中,他们则选择了 Zig,排除了 C 和现在非常流行的 Rust。Xata 的工程师在 HackerNews 论坛上表示,“使用 Zig 的原因之一是我们可以直接复用 C API。

Zig 的主要优势包括:

  • 它几乎可以直接调用 Postgres 的 API,这样我们拥有与使用 C 相同的能力。
  • 它比 C 提供更多的内存安全性,更具表现力,也更有趣。
  • 它与 Postgres 代码库非常匹配,例如在内存管理和字符串处理方面。

决定了方向和语言之后,pgzx 也就应运而生了。就像用 Rust 开发 PG 插件产生了 pgrx 项目,Xata 也需要一个 Zig 版本。Zig 和 pgzx 不一定适合去开发所有的 Postgres 扩展,但他们认为它是将来这个类似于 Citus 项目的最合理选择。

介绍一下 Zig

Zig 在其网站上被描述为一种通用的编程语言和工具链,用于维护健壮、优化和可重用的软件。Zig 仍然是一种新的语言(pre 1.0),但它在系统编程社区中已经逐渐开始流行,并有网友称其为 “最赚钱的编程语言”。Zig 简化了很多东西,也因此被戏称 Rust --, 但是简化不代表弱,Zig 为 C 语言提供了一个很不错的解决方案,提供更安全的内存管理,编译时计算(comptime)以及丰富的标准库。

使用 Zig 进行 PostgreSQL 开发的一个主要原因是它能够与 C 代码进行互操作。Zig 支持 C ABI,可以与 C 指针和数据类型兼容,包括 NULL 结束的字符串,并且甚至可以将 C 头文件转换为 Zig 代码。Zig 将 C 语言的宏自动转换为 Zig 代码的功能虽然还不是很完美,但仍然很有帮助。

pgzx 这个项目,由于其使用了 Zig 语言,也有了一些有趣的新特性。并且由于语言的选择也带来了很广泛的讨论度。

pgzx 的简介

Zig 可以调用任何 C 函数并将 C 的宏转换为内联 Zig 函数,您可以按照与开发一个 PG 的 C 扩展相同的步骤用 Zig 来编写 Postgres 扩展,不需要任何其他工具。但是,pgzx 可以提供开发环境、一组用于 Postgres API 的基础库和 Wrapper 包装、常见错误处理等,这样的框架显然可以简化使用 Zig 中编写 Postgres 扩展的流程。

示例

pgzx 目前有 2 个示例扩展。

  • char_count_zig, 一个很简单的扩展;
  • 以及 pg_audit_zig , 更复杂并且显示了 pgzx 的更多功能。

我们看一下 char_count_zig,它是一个功能上只比 “Hello, World!” 多一点点的 Postgres 扩展示例,添加一个函数来计算字符在字符串中出现的次数。

select char_count_zig('hi hii', 'i');

 char_count_zig
----------------
              3
(1 row)

虽然用 Zig 和 C 很相似,但 Zig 版本更简洁一些。一方面是因为 Zig 更具表现力,另外也是因为 pgzx 做了更多的工作。

注册的 SQL 函数接收序列化的参数,并且需要一些代码来进行反序列化。在 C 中,可以用 G_GETARG_* 宏来完成此操作,但是使用 pgzx,您只需将它们作为已反序列化的普通参数接收即可。如何?通过使用 comptime 函数在编译时生成必要的样板代码。如果您好奇,请查看 pgCall 函数,这是一个很好的例子,展示了 Zig 在comptime 执行的强大功能。

PG_MODULE_MAGICPG_FUNCTION_INFO_V1 函数是 comptime 使用的第二个示例。它们导出 Postgres 所需的符号,将其识别为扩展并将该函数注册为 SQL 函数。在这种情况下,comptime 的行为与对应的C语言宏非常相似。

运行时内存安全

如果你仔细看了上面的代码,就会发现它有个 bug。它检查了 target_char 不应该超过 1 个字符,但它没有检查它是否为 0 个字符。之后,代码访问 target_char[0] 时,如果字符串为空字符串,就会出现越界访问错误。我们故意留下了这个bug,这样可以看到当扩展中出现这种错误时会怎么样。

使用以下 SQL 触发此错误:

select char_count_zig('hi hii', '');

返回信息:

server closed the connection unexpectedly
        This probably means the server terminated abnormally
        before or while processing the request.
The connection to the server was lost. Attempting reset: Failed.

在 C 代码中,此类错误可能会触发 segmentfault 错误甚至安全漏洞。使用 char_count_zig 扩展尝试此操作,Postgres 进程仍然崩溃(但不是整个服务器受影响,只有提供连接的进程),但是检查日志,您将看到如下错误消息:

thread 70501513 panic: index out of bounds: index 0, len 0
/Users/tsg/src/xataio/pgzx/examples/char_count_zig/src/main.zig:21:32: 0x103aaedff in char_count_zig (char_count_zig)
        if (char == target_char[0]) {
                               ^
/Users/tsg/src/xataio/pgzx/src/pgzx/fmgr.zig:95:5: 0x103aaf20f in call (char_count_zig)
    const value = @call(.no_async, impl, callArgs) catch |e| elog.throwAsPostgresError(src, e);
    ^
???:?:?: 0x10316045b in _ExecInterpExpr (???)
???:?:?: 0x10315fbef in _ExecInterpExprStillValid (???)
???:?:?: 0x10326ceef in _evaluate_expr (???)
???:?:?: 0x10326da67 in _simplify_function (???)
???:?:?: 0x10326bacf in _eval_const_expressions_mutator (???)

它准确指出了错误发生的位置!发生这种情况是因为 Zig 根据 Build Mode(Zig 有四种 Build Mode,Debug (default)ReleaseFastReleaseSafeReleaseSmall)进行运行时检查。例如,ReleaseSafe 模式会牺牲一些性能来换取更多安全检查。

请注意,这个 stacktrace 很完整,因为错误出现在 Zig 代码中。在构建 Postgres 扩展时,经常需要调用 Postgres API,如果使用不正确,仍然会出现 segmentfault 错误。

内存管理

Postgres 使用分配器 arena 来管理内存。在 Postgres 源代码中,这些 arena 被称为 memory contexts 。在一个“ contexts ”中分配的内存可以一次性释放(比如,当查询执行完成的时候),这显著简化了内存管理,因为你只需要跟踪 “contexts”,而不是单个分配。Contexts 还是分层的,因此你可以创建一个 contexts 作为另一个 contexts 的子节点,当父节点的 contexts 被释放时,所有子节点也将被释放。这样内存泄漏就几乎不可能发生。

内存 contexts 的另一个优点是它们改善了内存监控,因为 contexts 有名称,你可以看到每个 contexts 使用了多少内存。这对于调试大内存场景是很有用的。 这种使用 arena/contexts 分配器的模型恰好非常适用于 Zig。 因为 Zig 的使用习惯是让所有分配内存的函数/对象接收一个分配器作为参数。这样,分配内存的行为会更加显式,而且适合使用自定义分配器的地方。pgzx 在 Postgres 的 contexts 外面又包装了一层,这样定义的自定义内存分配器更适合被 Zig 代码调用。

以下是一个示例,创建了一个新的 contexts 作为当前 contexts 的子节点,并获取了该 contexts 的分配器:

var memctx = try pgzx.mem.createAllocSetContext("zig_context", .{ .parent = pg.CurrentMemoryContext });
const allocator = memctx.allocator();

错误处理

在更复杂的扩展中,你很可能需要了解 Postgres API 的错误处理机制。Postgres 通过 setjmp/longjmp 实现了 C 中的 "异常"处理,并提供了一组宏来抛出和捕获它们(PG_TRY/PG_CATCH)。

问题在于长跳转可能会绕过 Zig 的控制流,比如说,errdefer 块可能没有被执行。这说明如果你的扩展调用了 Postgres API,这些 API 可能会抛出错误,长跳转可能会跳过你的 defererrdefer 块!

幸运的是,pgzx 此时可以发挥作用。它提供了一组函数,允许你捕获 Postgres 的异常并将它们转换为 Zig 的错误。例如:

var errctx = pgzx.err.Context.init();
defer errctx.deinit();
if (errctx.pg_try()) {
    // Call Postgres C functions.
} else {
    return errctx.errorValue();
}

开发环境

pgzx 附带一个基于 Nix flakes 的开发环境,可以用于开发扩展和 pgzx 本身。它还附带一个项目模板,您可以使用该模板在新存储库中设置此环境。

请安装 Nix,然后运行:

mkdir my_extension
cd my_extension
nix flake init -t github:xataio/pgzx

然后加载 nix shell:

nix develop

开发环境包括许多命令,这些命令可以用于在开发环境中重新定位 Postgres 二进制文件、启动服务器等。该模板还附带一个最小的扩展和一个包含一些常见任务的 build.zig 文件。请参阅模板 README 文件,了解如何从此时开始构建扩展。

单元测试

Postgres 扩展通常使用名为 pg_regress 的工具进行测试。 pgzx 也一样,只需调用 zig build pg_regress 即可。

但我们也想进行单元测试。这有点棘手,因为测试需要在 Postgres 实例的 contexts 中编译和运行。否则,就无法与 Postgres 的 API 交互。

为了解决这个问题,pgzx 注册了一个定制的 run_tests 功能。可以通过 SQL 调用(SELECT run_tests()) 来运行单元测试。一个测试套件是一个以 test 开头的 Zig 结构体。

要注册一个测试套件,你通常会做类似以下的操作:

comptime {
    pgzx.testing.registerTests(@import("build_options").testfn, .{Tests});
}

registerTests 函数是 comptime 使用的另一个用例。它会迭代结构体的所有字段,并在 SQL 中调用 run_tests() 函数时生成运行测试的调用。

其他

本文涵盖了 pgzx 已发布的一些有趣的功能,但还 pgzx 还有更多功能,比如,Postgres 数据结构的 wrapper(列表、哈希表)、SPI、共享内存访问、连接管理等等......

现状和下一步计划

pgzx 现在还是 “alpha” 阶段。但如果您想要构建 Postgres 扩展并且想要使用 Zig,使用 pgzx 会容易得多。现有功能位于README.文件中。

另外,如果这篇博文激发了您对 Zig 的兴趣并且您想尝试一下,为什么不开发一个 Postgres 扩展呢?

既生瑜,何生亮?

几乎每个程序员都为自己喜爱的语言辩护。尽管 pgzx 本身的能力很引人注目,Xata团队对未来的计划也令人兴奋,但是最引发讨论和质疑的,无疑是其选择了 Zig 作为开发语言。在 HackerNews 社区的网友也大多都关于这一点。

有网友表示,“ Zig 是一门非常令人愉快的语言。 comptime 是一项巧妙的发明。它以相同的语言语法实现类型安全的元编程和简单的通用支持。对 u2、u3 或 u7 等子字节类型的本机支持使打包数据变得非常容易。 SIMD 上的原生向量支持使得 SIMD 级并行编程就像儿童游戏一样。这种语言看起来非常好。

也有人直接提出这个问题,难道 Rust 不是一个清楚的选择吗?Zig 跟 C 的互操作性看起来很抽象啊。对此,博文作者 Tudor 亲自下场,说,“Rust 很好啊,但是在 Zig 中,我们几乎可以直接使用任何东西,非常方便。”

还有的观点指出,“我还没有使用 pgzx,但内存管理可能更容易。 Zig 使用内存区域,Postgres 也是如此。如果一个可以直接映射到另一个,那将是一个big win。对于 Rust/pgrx(我已经广泛使用过),内存集成更加困难。有 pg 内存,有 Rust 内存,它们不一样,你必须玩游戏才能在它们之间传递数据。终生问题突然出现。 Rust 将来也许能够通过自定义分配器解决这个问题,但目前还没有实现。”

更有人直接说“当使用 Rust 处理复杂的数据结构时,经常需要编写不安全的代码(unsafe code)。Unsafe code 在 Rust 中使用起来要痛苦得多。一旦进入,无论如何你都是在不安全的地方,有人说 Zig 更方便。”

针对这些,你怎么看,欢迎加入社区跟我们一起讨论。


小猿姐
6 声望1 粉丝

每个开发者都想知道的云原生和数据库技术