Yeni bir projeye başlarken karşılaştığım en yaygın refleks şu: daha ilk günden Redis, Kubernetes, mesaj kuyruğu ve üç mikroservis. Henüz tek bir gerçek kullanıcı yokken, “ya milyonlarca kullanıcı gelirse?” korkusuyla kurulan bir altyapı. Oysa çoğu ürün, iyi ayarlanmış tek bir Go binary ile mütevazı bir sunucuda on binlerce eşzamanlı kullanıcıyı rahatça karşılayabilir. Bu yazıda bunun neden mümkün olduğunu, nasıl yapıldığını ve — dürüst olalım — sınırların nerede başladığını konuşacağız.
Bu, aşırı mühendislik üzerine yazdığım düşüncenin somut bir uygulaması: karmaşıklığı ihtiyaç doğduğunda ekleyin, korkuyla değil.
Neden Go Bunu Kaldırabiliyor: Goroutine Ekonomisi
Klasik “C10K problemi” — tek sunucuda on bin eşzamanlı bağlantı — birçok dilde ciddi bir mühendislik meselesidir. Thread başına bağlantı modeli, her thread megabaytlarca yığın (stack) tükettiği için birkaç bine takılır.
Go bu problemi dilin çekirdeğinde çözer. net/http her gelen bağlantıyı ayrı bir goroutine ile karşılar. Ama goroutine bir OS thread’i değildir:
- Başlangıç yığını yaklaşık 2 KB’dır ve ihtiyaç oldukça büyür/küçülür.
- Go runtime scheduler’ı, binlerce goroutine’i çekirdek sayısı kadar OS thread’ine çoklar (M:N zamanlama).
- Bir goroutine bir I/O işleminde (ağ okuması gibi) bloke olduğunda, runtime o thread’i başka bir goroutine’e verir. Bekleyen bağlantılar CPU tüketmez.
Sonuç: 10.000 eşzamanlı kullanıcı, 10.000 pahalı OS thread’i değil, 10.000 ucuz goroutine demektir. Bu, birkaç çekirdekli ve birkaç GB RAM’li sıradan bir sunucunun rahatça kaldıracağı bir yüktür. Go’nun bu modelini Neden Golang yazımda temelden anlatmıştım.
Asıl Darboğaz Uygulama Sunucusu Değil
Buradaki kritik içgörü şu: Bu ölçekte tıkanan yer neredeyse hiçbir zaman Go uygulaması olmaz. Darboğaz genelde şudur:
- Veritabanı: Yavaş sorgular, eksik index’ler, bağlantı havuzunun yanlış ayarlanması.
- Dış servis çağrıları: Yanlış yönetilen HTTP istemcisi, timeout’suz istekler, bağlantı sızıntıları (bunu HTTP client yazımda ayrıntılı ele aldım).
Yani “ölçeklenmiyor” dediğiniz şey çoğu zaman uygulamanın goroutine kapasitesi değil, veritabanına açtığınız bağlantı sayısıdır. database/sql havuzunu doğru ayarlamak, çoğu zaman bir önbellek katmanı eklemekten daha etkilidir:
db.SetMaxOpenConns(50) // veritabanının kaldırabileceğine göre
db.SetMaxIdleConns(25) // sıcak bağlantıları koru
db.SetConnMaxLifetime(5 * time.Minute)
MaxOpenConns değerini sonsuz bırakmak, ani yükte veritabanını binlerce bağlantıyla boğar. Sınırlamak, uygulamanın veritabanını korumasını sağlar.
Redis’ten Önce: In-Process Önbellek
“Önbellek lazım” düşüncesi çoğu zaman doğrudan “Redis kur”a atlar. Oysa tek instance çalışıyorsanız, en hızlı önbellek uygulamanın kendi belleğidir — ağ gidiş-dönüşü bile yoktur:
type Cache struct {
mu sync.RWMutex
items map[string]cacheItem
}
type cacheItem struct {
value []byte
expiresAt time.Time
}
func (c *Cache) Get(key string) ([]byte, bool) {
c.mu.RLock()
item, ok := c.items[key]
c.mu.RUnlock()
if !ok || time.Now().After(item.expiresAt) {
return nil, false
}
return item.value, true
}
func (c *Cache) Set(key string, value []byte, ttl time.Duration) {
c.mu.Lock()
c.items[key] = cacheItem{value: value, expiresAt: time.Now().Add(ttl)}
c.mu.Unlock()
}
Aynı anda çok sayıda goroutine’in aynı pahalı sorguyu tetiklemesini önlemek için golang.org/x/sync/singleflight ile bu deseni bir adım ileri taşıyabilirsiniz. sync.RWMutex’in doğru kullanımı ve eşzamanlılık tuzakları için sık yapılan hatalar yazıma göz atın.
Kubernetes’ten Önce: Tek Binary’nin Sadeliği
Go’nun en büyük operasyonel avantajlarından biri, çıktının tek, bağımsız bir binary olmasıdır — çalışma zamanı bağımlılığı, yorumlayıcı veya ağır bir imaj gerektirmez. Bu binary’yi bir sunucuda systemd servisi ya da tek bir küçük Docker konteyneri olarak çalıştırmak çoğu proje için fazlasıyla yeterlidir. (Multi-stage, non-root bir imaj için standart kütüphane disiplinini koruyup Dockerfile üreticimizi kullanabilirsiniz.)
Kubernetes; servis keşfi, otomatik ölçekleme, kendi kendini iyileştirme ve çok düğümlü orkestrasyon gibi gerçek ihtiyaçları olağanüstü çözer. Ama tek bir servisi tek bir sunucuda çalıştırıyorsanız, getirdiği operasyonel karmaşıklık çoğu zaman çözdüğü problemden büyüktür.
Dürüst Sınırlar: Bunlar Ne Zaman Gerçekten Gerekir?
“İhtiyacın yok” demek “asla gerekmez” demek değildir. Tek sunucu modelinin net sınırları vardır:
- Yüksek erişilebilirlik (HA): Tek sunucu, tek hata noktasıdır. Kesinti kabul edilemezse en az iki instance + yük dengeleyici gerekir — ve işte tam bu noktada, instance’lar arası paylaşılan oturum/önbellek için Redis anlam kazanır.
- Dikey ölçek tavanı: Tek makineyi büyütmenin bir sınırı vardır. Sürekli artan yükte eninde sonunda yatay ölçeğe geçersiniz.
- Dağıtım kesintisi: Tek instance’ı güncellerken kısa bir kesinti olur; sıfır kesintili dağıtım için birden çok instance gerekir.
- Ekip ve servis sayısı: Onlarca servisi çok sayıda ekip yönetiyorsa, Kubernetes’in standardizasyonu gerçek bir kazanç olur.
Kural şu: Bu araçları bir korkuya karşı değil, ölçtüğünüz ve kanıtladığınız bir ihtiyaca karşılık ekleyin.
Özet
Go’nun goroutine modeli, tek bir iyi ayarlanmış binary’nin mütevazı donanımda on binlerce eşzamanlı kullanıcıyı karşılamasını mümkün kılar. Bu ölçekte darboğaz genelde uygulama değil, veritabanı ve dış çağrılardır; in-process önbellek ve doğru havuz ayarları çoğu zaman Redis’ten önce gelmelidir. Redis ve Kubernetes güçlü araçlardır — ama onları erken değil, ihtiyaç kanıtlandığında eklemek, hem sistemi hem de sizi daha sağlıklı tutar. Karmaşıklık kazanılır, varsayılan olarak alınmaz.