بناء محرّك مزامنة، الجزء الثاني: الترتيب
Vector clocks للواقع offline. ليش بترجع تكلفتها، والنموذج الأبسط اللي ما بيرجع.
جزء منsync-engineجاري تحميل المقال…
Vector clocks للواقع offline. ليش بترجع تكلفتها، والنموذج الأبسط اللي ما بيرجع.
جزء منsync-engineجاري تحميل المقال…
جهازان، صفّ واحد، تعديلان مختلفان، كلاهما تمّا بدون اتّصال. حين يعود الاثنان للاتّصال، كيف يبدو الصفّ؟ هذا هو السؤال الذي يجب على كلّ محرّك مزامنة أن يجيب عنه، والإجابة الخاطئة ستكلّفك عميلاً. هذا هو الجزء الثاني من خمسة عن المحرّك. الجزء الأوّل غطّى الـ queue؛ هنا نتعامل مع الترتيب.
إن قرأت الأدب، الإجابة هي vector clocks. كلّ عقدة تحتفظ بعدّاد لكلّ عقدة أخرى؛ كلّ كتابة تزيد العدّاد المحلّيّ وتُضمِّن المتّجه الكامل؛ تُكتشَف التعارضات بمقارنة المتّجهات؛ وأيّ من الطرفين يستطيع حلّها.
vector clocks صحيحة. وهي أيضاً مكلفة بطريقتين: كلّ كتابة تحمل متّجهاً بحجم تقريبيّ بحجم سكّان العملاء النشطين، وواجهة حلّ التعارضات التي يجب بناؤها للمستخدم تصبح معقّدة بسرعة. لتطبيق محاسبة لا تتعارض فيه ٩٩٪ من الكتابات أبداً، دفع تلك الكلفة على كلّ صفّ هو هدر.
للحالة المهيمنة (صفّ واحد، محرّر واحد، لا تزامن) نستخدم عموداً واحداً
على الخادم: version كعدد صحيح متزايد قاصر على ذلك الصفّ. كلّ تحديث
يرسل version مع الـ diff. الخادم يقبل الكتابة فقط إن طابق الإصدار؛
وإلّا يرفض بـ 409 Conflict ويعيد الصفّ الحالي.
// PATCH /invoices/:id
{
"version": 7,
"patch": { "amount": 1450 }
}
// ردّ ٤٠٩
{
"error": "version_mismatch",
"current": {
"version": 8,
"row": { /* الصفّ الكامل من الخادم */ }
}
}هذا يغطّي ٩٩٪ من الكتابات. الـ١٪ المتبقّي هو حيث يصبح الأمر مثيراً للاهتمام.
حين يحصل العميل على ٤٠٩، لديه تعديل المستخدم المحلّي والصفّ الحاليّ على الخادم. هناك ثلاثة مسارات صادقة:
amount وغيّر الإصدار
الأحدث على الخادم notes، يمكن لكلا التعديلين الوصول. نحسب تقاطع
الحقول المتغيّرة؛ إن كانت منفصلة، نُعيد إصدار الـ patch مستهدفاً
الإصدار الجديد.نختار المسار حسب نوع الكيان، لا عالمياً:
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 يجب تقديمها للمستخدم كـ «تعديلك» و «تعديلهم» ويختار المستخدم. المستخدم لا يفهم «تعديلهم» ما لم نُلصق اسماً وطابعاً زمنيّاً. ليس لدينا اسم دائماً. الشاشة التي يرسمها فريق الهندسة على السبّورة البيضاء لا تنتهي أبداً بمظهر الشاشة التي يجب أن نشحنها.
كلّ كتابة ستحمل متّجهاً مفهرساً بكلّ محرّر نشط لذلك الصفّ في آخر N من الأيّام. لتطبيق محاسبة لمستأجر فيه ٨٠ موظّفاً يلمسون كلّهم نفس دفتر الأستاذ، هذا ٨٠ مدخلاً على كلّ push. ليس أسوأ شيء في العالم. وليس مجّاناً أيضاً.
بيانات حقيقيّة: في تطبيقنا، ٩٤٪ من التعارضات الموجودة أصلاً هي نفس المستخدم يحرّر على جهازين (هاتف وسطح مكتب، كلاهما متّصل). الـ٦٪ المتبقّية تعديلات وضع المشاركة. vector clocks تعامل حالة الـ٩٤٪ مثل الـ٦٪، بينما يمكن في الواقع اكتشافها بحقل device-id واحد وحلّها بـ «ادمج حسب الحقل، تراجع إلى الجهاز الأحدث».
ثلاثة أعمدة لكلّ كيان قابل للمزامنة على الخادم:
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 هو مصدر
الحقيقة للترتيب عبر الأجهزة، لأنّ ساعات العملاء تكذب.
بروتوكول الـ 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 يفعل ذلك. الكتاب أيضاً محقّ في أنّ
التعارضات يجب أن تُحلّ قبل وصول البيانات؛ نفعل ذلك بجدول الحلّ. ما
أسقطناه هو افتراض أنّ كلّ تعارض مثير للاهتمام بنيوياً. معظمها ليس كذلك.
الجزء التالي عن عواصف إعادة الاتّصال: ما يحدث حين يعود ألف جهاز للاتّصال في نفس اللحظة، وكيف أبقَينا الخادم قائماً.
صفحة السلسلة تتابع البقيّة.