Veritabanı sorgularında bir noktaya gelirsiniz: GROUP BY ile aggregate fonksiyonlar artık yetmez, satırlar arasındaki ilişkileri görmek istersiniz ama satırları kaybetmek de istemezsiniz. İşte tam bu noktada PostgreSQL’in window functions özelliği devreye girer. Sıradan SQL yazarken “bunu nasıl çözeceğim” diye düşündüğünüz senaryoların büyük çoğunluğu, window functions ile hem daha temiz hem de çok daha verimli çözülür.
Window Functions Nedir, Neden Kullanmalıyız?
Window functions, bir satır kümesi üzerinde hesaplama yaparken o satırları gruplandırıp tek bir sonuca indirgemez. Yani GROUP BY gibi davranmaz. Her satır kendi kimliğini korur, ama aynı zamanda çevresindeki satırlarla ilgili hesaplamalar da yapılabilir.
Klasik aggregate fonksiyonlarla window functions arasındaki farkı bir örnekle gösterelim. Diyelim ki her çalışanın maaşı ve departman ortalaması aynı satırda olsun istiyorsunuz:
-- Klasik yöntem: Subquery veya JOIN gerekir
SELECT
ad,
departman,
maas,
(SELECT AVG(maas) FROM calisanlar c2 WHERE c2.departman = c1.departman) AS dept_ortalama
FROM calisanlar c1;
-- Window function ile: Temiz, okunabilir, verimli
SELECT
ad,
departman,
maas,
AVG(maas) OVER (PARTITION BY departman) AS dept_ortalama
FROM calisanlar;
İki sorgu aynı sonucu verir ama ikinci sorgu hem daha okunaklı hem de sorgu planlayıcısının üzerinde çok daha az baskı oluşturur.
Window function sözdiziminin temel yapısı şudur:
fonksiyon_adi(argüman) OVER (
PARTITION BY bölümleme_kolonu
ORDER BY sıralama_kolonu
ROWS/RANGE çerçeve_tanımı
)
- PARTITION BY: Hesaplamanın hangi grup içinde yapılacağını belirler. GROUP BY gibi düşünülebilir ama satırları kaybetmez.
- ORDER BY: Pencere içindeki sıralamayı belirler. Bazı fonksiyonlar için zorunludur.
- ROWS/RANGE: Pencere çerçevesini tanımlar. Varsayılan davranışı değiştirmek için kullanılır.
ROW_NUMBER, RANK ve DENSE_RANK ile Sıralama
Sysadmin hayatında sıkça karşılaştığım senaryo şu: Her departmandaki en yüksek maaşlı 3 kişiyi bulmak. Ya da her sunucudaki son 5 log girişini çekmek. Bu tür “her grup içindeki ilk N” sorguları window functions olmadan oldukça karmaşık hale gelir.
-- Her departmandaki maaş sıralaması
SELECT
ad,
departman,
maas,
ROW_NUMBER() OVER (PARTITION BY departman ORDER BY maas DESC) AS sira,
RANK() OVER (PARTITION BY departman ORDER BY maas DESC) AS rank,
DENSE_RANK() OVER (PARTITION BY departman ORDER BY maas DESC) AS dense_rank
FROM calisanlar
ORDER BY departman, maas DESC;
Bu üç fonksiyon arasındaki fark şudur:
- ROW_NUMBER(): Her satıra benzersiz bir numara verir. Eşit değerler olsa bile farklı numaralar alır.
- RANK(): Eşit değerler aynı sırayı alır, ama sonrasında boşluk oluşur. (1, 1, 3, 4…)
- DENSE_RANK(): Eşit değerler aynı sırayı alır, boşluk oluşmaz. (1, 1, 2, 3…)
Her gruptan ilk N kaydı çekmek için bu yapıyı bir CTE ile birleştirmek en temiz yoldur:
-- Her departmandan en yüksek maaşlı 3 kişi
WITH sirali_calisanlar AS (
SELECT
ad,
departman,
maas,
ROW_NUMBER() OVER (PARTITION BY departman ORDER BY maas DESC) AS sira
FROM calisanlar
)
SELECT ad, departman, maas
FROM sirali_calisanlar
WHERE sira <= 3
ORDER BY departman, sira;
Bu yapı, monitoring sistemlerinde her sunucu için son N olayı çekmek, e-ticaret sistemlerinde her kategori için en çok satan ürünleri bulmak gibi sayısız gerçek dünya senaryosunda kullanılır.
LAG ve LEAD ile Satırlar Arası Karşılaştırma
Zaman serisi verilerinde bir önceki veya sonraki satıra bakma ihtiyacı çok sık çıkar. Sunucu metriklerini izliyorsanız, CPU kullanımının bir önceki ölçüme göre ne kadar değiştiğini hesaplamak istersiniz. Satış verilerinde bir önceki aya göre değişimi görmek istersiniz.
-- Sunucu CPU metriklerinde bir önceki ölçüme göre değişim
SELECT
sunucu_adi,
olcum_zamani,
cpu_yuzde,
LAG(cpu_yuzde) OVER (PARTITION BY sunucu_adi ORDER BY olcum_zamani) AS onceki_olcum,
cpu_yuzde - LAG(cpu_yuzde) OVER (PARTITION BY sunucu_adi ORDER BY olcum_zamani) AS degisim,
LEAD(cpu_yuzde) OVER (PARTITION BY sunucu_adi ORDER BY olcum_zamani) AS sonraki_olcum
FROM sunucu_metrikleri
WHERE olcum_zamani >= NOW() - INTERVAL '24 hours'
ORDER BY sunucu_adi, olcum_zamani;
LAG ve LEAD fonksiyonlarının üç parametresi vardır:
- Birinci parametre: Hangi kolona bakılacağı
- İkinci parametre: Kaç satır ileri veya geri gidileceği (varsayılan: 1)
- Üçüncü parametre: Değer yoksa (NULL olduğunda) kullanılacak varsayılan değer
-- 3 ölçüm öncesiyle karşılaştırma, NULL yerine 0 kullan
SELECT
sunucu_adi,
olcum_zamani,
cpu_yuzde,
LAG(cpu_yuzde, 3, 0) OVER (PARTITION BY sunucu_adi ORDER BY olcum_zamani) AS uc_olcum_oncesi
FROM sunucu_metrikleri;
Pratikte bu yapıyı anormal değer tespiti için de kullanabilirsiniz. Bir önceki ölçüme göre CPU’nun yüzde 30’dan fazla artması durumunda uyarı tetiklemek gibi:
-- Anormal CPU artışı tespiti
WITH cpu_degisim AS (
SELECT
sunucu_adi,
olcum_zamani,
cpu_yuzde,
LAG(cpu_yuzde) OVER (PARTITION BY sunucu_adi ORDER BY olcum_zamani) AS onceki_cpu
FROM sunucu_metrikleri
)
SELECT
sunucu_adi,
olcum_zamani,
cpu_yuzde,
onceki_cpu,
cpu_yuzde - onceki_cpu AS artis
FROM cpu_degisim
WHERE (cpu_yuzde - onceki_cpu) > 30
ORDER BY artis DESC;
SUM ve AVG ile Kümülatif Hesaplamalar
Window functions ile aggregate fonksiyonları birleştirdiğinizde çok güçlü bir araç elde edersiniz. Kümülatif toplam (running total), hareketli ortalama (moving average) ve bunlar gibi hesaplamalar tam bu kategoriye girer.
-- Aylık satış ve kümülatif toplam
SELECT
ay,
aylik_satis,
SUM(aylik_satis) OVER (ORDER BY ay) AS kumulatif_toplam,
SUM(aylik_satis) OVER (ORDER BY ay ROWS BETWEEN 2 PRECEDING AND CURRENT ROW) AS uc_aylik_ortalama_toplam,
AVG(aylik_satis) OVER (ORDER BY ay ROWS BETWEEN 2 PRECEDING AND CURRENT ROW) AS uc_aylik_hareketli_ort
FROM aylik_satislar
ORDER BY ay;
Burada ROWS BETWEEN ifadesi pencere çerçevesini tanımlar:
- ROWS BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW: Baştan şimdiye kadar (kümülatif toplam için varsayılan)
- ROWS BETWEEN 2 PRECEDING AND CURRENT ROW: 2 satır öncesinden şimdiye
- ROWS BETWEEN 1 PRECEDING AND 1 FOLLOWING: 1 öncesi, şimdi ve 1 sonrası (3 satırlık pencere)
- ROWS BETWEEN UNBOUNDED PRECEDING AND UNBOUNDED FOLLOWING: Tüm pencere
Gerçek bir log analizi senaryosunu düşünelim. Web sunucusu loglarında her dakika kaç hata oluştuğunu ve son 5 dakikanın hareketli ortalamasını hesaplayalım:
-- Web sunucusu hata analizi: Hareketli ortalama
SELECT
dakika,
hata_sayisi,
AVG(hata_sayisi) OVER (
ORDER BY dakika
ROWS BETWEEN 4 PRECEDING AND CURRENT ROW
) AS bes_dakika_ort,
MAX(hata_sayisi) OVER (
ORDER BY dakika
ROWS BETWEEN 4 PRECEDING AND CURRENT ROW
) AS bes_dakika_maks,
SUM(hata_sayisi) OVER (ORDER BY dakika) AS toplam_hata
FROM dakiklik_hatalar
ORDER BY dakika;
NTILE ile Yüzdelik Dilimlere Ayırma
NTILE(n) fonksiyonu, satırları n eşit gruba böler. Performans analizi, müşteri segmentasyonu veya veri dağılımı analizi için çok kullanışlıdır.
-- Müşterileri harcama miktarına göre 4 gruba ayır (çeyrekler)
SELECT
musteri_id,
ad_soyad,
toplam_harcama,
NTILE(4) OVER (ORDER BY toplam_harcama DESC) AS ceyrek,
CASE NTILE(4) OVER (ORDER BY toplam_harcama DESC)
WHEN 1 THEN 'Platin'
WHEN 2 THEN 'Altin'
WHEN 3 THEN 'Gumus'
WHEN 4 THEN 'Bronz'
END AS musteri_segmenti
FROM musteriler
ORDER BY toplam_harcama DESC;
Sunucu performans analizi için de kullanabilirsiniz. Yanıt sürelerini yüzdelik dilimlere göre sınıflandırmak:
-- API endpoint yanıt sürelerini yüzdelik dilimlere göre sınıflandır
SELECT
endpoint,
yanit_suresi_ms,
NTILE(100) OVER (PARTITION BY endpoint ORDER BY yanit_suresi_ms) AS percentile,
NTILE(10) OVER (PARTITION BY endpoint ORDER BY yanit_suresi_ms) AS decile
FROM api_loglari
WHERE istek_zamani >= CURRENT_DATE - INTERVAL '7 days'
ORDER BY endpoint, yanit_suresi_ms;
FIRST_VALUE, LAST_VALUE ve NTH_VALUE
Bu fonksiyonlar, pencere içindeki belirli sıradaki değere erişmek için kullanılır.
-- Her departmanda en yüksek ve en düşük maaş ile kendi maaşı arasındaki fark
SELECT
ad,
departman,
maas,
FIRST_VALUE(maas) OVER (
PARTITION BY departman
ORDER BY maas DESC
) AS en_yuksek_maas,
LAST_VALUE(maas) OVER (
PARTITION BY departman
ORDER BY maas DESC
ROWS BETWEEN UNBOUNDED PRECEDING AND UNBOUNDED FOLLOWING
) AS en_dusuk_maas,
maas - LAST_VALUE(maas) OVER (
PARTITION BY departman
ORDER BY maas DESC
ROWS BETWEEN UNBOUNDED PRECEDING AND UNBOUNDED FOLLOWING
) AS en_dusukten_fark
FROM calisanlar
ORDER BY departman, maas DESC;
LAST_VALUE kullanırken dikkat edilmesi gereken önemli bir nokta var: Varsayılan pencere çerçevesi ROWS BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW şeklindedir. Bu yüzden LAST_VALUE beklendiği gibi çalışmaz. Yukarıdaki örnekte olduğu gibi ROWS BETWEEN UNBOUNDED PRECEDING AND UNBOUNDED FOLLOWING ile çerçeveyi açıkça tanımlamanız gerekir.
Pencere Tanımlarını Yeniden Kullanmak: WINDOW Clause
Aynı pencere tanımını birden fazla yerde kullandığınızda sorgu uzayıp karmaşık hale gelir. PostgreSQL’in WINDOW cümlesi bu problemi çözer:
-- WINDOW clause ile temiz ve tekrarsız sorgu
SELECT
sunucu_adi,
olcum_zamani,
cpu_yuzde,
bellek_yuzde,
disk_yuzde,
AVG(cpu_yuzde) OVER w AS ort_cpu,
MAX(cpu_yuzde) OVER w AS maks_cpu,
MIN(cpu_yuzde) OVER w AS min_cpu,
AVG(bellek_yuzde) OVER w AS ort_bellek,
AVG(disk_yuzde) OVER w AS ort_disk,
ROW_NUMBER() OVER (PARTITION BY sunucu_adi ORDER BY olcum_zamani) AS olcum_no
FROM sunucu_metrikleri
WINDOW w AS (PARTITION BY sunucu_adi ORDER BY olcum_zamani ROWS BETWEEN 9 PRECEDING AND CURRENT ROW)
ORDER BY sunucu_adi, olcum_zamani;
Bu yapı hem okunabilirliği artırır hem de sorgu bakımını kolaylaştırır.
Performans Konuları ve İndeks Stratejisi
Window functions güçlü ama yanlış kullanıldığında sorgu planlarını berbat edebilir. Birkaç kritik noktaya dikkat etmek gerekir.
İlk olarak, EXPLAIN ANALYZE ile sorgu planını inceleyin:
-- Sorgu planını incele
EXPLAIN (ANALYZE, BUFFERS, FORMAT TEXT)
SELECT
sunucu_adi,
olcum_zamani,
cpu_yuzde,
AVG(cpu_yuzde) OVER (
PARTITION BY sunucu_adi
ORDER BY olcum_zamani
ROWS BETWEEN 9 PRECEDING AND CURRENT ROW
) AS hareketli_ort
FROM sunucu_metrikleri
WHERE olcum_zamani >= NOW() - INTERVAL '7 days';
Window functions için kritik indeks noktaları:
- PARTITION BY + ORDER BY kolonlarına bileşik indeks: Hem sıralama hem de bölümleme işlemini hızlandırır
- WHERE filtresini öne al: CTE veya subquery ile önce filtreleme yapıp sonra window function uygula
- Gereksiz pencereleri birleştir: Aynı PARTITION BY ve ORDER BY yapısını kullanan hesaplamaları tek bir geçişte yap
-- İndeks önerisi
CREATE INDEX idx_sunucu_metrikleri_partition ON sunucu_metrikleri
(sunucu_adi, olcum_zamani DESC);
-- Önce filtrele, sonra window function uygula
WITH son_veriler AS (
SELECT sunucu_adi, olcum_zamani, cpu_yuzde
FROM sunucu_metrikleri
WHERE olcum_zamani >= NOW() - INTERVAL '7 days'
),
hesaplamalar AS (
SELECT
sunucu_adi,
olcum_zamani,
cpu_yuzde,
AVG(cpu_yuzde) OVER (
PARTITION BY sunucu_adi
ORDER BY olcum_zamani
ROWS BETWEEN 9 PRECEDING AND CURRENT ROW
) AS hareketli_ort,
MAX(cpu_yuzde) OVER (
PARTITION BY sunucu_adi
ORDER BY olcum_zamani
ROWS BETWEEN 9 PRECEDING AND CURRENT ROW
) AS hareketli_maks
FROM son_veriler
)
SELECT * FROM hesaplamalar
WHERE cpu_yuzde > hareketli_ort * 1.5; -- Ortalamanın 1.5 katından yüksek olanlar
Gerçek Dünya Senaryosu: Kullanıcı Oturum Analizi
Bir web uygulamasının oturum verilerini analiz ettiğinizi düşünelim. Her kullanıcının oturumları, önceki oturumdan bu yana geçen süre ve oturum süresi gibi metrikleri hesaplayalım:
-- Kullanıcı oturum analizi
WITH oturum_analiz AS (
SELECT
kullanici_id,
oturum_baslangic,
oturum_bitis,
EXTRACT(EPOCH FROM (oturum_bitis - oturum_baslangic)) / 60 AS sure_dakika,
LAG(oturum_bitis) OVER (
PARTITION BY kullanici_id
ORDER BY oturum_baslangic
) AS onceki_oturum_bitis,
ROW_NUMBER() OVER (
PARTITION BY kullanici_id
ORDER BY oturum_baslangic
) AS oturum_no,
COUNT(*) OVER (PARTITION BY kullanici_id) AS toplam_oturum
FROM kullanici_oturumlari
),
bekleme_sureli AS (
SELECT
*,
EXTRACT(EPOCH FROM (oturum_baslangic - onceki_oturum_bitis)) / 3600 AS bekleme_saat,
AVG(sure_dakika) OVER (
PARTITION BY kullanici_id
ORDER BY oturum_baslangic
ROWS BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW
) AS kumulatif_ort_sure
FROM oturum_analiz
)
SELECT
kullanici_id,
oturum_no,
toplam_oturum,
ROUND(sure_dakika::numeric, 2) AS sure_dakika,
ROUND(bekleme_saat::numeric, 2) AS bekleme_saat,
ROUND(kumulatif_ort_sure::numeric, 2) AS kumulatif_ort_sure
FROM bekleme_sureli
ORDER BY kullanici_id, oturum_no;
Bu sorgu, bir kullanıcının hangi oturumunun en uzun olduğunu, oturumlar arası ortalama bekleme süresini ve her oturumla birlikte değişen ortalama oturum süresini tek sorguda verir.
Sık Yapılan Hatalar
Window functions kullanırken bazı hatalar tekrarlanır:
- WHERE içinde window function kullanmak: Window functions, WHERE cümlesinde kullanılamaz. Bunun yerine CTE veya subquery kullanılmalıdır.
- LAST_VALUE için çerçeve tanımlamamak: Varsayılan çerçeve LAST_VALUE’yu bozar, her zaman açık tanımlayın.
- Gereksiz tekrarlayan pencere tanımları: WINDOW clause kullanarak tekrardan kaçının.
- Çok büyük veri setlerinde sıralama performansı: PARTITION BY kolonuna indeks eklemeden büyük tablolarda window function kullanmak sorguyu yavaşlatır.
Sonuç
PostgreSQL’in window functions özelliği, veri analizi ve raporlama sorgularında gerçek bir oyun değiştirici. Subquery karmaşasından kurtulursunuz, kümülatif hesaplamalar tek geçişte yapılır, satırlar arası karşılaştırmalar temiz bir sözdizimle ifade edilir.
Bir sysadmin veya veritabanı yöneticisi olarak bu fonksiyonları özellikle monitoring verilerini analiz ederken, log tabanlı raporlamalarda ve performans metriklerini yorumlarken sürekli kullanıyorum. ROW_NUMBER, LAG/LEAD ve kümülatif SUM/AVG ikilisi işlerin büyük çoğunluğunu halleder. Daha karmaşık senaryolar için NTILE, FIRST_VALUE ve LAST_VALUE devreye girer.
Başlangıç için şunu öneririm: Mevcut sorgularınızda correlated subquery veya self-join gördüğünüzde, o sorguyu window function ile yeniden yazmayı deneyin. Genellikle hem daha kısa hem daha hızlı bir çözüme ulaşırsınız. EXPLAIN ANALYZE ile her iki versiyonu karşılaştırın, farkı kendi gözlerinizle görün.