Los riesgos de usar UUID como clave primaria en SQLite
(andersmurphy.com)- La implementación de claves primarias en SQLite hace que el orden de almacenamiento físico sea distinto entre las tablas
rowidnormales y las tablasWITHOUT ROWID; si se usa un UUID4 aleatorio como índice clúster, se generan reequilibrios del B-tree y costos adicionales de paginación - La línea base con
rowidentero registra alrededor de 1 millón de inserciones por segundo en inserciones por lotes de 1 millón de filas, mientras que UUID4 WITHOUT ROWID mostró tiempos de inserción entre 14 y 16 veces más lentos - La naturaleza desordenada de UUID4 hace que las filas se inserten de forma aleatoria en el B-tree, y el perfilado mostró más tiempo dedicado al balanceo del árbol y a operaciones de lectura y escritura
- UUID7 WITHOUT ROWID redujo el problema de ordenamiento de UUID4 al ser un UUID ordenado por tiempo, mostrando tiempos de inserción más razonables, aunque sigue siendo más lento que una clave entera de 8 bytes por usar una clave
BLOBde 16 bytes - UUID4 WITH ROWID aprovecha la secuencialidad del
rowidoculto, pero la amplificación de escritura causada por tener dos índices y el costo de inserciones aleatorias en el índice siguen presentes, por lo que rinde peor que UUID7 WITHOUT ROWID
¿Qué es un índice clúster?
- Un índice clúster es el índice que determina el orden de almacenamiento físico de las filas de una tabla
- Como las filas físicamente solo pueden ordenarse de una manera, cada tabla puede tener un solo índice clúster
- El índice clúster es la tabla misma, mientras que los índices no clúster solo almacenan la columna indexada y un puntero a la ubicación donde están los datos reales de la fila
Rowid
- Todas las tablas normales de SQLite tienen una clave primaria implícita entera de 64 bits llamada
rowid - Los datos de la tabla se almacenan en una estructura B-tree con una entrada por cada fila, usando el valor de
rowidcomo clave - En la práctica,
rowides el índice clúster de SQLite, y el orden físico de almacenamiento de las filas sigue el orden derowid
WITHOUT ROWID
- SQLite soporta tablas
WITHOUT ROWID, que no tienen elrowidimplícito - En las tablas
WITHOUT ROWID, la clave primaria declarada actúa como índice clúster - Las tablas
rowidde SQLite están implementadas como un B*-Tree donde todo el contenido se guarda en las hojas, mientras que las tablasWITHOUT ROWIDusan un B-Tree normal que guarda contenido tanto en las hojas como en los nodos intermedios
Línea base: clave primaria entera con rowid
- La línea base midió el tiempo de inserción por bloques de 1 millón de filas en una tabla
rowidnormal con estructuraid INTEGER PRIMARY KEY, data BLOB - En la tabla de resultados, el total de filas va de 10 millones a 100 millones, y los tiempos medidos van de 692 ms a 838 ms
- El rendimiento base es de aproximadamente 1 millón de inserciones por segundo
UUID4 WITHOUT ROWID
- La prueba con UUID4 insertó valores
random-uuid4-bytescomo clave primaria en una tablaWITHOUT ROWIDcon estructuraid BLOB PRIMARY KEY, data BLOB - En la tabla de resultados, el tiempo medido fue de 2649 ms con 10 millones de filas y de 12586 ms con 100 millones de filas
- El rendimiento de inserción fue entre 14 y 16 veces más lento que la línea base con
rowidentero
Perfilado
- El diffgraph normalizado compara los snapshots de perfilado de INTEGER y UUID4, y muestra las diferencias en formato flamegraph
- La vista normalizada ajusta el número total de muestras de ambos perfiles para mostrar la diferencia relativa en porcentaje
- Los frames azules indican que en el segundo perfil, UUID4, el tiempo de esa función disminuyó respecto a INTEGER; los rojos indican que aumentó en UUID4
- La intensidad del color representa el cambio relativo en el número de muestras del propio frame, es decir, el cambio relativo del self time delta
- En el diffgraph se observa más tiempo dedicado al rebalanceo del árbol y a operaciones de lectura y escritura
- Debido a la naturaleza desordenada de UUID4, las claves quedan ordenadas en forma aleatoria, y SQLite tiene que reequilibrar continuamente el B-tree
UUID7 WITHOUT ROWID
- UUID7 es un UUID ordenado por tiempo, una forma de eliminar el problema de ordenamiento de UUID4
- La prueba con UUID7 también se ejecutó sobre una tabla
WITHOUT ROWIDcon estructuraid BLOB PRIMARY KEY, data BLOB - En la tabla de resultados, el tiempo medido fue de 1372 ms con 10 millones de filas y de 1258 ms con 100 millones de filas
- UUID7 WITHOUT ROWID volvió a cifras más razonables que UUID4 WITHOUT ROWID, aunque sigue siendo más lento que la línea base
- La diferencia es que una clave primaria UUID
BLOBocupa 16 bytes, mientras que una clave primaria entera ocupa 8 bytes
UUID4 WITH ROWID
- La prueba UUID4 WITH ROWID usó una tabla
id BLOB PRIMARY KEY, data BLOBsinWITHOUT ROWID - En esta configuración, el
rowidoculto es el índice clúster, y su ventaja es la secuencialidad - La desventaja es que la tabla termina con dos índices, lo que provoca amplificación de escritura
- En la tabla de resultados, el tiempo medido fue de 2003 ms con 10 millones de filas y de 7119 ms con 100 millones de filas
- UUID4 WITH ROWID no rinde tan bien como UUID7 WITHOUT ROWID, porque aunque no sea el índice clúster, igual hay que seguir construyendo el índice con inserciones aleatorias
Conclusión
- En SQLite, usar UUID como clave primaria puede afectar mucho el rendimiento de inserción según el índice clúster y el orden de las claves
- El problema de los UUID aleatorios no se limita a SQLite; también se extiende a otras bases de datos que usan índices clúster
- Todo el código del benchmark está publicado en el repositorio de GitHub
1 comentarios
Opiniones en Lobste.rs
Bien. También sería interesante ver cifras de qué pasa en tablas rowid cuando la clave entera no es monótonamente creciente sino aleatoria
Un punto importante que falta en el artículo es que el índice primario de las tablas rowid es un árbol B+, mientras que las tablas
without rowidusan un árbol BPor eso, en general, si el tamaño promedio del registro supera cierto umbral, la segunda opción deja de ser ideal. Es porque los nodos internos del índice almacenan el registro completo y, si no recuerdo mal, el manual de SQLite sugiere como regla práctica 1/20 del tamaño de página
Se esforzaron bastante en medir el rendimiento de UUID, pero no consideraron las claves naturales
Ya sean enteras, UUID o de cualquier otro tipo, las claves sustitutas agregan complejidad, no aportan información y ocultan errores de normalización
El autor dice que usa UUID para “evitar duplicados”, pero eso no es una razón. Toda fila de toda tabla en toda base de datos debe tener al menos una clave, es decir, un conjunto de columnas que identifique de forma única esa fila
Una base de datos sin esa clase de clave contiene información duplicada y queda expuesta a inconsistencias lógicas. Si esa clave ya existe, no hace falta una clave sustituta. Si la clave natural ya existe y además se hace cumplir, afirmar que “se necesita por rendimiento” implica costo extra y complejidad innecesaria, así que hace falta demostrarlo
Valores que parecen únicos a simple vista no siempre lo son tanto como se espera, y valores que uno suponía inmutables terminan necesitando cambiar
En cambio, con una clave sustituta puedo definir la identidad dentro de mi sistema sin depender de cómo otra persona definió la identificabilidad, o más bien de cómo normalmente no la definió bien
Hay excepciones. Si yo defino el modelo completo y los datos no vienen de eso que llamamos mundo real, una clave natural tiene más sentido. Pero al diseñar un esquema para datos reales que probablemente nunca estuvieron totalmente normalizados, las claves sustitutas suelen ser útiles
Poder referirse a cualquier fila con un entero simplifica mucho el acceso, y además es fácil de recordar y de usar en consultas
Si es monótonamente creciente, también contiene información por sí misma. Es información redundante, pero sigue siendo cierta
La velocidad de consulta también queda optimizada. Es casi el caso ideal para indexar con árboles B, bitmaps, etc.
Creo que la razón principal por la que la gente usa UUID es la confusión. Normalmente la lógica es ofuscar la clave y hacerla imposible de adivinar, pero ya me rendí con tratar de entender por qué creen que eso debe imponerse incluso a la clave primaria, en vez de usar un identificador aparte para ese propósito
UUID versión 7 tiene un timestamp de 48 bits al principio, así que no queda distribuido aleatoriamente de esta manera. Por eso también debería reducir la paginación excesiva y el rebalanceo
¿La gente de verdad usa UUID como clave primaria? Si se necesita un UUID, me pregunto qué ventaja tiene hacerlo así en vez de dejarlo como clave secundaria
GUID y UUID resuelven ese problema por diseño
v1 y v6 codifican el ID de máquina y el timestamp, así que se parecen a enteros autoincrementales con un espacio de nombres por máquina
Mucha de la confusión viene de que mucha gente asume que UUID significa aleatorio. Eso solo aplica a v4 y, por desgracia, elegir v4 sí tiene costo
Muchas veces se necesita cierto grado de determinismo, como con v3, v5 o v7, ya sea para ordenar datos o por garantías de rendimiento y colisiones