临夏回族自治州网站建设_网站建设公司_RESTful_seo优化
2026/1/11 13:33:08 网站建设 项目流程

本文字数:7060;估计阅读时间:18 分钟

作者:David Wheeler

本文在公众号【ClickHouseInc】首发

在开发 pg_clickhouse 的过程中(https://pgxn.org/dist/pg_clickhouse/),我设计了一个 PostgreSQL 的配置项(即 “GUC”(https://github.com/postgres/postgres/blob/master/src/backend/utils/misc/README)),它接受一组键值对,并在每条发送到 ClickHouse 的查询中,将这些键值对作为会话设置自动附加。在 v0.1.0 版本中(https://github.com/ClickHouse/pg_clickhouse/releases/tag/v0.1.0),这些设置以字符串形式保存,并在每次查询时重新解析。为了降低这种重复解析带来的性能开销,我们希望能在设置 GUC 时就完成解析,将其转为键值对结构进行存储。

这篇文章深入记录了这一功能在 v0.1.1 中的实现过程,包括曾尝试的方案与最终落地的实现细节,希望对其他 PostgreSQL 扩展开发者有所帮助。

如果你只是想尝试如何通过 pg_clickhouse 从 PostgreSQL 查询 ClickHouse,不想被这些 C 语言和底层实现细节困扰,可以直接参考教程。

问题与挑战

我们的目标是:避免在每条查询中重复解析 pg_clickhouse.session_settings GUC 中的键值对内容,而是在用户设置该 GUC 时,预先完成解析,并将结果赋值给一个单独的变量。但由于 GUC API 对于处理额外数据(extra data)时的内存分配方式有严格要求,这一过程我尝试了几次才找到既可行又正确的做法。

以下是 pg_clickhouse.session_settings 的参数配置:

DefineCustomStringVariable( "pg_clickhouse.session_settings", "Sets the default ClickHouse session settings.", NULL, &ch_session_settings, "join_use_nulls 1, group_by_use_nulls 1, final 1", PGC_USERSET, 0, chfdw_check_settings_guc, chfdw_settings_assign_hook, NULL );

参数很多,以下是其公开定义(https://github.com/postgres/postgres/blob/bfe5c4b/src/include/utils/guc.h#L393-L402):

extern void DefineCustomStringVariable( const char *name, const char *short_desc, const char *long_desc, char **valueAddr, const char *bootValue, GucContext context, int flags, GucStringCheckHook check_hook, GucStringAssignHook assign_hook, GucShowHook show_hook ) pg_attribute_nonnull(1, 4);

简要说明:

  • name:GUC 名称,用于 SET 命令调用。扩展定义的 GUC 应以扩展名前缀加点开头,因此命名为 pg_clickhouse.session_settings。

  • short_desc:简要描述该 GUC。

  • long_desc:详细描述该 GUC。

  • valueAddr:用于存储值的变量指针。pg_clickhouse 的 GUC 使用一个全局的 char * 类型变量。

  • bootValue:扩展加载时的默认值。

  • context:定义了哪些用户、在何种场景下可以设置该 GUC。我们希望允许所有用户设置 pg_clickhouse.session_settings,但还有其他控制选项。

  • flags:一组 GUC 行为标志位(位掩码),用于控制值的格式化和解析行为。

  • check_hook:值验证回调函数,可在校验同时设置额外数据,供 assign hook 使用。我们的实现中使用 chfdw_check_settings_guc,如果新值无法解析为合法的键值对列表,该函数会抛出错误。

  • assign_hook:将 GUC 的值赋给非 valueAddr 变量的回调函数,可使用 check_hook 设置的额外数据。

  • show_hook:将 GUC 的值进行格式化展示的回调函数,适用于对值进行规范化展示的场景,如时区等。

check_hook 和 assign_hook 的组合机制使我们能够实现预解析键值对并保存 extra 数据的功能。check hook 会将 extra 指向预解析后的数据结构,而 assign hook 则从中读取该数据,并赋值给目标变量。

第一次尝试

我的第一种尝试是:创建一个指向键值对列表的指针,并假设可以在 check hook 中将 extra 指向该结构,然后在 assign hook 中完成赋值操作。大致思路如下:

static bool chfdw_check_settings_guc(char **newval, void **extra, GucSource source) { if (*newval == NULL || *newval[0] == '\0') return true; kv_list * settings = parse_and_malloc_kv_list(*newval); if (!settings) return false; *extra = settings; return true; }

需要注意的是,函数 parse_and_malloc_kv_list() 会通过 malloc() 为整个列表、其中的每项、以及每个键和值分配内存。使用 malloc() 的原因在于,我们打算将这些值保存在全局变量中,不能依赖 GUC 的内存上下文进行释放。

随后在 assign hook 中的操作逻辑是:

static void chfdw_settings_assign_hook(const char *newval, void *extra) { if (ch_session_settings_list) kv_list_free(ch_session_settings_list); ch_session_settings_list = (kv_list *) extra; }

如果已有旧的设置列表,就先释放掉;然后将 extra 中的新列表赋值过去。

表面上看似简单,但实际上我始终无法让这个赋值过程顺利运行;我意识到自己在内存分配、指针、以及多级指针方面的理解还需要加强。

第二次尝试

于是我尝试将赋值操作直接放在 check hook 中,跳过 assign hook。这种做法的代码结构大致如下:

static bool chfdw_check_settings_guc(char **newval, void **extra, GucSource source) { if (*newval == NULL || *newval[0] == '\0') return true; kv_list * pairs = parse_and_malloc_kv_list(*newval); if (!pairs) return false; if (ch_session_settings_list) kv_list_free(ch_session_settings_list); ch_session_settings_list = pairs; return true; }

我在 Postgres 的 Discord 频道中提问了这种做法,Tom Lane 热心地回复了:

绝对不能在 check hook 中修改任何会话状态。这个钩子函数调用的场景,往往只是为了‘预判’一个设置操作(例如 ALTER DATABASE SET)是否有效,并不能保证后续一定会应用这个值。

我还发现,在执行 RESET 命令时,check hook 根本不会被触发。这意味着该方法连 RESET 都无法支持,显然不能继续采用。

第三次尝试

于是我采取了“双重解析”的策略来规避上述问题:在 check hook 中进行一次解析:

static bool chfdw_check_settings_guc(char **newval, void **extra, GucSource source) { if (*newval == NULL || *newval[0] == '\0') return true; kv_list * settings = parse_and_malloc_kv_list(*newval); if (!settings) return false kv_list_free(ch_session_settings_list); /* No errors, return true. */ return true; }

在 assign hook 中再解析一次,逻辑上分别如下所示:

static void chfdw_settings_assign_hook(const char *newval, void *extra) { if (ch_session_settings_list) kv_list_free(ch_session_settings_list); PG_TRY(); { ch_session_settings_list = parse_and_malloc_kv_list(newval); } PG_CATCH(); { ereport(LOG, (errcode(ERRCODE_FDW_ERROR), errmsg("unexpected error parsing \"%s\"", newval))); } PG_END_TRY(); }

现在这两个钩子函数都会调用 parse_and_malloc_kv_list()。虽然是重复解析,但相比于在每条查询中都重复解析一次 session_settings,这种做法的代价还是可以接受的。这个方案后来被整理为 pg_clickhouse#95。

不过我最终还是放弃了这一方案,原因主要有两个:一是它的内存管理逻辑比最终采用的方案更复杂;二是 Tom Lane 在 Discord 上的随口一评让我产生了顾虑:

我不建议在 assign hook 中进行新的内存分配。这个钩子函数应当设计成不会失败。

这让我意识到,虽然在 assign hook 中因分配失败而触发异常的概率非常小,但一旦真的发生,会绕过 PG_TRY() 异常处理机制,从而让这种方案的稳定性无法保证,至少不是理想的选择。

插曲:正确使用 Extra 数据

回顾我第一次的尝试,当时 parse_and_malloc_kv_list() 会使用 malloc() 为构建的整个结构体中的各个部分分别分配内存:包括整个键值对列表指针 kv_list、其中每一项,以及每个 key 和 value 字符串本身。这种分配逻辑至今仍保留在 pg_clickhouse#95 的 kv_list.c 文件中。但正是这种方式,使得我无法正确使用 extra —— 或者说,这已经超出了我当时对 C 语言的掌握。Tom 及时指出了正确的做法:

你应该使用 guc_malloc,而且 extra 数据必须作为一个单独的内存块进行分配,而不能是多个分散的分配。

GUC 的设计原则是:一旦 check_hook 把 extra 数据传回,后续就由 guc.c 来决定在什么时候释放它。必须作为单一内存块的原因是,guc.c 无法识别结构体中其它单独分配的组件。

这种策略的好处是,extra 数据的使用变得规范;check hook 中不再做赋值操作,而 assign hook 仅负责赋值,这样可以消除潜在的错误风险。接下来的问题就是:如何用一次 guc_malloc() 把键值对列表整体分配为一块内存。

第四次尝试

我在 pg_clickhouse#94 中提交的最终方案借鉴了 PostgreSQL 源码中 datetime.c 文件里 ConvertTimeZoneAbbrevs() 的实现思路:先计算出所有键值对字符串所需的总内存,然后一次性分配这一块内存。对应结构如下所示:

typedef struct kv_list { int length; char data[]; } kv_list;

这里使用了一个可变数组 data[],它实际上是一个写入所有 key 和 value 字符串的内存起点。构造函数会按顺序把这些以 null 结尾的字符串写入该区域。这一结构其实并不是标准的 C 结构体。

那么内存是怎么分配的呢?构造函数会遍历所有键值对,计算出各个字符串所需的总空间:

kv_list * list = guc_malloc(ERROR, offsetof(kv_list, data) + summed_size);

在分配内存时,会先算出 kv_list 结构体本身(不包括 data)的大小,再加上 key 和 value 字符串的总和。完成分配后,代码会再次遍历每个键值对,将它们逐个写入从 data 开始的位置。

因为字符串之间没有固定边界,遍历这些数据本身比较棘手,因此我们在 kv_list API 中设计了一个专用的迭代器结构,使得后续读取逻辑变得更简单。

for (kv_iter iter = new_kv_iter(settings); !kv_iter_done(&iter); kv_iter_next(&iter)) { printf("%s => %s\n", iter.name, iter.value); }

最终,这种结构在用于为 binary 或 HTTP 查询传递参数设置时,也让处理流程更加直观。

借助这块一次性通过 guc_malloc() 分配的内存,pg_clickhouse#94 实现了对 check 和 assign hook 的规范使用:

static bool chfdw_check_settings_guc(char **newval, void **extra, GucSource source) { if (*newval == NULL || *newval[0] == '\0') return true; kv_list * settings = parse_and_guc_malloc_kv_list(*newval); if (!settings) return false; *extra = settings; return true; }

这一方案与第一次尝试几乎一样,唯一的区别是 parse_and_guc_malloc_kv_list() 使用 guc_malloc() 将设置整体分配,并通过 extra 传递。assign hook 则不再容易出错:

static void chfdw_settings_assign_hook(const char *newval, void *extra) { ch_session_settings_list = (kv_list *) extra; }

它无需手动释放旧的 ch_session_settings_list,而是由 GUC 系统自动在适当时间进行释放。

还有很多值得学习的内容

这个方案让我感到满意:它有效地减少了内存使用,将释放逻辑交由 GUC 处理,为遍历设置项提供了简洁接口,并正确地应用了 check 与 assign hook 的职责划分。而且我从中学到了很多关于 GUC API 的细节,以及如何在 C 中绕开传统结构体分配限制,实现灵活的内存控制。

尽管如此,我仍然保留了一份 pg_clickhouse#95 的本地副本,作为继续学习 C 指针机制的练习素材 —— 尤其是关于指针、指针的指针,甚至指针的指针的指针。我相信,理论上应该可以让 extra 指向某块内存地址,而 GUC 只移除这个指针而不释放它指向的内存。这样就可以在 check hook 中通过 malloc() 分配数据,并在 assign hook 中安全地赋值。check hook 中可能像这样:

kv_list * settings = parse_and_malloc_kv_list(*newval); if (!settings) return false; /* Allocate just the memory needed to point to a kv_list. */ extra = guc_malloc(ERROR, sizeof(kv_list *)); extra = &settings; return true;

而 assign hook 的逻辑可能是:

ch_session_settings_list = (kv_list *) *extra;

当然,我还没有真正掌握这部分用法。就像我说的,这种方案即使能实现,我也不会将其合并到正式版本中 —— 因为它仍然会让 RESET 命令失效,毕竟 GUC 假设它拥有完整的 extra 数据。但这个挑战促使我继续深入学习 C,也许未来还能找到更“巧妙”的用法。😈

征稿启示

面向社区长期正文,文章内容包括但不限于关于 ClickHouse 的技术研究、项目实践和创新做法等。建议行文风格干货输出&图文并茂。质量合格的文章将会发布在本公众号,优秀者也有机会推荐到 ClickHouse 官网。请将文章稿件的 WORD 版本发邮件至:Tracy.Wang@clickhouse.com

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询