تعديل جداول قاعدة البيانات على الإنتاج بلا توقف

تعديل جدول بملايين الصفوف على الإنتاج ممكن بلا توقف إذا اخترت الخوارزمية الصحيحة: INSTANT لإضافة الأعمدة في MySQL 8، وgh-ost أو pt-online-schema-change لإعادة البناء الثقيلة، مع نمط التوسيع ثم الترحيل ثم التقليص بدل تغيير واحد كاسر.
تعديل جدول كبير على قاعدة بيانات الإنتاج بلا توقف يعتمد على ثلاث قواعد. أولاً اعرف نوع التعديل قبل تنفيذه: في MySQL 8 إضافة عمود في آخر الجدول أو تغيير قيمته الافتراضية تتم بـALGORITHM=INSTANT في أجزاء من الثانية مهما كان حجم الجدول، بينما تغيير نوع عمود أو مفتاحه الأساسي يعيد بناء الجدول كاملاً، وهذا يستغرق دقائق أو ساعات. ثانياً للتعديلات الثقيلة استخدم أداة تبني نسخة ظل من الجدول وتنسخ الصفوف على دفعات ثم تبدّل الاسمين في لحظة، مثل gh-ost أو pt-online-schema-change. ثالثاً لا تغيّر شيئاً يعتمد عليه الكود الحالي بخطوة واحدة، بل اتبع نمط التوسيع ثم الترحيل ثم التقليص: أضف الجديد بجانب القديم، انقل البيانات والكود تدريجياً، ثم احذف القديم في نشر لاحق. أهم سطر قبل أي هجرة هو SET SESSION lock_wait_timeout = 5، لأنه يمنع أخطر سيناريو: أمر ALTER ينتظر قفلاً، فيصطف خلفه كل استعلام على الجدول، ويتوقف الموقع كله في أقل من دقيقة.
لماذا يقفل ALTER TABLE جدولاً بملايين الصفوف
MySQL تنفذ تعديل البنية بإحدى ثلاث خوارزميات، والفرق بينها هو الفرق بين تعديل غير ملحوظ وتوقف ساعة:
| الخوارزمية | ما يحدث | جدول بـ20 مليون صف | أمثلة |
|---|---|---|---|
| INSTANT | تعديل بيانات وصف الجدول فقط | أقل من ثانية | إضافة عمود، تغيير DEFAULT، إعادة تسمية عمود |
| INPLACE | إعادة بناء داخلية مع السماح بالكتابة غالباً | من 5 إلى 40 دقيقة | إضافة فهرس، حذف فهرس |
| COPY | نسخ الجدول كاملاً مع منع الكتابة | من 20 دقيقة إلى ساعات | تغيير نوع عمود، تغيير المفتاح الأساسي |
الأرقام تقريبية على قرص SSD وتختلف حسب عرض الصف وعدد الفهارس، لكن النسبة بينها ثابتة. الخطر الأكبر ليس في الخوارزمية بل في قفل البيانات الوصفية (metadata lock): كل ALTER يحتاج هذا القفل ولو للحظة، حتى INSTANT. إذا كانت هناك معاملة طويلة مفتوحة على الجدول، كتقرير يعمل منذ 10 دقائق أو اتصال نسي أن يغلق معاملته، فإن ALTER ينتظر، وكل استعلام SELECT جديد على الجدول يصطف خلف ALTER المنتظر. خلال ثوانٍ تمتلئ مجموعة اتصالات PHP-FPM وتظهر أخطاء 502. الحل أن تحدد مهلة قصيرة فيفشل ALTER بأمان بدل أن يجمّد الموقع:
SET SESSION lock_wait_timeout = 5;
ALTER TABLE orders ADD COLUMN tracking_code VARCHAR(64) NULL, ALGORITHM=INSTANT;
كتابة ALGORITHM=INSTANT صراحة لها فائدة ثانية: إذا لم يكن التعديل قابلاً للتنفيذ الفوري ترفضه MySQL برسالة خطأ بدل أن تنتقل بصمت إلى COPY وتقفل الجدول. افعل الشيء نفسه مع ALGORITHM=INPLACE, LOCK=NONE عند إضافة الفهارس.
نمط التوسيع ثم الترحيل ثم التقليص
خذ مثالاً واقعياً: تريد إعادة تسمية العمود phone إلى mobile في جدول العملاء. لو نفذت إعادة التسمية مباشرة ثم نشرت الكود، ستكون هناك ثوانٍ أو دقائق يعمل فيها كود قديم على بنية جديدة أو العكس، وكل طلب خلالها يفشل. النمط الآمن يقسم العملية إلى أربعة نشرات منفصلة:
- التوسيع: أضف العمود
mobileفارغاً (INSTANT)، ولا تلمسphone. - الكتابة المزدوجة: انشر كوداً يكتب في العمودين معاً ويقرأ من القديم.
- الترحيل: انسخ البيانات القديمة على دفعات صغيرة، ثم انشر كوداً يقرأ من الجديد.
- التقليص: بعد أيام بلا مشاكل، احذف
phoneفي هجرة أخيرة.
نسخ البيانات في الخطوة الثالثة لا يتم بأمر UPDATE واحد على 20 مليون صف، لأنه يفتح معاملة ضخمة تقفل الصفوف وتضخم سجل التراجع. اكتبه كأمر artisan يعالج 5,000 صف في كل دفعة مع توقف قصير:
DB::table('customers')
->whereNull('mobile')
->chunkById(5000, function ($rows) {
foreach ($rows as $row) {
DB::table('customers')->where('id', $row->id)
->update(['mobile' => $row->phone]);
}
usleep(100000); // 100ms راحة للنسخ المتماثل
});
بهذه الوتيرة ينتهي 20 مليون صف في نحو ساعة إلى ساعتين دون أن يشعر أحد، والتوقف القصير يعطي خوادم النسخ المتماثل (replicas) فرصة للحاق فلا يتراكم التأخير.
gh-ost وpt-online-schema-change للتعديلات الثقيلة
حين يكون التعديل من نوع COPY، كتغيير INT إلى BIGINT في مفتاح أساسي اقترب من حده الأقصى البالغ 2,147,483,647، فأنت تحتاج أداة تنفذ النسخ بنفسها بلا قفل. الأداتان الأشهر تتبعان نفس الفكرة: إنشاء جدول ظل بالبنية الجديدة، نسخ الصفوف على دفعات، متابعة التغييرات الجارية، ثم تبديل الاسمين في عملية ذرية.
gh-ost --host=127.0.0.1 --database=app --table=orders \
--alter="MODIFY id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT" \
--chunk-size=1000 --max-load=Threads_running=25 \
--critical-load=Threads_running=60 --cut-over=default --execute
الفرق الجوهري بينهما في طريقة متابعة التغييرات:
| المعيار | gh-ost | pt-online-schema-change |
|---|---|---|
| متابعة التغييرات | يقرأ سجل binlog | triggers على الجدول الأصلي |
| الحمل على الجدول | أخف، لا كتابة إضافية متزامنة | كل كتابة تُنفذ مرتين |
| الإيقاف المؤقت | ممكن أثناء العمل | محدود |
| المتطلبات | binlog_format=ROW | يعمل مع أغلب الإعدادات |
| المفاتيح الأجنبية | لا يدعمها | يدعمها بخيارات خاصة |
الخياران --max-load و--critical-load هما صمام الأمان: الأول يبطئ النسخ إذا تجاوز عدد الاستعلامات النشطة 25، والثاني يلغي العملية كلها إذا وصل 60. خصص هذه الأرقام حسب الحمل الطبيعي لقاعدتك، وراقب المساحة لأن جدول الظل يحتاج مساحة تساوي حجم الجدول الأصلي تقريباً. جدول بـ40GB يحتاج 40GB فارغة إضافية على الأقل، وإذا كانت المساحة ضيقة فإضافة 100GB SSD على خادمك السحابي تكلف 500 ريال سنوياً، وهذا أرخص بكثير من هجرة توقفت في منتصفها لأن القرص امتلأ.
ترتيب نشر الكود مع الهجرات
القاعدة البسيطة: كل هجرة يجب أن تكون متوافقة مع الكود الحالي والكود الجديد معاً، لأن هناك لحظة يعمل فيها الاثنان. عملياً هذا يعني:
- الإضافات (عمود قابل لـNULL، جدول جديد، فهرس) تُنفذ قبل نشر الكود الذي يستخدمها.
- الحذف (عمود، جدول) يُنفذ بعد أن يصبح الكود لا يشير إليه، وفي نشر منفصل.
- عمود NOT NULL جديد يُضاف بقيمة DEFAULT، وإلا ستفشل عمليات الإدراج من الكود القديم.
- لا تضع تعديل بنية وتعديل بيانات ضخم في نفس ملف الهجرة.
في Laravel انتبه أن php artisan migrate في سكربت النشر يعمل بلا مهلة افتراضياً، فالهجرات الثقيلة على الجداول الكبيرة يجب أن تُستثنى من النشر التلقائي وتُنفذ يدوياً بالأدوات أعلاه، ثم تسجّلها في جدول migrations حتى لا يحاول Laravel تنفيذها مرة ثانية.
خطة الرجوع حين تسوء الأمور
دالة down() في هجرات Laravel مريحة في بيئة التطوير، لكنها على جدول بـ20 مليون صف تعني تعديلاً ثقيلاً ثانياً في أسوأ توقيت ممكن، لذلك نفضّل على الإنتاج الإصلاح للأمام: هجرة جديدة صغيرة تصحح الخطأ بدل التراجع عن هجرة كبيرة. نمط التوسيع ثم التقليص يجعل هذا سهلاً بطبيعته، لأن العمود القديم يبقى موجوداً طوال فترة الانتقال، فإذا ظهرت مشكلة في الكود الجديد ترجع للكود السابق فقط دون أي تعديل في قاعدة البيانات. أما gh-ost فيترك الجدول الأصلي بعد التبديل باسم مثل _orders_del، فيمكنك إعادة التسمية بالعكس خلال ثوانٍ إذا اكتشفت مشكلة مباشرة بعد التبديل، بشرط ألا تكون كتابات جديدة كثيرة قد وصلت للجدول الجديد. احتفظ بهذا الجدول يوماً أو يومين ثم احذفه بعد التأكد، لأنه يحجز مساحة بحجم الجدول كاملاً.
قائمة فحص قبل أي هجرة على جدول كبير
- اعرف حجم الجدول وعدد صفوفه من
information_schema.TABLES. - جرّب التعديل على نسخة staging بحجم بيانات حقيقي وسجّل الزمن.
- اكتب
ALGORITHMوLOCKصراحة، ودع MySQL ترفض بدل أن تفاجئك. - ابحث عن المعاملات الطويلة قبل البدء في
information_schema.INNODB_TRX. - اضبط
lock_wait_timeoutعلى 5 ثوانٍ في الجلسة. - تأكد من وجود نسخة احتياطية حديثة جرى اختبار استعادتها فعلاً.
- تحقق من المساحة الحرة إن كنت ستستخدم gh-ost أو pt-osc.
- نفّذ في وقت منخفض الحركة وراقب تأخر النسخ المتماثل وعدد الاتصالات.
النسخة الاحتياطية في البند السادس ليست شكلية، فكثير من الحوادث تأتي من هجرة تقليص حذفت عموداً ثم اكتشف أحدهم أن تقريراً قديماً ما زال يحتاجه. إضافة النسخ الاحتياطي اليومي بحفظ 30 يوماً متاحة بـ2,000 ريال سنوياً، وتفاصيل كل الإضافات في صفحة الأسعار.
القرار العملي
قبل أي هجرة اسأل سؤالاً واحداً: هل هذا التعديل INSTANT؟ إن كان كذلك فنفذه مع مهلة قفل قصيرة وانتهى الأمر. إن لم يكن، وكان الجدول أقل من مليون صف، فـINPLACE مع LOCK=NONE في وقت هادئ يكفي غالباً. أما فوق ذلك فاستخدم gh-ost، وقسّم أي تغيير يكسر التوافق إلى توسيع ثم ترحيل ثم تقليص على عدة نشرات. هذه الخطوات تضيف يوماً أو يومين للمهمة، لكنها تحوّل الهجرة من حدث مخيف إلى عملية روتينية.


