Cinco estudantes e tres tendas de clave-valor distribuídas

Ou como escribimos unha biblioteca C++ cliente para ZooKeeper, etcd e Consul KV

No mundo dos sistemas distribuídos, hai unha serie de tarefas típicas: almacenar información sobre a composición do clúster, xestionar a configuración dos nodos, detectar nodos defectuosos, escoller un líder. e outros. Para solucionar estes problemas, creáronse sistemas especiais distribuídos: servizos de coordinación. Agora interesarémonos en tres deles: ZooKeeper, etcd e Consul. De entre toda a rica funcionalidade de Consul, centrarémonos en Consul KV.

Cinco estudantes e tres tendas de clave-valor distribuídas

En esencia, todos estes sistemas son tendas de valores-chave linearizables e tolerantes a fallos. Aínda que os seus modelos de datos presentan diferenzas significativas, que comentaremos máis adiante, solucionan os mesmos problemas prácticos. Obviamente, cada aplicación que utiliza o servizo de coordinación está ligada a unha delas, o que pode levar á necesidade de soportar varios sistemas nun centro de datos que resolvan os mesmos problemas para diferentes aplicacións.

A idea de solucionar este problema xurdiu nunha axencia de consultoría australiana, e tocounos a nós, un pequeno equipo de estudantes, implementalo, que é do que vou falar.

Conseguimos crear unha biblioteca que proporciona unha interface común para traballar con ZooKeeper, etcd e Consul KV. A biblioteca está escrita en C++, pero hai plans para portala a outros idiomas.

Modelos de datos

Para desenvolver unha interface común para tres sistemas diferentes, cómpre comprender o que teñen en común e en que se diferencian. Imos descubrir.

ZooKeeper

Cinco estudantes e tres tendas de clave-valor distribuídas

As claves están organizadas nunha árbore e chámanse nós. En consecuencia, para un nodo pode obter unha lista dos seus fillos. As operacións de crear un znode (crear) e cambiar un valor (setData) están separadas: só se poden ler e cambiar as claves existentes. Os reloxos pódense unir ás operacións de comprobar a existencia dun nodo, ler un valor e conseguir fillos. Watch é un activador único que se activa cando cambia a versión dos datos correspondentes no servidor. Os nodos efémeros utilízanse para detectar fallos. Están vinculados á sesión do cliente que os creou. Cando un cliente pecha unha sesión ou deixa de notificar a ZooKeeper da súa existencia, estes nodos elimínanse automaticamente. Admítense transaccións simples: un conxunto de operacións que todas teñen éxito ou fracasan se isto non é posible para polo menos unha delas.

etcd

Cinco estudantes e tres tendas de clave-valor distribuídas

Os desenvolvedores deste sistema inspiráronse claramente en ZooKeeper e, polo tanto, fixeron todo de xeito diferente. Non existe unha xerarquía de claves, pero forman un conxunto ordenado lexicograficamente. Podes obter ou eliminar todas as claves que pertencen a un determinado intervalo. Esta estrutura pode parecer estraña, pero en realidade é moi expresiva e a través dela pódese emular facilmente unha visión xerárquica.

etcd non ten unha operación estándar de comparación e definición, pero ten algo mellor: transaccións. Por suposto, existen nos tres sistemas, pero as transaccións etcd son especialmente boas. Constan de tres bloques: comprobación, éxito, fracaso. O primeiro bloque contén un conxunto de condicións, o segundo e terceiro - operacións. A transacción execútase atomicamente. Se todas as condicións son verdadeiras, execútase o bloque de éxito, se non, execútase o bloque de fallo. Na API 3.3, os bloques de éxito e fallo poden conter transaccións aniñadas. É dicir, é posible executar atomicamente construcións condicionais de nivel de anidación case arbitrario. Podes obter máis información acerca de que comprobacións e operacións existen documentación.

Aquí tamén existen reloxos, aínda que son un pouco máis complicados e son reutilizables. É dicir, despois de instalar un reloxo nun rango de claves, recibirás todas as actualizacións deste rango ata que canceles o reloxo, e non só a primeira. En etcd, o análogo das sesións de clientes de ZooKeeper son arrendamentos.

Cónsul K.V.

Aquí tampouco hai unha estrutura xerárquica estrita, pero Consul pode crear a aparencia de que existe: pode obter e eliminar todas as claves co prefixo especificado, é dicir, traballar cunha "subárbore" da clave. Tales consultas chámanse recursivas. Ademais, Consul só pode seleccionar claves que non conteñan o carácter especificado despois do prefixo, que corresponde á obtención inmediata de "fillos". Pero convén lembrar que esta é precisamente a aparencia dunha estrutura xerárquica: é moi posible crear unha chave se o seu pai non existe ou eliminar unha chave que teña fillos, mentres que os fillos seguirán almacenados no sistema.

Cinco estudantes e tres tendas de clave-valor distribuídas
En lugar de reloxos, Consul bloquea as solicitudes HTTP. En esencia, trátase de chamadas ordinarias ao método de lectura de datos, para o que, xunto con outros parámetros, indícase a última versión coñecida dos datos. Se a versión actual dos datos correspondentes no servidor é maior que a especificada, a resposta devólvese inmediatamente, se non, cando o valor cambia. Tamén hai sesións que se poden unir ás chaves en calquera momento. Cabe sinalar que a diferenza de etcd e ZooKeeper, onde a eliminación de sesións leva á eliminación das claves asociadas, existe un modo no que a sesión simplemente se desvincula delas. Dispoñible transaccións, sen ramas, pero con todo tipo de controis.

Xuntando todo

ZooKeeper ten o modelo de datos máis rigoroso. As consultas de rango expresivo dispoñibles en etcd non se poden emular de forma efectiva nin en ZooKeeper nin en Consul. Tentando incorporar o mellor de todos os servizos, acabamos cunha interface case equivalente á interface de ZooKeeper coas seguintes excepcións significativas:

  • secuencia, contedor e nodos TTL non soportado
  • Non se admiten ACL
  • o método set crea unha clave se non existe (en ZK setData devolve un erro neste caso)
  • os métodos set e cas están separados (en ZK son esencialmente o mesmo)
  • o método de borrado elimina un nodo xunto coa súa subárbore (en ZK borrar devolve un erro se o nodo ten fillos)
  • Para cada clave só hai unha versión: a versión do valor (en ZK hai tres deles)

O rexeitamento dos nodos secuenciais débese ao feito de que etcd e Consul non teñen soporte incorporado para eles, e o usuario pode implementarlos facilmente enriba da interface da biblioteca resultante.

Implementar un comportamento similar ao de ZooKeeper ao eliminar un vértice requiriría manter un contador fillo separado para cada chave en etcd e Consul. Dado que tentamos evitar almacenar metainformación, decidiuse eliminar toda a subárbore.

Sutilezas de implementación

Vexamos con máis detalle algúns aspectos da implementación da interface da biblioteca en diferentes sistemas.

Xerarquía en etcd

Manter unha visión xerárquica en etcd resultou ser unha das tarefas máis interesantes. As consultas de intervalos facilitan a recuperación dunha lista de claves cun prefixo especificado. Por exemplo, se necesitas todo o que comeza "/foo", estás a pedir un rango ["/foo", "/fop"). Pero isto devolvería toda a subárbore da clave, o que pode non ser aceptable se a subárbore é grande. Nun principio planeamos utilizar un mecanismo de tradución clave, implementado en zetcd. Implica engadir un byte ao comezo da clave, igual á profundidade do nodo da árbore. Déixame un exemplo.

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

A continuación, obtén todos os fillos inmediatos da chave "/foo" posible solicitando un rango ["u02/foo/", "u02/foo0"). Si, en ASCII "0" queda xusto despois "/".

Pero como implementar a eliminación dun vértice neste caso? Resulta que cómpre eliminar todos os intervalos do tipo ["uXX/foo/", "uXX/foo0") para XX de 01 a FF. E entón topámonos límite de número de operacións dentro dunha transacción.

Como resultado, inventouse un sinxelo sistema de conversión de claves, que permitiu implementar de forma eficaz tanto a eliminación dunha clave como a obtención dunha lista de nenos. É suficiente engadir un carácter especial antes do último token. Por exemplo:

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

A continuación, elimina a chave "/very" convértese en eliminación "/u00very" e rango ["/very/", "/very0"), e conseguir todos os nenos - nunha solicitude de chaves da gama ["/very/u00", "/very/u01").

Eliminando unha chave en ZooKeeper

Como xa dixen, en ZooKeeper non se pode eliminar un nodo se ten fillos. Queremos eliminar a chave xunto coa subárbore. Qué debería facer? Facemos isto con optimismo. En primeiro lugar, percorremos recursivamente a subárbore, obtendo os fillos de cada vértice cunha consulta separada. A continuación, construímos unha transacción que tenta eliminar todos os nós da subárbore na orde correcta. Por suposto, pódense producir cambios entre a lectura dunha subárbore e a súa eliminación. Neste caso, a transacción fallará. Ademais, a subárbore pode cambiar durante o proceso de lectura. Unha solicitude para os fillos do seguinte nodo pode devolver un erro se, por exemplo, este nodo xa foi eliminado. En ambos casos, repetimos todo o proceso de novo.

Este enfoque fai que a eliminación dunha chave sexa moi ineficaz se ten fillos, e máis aínda se a aplicación segue traballando coa subárbore, eliminando e creando claves. Non obstante, isto permitiunos evitar complicar a implementación doutros métodos en etcd e Consul.

ambientado en ZooKeeper

En ZooKeeper hai métodos separados que traballan coa estrutura da árbore (create, delete, getChildren) e que traballan con datos en nodos (setData, getData). Ademais, todos os métodos teñen unhas condicións previas estritas: create devolverá un erro se o nodo xa ten creado, borrar ou configurar datos, se aínda non existe. Necesitabamos un método conxunto que se pode chamar sen pensar na presenza dunha chave.

Unha opción é adoptar un enfoque optimista, como ocorre coa eliminación. Comproba se existe un nodo. Se existe, chame a setData, en caso contrario, cree. Se o último método devolveu un erro, repíteo de novo. O primeiro que hai que ter en conta é que a proba de existencia non ten sentido. Podes chamar a crear inmediatamente. A finalización exitosa significará que o nodo non existía e foi creado. En caso contrario, create devolverá o erro apropiado, despois de que cómpre chamar a setData. Por suposto, entre chamadas, un vértice podería ser eliminado por unha chamada competidora, e setData tamén devolvería un erro. Neste caso, podes facelo todo de novo, pero paga a pena?

Se ambos os métodos devolven un erro, sabemos con certeza que se produciu unha eliminación competitiva. Imaxinemos que esta eliminación ocorreu despois de chamar ao conxunto. Entón, calquera significado que esteamos tentando establecer xa está borrado. Isto significa que podemos supoñer que ese conxunto se executou con éxito, aínda que de feito non se escribiu nada.

Máis detalles técnicos

Neste apartado faremos un descanso dos sistemas distribuídos e falaremos de codificación.
Un dos principais requisitos do cliente era multiplataforma: polo menos un dos servizos debe ser compatible con Linux, MacOS e Windows. Inicialmente, desenvolvemos só para Linux, e despois comezamos a probar noutros sistemas. Isto causou moitos problemas, que durante algún tempo non estaban completamente claros como abordar. Como resultado, os tres servizos de coordinación agora son compatibles con Linux e MacOS, mentres que só Consul KV é compatible con Windows.

Desde o primeiro momento, tentamos utilizar bibliotecas xa preparadas para acceder aos servizos. No caso de ZooKeeper, a elección recaeu ZooKeeper C++, que finalmente non se puido compilar en Windows. Isto, porén, non é sorprendente: a biblioteca sitúase como só para Linux. Para o Cónsul a única opción era ppcónsul. Había que engadirlle apoio sesións и transaccións. Para etcd, non se atopou unha biblioteca completa que admita a versión máis recente do protocolo, polo que simplemente cliente grpc xerado.

Inspirados na interface asíncrona da biblioteca ZooKeeper C++, decidimos implementar tamén unha interface asíncrona. ZooKeeper C++ usa primitivas futuras/promesas para iso. En STL, por desgraza, implícanse moi modestamente. Por exemplo, non entón método, que aplica a función pasada ao resultado do futuro cando estea dispoñible. No noso caso, tal método é necesario para converter o resultado ao formato da nosa biblioteca. Para evitar este problema, tivemos que implementar a nosa propia agrupación de fíos sinxelos, xa que a petición do cliente non podíamos utilizar bibliotecas pesadas de terceiros como Boost.

A nosa implementación funciona así. Cando se chama, créase un par promesa/futuro adicional. Devólvese o novo futuro e colócase o aprobado xunto coa función correspondente e unha promesa adicional na cola. Un fío do grupo selecciona varios futuros da cola e enquisaos usando wait_for. Cando un resultado está dispoñible, chámase á función correspondente e o seu valor de retorno pásase á promesa.

Usamos o mesmo grupo de fíos para executar consultas a etcd e Consul. Isto significa que se pode acceder ás bibliotecas subxacentes mediante varios fíos diferentes. ppconsul non é seguro para fíos, polo que as chamadas a el están protexidas por bloqueos.
Podes traballar con grpc desde varios fíos, pero hai sutilezas. En etcd os reloxos impléntanse mediante fluxos grpc. Son canles bidireccionais para mensaxes de certo tipo. A biblioteca crea un único fío para todos os reloxos e un único fío que procesa as mensaxes entrantes. Polo tanto, grpc prohibe as escrituras paralelas no fluxo. Isto significa que ao inicializar ou eliminar un reloxo, debes esperar ata que se complete o envío da solicitude anterior antes de enviar a seguinte. Usamos para sincronización variables condicionais.

Total

Mire por ti mesmo: liboffkv.

O noso equipo: Raed Romanov, Iván Glushenkov, Dmitri Kamaldinov, Víctor Krapivensky, Vitali Ivanin.

Fonte: www.habr.com

Engadir un comentario