概念
日志管理器
pg中的日志管理主要包括两种日志,一种是XLOG日志,记录的是事务对数据更新的过程和事务的最终状态。另一种就是基于SLRU缓冲池的日志,如CLOG日志、MultiXact日志、SUBTRANS日志等。
- XLOG: 记录了事务过程的操作的日志,数据写入需要优先保证XLOG日志先落盘才算事务完成,这样即使数据没刷入磁盘,也能通过XLOG日志恢复数据,对于pg数据库来讲XLOG时非常重要的日志,必须保证落盘。
每个日志文件大小是16M,其中可存储多少个XLOG日志,这个不固定主要是根据数据内容确定。 - CLOG: 记录事务的结果状态的日志,需要写XLOG日志进行持久化存储,对于元组的可见性判断非常重要。每个日志文件中最多可以存储32 * 8K * 4=1M=2^20个事务提交结果。
- SUBTRANS: 记录每个事务的父事务的事务ID,不会写XLOG日志,系统崩溃重启后会清零,事务提交时可能会用到。每个日志文件中最多可以存储32 * 8K / 4 = 64K个事务ID。
- MultiXact日志: 多个事务同时操作一个元组时,xmax会写入一个MultiXact ID,其对应映射的事务ID保存在MultiXact日志中,分为members和offset日志。会写XLOG日志进行持久化存储,对于元组可见性判断及行锁处理至关重要。每个offset日志文件中可以存储32 * 8K / 4 = 64K 个offset值,每个members日志文件最多可以存储32 * 8K /20 * 4 = 32 * 1636 = 52352个事务ID
在pg中,日志是通过日志文件来存放的如果日志记录在创建时都被立即写到磁盘中,将增加大量IO开销。因为往磁盘中写入是以块为单位的,而大多数情况是一个日志记录比一个块小多了,为了降低写入日志带来的IO开销,数据库中就设置了日志缓冲区,缓冲区以页为单位,当日志缓冲区满了之后就以块为单位写入磁盘。块和也都是8KB大小。
所以在数据库中,数据库并不直接操作磁盘日志文件,一般都是通过日志缓冲区来使用日志文件,而缓冲区和磁盘之间的交互和同步则由日志管理器来完成。pg提供了四种日志管理器: 即上面提到的XLOG,CLOG。SUBTRANS、MultiXact。
pg通过SLRU缓冲池实现对CLOG、SUBTRANS、MultiXact日志的管理。
这三种日志都需要写入磁盘文件,但是又有所不同。
- CLOG记录的事务的提交状态,这对于事务的可见性判断至关重要,所以CLOG会写XLOG进行持久化存储,这样在系统崩溃后恢复时能够准确知道每个事务是否提交或回滚,才能更准确的重建数据的一致性。
- MultiXact记录的是多个事务同时操作一个元组的时的组合事务ID信息跟CLOG一样,对于事务的可见性判断也至关重要,所以也需要写XLOG进行持久化存储,在系统崩溃后重启恢复时准确的知道每个事务的状态从而准确的重建数据的一致性。
- SUBTRANS记录的是每个事务的父事务ID信息,由于系统崩溃后重启恢复时会根据事务提交状态进行重放或回滚,然后重建子事务信息,所以就不再需要SUBTRANS信息,所以一般会在重启后将活跃的文件清零即可。既然重启后不需要也不持久化,那么为什么还需要写磁盘日志文件呢? 因为CLOG、MultiXact和SUBTRANS日志都是基于SLRU缓冲池实现的日志管理器,而SLRU缓冲池实际上就是在共享内存中分配的一部分空间,一般不会太大。SLRU缓冲池一般是分配有8个缓冲块,每个块8K大小,当缓冲块中数据存满之后,再写入日志时,就会根据SLRU算法选择一个最少使用的页块,先将页内容刷入磁盘文件,再将该页清空返回给新的写入日志请求使用。而写入磁盘文件的日志,在数据库运行期间还是有可能会被重新访问的,到时候再从磁盘读入缓冲块中即可,所以SUBTRANS日志也需要写入磁盘文件,但是不用谢持久化存储XLOG。
SLRU缓冲池
pg中对于CLOG、MultiXact、SUBTRANS等日志的存储形式是日志文件由一个个段文件组成,每个段中默认有32个页面,每个页面大小事8KB。每个段都有一个段号,通过段号找到对应的文件,相反的如果知道日志的页面号,就可以找到该页面所在的段文件及该页在段文件中的偏移,当需要对这些文件进行操作时,需要先将其读入内存中,在内存中则以SLRU缓冲池的形式存在。
对于CLOG日志可以存1M个信息,对于SUBTRANS日志可以存32K个子事务。
SLRU缓存池有8个缓冲块组成,每个缓冲块大小事8KB,对应文件段中的每个页面大小。页面调度算法采用SLRU算法,即简单的最近最少使用算法,因为缓冲区只有8个页面,所以实现搜索时,只需要使用简单的线性搜索算法。
- SLRU算法
每个页面都会记录一个LRU值,每次操作该页面时会更新该值。在select页面的时候,会拿当前的LRU值减去页面的LRU,值最大的页面就是使用频率最少得页面,就可以选择上这页面。
写日志记录时,先写入到对应日志页面所在的缓冲区,再进一步写入到磁盘上的日志段文件,而读日志文件需要先将要读的日志所在的页面从磁盘读取到缓冲池的页面槽中,然后再从日志缓冲区中读具体的日志记录。
缓冲池的并发控制
对于SLRU缓冲池,数据库使用了两种锁来进行并发控制。
- shared->ControlLock: 整个缓冲区的控制锁,是轻量锁,修改整个SLRU缓冲区的变量时需要先获取对应的锁。
- shared->buffer_locks[slotno].lock: 每个缓冲区页面锁,是轻量锁
当对缓冲页进行数据操作时需要加锁
当一个进程对缓冲区操作时,只持有一个页面锁,不持有控制锁,这样能提高并发性能,只有在需要修改缓冲区的变量值(如各个页面的状态)才需要持有控制锁。
缓冲池相关数据结构
SlruCtlData
非共享架构下的SLRU控制数据结构体,主要包含共享内存的地址,缓冲区类型(CLOG、MULTIXACT等),页面判断方法接口,还有文件所在的目录。
typedef struct SlruCtlData
{
SlruShared shared; //共享的SLRU数据指针
SyncRequestHandler sync_handler; //同步数据的类型,如CLOG,MULTIXACT等
bool (*PagePrecedes) (int, int); //比较两个页面前后关系的方法接口
char Dir[64]; //物理文件在磁盘的存储目录
} SlruCtlData;
SlruSharedData
SLRU共享缓冲区信息结构
缓冲页面信息就存储在page_buffer中,该结构体会在SimpleLruInit函数中进行初始化。
typedef struct SlruSharedData
{
LWLock *ControlLock; //缓冲区控制锁,是轻量锁
int num_slots; //SLRU管理的缓冲区数量
char **page_buffer; //缓冲页数据,是个二维数组,比如8个缓存页,page_buffer[0]表示第一个页面的地址
SlruPageStatus *page_status;//缓冲页状态
bool *page_dirty;//缓冲页是否为脏
int *page_number; //缓冲页对应的物理文件页
int *page_lru_count;//物理文件页的引用次数
LWLockPadded *buffer_locks; //缓存区锁
XLogRecPtr *group_lsn; //写入SLRU缓冲池前,根据需要刷WAL日志,里面存储每个buffer槽的入口地址
int lsn_groups_per_page;
int cur_lru_count; //页面的引用次数
int latest_page_number;//最新的页面的页号
int slru_stats_idx;//统计相关的索引
} SlruSharedData;
typedef SlruSharedData *SlruShared;
LRU缓存区操作函数
SimpleLruInit
LRU缓冲区的初始化
段文件是4位16进制数字
- 调用ShmemInitStruct函数从共享内存中申请空间,如果已经存在的话直接返回地址,不存在的话需要新申请
shared = (SlruShared) ShmemInitStruct(name,
SimpleLruShmemSize(nslots, nlsns),
&found);
- 初始化共享内存区域为SlruSharedData结构,每个页槽也初始化。
memset(shared, 0, sizeof(SlruSharedData));
shared->ControlLock = ctllock;
shared->num_slots = nslots;
shared->lsn_groups_per_page = nlsns;
shared->cur_lru_count = 0;
/* shared->latest_page_number will be set later */
shared->slru_stats_idx = pgstat_slru_index(name);
ptr = (char *) shared;
offset = MAXALIGN(sizeof(SlruSharedData));
shared->page_buffer = (char **) (ptr + offset);
offset += MAXALIGN(nslots * sizeof(char *));
shared->page_status = (SlruPageStatus *) (ptr + offset);
offset += MAXALIGN(nslots * sizeof(SlruPageStatus));
shared->page_dirty = (bool *) (ptr + offset);
offset += MAXALIGN(nslots * sizeof(bool));
shared->page_number = (int *) (ptr + offset);
offset += MAXALIGN(nslots * sizeof(int));
shared->page_lru_count = (int *) (ptr + offset);
offset += MAXALIGN(nslots * sizeof(int));
/* Initialize LWLocks */
shared->buffer_locks = (LWLockPadded *) (ptr + offset);
offset += MAXALIGN(nslots * sizeof(LWLockPadded));
if (nlsns > 0)
{
shared->group_lsn = (XLogRecPtr *) (ptr + offset);
offset += MAXALIGN(nslots * nlsns * sizeof(XLogRecPtr));
}
ptr += BUFFERALIGN(offset);
for (slotno = 0; slotno < nslots; slotno++) //初始化每个页的信息
{
LWLockInitialize(&shared->buffer_locks[slotno].lock,
tranche_id);
shared->page_buffer[slotno] = ptr;
shared->page_status[slotno] = SLRU_PAGE_EMPTY;
shared->page_dirty[slotno] = false;
shared->page_lru_count[slotno] = 0;
ptr += BLCKSZ;
}
- 初始化SlruCtlData结构
ctl->shared = shared;
ctl->sync_handler = sync_handler;
strlcpy(ctl->Dir, subdir, sizeof(ctl->Dir));
SlruSelectLRUPage
选一个可用的缓冲区页面,使用LRU算法,即选择一个最少调用的页面。
- 遍历所有槽位,首先检查给定页号
pageno
是否已经分配了缓冲区且非空。如果有,则直接返回对应的槽位号。
for (slotno = 0; slotno < shared->num_slots; slotno++)
{
if (shared->page_number[slotno] == pageno &&
shared->page_status[slotno] != SLRU_PAGE_EMPTY)
return slotno;
}
- 遍历所有槽位,如果槽位是空闲状态,直接返回该槽位
if (shared->page_status[slotno] == SLRU_PAGE_EMPTY)
return slotno;
- 遍历时跳过最新的页,即使它的次数是最大的也不选,因为它随后被访问的可能性很大,现在在缓存中,没必要使用它。
if (this_page_number == shared->latest_page_number)
continue;
- 计算每个页面的访问次数,找出最大值即最少访问次数的页面
if (shared->page_status[slotno] == SLRU_PAGE_VALID) //如果页面是有效的且没有正在被写入
{
if (this_delta > best_valid_delta ||
(this_delta == best_valid_delta &&
ctl->PagePrecedes(this_page_number,
best_valid_page_number)))
{//找到最大的有效槽位
bestvalidslot = slotno;
best_valid_delta = this_delta;
best_valid_page_number = this_page_number;
}
}
else
{
if (this_delta > best_invalid_delta ||
(this_delta == best_invalid_delta &&
ctl->PagePrecedes(this_page_number,
best_invalid_page_number)))
{
bestinvalidslot = slotno; //找到最大的无效槽位
best_invalid_delta = this_delta;
best_invalid_page_number = this_page_number;
}
}
- 如果遍历一遍也没有找到最大次数的槽位,等待某些槽位IO结束,再继续遍历
if (best_valid_delta < 0)
{
SimpleLruWaitIO(ctl, bestinvalidslot);/
continue;
}
- 如果找到了最大次数的槽位且槽位不脏,直接返回该槽位号
if (!shared->page_dirty[bestvalidslot]) //选择的页面不脏,那可以直接用,返回
return bestvalidslot;
- 如果找到最大的槽位但是是脏的,那么将脏页刷盘然后继续循环遍历
SlruInternalWritePage(ctl, bestvalidslot, NULL); //刷盘
SimpleLruReadPage
读取数据到共享内存的SLRU槽位页中,如果共享内存中已存在对应页直接从共享内存中读取数据即可,否则就得从日志文件中读取。
- 调用SlruSelectLRUPage函数找出一个合适的槽位页
slotno = SlruSelectLRUPage(ctl, pageno);
- 如果在内存中找到对应页且页不为空
- 如果槽位页正在读取中或者在写入中且写操作标志为false,等待该页的IO结束然后重新检查
- 其他情况就说明该页可以读取,更新LRU计数然后返回槽位号
if (shared->page_number[slotno] == pageno &&
shared->page_status[slotno] != SLRU_PAGE_EMPTY)
{
/*
* 如果页面仍在读取中,必须等待I/O。同样,如果页面正在写入并且调用方表示这不被允许。
*/
if (shared->page_status[slotno] == SLRU_PAGE_READ_IN_PROGRESS ||
(shared->page_status[slotno] == SLRU_PAGE_WRITE_IN_PROGRESS &&
!write_ok))
{
SimpleLruWaitIO(ctl, slotno);
/* 现在必须从顶部重新检查状态 */
continue;
}
/* 否则,它已经准备好使用,更新引用计数 */
SlruRecentlyUsed(shared, slotno);
/* 更新SLRU的统计计数器,页面在SLRU中找到 */
pgstat_count_slru_page_hit(shared->slru_stats_idx);
return slotno;
}
- 找到的槽位页是空闲的或者有效可用的,更新槽位信息
/* 我们没有找到匹配项;断言我们选择了一个可以释放的槽位 */
Assert(shared->page_status[slotno] == SLRU_PAGE_EMPTY ||
(shared->page_status[slotno] == SLRU_PAGE_VALID &&
!shared->page_dirty[slotno]));
/* 标记槽位为读取繁忙 */
shared->page_number[slotno] = pageno;
shared->page_status[slotno] = SLRU_PAGE_READ_IN_PROGRESS;
shared->page_dirty[slotno] = false;
- 调用SlruPhysicalReadPage开始读取数据到槽位中,读取之前需要对槽位页加排他锁,持锁后就可以释放controlLock了,这样不会影响其他事务访问其他槽位页。
LWLockAcquire(&shared->buffer_locks[slotno].lock, LW_EXCLUSIVE);
/* 释放控制锁,同时进行I/O */
LWLockRelease(shared->ControlLock);
/* 进行读取 */
ok = SlruPhysicalReadPage(ctl, pageno, slotno);
/* 将新读入的页面的LSN设置为零 */
SimpleLruZeroLSNs(ctl, slotno);
/* 重新获取控制锁并更新页面状态 */
- 读取完毕之后更新缓冲池对应位状态
LWLockAcquire(shared->ControlLock, LW_EXCLUSIVE);
Assert(shared->page_number[slotno] == pageno &&
shared->page_status[slotno] == SLRU_PAGE_READ_IN_PROGRESS &&
!shared->page_dirty[slotno]);
shared->page_status[slotno] = ok ? SLRU_PAGE_VALID : SLRU_PAGE_EMPTY;
LWLockRelease(&shared->buffer_locks[slotno].lock);
/* 现在可以报告失败了 */
if (!ok)
SlruReportIOError(ctl, pageno, xid);
SlruRecentlyUsed(shared, slotno); //更新page_lru_count
- SlruPhysicalReadPage
该函数是一个物理读取页面到缓冲区槽位的函数。如果失败,我们不能直接使用ereport(ERROR),因为调用者已经将状态保存在共享内存中,必须进行回滚。因此,我们返回false,并将足够的信息保存在静态变量中,以便SlruReportIOError函数进行报告。 - 获取文件路径,例如CLOG日志文件路径:$PGDATA/pg_xact/0000
- 打开文件,打开失败的话,将缓冲页置零
- 打开成功,读取8K数据到缓冲页
- 读取完毕,关闭文件
SlruPhysicalWritePage
该函数用于将共享缓冲区中的页面写入磁盘,如果必要的话。如果指定的槽位不是脏页面,则不执行任何操作。
注意:该函数只尝试一次写入操作。因此,如果在写入过程中其他线程重新将页面标记为脏页面,则页面可能仍然处于脏页面状态。但是,即使页面正在被写入,我们也会尝试进行新的写入操作,这是为了进行检查点操作。
- 如果对应页面正在写入中,则等待写入完成
while (shared->page_status[slotno] == SLRU_PAGE_WRITE_IN_PROGRESS &&
shared->page_number[slotno] == pageno)
{
SimpleLruWaitIO(ctl, slotno);
}
- 如果页面不脏,或者缓冲区不再包含相同的页面,则什么都不做
if (!shared->page_dirty[slotno] ||
shared->page_status[slotno] != SLRU_PAGE_VALID ||
shared->page_number[slotno] != pageno)
return;
- 开始写入前,要先标记为正在写入中然后后申请缓冲页的排他锁,单后调用SlruPhysicalWritePage函数执行写入
shared->page_status[slotno] = SLRU_PAGE_WRITE_IN_PROGRESS;
shared->page_dirty[slotno] = false;
/* 获取缓冲区锁(不会死锁,参见顶部的注释) */
LWLockAcquire(&shared->buffer_locks[slotno].lock, LW_EXCLUSIVE);
/* 在进行I/O时释放控制锁 */
LWLockRelease(shared->ControlLock);
/* 执行写入 */
ok = SlruPhysicalWritePage(ctl, pageno, slotno, fdata);
- 写入完成后缓充页标记为可用
LWLockAcquire(shared->ControlLock, LW_EXCLUSIVE);
Assert(shared->page_number[slotno] == pageno &&
shared->page_status[slotno] == SLRU_PAGE_WRITE_IN_PROGRESS);
/* 如果写入失败,再次标记页面为脏 */
if (!ok)
shared->page_dirty[slotno] = true;
shared->page_status[slotno] = SLRU_PAGE_VALID;
LWLockRelease(&shared->buffer_locks[slotno].lock);
- SlruPhysicalWritePage
该函数是一个物理写入页面的函数。如果函数执行失败,不能直接使用ereport(ERROR),因为调用者已经将状态保存在共享内存中,必须进行回滚。因此,函数返回false,并将足够的信息保存在静态变量中,以便SlruReportIOError函数进行报告。
目前假设在独立的读写操作中不值得保持文件指针打开。在SimpleLruWriteAll中,我们进行批量操作。
fdata在单独的写入操作中为NULL,在SimpleLruWriteAll期间指向打开文件的信息。 - 如果group_lsn不是空,需要考虑先写WAL日志,确定页面上的最大LSN后,调用XLogFlush函数将WAL日志刷入磁盘
/*
* 若适用,则遵循先写WAL再写数据的规则,确保在关联WAL记录之前不写出数据。这与主缓冲管理器中的FlushBuffer()执行相同操作。
*/
if (shared->group_lsn != NULL)
{
/*
* 我们必须确定页面上的最大异步提交LSN。虽然这个过程有些繁琐,但考虑到整个函数已经属于慢路径,这里进行计算比维护每个页面的LSN变量(这会在事务提交路径中增加额外比较)更为合理。
*/
XLogRecPtr max_lsn;
int lsnindex, lsnoff;
lsnindex = slotno * shared->lsn_groups_per_page;
max_lsn = shared->group_lsn[lsnindex++];
for (lsnoff = 1; lsnoff < shared->lsn_groups_per_page; lsnoff++)
{
XLogRecPtr this_lsn = shared->group_lsn[lsnindex++];
if (max_lsn < this_lsn)
max_lsn = this_lsn;
}
if (!XLogRecPtrIsInvalid(max_lsn))
{
/*
* 如上所述,在此处调用elog(ERROR)是不可接受的,因此如果XLogFlush失败,我们必须PANIC。虽然XLogFlush几乎总是处于临界区,但我们要确保这一点。
*/
START_CRIT_SECTION();
XLogFlush(max_lsn);
END_CRIT_SECTION();
}
}
- 在执行WriteAll操作时,我们可能已经打开了所需的文件,我们这里从fdata中获取即可,无需再打开
- 如果文件不存在,打开文件
- 调用pg_write函数将缓冲区数据写入磁盘
- 调用pg_fsync同步数据
- 关闭文件
SimpleLruTruncate
删除所有的小于当前页号的段文件
- 申请共享内存的锁
- 遍历每个缓冲页,判断是否需要删除对应页
- 如果页是空的或者其page号大于等于当前页号,然后跳过该页面
- 如果页是可用的且不是脏的,标记为空,然后跳过该页面
- 如果页是可用的且是脏的,将页内容写入磁盘
- 如果正在写入中,则等待写入完成,然后重新处理
- 删除旧的段文件,实际调用的是SlruInternalDeleteSegment函数
/**
* 函数名称:SimpleLruTruncate
* 函数功能:截断简单LRU缓存
* 参数:
* - ctl:LRU缓存控制结构体指针
* - cutoffPage:截断页码
* 返回值:无
*/
void
SimpleLruTruncate(SlruCtl ctl, int cutoffPage)
{
SlruShared shared = ctl->shared;
int slotno;
/* 更新截断统计计数器 */
pgstat_count_slru_truncate(shared->slru_stats_idx);
/*
* 扫描共享内存,移除所有位于截断页码之前的页面,以确保之后不会重新写入这些页面。
* (由于通常在检查点之后或期间调用此函数,任何脏页面应该已经被刷新,我们在这里只是格外小心。)
*/
LWLockAcquire(shared->ControlLock, LW_EXCLUSIVE);
restart:;
/*
* 在持有锁的情况下,进行一个重要的安全检查:当前端点页面不能被移除。
*/
if (ctl->PagePrecedes(shared->latest_page_number, cutoffPage))
{
LWLockRelease(shared->ControlLock);
ereport(LOG,
(errmsg("无法截断目录 \"%s\": 明显的循环",
ctl->Dir)));
return;
}
for (slotno = 0; slotno < shared->num_slots; slotno++)
{
if (shared->page_status[slotno] == SLRU_PAGE_EMPTY)
continue;
if (!ctl->PagePrecedes(shared->page_number[slotno], cutoffPage))
continue;
/*
* 如果页面是干净的,只需将其状态更改为EMPTY(预期情况)。
*/
if (shared->page_status[slotno] == SLRU_PAGE_VALID &&
!shared->page_dirty[slotno])
{
shared->page_status[slotno] = SLRU_PAGE_EMPTY;
continue;
}
/*
* 嗯,我们正在对页面执行I/O操作,所以我们必须等待它们完成并重新开始。
* 这与SlruSelectLRUPage中的逻辑相同。(XXX如果页面是脏的,不写入页面可以吗?
* SlruMayDeleteSegment()使用了更严格的条件,所以我们最终可能不会删除该页面;
* 即使我们不删除它,我们也不会再次读取其数据。目前,保持与之前的逻辑相同。)
*/
if (shared->page_status[slotno] == SLRU_PAGE_VALID)
SlruInternalWritePage(ctl, slotno, NULL);
else
SimpleLruWaitIO(ctl, slotno);
goto restart;
}
LWLockRelease(shared->ControlLock);
/* 现在可以删除旧段了 */
(void) SlruScanDirectory(ctl, SlruScanDirCbDeleteCutoff, &cutoffPage);
}