تطوير الواجهات والجوال Frontend & Mobile الأدلة التقنية

تطبيق يعمل دون إنترنت Offline-first: التخزين والمزامنة

نبني سيرفراً وجوالين بقواعد SQLite ونرى بأرقام حقيقية كيف يضيع التسليم أو يسجل المبلغ مرتين أو يعود الطلب المحذوف، وكيف يمنع ذلك Outbox ومفتاح منع التكرار ودمج الحقول.

تطبيق يعمل دون إنترنت Offline-first: التخزين والمزامنة

المندوب ينزل بسيارته إلى موقف السيارات تحت الأرض في عمارة سكنية، ويسلم الطلب للحارس، ثم يضغط في التطبيق على زر «سلمت» ويكتب «تركته مع الحارس»، فتظهر له دائرة التحميل وتبقى تدور. بعد عشرين ثانية تظهر رسالة «تعذر الاتصال بالسيرفر، حاول مرة أخرى»، فيضغط مرة ثانية، ثم يخرج من الموقف ويركب السيارة ويتجه إلى الطلب التالي وهو لا يعرف هل سجل التسليم أم لا، وفي المساء يتصل به الموزع ليسأله لماذا ما زال الطلب «قيد التوصيل»، ويتصل به المحاسب ليسأله لماذا سجل النظام أنه حصل مبلغ الطلب مرتين. وهذا ليس موقفاً نادراً في تطبيقات التوصيل والمبيعات الميدانية والصيانة، فالمندوب يقضي جزءاً كبيراً من يومه في المواقف تحت الأرض والمصاعد والمستودعات والطرق خارج المدن حيث التغطية ضعيفة أو غير موجودة.

والمشكلة هنا ليست في الشبكة، فالشبكة في الجوال سوف تنقطع دائماً، وإنما في أن التطبيق بني على افتراض أنها موجودة: كل ضغطة زر تساوي طلباً إلى السيرفر، والواجهة تنتظر الرد قبل أن تعرض أي شيء. والبديل هو أن تبني التطبيق بحيث يعمل دون إنترنت أولاً Offline-first، أي أن يكتب كل شيء على الجهاز فوراً ويعرضه للمستخدم، ثم تتولى المزامنة Sync في الخلفية إيصال التغييرات إلى السيرفر وجلب تغييرات الآخرين عندما تعود الشبكة. والفكرة تبدو بسيطة، لكن المزامنة هي المكان الذي تختبئ فيه أصعب أخطاء تطبيقات الجوال: تعديل يرسل مرتين، وتعديلان يلغي أحدهما الآخر بصمت، وطلب محذوف يعود للحياة، وتغيير يظهر على الجوال ولا يصل أبداً إلى السيرفر. وكل هذه الأخطاء سوف نراها في هذا المقال بأعيننا، لأننا بنينا تجربة فيها سيرفر وجوالان، لكل منهم قاعدة بيانات SQLite خاصة به، وشغلناها حتى ظهرت كل مشكلة بأرقامها.

وسوف نناقش في هذا المقال ما يلي:

  • لماذا لا تكفي إعادة المحاولة، وما معنى أن تكون قاعدة البيانات المحلية هي مصدر الحقيقة للواجهة.
  • أين تحفظ البيانات على الجهاز: SQLite وRoom وCore Data وSwiftData والتخزين البسيط بالمفتاح والقيمة.
  • نمط صندوق الصادر Outbox على الجهاز، ولماذا يجب أن يكتب مع الطلب في معاملة واحدة Transaction.
  • المزامنة بالمؤشر Cursor التي تجلب ما تغير فقط، بأرقام حقيقية من التجربة.
  • الرد الذي يضيع في الطريق، ومفتاح منع التكرار Idempotency Key الذي يمنع تسجيل المبلغ مرتين.
  • التعارض Conflict عندما يعدل شخصان نفس الطلب: آخر كتابة تفوز، وفحص الإصدار، ودمج الحقول.
  • الحذف دون شبكة وعلامة الحذف Tombstone، والمشكلة الحقيقية التي ضيعت 120 تسليماً في تجربتنا.
  • متى تعمل المزامنة في الخلفية، وترقية قاعدة البيانات على الجهاز، وحماية البيانات المحلية.
  • الأخطاء الشائعة، وجدول قرار، وقائمة تحقق قبل الإطلاق.

لماذا لا تكفي إعادة المحاولة؟ مبدأ العمل دون إنترنت أولاً Offline-first

لنبدأ من الحل البسيط الذي تبدأ به أغلب الفرق: التطبيق يرسل الطلب إلى السيرفر، وإذا فشل يعرض رسالة خطأ ويطلب من المستخدم أن يحاول مرة أخرى. وهذا الحل يجعل المستخدم هو آلية إعادة المحاولة Retry، ويجعل نجاح عمله مرتبطاً بمكان وقوفه لحظة الضغط، ولا يحفظ ما كتبه إذا أغلق التطبيق، والأسوأ أنه لا يعرف هل وصل الطلب الأول أم لا، فإذا ضغط مرة ثانية فقد يسجل العملية مرتين، وإذا لم يضغط فقد لا تسجل أبداً.

والحل الثاني أن يعيد التطبيق المحاولة تلقائياً في الذاكرة، مع انتظار يتزايد بين المحاولات Exponential Backoff. وهذا أفضل بكثير، لكنه يبقى معلقاً بالذاكرة: إذا أنهى النظام التطبيق في الخلفية، وهو ما يحدث كثيراً في الجوال كما سنرى في مقال دورة حياة التطبيق من هذه المرحلة، ضاع الطلب مع كل محاولاته، والواجهة ما زالت تعرض دائرة التحميل لأنها تنتظر الرد قبل أن تغير أي شيء.

والحل الثالث أن تحفظ نسخة من بيانات السيرفر على الجهاز كذاكرة مؤقتة Cache، فتعرض الشاشات آخر بيانات معروفة عندما لا توجد شبكة. وهذا يحل القراءة فقط، أما الكتابة فما زالت تذهب مباشرة إلى السيرفر، والتطبيق الآن يعرض بيانات قد تكون قديمة دون أن يعرف المستخدم ذلك.

والحل الذي نقصده بالعمل دون إنترنت أولاً يقلب العلاقة: قاعدة البيانات المحلية على الجهاز هي المصدر الوحيد الذي تقرأ منه الواجهة، وكل كتابة تذهب إليها أولاً، والشبكة مجرد قناة في الخلفية تنقل التغييرات بين هذه القاعدة والسيرفر. وهذا بالضبط ما يقوله دليل Android لبناء تطبيق يعمل دون اتصال: مصدر البيانات المحلي هو مصدر الحقيقة Source of Truth الذي تقرأ منه طبقات التطبيق الأعلى، ومصدر الشبكة هو المرجع النهائي الذي تتفق عليه كل الأجهزة. والصورة التالية تبين الفكرة في تطبيق التوصيل:

جهاز المندوب وفيه الواجهة تقرأ وتكتب في قاعدة بيانات SQLite محلية فيها جدول الطلبات وجدول Outbox، ومحرك مزامنة في الخلفية يرسل ال Outbox إلى السيرفر ويجلب التغييرات منه
الواجهة لا تتكلم مع الشبكة أبداً، وإنما مع قاعدة البيانات المحلية، والمزامنة تعمل في الخلفية

لاحظ ما الذي تغير للمندوب في القبو: الضغط على «سلمت» يكتب في القاعدة المحلية في أجزاء من الثانية، والواجهة تعرض الطلب مسلماً فوراً مع علامة صغيرة تقول إنه لم يرسل بعد، ثم يكمل يومه، وعندما يخرج إلى الشارع تعمل المزامنة وحدها وتختفي العلامة. ولم يعد السؤال «هل وصل الطلب؟» سؤالاً للمندوب، وإنما سؤال لمحرك المزامنة يجيب عنه بنفسه. وفكرة أن تكون بيانات المستخدم على جهازه أولاً ليست جديدة على أي حال، فقد كتب Martin Kleppmann وزملاؤه في Ink & Switch ورقة البرمجيات المحلية أولاً Local-first Software في أبريل 2019، ووضعوا فيها سبعة أهداف أولها «لا دوائر تحميل»، أي أن عملك يجب أن يكون تحت يدك دون انتظار الشبكة.

وقد يتساءل البعض: هل كل تطبيق يحتاج هذا؟ والإجابة لا. تطبيق تحويل الأموال لا يجب أن يقول للمستخدم «تم التحويل» قبل أن يؤكده السيرفر، وتطبيق حجز المقاعد لا يستطيع أن يحجز مقعداً دون أن يسأل السيرفر هل هو متاح، وفي هذه الحالات يكون الصحيح أن تقول للمستخدم بوضوح إن العملية تحتاج اتصالاً. لكن أغلب ما يفعله المندوب أو الفني أو مسؤول المستودع هو تسجيل ما حدث فعلاً في العالم الحقيقي: الطلب سلم، والقطعة ركبت، والصندوق عد، وهذه حقائق لا يملك السيرفر أن يرفضها، وتسجيلها محلياً ثم إرسالها لاحقاً هو الوصف الصحيح لما حدث.

أين تحفظ البيانات على الجهاز؟ التخزين المحلي Local Storage

قبل المزامنة يجب أن تختار أين تعيش البيانات على الجهاز، والخيارات في المنصتين متشابهة، وتنقسم إلى ثلاثة أنواع بحسب شكل البيانات:

  • قاعدة بيانات علائقية Relational Database: وفي الجوال هي SQLite في كل الحالات تقريباً، سواءً استخدمتها مباشرة أو عبر طبقة فوقها. ففي Android توجد مكتبة Room التي تولد كود الوصول إلى SQLite وتتحقق من الاستعلامات عند البناء، وفي iOS يوجد Core Data الذي يستخدم SQLite تحته، وSwiftData الأحدث الذي بنته Apple فوق نفس الفكرة بأسلوب Swift. وFlutter وReact Native لهما مكتبات تصل إلى SQLite نفسها. وهذا هو المكان الصحيح للطلبات وال Outbox وكل ما له علاقات وتحتاج أن تبحث فيه أو ترتبه.
  • تخزين بالمفتاح والقيمة Key-Value: مثل DataStore في Android وUserDefaults في iOS، وهو مناسب للإعدادات الصغيرة مثل اللغة وآخر شاشة فتحها المستخدم، وغير مناسب للبيانات التي تكبر أو تحتاج معاملات، والسبب أنه يقرأ ويكتب الملف كاملاً ولا يملك استعلامات.
  • الملفات Files: للصور ومرفقات الطلب مثل صورة التوقيع أو صورة الطرد عند التسليم. والقاعدة هنا أن الملف الكبير يحفظ في مجلد التطبيق، والقاعدة المحلية تحفظ مساره وحالته (مرفوع أو ينتظر)، ولا تحفظ الصورة نفسها داخل جدول.

وتوثيق Android يلخص هذه الخيارات في صفحة نظرة عامة على تخزين البيانات، ولن نكررها هنا، فالذي يهمنا في هذا المقال هو ما تبنيه فوق قاعدة البيانات.

لماذا SQLite تحديداً؟

لأنها موجودة في كل جهاز أصلاً، فموقع SQLite يذكر في صفحة أكثر قواعد البيانات انتشاراً أنها موجودة في كل جهاز Android وكل iPhone، ويقدر أن هناك أكثر من تريليون قاعدة SQLite مستخدمة حالياً. لكن السبب الأهم للمزامنة ليس الانتشار، وإنما أن SQLite قاعدة معاملات Transactional حقيقية، فكل معاملة إما تكتب كاملة أو لا تكتب أبداً حتى لو انقطعت الكهرباء أو أنهى النظام التطبيق في منتصفها، وهذا موثق في صفحة SQLite قاعدة معاملات وفي شرح الحفظ الذري Atomic Commit. وسوف نرى بعد قليل أن كل تصميم المزامنة يعتمد على هذه الخاصية بالذات.

وفي الجوال تفعل أغلب المكتبات وضع سجل الكتابة المسبقة Write-Ahead Logging، واختصاراً WAL، وهو الوضع الذي فعلناه في التجربة بالأمر PRAGMA journal_mode=WAL. وفائدته أن القراءة لا تنتظر الكتابة، فالواجهة تقرأ قائمة الطلبات بينما محرك المزامنة يكتب مئات الصفوف في الخلفية، دون أن تتجمد الشاشة. وRoom يفعله تلقائياً على الأجهزة التي تدعمه.

نمط صندوق الصادر Outbox على الجهاز

الآن المندوب ضغط «سلمت» والطلب تغير في القاعدة المحلية، فكيف يعرف محرك المزامنة لاحقاً ما الذي تغير ويحتاج أن يرسل؟ الحل البسيط أن تضيف عموداً dirty في جدول الطلبات، وترسل كل صف عليه علامة. وهذا يعمل في البداية، لكنه يخسر معلومة مهمة: ماذا حدث بالضبط؟ فالصف يقول إن الحالة الآن «مسلم» والمبلغ 150، ولا يقول هل المبلغ 150 لأن المندوب حصل 150 مرة واحدة أم لأنه حصل 100 ثم 50، ولا يقول ما الإصدار الذي رآه المندوب عندما عدل، وهذه المعلومات هي التي يحتاجها السيرفر ليحل التعارض كما سيأتي.

والحل الأفضل هو صندوق الصادر Outbox: جدول على الجهاز، كل سطر فيه عملية واحدة قام بها المستخدم، بترتيبها، ومعها ما يحتاجه السيرفر لتطبيقها: رقم فريد للعملية، ونوعها، والحقول التي تغيرت، والإصدار الذي بنيت عليه، ووقت الجهاز. ومحرك المزامنة يقرأ هذا الجدول بالترتيب ويرسله، ويحذف كل سطر بعد أن يؤكد السيرفر استلامه. وهي نفس فكرة نمط Outbox بين الخدمات التي شرحناها في مقال المعاملات الموزعة ونمط Outbox، لكن على الجوال بدل السيرفر. وفي تجربتنا هذا هو جدول الجوال:

CREATE TABLE orders(id TEXT PRIMARY KEY, status TEXT, note TEXT, cash INTEGER,
    version INTEGER, deleted INTEGER DEFAULT 0);
CREATE TABLE outbox(op_id TEXT PRIMARY KEY, body TEXT, created INTEGER);
CREATE TABLE state(k TEXT PRIMARY KEY, v INTEGER);

والدالة التي تنفذ ضغطة «سلمت» تعدل الصف وتضيف العملية إلى ال Outbox:

def edit(self, oid, real_time, **fields):
    sets = ", ".join(f"{k}=?" for k in fields)
    self.db.execute(f"UPDATE orders SET {sets} WHERE id=?", (*fields.values(), oid))
    base = self.local(oid)["version"]
    return self._queue({"order_id": oid, "kind": "set", "fields": fields,
                        "base_version": base, "client_ts": self.now(real_time)})

في الكود أعلاه لاحظ التالي:

  • العمود version في الصف المحلي هو آخر إصدار رآه الجوال من السيرفر، والعملية تحمله باسم base_version، أي «عدلت على هذه النسخة»، وهذا ما يسمح للسيرفر أن يعرف هل عدل أحد آخر بعدها.
  • الحقل client_ts هو وقت الجوال، ونحمله فقط لنرى في التجربة ماذا يحدث عندما يعتمد عليه السيرفر.
  • الدالة _queue تولد رقماً فريداً op_id بصيغة UUID لكل عملية، وتكتب السطر في ال Outbox ثم تنفذ commit.

لماذا تكتب الطلب وال Outbox في معاملة واحدة Transaction؟

هنا أول خطأ يقع فيه كثيرون: يكتبون التعديل في جدول الطلبات ويحفظونه، ثم في سطر لاحق يضيفون العملية إلى ال Outbox ويحفظونها، كخطوتين منفصلتين. وفي السيرفر قد تمر هذه الطريقة سنوات دون مشكلة، أما في الجوال فالنظام ينهي التطبيق في أي لحظة: المستخدم أغلقه من قائمة التطبيقات الأخيرة، أو الذاكرة امتلأت، أو البطارية نفدت. ولكي نرى أثر ذلك كتبنا في التجربة السيناريو S6: عملية Python منفصلة تعدل الطلب o2 إلى delivered، ثم تنهي نفسها بالأمر os._exit(9) قبل أن تكتب سطر ال Outbox، تماماً كما لو أن النظام قتل التطبيق في تلك اللحظة. ونفذنا ذلك مرة بكتابتين منفصلتين ومرة داخل معاملة واحدة:

python -I sync_lab.py s6

والمخرج سوف يكون كما يلي:

S6  the app is killed between writing the order and writing the outbox
  one transaction=no   child exit code=9
  phone o2 status        delivered
  outbox operations      0
  server o2 after sync   new
  phone o2 after sync    delivered
  one transaction=yes  child exit code=9
  phone o2 status        new
  outbox operations      0
  server o2 after sync   new
  phone o2 after sync    new

لنقرأ المخرج سطراً سطراً:

  • child exit code=9 في الحالتين: العملية انتهت بالرمز الذي اخترناه، أي أنها لم تصل إلى سطر ال Outbox في أي منهما.
  • في الحالة الأولى phone o2 status delivered مع outbox operations 0: الجوال يعرض الطلب مسلماً، لكن لا توجد أي عملية تنتظر الإرسال، فالمحرك لا يعرف أن هناك شيئاً يرسله.
  • server o2 after sync new وphone o2 after sync delivered: بعد المزامنة بقي الاختلاف كما هو، والسبب أن الجوال يجلب من السيرفر ما تغير بعد آخر مؤشر فقط، والطلب o2 لم يتغير في السيرفر، فلن يصل أبداً ما يصحح الجوال. وهذا أسوأ نوع من الأخطاء، اختلاف صامت بين الجوال والسيرفر لا يظهر في أي سجل.
  • في الحالة الثانية phone o2 status new: لم يكتب شيء، لأن المعاملة لم تصل إلى COMMIT، فأعادت SQLite القاعدة إلى ما كانت عليه عند فتحها التالي. والمندوب يرى أن ضغطته لم تحفظ فيعيدها، والجوال والسيرفر متطابقان.
مقارنة بين كتابتين منفصلتين، حيث حفظ تعديل الطلب ولم يكتب سطر ال Outbox فبقي الجوال على delivered والسيرفر على new، ومعاملة واحدة حيث لم يكتب أي شيء فبقي الجهازان متطابقين
الكتابتان المنفصلتان تتركان اختلافاً لا يصلحه أي Sync لاحق، والمعاملة الواحدة تجعل الأمر كله أو لا شيء

لذلك القاعدة الأولى في هذا المقال: تعديل البيانات وإضافة العملية إلى ال Outbox يحدثان في معاملة واحدة دائماً. وفي Room تضع الدالتين داخل دالة عليها @Transaction أو تستخدم withTransaction، وفي Core Data تكون في نفس عملية save لنفس السياق Context. وفي كود التجربة يحدث ذلك دون أن نكتبه صراحة، لأن مكتبة sqlite3 في Python تفتح معاملة ضمنياً قبل أول تعديل ولا تحفظها إلا عند commit داخل _queue، لكن لا تعتمد على سلوك ضمني كهذا في تطبيقك الحقيقي، واكتب المعاملة بوضوح حتى لا يكسرها أحد لاحقاً بإضافة commit في المكان الخطأ.

المختبر Lab: سيرفر وجوالان في ملف Python واحد

قبل أن نكمل، هذا ما بنيناه بالضبط. ملف واحد sync_lab.py بمكتبات Python القياسية فقط، فيه كلاس Server له قاعدة SQLite خاصة، وكلاس Device يمثل جوالاً أو شاشة الموزع، وله قاعدة SQLite منفصلة. والاتصال بين الجهاز والسيرفر استدعاء دالة بدل طلب HTTP، والسبب أننا نريد أن نرى منطق المزامنة نفسه دون ضجيج الشبكة، والقرار في كل حالة لا يتغير إذا وضعت HTTP بينهما. ولكل جهاز عمليتان: push يرسل ال Outbox بالترتيب ويحذف ما أكده السيرفر، ثم pull يجلب ما تغير منذ آخر مؤشر. والسيرفر يعرف ثلاث طرق لحل التعارض نختار بينها عند إنشائه، ويمكن تشغيل منع التكرار وعلامة الحذف أو إيقافهما لنرى أثر كل منهما.

والتشغيل لا يحتاج أي تثبيت غير Python 3.13 أو أحدث (وSQLite المضمنة معها كانت 3.49.1):

python -I sync_lab.py        # all scenarios
python -I sync_lab.py s3     # one scenario

والخيار -I يشغل Python في وضع معزول لا يقرأ متغيرات البيئة ولا المجلد الحالي، وهي عادة جيدة عند تشغيل ملفات التجارب. والملف كاملاً مع مخرج التشغيل الكامل في مجلد lab من حزمة هذا المقال، وكل الأرقام التالية منقولة من هذا المخرج كما هي.

المزامنة بالمؤشر Cursor: اجلب ما تغير فقط

أول قرار في المزامنة هو كيف يعرف الجوال ما الذي تغير في السيرفر. والحل البسيط أن يجلب كل الطلبات في كل مرة، وهذا يعمل مع عشرين طلباً، لكنه يستهلك باقة المندوب وبطاريته ووقت السيرفر مع آلاف الطلبات، ويفشل غالباً في نفس المكان الذي نحتاج فيه المزامنة، أي عندما تكون الشبكة ضعيفة ولا يكفي وقت الاتصال لنقل كل شيء.

والحل الذي استخدمناه هو المؤشر Cursor: السيرفر يعطي كل تعديل رقماً تسلسلياً يزيد دائماً، ونخزنه في العمود seq، والجوال يحفظ آخر رقم رآه، ويطلب في المرة التالية الصفوف التي رقمها أكبر منه فقط:

def pull(self, since):
    rows = [dict(r) for r in self.db.execute(
        "SELECT id, status, note, cash, version, deleted, seq FROM orders WHERE seq>? ORDER BY seq", (since,))]
    cursor = self.db.execute("SELECT n FROM counter").fetchone()[0]
    return {"rows": rows, "cursor": cursor}

وفي السيناريو S1 أنشأنا في السيرفر 2000 طلب، ثم زامن جوال المندوب أول مرة، ثم غير الموزع ثلاثة طلبات، ثم زامن الجوال مرتين:

S1  first sync, then delta sync with a cursor
  first pull rows        2000
  first pull bytes       193812
  cursor                 2000
  delta pull rows        3
  delta pull bytes       350
  cursor                 2003
  nothing new rows       0
  nothing new bytes      28

في المخرج أعلاه لاحظ التالي:

  • أول مزامنة جلبت 2000 صف في 193,812 بايت من JSON، أي قرابة 190 KB، وحفظ الجوال المؤشر 2000.
  • بعد تعديل الموزع جلب الجوال 3 صفوف فقط في 350 بايت، أي أقل من 0.2% من المزامنة الكاملة، وانتقل المؤشر إلى 2003 لأن كل تعديل أخذ رقماً جديداً.
  • عندما لا يوجد جديد كان الرد 28 بايت، وهو غلاف JSON فارغ فيه المؤشر فقط، ولذلك تستطيع أن تزامن كثيراً دون تكلفة تذكر.
ثلاث مراحل للمزامنة: الأولى تجلب 2000 طلب في 193,812 بايت، والثانية بعد تعديل الموزع تجلب 3 صفوف في 350 بايت، والثالثة دون جديد ترد ب 28 بايت
المؤشر يجعل تكلفة المزامنة على قدر ما تغير، لا على قدر حجم البيانات

وفي التطبيق الحقيقي لا يجلب الجوال كل الطلبات أصلاً، وإنما طلبات هذا المندوب لهذا اليوم، فالنطاق Scope جزء من التصميم مثل المؤشر. وإذا كانت المزامنة الأولى كبيرة فاجعلها على صفحات Pages بحجم ثابت، واحفظ المؤشر بعد كل صفحة، حتى إذا انقطع الاتصال في منتصفها لا تبدأ من الصفر.

لماذا رقم تسلسلي وليس وقت التعديل Timestamp؟

قد يتساءل البعض: لماذا لا نطلب ببساطة الصفوف التي عدلت بعد آخر وقت مزامنة، باستخدام عمود updated_at؟ والإجابة أن الوقت لا يصلح للترتيب، والأسباب ثلاثة. الأول أن معاملتين في السيرفر قد تبدآن بترتيب وتحفظان بعكسه، فتحفظ المعاملة ذات الوقت الأقدم بعد أن جلب الجوال ما بعدها، فلا يراها أبداً. والثاني أن عدة تعديلات قد تأخذ نفس الوقت بالميلي ثانية، فإذا استخدمت «أكبر من» ضاع بعضها، وإذا استخدمت «أكبر من أو يساوي» تكرر بعضها. والثالث أن ساعات السيرفرات نفسها قد تختلف إذا كان لديك أكثر من سيرفر. وقد شرحنا هذه المشكلة بتفصيل في قسم الوقت والترتيب في مقال الأنظمة الموزعة. أما الرقم التسلسلي الذي يعطيه مصدر واحد عند الحفظ فلا يتكرر ولا يرجع إلى الخلف، وفي PostgreSQL مثلاً تستخدم Sequence أو جدول سجل التغييرات، ويجب أن تأخذ الرقم داخل نفس المعاملة التي تحفظ التعديل.

الرد الذي لم يصل: مفتاح منع التكرار Idempotency Key

نرجع إلى المندوب في المصعد. الجوال أرسل العملية «حصلت 150 ريالاً من الطلب o3»، والسيرفر استلمها ونفذها، ثم انقطعت الشبكة قبل أن يصل الرد. والجوال من جهته لا يعرف إلا أن الطلب انتهت مهلته Timeout، فلا يستطيع أن يفرق بين «لم يصل الطلب» و«وصل ونفذ لكن الرد ضاع». والتصرف الوحيد الصحيح للجوال أن يعيد الإرسال، لأن البديل أن يخسر العملية، وبالتالي يجب أن يكون السيرفر هو من يتعامل مع التكرار. وهذا هو السيناريو S2، نفذناه مرة دون حماية ومرة مع جدول يحفظ رقم كل عملية نفذت:

S2  the push reached the server but the response was lost, the app retries
  idempotency=off  first=LOST (timeout before the response arrived)
                   retry=[{'result': 'applied', 'version': 3}]
  server cash for o3     300
  phone cash for o3      300
  idempotency=on   first=LOST (timeout before the response arrived)
                   retry=[{'result': 'applied', 'version': 2, 'duplicate': True}]
  server cash for o3     150
  phone cash for o3      150

والمخرج يقول التالي:

  • first=LOST في الحالتين: الإرسال الأول وصل إلى السيرفر ونفذ، لكننا حذفنا الرد قبل أن يقرأه الجوال، فبقيت العملية في ال Outbox.
  • دون الحماية كان رد الإعادة applied مع version 3: السيرفر نفذ الإضافة مرة ثانية، فالإصدار انتقل من 1 إلى 2 في الإرسال الأول ثم إلى 3 في الإعادة، والمبلغ في السيرفر 300 بينما المندوب استلم 150 فقط. ولاحظ أن الجوال نفسه أصبح يعرض 300، لأنه جلب صف السيرفر بعد الدفع، فالخطأ انتقل إلى الجهازين.
  • مع الحماية كان الرد applied مع version 2 وduplicate: True: السيرفر وجد رقم العملية في جدول applied، فلم ينفذها، وأعاد الرد الذي حفظه في المرة الأولى كما هو، والمبلغ 150 في الجهازين.
الجوال يرسل إضافة 150 ريالاً برقم عملية، والسيرفر ينفذها، والرد يضيع، والجوال يعيد نفس العملية بنفس الرقم؛ دون مفتاح يصبح المبلغ 300، ومع المفتاح يبقى 150 ويعود الرد المحفوظ
الإعادة لا تضر إذا كان لكل عملية رقم ثابت يحفظه السيرفر

والكود الذي صنع هذا الفرق في السيرفر قليل:

def push(self, ops):
    out = []
    for op in ops:
        if self.idempotent:
            seen = self.db.execute("SELECT result FROM applied WHERE op_id=?", (op["op_id"],)).fetchone()
            if seen:
                out.append({**json.loads(seen[0]), "duplicate": True})
                continue
        res = self.apply(op)
        if self.idempotent:
            self.db.execute("INSERT INTO applied VALUES (?,?)", (op["op_id"], json.dumps(res)))
        out.append(res)
    self.db.commit()
    return out

في الكود أعلاه لاحظ أن تسجيل رقم العملية في جدول applied وتنفيذها يحدثان في نفس المعاملة، فلا يمكن أن تنفذ العملية دون أن يسجل رقمها أو العكس، وهي نفس قاعدة القسم السابق لكن في السيرفر. ولاحظ أن الرقم يولده الجوال عند إنشاء العملية ويحفظه في ال Outbox، وليس عند الإرسال، والسبب أن الإعادة يجب أن تحمل نفس الرقم بالضبط، فإذا ولدت رقماً جديداً مع كل محاولة فقد خسرت الحماية كلها.

وهذا الأسلوب معروف ومستخدم في واجهات الدفع منذ سنوات، فتوثيق الطلبات الآمنة عند التكرار في Stripe يطلب من العميل أن يرسل مفتاحاً فريداً مع كل طلب، ويحفظ السيرفر نتيجة الطلب الأول ويعيدها مع أي تكرار، ويقول إن المفاتيح يمكن أن تحذف بعد 24 ساعة على الأقل. وهناك مسودة في IETF لتوحيد هذا الأسلوب في ترويسة HTTP اسمها Idempotency-Key. أما في الجوال فتحتاج أن تفكر في مدة الحفظ بشكل مختلف: المندوب قد يبقى دون شبكة يوماً كاملاً أو أكثر، فإذا حذف السيرفر أرقام العمليات بعد 24 ساعة ثم وصلت إعادة متأخرة، فسوف تنفذ مرة ثانية. لذلك احفظ الأرقام مدة أطول من أطول فترة يمكن أن يبقى فيها جهاز دون اتصال، وارفض العمليات الأقدم من ذلك برسالة واضحة بدل أن تنفذها. وقد شرحنا موضوع منع التكرار من جهة السيرفر بالكامل، بما فيه مدة الحفظ والآثار خارج قاعدة البيانات، في مقال Idempotency: كيف تجعل العملية آمنة عند تكرارها.

أرسل ما حدث Operation، لا القيمة النهائية State

لاحظ أن عملية التحصيل في التجربة نوعها add_cash ومعها المبلغ 150، وليست «اجعل المبلغ 150». والفرق مهم عندما يعدل جهازان نفس الرقم: إذا حصل المندوب 100 من العميل، وسجل الموزع من شاشته دفعة 50 حولها العميل إلكترونياً، فإن إرسال القيمة النهائية من كل جهاز يجعل آخر من يرسل يمحو الآخر، فيصبح المبلغ 100 أو 50، بينما إرسال العمليتين يجعل السيرفر يجمعهما فيصبح 150 مهما كان ترتيب الوصول. والقاعدة: الأرقام التي تتراكم مثل المبالغ والكميات والعدادات ترسل كعمليات إضافة وطرح، والحقول التي تستبدل مثل الحالة والعنوان والملاحظة ترسل كقيم. لكن تذكر أن عمليات الإضافة هي بالذات التي يضاعفها التكرار كما رأينا قبل قليل، فهي تحتاج مفتاح منع التكرار أكثر من غيرها.

عندما يعدل شخصان نفس الطلب: حل التعارض Conflict Resolution

الآن الحالة الأصعب. في الثانية 50 من التجربة كتب الموزع من شاشته، وهو متصل، ملاحظة على الطلب o4: «call before arriving». وفي الثانية 100 كان المندوب في القبو فسلم الطلب وكتب ملاحظته «left with the guard»، وجواله دون شبكة. وأضفنا تفصيلاً يحدث في الواقع أكثر مما تتوقع: ساعة جوال المندوب متأخرة 300 ثانية، أي خمس دقائق، إما لأنه غيرها يدوياً أو لأن الجهاز لم يزامن الوقت منذ مدة. ثم عاد المندوب إلى الشارع وزامن الجوال. والسؤال: ما الذي يجب أن يبقى في السيرفر؟ والإجابة المنطقية لأي إنسان أن الطلب مسلم، والملاحظتان كلتاهما مفيدتان. ونفذنا السيناريو S3 بثلاث طرق للحل:

S3  two people edit the same order, the driver's phone clock is 5 minutes slow
  strategy=lww      result=ignored_older
  server o4              status=new note='call before arriving' version=2
  phone o4               status=new note='call before arriving' version=2
  strategy=version  result=conflict
  server o4              status=new note='call before arriving' version=2
  phone o4               status=new note='call before arriving' version=2
  strategy=merge    result=merged resolved=['note: both kept']
  server o4              status=delivered note='call before arriving | left with the guard' version=3
  phone o4               status=delivered note='call before arriving | left with the guard' version=3
الموزع يكتب ملاحظة في الثانية 50 والمندوب يسلم ويكتب ملاحظة في الثانية 100 وساعة جواله متأخرة 300 ثانية؛ آخر كتابة تفوز تتجاهل تعديل المندوب، وفحص الإصدار يرفضه ويعيد الصف، ودمج الحقول يقبل الحالة ويحفظ الملاحظتين
نفس التعديلين، وثلاث طرق للحل، وثلاث نتائج مختلفة تماماً

آخر كتابة تفوز Last Write Wins وساعة الجوال

آخر كتابة تفوز Last Write Wins، واختصاراً LWW، هي أبسط طريقة وأكثرها انتشاراً، ودليل Android نفسه يذكرها كأسلوب شائع في تطبيقات الجوال: كل جهاز يرسل مع التعديل وقت كتابته، والسيرفر يحتفظ بالتعديل الأحدث ويتجاهل الأقدم. والمخرج أعلاه يبين ما الذي حدث: result=ignored_older، أي أن السيرفر قارن وقت المندوب الذي يقول إن التعديل حدث في الثانية 200 قبل البداية (100 ناقص 300) بوقت تعديل الموزع في الثانية 50، فقرر أن تعديل المندوب أقدم وتجاهله. والنتيجة في السطرين التاليين: السيرفر يقول status=new، والأخطر أن الجوال نفسه أصبح يقول status=new، لأن تطبيقنا يأخذ صف السيرفر عندما يرفض تعديله. أي أن المندوب سلم الطلب، وضغط «سلمت»، ورأى العلامة، ثم بعد المزامنة رجع الطلب أمامه إلى «جديد» دون أي رسالة.

ولاحظ أن المشكلة ليست في الساعة المتأخرة وحدها، فحتى لو كانت الساعتان دقيقتين، فإن LWW تمحو تعديلاً كاملاً من أجل حقل واحد: المندوب عدل الحالة والموزع لم يلمسها أصلاً، ومع ذلك ضاعت الحالة لأنها جاءت في نفس العملية مع ملاحظة خسرت المقارنة. لذلك إذا استخدمت LWW فاستخدمها على مستوى الحقل وليس الصف، ولا تعتمد على ساعة الجوال أبداً، واجعل السيرفر يضع الوقت عند الاستلام، مع العلم أن وقت الاستلام في تطبيق يعمل دون شبكة قد يتأخر عن وقت الحدث بساعات، فالذي يصل أخيراً قد يكون الأقدم فعلاً.

فحص الإصدار Version Check: لا شيء يضيع بصمت

الطريقة الثانية تشبه القفل المتفائل Optimistic Locking في قواعد البيانات: كل صف في السيرفر له رقم إصدار يزيد مع كل تعديل، والعملية تحمل الإصدار الذي بنيت عليه، فإذا لم يطابق الإصدار الحالي يرفض السيرفر التعديل ويعيد الصف الحالي:

if self.strategy == "version":
    if op["base_version"] != cur["version"]:
        return {"result": "conflict", "row": cur}
    v = self._write(oid, fields, ts, list(fields))
    return {"result": "applied", "version": v}

والمخرج يقول result=conflict، لأن المندوب عدل على الإصدار 1 بينما الموزع رفعه إلى 2. وهذه الطريقة لا تضيع شيئاً بصمت، فالجوال يعرف أن هناك تعارضاً ومعه النسخة الحالية من السيرفر، والقرار الآن بيده. لكن تطبيقنا في التجربة اختار أبسط قرار وهو أن نسخة السيرفر تفوز، ولذلك يظهر في السطر الأخير status=new على الجوال أيضاً، وهذا نفس الضرر الذي رأيناه في LWW، والفرق الوحيد أن التطبيق يعرف الآن أنه حدث. وفي التطبيق الحقيقي إما أن تعرض التعارض على المستخدم («الموزع عدل هذا الطلب، هذه نسخته وهذه نسختك»)، أو تعيد تطبيق تعديل المستخدم فوق النسخة الجديدة وترسله بالإصدار الجديد، وهذا الثاني هو نفسه الدمج الذي سنراه الآن.

وعرض التعارض على المستخدم يبدو عادلاً، لكنه في تطبيق المندوب غير مناسب إطلاقاً، فالمندوب يقود السيارة إلى الطلب التالي، ولا يملك الوقت ولا المعلومة ليقرر بين ملاحظتين. وعرضه مناسب في تطبيقات التحرير مثل الملاحظات والمستندات، حيث المستخدم هو صاحب النص ويعرف ما الذي يريده.

دمج الحقول Field-level Merge وحالة الطلب State Machine

الطريقة الثالثة تنظر في كل حقل على حدة: السيرفر يحفظ لكل حقل الإصدار الذي عدل فيه آخر مرة، وعندما تصل عملية مبنية على الإصدار 1، يسأل «أي الحقول عدلها أحد بعد الإصدار 1؟». والحقول التي لم يلمسها أحد تقبل مباشرة، أما الحقول التي عدلها الطرفان فتحل بقاعدة خاصة بكل حقل. وفي تجربتنا القاعدتان كالتالي:

  • الحالة status تتقدم ولا ترجع: new ثم out_for_delivery ثم delivered، وcancelled في أعلى الترتيب. فإذا اختلف الطرفان تفوز الحالة الأبعد، والسبب أن «مسلم» حقيقة حدثت في العالم ولا يمكن أن يلغيها أن الموزع رأى الطلب «قيد التوصيل» قبل دقيقة. وهذا ما يسمى آلة الحالات State Machine، وهي من أنفع الأدوات في حل التعارض لأنها تحول السؤال من «من كتب أخيراً؟» إلى «أي الحالتين أبعد في مسار الطلب؟».
  • الملاحظة note إذا عدلها الطرفان يحفظ النصان معاً بينهما فاصل، والسبب أن كل ملاحظة كتبها إنسان لسبب، وحذف إحداهما أسوأ من بقائهما معاً.

والمخرج يقول result=merged مع resolved=['note: both kept']: الحالة لم تظهر في قائمة الحلول لأن الموزع لم يعدلها، فقبلت كما هي من المندوب، والملاحظة عدلها الاثنان فحفظت الاثنتان. والسطر الأخير يبين النتيجة على الجهازين: status=delivered مع note='call before arriving | left with the guard' وversion=3. وهذه هي النتيجة التي قالها أي إنسان من البداية، ولم نحتج فيها إلى ساعة الجوال أبداً.

والثمن أن السيرفر يجب أن يحفظ إصداراً لكل حقل، وفي التجربة جدول field_version، وأن تكتب قاعدة لكل حقل يمكن أن يتعارض، وأن تختبر هذه القواعد. وهذا عمل حقيقي، لكنه عمل تقوم به مرة عند التصميم بدل أن يقوم به فريق الدعم كل يوم عندما يتصل مندوب يسأل لماذا رجع طلبه إلى «جديد».

ماذا عن أنواع البيانات المتقاربة CRDT؟

إذا بحثت عن المزامنة دون اتصال فسوف تصل سريعاً إلى أنواع البيانات المتقاربة دون تنسيق Conflict-free Replicated Data Types، واختصاراً CRDT، وهي هياكل بيانات مصممة رياضياً بحيث إذا وصلت كل التعديلات إلى كل الأجهزة بأي ترتيب، وصلت كلها إلى نفس النتيجة دون سيرفر يقرر. وموقع crdt.tech يجمع الأبحاث والمكتبات حولها، وهي أساس أدوات التحرير المشترك مثل Automerge وYjs. وعملية add_cash في تجربتنا هي في الحقيقة أبسط CRDT ممكن، عداد يجمع الإضافات من كل الأجهزة.

لكن كن واقعياً في توقعك منها: CRDT تضمن أن الأجهزة تتفق على نتيجة واحدة، ولا تضمن أن هذه النتيجة صحيحة في منطق عملك. فهي لا تعرف أن «مسلم» يجب ألا يرجع إلى «جديد»، ولا أن الطلب الملغى يجب ألا يسلم، ولا أن المخزون يجب ألا ينزل تحت الصفر، فهذه قواعد عمل تحتاج من يفرضها. وشركة Figma مثلاً شرحت في مقالها عن تقنية التحرير المشترك لديها أنها استلهمت من CRDT لكنها لم تستخدمها كما هي، واعتمدت على سيرفر مركزي يقرر لأن ذلك أبسط في حالتها. لذلك في تطبيقات العمل مثل التوصيل والصيانة والمبيعات، فإن السيرفر الذي يطبق قواعد الحقول كما فعلنا هو الحل الأنسب غالباً، واترك CRDT للنصوص والمستندات التي يحررها عدة أشخاص في نفس اللحظة.

الحذف دون شبكة: علامة الحذف Tombstone

العميل اتصل وألغى الطلب o7، فحذفه الموزع من شاشته. وفي نفس الوقت كان مندوب آخر قد زامن جواله في الصباح والطلب ما زال في قائمته. والحل البسيط أن يحذف السيرفر الصف من الجدول بالأمر DELETE، فما الذي يحدث؟ هذا هو السيناريو S4، مرة بالحذف الحقيقي ومرة بعلامة حذف:

S4  the dispatcher deletes an order while a second phone is offline
  tombstones=off  phone pulled 0 rows, phone still has o7: True
  push result            applied_created
  server o7              {'id': 'o7', 'status': 'new', 'note': 'customer not home', 'cash': 0, 'version': 1, 'deleted': 0, 'updated_at': 260.0, 'seq': 12}
  tombstones=on   phone pulled 1 rows, phone still has o7: False
  push result            nothing to push
  server o7              {'id': 'o7', 'status': 'new', 'note': '', 'cash': 0, 'version': 2, 'deleted': 1, 'updated_at': 200.0, 'seq': 11}

لنقرأ الحالة الأولى سطراً سطراً:

  • phone pulled 0 rows, phone still has o7: True: الجوال زامن بعد الحذف، ولم يجلب أي صف، والطلب ما زال عنده. والسبب أن المؤشر يجلب الصفوف التي تغير رقمها التسلسلي، والصف المحذوف لم يعد موجوداً أصلاً ليحمل رقماً، فالحذف ببساطة غير مرئي للمزامنة.
  • المندوب وصل إلى عنوان الطلب الملغى، فلم يجد أحداً، فكتب ملاحظة «customer not home»، ثم زامن، فكان الرد applied_created: السيرفر لم يجد الصف، فاعتبر التعديل صفاً جديداً وأنشأه.
  • السطر الأخير يبين الطلب في السيرفر بحالة new وdeleted: 0، أي أن الطلب الذي ألغاه العميل عاد إلى قائمة التوصيل، وقد يرسل إلى مندوب ثالث غداً.

وفي الحالة الثانية لم يحذف السيرفر الصف، وإنما غير العمود deleted إلى 1، وهذا تعديل عادي يأخذ رقماً تسلسلياً جديداً (seq 11). لذلك جلب الجوال صفاً واحداً (phone pulled 1 rows) فحذف الطلب من قائمته (phone still has o7: False)، ولم يجد المندوب الطلب أصلاً ليكتب عليه، فكان الرد nothing to push. ولو أن المندوب كتب الملاحظة قبل أن يزامن، لرفض السيرفر تعديله بالرد rejected_deleted وأعاد له الصف المحذوف ليحذفه محلياً، لأن كود السيرفر يفحص العلامة قبل أي تعديل.

مقارنة بين الحذف الحقيقي الذي لا يراه الجوال فيعيد تعديله إنشاء الطلب في السيرفر، وعلامة الحذف التي تصل إلى الجوال عبر المؤشر كأي تعديل فيحذف الطلب محلياً
الحذف الحقيقي غير مرئي للمزامنة، وعلامة الحذف تجعله تعديلاً يصل إلى كل الأجهزة

هذه الظاهرة اسمها عودة المحذوف Resurrection، وهي من أكثر الأخطاء شيوعاً في أول تطبيق يبنيه فريق للعمل دون شبكة، لأن كل اختبار يجري على جهاز واحد متصل لن يظهرها أبداً. والقاعدة: في أي جدول يزامن، الحذف علامة وليس DELETE.

متى تحذف علامات الحذف Tombstones نهائياً؟

علامات الحذف تتراكم، فجدول الطلبات بعد سنة سيكون أغلبه طلبات قديمة محذوفة أو منتهية. والحل أن تحذفها نهائياً بعد مدة أطول من أطول فترة يمكن أن يبقى فيها جهاز دون مزامنة، وهي نفس المدة التي حددتها لحفظ أرقام العمليات في القسم السابق، وأن ترفض مزامنة أي جهاز مؤشره أقدم من هذه المدة، وتطلب منه مزامنة كاملة من البداية. والسبب أن جهازاً غاب ثلاثة أشهر ثم رجع، إذا جلب ما تغير فقط فلن يرى الحذف الذي محيت علامته، وبالتالي يعود المحذوف للحياة مرة أخرى. وهذا الرفض يجب أن يكون في تصميم ال API من اليوم الأول، بحالة خاصة يفهمها التطبيق ويبدأ بها المزامنة الكاملة دون تدخل المستخدم.

المشكلة التي ظهرت لنا: 120 تسليماً ضاعت دون أي خطأ

بعد أن عملت كل السيناريوهات السابقة كتبنا السيناريو S5 لنقيس حجم ال Outbox بعد يوم عمل كامل دون شبكة: المندوب يمر على 120 طلباً، ولكل طلب ثلاث عمليات، تغيير الحالة إلى out_for_delivery عند الخروج به، ثم إلى delivered مع ملاحظة عند التسليم، ثم تسجيل المبلغ. ثم يزامن مرة واحدة في نهاية اليوم. وكنا نتوقع أن نرى أرقام الحجم والزمن فقط، لكن هذا ما ظهر في أول تشغيل:

S5  how big is the outbox after a day offline, and how long does a push take
  coalesce=off
  outbox operations      360
  outbox bytes           55486
  ops sent               360
  push + pull ms         60.5
  results                {'applied': 240, 'conflict': 120}
  delivered on server    0
  server cash total      5158

360 عملية في 55,486 بايت، وهذا حجم صغير لا يقلق. لكن السطر results يقول إن 120 عملية رفضت كتعارض، والسطر الذي بعده يقول إن عدد الطلبات المسلمة في السيرفر صفر. أي أن المندوب سلم 120 طلباً، وحصل مبالغها كلها (5158 وصلت كاملة)، ولا يوجد في السيرفر طلب واحد مسلم.

والسبب كان في كود الجوال نفسه، وليس في السيرفر. فعندما يغير المندوب الحالة إلى out_for_delivery، تحمل العملية base_version يساوي 1، وهو الإصدار المحفوظ في الصف المحلي. ثم عندما يسلم، تحمل العملية الثانية أيضاً base_version يساوي 1، لأن الصف المحلي لا يعرف إصداراً جديداً حتى يرد السيرفر، والسيرفر لم يرد لأن الجوال دون شبكة. وعند المزامنة نفذ السيرفر العملية الأولى فرفع الإصدار إلى 2، ثم وصلته الثانية مبنية على الإصدار 1، ففحص الإصدار قال «تعارض» ورفضها، وأعاد الصف بحالة out_for_delivery. أي أن التطبيق تعارض مع نفسه، والعمليات الثلاث من نفس الجهاز ونفس المستخدم. وعمليات التحصيل نجحت لأنها إضافات لا تفحص الإصدار، ولذلك بدا كل شيء سليماً في الحسابات، وهذا ما يجعل الخطأ خطيراً: لا يوجد استثناء Exception، ولا رسالة خطأ للمستخدم، ولا شيء في السجلات إلا ردود conflict تبدو طبيعية في نظام فيه تعارضات أصلاً.

الحل: دمج العمليات Coalescing قبل الإرسال

هناك حلان. الأول أن يرسل الجوال العمليات واحدة واحدة، وبعد كل رد يحدث base_version للعمليات التالية على نفس الطلب، وهذا صحيح لكنه يحول 360 عملية إلى 360 طلباً متتالياً على شبكة ضعيفة. والثاني، وهو ما طبقناه، أن يدمج الجوال قبل الإرسال كل تعديلات القيم المتتالية على نفس الطلب في عملية واحدة تحمل base_version الأول وآخر قيمة لكل حقل، ويترك عمليات الإضافة كما هي لأنها لا تدمج بهذه الطريقة:

def coalesce(self):
    """Fold consecutive 'set' operations on the same order into one, keeping the first base_version."""
    merged, last_set = [], {}
    for op in self.pending():
        prev = last_set.get(op["order_id"])
        if op["kind"] == "set" and prev is not None:
            prev["fields"].update(op["fields"])
            prev["client_ts"] = op["client_ts"]
            continue
        merged.append(op)
        if op["kind"] == "set":
            last_set[op["order_id"]] = op
        elif op["kind"] == "delete":
            last_set.pop(op["order_id"], None)

ثم أعدنا نفس السيناريو مع الدمج:

  coalesce=on
  outbox operations      360
  outbox bytes           55486
  ops sent               240
  push + pull ms         108.9
  results                {'applied': 240}
  delivered on server    120
  server cash total      5158

ال Outbox ما زال فيه 360 عملية قبل الإرسال، لكن الجوال أرسل 240 فقط: 120 تعديلاً للحالة بعد الدمج و120 عملية تحصيل. وكل العمليات قبلت، والطلبات المسلمة في السيرفر 120، والمبلغ نفسه 5158. أما زمن الدفع والجلب فكان في الحالتين أقل من ربع ثانية، ويتغير من تشغيل لآخر بحسب انشغال الجهاز، فلا تقرأ الفرق بين الرقمين على أنه أثر للدمج، فالذي يهم هنا هو عدد العمليات وعدد الردود المرفوضة.

120 طلباً لكل منها ثلاث عمليات في ال Outbox؛ الإرسال كما هي يعطي 240 مقبولة و120 تعارضاً وصفر طلبات مسلمة في السيرفر، والدمج قبل الإرسال يعطي 240 عملية كلها مقبولة و120 طلباً مسلماً
تعديلان متتاليان من نفس الجهاز على نفس الطلب تعارضا مع بعضهما حتى دمجناهما

والدرس من هذه المشكلة أكبر من الدمج نفسه. أولاً فحص الإصدار يحمي من تعارض جهازين، لكنه يحتاج أن تفكر في تعارض الجهاز مع نفسه، وهذه حالة لا تظهر أبداً عندما تختبر وأنت متصل، لأن كل تعديل يرسل ويرد قبل التالي. ثانياً، احسب في كل اختبار للمزامنة ما يجب أن تكون عليه النتيجة النهائية في السيرفر (120 طلباً مسلماً)، ولا تكتف بأن العملية انتهت دون استثناء. ولو لم نضع السطر delivered on server في التجربة لما رأينا المشكلة، فالمبالغ كانت صحيحة والتطبيق لم يعرض أي خطأ.

متى تعمل المزامنة؟ الجدولة في الخلفية Background Sync

محرك المزامنة يحتاج أن يعمل في الوقت المناسب: بعد كل تعديل إذا كانت الشبكة موجودة، وعند عودة الشبكة إذا لم تكن، ودورياً لجلب تغييرات الآخرين. والخطأ الشائع أن تبني ذلك بخيط Thread يعيد المحاولة كل دقيقة داخل التطبيق، وهذا يتوقف بمجرد أن ينهي النظام التطبيق في الخلفية، ويستنزف البطارية بينما التطبيق يعمل. والصحيح أن تسلم الجدولة للنظام:

  • في Android تستخدم WorkManager: عمل مزامنة واحد باسم ثابت، بشرط وجود الشبكة، وإعادة محاولة بانتظار يتزايد. والنظام يحفظ هذا العمل حتى لو أعيد تشغيل الجهاز، وينفذه عندما يتحقق الشرط.
  • في iOS يوجد إطار BackgroundTasks لجدولة عمل في الخلفية، لكن النظام هو من يقرر متى ينفذه بحسب استخدام المستخدم للتطبيق والبطارية، ولا يوجد ضمان للتوقيت، فالمزامنة الأساسية في iOS تحدث عادة عند فتح التطبيق وعودته إلى الواجهة.
  • الإشعار الصامت Silent Push يستطيع أن يوقظ التطبيق ليجلب التغييرات، لكن المنصتين تحدان منه ولا تضمنان وصوله، فاستخدمه كتلميح «هناك جديد» وليس كآلية المزامنة نفسها. وقد شرحنا كيف تصل الإشعارات إلى الجهاز ولماذا لا تضمن المنصات وصولها في مقال كيف تعمل الإشعارات الفورية.

وتفاصيل متى يسمح النظام للتطبيق بالعمل في الخلفية، ووضع توفير الطاقة Doze، ولماذا يتأخر العمل المجدول، هي موضوع مقال دورة حياة التطبيق من هذه المرحلة، فلن نكررها هنا. والذي يخص المزامنة منها قاعدة واحدة: صمم المحرك بحيث يكون تشغيله في أي لحظة آمناً، وتشغيله مرتين متتاليتين آمناً، وتوقفه في منتصف العمل آمناً. وهذا بالضبط ما تعطيك إياه الأجزاء السابقة: ال Outbox لا يحذف منه شيء قبل تأكيد السيرفر، ومفتاح منع التكرار يجعل الإعادة لا تضر، والمؤشر لا يتقدم إلا بعد حفظ الصفوف المجلوبة.

حالة المزامنة Sync Status: كيف يعرف المستخدم ما الذي لم يرسل بعد؟

التطبيق الذي يعمل دون شبكة يجب أن يكون صادقاً مع المستخدم، فإذا عرض الطلب مسلماً والتسليم ما زال في ال Outbox، ثم أعطى المندوب جواله لزميل أو نفدت بطاريته في نهاية اليوم، فمن حقه أن يعرف أن هناك عمليات لم تصل بعد. لذلك اعرض علامة صغيرة على كل عنصر له عمليات تنتظر، وهذا سهل لأن ال Outbox يحمل رقم الطلب، واعرض في أعلى الشاشة عدد العمليات المنتظرة ووقت آخر مزامنة ناجحة. وإذا رفض السيرفر عملية، كما في التعارض أو الطلب المحذوف، فلا تبتلع الرفض بصمت كما فعل كود التجربة عمداً، وإنما احفظه وأظهره للمستخدم أو للمشرف بلغة يفهمها: «ألغي هذا الطلب قبل أن تصل ملاحظتك». ولا تسمح بتسجيل الخروج Logout وفي ال Outbox عمليات، أو على الأقل حذر المستخدم بوضوح، لأن تسجيل الخروج عادة يمسح القاعدة المحلية ومعها كل ما لم يرسل.

ترقية قاعدة البيانات على الجهاز Schema Migration

في السيرفر تملك قاعدة البيانات وحدك، وتنفذ الترقية مرة واحدة في وقت تختاره. أما في الجوال فلديك آلاف القواعد، كل منها على إصدار مختلف من التطبيق، والترقية تحدث على جهاز المستخدم عندما يحدث التطبيق من المتجر، وقد يقفز المستخدم من الإصدار 3 إلى الإصدار 9 مباشرة. وفي Room تكتب لكل انتقال بين إصدارين دالة ترحيل Migration، أو تعرف ترحيلاً تلقائياً للتغييرات البسيطة، وإذا لم يجد Room طريقاً من إصدار القاعدة الحالي إلى الجديد فإنه يرمي استثناء عند فتح القاعدة، وهذا موثق في صفحة ترحيل قاعدة بيانات Room. والخيار الذي يحذف القاعدة ويبنيها من جديد عند غياب الترحيل مريح في أثناء التطوير، لكنه في تطبيق يعمل دون شبكة يعني حذف ال Outbox، أي حذف كل عمل المستخدم الذي لم يرسل بعد، فلا تستخدمه في الإصدارات المنشورة إطلاقاً.

ولاحظ أن الترقية لها وجه ثان في السيرفر: ال Outbox في جوال لم يحدث منذ شهر يحمل عمليات بالشكل القديم، وسوف تصل إلى السيرفر بعد أن غيرت ال API. لذلك ضع رقم إصدار لشكل العملية داخل كل سطر في ال Outbox، واجعل السيرفر يقبل الأشكال القديمة مدة كافية، أو يرفضها برد يفهمه التطبيق. وقد ناقشنا كيف تغير ال API والنسخ القديمة من التطبيق ما زالت عند المستخدمين في سؤال النسخ القديمة من التطبيق في أسئلة مقابلة مطور الجوال.

حماية البيانات المحلية Local Data Security

عندما تنقل البيانات إلى الجهاز، فأنت تنقل معها مسؤولية حمايتها. وجدول الطلبات في تطبيق التوصيل فيه أسماء العملاء وأرقام هواتفهم وعناوينهم، وال Outbox فيه الملاحظات التي كتبها المندوب، وقد تكون فيها معلومات لا يجب أن تبقى على الجهاز. لذلك:

  • اجعل البيانات في مجلد التطبيق الخاص، فهو محمي من التطبيقات الأخرى، ولا تكتب نسخاً في مجلدات مشتركة أو في السجلات Logs. وتوصيات Android العامة في صفحة نصائح الأمن.
  • في iOS حدد مستوى حماية الملفات، مثل NSFileProtectionComplete الذي يجعل الملف مشفراً ولا يقرأ والجهاز مقفل، لكن انتبه أن المزامنة في الخلفية والجهاز مقفل تحتاج مستوى يسمح بذلك، وهذا قرار توازن فيه بين الحماية والعمل في الخلفية.
  • إذا قررت تشفير القاعدة نفسها، فاحفظ المفتاح في Android Keystore أو Keychain في iOS، ولا تحفظه في الكود ولا في ملف بجانب القاعدة.
  • احذف ما لا تحتاجه: الطلبات المسلمة لا يحتاجها المندوب بعد أيام، فاحذفها محلياً بعد المزامنة ومرور مدة، وقلل بذلك ما يمكن أن يسرق مع الجهاز.

ومتى تشفر القاعدة ومتى يكفي تشفير الجهاز ناقشناه في سؤال تشفير قاعدة البيانات المحلية.

كيف تختبر المزامنة Sync Testing؟

كل خطأ رأيناه في هذا المقال لا يظهر في الاختبار العادي على جهاز متصل، ولذلك اختبار المزامنة يحتاج أن تصنع الظروف بنفسك. والسيناريوهات الستة في التجربة هي في الحقيقة اختبارات، ويمكنك أن تحولها إلى اختبارات وحدة Unit Tests لمنطق المزامنة في تطبيقك: رد يضيع ثم إعادة، وجهازان يعدلان نفس الحقل، وساعة جهاز متأخرة، وحذف وتعديل متزامنان، ويوم كامل دون شبكة بعمليات متتالية على نفس العنصر، وإنهاء التطبيق بين كتابتين. وفي كل اختبار لا تفحص أن العملية انتهت، وإنما افحص الحالة النهائية في السيرفر وعلى كل جهاز، وأن الجهازين متطابقان بعد المزامنة.

ثم على الجهاز الحقيقي أو المحاكي جرب وضع الطيران في منتصف المزامنة، وأغلق التطبيق من قائمة التطبيقات الأخيرة بعد الضغط مباشرة، وأعد تشغيل الجهاز وال Outbox ممتلئ، وتأكد أن كل شيء وصل. وأدوات أتمتة اختبارات الواجهة التي شرحناها في مقال اختبار تطبيقات الجوال مع Appium وMaestro تستطيع أن تنفذ هذه الخطوات على المحاكي في كل إصدار. وإذا أردت أن تعرف ما يحدث في أجهزة المستخدمين الحقيقية بعد الإطلاق، فأرسل خطأ غير قاتل Non-fatal إلى أداة تقارير الأعطال كلما رفض السيرفر عملية أو بقي ال Outbox ممتلئاً أكثر من يوم، كما شرحنا في مقال تقارير الأعطال في تطبيقات الجوال.

أخطاء شائعة

  • الواجهة تنتظر رد الشبكة قبل أن تعرض التغيير: هذا ليس تطبيقاً يعمل دون شبكة، حتى لو كان فيه Cache للقراءة.
  • تعديل الصف وإضافة العملية إلى ال Outbox في معاملتين منفصلتين: رأينا في S6 أن هذا يترك الجوال على delivered والسيرفر على new إلى الأبد.
  • توليد رقم العملية عند كل إرسال بدل حفظه مع العملية: الإعادة تصبح عملية جديدة، ويسجل المبلغ مرتين كما في S2 دون الحماية.
  • حذف أرقام العمليات من السيرفر بعد يوم واحد بينما الأجهزة قد تبقى دون شبكة أياماً.
  • الاعتماد على ساعة الجوال في ترتيب التعديلات: خمس دقائق تأخير جعلت السيرفر يتجاهل تسليماً حقيقياً في S3.
  • حل التعارض على مستوى الصف كاملاً: تعديل ملاحظة يمحو حالة لم يلمسها أحد.
  • جلب ما تغير بعد آخر وقت مزامنة بدل رقم تسلسلي: تعديلات تضيع أو تتكرر عند الحدود.
  • الحذف الحقيقي في جدول يزامن: الطلب الملغى يعود للحياة كما في S4.
  • إرسال عمليات متتالية على نفس العنصر كل منها مبنية على نفس الإصدار القديم: التطبيق يتعارض مع نفسه، وقد ضاعت 120 تسليماً في S5 دون أي رسالة خطأ.
  • ابتلاع ردود الرفض بصمت، وعدم إظهار العمليات المنتظرة للمستخدم.
  • الترحيل الذي يحذف القاعدة عند غياب دالة الترحيل في إصدار منشور: يحذف معه كل ما في ال Outbox.
  • اختبار المزامنة على جهاز واحد متصل فقط.

جدول قرار Decision Table: أي طريقة لأي نوع من البيانات؟

نوع البياناتمثال من تطبيق التوصيلطريقة الإرسالحل التعارض
حالة تتقدم في مسار معروفحالة الطلبقيمة جديدة مع base_versionآلة حالات: الأبعد في المسار يفوز، والملغى له قاعدة خاصة
رقم يتراكمالمبلغ المحصل، الكمية في المستودععملية إضافة أو طرح مع مفتاح منع التكرارلا يوجد تعارض، السيرفر يجمع، مع قيود العمل مثل عدم النزول تحت الصفر
نص يكتبه إنسانملاحظة التسليمقيمة جديدة مع base_versionدمج النصين، أو عرض التعارض في تطبيقات التحرير
حقل يملكه طرف واحدعنوان العميل يعدله الموزع فقطقيمة جديدةالطرف المالك يفوز، ويرفض التعديل من غيره
إعداد شخصيلغة التطبيق، ترتيب القائمةقيمة جديدةآخر كتابة تفوز مقبولة هنا، والخسارة بسيطة
حذفطلب ألغاه العميلعلامة حذف بإصدار جديدالحذف يفوز على التعديل، أو يرفض الحذف إذا كان الطلب سلم
عملية يجب أن يقبلها السيرفر قبل حدوثهاحجز موعد، دفع إلكترونيلا تعمل دون شبكةأظهر للمستخدم أن العملية تحتاج اتصالاً

قائمة تحقق قبل الإطلاق Checklist

  • الواجهة تقرأ من القاعدة المحلية فقط، وتكتب فيها أولاً.
  • كل تعديل يكتب مع سطره في ال Outbox في معاملة واحدة.
  • لكل عملية رقم فريد يولد عند إنشائها ويحفظ معها.
  • السيرفر يحفظ أرقام العمليات المنفذة مع ردودها، في نفس معاملة التنفيذ، مدة أطول من أطول غياب متوقع للجهاز.
  • المزامنة تجلب بمؤشر تسلسلي من السيرفر، على صفحات، ويحفظ المؤشر بعد حفظ كل صفحة.
  • لكل حقل يمكن أن يتعارض قاعدة مكتوبة ومختبرة، وساعة الجوال لا تستخدم في الترتيب.
  • الحذف علامة، والعلامات تمحى بعد مدة محددة، والأجهزة الأقدم منها تزامن من البداية.
  • العمليات المتتالية على نفس العنصر تدمج قبل الإرسال أو يحدث إصدارها بعد كل رد.
  • المزامنة يجدولها النظام بشرط الشبكة، وتشغيلها مرتين أو توقفها في المنتصف آمن.
  • المستخدم يرى ما لم يرسل بعد، وما رفضه السيرفر، ولا يخسر شيئاً عند تسجيل الخروج دون تحذير.
  • لكل إصدار من قاعدة الجهاز دالة ترحيل، ولا يوجد ترحيل يحذف القاعدة في الإصدارات المنشورة.
  • البيانات المحلية في مجلد التطبيق، ومحمية بحسب حساسيتها، وتحذف عندما لا يحتاجها المستخدم.
  • اختبارات المزامنة تفحص الحالة النهائية في السيرفر والأجهزة، وتغطي الرد الضائع والتعارض والحذف والإنهاء في المنتصف.

تدريب عملي

  • شغل السيناريو S3 بعد أن تجعل ساعة جوال المندوب متقدمة 300 ثانية بدل متأخرة (غير clock_skew إلى 300.0)، ولاحظ أن LWW تقبل تعديل المندوب هذه المرة، ثم فكر: هل أصبحت الطريقة صحيحة، أم أن الخطأ انتقل إلى طرف آخر؟
  • أضف إلى طريقة الدمج قاعدة تمنع تسليم طلب ملغى: إذا كانت حالة السيرفر cancelled وحالة المندوب delivered، احفظ الطلب كتعارض يراجعه المشرف بدل أن تختار أحدهما.

الخلاصة

وصلنا لنهاية الموضوع، وأهم ما فيه:

  • التطبيق الذي يعمل دون إنترنت أولاً يجعل القاعدة المحلية مصدر الحقيقة للواجهة، والشبكة قناة في الخلفية، والسيرفر المرجع الذي تتفق عليه الأجهزة.
  • كل تعديل يكتب مع عمليته في ال Outbox في معاملة واحدة، وإلا بقي الجوال على delivered والسيرفر على new دون أي طريق للتصحيح.
  • المؤشر التسلسلي جعل مزامنة 3 تغييرات 350 بايت بدل 193,812 بايت لكل الطلبات، والرد الفارغ 28 بايت.
  • الرد الضائع يجعل الإعادة حتمية، ومفتاح منع التكرار هو ما جعل المبلغ 150 بدل 300.
  • آخر كتابة تفوز مع ساعة متأخرة خمس دقائق ضيعت تسليماً حقيقياً، وفحص الإصدار كشف التعارض، ودمج الحقول مع آلة الحالات أعطى النتيجة الصحيحة.
  • الحذف الحقيقي غير مرئي للمزامنة فيعود المحذوف للحياة، وعلامة الحذف تجعله تعديلاً يصل إلى كل جهاز.
  • عمليتان متتاليتان من نفس الجهاز على نفس الطلب تعارضتا مع بعضهما فضاعت 120 تسليماً دون أي خطأ ظاهر، والدمج قبل الإرسال أصلح ذلك، والدرس أن تفحص النتيجة النهائية وليس نجاح العملية.

وفي مقالات هذه المرحلة نكمل بقية ما يحتاجه تطبيق الجوال: اختيار التقنية، ودورة حياة التطبيق والعمل في الخلفية، والروابط العميقة والإشعارات، ثم النشر في المتاجر:

دليلدورة حياة تطبيق الجوال Lifecycle والمهام الخلفيةلماذا يجد المستخدم السلة فارغة بعد مكالمة، وما الذي يبقى فعلاً بعد تدوير الشاشة وموت العملية والسبات Doze، بتجربة حقيقية على Android وشرح ما يقابلها في iOS.دليلالمعاملات الموزعة: Saga ونمط Outbox بدل 2PCكيف تنفذ عملية تمتد على عدة خدمات دون 2PC، بخطوات محلية وتعويضات وجدول Outbox لا يضيع فيه أي حدث.دليلIdempotency: كيف تجعل العملية آمنة عند تكرارهالماذا تصل الرسالة نفسها أكثر من مرة، وكيف تكتب مستهلكاً يطبقها مرة واحدة فقط مهما تكررت.دليلأسئلة مقابلة مطور تطبيقات الجوال من المبتدئ إلى الخبيربنك أسئلة لمقابلة مطور تطبيقات الجوال على Android وiOS، لكل سؤال إجابة المبتدئ وإجابة الخبير، مع تمارين عملية شغلناها.

سجل التحديثات

  • أكتوبر 2026: كتابة المقال، وتنفيذ تجربة المزامنة بستة سيناريوهات على Python 3.13.3 مع SQLite 3.49.1 على Windows 11.
نشرة عرب رووت | ArabRoot

معرفة تستحق مكاناً في بريدك.

مقالات مختارة وأدوات مفيدة وأفكار لمشروعك القادم، في رسالة واحدة كل أسبوع.

يمكنك إلغاء الاشتراك متى شئت. الخصوصية

تم استلام طلبك. افتح بريدك واضغط رابط التأكيد لإتمام الاشتراك.