<

max_connections Cehennemi: Bağlantı Havuzu Tükenmesinin Anatomisi

Telefonun titremesiyle uyandığımda saat 03:47'ydi. İzleme sistemi kısa ve net yazmıştı: "API hata oranı %62". Dizüstünü açtım, loglar tek bir cümleyi binlerce kez tekrarlıyordu: SQLSTATE[HY000] [1040] Too many connections. İşin tuhafı, gece 03:47'de trafiğimiz gündüzün onda biriydi. Kimse yokken bağlantılar nasıl tükenir? O gece öğrendim: bağlantı havuzu tükenmesi bir trafik hastalığı değil, bir gecikme hastalığıdır. Ve gecikme, gece yarısı da pekâlâ ortaya çıkabilir.

Suçluyu bulmak iki saatimi aldı; anlatınca beş dakika sürecek. Ama sırayla gidelim, çünkü asıl değerli olan aradaki yanlış hipotezler.

Bağlantı Matematiği: Herkesin Bildiğini Sandığı Hesap

Önce zemin: bizim mimaride 6 API sunucusu, her birinde PHP-FPM 80 worker'a kadar çıkabiliyor. Her worker istek başına bir MySQL bağlantısı açıyor. Tavan hesabı basit: 6 × 80 = 480 potansiyel bağlantı; max_connections ise 500. Kâğıt üstünde sığıyor. Ama bu hesap statik; gerçek dünyada kritik değişken, bağlantının açık kaldığı süre. Little Yasası burada da hükmünü sürüyor: eş zamanlı bağlantı sayısı = istek hızı × istek başına bağlantı tutma süresi. İstek hızınız sabitken sorgularınız 20 ms yerine 2 saniye sürmeye başlarsa, bağlantı ihtiyacınız 100 katına çıkar. Havuz tükenmesinin anatomisi tek cümle: bağlantı sayısı patlamaz, süre patlar.

Yanlış Hipotezler Geçidi

İlk hipotez: saldırı ya da trafik sıçraması. Nginx loglarına baktım; istek hızı normal, hatta düşük. İkinci hipotez: bağlantı sızıntısı — kapatılmayan bağlantılar birikiyor olmalı. SHOW PROCESSLIST çıktısını döktüm: 500 bağlantının 460'ı "Sleep" durumundaydı ama Time değerleri küçüktü, yani worker'lar bağlantıyı tutuyor ama uyutuyordu. Sızıntı olsaydı Time değerleri saatlere uzanırdı. Üçüncü hipotez: birisi wait_timeout'u bozdu. Hayır, 28800 duruyordu (bu arada 8 saatlik varsayılan, ayrı bir skandal ama o gecenin konusu değil).

Gerçek ipucu PROCESSLIST'in "Sleep olmayan" azınlığındaydı: 40 kadar bağlantı, aynı sorguda, dakikalardır "Waiting for table metadata lock" bekliyordu. Metadata kilidi zinciri çektim; zincirin ucunda gece 03:30'da cron'dan tetiklenen bir ALTER TABLE oturuyordu — bir meslektaşın "gece kimse yokken çalışsın" diye zamanladığı indeks ekleme işi. ALTER, uzun süren eski bir raporlama transaction'ının bitmesini bekliyordu; ALTER'ın arkasına da o tabloya dokunan her yeni sorgu dizilmişti. Worker'lar cevap alamayınca bağlantıları dakikalarca tuttu; FPM yeni istekler için yeni worker açtı, onlar da bağlantı istedi. 03:47'de 500 doldu. Trafik değil, tek bir kilit zinciri — gecikme hastalığı dediğim tam olarak bu.

O Gece ve Ertesi Hafta Yapılanlar

Acil müdahale sırasıyla: raporlama transaction'ını öldürdüm (kill), ALTER kendiliğinden aktı, kuyruk 90 saniyede eridi, hata oranı sıfırlandı. Saat 04:20'de her şey yeşildi. Asıl iş ertesi hafta yapıldı, çünkü bu olay bir kere olduysa yine olurdu.

Birincisi, şema değişikliklerini kural altına aldık: her ALTER önce lock_wait_timeout'u 30 saniyeye çekerek çalışır (varsayılanı bir yıl! — kilit alamıyorsa sonsuza dek bekleyip arkasında kuyruk büyütür), alamazsa vazgeçip tekrar dener. İkincisi, uygulama tarafına sorgu zaman aşımı koyduk; 30 saniyeyi aşan hiçbir API sorgusunun bağlantıyı rehin tutmasına izin yok. Kuyruğun kaynağında beklemek, havuzda beklemekten iyidir.

Üçüncüsü ve en yapısalı: araya ProxySQL koyduk. FPM worker'ları artık MySQL'e değil, yerel ProxySQL'e bağlanıyor; ProxySQL arka tarafta 120 bağlantılık gerçek bir havuzu çokluyor (multiplexing). 480 ön bağlantı, 120 arka bağlantıya katlanıyor çünkü "Sleep" duran bir ön bağlantının arka bağlantı işgal etmesine gerek yok — worker sorgu aralarında düşünürken (PHP tarafında JSON kurarken mesela) arka bağlantı başkasına hizmet ediyor. Devreye aldıktan sonra MySQL tarafında Threads_connected tepe değeri 470'ten 140'a indi. Aynı donanım, aynı trafik, üç kat nefes payı.

ProxySQL'in bonus hediyeleri de oldu. Sorguları kural tablosuyla yönlendirebildiğimiz için ağır rapor sorgularını replikaya, yazmaları ana makineye ayırmak uygulama koduna dokunmadan halloldu. Ve en tatlısı: veritabanını yeniden başlatmamız gereken bir bakımda ProxySQL ön bağlantıları birkaç saniye bekletip yeni arka bağlantılara devretti; uygulama katmanı kesintiyi hata olarak değil, tek seferlik bir yavaş istek olarak yaşadı.

Bu arada FPM tarafında da hesap disiplini geldi. pm.max_children değerini "sunucu belleği kaldırdığı kadar" belirlemek yaygın refleks; oysa asıl tavanı veritabanının kaldırabileceği eş zamanlılık koymalı. Biz formülü tersine çevirdik: veritabanı sağlıklı biçimde 120 eş zamanlı sorgu taşıyabiliyorsa ve isteklerin yüzde 60'ı veritabanına dokunuyorsa, tüm kümedeki worker toplamının buna göre bütçelenmesi gerekir. Fazla worker kapasite değil, kuyruğun yer değiştirmesidir — nginx'te beklemek yerine veritabanı kilidinde beklersiniz, üstelik bağlantıyı da işgal ederek.

persistent Bağlantı Efsanesi ve Küçük Ayarların Dökümü

Bu vesileyle PHP'nin kalıcı bağlantılarıyla (p: öneki, mysqli.allow_persistent) ilgili pozisyonumu da netleştireyim: FPM'de kalıcı bağlantı, havuz değildir. Her worker kendi bağlantısını ölene kadar saklar; 480 worker'ınız varsa 480 kalıcı bağlantınız olur, sadece açılış maliyetinden kurtulursunuz. Localhost'ta TCP bağlantı kurulumu zaten yarım milisaniye civarı; buna karşılık kalıcı bağlantının yarım kalan transaction'ı, değişmiş session değişkenini sonraki isteğe miras bırakma huyu, teşhisi haftalar süren tuhaflıklar üretir. Bir kez, kalıcı bağlantıda önceki isteğin SET time_zone'u yüzünden bazı müşterilere üç saat kaymış rapor gönderdik. O günden beri kalıcı bağlantı bizde yasak; havuz isteyen ProxySQL kullanır.

İzleme tarafında da iki gösterge ekledik ve ikisini de alarma bağladık. İlki doluluk oranı: Threads_connected / max_connections yüzde 70'i aşarsa uyarı — tükenme anında değil, tükenmeye giderken haber almak için. İkincisi Threads_running: eş zamanlı gerçekten çalışan sorgu sayısı. Bizim makinede bu değer 30'u aştığında gecikme eğrisi dikleşiyor; 8 çekirdekli bir veritabanının aynı anda 300 sorguyu "çalıştırması" fiziksel olarak mümkün değil, sadece bağlam değiştirerek hepsini birden yavaşlatıyor. max_connections'ı artırmak bu yüzden çoğu zaman çözüm değil, semptomu büyütme aracı: kapıdan daha çok insan almak, yangına daha çok seyirci taşımaktan ibaret.

Küçük bir dipnot daha: aynı hafta wait_timeout'u da 28800'den 300'e indirdik. Sekiz saat boyunca "Sleep" durumda tutulan bir bağlantının kimseye faydası yok; üstelik aradaki güvenlik duvarı boştaki oturumu sessizce düşürdüğünde uygulama tarafı bunu ancak bir sonraki sorguda, o meşhur "MySQL server has gone away" hatasıyla öğreniyordu. Kısa timeout + hata durumunda tek seferlik yeniden bağlanma, bu hayalet hatayı da kökünden temizledi.

Olayın kök neden raporuna yazdığım son cümleyi buraya da yazayım: havuz tükenmesi her zaman bir üst katmanın günahının veritabanında kesilen faturasıdır. Fatura kesildiğinde bakılacak yer bağlantı sayısı değil, bağlantıları rehin tutan şeydir — yavaş sorgu, kilit zinciri, dış servise takılmış bir istek ya da gece 03:30'a zamanlanmış iyi niyetli bir ALTER. Sizin sisteminizde de "Too many connections" ara ara hortluyorsa ve max_connections artırmaktan yorulduysanız buradan yazın; PROCESSLIST okumak, kahve falından daha somut bir gelecek söyler.

📅 Yayınlanma:  ·  Yakup Zengin