Elido
16 min de leituraEngenharia
Essencial

Atingir p95 < 15ms para redirecionamentos em FRA, ASH e SGP

Como o caminho de redirecionamento edge do Elido mantém um orçamento de 15ms p95 em HIT de cache em três regiões - arquitetura, estratégia de cache, medições por região reais

Marius Voß
DevRel · edge infra
Mapa do mundo mostrando os POPs edge do Elido em Frankfurt, Ashburn e Singapura com anotações de latência p95 de 12ms, 13ms e 14ms respetivamente

Um redirecionamento é um bloqueio síncrono. O utilizador clica no seu link curto, o browser para, e nada mais acontece até que o 302 chegue e o próximo carregamento de página possa começar. O redirecionamento não é uma tarefa de fundo que pode desprioritizar. Cada milissegundo que adiciona aqui é um milissegundo subtraído à página que realmente importa.

É por isso que definimos um orçamento rigoroso antes de escrever a primeira linha de services/edge-redirect: p50 5ms, p95 15ms num hit de cache, medido no POP, excluindo o handshake TLS completo. Não aspiracional. Se algo nos empurrar para além da linha, é removido ou movido para um caminho assíncrono.

Temos vindo a executar três regiões de produção - Frankfurt (FRA), Ashburn (ASH) e Singapura (SGP) - durante vários meses. Este artigo é um relato completo de como funciona o caminho crítico, por que os números são como são e o que fizemos mal na primeira vez.

Resumo#

  • O caminho crítico é Go + fasthttp no Hetzner FRA/ASH e OVH SGP, atrás do Caddy com encaminhamento anycast. Sem scoring de bots síncrono, sem JS challenge no caminho de redirecionamento.
  • Cache de dois níveis: LRU ristretto em processo (L1, ~88% taxa de hit) suportado por Redis Cluster (L1+L2 combinados ~99,4%). gRPC de origem para api-core apenas em cold miss (~0,6% dos pedidos).
  • p95 por região em 90 dias: FRA 12,1ms, ASH 13,4ms, SGP 14,2ms. Cold miss adiciona ~22ms no p95, ainda dentro do orçamento.
  • A invalidação de cache em mutação de link é Redis pub/sub, propagação sub-segundo no p99. O TTL do L1 é 60 segundos como rede de segurança.

Por que um teto de 15ms#

Antes de entrar na arquitetura: por que 15ms e não 50ms ou 5ms?

O limite inferior de 5ms é simples - é aproximadamente o que o trânsito físico de rede custa na mediana para um visitante europeu a atingir um POP em Frankfurt. Não se pode ir abaixo da física. O teto de 50ms é demasiado laxo - a 50ms no p95, está a adicionar uma espera percetível antes de cada página para uma fração significativa do seu tráfego. A investigação sobre desempenho web mostra consistentemente que atrasos de rede abaixo de 50ms começam a ser percetíveis em dispositivos móveis onde a latência de rádio se combina com o tempo de processamento, um ponto que as diretrizes de programação network-aware da Apple referem explicitamente.

O número de 15ms surgiu de algumas restrições concretas. Primeiro, os redirecionamentos compõem-se. Se uma campanha de marketing envia tráfego através de um link encurtado que depois redireciona para uma página de produto, a latência do redirecionamento adiciona-se ao TTFB da página de destino. As Core Web Vitals do Google usam o LCP como sinal principal, e uma cadeia de redirecionamentos que adiciona 50ms no p95 é mensurável. Segundo, queremos margem de orçamento suficiente para executar a avaliação de regras para smart links inline no caminho crítico - as dimensões de encaminhamento (país, dispositivo, SO, idioma, hora, referrer) precisam de executar dentro do mesmo envelope de latência que um redirecionamento simples, ou teríamos de remover o suporte a smart links do edge. A 15ms com um custo de avaliação de regras de ~0,3ms, há espaço.

O orçamento de 15ms aplica-se ao tráfego em hit de cache. Os cold misses têm permissão para ser mais lentos - a chamada gRPC de origem adiciona latência - mas os cold misses por design são raros o suficiente para não moverem significativamente o p95.

A arquitetura#

Três POPs, cada um com o mesmo binário: services/edge-redirect, escrito em Go usando fasthttp. O throughput do servidor fasthttp é aproximadamente 8x o net/http no suite de benchmarks e, mais praticamente para nós, o seu caminho de pedido zero-alloc mantém as pausas de GC previsíveis sob carga sustentada. O net/http da biblioteca padrão está bem para a maioria dos serviços; para um handler de redirecionamento que precisa de manter sub-milissegundo de tempo de processamento com alta concorrência, evitar alocação de heap por pedido vale a pena a API menos ergonómica.

O Caddy está à frente como terminador TLS e proxy reverso. TLS on-demand para domínios personalizados de tenants (descrito em detalhe na página de funcionalidade de domínios personalizados) provisiona certificados no primeiro pedido. Avaliámos o HAProxy e o nginx como alternativas - ambos são rápidos, ambos têm padrões maduros de implementação anycast, mas o TLS on-demand do Caddy é o caminho mais limpo para o ciclo de vida de certificados zero-touch para um número arbitrário de domínios de clientes, e isso importa-nos mais do que espremer mais uma fração de milissegundo na camada de proxy.

O encaminhamento anycast significa que quando um visitante acede a f.elido.me, s.elido.me ou b.elido.me, o DNS resolve para um prefixo anycast partilhado e a rede encaminha a ligação TCP para o POP mais próximo. Não há lógica de geo-roteamento ao nível da aplicação: a rede faz a seleção do POP. O primer anycast da Cloudflare é a explicação pública mais clara de por que isso importa - a propriedade fundamental é que o failover é tratado na camada BGP, não pela expiração do TTL DNS. Se o FRA perder conectividade, o ASH torna-se o caminho mais curto para o tráfego europeu em segundos, não minutos. A documentação da infraestrutura de rede cloud do Hetzner cobre a configuração de encaminhamento subjacente para as suas regiões FRA e ASH.

Importante: não há scoring de bots síncrono no caminho crítico. Uma verificação de scoring de bots que leve 10ms destruiria sozinha o orçamento de p95. Todos os sinais de qualidade de tráfego - deteção de anonimizadores, scoring de ASN de hosting, deduplicação de cliques - correm no url-scanner e click-ingester como workers assíncronos de caminho frio. O redirecionamento dispara e o clique vai para a fila do Redpanda; a adjudicação de qualidade acontece a posteriori.

O cache de dois níveis#

O cache é onde vive o orçamento. A lógica:

// Simplified cache lookup: L1 → L2 → origin, with singleflight dedup
func (h *RedirectHandler) resolve(ctx *fasthttp.RequestCtx, slug string) (*Link, error) {
    // L1: in-process ristretto LRU - sub-microsecond on hit
    if link, ok := h.l1.Get(slug); ok {
        return link.(*Link), nil
    }

    // L2 + origin share a singleflight group to prevent thundering herd
    // on concurrent cold misses for the same slug
    val, err, _ := h.sf.Do(slug, func() (interface{}, error) {
        // L2: Redis Cluster - single RTT, typically 0.3–0.8ms within POP
        if data, err := h.redis.Get(ctx, cacheKey(slug)).Bytes(); err == nil {
            link, err := unmarshalLink(data)
            if err == nil {
                h.l1.Set(slug, link, linkCost(link))
                return link, nil
            }
        }

        // Origin: gRPC to api-core - cold miss, ~20ms extra
        link, err := h.origin.GetLink(ctx, &pb.GetLinkRequest{Slug: slug})
        if err != nil {
            return nil, err
        }
        payload, _ := marshalLink(link)
        h.redis.Set(ctx, cacheKey(slug), payload, redisTTL)
        h.l1.Set(slug, link, linkCost(link))
        return link, nil
    })
    if err != nil {
        return nil, err
    }
    return val.(*Link), nil
}

O L1 é o ristretto, o cache LRU com controlo de admissão da Dgraph. O controlador de admissão importa: um LRU ingénuo sob uma carga de scan (um bot a aceder a milhares de slugs únicos) irá despejar entradas quentes para dar lugar a entradas frias que nunca serão pedidas novamente. A política de admissão baseada em TinyLFU do ristretto resiste a isto - rastreia contadores de frequência de forma barata e recusa admitir uma entrada que nunca foi vista antes quando o cache está sob pressão. O efeito líquido é que a taxa de hit do cache sob tráfego de scan adversarial se mantém perto da taxa de hit orgânica em vez de colapsar.

O L2 é o Redis Cluster. Cada POP tem a sua própria instância de cluster para manter o tráfego inter-regiões fora do caminho crítico. FRA e ASH partilham uma instância Redis separada para sinais de invalidação pub/sub (mais sobre isso abaixo); SGP tem a sua. Um único GET Redis dentro do mesmo datacenter é fiável abaixo de 1ms. A taxa de hit combinada L1+L2 está em aproximadamente 99,4% nos últimos 90 dias - o que significa que as chamadas de origem acontecem em cerca de 1 em 167 pedidos.

Para o caso de uso de solutions/developers - equipas que usam a API para cunhar links em alto volume - a implicação prática é que um link recém-criado experienciará um cold miss por POP, depois ficará quente pelo tempo do seu TTL. Os links que não têm tráfego expiram de ambos os caches limpa­mente sem evicção manual.

Para onde vão os 15ms#

O diagrama abaixo desagrega o orçamento de hit de cache p95 por fase:

Barra empilhada horizontal mostrando o orçamento de hit de cache p95 de 15ms decomposto em retoma TLS 2ms, lookup L1 0,4ms, construção de cabeçalho 1ms, retorno de rede 9ms e margem 2,6ms. Valores medianos ilustrativos de FRA.

O segmento dominante é o retorno de rede - aproximadamente 9ms mediana, o que significa que a distância física entre o visitante e o POP representa 60% do orçamento. Não conseguimos comprimir isto. A implementação multi-região é a única alavanca: adicionar um POP reduz o RTT mediano para os visitantes nessa região. A próxima região no roadmap reduz o p95 do SGP para o tráfego do Sul Asiático, onde atualmente estamos a encaminhar 14ms porque Singapura é o POP mais próximo.

A retoma de sessão TLS a 2ms assume TLS 1.3 0-RTT com um ticket de sessão já disponível. Para uma primeira visita a partir de um dado dispositivo, um handshake TLS completo adiciona aproximadamente 10-15ms por cima - é por isso que o orçamento de 15ms se aplica explicitamente ao tráfego de hit de cache + sessão retomada, que é a grande maioria do tráfego de cliques na prática. O RFC 7234 rege a semântica de caching para a camada HTTP; note-se que as respostas 302 não são armazenadas pelos caches do browser por padrão (§4.2.2), que é o comportamento correto para o nosso caso de uso - cada pedido de redirecionamento chega ao edge, cada redirecionamento recebe a sua própria decisão de encaminhamento, sem destino desatualizado no cache do browser.

A margem de 2,6ms é espaço operacional real, não padding. Sob o GC do Go, pausas stop-the-world ocasionais da ordem de 0,5-1ms são esperadas mesmo com configurações GOGC ajustadas. O overhead de proxy do Caddy adiciona um pequeno custo fixo. A margem evita que ultrapassemos o orçamento quando estes efeitos se compõem.

Invalidação de cache#

O Redis pub/sub é o mecanismo. Quando um link é mutado no api-core - destino alterado, regras de targeting atualizadas, link arquivado - o handler de mutação publica para um canal link:invalidate com o slug como payload. Cada POP edge subscreve este canal. Na receção, o subscritor chama l1.Del(slug) e redis.Del(cacheKey(slug)). O próximo pedido para esse slug repopula ambos os níveis a partir da origem.

O TTL de 60 segundos do L1 é o fallback, não o mecanismo principal. Se o subscritor pub/sub estiver inativo - digamos, uma falha Redis ou uma partição de rede entre o POP e a instância pub/sub - a entrada expira do L1 em no máximo 60 segundos. O TTL do L2 é definido para 300 segundos, por isso uma interrupção do subscritor significa até 5 minutos de dados L2 potencialmente desatualizados, durante os quais o TTL do L1 é a única rede de segurança. Alertamos para perda de subscrição pub/sub dentro de 30 segundos.

Para smart links com regras com janelas temporais, a obsolescência tem uma implicação específica: se uma regra ativar às 17:00 e o L1 do POP edge tiver a versão anterior da regra em cache com até 60 segundos de TTL restante, o tráfego entre 17:00 e 17:01 pode ir para o destino anterior à atualização. O caminho pub/sub elimina isto no caso comum; o TTL de 60 segundos apanha o caso de extremo. Para campanhas onde o limite temporal importa precisamente, o padrão recomendado é usar status=disabled na regra antiga, aguardar um ciclo TTL (60 segundos), depois ativar a nova. Adicionámos um endpoint de polling em GET /v1/links/{id}/cache-status para que os pipelines possam confirmar a propagação antes de prosseguir.

Medições por região reais#

Os seguintes números provêm de dados de workspace de demonstração recolhidos durante 90 dias até 2026-05-12. Refletem apenas tráfego em hit de cache. Todos os timestamps são UTC.

RegiãoPOPp50p95p99
EU (Frankfurt)FRA · Hetzner4,8ms12,1ms18,4ms
US East (Ashburn)ASH · Hetzner5,2ms13,4ms20,1ms
SE Asia (Singapura)SGP · OVH5,6ms14,2ms22,8ms

O FRA é o mais rápido porque a maioria da carga é europeia, pelo que o RTT mediano é menor. O SGP serve uma distribuição geográfica mais ampla - o tráfego do Sudeste Asiático tem RTT mais baixo, enquanto o tráfego do Sul Asiático e do Leste Asiático adiciona-se à cauda.

Os números p99 excedem o orçamento de 15ms. Isso é deliberado. O p95 é o orçamento, não o p99. O p99 é moldado por condições extremas: transições celulares, retransmissões TCP, o ocasional pico de latência Redis. Monitoramos o p99 mas não o incluímos no SLA. A decisão de engenharia é que o p95 captura a experiência para "quase todos quase sempre", e otimizar os últimos 1% exigiria eliminar fontes de variabilidade natural de rede que não estão sob o nosso controlo.

O p95 do cold miss é aproximadamente 22ms. Este é o mínimo que podemos alcançar dado que o gRPC de origem adiciona uma viagem de ida e volta no mesmo datacenter (FRA → FRA por rede privada é aproximadamente 0,3ms) mais o lookup Postgres do api-core (tipicamente 1-3ms para um lookup de slug por chave). O número de 22ms é medido, não estimado; está dentro do orçamento que permitimos para caminhos de cold miss, que está definido em 35ms no p95.

Para equipas a avaliar análises multi-região, estes números de latência estão disponíveis como métrica Prometheus (redirect_duration_seconds com rótulos region e cache_tier) a partir do endpoint de métricas.

Modos de falha sobre os quais não escrevemos da primeira vez#

Thundering herd na expiração de chave#

Antes de adicionarmos o singleflight, um slug a expirar de L1 e L2 simultaneamente sob tráfego moderado gerava uma rajada de chamadas gRPC de origem concorrentes - cada uma a fazer um leitura Postgres para o mesmo slug, todas a devolver o mesmo resultado. Sob teste de carga, isto produzia picos de CPU no api-core que não tinham nada a ver com o volume de criação de links. O grupo singleflight colapsa misses concorrentes para o mesmo slug numa única chamada de origem. As outras goroutines em espera bloqueiam no grupo e recebem o mesmo resultado quando ele resolve. A implementação é o pacote padrão Go golang.org/x/sync/singleflight.

Acertámos nisto no primeiro protótipo. Um thundering herd sob expiração de chave é um dos modos de falha que não aparecem em testes unitários - só surge sob concorrência realista. A incluir neste artigo porque é uma omissão comum em writeups de arquitetura de cache e a correção é genuinamente simples.

Fallback em falha Redis#

Se um POP perder conectividade com o seu cluster Redis, o fallback não é um erro - o caminho de código degrada para apenas L1 mais gRPC de origem direto em miss de L1. O POP continua a servir. A taxa de hit cai porque L2 está indisponível, pelo que o volume de chamadas de origem sobe, mas o caminho de redirecionamento mantém-se funcional. O caminho de falha Redis foi exercitado duas vezes em produção (ambas foram janelas de manutenção do Hetzner). A taxa de chamadas de origem de pico durante o segundo incidente foi aproximadamente 8x a linha de base pelo tempo da falha (~4 minutos). O api-core tratou disso sem eventos de escalonamento.

Propagação DNS durante failover de POP#

O failover anycast é na camada BGP - sem TTL DNS a aguardar, sem timeout de verificação de saúde ao nível da aplicação no caminho do pedido. Um POP a ficar offline desencadeia a retirada BGP da rota, e o tráfego de rede muda para o POP mais próximo seguinte dentro da janela de convergência BGP (tipicamente 15-90 segundos dependendo do número de saltos de rede para o caminho afetado). O parâmetro operacional relevante é o nosso intervalo de verificação de saúde: executamos verificações TCP de saúde a cada 10 segundos por POP. Uma falha de verificação desencadeia a retirada. Um intervalo de verificação de 10 segundos significa que um POP em colapso pode servir até 10 segundos de tráfego falhado antes da retirada. Testámos este limite deliberadamente; o impacto real nos dois incidentes de produção foi abaixo do intervalo de verificação.

O que não fazemos no caminho crítico#

Cada item que não está no caminho crítico é uma escolha deliberada, não uma omissão.

Escritas síncronas de cliques. Os cliques são fire-and-forget para o Redpanda. O handler de redirecionamento acrescenta um evento de clique a um tópico Kafka (clicks.raw) com o slug, timestamp, IP truncado e hash de user-agent, depois responde com o 302. A escrita é não-bloqueante. Se o Redpanda estiver indisponível, o clique é descartado - não o redirecionamento. Fizemos a troca consciente de que a perda de cliques sob falha de infraestrutura é aceitável e a falha de redirecionamento não é. O consumidor click-ingester processa o tópico Redpanda e escreve para o ClickHouse. É por isso que os dados de análises para um dado evento de clique estão disponíveis com um pequeno atraso (tipicamente abaixo de 5 segundos), não instantaneamente.

Desafios de bots inline. Um desafio de bot adiciona 10-50ms de trabalho síncrono no mínimo - os desafios JavaScript adicionam uma viagem de ida e volta completa. Não fazemos nenhum dos dois no caminho de redirecionamento. O serviço url-scanner processa sinais de qualidade de tráfego de forma assíncrona. Para equipas de solutions/developers a construir campanhas de links, isto significa que o redirecionamento nunca é bloqueado atrás de um desafio que degrada a experiência para o tráfego legítimo.

Validação de esquema no momento do redirecionamento. O URL de destino e as regras de targeting são validados no momento da escrita, quando o link é criado ou atualizado via api-core. No momento em que um slug entra no cache, a sua estrutura é conhecida como válida. Não há validação de esquema JSON, passo de parse de URL, nem verificação de sintaxe de regras no momento do redirecionamento. O binário edge confia completamente na entrada do cache. Isto só é seguro porque o caminho de escrita valida antes da admissão ao cache.

As partes sem glamour#

Três coisas sobre as quais não escrevemos suficientemente, porque são entediantes de ler e importantes de acertar.

Orçamentos de tamanho de cache. O ristretto é inicializado com um orçamento de custo explícito em bytes, não uma contagem simples de itens. Cada link em cache tem um custo pelo seu tamanho serializado, que varia com o número de regras de targeting. Um link sem regras custa aproximadamente 200 bytes; um link com 6 regras de targeting custa cerca de 800 bytes. O orçamento é definido para consumir no máximo 10% da RAM disponível da instância, deixando espaço para o runtime Go, o Caddy e os buffers de ligação. Acertar nisto errado causa thrashing de cache: um orçamento demasiado pequeno despeja entradas antes de o TTL expirar, empurrando tráfego para L2 e origem.

Ajuste de GC sob carga. O coletor de lixo do Go está bem ajustado por padrão, mas o GOGC=100 padrão desencadeia GC a duas vezes o tamanho do heap ativo. Para um handler de redirecionamento onde o heap ativo é pequeno mas a taxa de alocação é moderada (o fasthttp é zero-alloc no caminho crítico, mas há alocações de objetos para eventos de clique e chamadas gRPC), o GC dispara com mais frequência do que necessário. Executamos GOGC=400 em produção. O efeito são ciclos GC mais longos mas com menor frequência - que importa para a latência de cauda. Um ciclo GC que leva 2ms e acontece uma vez a cada 4 segundos adiciona uma contribuição menor ao p99 do que um ciclo de 1ms por segundo. Verificámos isto empiricamente com make bench antes de o definir na configuração de implementação.

A disciplina make bench. O binário edge tem um suite de benchmarks (go test -bench=. -benchmem ./... a partir de services/edge-redirect). Cada alteração proposta ao caminho crítico - adicionar um novo cabeçalho, alterar o formato da chave de cache, ajustar o avaliador de regras - corre pelos benchmarks antes do merge. Uma alteração que adiciona 0,5ms ao benchmark p50 é uma alteração que move o p95 em produção. O benchmark é o gate, não uma verificação post-hoc. Uma vez ficámos relaxados sobre isto, numa refatoração que alterou a lógica de normalização de slugs, e lançámos uma regressão de 1,2ms que apareceu nos dashboards de região dois dias depois. A regressão foi real e a lição ficou.


As decisões de arquitetura aqui estão documentadas em mais detalhe em /docs/architecture/edge-redirect. Se estiver a avaliar o Elido como camada de infraestrutura de redirecionamento para uma campanha de alto volume ou uma plataforma de programadores, a página solutions/developers cobre a superfície da API e as opções de SDK. Para uma análise do que o cache de dois níveis implica para o comportamento de smart links - particularmente a janela de propagação para alterações de regras - o artigo smart links explained cobre isso em profundidade.


Marius Voß é DevRel e edge infra no Elido. Foi um dos engenheiros que levou o binário edge-redirect do protótipo à produção e tem vindo a olhar para os seus dashboards de latência desde então.

Experimente Elido

Cole uma URL, obtenha um link curto

Sem cadastro. O link vive 30 dias. Cadastre-se para mantê-lo para sempre.

Grátis, sem necessidade de registo · 2 por dia

Experimente o Elido

Encurtador de URL hospedado na UE: domínios personalizados, análises profundas e API aberta. Plano gratuito - sem cartão de crédito.

Tags
url shortener performance
edge redirect latency
multi-region url shortener
redirect cache strategy
fasthttp
anycast routing

Continuar lendo