Otimizar o design do esquema para o Spanner

As tecnologias de armazenamento da Google alimentam algumas das maiores aplicações do mundo. No entanto, a escala nem sempre é um resultado automático da utilização destes sistemas. Os designers têm de pensar cuidadosamente em como modelar os respetivos dados para garantir que a aplicação pode ser dimensionada e ter um bom desempenho à medida que cresce em várias dimensões.

O Spanner é uma base de dados distribuída e a sua utilização eficaz requer uma abordagem diferente ao design do esquema e aos padrões de acesso em comparação com as bases de dados tradicionais. Os sistemas distribuídos, pela sua natureza, obrigam os criadores a pensar na localização dos dados e do processamento.

O Spanner suporta consultas SQL e transações com a capacidade de expansão horizontal. Muitas vezes, é necessário um planeamento cuidadoso para tirar o máximo partido do Spanner. Este documento aborda algumas das principais ideias que ajudam a garantir que a sua aplicação pode ser dimensionada para níveis arbitrários e maximizar o respetivo desempenho. Em particular, duas ferramentas têm um grande impacto na escalabilidade: a definição de chaves e a intercalação.

Esquema de tabela

As linhas numa tabela do Spanner são organizadas lexicograficamente por PRIMARY KEY. Em termos conceptuais, as chaves são ordenadas pela concatenação das colunas na ordem em que são declaradas na cláusula PRIMARY KEY. Esta opção apresenta todas as propriedades padrão da localidade:

  • A análise da tabela por ordem lexicográfica é eficiente.
  • As linhas suficientemente próximas são armazenadas nos mesmos blocos de disco e são lidas e colocadas em cache em conjunto.

O Spanner replica os seus dados em várias zonas para disponibilidade e escala. Cada zona contém uma réplica completa dos seus dados. Quando aprovisiona um nó de instância do Spanner, especifica a respetiva capacidade de computação. A capacidade de computação é a quantidade de recursos de computação atribuídos à sua instância em cada uma destas zonas. Embora cada réplica seja um conjunto completo dos seus dados, os dados numa réplica são particionados nos recursos de computação nessa zona.

Os dados em cada réplica do Spanner estão organizados em dois níveis de hierarquia física: divisões da base de dados e, em seguida, blocos. As divisões contêm intervalos contíguos de linhas e são a unidade pela qual o Spanner distribui a sua base de dados pelos recursos de computação. Ao longo do tempo, as divisões podem ser divididas em partes mais pequenas, unidas ou movidas para outros nós na sua instância para aumentar o paralelismo e permitir que a sua aplicação seja dimensionada. As operações que abrangem divisões são mais caras do que as operações equivalentes que não o fazem, devido ao aumento da comunicação. Isto é verdade mesmo que essas divisões sejam publicadas pelo mesmo nó.

Existem dois tipos de tabelas no Spanner: tabelas raiz (por vezes, denominadas tabelas de nível superior) e tabelas intercaladas. As tabelas intercaladas são definidas especificando outra tabela como a respetiva principal, o que faz com que as linhas na tabela intercalada sejam agrupadas com a linha principal. As tabelas raiz não têm um elemento principal e cada linha numa tabela raiz define uma nova linha de nível superior ou linha raiz. As linhas intercaladas com esta linha raiz são denominadas linhas secundárias, e a coleção de uma linha raiz mais todos os respetivos descendentes é denominada árvore de linhas. A linha principal tem de existir antes de poder inserir linhas secundárias. A linha principal pode já existir na base de dados ou pode ser inserida antes da inserção das linhas secundárias na mesma transação.

O Spanner divide automaticamente as partições quando o considera necessário devido ao tamanho ou à carga. Para preservar a localidade dos dados, o Spanner prefere adicionar limites de divisão o mais próximo possível das tabelas raiz, para que qualquer árvore de linhas dada possa ser mantida numa única divisão. Isto significa que as operações numa árvore de linhas tendem a ser mais eficientes porque é improvável que exijam comunicação com outras divisões.

No entanto, se existir um ponto crítico numa linha secundária, o Spanner tenta adicionar limites de divisão a tabelas intercaladas para isolar essa linha de ponto crítico, juntamente com todas as linhas secundárias abaixo da mesma.

Escolher que tabelas devem ser raízes é uma decisão importante na conceção da sua aplicação para escalar. As raízes são normalmente elementos como utilizadores, contas, projetos e semelhantes, e as respetivas tabelas secundárias contêm a maioria dos outros dados sobre a entidade em questão.

Recomendações:

  • Use um prefixo de chave comum para linhas relacionadas na mesma tabela para melhorar a localidade.
  • Intercale dados relacionados noutra tabela sempre que fizer sentido.

Compromissos da localidade

Se os dados forem frequentemente escritos ou lidos em conjunto, pode beneficiar a latência e o débito agrupando-os através da seleção cuidadosa de chaves primárias e da utilização da intercalação. Isto deve-se ao facto de existir um custo fixo para comunicar com qualquer servidor ou bloco de disco, por isso, porque não obter o máximo possível enquanto lá está? Além disso, quanto mais servidores comunicar, maior é a probabilidade de encontrar um servidor temporariamente ocupado, o que aumenta as latências finais. Por último, as transações que abrangem divisões, embora sejam automáticas e transparentes no Spanner, têm um custo de CPU e uma latência ligeiramente superiores devido à natureza distribuída da confirmação em duas fases.

Por outro lado, se os dados estiverem relacionados, mas não forem acedidos com frequência em conjunto, considere separá-los. Isto é mais vantajoso quando os dados acedidos com pouca frequência são grandes. Por exemplo, muitas bases de dados armazenam grandes quantidades de dados binários fora da banda dos dados das linhas principais, com apenas referências aos grandes dados intercalados.

Tenha em atenção que algum nível de confirmação de duas fases e operações de dados não locais são inevitáveis numa base de dados distribuída. Não se preocupe demasiado em obter uma história de localidade perfeita para cada operação. Concentre-se em obter a localidade desejada para as entidades raiz mais importantes e os padrões de acesso mais comuns, e permita que as operações distribuídas menos frequentes ou menos sensíveis ao desempenho ocorram quando necessário. A confirmação em duas fases e as leituras distribuídas existem para ajudar a simplificar os esquemas e facilitar o trabalho dos programadores: em todos os exemplos de utilização, exceto nos mais críticos em termos de desempenho, é melhor deixá-los.

Recomendações:

  • Organize os seus dados em hierarquias de modo que os dados lidos ou escritos em conjunto tendam a estar próximos.
  • Considere armazenar colunas grandes em tabelas não intercaladas se forem acedidas com menos frequência.

Opções de marcadores

Os índices secundários permitem-lhe encontrar rapidamente linhas por valores que não a chave primária. O Spanner suporta índices não intercalados e intercalados. Os índices não intercalados são a predefinição e o tipo mais análogo ao que é suportado num RDBMS tradicional. Não colocam restrições sobre as colunas que estão a ser indexadas e, embora sejam poderosas, nem sempre são a melhor escolha. Os índices intercalados têm de ser definidos em colunas que partilham um prefixo com a tabela principal e permitem um maior controlo da localidade.

O Spanner armazena dados de índice da mesma forma que as tabelas, com uma linha por entrada de índice. Muitas das considerações de design para tabelas também se aplicam a índices. Os índices não intercalados armazenam dados em tabelas raiz. Uma vez que as tabelas raiz podem ser divididas entre qualquer linha raiz, isto garante que os índices não intercalados podem ser dimensionados para um tamanho arbitrário e, ignorando os pontos críticos, para quase qualquer carga de trabalho. Infelizmente, isto também significa que as entradas de índice normalmente não estão nas mesmas divisões que os dados primários. Isto cria trabalho adicional e latência para qualquer processo de escrita e adiciona divisões adicionais para consultar no momento da leitura.

Por outro lado, os índices intercalados armazenam dados em tabelas intercaladas. São adequados quando está a pesquisar no domínio de uma única entidade. Os índices intercalados forçam os dados e as entradas de índice a permanecerem na mesma árvore de linhas, o que torna as junções entre eles muito mais eficientes. Exemplos de utilizações de um índice intercalado:

  • Aceder às suas fotos por várias ordens de ordenação, como data de captura, data de modificação, título, álbum, etc.
  • Encontrar todas as suas publicações que têm um conjunto específico de etiquetas.
  • Encontrar as minhas encomendas de compras anteriores que continham um artigo específico.

Recomendações:

  • Use índices não intercalados quando precisar de encontrar linhas em qualquer parte da sua base de dados.
  • Prefira índices intercalados sempre que as suas pesquisas estiverem no âmbito de uma única entidade.

Cláusula STORING index

Os índices secundários permitem-lhe encontrar linhas por atributos que não sejam a chave primária. Se todos os dados pedidos estiverem no próprio índice, podem ser consultados de forma autónoma sem ler o registo principal. Isto pode poupar recursos significativos, uma vez que não é necessária nenhuma junção.

Infelizmente, as chaves de índice estão limitadas a 16 em número e 8 KiB em tamanho agregado, o que restringe o que pode ser colocado nas mesmas. Para compensar estas limitações, o Spanner tem a capacidade de armazenar dados adicionais em qualquer índice através da cláusula STORING. STORING uma coluna num índice faz com que os respetivos valores sejam duplicados, com uma cópia armazenada no índice. Pode considerar um índice com STORING como uma vista de propriedades materializadas de tabela única simples (as vistas não são suportadas nativamente no Spanner neste momento).

Outra aplicação útil do STORING é como parte de um índice NULL_FILTERED. Isto permite-lhe definir o que é efetivamente uma vista materializada de um subconjunto esparso de uma tabela que pode analisar de forma eficiente. Por exemplo, pode criar um índice na coluna is_unread de uma caixa de correio para poder publicar a vista de mensagens não lidas numa única análise da tabela, mas sem pagar por uma cópia completa de todas as caixas de correio.

Recomendações:

  • Use STORING com prudência para equilibrar o desempenho do tempo de leitura com o tamanho do armazenamento e o desempenho do tempo de gravação.
  • Use NULL_FILTERED para controlar os custos de armazenamento de índices esparsos.

Antipadrões

Antipadrão: ordenação por data/hora

Muitos criadores de esquemas têm tendência a definir uma tabela raiz ordenada por data/hora e atualizada em cada gravação. Infelizmente, esta é uma das coisas menos escaláveis que pode fazer. O motivo é que este design resulta num enorme ponto crítico no final da tabela que não pode ser facilmente mitigado. À medida que as taxas de gravação aumentam, também aumentam os RPCs para uma única divisão, bem como os eventos de contenção de bloqueios e outros problemas. Muitas vezes, este tipo de problemas não aparece em testes de carga pequenos e, em vez disso, aparece depois de a aplicação estar em produção durante algum tempo. Até lá, é demasiado tarde!

Se a sua aplicação tiver absolutamente de incluir um registo ordenado por indicação de tempo, considere se pode tornar o registo local intercalando-o numa das suas outras tabelas raiz. Isto tem a vantagem de distribuir o ponto de acesso por muitas raízes. No entanto, continua a ter de garantir que cada raiz distinta tem uma taxa de gravação suficientemente baixa.

Se precisar de uma tabela global (entre raízes) ordenada por data/hora e tiver de suportar taxas de gravação mais elevadas nessa tabela do que um único nó é capaz, use a divisão horizontal ao nível da aplicação. Fragmentar uma tabela significa parti-la num número N de divisões aproximadamente iguais denominadas fragmentos. Normalmente, isto é feito ao adicionar um prefixo à chave principal original com uma coluna ShardId adicional que contenha valores inteiros entre [0, N). O ShardId para uma determinada gravação é normalmente selecionado aleatoriamente ou através da aplicação de hash a uma parte da chave base. A aplicação de hash é frequentemente preferível porque pode ser usada para garantir que todos os registos de um determinado tipo são colocados no mesmo fragmento, o que melhora o desempenho da obtenção. De qualquer forma, o objetivo é garantir que, ao longo do tempo, as gravações são distribuídas por todos os fragmentos de forma igual. Por vezes, esta abordagem significa que as leituras têm de analisar todos os fragmentos para reconstruir a ordenação total original das escritas.

Ilustração de fragmentos para paralelismo e linhas por ordem cronológica por fragmento

Recomendações:

  • Evite tabelas e índices ordenados por data/hora com uma taxa de gravação elevada a todo o custo.
  • Use alguma técnica para distribuir os pontos críticos, seja intercalando noutra tabela ou dividindo.

Antipadrão: sequências

Os programadores de aplicações adoram usar sequências de bases de dados (ou incremento automático) para gerar chaves primárias. Infelizmente, este hábito dos tempos dos SGBDRs (denominado chaves substitutas) é quase tão prejudicial quanto o padrão anti-padrão de ordenação de data/hora descrito acima. O motivo é que as sequências de bases de dados tendem a emitir valores de forma quase monótona ao longo do tempo, produzindo valores agrupados próximos uns dos outros. Normalmente, isto produz pontos críticos quando usado como chaves primárias, especialmente para linhas raiz.

Ao contrário da sabedoria convencional dos SGBDRs, recomendamos que use atributos do mundo real para chaves primárias sempre que fizer sentido. Isto é particularmente o caso se o atributo nunca for mudar.

Se quiser gerar chaves primárias únicas numéricas, procure que os bits de ordem superior dos números subsequentes sejam distribuídos aproximadamente de forma igual por todo o espaço de números. Um truque é gerar números sequenciais por meios convencionais e, em seguida, inverter os bits para obter um valor final. Em alternativa, pode procurar um gerador de UUID, mas tenha cuidado: nem todas as funções de UUID são iguais e algumas armazenam a data/hora nos bits de ordem superior, o que anula efetivamente a vantagem. Certifique-se de que o gerador de UUIDs escolhe pseudoaleatoriamente bits de ordem superior.

Recomendações:

  • Evite usar valores de sequência incrementais como chaves principais. Em alternativa, inverta os bits de um valor de sequência ou use um UUID cuidadosamente escolhido.
  • Use valores do mundo real para chaves principais em vez de chaves substitutas.