Mobil uygulamalarda offline-first senkronizasyonun maliyeti sadece yazılım geliştirme aşamasında değil, production ortamında binlerce kullanıcıya ulaştığınızda ortaya çıkan operasyonel bir faturadır. Çoğu zaman “kullanıcı çevrimdışı da çalışabilsin” talebiyle başlanan bu yolculuk, yerel cihazda veritabanı yönetimi, ağ katmanındaki paket kayıpları ve sunucu tarafındaki veri tutarlılığı problemleriyle tam bir mühendislik kabusuna dönüşebilir. Kendi geliştirdiğim mobil projelerde ve sahada danışmanlık verdiğim ekiplerde bu mimarinin getirdiği gizli bedelleri bizzat ödedim.
Bu yazıda, bir mobil uygulamanın yerel veritabanı katmanından sunucu tarafındaki çakışma çözümleme (conflict resolution) algoritmalarına kadar, offline-first mimarinin getirdiği gerçek teknik yükleri somut verilerle ele alacağım. “Yapalım gitsin” demeden önce hangi trade-off’ları (ödünleşimleri) göze almanız gerektiğini net bir şekilde göreceksiniz.
Yerel Veri Depolama ve Şema Yönetiminin Görünmez Yükü
Çevrimdışı çalışabilen bir mobil uygulamanın kalbi, cihazın içinde koşan yerel veritabanıdır. Genellikle SQLite tabanlı çözümler (Room, Writable SQLite) veya NoSQL alternatifleri (Isar, Hive) tercih edilir. Ancak production ortamında binlerce farklı cihaza ulaştığınızda, bu yerel veritabanlarının şema göçleri (schema migrations) tam bir operasyonel riske dönüşür.
Sunucu tarafındaki veritabanını tek bir canlı deployment ile güncelleyebilirken, kullanıcının telefonundaki veritabanı şemasını keyfinize göre güncelleyemezsiniz. Kullanıcı uygulamanızı 6 ay boyunca güncellememiş olabilir ve v1.0.2 sürümünden doğrudan v2.1.0 sürümüne geçiş yapabilir. Bu durumda yazacağınız migration script’lerinin hatasız çalışması gerekir; aksi takdirde yerel veritabanı bozulur (corruption) ve kullanıcının cihazındaki henüz senkronize edilmemiş tüm yerel veriler uçar.
-- SQLite üzerinde v3 migration örneği - Yerel veriyi kaybetmeden yeni kolon ekleme
-- Eğer kullanıcı v1'den v3'e atlıyorsa, tüm geçiş yollarının (1->2, 2->3) tanımlanması gerekir.
BEGIN TRANSACTION;
CREATE TABLE IF NOT EXISTS local_orders_new (
id TEXT PRIMARY KEY,
amount REAL NOT NULL,
status TEXT NOT NULL,
created_at INTEGER NOT NULL,
synced INTEGER DEFAULT 0,
discount_code TEXT -- v3 ile eklenen yeni kolon
);
INSERT INTO local_orders_new (id, amount, status, created_at, synced)
SELECT id, amount, status, created_at, synced FROM local_orders;
DROP TABLE local_orders;
ALTER TABLE local_orders_new RENAME TO local_orders;
COMMIT;
Yerel veritabanında index tasarımı yaparken de mobil cihazın kısıtlı donanım kaynaklarını hesaba katmak zorundasınız. SQLite üzerinde gereksiz yere tanımlanan her B-tree index’i, her INSERT işleminde cihazın disk yazma (I/O) yükünü artırır ve pil tüketimini doğrudan etkiler. Android ve iOS platformlarında arka planda çalışan veritabanı işlemlerinin CPU tüketimi belirli bir eşiği aşarsa, işletim sistemi uygulamanızı “resource-heavy” olarak işaretleyip arka planda zorla kapatabilir (force kill).
Ağ Paketleri ve Protokol Seçimi: REST vs WebSockets vs gRPC
Offline-first mimaride yerel cihaz ile uzak sunucu arasındaki veri alışverişini optimize etmek zorundasınız. Her bağlantı kurulduğunda tüm veritabanını baştan aşağı senkronize etmek (full sync) sürdürülebilir değildir. Bu yüzden sadece değişen verileri (delta updates) göndermeniz gerekir. Ancak bu delta paketlerinin taşınacağı protokolün seçimi büyük bir maliyet kalemidir.
Genel bir HTTP REST API kullanarak senkronizasyon yapmaya kalktığınızda, her bir istek için giden HTTP header’ları (yaklaşık 400-800 byte) ve TLS el sıkışması (handshake) her bağlantıda ciddi bir overhead yaratır. 15 saniyede bir ufak bir konum veya sipariş durumu güncellemesi gönderen bir cihaz, ay sonunda sadece HTTP protokol yükü yüzünden gigabaytlarca gereksiz veri tüketebilir.
| Protokol | Ortalama Header Boyutu | Bağlantı Tipi | Mobil Pil Tüketimi | Çevrimdışı Uyumluluk |
|---|---|---|---|---|
| HTTP REST | 500 - 1000 byte | Stateless / Request-Response | Orta - Yüksek | Kolay (Retry mekanizmaları ile) |
| WebSockets | ~2 - 10 byte (El sıkışma sonrası) | Stateful / Çift Yönlü | Yüksek (Bağlantı açık kaldıkça) | Zor (Kesintilerde reconnection yükü) |
| gRPC (HTTP/2) | ~10 - 50 byte (Sıkıştırılmış) | Stateful / Multiplexed | Düşük - Orta | Orta (Client-side interceptor gerekir) |
Ağ kesintilerinin sık yaşandığı mobil ortamlarda, bağlantı koptuğu anda yarım kalan paketlerin takibi yapılmalıdır. Örneğin, cihaz 10 adet yerel kaydı sunucuya gönderdi, sunucu bunları veritabanına yazdı ancak istemciye “200 OK” yanıtı dönerken ağ koptu. İstemci bu verilerin sunucuya ulaştığını bilemez. Bir sonraki bağlantıda aynı verileri tekrar gönderecektir. Bu durum, sunucu tarafında mükerrer kayıt (duplicate data) oluşmasına yol açar. Bu sorunu aşmak için her isteğin benzersiz bir idempotency-key ile imzalanması şarttır.
Daha önce ele aldığım PostgreSQL index stratejileri konusunda olduğu gibi, sunucu tarafında bu idempotency anahtarlarını hızlıca sorgulayacak bir indeks yapısı kurmazsanız, senkronizasyon istekleri büyüdükçe sunucu veritabanınız kilitlenme noktasına gelecektir.
Çakışma Çözümleme (Conflict Resolution) Çıkmazı
İki farklı cihaz aynı veri üzerinde çevrimdışı değişiklik yapıp ardından aynı anda internete bağlandığında ne olacak? İşte offline-first mimarinin en büyük teknik açmazı burasıdır. Çakışma çözümleme stratejileri teoride çok kolay görünse de pratikte veri kaybına veya tutarsızlıklara yol açar.
En yaygın kullanılan üç çakışma çözümleme yöntemini ve bunların gerçek hayattaki maliyetlerini inceleyelim:
- Last-Write-Wins (LWW): En son yazanın verisi kabul edilir. Cihaz saatlerine (
timestamp) güvenilir. Ancak mobil cihazların saatleri kullanıcı tarafından değiştirilebilir veya ağ tabanlı saat senkronizasyonu (NTP) sapabilir. Saati ileri kaymış bir cihaz, başka bir cihazdan gelen daha güncel veriyi yanlışlıkla ezebilir. - Merge (Birleştirme): Çakışan alanlar alan bazında (field-level) birleştirilir. Örneğin, A kullanıcısı siparişin açıklamasını, B kullanıcısı ise miktarını değiştirdiyse iki değişiklik de uygulanır. Ancak bu durum iş kurallarını bozabilir (örneğin miktar değiştiği için eski açıklama geçersiz kalabilir).
- Conflict-Free Replicated Data Types (CRDT): Matematiksel olarak çakışma üretmeyen veri yapılarıdır (örneğin PN-Counter veya LWW-Element-Set). Geliştirmesi son derece karmaşıktır ve mobil cihaz üzerinde ciddi bir bellek (RAM) ve CPU yükü oluşturur.
Aşağıdaki JSON şeması, çakışma çözümü için istemci ile sunucu arasında taşınan bir çatışma paketinin ne kadar karmaşık olabileceğini göstermektedir:
{
"sync_session_id": "8f9b2c3a-4d5e-6f7a-8b9c-0d1e2f3a4b5e",
"client_version": 42,
"server_version": 40,
"conflicts": [
{
"entity_type": "customer_profile",
"entity_id": "usr_9921",
"client_state": {
"phone": "+905554443322",
"updated_at": "2026-05-29T10:14:00Z"
},
"server_state": {
"phone": "+905551112233",
"updated_at": "2026-05-29T10:13:55Z"
},
"resolution_strategy": "MANUAL_RESOLVE_REQUIRED"
}
]
}
Pil, CPU ve Background Sync Sınırları
Mobil işletim sistemleri (özellikle son sürümleriyle iOS ve Android), arka plan işlemlerine (background tasks) karşı son derece agresiftir. Kullanıcı uygulamanızı arka plana attığı anda, işletim sistemi ağ soketlerini kapatır ve CPU kullanımını sınırlar. Bu durum, arka planda sessiz sedasız veri senkronize etme hayallerinizi suya düşürür.
Android tarafında WorkManager API’si, iOS tarafında ise BGAppRefreshTask kullanarak arka plan senkronizasyonunu planlamak zorundasınız. Ancak bu araçlar size kesin bir çalışma zamanı garantisi vermez. İşletim sistemi, cihazın şarj durumuna, bağlı olduğu ağ tipine (Wi-Fi veya hücresel veri) ve kullanıcının uygulamayı kullanma sıklığına göre senkronizasyon işlemini saatlerce erteleyebilir.
// Android WorkManager ile esnek arka plan senkronizasyonu yapılandırması
val constraints = Constraints.Builder()
.setRequiredNetworkType(NetworkType.UNMETERED) // Sadece Wi-Fi'da çalışsın
.setRequiresBatteryNotLow(true) // Şarj düşükken çalışmasın
.build()
val syncWorkRequest = PeriodicWorkRequestBuilder<SyncWorker>(1, TimeUnit.HOURS)
.setConstraints(constraints)
.setBackoffCriteria(
BackoffPolicy.EXPONENTIAL,
WorkRequest.MIN_BACKOFF_MILLIS,
TimeUnit.MILLISECONDS
)
.build()
WorkManager.getInstance(context).enqueueUniquePeriodicWork(
"app_data_sync",
ExistingPeriodicWorkPolicy.KEEP,
syncWorkRequest
)
Eğer uygulamanız arka planda büyük miktarda veriyi SQLite’a yazmaya çalışırsa, disk yazma işlemleri (disk commits) nedeniyle cihazın ısınmasına ve pil grafiğinin hızla düşmesine neden olur. Kullanıcı pil ayarlarına girdiğinde uygulamanızın listenin en başında, baskın bir pil tüketicisi olarak yer aldığını görürse, uygulamanızı anında cihazından kaldıracaktır. Bu, offline-first mimarinin teknik değil, doğrudan kullanıcı kaybına yol açan ticari bir maliyetidir.
Sunucu Tarafındaki Veritabanı ve API Tasarımı
Mobil cihazların çevrimdışı çalışabilmesi için sunucu tarafındaki mimarinizi de kökten değiştirmeniz gerekir. Standart bir “al, güncelle, kaydet” API’si yerine, her kaydın tarihsel gelişimini takip edebilen olay tabanlı (event-driven) veya sürüm kontrollü bir veritabanı tasarımı kurmalısınız.
Sunucuda silinen kayıtların takibi (soft delete) en kritik konulardan biridir. Eğer veritabanından bir satırı fiziksel olarak silerseniz (DELETE FROM orders WHERE id = 1), çevrimdışı olan istemci bu kaydın silindiğini asla öğrenemez ve kendi yerel veritabanında o kaydı sonsuza kadar saklamaya devam eder. Bu yüzden sunucu tarafında her silme işlemini bir “Tombstone” (mezar taşı) kaydı olarak tutmanız gerekir.
-- PostgreSQL üzerinde yumuşak silme (soft delete) ve senkronizasyon takibi için tombstone tablosu
CREATE TABLE IF NOT EXISTS deleted_records (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
table_name VARCHAR(50) NOT NULL,
record_id VARCHAR(100) NOT NULL,
deleted_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP
);
-- Bir kayıt silindiğinde tetiklenecek trigger fonksiyonu
CREATE OR REPLACE FUNCTION log_record_deletion()
RETURNS TRIGGER AS $$
BEGIN
INSERT INTO deleted_records (table_name, record_id)
VALUES (TG_TABLE_NAME, OLD.id::text);
RETURN OLD;
END;
$$ LANGUAGE plpgsql;
Bu deleted_records tablosu zamanla milyonlarca satıra ulaşır. Mobil cihazlar her senkronizasyon isteğinde “benden sonra silinen kayıtlar var mı?” diye bu tabloyu sorgulamak zorundadır. Bu durum, sunucu tarafındaki PostgreSQL veya MySQL sunucunuzda ciddi bir disk I/O ve memory (RAM) yükü oluşturur. Tabloyu düzenli olarak temizlemek (vacuuming/cleanup) ve eski mezar taşlarını arşivlemek için arka planda çalışan cron işleri veya sistem servisleri (systemd timer’ları) kurmanız gerekir.
Daha önce Linux servisleri başlığı altında tartıştığımız gibi, bu temizlik işlemlerini yürüten servislerin kaynak tüketimini cgroup limitleriyle sınırlandırmazsanız, veri temizliği yaparken canlı API sunucularınızın yanıt sürelerini (latency) patlatabilirsiniz.
Somut Bir Senkronizasyon Motoru ve Durum Yönetimi
Tüm bu anlattıklarımızı bir araya getiren, mobil istemci üzerinde çalışacak güvenilir bir senkronizasyon motorunun (sync engine) çekirdek yapısını tasarlayalım. Bu motor, başarısız isteklerde üssel geri çekilme (exponential backoff) uygulamalı, ağ durumunu kontrol etmeli ve işlem bütünlüğünü (transactional integrity) korumalıdır.
Aşağıdaki Dart/Flutter kodu, yerel SQLite veritabanı ile uzak API arasında güvenli bir senkronizasyon döngüsünün nasıl kurulacağını göstermektedir:
import 'dart:async';
import 'dart:math';
enum SyncStatus { idle, syncing, error }
class SyncEngine {
final LocalDatabase _db;
final ApiClient _api;
SyncStatus _status = SyncStatus.idle;
int _retryCount = 0;
SyncEngine(this._db, this._api);
Future<void> triggerSync() async {
if (_status == SyncStatus.syncing) return;
_status = SyncStatus.syncing;
try {
// 1. Yerel olarak değişen ama henüz sunucuya gönderilmemiş kayıtları al
final pendingRecords = await _db.getUnsyncedRecords();
if (pendingRecords.isEmpty) {
_status = SyncStatus.idle;
_retryCount = 0;
return;
}
// 2. Sunucuya toplu (bulk) gönderim yap
final response = await _api.sendSyncPayload(pendingRecords);
if (response.statusCode == 200) {
// 3. Başarılı olan kayıtları yerelde 'senkronize edildi' olarak işaretle
final List<String> successfulIds = response.data['success_ids'];
await _db.markAsSynced(successfulIds);
_retryCount = 0;
_status = SyncStatus.idle;
} else {
throw Exception("Sunucu hatası: ${response.statusCode}");
}
} catch (e) {
_status = SyncStatus.error;
_handleSyncFailure();
}
}
void _handleSyncFailure() {
_retryCount++;
// Üssel geri çekilme (Exponential Backoff): 2^retry * 1000ms + random jitter
final int backoffMs = (pow(2, _retryCount) * 1000).toInt() + Random().nextInt(1000);
print("Senkronizasyon başarısız. $backoffMs ms sonra tekrar denenecek. Deneme: $_retryCount");
Timer(Duration(milliseconds: backoffMs), () {
triggerSync();
});
}
}
// Mock Sınıflar (Derleme hatası vermemesi için)
abstract class LocalDatabase {
Future<List<Map<String, dynamic>>> getUnsyncedRecords();
Future<void> markAsSynced(List<String> ids);
}
abstract class ApiClient {
Future<ApiResponse> sendSyncPayload(List<Map<String, dynamic>> payload);
}
class ApiResponse {
final int statusCode;
final Map<String, dynamic> data;
ApiResponse(this.statusCode, this.data);
}
Bu koddaki en kritik nokta, herhangi bir ağ hatası veya sunucu kesintisi durumunda uygulamanın sunucuya yüklenmesini engellemektir. Eğer çok sayıda cihaz aynı anda hata alır ve kısa aralıklarla durmadan istek atmaya çalışırsa (thundering herd problem), sunucu altyapınızı kendi ellerinizle çökertirsiniz. Üssel geri çekilme ve rastgele gecikme (jitter) eklenmiş bu algoritma, bu riskin önüne geçmek için hayati önem taşır.
Sonraki Adım: Mimari Karar Matrisi
Mobil uygulamanızda offline-first mimarisini seçmeden önce kendinize şu soruları sorun ve aşağıdaki karar matrisine göre hareket edin:
- Veri Hassasiyeti Nedir? Finansal işlemler veya stok hareketleri gibi %100 tutarlılık gerektiren verilerde çevrimdışı yazma işlemine izin vermeyin. Bu durumlarda uygulamayı doğrudan internet bağlantısı zorunlu (online-only) olarak tasarlamak en ucuz ve en güvenli yoldur.
- Kullanıcı Kitlesi Nerede? Eğer uygulamanız metroda, depolarda veya kırsal arazide çalışan saha personeli tarafından kullanılıyorsa, offline-first bir zorunluluktur. Bu durumda yukarıda bahsettiğim tüm mimari maliyetleri bütçenize eklemek zorundasınız.
- Geliştirme Kaynağınız Yeterli mi? Çevrimdışı senkronizasyon motoru yazmak, standart bir CRUD uygulaması yazmaktan kat be kat daha fazla test ve hata ayıklama (debugging) süresi gerektirir.
Sonraki adım: Yerel veritabanı şema göçlerini (schema migrations) otomatize etmek için CI/CD süreçlerinize SQLite entegrasyon testlerini dahil edin.