MariaDB ve MySQL’de Window Fonksiyonları ile Kümülatif Toplam Hesaplama

Veritabanı sorgularında zaman zaman “bu aya kadar toplam ne kadar sattık?” veya “bu kullanıcı şimdiye kadar kaç sipariş verdi?” gibi sorulara cevap vermek gerekir. İşte tam bu noktada SQL’in window fonksiyonları devreye girer ve hayatı kolaylaştırır. Kümülatif toplam (running total veya cumulative sum) hesaplama, özellikle raporlama ve analitik sorgularda sıkça ihtiyaç duyulan bir tekniktir. MariaDB ve MySQL’de bu işi yapmanın birden fazla yolu var; ama window fonksiyonları ile yapılan çözüm hem en okunaklı hem de en performanslı olanıdır.

Window Fonksiyonu Nedir?

Window fonksiyonları, bir satır grubu üzerinde hesaplama yapan ama o grubun satırlarını tek bir satıra indirgemeden sonuç döndüren SQL fonksiyonlarıdır. Klasik GROUP BY ile yapılan agregasyon, birden fazla satırı tek bir satıra sıkıştırır. Window fonksiyonları ise her satır için ayrı ayrı hesaplama yapar ve orijinal satır sayısını korur.

OVER() ifadesi, bir fonksiyonu window fonksiyonuna dönüştüren anahtar kelimedir. OVER() içine PARTITION BY ve ORDER BY yazarak pencereyi (window) tanımlarsınız.

MariaDB’de window fonksiyonları 10.2 sürümünden itibaren desteklenmektedir. MySQL’de ise 8.0 ile birlikte geldi. Eğer daha eski sürümler kullanıyorsanız, yazının ilerleyen kısımlarında değişken tabanlı alternatif yöntemi de ele alacağım.

Sürümünüzü kontrol etmek için:

SELECT VERSION();

Temel Sözdizimi

SUM() fonksiyonunu window fonksiyonu olarak kullanmanın genel yapısı şöyledir:

SUM(toplam_alinacak_kolon) OVER (
    PARTITION BY gruplama_kolonu
    ORDER BY siralama_kolonu
    ROWS BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW
)

Buradaki ROWS BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW ifadesi, “ilk satırdan mevcut satıra kadar” anlamına gelir ve kümülatif toplamın ruhunu oluşturur. Aslında ORDER BY ile birlikte ROWS ifadesi yazmadığınızda MariaDB ve MySQL bu davranışı varsayılan olarak uygular; ama açıkça yazmak sorgunun niyetini netleştirir ve taşınabilirlik açısından iyidir.

Örnek Senaryo 1: Satış Raporunda Aylık Kümülatif Toplam

Bir e-ticaret uygulamasında aylık satış rakamlarının kümülatif toplamını hesaplayalım. Tablomuz şu şekilde olsun:

CREATE TABLE aylik_satislar (
    id INT AUTO_INCREMENT PRIMARY KEY,
    ay INT NOT NULL,
    yil INT NOT NULL,
    toplam_satis DECIMAL(12, 2) NOT NULL
);

INSERT INTO aylik_satislar (ay, yil, toplam_satis) VALUES
(1, 2024, 15000.00),
(2, 2024, 22000.00),
(3, 2024, 18500.00),
(4, 2024, 31000.00),
(5, 2024, 27000.00),
(6, 2024, 33500.00);

Kümülatif toplamı hesaplayan sorgu:

SELECT
    yil,
    ay,
    toplam_satis,
    SUM(toplam_satis) OVER (
        PARTITION BY yil
        ORDER BY ay
        ROWS BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW
    ) AS kumulatif_toplam
FROM aylik_satislar
ORDER BY yil, ay;

Bu sorgu çalıştığında her satır için, o aya kadar olan toplam satış miktarını göreceksiniz. Ocak ayı için kümülatif toplam 15.000 TL iken Haziran ayına gelindiğinde 147.000 TL olacaktır. PARTITION BY yil ifadesi sayesinde her yıl için kümülatif toplam sıfırdan başlar; bu nedenle 2023 ve 2024 verileri birbirini etkilemez.

Örnek Senaryo 2: Kullanıcı Bazında Sipariş Kümülatifi

Bir müşteri sipariş geçmişinde, her kullanıcının sipariş başına kadar olan toplam harcamasını bulmak oldukça yaygın bir analitik ihtiyaçtır. CRM sistemleri ve müşteri segmentasyon araçları bu tür sorgulara sıkça başvurur.

CREATE TABLE siparisler (
    siparis_id INT AUTO_INCREMENT PRIMARY KEY,
    kullanici_id INT NOT NULL,
    siparis_tarihi DATE NOT NULL,
    siparis_tutari DECIMAL(10, 2) NOT NULL
);

INSERT INTO siparisler (kullanici_id, siparis_tarihi, siparis_tutari) VALUES
(1, '2024-01-05', 250.00),
(1, '2024-01-20', 180.00),
(1, '2024-02-10', 420.00),
(2, '2024-01-08', 90.00),
(2, '2024-01-25', 310.00),
(2, '2024-03-01', 155.00),
(3, '2024-02-14', 780.00);

Her kullanıcı için sipariş bazında kümülatif toplam:

SELECT
    kullanici_id,
    siparis_tarihi,
    siparis_tutari,
    SUM(siparis_tutari) OVER (
        PARTITION BY kullanici_id
        ORDER BY siparis_tarihi
    ) AS toplam_harcama
FROM siparisler
ORDER BY kullanici_id, siparis_tarihi;

Bu sorgu, kullanıcı 1 için ilk siparişte 250 TL, ikinci siparişte 430 TL, üçüncü siparişte 850 TL olarak kümülatif toplamı döndürür. Kullanıcı 2’ye geçildiğinde kümülatif toplam yeniden sıfırdan başlar; çünkü PARTITION BY kullanici_id bölümleme yapıyor.

ROWS ve RANGE Arasındaki Fark

Bu nokta pratikte sıklıkla karıştırılır. ROWS ve RANGE ifadeleri pencere çerçevesini farklı şekillerde tanımlar.

  • ROWS: Fiziksel satır sayısına göre çerçeve belirler. “Mevcut satırdan 2 öncesine kadar” derseniz, gerçekten 2 satır geriye gider.
  • RANGE: Mantıksal değere göre çerçeve belirler. Aynı ORDER BY değerine sahip satırlar aynı gruba dahil edilir.

Aynı tarihe sahip birden fazla sipariş varsa ve siz RANGE kullanırsanız, bu siparişlerin tamamı aynı anda kümülatife dahil edilir. ROWS kullanırsanız sıra sıra işlenir. Çoğu durumda ROWS BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW kullanmak daha öngörülür bir davranış sergiler.

-- ROWS ile: Her satır tek tek işlenir
SELECT
    siparis_tarihi,
    siparis_tutari,
    SUM(siparis_tutari) OVER (
        ORDER BY siparis_tarihi
        ROWS BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW
    ) AS rows_kumulatif
FROM siparisler;

-- RANGE ile: Aynı tarihteki tüm satırlar birlikte değerlendirilir
SELECT
    siparis_tarihi,
    siparis_tutari,
    SUM(siparis_tutari) OVER (
        ORDER BY siparis_tarihi
        RANGE BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW
    ) AS range_kumulatif
FROM siparisler;

Örnek Senaryo 3: Finans Uygulamasında Banka Hesabı Bakiyesi

Klasik bankacılık uygulamasında, her işlem sonrası bakiyeyi hesaplamak kümülatif toplamın en somut gerçek dünya örneğidir. Borç ve alacak işlemlerini içeren bir tabloda her satır için güncel bakiyeyi hesaplayalım:

CREATE TABLE hesap_hareketleri (
    hareket_id INT AUTO_INCREMENT PRIMARY KEY,
    hesap_no VARCHAR(20) NOT NULL,
    islem_tarihi DATETIME NOT NULL,
    aciklama VARCHAR(100),
    tutar DECIMAL(12, 2) NOT NULL  -- Pozitif: alacak, Negatif: borc
);

INSERT INTO hesap_hareketleri (hesap_no, islem_tarihi, aciklama, tutar) VALUES
('TR001', '2024-03-01 09:00:00', 'Maas yatma', 15000.00),
('TR001', '2024-03-03 14:30:00', 'Market alisverisi', -850.00),
('TR001', '2024-03-05 11:00:00', 'Kira ödeme', -3500.00),
('TR001', '2024-03-10 16:45:00', 'Freelance gelir', 4200.00),
('TR001', '2024-03-15 09:30:00', 'Fatura ödemeleri', -1200.00);

SELECT
    hesap_no,
    islem_tarihi,
    aciklama,
    tutar,
    SUM(tutar) OVER (
        PARTITION BY hesap_no
        ORDER BY islem_tarihi
        ROWS BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW
    ) AS anlik_bakiye
FROM hesap_hareketleri
ORDER BY hesap_no, islem_tarihi;

Bu sorgu her işlem sonrası bakiyeyi canlı olarak hesaplar. Böyle bir sorguyu uygulama katmanında döngü yazarak yapmak hem daha karmaşık hem de çok daha yavaş olurdu.

Eski Sürümlerde Değişken Tabanlı Yöntem

Eğer MariaDB 10.2 veya MySQL 8.0 öncesi bir sürüm kullanıyorsanız, window fonksiyonları desteklenmez. Bu durumda kullanıcı tanımlı değişkenlerle kümülatif toplam hesaplanabilir:

SET @kumulatif := 0;

SELECT
    kullanici_id,
    siparis_tarihi,
    siparis_tutari,
    @kumulatif := @kumulatif + siparis_tutari AS toplam_harcama
FROM siparisler
ORDER BY siparis_tarihi;

Dikkat: Bu yöntemin birkaç önemli kısıtı vardır. PARTITION BY gibi bir gruplandırma yapmak için ek değişken ve CASE ifadesi gerekir. Ayrıca MySQL 8.0 sonrası bu değişken tabanlı yaklaşım resmi olarak desteklenmediğinden güvenilirliği azalmıştır. Mümkünse güncel sürüme geçmek ve window fonksiyonlarını kullanmak her zaman daha sağlıklıdır.

Eski sürümde bölümlü kümülatif toplam için:

SET @kumulatif := 0;
SET @onceki_kullanici := NULL;

SELECT
    kullanici_id,
    siparis_tarihi,
    siparis_tutari,
    @kumulatif := CASE
        WHEN @onceki_kullanici = kullanici_id
        THEN @kumulatif + siparis_tutari
        ELSE siparis_tutari
    END AS toplam_harcama,
    @onceki_kullanici := kullanici_id AS _gizli
FROM siparisler
ORDER BY kullanici_id, siparis_tarihi;

Bu sorgu oldukça çirkin ve kırılgan; ama eski sistemlerde bu tür geçici çözümler hayat kurtarır.

Performans İpuçları

Kümülatif toplam sorguları büyük tablolarda yavaşlayabilir. Birkaç pratik optimizasyon önerisi:

  • İndeks kullanımı kritiktir: ORDER BY ve PARTITION BY kolonlarına mutlaka indeks ekleyin. Yukarıdaki örnekler için (kullanici_id, siparis_tarihi) şeklinde bileşik indeks oluşturun.
CREATE INDEX idx_siparis_kullanici_tarih
ON siparisler (kullanici_id, siparis_tarihi);
  • EXPLAIN ile sorgu planını inceleyin: Window fonksiyonu kullanan sorgularda EXPLAIN çıktısı, geçici tablo kullanımını ve dosya sıralamasını gösterir. Using filesort görüyorsanız ve bu performansı etkiliyorsa indeks stratejinizi gözden geçirin.
EXPLAIN SELECT
    kullanici_id,
    siparis_tarihi,
    SUM(siparis_tutari) OVER (
        PARTITION BY kullanici_id
        ORDER BY siparis_tarihi
    ) AS toplam_harcama
FROM siparisler;
  • Gereksiz kolonları sorguya dahil etmeyin: Window fonksiyonu hesaplamaları için yalnızca ihtiyaç duyduğunuz kolonları seçin. SELECT * kullanmaktan kaçının.
  • Büyük veri setlerinde filtreleyin: Milyonlarca satırlık bir tabloda kümülatif toplam hesaplıyorsanız, önce bir WHERE ile veri setini daraltın, ardından window fonksiyonunu uygulayın. Bunu bir alt sorgu veya CTE ile yapabilirsiniz.
WITH filtrelenmis_veri AS (
    SELECT kullanici_id, siparis_tarihi, siparis_tutari
    FROM siparisler
    WHERE siparis_tarihi >= '2024-01-01'
      AND siparis_tarihi < '2024-04-01'
)
SELECT
    kullanici_id,
    siparis_tarihi,
    siparis_tutari,
    SUM(siparis_tutari) OVER (
        PARTITION BY kullanici_id
        ORDER BY siparis_tarihi
    ) AS toplam_harcama
FROM filtrelenmis_veri;

Birden Fazla Window Fonksiyonunu Birlikte Kullanmak

Aynı sorguda hem kümülatif toplam hem de kümülatif ortalama, satır numarası gibi farklı window hesaplamalarını bir arada kullanabilirsiniz. Bu, raporlama sorgularını tek seferde zenginleştirir:

SELECT
    kullanici_id,
    siparis_tarihi,
    siparis_tutari,
    SUM(siparis_tutari) OVER (
        PARTITION BY kullanici_id
        ORDER BY siparis_tarihi
    ) AS kumulatif_toplam,
    AVG(siparis_tutari) OVER (
        PARTITION BY kullanici_id
        ORDER BY siparis_tarihi
    ) AS kumulatif_ortalama,
    COUNT(*) OVER (
        PARTITION BY kullanici_id
        ORDER BY siparis_tarihi
    ) AS siparis_sira_no,
    MAX(siparis_tutari) OVER (
        PARTITION BY kullanici_id
        ORDER BY siparis_tarihi
    ) AS simdiye_kadar_max
FROM siparisler
ORDER BY kullanici_id, siparis_tarihi;

Bu tek sorgu ile her kullanıcı için o ana kadarki toplam harcama, ortalama sipariş değeri, kaçıncı sipariş olduğu ve en yüksek tek sipariş tutarı bilgilerini aynı anda alırsınız. Bunu ayrı sorgularla veya uygulama tarafında hesaplamak yerine veritabanına bırakmak büyük performans kazancı sağlar.

Görünüm (View) ile Tekrar Kullanılabilir Hale Getirme

Sıkça kullanılan kümülatif toplam sorgularını bir view altında saklayabilirsiniz. Böylece uygulama katmanı her seferinde karmaşık sorgu yazmak zorunda kalmaz:

CREATE OR REPLACE VIEW kullanici_kumulatif_harcama AS
SELECT
    s.kullanici_id,
    s.siparis_tarihi,
    s.siparis_tutari,
    SUM(s.siparis_tutari) OVER (
        PARTITION BY s.kullanici_id
        ORDER BY s.siparis_tarihi
        ROWS BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW
    ) AS kumulatif_toplam
FROM siparisler s;

-- View'i kullanmak
SELECT * FROM kullanici_kumulatif_harcama
WHERE kullanici_id = 1;

Not: View’larda window fonksiyonlarının performansı, altta yatan tablonun büyüklüğüne bağlıdır. Milyonlarca satırlı tablolar için view yerine materialize edilmiş sonuçları ayrı bir tabloda saklamak daha sağlıklı olabilir. MariaDB’de CREATE TABLE ... AS SELECT ile periyodik olarak bu tabloyu yenileyebilirsiniz.

Yaygın Hatalar ve Çözümleri

Kümülatif toplam sorgularında karşılaşılan en yaygın sorunlar:

  • Hatalı sıralama: ORDER BY olmadan SUM() OVER() yazmak kümülatif değil, bölümdeki tüm satırların toplamını döndürür. ORDER BY mutlaka olmalı.
  • NULL değerler: SUM() NULL değerleri otomatik olarak atlar; ancak sıralama kolonunda NULL varsa beklenmedik sonuçlar çıkabilir. ORDER BY kolon IS NULL, kolon şeklinde NULL’ları sona atabilirsiniz.
  • Tarih formatı sorunları: siparis_tarihi kolonu VARCHAR olarak saklanıyorsa, ORDER BY alfabetik sıralama yapar ve kümülatif toplam yanlış hesaplanır. Tarih kolonlarını her zaman DATE veya DATETIME tipinde tutun.
  • Duplicate satırlar: Aynı tarihte birden fazla işlem varsa ve siz RANGE kullanıyorsanız, tüm aynı tarihliler aynı kümülatif değeri gösterir. Bu bazen istenen davranış, bazen değil. Duruma göre ROWS ve RANGE arasında bilinçli tercih yapın.

Sonuç

Window fonksiyonları ile kümülatif toplam hesaplama, SQL’in güçlü ama yeterince kullanılmayan özelliklerinden biridir. Satış raporları, banka bakiyeleri, kullanıcı davranış analizleri gibi pek çok gerçek dünya senaryosunda bu yaklaşım hem kodun okunabilirliğini artırır hem de performansı iyileştirir.

MariaDB 10.2 ve MySQL 8.0 ile birlikte window fonksiyonları artık kararlı ve production ortamına hazır hale geldi. Eğer hala eski sürüm kullanıyorsanız, değişken tabanlı geçici çözümlerle idare edebilirsiniz; ama orta vadede güncelleme planı yapmakta fayda var.

Pratik bir özet vermek gerekirse:

  • PARTITION BY kullanarak her grup için kümülatifi bağımsız hesaplayın
  • ORDER BY olmadan kümülatif toplam hesaplanamaz, mutlaka ekleyin
  • ROWS BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW ifadesiyle davranışı açıkça tanımlayın
  • Performans için PARTITION BY ve ORDER BY kolonlarına bileşik indeks ekleyin
  • Sık kullanılan sorguları view olarak saklayın, büyük tablolarda materyalize tablolar değerlendirin

Bu teknikleri bir kez kavradıktan sonra, pek çok karmaşık raporlama sorgusunun aslında birkaç satırlık temiz SQL ile çözülebileceğini göreceksiniz.

Bir yanıt yazın

E-posta adresiniz yayınlanmayacak. Gerekli alanlar * ile işaretlenmişlerdir