SQLite é um banco de dados relacional popular que você incorpora em seu aplicativo. No entanto, existem muitas armadilhas e armadilhas que você deve evitar. Este artigo discute várias armadilhas (e como evitá-las), como o uso de ORMs, como recuperar espaço em disco, cuidar do número máximo de variáveis de consulta, tipos de dados de coluna e como lidar com números inteiros grandes.
Introdução
SQLite é um sistema de banco de dados relacional (DB) popular . Ele tem um conjunto de recursos muito semelhante aos seus irmãos maiores, como o MySQL , que são sistemas baseados em cliente/servidor. No entanto, SQLite é um incorporado banco de dados . Ele pode ser incluído em seu programa como uma biblioteca estática (ou dinâmica). Isso simplifica a implantação , porque nenhum processo de servidor separado é necessário. Bindings e bibliotecas wrapper permitem acessar o SQLite na maioria das linguagens de programação .
Eu tenho trabalhado extensivamente com SQLite enquanto desenvolvia o BSync como parte da minha dissertação de doutorado. Este artigo é uma lista (aleatória) de armadilhas e armadilhas que encontrei durante o desenvolvimento . Espero que você os ache úteis e evite cometer os mesmos erros que cometi uma vez.
Armadilhas e armadilhas
Use bibliotecas ORM com cuidado
As bibliotecas de mapeamento relacional de objeto (ORM) abstraem os detalhes de mecanismos de banco de dados concretos e sua sintaxe (como instruções SQL específicas) para uma API orientada a objetos de alto nível. Existem muitas bibliotecas de terceiros por aí (veja Wikipedia). As bibliotecas ORM têm algumas vantagens:
- Eles economizam tempo durante o desenvolvimento , porque eles mapeiam rapidamente seu código/classes para estruturas de banco de dados,
- Eles são geralmente multiplataforma , ou seja, permitir a substituição da tecnologia de banco de dados concreto (por exemplo, SQLite com MySQL),
- Eles oferecem código auxiliar para migração de esquema .
No entanto, eles também têm várias desvantagens graves você deve estar ciente de:
- Eles fazem o trabalho com bancos de dados aparecer fácil . No entanto, na realidade, os mecanismos de banco de dados têm detalhes complexos que você simplesmente precisa saber . Quando algo der errado, por exemplo, quando a biblioteca ORM gera exceções que você não entende ou quando o desempenho do tempo de execução diminui, o tempo de desenvolvimento que você economizou usando o ORM será rapidamente consumido pelos esforços necessários para depurar o problema . Por exemplo, se você não souber quais índices são, você teria dificuldade em solucionar gargalos de desempenho causados pelo ORM, quando ele não criasse automaticamente todos os índices necessários. Em essência:não existe almoço grátis.
- Devido à abstração do fornecedor de banco de dados concreto, funcionalidade específica do fornecedor é difícil de acessar, ou não acessível .
- Há alguma sobrecarga computacional comparado a escrever e executar consultas SQL diretamente. No entanto, eu diria que esse ponto é discutível na prática, pois é comum que você perca desempenho quando muda para um nível mais alto de abstração.
No final, usar uma biblioteca ORM é uma questão de preferência pessoal. Se você fizer isso, esteja preparado para aprender sobre as peculiaridades dos bancos de dados relacionais (e advertências específicas do fornecedor), assim que ocorrerem comportamentos inesperados ou gargalos de desempenho.
Incluir uma tabela de migrações desde o início
Se você não usando uma biblioteca ORM, você terá que cuidar da migração de esquema do banco de dados . Isso envolve escrever código de migração que altera seus esquemas de tabela e transforma os dados armazenados de alguma forma. Eu recomendo que você crie uma tabela chamada “migrações” ou “versão”, com uma única linha e coluna, que simplesmente armazena a versão do esquema, por exemplo usando um inteiro monotonicamente crescente. Isso permite que sua função de migração detecte quais migrações ainda precisam ser aplicadas. Sempre que uma etapa de migração foi concluída com êxito, seu código de ferramentas de migração incrementa esse contador por meio de um
UPDATE
instrução SQL. Coluna rowid criada automaticamente
Sempre que você criar uma tabela, o SQLite criará automaticamente um
INTEGER
coluna chamada rowid
para você – a menos que você tenha fornecido o WITHOUT ROWID
cláusula (mas é provável que você não saiba sobre esta cláusula). O rowid
linha é uma coluna de chave primária. Se você também especificar uma coluna de chave primária (por exemplo, usando a sintaxe some_column INTEGER PRIMARY KEY
) esta coluna será simplesmente um alias para rowid
. Veja aqui para mais informações, que descrevem a mesma coisa em palavras bastante enigmáticas. Observe que um SELECT * FROM table
declaração não inclua rowid
por padrão - você precisa pedir o rowid
coluna explicitamente. Verifique se PRAGMA
realmente funciona
Entre outras coisas,
PRAGMA
instruções são usadas para definir as configurações do banco de dados ou para invocar várias funcionalidades (documentos oficiais). No entanto, há efeitos colaterais não documentados em que, às vezes, definir uma variável não tem efeito . Em outras palavras, não funciona e falha silenciosamente. Por exemplo, se você emitir as seguintes declarações na ordem indicada, a última declaração não ter qualquer efeito. Variável
auto_vacuum
ainda tem valor 0
(NONE
), sem motivo.
PRAGMA journal_mode = WAL
PRAGMA synchronous = NORMAL
PRAGMA auto_vacuum = INCREMENTAL
Code language: SQL (Structured Query Language) (sql)
Você pode ler o valor de uma variável executando
PRAGMA variableName
e omitindo o sinal de igual e o valor. Para corrigir o exemplo acima, use uma ordem diferente. Usar a ordem de linha 3, 1, 2 funcionará conforme o esperado.
Você pode até querer incluir essas verificações em sua produção código, porque esses efeitos colaterais podem depender da versão concreta do SQLite e de como ela foi construída. A biblioteca usada na produção pode ser diferente daquela usada durante o desenvolvimento.
Reivindicando espaço em disco para bancos de dados grandes
Por padrão, o tamanho de um arquivo de banco de dados SQLite está aumentando monotonicamente . A exclusão de linhas marca apenas páginas específicas como gratuitas , para que possam ser usados para
INSERT
dados no futuro. Para realmente recuperar espaço em disco e acelerar o desempenho, há duas opções: - Execute o
VACUUM
declaração . No entanto, isso tem vários efeitos colaterais:- Ele bloqueia todo o banco de dados. Nenhuma operação simultânea pode ocorrer durante o
VACUUM
operação. - Demora muito tempo (para bancos de dados maiores), porque internamente recria o banco de dados em um arquivo temporário separado e, finalmente, exclui o banco de dados original, substituindo-o por esse arquivo temporário.
- O arquivo temporário consome adicionais espaço em disco enquanto a operação está em execução. Portanto, não é uma boa ideia executar
VACUUM
caso você esteja com pouco espaço em disco. Você ainda pode fazer isso, mas teria que verificar regularmente se(freeDiskSpace - currentDbFileSize) > 0
.
- Ele bloqueia todo o banco de dados. Nenhuma operação simultânea pode ocorrer durante o
- Use
PRAGMA auto_vacuum = INCREMENTAL
ao criar o banco de dados. Faça estePRAGMA
o primeiro declaração depois de criar o arquivo! Isso permite alguma manutenção interna, ajudando o banco de dados a recuperar espaço sempre que você chamarPRAGMA incremental_vacuum(N)
. Esta chamada recupera atéN
Páginas. Os documentos oficiais fornecem mais detalhes e também outros valores possíveis paraauto_vacuum
.- Observação:você pode determinar quanto espaço livre em disco (em bytes) seria ganho ao chamar
PRAGMA incremental_vacuum(N)
:multiplique o valor retornado porPRAGMA freelist_count
comPRAGMA page_size
.
- Observação:você pode determinar quanto espaço livre em disco (em bytes) seria ganho ao chamar
A melhor opção depende do seu contexto. Para arquivos de banco de dados muito grandes, recomendo a opção 2 , porque a opção 1 incomodaria seus usuários com minutos ou horas de espera pela limpeza do banco de dados. A opção 1 é adequada para bancos de dados menores . Sua vantagem adicional é que o desempenho do banco de dados melhorará (o que não é o caso da opção 2), porque a recriação elimina os efeitos colaterais da fragmentação de dados.
Lembre-se do número máximo de variáveis nas consultas
Por padrão, o número máximo de variáveis ("parâmetros de host") que você pode usar em uma consulta é codificado em 999 (veja aqui a seção Número máximo de parâmetros de host em uma única instrução SQL ). Este limite pode variar, pois é um tempo de compilação parâmetro, cujo valor padrão você (ou quem mais compilou o SQLite) pode ter alterado.
Isso é problemático na prática, porque não é incomum que seu aplicativo forneça uma lista (arbitrariamente grande) para o mecanismo de banco de dados. Por exemplo, se você quiser massa-
DELETE
(ou SELECT
) com base em, digamos, uma lista de IDs. Uma afirmação como DELETE FROM some_table WHERE rowid IN (?, ?, ?, ?, <999 times "?, ">, ?)
Code language: SQL (Structured Query Language) (sql)
lançará um erro e não será concluído.
Para corrigir isso, considere as seguintes etapas:
- Analise suas listas e divida-as em listas menores,
- Se uma divisão for necessária, certifique-se de usar
BEGIN TRANSACTION
eCOMMIT
para emular a atomicidade que uma única declaração teria . - Lembre-se de considerar também outros
?
variáveis que você pode usar em sua consulta que não estão relacionadas à lista de entrada (por exemplo,?
variáveis usadas em umORDER BY
condição), para que o total o número de variáveis não excede o limite.
Uma solução alternativa é o uso de tabelas temporárias. A ideia é criar uma tabela temporária, inserir as variáveis de consulta como linhas e, em seguida, usar essa tabela temporária em uma subconsulta, por exemplo,
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)
Cuidado com a afinidade de tipo do SQLite
As colunas SQLite não são estritamente tipadas, e as conversões não ocorrem necessariamente como esperado. Os tipos que você fornece são apenas dicas . O SQLite geralmente armazena dados de qualquer digite seu original type, e apenas converta os dados para o tipo da coluna caso a conversão seja sem perdas. Por exemplo, você pode simplesmente inserir um
"hello"
string em um INTEGER
coluna. O SQLite não reclamará ou avisará sobre incompatibilidades de tipo. Por outro lado, você não pode esperar que os dados sejam retornados por um SELECT
declaração de um INTEGER
coluna é sempre um INTEGER
. Essas dicas de tipo são chamadas de “afinidade de tipo” na linguagem SQLite, veja aqui. Certifique-se de estudar atentamente esta parte do manual SQLite, para entender melhor o significado dos tipos de coluna que você especifica ao criar novas tabelas. Cuidado com números inteiros grandes
SQLite suporta assinado inteiros de 64 bits , com o qual ele pode armazenar ou fazer cálculos. Em outras palavras, apenas números de
-2^63
para (2^63) - 1
são suportados, porque um bit é necessário para representar o sinal! Isso significa que, se você espera trabalhar com números maiores, por exemplo, inteiros de 128 bits (assinados) ou inteiros não assinados de 64 bits, você deve converter os dados para texto antes de inseri-lo .
O horror começa quando você ignora isso e simplesmente insere números maiores (como números inteiros). SQLite não vai reclamar e armazenar um arredondado número em vez disso! Por exemplo, se você inserir 2^63 (que já está fora do intervalo suportado), o
SELECT
valor ed será 9223372036854776000, e não 2^63=9223372036854775808. Dependendo da linguagem de programação e da biblioteca de vinculação que você usa, o comportamento pode ser diferente! Por exemplo, a ligação sqlite3 do Python verifica esses estouros de inteiros! Não use REPLACE()
para caminhos de arquivo
Imagine que você armazena caminhos de arquivos relativos ou absolutos em um
TEXT
coluna no SQLite, por exemplo para acompanhar os arquivos no sistema de arquivos real. Aqui está um exemplo de três linhas:foo/test.txt
foo/bar/
foo/bar/x.y
Suponha que você queira renomear o diretório “foo” para “xyz”. Qual comando SQL você usaria? Este?
REPLACE(path_column, old_path, new_path)
Code language: SQL (Structured Query Language) (sql)
Foi o que fiz, até que coisas estranhas começaram a acontecer. O problema com
REPLACE()
é que ele substituirá todos ocorrências. Se houver uma linha com o caminho “foo/bar/foo/”, então REPLACE(column_name, 'foo/', 'xyz/')
causará estragos, pois o resultado não será “xyz/bar/foo/”, mas “xyz/bar/xyz/”. Uma solução melhor é algo como
UPDATE mytable SET path_column = 'xyz/' || substr(path_column, 4) WHERE path_column GLOB 'foo/*'"
Code language: SQL (Structured Query Language) (sql)
O
4
reflete o comprimento do caminho antigo ('foo/' neste caso). Observe que usei GLOB
em vez de LIKE
para atualizar apenas as linhas que iniciam com 'foo/'. Conclusão
SQLite é um fantástico mecanismo de banco de dados, onde a maioria dos comandos funciona conforme o esperado. No entanto, complexidades específicas, como as que acabei de apresentar, ainda exigem a atenção de um desenvolvedor. Além deste artigo, certifique-se de ler também a documentação oficial de advertências do SQLite.
Você já encontrou outras advertências no passado? Se sim, deixe-me saber nos comentários.