在前面2篇文章讲了有关WAL日志相关的一些基础知识:
SQLite3源码学习(31) WAL日志的锁机制
SQLite3源码学习(30)WAL-Index文件中的hash表
接下来分析一下在WAL日志模式下,整个事务的处理机制和流程
事务管理最核心的特性就是满足原子提交特性,之前的回滚日志模式实现了这个特性,而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文件出错会影响接下来读写的正确性。
优点:
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文件变得很大。
在开始读数据之前,需要通过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]之后的帧同步到数据库,否则会影响读事务的正确性。
这部分加锁的代码比较繁琐,就不再贴出。
写事务的实现基本全在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 || iWriteiReCksum ){
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文件头
……
}
检查点就是把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->nBackfillhdr.mxFrame ){
/* Allocate the iterator */
//迭代器把每一块的帧按照页号排序
//如果JnBackfill
下面来简要说明一下迭代器,初始化中有一个归并排序,比较难理解,这里稍微讲一下,之前讲的归并排序是关于链表的,而这里是数组元素的排序:
// 排序目标是,如果JaList为子数组的第一个元素
//归并后,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++; iSubaList, 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->iNextnEntry ){
u32 iPg = pSegment->aPgno[pSegment->aIndex[pSegment->iNext]];
//在当前块内找到大于上一次迭代的页号
//找到之后先别急着增加pSegment->iNext
//可能iPg并不是所有块内最小的页,需要遍历
//完所有的块才知道
if( iPg>iMin ){
if( iPgiZero + pSegment->aIndex[pSegment->iNext];
}
break;
}
pSegment->iNext++;
}
}
*piPage = p->iPrior = iRet;
//遍历迭代器所有元素后返回1
return (iRet==0xFFFFFFFF);
}
《SQLite Database System Design andImplementation》p.249~p.252
Write-AheadLogging
WAL-mode File Format
SQLite分析之WAL机制
Sqlite学习笔记(四)&&SQLite-WAL原理
Sqlite学习笔记(三)&&WAL性能测试
SQLite中的WAL机制详细介绍