SQL sorguları: join, gruplama, window function ve live coding kalıpları
Live coding'de SQL yazarken doğru sonucu veren, NULL ve fan-out tuzaklarına düşmeyen, eşzamanlılığa dayanıklı sorguları neden öyle yazdığını açıklayarak kurmayı öğretir.
Bu bölümde
Bu bölüm, live coding'de önüne bir şema konup "şu raporu çıkar" dendiğinde kullanacağın SQL kalıplarını anlatır: join türleri, NULL'un üç değerli mantığı, anti-join, gruplama, fan-out, window function'lar, grup başına ilk N, CTE ve recursive CTE, upsert, koşullu UPDATE ve sayfalama. Mülakatta bu konu üç şekilde gelir: canlı SQL yazma (en sık), "bu sorgu neden yanlış sonuç veriyor" diye kod okuma ve "50 milyon satırda bunu nasıl yaparsın" diye ölçek sorusu. Tüm örnekler live coding provasındaki e-ticaret şemasıyla (customers, orders, order_items, products, sellers, categories, returns, stock) yazıldı ve PostgreSQL 16/17 üzerinde çalıştırılarak doğrulandı.
Konu anlatımı
Şema ve SQL'in mantıksal çalışma sırası
Bölüm boyunca kullanılan şema:
categories (id, name, parent_id) -- ağaç: parent_id kendi tablosuna
sellers (id, name, city)
customers (id, name, email, city, created_at)
products (id, seller_id, category_id, name, price)
orders (id, customer_id, status, created_at) -- CREATED, SHIPPED, DELIVERED, CANCELLED
order_items (order_id, product_id, quantity, unit_price) -- PK (order_id, product_id)
returns (id, order_id, product_id, quantity, reason, created_at)
stock (product_id, quantity CHECK (quantity >= 0))
SQL yazım sırası (SELECT ... FROM ... WHERE ...) ile veritabanının sorguyu mantıksal olarak değerlendirme sırası farklıdır:
FROM/JOIN(satır kümesi kurulur)WHERE(satırlar elenir)GROUP BY(satırlar gruplara toplanır)HAVING(gruplar elenir)SELECT(ifadeler ve window function'lar hesaplanır, alias'lar burada doğar)DISTINCTORDER BYLIMIT/OFFSET
Bu sıra ezberlenecek bir liste değil, birçok hata mesajının açıklamasıdır:
SELECT price * 2 AS dbl FROM products WHERE dbl > 100;
-- ERROR: column "dbl" does not exist (alias SELECT'te doğar, WHERE ondan önce çalışır)
SELECT id FROM products WHERE row_number() OVER (ORDER BY price) = 1;
-- ERROR: window functions are not allowed in WHERE
ORDER BY SELECT'ten sonra geldiği için alias'ı kullanabilir. PostgreSQL ayrıca GROUP BY içinde de SELECT alias'ına izin verir (GROUP BY e), bu standart dışı bir kolaylıktır. Bu sıranın "mantıksal" olduğunu vurgula: planner fiziksel olarak filtreyi join'den önce uygulayabilir, index'ten sıralı okuyup sort'u atlayabilir. Sonucun anlamı ise her zaman bu sıraya göre tanımlıdır.
JOIN türleri ve NULL davranışı
- INNER JOIN: Join koşulu iki tarafta da sağlanan satır çiftleri.
- LEFT JOIN: Soldaki her satır en az bir kez gelir. Sağda eşleşme yoksa sağ tablonun kolonları NULL olur.
- RIGHT JOIN: LEFT'in aynası; pratikte tabloların yeri değiştirilip LEFT yazılır.
- FULL JOIN: İki tarafın da eşleşmeyen satırları gelir. Mutabakat (reconciliation) sorgularında işe yarar: "DB'de olup Elasticsearch'te olmayan ve tersi".
- CROSS JOIN: Kartezyen çarpım. Takvim üretmek (
generate_series) ya da her kategori için bir şey hesaplamak için bilinçli kullanılır. - LATERAL: Sağdaki alt sorgu soldaki satırın kolonlarını görebilir. "Her müşteri için son 3 sipariş" gibi satır başına alt sorgu çalıştırır.
Join koşulunda NULL hiçbir şeye eşit değildir, kendisine bile. a.x = b.x iki taraf da NULL olduğunda UNKNOWN döner ve satır eşleşmez. Nullable bir kolon üzerinden join yapıyorsan NULL'lu satırların sessizce düştüğünü bilmelisin.
LEFT JOIN'in en sık bozulduğu yer, sağ tabloya ait filtrenin nereye yazıldığıdır:
-- 18 ürün var, 16'sının stock kaydı var, 1'inin stoğu 0.
SELECT count(*) FROM products p
LEFT JOIN stock s ON s.product_id = p.id
WHERE s.quantity > 0; -- 15: LEFT JOIN fiilen INNER JOIN'e döndü
SELECT count(*) FROM products p
LEFT JOIN stock s ON s.product_id = p.id AND s.quantity > 0; -- 18: tüm ürünler korunur
Mekanizma: Eşleşmeyen ürünlerde s.quantity NULL'dur. NULL > 0 UNKNOWN olur ve WHERE sadece TRUE olan satırları geçirir. Kural: sağ tabloya ait koşul ON'a, sonucun tamamına ait koşul WHERE'e. Tek istisna bilinçli anti-join'dir: WHERE s.product_id IS NULL.
NULL ve üç değerli mantık
SQL'de bir karşılaştırmanın üç sonucu vardır: TRUE, FALSE, UNKNOWN. NULL "değer yok / bilinmiyor" demektir; bilinmeyen bir şeyle yapılan karşılaştırmanın sonucu da bilinmez.
| İfade | Sonuç | Neden |
|---|---|---|
NULL = NULL | NULL (UNKNOWN) | İki bilinmeyenin eşit olup olmadığı bilinemez |
NULL <> 1 | NULL | Aynı sebep |
1 IN (1, NULL) | TRUE | Bir eşleşme bulundu |
2 IN (1, NULL) | NULL | 2 = 1 FALSE, 2 = NULL UNKNOWN; OR'ları UNKNOWN |
2 NOT IN (1, NULL) | NULL | 2 <> 1 AND 2 <> NULL = TRUE AND UNKNOWN = UNKNOWN |
TRUE OR NULL | TRUE | Sonuç diğer taraftan bağımsız |
FALSE AND NULL | FALSE | Sonuç diğer taraftan bağımsız |
NULL IS DISTINCT FROM NULL | FALSE | NULL'u değer gibi karşılaştıran operatör |
Pratik sonuçlar:
WHERE col = NULLhiçbir zaman satır döndürmez.IS NULL/IS NOT NULLkullanılır.- "Değişti mi?" kontrolü için
old IS DISTINCT FROM newyazılır.old <> newNULL'dan değere geçişi kaçırır. ORDER BYPostgreSQL'de NULL'ları en büyük değer gibi sıralar: ASC'de sonda, DESC'de başta. GerekirseNULLS FIRST/NULLS LASTile açıkça belirtilir. "En son giriş tarihine göre azalan" sıralamada hiç giriş yapmamış kullanıcılar listenin başına çıkar; bu klasik bir rapor hatasıdır.UNIQUE (a, b)constraint'i(1, NULL)satırını birden fazla kez kabul eder, çünkü NULL'lar birbirine eşit sayılmaz. PostgreSQL 15+ ileUNIQUE NULLS NOT DISTINCT (a, b)yazılırsa ikinci satır reddedilir.coalesce(x, 0)NULL'u varsayılan değere çevirir,NULLIF(x, 0)ise 0'ı NULL'a çevirir (sıfıra bölmeyi önlemek için).
COUNT(*), COUNT(col) ve boş kümelerde aggregate
count(*)satır sayar, değerlere bakmaz.count(col)sadececolNULL olmayan satırları sayar.count(DISTINCT col)NULL olmayan farklı değerleri sayar.sum,avg,min,maxNULL'ları yok sayar.avgbu yüzden "NULL'ları sıfır kabul ederek" değil, "NULL'ları dışarıda bırakarak" ortalar.
SELECT count(*), count(s.quantity)
FROM products p LEFT JOIN stock s ON s.product_id = p.id;
-- 18 | 16 (stoğu olmayan 2 ürünün s.quantity'si NULL)
Boş küme davranışı sık sorulur: SELECT count(*), sum(quantity), max(quantity) FROM stock WHERE false tek satır döner: 0 | NULL | NULL. Yani count boş kümede 0, diğerleri NULL'dur. Uygulama tarafında BigDecimal total = jdbc.queryForObject("SELECT sum(...)", BigDecimal.class) çağrısı NULL döner ve bir sonraki .add() NullPointerException fırlatır. Çözüm: coalesce(sum(...), 0). Ayrıca GROUP BY varken boş girdi sıfır satır döner, tek bir 0 satırı değil.
Anti-join: NOT EXISTS, NOT IN ve LEFT JOIN ... IS NULL
"Hiç siparişi olmayan müşteriler" bir anti-join'dir: karşı tabloda eşleşmesi olmayan satırlar. Üç yazım vardır:
-- 1) NOT EXISTS: önerilen
SELECT c.id, c.name FROM customers c
WHERE NOT EXISTS (SELECT 1 FROM orders o WHERE o.customer_id = c.id);
-- 2) LEFT JOIN + IS NULL: eşdeğer
SELECT c.id, c.name FROM customers c
LEFT JOIN orders o ON o.customer_id = c.id
WHERE o.id IS NULL;
-- 3) NOT IN: tuzaklı
SELECT c.id, c.name FROM customers c
WHERE c.id NOT IN (SELECT customer_id FROM orders);
NOT IN'in NULL tuzağı: Alt sorgu tek bir NULL döndürürse c.id NOT IN (..., NULL) hiçbir satır için TRUE olamaz (yukarıdaki tabloya bak), sorgu boş sonuç döner. Seed verisinde alt sorguya yapay bir NULL eklendiğinde NOT IN 0 satır, NOT EXISTS ise doğru cevap olan 2 satır döndü. Burada orders.customer_id NOT NULL olduğu için tuzak tetiklenmez; ama alt sorgu yarın nullable bir kolona veya LEFT JOIN'li bir view'a dönerse sorgu sessizce bozulur. Hata vermez, sadece yanlış (boş) sonuç verir. Bu yüzden alışkanlık olarak NOT EXISTS yazılır.
Performans tarafı: PostgreSQL, NOT EXISTS'i ve LEFT JOIN/IS NULL'u gerçek bir anti-join'e çevirir (planda Hash Anti Join, PostgreSQL 16+'da Hash Right Anti Join, Merge Anti Join veya Nested Loop Anti Join). NOT IN ise NULL semantiği yüzünden anti-join'e çevrilemez; planda hashed SubPlan'lı bir filtre görürsün: PostgreSQL 16'da Filter: (NOT (hashed SubPlan 1)), 17+'da Filter: (NOT (ANY (id = (hashed SubPlan 1).col1))). Alt sorgu sonucu work_mem'e sığarsa hash'lenir ve hızlıdır. Sığmazsa hash'lenmemiş subplan olarak her dış satır için alt sorgu sonucunu tarar, büyük tablolarda karesel maliyete gider. Yani NOT IN hem doğruluk hem performans riski taşır.
Pozitif yönde (IN, EXISTS) NULL tuzağı yoktur: 1 IN (1, NULL) TRUE'dur ve NULL'lu satır sadece eşleşme üretmez. EXISTS ve IN (subquery) PostgreSQL'de ikisi de semi-join'e çevrilebilir; seçim okunurluğa göre yapılır.
GROUP BY, HAVING ve WHERE ile HAVING farkı
- WHERE tek tek satırları gruplamadan önce eler. Aggregate kullanamaz.
- HAVING grupları gruplamadan sonra eler. Aggregate kullanır.
SELECT c.id, c.name, count(DISTINCT p.category_id) AS category_count
FROM customers c
JOIN orders o ON o.customer_id = c.id
JOIN order_items oi ON oi.order_id = o.id
JOIN products p ON p.id = oi.product_id
WHERE o.status = 'DELIVERED' -- satır filtresi
GROUP BY c.id, c.name
HAVING count(DISTINCT p.category_id) >= 3; -- grup filtresi
Aggregate olmayan bir koşulu HAVING'e yazmak hata vermez ama israftır: satırlar boşuna gruplanır, sonra atılır. (PostgreSQL basit durumlarda bunu WHERE'e taşıyabilir, ama niyeti doğru yere yazmak okuyucuya da yardım eder.)
SELECT listesi kuralı: GROUP BY varken SELECT'teki her kolon ya GROUP BY'da olmalı ya da bir aggregate içinde olmalı. Aksi halde "hangi satırın değerini göstereyim?" sorusunun cevabı yoktur ve PostgreSQL hata verir: column "c.city" must appear in the GROUP BY clause or be used in an aggregate function. İstisna: functional dependency. Bir tablonun primary key'ine göre gruplarsan, PostgreSQL o tablonun diğer kolonlarının tek değerli olduğunu bilir: GROUP BY c.id yazıp c.name, c.city seçebilirsin. (MySQL'in eski ONLY_FULL_GROUP_BY kapalı modu ise rastgele bir değer döndürürdü; bu yüzden MySQL'den gelenlerin alışkanlıkları tehlikelidir.)
İsim yerine id'ye göre grupla: GROUP BY s.name aynı isimli iki satıcıyı tek satıra birleştirir. Seed verisinde iki farklı "Ayşe Yılmaz" müşterisi (id 1 ve 10) var; isme göre gruplayan bir müşteri raporu ikisini birleştirip yanlış toplam verir. GROUP BY s.id, s.name hem doğru hem okunurdur.
Fan-out: join sonrası SUM şişmesi
Join, satırları çoğaltabilir. Bire-çok bir join'den sonra "bir" tarafındaki bir değeri toplarsan, o değer "çok" tarafındaki satır sayısı kadar tekrar sayılır. Buna fan-out denir ve live coding'de en sık yapılan SQL hatasıdır, çünkü sorgu hata vermez, sadece büyük sayılar üretir.
Seed verisi üzerinde gerçek bir örnek: kategori bazında satılan adet ve iade edilen adedi tek join'de toplamak.
-- YANLIŞ: order_items ile returns aynı join'de
SELECT c.name, sum(oi.quantity) AS sold, sum(r.quantity) AS returned
FROM order_items oi
JOIN orders o ON o.id = oi.order_id AND o.status = 'DELIVERED'
JOIN products p ON p.id = oi.product_id
JOIN categories c ON c.id = p.category_id
LEFT JOIN returns r ON r.product_id = p.id
GROUP BY c.name;
-- Şarj Aleti: sold = 31, returned = 16
Doğru değerler sold = 18, returned = 2. Ürün 8'in iki iade kaydı olduğu için ürün 8'in her sipariş satırı iki kez sayıldı; iadeler de ürünün her sipariş satırı kadar tekrarlandı.
Çözüm: önce topla, sonra birleştir (aggregate-then-join). Her ölçüyü kendi CTE'sinde join anahtarına (burada kategori) indirgeyip birleştirirsin:
WITH sold AS (
SELECT p.category_id, sum(oi.quantity) AS sold_qty
FROM order_items oi
JOIN orders o ON o.id = oi.order_id AND o.status = 'DELIVERED'
JOIN products p ON p.id = oi.product_id
GROUP BY p.category_id
), returned AS (
SELECT p.category_id, sum(r.quantity) AS returned_qty
FROM returns r
JOIN products p ON p.id = r.product_id
GROUP BY p.category_id
)
SELECT s.category_id, s.sold_qty, coalesce(r.returned_qty, 0) AS returned_qty
FROM sold s
LEFT JOIN returned r ON r.category_id = s.category_id;
Her CTE kategori başına tek satır üretir; 1:1 join'de çoğalma olmaz.
Sayımlarda count(DISTINCT o.id) fan-out'u maskeleyebilir (aynı sipariş birden çok kez gelse de bir kez sayılır). Toplamlarda bu hile çalışmaz: sum(DISTINCT amount) aynı tutarlı iki farklı siparişi tek sayar (sum(DISTINCT x) değerleri 100, 100, 50 için 150 döner, doğru cevap 250). Fan-out şüphesi olduğunda kendine şu soruyu sor: "Bu join'den sonra satırın tanecikliği (grain) ne? Topladığım kolon o tanecikte bir kez mi var?"
Koşullu aggregation: FILTER ve CASE
Tek geçişte birden fazla metrik hesaplamak için aggregate'e koşul eklenir:
SELECT s.name,
count(DISTINCT o.id) AS total_orders,
count(DISTINCT o.id) FILTER (WHERE o.status = 'CANCELLED') AS cancelled_orders,
round(100.0 * count(DISTINCT o.id) FILTER (WHERE o.status = 'CANCELLED')
/ NULLIF(count(DISTINCT o.id), 0), 1) AS cancel_rate
FROM sellers s
JOIN products p ON p.seller_id = s.id
JOIN order_items oi ON oi.product_id = p.id
JOIN orders o ON o.id = oi.order_id
GROUP BY s.id, s.name;
FILTER (WHERE ...) SQL standardındadır ve PostgreSQL 9.4'ten beri vardır. FILTER'ı desteklemeyen veritabanlarında eşdeğeri sum(CASE WHEN o.status = 'CANCELLED' THEN 1 ELSE 0 END) ya da count(CASE WHEN ... THEN 1 END) (ELSE yazılmazsa NULL döner ve count saymaz).
İki aritmetik tuzak:
- Tam sayı bölmesi:
3 / 10integer'dır ve0döner.100.0 * a / byazınca ifade numeric'e döner. - Sıfıra bölme:
a / 0hata fırlatır ve tüm rapor patlar.a / NULLIF(b, 0)payda sıfırsa NULL döner.
Bölümün toplam içindeki payı (yüzde) için aggregate'in üzerine window yazılabilir: 100.0 * sum(x) / sum(sum(x)) OVER (). İçteki sum GROUP BY aşamasında, dıştaki sum ... OVER () SELECT aşamasında tüm gruplar üzerinden hesaplanır.
Window function'lar
Window function, satırları birleştirmeden her satırın yanına bir "pencere" (ilişkili satırlar kümesi) üzerinden hesaplanan bir değer ekler. GROUP BY 10 satırı 1 satıra indirir; window 10 satırı 10 satır olarak bırakır.
Anatomi:
fonksiyon(...) OVER (
PARTITION BY ... -- pencereyi hangi gruplara böleceğim (GROUP BY gibi ama satırları birleştirmez)
ORDER BY ... -- pencere içindeki sıra
ROWS | RANGE | GROUPS BETWEEN ... AND ... -- çerçeve (frame): hangi satırlar hesaba girer
)
Sıralama fonksiyonları: Eşitlik (tie) olduğunda farkları ortaya çıkar.
| Değerler (azalan) | row_number() | rank() | dense_rank() |
|---|---|---|---|
| 10, 8, 8, 5 | 1, 2, 3, 4 | 1, 2, 2, 4 | 1, 2, 2, 3 |
row_number: Her satıra benzersiz numara. Eşitlerde hangisinin 2, hangisinin 3 olacağı belirsizdir (ORDER BY'a tie-breaker eklemezsen her çalıştırmada değişebilir).rank: Eşitler aynı sırayı alır, sonra boşluk bırakılır (olimpiyat sıralaması).dense_rank: Eşitler aynı sırayı alır, boşluk bırakılmaz.
"İlk 3" istendiğinde hangisini seçeceğin bir iş kararıdır: tam 3 satır mı (row_number), eşitler dahil "ilk 3 sıradakiler" mi (rank: 3'üncü sırada eşitlik varsa 4-5 satır gelebilir), yoksa "ilk 3 farklı değer" mi (dense_rank)? Mülakatta bunu sormak senior bir davranıştır.
Önceki ve sonraki satır: LAG / LEAD. Self-join yazmadan bir önceki satıra bakılır:
SELECT id, created_at,
lag(created_at) OVER (PARTITION BY customer_id ORDER BY created_at) AS prev_order,
lead(created_at) OVER (PARTITION BY customer_id ORDER BY created_at) AS next_order
FROM orders;
Partition'ın ilk satırında lag, son satırında lead NULL döner. lag(x, 1, default) ile varsayılan verilebilir. Kullanım alanları: siparişler arası süre, fiyat değişimi, durum geçişleri, oturum (session) analizi.
Kümülatif toplam ve hareketli ortalama:
sum(daily_revenue) OVER (ORDER BY day) -- kümülatif
avg(daily_revenue) OVER (ORDER BY day ROWS BETWEEN 6 PRECEDING AND CURRENT ROW) -- son 7 satır
sum(x) OVER () -- tüm sonucun toplamı
Çerçeve (frame): ROWS, RANGE, GROUPS. OVER (ORDER BY ...) yazıp frame belirtmezsen varsayılan çerçeve RANGE BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW olur. RANGE "sıralama değeri aynı olan satırları (peer) birlikte" ele alır. ROWS fiziksel satır sayar. GROUPS (PostgreSQL 11+) peer gruplarını sayar. Fark, sıralama kolonunda tekrar eden değer olduğunda ortaya çıkar:
WITH t(d, v) AS (VALUES (1, 10), (2, 20), (2, 30), (3, 40))
SELECT d, v,
sum(v) OVER (ORDER BY d) AS range_default,
sum(v) OVER (ORDER BY d ROWS BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW) AS rows_cum,
last_value(v) OVER (ORDER BY d) AS last_default,
last_value(v) OVER (ORDER BY d ROWS BETWEEN UNBOUNDED PRECEDING
AND UNBOUNDED FOLLOWING) AS last_full
FROM t;
| d | v | range_default | rows_cum | last_default | last_full |
|---|---|---|---|---|---|
| 1 | 10 | 10 | 10 | 10 | 40 |
| 2 | 20 | 60 | 30 | 30 | 40 |
| 2 | 30 | 60 | 60 | 30 | 40 |
| 3 | 40 | 100 | 100 | 40 | 40 |
Okuman gereken iki şey:
d = 2olan iki satır varsayılan RANGE çerçevesinde peer'dır; ikisi de 60 görür. ROWS ise satır satır ilerler (30, 60). Kümülatif toplamda sıralama anahtarı benzersiz değilse (aynı gün iki sipariş) RANGE "o güne kadar dahil" toplamı verir, ROWS ise satır sırasına bağlı ara değerler üretir. Hangisini istediğine karar ver ve sıralama anahtarını gerekirse benzersiz yap (ORDER BY created_at, id).last_valuevarsayılan çerçevede "son satır" değil, "geçerli satırın son peer'ı"dır, çünkü çerçeve CURRENT ROW'da biter. Gerçek son değer için çerçeveUNBOUNDED FOLLOWING'e açılmalı ya dafirst_value(...) OVER (ORDER BY ... DESC)kullanılmalı.
Hareketli ortalamada ROWS ile RANGE farkı daha da somuttur: ROWS BETWEEN 6 PRECEDING "son 7 satır" demektir; satış olmayan günler satır üretmediği için bu pencere 7 günden uzun bir zamanı kapsayabilir. Gerçek "son 7 gün" için RANGE BETWEEN interval '6 days' PRECEDING AND CURRENT ROW (PostgreSQL 11+, tarih/zaman sıralamasında offset destekli RANGE) ya da önce generate_series ile takvim üretilip boş günler 0 ile doldurulur. Seed verisinde iki yaklaşım aynı gün için 1.29 ve 1.00 gibi farklı ortalamalar verdi.
Window function'lar nerede kullanılır? Sadece SELECT ve ORDER BY'da. WHERE, GROUP BY ve HAVING'de kullanılamaz, çünkü onlardan sonra hesaplanırlar. Sonucuna göre filtrelemek için CTE ya da alt sorgu gerekir. Bunun tersi de doğrudur: window, GROUP BY'dan sonra çalıştığı için aggregate sonuçlarının üzerinde çalışabilir (rank() OVER (ORDER BY sum(x) DESC)).
Grup başına ilk N (top-N per group)
"Her müşterinin son siparişi", "her kategoride en çok satan 3 ürün" live coding'in en klasik SQL sorusudur (greatest-n-per-group). Dört yol:
1) Window + dış filtre (taşınabilir, N'e genellenir):
WITH ranked AS (
SELECT o.*, row_number() OVER (PARTITION BY customer_id
ORDER BY created_at DESC, id DESC) AS rn
FROM orders o
)
SELECT * FROM ranked WHERE rn <= 3;
2) DISTINCT ON (PostgreSQL'e özgü, sadece N = 1):
SELECT DISTINCT ON (customer_id) customer_id, id, status, created_at
FROM orders
ORDER BY customer_id, created_at DESC, id DESC;
DISTINCT ON, ORDER BY'a göre her grubun ilk satırını tutar. Kural: ORDER BY'ın baştaki ifadeleri DISTINCT ON ifadeleriyle aynı olmalı; değilse PostgreSQL SELECT DISTINCT ON expressions must match initial ORDER BY expressions hatası verir. Kısa ve okunurdur, ama diğer veritabanlarına taşınmaz.
3) LATERAL + LIMIT (index varsa büyük tablolarda en hızlısı):
SELECT c.id, o.id AS order_id, o.created_at
FROM customers c
CROSS JOIN LATERAL (
SELECT id, created_at FROM orders o
WHERE o.customer_id = c.id
ORDER BY created_at DESC, id DESC
LIMIT 3) o;
orders (customer_id, created_at DESC) index'i varsa her müşteri için index'e bir kez inilir ve 3 satır okunup durulur. Window çözümü ise tüm siparişleri okuyup numaralandırır. Müşteri sayısı az, sipariş sayısı çoksa LATERAL kat kat hızlıdır. Müşteri listesinin tamamı ve az sipariş varsa window daha iyi olabilir. Hiç siparişi olmayan müşteriler de görünsün istiyorsan LEFT JOIN LATERAL (...) o ON true yazılır.
4) max() ile join (kaçın): JOIN (SELECT customer_id, max(created_at) ...) aynı anda iki sipariş varsa iki satır üretir ve tie-breaker koyamazsın.
Tie-breaker: ORDER BY created_at DESC tek başına deterministik değildir. Aynı zaman damgalı iki satırda sonuç çalıştırmadan çalıştırmaya değişebilir; test'ler "bazen" kırılır. Benzersiz bir kolon (id DESC) ekle.
Eşitliklerle ilk N tek sorgu için (top-N genel): PostgreSQL 13+ FETCH FIRST 3 ROWS WITH TIES, son satırla eşit olanları da getirir (ORDER BY şarttır).
CTE (WITH) ve recursive CTE
CTE, sorgu içinde isimlendirilmiş bir ara sonuçtur. Asıl faydası okunurluktur: live coding'de adım adım düşünmeyi koda döker (product_sales → ranked → sonuç) ve mülakatçı her adımı ayrı ayrı takip eder.
Performans notu (PostgreSQL 12+): PostgreSQL 12'den önce CTE'ler her zaman ayrı hesaplanıp saklanırdı (optimization fence); dış sorgudaki filtre CTE'nin içine itilemezdi. PostgreSQL 12+ yan etkisiz, recursive olmayan ve bir kez referans verilen CTE'leri dış sorguya inline eder. Bunu kendin görebilirsin:
EXPLAIN WITH x AS (SELECT * FROM orders) SELECT * FROM x WHERE id = 5;
-- Index Scan using orders_pkey on orders (inline edildi, filtre index'e indi)
EXPLAIN WITH x AS MATERIALIZED (SELECT * FROM orders) SELECT * FROM x WHERE id = 5;
-- CTE Scan on x / Filter: (id = 5) / -> Seq Scan on orders (önce hepsi hesaplandı)
MATERIALIZED / NOT MATERIALIZED ile davranış zorlanabilir. Pahalı bir alt sonuç birden çok kez kullanılıyorsa MATERIALIZED işe yarar.
Veri değiştiren CTE: PostgreSQL'de WITH içinde UPDATE ... RETURNING yazıp sonucunu aynı ifadede kullanabilirsin. Tek ifade olduğu için atomiktir:
WITH dec AS (
UPDATE stock SET quantity = quantity - 1
WHERE product_id = 2 AND quantity >= 1
RETURNING product_id
)
INSERT INTO stock_movements (product_id, delta, created_at)
SELECT product_id, -1, now() FROM dec; -- stok düşmediyse hareket kaydı da yazılmaz
Recursive CTE hiyerarşik verinin (kategori ağacı, organizasyon şeması, yorum zinciri, bileşen ağacı) standart çözümüdür. Yapısı:
WITH RECURSIVE tree AS (
-- 1) anchor: başlangıç satırları
SELECT id, name, 0 AS depth
FROM categories
WHERE id = 1
UNION ALL
-- 2) recursive kısım: bir önceki adımın sonucuyla join
SELECT c.id, c.name, t.depth + 1
FROM categories c
JOIN tree t ON c.parent_id = t.id
)
SELECT * FROM tree;
Çalışma mantığı iteratiftir: anchor sonucu "çalışma tablosu" olur; recursive kısım her turda sadece bir önceki turun satırlarıyla join edilir; yeni satır üretmeyen ilk turda durur. Yani gerçek bir özyineleme değil, genişlik öncelikli bir döngüdür.
Üç önemli nokta:
- UNION ALL ve UNION: UNION ALL hızlıdır. UNION her turda tekrarları eler; bu bazı döngüleri durdurur ama
depthgibi değişen bir kolon varsa satırlar zaten farklı olduğu için döngüyü durdurmaz. - Döngü koruması: Hatalı veride (A'nın parent'ı B, B'nin parent'ı A) sorgu sonsuza kadar döner. Klasik çözüm ziyaret edilen id'leri bir dizide taşımaktır (
path || c.id,WHERE NOT c.id = ANY(path)). PostgreSQL 14+ bunu standart sözdizimiyle yapar:
WITH RECURSIVE tree AS (
SELECT id, name, parent_id FROM categories WHERE id = 1
UNION ALL
SELECT c.id, c.name, c.parent_id FROM categories c JOIN tree t ON c.parent_id = t.id
) SEARCH DEPTH FIRST BY id SET ord
CYCLE id SET is_cycle USING path
SELECT id, name, path, is_cycle FROM tree ORDER BY ord;
SEARCH DEPTH FIRST sonucu ağaç görünümünde (ebeveynin hemen altında çocukları) sıralamak için bir sıralama kolonu üretir; CYCLE döngüyü tespit edip o dalı durdurur.
- Ölçek: Çok okunan ve az değişen ağaçlarda her istekte recursive sorgu çalıştırmak yerine materialized path (
'1/4/5', alt ağaç sorgusuLIKE '1/4/%'), PostgreSQL'inltreeeklentisi ya da closure table (her ata-torun çifti bir satır) kullanılır. Bedeli yazma tarafındaki karmaşıklıktır: bir düğümü taşımak alt ağacın tamamını günceller.
Upsert: INSERT ... ON CONFLICT
"Kayıt varsa güncelle, yoksa ekle" iki ayrı ifadeyle (SELECT, sonra INSERT ya da UPDATE) yazılırsa check-then-act yarışı oluşur: iki eşzamanlı istek ikisi de "yok" görür, ikisi de INSERT eder, biri unique violation (23505) alır ya da unique constraint yoksa mükerrer kayıt oluşur. PostgreSQL 9.5+ bunu tek atomik ifadeyle çözer:
INSERT INTO stock (product_id, quantity)
VALUES (5, 10), (17, 25)
ON CONFLICT (product_id)
DO UPDATE SET quantity = stock.quantity + EXCLUDED.quantity
RETURNING product_id, quantity;
- Conflict target (
(product_id)) bir unique index ya da constraint'e karşılık gelmeli. Upsert'in garantisi oradan gelir; unique index yoksa ON CONFLICT yazamazsın. EXCLUDED, eklenmek istenen (çakışma yüzünden reddedilen) satırdır.stock.quantitymevcut satırdır.SET quantity = EXCLUDED.quantityyazmak mevcut stoğu ezer; artırmak için ikisini toplamalısın.DO UPDATE ... WHERE stock.quantity < EXCLUDED.quantitykoşullu güncellemedir; koşul sağlanmazsa satır değişmez ve RETURNING o satırı döndürmez.DO NOTHINGidempotent kayıt için kullanılır: Kafka consumer'ındaINSERT INTO processed_event (event_id) VALUES (?) ON CONFLICT DO NOTHINGve etkilenen satır 0 ise event daha önce işlenmiştir.- Aynı ifadede aynı anahtar iki kez gelirse PostgreSQL
ON CONFLICT DO UPDATE command cannot affect row a second timehatası verir. Toplu upsert'ten önce girdiyi anahtara göre tekilleştir (GROUP BY product_idile topla).
MERGE (PostgreSQL 15+) SQL standardındaki karşılığıdır ve daha esnektir (eşleşince sil, eşleşmeyince ekle gibi birden fazla dal). Ama eşzamanlı INSERT'lere karşı ON CONFLICT kadar güvenli değildir: iki MERGE aynı anda "eşleşme yok" görüp INSERT dalına girerse biri unique violation alabilir. Yüksek eşzamanlılıkta tek satır upsert için ON CONFLICT tercih edilir.
JPA notu: Spring Data'nın save() metodu upsert değildir: entity yeniyse persist (INSERT), değilse merge (önce SELECT, sonra UPDATE) yapar ve check-then-act yarışına açıktır. Upsert gerekiyorsa @Modifying @Query(nativeQuery = true) ile ON CONFLICT yazılır.
Koşullu UPDATE ile stok düşme
Flash sale'de stok düşmenin doğru yolu tek, koşullu ve atomik bir UPDATE'tir:
UPDATE stock
SET quantity = quantity - :qty
WHERE product_id = :productId
AND quantity >= :qty
RETURNING quantity;
Neden doğru:
- Okuma ve yazma tek ifadede. "SELECT ile stoğu oku, Java'da kontrol et, UPDATE et" deseninde iki istek aynı değeri okuyup ikisi de düşer (lost update, overselling). Burada kontrol ile yazma arasında boşluk yoktur.
- Satır kilidi ve yeniden değerlendirme. UPDATE, güncellediği satıra satır kilidi alır. Aynı satırı güncellemek isteyen ikinci transaction ilki bitene kadar bekler. READ COMMITTED'da ilki commit olunca PostgreSQL ikinci UPDATE'in WHERE koşulunu satırın yeni sürümü üzerinde yeniden değerlendirir. Stok 1 iken iki eşzamanlı UPDATE ile yapılan denemede ikinci UPDATE yaklaşık 1,5 saniye (birinci transaction'ın süresi kadar) bekledi ve
0 rowsdöndü; stok 0'da kaldı, eksiye düşmedi. - Sonuç etkilenen satır sayısıyla okunur. 1 satır: stok düştü. 0 satır: stok yetersiz ya da ürün yok. Uygulama bunu iş hatasına çevirir.
@Modifying(flushAutomatically = true, clearAutomatically = true)
@Query("""
update Stock s
set s.quantity = s.quantity - :quantity
where s.productId = :productId
and s.quantity >= :quantity
""")
int decrease(@Param("productId") long productId, @Param("quantity") int quantity);
// serviste
if (stockRepository.decrease(productId, quantity) == 0) {
throw new InsufficientStockException(productId, quantity);
}
CHECK (quantity >= 0) constraint'i son savunma hattıdır: uygulamada biri koşulsuz quantity - 1000 yazarsa PostgreSQL new row for relation "stock" violates check constraint (SQLSTATE 23514) ile reddeder. İkisini birlikte kullan: koşullu WHERE iş kuralını temiz bir "0 satır" sonucuyla bildirir, CHECK ise bug'a karşı korur.
Bedeli: Binlerce istek aynı satırı günceller (hot row), hepsi o satırın kilidi için sıraya girer. Doğruluk tamdır ama throughput sınırlıdır. Çok yüksek trafikte Redis + Lua ile ön eleme, stoğu birden çok satıra bölme (bucket) ya da rezervasyon modeli düşünülür (bkz. Bölüm 13).
Sayfalama: OFFSET ve keyset
OFFSET sayfalama basittir: ORDER BY created_at DESC, id DESC LIMIT 20 OFFSET 400. Sorun, veritabanının atlanan 400 satırı da okuyup üretmek zorunda olmasıdır; maliyet sayfa numarasıyla doğrusal artar. 1 milyon satırlık bir tabloda (PostgreSQL 16) ölçülen fark:
EXPLAIN ANALYZE SELECT id FROM orders2 ORDER BY id LIMIT 20 OFFSET 900000;
-- Index Only Scan ... (actual rows=900020) Execution Time: 219 ms
EXPLAIN ANALYZE SELECT id FROM orders2 WHERE id > 900000 ORDER BY id LIMIT 20;
-- Index Only Scan ... Index Cond: (id > 900000) (actual rows=20) Execution Time: 0.06 ms
İkinci yöntem keyset (seek) sayfalamadır: bir önceki sayfanın son satırının sıralama anahtarı hatırlanır ve "bundan sonrakiler" istenir. Sıralama anahtarı benzersiz olmalıdır; değilse tie-breaker eklenir ve PostgreSQL'in satır karşılaştırması (row value comparison) kullanılır:
SELECT id, status, created_at
FROM orders
WHERE customer_id = :customerId
AND (created_at, id) < (:lastCreatedAt, :lastId)
ORDER BY created_at DESC, id DESC
LIMIT 20;
(a, b) < (x, y) ifadesi a < x OR (a = x AND b < y) demektir ve (customer_id, created_at DESC, id DESC) gibi bir index ile doğrudan index aralığına çevrilir.
Keyset'in artıları: her sayfa aynı hızdadır; sayfalar arasında yeni kayıt eklendiğinde OFFSET'teki gibi satır kayması (aynı kaydı iki kez görme veya atlama) olmaz. Eksileri: "47. sayfaya atla" yapılamaz, sadece ileri/geri gezilir; sıralama kriteri değişince imleç geçersiz olur. Mobil "sonsuz kaydırma" ve API'lerde cursor (?after=<opaque-token>) için idealdir. İmleç değeri istemciye base64 ile kodlanmış opak bir token olarak verilir ki istemci iç yapıya bağımlı olmasın.
Toplam sayı (count(*)) da pahalıdır: PostgreSQL MVCC yüzünden satır sayısını bir yerde hazır tutmaz, sayım için tarama yapar. Büyük listelerde "toplam 12.345.678 sonuç" yerine "1000+ sonuç" ya da pg_class.reltuples tahmini gösterilir.
Quiz'deki sorular: çözüm ve nedenleri
j25: CRM ekibi iki rapor istiyor
Senaryo. Şema: customers(id, name, created_at), orders(id, customer_id, status, created_at), order_items(order_id, product_id, category, quantity, unit_price). İki rapor:
- A) Toplamda en az 3 teslim edilmiş siparişi olan ama son 30 günde hiç teslim edilmiş siparişi olmayan müşteriler (id, ad, sipariş sayısı, son sipariş tarihi).
- B) Son 90 günde teslim edilen siparişlere göre her kategoride ciroya göre en çok satan 3 ürün.
A'nın çözümü. İki koşul da müşteri grubunun özelliğidir, bu yüzden HAVING'e gider:
SELECT c.id,
c.name,
count(*) AS delivered_orders,
max(o.created_at) AS last_delivered_at
FROM customers c
JOIN orders o
ON o.customer_id = c.id
AND o.status = 'DELIVERED'
GROUP BY c.id, c.name
HAVING count(*) >= 3
AND max(o.created_at) < now() - interval '30 days';
Neden doğru: DELIVERED filtresi join'de (ya da WHERE'de, INNER JOIN'de fark etmez) satırları eler; GROUP BY müşteri başına tek satır üretir; "en az 3" bir sayım, "son 30 günde hiç yok" ise "en son teslim edilmiş sipariş 30 günden eski" demektir, yani max(created_at) üzerinde bir koşuldur. İkisi de aggregate olduğu için WHERE'e yazılamaz.
Seed verisinde bu sorgu müşteri 1 (5 teslim, son teslim 34 gün önce) ve 6'yı (4 teslim, son teslim 37 gün önce) döndürür. Müşteri 4'ün 3 teslimi var ama sonuncusu 2 gün önce olduğu için listede yok; doğru davranış budur.
Alternatif: "son 30 günde yok" koşulunu NOT EXISTS ile yazmak. Sonuç aynıdır; niyeti daha açık ifade eder ve (customer_id, status, created_at) index'iyle müşteri başına tek index araması olur:
SELECT c.id, c.name, count(*) AS delivered_orders, max(o.created_at) AS last_delivered_at
FROM customers c
JOIN orders o ON o.customer_id = c.id AND o.status = 'DELIVERED'
WHERE NOT EXISTS (SELECT 1 FROM orders o2
WHERE o2.customer_id = c.id
AND o2.status = 'DELIVERED'
AND o2.created_at >= now() - interval '30 days')
GROUP BY c.id, c.name
HAVING count(*) >= 3;
NOT IN (SELECT customer_id FROM orders WHERE ...) yazılırsa alt sorguda NULL olduğu gün sonuç boşalır (bkz. anti-join).
B'nin çözümü. Önce ürün bazında ciro, sonra kategori içinde sıralama, sonra filtre:
WITH product_revenue AS (
SELECT oi.category,
oi.product_id,
sum(oi.quantity * oi.unit_price) AS revenue
FROM order_items oi
JOIN orders o ON o.id = oi.order_id
WHERE o.status = 'DELIVERED'
AND o.created_at >= now() - interval '90 days'
GROUP BY oi.category, oi.product_id
), ranked AS (
SELECT pr.*,
row_number() OVER (PARTITION BY category
ORDER BY revenue DESC, product_id) AS rn
FROM product_revenue pr
)
SELECT category, product_id, revenue
FROM ranked
WHERE rn <= 3
ORDER BY category, rn;
Neden üç adım: (1) Window function satırlar üzerinde çalışır; ciro önce ürün başına bir satıra indirilmelidir. (2) Sıra numarası SELECT aşamasında hesaplanır, aynı sorgunun WHERE'inde kullanılamaz. (3) Filtre dış sorguda yapılır.
Quiz'in model çözümüne bir ekleme: ORDER BY revenue DESC tek başına deterministik değildir; eşit cirolu iki üründe hangisinin 3. olacağı çalıştırmadan çalıştırmaya değişebilir. Bu yüzden product_id tie-breaker'ı eklendi. Eşitlerin de listede kalması isteniyorsa rank() (ilk 3 sıra, eşitlik varsa 3'ten fazla satır) ya da dense_rank() kullanılır; hangisi istendiği CRM ekibine sorulur.
Bir şema notu: Bu sorudaki order_items tablosunda category kolonu var, yani kategori sipariş satırına kopyalanmış (bilinçli denormalizasyon: sipariş anındaki kategori). Live coding şemasındaki gibi kategori products tablosundaysa JOIN products p eklenir ve p.category_id kullanılır. Bu durumda ürünün kategorisi değişirse geçmiş raporlar da değişir; hangisinin istendiği bir iş sorusudur.
Yanlış yaklaşımlar ve nedenleri:
WHERE count(*) >= 3: aggregate WHERE'de kullanılamaz.WHERE o.created_at < now() - interval '30 days'ile "son 30 günde yok" koşulunu yazmak: Bu, son 30 gündeki siparişleri sayımdan çıkarır ama müşteriyi listeden çıkarmaz. Son 30 günde siparişi olan müşteri yine listelenir. Koşul satıra değil gruba aittir.WHERE rn <= 3aynı sorgu seviyesinde: window function WHERE'den sonra hesaplanır.LIMIT 3: tüm sonucun ilk 3'ünü alır, kategori başına değil.
Production karşılığı: Bu raporlar büyük tablolarda canlı OLTP veritabanında her istekte çalıştırılmaz. A için orders (customer_id, status, created_at) index'i yeterlidir. B gibi analitik raporlar replica'da, veri ambarında ya da event'lerden beslenen önceden toplanmış (pre-aggregated) bir tabloda çalıştırılır.
q3: Son 30 günün en çok ciro yapan 3 satıcısı (SQL tarafı)
Senaryo. Soru, bellekteki List<Order> üzerinden Stream API ile son 30 günde teslim edilmiş siparişlerin satıcı bazında cirosunu hesaplayıp ilk 3 satıcıyı döndürmeyi istiyor ve sonunda soruyor: "Bu hesap 50 milyon sipariş için gerekseydi nerede yapardın?" Stream çözümü ayrı anlatılıyor (bkz. Bölüm 4). Burada cevabın SQL tarafı var.
Cevap: veritabanında, veriyi uygulamaya taşımadan. 50 milyon satırı JVM'e çekmek ağ, bellek (heap) ve GC maliyetidir; veritabanı aynı işi index'le ve veriyi taşımadan yapar:
SELECT p.seller_id,
sum(oi.quantity * oi.unit_price) AS revenue
FROM orders o
JOIN order_items oi ON oi.order_id = o.id
JOIN products p ON p.id = oi.product_id
WHERE o.status = 'DELIVERED'
AND o.created_at >= now() - interval '30 days'
GROUP BY p.seller_id
ORDER BY revenue DESC
LIMIT 3;
Bu sorgu Stream çözümünün birebir karşılığıdır: filter → WHERE, groupingBy + reducing(BigDecimal::add) → GROUP BY + sum, sorted(...reversed()) → ORDER BY DESC, limit(3) → LIMIT 3. numeric tipi BigDecimal gibi tam ondalık aritmetik yapar; double kullanılmaz.
Eşitlik politikası burada da geçerlidir: 3. ve 4. satıcının cirosu eşitse LIMIT 3 birini keyfi olarak atar. PostgreSQL 13+ ile FETCH FIRST 3 ROWS WITH TIES ya da rank() OVER (ORDER BY sum(...) DESC) <= 3 eşitleri de getirir.
Ölçekte bir adım öte: önceden toplama. Bu ekran saniyede yüzlerce kez açılıyorsa her seferinde son 30 günün tüm siparişlerini toplamak yine pahalıdır. Satıcı ve gün bazında bir özet tablo tutulur ve sipariş teslim edildiğinde (ya da OrderDelivered event'i Kafka'dan tüketildiğinde) upsert ile güncellenir:
CREATE TABLE seller_daily_revenue (
seller_id int NOT NULL,
day date NOT NULL,
revenue numeric(14,2) NOT NULL,
PRIMARY KEY (seller_id, day)
);
INSERT INTO seller_daily_revenue (seller_id, day, revenue)
VALUES (:sellerId, :day, :amount)
ON CONFLICT (seller_id, day)
DO UPDATE SET revenue = seller_daily_revenue.revenue + EXCLUDED.revenue;
-- rapor: en fazla satıcı sayısı x 30 satır okunur
SELECT seller_id, sum(revenue) AS revenue
FROM seller_daily_revenue
WHERE day >= current_date - 30
GROUP BY seller_id
ORDER BY revenue DESC
LIMIT 3;
Bunun bedeli: event iki kez işlenirse ciro iki kez eklenir; consumer idempotent olmalıdır (işlenen event id'leri processed_event tablosunda, aynı transaction'da). İade ve iptal de negatif tutarla yansıtılmalıdır. Raporun birkaç saniye gecikmeli olması (eventual consistency) genelde kabul edilir. Daha büyük ölçekte bu iş Elasticsearch aggregation'ına ya da bir OLAP veritabanına taşınır.
SQL Lab görevleri: çözüm ve nedenleri
Bu bölümdeki 15 görev live coding provasının SQL Lab'ından. Her biri için çözüm mantığı, neden öyle yazıldığı ve mülakatçının devam sorusu var. Çözümlerin hepsi seed verisi üzerinde çalıştırıldı.
s1: En yüksek ikinci ürün fiyatı
SELECT DISTINCT price
FROM products
ORDER BY price DESC
OFFSET 1 LIMIT 1;
Fiyatları azalan sıralayıp ilkini atlarız. DISTINCT olmadan en pahalı fiyattan iki ürün varsa "ikinci" yine en yüksek fiyat olur. Alternatifler: SELECT max(price) FROM products WHERE price < (SELECT max(price) FROM products) sadece "ikinci" için çalışır; dense_rank() OVER (ORDER BY price DESC) = 2 ise N'inci değere ve "her kategoride ikinci en yüksek" gibi gruplu sürüme genellenir (dense_rank'te aynı fiyattan birden fazla ürün olabileceği için dış sorguda yine DISTINCT gerekir). Seed verisinde üçü de 32999.00 döndürür.
Devam sorusu: "İkinci fiyat yoksa ne döner?" OFFSET sürümü boş sonuç (sıfır satır), max sürümü tek satır NULL döner. Uygulama tarafında queryForObject boş sonuçta EmptyResultDataAccessException fırlatır; hangisini istediğini sor.
s2: Satıcı bazında teslim edilmiş ciro
SELECT s.name AS seller_name,
sum(oi.quantity * oi.unit_price) AS revenue
FROM sellers s
JOIN products p ON p.seller_id = s.id
JOIN order_items oi ON oi.product_id = p.id
JOIN orders o ON o.id = oi.order_id
WHERE o.status = 'DELIVERED'
GROUP BY s.id, s.name
ORDER BY revenue DESC;
Join yolu şemadan okunur: satıcı → ürün → sipariş satırı → sipariş. Ciro order_items.unit_price ile hesaplanır, products.price ile değil: ürünün bugünkü fiyatı değil, sipariş anındaki fiyat geçerlidir. Bu bilinçli denormalizasyondur (tarihsel gerçek). GROUP BY s.id, s.name: isme göre gruplamak aynı isimli satıcıları birleştirir.
Devam sorusu: "Hiç satışı olmayan satıcılar da 0 ile görünsün." FROM sellers s LEFT JOIN products ... LEFT JOIN order_items ... LEFT JOIN orders o ON o.id = oi.order_id AND o.status = 'DELIVERED' ve coalesce(sum(...), 0). Statü koşulu WHERE'de kalırsa LEFT JOIN yine INNER'a döner.
s3: Hiç sipariş vermemiş müşteriler
SELECT c.id, c.name
FROM customers c
WHERE NOT EXISTS (SELECT 1 FROM orders o WHERE o.customer_id = c.id)
ORDER BY c.id;
Klasik anti-join; seed verisinde müşteri 9 ve 12 döner. LEFT JOIN orders o ... WHERE o.id IS NULL eşdeğerdir. NOT IN alt sorgu NULL döndürdüğü gün boş sonuç verir. Quiz açıklamasındaki "PostgreSQL bunu Hash Anti Join ile çalıştırır" ifadesi kesin değildir: planner veri boyutuna ve index'lere göre Hash, Merge ya da Nested Loop Anti Join seçer (seed verisinde (customer_id, ...) index'i varken Merge Anti Join seçildi). Önemli olan NOT EXISTS'in bir anti-join'e çevrilebilmesi, NOT IN'in çevrilememesidir.
s4: En az 3 farklı kategoriden alışveriş yapanlar
SELECT c.id AS customer_id, c.name,
count(DISTINCT p.category_id) AS category_count
FROM customers c
JOIN orders o ON o.customer_id = c.id AND o.status = 'DELIVERED'
JOIN order_items oi ON oi.order_id = o.id
JOIN products p ON p.id = oi.product_id
GROUP BY c.id, c.name
HAVING count(DISTINCT p.category_id) >= 3
ORDER BY category_count DESC, c.id;
count(DISTINCT ...): aynı kategoriden iki ürün tek sayılır. Aggregate üzerindeki koşul HAVING'e yazılır. Statü koşulu INNER JOIN'de ON'da ya da WHERE'de aynı sonucu verir. "Farklı kategori" ürünün yaprak kategorisidir; "üst kategori bazında (Elektronik, Moda...)" istenirse önce recursive CTE ile her kategorinin kök atası bulunur, sonra o kolon sayılır. Bunu sormak artı puandır.
s5: Mükerrer müşteri hesapları
SELECT lower(email) AS email,
count(*) AS account_count,
string_agg(id::text, ',' ORDER BY id) AS customer_ids
FROM customers
GROUP BY lower(email)
HAVING count(*) > 1
ORDER BY 1;
Gruplama anahtarı normalize edilmiş e-postadır. string_agg(..., ',' ORDER BY id) aggregate içinde sıralama yapar (aggregate'in kendi ORDER BY'ı). Seed verisinde ayse@mail.com | 2 | 1,10 döner.
Asıl mesele önlemektir: DB seviyesinde CREATE UNIQUE INDEX ON customers (lower(email)) (expression index) ya da citext tipi. Sadece uygulamada "var mı" kontrolü iki eşzamanlı kayıtta check-then-act yarışına girer; garantiyi unique index verir. Java'da toLowerCase() varsayılan locale'i kullanır ve Türkçe locale'de "I".toLowerCase() "ı" olur; e-posta gibi makine metinlerinde toLowerCase(Locale.ROOT) kullanılır. Mevcut mükerrerler için birleştirme (merge) bir iş kararıdır: hangi hesap kalır, siparişler, adresler, favoriler nasıl taşınır. Seed'deki 10 numaralı hesabın siparişleri olduğu için sadece silmek yetmez; önce UPDATE orders SET customer_id = 1 WHERE customer_id = 10 gibi taşıma gerekir.
s6: Her müşterinin en son siparişi
SELECT DISTINCT ON (customer_id)
customer_id, id AS order_id, status, created_at
FROM orders
ORDER BY customer_id, created_at DESC, id DESC;
Grup başına ilk satır (greatest-n-per-group). DISTINCT ON her customer_id grubunun ORDER BY'a göre ilk satırını tutar. id DESC tie-breaker'dır: aynı anda iki sipariş varsa büyük id'li seçilir ve sonuç deterministik olur. Taşınabilir sürüm row_number() OVER (PARTITION BY customer_id ORDER BY created_at DESC, id DESC) = 1; büyük tabloda ve orders (customer_id, created_at DESC) index'iyle en hızlısı LATERAL ... LIMIT 1. Seed verisinde üç yöntem de aynı 10 satırı döndürür.
s7: Satıcı bazında iptal oranı
SELECT s.name AS seller_name,
count(DISTINCT o.id) AS total_orders,
count(DISTINCT o.id) FILTER (WHERE o.status = 'DELIVERED') AS delivered_orders,
count(DISTINCT o.id) FILTER (WHERE o.status = 'CANCELLED') AS cancelled_orders,
round(100.0 * count(DISTINCT o.id) FILTER (WHERE o.status = 'CANCELLED')
/ NULLIF(count(DISTINCT o.id), 0), 1) AS cancel_rate
FROM sellers s
JOIN products p ON p.seller_id = s.id
JOIN order_items oi ON oi.product_id = p.id
JOIN orders o ON o.id = oi.order_id
GROUP BY s.id, s.name
ORDER BY s.name;
Grain sorusu: join'den sonra satır "sipariş satırı"dır, sipariş değil. Bir siparişte satıcının iki ürünü varsa sipariş iki kez gelir. count(o.id) TeknoMarket için 35 sayar, doğru sipariş sayısı 26'dır; bu yüzden count(DISTINCT o.id). Yüzde için 100.0 * (tam sayı bölmesini önler) ve NULLIF(..., 0) (sıfıra bölmeyi önler). FILTER yerine count(DISTINCT CASE WHEN o.status = 'CANCELLED' THEN o.id END) de yazılabilir.
s8: Her kategoride en çok satan 2 ürün
WITH product_sales AS (
SELECT p.category_id, p.id, p.name AS product_name, sum(oi.quantity) AS total_qty
FROM order_items oi
JOIN orders o ON o.id = oi.order_id AND o.status = 'DELIVERED'
JOIN products p ON p.id = oi.product_id
GROUP BY p.category_id, p.id, p.name
), ranked AS (
SELECT ps.*, dense_rank() OVER (PARTITION BY category_id ORDER BY total_qty DESC) AS rnk
FROM product_sales ps
)
SELECT c.name AS category_name, r.product_name, r.total_qty
FROM ranked r
JOIN categories c ON c.id = r.category_id
WHERE r.rnk <= 2
ORDER BY c.name, r.total_qty DESC, r.product_name;
Önce ürün başına toplam (CTE), sonra kategori içinde sıralama, sonra dış filtre. Görev "aynı adette satanlar aynı sırayı paylaşsın" dediği için dense_rank. Seed verisinde hiçbir kategoride ilk iki sırada eşitlik olmadığı için row_number, rank ve dense_rank aynı sonucu verir; bu yüzden test verisinin eşitlik içermemesi yanlış fonksiyonu yakalamaz. Kendi testini yazarken eşitlikli veri ekle.
s9: Müşterilerin sipariş verme sıklığı
WITH gaps AS (
SELECT customer_id,
created_at - lag(created_at) OVER (PARTITION BY customer_id ORDER BY created_at) AS gap
FROM orders
WHERE status <> 'CANCELLED'
)
SELECT customer_id,
round(avg(extract(epoch FROM gap) / 86400)::numeric, 1) AS avg_gap_days
FROM gaps
WHERE gap IS NOT NULL
GROUP BY customer_id
ORDER BY customer_id;
lag her siparişin yanına bir öncekinin zamanını koyar; iki timestamptz farkı bir intervaldir, extract(epoch FROM ...) / 86400 ile güne çevrilir. Müşterinin ilk siparişinde lag NULL'dur ve gap IS NOT NULL ile elenir; tek siparişli müşteriler böylece kendiliğinden düşer.
Fark edilmesi gereken matematik: ardışık farkların ortalaması (son - ilk) / (n - 1) ile aynıdır (farklar teleskop gibi toplanır). Window'suz eşdeğeri:
SELECT customer_id,
round((extract(epoch FROM max(created_at) - min(created_at)) / 86400 / (count(*) - 1))::numeric, 1)
FROM orders
WHERE status <> 'CANCELLED'
GROUP BY customer_id
HAVING count(*) >= 2;
Seed verisinde iki sorgu birebir aynı sonucu verir. Saat dilimi notu: takvim günü isteniyorsa (created_at AT TIME ZONE 'Europe/Istanbul')::date kullanılır; ::date dönüşümü oturumun TimeZone ayarına bağlıdır.
s10: Son 30 günün günlük ve kümülatif cirosu
WITH daily AS (
SELECT o.created_at::date AS day,
sum(oi.quantity * oi.unit_price) AS daily_revenue
FROM orders o
JOIN order_items oi ON oi.order_id = o.id
WHERE o.status = 'DELIVERED'
AND o.created_at >= now() - interval '30 days'
GROUP BY o.created_at::date
)
SELECT day, daily_revenue,
sum(daily_revenue) OVER (ORDER BY day) AS running_total
FROM daily
ORDER BY day;
Önce gün bazında topla, sonra window ile kümülatif topla. Varsayılan çerçeve RANGE BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW olduğu ve day CTE'de benzersiz olduğu için sonuç doğru kümülatif toplamdır. Devam sorusu "boş günler de 0 ile görünsün" ise takvim üretilir:
SELECT g.day::date,
coalesce(d.daily_revenue, 0) AS daily_revenue,
sum(coalesce(d.daily_revenue, 0)) OVER (ORDER BY g.day) AS running_total
FROM generate_series(current_date - 29, current_date, interval '1 day') AS g(day)
LEFT JOIN daily d ON d.day = g.day::date
ORDER BY 1;
Dikkat: now() - interval '30 days' "şu andan 30×24 saat önce"dir, current_date - 29 ise takvim günü başlangıcıdır; ikisi aynı pencereyi tanımlamaz. Raporun tanımını (30 takvim günü mü, 720 saat mi) netleştir.
s11: Kategori ağacı (Elektronik altındaki her şey)
WITH RECURSIVE tree AS (
SELECT id, name, 0 AS depth
FROM categories
WHERE id = 1
UNION ALL
SELECT c.id, c.name, t.depth + 1
FROM categories c
JOIN tree t ON c.parent_id = t.id
)
SELECT t.name AS category_name, t.depth, count(p.id) AS product_count
FROM tree t
LEFT JOIN products p ON p.category_id = t.id
GROUP BY t.id, t.name, t.depth
ORDER BY t.depth, t.name;
Anchor kökü, recursive kısım her turda bir alt seviyeyi getirir. Ürünü olmayan kategoriler de görünsün diye LEFT JOIN ve count(p.id). Seed verisinde "Elektronik" kategorisinin doğrudan ürünü yoktur; count(p.id) 0 verir, count(*) ise 1 verir (LEFT JOIN eşleşmeyen satırı yine üretir ve count(*) onu sayar). Devam soruları: döngü koruması (CYCLE, PostgreSQL 14+), "alt kategorilerdeki ürünler dahil toplam" (ağacı önce her düğümün kök atasıyla düzleştirip öyle saymak) ve okuma ağırlıklı ağaçlarda materialized path/closure table.
s12: Kategori bazında iade oranı
Bu görev fan-out tuzağının ta kendisidir. Doğru çözüm satışları ve iadeleri ayrı CTE'lerde kategoriye indirip birleştirmektir (bkz. "Fan-out" alt başlığındaki sorgu, üzerine return_rate hesabı):
SELECT c.name AS category_name, s.sold_qty,
coalesce(r.returned_qty, 0) AS returned_qty,
round(100.0 * coalesce(r.returned_qty, 0) / NULLIF(s.sold_qty, 0), 1) AS return_rate
FROM sold s
JOIN categories c ON c.id = s.category_id
LEFT JOIN returned r ON r.category_id = s.category_id
ORDER BY return_rate DESC, c.name;
"İadesi olmayan kategoriler 0 ile" → LEFT JOIN + coalesce. "Hiç satışı olmayan kategoriler listelenmesin" → sold CTE'sinden başlamak. Seed verisinde tek join'li yanlış sorgu "Şarj Aleti" için 31 satış ve 16 iade gösterirken doğrusu 18 ve 2'dir. Bir titizlik notu: returned CTE'si iadeleri sipariş statüsüne bakmadan toplar. Seed'de tüm iadeler DELIVERED siparişlere ait olduğu için sonuç doğrudur; gerçekte iade sadece teslim edilmiş siparişe yapılabilir, ama veri bozuksa JOIN orders o ON o.id = r.order_id AND o.status = 'DELIVERED' eklemek payı ve paydayı aynı evrene sokar.
s13: Depoya sevkiyat geldi, stok ekle
INSERT INTO stock (product_id, quantity)
VALUES (5, 10), (17, 25)
ON CONFLICT (product_id)
DO UPDATE SET quantity = stock.quantity + EXCLUDED.quantity;
Ürün 5'in kaydı vardır (35 → 45), ürün 17'nin yoktur (yeni satır, 25). Tek ifade atomiktir ve eşzamanlı sevkiyat kayıtlarında check-then-act yarışı oluşmaz. En sık hata SET quantity = EXCLUDED.quantity yazıp mevcut stoğu ezmektir. Sevkiyat dosyasında aynı ürün iki satırda gelirse ifade cannot affect row a second time ile düşer; önce ürün bazında toplanır. Gerçek sistemde stok değişimi ayrıca bir hareket (movement) tablosuna da yazılır ki "bu 45 nereden geldi" sorusu cevaplanabilsin.
s14: Stok düş, ama asla eksiye düşürme
UPDATE stock SET quantity = quantity - 3
WHERE product_id = 7 AND quantity >= 3;
UPDATE stock SET quantity = quantity - 1000
WHERE product_id = 1 AND quantity >= 1000;
Kontrol WHERE'dedir; stok yetmiyorsa 0 satır güncellenir, hata da oluşmaz. Seed verisinde ürün 7 35'ten 32'ye iner, ürün 1 40'ta kalır. Koşulsuz UPDATE (quantity - 1000) CHECK constraint'e takılır ve 23514 hatası verir. Eşzamanlılıkta neden güvenli olduğu "Koşullu UPDATE ile stok düşme" alt başlığında anlatıldı: satır kilidi + yeni sürüm üzerinde WHERE'in yeniden değerlendirilmesi. Uygulama etkilenen satır sayısına bakar, RETURNING quantity ile kalan stoğu ek sorgu atmadan alır.
Devam sorusu: "Sepette 3 farklı ürün var; biri yetersizse hiçbiri düşmesin." Üç UPDATE aynı transaction'da çalışır; biri 0 dönerse exception fırlatılır ve transaction rollback olur. Deadlock'u önlemek için ürünler her zaman aynı sırayla (ör. product_id artan) güncellenir (bkz. Bölüm 10).
s15: "Son siparişlerim" sorgusu için index
CREATE INDEX idx_orders_customer_created
ON orders (customer_id, created_at DESC);
Sorgu WHERE customer_id = $1 ORDER BY created_at DESC LIMIT 5. Eşitlik kolonu önce, sıralama kolonu sonra: index müşterinin satırlarına doğrudan iner, onları zaten created_at DESC sırasında okur, 5 satırda durur, planda Sort node'u yoktur. Sadece (customer_id) index'i olsaydı planda Sort görünürdü (seed üzerinde doğrulandı). Tek kolonlu ASC bir created_at index'i de geriye doğru taranabilir; B-tree iki yönde de okunabildiği için tek kolonda DESC/ASC farkı önemsizdir, karışık yönlü çok kolonlu sıralamalarda (ORDER BY a ASC, b DESC) önemlidir.
Quiz açıklamasındaki bir hata: Görev açıklaması "INCLUDE (status) eklenirse tabloya hiç gidilmez (Index Only Scan)" diyor. Bu sorgu için doğru değildir, çünkü sorgu id kolonunu da seçiyor ve id bu index'te yok. PostgreSQL'de ikincil index'ler primary key'i taşımaz (MySQL InnoDB'nin aksine). Doğrulama: (customer_id, created_at DESC) INCLUDE (status) ile plan Index Scan kalır; INCLUDE (status, id) ile Index Only Scan ... Heap Fetches: 0 olur. Doğru covering index:
CREATE INDEX idx_orders_customer_created
ON orders (customer_id, created_at DESC) INCLUDE (status, id);
Production notları: büyük tabloda CREATE INDEX CONCURRENTLY (transaction bloğu içinde çalışmaz, iki tarama yapar, başarısız olursa INVALID index bırakır). Küçük tabloda planner index'i hiç kullanmayabilir (Seq Scan daha ucuz); bu lab tablosunda 46 satır var. Index tasarımı ve EXPLAIN okuma ayrıntılı olarak ayrı bir bölümde (bkz. Bölüm 10).
Üç seviyede cevap
Soru: "Hiç siparişi olmayan müşterileri bul. NOT IN ile NOT EXISTS arasında fark var mı?"
seviye: Başlangıç etiketler: [sql, not-exists, null-handling] kaynak: Tahmini
SELECT * FROM customers WHERE id NOT IN (SELECT customer_id FROM orders) yazarım. NOT EXISTS de olur, ikisi aynı sonucu verir, NOT IN daha okunur.
NOT EXISTS yazarım. NOT IN'in bir tuzağı var: alt sorgu tek bir NULL döndürürse id NOT IN (..., NULL) hiçbir satır için TRUE olmaz ve sorgu boş döner. LEFT JOIN orders ... WHERE o.id IS NULL da eşdeğer. Performans olarak NOT EXISTS genelde iyi, PostgreSQL onu anti-join'e çeviriyor.
NOT EXISTS yazarım, iki nedenle. Doğruluk: NOT IN üç değerli mantıkta x <> a AND x <> NULL demek; tek NULL tüm ifadeyi UNKNOWN yapar ve sorgu hata vermeden boş döner. Bugün customer_id NOT NULL olsa da alt sorgu yarın nullable bir kolona ya da LEFT JOIN'li bir view'a dönerse rapor sessizce sıfırlanır; bu tür bug'lar ancak bir iş birimi "rapor neden boş" dediğinde fark edilir. Performans: NULL semantiği yüzünden PostgreSQL NOT IN'i anti-join'e çeviremiyor; planda hashed SubPlan görürsün ve sonuç work_mem'e sığmazsa satır başına tarama yapan karesel bir plana düşer. NOT EXISTS ise Hash/Merge Anti Join olur. orders(customer_id) index'inin olduğundan emin olurum; PostgreSQL foreign key kolonlarına otomatik index açmaz. Büyük tabloda bu raporu canlı primary'de değil replica'da çalıştırırım.
Junior'ın cevabı "aynı sonucu verir" dediği için yanlış: NULL varlığında farklı sonuç verir, bu sorunun asıl ölçtüğü şey de budur. Mid tuzağı biliyor ve doğru aracı seçiyor. Senior mekanizmayı (üç değerli mantık), sessiz arıza modunu (hata yok, boş sonuç), planner'ın neden farklı plan ürettiğini ve operasyonel tarafı (FK index'i, replica) birlikte veriyor.
Takip sorusu: "Pozitif yönde IN (SELECT ...) için de aynı NULL tuzağı geçerli mi?" (Hayır: 1 IN (1, NULL) TRUE'dur; NULL sadece eşleşme üretmez.)
Soru: "Her kategoride en çok satan 3 ürünü getiren sorguyu yaz."
seviye: Orta etiketler: [sql, window-functions] kaynak: Tahmini
ORDER BY sum(quantity) DESC LIMIT 3 yazarım, kategoriye göre de GROUP BY yaparım.
Önce ürün bazında toplam satışları bir CTE'de hesaplarım, sonra row_number() OVER (PARTITION BY category_id ORDER BY total DESC) ile kategori içinde numaralandırırım, dış sorguda rn <= 3 ile filtrelerim. Window function WHERE'de kullanılamadığı için CTE gerekiyor. Eşitlik varsa rank veya dense_rank düşünülebilir.
Önce iki şeyi netleştiririm: "en çok satan" adet mi ciro mu, ve 3. sırada eşitlik olursa ne olacak. Tam 3 satır isteniyorsa row_number() ile ORDER BY total DESC, product_id (tie-breaker olmazsa sonuç deterministik değil, testler arada kırılır); eşitler dahil isteniyorsa rank(). Yapı üç adım: ürün bazında topla, partition içinde sırala, dışta filtrele. Ölçek tarafında iki alternatif bilirim. Kategori sayısı az ve önceden toplanmış satış tablosu varsa categories CROSS JOIN LATERAL (... ORDER BY total DESC LIMIT 3) her kategori için index'e bir kez iner, tüm ürünleri sıralamaz. Bu bir "çok satanlar" rozetiyse her istekte hesaplanmaz; event'lerden beslenen ürün/gün satış tablosundan periyodik hesaplanıp cache'lenir, birkaç dakikalık gecikme kabul edilir. Doğrulamak için eşitlik ve tek ürünlü kategori içeren küçük bir test verisiyle çalıştırırım.
Junior'ın LIMIT 3'ü tüm sonucun ilk 3'ünü alır, kategori başına değil: problemi gruplu düşünemediğini gösterir. Mid doğru kalıbı ve WHERE kısıtının nedenini biliyor. Senior gereksinimi netleştiriyor (adet/ciro, eşitlik politikası), deterministik sonuç için tie-breaker ekliyor, LATERAL alternatifiyle index kullanımını düşünüyor ve hesaplamanın nerede yapılması gerektiğini (canlı sorgu mu, önceden toplama mı) tartışıyor.
Takip sorusu: "Bu sorguda PARTITION BY'ı unutsaydın ne olurdu?" (Tüm ürünler tek pencerede numaralanır; sadece genel ilk 3 gelir.)
Soru: "Satıcı bazında satış toplamını ve iade toplamını tek sorguda getirdim ama sayılar çok büyük çıkıyor. Neden?"
seviye: Orta etiketler: [sql, join, aggregation] kaynak: Tahmini
Muhtemelen JOIN yanlış, DISTINCT eklerim, sum(DISTINCT ...) ile düzelir.
Fan-out. order_items ve returns ikisi de ürüne göre birden çok satır içeriyor; aynı join'de birleşince satırlar çarpılıyor ve her toplam diğer tablonun satır sayısı kadar tekrarlanıyor. Her ölçüyü ayrı CTE'de satıcıya göre toplayıp sonra satıcı üzerinden join yaparım.
Fan-out: iki bağımsız bire-çok ilişkiyi aynı join'e koyunca sonuç kümesinin tanecikliği "sipariş satırı × iade" olur ve her sum diğer tarafın kardinalitesiyle çarpılır. Teşhis için toplamadan önce count(*) ile satır sayısına bakarım; beklenen grain'den fazlaysa sebep budur. Düzeltme aggregate-then-join: her ölçüyü kendi CTE'sinde join anahtarına indirip 1:1 birleştiririm. sum(DISTINCT) bir düzeltme değil, ikinci bir bug'dır: aynı tutarlı iki farklı siparişi tek sayar. count(DISTINCT id) sayımlarda maskeleyebilir ama toplamlarda işe yaramaz. Bu tür raporlar için bir regresyon testi tutarım: bilinen küçük bir veri setinde beklenen toplamlar sabit; join eklenince test kırılır. Bu hata canlıda genelde "finans raporu ile satıcı paneli tutmuyor" diye fark edilir, yani maliyeti yüksektir.
Junior'ın sum(DISTINCT) önerisi yaygın bir yanlış inanç ve sonucu farklı bir şekilde yanlış yapar. Mid kavramı ve doğru düzeltmeyi biliyor. Senior grain kavramıyla teşhis yöntemini (satır sayısına bakmak), neden DISTINCT'in çözüm olmadığını ve bu hatayı önleyecek test stratejisini ekliyor.
Takip sorusu: "Satış ve iadeyi ürün bazında değil sipariş satırı bazında eşleştirmek isteseydin join koşulu ne olurdu?" (r.order_id = oi.order_id AND r.product_id = oi.product_id; o zaman da bir satırın birden çok iadesi varsa iadeler önce o anahtara göre toplanmalı.)
Soru: "Flash sale'de stok düşüren SQL'i yaz. Aynı anda 1000 istek gelirse ne olur?"
seviye: İleri etiketler: [sql, concurrency, locking] kaynak: Tahmini
Önce SELECT quantity FROM stock WHERE product_id = ? ile stoğa bakarım, yeterliyse UPDATE stock SET quantity = ? yaparım. Metodun üstüne @Transactional koyarım, transaction olduğu için güvenli olur.
Tek bir koşullu UPDATE yazarım: UPDATE stock SET quantity = quantity - :qty WHERE product_id = :id AND quantity >= :qty. Etkilenen satır sayısı 0 ise stok yetersizdir. Select-then-update yarış koşulu yaratır, @Transactional bunu çözmez çünkü READ COMMITTED'da SELECT kilit almaz. Ayrıca tabloya CHECK (quantity >= 0) koyarım.
Koşullu, atomik UPDATE yazarım ve dönüşteki satır sayısıyla karar veririm; RETURNING quantity ile kalan stoğu da alırım. Mekanizması: UPDATE satır kilidi alır, eşzamanlı ikinci UPDATE bekler, ilki commit olunca READ COMMITTED'da WHERE'i satırın yeni sürümünde yeniden değerlendirir; stok bittiyse 0 satır döner. Böylece overselling imkansız, CHECK constraint de bug'lara karşı son savunma. 1000 istekte doğruluk sorunu yok, throughput sorunu var: hepsi tek satırın kilidinde sıraya girer, transaction'lar ne kadar uzunsa kuyruk o kadar uzar. Bu yüzden transaction'ı kısa tutarım: stok düşümü, sipariş ve outbox kaydı aynı transaction'da, ödeme gibi dış çağrılar dışında. Ölçmek için pg_stat_activity'de wait_event_type = 'Lock' sayısına ve p99 latency'ye bakarım. Bu yetmezse hot SKU için Redis'te Lua ile atomik ön eleme yaparım (stok bitince istekler DB'ye hiç gelmez), DB yine nihai kaynak olur ve bir reconciliation job iki tarafı karşılaştırır. Ödeme süresi uzunsa düşüm yerine TTL'li rezervasyon modeli kurarım.
Junior'ın cevabı klasik lost update hatası ve "@Transactional her şeyi çözer" yanılgısı içeriyor. Mid doğru SQL'i ve nedenini biliyor. Senior kilit/yeniden değerlendirme mekanizmasını, hot row throughput sınırını, transaction süresinin etkisini, ölçüm yöntemini ve bir sonraki ölçek adımını (Redis ön eleme, rezervasyon) trade-off'larıyla anlatıyor.
Takip sorusu: "REPEATABLE READ'de aynı UPDATE çalışsaydı ikinci istek ne alırdı?" (could not serialize access due to concurrent update hatası; uygulama retry etmeli.)
Soru: "Sipariş listesini sayfalıyoruz, 1000. sayfadan sonra API yavaşlıyor. Ne yaparsın?"
seviye: Orta etiketler: [sql, pagination, keyset] kaynak: Tahmini
OFFSET'i küçültürüm ya da sayfa boyutunu artırırım. Index eklerim.
OFFSET N satırı okuyup atar, maliyet sayfa numarasıyla artar. Keyset sayfalamaya geçerim: son görülen (created_at, id) değerini cursor olarak alırım ve WHERE (created_at, id) < (:c, :i) ORDER BY created_at DESC, id DESC LIMIT 20 yazarım. (customer_id, created_at DESC, id DESC) index'i ile her sayfa sabit sürede gelir.
Önce EXPLAIN ANALYZE ile doğrularım: OFFSET'te planda okunan satır sayısı offset + limit kadardır; 1 milyon satırlık bir tabloda 900.000 offset 200 ms sürerken keyset 0,06 ms sürdü. Keyset'e geçerim, cursor'ı istemciye opak bir token olarak veririm ki iç yapıya bağımlı olmasın. Bedelini de söylerim: sayfaya atlama yok, sıralama kriteri değişirse cursor geçersiz. Ürün ekibi "sayfa 47" istiyorsa bu bir UX kararıdır; çoğu zaman sonsuz kaydırma yeterlidir. Toplam sayıyı her istekte count(*) ile hesaplamam, MVCC yüzünden tarama gerektirir; tahmini sayı ya da "1000+" gösteririm. Ayrıca derin sayfaları kimin istediğine bakarım: çoğu zaman bir scraper ya da "tümünü dışa aktar" ihtiyacıdır ve onun doğru çözümü asenkron bir export job'ıdır.
Junior semptomu tedavi ediyor, mekanizmayı bilmiyor. Mid doğru çözümü ve gereken index'i biliyor. Senior ölçüyle doğruluyor, API tasarımını (opak cursor), keyset'in sınırlarını ve asıl ihtiyacın ne olduğunu (export) sorguluyor.
Takip sorusu: "Keyset'te sıralama kolonu benzersiz değilse ne olur?" (Aynı değerli satırlar sayfa sınırında atlanır veya tekrarlanır; benzersiz tie-breaker eklenir.)
Sorulmadı ama sorulabilir
Gaps and islands: ardışık günler
"En az 2 gün üst üste sipariş veren müşteriler" ya da "kesintisiz aktif olunan en uzun dönem" sorularının kalıbı: tarihten satır numarasını çıkarırsan, ardışık günler aynı sabit değeri (ada kimliği) üretir.
WITH days AS (
SELECT DISTINCT customer_id, created_at::date AS d FROM orders
), grp AS (
SELECT customer_id, d,
d - (row_number() OVER (PARTITION BY customer_id ORDER BY d))::int AS island
FROM days
)
SELECT customer_id, min(d) AS start_day, max(d) AS end_day, count(*) AS streak
FROM grp
GROUP BY customer_id, island
HAVING count(*) >= 2;
Gün 10, 11, 12 için satır numaraları 1, 2, 3; fark her birinde "gün 9" olur. Araya boşluk girince fark değişir ve yeni ada başlar. Mülakatta tek cümle: "Tarih eksi satır numarası ardışık seriler için sabittir; ona göre gruplarım."
GROUPING SETS, ROLLUP ve ara toplamlar
Raporda hem satıcı bazında hem şehir bazında hem genel toplam isteniyorsa üç sorgu UNION'lamak yerine:
SELECT s.city, s.name, sum(oi.quantity * oi.unit_price) AS revenue
FROM sellers s
JOIN products p ON p.seller_id = s.id
JOIN order_items oi ON oi.product_id = p.id
JOIN orders o ON o.id = oi.order_id
WHERE o.status = 'DELIVERED'
GROUP BY ROLLUP (s.city, s.name);
ROLLUP (a, b) sırasıyla (a, b), (a) ve () gruplarını üretir; ara toplam satırlarında toplanan kolon NULL görünür. Kolonun gerçekten NULL mı yoksa ara toplam mı olduğunu ayırmak için GROUPING(s.name) kullanılır. CUBE tüm kombinasyonları, GROUPING SETS istediğin kombinasyonları üretir. Tek cümle: "Tek taramada çok seviyeli toplam için ROLLUP/GROUPING SETS."
Korelasyonlu alt sorgu, JOIN ve N+1
SELECT c.id, (SELECT count(*) FROM orders o WHERE o.customer_id = c.id) FROM customers c her müşteri için alt sorgu çalıştırır; index varsa küçük sonuçlarda kabul edilebilir, büyük listede LEFT JOIN (SELECT customer_id, count(*) ... GROUP BY customer_id) tek geçişte hesaplar. Bu, ORM'deki N+1 probleminin SQL içindeki karşılığıdır: N+1 uygulama ile veritabanı arasında N ağ gidiş-dönüşü üretir, korelasyonlu alt sorgu ise veritabanı içinde kalır ve planner bazen onu join'e çevirebilir. Tek cümle: "Satır başına iş varsa önce bunun bir join veya gruplu alt sorguyla tek geçişte yapılıp yapılamayacağına bakarım."
Zaman dilimleri ve timestamptz
PostgreSQL'de timestamptz değeri UTC olarak saklar, gösterirken oturumun TimeZone ayarına çevirir; timestamp (without time zone) ise saat dilimi bilgisi taşımaz. Bankacılık ve e-ticarette olay zamanı timestamptz ile tutulur. "1 Ekim'deki siparişler" sorgusu created_at >= '2026-10-01 00:00+03' AND created_at < '2026-10-02 00:00+03' gibi açık bir aralıkla yazılır. created_at::date oturum saat dilimine bağlı olduğu için immutable değildir ve üzerine index kurulamaz (functions in index expression must be marked IMMUTABLE); ((created_at AT TIME ZONE 'Europe/Istanbul')::date) ise sabit bir dilimle immutable'dır ve index'lenebilir. Java tarafında Instant/OffsetDateTime kullanılır, LocalDateTime olay zamanı için kullanılmaz. Tek cümle: "Olay zamanını timestamptz ile saklar, gün sınırlarını iş biriminin saat dilimine göre açık aralıkla sorgularım."
Mükerrer kayıt temizliği
"Aynı e-postalı hesaplardan sadece en eskisi kalsın" sorusunun SQL'i:
DELETE FROM customers c
USING customers keep
WHERE lower(c.email) = lower(keep.email)
AND c.id > keep.id;
Gerçekte bu ifade foreign key'e takılır (silinen hesabın siparişleri var). Doğru sıra: (1) çocuk kayıtları tutulacak hesaba taşı, (2) sil, (3) tekrarını önlemek için UNIQUE (lower(email)) index'ini ekle, (4) hepsini tek transaction'da ya da büyük tabloda batch'ler halinde yap. Tek cümle: "Mükerrerleri silmeden önce referansları taşırım, sonra unique index ile tekrarını kalıcı olarak engellerim."
Sık yapılan hatalar
- LEFT JOIN'den sonra sağ tablonun koşulunu WHERE'e yazmak → eşleşmeyen satırlarda kolon NULL olduğu için koşul UNKNOWN döner ve satır elenir, LEFT JOIN INNER'a döner → koşulu ON'a yaz.
NOT IN (SELECT ...)ile anti-join → alt sorguda tek NULL tüm sonucu boşaltır ve anti-join'e çevrilemediği için yavaş olabilir →NOT EXISTS.WHERE col = NULL→ hiçbir zaman TRUE olmaz →IS NULL; değişiklik kontrolündeIS DISTINCT FROM.- İki bire-çok ilişkiyi aynı join'de toplamak → fan-out, toplamlar şişer → her ölçüyü ayrı CTE'de topla, sonra birleştir.
- Fan-out'u
sum(DISTINCT ...)ile "düzeltmek" → eşit tutarlı farklı kayıtları tek sayar → grain'i düzelt. - Aggregate koşulunu WHERE'e yazmak → hata ya da yanlış anlam → HAVING.
- Window function sonucunu aynı seviyenin WHERE'inde kullanmak → window SELECT aşamasında hesaplanır → CTE/alt sorgu ile dışarıda filtrele.
- Sıralamada tie-breaker koymamak (
ORDER BY created_at DESC) → eşitlerde sonuç deterministik değil, testler ara ara kırılır, sayfalama kayıt atlar → benzersiz kolon ekle (, id DESC). last_valueile "son değeri" almak → varsayılan çerçeve CURRENT ROW'da biter → çerçeveyiUNBOUNDED FOLLOWING'e aç ya da ters sıradafirst_value.- Kümülatif toplamda benzersiz olmayan ORDER BY → RANGE peer'ları birlikte toplar → anahtarı benzersiz yap ya da bilinçli olarak ROWS seç.
- Tam sayı bölmesi (
cancelled / total) → 0 döner →100.0 * a / NULLIF(b, 0). sum()sonucunu NULL kontrolü yapmadan kullanmak → boş kümede NULL döner, Java'da NPE →coalesce(sum(...), 0).- İsme göre gruplamak (
GROUP BY s.name) → aynı isimli kayıtlar birleşir → PK'ya göre grupla. - Upsert'te
SET quantity = EXCLUDED.quantity→ mevcut değeri ezer →stock.quantity + EXCLUDED.quantity. - Select-then-update ile stok düşmek → lost update, overselling → tek koşullu UPDATE ve etkilenen satır sayısı.
- Derin sayfalarda OFFSET → atlanan tüm satırlar okunur → keyset (seek) sayfalama.
- Kolonu fonksiyonla sarmak (
WHERE date(created_at) = ...,WHERE lower(email) = ...index'siz) → index kullanılamaz → aralık sorgusu ya da expression index (bkz. Bölüm 10).
Hızlı tekrar
S: SQL'in mantıksal çalışma sırası nedir?
C: FROM/JOIN, WHERE, GROUP BY, HAVING, SELECT (window'lar burada), DISTINCT, ORDER BY, LIMIT/OFFSET.
S: 2 NOT IN (1, NULL) ne döner, neden?
C: NULL (UNKNOWN). İfade 2 <> 1 AND 2 <> NULL demektir; TRUE AND UNKNOWN = UNKNOWN ve WHERE onu geçirmez.
S: LEFT JOIN'de sağ tablo koşulu WHERE'e yazılırsa ne olur?
C: Eşleşmeyen satırlarda kolon NULL olduğu için koşul UNKNOWN olur, satırlar elenir ve LEFT JOIN fiilen INNER JOIN'e döner.
S: count(*) ile count(col) farkı?
C: count(*) satır sayar; count(col) sadece col'un NULL olmadığı satırları sayar. LEFT JOIN'de eşleşmeyen tarafı saymamak için count(sag.id) kullanılır.
S: Boş bir kümede count(*) ve sum(x) ne döner?
C: count(*) 0, sum(x) NULL döner. GROUP BY varsa boş girdi sıfır satır üretir.
S: Fan-out nedir ve nasıl düzeltilir?
C: Bire-çok join sonrası satırların çoğalması ve toplamların şişmesidir. Her ölçü kendi CTE'sinde join anahtarına göre toplanır, sonra 1:1 birleştirilir.
S: 10, 8, 8, 5 için row_number, rank ve dense_rank?
C: row_number 1,2,3,4; rank 1,2,2,4; dense_rank 1,2,2,3.
S: sum(x) OVER (ORDER BY d) varsayılan çerçevesi nedir ve ne zaman fark yaratır?
C: RANGE BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW. d tekrar ettiğinde eşit değerli satırlar (peer) aynı kümülatif toplamı görür; ROWS ise satır satır ilerler.
S: Window function sonucuna göre neden WHERE'de filtreleyemezsin?
C: Window'lar SELECT aşamasında, WHERE'den sonra hesaplanır. Filtre CTE veya alt sorguda dış seviyede yapılır.
S: DISTINCT ON'un kuralı nedir?
C: ORDER BY'ın baştaki ifadeleri DISTINCT ON ifadeleriyle aynı olmalı; her grubun ORDER BY'a göre ilk satırı kalır. PostgreSQL'e özgüdür, sadece N = 1 içindir.
S: ON CONFLICT DO UPDATE içinde EXCLUDED neyi temsil eder?
C: Eklenmek istenen ve çakışma yüzünden reddedilen satırı. Mevcut satıra tablo adıyla (stock.quantity) erişilir.
S: Koşullu stok UPDATE'i neden lost update'e karşı güvenlidir?
C: Kontrol ve yazma tek ifadededir; satır kilidi eşzamanlı UPDATE'i bekletir ve READ COMMITTED'da WHERE satırın yeni sürümü üzerinde yeniden değerlendirilir. Stok yetmezse 0 satır döner.
S: Keyset sayfalama nasıl yazılır?
C: WHERE (created_at, id) < (:lastCreatedAt, :lastId) ORDER BY created_at DESC, id DESC LIMIT 20 ve aynı sıralamada bir index; her sayfa sabit maliyetlidir.
S: Recursive CTE'de sonsuz döngü nasıl önlenir?
C: Ziyaret edilen id'leri bir dizide taşıyıp tekrarını engelleyerek ya da PostgreSQL 14+'te CYCLE id SET is_cycle USING path cümlesiyle.
Kaynaklar
- PostgreSQL: Window Functions (Tutorial): PARTITION BY, ORDER BY ve çerçeve kavramının resmi ve kısa girişi.
- PostgreSQL: Window Function Calls: ROWS, RANGE, GROUPS ve varsayılan çerçevenin kesin tanımı.
- PostgreSQL: WITH Queries (CTE): recursive CTE, SEARCH/CYCLE, MATERIALIZED ve veri değiştiren CTE.
- PostgreSQL: INSERT (ON CONFLICT): upsert semantiği, conflict target ve EXCLUDED.
- PostgreSQL: Subquery Expressions: EXISTS, IN ve NOT IN'in NULL karşısındaki davranışı.
- PostgreSQL: Aggregate Functions (Tutorial): WHERE ile HAVING farkının resmi açıklaması.
- Use The Index, Luke: Paging Through Results: OFFSET'in neden yavaşladığı ve keyset sayfalamanın index'le ilişkisi.
- Use The Index, Luke: SQL yazarken index'i hesaba katmayı öğreten, veritabanı bağımsız klasik kaynak.