DuckDB أم SQLite على الخادم؟ لماذا تحتاجهما معاً
يخزّن SQLite حالة التطبيق والمعاملات، بينما يحلل DuckDB ملفات Parquet وCSV. تعرّف إلى سبب تشغيلهما معاً على VPS، مع مثال عملي لكل محرك.
DuckDB مقابل SQLite على خادم: الإجابة في جملة واحدة
SQLite هو محرك OLTP (معالجة المعاملات عبر الإنترنت): يخزّن البيانات في صفوف، وصُمّم لقراءة عدد قليل منها وكتابته بأمان وسرعة في كل مرة. DuckDB هو محرك OLAP (المعالجة التحليلية عبر الإنترنت): يخزّن البيانات في أعمدة، وصُمّم لفحص ملايين الصفوف وإرجاع قيمة تجميعية واحدة. كلاهما مكتبتان مضمّنتان، وكلاهما يفتح ملفاً عادياً، ولا يتطلب أيٌّ منهما تشغيل عملية خادم تحتاج إلى مراقبتها باستمرار.
لذلك، فإن الإجابة الصادقة عن سؤال «أيّهما؟» هي غالباً «كلاهما على VPS نفسه». يحتفظ تطبيقك بحالته الحية في SQLite. وتقرأ تقاريرك ملفات Parquet وCSV باستخدام DuckDB. لا يتنافس المحركان لأنّهما لا يؤديان المهمة نفسها.
لماذا تغيّر طريقة تخزين الصفوف وتخزين الأعمدة الإجابة
يكتب SQLite الصف في جزء متصل واحد من الصفحة. يؤدي جلب طلب واحد باستخدام مفتاحه الأساسي إلى الوصول إلى صفحة فهرس واحدة وصفحة بيانات واحدة، أي إجراء عمليتي قراءة. وهذا يطابق ما يفعله التطبيق آلاف المرات في الثانية: قراءة مستخدم معين، وتحديث جلسة، وإدراج طلب.
يكتب DuckDB كل عمود على حدة ويضغطه. يؤدي جمع amount_cents عبر خمسة ملايين صف إلى قراءة العمود amount_cents فقط، وتجاوز كل بايت آخر في الملف، وتنفيذ عملية الجمع باستخدام تعليمات متجهة على دفعات من القيم. لا تُقرأ الأعمدة الأخرى من القرص أبداً، ومن هنا تأتي السرعة.
شغّل الآن كل محرك على عبء العمل الخاص بالمحرك الآخر. عند جمع عمود في SQLite، يجب المرور على كل صف وجلب الصف بأكمله من الصفحة للوصول إلى حقل واحد، ولذلك يقرأ من القرص بيانات أكثر بكثير مما يحتاج إليه. وعند إدراج طلب واحد في DuckDB، يجب الوصول إلى مخزن كل عمود لكتابة قيمة واحدة، كما يفرض قفل كتابة على ملف قاعدة البيانات بأكمله لتنفيذ ذلك. لا يوجد خلل في أي من المحركين. لكن كل محرك يجيب عن سؤال لم يُصم لمعالجته.
متى تتفوق SQLite: حالة التطبيق المعاملاتية
استخدم SQLite عندما تكون عمليات الكتابة صغيرة ومتكررة، ويجب ألّا تُفقد. يشمل ذلك الجلسات والطلبات وصفوف قائمة الانتظار والإعدادات وكل ما ينشئه طلب ويب.
sudo apt update && sudo apt install -y sqlite3
sudo install -d -o "$USER" -g "$USER" /srv/appأنشئ الجدول وفعّل تسجيل الكتابة المسبقة في الخطوة نفسها.
sqlite3 /srv/app/app.db <<'SQL'
PRAGMA journal_mode = WAL;
CREATE TABLE IF NOT EXISTS orders (
id INTEGER PRIMARY KEY,
customer TEXT NOT NULL,
placed_at TEXT NOT NULL,
amount_cents INTEGER NOT NULL
);
CREATE INDEX IF NOT EXISTS orders_customer ON orders (customer);
INSERT INTO orders (customer, placed_at, amount_cents)
VALUES ('ana', '2026-07-30T09:14:00Z', 4200);
SQLالسطر الأول من الناتج هو wal. وهذه هي PRAGMA التي تعرض الوضع الذي انتقلت إليه، وهي الإعداد الأكثر فائدة على الخادم. في وضع سجل التراجع الافتراضي، تمنع عملية كتابة واحدة جميع عمليات القراءة. في وضع WAL، تواصل عمليات القراءة قراءة آخر حالة ملتزم بها بينما تُلحق عملية كتابة واحدة البيانات، لذلك لا يتسبب تقرير بطيء في تعطيل طلب الويب الذي ينتظره.
تحقق من ظهور الصف مجدداً:
sqlite3 /srv/app/app.db "SELECT * FROM orders WHERE customer = 'ana';"ستحصل على 1|ana|2026-07-30T09:14:00Z|4200. ظهر ملفان إضافيان بجوار قاعدة البيانات، وهما app.db-wal وapp.db-shm، وكلاهما جزء منها. إن نسخت app.db وحده أثناء تشغيل التطبيق، فستحصل على نسخة احتياطية غير متسقة، وترد التفاصيل في قسم لاحق.
ما زالت SQLite تسمح بعملية كتابة واحدة في كل مرة. هذا الحد عبارة عن قفل، وليس قائمة انتظار، لذلك تفشل عملية كتابة ثانية إذا انتظرت مدة طويلة، وتُرجع database is locked بدلاً من الانتظار إلى أجل غير مسمى. ارفع مدة الانتظار باستخدام PRAGMA busy_timeout = 5000; في كل اتصال يفتحه التطبيق. ويؤدي الانتظار مدة خمس ثوانٍ إلى إزالة معظم هذه الأخطاء في حمل ويب عادي.
متى يتفوّق DuckDB: تحليلات على الملفات الموجودة لديك
استخدم DuckDB عندما يبدأ السؤال بـ"كم عدد" أو "كم تبلغ الكمية" أو "ما أعلى عشرة عناصر"، وتكون المدخلات مجموعة من ملفات CSV أو Parquet. ثبّت عميل سطر الأوامر، وإصداره 2.0.0، وهو إصدار LTS اعتباراً من 27 October 2026:
curl https://install.duckdb.org | shيثبّت البرنامج النصي أحدث إصدار مستقر ما لم تثبّت إصداراً محدداً، مثل curl https://install.duckdb.org | DUCKDB_VERSION=1.5.6 sh للبقاء على الإصدار السابق. يثبّت الملف التنفيذي في ~/.duckdb/cli/latest/duckdb ويطبع السطر الذي يضيفه إلى PATH. تحقق من أنه يعمل:
~/.duckdb/cli/latest/duckdb :memory: "SELECT version();"أنشئ ملفاً واقعياً للاستعلام عنه. يكتب هذا الأمر خمسة ملايين صف من الطلبات إلى Parquet، مع ضغطها باستخدام zstd:
mkdir -p /srv/data
duckdb :memory: "
COPY (
SELECT i AS id,
'cust_' || (i % 5000) AS customer,
TIMESTAMP '2026-01-01 00:00:00' + INTERVAL (i) MINUTE AS placed_at,
(i * 37) % 20000 AS amount_cents
FROM range(5000000) t(i)
) TO '/srv/data/orders.parquet' (FORMAT parquet, COMPRESSION zstd);"اطرح الآن السؤال التحليلي. افتح الصدفة، فعّل المؤقت، واستعلم عن الملف مباشرةً من دون خطوة استيراد:
.timer on
SELECT customer,
count(*) AS orders,
sum(amount_cents)/100.0 AS revenue
FROM '/srv/data/orders.parquet'
GROUP BY customer
ORDER BY revenue DESC
LIMIT 5;اقرأ الرقم الناتج بنفسك من .timer بدلاً من الوثوق برقم منشور، لأن النتيجة تعتمد على القرص وعدد الأنوية لديك. المهم هو شكل النتيجة. لم تكن هناك CREATE TABLE ولا INSERT ولا خطوة تحميل: قرأ DuckDB تذييل Parquet، وحدد أجزاء الأعمدة التي يحتاج إليها الاستعلام، ثم قرأ هذه الأجزاء فقط. يعمل الدليل الكامل بالطريقة نفسها باستخدام نمط glob، FROM '/srv/data/orders-*.parquet'، وبذلك تتحول صادرات يومية لشهر كامل إلى استعلام واحد.
تحدد سرعة القرص الحد الأدنى للأداء هنا، كما أن فحص الأعمدة قراءة تسلسلية طويلة. لذلك يظهر الفرق بين تخزين NVMe وتخزين SATA الأقدم على VPS هنا بوضوح أكبر مما يظهر مع القراءات العشوائية الصغيرة في SQLite.
قراءة قاعدة بيانات SQLite من DuckDB
يتصل المحركان عبر إضافة sqlite في DuckDB. أرفق قاعدة بيانات التطبيق بوضع القراءة فقط، حتى لا يتمكن استعلام تحليلي من الكتابة إلى الحالة الحية مطلقاً:
INSTALL sqlite;
LOAD sqlite;
ATTACH '/srv/app/app.db' AS app (TYPE sqlite, READ_ONLY);
SELECT customer, count(*) AS orders
FROM app.orders
GROUP BY customer
ORDER BY orders DESC;يقرأ هذا الصفوف من ملف SQLite وقت تنفيذ الاستعلام، من دون إنشاء نسخة. هذا مريح، لكنه ليس سريعاً، لأن البيانات على القرص ما تزال مخزنة في صفوف، ولأن DuckDB يجب أن يمر عليها. استخدمه للتصدير، وليس للوحة معلومات يُعاد تحميلها كل ثلاثين ثانية:
COPY (SELECT * FROM app.orders)
TO '/srv/data/orders-2026-07.parquet' (FORMAT parquet, COMPRESSION zstd);هذا البيان الواحد يوضّح النمط بأكمله. تملك SQLite الصفوف الحية الحديثة. ويحوّل تصدير مجدول الفترات المغلقة إلى Parquet. ويجيب DuckDB عن كل سؤال يمتد عبر أشهر، بينما تبقى قاعدة بيانات التطبيق صغيرة، مما يحافظ على سرعة عمليات الكتابة فيها.
شغّل التصدير وفق جدول زمني بدلاً من تشغيله يدوياً. إن زوجاً من خدمة ومؤقت systemd هو الحجم المناسب لذلك: وحدة واحدة تشغّل COPY، ومؤقت واحد يشغّلها كل ليلة.
التشغيل معاً على VPS واحد
لا يحتاج أيٌّ من هذين المحركين إلى حاوية أو منفذ. فكلاهما مكتبة، لذا يقتصر التثبيت على حزمة ومسار ملف. إذا كانت بقية مكوّنات الحزمة لديك تعمل بالفعل ضمن Docker Compose على VPS نفسه، فقم بتركيب دليل البيانات داخل الحاوية التي تحتاج إليه بدلاً من إضافة خدمة قاعدة بيانات، إذ لا توجد خدمة لإضافتها.
تمنع قاعدتان حدوث مشكلات في هذا الترتيب.
امنح كل محرك دليله الخاص: /srv/app لملف SQLite الذي يكتب فيه التطبيق، و/srv/data لملفات Parquet التي يقرأ منها نظام التحليلات. عند مشاركة دليل واحد، قد تتنافس مهمة النسخ الاحتياطي التي تنشئ لقطة لأحدهما مع الآخر.
لا توجّه عمليتين إلى ملف قاعدة بيانات DuckDB واحد في وضع القراءة والكتابة. يمكن لعملية واحدة فقط فتح ملف DuckDB للكتابة، وستفشل العملية الثانية في فتحه بالكامل. يمكن لعدد كبير من القرّاء العمل بشكل طبيعي عندما يضبط كل منهم access_mode = 'READ_ONLY'. يفاجئ هذا السلوك القادمين من SQLite، حيث تتشارك عدة عمليات ملفاً واحداً بصورة اعتيادية. إذا كان نظام التحليلات يقرأ ملفات Parquet فقط، فلن تظهر هذه المشكلة، وهذا سبب إضافي للاحتفاظ بالبيانات الدائمة في SQLite.
تختلف النسخ الاحتياطية، والفرق يسبب مشكلات
تتكوّن قاعدة بيانات SQLite قيد التشغيل من ثلاثة ملفات. يؤدي نسخها باستخدام cp أثناء الكتابة إلى إنشاء ملف يمكن فتحه، لكنه غير صحيح. استخدم أمر النسخ الاحتياطي الخاص بالمحرك. ينشئ هذا الأمر لقطة متسقة بينما يواصل التطبيق الكتابة:
sqlite3 /srv/app/app.db ".backup '/srv/backup/app-$(date -u +%Y%m%dT%H%M%SZ).db'"
sqlite3 /srv/backup/app-20260730T091400Z.db "PRAGMA integrity_check;"يطبع integrity_check القيمة ok عند نجاح النسخ. أي نتيجة أخرى تعني حذف تلك اللقطة وإنشاء نسخة أخرى.
لا تتغير ملفات Parquet بعد كتابتها، لذلك لا تحتاج إلى معالجة خاصة. انسخ الدليل احتياطياً. أرسل كلا المسارين خارج الخادم باستخدام النسخ الاحتياطية عبر restic من VPS الخاص بك. وبذلك تصبح طبقة البيانات بأكملها دليلين ضمن مهمة نسخ احتياطي واحدة.
حالات الفشل والرسائل النصية الدقيقة التي ستراها
تعني database is locked من SQLite، سواء ظهرت عبر برنامج تشغيل تطبيقك أو عبر صدفة sqlite3، أن اتصالاً آخر احتفظ بقفل الكتابة مدة أطول من المهلة المسموح بها. لا يشير ذلك إلى تلف البيانات. اضبط PRAGMA busy_timeout في كل اتصال، ثم ابحث عن معاملة طويلة كان ينبغي تقسيمها إلى عدة معاملات قصيرة.
تعني unable to open database file بعد تغيير الصلاحيات عادةً أن العملية تستطيع الكتابة إلى الملف، لكنها لا تستطيع الكتابة إلى دليله. ينشئ SQLite app.db-wal وapp.db-shm بجانب قاعدة البيانات، لذلك يجب أن يكون الدليل نفسه قابلاً للكتابة، وليس ملف .db وحده.
تعني IO Error: Could not set lock on file من DuckDB أن عملية ثانية فتحت قاعدة البيانات للكتابة بالفعل. أغلق الصدفة الأخرى، أو افتح صدفتك بوضع القراءة فقط.
تعني Out of Memory Error من DuckDB على VPS صغير أن الاستعلام احتاج إلى ذاكرة عمل أكبر من المتاح. يكتب DuckDB النتائج الوسيطة إلى القرص افتراضياً، ويشمل ذلك جلسة :memory:، لذلك لا يجعل فتح ملف قاعدة البيانات عملية التفريغ إلى القرص ممكنة بحد ذاتها. يكتب هذه الملفات المؤقتة في دليل .tmp بجانب ملف قاعدة البيانات، أو، في جلسة داخل الذاكرة، في دليل .tmp نسبةً إلى المكان الذي بدأت منه. لذلك شغّله من مكان قابل للكتابة تتوفر فيه مساحة حرة على القرص، أو وجّهه إلى مكان آخر باستخدام SET temp_directory = '/var/tmp/duckdb';. الإعداد المهم هو الحد الأقصى: SET memory_limit = '2GB';. القيمة الافتراضية هي 80% من ذاكرة RAM في الجهاز. لذلك، على جهاز يشغّل خدمات أخرى، يمنع هذا الحد الاستعلام المؤقت من دفع تطبيقك إلى خارج RAM. لا يمكن لبعض عمليات التجميع، مثل string_agg()، التفريغ إلى القرص، وكذلك لا يمكن لبعض عمليات التجميع المعتمدة على الفرز. لذلك قد ينفد الذاكرة من استعلام يعتمد عليها.
تعني Binder Error: Referenced column "amount" not found عند الاستعلام عن Parquet في معظم الحالات أن مخطط الملف ليس المخطط الذي تتذكره. شغّل DESCRIBE SELECT * FROM '/srv/data/orders.parquet'; واقرأ أسماء الأعمدة الفعلية.
كيف تختار عملياً
اسأل عن نمط الكتابة. إذا كانت هناك عمليات كتابة صغيرة كثيرة يجب أن تبقى بعد انقطاع التيار الكهربائي، فاستخدم SQLite. اسأل عن نمط القراءة. إذا كانت هناك عمليات مسح كاملة مع تجميعات على سجل تاريخي طويل، فاستخدم DuckDB. تجيب معظم الأنظمة الفعلية بنعم عن السؤالين. والاستجابة الصحيحة هي منح كل محرك الجزء الذي يجيده، بدلاً من إجبار أحدهما على أداء دور الآخر.
يجب تجنّب نقل حالة التطبيق الحية إلى DuckDB لأن أحد التقارير كان بطيئاً. كان التقرير بطيئاً بسبب طريقة ترتيب التخزين، ولذلك يكون الحل هو التصدير، وليس إعادة كتابة مسار الكتابة لديك.
FAQ
هل يمكن لـ DuckDB استبدال SQLite كقاعدة بيانات لتطبيقي؟
ليس إذا كان تطبيقك يكتب البيانات بشكل متكرر. تفرض DuckDB قفل كتابة على ملف قاعدة البيانات بالكامل، وتسمح بعملية قراءة وكتابة واحدة فقط في كل مرة، كما أنها مُحسّنة للعمليات الجماعية (bulk changes) لا لإدراج الصفوف المفردة. احتفظ بحالة المعاملات في SQLite واجعل DuckDB تقرأها باستخدام ATTACH '/srv/app/app.db' AS app (TYPE sqlite, READ_ONLY); عند الحاجة لإعداد تقرير.
هل DuckDB أسرع فعلياً من SQLite في التحليلات؟
نعم، عند إجراء عمليات المسح والتجميع (aggregates) على جداول كبيرة، والسبب يعود إلى هيكلية التخزين وليس مجرد إعدادات. تقرأ DuckDB الأعمدة المذكورة في الاستعلام فقط وتعالج القيم على دفعات، بينما تضطر SQLite إلى مسح الصفوف بالكامل للوصول إلى حقل واحد. أما عند جلب صف واحد عبر المفتاح الأساسي (primary key)، ينعكس الأمر، لأن SQLite تصل إلى صفحتين فقط بينما تضطر DuckDB للوصول إلى تخزين كل عمود.
هل أحتاج إلى ذاكرة RAM كبيرة لتشغيل DuckDB على خادم VPS؟
لا، ولكن حدد لها سقفاً واترك مساحة للتخزين المؤقت (spill). تقوم DuckDB بنقل النتائج الوسيطة إلى القرص افتراضياً، حتى في جلسة :memory:، طالما أن دليل الملفات المؤقتة قابل للكتابة ويحتوي على مساحة فارغة. اضبط SET memory_limit = '2GB'; على قيمة يمكن لخادمك توفيرها. إذا تركت الإعداد الافتراضي عند 80% من الذاكرة، فقد يؤدي استعلام GROUP BY ضخم إلى رفع Out of Memory Error أو التسبب في طرد الخدمات الأخرى من الذاكرة.
كيف أنقل بياناتي من SQLite إلى صيغة Parquet؟
قم بإرفاق ملف SQLite من داخل DuckDB وانسخ نتائج الاستعلام مباشرة باستخدام COPY (SELECT * FROM app.orders) TO '/srv/data/orders.parquet' (FORMAT parquet, COMPRESSION zstd);. نفذ هذه العملية وفق جدول زمني للفترات المنتهية، مثل صفوف الشهر الماضي، واترك الصفوف الحديثة في SQLite حيث لا يزال التطبيق يكتب فيها.
أيهما يجب أن أحتفظ بنسخة احتياطية منه، وكيف؟
كلاهما، ولكن بطرق مختلفة. خذ لقطات (snapshots) لـ SQLite باستخدام sqlite3 app.db ".backup '/srv/backup/app.db'" بدلاً من cp، لأن قاعدة البيانات قيد التشغيل تُعد أيضاً -wal وملف -shm، والنسخ المباشر قد يؤدي إلى تلف البيانات. ملفات Parquet لا تتغير أبداً بمجرد كتابتها، لذا يكفي نسخ الدليل الذي يحتويها.