Os riscos de usar UUID como chave primária no SQLite
(andersmurphy.com)- A implementação de chave primária no SQLite faz a ordem de armazenamento físico variar entre tabelas
rowidcomuns e tabelasWITHOUT ROWID, e usar um UUID4 aleatório como índice clusterizado gera rebalanceamento de B-tree e custo extra de paginação - A linha de base com
rowidinteiro fica em cerca de 1 milhão de inserções por segundo em inserções em lotes de 1 milhão de linhas, enquanto UUID4 WITHOUT ROWID registrou tempos de inserção 14 a 16 vezes mais lentos - A característica não ordenada do UUID4 faz as linhas serem inseridas aleatoriamente na B-tree, e o profiling mostrou mais tempo gasto com balanceamento da árvore e operações de leitura e escrita
- UUID7 WITHOUT ROWID usa UUIDs ordenados por tempo para reduzir o problema de ordenação do UUID4, mostrando tempos de inserção mais razoáveis, mas ainda é mais lento que uma chave inteira de 8 bytes por usar uma chave BLOB de 16 bytes
- UUID4 WITH ROWID aproveita a sequencialidade do
rowidoculto, mas a amplificação de escrita causada por dois índices e o custo de inserções aleatórias no índice permanecem, resultando em desempenho inferior ao de UUID7 WITHOUT ROWID
O que é um índice clusterizado?
- Um índice clusterizado é o índice que determina a ordem física de armazenamento das linhas de uma tabela
- Como as linhas só podem ser ordenadas fisicamente de uma única forma, cada tabela pode ter apenas um índice clusterizado
- O índice clusterizado é a própria tabela, enquanto índices não clusterizados armazenam apenas a coluna indexada e um ponteiro para a localização dos dados reais da linha
Rowid
- Toda tabela SQLite comum possui uma chave primária implícita de inteiro de 64 bits chamada
rowid - Os dados da tabela são armazenados em uma estrutura B-tree com uma entrada por linha, usando o valor de
rowidcomo chave - Na prática, o
rowidé o índice clusterizado do SQLite, e a ordem física de armazenamento das linhas segue a ordem dorowid
WITHOUT ROWID
- O SQLite oferece suporte a tabelas
WITHOUT ROWID, que não têmrowidimplícito - Em tabelas
WITHOUT ROWID, a chave primária declarada passa a cumprir o papel de índice clusterizado - Tabelas SQLite com
rowidsão implementadas como uma B*-Tree em que todo o conteúdo fica nas folhas, enquanto tabelasWITHOUT ROWIDusam uma B-Tree comum que armazena conteúdo tanto nas folhas quanto nos nós intermediários
Linha de base: chave primária inteira com rowid
- A linha de base mede o tempo de inserção em lotes de 1 milhão de linhas em uma tabela
rowidcomum com a estruturaid INTEGER PRIMARY KEY, data BLOB - O total de linhas na tabela de resultados vai de 10 milhões a 100 milhões, com tempos medidos entre 692ms e 838ms
- O desempenho de referência fica em aproximadamente 1 milhão de inserções por segundo
UUID4 WITHOUT ROWID
- O teste com UUID4 usa uma tabela
WITHOUT ROWIDcom a estruturaid BLOB PRIMARY KEY, data BLOB, inserindo valoresrandom-uuid4-bytescomo chave primária - Na tabela de resultados, os tempos medidos vão de 2649ms com 10 milhões de linhas a 12586ms com 100 milhões de linhas
- O desempenho de inserção ficou de 14 a 16 vezes mais lento que a linha de base com
rowidinteiro
Profiling
- O diffgraph normalizado compara snapshots de profiling de INTEGER e UUID4 e mostra as diferenças em formato de flamegraph
- A visualização normalizada ajusta o número total de amostras dos dois perfis para que a diferença relativa possa ser observada em porcentagens
- Quadros azuis indicam que o tempo daquela função caiu no segundo perfil, o de UUID4, em relação ao INTEGER; quadros vermelhos indicam aumento no UUID4
- A intensidade da cor representa a variação relativa no número de amostras do próprio quadro, ou seja, a mudança no self time delta
- No diffgraph, há mais tempo gasto com rebalanceamento da árvore e operações de leitura e escrita
- Por causa da natureza não ordenada do UUID4, as chaves são organizadas em ordem aleatória, e o SQLite precisa rebalancear continuamente a B-tree
UUID7 WITHOUT ROWID
- UUID7 é um UUID ordenado por tempo, uma abordagem que pode eliminar o problema de ordenação do UUID4
- O teste com UUID7 também foi executado em uma tabela
WITHOUT ROWIDcom a estruturaid BLOB PRIMARY KEY, data BLOB - Na tabela de resultados, os tempos medidos foram de 1372ms com 10 milhões de linhas a 1258ms com 100 milhões de linhas
- UUID7 WITHOUT ROWID voltou a números mais razoáveis do que UUID4 WITHOUT ROWID, mas ainda teve desempenho inferior à linha de base
- A diferença é que a chave primária UUID em BLOB tem 16 bytes, enquanto a chave primária inteira tem 8 bytes
UUID4 WITH ROWID
- O teste com UUID4 WITH ROWID usa a tabela
id BLOB PRIMARY KEY, data BLOBsemWITHOUT ROWID - Nessa configuração, o
rowidoculto é o índice clusterizado, e a vantagem dorowidé a sequencialidade - A desvantagem é que a tabela passa a ter dois índices, com a consequente amplificação de escrita
- Na tabela de resultados, os tempos medidos vão de 2003ms com 10 milhões de linhas a 7119ms com 100 milhões de linhas
- UUID4 WITH ROWID não teve desempenho tão bom quanto UUID7 WITHOUT ROWID, pois, mesmo sem ser o índice clusterizado, a estrutura ainda precisa construir continuamente o índice com inserções aleatórias
Conclusão
- No SQLite, usar UUID como chave primária pode afetar fortemente o desempenho de inserção dependendo do índice clusterizado e da ordenação das chaves
- O problema de UUIDs aleatórios não se limita ao SQLite e também se estende a outros bancos de dados que usam índices clusterizados
- Todo o código do benchmark está disponível no repositório no GitHub
1 comentários
Comentários no Lobste.rs
Bom. Também seria interessante ver números para o caso em que, em uma tabela rowid, a chave inteira não é monotonicamente crescente, mas aleatória
Um ponto importante que o texto não menciona é que o índice primário de tabelas rowid é uma árvore B+ e tabelas
without rowidusam árvore BEntão, em geral, quando o tamanho médio do registro ultrapassa certo limiar, a segunda opção deixa de ser ideal. Isso porque os nós internos do índice armazenam o registro inteiro e, se bem me lembro, o manual do SQLite sugere como regra prática 1/20 do tamanho da página
Dedicaram bastante esforço para medir o desempenho de UUID, mas não consideraram chaves naturais
Seja inteiro, UUID ou outra forma, chaves substitutas adicionam complexidade, não acrescentam informação e escondem erros de normalização
O autor cita “evitar duplicatas” como motivo para usar UUID, mas isso não é motivo. Toda linha de toda tabela em todo banco de dados deve ter pelo menos uma chave, isto é, um conjunto de colunas que a identifique de forma única
Um banco sem essa chave contém informação duplicada e fica vulnerável a inconsistências lógicas. Se essa chave já existe, não há necessidade de chave substituta. Se a chave natural já existe e é imposta, dizer que “é necessário por desempenho” implica custo extra e complexidade desnecessária, então isso precisa ser demonstrado
Valores que parecem únicos à primeira vista muitas vezes não são tão únicos quanto se espera, ou valores que se supõe imutáveis acabam precisando mudar
Já com uma chave substituta, posso definir a identidade dentro do meu sistema sem depender de como outra pessoa definiu identificabilidade — ou, em geral, deixou de definir direito
Há exceções. Se eu mesmo defini todo o modelo e os dados não vêm do chamado mundo real, a chave natural faz mais sentido. Mas, ao projetar um esquema para dados do mundo real que provavelmente nunca foram totalmente normalizados, chaves substitutas costumam ser úteis
Poder referenciar qualquer linha com um inteiro simplifica bastante o acesso, e também é fácil para humanos memorizar e usar em consultas
Se for monotonicamente crescente, isso por si só também carrega informação. É informação redundante, mas ainda assim é um fato
A velocidade de busca também é otimizada. É um caso quase ideal para indexação com árvores B, bitmaps etc.
Acho que as pessoas usam UUID principalmente por confusão. Normalmente a lógica é ofuscar a chave e torná-la imprevisível, mas desisti de tentar entender por que acham que isso deve ser imposto até à chave primária, em vez de usar um identificador separado para esse objetivo
UUID versão 7 tem um timestamp de 48 bits no início, então não fica distribuído aleatoriamente desse jeito. Portanto, também deve reduzir paginação excessiva e rebalanceamento
As pessoas realmente usam UUID como chave primária? Quando é preciso UUID, qual é a vantagem de fazer isso em vez de mantê-lo como chave secundária?
GUIDs e UUIDs resolvem esse problema por construção
v1 e v6 codificam ID da máquina e timestamp, então se aproximam de inteiros autoincrementais com um namespace por máquina
Muita confusão vem do fato de muita gente assumir que UUID é aleatório. Isso só vale para v4 e, infelizmente, escolher v4 tem custo
Muitas vezes é preciso algum grau de determinismo, como em v3, v5 e v7, para organizar os dados ou por garantias de desempenho e colisão