缓存是一种利用高速内存加速对低速内存访问的硬件或软件组件。 高速内存昂贵且容量更小,而低速内存更大且更便宜。与此同时数据的访问具有一定的时间局部性(访问过的数据短时间内很可能会再次访问)和空间局部性(访问过数据后很可能会继续访问附近的其他数据)。 因此可以在高速内存中存储原本存储在低速内存中的一部份数据,高速内存中用于存储部分数据副本的区域就称为缓存,而存有所有数据的低速内存则被称为主存。需要访问数据时会优先在缓存中查找,如果缓存中存有数据副本则称为缓存命中,此时便不再需要访问主存。缓存未命中时仍然需要访问主存。因此不当的缓存(访问不满足时空局部性)相比于直接访问主存性能可能会更低。
PostgreSQL 在不同的会话之间会维护一个共享的缓存(Shared Buffer)。
PostgreSQL 以页面为单位缓存数据,即缓存中的一个行(Line)的大小为一个页面。在 PostgreSQL 中缓存的行被称为缓冲区(Buffer)。 每一行还拥有一个头部存储数据的元信息,如数据在主存的地址,是否有脏数据未回写等。 缓存使用哈希表维护从缓存地址到主存地址(缓存行号)中的映射,哈希冲突时使用拉链法(Zipper method,也被称为开散列法 Open hashing,注意不是 Open addressing)解决冲突。
PostgreSQL 的所有数据访问都会经过缓存。 每当有页面访问未命中缓存时,PostgreSQL 会向缓存中加载这个页面。
当缓存已满时,缓存会基于替换策略(Replacement Policy,或 Cache Algorithm)将其中一行换出,供新页面使用。常见的替换策略有 LRU(Least Recently Used,替换最近使用时间距今最久的行),MRU(Most Recently Used,替换最近使用时间距今最近的行),LFU(Least Frequently Used,替换使用频率最少的行)。 缓存替换策略的优劣需要结合实际的工作负载评判。
PostgreSQL 使用修改后的时钟算法作为缓存替换策略,优先替换最近未被访问过和访问频率最低的页面。
最近是否被访问过和访问频率均通过行的 usage_count 字段指示。每次缓存命中时, usage_count 加一,上限为 5。而每当缓存已满需要替换页面时,PostgreSQL 会遍历整个缓存并将所有行的 usage_count 减一(这也是时钟扫描的名字来源),当 PostgreSQL 找到一个 usage_count == 0 的行时,就立即停止扫描并替换这个页面。下次扫描会从这个位置继续。若缓存内所有行的 usage_count 仍然均大于零,那么会再进行一轮时钟扫描,直到有页面被换出。
当上层应用需要写入数据时,缓存有两种写入策略(Write Policy):
- 透写(Write-Through):同时写入缓存和主存。
- 回写(Write-Back):仅写入缓存,在缓存行被换出时才会将修改写入主存。
PostgreSQL 使用回写策略。
但回写策略导致了在因为缓存已满需要替换缓存行时,必须先将缓存行中的脏数据写入主存,才能继续载入新的页面。 这会导致后续依赖新页面的操作必须等待不短的磁盘写入时间。
为了尽量减少缓存中的脏页面,PostgreSQL 会在后台进程 bgwriter 中定时异步地将脏页面写入主存。 这个过程使用和缓存替换几乎相同的时钟算法,只有两点区别:1)使用自己的时钟指针,但这个指针永远不会落后于驱逐指针,并且通常会在它前面;2)在遍历缓冲区时,使用计数不会减少。 因此只有缓存行未被锁定且使用计数为零的脏页才会被刷新至磁盘。 直观上这会主动将那些很可能很快被缓存替换策略换出的页面写入磁盘,避免在缓存替换时才写入导致的性能下降。