Índices para N1QL: o cómo conseguí un aumento de velocidad de un orden de magnitud
En Couchbase 4.0 introdujimos el lenguaje de consulta N1QL: un lenguaje de consulta flexible que lleva consultas tipo SQL a documentos JSON.
Siempre que hablamos de N1QL el conversación siempre da pie a preguntas sobre el rendimiento: qué esperar en términos de rendimiento y qué opciones hay para optimizar las consultas.
Lo más probable es que la primera respuesta sea "depende de su caso de uso y de la forma de sus datos", pero sinceramente eso no ayuda mucho.
Esta entrada del blog trata de responder a la pregunta del rendimiento con un poco más de detalle y dar algunas cifras reales en términos de tiempo de ejecución y mostrar cómo optimizar las consultas para obtener más rendimiento.
Historia de fondo
Couchbase utiliza N1QL en varias herramientas y aplicaciones internas y la semana pasada hice una observación muy importante.
Cuando se utiliza N1QL es extremadamente importante crear índices.
En una pequeña aplicación, añadir un índice a un atributo cambió el tiempo de ejecución de +2min. a 2 segundos. No se hizo ningún cambio en la consulta en sí, ¡el único cambio fue el índice!
Nota: El tiempo de consulta anterior no es para una sola consulta N1QL, sino para una secuencia de múltiples consultas en la aplicación en una máquina virtual relativamente poco potente.
El tiempo de ejecución esperado para una consulta depende en gran medida de la complejidad de la consulta y del sistema, el Couchbase Server y el hardware.
Por eso, para dar una respuesta más precisa se necesita un banco de pruebas. Se trata de un conjunto bien definido de pruebas que pueden ejecutarse en diferentes sistemas para revelar las métricas de rendimiento reales de una configuración determinada. De este modo, se puede obtener una medición para un sistema y una consulta reales.
Así, en lugar de limitarnos a afirmar que N1QL es rápido, podemos probarlo en un sistema real: ¡tu propia configuración!
Creación de un banco de pruebas
En primer lugar, el rendimiento es un reto. Es un reto medirlo, pero el verdadero problema es que a menudo olvidamos QUÉ estamos probando y, por tanto, también olvidamos cuándo poner en marcha el "cronómetro" y cuándo volver a pararlo.
Por lo tanto, al realizar una prueba es importante definir lo que se pretende medir y cómo medirlo de forma justa, repetible y comparable.
En nuestro caso queremos medir la diferencia en el tiempo de ejecución de una consulta N1QL predefinida cuando se utiliza un índice y cuando no se utiliza.
Sólo estamos interesados en el tiempo de ejecución real de la consulta N1QL, independientemente de cualquier retraso específico de la plataforma, tales como: retrasos de red, tiempo de arranque, rendimiento del SDK, tiempos de configuración/limpieza, etc.
¡En otras palabras, para esta prueba de rendimiento en particular, estamos ignorando todo lo que no sea el 'tiempo de ejecución de la consulta' en los dos escenarios!
Por suerte, medir el tiempo de ejecución de una consulta es muy fácil. Cada respuesta de Couchbase Server se devuelve con un valor Medida que contiene todas las métricas sobre la solicitud.
|
1 2 3 4 5 6 7 8 9 10 |
"Metrics": { "elapsedTime": "1.7900093s", "executionTime": "1.7900093s", "resultCount": 0, "resultSize": 0, "mutationCount": 0, "errorCount": 0, "warningCount": 0 } |
La métrica anterior contiene executionTime y este valor representa el tiempo de ejecución en Couchbase Server, independientemente de la latencia de la red, el tiempo de ejecución del código de la plataforma, etc. Es exactamente lo que necesitamos.
Antes de ejecutar cualquier consulta, necesitamos algunos datos de prueba para ejecutar las consultas. La cantidad de datos de prueba que tenemos puede influir en gran medida los resultados de la prueba y por lo tanto esto debe ser configurable para cada prueba.
La forma de crear los datos de prueba no es en absoluto importante para nuestra prueba, como tampoco lo es el tiempo que se tarde en crearlos. Lo importante es la forma de los datos, ya que deben reflejar lo mejor posible los datos reales. Aparte de eso, tenemos un alto nivel de libertad en la forma de crearlos y el tiempo que se tarda.
En la mayoría de los casos es justo asumir que los documentos variarán en tamaño y forma. A Couchbase no le afecta la forma del documento. Couchbase sólo "ve" una clave que apunta a un valor. El tamaño es un tema diferente y por lo tanto los documentos en el conjunto de datos deben variar en tamaño.
La imitación de varios documentos diferentes puede lograrse cambiando un tipo en el documento JSON. De nuevo, la forma no es importante para Couchbase, pero cambiando el atributo tipo podemos imitar distintos tipos de documentos aunque compartan la misma estructura documental.
Los criterios de los datos de prueba pueden reducirse ahora a:
- Los documentos deben variar de tamaño
- El documento puede compartir la misma estructura JSON
- El contenido de los documentos debe ser único
- El documento debe tener un
tipoque puede modificarse para imitar diferentes documentos del conjunto de datos.
Teniendo esto en cuenta, vamos a definir la estructura del documento JSON de la siguiente manera:
|
1 2 3 4 5 6 7 8 9 10 11 12 13 |
{ "Id": "GUID", "type": "perfTest", "IndexedType": "person + #", "NoneIndexedType": "person + #", "Day": 1->29, "Month": 1->12, "Year": 2015, "TextSmall": "100->250 random chars", "TextMedium": "200->500 random chars", "TextLarge": "700->1000 random chars", "TextExtraLarge": "1200-1500 random chars" } |
La estructura del documento anterior representa el documento de prueba. El tamaño de cada documento puede variar mucho, ya que todos los Texto... tienen un tamaño y un valor aleatorios. Al tener este tamaño y contenido aleatorios, cada documento imita mejor los documentos reales de un sistema real.
Es probable que un sistema real contenga más de un tipo de documento y al cambiar el valor de Tipo indexado la misma estructura de documento puede imitar diferentes tipos de documento en el sistema.
El atributo Tipo indexado puede tomar diferentes valores predecibles en la forma: persona1, persona2, persona3 y persona4. Los cuatro valores diferentes se utilizan para imitar cuatro documentos diferentes. Es posible añadir más 'tipos' pero para nuestra prueba cuatro son suficientes.
En tipo permite buscar y eliminar fácilmente los documentos de prueba una vez finalizada la prueba, y siempre recibe el valor perfTest.
Cargar los documentos en un bucket en Couchbase Server puede hacerse de muchas maneras. Una opción sería crear previamente los documentos y cargarlos en el bucket utilizando una combinación de los métodos cbbackup y cbrestore herramientas.
Otra opción sería crear los datos de prueba sobre la marcha. Supongo que se le ocurrirán otras formas de cargar los datos. Recuerde que este paso no es crítico para el rendimiento. Haz lo que te resulte más fácil.
Con las definiciones anteriores en su lugar, estamos listos para definir los pasos para el banco de pruebas:
- Llevar el sistema a un estado conocido
- Datos de la prueba de carga
- Consultar los datos de las pruebas y medir el tiempo de ejecución
- Crear índices
- Consultar los datos de las pruebas y medir el tiempo de ejecución
- Llevar el sistema a un estado conocido
- Imprimir resultado
Aplicación
Primer paso
Aunque las funciones de manipulación de datos de N1QL aún están en fase de previsualización, ya pueden utilizarse. Eso hace que la limpieza de datos sea muy sencilla:
|
1 |
"DELETE FROM `default` d WHERE d.type = 'perfTest' RETURNING d.Id |
Los índices pueden eliminarse mediante la función DROP mando:
|
1 2 3 |
DROP INDEX `default`.`index_1` USING GSI; DROP INDEX `default`.`index_2` USING GSI; DROP INDEX `default`.`index_3` USING GSI; |
Couchbase Server devolverá un error si el archivo DROP se ejecuta contra un índice que no existe. Esto puede solucionarse con una comprobación de si el índice existe o no:
|
1 2 3 |
SELECT * FROM system:indexes WHERE name='index_1'; SELECT * FROM system:indexes WHERE name='index_2'; SELECT * FROM system:indexes WHERE name='index_3'; |
Paso 2
Probablemente sería posible seguir utilizando las funciones de manipulación de datos de N1QL para crear un conjunto de datos aleatorios, pero también sería un poco más complicado que crear los documentos en código.
El código del banco de pruebas se implementará utilizando .NET y los documentos se generarán utilizando el siguiente fragmento C#:
|
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 |
private static void GenerateDocuments() { int rounds = numberOfTestDocuments > batchSize ? numberOfTestDocuments / batchSize : 1; int testDocsPerLoop = rounds > 1 ? batchSize : numberOfTestDocuments; Random ran = new Random(); for (int n = 0; n < rounds; n++) { var docs = new Dictionary<string, dynamic>(); for (int i = 0; i < testDocsPerLoop; i++) { string id = Guid.NewGuid().ToString(); string postFix = ran.Next(1, 4).ToString(); var doc = new { Id = id, type = "perfTest", IndexedType = "person" + postFix, NoneIndexedType = "person" + postFix, Day = ran.Next(1, 29), Month = ran.Next(1, 12), Year = "2015", TextSmall = new string( Enumerable.Range(0, ran.Next(100, 250)).Select(item => (char)ran.Next(44, 126)).ToArray()), TextMedium = new string( Enumerable.Range(0, ran.Next(200, 500)).Select(item => (char)ran.Next(44, 126)).ToArray()), TextLarge = new string( Enumerable.Range(0, ran.Next(700, 1000)).Select(item => (char)ran.Next(44, 126)).ToArray()), TextExtraLarge = new string( Enumerable.Range(0, ran.Next(1200, 1500)).Select(item => (char)ran.Next(44, 126)).ToArray()) }; docs.Add(id, doc); } ClusterHelper .GetBucket("default") .Upsert<dynamic>(docs); Console.Write("."); } } |
El método utiliza un bucle interior y otro exterior. El bucle interno define el tamaño del lote de subida. El bucle externo define el número de lotes a subir a Couchbase Server.
Los bucles se añaden para garantizar que el programa no se quede sin memoria al cargar un gran conjunto de datos.
Paso 3
Después de subir los documentos de prueba a Couchbase Server es hora de ejecutar la primera parte de la prueba y registrar el tiempo de ejecución:
|
1 |
SELECT * FROM `default` WHERE IndexedType='person3' AND Month > 5 AND Day < 20 |
Dependiendo del número de documentos utilizados en la prueba, es probable que esta consulta agote el tiempo de espera. En mi sistema, al ejecutar esta consulta con 500.000 documentos, se agota el tiempo de espera. Con 15.000 documentos, la consulta tarda unos 15 segundos.
Paso 4
Ahora es el momento de crear los índices:
|
1 2 3 |
CREATE INDEX `index_1` ON `default`(IndexedType) USING GSI; CREATE INDEX `index_2` ON `default`(Month) USING GSI; CREATE INDEX `index_3` ON `default`(Day) USING GSI; |
En CREAR ÍNDICE es síncrono y vuelve cuando el índice secundario está creado y listo. Eso significa que este comando puede tardar un rato en completarse, dependiendo del número de documentos de la prueba y del tamaño de tu máquina.
Índice múltiple frente a índice único: Tener varios índices independientes es beneficioso si se busca en cada atributo en consultas independientes en las que faltan otros atributos.
Sin embargo, tener un solo índice reduce la sobrecarga de mantener índices separados y puede simplemente reducir los requisitos de recursos y también puede acelerar la consulta aún más, ya que tiene la capacidad de encontrar los elementos que califican para todos los criterios de filtro de una sola vez.
En lugar de los tres índices independientes podríamos utilizar esto:
|
1 |
CREATE INDEX `index_type_month_year` ON `default`(IndexedType, Month, Year) USING GSI; |
O incluso:
|
1 |
CREATE INDEX `index_type_month_year` ON `default`(IndexedType, Month, Year) WHERE IndexedType='person3' AND Month > 5 AND Day < 20 USING GSI; |
Sin embargo, me parece más correcto tener varios índices para este tipo de pruebas. Siempre se puede ejecutar la prueba con un solo índice y medir la diferencia en el tiempo de ejecución y utilizar esa medida para tomar una decisión final de lo que's mejor en su caso particular.
Paso 5
Una vez creados los índices, es hora de ejecutar la segunda parte de la prueba y registrar el tiempo de ejecución:
|
1 |
SELECT * FROM `default` WHERE IndexedType='person3' AND Month > 5 AND Day < 20 |
Nota: se trata exactamente de la misma consulta utilizada en el paso 3. No se han realizado cambios en la consulta en sí.
Los tiempos de ejecución típicos en mi sistema oscilan entre 4 y 23 ms. Es una gran diferencia. Pero, ¿cómo afecta el tamaño del conjunto de datos a esta medida? Tendrás que seguir leyendo para obtener esa respuesta.
Paso 6
Borrar todos los documentos de prueba, eliminar los índices y devolver el sistema al estado conocido antes de ejecutar la prueba. Es lo mismo que el paso 1.
Se podría argumentar que el paso 1 o el paso 6 no son necesarios, pero ambos son muy importantes. Piense en el caso de que una prueba se interrumpa (se cancele, falle, etc.).
Paso 7
El último paso, y espero que el más interesante, el resultado.
Resultados de mi sistema:


Resumen de los resultados de mi sistema:
MacBook Pro 16 GB de memoria, Couchbase Server ejecutándose en Windows 10 usando Parallels Desktop con 10 GB de memoria (Couchbase Server tiene 2 GB de memoria)
- 15.000 documentos
- NO-index: 15s
- índice: 7ms
- 500.000 documentos:
- NO-index: tiempo de espera (más de 5min)
- índice: 10ms
Observación, En términos de tiempo de ejecución no hay gran diferencia entre 15K documentos y 500K documentos cuando se utiliza un índice.
Aprender, El uso de índices secundarios es muy importante y contribuye en gran medida al rendimiento de las consultas.
Código fuente
El código fuente está disponible en GitHub:
Código de prueba del índice N1QL
La implementación intenta compensar los tiempos de espera/errores y reintentos en fallos de operación y tiempos de espera.
Un tiempo de espera se produce con bastante frecuencia cuando se utiliza N1QL's beta comando BORRAR en un gran conjunto de datos (500.000 documentos). Recuerde que esta función aún está en fase de previsualización y, por lo tanto, no cabe esperar este comportamiento cuando se publique, pero por ahora es necesario esperar este comportamiento y compensarlo en consecuencia.
Eso es lo que pasa con los puntos de referencia. Depende.
¿Qué rendimiento puede esperar de su sistema? Pues depende. Pero ahora puedes ejecutar el código de prueba en tu sistema y hacerte una mejor idea.
¡Pero una cosa es segura! No te olvides de crear esos índices secundarios. ¡Mejoran enormemente el rendimiento! En términos numéricos, ¡los índices pueden mejorar el rendimiento entre 100 y 1000 veces!
El uso de índices tiene un impacto en el uso de la CPU del clúster, por lo que vale la pena considerar exactamente qué índices implementar y no crear más de los necesarios.
Dicho esto, por una sola línea de código, la relación calidad-precio es realmente buena ;)
|
1 |
CREATE INDEX `{INDEX_NAME}` ON `{BUCKET_NAME}`({ATTRIBUTE_NAME}) USING GSI; |
No dudes en publicar los resultados de tus pruebas en los comentarios y ayúdanos a comprender mejor el rendimiento que cabe esperar de N1QL.