

Giriş
Dağıtık sistemlerde ve mobil uygulamalarda ağ bağlantısının her zaman stabil kalacağını varsaymak, mimari düzeyde yapılabilecek en maliyetli hatadır. 2019 yılında saha operasyonları yürüten bir lojistik uygulamasında, klasik Last-Write-Wins (Son Yazan Kazanır) ve sunucu tabanlı zaman damgası (timestamp) senkronizasyonu kullandığımız için cihazlar çevrimdışı kaldığında %4.2 oranında veri ezilmesi (data override) vakasıyla karşılaştık. İki farklı kurye aynı teslimat kaydını internetsiz ortamda güncellediğinde, sunucuya ilk bağlanan değil, en son bağlanan diğerinin verisini yok ediyordu.
Bu problemi çözmenin yolu, Conflict-free Replicated Data Types (CRDT) kavramını istemci tarafına (client-side) indirmekten geçiyor. Ağ gecikmesinin (latency) 3000ms üzerine çıktığı veya %100 paket kaybı yaşanan durumlarda bile deterministik veri bütünlüğü sağlamak mümkündür. İstemciler kendi yerel durumlarını (state) günceller ve bağlantı sağlandığında merkezi bir otoriteye ihtiyaç duymadan, matematiksel olarak kanıtlanmış bir şekilde verileri birleştirir (merge).
Sıfırdan inşa edeceğimiz bu senkronizasyon motorunda, zaman damgalarındaki sapmaları (clock skew) tolere edebilmek için Hybrid Logical Clocks (HLC) ve Flutter üzerinde SQLite tabanlı bir LWW-Register (Last-Write-Wins Register) CRDT modeli kurgulayacağız.
İçindekiler
- Adım 1: Veri Yapısı ve Hybrid Logical Clock (HLC) Tasarımı
- Adım 2: SQLite ile Yerel Depolama (Offline Storage) Entegrasyonu
- Adım 3: Senkronizasyon Motoru ve Algoritması
- Adım 4: Çakışma Yönetimi (Conflict Resolution) Uygulaması
- Mimari Kararlar ve Trade-off Analizi
- Production Hazırlığı Kontrol Listesi
- Sık Sorulan Sorular
- Sonuç
Adım 1: Veri Yapısı ve Hybrid Logical Clock (HLC) Tasarımı
Mobil cihazların sistem saatlerine güvenemezsiniz. Kullanıcı saati manuel olarak değiştirebilir veya NTP (Network Time Protocol) senkronizasyonu günlerce yapılamamış olabilir. Bu nedenle geleneksel DateTime.now() yerine, hem fiziksel zamanı hem de mantıksal olay sırasını tutan HLC kullanacağız.
HLC temelde üç bileşenden oluşur: Fiziksel zaman (milisaniye), mantıksal sayaç (aynı milisaniyede gerçekleşen işlemleri sıralamak için) ve Node ID (aynı saat ve sayaç değerine sahip eşzamanlı işlemlerde determinizm sağlamak için cihazın benzersiz kimliği).
class HLC implements Comparable<HLC> {
final int millis;
final int counter;
final String nodeId;
HLC(this.millis, this.counter, this.nodeId);
// Yeni bir olay gerçekleştiğinde saat üretimi
static HLC send(HLC localClock, int physicalTime) {
if (physicalTime > localClock.millis) {
return HLC(physicalTime, 0, localClock.nodeId);
}
return HLC(localClock.millis, localClock.counter + 1, localClock.nodeId);
}
// Remote'tan veri geldiğinde saati güncelleme
static HLC receive(HLC localClock, HLC remoteClock, int physicalTime) {
final maxMillis = [localClock.millis, remoteClock.millis, physicalTime]
.reduce((a, b) => a > b ? a : b);
if (maxMillis == localClock.millis && maxMillis == remoteClock.millis) {
return HLC(maxMillis, [localClock.counter, remoteClock.counter].reduce((a, b) => a > b ? a : b) + 1, localClock.nodeId);
} else if (maxMillis == localClock.millis) {
return HLC(maxMillis, localClock.counter + 1, localClock.nodeId);
} else if (maxMillis == remoteClock.millis) {
return HLC(maxMillis, remoteClock.counter + 1, localClock.nodeId);
}
return HLC(maxMillis, 0, localClock.nodeId);
}
@override
int compareTo(HLC other) {
if (millis != other.millis) return millis.compareTo(other.millis);
if (counter != other.counter) return counter.compareTo(other.counter);
return nodeId.compareTo(other.nodeId);
}
String pack() => '${millis.toRadixString(36)}:${counter.toRadixString(36)}:$nodeId';
static HLC unpack(String packed) {
final parts = packed.split(':');
return HLC(int.parse(parts[0], radix: 36), int.parse(parts[1], radix: 36), parts[2]);
}
}
Veri modelimizde (örneğin bir ‘Görev’ objesi) veriyi fiziksel olarak silmek yerine (hard delete) is_deleted bayrağı kullanacağız. Buna CRDT terminolojisinde Tombstone denir.
Adım 2: SQLite ile Yerel Depolama (Offline Storage) Entegrasyonu
Flutter tarafında depolama için sqflite paketini kullanacağız. Production ortamında disk I/O beklemelerini minimize etmek için WAL (Write-Ahead Logging) modunu aktif etmelisiniz. Standart Journal modundan WAL moduna geçiş, yazma blokajlarını ortadan kaldırarak p99 sorgu sürelerini 18ms’den 3ms’nin altına düşürür.
import 'package:sqflite/sqflite.dart';
import 'package:path/path.dart';
class DatabaseProvider {
Database? _db;
Future<Database> get database async {
if (_db != null) return _db!;
_db = await _initDB();
return _db!;
}
Future<Database> _initDB() async {
final dbPath = await getDatabasesPath();
final path = join(dbPath, 'offline_sync.db');
return await openDatabase(
path,
version: 1,
onConfigure: (db) async {
// Production Kritik: WAL modunu aktif et
await db.execute('PRAGMA journal_mode = WAL;');
// Asenkron disk yazma (I/O performans artışı)
await db.execute('PRAGMA synchronous = NORMAL;');
},
onCreate: (db, version) async {
await db.execute('''
CREATE TABLE tasks (
id TEXT PRIMARY KEY,
title TEXT,
status TEXT,
hlc TEXT NOT NULL,
is_deleted INTEGER DEFAULT 0
)
''');
},
);
}
}
Adım 3: Senkronizasyon Motoru ve Algoritması
Motorun temel görevi, arkaplanda ağ durumunu dinlemek ve bağlantı geldiğinde yerel ile uzak veri arasındaki farkları (diffing) bulmaktır. Her kayıt kendi HLC’sine sahip olduğu için, tüm tabloyu göndermek yerine sadece son senkronizasyon zamanından daha büyük HLC’ye sahip olan kayıtları çekeceğiz/göndereceğiz.
class SyncEngine {
final DatabaseProvider dbProvider;
final ApiClient apiClient;
String lastSyncHlcPack = '0:0:0';
SyncEngine(this.dbProvider, this.apiClient);
Future<void> performSync() async {
final db = await dbProvider.database;
// 1. Yerel değişiklikleri bul (Push)
final localChanges = await db.query(
'tasks',
where: 'hlc > ?',
whereArgs: [lastSyncHlcPack],
);
if (localChanges.isNotEmpty) {
await apiClient.pushChanges(localChanges);
}
// 2. Uzak değişiklikleri al (Pull)
final remoteChanges = await apiClient.pullChanges(since: lastSyncHlcPack);
// 3. Değişiklikleri birleştir (Merge)
if (remoteChanges.isNotEmpty) {
await _mergeChanges(db, remoteChanges);
}
// 4. Son senkronizasyon zamanını güncelle
_updateLastSyncHlc(localChanges, remoteChanges);
}
}
Adım 4: Çakışma Yönetimi (Conflict Resolution) Uygulaması
İşin en kritik noktası _mergeChanges metodudur. Aynı satır hem sunucuda hem de yerelde değişmiş olabilir. CRDT LWW (Last-Write-Wins) mantığına göre, iki verinin HLC’sini karşılaştırırız. HLC sınıfındaki compareTo metodu sayesinde, milisaniyeler eşit olsa bile sayaçlara, sayaçlar eşitse Node ID’ye bakılarak %100 deterministik bir galip belirlenir.
Future<void> _mergeChanges(Database db, List<Map<String, dynamic>> remoteChanges) async {
await db.transaction((txn) async {
for (var remoteRecord in remoteChanges) {
final localRecords = await txn.query(
'tasks',
where: 'id = ?',
whereArgs: [remoteRecord['id']],
);
if (localRecords.isEmpty) {
// Kayıt yerelde yoksa doğrudan ekle
await txn.insert('tasks', remoteRecord);
} else {
// Çakışma kontrolü
final localRecord = localRecords.first;
final localHlc = HLC.unpack(localRecord['hlc'] as String);
final remoteHlc = HLC.unpack(remoteRecord['hlc'] as String);
// Eğer remote HLC > local HLC ise veriyi ez
if (remoteHlc.compareTo(localHlc) > 0) {
await txn.update(
'tasks',
remoteRecord,
where: 'id = ?',
whereArgs: [remoteRecord['id']],
);
}
// Aksi takdirde yerel veriyi koru (ignore)
}
}
});
}
Mimari Kararlar ve Trade-off Analizi
| Mimari Yaklaşım | Kullanım Senaryosu | Dezavantajı (Trade-off) |
|---|---|---|
| CRDT (LWW-Register) | Dağıtık ve tamamen çevrimdışı çalışabilen P2P veya İstemci-Sunucu sistemler. Cihazlar uzun süre offline kalıyorsa idealdir. | Tombstone (silinen verilerin saklanması) nedeniyle veritabanı boyutu zamanla şişer. Garbage Collection mekanizması gerektirir. |
| Operational Transformation (OT) | Google Docs gibi eşzamanlı, düşük gecikmeli (low-latency) kolaboratif metin editörleri. | Merkezi bir sunucunun operasyonları sıralaması zorunludur. Gerçek offline-first senaryolarda implementasyonu inanılmaz derecede karmaşıktır. |
| Pessimistic Locking | Banka hesap transferleri, uçak bileti rezervasyonu gibi kesin tutarlılık (strict consistency) gereken yerler. | Kullanıcı çevrimdışıysa hiçbir işlem yapamaz. Kullanıcı deneyimi kesintiye uğrar. |
Bizim tasarımımızda Event Sourcing yerine State-based (Durum tabanlı) CRDT seçtik. Event Sourcing, tüm işlem geçmişini (mutations) sakladığı için ağ yükünü artırır ve payload boyutunu megabaytlar seviyesine çıkarabilir. LWW-Register ise sadece son durumu (state) ve HLC değerini taşır, bu da her bir güncellemenin payload boyutunu ortalama 200 byte civarında tutar.
Production Hazırlığı Kontrol Listesi
- Batching (Gruplama): Binlerce değişikliği tek bir HTTP isteğinde göndermeyin. Ağ kesintilerinde retry operasyonlarının maliyetini düşürmek için 50’şer veya 100’erlik chunk’lar halinde gönderim (Push) yapın.
- Tombstone Garbage Collection:
is_deleted = 1olan kayıtları sonsuza dek saklayamazsınız. Minimum eşik değeri (örneğin 30 gün) belirleyin ve 30 günden eski HLC değerine sahip silinmiş kayıtları veritabanından kalıcı olarak silen (hard delete) bir arka plan görevi (worker) yazın. - Gzip Compression: Senkronizasyon payload’ları genellikle tekrar eden JSON verileridir. HTTP katmanında
Accept-Encoding: gzipkullanarak payload boyutunda %60-70 bandında tasarruf sağlayın. - İndeksleme: SQLite tablosunda
hlcveis_deletedkolonlarına indeks (B-Tree index) atamayı unutmayın. Aksi takdirdewhere hlc > ?sorgusu table-scan yaparak cihazın pilini ve CPU’sunu tüketir.
Monitoring Notu: Senkronizasyon süresini ve çakışma (merge conflict) oranlarını metrik olarak toplayın. Eğer çakışma oranı beklenen eşiğin (örneğin %1) üzerine çıkıyorsa, sistemdeki istemcilerin senkronizasyon frekansını sıklaştırmanız (örneğin her 15 dakikada bir yerine, socket bağlantısı ile anlık) gerekebilir.
Sık Sorulan Sorular
Büyük metin bloklarında (örneğin makale yazımı) LWW-Register kullanmak veri kaybına yol açar mı?
Evet. Tüm metni tek bir alan (field) olarak tutuyorsanız, iki kullanıcının farklı paragraflarda yaptığı değişiklikler birbirini ezer. Sadece HLC değeri büyük olanın kaydı kalır. Uzun metinler için LWW-Register yerine metnin her bir karakterini veya kelimesini ayrı bir düğüm (node) olarak kabul eden Sequence CRDT (Örn: Yjs, Automerge) kütüphaneleri kullanılmalıdır.
Sunucu (Backend) tarafında nasıl bir yapı kurulmalı?
Sunucu aslında sadece bir “Dumb Store” (Aptal Depo) olarak davranmalıdır. Sunucu verileri birleştirme (merge) mantığı içermez. Sadece istemciden gelen kayıtların HLC değerini kontrol eder, mevcut HLC’den büyükse kendi veritabanını günceller. Çakışma çözümü tamamen istemcilerin (client) sorumluluğundadır.
Senkronizasyon esnasında cihaz aniden kapanırsa veritabanı bozulur mu?
Adım 4’teki koda dikkat ederseniz db.transaction() kullandık. SQLite ACID uyumludur ve işlemler Transaction bloğu içinde yapıldığı için, cihazın gücü kesilse bile ya tüm batch işlenmiş olur ya da hiçbirisi işlenmez (Rollback). Kısmi (partial) yazma durumu engellenmiş olur.
Clock Skew (Saat Sapması) HLC’yi nasıl etkiler?
Bir kullanıcının saati geleceğe ayarlanmışsa (örneğin 2030 yılı), ürettiği HLC çok büyük olacaktır. Bu cihazdan gelen veriler sistemdeki diğer tüm verileri sonsuza dek ezebilir. Bunu engellemek için, backend tarafına gelen kaydın fiziksel zaman dilimi (millis) sunucu saatinden 24 saatten daha fazla ilerideyse isteği 400 Bad Request ile reddeden bir güvenlik bariyeri konulmalıdır.
Sonuç
Offline-first mimari bir eklenti (feature) değil, sistem tasarımının temel taşıdır. SQLite WAL konfigürasyonlarıyla I/O limitlerini aşarak ve HLC tabanlı deterministik bir çakışma çözümü uygulayarak ağ bağlantısından tamamen bağımsız, kendi kendine yetebilen mobil istemciler yarattık.
Action Item: Mevcut REST tabanlı CRUD yapınızı parçalamadan önce, veri modelinize HLC mantığını ve Tombstone sütunlarını dahil ederek istemcilerinizdeki Local Storage yapısını tek bir tablo üzerinden bu mimariye uyarlamaya başlayın.
Bunları da beğenebilirsiniz

VictoriaMetrics’te Polimorfik İndeksleme: Yaşlanan Zaman Serisi Verilerinde Sorgu Gecikmesini Sabitleme
VictoriaMetrics’in yaşlanan zaman serisi verileri için polimorfik indekslemeyi nasıl kullandığını keşfedin. Bu yenilikçi yaklaşım, üretim seviyesi sorgu gecikmelerini önemli ölçüde azaltarak veri erişimini optimize eder ve operasyonel verimliliği artırır.

