个人学习笔记,有理解错误的请赐教,有没写清楚,或者原理上有漏掉比较重要的地方没写的,请大家留言,一起学习交流,谢谢
文件结构
WAL日志共有两个文件WAL文件和WAL-INDEX文件,WAL文件是记录redo日志的文件,WAL-INDEX文件是一个map-shared文件,用来加快检索WAL文件的日志
WAL文件
wal文件由一个文件头和多个frame组成,frame由frame头和数据页的修改组成。
结构图如下:
WAL-INDEX内存
哈希索引的目的是为了快速查找给定页对应的Frame序号。
WAL-INDEX多进程场景下使用map内存,单进程使用堆内存。WAL-INDEX内存由相同大小的索引块组成,索引块大小为32K。WAL-INDEX的头存放在第一个索引块内,所以第一个索引块存放索引的数量比其他索引块少。
Wal-Index头结构如下:
索引块分为两部分,一部分存放了page序号的数组(下标是frame序号),共4096个(第一个索引块为4062个);另一部分存放了一个HashTable(key是page序号,value是frame序号)。
整个文件的结构如下:
HashTable大小为4096 * 2 个 frame序号(第一个索引块除外),每个序号占2个字节,page序号占4个字节,因此一个索引块大小为4K * 2 * 2 + 4K * 4 = 32K。
基础原理
校验和CheckSum
校验和有两个值aCkSum0, aCkSum1, 初始值为0。
计算一段给定内存buf的checksum值算法为:
aCkSum0 += aCkSum1 + *(uint32_t *)((uint32_t *)buf + i);
aCkSum1 += aCkSum0 + *(uint32_t *)((uint32_t *)buf + i + 1);
因此buf的长度必须是8字节的倍数
Wal日志的校验和是累加计算,也就是说Frame1的aCksum0, aCksum1的初始值为0, Frame2的aCksum0, aCksum1的初始值是Frame1计算出来的校验和。
wal index的插入与查找
插入:
- 每个索引块存储的编号数量是固定的,根据frame序号可算出该frame序号所在的索引块
iHashBlock = (iFrameNo + HASHTABLE_NPAGE-HASHTABLE_NPAGE_ONE-1) / HASHTABLE_NPAGE
- 用pageNo进行Hash,计算出iHashKey = (iPage*HASHTABLE_HASH_1) % HASHTABLE_NSLOT;
- 如果哈希冲突,即HashTable[iHashKey] 已经有值,iHashKey = (iHashKey + 1) %HASHTABLE_NSLOT
- HashTable[iHashKey] = frameNo,PageNo[frameNo - StartFrameNo] = pageNo
查找:
通过页号pageNo查找最新的frame序号frameNo
- frameNo 搜索范围为[minFrame, maxFrame], minFrame为当前最小的没有执行checkpoint的frame序号,maxFrame为当前最大的frame序号。根据frameNo搜索范围确定搜索的wal-index块范围[iFirst, iLast]
- 从iLast索引块开始往前查找,计算pageNo的哈希值,得到iHashKey。
- 记frameNo = HashTable[iHashKey], 如果frameNo != 0 且 PageNo[frameNo - StartFrameNo] != pageNo, 说明哈希冲突,iHashKey = (iHashKey + 1) %HASHTABLE_NSLOT, 如果frameNo != 0 且 PageNo[frameNo - StartFrameNo] == pageNo,查找成功,返回
- 遍历所有范围内的索引块,都没找到对应的frame,说明该页在checkpoint后并没有写过frame日志,db文件的页已经是最新的
写日志
假设当前状态如图,写事务已经开始一段时间,已经修改过三个页,并产生了Frame1、Frame2、Frame3三个Wal日志Frame,此时pWal->mxFrame = Frame3 记为CurMaxFrame, 写事务开始时的maxFrame也就是索引内存块内WalIndexHdr1->mxFrame,记为TrxBeginMaxFrame。
现在有两种情况:
- 修改一个新页D,Wal日志只需要追加写在Frame3后,写一个新的Frame,同时将CurMaxFrame+1,并将对应的Frame4和PageD插入索引内。
- 修改一个旧页B,将新日志覆盖Frame2,同时记下被覆盖的最小序号的Frame序号,记为iReCksum。当事务提交时,从iReCksum 序号的Frame开始计算校验和,重新覆写Frame的校验和。(因为Wal是累加校验,当Frame2发生改变时,意味着Frame3在Frame2发生改变之前计算的CheckSum是不对的,需要重新计算)。
判断是旧页还是新页的依据:在[TrxBeginMaxFrame, CurMaxFrame]区间内存在着一个Frame,这个Frame记录的是该页的Wal日志。
扫描日志
Sqlite使用WalIterator结构体来辅助读取指定frame序号以后的frame,并且是按页号升序的方式读取frame。
walIteratorInit函数缓存了每个索引块的PageNo数组地址,并将PageNo数组归并升序排序。即WalIterator保存了多个升序的数组。
如图:从aIndex按序取出来的frame序号iFrame(相对值,真实值需要加startFrameNo),再从aPgNo取出来的页号是升序的。
比较每个Segment中aPgno[aIndex[iNext]],最小值即为本次需要取出来的pageNo, aIndex[iNext] + startFrameNo为本次取出来的frame号,取完值后将对应段的iNext ++。
CheckPoint
- 假设上一次执行checkpoint刷盘时的最大frame序号为nBackfill, 执行CheckPoint时的所有读事务时的可读的最小frame序号为mxSafeFrame, 那么本次刷盘的frame序号范围为[nBackfill+1, mxSafeFrame]
- 按页号升序的方式扫描Wal日志,如果frame序号范围在区间内,则读取页数据并刷盘到数据文件中。
锁与并发
Wal总共有8个锁位,锁都存放在wal-index内。(详细请看)
Wal支持一写多读同时并发,拥有一个写锁位,五个读锁位,其中Rd0锁位
锁保护的区域如下:
WAL_WRITE_LOCK:保护wal-index内的WalIndexHdr的并发,在写事务内全程加锁
WAL_CKPT_LOCK:保护checkpoint, 使得不同线程调用checkpoint只能串行执行
WAL_RECOVER_LOCK: 保护wal-index的恢复。
事实上walIndexRecover流程需要加三把锁WAL_WRITE_LOCK、WAL_CKPT_LOCK和WAL_RECOVER_LOCK。加写锁是为了防止在恢复过程中被写事务修改,加WAL_CKPT_LOCK锁也是为了防止被checkpoint修改。除了这两个流程会修改wal-index外,不会有其它流程修改。加WAL_RECOVER_LOCK锁是为了让读事务判断是不是在恢复过程中。
WAL_READ_LOCK:保护对应aReadMark的并发,在读事务内全程加锁。
WAL_READ_LOCK(0) 是个特殊的锁,意味着当前读事务会直接从Db文件中读取页,而不会从wal日志中读取页内容。因为开启读事务时所有页都已经刷入Db文件中 。
WalIndexHdr
- 开启读事务和写事务都需要从wal-index中拷贝WalIndexHdr到pWal中
- 写事务日志提交时会将pWal写回到wal-index中。
- 写回到wal-index中,先写第二个WalIndexHdr,再写第一个WalIndexHdr。开启读事务时,先读取第一个,再读取第二个,并判断是两个WalIndexHdr是否相同,相同才能开启读事务。
因为sqlite是可以读写同时并发的,也就是说可能存在写事务在写WalIndexHdr到一半时,读事务开始读WalIndexHdr,这样就会导致读事务读取到的WalIndexHdr结构体不一致。
重置日志
将wal日志重置到初始值,长度截断,文件头也重新init。
必须满足没有读事务、其他进程/线程也没有写事务时才可以重置。
时机:checkpoint、写事务写日志时
结构体解析
WalIndexHdr:WAL-INDEX文件头
struct WalIndexHdr {
u32 iVersion; // wal index 版本号
u32 unused; // 未使用
u32 iChange; /* Counter incremented each transaction */
u8 isInit; // 1 表示已经wal index init过了
u8 bigEndCksum; // 是否是大端格式
u16 szPage; // 数据页大小(16字节最大值为64K-1,因此设定szPage = 1时表示为64K)
u32 mxFrame; // 更新结构体WalIndexHdr时最大的合法frame序号
u32 nPage; //已提交的数据库页个数, 提交时更新
u32 aFrameCksum[2]; // 日志中最后一个frame的校验和
u32 aSalt[2]; // 加盐值
u32 aCksum[2]; // WalIndexHdr结构体的校验和
};
WalIndexHdr结构体在Wal-Index内存有两份,在Wal管理结构体中也有一份,
WalCkptInfo:checkpoint信息
struct WalCkptInfo {
u32 nBackfill; /* 转换写到数据文件中的WAL Frame的数量 */
u32 aReadMark[WAL_NREADER]; // 读mark位,开启读事务时写入当前最大frame号
u8 aLock[SQLITE_SHM_NLOCK]; // 读事务锁
u32 nBackfillAttempted; /* 准备写到数据文件中的WAL Frame 序号*/
u32 notUsed0;
};
准备往DB文件中写入数据页时,将nBackfillAttempted 设置为当前可写的最大frame序号,写完后再将nBackfill设置为nBackfillAttempted。
Wal:Wal日志管理结构体
struct Wal {
sqlite3_vfs *pVfs; /* The VFS used to create pDbFd */
sqlite3_file *pDbFd; // db文件句柄
sqlite3_file *pWalFd; // wal文件句柄
u32 iCallback; /* Value to pass to log callback (or 0) */
i64 mxWalSize; // 当WAL文件大小超过mxWalSize,就对WAL文件进行截断。截断策略
int nWiData; // apWiData数据长度
volatile u32 **apWiData; // 存储wal-index各个索引块的首地址
int szFirstBlock; /* Size of first block written to WAL file */
u32 szPage; // 数据页大小
i16 readLock; // 加读锁的序号, -1 表示没人加读锁
u8 syncFlags; // sync wal文件头的flag
u8 exclusiveMode; // wal index的内存是使用堆内存还是map内存。(多进程模式使用map内存,单进程模式使用堆内存)
u8 writeLock; // true表示正在执行一个写事务
u8 ckptLock; // true 表示当前连接已经加了ckpt锁
u8 readOnly; // true 表示 wal只支持读事务,开启写事务时会报错
u8 truncateOnCommit; // true表示当事务提交时会对WAL文件进行截断
u8 syncHeader; // true 表示每次write Wal文件头后都会进行sync
u8 padToSectorBoundary; // wal 日志是否边界对齐
u8 bShmUnreliable; // 不可靠模式,使用的是堆内存去存储wal-index索引,当wal-index在当前连接不可写时触发
WalIndexHdr hdr; // 当前事务的wal-index头
u32 minFrame; // 没转换写入数据文件的最小frame序号
u32 iReCksum; // 提交时,frame序号从iReCksum开始,要重新计算checksum
const char *zWalName; // WAL 文件的文件名
u32 nCkpt; // 日志绕回WAL文件开头的次数
};
每个连接会有一个wal结构体
WalIterator:扫描WAL日志时用的迭代器
struct WalIterator {
u32 iPrior; // 上一次取出来的页号
int nSegment; // Segment 个数,一个Segment 对应一个索引页
struct WalSegment {
int iNext; // aIndex[iNext]之前的页号都被读走了
ht_slot *aIndex; // 根据aPgno[aIndex[i]]得出来的页号是升序的
u32 *aPgno; // 索引页page序号数组地址
int nEntry; // 当前索引页page序号数组的长度
int iZero; // 索引页的起始frame序号
} aSegment[1];
};
WalWriter
typedef struct WalWriter {
Wal *pWal; // wal
sqlite3_file *pFd; // wal 文件句柄
sqlite3_int64 iSyncPoint; // 当开启了边界对齐时,写日志写到边界对齐的地方会拆成两次写,并且在边界对齐的地方sync
int syncFlags; // fsync flag
int szPage; // 页大小
} WalWriter;
WalHashLoc
struct WalHashLoc {
volatile ht_slot *aHash; // 索引块中的哈希表地址
volatile u32 *aPgno; // 当前索引块的帧序号和页序号映射数组地址
u32 iZero; // 当前索引页的帧编号和页编号映射的逻辑数组中的起始下标
};
索引块的描述结构体,记录了哈希表地址和页序号数组地址、当前索引块的frame序号偏移,即aPgno[index],所表示的是iZero + index的frame序号对应的页序号
主要函数及原理
黑色背景为对外接口,灰色背景为内部函数
sqlite3WalOpen
int sqlite3WalOpen(sqlite3_vfs *pVfs, sqlite3_file *pDbFd, const char *zWalName, int bNoShm, i64 mxWalSize, Wal **ppWal);
作用:初始化一个WAL实例
参数 | 含义 |
---|---|
sqlite3_vfs *pVfs | sqlite的文件管理系统 |
sqlite3_file *pDbFd | 数据文件句柄 |
const char *zWalName | Wal文件名 |
int bNoShm | true选择堆内存模式,false选择map内存模式,详见Wal结构体字段exclusiveMode |
int mxWalSize | Wal文件的最大长度,详见Wal结构体字段mxWalSize |
Wal **ppWal | 出参,初始化好的WAL实例 |
sqlite3WalBeginWriteTransaction/sqlite3WalEndWriteTransaction
Wal只支持一个写事务。
int sqlite3WalBeginWriteTransaction(Wal *pWal);
int sqlite3WalEndWriteTransaction(Wal *pWal);
作用:开启/结束写事务
参数 | 含义 |
---|---|
Wal *pWal | Wal 实例 |
sqlite3WalBeginWriteTransaction 会加上一个写锁,并置pWal->writeLock为true,同时检查pWal->hdr和WAL-Index的写事务WalIndexHdr是否一致,不一致则报错。
sqlite3WalEndWriteTransaction 解锁并至pWal->writeLock为false
sqlite3WalBeginReadTransaction/sqlite3WalEndReadTransaction
int sqlite3WalBeginReadTransaction(Wal *pWal, int *pChanged);
void sqlite3WalEndReadTransaction(Wal *pWal)
作用:开启/结束读事务
参数 | 含义 |
---|---|
Wal *pWal | Wal 实例 |
int *pChanged | 上一次读事务之后,数据库已经发生了改变 |
DML操作会先开启读事务,读取数据库的schema信息。
如果是建库后的第一次开启读事务还会对wal-index初始化,如果是开库后的第一次开启读事务就重建wal-index。
wal-index初始化和重建详见
sqlite3WalFrames
int sqlite3WalFrames(Wal *pWal, int szPage, PgHdr *pList, Pgno nTruncate, int isCommit, int sync_flags);
作用:数据页的修改写入WAL日志,如果isCommit为true时,会将事务进行阶段性的提交。(写事务可能会多次提交,sqlite3WalFrames会将近期的修改提交)。必须在开启写事务之后使用。
参数 | 含义 |
---|---|
Wal *pWal | Wal 实例 |
int szPage | 数据页大小 |
PgHdr *pList | 脏页列表 |
Pgno nTruncate | 当事务提交后数据页的数量 |
int isCommit | 是否提交 |
int sync_flags | sync标志,当CKPT_SYNC_FLAGS(sync_flags)与pWal->syncHeader都为true时,更新了WAL文件头(第一次写frame时),会sync到文件中当WAL_SYNC_FLAGS(sync_flags)与isCommit为true时,。。。 |
- 遍历脏页列表,查找开启写事务后是否有写过相同页号的frame,没有的话追加写到wal文件中,有的话则覆写原来的frame
因为checkSum是累加每个frame进行校验,所以覆写后的frame会导致该frame后的所有frame的校验和改变。在日志提交时,我们需要重新计算checkSum,用pWal->iReCksum来标记最小的被改变的frame序号
- 日志如果需要提交,则从 pWal->iReCksum开始读取日志并计算校验和,再重新写入每个frame的校验和。初始化校验和为iReCksum的上一个frame,没有的话用wal文件头上的checksum
- 如果需要扇区边界对齐,那么将最后一个frame帧,重复写,直到超过或等于扇区边界。
- 如果配置了truncateOnCommit, 对wal文件超出有效日志区域进行截断
- 将追加写的frame插入wal-index内存中。
int walIndexAppend(Wal *pWal, u32 iFrame, u32 iPage); 1. 根据frame序号找到对应的索引块,取得索引块的描述结构体WalHashLoc index = (iFrame+HASHTABLE_NPAGE-HASHTABLE_NPAGE_ONE-1) / HASHTABLE_NPAGE; 2. 对页序号进行哈希,插入到哈希表中 1> iKey = (iPage * 383 ) % (4096 - 1) 2> 如果哈希冲突,即hash[iKey] 已被使用,那就往iKey + 1找,到边界时再从0开始找,直到找到空位 3. 将页序号插入到索引块的页序号数组中,下标为 iFrame - iZero - 1
- 更新pWal->hdr.mxFrame,如果需要提交,还需要更新pWal->hdr.iChange/npage, 并更新wal index内存头的WalIndexHdr
sqlite3WalFindFrame
int sqlite3WalFindFrame(Wal *pWal, Pgno pgno,u32 *piRead);
作用:查找该页的最新的wal日志
参数 | 含义 |
---|---|
Wal *pWal | Wal 实例 |
Pgno pgno | 要查找的页序号 |
u32 *piRead | 出参,找到的frame序号,没找到为0(没找到说明该页在数据文件中已经是最新的),返回出错时,该值无效 |
- 确定搜索范围,从pWal->minFrame所在的索引页到pWal->hdr.mxFrame所在的索引页
- 在从最后的索引页中往前查找
1> 先对pgno进行哈希,iKey = pgno * 383 % 4095。
2> 如果aPgno[aHash[iKey] - 1] == pgno,记录frame序号(只保留最后搜索到的frame序号),继续向后搜索
3> 如果aHash[iKey] == 0,当前索引页搜索结束。
4> iKey = (iKey + 1) % 4095,继续执行2,3步
5> 当前索引页没有搜索到,搜索前一个索引页,回到第一步
sqlite3WalReadFrame
int sqlite3WalReadFrame(Wal *pWal, u32 iRead, int nOut, u8 *pOut)
作用:读取iRead序号的frame中的页数据
参数 | 含义 |
---|---|
Wal *pWal | Wal 实例 |
u32 iRead | frame序号 |
int nOut | 读取页数据的大小,从页头开始读 |
u8 *pOut | 出参,读取的数据 |
每个frame的大小是固定的,所以frameOffset = WAL_HDRSIZE + (iRead-1)*(pageSize+WAL_FRAME_HDRSIZE) | |
frameOffset + WAL_FRAME_HDRSIZE就是数据页的偏移 |
sqlite3WalUndo
int sqlite3WalUndo(Wal *pWal, int (*xUndo)(void *, Pgno), void *pUndoCtx)
作用:回滚当前写事务
参数 | 含义 |
---|---|
Wal *pWal | Wal 实例 |
xUndo | 由外部传入的函数,用于回滚pgno页 |
void *pUndoCtx | 传递给xUndo的参数 |
- 遍历当前写事务写过的frame序号,(从walIndexHdr->mxFrame到pWal->hdr->mxFrame)找到对应的页号,再调用xUndo函数进行还原
- 还原pWal->hdr到写事务开始时的状态,即拷贝walIndexHdr到pWal->hdr。
- 清除pWal->hdr->mxFrame后的wal index的记录。
sqlite3WalSavepoint/sqlite3WalSavepointUndo
void sqlite3WalSavepoint(Wal *pWal, u32 *aWalData);
int sqlite3WalSavepointUndo(Wal *pWal, u32 *aWalData);
作用:保存/回滚日志到还原点
参数 | 含义 |
---|---|
Wal *pWal | Wal 实例 |
aWalData | 还原点数据,4个uint32值分别是pWal->hdr.mxFrame,pWal->hdr.aFrameCksum[0], pWal->hdr.aFrameCksum[1], pWal->nCkpt |
回滚时,还原当前pWal->hdr当前字段的对应四个字段,同时清除还原点以后的日志(frame > 保存的pWal->hdr.mxFrame)。
疑问:
假设在T1时刻,设置了还原点,之后修改了B页,导致B的Wal 日志进行了更新变成了B1,sqlite3WalSavepointUndo只能清除frame D、E,
并不能将B1 回滚到 B
sqlite3WalCheckpoint
int sqlite3WalCheckpoint(Wal *pWal, sqlite3 *db, int eMode, int (*xBusy)(void*), void *pBusyArg, int sync_flags, int nBuf, u8 *zBuf, int *pnLog, int *pnCkpt);
作用:wal frame转换成页数据写到数据文件中
约束:不能在写锁开启时调用,也不能在ckptLock被锁时调用
参数 | 含义 |
---|---|
Wal *pWal | Wal 实例 |
sqlite3 *db | |
int eMode | SQLITE_CHECKPOINT_PASSIVE:被动式的,只刷页 SQLITE_CHECKPOINT_FULL:等待写事务完成,再刷页 SQLITE_CHECKPOINT_RESTART:等待写事务完成,并重头开始写日志 SQLITE_CHECKPOINT_TRUNCATE:等待读写事务完成,并truncate文件,然后重头开始写日志 除被动式外,其他都需要加写锁 |
xBusy | 繁忙时调用的函数 |
void *pBusyArg | 传递给xBusy的参数 |
int sync_flags | sync参数 |
int nBuf | 缓存zBuf的大小 |
u8 *zBuf | checkpoint 要用到的缓存 |
int *pnLog | wal日志里frame的个数 |
int *pnCkpt | 回写到Db文件的frame个数 |
1.根据eMode判断是不是要加写锁
2.读取ckptInfo->aReadMark数组与pWal->hdr.mxFrame,取最小值,记mxSafeFrame,[nBackFilll, mxSafeFrame]就是这批要刷的frame
3.扫描日志,按页号升序刷页进db
4.如果eMode是SQLITE_CHECKPOINT_TRUNCATE,截断WAL文件
5.如果eMode是SQLITE_CHECKPOINT_RESTART或SQLITE_CHECKPOINT_TRUNCATE,需要重置WAL文件头。
Wal Index的初始化和重建
int walIndexRecover(Wal *pWal);
作用:初始化或重建wal index。
参数 | 含义 |
---|---|
Wal *pWal | Wal 实例 |
初始化
初始化Wal结构体的isInit、iVersion字段,然后计算WalIndexHdrT结构体的checkSum(存放于aCksum字段),同时拷贝到wal-index的两个WalIndexHdrT内存中。
walIndexRecover 是根据Wal文件的大小是否大于WAL_HDRSIZE来判断是初始化还是重建,小于则初始化。
重建
- 读取 Wal文件头,对文件头的校验和进行校验。
- 计算Wal Index页的个数, 逐页进行恢复。
根据Wal文件大小可以算出frame的个数,根据frame的个数再算出有多少个Wal index页 LastFrameIndex = (fileSize - WAL_HDRSIZE) / frameSize LastIndexPage = (LastFrameIndex + HASHTABLE_NPAGE-HASHTABLE_NPAGE_ONE-1) / HASHTABLE_NPAGE HASHTABLE_NPAGE:除第一个索引块外的其他索引块存放frame序号的个数,值为4096 HASHTABLE_NPAGE_ONE:第一个索引块存放的frame序号个数,值为 4062
- 计算Wal Index页存放的frame序号范围。
iFirstFrame = 第一个Index页为1,其他:HASHTABLE_NPAGE_ONE+(索引页序号-1) * HASHTABLE_NPAGE iLastFrame = 最后一个页为LastFrameIndex, 其他HASHTABLE_NPAGE_ONE+索引页序号 * HASHTABLE_NPAGE
- 遍历读取iFirstFrame到iLastFrame wal日志,解码后得到frame序号和page序号,插入到Wal Index 中。
- 将pWal中的hdr拷贝到索引块的两个WalIndexHdr内存中
- 重置CkptInfo结构体字段nBackFill、nBackfillAttempted、aReadMark数组
walIteratorInit/walIteratorNext/walIteratorFree
int walIteratorInit(Wal *pWal, u32 nBackfill, WalIterator **pp);
int walIteratorNext(WalIterator *p, u32 *piPage, u32 *piFrame);
void walIteratorFree(WalIterator *p);
作用:扫描frame nBackfill以后的的frame,按照页序号升序的方式读出页序号和frame序号
参数 | 含义 |
---|---|
Wal *pWal | Wal 实例 |
u32 nBackfill | 扫描frame nBackfill以后的的frame |
WalIterator *p | 迭代器 |
u32 *piPage | 出参,页号 |
u32 *piFrame | 出参,frame 号 |
WalIteraor 内存结构图
walIteratorInit时,申请iterator内存,并将各个段的页号,按照升序的方式排序,用p->aSegment[x].aPgno[p->aSegment[x].aIndex[i]]读出来是升序的
walIteratorNext 比较每个Segment 中aPgno第一个没被读出来过的页号,取最小值返回
sqlite3WalDbsize
Pgno sqlite3WalDbsize(Wal *pWal);
作用:返回数据库数据页个数,存储在pWal->hdr->nPage
参数 | 含义 |
---|---|
Wal *pWal | Wal 实例 |