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. ثبّت عميل سطر الأوامر، وإصداره 1.5.5 اعتبارًا من يوليو 2026:
curl https://install.duckdb.org | 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
يتكامل المحركان من خلال إضافة DuckDB المسماة sqlite. أرفق قاعدة بيانات التطبيق للقراءة فقط، حتى لا يتمكن استعلام تحليلي من الكتابة إلى الحالة المباشرة:
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 قيد التشغيل من 3 ملفات، ونسخها باستخدام 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 الخاص بك، وبذلك تتكون طبقة البيانات بأكملها من دليلين ضمن مهمة نسخ احتياطي واحدة.
حالات الفشل والنصوص الدقيقة التي ستظهر لك
يشير Error: database is locked من SQLite إلى أن اتصالًا آخر احتفظ بقفل الكتابة مدة أطول من المهلة التي سمحت بها. لا يعني ذلك تلف البيانات. عيّن PRAGMA busy_timeout في كل اتصال، ثم ابحث عن معاملة طويلة كان ينبغي تقسيمها إلى عدة معاملات قصيرة.
يشير Error: unable to open database file بعد تغيير الأذونات عادةً إلى أن العملية تستطيع الكتابة في الملف، لكنها لا تستطيع الكتابة في دليله. ينشئ SQLite الملفين app.db-wal وapp.db-shm بجانب قاعدة البيانات، لذلك يجب أن يكون الدليل نفسه قابلًا للكتابة، وليس الملف .db فقط.
يشير IO Error: Could not set lock on file من DuckDB إلى أن عملية ثانية تستخدم قاعدة البيانات نفسها مفتوحة للكتابة. أغلق shell الآخر، أو افتح قاعدة البيانات للقراءة فقط.
يعني Out of Memory Error من DuckDB على VPS صغير أن استعلامًا احتاج إلى ذاكرة عمل أكبر من المتاح. يكتب DuckDB البيانات المؤقتة إلى القرص عندما يستطيع، لذلك وفّر له مكانًا للكتابة بفتح ملف قاعدة بيانات على القرص بدلًا من :memory:، وحدد استهلاكه باستخدام SET memory_limit = '2GB';. على خادم يشغّل خدمات أخرى، يمنع هذا الحد استعلامًا مؤقتًا من دفع تطبيقك إلى استنفاد RAM.
يعني Binder Error: Referenced column "amount" not found عند الاستعلام من Parquet في الغالب أن مخطط الملف ليس المخطط الذي تتذكره. شغّل DESCRIBE SELECT * FROM '/srv/data/orders.parquet'; واقرأ أسماء الأعمدة الفعلية.
كيفية الاختيار عمليًا
اسأل عن نمط الكتابة. تعني عمليات الكتابة الصغيرة الكثيرة التي يجب أن تبقى بعد انقطاع الطاقة أن SQLite هو الخيار المناسب. اسأل عن نمط القراءة. تعني عمليات المسح الكامل مع التجميع على سجل تاريخي طويل أن DuckDB هو الخيار المناسب. تجيب معظم الأنظمة الفعلية بنعم عن السؤالين، والاستجابة الصحيحة هي منح كل محرك الجزء الذي يجيده بدلًا من إجبار أحدهما على تغطية الجزء الآخر.
تجنب نقل حالة التطبيق النشطة إلى DuckDB لأن أحد التقارير كان بطيئًا. كان التقرير بطيئًا بسبب تخطيط التخزين، ولذلك يكون الحل تصدير البيانات، لا إعادة كتابة مسار الكتابة.
FAQ
هل يمكن أن يحل DuckDB محل SQLite لقاعدة بيانات تطبيقي؟
ليس لقاعدة تُجري عمليات كتابة متكررة. يفرض DuckDB قفل كتابة على ملف قاعدة البيانات بالكامل، ويسمح بعملية واحدة للقراءة والكتابة في كل مرة، وهو مُحسّن للتغييرات المجمّعة بدلًا من عمليات الإدراج في صفوف منفردة. احتفظ بالحالة المعاملاتية في SQLite، ودَع DuckDB يقرأها باستخدام ATTACH '/srv/app/app.db' AS app (TYPE sqlite, READ_ONLY); عند الحاجة إلى تقرير.
هل DuckDB أسرع فعلًا من SQLite في التحليلات؟
نعم، عند إجراء عمليات المسح والتجميع على جدول كبير. والسبب هو تخطيط التخزين، وليس حيلة لضبط الأداء. يقرأ DuckDB الأعمدة التي يذكرها الاستعلام فقط، ويعالج القيم على دفعات، بينما يجب على SQLite المرور عبر الصفوف كاملة للوصول إلى حقل واحد. أما عند جلب صف واحد باستخدام المفتاح الأساسي، فتنقلب النتيجة، لأن SQLite يلمس صفحتين، بينما يلمس DuckDB تخزين كل عمود.
هل أحتاج إلى قدر كبير من RAM لتشغيل DuckDB على VPS؟
لا، لكن خصص له حدًا ومساحة على القرص. افتح ملف قاعدة بيانات بدلًا من :memory: حتى يتمكن DuckDB من تفريغ النتائج الوسيطة إلى القرص، ثم اضبط SET memory_limit = '2GB'; على قيمة يستطيع VPS توفيرها. من دون حد، قد يؤدي GROUP BY كبير واحد إلى رفع Out of Memory Error أو إلى إخراج خدمات أخرى من RAM.
كيف أنقل بيانات SQLite إلى Parquet؟
أرفق ملف SQLite من DuckDB، وانسخ نتائج استعلام مباشرة باستخدام COPY (SELECT * FROM app.orders) TO '/srv/data/orders.parquet' (FORMAT parquet, COMPRESSION zstd);. شغّل ذلك وفق جدول زمني للفترات المغلقة، مثل صفوف الشهر الماضي، واترك الصفوف الحديثة في SQLite حيث يواصل التطبيق الكتابة إليها.
أي قاعدة يجب أن أنسخها احتياطيًا، وكيف؟
كلتاهما، لكن بطرائق مختلفة. أنشئ لقطات من SQLite باستخدام sqlite3 app.db ".backup '/srv/backup/app.db'" بدلًا من cp، لأن قاعدة البيانات قيد التشغيل تُعد أيضًا -wal وملف -shm، وقد تكون النسخة العادية غير مكتملة. لا تتغير ملفات Parquet بعد كتابتها، لذا يكفي نسخ الدليل.