PostgreSQL插件说白了就是一组按约定编译好的动态库加载进数据库内核后能让你像调用普通SQL函数一样去调用C语言写的逻辑。我刚接触这个概念的时候也觉得它高不可攀毕竟要跟数据库内核打交道但实际跟了一遍完整流程才发现PG早就把扩展协议做得非常完整一个最小插件甚至只需要三个文本文件和一个C函数。这篇文章我会按从零开始写插件的真实路径走一遍先把“插件到底是个什么东西”讲清楚再带你写一个能编译运行的示例最后把调试和版本管理这些容易踩坑的地方一次性说透。适合已经会基本的SQL、能跑通CREATE EXTENSION、但对C接口还不太熟的人。1. 写插件前先搞清楚你要写的到底是什么1.1 PostgreSQL里的插件是一套协议不是单文件很多新手一听到“写PostgreSQL插件”第一反应是“我要去改数据库源码了”。其实不是。你写的大多数插件本质上是一组按照PG约定放在指定目录下的文件由CREATE EXTENSION命令把它组装起来。这套东西在PG社区里习惯叫“Extension”也就是扩展而“插件”是更口语的说法。一个标准扩展至少包含四样东西一个动态库文件比如my_plugin.so里面是你用C写的函数、类型、操作符等真正干活的部分。一个控制文件比如my_plugin.control告诉PG这个扩展叫什么、默认版本是多少、动态库路径在哪。一个或多个SQL脚本比如my_plugin--1.0.sql用来在数据库里创建函数、类型、操作符等数据库对象。一个Makefile负责把C源码编译成动态库并把文件复制到PG的扩展目录。CREATE EXTENSION做的事情就是读控制文件、执行对应版本的SQL脚本把动态库和数据库对象绑定起来。打个比方动态库是积木块SQL脚本是拼装说明书控制文件是盒子外面的标签。三者缺一个扩展就用不了。1.2 三种值得用C插件解决的场景遇到问题先别急着写C很多场景用普通的PL/pgSQL函数就够了。但下面三类场景C插件的价值非常明显第一类是“性能敏感但逻辑固定”的计算。比如在一个大数据量表上做文本相似度、距离计算、编码转换PL/pgSQL每行都要解释执行循环开销大C代码直接跑在数据库进程里没有解释器开销也不会有额外的类型转换损耗。第二类是“SQL和PL/pgSQL表达不了”的内核级能力。比如自定义数据类型、自定义聚合函数、自定义操作符、自定义索引访问方法。这些不是过程逻辑而是数据库本身需要理解和优化的对象必须通过C接口告诉PG“这个类型内部长什么样”、“怎么比较大小”、“怎么支持排序”。第三类是“需要和外部系统深度交互”的场景。比如把外部数据源映射成一张表、接入一种新的文件格式、挂一个自定义的全文检索分词器。这些能力往往涉及文件描述符、网络连接、底层协议PL/pgSQL根本拿不到那些系统资源。所以写插件不是炫技而是当标准SQL能力不够用时把数据库内核打通的一把钥匙。1.3 写插件会影响什么范围写一个插件影响的通常不是某一个SQL语句而是整个数据库实例对某些能力的使用方式。比如你定义了一个新类型那所有会话都能用这个类型建表、做条件过滤、参与计算你注册了一个新操作符优化器就会在生成执行计划时考虑它可能被用于索引扫描。这也是为什么插件虽然小但风险不小。一个C函数里出现内存越界能让整个数据库进程崩掉不是报个错那么简单。所以后面调试那部分我会专门讲怎么降低这种风险。2. 动手前先把环境搭对否则后面全是坑2.1 别拿生产库练手先确认开发头文件写C插件第一个必须确认的是“开发环境版本”和“运行数据库版本”是否匹配。PG的C接口在不同大版本之间是有差异的尤其是一些内部结构体变化。你在14上编译的.so拿到15的实例上加载轻则报版本不兼容重则直接崩溃。先跑一句pg_config --version pg_config --includedir-server pg_config --pkglibdirpg_config是PG自带的工具能告诉你当前安装版本对应的头文件目录和动态库目录。如果系统里同时装了多个PG版本一定要确认你用的pg_config和你要加载插件的那个PG实例是同一个版本。很多人踩的坑就是PATH里先找到了旧版本的pg_config编译出来的动态库加载到新版本实例上报错。另外很多操作系统发行版把PG的开发头文件单独拆成了一个包比如名字里带-dev或-devel。只装客户端和服务器还不够必须把postgresql-server-dev-XX这类包装上否则编译时找不到postgres.h。2.2 最小插件的四个文件一个都不能少我先给你一个能跑的最小插件叫hello_plugin。它只做一个事返回一段固定文本。但通过它你能把整个编译、安装、加载链路跑通。先看C文件hello_plugin.c#include postgres.h #include fmgr.h #include utils/builtins.h PG_MODULE_MAGIC; PG_FUNCTION_INFO_V1(hello_world); Datum hello_world(PG_FUNCTION_ARGS) { PG_RETURN_TEXT_P(cstring_to_text(hello from pg plugin)); }控制文件hello_plugin.control# hello_plugin.control comment A minimal hello extension default_version 1.0 module_pathname $libdir/hello_plugin relocatable trueSQL脚本hello_plugin--1.0.sqlCREATE FUNCTION hello_world() RETURNS text AS MODULE_PATHNAME, hello_world LANGUAGE C STRICT IMMUTABLE PARALLEL SAFE;MakefileMODULE_big hello_plugin OBJS hello_plugin.o EXTENSION hello_plugin DATA hello_plugin--1.0.sql PG_CONFIG pg_config PGXS : $(shell $(PG_CONFIG) --pgxs) include $(PGXS)目录名和文件名要保持一致hello_plugin.control、hello_plugin--1.0.sql、hello_plugin.so这三样东西都以hello_plugin为主名。2.3 编译安装并验证在文件所在目录执行make make install如果当前用户没有权限写PG安装目录需要加上sudo make install。这一步会把.so、.control、.sql都复制到对应目录。然后启动psql连接到想测试的库执行CREATE EXTENSION hello_plugin; SELECT hello_world();能看到hello from pg plugin就说明整条链路通了。这里有个经验make install之后如果psql还是提示找不到扩展先别急着怀疑文件没装好用pg_config --sharedir看扩展SQL目录到底在哪里再和实际安装路径对比。很多发行版会自己定义PG的安装前缀直接find / -name hello_plugin.control是最快的确认方式。3. C函数和SQL之间的桥是怎么搭起来的3.1 从PG_FUNCTION_ARGS到Datum插件里的C函数不是给普通程序调用的普通函数。每个可被SQL调用的C函数都必须使用PG约定的签名和宏。只要你写一个供SQL调用的C函数结构基本是这样PG_FUNCTION_INFO_V1(my_add); Datum my_add(PG_FUNCTION_ARGS) { int32 a PG_GETARG_INT32(0); int32 b PG_GETARG_INT32(1); PG_RETURN_INT32(a b); }对应SQLCREATE FUNCTION my_add(integer, integer) RETURNS integer AS MODULE_PATHNAME, my_add LANGUAGE C STRICT IMMUTABLE PARALLEL SAFE;这里最需要理解的是Datum。PG内部为了实现跨类型的统一接口把所有参数和返回值都塞进一个8字节的容器这个容器就是Datum。对于整数类型直接塞数值对于指针类型塞的是指向内存的地址。PG_GETARG_INT32(0)就是从第一个参数对应的Datum里把int32解出来PG_RETURN_INT32(a b)则是把计算结果重新包成Datum返回。注意SQL里的integer在C里对应的是int32不是C语言的int。不同平台int宽度可能不一样但PG内部明确是4字节。这个细节不处理好很容易在64位环境下出现截断或读出脏数据。3.2 千万不要用malloc返回结果内存上下文C插件里最容易出的问题就是内存管理方式用错。你在普通C程序里习惯用malloc分配内存但PG的代码里更推荐用palloc。原因是PG执行SQL时会维护一个内存上下文。同一个查询里分配的小内存块在查询结束时会统一释放。用palloc你不用每个分支都去手动pfree上下文会自动回收。用malloc的话要么泄漏要么你手动释放的时机不对引用到了已经释放的内存。但也不是说完全不能用malloc。如果只是一个函数内部的临时计算缓冲区用完立即free没问题。你唯一不能做的是把一个malloc出来的指针塞进PG的返回值里让上层按palloc管理去释放。那基本必崩。抛错误也不要用printf加exit而是用PG提供的ereport(ERROR, ...)。它会触发事务层面的错误处理而不是把整个数据库进程干掉。3.3 函数标记比实现细节更影响行为上面SQL里的STRICT IMMUTABLE PARALLEL SAFE不是随便写的每个都有实际含义。STRICT表示只要任何一个参数为NULL函数就不执行直接返回NULL。这等于自动帮你做了空值判断。但如果你的业务逻辑需要区分“参数是NULL”和“参数是空字符串”就不要设STRICT否则NULL值根本进不了你的C函数。IMMUTABLE告诉优化器同样的输入永远得到同样输出可以放心地做常量折叠、放到索引表达式中。如果函数内部读了表、依赖了当前时间或会话设置比如current_setting那就绝对不能标IMMUTABLE否则会得到错误结果。PARALLEL SAFE允许这个函数在并行worker里执行。如果你的函数用了只有主进程才有的资源或者写临时文件、连外部网络都要标成PARALLEL RESTRICTED或PARALLEL UNSAFE否则会产生竞态。4. 完整实战写一个文本相似度计算插件4.1 为什么选“编辑距离”这个例子编辑距离也叫Levenshtein距离是文本模糊匹配里非常经典的算法。业务上很像“字符串清洗”场景两张表里的地址写法不完全一致需要判断是否指向同一个地方或者用户填的姓名和身份证记录里的姓名有少量错字要做模糊关联。这种逻辑用PL/pgSQL写出来也不难但一旦数据量到百万、千万行解释执行的循环会拖慢整条SQL。而C实现可以稳定地控制每一行的计算成本插件接口又允许它像普通函数一样直接用在SELECT和JOIN里。用来演示完整流程非常合适。4.2 C代码完整实现在项目目录下新建my_similarity.c内容如下#include postgres.h #include fmgr.h #include utils/builtins.h #include string.h PG_MODULE_MAGIC; static int min3(int a, int b, int c) { if (a b) a b; if (a c) a c; return a; } PG_FUNCTION_INFO_V1(levenshtein_distance); Datum levenshtein_distance(PG_FUNCTION_ARGS) { text *left PG_GETARG_TEXT_PP(0); text *right PG_GETARG_TEXT_PP(1); char *lstr text_to_cstring(left); char *rstr text_to_cstring(right); int llen strlen(lstr); int rlen strlen(rstr); int *prev (int *) palloc((rlen 1) * sizeof(int)); int *curr (int *) palloc((rlen 1) * sizeof(int)); int i, j; for (j 0; j rlen; j) prev[j] j; for (i 1; i llen; i) { curr[0] i; for (j 1; j rlen; j) { int cost (lstr[i - 1] rstr[j - 1]) ? 0 : 1; int del prev[j] 1; int ins curr[j - 1] 1; int sub prev[j - 1] cost; int m (del ins) ? del : ins; curr[j] (m sub) ? m : sub; } int *tmp prev; prev curr; curr tmp; } int result prev[rlen]; pfree(curr); pfree(prev); pfree(lstr); pfree(rstr); PG_RETURN_INT32(result); }流程上PG_GETARG_TEXT_PP拿到PG内部text类型的指针再用text_to_cstring转成C字符串。这里要注意返回的char*也是PG内存上下文分配的用完要pfree。动态规划部分用两行数组滚动计算prev表示上一行的距离curr表示当前行的距离每次循环结束互换指针。最终结果在prev[rlen]里。这个实现有几个简化它按字节计算不是按字符计算对多字节字符集不一定完全符合业务预期它也没有做长度阈值优化超长文本会比较消耗内存。如果真要上线用建议至少加上“其中一个字符串为空时的提前返回”以及“长度差距超过阈值时直接返回最大距离”这类剪枝。4.3 插件配置文件与SQL声明继续沿用第二章的骨架把文件主名改成my_similarity。控制文件my_similarity.control# my_similarity.control comment String similarity functions for PostgreSQL default_version 1.0 module_pathname $libdir/my_similarity relocatable trueSQL脚本my_similarity--1.0.sqlCREATE FUNCTION my_similarity(text, text) RETURNS integer AS MODULE_PATHNAME, levenshtein_distance LANGUAGE C STRICT IMMUTABLE PARALLEL SAFE;MakefileMODULE_big my_similarity OBJS my_similarity.o EXTENSION my_similarity DATA my_similarity--1.0.sql PG_CONFIG pg_config PGXS : $(shell $(PG_CONFIG) --pgxs) include $(PGXS)注意SQL里函数名是my_similarity但C函数符号是levenshtein_distance两者不要求一致。AS MODULE_PATHNAME, levenshtein_distance里的第二个字符串就是指定动态库里的导出符号名。MODULE_PATHNAME是PG的特殊替换符加载时会替换成控制文件里module_pathname的实际值。4.4 编译安装并验证结果执行make clean make sudo make install然后psql里CREATE EXTENSION my_similarity; SELECT my_similarity(kitten, sitting);结果应该是3。因为kitten要变成sitting需要把k替换成s把e替换成i再在末尾加一个g正好3次编辑操作。如果你想快速验证性能可以建一张小表造几万行数据用EXPLAIN ANALYZE SELECT my_similarity(a, b) FROM ...看一下执行时间。相比同逻辑的PL/pgSQL版本C版本在数据量大时会把单行计算成本压得很低而且不会因为循环写得不太优雅产生额外的解释执行开销。5. 写插件必遇的坑编译、加载、调试5.1 动态库加载失败怎么排查最常见的报错是ERROR: could not load library /usr/local/pgsql/lib/my_similarity.so: lib...so: cannot open shared object file这类问题十有八九是文件没装到正确目录或者系统动态库搜索路径里找不到依赖库。先用ls确认.so是否真的在pg_config --pkglibdir指向的目录里再用ldd查看它依赖的动态库是否能被系统找到。如果你依赖了第三方库可能需要设置LD_LIBRARY_PATH或者把动态库放到系统的标准搜索目录。还有一类常见于“本机同时有多个PG版本”ERROR: incompatible library ... server version 15, library version 14这几乎可以确定是pg_config指向了别的版本。重新检查PATH环境变量保证编译时用的pg_config和运行时用的PG实例版本一致然后重新编译、重新安装。5.2 崩溃和内存错误怎么定位C插件崩溃和普通C程序崩溃一样最常见的是段错误。但数据库场景里更隐蔽因为一个崩溃可能把整个PG进程带崩影响的是所有连在这个实例上的会话。我自己的习惯是先分两类去排一类是“类型不匹配”。SQL层声明参数是int4C函数里却用PG_GETARG_TEXT_PP去取那拿到的一定是垃圾指针。每个PG_GETARG宏都必须和SQL声明的类型严格对应。这个排查思路很简单先把函数声明和C代码里每个参数的宏列出来逐一对照。另一类是“内存越界或重复释放”。动态规划里如果长度算错很容易越界写。比如我示例里的palloc((rlen 1) * sizeof(int))如果你写成了rlen * sizeof(int)循环到j rlen时就会越界。排查时可以在关键步骤前后加elog(INFO, step, len%d, val);打印把问题范围缩小到某一个循环或某几个参数。也可以开core dump用gdb看崩溃时的调用栈栈里指向哪个函数哪一行信息非常直接。提示开发期可以把client_min_messages调成debug1再把代码里关键节点用elog(DEBUG1, ...)打出来。但别把这种日志带进生产环境插件上线前要把调试日志清理干净。5.3 扩展升级脚本要怎么写插件不是写完了就结束后面大概率要加新函数、修bug。PG的扩展机制支持平滑升级但需要你按规范补脚本。比如现在要加一个新函数my_similarity_ext同时把插件版本升到1.1。你需要做三件事第一修改控制文件的default_version 1.1。第二为全新安装准备一个my_similarity--1.1.sql里面包含从1.0版本以来的所有数据库对象也就是说它必须能独立建出完整扩展。第三为已安装1.0的库准备一个升级脚本my_similarity--1.0--1.1.sql只写两个版本之间的差异。升级脚本示例-- my_similarity--1.0--1.1.sql CREATE FUNCTION my_similarity_ext(text) RETURNS text AS MODULE_PATHNAME, my_similarity_ext LANGUAGE C STRICT IMMUTABLE PARALLEL SAFE;然后在psql里执行ALTER EXTENSION my_similarity UPDATE TO 1.1;如果已经运行的会话在升级前就加载了旧动态库可能需要断开重连甚至重启数据库实例才能让新代码真正生效。这个我踩过不止一次代码改了、make install也跑了但旧psql会话里一直还是旧行为不是没装好是会话缓存了旧函数计划。6. 再往前走一步类型、操作符、聚合6.1 自定义类型从函数库到数据类型只写函数还只是插件能力的冰山一角。很多插件真正的价值是定义一种全新的数据类型让用户能像用int4、text一样去建表、比较、排序。自定义类型在C层通常是一个结构体然后你提供一对输入/输出函数。输入函数负责把字符串文本解析成结构体输出函数负责把结构体转回字符串。比如要定义一个二维坐标点typedef struct Point2D { double x; double y; } Point2D;对应C函数point2d_in接收一个cstring用sscanf或手动解析出x和ypoint2d_out则按固定格式输出。之后在SQL里CREATE TYPE point2d ( INTERNALLENGTH 16, INPUT point2d_in, OUTPUT point2d_out, STORAGE plain );这个类型就建出来了。你先不用管内部实现细节只需要体会一点类型不是只有SQL能创建C插件可以把任意二进制结构包装成数据库类型这是PL/pgSQL完全做不到的。6.2 操作符和操作符类让类型能被优化器使用有了类型下一步自然是想支持比较操作。比如判断两个point2d是否相等、谁大谁小。你可以先写一个返回bool的C函数再用SQL语法把它映射成操作符CREATE FUNCTION point2d_eq(point2d, point2d) RETURNS boolean AS MODULE_PATHNAME, point2d_eq LANGUAGE C STRICT IMMUTABLE PARALLEL SAFE; CREATE OPERATOR ( LEFTARG point2d, RIGHTARG point2d, FUNCTION point2d_eq );但这只是“能用操作符”数据库还不会自动为这个类型建索引。如果想让btree索引认识point2d还需要定义一个操作符类把小于、小于等于、大于等一套操作符都关联起来。操作符类一旦建好优化器就可以对这个类型做排序、去重、索引扫描。这个阶段才算是真正碰到了PG内核的设计精髓一切能力都是可插拔的。6.3 新手进阶路线建议我见过不少人一上来就想写一个完整的索引访问方法结果卡在内存和可见性管理上项目直接烂尾。更务实的路线是先把函数插件写熟搞明白Datum、内存上下文、函数标记这些基础概念再做自定义类型和输入输出函数理解数据在磁盘和内存里怎么表示然后做操作符和操作符类理解优化器怎么用操作符之后再碰聚合函数和窗口函数最后才考虑外部数据源和索引访问方法这种高阶主题。阅读源码时优先看PG源码目录下的contrib示例那里面全是官方维护的标准插件从简单的函数库到复杂的外部数据包装器都有。把里面一个经典示例从头读到尾比看十篇零散教程都管用。最后分享一个实测里的习惯个人写插件这几年最大的体会是不要一开始就把目标定成“写一个很复杂的东西”。先让最小的例子跑通编译、安装、CREATE EXTENSION、调用每一步都确认没问题再往里面加逻辑。遇到崩溃先怀疑类型不匹配再怀疑内存越界按这两个方向去查多数问题都能定位。最后一个小技巧开发期每次修改C代码后重新make install然后务必新建一个psql会话再测试千万不要在旧会话里反复执行同一个函数。旧会话的缓存会让动态库加载行为显得“没更新”这个问题我排查过太多次浪费时间不值当。