PARTITION BY ile Gruplara Göre Sıralama
Veritabanı yönetiminde en çok ihtiyaç duyduğumuz şeylerden biri, bir grup içindeki kayıtları sıralamak ve her gruba özgü sıra numaraları atamaktır. Klasik ORDER BY ile bunu yapmak mümkün değil. İşte tam bu noktada PARTITION BY devreye giriyor. MariaDB ve MySQL’de pencere fonksiyonları (window functions) olarak bilinen bu yapı, özellikle raporlama ve analitik sorgularda hayat kurtarıcı oluyor. Bu yazıda PARTITION BY ile gruplara göre sıralama konusunu gerçek dünya senaryolarıyla birlikte ele alacağız.
PARTITION BY Nedir ve Neden Kullanılır?
PARTITION BY, bir sonuç kümesini mantıksal gruplara bölerek her grup içinde bağımsız hesaplamalar yapmanı sağlar. SQL’de pencere fonksiyonlarının bir parçasıdır ve OVER() cümlesiyle birlikte kullanılır.
Bunu şöyle düşün: Elinizde bir e-ticaret sitesinin sipariş tablosu var. Her müşterinin siparişlerini, o müşteri özelinde en yeniden en eskiye doğru sıralamak istiyorsunuz. Normal bir ORDER BY tüm tabloyu sıralar. Ama PARTITION BY ile her müşteri için ayrı ayrı sıralama yapabilirsin.
Pencere fonksiyonları ne zaman MySQL/MariaDB’ye geldi?
- MySQL: 8.0 sürümüyle (2018) tam destek geldi
- MariaDB: 10.2 sürümüyle (2017) pencere fonksiyonları eklendi
Eğer hala MySQL 5.7 veya MariaDB 10.1 kullanıyorsan, bu fonksiyonlara erişimin olmadığını bilmeni isterim. Sürüm kontrolü için:
SELECT VERSION();
-- veya
mysql --version
mariadb --version
Temel Söz Dizimi
PARTITION BY kullanımının genel yapısı şöyle:
SELECT
kolon1,
kolon2,
FONKSIYON() OVER (
PARTITION BY gruplandirma_kolonu
ORDER BY siralama_kolonu [ASC|DESC]
) AS takma_ad
FROM tablo_adi;
Burada kullanılan başlıca pencere fonksiyonları:
- ROW_NUMBER(): Her satıra benzersiz sıra numarası atar, eşit değerlerde bile farklı numara verir
- RANK(): Sıralamada eşit değerlere aynı numarayı verir, sonraki numara atlanır
- DENSE_RANK(): Eşit değerlere aynı numarayı verir ama sonraki numara atlanmaz
- NTILE(n): Sonuç kümesini n eşit parçaya böler
- LAG(): Önceki satırın değerine erişim sağlar
- LEAD(): Sonraki satırın değerine erişim sağlar
- SUM(), AVG(), MAX(), MIN(): Toplu hesaplamalar yapar
Örnek Veritabanı Kurulumu
Önce çalışacağımız test veritabanını ve tablolarını oluşturalım:
CREATE DATABASE egitim_db;
USE egitim_db;
CREATE TABLE siparisler (
siparis_id INT AUTO_INCREMENT PRIMARY KEY,
musteri_id INT NOT NULL,
musteri_adi VARCHAR(100),
urun_adi VARCHAR(100),
kategori VARCHAR(50),
tutar DECIMAL(10,2),
siparis_tarihi DATE
);
INSERT INTO siparisler (musteri_id, musteri_adi, urun_adi, kategori, tutar, siparis_tarihi) VALUES
(1, 'Ahmet Yilmaz', 'Laptop', 'Elektronik', 15000.00, '2024-01-05'),
(1, 'Ahmet Yilmaz', 'Mouse', 'Elektronik', 250.00, '2024-01-15'),
(1, 'Ahmet Yilmaz', 'Klavye', 'Elektronik', 750.00, '2024-02-10'),
(2, 'Ayse Kaya', 'Telefon', 'Elektronik', 12000.00, '2024-01-08'),
(2, 'Ayse Kaya', 'Kitap', 'Egitim', 350.00, '2024-01-20'),
(2, 'Ayse Kaya', 'Kulalik', 'Elektronik', 1800.00, '2024-02-05'),
(3, 'Mehmet Demir', 'Monitor', 'Elektronik', 8500.00, '2024-01-12'),
(3, 'Mehmet Demir', 'Webcam', 'Elektronik', 1200.00, '2024-02-01'),
(3, 'Mehmet Demir', 'Mikrofon', 'Ses', 3500.00, '2024-02-20'),
(4, 'Fatma Celik', 'Tablet', 'Elektronik', 9000.00, '2024-01-18'),
(4, 'Fatma Celik', 'Kalem', 'Kirtasiye', 150.00, '2024-02-08');
ROW_NUMBER() ile Gruba Özgü Sıra Numarası
En sık kullanılan senaryo: Her müşterinin siparişlerini tarih sırasına göre numaralandırmak.
SELECT
musteri_adi,
urun_adi,
tutar,
siparis_tarihi,
ROW_NUMBER() OVER (
PARTITION BY musteri_id
ORDER BY siparis_tarihi ASC
) AS siparis_sirasi
FROM siparisler
ORDER BY musteri_id, siparis_sirasi;
Bu sorgu şunu yapar: Her musteri_id için bağımsız bir sayaç başlatır. Ahmet’in ilk siparişi 1, ikincisi 2 olarak numaralandırılır. Ayşe’nin siparişleri de kendi içinde 1’den başlar.
Gerçek dünya kullanımı: Müşterilerin ilk alışverişlerini bulmak istediğinde bu numaralandırmayı alt sorgu olarak kullanabilirsin:
SELECT musteri_adi, urun_adi, tutar, siparis_tarihi
FROM (
SELECT
musteri_adi,
urun_adi,
tutar,
siparis_tarihi,
ROW_NUMBER() OVER (
PARTITION BY musteri_id
ORDER BY siparis_tarihi ASC
) AS siparis_sirasi
FROM siparisler
) AS numaralanmis
WHERE siparis_sirasi = 1;
Bu sorgu her müşterinin ilk siparişini döndürür. Yeni kullanıcı analizi, ilk satın alma davranışı araştırmaları gibi senaryolarda son derece işlevseldir.
RANK() ve DENSE_RANK() Farkı
Bu ikisinin farkını kavramak önemli. Bir senaryo üzerinden gösterelim. Diyelim ki her kategorideki en pahalı ürünleri sıralamak istiyoruz:
SELECT
kategori,
urun_adi,
tutar,
RANK() OVER (
PARTITION BY kategori
ORDER BY tutar DESC
) AS rank_sirasi,
DENSE_RANK() OVER (
PARTITION BY kategori
ORDER BY tutar DESC
) AS dense_rank_sirasi
FROM siparisler
ORDER BY kategori, rank_sirasi;
Farkı şöyle özetleyebilirim:
- RANK(): 1, 2, 2, 4 (3. sıra atlanır, iki kişi 2.’yse bir sonraki 4 olur)
- DENSE_RANK(): 1, 2, 2, 3 (hiçbir sıra atlanmaz, iki kişi 2.’yse bir sonraki 3 olur)
Hangi durumda hangisini kullanmalısın? Eğer “kaçıncı sıradadır” sorusunun cevabı önemliyse RANK() kullan. Eğer “kaç farklı değer grubu var” sorusu önemliyse DENSE_RANK() tercih et.
Kümülatif Toplam Hesaplama
Bir diğer güçlü kullanım senaryosu, her müşterinin siparişlerini tarih sırasına göre kümülatif tutarla göstermek:
SELECT
musteri_adi,
siparis_tarihi,
urun_adi,
tutar,
SUM(tutar) OVER (
PARTITION BY musteri_id
ORDER BY siparis_tarihi ASC
ROWS BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW
) AS kumulatif_toplam
FROM siparisler
ORDER BY musteri_id, siparis_tarihi;
ROWS BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW ifadesi, “ilk satırdan mevcut satıra kadar topla” anlamına gelir. Bu yapıya çerçeve tanımı (frame definition) denir.
Pratik kullanım alanları:
- Müşteri bazında aylık harcama takibi
- Bütçe tüketim raporları
- Satış hedefi gerçekleşme oranı takibi
- Stok giriş/çıkış hareketleri
LAG() ve LEAD() ile Önceki ve Sonraki Değerlere Erişim
Önceki siparişle fiyat karşılaştırması yapmak istediğinde LAG() ve LEAD() fonksiyonları devreye girer:
SELECT
musteri_adi,
siparis_tarihi,
urun_adi,
tutar,
LAG(tutar) OVER (
PARTITION BY musteri_id
ORDER BY siparis_tarihi
) AS onceki_siparis_tutari,
tutar - LAG(tutar, 1, 0) OVER (
PARTITION BY musteri_id
ORDER BY siparis_tarihi
) AS tutar_farki
FROM siparisler
ORDER BY musteri_id, siparis_tarihi;
LAG(tutar, 1, 0) ifadesindeki parametreler:
- tutar: Hangi kolonun değeri alınacak
- 1: Kaç satır geriye gidilecek
- 0: Önceki satır yoksa (ilk satır) kullanılacak varsayılan değer
LEAD() fonksiyonu aynı mantıkla çalışır ama ileriye bakar. Örneğin bir kullanıcının bir sonraki siparişine kaç gün kaldığını hesaplamak için:
SELECT
musteri_adi,
siparis_tarihi,
urun_adi,
LEAD(siparis_tarihi) OVER (
PARTITION BY musteri_id
ORDER BY siparis_tarihi
) AS sonraki_siparis_tarihi,
DATEDIFF(
LEAD(siparis_tarihi) OVER (
PARTITION BY musteri_id
ORDER BY siparis_tarihi
),
siparis_tarihi
) AS sonraki_siparise_gun
FROM siparisler
ORDER BY musteri_id, siparis_tarihi;
Bu sorgu müşteri davranış analizi için çok değerli veriler üretir. Ortalama sipariş aralığını, en aktif dönemleri ve müşteri churn (kayıp) riskini belirlemek için kullanılabilir.
Çoklu PARTITION BY Kolonu Kullanımı
PARTITION BY birden fazla kolona göre gruplayabilir. Hem müşteriye hem de kategoriye göre sıralama yapalım:
SELECT
musteri_adi,
kategori,
urun_adi,
tutar,
siparis_tarihi,
ROW_NUMBER() OVER (
PARTITION BY musteri_id, kategori
ORDER BY tutar DESC
) AS kategori_icerisindeki_siralama,
SUM(tutar) OVER (
PARTITION BY musteri_id, kategori
) AS kategori_toplam_harcama
FROM siparisler
ORDER BY musteri_id, kategori, kategori_icerisindeki_siralama;
Bu sorgu her müşteri için, her kategoride ne kadar harcadığını ve o kategorideki siparişlerini büyükten küçüğe sıralı gösterir. Bir müşteri Elektronik kategorisinde 5 sipariş verdiyse, bu siparişler kendi aralarında sıralanır ve o kategoriye ait toplam gösterilir.
NTILE() ile Gruplara Bölme
Müşterileri harcama miktarına göre segmentlere ayırmak istiyorsak NTILE() kullanırız:
SELECT
musteri_adi,
toplam_harcama,
NTILE(4) OVER (
ORDER BY toplam_harcama DESC
) AS segment,
CASE NTILE(4) OVER (ORDER BY toplam_harcama DESC)
WHEN 1 THEN 'Altin Musteri'
WHEN 2 THEN 'Gumus Musteri'
WHEN 3 THEN 'Bronz Musteri'
WHEN 4 THEN 'Standart Musteri'
END AS segment_adi
FROM (
SELECT
musteri_adi,
SUM(tutar) AS toplam_harcama
FROM siparisler
GROUP BY musteri_id, musteri_adi
) AS musteri_ozet
ORDER BY toplam_harcama DESC;
NTILE(4) sonuç kümesini 4 eşit parçaya böler. İlk çeyrek en yüksek harcamalılar olur. CRM sistemlerinde müşteri segmentasyonu için klasik bir yaklaşımdır.
Performans İpuçları ve Dikkat Edilmesi Gerekenler
Pencere fonksiyonları güçlüdür ama dikkatli kullanılmazsa performans sorunlarına yol açabilir.
İndeks kullanımı:
Pencere fonksiyonlarında PARTITION BY ve ORDER BY kolonlarına indeks eklemek performansı ciddi ölçüde artırır:
-- Musteri_id ve siparis_tarihi uzerinde indeks olustur
CREATE INDEX idx_musteri_tarih ON siparisler(musteri_id, siparis_tarihi);
-- Sorgu planini incele
EXPLAIN SELECT
musteri_adi,
tutar,
ROW_NUMBER() OVER (
PARTITION BY musteri_id
ORDER BY siparis_tarihi
) AS sira
FROM siparisler;
EXPLAIN çıktısında Using filesort görüyorsan, indeksin doğru tanımlanmadığını ya da sorgunun beklenmedik şekilde çalıştığını anlayabilirsin.
Dikkat edilmesi gereken noktalar:
- Pencere fonksiyonları
WHEREcümlesinde kullanılamaz, alt sorguya alman gerekir GROUP BYile birlikte kullanıldığında önceGROUP BYişlenir, sonra pencere fonksiyonu çalışır- Büyük tablolarda
PARTITION BYkullanan sorgular bellek yoğun olabilir;innodb_buffer_pool_sizevesort_buffer_sizeayarlarını gözden geçir - MariaDB 10.2 altı ve MySQL 5.7 altı sürümlerde bu fonksiyonlar çalışmaz
MariaDB için ek not:
MariaDB’de pencere fonksiyonları OVER() içinde PARTITION BY olmadan da kullanılabilir. Bu durumda tüm sonuç kümesi tek bir pencere olarak değerlendirilir:
-- Tum siparisler icinde siralama
SELECT
urun_adi,
tutar,
RANK() OVER (ORDER BY tutar DESC) AS genel_siralama
FROM siparisler;
Gerçek Dünya Senaryosu: En Son N Kayıt Çekme
Üretim ortamında sıklıkla karşılaşılan bir senaryo: Her kategoriden en pahalı 2 ürünü listele.
Bunu PARTITION BY olmadan yapmak hem zor hem de yavaş olurdu. Şimdi nasıl kolaylaştığına bak:
SELECT kategori, urun_adi, tutar, musteri_adi
FROM (
SELECT
kategori,
urun_adi,
tutar,
musteri_adi,
ROW_NUMBER() OVER (
PARTITION BY kategori
ORDER BY tutar DESC
) AS sira_no
FROM siparisler
) AS sirali
WHERE sira_no <= 2
ORDER BY kategori, tutar DESC;
Bu yaklaşım Top-N per group problemi olarak bilinir ve pencere fonksiyonları öncesinde NOT EXISTS, LEFT JOIN veya GROUP BY gibi karmaşık yapılarla çözülürdü. Şimdiki hali hem okunabilir hem de performanslı.
Benzer bir senaryo: Her müşterinin en son 3 siparişini getir:
SELECT musteri_adi, urun_adi, tutar, siparis_tarihi
FROM (
SELECT
musteri_adi,
urun_adi,
tutar,
siparis_tarihi,
ROW_NUMBER() OVER (
PARTITION BY musteri_id
ORDER BY siparis_tarihi DESC
) AS son_siparisler
FROM siparisler
) AS son_kayitlar
WHERE son_siparisler <= 3
ORDER BY musteri_adi, siparis_tarihi DESC;
Bu tür sorgular e-ticaret platformlarında “Son Görüntülenenler”, “Son Siparişler” gibi widget’lar için arka planda çalışan sorgularda sıklıkla kullanılır.
Hata Ayıklama ve Sık Karşılaşılan Sorunlar
Hata 1: “Window function not supported in WHERE clause”
-- YANLIS kullanim
SELECT * FROM siparisler
WHERE ROW_NUMBER() OVER (PARTITION BY musteri_id ORDER BY tutar) = 1;
-- DOGRU kullanim - alt sorgu ile
SELECT * FROM (
SELECT *, ROW_NUMBER() OVER (PARTITION BY musteri_id ORDER BY tutar) AS rn
FROM siparisler
) t WHERE rn = 1;
Hata 2: Sürüm uyumsuzluğu
MySQL 5.7 veya altı kullanıyorsan pencere fonksiyonları çalışmaz. Bu durumda değişken tabanlı simülasyon gerekir. Ama bu yöntem hatalara açıktır ve bakımı zordur. En iyi çözüm veritabanını güncellemektir:
# MySQL versiyonunu kontrol et
mysql -u root -p -e "SELECT VERSION();"
# MariaDB versiyonunu kontrol et
mariadb -u root -p -e "SELECT VERSION();"
Hata 3: NULL değerler ve sıralama
ORDER BY içinde NULL değerler varsayılan olarak en sona (ASC’de) ya da en başa (DESC’de) gider. Bunu kontrol etmek için:
SELECT
musteri_adi,
urun_adi,
tutar,
ROW_NUMBER() OVER (
PARTITION BY musteri_id
ORDER BY tutar DESC NULLS LAST
) AS sira
FROM siparisler;
Not: NULLS LAST ve NULLS FIRST söz dizimi MariaDB 10.6+ ve MySQL 8.0.x sürümlerinde desteklenmektedir.
Sonuç
PARTITION BY ile pencere fonksiyonları, SQL dünyasında büyük bir devrimdir. Bu fonksiyonlar öncesinde GROUP BY ile elde edemeyeceğin, alt sorgularla zoraki çözümlediğin onlarca senaryo artık çok daha temiz ve okunabilir sorgularla halledilebiliyor.
Özellikle şu durumlarda PARTITION BY kullanmayı düşünmelisin:
- Her grup içinde sıralama yapmak istediğinde
- Gruba özgü kümülatif hesaplamalar (toplam, ortalama) gerektiğinde
- Önceki ya da sonraki satır değerlerine ihtiyaç duyduğunda
- Her gruptan ilk N kaydı çekmek istediğinde
- Müşteri segmentasyonu veya yüzdelik dilim hesapları yapacağında
MariaDB 10.2+ veya MySQL 8.0+ kullanıyorsan bu fonksiyonlar hazır seni bekliyor. Kullanmaya başlamadan önce sürüm kontrolünü yap, kritik sorgularda EXPLAIN ile planı incele ve PARTITION BY ile ORDER BY kolonlarını indeksle. Bu üç adımı takip ettiğinde hem doğru hem de verimli sorgular yazacaksın.
