Práticas recomendadas de design de esquemas

A arquitetura distribuída do Spanner permite-lhe estruturar o seu esquema para evitar hotspots, ou seja, situações em que são enviados demasiados pedidos para o mesmo servidor, o que satura os recursos do servidor e pode causar latências elevadas.

Esta página descreve as práticas recomendadas para criar os seus esquemas de modo a evitar a criação de pontos críticos. Uma forma de evitar hotspots é ajustar o design do esquema para permitir que o Spanner divida e distribua os dados por vários servidores. A distribuição de dados entre servidores ajuda a sua base de dados do Spanner a funcionar de forma eficiente, especialmente quando realiza inserções de dados em massa.

Escolha uma chave principal para evitar pontos críticos

Conforme mencionado em Esquema e modelo de dados, deve ter cuidado ao escolher uma chave primária na conceção do esquema para não criar acidentalmente pontos críticos na sua base de dados. Uma das causas dos pontos críticos é ter uma coluna cujo valor muda monotonicamente como a primeira parte da chave, porque isto faz com que todas as inserções ocorram no final do seu espaço de chaves. Este padrão é indesejável porque o Spanner usa intervalos de chaves para dividir os dados entre os servidores, o que significa que todas as suas inserções são direcionadas para um único servidor que acaba por fazer todo o trabalho.

Por exemplo, suponhamos que quer manter uma coluna de data/hora do último acesso nas linhas da tabela UserAccessLogs. A definição da tabela seguinte usa uma chave principal baseada na data/hora como a primeira parte da chave. Não recomendamos esta opção se a tabela tiver uma taxa de inserção elevada:

GoogleSQL

CREATE TABLE UserAccessLogs (
  LastAccess TIMESTAMP NOT NULL,
  UserId STRING(1024),
  ...
) PRIMARY KEY (LastAccess, UserId);

PostgreSQL

CREATE TABLE useraccesslogs (
  lastaccess timestamptz NOT NULL,
  userid text,
  ...
PRIMARY KEY (lastaccess, userid)
);

O problema aqui é que as linhas são escritas nesta tabela por ordem da data/hora do último acesso e, como as datas/horas do último acesso estão sempre a aumentar, são sempre escritas no final da tabela. A zona Wi-Fi é criada porque um único servidor do Spanner recebe todas as escritas, o que sobrecarrega esse servidor.

O diagrama seguinte ilustra este erro:

Tabela UserAccessLog ordenada por data/hora com a zona Wi-Fi correspondente

A tabela UserAccessLogs anterior inclui cinco linhas de dados de exemplo, que representam cinco utilizadores diferentes que realizam algum tipo de ação do utilizador com um intervalo de cerca de um milissegundo entre si. O diagrama também anota a ordem em que o Spanner insere as linhas (as setas etiquetadas indicam a ordem de escritas para cada linha). Uma vez que as inserções são ordenadas por data/hora e o valor de data/hora está sempre a aumentar, o Spanner adiciona sempre as inserções ao final da tabela e direciona-as para a mesma divisão. (Conforme abordado no esquema e modelo de dados, uma divisão é um conjunto de linhas de uma ou mais tabelas relacionadas que o Spanner armazena por ordem da chave da linha.)

Isto é problemático porque o Spanner atribui trabalho a diferentes servidores em unidades de divisões, pelo que o servidor atribuído a esta divisão específica acaba por processar todos os pedidos de inserção. À medida que a frequência dos eventos de acesso dos utilizadores aumenta, a frequência dos pedidos de inserção para o servidor correspondente também aumenta. O servidor torna-se, então, propenso a tornar-se um ponto de acesso e apresenta o aspeto da borda e do fundo vermelhos apresentados na imagem anterior. Nesta ilustração simplificada, cada servidor processa, no máximo, uma divisão, mas o Spanner pode atribuir a cada servidor mais do que uma divisão.

Quando o Spanner anexa mais linhas à tabela, a divisão aumenta e, em seguida, cria novas divisões conforme necessário. Para saber como são criadas as divisões, consulte o artigo Divisão com base na carga. O Spanner anexa as novas linhas subsequentes a esta nova divisão e o servidor atribuído à divisão torna-se o novo potencial ponto crítico.

Quando ocorrem pontos críticos, pode observar que as suas inserções são lentas e que outras tarefas no mesmo servidor podem ficar mais lentas. Alterar a ordem da coluna LastAccess para ordem ascendente não resolve este problema, porque todas as gravações são inseridas na parte superior da tabela, o que continua a enviar todas as inserções para um único servidor.

Prática recomendada de design de esquemas n.º 1: não escolha uma coluna cujo valor aumente ou diminua monotonicamente como a primeira parte da chave para uma tabela com uma taxa de gravação elevada.

Use um identificador exclusivo universal (UUID)

Pode usar um identificador único universal (UUID), conforme definido pelo RFC 4122, como chave principal. Recomendamos a utilização da versão 4 do UUID, porque usa valores aleatórios na sequência de bits. Não recomendamos UUIDs da versão 1 porque armazenam a data/hora nos bits de ordem superior.

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

  • Numa coluna STRING(36).
  • Num par de colunas INT64.
  • Numa coluna BYTES(16).

Para uma coluna STRING(36), pode usar a função Spanner GENERATE_UUID() (GoogleSQL ou PostgreSQL) como o valor predefinido da coluna para que o Spanner gere automaticamente valores UUID.

Por exemplo, para a seguinte tabela:

GoogleSQL

CREATE TABLE UserAccessLogs (
  LogEntryId STRING(36) NOT NULL,
  LastAccess TIMESTAMP NOT NULL,
  UserId STRING(1024),
  ...
) PRIMARY KEY (LogEntryId, LastAccess, UserId);

PostgreSQL

CREATE TABLE useraccesslogs (
  logentryid VARCHAR(36) NOT NULL,
  lastaccess timestamptz NOT NULL,
  userid text,
  ...
PRIMARY KEY (lastaccess, userid)
);

Pode inserir GENERATE_UUID() para gerar os valores LogEntryId. GENERATE_UUID() produz um valor STRING, pelo que a coluna LogEntryId tem de usar o tipo STRING para o GoogleSQL ou o tipo text para o PostgreSQL.

GoogleSQL

INSERT INTO
  UserAccessLogs (LogEntryId, LastAccess, UserId)
VALUES
  (GENERATE_UUID(), '2016-01-25 10:10:10.555555-05:00', 'TomSmith');

PostgreSQL

INSERT INTO
  useraccesslogs (logentryid, lastaccess, userid)
VALUES
  (spanner.generate_uuid(),'2016-01-25 10:10:10.555555-05:00', 'TomSmith');

Existem algumas desvantagens na utilização de um UUID:

  • São ligeiramente grandes, com 16 bytes ou mais. Outras opções para chaves primárias não usam tanto armazenamento.
  • Não contêm informações sobre o registo. Por exemplo, uma chave principal de SingerId e AlbumId tem um significado inerente, enquanto um UUID não tem.
  • Perde a localidade entre registos relacionados, motivo pelo qual a utilização de um UUID elimina os pontos críticos.

Valores sequenciais invertidos por bits

Deve garantir que as chaves primárias numéricas (INT64 no GoogleSQL ou bigint no PostgreSQL) não aumentam nem diminuem sequencialmente. As chaves primárias sequenciais podem causar pontos críticos em grande escala. Uma forma de evitar este problema é inverter os bits dos valores sequenciais, certificando-se de que distribui os valores da chave primária uniformemente pelo espaço de chaves.

O Spanner suporta a sequência invertida por bits, que gera valores inteiros únicos invertidos por bits. Pode usar uma sequência no primeiro (ou único) componente de uma chave principal para evitar problemas de uso excessivo de recursos. Para mais informações, consulte o artigo Sequência invertida.

Troque a ordem das teclas

Uma forma de distribuir as escritas pelo espaço de chaves de forma mais uniforme é trocar a ordem das chaves para que a coluna que contém o valor monótono não seja a primeira parte da chave:

GoogleSQL

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

PostgreSQL

CREATE TABLE useraccesslogs (
userid bigint NOT NULL,
lastaccess TIMESTAMPTZ NOT NULL,
...
PRIMARY KEY (UserId, LastAccess)
);

Neste esquema modificado, as inserções são agora ordenadas primeiro por UserId, em vez de pela data/hora de acesso mais recente cronológica. Este esquema distribui as gravações por diferentes divisões porque é improvável que um único utilizador produza milhares de eventos por segundo.

A imagem seguinte mostra as cinco linhas da tabela UserAccessLogs que o Spanner ordena com UserId em vez da data/hora de acesso:

Tabela UserAccessLogs ordenada por UserId com débito de escrita equilibrado

Aqui, o Spanner pode dividir os dados UserAccessLogs em três divisões, com cada divisão a conter aproximadamente mil linhas de valores UserId ordenados. Embora os eventos do utilizador tenham ocorrido com cerca de um milissegundo de diferença, cada evento foi gerado por um utilizador diferente. Por isso, é muito menos provável que a ordem das inserções crie um ponto crítico em comparação com a utilização da data/hora para ordenar. Para saber mais sobre como são criadas as divisões, consulte o artigo Divisão com base na carga

Consulte também a prática recomendada relacionada para ordenar chaves baseadas na data/hora.

Aplique hash à chave única e distribua as escritas por fragmentos lógicos

Outra técnica comum para distribuir a carga por vários servidores é criar uma coluna que contenha o hash da chave única real e, em seguida, usar a coluna de hash (ou a coluna de hash e as colunas de chaves únicas em conjunto) como a chave primária. Este padrão ajuda a evitar pontos críticos, porque as novas linhas são distribuídas de forma mais uniforme no espaço de chaves.

Pode usar o valor hash para criar fragmentos lógicos ou partições na sua base de dados. Numa base de dados dividida fisicamente, as linhas são distribuídas por vários servidores de base de dados. Numa base de dados dividida logicamente, os dados na tabela definem os fragmentos. Por exemplo, para distribuir as escritas na tabela UserAccessLogs por N fragmentos lógicos, pode antepor uma coluna de chave ShardId à tabela:

GoogleSQL

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

PostgreSQL

CREATE TABLE useraccesslogs (
shardid bigint NOT NULL,
lastaccess TIMESTAMPTZ NOT NULL,
userid bigint NOT NULL,
...
PRIMARY KEY (shardid, lastaccess, userid)
);

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

GoogleSQL

ShardId = hash(LastAccess and UserId) % N

A sua escolha da função de hash e da combinação de colunas determina a forma como as linhas são distribuídas pelo espaço de chaves. Em seguida, o Spanner cria divisões nas linhas para otimizar o desempenho.

O diagrama seguinte ilustra como a utilização de um hash para criar três fragmentos lógicos pode distribuir o débito de gravação de forma mais uniforme pelos servidores:

Tabela UserAccessLogs ordenada por ShardId com débito de gravação equilibrado

Aqui, a tabela UserAccessLogs está ordenada por ShardId, que é calculado como uma função hash das colunas principais. As cinco linhas UserAccessLogs são divididas em três fragmentos lógicos, cada um dos quais está coincidentemente numa divisão diferente. As inserções são distribuídas uniformemente entre as divisões, o que equilibra o débito de gravação para os três servidores que processam as divisões.

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

Para o fazer no GoogleSQL, use a função FARM_FINGERPRINT durante o tempo de gravação, como mostrado no exemplo seguinte:

GoogleSQL

CREATE TABLE UserAccessLogs (
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);

A sua escolha da função hash determina a forma como as inserções são distribuídas no intervalo de chaves. Não precisa de um hash criptográfico, embora um hash criptográfico possa ser uma boa escolha. Quando escolher uma função de hash, tem de ter em conta os seguintes fatores:

  • Evitar pontos críticos. Uma função que resulta em mais valores hash tende a reduzir os pontos críticos.
  • Ler eficiência. As leituras em todos os valores de hash são mais rápidas se houver menos valores de hash para analisar.
  • Número de nós.

Use a ordem descendente para chaves baseadas na data/hora

Se tiver uma tabela para o seu histórico que use a data/hora como chave, considere usar a ordem descendente para a coluna de chave se alguma das seguintes situações se aplicar:

  • Se quiser ler o histórico mais recente, está a usar uma tabela intercalada para o histórico e a ler a linha principal. Neste caso, com uma coluna de data/hora DESC, as entradas do histórico mais recentes são armazenadas adjacentes à linha principal. Caso contrário, a leitura da linha principal e do respetivo histórico recente requer uma procura no meio para ignorar o histórico mais antigo.
  • Se estiver a ler entradas sequenciais por ordem cronológica inversa e não souber exatamente até que ponto está a recuar. Por exemplo, pode usar uma consulta SQL com um LIMIT para obter os N eventos mais recentes ou planear cancelar a leitura depois de ler um determinado número de linhas. Nestes casos, deve começar pelas entradas mais recentes e ler sequencialmente as entradas mais antigas até que a sua condição seja cumprida, o que o Spanner faz de forma mais eficiente para chaves de data/hora que o Spanner armazena por ordem descendente.

Adicione a palavra-chave DESC para ordenar a chave de indicação de tempo por ordem descendente. Por exemplo:

GoogleSQL

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

Prática recomendada de design de esquemas n.º 2: a ordem descendente ou ascendente depende das consultas do utilizador. Por exemplo, o topo pode ser o mais recente ou o mais antigo.

Quando usar um índice intercalado

Tal como no exemplo anterior de chave primária que deve evitar, também é uma má ideia criar índices não intercalados em colunas cujos valores estão a aumentar ou diminuir monotonicamente, mesmo que não sejam colunas de chave primária.

Por exemplo, suponha que define a seguinte tabela, na qual LastAccess é uma coluna de chave não principal:

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 a base de dados para acessos de utilizadores "desde a hora X", da seguinte forma:

GoogleSQL

CREATE NULL_FILTERED INDEX UsersByLastAccess ON Users(LastAccess);

PostgreSQL

CREATE INDEX usersbylastaccess ON users(lastaccess)
WHERE lastaccess IS NOT NULL;

No entanto, isto resulta na mesma armadilha descrita na prática recomendada anterior, porque o Spanner implementa índices como tabelas nos bastidores, e a tabela de índice resultante usa uma coluna cujo valor aumenta monotonicamente como a sua primeira parte da chave.

Não há problema em criar um índice intercalado em que as linhas de último acesso são intercaladas na linha do utilizador correspondente. Isto deve-se ao facto de ser improvável que uma única linha principal produza milhares de eventos por segundo.

GoogleSQL

CREATE NULL_FILTERED INDEX UsersByLastAccess
ON Users(UserId, LastAccess),
INTERLEAVE IN Users;

PostgreSQL

CREATE INDEX usersbylastaccess ON users(userid, lastaccess)
WHERE lastaccess IS NOT NULL,
INTERLEAVE IN Users;

Prática recomendada de design de esquemas n.º 3: não crie um índice não intercalado numa coluna de taxa de gravação elevada cujo valor aumente ou diminua monotonicamente. Use um índice intercalado ou técnicas semelhantes às que usaria para a estrutura da chave principal da tabela base ao estruturar colunas de índice. Por exemplo, adicione `shardId`.

O que se segue?