原文出处:sqlite3.36版本btree实现(四)- WAL的实现

前面两节,分别讲解了sqlite中写入事务时的并发控制框架,以及journal备份文件的实现机制。

回忆一下journal备份文件的实现:

可以看到,journal备份的整个流程都较为原始,性能不高,所以在sqlite 3.7.0版本(SQLite Release 3.7.0 On 2010- 07-21,2010-07-21)中,引入了另一种备份机制:WAL(Write Ahead Log)。

本节首先介绍WAL的实现原理,然后再展开其具体的实现。

WAL工作原理

从前面journal的实现中可以看到,写入journal文件中的内容,是待修改页面修改之前的内容,而WAL则相反:被修改的页面内容首先写入到WAL中。

用sqlite官网的文字来说,WAL文件的定义是这样的:

The write-ahead log or "wal" file is a roll-forward journal that records transactions that have been committed but not yet applied to the main database.

即WAL文件中存储的是被修改但是还没有写入数据库文件的页面内容。

两种页面备份机制

WAL整体的实现机制,分为以下几个流程:

WAL相关文件结构

在工作原理部分,只会简单讲解WAL相关文件结构,具体的格式等细节留待下面的实现部分详细讲解。

WAL文件本身的格式很简单,有如下两部分组成:

换言之,一个WAL中,可能先后存储了多个事务的写入。

由于WAL文件保存的修改页面的内容,同一个页面,可能在一次事务中被多次修改,如下所示:

WAL及WAL页面索引数据

WAL存储了四个页面数据,其中页面编号1被修改了两次。

如果在这个写操作完成之后,需要读这些页面的内容,都需要读到最新的内容。所以,WAL还有一个对应的WAL页面索引数据,这部分索引数据存储在内存中,作用是根据页面编号,知道该页面编号对应的最新内容,存储在WAL文件中的具体位置,以取得某个页面的最新页面内容;如果在这个内存索引中查不到的数据,都需要到数据库文件中读取。

checkpoint

随着WAL文件的增长,终究要将里面修改的内容同步到数据库文件中,这个流程被称为“checkpoint”。只要WAL文件被“checkpoint”,就可以从头开始写这个文件,避免文件的无限增大。

对于journal备份机制而言,只有两种操作:读和写;而对于WAL机制而言,实际有三种操作:读、写、checkpoint。这也是两种机制的主要区别之一。

并发的实现

前面提到了,WAL机制的一个优势在于:在写未完成之前,可以允许同时并发多个读操作,来看看这一点是如何做到的。

在每次读操作开始之前,都会记录下来当前最后提交事务的提交记录,这条记录被称为“end mark”。因为WAL会随着写操作的进行不断增加,通过读操作的“end mark”,就能知道对于这个读操作而言,页面内容应该以当前WAL内容为准,还是以数据库文件为准。

以下图为例来做个说明:

读并发的实现

在上图中:

可见,有了“end mark”这一标记位置之后,加上页面索引,任意数量的读操作都能快速判断自己应该读WAL文件还是数据库文件,写操作可以继续写,读和写之间并不会冲突,极大提升了读并发的性能。

同样要看到的是,由于只有一个WAL文件,同一时间之内,只能有一个写操作。即:sqlite的WAL模式,只能支持单写多读的模式。

读操作和checkpoint的联系

前面讲到了checkpoint以及读并发的实现,两者可以并发一起执行,但是某些时刻会有一些关联,影响系统的性能。

因为超过读操作“end mark”的页面,读操作需要到数据库文件中读取该页面内容,那么反过来,当checkpoint操作要将一个超过当前并发的任意读操作“end mark”的页面落到数据库文件中时,就必须等待这个读操作完成才能进行。

仍然以前面读并发的示意图来解释这个过程:

换言之:一个执行很久的读操作,可能会影响同时在进行的checkpoint操作的执行。

被阻塞的checkpoint必须等待读操作完成才能继续执行,因此需要一些额外的信息来维护当前checkpoint执行的状态,这些具体的实现细节都会在下面实现环节的分析中涉及。

现在已经大体清楚WAL的原理了,下面来看具体的实现。

WAL的实现

sqlite中,可以使用PRAGMA journal_mode=wal设置页面备份机制为wal,这时候就会有三个与数据库相关的文件在同一个目录:

WAL的文件格式

首先来看WAL文件的格式,分为两个部分:

WAL文件头,只会在每次写事务中写入一次,而帧可能在一次写事务中多多个,取决于这一次写事务修改了多少页面。如下图:

WAL文件结构

上图中,依次存储了两次写事务的数据,其中第一次写了两帧数据,第二次写了一帧数据。

有了以上的概念,下面详细看WAL文件的结构。

WAL文件头格式

(引用自 Database File Format section 4.1)

Offset Size Description
0 4 Magic number. 0x377f0682 or 0x377f0683
4 4 File format version. Currently 3007000.
8 4 Database page size. Example: 1024
12 4 Checkpoint sequence number
16 4 Salt-1: random integer incremented with each checkpoint
20 4 Salt-2: a different random number for each checkpoint
24 4 Checksum-1: First part of a checksum on the first 24 bytes of header
28 4 Checksum-2: Second part of the checksum on the first 24 bytes of header

其中的很多字段自有解释,其中多数涉及到页面内容的校验,后面再展开说。

WAL帧头部格式

(引用自 Database File Format section 4.1)

Offset Size Description
0 4 Page number
4 4 For commit records, the size of the database file in pages after the commit. For all other records, zero.
8 4 Salt-1 copied from the WAL header
12 4 Salt-2 copied from the WAL header
16 4 Checksum-1: Cumulative checksum up through and including this page
20 4 Checksum-2: Second half of the cumulative checksum.

帧头部需要存储如下的信息:

其它的字段,都跟页面的校验有关,接下来就看看这部分的实现。

页面内容校验算法

前面的格式中,无论是WAL文件头,还是WAL帧头部,都有以下的字段:

校验时,只有满足以下条件的情况下才认为是正确的帧数据:

第一部分salt数据,每次写事务生成一次,所以校验这个值可以认为校验这一帧数据是否和这次事务匹配;而第二部分的checksum数据,则会用来依次串起一次写事 务的所有页面修改。

比如下面这个写事务流程:

这样,相邻页面之间的校验值就有了关联。

总结起来:

校验页面的函数实现在walDecodeFrame中,而计算页面校验值的函数实现在walChecksumBytes中。

WAL页面索引

结构

前面分析WAL文件结构的时候,提到保存一页数据的内容被称为“帧(frame)”,帧的编号从1开始顺序递增,每一帧内容存储的页面内容可能会发生变化,如下图所示:

帧与页面的对应关系

图中,依次有四帧页面数据,帧数与页面的对应关系依次是:(1,1),(2,3),(3,5),(4,4)。假设随着运行,第一帧对应的页面1被写入了数据库,那么第一帧的空间就会被复用来存储别的页面的内容。

所以,WAL页面索引中,需要存储帧数与页面编号之间的对应关系,这样就能:

根据需要的这两份数据,定义了用于保存wal索引的数据结构:

struct WalHashLoc {
  volatile ht_slot *aHash;  /* Start of the wal-index hash table */
  volatile u32 *aPgno;      /* aPgno[1] is the page of first frame indexed */
  u32 iZero;                /* One less than the frame number of first indexed*/
};

其中:

这么看来有一些抽象,我们以下图来做解释wal-index文件的结构:

WAL-Index索引文件结构图

如上图所示,其中:

页面索引数据的构成

这几个常量,由下面这几个宏来定义:

// 每一页aPgno数组大小
#define HASHTABLE_NPAGE      4096                 /* Must be power of 2 */
// 查询时hash取模时的质数
#define HASHTABLE_HASH_1     383                  /* Should be prime */
// 每一页hash slot数组大小
#define HASHTABLE_NSLOT      (HASHTABLE_NPAGE*2)  /* Must be a power of 2 */
// 页面1实际能容纳的aPgno大小:HASHTABLE_NPAGE减去WALINDEX_HDR_SIZE使用的大小
#define HASHTABLE_NPAGE_ONE  (HASHTABLE_NPAGE - (WALINDEX_HDR_SIZE/sizeof(u32)))
// 一个wal索引页面的大小,为4096*4+8192*2 = 32KB
#define WALINDEX_PGSZ   (                                         \
    sizeof(ht_slot)*HASHTABLE_NSLOT + HASHTABLE_NPAGE*sizeof(u32) \
)
索引文件头结构

来看看索引文件头的结构,其整体大小为136字节,划分为三部分:

(引用自WAL-mode File Format section 2.1)

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

这总共136字节的数据,一共分为三个部分:

下面的表格,详细展示了这136字节中都有哪些字段。

(引用自WAL-mode File Format section 2.1)

Bytes Name Meaning
0..3 iVersion The WAL-index format version number. Always 3007000.
4..7 Unused padding space. Must be zero.
8..11 iChange Unsigned integer counter, incremented with each transaction
12 isInit The “isInit” flag. 1 when the shm file has been initialized.
13 bigEndCksum True if the WAL file uses big-ending checksums. 0 if the WAL uses little-endian checksums.
14..15 szPage The database page size in bytes, or 1 if the page size is 65536.
16..19 mxFrame Number of valid and committed frames in the WAL file.
20..23 nPage Size of the database file in pages.
24..31 aFrameCksum Checksum of the last frame in the WAL file.
32..39 aSalt The two salt value copied from the WAL file header. These values are in the byte-order of the WAL file, which might be different from the native byte-order of the machine.
40..47 aCksum A checksum over bytes 0 through 39 of this header.
48..95 A copy of bytes 0 through 47 of this header.
96..99 nBackfill Number of WAL frames that have already been backfilled into the database by prior checkpoints
100..119 read-mark[0..4] Five “read marks”. Each read mark is a 32-bit unsigned integer (4 bytes).
120..127 Unused space set aside for 8 file locks.
128..132 nBackfillAttempted Number of WAL frames that have attempted to be backfilled but which might not have been backfilled successfully.
132..136 Unused space reserved for further expansion.

下面就这里的一些重点字段做一下介绍。

为什么需要两个相同大小的WAL索引头部?

注意到0-47和48-95这两部分48字节的数据,都是同样类型的数据,都由WalIndexHdr结构体来描述。

这样设计的目的,是为了读写的时候数据校验。假设头48字节为h1,后48字节为h2。那么读操作的时候是先读h1再读h2,而写操作的时候则相反,先写h2再写h1。这样,如果在读操作的时候,读到这两部分内容并不一样,说明当前有写操作在进行。

读头部的实现见函数walIndexTryHdr

static SQLITE_NO_TSAN 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 */
  aHdr = walIndexHdr(pWal);
  memcpy(&h1, (void *)&aHdr[0], sizeof(h1)); /* Possible TSAN false-positive */
  walShmBarrier(pWal);
  memcpy(&h2, (void *)&aHdr[1], sizeof(h2));
  // 对比两个header,不相同就返回
  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 */
  }
  // 到了这里,就是判断是否发生过改变了
  if (memcmp(&pWal->hdr, &h1, sizeof(WalIndexHdr)))
  {
    *pChanged = 1;
    memcpy(&pWal->hdr, &h1, sizeof(WalIndexHdr));
    pWal->szPage = (pWal->hdr.szPage & 0xfe00) + ((pWal->hdr.szPage & 0x0001) << 16);
    testcase(pWal->szPage <= 32768);
    testcase(pWal->szPage >= 65536);
  }
  /* The header was successfully read. Return zero. */
  return 0;
}
mxFrame和nBackfill

mxFrame记录着当前WAL文件的最大帧数,而nBackfill记录着当前checkpoint操作进行到第几帧,即在nBackfill之前的帧数都已经被写入数据库文件了。

显然这两个值有如下关系:nBackfill<=mxFrame

实现

有了前面的准备,我们来看看这两种对应关系的查找是怎么做的。

索引页面的存储

前面分析到,一个wal索引页面的大小为32KB,这些数据是存储在Wal结构体中的:

struct Wal {
    // ...
    int nWiData;               /* Size of array apWiData */
    volatile u32 **apWiData;   /* Pointer to wal-index content in memory */
    // ...
}

其中:

根据帧数查询页面编号

首先来看根据帧数查询这一帧存储的是哪个页面的数据,即根据帧数查询页面编号的实现。

由于wal文件中,到了一定大小之后,就会执行“checkpoint”操作,所以帧数一定是有限的。即帧数满足以下的条件:

所以在aPgno中,就直接使用帧数来做为这个数组的索引。总结下来,添加一个帧数和页面之间对应关系的大体的步骤如下(函数walIndexAppend):

rc = walHashGet(pWal, walFramePage(iFrame), &sLoc);
idx = iFrame - sLoc.iZero;
/* Write the aPgno[] array entry and the hash-table slot. */
nCollide = idx;
for(iKey=walHash(iPage); sLoc.aHash[iKey]; iKey=walNextHash(iKey)){
  if( (nCollide--)==0 ) return SQLITE_CORRUPT_BKPT;
}
sLoc.aPgno[idx] = iPage;
AtomicStore(&sLoc.aHash[iKey], (ht_slot)idx);

下图展示了这个过程的大体示意:

添加帧数与页面编号对应关系的流程

举个例子来说明上面的流程,假设要存储的对应关系是帧数5000存储的是页面编号5:

sLoc.aPgno[927] = 5;
AtomicStore(&sLoc.aHash[101], (ht_slot)927);

可以看到:

根据页面编号查询所在帧

前面分析了如何存储帧数到页面编号的对应关系,可以看到这一次更新是把两个对应关系一起更新的。也可以看到,根据帧数查找的流程实际还是相对简单的,就是两次索引:一次找到页面,再一次就是页面内的查找,原因在于:帧数是有限的。

但是页面就不是这样了,因为这里要存储的页面编号是数据库文件中的页面编号,并不知道当前数据库到底变到多大了,这样就不能按照前面的方式来两次索引。

我们来看看如何根据页面编号,知道这一页面是存储在哪一帧里的,即wal文件的帧数,这个过程在函数sqlite3WalFindFrame中。

根据页面编号查找帧的流程

从这个流程可以看到:查找页面对应帧数的流程,最坏的情况下可能遍历了所有索引页面,虽然其中的查找过程会根据页面编号的hash值来查找。于是一个重要的结论就出来了:WAL的实现,其中有一个缺点是,当WAL文件很大时,对应的索引页面也会很大,在索引中查找页面编号的流程就会变久。

锁的实现

数据结构

wal模式提供了以下4种锁,这4种锁从wal索引文件头部120字节处开始,每种锁占一个字节:

这四种锁,其中有五个读锁,一共加起来就是8个锁,定义如下:

// wal索引文件中锁的数量
#define SQLITE_SHM_NLOCK        8
// 写锁在所有锁中的偏移量
#define WAL_WRITE_LOCK 0
// 除了写锁以外的其他所有锁
#define WAL_ALL_BUT_WRITE 1
// checkpoint锁在所有锁中的偏移量
#define WAL_CKPT_LOCK 1
// 恢复锁在所有锁中的偏移量
#define WAL_RECOVER_LOCK 2
// 输入读锁索引,返回对应读锁在所有锁中的偏移量,因为读锁从3开始,所以+3
#define WAL_READ_LOCK(I) (3 + (I))
// 读索引的数量 = 所有锁数量 - 读锁起始位置3
#define WAL_NREADER (SQLITE_SHM_NLOCK - 3)

问题来了,为什么是索引文件头部120字节处开始的呢?从上面对wal索引文件的格式分析可知:索引文件开始是两个WalIndexHdr + 一个WalCkptInfo,而WalCkptInfo结构体定义如下:

struct WalCkptInfo
{
  u32 nBackfill;              /* Number of WAL frames backfilled into DB */
  u32 aReadMark[WAL_NREADER]; /* Reader marks */
  u8 aLock[SQLITE_SHM_NLOCK]; /* Reserved space for locks */
  u32 nBackfillAttempted;     /* WAL frames perhaps written, or maybe not */
  u32 notUsed0;               /* Available for future enhancements */
};

其中的aLock字段就是存储上面的这些锁的数组,把前面这些数据的大小加起来,一直到这个字段就正好是120,有宏定义为证:

/* A block of WALINDEX_LOCK_RESERVED bytes beginning at
** WALINDEX_LOCK_OFFSET is reserved for locks. Since some systems
** only support mandatory file-locks, we do not read or write data
** from the region of the file on which locks are applied.
*/
#define WALINDEX_LOCK_OFFSET (sizeof(WalIndexHdr) * 2 + offsetof(WalCkptInfo, aLock))

(引用自WAL-mode File Format

Name Offset
xShmLock File
WAL_WRITE_LOCK 0 120
WAL_CKPT_LOCK 1 121
WAL_RECOVER_LOCK 2 122
WAL_READ_LOCK(0) 3 123
WAL_READ_LOCK(1) 4 124
WAL_READ_LOCK(2) 5 125
WAL_READ_LOCK(3) 6 126
WAL_READ_LOCK(4) 7 127

加解锁操作

wal与前面的journal相比,少了很多其他类型的锁,wal只有两种类型的锁:shared共享锁,以及exclusive排它锁。对应的API有下面四个:

static int walLockShared(Wal *pWal, int lockIdx);
static void walUnlockShared(Wal *pWal, int lockIdx);
static int walLockExclusive(Wal *pWal, int lockIdx, int n);
static void walUnlockExclusive(Wal *pWal, int lockIdx, int n);

这里有几个细节,需要交待一下。

首先,其中传入的参数lockIdx,就是上面提到的几种锁的类型索引。

其次,代码中有walLockShared(pWal, WAL_WRITE_LOCK)这样的操作。对一个写锁加共享锁应该怎么理解?需要纠正的是,类似WAL_WRITE_LOCK这样的宏,只是表示这一字节用于什么操作,比如WAL_WRITE_LOCK用于写操作,即:

其他类型的锁依次类推,即WAL_*_LOCK这类宏只是表示这一个字节的用途。

最后一个细节是,加共享锁时只能传入锁类型索引,而加排它锁的时候还能传入一个参数n,这是什么意思?

因为这些不同类型的锁,本质上就是wal索引共享文件上连续的字节,所以区别在于,共享锁一次只能对一个锁进行操作;而排它锁则可以一次多对多个锁进行操作。

比如

walLockExclusive(pWal, WAL_READ_LOCK(1), WAL_NREADER - 1);

这样就能把从1号读锁开始的所有读锁都加上排它锁。

再比如:

iLock = WAL_ALL_BUT_WRITE + pWal->ckptLock;
rc = walLockExclusive(pWal, iLock, WAL_READ_LOCK(0) - iLock);

这里的几个常量取值如下:

于是:

特殊的0号读锁

除此以外,还需要注意0号读锁很特殊,它表示读事务申请的共享锁,和WAL_WRITE_LOCK不冲突,读写可以完全并发进行,互不影响,但是不能和数据库同步操作和WAL-index文件恢复并发进行。0号读锁表示只从数据库读取页。

有了对锁的了解,可以接下来看各种操作的具体实现了。

读操作

进行读操作时,大体需要以下两个操作:

下面分别讨论这两方面的内容。

readMark

首先来了解一下什么叫readMark,以及有什么作用。

回顾之前谈到wal文件以及wal索引文件的格式,有这么几个要点:

以下图为例来说明情况,为了简化说明,一个页面存储的一对KV的信息。

读操作看到的数据库文件和wal

在上图中:

于是,当一个新的读操作开始的时候,会记录下来当时的mxFrame,这个值对于读操作而言,被称为readMark,保存在WalCkptInfo结构体的成员aReadMark数组中。有了这个值,当进行checkpoint操作的时候,就能判断当前是否需要等待读操作完成。这部分将在下面结合checkpoint流程继续讲解。

除此之外,readMark还有另一层含义:即当前已完成事务的最大帧数,所以当读事务去读一个页面的内容时,会首先到wal索引中,根据该页面的编号查询这个页面对应的帧数,有这几种可能:

仍然以前面的图为例来说明情况,假设在上图的第三个写事务还在进行的时候,来了一个读事务,按照前面的解释,此时:

再次说明的是,为了问题描述的简化,这里假设一个页面只存储了一对KV的值。

有了对readMark的初步了解,继续看读操作如何获得读锁。

读锁

前面已经提到,读锁的信息保存在WalCkptInfoaLock成员中:

struct WalCkptInfo
{
    // ...
  u32 aReadMark[WAL_NREADER]; /* Reader marks */
  u8 aLock[SQLITE_SHM_NLOCK]; /* Reserved space for locks */
  // ...
};

在这里:

当一个读操作来的时候,需要获得一个读锁,才能继续往下进行它的读操作,这个获得锁的流程,在函数walTryBeginRead中:

  1. 调用walIndexReadHdr函数读取wal的索引文件头。
  2. 由于WalCkptInfo信息存储在索引文件头中,于是可以接下来调用walCkptInfo函数拿到这部分信息。
  3. 寻找当前可用的读锁,分为以下几步:
    1. 有了当前的WalCkptInfo信息,遍历其中的aReadMark数组,选出其中readMark最小的那个值,并且记录下这个最小值的索引i。这是因为readMark小的读操作,更有可能已经完成了读操作。
    2. 尝试调用walLockExclusive(pWal, WAL_READ_LOCK(i), 1)对上一步拿到的readMark数组索引加排他锁:
      1. 如果成功,说明这个读锁当前没有其它进程在用,可以退出循环了。
      2. 否则,就递增i索引对下一个读锁进行尝试,直到遍历完毕所有读锁。
    3. 到了这里,已经拿到一个可用的读锁了,调用walLockShared对这个读锁加共享锁。
  4. 在前面的过程中,很可能有写操作在进行,所以在返回之前,最后判断一下wal 索引头数据是否发生了变化,如果发生了变化,前面的步骤就得重新来过,返回重试。

需要补充说明的是,函数walTryBeginRead在调用时,如果返回重试(WAL_RETRY)的话,调用者会将调用计数递增,当这个调用计数超过一个阈值时,再次调用时walTryBeginRead会休眠一下,超过100次则会报错不再尝试。

// 尝试超过5次的情况下,要休眠一下
if (cnt > 5)
{
  int nDelay = 1; /* Pause time in microseconds */
  if (cnt > 100)
  {
    // 超过100次了,退出报错
    VVA_ONLY(pWal->lockError = 1;)
    return SQLITE_PROTOCOL;
  }
  if (cnt >= 10)
    nDelay = (cnt - 9) * (cnt - 9) * 39;
  sqlite3OsSleep(pWal->pVfs, nDelay);
}

walTryBeginRead函数流程

可以从加读锁的流程看到:

写操作

写锁

拿到写锁的流程,对比上面拿到读锁的流程来说,就简单很多了,在函数sqlite3WalBeginWriteTransaction中:

  1. 调用walLockExclusive(pWal, WAL_WRITE_LOCK, 1)拿到写锁的排它锁。
  2. 同样也是检查是否wal索引头发生了变化,如果是则需要再次尝试。

而解写锁操作就在函数sqlite3WalBeginWriteTransaction

这两个函数的实现都挺简单,就不展开阐述了。

写操作

真正将脏页面写入wal文件中的操作在函数sqlite3WalFrames中,该函数有几个比较重要的参数:

sqlite3WalFrames函数的实现也并不复杂,有这么几个事情:

还是以一个例子来说明这个流程。

写事务修改WAL文件和WAL索引数据

如上图所示,精简了很多情况,假设从WAL文件的开头开始写脏页面了,图中的写事务一共写了三次页面:

页面校验值的计算

checkpoint

总体流程

有了前面读、写操作的了解,接着来了解一下checkpoint操作是如何实现的。

我们回顾一下checkpoint操作要完成的事情:由于wal日志中存储的,都是每次写事务被修改的页面,因此checkpoint操作就是将wal日志中被修改的页面写入数据库文件中。也是因为这个原因,因此checkpoint也被称为backfill(回填)操作。

checkpoint操作

从上面的读写流程的分析里看出:

因此,在做checkpoint的时候,需要保证:

checkpoint操作

第一点很好理解,因为未提交的写事务,可能只修改了一部分,如果在未提交这个写事务之前,就把这一部分回填到数据库文件中,会造成读出来的这部分数据驴头不对马嘴。比如一个写事务的修改,是将A账号的100转账到B账号上,于是这个写事务就涉及两个修改:

如果这个事务当前只完成了上面的第一步修改,这个修改马上被回填到数据库文件中,这时候看到的就是A少了100,而B没有变化,这显然是不可接受的。

第二点的理解,要回到前面对readMark值的解释上:一个读操作开始之前,会记录一下当前已完成写事务的最大修改帧数做为自己的readMark,在后续的读操作中,从wal索引中查询一个页面编号,有以下几种可能:

注意:上面的”这个页面在读操作之后被修改“条件,不仅包括这个修改对应的写事务没有被提交,也包括写事务已提交的情况。

即:readMark保证了,一个读事务绝对不能读到在这个读事务之后的任何修改。

由这个解释可以看到,当一个读操作判断一个页面的内容需要到数据库文件中读取时,需要读到的是这个读事务之前的修改。因此,checkpoint需要保证:不能将超过当前任何读操作的readMark值的帧数,回填其保存的页面到数据库文件中。

到了这里,基本可以确定一个checkpoint操作的流程了:

以上是checkpoint流程的总体描述,其中涉及的主要函数是:

想了解更多细节的读者可以自行阅读。

不同的checkpoint模式

上面只是了解了checkpoint的大体流程,但是不同的checkpoint模式又有区别,有如下的宏来进行区分:

#define SQLITE_CHECKPOINT_PASSIVE  0  /* Do as much as possible w/o blocking */
#define SQLITE_CHECKPOINT_FULL     1  /* Wait for writers, then checkpoint */
#define SQLITE_CHECKPOINT_RESTART  2  /* Like FULL but wait for for readers */
#define SQLITE_CHECKPOINT_TRUNCATE 3  /* Like RESTART but also truncate WAL */
SQLITE_CHECKPOINT_PASSIVE

这个模式下,不会等待读写操作完成,而是基于现有的数据,尽可能将安全的帧回填到数据库文件中。

可以看到,这种模式更接近于一种”步进(step)“的模式:每次回填一部分数据,回填不完就直接返回不再进行。所以,需要某些变量来保存当前的回填进度,这个值保存在WalCkptInfo.nBackfill,所以回填还没有结束的条件也就是:pInfo->nBackfill < pWal->hdr.mxFrame

SQLITE_CHECKPOINT_FULL

这个模式下,checkpoint操作会等待写操作完成,才继续进行回填操作,而在回填过程中也不再允许有新的写操作进行。

SQLITE_CHECKPOINT_RESTART

对比SQLITE_CHECKPOINT_FULL模式,这一个模式更进了一步:等待所有读操作完成才开始回填操作,同样的,在checkpoint过程中除了不能有写操作还不能有读操作。

SQLITE_CHECKPOINT_TRUNCATE

这一个模式对比SQLITE_CHECKPOINT_RESTART又更近了一步,在回填完毕之后,将截断WAL文件,这样后面新来的wal的写操作,将从wal文件的开始位置开始写。我们前面提到,在wal文件中查找一个页面时,跟wal文件的大小成正比,所以回填完毕截断wal文件重新开始写,会加速后面的查询操作。

错误恢复

以上已经把wal的读、写、checkpoint流程都了解了,最后了解一下wal的错误恢复是如何实现的。

区分几种情况下面的出错崩溃,以及这些情况下都如何恢复的:

总结

最后对wal机制做一个简短的总结:

参考资料


原文出处:sqlite3.36版本 btree实现(五)- Btree的实现

前面的内容里,详细介绍了页面管理器部分的内容,回顾一下页面管理器和Btree模块的分工:

还记得最开始,研究生产级别btree实现时的几个疑问:

这些问题,都与“一个物理页面内数据如何组织”这个核心问题息息相关,带着这些问题展开btree实现的讨论。

在下文中,不会讨论btree算法的细节,这部分不熟悉的,可以回看之前的文章或者教科书:

物理页面的数据组织

数据表的逻辑组织和页面类型

在展开具体的格式讨论之前,有必要先了解一下数据库文件的大体结构,已经不同的页面类型。

sqlite中所谓的数据库文件是单一文件,按照物理页面(2的次方)的大小来划分为多个页面。其中,每个表在数据库文件中是一棵btree的结构来组织,而不同类型的btree还区分了不同的页面。

比如下图中,将平面的数据库文件,按照颜色划分成存储两个表的btree:

数据库文件的物理页面组织和逻辑页面结构

在上图中:

因为每个表都有自己的btree树形结构,如果每个表都有一个对应的根页面编号,比如图中的两个表,对应的树形结构中,根节点所在的页面分别是1和2。

接着来看不同的页面类型,以及存储上的差异。

以一个例子来说明,创建以下的数据库,插入数据,以及索引:

// 创建数据库COMPANY
CREATE TABLE COMPANY(
   ID             INT      NOT NULL,
   NAME           TEXT    NOT NULL,
   AGE            INT     NOT NULL,
   ADDRESS        CHAR(50),
   SALARY         REAL
);
// 创建索引
CREATE INDEX id_index ON COMPANY (id);
// 插入2条数据
INSERT INTO COMPANY (ID,NAME,AGE,ADDRESS,SALARY) VALUES (1, 'Paul', 32, 'California', 20000.00 );
INSERT INTO COMPANY (ID,NAME,AGE,ADDRESS,SALARY)
VALUES (2, 'Allen', 25, 'Texas', 15000.00 );
// 查询数据
sqlite> select * from COMPANY;
1|Paul|32|California|20000.0
2|Allen|25|Texas|15000.0
// 查询rowid和数据
sqlite> select rowid,* from COMPANY;
1|1|Paul|32|California|20000.0
2|2|Allen|25|Texas|15000.0

在上面的流程里:

到了这里,问题就来了,rowid是什么?为什么会自动存在这个字段,起了什么作用?

rowid是sqlite中的一个隐藏列:整数类型,自增。

为什么需要这个隐藏列?有以下两个原因:

以上面为例,在sqlite的btree中大体就是这样的:

数据库文件的rowid全量数据表和索引表

在上图中存在两个表:

从上面的这个例子里也可以看出,“逻辑意义”上的一个数据表,与实际存储时在btree中的表并不是一一对应的,由于索引表的存在,可能是一对多的关系。

有了前面的对rowid以及索引表、全量数据表的理解,就可以展开讨论不同的页面类型了。

sqlite中分为几类页面:

显然,全量数据表的页面就是这类页面,即全量数据表更准确的是b+tree结构。

为了表示这些页面,有如下几种页面标志位:

/*
** Page type flags.  An ORed combination of these flags appear as the
** first byte of on-disk image of every BTree page.
*/
#define PTF_INTKEY    0x01
#define PTF_ZERODATA  0x02
#define PTF_LEAFDATA  0x04
#define PTF_LEAF      0x08

页面标志位只可能是以下几种组合,其余组合都认为文件损坏了:

说明,其实以上就是分成了两组PTF_ZERODATAPTF_LEAFDATA | PTF_INTKEYPTF_LEAF的组合(见函数decodeFlags):

物理页面的大体划分

来看看sqlite代码中btreeInt.h文件里对btree一个物理页面内数据组织的注释:

** Each btree pages is divided into three sections:  The header, the
** cell pointer array, and the cell content area.  Page 1 also has a 100-byte
** file header that occurs before the page header.
**
**      |----------------|
**      | file header    |   100 bytes.  Page 1 only.
**      |----------------|
**      | page header    |   8 bytes for leaves.  12 bytes for interior nodes
**      |----------------|
**      | cell pointer   |   |  2 bytes per cell.  Sorted order.
**      | array          |   |  Grows downward
**      |                |   v
**      |----------------|
**      | unallocated    |
**      | space          |
**      |----------------|   ^  Grows upwards
**      | cell content   |   |  Arbitrary order interspersed with freeblocks.
**      | area           |   |  and free space fragments.
**      |----------------|

页面内数组的组织

按照这里的说法,一个物理页面在btree中划分为以下几部分:

有了这里大体的划分,我们来展开看看具体的内容。

file header(文件头)

其中,file header部分只有第一页才有,固定长度100字节,存储整个数据库文件的元信息。

页面1是一个特殊的页面,其前100个字节是整个数据库的header部分,内容如下:

**   OFFSET   SIZE    DESCRIPTION
**      0      16     Header string: "SQLite format 3\000"
**     16       2     Page size in bytes.  
**     18       1     File format write version
**     19       1     File format read version
**     20       1     Bytes of unused space at the end of each page
**     21       1     Max embedded payload fraction
**     22       1     Min embedded payload fraction
**     23       1     Min leaf payload fraction
**     24       4     File change counter
**     28       4     Reserved for future use
**     32       4     First freelist page
**     36       4     Number of freelist pages in the file
**     40      60     15 4-byte meta values passed to higher layers
**
** All of the integer values are big-endian (most significant byte first).

逐个解析这个header里的内容:

page header(页面头)

除了第一页以外,其他情况下页面头在页面的开始位置(即偏移量为0开始),第一页从偏移量100开始。

其内容如下:

** The page headers looks like this:
**
**   OFFSET   SIZE     DESCRIPTION
**      0       1      Flags. 1: intkey, 2: zerodata, 4: leafdata, 8: leaf
**      1       2      byte offset to the first freeblock
**      3       2      number of cells on this page
**      5       2      first byte of the cell content area
**      7       1      number of fragmented free bytes
**      8       4      Right child (the Ptr(N) value).  Omitted on leaves.

分析如下:

重点解释其中的几部分:

cell数组

cell数组中,每个元素大小为2字节,指向对应cell内容在页面中的位置。由于一个页面不会超过65535字节大小,所以2字节是足够表示这个页面内的偏移量的。

另外,cell数组中的元素,是按照key的大小有小到大按顺序排列的,这样才能方便查找。

有了对物理页面内数据组织的了解,可以看到:

查找key的流程

空闲空间链表

所谓的“空闲空间”,就是夹在cell数组cell内容数组中间的部分,最开始是一大整块的空间。

但是随着写入操作进行,比如一行数据被删除之后,回收的空间就会放到空闲空间链表上。空闲空间链表就是用来组织现在空闲空间的数据的,其中的每个元素为4字节,其内容是:

**    SIZE    DESCRIPTION
**      2     Byte offset of the next freeblock
**      2     Bytes in this freeblock

其中:

另外,空闲空间链表是按照起始地址递增进行排序的,这样才能在从空闲空间中分配空间时高效查找最合适的空间。

空闲区链表

碎片空间总大小

如前所述,表示一个空闲空间至少需要4字节,因此如果一个空闲空间不足4字节时,显然是不划算的。所以,将所有空闲的、且小于4字节的空间,称为“碎片空间”,其总大小记录在头的7-8字节处,当这个值超过某个阈值时将进行页面碎片空间整理。

页面标志位

见前面页面类型部分的解释。

cell的结构

一个cell,就是存储一行数据的单位,由于要考虑一行数据有不同的长度,因此cell里面就要有应对变长数据的准备,来看cell的结构。

** The content of a cell looks like this:
**
**    SIZE    DESCRIPTION
**      4     Page number of the left child. Omitted if leaf flag is set.
**     var    Number of bytes of data. Omitted if the zerodata flag is set.
**     var    Number of bytes of key. Or the key itself if intkey flag is set.
**      *     Payload
**      4     First page of the overflow chain.  Omitted if no overflow

逐个解释:

因为叶子页面没有左子树,所以不需要存储左子树页面编号,因为如果是叶子页面的话,页面头的大小就只有8字节,反之如果是内部节点就需要12字节。

cell结构

其中,根据表类型的不同,key、data、payload部分的大小关系如下:

这种类型的页面,肯定没有数据部分,只有key部分,所以这时候nPayload = nKey。(分析这类型页面cell数据的入口函数是btreeParseCellPtrIndex

为什么非int类型的表,会没有数据部分?这是因为,完整的数据,都以rowid为key的情况,存储在了int类型的表中;而非int类型的表,只要存储这个表的键值整数即可。

而溢出页面的结构如下:

** Overflow pages form a linked list.  Each page except the last is completely
** filled with data (pagesize - 4 bytes).  The last page can have as little
** as 1 byte of data.
**
**    SIZE    DESCRIPTION
**      4     Page number of next overflow page
**      *     Data

即:对于存储溢出数据的页面而言,头部的4字节存储下一个溢出页面的页面编号(如果还有溢出页面的话),而剩余则存储数据。

结合起来,一个存储了溢出数据(即当前页面不足以存储这个cell的所有数据)的结构大体是:

有溢出页面的cell结构

空间利用和碎片整理

了解了一个物理页面内部的结构,接下来看碎片整理的实现。

虽然有空闲链表用于组织已被回收的空间,但是随着分配的进行,还是会产生各种大小的碎片。可能会出现这样的情况:虽然空闲空间的总大小够用,但是由于散落在各块碎片空间里,以至于无法分配成功。

这种情况下,就需要碎片整理流程,这个流程做完之后,要达到这样的目的:空闲链表上只有一块大的整空间。

来看看碎片整理的触发条件和过程。

碎片整理的触发条件

分配空间的入口函数是allocateSpace,其入参nByte表示需要从页面中分配大小为nByte的空间返回。进入这个函数时,都会首先判断:页面剩余的空闲空间是足够满足nByte的,但是即便如此也可能由于碎片太多导致不能再分配了。

这个函数的大体逻辑如下:

所以,分别来看上面两个条件。

首先来看什么情况下在空闲链表中分配不出适合的空间,入口函数是pageFindSlot,有两种可能:

至于为什么小于4字节认为是碎片,以及为什么碎片总大小超过60字节就认为碎片过多,可能是作者的经验值。

除此之外,如果当前cell数组结束位置,距离cell内容区域不足2字节,即无法再分配一个cell指针出来,这时候就需要进行碎片整理了。

触发碎片整理的条件

总结一下触发碎片整理的条件:

碎片整理的流程

碎片整理的流程,其实相对简单,简而言之:

碎片整理前后

碎片整理的整体流程在函数defragmentPage中,读者可以自行阅读。

总结

参考资料