构建表访问方法,第三部分:Postgres 的共享内存
手工给 Postgres 写一个表访问方法(TAM)的系列第三节。三种共享内存机制怎么选、为什么 DSA 的指针间接让顺序扫描慢 4-5 倍、pin 的是段还是映射、以及 _PG_init 与 shmem_startup_hook 的分工。
中文
复制
在第 2 部分中,我们接好了 CREATE TABLE ... USING memtable 所需的 TAM 回调,但此时还没有任何存储,所以让 INSERT、DELETE、UPDATE 和 SELECT 跑起来也没有意义。pg_memtable 把表数据放在共享内存里,所以在写第一行 pg_memtable 相关代码之前,值得先弄清楚 Postgres 到底是怎么让各个 backend 共享内存的。
这篇文章会介绍 Postgres 实现了哪几种共享内存,以及每种方式各自的优缺点。然后重点讲 DSM 系统——实现 pg_memtable 时会用到它——并梳理如何创建共享内存段、如何保证它们常驻内存,以及如何把它们归还给系统。
Postgres 中共享内存的三种方式
Postgres 提供了三种把数据放进共享内存的方式,它们解决的是不同的问题。
传统共享内存是最简单的一种:在 postmaster 启动时一次性分配一块固定大小的段,通过 _PG_init 期间的 RequestAddinShmemSpace 申请。每个 backend 在 fork 时自动继承它,所以没有 attach 这一步,也没有每个 backend 的额外设置开销。代价是大小必须在第一个 backend 启动之前就确定。它不能增长,不能释放,而且无论有没有人在用,它都会在整个服务器生命周期内一直占着。如果你想用一小块共享内存在 backend 之间通信,这非常有用;但作为通用的共享内存分配机制,它就不太行了。
动态共享内存(DSM) 段则相反:在运行时用 dsm_create/dsm_destroy 创建和销毁,大小按具体用例需要来定,只有当某个 backend 显式调用 dsm_attach 时才会映射进它的地址空间。不需要预先保留任何东西,而且可以同时存在任意多个相互独立的段,每个都有自己的生命周期。
动态共享内存区域(DSA) 建立在 DSM 之上。dsa_area 是一个通用分配器,dsa_allocate/dsa_free,它在一个或多个 DSM 段内部工作,需要时通过 attach 更多段来扩容。它相当于共享内存版的 palloc/pfree:适合大量小的、大小独立、释放也独立的对象。Postgres 自己在内部就用它来做并行哈希连接、dshash 表,以及其他需要真正动态分配的结构。这种便利是有代价的:每次访问都要经过分配器自己的记账,还多了一层间接寻址,因为每次解引用都得把一个 dsa_pointer 解析成真实地址。
pg_memtable 的实现一开始走的是 DSA 路线。在更早的版本里,元组存在一棵由 DSA 支撑的红黑树中,顺序扫描慢了 4-5 倍,完全就是那层间接寻址造成的。树的每一步遍历都意味着要再解析一个 dsa_pointer。解决办法是彻底放弃 DSA,直接用一块裸的 DSM 段,用字节偏移量直接访问,完全不经过分配器的间接寻址。
创建并共享一个 DSM 段
在创建和共享 DSM 段时,有四个函数值得关注:
extern dsm_segment *dsm_create(Size size, int flags);
extern dsm_segment *dsm_attach(dsm_handle h);
extern void dsm_detach(dsm_segment *seg);
extern dsm_handle dsm_segment_handle(dsm_segment *seg);
创建 DSM 段很直接:调用 dsm_create 并传入所需大小,它会返回一个 dsm_segment*。这个段是指向段信息的指针,可以用来获取段的基地址等。它是后端本地指针,不能传给其他后端,因为该地址只在当前后端的地址空间内有意义。要把段交给其他后端,可以传 段句柄,对方用 dsm_attach(handle) 就能拿到属于自己后端的段指针。
dsm_handle 本质上就是一个 uint32,所以通常很容易传给其他后端。常见做法是在启动时预留一小块固定大小的传统共享内存,把句柄放在那里。每个后端在 fork 时就已经映射了这块预留内存,找到句柄不成问题。
对 pg_memtable 来说这没必要,pg_memtable 用了另一种办法在后端之间共享句柄。这是下一篇的主题。
最后一个调用是 dsm_detach,它把后端从段上分离,如果没有其他后端还在使用该段,Postgres 就可以将其释放。
段的 pin 与 unpin
新建的 DSM 段默认不会自动存活。和大多数 Postgres 资源一样,它绑定在创建它的 resource owner 上,resource owner 一旦消失,Postgres 就会自动把它清理掉。
要想避免这种情况、让段无限期地保留下去,可以把它 pin 住:要么用 dsm_pin_segment pin 住它的生命周期,要么用 dsm_pin_mapping pin 住当前 backend 对它的映射。这两者解决的是不同的问题:
dsm_pin_segment把段的生命周期从 backend 上解绑。没有它,创建段的 backend 一退出,段就没了。dsm_pin_mapping管的是段在 当前 backend 中的映射。没有它,dsm_attach创建的映射会在事务结束时被 resource-owner 清理掉。底层的段还活着,但 backend 里的下一条语句得重新调用dsm_attach。
只有 dsm_pin_segment 有回头路:dsm_unpin_segment 会释放这个 pin,等没有 backend 再用这个段时,Postgres 就会把它清理掉。pin 住的映射没有对应的 unpin 调用;只要 backend 还活着,它在这个 backend 里就一直处于 pin 住的状态。
注意,pin 住的段最终必须由 某个东西 显式 unpin,否则它会一直泄漏到服务器生命周期结束。后面几篇里为 pg_memtable 创建和销毁存储时,这一点值得记在心里。
_PG_init 与共享内存启动钩子
_PG_init 在之前的一篇里简单提过:它是 Postgres 在加载共享库时自动调用的函数。对于 shared_preload_libraries 中列出的库,这个调用发生得非常早,那时 postmaster 还没用 CreateSharedMemoryAndSemaphores 把共享内存设置完。任何触碰真正共享状态的事情这时都还不能做,但 Postgres 提供了第二个钩子 shmem_startup_hook,它在共享内存子系统建好之后运行,正是为这类初始化准备的。
pg_memtable 自己也需要做一些共享内存初始化,靠的是下面从 _PG_init 里安装的 memtable_shmem_startup_hook 函数。
static shmem_startup_hook_type prev_shmem_startup = NULL;
void _PG_init(void) {
...
prev_shmem_startup = shmem_startup_hook;
shmem_startup_hook = memtable_shmem_startup_hook;
...
}
prev_shmem_startup 会保存别的扩展已经安装的钩子(如果有的话),这样 memtable_shmem_startup_hook 就能链到它上面,而不是悄悄把它替换掉。
Postgres 中的共享内存锁
要防止共享内存同时被多个 backend 修改,就需要某种锁。好在 Postgres 支持 LWLock(即“LightWeight Lock”,轻量级锁)。
不过,出于调试和性能方面的考虑,能监控这些锁会很有用;按锁的类型把它们分组,让它们在 pg_stat_activity 中显示出有意义的信息,同样很有用。
为此,我们用 LWLockNewTrancheId 创建一个共享的 LWLock tranche 标识符,再用 LWLockRegisterTranche 给它注册一个名字,所用的是刚刚引入的 shmem_startup_hook:
static int MemTableArenaTranche = -1;
static void memtable_shmem_startup_hook(void) {
if (prev_shmem_startup)
prev_shmem_startup();
MemTableArenaTranche = LWLockNewTrancheId();
LWLockRegisterTranche(MemTableArenaTranche, MEMTABLE_EXTNAME "_arena");
}
接下来的几篇文章会把每个 arena 锁都关联到这个 tranche,这样它就能以正确的名字出现在那里。MemTableArenaTranche 本身不过是一个 int,在启动时保存一次,之后每次初始化 arena 头时读取。
下一篇
下一篇讲的是在创建和删除 pg_memtable 表时,如何建立和拆除共享段。