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.
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=WALdosyanı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ızFULLbı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
executemanyile tek işlem, döngü içinde tek tekexecuteç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
excludedtakma 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_versionile 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 UPDATEyerineDO NOTHINGkullanı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.