Destek hattına düşen kayıt şöyleydi: "Müşteri coğrafi bölge tanımlıyor, kaydet diyor, liste boş geliyor. Sayfayı yenileyince bölge ortaya çıkıyor. Müşteri sistemin verisini kaybettiğini düşünüp aynı bölgeyi üç kez tanımlamış, şimdi listede üç kopya var ve haklı olarak sinirli." Destek ekibi bunu "arayüz hatası" diye bize devretti. Kullanıcının gözünde gerçekten öyleydi. Ama arayüzün hiçbir suçu yoktu.
Yerlem'deki mimarimizde okuma trafiğini ana veritabanından ayırmak için iki read replica kullanıyorduk. Yazmalar master'a, listeleme sorguları replikalara gidiyordu. Kâğıt üzerinde zarif; pratikte, kullanıcı kendi yazdığı veriyi okuyamadığı anda zarafet bitiyor.
Suçlu Sandığımız Üç Şey
İlk hipotezimiz frontend önbelleğiydi; liste bileşeninin bayat state gösterdiğini düşündük. İki gün network kayıtları inceledik: hayır, istek gerçekten sunucuya gidiyor ve sunucu gerçekten boş liste dönüyordu. İkinci hipotez API katmanındaki sorgu önbelleğiydi; kapattık, hata sürdü. Üçüncü hipotez, transaction commit edilmeden okuma yapıldığıydı; kodu satır satır okuduk, commit yerli yerindeydi.
Üç hipotezin de çökmesi moral bozucuydu ama boşa gitmedi; her biri bir olasılığı kesin olarak eledi ve elimizde tek bir ortak gözlem kaldı: hata asla geliştirme ortamında, hep üretimde oluyordu. İki ortam arasındaki en bariz mimari fark da okuma replikalarıydı — geliştirmede tek veritabanı vardı. Şüphe oraya döndüğünde doğrulaması dakikalar sürdü.
Gerçeği izleme grafiği söyledi. Hata bildirimlerinin zaman damgalarını replikasyon gecikmesi metriğinin üzerine bindirince örtüşme kusursuzdu: şikâyetlerin tamamı, replika gecikmesinin 3 saniyeyi aştığı dakikalarda toplanmıştı. Gecikme günün büyük kısmında 200 milisaniyenin altındaydı; ama rapor saatlerinde ve toplu silme işleri sırasında 8-12 saniyeye tırmanıyordu. Kullanıcı kaydet'e basıyor, yazma master'a iniyor, liste isteği yarım saniye sonra henüz o yazmayı görmemiş replikaya düşüyordu.
Teşhis sürecinin bir yan ürünü de oldu: mükerrer kayıtlar. Kullanıcılar "kaydolmadı" sanıp aynı bölgeyi defalarca kaydettiği için veritabanında yüzlerce kopya bölge birikmişti. Bunları temizlemek başlı başına küçük bir proje oldu — hangi kopya asıl, hangisi çöp, alarm tanımları hangisine bağlı? Ve bu bize ikinci bir ders verdi: tutarlılık gecikmesi yalnızca kafa karışıklığı üretmez, kullanıcı davranışı üzerinden veri kirliliği de üretir. Kayıt formuna gönderim sonrası çift tıklama kilidi ve bölge adı üzerinde unique kısıt ekledik; replikasyon düzelse bile bu iki savunma yerinde kalacak.
Seconds_Behind_Master'ın Tatlı Yalanı
İşin utandıran kısmı: gecikme alarmımız vardı ve hiç ötmemişti. Çünkü SHOW SLAVE STATUS çıktısındaki Seconds_Behind_Master'a güveniyorduk. Bu metrik, replikanın o an işlediği event'in zaman damgasına bakar; SQL thread boşta kaldığında ya da IO thread'in kendisi geride olduğunda yanıltıcı biçimde 0 gösterebilir. Bizim vakada tek bir dev DELETE ifadesi replikada 9 saniye çalışırken metrik sıfırla dokuz arasında zıplıyor, alarm ortalamaya baktığı için susuyordu.
Çözüm eski ama sağlam bir numaraydı: master'a saniyede bir, o anın zaman damgasını yazan bir heartbeat tablosu. Replikada bu değeri okuyup şimdiki zamanla farkını alınca elinizde yalan söylemeyen, milisaniye çözünürlüklü bir gecikme ölçümü oluyor. Bu ölçümle gecikmenin 1 saniyeyi aştığı dakikaların günde toplam 23 dakikayı bulduğunu gördük. Şikâyetlerin neden "ara sıra" geldiği de böylece anlaşıldı.
Yönlendirme mekanizmasının nerede yaşayacağı da başlı başına bir karardı. ProxySQL gibi bir ara katman koyup okuma/yazma ayrımını ve master'a sabitleme mantığını oraya taşıyabilirdik; sorgu yorumlarıyla yönlendirme yapan şık kurulumlar da gördük. Biz uygulama katmanında kalmayı seçtik: bağlantı fabrikamızda zaten "okuma" ve "yazma" diye iki kapı vardı, oturum işaretine bakan üç satırlık mantık oraya doğal oturdu. Araya yeni bir ağ bileşeni koymak, onu da izlemek, onun da sürümünü yönetmek demek; iki kişilik altyapı kadromuzla her yeni kutunun bedelini biz ödüyoruz. Daha büyük bir ekipte muhtemelen tersini seçerdim; doğru cevap ekip büyüklüğüne göre değişiyor.
Gecikmeyi Küçültmek: Sebeplerin Peşinde
İki büyük gecikme kaynağı bulduk. Birincisi, gece ve öğlen koşan temizlik işlerindeki tek parça DELETE'lerdi; 2 milyon satırı tek ifadeyle silmek, tek thread çalışan replika SQL thread'ini dakikalarca meşgul ediyordu. Bunları 5.000'er satırlık parçalara bölüp aralarına küçük beklemeler koyduk; replika üzerindeki en uzun tek işlem 200 milisaniyenin altına indi. İkincisi, replikaların paralel uygulama yapmamasıydı; slave_parallel_workers ve LOGICAL_CLOCK ile commit sırası korunarak paralellik açıldı. İki değişiklik birlikte pik gecikmeyi 12 saniyeden 800 milisaniyeye çekti.
Paralel replikasyona geçiş de tamamen pürüzsüz olmadı; onu da söyleyeyim ki tablo eksik kalmasın. İlk açtığımızda bir gece boyunca replikalardan birinde tekrarlayan bir deadlock hatası aldık; sebep, uygulamadaki eski bir toplu güncelleme işinin commit sırası açısından paralelliğe uygun olmayan bir desende yazılmış olmasıydı. İşi yeniden düzenleyene kadar o replikada paralelliği 4 worker'a düşürerek idare ettik. Replikasyon ayarları tek başına dönen düğmeler değil; uygulamanızın yazma desenleriyle birlikte düşünülmesi gereken bir bütün.
Sıfıra İnmeyen Gecikmeyle Yaşamak
Ama asıl ders şu: replikasyon gecikmesi azaltılır, yok edilemez. Asenkron replikasyon kullandığınız sürece "yazdım ama okuyamadım" penceresi fizik gereği vardır. Bu yüzden uygulama katmanına read-your-writes garantisi ekledik. Yöntemimiz mütevazıydı: bir kullanıcı yazma yaptığında oturumuna kısa ömürlü bir işaret koyduk ve sonraki 3 saniye boyunca o kullanıcının okuma sorgularını master'a yönlendirdik. Trafiğin yüzde 2'sinden azı master'a kaydı; buna karşılık "kaydettim, kayboldu" şikâyeti bir daha hiç gelmedi. Daha şık yöntemler de var: GTID tabanlı bekleme fonksiyonlarıyla replikanın belirli bir noktaya gelmesini beklemek gibi. Biz basit olanı seçtik çünkü hata bütçemizi karmaşıklığa değil görünürlüğe harcamak istedik.
Failover senaryosunu da bu vesileyle elden geçirdik, çünkü gecikme yalnızca okuma tutarlılığı meselesi değil, felaket ânında veri kaybı meselesi. Master çöktüğünde en güncel replika 8 saniye gerideyse, terfi eden replika o 8 saniyenin yazmalarını hiç görmemiş olur. Heartbeat tabanlı ölçüm burada da işe yaradı: failover script'imiz artık terfiden önce adayların gecikmesine bakıyor ve yarım saniyeden gerideki adayı, binlog'un kalan kısmını uygulamadan terfi ettirmiyor. Ayrıca semi-sync replikasyonu değerlendirdik; yazma gecikmesine eklediği maliyeti ölçtük ve kritik tablolar için açmayı bir sonraki çeyreğin gündemine koyduk. Gecikme problemi bir kez görünür olunca, etrafındaki bütün kararlar daha akıllı verilebiliyor; asıl tehlike, metriğin yalan söylediği o sessiz dönemdi.
Bir de ürün tarafına dokunan ince bir karar vardı. Her ekran aynı tutarlılığa muhtaç değil: kullanıcının az önce tanımladığı bölgenin listede görünmesi anlık olmak zorunda; yönetici panosundaki "toplam araç sayısı" göstergesinin 5 saniye eski olması ise kimsenin umurunda değil. Ekranları bu gözle sınıflandırıp yalnızca gerekenleri master'a yönlendirmek, replikaları çöpe atmadan tutarlılık sağlamanın tek sürdürülebilir yolu. Tutarlılık bir altyapı ayarı değil, ekran ekran verilecek bir ürün kararı; bunu bize o üç mükerrer bölge kaydı öğretti. Benzer bir "kayboldu sanılan veri" hikâyeniz varsa bana yazın; teşhis adımlarını birlikte yürümekten keyif alırım.