PostgreSQL JSONB ile Doküman Tabanlı Veri Saklama

MongoDB’yi duydunuz, belki kullandınız. “Esnek şema, hızlı geliştirme, JSON her yerde” diye tanıtıldı yıllarca. Ama şunu sormak lazım: Zaten PostgreSQL kullanıyorsanız, neden ayrı bir NoSQL çözümü için ekstra operasyonel yük taşıyasınız ki? PostgreSQL’in JSONB tipi, ilişkisel dünyanın tüm güçlü yanlarını korurken size gerçek anlamda doküman tabanlı bir depolama deneyimi sunuyor. Bugün bunu derinlemesine inceliyoruz.

JSON mu, JSONB mi?

PostgreSQL’de iki farklı JSON tipi var: json ve jsonb. İkisi de JSON verisi saklar ama aralarında çok kritik bir fark var.

json tipi veriyi metin olarak saklar. Her sorgu çalıştığında bu metni parse etmesi gerekir. Anahtarların sırası korunur, tekrar eden anahtarlar saklanır. Doğrulama yapılır ama derinlemesine işlem için her seferinde yeniden ayrıştırma gerekir.

jsonb tipi ise veriyi binary formatta saklar. Bir kez parse edilir, decomposed binary formatında yazılır. Anahtarlar sıralanır, tekrar eden anahtarlarda sonuncusu tutulur. Ve en önemlisi: üzerine index açılabilir.

Sonuç olarak yazma işlemi json‘da biraz daha hızlıdır (parse yok), ama okuma ve sorgulama işlemlerinde jsonb çok daha üstündür. Gerçek dünyada neredeyse her zaman jsonb kullanmalısınız.

Temel Kurulum ve İlk Tablo

Bir e-ticaret senaryosu düşünelim. Farklı kategorilerdeki ürünlerin farklı özellikleri var: televizyonun çözünürlüğü, ayakkabının numarası, gıdanın son kullanma tarihi. Bunları ayrı tablolarda tutmak karmaşık join’lere yol açar. JSONB burada devreye girer.

-- Bağlantı ve veritabanı hazırlığı
psql -U postgres

CREATE DATABASE ecommerce;
c ecommerce

-- Ürün tablosu oluşturma
CREATE TABLE products (
    id          SERIAL PRIMARY KEY,
    sku         VARCHAR(50) UNIQUE NOT NULL,
    category    VARCHAR(50) NOT NULL,
    name        VARCHAR(200) NOT NULL,
    price       NUMERIC(10,2) NOT NULL,
    attributes  JSONB,
    metadata    JSONB,
    created_at  TIMESTAMPTZ DEFAULT NOW(),
    updated_at  TIMESTAMPTZ DEFAULT NOW()
);

Dikkat edin: fiyat, SKU, kategori gibi her ürün için ortak olan alanlar normal kolon olarak tutuldu. Sadece kategoriye özgü, değişken yapıdaki veriler JSONB’ye alındı. Bu hibrit yaklaşım çok önemli. Her şeyi JSONB’ye koymak yanlış bir pratik.

Veri Ekleme ve Operatörler

Şimdi farklı kategorilerden ürünler ekleyelim:

-- Elektronik ürün
INSERT INTO products (sku, category, name, price, attributes, metadata)
VALUES (
    'TV-SAMSUNG-65',
    'electronics',
    'Samsung 65" 4K QLED TV',
    24999.99,
    '{
        "screen_size": 65,
        "resolution": "4K",
        "panel_type": "QLED",
        "smart_tv": true,
        "hdmi_ports": 4,
        "refresh_rate": 120,
        "dimensions": {
            "width": 144.4,
            "height": 83.2,
            "depth": 5.7,
            "unit": "cm"
        },
        "connectivity": ["WiFi", "Bluetooth", "ARC", "eARC"]
    }',
    '{"warehouse": "IST-01", "supplier_id": 1042, "tags": ["premium", "featured"]}'
);

-- Ayakkabı ürünü
INSERT INTO products (sku, category, name, price, attributes, metadata)
VALUES (
    'SHOE-NIKE-AIR-42',
    'footwear',
    'Nike Air Max 270',
    2899.00,
    '{
        "size": 42,
        "color": "black/white",
        "material": "mesh",
        "gender": "unisex",
        "sole_type": "rubber",
        "available_sizes": [40, 41, 42, 43, 44, 45]
    }',
    '{"warehouse": "ANK-02", "supplier_id": 2011, "tags": ["bestseller"]}'
);

-- Gıda ürünü
INSERT INTO products (sku, category, name, price, attributes, metadata)
VALUES (
    'FOOD-OLIVE-OIL-500',
    'food',
    'Zeytinyağı Sızma 500ml',
    189.90,
    '{
        "volume_ml": 500,
        "origin": "Ayvalık",
        "acidity": 0.3,
        "organic": true,
        "expiry_months": 24,
        "certifications": ["TSE", "Organic EU", "ISO 22000"]
    }',
    '{"warehouse": "IZM-01", "supplier_id": 3055, "tags": ["organic", "local"]}'
);

Şimdi bu verileri sorgulama operatörlerine bakalım:

-- -> operatörü: JSON objesi döndürür
SELECT name, attributes -> 'dimensions' AS dims
FROM products
WHERE category = 'electronics';

-- ->> operatörü: metin döndürür
SELECT name, attributes ->> 'resolution' AS resolution
FROM products
WHERE category = 'electronics';

-- İç içe geçmiş erişim
SELECT name, 
       attributes -> 'dimensions' ->> 'width' AS width_cm
FROM products
WHERE category = 'electronics';

-- #> operatörü: path ile erişim
SELECT name, attributes #> '{dimensions, width}' AS width
FROM products;

-- @> operatörü: içerme sorgusu (containment)
-- JSONB'nin en güçlü operatörü
SELECT name, price
FROM products
WHERE attributes @> '{"smart_tv": true}';

-- Dizi içinde değer arama
SELECT name, category
FROM products
WHERE attributes @> '{"connectivity": ["Bluetooth"]}';

-- Belirli bir anahtarın varlığını kontrol etme
SELECT name
FROM products
WHERE attributes ? 'organic';

-- Birden fazla anahtar kontrolü
SELECT name
FROM products
WHERE attributes ?& ARRAY['size', 'color', 'material'];

Indexleme Stratejileri

JSONB’nin gerçek gücü index desteğiyle ortaya çıkıyor. Üç farklı index türü var ve hangisini kullanacağınızı bilmek performans açısından kritik.

-- GIN index: @>, ?, ?&, ?| operatörleri için
-- Genel amaçlı, en yaygın kullanılan
CREATE INDEX idx_products_attributes_gin 
ON products USING GIN (attributes);

-- jsonb_path_ops ile daha küçük, daha hızlı GIN index
-- Sadece @> operatörünü destekler ama daha verimli
CREATE INDEX idx_products_attributes_path 
ON products USING GIN (attributes jsonb_path_ops);

-- Belirli bir alan için B-Tree index
-- Sadece o alana sorgu yapılacaksa çok verimli
CREATE INDEX idx_products_price_range 
ON products ((attributes ->> 'screen_size'));

-- Kısmi index: Sadece belirli kategorideki ürünler için
CREATE INDEX idx_electronics_resolution
ON products ((attributes ->> 'resolution'))
WHERE category = 'electronics';

-- Metadata tag aramaları için
CREATE INDEX idx_products_metadata_gin
ON products USING GIN (metadata jsonb_path_ops);

Bir gerçek dünya uyarısı: Çok fazla GIN index yaratmak write performansını düşürür. Hangi sorgular sık çalışıyor, hangi alanlar filter olarak kullanılıyor, bunu EXPLAIN ANALYZE ile test edin ve sadece gerekli index’leri oluşturun.

Güncelleme İşlemleri

JSONB güncellemelerinde dikkat edilmesi gereken bazı incelikler var. Tüm objeyi replace etmek yerine belirli alanları güncellemek için jsonb_set fonksiyonu kullanılır.

-- Tüm attributes'u güncelleme (dikkatli kullanın)
UPDATE products 
SET attributes = attributes || '{"smart_tv": true, "os": "Tizen 7.0"}'
WHERE sku = 'TV-SAMSUNG-65';

-- Belirli bir alanı güncelleme
UPDATE products
SET attributes = jsonb_set(
    attributes,
    '{hdmi_ports}',
    '6'::jsonb
)
WHERE sku = 'TV-SAMSUNG-65';

-- İç içe geçmiş alan güncelleme
UPDATE products
SET attributes = jsonb_set(
    attributes,
    '{dimensions, depth}',
    '6.2'::jsonb
)
WHERE sku = 'TV-SAMSUNG-65';

-- Alan silme
UPDATE products
SET attributes = attributes - 'refresh_rate'
WHERE sku = 'TV-SAMSUNG-65';

-- Dizi içinden eleman silme
UPDATE products
SET attributes = attributes #- '{connectivity, 2}'
WHERE sku = 'TV-SAMSUNG-65';

-- Toplu güncelleme: Tüm gıda ürünlerine alan ekle
UPDATE products
SET attributes = attributes || '{"storage_temp": "cool_dry"}'::jsonb
WHERE category = 'food';

Gerçek Dünya Senaryosu: Kullanıcı Profil Sistemi

Şimdi daha karmaşık bir senaryo: Çok sayıda konfigürasyon seçeneği olan bir kullanıcı profil sistemi. Kullanıcıların bildirim tercihleri, UI ayarları, entegrasyon konfigürasyonları değişkendir.

CREATE TABLE user_profiles (
    user_id     UUID PRIMARY KEY DEFAULT gen_random_uuid(),
    email       VARCHAR(255) UNIQUE NOT NULL,
    username    VARCHAR(50) UNIQUE NOT NULL,
    preferences JSONB DEFAULT '{}'::jsonb,
    permissions JSONB DEFAULT '[]'::jsonb,
    audit_log   JSONB DEFAULT '[]'::jsonb,
    created_at  TIMESTAMPTZ DEFAULT NOW()
);

-- Index'ler
CREATE INDEX idx_users_prefs_gin ON user_profiles USING GIN (preferences);
CREATE INDEX idx_users_perms_gin ON user_profiles USING GIN (permissions);

-- Kullanıcı ekleme
INSERT INTO user_profiles (email, username, preferences, permissions)
VALUES (
    '[email protected]',
    'ahmet.yilmaz',
    '{
        "theme": "dark",
        "language": "tr",
        "timezone": "Europe/Istanbul",
        "notifications": {
            "email": true,
            "sms": false,
            "push": true,
            "digest_frequency": "daily"
        },
        "dashboard": {
            "widgets": ["sales", "inventory", "alerts"],
            "default_date_range": 30
        }
    }',
    '["read:reports", "write:orders", "read:inventory", "admin:users"]'
);

-- Belirli izne sahip kullanıcıları bulma
SELECT username, email
FROM user_profiles
WHERE permissions @> '["admin:users"]'::jsonb;

-- Bildirim tercihi email açık olanlar
SELECT username
FROM user_profiles
WHERE preferences -> 'notifications' ->> 'email' = 'true';

-- Audit log'a yeni kayıt ekleme (dizi'ye append)
UPDATE user_profiles
SET audit_log = audit_log || jsonb_build_array(
    jsonb_build_object(
        'action', 'login',
        'ip', '192.168.1.45',
        'timestamp', NOW()::text,
        'user_agent', 'Mozilla/5.0...'
    )
)
WHERE username = 'ahmet.yilmaz';

jsonb_each, jsonb_array_elements ve Aggregation

JSONB verilerini ilişkisel veri gibi düzleştirmek için jsonb_each ve jsonb_array_elements fonksiyonları çok işe yarıyor.

-- JSONB anahtarlarını satırlara dökmek
SELECT 
    p.name,
    attr.key,
    attr.value
FROM products p,
     jsonb_each(p.attributes) AS attr
WHERE p.category = 'electronics';

-- Dizi elemanlarını satırlara dökmek
SELECT 
    p.name,
    cert.value ->> 0 AS certification
FROM products p,
     jsonb_array_elements(p.attributes -> 'certifications') AS cert
WHERE p.category = 'food';

-- Kategori bazında aggregation
SELECT 
    category,
    COUNT(*) AS product_count,
    AVG((attributes ->> 'screen_size')::numeric) AS avg_screen_size
FROM products
WHERE category = 'electronics'
  AND attributes ? 'screen_size'
GROUP BY category;

-- Tag bazında ürün sayısı (metadata'dan)
SELECT 
    tag,
    COUNT(*) AS product_count
FROM products,
     jsonb_array_elements_text(metadata -> 'tags') AS tag
GROUP BY tag
ORDER BY product_count DESC;

-- JSONB verilerini formatlı çıktıya alma
SELECT 
    sku,
    name,
    jsonb_pretty(attributes) AS formatted_attributes
FROM products
WHERE sku = 'TV-SAMSUNG-65';

JSON Path ile Güçlü Sorgular (PostgreSQL 12+)

PostgreSQL 12 ile gelen jsonpath desteği, çok daha ekspresif sorgular yazmanıza olanak sağlıyor.

-- jsonb_path_query kullanımı
SELECT jsonb_path_query(
    attributes,
    '$.connectivity[*] ? (@ starts with "B")'
)
FROM products
WHERE sku = 'TV-SAMSUNG-65';

-- 4K çözünürlüklü ve 60hz üstü TV'ler
SELECT name, price
FROM products
WHERE jsonb_path_exists(
    attributes,
    '$ ? (@.resolution == "4K" && @.refresh_rate > 60)'
);

-- Fiyat aralığı ile birlikte attribute filtresi
SELECT name, price, attributes ->> 'origin' AS origin
FROM products
WHERE category = 'food'
  AND jsonb_path_exists(attributes, '$ ? (@.organic == true)')
  AND price BETWEEN 100 AND 300;

-- @@ operatörü ile kısa jsonpath sorgusu
SELECT name
FROM products
WHERE attributes @@ '$.smart_tv == true';

Şema Doğrulama ile JSONB Kontrolü

JSONB’nin “şemasız” yapısı bazen tutarsız veriye yol açabilir. PostgreSQL’de CHECK constraint veya trigger ile bunu önleyebilirsiniz.

-- CHECK constraint ile temel doğrulama
ALTER TABLE products
ADD CONSTRAINT chk_electronics_attrs
CHECK (
    category != 'electronics' 
    OR (
        attributes ? 'resolution' 
        AND attributes ? 'screen_size'
        AND (attributes ->> 'screen_size')::numeric > 0
    )
);

-- Doğrulama fonksiyonu
CREATE OR REPLACE FUNCTION validate_product_attributes()
RETURNS TRIGGER AS $$
BEGIN
    IF NEW.category = 'food' AND NOT (NEW.attributes ? 'expiry_months') THEN
        RAISE EXCEPTION 'Gıda ürünleri için expiry_months zorunludur';
    END IF;
    
    IF NEW.category = 'footwear' AND NOT (NEW.attributes ? 'size') THEN
        RAISE EXCEPTION 'Ayakkabı ürünleri için size zorunludur';
    END IF;
    
    RETURN NEW;
END;
$$ LANGUAGE plpgsql;

CREATE TRIGGER trg_validate_products
    BEFORE INSERT OR UPDATE ON products
    FOR EACH ROW EXECUTE FUNCTION validate_product_attributes();

Performans Takibi ve Sorgu Optimizasyonu

-- EXPLAIN ANALYZE ile index kullanımını kontrol edin
EXPLAIN (ANALYZE, BUFFERS, FORMAT TEXT)
SELECT name, price
FROM products
WHERE attributes @> '{"smart_tv": true}';

-- Index kullanımını kontrol et
SELECT 
    schemaname,
    tablename,
    indexname,
    idx_scan,
    idx_tup_read,
    idx_tup_fetch
FROM pg_stat_user_indexes
WHERE tablename = 'products'
ORDER BY idx_scan DESC;

-- Tablo istatistiklerini güncelle (özellikle bulk insert sonrası)
ANALYZE products;

-- JSONB kolonunun boyutunu kontrol et
SELECT 
    pg_size_pretty(pg_total_relation_size('products')) AS total_size,
    pg_size_pretty(SUM(pg_column_size(attributes))) AS attributes_size,
    COUNT(*) AS row_count
FROM products;

-- Sık kullanılan JSONB değerlerini cache'lemek için generated column
ALTER TABLE products
ADD COLUMN is_smart_tv BOOLEAN 
GENERATED ALWAYS AS ((attributes ->> 'smart_tv')::boolean) STORED;

CREATE INDEX idx_smart_tv ON products(is_smart_tv) WHERE is_smart_tv = true;

Generated column yaklaşımı özellikle ilginç: Sık filtrelenen JSONB alanları için bu teknik, hem GIN index overhead’inden kurtarır hem de planner’ın daha akıllı kararlar vermesini sağlar.

Ne Zaman JSONB Kullanmamalısınız?

Dürüst olmak gerekirse, JSONB her derde deva değil. Şu durumlarda klasik ilişkisel yapı tercih edilmeli:

  • Sık join yapılacak alanlar: JSONB içindeki bir alana başka tablodan foreign key referansı veremezsiniz. Eğer bir alanı başka tablolarla ilişkilendirecekseniz, o alan JSONB dışında olmalı.
  • Sık güncellenen alanlar: JSONB satırın tamamını yeniden yazar. Bir alan sürekli güncelleniyorsa, normal kolon çok daha verimli.
  • Aggregate ve reporting sorgular: SUM, AVG, GROUP BY işlemleri JSONB alanlar üzerinde çok daha yavaş çalışır. Raporlama için normalize edilmiş yapı veya materialized view kullanın.
  • Zorunlu şema gerektiren durumlar: Veri tutarlılığı kritikse ve her alanın kesinlikle var olması gerekiyorsa, sütun kısıtlamaları daha güvenilir.

Sonuç

PostgreSQL’in JSONB tipi, “ya ilişkisel ya NoSQL” ikilemi yaşıyorsanız gerçek bir çözüm sunuyor. Doğru kullanıldığında yani sabit alanları normal kolonlarda, değişken yapıdaki verileri JSONB’de tutarak hibrit bir mimari kurduğunuzda hem SQL’in güvenilirliğini hem de doküman tabanlı depolamanın esnekliğini aynı anda elde ediyorsunuz.

GIN indexleme, jsonpath sorguları, constraint’ler ve generated column teknikleri bir arada kullanıldığında, iyi tasarlanmış bir JSONB yapısı production ortamında çok ciddi yüklerle başa çıkabiliyor. Ayrı bir MongoDB veya Elasticsearch kümesi yönetmek yerine PostgreSQL’i tek platform olarak tutmak, operasyonel karmaşıklığı dramatik biçimde azaltıyor.

Başlangıç noktanız şu olsun: Yeni bir proje veya tablo tasarlarken hangi alanların sabit, hangilerinin değişken olduğunu listeleyin. Değişken olanlar için JSONB’ye geçin, sorgu ihtiyaçlarınıza göre index stratejinizi belirleyin ve EXPLAIN ANALYZE ile doğrulayın. Gerisi pratik yapıyla gelecek.

Yorum yapın