Mert Özen Her Satırda Daha İleri
Veritabanı MongoDB — #12

Read Concern, Write Concern ve Read Preference: MongoDB'de Tutarlılığı Ayarlamak

Mert Özen 14 Kas 2026 10 dk 9 görüntülenme
Read Concern, Write Concern ve Read Preference: MongoDB'de Tutarlılığı Ayarlamak

MongoDB'de bir yazmanın ne kadar kalıcı, bir okumanın ne kadar güncel olduğunu write concern, read concern ve read preference birlikte belirler; secondary'den okumak bayat veri demektir ve read-your-writes için majority ile causal session gerekir. Üç üyeli bir lab'da gerçek çıktılarla: secondary'de görünmeyen yeni sipariş, zaman aşımına uğrayıp yine de yazılmış bir w:majority, .NET'te 200 denemede 51-59 bayat okuma ve causal session ile sıfır.

Sürüm notu: Bu yazıdaki örnekler MongoDB 9.0.8 Community Server, mongosh 2.6, .NET 10 ve MongoDB.Driver 3.14 ile çalıştırılmıştır. Script'ler MongoDB 8.0.38'de de aynı sonucu verdi; tek fark bir hata kodunun adı, ilgili bölümde.

Önce Tahmin Et

Bookstore'da (sitedeki serilerin ortak örnek uygulaması, bir online kitapçı) ödeme servisi siparişi Paid yapıyor, ardından müşteri sipariş sayfasına yönlendiriliyor. Primary'nin yükünü azaltmak için sipariş sayfası secondary'den okuyor. Bu yazının lab'ı üç üyeli bir replica set ve iki secondary o anda geride. Ödeme servisi 379 numaralı siparişi ödenmiş yapıyor ve 380'i oluşturuyor, ikisini de w: 1 ile:

// Ödeme servisi: 379 Paid olur, 380 oluşturulur, ikisi de w:1 ile
orders.updateOne({ _id: 379 }, { $set: { status: "Paid" } }, { writeConcern: { w: 1 } });

show("primary, read concern local:", id => orders.findOne({ _id: id }));
show("secondary:", id => orders.find({ _id: id }).readPref("secondary").next() ?? null);
show("primary, read concern majority:", id => orders.find({ _id: id }).readConcern("majority").next() ?? null);

Üç okuma, iki sipariş. Hangisi ne görür? Üçüncü satır primary'den okuyor; o da güncel değer olmalı, değil mi?

primary, read concern local:       379 Paid, 380 Paid
secondary:                         379 Pending, 380 not found
primary, read concern majority:    379 Pending, 380 not found

Secondary'den okuyan sayfa müşteriye "sipariş bulunamadı" diyor; 379 hâlâ ödenmemiş görünüyor. Üçüncü satır daha az bilinen tarafı gösteriyor: primary'den okusan bile readConcern: "majority" istersen, çoğunluğa ulaşmamış yazmaları görmüyorsun. Bu yazıda MongoDB'de tutarlılığı belirleyen üç ayarı ele alıyoruz: bir yazmanın ne zaman "tamam" sayılacağı (write concern), bir okumanın hangi veriyi göreceği (read concern) ve okumanın hangi üyeye gideceği (read preference). Teorinin kendisi, yani consistency model'leri, Distributed Systems Temelleri serisinin "Consistency Model'leri: Linearizability, Sequential, Causal ve Eventual Consistency" yazısında; burada MongoDB'nin bu kavramları nasıl uyguladığına ve maliyetlerine bakıyoruz.

Lab: Tek Container'da Üç Üyeli Replica Set

2. yazıdaki lab tek üyeli; secondary olmadan bu yazının hiçbir çıktısı oluşmaz. Gerçek bir kurulumda her üye ayrı bir makinede, tercihen ayrı bir bölgede çalışır. Lab'da ise amaç davranışı görmek, bu yüzden üç mongod process'ini tek container'da, her birine 0.25 GB WiredTiger cache vererek çalıştırıyoruz; toplam bellek 1 GB civarında kalıyor ve makinede 2. yazının container'ıyla birlikte iki container çalışıyor. Container'ın başlangıç script'i:

# 12 - tek container'da üç mongod process'i: az kaynakla 3 üyeli replica set (yalnız lab)
for port in 27021 27022 27023; do
    mkdir -p "$base/$port"
    mongod --replSet rs3 --port "$port" --bind_ip_all --dbpath "$base/$port" \
        --keyFile "$base/keyfile" --wiredTigerCacheSizeGB 0.25 \
        --logpath "$base/$port.log" --pidfilepath "$base/$port.pid" --fork
done

compose.rs3.yaml üç portu yalnız 127.0.0.1'e yayınlıyor ve 2. yazının .env dosyasındaki admin parolasını kullanıyor. Üyelerin adları localhost:27021–27023; 2. yazıdaki gibi bu adlar hem container içinden hem Windows'taki uygulamadan çözülebiliyor. PowerShell'de:

docker compose -f compose.rs3.yaml up -d
docker compose -f compose.rs3.yaml exec rs3 bash /lab/scripts/12-rs3-init.sh
docker compose -f compose.rs3.yaml exec rs3 mongosh "mongodb://admin@localhost:27021,localhost:27022,localhost:27023/?authSource=admin&replicaSet=rs3" --quiet /lab/scripts/12-consistency.js -p
localhost:27021 PRIMARY
localhost:27022 SECONDARY
localhost:27023 SECONDARY
books: 77

Init script'i replica set'i başlatıyor, localhost exception (kullanıcı yokken localhost'tan ilk kullanıcıyı oluşturma izni) ile admin'i ve bookstore_app'i oluşturuyor ve Bookstore verisini mongoimport ile yüklüyor. Gecikmeyi görünür kılmak için 12-consistency.js iki secondary'yi fsyncLock ile kilitliyor: kilitli bir üye yazma kabul etmiyor, oplog'u (replica set'in değişiklik günlüğü) uygulayamıyor ama okumaya cevap veriyor. Ağır yük altında geride kalan bir secondary'nin abartılmış hâli. Script sonda kilitleri açıyor ve yaptığı değişiklikleri geri alıyor.

Write Concern: Yazma Ne Zaman Tamam Sayılır?

Write concern, sunucunun bir yazmayı onaylamadan önce kaç üyede görmesi gerektiğini söylüyor. w: 1 yalnız primary'nin onayı, w: "majority" oy veren üyelerin çoğunluğunun yazmayı oplog'una kalıcı olarak yazması. Hiçbir şey söylemezsen ne geçerli?

default write concern: {"w":"majority","wtimeout":0} source: implicit

Replica set'lerde varsayılan majority; source: implicit kimsenin bunu açıkça ayarlamadığını, sunucunun kendi varsayılanı olduğunu gösteriyor. Üç üyede çoğunluk iki, yani primary'ye ek olarak bir secondary. j: true onaydan önce journal'a (diske yazılan değişiklik günlüğü) yazılmasını istiyor; majority'nin journal'ı da kapsayıp kapsamadığını replica set'in writeConcernMajorityJournalDefault ayarı belirliyor. Asıl tuzak zaman aşımında:

// Zaman aşımlı w:"majority": hata yazmayı geri almaz
attempt("w: majority, wtimeout 2000:", () => {
    const r = orders.updateOne({ _id: 378 }, { $set: { note: "checked" } }, { writeConcern: { w: "majority", wtimeout: 2000 } });
    return JSON.stringify(r);
});
print("order 378 note on the primary:".padEnd(34), orders.findOne({ _id: 378 }).note);
w: majority, wtimeout 2000:        WriteConcernTimeout: waiting for replication timed out
order 378 note on the primary:     checked

Uygulama hata aldı, ama yazma primary'de duruyor ve secondary'ler kilidi açınca onu da alacak. Write concern hatası "yazma olmadı" değil, "istediğin güvenceyi zamanında veremedim" demek. Uygulama bu hatada işlemi "başarısız" sayıp kullanıcıya tekrar denetirse, aynı ödeme ikinci kez yazılabilir; işlem idempotent olmalı ya da sonucu yeniden okuyarak karar vermelisin. 8.0'da farkı: 8.0.38 aynı durumda WriteConcernFailed adını döndürdü, 9.0.8 WriteConcernTimeout; koduna değil adına bakan hata işleme kodu varsa yükseltmede kontrol et.

w: 1'in bedeli kalıcılık. Primary yazmayı onaylayıp secondary'lere iletemeden çökerse yeni primary bu yazmayı hiç görmemiş olur; eski primary geri döndüğünde yazma rollback ile geri alınır. Kullanıcı "ödemeniz alındı" mesajını görmüştür, veritabanında ödeme yoktur.

Read Concern: Okuma Hangi Veriyi Görür?

Read concern okumanın hangi kalıcılık seviyesindeki veriyi göreceğini belirliyor. local (varsayılan) üyenin elindeki en son veri, rollback ile geri alınabilecek yazmalar dahil. majority yalnız çoğunluğa ulaşmış, yani geri alınamayacak veri; "Önce Tahmin Et"in üçüncü satırı bu yüzden Pending gösterdi: primary Paid'i biliyordu ama o yazma henüz çoğunlukta değildi. snapshot transaction'larda ve atClusterTime ile belli bir andaki tutarlı görüntüyü okumada kullanılıyor. linearizable en güçlüsü: okumanın, başlamadan önce onaylanmış bütün majority yazmaları gördüğünü garanti ediyor ve bunun için primary'nin hâlâ w: "majority" yazmaları onaylayabilecek durumda olduğunu secondary'lerle teyit ediyor:

read concern linearizable:         MaxTimeMSExpired: operation exceeded time limit

Secondary'ler kilitliyken teyit gelmedi ve okuma maxTimeMS sınırında kesildi; maxTimeMS vermeseydin sonsuza kadar bekleyebilirdi. linearizable yalnız primary'den, tek document'i dönen sorgular için anlamlı ve her okumaya bir çoğunluk turu ekliyor. Stok sayımı gibi "kesinlikle son değer" gereken nadir okumalar için; sipariş listesi için değil.

Read Preference: Okuma Hangi Üyeye Gider?

ModNereden okurDikkat
primary (varsayılan)Yalnız primarySeçim sırasında okuma da durur
primaryPreferredPrimary, yoksa secondaryFailover sırasında bayat veri
secondaryYalnız secondaryHer zaman bayat olabilir
secondaryPreferredSecondary, yoksa primaryAynı risk; "yük azaltma" diye en sık seçilen
nearestGecikmesi en düşük üyelerPrimary de olabilir, secondary de

Secondary'den okumanın gecikmesini sınırlamak için maxStalenessSeconds var, ama doküman bunun 90 saniyeden küçük olamayacağını söylüyor: driver'lar üyelerin son yazma zamanını seyrek aralıklarla kontrol ettiği için tahmin kaba. Yani "en fazla birkaç yüz milisaniye bayat" diye bir ayar yok. Secondary'den okumanın bir yanılgısı daha var: her secondary primary'nin bütün yazmalarını kendisi de uyguluyor, bu yüzden secondary'ler yazma yükünü paylaşmıyor, yalnız okumayı dağıtıyor. Primary yazmadan zorlanıyorsa secondary'ye okuma taşımak sorunu çözmez.

Causal Consistency: Kendi Yazdığını Okumak

Sipariş sayfasının asıl ihtiyacı "herkes için en güncel veri" değil, "bu müşterinin az önce yaptığı ödemeyi görmek": read-your-writes. MongoDB bunu causally consistent session ile veriyor. Session her işlemden sonra sunucunun verdiği zaman damgasını (operationTime) tutuyor ve sonraki okumaya afterClusterTime olarak ekliyor; secondary o ana kadar yetişmediyse cevap vermek yerine bekliyor. Kilitliyken:

// Causal consistency: aynı session'daki okuma, session'ın kendi yazmasını bekler
const session = db.getMongo().startSession({ causalConsistency: true });
const sessionOrders = session.getDatabase("bookstore").orders;
sessionOrders.updateOne({ _id: 379 }, { $set: { status: "Shipped" } }, { writeConcern: { w: 1 } });
attempt("causal session, secondary read:", () => sessionOrders.find({ _id: 379 }).readPref("secondary").maxTimeMS(2000).next().status);
causal session, secondary read:    MaxTimeMSExpired: Error waiting for optime
read concern linearizable:         MaxTimeMSExpired: operation exceeded time limit
--- secondaries caught up
secondary:                         379 Shipped, 380 Paid
primary, read concern majority:    379 Shipped, 380 Paid
causal session, secondary read:    Shipped

Kilitliyken session bayat bir cevap vermedi, bekledi; kilit açılınca aynı okuma Shipped'i döndürdü. Gerçek hayatta secondary'ler kilitli değil, birkaç milisaniye geride. Bunun ne demek olduğunu .NET'ten ölçtüm: read-your-writes komutu 379 numaralı siparişe 200 kez bir sayı yazıp hemen secondary'den geri okuyor ve okunan değer yazılandan eskiyse sayıyor. Bu sefer kilit yok:

IMongoCollection<BsonDocument> secondary = reader.WithReadPreference(ReadPreference.Secondary);

using IClientSessionHandle session = await client.StartSessionAsync(new ClientSessionOptions { CausalConsistency = true });
await RunAsync("w:majority, secondary, causal session",
    orders.WithWriteConcern(WriteConcern.WMajority), orders.WithReadConcern(ReadConcern.Majority), session);

    await writer.UpdateOneAsync(session, order379, update);
    read = await secondary.Find(session, order379).FirstAsync();
w:1, secondary                           stale reads: 59 of 200
w:majority, secondary                    stale reads: 3 of 200
w:majority, secondary, causal session    stale reads: 0 of 200

İkinci koşu 51, 5 ve 0 verdi; 8.0.38'de 56, 2 ve 0. Sayılar makineden makineye değişir, sıralama değişmez. w: 1 ile yaklaşık her dört okumadan biri bayat. w: "majority" farkı küçültüyor ama sıfırlamıyor: çoğunluk primary ve bir secondary demek, okuma diğer secondary'ye düşebiliyor. Causal session ile sıfır. Doküman bu birleşimin önemini açıkça söylüyor: dört causal consistency garantisinin (kendi yazdığını okumak dahil) ağ bölünmesi gibi durumlarda da geçerli olması için okumanın majority read concern, yazmanın majority write concern ile yapılması gerekiyor. w: 1 ve local ile session da normal günde çalışır, ama failover anında garanti vermez. .NET'te session'ı istek boyunca taşımak gerekiyor; ASP.NET Core'da bunu scoped bir servis olarak tutmak, ödeme ile sipariş sayfası farklı isteklerse son operationTime'ı (session.OperationTime) müşterinin oturumunda saklayıp yeni session'a AdvanceOperationTime ile vermek gerekir.

Multi-Document Transaction'ın Sınırları

Script'in ikinci satırı transaction'larla ilgili iki sunucu parametresini okuyor:

transaction lifetime: 60 s, max open transactions: 10000

transactionLifetimeLimitSeconds varsayılan 60: daha uzun süren bir transaction arka planda iptal ediliyor. maxConcurrentMultiDocumentTransactions 9.0'ın release notes'unda yeni olarak geçiyor: aynı anda açık transaction sayısı 10000'e ulaşınca sunucu yenilerini TooManyOpenTransactions hatasıyla reddediyor; 0 sınırı kaldırıyor. 8.0'da farkı: 8.0.38'de de parametre var ve aynı değeri döndürdü; 8.0 parametre dokümanı onu 8.0.30 ile gelmiş olarak listeliyor. Yani sınır 8.0'ın son patch'lerinde de geçerli. Sınıra yaklaşmanın tipik nedeni çok sayıda transaction'ın açık kalması: commit ya da abort edilmeden bekleyen transaction'lar hem bu sayacı hem WiredTiger cache'ini dolduruyor. Transaction'lar ayrıca read preference olarak yalnız primary'den okuyor; secondary'ye okuma taşıyarak transaction yükü hafiflemiyor. 10. yazıdaki kural geçerli: transaction kısa, gövde yan etkisiz ve önce modelin tek document'e sığıp sığmadığına bak.

Üretimde Dikkat

Ayarları bilinçli ve tek yerde ver. Connection string'de w, readConcernLevel ve readPreference seçenekleri var; .NET'te ayrıca WithWriteConcern, WithReadConcern ve WithReadPreference ile collection başına değiştirebiliyorsun. Kural olarak varsayılanı (majority yazma, primary okuma) koru ve sapmaları kodda adı olan yerlerde yap: "raporlar secondaryPreferred", "log yazmaları w: 1".

Replication gecikmesini izle. rs.printSecondaryReplicationInfo() her secondary'nin primary'den ne kadar geride olduğunu gösteriyor; secondary'den okuyan her ekran bu sayıyı kullanıcıya bayatlık olarak yansıtır. Gecikme büyüyorsa nedeni çoğunlukla secondary'nin diski ya da üzerinde çalışan ağır okumalar; 13. yazıda raporlar için gizli bir analitik üyeye bakacağız.

Sık Düşülen Tuzaklar

Write concern'ü düşürüp "hızlandı" demek. w: 1 yazma gecikmesini azaltır, çünkü secondary'yi beklemez; kazanılan süre, failover'da kaybolabilecek veridir. Ödeme, sipariş ve stok için kabul edilemez; tıklama sayacı ya da görüntülenme log'u için olabilir. Kararı ölçümle ve yazılı olarak ver.

Secondary'yi raporlama için sınırsız kullanmak. Ağır bir aggregation secondary'nin CPU'sunu ve cache'ini doldurduğunda oplog'u uygulaması yavaşlar; gecikme artar, w: "majority" yazmalar o secondary'yi beklediği için primary'deki yazmalar da yavaşlar. Üstelik o secondary bir seçimde primary olabilir. Raporlar için 13. yazıdaki hidden üye ya da 8. yazıdaki materialized view daha güvenli.

Write concern zaman aşımını rollback sanmak. Yukarıdaki 378 örneği: hata geldi, veri yazıldı. Retry'dan önce durumu oku ya da işlemi idempotent yap.

Küçük Bir Deneme

★☆☆ 12-consistency.js'te yalnız bir secondary'yi kilitle. w: "majority" ve readConcern("majority") satırları nasıl değişiyor? Neden?

★★☆ read-your-writes komutuna ReadPreference.Nearest ile dördüncü bir senaryo ekle ve stale okuma sayısını karşılaştır. Sonra causal session'ı w: 1 ve local ile dene; normal günde sonuç neden yine sıfır?

★★★ Ödeme ve sipariş sayfası ayrı HTTP istekleri olsun: ilk istekte session.OperationTime'ı bir cookie'ye yaz, ikinci istekte yeni bir causal session açıp AdvanceOperationTime ile ilerlet ve secondary'den oku. Stale okuma sayısı sıfır kalıyor mu?

Bir sonraki yazıda aynı üç üyeli lab'da replica set'in içine bakıyoruz: primary'yi öldürüp seçimin ne kadar sürdüğünü ölçüyoruz, oplog penceresini hesaplıyoruz ve ardından sharding'e geçip shard key seçiminin siparişleri nasıl dağıttığını görüyoruz.