System Design Case Study: URL Shortener Tasarlamak
URL shortener okuma ağırlıklı bir sistemdir: saniyede ~40 yazmaya karşı ~3.900 redirect, bu yüzden stateless bir redirect yolu, cache-aside, çakışmasız ve tahmin edilmesi zor kodlar ve analytics için 301 değil 302 gerekir. Hesaplanmış capacity tablosu, hash çakışmalarının gerçek ölçümü, sayaç + bijective dönüşüm + base62, 301/302 curl çıktıları, alias, expiration ve kötüye kullanım.
Sürüm notu: Bu yazıdaki kodlar C# 14 ve .NET 10 ile derlenip denenmiştir; redirect çıktıları ASP.NET Core 10 ile yazılmış prototipe curl ile atılan isteklerden alındı.
Önce Tahmin Et
Bookstore pazarlama ekibi bülten ve SMS (Short Message Service) kampanyaları için kendi kısa link servisini istiyor: bkst.re/c9Ac8PH gibi linkler, hangi kampanyanın kaç tıklama aldığını gösteren bir rapor ve kampanya bitince süresi dolan linkler. İlk prototipte geliştirici redirect için Results.Redirect(url, permanent: true) yazmış, yani 301; kısa kodları da veritabanının identity değerinden base62 ile üretiyor: 1, 2, 3...
İki soru. Kampanyanın ikinci haftasında rapor, aynı kullanıcıların linke tekrar tekrar tıkladığı hâlde tıklama sayısının neredeyse artmadığını gösteriyor. Neden? Ve rakip bir şirket bkst.re/1, bkst.re/2, ... diye denerse ne öğrenir?
Requirement'lar ve Capacity Estimation
Serinin "System Design Nedir, Nasıl Çalışılır? Requirement'lardan Deep Dive'a 4 Adımlı Yöntem" yazısındaki sırayla gidiyoruz. Functional requirement'lar: uzun bir URL (Uniform Resource Locator) verildiğinde kısa bir link üret; kısa linke gelen isteği uzun URL'ye yönlendir; isteğe bağlı custom alias (bkst.re/autumn-sale) ve son kullanma tarihi; link başına tıklama sayısı. Kapsam dışı: link düzenleme arayüzü, kullanıcı yönetimi, ayrıntılı analytics panosu.
Non-functional requirement'lar daha belirleyici. Redirect hızlı olmalı: kullanıcı ile asıl sayfa arasına giren bir adım, p99 (isteklerin %99'unun altında kaldığı süre) 50 ms'yi geçmesin. Redirect yolu yüksek availability'li olmalı: bültendeki bütün linkler aynı servise bağlı, servis düşerse kampanya düşer. Kısa kodlar asla çakışmamalı ve kolay tahmin edilmemeli. Tıklama sayısında küçük gecikme ya da kayıp kabul edilebilir; redirect'in hızı ondan önemli.
Rakamlar için varsayımlarımızı yazıp hesabı koda bıraktım: ayda 100 milyon yeni link, okuma ile yazma oranı 100'e 1, peak ortalamanın 3 katı, link başına 500 byte, 10 yıl saklama.
const double newLinksPerMonth = 100_000_000, readsPerWrite = 100, peakFactor = 3;
const double bytesPerLink = 500, years = 10, redirectResponseBytes = 500;
double writesPerSec = newLinksPerMonth / (30 * 86_400);
double readsPerSec = writesPerSec * readsPerWrite;
double totalLinks = newLinksPerMonth * 12 * years;
double redirectsPerDay = readsPerSec * 86_400;
writes/s avg 39, peak 116
redirects/s avg 3,858, peak 11,574
links in 10 years 12.0 billion, storage 6.0 TB
redirect egress avg 15.4 Mbit/s
cache for top 20% of a day's redirects: 33.3 GB (upper bound, before dedup)
62^7 = 3,521,614,606,208 codes, 0.34 % used after 10 years
Tablo tasarımı şekillendiriyor. Yazma yükü küçük (saniyede 40), okuma yükü yazmanın 100 katı ve peak'te saniyede 11.500'ü buluyor: bu bir okuma ağırlıklı sistem, ölçeklenecek olan redirect yolu. 6 TB on yılda birikiyor; tek bir veritabanı sunucusu bunu taşıyabilir, ama satır sayısı 12 milyara çıkacağı için anahtarın küçük ve index'in sıkı olması önemli. Cache satırı bir üst sınır: aynı popüler link gün içinde binlerce kez tıklandığı için gerçek çalışma kümesi bunun çok altında kalır. Ve 7 karakterlik base62 uzayı (62^7 ≈ 3,5 trilyon) on yıl sonra bile yalnız %0,34 dolu; 7 karakter yeterli.
API ve Veri Modeli
Üç endpoint yeterli:
POST /api/links: gövdedelongUrl, isteğe bağlıcustomAliasveexpiresAt; 201 Created veshortUrldöner, alias doluysa 409 Conflict.GET /{code}(veHEAD): 302 veLocationheader'ı; bilinmeyen kod 404, süresi dolmuş kod 410 Gone.GET /api/links/{code}/stats: tıklama sayıları, analytics store'dan.
Veri modeli bir Links tablosu: Code (primary key, 7–30 karakter), LongUrl, OwnerId, CreatedAt, ExpiresAt. Tıklamalar bu tabloya yazılmaz; her tıklama için Links satırını güncellemek, okuma ağırlıklı bir sistemin en sıcak satırlarına saniyede binlerce yazma eklemek olurdu. Tıklamalar ayrı bir event akışına gider ve toplu olarak sayılır. Bir ayrıntı: base62 büyük-küçük harfe duyarlıdır, abc ile ABC farklı kodlardır. SQL (Structured Query Language) Server'ın varsayılan collation'ı ise büyük-küçük harfe duyarsızdır; Code kolonu binary ya da case-sensitive bir collation ile tanımlanmazsa iki farklı kod unique constraint'e takılır ya da yanlış linke gider.
High-Level Design

İki yol birbirinden ayrı. Yazma yolunda Link API (Application Programming Interface), URL'yi doğrular, kodu üretir ve satırı primary veritabanına yazar. Redirect yolunda stateless redirect service önce Redis cache'e bakar; bulamazsa read replica'dan okur, sonucu cache'e koyar ve 302 döner. Tıklama, cevabı geciktirmemek için bir queue'ya event olarak bırakılır; analytics worker'ı bunları toplayıp sayaçları günceller. Bu ayrım, serinin "Asenkron İşleme: Queue, Worker, Fan-Out ve Backpressure" yazısındaki fikrin doğrudan uygulaması: redirect yolunda yalnız kullanıcının beklemesi gereken iş kalıyor. Redirect service stateless olduğu için "Tek Sunucudan Milyonlarca Kullanıcıya: Vertical ve Horizontal Scaling, Stateless Tier" yazısındaki gibi yatayda istediğin kadar çoğalır.
Deep Dive: Kısa Kodu Üretmek
Üç yol var. Birincisi uzun URL'nin hash'ini alıp ilk 7 karakterini kullanmak. Cazip, çünkü aynı URL hep aynı kodu verir. Ama kısaltılmış bir hash'te çakışma kaçınılmazdır ve doğum günü problemi (birthday problem) yüzünden beklenenden çok daha erken başlar. 5 karakterlik bir uzayda bir milyon URL ile denedim:
// 5 karakterlik uzayda SHA-256 + kesme: 1.000.000 URL'de kaç çakışma?
long space5 = (long)Math.Pow(62, 5);
HashSet<string> seen = [];
int collisions = 0;
for (int i = 0; i < 1_000_000; i++)
{
byte[] hash = SHA256.HashData(Encoding.UTF8.GetBytes($"https://bookstore.example/books/{i}"));
long n = (long)(BitConverter.ToUInt64(hash, 0) % (ulong)space5);
if (!seen.Add(Base62.Encode(n, 5))) collisions++;
}
5-char hash codes: 519 collisions in 1,000,000 (birthday estimate ~546)
7-char hash codes after 10 years: ~20,445,167 expected collisions
Uzay 916 milyon, kullanılan bir milyon (binde bir) ama yine de 519 çakışma; yaklaşık formül n²/2N'nin tahmini 546. Bizim 7 karakter ve 12 milyar link varsayımımızla beklenen çakışma 20 milyonun üstünde. Her çakışmada "kodu bir karakter uzat ya da URL'ye tuz ekleyip tekrar hash'le" mantığı ve bunu kontrol etmek için her yazmada bir okuma gerekir. Aynı URL'nin aynı kodu vermesi de sanıldığı kadar değerli değil: iki farklı kampanya aynı URL'yi kısaltıp tıklamalarını ayrı saymak isteyebilir.
İkincisi rastgele 7 karakter üretip unique constraint'e güvenmek. Uzayın yalnız %0,34'ü dolu olacağı için bir eklemenin çakışma ihtimali en kötü durumda binde 3,4; çakışınca yeni bir kod üretip tekrar denemek yeter. Basit, tahmin edilemez, koordinasyon gerektirmez.
Üçüncüsü bir sayaç ve base62. Çakışma imkânsız, ama Önce Tahmin Et'teki ikinci sorunun cevabı burada: ardışık kodlar sitedeki toplam link sayısını ve günlük hacmi ele verir, her kod bir sonrakinin tahminidir. Sayacı kullanıp bu sızıntıyı kapatmanın ucuz bir yolu, sayacı uzay üzerinde birebir (bijective) bir dönüşümden geçirmek: 62^7 ile aralarında asal bir sabitle çarpıp mod almak. Her sayaç değeri farklı bir koda gider, ama ardışık sayaçlar ardışık kod vermez:
public static class ShortCode
{
public const int Length = 7;
public static readonly long Space = (long)Math.Pow(62, Length); // 3,521,614,606,208
private const long Multiplier = 2_166_810_999_823; // 62^7 (= 2^7 * 31^7) ile aralarında asal
private static readonly long Inverse = (long)BigInteger.ModPow(Multiplier, Totient() - 1, Space);
public static string FromCounter(long counter) =>
Base62.Encode((long)((UInt128)counter * Multiplier % (UInt128)Space), Length);
public static long ToCounter(string code) =>
(long)((UInt128)Base62.Decode(code) * (UInt128)Inverse % (UInt128)Space);
// phi(2^7 * 31^7) = 2^6 * 31^6 * 30
private static BigInteger Totient() => BigInteger.Pow(2, 6) * BigInteger.Pow(31, 6) * 30;
}
counter 1 -> c9Ac8PH -> back to 1
counter 2 -> EILEGoY -> back to 2
counter 3 -> qRVqPDp -> back to 3
counter 1,000,000 -> 4l8REHY -> back to 1,000,000
2,000,000 counters -> 2,000,000 distinct codes
Bu bir şifreleme değil, gizleme: sabiti bilen biri dönüşümü tersine çevirebilir. Ama kodları sıradan bir gözlemcinin okuyamayacağı hâle getirir ve çakışmasızlık garantisini korur. Sayacın kendisi için tek bir veritabanı sequence'ine her yazmada gitmek gerekmez: serinin "Unique ID Üretimi: Auto-Increment, UUIDv4, UUIDv7 ve Snowflake" yazısındaki blok tahsisi ile her Link API instance'ı 1.000'lik bir blok alır ve bitene kadar bellekten dağıtır. Saniyede 40 yazma için bu, saatte birkaç sequence çağrısı demek. Kodun gerçekten tahmin edilemez olması bir requirement ise (özel belgeleri paylaşan linkler gibi) ikinci yol, yani rastgele kod, doğru seçim; o durumda kodu 8–10 karaktere çıkarmak da düşünülmeli.
Deep Dive: 301 mi 302 mi?
Önce Tahmin Et'teki ilk sorunun cevabı redirect'in status code'unda. İki endpoint'i yan yana koydum; biri 302, biri 301 dönüyor. Linkin önizlemesini çıkaran araçlar HEAD istediği için endpoint'ler GET ile birlikte HEAD'e de cevap veriyor; yalnız MapGet kullandığım ilk denemede curl -I 405 Method Not Allowed almıştı.
// 302: her tıklama bize geri gelir (analytics, expiry ve düzenleme çalışmaya devam eder); link önizlemeleri için HEAD
app.MapMethods("/{code}", ["GET", "HEAD"], (string code) => Resolve(code, permanent: false));
// 301: yalnız karşılaştırma için
app.MapMethods("/p/{code}", ["GET", "HEAD"], (string code) => Resolve(code, permanent: true));
IResult Resolve(string code, bool permanent)
{
if (!links.TryGetValue(code, out Link? link)) return Results.NotFound();
if (link.ExpiresAt <= DateTimeOffset.UtcNow) return Results.StatusCode(StatusCodes.Status410Gone);
return Results.Redirect(link.LongUrl, permanent);
}
$ curl -sI localhost:5392/c9Ac8PH
HTTP/1.1 302 Found
Date: Sun, 08 Nov 2026 01:13:23 GMT
Server: Kestrel
Location: https://bookstore.example/books/978-0134757599?utm_source=newsletter
$ curl -sI localhost:5392/p/c9Ac8PH
HTTP/1.1 301 Moved Permanently
Date: Sun, 08 Nov 2026 01:13:23 GMT
Server: Kestrel
Location: https://bookstore.example/books/978-0134757599?utm_source=newsletter
İki cevap neredeyse aynı görünüyor ama HTTP (Hypertext Transfer Protocol) semantiği farklı. RFC (Request for Comments) 9110'a göre 301 "heuristically cacheable": Cache-Control header'ı olmasa bile tarayıcı ve ara cache'ler onu saklayabilir, ve tarayıcılar kalıcı redirect'i genelde uzun süre hatırlar. Kullanıcı aynı linke ikinci kez tıkladığında tarayıcı bize hiç uğramadan doğrudan hedefe gider: tıklama sayılmaz, link düzenlenemez, süresi dolan link çalışmaya devam eder. 302 ise varsayılan olarak cache'lenmez; her tıklama redirect service'ten geçer. Bunun bedeli, sunucuya gelen yükün artması; ama capacity tablosundaki redirect sayısı zaten her tıklamanın bize geldiği varsayımıyla hesaplandı. Tıklama sayımı ve expiration requirement olduğu için seçim 302. Analytics gerekmeyen, hedefin asla değişmeyeceği linklerde 301 sunucu yükünü azaltır. Yeniden yönlendirmede method'un korunması gerekiyorsa (POST'u POST olarak iletmek) karşılıkları 307 ve 308.
Deep Dive: Okuma Yolu ve Cache
Saniyede 11.500 peak redirect'i her seferinde veritabanına götürmek gereksiz: link oluştuktan sonra neredeyse hiç değişmiyor, yani cache için ideal. Serinin "Caching Stratejileri: Cache-Aside, Write-Through, Write-Behind ve Cache Stampede" yazısındaki cache-aside burada doğal seçim: redirect service önce Redis'e bakar, yoksa replica'dan okur ve TTL (Time To Live) ile cache'e yazar. Eviction politikası LRU (Least Recently Used) ya da LFU (Least Frequently Used) olabilir; link tıklamaları ağır bir şekilde birkaç popüler linkte toplandığı için ikisi de yüksek hit oranı verir.
Üç ayrıntı fark yaratır. Birincisi negatif cache: var olmayan kodlar da kısa bir TTL ile cache'lenir. Yoksa kodları rastgele deneyen bir scanner (tarama aracı) her isteğiyle veritabanına iner. İkincisi expiration: cache'teki girişin TTL'i linkin ExpiresAt zamanını geçmemeli, yoksa süresi dolan link cache'ten çalışmaya devam eder. Üçüncüsü hot key: bir bülten gönderildiği anda tek bir link saniyede binlerce tıklama alabilir; bu link'in Redis'teki tek key'i tek bir node'a yük bindirir. Çözüm, redirect service instance'larında birkaç saniyelik küçük bir in-memory cache; bütün instance'lar aynı popüler linki kendi belleğinden cevaplar. Bir adım öteye gidilirse redirect, CDN'in (Content Delivery Network) edge'inde de yapılabilir, ama o zaman her tıklama sunucuya gelmez ve analytics edge'in log'larından toplanmak zorundadır.
Deep Dive: Alias, Expiration ve Kötüye Kullanım
Custom alias'lar aynı Code kolonunu paylaşır; unique constraint çakışmayı yakalar. Ama bazı kelimeler kimseye verilmemeli: api, admin, login gibi uygulamanın kendi route'larıyla karışacak adlar. Prototipe birkaç senaryo gönderdim (süresi dolmuş linki görmek için geçmiş bir tarih verdim; gerçek API'de geçmiş bir expiresAt de validation hatası olmalı):
$ curl -s -X POST localhost:5392/api/links -H "Content-Type: application/json" -d '{"longUrl":"https://bookstore.example/sale","customAlias":"autumn-sale","expiresAt":"2026-11-01T00:00:00Z"}'
{"shortUrl":"http://localhost:5392/autumn-sale"}
$ curl -s -o /dev/null -w "%{http_code}" localhost:5392/autumn-sale
410
$ curl -s -X POST localhost:5392/api/links -H "Content-Type: application/json" -d '{"longUrl":"https://bookstore.example/x","customAlias":"admin"}'
{"error":"'admin' is not available."}
$ curl -s -X POST localhost:5392/api/links -H "Content-Type: application/json" -d '{"longUrl":"javascript:alert(1)"}'
{"type":"https://tools.ietf.org/html/rfc9110#section-15.5.1","title":"One or more validation errors occurred.","status":400,"errors":{"longUrl":["Must be an absolute http(s) URL."]}}
Süresi dolmuş link 410 Gone dönüyor; 404'ten farkı "vardı ama artık yok" demesi. Süresi dolan satırları silmek ayrı bir iş: redirect anında kontrol etmek doğruluğu sağlar, gece çalışan bir background job da tabloyu temizler. javascript: gibi tehlikeli scheme'ler daha oluşturma anında reddediliyor.
Kötüye kullanım, URL shortener'ın en çok hafife alınan tarafı. Kısa link hedefi gizlediği için phishing ve zararlı yazılım dağıtımının sevdiği bir araçtır; senin alan adın bir kez kara listeye girerse bültendeki meşru linkler de engellenir. Savunmalar katmanlı: link oluşturmaya kullanıcı ya da API key başına rate limit (serinin "Rate Limiting Tasarımı: Token Bucket, Sliding Window ve Distributed Rate Limiter" yazısındaki gibi), hedef URL'yi oluşturma anında ve periyodik olarak bir tehdit istihbaratı servisine sormak, şikâyet edilen linki hızla devre dışı bırakabilmek ve anonim link oluşturmaya hiç izin vermemek. Bookstore'un servisi yalnız kendi pazarlama ekibine açık olduğu için bu risk küçük; halka açık bir servis için tasarımın merkezinde.
Trade-off'lar
| Karar | Seçtiğimiz | Alternatif ve bedeli |
|---|---|---|
| Kod üretimi | Sayaç bloğu + bijective dönüşüm + base62 | Rastgele kod: tahmin edilemez ama her yazmada çakışma kontrolü; hash: aynı URL aynı kod ama çakışma yönetimi |
| Redirect kodu | 302 | 301: daha az yük ama tıklama sayımı, düzenleme ve expiration kaybolur |
| Tıklama sayımı | Queue + asenkron toplama | Senkron UPDATE: kesin ama redirect latency'si ve sıcak satıra yazma kilitleri |
| Okuma yolu | Redis cache-aside + negatif cache + kısa in-memory cache | Edge'de redirect: en hızlısı ama analytics edge log'larına bağımlı |
| Veri katmanı | Tek primary + read replica | Sharding: 12 milyar satırda gerekebilir, ama 6 TB için erken |
Sık Düşülen Tuzaklar
Tıklama sayan servise 301 koymak
Tarayıcı 301'i hatırlar, ikinci tıklama sana gelmez. Analytics ve expiration varsa 302.
Kısaltılmış hash'in çakışmayacağını sanmak
Doğum günü problemi yüzünden çakışmalar, uzayın binde biri dolduğunda bile başlar. Hash kullanacaksan çakışma yolunu baştan tasarla.
Ardışık kodları dışarı açmak
Sayaç doğrudan base62'ye girerse hacmin ve sıranın hepsi okunur. Ya bijective bir dönüşümden geçir ya da rastgele kod kullan.
Case-insensitive collation
Base62 büyük-küçük harfe duyarlıdır; veritabanı değilse iki farklı kod aynı satıra düşer.
Tıklamayı link satırına yazmak
Her redirect'te UPDATE yapmak, okuma ağırlıklı sistemi en popüler satırların kilidinde boğar. Tıklama ayrı bir akış.
Küçük Bir Deneme
★☆☆ Capacity hesabını ayda 1 milyar yeni link ve 20 yıl saklama ile tekrar yap. 7 karakter hâlâ yeter mi? Uzayın yüzde kaçı dolar?
★★☆ Prototipe Redis tabanlı cache-aside ekle: hit'te replica'ya gitmesin, olmayan kod için 60 saniyelik negatif cache tutsun, cache TTL'i ExpiresAt'i geçmesin.
★★★ Link başına saatlik tıklama sayılarını tutan analytics tarafını tasarla: event şeması, queue'dan okuyan worker, at-least-once teslimde çift sayımı önleme ve GET /api/links/{code}/stats'ın okuyacağı tablo.
URL shortener okuma ağırlıklı, veri modeli basit bir sistemdi. Bir sonraki case study'de yazmanın kendisi bir fan-out problemine dönüşüyor: bir kullanıcının paylaşımını binlerce takipçinin akışına nasıl ulaştıracağımızı, news feed ve timeline tasarımında konuşacağız.