İçeriğe geç
academia.sh

Ders 10 / 21

Dağıtık İşlem Sorunu

Atomikliğin servis sınırını geçememesi: iki ayrı veritabanına yapılan yazmaların yarım kalması, telafi adımlarıyla geri sarma, telafinin de başarısız olabilmesi ve durum değişikliğiyle iletinin aynı işlemde yazıldığı giden kutusu düzeni.

İçindekiler

Önceki dersteki iki çözüm de tek bir veritabanının içinde çalıştı. Kilidi de sürüm sayacını da aynı motor yönetti; ROLLBACK çağrısı bütün yazmaları birlikte geri aldı.

Ödünç servisi tek başına değilse bu dayanak kalkar. Kitap kataloğu bir serviste, üye kayıtları başka bir serviste duruyorsa “ödünç ver” isteği iki ayrı veritabanına dokunur ve hiçbiri ötekinin işlemini göremez. Bu ders o durumu kurar, kaybı ölçer ve iki düzeni gösterir.

Modelin Sınırı

Aşağıdaki düzenek gerçek bir dağıtık sistem değildir. İki servis aynı makinede, iki ayrı SQLite dosyası üzerinde çalışan iki modüldür; aralarında ağ yoktur, çağrılar süreç içinde yapılır. Modelin taşıdığı tek özellik, iki veritabanının ortak bir işlem sınırı paylaşmamasıdır. Atomikliğin kaybı bu özellikten doğar; ağ gecikmesi, kısmi hata ve zaman aşımı gibi gerçek dağıtık sistem sorunları modelde yoktur ve modelden çıkarılamaz.

# kur.sh — iki servisin ayri veritabanlari
rm -f katalog.db uyelik.db
sqlite3 katalog.db >/dev/null <<'SQL'
CREATE TABLE kitap (kitap_id INTEGER PRIMARY KEY, baslik TEXT NOT NULL,
                    durumu TEXT NOT NULL CHECK (durumu IN ('rafta','odunc')));
CREATE TABLE giden_kutusu (ileti_id INTEGER PRIMARY KEY, tur TEXT NOT NULL,
                           kitap_id INTEGER NOT NULL, uye_id INTEGER NOT NULL,
                           durum TEXT NOT NULL DEFAULT 'bekliyor');
INSERT INTO kitap VALUES (1,'Körlük','rafta'),(2,'Tutunamayanlar','rafta'),(3,'Kum Kitabı','rafta');
SQL
sqlite3 uyelik.db >/dev/null <<'SQL'
CREATE TABLE uye (uye_id INTEGER PRIMARY KEY, ad TEXT NOT NULL,
                  acik_odunc INTEGER NOT NULL, sinir INTEGER NOT NULL);
INSERT INTO uye VALUES (4,'Emre',2,3),(5,'Selin',3,3),(6,'Burak',0,9);
SQL

Her servis kendi işlem sınırını kendi içinde kurar. Dışarıya yalnız iş anlamı taşıyan çağrılar verir.

// servisler.mjs — iki ayri veritabani, iki ayri servis; ortak islem yok
import { DatabaseSync } from "node:sqlite";

export const katalog = {
  db: new DatabaseSync("katalog.db"),
  oduncIsaretle(kitapId) {
    this.db.exec("BEGIN IMMEDIATE");
    const s = this.db.prepare(
      "UPDATE kitap SET durumu = 'odunc' WHERE kitap_id = ? AND durumu = 'rafta'").run(kitapId);
    this.db.exec(s.changes === 1 ? "COMMIT" : "ROLLBACK");
    if (s.changes !== 1) throw new Error("katalog: kitap rafta degil");
  },
  raftaIsaretle(kitapId) {
    if (process.env.KATALOG_ERISILEMEZ === "1") throw new Error("katalog: servise ulasilamiyor");
    this.db.exec("BEGIN IMMEDIATE");
    this.db.prepare("UPDATE kitap SET durumu = 'rafta' WHERE kitap_id = ?").run(kitapId);
    this.db.exec("COMMIT");
  },
  durum(kitapId) {
    return this.db.prepare("SELECT durumu FROM kitap WHERE kitap_id = ?").get(kitapId).durumu;
  },
};

export const uyelik = {
  db: new DatabaseSync("uyelik.db"),
  oduncEkle(uyeId) {
    this.db.exec("BEGIN IMMEDIATE");
    const s = this.db.prepare(
      "UPDATE uye SET acik_odunc = acik_odunc + 1 WHERE uye_id = ? AND acik_odunc < sinir")
      .run(uyeId);
    this.db.exec(s.changes === 1 ? "COMMIT" : "ROLLBACK");
    if (s.changes !== 1) throw new Error("uyelik: odunc siniri asildi");
  },
  sayi(uyeId) {
    return this.db.prepare("SELECT acik_odunc FROM uye WHERE uye_id = ?").get(uyeId).acik_odunc;
  },
};

Atomikliğin Kaybı

En doğrudan yazım iki çağrıyı sırayla yapar.

// telafisiz.mjs — iki servisi sirayla cagirir; ikincisi basarisiz olursa geri donus yok
import { katalog, uyelik } from "./servisler.mjs";
const [kitapId, uyeId] = process.argv.slice(2).map(Number);
try {
  katalog.oduncIsaretle(kitapId);
  uyelik.oduncEkle(uyeId);
  console.log(`odunc verildi: kitap ${kitapId} -> uye ${uyeId}`);
} catch (h) {
  console.log("basarisiz:", h.message);
}
console.log(`  katalog: kitap ${kitapId} durumu = ${katalog.durum(kitapId)}`);
console.log(`  uyelik : uye ${uyeId} acik odunc = ${uyelik.sayi(uyeId)}`);

Üye 4’ün iki açık kaydı ve üç sınırı var; isteği geçmeli. Üye 5 sınırında; isteği reddedilmeli.

sh kur.sh
node telafisiz.mjs 1 4
echo "---"
node telafisiz.mjs 2 5
odunc verildi: kitap 1 -> uye 4
  katalog: kitap 1 durumu = odunc
  uyelik : uye 4 acik odunc = 3
---
basarisiz: uyelik: odunc siniri asildi
  katalog: kitap 2 durumu = odunc
  uyelik : uye 5 acik odunc = 3

İkinci istek reddedildi, ama katalogdaki kitap ödünçte kaldı. Ortada kimsenin almadığı, hiçbir üyenin hesabında görünmeyen bir ödünç kitap var. Kimse onu ödünç alamaz; iade de edilemez, çünkü iade edecek bir kayıt yok.

try bloğu bunu düzeltmez. Hata yakalandı, kod hatayı bildirdi; katalogdaki değişiklik zaten kesinleşmişti. Atomiklik işlem sınırının içinde geçerlidir; iki ayrı sınır arasında geçerli değildir.

Klasik çözüm iki evreli kesinleştirmedir: bir eşgüdümcü bütün katılımcılara “hazır mısın” diye sorar, hepsi olumlu yanıt verirse “kesinleştir” der. Bedeli, katılımcıların oy ile karar arasında kilitlerini tutmak zorunda kalmasıdır; eşgüdümcü bu aralıkta erişilemez duruma gelirse katılımcılar belirsiz durumda bekler. Bu maliyet, servis sınırlarının ayrıldığı sistemlerde çoğunlukla kabul edilmez ve aşağıdaki iki düzen tercih edilir.

Telafi Düzeni

Birinci düzen atomikliği taklit eder. Her adımın etkisini geri alan bir telafi adımı tanımlanır; bir adım başarısız olunca daha önce tamamlananlar tersten telafi edilir. Bu düzene saga denir.

// saga.mjs — her adimin bir telafi adimi vardir; hata cikinca tersten yurutulur
import { katalog, uyelik } from "./servisler.mjs";
const [kitapId, uyeId] = process.argv.slice(2).map(Number);

const adimlar = [
  { ad: "katalog.oduncIsaretle", yap: () => katalog.oduncIsaretle(kitapId),
    telafi: () => katalog.raftaIsaretle(kitapId) },
  { ad: "uyelik.oduncEkle", yap: () => uyelik.oduncEkle(uyeId), telafi: () => {} },
];

const yapilan = [];
try {
  for (const a of adimlar) { a.yap(); yapilan.push(a); console.log(`  adim tamam: ${a.ad}`); }
  console.log(`odunc verildi: kitap ${kitapId} -> uye ${uyeId}`);
} catch (h) {
  console.log("  hata:", h.message);
  let eksik = 0;
  for (const a of yapilan.reverse()) {
    try { a.telafi(); console.log(`  telafi edildi: ${a.ad}`); }
    catch (t) { eksik += 1; console.log(`  TELAFI BASARISIZ: ${a.ad} -> ${t.message}`); }
  }
  console.log(eksik === 0 ? "istek geri alindi" : `istek yarim kaldi (${eksik} telafi bekliyor)`);
}
console.log(`  katalog: kitap ${kitapId} durumu = ${katalog.durum(kitapId)}`);
console.log(`  uyelik : uye ${uyeId} acik odunc = ${uyelik.sayi(uyeId)}`);
sh kur.sh
node saga.mjs 2 5
  adim tamam: katalog.oduncIsaretle
  hata: uyelik: odunc siniri asildi
  telafi edildi: katalog.oduncIsaretle
istek geri alindi
  katalog: kitap 2 durumu = rafta
  uyelik : uye 5 acik odunc = 3

Kitap rafa döndü. Dışarıdan bakınca sonuç geri almaya benziyor, ama aynı şey değil. Geri almada değişiklik hiç görünmez; telafide değişiklik bir süre görünür ve sonra ters bir değişiklikle kapatılır. Bu aralıkta kitabı sorgulayan biri onu ödünçte görür. Sistem bu aralıkta tutarsızdır ve tutarlılığa sonradan ulaşır.

Telafi adımının kendisi de bir iş kararıdır. “Ödünç işaretini kaldır” telafi edilebilir bir adımdır. “Üyeye bildirim gönder” edilemez; gönderilmiş bir bildirim geri alınamaz, ancak ikinci bir bildirimle düzeltilebilir. Bu yüzden telafi edilemeyen adımlar sıralamanın sonuna konur.

Telafinin Sınırı

Telafi çağrısı da bir uzak çağrıdır ve başarısız olabilir.

sh kur.sh
KATALOG_ERISILEMEZ=1 node saga.mjs 2 5
  adim tamam: katalog.oduncIsaretle
  hata: uyelik: odunc siniri asildi
  TELAFI BASARISIZ: katalog.oduncIsaretle -> katalog: servise ulasilamiyor
istek yarim kaldi (1 telafi bekliyor)
  katalog: kitap 2 durumu = odunc
  uyelik : uye 5 acik odunc = 3

Baştaki tutarsız durumun aynısına geri dönüldü. Saga düzeni sorunu kaldırmadı, sorumluluğu taşıdı: artık sistemde “telafi bekliyor” durumundaki bir istek var ve birinin onu yeniden denemesi gerekiyor. Bu yüzden telafi adımları kalıcı olarak kaydedilir; süreç çöktüğünde bile bekleyen telafinin izi kalmalıdır.

Giden Kutusu

İkinci düzen sorunu başka bir yerden yakalar. Katalog servisi durumu değiştirirken, “bu oldu” bilgisini de aynı işlemde kendi veritabanına yazar. İki yazma tek bir sınırın içinde olduğu için ya ikisi de olur ya hiçbiri.

// kutuya-yaz.mjs — durum degisikligi ile iletiyi ayni islemde yazar
import { DatabaseSync } from "node:sqlite";
const db = new DatabaseSync("katalog.db");
const [kitapId, uyeId] = process.argv.slice(2).map(Number);

db.exec("BEGIN IMMEDIATE");
const s = db.prepare(
  "UPDATE kitap SET durumu = 'odunc' WHERE kitap_id = ? AND durumu = 'rafta'").run(kitapId);
if (s.changes !== 1) { db.exec("ROLLBACK"); console.log("kitap rafta degil"); process.exit(1); }
db.prepare("INSERT INTO giden_kutusu (tur, kitap_id, uye_id) VALUES ('odunc_verildi',?,?)")
  .run(kitapId, uyeId);
db.exec("COMMIT");
console.log(`katalog: kitap ${kitapId} oduncte, ileti kutuya yazildi`);

İletiyi hedefe götüren iş ayrı bir süreçtir. Bu düzene giden kutusu (outbox) denir.

// aktarici.mjs — kutudaki iletileri hedef servise gonderir; --cokme isaretleme oncesi durur
import { DatabaseSync } from "node:sqlite";
import { uyelik } from "./servisler.mjs";
const cokme = process.argv.includes("--cokme");
const db = new DatabaseSync("katalog.db");

const bekleyenler = db.prepare(
  "SELECT ileti_id, kitap_id, uye_id FROM giden_kutusu WHERE durum = 'bekliyor' ORDER BY ileti_id").all();

for (const i of bekleyenler) {
  uyelik.oduncEkle(i.uye_id);
  console.log(`aktarici: ileti ${i.ileti_id} gonderildi (uye ${i.uye_id})`);
  if (cokme) { console.log("aktarici: isaretleme oncesi coktu"); process.exit(0); }
  db.prepare("UPDATE giden_kutusu SET durum = 'gonderildi' WHERE ileti_id = ?").run(i.ileti_id);
}
console.log(`aktarici: ${bekleyenler.length} ileti islendi`);

Kazanç şudur: durum değiştiyse ileti mutlaka vardır. Aktarıcı çökerse ileti kutuda bekler, aktarıcı yeniden başladığında gönderilir. Bilgi kaybolmaz.

En Az Bir Kez Teslim

Kazancın bir bedeli var. Aktarıcı iletiyi gönderdikten sonra, kutuyu işaretlemeden önce çökebilir.

sh kur.sh
node kutuya-yaz.mjs 3 6
node aktarici.mjs --cokme
echo "-- yeniden calistir --"
node aktarici.mjs
sqlite3 uyelik.db "SELECT uye_id, acik_odunc FROM uye WHERE uye_id = 6;"
sqlite3 katalog.db "SELECT ileti_id, durum FROM giden_kutusu;"
katalog: kitap 3 oduncte, ileti kutuya yazildi
aktarici: ileti 1 gonderildi (uye 6)
aktarici: isaretleme oncesi coktu
-- yeniden calistir --
aktarici: ileti 1 gonderildi (uye 6)
aktarici: 1 ileti islendi
6|2
1|gonderildi

Tek bir ödünç verildi, üyenin açık ödünç sayısı ikiye çıktı. İleti iki kez teslim edildi ve etkisi iki kez uygulandı.

Bu kaçınılmazdır. Aktarıcının “gönderdim” bilgisini yazması ile hedefin iletiyi işlemesi iki ayrı veritabanında olur; yani ilk bölümdeki sorunun aynısı, bir katman aşağıda yeniden karşımızdadır. Sonsuz gerilemeyi kesen tek yol, garantiyi düşürmek ve sonucu düzeltmektir: teslim en az bir kez yapılır, alıcı da aynı iletiyi ikinci kez aldığında ek bir etki üretmez. Alıcının bu özelliği taşıması, giden kutusu düzeninin karşılanması gereken koşuludur.

Özet

  • İki ayrı veritabanına yapılan yazmalar ortak bir işlem sınırı paylaşmaz; ikinci çağrı başarısız olduğunda birinci kesinleşmiş kaldı ve kimsenin almadığı bir ödünç kitap oluştu.
  • İki evreli kesinleştirme atomikliği korur, bedeli katılımcıların karar beklerken kilit tutmasıdır.
  • Telafi düzeni tamamlanan adımları tersten geri sarar; geri almadan farkı, tutarsız durumun bir süre görünür olmasıdır.
  • Telafi çağrısı da başarısız olabilir; bu durumda düzen sorunu kaldırmaz, bekleyen bir telafi kaydı bırakır ve yeniden denenmesini gerektirir.
  • Giden kutusu, durum değişikliğiyle iletiyi aynı işlemde yazarak bilgi kaybını önler; bedeli, iletinin en az bir kez — bazen birden çok kez — teslim edilmesidir.

Sonraki Adım

Giden kutusu düzeninin karşılanmamış tek koşulu kaldı: aynı iletinin ikinci kez işlenmesi ek bir etki üretmemeli. Bu ölçümde üyenin açık ödünç sayısı ikiye çıktı, yani koşul sağlanmadı. Aynı sorun ağ üzerinden gelen her yeniden denemede belirir; istemci zaman aşımı alıp isteği tekrarladığında sunucunun ilk isteği işlemiş olup olmadığını bilmesi gerekir. Sonraki ders bir işlemin tekrarının neden ek etki üretmediğini tanımlar, doğal olarak bu özelliği taşıyan yazmalarla taşımayanları ayırır ve taşımayanları tekillik anahtarıyla güvenli hâle getirir.

İlerlemeni kaydetmek ve not almak için Giriş yap

Notlarım

Not almak için giriş yapmalısın.

Aramak için yazmaya başlayın.

↑↓ Esc gezin · aç · kapat