Bir CRM'i muhasebe yazılımına, proje stok tablosuna ya da pazarlama aracına bağlarken ilk istek hep aynı cümleyle gelir: "İki taraf da birbirinin aynısı olsun." Kâğıt üzerinde basit görünür. Pratikte ise çift yönlü senkronizasyon, entegrasyon işlerinin en çok sessiz hata üreten parçasıdır. Ben, Koray Yalçın, gayrimenkul satış ekipleri için lead yönetimi ve CRM altyapısı kurarken bu hataların neredeyse hepsini bir kez yaşadım: birbirini sonsuza kadar güncelleyen iki sistem, gece toplu güncellemeden sonra kaybolan telefon numaraları, saat farkı yüzünden "eski" sayılıp ezilen yeni kayıtlar.

Bu yazıda tek yönlü "her gece dışa aktar" yaklaşımının ötesine geçip, iki sistemi güvenle eşitleyen bir senkronizasyon katmanını geliştirici gözüyle adım adım tasarlıyorum. Örnekler PostgreSQL ve TypeScript ile, ama fikirler hangi yığını kullanırsanız kullanın geçerli.

Önce soru: Gerçekten çift yönlü mü olmalı?

En ucuz senkronizasyon, hiç çift yönlü olmayanıdır. Her alan için tek bir "sahip" sistem belirleyebiliyorsanız, sorunun büyük kısmı ortadan kalkar. Bir konut projesi örneği düşünelim:

  • Lead'in adı, telefonu, kaynağı ve danışman ataması CRM'de doğar ve CRM'e aittir.
  • Ünitenin fiyatı ve durumu stok sisteminde doğar ve oraya aittir.
  • Fatura numarası ve tahsilat durumu muhasebe yazılımına aittir.

Bu durumda "çift yönlü" aslında "her alan tek yönlü, ama iki sistem de bazı alanların sahibi" demektir. Gerçek çakışma ancak iki sistemin de aynı alanı yazmasına izin verdiğinizde ortaya çıkar. O yüzden tasarıma kod yazarak değil, bir alan sahipliği tablosu çıkararak başlıyorum.

Alan sahipliğini veri olarak tutmak

Sahiplik kuralını koda gömmek yerine bir tabloda tutmak, ileride "bu alanı artık muhasebe yönetsin" dendiğinde dağıtım yapmadan değişiklik yapmanızı sağlar.

CREATE TABLE sync_field_policy ( connector text NOT NULL, -- 'accounting', 'inventory' ... entity text NOT NULL, -- 'contact', 'deal', 'unit' field text NOT NULL, owner text NOT NULL CHECK (owner IN ('crm','remote','both')), conflict_rule text NOT NULL DEFAULT 'owner_wins' CHECK (conflict_rule IN ('owner_wins','latest_wins','manual')), PRIMARY KEY (connector, entity, field) ); INSERT INTO sync_field_policy VALUES ('accounting','contact','full_name','crm','owner_wins'), ('accounting','contact','tax_number','remote','owner_wins'), ('accounting','contact','email','both','latest_wins'), ('accounting','contact','phone','both','manual'); 

both olarak işaretlenen her alan bir uyarı işaretidir. Benim kuralım şu: bir alan both ise, bunun neden gerekli olduğu tabloya yorum olarak yazılmadan canlıya çıkmaz. Telefon gibi kritik bir alanda manual seçmek, yanlış numaraya otomatik mesaj gitmesinden çok daha ucuzdur.

Kimlik eşleme: İki sistemin aynı kaydı bilmesi

Senkronizasyonun ikinci temeli, "CRM'deki 4812 numaralı kişi, muhasebedeki C-00931'dir" bilgisini güvenilir biçimde saklamaktır. Bu bilgiyi kayıtların kendisine bir external_id sütunu olarak eklemek ilk bakışta pratiktir, ama birden fazla bağlayıcı geldiğinde dağılır. Ayrı bir eşleme tablosu daha temizdir:

CREATE TABLE sync_link ( connector text NOT NULL, entity text NOT NULL, local_id bigint NOT NULL, remote_id text NOT NULL, last_local_hash text, -- son gönderdiğimiz içeriğin özeti last_remote_hash text, -- son aldığımız içeriğin özeti last_synced_at timestamptz, PRIMARY KEY (connector, entity, local_id), UNIQUE (connector, entity, remote_id) ); 

İki benzersizlik kısıtı birlikte bire bir eşlemeyi garanti eder. Bir kayıt iki kez oluşturulmaya çalışılırsa veritabanı reddeder; böylece "aynı müşteri muhasebede iki kez açıldı" hatası uygulama mantığına güvenmeden engellenir. Hash alanlarını birazdan döngü önlemede kullanacağız.

Değişiklikleri yakalamak: Zaman damgası değil, imleç

En yaygın hata, "son senkrondan sonra updated_at değeri büyük olan kayıtları al" sorgusudur. İki sorunu var. Birincisi, aynı milisaniyede güncellenen kayıtlar sınırda kaybolabilir. İkincisi, uzun süren bir işlem eski bir zaman damgasıyla commit edildiğinde, siz o anı çoktan geçmiş olursunuz ve kayıt hiç görülmez.

CRM tarafında daha sağlam yol, her değişikliğe artan bir sıra numarası veren bir değişiklik günlüğüdür (outbox). Kayıt güncellenirken aynı işlem içinde bu tabloya da bir satır yazılır:

CREATE TABLE change_log ( seq bigserial PRIMARY KEY, entity text NOT NULL, local_id bigint NOT NULL, changed_fields text[] NOT NULL, origin text NOT NULL, -- 'user', 'workflow', 'sync:accounting' created_at timestamptz NOT NULL DEFAULT now() ); CREATE TABLE sync_cursor ( connector text PRIMARY KEY, last_seq bigint NOT NULL DEFAULT 0 ); 

Gönderici işçi seq > last_seq olan satırları sırayla okur, işler ve imleci yalnızca başarılı işlemden sonra ilerletir. bigserial değerlerinin commit sırasına göre değil tahsis sırasına göre arttığını unutmayın; çok yoğun sistemlerde kısa bir güvenlik gecikmesi bırakmak (örneğin son 5 saniyenin satırlarını bir sonraki tura bırakmak) boşluk kaçırma riskini azaltır.

Uzak sistem tarafında ise elinizde ne varsa onu kullanırsınız: webhook, "şu tarihten beri değişenler" uç noktası ya da en kötü ihtimalle tam liste. Uzak sistem yalnızca zaman damgası veriyorsa, imleci "son görülen zaman eksi birkaç dakika" olarak tutup örtüşen kayıtları hash karşılaştırmasıyla elemek pratik bir çözümdür.

Döngüyü kırmak: Kendi yankınızı tanıyın

Çift yönlü senkronizasyonun klasik kâbusu şudur: CRM bir alanı günceller, değişikliği muhasebeye gönderir; muhasebe bunu "yeni değişiklik" olarak webhook ile geri yollar; CRM bunu tekrar kaydeder ve yeni bir change_log satırı oluşur. Döngü başlar.

Bunu kırmanın iki katmanı var. İlki köken etiketidir: senkron işçisi CRM'e yazarken origin = 'sync:accounting' kullanır ve muhasebeye giden işçi bu kökenli satırları atlar. İkincisi içerik özetidir: gönderdiğimiz alanların normalize edilmiş bir özetini saklarız; uzaktan gelen değişikliğin özeti bizim gönderdiğimizle aynıysa, bu bir yankıdır.

import { createHash } from "node:crypto"; function fingerprint(fields: Record<string, unknown>): string { const keys = Object.keys(fields).sort(); const canonical = keys.map((k) => [k, normalize(fields[k])]); return createHash("sha256").update(JSON.stringify(canonical)).digest("hex"); } function normalize(v: unknown): unknown { if (typeof v === "string") return v.trim().normalize("NFC"); if (v === undefined) return null; return v; } async function onRemoteChange(link: SyncLink, remote: Record<string, unknown>) { const fp = fingerprint(pickSyncedFields(remote)); if (fp === link.last_local_hash || fp === link.last_remote_hash) { return; // kendi yazdığımızın yankısı ya da zaten gördüğümüz içerik } await applyRemote(link, remote, fp); } 

Türkçe veride normalize("NFC") küçük bir ayrıntı gibi görünse de önemlidir: bazı sistemler "ş" harfini birleşik karakter olarak, bazıları ayrık olarak gönderir. Normalize etmezseniz aynı isim iki farklı özet üretir ve döngü önleme sessizce çalışmaz.

Çakışma çözümü: "Son yazan kazanır" neden yetmez

İki taraf aynı alanı iki senkron turu arasında değiştirdiğinde çakışma vardır. "Son yazan kazanır" kuralı basittir ama iki varsayıma dayanır: iki sistemin saatleri uyumludur ve zaman damgaları gerçekten değişikliğin zamanını gösterir. Muhasebe yazılımlarının çoğu toplu içe aktarmalarda tüm kayıtların modified alanını aynı ana çeker; bu durumda yüzlerce kayıt, aslında eski olduğu halde "en yeni" görünür.

Daha güvenilir yöntem üç yollu karşılaştırmadır. Son başarılı senkronda iki tarafın üzerinde anlaştığı değeri (taban) saklarsınız ve her alanda şunu sorarsınız: hangi taraf tabandan uzaklaştı?

type Side = "crm" | "remote"; function resolveField( base: unknown, local: unknown, remote: unknown, policy: { owner: Side | "both"; conflict_rule: string } ): { value: unknown; conflict: boolean } { const localChanged = !same(base, local); const remoteChanged = !same(base, remote); if (!localChanged && !remoteChanged) return { value: local, conflict: false }; if (localChanged && !remoteChanged) return { value: local, conflict: false }; if (!localChanged && remoteChanged) return { value: remote, conflict: false }; if (same(local, remote)) return { value: local, conflict: false }; // Gerçek çakışma: iki taraf da farklı yöne değiştirdi if (policy.owner === "crm") return { value: local, conflict: false }; if (policy.owner === "remote") return { value: remote, conflict: false }; if (policy.conflict_rule === "manual") return { value: local, conflict: true }; return { value: local, conflict: true }; // latest_wins ayrıca ele alınır } 

Bu yaklaşımda gerçek çakışmalar beklenenden çok daha az çıkar; çoğu "çakışma" aslında yalnızca bir tarafın değişmesidir. Gerçekten iki tarafın da değiştirdiği manual alanlar ise bir inceleme kuyruğuna düşer ve CRM'de kaydın üzerinde görünür bir uyarı olarak durur. Satış operasyonunda bu kuyruk genelde günde birkaç kayıttan ibarettir; bunu bir insanın görmesi, yanlış telefon numarasıyla kaybedilen bir müşteriden çok daha ucuzdur.

Silme ve birleştirme: En çok unutulan iki olay

Güncellemeleri senkronize etmek kolaydır; silmeler ve birleştirmeler tasarımı zorlar. Bir kayıt CRM'de silindiğinde muhasebede de silinmeli mi? Çoğu zaman hayır: faturası kesilmiş bir müşteri muhasebede kalmak zorundadır. Bu yüzden silmeyi ayrı bir politika olarak ele alıyorum: varsayılan davranış uzak kaydı silmek değil, eşlemeyi "bağlantısız" işaretleyip uzak tarafa bir arşiv etiketi göndermektir.

Mükerrer lead birleştirme ise daha sinsidir. CRM'de iki kayıt birleştiğinde, kaybolan kaydın uzak sistemdeki karşılığı ne olacak? Eşleme tablosunda iki satır aynı hayatta kalan kayda işaret etmeye çalışır ve benzersizlik kısıtı haklı olarak itiraz eder. Çözüm, birleştirmeyi change_log içinde ayrı bir olay türü olarak yaymak ve bağlayıcıya özel bir davranış tanımlamaktır: uzak sistem de birleştirmeyi destekliyorsa orada da birleştirmek, desteklemiyorsa ikinci kaydı arşivleyip eşlemesini hayatta kalan kayda not olarak eklemek.

Gözlemlenebilirlik: Senkron sessizce bozulur

Senkronizasyon katmanı nadiren gürültülü biçimde çöker; genellikle sessizce geride kalır. Bu yüzden her bağlayıcı için şu üç ölçüyü panoda tutuyorum:

  1. Gecikme: change_log içindeki en yüksek seq ile bağlayıcının imleci arasındaki fark ve en eski işlenmemiş satırın yaşı.
  2. Çakışma ve hata oranı: Günlük çakışma kuyruğuna düşen kayıt sayısı ve kalıcı hataya düşen gönderimler.
  3. Mutabakat farkı: Haftada bir, iki taraftaki kayıtların hash'lerini toplu olarak karşılaştıran bir mutabakat işi. Olay tabanlı senkron ne kadar iyi olursa olsun, arada kaçan bir webhook olur; mutabakat işi bu boşlukları kapatan güvenlik ağıdır.

Mutabakat işinin bulduğu farkları otomatik düzeltmeden önce bir süre yalnızca raporlamasını öneririm. İlk haftalarda bulunan farklar genellikle bir normalize etme hatasını ya da yanlış işaretlenmiş bir alan sahipliğini ortaya çıkarır; bunları kuralda düzeltmek, sonuçları tek tek düzeltmekten çok daha kalıcıdır.

Canlıya alma sırası

Yeni bir bağlayıcıyı canlıya alırken izlediğim sıra kabaca şöyle:

  • Önce alan sahipliği tablosunu ilgili ekiplerle birlikte doldurun; both alanları tek tek gerekçelendirin.
  • Kimlik eşlemesini tek seferlik bir eşleştirme işiyle kurun (vergi numarası, e-posta, normalize telefon gibi güçlü anahtarlarla) ve emin olunamayan eşleşmeleri insan onayına bırakın.
  • Senkronu bir süre "yalnızca okuma ve raporlama" kipinde çalıştırın; ne yazacağını kaydetsin ama yazmasın.
  • Sonra tek yönlü alanları açın, en son both alanları.

Sonuç

İyi bir veri senkronizasyonu, "iki sistem aynı olsun" isteğini birkaç açık karara böler: her alanın sahibi kim, kayıtlar birbirini nasıl tanıyor, değişiklik nasıl kaçırılmadan yakalanıyor, yankı nasıl tanınıyor ve gerçek çakışmada kim karar veriyor. Bu kararları veri olarak tuttuğunuzda, senkron katmanı bir kara kutu olmaktan çıkar ve satış ekibinin güvendiği bir altyapıya dönüşür. Kavramın kısa tanımı ve gayrimenkul satış operasyonlarındaki yeri için Data sync nedir? sayfasına göz atabilirsiniz.

SSS

Çift yönlü senkron için webhook mu, periyodik sorgu mu kullanmalıyım?
İkisini birlikte. Webhook düşük gecikme sağlar ama kaçabilir; periyodik "değişenleri getir" sorgusu ve haftalık mutabakat işi kaçanları yakalar. Yalnızca webhook'a güvenen bir senkron er geç sapar.

updated_at ile imleç tutmak neden riskli?
Aynı zaman damgasına sahip kayıtlar sınırda kaybolabilir ve uzun süren işlemler geçmiş bir zaman damgasıyla commit edilebilir. Artan sıra numaralı bir değişiklik günlüğü bu iki sorunu da ortadan kaldırır.

Sonsuz güncelleme döngüsünü en basit nasıl önlerim?
Senkron işçisinin yazdığı değişiklikleri bir köken etiketiyle işaretleyin ve o kökenli değişiklikleri karşı tarafa geri göndermeyin. Buna ek olarak, gönderdiğiniz içeriğin normalize edilmiş özetini saklayıp geri gelen değişikliği bu özetle karşılaştırın.

"Son yazan kazanır" hiç mi kullanılmamalı?
Saatleri güvenilir, düşük riskli alanlarda (örneğin bir not alanı) kabul edilebilir. Telefon, e-posta, danışman ataması gibi satış akışını etkileyen alanlarda üç yollu karşılaştırma ve gerekirse insan onayı daha güvenlidir.

CRM'de silinen bir lead uzak sistemde de silinmeli mi?
Genellikle hayır. Muhasebe veya sözleşme kayıtları yasal ve operasyonel nedenlerle kalmalıdır. Varsayılan davranış olarak eşlemeyi bağlantısız işaretleyip uzak kaydı arşivlemek daha güvenlidir.

Senkronun sağlıklı çalıştığını nasıl anlarım?
İmleç gecikmesini, çakışma kuyruğunun büyüklüğünü ve periyodik mutabakat farkını izleyin. Bu üç ölçü sıfıra yakın ve kararlıysa senkron sağlıklıdır; biri yavaşça artıyorsa sessiz bir sorun büyüyordur.