CPF/CNPJ: estudo de performance em banco de dados, devemos armazenar um inteiro mesmo?
Com a nova mudança no formato do CNPJ, temos uma discussão que voltou em alta. Lá vem o coitado que armazena documento em formato numérico. Porém, essa é uma guerra muito antiga e todo mundo já conhece.
Curiosamente esse movimento me lembrou de quando fiz engenharia de software, há mais de 15 anos atrás. Uma das primeiras matérias foi "Introdução a Banco de Dados". Naquela época, já havia a mesma discussão.
Um dos trabalhos do período, inclusive, foi montar uma tese em relação a performance de diferentes dados. E eu acabei fazendo a exploração de vários tipos de jeitos de armazenar um CPF em uma base de dados. Não achei a tese original, contudo uso a solução dela até hoje e consegui montar uma linha de raciocínio parecida.
Bora descobrir finalmente qual é o melhor jeito de armazenar esse tipo de dado!
Metodologia
Antes de qualquer coisa, preciso criar uma hipótese. Uma das coisas mais benéficas do ambiente acadêmico é o aprendizado sobre construção de testes a partir de uma hipótese e a importância de utilizar alguma metodologia para conduzir seu estudo. Não basta que eu diga "VARCHAR é melhor", preciso provar.
A minha hipótese é “não há nada que justifique armazenar CPFs como inteiro e não é a melhor opção disponível”. A metodologia que vou adotar para tentar sustentar essa hipótese consiste nas seguintes etapas:
- Gerar uma amostragem com 2 milhões de CPFs aleatórios que não se repetem;
- Coletar alguns CPFs de controle (aleatórios) dentro da amostragem;
- Montar diferentes esquemas de tabela e inserir os mesmos CPFs gerados em cada uma dessas tabelas;
- Executar uma query de seleção para cada um dos CPFs de controle e extrair alguns indicadores de performance.
Os esquemas de tabela adotados:
table_a
: Uma única colunacpf
do tipoBIGINT
(inteiro sem índice);table_b
: Uma única colunacpf
do tipoBIGINT INDEX
(inteiro com índice);table_c
: Uma única colunacpf
do tipoCHAR(11)
(CHAR
sem índice);table_d
: Uma única colunacpf
do tipoCHAR(11) + INDEX
(CHAR
com índice);table_e
: Uma única colunacpf
do tipoVARCHAR(11)
(VARCHAR
sem índice);table_f
: Uma única colunacpf
do tipoVARCHAR(11) + INDEX
(VARCHAR
com índice);table_g
: Uma única colunacpf
do tipoCHAR(11)
e uma colunacrc32
do tipoBIGINT GENERATED ALWAYS AS (CRC32(cpf)) STORED
(CRC32
sem índice);table_h
: Uma única colunacpf
do tipoCHAR(11)
e uma colunacrc32
do tipoBIGINT GENERATED ALWAYS AS (CRC32(cpf)) STORED + INDEX
(CHAR
com índice);
Pela forma como banco de dados funcionam com e sem índice, os CPFs de controle precisam estar bem distribuídos para favorecer as melhores condições de cada tabela, mas também forçar as piores condições. Dessa forma:
- 10 CPFs aleatórios foram coletados em um intervalo de 1000 no início da inserção (após 1000 registros);
- 10 CPFs aleatórios foram coletados em um intervalo de 1000 no meio da inserção (após 1 milhão de registros);
- 10 CPFs aleatórios foram coletados em um intervalo de 1000 no final da inserção (antes de 2 milhões de registros);
Os indicadores de performance são:
- Tempo execução das instruções
INSERT
; - Tempo médio de resposta das instruções
SELECT
; - Total em megabytes acumulado por cada tabela + índice;
- Cálculo de operação realizado pelo banco de dados.
⚠️ Aviso: Os testes foram realizados em uma máquina Ubuntu 20.04/Linux com Intel i7-12700K, 64 GB de RAM e MySQL v8.
Primeira Etapa: Geração de CPFs
A primeira etapa é bem simples, gerar 2 milhões de CPFs aleatórios e únicos. A princípio pouco importa para a gente a autenticidade do CPF, uma vez que isso é irrelevante para fins de busca. Logo, é necessário gerar uma string
que possua:
11
caracteres, onde cada um desses caracteres deve variar entre o valor0
e9
;- E uma regra de integridade para evitar a repetição de muitos zeros em sequência (ignorando resultados como
000.001.453-56
, mas aceitando resultados como012.145.002-12
).
Outro ponto necessário é o uso de multithreading. Desse modo, conseguimos otimizar o tempo que irá levar para gerar os 2 milhões de CPFs necessários (do contrário, teria que esperar algum tempinho dependendo da CPU).
- A função
generate_cpf
apenas aplica as regras necessárias para que sejamos capaz de gerar umastring
válida para os testes; - A função
generate_unique_cpfs_process
gera uma quantidade finita de CPFs para aquele processo, sendo essa quantidade uma parte do todo (total/processos); - A função
generate_unique_cpfs
inicia todos os processos, aguarda os CPFs gerados e faz a união desses conjuntos removendo as colisões. Por fim, adiciona mais alguns CPFs após a remoção caso não tenha completado os 2 milhões necessários. - Encerramos armazenando todos esses CPFs em um CSV.
Segunda Etapa: CPFs de Controle
Com os CPFs em mãos, chegou a hora da coleta. Nessa parte, efetuei um processo bem manual. Naveguei até as linhas 1000
, depois 1000000
e por fim 2000000
; então selecionei 1 CPF aleatório iniciado com cada um dos dígitos de 0
a 9
, totalizando 10
CPFs por cada localização. Essa lista foi salva em um arquivo TXT (reserved_cpfs.txt
) um por linha.
Terceira Etapa: Preparação da Base de Dados
Para montagem da base de dados foi utilizado o MySQL, certamente outras bases de dados terão respostas diferentes. A intenção aqui é criar todas as tabelas e depois inserir, antes é preciso que o banco de dados já tenha sido criado com CREATE DATABASE
.
Da mesma forma que anteriormente, temos um problema ao lidar com 2 milhões de registros. A velocidade de inserção para tantos esquemas diferentes pode demorar horas, por tanto, é necessário utilizar novamente multithreading. Esses processos entretanto não afetam o tempo de resposta do banco de dados (que será coletado), uma vez que são interpretados como novas conexões simultâneas.
- Na função
create_tables
estão todos os esquemas de tabelas que serão utilizadas, para cada um deles foi criado uma versão com índice e sem índice (como mencionado anteriormente); - As funções
insert_cpfs_process
einsert_cpfs
tem o objetivo de deixar o processo mais eficiente além de fazer as devidas inserções.
(Ver código-fonte)[https://gist.github.com/caiquearaujo/a7599da5801f1163776d274e46e012d1]
Quarta Etapa: Teste de Performance
Depois de preparar todo o setup é hora de executar as queries. Os dados que são coletados aqui serão salvos em um arquivo JSON. Para cada uma das tabelas serão executadas 30
queries com os CPFs de controle, mais uma de uma instrução para obter o tamanho da tabela e outra para entender detalhes da lógica utilizada para obter um CPF.
Resultados
Depois de coletar todos esses dados finalmente chegamos aos resultados e a análise deles. Primeiro, vamos destrinchar cada uma das tabelas. Para fins de comparação, todas pegaram o mesmo CPF da última faixa de controle (os últimos 1000
).
Os parâmetros importantes a serem considerados nos resultados a seguir são:
-
query_cost
: Esse valor é uma estimativa do custo total da query em termos de recursos, como I/O e CPU. Um custo mais alto sugere que a query é "cara", o que pode afetar a performance se usada frequentemente; -
access_type
: O tipo de varredura que foi escolhida pelo MySQL para acessar a tabela. Aqui conseguimos identificar se o índice foi utilizado ou não. -
rows_examined_per_scan
: O número estimado de linhas que o MySQL precisará examinar durante a varredura completa antes de aplicar a filtragemWHERE
; -
read_cost
: O custo estimado de leitura ao fazer a varredura completa da tabela; -
eval_cost
: O custo estimado de avaliar o filtroWHERE
em cada linha; -
prefix_cost
: A soma deread_cost
eeval_cost
, o custo total para a execução da query; -
data_read_per_join
: A quantidade de dados esperada para ser lida e processada por esse join.
table_a, BIGINT, sem índice
Para localizar o CPF na table_a
, o MySQL está realizando uma varredura quase completa (1.465.034
de registros lidos antes da filtragem). Nem é necessário comentar que o custo dessa query é absurdo e provavelmente irá degradar bastante o tempo de resposta.
table_b, BIGINT, com índice
Em comparação com a anterior, deu para notar que a table_b
é bem mais eficiente. O uso de índice em números inteiros faz a total diferença. Apenas 16
registros precisaram ser lidos para encontrar o CPF desejado. Ademais, nenhum problema de performance é indicado nem de longe.
table_c, CHAR, sem índice
Como esperado para a table_c
usar o CHAR
sem índice é 33% pior do que usar inteiro sem índice. Totalmente descartado como opção.
table_d, CHAR, com índice
Em relação a complexidade da query, tivemos o mesmo custo de uma query de inteiros com índice. Porém, tivemos um aumento de 200% de dados lidos para encontrar o resultado. O que pode indicar que alguns CPFs, podem custar um pouquinho mais do que outros. Embora o custo não preocupe, quanto mais queries forem sendo executadas, a quantidade de dados lidos poderá afetar mais o desempenho do que em comparação com a table_b
.
table_e, VARCHAR, sem índice
É o mesmo cenário da table_c
, o VARCHAR
sem índice consegue perder do CHAR
em dados lidos. Um desempenho 11% pior, porque ele precisou ler 1 milhão de registros a mais antes de encontrar o resultado.
table_f, VARCHAR, com índice
O VARCHAR
com índice tem o mesmo desempenho do que a table_d
(CHAR
com índice). Algo já esperado, pois, a estrutura de busca indexada deles é muito parecida. Nesse caso, a escolha entre esses dois tipos de dados tem exatamente haver com o tipo de dado que você está armazenando, sendo que com VARCHAR
você vai gastar alguns bytes
a mais.
table_g, CHAR + CRC32, sem índice
Dentre os dados preliminares, está eleita a pior estrutura de todas. Porém, aqui tem algumas coisas bem curiosas sobre a forma de operação das queries. Veja:
- Embora a instrução SQL tenha sido montada como
SELECT * FROM {table} WHERE crc32 = CRC32(%s) AND cpf = %s
, o MySQL ignorou a ordem descrita na query (executou((cpfs_data.table_g.cpf = '99786051285') and (cpfs_data.table_g.crc32 = 946011781))
). Isso aconteceu pois como nenhuma otimização foi calculada, ele seguiu a ordem das colunas na tabela. PS: É importante definir bem a ordenação das colunas ou uma delas (CHAR
, no caso) pode desacelerar toda a query; - Outro ponto interessante é que, embora o desempenho seja horrível, a inclusão da coluna
crc32
reduziu consideravelmente a quantidade de registros lidos (para 1 milhão) comparado as demais queries sem índices. Será que existe uma oportunidade aqui para encontrar uma solução adequada?
table_h, CHAR + CRC32, com índice
A análise dessa tabela é complicada. Se levarmos em consideração o custo da query, em comparação as demais com índices, essa estrutura foi 0,92% pior. Não que seja um número absurdo, mas dado o esforço de gerenciar duas colunas… o suficiente para deixar muitos com pé atrás. Antes de desistir, vamos nos atentar para alguns detalhes adicionais:
- Dessa vez o
crc32
foi aplicado primeiro (e ainda seria independente da ordem da query), afinal ele é um índice disponível para consulta; - Além disso, o check de redundância cíclica de 32 bits tem algumas vantagens: (1) a geração dele não custa tanto esforço; (1) quando gerado, é um inteiro que varia entre
8
e10
dígitos, isso é ainda menos que os11
dígitos do CPF e seria menos ainda que os14
dígitos do CNPJ. O que acaba reduzindo consideravelmente a quantidade de registros lidos (dessa vez, menos de 5); - Por fim, aplicamos o filtro no
CHAR
para sanar colisões que vão acontecer. Concluímos que, por conta disso, essa estratégia exige mais cuidado ao escrever as queries. Sempre as duas colunas devem ser chamadas para pesquisar por um "único" dado.
Tamanho do Armazenamento
Os tempos mudam. Antigamente, um dos grandes objetivos era tornar o espaço de armazenamento eficiente. Em alguns casos, até acima da performance. Armazenar era caro e cada gigabyte mais poderia custar milhares de dólares a uma empresa. Hoje em dia não é assim.
Hoje competimos pelo custo de processamento, não pelo custo de armazenamento. Você consegue ter um grande volume de dados armazenado por quase nada. Ainda sim, temos que apresentar a realidade. Dê uma olhada no gráfico a seguir:
Em uma observação inicial vemos uma escada nos dois grupos (sem e com índice). Mas, observando os micro dados, olhem que interessante:
- A versão de inteiro com índice é 60.87% maior do que a versão sem;
- A versão de
CHAR
com índice é 57.81% maior; - A versão de
VARCHAR
com índice é 55.81% maior; - E, a versão CRC32 com índice é apenas 44.30% maior.
Ainda sim é importante considerar que a versão de inteiro é a que ocupa menos espaço. Mas, ao colocar a versão de inteiro com índice em comparação com a versão de CRC32 com índice só existe uma diferença de 23.24%. Quando somado os dois crescimentos, a razão entre essas versão é a mesma 1.6x em relação ao dado original. Na prática, em termos de armazenamento, CRC32 com índice tem o maior peso, mas não é tão substancialmente maior do que a versão mais econômica.
Tempo Médio de Resposta
Enquanto armazenamento está mais acessível, a capacidade de processamento é bem mais disputada. Além disso, o tempo de resposta é um dos fatores que mais impacta o usuário final. O risco de perder receita sempre está na ponta final. Ninguém quer passar segundos esperando a resolução do seu CPF.
Novamente, dê uma olhada no gráfico a seguir:
Nesse caso, o gráfico é alto explicativo... e a certeza absoluta é NÃO deixe de usar índices sempre para consultas filtradas. O impacto na performance já era evidente nas análises da query. Um tipo de query assim pode travar todo o sistema, afinal aqui ainda estamos em um ambiente controlado.
Outro ponto curioso é a performance do CHAR
com índice, ele consegue ser melhor que inteiros e VARCHAR
indexados. A delimitação de bytes (11
) aqui ajuda bastante a ganhar alguma performance. Mas impressionante mesmo é a velocidade o CRC32 com índice, a quantidade de dados que ele precisa ler para encontrar uma resposta é tão insignificante que o retorno é quase imediato.
Tempo Médio de Inserção
Em relação as inserções, todas aquelas que tem índices sofreram mais o impacto. O fato da mais eficiente ser o VARCHAR
sem índice é um benefício da sua alocação dinâmica, enquanto o CHAR
sem índice é o oposto. Considerando que devemos ignorar a partir de agora as tabelas sem índice, pouco importa.
Agora o que deve ter chamado a sua atenção, despertado o seu olhar, foi o custo de inserção de um CRC32 com índice. Confesso que utilizei uma estratégia "preguiçosa", o tal BIGINT GENERATED ALWAYS AS (crc32(cpf)) STORED
. Se ao invés de calcular em tempo de inserção, já tivesse inserido o CRC32 na query teríamos um ganho aqui. Será que o custo alto de resposta na hora da inserção (que acontece uma vez por CPF) é um problema?
Considerações Finais
Já que você leu até aqui está na hora de concluir. Considerando as observações e análises detalhadas, acredito que é válido dizer que:
- O uso de índices é essencial para manter a eficiência das consultas, especialmente em bases de dados de grande volume;
- Antes armazenar CPF como inteiro poupava armazenamento enquanto mantinha um bom tempo de resposta, hoje não há nada que justifique e, conforme minha hipótese, CRC32 consegue ser uma melhor opção pelo conjunto de benefícios trazidos.
A minha defesa é que a comparação entre os tipos de armazenamento indica que o uso de CRC32
com índice, embora levemente mais custoso em termos de armazenamento, tem uma performance superior em consultas, especialmente para buscas específicas e recorrentes. E acredito que atualmente esse é o maior objetivo da maioria dos sistemas.
Entretanto, confesso que o custo de inserção é um fator a ser ponderado. Alternativas como pré-calcular o valor do CRC32
antes da inserção podem minimizar esse impacto e aumentar a eficiência geral do sistema. No fim, a escolha entre as opções testadas deve, portanto, considerar a frequência das consultas vs a necessidade de inserção constante.
E para vocês CPF ainda deve ser inteiro?