إدارة إصدارات مخطّط قاعدة بيانات تطبيقات الأعمال ── ممارسات الترحيل (migration) لمنع «اختلاف قاعدة البيانات من عميل لآخر»

· آخر تحديث: · · قواعد البيانات, SQLite, SQL Server, الترحيل, إدارة المخطّط, C#, .NET, الصيانة, جدول القرار, تطوير Windows

«يوجد هذا العمود في قاعدة البيانات المُثبَّتة لدى الشركة A، لكنّه غير موجود في قاعدة بيانات الشركة B. ولم يعد أحد يتذكّر في أيّ إصدار أُضيف هذا العمود» ── عند تسلُّم صيانة تطبيق أعمال مُثبَّت لدى عملاء متعدّدين، من المرجَّح جدّاً أن تصادف هذه الحالة.

يذكر دليل التحديث «نفِّذ SQL التالي على قاعدة البيانات». لكن لا يعرف تنفيذه فعليّاً إلّا الفنّيّ الميدانيّ نفسه، فتختلط الحالات: عملاء نُسي تنفيذ التحديث لديهم، وعملاء تُرِك التحديث عندهم متوقّفاً بعد خطأ في منتصفه، وعملاء افتقدوا عبارات ALTER TABLE الوسيطة بسبب تحديث قفز عدّة إصدارات. وبعد سنوات، يُستنزَف الجهد بلا نهاية في التحقيق في «خطأ يحدث فقط لدى عميل معيّن».

شرحنا في هذه المدوَّنة الشيفرة الأدنى لإرفاق رقم إصدار بالمخطّط في «كيفيّة اختيار موضع حفظ بيانات تطبيق Windows»، وشرحنا تصميم تشغيل SQLite في «استخدام SQLite في تطبيقات C# للأعمال». وهذا المقال امتداد لهما، يتعمَّق في كيفيّة إدارة إصدارات تغييرات مخطّط قاعدة البيانات، وكيفيّة تطبيقها بأمان على العدد الكبير من قواعد البيانات الموزَّعة على العملاء. سنتّخذ SQLite موضوعاً رئيسيّاً، مع ترتيبه كتصميم مشترك مع SQL Server (Express) أيضاً.

1. الخلاصة أوّلاً

  • لا تكن تغييرات المخطّط دليل إجراءات SQL، بل شيفرة (ترحيلات مرقَّمة) مضمَّنة داخل التطبيق نفسه وتُطبَّق تلقائيّاً عند بدء التشغيل. فالاعتماد على تنفيذ الأشخاص للدليل يفشل بمجرّد أن تتوزَّع قاعدة البيانات على العملاء.
  • سجِّل إصدار المخطّط الحاليّ داخل قاعدة البيانات نفسها. في SQLite، PRAGMA user_version هو تحديداً الحقل المخصَّص لهذا الغرض.1 وفي SQL Server، احفظ سجلّ التطبيق في جدول مخصَّص.
  • الترحيل أماميّ فقط وإلحاقيّ فقط (forward-only, append-only). لا تُعِد كتابة SQL الخاصّ برقم إصدار تمّ شحنه سابقاً؛ نفِّذ أيّ تصحيح برقم جديد. بهذا يصبح التحديث القافز من v1.2 إلى v1.5 مجرَّد «تشغيل الأجزاء غير المُطبَّقة بالترتيب».
  • نفِّذ التغييرات الهدّامة (حذف عمود، إعادة تسمية) عبر إصدار من مرحلتين بأسلوب expand-contract. أصدر أوّلاً إصداراً يضيف فقط، ثمّ أصدر إصدار الحذف بعد اختفاء أيّ إشارة إلى الصيغة القديمة.
  • احمِ من حادثة فتح تطبيق بإصدار قديم لقاعدة بيانات أحدث، عبر فحص الحدّ الأدنى للإصدار. المبدأ هو عدم السماح بالكتابة في مخطّط مستقبليّ غير معروف للتطبيق.
  • خذ نسخة احتياطيّة تلقائيّة قبل التطبيق. في SQLite، تكفي جملة VACUUM INTO واحدة للحصول على نسخة متماسكة2، وتصبح الاستعادة عند الفشل مجرَّد استبدال ملفّ.
  • ترحيل واحد = معاملة واحدة، وضمِّن تحديث رقم الإصدار في المعاملة نفسها. يمكن لـ SQLite التراجع عن DDL أيضاً ضمن المعاملة.3 ولأنّ SQL Server يملك DDL استثنائيّاً، افصل العمليّات الاستثنائيّة في ترحيل مستقلّ.4

2. لماذا تحدث مشكلة «اختلاف قاعدة البيانات من عميل لآخر»

عند تفكيك الأسباب، تنتهي جميعها إلى «تشغيل يفترض تدخّلاً بشريّاً».

  • إغفال تطبيق ALTER اليدويّ. لا يبقى في قاعدة البيانات أيّ سجلّ يُثبِت ما إذا نُفِّذ SQL الدليل أم لا، وبما أنّ وسيلة التحقّق الوحيدة هي «المعاينة البصريّة لتعريف الجدول»، فلا بدّ أن يحدث إغفال.
  • ترك الفشل في منتصف التنفيذ دون معالجة. عندما يحدث خطأ في الجملة الثالثة من بين خمس جمل SQL في الدليل، لا يستطيع الفنّيّ أن يقرِّر المتابعة أو التراجع، فتُترَك الحال على أنّ «التطبيق يعمل فليبقَ كما هو». تصبح تلك القاعدة مخطّطاً فريداً في العالم لا يطابق أيّ إصدار.
  • التحديث بقفز الإصدارات. لدى العميل الذي يُثبَّت لديه v1.5 مباشرةً بعد v1.2، يلزم تتبّع تغييرات مخطّط v1.3 وv1.4 معاً بشكل صحيح، وهو أمر صعب مع تشغيل يعتمد على دليل إجراءات.
  • الترقيع الميدانيّ للاستجابة الطارئة. تحدث حالات «أُضيف هذا العمود لهذا العميل وحده مسبقاً»، فيحدث خطأ تطبيق مزدوج عند التحديث الرسميّ لاحقاً.

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

3. النمط الأساسيّ: إصدار المخطّط + الترحيل الأماميّ

هيكل الآليّة يتكوَّن من ثلاثة عناصر فقط.

  1. تحمل قاعدة البيانات نفسها رقم إصدار المخطّط (عدد صحيح مخصَّص للمخطّط، منفصل عن إصدار منتج التطبيق).
  2. تُضاف تغييرات المخطّط إلى شيفرة التطبيق كسلسلة (list) من الترحيلات المرقَّمة.
  3. يُطبِّق التطبيق عند بدء التشغيل (مباشرةً بعد الاتّصال بقاعدة البيانات) الترحيلات ذات الأرقام الأكبر من الإصدار الحاليّ، بالترتيب وضمن معاملات.

في حالة SQLite، يمكن استخدام PRAGMA user_version كموضع لرقم الإصدار. وهو عدد صحيح يُخزَّن في ترويسة قاعدة البيانات (الإزاحة 60)، وتنصّ الوثائق الرسميّة صراحةً على أنّه «مُتاح للتطبيق ليستخدمه بحرّيّة، ولا يستخدم SQLite نفسه هذه القيمة».1 وبذلك يمكن لملفّ قاعدة البيانات وحده أن يُعلِن عن إصداره دون الحاجة إلى إنشاء جدول مخصَّص.

التنفيذ الذاتيّ بلغة C# يصبح عمليّاً بالأسطر العشرات التالية.

using Microsoft.Data.Sqlite;

public static class SchemaMigrator
{
    // قائمة إلحاقيّة فقط. لا تُعِد كتابة SQL الخاصّ برقم تمّ شحنه مطلقاً
    private static readonly (int Version, string Sql)[] Migrations =
    {
        (1, "CREATE TABLE customer (id INTEGER PRIMARY KEY, name TEXT NOT NULL)"),
        (2, "ALTER TABLE customer ADD COLUMN phone TEXT"),
        (3, """
            CREATE TABLE invoice (
                id          INTEGER PRIMARY KEY,
                customer_id INTEGER NOT NULL REFERENCES customer(id),
                issued_at   TEXT    NOT NULL,  -- يُحفَظ بصيغة UTC وصيغة ثابتة
                amount      INTEGER NOT NULL   -- المبلغ عدد صحيح بأصغر وحدة عملة
            )
            """),
    };

    public static void Migrate(SqliteConnection conn)
    {
        // خطأ الإلحاق (تكرار الأرقام أو انعكاس ترتيبها) يؤدّي إلى تطبيق مزدوج صامت أو إغفال التطبيق،
        // لذا يُكتشَف ويُوقَف قبل تطبيق أيّ شيء
        for (int i = 1; i < Migrations.Length; i++)
            if (Migrations[i].Version <= Migrations[i - 1].Version)
                throw new InvalidOperationException(
                    "يجب أن تكون أرقام الترحيل تصاعديّة وفريدة.");

        int current = GetUserVersion(conn);
        int latest = Migrations[^1].Version;

        if (current > latest)
            // حالة فتح تطبيق قديم لقاعدة بيانات أنشأها تطبيق أحدث (القسم 5.2).
            // الأسلم التوقّف هنا دون لمس مخطّط غير معروف
            throw new InvalidOperationException(
                $"تمّ إنشاء قاعدة البيانات هذه (مخطّط v{current}) بواسطة تطبيق أحدث. " +
                "يُرجى تحديث التطبيق.");

        foreach (var (version, sql) in Migrations)
        {
            if (version <= current) continue;

            using var tx = conn.BeginTransaction();
            using var cmd = conn.CreateCommand();
            cmd.Transaction = tx;
            cmd.CommandText = sql;
            cmd.ExecuteNonQuery();

            // تحديث الإصدار أيضاً يُثبَّت ضمن المعاملة نفسها.
            // بهذا تختفي حالة «التغيير طُبِّق لكن الرقم بقي قديماً»
            cmd.CommandText = $"PRAGMA user_version = {version}";
            cmd.ExecuteNonQuery();
            tx.Commit();
        }
    }

    private static int GetUserVersion(SqliteConnection conn)
    {
        using var cmd = conn.CreateCommand();
        cmd.CommandText = "PRAGMA user_version";
        return Convert.ToInt32(cmd.ExecuteScalar());
    }
}

بهذا تُحلّ مشكلة الفصل 2 بنيويّاً. لا يحدث إغفال في التطبيق (يُفحَص عند كلّ بدء تشغيل)، ويتراجع الفشل في منتصف التنفيذ (الفصل 6)، ولا مشكلة في قفز الإصدارات (إن كانت قاعدة بيانات v1.2 على مخطّط v2، فسيُطبِّق تطبيق v1.5 الترحيلات 3 و4 و5 بالترتيب فقط). كما يمكن الإجابة عن «ما حالة قاعدة بيانات هذا العميل؟» بمجرَّد قراءة PRAGMA user_version مرّة واحدة.

يوجد قاعدتان تشغيليّتان فقط يجب الالتزام بهما بصرامة.

  • لا تُعِد كتابة رقم تمّ شحنه. حتّى إن كانت هناك علّة في SQL الخاصّ بـ v3، صحِّحها في v4. إعادة الكتابة تُولِّد تفاوتاً جديداً بين «مَن طبَّق v3 القديم» و«مَن طبَّق v3 الجديد».
  • ضمِّن تحويل البيانات ضمن الترحيل أيضاً. لا تكتفِ بإضافة الأعمدة، بل نفِّذ نقل البيانات القائمة (UPDATE) ضمن نفس الرقم. توحيد صيغة عمود التاريخ/الوقت والمنطقة الزمنيّة على UTC وصيغة ثابتة منذ البداية، كما ورد في «التاريخ والوقت والمنطقة الزمنيّة في تطبيقات الأعمال»، يُبسِّط الترحيلات اللاحقة.

توجد نقطة أخرى، وهي عمل يلزم فقط عند إضافة هذه الآليّة إلى نظام قائم مسبقاً. قد تكون قاعدة البيانات التي كانت تُدار بترقيعات يدويّة في حالة «user_version لا يزال 0، بينما المخطّط الفعليّ متقدِّم جزئيّاً» (وهذا بالضبط ما ذكرناه في الفصل 2 عن الترقيع الطارئ). إن وضعتَها في هذه السلسلة كما هي، سينهار ALTER TABLE الخاصّ بالتغييرات المُطبَّقة سلفاً بخطأ «العمود موجود بالفعل». في إصدار الاعتماد الأوّل، افحص المخطّط الفعليّ كمعالجة أساس (baseline) تُنفَّذ مرّة واحدة فقط (في SQLite تحقَّق من وجود الأعمدة عبر PRAGMA table_info)، واحفر رقم الإصدار المناسب في قواعد البيانات التي تحمل ترقيعات يدويّة معروفة، ثمّ اترك ما بعد ذلك للترحيل الأماميّ. هذه الخطوة يمكن تخطّيها فقط إذا اعتمدتَ هذه الآليّة منذ الإصدار الأوّليّ.

لا يملك SQL Server آليّة مماثلة لِـ user_version، لذا نُدرِج (INSERT) في جدول مخصَّص (مثل schema_version) صفّاً واحداً لكلّ عمليّة تطبيق يحتوي «رقم الإصدار، وتاريخ التطبيق ووقته، وإصدار التطبيق وقت التطبيق». وبما أنّ السجلّ التاريخيّ يبقى كصفوف، يصبح التحقيق اللاحق أقوى.

4. استخدام أداة أم تنفيذ ذاتيّ ── جدول القرار

توجد ثلاث فئات من الوسائل لتحقيق الشيء نفسه: EF Core Migrations، ومكتبات الترحيل (كـ DbUp)، والتنفيذ الذاتيّ من الفصل السابق.

محور المقارنة EF Core Migrations مكتبة ترحيل (DbUp وغيرها) تنفيذ ذاتيّ
وصف التغيير توليد تلقائيّ من تغييرات نموذج C# تسجيل سكربتات SQL كأصول كما هي نصّ SQL أو شيفرة C#
تكلفة التعلّم عالية (يلزم فهم النموذج والأداة والقيود) منخفضة إلى متوسّطة أدنى حدّ (فهم بضعة عشرات من الأسطر فقط)
التوافق مع SQLite △ تغيير الأعمدة وحذفها يتحوَّل إلى إعادة بناء الجدول. يتعذّر توليد سكربتات مثاليّة (idempotent)5 ○ يتمحور حول SQL Server لكنّه يدعم SQLite وغيرها أيضاً6 ◎ يمكن الكتابة المباشرة مع معرفة القيود
أصول SQL الخام القائمة يصعب إعادة استخدامها (يلزم استبدالها بتعريف نموذج) ◎ يمكن نقل SQL الخاصّ بالدليل كما هو تقريباً ◎ الأمر نفسه
إدارة ما تمّ تطبيقه جدول سجلّ (تلقائيّ) جدول يوميّة (journal) (تلقائيّ)6 user_version / جدول ذاتيّ الصنع
التوافق مع شكل التوزيع مضمَّن مع التطبيق، وتُنفَّذ Migrate() عند بدء التشغيل (توجد نقاط تنبّه، لاحقاً) مضمَّن مع التطبيق، ويُنفَّذ عند بدء التشغيل مضمَّن مع التطبيق، ويُنفَّذ عند بدء التشغيل

التوصيات حسب الحالة كالتالي.

الحالة التوصية السبب
الوصول إلى البيانات يتمّ بالفعل عبر EF Core EF Core Migrations يمكن تجنّب الإدارة المزدوجة للنموذج والمخطّط. لا مبرِّر لإضافة أداة أخرى
SQL خام (ADO.NET / Dapper) بشكل أساسيّ + SQLite تنفيذ ذاتيّ يكفي بلا أيّ اعتماديّة خارجيّة. قيود ALTER TABLE في SQLite ستُدرَك ذاتيّاً على أيّ حال
SQL خام بشكل أساسيّ + SQL Server، وتراكم كبير من SQL في دليل الإجراءات مكتبة مثل DbUp يمكن تسجيل SQL القائم كسكربتات، وتُغنيك عن كتابة إدارة تطبيق ذاتيّة
إجراءات مخزَّنة وطرق عرض (views) كثيرة مكتبة مثل DbUp إدارة الكائنات التي يتعذّر توليدها من النموذج بسكربتات SQL أبسط
قاعدة بيانات صغيرة وتكرار تغيير منخفض تنفيذ ذاتيّ يقلِّل تكلفة صيانة الآليّة إلى الحدّ الأدنى

DbUp «مكتبة .NET تساعد على نشر التغييرات في قاعدة بيانات SQL Server»، تُسجِّل السكربتات المُنفَّذة في جدول يوميّة (journal) وتُنفِّذ فقط ما لم يُنفَّذ بعد. تدعم أيضاً SQLite وPostgreSQL وMySQL وغيرها.6 وهي أقصر مسار انتقاليّ لتحويل «SQL الدليل» إلى «تطبيق تلقائيّ مصحوب بسجلّ تنفيذ».

4.1 تنبيهات استخدام EF Core Migrations في تطبيق موزَّع

يكون التطبيق أثناء التطوير في EF Core عبر dotnet ef database update، لكن لا يوجد لدى العميل لا SDK ولا الشيفرة المصدريّة على جهازه. الطريقة الواقعيّة للتطبيق هي context.Database.Migrate() عند بدء تشغيل التطبيق.

ما ينبغي معرفته هنا هو أنّ وثائق Microsoft تُنبِّه صراحةً إلى التطبيق عند بدء التشغيل كوسيلة لإدارة قاعدة بيانات الإنتاج. والأسباب خمسة: (1) الفشل أو التلف الناتج عن التطبيق المتزامن من عدّة نُسخ (قبل EF Core 9)، (2) احتمال حدوث مشكلات خطيرة إن وصل تطبيق آخر إلى قاعدة البيانات أثناء التطبيق، (3) حاجة التطبيق إلى صلاحيّة مرتفعة لتغيير المخطّط، (4) قلّة وسائل التراجع (rollback)، (5) تعذّر مراجعة SQL المُنفَّذ وتصحيحه مسبقاً - والتوصية هي توليد سكربت SQL وتطبيقه ضمن مسار النشر (deployment).7

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

  • التنفيذ المتزامن: تحصل Migrate() تلقائيّاً على قفل ابتداءً من EF Core 9، فتمنع تنفيذ عمليّات ترحيل متزامنة من عدّة عمليّات (processes).7 أمّا في الإصدارات الأقدم فيتمّ التسلسل ذاتيّاً كما في الفصل 6. لكنّ هذا القفل يُسلسِل فقط تنفيذ الترحيلات فيما بينها، ولا يمنع تطبيقاً بإصدار قديم من القراءة والكتابة بشكل عاديّ أثناء التطبيق. في قواعد البيانات المشتركة، يُفترَض دمج فحص الحدّ الأدنى للإصدار (القسم 5.2) مع نوافذ الصيانة.
  • مراجعة SQL مسبقاً: راجع دائماً الترحيلات المُولَّدة، وأجرِ بروفة (rehearsal) قبل الإصدار على قاعدة بيانات تعادل البيانات الحقيقيّة (القسم 6.3).
  • لا تخلط مع EnsureCreated(): يُبنى المخطّط دون سجلّ ترحيل، فتفشل Migrate() لاحقاً. وحِّد الاستخدام على Migrate() منذ البداية.7

في موفِّر SQLite، تُنفَّذ الترحيلات التي تتضمَّن تغيير نوع عمود أو حذفه عبر إعادة بناء الجدول («إنشاء جدول جديد ← نسخ البيانات ← حذف الجدول القديم ← إعادة التسمية»)، ويتعذَّر أيضاً توليد سكربتات مثاليّة (idempotent).5 أمّا قرار استخدام EF Core نفسه من عدمه فمرتَّب في الفصل 8 من «استخدام SQLite في تطبيقات C# للأعمال».

5. كيفيّة كتابة ترحيل لا ينكسر

المبدأ لكلّ ترحيل على حدة هو «لا تُجرِ تغييراً يكسر التوافق مع الإصدارات السابقة ضمن إصدار واحد».

5.1 التغييرات الهدّامة عبر expand-contract (إصدار من مرحلتين)

إضافة عمود آمنة، لكنّ الحذف وإعادة التسمية وتغيير النوع تكسر «شيئاً يفترض الصيغة القديمة». حتّى مع SQLite محلّيّ بعلاقة واحد-لواحد بين التطبيق وقاعدة البيانات، يوجد غالباً أحد الاحتمالات التالية: (أ) إمكانيّة إرجاع التطبيق إلى إصدار قديم عند حدوث عطل، (ب) أداة أخرى تقرأ قاعدة البيانات مباشرةً (أداة تقارير، مُصدِّر CSV، تكامل مع Access)، (ج) بنية يشترك فيها عملاء جدد وقدامى في مراجعة SQL Server في آنٍ واحد. لذا نقسِّم التغييرات الهدّامة إلى مرحلتين: expand (توسيع) ثمّ contract (تضييق).

نوع التغيير ما يحدث إن نُفِّذ دفعةً واحدة المرحلتان الآمنتان
إعادة تسمية عمود يتعطَّل فوراً التطبيق القديم أو التقارير التي تشير إلى الاسم القديم expand: أضِف عموداً جديداً وانسخ القيمة من العمود القديم. يكتب التطبيق الجديد إلى كليهما، مع اعتبار العمود القديم مرجعاً للقراءة (لأنّ التطبيق القديم، في قاعدة بيانات مشتركة يعمل فيها القديم والجديد معاً، يكتب في العمود القديم فقط؛ يمكن أيضاً المزامنة عبر مُشغِّل - trigger - على مستوى قاعدة البيانات) ← contract: بعد إقصاء التطبيق القديم، انسخ نسخاً نهائيّاً لأحدث قيم العمود القديم إلى الجديد، ثمّ حوِّل القراءة إلى العمود الجديد، واحذف العمود القديم (التحويل قبل الإقصاء يُفقِد التحديثات التي كتبها التطبيق القديم في العمود القديم فقط)
حذف عمود تفشل عمليّات INSERT/SELECT الخاصّة بالتطبيق القديم expand: يتوقّف التطبيق عن الإشارة إليه فقط (يبقى العمود) ← contract: يُحذَف بعد عدّة إصدارات
تغيير النوع أو المعنى (مثال: الوقت المحلّيّ ← UTC) تختلط القيم الجديدة والقديمة في عمود واحد فينكسر بصمت expand: أضِف عموداً جديداً وضَع فيه القيم المُحوَّلة بالفعل. يُعامَل التعايش كما في إعادة التسمية (يكتب التطبيق الجديد إلى كليهما، والقراءة مرجعها العمود القديم) ← contract: بعد إقصاء التطبيق القديم، نفِّذ التحويل النهائيّ من العمود القديم، ثمّ حوِّل القراءة، واحذف العمود القديم
إضافة قيد NOT NULL يفشل التطبيق مع الصفوف ذات NULL القائمة. كتابة NULL من التطبيق القديم تتعطَّل فوراً بمخالفة القيد expand: جهِّز قيمة افتراضيّة، وحدِّث كلّ العملاء إلى إصدار يكتب قيماً غير فارغة (non-NULL) ← contract: بعد إقصاء التطبيق القديم، املأ ما تبقّى من NULL عبر UPDATE، ثمّ أضِف القيد

الأسلم هو إصدار جانب contract (التضييق) بعد أن يصبح بالإمكان إقصاء التطبيق القديم عبر فحص الحدّ الأدنى للإصدار (القسم التالي).

من خصوصيّات SQLite أنّ ALTER TABLE يدعم فقط إعادة تسمية الجدول، وإعادة تسمية عمود، وإضافة عمود، وحذف عمود، وحتّى حذف العمود يخضع لقيود كثيرة («لا يجوز على عمود بمفتاح أساسيّ PRIMARY KEY أو قيد UNIQUE، ولا على عمود مُشار إليه من فهرس أو قيد CHECK أو مفتاح خارجيّ أو طريقة عرض - view»). أمّا التغييرات الأخرى فتُنفَّذ وفق الإجراء الذي تحدِّده الوثائق الرسميّة: «أنشئ جدولاً جديداً ضمن معاملة، وانقل البيانات عبر INSERT INTO new_X SELECT ... FROM X، ثمّ احذف الجدول القديم وأعِد التسمية».3 وبما أنّ هذا يصبح نسخاً لكامل السجلّات في الجداول الكبيرة، فتوقَّع وقت التطبيق ومساحة القرص الحرّة اللازمَين.

5.2 الحماية من التخفيض (downgrade) ── فحص الحدّ الأدنى للإصدار

في تصميم يعتمد على الترحيل الأماميّ فقط، لا نكتب سكربتات باتّجاه التراجع (فلا فرصة لاستخدامها لدى العملاء، والشيفرة غير المُختبَرة خطر لا أكثر). المطلوب بدلاً من ذلك آليّة تتوقَّف عندما يفتح تطبيق بإصدار قديم قاعدة بيانات أحدث. وهذا بالضبط ما تفعله بداية شيفرة الفصل 3: إن كان user_version أكبر من أقصى قيمة يعرفها التطبيق، يُرمى استثناء ويتوقّف بدء التشغيل.

إذا قرَّرتَ سلفاً «عدم إدخال تغييرات هدّامة في إصدار يحتمل التراجع عنه (اجعله expand فقط)»، فإنّ قراءة قاعدة بيانات جديدة بتطبيق قديم تصبح آمنة بحدّ ذاتها، ويمكن حينها اختيار تصميم أخفّ وطأةً مثل «تحذير وبدء التشغيل في وضع القراءة فقط». يُحسَم الاختيار بينهما وفق مدى القدرة على إيقاف العمل.

5.3 النسخ الاحتياطيّ التلقائيّ قبل التطبيق

الترحيل عمليّة جراحيّة على «بيانات إنتاج موجودة على جهاز شخص آخر». لذا نُؤتمِت مبدأ «خذ نسخة احتياطيّة ثمّ نفِّذ». في SQLite، تُعدّ VACUUM INTO الأمثل، إذ يمكنها إنشاء لقطة (snapshot) متماسكة في ملفّ منفصل بجملة واحدة، حتّى من قاعدة بيانات قيد التشغيل.2

// خذ نسخة احتياطيّة من جيل واحد مباشرةً قبل التطبيق، فقط عند الحاجة إلى التطبيق
if (GetUserVersion(conn) < latest)
{
    Directory.CreateDirectory(backupDir);
    var backupPath = Path.Combine(backupDir,
        $"app_schema_v{GetUserVersion(conn)}_{DateTime.Now:yyyyMMdd_HHmmss}.db");
    // لتجنّب ظهور ملفّ غير مكتمل ناتج عن انقطاع الكهرباء أو إنهاء العمليّة قسريّاً في منتصف التنفيذ
    // وكأنّه «نسخة احتياطيّة مكتملة»، يُنشَأ باسم مؤقّت ثمّ يُعاد تسميته بعد النجاح
    var tempPath = backupPath + ".tmp";
    using var cmd = conn.CreateCommand();
    cmd.CommandText = "VACUUM INTO $path";
    cmd.Parameters.AddWithValue("$path", tempPath);
    cmd.ExecuteNonQuery();  // يُنفَّذ VACUUM خارج المعاملة
    File.Move(tempPath, backupPath);
    // إن بقي ملفّ *.tmp عند بدء التشغيل، فهو أثر فشل سابق، فاحذفه
}

بوضع رقم إصدار المخطّط في اسم الملفّ، يمكن معرفة «إلى أيّ حدّ نتراجع» بنظرة واحدة عند الاستعادة. راجع الفصل 7 من «استخدام SQLite في تطبيقات C# للأعمال» لتفاصيل النسخ الاحتياطيّ، ومنها كون نسخ الملفّ المباشر لقاعدة بيانات قيد التشغيل مصدراً للتلف. في SQL Server، نفِّذ BACKUP DATABASE قبل التطبيق، والفكرة نفسها.

6. مطبّات التشغيل

6.1 الفشل في منتصف التنفيذ والمعاملات ── اعرف الفروق بين أنظمة إدارة قواعد البيانات

تُحيط شيفرة الفصل 3 كلّ ترحيل بمعاملة واحدة، وتُضمِّن تحديث user_version في المعاملة نفسها. وهذا ممكن لأنّ SQLite يستطيع تنفيذ DDL (مثل CREATE TABLE وALTER TABLE) ضمن معاملة، والتراجع عنه عند الفشل. والإجراء الرسميّ لإعادة بناء الجدول نفسه مبنيّ على «بدء معاملة، وتنفيذ CREATE/INSERT/DROP/RENAME، ثمّ الالتزام (commit)».3 وحتّى إن انقطعت الكهرباء في منتصف التنفيذ، تكون قاعدة البيانات عند بدء التشغيل التالي في حالة متماسكة «قبل تلك الترحيلة مباشرةً».

يستطيع SQL Server أيضاً تنفيذ الكثير من DDL ضمن معاملة، لكن توجد استثناءات. فمثلاً لا يمكن استخدام ALTER DATABASE ضمن معاملة صريحة، ولا يمكن وضع CREATE FULLTEXT INDEX ضمن معاملة مستخدِم أيضاً.4 كما أنّ EF Core، رغم إحاطته تلقائيّاً كلّ ترحيل بمعاملة كلّما أمكن، ينصّ صراحةً على أنّ «بعض العمليّات يتعذَّر تنفيذها ضمن معاملة حسب قاعدة البيانات».8 والقاعدة العمليّة تنحصر في: لا تخلط عمليّة لا تدخل في معاملة مع تغيير مخطّط عاديّ ضمن نفس الترحيل. وعند التبديل بين أنظمة إدارة قواعد البيانات، تحقَّق دائماً «هل يشارك DDL في المعاملة؟».

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

6.2 بدء تشغيل متزامن لعدّة عمليّات ── تسلسل التطبيق

تطبيقات الأعمال برمجيّات «يشغِّلها الجميع دفعة واحدة في الصباح». يمكن أن تُشغِّل عدّة عملاء يراجعون قاعدة بيانات مشتركة (SQL Server)، أو تشغيلات متعدّدة على نفس الجهاز، ترحيلات متزامنة في آنٍ واحد.

  • ابتداءً من EF Core 9، تحصل Migrate() تلقائيّاً على قفل لكامل قاعدة البيانات، فتمنع التطبيق المتزامن (لا توجد هذه الحماية قبل ذلك). كما أنّ قفل موفِّر SQLite يُنفَّذ عبر جدول قفل مخصَّص، وتُشير الوثائق الرسميّة إلى احتمال بقاء الجدول إن أُنهيت العمليّة المُنفِّذة بشكل غير طبيعيّ أثناء التطبيق.7 إن توقّف بدء التشغيل معلَّقاً في انتظار القفل، يمكن الاستعادة - بعد التأكّد من عدم وجود عمليّة أخرى تُنفِّذ ترحيلاً - بحذف (DROP) جدول القفل المتبقّي (__EFMigrationsLock).
  • في التنفيذ الذاتيّ، مع قاعدة بيانات محلّيّة، يسهل التسلسل عبر Mutex مُسمّى.
// أضِف البادئة Global\ حتّى يتحقّق التسلسل على مستوى الجهاز كلّه حتّى لو شُغِّل
// من عدّة جلسات تسجيل دخول عبر RDP أو تبديل المستخدم (Local\ يقتصر على نفس الجلسة فقط)
using var mutex = new Mutex(false, @"Global\MyApp.SchemaMigration");
try
{
    mutex.WaitOne();
}
catch (AbandonedMutexException)
{
    // حالة إنهاء العمليّة المالكة السابقة بشكل غير طبيعيّ دون تحرير (Release).
    // حتّى مع ظهور الاستثناء، المِلكيّة نفسها تُكتسَب فعليّاً، فيمكن المتابعة كما هي.
    // احتماليّة انتهاء التطبيق منتصف التنفيذ يُتعامَل معها لاحقاً عبر إعادة التحقّق
    // من الإصدار والمعاملة الخاصّة بكلّ ترحيل
}
try
{
    SchemaMigrator.Migrate(conn);
}
finally
{
    mutex.ReleaseMutex();
}

بما أنّ الطرف الذي انتظر يتحقَّق من الإصدار مرّة أخرى بعد الحصول على القفل (شيفرة الفصل 3 تفحص version <= current في كلّ مرّة قبل التطبيق)، فلا يحدث تطبيق مزدوج. كما أنّ الكائنات المُسمّاة تحت Global\ تحمل افتراضيّاً قائمة تحكّم وصول (ACL) مشتقّة من المستخدم الذي أنشأها، لذا قد يؤدّي فتح نفس Mutex من جلسة (session) حساب Windows آخر إلى UnauthorizedAccessException. إن كان الاستخدام من عدّة حسابات مفترضاً، فأنشئ الـ Mutex عبر MutexAcl من System.Threading.AccessControl مانحاً حقوق المزامنة والتعديل للمستخدمين المعنيّين، أو التجأ إلى قفل جانب قاعدة البيانات المذكور لاحقاً. وبما أنّ Mutex لا يتجاوز حدود الجهاز، فإنّ قاعدة البيانات المشتركة تحتاج إلى تسلسل جانب قاعدة البيانات، مثل «إنهاء التطبيق على جانب الخادم قبل توزيع التحديث» أو «الحصول على قفل جانب قاعدة البيانات عند بدء التطبيق (BEGIN IMMEDIATE في SQLite، أو قفل تطبيقيّ - application lock - في SQL Server)».

6.3 البروفة (rehearsal) ── اختبار التطبيق الدفعيّ بدءاً من «أقدم قاعدة بيانات»

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

  • احفظ ملفّ قاعدة بيانات لكلّ إصدار مخطّط كـ fixture اختباريّة، وأتمِتْ اختباراً يُطبِّق دفعةً واحدة من كلّ منها حتّى أحدث إصدار. أنماط القفز مثل «من v1 إلى v5» أو «من v3 إلى v5» هي الواقع الفعليّ لدى العملاء. في SQLite، يكفي وضع ملفّات قواعد البيانات في المستودع، فهذا النوع من الاختبار سهل الكتابة.
  • اختبر بكمّيّة ونوعيّة تعادل البيانات الحقيقيّة. لا تظهر مشكلات مثل الأعمدة المليئة بـ NULL، أو التكرار غير المتوقَّع، أو وقت إعادة البناء في الجداول الضخمة (القسم 5.1) إلّا إن كانت البيانات قريبة من الواقع. إن أمكن، أجرِ البروفة على قاعدة بيانات عميل مُخفاة الهويّة (anonymized).
  • اختبر مسارات الفشل. أنهِ العمليّة قسريّاً في منتصف التطبيق، وتحقَّق من العودة الصحيحة عند بدء التشغيل التالي (إعادة التطبيق من الإصدار الذي تراجع إليه).

7. الخلاصة

  • «اختلاف قاعدة البيانات من عميل لآخر» ليس مسألة انتباه الفنّيّ، بل نتيجة بنيويّة لـتشغيل يعتمد على تنفيذ الأشخاص لدليل إجراءات SQL. في تطبيقات سطح المكتب للأعمال التي تتوزَّع فيها قاعدة البيانات، لا خيار سوى جعل التطبيق نفسه يُحدِّث قاعدة بياناته.
  • الهيكل هو رقم إصدار المخطّط الذي تحمله قاعدة البيانات نفسها (PRAGMA user_version في SQLite1)، وتطبيق الترحيلات الأماميّة المرقَّمة عند بدء التشغيل. يمكن تحقيق ذلك بلغة C# عبر تنفيذ ذاتيّ من بضعة عشرات من الأسطر.
  • الوسائل ثلاث فئات: EF Core Migrations، ومكتبات مثل DbUp، والتنفيذ الذاتيّ. اختر وفق ما إذا كنتَ تستخدم EF Core بالفعل، أو حجم أصول SQL الخام لديك (جدول القرار في الفصل 4). بما أنّ Migrate() عند بدء التشغيل في EF Core مُدرَجة رسميّاً بنقاط تنبّه7، استخدمها مصحوبة بإجراءات مواجهة التنفيذ المتزامن والبروفة.
  • نفِّذ التغييرات الهدّامة عبر إصدار من مرحلتين بأسلوب expand-contract، وأوقِف حادثة فتح تطبيق قديم لقاعدة بيانات جديدة عبر فحص الحدّ الأدنى للإصدار. اتَّبع الوثائق الرسميّة بخصوص قيود ALTER TABLE في SQLite وإجراء إعادة البناء.3
  • المبدأ هو ترحيل واحد = معاملة واحدة، وتحديث الإصدار ضمن المعاملة نفسها. ولأنّ SQL Server يملك DDL لا يدخل في معاملة4، افصل العمليّات الاستثنائيّة. ولا يصبح الترحيل «جاهزاً للإرسال إلى العملاء» إلّا بإضافة النسخ الاحتياطيّ عبر VACUUM INTO قبل التطبيق2، والبروفة الدفعيّة بدءاً من أقدم إصدار.

إن كانت لديك ذكرى لتشغيل يعتمد على ALTER بدليل إجراءات، جرِّب في الإصدار القادم إدخال «تسجيل رقم الإصدار» و«التطبيق عند بدء التشغيل» فقط. فبمجرَّد وجود الأساس، يمكن إضافة الإصدار من مرحلتين والنسخ الاحتياطيّ تدريجيّاً لاحقاً.

مقالات ذات صلة

مجالات الاستشارة ذات الصلة

تتعامل شركة Komura Soft LLC مع تصميم قاعدة بيانات تطبيقات الأعمال المُثبَّتة لدى عملاء متعدّدين وإدخال بنية الترحيل، وتحقيق المخطّطات المتفاوتة الناتجة عن تشغيل دليل الإجراءات وتطبيعها، وتصميم توزيع التحديثات لكلٍّ من بنية EF Core وبنية SQL الخام.

المراجع

  1. SQLite، Pragma statements supported by SQLite - user_version. حول كون user_version عدداً صحيحاً يُخزَّن في ترويسة قاعدة البيانات (الإزاحة 60)، أُعِدَّ ليستخدمه التطبيق بحرّيّة، ولا يستخدم SQLite نفسه هذه القيمة.  2 3

  2. SQLite، VACUUM. حول قدرة VACUUM INTO على إنشاء لقطة متماسكة لقاعدة بيانات قيد التشغيل في ملفّ منفصل دون تعديل الأصل، واستخدامها بديلاً عن Backup API.  2 3

  3. SQLite، ALTER TABLE. حول اقتصار ALTER TABLE في SQLite على إعادة تسمية الجدول، وإعادة تسمية عمود، وإضافة عمود، وحذف عمود، ووجود قيود كثيرة على حذف الأعمدة، وتنفيذ تغييرات المخطّط الأخرى وفق الإجراء الرسميّ (إنشاء جدول جديد ← نسخ البيانات ← حذف الجدول القديم ← إعادة التسمية) ضمن معاملة.  2 3 4

  4. Microsoft Learn، ALTER DATABASE (Transact-SQL) وَCREATE FULLTEXT INDEX (Transact-SQL). حول وجوب تنفيذ ALTER DATABASE في وضع الالتزام التلقائيّ (autocommit) وعدم السماح به ضمن معاملة صريحة أو ضمنيّة، وتعذّر وضع CREATE FULLTEXT INDEX ضمن معاملة مستخدِم.  2 3

  5. Microsoft Learn، SQLite EF Core Database Provider Limitations. حول تنفيذ الكثير من عمليّات الترحيل في موفِّر SQLite عبر إعادة بناء الجدول، وتعذّر توليد سكربتات مثاليّة (idempotent).  2

  6. DbUp، DbUp Documentation وَSupported Databases. حول كونها مكتبة .NET تساعد على نشر التغييرات في قاعدة بيانات SQL Server، وتسجيلها سكربتات SQL المُنفَّذة وتنفيذ ما لم يُنفَّذ بعد فقط، ودعمها أيضاً SQLite وPostgreSQL وMySQL وغيرها.  2 3

  7. Microsoft Learn، Applying Migrations (EF Core). حول الأسباب الخمسة التي تجعل التطبيق وقت التشغيل (عند بدء التشغيل) غير مناسب لإدارة قاعدة بيانات الإنتاج، والتوصية بتوليد سكربت SQL، ووجوب عدم الجمع بين EnsureCreated() وMigrate()، وحصول Migrate() في EF Core 9 فصاعداً تلقائيّاً على قفل لكامل قاعدة البيانات، وتنفيذ قفل موفِّر SQLite عبر جدول قد يبقى عند إنهاء غير طبيعيّ.  2 3 4 5

  8. Microsoft Learn، Managing Migrations (EF Core). حول إحاطة EF Core تلقائيّاً كلّ ترحيل بمعاملة كلّما أمكن عند التطبيق، وتعذّر تنفيذ بعض العمليّات ضمن معاملة حسب قاعدة البيانات. 

أحدث المقالات التي تشترك في نفس الوسوم. عمّق فهمك بمواضيع مرتبطة.

ترتبط هذه المقالة بشكل طبيعي بصفحات الخدمات التالية.

الأسئلة الشائعة

أسئلة شائعة حول موضوع هذه المقالة.

كيف ينبغي إدارة تغييرات مخطّط قاعدة بيانات تطبيقات الأعمال؟
بدلاً من الاعتماد على تنفيذ الأشخاص لدليل إجراءات SQL يدويّاً، ينبغي تضمين ترحيلات (migrations) مرقَّمة - أي شيفرة تغييرات المخطّط - داخل التطبيق نفسه، وتطبيقها تلقائيّاً عند بدء التشغيل. تُسجَّل قاعدة البيانات نفسها إصدار مخطّطها الحاليّ (عبر PRAGMA user_version في SQLite، أو جدول مخصَّص في SQL Server)، ويطبِّق التطبيق فقط الأرقام غير المُطبَّقة بالترتيب ضمن معاملة (transaction). بهذا الشكل، حتّى عند تحديث يقفز من الإصدار v1.2 إلى v1.5، تُطبَّق جميع تغييرات المخطّط الوسيطة، ولا تحدث بنيويّاً حالة «اختلاف شكل قاعدة البيانات من عميل لآخر».
هل يجوز استدعاء Migrate() الخاصّة بـ EF Core عند بدء تشغيل التطبيق؟
خيار واقعيّ بشروط. تُنبِّه وثائق Microsoft إلى التطبيق عند بدء التشغيل في بيئة الإنتاج، لأسباب منها التطبيق المتزامن من عدّة نُسخ (instances)، ومنح التطبيق صلاحيّة تغيير المخطّط، وتعذّر مراجعة SQL مسبقاً، وتُوصي بالتطبيق عبر توليد سكربت SQL في تطبيقات الخادم. أمّا في تطبيقات سطح المكتب للأعمال التي تملك قاعدة بيانات محلّيّة لكلّ جهاز عميل، فإنّ تشغيل السكربت ميدانيّاً غير قابل للتطبيق عمليّاً، فتصبح Migrate() عند بدء التشغيل الحلّ المعياريّ فعليّاً. حتّى في هذه الحالة، اجمع دائماً بين إجراء مواجهة التشغيل المتزامن (القفل التلقائيّ من EF Core 9 فصاعداً، أو Mutex ذاتيّ) والنسخ الاحتياطيّ قبل التطبيق.
ماذا يحدث لقاعدة البيانات إن فشل الترحيل في منتصف تنفيذه؟
إن أحطتَ كلّ ترحيل بمعاملة واحدة، وضمَّنتَ تحديث رقم الإصدار في المعاملة نفسها، فسيتراجع (rollback) عند الفشل إلى الحالة السابقة لبدء ذلك الترحيل، ولن يتبقّى مخطّط منتصف التنفيذ. يمكن لـ SQLite تنفيذ لغة تعريف البيانات (DDL) مثل CREATE TABLE وALTER TABLE ضمن معاملة أيضاً، والإجراء الرسميّ لإعادة بناء الجدول نفسه مكتوب على افتراض وجود معاملة. يستطيع SQL Server أيضاً تنفيذ الكثير من DDL ضمن معاملة، لكن توجد استثناءات مثل ALTER DATABASE والفهرسة النصّيّة الكاملة (full-text index)، لذا افصل العمليّات الاستثنائيّة في ترحيل مستقلّ بذاته. كما أنّ وجود نسخة احتياطيّة تلقائيّة قبل التطبيق يجعل الاستعادة ممكنة حتّى في أسوأ الحالات، بمجرّد استبدال الملفّ.
إذا كان مخطّط قاعدة البيانات مختلفاً بالفعل من عميل لآخر، كيف نُطبِّعه؟
حدِّد أوّلاً «المخطّط المرجعيّ الصحيح» الواحد، ثمّ افحص قاعدة بيانات كلّ عميل واستخرج الفروق عن الحالة الراهنة. بعد ذلك، اكتب لقاعدة البيانات التي لا تحمل رقم إصدار ترحيلاً أوّليّاً يكتشف الأنماط الفعليّة الموجودة ويوحِّدها إلى الصيغة القياسيّة، وسجِّل رقم الإصدار عند اكتمال ذلك الترحيل. في SQLite يمكن تحديد وجود عمود من عدمه آليّاً عبر sqlite_master أو PRAGMA table_info، ويمكن استيعاب الاختلاف بشيفرة SQL دفاعيّة على شكل «أضِف العمود إن لم يكن موجوداً». إن وضعتَ كلّ التغييرات اللاحقة ضمن ترحيلات مرقَّمة، فلن يتكرّر التفاوت.

الملف الشخصي للمؤلف

صفحة الملف الشخصي لمؤلف المقالة.

غو كومورا

مؤسّس شركة كومورا سوفت ذ.م.م.

يركّز على تطوير برامج ويندوز، والاستشارات التقنية، والتحقيق في الأخطاء، ويتميّز في المشاريع التي تبقى فيها الأصول القديمة ناشطة، وفي تشخيص الأعطال التي يصعب تحديد سببها.

روابط عامة

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