انتقل للمحتوى
علي هيثم·TECH·يردّ خلال أقل من ساعة
  • الرئيسيةابدأ من هنا
  • الأعمالدراسات حالة، مشاريع
  • الخدماتما الذي سأبنيه لك
  • المدونةمقالات، سلاسل
  • التدريبدورات، مختبرات، دورات
  • عنّيالمهندس وراء هذا الموقع
تسجيل الدخولابدأ مشروع
علي هيثم · TECH
  • الرئيسية↗
  • الأعمال↗
  • الخدمات↗
  • المدونة↗
  • التدريب↗
  • عنّي↗
تسجيل الدخولابدأ مشروع
متاح
علي هيثم·TECH

استوديو هندسي. دمشق، GMT+3.

aliyosef.online

الاستوديو

  • الأعمال
  • المدونة
  • التدريب
  • عنّي
  • تواصل معي

البوابة

  • تسجيل الدخول
  • فتح تذكرة دعم
  • متابعة مشروع
  • حسابي

مصادر

  • الوثائق
  • حالة الأنظمة
  • سجلّ التغييرات
  • حزمة العلامة
  • الخصوصية
  • الشروط

النشرة

ملاحظات تقنية وأخبار، أسبوعياً. بدون حشو.

مجاني. إلغاء الاشتراك متى ما حبيت.

تابعني على
© 2026 علي هيثم يوسف. جميع الحقوق محفوظة.مبنيّ يدويًا بـ React 19.آخر نشر · 2026-05-08كل الأنظمة تعمل
  1. الرئيسية/
  2. المدوّنة/
  3. محرّكات المزامنة
محرّكات المزامنة

بناء محرّك مزامنة، الجزء الثاني: الترتيب

Vector clocks للواقع offline. ليش بترجع تكلفتها، والنموذج الأبسط اللي ما بيرجع.

٢٨ تشرين الثاني ٢٠٢٥·13 دقيقة·بقلم علي يوسف
جزء منsync-engine→

جاري تحميل المقال…

شاركXLinkedInRSS
في هذه السلسلةالجزء 1 من 2
←نظرة عامة على السلسلةالتاليبناء محرّك مزامنة، الجزء الأول: الـ queue→
استلم المقال التالي في بريدك.مقال جديد كل أحد. هندسة بلغة واضحة. مجاني، يمكنك إلغاء الاشتراك في أي وقت.اشترك→
AY
علي هيثم يوسف

مهندس وكاتب. أبني منصات متعدّدة المستأجرين، محرّكات مزامنة، وأنظمة موثوقة بهدوء. أكثر من ٦٠٠ مشروع مُسلَّم منذ ٢٠١٥.

اشتركابدأ مشروعاً→

اقرأ بعدها

  • SE
    Series
    ٢٠ نيسان ٢٠٢٦·محرّكات المزامنة

    بناء محرّك مزامنة، الجزء الأول: الـ queue

    افتتاح السلسلة. شكل الـ queue الصادر، ليش الترتيب مهم، والـ test harness اللي بيمسك كل شي.

    14 دقيقة
  • PB
    ٥ أيار ٢٠٢٦·محرّكات المزامنة

    متى الـ polling أفضل من الـ websockets

    سنة من الـ trade-offs بمحاسب. ليش الجواب الواضح طلع غلط على ظروف الشبكة عندنا.

    8 دقائق
  • CR
    ٢٨ نيسان ٢٠٢٦·محرّكات المزامنة

    حلّ التعارضات في تطبيقات offline-first

    Last-write-wins غالباً غلط. هاد اللي بيشتغل لما اثنين clients يختلفوا.

    10 دقائق
←العودة إلى كل المقالات

جهازان، صفّ واحد، تعديلان مختلفان، كلاهما تمّا بدون اتّصال. حين يعود الاثنان للاتّصال، كيف يبدو الصفّ؟ هذا هو السؤال الذي يجب على كلّ محرّك مزامنة أن يجيب عنه، والإجابة الخاطئة ستكلّفك عميلاً. هذا هو الجزء الثاني من خمسة عن المحرّك. الجزء الأوّل غطّى الـ queue؛ هنا نتعامل مع الترتيب.

إجابة الكتاب هي «vector clocks»#

إن قرأت الأدب، الإجابة هي vector clocks. كلّ عقدة تحتفظ بعدّاد لكلّ عقدة أخرى؛ كلّ كتابة تزيد العدّاد المحلّيّ وتُضمِّن المتّجه الكامل؛ تُكتشَف التعارضات بمقارنة المتّجهات؛ وأيّ من الطرفين يستطيع حلّها.

vector clocks صحيحة. وهي أيضاً مكلفة بطريقتين: كلّ كتابة تحمل متّجهاً بحجم تقريبيّ بحجم سكّان العملاء النشطين، وواجهة حلّ التعارضات التي يجب بناؤها للمستخدم تصبح معقّدة بسرعة. لتطبيق محاسبة لا تتعارض فيه ٩٩٪ من الكتابات أبداً، دفع تلك الكلفة على كلّ صفّ هو هدر.

ما نشحنه بدلاً من ذلك#

للحالة المهيمنة (صفّ واحد، محرّر واحد، لا تزامن) نستخدم عموداً واحداً على الخادم: version كعدد صحيح متزايد قاصر على ذلك الصفّ. كلّ تحديث يرسل version مع الـ diff. الخادم يقبل الكتابة فقط إن طابق الإصدار؛ وإلّا يرفض بـ 409 Conflict ويعيد الصفّ الحالي.

// PATCH /invoices/:id
{
  "version": 7,
  "patch": { "amount": 1450 }
}

// ردّ ٤٠٩
{
  "error": "version_mismatch",
  "current": {
    "version": 8,
    "row": { /* الصفّ الكامل من الخادم */ }
  }
}

هذا يغطّي ٩٩٪ من الكتابات. الـ١٪ المتبقّي هو حيث يصبح الأمر مثيراً للاهتمام.

ماذا يعني «حلّ التعارض» عملياً#

حين يحصل العميل على ٤٠٩، لديه تعديل المستخدم المحلّي والصفّ الحاليّ على الخادم. هناك ثلاثة مسارات صادقة:

  1. دمج على مستوى الحقل. إن غيّر المستخدم amount وغيّر الإصدار الأحدث على الخادم notes، يمكن لكلا التعديلين الوصول. نحسب تقاطع الحقول المتغيّرة؛ إن كانت منفصلة، نُعيد إصدار الـ patch مستهدفاً الإصدار الجديد.
  2. آخر كاتب يفوز، مع الخادم كسلطة. للحقول التي لا يكون فيها الدمج آمناً (مثل بنود الفاتورة)، نتجاهل تعديل العميل ونعرض إشعاراً.
  3. اعرض على المستخدم. إن كان تعديل العميل المعلّق كبيراً أو ذا أهمّية دلاليّة، نقدّم مصالحة inline صغيرة: «نسختك مقابل الحاليّ». زرّان.

نختار المسار حسب نوع الكيان، لا عالمياً:

const RESOLUTION: Record<string, ResolutionStrategy> = {
  contact:        'field-merge',
  invoice:        'show-user',
  invoice_item:   'last-write-wins',
  user_profile:   'field-merge',
  payment:        'show-user',
};

بعض المحرّكات تحاول أن تكون ذكيّة وتستنتج الاستراتيجيّة من الـ diff. جرّبنا. أنتج مفاجآت. جدول مسطّح لكلّ كيان أغبى، أسهل في تتبّع الأخطاء، وأسهل في التغيير حين يبلّغ العميل عن شيء غريب.

المحرّك الذكيّ الذي يستنتج استراتيجيّة الحلّ من الـ diff صحيح في ٩٥٪ من الحالات وخاطئ في الـ٥٪ التي تنتج مكالمة هاتفيّة. الجدول الغبيّ صحيح في ١٠٠٪ من الحالات التي يغطّيها وصامت عن البقيّة. الصمت عن البقيّة ميزة.

سجلّ هندسي، الأسبوع الحادي والأربعون

لماذا لا نستخدم vector clocks#

ثلاثة أسباب، بترتيب أهمّيتها:

١. كلفة الواجهة#

تعارضات vector clocks يجب تقديمها للمستخدم كـ «تعديلك» و «تعديلهم» ويختار المستخدم. المستخدم لا يفهم «تعديلهم» ما لم نُلصق اسماً وطابعاً زمنيّاً. ليس لدينا اسم دائماً. الشاشة التي يرسمها فريق الهندسة على السبّورة البيضاء لا تنتهي أبداً بمظهر الشاشة التي يجب أن نشحنها.

٢. تضخّم الـ header#

كلّ كتابة ستحمل متّجهاً مفهرساً بكلّ محرّر نشط لذلك الصفّ في آخر N من الأيّام. لتطبيق محاسبة لمستأجر فيه ٨٠ موظّفاً يلمسون كلّهم نفس دفتر الأستاذ، هذا ٨٠ مدخلاً على كلّ push. ليس أسوأ شيء في العالم. وليس مجّاناً أيضاً.

٣. أسطورة «المحرّران»#

بيانات حقيقيّة: في تطبيقنا، ٩٤٪ من التعارضات الموجودة أصلاً هي نفس المستخدم يحرّر على جهازين (هاتف وسطح مكتب، كلاهما متّصل). الـ٦٪ المتبقّية تعديلات وضع المشاركة. vector clocks تعامل حالة الـ٩٤٪ مثل الـ٦٪، بينما يمكن في الواقع اكتشافها بحقل device-id واحد وحلّها بـ «ادمج حسب الحقل، تراجع إلى الجهاز الأحدث».

الـ schema الذي استقررنا عليه#

ثلاثة أعمدة لكلّ كيان قابل للمزامنة على الخادم:

ALTER TABLE invoices ADD COLUMN version INTEGER NOT NULL DEFAULT 1;
ALTER TABLE invoices ADD COLUMN updated_by_device VARCHAR(64);
ALTER TABLE invoices ADD COLUMN updated_at_server TIMESTAMP NOT NULL;

version يقوم بعمل التحكّم المتفائل بالتزامن. updated_by_device يدع نا ننفّذ مسار «نفس المستخدم على جهازين». updated_at_server هو مصدر الحقيقة للترتيب عبر الأجهزة، لأنّ ساعات العملاء تكذب.

لا ترتّب حسب طابع زمن العميل

ساعات العملاء تنحرف، تُعبَث بها، تتغيّر حين يغيّر المستخدم المنطقة الزمنيّة. ترتيب الكتابات حسب client_created_at مغناطيس bugs. استخدم updated_at_server المُسنَد من الخادم. الطابع الزمنيّ للعميل جيّد للعرض، أبداً للترتيب.

على السلك#

بروتوكول الـ push من الجزء الأوّل يحمل version واحدة لكلّ عنصر؛ الخادم يتحقّق ويعيد إمّا الإصدار الجديد أو ٤٠٩ مع الصفّ الحاليّ. العميل، عند استلام ٤٠٩، يشغّل جدول الحلّ:

async function resolve(item: QueueItem, current: ServerRow): Promise<Action> {
  const strategy = RESOLUTION[item.entityType] ?? 'show-user';
  const localChanges = computeDiff(item.payload, current);

  switch (strategy) {
    case 'field-merge':
      if (disjointFields(item.payload, current.recentChangedFields)) {
        return { type: 'rebase', toVersion: current.version };
      }
      return { type: 'show-user', current, local: localChanges };

    case 'last-write-wins':
      return { type: 'discard', notice: 'serverWonNotice' };

    case 'show-user':
      return { type: 'show-user', current, local: localChanges };
  }
}

rebase هو أبسط الثلاثة: أعد كتابة حمولة عنصر الـ queue ليستهدف الإصدار الجديد وأعده إلى مقدّمة الـ queue. محاولة الـ push التالية ترسله.

ما أبقيناه من الكتاب#

الكتاب محقّ في أنّك تحتاج طريقة لاكتشاف التعديلات المتزامنة. التحكّم المتفائل بالتزامن على عمود version يفعل ذلك. الكتاب أيضاً محقّ في أنّ التعارضات يجب أن تُحلّ قبل وصول البيانات؛ نفعل ذلك بجدول الحلّ. ما أسقطناه هو افتراض أنّ كلّ تعارض مثير للاهتمام بنيوياً. معظمها ليس كذلك.

الجزء التالي عن عواصف إعادة الاتّصال: ما يحدث حين يعود ألف جهاز للاتّصال في نفس اللحظة، وكيف أبقَينا الخادم قائماً.

صفحة السلسلة تتابع البقيّة.