<

SQL'de N+1 Problemi ve ORM Tuzakları: Sessiz Performans Katili

Filo yazılımı yıllarımdan, bir araç takip müşterisinden gelen telefonla başlayayım: "Panel açılmıyor gibi bir şey, ama sunucuya dokunmadık, kod da değişmedi." Klasik cümle. Araç listesi sayfası eskiden yarım saniyede açılıyormuş, artık altı saniyeyi buluyormuş. Bağlanıp sorgu logunu açtık ve sayaç 1.601'de durdu — tek bir sayfa yüklemesi, veritabanına bin altı yüz bir ayrı sorgu atıyordu. Müşteri haklıydı, kod gerçekten değişmemişti; değişen şey filoydu. 40 araçla başlamışlar, 800 araca büyümüşlerdi. Ve kodun içinde ta ilk günden beri bir N+1 problemi uyuyordu.

Bu hikâyeyi özellikle seçtim, çünkü N+1'in en sinsi özelliğini gösteriyor: geliştirme ortamında asla görünmez. Yerel makinenizde 20 kayıtla her şey ışık hızındadır, testler yeşildir, demo alkış alır. Problem, veri büyüdükçe faiziyle birlikte tahsil edilen bir borç gibi çalışır — ve tahsilat günü hiçbir zaman uygun bir gün değildir.

Bir sorgu güzel, bin sorgu felaket

Mekanizma aslında çok basit. Listeyi tek sorguyla çekersiniz — bu, formüldeki "1". Sonra döngü içinde her satırın bir ilişkisine dokunursunuz: siparişin müşterisi, aracın son konumu, yazının yorum sayısı. ORM her dokunuşta arkanızdan yeni bir sorgu atar — bu da "N". 100 siparişlik bir sayfada müşteri adını ve teslimat adresini gösteriyorsanız hesap şöyle: 1 liste sorgusu + 100 müşteri sorgusu + 100 adres sorgusu. Veritabanına 201 kez gidip geliyorsunuz ve her gidiş-gelişin kendi ağ maliyeti, kendi bağlantı meşguliyeti var. Sorguların her biri tek başına hızlı olabilir; sizi öldüren toplamı ve gidiş-geliş sayısıdır.

İşin tuhaf tarafı, kodun gayet masum görünmesi. siparis.musteri.ad yazmak son derece doğal; ORM'lerin bütün vaadi de bu doğallık zaten. Lazy loading denen mekanizma, ilişkiye dokunduğunuz anda sorguyu sessizce atar ve size hiçbir şey hissettirmez. Konfor gerçek — faturası da gerçek. Kod incelemesinde bu satırı gören deneyimsiz bir göz hiçbir tehlike sezmez; SQL loguna bakan bir göz ise felaketi üç saniyede görür. Aradaki fark, bakılan yerdir.

Bizim 1.601 sorguluk vakada durum tam buydu: araç listesi çekiliyor, sonra her araç için son konum kaydı ve sürücü bilgisi tek tek sorgulanıyordu. 40 araçta 81 sorgu kimsenin dikkatini çekmemişti — sayfa yine de çeyrek saniyede açılıyordu çünkü; 800 araçta aynı kod, veritabanını dövmeye başladı. Üstelik yavaşlık sadece o sayfayla sınırlı kalmadı — bağlantı havuzunu işgal eden bu sorgu seli, sistemin geri kalanını da sürüklüyordu. N+1'in ikinci sinsi yüzü budur: faturayı bazen başka sayfalar öder.

Teşhis tahminle değil sayımla konur

"Sayfa yavaş, herhalde sunucu yetersiz, büyütelim" cümlesini duyduğumuzda ilk yaptığımız şey sunucuya bakmak değil, sorgu saymak. Laravel projelerinde Debugbar, Django'da debug toolbar, olmadı doğrudan veritabanının kendi sorgu logu — araç fark etmez, soru hep aynı: bu tek sayfa kaç sorgu üretiyor? Sunucu büyütmek N+1'i çözmez bu arada; sadece faturayı büyütüp tahsilat gününü erteler.

Sağlıklı bir liste sayfası tek haneli sayıda sorgu atar. On küsur sorgu "bakılsa iyi olur" demektir; üç haneli bir sayı görüyorsanız teşhis konmuştur, tartışma bitmiştir. Bu sayım o kadar ucuz ve o kadar öğreticidir ki, performans şikâyetiyle önümüze gelen her serviste işe buradan başlıyoruz. Çoğu zaman suçlu beş dakikada bulunuyor; ekip "bu kadar basit miydi" diye şaşırıyor. (Basitti. Ama bakmayan için görünmezdi.)

Bir adım ötesi, teşhisi kalıcı hâle getirmek: CI hattına "bu endpoint en fazla X sorgu atabilir" şeklinde bir test ekliyoruz. Böylece altı ay sonra ekibe yeni katılan bir arkadaşın listeye masum bir alan eklemesiyle N+1'in geri sızması, üretimde değil pull request aşamasında yakalanıyor. Performans regresyonunu test etmeyen ekip, aynı hastalığı iki kere tedavi eder — ikincisinde genellikle daha kalabalık bir toplantıyla.

Tedavi: sorguyu döngüden çıkarmak

Çözümün özü tek cümleye sığıyor: veritabanına döngü içinde değil, döngüden önce gidin. Pratikte bunun adı eager loading. Laravel'de Siparis::with(['musteri','adres']) yazdığınız anda ORM, 201 sorguluk senaryoyu 3 sorguya indirir: siparişler, ilgili tüm müşteriler, ilgili tüm adresler. Bizim araç takip vakasında da tedavi buydu — 1.601 sorgu 4'e indi, sayfa 6 saniyeden 400 milisaniyenin altına döndü. Kodda değişen satır sayısı: iki. Müşterinin "sunucu mu büyütsek" diye başlayan haftası, iki satırlık bir pull request'le kapandı.

Eager loading'in bir üst düzeyi, ilişkiden yalnızca gereken kolonları çekmek. "Müşterinin her şeyini getir" alışkanlığı, listede sadece ad-soyad gösterirken belleği ve ağ bandını çöpe atar. Toplama işlerinde de aynı ilke geçerli: döngüde sayaç toplamak yerine COUNT ve SUM'ı veritabanına yaptırın. Veritabanı motorları on yıllardır tam bu iş için optimize ediliyor; uygulama katmanındaki döngünüzle yarışmaları söz konusu bile değil.

Ve bir kural ki, ekipte neredeyse yasa hükmünde: liste ekranında lazy loading yasak. Lazy loading tekil kayıt ekranı içindir — detay sayfasında bir ilişkiye ihtiyaç olursa o anda yüklenir, gayet güzel. Ama liste + lazy kombinasyonu tanım gereği N+1'dir; istisnası yoktur. Modern ORM'lerin çoğunda lazy loading'i tümden kapatıp ihlali exception'a çevirmek mümkün — Laravel'de Model::preventLazyLoading() tam bunu yapar. Yeni servislerimizde bunu ilk gün açıyoruz: geliştirici N+1 yazdığı anda uygulama yüzüne karşı patlıyor ve hata üretime hiç ulaşmıyor. Biraz acımasız, fazlasıyla etkili.

ORM suçlu değil, okunmayan SQL suçlu

Bu yazıdan "ORM'ler kötü, ham SQL'e dönün" sonucu çıkmasın; öyle düşünmüyoruz. ORM'ler geliştirme hızına, kod okunabilirliğine ve güvenliğe (merhaba SQL injection) gerçek katkı sunuyor; Yıllar içinde dokunduğum yüzlerce projenin ezici çoğunluğunda ben de ORM kullandım, bugün ekipçe kullanmaya devam ediyoruz. Bizim itirazımız bilinçsiz kullanıma: ürettiği SQL'i hiç okumadığınız bir ORM, sizin adınıza teknik borç senedi imzalayan bir vekildir. Arada bir sorgu loguna bakmak, o senetleri vadesinden önce görmenin en ucuz yoludur. Ekipte yeni başlayan her geliştiriciye söylediğimiz cümle şu: ORM senin yazdığın kod değil, senin adına yazılan koddur — ve başkasının senin adına yazdığı kodu ara sıra okumak, en temel mesleki tedbirdir.

Sizin de "hiçbir şey değişmedi ama yavaşladı" diyen bir paneliniz varsa, büyük ihtimalle bir yerlerde N büyümüştür. Sorgu sayımı genellikle bir kahve molasından uzun sürmüyor; tedavisi de çoğu zaman iki satır. Bu tür teşhis hikâyelerini konuşmayı severim; iletişim sayfası açık.

📅 Yayınlanma:  ·  Yakup Zengin