Cinco alunos e três armazenamentos de valores-chave distribuídos

Ou como escrevemos uma biblioteca cliente C++ para ZooKeeper, etcd e Consul KV

No mundo dos sistemas distribuídos, existem várias tarefas típicas: armazenar informações sobre a composição do cluster, gerenciar a configuração dos nós, detectar nós defeituosos, escolher um líder e outros. Para resolver estes problemas, foram criados sistemas distribuídos especiais - serviços de coordenação. Agora estaremos interessados ​​em três deles: ZooKeeper, etcd e Consul. De todas as ricas funcionalidades do Consul, vamos nos concentrar no Consul KV.

Cinco alunos e três armazenamentos de valores-chave distribuídos

Em essência, todos esses sistemas são armazenamentos de valores-chave linearizáveis ​​e tolerantes a falhas. Embora seus modelos de dados apresentem diferenças significativas, que discutiremos mais adiante, eles resolvem os mesmos problemas práticos. Obviamente, cada aplicação que utiliza o serviço de coordenação está vinculada a uma delas, o que pode levar à necessidade de suportar vários sistemas em um data center que resolvam os mesmos problemas para diferentes aplicações.

A ideia de resolver este problema surgiu numa agência de consultoria australiana, e coube a nós, uma pequena equipa de estudantes, implementá-la, e é sobre isso que vou falar.

Conseguimos criar uma biblioteca que fornece uma interface comum para trabalhar com ZooKeeper, etcd e Consul KV. A biblioteca é escrita em C++, mas há planos de portá-la para outras linguagens.

Modelos de dados

Para desenvolver uma interface comum para três sistemas diferentes, você precisa entender o que eles têm em comum e como diferem. Vamos descobrir.

Funcionário do zoológico

Cinco alunos e três armazenamentos de valores-chave distribuídos

As chaves são organizadas em uma árvore e são chamadas de nós. Da mesma forma, para um nó você pode obter uma lista de seus filhos. As operações de criação de um znode (create) e alteração de um valor (setData) são separadas: apenas as chaves existentes podem ser lidas e alteradas. Os relógios podem ser anexados às operações de verificação da existência de um nó, leitura de um valor e obtenção de filhos. Watch é um gatilho único que é acionado quando a versão dos dados correspondentes no servidor é alterada. Nós efêmeros são usados ​​para detectar falhas. Eles estão vinculados à sessão do cliente que os criou. Quando um cliente fecha uma sessão ou para de notificar o ZooKeeper sobre sua existência, esses nós são automaticamente excluídos. São suportadas transações simples - um conjunto de operações que são bem-sucedidas ou falham se isso não for possível para pelo menos uma delas.

etc.

Cinco alunos e três armazenamentos de valores-chave distribuídos

Os desenvolvedores deste sistema foram claramente inspirados no ZooKeeper e, portanto, fizeram tudo de forma diferente. Não há hierarquia de chaves, mas elas formam um conjunto ordenado lexicograficamente. Você pode obter ou excluir todas as chaves pertencentes a um determinado intervalo. Essa estrutura pode parecer estranha, mas na verdade é muito expressiva e uma visão hierárquica pode ser facilmente emulada através dela.

O etcd não possui uma operação padrão de comparação e configuração, mas possui algo melhor: transações. Claro, eles existem em todos os três sistemas, mas as transações etcd são especialmente boas. Eles consistem em três blocos: verificação, sucesso, falha. O primeiro bloco contém um conjunto de condições, o segundo e o terceiro - operações. A transação é executada atomicamente. Se todas as condições forem verdadeiras, o bloco de sucesso será executado; caso contrário, o bloco de falha será executado. Na API 3.3, os blocos de sucesso e falha podem conter transações aninhadas. Ou seja, é possível executar atomicamente construções condicionais de nível de aninhamento quase arbitrário. Você pode aprender mais sobre quais verificações e operações existem em documentação.

Aqui também existem relógios, embora sejam um pouco mais complicados e reutilizáveis. Ou seja, após instalar um relógio em uma faixa de chaves, você receberá todas as atualizações dessa faixa até cancelar o relógio, e não apenas a primeira. No etcd, o análogo das sessões do cliente ZooKeeper são concessões.

Consul KV

Também não existe uma estrutura hierárquica rígida aqui, mas o Consul pode criar a aparência de que ela existe: você pode obter e excluir todas as chaves com o prefixo especificado, ou seja, trabalhar com a “subárvore” da chave. Essas consultas são chamadas de recursivas. Além disso, o Consul pode selecionar apenas chaves que não contenham o caractere especificado após o prefixo, o que corresponde à obtenção de “filhos” imediatos. Mas vale lembrar que esse é justamente o surgimento de uma estrutura hierárquica: é bem possível criar uma chave se seu pai não existir ou excluir uma chave que tenha filhos, enquanto os filhos continuarão armazenados no sistema.

Cinco alunos e três armazenamentos de valores-chave distribuídos
Em vez de relógios, o Consul bloqueia solicitações HTTP. Em essência, trata-se de chamadas comuns ao método de leitura de dados, para o qual, juntamente com outros parâmetros, é indicada a última versão conhecida dos dados. Se a versão atual dos dados correspondentes no servidor for maior que a especificada, a resposta será retornada imediatamente, caso contrário - quando o valor mudar. Existem também sessões que podem ser anexadas às chaves a qualquer momento. É importante notar que, diferentemente do etcd e do ZooKeeper, onde a exclusão de sessões leva à exclusão das chaves associadas, existe um modo em que a sessão é simplesmente desvinculada deles. Disponível transações, sem filiais, mas com todos os tipos de cheques.

Juntando tudo

ZooKeeper possui o modelo de dados mais rigoroso. As consultas de intervalo expressivas disponíveis no etcd não podem ser emuladas de forma eficaz no ZooKeeper ou no Consul. Tentando incorporar o melhor de todos os serviços, acabamos com uma interface quase equivalente à interface do ZooKeeper com as seguintes exceções significativas:

  • nós de sequência, contêiner e TTL não suportado
  • ACLs não são suportadas
  • o método set cria uma chave se ela não existir (em ZK setData retorna um erro neste caso)
  • Os métodos set e cas são separados (em ZK eles são essencialmente a mesma coisa)
  • o método erase exclui um nó junto com sua subárvore (em ZK delete retorna um erro se o nó tiver filhos)
  • Para cada chave existe apenas uma versão - a versão do valor (em ZK Há três deles)

A rejeição de nós sequenciais se deve ao fato de que etcd e Consul não possuem suporte integrado para eles, e eles podem ser facilmente implementados pelo usuário na interface da biblioteca resultante.

Реализация аналогичного ZooKeeper поведения при удалении вершины потребовала бы поддержания в etcd и Consul для каждого ключа отдельного счётчика детей. Поскольку мы старались избегать хранения метаинформации, было решено удалять всё поддерево.

Sutilezas de implementação

Рассмотрим подробнее некоторые аспекты реализации интерфейса библиотеки в разных системах.

Hierarquia no etcd

Manter uma visão hierárquica no etcd acabou sendo uma das tarefas mais interessantes. As consultas de intervalo facilitam a recuperação de uma lista de chaves com um prefixo especificado. Por exemplo, se você precisar de tudo que começa com "/foo", você está pedindo um intervalo ["/foo", "/fop"). Mas isso retornaria toda a subárvore da chave, o que pode não ser aceitável se a subárvore for grande. No início, planejamos usar um mecanismo de tradução de chave, implementado em zetcd. Он предполагает добавление в начало ключа одного байта, равного глубине узла в дереве. Приведу пример.

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

Então pegue todos os filhos imediatos da chave "/foo" possível solicitando um intervalo ["u02/foo/", "u02/foo0"). Sim, em ASCII "0" fica logo depois "/".

Но как в таком случае реализовать удаление вершины? Получается, нужно удалить все диапазоны вида ["uXX/foo/", "uXX/foo0") para XX de 01 a FF. E então nos deparamos limite de número de operação внутри одной транзакции.

В итоге была придумана простая система преобразования ключей, которая позволила эффективно реализовать и удаление ключа, и получение списка детей. Достаточно дописывать спецсимвол перед последним токеном. Например:

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

Em seguida, excluindo a chave "/very" se transforma em exclusão "/u00very" e alcance ["/very/", "/very0"), e obtendo todos os filhos - em uma solicitação de chaves do intervalo ["/very/u00", "/very/u01").

Removendo uma chave no ZooKeeper

Como já mencionei, no ZooKeeper você não pode excluir um nó se ele tiver filhos. Queremos deletar a chave junto com a subárvore. O que devo fazer? Fazemos isso com otimismo. Primeiro, percorremos recursivamente a subárvore, obtendo os filhos de cada vértice com uma consulta separada. Em seguida, construímos uma transação que tenta excluir todos os nós da subárvore na ordem correta. É claro que podem ocorrer alterações entre a leitura de uma subárvore e sua exclusão. Neste caso, a transação falhará. Além disso, a subárvore pode mudar durante o processo de leitura. Uma solicitação para os filhos do próximo nó pode retornar um erro se, por exemplo, este nó já tiver sido excluído. Em ambos os casos, repetimos todo o processo novamente.

Такой подход делает удаление ключа весьма неэффективным, если у него есть дети и тем более если приложение продолжает работать с поддеревом, удаляя и создавая ключи. Однако, это позволило не усложнять реализацию других методов в etcd и Consul.

definido no ZooKeeper

No ZooKeeper existem métodos separados que funcionam com a estrutura em árvore (criar, excluir, getChildren) e que funcionam com dados em nós (setData, getData) Além disso, todos os métodos têm pré-condições estritas: create retornará um erro se o nó já tiver foi criado, exclua ou setData – se ainda não existir. Precisávamos de um método set que pudesse ser chamado sem pensar na presença de uma chave.

Uma opção é adoptar uma abordagem optimista, como acontece com a eliminação. Verifique se existe um nó. Se existir, chame setData, caso contrário, crie. Se o último método retornou um erro, repita tudo novamente. A primeira coisa a notar é que o teste de existência é inútil. Você pode chamar create imediatamente. A conclusão bem-sucedida significará que o nó não existia e foi criado. Caso contrário, create retornará o erro apropriado, após o qual você precisará chamar setData. É claro que, entre chamadas, um vértice poderia ser excluído por uma chamada concorrente e setData também retornaria um erro. Nesse caso você pode fazer tudo de novo, mas vale a pena?

Если оба метода вернули ошибку, то мы точно знаем, что имело место конкурирующее удаление. Представим, что это удаление произошло после вызова set. Тогда какое бы значение мы не пытались установить, оно уже стёрто. Значит можно считать, что set выполнился успешно, даже если на самом деле ничего записать не удалось.

Mais detalhes técnicos

Nesta seção faremos uma pausa nos sistemas distribuídos e falaremos sobre codificação.
Одним из основных требований заказчика была кроссплатформенность: в Linux, MacOS и Windows должен поддерживаться хотя бы один из сервисов. Изначально мы вели разработку только под Linux, а в остальных системах начали тестировать позже. Это вызвало массу проблем, к которым некоторое время было совершенно непонятно как подступиться. В итоге сейчас в Linux и MacOS поддерживаются все три сервиса координации, а в Windows – только Consul KV.

С самого начала для доступа к сервисам мы старались использовать готовые библиотеки. В случае с ZooKeeper выбор пал на Zookeeper C++, que acabou falhando ao compilar no Windows. Isso, entretanto, não é surpreendente: a biblioteca está posicionada apenas para Linux. Para o Cônsul a única opção era ppconsul. O suporte teve que ser adicionado a ele sessões и transações. Para o etcd, não foi encontrada uma biblioteca completa que suporte a versão mais recente do protocolo, então simplesmente cliente grpc gerado.

Inspirados na interface assíncrona da biblioteca ZooKeeper C++, decidimos implementar também uma interface assíncrona. ZooKeeper C++ usa primitivos de futuro/promessa para isso. No STL, infelizmente, eles são implementados de forma muito modesta. Por exemplo, não então método, que aplica a função passada ao resultado do futuro quando ele estiver disponível. No nosso caso, tal método é necessário para converter o resultado para o formato da nossa biblioteca. Para contornar esse problema, tivemos que implementar nosso próprio pool de threads simples, já que a pedido do cliente não poderíamos usar bibliotecas pesadas de terceiros, como Boost.

Nossa implementação então funciona assim. Quando chamado, um par promessa/futuro adicional é criado. O novo futuro é retornado e o passado é colocado junto com a função correspondente e uma promessa adicional na fila. Um thread do pool seleciona vários futuros da fila e os pesquisa usando wait_for. Quando um resultado fica disponível, a função correspondente é chamada e seu valor de retorno é passado para a promessa.

Usamos o mesmo pool de threads para executar consultas ao etcd e ao Consul. Isso significa que as bibliotecas subjacentes podem ser acessadas por vários threads diferentes. O ppconsul não é thread-safe, portanto as chamadas para ele são protegidas por bloqueios.
С grpc можно работать из нескольких потоков, но есть тонкости. В etcd watches реализованы через grpc streams. Это такие двунаправленные каналы для сообщений определённого типа. Библиотека создаёт единственный stream для всех watches и единственный поток, который обрабатывает поступающие сообщения. Так вот grpc запрещает производить параллельные записи в stream. Это значит, что при инициализации или удалении watch’а нужно дождаться, пока завершиться отправка предыдущего запроса, перед тем как посылать следующий. Мы используем для синхронизации условные переменные.

Total

Veja você mesmo: liboffkv.

Nosso time: Raed Romanov, Иван Глушенков, Dmitri Kamaldinov, Victor Krapivensky, Vitaly Ivanina.

Fonte: habr.com

Adicionar um comentário