优化 SQLite 很棘手。C 应用程序的批量插入性能可以从每秒 85 次插入到每秒超过 96,000 次插入不等!
背景:我们使用 SQLite 作为桌面应用程序的一部分。我们有大量的配置数据存储在 XML 文件中,这些数据被解析并加载到 SQLite 数据库中,以便在应用程序初始化时进行进一步处理。SQLite 非常适合这种情况,因为它速度快,不需要专门的配置,并且数据库作为单个文件存储在磁盘上。
理由: 最初我对我所看到的表现感到失望。事实证明,SQLite 的性能可能会有很大差异(对于批量插入和选择),具体取决于数据库的配置方式以及您使用 API 的方式。弄清楚所有选项和技术是什么并不是一件小事,所以我认为创建这个社区 wiki 条目以与 Stack Overflow 读者分享结果以节省其他人进行相同调查的麻烦是明智的。
实验:与其简单地谈论一般意义上的性能技巧(即“使用事务!”),我认为最好编写一些 C 代码并实际测量各种选项的影响。我们将从一些简单的数据开始:
让我们写一些代码!
代码:一个简单的 C 程序,逐行读取文本文件,将字符串拆分为值,然后将数据插入 SQLite 数据库。在这个“基线”版本的代码中,创建了数据库,但我们实际上不会插入数据:
/************************************************************* Baseline code to experiment with SQLite performance. Input data is a 28 MB TAB-delimited text file of the complete Toronto Transit System schedule/route info from http://www.toronto.ca/open/datasets/ttc-routes/ **************************************************************/ #include <stdio.h> #include <stdlib.h> #include <time.h> #include <string.h> #include "sqlite3.h" #define INPUTDATA "C:\\TTC_schedule_scheduleitem_10-27-2009.txt" #define DATABASE "c:\\TTC_schedule_scheduleitem_10-27-2009.sqlite" #define TABLE "CREATE TABLE IF NOT EXISTS TTC (id INTEGER PRIMARY KEY, Route_ID TEXT, Branch_Code TEXT, Version INTEGER, Stop INTEGER, Vehicle_Index INTEGER, Day Integer, Time TEXT)" #define BUFFER_SIZE 256 int main(int argc, char **argv) { sqlite3 * db; sqlite3_stmt * stmt; char * sErrMsg = 0; char * tail = 0; int nRetCode; int n = 0; clock_t cStartClock; FILE * pFile; char sInputBuf [BUFFER_SIZE] = "\0"; char * sRT = 0; /* Route */ char * sBR = 0; /* Branch */ char * sVR = 0; /* Version */ char * sST = 0; /* Stop Number */ char * sVI = 0; /* Vehicle */ char * sDT = 0; /* Date */ char * sTM = 0; /* Time */ char sSQL [BUFFER_SIZE] = "\0"; /*********************************************/ /* Open the Database and create the Schema */ sqlite3_open(DATABASE, &db); sqlite3_exec(db, TABLE, NULL, NULL, &sErrMsg); /*********************************************/ /* Open input file and import into Database*/ cStartClock = clock(); pFile = fopen (INPUTDATA,"r"); while (!feof(pFile)) { fgets (sInputBuf, BUFFER_SIZE, pFile); sRT = strtok (sInputBuf, "\t"); /* Get Route */ sBR = strtok (NULL, "\t"); /* Get Branch */ sVR = strtok (NULL, "\t"); /* Get Version */ sST = strtok (NULL, "\t"); /* Get Stop Number */ sVI = strtok (NULL, "\t"); /* Get Vehicle */ sDT = strtok (NULL, "\t"); /* Get Date */ sTM = strtok (NULL, "\t"); /* Get Time */ /* ACTUAL INSERT WILL GO HERE */ n++; } fclose (pFile); printf("Imported %d records in %4.2f seconds\n", n, (clock() - cStartClock) / (double)CLOCKS_PER_SEC); sqlite3_close(db); return 0; }
按原样运行代码实际上并不执行任何数据库操作,但它会让我们了解原始 C 文件 I/O 和字符串处理操作的速度有多快。
0.94秒导入864913条记录
伟大的!我们每秒可以进行 920,000 次插入,前提是我们实际上不进行任何插入 :-)
我们将使用从文件中读取的值生成 SQL 字符串,并使用 sqlite3_exec 调用该 SQL 操作:
sprintf(sSQL, "INSERT INTO TTC VALUES (NULL, '%s', '%s', '%s', '%s', '%s', '%s', '%s')", sRT, sBR, sVR, sST, sVI, sDT, sTM); sqlite3_exec(db, sSQL, NULL, NULL, &sErrMsg);
这会很慢,因为每次插入的 SQL 都会被编译成 VDBE 代码,而且每次插入都会发生在它自己的事务中。有多慢?
在 9933.61 秒内导入 864913 条记录
哎呀!2小时45分钟!每秒只有85 次插入。
默认情况下,SQLite 将评估唯一事务中的每个 INSERT / UPDATE 语句。如果执行大量插入,建议将您的操作包装在事务中:
sqlite3_exec(db, "BEGIN TRANSACTION", NULL, NULL, &sErrMsg); pFile = fopen (INPUTDATA,"r"); while (!feof(pFile)) { ... } fclose (pFile); sqlite3_exec(db, "END TRANSACTION", NULL, NULL, &sErrMsg);
在 38.03 秒内导入 864913 条记录
这样更好。只需将我们所有的插入包装在一个事务中即可将我们的性能提高到每秒 23,000 次插入。
使用事务是一个巨大的改进,但是如果我们一遍又一遍地使用相同的 SQL,那么为每个插入重新编译 SQL 语句就没有意义了。让我们使用sqlite3_prepare_v2一次来编译我们的 SQL 语句,然后使用以下命令将我们的参数绑定到该语句sqlite3_bind_text:
sqlite3_prepare_v2
sqlite3_bind_text
/* Open input file and import into the database */ cStartClock = clock(); sprintf(sSQL, "INSERT INTO TTC VALUES (NULL, @RT, @BR, @VR, @ST, @VI, @DT, @TM)"); sqlite3_prepare_v2(db, sSQL, BUFFER_SIZE, &stmt, &tail); sqlite3_exec(db, "BEGIN TRANSACTION", NULL, NULL, &sErrMsg); pFile = fopen (INPUTDATA,"r"); while (!feof(pFile)) { fgets (sInputBuf, BUFFER_SIZE, pFile); sRT = strtok (sInputBuf, "\t"); /* Get Route */ sBR = strtok (NULL, "\t"); /* Get Branch */ sVR = strtok (NULL, "\t"); /* Get Version */ sST = strtok (NULL, "\t"); /* Get Stop Number */ sVI = strtok (NULL, "\t"); /* Get Vehicle */ sDT = strtok (NULL, "\t"); /* Get Date */ sTM = strtok (NULL, "\t"); /* Get Time */ sqlite3_bind_text(stmt, 1, sRT, -1, SQLITE_TRANSIENT); sqlite3_bind_text(stmt, 2, sBR, -1, SQLITE_TRANSIENT); sqlite3_bind_text(stmt, 3, sVR, -1, SQLITE_TRANSIENT); sqlite3_bind_text(stmt, 4, sST, -1, SQLITE_TRANSIENT); sqlite3_bind_text(stmt, 5, sVI, -1, SQLITE_TRANSIENT); sqlite3_bind_text(stmt, 6, sDT, -1, SQLITE_TRANSIENT); sqlite3_bind_text(stmt, 7, sTM, -1, SQLITE_TRANSIENT); sqlite3_step(stmt); sqlite3_clear_bindings(stmt); sqlite3_reset(stmt); n++; } fclose (pFile); sqlite3_exec(db, "END TRANSACTION", NULL, NULL, &sErrMsg); printf("Imported %d records in %4.2f seconds\n", n, (clock() - cStartClock) / (double)CLOCKS_PER_SEC); sqlite3_finalize(stmt); sqlite3_close(db); return 0;
16.27秒导入864913条记录
好的!还有一点点代码(不要忘记调用sqlite3_clear_bindingsand sqlite3_reset),但我们的性能提高了一倍多,达到每秒 53,000 次插入。
sqlite3_clear_bindings
sqlite3_reset
默认情况下,SQLite 将在发出操作系统级别的写入命令后暂停。这保证了数据被写入磁盘。通过设置synchronous = OFF,我们指示 SQLite 将数据简单地交给操作系统进行写入,然后继续。如果计算机在数据写入盘片之前发生灾难性崩溃(或电源故障),则数据库文件可能会损坏:
synchronous = OFF
/* Open the database and create the schema */ sqlite3_open(DATABASE, &db); sqlite3_exec(db, TABLE, NULL, NULL, &sErrMsg); sqlite3_exec(db, "PRAGMA synchronous = OFF", NULL, NULL, &sErrMsg);
12.41秒导入864913条记录
现在的改进更小了,但我们每秒最多可以插入 69,600 次。
考虑通过评估将回滚日志存储在内存中PRAGMA journal_mode = MEMORY。您的事务会更快,但如果您在事务期间断电或程序崩溃,您的数据库可能会因事务部分完成而处于损坏状态:
PRAGMA journal_mode = MEMORY
/* Open the database and create the schema */ sqlite3_open(DATABASE, &db); sqlite3_exec(db, TABLE, NULL, NULL, &sErrMsg); sqlite3_exec(db, "PRAGMA journal_mode = MEMORY", NULL, NULL, &sErrMsg);
13.50秒导入864913条记录
比之前的优化慢一点,每秒 64,000 次插入。
让我们结合前两个优化。这有点风险(在崩溃的情况下),但我们只是在导入数据(不是经营银行):
/* Open the database and create the schema */ sqlite3_open(DATABASE, &db); sqlite3_exec(db, TABLE, NULL, NULL, &sErrMsg); sqlite3_exec(db, "PRAGMA synchronous = OFF", NULL, NULL, &sErrMsg); sqlite3_exec(db, "PRAGMA journal_mode = MEMORY", NULL, NULL, &sErrMsg);
12.00 秒导入 864913 条记录
极好的!我们每秒可以进行 72,000 次插入。
只是为了好玩,让我们在之前的所有优化的基础上重新定义数据库文件名,以便我们完全在 RAM 中工作:
#define DATABASE ":memory:"
10.94秒导入864913条记录
将我们的数据库存储在 RAM 中并不是很实用,但令人印象深刻的是我们每秒可以执行 79,000 次插入。
虽然不是专门的 SQLite 改进,但我不喜欢循环char*中的额外赋值操作。while让我们快速重构该代码以将 的输出strtok()直接传递到sqlite3_bind_text(),并让编译器尝试为我们加快速度:
char*
while
strtok()
sqlite3_bind_text()
pFile = fopen (INPUTDATA,"r"); while (!feof(pFile)) { fgets (sInputBuf, BUFFER_SIZE, pFile); sqlite3_bind_text(stmt, 1, strtok (sInputBuf, "\t"), -1, SQLITE_TRANSIENT); /* Get Route */ sqlite3_bind_text(stmt, 2, strtok (NULL, "\t"), -1, SQLITE_TRANSIENT); /* Get Branch */ sqlite3_bind_text(stmt, 3, strtok (NULL, "\t"), -1, SQLITE_TRANSIENT); /* Get Version */ sqlite3_bind_text(stmt, 4, strtok (NULL, "\t"), -1, SQLITE_TRANSIENT); /* Get Stop Number */ sqlite3_bind_text(stmt, 5, strtok (NULL, "\t"), -1, SQLITE_TRANSIENT); /* Get Vehicle */ sqlite3_bind_text(stmt, 6, strtok (NULL, "\t"), -1, SQLITE_TRANSIENT); /* Get Date */ sqlite3_bind_text(stmt, 7, strtok (NULL, "\t"), -1, SQLITE_TRANSIENT); /* Get Time */ sqlite3_step(stmt); /* Execute the SQL Statement */ sqlite3_clear_bindings(stmt); /* Clear bindings */ sqlite3_reset(stmt); /* Reset VDBE */ n++; } fclose (pFile);
注意:我们回到使用真正的数据库文件。内存数据库很快,但不一定实用
8.94秒导入864913条记录
对参数绑定中使用的字符串处理代码进行轻微重构,我们可以每秒执行 96,700 次插入。我认为可以肯定地说这很快。当我们开始调整其他变量(即页面大小、索引创建等)时,这将是我们的基准。
我希望你还在我身边!我们开始走这条路的原因是 SQLite 的批量插入性能变化如此之大,并且需要进行哪些更改来加快我们的操作并不总是很明显。使用相同的编译器(和编译器选项)、相同版本的 SQLite 和相同的数据,我们优化了我们的代码和 SQLite 的使用,从每秒 85 次插入的最坏情况变为每秒超过 96,000 次插入!
在我们开始衡量SELECT性能之前,我们知道我们将创建索引。下面的答案之一建议在进行批量插入时,在插入数据后创建索引更快(而不是先创建索引然后插入数据)。我们试试吧:
SELECT
创建索引然后插入数据
sqlite3_exec(db, "CREATE INDEX 'TTC_Stop_Index' ON 'TTC' ('Stop')", NULL, NULL, &sErrMsg); sqlite3_exec(db, "BEGIN TRANSACTION", NULL, NULL, &sErrMsg); ...
18.13秒导入864913条记录
插入数据然后创建索引
... sqlite3_exec(db, "END TRANSACTION", NULL, NULL, &sErrMsg); sqlite3_exec(db, "CREATE INDEX 'TTC_Stop_Index' ON 'TTC' ('Stop')", NULL, NULL, &sErrMsg);
13.66秒导入864913条记录
正如预期的那样,如果为一列建立索引,批量插入会更慢,但如果在插入数据之后创建索引,它确实会有所不同。我们的无索引基线是每秒 96,000 次插入。先创建索引再插入数据给我们每秒 47,700 次插入,而先插入数据然后创建索引给我们每秒 63,300 次插入。
我很乐意为其他场景提供建议来尝试......并且很快就会为 SELECT 查询编译类似的数据。
几个提示:
pragma journal_mode
NORMAL
OFF
OFF/MEMORY
PRAGMA page_size
CREATE INDEX
INTEGER PRIMARY KEY
!feof(file)