DevGuideTK

Consistência eventual

Como projetar telas e regras de negócio quando a leitura pode vir desatualizada.

O código desta página foi executado nas cinco linguagens. O teste das leituras que voltam no tempo, com um PostgreSQL 18 e duas réplicas, está no nível Conceito.

Um contador que converge, em cada linguagem

Cada versão tem um Contador (um G-Counter) com três operações: incrementar, que soma só na contagem do próprio nó; mesclar, que fica com o maior valor de cada nó entre os dois estados; e valor, a soma de todas as contagens. Para comparar, um registro em que a última gravação vence pelo relógio de quem gravou.

O teste: os nós A, B e C somam 5, 3 e 2 sem se comunicar. Depois, A recebe B e C; C recebe B e A, em outra ordem; e B recebe o estado de A duas vezes. O relógio do nó C está adiantado.

// Contador que converge sem coordenação (G-Counter, um CRDT):
// cada nó conta só os próprios incrementos, e a mesclagem fica com o maior valor de cada nó
class Contador {
  private contagens: Record<string, number> = {};
  private no: string;

  constructor(no: string) {
    this.no = no;
  }

  incrementar(n = 1) {
    this.contagens[this.no] = (this.contagens[this.no] ?? 0) + n;
  }

  // Mesclar é comutativo, associativo e idempotente: a ordem e as repetições não importam
  mesclar(outro: Contador) {
    for (const [no, valor] of Object.entries(outro.contagens)) {
      this.contagens[no] = Math.max(this.contagens[no] ?? 0, valor);
    }
  }

  valor() {
    return Object.values(this.contagens).reduce((a, b) => a + b, 0);
  }
}

// Para comparar: um registro em que a última gravação vence (pelo relógio de quem gravou)
type Registro = { valor: number; relogio: number };
const ultimaVence = (a: Registro, b: Registro) => (b.relogio > a.relogio ? b : a);

// Teste: A soma 5, B soma 3 e C soma 2, sem se comunicar; depois todos trocam estados
const nos = ["A", "B", "C"].map((n) => new Contador(n));
const [a, b, c] = nos;
for (let i = 0; i < 5; i++) a.incrementar();
for (let i = 0; i < 3; i++) b.incrementar();
for (let i = 0; i < 2; i++) c.incrementar();
a.mesclar(b); a.mesclar(c); // A recebe B e C
c.mesclar(b); c.mesclar(a); // C recebe em outra ordem
b.mesclar(a); b.mesclar(a); // B recebe A duas vezes
console.log("contador:", nos.map((n) => n.valor()).join(", "));

const registros = [{ valor: 5, relogio: 14 }, { valor: 3, relogio: 13 }, { valor: 2, relogio: 21 }];
console.log("última gravação vence:", registros.reduce(ultimaVence).valor);

O resultado, nas cinco linguagens

Nó ANó BNó CA última gravação vence
Nas cinco1010102

Os três nós chegaram a 10, embora tenham recebido os estados em ordens diferentes e o nó B tenha recebido o mesmo estado duas vezes. É o efeito das três propriedades da mesclagem: comutativa, associativa e idempotente. A última gravação vence ficou com o valor do nó C, o de relógio adiantado, e perdeu 8 das 10 somas.

O que muda de uma linguagem para outra

O mapa de contagensMaior valor na mesclagem
TypeScriptRecord<string, number>Math.max
Pythondict num dataclassmax
Gomap[string]intmax (embutido desde o Go 1.21)
JavaHashMapmerge(no, valor, Math::max)
C#DictionaryMath.Max

O contador só cresce. Para permitir subtrações (descurtir), o PN-Counter usa dois contadores como este, um de somas e outro de subtrações, e o valor é a diferença. Contadores em que um nó faz contagem = contagem - 1 na própria entrada quebram a mesclagem por maior valor.

Em produção, raramente se implementa um CRDT do zero: bibliotecas como Automerge e Yjs (texto e documentos), e bancos com replicação ativa-ativa, já trazem as estruturas prontas. O exemplo mostra a ideia que elas usam.

Afinidade de leitura

A correção do teste das réplicas, num trecho em TypeScript: escolher a réplica pelo usuário, e não pela vez.

// Sempre a mesma réplica para o mesmo usuário: as leituras dele nunca voltam no tempo
function replicaDe(usuarioId: string, replicas: Pool[]): Pool {
  const h = crypto.createHash("sha256").update(usuarioId).digest().readUInt32BE(0);
  return replicas[h % replicas.length];
}

Com afinidade, no teste, cada usuário viu sempre o mesmo valor, atualizado ou não, até a réplica dele alcançar o principal. Se a réplica do usuário cair, ele passa para outra e pode ver um salto; para evitar até isso, guarde a versão mais nova que ele já viu (o LSN, como no tópico de replicação) e não aceite leituras de uma cópia mais velha. Este trecho não foi executado; a afinidade foi testada lendo sempre da mesma réplica.

Lista de revisão

Leituras

  • As leituras de réplicas têm afinidade por usuário, para o tempo não voltar?
  • As telas que mostram o que o usuário acabou de gravar leem o que gravou, ou usam o registro devolvido pela gravação?
  • Os dados mostrados de cópias informam a idade, quando ela importa?

Gravações

  • Cada dado tem um dono (uma região, um nó), ou os conflitos são detectados com versão?
  • "A última gravação vence" só é usada onde perder uma atualização não importa?
  • Contadores e conjuntos replicados usam operações que convergem, e não ler, somar e gravar?

Regras de negócio

  • Saldo, estoque disponível, limites e permissões são decididos na fonte da verdade?
  • Processos entre serviços usam eventos com entrega garantida, consumidores idempotentes e compensações?

Prompts para aprofundar

Ao abrir no ChatGPT ou no Claude, o prompt é enviado ao serviço escolhido pela URL e também fica copiado na sua área de transferência.

  • Implementar as garantias de leitura

    Quero implementar ler o que gravou e leituras monotônicas na minha aplicação, que lê de réplicas.
    
    Pergunte qual linguagem, framework e banco eu uso, e como guardo a sessão do usuário.
    
    Vamos em etapas:
    1. Afinidade de réplica por usuário.
    2. Guardar a versão (LSN) da última gravação e da leitura mais nova do usuário.
    3. Escolher a réplica, ou cair para o principal, a partir dessas versões.
    4. Um teste com uma réplica pausada.
    5. Métricas de leituras que caíram para o principal.
  • Implementar um CRDT

    Quero implementar um CRDT para entender como ele funciona.
    
    Pergunte qual linguagem eu uso e qual dado quero replicar (contador, conjunto, registro).
    
    Me guie:
    1. O estado e as operações locais.
    2. A mesclagem, e por que ela é comutativa, associativa e idempotente.
    3. Testes que mesclam em ordens diferentes e com mensagens repetidas.
    4. A comparação com "a última gravação vence" no mesmo cenário.
    5. Quando usar uma biblioteca pronta em vez de implementar.
  • Revisar o tratamento de consistência

    Vou colar abaixo o código de uma aplicação que lê de réplicas ou de cache, ou que troca eventos entre serviços. Revise o tratamento de consistência.
    
    Verifique, e para cada item diga se está correto, incorreto ou ausente, citando o trecho:
    1. As leituras de réplicas têm afinidade, para o tempo não voltar?
    2. As telas que mostram o que o usuário gravou leem o que gravou?
    3. Decisões de saldo, estoque e permissão leem a fonte da verdade?
    4. Há "a última gravação vence" onde perder dados importa?
    5. Contadores ou conjuntos replicados usam operações que convergem?
    6. Consumidores de eventos toleram repetições e ordem trocada?
    
    Para cada problema, descreva o que o usuário veria e mostre a correção.
    
    Código:
    [cole aqui]