Flutter ve CRDT ile Sıfırdan Offline-First Senkronizasyon Motoru İnşası
Blog'a Dön

Flutter ve CRDT ile Sıfırdan Offline-First Senkronizasyon Motoru İnşası

Buğra Şıkel

Flutter ve CRDT ile Sıfırdan Offline-First Senkronizasyon Motoru İnşası

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 = 1 olan 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: gzip kullanarak payload boyutunda %60-70 bandında tasarruf sağlayın.
  • İndeksleme: SQLite tablosunda hlc ve is_deleted kolonlarına indeks (B-Tree index) atamayı unutmayın. Aksi takdirde where 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&#8217;te Polimorfik İndeksleme: Yaşlanan Zaman Serisi Verilerinde Sorgu Gecikmesini Sabitleme
23 Mart 2026

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.

Devamını Oku
Neden Cloudflare Kullanmalıyız?
26 Ekim 2022

Neden Cloudflare Kullanmalıyız?

Merhabalar, bu içeriğimde sizlere Cloudflare neden kullanmalıyız, Cloudflare bize ne gibi avantajlar sağlar, faydaları nelerdir, kurulumunu nasıl yapabiliriz gibi soruları cevaplandıracağım. Öncelikle yazımızın akışını belirlemek…

Devamını Oku
Dağıtık Sistemlerde Context Propagation Hataları: İzlenebilirlik Kayıplarını Teşhis Etme
26 Nisan 2026

Dağıtık Sistemlerde Context Propagation Hataları: İzlenebilirlik Kayıplarını Teşhis Etme

Heterojen mikroservis ağlarında context propagation hatalarını teşhis etme stratejilerini öğrenin ve üretim ortamındaki izlenebilirlik açıklarını kapatın.

Devamını Oku
AI Asistan