Cinco estudiantes y tres tiendas de valor clave distribuidas

O cómo escribimos una biblioteca cliente C++ para ZooKeeper, etcd y Consul KV

En el mundo de los sistemas distribuidos, existen una serie de tareas típicas: almacenar información sobre la composición del clúster, gestionar la configuración de los nodos, detectar nodos defectuosos, elegir un líder. otro. Para resolver estos problemas, se han creado sistemas distribuidos especiales: servicios de coordinación. Ahora nos interesarán tres de ellos: ZooKeeper, etcd y Consul. De toda la rica funcionalidad de Consul, nos centraremos en Consul KV.

Cinco estudiantes y tres tiendas de valor clave distribuidas

En esencia, todos estos sistemas son almacenes de valores clave linealizables y tolerantes a fallas. Aunque sus modelos de datos tienen diferencias significativas, que discutiremos más adelante, resuelven los mismos problemas prácticos. Evidentemente, cada aplicación que utiliza el servicio de coordinación está vinculada a una de ellas, lo que puede llevar a la necesidad de soportar en un centro de datos varios sistemas que resuelvan los mismos problemas para diferentes aplicaciones.

La idea de resolver este problema surgió en una agencia de consultoría australiana, y nos tocó a nosotros, un pequeño equipo de estudiantes, implementarla, que es de lo que voy a hablar.

Logramos crear una biblioteca que proporciona una interfaz común para trabajar con ZooKeeper, etcd y Consul KV. La biblioteca está escrita en C++, pero hay planes para migrarla a otros lenguajes.

Modelos de datos

Para desarrollar una interfaz común para tres sistemas diferentes, es necesario comprender qué tienen en común y en qué se diferencian. Vamos a resolverlo.

guardián del zoológico

Cinco estudiantes y tres tiendas de valor clave distribuidas

Las claves están organizadas en un árbol y se denominan nodos. En consecuencia, para un nodo puede obtener una lista de sus hijos. Las operaciones de crear un znode (create) y cambiar un valor (setData) están separadas: solo se pueden leer y cambiar las claves existentes. Se pueden adjuntar relojes a las operaciones de verificar la existencia de un nodo, leer un valor y obtener hijos. Watch es un disparador único que se activa cuando cambia la versión de los datos correspondientes en el servidor. Los nodos efímeros se utilizan para detectar fallas. Están vinculados a la sesión del cliente que los creó. Cuando un cliente cierra una sesión o deja de notificar a ZooKeeper de su existencia, estos nodos se eliminan automáticamente. Se admiten transacciones simples: un conjunto de operaciones que tienen éxito o fracasan si esto no es posible para al menos una de ellas.

etcd

Cinco estudiantes y tres tiendas de valor clave distribuidas

Los desarrolladores de este sistema se inspiraron claramente en ZooKeeper y, por lo tanto, hicieron todo de manera diferente. No existe una jerarquía de claves, sino que forman un conjunto ordenado lexicográficamente. Puede obtener o eliminar todas las claves que pertenecen a un rango determinado. Esta estructura puede parecer extraña, pero en realidad es muy expresiva y a través de ella se puede emular fácilmente una visión jerárquica.

etcd no tiene una operación estándar de comparar y configurar, pero tiene algo mejor: transacciones. Por supuesto, existen en los tres sistemas, pero las transacciones etcd son especialmente buenas. Constan de tres bloques: verificación, éxito, fracaso. El primer bloque contiene un conjunto de condiciones, el segundo y tercero, operaciones. La transacción se ejecuta de forma atómica. Si todas las condiciones son verdaderas, entonces se ejecuta el bloque de éxito; de lo contrario, se ejecuta el bloque de falla. En API 3.3, los bloques de éxito y fracaso pueden contener transacciones anidadas. Es decir, es posible ejecutar atómicamente construcciones condicionales de nivel de anidamiento casi arbitrario. Puede obtener más información sobre qué comprobaciones y operaciones existen en documentación.

Aquí también existen relojes, aunque son un poco más complicados y reutilizables. Es decir, después de instalar un reloj en un rango clave, recibirás todas las actualizaciones de ese rango hasta que canceles el reloj, y no solo la primera. En etcd, el análogo de las sesiones del cliente ZooKeeper son los arrendamientos.

Cónsul K.V.

Aquí tampoco existe una estructura jerárquica estricta, pero Consul puede crear la apariencia de que existe: puede obtener y eliminar todas las claves con el prefijo especificado, es decir, trabajar con el "subárbol" de la clave. Estas consultas se denominan recursivas. Además, Consul puede seleccionar sólo claves que no contengan el carácter especificado después del prefijo, lo que corresponde a la obtención de "hijos" inmediatos. Pero vale la pena recordar que esta es precisamente la apariencia de una estructura jerárquica: es muy posible crear una clave si su padre no existe o eliminar una clave que tiene hijos, mientras que los hijos seguirán almacenados en el sistema.

Cinco estudiantes y tres tiendas de valor clave distribuidas
En lugar de relojes, Consul bloquea las solicitudes HTTP. De hecho, se trata de llamadas ordinarias al método de lectura de datos, para las cuales, junto con otros parámetros, se indica la última versión conocida de los datos. Si la versión actual de los datos correspondientes en el servidor es mayor que la especificada, la respuesta se devuelve inmediatamente; de ​​lo contrario, cuando cambia el valor. También hay sesiones que se pueden adjuntar a las claves en cualquier momento. Vale la pena señalar que, a diferencia de etcd y ZooKeeper, donde la eliminación de sesiones conduce a la eliminación de claves asociadas, existe un modo en el que la sesión simplemente se desvincula de ellas. Disponible transacciones, sin sucursales, pero con todo tipo de controles.

Poniendolo todo junto

ZooKeeper tiene el modelo de datos más riguroso. Las consultas de rango expresivo disponibles en etcd no se pueden emular de manera efectiva ni en ZooKeeper ni en Consul. Al intentar incorporar lo mejor de todos los servicios, terminamos con una interfaz casi equivalente a la interfaz de ZooKeeper con las siguientes excepciones importantes:

  • Nodos de secuencia, contenedor y TTL. No soportado
  • Las ACL no son compatibles
  • el método set crea una clave si no existe (en ZK setData devuelve un error en este caso)
  • Los métodos set y cas están separados (en ZK son esencialmente lo mismo)
  • el método de borrado elimina un nodo junto con su subárbol (en ZK, eliminar devuelve un error si el nodo tiene hijos)
  • Para cada clave hay sólo una versión: la versión de valor (en ZK Hay tres de ellos)

El rechazo de los nodos secuenciales se debe al hecho de que etcd y Consul no tienen soporte integrado para ellos, y el usuario puede implementarlos fácilmente sobre la interfaz de biblioteca resultante.

Implementar un comportamiento similar a ZooKeeper al eliminar un vértice requeriría mantener un contador secundario separado para cada clave en etcd y Consul. Como intentamos evitar almacenar metainformación, se decidió eliminar todo el subárbol.

Sutilezas de implementación.

Echemos un vistazo más de cerca a algunos aspectos de la implementación de la interfaz de la biblioteca en diferentes sistemas.

Jerarquía en etcd

Mantener una vista jerárquica en etcd resultó ser una de las tareas más interesantes. Las consultas de rango facilitan la recuperación de una lista de claves con un prefijo específico. Por ejemplo, si necesita todo lo que comienza con "/foo", estás pidiendo un rango ["/foo", "/fop"). Pero esto devolvería el subárbol completo de la clave, lo que puede no ser aceptable si el subárbol es grande. Al principio planeamos utilizar un mecanismo de traducción clave, implementado en zetcd. Implica agregar un byte al comienzo de la clave, igual a la profundidad del nodo en el árbol. Dejame darte un ejemplo.

"/foo" -> "u01/foo"
"/foo/bar" -> "u02/foo/bar"

Luego obtenga todos los hijos inmediatos de la clave. "/foo" posible solicitando un rango ["u02/foo/", "u02/foo0"). Si, en ASCII "0" se encuentra justo después "/".

Pero ¿cómo implementar la eliminación de un vértice en este caso? Resulta que necesitas eliminar todos los rangos del tipo. ["uXX/foo/", "uXX/foo0") para XX de 01 a FF. Y luego nos topamos límite de número de operaciones dentro de una transacción.

Como resultado, se inventó un sistema simple de conversión de claves, que hizo posible implementar de manera efectiva tanto la eliminación de una clave como la obtención de una lista de niños. Basta con añadir un carácter especial antes del último token. Por ejemplo:

"/very" -> "/u00very"
"/very/long" -> "/very/u00long"
"/very/long/path" -> "/very/long/u00path"

Luego borrando la clave "/very" se convierte en eliminación "/u00very" y rango ["/very/", "/very0")y obtener todos los niños - en una solicitud de claves del rango ["/very/u00", "/very/u01").

Quitar una clave en ZooKeeper

Como ya mencioné, en ZooKeeper no puedes eliminar un nodo si tiene hijos. Queremos eliminar la clave junto con el subárbol. ¿Qué tengo que hacer? Lo hacemos con optimismo. Primero, recorremos recursivamente el subárbol, obteniendo los hijos de cada vértice con una consulta separada. Luego construimos una transacción que intenta eliminar todos los nodos del subárbol en el orden correcto. Por supuesto, pueden ocurrir cambios entre la lectura de un subárbol y su eliminación. En este caso, la transacción fracasará. Además, el subárbol puede cambiar durante el proceso de lectura. Una solicitud de los hijos del siguiente nodo puede devolver un error si, por ejemplo, este nodo ya se ha eliminado. En ambos casos volvemos a repetir todo el proceso.

Este enfoque hace que eliminar una clave sea muy ineficaz si tiene hijos, y más aún si la aplicación continúa trabajando con el subárbol, eliminando y creando claves. Sin embargo, esto nos permitió evitar complicar la implementación de otros métodos en etcd y Consul.

ambientado en ZooKeeper

En ZooKeeper hay métodos separados que trabajan con la estructura de árbol (crear, eliminar, getChildren) y que trabajan con datos en nodos (setData, getData). Además, todos los métodos tienen condiciones previas estrictas: crear devolverá un error si el nodo ya creado, elimine o establezca datos, si aún no existe. Necesitábamos un método establecido que se pudiera llamar sin pensar en la presencia de una clave.

Una opción es adoptar un enfoque optimista, como ocurre con la eliminación. Compruebe si existe un nodo. Si existe, llame a setData; de lo contrario, cree. Si el último método arrojó un error, repítalo nuevamente. Lo primero que hay que tener en cuenta es que la prueba de existencia no tiene sentido. Puede llamar inmediatamente a crear. La finalización exitosa significará que el nodo no existía y fue creado. De lo contrario, crear devolverá el error apropiado, después del cual deberá llamar a setData. Por supuesto, entre llamadas, una llamada competidora podría eliminar un vértice y setData también devolvería un error. En este caso, puedes hacerlo todo de nuevo, pero ¿merece la pena?

Si ambos métodos devuelven un error, entonces sabremos con certeza que se produjo una eliminación competitiva. Imaginemos que esta eliminación se produjo después de llamar a set. Entonces cualquier significado que estemos tratando de establecer ya estará borrado. Esto significa que podemos asumir que set se ejecutó exitosamente, incluso si en realidad no se escribió nada.

Más detalles técnicos

En esta sección tomaremos un descanso de los sistemas distribuidos y hablaremos sobre codificación.
Uno de los principales requisitos del cliente era la multiplataforma: al menos uno de los servicios debía ser compatible con Linux, MacOS y Windows. Inicialmente, desarrollamos solo para Linux y luego comenzamos a probar en otros sistemas. Esto causó muchos problemas, que durante algún tiempo no estaba claro cómo abordarlos. Como resultado, los tres servicios de coordinación ahora son compatibles con Linux y MacOS, mientras que en Windows solo se admite Consul KV.

Desde el principio, intentamos utilizar bibliotecas ya preparadas para acceder a los servicios. En el caso de ZooKeeper, la elección recayó en ZooKeeperC++, que finalmente no pudo compilarse en Windows. Esto, sin embargo, no es sorprendente: la biblioteca está posicionada como solo para Linux. Para Cónsul la única opción era ppcónsul. Hubo que añadirle soporte sesiones и actas. Para etcd, no se encontró una biblioteca completa que admita la última versión del protocolo, por lo que simplemente cliente grpc generado.

Inspirándonos en la interfaz asincrónica de la biblioteca ZooKeeper C++, decidimos implementar también una interfaz asincrónica. ZooKeeper C++ utiliza primitivas de futuro/promesa para esto. Desgraciadamente, en STL se implementan de forma muy modesta. Por ejemplo, no entonces método, que aplica la función pasada al resultado del futuro cuando esté disponible. En nuestro caso, este método es necesario para convertir el resultado al formato de nuestra biblioteca. Para solucionar este problema, tuvimos que implementar nuestro propio grupo de subprocesos simple, ya que a petición del cliente no podíamos utilizar bibliotecas pesadas de terceros como Boost.

Nuestra implementación entonces funciona así. Cuando se llama, se crea un par promesa/futuro adicional. Se devuelve el nuevo futuro y el pasado se coloca junto con la función correspondiente y una promesa adicional en la cola. Un hilo del grupo selecciona varios futuros de la cola y los sondea usando wait_for. Cuando un resultado está disponible, se llama a la función correspondiente y su valor de retorno se pasa a la promesa.

Usamos el mismo grupo de subprocesos para ejecutar consultas a etcd y Consul. Esto significa que se puede acceder a las bibliotecas subyacentes mediante varios subprocesos diferentes. ppconsul no es seguro para subprocesos, por lo que las llamadas están protegidas por bloqueos.
Puede trabajar con grpc desde varios hilos, pero hay sutilezas. En etcd, las vigilancias se implementan a través de secuencias grpc. Son canales bidireccionales para mensajes de un determinado tipo. La biblioteca crea un hilo único para todas las vigilancias y un hilo único que procesa los mensajes entrantes. Entonces grpc prohíbe las escrituras paralelas en la transmisión. Esto significa que al inicializar o eliminar un reloj, debe esperar hasta que la solicitud anterior haya completado su envío antes de enviar la siguiente. Usamos para sincronización. variables condicionales.

Total

Véalo usted mismo: liboffkv.

Nuestro equipo: Raed Romanov, Iván Glushenkov, Dmitri Kamáldinov, Víctor Krapivensky, Vitaly Ivanin.

Fuente: habr.com

Añadir un comentario