Conception d'un service URL shortener supportant les fortes charges

1. Aperçu

Le raccourcissement de lien est un problème classique de conception de système. La logique se réduit à mapper un code court vers une URL longue. La difficulté réside dans l’échelle : des dizaines de milliers de redirections par seconde, des liens « viraux » soudains, et l’exigence de ne jamais générer de codes en double.

Cet article couvre le processus complet : validation des exigences, estimation de taille, modèle de données, API, génération de codes, cache et points difficiles. Après la lecture, vous disposerez d’un cadre de conception accompagné de code Spring Boot 3 pour construire votre propre service ou l’utiliser en entretien.

2. Spécifications du problème

2.1. Exigences fonctionnelles

  • Créer un lien court à partir d’une URL longue. L’utilisateur peut définir un alias personnalisé, par exemple /sale-2026.
  • Accéder au lien court redirige vers l’URL originale.
  • Le lien peut avoir une date d’expiration (expires_at) et peut être désactivé par le propriétaire.
  • Statistiques de clics par lien. Les données peuvent avoir un délai de quelques secondes.

2.2. Échelle

Supposons 100 millions de nouveaux liens par mois et un ratio lecture/écriture de 100:1.

Écritures :   100M / (30 * 86400s)  ≈ 40 liens/s   (pic ×10 ≈ 400/s)
Lectures :    40 × 100              ≈ 4 000 rps  (pic ×10 ≈ 40 000 rps)
Stockage 5 ans : 6 milliards de liens × ~500 octets ≈ 3 TB
Espace de code 7 caractères base62 : 62^7 ≈ 3,5 mille milliards  (largement suffisant pour 6 milliards)

De ces chiffres, nous tirons deux conclusions. Premièrement, le système est fortement orienté lecture, donc le cache est au cœur de la conception. Deuxièmement, la charge d’écriture est faible, donc un PostgreSQL primaire suffit, pas besoin de sharding immédiatement.

2.3. Contraintes

  • Redirections avec p99 < 20 ms.
  • Les codes courts sont uniques et imprévisibles, pour que personne ne puisse deviner les liens privés.
  • Le système continue à rediriger même si les analytics ou Kafka rencontrent des problèmes.

3. Modèle de données et API

Dépendances à utiliser (version gérée par 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>

Schéma 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;

Explication des décisions principales :

  • code est la clé primaire. La requête la plus fréquente est WHERE code = ?, donc elle utilise directement l’index de clé primaire.
  • idx_links_owner supporte l’écran « Mes liens ».
  • link_id_seq s’incrémente de 1000 à chaque fois. Chaque appel à nextval retourne un bloc entier d’ID (section 4.2).

Les deux API principales :

POST /api/links          {"url": "https://shop.vn/...", "alias": null}
  -> 201 {"code": "0bX3kQz", "shortUrl": "https://nonla.ly/0bX3kQz"}
  -> 409 si l'alias existe déjà

GET  /{code}
  -> 302 Location: https://shop.vn/...
  -> 404 si absent / expiré / désactivé

Note : Utilisez 302, pas 301. Les navigateurs cachent 301 de façon permanente. Vous perdez les statistiques de clics et ne pouvez pas désactiver les liens déjà diffusés.

4. Génération de codes courts

4.1. Choix de la stratégie

Approche Avantages Inconvénients
Hash URL (MD5 puis troncature 7 caractères) Pas d’état nécessaire Possibilité de collisions, vérification et tentatives nécessaires
7 caractères aléatoires Simple Plus de liens augmentent les collisions, chaque création nécessite une vérification DB
Compteur + base62 Jamais de collision Ordre prévisible, nécessite une source ID centralisée

Cet article choisit la troisième approche et corrige ses deux inconvénients. Pour masquer l’ordre, on mélange l’ID avec une bijection. Pour réduire la dépendance envers une source ID centralisée, on attribue les ID par bloc.

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);
    // Impair, non divisible par 31 => coprime avec 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 en dehors de l'espace de code : " + 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);
    }
}

Fonctionnement :

  • La multiplication modulo id × MULTIPLIER mod 62^7 est une bijection, car MULTIPLIER est coprime avec 62^7. Chaque ID produit exactement un code, et deux ID différents ne produisent jamais le même code.
  • La boucle convertit le nombre en base62 et le complète avec des zéros à gauche, donc le code fait toujours exactement 7 caractères.
  • Les ID 1 et 2 produisent des codes différents à partir du deuxième caractère, ce qui rend difficile de deviner le lien suivant.
  • BigInteger évite le débordement de long lors de la multiplication. Avec une charge de 400 écritures/s, ce coût est négligeable.

Cette approche rend seulement le code difficile à deviner, ce n’est pas du chiffrement. Quiconque connaît MULTIPLIER peut l’inverser. Les liens vraiment privés ont besoin de mécanismes d’authentification supplémentaires.

4.2. Attribution d’ID par bloc

Appeler la DB pour chaque ID ferait du primary un goulot d’étranglement. À la place, chaque instance récupère un bloc de 1000 ID et les distribue graduellement en mémoire :

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; // doit correspondre à 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();
        }
    }
}

Points importants :

  • nextval est une opération atomique. Deux instances ne reçoivent jamais le même bloc, donc pas besoin de verrouillage distribué.
  • Avec 400 écritures/s, chaque instance ne touche la DB que quelques fois par seconde.
  • Le code utilise ReentrantLock plutôt que synchronized. Sur Java 21, les virtual threads sont « épinglés » au carrier thread lors de l’attente d’I/O dans un bloc synchronized.
  • Un redémarrage d’instance gaspille le reste du bloc. L’espace de code a 3,5 mille milliards de valeurs, donc c’est acceptable.

5. Flux de création de lien

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("Seul http/https est accepté");
        }
        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 déjà pris : " + code); }
}

Le flux de traitement comprend quatre étapes :

  1. Vérifier le schéma pour bloquer javascript: ou file:. Si vous sautez cette étape, votre service devient un vecteur de propagation de liens malveillants.
  2. Obtenir le code : utiliser l’alias personnalisé ou générer le code à partir de l’ID.
  3. INSERT. La contrainte de clé primaire est la dernière ligne de défense contre les doublons.
  4. Écrire immédiatement dans Redis (write-through), pour que la première redirection ne descende pas à la DB.

En production, les alias personnalisés doivent utiliser des caractères ou une longueur différents des codes auto-générés, par exemple obligatoirement avec un tiret - ou au moins 8 caractères. Sinon, un alias utilisateur peut « occuper » un code que le générateur créerait plus tard. Le générateur rencontre alors DuplicateKeyException, alors que son objectif est de ne jamais créer de doublons.

6. Flux de redirection et cache multi-niveaux

À 40 000 rps en pic, la plupart des requêtes doivent être servies sans toucher la DB. On utilise trois niveaux : Caffeine dans la JVM, Redis partagé, puis 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 pour 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;
    }
}

Points importants :

  • local.get(code, loader) de Caffeine fusionne les chargements simultanés pour la même clé. Si 5 000 requêtes demandent le même code absent du cache, seul un thread descend à Redis/DB. Les autres threads attendent le résultat. C’est la technique pour éviter le cache stampede au niveau d’une instance.
  • Negative caching : les codes inexistants sont aussi cachés sous forme de chaîne vide pendant 5 minutes. Grâce à cela, les bots qui devinent des codes aléatoires ne peuvent pas surcharger la DB.
  • Caffeine ne cache pas les valeurs null, donc le code utilise le sentinel "" à la place de null.

Le contrôleur retourne 302 et pousse les événements de clic vers 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 est asynchrone, donc la redirection n’attend pas le broker. Un consumer dédié agrège les événements par code toutes les quelques secondes puis écrit par batch dans la table de statistiques. Configuration du producer pour la latence basse :

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

max.block.ms=50 empêche le thread de redirection de bloquer longtemps si le broker tombe, conforme à la contrainte « la redirection fonctionne même si analytics échoue ». Accepter la perte de quelques événements de clic dans ce cas.

7. Défis et solutions

7.1. Conditions de course

  • Deux personnes fixent le même alias : Ne pas faire de SELECT puis INSERT. Les deux requêtes peuvent voir « n’existe pas » puis écrire toutes les deux. Laissez la contrainte unique de la DB décider et capturez DuplicateKeyException comme en section 5.
  • Plusieurs instances génèrent des codes : La séquence attribue des blocs à chaque instance, donc pas de race condition.

7.2. Cohérence

  • Lire immédiatement après la création : si on lit depuis un read replica, le délai de réplication peut faire retourner 404 pour un lien nouvellement créé. Écrire dans Redis immédiatement à la création (write-through) cache ce délai.
  • Désactiver un lien : mettre à jour la DB puis supprimer la clé Redis. Caffeine conserve l’ancienne version jusqu’à 30 secondes sur chaque instance. Pour bloquer immédiatement, par exemple pour un lien frauduleux, publiez un événement d’invalidation via Redis Pub/Sub ou Kafka pour que les instances suppriment le cache local.
  • Expiration : Le TTL du cache doit être min(24h, expires_at - now). Sinon, un lien expiré redirige encore jusqu’à 24 heures de plus.

7.3. Pics de charge

  • Clé chaude : un lien viral concentre tout le trafic sur un shard Redis. Caffeine local absorbe la majorité de cette charge. Chaque instance ne questionne Redis environ qu’une fois toutes les 30 secondes par clé.
  • Démarrage à froid : une nouvelle instance n’a rien dans Caffeine, mais la fusion de charge en section 6 évite de noyer Redis.
  • Spam de création : limiter le taux par clé API ou IP, par exemple avec token bucket sur Redis, avant d’appeler LinkWriteService.

8. Tests du comportement de génération de code

La propriété la plus importante du système est que les codes ne collisionnent pas, ont une longueur fixe et l’ordre est imprévisible :

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 generateCodeWithCorrectLengthAndAlphabet() {
        assertThat(ShortCode.of(1)).hasSize(7).matches("[0-9a-zA-Z]{7}");
    }

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

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

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

Le deuxième test vérifie sur un échantillon, ne prouve pas complètement la bijectivité. La bijectivité provient des mathématiques (gcd égal à 1). Le test vous protège simplement si quelqu’un change accidentellement MULTIPLIER en nombre pair.

9. Erreurs courantes et quand ne pas sur-concevoir

Note : Changer MULTIPLIER ou ALPHABET après le lancement causera des collisions entre les codes anciens et nouveaux. Traitez-les comme des constantes permanentes.

  • Écrire chaque clic directement dans la table links (UPDATE ... SET clicks = clicks + 1) crée un verrou de ligne chaud précisément sur les liens viraux. Agrégez via Kafka.
  • Ne pas limiter la taille de Caffeine cause OutOfMemoryError lors d’une attaque de devinage massif.
  • Pour un produit interne avec seulement quelques centaines de liens par jour, une table PostgreSQL et un contrôleur suffisent. Redis, Kafka et l’allocation d’ID par bloc ne sont justifiés que quand la charge l’exige vraiment.

10. Conclusion

La conception d’un URL shortener supportant de fortes charges repose sur trois décisions. Générer les codes avec un compteur attribué par bloc, combiné à une bijection, pour que les codes ne collisionnent pas mais restent imprévisibles. Utiliser un cache multi-niveaux avec negative caching et fusion de charge, pour supporter les clés chaudes et les bots de devinage. Pousser les tâches non-critiques, comme les statistiques de clics, hors du chemin de redirection via Kafka.

Commencez par l’estimation d’échelle. Ce sont les chiffres de la section 2 qui vous disent quand ajouter chaque couche, plutôt que de tout construire dès le départ.

system-designspring-bootrediskafkapostgresql