عندما يتشارك إصداران من التطبيق قاعدة بيانات واحدة: مرحلة الكتابة المزدوجة والقراءة المزدوجة
تخيل هذا: قام فريقك للتو بإضافة عمود جديد إلى جدول قاعدة بيانات في بيئة الإنتاج. تم تغيير المخطط (schema) بسلاسة. الآن تحتاج إلى نشر الإصدار الجديد من التطبيق الذي يكتب في هذا العمود. لكن الإصدار القديم من التطبيق لا يزال قيد التشغيل، يعالج الطلبات، ولا يعرف شيئًا عن هذا العمود الجديد.
إذا بدأ التطبيق الجديد بالكتابة فقط في العمود الجديد، فلن يرى التطبيق القديم تلك البيانات. سيحصل المستخدمون الذين تصل طلباتهم إلى التطبيق القديم على نتائج غير كاملة أو غير متسقة. لا يمكنك إيقاف التطبيق القديم فورًا. لا يمكنك تحويل كل شيء إلى الإصدار الجديد دفعة واحدة. أنت بحاجة إلى فترة انتقالية يتعايش فيها كلا الإصدارين ويتشاركان نفس قاعدة البيانات.
هنا تصبح أنماط الكتابة المزدوجة (dual-write) والقراءة المزدوجة (dual-read) ضرورية. ليست أنيقة. ليست بسيطة. لكنها الطريقة العملية لترحيل هياكل البيانات في نظام حي دون توقف.
المشكلة الأساسية: إصداران، قاعدة بيانات واحدة
عندما توسع مخططًا (schema) بإضافة عمود أو جدول جديد، يمكن لقاعدة البيانات أن تحتوي على كل من الهياكل القديمة والجديدة. لكن التطبيقات التي تقرأ وتكتب تلك البيانات ليست مرنة بنفس القدر. الإصدار القديم من التطبيق يفهم فقط الهيكل القديم. الإصدار الجديد يفهم كليهما، لكنه يحتاج إلى إبقاء الإصدار القديم يعمل حتى يتم ترقية كل مثيل (instance).
النهج الساذج هو جعل التطبيق الجديد يكتب فقط في العمود الجديد. هذا يكسر التطبيق القديم فورًا. التطبيق القديم يقرأ من العمود القديم، ولا يجد شيئًا، ويفشل. النهج الصحيح هو جعل التطبيق الجديد يكتب في كلا المكانين حتى يختفي التطبيق القديم.
يوضح مخطط التسلسل التالي تدفق عمليات الكتابة والقراءة خلال الفترة الانتقالية:
الكتابة المزدوجة: الكتابة في مكانين في وقت واحد
الكتابة المزدوجة تعني أن الإصدار الجديد من التطبيق يكتب البيانات إلى كل من الهيكل القديم والهيكل الجديد في كل عملية كتابة. عندما يقوم مستخدم بإنشاء سجل، يملأ التطبيق الجديد العمود القديم كما كان يفعل دائمًا، ثم يكتب نفس البيانات أيضًا إلى العمود الجديد.
هذا يبدو مباشرًا، لكن هناك تفصيلان مهمان جدًا.
إليك مثال بلغة JavaScript يطبق الكتابة المزدوجة والقراءة المزدوجة لتحديث ملف تعريف مستخدم:
async function updateUserProfile(userId, name, email) {
// كتابة مزدوجة: اكتب إلى العمود القديم أولاً، ثم العمود الجديد
await db.query(
'UPDATE users SET name = ?, email = ? WHERE id = ?',
[name, email, userId]
);
await db.query(
'UPDATE users SET profile_data = ? WHERE id = ?',
[JSON.stringify({ name, email }), userId]
);
}
async function getUserProfile(userId) {
// قراءة مزدوجة: فضل العمود الجديد، وتراجع إلى العمود القديم
const row = await db.query(
'SELECT profile_data, name, email FROM users WHERE id = ?',
[userId]
);
if (row.profile_data) {
return JSON.parse(row.profile_data);
}
return { name: row.name, email: row.email };
}
أولاً، يجب أن يكون ترتيب الكتابة ثابتًا. اكتب إلى العمود القديم أولاً، ثم إلى العمود الجديد. إذا فشلت العملية بعد الكتابة إلى العمود القديم وقبل الكتابة إلى العمود الجديد، فلا يزال بإمكان التطبيق القديم قراءة البيانات. سيكون العمود الجديد مفقودًا لهذا السجل، لكن يمكنك إصلاح ذلك لاحقًا بعملية الملء الخلفي (backfill). إذا كتبت إلى العمود الجديد أولاً وفشلت العملية، فسيرى التطبيق القديم بيانات غير مكتملة على الفور. هذه حادثة إنتاجية تنتظر الحدوث.
ثانيًا، يجب أن تكون القيم متطابقة. البيانات المكتوبة إلى العمود القديم والعمود الجديد يجب أن تمثل نفس المعلومات. إذا كان هناك أي تحويل أو اختلاف في المنطق بين عمليتي الكتابة، فسيكون لديك عدم تناسق في البيانات يصبح من الصعب جدًا تصحيحه لاحقًا. حافظ على منطق الكتابة متطابقًا. قد يخزن العمود الجديد البيانات بتنسيق أو هيكل مختلف، لكن المعنى يجب أن يكون هو نفسه.
القراءة المزدوجة: القراءة من مكانين، مع تفضيل القديم
بمجرد تشغيل الكتابة المزدوجة، يمكن للتطبيق الجديد كتابة البيانات التي يمكن لكلا الإصدارين قراءتها. لكن ماذا عن القراءة؟ يمكن للتطبيق الجديد أن يبدأ القراءة من العمود الجديد فورًا، لكن هذا يخلق مشكلة. التطبيق القديم لا يزال يكتب البيانات فقط إلى العمود القديم. إذا قرأ التطبيق الجديد فقط من العمود الجديد، فسيفتقد البيانات التي كتبها التطبيق القديم.
الحل هو القراءة المزدوجة. يقرأ التطبيق الجديد من كلا المكانين لكنه يعطي الأولوية للعمود القديم أثناء الفترة الانتقالية. هذا يضمن أن البيانات التي كتبها التطبيق القديم تكون مرئية دائمًا للتطبيق الجديد. بمرور الوقت، عندما تتحقق من أن البيانات تتدفق بشكل صحيح إلى العمود الجديد، يمكنك تحويل القراءات تدريجيًا إلى العمود الجديد.
هذا التحول التدريجي هو المكان الذي تصبح فيه أعلام الميزات (feature flags) مفيدة. يمكنك تكوين التطبيق الجديد ليقرأ من العمود الجديد لنسبة صغيرة من الطلبات. إذا لم يحدث شيء خاطئ، قم بزيادة النسبة. إذا ظهر خطأ، قم بعكس العلم (flag) وستعود جميع القراءات إلى العمود القديم. لا حاجة لإعادة النشر.
ماذا عن البيانات التي كتبها التطبيق القديم؟
خلال هذه الفترة الانتقالية، التطبيق القديم لا يزال قيد التشغيل ويكتب فقط إلى العمود القديم. يجب على التطبيق الجديد التعامل مع هذا. عندما يقرأ التطبيق الجديد سجلاً كتبه التطبيق القديم، فإنه يجد بيانات فقط في العمود القديم. يجب أن يكون التطبيق الجديد قادرًا على قراءة تلك البيانات من العمود القديم، واستخدامها، واختياريًا نسخها إلى العمود الجديد كجزء من عملية خلفية.
هذا ليس مثل الكتابة المزدوجة. هذه عملية ملء خلفي (backfill) تعمل بشكل منفصل، وتملأ العمود الجديد بالبيانات التي تمت كتابتها قبل أن يبدأ التطبيق الجديد في الكتابة إلى كلا المكانين. الملء الخلفي هو عملية دفعية (batch operation) تعمل بعد استقرار أنماط الكتابة المزدوجة والقراءة المزدوجة.
التعقيد الحقيقي: تنسيق الفترة الانتقالية
الجزء الأصعب من هذه المرحلة ليس الكود. إنه التنسيق. تحتاج إلى معرفة أي المثيلات (instances) تعمل بأي إصدار. تحتاج إلى معرفة متى تم إيقاف تشغيل آخر مثيل قديم. تحتاج إلى مراقبة التناقضات في البيانات بين الأعمدة القديمة والجديدة.
أثناء الكتابة المزدوجة، تصبح كل عملية كتابة عمليتين كتابة. هذا يعني حملًا أكبر على قاعدة البيانات، ووقت معاملات (transaction) أطول، ونقاط فشل محتملة أكثر. راقب مقاييس قاعدة البيانات خلال هذه المرحلة. إذا زاد زمن الوصول (latency) للكتابة، قد تحتاج إلى تجميع عمليات الكتابة (batching) أو استخدام النسخ المتماثل غير المتزامن (asynchronous replication).
أثناء القراءة المزدوجة، قد تحتاج كل عملية قراءة إلى التحقق من موقعين. هذا يضيف تعقيدًا لمنطق الاستعلام (query logic) الخاص بك ويمكن أن يبطئ مسارات القراءة. استخدم التخزين المؤقت (caching) بحذر. لا تقم بتخزين البيانات من العمود القديم مؤقتًا إذا كان من المفترض أن يصبح العمود الجديد مصدر الحقيقة (source of truth).
قائمة ممارسات عملية للفترة الانتقالية
- تأكد من نشر توسعة المخطط (عمود أو جدول جديد) والتحقق منها.
- انشر الإصدار الجديد من التطبيق مع منطق الكتابة المزدوجة: اكتب إلى العمود القديم أولاً، ثم العمود الجديد.
- تحقق من أن البيانات التي كتبها التطبيق الجديد مرئية للتطبيق القديم.
- فعّل القراءة المزدوجة في التطبيق الجديد: اقرأ من العمود القديم افتراضيًا، لكن كن مستعدًا للتبديل.
- استخدم علم ميزة (feature flag) لتحويل القراءات تدريجيًا من العمود القديم إلى العمود الجديد.
- راقب التناقضات في البيانات بين الأعمدة القديمة والجديدة.
- ابدأ عملية الملء الخلفي (backfill) لنسخ البيانات القديمة إلى العمود الجديد.
- بعد اكتمال الملء الخلفي واستخدام جميع القراءات للعمود الجديد، قم بإزالة منطق الكتابة المزدوجة.
- قم بإيقاف تشغيل العمود أو الجدول القديم بعد التأكد من عدم قراءة أي تطبيق منه.
الخلاصة
الكتابة المزدوجة والقراءة المزدوجة ليستا أنماطًا دائمة. إنها جسور مؤقتة تسمح لك بترحيل هياكل البيانات مع إبقاء النظام قيد التشغيل لجميع المستخدمين. الهدف هو الوصول إلى حالة حيث يهم فقط الهيكل الجديد، ويمكن إزالة الهيكل القديم. حتى ذلك الحين، كل كتابة تذهب إلى مكانين، وكل قراءة تتحقق من مصدرين، ويظل فريقك في حالة تأهب للتناقضات. هذه المرحلة غير مريحة، لكنها الطريقة الوحيدة لتغيير مخطط قاعدة بيانات حي دون إيقاف العالم.