SQLite3原始碼學習(32) WAL日誌詳細分析

偏飛123發表於2018-06-06

在前面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機制詳細介紹

 

 

 


相關文章