返回

文件映射数据一致性:事务机制方案对比

Linux

解决读/写文件映射完整性问题

在处理大型持久化数据(几十GB级别)时,文件映射是一个常见的选择。 服务器通过映射内存页对数据进行操作,最终可能会修改数据。然而,文件映射的修改是异步写回磁盘的。 如果服务器进程崩溃或断电等意外终止发生,数据很有可能处于不一致状态。 因此,需要一种机制确保数据在修改过程中的一致性。一种类事务的方法可以有效地解决这一问题,它把对文件映射的所有修改视为“临时”的,并在异常终止时能够回滚。当修改完成时,则可以执行“提交”操作,提交操作成功后,更改被视为持久化。

本文将讨论一些实现这种事务性机制的方法。

使用WAL(预写日志)的显式写访问

这种方法在首次写入某个内存页前,将页的内容备份到日志文件。每次需要写入内存页时,应用会调用相应函数。

工作原理

如果这是自上次提交以来第一次修改某个页,其内容会被保存到预写日志(WAL)文件中。提交更改时,将执行以下步骤:

  1. 刷新预写日志文件到磁盘(Windows:FlushFileBuffers;Linux: fsync)。
  2. 刷新文件映射的视图(Windows: FlushViewOfFile;Linux: msync)。
  3. 刷新底层映射文件。
  4. 将日志文件大小调整为0。
  5. 刷新日志文件,并可以选择删除日志文件。

启动时,若检测到日志文件大小不为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日志+显式写入)虽然安全且通用性高,但是需要修改现有代码并付出一些性能开销。
方案二(基于访问违规的隐式写入)相对侵入性更小,实现较为复杂,并需要特殊异常处理。
方案三和方案四,都较为理想,但是在实现上,技术难度都相对较大。目前看来第一种方案最稳定可行。如果对代码改动有很高容忍度并且能够很好地处理数据结构开销, 那么使用第一种方案会相对更稳妥;如果对代码的侵入性非常敏感,可以考虑第二种方案。
每种方案都需要权衡其成本与收益,在实际中应该选择最适合场景的方案。