MongoDB Transactions: Çok Doküman ACID İşlemleri

MongoDB 4.0 ile hayatımıza giren çok doküman ACID transaction desteği, o güne kadar “MongoDB’de transaction olmaz” diyen herkesi biraz şaşırttı. Evet, yıllarca NoSQL dünyasının “eventual consistency” anlayışıyla büyüdük, tek doküman atomikliğiyle idare ettik. Ama artık durum farklı. Gerçek hayatta bir banka transferi, bir e-ticaret siparişi ya da bir rezervasyon sistemi kurgularken birden fazla koleksiyona yazmanız ve bunların hepsinin ya gerçekleşmesini ya da hiçbirinin gerçekleşmemesini garanti etmeniz gerekiyor. İşte bu yazıda MongoDB transactions konusunu teknik derinliğiyle, gerçek dünya senaryolarıyla ve pratik kod örnekleriyle ele alacağız.

ACID Nedir ve MongoDB’de Neden Önemlidir

ACID, veritabanı işlemlerinin güvenilirliğini tanımlayan dört temel özelliği ifade eder:

  • Atomicity (Atomiklik): İşlemin tamamı başarılı olur ya da hiçbir şey uygulanmaz.
  • Consistency (Tutarlılık): İşlem öncesi ve sonrası veri bütünlüğü korunur.
  • Isolation (İzolasyon): Eş zamanlı işlemler birbirini etkilemez.
  • Durability (Dayanıklılık): Commit edilen işlemler kalıcıdır.

MongoDB, tek doküman bazında her zaman atomik davranıyordu. Bir dokümanı güncellerken yarım kalmış bir işlemle karşılaşmazsınız. Ancak sorun birden fazla koleksiyon veya birden fazla doküman söz konusu olduğunda başlıyordu. Örneğin bir e-ticaret uygulamasında siparişi oluşturup stoku azaltmanız gerekiyor. Bu iki ayrı koleksiyon işlemi olduğunda, birincisi başarılı olup ikincisi başarısız olabilir ve veritabanınız tutarsız bir hale gelir.

MongoDB 4.0 ile replica set üzerinde, 4.2 ile sharded cluster üzerinde multi-document transactions desteği geldi. Bu sayede ilişkisel veritabanlarına has bu güvenlik garantisi artık MongoDB’de de kullanılabilir.

Önkoşullar ve Altyapı Gereksinimleri

Transactions kullanmadan önce altyapınızın hazır olduğundan emin olmanız gerekiyor:

  • Replica Set: En az bir primary ve bir secondary node içeren replica set konfigürasyonu zorunludur. Standalone MongoDB instance üzerinde transactions çalışmaz.
  • MongoDB Sürümü: 4.0 veya üzeri olmalı. Sharded cluster için 4.2 veya üzeri gerekiyor.
  • WiredTiger Storage Engine: Varsayılan storage engine olduğu için genellikle sorun çıkmaz ama eski kurulumları kontrol edin.
  • Read/Write Concern Ayarları: Transaction güvenilirliği için doğru concern seviyelerini ayarlamak kritik.

Kurulumunuzu hızlıca test etmek için:

# Replica set durumunu kontrol et
mongosh --eval "rs.status()"

# Storage engine kontrolü
mongosh --eval "db.serverStatus().storageEngine"

# MongoDB sürüm kontrolü
mongosh --eval "db.version()"

Temel Transaction Kullanımı

En basit haliyle bir MongoDB transaction şöyle başlatılır ve yönetilir:

// mongosh ile temel transaction örneği
const session = db.getMongo().startSession();

session.startTransaction({
  readConcern: { level: "snapshot" },
  writeConcern: { w: "majority" }
});

try {
  const ordersCollection = session.getDatabase("ecommerce").orders;
  const inventoryCollection = session.getDatabase("ecommerce").inventory;

  // Sipariş oluştur
  ordersCollection.insertOne({
    orderId: "ORD-2024-001",
    userId: "USR-456",
    productId: "PRD-789",
    quantity: 2,
    status: "pending",
    createdAt: new Date()
  }, { session });

  // Stok güncelle
  inventoryCollection.updateOne(
    { productId: "PRD-789", stock: { $gte: 2 } },
    { $inc: { stock: -2 } },
    { session }
  );

  // Her şey yolundaysa commit et
  session.commitTransaction();
  print("Transaction başarıyla tamamlandı.");

} catch (error) {
  // Hata durumunda rollback
  session.abortTransaction();
  print("Transaction iptal edildi: " + error.message);
} finally {
  session.endSession();
}

Burada dikkat etmeniz gereken kritik nokta: her işleme { session } parametresini geçmeniz gerekiyor. Bu parametre olmadan o işlem transaction kapsamı dışında değerlendirilir.

Gerçek Dünya Senaryosu: Banka Transferi

Klasik ama en açıklayıcı örnek olan banka transferi senaryosunu inceleyelim. İki hesap arasında para transferi yapılırken hem gönderen hesaptan para çekilmeli hem alıcı hesaba para yatırılmalı, hem de işlem kaydı tutulmalı:

async function transferMoney(fromAccountId, toAccountId, amount) {
  const client = await MongoClient.connect(uri);
  const session = client.startSession();

  try {
    session.startTransaction({
      readConcern: { level: "snapshot" },
      writeConcern: { w: "majority" },
      maxCommitTimeMS: 5000
    });

    const accounts = client.db("banking").collection("accounts");
    const transactions = client.db("banking").collection("transactions");

    // Gönderen hesabı kontrol et ve güncelle
    const sender = await accounts.findOneAndUpdate(
      {
        accountId: fromAccountId,
        balance: { $gte: amount }
      },
      { $inc: { balance: -amount } },
      { session, returnDocument: "after" }
    );

    if (!sender.value) {
      throw new Error("Yetersiz bakiye veya hesap bulunamadı.");
    }

    // Alıcı hesabı güncelle
    const receiver = await accounts.findOneAndUpdate(
      { accountId: toAccountId },
      { $inc: { balance: amount } },
      { session, returnDocument: "after" }
    );

    if (!receiver.value) {
      throw new Error("Alıcı hesap bulunamadı.");
    }

    // Transfer kaydı oluştur
    await transactions.insertOne({
      transactionId: new ObjectId().toString(),
      from: fromAccountId,
      to: toAccountId,
      amount: amount,
      status: "completed",
      timestamp: new Date()
    }, { session });

    await session.commitTransaction();
    return { success: true, message: "Transfer tamamlandı." };

  } catch (error) {
    await session.abortTransaction();
    return { success: false, message: error.message };
  } finally {
    await session.endSession();
    await client.close();
  }
}

Bu örnekte findOneAndUpdate ile hem okuma hem yazma işlemini atomik olarak yapıyoruz. Yetersiz bakiye durumunda transaction otomatik olarak geri alınıyor.

Read ve Write Concern Seviyeleri

Transaction güvenilirliği büyük ölçüde concern seviyelerine bağlı. Doğru yapılandırma kritik:

Read Concern Seviyeleri:

  • local: En hızlı, en az güvenli. Replica set’e henüz çoğaltılmamış veriyi okuyabilir.
  • majority: Çoğunluk tarafından onaylanmış veriyi okur.
  • snapshot: Transaction başında tutarlı bir anlık görüntü alır. Transactions için önerilen.

Write Concern Seviyeleri:

  • w: 1: Sadece primary’nin onayını bekler.
  • w: “majority”: Çoğunluğun yazma onayını bekler. Production için şiddetle önerilir.
  • j: true: Journal’a yazıldığını garanti eder.
# MongoDB konfigürasyonunda varsayılan transaction timeout ayarı
# /etc/mongod.conf dosyasına eklenecek kısım

# mongod.conf
net:
  port: 27017
  bindIp: 127.0.0.1

replication:
  replSetName: "rs0"

# Transaction timeout süresi (ms cinsinden)
# Bu değeri doğrudan sunucudan da ayarlayabilirsiniz:
mongosh --eval "db.adminCommand({ setParameter: 1, transactionLifetimeLimitSeconds: 60 })"

Hata Yönetimi ve Retry Mekanizması

Production ortamında transactions yazarken geçici hatalar için retry mekanizması kurmanız şart. MongoDB bazı hataları TransientTransactionError veya UnknownTransactionCommitResult olarak etiketler ve bunlar retry edilebilir:

async function runTransactionWithRetry(txnFunc, session) {
  while (true) {
    try {
      await txnFunc(session);
      break;
    } catch (error) {
      if (error.hasErrorLabel("TransientTransactionError")) {
        console.log("Geçici hata, transaction yeniden deneniyor...");
        continue;
      } else {
        throw error;
      }
    }
  }
}

async function commitWithRetry(session) {
  while (true) {
    try {
      await session.commitTransaction();
      console.log("Transaction başarıyla commit edildi.");
      break;
    } catch (error) {
      if (error.hasErrorLabel("UnknownTransactionCommitResult")) {
        console.log("Commit sonucu belirsiz, yeniden deneniyor...");
        continue;
      } else {
        await session.abortTransaction();
        throw error;
      }
    }
  }
}

async function runTransaction() {
  const session = client.startSession();

  async function updateInventoryAndOrder(session) {
    session.startTransaction();
    // ... işlemler burada
    await commitWithRetry(session);
  }

  try {
    await runTransactionWithRetry(updateInventoryAndOrder, session);
  } finally {
    await session.endSession();
  }
}

Bu pattern MongoDB’nin resmi olarak önerdiği yaklaşım. Özellikle sharded cluster ortamlarında chunk migration sırasında geçici hatalar alabileceğinizi unutmayın.

Performans Optimizasyonu ve İzleme

Transactions performansa etki eder, bunu kabul etmemiz gerekiyor. Doğru optimizasyon yapmadan production’da sorun yaşayabilirsiniz.

Transaction Performansını İzleme:

# Aktif transaction'ları görüntüle
mongosh --eval "db.adminCommand({ currentOp: true, '$all': true })" | grep -A 20 "transaction"

# Transaction istatistiklerini görüntüle
mongosh --eval "db.serverStatus().transactions"

# Uzun süren transaction'ları tespit et (10 saniyeden uzun)
mongosh --eval "
db.adminCommand({
  currentOp: true,
  active: true,
  'transaction.timePreparedMicros': { '$exists': true },
  secs_running: { '$gt': 10 }
})
"

Performans İpuçları:

  • Transaction süresini kısa tutun: Uzun süren transactions lock çakışmalarına ve abort durumlarına yol açar. İdeal süre birkaç saniyeyi geçmemeli.
  • Gereksiz okuma yapmayın: Transaction içinde sadece gerçekten ihtiyaç duyduğunuz verileri okuyun.
  • Index kullanımını garantileyin: Transaction içindeki sorgular mutlaka index kullanmalı, collection scan olmamalı.
  • Döküman boyutunu gözetin: Büyük dökümanlar transaction overhead’ini artırır.
  • Sıra önemlidir: Birden fazla koleksiyona yazarken hep aynı sırada yazın, deadlock olasılığını azaltır.
// YANLIŞ: Transaction içinde gereksiz okuma
session.startTransaction();
const allProducts = await products.find({}).toArray({ session }); // Tüm ürünleri çekme
const product = allProducts.find(p => p.id === productId);

// DOĞRU: Sadece gerekli dokümanı çek
session.startTransaction();
const product = await products.findOne(
  { id: productId },
  { session }
);

Sharded Cluster’da Transaction Kullanımı

MongoDB 4.2 ile gelen sharded cluster transaction desteği birkaç ek konfigürasyon gerektiriyor:

# Mongos üzerinden transaction başlatmak için
# mongos konfigürasyonu (/etc/mongos.conf)
sharding:
  configDB: configrs/cfg1:27019,cfg2:27019,cfg3:27019

# Shard'lar arası transaction için kritik ayar
# Her shard'da replica set olması zorunlu

# Transaction koordinasyon durumunu kontrol et
mongosh --eval "
use admin
db.adminCommand({ serverStatus: 1 }).transactions
"

# Sharded cluster'da hangi shard'ların transaction'a katıldığını görme
mongosh --eval "
db.adminCommand({
  currentOp: true,
  $all: true,
  'twoPhaseCommitCoordinator': { $exists: true }
})
"

Sharded cluster’da transactions için dikkat edilmesi gerekenler:

  • Cross-shard transactions: Birden fazla shard’a dokunan işlemler two-phase commit protokolü kullanır ve bu ek latency demektir.
  • mongos bağlantısı kullanın: Direkt shard bağlantısı yerine her zaman mongos üzerinden bağlanın.
  • Shard key tasarımı: Transaction’ların mümkün olduğunca tek bir shard üzerinde kalması için shard key tasarımınızı ona göre yapın.

Gerçek Dünya Senaryosu: Otel Rezervasyon Sistemi

Birden fazla koleksiyonu etkileyen daha karmaşık bir senaryo:

async function createReservation(roomId, userId, checkIn, checkOut) {
  const session = client.startSession();

  try {
    session.startTransaction({
      readConcern: { level: "snapshot" },
      writeConcern: { w: "majority" }
    });

    const rooms = client.db("hotel").collection("rooms");
    const reservations = client.db("hotel").collection("reservations");
    const users = client.db("hotel").collection("users");
    const billing = client.db("hotel").collection("billing");

    // Oda müsaitliğini kontrol et ve kilitle
    const room = await rooms.findOneAndUpdate(
      {
        roomId: roomId,
        status: "available",
        blockedDates: {
          $not: {
            $elemMatch: {
              $and: [
                { checkIn: { $lt: checkOut } },
                { checkOut: { $gt: checkIn } }
              ]
            }
          }
        }
      },
      {
        $push: {
          blockedDates: { checkIn, checkOut, reservedFor: userId }
        }
      },
      { session, returnDocument: "after" }
    );

    if (!room.value) {
      throw new Error("Oda müsait değil veya belirtilen tarihler dolu.");
    }

    const nights = Math.ceil((checkOut - checkIn) / (1000 * 60 * 60 * 24));
    const totalAmount = room.value.pricePerNight * nights;

    // Rezervasyon oluştur
    const reservation = await reservations.insertOne({
      reservationId: new ObjectId().toString(),
      roomId,
      userId,
      checkIn,
      checkOut,
      totalAmount,
      status: "confirmed",
      createdAt: new Date()
    }, { session });

    // Kullanıcı geçmişini güncelle
    await users.updateOne(
      { userId },
      {
        $push: { reservationHistory: reservation.insertedId },
        $inc: { totalSpent: totalAmount }
      },
      { session }
    );

    // Fatura oluştur
    await billing.insertOne({
      reservationId: reservation.insertedId,
      userId,
      amount: totalAmount,
      status: "pending",
      dueDate: checkIn
    }, { session });

    await session.commitTransaction();
    return { success: true, reservationId: reservation.insertedId };

  } catch (error) {
    await session.abortTransaction();
    throw error;
  } finally {
    await session.endSession();
  }
}

Bu örnekte dört farklı koleksiyona yazma işlemi gerçekleştiriliyor ve tümü transaction kapsamında tutulmuş durumda.

Transaction Limitlerini Anlamak

MongoDB transactions’ın bazı önemli sınırları var:

  • 16MB doküman limiti: Bu değişmez ama transaction içindeki toplam oplog boyutu da 16MB ile sınırlı. Büyük batch işlemlerde bu limite dikkat edin.
  • transactionLifetimeLimitSeconds: Varsayılan değer 60 saniye. Bu süre aşılırsa transaction otomatik abort edilir.
  • DDL operasyonları: Transaction içinde koleksiyon oluşturma, index oluşturma gibi DDL işlemleri yapılamaz.
  • Bazı komutlar kısıtlıdır: listCollections, listIndexes, count (eski sürümler) gibi bazı komutlar transaction içinde kullanılamaz veya kısıtlıdır.
# Transaction lifetime limitini artırma (gerekirse)
mongosh --eval "
db.adminCommand({
  setParameter: 1,
  transactionLifetimeLimitSeconds: 120
})
"

# Oplog boyutunu kontrol et
mongosh --eval "db.adminCommand({ replSetGetStatus: 1 }).oplogSizeMB"

# Mevcut transaction sayısını kontrol et
mongosh --eval "db.serverStatus().transactions.currentActive"

Monitoring ve Alerting

Production’da transaction sağlığını izlemek için şu metriklere odaklanın:

# Transaction istatistiklerini düzenli aralıklarla çeken basit script
cat << 'EOF' > /usr/local/bin/mongo-txn-monitor.sh
#!/bin/bash

MONGOSH_URI="mongodb://localhost:27017"
LOG_FILE="/var/log/mongodb/transaction-stats.log"

while true; do
  TIMESTAMP=$(date '+%Y-%m-%d %H:%M:%S')
  STATS=$(mongosh $MONGOSH_URI --quiet --eval "
    const s = db.serverStatus().transactions;
    print(JSON.stringify({
      currentActive: s.currentActive,
      currentInactive: s.currentInactive,
      currentPrepared: s.currentPrepared,
      totalCommitted: s.totalCommitted,
      totalAborted: s.totalAborted,
      totalStarted: s.totalStarted
    }));
  ")
  echo "$TIMESTAMP $STATS" >> $LOG_FILE
  sleep 30
done
EOF

chmod +x /usr/local/bin/mongo-txn-monitor.sh

İzlenecek kritik metrikler:

  • totalAborted / totalStarted oranı: Bu oran yüksekse transaction’larınızda sık hata var demektir.
  • currentActive: Anlık aktif transaction sayısı sürekli yüksekse performans problemi var olabilir.
  • currentPrepared: Two-phase commit bekleyen transaction sayısı. Sharded cluster’da dikkat edin.

Ne Zaman Transaction Kullanmalı, Ne Zaman Kullanmamalı

Bu kararı doğru vermek çok önemli:

Transaction kullanın:

  • Birden fazla koleksiyonda atomik yazma gerektiğinde
  • Finansal işlemler, stok yönetimi gibi veri tutarsızlığının kabul edilemez olduğu durumlarda
  • Okuma-sonra-yazma işlemlerinde race condition önlemeniz gerektiğinde

Transaction kullanmayın:

  • Tek bir doküman güncelliyorsanız, zaten atomik
  • Sadece okuma işlemi yapıyorsanız, gereksiz overhead ekler
  • Yüksek throughput gerektiren, kısa gecikme toleransı olmayan işlemlerde
  • Veriyi tek doküman altında yeniden modelleyerek atomikliği sağlayabiliyorsanız

MongoDB’nin document model gücünü kullanarak veriyi iç içe dökümanlar şeklinde modellemek, çoğu zaman transaction ihtiyacını ortadan kaldırır. Örneğin bir sipariş ve sipariş kalemleri ayrı koleksiyonlarda tutmak yerine tek doküman içinde array olarak tutabilirsiniz.

Sonuç

MongoDB transactions, doğru kullanıldığında güçlü bir araç. Ama “artık transactions var, her şeyi transaction içine alalım” yaklaşımı yanlış. Her transaction bir performans maliyeti getirir, lock mekanizmaları devreye girer ve yanlış kullanımda sisteminizin throughput’u ciddi şekilde düşebilir.

Production’da iyi bir MongoDB transaction stratejisi şu temellere dayanmalı: önce veri modelinizi optimize edin, mümkün olduğunca tek doküman atomikliğinden yararlanın, kaçınılmaz olduğunda transactions kullanın ve mutlaka retry mekanizması kurun. Read/write concern seviyelerini uygulamanızın gereksinimlerine göre seçin, “majority” her zaman doğru değildir, bazen latency’den taviz vermek istemezsiniz.

İzleme kısmını asla ihmal etmeyin. db.serverStatus().transactions size abort oranlarını, aktif transaction sayısını ve genel sağlık bilgisini verir. Bu metrikleri düzenli olarak izleyen bir monitoring altyapısı kurmak, production’da sürprizlerle karşılaşmanızı engeller.

Sonuç olarak MongoDB’nin transaction desteği artık production-ready ve olgun bir seviyede. Doğru senaryo, doğru konfigürasyon ve doğru hata yönetimiyle hem güvenilir hem de performanslı sistemler kurabilirsiniz.

Yorum yapın