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 pontos críticos é ajustar a estrutura 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.

O Spanner deteta automaticamente oportunidades para aplicar as práticas recomendadas de design de esquemas. Se estiverem disponíveis recomendações para uma base de dados, pode vê-las na página Spanner Studio dessa base de dados. Para mais informações, consulte o artigo Veja as recomendações de práticas recomendadas de criação de esquemas.

Escolha uma chave principal para evitar pontos críticos

Para evitar a criação de hotspots na sua base de dados, escolha cuidadosamente uma chave primária durante a conceção do esquema.

Uma causa comum de pontos críticos é a utilização de uma chave que aumenta ou diminui monotonicamente, como uma data/hora. As chaves monótonas fazem com que todas as novas entradas sejam escritas no mesmo intervalo do seu espaço de chaves. Uma vez que o Spanner usa intervalos de chaves para distribuir dados pelos servidores, uma chave monótona direciona todo o tráfego de inserção para um único servidor, criando um gargalo.

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 o ponto de acesso 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 uma diferença 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 crítico e apresenta o aspeto do limite 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, quando atinge aproximadamente 8 GiB, o Spanner cria outra divisão, conforme descrito na divisão baseada no carregamento. 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 pela 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 principal:

  • 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 (logentryid, 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 verificar se as chaves primárias numéricas (INT64 no GoogleSQL ou bigint no PostgreSQL) não estão a aumentar nem a diminuir 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 pelo 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