PostgreSQL TableAM深入解析:从存储引擎原理到自定义实现

📅 发布时间:2026/10/21 8:15:27
PostgreSQL TableAM深入解析:从存储引擎原理到自定义实现
1. 为什么PostgreSQL需要TableAM1.1 曾经只有一种“表”如果你在PostgreSQL社区待过几年一定听过类似的吐槽PostgreSQL什么都好就是存储引擎只有一种。从任何角度看它本质都是一个堆表heap系统行数据按顺序堆放在数据文件里配合MVCC机制和WAL日志所有表都长一个样。这种“只此一家”的设计在其他数据库里是常态但对开发者来说就非常憋屈了。想做一张列存表得去改核心代码或者用一个绕路方案——存成外部表让别的组件去扫。想做压缩存储那更需要自己写文件格式、自己管理缓冲池所有底层逻辑全要从零开始。更麻烦的是这些改动一旦进了主线就得跟着官方一起去维护对WAL、对崩溃恢复、对备份工具的兼容风险极高。直到PG 12把Table Access Method表访问方法下文简称TableAM这一整套可插拔接口引入内核局面才真正改变。它的思路其实很简单把“表”这个SQL层概念和“物理存储结构”解耦。SQL层定义一个表的时候只需要知道它叫表、有行有列就行至于这些行到底存在文件里、还是压成列存、甚至放在远端都可以交给表访问方法去决定。1.2 TableAM想解决的三个核心问题第一是存储布局的可替换性。过去如果你想给PG做一版“列存内核”基本等于fork一个社区分支从Buffer Pool到Page结构都得改。TableAM出现之后你只需要实现一组表访问接口把自己那份存储格式塞进去SQL层、优化器、执行器、索引逻辑基本不用动。第二是MVCC机制的收敛。heap的MVCC实现内置在存储层里包括xmin/xmax系统列、事务快照判断、垃圾版本回收。TableAM接口把这些逻辑完整暴露出来允许一个自定义AM实现自己的并发控制方案。你可能不需要在第一个版本里重写MVCC但至少接口给了你选择的余地。第三是让扩展沿“官方轨道”走。社区一直有很多外部存储项目过去用FDW绕路实现性能损失在天上地下好几个数量级。TableAM把这些扩展从“外部表这层壳”下沉到真正的存储引擎位置让自定义存储能够参与索引扫描、参与优化器估算、参与VACUUM调度——这些都是FDW做不到的。用一句话概括TableAM是PostgreSQL从“单机多功能数据库”走向“可插拔存储架构”的咽喉要道。它未必适合每个普通应用开发人员但对做数据仓库内核改造、研究存储引擎、以及想深入理解PG到底怎么管“一张表”的人来说这套机制是不可回避的。2. TableAM的整体架构与核心接口2.1 一切都是围绕TableAmRoutine转的如果你打开源码里的src/include/access/tableam.h看到的是一张巨大的结构体TableAmRoutine。它里面全是函数指针几乎覆盖了对一张物理表能做的一切操作。有的函数职责很直观比如扫描scan_begin / scan_getnext_slot、插入tuple_insert、删除tuple_delete、更新tuple_update。还有一些名字特别像“关系管理”的事比如relation_set_new_filelocator给表重新分配物理文件位置、relation_nontransactional_truncate非事务化截断、relation_copy_data物理拷贝表数据。连relation_vacuumVACUUM入口和relation_estimate_size给优化器估算大小都在里面。每次CREATE TABLE执行到DefineRelation阶段内核都会去pg_am系统表里找到对应的handler然后调用它返回一个填满函数指针的TableAmRoutine。之后整张表的生命周期包括建文件、写数据、被扫描、被清理、被删除全部通过这组指针完成。PostgreSQL本身内置的heap实现就是一份完整的TableAmRoutine实现代码在src/backend/access/table/tableam.c和 heap相关文件里。如果你把TableAmRoutine想成一款游戏的角色模板里面每个字段是“技能槽位”。默认的heap角色把所有槽位都填满了你的自定义AM哪怕只填了扫描、插入和建表三个槽位其他槽位哪怕置空或报错系统也会按空槽的默认逻辑尽量兜底——当然兜不住就直接报错。2.2 扫描回调AM必须回答的第一道面试题几乎每一个自定义AM第一步都是先让SELECT能跑通。为此你必须实现完整的扫描路径。传统的heap扫描流程是这样的执行器向AM请求一个“扫描描述符”内部就是TableScanDescAM初始化这个描述符时可以决定“我接下来怎么读物理文件”、要不要预取、要不要按块批量取。然后执行器反复调用scan_getnext_slot每一次AM都负责把“物理存在的东西”变成一个TupleTableSlot交还给上层。有一个细节值得展开PostgreSQL执行器上层并不怎么关心你按什么格式存数据。一切通过TupleTableSlot交互特别是虚拟槽virtual slot。你的AM完全可以不按heap页的物理布局来读只需要每次迭代时把一个slot填干净。常见做法是内部解析一行数据后调用table_slot_create创建一个slot把解析出的列值ExecStoreVirtualTuple塞进去返回给执行器即可。我在实现中习惯把扫描分成三个层级第一个层级是“打开”定位到表文件的起点记录scan位置第二个层级是“取下一行”按自己的存储顺序取出物理记录并解析成slot第三个层级是“关闭”清理句柄与缓冲。如果打算支持并行扫描还需要处理parallelscan_begin。它返回一个包含共享状态的结构体让多个worker分块扫描同一个表。heap的并行扫描是靠按block编号分配实现的你的存储如果没法按block切分并行扫描可以直接暂时返回不支持让执行器走串行路径。2.3 插入、删除、更新事务与可见性的分水岭扫描是“读”插入和更新是“写”。这部分的坑比读要深得多。heap的插入流程是执行器调用tuple_insertAM把元组准备成物理堆记录写入当前事务的页面缓冲区并设置xmin为当前事务ID。如果是更新实际动作等于delete旧版本 insert新版本靠tuple_update一次搞定——多数实现内部就是先删除旧tuple再插入新tuple。但你的自定义AM不一定想走“多版本堆”这套。理论上你可以实现tuple_insert时直接覆盖写文件完全不做版本管理代价是并发事务、读已提交隔离级别、回滚时的数据一致性全都要自己负责。TableAM接口其实没有强制你必须实现完整的MVCC语义——它只保证“通过AM读出来的一定是AM自己写进去的东西”。至于这个结果是否符合SQL标准取决于你的实现态度。实操中我的建议是第一款自定义AM不要试图一上来就实现完整MVCC把tuple_delete、tuple_update接口先返回错误或者退化成“直接覆盖”就行。很多做列存、做压缩存储的AM早期版本都是这样走过来的只支持INSERT不支持UPDATE/DELETE报表类场景用户一般也接受这种约束。想真正做成通用存储引擎MVCC和并发控制是绕不开的独立大工程。2.4 表级管理回调容易被忽略但早晚要用上接口里还有一堆不直接出现在SQL调用路径上、但运维和内核其他模块会调用的回调包括relation_set_new_filelocatorREINDEX、CLUSTER、表空间搬迁等操作依赖它重新分配物理存储relation_copy_for_clusterCLUSTER命令用新顺序重建表时需要AM配合把数据拷贝到新文件relation_vacuum执行VACUUM时让AM自行决定怎么清理自己物理存储中的垃圾relation_sizepg_table_size 等函数要拿到“这个AM管理的物理文件到底有多大”relation_needs_toast_table告诉SQL层你的AM是不是需要额外创建TOAST表来存放超大字段。我的经验是scan和insert是“面子”决定了功能跑不跑得通表级回调是“里子”决定了这东西能不能被日常运维真正用好。如果relation_vacuum返回错误那用户一旦执行VACUUM整个表就动不了如果relation_size不实现磁盘使用量监控看到的永远是0。3. 实操记录从零实现一个“文本文件表AM”3.1 为什么要选文本文件作为Demo实现源码级的TableAM几乎没有一个官方文档手把手教你从头写因为PG的内核文档更像个“参考手册”而不是“动手教程”。我最初实践的时候挑了一个尽可能简单、又能把人带进状态的存储格式把每一行数据按字段分隔符存成文本行一个表就是文件里的一堆文本行。SELECT时逐行读完解析成slot归还给执行器。这个选择有三个好处存储格式完全不同于heap逼你需要认真地走一遍AM注册和回调流程不涉及真实的缓冲区管理、页面结构把精力全部集中到接口上可以随时用文本编辑器直接查看表文件方便调试。注意PostgreSQL在创建自定义AM表时会和普通表一样在PGDATA/base/数据库OID/表OID目录下创建文件。你的AM并不需要自己非要用这个文件也可以把数据“重定向”到别处但最好还是顺着文件系统路径来避免破坏备份工具的预期。3.2 第一步在pg_am里注册你的访问方法PostgreSQL自定义AM有两个层面一个是“访问方法本身”一个是“AM的handler函数”。后者是一条C函数返回TableAmRoutine *前者是pg_am系统表里的一条记录。可以用SQL直接注册CREATE FUNCTION textam_handler(internal) RETURNS table_am_handler AS MODULE_PATHNAME LANGUAGE C STRICT; CREATE ACCESS METHOD textam TYPE TABLE HANDLER textam_handler;CREATE ACCESS METHOD执行后pg_am会多出一条类型为TABLE的访问方法记录。之后建表就能写CREATE TABLE my_text_table (id int, name text) USING textam;注意这里的handler函数在C代码里必须返回一个填好回调的TableAmRoutine函数的形参必须写internal。如果有一步没对上建表时会出现“access method be already defined”或者handler返回空指针这类让人摸不着头脑的报错。3.3 第二步实现建表与最简单的扫描一个能完成“建表插入全表扫描”的基础实现需要这些回调static const TableAmRoutine textam_methods { .tuple_slot textam_slot_ops, .scan_begin textam_scan_begin, .scan_getnext_slot textam_scan_getnext_slot, .scan_end textam_scan_end, .tuple_insert textam_tuple_insert, .tuple_delete textam_tuple_delete, .tuple_update textam_tuple_update, .relation_size textam_relation_size, .relation_vacuum textam_relation_vacuum, };textam_slot_ops定义了该AM使用的TupleTableSlot类型。heap默认使用TTSOpsBufferHeapTuple如果你的AM不依赖缓冲池页可以直接使用虚拟槽TTSOpsVirtual也就是不自带物理槽类型每行临时构造虚拟槽。扫描流程写法很常规static TableScanDesc textam_scan_begin(Relation rel, Snapshot snapshot, int nkeys, struct ScanKeyData *key, ParallelTableScanDesc pscan, uint32 flags) { TextamScan *scan palloc(sizeof(TextamScan)); scan-rel rel; scan-the_file AllocateFile(relpath(rel-rd_locator, RELKIND_RELATION), r); scan-next_lineno 0; return (TableScanDesc) scan; }scan_getnext_slot是核心每次从文件里取一行文本拆分成若干字段填充slot。我用的是PostgreSQL自带的字符串解析函数把文本按\t分割成字段。解析完成后调用ExecStoreVirtualTuple把数据交给上层然后返回true读到EOF时返回false。这里有个容易踩的坑TableScanDesc本身不是固定大小的结构AM可以把它当“基类”在末尾追加自己的私有数据。定义TextamScan时把TableScanDescData放在第一个字段用强制类型转换去访问这是官方heap和seqscan代码的常见做法。3.4 第三步处理INSERT和“事务化”这个地狱我的第一个版本里tuple_insert实现非常直白调用heap风格的做法不合适直接拼接文本行写到文件尾部。但问题是PostgreSQL要求数据变更必须能被事务回滚。如果是真正的核心代码你会用WAL日志或者Undo段来保证但Demo阶段最简单的做法是在文件尾部追加一条带事务ID的标记行回滚的时候扫描文件把所有带这个事务ID的行删掉。偷懒一点的替代方案是“不让INSERT在崩溃恢复中保证持久性”。但这就带来一个现实问题如果崩溃后表文件残缺没有任何机制恢复。所以我的处理是直接提醒自己自定义AM如果不做WAL接口对接只能用于测试环境不能上生产。relation_vacuum如果暂时不实现物理清理可以在回调里直接返回成功但要注意VACUUM不会真正帮你回收空间文件只增不减。3.5 第五步把表文件路径和回调完整串起来完整的实验流程最终长这样编译扩展模块CREATE ACCESS METHOD textamCREATE TABLE t1 (a int, b text) USING textamINSERT INTO t1 VALUES (1, hello), (2, world)SELECT * FROM t1能看到返回两行打开表对应的物理文件确认里面是一行一行可读的文本。走到这一步你已经完成了从SQL层到物理存储层的一个最小闭环。剩下的路再往下走就是各种实际工程问题了。4. 自定义AM的常见问题与排查实录4.1 为什么INSERT没有报错但SELECT总是空表这个场景我第一次跑的时候卡了一整天。原因在于我以为tuple_insert回调只要被调用就结束但实际上PostgreSQL要求AM自己把slot里的数据加到事务关联的“插入结果”中。更准确的讲SQL执行器调用tuple_insert后如果AM没有在这个回调里把数据实际写入自己的文件那么后续扫描当然读不到。典型的错误是回调函数体写了个ereport(ERROR)或者直接没注册这个回调。表能建出来INSERT却失败。我的建议是写自定义AM的时候先给每个回调加上一条调试日志确认调用路径到底走到了哪里。4.2 自定义AM能不能直接使用索引不少第一次接触TableAM的人会有一个误解认为AM只要支持索引扫描存到索引里的指针就直接指向自己的存储结构。实际上PostgreSQL的索引B-Tree、Hash等它们自己就是一套独立的访问方法索引项里保存的是TID包括block number和offset。自定义AM如果想让索引生效就必须保证自己能通过index_fetch_tuple这类回调根据TID回头找到对应版本的元组。这带来一个大问题如果你的表存储结构并没有“块号槽位”这种定位概念得想清楚怎么把你的物理定位信息编码成TID。有的AM直接把TID当作“文件内部偏移量”用有的AM干脆不支持索引扫描一律走全表。别看后者loser很多列存AM在早期都是这么干的毕竟索引带来的优化对OLAP场景并不总是关键。4.3 VACUUM到底清的是什么垃圾heap的VACUUM核心工作是清理死元组死元组来源于UPDATE/DELETE留下的旧版本。如果你的AM完全不支持多版本也没做DELETE/UPDATE那么VACUUM几乎只需要做一个动作告诉优化器“表统计信息已过时”顺便做点文件碎片整理。所以relation_vacuum可以先做成空操作。但如果你实现了覆盖写式的DELETE/UPDATE麻烦就来了旧数据直接被覆盖那么已经打开的游标可能读到不一致的结果。意识到这个问题之后你就明白为什么heap要用追加式的多版本存储了——它用磁盘空间换取了快照隔离的一个很重要的特性。自定义AM想实现同样的隔离级别要么同样做多版本要么老老实实宣布自己不支持某些并发级别。4.4 并行扫描、预取、和崩溃恢复这三个往往不是自定义AM的第一版关心的内容但如果后续要演化成正式项目每一条都绕不开。并行扫描的回调主要是parallelscan_begin和对应的scan_getnext_slot分块逻辑。如果你决定不支持并行扫描就让它保持NULL优化器和执行器会退化为单进程顺序扫。预取和PrefetchBuffer机制只在heap存储上比较重要自定义AM如果自己管理IO建议自己也做一点预取策略。最要命的是崩溃恢复。heap的崩溃恢复依赖WAL日志重放记录进来的每个变更。你的AM不接WAL的话数据库服务器宕机之后文件里可能只有部分写入的内容甚至文件根本处于不一致状态。某些社区扩展的做法是把这部分交给用户自己权衡比如当作“非事务型数据表”使用或者干脆要求这个AM只能存在于测试库。没有WAL接入能力的TableAM绝不要部署到生产环境。5. TableAM、FDW和其他存储扩展的分工5.1 TableAM比FDW更底层很多人在前几年用FDW来实现“外表存储”比如写一个文件FDW让一张普通表指向某个CSV文件。FDW最大的问题是它处于SQL执行器的上一层所有扫描都被当成远程表访问执行优化器对它知之甚少索引条件下推、统计估算、JOIN排序优化全都受限。TableAM完全不同。它处在执行器之下优化器看到的是一张“普通表”只是它知道这张表由谁管理。统计信息照样被收集条件过滤照样执行参与JOIN时也特别自然。如果你需要一个高性能的存储拓展用TableAM写如果你需要一个到达远端数据库的通道用FDW写两者经常互相借鉴但分工不同。5.2 TableAM不会帮你解决分区问题分区表功能对应的是PartitionedTable这一层它本质上是把父表和一堆子表撞在一起的逻辑抽象。每个分区仍然是实体表各自由各自的AM管理。自定义AM可以和分区配合使用但AM本身不感知分区逻辑也不负责路由。所以“给某个AM表加分区”和“给heap表加分区”一样由分区表模块统一处理你不用在AM里做任何额外工作。5.3 对普通开发者和DBA的价值就算你压根不打算写一行C代码理解TableAM也能让你在看一些故障报告时更有方向感。比如某个扩展提供的表不支持更新、某张表的物理文件格式奇怪、某条SQL在EXPLAIN里显示走了自定义扫描……这些现象背后都指向TableAM机制。维护PostgreSQL而不懂它就像用Linux却不了解文件系统——多数时候没事一旦出事就是灰头土脸的事。我在把自定义AM跑通、然后又踩了无数坑之后最大的体会是PostgreSQL的存储引擎其实并没有想象中的“铁板一块”它提前就把“存储”和“关系运算”分清了边界。TableAM只是把这条边界正式化、公开化让后来者不用再靠改源码打补丁做存储。这个边界有多值钱只有你真去读一遍tableam.h的时候才会感受到。