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_vacuum(VACUUM入口)和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请求一个“扫描描述符”,内部就是TableScanDesc;AM初始化这个描述符时,可以决定“我接下来怎么读物理文件”、要不要预取、要不要按块批量取。然后执行器反复调用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_insert,AM把元组准备成物理堆记录,写入当前事务的页面缓冲区,并设置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_filelocator:REINDEX、CLUSTER、表空间搬迁等操作依赖它重新分配物理存储;relation_copy_for_cluster:CLUSTER命令用新顺序重建表时,需要AM配合把数据拷贝到新文件;relation_vacuum:执行VACUUM时让AM自行决定怎么清理自己物理存储中的垃圾;relation_size:pg_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 textam;CREATE TABLE t1 (a int, b text) USING textam;INSERT 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的时候才会感受到。
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_vacuum(VACUUM入口)和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请求一个“扫描描述符”,内部就是TableScanDesc;AM初始化这个描述符时,可以决定“我接下来怎么读物理文件”、要不要预取、要不要按块批量取。然后执行器反复调用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_insert,AM把元组准备成物理堆记录,写入当前事务的页面缓冲区,并设置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_filelocator:REINDEX、CLUSTER、表空间搬迁等操作依赖它重新分配物理存储;relation_copy_for_cluster:CLUSTER命令用新顺序重建表时,需要AM配合把数据拷贝到新文件;relation_vacuum:执行VACUUM时让AM自行决定怎么清理自己物理存储中的垃圾;relation_size:pg_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 textam;CREATE TABLE t1 (a int, b text) USING textam;INSERT 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的时候才会感受到。