Escolher o modelo de dados certo é a parte mais difícil de usar o Cassandra. Se você tiver um histórico relacional, o CQL parecerá familiar, mas a maneira como você o utiliza pode ser muito diferente. O objetivo deste post é explicar as regras básicas que você deve ter em mente ao projetar seu esquema para o Cassandra. Se você seguir essas regras, obterá um bom desempenho imediato. Melhor ainda, seu desempenho deve ser dimensionado linearmente à medida que você adiciona nós ao cluster.
Não-objetivos
Os desenvolvedores que vêm de um background relacional geralmente carregam regras sobre modelagem relacional e tentam aplicá-las a Cassandra. Para evitar perder tempo com regras que realmente não importam com o Cassandra, quero destacar algumas não- metas:
Minimize o número de gravações
As gravações em Cassandra não são gratuitas, mas são muito baratas. O Cassandra é otimizado para alta taxa de transferência, e quase todas as gravações são igualmente eficientes [1] . Se você pode realizar gravações extras para melhorar a eficiência de suas consultas de leitura, quase sempre é uma boa compensação. As leituras tendem a ser mais caras e são muito mais difíceis de ajustar.
Minimize a duplicação de dados
Desnormalização e duplicação de dados é um fato da vida com Cassandra. Não tenha medo disso. Geralmente, o espaço em disco é o recurso mais barato (comparado à CPU, memória, IOPs de disco ou rede), e Cassandra é arquitetada em torno desse fato. Para obter as leituras mais eficientes, você geralmente precisa duplicar os dados.
Além disso, a Cassandra não tem o JOIN s, e você não quer realmente usá-los de maneira distribuída.
Objetivos Básicos
Estas são as duas metas de alto nível para seu modelo de dados:
- Espalhar dados uniformemente ao redor do cluster
- Minimize o número de partições lidas
Há outras metas menores a serem lembradas, mas estas são as mais importantes. Na maioria das vezes, vou me concentrar no básico de alcançar esses dois objetivos.Existem outros truques que você pode usar, mas você deve saber como avaliá-los primeiro.
Regra 1: espalhar dados uniformemente em torno do cluster
Você deseja que todos os nós do cluster tenham aproximadamente a mesma quantidade de dados. Cassandra torna isso fácil, mas não é um dado. As linhas são espalhadas pelo cluster com base em um hash da chave de partição , que é o primeiro elemento da PRIMARY KEY . Então, a chave para espalhar dados uniformemente é esta: escolha uma boa chave primária . Vou explicar como fazer isso daqui a pouco.
Regra 2: Minimizar o número de partições lidas
Partições são grupos de linhas que compartilham a mesma chave de partição. Quando você emite uma consulta de leitura, deseja ler as linhas do menor número possível de partições.
Por que isso é importante? Cada partição pode residir em um nó diferente. O coordenador geralmente precisará emitir comandos separados para separar nós para cada partição solicitada. Isso adiciona muita sobrecarga e aumenta a variação na latência. Além disso, mesmo em um único nó, é mais caro ler de várias partições do que de uma única, devido à maneira como as linhas são armazenadas.
Regras conflitantes?
Se é bom minimizar o número de partições das quais você lê, por que não colocar tudo em uma única partição grande? Você acabaria violando a Regra nº 1, que é espalhar dados uniformemente pelo cluster.
O ponto é que esses dois objetivos geralmente estão em conflito, então você precisa tentar equilibrá-los.
Modelo em torno de suas consultas
A maneira de minimizar as leituras de partição é modelar seus dados para atender às suas consultas. Não modele em torno de relações. Não modele em torno de objetos.Modelo em torno de suas consultas. Veja como você faz isso:
Etapa 1: determine quais consultas devem ser suportadas
Tente determinar exatamente quais consultas você precisa dar suporte. Isso pode incluir muitas considerações que você pode não pensar no início. Por exemplo, você pode precisar pensar sobre:
- Agrupamento por um atributo
- Ordenação por um atributo
- Filtrando com base em algum conjunto de condições
- Impondo exclusividade no conjunto de resultados
- etc …
As alterações em apenas um desses requisitos de consulta geralmente garantirão uma alteração do modelo de dados para máxima eficiência.
Etapa 2: tente criar uma tabela onde você possa satisfazer sua consulta lendo (aproximadamente) uma partição
Na prática, isso geralmente significa que você usará aproximadamente uma tabela por padrão de consulta. Se você precisar oferecer suporte a vários padrões de consulta, geralmente precisará de mais de uma tabela.
Para colocar isso de outra forma, cada tabela deve pré-construir a “resposta” para uma consulta de alto nível que você precisa dar suporte. Se você precisa de diferentes tipos de respostas, geralmente precisa de tabelas diferentes. É assim que você otimiza para leituras.
Lembre-se, a duplicação de dados está bem. Muitas de suas tabelas podem repetir os mesmos dados.
Aplicando as regras: exemplos
Para mostrar alguns exemplos de um bom processo, vou orientá-lo no design de um modelo de dados para alguns problemas simples.
Exemplo 1: pesquisa de usuário
O requisito de alto nível é “nós temos usuários e queremos procurá-los”. Vamos seguir os passos:
Etapa 1 : Determinar quais consultas específicas para suporte
Digamos que queremos ser capazes de procurar um usuário pelo nome de usuário ou pelo e-mail. Com o método de pesquisa, devemos obter o conjunto completo de detalhes do usuário.
Etapa 2 : tente criar uma tabela onde você possa satisfazer sua consulta lendo (aproximadamente) uma partição
Como queremos obter todos os detalhes para o usuário com o método de pesquisa, é melhor usar duas tabelas:
1234567891011 | CREATE TABLE users_by_username ( username text PRIMARY KEY , email text, age int ) CREATE TABLE users_by_email ( email text PRIMARY KEY , username text, age int ) |
Agora, vamos verificar as duas regras para este modelo:
Espalha os dados uniformemente? Cada usuário recebe sua própria partição, então sim.
Partições mínimas são lidas? Nós só temos que ler uma partição, então sim.
Agora, vamos supor que tentamos otimizar para os não- objetivos e, em vez disso, criamos esse modelo de dados:
12345678910111213141516 | CREATE TABLE users ( id uuid PRIMARY KEY , username text, email text, age int ) CREATE TABLE users_by_username ( username text PRIMARY KEY , id uuid ) CREATE TABLE users_by_email ( email text PRIMARY KEY , id uuid ) |
Esse modelo de dados também distribui os dados de maneira uniforme, mas há uma desvantagem: agora temos que ler duas partições, uma de users_by_username (ou users_by_email ) e outra de usuários . Portanto, as leituras são aproximadamente duas vezes mais caras.
Exemplo 2: grupos de usuários
Agora o requisito de alto nível mudou. Os usuários estão em grupos e queremos que todos os usuários entrem em um grupo.
Etapa 1 : Determinar quais consultas específicas para suporte
Queremos obter as informações completas do usuário para cada usuário em um grupo específico. Ordem dos usuários não importa.
Etapa 2 : tente criar uma tabela onde você possa satisfazer sua consulta lendo (aproximadamente) uma partição
Como podemos encaixar um grupo em uma partição? Podemos usar um PRIMARY KEY composto para isso:
1234567 | CREATE TABLE groups ( groupname text, username text, email text, age int , PRIMARY KEY (groupname, username) ) |
Observe que a PRIMARY KEY tem dois componentes: groupname , que é a chave de particionamento, e username , que é chamado de chave de clustering. Isso nos dará uma partição por nome de grupo . Dentro de uma determinada partição (grupo), as linhas serão ordenadas por nome de usuário . Buscar um grupo é tão simples quanto fazer o seguinte:
1 | SELECT * FROM groups WHERE groupname = ? |
Isso satisfaz o objetivo de minimizar o número de partições que são lidas, porque precisamos apenas ler uma partição. No entanto, ele não funciona tão bem com o primeiro objetivo de espalhar uniformemente os dados ao redor do cluster. Se tivermos milhares ou milhões de pequenos grupos com centenas de usuários cada, teremos uma distribuição bastante uniforme. Mas se houver um grupo com milhões de usuários, todo o fardo será suportado por um nó (ou um conjunto de réplicas).
Se quisermos distribuir a carga de maneira mais uniforme, há algumas estratégias que podemos usar. A técnica básica é adicionar outra coluna à PRIMARY KEY para formar uma chave de partição composta. Aqui está um exemplo:
12345678 | CREATE TABLE groups ( groupname text, username text, email text, age int , hash_prefix int , PRIMARY KEY ((groupname, hash_prefix), username) ) |
A nova coluna, hash_prefix , contém um prefixo de um hash do nome de usuário. Por exemplo, pode ser o primeiro byte do hash módulo quatro. Juntamente com groupname , essas duas colunas formam a chave de partição composta. Em vez de um grupo residindo em uma partição, ela agora está espalhada em quatro partições.Nossos dados estão mais uniformemente distribuídos, mas agora temos que ler quatro vezes mais partições. Este é um exemplo dos dois objetivos conflitantes. Você precisa encontrar um bom equilíbrio para seu caso de uso específico. Se você fizer um monte de leituras e grupos não ficarem muito grandes, talvez mudar o valor do módulo de quatro para dois seria uma boa escolha. Por outro lado, se você fizer muito poucas leituras, mas qualquer grupo pode crescer muito, mudar de quatro para dez seria uma escolha melhor.
Existem outras maneiras de dividir uma partição, que abordarei no próximo exemplo.
Antes de prosseguirmos, deixe-me apontar algo sobre esse modelo de dados: estamos duplicando as informações do usuário muitas vezes, uma vez para cada grupo. Você pode ser tentado a tentar um modelo de dados como esse para reduzir a duplicação:
123456789101112 | CREATE TABLE users ( id uuid PRIMARY KEY , username text, email text, age int ) CREATE TABLE groups ( groupname text, user_id uuid, PRIMARY KEY (groupname, user_id) ) |
Obviamente, isso minimiza a duplicação. Mas quantas partições precisamos ler? Se um grupo tiver 1000 usuários, precisamos ler 1001 partições. Isso é provavelmente 100 vezes mais caro para ler do que nosso primeiro modelo de dados. Se as leituras precisam ser eficientes, isso não é um bom modelo. Por outro lado, se as leituras são extremamente raras, mas as atualizações das informações do usuário (digamos, o nome de usuário) são extremamente comuns, esse modelo de dados pode realmente fazer sentido. Certifique-se de levar em consideração sua proporção de leitura / atualização ao projetar seu esquema.
Exemplo 3: grupos de usuários por data de associação
Suponha que continuemos com o exemplo anterior de grupos, mas precisemos adicionar suporte para obter os usuários X mais recentes em um grupo.
Podemos usar uma tabela semelhante à última:
12345678 | CREATE TABLE group_join_dates ( groupname text, joined timeuuid, username text, email text, age int , PRIMARY KEY (groupname, joined) ) |
Aqui estamos usando um timeuuid (que é como um timestamp, mas evita colisões) como a coluna de clustering. Dentro de um grupo (partição), as linhas serão ordenadas no momento em que o usuário ingressou no grupo. Isso nos permite obter os usuários mais novos em um grupo da seguinte forma:
1234 | SELECT * FROM group_join_dates WHERE groupname = ? ORDER BY joined DESC LIMIT ? |
Isso é razoavelmente eficiente, pois estamos lendo uma fatia de linhas de uma única partição. No entanto, em vez de sempre usar o DESC associado ao ORDER BY , o que torna a consulta menos eficiente, podemos simplesmente inverter a ordem de cluster:
12345678 | CREATE TABLE group_join_dates ( groupname text, joined timeuuid, username text, email text, age int , PRIMARY KEY (groupname, joined) ) WITH CLUSTERING ORDER BY (joined DESC ) |
Agora podemos usar a consulta um pouco mais eficiente:
123 | SELECT * FROM group_join_dates WHERE groupname = ? LIMIT ? |
Como no exemplo anterior, poderíamos ter problemas com os dados sendo distribuídos uniformemente ao redor do cluster, caso algum grupo ficasse muito grande. Nesse exemplo, dividimos as partições de forma aleatória, mas, nesse caso, podemos utilizar nosso conhecimento sobre os padrões de consulta para dividir as partições de uma maneira diferente: por um intervalo de tempo.
Por exemplo, podemos dividir partições por data:
123456789 | CREATE TABLE group_join_dates ( groupname text, joined timeuuid, join_date text, username text, email text, age int , PRIMARY KEY ((groupname, join_date), joined) ) WITH CLUSTERING ORDER BY (joined DESC ) |
Estamos usando uma chave de partição composta novamente, mas desta vez estamos usando a data de associação. Cada dia, uma nova partição será iniciada. Ao consultar os usuários mais novos do X, primeiro consultaremos a partição de hoje, depois a de ontem e assim por diante, até que tenhamos usuários X. Talvez tenhamos que ler várias partições antes que o limite seja atingido.
Para minimizar o número de partições que você precisa consultar, tente selecionar um intervalo de tempo para dividir as partições que geralmente permitem que você consulte apenas uma ou duas partições. Por exemplo, se normalmente precisamos dos dez usuários mais novos, e os grupos geralmente adquirem três usuários por dia, devemos dividir por intervalos de quatro dias em vez de um único dia [2] .
Resumo
As regras básicas de modelagem de dados cobertas aqui aplicam-se a todas as versões (atualmente) existentes do Cassandra e são muito prováveis de se aplicarem a todas as versões futuras. Outros problemas de modelagem de dados menores, como lidar com marcas de exclusão , também podem precisar ser considerados, mas esses problemas têm maior probabilidade de mudar (ou serem atenuados) por versões futuras do Cassandra.
Além das estratégias básicas abordadas aqui, alguns dos recursos mais extravagantes do Cassandra, como coleções , tipos definidos pelo usuário e colunas estáticas , também podem ser usados para reduzir o número de partições que você precisa ler para satisfazer uma consulta. Não se esqueça de considerar essas opções ao projetar seu esquema.
Fonte: https://www.datastax.com/dev/blog/basic-rules-of-cassandra-data-modeling