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.
- 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.
- Sıra garantisi yoksa sürüm filtresi olay kaybettirir. Delta ile güncellenen görünümde önce sırayı çöz (partition anahtarı), sonra tekrarı.
- Yeniden inşa edilebilirlik en değerli özelliği. Bozulan görünümü olaylardan sıfırdan üretebilmelisin — tam geçmiş ya da snapshot + sonrası elinde olmalı.
Neden aynı model iki işi göremiyor?
- 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)
- Denormalize — her şey tek satırda
- Birleştirme yok, hazır hesaplanmış toplamlar
- Bol indeks, her filtre için biri
- Kullanıcı açısından sadece okuma; kilit beklemez
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 konunun gereksiz yere korkutucu bir ünü var. Netleştirelim:
- 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ünde 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.
Bir not: “materialized view”u burada kavram olarak kullanıyorum —
sonucu önceden hesaplanıp saklanan görünüm. PostgreSQL’in
CREATE MATERIALIZED VIEW + REFRESH yapısı değil; o, her
yenilemede sorguyu baştan çalıştırır. Buradaki, olaylarla artımlı güncellenen sıradan
bir tablo:
CREATE TABLE portfoy_gorunum (
hesap_id bigint,
sembol text,
net_lot numeric,
ort_giris numeric,
gerceklesmis numeric, -- kapanan pozisyondan; olayla guncellenir
kullanilan_tem numeric,
son_islem timestamptz,
surum bigint, -- son islenen olayin sirasi ("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.
-- Guncel fiyat ve gerceklesmemis K/Z bu tabloda YOK.
-- Okuma aninda fiyat onbelleginden hesaplanir:
-- gerceklesmemis = net_lot * (guncel_fiyat - ort_giris)
Tabloda ne olmadığına da bak: güncel fiyat ve gerçekleşmemiş kâr/zarar yok.
Fiyat her tick’te değişiyor; bunu tabloya yazarsan her fiyat hareketinde o
sembolü tutan bütün hesapların satırı güncellenir. Görünümü besleyen olay
(FillGerceklesti) fiyata zaten dokunmuyor. Kural: görünüme
yalnızca olayla değişen şey girer; fiyata bağlı değerler okuma anında
hesaplanır.
Bu tabloyu güncelleyen şey, outbox’tan çıkan olaylar:
-- On kosul: ayni hesabin olaylari tek partition'dan, sirali gelir.
-- Inbox: anahtar sadece olay_id DEGIL, (tuketici, olay_id)
CREATE TABLE islenen_olaylar (
tuketici text, -- 'portfoy_gorunum', 'risk_gorunum', 'portfoy_gorunum_v2'
olay_id text,
islenme timestamptz NOT NULL DEFAULT now(),
PRIMARY KEY (tuketici, olay_id)
);
-- FillGerceklesti olayi geldiginde, TEK ifade:
-- inbox satiri yazilmazsa delta da uygulanmaz.
WITH yeni AS (
INSERT INTO islenen_olaylar (tuketici, olay_id)
VALUES ('portfoy_gorunum', :olay_id)
ON CONFLICT DO NOTHING
RETURNING 1 -- tekrar gelen olayda hic satir donmez
)
INSERT INTO portfoy_gorunum (hesap_id, sembol, net_lot, ort_giris, son_islem, surum)
SELECT :hesap, :sembol, :lot, :fiyat, :zaman, :olay_sirasi
WHERE EXISTS (SELECT 1 FROM yeni) -- yoksa INSERT hic calismaz
ON CONFLICT (hesap_id, sembol) DO UPDATE
SET net_lot = portfoy_gorunum.net_lot + EXCLUDED.net_lot,
ort_giris = (...),
son_islem = EXCLUDED.son_islem,
surum = EXCLUDED.surum; -- filtre degil, sadece kayit
Dört ayrıntı, dördü de canımı yaktı.
Sürüm filtresi yok. İlk sürümde WHERE surum <
EXCLUDED.surum vardı; “geç gelen eski olay görünümü bozamaz” diye
yazmıştım. Yanlıştı. Bu tablo delta ile güncelleniyor (“300 lot
eklendi”), mutlak durumla değil (“net lot artık 300”). Olay 5,
olay 4’ten önce gelirse filtre 4’ü tamamen atar ve o fill’in lotu
kaybolur. Görünüm bozulmuyor, eksik kalıyor; daha sinsi. Delta
ile çalışıyorsan sıra pazarlık konusu değil:
aynı hesabın olayları aynı partition’dan, aynı sırayla gelir. Sıra varsa
filtre yine işine yarar — aşağıda.
Inbox’ın anahtarı (tuketici, olay_id). İlk
yazdığımda anahtar sadece olay_id’ydi. Aynı fill olayı
risk_gorunum’u ve gun_sonu_gorunum’u da
besliyor; hepsi aynı inbox’a bakınca ilk davranan olayı “işlendi”
diye işaretliyor, diğer ikisi tekrar sanıp atlıyor. Sessizce eksik iki görünüm.
Aynı hata yeniden inşayı da öldürüyor: portfoy_gorunum_v2’ye
geçmişi oynattığında bütün olay ID’leri zaten kayıtlı olduğu için v2 boş
kalıyor. Her görünüm ve her sürümü kendi tüketici adıyla kendi kaydını
tutar.
Kontrol yorumda değil, ifadenin içinde. Bunu da bir kez
“önce inbox’a yaz, 0 satır dönerse atla” diye yorum satırıyla
anlatmıştım. Yorum kodu durdurmuyor: ikinci INSERT koşulsuz çalışıyor
ve delta iki kez ekleniyor — tam da engellemeye çalıştığın şey. Ya yukarıdaki
gibi tek ifadeye indir, ya da dallanmayı uygulama kodunda açıkça yaz. Transaction
da bu yüzden var: tek ifadeyi sarmak için değil, “işledim” kaydıyla
güncellemeyi birlikte yazmak için.
olay_sirasi outbox’ın bigserial’i
değil. O ID INSERT anında ayrılıyor, commit anında değil;
101 numaralı olay 100’den önce commit olabiliyor
(outbox yazısında detayı var). Buradaki sıra
hesap başına üretiliyor: fill’i yazan transaction, aynı anda
hesap satırındaki sayacı bir artırıyor ve o değeri olaya koyuyor. Aynı hesaba yazan
transaction’lar o satırın kilidinde serileştiği için sayaç monoton artıyor.
Partition anahtarı da hesap; yani sıra hangi anahtar için garanti ediliyorsa, sayaç
da o anahtar için artıyor. İkisi aynı anahtar değilse garanti yok demektir.
Dürüst cevap: sıra garantin varsa şart değil. Sıra varsa at-least-once’tan
gelen tekrarlar her zaman zaten uygulanmış olayların tekrarıdır, yani
olay_sirasi ≤ surum. O yüzden WHERE portfoy_gorunum.surum
< EXCLUDED.surum bunları doğru atar — bedava, kalıcı ve büyümeyen
bir tekrar koruması. Filtrenin kendisi hatalı değildi; hatam onu sırasız
teslimatla birlikte kullanmaktı.
Inbox iki durumda hak ediyor yerini. Birincisi, sıra garantin olsa bile karşılaştırabileceğin monoton bir sıra numarası üretemiyorsan — filtrenin bakacağı bir şey yoksa dedup’u başka yerde yapman lazım. İkincisi, olayı işlemek tek satırlık bir delta’dan fazlasını yapıyorsa (bildirim gönder, ikinci tabloya yaz); o zaman “işledim” kaydının satırın içinde değil, tek ve ortak bir yerde durması gerekiyor. Sırasız teslimatta ise inbox seni kurtarmaz: tekrarı engeller, sıra bozulmasını engellemez — delta yine yanlış toplanır.
Inbox’ı seçtiysen temizliğini de planla: tablo sonsuza kadar büyüyor. Günlük
bir iş eski satırları siler, ama silme penceresi broker’ın en uzun
tekrar teslimat penceresinden uzun olmalı — yoksa çok geç gelen bir
tekrar “yeni” görünür ve delta ikinci kez uygulanır. Pratikte
7 gün tutup 3 günlük retention’la çalışmak yeterli oluyor. Sürüm filtresi
varsa bu sorunun tamamı yok: surum zaten satırın içinde duruyor ve
temizlik istemiyor.
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:
Piyasa emri: fiyat karsisi var, borsada aninda gerceklesti.
10:30:00.000 Musteri "AL" butonuna basti
10:30:00.040 Emir kabul + fill tek transaction'da yazildi
(emirler, fill_kayitlari, pozisyonlar, outbox)
cevap dondu: "gerceklesti"
10:30:00.050 Arayuz portfoyu yeniden yukledi
10:30:00.055 portfoy_gorunum HENUZ GUNCELLENMEDI
→ Musteri pozisyonunu goremiyor
10:30:00.180 Olay islendi, 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ü.
Senaryodaki “piyasa emri” kaydı önemli.
portfoy_gorunum’u besleyen olay FillGerceklesti;
yani portföyde pozisyon görünüyor, emir değil. Gerçekleşmemiş bir limit
emri burada zaten görünmez — onun yeri
EmirKabulEdildi’den beslenen ayrı bir acik_emirler
görünümü. Bu ikisini tek ekranda karıştırırsan “emrim nerede?” sorusunu
eventual consistency’ye yazarsın; oysa sorun görünümün yanlış olayla
beslenmesidir.
Asıl koruma arayüzde değil, yazma tarafında: her emir istemciden bir idempotency anahtarı (client order ID) taşır; aynı anahtarla gelen ikinci emir reddedilir. Aşağıdaki üç çözüm kullanıcının gördüğünü düzeltir; ikinci emrin kabul edilmesini engelleyen şey bu anahtar. Önce onu koy, sonra algıyı düzelt.
Üç pratik çözüm, en basitten:
- İ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.
-
Dokunulan satırı yazma tarafından oku. Emir verdikten sonra
3 saniye boyunca o kullanıcının portföyü yine görünümden geliyor; yalnızca emrin
dokunduğu sembolün satırı yazma tarafındaki
pozisyonlartablosundan çekilip üzerine yazılıyor. Tek hesap, tek sembol: 14 join değil, tek satır. Bütün portföyü yazma tarafından çekmek 8 saniye demek — girişteki sorunu geri getirir, çift tıklamayı daha da kötüleştirir. Bunun işe yaraması emrin gerçekleşmiş olmasına bağlı:pozisyonlartablosunu değiştiren şey fill, emir kaydı değil. Bekleyen bir limit emri için doğru cevap bu değil, birinci çözüm. - 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şığı.
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.
# 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. v2 canli akisi da tuketsin; v1'e yetisene kadar bekle (lag ≈ 0)
(bu adim atlanirsa oynatma sirasinda gelen olaylar v2'de eksik kalir)
4. Karsilastir: v1-v2 farklari YALNIZCA bilinen hatadan mi geliyor?
(fark olacak, v1 zaten yanlis; baska fark varsa v2'de de sorun var)
5. Okumalari v2'ye cevir
6. v1'i sil
Kullanıcı hiçbir kesinti görmüyor, asıl veriye hiç dokunulmuyor. Klasik mimaride aynı iş, canlı tabloda bir şema değişikliği (migration) ve geri dönüşü olmayan bir gece demek.
Bunun şartı şu: olay geçmişinin tamamı elinde olmalı. Outbox
tablosunu 3 gün sonra siliyorsan görünümü sıfırdan kuramazsın; elinde son
3 günün deltaları var, 3 gün önceki pozisyon yok. İki yol var: ya olayları ilk günden
itibaren ayrı bir arşivde tutarsın (ya da Kafka’da uzun saklama süresiyle), ya
da düzenli snapshot alırsın — yazma tarafındaki pozisyonlar
tablosundan gün sonu kopyası + o andan sonraki olaylar. İkincisi daha ucuz; snapshot
yazma tarafından geldiği için görünümdeki hatayı taşımaz.
Snapshot’ın iki şartı var, ikisi de atlanınca sessizce yanlış sonuç veriyor.
Birincisi snapshot hangi olay konumuna karşılık geliyor, bilmek.
“Gün sonu kopyası, sonra sonraki olaylar” yeterli değil; sınırdaki olaylar
ya iki kez uygulanır ya hiç uygulanmaz. Kopyayı ve olay geçmişindeki son sırayı
aynı transaction içinde (aynı MVCC anlık görüntüsünde) al, oynatmayı tam o
sıradan başlat. İkincisi snapshot her kolonu kapsamıyor:
pozisyonlar tablosunda gerceklesmis ya da
kullanilan_tem gibi görünüme özel kolonlar yoktur. Kapsamayan kolonlar
için ya ayrı bir kaynak bulman ya da o kolonları tam geçmişten üretmen gerekir.
Hangisini seçtiğin yazılı olmalı, yoksa ilk yeniden inşa gecesinde öğrenirsin.
Ne zaman CQRS yapma
- 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
- 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
- Yavaşlığın sebebini ölçtüm mü? (Eksik indeks olabilir.)
- Görünüm hangi olaylardan besleniyor, listesi yazılı mı?
- Aynı olay iki kez gelirse iki kez uygulanıyor mu? (Kontrol yorumda mı, ifadenin içinde mi?)
- Inbox’ın anahtarı
(tuketici, olay_id)mi? Her görünüm ve her sürümü kendi kaydını tutuyor mu? - Inbox ne zaman temizleniyor? Pencere, broker’ın tekrar teslimat penceresinden uzun mu?
- Aynı anahtarın olayları sıralı mı geliyor? (Partition anahtarı hesap mı?)
- Sıra numarası nereden geliyor? Partition anahtarıyla aynı anahtarda mı üretiliyor?
- Görünüm hangi olayla besleniyor, ekranda gösterdiği şey o mu? (Pozisyon mu, emir mi?)
- Çift emri engelleyen idempotency anahtarı yazma tarafında 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 tam geçmiş mi var, snapshot + sonrası mı? Hangisi, yazılı mı?
- Snapshot hangi olay sırasına karşılık geliyor? Aynı transaction’da mı alındı?
- Snapshot görünümdeki bütün kolonları kapsıyor mu?
- Yeniden inşada canlı akış yeni tabloya da akıyor mu?
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.