Neste artigo, você aprenderá a usar a semântica por trás de seus dados ao particionar seu banco de dados. Isso pode melhorar drasticamente o desempenho do seu aplicativo. E, o mais importante, você descobrirá que deve adaptar seus critérios de particionamento ao seu domínio de aplicativo exclusivo.
Colaborei com uma startup para desenvolver um aplicativo da web para especialistas em esportes tomarem decisões e explorarem dados. O aplicativo suporta qualquer esporte, mas estamos sediados na Europa - e os europeus adoram futebol. Cada uma das centenas de jogos jogados todos os dias em todo o mundo vem com milhares de linhas. Em apenas alguns meses, a tabela Eventos em nosso aplicativo atingiu meio bilhão de linhas!
Ao entender como os especialistas em futebol consultavam nossos dados, poderíamos particionar o banco de dados de forma inteligente. A melhoria de tempo médio nesta nova mesa foi entre 20x e 40x mais rápida. A melhoria de tempo médio em todas as consultas foi de 5X a 10X.
Vamos agora nos aprofundar nesse cenário e aprender por que você não pode ignorar seu contexto de dados ao particionar um banco de dados.
Apresentando o contexto
Nosso aplicativo esportivo oferece dados brutos e agregados, embora os profissionais que o adotaram prefiram o último. O banco de dados subjacente contém terabytes de dados complexos, não estruturados e heterogêneos de vários provedores. Assim, o maior desafio foi projetar um banco de dados confiável, rápido e fácil de explorar.
Domínio do aplicativo
Neste setor, muitos provedores oferecem aos seus clientes acesso aos eventos dos jogos de futebol mais importantes. Especificamente, eles fornecem dados relacionados ao que aconteceu durante um jogo, como gols, assistências, cartões amarelos, passes e muito mais. A tabela que contém esses dados é de longe a maior com a qual trabalhamos.
Especificações, tecnologias e arquitetura do VPS
Minha equipe está desenvolvendo o aplicativo de back-end que fornece os recursos de exploração de dados mais importantes. Adotamos o Kotlin v1.6 rodando em cima de uma JVM (Java Virtual Machine) como linguagem de programação, Spring Boot 2.5.3 como framework e Hibernate 5.4.32.Final como ORM (Object Relational Mapping). A principal razão pela qual optamos por essa pilha de tecnologia é que a velocidade é um dos requisitos de negócios mais importantes. Portanto, precisávamos de uma tecnologia que pudesse alavancar o processamento multithread pesado, e o Spring Boot acabou sendo uma solução confiável.
Implantamos nosso back-end em um VPS de 16 GB e 8 CPU por meio de um contêiner Docker gerenciado pelo Dokku. Ele pode usar no máximo 15 GB de RAM. Isso ocorre porque um GB de RAM é dedicado a um sistema de cache baseado em Redis. Nós o adicionamos para melhorar o desempenho e evitar sobrecarregar o back-end com operações repetidas.
Banco de dados e estrutura de tabelas
Quanto ao banco de dados, optamos pelo MySQL 8. Atualmente, um VPS de 8 GB e 2 CPUs hospeda o servidor de banco de dados, que suporta até 200 conexões simultâneas. O aplicativo de back-end e o banco de dados estão no mesmo farm de servidores para evitar sobrecarga de comunicação. Projetamos a estrutura do banco de dados para evitar duplicação e com o desempenho em mente. Decidimos adotar um banco de dados relacional porque queríamos ter uma estrutura consistente para converter os dados recebidos dos provedores. Dessa forma, padronizamos os dados esportivos, facilitando sua exploração e apresentação aos usuários finais.
O banco de dados contém centenas de tabelas no momento em que escrevo e não posso apresentá-las todas por causa do NDA que assinei. Felizmente, uma tabela é suficiente para analisar minuciosamente por que acabamos adotando a partição baseada em contexto de dados que você está prestes a ver. O verdadeiro desafio veio quando começamos a realizar consultas pesadas na tabela de Eventos. Mas antes de mergulhar nisso, vamos ver como é a tabela de eventos:
Como você pode ver, não envolve muitas colunas, mas lembre-se de que tive que omitir algumas delas por motivos de confidencialidade. Mas o que realmente importa aqui é o
parameterId
e gameId
colunas. Usamos essas duas chaves estrangeiras para selecionar um tipo de parâmetro (por exemplo, gol, cartão amarelo, passe, pênalti) e os jogos em que isso aconteceu. Problemas de desempenho
A tabela Eventos atingiu meio bilhão de linhas em apenas alguns meses. Como já abordamos em profundidade nesta postagem do blog, o principal problema é que precisamos realizar operações agregadas usando consultas IN lentas. Isso ocorre porque o que acontece durante um jogo não é tão importante. Em vez disso, os especialistas em esportes desejam analisar dados agregados para encontrar tendências e tomar decisões com base nelas.
Além disso, embora geralmente analisem toda a temporada ou os últimos 5 ou 10 jogos, os usuários geralmente desejam excluir alguns jogos específicos de sua análise. Isso ocorre porque eles não querem um jogo particularmente mal ou bem para polarizar seus resultados. Não podemos pré-gerar os dados agregados porque teríamos que fazer isso em todas as combinações possíveis, o que não é viável. Então, temos que armazenar todos os dados e agregá-los rapidamente.
Entendendo o problema de desempenho
Agora, vamos mergulhar no aspecto central que levou aos problemas de desempenho que tivemos que enfrentar.
As tabelas de milhões de linhas são lentas
Se você já lidou com tabelas contendo centenas de milhões de linhas, sabe que elas são inerentemente lentas. Você não pode nem pensar em executar JOINs em tabelas tão grandes. No entanto, você pode realizar consultas SELECT em um período de tempo razoável. Isso é particularmente verdadeiro quando essas consultas envolvem condições WHERE simples. Por outro lado, eles se tornam terrivelmente lentos ao usar funções agregadas ou cláusulas IN. Nesses casos, eles podem facilmente levar até 80 segundos, o que é simplesmente demais.
Os índices não são suficientes
Para melhorar o desempenho, decidimos definir alguns índices. Esta foi a nossa primeira abordagem para encontrar uma solução para os problemas de desempenho. Mas, infelizmente, isso levou a outro problema. Os índices levam tempo e espaço. Isso geralmente é insignificante, mas não ao lidar com tabelas tão grandes. Descobriu-se que definir índices complexos com base nas consultas mais comuns levou várias horas e GBs de espaço. Além disso, os índices são úteis, mas não são mágicos.
Particionamento de banco de dados baseado em contexto de dados como uma solução
Como não conseguimos resolver o problema de desempenho com índices personalizados, decidimos tentar uma nova abordagem. Conversamos com outros especialistas, procuramos soluções online, lemos artigos baseados em cenários semelhantes e, finalmente, decidimos que particionar o banco de dados era a abordagem correta a seguir.
Por que o particionamento tradicional pode não ser a abordagem correta
Antes de particionar todas as nossas maiores tabelas, estudamos o tópico tanto na documentação oficial do MySQL quanto em artigos interessantes. Embora todos concordássemos que esse era o caminho a seguir, também percebemos que aplicar o particionamento sem levar em consideração nosso domínio de aplicativo específico seria um erro. Especificamente, entendemos como era crucial encontrar os critérios adequados ao particionar um banco de dados. Alguns especialistas em particionamento nos ensinaram que a abordagem tradicional é particionar pelo número de linhas. Mas queríamos encontrar algo mais inteligente e mais eficiente do que isso.
Explorando o domínio do aplicativo para encontrar os critérios de particionamento
Aprendemos uma lição essencial analisando o domínio do aplicativo e entrevistando nossos usuários. Especialistas em esportes tendem a analisar dados agregados de jogos na mesma competição. Por exemplo, uma competição de futebol pode ser uma liga, um torneio ou uma única partida em que você pode ganhar um troféu. Existem milhares de competições diferentes. Os mais importantes na Europa são a Liga dos Campeões, Premier League, LaLiga, Serie A, Bundesliga, Eredivisie, Liga 1 e Primeira Liga.
Isso significa que nossos usuários levam em consideração dados provenientes de diferentes competições muito raramente. Além disso, eles preferem explorar os dados temporada por temporada. Ou seja, raramente saem do contexto representado por uma competição esportiva disputada em uma determinada temporada. Nossa estrutura de banco de dados expressou esse conceito com uma tabela chamada
SeasonCompetition
, cujo objetivo é associar uma competição a uma época específica. Então, percebemos que uma boa abordagem seria particionar nossas tabelas maiores em subtabelas relacionadas a uma determinada SeasonCompetition
instância. Especificamente, definimos o seguinte formato de nome para essas novas tabelas:
<tableName>_<seasonCompetitionId>
. Conseqüentemente, se tivéssemos 100 linhas no
SeasonCompetition
tabela, teríamos que dividir os grandes Events
tabela no menor Events_1
, Events_2
, …, Events_100
mesas. Com base em nossa análise, essa abordagem levaria a um aumento considerável de desempenho no caso médio, embora introduzindo alguma sobrecarga nos casos mais raros. Correspondência dos critérios com as consultas mais comuns
Antes de codificar e lançar os scripts para executar essa operação complexa e potencialmente sem retorno, validamos nossos estudos analisando as consultas mais comuns realizadas pelo nosso aplicativo de back-end. Mas ao fazer isso, descobrimos que a grande maioria das consultas envolvia apenas jogos jogados em uma SeasonCompetition. Isso nos convenceu de que estávamos certos. Então particionamos todas as tabelas grandes no banco de dados com a abordagem que acabamos de definir.
SELECT AVG('value') as 'value', SUM('minutes') as 'minutes'
FROM 'Events'
WHERE 'parameterId' = 15 AND 'gameId' IN(223,241,245,212,201,299,187,304,187,205)
GROUP BY 'teamId'
Agora, vamos estudar os prós e contras desta decisão.
Prós
- Executar consultas em uma tabela com no máximo meio milhão de linhas tem muito mais desempenho do que em uma tabela de meio bilhão de linhas, especialmente quando se trata de consultas agregadas.
- Tabelas menores são mais fáceis de gerenciar e atualizar. Adicionar uma coluna ou índice não é comparável a antes em termos de tempo e espaço. Além disso, cada
SeasonCompetition
é diferente e requer análises diferentes. Consequentemente, pode exigir colunas e índices especiais, e o particionamento mencionado acima nos permite lidar com isso facilmente. - O provedor pode alterar alguns dados. Isso nos obriga a realizar consultas de exclusão e atualização, que são infinitamente mais rápidas em tabelas tão pequenas. Além disso, eles sempre dizem respeito apenas a alguns jogos de uma determinada
SeasonCompetition
, então só precisamos operar em uma única tabela agora.
Contras
- Antes de fazer uma consulta nessas subtabelas, precisamos saber o
seasonCompetitionId
associados aos jogos de interesse. Isso ocorre porque oseasonCompetitionId
valor é usado no nome da tabela. Portanto, nosso back-end precisa recuperar essas informações antes de executar a consulta observando os jogos em análise, representando uma pequena sobrecarga. - Quando uma consulta envolve um conjunto de jogos que envolve muitas
SeasonCompetitions
, o aplicativo de back-end deve executar uma consulta em cada subtabela. Portanto, nesses casos, não podemos mais agregar os dados no nível do banco de dados e devemos fazê-lo no nível do aplicativo. Isso introduz alguma complexidade na lógica de back-end. Ao mesmo tempo, podemos executar essas consultas em paralelo. Além disso, podemos agregar os dados recuperados de forma eficiente e em paralelo. - Gerenciar um banco de dados com milhares de tabelas não é fácil e pode ser um desafio explorar em um cliente. Da mesma forma, adicionar uma nova coluna ou atualizar uma coluna existente em cada tabela é complicado e requer um script personalizado.
Efeitos do particionamento baseado em contexto de dados no desempenho
Vejamos agora a melhoria de tempo alcançada ao executar uma consulta no novo banco de dados particionado.
- Melhoria de tempo no caso médio (consulta envolvendo apenas uma
SeasonCompetition
):de 20x a 40x - Melhoria de tempo no caso geral (consulta envolvendo uma ou mais
SeasonCompetitions
):de 5x a 10x
Considerações finais
Particionar seu banco de dados é, sem dúvida, uma excelente maneira de melhorar o desempenho, principalmente em grandes bancos de dados. No entanto, fazer isso sem considerar seu domínio de aplicativo específico pode ser um erro ou levar a uma solução ineficiente. Em vez disso, dedicar seu tempo para estudar o domínio entrevistando especialistas e seus usuários e observando as consultas mais executadas é crucial para conceber critérios de particionamento altamente eficientes. Este artigo mostrou como fazer isso e demonstrou os resultados de tal abordagem por meio de um estudo de caso do mundo real.