Edit page

Replicação e Tolerância a Faltas

Introdução a sistemas replicados

A replicação consiste no processo de manter cópias de dados e software de um serviço em várias máquinas.

Diagrama de uma replicação

Benefícios de replicar um sistema

  • melhor disponibilidade: mesmo que alguns nós falhem ou fiquem indisponíveis devido a falhas na rede, o sistema continua disponível
  • melhor desempenho e escalabilidade:
    • Podem existir cópias mais próximas do cliente
    • Algumas operações não precisam de ser executadas sobre todo o sistema, podendo ser executadas apenas sobre algumas cópias (distribuindo assim a carga e aumentando a escalabilidade)

Linearizabilidade

Uma das garantias essenciais que um sistema replicado tem que assegurar é a coerência. O ideal seria um cliente ler a versão mais recente do recurso sempre que lê de uma réplica (mesmo que essa versão tenha sido escrita noutra réplica). Um critério de coerência que podemos utilizar é a linearizabilidade. Antes de definirmos o que é, é necessário ter em conta o seguinte quanto à ordenação de operações:

  • As operações realizadas sobre um sistema replicado não são instantâneas:
    • A operação é invocada por um cliente, executada durante algum tempo e o cliente só recebe a resposta mais tarde.
  • Se uma operação X começa depois de outra operação Y acabar, então X ocorre depois de Y
  • Se uma operação X começa antes de outra operação Y acabar, então X é concorrente com Y
Exemplo

Exemplo de ordenação de operações

  • A concorrente com B
  • A anterior a C
  • B anterior a C
  • C concorrente com D
  • C anterior a E
  • D concorrente com E

Definição

Um sistema replicado diz-se linearizável se e só se:

  1. Existe uma serialização virtual que respeita o tempo real em que as operações foram invocadas, isto é:

    • Se op1op_1 ocorre antes de op2op_2 (em tempo real), então op1op_1 tem de aparecer antes de op2op_2 na serialização virtual. (se op1op_1 e op2op_2 forem concorrentes, a serialização pode ordená-las de forma arbitrária)
  2. A execução observada por cada cliente é coerente com essa serialização (para todos os clientes), ou seja:

    • Os valores retornados pelas leituras feitas por cada cliente refletem as operações anteriores na serialização
Exercício

Considere os seguintes exemplos em que um cliente escreve sobre um inteiro replicado. Quais das execuções são serializáveis?

Exemplo 1: Exemplo 1 de serialização

Exemplo 2: Exemplo 2 de serialização

Exemplo 3: Exemplo 3 de serialização

R: Apenas o 1º exemplo:

Solução exemplo 1

Registos partilhados e replicados

Um registo suporta duas operações:

  • Escrita:
    • uma escrita substitui o valor da anterior
    • apenas um cliente pode escrever no registo num dado instante, ou seja, as escritas são ordenadas
  • Leitura:
    • múltiplos clientes podem ler do registo ao mesmo tempo

Tipos de registos

Lamport definiu três modelos de coerência para registos:

  • Safe:

    • Se uma leitura não for concorrente com uma escrita, lê o último valor escrito
    • Se for, pode retornar um valor arbitrário
  • Regular:

    • Se uma leitura não for concorrente com uma escrita, lê o último valor escrito
    • Se for, ou retorna o valor anterior ou o valor que está a ser escrito
    • NOTA: este tipo de registo não é linearizável, já que enquanto decorre uma escrita, é possível que leituras seguidas leiam sequências incoerentes de valores (primeiro o novo valor e depois o antigo)
  • Atomic:

    • Equivalente a linearizabilidade quando aplicada a registos
    • O resultado da execução é equivalente ao resultado de uma execução em que todas as escritas e leituras ocorrem instantaneamente num ponto entre o início e o fim da operação
Exemplos
  • registo "Unsafe":

Exemplo de registo unsafe

  • registo "Safe":

Exemplo de registo safe

  • registo "Regular":

Exemplo de registo regular

  • registo "Atomic":

Exemplo de registo atomic

De onde surgiu a ideia de um registo "safe"?

Considere um registo com o valor 00 (em binário: 0000 00000000~0000) e que se quer escrever o valor 33 (em binário: 0000 00110000~0011). Se não existir um mecânismo de sincronização que impeça o leitor de ler o registo durante a escrita, a leitura pode retornar um dos seguintes valores:

  • 0000 00000000~0000 = 00
  • 0000 00010000~0001 = 11
  • 0000 00100000~0010 = 22
  • 0000 00110000~0011 = 33

(já que os bits são alterados individualmente em instantes diferentes)

Registos distribuídos

Uma forma de implementar registos distribuídos é a seguinte:

  • Cada processo mantém uma cópia do registo
  • Cada registo guarda um tuplo <valor, versão>
  • Para executar uma operação (leitura ou escrita), os processos trocam mensagens entre si

É possível implementar este funcionamento de forma a que seja tolerante a faltas e não bloqueante!

Algoritmo ABD

Registo regular (com um só escritor):

  • Escrita:

    • O escritor incrementa o número de versão e envia o tuplo <valor, versão> para todos os processos.
    • Quando os outros processos recebem esta mensagem, atualizam a sua cópia do registo (caso seja uma versão mais recente que a local) e enviam uma confirmação ao escritor.
    • A operação termina quando o escritor receber resposta de uma maioria.
  • Leitura:

    • O leitor envia uma mensagem a todos os processos solicitando o tuplo mais recente
    • Cada processo envia o seu tuplo <valor, versão>
    • Após receber resposta de uma maioria, retorna o valor mais recente e atualiza o valor local (caso necessário)

Intuição do algoritmo

Exemplo

Exemplo de execução do algoritmo

O algoritmo não é atómico

Diagrama que demonstra que o algoritmo não é atómico

Tal como ilustrado no diagrama, é possível obter leituras diferentes consoante as réplicas que contactamos (lendo v1v_1, depois v0v_0 e v1v_1 novamente), violando o princípio da linearizabilidade.

Registo atómico (com um só escritor):

  • Escrita:

    • Idêntico ao anterior.
  • Leitura:

    • Executa o algoritmo de leitura anterior mas não retorna o valor
    • Executa o algoritmo de escrita, usando o valor lido
    • Retorna o valor lido apenas após a escrita ter terminado

Espaço de Tuplos

Espaço de Tuplos "Linda"

Consiste num espaço partilhado que contêm um conjunto de tuplos (ex: <"A">, <"A", 1>, <"A", "B">) e que suporta 3 operações:

  • Put: adiciona um tuplo (sem afetar os tuplos existentes) no espaço
  • Read: retorna o valor de um tuplo (sem afetar o conteúdo do espaço)
  • Take: também retorna um tuplo mas remove-o do espaço
  • Tanto Read como Take são bloqueantes caso o tuplo não exista
  • Tanto Read como Take aceitam wildcards: Read(<"A", *>) pode retornar <"A", 1> ou <"A", "B">

Como o Take é bloqueante, pode ser usado para sincronizar processos:

  • Elege-se um tuplo especial, por ex. <lock>.
  • Cada processo remove o tuplo do espaço antes de aceder à região crítica e volta a colocá-lo no fim, garantindo assim exclusão mútua
  • Conseguimos assim através de uma única interface partilhar memória e sincronizar processos

Enquanto que nos registos uma escrita fazia override do valor antigo, aqui o processo equivalente é realizar um Take seguido de um Put (os tuplos são imutáveis). O Take permite fazer operações que em memória partilhada requerem uma instrução do tipo compare-and-swap.

Nota

Note que as operações Put, Read e Take são conhecidas como out, rd e in no modelo Linda, usamos estes nomes mais descritivos como simplificação.

Xu-Liskov

Muitas das implementações de espaços de tuplos adotam uma solução centralizada. Isto tem vantagens em termos de simplicidade, mas tais soluções não são tolerantes a falhas nem escaláveis.

Xu-Liskov é uma implementação distribuída e tolerante a faltas do "Linda".

Algumas observações prévias

  • A tolerância de faltas pressupõe um serviço de filiação que gere o grupo de réplicas:

    • Quando uma réplica falha, a filiação do grupo é alterada
  • Sendo assim, quando o algoritmo espera por "todas" as respostas ou pela maioria delas, refere-se à filiação do grupo num dado instante.

  • Esta alteração dinâmica da filiação é um problema bastante complexo por si só e portanto não vai ser abordado

  • Os autores optam por usar UDP (portanto pode haver perda de mensagens), sendo o próprio algoritmo responsável por retransmitir mensagens.

    • Solução modular: usar TCP

O objetivo dos autores com este design era obter a solução mais eficiente e com o menor tempo de resposta possível, mas assegurando linearizabilidade.

Funcionamento do algoritmo

Put:

  1. The requesting site multicasts the put request to all members of the view;
  2. On receiving this request, members insert the tuple into their replica and acknowledge this action;
  3. Step 1 is repeated until all acknowledgements are received. For the correct operation of the protocol, replicas must detect and acknowledge duplicate requests, but not carry out the associated put operations.

Read:

  1. The requesting site multicasts the read request to all members of the view;
  2. On receiving this request, a member returns a matching tuple to the requestor,
  3. The requestor returns the first matching tuple received as the result of the operation (ignoring others);
  4. Step 1 is repeated until at least one response is received.

Take:

Phase 1: Selecting the tuple to be removed

  1. The requesting site multicasts the take request to all members of the view;
  2. On receiving this request, each replica acquires a lock on the associated tuple set and, if the lock cannot be acquired, the take request is rejected;
  3. All accepting members reply with the set of all matching tuples;
  4. Step 1 is repeated until all sites have accepted the request and responded with their set of tuples and the intersection is non-null;
  5. A particular tuple is selected as the result of the operation (selected randomly from the intersection of all the replies);
  6. If only a minority accept the request, this minority are asked to release their locks and phase 1 repeats.

Phase 2: Removing the selected tuple

  1. The requesting site multicasts a remove request to all members of the view citing the tuple to be removed;
  2. On receiving this request, members remove the tuple from their replica, send an acknowledgement and release the lock;
  3. Step 1 is repeated until all acknowledgements are received.

Visto que este algoritmo foi projetado para minimizar o delay :

  • Operações read apenas bloqueiam até que a primeira réplica responda ao pedido
  • Operações take bloqueiam até o final da fase 1, quando o tuplo a ser excluído foi acordado
  • Operações put podem retornar imediatamente

No entanto, isto introduz níveis inaceitáveis de concorrência. Por exemplo, uma operação read pode aceder a um tuplo que deveria ter sido excluído na segunda fase de uma operação take. Assim, são necessárias as seguintes restrições adicionais:

  • As operações de cada worker devem ser executadas em cada réplica na mesma ordem em que foram emitidas pelo worker
  • Uma operação put não deve ser executada em nenhuma réplica até que todas as operações take anteriores, emitidas pelo mesmo worker, tenham sido concluídas em todas as réplicas (na visão do mesmo)

Nota

Operações take bloqueiam até o final da fase 1, quando o tuplo a ser excluído foi acordado

Note que isto é uma otimização para minimizar a latência, no algoritmo original o worker fica bloqueado até o final da fase 2. Esta otimização não faz parte do projeto.

Exemplos

Read:

Exemplo de operação "Read"

Put:

Exemplo de operação "Put"

Take:

Exemplo de operação "Take"

Imaginemos que o Read não era bloqueante, ou seja, ou retornava o tuplo ou "null". O sistema continuaria a ser linearizável? Não, pois seria possível:

  • Um cliente executa Put de um tuplo tt
  • Um cliente executa Read(t) várias vezes, concorrentemente com o Put
  • Se o leitor recebe respostas de réplicas diferentes em cada Read, pode ler tt e de seguida "null"

Quanto ao Take, é possível que dois processos tentem fazer Take do mesmo tuplo concorrentemente e nenhum consiga a maioria (deadlock). Para resolver este problema introduz-se um fator de aleatoriedade: cada processo repete o seu pedido um tempo aleatório depois (solução não determinista).

Teorema CAP

Segundo este teorema, é impossível um sistema apresentar simultaneamente:

  • Consistency (coerência)
  • Availability (disponibilidade)
  • Partition-tolerance (tolerância a partições na rede)

Apenas pode ter 2 destas propriedades (quaisquer duas).

CAP theorem

Coerência fraca

Nesta secção, vamos explorar técnicas de replicação para tornar os serviços altamente disponíveis. O objetivo é dar aos clientes acesso ao serviço (com tempos de resposta razoáveis) durante o máximo de tempo possível, mesmo que alguns resultados não respeitem a consistência sequencial.

Garantias de sessão

  • Read Your Writes:
    • Se um Read RR procede um Write WW numa sessão, então o valor de WW é incluído em RR
  • Monotonic Reads:
    • Se um Read R1R1 precede um Read R2R2 numa sessão, R2R2 tem que incluir todos os Writes completos incluídos em R1R1 (mas pode ter alterações mais recentes)
  • Writes Follow Reads:
    • Se um Read R1R1 precede um Write W2W2 numa sessão e R1R1 é executado no servidor S1S1, então qualquer servidor S2S2 que inclui W2W2 também inclui qualquer Write W1W1 incluido em R1R1 na ordem em que foram escritos
  • Monotonic Writes:
    • Se um Write W1W1 precede um Write W2W2 numa sessão, então qualquer servidor S2S2 que inclua W2W2 também inclui W1W1 na ordem em que foram escritos
Guarantee Session state updated on Session state checked on
RYR Write Read
MR Read Read
WFR Read Write
MW Write Write
Pseudocódigo de operações de escrita e leitura
Write(W,S) = {
  if WFR then
    check S.vector dominates read-vector
  if MW then
    check S.vector dominates write-vector
  wid := write W to S
  write-vector[S] := wid.clock
}

Read(R,S) = {
  if MR then
    check S.vector dominates read-vector
  if RYW then
    check S.vector dominates write-vector
  [result, relevant-write-vector] := read R from S
  read-vector := MAX(read-vector, relevant-write-vector)
  return result
}

Propagação epidémica (gossip propagation)

De forma a introduzir o conceito de propagação de informação, vamos pensar sobre a forma menos coordenada possível de fazê-lo:

  • Periodicamente, cada processo contacta os outros e envia-lhes informação/atualizações
  • A informação é assim propagada como uma espécie de "rumor" (do inglês gossip)
  • Apresenta a grande vantagem de ser totalmente descentralizada, balanceando a carga por todos os processos

Ao propagar informação desta forma surgem, naturalmente, duas questões essenciais:

  • Por que ordem devem ser aplicadas as atualizações recebidas dos outros processos?
  • Se um processo contacta primeiro uma réplica R1 e depois outra R2, que garantias mínimas devem existir?

Pensemos no exemplo em que existem três réplicas R1, R2 e R3 e que se está a executar uma aplicação de preparação de slides de forma cooperativa:

  • Em R1 um cliente executa a instrução "criar círculo", que é propagada para R2
  • Em R2 outro cliente executa a instrução "pintar círculo", que é propagada para R3
  • R3 recebe primeiro a instrução "pintar círculo" antes de "criar círculo", o que pode gerar um erro

Outro exemplo:

  • Em R1 um cliente executa a instrução "label=distribúidos" e propaga-a para R2
  • Em R2 um cliente apercebe-se da gralha e corrige-a: "label=distribdos"
  • R3 recebe primeiro a instrução executada em R2 e só depois a executada em R1
  • Se aplicar as atualizações por esta ordem, a correção da gralha fica sem efeito

A grande maioria dos sistemas de propagação epidémica aplica as atualizações de forma a respeitar a relação aconteceu-antes, ou seja, respeita a ordem causal dos acontecimentos.

E no caso dos clientes móveis?

  • Um cliente muda a sua password na réplica R1
  • Muda para a réplica R2 antes da alteração ser propagada
  • Não consegue utilizar a sua nova password

Existem sistemas capazes de garantir a relação aconteceu-antes para clientes móveis, enquanto que outros oferecem garantias ainda mais fracas como as garantias de sessão apresentadas acima.

Todas estas garantias podem ser atingidas com o uso de relógios lógicos (pouco eficiente).

Lazy Replication - gossip architecture

Foi proposta em 1922 e inspirou muitos dos sistemas fracamente coerentes dos dias de hoje. Tem como objetivo:

  • garantir acesso rápido aos clientes
  • mesmo quando existem partições na rede
  • com o trade-off de sacrificar a coerência

O nome gossip vem de as réplicas propagarem as alterações periodicamente em background, como se fossem boatos.

lazy replication basic operation

O algoritmo oferece coerência fraca, mas assegura duas propriedades:

  • Monotonic reads
    • mesmo que o cliente aceda a réplicas diferentes
  • O estado das réplicas respeitam a ordem causal das alterações
    • se uma modificação m2m_2 depende de outra m1m_1, a réplica nunca executa m2m_2 antes de m1m_1

Interação cliente-réplica

  • Cada cliente mantém um timestamp vetorial denominado prevprev

    • vetor de inteiros, um por réplica
    • reflete a última versão acedida pelo cliente
  • Em cada contacto com uma réplica, é enviado também o prevprev: (pedido, prevprev)

  • A réplica devolve (resposta, newnew)

    • em que newnew é o timestamp vetorial que reflete o estado da réplica
    • se a réplica estiver atrasada em relação ao cliente, espera até se atualizar para devolver uma resposta (caso seja uma leitura)
  • Cliente atualiza prevprev de acordo com newnew, confrontando cada entrada de prevprev com a correspondente de newnew

    • se new[i]>prev[i]new[i] \gt prev[i], prev[i]=new[i]prev[i] = new[i]
  • Cada réplica mantém também localmente um update log

    • uma réplica pode já ter recebido uma modificação mas não a poder executar devido a ainda não ter recebido/executado dependências causais, pelo que a mantém em estado pendente no log
    • pode assim propagar modificações individuais para as outras réplicas, mantendo o update no log até receber confirmação de todas elas

lazy replication basic operation

Quando uma réplica recebe um pedido de leitura:

  • verifica se pedido.prevvalue timestamppedido.prev \leq value~timestamp
    • se sim, retorna o valor atual juntamente com o value timestampvalue~timestamp
    • se não, o pedido fica pendente até estar atualizada

Quando uma réplica ii recebe um pedido de modificação:

  • verifica se já o executou e em caso afirmativo descarta-o
  • incrementa a entrada ii do seu replica timestampreplica~timestamp em uma unidade
  • atribui à modificação um novo timestamp calculado por:
    • pedido.prevpedido.prev com a entrada ii substituída pelo novo valor calculado acima (gerando assim um timestamp único para este update)
  • adiciona a modificação ao log e retorna o novo timestamp ao cliente
  • espera até pedido.prevvalue timestamppedido.prev \leq value~timestamp se verificar para executar o código localmente
    • garantindo assim que a execução respeita a ordem causal
  • quando finalmente executar o pedido, atualiza o value timestampvalue~timestamp
    • para cada entrada jj: se replicaTS[j]>valueTS[j]replicaTS[j] \gt valueTS[j], valueTS[j]=replicaTS[j]valueTS[j] = replicaTS[j]

Para propagar as modificações:

  • periodicamente, cada gestor de réplica ii contacta outro gestor jj
  • ii envia de forma ordenada a jj as modificações do seu log que estima que jj não tem
  • para cada modificação que jj recebe:
    • se não for duplicada, adiciona ao seu log
    • atualiza o seu replicaTSreplicaTS
    • assim que prevvalue timestampprev \leq value~timestamp, executa a modificação
  • NOTA: As modificações não são apenas propagadas desta forma (comunicação periódica). Quando um gestor de réplicas descobre que precisa de uma certa atualização para processar um pedido, este pode pedi-la diretamente.

Aconteceu-antes vs protocolo gossip

Enquanto que quando estudámos a relação aconteceu-antes vimos que 3 tipos de eventos atualizavam os timestamps vetoriais:

  • Eventos locais genéricos
  • Envio de uma mensagem
  • Receção de uma mensagem

Exemplo de vector clocks

No gossip apenas existe um tipo de eventos que causa esta atualização: Updates

Exemplo

Imaginemos um sistema com 2 réplicas R0R_0 e R1R_1, em que ambas mantêm saldos de contas bancárias. As contas da Alice e Bob começaram com saldo nulo e mais tarde receberam transferências de uma conta SS (que assumimos que contava com saldo suficiente para realizar ambas as transferências). Cada operação foi aceite por uma réplica diferente:

op SAliceop_{~S \rarr Alice} : S.transferTo(Alice, 10)\text{S.transferTo(Alice, 10)}

  • aceite por R0R_0 com TS=<1,0>TS = \text{<1,0>} e prevTS=<0,0>prevTS = \text{<0,0>}

op SBobop_{~S \rarr Bob} : S.transferTo(Bob, 20)\text{S.transferTo(Bob, 20)}

  • aceite por R1R_1 com TS=<0,1>TS = \text{<0,1>} e prevTS=<0,0>prevTS = \text{<0,0>}

Execução da primeira operação:

Lazy replication - 1ª operação

Execução de reads de réplicas diferentes:

Lazy replication - reads de réplicas diferentes

Nota

Os clientes também podem comunicar diretamente entre si. Visto que estas comunicações podem originar relações causais entre operações realizadas no serviço, os clientes devem incluir os seus timestamps nestas mensagens de forma a que os destinatários consigam fazer merge dos timestamps. Assim, estas relações causais podem ser inferidas corretamente pelas réplicas do serviço.

Existem outros tipos de algoritmos para suportar operações que requerem modelos de coerência mais fortes, como por exemplo:

  • Forced operations (total e causal)
  • Immediate operations

As atualizações immediate-ordered são aplicadas numa ordem consistente em relação a qualquer outra atualização em todos os gestores de réplicas. Esta ordenação é utilizada porque, por exemplo, uma atualização forced e outra causal que não têm uma relação happened-before podem ser aplicadas em ordens diferentes nos diversos gestores de réplicas.

Estas garantias de ordenação não são abordadas nesta cadeira.

Algoritmo de Bayou

No sistema de Lazy Replication assume-se que as operações concorrentes são comutativas, ou seja, independentemente da ordem pela qual são aplicadas, o resultado final é o mesmo (por ex: "desenhar círculo" e "alterar cor da linha"). Se as operações não forem comutativas, é necessário utilizar primitivas mais fortes (forced/immediate).

O Bayou introduz uma solução para reconciliar automaticamente réplicas divergentes:

  • Ordenar operações concorrentes por uma ordem total
  • Cancelar as operações que foram executadas por uma ordem diferente
  • Re-aplicar estas operações pela ordem total, aplicando uma função de reconciliação que é específica da aplicação
  • As operações podem ser codificadas de forma a facilitar a reconciliação automática

Updates: tentativo e commit

  • Quando se faz um update, ele fica marcado como tentativo
    • podendo ainda sofrer alterações nesta fase
  • Quando o update é commited, a sua ordem já não pode ser alterada
    • quem decide a ordem é tipicamente uma réplica especial, o primário
    • quando um update é commited, as outras réplicas podem por isso ter que o ordenar

Bayou - updates tentativos e commited

Na figura acima, tit_i tornou-se commited. Todas as atualizações tentative após cNc_N precisam de ser desfeitas; tit_i é então aplicado após cNc_N e de t0t_0 a ti1t_{i-1} e ti+1t_{i+1}, etc., reaplicados após tit_i.

Exemplo de operação
Bayou_Write(
  update = {insert, Meetings, 12/18/95, 1:30pm, 60min, "Budget Meeting"},
  dependency_check = {
    query = "SELECT key FROM Meetings WHERE day = 12/18/95
      AND start < 2:30pm AND end > 1:30pm",
    expected_result = EMPTY
  },
  mergeproc = {
    alternates = {{12/18/95, 3:00pm}, {12/19/95, 9:30am}};
    newupdate = {};
    FOREACH a IN alternates {
      # check if there would be a conflict
      IF (NOT EMPTY (SELECT key FROM Meetings WHERE day = a.date
        AND start < a.time + 60min AND end > a.time))
          CONTINUE;
      # no conflict, can schedule meeting at that time
      newupdate = {insert, Meetings, a.date, a.time, 60min, "Budget Meeting"};
      BREAK;
    }
    IF (newupdate = {}) # no alternate is acceptable
      newupdate = {insert, ErrorLog, 12/18/95, 1:30pm, 60min, "Budget Meeting"};
    RETURN newupdate;
  }
)

CRDTs (Conflict-Free Replicated Datatypes)

Um CRDT é uma estrutura de dados que é replicada em vários computadores numa rede, com as seguintes características:

  • A aplicação pode atualizar qualquer réplica de forma independente, concorrente e sem precisar de comunicar com outras réplicas (ou seja, sem coordenação, ao contrário da abordagem operational transformation usada por Bayou)
  • Um algoritmo (que faz parte da própria estrutura de dados) resolve automaticamente quaisquer inconsistências que possam surgir entre réplicas
  • Embora as réplicas possam ter estados diferentes num determinado momento, é garantido que eventualmente irão convergir

Pequeno exemplo de um contador (CRDT) que apenas suporta as operações "increment" e "read":

// Replica i

increment() {
  value[i]++;
}

read() {
  return SUM k: 1..N_REPLICAS (value[k])
}

merge_with_replica_j() {
  for k: 1..N_REPLICAS {
    i.value[k] = max(i.value[k], j.value[k])
  }
}

Os CRDTs foram apresentados em aula apenas como uma curiosidade, se tiveres interesse em aprender mais recomendamos a palestra do Martin Kleppmann na QCon London 2018.

Coerência forte

Tolerância a faltas

Existem duas estratégias principais:

  • Recuperação "para trás":
    • guardar o estado do sistema de forma periódica ou em momentos relevantes, utilizando um algoritmo de salvaguarda distribuída
    • quando os processos falham, são relançados o mais rapidamente possível
    • os novos processos recomeçam a partir do último estado guardado
    • solução tipicamente designada por "checkpoint-recovery"
  • Recuperação "para a frente":
    • são mantidas várias cópias (réplicas) do processo
    • quando é feita uma alteração a uma réplica, esta deve ser propagada para todas as outras de forma a estarem atualizadas/sincronizadas
    • se uma réplica falha, o cliente pode passar a utilizar outra
    • estrutura de dados replicada:
      • registo (algoritmo ABD)
      • espaço de tuplos (Xu-Liskov)
      • qualquer estrutura de dados que ofereça uma interface remota (exige algoritmos de replicação genéricos, tema deste capítulo)

De forma resumida:

  • na recuperação para trás os estados guardados pelos vários processos que falharam devem estar mutuamente coerentes
  • na recuperação para a frente os estados das réplicas que sobrevivem devem estar mutuamente coerentes.

Iremos analisar dois algoritmos de replicação genéricos (por recuperação para a frente):

  • Primário-secundário
  • Replicação de máquina de estados

Objetivo: assegurar linearizabilidade (coerência forte).

Algoritmo Primário-secundário (esboço)

  • É eleito um processo como primário (sendo outro eleito em caso de falha)
  • Os pedidos dos clientes são enviados para o primário
  • Quando o primário recebe um pedido:
    • executa-o
    • propaga o estado para os secundários e aguarda confirmação de todos
    • responde ao cliente
    • (processa o próximo pedido que estiver na fila)

Funcionamento do Primário-secundário

Vantagem: Suporta operações não determinísticas (primário decide o resultado).

Desvantagens:

  • Se o primário produzir um valor errado, o erro vai ser propagado para as réplicas
  • Se o detetor de falhas não for perfeito, pode existir mais que um primário concorrentemente, havendo o risco dos estados divergirem

Replicação de máquina de estados (esboço)

  • Os clientes enviam os pedidos para todas as réplicas
  • Os pedidos são ordenados por ordem total
  • Todas as réplicas processam os mesmos pedidos, pela mesma ordem
    • assume-se que as operações são determinísticas, de forma a todas as réplicas ficarem idênticas
  • Cada réplica responde ao cliente (o número de respostas pelo qual o cliente aguarda depende das suposições de falhas)

Funcionamento da Replicação de máquina de estados

Vantagem: Se uma réplica produzir um valor errado, não afecta as outras.

Desvantagens:

  • As operações necessitam de ser determinísticas.
  • Se o detetor de falhas não for perfeito, pode ser impossível estabelecer uma ordem total dos pedidos

Abstrações para a construção de sistemas replicados

Construir sistemas replicados com estes algoritmos é bastante complicado e complexo, pelo que iremos falar de algumas abstrações sobre as quais podemos construir de forma a facilitar a implementação dos mesmos.

Aplicação
Difusão Atómica
Sincronia na Vista
Ordem FIFO e Causal
Difusão Fiável Uniforme
Difusão Fiável Regular
Canais Perfeitos
TCP ou UDP

Canais Perfeitos

  • Garante a entrega de mensagens ponto a ponto de forma ordenada, caso nem o emissor nem o destinatário falhem

  • Implementação prática: retransmitir a mensagem até que a receção desta seja confirmada pelo destinatário

Difusão Fiável

  • Permite difundir uma mensagem com a garantia de que ou todos os destinatários a recebem ou nenhum recebe

  • Duas variantes: Regular e Uniforme

Difusão Fiável Regular

Propriedades:

  • Seja mm uma mensagem enviada para um grupo de processos {p1,p2,...,pN}\Set{p_1, p_2, ..., p_N} por um elemento do grupo
  • Validade: se um processo correto pip_i envia mm então eventualmente pip_i entrega mm
  • Não-duplicação: nenhuma mensagem mm é entregue mais que uma vez
  • Não-criação: se uma mensagem mm é entregue então mm foi enviada por um processo correto
  • Acordo: se um processo correto entrega mm então todos os processos corretos entregam mm

Algoritmo:

  • O emissor envia a mensagem usando canais perfeitos para todos os membros do grupo
  • Quando um membro do grupo recebe a mensagem, entrega-a à aplicação e reenvia-a para todos os membros do grupo

Difusão Fiável Uniforme

Propriedades:

  • Seja mm uma mensagem enviada a para um grupo de processos {p1,p2,...,pN}\Set{p_1, p_2, ..., p_N} por um elemento do grupo
  • Validade, Não-duplicação e Não-criação tal como Difusão Regular
  • Acordo: se um processo entrega mm então todos os processos corretos entregam mm

Algoritmo:

  • O emissor envia a mensagem usando canais perfeitos para todos os membros do grupo
  • Quando um membro do grupo recebe a mensagem, reenvia-a para todos os membros do grupo
  • Quando um membro receber uma mensagem mm de ff membros distintos, entrega a mensagem mm à aplicação
  • Em que ff é o número de processos que pode falhar
f<N2f \lt \frac{N}{2}

Sincronia na Vista

Vista: conjunto de processos que pertence ao grupo:

  • Novos membros podem ser adicionados dinamicamente
  • Um processo pode sair voluntariamente do grupo ou ser expulso caso falhe

Propriedades:

  • Permite alterar a filiação de um grupo de processos de uma forma que facilita a tolerância a faltas
  • A evolução do sistema é dada por uma sequência totalmente ordenada de vistas
    • Exemplo: V1={p1,p2,p3} V2={p1,p2,p3,p4} V3={p1,p2,p3,p4,p5} V4={p2,p3,p4,p5}V_1 = \Set{p_1, p_2, p_3}~V_2 = \Set{p_1, p_2, p_3, p_4}~ V_3 = \Set{p_1, p_2, p_3, p_4, p_5}~V_4 = \Set{p_2, p_3, p_4, p_5}
  • Um processo é considerado correto numa vista ViV_i se faz parte dessa vista e de Vi+1V_{i\op{+}1}
    • Por outro lado, se um processo pertence à vista ViV_i mas não faz parte de Vi+1V_{i\op{+}1}, pode ter falhado durante ViV_i
  • Uma aplicação à qual já foi entregue a vista ViV_i mas à qual ainda não foi entregue a vista Vi+1V_{i\op{+}1} diz-se que "está na vista ViV_i"
  • Uma aplicação que usa o modelo de Sincronia na Vista recebe vistas e mensagens
  • Se uma aplicação envia uma mensagem mm quando se encontra na vista ViV_i, diz-se que mm foi enviada na vista ViV_i
  • Se uma mensagem mm é entregue à aplicação depois da vista ViV_i mas antes da vista Vi+1V_{i\op{+}1}, diz-se que mm foi entregue na vista ViV_i
  • Uma mensagem mm enviada na vista ViV_i é entregue na vista ViV_i
  • Se um processo qq se junta a um grupo e se torna indefinidamente alcançável a partir do processo pp (sendo pqp \neq q), então eventualmente qq estará sempre nas views que pp entrega (a mesma lógica pode ser aplicada a processos que não conseguem comunicar)

Todas estas propriedades culminam nestas duas principais:

  • Comunicação fiável:

    • Se um processo correto pp na vista ViV_i envia uma mensagem mm na vista ViV_i, então mm é entregue a pp na vista ViV_i
    • Se um processo entrega uma mensagem mm na vista ViV_i todos os processos corretos da vista ViV_i entregam mm na vista ViV_i
  • Corolário:

    • Dois processos que entregam as vistas ViV_i e Vi+1V_{i\op{+}1} entregam exactamente o mesmo conjunto de mensagens na vista ViV_i
Exemplo

Considere um grupo com três processos, pp, qq e rr. Suponha que pp envia uma mensagem mm na view (p,q,r)(p, q, r), mas este falha depois de enviar mm. A seguinte figura ilustra as execuções possíveis:

Exemplos de Sincronia na Vista

Para mudar de vista:

  • é necessário interromper temporariamente a transmissão de mensagens de forma a que o conjunto de mensagens a entregar seja finito
  • é necessário executar um algoritmo de coordenação para garantir que todos os processos corretos chegam a acordo sobre:
    • qual a composição da próxima vista
    • qual o conjunto de mensagens a ser entregue antes de mudar de vista

Difusão Atómica

Consiste em ter as garantias de Difusão Fiável + Ordem total:

  • Difusão Fiável: se uma réplica recebe o pedido, todas as réplicas recebem o pedido
  • Ordem total: todas as réplicas recebem os pedidos pela mesma ordem

A abordagem básica para implementar ordenação total é atribuir identificadores totalmente ordenados às mensagens multicast, de modo a que cada processo tome a mesma decisão de ordenação com base nesses identificadores.

Algoritmos de ordem total (sem falhas):

  • Ordem total baseada em sequenciador:

    • as mensagens são enviadas para todas as réplicas usando um algoritmo de Difusão Fiável
    • é eleito um líder que decide a ordem pela qual as mensagens devem ser processadas, enviando esta informação para as réplicas restantes
    • quando existe uma falha, as réplicas podem ser confrontadas com um estado incoerente:
      • mensagens de dados que não foram ordenadas pelo líder
      • mensagens do líder referentes a mensagens que ainda não chegaram
      • podem existir de forma geral diferenças nas perspectivas que cada réplica tem do sistema
      • Todos estes problemas são resolvidos na camada de Sincronia na Vista
  • Ordem total baseada em acordo coletivo:

    • um processo envia uma mensagem multicast para os membros do grupo. Estes processos propõem números de sequência para a mensagem (atribuição "tentativa") e devolvem-nos ao remetente, que gera o número de sequência acordado com base nos números propostos (atribuição final).
    • o algoritmo funciona mesmo que cada mensagem seja enviada para um sub-conjunto diferente de nós
    • quando há falhas, é preciso executar um algoritmo de reconfiguração que fica bastante simplificado pela Sincronia na Vista Funcionamento do algoritmo baseado em acorco coletivo
    Exemplo de aplicação no algoritmo de Maekawa

    Iremos analisar o funcionamento do algoritmo de Skeen (em que o cliente escolhe o maior dos números de sequência propostos) aplicado à exclusão mútua de Maekawa.

    Existem três clientes AA, BB e CC, com quóruns {P1,P2}\Set{P_1, P_2}, {P2,P3}\Set{P_2, P_3} e {P1,P3}\Set{P_1, P_3}.

    Exemplo de skeen com Maekawa - 1

    P1P_1 e P2P_2 já receberam o pedido de AA, marcando-os com o número de sequência 1 e 2 (respectivamente) e enviando essas propostas ao cliente. Estes pedidos encontram-se no estado "tentativo" (T), já que se tratam de propostas, ainda não foram confirmados.

    Exemplo de skeen com Maekawa - 2

    O cliente ao receber estes dois números, escolhe o mais alto (2) e envia essa informação ao seu quórum. O pedido de AA encontra-se agora ordenado definitivamente (estado final, F).

    Exemplo de skeen com Maekawa - 3

    Ao chegar ao topo da lista de P1P_1 com estado final, AA é enviado para a camada de aplicação, que neste caso é o algoritmo Maekawa. P1P_1 dá acesso a AA à exclusão mútua dado estar livre. Entretanto chegou também o pedido de CC a P1P_1, que foi marcado com o número 3.

    Exemplo de skeen com Maekawa - 4

    O cliente CC recebe 1 e 3 como propostas, pelo que escolhe 3. Chega agora o pedido de BB a P3P_3, que lhe atribui o número 4.

    Exemplo de skeen com Maekawa - 5

    Os pedidos de CC chegaram ao topo, pelo que foram enviados para a camada superior. P1P_1 colocou CC como pendente, pois AA tem a exclusão mútua, e P3P_3 concedeu-lhe acesso. BB recebe como respostas 1 e 4, pelo que escolhe 4, o que faz com que AA vá para o topo da lista de P2P_2.

    Exemplo de skeen com Maekawa - 6

    O pedido de AA é enviado para a camada superior e ganha o acesso à exclusão mútua em P2P_2. Em P3P_3, é enviado o pedido de BB, que fica na lista de pendentes dado já existir outro cliente na exclusão mútua. De seguida, o pedido de BB em P2P_2 passa a estar no topo da lista com estado final, pelo que também é enviado. AA já está na exclusão mútua, portanto BB é adicionado à lista de pendentes.

    Recordar: O algoritmo de Maekawa tinha uma falha: era suscetível a deadlocks. Ao utilizarmos ordem total, garantindo que todos os processos recebem os pedidos pela mesma ordem, este problema fica resolvido.

Algoritmo Primário-secundário (concretizado)

  • Primário usa Difusão Fiável Uniforme síncrona na vista para propagar novos estados aos secundários
    • só responde ao cliente quando a uniformidade estiver garantida
  • Réplicas usam Sincronia na Vista para lidar com falhas do primário:
    • quando o primário pp falha, eventualmente será entregue nova vista sem pp
    • quando uma nova vista é entregue e o anterior primário não consta nela, os restantes processos elegem o novo primário
    • o novo primário anuncia o seu endereço num serviço de nomes (para que os clientes o descubram)

Replicação de máquina de estados (concretizado)

  • Processos usam Sincronia na Vista
  • Clientes usam Difusão Atómica síncrona na vista para enviar pedidos a todas as réplicas

Nota

Este tipo de replicação não alcança a linearizabilidade (ao contrário da replicação primário-secundário) porque a ordem total na qual os gestores de réplicas processam os pedidos pode não coincidir com a ordem real na qual os clientes fizeram os pedidos.

Consenso e Replicação

Iremos agora analisar como resolver problemas/algoritmos relacionados com replicação através do consenso.

Difusão atómica usando Consenso

Uma primeira abordagem poderia ser:

  • cada processo guarda uma lista de mensagens Recebidas (RR) e Ordenadas (OO)
  • sempre que tiver alguma mensagem recebida que ainda não está ordenada, propõe essa mensagem para consenso (uma instância de consenso por mensagem a ordenar)
  • o consenso decide qual a próxima mensagem, que é adicionada à lista de mensagens ordenadas de cada processo

Esta abordagem tem um problema: como o consenso pode decidir qualquer uma das mensagens propostas, é possível que uma mensagem que apenas um processo recebeu nunca seja decidida (e portanto entregue):

Difusão atómica usando consenso por mensagem individual

A solução é propôr para consenso todas as mensagens recebidas mas não ordenadas (ROR \setminus O). A decisão do consenso será qualquer um dos sets propostos pelos processos:

Difusão atómica usando consenso por mensagem individual

Pseudocódigo

Init:\text{Init:}\\ por_ordenar={}\qquad \text{por\_ordenar} = \Set{}\\ ordenadas={}\qquad \text{ordenadas} = \Set{}\\ num_seq=0;\qquad \text{num\_seq} = 0;\\

Quando AB.send(m)\text{Quando AB.send(m)}\\ RB.send(m)\qquad RB.send(m)\\

Quando RB.deliver(m)\text{Quando RB.deliver(m)}\\ por_ordenar=por_ordenar{m}\qquad \text{por\_ordenar} = \text{por\_ordenar} \cup \Set{m}

while TRUE {\text{while TRUE } \{\\ espera ateˊ que (por_ordenarordenadas{});\qquad \text{espera até que } (\text{por\_ordenar} \setminus \text{ordenadas} \not = \Set{});\\ num_seq=num_seq+1\qquad \text{num\_seq} = \text{num\_seq} \op{+} 1\\

// executa mais uma instancia de consenso\qquad \text{// executa mais uma instancia de consenso}\\ Consensus[num_seq].propose(por_ordenarordenadas)\qquad \text{Consensus[num\_seq].propose}(por\_ordenar \setminus ordenadas)\\ espera ate Consensus[num_seq].decide(proximas)\qquad \text{espera ate Consensus[num\_seq].decide(proximas)}\\ // o consenso decide quais a mensagens a entregar\qquad \text{// o consenso decide quais a mensagens a entregar}\\

ordenadas=ordenadasproximas\qquad \text{ordenadas} = \text{ordenadas} \cup \text{proximas}\\

para todas as mensagem m em proximas // por uma ordem determinista\qquad \text{para todas as mensagem m em proximas // por uma ordem determinista} AB.deliver(m)\qquad\qquad \text{AB.deliver(m)}\\ }\}

Primário-secundário com detector de falhas não perfeito

Já abordámos uma implementação de replicação com Primário-secundário que utilizava Sincronia na Vista e Difusão Fiável, mas será que é possível construir uma solução robusta sem um detector de falhas perfeito?

Utilizaremos o detector não perfeito para escolher o primário:

  • devido à natureza do detector, podem existir dois processos que pensam ser o primário
  • as réplicas secundárias podem também divergir sobre quem consideram ser o primário

Primário-secundário com detector de falhas não perfeito

No exemplo acima, o primário P1P_1 decide que a primeira mensagem é BB e envia essa informação a P2P_2, que a regista com sucesso. No entanto, a mensagem não chegou a P3P_3 devido a uma partição na rede, que após não receber mensagens do primário durante algum tempo se elegeu como primário, escolhendo EE como primeira mensagem.

Precisamos agora de um algoritmo que consegue resolver consenso sem um detector de falhas perfeito... O floodset consensus não consegue, já que foi arquitetado exatamente com esse pressuposto. Mas podemos usar o Paxos mencionado anteriormente!

Resumidamente, o Paxos garante que o sistema apenas avança quando tem aprovação da maioria das réplicas. Desta forma, P1 e P2 conseguem continuar a execução normal do algoritmo enquanto que as propostas de P3 (por exemplo, escolher a mensagem E como primeira) não são aceites por uma maioria. Quando a estabilidade da rede voltar ao normal, P3 irá receber as operações que foram realizadas na sua "ausência" e o sistema voltará a estar consistente.

Primário-secundário com Paxos

Pseudocódigo

pedidos_pendentes={}\text{pedidos\_pendentes} = \Set{}\\ pedidos_executados={}\text{pedidos\_executados} = \Set{}\\ propostas={}\text{propostas} = \Set{}\\ num_seq=0\text{num\_seq} = 0\\ Para todo i:\text{Para todo i:}\\ decidido[i] = false\qquad \text{decidido[i] = false}\\

Quando recebe pedido do cliente P\text{Quando recebe pedido do cliente P}\\ pedidos_pendentes = pedidos_pendentes{P}\qquad \text{pedidos\_pendentes = pedidos\_pendentes} \cup \Set{P}\\

Quando sou o lider AND decidido[num_seq] = true AND\text{Quando sou o lider AND decidido[num\_seq] = true AND}\\ (pedidos_pendentespedidos_executados{})\quad (\text{pedidos\_pendentes} \setminus \text{pedidos\_executados} \not = \Set{})\\ proximo_pedido = escolhe_um(pedidos_pendentespedidos_executados)\qquad \text{proximo\_pedido = escolhe\_um} (\text{pedidos\_pendentes} \setminus \text{pedidos\_executados})\\ <proximo_estado, resposta_cliente>=ExecutaPedido(estado, proximo_pedido);\qquad \text{<proximo\_estado, resposta\_cliente>} = \text{ExecutaPedido(estado, proximo\_pedido);}\\ RB.send(<PROPOSTA, my_id, num_seq + 1, \qquad \text{RB.send(<PROPOSTA, my\_id, num\_seq + 1, }\\ proximo_pedido, proximo_estado, resposta_cliente>)\qquad \quad \text{proximo\_pedido, proximo\_estado, resposta\_cliente>)}\\

Quando RE.deIiver(proposta = <PROPOSTA, my_id,\text{Quando RE.deIiver(proposta = <PROPOSTA, my\_id,}\\ num_seq + 1, proximo_pedido, proximo_estado, resposta_cliente>)\quad \text{num\_seq + 1, proximo\_pedido, proximo\_estado, resposta\_cliente>)}\\ propostas = propostas{proposta}\qquad \text{propostas = propostas} \cup \Set{\text{proposta}}\\

Quando existe proposta = <PROPOSTA, id, ns, pedido, estado>\text{Quando existe proposta = <PROPOSTA, id, ns, pedido, estado>}\\ em propostas tal que:\quad \text{em propostas tal que:}\\ decidido[num_seq] = true AND ns = num_seq + 1 AND id = lider\quad \text{decidido[num\_seq] = true AND ns = num\_seq + 1 AND id = lider}\\

consenso[num_seq + 1].propose(proposta_input = \qquad \text{consenso[num\_seq + 1].propose(proposta\_input = }\\ <PROPOSTA, id, ns, pedido, estado, resposta)\qquad \quad \text{<PROPOSTA, id, ns, pedido, estado, resposta)}\\ espera ate que consenso[num_seq + 1].decide(\qquad \text{espera ate que consenso[num\_seq + 1].decide(}\\ proposta_ouput = <PROPOSTA, id_out, sn_out, \qquad \quad \text{proposta\_ouput = <PROPOSTA, id\_out, sn\_out, }\\ pedido_out, estado_out, resposta_out>)\qquad \quad \text{pedido\_out, estado\_out, resposta\_out>)}\\

num_seq = num_seq + 1;\qquad \text{num\_seq = num\_seq + 1;}\\ decidido[num_seq] = true;\qquad \text{decidido[num\_seq] = true;}\\ estado = estado_out\qquad \text{estado = estado\_out}\\ pedidos_executados = pedidos_executados {pedido_out}\qquad \text{pedidos\_executados = pedidos\_executados } \cup \Set{\text{pedido\_out}}\\ envia resposta_out para o cliente\qquad \text{envia resposta\_out para o cliente}\\

Sincronia na Vista usando Consenso

Mudar da vista ViV_i para a Vi+1V_{i + 1} consiste essencialmente em:

  • recolher todas as mensagens entregues durante ViV_i
  • propôr para consenso um tuplo:
    • Vi+1V_{i + 1} = <membros de Vi+1, mensagens entregues em Vi><\text{membros de } V_{i + 1} \text{, mensagens entregues em } V_i>
  • esperar decisão do consenso
  • entregar mensagens em falta
  • entregar nova vista

Funcionamento do algoritmo:

  • cada processo pip_i mantém um registo hih_i de todas as mensagens que já entregou numa dada vista
  • quando se inicia a mudança de vista de ViV_i para Vi+1V_{i + 1}, é enviada uma mensagem especial "view-change(saıˊdas, entradas)\text{view-change(saídas, entradas)}" para todos os processos da vista ViV_i (podendo a lista de saídas e/ou entradas ser vazia)
  • quando um processo recebe uma mensagem "view-change(saıˊdas, entradas)\text{view-change(saídas, entradas)}", pára de entregar novas mensagens e inicia Vi+1=VisaıˊdasentradasV_{i + 1} = V_i - \text{saídas} - \text{entradas}, hj=nullh_j = \text{null} para todos os processos pjp_j na vista ViV_i e envia hih_i para todos os outros processo em Vi+1V_{i + 1}
  • cada processo recebe os valores hjh_j de todos os outros processos pjp_j em ViV_i
  • se um processo pip_i não recebe hjh_j de um processo pjp_j e suspeita que pjp_j falhou, remove pjp_j de Vi+1V_{i + 1} e coloca hj={}h_j = \Set{}
  • quando um processo pip_i tem todos os valores de hjnullh_j \not = \text{null} (ou seja, se já recebeu hjh_j de pjp_j ou se assume que pjp_j falhou), define o conjunto de mensagens a entregar mVim-V_i como a união de todos os hjh_j recebidos e inicia o consenso propondo <Vi+1,mVi><V_{i + 1}, m-V_i>
  • o resultado do consenso define a nova vista e o conjunto de mensagens a entregar na vista anterior

Referências