Práticas recomendadas de criação de esquema

A arquitetura distribuída do Spanner permite projetar seu esquema para evitar pontos de acesso, situações em que muitas solicitações são enviadas para o mesmo servidor, o que satura os recursos do servidor e pode causar altas latências.

Esta página descreve as práticas recomendadas para projetar seus esquemas para evitar a criação de pontos de acesso. Uma maneira de evitar pontos de acesso é ajustar o design do esquema para permitir que o Spanner divida e distribua os dados em vários servidores. A distribuição de dados entre servidores ajuda o banco de dados do Spanner a operar de maneira eficiente, especialmente ao realizar inserções de dados em massa.

Como escolher uma chave primária para evitar pontos de acesso

Conforme mencionado no Modelo de dados e esquema, tenha cuidado ao escolher uma chave primária na concepção do esquema para não criar, acidentalmente, pontos de acesso no banco de dados. Uma das causas dos pontos de acesso é ter uma coluna com um valor que muda monotonicamente como a primeira parte da chave, porque isso faz com que todas as inserções ocorram no final do espaço da chave. Esse padrão não é desejável porque o Spanner usa intervalos de chaves para dividir dados entre os servidores, o que significa que todas as inserções são direcionadas para um único servidor que acaba 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 usa uma chave primária baseada em carimbo de data/hora como a primeira parte da chave. Isso não será recomendado se a tabela tiver uma taxa de inserção alta:

GoogleSQL


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

PostgreSQL


CREATE TABLE UserAccessLog (
LastAccess TIMESTAMPTZ NOT NULL,
UserId bigint NOT NULL,
...
PRIMARY KEY (LastAccess, UserId)
);

O problema aqui é que as linhas são gravadas nessa tabela na ordem do carimbo de data/hora do último acesso e, como esses carimbos estão sempre aumentando, eles são sempre gravados no final da tabela. O ponto de acesso é criado porque um único servidor do Spanner recebe 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 o Spanner insere as linhas. As setas rotuladas indicam a ordem das gravações de cada linha. Como as inserções são ordenadas pelo carimbo de data/hora e o valor do carimbo de data/hora está sempre aumentando, o Spanner sempre adiciona as inserções ao final da tabela e as direciona na 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 o Spanner armazena em ordem de chave de linha.

Isso é problemático porque o Spanner atribui trabalho a servidores diferentes em unidades de divisões. Portanto, o servidor atribuído a essa divisão específica acaba processando 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 e se parece com a borda vermelha e o plano de fundo acima. Observe que, nesta ilustração simplificada, cada servidor processa no máximo uma divisão, mas, na realidade, o Spanner pode atribuir mais de uma divisão a cada servidor.

Quando o Spanner anexa mais linhas à tabela, a divisão cresce e, quando atinge aproximadamente 8 GB, o Spanner cria outra divisão, conforme descrito em Divisão baseada em carga. O Spanner anexa novas linhas subsequentes a essa nova divisão, e o servidor atribuído à divisão se torna o novo ponto de acesso em potencial.

Quando ocorrem pontos 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.

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 usar o UUID versão 4, porque ela utiliza valores aleatórios na sequência de bits. Não recomendamos a versão 1 dos UUIDs porque eles armazenam o carimbo de data/hora nos bits de ordem superior.

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).

Para uma coluna STRING(36), é possível usar a função GENERATE_UUID() do Spanner (GoogleSQL ou PostgreSQL) como o valor padrão da coluna para que o Spanner gere valores UUID automaticamente.

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.
  • Eles 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.
  • Você perde a localidade entre os registros relacionados, e é por isso que usar um UUID elimina os pontos de acesso.

Fazer reversão em bits dos valores sequenciais

Verifique se as chaves primárias numéricas (INT64 no GoogleSQL ou bigint no PostgreSQL) não estão aumentando ou diminuindo sequencialmente. Chaves primárias sequenciais podem causar uso excessivo do ponto de acesso em escala. Uma maneira de evitar esse problema é fazer a reversão em bits dos valores sequenciais, certificando-se de distribuir os valores chave primária uniformemente pelo espaço da chave.

O Spanner é compatível com a sequência reversa de bits, que gera valores inteiros exclusivos de reversão em bits. Você pode usar uma sequência no primeiro (ou único) componente em uma chave primária para evitar problemas de ponto de acesso. Para mais informações, consulte Sequência invertida em bits.

Trocar a ordem das chaves

Uma maneira de espalhar as gravações no espaço da chave de maneira mais uniforme é trocar a ordem das chaves para que a coluna que contém o valor monotônico não seja a primeira parte da chave:

GoogleSQL

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

PostgreSQL

CREATE TABLE UserAccessLog (
LastAccess TIMESTAMPTZ NOT NULL,
UserId bigint 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 distribui gravações entre divisões diferentes, porque é improvável que um único usuário produza milhares de eventos por segundo.

O diagrama abaixo ilustra as cinco linhas da tabela UserAccessLog que o Spanner ordena com UserId em vez do carimbo de data/hora de acesso:

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

Aqui, o Spanner agrupa os dados UserAccessLog em três divisões. Cada divisão contém aproximadamente mil linhas de valores 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 um tamanho máximo de divisão de aproximadamente 8 GB. Mesmo que os eventos do usuário tenham ocorrido com cerca de um milissegundo de diferença, cada evento foi gerado por um usuário diferente. Portanto, a ordem das inserções é muito menos provável de criar um ponto de acesso em comparação com o uso do carimbo de data/hora para ordenação.

Consulte também as práticas recomendadas relacionadas à ordenação de chaves baseadas 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 são distribuídas por vários servidores de banco de dados. Em um banco de dados logicamente fragmentado, os dados na tabela definem os fragmentos. Por exemplo, para espalhar gravações na tabela UserAccessLog em N fragmentos lógicos, seria possível preceder uma coluna-chave ShardId à tabela:

GoogleSQL

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

PostgreSQL

CREATE TABLE UserAccessLog (
ShardId bigint NOT NULL,
LastAccess TIMESTAMPTZ NOT NULL,
UserId bigint NOT NULL,
...
PRIMARY KEY (ShardId, LastAccess, UserId)
);

Para calcular o ShardId, gere o hash de uma combinação das colunas de chave primária e, em seguida, calcule o módulo N do hash. Exemplo:

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. Em seguida, o Spanner criará divisões nas linhas para otimizar o desempenho.

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 "UserAccessLog" ordenada por ShardId com capacidade de gravação balanceada

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.

O Spanner também permite criar uma função de hash em uma coluna gerada.

Para fazer isso no Google SQL, use a função FARM_FINGERPRINT durante o tempo de gravação, conforme mostrado no exemplo a seguir:

GoogleSQL

CREATE TABLE UserAccessLog (
ShardId INT64 NOT NULL
AS (MOD(FARM_FINGERPRINT(CAST(LastAccess AS STRING)), 2048)) STORED,
LastAccess TIMESTAMP NOT NULL,
UserId    INT64 NOT NULL,
) PRIMARY KEY (ShardId, LastAccess, UserId);

Sua escolha da função hash determina a forma como suas inserções são distribuídas pelo intervalo de chaves. Você não precisa de um hash criptográfico, embora um hash criptográfico possa ser uma boa escolha. Ao escolher uma função hash, considere os seguintes fatores:

  • Evasão de ponto 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 a ordem decrescente para as chaves baseadas em carimbo de data/hora

Se você tiver uma tabela para seu histórico que use o carimbo de data/hora como chave, use a ordem decrescente para a coluna de chave, se alguma das seguintes condições se aplicar:

  • Para ler o histórico mais recente, você está usando uma tabela intercalada para o histórico e está lendo 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, convém começar com as entradas mais recentes e ler as entradas sequencialmente mais antigas até que sua condição seja atendida, o que o Spanner faz com mais eficiência para as chaves de carimbo de data/hora que o Spanner armazena em ordem decrescente.

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

GoogleSQL

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

Prática recomendada de criação de esquema no 2: a ordem decrescente ou a ordem crescente depende das consultas do usuário, por exemplo, sendo a mais recente a mais recente ou a mais antiga.

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

Assim como no exemplo anterior da chave primária que precisa ser evitado, também não é uma ideia criar índices não intercalados em colunas com valores que aumentam ou diminuem 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:

GoogleSQL

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

PostgreSQL

CREATE TABLE Users (
UserId     bigint NOT NULL,
LastAccess TIMESTAMPTZ,
...
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:

GoogleSQL

CREATE NULL_FILTERED INDEX UsersByLastAccess ON Users(LastAccess);

PostgreSQL

CREATE INDEX UsersByLastAccess ON Users(LastAccess)
WHERE LastAccess IS NOT NULL;

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

Não há problema em criar um índice intercalado como esse, porque as linhas de índices intercalados são intercalados em linhas pai correspondentes, e é improvável que uma única linha pai produza milhares de eventos por segundo.

Prática recomendada de criação do esquema no 3: não crie um índice não intercalado em uma coluna com alta taxa de gravação cujo valor aumenta ou diminui monotonicamente. Em vez de usar índices intercalados, use técnicas como as que você usaria para criar a chave primária da tabela base ao projetar colunas de índice. Por exemplo, adicione "shardId".

A seguir