本文字数: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