Projetando um serviço encurtador de URLs para alta carga

1. Visão Geral

Encurtar links é um problema de design de sistema bem conhecido. A lógica consiste apenas em mapear um código curto para uma URL longa. A dificuldade está na escala: dezenas de milhares de redirecionamentos por segundo, links “virais” de repente, e a exigência de nunca gerar códigos duplicados.

Este artigo passa por todo o processo: definição de requisitos, estimativa de carga, modelo de dados, API, forma de gerar código, cache e pontos de dificuldade. Ao terminar de ler, você terá um framework de design junto com código Spring Boot 3 para construir por conta própria ou levar para uma entrevista.

2. Requisitos do Problema

2.1. Requisitos Funcionais

  • Criar links curtos a partir de URLs longas. Usuários podem definir seus próprios aliases, por exemplo /sale-2026.
  • Acessar um link curto redireciona para a URL original.
  • Links podem ter data de expiração (expires_at) e podem ser desativados pelo proprietário.
  • Contar cliques por link. Os dados podem ter um atraso de alguns segundos.

2.2. Escala

Suponha 100 milhões de novos links por mês e uma proporção de leitura/escrita de 100:1.

Escrita:   100M / (30 * 86400s)  ≈ 40 link/s   (pico ×10 ≈ 400/s)
Leitura:   40 × 100              ≈ 4.000 rps  (pico ×10 ≈ 40.000 rps)
Armazenamento 5 anos: 6 bilhões de links × ~500 bytes ≈ 3 TB
Espaço de código 7 caracteres base62: 62^7 ≈ 3,5 trilhões  (com folga para 6 bilhões)

Desses números podemos tirar duas conclusões. Primeiro, o sistema é tendencioso para leitura, portanto cache é o centro do design. Segundo, a carga de escrita é baixa, então um PostgreSQL primário é suficiente, sem necessidade de sharding no momento.

2.3. Restrições

  • Redirecionamento com p99 < 20 ms.
  • Código curto não deve ter colisões e não deve ser previsível em sequência, para que outros não possam adivinhar links privados.
  • O sistema continua redirecionando mesmo se analytics ou Kafka falhar.

3. Modelo de Dados e API

Dependências necessárias (versões gerenciadas pelo Spring Boot):

<dependencies>
    <dependency>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter-web</artifactId>
    </dependency>
    <dependency>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter-jdbc</artifactId>
    </dependency>
    <dependency>
        <groupId>org.postgresql</groupId>
        <artifactId>postgresql</artifactId>
        <scope>runtime</scope>
    </dependency>
    <dependency>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter-data-redis</artifactId>
    </dependency>
    <dependency>
        <groupId>org.springframework.kafka</groupId>
        <artifactId>spring-kafka</artifactId>
    </dependency>
    <dependency>
        <groupId>com.github.ben-manes.caffeine</groupId>
        <artifactId>caffeine</artifactId>
    </dependency>
</dependencies>

Schema PostgreSQL:

CREATE TABLE links (
    code        VARCHAR(16) PRIMARY KEY,
    long_url    TEXT        NOT NULL,
    owner_id    BIGINT,
    created_at  TIMESTAMPTZ NOT NULL DEFAULT now(),
    expires_at  TIMESTAMPTZ,
    disabled    BOOLEAN     NOT NULL DEFAULT false
);
CREATE INDEX idx_links_owner ON links (owner_id, created_at DESC);

CREATE SEQUENCE link_id_seq START 1 INCREMENT BY 1000;

Explicação das decisões principais:

  • code é a chave primária. A consulta mais comum é WHERE code = ?, então vai direto para o índice da chave primária.
  • idx_links_owner serve para a tela “meus links”.
  • link_id_seq aumenta 1000 unidades por vez. A cada chamada a nextval, a aplicação recebe um bloco inteiro de IDs (seção 4.2).

Duas APIs principais:

POST /api/links          {"url": "https://shop.vn/...", "alias": null}
  -> 201 {"code": "0bX3kQz", "shortUrl": "https://nonla.ly/0bX3kQz"}
  -> 409 se o alias já existe

GET  /{code}
  -> 302 Location: https://shop.vn/...
  -> 404 se não existe / expirou / está desativado

Nota: Use 302, não 301. Navegadores fazem cache permanente de 301. Assim você perde dados de cliques e não consegue desativar links já espalhados.

4. Geração de Código Curto

4.1. Escolher Estratégia

Método Vantagens Desvantagens
Hash de URL (MD5 depois corta 7 caracteres) Sem estado necessário Pode haver colisão, precisa verificar e tentar novamente
7 caracteres aleatórios Simples Quanto mais links, mais fácil colidir; cada criação precisa verificar BD
Contador + base62 Nunca colide Revela sequência, precisa de uma fonte de IDs centralizada

Este artigo escolhe o terceiro método e contorna suas duas desvantagens. Para não revelar a sequência, embaralhamos os IDs usando uma bijeção. Para reduzir a dependência de uma fonte de IDs centralizada, alocamos IDs em blocos.

import java.math.BigInteger;

public final class ShortCode {
    private static final String ALPHABET =
            "0123456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ";
    private static final int LENGTH = 7;
    private static final BigInteger SPACE = BigInteger.valueOf(62).pow(LENGTH);
    // Número ímpar, não divisível por 31 => coprimo com 62^7
    private static final BigInteger MULTIPLIER = BigInteger.valueOf(1_580_030_173L);

    private ShortCode() {}

    public static String of(long id) {
        if (id < 0 || id >= SPACE.longValue()) {
            throw new IllegalArgumentException("ID fora do espaço de código: " + id);
        }
        long mixed = BigInteger.valueOf(id).multiply(MULTIPLIER).mod(SPACE).longValue();
        char[] out = new char[LENGTH];
        for (int i = LENGTH - 1; i >= 0; i--) {
            out[i] = ALPHABET.charAt((int) (mixed % 62));
            mixed /= 62;
        }
        return new String(out);
    }
}

Como funciona:

  • A multiplicação modular id × MULTIPLIER mod 62^7 é uma bijeção porque MULTIPLIER é coprimo com 62^7. Cada ID produz exatamente um código, e dois IDs diferentes nunca produzem o mesmo código.
  • O loop converte o número para base62 e o preenche com zeros à esquerda, então o código sempre tem exatamente 7 caracteres.
  • IDs 1 e 2 produzem códigos diferentes já no segundo caractere, portanto é difícil adivinhar o próximo link.
  • BigInteger evita overflow de long ao multiplicar. Em uma carga de 400 escritas/s, esse custo é negligenciável.

Este método apenas torna o código difícil de adivinhar, não o criptografa. Quem conhecer o MULTIPLIER ainda consegue reverter. Links realmente privados precisam de um mecanismo de autenticação adicional.

4.2. Alocar IDs em Blocos

Chamar o BD para cada ID se torna um gargalo. Em vez disso, cada instância pega um bloco de 1000 IDs e os distribui gradualmente na memória:

import java.util.concurrent.locks.ReentrantLock;
import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.stereotype.Component;

@Component
public class IdBlockAllocator {
    private static final int BLOCK_SIZE = 1000; // deve corresponder ao INCREMENT BY
    private final JdbcTemplate jdbc;
    private final ReentrantLock lock = new ReentrantLock();
    private long next;
    private long end;

    public IdBlockAllocator(JdbcTemplate jdbc) {
        this.jdbc = jdbc;
    }

    public long nextId() {
        lock.lock();
        try {
            if (next >= end) {
                Long start = jdbc.queryForObject("SELECT nextval('link_id_seq')", Long.class);
                next = start;
                end = start + BLOCK_SIZE;
            }
            return next++;
        } finally {
            lock.unlock();
        }
    }
}

Pontos a observar:

  • nextval é uma operação atômica. Duas instâncias nunca recebem o mesmo bloco, então não é necessário um lock distribuído.
  • Com 400 escritas/s, cada instância toca o BD apenas uma vez a cada poucos segundos.
  • O código usa ReentrantLock em vez de synchronized. No Java 21, threads virtuais ficam “presos” em um carrier thread ao esperar I/O dentro de um bloco synchronized.
  • Um reinício da instância descartará o restante do bloco. O espaço de código tem 3,5 trilhões de valores, então isso é aceitável.
import java.net.URI;
import java.time.Duration;
import org.springframework.dao.DuplicateKeyException;
import org.springframework.data.redis.core.StringRedisTemplate;
import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.stereotype.Service;

@Service
public class LinkWriteService {
    static final String KEY = "link:";
    private final JdbcTemplate jdbc;
    private final StringRedisTemplate redis;
    private final IdBlockAllocator allocator;

    public LinkWriteService(JdbcTemplate jdbc, StringRedisTemplate redis, IdBlockAllocator allocator) {
        this.jdbc = jdbc;
        this.redis = redis;
        this.allocator = allocator;
    }

    public String create(String longUrl, String alias, Long ownerId) {
        String scheme = URI.create(longUrl).getScheme();
        if (!"http".equals(scheme) && !"https".equals(scheme)) {
            throw new IllegalArgumentException("Apenas http/https são aceitos");
        }
        String code = (alias != null) ? alias : ShortCode.of(allocator.nextId());
        try {
            jdbc.update("INSERT INTO links(code, long_url, owner_id) VALUES (?, ?, ?)",
                    code, longUrl, ownerId);
        } catch (DuplicateKeyException e) {
            throw new AliasTakenException(code);
        }
        redis.opsForValue().set(KEY + code, longUrl, Duration.ofHours(24));
        return code;
    }
}

class AliasTakenException extends RuntimeException {
    AliasTakenException(String code) { super("Alias já existe: " + code); }
}

O fluxo de processamento tem quatro etapas:

  1. Verificar o scheme para bloquear javascript: ou file:. Se pular essa etapa, seu serviço se torna uma ferramenta para distribuir links maliciosos.
  2. Obter o código: usar um alias personalizado ou gerar um código a partir do ID.
  3. INSERT. A restrição de chave primária é a camada final de proteção contra duplicação de códigos.
  4. Escrever sempre no Redis (write-through) para que o primeiro redirecionamento não precise acessar o BD.

Em produção, aliases personalizados devem usar caracteres ou comprimento diferentes do código gerado automaticamente, por exemplo, exigir um - ou ter pelo menos 8 caracteres. Caso contrário, o alias de um usuário pode “reivindicar” um código que o gerador criará mais tarde. Então o gerador encontraria DuplicateKeyException, enquanto seu propósito é nunca ter colisões.

6. Fluxo de Redirecionamento e Cache em Múltiplas Camadas

Com 40.000 rps no pico, a maioria das requisições deve ser servida sem acessar o BD. Usamos três camadas: Caffeine na JVM, Redis compartilhado, e por último PostgreSQL.

import com.github.benmanes.caffeine.cache.Cache;
import com.github.benmanes.caffeine.cache.Caffeine;
import java.time.Duration;
import java.util.List;
import java.util.Optional;
import org.springframework.data.redis.core.StringRedisTemplate;
import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.stereotype.Service;

@Service
public class LinkReadService {
    private static final String NOT_FOUND = ""; // sentinel para cache negativo
    private final Cache<String, String> local = Caffeine.newBuilder()
            .maximumSize(100_000)
            .expireAfterWrite(Duration.ofSeconds(30))
            .build();
    private final JdbcTemplate jdbc;
    private final StringRedisTemplate redis;

    public LinkReadService(JdbcTemplate jdbc, StringRedisTemplate redis) {
        this.jdbc = jdbc;
        this.redis = redis;
    }

    public Optional<String> resolve(String code) {
        String url = local.get(code, this::loadFromRedisOrDb);
        return url.isEmpty() ? Optional.empty() : Optional.of(url);
    }

    private String loadFromRedisOrDb(String code) {
        String key = LinkWriteService.KEY + code;
        String cached = redis.opsForValue().get(key);
        if (cached != null) return cached;

        List<String> rows = jdbc.queryForList("""
                SELECT long_url FROM links
                WHERE code = ? AND NOT disabled
                  AND (expires_at IS NULL OR expires_at > now())""", String.class, code);
        String url = rows.isEmpty() ? NOT_FOUND : rows.getFirst();
        redis.opsForValue().set(key, url, url.isEmpty() ? Duration.ofMinutes(5) : Duration.ofHours(24));
        return url;
    }
}

Pontos-chave:

  • O local.get(code, loader) do Caffeine agrupa carregamentos simultâneos para a mesma chave. Se 5.000 requisições perguntarem por um código não está no cache, apenas uma thread acessa Redis/BD. As outras threads aguardam o resultado. Esta é a forma de evitar cache stampede dentro de uma instância.
  • Cache negativo: códigos inexistentes também são colocados em cache como uma string vazia por 5 minutos. Assim, bots que sondam códigos aleatórios não conseguem derrubar o BD.
  • Caffeine não coloca null em cache, então o código usa o sentinel "" em vez de null.

O controller retorna 302 e envia eventos de clique para Kafka:

import java.net.URI;
import java.util.Map;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.kafka.core.KafkaTemplate;
import org.springframework.web.bind.annotation.*;

@RestController
public class LinkController {
    private final LinkWriteService writer;
    private final LinkReadService reader;
    private final KafkaTemplate<String, String> kafka;

    public LinkController(LinkWriteService writer, LinkReadService reader,
                          KafkaTemplate<String, String> kafka) {
        this.writer = writer;
        this.reader = reader;
        this.kafka = kafka;
    }

    public record CreateLinkRequest(String url, String alias) {}

    @PostMapping("/api/links")
    public ResponseEntity<Map<String, String>> create(@RequestBody CreateLinkRequest req) {
        String code = writer.create(req.url(), req.alias(), null);
        return ResponseEntity.status(HttpStatus.CREATED)
                .body(Map.of("code", code, "shortUrl", "https://nonla.ly/" + code));
    }

    @GetMapping("/{code:[0-9a-zA-Z-]{1,16}}")
    public ResponseEntity<Void> redirect(@PathVariable String code) {
        return reader.resolve(code)
                .map(url -> {
                    kafka.send("link-clicks", code, String.valueOf(System.currentTimeMillis()));
                    return ResponseEntity.status(HttpStatus.FOUND).location(URI.create(url)).<Void>build();
                })
                .orElse(ResponseEntity.notFound().build());
    }

    @ExceptionHandler(AliasTakenException.class)
    ResponseEntity<String> conflict(AliasTakenException e) {
        return ResponseEntity.status(HttpStatus.CONFLICT).body(e.getMessage());
    }
}

kafka.send é assíncrono, portanto o redirecionamento não precisa esperar o broker. Um consumer separado agrupa eventos por code a cada poucos segundos e escreve em lote na tabela de estatísticas. Configure o producer para priorizar baixa latência:

spring.kafka.producer.acks=1
spring.kafka.producer.properties.linger.ms=20
spring.kafka.producer.properties.max.block.ms=50

max.block.ms=50 evita que a thread de redirecionamento fique travada por muito tempo se o broker cair, alinhado com a restrição “analytics falhando ainda permite redirecionamento”. Aceitamos perder alguns eventos de clique nesse cenário.

7. Pontos de Dificuldade e Soluções

7.1. Condições de Corrida

  • Duas pessoas definem o mesmo alias: não faça SELECT e depois INSERT. Dois requests podem ambos ver “não existe” e então gravar. Deixe a restrição unique do BD decidir e capture DuplicateKeyException como na seção 5.
  • Várias instâncias gerando código: a sequência aloca blocos separados para cada instância, então sem condições de corrida.

7.2. Consistência

  • Ler logo após criar: se ler de uma read replica, a latência de replicação pode fazer um link recém-criado retornar 404. Escrever no Redis imediatamente ao criar (write-through) mascara esse atraso.
  • Desativar link: atualize o BD e então delete a chave Redis. Caffeine ainda mantém a versão antiga por até 30 segundos em cada instância. Se precisar bloquear imediatamente, por exemplo um link fraudulento, publique um evento de invalidação via Redis Pub/Sub ou Kafka para que as instâncias limpem seus caches locais.
  • Expiração: a TTL do cache deve ser min(24h, expires_at - now). Caso contrário, links já expirados ainda podem redirecionar por até 24 horas.

7.3. Picos de Carga

  • Hot key: um link viral concentra todo o tráfego em um shard Redis. O Caffeine local absorve a maior parte da carga. Cada instância consulta Redis apenas cerca de uma vez a cada 30 segundos por chave.
  • Cold start: uma nova instância não tem nada no Caffeine, mas o recurso de coalescência de carga na seção 6 protege o Redis de ser bombardeado.
  • Spam criando links: implemente rate limiting por chave de API ou IP, por exemplo usando token bucket no Redis, antes de chamar LinkWriteService.

8. Testando o Comportamento da Geração de Código

A propriedade mais importante do sistema é que códigos nunca colidem, têm comprimento fixo e não revelam sequência:

import static java.util.stream.Collectors.toSet;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;

import java.util.Set;
import java.util.stream.LongStream;
import org.junit.jupiter.api.Test;

class ShortCodeTest {

    @Test
    void geraCodigoComTamanhoeAlfabetoCorretos() {
        assertThat(ShortCode.of(1)).hasSize(7).matches("[0-9a-zA-Z]{7}");
    }

    @Test
    void naoHaColusaoEmCemMilIdsConsecutivos() {
        Set<String> codes = LongStream.range(0, 100_000).mapToObj(ShortCode::of).collect(toSet());
        assertThat(codes).hasSize(100_000);
    }

    @Test
    void idsConsecutivosNaoGeramCodigosConsecutivos() {
        assertThat(ShortCode.of(1).substring(0, 5)).isNotEqualTo(ShortCode.of(2).substring(0, 5));
    }

    @Test
    void rejeitaIdsForaDoEspacoDeCodigo() {
        assertThatThrownBy(() -> ShortCode.of(-1)).isInstanceOf(IllegalArgumentException.class);
    }
}

O segundo teste verifica uma amostra, não prova a bijeção matematicamente. A bijeção vem da matemática (gcd igual a 1). O teste apenas protege você de alguém acidentalmente mudar MULTIPLIER para um número par.

9. Erros Comuns e Quando Não Complicar

Nota: Mudar MULTIPLIER ou ALPHABET após o lançamento causará colisão entre IDs novos e antigos. Trate-os como constantes permanentes.

  • Escrever cada clique diretamente na tabela links (UPDATE ... SET clicks = clicks + 1) cria contenção de lock na linha para links virais. Use Kafka para agregação.
  • Sem limite de tamanho no Caffeine, buscas aleatórias em massa causarão OutOfMemoryError.
  • Para um produto interno com apenas centenas de links por dia, uma tabela PostgreSQL e um controller são suficientes. Redis, Kafka e alocação de IDs em blocos só valem a pena quando a carga realmente exigir.

10. Conclusão

O design de um URL shortener de alta escala gira em torno de três decisões. Gerar códigos com contador alocado em blocos e uma bijeção, para garantir sem colisões mas difícil de adivinhar. Usar cache em múltiplas camadas com caching negativo e coalescência de carga, para aguentar hot keys e varreduras de códigos. Tirar tudo não essencial, como contagem de cliques, do caminho de redirecionamento via Kafka.

Comece com a estimativa de escala. Os números da seção 2 dizem quando adicionar cada camada, em vez de construir tudo desde o primeiro dia.

system-designspring-bootrediskafkapostgresql