Tüm yazılar
Veri Otomasyonu 6 dk okuma

SQLite ile küçük ama sağlam veri boru hatları

SQLite ile tek dosyalık ama dayanıklı veri boru hattı: WAL kipi, işlem sınırları, UPSERT ile güvenli yeniden çalıştırma, şema sürümleme ve .backup ile doğru yedek.

#sqlite

Küçük ekiplerde ve tek kişilik otomasyon işlerinde en sık gördüğüm refleks, daha ilk günden bir veritabanı sunucusu kurmaya kalkmak. Oysa günde birkaç bin satır işleyen, zamanlayıcıyla tetiklenen ve tek makinede koşan bir iş için SQLite ile veri boru hattı kurmak çoğu zaman hem daha sade hem de daha dayanıklı bir yol. Tek dosya, kurulacak servis yok, taşıması bir kopyala-yapıştır kadar kolay.

Ama "tek dosya" demek "özensiz" demek değil. Birkaç projede aynı yerlerde takıldıktan sonra kendime dört kural koydum: günlük kipini WAL yap, işlem sınırlarını bilinçli çiz, yazma adımlarını yeniden çalıştırılabilir kur, şemayı sürümle. Bir de yedeği dosyayı kopyalayarak değil, SQLite'ın kendi mekanizmasıyla al.

Bu yazıda bu kuralları Python üzerinden, çalışan örneklerle anlatacağım. Örnekler standart kütüphanedeki sqlite3 modülüyle yazıldı; ek paket gerekmiyor. Kod parçaları birbirinin devamı, aynı bağlantı nesnesini kullanıyor.

Neden SQLite, nerede durmalı

SQLite'ı "oyuncak veritabanı" diye küçümsemek yaygın ama yanlış. Tarayıcılardan telefonlara kadar her yerde çalışıyor ve dosya formatı uzun vadeli geriye uyumluluk sözüyle korunuyor. Bir boru hattı için asıl soru "yeterince güçlü mü" değil, "bu iş yükü SQLite'ın tasarım sınırlarına uyuyor mu".

  • Uyar: tek makinede koşan toplu işler, zamanlanmış ETL adımları, izleme ve günlük toplama, ara sonuçların kalıcı tutulması, tek makinenin diskine rahatça sığan veri.
  • Uymaz: aynı anda çok sayıda süreç yazacaksa, dosya bir ağ paylaşımında (NFS, SMB) duruyorsa, birden fazla sunucu aynı veriye yazıyorsa.

Ben örneğin bir PowerShell izleme betiğinin ürettiği ölçümleri ya da bir Excel kalite kontrol tablosundan gelen kayıtları toplarken hep SQLite'a yazıyorum. İş büyürse elimde zaten temiz bir şema oluyor; taşımak, sıfırdan tasarlamaktan kolay.

WAL kipi: okuyanla yazan birbirini beklemesin

SQLite'ın varsayılan günlük kipi (rollback journal) yazma sırasında okuyucuları bekletir. Boru hattında bunun karşılığı şu: yükleme sürerken rapor çeken başka bir süreç "database is locked" hatası alır. WAL (Write-Ahead Logging) kipi bu tabloyu değiştirir; okuyucular yazıcıyı, yazıcı okuyucuları engellemez. Yine de aynı anda tek yazıcı olabilir, o kural değişmiyor.

Bağlantıyı açarken hep şu ayarları birlikte yapıyorum:

import sqlite3

def baglan(yol: str) -> sqlite3.Connection:
    conn = sqlite3.connect(yol, timeout=10)   # kilitliyse 10 sn bekle
    conn.row_factory = sqlite3.Row
    conn.execute("PRAGMA journal_mode=WAL")
    conn.execute("PRAGMA synchronous=NORMAL")
    conn.execute("PRAGMA foreign_keys=ON")
    return conn

conn = baglan("olcumler.db")
print(conn.execute("PRAGMA journal_mode").fetchone()[0])   # wal
  • journal_mode=WAL dosyanın kendisine yazılır; bir kez ayarlamak yeter ama her açılışta tekrarlamanın zararı yok.
  • synchronous=NORMAL, WAL kipinde uygulama çökmesine karşı güvenlidir. Elektrik kesintisinde en son işlemler kaybolabilir ama dosya bozulmaz. Bu ödünleşimi kabul edemiyorsanız FULL bırakın.
  • Bekleme süresi (timeout) olmadan ikinci bir yazıcı anında hata alır; birkaç saniye beklemek çoğu çakışmayı sessizce çözer.
  • WAL ağ dosya sistemlerinde güvenilir çalışmaz; dosya bir NFS ya da SMB paylaşımındaysa kip ayarlanmış görünse bile kilitleme mekanizmasına güvenmeyin.

WAL kipinde ana dosyanın yanında -wal ve -shm uzantılı iki dosya daha belirir. Bunları elle silmeyin, yedek bölümünde nedenini göreceksiniz.

İşlem sınırlarını bilinçli çizmek

SQLite'ta tek başına çalışan her INSERT kendi işlemini açıp kapatır; her kapanış ayrı bir günlük yazımı ve çoğu ayarda diske eşitleme demektir. Bin satırı tek tek yazıp her birini ayrı commit'lemek, aynı bin satırı tek işlemde yazmaktan belirgin biçimde yavaştır; bunu ilk ölçtüğümde farkın bu kadar açık olmasını beklemiyordum. Kural basit: bir mantıksal iş, bir işlem.

  • Bir dosyanın tamamı, bir API sayfası ya da bir günün kayıtları tek işlemde girsin. Toplu yüklemede executemany ile tek işlem, döngü içinde tek tek execute çağırıp her seferinde commit etmekten hem daha kısa hem daha hızlıdır.
  • Python'da with conn: bloğu işlemi başarıda commit eder, hata fırlarsa geri alır. Bağlantıyı kapatmaz; bu ayrıntı çok karıştırılıyor.
  • Çok büyük yüklerde işlemi birkaç bin satırlık parçalara bölün; hem bellek hem kilit süresi kontrol altında kalır.
  • Bir işlemi dakikalarca açık tutmayın; WAL dosyası büyür ve okuyucular eski bir görüntüye takılı kalır.

UPSERT ile yeniden çalıştırılabilir yazma

Boru hattı bir gün mutlaka yarıda kesilecek: ağ kopar, disk dolar, zamanlayıcı aynı işi iki kez tetikler. Bu noktada sorulacak soru "kesilirse ne olur" değil, "aynı işi tekrar çalıştırırsam ne olur". Cevap "hiçbir şey bozulmaz, eksik olan tamamlanır" ise hattınız idempotent demektir; benim gözümde dayanıklılığın asıl ölçüsü bu.

SQLite'ta bunu en sade sağlayan araç INSERT ... ON CONFLICT DO UPDATE, yani UPSERT. Ön koşul, kaydı doğal olarak tanımlayan bir anahtar üzerinde tekillik kısıtı: kaynak sistemin kimliği, dosya adı ile satır numarası ya da aşağıdaki gibi sensör adı ile zaman damgası.

from datetime import datetime, timezone

SEMA = """
CREATE TABLE IF NOT EXISTS olcum (
    kaynak      TEXT NOT NULL,
    zaman       TEXT NOT NULL,
    deger       REAL NOT NULL,
    guncellendi TEXT NOT NULL,
    PRIMARY KEY (kaynak, zaman)
);
CREATE TABLE IF NOT EXISTS calisma (
    id      INTEGER PRIMARY KEY,
    baslama TEXT NOT NULL,
    bitis   TEXT,
    durum   TEXT NOT NULL,
    satir   INTEGER NOT NULL DEFAULT 0
);
"""

UPSERT = """
INSERT INTO olcum (kaynak, zaman, deger, guncellendi)
VALUES (?, ?, ?, ?)
ON CONFLICT(kaynak, zaman) DO UPDATE SET
    deger       = excluded.deger,
    guncellendi = excluded.guncellendi
"""

def yukle(conn: sqlite3.Connection, satirlar) -> int:
    satirlar = list(satirlar)
    simdi = datetime.now(timezone.utc).isoformat(timespec="seconds")
    conn.executescript(SEMA)
    with conn:   # kosum kaydi kendi isleminde: hata olsa da iz kalsin
        cur = conn.execute(
            "INSERT INTO calisma (baslama, durum) VALUES (?, 'basladi')", (simdi,))
        calisma_id = cur.lastrowid
    try:
        with conn:   # veri tek islemde: ya hepsi girer ya hicbiri
            conn.executemany(UPSERT, [(k, z, d, simdi) for k, z, d in satirlar])
            conn.execute(
                "UPDATE calisma SET bitis=?, durum='bitti', satir=? WHERE id=?",
                (simdi, len(satirlar), calisma_id))
    except Exception:
        with conn:
            conn.execute(
                "UPDATE calisma SET bitis=?, durum='hata' WHERE id=?",
                (simdi, calisma_id))
        raise
    return calisma_id

satirlar = [("cpu", "2026-09-05T09:00:00Z", 41.5),
            ("cpu", "2026-09-05T09:05:00Z", 38.0)]
yukle(conn, satirlar)
yukle(conn, satirlar)   # ikinci kosum: ayni veri, satir sayisi degismez
print(conn.execute("SELECT COUNT(*) FROM olcum").fetchone()[0])   # 2
  • excluded takma adı, çakışan yeni satırın değerlerini temsil eder; hangi sütunların güncelleneceğini siz seçersiniz.
  • UPSERT için SQLite 3.24 ve üzeri gerekir; Python'ın hangi sürümü bağladığını sqlite3.sqlite_version ile görebilirsiniz.
  • Kaynakta kimlik yoksa satırın içeriğinden üretilen bir karma (hash) değerini anahtar yapmak işe yarar.
  • Var olan kaydı hiç dokunmadan geçmek istiyorsanız DO UPDATE yerine DO NOTHING kullanın; bir kez yazılıp değişmeyen günlük kayıtlarında bu daha temiz.
  • Koşum kaydını veriden ayrı bir işlemde açıyorum ki yükleme patlasa bile "başladı ama bitmedi" izi kalsın. "Dün gece gerçekten çalıştı mı" sorusunu tahminle değil kayıtla cevaplarsınız; sessizce başarısız olan işleri bu tablo yakalar.

Şemayı sürümlemek ve doğru yedek almak

Şema değişecek: sütun eklenecek, indeks gelecek. Ben bunu PRAGMA user_version ile yönetiyorum; dosya başlığında tutulan bir tam sayı, varsayılanı sıfır. Her göç adımı listeye bir eleman olarak eklenir, program açılışta hangi sürümde olduğuna bakar ve eksikleri sırayla uygular. SQLite'ta DDL de işlem içinde çalıştığı için yarım kalan bir adım geri alınır; dosya iki sürüm arasında kalmaz. Bir önceki örnekteki CREATE TABLE IF NOT EXISTS bloğunun yerini artık bu liste alıyor.

GOCLER = {
    1: [
        """CREATE TABLE IF NOT EXISTS olcum (
               kaynak TEXT NOT NULL, zaman TEXT NOT NULL,
               deger REAL NOT NULL, guncellendi TEXT NOT NULL,
               PRIMARY KEY (kaynak, zaman))""",
    ],
    2: [
        "ALTER TABLE olcum ADD COLUMN birim TEXT NOT NULL DEFAULT ''",
        "CREATE INDEX IF NOT EXISTS ix_olcum_zaman ON olcum(zaman)",
    ],
}

def goc_uygula(conn: sqlite3.Connection) -> int:
    mevcut = conn.execute("PRAGMA user_version").fetchone()[0]
    for surum in sorted(GOCLER):
        if surum <= mevcut:
            continue
        conn.execute("BEGIN")
        try:
            for ifade in GOCLER[surum]:
                conn.execute(ifade)
            conn.execute(f"PRAGMA user_version = {int(surum)}")
            conn.execute("COMMIT")
        except Exception:
            conn.execute("ROLLBACK")
            raise
        mevcut = surum
    return mevcut

print("sema surumu:", goc_uygula(conn))   # 2
print("sema surumu:", goc_uygula(conn))   # 2 -- ikinci cagri bos gecer

Listeye bir kez giren adımı bir daha değiştirmeyin; yanlışsa düzelten yeni bir adım ekleyin. Aksi hâlde eski dosyayla yeni kod farklı şemalar üretir ve bunu ancak bir sorgu patladığında fark edersiniz.

Yedekte en sık gördüğüm hata, cp ya da Copy-Item ile .db dosyasını kopyalayıp "yedek aldım" demek. WAL kipinde son işlemler henüz ana dosyaya taşınmamış olabilir; yalnız ana dosyayı kopyalarsanız eksik, yazma sırasında kopyalarsanız tutarsız bir kopya elde edersiniz. SQLite'ın çevrimiçi yedekleme mekanizması bu sorunu çözer ve komut satırı aracında .backup komutu olarak karşınıza çıkar:

# Tutarli cevrimici yedek: yazma surerken bile guvenli
sqlite3 olcumler.db ".backup 'yedek/olcumler-$(date +%F).db'"

# Alternatif: kopyala ve sikistir (SQLite 3.27+)
sqlite3 olcumler.db "VACUUM INTO 'yedek/olcumler-vacuum.db'"

# Dogrulamadan "yedek var" deme
sqlite3 "yedek/olcumler-$(date +%F).db" "PRAGMA integrity_check"
  • Python tarafında aynı iş kaynak.backup(hedef) ile yapılır; iki bağlantı açıp birinden diğerine kopyalarsınız, yazma devam ederken bile tutarlı sonuç verir.
  • Yedeği aldıktan sonra açıp PRAGMA integrity_check çalıştırmadan "yedek var" demeyin. Açılmayan yedek, yedek değildir.
  • Yedek dosyasını ayrı bir diske ya da nesne depolamaya taşımak ayrı bir adım; SQLite bunu sizin yerinize yapmaz. Yedeği zamanlayın, eski kopyaları kendiliğinden silen bir kural koyun; elle alınan yedek, unutulan yedektir.

Beş kuralın hiçbiri tek başına yeni değil; farkı, hepsinin aynı boru hattında bir arada olması yaratıyor. WAL kipi okuyucuyla yazıcıyı ayırır, doğru işlem sınırı hızı ve bütünlüğü korur, UPSERT yeniden çalıştırmayı güvenli kılar, sürümlenmiş şema kodla dosyanın uyumunu garanti eder, çevrimiçi yedek de bütün bunları bir felakette anlamlı hâle getirir. Bu düzeni kurduktan sonra "gece çalıştı mı, tekrar çalıştırsam bir şey bozulur mu" soruları büyük ölçüde masadan kalkıyor. Sunucuya geçme kararını da artık bir korkuyla değil, elinizdeki koşum kayıtlarına bakarak verirsiniz.

Küçük ama dayanıklı otomasyonlar kurmak veri ve otomasyon danışmanlığı işimin temel yaklaşımı. Elinizdeki veri akışını benzer bir yapıya oturtmak isterseniz iletişim sayfasından bana yazabilirsiniz; hangi noktada SQLite'ın yettiğini, nerede bir sunucuya geçmek gerektiğini birlikte netleştiririz.