【翻译】关于PostgreSQL 的共享缓存访问控制的规则(Notes About Shared Buffer Access Rules)

翻译自 src/backend/storage/buffer/README

译者注:下文里面如果单独的用“缓存”这个词,那么原文中一般是是“buffer”。这时候指的是内存里面用来存放磁盘内容的最小单位。或者也可以用缓存页,缓存帧来表示。

对于共享缓存(shared disk buffer),这里有两种控制机制:引用计数(refernce counts, 或者说是pin counts) 和 buffer content locks。(实际上还有第三个层面的控制:一次访问必须要先得到一个表的锁才能合法的访问属于这个表的页面。表级别的锁(relation-level)不在这里的讨论范围内。)

Pins: 在访问一个缓存的内容之前,必须”pin这个缓存”(通过增加引用计数)。对于没有被pin的缓存,可能在任何时候被重新利用,存新的页到这个缓存里面。通常pin可以通过ReadBuffer得到(这里应该指ReadBuffer是一个函数),释放可以通过调用ReleaseBuffer。一个后端(backend,后文翻译时也会称作“进程”或者“任务”)可以pin一个页面多次,而且事实上也经常这样。而且持有pin很长一段时间也是可以的,比如说为了完成join操作,外层循环需要连续扫描(sequential scan)一个页面上面的全部tuple,这样就会花费很长时间。类似的,btree index scan也可能持有当前的索引页(index page)很长时间。这样不会有太大问题是因为通常的操作不需要等pin的数量减到0(如果真的有进程想要这样,那么最好是先获得一个表级别的锁。)然而在事务的边界处,是不应该持有pin的。

Buffer content locks: 这里有两种类型的缓存锁,共享(shared)的和排他的(exclusive)。和你想象的一样,多个任务可以持有共享锁锁,但是排他锁不允许其他任务持有任何类型的锁(所以有时候也叫做读锁和写锁)。这些锁设计上应该只被持有很端的一段时间,不应该长期持有。可以通过LockBuffer()来获得缓存锁。一个任务不能持有一个缓存锁多次!同时在获得锁之前,应该固定这个缓存。

缓存访问规则

  1. 为了扫描一个页面的tuples,必须pin缓存,而且获得共享锁或者排他锁。如果是为了检查缓存里面一个tuple的提交状态(commit status, XIDs 和 status bits),也要固定缓存,而且获得共享锁或者排他锁。

  2. 如果一旦决定对一个tuple 感兴趣(或可以被当前的事物看到),也许可以在持有pin的时候,扔掉内容锁。通常heap scans 时会这样做,这时tuple是通过heap_fetch返回一个指向缓存的数据来得到。 因此在持有pin的时候tuple 不可能消失(详见规则5)。它的状态可能会改变,但是一旦决定了是可以访问的就没啥关系了。(译者:不确定这里的heap scan是啥。看上去这里的意思是,反正只要先拿锁再拿pin,根据规则5,这个tuple就不能被删掉了。)

  3. 增加一个tuple,或者修个一个现有tuple的xmin/xmax,必须持有包含这个tuple缓存的pin和排他锁。这保证了其他人在做可视性检查的时候,不会访问到tuple更新到一半的状态。

  4. 即使只获得了共享锁和pin,有时候更新tuple的commit status (HEXAP_XMIN_COMMITED, HEAP_XMIN_INVALID, HEAP_XMAX_COMMITED 或者 HEAP_XMAX_INVALID 在 t_infomask 里面)也许是可以的。因为其他的其他正在查看tuple的后端会用OR来获得,所以这里可能只有一点或者没有风险;最多,这个导致了冲突只是意味着一个bit丢掉需要重新计算一遍。这四个bits只是提示(这些缓存了在pg_xactl中事物的结果),所以如果由于冲突导致以为变成0这没太大的危害。注意,然而如果一个tuple的HEAP_XMIN_INVALID和HEAP_XMIN_COMMITTED都被设置了,这个tuple是冻住的;这是一个critical update,对应的需要一个排他锁(也需要被WAL记录)。

  5. 为了物理是删除一个tuple 或者压缩没有利用的空间,则必须持有pin 和排他锁,而且需要在持有排他锁以后,观察到引用计数为1(没有其他人物持有pin)。当这些条件都满足的时候,没有其他任务可以执行page scan 知道排它锁被释放掉,而且没有其他任务可以重新引用里面tuple。注意虽然在清理的时候其他进程可以持有pin,但是并不能使用page因为无法持有共享锁。

获得符合规则#5 的锁可以通过bufmgr的函数LockBufferForCleanup()或者ConditionalLockBufferForCleanup()。首先获得一个排他锁,并且检查当前的引用数量是不是1。如果不是,ConditionalLockBufferForCleanup()释放排他锁,然后返回false。而LockBufferForCleanup()释放排他锁但不释放调用者的pin,然后等其他任务的信号,当收到信号时候会再次尝试。这个信号当UnpinBuffer减小pin技术到1时候会出现。如上所说,这个操作可能会等很长时间来得到锁,但是这对并行的VACUUM操作并不是很大的问题。当前的实现只支持一个任务等待某一个buffer的pin-count-1信号。总之我们不支持多个VACUUMs 同时对一个库操作,这对于VACUUM已经够用了。除了VACUUM以外其他任务应该使用条件版本的函数(conditional)。

缓存管理器内部的锁 (Buffer Manger’s Internal Locking)

在PostgrSQL 8.1 之前,所有对缓存管理器的操作又一个系统级别的锁,BufMgrLock来保证,并不意外这是竞争的源头。新的锁机制避免了通常情况下获得系统级别的锁。它是这样工作的:

  • 有一个系统级别的读写锁,BufMappingLock,这个锁保护了从buffer tags(page id)到缓存的映射。(实际上,可以认为这是在保护buf_table.c实现的hash表)。为了查询一个tag是否存在一个对应的缓存,从BufMappingLock获得一个共享锁已经足够了。注意如果想要pin这个缓存,必须要在释放锁共享锁之前就获得pin。如果想要改动一个缓存对应的具体的页面,则必须获得BufMappingLock的排它锁。这个锁必须在整个调整缓存的头和改变hash表整个期间持有。通常需要排它锁的操作是,读一个页面,发现这个页面不在缓存里面,这需要至少一次内核调用,而且通常需要等待IO操作,这本身就很慢了。

  • 在PG 8.2中,BufMappingLock 被分成NUM_BUFFER_PARTITIONS个分别的锁,每个所负责保护一部分buffer tag 的范围。这样可以进一步减小在通常情况下代码里面的竞争。每一个buffer tag 属于的分区是由tag哈希值的low-order bits 来决定的。之前描述的规则适用于每一个单独的分区。如果需要同时锁上多个分区,那么必须按照分区编号的顺序来锁,来避免死锁的风险。

  • 一个独立的系统范围的自旋锁,buffer_strategy_lock,为访问缓存的free list和“选择缓存以用来替换”的操作提供了互斥保障。这里用自旋锁而不是为了效率轻量的锁;而且当buffer_strategy_lock被持有的时候,不应持有其他任何类型的锁。这是多个后端同时出现缓存替换发生的时候,提供一定的并发的基本保障。

  • 每个缓存头需要有一个自旋锁,而且所需要在修改缓存头内容的时候要锁上。这样可以让诸如ReleaseBuffer这样的操作改变一些自身的状态,而不需要获得一个系统级别的锁。我们用的是自旋锁,不是读写锁,因为这里没有特例需要持有锁去执行很多命令

注意缓存头的自旋锁不控制对于缓存里的内容的控制。每个缓存头也包含一个读写锁,“缓存内容锁”,这用来表示访问缓存里数据的权利。需要使用之前描述的规则。

每个缓存还有另一个读写锁,io_in_progress,被用作等待缓存IO的结束。在进程读和写的时候需要持有排他锁,而且进程在读写时需要先等待拿到共享锁(获得后之后立即释放掉)(这里面后面没太看懂)。在里面里面XXX 在系统里面是用来表示不一般的资源(nontrival resources)访问的读写锁,这么多读写锁有点烦人。也许我们可以每个后端放一个读写锁来代替这些(缓存头用一个字段表示那个后端在做IO操作)

正常的缓存替换策略(Normal Buffer Replacement)

有一个缓存”free list”,里面用来表示可以被用来替换的候选缓存。特别的,完全空的缓存(没有有效的页面)总是在这个列表里面。我们也可以把我们认为马上就不需要的缓存丢到这里面;然而,现在的算法还没有这样做。这个链表是用缓存头里面的一些变量来连接起来;我们在全局维护一个头指针和尾指针。(注意:尽管这些链表的链(next和last指针)在缓存头里面,但需要考虑用bufer_strategy_lock来保护起来,而不是缓存的自旋锁。)当free list是空的但要选择一个缓存提出的时候,我们使用一个很简单的clock-sweep 算法,它避免了通常操作下需要拿一个全局锁。它像这样工作:

每个缓存头包含一个使用计数,当缓存被pin的时候会增加(最多到一个有限的值)。(这只需要缓存头的自旋锁,那个锁在增加引用的时候也要增加,所以基本上没啥开销。)

“表的指针(clock hand)”是一个缓存页的索引,nextVictimBuffer,它周期性的在所有可用的缓存间移动。nextVictimBufferbuffer_strategy_clock来保护。

  1. 获得buffer_strategy_lock

  2. 如果free list不是空的,那么就删掉头部的缓存页。释放buffer_strategy_lock。如果缓存页被pin 或者使用计数不是0,它本来不该被使用;忽略掉他然后回到第一步。否则,pin这个缓存页,然后返回它。

  3. 否则,free list是空的。选择nextVictimBuffer指向的缓存页,然后让nextVictimBuffer向后走一下以来用作下一次使用。释放buffer_strategy_lock

  4. 如果选择的缓存页被pin了,或者使用计数不为0,那么不能用这个缓存。减小缓存的使用计数(不是0的时候),获得buffer_strategy_lock,返回第三步继续判断下一个缓存。

  5. pin选择的缓存页,返回。

(注意如果选择的缓存是脏的,我们必须在回收之前将它写回;如果这时有其他人在同一时间pin 了这个缓存页,我们必须放弃然后尝试其他的缓存页。作为一个只是用来“选择一个被提出缓存页算法”(select-a-victim-buff),这不是应该考虑的地方)

环状缓存替换策略(Buffer Ring Replacement Strategy)

当一个查询需要一次性访问许多缓存页的时候,比如VACUUM或者大规模的连续扫描,使用不同的策略。一个页面被这种操作用到看起来不像是很快需要再次用到,所以与其使用一个正常的clock-sweep 算法使得提出全部缓存,一个小的使用clock-sweep算法的环状的缓存页被分配来做整个扫描。这也意味着这个后端导致的巨大写流量只与自己有关,不需要影响其他进程。

对于连续扫描,使用一个256KB 的环。这小到可以填充进L2缓存,这使得从系统缓存(OS cache)到共享缓存(也就是PG自己管理的缓存)效率更高。即使环再小一点也没关系,但是至少要可以放下需要同时pin的缓存页。256KB 也已经留下足够的尾巴让其他后端加进来一起执行线性扫描。如果一个环状缓存是脏的,然后它的LSN(译者注:LSN是日志的Sequence Number,页的LSN应该是最后一个与这个页相关的最后一个LSN)被更新了,我们应该先执行写操作,并且刷新WAL(write-ahead log)之后再重新用这个缓存;这种清空下我们就放弃用环状缓存,而是用正常的clock-sweep算法。因此环状缓存最好是让只读的扫描来用(最多只能更新一下hint bits)。如果一个扫面更新了每一个页面,比如说大规模的UPDATE或者DELETE,环状的缓存总是会脏,然后缓存策略退化到正常的缓存策略。

VACUUM 像连续扫描一样使用256KB的环,但是脏页不从环里面删掉。而是,在需要的时候刷新WAL来重用缓存。在8.3引入环状缓存之前,VACUUM的环被放进了freelist里,相当于只有一个Buffer的环,但是需要大量的WAL刷新。允许VACUUM在刷新WAL的时候更新256KB可以更有效。

大量写的任务类似于VACUUM。目前只应用于COPY INCREATE TABLE AS SELECT。(也许让连续扫描的UPDATEDELETE用这种大量写策略会很有趣?)对于大量写我们用16MB的环(但是不能超过shared_buffers的1/8)。已经发现更小的空间对于COPY经常需要阻塞来等待刷新WAL。虽然后台vacuum操作被刷新WAL阻塞问题不是很大, 我们更想让COPY不被这个限制住,所以我们让它用更大的缓存空间。

后台写进程的任务(Background Writer’s Processing)

后台写进程被设计用来写回那些看起来很快就要回收的页面,因此需要分离写工作和活跃的后端(backends)。为了达到这个目标,它从nextVictiBuffer 循环扫描(nextVictiBuffer不需要动),找到那些没有被pin或者没有使用引用计数的脏页。然后pin这个缓存页,写回,然后释放pin。

如果我们可以假设读nextVictimBuffer是原子操作,那么在找页面写回的时候,写进程甚至不需要获得buffer_startegy_lock;他只需要用自旋锁住缓存头来检查是不是脏的(检查dirtybit)。即使没有这个假设,也只需要在读nextVictimBuffer这个变量的时候来获得锁,在扫面的时候不需要。(译者:现在还有不是原子读的变量?即使是dirtybit?)(这相比PG 8.0,在避免竞争的花费方面提升了很多)。

后台写进程在写回的时候需要获得内容的共享锁(其他人做这件事情的时候也要这样干)。这保证了被写回磁盘的的页面是完整的。我们可能丢掉hint-bit的更新,但是根据在上面的缓存访问规则, 这不是个问题。

在8.4中,恢复模式中一些潜在的恢复被执行的时候,后台写进程也可能启动。它像正常处理提供服务,除了他写的checkpioints是理论上的restartpoints。