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'dekiDISTINCT 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'deLATERAL ... 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 ONile logical read'leri karşılaştır. PostgreSQL'deEXPLAIN (ANALYZE, BUFFERS)çalıştır ve nested loop'un iç tarafındaloopsile 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.
- Yöntem seçmeden önce grup sayısını ve grup başına düşen satır sayısını ölç.
- Çok sayıda küçük grup için
ROW_NUMBER()ya daDISTINCT ON, az sayıda büyük grup içinTOP 1/LIMIT 1ileAPPLY/LATERALkullan. (grup_anahtari, siralama_kolonu DESC)index'i oluştur ve döndürdüğün kolonları include et.- Her zaman deterministik bir tiebreaker ekle.
- ORM'in ürettiği SQL'i kontrol et. SQL metnini değil plan'ı oku.
- ---
- baslik: "SQL'de grup başına ilk satır: sorguyu veriye göre seçmek"
- ozet: "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."
- tarih: 2026-10-21
- etiketler:
- - sql
- - sql-server
- - postgresql
- - performance
- kategoriler:
- - veritabani
- ---
- 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
- ```sql
- 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
- ```sql
- 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
- ```sql
- 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
- ```sql
- 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
- ```sql
- 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:
- ```sql
- 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
- ```csharp
- 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:
- ```csharp
- 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
- ```python
- 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:
- ```python
- 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.