SQLite è un popolare database relazionale che puoi incorporare nella tua applicazione. Tuttavia, ci sono molte trappole e insidie da evitare. Questo articolo discute diverse insidie (e come evitarle), come l'uso di ORM, come recuperare spazio su disco, tenendo presente il numero massimo di variabili di query, i tipi di dati delle colonne e come gestire interi grandi.
Introduzione
SQLite è un popolare sistema di database relazionali (DB) . Ha un set di funzionalità molto simile ai suoi fratelli maggiori, come MySQL , che sono sistemi basati su client/server. Tuttavia, SQLite è un embedded banca dati . Può essere incluso nel tuo programma come libreria statica (o dinamica). Questo semplifica l'implementazione , poiché non è necessario alcun processo server separato. Le librerie di binding e wrapper ti consentono di accedere a SQLite nella maggior parte dei linguaggi di programmazione .
Ho lavorato a lungo con SQLite durante lo sviluppo di BSync come parte della mia tesi di dottorato. Questo articolo è un elenco (casuale) di trappole e insidie in cui mi sono imbattuto durante lo sviluppo . Spero che li troverai utili ed eviterai di fare gli stessi errori che ho fatto una volta.
Trappole e insidie
Utilizza le librerie ORM con cautela
Le librerie Object-Relational Mapping (ORM) astraggono i dettagli dai motori di database concreti e dalla loro sintassi (come specifiche istruzioni SQL) in un'API di alto livello orientata agli oggetti. Esistono molte librerie di terze parti (vedi Wikipedia). Le librerie ORM presentano alcuni vantaggi:
- Fanno risparmiare tempo durante lo sviluppo , perché mappano rapidamente il tuo codice/le tue classi su strutture di database,
- Sono spesso multipiattaforma , ovvero consentire la sostituzione della tecnologia DB concreta (es. SQLite con MySQL),
- Offrono codice di supporto per la migrazione dello schema .
Tuttavia, hanno anche diversi gravi svantaggi dovresti essere a conoscenza di:
- Fanno apparire lavorare con i database facile . Tuttavia, in realtà, i motori DB hanno dettagli complessi che devi semplicemente conoscere . Quando qualcosa va storto, ad es. quando la libreria ORM genera eccezioni che non comprendi o quando le prestazioni di runtime peggiorano, il tempo di sviluppo risparmiato utilizzando ORM verrà rapidamente consumato dagli sforzi necessari per eseguire il debug del problema . Ad esempio, se non sai cosa indici sono, avresti difficoltà a risolvere i colli di bottiglia delle prestazioni causati dall'ORM, quando non ha creato automaticamente tutti gli indici richiesti. In sostanza:non c'è il pranzo gratis.
- A causa dell'astrazione del fornitore di DB concreto, è difficile accedere alla funzionalità specifica del fornitore, ma non è affatto accessibile .
- C'è qualche sovraccarico di calcolo rispetto alla scrittura e all'esecuzione diretta di query SQL. Tuttavia, direi che questo punto è controverso nella pratica, poiché è normale che perdi le prestazioni una volta che passi a un livello di astrazione più elevato.
Alla fine, l'utilizzo di una libreria ORM è una questione di preferenze personali. Se lo fai, preparati a dover conoscere le stranezze dei database relazionali (e gli avvertimenti specifici del fornitore), una volta che si verificano comportamenti imprevisti o colli di bottiglia delle prestazioni.
Includi una tabella delle migrazioni dall'inizio
Se lo sei non utilizzando una libreria ORM, dovrai occuparti della migrazione dello schema del DB . Ciò comporta la scrittura di codice di migrazione che altera gli schemi delle tabelle e trasforma in qualche modo i dati archiviati. Ti consiglio di creare una tabella chiamata "migrazioni" o "versione", con una singola riga e colonna, che memorizza semplicemente la versione dello schema, ad es. utilizzando un intero monotonicamente crescente. Ciò consente alla funzione di migrazione di rilevare quali migrazioni devono ancora essere applicate. Ogni volta che un passaggio di migrazione è stato completato correttamente, il codice degli strumenti di migrazione incrementa questo contatore tramite un UPDATE
Istruzione SQL.
Colonna rowid creata automaticamente
Ogni volta che crei una tabella, SQLite creerà automaticamente un INTEGER
colonna denominata rowid
per te – a meno che tu non abbia fornito il WITHOUT ROWID
clausola (ma è probabile che tu non sapessi di questa clausola). Il rowid
riga è una colonna della chiave primaria. Se specifichi anche tu stesso una tale colonna di chiave primaria (ad esempio usando la sintassi some_column INTEGER PRIMARY KEY
) questa colonna sarà semplicemente un alias per rowid
. Vedi qui per ulteriori informazioni, che descrivono la stessa cosa con parole piuttosto criptiche. Nota che una tabella SELECT * FROM table
dichiarazione non includi rowid
per impostazione predefinita:devi chiedere il rowid
colonna in modo esplicito.
Verifica che PRAGMA
funziona davvero
Tra le altre cose, PRAGMA
le istruzioni vengono utilizzate per configurare le impostazioni del database o per invocare varie funzionalità (documenti ufficiali). Tuttavia, ci sono effetti collaterali non documentati in cui a volte l'impostazione di una variabile in realtà non ha alcun effetto . In altre parole, non funziona e si guasta silenziosamente.
Ad esempio, se emetti le seguenti dichiarazioni nell'ordine indicato, l'ultimo dichiarazione non avere alcun effetto. Variabile auto_vacuum
ha ancora valore 0
(NONE
), senza una buona ragione.
PRAGMA journal_mode = WAL
PRAGMA synchronous = NORMAL
PRAGMA auto_vacuum = INCREMENTAL
Code language: SQL (Structured Query Language) (sql)
Puoi leggere il valore di una variabile eseguendo PRAGMA variableName
e omettendo il segno di uguale e il valore.
Per correggere l'esempio precedente, utilizzare un ordine diverso. Utilizzando l'ordinamento delle righe 3, 1, 2 funzionerà come previsto.
Puoi anche voler includere tali controlli nella tua produzione codice, perché questi effetti collaterali possono dipendere dalla versione concreta di SQLite e da come è stata creata. La libreria utilizzata nella produzione potrebbe differire da quella utilizzata durante lo sviluppo.
Rivendicazione dello spazio su disco per database di grandi dimensioni
Per impostazione predefinita, la dimensione di un file di database SQLite è crescente monotonicamente . L'eliminazione di righe contrassegna solo pagine specifiche come libere , in modo che possano essere usati per INSERT
dati in futuro. Per recuperare effettivamente spazio su disco e per velocizzare le prestazioni, sono disponibili due opzioni:
- Esegui
VACUUM
dichiarazione . Tuttavia, questo ha diversi effetti collaterali:- Blocca l'intero DB. Nessuna operazione simultanea può aver luogo durante il
VACUUM
operazione. - Ci vuole molto tempo (per database più grandi), perché internamente si ricrea il DB in un file temporaneo separato e, infine, elimina il database originale, sostituendolo con quel file temporaneo.
- Il file temporaneo consuma ulteriori spazio su disco mentre l'operazione è in esecuzione. Pertanto, non è una buona idea eseguire
VACUUM
nel caso in cui lo spazio su disco sia insufficiente. Potresti ancora farlo, ma dovresti controllare regolarmente che(freeDiskSpace - currentDbFileSize) > 0
.
- Blocca l'intero DB. Nessuna operazione simultanea può aver luogo durante il
- Usa
PRAGMA auto_vacuum = INCREMENTAL
durante la creazione il DB. Crea questoPRAGMA
il primo dichiarazione dopo aver creato il file! Ciò consente alcune attività di pulizia interna, aiutando il database a recuperare spazio ogni volta che chiamiPRAGMA incremental_vacuum(N)
. Questa chiamata rivendica fino aN
pagine. I documenti ufficiali forniscono ulteriori dettagli e anche altri possibili valori perauto_vacuum
.- Nota:puoi determinare quanto spazio libero su disco (in byte) verrebbe guadagnato chiamando
PRAGMA incremental_vacuum(N)
:moltiplica il valore restituito perPRAGMA freelist_count
conPRAGMA page_size
.
- Nota:puoi determinare quanto spazio libero su disco (in byte) verrebbe guadagnato chiamando
L'opzione migliore dipende dal tuo contesto. Per file di database molto grandi consiglio l'opzione 2 , perché l'opzione 1 infastidirebbe gli utenti con minuti o ore di attesa per la pulizia del database. L'opzione 1 è adatta per database più piccoli . Il suo ulteriore vantaggio è che le prestazioni del DB migliorerà (che non è il caso dell'opzione 2), perché la ricreazione elimina gli effetti collaterali della frammentazione dei dati.
Attenzione al numero massimo di variabili nelle query
Per impostazione predefinita, il numero massimo di variabili ("parametri host") che puoi utilizzare in una query è hardcoded a 999 (vedi qui, sezione Numero massimo di parametri host in una singola istruzione SQL ). Questo limite può variare, perché è un tempo di compilazione parametro, il cui valore predefinito tu (o chiunque altro abbia compilato SQLite) potresti aver alterato.
Questo è problematico in pratica, perché non è raro che l'applicazione fornisca un elenco (arbitrariamente grande) al motore DB. Ad esempio, se vuoi eseguire la massa-DELETE
(o SELECT
) righe basate, ad esempio, su un elenco di ID. Una dichiarazione come
DELETE FROM some_table WHERE rowid IN (?, ?, ?, ?, <999 times "?, ">, ?)
Code language: SQL (Structured Query Language) (sql)
genererà un errore e non verrà completato.
Per risolvere questo problema, considera i seguenti passaggi:
- Analizza i tuoi elenchi e dividili in elenchi più piccoli,
- Se fosse necessaria una divisione, assicurati di utilizzare
BEGIN TRANSACTION
eCOMMIT
per emulare l'atomicità che avrebbe avuto una singola affermazione . - Assicurati di considerare anche altri
?
variabili che potresti utilizzare nella tua query che non sono correlate all'elenco in entrata (es.?
variabili utilizzate in unORDER BY
condizione), in modo che il totale numero di variabili non supera il limite.
Una soluzione alternativa è l'uso di tabelle temporanee. L'idea è quella di creare una tabella temporanea, inserire le variabili di query come righe e quindi utilizzare quella tabella temporanea in una sottoquery, ad es.
DROP TABLE IF EXISTS temp.input_data
CREATE TABLE temp.input_data (some_column TEXT UNIQUE)
# Insert input data, running the next query multiple times
INSERT INTO temp.input_data (some_column) VALUES (...)
# The above DELETE statement now changes to this one:
DELETE FROM some_table WHERE rowid IN (SELECT some_column from temp.input_data)
Code language: SQL (Structured Query Language) (sql)
Attenzione all'affinità di tipo di SQLite
Le colonne SQLite non sono rigorosamente tipizzate e le conversioni non avvengono necessariamente come ci si potrebbe aspettare. I tipi che fornisci sono solo suggerimenti . SQLite memorizzerà spesso i dati di qualsiasi digita il suo originale digitare e convertire i dati nel tipo della colonna solo nel caso in cui la conversione sia senza perdite. Ad esempio, puoi semplicemente inserire un "hello"
stringa in un INTEGER
colonna. SQLite non si lamenterà né ti avviserà delle mancate corrispondenze di tipo. Al contrario, potresti non aspettarti che i dati restituiti da un SELECT
istruzione di un INTEGER
la colonna è sempre un INTEGER
. Questi suggerimenti sul tipo sono indicati come "affinità del tipo" nel linguaggio SQLite, vedere qui. Assicurati di studiare da vicino questa parte del manuale di SQLite, per comprendere meglio il significato dei tipi di colonna che specifichi durante la creazione di nuove tabelle.
Attenzione ai numeri interi grandi
SQLite supporta firmato Interi a 64 bit , con cui può archiviare o eseguire calcoli. In altre parole, solo numeri da -2^63
a (2^63) - 1
sono supportati, perché basta un bit per rappresentare il segno!
Ciò significa che se prevedi di lavorare con numeri più grandi, ad es. Interi a 128 bit (con segno) o interi a 64 bit senza segno, devi devi converti i dati in testo prima di inserirlo .
L'orrore inizia quando lo ignori e inserisci semplicemente numeri più grandi (come numeri interi). SQLite non si lamenterà e memorizzerà un arrotondato numero invece! Ad esempio, se inserisci 2^63 (che è già al di fuori dell'intervallo supportato), il SELECT
ed il valore sarà 9223372036854776000 e non 2^63=9223372036854775808. Tuttavia, a seconda del linguaggio di programmazione e della libreria di binding che utilizzi, il comportamento potrebbe differire! Ad esempio, l'associazione sqlite3 di Python verifica la presenza di tali overflow di interi!
Non utilizzare REPLACE()
per i percorsi dei file
Immagina di memorizzare percorsi di file relativi o assoluti in un TEXT
colonna in SQLite, ad es. per tenere traccia dei file sul file system effettivo. Ecco un esempio di tre righe:
foo/test.txt
foo/bar/
foo/bar/x.y
Supponiamo di voler rinominare la directory "foo" in "xyz". Quale comando SQL useresti? Questo?
REPLACE(path_column, old_path, new_path)
Code language: SQL (Structured Query Language) (sql)
Questo è quello che ho fatto, finché non sono iniziate ad accadere cose strane. Il problema con REPLACE()
è che sostituirà tutti occorrenze. Se c'era una riga con il percorso “foo/bar/foo/”, allora REPLACE(column_name, 'foo/', 'xyz/')
provocherà il caos, poiché il risultato non sarà "xyz/bar/foo/", ma "xyz/bar/xyz/".
Una soluzione migliore è qualcosa come
UPDATE mytable SET path_column = 'xyz/' || substr(path_column, 4) WHERE path_column GLOB 'foo/*'"
Code language: SQL (Structured Query Language) (sql)
Il 4
riflette la lunghezza del vecchio percorso ("pippo/" in questo caso). Nota che ho usato GLOB
invece di LIKE
per aggiornare solo le righe che iniziano con 'pippo/'.
Conclusione
SQLite è un fantastico motore di database, in cui la maggior parte dei comandi funziona come previsto. Tuttavia, le complessità specifiche, come quelle che ho appena presentato, richiedono ancora l'attenzione di uno sviluppatore. Oltre a questo articolo, assicurati di leggere anche la documentazione ufficiale sugli avvertimenti di SQLite.
Hai riscontrato altri avvertimenti in passato? Se sì, fatemelo sapere nei commenti.