Caso: Acortador de URLs Básico¶
Paso 1 — Requisitos y estimaciones¶
- Funcionales: acortar una URL larga; redirigir de la corta a la larga. (Fuera: analítica avanzada, URLs personalizadas.)
- Escala: 100 M URLs nuevas/día, ratio lectura:escritura 10:1, retención 10 años.
Estima QPS y almacenamiento
- Escrituras: 100 M / 10^5 ≈ 1 200 QPS. Lecturas: ≈ 12 000 QPS.
- Registros en 10 años: 100 M × 365 × 10 ≈ 365 000 M (3,65 × 10^11).
- Con ~100 bytes por registro: ≈ 36,5 TB.
- Longitud del código en Base62 (a-z, A-Z, 0-9): 62^7 ≈ 3,5 × 10^12 > 3,65 × 10^11 → 7 caracteres.
Paso 2 — Diseño de alto nivel¶
flowchart LR
C[Cliente] --> LB[Load balancer]
LB --> W[Web servers<br/>stateless]
W --> CACHE[(Redis<br/>código → URL)]
W --> DB[(BBDD clave-valor)]
W --> IDG[Generador de IDs<br/>Snowflake]
API:
POST /api/v1/urls { "longUrl": "https://..." } → 201 { "shortUrl": "https://sho.rt/aZ3k9Qx" }
GET /aZ3k9Qx → 301/302 Location: https://...
Trade-off 301 vs. 302
301 (permanente): el navegador cachea → menos carga, pero pierdes analítica. 302 (temporal): cada clic pasa por el servidor → analítica posible, más carga.
Paso 3 — Profundizar: cómo generar el código¶
| Opción | Cómo | Pros | Contras |
|---|---|---|---|
| Hash + colisiones | CRC32/MD5 de la URL, tomar 7 caracteres, si colisiona añadir sal | Misma URL → mismo código | Comprobar colisiones en BBDD (filtro de Bloom ayuda) |
| ID único + Base62 | ID de Snowflake convertido a Base62 | Sin colisiones | Código predecible; depende del generador |
private static final String ALPHABET = "0123456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ";
static String toBase62(long id) {
var sb = new StringBuilder();
do {
sb.append(ALPHABET.charAt((int) (id % 62)));
id /= 62;
} while (id > 0);
return sb.reverse().toString();
}
Generar IDs sin cuello de botella¶
- Snowflake en cada instancia: sin coordinación, pero códigos de 11 caracteres (64 bits en Base62).
- Rangos pre-asignados: un servicio central (o una secuencia de BBDD) entrega bloques de 1 000 IDs a cada instancia; la instancia los consume en memoria. Códigos cortos (7 caracteres) y casi sin coordinación.
- Para que no sean predecibles (y no se puedan enumerar todos los enlaces): barajar los bits del ID con una permutación reversible antes de codificar.
Flujo de redirección (camino caliente)¶
sequenceDiagram
participant U as Usuario
participant W as Web server
participant R as Redis
participant D as BBDD
participant K as Kafka
U->>W: GET /aZ3k9Qx
W->>R: GET url:aZ3k9Qx
alt en caché (≈ 90 %+ de las veces)
R-->>W: https://...
else fallo de caché
W->>D: SELECT long_url WHERE code = 'aZ3k9Qx'
D-->>W: https://...
W->>R: SET url:aZ3k9Qx (TTL 24 h)
end
W-->>U: 302 Location: https://...
W--)K: evento click (asíncrono, no bloquea la redirección)
Modelo de datos¶
| Campo | Tipo | Nota |
|---|---|---|
code |
CHAR(7) |
Clave primaria / de partición |
long_url |
TEXT |
Hasta ~2 KB |
owner_id |
BIGINT |
Opcional |
created_at, expires_at |
TIMESTAMP |
Expiración opcional |
Almacén: clave-valor (DynamoDB, Cassandra) encaja por acceso puro por clave y escala horizontal; PostgreSQL con
sharding por code también es válido para este volumen.
Paso 4 — Cierre¶
| Tema | Decisión |
|---|---|
| Caché | Redis con LRU; el 20 % de los enlaces recibe el 80 % de los clics; caché también en CDN para los más populares (con 301 o Cache-Control) |
| Analítica | Eventos de clic a Kafka → procesamiento en streaming → almacén analítico (ClickHouse, BigQuery). Nunca en el camino de la redirección |
| Expiración | expires_at + TTL en caché; limpieza periódica en segundo plano; opcionalmente reutilizar códigos caducados |
| Abuso | Rate limiting por IP/usuario al crear, listas de dominios maliciosos (Safe Browsing), página intermedia de aviso |
| Multi-región | Lecturas servidas desde réplicas regionales + caché local; escrituras con IDs sin colisión entre regiones (prefijo de región o rangos distintos) |
| Disponibilidad | Web stateless detrás de LB en varias AZs; si cae Redis, degradar a BBDD; si cae Kafka, buffer local de eventos |
| Monitorización | Latencia p99 de redirección, tasa de aciertos de caché, 404s, tasa de creación (detectar spam) |
¿Qué pasa si dos usuarios acortan la misma URL larga?
Depende del requisito: con hash obtienen el mismo código (deduplicación natural); con ID único, dos códigos
distintos (cada uno con su analítica y propietario). Se puede añadir un índice por long_url si se quiere deduplicar.
¿Por qué la analítica no debe ir en el camino de la redirección?
Porque añadiría latencia y un punto de fallo a la operación más frecuente y crítica. Se publica un evento asíncrono y se procesa aparte; si se pierde alguno, es tolerable.
Ejercicios¶
Ejercicio 1 · Básico — Longitud del código
¿Cuántos caracteres Base62 necesitas para 50 000 millones de URLs? ¿Y si usaras solo minúsculas y dígitos (Base36)?
Solución
Base62: 62⁶ ≈ 5,7 × 10¹⁰ = 57 000 M ≥ 50 000 M → 6 caracteres (justo; con margen, 7). Base36: 36⁷ ≈ 7,8 × 10¹⁰ → 7 caracteres. Base36 evita confusiones entre mayúsculas y minúsculas (útil si los códigos se dictan o se escriben a mano).
Ejercicio 2 · Medio — Codificar y decodificar
Implementa toBase62(long) y fromBase62(String) y comprueba que son inversas.
Solución
private static final String ALPHABET = "0123456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ";
static String toBase62(long id) {
if (id == 0) return "0";
var sb = new StringBuilder();
while (id > 0) { sb.append(ALPHABET.charAt((int) (id % 62))); id /= 62; }
return sb.reverse().toString();
}
static long fromBase62(String code) {
long value = 0;
for (char c : code.toCharArray()) {
int digit = ALPHABET.indexOf(c);
if (digit < 0) throw new IllegalArgumentException("Carácter no válido: " + c);
value = value * 62 + digit; // mismo principio que leer un número en base 10
}
return value;
}
@Test
void roundTrip() {
for (long id : new long[]{0, 1, 61, 62, 123_456_789L, Long.MAX_VALUE})
assertThat(fromBase62(toBase62(id))).isEqualTo(id);
}
Ejercicio 3 · Medio — Generador por rangos
Implementa un generador de IDs que pide a la base de datos bloques de 1 000 y los reparte en memoria de forma segura entre hilos.
Solución
public class RangeIdGenerator {
private final JdbcTemplate jdbc;
private long next = 0, end = 0; // [next, end) disponible
public synchronized long nextId() {
if (next >= end) { // bloque agotado: reservar otro
long start = jdbc.queryForObject(
"UPDATE id_block SET next_value = next_value + 1000 WHERE name = 'url' RETURNING next_value - 1000",
Long.class);
next = start;
end = start + 1000;
}
return next++;
}
}
UPDATE … RETURNING), así que dos instancias nunca reciben
el mismo rango. Si una instancia se reinicia, pierde los IDs no usados de su bloque: huecos aceptables.
Ejercicio 4 · Avanzado — Evitar códigos enumerables
Con IDs consecutivos, los códigos son predecibles (aZ3k9Qx, aZ3k9Qy…) y cualquiera puede recorrer todos los enlaces.
¿Cómo lo evitas sin perder la garantía de unicidad?
Solución
Aplicar al ID una permutación reversible antes de codificar: por ejemplo, multiplicar por un número impar grande módulo 2⁴² (y usar su inverso para decodificar), o un cifrado de bloque pequeño con clave secreta (format-preserving encryption, Feistel). Como es una biyección, IDs distintos dan códigos distintos (unicidad garantizada), pero consecutivos producen códigos que parecen aleatorios. Alternativa más simple: generar códigos aleatorios y comprobar colisión con una restricción única en la base de datos (reintentar si choca).