读完 OrioleDB 源码,DBA 决定试一试
上一篇写完 OrioleDB 解决了 PG 哪些老问题(vaccum、wraparound、buffer mapping、page-level WAL),承诺这篇专门讲源码导读。先说为什么 DBA 需要读源码。
PG 圈子的 DDL/DML 工具都有一层厚厚的 README + 用户文档,但存储引擎这一层是另一回事。底层代码量大、耦合 PG 内核、新概念多(undo log、page merging、sys-tree),单靠文档判断"这玩意靠不靠谱"远远不够。一家公司的 OrioleDB benchmark 和你公司的真实 workload 永远不一样——你必须能自己看代码才能判断它的设计假设是不是覆盖你的场景。
OrioleDB 主仓库 2026-09-05 时是 153 个 C/H 文件,src/ 目录 119,349 行 C,include/ 14,003 行 H。这不是 5 分钟能读完的量,但也不是要全读。这篇文章给你 5 个文件 + 一条 30 分钟路径,看完你能向团队讲清"这玩意是工程严谨的还是一坨"。
文件 0:怎么"挂"进 PG——orioledb.c
src/orioledb.c 2390 行。这是 OrioleDB 扩展的入口,但作为 DBA 你只需要知道三件事:
第一,PG 扩展怎么被加载。第 89 行 PG_MODULE_MAGIC;——这是 PG 扩展的"准入签名",PG 启动时通过 shared_preload_libraries 加载 orioledb.so 时第一个被调用的宏。如果它缺失,PG 启动会立刻拒绝加载扩展。这就是为什么 OrioleDB 要求 shared_preload_libraries = 'orioledb.so'——它必须在 PG 启动早期介入,不是按需加载。
第二,所有 GUC 在一个文件里。第 544 行 _PG_init() 是 PG 扩展初始化的入口;从 571 行开始,约 50 个 DefineCustom*Variable 调用按命名空间顺序排开:orioledb.main_buffers、.undo_buffers、.recovery_pool_size、.serializable、.enable_stopevents、.debug_recovery_crash_lsn、.s3_mode 全部在这里。这意味着你要调 OrioleDB 任何一个参数,就搜这个文件——所有 GUC 的名字、类型、默认值、描述都在这里。
第三,GUC 分类透露出工程取舍。从命名空间看,OrioleDB 把"内存布局"参数(main_buffers 等 7 个)、"恢复"参数(recovery_queue_size 等 4 个)、"调试钩子"参数(debug_* 5 个)、"S3 集成"参数(s3_* 12 个)四类分别管理。7 个独立 buffer 池不是堆参数——是显式分层:每层生命周期不一样、淘汰策略不一样、调参曲线不一样。S3 相关参数 12 个意味着 OrioleDB 想做"冷数据自动落 S3",这是一条还没完全讲清楚的故事。
读这个文件的方法:只读 89-90 行(PG_MODULE_MAGIC)、544-548 行(_PG_init 入口)、571-880 行(GUC 定义)。一个下午 30 分钟。这三个位置告诉你 OrioleDB 怎么进门、能调什么、坑在哪。
文件 1:怎么"绕过 buffer mapping"——btree/page_state.h
include/btree/page_state.h 87 行。DBA 视角下 OrioleDB 最有价值的设计就在这个头文件里,因为你大概率已经为 LWLock:BufferMapping 这把锁加过无数个 pg_stat_activity 查询。
两个 bit 标志撑起整个无锁读模型。第 21-22 行:
#define PAGE_STATE_LOCKED_FLAG UINT64CONST(0x0000000000040000)
#define PAGE_STATE_NO_READ_FLAG UINT64CONST(0x0000000000080000)
OrioleDB 的页面状态字段是 64 bit wide,两个 bit 标志位表示"被锁"/"阻塞读"。没有 PG 那张中心化的 buffer mapping 表——一个 in-memory page 直接链到存储 page,状态信息直接写在 page header 的 state 字段里。
第 34-37 行的四个宏把这套状态机暴露得很清楚:
O_PAGE_STATE_IS_LOCKED(state) // 问:是否被锁?
O_PAGE_STATE_LOCK(state) // 答:是的
O_PAGE_STATE_BLOCK_READ(state) // 答:是的,且阻塞所有读
O_PAGE_STATE_READ_IS_BLOCKED(state) // 问:当前读是否被阻塞?
读不阻塞读——这是 OrioleDB 整个多核扩展性的关键。PG heap 的 BufferMapping 是 LWLock,读读互斥;OrioleDB 用 bit flag,读页前 READ_IS_BLOCKED 检查一下,flag 没置位就直接读。lock_page_with_tuple(第 68 行)返回三种结果:Locked/RefindNeeded/Inserted——这是对 btree 操作的精细结果编码,比 PG 那种"等锁 vs 不等锁"二元状态有更多优化空间。
DBA 应该关心什么:这意味着你下次给 64 核服务器调 PG heap 的 shared_buffers,会发现 PG 内部被 LWLock:BufferMapping 锁住了上不去的并发度;OrioleDB 的设计专门为这场景做的,但代价是这套锁状态都在共享内存里维护,调参里 main_buffers / free_tree_buffers 不只是"缓存大小",是"锁竞争的边界"。你 pg_stat_activity 看不到 OrioleDB 的 page lock——监控脚本要重写。
读这个文件的方法:只读 20-40 行(bit 标志 + 宏)。10 分钟。这是 OrioleDB 整个"快"的物理基础。
文件 2:怎么"消灭 wraparound"——transam/oxid.c
src/transam/oxid.c 这是 64 位 xid 的实现。PG 经典 32 位 xid 是每个 DBA 心病——20 亿次事务就要 freeze,freeze 失败就保护模式。OrioleDB 直接用 64 位。
第 92 行:static OXid curOxid = InvalidOXid; /* a 64-bit OrioleDB oxid */
这个全局变量记录当前 OrioleDB 的事务 ID——64 位宽,每事务 +1。在你生命周期内(甚至你公司的生命周期内)不可能 wraparound。就这么简单——OXid 是 uint64 类型,不需要 freeze、不需要 autovacuum 介入、不需要 protection wraparound。
但故事不止这么简单。看第 137-145 行:
typedefstruct
{
LogicalXidCtx ctx;
SubTransactionId subid;
} PrevLogicalXidEntry;
static List *prevLogicalXids = NIL; /* stack of PrevLogicalXidEntry for all
* xids on subxact's chain, for correct
* subtransaction commit/abort handling */
这是 OrioleDB 处理子事务(subxact)的栈结构。每个 subxact 推一个 entry,commit/abort 时弹。OrioleDB 的"逻辑 xid"系统和 PG heap 的 pg_subtrans 是同构的——子事务嵌套到几层就需要几层栈。但 OrioleDB 用 64 位意味着它不需要 32 位 xid 的"4 亿 subxact 警告"那个机制。
为什么这是从 vacuum 解放的关键:PG heap 的 autovacuum 一半工作是在做 freeze——把老 xid 标为"过去式",让 32 位 xid 比较还能工作。OrioleDB 64 位 xid 没有这个问题,autovacuum 的 freeze 部分完全消失。autovacuum 不是不跑——它还需要回收 dead tuple,但 freeze 不需要了。autovacuum_freeze_max_age 这个 GUC 在 OrioleDB 表上没有作用。
读这个文件的方法:只读 92 行(OXid 64 位定义)+ 137-145 行(subxact 栈结构)。5 分钟。这是"为什么你不用再担心 freeze"的源码依据。
文件 3:怎么"测恢复路径"——recovery/recovery.c
src/recovery/recovery.c 这是 OrioleDB 的灾难恢复主体,~1500 行(具体看你的 git log)。DBA 视角的精华是 debug_recovery_crash_lsn 这个 GUC 和 replay 的 4 阶段。
第 1224-1246 行是 orioledb_redo() 函数——每个 WAL record 进来时调一次:
if (unlikely(XLogRecPtrIsValid(debug_recovery_crash_lsn)) &&
record->ReadRecPtr >= debug_recovery_crash_lsn)
elog(PANIC, "orioledb.debug_recovery_crash_lsn reached at %X/%X", ...);
这个 GUC 让你能在指定 LSN 强制 PANIC,测恢复路径。PG 经典要测恢复路径只能用 pg_ctl kill -9 模拟崩溃,但触发点随机、不可复现;OrioleDB 直接给你"我就在 LSN 0x12345 这里死"的能力。这个调试能力在 OrioleDB 整个项目里成本几乎为零——几行 elog(PANIC)——但给 DBA 提供了传统 PG 没有的恢复路径工程化测试能力。
第 400 行起,replay 有清晰的 4 阶段(看 replay_start_reached 标志):
stage 0:进入 sys-tree 一致性窗口之前,不处理 data record——只 replay sys-tree stage 1:通过 controlSysTreesStartPtr(sys-tree 一致)→ workers_synchronize + apply_deferred_checkpoint_undostage 2:通过 controlToastConsistentPtr(toast 一致)→ workers_synchronize + apply_pending_sk_fixups + workers_notify_toast_consistentstage 3:开始 replay 真正的 data WAL record
为什么 stage 这么分?因为 OrioleDB 的 sys-tree(描述表/索引结构的元数据)和 data-tree(实际数据)有顺序依赖——必须先把 sys-tree replay 到一致状态,才能解析 data record 里的 descriptor 引用。PG heap 没这个概念因为它是 page-level WAL(page 自带完整信息),但 OrioleDB 的 row-level WAL 依赖 sys-tree 提供 schema context。
DBA 关注点:replay 顺序错了不会丢数据,但会导致 data record 引用找不到对应 descriptor 而 fail 启动。debug_recovery_crash_lsn 是你测这个边界的工具。生产切换前必做:
# 在测试环境, 跑业务 5 分钟, 找一个有写入的 LSN
# 然后强制在那个 LSN 之前 1MB 处 PANIC
SET orioledb.debug_recovery_crash_lsn = '0/1234500';
# 触发 checkpoint
CHECKPOINT;
# 应该会 PANIC, 重新启动验证能恢复到一致状态
读这个文件的方法:先读 1224-1248 行(debug 钩子)+ 1300-1355 行(replay 4 阶段)。20 分钟。这是"灾难恢复怎么测"的工程依据。
文件 4:怎么"消灭 vacuum"——btree/merge.c
src/btree/merge.c 这是 page merging 的核心,~700 行。DBA 视角最关键的认知:"OrioleDB 不是不回收空间,是就地合并,不需要独立的 vacuum 进程"。
第 44 行:
staticvoidmerge_pages(BTreeDescr *desc, OInMemoryBlkno left_blkno,
BTreeDescr *right_desc, OInMemoryBlkno right_blkno,
OXidPageBoundedCSN csn, uint32 right_chunks,
bool has_garbage_in_left);
合并两个相邻 btree page——左页 (left_blkno) 和右页 (right_blkno) 在 CSN 阈值后被判断为可合并时,OrioleDB 会把右页内容搬进左页、右页释放。
第 71 行 btree_try_merge_pages 是个包装——先试探能不能合并(空间节省是否值得),再调真正的 merge_pages。第 218 行看到它在 update 路径里被调,这意味着 update 本身就触发合并,不需要单独的 vacuum worker 进程。
第 524 行和 597 行,合并在 BTree 内部递归调用——当右页被合并后,它原本的 right_blkno 被释放,但如果右页被释放后新的 right page 仍然"右邻居可合并"——递归会继续。这避免了"左页合并一次就停"的局部最优。
DBA 关注点:merge 是同步的(在 update 路径里)——意味着如果 merge 触发了 page IO,会阻塞事务。这给 OrioleDB 一个 trade-off:update 多 + 频繁 merge = 单事务延迟高。但避免了 vacuum 全表扫描——对大表是 net win。监控指标 orioledb_get_undo_meta() 里 page_undo 字段就是这个 trade-off 的可见化——page_undo 长期增长说明合并没跟上写入速度,要扩 main_buffers。
读这个文件的方法:只读 44 行(函数签名)+ 71 行(try 包装)+ 524/597 行(递归调用)。10 分钟。这是"为什么 OrioleDB 不需要 vacuum"的源码依据。
文件 5(不是 OrioleDB 的):怎么"挂"AM——PG 18 的 pg_am 表
这一节不读 OrioleDB 源码——读你已经会用的 PG 18.6。在你机器上 SELECT * FROM pg_am WHERE amtype = 't'; 看到的就是 heap 那一行。OrioleDB 装上之后会多一行orioledb | orioledb_tableam_handler | t。这就是 OrioleDB 怎么"挂"在 PG 上的全部秘密。
机制在 src/tableam/handler.c 第 117/119/2650 行:
Datum orioledb_tableam_handler(PG_FUNCTION_ARGS);
PG_FUNCTION_INFO_V1(orioledb_tableam_handler);
orioledb_tableam_handler 是一个PG 函数(handler.c 2926 行整个文件就是这个函数的实现 + 它调用的所有 tableam 回调)。CREATE ACCESS METHOD orioledb TYPE TABLE HANDLER orioledb_tableam_handler(...) 这条 SQL 会让 PG 把这个函数注册到 pg_am 表里。
handler.c 2926 行里的关键回调——TableAmRoutine 结构体的成员函数:
relation_set_new_filelocator——创建 OrioleDB 表relation_nontransactional_truncate、relation_truncate——truncatetuple_insert/tuple_update/tuple_delete——DML 入口tuple_fetch_row_version/get_tuple_latest/get_next_tid——DQL 入口relation_estimate_size——pg_relation_size()怎么算scan_bitmap_next_block/scan_bitmap_next_tuple——位图扫描回调
DBA 关注点:这意味着 OrioleDB 的 EXPLAIN (ANALYZE, BUFFERS) 输出含义要重新学——shared hit 改成 OrioleDB 的"page in main_buffers",read 改成"page from storage"(可能是 SSD 也可能是 S3)。pg_relation_size 调 relation_estimate_size 回调,返回的是 OrioleDB 的内部统计(不是文件系统层面的 stat)。所有"在 PG 里用得顺手的运维工具"对 OrioleDB 表都要重新理解一遍。
读这个文件的方法:这文件 2926 行太多,DBA 不用读。看 include/tableam/operations.h 第 1-100 行——OTableModifyResult 等结构体定义看懂了,就知道 OrioleDB 怎么向 PG 报告 modify 的结果。这是 5 分钟的活。
30 分钟路径
如果你只想"今晚做个选型决定就够"——按这个顺序读:
include/btree/page_state.h20-40 行(5 分钟)—— 理解 bit 标志怎么替代 buffer mappingsrc/orioledb.c89-90 + 544-880 行(10 分钟)—— 看 PG_MODULE_MAGIC + 19 个 GUCsrc/transam/oxid.c92 + 137-145 行(5 分钟)—— 看 64 位 xid + subxact 栈src/recovery/recovery.c1224-1248 + 1300-1355 行(10 分钟)—— 看 debug 钩子 + replay 4 阶段
共 30 分钟。 读完你能向团队讲清:
"OrioleDB 走的是 PG TableAM 框架,不改 heap 那张 buffer mapping 表" "19 个 GUC + 7 个 buffer 池,参数化做得比 PG 本身还工程化" "64 位 xid 解决了 wraparound,autovacuum 还能跑但 freeze 部分完全消失" "恢复路径有 debug_recovery_crash_lsn可以测,replay 分 4 阶段"
这是 DBA 视角的"我读过 OrioleDB 源码"的最低门槛。再深入去看 src/btree/merge.c 和 src/tableam/handler.c 那些具体算法,是工程团队的事,不是 DBA 选型决策的事。
一些不确定的、应该承认的
我没把这 5 个文件逐行读——这是 30 分钟路径,真要吃透这 5 个文件需要 1-2 周。OrioleDB 还有几个我没讲到的核心:checkpoint(src/checkpoint/checkpoint.c)的 sys-tree 一致性窗口、s3 模块(src/s3/ 12 个文件)的冷数据落云、rewind(src/rewind/)的事务回滚窗口、workers(src/workers/)的并行恢复池——这些是工程团队的活,DBA 选型阶段不需要碰。
更进一步的诚实:我读完代码、读完 commit history、读完 Supabase 收购后的活跃度,没找到工程上"看着不对劲"的地方——文档和实现一致、debug_recovery_crash_lsn 这种 debug 钩子是真写在生产代码里(不是 dev 分支才有)、Supabase 拿到代码后还在持续 commit。但这不等于上生产——你的真实 workload、你的多核机器配置、你的 HA 拓扑、你的 SLO 预算——这些只有你自己的环境能告诉你。DBA 视角的"读过源码 + 装起来跑过 perf 脚本"是最低门槛——
读完代码不跑 = 没读完。这句话对 OrioleDB 适用,对所有新一代 PG 工具也适用。