[C] 提高SQLite的每秒插入性能?


Answers

对于这些插入尝试使用SQLITE_STATIC而不是SQLITE_TRANSIENT

SQLITE_TRANSIENT将导致SQLite在返回之前复制字符串数据。

SQLITE_STATIC告诉它你的内存地址是有效的,直到查询完成(在这个循环中总是这样)。 这将为您节省几次每个循环的分配,复制和释放操作。 可能是一个很大的改进。

Question

优化SQLite是非常棘手的。 C应用程序的批量插入性能可以从每秒85次插入到每秒超过96,000次插入!

背景:我们使用SQLite作为桌面应用程序的一部分。 我们有大量的配置数据存储在XML文件中,这些数据被解析并加载到SQLite数据库中,以便在初始化应用程序时进一步处理。 SQLite非常适合这种情况,因为它速度很快,不需要专门的配置,数据库作为单个文件存储在磁盘上。

理由: 最初,我对我所看到的表现感到失望。 事实证明,根据数据库的配置方式以及如何使用API​​,SQLite的性能可能会有很大差异(对于批量插入和选择)。 找出所有的选项和技术并不是一件简单的事情,所以我认为创建这个社区wiki条目与读者分享结果是很明智的,以便为其他人省去相同调查的麻烦。

实验:我认为最好是编写一些C代码并实际测量各种选项的影响,而不是简单地谈论一般意义上的性能提示(即“使用事务!” )。 我们将从一些简单的数据开始:

  • 多伦多市全部交通时间表的28 MB TAB分隔文本文件(约865,000条记录)
  • 我的测试机是运行Windows XP的3.60 GHz P4。
  • 代码使用Visual C ++ 2005作为“发布”与“完全优化”(/ Ox)和优惠快速代码(/ Ot)进行编译。
  • 我使用SQLite“Amalgamation”,直接编译到我的测试应用程序中。 我碰巧遇到的SQLite版本有点旧(3.6.7),但我怀疑这些结果将与最新版本相媲美(如果您不这么认为,请留下评论)。

我们来写一些代码吧!

代码:一个简单的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 over-over,重新编译每个插入的SQL语句没有任何意义。 让我们使用sqlite3_prepare_v2来编译我们的SQL语句一次,然后使用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_bindingssqlite3_reset ),但是我们的性能已经提高了一倍以上,达到每秒53,000次插入。

PRAGMA同步=关闭

默认情况下,SQLite会在发出OS级写入命令后暂停。 这保证了数据被写入磁盘。 通过设置synchronous = OFF ,我们正在指示SQLite将数据简单地交给操作系统进行写入,然后继续。 如果计算机在数据写入磁盘之前遭受灾难性故障(或电源故障),那么数据库文件可能会损坏:

/* 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次插入优化慢一点

PRAGMA synchronous = OFF 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 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次插入。

重构C代码

虽然不是特别的SQLite改进,但我不喜欢while循环中额外的char*赋值操作。 让我们快速重构该代码以将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次插入的最坏情况!

CREATE INDEX,然后INSERT和INSERT,然后CREATE INDEX

在我们开始测量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查询编译类似的数据。




如果你只关心读取,有些更快(但可能读取陈旧的数据)版本是从多个线程(每线程连接)读取多个连接。

首先在表格中找到这些项目:

 SELECT COUNT(*) FROM table

然后读入页面(LIMIT / OFFSET)

  SELECT * FROM table ORDER BY _ROWID_ LIMIT <limit> OFFSET <offset>

在哪里和每个线程计算,如下所示:

int limit = (count + n_threads - 1)/n_threads;

对于每个线程:

int offset = thread_index * limit

对于我们的小型(200mb)db,这使得速度提高了50-75%(Windows 7上的3.8.0.2 64位)。 我们的表格非常规范化(1000-1500列,大约100,000或更多行)。

太多或太少的线程都不会这样做,您需要对自己进行基准测试和描述。

同样对于我们来说,SHAREDCACHE也使性能变慢,所以我手动将PRIVATECACHE(因为它在全球范围内为我们启用)







阅读本教程后,我试图将其实现到我的程序中。

我有4-5个包含地址的文件。 每个文件大约有3000万条记录。 我使用的是与你建议的相同的配置,但是我每秒的INSERT数量很低(每秒约10.000条记录)。

这里是你的建议失败的地方。 您对所有记录使用单个事务,并且单个插入没有错误/失败。 假设您将每条记录拆分为不同表格上的多个插入。 如果记录被破坏会发生什么?

ON CONFLICT命令不适用,因为如果记录中有10个元素,并且您需要将每个元素插入到不同的表中,如果元素5出现CONSTRAINT错误,则以前的所有4个插入都需要执行。

所以这里是回滚的地方。 回滚的唯一问题是你失去了所有的插入,并从顶部开始。 你怎么解决这个问题?

我的解决方案是使用多个事务。 我每10.000条记录开始和结束交易(不要问为什么这个数字,这是我测试过的最快的数字)。 我创建了一个大小为10.000的数组,并在那里插入成功的记录。 发生错误时,我会进行回滚,开始一个事务,从我的数组中插入记录,提交并在破损记录后开始一个新的事务。

这个解决方案帮助我绕过了处理包含坏/重复记录的文件(我几乎有4%的不良记录)时遇到的问题。

我创建的算法帮助我将过程缩短了2个小时。 文件1小时30分钟的最后加载过程仍然缓慢,但与最初花费的4小时相比并不相同。 我设法将插入速度从10.000 / s加速到〜14.000 / s

如果任何人有任何其他想法如何加快它,我接受建议。

更新

除了上面的我的回答,你应该记住,每秒插入取决于你使用的硬盘驱动器。 我使用不同的硬盘驱动器在3台不同的PC上测试过它,并且在不同的时代有很大的差异 PC1(1小时30分钟),PC2(6小时)PC3(14小时),所以我开始想知道为什么会这样。

经过两周的研究和检查多种资源:硬盘,RAM,缓存,我发现硬盘上的一些设置会影响I / O速率。 通过在所需的输出驱动器上单击属性,可以在常规选项卡中看到两个选项。 Opt1:压缩此驱动器,Opt2:允许此驱动器的文件具有索引的内容。

通过禁用这两个选项,所有3台个人电脑现在大约可以完成相同的时间(1小时和20至40分钟)。 如果遇到插入缓慢的问题,请检查您的硬盘是否配置了这些选项。 它将为您节省大量时间和头痛,试图找到解决方案




在批量插入

受这篇文章以及问题的启发, 是否可以在SQLite数据库中一次插入多行? - 我发布了我的第一个Git存储库:

https://github.com/rdpoor/CreateOrUpdate

它将一组ActiveRecords批量加载到MySQL ,SQLite或PostgreSQL数据库中。 它包括忽略现有记录,覆盖它们或引发错误的选项。 与连续写入相比,我的基本基准测试显示速度提高了10倍 - YMMV。

我在生产代码中使用它,我经常需要导入大型数据集,而且我对此非常满意。