◀ العودة إلى المدونة
DevOps

ترحيل قواعد البيانات دون انقطاع

نشر في 12 Oct 2024· 8 min قراءة
#Database#Migration#DevOps

ترحيل قواعد البيانات دون أي انقطاع (Zero-downtime)

غالباً ما تكون ترحيلات قاعدة البيانات الجزء الأكثر خطورة في عملية النشر. إليك كيف تنفّذها دون أي انقطاع.

يسهل نشر شيفرة التطبيق دون توقف: نشغّل النسخة الجديدة، ونحوّل الحركة إليها، ثم نوقف القديمة. أما قاعدة البيانات فتتشاركها النسختان خلال هذا التحويل. وينتج عن ذلك خطران: مخطط (schema) غير متوافق مع إحدى نسختي الشيفرة، وأقفال (locks) تعطّل الاستعلامات طوال مدة تنفيذ ALTER TABLE. يجب على الترحيل دون انقطاع أن يتجنّب الخطرين معاً.

لماذا يجب أن يبقى المخطط متوافقاً

خلال نشر blue-green أو نشر تدريجي، تعمل النسخة القديمة والنسخة الجديدة من الشيفرة في الوقت نفسه، أحياناً لعدة دقائق. إذا أعاد الترحيل تسمية عمود، تنهار النسخة القديمة بمجرد قراءته. وإذا أضاف عموداً NOT NULL دون قيمة افتراضية، تنهار النسخة القديمة بمجرد إدراج سطر. القاعدة الذهبية: يجب أن يكون كل ترحيل متوافقاً مع الشيفرة الموجودة حالياً في الإنتاج ومع الشيفرة التي ستحلّ محلها. ويجب أن يبقى التراجع (rollback) ممكناً في كل لحظة.

القواعد الأساسية

  • لا تحذف أبداً عموداً تستخدمه الشيفرة في الإنتاج
  • أضف الأعمدة الجديدة دائماً بصيغة nullable
  • افصل ترحيلات المخطط عن ترحيلات البيانات
  • اختبر الترحيلات على نسخة من قاعدة بيانات الإنتاج

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

نمط Expand-Contract

يقسّم نمط Expand-Contract (أو التغيير المتوازي parallel change) أي تغيير غير متوافق إلى خطوات متوافقة، موزّعة على عدة عمليات نشر.

المرحلة 1 - Expand (التوسيع): إضافة البنية الجديدة

-- Migration 1: Add the new column
ALTER TABLE users ADD COLUMN email_verified BOOLEAN DEFAULT FALSE;

-- Migration 2: Backfill the data
UPDATE users SET email_verified = TRUE WHERE verified_at IS NOT NULL;

المرحلة 2 - نشر الشيفرة التي تستخدم العمودين

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

المرحلة 3 - Contract (التقليص): حذف البنية القديمة

-- Migration 3: Drop the old column
ALTER TABLE users DROP COLUMN verified_at;

يُنشر هذا الترحيل الأخير في عملية نشر لاحقة، حين لا تعود أي نسخة في الإنتاج تقرأ العمود القديم. واحذفه أيضاً من ربط Doctrine (mapping) قبل حذفه من الجدول، وإلا فسيستمر ORM في تضمينه في استعلاماته.

ينطبق المبدأ نفسه على إعادة تسمية عمود، وهو ما لا نفعله مباشرة أبداً:

  1. إضافة العمود الجديد؛
  2. نشر شيفرة تكتب في العمودين؛
  3. نسخ البيانات الموجودة على دفعات؛
  4. نشر شيفرة تقرأ العمود الجديد؛
  5. نشر شيفرة لم تعد تكتب في العمود القديم؛
  6. حذف العمود القديم.

ملء البيانات على دفعات

أمر UPDATE واحد على جدول كبير يفتح معاملة طويلة، ويقفل عدداً كبيراً من الأسطر، ويزيد تأخّر النسخ المتماثلة (replicas). على MySQL، الذي يقبل LIMIT داخل UPDATE، نعالج الأسطر بدلاً من ذلك على دفعات صغيرة:

$batchSize = 1000;

do {
    $affected = $connection->executeStatement(
        'UPDATE users SET email_verified = TRUE
         WHERE verified_at IS NOT NULL AND email_verified = FALSE
         LIMIT ' . $batchSize
    );

    // Give the database and replicas room to breathe
    usleep(100_000);
} while ($affected > 0);

كل دفعة هي معاملة قصيرة. ويمكن إيقاف السكربت وإعادة تشغيله دون خطر، لأن الشرط email_verified = FALSE يستبعد الأسطر المعالَجة سابقاً. ضع هذه الشيفرة في أمر Symfony بدلاً من ترحيل Doctrine: فقد تعمل مدة طويلة، ويجب أن يكون ممكناً إعادة تشغيلها بمعزل عن المخطط.

ترحيلات Doctrine محسَّنة

public function up(Schema $schema): void
{
    // Use non-blocking operations
    $this->addSql('ALTER TABLE orders ADD COLUMN status VARCHAR(50) DEFAULT NULL');

    // For large tables, use pt-online-schema-change
    // or gh-ost to avoid locks
}

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

ALTER TABLE orders ADD INDEX idx_orders_status (status), ALGORITHM=INPLACE, LOCK=NONE;

على PostgreSQL، يُنشأ الفهرس دون تعطيل عمليات الكتابة بواسطة CREATE INDEX CONCURRENTLY، الذي لا يمكن تنفيذه داخل معاملة. لذا يجب تعطيل المعاملة المحيطة التي يضيفها Doctrine Migrations لهذا الترحيل:

final class Version20241012120000 extends AbstractMigration
{
    public function isTransactional(): bool
    {
        return false;
    }

    public function up(Schema $schema): void
    {
        $this->addSql('SET lock_timeout = \'5s\'');
        $this->addSql('CREATE INDEX CONCURRENTLY idx_orders_status ON orders (status)');
    }
}

يحمي lock_timeout من فخ معروف: فأمر ALTER TABLE، حتى السريع منه، يجب أن ينتظر انتهاء المعاملات الجارية على الجدول، وتتراكم خلفه كل الاستعلامات اللاحقة. مع مهلة قصيرة، يفشل الترحيل بشكل نظيف ويمكن إعادة تشغيله، بدلاً من تجميد التطبيق بأكمله. المكافئ في MySQL هو SET SESSION lock_wait_timeout = 5.

أدوات الترحيل المباشر (online)

حين يحتاج MySQL إلى إعادة بناء الجدول (عند تغيير نوع عمود مثلاً)، تُنشئ أداتا pt-online-schema-change وgh-ost نسخة من الجدول بالمخطط الجديد، وتُبقيانها متزامنة أثناء النسخ، ثم تبدّلان الجدولين في عملية قصيرة جداً:

# Percona Toolkit: test, then execute
pt-online-schema-change --alter "MODIFY status VARCHAR(100) DEFAULT NULL" D=app,t=orders --dry-run
pt-online-schema-change --alter "MODIFY status VARCHAR(100) DEFAULT NULL" D=app,t=orders --execute

# gh-ost: relies on the binlog rather than triggers
gh-ost --host=db.internal --user=app --ask-pass --database=app --table=orders \
  --alter="MODIFY status VARCHAR(100) DEFAULT NULL" --allow-on-master --execute

أدوات موصى بها

  • pt-online-schema-change: ترحيلات بلا أقفال لـ MySQL
  • gh-ost: بديل GitHub للترحيلات المباشرة
  • Doctrine Migrations: إدارة الترحيلات بنظام الإصدارات
  • Flyway: أداة ترحيل متعددة قواعد البيانات

أخطاء شائعة

  • doctrine:schema:update --force في الإنتاج: قد يحذف هذا الأمر أعمدة أو يعيد تسميتها دون سابق إنذار. في الإنتاج، يجب ألا يمسّ المخطط سوى الترحيلات المُدارة بالإصدارات والمُراجَعة.
  • مراجعة SQL المولَّد: ينتج doctrine:migrations:diff أحياناً DROP يليه ADD حيث كنت تتوقع إعادة تسمية.
  • المفاتيح الأجنبية والفهارس على الجداول الكبيرة: قد يستغرق إنشاؤها وقتاً طويلاً. قِس ذلك على نسخة الإنتاج.
  • الدالة down(): لا تعتمد عليها للتراجع في الإنتاج. بفضل Expand-Contract، تكفي العودة إلى النسخة السابقة من الشيفرة، دون المساس بالمخطط.

قائمة التحقق قبل كل ترحيل

  1. هل الترحيل متوافق مع الشيفرة الحالية ومع الشيفرة الجديدة؟
  2. هل قيست مدته على نسخة من قاعدة بيانات الإنتاج؟
  3. هل تُنفَّذ العمليات الثقيلة مباشرة (online) أو على دفعات؟
  4. هل حُدّدت مهلة انتظار للأقفال؟
  5. هل أُجّل حذف البنى القديمة إلى عملية نشر لاحقة؟

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