尧图网络 高端网站定制 · 原创设计
免费咨询热线
400-888-6620
免费获取方案
PostgreSQL Table Access Method 实现指南:从回调到存储引擎
1. TableAM 是什么为什么值得关注PostgreSQL 的 Table Access Method简称 TableAM是数据库内核里一套定义“表如何存储、如何访问”的抽象接口。简单说它把表和索引的物理实现从查询引擎里剥离开让存储层变成可以插拔的模块。默认的 heap 存储引擎就是 TableAM 的一个实现而开发者可以自己写一个 TableAM用完全不同的方式组织数据。我最初接触 TableAM 时是在一个模拟项目里研究存储引擎扩展当时好奇的是PostgreSQL 已经有成熟的 heap 表了为什么还要搞这么一层抽象后来踩过一些坑才明白TableAM 解决的是“扩展存储方式”这件事的标准化问题。比如你想做列存表、追加优化表、压缩表或者把数据放到分布式对象存储上如果没有 TableAM你就得去改 executor、改 buffer manager、改 WAL 日志那个工作量几乎是重写整个数据库。而有了 TableAM 之后你只需要实现一套明确的回调函数就能让优化器、执行器、事务模块都通过这套接口来配合你的存储方案。这个主题适合谁如果你在阅读 PostgreSQL 源码、准备数据库内核相关的面试或者你所在的团队需要定制存储格式TableAM 都是绕不开的一块内容。即便是 DBA 角色理解 TableAM 也有助于分析不同表类型的行为差异排查与扩展存储相关的性能问题。TableAM 的核心思想并不复杂定义一套约定的接口让上层机制不关心底层数据是躺在磁盘页里还是躺在远端不关心元组是定长还是变长不关心并发控制是 MVCC 还是别的策略。上层只需要通过“槽位”和“句柄”来读写数据剩下的交给 AM 自己去处理。这也是 TableAM 设计和实现里最优雅、也最考验细节的地方。2. TableAM 的设计思路与接口拆解2.1 一套回调函数如何支撑起整个存储层TableAM 的形态本质上是一个大结构体名为 TableAmRoutine里面挂着接近一百个函数指针。这些指针覆盖了表生命周期、元组读写、索引扫描、并行扫描、采样、分析等几乎全部与存储相关的操作。初次接触这个结构体时我的第一反应是这也太多了。但如果你按职责去分组思路就很清晰了。我把它们归纳成几类表级操作建表、删除表、重新定位文件、调整表大小、表是否支持某种选项等等。元组操作插入、删除、更新、锁定、通过 TID 抓取元组、扫描下一条元组。扫描支持顺序扫描、索引扫描、并行扫描的初始化与资源清理。可见性判断判断某个元组版本对当前事务是否可见。统计与采样analyze 要用到的采样函数、统计信息存储。特殊属性比如表是否支持 TOAST、是否支持 OID、是否需要 WAL 日志。其中对于开发者来说元组读写与扫描是优先级最高的几组。你得先让数据能写进去、能读出来表才能用。而可见性判断则是所有 AM 实现里最容易被忽略却最容易出 bug 的部分。2.2 槽位机制TableAM 与执行器之间的通用数据载体PostgreSQL 执行器和存储层之间传递元组数据用的不是裸的内存结构而是 TupleTableSlot。每个 AM 都需要实现自己的 slot 类型并注册到 slot 机制中。从代码结构上看slot 的类型决定了元组是以物理形式heap tuple还是虚拟形式virtual tuple存在也决定了访问器函数怎么从 slot 里取字段值。初写 AM 时容易犯的错误是直接复用 heap 的 tuple slot这样做确实能省不少事但会限制你的 AM 表达力。例如某些存储引擎的数据本身就在其他内存布局里硬转成 heap tuple 格式反而增加拷贝开销。正确做法是自定义自己的 slot 回调函数让执行器直接操作你本来的数据表示减少不必要的转换。2.3 表级回调的关键路径与候选实现思路理解 TableAM 最快的方式是看建表路径。当用户执行 CREATE TABLE 时优化器不会直接生成文件而是通过 DefineRelation 调用 AM 的 relation_set_new_filelocator 回调。这个回调决定数据文件放在哪、用什么格式初始化。对默认 heap 来说它会在表空间中创建一个物理文件初始化空闲空间映射和可见性映射。对自研 AM 来说这一步可以做成任何事往远端服务注册一个对象、建立内存映射、甚至只在元数据里登记一下。这里要特别提醒的是PostgreSQL 16 之前的代码叫 relfilenode到 PostgreSQL 18 引入了 FileLocator 的概念把逻辑标识和物理实体做了进一步区分。如果你把网上旧版本的代码搬到新版本上大概率会在 relfilenode 相关 API 上报编译错误这是版本差异里最常见的一处坑。2.4 确定性与并发AM 设计里最难的部分支撑 TableAM 的另外一个底层机制是并发与事务语义。PostgreSQL 默认的 heap 使用 MVCC通过多版本元组和事务快照实现隔离。但 TableAM 并不强制你用 MVCC你可以实现自己的并发控制策略比如锁表、版本链、甚至完全不同的可见性算法。不过这带来的问题是你必须在回调函数里老老实实把可见性语义表达完整。如果某个 AM 在 tuple_fetch 时没有正确判断事务状态会导致数据读到一半就出现幻读或者丢更新而且这类问题极难通过常规 SQL 测试发现。我的建议是在动手写可见性逻辑之前先把你想要的事务隔离级别画清楚再对照 PostgreSQL 已定义的 Snapshot 结构逐个实现。3. 实操从零实现一个最小可用 TableAM这一节我以自己做过的一个模拟项目为例带你完整走一遍最小 TableAM 的实现流程。项目背景很简单做一个内存表 AM数据放在共享内存里不落盘适用于临时加速查询场景。需要说明的是这里描述的步骤是基于常见实践的补充目的是帮你建立起对 TableAM 开发流程的整体认知。3.1 初始化环境从扩展骨架到注册 RoutinePostgreSQL 的 AM 开发通常以扩展的形式进行不一定要改内核源码。扩展里定义一个TableAmRoutine结构体然后通过_PG_init注册。你需要准备的环境包括PostgreSQL 的开发头文件、PGXS 构建框架以及一个能跑测试的本地实例。扩展的最小骨架如下#include postgres.h #include access/tableam.h #include fmgr.h #include utils/builtins.h PG_MODULE_MAGIC; PG_FUNCTION_INFO_V1(heap_am_handler_custom); static TableAmRoutine custom_tableam_routine; Datum heap_am_handler_custom(PG_FUNCTION_ARGS) { // 1. 为 routine 结构体分配内存 TableAmRoutine *routine makeNode(TableAmRoutine); // 2. 挂接各个回调函数 routine-tuple_insert custom_tuple_insert; routine-tuple_fetch_row_version custom_tuple_fetch; routine-relation_open custom_relation_open; // ... 其余回调 PG_RETURN_POINTER(routine); }这个 handler 函数通过CREATE ACCESS METHOD命令绑定到扩展里作为 AM 的入口。初次运行时会遇到各种函数指针未设置导致的崩溃所以我强烈建议在 handler 里把所有指针先设为NULL再逐个实现并打日志确认被调用到。3.2 建表流程把自己的 AM 挂到 CREATE TABLE 上假设内存 AM 需要把表元数据存到一个哈希表里那么relation_set_new_filelocator回调里要做的就是在共享内存区域分配一块空间并记录文件名与内存位置的映射。这里有一个细节PostgreSQL 的CREATE TABLE流程会预留物理文件如果完全不想碰磁盘必须保证回调里返回的filelocator有效但并不真正创建文件。在我做这个项目时最简单的方式是把物理文件创建逻辑“截胡”掉但保留 relfilenode 的分配。上层系统目录仍然认为表存在表数据则在内存哈希表里维护。实战里你几乎不可能完全避开文件系统因为系统目录、WAL、检查点等机制都基于文件概念。所以更现实的折中是物理文件只创建成零字节占位真正的数据放在自己管理的存储里。3.3 插入路径从 exec 到 AM 的调用链一条 INSERT 语句进入执行器后会通过table_tuple_insert进入 AM 层。此时传给 AM 的参数包括 Relation、TupleTableSlot、事务状态、以及可选的冲突检测信息。我实现的一个简化版插入函数如下static void custom_tuple_insert(Relation rel, TupleTableSlot *slot, CommandId cid, Snapshot snapshot, bool update_indexes) { // 1. 从 slot 中强制取出物理元组 HeapTuple tuple ExecFetchSlotHeapTuple(slot, true, NULL); // 2. 把元组复制到自己的内存结构里这里用数组模拟 CustomTableData *td (CustomTableData *) rel-rd_amcache; memcpy(td-tuples[td-next_tid], tuple-t_data, tuple-t_len); // 3. 返回给上层设置 TID 或者其他定位信息 ItemPointerSet(slot-tts_tid, td-next_tid, 0); td-next_tid; // 4. 如果有索引触发索引更新通常是调用 index_insert if (update_indexes) CatalogIndexInsert(rel, slot, InvalidBuffer); }这段代码里最关键的一点是 TID 的生成。PostgreSQL 整个执行器和索引模块都依赖 TID 定位元组你的 AM 必须为每条元组分配一个自己的 TID 语义。Heap 用文件页号偏移量内存 AM 完全可以把它映射成数组下标。但注意索引 AM 的索引条目里也存着 TID所以 TID 的编码必须是可逆的否则索引扫描时根本无法找回元组。3.4 扫描路径返回下一条匹配元组扫描回调的核心是iterate系列函数。PostgreSQL 的 Executor 会通过table_beginscan创建扫描描述符然后反复调用table_scan_getnextslot。AM 需要维护一个扫描状态记录当前扫描到哪了。我用数组模拟扫描时实现如下static bool custom_scan_getnextslot(TableScanDesc sscan, TupleTableSlot *slot) { CustomScanDesc *cs (CustomScanDesc *) sscan-opaque; CustomTableData *td (CustomTableData *) cs-relation-rd_amcache; if (cs-current td-next_tid) return false; // 扫描结束 // 把内存里的元组数据填回 slot ExecStoreHeapTuple(td-tuples[cs-current], slot, false); cs-current; return true; }这里有个经验教训ExecStoreHeapTuple的第三个参数是shouldFree如果传 true元组内存的释放责任交给了 slot第二次访问同一元组时可能已经释放传 false 又可能导致多次拷贝。我在实际调试中推荐传 false然后由你自己的存储结构统一管理生命周期避免执行器在多个节点间流转时出现 use-after-free。3.5 挂载测试让 SQL 真正跑起来完成上述代码后编译安装扩展执行CREATE EXTENSION custom_am; CREATE ACCESS METHOD custom_am TYPE TABLE HANDLER heap_am_handler_custom; CREATE TABLE t (id int, info text) USING custom_am; INSERT INTO t VALUES (1, hello); SELECT * FROM t;如果运气好这三条 SQL 就能完整跑通。如果中途报错最有嫌疑的是两个地方一是扫描描述符初始化阶段没设置opaque指针二是系统目录里缺了访问方法的属性定义。我在调这个项目时花了最久的时间定位一个问题建表成功、插入成功、但 SELECT 返回 0 行后来发现是table_beginscan里没有正确注册 Relation 的rd_amcache导致扫描时拿不到数据指针。4. 进阶TableAM 想落地还要补哪些功课4.1 别硬编码调用用 DirectFunctionCall 还是内部接口在 TableAM 实现里经常会碰到需要回调系统内置函数比如heap_table_am_handler的情况。很多初学者直接写DirectFunctionCall1(...)这在某些场景下能工作但会绕开权限校验和函数执行上下文容易掩盖问题。正确做法是尽量使用tableamAPI 层已经暴露的函数例如table_tuple_insert、table_scan_getnextslot或者通过RelationGetTableAmRoutine(rel)拿到 routine 再调用对应指针。这样做的好处是表级别的行为始终遵循 AM 自身的语义不会被某个硬编码路径破坏。4.2 与 MVCC 的边界问题看得见和看不见的过滤PostgreSQL 的可见性判断逻辑和快照耦合很深。如果你的 AM 不使用标准的HeapTupleSatisfiesVisibility那所有扫描回调里都要自己做可见性过滤。常见的错误是只比较事务 ID 大小最新写的就可见。这在单会话测试时没问题但一旦开两个并发会话就会出现脏读。我自己在测试时最喜欢用两个 psql 窗口同时操作同一张表一个反复INSERT一个反复SELECT count(*)很快就能暴露可见性判断不严谨的问题。4.3 WAL 与崩溃恢复不是所有 AM 都能写完就完事PostgreSQL 默认的 heap 表通过 WAL 保证崩溃安全。如果你的 AM 只是实验性质可以声明AMOPT_NO_WAL但随之而来的责任是任何系统崩溃后该表数据可能丢失或损坏这是可以被接受的前提是你明确知道这种取舍。如果你希望 AM 具备持久性和崩溃恢复能力就必须实现 WAL 相关的日志记录和回放函数。这一步工作量不小需要把每个写操作编码成重做记录并在恢复时保证幂等。现实中不少第三方 AM 都选择把 WAL 生成交给通用的RelationLogWAL机制尽量复用已有的日志框架。4.4 排查技巧用 GUC 参数和日志定位问题开发 AM 时建议在代码里显式加上elog(LOG, ...)输出关键路径的进入和退出。虽然这会影响性能但在开发阶段比 gdb 打断点高效得多。另外把debug_deadlocks、debug_query_string等 GUC 参数打开能确认 SQL 执行计划走到了你是不是以为的 AM 回调。我在排一个“索引扫描总是走偏”的 bug 时就是靠打印扫描类型和扫描 key 才定位到问题出在build_index_scan回调里漏了索引条件的过滤。5. 常见问题与排查速查表我在多次调试 TableAM 的过程中把容易踩到的问题整理成了一张速查表方便你对照排查。现象可能原因解决方案CREATE TABLE 报 “access method ... does not exist”handler 函数未注册或扩展未加载确认CREATE EXTENSION已执行检查_PG_init是否被调用INSERT 成功但 SELECT 返回 0 行扫描回调里的opaque没关联到数据区在table_beginscan里分配并初始化opaque并发下读到半新半旧数据可见性判断没有处理事务快照实现按快照过滤的逻辑或复用系统提供的快照判断函数UPDATE 后索引失效更新回调里没有同步维护索引调用CatalogIndexInsert或等价函数崩溃后表数据丢失AM 声明了AMOPT_NO_WAL开发阶段可接受生产环境需实现 WAL 日志编译报错提示 relfilenode 相关 API 找不到PostgreSQL 版本升级后接口改名按当前版本头文件适配使用FileLocator替代老接口内存不断增长元组生命周期管理混乱统一用存储结构持有内存slot 只引用不释放这张表覆盖的是高频问题。真正开发中还会有更奇怪的报错比如触发器和外键相关回调缺失赋默认值导致递归调用失败等。我的通用排查方式是拿到任何异常先看是不是 NULL 函数指针跳进来的再看是不是内存所有权标错了。6. 最后再分享一点个人体会我在做这个模拟项目的过程中最大的体会是TableAM 其实是一套约束非常清晰的插件协议。它不像很多“全自由发挥”的扩展机制而是把存储层所有对外行为都明确列出来让你逐条填写。填得越完整PostgreSQL 的优化器和执行器就越能像对待原生表一样对待你的表填得越偷懒后续暴露的问题就越诡异。如果你的目标是把某个存储引擎集成进 PostgreSQL不妨先花点时间梳理清楚你的引擎与 PostgreSQL 在以下三个层面的差异数据组织方式、并发控制方式、持久化保证。这三个差异基本决定了你需要重写哪些 TableAM 回调而不是盲目照着别人的 AM 抄代码。实操中还有一个小心得先用最简单的数据结构和最粗暴的逻辑把通路跑通再逐步优化。比如先别做压缩、别做异步 IO只是把元组塞进内存数组确保插入、扫描、删除都能工作然后再加上真正的存储特性。直接一步到位实现一个高性能 AM 是非常难的但先有一个可用版本再迭代改进路反而更顺。
RELATED

相关推荐

artcraft从概念到成品:树脂渐变摆件实操与避坑指南

artcraft从概念到成品:树脂渐变摆件实操与避坑指南

1. 从“artcraft”这个名字说起:它到底想解决什么问题 第一次看到“artcraft”这个标题,我脑子里蹦出来的第一反应是:这大概率是一个把“艺术创作”和“手工实现”揉在一起的项目。art 指向审美、设计、视觉表达,craft 指向动手、…

📅 2026/10/21 18:06:59
用DeepWiki和CloudCode高效解析开源项目源码

用DeepWiki和CloudCode高效解析开源项目源码

很多朋友问我,开源项目学习到底卡在哪。照着 README 把环境装起来并不难,难的是装好之后打开 src 目录,几百个文件摊在屏幕上,光标停在第一个文件上,完全不知道往哪儿看。这种体验我太熟悉了。今天想聊的不是“再努力一…

📅 2026/10/21 18:06:59
多模态多跳检索实战:分层组件图与晚期交互如何打通图文证据链

多模态多跳检索实战:分层组件图与晚期交互如何打通图文证据链

如果你做过多模态检索,大概率遇到过这种尴尬场景:问"这张图里是什么"效果很好,但问"这张图里的关键食材,在另一张图里对应哪个品牌"就崩了。前者是单跳,模型看一眼图就能答;后者是典型…

📅 2026/10/21 18:06:59
MORE NEWS

更多资讯

📰

RS232通讯芯片D3232

应用领域D3232芯片主要用于工控主板、工业控制器、程序烧录下载器、仿真器、新能源充电桩等众多涉及RS232通讯的产品。基本特性D3232芯片由两个线路驱动器、两个线路接收器和 一个双路电荷泵电路组成,具有端子间(串行端口连接 端子,包括 GND&…

📰

Agentic Awesome Skills 实战指南:Apify Actor 日志系统深度解析(apify/log 与 Actor.log 全级别使用规范)

Agentic Awesome Skills 实战指南:Apify Actor 日志系统深度解析(apify/log 与 Actor.log 全级别使用规范) 【免费下载链接】agentic-awesome-skills AAS Core is the local, agent-first control plane for complete catalog discovery, age…

📰

双通道音频功率放大器D2025

在音频设备领域,卓越的音质和高效的功率放大是提升用户体验的关键。 D2025 双通道音频功率放大集成电路,凭借其高性能、低功耗和出色的音质表现,成为各类便携式音频设备的理想选择。本文将详细介绍 D2025 的主要特性、应用场景以及其在市场中…

📰

MySQL底层运行原理深度解析与性能优化指南

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

📰

44:第n小的质数

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

📰

嵌入式C与标准C的差异:内存、运行时与编译器行为解析

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

TODAY

今日更新

THIS WEEK

本周精选

THIS MONTH

本月热门

读完文章,想聊聊您的网站?

告诉我们您的行业与需求,资深顾问一对一梳理方案与报价,全程免费。

📞 💬