前言
简单的 lru 缓存管理(简称 slru),用于持久化数据并且提供 lru 算法来缓存。slru 在 postgresql 存在着多处使用,比如存储事务状态的 clog 日志,就是使用 slru 来管理的。
缓存和文件的对应关系
文件的数据都是存储在 page 里,每个 page 的大小都是相同的。这些连续的 page 就构成了文件。
一个缓存对应着一个 page,所以缓存的大小和 page 的大小是相同的。
结构体
slru 需要负责文件和缓存两个方面,所以会有两个配置。
文件配置
|
|
缓存配置
|
|
缓存的状态有下面四种,由SlruPageStatus
表示
|
|
设置最新访问
既然 slru 使用 lru 算法来管理缓存,那么我们需要了解下它是如何实现的。postgresql 提供了 SlruRecentlyUsed
宏,来标记缓存为最近被访问了,通过它的定义就可以知道实现原理了。
|
|
SlruRecentlyUsed
宏只是将全局的cur_lru_count
自增,然后提高指定 buffer 的page_lru_count
。这里需要注意page_lru_count
属性,通过它的大小,就可以判断出缓存是否最近被访问了。page_lru_count
越大,就代表着数据最近被使用过。当要替换掉长时间不在访问的 buffer 时,就选择page_lru_count
值小的。
当每次读取到缓存时,就会调用SlruRecentlyUsed
设置为最近访问。
挑选空闲缓存
当我们需要读取指定 page 的数据时,需要经过下图的步骤。整体思想分为三部分:
- 如果 page 数据已经存储在缓存中,则直接返回
- 如果有空闲状态的缓存,则直接返回
- 如果有不处于读写的缓存,则从中挑选出一个
- 等待缓存读写完成
graph TD;
start(开始)
in_buffers[/查找缓存数组, 是否已经包含指定page的数据/]
empty_buffer[/查找空闲缓存/]
valid_buffer[/查找有效缓存/]
find_oldest_buffer[从中查找最久没被访问的缓存]
is_buffer_dirty[/缓存是否为脏页/]
flush_buffer[刷新缓存到文件]
invalid_buffer[查找处于读写中的缓存]
wait_buffer_io[等待缓存读写完成]
find_oldest_buffer2[从中查找最久没被访问的缓存]
return_buffer[返回此缓存]
return(结束)
start-->in_buffers
in_buffers-- 已存在 -->return_buffer
in_buffers-- 不存在 -->empty_buffer
empty_buffer-- 存在 -->return_buffer
empty_buffer-- 存在 -->valid_buffer
valid_buffer-- 存在 -->find_oldest_buffer
find_oldest_buffer-->is_buffer_dirty
is_buffer_dirty--不是脏页-->return_buffer
is_buffer_dirty--是脏页-->flush_buffer
flush_buffer--重新寻找-->start
valid_buffer--不存在-->invalid_buffer
invalid_buffer-->find_oldest_buffer2
find_oldest_buffer2-->wait_buffer_io
wait_buffer_io--重新寻找-->start
return_buffer-->return
文件读写
文件格式
我们以pg_xact
目录为例,它使用 slru 存储事务状态信息。
|
|
这个目录存在了多个文件,这些文件称作 segment,文件名称表示 segment 的编号,由4 个十六进制数字组成。数据都是存储在page
单元里,page
的大小是固定的,默认 8KB。多个page
组织成了一个 segment 文件,每个 segment 文件的大小也是固定的,它包含了相同数目的page
。
读取数据
SlruPhysicalReadPage
负责读取指定 page 的数据。它会确定数据位于哪个 segment 文件,还有所在文件的偏移量。然后打开文件读取。
|
|
写入数据
SlruPhysicalWritePage
负责将刷新指定缓存到磁盘。它的原理同读取数据相同,也是先定位文件的位置,然后打开文件写入。这里多了一个参数SlruFlush
,用于一次性刷新所有脏页时,避免重复打开相同文件。
|
|
在写入磁盘之前,会将这个缓存里所有数据,对应的 xlog 刷新文件中。最简单的一种实现方式,就是找到 xlog 位置最大的值,然后调用XLogFlush
函数,将指定位置之前的 xlog 都刷新。
读写锁
slru 在读取数据或者写入数据的时候,为了防止并发引起的错误,都采用了锁机制。它有两种锁,一种是SlruSharedData
的ControlLock
全局锁,另一种是每个缓存对应的读写锁。
ControlLock
是读写锁LwLock
,在读取数据时或者刷新缓存到文件的时候,都会获取它的写锁。它是所有缓存共享的,所以叫做全局锁。
刷新缓存
刷新缓存的流程:
- 获取
ControlLock
的全局锁 - 设置缓存的状态为正在写入中,并且清除脏页标记
- 获取缓存的写锁
- 释放
ControlLock
的全局锁,因为刷新磁盘的时间会很长,这里释放全局锁提高并发性能 - 刷新缓存到文件
- 重新获取
ControlLock
全局锁,因为接下来要修改缓存的状态 - 设置缓存的状态为有效状态
- 释放缓存的写锁
- 释放
ControlLock
全局锁
读取数据
读取数据到缓存的流程:
-
获取
ControlLock
的全局锁 -
挑选出替换的缓存,更新缓存的状态为正在读
-
获取缓存的写锁
-
释放
ControlLock
的全局锁,因为刷新磁盘的时间会很长,这里释放全局锁提高并发性能 -
从文件中读取数据到缓存
-
重新获取
ControlLock
全局锁,因为接下来要修改缓存的状态 -
设置缓存的状态为有效状态
-
释放
ControlLock
全局锁 -
释放缓存的写锁
-
并且设置缓存为最近访问