A arquitetura distribuída do Spanner permite projetar seu esquema para evitar pontos de acesso, ou seja, situações em que muitas solicitações são enviadas para o mesmo servidor, o que satura os recursos dele e pode causar latências altas.
Nesta página, descrevemos as práticas recomendadas para projetar seus esquemas e 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.
Escolha uma chave primária para evitar pontos de acesso:
Conforme mencionado em Modelo de dados e esquema, tenha cuidado ao escolher uma chave primária no design do esquema para não criar, acidentalmente, pontos 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 muda 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 Spanner usa intervalos de chaves para dividir dados entre servidores. Isso 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 UserAccessLogs
. A definição de tabela a seguir usa uma chave primária baseada em carimbo de data/hora como a primeira parte da chave. Não recomendamos isso se
a tabela tiver uma alta taxa de inserção:
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 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 escritos 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:
A tabela UserAccessLogs
anterior 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 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, o Spanner sempre adiciona as inserções ao final da tabela e as direciona 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 o Spanner armazena por ordem da chave de linha.
Isso é problemático, porque o 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 e fica parecido com a borda vermelha e o plano de fundo mostrados na imagem anterior. Nesta ilustração simplificada, cada servidor lida com o máximo de uma divisão, mas o Spanner pode atribuir mais de uma divisão a cada servidor.
Quando o Spanner anexa mais linhas à tabela, a divisão cresce e cria novas divisões conforme necessário. Para saber mais sobre como as divisões são criadas, consulte Divisão com base em carga. O Spanner anexa novas linhas subsequentes a essa nova divisão, e o servidor atribuído a ela se torna o novo ponto de acesso 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 a versão 4 do UUID, porque ela usa valores aleatórios na sequência de bits. Não recomendamos UUIDs da versão 1 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)
, use a função GENERATE_UUID()
do Spanner (GoogleSQL ou PostgreSQL) como o valor padrão da coluna para que o Spanner gere automaticamente valores de UUID.
Por exemplo, para a tabela a seguir:
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)
);
Você pode inserir GENERATE_UUID()
para gerar os valores LogEntryId
.
GENERATE_UUID()
produz um valor STRING
. Portanto, a coluna LogEntryId
precisa usar o tipo STRING
para GoogleSQL ou o tipo text
para 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 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.
- Elas não carregam informações sobre o registro. Por exemplo, uma chave primária de
SingerId
eAlbumId
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
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 pontos de acesso em grande escala. Uma maneira de evitar esse problema é inverter os bits dos valores sequenciais, distribuindo os valores de chave primária de maneira uniforme no espaço de chaves.
O Spanner oferece suporte a sequências invertidas por bits, que geram valores inteiros exclusivos invertidos por bits. É possível usar uma sequência no primeiro (ou único) componente de uma chave primária para evitar problemas de hotspot. Para mais informações, consulte Sequência invertida por 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 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) );
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.
A imagem a seguir mostra as cinco linhas da tabela UserAccessLogs
que
o Spanner ordena com UserId
em vez de carimbo de data/hora de acesso:
Nesse caso, o Spanner pode dividir os dados de UserAccessLogs
em três divisões, cada uma contendo aproximadamente mil linhas de valores de UserId
ordenados. 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. Para
saber mais sobre como as divisões são criadas, consulte
Divisão baseada em carga
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 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 UserAccessLogs
em N fragmentos lógicos, seria possível preceder 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
, gere o hash de uma combinação das colunas de chave primária e calcule o módulo N do hash. Exemplo:
GoogleSQL
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 Spanner vai criar divisões nas linhas para otimizar o desempenho.
O diagrama a seguir 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:
Aqui, a tabela UserAccessLogs
é ordenada por ShardId
, que é calculada como uma função hash de colunas de chave. As cinco linhas de UserAccessLogs
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 GoogleSQL, use a função FARM_FINGERPRINT durante o tempo de gravação, como mostrado no exemplo a seguir:
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);
Sua escolha da função hash determina o quanto suas inserções sã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 os seguintes fatores:
- Evitar 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 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, considere usar a ordem decrescente para a coluna-chave se qualquer uma das situações a seguir se aplicar:
- Se você quiser ler o histórico mais recente, estiver usando uma tabela intercalada para o histórico e 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, 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 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 UserAccessLogs ( UserId INT64 NOT NULL, LastAccess TIMESTAMP NOT NULL, ... ) PRIMARY KEY (UserId, LastAccess DESC);
Prática recomendada de criação do esquema nº 2: a ordem decrescente ou crescente depende das consultas do usuário. Por exemplo, o primeiro item pode ser o mais novo ou o mais antigo.
Quando usar um índice intercalado
De maneira semelhante ao exemplo anterior de chave primária que você deve evitar, 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:
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 conforme a primeira parte da chave.
É bom criar um índice intercalado em que as linhas de último acesso são intercaladas na linha do usuário correspondente. Isso porque é improvável que uma única linha pai 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 esquema nº 3: não crie um índice sem intercalação em uma coluna de alta taxa de gravação cujo valor aumente ou diminua monotonicamente. Use um índice intercalado ou técnicas como as que você usaria para o design da chave primária da tabela de base ao criar colunas de índice. Por exemplo, adicione "shardId".
A seguir
- Confira exemplos de designs de esquemas.
- Saiba mais sobre carregar dados em massa.