Considerações sobre chave primária não inteira

16

Contexto

Estou projetando um banco de dados (no PostgreSQL 9.6) que armazena dados de um aplicativo distribuído. Devido à natureza distribuída do aplicativo, não posso usar números inteiros de incremento automático ( SERIAL) como minha chave primária devido a possíveis condições de corrida.

A solução natural é usar um UUID ou um identificador globalmente exclusivo. O Postgres vem com um tipo embutidoUUID , que é um ajuste perfeito.

O problema que tenho com o UUID está relacionado à depuração: é uma sequência não amigável ao ser humano. O identificador não ff53e96d-5fd7-4450-bc99-111b91875ec5diz nada, enquanto ACC-f8kJd9xKCd, embora não seja garantido que seja único, diz que estou lidando com um ACCobjeto.

De uma perspectiva de programação, é comum depurar consultas de aplicativos relacionadas a vários objetos diferentes. Suponha que o programador procure erradamente um ACCobjeto (conta) na ORDtabela (pedido). Com um identificador legível por humanos, o programador identifica instantaneamente o problema, enquanto usava UUIDs, ele passava algum tempo descobrindo o que estava errado.

Não preciso da exclusividade "garantida" dos UUIDs; Eu não preciso de algum espaço para a geração de chaves, sem conflitos, mas UUID é um exagero. Além disso, no pior cenário, não seria o fim do mundo se ocorresse uma colisão (o banco de dados a rejeita e o aplicativo pode se recuperar). Portanto, considerando as vantagens e desvantagens, um identificador menor, porém amigável ao ser humano, seria a solução ideal para o meu caso de uso.

Identificando objetos de aplicativo

O identificador que criei tem o seguinte formato:, {domain}-{string}onde {domain}é substituído pelo domínio do objeto (conta, pedido, produto) e {string}é uma sequência gerada aleatoriamente. Em alguns casos, pode até fazer sentido inserir a {sub-domain}antes da sequência aleatória. Vamos ignorar o comprimento {domain}e {string}com o objetivo de garantir a exclusividade.

O formato pode ter um tamanho fixo se ajudar no desempenho da indexação / consulta.

O problema

Sabendo que:

  • Eu quero ter chaves primárias com um formato como ACC-f8kJd9xKCd.
  • Essas chaves primárias farão parte de várias tabelas.
  • Todas essas chaves serão usadas em várias junções / relacionamentos, em um banco de dados 6NF.
  • A maioria das tabelas terá um tamanho médio a grande (média de ~ 1 milhão de linhas; as maiores com ~ 100 milhões de linhas).

Em relação ao desempenho, qual é a melhor maneira de armazenar essa chave?

Abaixo estão quatro soluções possíveis, mas como tenho pouca experiência com bancos de dados, não tenho certeza qual (se houver) é a melhor.

Soluções consideradas

1. Armazene como string ( VARCHAR)

(O Postgres não faz diferença entre CHAR(n)e VARCHAR(n), por isso estou ignorando CHAR).

Após algumas pesquisas, descobri que a comparação de strings com VARCHAR, especialmente em operações de junção, é mais lenta do que usando INTEGER. Isso faz sentido, mas é algo com que eu deveria me preocupar nessa escala?

2. Armazenar como binário ( bytea)

Diferentemente do Postgres, o MySQL não possui um UUIDtipo nativo . Existem várias postagens explicando como armazenar um UUID usando um BINARYcampo de 16 bytes , em vez de um campo de 36 bytes VARCHAR. Essas postagens me deram a idéia de armazenar a chave como binária ( byteano Postgres).

Isso economiza tamanho, mas estou mais preocupado com o desempenho. Tive pouca sorte em encontrar uma explicação sobre qual comparação é mais rápida: binária ou de string. Eu acredito que as comparações binárias são mais rápidas. Se estiverem, byteaprovavelmente é melhor do que VARCHAR, mesmo que o programador agora precise codificar / decodificar os dados todas as vezes.

Posso estar errado, mas acho que ambos byteae VARCHARcompararei (igualdade) byte por byte (ou caractere por caractere). Existe uma maneira de "pular" essa comparação passo a passo e simplesmente comparar "a coisa toda"? (Acho que não, mas não custa checar).

Acho que armazenar byteaé a melhor solução, mas me pergunto se existem outras alternativas que estou ignorando. Além disso, a mesma preocupação que expressei na solução 1 é verdadeira: a sobrecarga nas comparações é suficiente para me preocupar?

"Soluções criativas

Eu vim com duas soluções muito "criativas" que podem funcionar, mas não tenho certeza até que ponto (ou seja, se eu tiver problemas para dimensioná-las para mais de algumas milhares de linhas em uma tabela).

3. Armazene como UUIDcom um "rótulo" anexado

O principal motivo para não usar UUIDs é para que os programadores possam depurar melhor o aplicativo. Mas e se pudermos usar os dois: o banco de dados armazena todas as chaves UUIDapenas como s, mas envolve o objeto antes / depois das consultas.

Por exemplo, o programador pede ACC-{UUID}, o banco de dados ignora a ACC-parte, busca os resultados e retorna todos eles como {domain}-{UUID}.

Talvez isso seja possível com alguma invasão com procedimentos ou funções armazenadas, mas algumas perguntas vêm à mente:

  • Isso (remover / adicionar o domínio em cada consulta) é uma sobrecarga substancial?
  • Isso é possível?

Eu nunca usei procedimentos ou funções armazenados antes, então não tenho certeza se isso é possível. Alguém pode lançar alguma luz? Se eu puder adicionar uma camada transparente entre o programador e os dados armazenados, parece uma solução perfeita.

4. (O meu favorito) Armazene como IPv6 cidr

Sim, você leu certo. Acontece que o formato do endereço IPv6 resolve meu problema perfeitamente .

  • Posso adicionar domínios e subdomínios nos primeiros octetos e usar os restantes como uma sequência aleatória.
  • As probabilidades de colisão estão OK. (Eu não usaria 2 ^ 128, mas ainda está OK.)
  • Esperamos que as comparações de igualdade sejam otimizadas, para que eu possa obter melhor desempenho do que simplesmente usar bytea.
  • Eu posso realmente fazer algumas comparações interessantes, como contains, dependendo de como os domínios e sua hierarquia são representados.

Por exemplo, suponha que eu use o código 0000para representar o domínio "produtos". A chave 0000:0db8:85a3:0000:0000:8a2e:0370:7334representaria o produto 0db8:85a3:0000:0000:8a2e:0370:7334.

A principal questão aqui é: em comparação com bytea, existe alguma vantagem ou desvantagem no uso do cidrtipo de dados?

Renato Siqueira Massaro
fonte
5
Quantos nós distribuídos são possíveis? Você sabe o número (e os nomes) com antecedência? Você considerou PKs compostas (várias colunas)? Um domínio (dependendo da minha primeira pergunta), além de uma coluna serial simples pode ser menor, mais simples e mais rápido ...
Erwin Brandstetter
@Phil thanks! @ErwinBrandstetter Em relação ao aplicativo, ele está sendo projetado para dimensionar automaticamente de acordo com a carga, para que haja muito pouca informação com antecedência. Pensei em usar (domínio, UUID) como PK, mas isso repetiria "domínio" por todo o lado, o domínio ainda estaria varcharentre muitos outros problemas. Eu não sabia sobre os domínios da pg, o que é ótimo para aprender. Vejo domínios sendo usados ​​para validar se uma determinada consulta está usando o objeto correto, mas ele ainda depende de ter um índice não inteiro. Não tenho certeza se existe uma maneira "segura" de usar serialaqui (sem uma etapa de bloqueio).
Renato Siqueira Massaro
11
O domínio não precisa necessariamente ser um varchar. Considere transformá-lo em um FK integertipo e adicione uma tabela de pesquisa. Dessa forma, você pode ter legibilidade humana e protegerá seu composto PKcontra anomalias de inserção / atualização (colocando um domínio inexistente).
yemet 02/10
11
Quero ter chaves primárias com um formato parecido ACC-f8kJd9xKCd. ← ← Parece ser um trabalho para a boa e velha chave primária composta .
MDCCL 04/10

Respostas:

5

Usando ltree

Se o IPV6 funcionar, ótimo. Não suporta "ACC". ltreefaz.

Um caminho de rótulo é uma sequência de zero ou mais rótulos separados por pontos, por exemplo L1.L2.L3, representando um caminho da raiz de uma árvore hierárquica para um nó específico. O comprimento do caminho de um rótulo deve ser menor que 65 kB, mas é preferível mantê-lo abaixo de 2 kB. Na prática, essa não é uma grande limitação; por exemplo, o caminho mais longo do rótulo no catálogo DMOZ ( http://www.dmoz.org ) é de cerca de 240 bytes.

Você usaria assim,

CREATE EXTENSION ltree;
SELECT replace('ACC-f8kJd9xKCd', '-', '.')::ltree;

Criamos dados de amostra.

SELECT x, (
  CASE WHEN x%7=0 THEN 'ACC'
    WHEN x%3=0 THEN 'XYZ'
    ELSE 'COM'
  END ||'.'|| md5(x::text)
  )::ltree
FROM generate_series(1,10000) AS t(x);

CREATE INDEX ON foo USING GIST (ltree);
ANALYZE foo;


  x  |                ltree                 
-----+--------------------------------------
   1 | COM.c4ca4238a0b923820dcc509a6f75849b
   2 | COM.c81e728d9d4c2f636f067f89cc14862c
   3 | XYZ.eccbc87e4b5ce2fe28308fd9f2a7baf3
   4 | COM.a87ff679a2f3e71d9181a67b7542122c
   5 | COM.e4da3b7fbbce2345d7772b0674a318d5
   6 | XYZ.1679091c5a880faf6fb5e6087eb1b2dc
   7 | ACC.8f14e45fceea167a5a36dedd4bea2543
   8 | COM.c9f0f895fb98ab9159f51fd0297e236d

E viola ..

                                                          QUERY PLAN                                                          
------------------------------------------------------------------------------------------------------------------------------
 Bitmap Heap Scan on foo  (cost=103.23..234.91 rows=1414 width=57) (actual time=0.422..0.908 rows=1428 loops=1)
   Recheck Cond: ('ACC'::ltree @> ltree)
   Heap Blocks: exact=114
   ->  Bitmap Index Scan on foo_ltree_idx  (cost=0.00..102.88 rows=1414 width=0) (actual time=0.389..0.389 rows=1428 loops=1)
         Index Cond: ('ACC'::ltree @> ltree)
 Planning time: 0.133 ms
 Execution time: 1.033 ms
(7 rows)

Consulte os documentos para obter mais informações e operadores

Se você estiver criando os IDs do produto, eu ltree. Se você precisar de algo para criá-los, eu usaria o UUID.

Evan Carroll
fonte
1

Apenas sobre a comparação de desempenho com bytea. A comparação da rede é feita em três etapas: primeiro nos bits comuns da parte da rede, depois no comprimento da parte da rede e, em seguida, no endereço inteiro não mascarado. consulte: network_cmp_internal

então deve ser um pouco mais lento que o bytea, que vai direto para o memcmp. Fiz um teste simples em uma tabela com 10 milhões de linhas procurando uma única:

  • usando id numérico (número inteiro) me levou 1000ms.
  • usando cidr levou 1300ms.
  • usando bytea levou 1250ms.

Não posso dizer que há muita diferença entre o bytea e o cidr (embora a diferença tenha permanecido consistente) if.

Espero que ajude - adoraria ouvir o que você acabou escolhendo.

cohenjo
fonte