iOS sqlite3数据库解析

多个应用程序或者同一个应用程序的多个例程能同时存取同一个数据库文件吗?

多进程可以同时打开同一个数据库,也可以同时 SELECT 。但只有一个进程可以立即改数据库。

SQLite使用读/写锁定来控制数据库访问。(Win95/98/ME 操作系统缺乏读/写锁定支持,在低于 2.7.0 的版本中,这意味着在 windows 下在同一时间内只能有一个进程读数据库。在版本 2.7.0 中 这个问题通过在 windows 接口代码中执行一个用户间隔几率读写锁定策略解决了。) 但如果数据库文件在一个 NFS 文件系统中,控制并发读书的锁定机制可以会出错。因为 NFS 的fcntl() 文件锁定有时会出问题。如果有多进程可能并发读数据库则因当避免把数据库文件放在 NFS 文件系统中。 根据微软的文档,如果不运行 Share.exe 后台程序则 FAT 文件系统中的锁定可能不工作。对 Windows 非常有经验的人告诉我网络文件的锁定有许多问题并且不可靠。如果是这样,在2个或以上 Windows 系统中共享一个 SQLite 数据库文件会导致不可预知的问题。

我们知道没有其他的嵌入式 SQL数据库引擎比SQLite支持更多的并发性。 SQLite允许多进程 同时打开和读取数据库。任何一个进程需要写入时,整个数据库将在这一过程中被锁定。但这一般仅耗时 几毫秒。其他进程只需等待然后继续其他事务。其他嵌入式SQL数据库引擎往往只允许单进程访问数据库。

但是,client/server型的数据库引擎 (如 PostgreSQL, MySQL, 以及 Oracle) 通常支持更高的并发度, 并支持多进程同时写入同一个数据库。由于总有一个控制良好的服务器协调数据库的访问,这才保证了以上 特性的实现。如果你的应用需要很高的并发度,你应该考虑使用client/server数据库。事实上,经验告诉 我们大多数应用所需要的并发度比他们的设计者们想象的要少得多。

当 SQLite 尝试操作一个被另一个进程锁定的文件时,缺省的行为是返回 SQLITE_BUSY。你可以用 C代码更改这一行为。 使用 sqlite3_busy_handler() 或sqlite3_busy_timeout() API函数。

如果两个或更多进程同时打开同一个数据库,其中一个进程创建了新的表或索引,则其它进程可能不能立即看见新的表。其它进程可能需要关闭并重新连结数据库。

这几天研究了一下SQLite这个嵌入式数据库在多线程环境下的应用,感觉里面的学问还挺多,于是就在此分享一下。

先说下初衷吧,实际上我经常看到有人抱怨SQLite不支持多线程。而在iOS开发时,为了不阻塞主线程,数据库访问必须移到子线程中。为了解决这个矛盾,很有必要对此一探究竟。

关于这个问题,最权威的解答当然是SQLite官网上的“Is SQLite threadsafe?”这个问答。
简单来说,从3.3.1版本开始,它就是线程安全的了。而iOS的SQLite版本没有低于这个版本的:

3.4.0 - iPhone OS 2.2.1
3.6.12 - iPhone OS 3.0 / 3.1
3.6.22 - iPhone OS 4.0
3.6.23.2 - iPhone OS 4.1 / 4.2
3.7.2 - iPhone OS 4.3
3.7.7 - iPhone OS 5.0

当然,你也可以自己编译最新版本。

不过这个线程安全仍然是有限制的,在这篇《Is SQLite thread-safe?》里有详细的解释。
另一篇重要的文档就是《SQLite And Multiple Threads》。它指出SQLite支持3种线程模式:

  1. 单线程:禁用所有的mutex锁,并发使用时会出错。当SQLite编译时加了SQLITE_THREADSAFE=0参数,或者在初始化SQLite前调用sqlite3_config(SQLITE_CONFIG_SINGLETHREAD)时启用。
  2. 多线程:只要一个数据库连接不被多个线程同时使用就是安全的。源码中是启用bCoreMutex,禁用bFullMutex。实际上就是禁用数据库连接和prepared statement(准备好的语句)上的锁,因此不能在多个线程中并发使用同一个数据库连接或prepared statement。当SQLite编译时加了SQLITE_THREADSAFE=2参数时默认启用。若SQLITE_THREADSAFE不为0,可以在初始化SQLite前,调用sqlite3_config(SQLITE_CONFIG_MULTITHREAD)启用;或者在创建数据库连接时,设置SQLITE_OPEN_NOMUTEX flag。
  3. 串行:启用所有的锁,包括bCoreMutex和bFullMutex。因为数据库连接和prepared statement都已加锁,所以多线程使用这些对象时没法并发,也就变成串行了。当SQLite编译时加了SQLITE_THREADSAFE=1参数时默认启用。若SQLITE_THREADSAFE不为0,可以在初始化SQLite前,调用sqlite3_config(SQLITE_CONFIG_SERIALIZED)启用;或者在创建数据库连接时,设置SQLITE_OPEN_FULLMUTEX flag。

而这里所说的初始化是指调用sqlite3_initialize()函数,这个函数在调用sqlite3_open()时会自动调用,且只有第一次调用是有效的。
另一个要说明的是prepared statement,它是由数据库连接(的pager)来管理的,使用它也可看成使用这个数据库连接。因此在多线程模式下,并发对同一个数据库连接调用sqlite3_prepare_v2()来创建prepared statement,或者对同一个数据库连接的任何prepared statement并发调用sqlite3_bind_*()和sqlite3_step()等函数都会出错(在iOS上,该线程会出现EXC_BAD_ACCESS而中止)。这种错误无关读写,就是只读也会出错。文档中给出的安全使用规则是:没有事务正在等待执行,所有prepared statement都被finalized
顺带一提,调用sqlite3_threadsafe()可以获得编译期的SQLITE_THREADSAFE参数。标准发行版是1,也就是串行模式;而iOS上是2,也就是多线程模式;Python的sqlite3模块也默认使用串行模式,可以用sqlite3.threadsafety来配置。但是默认情况下,一个线程只能使用当前线程打开的数据库连接,除非在连接时设置了check_same_thread=False参数。

现在3种模式都有所了解了,清楚SQLite并不是对多线程无能为力后,接下来就了解下事务吧。
数据库只有在事务中才能被更改。所有更改数据库的命令(除SELECT以外的所有SQL命令)都会自动开启一个新事务,并且当最后一个查询完成时自动提交。
而BEGIN命令可以手动开始事务,并关闭自动提交。当下一条COMMIT命令执行时,自动提交再次打开,事务中所做的更改也被写入数据库。当COMMIT失败时,自动提交仍然关闭,以便让用户尝试再次提交。若执行的是ROLLBACK命令,则也打开自动提交,但不保存事务中的更改。关闭数据库或遇到错误时,也会自动回滚事务。
经常有人抱怨SQLite的插入太慢,实际上它可以做到每秒插入几万次,但是每秒只能提交几十次事务。因此在插入大批数据时,可以通过禁用自动提交来提速。

事务在改写数据库文件时,会先生成一个rollback journal(回滚日志),记录初始状态(其实就是备份),所有改动都是在数据库文件上进行的。当事务需要回滚时,可以将备份文件的内容还原到数据库文件;提交成功时,默认的delete模式下会直接删除这个日志。这个日志也可以帮助解决事务执行过程中断电,导致数据库文件损坏的问题。但如果操作系统或文件系统有bug,或是磁盘损坏,则仍有可能无法恢复。
而从3.7.0版本(对应iOS 4.3)开始,SQLite还提供了Write-Ahead Logging模式。与delete模式相比,WAL模式在大部分情况下更快,并发性更好,读和写之间互不阻塞;而其缺点对于iPhone这种嵌入式设备来说可以忽略,只需注意不要以只读方式打开WAL模式的数据库即可。
使用WAL模式时,改写操是附加(append)到WAL文件,而不改动数据库文件,因此数据库文件可以被同时读取。当执行checkpoint操作时,WAL文件的内容会被写回数据库文件。当WAL文件达到SQLITE_DEFAULT_WAL_AUTOCHECKPOINT(默认值是1000)页(默认大小是1KB)时,会自动使用当前COMMIT的线程来执行checkpoint操作。也可以关闭自动checkpoint,改为手动定期checkpoint。
为了避免读取的数据不一致,查询时也需要读取WAL文件,并记录一个结尾标记(end mark)。这样的代价就是读取会变得稍慢,但是写入会变快很多。要提高查询性能的话,可以减小WAL文件的大小,但写入性能也会降低。
需要注意的是,低版本的SQLite不能读取高版本的SQLite生成的WAL文件,但是数据库文件是通用的。这种情况在用户进行iOS降级时可能会出现,可以把模式改成delete,再改回WAL来修复。
要对一个数据库连接启用WAL模式,需要执行“PRAGMA journal_mode=WAL;”这条命令,它的默认值是“journal_mode=DELETE”。执行后会返回新的journal_mode字符串值,即成功时为"wal",失败时为之前的模式(例如"delete")。一旦启用WAL模式后,数据库会保持这个模式,这样下次打开数据库时仍然是WAL模式。
要停止自动checkpoint,可以使用wal_autocheckpoint指令或sqlite3_wal_checkpoint()函数。手动执行checkpoint可以使用wal_checkpoint指令或sqlite3_wal_checkpoint()函数。

还有一个很重要的知识点需要强调:事务是和数据库连接相关的,每个数据库连接(使用pager来)维护自己的事务,且同时只能有一个事务(但是可以用SAVEPOINT来实现内嵌事务)。
也就是说,事务与线程无关,一个线程里可以同时用多个数据库连接来完成多个事务,而多个线程也可以同时(非并发)使用一个数据库连接来共同完成一个事务。
下面用Python来演示一下:

# -*- coding: utf-8 -*-

import sqlite3
import threading

def f():
    con.rollback()

con = sqlite3.connect('test.db', check_same_thread=False) # 允许在其他线程中使用这个连接
cu = con.cursor()

cu.execute('CREATE TABLE IF NOT EXISTS test (id INTEGER PRIMARY KEY)')

print cu.execute('SELECT count(*) FROM test').fetchone()[0] # 0
cu.execute('INSERT INTO test VALUES (NULL)')
print cu.execute('SELECT count(*) FROM test').fetchone()[0] # 1

thread = threading.Thread(target=f)
thread.start()
thread.join()

print cu.execute('SELECT count(*) FROM test').fetchone()[0] # 0

cu.close()
con.close()

在这个例子中,虽然是在子线程中执行rollback,但由于和主线程用的是同一个数据库连接,所以主线程所做的更改也被回滚了。
而如果是用不同的数据库连接,每个连接都不能读取其他连接中未提交的数据,除非使用read-uncommitted模式。

而要实现事务,就不得不用到
一个SQLite数据库文件有5种锁的状态:

  • UNLOCKED:表示数据库此时并未被读写。
  • SHARED:表示数据库可以被读取。SHARED锁可以同时被多个线程拥有。一旦某个线程持有SHARED锁,就没有任何线程可以进行写操作。
  • RESERVED:表示准备写入数据库。RESERVED锁最多只能被一个线程拥有,此后它可以进入PENDING状态。
  • PENDING:表示即将写入数据库,正在等待其他读线程释放SHARED锁。一旦某个线程持有PENDING锁,其他线程就不能获取SHARED锁。这样一来,只要等所有读线程完成,释放SHARED锁后,它就可以进入EXCLUSIVE状态了。
  • EXCLUSIVE:表示它可以写入数据库了。进入这个状态后,其他任何线程都不能访问数据库文件。因此为了并发性,它的持有时间越短越好。

一个线程只有在拥有低级别的锁的时候,才能获取更高一级的锁。SQLite就是靠这5种类型的锁,巧妙地实现了读写线程的互斥。同时也可看出,写操作必须进入EXCLUSIVE状态,此时并发数被降到1,这也是SQLite被认为并发插入性能不好的原因。
另外,read-uncommitted和WAL模式会影响这个锁的机制。在这2种模式下,读线程不会被写线程阻塞,即使写线程持有PENDING或EXCLUSIVE锁。

提到锁就不得不说到死锁的问题,而SQLite也可能出现死锁。
下面举个例子:

连接1:BEGIN (UNLOCKED)
连接1:SELECT ... (SHARED)
连接1:INSERT ... (RESERVED)
连接2:BEGIN (UNLOCKED)
连接2:SELECT ... (SHARED)
连接1:COMMIT (PENDING,尝试获取EXCLUSIVE锁,但还有SHARED锁未释放,返回SQLITE_BUSY)
连接2:INSERT ... (尝试获取RESERVED锁,但已有PENDING锁未释放,返回SQLITE_BUSY)

现在2个连接都在等待对方释放锁,于是就死锁了。当然,实际情况并没那么糟糕,任何一方选择不继续等待,回滚事务就行了。

不过要更好地解决这个问题,就必须更深入地了解事务了。
实际上BEGIN语句可以有3种起始状态:

  • DEFERRED:默认值,开始事务时不获取任何锁。进行第一次读操作时获取SHARED锁,进行第一次写操作时获取RESERVED锁。
  • IMMEDIATE:开始事务时获取RESERVED锁。
  • EXCLUSIVE:开始事务时获取EXCLUSIVE锁。

现在考虑2个事务在开始时都使用IMMEDIATE方式:

连接1:BEGIN IMMEDIATE (RESERVED)
连接1:SELECT ... (RESERVED)
连接1:INSERT ... (RESERVED)
连接2:BEGIN IMMEDIATE (尝试获取RESERVED锁,但已有RESERVED锁未释放,因此事务开始失败,返回SQLITE_BUSY,等待用户重试)
连接1:COMMIT (EXCLUSIVE,写入完成后释放)
连接2:BEGIN IMMEDIATE (RESERVED)
连接2:SELECT ... (RESERVED)
连接2:INSERT ... (RESERVED)
连接2:COMMIT (EXCLUSIVE,写入完成后释放)

这样死锁就被避免了。

而EXCLUSIVE方式则更为严苛,即使其他连接以DEFERRED方式开启事务也不会死锁:

连接1:BEGIN EXCLUSIVE (EXCLUSIVE)
连接1:SELECT ... (EXCLUSIVE)
连接1:INSERT ... (EXCLUSIVE)
连接2:BEGIN (UNLOCKED)
连接2:SELECT ... (尝试获取SHARED锁,但已有EXCLUSIVE锁未释放,返回SQLITE_BUSY,等待用户重试)
连接1:COMMIT (EXCLUSIVE,写入完成后释放)
连接2:SELECT ... (SHARED)
连接2:INSERT ... (RESERVED)
连接2:COMMIT (EXCLUSIVE,写入完成后释放)

不过在并非很高的情况下,直接获取EXCLUSIVE锁的难度比较大;而且为了避免EXCLUSIVE状态长期阻塞其他请求,最好的方式还是让所有写事务都以IMMEDIATE方式开始。
顺带一提,要实现重试的话,可以使用sqlite3_busy_timeout()或sqlite3_busy_handler()函数。

由此可见,要想保证线程安全的话,可以有这4种方式:

  1. SQLite使用单线程模式,用一个专门的线程访问数据库。
  2. SQLite使用单线程模式,用一个线程队列来访问数据库,队列一次只允许一个线程执行,队列里的线程共用一个数据库连接。
  3. SQLite使用多线程模式,每个线程创建自己的数据库连接。
  4. SQLite使用串行模式,所有线程共用全局的数据库连接。

接下来就一一测试这几种方式在iPhone 4(iOS 4.3.3,SQLite 3.7.2)上的性能表现。

第一种方式太过麻烦,需要线程间通信,这里我就忽略了。

第二种方式可以用dispatch_queue_create()来创建一个serial queue,或者用一个maxConcurrentOperationCount为1的NSOperationQueue来实现。
这种方式的缺点就是事务必须在一个block或operation里完成,否则会乱序;而耗时较长的事务会阻塞队列。另外,没法利用多核CPU的优势。

先初始化数据库:

#import <sqlite3.h>

static char dbPath[200];
static sqlite3 *database;

static sqlite3 *openDb() {
    if (sqlite3_open(dbPath, &database) != SQLITE_OK) {
        sqlite3_close(database);
        NSLog(@"Failed to open database: %s", sqlite3_errmsg(database));
    }
    return database;
}

- (void)viewDidLoad {
    [super viewDidLoad];

    sqlite3_config(SQLITE_CONFIG_SINGLETHREAD);

    NSLog(@"%d", sqlite3_threadsafe());
    NSLog(@"%s", sqlite3_libversion());

    NSArray *paths = NSSearchPathForDirectoriesInDomains(NSDocumentDirectory, NSUserDomainMask, YES);
    NSString *documentsDirectory = [paths objectAtIndex:0];
    strcpy(dbPath, [[documentsDirectory stringByAppendingPathComponent:@"data.sqlite3"] UTF8String]);

    database = openDb();

    char *errorMsg;
    if (sqlite3_exec(database, "CREATE TABLE IF NOT EXISTS test (id INTEGER PRIMARY KEY AUTOINCREMENT, value INTEGER);", NULL, NULL, &errorMsg) != SQLITE_OK) {
        NSLog(@"Failed to create table: %s", errorMsg);
    }
}

再插入1000条测试数据:

static void insertData() {
    char *errorMsg;

    if (sqlite3_exec(database, "BEGIN TRANSACTION", NULL, NULL, &errorMsg) != SQLITE_OK) {
        NSLog(@"Failed to begin transaction: %s", errorMsg);
    }

    static const char *insert = "INSERT INTO test VALUES (NULL, ?);";
    sqlite3_stmt *stmt;
    if (sqlite3_prepare_v2(database, insert, -1, &stmt, NULL) == SQLITE_OK) {
        for (int i = 0; i < 1000; ++i) {
            sqlite3_bind_int(stmt, 1, arc4random());
            if (sqlite3_step(stmt) != SQLITE_DONE) {
                --i;
                NSLog(@"Error inserting table: %s", sqlite3_errmsg(database));
            }
            sqlite3_reset(stmt);
        }
        sqlite3_finalize(stmt);
    }

    if (sqlite3_exec(database, "COMMIT TRANSACTION", NULL, NULL, &errorMsg) != SQLITE_OK) {
        NSLog(@"Failed to commit transaction: %s", errorMsg);
    }

    static const char *query = "SELECT count(*) FROM test;";
    if (sqlite3_prepare_v2(database, query, -1, &stmt, NULL) == SQLITE_OK) {
        if (sqlite3_step(stmt) == SQLITE_ROW) {
            NSLog(@"Table size: %d", sqlite3_column_int(stmt, 0));
        } else {
            NSLog(@"Failed to read table: %s", sqlite3_errmsg(database));
        }
        sqlite3_finalize(stmt);
    }
}

然后创建一个串行队列:

static dispatch_queue_t queue;

- (void)viewDidLoad {
    // ...
    queue = dispatch_queue_create("net.keakon.db", NULL);
}

再设置一个计数器,每秒执行一次:

static int lastReadCount = 0;
static int readCount = 0;
static int lastWriteCount = 0;
static int writeCount = 0;

- (void)count {
    int lastRead = lastReadCount;
    int lastWrite = lastWriteCount;
    lastReadCount = readCount;
    lastWriteCount = writeCount;
    NSLog(@"%d, %d", lastReadCount - lastRead, lastWriteCount - lastWrite);
}

- (void)viewDidLoad {
    // ...
    [NSTimer scheduledTimerWithTimeInterval:1.0 target:self selector:@selector(count) userInfo:nil repeats:YES];
}

这样就可以开始测试select和update了:

static void readData() {
    static const char *query = "SELECT value FROM test WHERE value < ? ORDER BY value DESC LIMIT 1;";

    void (^ __block readBlock)() = Block_copy(^{
        sqlite3_stmt *stmt;
        if (sqlite3_prepare_v2(database, query, -1, &stmt, NULL) == SQLITE_OK) {
            sqlite3_bind_int(stmt, 1, arc4random());
            int returnCode = sqlite3_step(stmt);
            if (returnCode == SQLITE_ROW || returnCode == SQLITE_DONE) {
                ++readCount;
            }
            sqlite3_finalize(stmt);
        } else {
            NSLog(@"Failed to prepare statement: %s", sqlite3_errmsg(database));
        }
        dispatch_async(queue, readBlock);
    });
    dispatch_async(queue, readBlock);
}

static void writeData() {
    static const char *update = "UPDATE test SET value = ? WHERE id = ?;";

    void (^ __block writeBlock)() = Block_copy(^{
        sqlite3_stmt *stmt;
        if (sqlite3_prepare_v2(database, update, -1, &stmt, NULL) == SQLITE_OK) {
            sqlite3_bind_int(stmt, 1, arc4random());
            sqlite3_bind_int(stmt, 2, arc4random() % 1000 + 1);
            if (sqlite3_step(stmt) == SQLITE_DONE) {
                ++writeCount;
            }
            sqlite3_finalize(stmt);
        } else {
            NSLog(@"Failed to prepare statement: %s", sqlite3_errmsg(database));
        }
        dispatch_async(queue, writeBlock);
    });
    dispatch_async(queue, writeBlock);
}

这里是用dispatch_async()来异步地递归调用block。
因为block是在栈里生成的,异步执行时已经被销毁,所以需要copy到堆。因为需要一直执行,所以我就没release了。
此外,光copy的话还是无法正常执行,但是把block本身的存储类型设为__block后就正常了,原因我也不清楚。

测试结果为只读时平均每秒165次,只写时每秒68次,同时读写时每秒各47次。换成多线程或串行模式时,效率也差不多。

接着试试WAL模式:

if (sqlite3_exec(database, "PRAGMA journal_mode=WAL;", NULL, NULL, &errorMsg) != SQLITE_OK) {
    NSLog(@"Failed to set WAL mode: %s", errorMsg);
}

sqlite3_wal_checkpoint(database, NULL); // 每次测试前先checkpoint,避免WAL文件过大而影响性能

测试结果为只读时平均每秒166次,只写时每秒244次,同时读写时每秒各97次。并发性增加了1倍有木有!更夸张的是写入比读取还快了。

在自编译的3.7.8版中,同时读写为每秒各102次,加上SQLITE_THREADSAFE=0参数后为每秒各104次,性能稍有提升。

第三种方式需要打开和关闭数据库连接,所以会额外消耗一些时间。此外还要维持各个连接间的互斥,事务也比较容易冲突,但能确保事务正确执行。

首先需要移除全局的database变量,并修改openDb()函数:

static sqlite3 *openDb() {
    sqlite3 *database = NULL;
    if (sqlite3_open(dbPath, &database) != SQLITE_OK) {
        sqlite3_close(database);
        NSLog(@"Failed to open database: %s", sqlite3_errmsg(database));
    }
    return database;
}

再配置成多线程模式:

sqlite3_config(SQLITE_CONFIG_MULTITHREAD);

队列改成可以乱序执行的:

queue = dispatch_get_global_queue(DISPATCH_QUEUE_PRIORITY_BACKGROUND, 0);

然后是访问数据库:

static void readData() {
    static const char *query = "SELECT value FROM test WHERE value < ? ORDER BY value DESC LIMIT 1;";

    dispatch_async(queue, ^{
        sqlite3 *database = openDb();
        sqlite3_stmt *stmt;
        if (sqlite3_prepare_v2(database, query, -1, &stmt, NULL) == SQLITE_OK) {
            while (YES) {
                sqlite3_bind_int(stmt, 1, arc4random());
                int returnCode = sqlite3_step(stmt);
                if (returnCode == SQLITE_ROW || returnCode == SQLITE_DONE) {
                    ++readCount;
                }
                sqlite3_reset(stmt);
            }
            sqlite3_finalize(stmt);
        } else {
            NSLog(@"Failed to prepare statement: %s", sqlite3_errmsg(database));
        }
        sqlite3_close(database);
    });
}

static void writeData() {
    static const char *update = "UPDATE test SET value = ? WHERE id = ?;";

    dispatch_async(queue, ^{
        sqlite3 *database = openDb();
        sqlite3_stmt *stmt;
        if (sqlite3_prepare_v2(database, update, -1, &stmt, nil) == SQLITE_OK) {
            while (YES) {
                sqlite3_bind_int(stmt, 1, arc4random());
                sqlite3_bind_int(stmt, 2, arc4random() % 1000 + 1);
                if (sqlite3_step(stmt) == SQLITE_DONE) {
                    ++writeCount;
                }
                sqlite3_reset(stmt);
            }
            sqlite3_finalize(stmt);
        } else {
            NSLog(@"Failed to prepare statement: %s", sqlite3_errmsg(database));
        }
        sqlite3_close(database);
    });
}

这里就无需递归调用了,直接在子线程中循环即可。

测试结果为只读时平均每秒164次,只写时每秒68次,同时读写时分别为每秒14和30次(波动很大)。此外,这种方式因为最初启动的几个线程持续访问数据库,后加入的线程会滞后几秒才启动,且很难打开数据库连接或创建prepare statement。调试时发现只会启用2个线程,但是随队列中block数目的增加,读性能增高,写性能降低。读写各3个block时分别为每秒35和14次。

WAL模式下甚至连初始时启动2个线程都会被lock,因此只能改成不断重试:

static void readData() {
    static const char *query = "SELECT value FROM test WHERE value < ? ORDER BY value DESC LIMIT 1;";

    dispatch_async(queue, ^{
        sqlite3 *database = openDb();
        sqlite3_stmt *stmt;
        while (sqlite3_prepare_v2(database, query, -1, &stmt, NULL) != SQLITE_OK);
        while (YES) {
            sqlite3_bind_int(stmt, 1, arc4random());
            int returnCode = sqlite3_step(stmt);
            if (returnCode == SQLITE_ROW || returnCode == SQLITE_DONE) {
                ++readCount;
            }
            sqlite3_reset(stmt);
        }
        sqlite3_finalize(stmt);
        sqlite3_close(database);
    });
}

static void writeData() {
    static const char *update = "UPDATE test SET value = ? WHERE id = ?;";

    dispatch_async(queue, ^{
        sqlite3 *database = openDb();
        sqlite3_stmt *stmt;
        while (sqlite3_prepare_v2(database, update, -1, &stmt, nil) != SQLITE_OK);
        while (YES) {
            sqlite3_bind_int(stmt, 1, arc4random());
            sqlite3_bind_int(stmt, 2, arc4random() % 1000 + 1);
            if (sqlite3_step(stmt) == SQLITE_DONE) {
                ++writeCount;
            }
            sqlite3_reset(stmt);
        }
        sqlite3_finalize(stmt);
        sqlite3_close(database);
    });
}

结果为只读时平均每秒169次,只写时每秒246次,同时读写时每秒分别为90和57次(波动较大)。并发效率有了显著提升,但仍不及第二种方式。

第四种方式相当于让SQLite来维护队列,只不过SQL的执行是乱序的,因此无法保证事务性。

先恢复全局的database变量,然后配置成串行模式:

sqlite3_config(SQLITE_CONFIG_SERIALIZED);

再是访问数据库:

static void readData() {
    static const char *query = "SELECT value FROM test WHERE value < ? ORDER BY value DESC LIMIT 1;";

    dispatch_async(queue, ^{
        sqlite3_stmt *stmt;
        if (sqlite3_prepare_v2(database, query, -1, &stmt, NULL) == SQLITE_OK) {
            while (YES) {
                sqlite3_bind_int(stmt, 1, arc4random());
                int returnCode = sqlite3_step(stmt);
                if (returnCode == SQLITE_ROW || returnCode == SQLITE_DONE) {
                    ++readCount;
                }
                sqlite3_reset(stmt);
            }
            sqlite3_finalize(stmt);
        } else {
            NSLog(@"Failed to prepare statement: %s", sqlite3_errmsg(database));
        }
    });
}

static void writeData() {
    static const char *update = "UPDATE test SET value = ? WHERE id = ?;";

    dispatch_async(queue, ^{
        sqlite3_stmt *stmt;
        if (sqlite3_prepare_v2(database, update, -1, &stmt, NULL) == SQLITE_OK) {
            while (YES) {
                sqlite3_bind_int(stmt, 1, arc4random());
                sqlite3_bind_int(stmt, 2, arc4random() % 1000 + 1);
                if (sqlite3_step(stmt) == SQLITE_DONE) {
                    ++writeCount;
                }
                sqlite3_reset(stmt);
            }
            sqlite3_finalize(stmt);
        } else {
            NSLog(@"Failed to prepare statement: %s", sqlite3_errmsg(database));
        }
    });
}

测试结果为只读时平均每秒164次,只写时每秒68次,同时读写时每秒分别为57和43次。读线程比写线程的速率更高,而且新线程的加入不需要等待。
WAL模式下,只读时平均每秒176次,只写时每秒254次,同时读写时每秒分别为109和85次。

由此可见,要获得最好的性能的话,WAL模式是必须启用的,为此也有必要自己编译SQLite 3.7.0以上的版本(除非不支持iOS 4.2及以下版本)。
而在测试过的后3种方式中:第3种是效率最低的,不建议使用;第4种读取性能更高,适合无需使用事务的场合;第2种适用范围更广,效率也足够优秀,一般应采用这种方式。
不过要注意的是,第2种方式在测试时的逻辑是完全与数据库相关的。实际中可能要做计算或IO访问等工作,在此期间其他线程都是被阻塞的,这样就会大大降低效率了。因此只建议把访问数据库的逻辑放入队列,其余工作在其他线程里完成。

刚才洗澡时我又想到一点,既然第2种方式不能并行,第4种方式不能保证事务性,那么能否将各自的优点结合起来呢?
于是一个新的实现方案又浮出水面了:使用2个串行队列,分别负责读和写,每个队列各使用一个数据库连接,线程模式可以采用多线程或串行模式。
代码拿方式2稍做修改就行了,这里就不列出了。测试结果波动比较大(估计是checkpoint的影响),多线程模式下平均约为89和73次,串行模式下为91和86次。
但在iPad 2这种双核的机型上,多线程明显要比单队列更具优势:方式2的成绩是每秒各85次,方式3是94和124次(写波动较大),方式4是95和72次,而新方案在多线程模式下是104和168次(写波动很大,40~280之间),串行模式下为108和177次(写波动很大)。
因此极端的优化情况下,可以根据CPU核心数来创建队列数,然后把数据库访问线程随机分配到某个队列中。不过考虑到iOS设备这种嵌入式平台并不需要密集地访问数据库,而且除数据库线程以外还有其他事要做,如果没遇到瓶颈的话,简单的方案2其实也够用了。

1)如果数据库配置成为串行模式:sqlite3_config(SQLITE_CONFIG_SERIALIZED)

 此模式下可以多个线程使用一个数据库连接,但显然对数据库的操作很慢;

 所以建议:

 数据库配置成多线程模式:sqlite3_config(SQLITE_CONFIG_MULTITHREAD)

 此模式下多个线程必须各自使用自己的数据库连接,多个连接对SQLite的操作不用担心,因为SQLite是线程安全的。

 但是这样仍然不好,线程数过多(上百左右个线程发起数据操作)会导致数据库压力大,尤其是有事务操作时会发起很多事务请求,这时所有的带事务请求都会卡在beginTransaction,严重影响性能,使得整个App中所有对数据库数据有请求的页面一直loading,虽然界面不会卡死,但是数据一直出不来;

 所以,采用FMDB queue(其实就是GCD串行队列),因为FMDB queue串行队列,所以所有的数据操作包括事务都是串行执行的,所有多线程发起的数据库操作都被异步放到了这个队列里,不会卡住UI,也不会有数据库事务并发问题;

2)事务中有大数据量(几千条)条件查询(注意是条件查询),然后再数据更新和插入

按1)优化后也有可能性能还是慢的情况,也就是如2)题,这种查询特别慢,严重影响了事务的执行;所以尽量避免在事务中有大数据量条件查询;

3)最后,把数据库的WAL模式开启,这样数据库的写操作很快,而且可以对数据并发写和读取;

  1. WAL is significantly faster in most scenarios.

  2. WAL provides more concurrency as readers do not block writers and a writer does not block readers. Reading and writing can proceed concurrently.

  3. Disk I/O operations tends to be more sequential using WAL.

  4. WAL uses many fewer fsync() operations and is thus less vulnerable to problems on systems where the fsync() system call is broken.

综上,总结如下:

1)数据库多线程模下,有并发事务或并发事务多会超成数据库操作拥堵,这个与数据库模式无关(FMDB queue解决)

2)事务中有大数据(几千条)的先条件查询后更新或插入操作时会很慢(避免事务中大数据条件查询)

2)开启WAL模式;

3)区分对待数据库串行模式和串行数据库操作队列;

目前,我采用的是:
a)多线程数据库操作+FMDB Queue(W/R两队表两数据库连接)+数据库SQLITE_CONFIG_MULTITHREAD模式+WAL+事务中避免大数据条件查询。

但是,我觉得另一种方式的性能也应该不错:b)多线程数据库操作+数据库SQLITE_CONFIG_SERIALIZED模式+(WAL)+一个数据库连接+事务中避免大数据条件查询

a)中数据库模式虽然是多线程(SQLITE_CONFIG_MULTITHREAD)模式,但所有程序级多线程数据库操作都被FMDB queue串行化,最多也就两个数据库并发(W/R),各自一个数据库连接; SQLITE_CONFIG_MULTITHREAD模式是好处在于读和写可分成各一个FMDB queue以及开启WAL模式支持可并发写和读

b)中数据库模式是串行(SQLITE_CONFIG_SERIALIZED)模式且一个数据库连接,所以不需要队列来串行化程序级的多线程数据库操作;同时WAL模式是可选开启,如果开启则写数据库会更快,但没有并发的读和写(因为当前数据库是SQLITE_CONFIG_SERIALIZED模式)

sqlite3中的常见问题

sqlite常见问题

(1) 如何建立自动增长字段?

简短回答:声明为 INTEGER PRIMARY KEY 的列将会自动增长。

长一点的答案: 如果你声明表的一列为 INTEGER PRIMARY KEY,那么, 每当你在该列上插入一NULL值时, NULL自动被转换为一个比该列中最大值大1的一个整数,如果表是空的, 将会是1。 (如果是最大可能的主键 9223372036854775807,那个,将键值将是随机未使用的数。) 如,有下列表:

CREATE TABLE t1(
a INTEGER PRIMARY KEY,
b INTEGER
);

在该表上,下列语句

INSERT INTO t1 VALUES(NULL,123);

在逻辑上等价于:

INSERT INTO t1 VALUES((SELECT max(a) FROM t1)+1,123);

有一个新的API叫做 sqlite3_last_insert_rowid(), 它将返回最近插入的整数值。

注意该整数会比表中该列上的插入之前的最大值大1。 该键值在当前的表中是唯一的。但有可能与已从表中删除的值重叠。 要想建立在整个表的生命周期中唯一的键值,需要在 INTEGER PRIMARY KEY 上增加AUTOINCREMENT声明。那么,新的键值将会比该表中曾能存在过的最大值大1。 如果最大可能的整数值在数据表中曾经存在过,INSERT将会失败, 并返回SQLITE_FULL错误代码。


(2)SQLite3支持何种数据类型?

NULL
INTEGER
REAL
TEXT
BLOB
但实际上,sqlite3也接受如下的数据类型:
smallint 16 位元的整数。
interger 32 位元的整数。
decimal(p,s) p 精确值和 s 大小的十进位整数,精确值p是指全部有几个数(digits)大小值 ,s是指小数点後有几位数。如果没有特别指定,则系统会设为 p=5; s=0 。
float 32位元的实数。
double 64位元的实数。
char(n) n 长度的字串,n不能超过 254。
varchar(n) 长度不固定且其最大长度为 n 的字串,n不能超过 4000。
graphic(n) 和 char(n) 一样,不过其单位是两个字元 double-bytes, n不能超过127。 这个形态是为了支援两个字元长度的字体,例如中文字。
vargraphic(n) 可变长度且其最大长度为 n 的双字元字串,n不能超过 2000。
date 包含了 年份、月份、日期。
time 包含了 小时、分钟、秒。
timestamp 包含了 年、月、日、时、分、秒、千分之一秒。


(3)SQLite允许向一个integer型字段中插入字符串!

这是一个特性,而不是一个bug。SQLite不强制数据类型约束。 任何数据都可以插入任何列。你可以向一个整型列中插入任意长度的字符串, 向布尔型列中插入浮点数,或者向字符型列中插入日期型值。 在 CREATE TABLE 中所指定的数据类型不会限制在该列中插入任何数据。 任何列均可接受任意长度的字符串(只有一种情况除外: 标志为INTEGER PRIMARY KEY的列只能存储64位整数, 当向这种列中插数据除整数以外的数据时,将会产生错误。

但SQLite确实使用声明的列类型来指示你所期望的格式。所以, 例如你向一个整型列中插入字符串时,SQLite会试图将该字符串转换成一个整数。 如果可以转换,它将插入该整数;否则,将插入字符串。 这种特性有时被称为 类型或列亲和性(type or column affinity).


(4)为什么SQLite不允许在同一个表不同的两行上使用0和0.0作主键?

主键必须是数值类型,将主键改为TEXT型将不起作用。

每一行必须有一个唯一的主键。对于一个数值型列, SQLite认为 '0' 和 '0.0' 是相同的, 因为他们在作为整数比较时是相等的(参见上一问题)。 所以,这样值就不唯一了。


(5)多个应用程序或一个应用程序的多个实例可以同时访问同一个数据库文件吗?

多个进程可同时打开同一个数据库。多个进程可以同时进行SELECT 操作,但在任一时刻,只能有一个进程对数据库进行更改。

SQLite使用读、写锁控制对数据库的访问。(在Win95/98/ME等不支持读、 写锁的系统下,使用一个概率性的模拟来代替。)但使用时要注意: 如果数据库文件存放于一个NFS文件系统上,这种锁机制可能不能正常工作。 这是因为 fcntl() 文件锁在很多NFS上没有正确的实现。 在可能有多个进程同时访问数据库的时候,应该避免将数据库文件放到NFS上。 在Windows上,Microsoft的文档中说:如果使用 FAT 文件系统而没有运行 share.exe 守护进程,那么锁可能是不能正常使用的。那些在Windows上有很多经验的人告诉我: 对于网络文件,文件锁的实现有好多Bug,是靠不住的。如果他们说的是对的, 那么在两台或多台Windows机器间共享数据库可能会引起不期望的问题。

我们意识到,没有其它嵌入式的 SQL 数据库引擎能象 SQLite 这样处理如此多的并发。SQLite允许多个进程同时打开一个数据库, 同时读一个数据库。当有任何进程想要写时,它必须在更新过程中锁住数据库文件。 但那通常只是几毫秒的时间。其它进程只需等待写进程干完活结束。 典型地,其它嵌入式的SQL数据库引擎同时只允许一个进程连接到数据库。

但是,Client/Server数据库引擎(如 PostgreSQL, MySQL, 或 Oracle) 通常支持更高级别的并发,并且允许多个进程同时写同一个数据库。 这种机制在Client/Server结构的数据库上是可能的, 因为总是有一个单一的服务器进程很好地控制、协调对数据库的访问。 如果你的应用程序需要很多的并发,那么你应该考虑使用一个Client/Server 结构的数据库。但经验表明,很多应用程序需要的并发,往往比其设计者所想象的少得多。

当SQLite试图访问一个被其它进程锁住的文件时,缺省的行为是返回 SQLITE_BUSY。 可以在C代码中使用 sqlite3_busy_handler() 或 sqlite3_busy_timeout() API 函数调整这一行为。

(6)SQLite线程安全吗?

线程是魔鬼(Threads are evil)。 避免使用它们。

SQLite 是线程安全的。由于很多用户会忽略我们在上一段中给出的建议, 我们做出了这种让步。但是,为了达到线程安全,SQLite在编译时必须将 SQLITE_THREADSAFE 预处理宏置为1。在Windows和Linux上, 已编译的好的二进制发行版中都是这样设置的。 如果不确定你所使用的库是否是线程安全的,可以调用 sqlite3_threadsafe() 接口找出。

在 3.3.1 版本之前,一个 sqlite3 结构只能被用于调用 sqlite3_open 创建的同一线程。你不能在一个线程中打开数据库, 然后将数据库句柄传递给另外一个进程使用。 这主要是由于在好多通用的线程实现(如RedHat9)中的限制引起的(是Bug吗?)。 特别的,在有问题的系统上,一个进程创建的 fcntl() 锁无法被其它线程清除或修改。 所以,由于 SQLite 大量使用 fcntl() 锁做并发控制, 如果你在不同的线程间移动数据库连接,就可能会出现严重的问题。

在3.3.1版本上,关于在线程间移动数据库连接的限制变得宽松了。 在它及以后的版本中,只要连接没有持有 fcntl() 锁,在线程间移动句柄是安全的。 如果没有未决的事务,并且所有的语句都已执行完毕, 你就可以安全的假定不再持有任何锁。

在UNIX中,在执行 fork() 系统调用时不应携带已打开的数据库进入子进程。 那样做将会有问题。


(7)在SQLite数据库中如何列出所有的表和索引?

如果你运行 sqlite3 命令行来访问你的数据库,可以键入 “.tables”来获得所有表的列表。或者,你可以输入 “.schema” 来看整个数据库模式,包括所有的表的索引。 输入这些命令,后面跟一个LIKE模式匹配可以限制显示的表。

在一个 C/C++ 程序中(或者脚本语言使用 Tcl/Ruby/Perl/Python 等) 你可以在一个特殊的名叫 SQLITE_MASTER 上执行一个SELECT查询以获得所有 表的索引。每一个 SQLite 数据库都有一个叫 SQLITE_MASTER 的表, 它定义数据库的模式。 SQLITE_MASTER 表看起来如下:

CREATE TABLE sqlite_master (
type TEXT,
name TEXT,
tbl_name TEXT,
rootpage INTEGER,
sql TEXT
);

对于表来说,type 字段永远是 'table',name 字段永远是表的名字。所以,要获得数据库中所有表的列表, 使用下列SELECT语句:

SELECT name FROM sqlite_master
WHERE type='table'
ORDER BY name;

对于索引,type 等于 'index', name 则是索引的名字,tbl_name 是该索引所属的表的名字。 不管是表还是索引,sql 字段是原先用 CREATE TABLE 或 CREATE INDEX 语句创建它们时的命令文本。对于自动创建的索引(用来实现 PRIMARY KEY 或 UNIQUE 约束),sql字段为NULL。

SQLITE_MASTER 表是只读的。不能对它使用 UPDATE、INSERT 或 DELETE。 它会被 CREATE TABLE、CREATE INDEX、DROP TABLE 和 DROP INDEX 命令自动更新。

临时表不会出现在 SQLITE_MASTER 表中。临时表及其索引和触发器存放在另外一个叫 SQLITE_TEMP_MASTER 的表中。SQLITE_TEMP_MASTER 跟 SQLITE_MASTER 差不多, 但它只是对于创建那些临时表的应用可见。如果要获得所有表的列表, 不管是永久的还是临时的,可以使用类似下面的命令:

SELECT name FROM
(SELECT * FROM sqlite_master UNION ALL
SELECT * FROM sqlite_temp_master)
WHERE type='table'
ORDER BY name


(8) SQLite数据库有已知的大小限制吗?

在 Windows 和 Unix 下,版本 2.7.4的 SQLite 可以达到 2的41次方字节 (2T 字节)。老版本的为 2的31 次方字节(2G 字节)。
SQLite 版本 2.8 限制一个记录的容量为 1M。SQLite 版本 3.0 则对单个记录容量没有限制。
表名、索引表名、视图名、触发器名和字段名没有长度限制。但 SQL 函数的名称 (由 sqlite3_create_function() API 函数创建) 不得超过 255 个字符。

对有关SQLite限制的详细讨论,见 limits.html 。


(9) 在SQLite中,VARCHAR字段最长是多少?

SQLite 不强制 VARCHAR 的长度。 你可以在 SQLITE 中声明一个 VARCHAR(10),SQLite还是可以很高兴地允许你放入500个字符。 并且这500个字符是原封不动的,它永远不会被截断。


(10) SQLite支持二进制大对象吗?

SQLite 3.0 及以后版本允许你在任何列中存储 BLOB 数据。 即使该列被声明为其它类型也可以。


(11) 在SQLite中,如何在一个表上添加或删除一列?

SQLite 有有限地 ALTER TABLE 支持。你可以使用它来在表的末尾增加一列,可更改表的名称。 如果需要对表结构做更复杂的改变,则必须重新建表。 重建时可以先将已存在的数据放到一个临时表中,删除原表, 创建新表,然后将数据从临时表中复制回来。

如,假设有一个 t1 表,其中有 "a", "b", "c" 三列, 如果要删除列 c ,以下过程描述如何做:

BEGIN TRANSACTION;
CREATE TEMPORARY TABLE t1_backup(a,b);
INSERT INTO t1_backup SELECT a,b FROM t1;
DROP TABLE t1;
CREATE TABLE t1(a,b);
INSERT INTO t1 SELECT a,b FROM t1_backup;
DROP TABLE t1_backup;
COMMIT;


(12) 我在数据库中删除了很多数据,但数据库文件没有变小,是Bug吗?

不是。当你从SQLite数据库中删除数据时, 未用的磁盘空间将会加入一个内部的“自由列表”中。 当你下次插入数据时,这部分空间可以重用。磁盘空间不会丢失, 但也不会返还给操作系统。

如果删除了大量数据,而又想缩小数据库文件占用的空间,执行 VACUUM 命令。 VACUUM 将会从头重新组织数据库。这将会使用数据库有一个空的“自由链表”, 数据库文件也会最小。但要注意的是,VACUUM 的执行会需要一些时间 (在SQLite开发时,在Linux上,大约每M字节需要半秒种),并且, 执行过程中需要原数据库文件至多两倍的临时磁盘空间。

对于 SQLite 3.1版本,一个 auto-vacumm 模式可以替代 VACUUM 命令。 可以使用 auto_vacuum pragma 打开。


(13) 我可以在商业产品中使用SQLite而不需支付许可费用吗?

是的。SQLite 在 public domain。 对代码的任何部分没有任何所有权声明。你可以使用它做任何事。


(14) 如何在字符串中使用单引号(')?

SQL 标准规定,在字符串中,单引号需要使用逃逸字符,即在一行中使用两个单引号。在这方面 SQL 用起来类似 Pascal 语言。 SQLite 尊循标准。如:

INSERT INTO xyz VALUES('5 O''clock');


(15) SQLITE_SCHEMA error是什么错误?为什么会出现该错误?

当一个准备好的(prepared)SQL语句不再有效或者无法执行时, 将返回一个 SQLITE_SCHEMA 错误。发生该错误时,SQL语句必须使用 sqlite3_prepare() API来重新编译. 在 SQLite 3 中, 一个 SQLITE_SCHEMA 错误只会发生在用 sqlite3_prepare()/sqlite3_step()/sqlite3_finalize() API 执行 SQL 时。而不会发生在使用 sqlite3_exec()时。 在版本2中不是这样。

准备好的语句失效的最通常原因是:在语句准备好后, 数据库的模式又被修改了。另外的原因会发生在:

数据库离线:DETACHed.
数据库被 VACUUMed
一个用户存储过程定义被删除或改变。
一个 collation 序列定义被删除或改变。
认证函数被改变。
在所有情况下,解决方法是重新编译并执行该SQL语句。 因为一个已准备好的语句可以由于其它进程改变数据库模式而失效, 所有使用 sqlite3_prepare()/sqlite3_step()/sqlite3_finalize() API 的代码都应准备处理 SQLITE_SCHEMA 错误。下面给出一个例子:

int rc;
sqlite3_stmt *pStmt;
char zSql[] = "SELECT .....";

do {

sqlite3_prepare(pDb, zSql, -1, &pStmt, 0);

while( SQLITE_ROW==sqlite3_step(pStmt) ){

}

rc = sqlite3_finalize(pStmt);
} while( rc==SQLITE_SCHEMA );


(16) 为什么 ROUND(9.95,1) 返回 9.9 而不是 10.0? 9.95不应该圆整 (四舍五入)吗?

SQLite 使用二进制算术,在二进制中, 无法用有限的二进制位数表示 9.95 。使用 64-bit IEEE 浮点 (SQLite就是使用这个)最接近 9.95 的二进制表示是 9.949999999999999289457264239899814128875732421875。 所在,当你输入 9.95 时,SQLite实际上以为是上面的数字, 在四舍五入时会舍去。

这种问题在使用二进制浮点数的任何时候都会出现。 通常的规则是记住很多有限的十进制小数都没有一个对应的二进制表示。 所以,它们只能使用最接近的二进制数。它们通常非常接近, 但也会有些微小的不同,有些时候也会导致你所期望的不同的结果。

SQLITE的锁

在SQLite中,锁和事务是紧密联系的。为了有效地使用事务,需要了解一些关于如何加锁的知识。
SQLite采用粗放型的锁。当一个连接要写数据库,所有其它的连接被锁住,直到写连接结束了它的事务。SQLite有一个加锁表,来帮助不同的写数据库都能够在最后一刻再加锁,
以保证最大的并发性。
SQLite使用锁逐步上升机制,为了写数据库,连接需要逐级地获得排它锁。SQLite有5个不同的锁状态:未加锁(UNLOCKED)、共享(SHARED)、保留(RESERVED)、未决(PENDING)和排它(EXCLUSIVE)。每个数据库连接在同一时刻只能处于其中一个状态。每种状态(未加
锁状态除外)都有一种锁与之对应。
最初的状态是未加锁状态,在此状态下,连接还没有存取数据库。当连接到了一个数据库,甚至已经用BEGIN开始了一个事务时,连接都还处于未加锁状态。未加锁状态的下一个状态是共享状态。为了能够从数据库中读(不写)数据,连接必须首先进入共享状态,也就是说首先要获得一个共享锁。多个连接可以同时获得并保持共享锁,也就是说多个连接可以同时从同一个数据库中读数据。但哪怕只有一个共享锁还没有释放,也不允许任何连接写数据库。

如果一个连接想要写数据库,它必须首先获得一个保留锁。一个数据库上同时只能有一个保 留锁。保留锁可以与共享锁共存,保留锁是写数据库的第1阶段。保留锁即不阻止其它拥有 共享锁的连接继续读数据库,也不阻止其它连接获得新的共享锁。 一旦一个连接获得了保留锁,它就可以开始处理数据库修改操作了,尽管这些修改只能在
缓冲区中进行,而不是实际地写到磁盘。对读出内容所做的修改保存在内存缓冲区中。 当连接想要提交修改(或事务)时,需要将保留锁提升为排它锁。为了得到排它锁,还必须首
先将保留锁提升为未决锁。获得未决锁之后,其它连接就不能再获得新的共享锁了,但已经拥有共享锁的连接仍然可以继续正常读数据库。此时,拥有未决锁的连接等待其它拥有共享锁的连接完成工作并释放其共享锁。
一旦所有其它共享锁都被释放,拥有未决锁的连接就可以将其锁提升至排它锁,此时就可以自由地对数据库进行修改了。所有以前对缓冲区所做的修改都会被写到数据库文件。

事务的种类

SQLite有三种不同的事务,使用不同的锁状态。事务可以开始于:DEFERRED、MMEDIATE或EXCLUSIVE。事务类型在BEGIN命令中指定:
BEGIN [ DEFERRED | IMMEDIATE | EXCLUSIVE ] TRANSACTION;
一个DEFERRED事务不获取任何锁(直到它需要锁的时候),BEGIN语句本身也不会做什么事情——它开始于UNLOCK状态。默认情况下就是这样的,如果仅仅用BEGIN开始一个事
务,那么事务就是DEFERRED的,同时它不会获取任何锁;当对数据库进行第一次读操作时,它会获取SHARED锁;同样,当进行第一次写操作时,它会获取RESERVED锁。由BEGIN开始的IMMEDIATE 事务会尝试获取RESERVED锁。如果成功,BEGIN IMMEDIATE保证没有别的连接可以写数据库。但是,别的连接可以对数据库进行读操作;但是,RESERVED锁会阻止其它连接的BEGIN IMMEDIATE或者BEGIN EXCLUSIVE命令,当其它连接执行上述命令时,会返回SQLITE_BUSY错误。这时你就可以对数据库进行
修改操作了,但是你还不能提交,当你COMMIT时,会返回SQLITE_BUSY错误,这意味着还有其它的读事务没有完成,得等它们执行完后才能提交事务。

EXCLUSIVE事务会试着获取对数据库的EXCLUSIVE锁。这与IMMEDIATE类似,但是一旦成功,EXCLUSIVE事务保证没有其它的连接,所以就可对数据库进行读写操作了。
上节那个例子的问题在于两个连接最终都想写数据库,但是它们都没有放弃各自原来的锁最终,SHARED锁导致了问题的出现。如果两个连接都以BEGIN IMMEDIATE开始事务,
那么死锁就不会发生。在这种情况下,在同一时刻只能有一个连接进入BEGIN IMMEDIATE,其它的连接就得等待。BEGIN IMMEDIATE和BEGIN EXCLUSIVE通常被写
事务使用。就像同步机制一样,它防止了死锁的产生。基本的准则是:如果你正在使用的数据库没有其它的连接,用BEGIN就足够了。但是,如果你使用的数据库有其它的连接也会对数据库进行写操作,就得使用BEGIN IMMEDIATE或BEGIN EXCLUSIVE开始你的事务。

事务的生命周期

有一些关于代码和事务的问题需要关注。首先需要知道哪个对象运行在哪个事务之下。另一个问题是持续时间——一个事务何时开始,何时结束,从哪一点开始影响其它连接?第一
个问题与API直接关联,第二个与SQL的一般概念及SQLite的特殊实现关联。
一个连接(connection)可以包含多个语句(statement),而且每个连接有一个与数据库关联的B-tree和一个pager。Pager在连接中起着很重要的作用,因为它管理事务、锁、内存缓冲以及负责崩溃恢复(crash recovery)。当你进行数据库写操作时,记住最重要的一件事:在任何时候,只在一个事务下执行一个连接。这回答了第一个问题。
关于第二个问题,一般来说,一个事务的生命周期和语句差不多,你也可以手动结束它。默认情况下,事务自动提交,当然你也可以通过BEGIN..COMMIT手动提交。接下来的问题
是事务如何与锁关联。

锁的状态

大多数情况下,锁的生命周期在事务的生命周期之中。它们不一定同时开始,但总时同时结束。当你结束一个事务时,也会释放它相关的锁。或者说,锁直到事务结束或系统崩溃时才会释放。如果系统在事务没有结束的情况下崩溃,那么下一个访问数据库的连接会处理这种情况。

读事务

我们先来看看SELECT语句执行时锁的状态变化过程,非常简单:一个连接执行SELECT语句,触发一个事务,从UNLOCKED到SHARED,当事务COMMIT时,又回到UNLOCKED,就这么简单。
那么,当你运行两个语句时会发生什么呢?这时如何加锁呢?这依赖于你是否运行在自动提交状态。考虑下面的例子(为了简单,这里用了伪码):
db = open('foods.db')
db.exec('BEGIN')
db.exec('SELECT * FROM episodes')
db.exec('SELECT * FROM episodes')
db.exec('COMMIT')
db.close()
由于显式地使用了BEGIN和COMMIT,两个SELECT命令在一个事务下执行。第一个exec()执行时,连接处于SHARED,然后第二个exec()执行。当事务提交时,连接又从
SHARED回到UNLOCKED状态,状态变化如下:

UNLOCKED→PENDING→SHARED→UNLOCKED
如果没有BEGIN和COMMIT两行,两个SELECT都运行于自动提交状态,状态变化如下:
UNLOCKED→PENDING→SHARED→UNLOCKED→PENDING→SHARED→UNLOCKED
仅仅是读数据,但在自动提交模式下,却会经历两个加解锁的循环,太麻烦。而且,一个写进程可能插到两个SELECT中间对数据库进行修改,这样,你就不能保证第二次能够读到
同样的数据了,而使用BEGIN..COMMIT就可以有此保证。

写事务

下面我们来考虑写数据库,比如UPDATE。和读事务一样,它也会经历UNLOCKED→PENDING→SHARED的变化过程,但接下来就会看到PENDING锁是如何起到关口作用的了。

保留(RESERVED)状态

当一个连接(connection)要向数据库写数据时,从SHARED状态变为RESERVED状态。如果它得到RESERVED锁,也就意味着它已经准备好进行写操作了。即使它没有把修改写入数据库,也可以把修改保存到位于pager的缓冲区中(page cache)了。
当一个连接进入RESERVED状态,pager就开始初始化回卷日志(rollback journal)。回卷日志是一个文件,用于回卷和崩溃恢复,见图5-1。在RESERVED状态下,pager管理着三种页:
(1)已修改的页:包含被B-tree修改的记录,位于page cache中。
(2)未修改的页:包含没有被B-tree修改的记录。
(3)日志页:这是修改页以前的版本,日志页并不存储在page cache中,而是在B-tree修改页之前写入日志。
Page cache非常重要,正是因为它的存在,一个处于RESERVED状态的连接可以真正的开始工作,而不会干扰其它的(读)连接。所以,SQLite可以高效地处理在同一时刻的多个读连接和一个写连接。

未决(UNPENDING)状态

当一个连接完成修改,需要真正开始提交事务时,执行该过程的pager进入EXCLUSIVE状态。从RESERVED状态开始,pager试着获取PENDING锁,一旦得到,就独占它,不允
许任何其它连接获得PENDING锁。既然写操作持有PENDING锁,其它任何连接都不能从UNLOCKED状态进入SHARED状态,即不会再有新的读进程,也不会再有新的写进程。
只有那些已经处于SHARED状态的连接可以继续工作。而处于PENDING状态的写进程会一直等到所有这些连接释放它们的锁,然后对数据库加EXCUSIVE锁,进入EXCLUSIVE
状态,独占数据库。

排它(EXCLUSIVE)状态

在EXCLUSIVE状态下,主要的工作是把修改的页从page cache写入数据库文件,这是真正进行写操作的地方。在pager将修改页写到文件之前,还必须先处理日志。它检查是否所有的日志都写入了磁盘,因为它们可能还位于操作系统的缓冲区中。所以pager得告诉OS把所有的文件写入磁盘,这与synchronous pragma所做的工作相同,如第4章所述。
日志是数据库进行恢复的惟一方法,所以日志对于DBMS非常重要。如果日志页没有完全写入磁盘而发生崩溃,数据库就不能恢复到它原来的状态,此时数据库就处于不一致状态。
日志写盘完成后,pager就把所有的修改页写入数据库文件。接下来做什么取决于事务提交的模式,如果是自动提交,那么pager清理日志、page cache,然后由EXCLUSIVE进入
UNLOCKED。如果是手动提交,那么pager继续持有EXCLUSIVE锁和回卷日志,直至遇到COMMIT或者ROLLBACK。
总之,出于性能方面的考虑,进程占有排它锁的时间应该尽可能的短,所以DBMS通常都是在真正写文件时才会占有排它锁,这样能大大提高并发性能。

1.什么是WAL?

WAL的全称是Write Ahead Logging,它是很多数据库中用于实现原子事务的一种机制,SQLite在3.7.0版本引入了该特性。

2.WAL如何工作?

在引入WAL机制之前,SQLite使用rollback journal机制实现原子事务。

rollback journal机制的原理是:在修改数据库文件中的数据之前,先将修改所在分页中的数据备份在另外一个地方,然后才将修改写入到数据库文件中;如果事务失败,则将备份数据拷贝回来,撤销修改;如果事务成功,则删除备份数据,提交修改。

WAL机制的原理是:修改并不直接写入到数据库文件中,而是写入到另外一个称为WAL的文件中;如果事务失败,WAL中的记录会被忽略,撤销修改;如果事务成功,它将在随后的某个时间被写回到数据库文件中,提交修改。

同步WAL文件和数据库文件的行为被称为checkpoint(检查点),它由SQLite自动执行,默认是在WAL文件积累到1000页修改的时候;当然,在适当的时候,也可以手动执行checkpoint,SQLite提供了相关的接口。执行checkpoint之后,WAL文件会被清空。

在读的时候,SQLite将在WAL文件中搜索,找到最后一个写入点,记住它,并忽略在此之后的写入点(这保证了读写和读读可以并行执行);随后,它确定所要读的数据所在页是否在WAL文件中,如果在,则读WAL文件中的数据,如果不在,则直接读数据库文件中的数据。

在写的时候,SQLite将之写入到WAL文件中即可,但是必须保证独占写入,因此写写之间不能并行执行。

WAL在实现的过程中,使用了共享内存技术,因此,所有的读写进程必须在同一个机器上,否则,无法保证数据一致性。

3.WAL的优点与缺点

优点:

  1. 读和写可以完全地并发执行,不会互相阻塞(但是写之间仍然不能并发)。

  2. WAL在大多数情况下,拥有更好的性能(因为无需每次写入时都要写两个文件)。

3.磁盘I/O行为更容易被预测。

缺点:

1.访问数据库的所有程序必须在同一主机上,且支持共享内存技术。

2.每个数据库现在对应3个文件:<yourdb>.db,<yourdb>-wal,<yourdb>-shm。

3.当写入数据达到GB级的时候,数据库性能将下降。

4.3.7.0之前的SQLite无法识别启用了WAL机制的数据库文件。

4.WAL引入的兼容性问题

在启用了WAL之后,数据库文件格式的版本号由1升级到了2,因此,3.7.0之前的SQLite无法识别启用了WAL机制的数据库文件。

禁用WAL会使数据库文件格式的版本号恢复到1,从而可以被SQLite 3.7.0之前的版本识别。

5.WAL引入的性能问题

在一般情况下,WAL会提高SQLite的事务性能;但是在某些极端情况下,却会导致SQLite事务性能的下降。

1.在事务执行时间较长或者要修改的数据量达到GB级的时候,WAL文件会被占用,它会暂时阻止checkpoint的执行(checkpoint会清空WAL文件),这将导致WAL文件变得很大,增加寻址时间,最终导致读写性能的下降。

2.当checkpoint执行的时候,会降低当时的读写性能,因此,WAL可能会导致周期性的性能下降。

6.与WAL相关的PRAGMA和接口

PRAGMA journal_mode

PRAGMA wal_checkpoint

PRAGMA wal_autocheckpoint

sqlite3_wal_checkpoint

sqlite3_wal_autocheckpoint

sqlite3_wal_hook

©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 203,547评论 6 477
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 85,399评论 2 381
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 150,428评论 0 337
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 54,599评论 1 274
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 63,612评论 5 365
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 48,577评论 1 281
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 37,941评论 3 395
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 36,603评论 0 258
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 40,852评论 1 297
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 35,605评论 2 321
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 37,693评论 1 329
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 33,375评论 4 318
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 38,955评论 3 307
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 29,936评论 0 19
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 31,172评论 1 259
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 43,970评论 2 349
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 42,414评论 2 342

推荐阅读更多精彩内容