MariaDB ve MySQL’de Her Gruptan En Son Kaydı Getirme

Veritabanı yönetiminde en sık karşılaşılan ve aynı zamanda en çok kafa karıştıran problemlerden biri şudur: Bir tabloda gruplanmış veriler var ve her gruptan sadece en son kaydı çekmek istiyorsunuz. Kulağa basit geliyor, değil mi? Ama uygulamaya geçince işler biraz karmaşıklaşıyor. Bu yazıda MariaDB ve MySQL üzerinde bu problemi çözmenin birden fazla yolunu gerçek dünya senaryolarıyla ele alacağız.

Problem Nedir?

Düşünün: Bir e-ticaret sisteminde her müşterinin sipariş geçmişi var. Her müşterinin en son siparişini getirmek istiyorsunuz. Ya da bir IoT sisteminde yüzlerce sensörden sürekli veri akıyor ve her sensörün en güncel ölçümünü görmek istiyorsunuz. Ya da kullanıcıların giriş logları tutuluyor ve her kullanıcının son giriş zamanını raporlamak gerekiyor.

Tüm bu senaryolarda yapmanız gereken şey aynı: Bir sütuna göre grupla, her gruptan en son (veya en yüksek ID’li, en son tarihli) kaydı getir.

Bu işlemi yanlış yaparsanız yanlış veri döner, performans felakete gider veya her ikisi birden yaşanır. O yüzden bu konuyu doğru anlamak önemli.

Test Ortamını Hazırlayalım

Önce üzerinde çalışacağımız örnek tabloları oluşturalım. Gerçekçi senaryolar için birkaç farklı tablo kullanalım.

-- Müşteri siparişleri tablosu
CREATE TABLE orders (
    id INT AUTO_INCREMENT PRIMARY KEY,
    customer_id INT NOT NULL,
    order_date DATETIME NOT NULL,
    total_amount DECIMAL(10,2),
    status VARCHAR(50),
    INDEX idx_customer_id (customer_id),
    INDEX idx_order_date (order_date)
);

-- Sensör verileri tablosu
CREATE TABLE sensor_readings (
    id INT AUTO_INCREMENT PRIMARY KEY,
    sensor_id VARCHAR(50) NOT NULL,
    recorded_at DATETIME NOT NULL,
    temperature DECIMAL(5,2),
    humidity DECIMAL(5,2),
    INDEX idx_sensor_id (sensor_id),
    INDEX idx_recorded_at (recorded_at)
);

-- Kullanıcı giriş logları
CREATE TABLE login_logs (
    id INT AUTO_INCREMENT PRIMARY KEY,
    user_id INT NOT NULL,
    login_time DATETIME NOT NULL,
    ip_address VARCHAR(45),
    success TINYINT(1) DEFAULT 1,
    INDEX idx_user_id (user_id)
);

Şimdi test verilerini ekleyelim:

-- Örnek sipariş verileri
INSERT INTO orders (customer_id, order_date, total_amount, status) VALUES
(1, '2024-01-15 10:30:00', 250.00, 'completed'),
(1, '2024-02-20 14:15:00', 180.50, 'completed'),
(1, '2024-03-10 09:45:00', 420.75, 'pending'),
(2, '2024-01-08 11:00:00', 95.00, 'completed'),
(2, '2024-03-22 16:30:00', 310.25, 'completed'),
(3, '2024-02-14 13:20:00', 75.50, 'cancelled'),
(3, '2024-03-18 10:10:00', 540.00, 'pending'),
(4, '2024-03-25 15:45:00', 125.00, 'completed');

-- Örnek sensör verileri
INSERT INTO sensor_readings (sensor_id, recorded_at, temperature, humidity) VALUES
('SENSOR_01', '2024-03-28 08:00:00', 22.5, 65.0),
('SENSOR_01', '2024-03-28 09:00:00', 23.1, 64.5),
('SENSOR_01', '2024-03-28 10:00:00', 24.0, 63.8),
('SENSOR_02', '2024-03-28 08:00:00', 19.8, 70.2),
('SENSOR_02', '2024-03-28 09:00:00', 20.3, 69.5),
('SENSOR_03', '2024-03-28 10:30:00', 26.7, 58.1);

Yöntem 1: NOT IN ile Alt Sorgu

İlk yöntem klasik ve anlaşılması kolay bir yaklaşım. Mantığı şu: “Kendisinden daha yeni bir kaydı olan siparişleri dışla.”

-- Her müşterinin en son siparişini getir (NOT IN yöntemi)
SELECT o.*
FROM orders o
WHERE o.id NOT IN (
    SELECT o2.id
    FROM orders o2
    INNER JOIN orders o3
        ON o2.customer_id = o3.customer_id
        AND o2.order_date < o3.order_date
);

Bu sorgu çalışır ama büyük tablolarda performansı kötü olabilir. NOT IN ile alt sorgular MySQL/MariaDB optimizer’ı için çoğu zaman verimli bir execution plan üretemez. Küçük tablolar için kabul edilebilir, ama milyonlarca kayıt içeren tablolarda bu yaklaşımdan kaçının.

Yöntem 2: LEFT JOIN ile Self Join

Bu yöntem çok daha performanslı ve yaygın kullanılan bir yaklaşımdır. Fikir şu: Her kayıt için aynı grupta daha yeni bir kayıt var mı diye join yapıyoruz. Eğer yoksa o kayıt en son kayıttır.

-- Her müşterinin en son siparişini getir (LEFT JOIN yöntemi)
SELECT o1.*
FROM orders o1
LEFT JOIN orders o2
    ON o1.customer_id = o2.customer_id
    AND o1.order_date < o2.order_date
WHERE o2.id IS NULL;

Bu sorgunun güzelliği şu: LEFT JOIN ile aynı müşteriye ait ve daha geç tarihli bir sipariş arıyoruz. Eğer böyle bir kayıt bulunamazsa (o2.id IS NULL), demek ki o1 zaten en son kayıttır.

Önemli not: Bu yöntem order_date sütununda iki kaydın tam olarak aynı zamana sahip olduğu durumlarda her iki kaydı da döndürür. Bunu önlemek için aşağıdaki gibi id sütununu da kritere ekleyebilirsiniz:

-- Aynı tarihli kayıtlar için ID'yi de kriter olarak ekle
SELECT o1.*
FROM orders o1
LEFT JOIN orders o2
    ON o1.customer_id = o2.customer_id
    AND (
        o1.order_date < o2.order_date
        OR (o1.order_date = o2.order_date AND o1.id < o2.id)
    )
WHERE o2.id IS NULL;

Yöntem 3: MAX ile GROUP BY (En Yaygın Kullanılan)

Bu yöntem belki de en çok kullanılan yaklaşımdır. Önce her gruptan maksimum değeri (tarih veya ID) buluyoruz, sonra bu değerlere göre ana tabloyla join yapıyoruz.

-- Adım 1: Her müşteri için maksimum sipariş tarihini bul
-- Adım 2: Bu tarihlerle ana tabloyu join'le

SELECT o.*
FROM orders o
INNER JOIN (
    SELECT customer_id, MAX(order_date) AS max_date
    FROM orders
    GROUP BY customer_id
) latest ON o.customer_id = latest.customer_id
         AND o.order_date = latest.max_date;

Bu yöntem genellikle iyi performans gösterir çünkü optimizer GROUP BY sorgusunu etkili şekilde çalıştırabilir. Ancak yine aynı tarih sorunu burada da geçerli. Eğer birden fazla sipariş aynı dakikada oluşturulmuşsa, her ikisi de sonuçta görünür.

Daha sağlam bir versiyon için ID üzerinden gidelim:

-- ID tabanlı yaklaşım (daha güvenilir)
SELECT o.*
FROM orders o
INNER JOIN (
    SELECT customer_id, MAX(id) AS max_id
    FROM orders
    GROUP BY customer_id
) latest ON o.id = latest.max_id;

Auto increment ID kullandığınızda, en yüksek ID her zaman en son eklenen kayıttır. Bu yaklaşım çok daha temiz ve hızlı çalışır.

Yöntem 4: Window Functions (MariaDB 10.2+ ve MySQL 8.0+)

Modern MariaDB ve MySQL sürümleri window function desteği sunuyor. Bu özellik bu tür sorguları hem daha okunabilir hem de daha performanslı hale getiriyor.

-- ROW_NUMBER() ile her gruptan en son kaydı getir
SELECT *
FROM (
    SELECT *,
           ROW_NUMBER() OVER (
               PARTITION BY customer_id
               ORDER BY order_date DESC, id DESC
           ) AS rn
    FROM orders
) ranked
WHERE rn = 1;

PARTITION BY ile gruplamayı, ORDER BY ile sıralamayı belirtiyoruz. ROW_NUMBER() her partition içinde sıra numarası atıyor ve biz sadece 1 numaralı olanları, yani her gruptan en üstteki kaydı alıyoruz.

Bu yaklaşımın büyük avantajı: Hem okunabilirliği çok yüksek hem de MySQL/MariaDB optimizer’ı bu sorguları genellikle çok iyi optimize ediyor.

Versiyon kontrolü yapmayı unutmayın:

-- MariaDB versiyonunuzu kontrol edin
SELECT VERSION();

-- Window function desteği için MariaDB 10.2.2+, MySQL 8.0+ gerekli
-- 10.2.2 öncesi MariaDB'de bu yöntem çalışmaz

Gerçek Dünya Senaryosu 1: Sensör Dashboard’u

IoT sistemlerinde sensör tablosu saatler içinde milyonlarca kayda ulaşabilir. Her sensörün anlık (en son) değerini göstermek için şu sorguyu kullanabilirsiniz:

-- Her sensörün en güncel okumasını getir
-- Büyük tablolar için önerilen yöntem

SELECT sr.sensor_id,
       sr.recorded_at,
       sr.temperature,
       sr.humidity
FROM sensor_readings sr
INNER JOIN (
    SELECT sensor_id, MAX(id) AS max_id
    FROM sensor_readings
    GROUP BY sensor_id
) latest ON sr.id = latest.max_id
ORDER BY sr.sensor_id;

Bu sorgunun hızlı çalışması için sensor_id ve id sütunlarında index olması şart. Zaten tablomuzda idx_sensor_id indexi tanımladık. Execution plan’ı kontrol etmek için:

-- Sorgu planını incele
EXPLAIN SELECT sr.sensor_id,
               sr.recorded_at,
               sr.temperature,
               sr.humidity
FROM sensor_readings sr
INNER JOIN (
    SELECT sensor_id, MAX(id) AS max_id
    FROM sensor_readings
    GROUP BY sensor_id
) latest ON sr.id = latest.max_id;

-- EXPLAIN çıktısında şunlara dikkat edin:
-- type: ref veya eq_ref olmalı, ALL olmamalı
-- key: index kullanıldığını gösterir
-- rows: ne kadar satır tarandığını gösterir

Gerçek Dünya Senaryosu 2: Kullanıcı Son Giriş Raporu

Güvenlik denetimleri için kullanıcıların son giriş bilgilerini raporlamanız gerekebilir. Üstelik bu bilgiyi kullanıcı tablosuyla join’leyerek anlamlı hale getirmek isteyebilirsiniz:

-- Önce users tablosu oluşturalım (varsayımsal)
-- CREATE TABLE users (id INT PRIMARY KEY, username VARCHAR(100), email VARCHAR(200));

-- Her kullanıcının son başarılı girişini getir
SELECT
    u.id AS user_id,
    u.username,
    u.email,
    ll.login_time AS last_login,
    ll.ip_address AS last_ip
FROM users u
LEFT JOIN login_logs ll ON ll.id = (
    SELECT id
    FROM login_logs
    WHERE user_id = u.id
      AND success = 1
    ORDER BY login_time DESC
    LIMIT 1
)
ORDER BY ll.login_time DESC;

Bu sorguda correlated subquery kullandık. Her kullanıcı satırı için ayrı bir alt sorgu çalışıyor. Küçük kullanıcı tablolarında (birkaç bin kayıt) makul performans gösterir ama büyük tablolarda yavaşlar. Büyük tablolar için window function yaklaşımı daha iyi:

-- Büyük kullanıcı tabloları için window function ile
SELECT user_id, login_time, ip_address
FROM (
    SELECT
        user_id,
        login_time,
        ip_address,
        ROW_NUMBER() OVER (
            PARTITION BY user_id
            ORDER BY login_time DESC
        ) AS rn
    FROM login_logs
    WHERE success = 1
) ranked
WHERE rn = 1;

Performans İpuçları

Bu tür sorgularda performans kritik önem taşır. Birkaç pratik ipucu:

  • Composite index kullanın: (customer_id, order_date) veya (customer_id, id) gibi composite indexler bu sorguları dramatik şekilde hızlandırır.
-- Composite index oluşturma
ALTER TABLE orders ADD INDEX idx_customer_date (customer_id, order_date);
ALTER TABLE orders ADD INDEX idx_customer_id_cover (customer_id, id, order_date, total_amount, status);

-- Covering index: Sorgunun ihtiyaç duyduğu tüm sütunları index'e dahil et
-- Bu durumda MySQL tablo verilerine hiç gitmeden sadece index'ten okur
  • Partition by date düşünün: Çok büyük tablolarda (100M+ kayıt) tablo partitioning ciddi performans kazanımı sağlar.
  • EXPLAIN ANALYZE kullanın (MariaDB 10.9+): Normal EXPLAIN tahmini değerler verir, EXPLAIN ANALYZE gerçek değerleri gösterir.
-- Gerçek execution bilgilerini görmek için
EXPLAIN ANALYZE
SELECT o.*
FROM orders o
INNER JOIN (
    SELECT customer_id, MAX(id) AS max_id
    FROM orders
    GROUP BY customer_id
) latest ON o.id = latest.max_id;
  • Büyük sonuç setlerinde materialized view düşünün: Eğer bu sorguyu sürekli çalıştırıyorsanız ve tablo sık güncellenmiyorsa, sonuçları ayrı bir özet tabloya yazıp bunu kullanmak çok daha hızlı olabilir.

Hangi Yöntemi Kullanmalısınız?

Yöntem seçimini birkaç faktör belirler:

  • MariaDB 10.2+/MySQL 8.0+ kullanıyorsanız: Window function yöntemini tercih edin. Hem okunabilir hem de genellikle en performanslı.
  • Eski sürüm kullanıyorsanız: MAX(id) ile INNER JOIN yöntemi en güvenli ve performanslı seçenek.
  • Aynı zamanda birden fazla kayıt olabiliyorsa: ID tabanlı yaklaşımı kullanın, tarih tabanlı yaklaşım belirsiz sonuçlar döndürebilir.
  • Çok büyük tablolarda: Composite index + MAX(id)/INNER JOIN kombinasyonu veya window function. Her iki durumda da EXPLAIN çıktısını kontrol edin.
  • NOT IN yaklaşımından kaçının: Büyük tablolarda ciddi performans sorunlarına yol açar.

Sık Yapılan Hatalar

Bu konuda karşılaştığım yaygın hataları paylaşayım:

  • Sadece GROUP BY kullanmak: SELECT customer_id, order_date, total_amount FROM orders GROUP BY customer_id yazarsanız, order_date ve total_amount değerleri belirsiz olur (MySQL’in ONLY_FULL_GROUP_BY modu kapalıysa rastgele bir kayıt döner, açıksa hata verir).
  • Index olmadan büyük tablolarda bu sorguları çalıştırmak: Full table scan kaçınılmaz olur.
  • Tarih sütununda duplicate değerleri göz ardı etmek: İki sipariş aynı anda oluşturulabilir, bu durumu her zaman handle edin.
-- Bu YANLIŞ: Her müşteri için keyfi bir kayıt döner
SELECT customer_id, MAX(order_date), total_amount
FROM orders
GROUP BY customer_id;

-- Bu DOĞRU: MAX sonrasında tekrar join yap
SELECT o.*
FROM orders o
INNER JOIN (
    SELECT customer_id, MAX(id) AS max_id
    FROM orders
    GROUP BY customer_id
) latest ON o.id = latest.max_id;

Sonuç

Her gruptan en son kaydı getirmek, görünüşte basit ama ayrıntılarda önem taşıyan bir SQL problemidir. Doğru yöntemi seçmek hem doğru veri döndürmenizi hem de sisteminizin performanslı çalışmasını sağlar.

Özetlemek gerekirse: Modern MariaDB/MySQL sürümleri için window function yöntemi hem en okunabilir hem de en bakımı kolay çözümdür. Eski sürümler için MAX(id) ile INNER JOIN yöntemi en güvenilir ve performanslı alternatiftir. Her iki durumda da uygun index’leri oluşturmayı ve sorgu planınızı EXPLAIN ile kontrol etmeyi ihmal etmeyin.

Production sistemlerde bu tür sorguları deploy etmeden önce mutlaka gerçek veri hacimleriyle test edin. Geliştirme ortamındaki 1000 kayıt ile gayet iyi çalışan bir sorgu, production’daki 50 milyon kayıtta tamamen farklı davranabilir.

Bir yanıt yazın

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