Ingeniería

Campos de tiempo de ejecución: la implementación de Elastic del esquema durante la lectura

Históricamente, Elasticsearch ha adoptado un enfoque de esquema durante la escritura para agilizar la búsqueda de datos. Ahora agregamos capacidades del esquema durante la lectura en Elasticsearch para que los usuarios tengan la flexibilidad de cambiar el esquema de un documento después de la ingesta y también generar campos que solo existan como parte de la búsqueda. Juntos, el esquema durante la lectura y el esquema durante la escritura brindan a los usuarios la opción de equilibrar el rendimiento y la flexibilidad en función de sus necesidades.

Nuestra solución para el esquema durante la lectura son los campos de tiempo de ejecución, que solo se evalúan en el tiempo de la búsqueda. Se definen en el mapping del índice o en la búsqueda, y una vez definidos, se encuentran disponibles de inmediato para solicitudes de búsqueda, agregaciones, filtros y clasificación. Debido a que los campos de ejecución no están indexados, agregar uno de ellos no aumenta el tamaño del índice. De hecho, pueden reducir los costos de almacenamiento y aumentar la velocidad de ingesta.

Sin embargo, hay ventajas. Las búsquedas en los campos de tiempo de ejecución pueden ser costosas, por lo que los datos que comúnmente buscas o filtras aún deben mapearse a los campos indexados. Los campos de tiempo de ejecución también pueden disminuir la velocidad de búsqueda, aunque el tamaño del índice sea más pequeño. Recomendamos usar campos de tiempo de ejecución junto con campos indexados para encontrar el equilibrio adecuado entre la velocidad de ingesta, el tamaño del índice, la flexibilidad y el rendimiento de la búsqueda para tus casos de uso.

Agregar campos de tiempo de ejecución es fácil

La forma más sencilla de definir un campo de tiempo de ejecución es en la búsqueda. Por ejemplo, si tenemos el siguiente índice:

 PUT my_index
 {
   "mappings": {
     "properties": {
       "address": {
         "type": "ip"},
       "port": {
         "type": "long"
       }
     }
   } 
 }

Y le cargamos algunos documentos:

 POST my_index/_bulk
 {"index":{"_id":"1"}}
 {"address":"1.2.3.4","port":"80"}
 {"index":{"_id":"2"}}
 {"address":"1.2.3.4","port":"8080"}
 {"index":{"_id":"3"}}
 {"address":"2.4.8.16","port":"80"}

Podemos crear una concatenación de dos campos con un texto estático de esta manera:

 GET my_index/_search
 {
   "runtime_mappings": {
     "socket": {
       "type": "keyword",
       "script": {
         "source": "emit(doc['address'].value + ':' + doc['port'].value)"
       }
     }
   },
   "fields": [
     "socket"
   ],
   "query": {
     "match": {
       "socket": "1.2.3.4:8080"
     }
   }
 }

Recibirás la siguiente respuesta:

…
     "hits" : [
       {
         "_index" : "my_index",
         "_type" : "_doc",
         "_id" : "2",
         "_score" : 1.0,
         "_source" : {
           "address" : "1.2.3.4",
           "port" : "8080"
         },
         "fields" : {
           "socket" : [
             "1.2.3.4:8080"
           ]
         }
       }
     ]

Definimos el socket del campo en la sección runtime_mappings. Usamos un script de Painless corto que define cómo se calculará el valor del socket por documento (usar + para indicar la concatenación del valor del campo de la dirección con el texto estático ‘:’ y el valor del campo del puerto). Después, usamos el socket de campo en la búsqueda. El socket de campo es un campo de tiempo de ejecución efímero que solo existe para esta búsqueda y se calcula cuando se realiza la búsqueda. Cuando defines un script de Painless para usar con los campos de tiempo de ejecución, debes incluir emit para obtener los valores calculados.

Si ese socket es un campo que queremos usar en varias búsquedas sin tener que definirlo en cada una, simplemente podemos agregarlo al mapping mediante la llamada:

 PUT my_index/_mapping
 {
   "runtime": {
     "socket": {
       "type": "keyword",
       "script": {
         "source": "emit(doc['address'].value + ':' + doc['port'].value)"
       }
     } 
   } 
 }

Y entonces la búsqueda no tiene que incluir la definición del campo, por ejemplo:

 GET my_index/_search
 {
   "fields": [
     "socket"
  ],
   "query": {
     "match": {
       "socket": "1.2.3.4:8080"
     }
   }
 }

La instrucción "fields": ["socket"] solo se requiere si deseas que aparezca el valor del campo del socket. Si bien el socket del campo ahora está disponible para cualquier búsqueda, no existe en el índice y no aumenta su tamaño. El socket se calcula solo cuando una búsqueda lo requiere y para los documentos para los que se requiere.

Se consume como cualquier campo

Debido a que los campos de tiempo de ejecución están expuestos mediante la misma API como campos indexados, una búsqueda puede hacer referencia a algunos índices donde el campo es de tiempo de ejecución y a otros índices donde el campo es indexado. Tienes la flexibilidad de elegir los campos que deseas indexar y los que quieres mantener como campos de tiempo de ejecución. Esta separación entre la generación de campos y el consumo de campos facilita un código más organizado que es más fácil de crear y mantener.

Puedes definir los campos de tiempo de ejecución en el mapping del índice o en la solicitud de búsqueda. Esta capacidad inherente brinda flexibilidad en cómo usas los campos de tiempo de ejecución junto con los campos indexados. 

Anular los valores del campo en el tiempo de la búsqueda

A veces, detectas errores en los datos de producción cuando ya es demasiado tarde.  Si bien es fácil corregir las instrucciones de ingesta en los documentos que ingestarás en el futuro, es mucho más desafiante corregir los datos que ya se ingestaron e indexaron. Al usar campos de tiempo de ejecución, puedes corregir los errores en los datos indexados anulando los valores en el tiempo de la búsqueda. Los campos de tiempo de ejecución pueden detectar los campos indexados con el mismo nombre para que puedas corregir los errores de los datos indexados.  

Este es un ejemplo simple para verlo más concretamente. Digamos que tenemos un índice con un campo de mensaje y un campo de dirección:

 PUT my_raw_index 
{
  "mappings": {
    "properties": {
      "raw_message": {
        "type": "keyword"
      },
      "address": {
        "type": "ip"
      }
    }
  }
}

Y le cargamos un documento:

 POST my_raw_index/_doc/1
{
  "raw_message": "199.72.81.55 - - [01/Jul/1995:00:00:01 -0400] GET /history/apollo/ HTTP/1.0 200 6245",
  "address": "1.2.3.4"
}

Desafortunadamente, el documento contiene una dirección IP incorrecta en el campo de la dirección. La dirección IP correcta existe en el mensaje, pero de alguna manera la dirección incorrecta se parseó en el documento que se envió para la ingesta en Elasticsearch y se indexó. Para un solo documento, eso no significa un problema, ¿pero qué sucedería si descubriéramos después de un mes que el 10 % de nuestros documentos contienen una dirección incorrecta? Corregirla en documentos nuevos no es un gran problema, pero volver a indexar los documentos que ya se ingestaron frecuentemente es operativamente complejo. En el caso de los campos de tiempo de ejecución, se puede corregir de inmediato ocultando el campo indexado con un campo de tiempo de ejecución. Así es cómo se haría en una búsqueda:

GET my_raw_index/_search
{
  "runtime_mappings": {
    "address": {
      "type": "ip",
      "script": "Matcher m = /\\d+\\.\\d+\\.\\d+\\.\\d+/.matcher(doc[\"raw_message\"].value);if (m.find()) emit(m.group());"
    }
  },
  "fields": [ 
    "address"
  ]
}

También puedes cambiarla en el mapping para que esté disponible en todas las búsquedas. Ten en cuenta que el uso de regex ahora está habilitado de manera predeterminada mediante el script de Painless.

Equilibrar el rendimiento y la flexibilidad

En el caso de los campos indexados, haces todas las preparaciones durante la ingesta y mantienes las estructuras de datos sofisticadas para brindar un rendimiento óptimo. Pero la búsqueda en campos de tiempo de ejecución es más lenta que en campos indexados. Así que, ¿qué sucedería si las búsquedas fueran más lentas después de comenzar a usar los campos de tiempo de ejecución?

Recomendamos usar la búsqueda asíncrona al obtener un campo de tiempo de ejecución. Se obtiene todo el conjunto de resultados como en una búsqueda sincrónica, siempre que esta finalice dentro de un plazo determinado. Sin embargo, aunque la búsqueda no termine en ese plazo, aún obtienes un conjunto de resultados parcial y Elasticsearch continuará con el sondeo hasta que se obtenga el resultado completo. Este mecanismo resulta particularmente útil cuando se administra el ciclo de vida de un índice, porque los resultados más recientes generalmente aparecen primero y también son más importantes para los usuarios.

Para lograr un rendimiento óptimo, nos basamos en los campos indexados para hacer el trabajo pesado de la búsqueda para que los valores de los campos de tiempo de ejecución solo se calculen en un subconjunto de documentos.

Cambiar un campo de tiempo de ejecución a indexado

Los campos de tiempo de ejecución permiten a los usuarios cambiar de manera flexible el mapping y el análisis mientras trabajan con los datos en un entorno dinámico. Debido a que un campo de tiempo de ejecución no consume recursos y debido a que el script que lo define puede cambiarse, los usuarios pueden experimentar hasta alcanzar el mapping óptimo. Cuando un campo de tiempo de ejecución resulta útil a largo plazo, se puede calcular previamente su valor en el tiempo del índice simplemente definiendo ese campo en la plantilla como un campo indexado y asegurándose de que se incluya en el documento que se ingestó. El campo se indexará desde la próxima sustitución del índice y proporcionará un mejor rendimiento. Las búsquedas que usan el campo no deben cambiar en absoluto. 

Este escenario es particularmente útil con el mapping dinámico. Por un lado, es muy útil para que nuevos documentos generen nuevos campos porque así, los datos en ellos pueden usarse de inmediato (la estructura de las entradas cambia con frecuencia, p. ej., por un cambio en el software que genera el log). Por otro lado, el riesgo del mapping dinámico es cargar el índice e incluso crear una explosión de mapping, porque nunca se sabe si algún documento podría sorprenderte con 2000 campos nuevos. Los campos de tiempo de ejecución pueden ser la solución de este escenario. Los campos nuevos pueden crearse de forma automática como campos de tiempo de ejecución para no cargar el índice (porque no existen en el índice) y no se cuentan en el index.mapping.total_fields.limit. Estos campos de tiempo de ejecución creados automáticamente pueden buscarse, aunque con un menor rendimiento, por lo que los usuarios pueden usarlos y, si es necesario, decidir cambiarlos a campos indexados en la siguiente sustitución.   

Recomendamos usar los campos de tiempo de ejecución al principio para experimentar con la estructura de los datos. Después de trabajar con los datos, probablemente decidas indexar un campo de tiempo de ejecución para obtener un mejor rendimiento de la búsqueda. Puedes crear un índice nuevo y después agregar la definición del campo al mapping del índice, agregar el campo a _source y asegurarte de que el campo nuevo se incluya en los documentos que se ingestaron. Si usas flujos de datos, puedes actualizar la plantilla del índice para que, cuando se creen los índices a partir de esta, Elasticsearch sepa que debe indexar ese campo. En una versión futura, tenemos pensado simplificar el proceso de cambiar un campo de tiempo de ejecución a un campo indexado que implique solo mover el campo desde la sección del tiempo de ejecución del mapping a la sección de propiedades. 

La siguiente solicitud crea un mapping de índice simple con un campo de marca de tiempo. Incluir "dynamic": "runtime" indica a Elasticsearch que cree dinámicamente campos adicionales en este índice como campos de tiempo de ejecución. Si en un campo de tiempo de ejecución se incluye un script de Painless, se calculará el valor del campo en función de este. Si se crea un campo de tiempo de ejecución sin un script, como se muestra en la siguiente solicitud, el sistema buscará un campo en _source que tenga el mismo nombre que el campo de tiempo de ejecución y usará su valor como el valor del campo de tiempo de ejecución.

PUT my_index-1
{
  "mappings": {
    "dynamic": "runtime",
    "properties": {
      "timestamp": {
        "type": "date",
        "format": "yyyy-MM-dd"
      }
    }
  }
}

Indexemos un documento para ver las ventajas de estas configuraciones:

POST my_index-1/_doc/1
{
  "timestamp": "2021-01-01",
  "message": "my message",
  "voltage": "12"
}

Ahora que tenemos un campo indexado de marca de tiempo y dos campos de tiempo de ejecución (mensaje y voltaje), podemos ver el mapping del índice:

GET my_index-1/_mapping

En la sección del tiempo de ejecución se incluyen el mensaje y el voltaje. Estos campos no están indexados, pero aún podemos buscarlos exactamente como si lo estuvieran.

{
  "my_index-1" : {
    "mappings" : {
      "dynamic" : "runtime",
      "runtime" : {
        "message" : {
          "type" : "keyword"
        },
        "voltage" : {
          "type" : "keyword"
        }
      },
      "properties" : {
        "timestamp" : {
          "type" : "date",
          "format" : "yyyy-MM-dd"
        }
      }
    }
  }
}

Crearemos una simple solicitud de búsqueda que busque en el campo de mensaje:

GET my_index-1/_search
{
  "query": {
    "match": {
      "message": "my message"
    }
  }
}

Las respuestas son las siguientes:

... 
"hits" : [
      {
        "_index" : "my_index-1", 
        "_type" : "_doc", 
        "_id" : "1", 
        "_score" : 1.0, 
        "_source" : { 
          "timestamp" : "2021-01-01", 
          "message" : "my message", 
          "voltage" : "12" 
        } 
      } 
    ]
…

Si observamos esta respuesta, notamos un problema: no especificamos que el voltaje es un número. Debido a que el voltaje es un campo de tiempo de ejecución, es fácil de corregir actualizando la definición del campo en la sección de tiempo de ejecución del mapping:

PUT my_index-1/_mapping
{
  "runtime":{
    "voltage":{
      "type": "long"
    }
  }
}

La solicitud anterior cambia el voltaje a un tipo de largo, lo que surte efecto de inmediato en los documentos que ya se indexaron. Para probar ese comportamiento, construimos una búsqueda sencilla para todos los documentos con un voltaje entre 11 y 13:

GET my_index-1/_search
{
  "query": {
    "range": {
      "voltage": {
        "gt": 11,
        "lt": 13
      }
    }
  }
}

Debido a que el voltaje era 12, la búsqueda devuelve nuestro documento en my_index-1. Si observamos el mapping nuevamente, veremos que el voltaje es ahora un campo de tiempo de ejecución de tipo largo, incluso en documentos que se ingestaron en Elasticsearch antes de que actualizáramos el tipo de campo en el mapping:

...
{
  "my_index-1" : {
    "mappings" : {
      "dynamic" : "runtime",
      "runtime" : {
        "message" : {
          "type" : "keyword"
        },
        "voltage" : {
          "type" : "long"
        }
      },
      "properties" : {
        "timestamp" : {
          "type" : "date",
          "format" : "yyyy-MM-dd"
        }
      }
    }
  }
}
…

Más adelante, podríamos decidir que el voltaje es útil en las agregaciones y que queremos indexarlo en el próximo índice que se crea en un flujo de datos. Creamos un índice nuevo (my_index-2) que se corresponde con la plantilla del índice del flujo de datos y definimos el voltaje como un número entero, ya que sabemos qué tipo de datos queremos después de experimentar con los campos de tiempo de ejecución.

Lo ideal sería actualizar la plantilla del índice para que los cambios surtan efecto en la próxima sustitución. Puedes hacer búsquedas en el campo de voltaje en cualquier índice que coincida con el patrón my_index*, aunque el campo sea un campo de tiempo de ejecución en un índice y un campo indexado en otro.

PUT my_index-2
{
  "mappings": {
    "dynamic": "runtime",
    "properties": {
      "timestamp": {
        "type": "date",
        "format": "yyyy-MM-dd"
      },
      "voltage":
      {
        "type": "integer"
      }
    }
  }
}

Con los campos de tiempo de ejecución, introdujimos un nuevo flujo de trabajo del ciclo de vida del campo. En este flujo de trabajo, se puede generar un campo de forma automática como un campo de tiempo de ejecución sin afectar el consumo de recursos y sin correr el riesgo de que se produzca una explosión de mapping, lo que les permite a los usuarios comenzar de inmediato a trabajar con los datos. El mapping del campo puede refinarse en los datos reales mientras aún sea un campo de tiempo de ejecución y, debido a la flexibilidad de este, los cambios surten efecto en los documentos que ya se ingestaron en Elasticsearch. Cuando queda claro que el campo es útil, se puede cambiar la plantilla para que en los índices que se creen a partir de ese punto (después de la próxima sustitución), el campo se indexe para lograr un rendimiento óptimo.

Resumen

En la mayoría de los casos, y particularmente si conoces tus datos y lo que deseas hacer con ellos, los campos indexados son la solución debido a su ventaja de rendimiento. Por otro lado, cuando existe la necesidad de flexibilidad en el análisis de documentos y la estructura del esquema, los campos de tiempo de ejecución son la respuesta.

Los campos de tiempo de ejecución y los campos indexados son características que se complementan: forman una simbiosis. Los campos de tiempo de ejecución ofrecen flexibilidad, pero nunca ofrecerán un buen rendimiento en un entorno de gran escala sin el soporte del índice. La potente y rígida estructura del índice brinda un entorno protegido en el cual la flexibilidad de los campos de tiempo de ejecución puede mostrar su verdadera naturaleza en una manera no muy diferente de cómo las algas se refugian en un coral. Todos se benefician con esta simbiosis.

Comienza hoy mismo

Para comenzar con los campos de tiempo de ejecución, activa un cluster en Elasticsearch Service o instala la versión más reciente del Elastic Stack. ¿Ya tienes Elasticsearch en ejecución? Solo actualiza los clusters a la versión 7.11 y pruébalo. Para una visualización de más alto nivel de los campos de tiempo de ejecución y sus beneficios, lee el blog Campos de tiempo de ejecución: esquema durante la lectura de Elastic. Además, también grabamos 4 videos para ayudarte a comenzar a usar los campos de tiempo de ejecución.