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_ownerserve para a tela “meus links”.link_id_seqaumenta 1000 unidades por vez. A cada chamada anextval, 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ão301. Navegadores fazem cache permanente de301. 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 porqueMULTIPLIERé coprimo com62^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.
BigIntegerevita overflow delongao 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
ReentrantLockem vez desynchronized. No Java 21, threads virtuais ficam “presos” em um carrier thread ao esperar I/O dentro de um blocosynchronized. - 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.
5. Fluxo de Criação de Link
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:
- Verificar o scheme para bloquear
javascript:oufile:. Se pular essa etapa, seu serviço se torna uma ferramenta para distribuir links maliciosos. - Obter o código: usar um alias personalizado ou gerar um código a partir do ID.
INSERT. A restrição de chave primária é a camada final de proteção contra duplicação de códigos.- 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
nullem cache, então o código usa o sentinel""em vez denull.
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
SELECTe depoisINSERT. Dois requests podem ambos ver “não existe” e então gravar. Deixe a restrição unique do BD decidir e captureDuplicateKeyExceptioncomo 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
MULTIPLIERouALPHABETapó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.