Mats Kindahl:构建表访问方法,第7部分:存储与读取行
内容提要
本文是 pg_memtable 表访问方法系列的第七部分,介绍在共享内存 arena 中存储行并通过全表扫描读取。采用堆元组格式,TID 以 arena 字节偏移编码;任意后端可通过 relfilenode 定位 arena,TRUNCATE 后自动连接新 arena。插入时写入 xmin 等可见性字段,扫描时按事务提交状态过滤行。示例验证了并发可见性以及 TRUNCATE 回滚后旧数据仍保留。目前尚不支持删除、更新和快照可见性。
延伸解读
TID 编码:用字节偏移替代页号
pg_memtable 的 arena 没有页结构,因此作者把 TID 的 block number 和 offset number 重新解释为 arena 字节偏移:block 为偏移除以 BLCKSZ,offset 为余数加一。这样索引、UPDATE、DELETE 仍能通过 TID 找回行,但文章也提醒,若将来接入 bitmap heap scan 或 brin/gin 等索引,这种非标准含义可能引发问题。
堆元组格式的取舍:够用但有代价
作者用百万行宽表对比了堆元组、虚拟槽和最小元组三种槽类型。虚拟槽在只读前几列时明显更慢,因为它每次都要解出全部属性;最小元组速度接近堆元组,却缺少可见性检查所需的 xmin/xmax。最终选择堆元组格式,代价是每行 23 字节头部,且 t_cid、t_ctid 等字段暂时闲置。
可见性简化:只看 xmin 提交状态
当前实现不读取快照,只检查 t_xmin:本事务插入的行立即可见,其他事务需等插入事务提交后才可见,回滚或仍在运行的事务所插行被隐藏。这足以支撑示例中的并发验证,但也意味着行在语句执行中途提交就会立刻出现,与完整 MVCC 快照语义仍有距离。
TRUNCATE 换 arena 与缓存遗留问题
TRUNCATE 会创建新 arena 并写入 relfilenode,回滚后旧 arena 未被触碰,因此旧数据完整保留;若原地重置游标则会覆盖旧行。同时,已读过表的空闲后端仍持有旧 arena 映射,直到再次使用该表或断开连接才释放,作者明确将这一遗留问题留到下一篇处理。
Q&A
pg_memtable 中行数据具体是怎么存放在共享内存 arena 里的?
每行采用堆元组格式存储,前面有一个 8 字节的 MemTableSlotHeader(包含 tuple_size 和 padding),用于对齐并记录元组长度。元组本身是 HeapTupleHeaderData 加属性数据,其中 t_xmin 设为插入事务 ID,t_xmax 设为 InvalidTransactionId 并置 HEAP_XMAX_INVALID 表示未删除。行在 arena 中的字节偏移被编码成 TID。
pg_memtable 的 TID 是怎么编码的?为什么这样设计?
TID 使用 ItemPointerData 的 block number 和 offset number 来编码行在 arena 中的字节偏移:block number 为偏移除以 BLCKSZ,offset number 为偏移对 BLCKSZ 取余加一(因为 offset 从 1 开始且 0 视为无效)。这样任何后端都能通过 TID 反算出偏移,支持索引、UPDATE 和 DELETE 等未来功能。
任意后端如何找到某个表的 arena?TRUNCATE 后怎么处理?
arena 的句柄存放在 relfilenode 中,即 rel->rd_locator.relNumber。每个后端通过 memtable_get_state 查找缓存条目,若条目不存在或句柄与 relfilenode 不一致(说明被其他后端 TRUNCATE 过),则先 detach 旧映射,再通过 dsm_attach 附加新 arena 并 pin 住映射。这样任意后端都能定位并访问当前 arena。
pg_memtable 插入一行时具体做了哪些操作?
memtable_tuple_insert 先通过 ExecFetchSlotHeapTuple 把 slot 转成堆元组,然后调用 memtable_arena_alloc 在 arena 中分配空间(bump 分配器,移动 tuple_free 游标),接着写入 MemTableSlotHeader 并拷贝元组数据,最后显式设置 t_xmin 为当前事务 ID、t_xmax 为 InvalidTransactionId 并置 HEAP_XMAX_INVALID,同时把新行的 TID 存回 slot。
pg_memtable 的全表扫描是如何判断一行是否可见的?
扫描时调用 memtable_tuple_visible,它检查 t_infomask 是否含 HEAP_XMAX_INVALID(若没有则视为已删除,当前不支持删除所以不会发生),然后看 t_xmin:如果是当前事务 ID 则可见(包括未提交的自身插入),否则要求 TransactionIdDidCommit 为真。该函数不检查快照,因此行在插入事务提交后立即对所有人可见。
为什么 pg_memtable 选择堆元组格式而不是虚拟槽或最小元组槽?
基准测试显示最小元组槽速度与堆元组槽相当,但缺少可见性检查所需的 xmin 和 xmax;虚拟槽在只查询前几列时反而更慢,因为它会提取所有属性。堆元组格式已经携带可见性字段,因此当前版本直接使用 TTSOpsHeapTuple 槽类型,代价是每行有 23 字节头部开销。
pg_memtable 目前还不支持哪些功能?
目前不支持 DELETE 和 UPDATE,没有快照可见性(行在事务提交后立即可见,不按快照过滤),只能通过全表扫描查找行,不支持并行扫描和反向扫描。此外,已读取过表的空闲后端在表被删除或 TRUNCATE 后仍会保留 arena 映射,导致内存不能及时释放,这个问题计划在下一篇修复。