MySQL ve MariaDB’de FIND_IN_SET ile Virgüllü Listelerde Arama
Veritabanı tasarımında bazen köşelere sıkışırız. Normalizasyonun her zaman mümkün olmadığı ya da pratik olmadığı durumlar vardır ve bu gibi anlarda virgülle ayrılmış değerleri tek bir sütunda saklama yoluna gideriz. İşte tam bu noktada MySQL ve MariaDB’nin sunduğu FIND_IN_SET fonksiyonu hayat kurtarıcı olur. Bu yazıda bu fonksiyonu her açıdan ele alacağız, gerçek dünya senaryolarıyla pekiştireceğiz ve performans konusunda da dürüst bir değerlendirme yapacağız.
FIND_IN_SET Nedir ve Ne İşe Yarar?
FIND_IN_SET(str, strlist) fonksiyonu, bir string’in virgülle ayrılmış bir liste içinde olup olmadığını kontrol eder. Eğer değer listede bulunursa, değerin listedeki pozisyonunu (1’den başlayarak) döndürür. Bulunamazsa 0 döndürür.
Söz dizimi son derece basit:
FIND_IN_SET(aranacak_deger, virgüllü_liste)
Basit bir örnek verelim:
SELECT FIND_IN_SET('kivi', 'elma,armut,kivi,muz');
-- Sonuç: 3
SELECT FIND_IN_SET('üzüm', 'elma,armut,kivi,muz');
-- Sonuç: 0
Burada dikkat edilmesi gereken birkaç nokta var. Virgülden sonra boşluk bırakırsanız, o boşluk değerin parçası sayılır. Yani 'elma, armut' listesinde 'armut' aramak 0 döndürür, çünkü listede aslında ' armut' var (başında boşluk). Bunu aklınızın bir köşesinde tutun, ileride canınızı yakabilir.
Gerçek Dünya Senaryosu 1: Ürün Etiketleri Sistemi
Diyelim ki bir e-ticaret platformu yönetiyorsunuz. Ürün tablosunda her ürünün birden fazla etiketi var ve bu etiketler virgülle ayrılmış şekilde tek sütunda tutuluyor. Bunun iyi bir tasarım olmadığını biliyoruz ama miras kod böyle gelmiş ve şimdi siz bunu yönetmek zorunda kalıyorsunuz.
CREATE TABLE urunler (
id INT AUTO_INCREMENT PRIMARY KEY,
urun_adi VARCHAR(255) NOT NULL,
fiyat DECIMAL(10,2),
etiketler VARCHAR(500)
);
INSERT INTO urunler (urun_adi, fiyat, etiketler) VALUES
('Laptop', 15000.00, 'elektronik,bilgisayar,tasinabilir,ofis'),
('Klavye', 800.00, 'elektronik,bilgisayar,aksesuar,ofis'),
('Koşu Ayakkabısı', 1200.00, 'spor,ayakkabi,outdoor'),
('Yoga Matı', 350.00, 'spor,fitness,indoor'),
('Kahve Makinesi', 2500.00, 'mutfak,elektronik,ev');
Şimdi “elektronik” etiketine sahip tüm ürünleri çekmek istiyoruz:
SELECT id, urun_adi, fiyat, etiketler
FROM urunler
WHERE FIND_IN_SET('elektronik', etiketler) > 0;
-- Sonuç:
-- 1, Laptop, 15000.00, elektronik,bilgisayar,tasinabilir,ofis
-- 2, Klavye, 800.00, elektronik,bilgisayar,aksesuar,ofis
-- 5, Kahve Makinesi, 2500.00, mutfak,elektronik,ev
> 0 kontrolü yapıyoruz çünkü fonksiyon bulunan değerin pozisyonunu döndürüyor. Pozisyon her zaman 0’dan büyük olur, dolayısıyla bu kontrol yeterli. Dilerseniz sadece FIND_IN_SET('elektronik', etiketler) yazarak da WHERE koşulu olarak kullanabilirsiniz, MySQL sıfır olmayan değerleri true kabul eder.
Gerçek Dünya Senaryosu 2: Kullanıcı Rolleri ve Yetkilendirme
Bir uygulama veritabanında kullanıcı rolleri virgüllü liste olarak saklanıyor. Bu sefer birden fazla rol için arama yapmamız gerekiyor.
CREATE TABLE kullanicilar (
id INT AUTO_INCREMENT PRIMARY KEY,
kullanici_adi VARCHAR(100) NOT NULL,
email VARCHAR(200),
roller VARCHAR(300)
);
INSERT INTO kullanicilar (kullanici_adi, email, roller) VALUES
('ahmet', '[email protected]', 'admin,editor,viewer'),
('mehmet', '[email protected]', 'editor,viewer'),
('ayse', '[email protected]', 'admin,viewer'),
('fatma', '[email protected]', 'viewer'),
('ali', '[email protected]', 'editor,moderator');
Admin yetkisine sahip kullanıcıları bulalım:
SELECT kullanici_adi, email, roller
FROM kullanicilar
WHERE FIND_IN_SET('admin', roller) > 0;
-- Sonuç: ahmet ve ayse
Peki ya hem editor hem de admin yetkisine sahip kullanıcıları bulmak istersek?
SELECT kullanici_adi, email, roller
FROM kullanicilar
WHERE FIND_IN_SET('admin', roller) > 0
AND FIND_IN_SET('editor', roller) > 0;
-- Sonuç: sadece ahmet (hem admin hem editor)
Ya da en az birini taşıyan kullanıcılar için OR kullanabiliriz:
SELECT kullanici_adi, email, roller
FROM kullanicilar
WHERE FIND_IN_SET('admin', roller) > 0
OR FIND_IN_SET('moderator', roller) > 0;
-- Sonuç: ahmet, ayse, ali
FIND_IN_SET ile LIKE Arasındaki Fark
Sıkça sorulan bir soru: “Bunu LIKE ile de yapamaz mıyız?” Teknik olarak yapabilirsiniz, ama büyük sorunlarla karşılaşırsınız.
-- YANLIS yöntem - LIKE kullanmak
SELECT * FROM urunler WHERE etiketler LIKE '%ofis%';
Bu sorgu “ofis” geçen tüm kayıtları getirir ama “ofis-malzemeleri” ya da “evaofis” gibi değerleri de getirebilir (bu örnekte olmasa da). Ayrıca “ofis” tek başına bir etiket mi, yoksa başka bir kelimenin parçası mı, onu ayırt edemez.
-- DOGRU yöntem - FIND_IN_SET kullanmak
SELECT * FROM urunler WHERE FIND_IN_SET('ofis', etiketler) > 0;
FIND_IN_SET tam eşleşme yapar. Virgüllü listenin her bir öğesiyle karşılaştırma yapar, kısmi eşleşme kabul etmez. Bu açıdan çok daha güvenilirdir.
FIND_IN_SET ile ORDER BY Kullanımı
Fonksiyonun döndürdüğü pozisyon değerini sıralama için de kullanabilirsiniz. Bu bazen işe yarar bir numara olabiliyor.
-- Belirli etiketlerin listede kaçıncı sırada olduğuna göre sırala
SELECT urun_adi, etiketler,
FIND_IN_SET('elektronik', etiketler) AS elektronik_pozisyon
FROM urunler
WHERE FIND_IN_SET('elektronik', etiketler) > 0
ORDER BY FIND_IN_SET('elektronik', etiketler);
Daha pratik bir örnek: Önce belirli etikete sahip olanları, sonra diğerlerini listele.
SELECT urun_adi, etiketler
FROM urunler
ORDER BY FIND_IN_SET('spor', etiketler) = 0, urun_adi;
Bu sorgu önce “spor” etiketine sahip ürünleri, ardından diğerlerini alfabetik sırayla listeler. = 0 ifadesi spor olmayanlar için 1 (true), spor olanlar için 0 (false) döndürür, bu da sıralamayı spor olanlar önce gelecek şekilde yapar.
Gelişmiş Kullanım: Dinamik Değerlerle Arama
Gerçek hayatta genellikle sabit değerler değil, başka bir tablodan ya da değişkenden gelen değerlerle çalışırsınız. İşte bu noktada FIND_IN_SET daha ilginç bir hal alır.
-- Başka bir tablodan gelen değerle arama
CREATE TABLE arama_kriterleri (
id INT PRIMARY KEY,
aranan_etiket VARCHAR(100)
);
INSERT INTO arama_kriterleri VALUES (1, 'spor');
SELECT u.urun_adi, u.etiketler
FROM urunler u
JOIN arama_kriterleri a ON FIND_IN_SET(a.aranan_etiket, u.etiketler) > 0
WHERE a.id = 1;
Bir diğer yaygın senaryo: Listedeki her öğeyi başka bir tabloyla eşleştirmek. Yani tam tersi, virgüllü listedeki değerleri bir referans tabloya göre genişletmek.
CREATE TABLE etiket_referans (
etiket_kodu VARCHAR(50) PRIMARY KEY,
etiket_adi VARCHAR(100),
ana_kategori VARCHAR(100)
);
INSERT INTO etiket_referans VALUES
('elektronik', 'Elektronik', 'Teknoloji'),
('spor', 'Spor', 'Sağlık ve Spor'),
('mutfak', 'Mutfak', 'Ev ve Yaşam'),
('ofis', 'Ofis', 'İş Dünyası');
-- Ürünlerin etiket kategorilerini de getir
SELECT u.urun_adi, u.etiketler, e.ana_kategori
FROM urunler u
JOIN etiket_referans e ON FIND_IN_SET(e.etiket_kodu, u.etiketler) > 0
WHERE e.etiket_kodu = 'elektronik';
Performans Meselesi: Dürüst Bir Değerlendirme
Bu noktada size dürüst olmam gerekiyor. FIND_IN_SET indeks kullanamaz. Her sorgu için tam tablo taraması (full table scan) yapar. Yani tablonuzda 10 kayıt varken harika çalışır, 10 milyon kayıt olduğunda yavaşlar.
Bunu EXPLAIN ile kendiniz de görebilirsiniz:
EXPLAIN SELECT * FROM urunler WHERE FIND_IN_SET('elektronik', etiketler) > 0;
-- type: ALL (tam tablo taraması)
-- key: NULL (indeks kullanılmıyor)
-- rows: Tablodaki tüm satır sayısı
Bu durumla başa çıkmak için birkaç yaklaşım var:
Küçük tablolar için: Sorun yok, devam edin. 10.000 satıra kadar genellikle kabul edilebilir performans alırsınız.
Orta büyüklükteki tablolar için: Önce daha seçici filtreleri uygulayın, FIND_IN_SET’i en sona bırakın.
-- Önce fiyat filtresi (indeks kullanabilir), sonra FIND_IN_SET
SELECT * FROM urunler
WHERE fiyat < 2000
AND FIND_IN_SET('elektronik', etiketler) > 0;
Büyük tablolar için: Tasarımı yeniden düşünmeniz gerekiyor. MySQL 5.7+ ile JSON sütunları ve JSON_CONTAINS kullanabilir ya da ayrı bir etiket tablosu oluşturabilirsiniz. MariaDB’de de benzer seçenekler mevcut.
Pratik Senaryo: Blog Sistemi Kategori Yönetimi
Gerçek bir blog sisteminde karşılaşabileceğiniz duruma bakalım. Yazıların birden fazla kategorisi var ve bunlar virgüllü liste olarak saklanıyor.
CREATE TABLE blog_yazilari (
id INT AUTO_INCREMENT PRIMARY KEY,
baslik VARCHAR(300),
icerik TEXT,
kategoriler VARCHAR(500),
yazar_id INT,
yayin_tarihi DATE,
durum ENUM('taslak', 'yayinda', 'arsiv')
);
INSERT INTO blog_yazilari (baslik, kategoriler, yazar_id, yayin_tarihi, durum) VALUES
('Linux Kernel Optimizasyonu', 'linux,performans,sistem-yonetimi', 1, '2024-01-15', 'yayinda'),
('MySQL Yedekleme Stratejileri', 'veritabani,mysql,yedekleme,sistem-yonetimi', 2, '2024-01-20', 'yayinda'),
('Docker ile Konteyner Yönetimi', 'docker,konteyner,devops', 1, '2024-02-01', 'yayinda'),
('Nginx Yapılandırması', 'webserver,nginx,linux,performans', 3, '2024-02-10', 'yayinda'),
('PostgreSQL vs MySQL', 'veritabani,mysql,postgresql,karsilastirma', 2, '2024-02-15', 'taslak');
Şimdi birkaç farklı sorgu yazalım:
-- Yayında olan ve sistem-yonetimi kategorisindeki yazılar
SELECT baslik, kategoriler, yayin_tarihi
FROM blog_yazilari
WHERE durum = 'yayinda'
AND FIND_IN_SET('sistem-yonetimi', kategoriler) > 0
ORDER BY yayin_tarihi DESC;
-- Birden fazla kategoride eşleşen yazılar (linux VE performans)
SELECT baslik, kategoriler
FROM blog_yazilari
WHERE FIND_IN_SET('linux', kategoriler) > 0
AND FIND_IN_SET('performans', kategoriler) > 0;
-- Kategori bazında yazı sayısı (bu biraz daha karmaşık)
-- Önce hangi kategorilerde kaç yazı var görmek için
SELECT
kategoriler,
CASE WHEN FIND_IN_SET('linux', kategoriler) > 0 THEN 'Linux Yazısı' ELSE 'Diğer' END AS kategori_grubu
FROM blog_yazilari
WHERE durum = 'yayinda';
NULL Değerler ve Edge Case’ler
Dikkat etmeniz gereken birkaç özel durum var:
-- NULL değerler her zaman NULL döndürür
SELECT FIND_IN_SET('test', NULL);
-- Sonuç: NULL
-- Boş string 0 döndürür
SELECT FIND_IN_SET('test', '');
-- Sonuç: 0
-- Aranan değer boşsa 0 döndürür
SELECT FIND_IN_SET('', 'elma,armut,kivi');
-- Sonuç: 0
-- NULL değerleri olan sütunlarla güvenli çalışmak için
SELECT * FROM urunler
WHERE FIND_IN_SET('elektronik', IFNULL(etiketler, '')) > 0;
Bu edge case’leri önceden bilmek, production ortamında sürprizlerle karşılaşmamanızı sağlar. Özellikle sütunun NULL olabileceği durumlarda IFNULL ya da COALESCE kullanmayı alışkanlık haline getirin.
FIND_IN_SET ile COUNT ve GROUP BY
Raporlama sorgularında da sıkça kullanılan bu kombinasyona bakalım:
-- Her kategorideki ürün sayısını bulmak (el ile yapılmış yöntem)
SELECT
'elektronik' AS kategori,
COUNT(*) AS urun_sayisi
FROM urunler
WHERE FIND_IN_SET('elektronik', etiketler) > 0
UNION ALL
SELECT
'spor' AS kategori,
COUNT(*) AS urun_sayisi
FROM urunler
WHERE FIND_IN_SET('spor', etiketler) > 0
UNION ALL
SELECT
'mutfak' AS kategori,
COUNT(*) AS urun_sayisi
FROM urunler
WHERE FIND_IN_SET('mutfak', etiketler) > 0;
Bu sorgu biraz tekrarlı ama küçük ve sabit kategori listeleri için işe yarar. Dinamik kategoriler için uygulama katmanında döngü ya da stored procedure kullanmak daha mantıklı olur.
Stored Procedure ile Dinamik Kullanım
Birden fazla değeri dinamik olarak aramak için stored procedure yazabilirsiniz:
DELIMITER //
CREATE PROCEDURE etiketle_ara(IN aranan_etiket VARCHAR(100))
BEGIN
SELECT
id,
urun_adi,
fiyat,
etiketler,
FIND_IN_SET(aranan_etiket, etiketler) AS liste_pozisyonu
FROM urunler
WHERE FIND_IN_SET(aranan_etiket, etiketler) > 0
ORDER BY fiyat ASC;
END //
DELIMITER ;
-- Kullanımı
CALL etiketle_ara('elektronik');
CALL etiketle_ara('spor');
Bu yaklaşım özellikle uygulama kodundan sık çağrılan sorgular için temiz bir arayüz sağlar.
MariaDB Özel Notları
MariaDB’de FIND_IN_SET MySQL ile birebir aynı çalışır, sözdizimi ve davranış açısından farklılık yoktur. Ancak MariaDB 10.2+ ile birlikte gelen sanal sütunlar (virtual columns) ve tam metin indeksleri (full-text indexes) sayesinde virgüllü liste aramasını biraz daha verimli hale getirebilirsiniz.
-- MariaDB'de sanal sütun ile indeksleme hilesi
-- (Belirli ve sabit bir etiket için)
ALTER TABLE urunler
ADD COLUMN is_elektronik TINYINT(1)
GENERATED ALWAYS AS (FIND_IN_SET('elektronik', etiketler) > 0) STORED,
ADD INDEX idx_elektronik (is_elektronik);
-- Artık bu sorgu indeks kullanır
SELECT * FROM urunler WHERE is_elektronik = 1;
Bu yöntem sadece çok sık sorgulanan ve sabit etiketler için mantıklıdır. Her etiket için sanal sütun oluşturmak pratik değil.
Ne Zaman FIND_IN_SET Kullanmayın?
Dürüstçe konuşmak gerekirse, FIND_IN_SET bir geçici çözümdür. Aşağıdaki durumlarda kesinlikle başka bir yol izleyin:
- Tablonuzda milyonlarca kayıt varsa ve bu sütunu sık sorguluyorsanız, ilişkisel bir tasarıma geçin.
- Bu sütuna göre sıkça JOIN yapıyorsanız, ayrı bir ilişki tablosu çok daha verimli olacaktır.
- Listedeki elemanları ayrı ayrı işlemeniz gerekiyorsa, yani her etiketi ayrı bir kayıt olarak ele almanız şartsa, normalize bir yapı zorunludur.
- Veri bütünlüğü kritikse, virgüllü liste sütununa foreign key ekleyemezsiniz.
Buna karşın şu durumlarda FIND_IN_SET makul bir seçimdir:
- Miras (legacy) kod ve değiştirme imkanı yoktur.
- Tablo küçüktür ve büyümesi beklenmiyordur.
- Hızlı prototip geliştirme aşamasındasınızdır.
- Sorgular seyrek yapılıyordur.
Sonuç
FIND_IN_SET MySQL ve MariaDB’de virgüllü listelerle çalışmak zorunda kaldığınızda başvuracağınız en temiz çözümdür. LIKE '%değer%' kullanımının aksine tam eşleşme sağlar, sözdizimi sade ve anlaşılırdır, NULL ve edge case davranışlarını bildiğinizde güvenilir sonuçlar üretir.
Ancak bu fonksiyonu bir araç olarak görün, bir çözüm olarak değil. Gerçek çözüm çoğunlukla veri modelinizi düzgün normalize etmektir. Eğer virgüllü liste kaçınılmazsa, FIND_IN_SET‘i bilinçli bir şekilde, performans sınırlarını bilerek kullanın. Küçük tablolarda ve miras sistemlerde size zaman ve enerji kazandırır. Büyük, kritik sistemlerde ise normalize edilmiş bir yapıya yatırım yapmak uzun vadede her zaman daha iyi sonuç verir.
Bu fonksiyonu gerçek projelerinizde kullandıysanız ya da başka kullanım senaryolarınız varsa yorumlarda paylaşın, birlikte öğrenelim.
