SaaS uygulamaları için PostgreSQL indeksleri: filtreler, arama, JSONB ve diziler gibi gerçek sorgu şekillerini kullanarak btree, GIN ve GiST arasında seçim yapın.

Bir indeks, PostgreSQL'in satırları bulma şeklini değiştirir. İndeks yoksa, veritabanı genellikle tablonun büyük bir kısmını okumak (sequential scan) ve sonra çoğunu eler. Doğru indeksle, eşleşen satırlara doğrudan atlayabilir (index lookup) ve sadece ihtiyaç duyduğu verileri alır.
Bunu SaaS'ta erken fark edersiniz çünkü günlük ekranlar sorgu-ağırlıklıdır. Tek bir tıklama birkaç okuma tetikleyebilir: liste sayfası, toplam sayım, birkaç pano kartı ve arama kutusu. Bir tablo binlerden milyonlara büyüdüğünde, eskiden anlık hissettiren aynı sorgu gecikmeye başlar.
Tipik bir örnek, durum ve tarihe göre filtrelenen, en yeniye göre sıralanan ve sayfalandırma yapılan Orders sayfasıdır. PostgreSQL son 30 gündeki ödenmiş siparişleri bulmak için tüm orders tablosunu taramak zorunda kalırsa, her sayfa yüklemede ekstra iş olur. İyi bir indeks bunu doğru veri dilimine hızlı bir atlayışa çevirir.
İndeksler ücretsiz değildir. Her biri belirli sorgular için daha hızlı okumalar sağlar, ancak yazmaları yavaşlatır (INSERT/UPDATE/DELETE indeksleri güncellemek zorundadır) ve daha fazla depolama/önbellek baskısı kullanır. Bu takas, indeks tiplerinden ziyade gerçek sorgu kalıplarından başlamanız gerektiği sebeptir.
Zaman kaybını önleyen basit bir kural: yalnızca hızlandıracağı belirli, sık kullanılan bir sorguyu işaret edebiliyorsanız indeks ekleyin. Koder.ai gibi sohbet tabanlı bir oluşturucu ile ekranlar inşa ediyorsanız, liste sayfalarınızın ve panolarınızın arkasındaki SQL'i yakalamak ve bunu indeks istek listeniz olarak kullanmak faydalıdır.
Çoğu indeks karışıklığı, özellikler (JSON, arama, diziler) yerine sorgu şeklini düşünmeye başladığınızda kaybolur: WHERE ne yapıyor ve sonuçları nasıl sıralamak istiyorsunuz?
Sorgunuz normal karşılaştırmalar gibiyse ve sıralama önemliyse B-tree kullanın. Eşitlik, aralıklar ve join'ler için iş atıdır.
Örnek şekiller: tenant_id = ?, status = 'active', created_at \u003e= ?, users.id = orders.user_id ile join yapmak veya ORDER BY created_at DESC ile "en yeni önde" göstermek.
GIN (Generalized Inverted Index), bir sütunda birçok üye olduğunda ve "içeriyor mu X?" sorusunu sorduğunuzda iyi bir uyumdur. Bu JSONB anahtarları, dizi elemanları ve tam metin vektörleriyle yaygındır.
Örnek şekiller: JSONB'de metadata @\u003e {'plan':'pro'}, tags @\u003e ARRAY['urgent'], veya to_tsvector(body) @@ plainto_tsquery('reset password').
GiST (Generalized Search Tree) ise değerlerin aralıklar veya şekiller gibi davrandığı, uzaklık veya örtüşme ile ilgili sorulara uyar. Genellikle aralık tipleri, geometrik veriler ve bazı "en yakın" eşleşme aramaları için kullanılır.
Örnek şekiller: aralık sütunları ile örtüşen zaman pencereleri, trigram operatörleriyle bazı benzerlik tarzı aramalar veya PostGIS kullanıyorsanız mekansal sorgular.
Pratik bir seçim yöntemi:
İndeksler okumaları hızlandırır, ama yazma zamanı ve diskte maliyeti vardır. SaaS'ta bu takas en çok events, sessions ve activity logs gibi sıcak tablolarda önem kazanır.
Çoğu SaaS liste ekranı aynı şekli paylaşır: bir tenant sınırı, birkaç filtre ve öngörülebilir bir sıralama. B-tree indeksleri burada varsayılan seçimdir ve genellikle bakım için en ucuz olanlardır.
Yaygın bir desen WHERE tenant_id = ? artı status = ?, user_id = ? gibi filtreler ve created_at \u003e= ? gibi bir zaman aralığıdır. Bileşik B-tree indeksleri için eşitlik filtrelerini önce koyun (= ile eşleşen sütunlar), sonra sıraladığınız sütunu ekleyin.
Uygulamalarda iyi işleyen kurallar:
tenant_id ile başlayın.= filtrelerini koyun (genellikle status, user_id).ORDER BY sütununu en sona koyun (genellikle created_at veya id).INCLUDE kullanın, böylece anahtar daha genişlemez.Gerçekçi bir örnek: durum ile filtrelenen ve en yeni ilk olan Tickets sayfası.
-- Query
SELECT id, status, created_at, title
FROM tickets
WHERE tenant_id = $1
AND status = $2
ORDER BY created_at DESC
LIMIT 50;
-- Index
CREATE INDEX tickets_tenant_status_created_at_idx
ON tickets (tenant_id, status, created_at DESC)
INCLUDE (title);
Bu indeks hem filtreyi hem de sıralamayı destekler, böylece Postgres büyük bir sonuç kümesini sıralamaktan kaçınabilir. INCLUDE (title) kısmı liste sayfasının daha az tablo sayfasına dokunmasına yardımcı olurken, indeks anahtarlarını filtreleme ve sıralamaya odaklı tutar.
Zaman aralıkları için aynı fikir geçerlidir:
SELECT id, created_at
FROM events
WHERE tenant_id = $1
AND created_at \u003e= $2
AND created_at \u003c $3
ORDER BY created_at DESC
LIMIT 100;
CREATE INDEX events_tenant_created_at_idx
ON events (tenant_id, created_at DESC);
Sayfalama, birçok SaaS uygulamasının yavaşladığı yerdir. Offset paginasyon (OFFSET 50000) veritabanının birçok satırı geçmesini zorlar. Seek paginasyon, son görülen sıralama anahtarını kullanarak hızlı kalır:
SELECT id, created_at
FROM tickets
WHERE tenant_id = $1
AND created_at \u003c $2
ORDER BY created_at DESC
LIMIT 50;
Doğru B-tree indeksi ile bu, tablo büyüdükçe bile hızlı kalır.
Çoğu SaaS uygulaması çok kiracılıdır: her sorgu bir tenant içinde kalmalıdır. İndeksleriniz tenant_id içermiyorsa Postgres yine hızlı satır bulabilir, ama genellikle ihtiyaç duyulanlardan çok daha fazla indeks girdisi tarar. Tenant-dostu indeksler, her tenant'ın verilerini indekste kümeler, böylece yaygın ekranlar hızlı ve öngörülebilir olur.
Basit bir kural: sorgu her zaman tenant ile sınırlanıyorsa indeksi başlatırken tenant_id'yi öne koyun. Sonra en çok filtrelediğiniz veya sıraladığınız sütunu ekleyin.
Yüksek etki yapan sıkıcı indeksler genellikle şöyle görünür:
(tenant_id, created_at) — yeni öğeler listeleri ve cursor paginasyon için(tenant_id, status) — durum filtreleri (Open, Paid, Failed)(tenant_id, user_id) — bu kullanıcıya ait öğeler ekranları için(tenant_id, updated_at) — "son değişiklikler" admin görünümleri için(tenant_id, external_id) — webhook veya import kaynaklı lookuplar içinAşırı indeksleme, her biraz farklı ekran için yeni bir indeks eklediğinizde olur. Yeni bir tane oluşturmadan önce mevcut bileşik indeksin ihtiyacınız olan en soldaki sütunları zaten kapsayıp kapsamadığını kontrol edin. Örneğin (tenant_id, created_at) varsa genellikle (tenant_id, created_at, id)'ye ayrıca ihtiyacınız yoktur, ancak gerçekten id'ye sonrasında filtre uyguluyorsanız gerekebilir.
Partial index'ler, çoğu satırın alakasız olduğu durumlarda boyutu ve yazma maliyetini azaltabilir. Soft delete ve sadece "aktif" veriler için iyi çalışırlar; örneğin yalnızca deleted_at IS NULL olanları veya sadece status = 'active' olanları indeksleyin.
Her ekstra indeks yazmaları ağırlaştırır. Insert'ler her indeksi güncellemek zorundadır ve güncellemeler tek bir sütunu değiştirseniz bile birden fazla indeksi etkileyebilir. Uygulamanız çok sayıda event alıyorsa (Koder.ai ile hızlıca kurulmuş uygulamalar dahil), indeksleri günlük kullanıcıların vurduğu birkaç sorgu şekline odaklı tutun.
JSONB, uygulamanızın feature flag'leri, kullanıcı nitelikleri veya tenant başına ayarlar gibi esnek ekstra alanlara ihtiyaç duyduğunda kullanışlıdır. Sorun şu ki farklı JSONB operatörleri farklı davranır; bu yüzden en iyi indeks, nasıl sorguladığınıza bağlıdır.
İki şekil en çok önemlidir:
@\u003e kullanarak "Bu JSON bu anahtar-değerleri içeriyor mu?"-\u003e / -\u003e\u003e ile belirli alanın değeri çıkarılır ve genellikle = ile karşılaştırılır.Sıkça @\u003e ile filtreleme yapıyorsanız, JSONB sütununa GIN indeksi genellikle işe yarar.
-- Query shape: containment
SELECT id
FROM accounts
WHERE tenant_id = $1
AND metadata @\u003e '{\"region\":\"eu\",\"plan\":\"pro\"}';
-- Index
CREATE INDEX accounts_metadata_gin
ON accounts
USING GIN (metadata);
JSON yapınız öngörülebilirse ve çoğunlukla üst düzey anahtarlarla @\u003e kullanıyorsanız, jsonb_path_ops daha küçük ve hızlı olabilir, ama daha az operatör türünü destekler.
UI'niz belirli bir alana (ör. plan) sıkça filtre uyguluyorsa, o alanı çıkarıp indekslemek geniş GIN'den daha hızlı ve daha ucuz olabilir.
SELECT id
FROM accounts
WHERE tenant_id = $1
AND metadata-\u003e\u003e'plan' = 'pro';
CREATE INDEX accounts_plan_expr
ON accounts ((metadata-\u003e\u003e'plan'));
Pratik bir kural: JSONB'yi esnek ve nadiren filtrelenen özellikler için tutun, ama stabil ve sık kullanılan alanları (plan, status, created_at) gerçek sütunlara taşıyın. Hızlı iterasyon yapan bir uygulamada, hangi filtrelerin her sayfada göründüğünü gördüğünüzde bu genellikle kolay bir şema değişikliği olur.
Örnek: {\"tags\":[\"beta\",\"finance\"],\"region\":\"us\"} gibi JSONB saklıyorsanız, birden çok özellik kümesiyle (@\u003e) filtreliyorsanız GIN; en çok yönlendiren birkaç anahtar için expression indeksleri ekleyin (plan, region).
Diziler saklamak kolay olduğu için caziptir. users.roles text[] veya projects.labels text[] sütunu, genellikle "bu satır bir değeri içeriyor mu?" sorusunu sorduğunuzda iyi çalışır. İşte GIN burada faydalıdır.
GIN, dizi öğelerini ayrı parçalar halinde ele alır ve onları içeren satırlara hızlı bir arama sağlar.
Diziler için fayda sağlayan sorgu şekilleri:
@\u003e (dizi içerir)&& (dizi herhangi bir öğeyi paylaşır)= ANY(...), ama @\u003e genellikle daha öngörülebilirdirKullanıcıları rol ile filtreleme için tipik örnek:
-- Find users who have the "admin" role
SELECT id, email
FROM users
WHERE roles @\u003e ARRAY['admin'];
CREATE INDEX users_roles_gin ON users USING GIN (roles);
Ve etiket kümesine göre projeleri filtreleme (hem etiketleri içermeli):
SELECT id, name
FROM projects
WHERE labels @\u003e ARRAY['billing', 'urgent'];
CREATE INDEX projects_labels_gin ON projects USING GIN (labels);
İnsanların şaşırdığı yerler: bazı desenler indeksin beklediğiniz gibi kullanılmamasına neden olur. Diziyi string'e (array_to_string(labels, ',')) çevirip LIKE çalıştırırsanız, GIN indeksi yardımcı olmaz. Ayrıca etiket içinde "başlıyor" veya bulanık eşleşmeler gerekiyorsa, bu metin arama alanına girer, dizi üyeliği değil.
Diziler aynı zamanda küçük bir veritabanı haline geldiklerinde yönetimi zorlaşır: sık güncellemeler, öğe başına metadata (kimi ekledi, ne zaman, neden) veya öğe başına analiz ihtiyaçları. O noktada project_labels(project_id, label) gibi bir join tablosu genellikle doğrulaması, sorgulanması ve evrilmesi daha kolaydır.
Arama kutuları için iki desen sıkça çıkar: tam metin arama (bir konuda kayıtları bulma) ve bulanık eşleşme (yazım hataları, kısmi isimler, ILIKE desenleri). Doğru indeks "anında" ile "10k kullanıcıda zaman aşımı" arasındaki farktır.
Kullanıcılar gerçek kelimeler yazdığında ve sonuçları alaka sırasına göre getirmek istediğinizde tam metin aramayı kullanın. Yaygın kurulum, bir tsvector saklamak (çoğunlukla generated column) ve bunu GIN ile indekslemektir. @@ ve bir tsquery ile arama yaparsınız.
-- Tickets: full-text search on subject + body
ALTER TABLE tickets
ADD COLUMN search_vec tsvector
GENERATED ALWAYS AS (
to_tsvector('simple', coalesce(subject,'') || ' ' || coalesce(body,''))
) STORED;
CREATE INDEX tickets_search_vec_gin
ON tickets USING GIN (search_vec);
-- Query
SELECT id, subject
FROM tickets
WHERE search_vec @@ plainto_tsquery('simple', 'invoice failed');
-- Customers: fuzzy name search using trigrams
CREATE INDEX customers_name_trgm
ON customers USING GIN (name gin_trgm_ops);
SELECT id, name
FROM customers
WHERE name ILIKE '%jon smth%';
Vektörde ne saklanmalı: yalnızca gerçekten aradığınız alanları. Her şeyi (notlar, dahili loglar) dahil ederseniz indeks boyutu ve yazma maliyeti artar.
Kullanıcılar isim, e-posta veya kısa ifadeler arıyorsa ve kısmi eşleşme veya yazım hatalarına tolerans gerekiyorsa trigram benzerliği kullanın. Trigramlar ILIKE '%term%' ve similarity operatörleriyle iyi çalışır. GIN genellikle "eşleşiyor mu?" sorguları için daha hızlıdır; GiST ise benzerliğe göre sıralama da önemli olduğunda daha uygun olabilir.
Kuralı:
tsvector kullanın.Dikkat edilmesi gereken tuzaklar:
ILIKE '%abc') taramalar yapılır.Arama ekranlarını hızlı gönderecekseniz, indeksi özelliğin bir parçası olarak ele alın: arama UX ve indeks seçimi birlikte tasarlanmalı.
Tahmin yerine uygulamanızın çalıştırdığı tam sorgu ile başlayın. "Yavaş ekran" genellikle WHERE ve ORDER BY içeren belirli bir SQL ifadesidir. Bunu loglardan, ORM debug çıktısından veya kullandığınız sorgu yakalamadan kopyalayın.
Gerçek uygulamalarda işe yarayan bir iş akışı:
EXPLAIN (ANALYZE, BUFFERS) çalıştırın.=, \u003e=, LIKE, @\u003e, @@) odaklanın, sadece sütun isimlerine değil.EXPLAIN (ANALYZE, BUFFERS) tekrar çalıştırın.Somut bir örnek: Customers sayfası tenant ve status ile filtreliyor, en yeniye göre sıralıyor ve paginasyon yapıyor:
SELECT id, created_at, email
FROM customers
WHERE tenant_id = $1 AND status = $2
ORDER BY created_at DESC
LIMIT 50;
Eğer EXPLAIN sıralama ve sequential scan gösteriyorsa, filtre ve sıralamaya uyan bir B-tree indeksi genellikle bunu düzeltir:
CREATE INDEX ON customers (tenant_id, status, created_at DESC);
Yavaş kısım JSONB filtreleme gibi metadata @\u003e '{\"plan\":\"pro\"}' ise bu GIN'e işaret eder. Tam metin arama to_tsvector(...) @@ plainto_tsquery(...) ise o da GIN destekli arama indeksine işaret eder. "En yakın eşleşme" veya örtüşme tarzı operatör setiyse GiST uygun olabilir.
İndeksi ekledikten sonra takası ölçün. İndeks boyutunu, insert/update süresini ve bunun sadece birkaç yavaş sorguyu mu yoksa tek bir kenar durumunu mu iyileştirdiğini kontrol edin. Koder.ai gibi hızlı ilerleyen projelerde bu tekrar kontrol, kullanılmayan indekslerin birikmesini önler.
Çoğu indeks sorunu B-tree vs GIN vs GiST seçimiyle ilgili değildir. Asıl sorun, uygulamanın tabloyu nasıl sorguladığıyla eşleşmeyen bir indeks oluşturmaktır.
En çok zarar veren hatalar:
tenant_id ve created_at ile başlıyorsa ama indeks created_at ile başlıyorsa planner onu atlayabilir.status, is_active veya boolean üzerinde tek başına indeks genellikle az fayda sağlar çünkü çok fazla satırla eşleşir. Bunu seçici bir sütunla eşleştirin veya atlayın.ANALYZE yakın zamanda çalışmadıysa, doğru indeks olsa bile planner kötü planlar seçebilir.Somut örnek: Invoices ekranınız tenant_id ve status ile filtreleyip sonra created_at DESC ile sıralıyorsa, sadece status üzerinde bir indeks neredeyse hiç yardımcı olmaz. Daha iyi bir uyum, tenant_id, sonra status, sonra created_at (filtre önce, sıralama sondadır) içeren bileşik bir indekstir. Bu tek değişiklik genellikle üç ayrı indeks eklemekten üstündür.
Her indeksi bir maliyet olarak değerlendirin. Gerçek sorgularda karşılığını vermelidir, teoride değil.
İndeks değişiklikleri gönderilmesi kolay ama geri alınması can sıkıcı olabilir çünkü yazma maliyetini artırabilir veya yoğun bir tabloyu kilitleyebilir. Merge etmeden önce bunu küçük bir sürüm gibi ele alın. Loglardan veya monitoring'den en çok çalışan sorgular ve en yüksek gecikmeli sorgular olmak üzere iki kısa sıralama çekin. Her biri için tam şekli (filtre sütunları, sıralama, join'ler ve kullanılan operatörler) yazın. Bu tahmini önler ve doğru indeks tipini seçmenize yardımcı olur.
Gönderim öncesi kontrol listesi:
İndeksi ekledikten sonra gerçekten yardımcı olup olmadığını doğrulayın. Aynı sorguda EXPLAIN (ANALYZE, BUFFERS) çalıştırıp önce/sonra karşılaştırması yapın. Ardından üretimi bir gün izleyin:
Koder.ai ile inşa ediyorsanız, yapılan değişikliğin yanına bir veya iki yavaş ekran için oluşturulan SQL'i koymak, indeksi uygulamanın gerçekten çalıştırdığı sorguyla eşleştirmenize yardımcı olur.
Bir admin ekranı hayal edin: tenant scopu, birkaç filtre, son aktifliğe göre sıralama ve bir arama kutusu içeren bir Users listesi. İndeksler teoriden çıkarak gerçek zamanda zamanı kurtarmaya başlar.
Bu ekranda genellikle göreceğiniz üç sorgu şekli:
-- 1) List page with tenant + status filter + sort
SELECT id, email, last_active_at
FROM users
WHERE tenant_id = $1 AND status = $2
ORDER BY last_active_at DESC
LIMIT 50;
-- 2) Search box (full-text)
SELECT id, email
FROM users
WHERE tenant_id = $1
AND to_tsvector('simple', coalesce(name,'') || ' ' || coalesce(email,'')) @@ plainto_tsquery($2)
ORDER BY last_active_at DESC
LIMIT 50;
-- 3) Filter on JSON metadata (plan, flags)
SELECT id
FROM users
WHERE tenant_id = $1
AND metadata @\u003e '{\"plan\":\"pro\"}'::jsonb;
Bu ekran için küçük ama kasıtlı bir indeks seti:
(tenant_id, status, last_active_at DESC).tsvector sütunu ve GIN indeksi.@\u003e çok kullanılıyorsa GIN (metadata), yok çoğunlukla tek anahtara göre filtreliyorsanız ((metadata-\u003e\u003e'plan')) gibi expression B-tree.Karışık ihtiyaçlar normaldir. Bir sayfa filtre + arama + JSON yapıyorsa, her şeyi tek bir dev indeks içine sıkıştırmaktan kaçının. B-tree'yi sıralama/paginasyon için tutun, sonra pahalı kısım için bir uzman indeks (çoğunlukla GIN) ekleyin.
Sonraki adımlar: bir yavaş ekran seçin, en önemli 2–3 sorgu şeklini yazın ve her indeksi amacına göre gözden geçirin (filtre, sıralama, arama, JSON). Bir indeks gerçek bir sorguyla açıkça eşleşmiyorsa, planınızdan çıkarın. Koder.ai ile hızlı iterasyon yapıyorsanız, yeni ekranlar eklerken bu incelemeyi yapmak, şema hâlâ değişirken indeks çoğalmasını önler.
Bir indeks, PostgreSQL'in eşleşen satırları bulmasını sağlar; böylece tablonun çoğunu okumak zorunda kalmaz. Listeler, panolar ve arama gibi yaygın SaaS ekranlarında doğru indeks, yavaş bir sıralı taramayı ölçeklenen ve hızlı bir aramaya dönüştürebilir.
Çoğu uygulama sorgusu için önce B-tree ile başlayın—= filtreleri, aralıklar, join'ler ve ORDER BY için en uygunudur. Sorgunuz ağırlıklı olarak kapsama (JSONB, diziler) veya tam metin arama ile ilgiliyse GIN'i düşünün; örtüşme veya "en yakın" tarzı sorgular için GiST daha uygundur.
= ile filtrelediğiniz sütunları önce koyun, sonra sıraladığınız sütunu en sona koyun. Bu sıra, planner'ın indeksi verimli şekilde okumasını sağlar; hem filtreyi hem de sıralamayı ek bir sort yapmadan karşılayabilir.
Her sorgu tenant_id ile sınırlanıyorsa, tenant_id'yi indeksin başına koymak her tenant'ın verilerini indekste gruplayarak günlük liste sayfalarında daha az veri okunmasını sağlar.
INCLUDE, anahtarları genişletmeden liste sayfaları için indeks-only okuma sağlamak amacıyla ekstra sütunları eklemenizi sağlar. Filtre ve sıralama için birkaç sütun kullanıyorsanız ve ek alanları da görüntülüyorsanız faydalıdır.
Sadece ilginizi çeken alt küme için (örneğin deleted_at IS NULL veya status = 'active') bir partial index kullanın. Bu, indeksi daha küçük ve bakım maliyetini daha düşük tutar; sıcak tablolarda önemlidir.
Sıkça metadata @\u003e '{"plan":"pro"}' gibi containment sorguları çalıştırıyorsanız JSONB sütununa GIN indeksi koyun. Eğer çoğunlukla tek bir JSON anahtarına göre filtreliyorsanız, (metadata-\u003e\u003e'plan') gibi bir expression B-tree indeksi genellikle daha küçük ve daha hızlıdır.
Ana sorunuz "bu dizi X içeriyor mu?" ise GIN uygundur (@\u003e, && operatörleri). Eğer her öğe için metadata, sık güncellemeler veya öğe başına analiz gerekiyorsa normalleştirilmiş bir join tablosu (ör. project_labels(project_id, label)) genellikle daha sürdürülebilirdir.
Tam metin arama için bir tsvector saklayın (çoğunlukla generated column) ve GIN ile indeksleyin, sonra @@ ile sorgulayın. ILIKE '%name%' ve yazım hatalarına tolerans için trigram indeksleri (genellikle GIN) uygundur.
Uygulamanızın çalıştırdığı tam SQL'i alın ve EXPLAIN (ANALYZE, BUFFERS) ile nerede zaman harcandığını görün. Sorgunun operatörlerine ve sıralamasına uyan en küçük indeksi ekleyin, sonra aynı EXPLAIN'i tekrar çalıştırarak indeksin gerçekten kullanılıp kullanılmadığını ve planı iyileştirip iyileştirmediğini doğrulayın.