Projetando um URL Shortener em Escala: Guia Definitivo de System Design

Software Architect
Pós-graduado em arquitetura de software e soluções. Conecto profundidade técnica com resultados de negócio para entregar produtos que as pessoas realmente usam. Também mentoro desenvolvedores e criadores em programas ao vivo, podcasts e iniciativas de comunidade focadas em tecnologia inclusiva.
Productos gratuitos y de pago para convertir ideas en una base que puedas lanzar.
13 productos disponiblesContinúa explorando temas similares

# Projetando um Sistema Global de Notificações: Guia Completo de System Design Resumo: Um guia de arquitetura em nível de produção para plataformas de notificação multicanal (pus

# Projetando o Uber em Escala: Um Guia Completo de System Design Resumo: Um guia completo de system design para construir uma plataforma de compartilhamento de corridas lidando c

# Projetando o WhatsApp em Escala Planetária: Guia Completo de System Design Resumo: Um guia de arquitetura em nível de produção para construir um sistema estilo WhatsApp com con
Una lista de 47 puntos para encontrar errores, riesgos de seguridad y problemas de rendimiento antes del lanzamiento.
Templates probados en producción, usados por desarrolladores. Ahorra semanas de setup en tu próximo proyecto.

Resumo: Um guia completo para projetar um encurtador de URL como bit.ly e TinyURL, cobrindo geração de chave, redirecionamento, cache, analytics em tempo real, segurança, multi-região e operação em escala global.
Publicado: Fevereiro 2026 Tempo de leitura: 60 minutos Palavras-chave: #SystemDesign #URLShortener #Bitly #TinyURL #SistemasDistribuidos #Escalabilidade #Cache #Analytics #EntrevistaTecnica
Uma URL curta parece um detalhe de produto. Na prática, ela é um problema de infraestrutura sério.
Quando um usuário clica em https://s.ua/abc123x, você tem poucos milissegundos para resolver o código curto, validar expiração, decidir política de redirect, registrar evento de analytics, bloquear abuso, e devolver resposta de forma confiável. Tudo isso sem travar o caminho quente.
A maior parte das pessoas subestima esse tipo de sistema porque o produto final é simples. Entrevistadores fazem o mesmo problema justamente por isso: ele expõe maturidade em arquitetura, modelagem de dados, trade-offs de consistência e pensamento operacional.
Este guia segue o mesmo padrão técnico da série: começamos por requisitos, fazemos cálculos, desenhamos arquitetura, e entramos no detalhe das decisões que separam um sistema didático de um sistema pronto para produção.
A ordem aqui importa. Sem requisitos claros, você escolhe tecnologia cedo demais e quase sempre otimiza o lugar errado.
Obrigatórios (MVP sério):
Importantes (produção):
s.ua/minha-campanha).Opcional (evolução):
| Requisito | Meta | Motivo |
|---|---|---|
| Disponibilidade | 99.99% | Link quebrado afeta muitos canais ao mesmo tempo |
| Latência de redirect | < 50ms p99 (app) | Caminho quente precisa ser imediato |
| Latência de criação | < 200ms p99 | UX de criação não pode parecer lenta |
| Consistência de mapeamento | Forte | shortCode -> longUrl não pode oscilar |
| Consistência de analytics | Eventual | aceitável atraso de segundos/minutos |
| Durabilidade | sem perda após ACK | perda de mapeamento é perda de confiança |
| Escalabilidade | horizontal | crescimento de leitura é muito maior |
Esse sistema é read-heavy por natureza. Se você modela como sistema de escrita, erra no primeiro dia.
A decisão principal não é banco SQL ou NoSQL. A decisão principal é: como garantir lookup de redirect extremamente rápido e barato no caminho quente, sem sacrificar integridade de mapeamento.
Em entrevista, cálculo não é enfeite. Ele define o desenho.
Links novos por mês: 100,000,000
Segundos por mês (30 dias): 2,592,000
QPS médio de escrita: 100,000,000 / 2,592,000 ~= 38.6 ~ 40/s
QPS pico (5x): 200/s
Razão leitura/escrita: 100:1
QPS médio de leitura: 4,000/s
QPS pico: 20,000/s
Assumindo 300 bytes por registro de mapeamento (com metadados básicos):
100M/mês * 12 * 10 anos = 12 bilhões de links
12,000,000,000 * 300 bytes = 3.6 TB (dados crus)
Com índice + overhead (2x): 7.2 TB
Com replicação (3x): 21.6 TB
Com Base62:
62^6 = 56.8 bilhões
62^7 = 3.52 trilhões
Para 12 bilhões de links em 10 anos:
Decisão recomendada: 7 chars no caminho padrão.
Se 20% dos links recebem 80% dos cliques:
Assuma 20M chaves quentes
20,000,000 * 300 bytes = 6 GB
Com overhead de estrutura (Redis): ~10-14 GB
Cache quente cabe com folga em cluster pequeno.
export interface ShortenRequest {
longUrl: string;
customAlias?: string;
expiresAt?: string; // ISO 8601
campaign?: string;
tags?: string[];
}
export interface ShortenResponse {
shortCode: string;
shortUrl: string;
longUrl: string;
createdAt: string;
expiresAt: string | null;
qrCodeUrl?: string;
}
export interface RedirectResult {
location: string;
statusCode: 301 | 302;
}
export interface UrlStatsResponse {
shortCode: string;
totalClicks: number;
uniqueClicks: number;
topCountries: Array<{ code: string; clicks: number }>;
topReferrers: Array<{ domain: string; clicks: number }>;
topDevices: Array<{ device: string; clicks: number }>;
}
POST /api/v1/urls
GET /:shortCode
GET /api/v1/urls/:shortCode
DELETE /api/v1/urls/:shortCode
GET /api/v1/urls/:shortCode/stats
GET /api/v1/users/:userId/urls?cursor=...&limit=...
curl -X POST https://s.ua/api/v1/urls \
-H "Authorization: Bearer <token>" \
-H "Content-Type: application/json" \
-d '{
"longUrl": "https://example.com/campanha/lancamento?utm_source=mail",
"expiresAt": "2026-12-31T23:59:59Z",
"campaign": "lancamento-q4"
}'
Resposta esperada:
{
"shortCode": "aZ8k2Pm",
"shortUrl": "https://s.ua/aZ8k2Pm",
"longUrl": "https://example.com/campanha/lancamento?utm_source=mail",
"createdAt": "2026-02-15T18:20:41.000Z",
"expiresAt": "2026-12-31T23:59:59.000Z"
}
import type { Request, Response } from "express";
export async function redirectHandler(req: Request, res: Response): Promise<void> {
const shortCode = req.params.shortCode;
if (!/^[a-zA-Z0-9_-]{1,32}$/.test(shortCode)) {
res.status(400).json({ error: "invalid_short_code" });
return;
}
const cacheKey = `url:${shortCode}`;
let record = await redis.get(cacheKey);
if (!record) {
record = await urlRepo.findActiveByShortCode(shortCode);
if (!record) {
res.status(404).json({ error: "not_found" });
return;
}
if (record.expiresAt && new Date(record.expiresAt) <= new Date()) {
res.status(410).json({ error: "expired" });
return;
}
await redis.set(cacheKey, JSON.stringify(record), "EX", 3600);
}
// Fire-and-forget analytics
analyticsProducer.publishClick({
shortCode,
ts: Date.now(),
ip: req.ip,
userAgent: req.headers["user-agent"] ?? "",
referrer: req.headers["referer"] ?? "",
}).catch(() => undefined);
const payload = typeof record === "string" ? JSON.parse(record) : record;
res.redirect(payload.redirectType ?? 302, payload.longUrl);
}
A pergunta central é: como gerar um shortCode único, curto, barato e operável?
const ALPHABET = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789";
export function encodeBase62(n: bigint): string {
if (n === 0n) return ALPHABET[0];
let x = n;
let out = "";
while (x > 0n) {
const r = Number(x % 62n);
out = ALPHABET[r] + out;
x = x / 62n;
}
return out;
}
Pro: simples, sem colisão, fácil de depurar.
Contra: previsível e enumerável.
import { createHash } from "crypto";
export function hashBasedCode(longUrl: string): string {
const hex = createHash("sha256").update(longUrl).digest("hex");
const slice = hex.slice(0, 12);
const n = BigInt(`0x${slice}`);
return encodeBase62(n).padStart(7, "a").slice(0, 7);
}
Pro: sem contador central.
Contra: colisão inevitável com truncamento, precisa estratégia de retry.
Pro: caminho de escrita previsível, colisão praticamente eliminada.
Contra: adiciona serviço operacional extra.
// 41 bits timestamp | 10 bits worker | 12 bits sequence
Pro: distribuído e ordenável no tempo.
Contra: exige controle de clock e worker IDs.
export function randomCode(len = 7): string {
const chars = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789";
let out = "";
for (let i = 0; i < len; i++) {
out += chars[Math.floor(Math.random() * chars.length)];
}
return out;
}
Pro: fácil de implementar.
Contra: cresce custo de colisão com volume alto.
| Abordagem | Colisão | Operação | Escala | Enumeração |
|---|---|---|---|---|
| ID + Base62 | Zero | simples | média | alta |
| Hash truncado | média | média | alta | baixa |
| KGS | muito baixa | alta | alta | baixa |
| Snowflake | muito baixa | média | muito alta | média |
| Random + check | crescente | simples | média | baixa |
KGS é útil quando você quer previsibilidade total no caminho de criação.
class LocalKeyBuffer {
private keys: string[] = [];
private readonly refillThreshold = 100;
constructor(private readonly kgsClient: { fetchBatch: (n: number) => Promise<string[]> }) {}
async nextKey(): Promise<string> {
if (this.keys.length <= this.refillThreshold) {
void this.refill();
}
if (this.keys.length === 0) {
// fallback síncrono em cold start
this.keys.push(...await this.kgsClient.fetchBatch(1000));
}
const key = this.keys.shift();
if (!key) throw new Error("key_buffer_empty");
return key;
}
private async refill(): Promise<void> {
const batch = await this.kgsClient.fetchBatch(2000);
this.keys.push(...batch);
}
}
Se seu produto depende de analytics por clique, 301 pode reduzir visibilidade porque parte dos acessos não volta para sua borda após cache do cliente.
| Plano | Redirect padrão |
|---|---|
| Free | 302 |
| Pro | 302 configurável |
| Enterprise | 301/302 por campanha |
CREATE TABLE url_mapping (
short_code VARCHAR(32) PRIMARY KEY,
long_url TEXT NOT NULL,
user_id VARCHAR(64) NULL,
created_at TIMESTAMP NOT NULL DEFAULT NOW(),
expires_at TIMESTAMP NULL,
is_active BOOLEAN NOT NULL DEFAULT TRUE,
redirect_type SMALLINT NOT NULL DEFAULT 302,
click_count BIGINT NOT NULL DEFAULT 0,
campaign VARCHAR(128) NULL,
tags JSONB NULL,
CHECK (redirect_type IN (301, 302))
);
CREATE INDEX idx_url_mapping_user_created
ON url_mapping(user_id, created_at DESC);
CREATE INDEX idx_url_mapping_active_exp
ON url_mapping(is_active, expires_at);
CREATE TABLE click_events (
event_id String,
short_code String,
occurred_at DateTime,
country LowCardinality(String),
referrer_domain LowCardinality(String),
device_type LowCardinality(String),
browser LowCardinality(String),
os LowCardinality(String),
is_bot UInt8,
ip_hash String
)
ENGINE = MergeTree
PARTITION BY toYYYYMM(occurred_at)
ORDER BY (short_code, occurred_at);
short_code -> long_url (lookup ultra frequente).Pro: transação forte, índice robusto, operação conhecida.
Contra: escala de leitura global pode exigir réplica/shard cedo.
Pro: lookup por chave é horizontal por natureza.
Contra: consultas ad-hoc por usuário/campanha ficam piores sem modelagem extra.
Cache é onde se ganha p99 no redirect.
async function resolveShortCode(shortCode: string): Promise<string | null> {
const l1 = lru.get(shortCode);
if (l1) return l1;
const l2 = await redis.get(`url:${shortCode}`);
if (l2) {
lru.set(shortCode, l2);
return l2;
}
const row = await repo.findActiveByShortCode(shortCode);
if (!row) {
await redis.set(`url:missing:${shortCode}`, "1", "EX", 45);
return null;
}
await redis.set(`url:${shortCode}`, row.longUrl, "EX", 3600);
lru.set(shortCode, row.longUrl);
return row.longUrl;
}
Com distribuição de acesso típica:
Meta operacional realista: hit total > 80%.
O maior erro nesse tema é gravar analytics síncrono no caminho de redirect.
Redirect nunca espera analytics.
type ClickEvent = {
eventId: string;
shortCode: string;
occurredAt: string;
ipHash: string;
userAgent: string;
referrer: string;
acceptLanguage?: string;
edgeRegion: string;
};
export async function publishClick(event: ClickEvent): Promise<void> {
await kafkaProducer.send({
topic: "click-events-v1",
messages: [{ key: event.shortCode, value: JSON.stringify(event) }],
acks: 1,
});
}
export function enrich(event: ClickEvent) {
return {
...event,
country: geoip.lookup(event.ipHash)?.country ?? "UNK",
deviceType: parseDevice(event.userAgent),
browser: parseBrowser(event.userAgent),
referrerDomain: safeDomain(event.referrer),
isBot: botHeuristic(event.userAgent),
};
}
-- Cliques diários por link
SELECT toDate(occurred_at) AS d, count() AS clicks
FROM click_events
WHERE short_code = 'aZ8k2Pm'
AND occurred_at >= now() - INTERVAL 30 DAY
GROUP BY d
ORDER BY d;
-- Top países de um link
SELECT country, count() AS clicks
FROM click_events
WHERE short_code = 'aZ8k2Pm'
GROUP BY country
ORDER BY clicks DESC
LIMIT 10;
Alias customizado agrega valor de produto e risco operacional.
[a-zA-Z0-9-_].admin, api, help, login).INSERT INTO url_mapping (short_code, long_url, user_id, redirect_type)
VALUES ($1, $2, $3, 302)
ON CONFLICT (short_code) DO NOTHING;
409 conflict quando alias já existe.Dois modelos se complementam:
No redirect, se expires_at <= now(), responde 410 Gone e evita redirect.
DELETE FROM url_mapping
WHERE expires_at IS NOT NULL
AND expires_at < NOW() - INTERVAL '7 days'
AND is_active = false
LIMIT 50000;
Sem rate limiting, seu produto vira alvo barato para abuso.
type BucketState = {
tokens: number;
lastRefillMs: number;
};
function refill(state: BucketState, ratePerSec: number, capacity: number, nowMs: number): BucketState {
const elapsed = (nowMs - state.lastRefillMs) / 1000;
const add = elapsed * ratePerSec;
return {
tokens: Math.min(capacity, state.tokens + add),
lastRefillMs: nowMs,
};
}
function consume(state: BucketState, cost = 1): [BucketState, boolean] {
if (state.tokens >= cost) {
return [{ ...state, tokens: state.tokens - cost }, true];
}
return [state, false];
}
| Plano | Criação/hora | Redirect/min |
|---|---|---|
| Anônimo | 10 | 100 |
| Free | 100 | 1000 |
| Pro | 1000 | 10000 |
| Enterprise | custom | custom |
URL shortener é vetor comum de abuso. Segurança aqui é funcionalidade de produto.
export function validateLongUrl(input: string): { ok: boolean; reason?: string } {
try {
const u = new URL(input);
if (!["http:", "https:"].includes(u.protocol)) {
return { ok: false, reason: "unsupported_protocol" };
}
if (!u.hostname || u.hostname.length < 3) {
return { ok: false, reason: "invalid_host" };
}
if (isPrivateAddress(u.hostname)) {
return { ok: false, reason: "private_address_not_allowed" };
}
if (isBlockedDomain(u.hostname)) {
return { ok: false, reason: "domain_blocked" };
}
return { ok: true };
} catch {
return { ok: false, reason: "invalid_url" };
}
}
Se usar IDs sequenciais sem ofuscação, atacante varre grande parte da base.
Mitigações:
Para produto global, 50ms p99 sem edge é improvável.
Para links muito quentes, CDN pode responder redirect sem tocar app.
Cuidado: isso impacta nível de detalhe de analytics por clique.
TLS + edge: 10-15ms
LB + app routing: 2-5ms
Redis lookup: 1-3ms
Fallback DB (cache miss): 8-20ms
Response write: 2-5ms
Total p99 (cache hit): 20-35ms
class CircuitBreaker {
private failures = 0;
private openUntil = 0;
constructor(private readonly threshold = 5, private readonly coolDownMs = 5000) {}
async run<T>(fn: () => Promise<T>, fallback: () => Promise<T>): Promise<T> {
const now = Date.now();
if (now < this.openUntil) return fallback();
try {
const res = await fn();
this.failures = 0;
return res;
} catch {
this.failures += 1;
if (this.failures >= this.threshold) {
this.openUntil = now + this.coolDownMs;
}
return fallback();
}
}
}
UNK.Problema: mistura OLTP de redirect com analítico pesado.
Correto: separar mapeamento de URL e analytics em stores próprios.
Problema: aumenta latência e propaga falha de analytics para UX.
Correto: publicar evento async e devolver redirect primeiro.
Problema: colisão cresce com volume, custo de retry aumenta.
Correto: snowflake/base62 ou KGS para previsibilidade.
Problema: vira plataforma de abuso rapidamente.
Correto: validação de protocolo, host, reputação e políticas de risco.
Problema: crescimento de custo e acurácia ruim em dados antigos.
Correto: lazy expiration + cleanup ativo + retention clara.
Problema: perde observabilidade de clique em alguns cenários de cache.
Correto: 302 por padrão quando analytics importa.
Problema: scraping massivo de links.
Correto: chave não previsível + rate limit + detecção de padrão.
Um URL shortener parece pequeno no produto, mas é grande na engenharia.
A diferença entre um sistema didático e um sistema de produção está em três pontos:
Se você cobrir requisitos, cálculos, algoritmo de chave, cache, analytics e confiabilidade com clareza de trade-off, você entrega uma resposta de nível senior em entrevista e um desenho viável para produção.
| Item | Valor |
|---|---|
| Escrita média | ~40 req/s |
| Escrita pico | ~200 req/s |
| Leitura média | ~4,000 req/s |
| Leitura pico | ~20,000 req/s |
| Espaço de chave recomendado | 7 chars Base62 |
| Hit ratio alvo de cache | > 80% |
| Redirect p99 alvo | < 50ms |
| Tema | Decisão recomendada |
|---|---|
| Chave curta | Snowflake + Base62 (ou KGS) |
| Redirect padrão | 302 quando analytics é requisito |
| Analytics | assíncrono via fila |
| Banco de mapeamento | KV/SQL indexado conforme time |
| Banco de analytics | coluna (ClickHouse) |
| Expiração | lazy + cleanup ativo |
| Proteção | WAF + rate limit + URL validation |
import { randomUUID } from "crypto";
type CreateInput = {
longUrl: string;
userId?: string;
customAlias?: string;
expiresAt?: string;
redirectType?: 301 | 302;
campaign?: string;
tags?: string[];
};
type CreateOutput = {
shortCode: string;
shortUrl: string;
createdAt: string;
};
export class UrlCreationService {
constructor(
private readonly keyProvider: { next: () => Promise<string> },
private readonly repo: {
insert: (row: {
shortCode: string;
longUrl: string;
userId: string | null;
expiresAt: string | null;
redirectType: 301 | 302;
campaign: string | null;
tags: string[];
}) => Promise<boolean>;
exists: (shortCode: string) => Promise<boolean>;
},
) {}
async create(input: CreateInput): Promise<CreateOutput> {
const validation = validateLongUrl(input.longUrl);
if (!validation.ok) {
throw new Error(validation.reason ?? "invalid_url");
}
const shortCode = input.customAlias
? await this.reserveAlias(input.customAlias)
: await this.generateSystemCode();
const createdAt = new Date().toISOString();
const ok = await this.repo.insert({
shortCode,
longUrl: input.longUrl,
userId: input.userId ?? null,
expiresAt: input.expiresAt ?? null,
redirectType: input.redirectType ?? 302,
campaign: input.campaign ?? null,
tags: input.tags ?? [],
});
if (!ok) {
throw new Error("insert_failed");
}
return {
shortCode,
shortUrl: `https://s.ua/${shortCode}`,
createdAt,
};
}
private async reserveAlias(alias: string): Promise<string> {
const normalized = alias.trim();
if (!/^[a-zA-Z0-9_-]{3,32}$/.test(normalized)) {
throw new Error("invalid_alias");
}
if (isReservedAlias(normalized)) {
throw new Error("reserved_alias");
}
const exists = await this.repo.exists(normalized);
if (exists) throw new Error("alias_conflict");
return normalized;
}
private async generateSystemCode(): Promise<string> {
for (let i = 0; i < 5; i++) {
const code = await this.keyProvider.next();
const exists = await this.repo.exists(code);
if (!exists) return code;
}
// fallback defensivo
return randomUUID().replace(/-/g, "").slice(0, 8);
}
}
function isReservedAlias(alias: string): boolean {
const reserved = new Set([
"api",
"admin",
"help",
"login",
"status",
"docs",
"metrics",
]);
return reserved.has(alias.toLowerCase());
}
url:{shortCode} -> JSON mapping (TTL 3600)
url:missing:{shortCode} -> "1" (TTL 45)
rl:user:{userId}:create -> token bucket state
rl:ip:{ip}:redirect -> token bucket state
stats:counter:{shortCode}:{yyyyMMdd} -> integer
v1:url:{shortCode}
v2:url:{shortCode}
Facilita migração de payload sem invalidação massiva arriscada.
type WindowCounter = {
minute: number;
clicks: number;
};
export function isSuspiciousBurst(windowSeries: WindowCounter[]): boolean {
if (windowSeries.length < 10) return false;
const last = windowSeries.at(-1)?.clicks ?? 0;
const prev = windowSeries.slice(-10, -1).map((x) => x.clicks);
const avg = prev.reduce((a, b) => a + b, 0) / prev.length;
return last > avg * 20 && last > 500;
}
Quando true, ativa regras:
43,200 minutos por mês
0.01% de budget = 4.32 minutos de indisponibilidade
Se estourar budget:
{
"eventVersion": "1.0",
"eventType": "url.click",
"eventId": "evt_01J8YH8S0ZJYV9P8R3P7A5Y2Q1",
"occurredAt": "2026-02-15T22:10:11.124Z",
"shortCode": "aZ8k2Pm",
"request": {
"edgeRegion": "sa-east-1",
"referrer": "https://news.example.com",
"userAgent": "Mozilla/5.0 ..."
},
"privacy": {
"ipHash": "sha256:6c57..."
}
}
| Tema | Escolha | Ganho | Custo |
|---|---|---|---|
| Key gen | Snowflake+Base62 | escala horizontal | controle de clock |
| Redirect | cache-first | p99 baixo | invalidação cuidadosa |
| Analytics | async | UX estável | consistência eventual |
| DB split | OLTP + colunar | custo/perf ótimo | mais operação |
| Global reads | multi-região | latência global | replicação mais complexa |
Este artigo serve como blueprint de referência para entrevistas e para implementação real em etapas.