Sertaç Yıldırımsaha notları

Ana sayfa → Teknik

CQRS ve Materialized View

Portföy ekranı 8 saniyede açılıyor: 14 tablo birleştiriliyor, her açılışta kâr/zarar baştan hesaplanıyor. Aynı veritabanı, aynı anda 3 milisaniyede emir yazmak zorunda. Bir model iki işi birden yapamıyor.

Özet
  • Yazma ve okuma birbirine zıt şeyler istiyor. Biri normalize, diğeri denormalize.
  • CQRS iki veritabanı demek değil. En basit hali aynı veritabanında ayrı okuma tabloları.
  • Okuma modeli eski olur. Asıl mesele bunu bilerek tasarlamak, gizlemeye çalışmak değil.
  • Yeniden inşa edilebilirlik en değerli özelliği. Bozulan görünümü olaylardan sıfırdan üretebilmelisin.

Neden aynı model iki işi göremiyor?

Yazma tarafı ne istiyor
  • Normalize tablolar — veri tek yerde
  • Kısıtlar, yabancı anahtarlar, kilitler
  • Küçük ve hızlı işlemler
  • Az indeks (her indeks yazmayı yavaşlatır)
Okuma tarafı ne istiyor
  • Denormalize — her şey tek satırda
  • Birleştirme yok, hazır hesaplanmış toplamlar
  • Bol indeks, her filtre için biri
  • Kilit yok, sadece okuma

Bunları tek bir tablo ailesinde uzlaştırmaya çalışırsan ikisinde de orta karar bir sonuç alıyorsun: okumalar için indeks ekliyorsun, yazma yavaşlıyor; yazma için indeks kaldırıyorsun, ekran yavaşlıyor. Bu bir denge değil, bir çekişme.

CQRS ne değildir?

Bu konu, gereksiz yere korkutucu bir üne sahip. Netleştirelim:

Zorunlu olmayan üç şey
  • Ayrı veritabanı gerekmez. Aynı PostgreSQL içinde okuma için ayrı tablolar tutmak da CQRS’tir.
  • Event sourcing gerekmez. İkisi sık birlikte anılıyor ama bağımsız kararlar. Klasik tablolarla da yapılır.
  • Her yerde uygulanmaz. Sadece okuma yükü ile yazma yükünün çatıştığı yerlerde.

Öz olarak CQRS tek bir cümle: okumak için kullandığın yapı, yazmak için kullandığından farklı olsun. Gerisi uygulama detayı.

Somut örnek: portföy görünümü

Yazma tarafı olduğu gibi kalıyor: emirler, fill_kayitlari, pozisyonlar, teminat_hareketleri. Kısıtlı, normalize, doğru.

Okuma tarafına ekranın tam olarak ihtiyaç duyduğu tabloyu koyuyorsun:

Okumaya hazır tablo
CREATE TABLE portfoy_gorunum (
  hesap_id        bigint,
  sembol          text,
  net_lot         numeric,
  ort_giris       numeric,
  guncel_fiyat    numeric,
  gerceklesmemis  numeric,      -- ← onceden hesaplanmis
  gerceklesmis    numeric,
  kullanilan_tem  numeric,
  son_islem       timestamptz,
  surum           bigint,        -- ← "read your writes" icin
  PRIMARY KEY (hesap_id, sembol)
);

-- Ekranin sorgusu artik bu:
SELECT * FROM portfoy_gorunum WHERE hesap_id = 42;
-- 14 join yok. Tek indeks taramasi. ~2 ms.

Bu tabloyu güncelleyen şey, outbox’tan çıkan olaylar:

Olaydan görünüme
// FillGerceklesti olayi geldiginde:
BEGIN;
  INSERT INTO portfoy_gorunum (hesap_id, sembol, net_lot, ort_giris, surum)
  VALUES (:hesap, :sembol, :lot, :fiyat, :olay_sirasi)
  ON CONFLICT (hesap_id, sembol) DO UPDATE
     SET net_lot   = portfoy_gorunum.net_lot + EXCLUDED.net_lot,
         ort_giris = (...),
         surum     = EXCLUDED.surum
   WHERE portfoy_gorunum.surum < EXCLUDED.surum;   -- ← eski olay yazamaz
COMMIT;

Son satır sıra bozulmasına karşı koruma: geç gelen eski bir olay görünümü bozamıyor.

Aynı olaydan birden fazla görünüm beslenebiliyor ve her biri kendi ekranı için optimize: risk_gorunum (risk motoru için), sembol_gorunum (masa ekranı için), gun_sonu_gorunum (rapor için). Hepsi bağımsız güncelleniyor, biri bozulsa diğerleri çalışıyor.

Asıl bedel: okuma modeli eski olur

Görünüm, olay geldikten sonra güncelleniyor. Yani arada bir gecikme var — genelde 50–300 ms. Çoğu ekran için fark edilmez. Ama şu senaryoda fark edilir:

Kendi yazdığını görememek
10:30:00.000  Musteri "AL" butonuna basti
10:30:00.040  Emir yazildi, cevap dondu: "basarili"
10:30:00.050  Arayuz portfoyu yeniden yukledi
10:30:00.055  portfoy_gorunum HENUZ GUNCELLENMEDI
              → Musteri emrini goremiyor
10:30:00.180  Gorunum guncellendi

Musteri o 130 ms icinde ne dusunuyor? "Emrim gitmedi mi?"
Ve tekrar basiyor.

Sonuç: iki emir. Teknik bir gecikme, ticari bir hataya dönüştü.

Üç pratik çözüm, en basitten:

  1. İyimser arayüz. Emir cevabı geldiği anda arayüz onu listeye kendi ekliyor, görünümü beklemiyor. En ucuz ve çoğu durumda yeterli.
  2. İşlem yapan kullanıcı yazma tarafından okusun. Emir açtıktan sonra 3 saniye boyunca o kullanıcının portföy sorgusu okuma modeline değil, asıl tablolara gidiyor. Yavaş ama doğru — ve sadece bir kullanıcı için.
  3. Sürüm bekleme. Yazma işlemi bir sürüm numarası dönüyor; okuma isteği “en az bu sürüm” diyor ve görünüm oraya gelene kadar (kısa bir üst sınırla) bekliyor. En doğru olanı, en karmaşığı.
Nerede kullanılmaz

Görünümlerden karar üretilmez. Teminat kontrolü, risk limiti kontrolü, emir kabul kararı — bunlar her zaman yazma tarafındaki asıl veriden okunmalı. 200 ms eski bir teminat rakamıyla emir kabul etmek, kabul edilmemesi gereken bir emri geçirmek demek.

Kural: görünüm göstermek içindir, karar vermek için değil.

En değerli özellik: yeniden inşa edilebilirlik

Bu, CQRS’in en az konuşulan ama operasyonda en çok işe yarayan tarafı. Okuma modeli türetilmiş bir veri — yani kaybolursa yeniden üretilebilir.

Görünüm bozulduğunda
# Hesaplama mantiginda hata bulundu, gorunum yanlis.
# Yazma tarafina hic dokunmadan:

1. Yeni tabloyu olustur:  portfoy_gorunum_v2
2. Olay gecmisini bastan oynat  → v2'yi doldur
3. Karsilastir: v1 ile v2 arasindaki farki raporla
4. Sorun yoksa okumalari v2'ye cevir
5. v1'i sil

Kullanıcı hiçbir kesinti görmüyor, asıl veriye hiç dokunulmuyor. Klasik mimaride aynı iş, canlı tabloda bir migration ve geri dönüşü olmayan bir gece demek.

Bunun şartı şu: olayları saklamış olman gerekiyor. Outbox tablosunu 3 gün sonra siliyorsan yeniden inşa penceresi 3 gün. Kritik görünümler için olayları ayrı bir arşivde uzun süre tutmak (ya da Kafka’da uzun saklama süresi) bu özelliği ayakta tutuyor.

Ne zaman CQRS yapma

Gerekmiyor
  • Okuma ve yazma yükü zaten dengeliyse
  • Ekran sorguları hâlâ hızlıysa (önce indekse bak!)
  • Veri anlık doğru olmak zorundaysa
  • Ekip küçük ve iki modeli senkron tutacak disiplin yoksa
Gerekiyor
  • Okuma yazmadan kat kat fazla
  • Ekran sorgusu pahalı ve sık
  • Farklı ekranlar aynı veriyi çok farklı biçimlerde istiyor
  • Ağır raporlar canlı yazmayı yavaşlatıyor

Sık gördüğüm hata: yavaş bir sorguyu düzeltmek yerine doğrudan CQRS kurmak. Çoğu “8 saniyelik ekran” aslında bir eksik indeks ya da bir N+1 sorgusu. Önce ölç, sonra mimari değiştir — ikinci adım geri dönüşü pahalı bir karar.

Kontrol listesi AI agent görev listesi

Okuma modeli kurarken
  • Yavaşlığın sebebini ölçtüm mü? (Eksik indeks olabilir.)
  • Görünüm hangi olaylardan besleniyor, listesi yazılı mı?
  • Geç gelen eski olay görünümü bozabiliyor mu? (Sürüm kontrolü var mı?)
  • Görünüm ne kadar geride kalıyor? Ölçülüyor mu?
  • Kullanıcı kendi yazdığını görebiliyor mu?
  • Görünümden karar üretiliyor mu? (Üretilmemeli.)
  • Görünümü sıfırdan yeniden inşa edebiliyor muyum?
  • Yeniden inşa için olay geçmişi ne kadar geriye gidiyor?

Sonuç

CQRS, kulağa geldiğinden çok daha basit bir fikir: gösterdiğin veriyle sakladığın veri aynı şekle sahip olmak zorunda değil. Bu ayrımı yaptığın anda iki taraf da rahatlıyor.

Bedeli de net: artık iki yerde veri var ve biri diğerinden geride. Bunu gizlemeye çalışan sistemler tuhaf hatalar üretiyor; açıkça kabul edip tasarlayan sistemler ise hem hızlı hem öngörülebilir oluyor.