Soy un gran fan de las bases de datos. Incluso quise hacer mi propio DBMS cuando estaba en la universidad. Ahora trabajo tanto con RDBMS como con soluciones NoSQL, y estoy muy entusiasmado con ello. No hay un Martillo de Oro, cada problema tiene su propia solución. O bien, un subconjunto de soluciones.
En la serie de entradas del blog El SQL que me gusta <3 os recorro algunos problemas resueltos con SQL que me han parecido especialmente interesantes. Las soluciones se prueban utilizando una tabla con más de 100 millones de registros. Todos los ejemplos utilizan MySQL, pero las ideas se aplican a otros almacenes de datos relacionales como PostgreSQL, Oracle y SQL Server.
Este capítulo se centra en el escaneo eficiente de una tabla grande utilizando la paginación con offset
en la clave primaria. Esto también se conoce como paginación keyset.
Contexto
En el capítulo, utilizamos la siguiente estructura de base de datos como ejemplo. El ejemplo canónico sobre los usuarios debería ajustarse a cualquier dominio.
CREATE TABLE `users` ( `user_id` int(11) unsigned NOT NULL AUTO_INCREMENT, `external_id` varchar(32) NOT NULL, `name` varchar(100) COLLATE utf8_unicode_ci NOT NULL, `metadata` text COLLATE utf8_unicode_ci, `date_created` timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP, PRIMARY KEY (`user_id`), UNIQUE KEY `uf_uniq_external_id` (`external_id`), UNIQUE KEY `uf_uniq_name` (`name`), KEY `date_created` (`date_created`)) ENGINE=InnoDB DEFAULT CHARSET=utf8 COLLATE=utf8_unicode_ci;
Algunos comentarios sobre la estructura:
-
external_id
la columna almacena la referencia al mismo usuario en otro sistema en formato UUID -
name
representaFirstname Lastname
-
metadata
la columna contiene JSON blob con todo tipo de datos no estructurados
La tabla es relativamente grande y contiene alrededor de 100 000 000 registros. Comencemos nuestro viaje de aprendizaje.
Escaneo de una tabla grande
Problema: Necesitas recorrer la tabla, extraer cada registro, transformarlo dentro del código de tu aplicación e insertarlo en otro lugar. Nos centramos en la primera etapa en el post – escanear la tabla.
Solución obvia y errónea
SELECT user_id, external_id, name, metadata, date_createdFROM users;
En mi caso con 100 000 000 registros, la consulta nunca se termina. El SGBD simplemente la mata. ¿Por qué? Probablemente, porque llevó al intento de cargar toda la tabla en la RAM. Antes de devolver los datos al cliente. Otra suposición: se tardó demasiado tiempo en precargar los datos antes de enviarlos y la consulta se agotó. De todos modos, nuestro intento de obtener todos los registros en el tiempo falló. Necesitamos encontrar alguna otra solución.
Solución #2
Podemos intentar obtener los datos en páginas. Dado que los registros no están garantizados para ser ordenado en una tabla en el nivel físico o lógico – tenemos que ordenar en el lado DBMS con ORDER BY
cláusula.
SELECT user_id, external_id, name, metadata, date_createdFROM usersORDER BY user_id ASCLIMIT 0, 10 000;10 000 rows in set (0.03 sec)
Sweet. Ha funcionado. Pedimos la primera página de 10 000 registros, y sólo tardó 0.03
segundos en devolverla. Sin embargo, ¿cómo funcionaría para la página 5000?
SELECT user_id, external_id, name, metadata, date_createdFROM usersORDER BY user_id ASCLIMIT 50 000 000, 10 000; --- 5 000th page * 10 000 page size10 000 rows in set (40.81 sec)
De hecho, esto es muy lento. Veamos cuánto tiempo se necesita para obtener los datos de la última página.
SELECT user_id, external_id, name, metadata, date_createdFROM usersORDER BY user_id ASCLIMIT 99 990 000, 10 000; --- 9999th page * 10 000 page size10 000 rows in set (1 min 20.61 sec)
Esto es una locura. Sin embargo, puede estar bien para las soluciones que se ejecutan en el fondo. Un problema más oculto con el enfoque puede ser revelado si usted trata de eliminar un registro de la tabla en el medio de la exploración. Digamos que ha terminado la décima página (ya se han visitado 100 000 registros) y va a escanear los registros entre 100 001 y 110 000. Pero los registros 99 998 y 99 999 se borran antes de la siguiente ejecución de SELECT
. En ese caso, la siguiente consulta devuelve el resultado inesperado:
SELECT user_id, external_id, name, metadata, date_created FROM users ORDER BY user_id ASC LIMIT 100 000, 10 000; N, id, ... 1, 100 003, ... 2, 100 004, ...
Como puede ver, la consulta omitió los registros con los ids 100 001 y 100 002. No serán procesados por el código de la aplicación con el método porque después de las dos operaciones de borrado aparecen en los primeros 100 000 registros. Por lo tanto, el método no es fiable si el conjunto de datos es mutable.
Solución #3 – la definitiva para hoy
El enfoque es muy similar al anterior porque sigue utilizando la paginación, pero ahora en lugar de confiar en el número de registros escaneados, utilizamos el user_id
del último registro visitado como el offset
.
Algoritmo simplificado:
- Obtenemos
PAGE_SIZE
número de registros de la tabla. El valor de offset inicial es 0. - Usamos el valor máximo devuelto para
user_id
en el lote como el offset para la siguiente página. - Obtenemos el siguiente lote de los registros que tienen
user_id
valor superior al actualoffset
.
La consulta en acción para la página 5 000, cada página contiene datos sobre 10 000 usuarios:
SELECT user_id, external_id, name, metadata, date_createdFROM usersWHERE user_id > 51 234 123 --- value of user_id for 50 000 000th recordORDER BY user_id ASCLIMIT 10 000;10 000 rows in set (0.03 sec)
Nótese, que los valores de user_id
no son secuenciales y pueden tener huecos como 25 348 está justo después de 25 345. La solución también funciona si se eliminan los registros de las páginas futuras, incluso en ese caso la consulta no se salta los registros. Genial, ¿verdad?
Explicando el rendimiento
Para seguir aprendiendo, recomiendo investigar los resultados de EXPLAIN EXTENDED
para cada versión de la consulta para obtener los siguientes 10 000 registros después de 50 000 000.
Solución | Tiempo | Tipo | Claves | Filas | Filtrado | Extra |
---|---|---|---|---|---|---|
1. Obvio | Nunca | ALL | NULL | 100M | 100.00 | NULL |
2. Paginación utilizando el número de registros como offset | 40,81 seg | index | NULL / PRIMARY | 50M | 200,00 | NULL |
3. Paginación del conjunto de claves utilizando user_id como offset | 0,03 seg | range | PRIMARY / PRIMARY | 50M | 100.00 | Usando where |
Centrémonos en la diferencia clave entre los planes de ejecución de la 2ª y 3ª solución ya que la 1ª no es útil en la práctica para tablas grandes.
- Tipo de join:
index
vsrange
. La primera significa que se escanea todo el árbol de índices para encontrar los registros. El tiporange
nos dice que el índice se utiliza sólo para encontrar filas coincidentes dentro de un rango especificado. Por lo tanto, el tiporange
es más rápido queindex
. - Posibles claves:
NULL
frente aPRIMARY
. La columna muestra las claves que pueden ser utilizadas por MySQL. BTW, mirando en la columna de claves, podemos ver que eventualmentePRIMARY
clave se utiliza para las dos consultas. - Filas:
50 010 000
frente a50 000 000
. El valor muestra el número de registros analizados antes de devolver el resultado. Para la 2ª consulta, el valor depende de la profundidad de nuestro scroll. Por ejemplo, si intentamos obtener los siguientes10 000
registros después de la página 9999 entonces se examinan99 990 000
registros. Por el contrario, la tercera consulta tiene un valor constante; no importa si cargamos los datos de la primera página o de la última. Siempre es la mitad del tamaño de la tabla. - Filtrado:
200.00
frente a100.00
. La columna indica el porcentaje estimado de la tabla que hay que filtrar antes de procesarla. El valor más alto es mejor. El valor de100.00
significa que la consulta busca en toda la tabla. Para la segunda consulta, el valor no es constante y depende del número de página: si pedimos la primera página, el valor de la columna filtrada sería1000000.00
. Para la última página, sería100.00
. - Extra:
NULL
vsUsing where
. Proporciona información adicional sobre cómo MySQL resuelve la consulta. El uso deWHERE
en la clavePRIMARY
hace que la ejecución de la consulta sea más rápida.
Sospecho que el tipo de join es el parámetro de la consulta que más ha contribuido al rendimiento para que la 3ª consulta sea más rápida. Otra cosa importante es que la 2ª consulta es extremadamente dependiente del número de las páginas a desplazar. Una paginación más profunda es más lenta en ese caso.
Más orientación sobre la comprensión de la salida para el comando EXPLAIN
se puede encontrar en la documentación oficial de su RDBMS.
Resumen
El tema principal de la entrada del blog estaba relacionado con el escaneo de una tabla grande con 100 000 000 registros utilizando offset
con una clave primaria (paginación keyset). En general, se revisaron 3 enfoques diferentes y se probaron en el conjunto de datos correspondiente. Recomiendo sólo uno de ellos si necesita escanear una tabla grande mutable.
Además, revisamos el uso del comando EXPLAIN EXTENDED
para analizar el plan de ejecución de las consultas de MySQL. Estoy seguro de que otros RDBMS tienen análogos para la funcionalidad.
En el próximo capítulo, vamos a prestar atención a la agregación de datos y la optimización del almacenamiento. Estén atentos!
¿Cuál es su método para escanear tablas grandes?
¿Recuerda algún otro propósito de usar la paginación de conjuntos de claves como en la Solución #3?