Pular para o conteúdo
Nordlys logo, a drawing of two gray mountains with green northern lights in the background @phpeterle Blog
EN

Voltar para todos os posts

Do Node.js ao Flink: como migramos o CDC da Woovi

Publicado em por Pedro Peterle · 14 min de leitura

Toda mudança relevante no MongoDB da Woovi precisa chegar a vários lugares. Quando uma cobrança é paga, a busca do painel precisa refletir isso, a tela do pagador precisa mostrar “pago”, a maquininha Pix e a impressora térmica do lojista precisam saber, e outros serviços (notificações na plataforma, analytics, nota fiscal, integrações de e-commerce) consomem o evento pelo Kafka.

Durante muito tempo, um único processo Node.js fazia tudo isso. Este post conta como tiramos esse trabalho dele em duas etapas: primeiro a indexação no Elasticsearch, depois os eventos de domínio no Kafka. Os conceitos de Flink que aparecem aqui estão explicados em Apache Flink: o que ele resolve e quanto custa operar. Os números estão arredondados e os nomes internos foram trocados por descrições.

O ponto de partida

O processo, que vou chamar de publisher, abria um change stream por coleção do MongoDB com fullDocument: 'updateLookup' e executava os efeitos dentro dele mesmo. Eram cerca de 20 streams, alguns milhões de eventos por dia, num único pod.

Os efeitos se dividiam assim:

EfeitoExemploQuem percebe
Indexação no ElasticsearchCobranças, transações, empresasBusca do painel
Eventos de domínio no Kafkacharge.upserted, charge.completedNotificações na plataforma, analytics, nota fiscal, e-commerce
Tempo realGraphQL subscriptions via Redis PubSub, MQTT para maquininha e impressoraO pagador
Invalidação de cacheEmpresa, conta, aplicaçãoSegurança e consistência
Integração externaFerramenta de atendimentoSuporte
NenhumStreams que sobraram de funcionalidades antigasNinguém, mas custavam leituras

Sempre que este post fala em notificação, é a notificação dentro da própria plataforma da Woovi.

flowchart LR
  M[(MongoDB<br/>oplog)] -->|~20 change streams| P["publisher<br/>1 pod Node.js"]
  P -->|index| ES[(Elasticsearch)]
  P -->|PubSub| R[Redis<br/>subscriptions]
  P -->|publish| Q[MQTT<br/>maquininha e impressora]
  P -->|jobs| B[fila<br/>limpa cache]
  P -->|HTTP| C[Atendimento]
  P -->|send| K[(Kafka)]

Simplificado, o núcleo do publisher era isto:

// versão simplificada do padrão que existia
const stream = Model.watch([], { fullDocument: 'updateLookup' })
stream.on('change', async (change) => {
// a posição é salva no Redis, com TTL de 60 s, antes do efeito
await saveResumeToken(change._id)
await handler(change)
})
async function sendToTopic(topic, payload) {
try {
await producer.send({
topic,
messages: [{ value: JSON.stringify({ ...payload, eventId: uuidv4() }) }],
})
} catch (err) {
logger.error(err) // o erro morre aqui
}
}

Cada linha desse trecho esconde um problema.

Por que migrar

O motivo nunca foi custo. O publisher entregava at-most-once por construção e perdia eventos sem deixar rastro, e em vários casos o cliente final foi afetado.

Os defeitos estavam no desenho desde o início, mas enquanto o volume era baixo eles quase não apareciam. Os problemas começaram com o crescimento da empresa e, em especial, depois de um pico no volume de eventos: com mais carga, os handlers concorrentes se acumulavam, a memória estourava, e cada restart abria espaço para perder eventos.

Os mecanismos eram estes:

  1. A posição era salva antes do efeito. Se o processo caía depois de gravar o resume token e antes de terminar o handler, o evento sumia.
  2. A posição tinha TTL de 60 segundos. Um restart mais lento que isso não achava o token e recomeçava a leitura “de agora”. Essa era a perda mais perigosa, porque não gerava nenhuma linha de log.
  3. Erro de envio era engolido. Sem retry e sem dead letter. Ainda havia outros dois caminhos silenciosos: mensagem descartada quando o producer não estava inicializado, e falha de conexão inicial engolida, que deixava o processo sem Kafka até o próximo restart.
  4. O eventId era aleatório. Nenhum consumidor conseguia distinguir uma reentrega de um evento novo.
  5. Não havia backpressure. O EventEmitter não espera o listener async, então cada evento virava um handler concorrente. O p50 do handler de cobrança ia de dezenas de milissegundos fora do pico a mais de 10 segundos no pico.
  6. Não escalava. Change stream não tem consumer group: uma segunda réplica processaria tudo em dobro. Era um pod só, com todos os efeitos no mesmo event loop.

Havia outros problemas menores. Handlers concorrentes não garantiam ordem por documento. Um stream fechado por erro não resumível ficava fechado enquanto o processo seguia vivo e o health check continuava verde. E cada leitor do oplog (o publisher e os jobs Flink que já existiam) fazia o seu próprio updateLookup.

Como as perdas apareciam

Alguns episódios dão a medida do problema:

  • Um único 502 do Elasticsearch numa indexação em background virou unhandledRejection e derrubou o processo, levando junto todos os eventos em voo.
  • Depois de um OOM, o producer do Kafka voltou sem metadata dos tópicos e ficou assim por mais de 40 minutos. Foram cerca de 3 mil envios perdidos, entre eles eventos de cobrança paga.
  • Quando o payload do evento de cobrança ficou mais pesado, a falta de backpressure levou a vários OOMs no mesmo dia. O replay do backlog derrubava o processo de novo poucos minutos depois.
  • Em horários de pico, o producer falhava em rajadas. Numa semana, foram dezenas de milhares de envios perdidos.

O caso mais ilustrativo não teve erro nenhum. Em dois dias seguidos, o change stream ficou cerca de 10 minutos com 3% da vazão normal, sem exceção, sem restart e sem nada no APM. Quando voltou, a vazão retomou o patamar normal sem rajada de backlog: os eventos não estavam atrasados, tinham sido perdidos.

Nessas janelas, algumas cobranças pagas de lojas integradas a uma plataforma de e-commerce nunca viraram pedido, porque a integração só marca o pedido como pago a partir do evento charge.completed. Descobrimos pelo lojista, cujo cliente pagou e não encontrou o pedido. A correção foi manual, com um script que liquidou as cobranças pagas na janela. Não existia um jeito de reenviar o evento do Kafka, nem de detectar a perda pelo próprio publisher.

Etapa 1: Elasticsearch

A indexação foi a primeira a sair. Uma dúzia de coleções passou a ser indexada por um job Flink dedicado, e o publisher ganhou uma flag para desligar essa parte.

Busca era um bom primeiro candidato. Ela tolera alguns segundos de atraso, um reprocessamento só reescreve o mesmo documento no índice (idempotente por natureza), e era justamente o efeito que mais contaminava os outros: lentidão ou erro do Elasticsearch atrasava ou derrubava o processo que também cuidava da tela de “pago”.

Com o job separado, o backpressure do Elasticsearch só atrasa a busca. O job lê o oplog, transforma o documento e escreve no índice, e se o cluster fica lento a fonte simplesmente lê mais devagar.

Essa etapa também serviu de treino operacional. Foi com ela que aprendemos que um job Flink parado não avisa ninguém: o job de indexação já ficou quase dois dias em FAILED depois de uma manutenção do banco. Daí vieram os alertas de estado do job e de atraso da fonte que hoje valem para todos.

Antes da etapa 2, fizemos uma limpeza no publisher: removemos o código de Elasticsearch que tinha sobrado e os streams sem efeito nenhum (de cerca de 20 para 10), o que eliminou mais de um milhão de leituras por dia.

Etapa 2: eventos de domínio no Kafka

A decisão central foi separar captura de efeito. A captura é genérica e precisa de estado: ler o oplog, guardar a posição, montar o evento, publicar. O efeito é código de domínio: renderizar o recibo para a impressora, traduzir mensagens, montar o payload da subscription.

O Flink ficou com a captura. Ele publica um tópico <entidade>.upserted por coleção. Os efeitos continuaram em TypeScript, como consumidores Kafka.

flowchart LR
  M[(MongoDB<br/>oplog)] -->|"1 fonte CDC por coleção"| FE["job Flink<br/>eventos"]
  FE -->|"entidade.upserted<br/>chave = _id"| K[(Kafka)]
  K --> W["consumidores Node<br/>tópicos gerais"]
  K --> WC["consumidor Node<br/>cobranças, 6 partições"]
  K --> O["outros serviços<br/>notificação na plataforma, analytics..."]
  W --> R[Redis PubSub] & Q[MQTT] & B[fila de cache] & C[Atendimento]
  WC --> R & Q
  M -->|CDC| FES["job Flink<br/>indexação"] --> ES[(Elasticsearch)]

Portar os efeitos para dentro do Flink teria significado reescrever em Java a renderização ESC/POS da impressora, a internacionalização e a geração do BR Code do tempo real, além de dar ao job credenciais de MQTT e do Redis do produto. E qualquer restart do job pararia a tela de “pago”.

Também consideramos as alternativas de sempre:

OpçãoPor que não
Consertar o publisher (concorrência limitada, token depois do efeito, sem TTL)Continuaria sem escala horizontal e como ponto único de falha
Debezium / Kafka ConnectMais um runtime ao lado do Flink, que já operávamos
Outbox no caminho de escritaAs coleções principais têm dezenas de pontos de escrita, entre código, scripts e migrations. Fica para eventos de negócio novos

O job

Cada coleção tem o seu pipeline: fonte CDC, desserialização, filtro (descarta deletes), montagem do evento (com lookup assíncrono ordenado quando o evento precisa de dados de outra coleção) e KafkaSink. A fonte tem paralelismo 1, porque um change stream não se divide. Quando um pipeline roda com paralelismo maior, entra um keyBy(_id) e cada documento continua em ordem.

Para isolar as coleções umas das outras:

  • o job tem deployment próprio, separado do de indexação, então o Elasticsearch nunca atrasa a tela de “pago”;
  • as coleções de alto volume ficam em slot sharing groups dedicados;
  • o failover é por região, e uma fonte com problema reinicia só a sua coleção;
  • o restart é exponential-delay de 1 s a 60 s, sem orçamento global de falhas.

Nenhum operador mantém estado por chave (o estado é só a posição das fontes e os writers do Kafka), então o backend é hashmap. O checkpoint roda a cada 60 segundos e é retido quando o job é cancelado.

A configuração do sink, resumida:

KafkaSink<DomainEvent> sink = KafkaSink.<DomainEvent>builder()
.setBootstrapServers(bootstrapServers)
.setRecordSerializer(new DomainEventSerializer()) // chave = _id em UTF-8
.setDeliveryGuarantee(DeliveryGuarantee.AT_LEAST_ONCE)
.setKafkaProducerConfig(producerProps) // acks=all, idempotência, compression=none
.build();

Escolhemos AT_LEAST_ONCE em vez de EXACTLY_ONCE por latência. Com transações, um consumidor read_committed só veria o registro no próximo checkpoint, até 60 segundos depois. O producer roda com acks=all, idempotência ligada e até 5 requisições em voo, o que preserva a ordem por partição.

Um detalhe que facilitou a transição: o producer do Flink é compatível com o producer kafkajs que o publisher usava. A chave é a mesma (_id em UTF-8) e o particionador também (murmur2), então um documento cai na mesma partição com qualquer um dos dois. A compressão ficou desligada porque os consumidores kafkajs só tinham o codec GZIP.

Id determinístico

O eventId deixou de ser aleatório. Ele é um UUID v5 calculado a partir do tipo do evento e do resume token da mudança, então a mesma mudança tem sempre o mesmo id, não importa quantas vezes seja reenviada. Isso é o que torna o at-least-once suportável do lado do consumidor.

Projeções enxutas

Cada evento carrega o estado da entidade, para os consumidores não precisarem ler o documento de novo. Aproveitamos para reduzir dados pessoais: o cliente vai só com id, nome, e-mail e telefone, e campos que nenhum consumidor usava saíram do payload.

Lógica de domínio em duas linguagens

O evento de criação de cobrança carrega os métodos de pagamento, incluindo o BR Code do Pix. Para gerar isso no Flink, portamos para Java a geração do BR Code, a tabela de remoção de acentos e a semântica de serialização do JavaScript para datas, números e valores. Só o pacote do BR Code tem cerca de 1.400 linhas.

A paridade é garantida por fixtures geradas a partir do código TypeScript, com mais de uma centena de casos comparados byte a byte. Se alguém muda a geração no TypeScript, os testes do lado Java quebram até as fixtures serem regeneradas e o port acompanhar.

Os consumidores

Cada tópico tem um handler criado pela mesma fábrica, que segue quatro passos:

  1. Ignora eventos de outro produtor (alguns tópicos ainda recebem eventos publicados diretamente pelo código de escrita).
  2. Faz claim do eventId no Redis com SET NX e expiração de 1 hora. Um restart do job reenvia os últimos minutos com os mesmos ids, e um recibo não pode sair impresso duas vezes.
  3. Reconstrói o “change” a partir do evento, hidratando o documento no modelo Mongoose só com os campos presentes, sem defaults de schema que inventariam valores.
  4. Chama exatamente as mesmas funções que o publisher chamava. Um atraso de 5 segundos ou mais desde a escrita no banco gera um log de lentidão.
// versão simplificada do claim
const claimed = await redis.set(`event:${event.eventId}`, '1', 'EX', 3600, 'NX')
if (!claimed) return // reentrega, já processado
await handleChange(rebuildChange(event))

O offset do consumer group só avança depois do processamento. Se o consumidor cai, o evento continua no tópico. O tópico de cobranças, que concentra a maior parte do volume, tem 6 partições e um deployment próprio que escala até uma réplica por partição.

O cutover

O primeiro desenho usava janelas de tempo por efeito: cada lado sabia, por configuração, a partir de quando um efeito era dele. Funcionaria, mas exigia parsers dos dois lados e vários deployments coordenados. Trocamos por duas flags:

  • uma no Flink, que passa a emitir também os tópicos que o publisher emitia;
  • outra compartilhada pelo publisher (que deixa de abrir os streams) e pelos consumidores (que passam a processar).

Isso implica uma janela em que os dois lados fazem os mesmos efeitos. Antes de ligar, auditamos cada consumidor externo dos tópicos: o de notificações da plataforma deduplica por índice único, o de analytics usa ids de job fixos e recalcula, outro faz upsert. A exceção conhecida era a notificação push de “cobrança paga” na plataforma, sem deduplicação, que saiu duas vezes durante a sobreposição.

A ordem importa. Liga os consumidores primeiro e depois desliga o publisher, aceitando duplicata. Na ordem contrária, o intervalo entre os dois deploys perderia efeitos. Em produção, foram cerca de 20 minutos de efeitos duplicados. O rollback é o inverso, com um cuidado: antes de religar os consumidores, os consumer groups precisam ser reposicionados no fim do tópico, ou reprocessam tudo o que ficou para trás.

A verificação teve três partes. Em staging, comparamos os payloads do publisher e do Flink para cobranças reais, em insert e update, e eram idênticos exceto por alguns campos novos. Em produção, comparamos por janelas de 10 minutos a contagem do APM do publisher, as mudanças lidas pelo Flink e o avanço de offset dos consumer groups, e os três bateram com cerca de 1% de diferença. Por fim, acompanhamos os comandos no Redis: os SET NX de claim apareceram na hora da virada, e os PUBLISH do PubSub subiram durante a sobreposição e voltaram ao patamar anterior depois.

Do início da análise à remoção do código e da infraestrutura do publisher foram quatro dias. A remoção tirou cerca de 10 mil linhas do monorepo.

Antes e depois

Publisher (Node)Flink + consumidores
Garantia de entregaAt-most-onceAt-least-once, com deduplicação no consumidor
Posição de leituraRedis com TTL de 60 s, salva antes do efeitoEstado do checkpoint, confirmado depois do flush no Kafka
Escala horizontalNão (1 pod)Sim, por partição
Ordem por documentoNão garantidaGarantida (_id como chave)
CPU da capturaReferênciaCerca de 10× menos
Memória da capturaPicos perto de 3 GiB, com OOMsPouco mais de 2 GiB, fixos (JVM)
Perda observadaDezenas de milhares de envios por semana, mais as janelas de stallNenhuma desde o cutover

Em staging, um consumidor ficou 25 minutos fora do ar por um erro de deploy. Quando voltou, consumiu o backlog sem perda e sem nenhuma falha de handler. No modelo antigo isso seria impossível.

O que piorou

Latência

A latência ponta a ponta piorou em algumas janelas, principalmente nos horários de pico e durante o backup noturno do banco. As causas são operacionais: a leitura do oplog fica para trás quando o nó de onde o CDC lê está sob carga, e o consumidor de cobranças ainda roda com pouca CPU para o volume que recebe.

A comparação com o publisher precisa de cuidado. A métrica antiga era a duração do handler, não o atraso desde a escrita. E no modelo novo atraso não é perda: o backlog fica no Kafka esperando. No antigo, o mesmo excesso de carga virava OOM e evento perdido.

Armadilhas da migração

  • Um consumidor que usava outra biblioteca Kafka ignorou as partições novas quando o tópico de cobranças passou de 1 para 6 partições. Alguns milhares de eventos ficaram parados até um reset de offsets.
  • Em staging, máquinas de desenvolvedores rodavam o publisher antigo contra o banco de staging e duplicavam todos os efeitos. Achamos os culpados pelo IP no MONITOR do Redis e pela latência do MQTT: 10 a 30 ms vinha de change stream, perto de 350 ms vinha do caminho Flink, Kafka e consumidor.
  • Um pod de consumidor subiu em produção sem a flag. Conferir as linhas de startup virou parte do procedimento.
  • A remoção quebrou uma release de staging. O PR de infraestrutura foi mergeado segundos antes do PR de código, e a release pegou a definição antiga da pipeline, que ainda tentava buildar o publisher. Remover também é deploy, e a mudança de CI precisa ir antes.

Lições

  1. A posição de leitura faz parte do dado. Salvar o resume token antes do efeito, ou com TTL, transforma qualquer falha em perda silenciosa.
  2. EventEmitter não é fila. Listener async sem await é concorrência ilimitada, e a carga aparece como latência, depois OOM, depois perda.
  3. Separar captura de efeito deixou cada parte com o runtime que faz sentido para ela, e o Kafka no meio desacopla as duas.
  4. Porte para a outra linguagem só o mínimo que precisa estar no evento, e proteja com fixtures geradas pelo código original.
  5. eventId determinístico mais deduplicação no consumidor é o que torna at-least-once tolerável.
  6. Sobreposição proposital é mais simples que janelas de tempo, desde que a idempotência de cada consumidor tenha sido auditada antes. O único consumidor que não deduplicava era justamente o que o cliente via.
  7. Meça a vazão por coleção contra o esperado. O stall que perdeu pedidos não gerou erro nenhum.
  8. Atraso não é perda, mas precisa de dono. O caminho novo acabou com a perda e trouxe atrasos novos, que agora são trabalho de tuning na leitura do oplog e no consumidor.