| 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.
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:
- Mierz, zanim zoptymalizujesz — każda faza kończy się benchmarkiem. Bez liczb nie wiesz, czy zmiana coś dała.
- Optymalizuj gorącą ścieżkę —
POST /api/shorteniGET /api/stats/{code}mogą być wolne.GET /{code}musi być szybki. - 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.
| 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 |
┌──────────────┐ 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.
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 vsphp artisan serve.
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.
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
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.
// 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.
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.
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.
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.
| 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 |
| 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 |
- 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.
# 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:seedPo 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
| 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) |
# 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>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"./vendor/bin/sail test # cały zestaw (Pest)
./vendor/bin/sail test --filter=Redirect # konkretny test
./vendor/bin/sail test --coverage # z pokryciem 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# 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-sloWszystkie scenariusze wymagają k6 w PATH na hoście. Liczby do porównania znajdziesz w tabeli Wyniki wyżej.
./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ą jestoctane:start --watch, ale wymaga dodatkowej konfiguracji.
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)
| 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 | ✅ |
--max-requests=500na worker Octane — okresowy restart workera, ogranicza wycieki pamięci, redukcja skrajnych opóźnień (p99)- OPcache jawnie włączony w obrazie Sail (
opcache.enable=1wphp.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)
MIT. To projekt edukacyjny — kod, testy i dokumentacja są w pełni publiczne.