Demo felaketi şubat başında, en büyük müşteri adayımızın toplantı odasında yaşandı. Satış müdürü canlı harita ekranını açtı, adamların 4.800 araçlık filosunu tek ekranda gösterecekti. Sayfa yüklendi, araçlar göründü ve 20 saniye sonra sekme dondu. Tarayıcı "sayfa yanıt vermiyor" diyaloğunu gösterirken satış müdürü bana mesaj atıyordu: "ACİL. Donduk." Sorun sunucuda bile değildi; biz her araç güncellemesini ayrı WebSocket mesajı olarak basıyorduk ve tarayıcı saniyede 500 küçük mesajı ayrıştırıp 4.800 marker'ı tek tek oynatmaya çalışırken ana thread'de boğuluyordu.
O gün öğrendiğim ilk ders: "5 bin aracı haritada oynatmak" bir sunucu problemi değil, uçtan uca bir bütçe problemi. Bant genişliği bütçesi, mesaj sayısı bütçesi, tarayıcı ana thread bütçesi. Üçünü de ayrı ayrı yönetmeyen, üçünde birden batıyor.
Polling'den Kopuş ve İlk Naif Sürüm
WebSocket'e geçmeden önce sistem 10 saniyede bir AJAX polling yapıyordu; her seferinde tüm filonun son konumu, ortalama 900 KB JSON. 200 açık ekranda bu, sunucudan dakikada 1 GB'ın üzerinde tekrarlı veri demekti ve verinin yüzde 95'i bir önceki cevapla aynıydı. WebSocket'in vaadi tam buydu: bağlantı açık kalsın, yalnızca değişen gitsin.
Naif sürümü hızlı yazdık: cihaz mesajı işleme hattından çıktığı anda, o aracın abonesi olan her sokete JSON basılıyor. Küçük filolarda şahaneydi. Felaket, tek ekranda binlerce araç izleyen büyük müşteride patladı çünkü mesaj sayısını hiç bütçelememiştik. Saniyede 500 mesaj × 200 bayt, bant olarak masum (100 KB/s) ama tarayıcıda saniyede 500 onmessage çağrısı, 500 JSON.parse, 500 DOM/canvas dokunuşu demek. Chrome profiler'da ana thread'in yüzde 80'i bizim marker güncelleme fonksiyonumuzdaydı.
Saniyede Bir Nefes: Toplu Delta Çerçevesi
Çözümün omurgası, güncellemeleri tik mantığına bağlamak oldu. Sunucu tarafında her ekran oturumu için bir çıkış tamponu tuttuk; araç güncellemeleri anında sokete değil, bu tampona yazılıyor. Saniyede bir kez tampon boşalıyor: o saniye içinde değişen tüm araçlar, tek bir toplu çerçevede gidiyor. Aynı araç bir saniyede iki kez güncellendiyse yalnızca sonuncusu kalıyor (conflation — telemetride son değer her zaman öncekini ezer, kuyruk biriktirmenin anlamı yok).
Çerçevenin içi de delta: aracın değişmeyen alanları gitmiyor. Konum değiştiyse konum, sadece yön değiştiyse sadece yön. Alan adlarını da JSON anahtarı olarak değil, önceden anlaşılmış kısa kodlarla taşıdık; tam JSON'a göre ortalama mesaj boyutu araç başına 210 bayttan 46 bayta indi. Binary format (o dönem MessagePack'i denedik) yüzde 20 daha kazandırıyordu ama debug edilebilirliği öldürdüğü için kompakt JSON'da kaldık. Pişman değilim; production'da harcanmayan her debug saati, kazanılmış bant genişliğinden değerli.
Görünmeyen Aracı Göndermemek
İkinci büyük kazanım viewport aboneliği. Kullanıcı İstanbul'a zoom yapmışken Ankara'daki 800 aracın güncellemesini almasının hiçbir anlamı yok. İstemci, harita hareketi durduğunda (300 ms debounce ile) görünen alanın sınırlarını sunucuya bildiriyor; sunucu o oturumun aboneliğini geohash hücreleri kümesi olarak tutuyor — evet, mekânsal sorgu yazımdaki aynı geohash, burada yayın filtresi oldu. Araç hücre değiştirdiğinde abonelik kümeleri güncelleniyor. Uzaklaşıp tüm Türkiye'yi görüntüleyen kullanıcıya ise ham araçlar değil, hücre bazında sayı kümeleri (cluster özetleri) gidiyor; zaten o zoom'da 5.000 ayrı marker'ın görsel bir anlamı yok.
Bu iki değişiklikle tipik bir operasyon ekranının aldığı trafik saniyede 500 mesajdan 1 mesaja (içinde ortalama 40-120 araç deltası olan tek çerçeve), bant kullanımı da oturum başına 100 KB/s'den 4-6 KB/s'ye indi. Tarayıcı tarafında çerçeve başına tek parse + requestAnimationFrame içinde toplu çizim yapınca demo makinesinde bile ana thread kullanımı yüzde 80'den yüzde 7'ye düştü. Aynı müşteriye üç hafta sonra aynı demoyu yaptık; bu kez donan, satış müdürünün heyecanına yetişemeyen bendim.
Sunucu Tarafı: 200 Ekran Kolay, 2.000 Ekran Değil
Fan-out tarafında PHP-FPM bu iş için uygun değildi (istek-cevap modeli, kalıcı bağlantı tutamıyor); WebSocket katmanını ayrı bir süreç olarak yazdık — o dönem Ratchet ile başlayıp event loop'u yetmeyince Swoole'a geçtik. Tek süreçte 2.000 eş zamanlı ekran bağlantısını, tik başına ortalama 3-4 ms fan-out süresiyle taşıdık. Kritik numara, aynı abonelik kümesine sahip oturumlar için çerçevenin bir kez serialize edilmesi: 50 oturum İstanbul merkezini izliyorsa JSON encode 50 değil 1 kez çalışıyor, soketlere aynı string yazılıyor. Serialize maliyeti fan-out'ta CPU'nun en büyük kalemiydi; bu tekilleştirme onu doğrudan oturum sayısından koparttı.
Kalıcı bağlantının getirdiği dertleri de yaşadık elbette. Kurumsal proxy'ler arkasındaki müşterilerde WebSocket el sıkışması bazen engelleniyordu; 5 saniyede bağlanamayan istemci otomatik olarak eski polling moduna düşüyor. (O kod yolu bugün hâlâ duruyor ve trafiğin yüzde 6'sı onu kullanıyor. Zarif düşüş, havalı teknolojiden önemlidir.) Bir de sessiz kopma var: mobil ağdaki dizüstü uyuyup uyandığında soket "açık" görünüp ölü olabiliyor. Uygulama seviyesinde 25 saniyelik ping-pong ve iki kaçırılmış pong'da yeniden bağlanma kuralı koyduk; yeniden bağlanan istemci son aldığı tik numarasını bildiriyor, sunucu aradaki farkı tam durum çerçevesiyle kapatıyor.
Bu katmanı yayına almadan önce sahte istemci ordusuyla sınadık: Node ile yazılmış, her biri rastgele viewport'a abone olan 10.000 sanal ekran. İlk yük testinde 6.200 bağlantıda süreç dosya tanıtıcısı limitine çarptı (ulimit'i kim unutmuş, tahmin edin). İkincisinde 8.000 civarında tik süresi 40 ms'ye tırmandı; profil, zamanın çoğunun abonelik kümesi karşılaştırmasında geçtiğini gösterdi, hücre kümelerini bitmap'e çevirince düzeldi. Yük testinin bulduğu her sorun, demo odasında bulunmayan sorundur; o şubat gününden sonra bu cümle bizde duvar yazısı oldu.
Backpressure son boss'tu. Yavaş bir istemcinin (3G'deki tablet mesela) soket tamponu dolduğunda sunucuda o oturum için biriken çerçeveleri kuyruklamak, belleği yavaş yavaş yiyen bir zehir. Bizim kural: oturumun gönderim tamponunda 3 tikten fazla birikirse ara çerçeveler atılır, sıradaki gönderim tam durum çerçevesi olur. Konum verisinin güzelliği burada — ara değerlerin kaybı telafi edilebilir, güncel durum her şeyi anlatır. Finans tick verisi taşısaydık bu lüksümüz olmazdı.
Çerçevelere artan tik numarası koymanın beklenmedik bir faydası da teşhiste çıktı. Müşteri "harita takılıyor" dediğinde istemciden son 100 tik numarasının varış zamanlarını çekiyoruz; numaralar ardışık ama aralıklar düzensizse sorun ağda, numaralar atlıyorsa sorun bizim backpressure atmalarımızda, ikisi de normalse sorun istemci makinede. Tek bir sayaç, üç şüpheliyi dakikada ayırt ettiriyor.
Bugün sistem 5.000 aracı ve eş zamanlı 1.400 ekranı, iki orta boy sanal makinede, izlenebilir gecikmesi 1,5 saniyenin altında taşıyor. Gerçek zamanlı harita, fan-out, delta protokolü tasarımı gibi konularda kafa patlatıyorsanız buradan yazın; tik hızından çerçeve şemasına kadar tartışacak çok şey birikti.