Skip to content

jklejczyk/HighTrafficURLShortener

Repository files navigation

High-Traffic URL Shortener

Tests Pint Security codecov PHPStan

Najważniejsze liczby

Metryka Wynik Kontekst
p50 (mediana opóźnienia) 6.83 ms gorąca ścieżka GET /{code} przy stałych 500 RPS
Stała przepustowość 552 RPS test nasycenia, 4 wątki robocze Octane Swoole
Mediana — przyspieszenie 458× php artisan serve (3.13 s p50) → Octane (6.83 ms p50)
Odsetek błędów 0% 30 000 zapytań przez 60 s, narzędzie k6
Skuteczność cache 100 % po rozgrzewce mierzone w redis-cli MONITOR, zero SELECT links na gorącej ścieżce
Opóźnienie zapytania do bazy 0.041 ms EXPLAIN ANALYZE na ścieżce, gdy brak w cache

Pełna dekompozycja przed/po każdej z 5 faz znajduje się niżej, w sekcji Wyniki.

O projekcie

Skracacz linków typu bit.ly, zaprojektowany jako trening wydajnościowy pod rozmowy seniorskie. Gorąca ścieżka (GET /{code} — 99 % ruchu) zoptymalizowana agresywnie; reszta (zarządzanie linkami, statystyki) świadomie pozostawiona w "po prostu działa".

Filozofia projektu:

  1. Mierz, zanim zoptymalizujesz — każda faza kończy się benchmarkiem. Bez liczb nie wiesz, czy zmiana coś dała.
  2. Optymalizuj gorącą ścieżkęPOST /api/shorten i GET /api/stats/{code} mogą być wolne. GET /{code} musi być szybki.
  3. Cache to nie magia — każda decyzja o cache to kompromis (unieważnianie, przestarzałe dane, rozgrzewka). Wszystkie udokumentowane.

Świadomie poza zakresem projektu: frontend, GraphQL, własna autoryzacja, multi-tenant. To projekt o backendzie i wydajności.

Stos technologii

Warstwa Technologia Dlaczego
Runtime Laravel 13 + Octane (Swoole) Octane wycina ~25 ms rozruchu na każde zapytanie, daje prawdziwą współbieżność dzięki pętli zdarzeń
Język PHP 8.3 typed properties, readonly, fibers — wszystko już sprawdzone w produkcji
Baza PostgreSQL 18 porządne planery zapytań, CTE, indeksy częściowe — Postgres > MySQL dla obciążeń z przewagą odczytów
Cache / KV Redis 7 + phpredis (rozszerzenie w C) phpredis ~3–5× szybszy od predis (czysty PHP); przy < 5 ms na gorącej ścieżce ma to znaczenie
Kolejka Redis (baza zadań w .env.example) niskie opóźnienia, operacje atomowe, GETDEL, RENAME użyte w projekcie zadań
Testy obciążeniowe k6 tryb constant-arrival-rate — kluczowy dla testów SLO (gwarancji opóźnień), nie testów nasycenia
Autoryzacja (API) Laravel Sanctum (zhaszowane tokeny) przepływ kluczy API z requests_per_minute per użytkownik
Analiza statyczna PHPStan poziom 9 + Pint poziom 9 wymusza pełne typowanie
Testy Pest szybsze niż PHPUnit, czytelniejsza składnia

Architektura

┌──────────────┐    HTTP    ┌──────────────────────────────────────┐
│   Client     │───────────►│  Octane Swoole (4 workers, event-loop)│
└──────────────┘    301/302 └──────────────┬──────────────────────-─┘
                                           │
                  ┌────────────────────────┼─────────────────────┐
                  │                        │                     │
                  ▼                        ▼                     ▼
        ┌────────────────────┐   ┌────────────────────┐   ┌────────────────┐
        │ SlidingWindowThrottle│  │ LinkCacheService   │   │ ClickCounterSvc │
        │ (Redis ZSET window) │  │ (cache-aside +     │   │ pipeline:       │
        │ 60 req / 60 s / IP  │  │ negative caching)  │   │  INCR clicks:CODE│
        └─────────┬──────────┘   └─────────┬──────────┘   │  SADD dirty     │
                  │                        │              │  ZINCRBY top    │
                  │ deny → 429             │ miss         └────────┬────────┘
                  │                        ▼                       │
                  │              ┌────────────────────┐            │
                  │              │  PostgreSQL        │            │
                  │              │  links (B-tree on  │            │
                  │              │  short_code UNIQ)  │            │
                  │              └────────────────────┘            │
                  │                                                │
                  ▼                                                ▼
                                              ┌──────────────────────────┐
                                              │ AggregateClicksJob       │
                                              │ scheduled every minute   │
                                              │ RENAME dirty→processing  │
                                              │ batched UPDATE links     │
                                              └──────────────────────────┘

Trzy kluczowe niezależne ścieżki na gorącej ścieżce: ograniczenie ruchu → odczyt cache → zapis kliknięcia. Każda kosztuje jedno odpytanie Redisa tam i z powrotem (potok komend, gdzie się da). Postgres dotykany tylko przy braku w cache i raz na minutę przez agregator.

Wyniki

Pełne porównanie 8 wariantów (ab -n 1000 -c 10 dla faz 1–3, k6 dla fazy 5):

Faza Setup RPS p50 p95 p99 Komentarz
1 — naiwna DB + synchroniczny UPDATE 210 47 ms 70 ms 101 ms punkt wyjścia
2 — bez cache (kontrola) A/B test, ten sam kod bez cache 106 90 ms 166 ms 199 ms concurrency 10 ujawnia kolejkowanie PHP-FPM
2 — Redis cache cache-aside + zapis 404 do cache 105 90 ms 161 ms 214 ms bez zysku w benchmarku — rozruch Laravela dominuje
3 — kontrola (sync DB) Faza 2 z synchronicznym UPDATE 292 35 ms 51 ms 61 ms po rozgrzaniu shared_buffers Postgresa
3 — async kliknięcia potok Redis + agregacja w jobie 274 37 ms 51 ms 63 ms architektonicznie poprawne, ale różnica gubi się w szumie
5 — php serve (test nasycenia) dev server, 5 sekwencyjnych workerów 135 3.13 s 9.42 s wąskie gardło: 5 sekwencyjnych workerów
5 — Octane (test nasycenia) 4 workery Swoole 552 742 ms 2.00 s 4.1× więcej RPS, granica pojemności
5 — Octane @ 500 RPS constant-arrival-rate, test SLO 498 6.83 ms 89.77 ms typowy użytkownik w SLO (p90 = 38.7 ms)

Kluczowy wynik: Faza 5 SLO @ 500 RPS — p50 = 6.83 ms, 0 % błędów, 458× szybsza mediana vs php artisan serve.

Dekompozycja czasu pojedynczego zapytania (Faza 3, trafienie w cache, concurrency 1)

To wyjaśnia, dlaczego cache (Faza 2) i async (Faza 3) wyglądały na "uśpione" przed Octane:

Etap Czas Uwagi
PHP-FPM — rozruch workera Laravela ~25 ms parsowanie configów, service container, providery, middleware
Telescope CacheWatcher / RedisWatcher ~5 ms logowanie operacji cache/Redis do bazy Telescope
LinkCacheService::get() → Redis GET ~0.5 ms 1 odpytanie Redisa (trafienie w cache)
ClickCounterService::record() → potok ~0.5 ms 1 odpytanie Redisa, 3 komendy (INCR + SADD + ZINCRBY)
Budowa odpowiedzi i return ~2 ms redirect 302, nagłówki, log
Razem (Faza 3) ~33 ms
Alternatywa Faza 2: UPDATE links ~2 ms indeksowany update na kilku rzędach
Razem (Faza 2) ~35 ms

Różnica Faza 2 vs Faza 3 to ~1.5 ms na zapytanie — pod concurrency 10 gubi się w ~25 ms rozruchu Laravela. Realna walidacja Fazy 3 wymaga eliminacji wąskiego gardła PHP-FPM (Faza 5: Octane + k6 przy 500 RPS).

Lekcja całego projektu w jednym zdaniu: Każda warstwa optymalizacji (cache, async, okno przesuwne) była uśpiona, dopóki rozruch PHP-FPM / php serve dominował w szumie pomiaru. Octane to wzmacniacz, nie cudowne rozwiązanie — uwalnia wszystko, co zostało zrobione wcześniej.

Diagnostyka skrajnych opóźnień (ogon p95)

W teście SLO @ 500 RPS p50 = 6.83 ms ale p95 = 89.77 ms — krzywa "wybucha" między p90 a p99:

min:   2.59 ms    ← najlepszy przypadek (bezczynny worker, cache rozgrzany)
p50:   6.83 ms    ← typowy użytkownik (w SLO ✓)
p90:  38.7 ms     ← 1/10 użytkowników już 6× ponad próg
p95:  89.77 ms    ← 1/20 użytkowników 13× ponad próg
max: 571.92 ms    ← rzadkie wartości skrajne

Kandydaci na źródło skrajnych opóźnień (do zdiagnozowania w fazie 6+):

  • Wyciek pamięci w workerze bez --max-requests (mitygacja: --max-requests=500)
  • Pauzy garbage collectora PHP w pętli zdarzeń Swoole
  • Niedeterministyczne opóźnienia w sieci Docker (lokalny dev, nie odtworzy się na produkcji)
  • Sliding-window throttle: 5+ operacji Redisa na zapytanie

EXPLAIN ANALYZE — baza danych nie jest wąskim gardłem

Dla zapytania SELECT * FROM links WHERE short_code = ? na 100 wierszach (seeder) Postgres wybrał Seq Scan zamiast Index Scan — planner uznał, że sekwencyjny odczyt 4 bloków (32 KB) jest tańszy niż przejście po B-tree + pobranie z heap.

Seq Scan on links  (cost=0.00..5.25 rows=1 width=118)
                   (actual time=0.007..0.022 rows=1.00 loops=1)
  Filter: ((short_code)::text = 'OC5UDL1'::text)
  Rows Removed by Filter: 99
  Buffers: shared hit=4
Execution Time: 0.041 ms

Wymuszony Index Scan (SET enable_seqscan = off) dla porównania: 0.054 ms. Crossover punkt Index > Seq dla unikalnego lookup zaczyna się ~10k wierszy. Przy 1M wierszy planner sam wybierze Index Scan. Indeks links_short_code_unique istnieje (Laravel ->unique() w migracji) i działa. Baza to <0.2 % naszego czasu — cache eliminuje zapytania z gorącej ścieżki.

Kluczowe decyzje inżynierskie

Cache-aside z zapisywaniem braków do cache (Faza 2)

// LinkCacheService::get()
$cached = Cache::get($this->key($code));        // GET link:abc
if ($cached === self::NOT_FOUND_SENTINEL) return null;  // trafienie negatywne
if ($cached !== null) return $cached;                   // trafienie pozytywne
// ...brak w cache → DB + zapis do cache
  • TTL trafień pozytywnych: 24 h, TTL braków (404): 5 min. Asymetria zamierzona — jeśli ktoś zrobi literówkę, link może powstać; 5 min wystarczy, żeby odbić skanowanie, a nie irytuje prawdziwych użytkowników.
  • Zapisywanie 404 do cache to obrona warstwowa, nie podstawowa obrona przed DDoS. Realne źródła 404 w skracaczu: boty wyszukiwarek (Googlebot ciąga stare URL-e latami po usunięciu), umieranie linków, skanowanie przez boty.

Asynchroniczna agregacja kliknięć (Faza 3)

Każdy klik → 3 operacje atomowe w potoku Redis (jedno odpytanie tam i z powrotem):

Redis::pipeline(function ($pipe) use ($code) {
    $pipe->incr(self::COUNTER_PREFIX.$code);   // licznik per kod
    $pipe->sadd(self::DIRTY_SET, $code);       // zbiór kodów do agregacji
    $pipe->zincrby(self::TOP_ZSET, 1, $code);  // ranking "top linków"
});

AggregateClicksJob co minutę robi RENAME dirty → processing (atomowe przejęcie), aktualizację wsadową po 100 kodów, potem DECRBY o zaaplikowaną wartość. Zero rywalizacji o blokady wierszy na gorącej ścieżce — nawet pod obciążeniem Postgres widzi tylko jeden wsadowy UPDATE na minutę na kod, nie N zapisów/s.

Ranking top linków w O(log N) dzięki ZSET — GET /api/top to ZREVRANGE, bez sortowania w PHP ani SQL-owego ORDER BY count DESC LIMIT N.

Ograniczanie ruchu w oknie przesuwnym, sliding window (Faza 4)

Własny middleware używający Redis ZSET zamiast wbudowanego w Laravelu okna stałego:

// SlidingWindowLimiter::attempt()
Redis::pipeline(function ($pipe) {
    $pipe->zremrangebyscore($key, '-inf', $windowStart);  // wywal stare
    $pipe->zadd($key, $now, "{$now}:".random_int(0, 999)); // dodaj zapytanie
    $pipe->zcard($key);                                    // policz w oknie
    $pipe->expire($key, $windowSeconds);
});

Okno stałe (fixed window) pozwala na skok ruchu dwukrotnej wielkości limitu na styku okien (60 zapytań w ostatniej sekundzie minuty + 60 zapytań w pierwszej sekundzie kolejnej). Okno przesuwne płynniej rozkłada skoki — algorytm używany w bramach API typu Kong/Tyk.

Health check (Faza 6)

GET /health — endpoint dla load balancera, dostępny pod root path:

{
  "db": "ok",
  "redis": "ok",
  "queue_depth": 12,
  "cache_hit_ratio_5m": 0.97
}

cache_hit_ratio_5m liczone z liczników Redis podzielonych na minuty (cache:hits:YYYYMMDDHHmm + TTL 10 min). Odczyt = MGET na 5 ostatnich kluczy. Bez LUA, bez sorted setów — O(2) operacji Redisa na każde wywołanie cache.

API

Publiczne (limit 60 zapytań / 60 s per IP)

Metoda Endpoint Opis
GET /{code} Redirect 302 → original_url (404 zapisane w cache)
GET /health Health check (DB, Redis, długość kolejki, skuteczność cache)
GET /up Wbudowany w Laravela prosty health check

Autoryzowane (Sanctum bearer token + limit per użytkownik)

Metoda Endpoint Opis
POST /api/shorten Tworzy nowy link
PATCH /api/shorten/{code} Aktualizuje URL / datę wygaśnięcia
DELETE /api/shorten/{code} Usuwa link
GET /api/stats/{code} Statystyki (zagregowane w DB + oczekujące w Redis)
GET /api/top Top 10 linków (ZSET links:top)
GET /api/tokens Lista tokenów
POST /api/tokens Tworzy token
DELETE /api/tokens/{id} Usuwa token

Uruchomienie projektu

Wymagania

  • PHP 8.3+ i Composer na hoście (potrzebne tylko raz, do zainstalowania zależności — sam stos aplikacji chodzi w kontenerach)
  • Docker + Docker Compose (cały stos — PHP/Octane, Postgres, Redis, worker kolejki, scheduler — działa przez Laravel Sail)
  • k6 (opcjonalnie, tylko jeśli chcesz powtórzyć testy obciążeniowe) — instalacja: https://k6.io/docs/get-started/installation/

Postgresa ani Redisa nie musisz instalować lokalnie — są w obrazach Sail.

Pierwsza konfiguracja

# 1. Sklonuj repo
git clone https://github.com/jklejczyk/HighTrafficURLShortener.git
cd HighTrafficURLShortener

# 2. Skopiuj plik środowiska (nie wymaga edycji do uruchomienia lokalnie)
cp .env.example .env

# 3. Zainstaluj zależności PHP (wymaga PHP 8.3+ i Composera na hoście)
composer install

# 4. Wystartuj cały stos (Octane + Postgres + Redis + worker kolejki + scheduler)
./vendor/bin/sail up -d

# 5. Wygeneruj APP_KEY, uruchom migracje, zaseeduj testowe dane
./vendor/bin/sail artisan key:generate
./vendor/bin/sail artisan migrate
./vendor/bin/sail artisan db:seed

Po db:seed masz w bazie:

  • 1 testowego użytkownika: test@example.com
  • 5 dodatkowych użytkowników (factory)
  • 100 przykładowych linków z losowymi short_code

Dostęp do usług

Usługa URL / port Uwagi
Aplikacja http://localhost:8080 Octane (Swoole), 4 workery
PostgreSQL localhost:5433 user/db: sail / hasło: password (przekierowany port — DB w kontenerze chodzi na 5432)
Redis localhost:6380 bez hasła (przekierowany port — Redis w kontenerze na 6379)

Sprawdzenie, czy stos działa

# 1. Health check — wszystkie warstwy
curl http://localhost:8080/health
# oczekiwany JSON: {"db":"ok","redis":"ok","queue_depth":0,"cache_hit_ratio_5m":null}

# 2. Wylistuj przykładowe short_code z bazy
./vendor/bin/sail artisan tinker --execute='echo App\Models\Link::pluck("short_code")->take(5)->implode(", ");'

# 3. Sprawdź redirect (302 → original_url)
curl -I http://localhost:8080/<jeden_z_powyzszych_kodow>

Testowanie API (POST /api/shorten)

API wymaga tokena Sanctum w nagłówku Authorization: Bearer <token>. Wygeneruj go dla testowego użytkownika:

./vendor/bin/sail artisan tinker --execute='
$user = App\Models\User::where("email", "test@example.com")->first();
echo "Token: ".$user->createToken("local-test")->plainTextToken;'

Skopiuj wartość po Token: i użyj:

# Utwórz nowy link
curl -X POST http://localhost:8080/api/shorten \
  -H "Authorization: Bearer <TWÓJ_TOKEN>" \
  -H "Accept: application/json" \
  -H "Content-Type: application/json" \
  -d '{"original_url": "https://example.com"}'

# Pobierz statystyki
curl http://localhost:8080/api/stats/<short_code> \
  -H "Authorization: Bearer <TWÓJ_TOKEN>" \
  -H "Accept: application/json"

# Top 10 linków (sortowane przez Redis ZSET)
curl http://localhost:8080/api/top \
  -H "Authorization: Bearer <TWÓJ_TOKEN>" \
  -H "Accept: application/json"

Uruchomienie testów

./vendor/bin/sail test                       # cały zestaw (Pest)
./vendor/bin/sail test --filter=Redirect     # konkretny test
./vendor/bin/sail test --coverage            # z pokryciem kodu

Analiza statyczna i styl kodu

./vendor/bin/sail bin phpstan analyse        # PHPStan (poziom 9)
./vendor/bin/sail bin pint                   # Laravel Pint (formatowanie)
./vendor/bin/sail composer audit             # podatności w zależnościach

Powtórzenie testów obciążeniowych

# 1. Wygeneruj 100 kodów do scenariusza k6 (czytane przez load-tests/*.js)
./vendor/bin/sail artisan app:generate-codes-for-load-tests

# 2. Test nasycenia — narastanie 0→1000 wirtualnych użytkowników przez 3 minuty
#    Pytanie: ile maksymalnie system wytrzyma?
./vendor/bin/sail artisan app:run-load-tests --scenario=redirect

# 3. Test SLO — stałe 500 RPS przez 60 sekund
#    Pytanie: czy dla deklarowanej przepustowości trzymam opóźnienia?
./vendor/bin/sail artisan app:run-load-tests --scenario=redirect-slo

Wszystkie scenariusze wymagają k6 w PATH na hoście. Liczby do porównania znajdziesz w tabeli Wyniki wyżej.

Codzienna praca

./vendor/bin/sail up -d                  # wystartuj stos w tle
./vendor/bin/sail logs -f laravel.test   # logi aplikacji
./vendor/bin/sail artisan octane:reload  # przeładuj workery po zmianie kodu
./vendor/bin/sail down                   # zatrzymaj wszystko
./vendor/bin/sail down -v                # + wyczyść woluminy (reset DB/Redis)

Uwaga o Octane: Swoole trzyma Laravela w pamięci między zapytaniami. Po zmianie kodu uruchom octane:reload — inaczej nowy kod nie zostanie podchwycony. W trybie deweloperskim alternatywą jest octane:start --watch, ale wymaga dodatkowej konfiguracji.

Struktura projektu

app/
├── Http/
│   ├── Controllers/
│   │   ├── HealthController.php     # GET /health (invokable)
│   │   ├── LinkController.php       # CRUD + statystyki + top
│   │   ├── RedirectController.php   # GET /{code} (gorąca ścieżka)
│   │   └── TokenController.php      # tokeny Sanctum
│   └── Middleware/
│       ├── SlidingWindowThrottle.php  # limit zapytań w oknie przesuwnym (Redis ZSET)
│       └── RateLimitByAuth.php        # limit per użytkownik (klucze API)
├── Services/
│   ├── LinkCacheService.php         # cache-aside + zapis 404 do cache
│   ├── ClickCounterService.php      # potok Redis INCR/SADD/ZINCRBY
│   ├── SlidingWindowLimiter.php     # algorytm limitu w oknie przesuwnym
│   ├── ShortCodeGeneratorService.php # base62 + sprawdzenie kolizji
│   ├── HealthCheckService.php       # logika health checka (DB/Redis/kolejka/cache)
│   └── CacheMetrics.php             # liczniki trafień/braków podzielone na minuty
├── Jobs/
│   └── AggregateClicksJob.php       # RENAME dirty → wsadowy UPDATE
└── Console/Commands/
    ├── GenerateCodesForLoadTests.php
    └── RunLoadTests.php             # wrapper na k6
load-tests/
├── redirect.js                       # test nasycenia (ramping-vus)
└── redirect-slo.js                   # test SLO (constant-arrival-rate)

Stan implementacji

Faza Zakres Status
0 Setup środowiska (Sail, Postgres, Redis, phpredis)
1 MVP: model links, generator base62, naiwny redirect
2 Cache-aside + zapis 404 do cache + write-through
3 Asynchroniczne kliknięcia: potok Redis + agregator w schedulerze
4 Ograniczanie ruchu: okno przesuwne + klucze API w Sanctum
5 Testy obciążeniowe (k6) + Octane (Swoole)
6 Endpoint health checka, metryki trafień/braków cache

Możliwe kierunki rozwoju

  • --max-requests=500 na worker Octane — okresowy restart workera, ogranicza wycieki pamięci, redukcja skrajnych opóźnień (p99)
  • OPcache jawnie włączony w obrazie Sail (opcache.enable=1 w php.ini)
  • Indeks pokrywający links(short_code) INCLUDE (original_url) — Index Only Scan zamiast Index + Heap Fetch
  • Ochrona przed lawiną zapytań do cache (Cache::lock() lub losowe wcześniejsze wygasanie) dla "zimnych" kluczy
  • Wsadowanie operacji w sliding-window — obecnie 5 operacji Redisa na zapytanie; istotny składnik ogona p95
  • Laravel Pulse jako dashboard metryk (zamiast własnego CacheMetrics)

Licencja

MIT. To projekt edukacyjny — kod, testy i dokumentacja są w pełni publiczne.

About

Skracarka linków jako case study wydajności: hot path 6,83 ms p50 @ 500 RPS, 0% błędów na 30k żądań. Laravel 13 + Octane (Swoole), Redis, PostgreSQL, k6, PHPStan lvl 7

Topics

Resources

Stars

Watchers

Forks

Releases

No releases published

Packages

 
 
 

Contributors