Veritabanı Yönetimi

    Subquery mi JOIN mi: Performans Karşılaştırması

    Alt sorgu ile birleştirme arasındaki gerçek performans farkı ve doğru seçim ölçütleri.

    11 dk okuma Güncellendi: 25 Ağustos 2026

    Subquery mi JOIN mi tartışması, SQL öğrenen herkesin bir noktada duyduğu "alt sorgular yavaştır, hep JOIN kullan" tavsiyesiyle başlar. Bu tavsiye bir zamanlar büyük ölçüde doğruydu; MySQL 5.5 ve öncesinde IN içindeki alt sorgu dış tablodaki her satır için baştan çalıştırılıyordu ve gerçekten felaket bir performans veriyordu. Ancak modern sürümlerde optimizasyon motoru pek çok alt sorguyu zaten arka planda birleştirmeye çevirir; dolayısıyla kural artık bu kadar basit değildir.

    Bu rehberde alt sorgu türlerini tek tek ele alıp her birinin hangi durumda birleştirmeye üstün, hangi durumda ondan zayıf olduğunu göstereceğim. IN, EXISTS, NOT IN, ilişkili alt sorgu, türetilmiş tablo ve JOIN alternatiflerini aynı soru üzerinde yazıp EXPLAIN çıktılarıyla karşılaştıracağız. Ayrıca NOT IN ile NULL birleşiminin sessizce yanlış sonuç ürettiği o meşhur tuzağı ayrıntısıyla göreceksiniz.

    Alt Sorgu Türleri#

    "Alt sorgu" tek bir şey değildir; en az dört farklı yapıyı aynı isimle anıyoruz ve performans karakterleri birbirinden çok farklıdır.

    TürNerede dururNe dönerTipik risk
    Skaler alt sorguSELECT listesi veya WHERETek değerSatır başına çalışabilir
    IN / ANY alt sorgusuWHEREDeğer listesiBüyük listede maliyet
    EXISTS alt sorgusuWHEREDoğru/yanlışİndeks yoksa pahalı
    Türetilmiş tabloFROMSatır kümesiGerçekleştirme (materialization) maliyeti

    Örneklerde önceki rehberlerdeki şemayı sürdüreceğim: musteriler, siparisler ve siparis_kalemleri tabloları. Sorumuz da hep aynı olacak: "En az bir sipariş vermiş müşterileri listele." Bu tek soruyu dört farklı yolla yazıp karşılaştıracağız.

    -- 1) IN alt sorgusu
    SELECT m.id, m.ad_soyad
    FROM   musteriler m
    WHERE  m.id IN (SELECT s.musteri_id FROM siparisler s);
    
    -- 2) EXISTS alt sorgusu
    SELECT m.id, m.ad_soyad
    FROM   musteriler m
    WHERE  EXISTS (SELECT 1 FROM siparisler s WHERE s.musteri_id = m.id);
    
    -- 3) JOIN + DISTINCT
    SELECT DISTINCT m.id, m.ad_soyad
    FROM   musteriler m
    JOIN   siparisler s ON s.musteri_id = m.id;
    
    -- 4) Türetilmiş tabloyla JOIN
    SELECT m.id, m.ad_soyad
    FROM   musteriler m
    JOIN   (SELECT DISTINCT musteri_id FROM siparisler) s ON s.musteri_id = m.id;
    

    Dördü de aynı sonucu döner. Aralarındaki fark, motorun bunları nasıl çalıştırdığında ve sizin sonradan hangi hataya düşme ihtimalinizde ortaya çıkar.

    Motorlar Alt Sorguyu Nasıl Çalıştırır#

    Modern MySQL, IN biçimindeki alt sorguları çoğunlukla yarı birleştirmeye (semi-join) dönüştürür. Yarı birleştirme, "sağ tarafta karşılığı olan sol satırları getir ama satırları çoğaltma" anlamına gelir; yani JOIN + DISTINCT yazmanın yaptığı işi tek adımda ve daha ucuza yapar. Motor bunu birkaç farklı strateji ile uygular: alt sorguyu geçici bir tabloya gerçekleştirmek, ilk eşleşmede durmak veya tekrarları sonradan elemek.

    Bunu doğrulamanın yolu planı okumaktır:

    EXPLAIN
    SELECT m.id, m.ad_soyad
    FROM   musteriler m
    WHERE  m.id IN (SELECT s.musteri_id FROM siparisler s);
    

    Çıktıda select_type sütununda DEPENDENT SUBQUERY görüyorsanız kötü haber: alt sorgu dış satır başına yeniden çalıştırılıyor demektir. SIMPLE veya MATERIALIZED görüyorsanız optimizasyon devreye girmiştir ve muhtemelen elle JOIN yazmanız bir şey kazandırmaz.

    -- MySQL 8: gerçek süreleri ve satır sayılarını gösterir
    EXPLAIN ANALYZE
    SELECT m.id, m.ad_soyad
    FROM   musteriler m
    WHERE  EXISTS (SELECT 1 FROM siparisler s WHERE s.musteri_id = m.id);
    

    EXPLAIN ANALYZE sorguyu gerçekten çalıştırıp her adımın süresini ve döndürdüğü satır sayısını yazar. Tahmini satır sayısı ile gerçek satır sayısı arasında büyük fark varsa istatistikleriniz eskimiştir; ANALYZE TABLE siparisler; çalıştırmak çoğu zaman planı düzeltir. Plan okuma pratiğini derinleştirmek isterseniz MySQL yavaş sorgu bulma yazısı adım adım ilerliyor.

    IN, EXISTS ve JOIN: Üç Cevabın Karşılaştırması#

    Üç yaklaşım arasındaki gerçek farkları şöyle özetleyebiliriz:

    ÖlçütIN (alt sorgu)EXISTSJOIN + DISTINCT
    Satır çoğalmasıYokYokVar, DISTINCT gerekir
    Erken durmaMotora bağlıİlk eşleşmede dururTümünü tarar
    NULL güvenliğiNOT IN tehlikeliGüvenliGüvenli
    Dış tablodan sütun almaAlınamazAlınamazAlınabilir
    OkunabilirlikYüksekYüksekOrta

    En önemli satır sonuncudan bir önceki: EXISTS ve IN yalnızca "var mı yok mu" sorusunu cevaplar, alt sorgudaki tablodan sütun getiremezsiniz. Müşterinin adının yanında son sipariş tutarını da göstermek istiyorsanız birleştirme zorunludur.

    DISTINCT maliyeti de hafife alınmamalıdır. JOIN yaklaşımı, on siparişi olan bir müşteri için on satır üretir ve DISTINCT bu on satırı sıralayarak ya da karma tablo kurarak teke indirir. Büyük tablolarda bu, EXISTS ile hiç ödenmeyen bir maliyettir.

    Pratik ölçütüm şudur: yalnızca varlık kontrolü yapıyorsanız EXISTS yazın; alt sorgudaki tablodan veri de gerekiyorsa JOIN yazın. IN, kısa ve sabit bir liste için (WHERE durum IN ('yeni','onaylandi')) en okunaklı seçenektir.

    NOT IN Tuzağı: NULL ile Sessiz Yanlış Sonuç#

    Bu, SQL'in en sinsi davranışlarından biridir ve mutlaka bilmeniz gerekir. NOT IN listesinde tek bir NULL varsa sorgu hiçbir satır dönmez ve size hiçbir hata da vermez.

    -- Hiç sipariş vermemiş müşteriler... ama musteri_id NULL kabul ediyorsa
    SELECT m.id, m.ad_soyad
    FROM   musteriler m
    WHERE  m.id NOT IN (SELECT s.musteri_id FROM siparisler s);
    -- Misafir siparişi (musteri_id = NULL) varsa sonuç BOŞ döner
    

    Sebebi üç değerli mantıktır. 5 NOT IN (1, 2, NULL) ifadesi, "5 ≠ 1 ve 5 ≠ 2 ve 5 ≠ NULL" biçiminde açılır. Son karşılaştırma NULL ile yapıldığı için sonucu doğru ya da yanlış değil, bilinmeyendir; bilinmeyen içeren bir VE zinciri de doğru dönemez. Yani motor "bu müşterinin listede olmadığından emin olamıyorum" der ve satırı sonuca almaz.

    Üç güvenli alternatif vardır:

    -- 1) NOT EXISTS: NULL'dan etkilenmez, en güvenlisi
    SELECT m.id, m.ad_soyad
    FROM   musteriler m
    WHERE  NOT EXISTS (SELECT 1 FROM siparisler s WHERE s.musteri_id = m.id);
    
    -- 2) LEFT JOIN + IS NULL (anti-join kalıbı)
    SELECT m.id, m.ad_soyad
    FROM   musteriler m
    LEFT   JOIN siparisler s ON s.musteri_id = m.id
    WHERE  s.siparis_no IS NULL;
    
    -- 3) NOT IN kullanacaksanız NULL'ları elemek ZORUNDASINIZ
    SELECT m.id, m.ad_soyad
    FROM   musteriler m
    WHERE  m.id NOT IN (SELECT s.musteri_id FROM siparisler s
                        WHERE s.musteri_id IS NOT NULL);
    

    Üçüncü seçenek çalışır ama kırılgandır: bugün sütun NOT NULL olsa bile şema yarın değişebilir ve sorgu sessizce yanlış sonuç vermeye başlar. Bu yüzden alışkanlık olarak NOT EXISTS veya anti-join kalıbını tercih edin. Anti-join kalıbının ayrıntısını SQL JOIN türleri yazısında bulabilirsiniz.

    İlişkili Alt Sorgu ve Satır Satır Yürütme#

    İlişkili (correlated) alt sorgu, dış sorgunun bir sütununa referans veren alt sorgudur. EXISTS kullanımı da teknik olarak ilişkilidir ama motor onu verimli biçimde optimize eder. Asıl sorun, SELECT listesindeki ilişkili skaler alt sorgulardır:

    -- Her müşteri için toplam harcama: alt sorgu satır başına çalışabilir
    SELECT m.id,
           m.ad_soyad,
           (SELECT COALESCE(SUM(s.tutar), 0)
              FROM siparisler s
             WHERE s.musteri_id = m.id) AS toplam_harcama
    FROM   musteriler m;
    

    Bu sorgu 50.000 müşteri için 50.000 ayrı toplama işlemi anlamına gelebilir. siparisler.musteri_id indeksliyse her biri hızlıdır ve toplam süre kabul edilebilir kalır; indeks yoksa sorgu pratik olarak biter gibi görünmez. Aynı işi tek geçişte yapan birleştirmeli hâli genellikle çok daha hızlıdır:

    SELECT m.id,
           m.ad_soyad,
           COALESCE(t.toplam, 0) AS toplam_harcama
    FROM   musteriler m
    LEFT   JOIN (SELECT musteri_id, SUM(tutar) AS toplam
                   FROM siparisler
                  GROUP BY musteri_id) t
           ON t.musteri_id = m.id;
    

    Burada alt sorgu bir kez çalışır, sonucu bir kez gruplanır ve birleştirme tek geçişte yapılır. Fark, veri büyüdükçe açılır: 1.000 satırda ölçemezsiniz, 500.000 satırda saniyelerle dakikalar arasındaki fark olur.

    Aynı hesabı pencere fonksiyonlarıyla yazmak, hem satır detayını hem toplamı bir arada göstermek gerektiğinde daha da temiz olur; bu yaklaşımı SQL window fonksiyonları yazısında ayrıntılı anlatıyorum.

    Türetilmiş Tablolar, CTE ve Ölçüm#

    FROM içinde yazılan alt sorguya türetilmiş tablo denir. MySQL bunları duruma göre ya dış sorguyla birleştirir (merge) ya da geçici bir tabloya yazar (materialize). Birleştirme her zaman daha iyidir çünkü dış WHERE koşulları alt sorgunun içine itilebilir ve indeksler kullanılabilir. GROUP BY, DISTINCT, LIMIT veya pencere fonksiyonu içeren türetilmiş tablolar birleştirilemez ve gerçekleştirilir.

    Ortak tablo ifadeleri (WITH) okunabilirlik açısından türetilmiş tablolardan çok daha iyidir ve MySQL 8 ile birlikte kullanılabilir:

    WITH musteri_toplam AS (
      SELECT musteri_id, SUM(tutar) AS toplam, COUNT(*) AS siparis_sayisi
      FROM   siparisler
      WHERE  olusturuldu >= '2026-01-01'
      GROUP  BY musteri_id
    )
    SELECT m.ad_soyad, t.toplam, t.siparis_sayisi
    FROM   musteriler m
    JOIN   musteri_toplam t ON t.musteri_id = m.id
    WHERE  t.toplam > 5000
    ORDER  BY t.toplam DESC;
    

    Burada dikkat edilecek bir nokta var: MySQL ortak tablo ifadesini genellikle gerçekleştirir, PostgreSQL 12 ve sonrasında ise çoğu durumda dış sorguya gömer. Yani aynı sorgu iki motorda farklı planlarla çalışabilir. WITH sözdiziminin ayrıntıları ve özyinelemeli kullanımı için SQL CTE (WITH) kullanımı yazısına bakın.

    Hangi yazımın hızlı olduğunu tahmin etmek yerine ölçün; iki alternatifi arka arkaya çalıştırıp süreleri karşılaştırmak birkaç saniyelik bir iştir:

    SET profiling = 1;         -- eski sürümlerde kullanılabilir
    -- iki alternatifi çalıştırın
    SHOW PROFILES;
    
    -- MySQL 8 için tercih edilen yol
    EXPLAIN ANALYZE SELECT ... ;
    

    Ne Zaman Hangisini Seçmeli#

    Kararı somut bir tabloya bağlayalım:

    İhtiyaçTercihNeden
    Karşılığı var mı kontrolüEXISTSİlk eşleşmede durur, satır çoğaltmaz
    Karşılığı olmayanları bulNOT EXISTS veya anti-joinNOT IN NULL ile bozulur
    İkinci tablodan sütun da gerekliJOINAlt sorgu sütun döndüremez
    Kısa sabit listeIN ('a','b','c')En okunaklı hâli
    Grup toplamını satırlara eklemekTüretilmiş tablo veya CTE ile JOINTek geçişte hesaplanır
    Aynı hesap birden çok yerdeCTETekrarı önler, sorguyu okunur kılar
    Satır detayı ve toplam bir aradaPencere fonksiyonuGruplamadan toplam verir

    Bu tabloyu bir kere içselleştirdiğinizde "alt sorgu mu birleştirme mi" sorusu kendiliğinden cevaplanır: soruyu doğru sormak, doğru yazımı zaten belirler.

    Sık Yapılan Hatalar#

    NOT IN ile NULL kabul eden bir sütunu birlikte kullanmak. Sonuç boş döner, hata verilmez. Bu, üretimde en uzun süre fark edilmeden kalan hata türlerinden biridir.

    JOIN yazıp DISTINCT eklemeyi unutmak. Bire-çok ilişkide sol tablo satırları çoğalır; toplamlar şişer ve kimse fark etmez. Toplama yapıyorsanız EXISTS veya alt sorguda gruplama daha güvenlidir.

    SELECT listesine ilişkili alt sorgu koyup indekssiz bırakmak. Alt sorgunun WHERE sütunu indeksli değilse sorgu satır sayısıyla çarpımsal büyür. Bu tür bir alt sorgu yazdığınızda ilk işiniz o sütunun indeksini kontrol etmek olmalı.

    Modern sürümde eski tavsiyeye göre optimize etmek. "Alt sorgu her zaman yavaştır" kuralı MySQL 5.5 dönemine aittir. Elle JOIN'e çevirmeden önce planı okuyun; motor zaten dönüştürüyorsa yaptığınız değişiklik sadece okunabilirliği düşürür.

    Türetilmiş tabloya gereksiz sütun ve satır almak. Alt sorguda SELECT * yazmak ve filtrelemeyi dışarıda yapmak, gerçekleştirilen geçici tabloyu gereksiz büyütür. Filtreyi mümkün olan en içteki katmana koyun.

    Ölçmeden karar vermek. İki yazımın hangisinin hızlı olduğu şemaya, veri dağılımına ve motor sürümüne bağlıdır. EXPLAIN ANALYZE bu tartışmayı otuz saniyede bitirir.

    Sıkça Sorulan Sorular#

    Alt sorgular gerçekten JOIN'den yavaş mı#

    Genel bir kural olarak artık değil. Modern MySQL ve PostgreSQL sürümleri IN ve EXISTS alt sorgularını yarı birleştirmeye dönüştürür, dolayısıyla plan çoğu zaman elle yazacağınız birleştirmeyle aynı olur. Yavaş kalan tek belirgin sınıf, SELECT listesindeki indekssiz ilişkili alt sorgulardır. Karar vermeden önce EXPLAIN çıktısına bakmak, herhangi bir genel kuraldan daha güvenilirdir.

    EXISTS mi IN mi kullanmalıyım#

    Alt sorgu başka bir tabloya bakıyorsa ve amacınız yalnızca varlık kontrolüyse EXISTS tercih edin: ilk eşleşmede durur, satır çoğaltmaz ve NULL değerlerden etkilenmez. IN, kısa ve sabit bir değer listesi için daha okunaklıdır. Olumsuz kontrollerde ise tercih net biçimde NOT EXISTS olmalıdır.

    NOT IN neden boş sonuç döndürüyor#

    Alt sorgunun döndürdüğü kümede en az bir NULL değer olduğu için. SQL'in üç değerli mantığında bir değerin NULL ile karşılaştırılması "bilinmeyen" sonucunu verir ve bilinmeyen içeren bir koşul doğru dönemez, dolayısıyla hiçbir satır sonuca girmez. Çözüm, NOT EXISTS kullanmak veya alt sorguda IS NOT NULL filtresi eklemektir.

    Türetilmiş tablo ile CTE arasında performans farkı var mı#

    MySQL 8'de ikisi de genellikle aynı biçimde gerçekleştirilir, dolayısıyla performans farkı çoğu sorguda yoktur; asıl fark okunabilirliktir. PostgreSQL 12 ve sonrasında ise CTE'ler çoğu durumda dış sorguya gömülür ve daha iyi optimize edilebilir. Aynı hesabı sorguda birden fazla yerde kullanacaksanız CTE hem tekrarı önler hem de tek gerçekleştirmeyle çalışabilir.

    Sorgumun hangi yazımının hızlı olduğunu nasıl ölçerim#

    MySQL 8'de EXPLAIN ANALYZE en pratik yoldur; sorguyu gerçekten çalıştırır, her adımın süresini ve tahmini ile gerçek satır sayısını gösterir. Ölçüm yaparken önbelleğin ilk çalıştırmayı yavaş, ikinciyi hızlı göstereceğini unutmayın; her alternatifi birkaç kez çalıştırıp ortalamaya bakın. Tahmini ve gerçek satır sayıları arasında büyük fark varsa önce ANALYZE TABLE ile istatistikleri güncelleyin.

    DISTINCT kullanmak performansı ne kadar etkiler#

    DISTINCT, sonucun sıralanmasını veya bir karma tablo kurulmasını gerektirir; küçük sonuç kümelerinde maliyeti önemsizdir, yüz binlerce satırda ise belirgin hâle gelir. Asıl mesele şudur: DISTINCT genellikle birleştirmenin ürettiği tekrarları temizlemek için eklenir ve bu tekrarları hiç üretmemek daha ucuzdur. Varlık kontrolü yapıyorsanız EXISTS kullanarak DISTINCT ihtiyacını tamamen ortadan kaldırabilirsiniz.

    Alt sorguyu geçici tabloya yazmak mantıklı mı#

    Aynı ara sonucu birden fazla sorguda kullanacaksanız ve veri o sırada değişmiyorsa, sonucu bir geçici tabloya yazıp indekslemek bazen çok işe yarar. Bu, özellikle çok adımlı toplu raporlarda etkilidir. Ancak tek bir sorgu için geçici tablo oluşturmak genellikle gereksiz karmaşıklıktır; önce CTE ve doğru indekslerle çözmeyi deneyin.

    Kapanış#

    Alt sorgu ile birleştirme arasındaki seçim, bir üstünlük yarışı değil bir uygunluk sorusudur. Aklınızda kalması gereken dört alışkanlık: varlık kontrolünde EXISTS, olumsuz kontrolde NOT EXISTS kullanın, ikinci tablodan sütun gerekiyorsa birleştirmeye geçin, ilişkili alt sorgu yazdığınızda ilgili sütunun indeksini mutlaka kontrol edin ve iki alternatif arasında kalırsanız tahmin etmek yerine EXPLAIN ANALYZE ile ölçün.

    Ağır rapor sorguları çalıştıran veritabanları bellek ve disk hızından doğrudan fayda görür. Kendi MySQL veya PostgreSQL ayarlarınızı özgürce yapmak için VDS ve bulut sunucu paketlerimize göz atabilirsiniz; sorgu optimizasyonu, indeks planlaması ve izleme tarafında destek isterseniz sunucu yönetimi hizmetimiz bu işleri de üstleniyor. Küçük ölçekli projeler için web hosting paketleri çoğu zaman yeterli olacaktır.

    SQLPerformansSorgu

    Uygulamaya geçmeye hazır mısınız?

    NVMe SSD, ücretsiz SSL ve %99.9 uptime garantisiyle Clou.TR hosting ve sunucu çözümleriyle projenizi hayata geçirin.