TekinAli ⎇ main

SQL'de grup başına ilk satır: sorguyu veriye göre seçmek

SQL Server, PostgreSQL, EF Core ve Spark'ta grup başına ilk ya da ilk N satırı seçme rehberi: her kalıp nasıl çalışır, hangisinin kazanacağını veri dağılımı neden belirler, hangi index'ler gerekir ve seçim plan üzerinde nasıl doğrulanır.

Er ya da geç neredeyse her uygulama grup başına tek bir satıra ihtiyaç duyar: her müşterinin son siparişi, her sensörün son ölçümü, her ürünün güncel fiyatı. Küçük bir sorgu gibi görünür. Pratikte ise aynı sonucun maliyeti çok farklı olabilir, çünkü en iyi çalışma stratejisi yazdığın SQL'den çok verinin şekline bağlıdır.

Bu yazıda SQL Server ve PostgreSQL'deki temel kalıpları, EF Core ve Spark'ın aynı fikri nasıl ifade ettiğini ve doğru yöntemi seçtiğini execution plan'den nasıl doğrulayacağını anlatıyorum.

Örnek: her müşterinin son siparişi

Elimizde customer_id, created_at, amount ve status kolonları olan bir orders tablosu var. Her müşterinin en son siparişini istiyoruz.

En doğrudan yol, GROUP BY customer_id ile MAX(created_at) alıp sonucu tabloya geri join'lemek. Çalışır, ama tabloyu iki kez okur. Aynı zaman damgasına sahip iki sipariş varsa da duplicate döner. Aynı problem pandas'ta, Spark'ta ve her ORM'de karşına çıkar. Değişen tek şey, motorun bu işi ne kadar verimli yapabildiği.

Plan'i veri dağılımı belirler

Bu yazıdaki her kalıp iki stratejiden birini izler. Birincisi bütün satırları grup sırasıyla bir kez okur ve her grubun en üstteki satırını tutar. Window function'lar ve DISTINCT ON böyle çalışır. İkincisi grupların listesini gezer ve her grup için index üzerinden doğrudan en üstteki satıra seek yapar. CROSS APPLY ve LATERAL böyle çalışır.

Hangi stratejinin kazanacağı grup sayısı ile satır sayısı arasındaki orana bağlı. 200 müşteriye dağılmış 10 milyon satırlık bir tabloda, her satırı okuyan her yöntem 200 index seek'e kaybeder. Aynı 10 milyon satır 5 milyon müşteriye aitse, o seek'ler 5 milyon küçük aramaya dönüşür ve sıralı tek bir tarama kazanır. Benim pratik kuralım şu:

  • Çok sayıda grup ve grup başına az satır varsa: window function (ROW_NUMBER()) ya da PostgreSQL'deki DISTINCT ON. Veriyi tek seferde okumak burada zor geçilir.
  • Az sayıda grup ve grup başına çok satır varsa: her grup için bir index seek. SQL Server'da CROSS APPLY (SELECT TOP 1 ...), PostgreSQL'de LATERAL ... LIMIT 1, arkasında da bir (grup_anahtari, siralama_kolonu DESC) index'i.
  • Az grup varsa ama grupları listeleyen ayrı bir tablo yoksa: PostgreSQL'de loose index scan'i taklit eden bir recursive CTE.

Hangisini seçersen seç, kararı sorgunun metnine bakarak değil execution plan'e bakarak doğrula.

SQL Server

CTE içinde ROW_NUMBER

WITH ranked AS (
    SELECT o.customer_id, o.created_at, o.amount, o.status,
           ROW_NUMBER() OVER (PARTITION BY o.customer_id
                              ORDER BY o.created_at DESC, o.order_id DESC) AS rn
    FROM dbo.orders AS o
)
SELECT customer_id, created_at, amount, status
FROM ranked
WHERE rn = 1;

ORDER BY'a order_id gibi bir tiebreaker eklemeyi unutma. Eklemezsen, iki sipariş aynı zaman damgasını paylaştığında sonuç deterministik olmaz. Plan'da Segment, Sequence Project ve Filter operatörlerini görmen gerekir. Yanlarında bir de Sort varsa, satırları (customer_id, created_at DESC) sırasıyla getiren bir index yok demektir. Büyük tablolarda maliyetin çoğu genellikle bu sort'tan ve onun memory grant'inden gelir.

CROSS APPLY ve TOP 1

CREATE INDEX IX_orders_customer_created
    ON dbo.orders (customer_id, created_at DESC)
    INCLUDE (amount, status);

SELECT c.customer_id, x.created_at, x.amount, x.status
FROM dbo.customers AS c
CROSS APPLY (
    SELECT TOP (1) o.created_at, o.amount, o.status
    FROM dbo.orders AS o
    WHERE o.customer_id = c.customer_id
    ORDER BY o.created_at DESC, o.order_id DESC
) AS x;

Görmek istediğin plan, iç tarafında Index Seek ve Top olan bir Nested Loops. Bu durumda müşterinin kaç siparişi olursa olsun, her müşteri birkaç logical read'e mal olur. Siparişi olmayan müşterileri de görmek istiyorsan OUTER APPLY kullan. INCLUDE kolonları önemli: onlar olmadan her seek'in arkasından bir Key Lookup gelir ve avantaj hızla erir. Bu kalıp grupları süren bir tabloya ihtiyaç duyar, burada customers. Bu listeyi SELECT DISTINCT customer_id FROM orders ile üretmen gerekiyorsa, tabloyu zaten bir kez taramış olursun.

TOP 1 WITH TIES

SELECT TOP (1) WITH TIES customer_id, created_at, amount, status
FROM dbo.orders
ORDER BY ROW_NUMBER() OVER (PARTITION BY customer_id ORDER BY created_at DESC);

Kısa ve doğru bir sorgu, ama nadiren en hızlısı. Motor her satır için row number hesaplayıp ardından buna göre sıralamak zorunda kalıyor. Ad-hoc sorguda kullanırım, production kodunda kullanmam.

PostgreSQL

DISTINCT ON

SELECT DISTINCT ON (customer_id)
       customer_id, created_at, amount, status
FROM orders
ORDER BY customer_id, created_at DESC, order_id DESC;

PostgreSQL'e özgü ve çok okunaklı. ORDER BY'ın baştaki ifadeleri DISTINCT ON ifadeleriyle aynı olmalı. Her satırı okur. (customer_id, created_at DESC) index'i varsa üstünde Unique olan sıralı bir index scan görürsün, yoksa bir Sort. ROW_NUMBER yaklaşımının PostgreSQL karşılığı diyebiliriz: çok sayıda küçük grupta mükemmel, birkaç büyük grupta israf.

LATERAL

SELECT c.customer_id, o.created_at, o.amount, o.status
FROM customers AS c
CROSS JOIN LATERAL (
    SELECT created_at, amount, status
    FROM orders
    WHERE orders.customer_id = c.customer_id
    ORDER BY created_at DESC, order_id DESC
    LIMIT 1
) AS o;

CROSS APPLY'ın karşılığı bu. Siparişi olmayan müşterileri tutmak için LEFT JOIN LATERAL ... ON true yaz. Index'te INCLUDE (amount, status) varsa ve visibility map büyük ölçüde güncelse, iç tarafta Index Only Scan de alabilirsin.

Loose index scan yerine recursive CTE

Az sayıda farklı grup varsa ama onları listeleyen bir tablo yoksa, loose index scan'i taklit edebilirsin. CTE, index üzerinden bir anahtardan sonrakine atlar:

WITH RECURSIVE keys AS (
    (SELECT customer_id FROM orders ORDER BY customer_id LIMIT 1)
    UNION ALL
    SELECT (SELECT o.customer_id
            FROM orders AS o
            WHERE o.customer_id > k.customer_id
            ORDER BY o.customer_id
            LIMIT 1)
    FROM keys AS k
    WHERE k.customer_id IS NOT NULL
)
SELECT x.*
FROM keys AS k
CROSS JOIN LATERAL (
    SELECT customer_id, created_at, amount, status
    FROM orders
    WHERE orders.customer_id = k.customer_id
    ORDER BY created_at DESC, order_id DESC
    LIMIT 1
) AS x
WHERE k.customer_id IS NOT NULL;

Çirkin, ama 200 müşteride 10 milyon satır okumak yerine 400 civarı küçük index araması yapar.

PostgreSQL 18'de skip scan ve kapsamı

25 Eylül 2025'te çıkan PostgreSQL 18 ile B-tree skip scan geldi. Release notes'a göre bu özellik, baştaki kolonlarda hiç kısıt olmadığında ya da yalnızca eşitlik dışı kısıtlar olduğunda, ama sonraki bir kolonda işe yarar bir kısıt bulunduğunda çok kolonlu B-tree index'lerin kullanılabilmesini sağlıyor. Yani customer_id ile başlayan bir index üzerinde WHERE created_at >= now() - interval '1 day' gibi bir sorguya yardımcı oluyor. Release notes bunu DISTINCT ON ya da GROUP BY optimizasyonu olarak sunmuyor. O yüzden recursive CTE'nin yerini tutmasını beklemezdim. Kendi sürümünde EXPLAIN ile kontrol et.

EF Core

var latest = await db.Orders
    .GroupBy(o => o.CustomerId)
    .Select(g => g.OrderByDescending(o => o.CreatedAt)
                  .ThenByDescending(o => o.OrderId)
                  .First())
    .ToListAsync();

EF Core 6.0, GroupBy'dan sonra grup üzerinde First/FirstOrDefault çağrısını ve Take ile grup başına top-N'i çevirmeye başladı. Önceki sürümler ya exception fırlatıyor ya da sorguyu client tarafında değerlendiriyordu. SQL Server'da üretilen SQL genelde ROW_NUMBER() OVER (PARTITION BY ...) ve bir row <= 1 filtresi içeriyor. Yani veri buna uygun olsun ya da olmasın window function şeklini alıyorsun. APPLY şeklini istiyorsan sorguya parent tablodan başla:

var latest = await db.Customers
    .Select(c => c.Orders
                  .OrderByDescending(o => o.CreatedAt)
                  .FirstOrDefault())
    .ToListAsync();

İki durumda da üretilen SQL'i ToQueryString() ya da LogTo ile logla ve plan'a bak. Varsayımla ilerleme.

Spark

from pyspark.sql import Window, functions as F

w = Window.partitionBy("customer_id").orderBy(F.col("created_at").desc(), F.col("order_id").desc())

latest = (orders
          .withColumn("rn", F.row_number().over(w))
          .where(F.col("rn") == 1)
          .drop("rn"))

Window yaklaşımı veriyi customer_id'ye göre shuffle eder ve her partition'ı kendi içinde sıralar. Kazanan satırdan yalnızca birkaç kolon gerekiyorsa max_by daha iyi bir seçenek. Bir aggregate olduğu için grup içinde sıralama gerektirmez:

latest = (orders
          .groupBy("customer_id")
          .agg(F.max_by(F.struct("created_at", "amount", "status"), "created_at").alias("o"))
          .select("customer_id", "o.*"))

max_by/min_by, SQL fonksiyonu olarak Spark 3.0'dan, PySpark DataFrame fonksiyonu olarak 3.3.0'dan beri var. Eşitlik durumunda deterministik değiller. Bu senin için önemliyse sıralama ifadesine bir tiebreaker ekle. orderBy'dan sonra dropDuplicates kullanma, çünkü Spark hangi satırın kalacağını garanti etmiyor.

Tuzaklar

  • Tiebreaker yok. Aynı zaman damgasına sahip iki satır, çalıştırmadan çalıştırmaya değişen sonuçlar üretir. ORDER BY'ın sonuna her zaman unique bir kolon ekle.
  • Index yönü yanlış ya da INCLUDE kolonları eksik. Bu durumda seek'ler scan'e ya da her satır için key lookup'a dönüşür.
  • Plan yerine sorguyu okumak. SQL Server'da SET STATISTICS IO ON ile logical read'leri karşılaştır. PostgreSQL'de EXPLAIN (ANALYZE, BUFFERS) çalıştır ve nested loop'un iç tarafında loops ile satır sayısının çarpımına bak.
  • Çarpık veri. Satırların %90'ı beş müşteriye aitse, eşit dağılımlı bir test setiyle değil, production'a benzeyen veriyle test et.

Özet ve kontrol listesi

Grup başına tek satır seçmek, sözdiziminden önce bir strateji meselesi. Çok sayıda küçük grupta tarama tabanlı kalıplar, az sayıda büyük grupta seek tabanlı kalıplar kazanır. Doğru index ise ikisini de ucuzlatır. ORM'ler ve Spark da aynı kurallara tabi, bu yüzden ürettikleri sorguyu kontrol et.

  1. Yöntem seçmeden önce grup sayısını ve grup başına düşen satır sayısını ölç.
  2. Çok sayıda küçük grup için ROW_NUMBER() ya da DISTINCT ON, az sayıda büyük grup için TOP 1/LIMIT 1 ile APPLY/LATERAL kullan.
  3. (grup_anahtari, siralama_kolonu DESC) index'i oluştur ve döndürdüğün kolonları include et.
  4. Her zaman deterministik bir tiebreaker ekle.
  5. ORM'in ürettiği SQL'i kontrol et. SQL metnini değil plan'ı oku.
tekinali.db · salt okunur · ● bağlı

SQLite 3.53.3 · 8 tablo, 2 view.

Soldaki bir tabloya tıkla, aşağıdan bir sorgu seç ya da kendin yaz. Yardım: \?

↑↓ seç · Enter aç · Esc kapat