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.
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.