首页 > 代码库 > SQLite剖析之异步IO模式、共享缓存模式和解锁通知
SQLite剖析之异步IO模式、共享缓存模式和解锁通知
1、异步I/O模式
通常,当SQLite写一个数据库文件时,会等待,直到写操作完成,然后控制返回到调用程序。相比于CPU操作,写文件系统是非常耗时的,这是一个性能瓶颈。异步I/O后端是SQLite的一个扩展模块,允许SQLite使用一个独立的后台线程来执行所有的写请求。虽然这并不会减少整个系统的资源消耗(CPU,磁盘带宽等),但它允许SQLite在正在写数据库时立刻返回到调用者,从用户角度看,无疑提高了前端的响应速度。对异步I/O,写请求在一个独立的后台线程中被处理,这意味着启动数据库写操作的线程不必等待磁盘I/O的发生。写操作看起来似乎很快就发生了,但实际上速度跟通常是一样的,只不过在后台进行。
异步I/O似乎提供了更好的响应能力,但这是有代价的。你会失去ACID中的持久性(Durable)属性。在SQLite的缺省I/O后端中,一旦写操作完成,你知道更改的数据已经安全地在磁盘上了。而异步I/O却不是这样的情况。如果应用程序在数据写操作之后,异步写线程完成之前发生崩溃或掉电,则数据库更改可能根本没有被写到磁盘,下一次使用数据库时就看不到更改。
异步I/O失去了持久性,但仍然保持ACID的其他三个属性:原子性(Atomic)、一致性(Consistent)和隔离性(Isolated)。很多应用程序没有持久性也能很好地工作。
我们通过创建一个SQLite VFS对象并且用sqlite3_vfs_register()注册它来使用异步I/O模式。当用这个VFS打开数据库文件并进行写操作时(使用vfs的xWrite()方法),数据不会立刻写到磁盘,而是放在由后台线程维护的写队列中。当用异步VFS打开数据库文件并进行读操作时(使用vfs的xRead()方法),数据从磁盘读出,而写队列从vfs读进程的角度看,其xWrite()已经完成了。异步I/O的虚拟文件系统(VFS)通过sqlite3async_initialize()来注册,通过sqlite3async_shutdown()来关闭。
为了积累经验,异步I/O的实现有意保持简单。更多的功能会在将来的版本中添加。例如,在当前的实现中,如果写操作正在一个稳定的流上发生,而这个流超过了后台写线程的I/O能力,则挂起的写操作队列将会无限地增长,可能会耗尽主机系统的内存。复杂一点的模块则可以跟踪挂起的写操作数量,在超过一定数目后停止接收新的写请求。
在单个进程中、使用异步IO的多个连接可以并发地访问单个数据库。从用户的角度看,如果所有连接都位于单个进程中,则正常SQLite和使用异步IO的SQLite,其并发性并没有什么不同。如果文件锁是激活的(缺省是激活的),来自多个进程的连接都要读和写数据库文件,则并发性在下面的情况下会减弱:
(1)当使用异步IO的连接启动一个数据库事务时,数据库会立刻被锁住。然而锁只有在写队列中的所有操作已经刷新到磁盘后才能释放。这意味着有时即使在一个"COMMIT"或"ROLLBACK"执行完后,数据库可能仍然处于锁住状态。
(2)如果应用程序使用异步IO连续地执行多个事务,其他数据库用户可能会因为数据库一直被锁住而不能使用数据库。这是因为当一个BEGIN执行后,数据库锁会立刻建立起来。但当对应的COMMIT或ROLLBACK发生时,锁不一定释放了,要到后台写队列全部刷新到磁盘后才能释放。如果后台写队列还没刷新完,数据库就一直处于锁住状态,其他进程不能访问数据库。
文件锁可以在运行时通过sqlite3async_control()函数禁用。对NFS这可以提高性能,因为可以避免对服务器的来回异步操作建立文件锁。但是如果多个连接尝试访问同一个数据库,而文件锁被禁用了,则应用程序崩溃和数据库损坏就可能发生。
异步IO扩展模块由单个源文件sqlite3async.c,和一个头文件sqlite3async.h组成,位于源码树的ext/async/子目录下。应用程序可以用其中定义的C API来激活和控制这个模块的功能。为了使用异步IO扩展,把sqlite3async.c编译成使用SQLite的应用程序的一部分,然后使用sqlite3async.h中定义的API来初始化和配置这个模块。这些API在sqlite3async.h的注释中有详细说明,使用这些API通常有以下步骤:
(1)调用sqlite3async_initialize()来给SQLite注册异步IO VFS(虚拟文件系统)。
(2)创建一个后台线程来执行写操作,并调用sqlite3async_run()。
(3)通过异步IO VFS,使用正常的SQLite API来读写数据库。
当前的异步IO扩展兼容win32系统和支持pthread接口的系统,包括Mac OS X, Linux和其他Unix变体。为了移植异步IO扩展到其他的平台,用户必须在新平台上实现互斥锁和条件变量原语。当前并没有外部可用接口来允许做这样的控制,但是修改sqlite3async.c中的代码以包含新平台的并发控制原语是相当容易的,更多细节可搜索sqlite3async.c中的注释串"PORTING FUNCTIONS"。然后实现下面这些函数的新版本:
static void async_mutex_enter(int eMutex);
static void async_mutex_leave(int eMutex);
static void async_cond_wait(int eCond, int eMutex);
static void async_cond_signal(int eCond);
static void async_sched_yield(void);
上面这些函数的功能在sqlite3async.c的注释中有详细描述。
2、共享缓存模式
从3.3.0版开始,SQLite包含一个特别的“共享缓存”模式(缺省情况下禁用),主要用在嵌入式服务器中。如果共享缓存模式激活,并且一个线程在同一个数据库上建立多个连接,则这些连接共享一个数据和模式缓存。这能够显著减少系统的内存和IO消耗。在3.5.0版中,共享缓存模式被修改以便同一缓存的共享可以跨越整个进程而不只是单个线程。在这个修改之前,在线程间传递数据连接是受限制的。从3.5.0版开始这个限制就消除了。
从另一个进程或线程的角度看,使用共享缓存的两个或多个数据库连接看起来就像是一个连接。锁协议用来在多个共享缓存或数据库用户之间进行仲裁。
图1 共享缓存模式
图1描述一个运行时配置的例子,有三个数据库连接。连接1是一个正常的SQLite数据库连接,连接2和3共享一个缓存。正常的锁协议用来在连接1和共享缓存之间串行化数据库访问。而连接2和连接3对共享缓存访问的串行化则有专门的内部协议。见下面的描述。
有三个级别的共享缓存加锁模型,事务级别的加锁,表级别的加锁和模式级别的加锁。
(1)事务级别的加锁
SQLite连接可能打开两种类型的事务,读事务和写事务。这不是显式完成的,一个事务隐式地含有一个读事务,直到它首次写一个数据库文件,这时成为一个写事务。在任何时候共享缓存上最多只能有一个连接打开一个写事务,这个写事务可以和任何数量的读事务共存。这与非共享缓存模式不同,非共享缓存模式下有读操作时不允许有写操作。
(2)表级别的加锁
当两个或更多的连接使用一个共享缓存,用锁来串行化每个表格的并发访问。表支持两种类型的锁,读锁和写锁。锁被授予连接,任何时候每个数据库连接上的每个表格可以有读锁、写锁或没有锁。一个表格上可以任何数量的读锁,但只能有一个写锁。读数据库表格时必须首先获得一个读锁。写表格时必须获得一个写锁。如果不能获取需要的锁,查询失败并返回SQLITE_LOCKED给调用者。表级别的锁在获取之后,要到当前事务(读或写)结束时才释放。
如果使用read_uncommitted pragma指令把事务隔离模式从串行(serialized,缺省模式,即查询数据时会加上共享琐,阻塞其他事务修改真实数据)改成允许脏读(read-uncommitted,即SELECT会读取其他事务修改而还没有提交的数据),则上面描述的行为会有稍许的变化。事务隔离模式还有另外两种,无法重复读read-comitted是同一个事务中两次执行同样的查询语句,若在第一次与第二次查询之间时间段,其他事务又刚好修改了其查询的数据且提交了,则两次读到的数据不一致。可以重复读read-repeatable是指同一个事务中两次执行同样的查询语句,得到的数据始终都是一致的。
/* Set the value of the read-uncommitted flag: ** ** True -> Set the connection to read-uncommitted mode. ** False -> Set the connection to serialized (the default) mode. */ PRAGMA read_uncommitted = <boolean>; /* Retrieve the current value of the read-uncommitted flag */ PRAGMA read_uncommitted;
允许脏读模式的数据库连接在读数据库表时不会获取读锁,如果这时另外一个数据库连接修改了正在被读的表数据,则可能导致查询结果不一致,因为允许脏读模式的读事务不会被打断。允许脏读模式不会影响写事务,它必须获取写锁,因此数据库写操作可以被阻塞。允许脏读模式也不会影响sqlite_master级别的锁。
(3)模式(sqlite_master)级别的加锁
sqlite_master表支持与其他数据库表相同的共享缓存读锁和写锁。还会使用下面的特殊规则:
* 在访问任何数据库表格或者获取任何其他的读锁和写锁之前,连接必须先获取一个sqlite_master表上的读锁。
* 在执行修改数据库模式的语句(例如CREATE TABLE或DROP TABLE)之前,连接必须先获取一个sqlite_master表上的写锁。
* 如果任何其他的连接持有关联数据库(包括缺省的主数据库)的sqlite_master表上的写锁,则连接不可以编译一个SQL语句。
在SQLite 3.3.0到3.4.2之间,数据库连接只能被调用sqlite3_open()创建它的线程使用,一个连接只能与同一线程中的其他连接共享缓存。从SQLite 3.5.0开始,这个限制消除了。在老版本的SQLite上,共享缓存模式不能使用在虚拟表上,从SQLite 3.6.17开始,这个限制消除了。
共享缓存模式在每个进程级别上激活。C接口int sqlite3_enable_shared_cache(int)用来全局地激活或禁用共享缓存模式。每次调用sqlite3_enable_shared_cache()影响后续的使用sqlite3_open(), sqlite3_open16()或sqlite3_open_v2()创建的数据库连接,已经存在的数据库连接则不受影响。每次sqlite3_enable_shared_cache()的调用覆盖进程上的前面各次调用。
使用sqlite3_open_v2()创建的单个数据库连接,通过在第三个参数上使用SQLITE_OPEN_SHAREDCACHE或SQLITE_OPEN_PRIVATECACHE标志,可能选择参与或不参与共享缓存模式。在该数据库连接上这些标志会覆盖全局的sqlite3_enable_shared_cache()设置。如果同时使用这两个标志,则行为是未定义的。
当使用URI文件名时,"cache"查询参数可以用来指定连接是否使用共享缓存模式。"cache=shared"激活共享缓存,"cache=private"禁用共享缓存。例如:
ATTACH ‘file:aux.db?cache=shared‘ AS aux;
从SQLite 3.7.13开始,倘若数据库使用URI文件名创建,共享缓存模式可以在内存数据库上使用。为了向后兼容,使用未修饰的":memory:"名称打开内存数据库时缺省是禁用共享缓存的。而在SQLite 3.7.13之前,无论使用的内存数据库名、当前系统的共享缓存设置、以及查询参数或标志是什么,内存数据库上共享缓存总是被禁用的。
在内存数据库上激活共享缓存,会允许同一进程上的两个或更多数据库连接访问同一段内存。当最后一个连接关闭时,内存数据库会自动删除,这段内存也会被重置。
3、解锁通知
当多个连接在共享缓存模式下访问同一个数据库时,单个表上的读和写锁(即共享和排他锁)用来确保并发执行的事务是隔离的。如果连接不能获取到需要的锁,sqlite3_step()调用返回SQLITE_LOCKED。如果不能获取到每个关联数据库的sqlite_master表上的读锁(虽然这种情况并不常见),sqlite3_prepare()或sqlite3_prepare_v2()调用也会返回SQLITE_LOCKED。
通过使用SQLite的sqlite3_unlock_notify()接口,我们可以让sqlite3_step()或sqlite3_prepare_v2()调用阻塞直到获得需要的锁,而不是立刻返回SQLITE_LOCKED。下面的例子展示解锁通知的使用。
/* 本例子使用pthreads API */ #include <pthread.h> /* ** 当注册一个解锁通知时,传递本结构实例的指针,以作为用户上下文中的实例 */ typedef struct UnlockNotification UnlockNotification; struct UnlockNotification { int fired; /* 在解锁事件发生后为True */ pthread_cond_t cond; /* 要等待的条件变量 */ pthread_mutex_t mutex; /* 保护本结构的互斥量 */ }; /* ** 解锁通知回调函数 */ static void unlock_notify_cb(void **apArg, int nArg){ int i; for(i=0; i<nArg; i++){ UnlockNotification *p = (UnlockNotification *)apArg[i]; pthread_mutex_lock(&p->mutex); /* 对临界区加锁 */ p->fired = 1; /* 触发解锁事件,本变量只能互斥访问 */ pthread_cond_signal(&p->cond); pthread_mutex_unlock(&p->mutex); } } /* ** 本函数假设SQLite API调用(sqlite3_prepare_v2()或sqlite3_step())返回SQLITE_LOCKED。 ** 参数为关联的数据库连接。 ** 本函数调用sqlite3_unlock_notify()注册一个解锁通知回调函数,然后阻塞直到 ** 回调函数执行完并返回SQLITE_OK。调用者应该重试失败的操作。 ** 或者,如果sqlite3_unlock_notify()指示阻塞将会导致系统死锁,则本函数立刻 ** 返回SQLITE_LOCKED。调用者不应该重试失败的操作,而是回滚当前事务 */ static int wait_for_unlock_notify(sqlite3 *db){ int rc; UnlockNotification un; /* 初始化UnlockNotification结构 */ un.fired = 0; pthread_mutex_init(&un.mutex, 0); pthread_cond_init(&un.cond, 0); /* 注册一个解锁通知回调函数 */ rc = sqlite3_unlock_notify(db, unlock_notify_cb, (void *)&un); assert( rc==SQLITE_LOCKED || rc==SQLITE_OK ); /* sqlite3_unlock_notify()调用总是返回SQLITE_LOCKED或SQLITE_OK。 ** 如果返回SQLITE_LOCKED,则系统死锁。本函数需要返回SQLITE_LOCKED给调用者以 ** 便当前事务能够回滚。否则阻塞直到解锁通知回调函数执行,然后返回SQLITE_OK */ if( rc==SQLITE_OK ){ pthread_mutex_lock(&un.mutex); if( !un.fired ){ /* 如果解锁事件没有发生,则阻塞 */ pthread_cond_wait(&un.cond, &un.mutex); } pthread_mutex_unlock(&un.mutex); } /* 销毁互斥量和条件变量 */ pthread_cond_destroy(&un.cond); pthread_mutex_destroy(&un.mutex); return rc; } /* ** 本函数是SQLite函数sqlite3_step()的包装,它的工作方式与sqlite3_step()相同。 ** 但如果没有获得共享缓存锁,则本函数阻塞以等待锁可用。 ** 如果本函数返回SQLITE_LOCKED,调用者应该回滚当前事务,之后再尝试。否则系统可能死锁了 */ int sqlite3_blocking_step(sqlite3_stmt *pStmt){ int rc; while( SQLITE_LOCKED==(rc = sqlite3_step(pStmt)) ){ rc = wait_for_unlock_notify(sqlite3_db_handle(pStmt)); if( rc!=SQLITE_OK ) break; sqlite3_reset(pStmt); } return rc; } /* ** 本函数是SQLite函数sqlite3_prepare_v2()的包装,它的工作方式与sqlite3_prepare_v2()相同。 ** 但如果没有获得共享缓存锁,则本函数阻塞以等待锁可用。 ** 如果本函数返回SQLITE_LOCKED,调用者应该回滚当前事务,之后再尝试。否则系统可能死锁了 */ int sqlite3_blocking_prepare_v2( sqlite3 *db, /* 数据库句柄 */ const char *zSql, /* UTF-8编码的SQL语句 */ int nSql, /* zSql的字节数 */ sqlite3_stmt **ppStmt, /* OUT: 指向预处理语句的指针 */ const char **pz /* OUT: 解析过的字符串尾部位置 */ ){ int rc; while( SQLITE_LOCKED==(rc = sqlite3_prepare_v2(db, zSql, nSql, ppStmt, pz)) ){ rc = wait_for_unlock_notify(db); if( rc!=SQLITE_OK ) break; } return rc; }
如果例子中的sqlite3_blocking_step()或sqlite3_blocking_prepare_v2()函数返回SQLITE_LOCKED,则表明阻塞将导致系统死锁。
只有在编译时定义预处理宏SQLITE_ENABLE_UNLOCK_NOTIFY,才能使用sqlite3_unlock_notify()接口。该接口被设计成用在这样的系统中:每个数据库连接分配单独的线程。如果在一个线程中运行多个数据库连接,则不能使用该接口。sqlite3_unlock_notify()接口一次只在一个线程上工作,因此上面的锁控制逻辑只能工作于一个线程的单个数据库连接上。
上面的例子中,在sqlite3_step()或sqlite3_prepare_v2()返回SQLITE_LOCKED后,sqlite3_unlock_notify()被调用以注册一个解锁通知回调函数。在数据库连接持有表级别的锁后,解锁通知函数被执行以防止sqlite3_step()或sqlite3_prepare_v2()随后完成事务并释放所有锁。例如,如果sqlite3_step()尝试读表格X,而其他某个连接Y正持有表格X的写锁,sqlite3_step()将返回SQLITE_LOCKED。如果随后调用sqlite3_unlock_notify(),解锁通知函数将在连接Y的事务结束后被调用。解锁通知函数正在等待的连接(这里的Y),被称为“阻塞式连接”。
如果sqlite3_step()尝试写一个数据库,但返回SQLITE_LOCKED,则可能有多个进程持有当前数据库表格的读锁。这时SQLite随意地选择其中的一个连接,当这个连接的事务完成时执行解锁通知函数。解锁通知函数从sqlite3_step()(或sqlite3_close())里执行,它关联有一个阻塞式进程。解锁通知函数里面可以调用任何的sqlite3_XXX()函数,可以向其他等待线程发信号,或者安排一些在以后要发生的行为。
sqlite3_blocking_step()函数使用的算法描述如下:
(1)在指定的SQL语句对象上调用sqlite3_step(),如果返回除SQLITE_LOCKED之外的值,则直接返回这个值给调用者。如果返回SQLITE_LOCKED则继续。
(2)调用sqlite3_unlock_notify()注册一个解锁通知回调函数。如果sqlite3_unlock_notify()返回SQLITE_LOCKED,说明系统死锁,返回这个值给调用者以便回滚。否则继续。
(3)阻塞,直到解锁通知函数被另外一个线程执行。
(4)在SQL语句对象上调用sqlite3_reset()。因为SQLITE_LOCKED错误可能只发生在第一次调用sqlite3_step()时(不可能有sqlite3_step()先返回SQLITE_ROW而下一次却返回SQLITE_LOCKED的情况)。这时SQL语句对象会被重置,从而不会影响查询结果。如果不调用sqlite3_reset(),下一次调用sqlite3_step()将返回SQLITE_MISUSE。
(5)转向步骤(1)。
sqlite3_blocking_prepare_v2()使用的算法也类似,只不过第4步(重置SQL语句对象)忽略。
对于“写饥饿”现象,SQLite能帮助应用程序避免出现写饥饿的情况。当在一个表上获取写锁的任何尝试失败后(因为有连接一直持有读锁),共享缓存上启动新事务的所有尝试都会失败,直到下面有一种情况变成True为止:
* 当前写事务完成,或者
* 共享缓存上打开的读事务数量减为0。
启动新的读事务失败会返回SQLITE_LOCKED给调用者。如果调用者然后调用sqlite3_unlock_notify()注册一个解锁通知函数,阻塞式连接当前在共享缓存上会有一个写事务。这就避免了写饥饿,因为没有新的读锁可以打开了。当所有存在的读锁完成时,写操作最终能有机会获得需要的写锁。
在wait_for_unlock_notify()调用sqlite3_unlock_notify()时,有可能阻塞式线程已经完成它的事务,这样在sqlite3_unlock_notify()返回前解锁通知函数会立刻被调用。解锁通知函数也有可能被另一个线程调用,正好发生在sqlite3_unlock_notify()调用之后,而在这个线程开始等待异步信号之前。这样的竞争条件怎么处理,取决于应用程序使用的线程和同步原语。本例子中使用pthread,这是现代UNIX风格的系统(包括Linux)提供的接口。
pthread提供pthread_cond_wait()函数,它允许调用者同时释放一个互斥量并开始等待一个异步信号。使用这个函数、一个"fired"标志和一个互斥量,竞争状态可以消除,如下:
当解锁通知函数被调用时,这可能发生在调用sqlite3_unlock_notify()的线程开始等待一个异步信号之前,它做下面的工作:
(1)获取互斥量。
(2)设置"fired"标志为true。
(3)向等待线程发信号。
(4)释放互斥量。
当wait_for_unlock_notify()线程开始等待解锁通知函数到达时,它:
(1)获取互斥量。
(2)检查"fired"标志是否设置。如果已设置,解锁通知函数已经被调用,直接释放互斥量,然后继续。
(3)如果没设置,原子性地释放互斥量,并开始等待异步信号。当信号到达时,继续。
通过这种方式,当wait_for_unlock_notify()开始阻塞时,解锁通知函数不管是已经被调用,还是正在被调用,都没有问题。
本文例子中的代码至少在以下两个方面可以改进:
* 能管理线程优先级。
* 能处理SQLITE_LOCKED的特殊情形,这可能发生在删除一个表或索引时。
虽然sqlite3_unlock_notify()只允许调用者指定单个的用户上下文指针,但一个解锁通知回调是传给这种上下文指针数组的。这是因为当一个阻塞式线程完成它的事务时,如果有多个解锁通知被注册用于调用同一个C函数,则上下文指针就要排列成一个数组。如果每个线程分配一个优先级,则高优先级的线程就会比低优先级的线程先得到信号通知,而不是以任意的顺序来通知线程。
如果执行一个"DROP TABLE"或"DROP INDEX"命令,而当前数据库连接上有一个或多个正在执行的SELECT语句,则会返回SQLITE_LOCKED。如果调用了sqlite3_unlock_notify(),指定的回调函数立刻会被调用。重新尝试"DROP TABLE"或"DROP INDEX"将返回另外一个SQLITE_LOCKED错误。在上面的sqlite3_blocking_step()实现中,这会导致死循环。
调用者可以使用扩展错误码来区别这种特殊的"DROP TABLE|INDEX"情形和其他情形。当它正常调用sqlite3_unlock_notify()时,扩展错误码是SQLITE_LOCKED_SHAREDCACHE。在"DROP TABLE|INDEX"情形中,是普通的SQLITE_LOCKED。另外一种解决方法是限制重试单个查询的次数(如100次)。虽然这会导致效率低一点,但我们这里讨论的情况并不是经常发生的。
SQLite剖析之异步IO模式、共享缓存模式和解锁通知