Artigos /

CQRS: quando o usuário salva e não vê o próprio dado

Separar leitura de escrita resolve um monte de problema e cria um novo, que aparece na tela do usuário e chega como bug no suporte.

15 min de leitura#arquitetura #cqrs #consistencia #postgres

Já perdi a conta de quantas vezes vi esse chamado. O usuário edita o endereço de entrega, salva, a tela confirma que deu certo. Ele recarrega a página para conferir e o endereço antigo continua lá. Abre chamado dizendo que o sistema não salvou, o suporte escala como bug, e alguém do time responde que está funcionando como esperado.

E está mesmo funcionando como esperado. Só que isso não ajuda em nada o usuário, que agora está desconfiado se a compra dele vai chegar no lugar certo.

Esse texto é sobre esse momento específico, mas antes preciso passar pelo caminho que leva até ele.

Separando leitura de escrita

CQRS é a sigla para Command Query Responsibility Segregation, que em português seria algo como segregação de responsabilidade entre comando e consulta. O nome é grande e a ideia por trás dele cabe em uma frase, que é usar um modelo para alterar dados e outro para lê-los.

Na maioria dos sistemas que a gente escreve, o mesmo modelo faz as duas coisas. A mesma entidade Pedido, com os mesmos relacionamentos e as mesmas validações, é usada tanto para criar um pedido quanto para montar a listagem que aparece na tela. E em uns 95% dos casos isso resolve, sem precisar de padrão nenhum de nome bonito.

A história muda quando a consulta começa a pedir coisa que o modelo de escrita nunca foi feito para dar. Um relatório que junta pedido, item, cliente, pagamento e entrega, agrupa por mês e por região, e varre um ano inteiro de histórico para fechar um total. O modelo normalizado é ótimo para gravar, com transação curta e poucas linhas tocadas, e é o pior formato possível para responder esse tipo de pergunta.

Isso tem nome, é carga OLAP rodando dentro de um banco OLTP. Os dois perfis querem coisas opostas. A escrita quer transação curta, índice seletivo e o mínimo de linha tocada. A análise quer varrer muita linha, juntar tabela e agregar. Quando as duas dividem a mesma instância, elas brigam por buffer, IO e CPU, e o estrago costuma aparecer do lado errado, alguém abre o relatório do mês e a latência do checkout sobe junto.

Aí vem a parte que costuma ser mal entendida, porque existem dois degraus bem diferentes de CQRS e as pessoas normalmente pensam no segundo.

Primeiro degrau, mesmo banco

O degrau mais barato é separar só no código. Continuamos com um banco só, com o mesmo schema, e mudamos a forma de acessá-lo.

A escrita passa pelo caminho completo, carrega o agregado, valida, aplica a regra e salva. A leitura ignora tudo isso e faz uma query direta, devolvendo exatamente os campos que a tela precisa, em vez de carregar um agregado inteiro com todos os relacionamentos para renderizar quatro colunas.

// escrita, com regra de negócio no meio
func (s *PedidoService) Cancelar(ctx context.Context, id string) error {
    pedido, err := s.repo.FindByID(ctx, id)
    if err != nil {
        return err
    }
    if err := pedido.Cancelar(); err != nil {
        return err
    }
    return s.repo.Save(ctx, pedido)
}

// leitura, sem passar pelo agregado
func (q *PedidoQuery) Listar(ctx context.Context, clienteID string) ([]PedidoResumo, error) {
    return q.db.Select(ctx, `
        SELECT p.numero, p.criado_em, c.nome, p.valor_total
          FROM pedidos p
          JOIN clientes c ON c.id = p.cliente_id
         WHERE p.cliente_id = $1
         ORDER BY p.criado_em DESC`, clienteID)
}

Não tem evento, não tem fila, não tem sincronização. Não tem nem mesmo consistência eventual, porque o dado é o mesmo. E na minha experiência esse degrau resolve a grande maioria dos casos em que alguém diz que precisa de CQRS.

Vale um parêntese sobre o repository, porque é ele que costuma deixar esse segundo trecho com cara de gambiarra. A comunidade conhece isso como Repository Pattern, e para muita gente ele virou sinônimo de clean architecture. O papel dele é traduzir agregado de domínio em linha de banco, e linha de banco em agregado de domínio. Ele existe para o domínio não precisar saber que tem SQL do outro lado, e o preço disso é que ele sempre entrega o agregado inteiro, hidratado, com os relacionamentos que a regra de negócio pode vir a usar.

Do lado da leitura nada disso é necessário. O pedido.Cancelar() ali de cima é uma operação de domínio, encapsulada dentro de uma entidade rica, do jeito que o saudoso DDD manda, e ninguém vai chamar aquilo a partir de uma listagem. Então dá para pular o repository e ir direto no banco, devolvendo um read model que existe só para alimentar a tela e morre ali. Em alguns casos dá para empurrar mais um pouco e deixar a consulta pronta dentro do próprio banco, com uma view, ou com uma materialized view quando ela é pesada o suficiente para não valer a pena recalcular a cada acesso.

E repare que a materialized view já traz o mesmo trade-off do resto do texto em versão reduzida, porque alguém precisa atualizá-la, e entre um refresh e outro ela mostra dado velho. Se for por esse caminho, vale usar REFRESH MATERIALIZED VIEW CONCURRENTLY, que não bloqueia a leitura durante a atualização, ao custo de exigir um índice único na view.

Segundo degrau, bancos separados

O segundo degrau é quando os dois lados passam a ter armazenamentos diferentes. A escrita continua no Postgres, com o modelo normalizado, e a leitura passa a acontecer em outro lugar, que pode ser uma réplica, um Redis, um Elasticsearch, uma tabela desnormalizada com tudo pronto para a tela.

Alguém precisa manter os dois em dia, e normalmente é um evento que faz isso. O serviço grava a mudança, publica um evento, e do outro lado um consumidor atualiza a versão de leitura.

Aqui vale dizer que publicar esse evento tem uma armadilha própria, que é gravar no banco e publicar no broker sem transação em comum entre os dois. Já escrevi sobre isso no post de outbox e saga, então não vou repetir. Vale a leitura antes de montar esse pipeline, porque o consumidor que atualiza o modelo de leitura tem exatamente os mesmos problemas de qualquer outro consumidor, incluindo entrega repetida e chegada fora de ordem.

Outra confusão comum é achar que esse segundo degrau exige event sourcing, e ele não exige. Dá para separar os bancos e continuar guardando o estado atual dos dois lados, sem log de eventos, sem replay, sem agregado reconstruído a partir do histórico. Event sourcing combina bem com CQRS, e por isso os dois aparecem juntos com tanta frequência, mas um não depende do outro.

Quando isso vale

Vale quando a leitura e a escrita têm cargas muito diferentes, e a leitura ganha por larga margem. Um catálogo de produtos que é lido milhares de vezes por segundo e atualizado três vezes por dia é o caso clássico.

Vale quando a consulta precisa juntar dado de vários serviços. Se a tela de detalhe do pedido mostra dado que mora em quatro bancos diferentes, montar isso na hora custa quatro chamadas e uma latência que ninguém quer.

Vale quando a forma do dado na leitura é muito distante da forma na escrita. Se para renderizar uma tela eu preciso de seis joins e uma agregação, manter isso pré-calculado em uma tabela de leitura tira peso de todo mundo.

E vale quando duas partes do time podem trabalhar em paralelo, uma na regra de negócio complicada da escrita e outra na tela e nas consultas.

Agora, o mais importante, que é quando não vale. Se o domínio é simples e a tela é um CRUD, CQRS entrega complexidade e não entrega nada em troca. Se o time é pequeno, alguém vai ter que manter os dois lados sozinho, mais a sincronização entre eles. E se o dado precisa estar visível na hora em que foi gravado, o segundo degrau vai brigar com esse requisito o tempo todo, que é justamente o assunto do resto do texto.

A conta que chega depois

Quando a leitura mora em outro lugar, existe um intervalo entre gravar e conseguir ler o que foi gravado. Esse intervalo é a consistência eventual, e ele aparece em toda referência sobre CQRS como uma desvantagem aceitável.

O problema é que ele quase nunca aparece como um número, e quase nunca aparece como uma decisão de tela.

Do ponto de vista de quem desenhou o sistema, os passos aconteceram exatamente na ordem prevista. Do ponto de vista de quem estava na frente da tela, o sistema perdeu o que ele acabou de digitar.

E repare que o intervalo pode ser pequeno na média e horrível na cauda. Uma projeção que aplica em 40ms na mediana e em 4 segundos no percentil 99 vai gerar chamado, porque 4 segundos é tempo de sobra para o usuário recarregar a página.

Ler a própria escrita

A discussão sobre isso costuma travar em consistência forte contra consistência eventual, como se existissem só essas duas opções. Existe uma faixa no meio que raramente é citada, e é ela que resolve o chamado do suporte.

Read-your-own-writes garante que uma sessão sempre enxerga as próprias escritas. Ela não diz nada sobre o que as outras sessões enxergam. Se dois usuários editam coisas diferentes, cada um vê a própria alteração imediatamente, e vê a do outro quando o dado chegar.

Monotonic reads garante que uma sessão nunca volta no tempo. Sem essa garantia, dois F5 seguidos podem cair em réplicas diferentes, e o endereço novo aparece, some, e aparece de novo. Se você acha que ver o dado antigo é ruim, ver ele piscando é bem pior.

As duas são garantias de sessão. Elas custam bem menos do que deixar o sistema inteiro consistente, e são exatamente o que aquele usuário do chamado estava pedindo. Ele não estava preocupado se o resto do mundo já enxergava o endereço novo dele.

O que dá para fazer

Antes de escolher uma solução, vale saber o que a tela em questão realmente precisa prometer.

Aceitar

Vale começar por aqui, porque a resposta é essa com mais frequência do que parece.

Feed, busca, relatório, dashboard, listagem de histórico. Ninguém acabou de escrever aquele dado, e ninguém repara em dois segundos de atraso. Se a tela não é o resultado imediato de uma ação do próprio usuário, o atraso pode simplesmente existir.

Ler do modelo de escrita logo depois do comando

A tela de confirmação, ou o redirect que acontece logo após o salvamento, pode ir direto no banco de escrita em vez de passar pela leitura.

// GET /enderecos/:id?after_write=1
if req.URL.Query().Get("after_write") == "1" {
    return h.escrita.BuscarEndereco(ctx, id)
}
return h.leitura.BuscarEndereco(ctx, id)

É feio, e resolve boa parte dos chamados por quase nada. O cuidado aqui é de disciplina, isso precisa ficar restrito à rota que vem imediatamente depois do comando. No dia em que after_write=1 começar a aparecer na listagem, a separação virou enfeite e vale rever se ela deveria existir.

Renderizar com o que o cliente já tem

O navegador acabou de mandar o payload, então ele sabe o resultado sem precisar perguntar. Dá para renderizar a tela com o que foi enviado, sem nova consulta.

Instantâneo e barato, com uma consequência que precisa de tratamento. O comando pode falhar depois do 200. Uma validação assíncrona reprova, uma saga compensa, um consumidor rejeita o evento. A tela mostrou um estado que deixou de existir, e alguém vai ter que desfazer aquilo na frente do usuário.

Uso isso tranquilo para alteração de baixo risco, tipo trocar o nome de exibição. Não usaria para nada que envolva dinheiro.

Carregar a versão junto

Essa é a solução completa, e é a que dá mais trabalho.

O comando devolve a versão que ele produziu, e a leitura seguinte exige pelo menos aquela versão. No lado da escrita, uma coluna por agregado resolve:

UPDATE enderecos
   SET logradouro = $2, versao = versao + 1
 WHERE id = $1
RETURNING versao;

O número volta na resposta do comando:

HTTP/1.1 200 OK
x-resource-version: 42

E a projeção guarda a versão de origem junto com a linha que ela escreve:

INSERT INTO endereco_view (endereco_id, logradouro, versao_origem)
     VALUES ($1, $2, $3)
ON CONFLICT (endereco_id) DO UPDATE
        SET logradouro = excluded.logradouro,
            versao_origem = excluded.versao_origem
      WHERE endereco_view.versao_origem < excluded.versao_origem;

Aquele WHERE no final do DO UPDATE faz um serviço a mais além de permitir a comparação, ele impede que um evento antigo, reentregue depois de um mais novo, sobrescreva a linha com dado velho. Como eu disse lá em cima, projeção é consumidor como qualquer outro.

Na hora da consulta, com a versão mínima em mãos, existem três saídas:

view, err := h.leitura.BuscarEndereco(ctx, id)
if view.VersaoOrigem < versaoMinima {
    // 1. esperar um pouco e tentar de novo
    // 2. responder 409 e deixar o cliente decidir
    // 3. cair para o modelo de escrita
}

As três são defensáveis, e a escolha é mais de produto do que de arquitetura. A espera curta faz o problema sumir, ao preço de segurar a requisição. O 409 devolve a decisão para o cliente, que pode mostrar um aviso decente em vez de um dado errado. O fallback resolve sempre, e coloca carga no banco de escrita exatamente no momento em que a projeção está atrasada, que costuma ser o momento em que o sistema já está sofrendo.

Duas coisas para saber antes de escolher esse caminho. A primeira é que todo comando passa a devolver versão, toda leitura passa a carregá-la, e o cliente precisa guardar isso pela sessão. Atravessar serviço com esse número dá bastante trabalho.

A segunda é que isso funciona para tela de detalhe. Listagem, contador e agregação não têm uma versão única para exigir, porque cada linha depende de vários agregados diferentes. Para essas telas, aceitar o atraso costuma ser a resposta.

Quando a leitura é uma réplica

Se o modelo de leitura é uma réplica do mesmo Postgres, e não uma projeção com schema próprio, o banco já entrega esse controle pronto. O primário sabe a posição do commit:

SELECT pg_current_wal_lsn();

E a réplica sabe até onde já aplicou:

SELECT pg_last_wal_replay_lsn() >= $1;

Se a comparação der falso, a leitura vai para o primário ou para outra réplica. Se der verdadeiro, responde ali mesmo. É a mesma ideia da versão, com o banco fazendo o trabalho no lugar da aplicação.

Como medir isso

O consumer lag do broker costuma ser a métrica que está no dashboard, e ela responde uma pergunta menor do que a gente precisa. Ela diz o quanto o consumidor está atrás do fim do tópico, e ignora o tempo entre o commit e a publicação. Pior ainda, ela fica em zero enquanto a projeção consome evento e falha ao aplicar, porque do ponto de vista do broker aquilo já foi entregue.

A métrica que responde pergunta durante incidente é a de ponta a ponta, do momento em que o fato aconteceu até a linha ficar visível na leitura. Uma tabela de checkpoint dá conta:

CREATE TABLE projecao_checkpoint (
    projecao      TEXT PRIMARY KEY,
    ultimo_evento UUID NOT NULL,
    ocorreu_em    TIMESTAMPTZ NOT NULL,
    aplicado_em   TIMESTAMPTZ NOT NULL DEFAULT now()
);

De lá saem duas leituras que valem alerta. aplicado_em - ocorreu_em é o atraso real da projeção, e é o número que deveria guiar o desenho da tela. now() - aplicado_em é há quanto tempo ela não aplica nada, e é o que denuncia projeção travada em uma madrugada de pouco tráfego, quando todo o resto do dashboard parece saudável.

Fechando

O custo do CQRS aparece nas apresentações como o segundo banco e a projeção para manter, e essa parte até que é a mais fácil. O que costuma doer mesmo é a promessa que a interface faz, e essa promessa nasce na tela, bem longe do diagrama de arquitetura.

Por isso acho que a conversa precisa acontecer tela a tela, e antes de separar os modelos. Quais telas podem conviver com atraso, quais precisam mostrar a escrita na hora, e o que a interface vai fazer enquanto o dado ainda está a caminho. Respondendo isso primeiro, o resto do desenho fica bem mais fácil de decidir.