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);from dataclasses import dataclass, field
from functools import reduce
@dataclass
class Contador:
"""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ó."""
no: str
contagens: dict[str, int] = field(default_factory=dict)
def incrementar(self, n: int = 1) -> None:
self.contagens[self.no] = self.contagens.get(self.no, 0) + n
def mesclar(self, outro: "Contador") -> None:
# Comutativo, associativo e idempotente: a ordem e as repetições não importam
for no, valor in outro.contagens.items():
self.contagens[no] = max(self.contagens.get(no, 0), valor)
def valor(self) -> int:
return sum(self.contagens.values())
# Para comparar: um registro em que a última gravação vence (pelo relógio de quem gravou)
def ultima_vence(a: tuple[int, int], b: tuple[int, int]) -> tuple[int, int]:
return b if b[1] > a[1] else a
# Teste: A soma 5, B soma 3 e C soma 2, sem se comunicar; depois todos trocam estados
a, b, c = Contador("A"), Contador("B"), Contador("C")
for _ in range(5):
a.incrementar()
for _ in range(3):
b.incrementar()
for _ in range(2):
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
print("contador:", ", ".join(str(n.valor()) for n in (a, b, c)))
registros = [(5, 14), (3, 13), (2, 21)] # (valor, relógio)
print("última gravação vence:", reduce(ultima_vence, registros)[0])package main
import (
"fmt"
"strings"
)
// Contador 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ó
type Contador struct {
no string
contagens map[string]int
}
func NovoContador(no string) *Contador {
return &Contador{no: no, contagens: map[string]int{}}
}
func (c *Contador) Incrementar(n int) { c.contagens[c.no] += n }
// Mesclar é comutativo, associativo e idempotente: a ordem e as repetições não importam
func (c *Contador) Mesclar(outro *Contador) {
for no, valor := range outro.contagens {
c.contagens[no] = max(c.contagens[no], valor)
}
}
func (c *Contador) Valor() int {
total := 0
for _, v := range c.contagens {
total += v
}
return total
}
// Registro em que a última gravação vence, pelo relógio de quem gravou (para comparar)
type Registro struct{ Valor, Relogio int }
func ultimaVence(a, b Registro) Registro {
if b.Relogio > a.Relogio {
return b
}
return a
}
func main() {
// Teste: A soma 5, B soma 3 e C soma 2, sem se comunicar; depois todos trocam estados
a, b, c := NovoContador("A"), NovoContador("B"), NovoContador("C")
for range 5 {
a.Incrementar(1)
}
for range 3 {
b.Incrementar(1)
}
for range 2 {
c.Incrementar(1)
}
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
valores := []string{}
for _, n := range []*Contador{a, b, c} {
valores = append(valores, fmt.Sprint(n.Valor()))
}
fmt.Println("contador:", strings.Join(valores, ", "))
r := ultimaVence(ultimaVence(Registro{5, 14}, Registro{3, 13}), Registro{2, 21})
fmt.Println("última gravação vence:", r.Valor)
}import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.stream.Collectors;
// 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ó
public class Contador {
final String no;
final Map<String, Integer> contagens = new HashMap<>();
Contador(String no) {
this.no = no;
}
void incrementar(int n) {
contagens.merge(no, n, Integer::sum);
}
// Mesclar é comutativo, associativo e idempotente: a ordem e as repetições não importam
void mesclar(Contador outro) {
outro.contagens.forEach((no, valor) -> contagens.merge(no, valor, Math::max));
}
int valor() {
return contagens.values().stream().mapToInt(Integer::intValue).sum();
}
// Registro em que a última gravação vence, pelo relógio de quem gravou (para comparar)
record Registro(int valor, int relogio) {}
static Registro ultimaVence(Registro a, Registro b) {
return b.relogio() > a.relogio() ? b : a;
}
public static void main(String[] args) {
// Teste: A soma 5, B soma 3 e C soma 2, sem se comunicar; depois todos trocam estados
var a = new Contador("A");
var b = new Contador("B");
var c = new Contador("C");
for (int i = 0; i < 5; i++) a.incrementar(1);
for (int i = 0; i < 3; i++) b.incrementar(1);
for (int i = 0; i < 2; i++) c.incrementar(1);
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
System.out.println("contador: " + List.of(a, b, c).stream()
.map(n -> String.valueOf(n.valor())).collect(Collectors.joining(", ")));
var r = List.of(new Registro(5, 14), new Registro(3, 13), new Registro(2, 21)).stream()
.reduce(Contador::ultimaVence).orElseThrow();
System.out.println("última gravação vence: " + r.valor());
}
}// Teste: A soma 5, B soma 3 e C soma 2, sem se comunicar; depois todos trocam estados
var (a, b, c) = (new Contador("A"), new Contador("B"), new Contador("C"));
for (var i = 0; i < 5; i++) a.Incrementar();
for (var i = 0; i < 3; i++) b.Incrementar();
for (var 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.WriteLine($"contador: {a.Valor()}, {b.Valor()}, {c.Valor()}");
// Registro em que a última gravação vence, pelo relógio de quem gravou (para comparar)
Registro[] registros = [new(5, 14), new(3, 13), new(2, 21)];
var vencedor = registros.Aggregate((x, y) => y.Relogio > x.Relogio ? y : x);
Console.WriteLine($"última gravação vence: {vencedor.Valor}");
record Registro(int Valor, int Relogio);
// 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(string no)
{
private readonly Dictionary<string, int> contagens = [];
public void Incrementar(int n = 1) =>
contagens[no] = contagens.GetValueOrDefault(no) + n;
// Mesclar é comutativo, associativo e idempotente: a ordem e as repetições não importam
public void Mesclar(Contador outro)
{
foreach (var (outroNo, valor) in outro.contagens)
contagens[outroNo] = Math.Max(contagens.GetValueOrDefault(outroNo), valor);
}
public int Valor() => contagens.Values.Sum();
}O resultado, nas cinco linguagens
| Nó A | Nó B | Nó C | A última gravação vence | |
|---|---|---|---|---|
| Nas cinco | 10 | 10 | 10 | 2 |
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 contagens | Maior valor na mesclagem | |
|---|---|---|
| TypeScript | Record<string, number> | Math.max |
| Python | dict num dataclass | max |
| Go | map[string]int | max (embutido desde o Go 1.21) |
| Java | HashMap | merge(no, valor, Math::max) |
| C# | Dictionary | Math.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]