Consideraciones clave primarias no enteras

16

Contexto

Estoy diseñando una base de datos (en PostgreSQL 9.6) que almacenará datos de una aplicación distribuida. Debido a la naturaleza distribuida de la aplicación, no puedo usar enteros de incremento automático ( SERIAL) como mi clave principal debido a las posibles condiciones de carrera.

La solución natural es usar un UUID, o un identificador único global. Postgres viene con un built-in UUIDtipo , que es un ajuste perfecto.

El problema que tengo con UUID está relacionado con la depuración: es una cadena no amigable para los humanos. El identificador ff53e96d-5fd7-4450-bc99-111b91875ec5no me dice nada, mientras que ACC-f8kJd9xKCd, aunque no se garantiza que sea único, me dice que estoy tratando con un ACCobjeto.

Desde una perspectiva de programación, es común depurar consultas de aplicaciones relacionadas con varios objetos diferentes. Suponga que el programador busca erróneamente un ACCobjeto (cuenta) en la ORDtabla (orden). Con un identificador legible por humanos, el programador identifica instantáneamente el problema, mientras usa UUID, pasaría algún tiempo descubriendo qué estaba mal.

No necesito la unicidad "garantizada" de los UUID; Yo no necesito un poco de espacio para la generación de claves sin conflictos, pero UUID es un exceso. Además, en el peor de los casos, no sería el fin del mundo si ocurriera una colisión (la base de datos lo rechaza y la aplicación puede recuperarse). Entonces, considerando las compensaciones, un identificador más pequeño pero amigable para los humanos sería la solución ideal para mi caso de uso.

Identificar objetos de aplicación

El identificador que se me ocurrió tiene el siguiente formato:, {domain}-{string}donde {domain}se reemplaza con el dominio del objeto (cuenta, pedido, producto) y {string}es una cadena generada aleatoriamente. En algunos casos, incluso podría tener sentido insertar un {sub-domain}antes de la cadena aleatoria. Ignoremos la duración {domain}y {string}con el fin de garantizar la unicidad.

El formato puede tener un tamaño fijo si ayuda al rendimiento de indexación / consulta.

El problema

Sabiendo que:

  • Quiero tener claves primarias con un formato como ACC-f8kJd9xKCd.
  • Estas claves principales serán parte de varias tablas.
  • Todas estas claves se utilizarán en varias uniones / relaciones, en una base de datos 6NF.
  • La mayoría de las tablas tendrán un tamaño medio a grande (promediando ~ 1M filas; las más grandes con ~ 100M filas).

En cuanto al rendimiento, ¿cuál es la mejor manera de almacenar esta clave?

A continuación hay cuatro posibles soluciones, pero dado que tengo poca experiencia con bases de datos, no estoy seguro de cuál (si es que hay alguna) es la mejor.

Soluciones consideradas

1. Almacenar como cadena ( VARCHAR)

(Postgres no hace diferencia entre CHAR(n)y VARCHAR(n), así que estoy ignorando CHAR).

Después de investigar un poco, descubrí que la comparación de cadenas con VARCHAR, especialmente en operaciones de unión, es más lenta que el uso INTEGER. Esto tiene sentido, pero ¿es algo de lo que debería preocuparme a esta escala?

2. Almacenar como binario ( bytea)

A diferencia de Postgres, MySQL no tiene un UUIDtipo nativo . Hay varias publicaciones que explican cómo almacenar un UUID usando un BINARYcampo de 16 bytes , en lugar de uno de 36 bytes VARCHAR. Estas publicaciones me dieron la idea de almacenar la clave como binaria ( byteaen Postgres).

Esto ahorra tamaño, pero me preocupa más el rendimiento. Tuve poca suerte al encontrar una explicación sobre qué comparación es más rápida: binaria o de cadena. Creo que las comparaciones binarias son más rápidas. Si lo son, entonces byteaprobablemente sea mejor que eso VARCHAR, a pesar de que el programador ahora tiene que codificar / decodificar los datos cada vez.

Podría estar equivocado, pero pienso en ambos byteay VARCHARcompararé (igualdad) byte por byte (o carácter por carácter). ¿Hay alguna manera de "omitir" esta comparación paso a paso y simplemente comparar "todo"? (No lo creo, pero no cuesta comprobarlo).

Creo que almacenar byteaes la mejor solución, pero me pregunto si hay otras alternativas que estoy ignorando. Además, la misma preocupación que expresé en la solución 1 es cierta: ¿es suficiente la sobrecarga en las comparaciones por las que debería preocuparme?

"Soluciones creativas

Se me ocurrieron dos soluciones muy "creativas" que podrían funcionar, pero no estoy seguro de en qué medida (es decir, si tuviera problemas para escalarlas en más de un par de miles de filas en una tabla).

3. Almacene como UUIDpero con una "etiqueta" pegada

La razón principal para no usar UUID es para que los programadores puedan depurar mejor la aplicación. Pero qué UUIDpasa si podemos usar ambos: la base de datos almacena todas las claves solo como s, pero envuelve el objeto antes / después de realizar consultas.

Por ejemplo, el programador lo solicita ACC-{UUID}, la base de datos ignora la ACC-parte, obtiene los resultados y los devuelve todos como {domain}-{UUID}.

Tal vez esto sería posible con algún hacker con procedimientos o funciones almacenados, pero algunas preguntas me vienen a la mente:

  • ¿Es esto (eliminar / agregar el dominio en cada consulta) una sobrecarga sustancial?
  • ¿Es esto posible?

Nunca he usado procedimientos o funciones almacenados antes, así que no estoy seguro de si esto es posible. ¿Alguien puede arrojar algo de luz? Si puedo agregar una capa transparente entre el programador y los datos almacenados, parece una solución perfecta.

4. (Mi favorito) Almacenar como IPv6 cidr

Sí, has leído bien. Resulta que el formato de dirección IPv6 resuelve mi problema perfectamente .

  • Puedo agregar dominios y subdominios en los primeros octetos, y usar los restantes como cadena aleatoria.
  • Las probabilidades de colisión están bien. (Sin embargo, no estaría usando 2 ^ 128, pero todavía está bien).
  • Las comparaciones de igualdad están (con suerte) optimizadas, por lo que podría obtener un mejor rendimiento que simplemente usarlas bytea.
  • De hecho, puedo realizar algunas comparaciones interesantes, como contains, dependiendo de cómo se representan los dominios y su jerarquía.

Por ejemplo, supongamos que uso un código 0000para representar el dominio "productos". La clave 0000:0db8:85a3:0000:0000:8a2e:0370:7334representaría el producto 0db8:85a3:0000:0000:8a2e:0370:7334.

La pregunta principal aquí es: en comparación con bytea, ¿hay alguna ventaja o desventaja principal en el uso del cidrtipo de datos?

Renato Siqueira Massaro
fuente
55
¿Cuántos nodos distribuidos son posibles? ¿Conoces su número (y nombres) antes de tiempo? ¿Consideró las PK compuestas (de varias columnas)? Un dominio (dependiendo de mi primera pregunta), más una columna serial simple podría ser la más pequeña, la más simple y la más rápida ...
Erwin Brandstetter
@ Phil gracias! @ErwinBrandstetter Con respecto a la aplicación, está diseñada para escalar automáticamente según la carga, por lo que hay muy poca información por adelantado. He pensado en usar (dominio, UUID) como PK, pero esto repetiría "dominio" en todas partes, el dominio aún estaría varcharentre muchos otros problemas. No conocía los dominios de pg, lo cual es genial para aprender. Veo que se usan dominios para validar si una consulta dada está usando el objeto correcto, pero aún así dependería de tener un índice no entero. No estoy seguro si hay una forma "segura" de usar serialaquí (sin un paso de bloqueo).
Renato Siqueira Massaro
1
El dominio no necesariamente tiene que ser un varchar. Considere convertirlo en un FK integertipo y agregue una tabla de búsqueda. De esa manera, puede tener legibilidad humana y proteger su compuestoPK de las anomalías de inserción / actualización (poniendo un dominio inexistente).
yemet
1
Quiero tener claves primarias con un formato como ACC-f8kJd9xKCd. ”← Eso parece ser un trabajo para la vieja y mejor CLAVE PRIMARIA compuesta .
MDCCL

Respuestas:

5

Utilizando ltree

Si IPV6 funciona, genial. No es compatible con "ACC". ltreehace.

Una ruta de etiqueta es una secuencia de cero o más etiquetas separadas por puntos, por ejemplo L1.L2.L3, que representa una ruta desde la raíz de un árbol jerárquico a un nodo en particular. La longitud de una ruta de etiqueta debe ser inferior a 65kB, pero es preferible mantenerla por debajo de 2kB. En la práctica esto no es una limitación importante; por ejemplo, la ruta de etiqueta más larga en el catálogo DMOZ ( http://www.dmoz.org ) es de aproximadamente 240 bytes.

Lo usarías así,

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

Creamos datos de muestra.

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

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

Ver los documentos para más información y operadores.

Si está creando los identificadores de producto, yo haría ltree. Si necesita algo para crearlos, usaría UUID.

Evan Carroll
fuente
1

Solo con respecto a la comparación de rendimiento con bytea. La comparación de la red se realiza en 3 pasos: primero en los bits comunes de la parte de la red, luego en la longitud de la parte de la red y luego en toda la dirección sin máscara. ver: network_cmp_internal

por lo tanto, debería ser un poco más lento que bytea, que va directo a memcmp. He ejecutado una prueba simple en una tabla con 10 millones de filas buscando una sola:

  • usando la identificación numérica (entero) me tomó 1000 ms.
  • usando cidr tomó 1300ms.
  • usando bytea tomó 1250ms.

No puedo decir que haya mucha diferencia entre el bytea y el cidr (aunque la brecha se mantuvo constante) Solo el adicional if declaración : supongo que eso no es tan malo para las tuplas de 10m.

Espero que ayude, me encantaría saber qué elegiste.

Cohenjo
fuente