MariaDB ve MySQL’de ROW_NUMBER ile Satır Numaralandırma

Veri tabanı yönetiminde en sık karşılaştığımız ihtiyaçlardan biri satırlara sıra numarası atamaktır. Rapor üretmek, sayfalama yapmak ya da belirli bir grup içindeki en iyi/en kötü kaydı bulmak istediğinde ROW_NUMBER() pencere fonksiyonu hayat kurtarır. MariaDB 10.2 ve MySQL 8.0 ile birlikte standart SQL’e uygun olarak gelen bu fonksiyon, eski SET @rownum numaralarının yerini artık çok daha temiz bir söz dizimiyle aldı. Bu yazıda gerçek dünya senaryoları üzerinden ROW_NUMBER() fonksiyonunu her açıdan ele alacağız.

ROW_NUMBER() Nedir ve Neden Kullanılır?

ROW_NUMBER() bir pencere fonksiyonudur (window function). Sorgu sonuç kümesindeki her satıra, belirlediğin sıralama ve gruplama kurallarına göre benzersiz bir sıra numarası atar. Klasik AUTO_INCREMENT gibi tabloya kalıcı olarak yazılmaz; sorgu anında hesaplanır ve sonuç kümesine eklenir.

Neden işe yarar? Şunları düşün:

  • Bir e-ticaret sitesinde her kategorideki en çok satan 3 ürünü bulmak istiyorsun
  • Müşteri siparişlerini sayfalamak için offset hesabı yapmak istemiyorsun
  • Bir tablodaki duplicate kayıtları tespit edip sadece birini tutmak istiyorsun
  • Çalışan performans raporunda departman bazında sıralama yapmak istiyorsun

Tüm bu senaryolarda ROW_NUMBER() devreye girer.

Temel Söz Dizimi

ROW_NUMBER() OVER (
    [PARTITION BY kolon1, kolon2, ...]
    ORDER BY kolon3 [ASC|DESC]
)
  • PARTITION BY: Sonuç kümesini gruplara böler. Her grup için sayaç sıfırdan başlar. İsteğe bağlıdır.
  • ORDER BY: Hangi kritere göre sıralanacağını belirtir. Bu kısım zorunludur.
  • OVER: Pencere fonksiyonlarının temel ifadesidir. Bu olmadan ROW_NUMBER() çalışmaz.

MariaDB ve MySQL Sürüm Gereksinimleri

Pencere fonksiyonlarını kullanabilmek için sürümlere dikkat etmek gerekiyor:

  • MariaDB 10.2.0 ve üzeri: ROW_NUMBER() tam destek
  • MySQL 8.0 ve üzeri: ROW_NUMBER() tam destek
  • MySQL 5.7 ve altı: Desteklenmiyor, geçici değişken yöntemi gerekiyor
  • MariaDB 10.1 ve altı: Desteklenmiyor

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

SELECT VERSION();
-- veya
mysql --version

Eğer eski bir sürümde çalışıyorsan yazının ilerleyen bölümünde @rownum değişken yöntemini de göstereceğim.

İlk Pratik Örnek: Basit Satır Numaralandırma

Bir employees tablomuz olsun. Tüm çalışanları maaşa göre sıralayıp her birine numara verelim:

SELECT
    ROW_NUMBER() OVER (ORDER BY salary DESC) AS row_num,
    first_name,
    last_name,
    department,
    salary
FROM employees;

Bu sorgu çalıştığında her satır 1’den başlayarak artan bir numara alır. ORDER BY salary DESC diyerek en yüksek maaşlı çalışan 1 numarayı alır. Dikkat etmek gereken nokta şu: ORDER BY içindeki kolon sıralamayla sonuçtaki SELECT sıralaması birbirine bağlı değildir. Sonuçları farklı sıralamak istersen dış sorguda ayrıca ORDER BY kullanman gerekir.

PARTITION BY ile Grup İçi Numaralandırma

Şimdi işler daha ilginç hale geliyor. Her departman için ayrı ayrı sıralama yapalım:

SELECT
    ROW_NUMBER() OVER (
        PARTITION BY department
        ORDER BY salary DESC
    ) AS dept_rank,
    first_name,
    last_name,
    department,
    salary
FROM employees
ORDER BY department, dept_rank;

Bu sorguda PARTITION BY department ifadesi sayesinde her departman için sayaç sıfırlanır. Pazarlama departmanındaki en yüksek maaşlı çalışan 1, Yazılım departmanındaki en yüksek maaşlı çalışan da 1 numarayı alır. Sonunda ORDER BY department, dept_rank ile grupları düzenli gösteriyoruz.

Gerçek dünya kullanımı: Bir insan kaynakları uygulamasında “Her departmandaki en kıdemli 3 çalışanı getir” gibi raporlar için bu yapı birebir uyar.

Gerçek Senaryo 1: Her Kategorideki En Çok Satan Ürünleri Bulmak

E-ticaret veritabanında orders ve products tabloları var diyelim. Her kategoride en çok satılan ilk 3 ürünü bulmak istiyoruz:

SELECT *
FROM (
    SELECT
        ROW_NUMBER() OVER (
            PARTITION BY p.category_id
            ORDER BY SUM(oi.quantity) DESC
        ) AS category_rank,
        p.product_name,
        p.category_id,
        c.category_name,
        SUM(oi.quantity) AS total_sold
    FROM order_items oi
    JOIN products p ON oi.product_id = p.product_id
    JOIN categories c ON p.category_id = c.category_id
    WHERE oi.order_date >= DATE_SUB(NOW(), INTERVAL 30 DAY)
    GROUP BY p.product_id, p.product_name, p.category_id, c.category_name
) ranked_products
WHERE category_rank <= 3
ORDER BY category_id, category_rank;

Bu örnekte birkaç önemli nokta var:

  • ROW_NUMBER() doğrudan WHERE koşulunda kullanılamaz, bu yüzden alt sorgu (subquery) kullandık
  • GROUP BY ile SUM() hesaplanıyor, sonra ROW_NUMBER() bu aggregate sonuçları üzerinden sıralıyor
  • Dış sorgudaki WHERE category_rank <= 3 ile sadece ilk 3’ü alıyoruz

Gerçek Senaryo 2: Sayfalama (Pagination) için ROW_NUMBER()

Geleneksel LIMIT offset, count yöntemi büyük tablolarda performans sorunlarına yol açabilir. ROW_NUMBER() ile sayfalama:

-- Sayfa numarası ve sayfa başına kayıt sayısı değişkenleri
SET @page_number = 3;
SET @page_size = 10;

SELECT *
FROM (
    SELECT
        ROW_NUMBER() OVER (ORDER BY created_at DESC) AS row_num,
        customer_id,
        customer_name,
        email,
        created_at
    FROM customers
) paginated
WHERE row_num BETWEEN (@page_number - 1) * @page_size + 1
                  AND @page_number * @page_size
ORDER BY row_num;
  1. sayfa için row_num 21 ile 30 arasındaki kayıtları getirir. Bu yöntemin avantajı özellikle WHERE koşullarıyla filtrelenmiş sonuç kümelerinde sayfalama yaparken sıralı ve güvenilir sonuçlar vermesidir.

Gerçek Senaryo 3: Duplicate Kayıtları Tespit Etme ve Temizleme

Bir tabloda aynı e-posta adresiyle birden fazla müşteri kaydı oluşmuş olabilir. ROW_NUMBER() ile hangi kayıtların silinmesi gerektiğini bulabiliriz:

-- Önce duplicate'leri görelim
SELECT *
FROM (
    SELECT
        ROW_NUMBER() OVER (
            PARTITION BY email
            ORDER BY created_at ASC
        ) AS rn,
        customer_id,
        customer_name,
        email,
        created_at
    FROM customers
) duplicates
WHERE rn > 1;

İlk kayıt (rn = 1) her e-posta için en eski kayıt olacak, rn > 1 olanlar ise silinecek adaylar. Şimdi silme işlemi:

-- Güvenli silme: Önce DELETE yerine SELECT ile doğrula
DELETE FROM customers
WHERE customer_id IN (
    SELECT customer_id
    FROM (
        SELECT
            ROW_NUMBER() OVER (
                PARTITION BY email
                ORDER BY created_at ASC
            ) AS rn,
            customer_id
        FROM customers
    ) ranked
    WHERE rn > 1
);

Önemli uyarı: Bu sorguyu çalıştırmadan önce mutlaka BEGIN TRANSACTION ile işlemi başlat ve sonuçları doğruladıktan sonra COMMIT et. Yoksa geri dönüşü olmayan veri kaybıyla karşılaşabilirsin.

Gerçek Senaryo 4: Zaman Serisi Analizinde Sıralama

Log tablolarında belirli bir event’in art arda kaç kez tekrarlandığını bulmak için ROW_NUMBER() kullanılabilir:

SELECT
    ROW_NUMBER() OVER (
        PARTITION BY user_id, event_type
        ORDER BY event_time ASC
    ) AS occurrence_number,
    user_id,
    event_type,
    event_time,
    TIMESTAMPDIFF(
        MINUTE,
        LAG(event_time) OVER (PARTITION BY user_id, event_type ORDER BY event_time),
        event_time
    ) AS minutes_since_last
FROM user_events
WHERE event_time >= DATE_SUB(NOW(), INTERVAL 7 DAY)
ORDER BY user_id, event_type, occurrence_number;

Bu sorguda ROW_NUMBER() ve LAG() pencere fonksiyonlarını birlikte kullandık. LAG() bir önceki satırın değerini getirir, böylece aynı kullanıcının aynı event’ini ne sıklıkla yaptığını dakika bazında görebiliyoruz. Bu tür sorgular güvenlik log analizi, kullanıcı davranış analizi ve anomali tespitinde çok işe yarar.

WITH (CTE) ile ROW_NUMBER() Kullanımı

Okunabilirliği artırmak için Common Table Expression (CTE) yapısını tercih edebilirsin. MariaDB 10.2.1 ve MySQL 8.0’da desteklenir:

WITH ranked_sales AS (
    SELECT
        ROW_NUMBER() OVER (
            PARTITION BY salesperson_id
            ORDER BY sale_amount DESC
        ) AS sale_rank,
        salesperson_id,
        customer_name,
        sale_amount,
        sale_date,
        product_category
    FROM sales
    WHERE YEAR(sale_date) = YEAR(CURDATE())
),
top_performers AS (
    SELECT
        salesperson_id,
        COUNT(*) AS top_sale_count,
        SUM(sale_amount) AS top_sale_total
    FROM ranked_sales
    WHERE sale_rank <= 5
    GROUP BY salesperson_id
)
SELECT
    e.first_name,
    e.last_name,
    tp.top_sale_count,
    tp.top_sale_total
FROM top_performers tp
JOIN employees e ON tp.salesperson_id = e.employee_id
ORDER BY tp.top_sale_total DESC;

CTE kullanımı karmaşık sorguları adımlara bölerek hem yazması hem de bakımı kolay hale getirir. ranked_sales CTE’si önce sıralama yapar, top_performers CTE’si bu sonucu filtreler, son sorgu ise okunabilir bir rapor üretir.

Eski MySQL Sürümleri için Alternatif Yöntem

MySQL 5.7 veya daha eski bir MariaDB kullanıyorsan pencere fonksiyonu yoktur. Bu durumda kullanıcı değişkenleriyle aynı sonucu elde edebilirsin:

-- MySQL 5.7 ve altı için ROW_NUMBER() alternatifi
SELECT
    @row_num := @row_num + 1 AS row_num,
    t.employee_id,
    t.first_name,
    t.salary
FROM (
    SELECT employee_id, first_name, salary
    FROM employees
    ORDER BY salary DESC
) t,
(SELECT @row_num := 0) r;

Grup bazında numaralandırma için daha karmaşık bir yapı gerekir:

SELECT
    IF(@current_dept = department,
       @row_num := @row_num + 1,
       @row_num := 1
    ) AS dept_row_num,
    @current_dept := department AS department,
    first_name,
    salary
FROM employees,
     (SELECT @row_num := 0, @current_dept := '') r
ORDER BY department, salary DESC;

Bu yöntem işe yarar ama güvenilirlik açısından tartışmalıdır. MySQL optimizer sırayı değiştirebileceğinden sonuçlar bazen beklenmedik çıkabilir. Mümkünse sürümü güncelleyip pencere fonksiyonlarına geçmek en sağlıklı yol.

Performans Hususları

ROW_NUMBER() kullanırken performansı göz ardı etmemelisin:

  • İndeks kullanımı: PARTITION BY ve ORDER BY kolonlarında indeks olması sorguyu hızlandırır. EXPLAIN ile execution plan’ı incele.
  • Büyük tablolar: Milyonlarca satırlık tablolarda tüm tabloyu taramak yerine WHERE koşuluyla önceden filtrele, sonra ROW_NUMBER() uygula.
  • Alt sorgu derinliği: İç içe alt sorgular her zaman gerekli değildir. CTE kullanmak optimizer’ın işini kolaylaştırabilir.
  • Geçici tablo: Çok sık çalışan ve ağır sorgularda sonucu geçici tabloya yazıp oradan okumak toplam süreyi düşürebilir.

Performans analizi için:

EXPLAIN FORMAT=JSON
SELECT *
FROM (
    SELECT
        ROW_NUMBER() OVER (PARTITION BY department ORDER BY salary DESC) AS rn,
        employee_id,
        department,
        salary
    FROM employees
) t
WHERE rn = 1;

FORMAT=JSON ile çok daha detaylı bir execution plan alırsın. rows_examined, cost_info gibi değerlere bakarak sorgunun ne kadar ağır olduğunu görebilirsin.

ROW_NUMBER() ile RANK() ve DENSE_RANK() Arasındaki Fark

Sık karıştırılan bir konu olduğu için kısaca değinmek gerekiyor:

  • ROW_NUMBER(): Her satıra benzersiz numara verir. Eşit değerler bile farklı numara alır (1, 2, 3, 4…)
  • RANK(): Eşit değerler aynı sırayı alır ama sonraki numara atlanır (1, 2, 2, 4…)
  • DENSE_RANK(): Eşit değerler aynı sırayı alır, numara atlanmaz (1, 2, 2, 3…)
SELECT
    ROW_NUMBER() OVER (ORDER BY salary DESC) AS row_num,
    RANK()       OVER (ORDER BY salary DESC) AS rank_num,
    DENSE_RANK() OVER (ORDER BY salary DESC) AS dense_rank_num,
    first_name,
    salary
FROM employees
ORDER BY salary DESC;

Duplicate kayıt temizleme veya sayfalama gibi durumlarda mutlaka benzersiz numara istiyorsan ROW_NUMBER() kullan. Sıralama ve puan tablosu senaryolarında ise RANK() veya DENSE_RANK() daha doğru sonuç verir.

MariaDB’ye Özgü Bir Not

MariaDB 10.3 ve üzerinde SEQUENCE nesnesi de kullanılabilir ama bu ROW_NUMBER() ile aynı şey değil. ROW_NUMBER() sorgu bazlı, geçici bir numaralandırmadır. SEQUENCE ise kalıcı, veritabanı seviyesinde bir sayaçtır. Raporlama ve sıralama için ROW_NUMBER(), birincil anahtar gibi kalıcı benzersiz numara üretmek için AUTO_INCREMENT veya SEQUENCE tercih edilmeli.

Sonuç

ROW_NUMBER() pencere fonksiyonu, SQL dünyasında gerçekten çok yönlü bir araç. Sayfalama, grup içi sıralama, duplicate temizleme ve zaman serisi analizi gibi birbirinden farklı problemleri tek bir fonksiyonla çözebilirsin. MariaDB 10.2 ve MySQL 8.0 ile gelen bu destek, eski kullanıcı değişkeni yöntemlerine kıyasla hem daha okunabilir hem de daha güvenilir kod yazmanı sağlıyor.

Pratik önerim şu: Bir sonraki sorgunda LIMIT offset, count kullanmadan önce dur ve ROW_NUMBER() ile çözüp çözemeyeceğini düşün. Özellikle karmaşık filtreleme ve gruplama içeren sayfalama senaryolarında büyük fark görürsün. CTE ile kombine ettiğinde ise kod bakımı ve takım içi okunaklılık açısından çok daha iyi bir noktaya gelirsin.

Son olarak: Hangi yöntemi kullanırsan kullan, sorguyu production’a almadan önce EXPLAIN ile execution plan’ı incele ve test verileriyle performansını ölç. Veritabanı sunucusu ne kadar güçlü olursa olsun, optimize edilmemiş bir sorgu her şeyi yavaşlatabilir.

Bir yanıt yazın

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