Veri · Bölüm 11

NoSQL, arama ve CAP: Couchbase, Elasticsearch, Redis ve tutarlılık

Couchbase, Elasticsearch ve Redis'in içeride nasıl çalıştığını, birden fazla veri deposunu nasıl senkron tutacağını ve CAP/PACELC ile tutarlılık kararlarını doğru terimlerle anlatmayı öğretir.

~40 dk okumaQuiz: q11, j28
Cevaplar:

Bu bölümde

Bu bölümde büyük ölçekli e-ticaret sistemlerinin tipik veri katmanını öğreneceksin: ürün ve sepet gibi erişim deseni belli veriler için doküman ve key-value deposu (Couchbase), arama ve filtreleme için türetilmiş bir arama index'i (Elasticsearch), sıcak veri için cache (Redis). Asıl zor kısım tek tek araçlar değil, aralarındaki senkronizasyon ve bunun doğurduğu tutarlılık kararlarıdır. Mülakatta bu konu "bu sistem CP mi AP mi", "Couchbase'i ES'ye nasıl senkron tutarsın", "cache'i nasıl invalidate edersin", "ES'yi ana veritabanı yapabilir miyiz" gibi tasarım sorularıyla ve çok seçimli kavram sorularıyla gelir. Sürüme bağlı davranışlar sürümüyle yazıldı; Elasticsearch için 8.x/9.x, Couchbase için 7.x referans alındı.

Konu anlatımı

Neden tek veritabanı yetmiyor

İlişkisel veritabanı tek bir kaynakta tutarlılık, transaction ve esnek sorgu verir. Bedeli, yatayda ölçeklemenin zor olması ve bazı erişim desenlerinde (tam metin arama, milisaniye altı anahtar okuma, büyük ölçekli aggregation) özel araçlardan geride kalmasıdır. Büyük sistemler bu yüzden polyglot persistence uygular: her erişim desenine uygun depo. Ama her ek depo bir kopya demektir ve kopya kaynağından geride kalabilir. Bu bölümün geri kalanı aslında tek bir sorunun cevabıdır: "Hangi verinin doğruluk kaynağı (source of truth) nerede ve diğerleri ondan ne kadar geride kalabilir?"

Couchbase: doküman deposu ve key-value erişim

Couchbase'de veri JSON dokümanları olarak tutulur ve her dokümanın benzersiz bir key'i vardır. Hiyerarşi: bucket → scope → collection → doküman (scope ve collection'lar Couchbase 7.0 ile geldi; ilişkisel dünyadaki veritabanı → şema → tablo karşılığı gibi düşünülebilir).

Couchbase birden fazla servisten oluşur ve her node'da farklı servisler çalıştırılabilir: Data (KV), Query (SQL++), Index (GSI), Search (full-text), Analytics, Eventing. Bu ayrım ölçeklemeyi bağımsız yapar: yoğun sorgu yükü için Query/Index node'ları, yoğun KV trafiği için Data node'ları eklenir.

Veri dağıtımı: vBucket. Her bucket sabit sayıda vBucket'a bölünür: Linux'ta Couchstore motoruyla 1024 (Magma motorunda 128 ya da 1024 seçilebilir), macOS'ta 64. Bir dokümanın vBucket'ı key'in CRC32 hash'iyle belirlenir. Cluster Manager hangi vBucket'ın hangi node'da aktif, hangilerinde replika olduğunu gösteren cluster map'i tutar ve SDK'lara dağıtır. İstemci key'i hash'ler, vBucket'ı bulur, map'ten node'u bulur ve doğrudan o node'a bağlanır; arada bir koordinatör ya da sorgu planlayıcı yoktur. Node eklenince ya da çıkınca vBucket'lar taşınır (rebalance), map güncellenir, uygulama kodu değişmez.

Memory-first mimari. Data servisi her bucket için yönetilen bir bellek cache'i tutar. Yazma varsayılan olarak aktif node'un belleğine yazıldığında onaylanır; diske yazma ve replikalara kopyalama asenkron devam eder. Bu milisaniye altı yazma gecikmesi sağlar ama bedeli açıktır: yazma onaylandıktan hemen sonra aktif node çökerse, henüz replikaya ulaşmamış yazma kaybolabilir. Couchbase 6.5+ bunun için yazma başına durability level sunar: majority (aktif ve replika kopyaları taşıyan Data node'larının çoğunluğunun belleğine ulaştı), majorityAndPersistToActive, persistToMajority. Daha güçlü durability daha yüksek gecikme demektir; sipariş gibi kritik yazmalarda bilinçli seçilir.

KV erişim ve SQL++. Aynı veriye iki yoldan erişilir:

  • KV (get/upsert/replace by key): Key'in hash'iyle doğrudan aktif vBucket'a gider, bellekten döner. En hızlı ve en ucuz yoldur. Aktif vBucket'tan okuma kendi yazdığını hemen görür.
  • SQL++ (eski adıyla N1QL): SQL'e benzeyen bir dil (SELECT ... FROM products WHERE sellerId = $1). Query servisi sorguyu planlar, Index servisindeki GSI (global secondary index) üzerinden key'leri bulur, sonra dokümanları Data servisinden çeker. Ek ağ adımları ve planlama yüzünden KV'den belirgin şekilde yavaştır.

GSI'ler varsayılan olarak eventually consistent'tir: index, Data servisindeki değişiklikleri asenkron alır. Sorgunun varsayılan scan_consistency değeri not_bounded'dır, yani index'in o anki haliyle cevap verir; az önce yazılan doküman sorgu sonucunda görünmeyebilir. request_plus sorgu anına kadarki tüm yazmaların index'e işlenmesini bekler (daha yavaş), at_plus sadece belirli yazmaları bekler (read-your-writes için). Production'da CREATE PRIMARY INDEX ile tüm bucket'ı tarayan sorgular bir anti-pattern'dir; her sorgu desenine uygun GSI tanımlanır.

Erişim desenine göre key tasarımı. Couchbase'de veri modeli "hangi sorguları çalıştıracağım" sorusundan başlar. Sık okunan veri KV ile key'den okunacak şekilde modellenir: product::123, user::42::cart, seller::7::settings. Join yerine denormalizasyon tercih edilir: ürün dokümanı satıcı adını ve kategori yolunu içerir. Bir dokümanın büyük bir kısmı değil sadece bir alanı değişiyorsa sub-document API (lookupIn, mutateIn) dokümanın tamamını ağdan taşımadan o yolu okur/yazar.

Eşzamanlılık: CAS. Her dokümanın bir CAS (compare-and-swap) değeri vardır ve her değişiklikte değişir. replace(key, doc, cas) doküman okunduğundan beri değişmediyse başarılı olur, değiştiyse hata verir: JPA'daki @Version ile aynı optimistic locking fikri. Kısa süreli pessimistic kilit için getAndLock vardır; kilit sunucu tarafında bir süre sınırıyla tutulur. Birden fazla dokümanı atomik değiştirmek için Couchbase 6.5+ çok dokümanlı ACID transaction'lar sunar; ama bedeli ek gecikme ve yazma maliyetidir, veri modeli mümkünse tek dokümanda atomikliği sağlayacak şekilde tasarlanır.

DCP (Database Change Protocol). Couchbase'in iç değişiklik akışıdır: her vBucket'taki her değişiklik sıralı bir akış olarak yayınlanır. Replikasyon, index güncelleme, XDCR (cluster'lar arası replikasyon) ve dış connector'lar (Elasticsearch connector, Kafka connector) bu akışı kullanır. Senkronizasyon bölümünde tekrar döneceğiz.

Elasticsearch: inverted index ve analiz

Elasticsearch (ES), Apache Lucene üzerine kurulu dağıtık bir arama ve analiz motorudur. Temel veri yapısı inverted index'tir: kitabın arkasındaki dizin gibi, terim → o terimi içeren dokümanlar listesi.

text
Doküman 1: "Kablosuz Kulaklık"
Doküman 2: "Oyuncu Kulaklığı Kablolu"

Terim        → dokümanlar
kablosuz     → [1]
kulaklık     → [1]
kulaklığı    → [2]
oyuncu       → [2]
kablolu      → [2]

"kulaklık" araması dizinde tek bir terime bakar ve listeyi döndürür; satır satır tarama yoktur. Bu yüzden ES'de "metninde telefon kelimesi geçen ürünler" araması ucuzdur, PostgreSQL'de LIKE '%telefon%' ise B-tree ile yapılamaz. Dikkat: ucuz olan terim aramasıdır; terimin ortasındaki bir parçayı arayan gerçek substring araması (wildcard sorgusunda *telefon*) ES'de de terim sözlüğünü taradığı için pahalıdır, bunun için ngram analyzer ya da wildcard alan tipi kullanılır.

Ama yukarıdaki dizinde "kulaklık" araması 2. dokümanı bulmaz, çünkü "kulaklığı" farklı bir terimdir. Terimlerin nasıl üretildiğini analyzer belirler. Analyzer üç aşamalı bir zincirdir:

  1. Character filter: Ham metni ön işler (HTML temizleme, karakter eşleme).
  2. Tokenizer: Metni token'lara böler (standard tokenizer kelime sınırlarına göre).
  3. Token filter: Token'ları dönüştürür: lowercase, stop word silme, stemming (kök bulma: "kulaklığı" → "kulaklık"), synonym ("telefon" = "cep telefonu"), asciifolding ("ı" → "i").

Aynı analyzer hem index'leme anında hem arama anında uygulanır, böylece sorgu terimleri dizindeki terimlerle aynı biçime gelir.

Türkçe tuzağı: Dile duyarsız lowercase, "I" harfini "i"ye çevirir. Türkçede "I"nın küçüğü "ı", "İ"nin küçüğü "i"dir. "IŞIK" araması dile duyarsız lowercase ile "işik" olur ve "ışık" ile eşleşmez. ES'nin lowercase token filter'ı "language": "turkish" parametresini destekler ve hazır turkish dil analyzer'ı Türkçeye uygun lowercase, stop word ve stemming içerir. Aynı sorun Java'da "I".toLowerCase() çağrısının varsayılan locale'e bağlı olmasıyla yaşanır (bkz. Bölüm 2).

Mapping: text ve keyword. Mapping, alanların tipini ve analizini tanımlar (ilişkisel dünyadaki şema).

  • text: Analiz edilir, tam metin aramada kullanılır ("kulaklık" araması "Kablosuz Kulaklık"ı bulur). Sıralama ve aggregation için uygun değildir (bunun için fielddata gerekir ve varsayılan olarak kapalıdır, çünkü belleği şişirir).
  • keyword: Analiz edilmez, değer olduğu gibi tek terimdir. Birebir filtre (status = "ACTIVE"), sıralama, aggregation (marka bazında sayım) için kullanılır. Diskte sütun bazlı doc_values yapısıyla saklanır.

Ürün adı gibi alanlar genelde ikisi birden olarak tanımlanır (name text, name.keyword keyword; multi-field).

ES varsayılan olarak dynamic mapping ile ilk gördüğü değere göre tip tahmin eder. Production'da bu tehlikelidir: tarih gibi görünen bir string tarih olur, kullanıcı tanımlı anahtarlar binlerce alan yaratır (mapping explosion). Mapping açıkça tanımlanır, dynamic: strict ile beklenmeyen alanlar reddedilir. Var olan bir alanın tipi ya da analyzer'ı sonradan değiştirilemez, çünkü mevcut inverted index o kurala göre kurulmuştur; değişiklik için yeni index ve reindex gerekir.

Sorgu ve filtre bağlamı. match gibi sorgular query context'te çalışır ve her doküman için alaka skoru hesaplar (varsayılan algoritma BM25). term, range gibi evet/hayır koşulları filter context'e (bool.filter) konursa skor hesaplanmaz ve sonuç cache'lenebilir. Kural: arama kutusundaki metin query'de, "kategori = telefon, fiyat < 20.000" gibi facet'ler filter'da.

Elasticsearch: yazma yolu, near real-time, shard ve replica

Near real-time (NRT). Bir doküman index'lendiğinde önce bellekteki bir buffer'a ve dayanıklılık için translog'a yazılır. Aranabilir olması için Lucene'in yeni bir segment açması gerekir; buna refresh denir. Varsayılan refresh_interval 1 saniyedir. Ama self-managed kurulumlarda bir ayrıntı var: bu ayar açıkça verilmemişse, son 30 saniyedir arama isteği almayan shard'lar (search idle) arka planda refresh edilmez; ilk arama geldiğinde refresh tetiklenir. Sonuç: index isteği başarıyla döndükten sonra doküman aramada bir süre görünmeyebilir. GET /index/_doc/<id> ise realtime'dır ve dokümanı hemen döndürür.

Uygulama "kaydettim, listede görünmüyor" sorununu üç yolla çözer: (1) kaydedilen dokümanı listeye istemci tarafında eklemek ya da detay sayfasını id ile okumak, (2) o istekte ?refresh=wait_for ile bir sonraki periyodik refresh'i beklemek (yazma gecikmesini artırır), (3) ?refresh=true ile zorla refresh (her yazmada küçük segment üretir, toplu yazmalarda performansı öldürür; sadece testte). Toplu yükleme sırasında ise tersi yapılır: refresh_interval artırılır ya da -1 yapılır, sonra geri alınır.

Shard ve replica. Bir index, primary shard'lara bölünür; her shard bağımsız bir Lucene index'idir. Her primary'nin replica'ları farklı node'larda durur. Replica iki iş görür: node kaybında veri kaybını önler (replica primary'ye terfi eder) ve okuma kapasitesini artırır (aramalar primary ya da replica'ya gidebilir). ES 7.0+'da yeni index'in varsayılanı 1 primary, 1 replica'dır.

Bir dokümanın hangi shard'a gideceği basitleştirilmiş haliyle hash(_routing) % primary_shard_sayısı ile belirlenir; _routing varsayılan olarak _id'dir. Bu formül yüzünden primary shard sayısı sonradan doğrudan değiştirilemez: sayı değişirse mevcut her dokümanın shard'ı değişir. Değiştirmek için _split (katlarına artırma), _shrink (bölenlerine azaltma) API'leri ya da yeni index + reindex gerekir. Replica sayısı ise dinamik bir ayardır, anında değiştirilir. Bu yüzden shard sayısı veri büyüklüğüne göre önceden planlanır; Elastic'in genel önerisi shard başına onlarca GB mertebesidir. Çok küçük çok sayıda shard ("oversharding") cluster state'i ve heap'i şişirir, her arama daha çok shard'a dağılır.

Arama nasıl çalışır (scatter-gather): Koordinatör node isteği index'in her shard'ının bir kopyasına gönderir (query fazı), her shard kendi en iyi N sonucunu (id ve skor) döndürür, koordinatör birleştirip sıralar, sonra sadece gereken dokümanları ilgili shard'lardan çeker (fetch fazı).

Bu yapının iki sonucu mülakatta sık sorulur:

  • Derin sayfalama pahalıdır. from: 10000, size: 20 için her shard 10.020 sonuç üretmek zorundadır ve koordinatör hepsini birleştirir. Varsayılan index.max_result_window 10.000'dir. Derin gezinme için search_after + PIT (point in time) kullanılır: SQL'deki keyset sayfalamanın karşılığıdır (bkz. Bölüm 9).
  • Aggregation'lar yaklaşık olabilir. terms aggregation'ı her shard'ın kendi ilk N terimini döndürmesiyle hesaplanır; global ilk 10, shard'ların yerel ilk 10'larından farklı olabilir ve ES bunun hata payını doc_count_error_upper_bound ile bildirir. cardinality (farklı değer sayısı) HyperLogLog++ ile tahmin edilir. Finansal raporlama gibi kesin sayı gereken yerde ES aggregation'ı tek kaynak olarak kullanılmaz.

Custom routing. Satıcı panelinde sorguların hep sellerId ile filtrelendiğini düşün. Varsayılan routing ile bir satıcının ürünleri tüm shard'lara dağılır ve her sorgu tüm shard'lara gider. Index'lerken routing = sellerId verilirse bir satıcının tüm ürünleri tek shard'a düşer; sorguda da aynı routing verilince sadece o shard'a gidilir. Bedelleri: büyük satıcılar hot shard yaratır (shard boyutları dengesizleşir), routing değeri değişirse (ürün başka satıcıya geçerse) doküman eski shard'dan silinip yenisine yazılmalıdır, aksi halde aynı _id iki shard'da kalır. ES routing_partition_size ile bir routing değerini birden çok shard'a yayarak hot shard'ı hafifletebilir.

Eşzamanlılık: Her dokümanın _seq_no ve _primary_term değerleri vardır; if_seq_no ve if_primary_term ile optimistic concurrency yapılır. Dış bir kaynaktan senkronize ederken external versioning (version_type=external) daha kullanışlıdır: dokümanı kaynak sistemdeki versiyonla yazarsın ve ES, mevcut versiyondan küçük ya da eşit bir versiyonla gelen yazmayı reddeder. Böylece sıra dışı gelen eski bir güncelleme yeni veriyi ezemez.

Elasticsearch neden birincil veri deposu olmaz

  • Transaction yok. Birden çok dokümanı atomik değiştiremezsin; "stok düş, sipariş yaz" gibi bir akış ES'de kurulamaz.
  • Near real-time. Yazdığın hemen aranamaz; read-your-writes için ek mekanizma gerekir.
  • Şema değişikliği reindex demektir. Alan tipi ya da analyzer değişikliği tüm verinin yeniden yazılmasını gerektirir; kaynak veri başka yerde değilse reindex için ES'nin kendi _source'una güvenirsin.
  • Yazma maliyeti. Her yazma analiz, segment oluşturma ve arka planda segment birleştirme (merge) maliyeti taşır; güncelleme aslında sil + yeniden ekle'dir.
  • Operasyonel risk. Mapping explosion, split-brain'e karşı yanlış yapılandırma (eski sürümlerde), heap baskısı gibi durumlarda veriyi bir kaynaktan yeniden kurabilmek büyük güvencedir.

Doğru konumlandırma: ES türetilmiş bir read model'dir (CQRS'teki okuma tarafı). Doğruluk kaynağı Couchbase ya da PostgreSQL'dir; ES her an bu kaynaktan yeniden kurulabilir olmalıdır. Log ve gözlemlenebilirlik verisi (kaybı kabul edilebilir, append-only) bu kuralın bilinen istisnasıdır.

İki deponun senkronizasyonu

Dual write problemi. En naif çözüm: uygulama önce Couchbase'e, sonra ES'ye yazar.

java
productRepository.save(product);        // 1) Couchbase
searchClient.index(toDocument(product)); // 2) Elasticsearch

Bu kodun failure mode'ları:

  • 1 başarılı, 2 başarısız (ES timeout, pod restart): ürün kaynakta güncel, aramada eski. Kimse fark etmez; hata kalıcıdır.
  • 2'yi başarısız sanıp tekrar denemek: aslında yazılmıştı, sorun yok (idempotent); ama 1'i geri almak için compensating write gerekir ve o da başarısız olabilir.
  • Sıra yarışı: İki eşzamanlı güncelleme Couchbase'e A sonra B sırasıyla, ES'ye ağ gecikmesi yüzünden B sonra A sırasıyla ulaşır. Kaynakta B, aramada A kalır. Retry'lar bu yarışı daha da olası yapar.
  • İki sistem arasında dağıtık transaction yok; 2PC'yi ne ES ne de çoğu NoSQL desteklemez.

Çözüm: tek yere yaz, değişikliği akıştan türet. Uygulama sadece doğruluk kaynağına yazar; diğer depolar kaynağın değişiklik akışından güncellenir.

  • CDC (Change Data Capture): Veritabanının kendi değişiklik log'u okunur. Couchbase'de DCP (Couchbase Elasticsearch Connector ya da Kafka Source Connector), PostgreSQL'de WAL üzerinden logical decoding (Debezium). Uygulama koduna dokunmaz, commit edilen her değişiklik commit sırasıyla yakalanır.
  • Transactional outbox: PostgreSQL gibi transaction'lı bir kaynakta iş verisi ile "event" aynı transaction'da bir outbox tablosuna yazılır; ayrı bir relay (polling ya da CDC) event'i Kafka'ya taşır, consumer ES'yi günceller (bkz. Bölüm 13 ve flash sale projesi).
  • Domain event'leri: ProductPriceChanged gibi anlamlı event'ler Kafka'ya yayınlanır, arama servisi kendi read model'ini kurar. Event'in güvenilir yayını yine outbox ya da CDC gerektirir.

Bu yaklaşımda dikkat edilecekler:

  1. Eventual consistency kabul edilir ve ölçülür. Kaynak ile arama arasındaki gecikme (lag) bir SLO olur: "fiyat değişikliği p99 5 saniyede aramada". Connector/consumer lag'i metrik olarak izlenir. Ürün detay sayfası ve sepet gibi doğruluk gereken ekranlar kaynaktan okur; sepete eklerken fiyat ve stok kaynakta yeniden doğrulanır.
  2. Sıra ve idempotency. At-least-once teslimatta aynı değişiklik birden fazla gelebilir, yeniden denemelerde sıra bozulabilir. Aynı dokümanın değişiklikleri aynı Kafka partition'ına (key = productId) gider; ES'ye yazarken kaynaktaki versiyon (Couchbase CAS/seqno, PostgreSQL'de bir version kolonu) version_type=external ile verilir, böylece eski versiyon yeniyi ezemez. Silme de bir event'tir (tombstone).
  3. Zenginleştirme. Arama dokümanı genelde birden fazla kaynağı birleştirir (ürün + satıcı puanı + stok durumu + kampanya). Her kaynağın değişikliği dokümanı kısmi günceller ya da consumer kaynaklardan son hali okuyup tam dokümanı yazar. İkincisi daha basit ve sıra hatalarına daha dayanıklıdır.
  4. Reindex ve alias. Mapping değişikliği ya da bozulma durumunda sıfırdan kurulum: yeni index (products_v2) oluşturulur, kaynaktan toplu yüklenir, bu sırada gelen değişiklikler de yeni index'e uygulanır (ya da akış yükleme başlangıcından itibaren yeniden oynatılır), sonra okuma alias'ı (products) atomik olarak yeni index'e çevrilir. Uygulama hep alias'a sorgu atar; index adını bilmez. Kafka'da akışın yeterince geriye dönük saklanması ya da compacted topic bu senaryoyu kolaylaştırır.
  5. Mutabakat (reconciliation). Hiçbir senkronizasyon mekanizması yüzde yüz değildir. Periyodik bir job kaynak ile ES'yi karşılaştırır (sayılar, son güncelleme zamanları, örneklenmiş dokümanların hash'i) ve sapmaları yeniden index'ler.

CAP teoremi: doğru anlatım

CAP, Eric Brewer'ın 2000'de ortaya attığı ve 2002'de Gilbert ve Lynch'in ispatladığı bir sonuçtur. Üç kavram:

  • Consistency (C): Burada kastedilen linearizability'dir: sistem tek bir kopya varmış gibi davranır; bir yazma tamamlandıktan sonra başlayan her okuma o yazmayı (ya da daha yenisini) görür. ACID'deki C ile (constraint ve invariant'ların korunması) aynı şey değildir.
  • Availability (A): Çalışan (çökmemiş) her node'a gelen her istek, hata olmayan bir cevap alır. Bu çok katı bir tanımdır: "yüzde 99,99 uptime" ile aynı şey değildir.
  • Partition tolerance (P): Node'lar arasındaki mesajlar kaybolsa ya da gecikse (ağ bölünmesi) sistem çalışmaya devam eder.

Teoremin söylediği: Ağ bölünmesi olduğunda bir sistem hem linearizable hem de (bu katı anlamda) available olamaz. Bölünmenin bir tarafındaki node, diğer taraftaki yazmayı göremez; gelen okumaya ya cevap verir (A'yı seçer, bayat olabilir) ya da cevap vermez/hata döner (C'yi seçer).

"Üçünden ikisini seç" yanılgısı: Bu formülasyon sanki P'den vazgeçip "CA" bir dağıtık sistem kurulabilirmiş gibi okunur. Ağ üzerinden haberleşen bir sistemde bölünme olur: switch arızası, GC duraklaması yüzünden zaman aşımına uğrayan node, yanlış yapılandırılmış güvenlik duvarı. P bir seçenek değil, bir gerçektir. Asıl seçim bölünme sırasında C mi A mı'dır. "CA" sadece tek node'lu bir sistem (ya da bölünmeyi hiç yaşamayacağı varsayılan bir sistem) için anlamlıdır. Brewer'ın 2012 tarihli "CAP Twelve Years Later" yazısı da "ikisini seç" formülasyonunun baştan beri yanıltıcı olduğunu, gerilimleri fazla basitleştirdiğini söyler.

CAP'in sınırlarını da bilmek gerekir: Sadece tek bir tutarlılık modelini (linearizability) ve tek bir hata türünü (ağ bölünmesi) ele alır; gecikmeden, node çökmelerinden, disk kaybından bahsetmez. Bölünme yokken sistemin nasıl davrandığı hakkında hiçbir şey söylemez. Bu boşluğu PACELC doldurur.

PACELC

Daniel Abadi'nin önerdiği genişletme: Partition varsa A ile C arasında seçim; Else (bölünme yokken, normal çalışmada) Latency ile C arasında seçim.

İkinci yarı günlük hayatın gerçeğidir. Bölünme nadirdir ama replikasyon her yazmada vardır:

  • Senkron replikasyon: Yazma, replikalar onaylayana kadar dönmez. Tüm replikalar tutarlıdır (C), ama her yazma en yavaş replikanın gecikmesini öder (L).
  • Asenkron replikasyon: Yazma lider onaylayınca döner, replikalar sonra yetişir. Hızlıdır (L), ama replikadan okuyan bayat veri görebilir ve lider çökerse onaylanmış son yazmalar kaybolabilir.

Abadi'nin sınıflandırmasında Dynamo tarzı sistemler (Cassandra, Riak) PA/EL'dir: bölünmede erişilebilirlik, normalde düşük gecikme. Tam ACID dağıtık sistemler (ör. VoltDB) PC/EC'dir.

BASE ve ACID

BASE: Basically Available, Soft state, Eventually consistent. ACID'in tersine bir tasarım felsefesidir: sistem her zaman bir cevap verir (bazen eksik ya da bayat), durum dışarıdan bir giriş olmasa da zamanla değişebilir (replikalar yakınsadıkça), ve yeni yazma gelmezse tüm kopyalar sonunda aynı değere ulaşır.

Eventual consistency zayıf bir garantidir: "sonunda" ne zaman olduğunu ve arada ne görüleceğini söylemez. Pratikte sistemler bunun üstüne oturum garantileri ekler:

  • Read-your-writes: Kullanıcı kendi yazdığını hemen görür (yazmadan sonraki okumayı lidere/aynı replikaya yönlendirerek).
  • Monotonic reads: Kullanıcı bir kez yeni değeri gördükten sonra eskisini görmez (aynı replikaya yapışkan yönlendirme).
  • Causal consistency: Nedensel olarak bağlı işlemler (yorum ve ona cevap) herkese aynı sırayla görünür.

Tutarlılık modelleri güçlüden zayıfa: linearizability, sequential, causal, oturum garantileri (read-your-writes, monotonic reads), eventual.

Bir e-ticaret sisteminde ACID ve BASE bir arada yaşar: stok düşümü ve ödeme ACID (PostgreSQL, atomik UPDATE), arama ve öneriler BASE (ES, cache).

Gerçek sistemleri sınıflandırmak: neden "CP mi AP mi" yanlış soru

Martin Kleppmann'ın "Please stop calling databases CP or AP" yazısının ana fikri: Gerçek veritabanlarının çoğu CAP'in katı tanımlarına göre ne CP ne AP'dir, ve davranışları yapılandırmaya ve işlem bazında seçime göre değişir. Örnekler:

  • PostgreSQL primary + async replica: Primary'den okuma linearizable'dır (tek kopya). Replica'dan okuma bayat olabilir; bu ne C'dir ne de katı A (primary'ye ulaşamayan bir istemci yazamaz). Yani "CP" etiketi sadece primary'ye giden işlemler için anlamlıdır.
  • Couchbase (tek cluster içinde): Her vBucket'ın tek aktif kopyası vardır ve KV yazma/okumaları ona gider; aktif node erişilemezse o vBucket'lar failover olana kadar yazma kabul etmez. Bu CP'ye yakın bir davranıştır. Aynı cluster'da GSI sorguları not_bounded ile eventually consistent'tir, replica read API'si bayat veri döndürebilir, XDCR ile bağlı iki cluster arası ise açıkça AP'dir (çakışmalar sonradan çözülür).
  • Elasticsearch: Yazma primary shard üzerinden replica'lara gider, ama arama near real-time olduğu için kendi yazdığını bile hemen göremeyebilirsin; linearizable değildir.
  • Redis Cluster: Replikasyon asenkrondur; failover sırasında master'ın onayladığı ama replikaya ulaşmamış yazmalar kaybolabilir. Güçlü tutarlılık garanti etmez.
  • Kafka: acks=all, min.insync.replicas ve unclean leader election ayarlarıyla tutarlılık ile erişilebilirlik arasında yapılandırılabilir bir noktada durur (bkz. Bölüm 12).
  • Cassandra/Dynamo tarzı: Sorgu başına tutarlılık seviyesi seçilir. N replika, W yazma onayı, R okuma onayı için W + R > N ise okuma ve yazma kümeleri en az bir replikada kesişir ve okuma en son onaylanmış yazmayı içeren bir replikaya mutlaka ulaşır (QUORUM/QUORUM). Bu bile tek başına linearizability sayılmaz (eşzamanlı yazmalar ve okuma onarımı ayrıntıları yüzünden).

Mülakatta doğru cevap kalıbı: "Sistemi tek etiketle değil, işlem bazında sınıflandırırım: hangi işlem bölünmede ne yapar, normal çalışmada hangi gecikmeyi öder, kullanıcı hangi anomaliyi görebilir."

Redis: cache-aside, TTL ve eviction

Redis bellek içi bir veri yapıları sunucusudur (string, hash, list, set, sorted set, stream...). Komutlar tek bir ana thread'de sırayla yürütülür (Redis 6.0+ ağ I/O'su için ek thread kullanabilir ama komut yürütme tek thread'dir); bu yüzden tek komutlar ve Lua script'leri atomiktir. Flash sale projesindeki decrease_stock.lua bunu kullanır: GET + kontrol + DECRBY araya başka komut girmeden çalışır.

Cache-aside (lazy loading) en yaygın kalıptır:

java
public ProductView getProduct(long id) {
    String key = "product:v3:" + id;
    ProductView cached = cache.get(key);
    if (cached != null) {
        return cached;
    }
    ProductView fresh = productRepository.findView(id);         // kaynak
    cache.set(key, fresh, Duration.ofMinutes(10).plusSeconds(random.nextInt(60)));  // TTL + jitter
    return fresh;
}

Okuma önce cache'e bakar; yoksa kaynaktan okuyup cache'e yazar. Alternatifleri: read-through (cache katmanı kaynaktan kendisi okur), write-through (yazma cache ve kaynağa birlikte), write-behind (yazma önce cache'e, kaynağa asenkron; hızlı ama cache kaybında veri kaybı).

TTL (time to live): Her anahtara bir yaşam süresi verilir. TTL hem bellek yönetimi hem de tutarlılık sigortasıdır: invalidation bir şekilde kaçırılsa bile bayat veri en fazla TTL kadar yaşar. Redis süresi dolan anahtarları hem erişildiğinde (lazy) hem de periyodik örnekleme ile siler.

Eviction: maxmemory dolunca ne olacağını maxmemory-policy belirler. Varsayılan noeviction'dır: yeni veri yazan komutlar hata alır. Cache olarak kullanılan bir Redis'te bu genelde istenmez; allkeys-lru ya da allkeys-lfu seçilir. volatile-* politikaları sadece TTL'li anahtarları siler. Redis'in LRU'su kesin değil, örneklemeye dayalı bir yaklaşıktır (maxmemory-samples). Aynı Redis'te hem cache hem de silinmemesi gereken veri (sepet, oturum) tutuluyorsa eviction politikası ikisini de etkiler; bu yüzden bunlar ayrı instance'lara ayrılır.

Cache problemleri ve çözümleri:

  • Cache stampede (thundering herd): Çok okunan bir anahtarın (kampanya ürünü) TTL'i dolduğu anda yüzlerce istek aynı anda cache'i boş bulur ve hepsi kaynağa gider; veritabanı çöker. Çözümler: anahtar başına tek yükleyici (uygulama içinde single-flight, ya da Redis'te kısa süreli SET lock NX PX ile sadece birinin yüklemesi, diğerlerinin kısa bekleyip tekrar denemesi ya da eski değeri dönmesi); TTL dolmadan arka planda yenileme (refresh-ahead, olasılıksal erken yenileme); hiç expire olmayan değer + arka planda yenileme.
  • Cache avalanche: Çok sayıda anahtar aynı anda expire olur (hepsi deploy'da aynı TTL ile yüklendi). Çözüm TTL'e rastgele jitter eklemek.
  • Cache penetration: Var olmayan id'ler için gelen istekler (bot, hatalı link) her seferinde cache'i ıskalayıp kaynağa gider. Çözüm: "yok" sonucunu da kısa TTL ile cache'lemek (negative caching) ya da Bloom filter.
  • Hot key: Tek bir anahtar, Redis Cluster'da tek bir node'a yük bindirir. Çözüm: uygulama içinde kısa ömürlü yerel cache (Caffeine) ile iki katmanlı cache, ya da anahtarı kopyalayarak dağıtmak.

Redis ile distributed lock uyarıları. Basit kilit: SET lock:job "<rastgele-token>" NX PX 30000; bırakırken token'ı kontrol eden bir Lua script'iyle silinir (başkasının kilidini silmemek için). Sorun: Kilidi alan süreç uzun bir GC duraklamasına girerse TTL dolar, başka bir süreç kilidi alır ve ilk süreç uyanınca kilidi hâlâ tuttuğunu sanarak yazar. Redis replikasyonu asenkron olduğu için master failover'ında kilit hiç kopyalanmamış da olabilir. Martin Kleppmann'ın Redlock eleştirisinin özü budur: zaman varsayımlarına dayanan bir kilit doğruluk garantisi veremez. Doğruluk gerekiyorsa korunan kaynak fencing token (kilit her verildiğinde artan bir sayı) kontrol etmelidir: depolama, daha küçük token'lı yazmayı reddeder. Pratik kural: Redis kilidini verimlilik için kullan (aynı işi iki kez yapmamak, ikinci kez yapılması zararsızsa); doğruluk için veritabanı garantilerine (atomik UPDATE, unique constraint, SELECT FOR UPDATE, advisory lock) güven. Flash sale projesinin Redlock yerine atomik Lua script kullanmasının nedeni de budur: kilide hiç gerek yoktur.

Cache tutarlılığı ve invalidation

"Bilgisayar biliminde iki zor şey vardır: cache invalidation ve isimlendirme" esprisinin teknik karşılığı şudur: cache ile kaynak iki ayrı sistemdir ve aralarında transaction yoktur. Dual write probleminin aynısıdır.

Güncelle değil, sil. Kaynak güncellenince cache'i yeni değerle güncellemek yerine anahtarı silmek tercih edilir. Güncellemede iki eşzamanlı yazma cache'e ters sırayla ulaşırsa cache kalıcı olarak eski değerde kalır; silmede bir sonraki okuma kaynaktan doğru değeri yükler.

Sıra: önce kaynağa commit, sonra cache'i sil. Ters sırada (önce sil, sonra DB'ye yaz) silme ile commit arasındaki boşlukta gelen bir okuma eski değeri DB'den okuyup cache'e geri yazar. Spring'de silmeyi commit sonrasına bağlamak için @TransactionalEventListener(phase = AFTER_COMMIT) kullanılır.

Kalan yarış. "Commit sonrası sil" bile yarışsız değildir: Okuyucu A cache'i ıskalar ve DB'den eski değeri okur; yazıcı B DB'yi günceller ve cache'i siler; A, okuduğu eski değeri cache'e yazar. Bayat değer TTL dolana kadar kalır. Bu pencere dardır (A'nın okuma ile cache yazması arası) ama yüksek trafikte olur. Hafifletmeler: kısa TTL (her zaman sigorta), gecikmeli ikinci silme ("delayed double delete": birkaç yüz ms sonra bir kez daha sil), versiyonlu değerler (cache'e yazarken daha yeni versiyon varsa yazmamak) ya da invalidation'ı CDC'den türetmek (değişiklik akışını dinleyen bir consumer anahtarları siler; uygulama kodundaki her yazma yolunu hatırlamak gerekmez).

Anahtar versiyonlama. Cache'teki nesnenin şekli değiştiğinde (ProductView'a yeni alan eklendi) eski ve yeni pod'lar aynı anahtarı farklı biçimlerde okur/yazar. Anahtara şema versiyonu eklemek (product:v3:123) deploy sırasında bu çakışmayı önler.

Yerel cache. Uygulama içi cache (Caffeine) ağ gidiş-dönüşü olmadığı için en hızlısıdır ama her pod'un kendi kopyası vardır; invalidation tüm pod'lara yayılmalıdır (Redis pub/sub, Kafka) ya da TTL çok kısa tutulmalıdır. Konfigürasyon, kategori ağacı gibi az değişen veriler için idealdir.

Neyi cache'lemem: stok adedi ve fiyat gibi kararın doğruluğunu etkileyen veriler, karar anında (sepete ekleme, ödeme) kaynaktan doğrulanır. Listeleme sayfasında birkaç saniyelik bayat stok bilgisi kabul edilebilir, ödeme anında edilemez.

Ne zaman SQL, ne zaman NoSQL

Soruİlişkisel (PostgreSQL)Doküman/KV (Couchbase)Arama (Elasticsearch)Cache (Redis)
Çok tablolu atomik işlem gerekiyor mu?Evet, doğalTek dokümanda doğal; çok dokümanlı transaction maliyetliHayırHayır (Lua ile tek node'da atomik)
Erişim deseniEsnek, ad-hoc sorgu, join, raporKey ile okuma, önceden bilinen desenlerTam metin, facet, relevanceKey ile, çok sıcak veri
ŞemaSıkı, migration ile değişirEsnek, uygulama yönetirMapping sıkı, değişim reindexYok
Yatay ölçekZor (read replica, partitioning, sharding)Doğal (vBucket)Doğal (shard)Doğal (Cluster, slot)
Doğruluk kaynağı olur mu?EvetEvetHayırHayır (kalıcılık ayarlanabilir ama cache olarak düşün)

Karar sırası: önce erişim desenleri ve tutarlılık gereksinimleri yazılır, sonra araç seçilir. Para, stok, sipariş durumu gibi invariant'ı olan veri için varsayılan seçim transaction'lı ilişkisel veritabanıdır. Ürün kataloğu, kullanıcı profili, sepet gibi anahtarla erişilen, şeması sık değişen ve çok okunan veriler için doküman deposu mantıklıdır. "NoSQL daha hızlı" genellemesi yanlıştır: hız, erişim desenine uygun modelden gelir.

Quiz'deki sorular: çözüm ve nedenleri

q11: Ürün verisi Couchbase'de, arama Elasticsearch'te

Senaryo. Ürün detayı Couchbase'de, arama ve filtreleme ES'de; ikisi bir DCP connector'ı ile senkron tutuluyor.

Doğru seçenekler: a, c, d, f.

  • a) KV erişim SQL++'tan çok daha hızlıdır; sık okunan veri key tasarımıyla KV'den okunmalı. Doğru. KV isteği key'in CRC32 hash'iyle vBucket'ı, cluster map ile aktif node'u bulur ve doğrudan bellekten döner. SQL++ ise Query servisinde planlanır, GSI'den key'ler bulunur, sonra dokümanlar Data servisinden çekilir; ek ağ adımları ve planlama maliyeti vardır. Ayrıca GSI varsayılan olarak eventually consistent'tir.
  • c) Primary shard sayısı sonradan doğrudan değiştirilemez; replica sayısı anında değiştirilebilir. Doğru. Routing hash(_routing) % primary_shard_sayısına dayanır; sayı değişirse dokümanların yeri değişir. _split, _shrink ya da reindex gerekir. Replica sayısı dinamik bir ayardır. Pratikte değişiklik yeni index + alias ile yapılır.
  • d) Türkçe için uygun lowercase/analyzer seçilmeli. Doğru. Dile duyarsız lowercase "I"yı "i"ye çevirir; lowercase filter'ında "language": "turkish" ya da hazır turkish analyzer kullanılır.
  • f) Custom routing (sellerId) ile satıcı sorguları tek shard'a gider. Doğru. Bedeli hot shard riski ve routing değeri değiştiğinde eski shard'daki kopyanın silinmesi gerekliliğidir.

Yanlış seçenekler:

  • b) ES yazılan dokümanı index isteği döndüğü an aramada kesin gösterir. Yanlış. ES near real-time'dır; doküman refresh ile aranabilir olur. Varsayılan refresh_interval 1 saniyedir, ama ayar açıkça verilmemişse 30 saniyedir arama almayan shard'lar arka planda refresh edilmez (search idle) ve ilk aramada refresh beklenir. Quiz açıklaması sadece 1 saniyelik varsayılanı veriyor; search idle davranışı yüzünden gecikme 1 saniyeden uzun olabilir. GET by id realtime'dır.
  • e) ES birincil veritabanı olarak kullanılmalı. Yanlış. Transaction yok, near real-time, mapping değişikliği reindex ister, yazma maliyetlidir. ES türetilmiş bir read model'dir; kaynaktan her an yeniden kurulabilmelidir.

Ana kavram ve production karşılığı: Doğruluk kaynağı Couchbase, ES ondan türetilmiş ve geride kalabilen bir görünümdür. Senaryoda iki tutarlılık sınırı var: Couchbase içinde (KV aktif kopyadan okursa kendi yazdığını görür, GSI sorgusu görmeyebilir) ve Couchbase ile ES arasında (DCP connector lag'i + refresh). Production'da connector lag'i bir SLO olarak izlenir, ES'ye kaynak versiyonuyla (external versioning) yazılır ki tekrar ya da sıra dışı gelen değişiklik yeniyi ezmesin, periyodik mutabakat job'ı sapmaları düzeltir. Ürün detayı ve sepet kaynaktan okunur; arama sonuç sayfasında birkaç saniyelik bayatlık kabul edilir.

j28: CAP teoremi ve gerçek sistemler

Senaryo. Ürün verisi PostgreSQL'de (primary + async replica), arama Elasticsearch'te (Kafka ile güncelleniyor), sepet Redis Cluster'da. Mülakatçı: "Bu sistem CP mi, AP mi?"

Doğru seçenekler: a, c, d, e, g.

  • a) Partition olduğunda tutarlılık ile erişilebilirlik arasında seçim yapılır. Doğru. Teoremin doğru ifadesi budur; seçim bölünme sırasındadır.
  • c) PACELC: partition varsa A/C, yoksa latency/consistency. Doğru. Senkron replikasyon tutarlılık verir ama her yazmaya gecikme ekler; async hızlıdır ama bayat okuma ve failover'da kayıp riski taşır.
  • d) BASE tanımı. Doğru. Basically Available, Soft state, Eventually consistent.
  • e) Kafka ile beslenen ES eventual consistency örneğidir. Doğru. Kafka consumer lag'i + refresh süresi kadar arama eski hali gösterebilir.
  • g) Async replica'dan okuyan servis kullanıcının az önce yazdığını göremeyebilir; kritik okumalar primary'ye yönlendirilir. Doğru. Replication lag yüzünden read-your-writes ihlali.

Yanlış seçenekler:

  • b) "CA" dağıtık sistem için gerçekçi ve yaygın bir seçimdir. Yanlış. Ağ üzerinden haberleşen sistemde bölünme kaçınılmazdır; CA sadece tek node'lu sistemde anlamlıdır.
  • f) CAP'teki C ile ACID'deki C aynıdır. Yanlış. CAP'teki C linearizability, ACID'deki C constraint ve invariant'ların korunmasıdır.

Sorunun asıl cevabı: "Bu sistem CP mi AP mi?" sorusu tek etiketle cevaplanamaz; senior cevap soruyu işlem bazına indirir:

  • Stok düşümü, ödeme, sipariş oluşturma: PostgreSQL primary, atomik UPDATE, transaction. Primary erişilemezse bu işlemler hata döner: bilinçli olarak C seçilir.
  • Arama, listeleme, öneri: ES, eventually consistent, bölünmede bile cevap verir: A ve düşük gecikme seçilir.
  • Sepet (Redis Cluster): Hızlı ve erişilebilir; failover'da son yazmalar kaybolabilir. Bu yüzden sepet sipariş anında kaynakta (fiyat, stok, kampanya) yeniden doğrulanır.
  • Siparişlerim sayfası: Sipariş verdikten hemen sonra primary'den (ya da kısa bir süre primary'ye yapışkan yönlendirmeyle) okunur, sonra replica'dan.

Quiz'deki öğrenme notunda tutarlılık modelleri sıralamasında "sequential" ile "causal" arasındaki ilişki ve quorum formülü doğru verilmiş; bir ekleme: W + R > N kesişimi tek başına linearizability garantisi vermez, eşzamanlı yazmalar ve okuma onarımı gibi ayrıntılar yüzünden quorum sistemleri de linearizable olmayan davranışlar gösterebilir.

Üç seviyede cevap

Soru: "Bizim sistemimiz CP mi AP mi?"

CAP'e göre üçünden ikisini seçebiliriz. Bizde PostgreSQL var, o CA'dır; Elasticsearch ve Redis AP'dir. Yani sistem genel olarak AP.

Dağıtık sistemde partition kaçınılmaz, o yüzden asıl seçim partition sırasında C ile A arasında. PostgreSQL primary'ye yazdığımız sürece tutarlıyız ama primary düşerse yazamayız, o kısım CP. ES ve Redis eventual consistent, AP tarafında. Async replica'dan okuma da bayat olabilir.

Tek etiket vermem, çünkü CAP'in C'si linearizability, A'sı da "her çalışan node her isteğe cevap verir" gibi katı bir tanım; çoğu gerçek sistem ikisine de tam uymaz. İşlem bazında anlatırım: stok ve ödeme primary PostgreSQL'de, bölünmede hata dönmeyi seçiyoruz; arama ES'de, bölünmede bile cevap veriyor ama Kafka lag'i ve refresh kadar geride; sepet Redis'te, failover'da onaylanmış yazma kaybolabileceği için sipariş anında kaynakta yeniden doğruluyoruz. PACELC ile normal çalışmayı da söylerim: async replica ile gecikme için tutarlılıktan vazgeçtik, bu yüzden "siparişlerim" gibi read-your-writes gereken okumalar primary'ye gidiyor. Bunu ölçülebilir kılarım: replication lag, consumer lag ve ES refresh gecikmesi için SLO ve alarm. Netleştirme sorusu da sorarım: "Hangi işlem için soruyorsunuz, hangi hata senaryosunda?"

Junior iki yaygın yanılgıyı birden yapıyor: "üçünden ikisi" ve PostgreSQL'i "CA" sınıflandırmak. Mid teoremin doğru ifadesini biliyor ve bileşen bazında düşünüyor. Senior terimlerin katı tanımlarını, işlem bazında kararları, PACELC'i, her kararın kullanıcıya etkisini ve ölçümünü veriyor; soruyu netleştiriyor.

Takip sorusu: "Sipariş verdikten hemen sonra kullanıcı 'siparişlerim' sayfasında siparişini göremiyor. Neden olabilir, nasıl çözersin?" (Replica lag ya da ES lag; read-your-writes için primary'ye yönlendirme, yazma sonrası kısa süre yapışkan okuma ya da istemciye dönen veriyle ekranı güncelleme.)

Soru: "Ürün güncellemelerini Couchbase'den Elasticsearch'e nasıl senkron tutarsın?"

Ürünü kaydeden servis önce Couchbase'e, sonra Elasticsearch'e yazar. Hata olursa try-catch ile tekrar deneriz.

Dual write yapmam, çünkü ikinci yazma başarısız olursa iki sistem tutarsız kalır. Couchbase'in DCP akışını okuyan bir connector ile değişiklikleri ES'ye aktarırım, ya da değişiklikleri Kafka'ya event olarak yayınlayıp bir consumer ile ES'yi güncellerim. Sistem eventually consistent olur, arama birkaç saniye geride kalabilir.

Uygulama sadece doğruluk kaynağına yazar; ES değişiklik akışından türetilir: DCP connector ya da DCP'den Kafka'ya, oradan bir consumer. Dual write'ı reddetme sebebim sadece kısmi hata değil, sıra yarışı da: iki eşzamanlı güncelleme ES'ye ters sırayla ulaşırsa arama kalıcı olarak eski kalır. Akış tarafında üç garanti kurarım: aynı ürünün değişiklikleri aynı partition'da (key = productId), ES'ye kaynak versiyonuyla version_type=external yazma (eski versiyon yeniyi ezemez), idempotent işleme (at-least-once). Silmeler tombstone olarak akar. Index'e alias üzerinden erişirim; mapping değişince yeni index'i kaynaktan doldurup akışı yetiştirir, alias'ı atomik çeviririm. Lag'i SLO olarak ölçerim ve periyodik bir mutabakat job'ı ile sapmaları yeniden index'lerim. Ürün detayı ve sepet ES'den değil, kaynaktan okunur.

Junior'ın try-catch'i kısmi hatayı sadece kısmen çözüyor; ikinci yazma kalıcı başarısız olursa ya da sıra bozulursa tutarsızlık kalıyor. Mid doğru mimariyi biliyor. Senior sıra yarışını, versiyonlu yazmayı, silme ve reindex'i, ölçüm ve mutabakatı ekleyerek gerçek bir production tasarımı anlatıyor.

Takip sorusu: "Connector bir saat boyunca durdu. Geri geldiğinde ne olur, ne yaparsın?" (DCP/Kafka offset'inden kaldığı yerden devam eder; akış o kadar geriye saklanmadıysa tam reindex. O süre boyunca lag alarmı çalmalıydı.)

Soru: "Ürün detayını Redis'te cache'liyoruz. Fiyat değişince kullanıcılar eski fiyatı görüyor. Ne yaparsın?"

TTL'i kısaltırım, 1 dakika yaparım. Ya da fiyat değişince cache'i yeni fiyatla güncellerim.

Fiyat güncellendiğinde cache'teki anahtarı silerim; bir sonraki okuma DB'den güncel değeri yükler. Silmeyi DB commit'inden sonra yaparım, önce silersem araya giren bir okuma eski değeri cache'e geri yazabilir. TTL'i de sigorta olarak tutarım.

Güncellemek yerine silerim, çünkü iki eşzamanlı yazma cache'e ters sırayla ulaşırsa güncelleme yaklaşımı cache'i kalıcı olarak eski bırakır. Silmeyi commit sonrasına bağlarım (@TransactionalEventListener(AFTER_COMMIT)) ya da daha iyisi CDC'den türetirim, böylece fiyatı değiştiren her kod yolu (admin paneli, toplu import, kampanya job'ı) otomatik kapsanır. Okuma ile cache yazma arasındaki küçük yarış penceresi için TTL sigortası ve gerekirse gecikmeli ikinci silme. Asıl kararı ise iş tarafıyla netleştiririm: listelemede birkaç saniyelik eski fiyat kabul edilebilir mi? Ödeme ve sepet adımında fiyat zaten cache'ten değil, kaynaktan okunmalı; aksi halde bu bir tutarlılık değil, para hatasıdır. Gözlemlemek için cache hit oranını ve "cache'teki versiyon ile kaynak versiyonu" farkını örnekleyen bir metrik eklerim.

Junior iki çözüm önerisinde de yanılıyor: kısa TTL sorunu küçültür ama çözmez, cache'i güncellemek ise sıra yarışı yaratır. Mid doğru kalıbı ve sıralamayı biliyor. Senior yarışın mekanizmasını, tüm yazma yollarını kapsayan CDC'yi, iş gereksinimini netleştirmeyi ve kararın kritik akıştaki sonucunu (ödeme kaynaktan) anlatıyor.

Takip sorusu: "Kampanya başladığı an en popüler ürünün cache'i boşalırsa ne olur?" (Cache stampede; tek yükleyici, refresh-ahead, TTL jitter.)

Soru: "Elasticsearch zaten verimizi tutuyor. Couchbase'i kaldırıp ES'yi tek veritabanı yapalım mı?"

Olabilir, ES hem arama yapıyor hem de veriyi saklıyor, iki sistemi yönetmekten kurtuluruz.

Önermem. ES'de transaction yok, near real-time olduğu için yazılan hemen okunmayabilir, mapping değişikliği reindex gerektirir. Birincil kaynak ayrı kalmalı, ES türetilmiş bir read model olmalı.

Önermem ve nedenini maliyet üzerinden anlatırım. Doğruluk tarafı: çok dokümanlı atomik işlem yok, read-your-writes için refresh beklemek gerekiyor, aggregation'lar shard bazında yaklaşık. Operasyon tarafı: ES'nin bozulduğu ya da mapping'in değişmesi gereken gün veriyi yeniden kuracak bir kaynağımız olmaz; reindex'i ES'nin kendi _source'undan yapmak zorunda kalırız, bozuk veriyi bozuk veriden kurarız. Yazma tarafı: her güncelleme sil-ekle ve segment merge maliyeti, yoğun güncellenen alanlar (stok) için pahalı. Yine de iki sistemin maliyeti gerçekse alternatifleri tartarım: belki Couchbase'in Search servisi küçük bir ihtiyaç için yeter, belki PostgreSQL full-text ve trigram ile arama ihtiyacı karşılanır. Kural şu: doğruluk kaynağı transaction'lı ve yeniden kurulabilir olmalı, arama motoru her an kaynaktan yeniden üretilebilen bir projeksiyon. ES'nin tek kaynak olduğu meşru durum log ve metrik gibi kaybı kabul edilebilir, append-only veridir.

Junior operasyonel basitliği görüyor ama doğruluk ve kurtarma maliyetini görmüyor. Mid temel teknik engelleri sayıyor. Senior bunları failure mode ve kurtarma senaryosu üzerinden anlatıyor, ihtiyacı karşılayabilecek daha basit alternatifleri tartıyor ve meşru istisnayı belirtiyor.

Takip sorusu: "Mapping'de bir alanın tipini keyword'den text'e çevirmemiz gerekti. Kesintisiz nasıl yaparsın?" (Yeni index, kaynaktan doldur + akışı yeni index'e de uygula, alias'ı atomik çevir, eski index'i sonra sil.)

Sorulmadı ama sorulabilir

Consistent hashing

Basit hash(key) % N dağıtımında node sayısı değişince neredeyse tüm anahtarların yeri değişir. Consistent hashing'de node'lar ve anahtarlar aynı hash halkasına yerleştirilir; bir anahtar halkada saat yönünde ilk node'a aittir. Node eklenince sadece komşu aralıktaki anahtarlar taşınır (ortalama 1/N). Sanal node'lar yükü dengeler. Couchbase (sabit sayıda vBucket + map) ve Redis Cluster (16384 sabit hash slot) aynı soruna "sabit sayıda mantıksal parça, parçaları node'lara dağıt" çözümünü uygular; Cassandra ve Dynamo tarzı sistemler halkayı kullanır. Tek cümle: "Node sayısı değişince taşınan veriyi sınırlamak için ya consistent hashing ya da sabit sayıda mantıksal parça kullanılır."

Redis'in e-ticaretteki diğer kullanımları

  • Rate limiting: INCR + EXPIRE ile sabit pencere, sorted set ya da Lua ile kayan pencere.
  • Leaderboard / en çok satanlar: Sorted set (ZINCRBY, ZREVRANGE), skor güncelleme ve ilk N okuma logaritmik.
  • Oturum ve sepet: Hash veri yapısı, TTL ile terk edilen sepetlerin temizlenmesi.
  • Idempotency anahtarı: SET key NX EX 86400 ile tekrar gelen isteği tespit (doğruluk kritikse DB'de unique constraint ile yedeklenir).

Tek cümle: "Redis'i sadece cache değil, atomik komutları olan bellek içi veri yapıları sunucusu olarak kullanırım; kalıcılık ve failover kaybını hesaba katarak."

Redis kalıcılığı: RDB ve AOF

RDB belirli aralıklarla anlık görüntü alır (hızlı yeniden başlatma, ama son görüntüden sonraki yazmalar kaybolur). AOF her yazma komutunu log'a ekler; appendfsync everysec ile en fazla yaklaşık bir saniyelik veri kaybı. İkisi birlikte kullanılabilir. Replikasyon asenkron olduğu için kalıcılık açık olsa bile failover'da onaylanmış yazma kaybolabilir; WAIT komutu replikaların onayını bekleyerek riski azaltır ama güçlü tutarlılık vermez. Tek cümle: "Redis'te kaybedilemeyecek veri tutuyorsam kalıcılığı ve kabul edilebilir kayıp penceresini açıkça tasarlarım; tutmuyorsam onu cache olarak görürüm."

Read-your-writes'ı sağlamak

Kullanıcının kendi yazdığını görmesi için yollar: yazmadan sonraki belirli bir süre o kullanıcının okumalarını primary'ye yönlendirmek; yazma sırasında dönen versiyonu (PostgreSQL'de LSN, Couchbase'de mutation token) istemcide tutup okumada "en az bu versiyonu görmüş replika/index" şartı koşmak (Couchbase'de at_plus scan consistency tam olarak bunu yapar); ya da istemci tarafında yazma cevabıyla ekranı güncellemek. Tek cümle: "Global güçlü tutarlılık yerine, kullanıcıya en çok batan oturum garantisini (read-your-writes) hedefli olarak sağlarım."

Elasticsearch'te relevance ve Türkçe arama kalitesi

İyi arama sadece doğru eşleşme değil, doğru sıralamadır. Araçlar: alan bazında ağırlık (name^3, brand^2, description), multi_match, synonym filter (eş anlamlılar), fuzzy matching (yazım hatası), function_score ile iş sinyalleri (satış sayısı, stok durumu, satıcı puanı). Türkçede kök bulma (stemming) agresif olabilir; ürün adı için hafif stemming + keyword alanında birebir eşleşmeye bonus yaygın bir dengedir. Değişikliklerin etkisi sezgiyle değil, arama sonrası tıklama/sepete ekleme oranı ve "sonuçsuz arama" oranı gibi metriklerle ölçülür. Tek cümle: "Relevance ayarını metrikle (CTR, sonuçsuz arama oranı) yönetirim, tek tek sorgulara göre değil."

Sık yapılan hatalar

  • CAP'i "üçünden ikisini seç" diye anlatmak → P bir seçenek değil, gerçektir; CA dağıtık sistemde anlamsızdır → "bölünme sırasında C ile A arasında seçim" de.
  • CAP C'si ile ACID C'sini karıştırmak → biri linearizability, diğeri constraint/invariant korunması → terimi tanımlayarak kullan.
  • Bir veritabanını tek kelimeyle CP/AP etiketlemek → davranış yapılandırmaya ve işleme göre değişir → işlem bazında anlat, PACELC'i ekle.
  • Dual write (önce DB, sonra ES/cache) → kısmi hata ve sıra yarışıyla kalıcı tutarsızlık → tek kaynağa yaz, CDC/outbox ile türet.
  • ES'ye kaynak versiyonu olmadan yazmak → sıra dışı ya da tekrar gelen eski değişiklik yeniyi ezer → version_type=external.
  • ES'yi birincil veri deposu yapmak → transaction yok, reindex için kaynak yok → ES'yi kaynaktan yeniden kurulabilen read model olarak konumla.
  • İndex isteğinden hemen sonra aramada görmeyi beklemek → NRT; refresh (ve search idle) → GET by id, refresh=wait_for ya da istemci tarafı güncelleme.
  • Primary shard sayısını "sonra artırırız" diye düşünmeden seçmek → sonradan değişmez, split/reindex gerekir → veri büyüklüğünü tahmin et, alias ile başla.
  • Dinamik mapping'e güvenmek → yanlış tipler, mapping explosion → açık mapping, dynamic: strict.
  • Türkçe metinde dile duyarsız lowercase → "IŞIK" ile "ışık" eşleşmez → turkish analyzer ya da language: turkish lowercase.
  • Couchbase'de sık okunan veriyi SQL++ ile okumak → ek katmanlar, eventually consistent GSI → key tasarımı ve KV get.
  • Couchbase'de primary index ile production sorgusu → tüm bucket'ı tarar → desene uygun GSI.
  • Cache'i silmek yerine güncellemek, ya da commit'ten önce silmek → yarışta kalıcı bayat değer → commit sonrası sil, TTL sigortası, gerekirse CDC.
  • Tüm anahtarlara aynı TTL → toplu expire, avalanche → TTL'e jitter ekle.
  • Redis kilidiyle doğruluk garantisi kurmak → GC duraklaması ve async failover kilidi ikiye böler → kilidi verimlilik için kullan, doğruluğu DB garantisi ya da fencing token ile sağla.
  • Cache olarak kullanılan Redis'i noeviction'da bırakmak → bellek dolunca yazmalar hata verir → allkeys-lru/allkeys-lfu, cache ile kalıcı veriyi ayrı instance'larda tut.

Hızlı tekrar

S: Couchbase bir dokümanın hangi node'da olduğunu nasıl bulur?

C: Key'in CRC32 hash'iyle vBucket'ı (Linux'ta genelde 1024'ten biri) bulur, cluster map'ten o vBucket'ın aktif node'unu bulur ve doğrudan ona bağlanır.

S: Couchbase'de KV get ile SQL++ sorgusunun farkı?

C: KV doğrudan aktif vBucket'ın belleğinden döner, kendi yazdığını görür. SQL++ Query ve Index servislerinden geçer, daha yavaştır ve GSI varsayılan olarak (not_bounded) eventually consistent'tir.

S: Couchbase'de optimistic locking nasıl yapılır?

C: Dokümanın CAS değeriyle: replace(key, doc, cas) doküman okunduğundan beri değiştiyse hata verir.

S: Inverted index nedir?

C: Terimden o terimi içeren dokümanların listesine giden dizin; tam metin aramanın temelidir. Terimleri analyzer (char filter, tokenizer, token filter) üretir.

S: text ile keyword alan farkı?

C: text analiz edilir, tam metin arama içindir. keyword analiz edilmez, birebir filtre, sıralama ve aggregation içindir.

S: ES neden near real-time'dır?

C: Doküman ancak refresh ile yeni segment açılınca aranabilir olur; varsayılan aralık 1 sn, ama arama almayan (search idle) shard'lar ilk aramaya kadar refresh edilmez. GET by id realtime'dır.

S: ES'de primary shard sayısı neden sonradan değişmez?

C: Doküman yeri hash(_routing) % primary_shard_sayısı ile belirlenir; sayı değişirse tüm dokümanların yeri değişir. _split/_shrink ya da reindex gerekir. Replica sayısı dinamiktir.

S: Dual write neden tehlikelidir?

C: İki sistem arasında transaction yoktur; ikinci yazma başarısız olursa ya da eşzamanlı yazmalar ters sırayla ulaşırsa tutarsızlık kalıcı olur. Çözüm: tek kaynağa yaz, CDC/outbox ile türet.

S: CAP teoremi tam olarak ne söyler?

C: Ağ bölünmesi sırasında bir sistem hem linearizable hem de her çalışan node'un her isteğe cevap verdiği anlamda available olamaz; bölünmede C ile A arasında seçim yapılır.

S: PACELC neyi ekler?

C: Bölünme yokken de gecikme ile tutarlılık arasında seçim olduğunu: senkron replikasyon tutarlı ama yavaş, asenkron hızlı ama bayat okuma riskli.

S: W + R > N ne sağlar?

C: Okuma ve yazma quorum'ları en az bir replikada kesişir, okuma en son onaylanmış yazmayı içeren bir replikaya ulaşır. Tek başına linearizability garantisi değildir.

S: Cache invalidation'da neden güncelle değil sil, ve neden commit'ten sonra?

C: Güncellemede eşzamanlı yazmalar ters sırayla gelince cache kalıcı olarak eski kalır. Commit'ten önce silersen araya giren okuma eski değeri cache'e geri yazar.

S: Cache stampede nedir, nasıl önlenir?

C: Sıcak bir anahtar expire olunca çok sayıda isteğin aynı anda kaynağa gitmesi. Tek yükleyici (single-flight/kısa kilit), refresh-ahead ve TTL jitter ile önlenir.

S: Redis kilidinin doğruluk için yetersiz olmasının iki nedeni?

C: Kilit sahibi GC duraklamasında TTL'i kaçırıp kilidin başkasına geçtiğini bilmeden yazabilir; async replikasyon yüzünden failover'da kilit kaybolabilir. Doğruluk için fencing token ya da DB garantisi gerekir.

Kaynaklar