文件映射数据一致性:事务机制方案对比
2025-02-02 10:31:43
解决读/写文件映射完整性问题
在处理大型持久化数据(几十GB级别)时,文件映射是一个常见的选择。 服务器通过映射内存页对数据进行操作,最终可能会修改数据。然而,文件映射的修改是异步写回磁盘的。 如果服务器进程崩溃或断电等意外终止发生,数据很有可能处于不一致状态。 因此,需要一种机制确保数据在修改过程中的一致性。一种类事务的方法可以有效地解决这一问题,它把对文件映射的所有修改视为“临时”的,并在异常终止时能够回滚。当修改完成时,则可以执行“提交”操作,提交操作成功后,更改被视为持久化。
本文将讨论一些实现这种事务性机制的方法。
使用WAL(预写日志)的显式写访问
这种方法在首次写入某个内存页前,将页的内容备份到日志文件。每次需要写入内存页时,应用会调用相应函数。
工作原理
如果这是自上次提交以来第一次修改某个页,其内容会被保存到预写日志(WAL)文件中。提交更改时,将执行以下步骤:
- 刷新预写日志文件到磁盘(Windows:
FlushFileBuffers
;Linux:fsync
)。 - 刷新文件映射的视图(Windows:
FlushViewOfFile
;Linux:msync
)。 - 刷新底层映射文件。
- 将日志文件大小调整为0。
- 刷新日志文件,并可以选择删除日志文件。
启动时,若检测到日志文件大小不为0,表明上次更改未提交,此时需要进行回滚操作。回滚时,从日志文件中读取页数据,然后写入原始数据文件。此方案依赖于文件I/O按序执行的假设:将数据复制到日志文件,之后修改内存页所生成的对数据文件的I/O,它们被按顺序执行。
也就是说日志文件的更新先于数据文件的更新。
代码示例 (C++ 伪代码)
// 假设有以下函数
void backupPageToJournal(void* pageAddress, size_t pageSize, const std::string& journalFilePath);
void flushFileToDisk(int fileDescriptor);
void restorePageFromJournal(void* pageAddress, size_t pageSize, const std::string& journalFilePath);
void modifyData(void* pageAddress, size_t pageSize, const std::string& journalFilePath) {
if (!isPageDirty(pageAddress)) {
backupPageToJournal(pageAddress, pageSize, journalFilePath); // 将页面备份到 WAL
markPageAsDirty(pageAddress);
}
// 真正修改页面数据...
}
void commitChanges(const std::string& journalFilePath, int mappingFileDescriptor) {
// 刷新日志文件
int journalFileDescriptor = open(journalFilePath.c_str(), O_RDWR);
flushFileToDisk(journalFileDescriptor);
// 刷新文件映射及映射文件
msync(nullptr, 0, MS_SYNC); //Linux下刷新文件映射
flushFileToDisk(mappingFileDescriptor);
// 调整日志文件大小,可选删除日志文件
ftruncate(journalFileDescriptor, 0);
flushFileToDisk(journalFileDescriptor);
close(journalFileDescriptor);
//unlink(journalFilePath.c_str()); 删除日志文件
}
void rollbackChanges(void* pageAddress, size_t pageSize, const std::string& journalFilePath){
int journalFileDescriptor = open(journalFilePath.c_str(), O_RDONLY);
if(journalFileDescriptor >=0 ) {
// 如果日志文件不为空,回滚
struct stat fileStat;
if (fstat(journalFileDescriptor, &fileStat) == 0 && fileStat.st_size > 0){
restorePageFromJournal(pageAddress,pageSize, journalFilePath); // 从WAL还原页面数据
}
close(journalFileDescriptor);
unlink(journalFilePath.c_str()); // 删除日志文件
}
}
安全建议
- 务必使用原子操作修改页面“是否修改过”的状态,避免竞态条件。
- 考虑添加校验和到日志文件,以便在回滚时校验数据的有效性。
基于访问违规的隐式写访问
这种方案无需显式请求写入访问。而是将文件映射为只读(PAGE_READONLY
)。当尝试写入此内存区域时,会引发异常或信号。通过异常处理(Windows SEH)或者信号处理(Linux SIGSEGV
)捕获此行为。
工作原理
异常/信号处理函数中,首先保存页面内容到WAL日志文件,然后修改该页的保护属性为PAGE_READWRITE
,使其可以被修改。在提交阶段,需要恢复页面的只读保护属性。
这样做的好处是不需要修改原有写入的代码逻辑,它由异常处理程序在后台自动处理。
代码示例 (Windows C++ 伪代码)
#include <windows.h>
#include <iostream>
void backupPageToJournal(void* pageAddress, size_t pageSize, const std::string& journalFilePath);
void flushFileToDisk(int fileDescriptor);
void restorePageFromJournal(void* pageAddress, size_t pageSize, const std::string& journalFilePath);
LONG WINAPI exceptionFilter(EXCEPTION_POINTERS* pExcepInfo, const std::string& journalFilePath) {
if (pExcepInfo->ExceptionRecord->ExceptionCode == EXCEPTION_ACCESS_VIOLATION &&
pExcepInfo->ExceptionRecord->NumberParameters >= 2 )
{
PVOID faultingAddress = (PVOID)pExcepInfo->ExceptionRecord->ExceptionInformation[1];
// 假定页面的大小固定,获取当前页面的起始地址
DWORD pageSize = 4096;
PVOID pageBase = (PVOID)((uintptr_t)faultingAddress & ~(pageSize-1));
MEMORY_BASIC_INFORMATION meminfo;
if(VirtualQuery(pageBase,&meminfo,sizeof(meminfo))){
if(meminfo.Protect == PAGE_READONLY){
backupPageToJournal(pageBase, pageSize, journalFilePath); // 将页面备份到 WAL
DWORD oldProtect;
if(VirtualProtect(pageBase, pageSize, PAGE_READWRITE, &oldProtect)) {
//修改页面属性为可写后,程序可以继续运行.
return EXCEPTION_CONTINUE_EXECUTION;
}
}
}
}
return EXCEPTION_CONTINUE_SEARCH;
}
int main(){
const std::string journalFilePath = "my_journal.log";
__try {
// 创建文件映射...
LPVOID base_addr = MapDataToMemory();//假设已实现.将数据映射到内存
// 设置页面保护属性
DWORD dwOldProtect;
VirtualProtect(base_addr,FILESIZE , PAGE_READONLY, &dwOldProtect);
*(int*)base_addr = 10;//会引发异常,然后调用 exceptionFilter 函数
}
__except(exceptionFilter(GetExceptionInformation(),journalFilePath)){
// exceptionFilter 返回EXCEPTION_CONTINUE_EXECUTION时, 不会执行此处
}
//提交修改。
unmapData();//取消文件映射。
}
安全建议
- 由于此方案使用异常处理,可能会影响程序的调试流程。
- 务必保证在处理信号时线程安全,因为多个线程可能同时尝试写入只读页。
利用写时复制(Copy-on-Write)内存保护
这个想法的核心是使用操作系统提供的 PAGE_WRITECOPY
内存保护(Windows)或相似的机制,实现写时复制语义。当修改页面时,操作系统会自动分配新内存页,原页面保持不变。在提交阶段,需要识别所有已修改页面,将其复制到原文件(结合WAL),之后恢复页面的状态。目前尚未找到跨平台、高效、且与文件映射紧密结合的API接口。
文件系统级别的事务支持
一种更高级的理想方式是在文件系统层面提供事务支持。这意味着文件系统会管理映射文件的更改,直到事务“提交”,否则,回滚未提交的修改。 此方式可以在文件层面保证事务性,并且性能也相对高效。然而,目前大部分主流的文件系统并未直接支持此项特性,可能需要专门开发或者寻找特殊的系统或库实现此方案,目前通用性不够高。
方案对比与选择
方案一(WAL日志+显式写入)虽然安全且通用性高,但是需要修改现有代码并付出一些性能开销。
方案二(基于访问违规的隐式写入)相对侵入性更小,实现较为复杂,并需要特殊异常处理。
方案三和方案四,都较为理想,但是在实现上,技术难度都相对较大。目前看来第一种方案最稳定可行。如果对代码改动有很高容忍度并且能够很好地处理数据结构开销, 那么使用第一种方案会相对更稳妥;如果对代码的侵入性非常敏感,可以考虑第二种方案。
每种方案都需要权衡其成本与收益,在实际中应该选择最适合场景的方案。