一、操作系统API(以Windows为例)
Windows可以对文件中的部分内容加共享锁或排它锁,并且加锁区域可以在文件长度之外(超过文件尾的不实际存在的地方)。相关API函数为:LockFile()、LockFileEx()和UnlockFile(),函数的详细介绍可参考MSDN。
SQLite3.7版本对文件加锁使用了LockFile()、LockFileEx()。因为Win95, Win98, and WinME没有LockFileEx(),使用LockFile()。之后的WinNT/2K/XP则使用LockFileEx()。
说明:
LockFile():加锁区域不能重叠;
LockFileEx():可以对文件加共享锁和排它锁,共享锁可以与共享锁重叠,但排它锁不能与任何锁重叠;
二、锁页(加锁区域)
在SQLite的页(page)类型中有一种页为锁页(lock-byte page)。在SQLite数据文件中lock-byte page是一个单独的页,其位于数据库文件的1073741824 and 1073742335,即1G的位置,占512个字节;小于1G的数据库文件则没有此页。
锁页是操作系统的加锁区域,对其进行加锁,以实现文件的并发访问;SQLite本身并不使用此页,不读也不写。
SQLite源码os.h中对加锁区域有如下定义:
#define PENDING_BYTE sqlite3PendingByte
#define RESERVED_BYTE (PENDING_BYTE+1)
#define SHARED_FIRST (PENDING_BYTE+2)
#define SHARED_SIZE 510
SQLite在global.c中定义了全局变量sqlite3PendingByte:
int sqlite3PendingByte = 0x40000000;所以加锁区域是从数据库文件1G位置开始的,不管实际存不存在。
1、加锁区域PENDING_BYTE为何设置为0X4000 0000(1GB)?
前面说过,SQLite本身并不对加锁区域进行读写,这512字节也不存储实现的数据,如果数据库文件实际数据大于1G,则浪费了512字节;而由于操作系统又可以对不存在的遥远区域加锁,所以把加锁区域定在比较大的1G文件处(大部分数据库文件都小于1G),这样既不影响数据的存储,也避免了512字节的空间损失。
2、PENDING和RESERVED区域都为一个字节,为何SHARED为510字节?
我个人觉得主要是由于对于Windows来说,Win95, Win98, and WinME没有LockFileEx(),而LockFile()加锁区域不能重叠;在这种情况下,为了使两个读进程可以同时访问文件,对于SHARED LOCK则选择一个SHARED_FIRST—SHARED_FIRST+ SHARED_SIZE范围内的随机数进行加锁,以至于多个进程可以同时读数据库文件,但有可能两个进程取得一样的lock byte,所以此种情况对于Windows, SQLite的并发性就受到限制。但WinNT/2K/XP可以使用LockFileEx()对文件重叠加共享锁,因此在这种情况下,SQLite并发读并没有受到限制。
三、锁类型
在SQLite中为了写数据库,连接需要逐步地获得排它锁。SQLite有5个不同的锁:未加锁(NO_LOCK)、共享锁(SHARED_LOCK)、保留锁(RESERVED_LOCK)、未决锁(PENDING_LOCK)和排它锁(EXCLUSIVE_LOCK)。
SQLite在os.h中对锁类型有如下定义:
#define NO_LOCK 0
#define SHARED_LOCK 1
#define RESERVED_LOCK 2
#define PENDING_LOCK 3
#define EXCLUSIVE_LOCK 4
随着锁级别的升高,其类型值也变大。
SQLite为4种锁定义了上述的3个区域,SQLite通过对数据库文件的这3个区域加锁来实现其锁机制。
SHARED锁:SHARED锁意味着进程要读(不写)数据库。一个数据库上可以同时有多个进程获得SHARED锁,哪个进程能够在SHARED_FIRST区域加共享锁(使用LockFileEx()LockFileEx()函数),即获得了SHARED锁。
RESERVED锁: RESERVED锁意味着进程将要对数据库进行写操作。一个数据库上同时只能有一个进程拥有RESERVED锁,所以哪个进程能够在RESERVED_BYTE区域上加排它锁(使用LockFile()或LockFileEx()函数),即获得了RESERVED锁。RESERVED锁可以与SHARED锁共存,并可以继续对数据库加新的SHARED锁。
为什么要用RESERVED锁?
主要是出于并发性的考虑。由于SQLite只有库级排斥锁(EXCLUSIVE LOCK),如果写事务一开始就上EXCLUSIVE锁,然后再进行实际的数据更新,写磁盘操作,这会使得并发性大大降低。而SQLite一旦得到数据库的RESERVED锁,就可以对缓存中的数据进行修改,而与此同时,其它进程可以继续进行读操作。直到真正需要写磁盘时才对数据库加EXCLUSIVE 锁。
PENDING锁:PENDING LOCK意味着进程已经完成缓存中的数据修改,并想立即将更新写入磁盘。它将等待此时已经存在的读锁事务完成,但是不允许对数据库加新的SHARED LOCK(这与RESERVED LOCK相区别)。
一个数据库上同时也只能有一个进程拥有PENDING锁,所以哪个进程能够在PENDING_BYTE区域上加排它锁(使用LockFile()或LockFileEx()函数),即获得了PENDING锁。
外部用户并不能调用相应的加锁函数加此种锁,PENDING LOCK只是SQLite从RESERVED到EXCLUSIVE状态的一个中间锁,用来防止写饿死情况。
为什么要有PENDING LOCK?
主要是为了防止出现写饿死的情况。由于写事务先要获取RESERVED LOCK,所以可能一直产生新的SHARED LOCK,使得写事务发生饿死的情况。
EXCLUSIVE锁:虽然同时只能有一个进程拥有EXCLUSIVE锁,但由于在该进程拥有EXCLUSIVE锁时不允许其他进程拥有SHARED锁,因此EXCLUSIVE锁与SHARED锁使用相同的文件区域。哪个进程能够在SHARED_FIRST区域上加排它锁(使用LockFile()或LockFileEx()函数,这样操作系统就保证了不会有其他SHARED锁),即获得了PENDING锁。
四、SQLite锁状态转换
SQLite采用粗放型的锁。当一个连接要写数据库,所有其他的连接被锁住,直到写连接结束了它的事务。SQLite有一个加锁表,来帮助不同的写数据库者能够在最后一刻再加锁,以保证最大的并发性。
SQLite使用锁逐步上升机制,为了写数据库,连接需要逐步地获得排它锁。对于5个不同的锁状态:未加锁(UNLOCKED)、共享(SHARED)、保留(RESERVED)、未决(PENDING)和排它(EXCLUSIVE)。每个数据库连接在同一时刻只能处于其中一个状态。每种状态(未加锁状态除外)都有一种锁与之对应。锁的状态以及状态的转换如下图所示:
SQLite锁的状态以及状态的转换
最初的状态是未加锁状态,在此状态下,连接还没有存取数据库。当连接到了一个数据库,甚至已经用BEGIN开始了一个事务时,连接都还处于未加锁状态。
未加锁状态的下一个状态是共享状态。为了能够从数据库中读(不写)数据,连接必须首先进入共享状态,也就是首先要获得一个共享锁。多个连接可以同时获得并保持共享锁,也就是说多个连接可以同时从同一个数据库中读数据。但即使仅有一个共享锁没有释放,也不允许任何连接写数据库。
如果一个连接想要写数据库,它必须首先获得一个保留锁。一个数据库上同时只能有一个保留锁。保留锁可以与共享锁共存,保留锁是写数据库的第1阶段。保留锁即不阻止其它拥有共享锁的连接继续读数据库,也不阻止其它连接获得新的共享锁。
一旦一个连接获得了保留锁,它就可以开始处理数据库修改操作了,尽管这些修改只能在缓冲区中进行,而不是实际地写到磁盘。对读出内容所做的修改保存在内存缓冲区中。
当连接想要提交修改(或事务)时,需要将保留锁提升为排它锁。为了得到排它锁,还必须首先将保留锁提升为未决锁。获得未决锁之后,其它连接就不能再获得新的共享锁了,但已经拥有共享锁的连接仍然可以继续正常读数据库。此时,拥有未决锁的连接等待其它拥有共享锁的连接完成工作并释放其共享锁。
一旦所有其它共享锁都被释放,拥有未决锁的连接就可以将其锁提升至排它锁,此时就可以自由地对数据库进行修改了。所有以前对缓冲区所做的修改都会被写到数据库文件。
五、锁机制实现
实现程序位于os_win.c中:
/*
为文件加锁。
加锁的类型locktype,为以下之一:
(1) SHARED_LOCK
(2) RESERVED_LOCK
(3) EXCLUSIVE_LOCK
加锁类型不能为PENDING_LOCK,PENDING_LOCK为内部自动过渡的一种锁,外部用户不应显示的加此种锁。代码也做了判断:assert( locktype!=PENDING_LOCK );
*/
static int winLock(sqlite3_file *id, int locktype){
int rc = SQLITE_OK; /* 返回值 */
int res = 1; /* Windows锁函数操作的返回值*/
int newLocktype; /*在退出前将pFile->locktype设为此值*/
int gotPendingLock = 0;/* 标识此次是否申请了一个PENDING lock this time */
winFile *pFile = (winFile*)id;
DWORD lastErrno = NO_ERROR;
assert( id!=0 );
OSTRACE(("LOCK %d %d was %d(%d)\n",
pFile->h, locktype, pFile->locktype, pFile->sharedLockByte));
/* 如果申请的锁级别没有当前文件所拥有的高,则直接返回*/
if( pFile->locktype>=locktype ){
return SQLITE_OK;
}
/* 以下三行代码判断当前所申请的锁类型是否合法 */
/*
1、如果当前数据库的锁类型为NO_LOCK,则所申请的锁类型要为SHARED_LOCK
2、所申请的锁类型不能为PENDING_LOCK,因为其为一种过渡锁
3、如果所申请的锁类型为RESERVED _LOCK,则当前数据库的锁类型要为SHARED_LOCK
*/
assert( pFile->locktype!=NO_LOCK || locktype==SHARED_LOCK );
assert( locktype!=PENDING_LOCK );
assert( locktype!=RESERVED_LOCK || pFile->locktype==SHARED_LOCK );
newLocktype = pFile->locktype;
/*
如果我们申请 PENDING_LOCK 或 SHARED_LOCK,则需要对加锁区域 PENDING_BYTE 进行加锁 ;如果申请的是 SHARED_LOCK ,则锁 PENDING_BYTE 区域只是暂时的。
1、如果当前数据库处于无锁状态,则首先要获取共享锁,这也是读事务和写事务在最初阶段都要经历的阶段
2、如果前数据库处于保留锁状态,而申请排它锁进行写库,则要先获取未决锁。
此种情况是为了阻止其它进程对此库继续申请共享锁,以防止写饿死。
*/
if( (pFile->locktype==NO_LOCK)
|| ( (locktype==EXCLUSIVE_LOCK)
&& (pFile->locktype==RESERVED_LOCK))
){
int cnt = 3;
/* 获取未决锁 */
while( cnt-->0 && (res = winLockFile(&pFile->h, SQLITE_LOCKFILE_FLAGS,
PENDING_BYTE, 0, 1, 0))==0 ){
/* Try 3 times to get the pending lock. This is needed to work
** around problems caused by indexing and/or anti-virus software on
** Windows systems.
** If you are using this code as a model for alternative VFSes, do not
** copy this retry logic. It is a hack intended for Windows only.
*/
OSTRACE(("could not get a PENDING lock. cnt=%d\n", cnt));
if( cnt ) sqlite3_win32_sleep(1);
}
/* 设置gotPendingLock为1,后面的程序根据此值可能会释放PENDING锁*/
gotPendingLock = res;
if( !res ){
lastErrno = osGetLastError();
}
}
/*
获取 SHARED_LOCK
此时,事务应该持有 PENDING_LOCK(gotPendingLock == 1)。PENDING_LOCK 作为事务从 NO_LOCK 到 SHARED_LOCK 的一个过渡,实际上此时锁处于两个状态:PENDING和SHARED,直到后面释放 PENDING_LOCK 后,才真正处于SHARED状态。
*/
if( locktype==SHARED_LOCK && res ){
assert( pFile->locktype==NO_LOCK );
res = getReadLock(pFile);
if( res ){
newLocktype = SHARED_LOCK;
}else{
lastErrno = osGetLastError();
}
}
/*
获取 RESERVED_LOCK
此时事务应持有 SHARED_LOCK,变化过程为SHARED->RESERVED。
RESERVED锁的作用就是为了提高系统的并发性能。
*/
if( locktype==RESERVED_LOCK && res ){
assert( pFile->locktype==SHARED_LOCK );
res = winLockFile(&pFile->h, SQLITE_LOCKFILE_FLAGS, RESERVED_BYTE, 0, 1, 0);
if( res ){
newLocktype = RESERVED_LOCK;
}else{
lastErrno = osGetLastError();
}
}
/*
获取 PENDING_LOCK
此时事务持有 RESERVED_LOCK,且事务申请 EXCLUSIVE_LOCK。
变化过程为:RESERVED->PENDING。
PENDING状态唯一的作用就是防止写饿死。
读事务不会执行此段代码,但写事务会执行该代码。
执行该代码后gotPendingLock设为0,后面就不会释放 RESERVED_LOCK 了。
*/
if( locktype==EXCLUSIVE_LOCK && res ){
/*
这里没有实际的加锁操作,因为PENDING锁前面已经加过了,
只是把锁的状态改为PENDING状态
*/
newLocktype = PENDING_LOCK;
/*
设置gotPendingLock,后面就不会释放PENDING锁了,
相当于加了PENDING锁,实际上是在开始处加的PENDING锁
*/
gotPendingLock = 0;
}
/*
获取EXCLUSIVE_LOCK
此时事务应该持有 PENDING_LOCK,事务准备写库。
变化过程:PENDING->EXCLUSIVE
*/
if( locktype==EXCLUSIVE_LOCK && res ){
assert( pFile->locktype>=SHARED_LOCK );
/* 先解除该进程对数据库加的共享锁 */
res = unlockReadLock(pFile);
OSTRACE(("unreadlock = %d\n", res));
/*
对SHARED_FIRST区域加排它锁,防止其它进程读库。
1、如果加锁成功,则说明没有其它进程在读库,因此可进行写库操作
2、如果加锁失败,则说明仍有其它进程正在进行读操作,此时则无法获取EXCLUSIVE_LOCK,因此也无法写库,事务仍持有PENDING_LOCK
*/
res = winLockFile(&pFile->h, SQLITE_LOCKFILE_FLAGS, SHARED_FIRST, 0,
SHARED_SIZE, 0);
if( res ){
newLocktype = EXCLUSIVE_LOCK;
}else{
lastErrno = osGetLastError();
OSTRACE(("error-code = %d\n", lastErrno));
/* 获取排它锁失败,则继续对文件加共享锁 */
getReadLock(pFile);
}
}
/*
如果申请的是 SHARED_LOCK,此时需要释放在前面获得的PENDING_LOCK。
锁的变化为:PENDING->SHARED
*/
if( gotPendingLock && locktype==SHARED_LOCK ){
winUnlockFile(&pFile->h, PENDING_BYTE, 0, 1, 0);
}
/* 改变文件的锁状态,返回适当的结果码 */
if( res ){
rc = SQLITE_OK;
}else{
OSTRACE(("LOCK FAILED %d trying for %d but got %d\n", pFile->h,
locktype, newLocktype));
pFile->lastErrno = lastErrno;
rc = SQLITE_BUSY;
}
pFile->locktype = (u8)newLocktype;
return rc;
}
共享锁获取实现代码如下:
static int getReadLock(winFile *pFile){
int res;
/*判断操作系统类型*/
if( isNT() ){
/*为WinNT/2K/XP系统,则直接使用LockFileEx对SHARED_SIZE进行加共享锁*/
#if SQLITE_OS_WINCE
/*
** NOTE: Windows CE is handled differently here due its lack of the Win32
** API LockFileEx.
*/
res = winceLockFile(&pFile->h, SHARED_FIRST, 0, 1, 0);
#else
res = winLockFile(&pFile->h, SQLITE_LOCKFILEEX_FLAGS, SHARED_FIRST, 0,
SHARED_SIZE, 0);
#endif
}
#ifdef SQLITE_WIN32_HAS_ANSI
else{
/*
为Win95, Win98, and WinME系统,则使用LockFile对SHARED_SIZE中的随机字节进行加锁(此锁不能重叠)
*/
int lk;
sqlite3_randomness(sizeof(lk), &lk);
pFile->sharedLockByte = (short)((lk & 0x7fffffff)%(SHARED_SIZE - 1));
res = winLockFile(&pFile->h, SQLITE_LOCKFILE_FLAGS,
SHARED_FIRST+pFile->sharedLockByte, 0, 1, 0);
}
#endif
if( res == 0 ){
pFile->lastErrno = osGetLastError();
/* No need to log a failure to lock */
}
return res;
}
static BOOL winLockFile(
LPHANDLE phFile,
DWORD flags,
DWORD offsetLow,
DWORD offsetHigh,
DWORD numBytesLow,
DWORD numBytesHigh
){
#if SQLITE_OS_WINCE
return winceLockFile(phFile, offsetLow, offsetHigh, numBytesLow, numBytesHigh);
#else
if( isNT() ){
OVERLAPPED ovlp;
memset(&ovlp, 0, sizeof(OVERLAPPED));
ovlp.Offset = offsetLow;
ovlp.OffsetHigh = offsetHigh;
return osLockFileEx(*phFile, flags, 0, numBytesLow, numBytesHigh, &ovlp);
}else{
return osLockFile(*phFile, offsetLow, offsetHigh, numBytesLow,
numBytesHigh);
}
#endif
}
参考:
1、SQLite3源代码,源码版本 #define SQLITE_VERSION "3.7.14.1"
2、SQLite3源程序分析 作者:空转
六、SQLite事务并发访问及死锁问题
再看一下这张图:
从图中可以看每个事务从开始到结束其锁状的变化。
1、对于一个读事务会经过以下过程:
Ø UNLOCKED到PENDING;获取PENDING锁只是暂时的,获取PENDING锁是获取SHARED锁的第一步,因为若有其它事务已获取PENDING锁,则此事务不能再获取SHARED锁了。
Ø 如果获取PENDING锁成功,则此事务可以继续获取SHARED锁,并将之间获取的PENDING释放。
2、对于一个写事务会以下过程
Ø 第一步和读事务一样,获取SHARED锁。
Ø 获取RESERVED锁,一旦事务要进行写操作,首先就要获取此锁。
Ø 获取EXCLUSIVE锁,实际上此时要先获取PENDING锁,以阻止其它事务继续获取SHARED锁(因为前面说过获取PENDING锁是获取SHARED锁的第一步),进而防止写饿死。
Ø 获取PENDING锁后,才真去获取EXCLUSIVE锁;如果获取EXCLUSIVE锁成功,则事务就可以进行写磁盘操作了。
对于上述两种事务锁状态变化情况可参考前节的锁机制实现代码分析部分。
对于一个SQLite的写事务来说,理想情况下是各种锁正常获取,直到事务提交;但实际并不完全这样,SQLite也可能出现死锁。
比如:
情况1:以默认方式(DEFERRED)开始事务:
事务 | 操作(锁状态) | 说明 |
事务1 | BEGIN …(UNLOCKED) | 事务1开始时处于无锁状态 |
事务1 | SELECT ...(SHARED) | 读库,获取SHARED锁 |
事务1 | INSERT ...(RESERVED) | 准备写库,获取RESERVED锁 |
事务2 | BEGIN ..(UNLOCKED) | 事务2开始时也处于无锁状态 |
事务2 | SELECT ...(SHARED) | 事务1此时仍为RESERVED锁状态,所以事务2依然可以获取SHARED锁 |
事务1 | COMMIT(PENDING) | 事务1 要提交,尝试获取EXCLUSIVE锁,先获取了PENDING锁;在去获取EXCLUSIVE锁时,发现还有SHARED锁未释放,则获取失败,返回SQLITE_BUSY |
事务2 | INSERT ... | 准备写库,尝试获取RESERVED锁,但事务1已有RESERVED锁未释放,则获取失败,返回SQLITE_BUSY |
此种情况则发生的死锁:
事务1因事务2获取的SHARED锁未释放而获取EXCLUSIVE锁失败(前面说过SHARED锁和EXCLUSIVE锁共用同一加锁区域);
事务2因事务1已获取了RESERVED锁未释放而获取RESERVED锁失败;
为了避免上述死锁的产生,则要为应用程序选择合适的事务类型。SQLite有三种不同的事务类型(在BEGIN命令中指定:BEGIN [ DEFERRED | IMMEDIATE | EXCLUSIVE ] TRANSACTION)。
BEGIN [ DEFERRED] TRANSACTION:默认情况下是这样的。事务开始时并不获取任何锁,直到它需要锁的时候才加锁,而且BEGIN语句本身也不会做什么事情,—它开始处于UNLOCK状态;如果仅仅用BEGIN开始一个事务,那么事务就是DEFERRED的,同时它不会获取任何锁,当对数据库进行第一次读操作时,它会获取SHARED LOCK;同样,当进行第一次写操作时,它会获取RESERVED LOCK。
BEGIN [IMMEDIATE] TRANSACTION:事务开始时会试着获取RESERVED LOCK,如果成功,则事务处于RESERVED锁状态,以保证后续没有别的连接可以写数据库,但是,别的连接仍可以对数据库进行读操作; RESERVED LOCK会阻止其它的连接以BEGIN IMMEDIATE或者BEGIN EXCLUSIVE命令开始事务,SQLite会返回SQLITE_BUSY错误;但可以以BEGIN DEFERRED命令开始事务。事务开始成功后,就可以对数据库进行修改操作;当COMMIT时,如果返回SQLITE_BUSY错误,这意味着还有其它的读事务没有完成,得等它们执行完后才能提交事务。
BEGIN [EXCLUSIVE] TRANSACTION:事务开始时会试着获取EXCLUSIVE LOCK。这与IMMEDIATE类似,但是一旦成功,EXCLUSIVE事务保证没有其它的连接(其它事务读库都不行,因为获取不到SHARED LOCK),所以此事务就可对数据库进行读写操作了。
情况2:以IMMEDIATE方式开始事务
事务 | 操作(锁状态) | 说明 |
事务1 | BEGIN IMMEDIATE(RESERVED) | 事务1开始时就获取RESERVED锁 |
事务1 | SELECT ... (RESERVED) | 读库,已获取RESERVED锁 |
事务1 | INSERT ... (RESERVED) | |
事务2 | BEGIN IMMEDIATE … | 事务2开始时也尝试获取RESERVED锁,但事务1已获取RESERVED锁未释放,因此事务2开始失败,返回SQLITE_BUSY,等待用户重试 |
事务1 | COMMIT (EXCLUSIVE) | 事务1 要提交,成功获取EXCLUSIVE锁,写库完成后释放锁 |
事务2 | BEGIN IMMEDIATE(RESERVED) | 事务1已完成,事务2开始成功 |
事务2 | SELECT ... (RESERVED) |
|
事务2 | INSERT ... (RESERVED) |
|
事务2 | COMMIT (EXCLUSIVE) | 写入完成后释放 |
因此,这样就成功避免了死锁的产生。
情况3:以EXCLUSIVE的方式开始事务,即使其他连接以DEFERRED方式开启也不会死锁
事务 | 操作(锁状态) | 说明 |
事务1 | BEGIN EXCLUSIVE(EXCLUSIVE) | 事务1开始时就获取EXCLUSIVE锁 其它事务则不能获取SHARED锁,因此也不能读库 |
事务1 | SELECT ... (EXCLUSIVE) | |
事务1 | INSERT ... (EXCLUSIVE) | |
事务2 | BEGIN (UNLOCKED) | |
事务2 | SELECT ... | 尝试获取SHARED锁,但事务1已获取EXCLUSIVE锁未释放,则返回SQLITE_BUSY,等待用户重试 |
事务1 | COMMIT (EXCLUSIVE) | 写库完成后释放锁 |
事务2 | SELECT ... (SHARED) |
|
事务2 | INSERT ... (RESERVED) |
|
事务2 | COMMIT (EXCLUSIVE) | 写入完成后释放 |
以EXCLUSIVE的方式开始事务要求更为苛刻,直接获取EXCLUSIVE锁的难度比较大;为了避免EXCLUSIVE状态长期阻塞其他请求,最好的方式还是让所有写事务都以IMMEDIATE方式开始。