尧图网站设计 尧图网站设计YAOTU DESIGN
ARTICLE DETAIL

资讯详情

深耕网站设计与一线实操的经验洞察。

PostgreSQL GUC扩展开发:extra指针内存管理的四大坑与破解之道

PostgreSQL GUC扩展开发:extra指针内存管理的四大坑与破解之道 写这个扩展之前我完全没想过一个自定义 GUC 参数能把 C 内存管理的问题集中到一起玩出花。当时我要在一个 PostgreSQL 扩展里做动态配置让用户能通过SET mask.rules {phone:3}这种语法把一段 JSON 规则塞进参数里然后在 SQL 执行时读取这些规则做数据脱敏。为了不让每次执行都重新解析 JSON我把解析后的 C 结构体挂到了 GUC 的extra指针上。看起来是很标准的“缓存一下”思路实际跑起来之后从 session 崩溃到内存暴涨到事务回滚时突然 use-after-free前前后后踩了四个坑。这篇就把每个坑的现象、定位过程和最终修法完整记录下来给同样在 PostgreSQL 扩展里折腾 GUC 的兄弟们做个参考。1. 事故背景我为什么要让 GUC 的extra背上这么多私货1.1 需求与最初的设计事情源于一个数据脱敏模块。用户要能动态配置哪些列需要脱敏、用什么规则脱敏而且配置要支持在会话级别临时调整。当时最自然的选择就是做成一个字符串类型的 GUCstatic char *mask_rules NULL; DefineCustomStringVariable(mask.rules, Rules for runtime data masking., NULL, mask_rules, {}, PGC_USERSET, 0, check_mask_rules, assign_mask_rules, NULL);光存字符串是不够的。SQL 执行的时候如果每次都拿字符串去做 JSON 解析性能完全没法看。所以我加了一个check_mask_rules钩子在参数被校验的阶段就把字符串解析成一棵 C 结构体树然后塞进extra指针里。这样assign_mask_rules和执行阶段都能直接拿到解析好的规则对象不用重复解析。接口大概长这样static bool check_mask_rules(char **newval, void **extra, GucSource source, int elevel) { MaskRuleSet *rules parse_mask_rules(*newval); if (rules NULL) { ereport(elevel, (errcode(ERRCODE_INVALID_PARAMETER_VALUE), errmsg(invalid mask rules: %s, *newval))); return false; } *extra rules; return true; }当时我心里想得很简单extra就是留给扩展自己玩的一块“随身携带的私有数据”既然规则解析结果已经被放进去了后面想用的时候直接从*extra里取岂不是美滋滋。1.2 为什么extra是绕不开的有人可能会问为什么不用扩展自己的全局变量非要挂到 GUC 上。PostgreSQL 的 GUC 有一个关键特性它不是简单的“全局变量最新值”而是带着完整的会话上下文、事务上下文和配置栈管理。SET LOCAL会在事务结束时自动恢复原值SET SESSION则跨事务保留postgresql.conf里的配置在 reload 之后也会触发钩子。如果你自己额外维护一份全局变量很容易和 GUC 的保存/恢复机制不同步。事务回滚了、配置栈弹栈了你的全局变量还在傻乎乎地指向旧值。GUC 的extra设计出来的目的就是跟参数值一起保存、一起切换所以正路就是把它当成参数状态的一部分来管理。但问题也出在这里PostgreSQL 负责管理的是extra这个指针本身至于指针指向的内存是懒人堆、malloc 堆还是共享内存它一概不管。谁分配谁就得负责最后的释放。这个“没人管”的状态就是后面四个坑共同的土壤。2. 第一个坑palloc的指针存进extra每条 SQL 结束就会变野指针2.1 现象与初步排查第一次压测的时候问题非常具有迷惑性。单条 SQL 执行、短连接、长连接都看不出毛病但只要在同一个 session 里连续执行几条 SQL就时不时出现segmentation fault。我把崩溃栈抓下来看最后一次调用基本都落在读取mask_rules规则的函数里从extra拿到的指针好像已经变成一堆垃圾值。我一开始怀疑是 JSON 解析器的问题用 Valgrind 跑单条 SQL一切正常但用pgbench跑几十条语句之后Valgrind 开始报 invalid read。顺着报告看访问的地址在pfree之后依然被使用了。这时我才反应过来问题很可能出在check_mask_rules里分配内存时用的是palloc。我写的是MaskRuleSet *rules MemoryContextAlloc(CurrentMemoryContext, sizeof(MaskRuleSet));palloc本身没有错错的是我没有意识到CurrentMemoryContext到底是什么。2.2 根源extra的生存期比MessageContext长PostgreSQL 在执行一条 SQL 时很多内存对象是挂在MessageContext下的。这个 context 的生命周期非常短基本上一条命令处理完就被重置。SET命令触发的 GUC 检查钩子很多路径下就是在类似 message processing 的上下文里被调用的。我把MaskRuleSet分配在CurrentMemoryContext等于把长期要用的结构体塞进了短期内存池里。等这条 SQL 跑完MessageContext被重置palloc出来的那块内存就被回收了但 GUC 参数里的extra指针还牢牢指着那块已经回归给内存池的地址。下次再读取规则读到的是被复用甚至还没被初始化的内存不崩才怪。这里其实是 C 内存管理里最经典的“悬垂指针”问题只是一开始被 PostgreSQL 的 MemoryContext 机制给掩盖了。你以为palloc是安全的可它的安全性是相对于某个 context 的context 一旦 reset这块内存就跟没分配一样。2.3 修复把分配对象挂到足够长的 MemoryContext 上知道生命周期不对修复方案就很直接把规则对象分配到TopMemoryContext下。这个 context 从 backend 进程启动一直活到进程退出不会因为一条 SQL 结束就重置。MemoryContext oldctx MemoryContextSwitchTo(TopMemoryContext); MaskRuleSet *rules parse_mask_rules(*newval); MemoryContextSwitchTo(oldctx);如果直接用palloc还要确保当前 context 是对的所以我更习惯用MemoryContextAlloc并显式指定 contextMaskRuleSet *rules MemoryContextAlloc(TopMemoryContext, sizeof(MaskRuleSet));这里有个小细节TopMemoryContext下的内存在 backend 退出前不会自动释放所以一旦后续需要更新规则旧对象必须自己清理。这就把我引向了第二个坑。3. 第二个坑malloc / pfree 混用内存像被诅咒了一样随机崩溃3.1 定位到 malloc 和 pfree 混用的现场修复第一个坑之后运行稳定了一会儿但没过多久又在另一个地方崩了。这次的崩溃点更加诡异不是在读取规则时而是在一个MemoryContextReset或者pfree调用附近。用 Valgrind 跑完整的测试脚本报错信息是Invalid free(): ptr was not allocated by palloc我顺着栈看发现自己写代码时犯了一个特别低级的错误一部分分配用的malloc释放用的却是pfree。比如解析函数里某个子结构是用strdup创建的后来清理时统一走了一个cleanup_mask_rule_set()函数里面用了pfree。strdup走的是 libc 的堆pfree走的是 PostgreSQL 的 MemoryContext两种内存管理器的元数据布局完全不兼容把一个交给另一个释放轻则报错重则堆损坏而且往往不是当场崩是跑到某个不相干的malloc调用时才炸。3.2 为什么混用起来特别隐蔽PostgreSQL 里并不是说绝对不能调用malloc很多扩展在处理第三方 C 库时确实会用到 libc 的内存函数。但问题在于GUC 的extra指针在项目里被多个函数共享有人用palloc有人用strdup最后同一个释放函数想“通吃”。这种混乱的管理方式在单次执行里可能看不出来因为分配器之间的冲突会延迟爆发。另一个隐蔽点是repalloc和realloc的混用。我在把某个动态数组扩容的时候有时写成repalloc(ptr, newsize)有时又写成realloc(ptr, newsize)。一旦同一块内存被不同分配器操作过行为就完全不确定了。Valgrind 对这种问题的报告经常指向一个完全无关的函数因为堆损坏是在更早的位置发生的。3.3 血泪建议给extra锁死一种分配/释放方式踩完这次之后我给自己立了一条规矩只要这块数据最终要挂进extra它就必须走同一套分配/释放协议绝不混用。如果使用 PostgreSQL 的palloc/repalloc/pfree所有子对象也都用palloc和pfree管理。如果因为某些第三方 C 库的限制必须用malloc/realloc/free那么统一用 libc 那套绝不把 malloc 出来的指针交给 pfree。strdup属于 libc 家族释放时用free不要把strdup的结果交给pfree。所有清理逻辑收敛到一个独立的free_mask_rule_set()函数里避免散落在多个位置各自释放。但是用palloc在TopMemoryContext里依然有个问题你很难控制某个子对象单独释放。比如你想把整个MaskRuleSet作为一棵树整体丢掉最方便的方式是把它挂在一个专属 MemoryContext 下然后MemoryContextReset。这样既不会混用 allocator又能做到整体清理。这个思路后来帮我解决了第三个坑。4. 第三个坑反复SET不释放旧extrasession 内存肉眼可见地涨4.1 泄漏现场第二坑修复后扩展能稳定跑起来了但是压测过程中发现内存占用一路往上走。一个 session 里不停执行SET mask.rules ...用pg_top看 backend 的 RSS每执行一次就涨几十 KB直到 OOM 或者触发系统 cgroup 限制。因为我当时已经把MaskRuleSet挂到了TopMemoryContext而TopMemoryContext的生命周期是 backend 进程结束才结束。旧规则对象在extra指针被替换之后没有任何代码去释放它。我每次SET的时候check_mask_rules都会MemoryContextAlloc一个新对象然后*extra rules。旧的extra呢没人管了。当时我第一反应是“PostgreSQL 不是应该管理extra的切换吗”然后花了一个小时去翻guc.c看到的结果是extra变量本体确实会随着参数栈保存、恢复但 PostgreSQL 不会对extra指向的内存做任何pfree或free。这个语义从设计上就是合理的因为extra里到底有什么只有扩展作者自己知道。4.2 为什么不能无脑先 free 再赋值知道了泄漏原因最直接的做法是在分配新对象之前先把*extra里的旧对象释放掉if (*extra ! NULL) free_mask_rule_set(*extra); *extra rules;但这么做马上会撞上另一个问题check_mask_rules并不是只在“用户执行 SET”时被调用。配置加载、会话启动、参数重置都可能触发这个钩子而且在某些保存/恢复路径里*extra里可能还保存着旧值但旧值这时候还不能释放。比如 PostgreSQL 的 GUC 实现里事务或子事务中修改参数时旧值会被存到 GucStack 里用于回滚恢复。如果你在设置新值的瞬间就把旧extra释放了那回滚时extra要恢复成旧指针就会指向一块已经释放的内存。这个场景正是第四个坑但这里已经能看出简单粗暴地“先 free 再赋值”是不行的。4.3 正确的替换姿势用专属 MemoryContext 管理整个生命周期最终我采用的方案是为这个 GUC 的extra数据单独创建一个MemoryContext并且只让它在真正需要整体替换时才被 reset。具体来说static MemoryContext mask_rules_ctx NULL; static bool check_mask_rules(char **newval, void **extra, GucSource source, int elevel) { MemoryContext oldctx; MaskRuleSet *rules; if (mask_rules_ctx NULL) mask_rules_ctx AllocSetContextCreate(TopMemoryContext, mask rules context, ALLOCSET_DEFAULT_SIZES); /* * 先把旧 context 清掉避免每次 SET 都堆积。 * 但如果这个 extra 正处于事务栈中需要回滚就要额外处理。 */ MemoryContextReset(mask_rules_ctx); oldctx MemoryContextSwitchTo(mask_rules_ctx); rules parse_mask_rules(*newval); MemoryContextSwitchTo(oldctx); *extra rules; return true; }这个方案让整个MaskRuleSet树及其所有子对象都落在同一个 context 里释放时只要 reset 这个 context不需要关心内部结构哪个是palloc出来的、哪个是字符串拷贝出来的。它的前提是旧extra不再被事务栈引用这一点在第四个坑里需要额外部署。当然如果没有这样的专属 context也可以退而求其次用一个static指针专门记录当前extra在替换前手动释放旧对象但这种方式必须配合 GUC 的回滚机制一起考虑不能只在check钩子里做。5. 第四个坑事务回滚后extra被切回旧值旧对象却已经被我 free 了5.1 一次就在ROLLBACK面前翻车的场景第三个坑解决之后我以为万事大吉直到我写了这样一段测试 SQLBEGIN; SET mask.rules {phone:1}; SELECT mask_column(phone, 13800000000); ROLLBACK; SELECT mask_column(phone, 13800000000);在ROLLBACK之后第一次执行SELECT都可能直接崩溃。我当时很懵SET不是在事务里吗回滚之后GUC 参数值应该恢复到事务开始前那么extra也应该恢复到事务开始前的值怎么反而崩了仔细看崩溃时的规则内容发现extra指向的是一块已经被MemoryContextReset回收的内存。原因正在于我实现“替换旧 extra”时把旧对象放到了mask_rules_ctx这个专属于“当前参数值”的 context 里而事务回滚的时候GUC 机制会把extra指针恢复到事务开始前的旧指针。这个旧指针指向的对象早在我执行第一个SET时就被 context reset 给释放掉了。5.2 背后的 GucStack 快照机制PostgreSQL 的 GUC 在事务内被修改时会把之前的值连同extra指针一起压栈。回滚时不是简单地把参数变量设回初始值而是会把extra指针整体恢复成栈上的旧值。如果你当时已经释放了旧指针指向的内存那么恢复后的extra就是一个悬垂指针。这跟普通的 C 语言栈上临时变量还不一样。普通变量恢复的是一份拷贝而extra恢复的是一份指针拷贝。PostgreSQL 并不知道指针背后那块内存是什么也不会去复制一份新的规则对象。它默认的语义是指针背后那块内存的所有者知道如何配合保存/恢复必要时要让指针指向的内存也能随之“生成一个正确的旧状态”。换句话说extra的职责不只是“缓存一下”它必须能在 GUC 参数回滚时仍然有效。如果你在替换新值的时候把旧值的内存清掉了等于把回滚要用的底牌给撕了。5.3 解法把extra数据按“版本”存而不是按“当前值”存这个坑最麻烦的地方在于内存泄漏要求你及时释放旧对象事务回滚又要求旧对象不能在你释放后还被引用。两者互相矛盾。我最后的解决思路是不再把extra指向一块会随替换而清空的数据而是把它做成一个带有版本号的小对象并让规则数据本身存到事务级或会话级不随便释放的 context 中。一个可行的简化版方案是为每次check或assign分配新对象时不急着释放旧对象而是把旧对象留给AtEOXact_GUC/ 配置栈弹栈之后的清理阶段统一处理。具体实现上我会在assign_mask_rules钩子里记录旧extra在合适的时机比如事务结束、参数再次被设置时再释放。但这不是三言两语能写清楚的标准模板实际代码里要根据扩展使用的是PGC_USERSET还是PGC_SIGHUP、是否支持SET LOCAL、是否需要跨事务保留来定。我这边比较通用的一种做法是这样参数值每次变化时分配新的MaskRuleSet并让extra指向新对象。旧对象不立即释放而是放进一个“待清理队列”。在事务成功提交后或者确认没有 GucStack 引用旧extra之后再安全释放。如果涉及回滚GucStack 恢复的旧extra会从队列里取出保证它没有被释放。更简单点如果扩展对内存不敏感可以在整个 backend 生命周期内都保留所有历史extra对象只在 backend 退出前统一清理。这虽然浪费一点内存但换来了绝对安全。我的扩展对内存没那么抠最终就是在保存/恢复路径上做了个“延迟释放”的处理回滚后旧指针依然有效提交后旧对象再交给一个清理函数干掉。从结果看这个方案的收益是显而易见的事务内随便SET、ROLLBACK、SAVEPOINT都不再出现悬垂指针长时间反复修改参数内存增长速度也回到了正常水平。6. 教训沉淀驯服extra的四条铁律这个问题的本质不在 PostgreSQL而在 C 内存管理。GUC 的extra只是个背锅的它把一个复杂的生命周期问题暴露在了你面前。我把这四次踩坑的经历总结成一张表后面做扩展的时候可以直接拿来对照坑根因修复思路第一个坑SQL 结束后崩溃extra指向的内存挂在生命期很短的MessageContext下分配对象时显式指定TopMemoryContext或更合理的长期 context第二个坑随机崩溃 / Valgrind 报 invalid freemalloc和palloc混用释放函数对不上锁死一套分配/释放协议推荐全部走 MemoryContext第三个坑反复 SET 内存暴涨新extra覆盖旧extra旧内存从未释放用专属 MemoryContext 整体 reset或手动释放旧对象第四个坑ROLLBACK 后崩溃事务回滚恢复旧extra指针但旧对象已被释放延迟释放确保 GucStack 不再引用旧对象后再清理如果只记一句话我会说GUC 的extra是跟参数状态一起保存和恢复的指针它不是让你“临时挂个缓存”这么简单你必须把它的保存、恢复、替换、释放四个动作全部纳入设计。最后再分享一个小经验调试这种问题光靠gdb打印extra值是很难定位的因为问题往往发生在你设置extra之后很久。建议在check钩子和assign钩子里各加一个日志打印出extra指针的地址、分配来源和当前参数值同时在MemoryContext创建时给它起一个有辨识度的名字比如mask rules ctx这样一旦编译一个带--enable-cassert的 PostgreSQL很多悬垂指针问题能在 context 校验时直接暴露出来。我就是靠这招把第四个坑从“看起来像随机崩溃”定位成了“回滚后访问已释放内存”。说句实在话经过这次折腾我反而觉得 GUC 这套机制设计得是合理的。它把复杂的内存所有权明确丢给了扩展开发者只要你理解了指针的保存/恢复语义就能写出既高效又稳定的实现。但如果当初有人先告诉我“extra不是缓存”我大概能少熬两个通宵。
返回列表