Optimiser SQLite est délicat. Les performances d'insertion en bloc d'une application C peuvent varier de 85 insertions par seconde à plus de 96 000 insertions par seconde!
Arrière-plan: Nous utilisons SQLite dans le cadre d'une application de bureau. Nous avons de grandes quantités de données de configuration stockées dans des fichiers XML qui sont analysés et chargés dans une base de données SQLite pour un traitement ultérieur lors de l'initialisation de l'application. SQLite est idéal pour cette situation car il est rapide, il ne nécessite aucune configuration spécialisée et la base de données est stockée sur le disque dans un fichier unique.
Justification: Au départ, j’étais déçu de la performance que je voyais. Il s’avère que la performance de SQLite peut varier considérablement (à la fois pour les insertions en bloc et pour les sélections) en fonction de la configuration de la base de données et de l'utilisation de l'API. Ce n’était pas une mince affaire de savoir quelles étaient toutes les options et techniques, j’ai donc jugé prudent de créer cette entrée du wiki de la communauté afin de partager les résultats avec les lecteurs de Stack Overflow afin d’épargner aux autres le problème des mêmes enquêtes.
L'expérience: Plutôt que de simplement parler de conseils de performance au sens général (c'est-à-dire "Utilisez une transaction!" ), je pensais qu'il était préférable d'écrire du code C et réellement mesurer l'impact de diverses options. Nous allons commencer avec quelques données simples:
Ecrivons du code!
Le code: Un simple programme en C qui lit le fichier texte ligne par ligne, divise la chaîne en valeurs et insère ensuite les données dans un fichier SQLite. base de données. Dans cette version "baseline" du code, la base de données est créée, mais nous n'insérerons pas de données:
/*************************************************************
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;
}
L'exécution du code en l'état n'effectue pas d'opération de base de données, mais cela nous donnera une idée de la rapidité des opérations d'E/S de fichier C brut et de traitement de chaîne.
864913 enregistrements importés en 0.94 secondes
Génial! Nous pouvons réaliser 920 000 insertions par seconde, à condition de ne pas en faire: -)
Nous allons générer la chaîne SQL en utilisant les valeurs lues dans le fichier et appeler cette opération SQL en utilisant sqlite3_exec:
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);
Cela va être lent car le code SQL sera compilé dans le code VDBE pour chaque insertion et chaque insertion aura lieu dans sa propre transaction. combien lent?
Importé 864913 enregistrements en 9933.61 secondes
Beurk! 2 heures et 45 minutes! C'est seulement 85 insertions par seconde.
Par défaut, SQLite évaluera chaque instruction INSERT/UPDATE dans une transaction unique. Si vous effectuez un grand nombre d’insertions, il est conseillé d’envelopper votre opération dans une transaction:
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);
Importé 864913 enregistrements en 38.03 secondes
C'est mieux. Le simple fait de regrouper toutes nos insertions dans une seule transaction a amélioré notre performance à 23 000 insertions par seconde.
L'utilisation d'une transaction était une énorme amélioration, mais recompiler l'instruction SQL pour chaque insertion n'a pas de sens si nous utilisons le même SQL over-and-over. Utilisons sqlite3_prepare_v2
pour compiler notre instruction SQL une fois, puis lions nos paramètres à cette instruction à l'aide de 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;
Importé 864913 enregistrements en 16.27 secondes
Agréable! Il y a un peu plus de code (n'oubliez pas d'appeler sqlite3_clear_bindings
et sqlite3_reset
)), mais nous avons plus que doublé notre performance à 53 000 insertions par seconde.
Par défaut, SQLite s'interrompt après avoir émis une commande d'écriture au niveau du système d'exploitation. Cela garantit que les données sont écrites sur le disque. En définissant synchronous = OFF
, nous demandons à SQLite de simplement transférer les données au système d'exploitation pour l'écriture, puis de continuer. Il est possible que le fichier de base de données soit corrompu si l'ordinateur subissait un crash catastrophique (ou une panne de courant) avant que les données ne soient écrites sur le plateau:
/* 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);
Importé 864913 enregistrements en 12.41 secondes
Les améliorations sont maintenant moins importantes, mais nous avons jusqu'à 69 600 insertions par seconde.
Pensez à stocker le journal d'annulation en mémoire en évaluant PRAGMA journal_mode = MEMORY
. Votre transaction sera plus rapide, mais si vous perdez le courant ou si votre programme se bloque pendant une transaction, votre base de données pourrait être corrompue avec une transaction partiellement complétée:
/* 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);
864913 enregistrements importés en 13,50 secondes
Un peu plus lent que l'optimisation précédente à 64 000 insertions par seconde.
Combinons les deux optimisations précédentes. C'est un peu plus risqué (en cas de crash), mais nous importons simplement des données (sans gérer de banque):
/* 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);
Importé 864913 enregistrements en 12.00 secondes
Fantastique! Nous sommes en mesure de faire 72 000 insertions par seconde.
Juste pour le plaisir, construisons toutes les optimisations précédentes et redéfinissons le nom de fichier de la base de données afin que nous travaillions entièrement en RAM:
#define DATABASE ":memory:"
864913 enregistrements importés en 10.94 secondes
Ce n'est pas très pratique de stocker notre base de données dans la RAM, mais il est impressionnant de pouvoir effectuer 79 000 insertions par seconde.
Bien qu’il ne s’agisse pas d’une amélioration spécifique à SQLite, je n’aime pas les opérations d’affectation supplémentaires char*
dans la boucle while
. Refactorisons rapidement ce code pour passer le résultat de strtok()
directement dans sqlite3_bind_text()
, et laissez le compilateur essayer d'accélérer les choses:
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);
Remarque: Nous revenons à utiliser un fichier de base de données réel. Les bases de données en mémoire sont rapides, mais pas nécessairement pratiques
864913 enregistrements importés en 8.94 secondes
Un léger remaniement du code de traitement de chaîne utilisé dans notre liaison de paramètres nous a permis d’effectuer 96 700 insertions par seconde. Je pense qu’il est prudent de dire que c’est sûr beaucoup rapide . Lorsque nous commencerons à modifier d'autres variables (taille de la page, création d'index, etc.), ce sera notre référence.
J'espère que vous êtes toujours avec moi! La raison pour laquelle nous avons commencé dans cette voie est que les performances d'insertion en bloc varient énormément avec SQLite, et les modifications à apporter ne sont pas toujours évidentes. fait pour accélérer notre opération. En utilisant le même compilateur (et les mêmes options), la même version de SQLite et les mêmes données, nous avons optimisé notre code et notre utilisation de SQLite pour qu'elle corresponde au pire scénario de 85 insertions. par seconde à plus de 96 000 insertions par seconde!
Avant de commencer à mesurer les performances SELECT
, nous savons que nous allons créer des indices. L'une des réponses ci-dessous suggère que, lors de l'insertion en bloc, il est plus rapide de créer l'index une fois les données insérées (par opposition à la création de l'index avant l'insertion des données). Essayons:
Créez un index puis insérez des données
sqlite3_exec(db, "CREATE INDEX 'TTC_Stop_Index' ON 'TTC' ('Stop')", NULL, NULL, &sErrMsg);
sqlite3_exec(db, "BEGIN TRANSACTION", NULL, NULL, &sErrMsg);
...
Importé 864913 enregistrements en 18.13 secondes
Insérer des données puis créer un index
...
sqlite3_exec(db, "END TRANSACTION", NULL, NULL, &sErrMsg);
sqlite3_exec(db, "CREATE INDEX 'TTC_Stop_Index' ON 'TTC' ('Stop')", NULL, NULL, &sErrMsg);
864913 enregistrements importés en 13.66 secondes
Comme prévu, les insertions en bloc sont plus lentes si une colonne est indexée, mais cela change en revanche si l'index est créé après l'insertion des données. Notre ligne de base sans index est de 96 000 insertions par seconde. La création de l'index d'abord, puis l'insertion de données nous donne 47 700 insertions par seconde, tandis que l'insertion de ces données d'abord, puis la création de l'index nous donne 63 300 insertions par seconde.
Je serais heureux de recevoir des suggestions d'essais sur d'autres scénarios ... Et je compilerai bientôt des données similaires pour les requêtes SELECT.
Plusieurs astuces:
pragma journal_mode
). Il y a NORMAL
, puis OFF
, ce qui peut considérablement augmenter la vitesse d'insertion si vous ne craignez pas trop que la base de données ne soit corrompue en cas de panne du système d'exploitation. Si votre application plante, les données devraient être correctes. Notez que dans les versions plus récentes, les paramètres OFF/MEMORY
ne sont pas sûrs pour les plantages au niveau de l'application.PRAGMA page_size
). Avoir des tailles de page plus grandes peut rendre les lectures et les écritures un peu plus rapides car les pages plus grandes sont conservées en mémoire. Notez que plus de mémoire sera utilisée pour votre base de données.CREATE INDEX
après avoir effectué toutes vos insertions. Cela est nettement plus rapide que la création de l'index, puis la création de vos insertions.INTEGER PRIMARY KEY
, si possible, ce qui remplacera la colonne de numéro de ligne unique implicite de la table.!feof(file)
!Essayez d’utiliser SQLITE_STATIC
au lieu de SQLITE_TRANSIENT
pour ces insertions.
SQLITE_TRANSIENT
obligera SQLite à copier les données de chaîne avant de retourner.
SQLITE_STATIC
lui indique que l'adresse mémoire que vous lui avez donnée sera valable jusqu'à ce que la requête soit effectuée (ce qui est toujours le cas dans cette boucle). Vous éviterez ainsi plusieurs opérations d'allocation, de copie et de désallocation par boucle. Peut-être une grande amélioration.
Évitez sqlite3_clear_bindings(stmt)
.
Le code dans le test définit les liaisons à chaque fois, ce qui devrait suffire.
Le intro de l'API C de la documentation SQLite dit:
Avant d'appeler sqlite3_step () pour la première fois ou immédiatement après sqlite3_reset () , l'application peut appeler les sqlite3_bind () interfaces pour associer des valeurs aux paramètres. Chaque appel à sqlite3_bind () annule les liaisons antérieures sur le même paramètre
Il n'y a rien dans la documentation pour sqlite3_clear_bindings
indiquant que vous devez l'appeler en plus de simplement définir les liaisons.
Plus de détails: Avoid_sqlite3_clear_bindings ()
Inspiré par ce message et par la question du débordement de pile qui m'a amené ici - Est-il possible d'insérer plusieurs lignes à la fois dans une base de données SQLite? - J'ai posté mon premier Git référentiel:
https://github.com/rdpoor/CreateOrUpdate
qui charge en vrac un tableau d'ActiveRecords dans les bases de données MySQL , SQLite ou PostgreSQL . Il inclut une option permettant d'ignorer les enregistrements existants, de les écraser ou de générer une erreur. Mes repères rudimentaires montrent une amélioration de la vitesse 10 fois supérieure à celle des écritures séquentielles - YMMV.
Je l'utilise dans le code de production, où j'ai souvent besoin d'importer de grands ensembles de données, et j'en suis assez content.
Les importations en bloc semblent donner de meilleurs résultats si vous pouvez fractionner vos instructions INSERT/UPDATE. Une valeur de 10.000 ou plus a bien fonctionné pour moi sur une table avec seulement quelques lignes, YMMV ...
Si vous ne vous souciez que de la lecture, une version un peu plus rapide (mais pouvant lire des données obsolètes) consiste à lire à partir de plusieurs connexions de plusieurs threads (connexion par thread).
Commencez par trouver les articles dans le tableau:
SELECT COUNT(*) FROM table
puis lu en pages (LIMIT/OFFSET):
SELECT * FROM table ORDER BY _ROWID_ LIMIT <limit> OFFSET <offset>
où et sont calculés par thread, comme ceci:
int limit = (count + n_threads - 1)/n_threads;
pour chaque fil:
int offset = thread_index * limit
Pour notre petite (200 Mo) db, cela représente une accélération de 50 à 75% (3.8.0.2 64 bits sur Windows 7). Nos tables sont fortement non normalisées (1000-1500 colonnes, environ 100 000 lignes ou plus).
Trop ou trop peu de threads ne suffiront pas, vous devez vous mesurer et vous profiler.
Aussi pour nous, SHAREDCACHE a ralenti la performance, alors je mets manuellement PRIVATECACHE (car il a été activé globalement pour nous)
Je ne peux tirer aucun profit des transactions tant que je n’ai pas augmenté la valeur de cache_size, c’est-à-dire PRAGMA cache_size=10000;
Après avoir lu ce tutoriel, j'ai essayé de l'implémenter dans mon programme.
J'ai 4-5 fichiers qui contiennent des adresses. Chaque fichier contient environ 30 millions d’enregistrements. J'utilise la même configuration que celle que vous proposez, mais mon nombre d'insertions par seconde est très faible (~ 10 000 enregistrements par seconde).
Voici où votre suggestion échoue. Vous utilisez une seule transaction pour tous les enregistrements et une seule insertion sans erreur/échec. Supposons que vous divisez chaque enregistrement en plusieurs insertions sur des tables différentes. Que se passe-t-il si le record est cassé?
La commande ON CONFLICT ne s'applique pas, car si vous avez 10 éléments dans un enregistrement et que vous avez besoin d'insérer chaque élément dans une table différente, si l'élément 5 génère une erreur CONSTRAINT, toutes les 4 insertions précédentes doivent également être supprimées.
Alors voici où le retour en arrière vient. Le seul problème avec la restauration est que vous perdez toutes vos insertions et commencez par le haut. Comment pouvez-vous résoudre ce problème?
Ma solution consistait à utiliser plusieurs transactions. Je commence et termine une transaction tous les 10.000 enregistrements (ne demandez pas pourquoi ce nombre, c’est le plus rapide que j’ai testé). J'ai créé un tableau de taille 10.000 et y ai inséré les enregistrements réussis. Lorsque l'erreur se produit, je fais une restauration, commence une transaction, insère les enregistrements de mon tableau, valide puis commence une nouvelle transaction après l'enregistrement cassé.
Cette solution m'a permis d'éviter les problèmes que je rencontrais lorsque je traitais des fichiers contenant des enregistrements incorrects/en double (j'avais presque 4% d'enregistrements incorrects).
L'algorithme que j'ai créé m'a aidé à réduire mon processus de 2 heures. Processus de chargement final du fichier 1h30, ce qui est encore lent mais pas comparé aux 4 heures qu’il a initialement pris. J'ai réussi à accélérer les inserts de 10 000/s à ~ 14 000/s
Si quelqu'un a d'autres idées sur la manière d'accélérer le processus, je suis ouvert aux suggestions.
UPDATE:
En plus de ma réponse ci-dessus, vous devez garder à l'esprit que les insertions par seconde dépendent du disque dur que vous utilisez également. Je l'ai testé sur 3 ordinateurs différents avec des disques durs différents et j'ai eu des différences énormes dans le temps. PC1 (1h 30m), PC2 (6h) PC3 (14h), alors j'ai commencé à me demander pourquoi cela serait.
Après deux semaines de recherche et de vérification de plusieurs ressources: disque dur, mémoire vive, cache, j'ai découvert que certains paramètres de votre disque dur peuvent affecter le taux d'E/S. En cliquant sur les propriétés du lecteur de sortie souhaité, vous pouvez voir deux options dans l'onglet Général. Opt1: Compressez ce lecteur, Opt2: Autorisez les fichiers de ce lecteur à être indexés.
En désactivant ces deux options, les trois ordinateurs mettent maintenant à peu près le même temps (1 heure et 20 à 40 minutes). Si vous rencontrez des insertions lentes, vérifiez si votre disque dur est configuré avec ces options. Cela vous fera gagner beaucoup de temps et de maux de tête en essayant de trouver la solution
La réponse à votre question est que la nouvelle version de sqlite3 a amélioré les performances, utilisez-la.
Cette réponse Pourquoi SQLAlchemy insère-t-il avec sqlite 25 fois plus lentement que d'utiliser directement sqlite3? par SqlAlchemy Orm Author a 100k insertions en 0.5 seconde et j'ai obtenu des résultats similaires avec python-sqlite et SqlAlchemy. Ce qui me porte à penser que les performances se sont améliorées avec sqlite3