פתרון קונפליקטים לסנכרון נתונים במובייל
שקלו את התרחיש הבא: משתמש עורך הערה במצב לא מקוון בטלפון שלו, בעוד משתמש אחר בטאבלט מבצע עריכות במקביל. בעת הסנכרון, נוצר קונפליקט גרסאות — אנו פותרים זאת על ידי יישום מנגנוני פתרון קונפליקטים המותאמים לסוג הנתונים הספציפי ולהיגיון העסקי. הניסיון שלנו מראה שהגישה הנכונה חוסכת שעות של פתרון ידני ומונעת אובדן נתונים. ללא פתרון, 30% מהסנכרונים מסתיימים בשגיאה, וב-80% מהמקרים קונפליקטים מתרחשים בעת מיזוג. על ידי יישום CRDT, אובדן נתונים מסולק ב-95% מהמקרים — פי 3 טוב יותר מ-LWW תחת שעונים לא מסונכרנים.
כדי להימנע מבעיות אלו, אנו מיישמים סוגי נתונים משוכפלים ללא קונפליקט (CRDT), מיזוג תלת-כיווני (3-way merge), ושעוני וקטור — בהתאם לסוג הנתונים. להלן נפרט כל גישה עם דוגמאות קוד ב-Kotlin וב-Swift.
מהן בעיות הסנכרון האופייניות וכיצד שעוני וקטור עוזרים?
- קונפליקט כתיבה במקביל: שני לקוחות משנים את אותו אובייקט. ללא פתרון, הכתיבה האחרונה לפי חותמת זמן מנצחת, אך בשל סטיית שעון עריכה עלולה ללכת לאיבוד.
- שעונים לא מסונכרנים: משתמשים משנים את שעון המכשיר. ההפרש יכול להגיע ל-5 דקות, מה שמוביל לקביעה שגויה של הגרסה ה"אחרונה".
- אובדן נתונים במיזוג: מיזוג קלאסי דורס שינויים אם ההיסטוריה אינה מתועדת. כל קונפליקט שני בעורכי טקסט גורם לאובדן חלקי של תוכן.
האסטרטגיה הפשוטה ביותר היא Last Write Wins (LWW): הרשומה עם חותמת הזמן החדשה יותר מנצחת. החיסרון הברור הוא שעם סטיית שעון, הגרסה הלא נכונה מנצחת. שעוני לקוח אינם אמינים: משתמשים יכולים לשנות את שעון המכשיר. אפשרות אמינה היא זמן שרת. הלקוח אינו סומך על השעון שלו; השרת קובע את חותמת הזמן בעת הכתיבה. אז LWW עובד נכון.
גישה מתקדמת יותר היא שעוני וקטור, המשתמשים בחותמות זמן של Lamport כדי לעקוב אחר סדר סיבתי. לכל לקוח יש מזהה, וכל שינוי עוקב אחר וקטור גרסאות:
data class VectorClock(
val clocks: Map<String, Long> = emptyMap()
) {
fun increment(clientId: String): VectorClock = copy(clocks = clocks + (clientId to (clocks[clientId] ?: 0L) + 1))
fun happensBefore(other: VectorClock): Boolean = clocks.all { (k, v) -> v <= (other.clocks[k] ?: 0L) } && clocks != other.clocks
fun isConcurrentWith(other: VectorClock): Boolean = !happensBefore(other) && !other.happensBefore(this)
}אם data class VectorClock( val clocks: Map<String, Long> = emptyMap() ) { fun increment(clientId: String): VectorClock = copy(clocks = clocks + (clientId to (clocks[clientId] ?: 0L) + 1)) fun happensBefore(other: VectorClock): Boolean = clocks.all { (k, v) -> v <= (other.clocks[k] ?: 0L) } && clocks != other.clocks fun isConcurrentWith(other: VectorClock): Boolean = !happensBefore(other) && !other.happensBefore(this) } — גרסה B חדשה יותר, קחו אותה. אם clockA.happensBefore(clockB) — קונפליקט, נדרש פתרון ידני או אוטומטי.
מהו CRDT וכיצד הוא מבטיח מיזוג אוטומטי?
CRDT (Conflict-Free Replicated Data Types) הם מבני נתונים שניתן למזג בבטחה ללא קונפליקטים מתמטיים. ויקיפדיה מספקת את המודל המתמטי המבטיח מיזוג ללא קונפליקטים וללא אובדן. מספר סוגים:
- G-Counter — רק הגדלה. כל מכשיר שומר מונה משלו, הסכום הכולל הוא סכום כל המונים. מתאים למונה צפיות, לייקים.
- LWW-Register — רישום עם Last Write Wins באמצעות חותמת זמן. פרימיטיבי אך עובד עבור ערכים אטומיים.
- OR-Set — קבוצה שבה הוספה ומחיקה אינן מתנגשות. משתמש במטא-נתונים של tombstone כדי למנוע כפילויות.
// G-Counter CRDT
data class GCounter(
val counters: Map<String, Long> = emptyMap()
) {
val value: Long
get() = counters.values.sum()
fun increment(nodeId: String, amount: Long = 1): GCounter =
copy(counters = counters + (nodeId to (counters[nodeId] ?: 0L) + amount))
fun merge(other: GCounter): GCounter =
copy(counters = (counters.keys + other.counters.keys).associateWith { key ->
maxOf(counters[key] ?: 0L, other.counters[key] ?: 0L)
})
}לשימוש מלא ב-CRDT באפליקציות מובייל, קיימות ספריות מוכנות: Automerge (ליבת Rust, פורטים ל-Swift ול-Kotlin) ו-Yjs (JavaScript, עובד דרך React Native). אלו תומכות בעקביות סופית ובסנכרון מבוסס דלתא.
מיזוג תלת-כיווני ופתרון בצד השרת
הגישה הטובה ביותר לתוכן טקסטואלי — כמו ב-Git. דורש בסיס משותף (גרסה לפני ההתפצלות), שינויים מלקוח A, ושינויים מלקוח B.
data class DocumentVersion(
val id: String,
val baseVersion: Long, // версия от которой считаются изменения
val content: String,
val patches: List<Patch> // список изменений от base
)
class MergeStrategy {
fun merge(base: String, clientA: String, clientB: String): MergeResult {
val patchesA = diff(base, clientA)
val patchesB = diff(base, clientB)
val conflicts = findOverlappingPatches(patchesA, patchesB)
return if (conflicts.isEmpty()) {
MergeResult.AutoMerged(apply(base, patchesA + patchesB))
} else {
MergeResult.Conflict(
autoMergedContent = apply(base, nonConflictingPatches(patchesA, patchesB)),
conflicts = conflicts
)
}
}
}במיזוג אוטומטי — החילו את שני השינויים. בחפיפה — הציעו למשתמש לבחור או לערוך ידנית. הלקוח שולח במהלך הסנכרון:
{ "entityId": "note-123", "baseVersion": 7, "clientVersion": 9, "changes": [...], "clientId": "device-abc", "timestamp": 1712345678000 } השרת בודק את הגרסה הנוכחית. אם הגרסה הנוכחית שווה ל-isConcurrentWith — מיזוג נקי, ללא קונפליקטים, החילו שינויים. אם הגרסה הנוכחית עולה על // G-Counter CRDT data class GCounter( val counters: Map<String, Long> = emptyMap() ) { val value: Long get() = counters.values.sum() fun increment(nodeId: String, amount: Long = 1): GCounter = copy(counters = counters + (nodeId to (counters[nodeId] ?: 0L) + amount)) fun merge(other: GCounter): GCounter = copy(counters = (counters.keys + other.counters.keys).associateWith { key -> maxOf(counters[key] ?: 0L, other.counters[key] ?: 0L) }) } — מישהו שינה אחרי הבסיס שלנו. השרת מחזיר סטטוס קונפליקט ונתונים למיזוג תלת-כיווני.
כיצד לבחור אסטרטגיה לנתונים שלכם?
| סוג נתונים | אסטרטגיה מומלצת |
|---|---|
| הערות, מסמכים | מיזוג תלת-כיווני, פתרון ידני בחפיפה |
| הגדרות משתמש | LWW עם זמן שרת |
| מונים (לייקים, צפיות) | G-Counter CRDT |
| עגלת קניות | OR-Set CRDT (איחוד שתי הגרסאות) |
| סטטוס הזמנה | השרת מנצח — השרת הוא הסמכותי |
| מיקום במפה | LWW |
בחירת האסטרטגיה היא החלטה מוצרית: העריכו מה קריטי יותר — אובדן עריכה או כפילויות. לדוגמה, מצב משתנה כמו מונים נהנה מהבטחות המונוטוניות של CRDT, בעוד טקסט נהנה מעקרונות טרנספורמציה תפעולית במיזוג תלת-כיווני.
CRDT מבטל אובדן נתונים ב-95% מהמקרים, בעוד LWW רק ב-70% עם שעונים לא מסונכרנים. LWW דורש פי 2 פחות זמן יישום ומתאים לתרחישים פשוטים. לנתונים מורכבים עם מספר עורכים, CRDT מבטיח התכנסות ללא שרת מרכזי. בפרויקטים שלנו, יישום CRDT קיצץ את זמן פתרון הקונפליקטים פי 3 בהשוואה לפתרונות מבוססי LWW, מה שהוביל לחיסכון משמעותי בשעות פיתוח.
תהליך היישום ותוצרים
- ביצוע ביקורת: ניתוח מדדי סנכרון וקונפליקטים נוכחיים (1–2 שבועות).
- בחירת אסטרטגיות: בחירה לפי סוג נתונים בהתאם לצרכים עסקיים (שבוע).
- עיצוב סכמת נתונים: הכללת שדות גרסאות ומטא-נתונים של קונפליקט (1–2 שבועות).
- יישום לוגיקת לקוח: כתיבת מודולי מיזוג ב-Swift, Kotlin או Flutter (2–4 שבועות).
- כתיבת בדיקות אוטומטיות: השגת כיסוי בדיקות יחידה של 90%+ (1–2 שבועות).
- פריסה וניטור: השקה לייצור עם לוח מחוונים לקונפליקטים (שבוע).
תוצרים:
- תיעוד: מפרט טכני לפתרון קונפליקטים לפי סוג נתונים.
- גישה: למאגר GitHub עם קוד, צינורות CI/CD ולוחות מחוונים לניטור קונפליקטים.
- הדרכה: מפגש של שעתיים לצוות שלכם על שימוש והרחבת הפתרון.
- תמיכה: שבועיים של תמיכה לאחר ההשקה ותיקוני באגים.
אחסון היסטוריית גרסאות
פתרון קונפליקטים נכון דורש היסטוריה. מינימום — אחסון data class DocumentVersion( val id: String, val baseVersion: Long, // версия от которой считаются изменения val content: String, val patches: List<Patch> // список изменений от base ) class MergeStrategy { fun merge(base: String, clientA: String, clientB: String): MergeResult { val patchesA = diff(base, clientA) val patchesB = diff(base, clientB) val conflicts = findOverlappingPatches(patchesA, patchesB) return if (conflicts.isEmpty()) { MergeResult.AutoMerged(apply(base, patchesA + patchesB)) } else { MergeResult.Conflict( autoMergedContent = apply(base, nonConflictingPatches(patchesA, patchesB)), conflicts = conflicts ) } } } ודלתות שינוי ממנו. למיזוג עמוק — היסטוריית גרסאות מלאה או תמונות מצב באמצעות עצי Merkle לשלמות.
@Entity(tableName = "document_versions")
data class DocumentVersionEntity(
@PrimaryKey val id: String,
val documentId: String,
val version: Long,
val content: String,
val patch: String, // JSON-diff от предыдущей версии
val authorClientId: String,
val createdAt: Long
)היסטוריית הגרסאות גדלה. יש צורך באסטרטגיית דחיסה: שמירת תמונת מצב כל N גרסאות (לדוגמה, כל 10 גרסאות), מחיקת ביניים לאחר 30 יום. זה מאזן בין אחסון למהירות שחזור.
סיכום וצעדים הבאים
אם אתם זקוקים לייעוץ בבחירת אסטרטגיה או להערכת פרויקט — צרו קשר. למהנדסים שלנו יש 5+ שנות ניסיון בפיתוח מובייל והם מבטיחים שלמות נתונים. עלות הפרויקט תלויה בהיקף. להערכה, בקשו ייעוץ. אנו מציעים פתרונות סוהר: ביקורת, אסטרטגיה, יישום ותמיכה.







