استخدام SQLite في تطبيقات C# للأعمال ── وضع WAL، والتحكّم الحصريّ، والوقاية من التلف، والتمييز عن EF Core

· آخر تحديث: · · SQLite, C#, .NET, Microsoft.Data.Sqlite, EF Core, تخزين البيانات, Windows, التشغيل, الاستشارات التقنية

في المقال السابق «كيفيّة اختيار موضع حفظ بيانات تطبيق Windows» كتبنا أنّ SQLite هو الخيار الأوّل لحفظ بيانات الأعمال والسجلّات المتزايدة باستمرار. هذا القرار محسوم، لكن عند الدخول فعليّاً في مرحلة الدمج تظهر حيرة من نوع آخر. ما نسمعه كثيراً في الاستشارات هو: «عند البحث عن SQLite في NuGet تظهر عدّة حزم، ولا أدري أيّها أُثبِّت»، أو «يعمل التطبيق لكن يظهر أحياناً database is locked، وأتجاوزه بإعادة المحاولة، فهل هذا صحيح؟»، أو «هل تكفي بالنسخ الاحتياطيّ نسخ ملفّ قاعدة البيانات فقط؟».

كلّ هذه نقاط لا بدّ من المرور بها إن كنتَ ستُشغِّل SQLite لسنوات ضمن تطبيق أعمال، ومن النوع الذي لا يُتعبك لاحقاً إن ضبطتَه في التصميم الأوّليّ. في هذا المقال، وبافتراض استخدام Microsoft.Data.Sqlite، نرتّب دفعة واحدة العناصر التي نراجعها في كلّ مرّة أثناء مراجعة التصميم: اختيار المكتبة، وسلسلة الاتّصال (connection string) والتجميع (pooling)، وآليّة وضع WAL، وكيفيّة التعامل مع SQLITE_BUSY، ومطبّات تخطيط الأنواع، والوقاية من التلف والنسخ الاحتياطيّ، وحتّى التمييز عن EF Core.

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

  • المكتبة الأساسيّة للتطوير الجديد هي Microsoft.Data.Sqlite (أو موفِّر SQLite الخاصّ بـ EF Core المبنيّ فوقها). فهي مختلفة تماماً عن System.Data.SQLite من ناحية سلسلة الاتّصال وتفاصيل السلوك، لذا انتبه دائماً إلى أيّهما يفترضه المثال الذي تجده على الويب.1
  • فعِّل وضع WAL منذ أوّل إصدار. فهو يرفع تزامن القراءة والكتابة، ويُزيل معظم حالات database is locked. يُحفَظ هذا الإعداد داخل ملفّ قاعدة البيانات نفسه، لكنّه لا يعمل على مشاركة شبكيّة (network share).2
  • database is locked (أي SQLITE_BUSY) ليست مشكلة «تُضاف لها معالجة أخطاء»، بل إشارة لإعادة النظر في التصميم. تُعيد Microsoft.Data.Sqlite المحاولة تلقائيّاً حتّى بلوغ المهلة الزمنيّة (الافتراضيّة 30 ثانية)3، لكنّ الحلّ الجذريّ هو توحيد مسار الكتابة في نقطة واحدة.
  • عند تنفيذ عدد كبير من عمليّات INSERT الصغيرة، يكفي تجميعها ضمن معاملة (transaction) صريحة ليرتفع الأداء بمقدار رتبتين أو ثلاث رُتَب. إن شعرتَ أنّ SQLite بطيء، فابدأ بالشكّ في وحدة الالتزام (commit).
  • أنواع SQLite الفعليّة أربعة فقط: INTEGER وREAL وTEXT وBLOB، وتُحفَظ DateTime وGuid وdecimal كـ TEXT. بما أنّ decimal تحديداً تتبع قواعد المقارنة والفرز الخاصّة بالنصوص، فمن الأسلم حفظ المبالغ الماليّة كعدد صحيح بأصغر وحدة عملة.4
  • يُمنَع أخذ النسخة الاحتياطيّة عبر نسخ الملفّ مباشرةً أثناء التشغيل. استخدم VACUUM INTO أو Backup API (عبر SqliteConnection.BackupDatabase). كما يُمنَع تماماً حذف ملفّي -wal / -shm يدويّاً.5
  • لا يدعم SQLite الخام التشفير. لا يعمل Password في سلسلة الاتّصال إلّا عند تضمين مكتبة أصليّة (native) من عائلة SQLCipher6، وإن كانت المعلومات السرّيّة قليلة الحجم، ففضِّل النظر أوّلاً في الحماية عبر DPAPI قبل تشفير قاعدة البيانات كاملة.

2. اختيار المكتبة ── أسماء متشابهة بمحتوى مختلف

توجد عدّة حزم لاستخدام SQLite من .NET، وأوّل عقبة تُواجَه هي تشابه الأسماء المُربِك. إليك ترتيباً لها.

الحزمة الوضع معيار الاعتماد في مشروع جديد
Microsoft.Data.Sqlite موفِّر ADO.NET تُصونه Microsoft. خفيف الوزن، ويُضمَّن معه ملفّ SQLite الأصليّ نفسه في NuGet ◎ الخيار الأوّل
Microsoft.EntityFrameworkCore.Sqlite موفِّر SQLite لِـ EF Core. يستخدم Microsoft.Data.Sqlite داخليّاً ◎ للتطبيقات القائمة على الكيانات (entities) (الفصل 8)
System.Data.SQLite موفِّر عريق من سلسلة فريق تطوير SQLite. له سجلّ حافل من عصر .NET Framework △ لصيانة الأصول القائمة فقط
Dapper مُخطِّط (mapper) خفيف فوق ADO.NET، ليس مخصَّصاً لـ SQLite وحده ○ عندما تريد تقليل شيفرة تعبئة ADO.NET الخام

تتولّى فريق EF Core صيانة Microsoft.Data.Sqlite، وهي أيضاً الأساس الذي يقوم عليه موفِّر SQLite الخاصّ بـ EF Core.1 بما أنّ حزمة NuGet تتضمَّن الملفّ الثنائيّ الأصليّ (SQLite نفسه)، فلا حاجة إلى عمليّة توزيع منفصلة على أجهزة العملاء، ويتكفَّل NuGet نفسه بمعالجة الفروق بين x86/x64/ARM64.

ما يجب الانتباه إليه هو أنّ المعلومات الموجَّهة إلى System.Data.SQLite لا تصلح للاستخدام كما هي. فبحكم طول عمر استخدامها، توجد كمّية كبيرة من الأمثلة على الويب مبنيّة على افتراض System.Data.SQLite، وتقع فيها أوجه عدم التوافق التالية.

  • سلاسل الاتّصال غير متوافقة. كلمات مفتاحيّة مثل Version=3 وَUseUTF16Encoding وَDateTimeFormat (التي تغيِّر صيغة التاريخ) غير موجودة في Microsoft.Data.Sqlite، وتحديدها يؤدّي إلى استثناء. الكلمات المفتاحيّة المتاحة قليلة العدد: Data Source / Mode / Cache / Password / Foreign Keys / Default Timeout / Pooling وما شابه.6
  • تختلف معاملة الأنواع. فمثلاً يُحفَظ Guid كـ BLOB بشكل افتراضيّ في System.Data.SQLite، بينما يُحفَظ كـ TEXT في Microsoft.Data.Sqlite. في فترة الانتقال التي تقرأ وتكتب فيها كلتا المكتبتين نفس ملفّ قاعدة البيانات، يظهر هذا الفرق كتضارب في البيانات.

صُمِّمت Microsoft.Data.Sqlite عمداً لتكون خفيفة، فلا تملك تحويلات أنواع خاصّة بها أو ميزات مساعدة إضافيّة، وهذا يعني أنّ ما تذكره وثائق SQLite الرسميّة ينطبق عليها كما هو. إن كان تطبيقك القائم يعمل باستقرار على System.Data.SQLite فلا داعي لإجباره على الترحيل، لكنّ المبدأ هو توجيه الشيفرة الجديدة نحو Microsoft.Data.Sqlite، وعند الترحيل، حصر أوجه عدم التوافق أعلاه كبنود ترحيل أوّلاً.

3. الاتّصال وسلسلة الاتّصال ── التجميع (pooling) يُبقي الملفّ ممسوكاً

الشكل الأساسيّ لسلسلة الاتّصال هو مسار الملفّ فقط. أمّا ما يُنتبَه إليه في العمل الفعليّ فهو Mode وَPooling.6

using Microsoft.Data.Sqlite;

var builder = new SqliteConnectionStringBuilder
{
    DataSource = dbPath,
    Mode = SqliteOpenMode.ReadWriteCreate  // القيمة الافتراضيّة. يُنشئ الملفّ إن لم يكن موجوداً
};
using var conn = new SqliteConnection(builder.ConnectionString);
conn.Open();
  • Mode: القيمة الافتراضيّة هي ReadWriteCreate (يُنشئ الملفّ إن لم يكن موجوداً). في حالات مثل توزيع قاعدة بيانات رئيسيّة (master) للقراءة فقط، يمنع تحديد ReadOnly الكتابة الناتجة عن خطأ برمجيّ أو تشغيل خاطئ.
  • Cache: اتركه على القيمة الافتراضيّة عادةً. تنصّ الوثائق صراحةً على عدم استحسان الجمع بين Cache=Shared ووضع WAL، لذا لا مجال له ضمن السياسة المتّبَعة في هذا المقال التي تعتمد WAL.6
  • Password: عند تحديدها، تُرسَل PRAGMA key مباشرةً بعد الاتّصال، لكن لا يحدث شيء فعليّاً لأنّ المكتبة الأصليّة القياسيّة لا تدعم التشفير.6 إن كان تشفير قاعدة البيانات بأكملها مطلوباً، فيلزم الاستبدال بحزمة (bundle) من عائلة SQLCipher (مثل SQLitePCLRaw.bundle_e_sqlcipher)، وفي النهاية يحتاج حفظ مفتاح التشفير إلى DPAPI (الفصل 1).
  • Default Timeout: مهلة الأمر (command) الزمنيّة (30 ثانية افتراضيّاً)، وهي الحدّ الأقصى لوقت إعادة المحاولة الذي سنتناوله في الفصل 5.

نقطة أخرى تخصّ الواجهة غير المتزامنة (async API): بما أنّ SQLite نفسه لا يملك إدخال/إخراج غير متزامن، فإنّ الطرق غير المتزامنة مثل ExecuteNonQueryAsync تُنفَّذ داخليّاً بشكل متزامن.7 لذا لا تصحّ فكرة «جعلتُها async فلن تتجمَّد واجهة المستخدم»، وينبغي نقل الاستعلامات الثقيلة صراحةً إلى خيط عامل (worker thread) عبر Task.Run أو ما شابه. معايير القرار حول async/await مرتَّبة في «جدول القرار العمليّ لـ C# async/await».

3.1 مطبّ التجميع (pooling) ── الملفّ يبقى مفتوحاً حتّى بعد Close

تجميع الاتّصالات (connection pooling) مفعَّل افتراضيّاً في Microsoft.Data.Sqlite منذ الإصدار 6.0.6 وميزته أنّه يوفِّر إعادة فتح الملفّ في كلّ Open، لكن في المقابل يبقى الاتّصال الأصليّ (native) في المجمّع حتّى بعد Close / Dispose، ويستمرّ في الإمساك بمقبض (handle) ملفّ قاعدة البيانات. ونتيجة لذلك، تفشل العمليّات التالية برسالة «الملفّ قيد الاستخدام».

  • إعادة إنشاء ملفّ قاعدة البيانات بحذفه ضمن ميزة «تصفير البيانات»
  • استبدال ملفّ قاعدة البيانات عند الاستعادة من نسخة احتياطيّة
  • نقل ملفّ قاعدة البيانات أثناء إلغاء التثبيت أو عمليّة الإخلاء (evacuation)

الحلّ هو إفراغ المجمّع مباشرةً قبل أيّ عمليّة على الملفّ.

// إفراغ المجمّع الموافق لسلسلة الاتّصال هذه، وتحرير مقبض الملفّ
SqliteConnection.ClearPool(new SqliteConnection(connectionString));
File.Delete(dbPath);

في وضع WAL (الفصل التالي) قد يتبقّى ملفّا -wal / -shm، فتخلَّص منهما أيضاً. إن أردتَ تحرير كلّ شيء عند إنهاء التطبيق فاستخدم SqliteConnection.ClearAllPools()، وإن كانت أداة تُشغَّل مرّة واحدة ولا تحتاج أصلاً إلى تجميع، فيمكن أيضاً ضبط Pooling=False في سلسلة الاتّصال. تكثر الاستفسارات بعد الترحيل إلى SQLite من نوع «أغلقتُ الاتّصال لكن لا يمكنني حذف الملفّ»، لذا ضع ClearPool من البداية في أيّ معالجة أداتيّة (utility) تتعامل مع ملفّ قاعدة البيانات.

4. آليّة عمل وضع WAL ── استخدمه وأنت تعرف ما الذي يحدث

اكتفينا في المقال السابق بكتابة «فعِّل وضع WAL»، لذا سنتعمَّق هذه المرّة في آليّة عمله. في طريقة سجلّ التراجع (rollback journal) الافتراضيّة، تُحظَر القراءة أثناء الكتابة، وهذا هو السبب الرئيسيّ لظهور database is locked. أمّا في طريقة WAL (تسجيل الكتابة المُسبَقة - Write-Ahead Logging) فتُكتَب التغييرات في ملفّ سجلّ مُلحَق (append-only) بدل قاعدة البيانات نفسها، لذا لا تحظر القراءةُ الكتابةَ، ولا تحظر الكتابةُ القراءةَ.2 وهذا يجعل التركيبة النمطيّة لتطبيقات الأعمال - حيث يعرض خيط واجهة المستخدم السجلّ التاريخيّ بينما تُكتَب قيم القياس في الخلفيّة - قابلةً للتحقّق كما هي.

عند تفعيل وضع WAL، يظهر ملفّان بجانب قاعدة البيانات نفسها (التي تحتوي المحتوى المؤكَّد عبر نقاط التحقّق - checkpoint).

الملفّ الدور
app.db-wal سجلّ التغييرات المُلحَق. يحتوي تغييرات تمّ الالتزام (commit) بها لكن لم تنعكس بعد في الملفّ الرئيسيّ
app.db-shm ذاكرة مشتركة تُسمّى wal-index، تُنسِّق موضع قراءة WAL بين العمليّات (processes)

توجد أربع خصائص ينبغي معرفتها.

  • نقطة التحقّق (checkpoint): عمليّة تنقل محتوى -wal إلى الملفّ الرئيسيّ، وتُنفَّذ تلقائيّاً افتراضيّاً عندما يبلغ WAL حجم 1000 صفحة (نحو 4 ميغابايت).2 إذا بقيت معاملة قراءة طويلة قائمة، تتوقّف نقطة التحقّق عن التقدّم ويتضخّم -wal، لذا تجنَّب تصميماً «يُبقي اتّصال القراءة مفتوحاً باستمرار ويتناقله»، بل افتحه وأغلقه عند الاستخدام (وبفضل التجميع، تكون إعادة الفتح سريعة).
  • الإعداد يُحفَظ داخل قاعدة البيانات: بمجرّد تنفيذ PRAGMA journal_mode=WAL مرّة واحدة، يُسجَّل ذلك في ملفّ قاعدة البيانات نفسه، ويبقى الوضع WAL أيّاً كان الاتّصال الذي يفتحه لاحقاً.2 لا حاجة لتنفيذه في كلّ اتّصال.
  • الكتابة تبقى واحدة في كلّ مرّة: ما يرفعه WAL هو تزامن القراءة مع الكتابة، أمّا الكتابة مقابل الكتابة فتبقى حصريّة (exclusive). إن أسأتَ الفهم هنا وظننتَ أنّه «بما أنّه WAL، يمكن الكتابة بحرّيّة من خيوط متعدّدة»، ستصطدم بـ SQLITE_BUSY في الفصل 5.
  • لا يعمل على مشاركة شبكيّة: بما أنّ wal-index يفترض ذاكرة مشتركة، فهو لا يعمل بين عمليّات (processes) على أجهزة مختلفة.2 وكما ذكرنا في المقال السابق، فإنّ عدم وضع SQLite على مشاركة شبكيّة أصلاً هو المبدأ المتّبَع.

من ناحية التشغيل، يحتوي ملفّ -wal على معاملات تمّ الالتزام بها لكنّها لم تنعكس بعد في الملفّ الرئيسيّ. فكلا الاعتقادَين «يكفي نسخ ملفّ .db الرئيسيّ فقط» و«-wal ملفّ مؤقّت يمكن حذفه» خاطئ، وسيؤدّي إلى فقدان أحدث الالتزامات أو، في أسوأ الأحوال، إلى تلف قاعدة البيانات.5 وهذا يتّصل مباشرةً بموضوع الفصل 7 «النسخ الاحتياطيّ لا يصحّ بنسخ الملفّ».

5. الحصريّة وSQLITE_BUSY ── توحيد الكتابة في مسار واحد

يحدث SQLITE_BUSY (رسالة الاستثناء database is locked) عندما يحتفظ اتّصال آخر بقفل كتابة. أوّل ما ينبغي معرفته هو أنّ Microsoft.Data.Sqlite تعيد المحاولة تلقائيّاً عند أخطاء busy/locked حتّى بلوغ مهلة الأمر الزمنيّة (30 ثانية افتراضيّاً).3 أي أنّه لا حاجة عادةً إلى إعادة محاولة يدويّة من نوع catch ثمّ انتظار (sleep) ثمّ إعادة التنفيذ من جهة التطبيق. إن استمرّ الاستثناء رغم ذلك حتّى بلوغ المهلة، فالسبب أحد التالي.

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

لا يحلّ أيّاً من هذه الحالات «زيادة عدد إعادة المحاولات». الحلّ هو تقصير المعاملات وتوحيد مسار الكتابة في واحد.

5.1 إنشاء طابور كتابة عبر System.Threading.Channels

بالنسبة للبيانات التي تنبع من خيوط متعدّدة (كقيم القياس أو سجلّات العمليّات)، بدلاً من أن يكتب كلّ خيط مباشرةً إلى قاعدة البيانات، اجعله يُلقي البيانات في طابور (queue) تعالجه حلقة كتابة مخصَّصة واحدة. في .NET يمكن استخدام System.Threading.Channels مباشرةً لهذا الغرض.

using System.Globalization;
using System.Threading.Channels;
using Microsoft.Data.Sqlite;

public sealed record Measurement(string DeviceId, double Value, DateTime CreatedAtUtc);

public sealed class MeasurementWriter : IAsyncDisposable
{
    private readonly Channel<Measurement> _channel =
        Channel.CreateBounded<Measurement>(new BoundedChannelOptions(10_000)
        {
            FullMode = BoundedChannelFullMode.Wait  // عند الامتلاء، يُترَك جانب الإنتاج ينتظر
        });
    private readonly string _connectionString;
    private readonly Task _loop;

    public MeasurementWriter(string connectionString)
    {
        _connectionString = connectionString;
        _loop = Task.Run(WriteLoop);
    }

    // يمكن استدعاؤها من أيّ خيط. لا تلمس قاعدة البيانات. إن كانت حلقة الكتابة
    // ميّتة، يعرف الجانب المُرسِل ذلك فوراً عبر ChannelClosedException
    public ValueTask EnqueueAsync(Measurement m) => _channel.Writer.WriteAsync(m);

    private async Task WriteLoop()
    {
        try
        {
            await WriteLoopCore();
        }
        catch (Exception ex)
        {
            // إبلاغ الجانب المُرسِل عبر القناة بموت دور الكتابة، بسبب امتلاء
            // القرص مثلاً. إن أُهمِل هذا، فحين تمتلئ القناة سيظلّ EnqueueAsync
            // ينتظر إلى الأبد، ولن ينتبه أحد إلى العطل
            _channel.Writer.TryComplete(ex);
            throw;
        }
    }

    private async Task WriteLoopCore()
    {
        using var conn = new SqliteConnection(_connectionString);
        conn.Open();
        var buffer = new List<Measurement>(500);
        while (await _channel.Reader.WaitToReadAsync())
        {
            // جمع ما تراكم حتّى 500 سجلّ كحدّ أقصى وكتابتها ضمن معاملة واحدة
            buffer.Clear();
            while (buffer.Count < 500 && _channel.Reader.TryRead(out var m))
                buffer.Add(m);

            using var tx = conn.BeginTransaction();
            using var cmd = conn.CreateCommand();
            cmd.Transaction = tx;
            cmd.CommandText =
                "INSERT INTO measurement (device_id, value, created_at) " +
                "VALUES ($device, $value, $at)";
            var pDevice = cmd.Parameters.Add("$device", SqliteType.Text);
            var pValue  = cmd.Parameters.Add("$value",  SqliteType.Real);
            var pAt     = cmd.Parameters.Add("$at",     SqliteType.Text);
            foreach (var m in buffer)
            {
                pDevice.Value = m.DeviceId;
                pValue.Value  = m.Value;
                // تحديد InvariantCulture صراحةً حتّى لا يتأثّر التقويم/الأرقام بالثقافة الافتراضيّة
                pAt.Value     = m.CreatedAtUtc.ToString(
                    "yyyy-MM-dd HH:mm:ss.fffffff", CultureInfo.InvariantCulture);
                cmd.ExecuteNonQuery();
            }
            tx.Commit();
        }
    }

    public async ValueTask DisposeAsync()
    {
        // لا تُطلِق استثناءً حتّى لو أغلقت حلقة الكتابة القناة مسبقاً بسبب فشلها
        // (فـ Complete() تُطلِق استثناء الإغلاق المزدوج، فتُخفي الاستثناء الأصليّ)
        _channel.Writer.TryComplete();
        await _loop;  // إنهاء كتابة الباقي. إن كانت الحلقة قد ماتت، يظهر الاستثناء الأصليّ هنا
    }
}

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

5.2 التجميع في معاملة واحدة يغيّر رتبة الأداء

بما أنّ SQLite يقوم بكتابة متزامنة إلى وحدة التخزين (fsync) عند كلّ التزام (commit)، فإنّ تنفيذ INSERT سجلّاً سجلّاً بالتزام ضمنيّ يبلغ سقفه عند بضع مئات إلى بضعة آلاف في الثانية حتّى على SSD، وبضع عشرات فقط في الثانية على HDD. ويكفي تجميع نفس عمليّات INSERT ضمن معاملة صريحة بوحدة 1000 سجلّ (بإحاطتها بـ BeginTransaction وإنهائها بـ Commit) للوصول إلى رتبة عشرات إلى مئات آلاف السجلّات في الثانية. تغيير من ثلاثة أسطر لا يستحقّ حتّى أن يُسمّى «ضبطاً دقيقاً» (tuning) يُحدِث فرقاً برتبتين أو ثلاث رُتَب.

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

6. مطبّات تخطيط الأنواع (type mapping) ── ماذا نُسنِد إلى الأنواع الأربعة فقط

الأنواع التي يستطيع SQLite حفظها فعليّاً أربعة فقط: INTEGER وREAL وTEXT وBLOB، وتُسنَد أنواع .NET إلى أحدها. فيما يلي أهمّ التخطيطات (mappings) في Microsoft.Data.Sqlite.4

نوع .NET نوع SQLite صيغة الحفظ ملاحظة عمليّة
bool / int / long INTEGER   تُحفَظ bool كـ 0 / 1
double REAL   يبقى خطأ الفاصلة العائمة كما هو
string TEXT UTF-8  
DateTime TEXT yyyy-MM-dd HH:mm:ss.FFFFFFF توحيد الصيغة والمنطقة الزمنيّة إلزاميّ
DateTimeOffset TEXT مع الإزاحة (offset) اختلاط الإزاحات يجعل الفرز مستحيلاً
Guid TEXT مفصولة بشرطات غير متوافقة مع الافتراضيّ (BLOB) في System.Data.SQLite
decimal TEXT صيغة 0.0###... المقارنة والفرز يتبعان قواعد النصوص

الأنواع الثلاثة التي تنتهي إلى TEXT تستحقّ حذراً خاصّاً.

  • DateTime: طالما التزمت صيغة ISO 8601 من نوع TEXT بتوحيد الصيغة والمنطقة الزمنيّة، فإنّ الفرز النصّيّ يساوي الفرز الزمنيّ، ولا توجد مشكلة عمليّة. وبالمقابل، تنكسر بمجرّد اختلاط UTC بالوقت المحلّي. الحلّ الوحيد هو تقرير «الحفظ بـ UTC، والتحويل إلى الوقت المحلّي عند العرض» منذ البداية والالتزام به في كلّ المسارات، كما أنّ دوال SQLite مثل datetime('now') تُرجع بدورها UTC. نقطة أخرى: عند تحويل القيمة إلى نصّ بنفسك، يجب دائماً تمرير CultureInfo.InvariantCulture إلى ToString. فإن تُرِكت الثقافة (culture) على الإعداد الافتراضيّ، سيتغيّر تمثيل السنة على الأجهزة التي تعمل بثقافة غير غريغوريّة كالتقويم الهجريّ أو الياباني أو الفرنسيّ فقط، وينكسر كلّ من الفرز والقراءة (راجع مثال الشيفرة في 5.1).
  • Guid: يُحفَظ كنصّ، فالمطابقة تكون مطابقة نصّيّة. إذا كتبته أداة أو مكتبة أخرى بتمثيل مختلف (أحرف كبيرة/صغيرة، صيغة BLOB) فستفشل المطابقة، لذا إن كان سيُلمَس من عدّة لغات وأدوات، وثِّق صيغة التمثيل كمواصفة رسميّة.
  • decimal: أكبر المطبّات. تُحفَظ كـ TEXT لأنّها تفقد دقّتها في REAL4، لكنّ مقارنة من نوع WHERE amount > 1000 على عمود TEXT لا تعمل كما هو متوقَّع، لأنّ TEXT أكبر دائماً من الأرقام في تسلسل أنواع SQLite. كما تُحوَّل التجميعات مثل SUM داخليّاً إلى REAL، فتُفقَد الدقّة التي كانت السبب في اختيار decimal أصلاً.

التوجيه العمليّ بشأن decimal بسيط: احفظ المبالغ الماليّة كعدد صحيح (INTEGER) بأصغر وحدة عملة. بالنسبة للين اليابانيّ، احفظه كـ long بوحدة الين وحوِّله عند العرض. مع الأعداد الصحيحة تكون المقارنة والتجميع دقيقين وسريعين، وتختفي مطبّات الأنواع. إن اضطُررتَ لأسباب متعلّقة بمخطّط قائم إلى التعامل مع decimal كما هو، فاعتبر أنّ المقارنة والتجميع يجب ألّا يتمّا في SQL، بل بعد القراءة، على جانب .NET.

كما أنّ اسم النوع المكتوب في CREATE TABLE ليس إلّا تلميحاً لِـ«الانتماء» (affinity)، واستخدام اسم نوع خاصّ مثل STRING مصدر لحوادث التحويل الضمنيّ. التوصية الرسميّة هي استخدام أربعة أسماء أنواع فقط للأعمدة: INTEGER وREAL وTEXT وBLOB.4

7. التشغيل ── لا تُفسِد، وإن فسدت يمكن الاستعادة

7.1 فحص quick_check عند بدء التشغيل

رغم أنّ SQLite محميّ بالمعاملات، لا يمكن إنهاء احتماليّة التلف الناتج عن عطل في القرص أو تشغيل خاطئ للملفّ إلى الصفر. لتجنّب استمرار التشغيل على قاعدة بيانات تالفة وتوسيع الضرر، أدرِج فحص التماسك (integrity check) عند بدء التشغيل. يستغرق integrity_check الكامل وقتاً طويلاً في قواعد البيانات الكبيرة، لذا يكفي في الاستخدام اليوميّ الإصدار الخفيف quick_check.

using var cmd = conn.CreateCommand();
cmd.CommandText = "PRAGMA quick_check";
var result = (string)cmd.ExecuteScalar()!;
if (result != "ok")
{
    // لا تكتب المزيد إلى قاعدة بيانات تالفة. تراجع إلى وضع القراءة فقط وادفع نحو الاستعادة
    logger.LogError("تمّ اكتشاف تلف في قاعدة البيانات: {Detail}", result);
    EnterReadOnlyMode(result);
}

أمّا سياسة عدم إجراء إصلاح أو تراجع تلقائيّ عند اكتشاف التلف (بل إشراك المستخدم في القرار)، فهي كما ورد في القسم 6.2 من المقال السابق.

7.2 النسخ الاحتياطيّ ── لماذا لا يصحّ نسخ الملفّ

قد يلتقط النسخ المباشر لملفّ قاعدة بيانات قيد التشغيل حالة معاملة في منتصف تنفيذها، وقد نصّت وثائق SQLite الرسميّة صراحةً على أنّ هذا سبب للتلف.5 وفي وضع WAL، إن نسختَ الملفّ الرئيسيّ فقط وتركتَ ملفّ -wal (الذي يحتوي التزامات لم تنعكس بعد في الملفّ الرئيسيّ، انظر الفصل 4)، فستفقد أحدث البيانات.

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

  • VACUUM INTO: تُنشئ بجملة SQL واحدة نسخة بأصغر حجم بعد إزالة التجزّؤ (fragmentation).8 راجع مثال الشيفرة في القسم 6.3 من المقال السابق.
  • SqliteConnection.BackupDatabase: غلاف (wrapper) لِـ Backup API الخاصّة بـ SQLite، تنسخ بين كائنَي اتّصال.
using var source = new SqliteConnection($"Data Source={dbPath}");
using var target = new SqliteConnection($"Data Source={backupPath}");
source.Open();
target.Open();
source.BackupDatabase(target);  // تُنتِج لقطة (snapshot) متماسكة حتّى أثناء التشغيل

لكنّ لِـ BackupDatabase نقطة تنبّه: التنفيذ الحاليّ لِـ Microsoft.Data.Sqlite ينسخ بأسرع ما يمكن، ويحظر الكتابة من الاتّصالات الأخرى حتّى الانتهاء.9 إن أخذتَ نسخة احتياطيّة لقاعدة بيانات كبيرة أثناء استمرار القياس أو تفاعل المستخدم، ستظهر الكتابة خلال تلك الفترة كـ SQLITE_BUSY أو كتجمّد للشاشة. للأمان، استخدم VACUUM INTO للنسخ الاحتياطيّ الدوريّ اليوميّ، واستخدم BackupDatabase للنسخ المتبادل مع قاعدة بيانات في الذاكرة (in-memory)، أو للتكرار في أوقات توقّف الكتابة أو أثناء معالجات الصيانة. إن استخدمتَ جدولة المهام (Task Scheduler) للتنفيذ الدوريّ، فاجعل التطبيق نفسه (أو أداة صغيرة تفتح SQLite بالطريقة الصحيحة) هو من يُنفِّذ عمليّة النسخ الاحتياطيّ أعلاه، لا نسخ الملفّ من الخارج. أمّا تصميم التنفيذ الدوريّ نفسه فهو كما ورد في «تشغيل التنفيذ الدوريّ بأمان عبر جدولة المهام».

7.3 موضع الملفّ والترحيل (migration)

الموضع الأساسيّ لملفّ قاعدة البيانات هو %LOCALAPPDATA%\اسم الشركة\اسم التطبيق، والحدّ الأدنى لإدارة إصدارات المخطّط (schema) هو الترحيل عند بدء التشغيل عبر PRAGMA user_version. كتبنا كلا الأمرين مع الشيفرة في المقال السابق (الفصل 3 والقسم 6.1)، لذا لن نُكرِّرهما هنا. نُضيف فقط ملاحظة واحدة: إن أخذتَ نسخة احتياطيّة واحدة (7.2) قبل تنفيذ الترحيل، تصبح الاستعادة من أسوأ سيناريو - «فشل الترحيل وتعذّر بدء التشغيل» - مجرَّد استبدال ملفّ.

8. متى نستخدم EF Core ── نقطة التعادل (break-even) الخاصّة بـ ORM

كتبنا حتّى الآن باستخدام Microsoft.Data.Sqlite الخام، لكن توجد بوضوح مواقف ينبغي فيها استخدام موفِّر SQLite الخاصّ بـ EF Core. محور القرار هو طبيعة التطبيق.

طبيعة التطبيق التوصية السبب
عدد شاشات كبير، وعمليّات CRUD محوَرها الكيانات (entities) هي الأساس (كالطلبات والتوريد، إدارة البيانات الرئيسيّة) EF Core + الترحيل (Migrations) يقلّ حجم شيفرة التعبئة وSQL المكتوب يدويّاً، ويمكن تتبّع تغييرات المخطّط عبر dotnet ef migrations
متخصّص في الكتابة ومخطّطه صغير (سجلّات القياس، سجلّات التدقيق، التخزين المؤقّت) Microsoft.Data.Sqlite الخام (+ Dapper عند الحاجة) العبء الإضافيّ لتتبّع التغييرات مهدور. يمكن بناء طابور الكتابة + الدفعات من الفصل 5 بسهولة
مزيج من الطبيعتين استخدام الاثنين معاً لا مشكلة في استخدام EF Core لشاشات CRUD وADO.NET الخام لكتابة السجلّات على نفس ملفّ قاعدة البيانات

عند اختيار EF Core، يلزم معرفة القيود الخاصّة بموفِّر SQLite.10

  • إعادة بناء الجدول بسبب قيود ALTER TABLE: بما أنّ SQLite لا يدعم مباشرةً تغيير نوع عمود أو حذفه، فإنّ عمليّات الترحيل التي تتضمّن AlterColumn أو DropColumn تُنفَّذ عبر إعادة بناء («إنشاء جدول جديد ← نسخ البيانات ← حذف الجدول القديم ← إعادة التسمية»). في البيئات ذات حجم البيانات الكبير، يؤثِّر هذا في وقت التطبيق ومساحة القرص المستخدَمة، لذا خطِّط جيّداً لتغييرات مخطّط الجداول الكبيرة.
  • يتعذّر إنشاء سكربتات مثاليّة (idempotent): لا يمكن توليد سكربتات ترحيل بشرط if-then كما في SQL Server. التطبيق العمليّ هو تنفيذ dbContext.Database.Migrate() عند بدء تشغيل التطبيق.
  • عمليّات decimal / DateTimeOffset تُقيَّم من جانب العميل (client evaluation): أوضاع الأنواع المذكورة في الفصل 6 لا تختفي حتّى مع EF Core. تُقيَّم المقارنات والفرز غير المتعلّقة بالتساوي من جانب العميل، لذا يبقى توجيه حفظ المبالغ كعدد صحيح بأصغر وحدة سارياً حتّى مع EF Core (يمكن التحويل إلى long عبر مُحوِّل قيمة - value converter - عند الحفظ).
  • WAL مفعَّل افتراضيّاً: قاعدة البيانات التي ينشئها EF Core تكون في وضع WAL منذ البداية7، لذا لا حاجة إلى إعداد الفصل 4. لكن يبقى فهم آليّة العمل ضروريّاً.

كما أنّه حتّى عند اعتماد EF Core، يُفيد استخدام قاعدة بيانات SQLite في الذاكرة (in-memory) في اختبارات الوحدة (unit tests) لطبقة المستودع (repository). بما أنّها تعمل بنفس الموفِّر المستخدَم في الإنتاج، تضيق الفجوة من نوع «ينجح مع Mock لكن يفشل مع قاعدة البيانات الفعليّة». راجع «الحدّ الفاصل بين اختبار الوحدة واختبار التكامل» بخصوص أيّ طبقة تُكتَب فيها الاختبارات.

9. الخلاصة

SQLite مكتبة «يكفي دمجها 30 دقيقة، لكن تشغيلها بشكل صحيح يحتاج تصميماً». ومع ذلك، التصميم المطلوب محدَّد سلفاً، ويمكن تلخيص محتوى هذا المقال في قائمة فحص من 6 نقاط.

  • المكتبة هي Microsoft.Data.Sqlite (أو EF Core فوقها). لا تخلط مع معلومات مبنيّة على افتراض System.Data.SQLite
  • فعِّل وضع WAL منذ أوّل إصدار، وافهم دور -wal / -shm
  • وحِّد الكتابة في مسار واحد (طابور كتابة عبر Channels)، واجمع عمليّات INSERT الصغيرة ضمن معاملات
  • وحِّد DateTime على UTC، واحفظ المبالغ كعدد صحيح بأصغر وحدة عملة. لا تُقارِن أو تُجمِّع decimal وهو ما يزال TEXT
  • نفِّذ quick_check عند بدء التشغيل، وخذ النسخ الاحتياطيّ عبر VACUUM INTO أو BackupDatabase. لا تنسخ الملفّ أثناء التشغيل
  • لا تنسَ SqliteConnection.ClearPool في أيّ معالجة تحذف ملفّ قاعدة البيانات أو تستبدله

إن كانت لديك بنية تتجاوز database is locked بإعادة المحاولة المتكرِّرة، أو تأخذ النسخ الاحتياطيّ بنسخ الملفّ، فحاول مراجعتها ببنود هذا المقال قبل أن تتلف. إصلاح كلّ من هذه النقاط لا يتطلَّب أكثر من تعديل صغير.

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

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

تتعامل شركة Komura Soft LLC مع مراجعة تصميم تطبيقات الأعمال التي تدمج SQLite (تصميم التحكّم الحصريّ، والنسخ الاحتياطيّ، والترحيل)، وتحقيق أعطال التطبيقات القائمة من نوع database is locked وتلف البيانات وتراجع الأداء، ودعم الترحيل من مخازن بيانات قائمة مثل Access.

المراجع

  1. Microsoft Learn، Microsoft.Data.Sqlite overview. حول كونها موفِّر ADO.NET خفيفاً تصونه Microsoft، وأساساً يقوم عليه موفِّر SQLite الخاصّ بـ EF Core.  2

  2. SQLite، Write-Ahead Logging. حول دور ملفّي -wal / -shm، ونقطة التحقّق (1000 صفحة افتراضيّاً)، وتزامن القراءة والكتابة، وحفظ الوضع بشكل دائم، وعدم العمل على أنظمة الملفّات الشبكيّة.  2 3 4 5

  3. Microsoft Learn، Database errors (Microsoft.Data.Sqlite). حول إعادة المحاولة التلقائيّة عند أخطاء busy/locked حتّى مهلة الأمر (30 ثانية افتراضيّاً)، وعدم كون كائنات الاتّصال والأمر وغيرها آمنة للخيوط (thread-safe).  2

  4. Microsoft Learn، Data types (Microsoft.Data.Sqlite). حول الأنواع الأوّليّة الأربعة لـ SQLite، وتخطيط DateTime وGuid وdecimal إلى TEXT، ووجوب اقتصار أسماء أنواع الأعمدة على الأنواع الأوّليّة الأربعة أيضاً.  2 3 4

  5. SQLite، How To Corrupt An SQLite Database File. حول كون نسخ ملفّ قاعدة البيانات أثناء التشغيل (أثناء معاملة)، وحذف أو فصل ملفّات السجلّ الساخن (hot journal) وWAL، أسباباً للتلف.  2 3

  6. Microsoft Learn، Connection strings (Microsoft.Data.Sqlite). حول قائمة كلمات سلسلة الاتّصال المفتاحيّة، وكون Pooling مفعَّلاً افتراضيّاً، وأنّ Password لا تأثير لها إن كانت المكتبة الأصليّة لا تدعم التشفير، وعدم استحسان الجمع بين Cache=Shared وWAL.  2 3 4 5 6

  7. Microsoft Learn، Async limitations (Microsoft.Data.Sqlite). حول عدم دعم SQLite للإدخال/الإخراج غير المتزامن وتنفيذ الطرق async بشكل متزامن، وكون WAL مفعَّلاً افتراضيّاً في قواعد البيانات التي ينشئها EF Core.  2

  8. SQLite، VACUUM. حول إمكانيّة إنشاء نسخة متماسكة بأصغر حجم في ملفّ منفصل دون تعديل الملفّ الأصليّ عبر جملة VACUUM INTO الفرعيّة. 

  9. Microsoft Learn، Backup (Microsoft.Data.Sqlite). حول التنفيذ الحاليّ لِـ BackupDatabase الذي ينسخ بأسرع ما يمكن ويحظر كتابة الاتّصالات الأخرى حتّى الانتهاء. 

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

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

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

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

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

لماذا يظهر «database is locked» في SQLite؟
يحدث SQLITE_BUSY عندما يحتفظ اتّصال آخر بقفل كتابة. تعيد Microsoft.Data.Sqlite المحاولة تلقائيّاً حتّى بلوغ مهلة الأمر (30 ثانية افتراضيّاً)، لذا لا حاجة عادةً إلى إعادة محاولة يدويّة. إن استمرّ ظهور الاستثناء رغم ذلك، فالسبب معاملة طويلة تتجاوز 30 ثانية، أو تصادم ناتج عن الترقّي من القراءة إلى الكتابة، أو تنازع على القفل بين عمليّات كتابة صغيرة متعدّدة من خيوط عدّة - وزيادة عدد إعادة المحاولات لا تحلّ أيّاً منها. الحلّ الجذريّ هو تقصير المعاملات وتوحيد مسار الكتابة في واحد باستخدام طابور مثل System.Threading.Channels. كما أنّ تفعيل وضع WAL يُزيل معظم حالات الحظر بين القراءة والكتابة.
ما المكتبة التي ينبغي اختيارها لاستخدام SQLite في C#؟
في التطوير الجديد، الأساس هو Microsoft.Data.Sqlite، موفِّر ADO.NET الذي تصونه Microsoft (أو موفِّر SQLite الخاصّ بـ EF Core المبنيّ فوقها). بما أنّ حزمة NuGet تتضمَّن ملفّ SQLite الأصليّ نفسه، فلا حاجة إلى عمليّة توزيع منفصلة. وهي مختلفة تماماً عن System.Data.SQLite العريقة من ناحية سلسلة الاتّصال ومعاملة الأنواع - فمثلاً يُحفَظ Guid كـ BLOB بشكل افتراضيّ في System.Data.SQLite، بينما يُحفَظ كـ TEXT في Microsoft.Data.Sqlite. انتبه دائماً إلى أيّهما يفترضه المثال الذي تجده على الويب.
هل يكفي نسخ الملفّ لأخذ نسخة احتياطيّة من SQLite؟
يُمنَع النسخ المباشر للملفّ أثناء التشغيل. فقد يلتقط حالة معاملة في منتصف تنفيذها، وتنصّ وثائق SQLite الرسميّة صراحةً على أنّ هذا سبب للتلف. في وضع WAL، يحتوي ملفّ -wal على التزامات لم تنعكس بعد في الملفّ الرئيسيّ، لذا فإنّ نسخ الملفّ الرئيسيّ فقط يُفقِد أحدث البيانات. الطريقتان الصحيحتان هما VACUUM INTO (تُنشئ بجملة SQL واحدة نسخة متماسكة بأصغر حجم) أو SqliteConnection.BackupDatabase. لكن بما أنّ BackupDatabase تحظر الكتابة من الاتّصالات الأخرى حتّى الانتهاء، فإنّ VACUUM INTO أكثر أماناً للنسخ الاحتياطيّ الدوريّ اليوميّ.
لماذا يكون إدخال عدد كبير من السجلّات (INSERT) في SQLite بطيئاً؟
غالباً ما يكون السبب هو وحدة الالتزام (commit). يقوم SQLite بكتابة متزامنة إلى وحدة التخزين (fsync) عند كلّ التزام، لذا فإنّ تنفيذ INSERT سجلّاً سجلّاً بالتزام ضمنيّ يبلغ سقفه عند بضع مئات إلى بضعة آلاف في الثانية حتّى على SSD. ويكفي تجميع نفس عمليّات INSERT ضمن معاملة صريحة بوحدة 1000 سجلّ (بإحاطتها بـ BeginTransaction وإنهائها بـ Commit) للوصول إلى رتبة عشرات إلى مئات آلاف السجلّات في الثانية، أي تحسين بمقدار رتبتين أو ثلاث رُتَب. وفي المقابل، فإنّ إطالة المعاملة أكثر من اللازم تُعطِّل الكتابات الأخرى، لذا فإنّ جعل بضع مئات إلى بضعة آلاف سجلّ، أو ما يعادل بضع مئات المللي ثانية، التزاماً واحداً هو التسوية العمليّة المعتادة.

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

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

غو كومورا

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

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

روابط عامة

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