Published on

Co nieco o System Designie - projektujemy URL Shortener

Authors
  • avatar
    Name
    Piotr Kołodziejczyk
    Twitter
Co nieco o System Designie - projektujemy URL Shortener

URL Shortener to jeden z tych problemów, które na pierwszy rzut oka wyglądają trywialnie. Wziąć długi link, zwrócić krótki. Tyle. A jednak bit.ly obsługuje miliardy kliknięć miesięcznie, ma analitykę, georouting i nie pada. Jak to działa pod spodem?

Wymagania

Funkcjonalne:

  • Podajesz długi URL → dostajesz krótki (np. shortly.pl/aB3xK2)
  • Wchodzisz na krótki URL → redirect do oryginalnego
  • Opcjonalnie: własny alias (shortly.pl/moja-firma)
  • Opcjonalnie: wygaśnięcie linku po czasie

Niefunkcjonalne:

  • Redirect musi być szybki - poniżej 10ms (użytkownik tego nie czuje, ale SEO i UX tak)
  • Write heavy? Nie. Stosunek odczytów do zapisów ≈ 100:1. Znacznie więcej kliknięć niż tworzonych linków
  • Trwałość - raz stworzony link nie może zniknąć bez powodu
  • Dostępność 99.9%

Szacunki

Tworzenie linków:       100 000 / dzień = ~1.2 req/s
Przekierowania:         10 000 000 / dzień = ~115 req/s
                        peak ×10 = ~1 150 req/s

Rozmiar rekordu:        ~500 bajtów (short_code + long_url + metadata)
Storage / rok:          100 000 × 365 × 500 B18 GB
Storage / 10 lat:       ~180 GB

Storage jest śmieszny mały. Problem nie jest w dysku - jest w latencji przekierowania.


Base62 - skąd się bierze krótki kod

Tu jest serce systemu. Krótki URL to nic innego jak zakodowany identyfikator.

Mamy do dyspozycji znaki URL-safe bez cudzysłowów i znaków specjalnych:

0-910 znaków
a-z   →  26 znaków
A-Z26 znaków
      = 62 znaki łącznie  →  Base62

Ile unikalnych URLi zmieści się w N znakach?

1 znak:  62^1  =                   62
2 znaki: 62^2  =                3 844
3 znaki: 62^3  =              238 328
4 znaki: 62^4  =           14 776 336
5 znaki: 62^5  =          916 132 832  (~916 milionów)
6 znaki: 62^6  =       56 800 235 584  (~56 miliardów)
7 znaki: 62^7  =    3 521 614 606 208  (~3.5 biliona)

6 znaków = 56 miliardów unikalnych URLi. Bit.ly ma ~40 miliardów linków. 6 znaków wystarcza.

Konwersja ID → Base62

Każdy rekord w bazie dostaje auto-inkrementujące się id (liczba całkowita). Żeby zamienić id na krótki kod, robimy konwersję systemu liczbowego - dokładnie tak jak z binarnego na heksadecymalny, tylko podstawa to 62.

ALPHABET = "0123456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ"

def encode(num: int) -> str:
    if num == 0:
        return ALPHABET[0]
    result = []
    while num:
        result.append(ALPHABET[num % 62])
        num //= 62
    return ''.join(reversed(result))

def decode(code: str) -> int:
    result = 0
    for char in code:
        result = result * 62 + ALPHABET.index(char)
    return result
encode(1)"1"
encode(62)"10"
encode(12345678)"FpSK"
encode(56800235583)"zzzzzz"   (max 6-znakowy)

Konwersja jest deterministyczna - ten sam id zawsze daje ten sam kod. Brak kolizji.


Dwa podejścia do generowania kodu

1. Auto-increment ID + Base62 (zalecane)

Baza danych generuje sekwencyjne id, kodujemy do Base62.

INSERT INTO urls (long_url, created_at) VALUES (?, ?)
RETURNING id   →  12345678

encode(12345678)"FpSK"
short_url = "shortly.pl/FpSK"

Zalety: zero kolizji, przewidywalna długość kodu, prosta implementacja.

Wada: kody są sekwencyjne - można enumerować URLe (FpSK, FpSL, FpSM...). Dla większości zastosowań to nie problem, ale jeśli linki mają być "nieprzegadywalne" - słabo.

2. Hash (MD5/SHA256) + przycinanie

Hashujesz długi URL, bierzesz pierwsze 6-7 znaków z Base62.

import hashlib, base64

def hash_url(long_url: str) -> str:
    h = hashlib.md5(long_url.encode()).digest()
    encoded = base64.b62encode(h)  # lub własna implementacja
    return encoded[:6]

Problem: kolizje. Dwa różne URLe mogą dać ten sam 6-znakowy prefiks. Trzeba sprawdzać w bazie i przy kolizji - co? Appendować sól i hashować ponownie, iteracyjnie.

hash("https://google.com")[:6]"aB3xK2"
hash("https://youtube.com")[:6]"aB3xK2"  ← kolizja!
hash("https://youtube.com" + "1")[:6]"mK9pLx"  ← retry z solą

Przy małej skali kolizje są rzadkie, ale przy setkach milionów URLi zaczyna to boleć. Auto-increment jest prostsze i bezpieczniejsze.


Przepływ tworzenia linku

Klient                  API Server            DB (PostgreSQL)      Cache (Redis)
  │                          │                       │                    │
  │── POST /shorten ────────▶│                       │                    │
{ url: "https://..." } │                       │                    │
  │                          │── INSERT long_url ───▶│                    │
  │                          │◀── id: 12345678 ──────│                    │
  │                          │                       │                    │
  │                          │  encode(12345678) = "FpSK"  │                          │                       │                    │
  │                          │── SET FpSK → long_url ────────────────────▶│
   (TTL: 24h)          │                    │
  │◀── { short: "/FpSK" } ───│                       │                    │

Przepływ przekierowania - tu liczy się każda milisekunda

Klient          CDN (CloudFront)      API Server      Redis       PostgreSQL
  │                    │                   │              │              │
  │── GET /FpSK ──────▶│                   │              │              │
 (cache miss)      │              │              │
  │                    │── GET /FpSK ─────▶│              │              │
  │                    │                   │── GET FpSK ─▶│              │
  │                    │                   │◀── long_url ─│              │
  │                    │                      (hit ~1ms) │              │
  │                    │◀── 301/302 ───────│              │              │
  │                    │   Location: long_url             │              │
  │◀── 301/302 ────────│                   │              │              │

Jeśli Redis ma rekord (cache hit) - baza danych w ogóle nie jest odpytywana. Latencja Redis ~0.5ms, cały redirect < 5ms.

Jeśli Redis nie ma (cache miss) - idziemy do PostgreSQL, ładujemy do cache, zwracamy. Pierwsze trafienie wolniejsze, każde kolejne szybkie.

301 vs 302 - ważna różnica

301 Permanent302 Temporary
Przeglądarka cache'ujeTak - na zawszeNie
Kolejne kliknięciaBezpośrednio do celu (omija serwer)Zawsze przez serwer
Analityka kliknięćTraci po pierwszymKażde kliknięcie zliczane
Obciążenie serweraMinimalneWiększe

Jeśli chcesz zliczać kliknięcia → 302. Jeśli nie zależy ci na analityce → 301.


Architektura na AWS

                    ┌─────────────────────────────────────────┐
AWS Cloud                    │                                         │
  Klienci ─────────▶│  CloudFront (CDN)                    │       │                                 │
                    │       ▼                                 │
ALB (Load Balancer)                    │       │                                 │
                    │       ▼                                 │
ECS / Lambda                      (API: shorten + redirect)                    │       │              │                  │
                    │       ▼              ▼                  │
ElastiCache     PostgreSQL                      (Redis)         (RDS)                    │  hot cache       source of truth        │
                    └─────────────────────────────────────────┘

PostgreSQL zamiast DynamoDB - tym razem mamy relacyjne dane z auto-increment ID. Sekwencja w PostgreSQL jest atomowa i bezpieczna dla concurrent writes. Przy tej skali (18 GB / rok) PostgreSQL na RDS to overkill w dobrym sensie - znany, sprawdzony, tanie backupy.

Lambda zamiast ECS - operacje są bezstanowe i krótkie (< 100ms). Lambda idealnie pasuje, skaluje do zera w nocy.

CloudFront przed wszystkim - przekierowania można cache'ować na poziomie CDN. Kliknięcie w popularny link? CloudFront odpowiada z edge, serwer w ogóle nie widzi requestu.


Custom aliasy i wygasanie

Custom alias (shortly.pl/moja-firma):

ALTER TABLE urls ADD COLUMN custom_alias VARCHAR(50) UNIQUE;

Przy tworzeniu sprawdzamy czy alias jest wolny, jeśli zajęty - błąd 409. Priorytet przy lookupie: najpierw custom_alias, potem short_code.

Wygasanie linku:

ALTER TABLE urls ADD COLUMN expires_at TIMESTAMP;

Przy przekierowaniu sprawdzamy expires_at < NOW() → 410 Gone. Osobny cron (Lambda Event Bridge) czyści stare rekordy co dobę.


Co z enumeracją? (bezpieczeństwo)

Sekwencyjne kody (FpSK, FpSL, FpSM) można iterować i zbierać wszystkie URLe z systemu. Jeśli to problem:

  • Losowy salt przy encode - do ID dodajesz losową stałą przed kodowaniem (salt trzymasz w konfiguracji, nie w DB)
  • Hashids - biblioteka która "szyfruje" liczby całkowite na nieprzewidywalne stringi, deterministycznie
from hashids import Hashids
h = Hashids(salt="twoj-sekret", min_length=6)
h.encode(12345678)   # "kRHp5X" - nieprzewidywalne
h.decode("kRHp5X")   # [12345678]

Brak kolizji, brak sekwencyjności, nadal deterministyczne.


Kluczowe wnioski

  • Base62 daje 56 miliardów unikalnych URLi w 6 znakach - więcej niż wystarczy
  • Auto-increment + Base62 to prostsze i bezpieczniejsze niż hashowanie z obsługą kolizji
  • Redis przed bazą danych - przekierowania muszą być szybkie, cache jest obowiązkowy
  • 302 jeśli liczysz kliknięcia, 301 jeśli nie - różnica ma znaczenie
  • PostgreSQL wystarcza - problem nie jest w skali zapisu, tylko w latencji odczytu