因为工作的原因使用 SQLite 作为本地数据库,然而 SQLite 和 C++ 配合使用有许许多多的坑,此文用于记录工作过程中使用 SQLite 的坑。
Last Insert rowId
SQLite 中有一个函数可以获得最最近 insert 的一行的 id。也就是说,当你 insert 新的自增的一行时,你可以用 sqlite3_last_insert_rowid()
这个函数拿到这一行的 ID 这是一个取巧的办法,实际上这个函数是记录在 sqlite 实例里面的一个变量。这个变量的读写当然是没问题的,但是它需要和上一次 step 串行执行并上锁。
假设你有如下代码:
sqlite3_step(stmt);
std::int64_t id = sqlite3_last_insert_rowid(db);
你必须为这两句话上锁,因为如果这两句语句中间有其他线程插入了数据,你从 sqlite3_last_insert_rowid
拿到的是其他线程的数据。
sqlite3_step(stmt);
// 其他线程也执行了 sqlite_step() 导致下面语句 rowid 不对
std::int64_t id = sqlite3_last_insert_rowid(db);
正确写法
{
std::lock_guard<std::mutex> guard(mt);
sqlite3_step(stmt);
std::int64_t id = sqlite3_last_insert_rowid(db);
}
附上 SQLite 获取 last_rowid 的源码:
/*
** Return the ROWID of the most recent insert
*/
sqlite_int64 sqlite3_last_insert_rowid(sqlite3 *db){
return db->lastRowid;
}
事务
事务可以说是 SQLite 里面多线程使用最坑的东西了。SQLite 不像其他 C/S 数据库一样一条链接保持一个事务。它就是纯函数调用。
乱序提交
SQLite 本身可以保证 step 是线程安全的,也就是多个线程同时 step 是没问题的。但是会有乱序提交的问题。比如一个线程开启事务进行提交,另一个线程也同时进行提交,这个线程回滚导致了其他线程的提交也回滚了。
所以事务提交要上锁,主动上锁的话,一来性能下去了不说,二来忘了上锁就 gg,这个地方很坑。
多实例
既然 SQLite 是一个实例一个事务,那么每一个事务我开一个 SQLite 实例是不是就可以了呢。答案是依然有坑。
一个 DEFERRED 的事务(SQLite 事务默认是 DEFFERED)如果执行了一句写的语句,就会锁住整个数据库,直到 COMMIT,在写事务开始到结束之前,其他实例执行任何语句都会返回 SQLITE_BUSY(数据库繁忙)。不止 step 哦,是 prepare 也会哦,是不是很惊喜。如果你这个事务很长,其他实例也用不了数据库了(跟上锁也没啥区别)。
峰回路转,我终于在 SQLite 的 API 里面找到了一个 busy_handler
,可以在 busy 的时候执行一个回调函数,让你去做一些操作,比如重试。刚好官网也实现了一个实现了一个重试的 busy handler,你只要设置好 busy_timeout
这个参数就可以了,这个默认的 handler 就会贴心地帮你重试。而它的做法就是过一会儿再帮你提交一遍,直到 timeout 为止,显而易见这种做法非常挫,这挫做法导致它比用户自己加锁更慢。
下面看一下 sqlite 的默认 busy handler 的源码:
/*
** This routine implements a busy callback that sleeps and tries
** again until a timeout value is reached. The timeout value is
** an integer number of milliseconds passed in as the first
** argument.
*/
static int sqliteDefaultBusyCallback(
void *ptr, /* Database connection */
int count /* Number of times table has been busy */
){
#if SQLITE_OS_WIN || (defined(HAVE_USLEEP) && HAVE_USLEEP)
static const u8 delays[] =
{ 1, 2, 5, 10, 15, 20, 25, 25, 25, 50, 50, 100 };
static const u8 totals[] =
{ 0, 1, 3, 8, 18, 33, 53, 78, 103, 128, 178, 228 };
# define NDELAY (sizeof(delays)/sizeof(delays[0]))
sqlite3 *db = (sqlite3 *)ptr;
int timeout = db->busyTimeout;
int delay, prior;
assert( count>=0 );
if( count < NDELAY ){
delay = delays[count];
prior = totals[count];
}else{
delay = delays[NDELAY-1];
prior = totals[NDELAY-1] + delay*(count-(NDELAY-1));
}
if( prior + delay > timeout ){
delay = timeout - prior;
if( delay<=0 ) return 0;
}
sqlite3OsSleep(db->pVfs, delay*1000);
return 1;
#else
sqlite3 *db = (sqlite3 *)ptr;
int timeout = ((sqlite3 *)ptr)->busyTimeout;
if( (count+1)*1000 > timeout ){
return 0;
}
sqlite3OsSleep(db->pVfs, 1000000);
return 1;
#endif
}
这是一段退避重试的代码。可知重试时间是毫秒级的。这个延时速度真的还不如自己上锁呢……
正确写法
所以这里建议在 C++ 里面使用事务,还是使用单实例 + 使用的时候上锁来解决。另外要保证只有一个线程使用了事务,而且这个线程使用事务的时候,其他线程不能提交。
总结
SQLite 是一个很古老的数据库 lib。在业界也被广为使用。但是它的一些 api 设计真的有点坑。鉴于 SQLite 古老的历史,也许这些 api 不易修改得对多线程友好。所以在使用的时候还是要多多注意。写到这里,我想到了 sqlite 官方 FAQ 的一句话:https://www.sqlite.org/faq.html。
Threads are evil. Avoid them.
同时还贴出了一篇论文:https://www2.eecs.berkeley.edu/Pubs/TechRpts/2006/EECS-2006-1.pdf
或许 SQLite 的作者真的不喜欢多线程,才设计成这个样子吧。