PostgreSQL之所以扩展性极强,很大程度上得益于内核中广泛存在的Hook(钩子)机制。内核在执行SQL的各个关键路径上预留了函数指针,默认指向内置实现,而外部动态库可以将这些指针替换成自己的函数,从而实现执行器拦截、SQL审计、查询改写等功能。整个过程不需要修改一行PostgreSQL源码,这就是很多知名插件如pg_stat_statements、_timescaledb的底层基础。本文将系统讲解Hook的工作原理,并带大家动手实现一个自定义Hook扩展。

一、Hook机制的基本原理
Hook本质上是内核导出的一个全局函数指针变量。以执行器启动阶段的钩子为例,在PostgreSQL源码的executor目录中可以找到类似这样的定义:
/* executor/execMain.c 中的定义 */ ExecutorStart_hook_type ExecutorStart_hook = NULL; /* 函数指针类型定义 */ typedef void (*ExecutorStart_hook_type) (QueryDesc *queryDesc, int eflags);
内核在真正执行ExecutorStart函数时,会先判断钩子指针是否为空,如果不为空,就调用钩子指向的函数,否则走默认逻辑。外部扩展加载后,把这个指针指向自己的函数,就完成了对执行流程的接管。要注意的是,接管函数内部通常还需要调用原始的默认函数,否则执行器会失去原有行为,直接导致查询失败。
常见的Hook大致分布在SQL处理的四个阶段:解析阶段(如post_parse_analyze_hook,解析树生成后触发)、计划阶段(planner_hook,优化器生成执行计划前触发)、执行阶段(ExecutorStart_hook、ExecutorRun_hook、ExecutorEnd_hook)、以及工具命令阶段(ProcessUtility_hook,处理DDL和特殊命令时触发)。每个阶段的钩子能拿到的数据结构不同,做审计一般选执行阶段,做语句改写则更适合解析阶段。
二、常用钩子的触发时机与适用场景
不同钩子的适用场景差异很大,选错钩子是新手最常见的问题。下面通过一个表格对比几个高频钩子:
| 钩子名称 | 触发时机 | 典型用途 |
|---|---|---|
| planner_hook | 优化器生成计划前 | 查询计划干预、hint插件 |
| ExecutorStart_hook | 执行器初始化时 | SQL审计、权限二次校验 |
| ExecutorEnd_hook | 执行器收尾时 | 采集执行耗时和行数统计 |
| ProcessUtility_hook | 处理DDL命令时 | 拦截DROP、TRUNCATE等危险操作 |
| post_parse_analyze_hook | 语法解析完成后 | 语句改写、脱敏注入 |
举个例子,如果想阻止业务误删表,用ProcessUtility_hook最合适,因为它能在DDL真正执行前拿到完整的ParseState和Node节点,判断语句类型是否为T_DropStmt,再结合表名做白名单判断。而如果只是统计每条SELECT执行了多久,用ExecutorEnd_hook配合queryDesc中的estate字段更方便,可以拿到准确的行数和时间信息。
还有一个容易被忽略的细节:钩子函数是在服务器进程内执行的,每个后端进程都会独立加载一次扩展。这意味着你的钩子函数必须保证可重入,不能依赖进程外的全局状态做同步,多个后端同时触发钩子时的并发安全要自己把控。
三、实战:编写一个SQL审计Hook扩展
接下来动手写一个完整的扩展,目标是记录所有UPDATE语句的执行情况。首先创建扩展目录,包含三个文件:C源码、Makefile和控制文件。
C源码中,核心步骤是保存原始钩子指针、注册新钩子、在钩子函数里处理逻辑后再调用原函数:
#include "postgres.h"
#include "executor/executor.h"
#include "fmgr.h"
PG_MODULE_MAGIC;
/* 保存原始钩子指针,便于链式调用 */
static ExecutorStart_hook_type prev_ExecutorStart = NULL;
void _PG_init(void);
void _PG_fini(void);
static void audit_ExecutorStart(QueryDesc *queryDesc, int eflags);
/* 扩展加载时自动执行 */
void
_PG_init(void)
{
/* 防止重复加载 */
if (prev_ExecutorStart != NULL)
elog(ERROR, "audit hook already loaded");
prev_ExecutorStart = ExecutorStart_hook;
ExecutorStart_hook = audit_ExecutorStart;
}
/* 扩展卸载时恢复原状 */
void
_PG_fini(void)
{
ExecutorStart_hook = prev_ExecutorStart;
}
static void
audit_ExecutorStart(QueryDesc *queryDesc, int eflags)
{
/* 只关注UPDATE语句 */
if (queryDesc->plannedstmt != NULL &&
queryDesc->plannedstmt->commandType == CMD_UPDATE)
{
elog(LOG, "audit: UPDATE detected, queryid=%lu",
queryDesc->plannedstmt->queryId);
}
/* 链式调用:先调上一个钩子,再调默认实现 */
if (prev_ExecutorStart)
prev_ExecutorStart(queryDesc, eflags);
else
standard_ExecutorStart(queryDesc, eflags);
}Makefile按标准扩展模板编写即可:
MODULES = pg_audit_hook EXTENSION = pg_audit_hook DATA = pg_audit_hook--1.0.sql PG_CONFIG = pg_config PGXS := $(shell $(PG_CONFIG) --pgxs) include $(PGXS)
注意_PG_init和_PG_fini是动态库加载和卸载时的固定入口,PostgreSQL加载SO文件时会自动查找这两个符号。PG_MODULE_MAGIC宏也不能省,它用于校验扩展与服务器版本是否兼容,缺少这个宏会导致加载直接报错。
四、编译、加载与常见踩坑点
编译时先确认pg_config指向的是目标数据库的安装目录,然后执行make && make install。加载方式有两种:一是会话级加载,执行LOAD 'pg_audit_hook',只影响当前连接,适合调试;二是全局加载,在postgresql.conf中设置shared_preload_libraries = 'pg_audit_hook'后重启数据库,所有后端进程都会生效,生产环境推荐这种,同时也能避免每个连接重复加载的开销。
有几个坑值得提前留意。第一,钩子必须链式调用原始函数,否则执行器行为会被完全破坏,现象是任何查询都报错或挂起。第二,PostgreSQL大版本之间内部结构体定义可能变化,比如PlannedStmt的字段调整,扩展必须针对对应版本重新编译,跨版本直接复制SO文件会崩溃。第三,钩子函数里不要执行会产生递归的SQL,比如在ExecutorStart钩子里再跑一条SELECT,它会再次触发钩子造成无限循环,确实需要查询元数据时应使用SPI_connect并小心控制递归深度。
最后建议在开发阶段打开日志级别为DEBUG的输出,观察钩子触发顺序,确认链式调用路径正确。如果需要卸载扩展,务必通过_PG_fini恢复原始指针,否则进程内残留的指针指向已卸载的代码段,会引发段错误。掌握了这些要点之后,你就可以基于Hook机制实现SQL防火墙、慢查询采集、多租户隔离等更复杂的插件了,这正是PostgreSQL插件生态的精髓所在。
PostgreSQLHook钩子插件开发修改时间:2026-09-16 05:28:34