Práticas recomendadas de criação de esquema

Nesta página, descrevemos as práticas recomendadas para criar esquemas do Cloud Spanner com o objetivo de evitar uso excessivo do ponto de acesso e carregar dados no Cloud Spanner.

Como escolher uma chave primária para evitar uso excessivo do ponto de acesso

Conforme mencionado no Modelo de dados e esquema, tenha cuidado ao escolher uma chave primária para não criar, acidentalmente, uso excessivo do ponto de acesso no banco de dados. Uma das causas para a criação acidental de pontos de acesso é o fato de haver uma coluna com valor que aumenta constantemente como a primeira parte da chave, porque isso resulta em todas as inserções ocorrerem no final do espaço da chave. Isso não é desejável, porque o Cloud Spanner divide dados entre servidores por intervalos de chave. Isso significa que todas as inserções serão direcionadas para um único servidor que acabará fazendo todo o trabalho.

Por exemplo, suponha que você queira manter uma última coluna de carimbo de data/hora de acesso nas linhas da tabela UserAccessLog. A definição de tabela a seguir, que usa uma chave primária baseada em carimbo de data/hora como a primeira parte da chave, é um antipadrão se a tabela apresentar uma alta taxa de inserção:

-- ANTI-PATTERN: USING A COLUMN WHOSE VALUE MONOTONICALLY INCREASES OR
-- DECREASES AS THE FIRST KEY PART OF A HIGH WRITE RATE TABLE

CREATE TABLE UserAccessLog (
  LastAccess TIMESTAMP NOT NULL,
  UserId     INT64 NOT NULL,
  ...
) PRIMARY KEY (LastAccess, UserId);

O problema aqui é que as linhas serão gravadas nesta tabela na ordem do carimbo de data/hora do último acesso e, como esses carimbos estão sempre aumentando, eles são sempre escritos no final da tabela. O ponto de acesso é criado porque um único servidor do Cloud Spanner receberá todas as gravações, o que sobrecarrega esse servidor.

O diagrama abaixo ilustra essa armadilha:

Tabela UserAccessLog ordenada por carimbo de data/hora com o ponto de acesso correspondente

A tabela UserAccessLog acima inclui cinco linhas de dados de exemplo. Elas representam cinco usuários diferentes que realizam algum tipo de ação do usuário com cerca de um milésimo de segundo de diferença uma da outra. O diagrama também indica a ordem em que as linhas são inseridas. As setas marcadas indicam a ordem das gravações de cada linha. Como as inserções são ordenadas pelo carimbo de data/hora e o valor dele está sempre aumentando, as inserções são sempre adicionadas ao final da tabela e direcionadas para a mesma divisão. Conforme discutido em Modelo de dados e esquema, uma divisão é um conjunto de linhas de uma ou mais tabelas relacionadas que são armazenadas por ordem da chave de linha.

Isso é problemático, porque o Cloud Spanner atribui o trabalho a servidores diferentes em unidades de divisão, de modo que o servidor atribuído a essa divisão específica acaba tratando todas as solicitações de inserção. À medida que a frequência dos eventos de acesso do usuário aumenta, o mesmo ocorre com a frequência das solicitações de inserção para o servidor correspondente. O servidor torna-se propenso a se tornar um ponto de acesso, conforme indicado pela borda vermelha e no segundo plano acima. Observe que, nesta ilustração simplificada, cada servidor lida com o máximo de uma divisão, mas, na realidade, cada servidor do Cloud Spanner pode ser atribuído a mais de uma divisão.

Quando mais linhas são anexadas à tabela, a divisão cresce e, quando atinge o tamanho máximo (aproximadamente 4 GB), o Cloud Spanner cria outra divisão, conforme descrito em Divisão baseada em carga. Novas linhas subsequentes são anexadas a essa nova divisão, e o servidor que é atribuído a ela se torna o novo ponto de acesso potencial.

Quando ocorre uso excessivo do ponto de acesso, as inserções tornam-se lentas e outros trabalhos no mesmo servidor podem ficar mais lentos. Alterar a ordem da coluna LastAccess para ordem crescente não resolverá esse problema, já que todas as gravações serão inseridas na parte superior da tabela, o que ainda enviará todas as inserções para um único servidor.

Prática recomendada de design de esquema nº 1: não escolha uma coluna cujo valor aumente ou diminua monotonicamente como a primeira parte importante de uma tabela de alta taxa de gravação.

Trocar a ordem das chaves

Uma maneira é espalhar as gravações no espaço da chave é trocar a ordem das chaves para que a coluna que contém o valor crescente ou decrescente monotonicamente não seja a primeira parte da chave:

CREATE TABLE UserAccessLog (
  UserId     INT64 NOT NULL,
  LastAccess TIMESTAMP NOT NULL,
  ...
) PRIMARY KEY (UserId, LastAccess);

Nesse esquema modificado, as inserções agora são ordenadas primeiro por UserId, e não pelo carimbo de data/hora do último acesso cronológico. Esse esquema espalha gravações entre diferentes divisões porque é improvável que um único usuário produza milhares de eventos por segundo.

O diagrama abaixo ilustra as cinco linhas da tabela UserAccessLog ordenadas por UserId em vez de ordenadas pelo carimbo de data/hora de acesso:

Tabela UserAccessLog ordenada por UserId com capacidade de gravação balanceada

Aqui, os dados de UserAccessLog são fragmentados em três divisões. Cada divisão contém cerca de mil linhas de valores de UserId ordenados. Essa é uma estimativa razoável de como os dados do usuário podem ser divididos, supondo que cada linha contenha cerca de 1 MB de dados do usuário e receba um tamanho máximo de aproximadamente 4 GB. Embora os eventos do usuário tenham ocorrido com diferença de cerca de um milissegundos, cada evento foi gerado por um usuário diferente. Portanto, a ordem das inserções tem muito menos probabilidade de criar um ponto de acesso em comparação com a ordenação por carimbo de data/hora.

Consulte também as práticas recomendadas relacionadas à como ordenar chaves com base em carimbo de data/hora.

Gere o hash da chave única e espalhe as gravações em fragmentos lógicos

Outra técnica comum para espalhar a carga em vários servidores é criar uma coluna que contenha o hash da chave exclusiva real e usar a coluna hash (ou a coluna hash junto com as colunas de chave única) como a chave primária. Esse padrão ajuda a evitar pontos de acesso porque as novas linhas são distribuídas de maneira mais uniforme no espaço da chave.

Você pode usar o valor de hash para criar fragmentos lógicos ou partições em seu banco de dados. Em um banco de dados fisicamente fragmentado, as linhas estão espalhadas em vários bancos de dados. Em um banco de dados logicamente fragmentado, os fragmentos são definidos pelos dados na tabela. Por exemplo, para espalhar gravações na tabela UserAccessLog em N fragmentos lógicos, seria possível preceder uma coluna-chave ShardId à tabela:

CREATE TABLE UserAccessLog (
  ShardId     INT64 NOT NULL,
  LastAccess  TIMESTAMP NOT NULL,
  UserId      INT64 NOT NULL,
  ...
) PRIMARY KEY (ShardId, LastAccess, UserId);

Para calcular o ShardId, é gerado o hash de uma combinação das colunas de chave primária e é calculado o módulo N do hash - ShardId = hash(LastAccess and UserId) % N. Sua opção de função hash e combinação de colunas determina como as linhas são distribuídas pelo espaço de chave. O Cloud Spanner criará divisões nas linhas para otimizar o desempenho. Observe que as divisões podem não estar alinhadas com os fragmentos lógicos.

O diagrama abaixo ilustra como o uso de um hash para criar três fragmentos lógicos pode espalhar a taxa de transferência de gravação de maneira mais uniforme entre os servidores:

Tabela

Aqui, a tabela UserAccessLog é ordenada por ShardId, que é calculada como uma função hash de colunas de chave. As cinco linhas de UserAccessLog são divididas em três fragmentos lógicos, cada um deles, coincidentemente, em uma divisão diferente. As inserções são distribuídas uniformemente entre as divisões, o que equilibra a capacidade de gravação para os três servidores que manipulam as divisões.

Sua escolha da função hash determinará o quanto suas inserções serão distribuídas ao longo do intervalo de chaves. Não é preciso um hash criptográfico, embora um hash criptográfico possa ser uma boa escolha. Ao escolher uma função hash, é preciso considerar vários fatores:

  • Menor número de pontos de acesso: Uma função que resulte em mais valores de hash tende a reduzir os pontos de acesso.
  • Eficiência de leitura: As leituras em todos os valores de hash são mais rápidas se houver menos valores de hash a serem verificados.
  • Contagem de nós

Usar um identificador universal exclusivo (UUID, na sigla em inglês)

É possível usar um identificador universal exclusivo (UUID, na sigla em inglês), conforme definido pela RFC 4122 como a chave primária. Recomendamos a versão 4 do UUID porque utiliza valores aleatórios na sequência de bits. A versão 1 do UUID armazena o carimbo de data/hora nos bits de ordem superior e não é recomendada.

Existem várias maneiras de armazenar o UUID como chave primária:

  • Em uma coluna STRING(36).
  • Em um par de colunas INT64.
  • Em uma coluna BYTES(16).

Existem algumas desvantagens em usar um UUID:

  • Eles são um pouco grandes, usando 16 bytes ou mais. Outras opções para chaves primárias não utilizam tanto armazenamento.
  • Não carregam informações sobre o registro. Por exemplo, uma chave primária de SingerId e AlbumId tem um significado inerente, enquanto um UUID não.
  • A localidade entre os registros relacionados é perdida, e é por essa razão que o uso de um UUID elimina os pontos de acesso.

Fazer reversão em bits dos valores sequenciais

Quando você gera chaves primárias numéricas exclusivas, os bits de ordem superior dos números subsequentes devem ser distribuídos de forma aproximadamente uniforme em todo o espaço de números. Uma maneira de fazer isso é gerar números sequenciais por meios convencionais, e depois realizar a reversão deles em bits para chegar aos valores finais.

A reversão dos bits mantém valores exclusivos nas chaves primárias. É preciso armazenar apenas o valor invertido porque é possível recalcular o valor original no código do aplicativo.

Limitar o tamanho da linha

O tamanho de uma linha deve ser menor que 4 GB para garantir o melhor desempenho. O tamanho de uma linha inclui a linha de nível superior e todas as suas linhas filho e linhas de índice intercaladas. O Cloud Spanner normalmente cria uma nova divisão quando uma divisão existente atinge 4 GB. Além disso, o Cloud Spanner só pode dividir nas linhas de nível superior. Se uma linha for maior que 4 GB, a capacidade de gravação pode ser afetada.

Criar tabelas intercaladas para evitar pontos de acesso

O Cloud Spanner só pode criar divisões nas linhas de nível superior. Considere este exemplo com três tabelas intercaladas:

-- Schema hierarchy:
-- + Singers
--   + Albums (interleaved table, child table of Singers)
--     + Songs (interleaved table, child table of Albums)

O Cloud Spanner cria divisões que mantêm todos os álbuns e músicas de cada cantor juntos na mesma divisão. Se as músicas de um único cantor gerarem uso excessivo do ponto de acesso para leituras ou gravações, o Cloud Spanner não poderá dividir a tabela Songs entre os servidores. Se, em vez disso, Songs for uma tabela de nível superior, o Cloud Spanner poderá criar divisões com base nas músicas:

-- Schema hierarchy:
-- + Singers (top-level table)
--   + Albums (interleaved table, child table of Singers)
-- + Songs (top-level table)

Usar a ordem decrescente para as chaves baseadas em carimbo de data/hora

Se você tiver uma tabela para seu histórico que esteja codificada por carimbo de data/hora, considere usar a ordem decrescente para a colunas-chave, se qualquer uma das situações a seguir se aplicar:

  • Se você estiver usando uma tabela intercalada para o histórico, também lerá a linha-pai. Nesse caso, com uma coluna de carimbo de data/hora DESC, as entradas de histórico mais recentes são armazenadas adjacentes à linha-pai. Caso contrário, a leitura da linha-pai e seu histórico recente exigirão uma busca no meio para pular o histórico mais antigo.
  • Se você está lendo entradas sequenciais em ordem cronológica inversa, e não sabe exatamente a que distância está indo. Por exemplo, é possível usar uma consulta SQL com LIMIT para ver os N eventos mais recentes ou planejar cancelar a leitura depois de ler um determinado número de linhas. Nesses casos, você quer começar com as entradas mais recentes e ler as entradas mais antigas sequencialmente até que sua condição seja atendida, o que o Cloud Spanner faz com mais eficiência para as chaves de carimbo de data/hora que são armazenadas em ordem decrescente.

Adicione a palavra-chave DESC para tornar a chave de carimbo de data/hora decrescente. Por exemplo:

CREATE TABLE UserAccessLog (
  UserId     INT64 NOT NULL,
  LastAccess TIMESTAMP NOT NULL,
  ...
) PRIMARY KEY (UserId, LastAccess DESC);

Prática recomendada de criação do esquema nº 2: use a ordem decrescente para as chaves baseadas em carimbo de data/hora.

Usar um índice intercalado em uma coluna em que o valor aumenta ou diminui monotonicamente

De maneira semelhante ao antipadrão da chave primária anterior, também não é uma boa ideia criar índices não intercalados em colunas em que os valores estão aumentando ou diminuindo constantemente, mesmo que não sejam colunas de chave primária.

Por exemplo, suponha que você defina a seguinte tabela, em que LastAccess é uma coluna de chave não primária:

CREATE TABLE Users (
  UserId     INT64 NOT NULL,
  LastAccess TIMESTAMP,
  ...
) PRIMARY KEY (UserId);

Pode parecer conveniente definir um índice na coluna LastAccess para consultar rapidamente o banco de dados em busca dos acessos de usuários "desde a hora X", como este:

-- ANTI-PATTERN: CREATING A NON-INTERLEAVED INDEX ON A COLUMN WHOSE VALUE
-- MONOTONICALLY INCREASES OR DECREASES ON A HIGH WRITE RATE COLUMN

CREATE NULL_FILTERED INDEX UsersByLastAccess ON Users(LastAccess)

No entanto, isso resulta na mesma armadilha descrita na prática recomendada anterior, porque os índices são implementados como tabelas em segundo plano, e a tabela de índice resultante usaria uma coluna com um valor que aumenta monotonicamente conforme a primeira parte da chave.

É bom criar um índice intercalado assim, uma vez que as linhas desses índices são intercaladas em linhas pai correspondentes, e é improvável que uma única linha pai produza milhares de eventos por segundo.

Prática recomendada de design de esquema nº 3: não crie um índice não intercalado em uma coluna de alta taxa de gravação cujo valor aumente ou diminua monotonicamente.

A seguir