SQLite3源码学习(32) WAL日志详细分析

版权声明:本文为博主原创文章,遵循 CC 4.0 by-sa 版权协议,转载请附上原文出处链接和本声明。
本文链接:https://blog.csdn.net/pfysw/article/details/80602522

在前面2篇文章讲了有关WAL日志相关的一些基础知识:

SQLite3源码学习(31) WAL日志的锁机制

SQLite3源码学习(30)WAL-Index文件中的hash表

接下来分析一下在WAL日志模式下,整个事务的处理机制和流程

1.原子提交

事务管理最核心的特性就是满足原子提交特性,之前的回滚日志模式实现了这个特性,而WAL日志模式也实现了原子提交的特性。

在WAL日志模式下有3个文件,分别是:

1.数据库文件,文件名任意,例如"example.db"

2.WAL日志文件,在数据库文件名后加-wal,例如"example.db-wal"

3.WAL-index文件,在数据库文件名后加-shm,例如"example.db-shm"

WAL日志和回滚日志最大的区别是,在WAL模式下,修改过的数据并不直接写入到数据库,而是先写入到WAL日志。过一段时间后,通过检查点操作将WAL日志中修改的新页替换数据库文件的老页。

WAL日志模式下,对数据库的操作主要有4种:

1.读数据

2.写数据

3.检查点操作,把WAL日志的新页同步到数据库

4. WAL-index文件恢复操作

所谓原子提交特性,就是在写数据写到一半时出现系统崩溃或断电后,事务对数据库的修改只能处于初始状态或完成状态,而不能处于中间状态。

和回滚日志一样,在开始一个写事务之前,首先要有一个读事务将要修改的页读到内存中。读数据时,最新修改的还没同步到数据库的页从WAL日志读取,其他的页在数据库中读取,WAL-index文件是一个共享内存文件。

在把要修改的页读取到内存中后就可以对其修改,修改前需要对WAL-index文件加上写锁,修改完毕后将修改的页追加到WAL日志的末尾(即第mxFrame帧之后),在提交事务时,在最后一帧写入数据库长度,把WAL新添加的帧索引和页号记录到WAL-index文件中,最后更新WAL-index头部的mxFrame字段。

经过上一个步骤之后,事实上写事务已经完成了。虽然这些新修改的页没有同步到数据库中,但是读取的时候会通过WAL-index文件查询有哪些新修改的页在WAL文件中还没同步到数据库,如果在WAL文件中则在WAL文件中读取,否则从数据库中读取。

SQLite会定期把WAL日志中的页回填到数据库中,默认是WAL到了1000帧的时候执行检查点操作,把从nBackfill到mxFrame的页写回到数据库,如果写到一半出现异常并不会影响事务继续正常进行,因为读事务读取这些页面是在WAL日志中读取。在WAL日志和数据库同步完毕后,如果现在没有读事务,WAL-index头部字段的mxFrame复位为0,下一次向WAL日志追加数据时从头开始。

回写数据库出现异常并不影响事务的正常进行,写WAL日志异常页不会对事务的原子性有什么影响,事务只有在提交时才在WAL-index文件中更新mxFrame字段,如果在此前出现事务失败,刚写入WAL末尾的数据将会被忽略掉。如果在写WAL-index的时候中断,下一次开始读事务时会检测到头部异常,需要根据WAL日志的对WAL-index文件进行恢复,WAL-index文件出错会影响接下来读写的正确性。

2.WAL的优缺点

优点:

1.并发优势

在WAL模式中,写数据只是向WAL末尾添加数据,而读事务开始前,会做一个read-mark标记,只读read-mark之前的数据,所以写事务和读事务完全并发互不干扰。而回滚日志模式,在写事务把修改提交到数据库时会获取独占锁,阻止其他读事务的开始,一定程度影响了读写的并发。

2.写速度优势

在回滚日志中,写数据到数据库前需要先把原始数据写入到日志中,并对日志刷盘,再写记录到日志头,再刷盘,最后才把数据写入到数据库,这里出现了多次磁盘I/O操作,而WAL模式需一次向WAL日志写入数据即可,而且也能保持事务的原子性。而且写WAL日志都是按顺序写入的,相对于离散写入的也更快。

缺点:

1.需要共享内存

在WAL模式下,需要额外提供一个WAL-index文件,同时需要操作系统支持对该文件的共享内存访问,这就限制了所有进程访问数据库必须要在同一台机器上。

2.不支持多文件

WAL模式下没有回滚机制,所以一个事务处理多个文件时,并不能保证整体的原子性。而回滚日志模式,可以把多个数据库的日志关联到master日志里,事务恢复时可以进行整体回滚。

3.读性能会略有下降

因为每次读数据库之前都会通过WAL-index文件查找要读的页是否在日志中,会产生一些额外的损耗。

4.WAL文件可能会很大

在读事务一直持续进行时,一直没有机会把WAL日志里的内容更新到数据库,会使WAL文件变得很大。

3.读事务的实现

在开始读数据之前,需要通过sqlite3WalBeginReadTransaction()开启一个读事务,并检查此时有没有写事务对数据库进行改动,如果有改动的话,清除页缓存。

下面来一步步分析实现,首先要获取WAL-index文件头

rc =walIndexReadHdr(pWal, pChanged);

 在这里需要先判断WAL-index有没有变更,先来看一些WAL-index的头部格式:

Bytes

Description

0..47

First copy of the WAL Index Information

48..95

Second copy of the WAL Index Information

96..135

Checkpoint Information and Locks

可以看到WAL Index头部为48字节,后面48~95偏移位置还有一份拷贝。为什么同一个头部要记录2次呢?

把前面48字节记为h1,接下来的拷贝部分记为h2,读是先读h1再读h2,而写是先写h2再写h1,如果读到的h1和h2不同,就说明在写入WAL-index头部出现中断或正在写入,此时如果无法获取写锁,那需要等待将文件头写完再开始,如果可以获取写锁,说明是上一次出现损坏,需要对文件头修复。

static int walIndexTryHdr(Wal *pWal, int *pChanged){
  u32 aCksum[2];                  /* Checksum on the header content */
  WalIndexHdr h1, h2;             /* Two copies of the header content */
  WalIndexHdr volatile *aHdr;     /* Header in shared memory */

     //walShmBarrier(pWal);保证读取h1和h2是严格按照先后次序
  aHdr = walIndexHdr(pWal);
  memcpy(&h1, (void *)&aHdr[0], sizeof(h1));
  walShmBarrier(pWal);
  memcpy(&h2, (void *)&aHdr[1], sizeof(h2));
  //文件头损坏,未初始化,校验值不对都需要重新恢复
  if( memcmp(&h1, &h2, sizeof(h1))!=0 ){
    return 1;   /* Dirty read */
  }  
  if( h1.isInit==0 ){
    return 1;   /* Malformed header - probably all zeros */
  }
  walChecksumBytes(1, (u8*)&h1, sizeof(h1)-sizeof(h1.aCksum), 0, aCksum);
  if( aCksum[0]!=h1.aCksum[0] || aCksum[1]!=h1.aCksum[1] ){
    return 1;   /* Checksum does not match */
  }
  ……
  /* The header was successfully read. Return zero. */
  return 0;
}

恢复WAL-index文件由walIndexRecover()函数实现

static int walIndexRecover(Wal *pWal){
   //获取全部类型的独占锁,此时不能进行任何其他操作
  iLock = WAL_ALL_BUT_WRITE + pWal->ckptLock;
  nLock = SQLITE_SHM_NLOCK - iLock;
  rc = walLockExclusive(pWal, iLock, nLock);
  if( rc ){
    return rc;
  }
  //校验WAL日志头部,校验不通过不恢复WAL-index的页号索引,只初始化WAL-index头部
  ……
  //校验通过时读取WAL日志的所有帧,将其页号和帧号写入到WAL-index文件索引。
  //这里需要注意的是校验WAL日志每一帧的头部时是一个循环检验的过程,即上一帧的校验值输出需要作为下一帧的校验值输入
    for(iOffset=WAL_HDRSIZE; (iOffset+szFrame)<=nSize; iOffset+=szFrame){
      u32 pgno;                   /* Database page number for frame */
      u32 nTruncate;              /* dbsize field from frame header */

      /* Read and decode the next log frame. */
      iFrame++;
      //读取WAL日志的帧
      rc = sqlite3OsRead(pWal->pWalFd, aFrame, szFrame, iOffset);
      if( rc!=SQLITE_OK ) break;
      //校验读取的帧的头部
      isValid = walDecodeFrame(pWal, &pgno, &nTruncate, aData, aFrame);
      if( !isValid ) break;
      //把页号和帧号添加到索引
      rc = walIndexAppend(pWal, iFrame, pgno);
      if( rc!=SQLITE_OK ) break;
      ……
    }
  //完毕后释放锁
  walUnlockExclusive(pWal, iLock, nLock);
  return rc;
}

获取WAL-index头部信息后,还要获取读锁,如果不需要从WAL日志中读取时获取0号读锁

if( !useWal && pInfo->nBackfill==pWal->hdr.mxFrame){
    //此时WAL日志和数据库已经完全同步
    rc = walLockShared(pWal, WAL_READ_LOCK(0));
    ……
}

如果日志和数据没有完全同步,那么需要从1~4号读锁中获取一把,每一种锁都对应一个pInfo->aReadMark[i],这个读标记记录了拥有该锁的读事务在WAL中所能读取的最大帧。

如果有一个锁空闲,将该锁的ReadMark设为mxFrame,并获取该锁。如果没有锁空闲,那么找到ReadMark最大的锁并获取。

最终读取页面时,只查看pInfo->nBackfill+1~pInfo->aReadMark[i]的帧是否在WAL日志中,pInfo->nBackfill之前的帧在数据库中读取。在检查点操作时,不能将pInfo->aReadMark[i]之后的帧同步到数据库,否则会影响读事务的正确性。

这部分加锁的代码比较繁琐,就不再贴出。

4.写事务的实现

写事务的实现基本全在sqlite3WalFrames()函数里,首先要获取一把独占的写锁。在开始写事务之前必定开始了一个读事务,读取数据库的第一页。下面通过注释来说明代码的关键地方,很多细节的地方略去

int sqlite3WalFrames(
  Wal *pWal,                      /* Wal handle to write to */
  int szPage,                     /* Database page-size in bytes */
  PgHdr *pList,                   /* List of dirty pages to write */
  Pgno nTruncate,                 /* Database size after this commit */
  int isCommit,                   /* True if this is a commit */
  int sync_flags                  /* Flags to pass to OsSync() (or 0) */
){
  //检查WAL日志和数据库是否完全同步,
  //如果已经完全同步,获取0号读锁
  //将mxFrame的值设为0,即从头开始写WAL日志
  if( SQLITE_OK!=(rc = walRestartLog(pWal)) ){
    return rc;
  }
  iFrame = pWal->hdr.mxFrame;
  //如果这是第一帧,写入WAL日志头
  if( iFrame==0 ){
    …….
  }
  //为了便于理解把这块代码从头移到这里
  // iFirst初始为0,如果WAL-index头被改变
  //则为当前事务WAL添加的第一帧
  pLive = (WalIndexHdr*)walIndexHdr(pWal);
  if( memcmp(&pWal->hdr, (void *)pLive, sizeof(WalIndexHdr))!=0 ){
     iFirst = pLive->mxFrame+1;
  }
  //遍历所有的脏页,写入数据
  for(p=pList; p; p=p->pDirty){
    int nDbSize;   /* 0 normally.  Positive == commit flag */
    //在当前的写事务内,可能会多次调用写数据函数
    //如果这一帧在之前写过,则只写入帧数据
    //不写入帧头
    if( iFirst && (p->pDirty || isCommit==0) ){
      u32 iWrite = 0;
      VVA_ONLY(rc =) sqlite3WalFindFrame(pWal, p->pgno, &iWrite);
      assert( rc==SQLITE_OK || iWrite==0 );
      if( iWrite>=iFirst ){
        //这里非常关键,记下所有重写的帧中最小的一个
        // iReCksum为开始校验的帧,帧头是一个连续的循环校验
        if( pWal->iReCksum==0 || iWrite<pWal->iReCksum ){
          pWal->iReCksum = iWrite;
        }
        //覆盖已经写入的数据帧,暂时不修改帧头,之后统一修改
        ……
        p->flags &= ~PGHDR_WAL_APPEND;
        continue;
      }
    }
    //如果该帧没写过,帧号+1
    iFrame++;
    assert( iOffset==walFrameOffset(iFrame, szPage) );
    nDbSize = (isCommit && p->pDirty==0) ? nTruncate : 0;
    //这里会写入帧头
    rc = walWriteOneFrame(&w, p, nDbSize, iOffset);
    if( rc ) return rc;
    pLast = p;
    iOffset += szFrame;
    p->flags |= PGHDR_WAL_APPEND;
  }
  /* Recalculate checksums within the wal file if required. */
  //事务提交时,需要从pWal->iReCksum开始重新校验
  if( isCommit && pWal->iReCksum ){
    rc = walRewriteChecksums(pWal, iFrame);
    if( rc ) return rc;
  }
  //如果最后一帧需要在帧头写入数据库大小代表事务提交了
  //此后如果需要提交事务,要做的事情为:
  //1.将WAL日志刷入磁盘
  //2.将所有新增的帧的页号和帧号写入WAL-index文件
  //3.更新WAL-index文件头
  ……
}

4.检查点的实现

 检查点就是把WAL日志中最新的帧同步到数据库,默认为1000帧之后同步。在同步之后可以选择是否将日志文件的长度截断为0。

检查点需要更新的帧从从nBackfill开始到pInfo->aReadMark[i]结束,这里代码通过一个迭代器,把WAL-index的每一块记录的帧都按照页号排序,按照页号从小到大更新到数据库,如果页号相同,选择后面的帧,因为后面的帧比前面要新。

static int walCheckpoint(
  Wal *pWal,                      /* Wal connection */
  sqlite3 *db,                    /* Check for interrupts on this handle */
  int eMode,                      /* One of PASSIVE, FULL or RESTART */
  int (*xBusy)(void*),            /* Function to call when busy */
  void *pBusyArg,                 /* Context argument for xBusyHandler */
  int sync_flags,                 /* Flags for OsSync() (or 0) */
  u8 *zBuf                        /* Temporary buffer to use */
){
  //只有nBackfill比最大有效帧小时才更新数据库
  if( pInfo->nBackfill<pWal->hdr.mxFrame ){

    /* Allocate the iterator */
    //迭代器把每一块的帧按照页号排序
    //如果J<K,那么aPgno [aList[J]] < aPgno [aList[K]]
    rc = walIteratorInit(pWal, &pIter);
    if( rc!=SQLITE_OK ){
      return rc;
    }
    //获取所有读锁中,最大的aReadMark的值mxSafeFrame
    ……
    if( pInfo->nBackfill<mxSafeFrame
     && (rc = walBusyLock(pWal, xBusy, pBusyArg, WAL_READ_LOCK(0),1))==SQLITE_OK
    ){
      //在更新数据库时需要持有0号锁的独占锁
      //0号锁的读事务只在数据库中读取数据
      /* Iterate through the contents of the WAL, copying data to the db file */
      while( rc==SQLITE_OK && 0==walIteratorNext(pIter, &iDbpage, &iFrame) ){
        //遍历迭代器的每一个元素,找到符号要求的页将其更新到数据库
        ……
      }
      ……
      walUnlockExclusive(pWal, WAL_READ_LOCK(0), 1);
    }
  }
}

5.迭代器

下面来简要说明一下迭代器,初始化中有一个归并排序,比较难理解,这里稍微讲一下,之前讲的归并排序是关于链表的,而这里是数组元素的排序:

// 排序目标是,如果J<K,那么
//aContent [aList[J]] < aContent [aList[K]]
static void walMergesort(
  const u32 *aContent,            /* Pages in wal */
  ht_slot *aBuffer,               /* Buffer of at least *pnList items to use */
  ht_slot *aList,                 /* IN/OUT: List to sort */
  int *pnList                     /* IN/OUT: Number of elements in aList[] */
){
  //遍历迭代器的数组元素,将每个元素都划分到子数组里
  for(iList=0; iList<nList; iList++){
    nMerge = 1;
    aMerge = &aList[iList];
    // aSub[i]. aList中存的是子数组的首地址
    // aSub[i].nList中存的是子元素的个数
    //aSub[0]存1个,aSub[1]存2个,aSub[2]存2^2个元素
    //依次类推
    //假如当前iList是9(0b1001),那么只有在aSub[0]和aSub[3]
    //中存有子数组
    for(iSub=0; iList & (1<<iSub); iSub++){
      struct Sublist *p;
      assert( iSub<ArraySize(aSub) );
      p = &aSub[iSub];
      // p->aList为子数组的第一个元素
      //归并后,p->aList的内容经过了重新去重和排序
      //结束后p->aList本身的地址赋值给了aMerge, 
      // nMerge为归并后的元素个数
      walMerge(aContent, p->aList, p->nList, &aMerge, &nMerge, aBuffer);
    }
    // aMerge是上一个子数组的首地址
    //虽然归并后的内容经过了重新排序,但是地址没变
    aSub[iSub].aList = aMerge;
    aSub[iSub].nList = nMerge;
  }
  //经过简单分析,不难得出aMerge是第一个子数组的首地址
  //aMerge和接下来的aSub[iSub]继续归并,归并后数组的
  //首地址仍然输出给aMerge,p->aList更新的是内容
  //排序后它的地址已经不重要了
  for(iSub++; iSub<ArraySize(aSub); iSub++){
    if( nList & (1<<iSub) ){
      struct Sublist *p;
      p = &aSub[iSub];
      walMerge(aContent, p->aList, p->nList, &aMerge, &nMerge, aBuffer);
    }
  }
  //输出迭代器的大小
  *pnList = nMerge;
}

遍历迭代器时需要遍历所有的块,每一块初始化是都已经根据页号排好序了,找出所有块中最小的元素

static int walIteratorNext(
  WalIterator *p,               /* Iterator */
  u32 *piPage,                  /* OUT: The page number of the next page */
  u32 *piFrame                  /* OUT: Wal frame index of next page */
){
  u32 iMin;                     /* Result pgno must be greater than iMin */
  u32 iRet = 0xFFFFFFFF;        /* 0xffffffff is never a valid page number */
  int i;                        /* For looping through segments */

  iMin = p->iPrior;
  assert( iMin<0xffffffff );
  //这里遍历所有块
  for(i=p->nSegment-1; i>=0; i--){
    struct WalSegment *pSegment = &p->aSegment[i];
    while( pSegment->iNext<pSegment->nEntry ){
      u32 iPg = pSegment->aPgno[pSegment->aIndex[pSegment->iNext]];
      //在当前块内找到大于上一次迭代的页号
      //找到之后先别急着增加pSegment->iNext
      //可能iPg并不是所有块内最小的页,需要遍历
      //完所有的块才知道
      if( iPg>iMin ){
        if( iPg<iRet ){
          iRet = iPg;
          *piFrame = pSegment->iZero + pSegment->aIndex[pSegment->iNext];
        }
        break;
      }
      pSegment->iNext++;
    }
  }

  *piPage = p->iPrior = iRet;
  //遍历迭代器所有元素后返回1
  return (iRet==0xFFFFFFFF);
}

6.参考资料

《SQLite Database System Design andImplementation》p.249~p.252

Write-AheadLogging

WAL-mode File Format

SQLite分析之WAL机制

Sqlite学习笔记(四)&&SQLite-WAL原理

Sqlite学习笔记(三)&&WAL性能测试

SQLite中的WAL机制详细介绍

 

 

 


展开阅读全文

没有更多推荐了,返回首页