O caminho de redirecionamento de um encurtador de URL tem exatamente um trabalho: resolver um slug para um destino e retornar um 301 em milissegundos de dígito único. Todo o resto é escrituração. Analytics de cliques, atribuição, enriquecimento geográfico, pontuação de fraude, fan-out de webhook — nada disso pode estar no caminho da solicitação. O orçamento de latência não permite.
Este é o truque de engenharia que permite que o pipeline de analytics coexista com a pedra angular de redirecionamento p95 < 15ms: a borda dispara um evento de clique no Redpanda e o esquece. Um worker separado — click-ingester — o coleta mais tarde, o enriquece e o escreve no ClickHouse em lotes. O processo de redirecionamento nunca bloqueia. O pipeline de analytics nunca toca no caminho crítico. O tradeoff é a durabilidade, e é um tradeoff menor do que parece à primeira vista.
O que "fire and forget" realmente significa aqui#
O handler edge-redirect, após escolher a URL de destino a partir do cache de dois níveis, faz três coisas antes que o cabeçalho Location seja enviado:
- Constrói uma struct
click.Eventem memória a partir da solicitação (slug, ID do workspace, user agent, referer, IP, geo a partir do mmdb GeoLite2-City local, parse de dispositivo/navegador, flags de suspeita). - Chama
producer.Emit(ctx, event)no produtor Kafka franz-go. - Escreve
HTTP/1.1 301e o cabeçalhoLocationno buffer de resposta.
A chamada do produtor retorna imediatamente. Ela não espera por um ack de nenhum broker Redpanda. A biblioteca franz-go armazena o registro em buffer no processo e o despacha em uma goroutine em segundo plano; o callback de produção é invocado mais tarde, em um pool de workers que não é dono da goroutine da solicitação. Se a produção falhar, o callback registra o erro e o evento é descartado. O redirecionamento já foi servido.
func (p *Producer) Emit(ctx context.Context, e Event) {
if p == nil {
return
}
b, err := json.Marshal(e)
if err != nil {
p.log.Warn("click marshal", zap.Error(err))
return
}
rec := &kgo.Record{Topic: p.topic, Value: b}
p.client.Produce(ctx, rec, func(_ *kgo.Record, err error) {
if err != nil && p.log != nil {
p.log.Warn("click produce", zap.Error(err))
}
})
}
Essa é a interface completa. Sem fila de repetição dentro do processo de borda, sem espera síncrona por ack, sem spool em disco. O contrato com o resto do sistema é simples: emissão de melhor esforço (best-effort), registrar falhas, nunca bloquear.
Uma guarda de receptor nulo permite que o dev local rode sem um broker Kafka. Sem ela, cada contribuidor precisaria de um container Redpanda rodando apenas para testar o caminho de redirecionamento contra handlers fasthttp.
Por que não escolhemos uma escrita síncrona#
A alternativa óbvia é escrever cada clique diretamente no ClickHouse a partir da borda. Nós consideramos isso. Rejeitamos por três razões que se somam.
Latência. O round-trip de INSERT do ClickHouse do POP de Frankfurt para um cluster ClickHouse na mesma região fica em 3-6ms p50 em uma rede tranquila, 12-20ms p95 sob carga. Esse é todo o orçamento de redirecionamento. Adicioná-lo ao caminho de resposta empurraria o p95 para além do SLO de 15ms antes que qualquer outra coisa desse errado. O post sobre estratégia de cache explica o quão apertado é o orçamento na prática.
Backpressure. O ClickHouse fica feliz ingerindo lotes de 1000-10000 linhas por INSERT. Ele fica infeliz ingerindo linhas únicas em loops apertados — o motor MergeTree escreve um arquivo de parte por inserção e um processo em segundo plano mescla as partes. Um padrão de escrita direta de uma frota de borda multi-região criaria milhões de partes minúsculas e a fila de merge nunca alcançaria. A documentação do ClickHouse é explícita: insira em lotes de pelo menos 1000 linhas, não mais que uma vez por segundo.
Isolamento de falhas. Um reinício de cluster ClickHouse, uma oscilação de rede ou uma consulta lenta que bloqueie uma réplica se propagaria diretamente para falhas de redirecionamento. O processo de borda começaria a dar timeout (piorando o p95) ou começaria a descartar cliques (piorando a qualidade dos dados). Colocar um barramento de mensagens entre os dois permite que cada lado falhe independentemente — a borda continua redirecionando mesmo quando o ClickHouse está degradado, e o ClickHouse continua ingerindo mesmo quando um POP está offline.
O Redpanda absorve todas essas três pressões. Ele é compatível com o protocolo Kafka, então o franz-go fala com ele de forma transparente. Ele possui um footprint de binário único sem JVM. Ele armazena em buffer no disco, então uma interrupção de várias horas no ClickHouse não perde eventos, desde que a janela de retenção do tópico seja mantida.
O worker click-ingester#
O click-ingester é um serviço Go que roda como um grupo de consumidores no tópico de eventos de clique. Uma réplica por região, três regiões, sem sharding por slug ou workspace — o grupo de consumidores se reequilibra se uma réplica reiniciar e as partições são atribuídas pelo Redpanda. O trabalho do consumidor é pequeno:
- Faz poll de fetches do tópico.
- Decodifica o JSON de cada registro em um
Eventtipado. - Empurra o evento para um buffer em memória de um escritor.
- Às vezes: dispara webhooks, encaminha para Klaviyo / Mixpanel / GA4 MP, publica no stream de cliques ao vivo no app.
O escritor agrupa por contagem ou por tempo, o que ocorrer primeiro. Padrões: 1000 eventos por lote, intervalo de flush de 5 segundos. Um lote é construído em uma chamada PrepareBatch de INSERT INTO click_events contra o ClickHouse e confirmado como um append único no lado do servidor. Em caso de sucesso, o escritor marca os offsets dos registros Kafka subjacentes como confirmados; em caso de falha, nada é confirmado e o consumidor faz um novo fetch a partir do último offset bem-sucedido em seu próximo poll.
O contrato de offset após o flush é a garantia de durabilidade. O consumidor nunca diz ao Redpanda "eu processei este registro" até que o registro tenha pousado no ClickHouse como parte de um lote bem-sucedido. Um crash entre o consumo e o flush significa que o grupo de consumidores se reequilibra, o novo dono faz o poll a partir do último offset confirmado e os eventos são reprocessados. O reprocessamento é seguro porque a tabela click_events é um ReplacingMergeTree chaveado em um ID de evento sintético — inserções duplicadas colapsam no merge.
Mensagens ruins não são repetidas. Uma falha de decodificação de JSON é marcada como confirmada imediatamente para que o consumidor não fique preso em um registro tóxico (poison record). Esta é uma fonte pequena, mas real, de perda de dados; a taxa fica em eventos únicos por dia em toda a frota, e os eventos afetados aparecem no contador Prometheus decode_error_total do consumidor.
O tradeoff de durabilidade em números#
O fire-and-forget abre mão de alguns eventos. A questão é quantos, e se isso importa para o caso de uso.
Medimos a taxa de perda em produção em uma janela de 90 dias. O número é de aproximadamente 0,04% dos eventos emitidos — cerca de quatro cliques perdidos por dez mil. O detalhamento:
- Reinício do processo de borda com buffer em voo. O franz-go armazena em buffer até algumas centenas de milissegundos de registros antes de dar flush para um broker. Um SIGTERM durante um deploy pode descartar o que estiver no buffer. O script de deploy emite um desligamento limpo que drena o buffer com um timeout de 2 segundos, o que captura a maioria dos casos, mas não todos.
- Indisponibilidade do broker Redpanda além da janela de repetição do produtor. O franz-go repete falhas de produção, mas o orçamento de repetição é limitado. Se um cluster Redpanda de uma região estiver instável por mais de cerca de 30 segundos, o buffer transborda e novos registros são descartados na borda do produtor.
- Partição de rede entre o POP de borda e o cluster Redpanda regional. O mesmo efeito acima. O produtor registra avisos e descarta eventos até que a conectividade retorne.
Para a carga de trabalho do encurtador de URL, 0,04% de perda é aceitável. Cliques são sinal estatístico, não transações financeiras. Analytics de coorte, atribuição de conversão e distribuição geográfica agregam bem em uma amostra com essa taxa de perda. Casos de uso que não tolerariam isso — indústrias regulamentadas com requisitos de auditoria, contagens de cliques vinculadas ao faturamento — não são o que a camada de redirecionamento serve diretamente.
Para workspaces que precisam de maior durabilidade, oferecemos um modo de log de auditoria separado que escreve cada clique de forma síncrona no Postgres além do caminho fire-and-forget. A escrita síncrona adiciona 3-5ms p95 ao redirecionamento, opcional, desativado por padrão. O guia de exportação do ClickHouse documenta o formato do log de auditoria para equipes de conformidade que precisam reconciliar contagens.
Estratégia de replay quando o ClickHouse está fora do ar#
O produtor é fire-and-forget, mas o lado do consumidor tem uma história real de replay.
Quando o ClickHouse está indisponível, as chamadas de flush do escritor falham. O consumidor continua fazendo poll — o loop de poll do franz-go é independente do loop de flush do escritor — mas os offsets não são confirmados porque o flush não teve sucesso. A retenção do Redpanda é definida para 72 horas, que é o máximo de interrupção tolerável antes que os eventos comecem a expirar.
Durante uma interrupção real (tivemos três de duração significativa em 18 meses), a sequência de recuperação é:
- O ClickHouse volta a ficar online.
- A próxima tentativa de flush é bem-sucedida e confirma os offsets.
- O consumidor alcança o atraso drenando o backlog na taxa de lote configurada. Com um lote de 1000 eventos e um flush de 5 segundos, o consumidor pode drenar cerca de 200 eventos por segundo por réplica; três réplicas significam cerca de 36k eventos por minuto.
- O painel do Grafana para a tabela
click_eventsmostra a curva de recuperação (catch-up) — a taxa de inserção de linhas permanece elevada até que o backlog seja limpo.
A retenção de 72 horas é dimensionada para absorver uma reconstrução do ClickHouse de vários dias sem perda de dados. Nunca usamos mais de 4 horas dela em produção. O disco nos brokers Redpanda é o custo, e é pequeno comparado à perda de dados de analytics.
Um replay a partir do arquivo também é possível. O Redpanda possui armazenamento em camadas (tiered storage) enviando segmentos fechados para armazenamento de objetos compatível com S3. Temos isso configurado, mas não precisamos — o replay a quente cobre todos os incidentes que vimos.
O que o consumidor também faz#
A ingestão de cliques não é apenas escrita no ClickHouse. O consumidor é o ponto central de fan-out para todos os sistemas downstream que se interessam por cliques.
- Despachante de webhook. Webhooks configurados pelo cliente disparam do consumidor, não da borda. O consumidor enfileira um trabalho de webhook por clique que corresponda a um filtro configurado. Repetições, assinatura e entrega acontecem no
webhook-dispatcher. - Encaminhamento de eventos no lado do servidor. Klaviyo, Mixpanel, GA4 Measurement Protocol, Meta CAPI. O consumidor mantém um cache de configuração por workspace e dispara o POST apropriado para cada clique que o workspace configurou. Os encaminhadores são do tipo best-effort com uma pequena repetição em memória; falhas persistentes vão para uma tabela de dead-letter.
- Stream de cliques ao vivo. A visualização "assista a uma campanha cair ao vivo" no app assina um canal pub/sub do Redis. O consumidor publica um evento de formato mínimo para cada clique que corresponda a uma sessão ao vivo ativa. Esta é a única parte do pipeline que parece síncrona e é do tipo best-effort — descarta eventos quando o canal está congestionado.
- Disparo de pixels. Pixels de conversão (retargeting e conversão offline) disparam do consumidor com base na configuração por link. O disparo de pixel é seu próprio domínio de falha; as falhas são registradas, mas não causam back-pressure no escritor do ClickHouse.
Tudo isso roda após a confirmação do offset, mas antes do próximo poll. Um endpoint de pixel lento pode diminuir o throughput efetivo do consumidor. Um timeout por encaminhador (limite rígido de 1 segundo) e um limite de concorrência por lote (16 em voo) evitam que o caminho lento domine.
Por que este formato e não Kinesis ou uma fila#
Alguns formatos alternativos de barramento de eventos avaliados e não escolhidos.
SQS ou RabbitMQ como fila. Nenhum dos dois tem o throughput por broker que o Redpanda oferece no volume de eventos de clique. O SQS cobra por solicitação, o que torna streams de alto volume caros; o RabbitMQ sofre com tópicos densos.
AWS Kinesis. Razoável se fôssemos residentes na AWS. Não somos — Hetzner FRA, Hetzner ASH, OVH SGP. Kafka ou Redpanda auto-hospedado é o formato certo para um deploy focado na UE.
Kafka puro. Funciona. Escolhemos o Redpanda pelo perfil operacional — binário único, sem Zookeeper, sem ajuste de JVM. O protocolo de fio é idêntico e o franz-go não consegue notar a diferença. Um deploy auto-hospedado do Elido pode trocar para Apache Kafka sem alterações de código.
Serviços gerenciados como Confluent Cloud. Não são residentes na UE da maneira que queremos. A camada de redirecionamento precisa de latência de barramento de mensagens na mesma região.
A decisão está documentada em mais detalhes na página de arquitetura do edge-redirect, que é a fonte da verdade para as escolhas de configuração da camada de redirecionamento.
O que faríamos diferente da próxima vez#
O padrão fire-and-forget está correto. A implementação tem arestas que vale a pena sinalizar para quem for copiar o design.
Drenagem de desligamento. O timeout de drenagem de 2 segundos do franz-go perdeu eventos durante deploys quando o buffer está ocupado. A correção é um hook de SIGTERM que faz o flush de forma síncrona antes do processo sair, com um timeout mais longo e uma interrupção forçada se o broker estiver inacessível.
Caminho de dead-letter para falhas de decodificação. Marcar registros tóxicos como confirmados e seguir em frente é bom para o throughput, mas perde observabilidade. Uma iteração futura escreve os bytes brutos mais o erro de decodificação em uma tabela click_events_decode_failures para que a equipe possa auditar o que aparece.
Concorrência de encaminhador por workspace. Hoje, os encaminhadores de todos os workspaces compartilham o pool global do consumidor. Um workspace ruidoso com um endpoint lento do Mixpanel pode deixar os outros sem recursos. Um limite por workspace é a correção óbvia; ainda não o construímos.
Nada disso causou um incidente de produção. Eles são o tipo de coisa que você registra no backlog de ADR e vai resolvendo aos poucos.
Leituras relacionadas#
- Atingindo p95 < 15ms para redirecionamentos de FRA, ASH e SGP — a peça fundamental do orçamento de latência ao lado da qual este post se situa.
- Estratégia de cache para redirecionamentos de URL: L1 LRU e L2 Redis — a outra metade da história do caminho crítico.
- Por que usamos ClickHouse para analytics de cliques (não Postgres) — a decisão a jusante deste pipeline.
- Smart links explicados — o que o campo de URL de destino realmente resolve antes que o evento de clique seja emitido.
- Short links como Terraform — passo a passo operacional da configuração da camada de redirecionamento.
- Conectando Sentry em 12 serviços Go — o caminho de captura de panic e 5xx que roda ao lado do consumidor.
- Arquitetura:
/docs/architecture/edge-redirect. - Guia operacional:
/docs/guides/clickhouse-export— o modo de log de auditoria para workspaces que precisam de durabilidade por clique. - Externo: Armazenamento em camadas do Redpanda, Inserções em lote do ClickHouse, fasthttp.