Diseño de un acortador de URLs que soporta alta carga
1. Descripción general
El acortamiento de URLs es un problema familiar de diseño de sistemas. La lógica consiste simplemente en mapear un código corto a una URL larga. La dificultad reside en la escala: decenas de miles de redirecciones por segundo, enlaces viral repentinos, y el requisito de nunca generar códigos duplicados.
Este artículo cubre todo el proceso: definición de requisitos, estimación de carga, modelo de datos, API, generación de códigos, almacenamiento en caché y puntos complicados. Al terminar de leer, tendrás un marco de diseño con código de Spring Boot 3 para construir por tu cuenta o llevar a una entrevista.
2. Requisitos del problema
2.1. Requisitos funcionales
- Crear enlaces cortos a partir de URLs largas. Los usuarios pueden establecer un alias personalizado, por ejemplo
/sale-2026. - Acceder a un enlace corto redirige a la URL original.
- Los enlaces pueden tener una fecha de expiración (
expires_at) y pueden ser deshabilitados por el propietario. - Estadísticas de clics por enlace. Se aceptan unos pocos segundos de latencia en los datos.
2.2. Escala
Asumimos 100 millones de enlaces nuevos cada mes y una relación lectura/escritura de 100:1.
Escritura: 100M / (30 * 86400s) ≈ 40 link/s (pico ×10 ≈ 400/s)
Lectura: 40 × 100 ≈ 4.000 rps (pico ×10 ≈ 40.000 rps)
Almacenamiento 5 años: 6B de enlaces × ~500 bytes ≈ 3 TB
Espacio de código 7 caracteres base62: 62^7 ≈ 3,5 billones (espacio suficiente para 6B)
De estos números, extraemos dos conclusiones. Primero, el sistema tiende a ser de lectura, por lo que el almacenamiento en caché es el centro del diseño. Segundo, la carga de escritura es baja, por lo que un PostgreSQL principal es suficiente, sin necesidad de sharding todavía.
2.3. Restricciones
- Redirección con p99 < 20 ms.
- Los códigos cortos no deben ser duplicados ni predecibles en orden, para que otros no puedan descubrir enlaces privados.
- El sistema debe continuar redirigiendo incluso cuando analytics o Kafka fallan.
3. Modelo de datos y API
Dependencias necesarias (versiones gestionadas por 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 de 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;
Explicación de las decisiones clave:
codees la clave principal. La consulta más frecuente esWHERE code = ?, por lo que va directamente al índice de la clave principal.idx_links_ownersirve para la pantalla “mis enlaces”.link_id_seqse incrementa 1000 unidades a la vez. Cada vez que se llama anextval, la aplicación recibe un bloque completo de IDs (sección 4.2).
Dos APIs principales:
POST /api/links {"url": "https://shop.vn/...", "alias": null}
-> 201 {"code": "0bX3kQz", "shortUrl": "https://nonla.ly/0bX3kQz"}
-> 409 si el alias ya existe
GET /{code}
-> 302 Location: https://shop.vn/...
-> 404 si no existe / expirado / deshabilitado
Nota: Usa
302, no301. Los navegadores cachean301de forma permanente. Entonces pierdes datos de clics y no puedes deshabilitar enlaces que ya se han propagado.
4. Generación de códigos cortos
4.1. Elegir estrategia
| Método | Ventajas | Desventajas |
|---|---|---|
| Hash de URL (MD5 luego cortar 7 caracteres) | No requiere estado | Posibles colisiones, hay que verificar e intentar de nuevo |
| 7 caracteres aleatorios | Simple | Más enlaces = más colisiones, cada creación debe verificar DB |
| Contador + base62 | Nunca hay colisiones | Orden predecible, requiere una fuente centralizada de IDs |
Este artículo elige el tercer método y mitiga sus dos desventajas. Para no revelar el orden, mezclamos el ID usando una biyección. Para reducir la dependencia de una fuente centralizada de IDs, asignamos IDs en bloques.
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 impar, no divisible por 31 => coprimo con 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 fuera del espacio 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);
}
}
Cómo funciona:
- La multiplicación modular
id × MULTIPLIER mod 62^7es una biyección porqueMULTIPLIERes coprimo con62^7. Cada ID genera exactamente un código, y dos IDs diferentes nunca generan el mismo código. - El bucle convierte el número a base62 y rellena con ceros a la izquierda, por lo que el código siempre tiene exactamente 7 caracteres.
- Los IDs 1 y 2 generan códigos diferentes a partir del segundo carácter, lo que dificulta que otros adivinen el siguiente enlace.
BigIntegerevita desbordes delongal multiplicar. Con una carga de 400 escrituras/s, este costo es insignificante.
Este método solo hace que los códigos sean difíciles de adivinar, no los encripta. Alguien que sepa el valor de MULTIPLIER puede invertirlo. Los enlaces verdaderamente privados necesitan un mecanismo de autenticación adicional.
4.2. Asignación de IDs en bloques
Llamar a la DB para cada ID hace que la instancia principal sea un cuello de botella. En su lugar, cada instancia obtiene un bloque de 1000 IDs y los distribuye gradualmente desde la memoria:
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; // debe coincidir con 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();
}
}
}
Puntos a tener en cuenta:
nextvales una operación atómica. Dos instancias nunca reciben el mismo bloque, por lo que no se necesita un bloqueo distribuido.- Con 400 escrituras/s, cada instancia accede a la DB solo cada pocos segundos.
- El código usa
ReentrantLocken lugar desynchronized. En Java 21, un virtual thread se “fija” en un carrier thread al esperar I/O dentro de un bloquesynchronized. - El reinicio de una instancia desperdiciará el resto del bloque. El espacio de código tiene 3,5 billones de valores, por lo que esto es aceptable.
5. Flujo de creación de enlace
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("Solo se aceptan http/https");
}
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 ya existe: " + code); }
}
El flujo de procesamiento tiene cuatro pasos:
- Verificar el scheme para bloquear
javascript:ofile:. Si omites este paso, tu servicio se convierte en una herramienta para distribuir enlaces maliciosos. - Obtener el código: usar el alias personalizado o generar código desde el ID.
INSERT. La restricción de clave principal es la última capa de protección contra códigos duplicados.- Escribir inmediatamente a Redis (write-through), para que el primer redireccionamiento no tenga que ir a DB.
En producción, los alias personalizados deben usar caracteres o longitudes diferentes de los códigos generados automáticamente, por ejemplo, requerir un guion - o una longitud mínima de 8 caracteres. De lo contrario, el alias de un usuario podría “ocupar” un código que el generador crear más adelante. Entonces el generador encuentra DuplicateKeyException, cuando su propósito es nunca tener colisiones.
6. Flujo de redireccionamiento y almacenamiento en caché de múltiples niveles
Con 40.000 rps en picos, la mayoría de las solicitudes deben responderse sin acceder a la DB. Usamos tres niveles: Caffeine en JVM, Redis compartido, y finalmente 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 negative cache
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;
}
}
Puntos importantes:
- El método
local.get(code, loader)de Caffeine combina cargas simultáneas del mismo key. Si 5.000 solicitudes preguntan por un código que no está en caché, solo un thread va a Redis/DB. Los otros threads esperan el resultado. Esta es la forma de prevenir cache stampede dentro de una instancia. - Almacenamiento negativo en caché: los códigos no existentes también se cachean como cadenas vacías durante 5 minutos. Así, los bots que prueban códigos aleatorios no pueden abrumar la DB.
- Caffeine no cachea valores
null, por lo que el código usa el sentinel""en su lugar.
El controller devuelve 302 y envía el evento de clic a 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 es asincrónico, por lo que el redireccionamiento no espera al broker. Un consumer separado agrupa eventos por code cada pocos segundos y escribe lotes en la tabla de estadísticas. Configuración del productor que prioriza latencia baja:
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 el thread de redireccionamiento se bloquee demasiado cuando el broker cae, alineándose con el requisito “analytics puede fallar, pero el redireccionamiento funciona”. Se acepta perder algunos eventos de clics en ese caso.
7. Puntos difíciles y soluciones
7.1. Conflictos
- Dos personas establecen el mismo alias: no hagas
SELECTprimero y luegoINSERT. Dos solicitudes pueden ver “no existe” y luego ambas escribir. Deja que la restricción unique de la DB decida y atrapaDuplicateKeyExceptioncomo en la sección 5. - Múltiples instancias generan códigos: la secuencia asigna bloques separados a cada instancia, por lo que no hay conflictos.
7.2. Consistencia
- Lectura inmediatamente después de crear: si lees desde una réplica de lectura, la latencia de replicación puede hacer que un enlace recién creado devuelva 404. Escribir en Redis inmediatamente al crear (write-through) oculta esta latencia.
- Deshabilitar un enlace: actualiza la DB y luego elimina la clave de Redis. Caffeine mantiene la versión anterior hasta 30 segundos en cada instancia. Si necesitas un bloqueo inmediato, como para un enlace fraudulento, publica un evento de invalidación a través de Redis Pub/Sub o Kafka para que las instancias eliminen el caché local.
- Expiración: el TTL del caché debe ser
min(24h, expires_at - now). De lo contrario, un enlace expirado sigue redirigiendo hasta 24 horas.
7.3. Picos de carga
- Clave caliente: un enlace viral concentra todo el tráfico en un shard de Redis. Caffeine local absorbe la mayor parte de esta carga. Cada instancia solo consulta Redis aproximadamente una vez cada 30 segundos por clave.
- Arranque en frío: una instancia nueva no tiene nada en Caffeine, pero la función de combinación de cargas en la sección 6 evita que Redis sea inundado de solicitudes.
- Spam de creación de enlaces: limita la velocidad por clave de API o IP, por ejemplo, usando token bucket en Redis, antes de llamar a
LinkWriteService.
8. Pruebas del comportamiento de generación de códigos
La propiedad más importante del sistema es que los códigos no se duplican, tienen longitud fija y no revelan el orden:
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 generaCodigoConLongitudCorrecta() {
assertThat(ShortCode.of(1)).hasSize(7).matches("[0-9a-zA-Z]{7}");
}
@Test
void sinDuplicadosEnCienMilIdsConsecutivos() {
Set<String> codes = LongStream.range(0, 100_000).mapToObj(ShortCode::of).collect(toSet());
assertThat(codes).hasSize(100_000);
}
@Test
void idsConsecutivosNoGeneranCodigosConsecutivos() {
assertThat(ShortCode.of(1).substring(0, 5)).isNotEqualTo(ShortCode.of(2).substring(0, 5));
}
@Test
void rechazaIdFueraDelEspacioDeCodigos() {
assertThatThrownBy(() -> ShortCode.of(-1)).isInstanceOf(IllegalArgumentException.class);
}
}
La segunda prueba verifica en una muestra, no demuestra la biyección. La biyección viene de las matemáticas (gcd es 1). Las pruebas solo te protegen si alguien accidentalmente cambia MULTIPLIER a un número par.
9. Errores comunes y cuándo no complicarse
Nota: Cambiar
MULTIPLIERoALPHABETdespués de publicar hará que los IDs nuevos generen códigos duplicados con códigos antiguos. Trátalos como constantes permanentes.
- Escribir cada clic directamente en la tabla
links(UPDATE ... SET clicks = clicks + 1) crea un bloqueo de fila conflictivo en enlaces virales. Agrúpalos a través de Kafka. - No limitar el tamaño de Caffeine causa
OutOfMemoryErrorcuando se prueba aleatoriamente un gran número de códigos. - Para un producto interno con solo cientos de enlaces por día, una tabla PostgreSQL y un controller son suficientes. Redis, Kafka y la asignación de IDs en bloques solo valen la pena cuando la carga realmente lo justifica.
10. Conclusión
El diseño de un acortador de URLs que soporta alta carga se centra en tres decisiones. Generar códigos usando un contador con asignación en bloques combinado con una biyección, para que los códigos no se dupliquen pero sean difíciles de adivinar. Usar almacenamiento en caché de múltiples niveles con caché negativo y combinación de cargas, para soportar claves calientes y bots que prueban códigos. Desplazar todo lo no esencial, como las estadísticas de clics, fuera del camino de redireccionamiento a través de Kafka.
Comienza con la estimación de escala. Son los números de la sección 2 los que te dicen cuándo añadir cada capa, en lugar de construir todo de una vez desde el primer día.