SSD Nodes Learn Hosting plans →
מדריכים Matt Connorמאת Matt Connor · עודכן 2026-10-28

DuckDB לעומת SQLite בשרת: למה צריך את שניהם

SQLite שומר מצב עסקאות, ו-DuckDB מנתח קובצי Parquet ו-CSV. גלו מדוע VPS אחד מריץ לרוב את שניהם, עם דוגמה מעשית לכל מנוע.

DuckDB לעומת SQLite בשרת: התשובה במשפט אחד

SQLite הוא מנוע OLTP ‏(עיבוד עסקאות מקוון): הוא מאחסן נתונים בשורות, ונועד לקרוא ולכתוב כמה שורות בכל פעם, באופן בטוח ומהיר. DuckDB הוא מנוע OLAP ‏(עיבוד אנליטי מקוון): הוא מאחסן נתונים בעמודות, ונועד לסרוק מיליוני שורות ולהחזיר תוצאת צבירה אחת. שניהם ספריות משובצות, שניהם פותחים קובץ רגיל, ואף אחד מהם אינו מפעיל תהליך שרת שעליכם לנטר ולתחזק.

לכן התשובה הכנה לשאלה "באיזה מהם לבחור" היא כמעט תמיד "בשניהם, על אותו VPS". היישום שלכם שומר את המצב השוטף שלו ב־SQLite. הדוחות שלכם קוראים קובצי Parquet ו־CSV באמצעות DuckDB. הם אינם מתחרים זה בזה, משום שהם אינם מבצעים את אותה משימה.

מדוע אחסון לפי שורות ואחסון לפי עמודות משנים את התשובה

SQLite כותב שורה כיחידה רציפה אחת בתוך עמוד. שליפת הזמנה אחת לפי המפתח הראשי נוגעת בעמוד אינדקס אחד ובעמוד נתונים אחד, כלומר בשתי פעולות קריאה. זה בדיוק מה שיישום עושה אלפי פעמים בשנייה: קורא משתמש זה, מעדכן את ה־session הזה ומוסיף את ההזמנה הזו.

DuckDB כותב כל עמודה בנפרד ומבצע דחיסה. חישוב הסכום של amount_cents על פני חמישה מיליון שורות קורא רק את העמודה amount_cents, מדלג על כל בית אחר בקובץ ומריץ את החישוב באמצעות קוד וקטורי על אצוות ערכים. שאר העמודות אינן נקראות מהדיסק כלל, וזה מקור המהירות.

כעת הפעילו כל מנוע מול עומס העבודה של המנוע האחר. SQLite, בעת חישוב סכום של עמודה, צריך לעבור על כל השורות ולשלוף את השורה כולה מהעמוד כדי להגיע לשדה אחד. לכן הוא קורא מהדיסק הרבה יותר נתונים מהנדרש. DuckDB, בעת הוספת הזמנה אחת, צריך לגעת באחסון של כל עמודה כדי לכתוב ערך יחיד, והוא נועל לכתיבה את קובץ מסד הנתונים כולו כדי לעשות זאת. אף אחד מהמנועים אינו פגום. כל אחד מהם מתמודד עם שאלה שלשמה לא תוכנן.

היכן SQLite מתאים במיוחד: מצב יישומי טרנזקציוני

בחרו ב־SQLite כאשר הכתיבות קטנות, תכופות ואסור לאבד אותן. הפעלות, הזמנות, רשומות בתור, הגדרות וכל נתון שבקשת web יוצרת.

sudo apt update && sudo apt install -y sqlite3
sudo install -d -o "$USER" -g "$USER" /srv/app

צרו את הטבלה והפעילו write-ahead logging באותו שלב.

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 שמדווח על המצב שאליו המערכת עברה, והוא ההגדרה השימושית ביותר בשרת. במצב ברירת המחדל של rollback journal, תהליך כתיבה חוסם כל תהליך קריאה. במצב WAL, תהליכי קריאה ממשיכים לקרוא את המצב האחרון שאושר, בזמן שתהליך כתיבה אחד מוסיף נתונים. לכן דוח איטי אינו מעכב עוד את בקשת ה־web שממתינה מאחוריו.

בדקו שהשורה התקבלה:

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; בכל חיבור שהיישום פותח. המתנה של חמש שניות מונעת את רוב השגיאות האלה בעומס web רגיל.

היתרון של DuckDB: ניתוח נתונים על קבצים קיימים

בחרו ב-DuckDB כאשר השאלה מתחילה ב"כמה" או "מהם עשרת המובילים", והקלט הוא אוסף של קובצי CSV או Parquet. התקינו את הלקוח לשורת הפקודה, גרסה 2.0.0 (גרסת LTS) נכון ל-27 באוקטובר 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);"

כעת הציגו את השאלה האנליטית. פתחו את ה-shell, הפעילו את הטיימר, ושאלו את הקובץ ישירות ללא שלב ייבוא:

.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 קרא את ה-footer של ה-Parquet, חישב אילו chunks של עמודות נדרשים לשאילתה, וקרא רק אותם. ספרייה שלמה עובדת באותו אופן עם 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 עונה על כל שאלה המשתרעת על פני חודשים, ומסד הנתונים של היישום נשאר קטן, ולכן פעולות הכתיבה אליו מהירות.

הריצו את הייצוא לפי לוח זמנים ולא באופן ידני. זוג המורכב מ־שירות ו־timer של systemd מתאים בדיוק לכך: יחידה אחת שמריצה את COPY, ו־timer אחד שמפעיל אותה מדי לילה.

הפעלת שני המנועים על אותו VPS

שום דבר כאן אינו דורש מכולה, ושום דבר אינו דורש פורט. שני המנועים הם ספריות, ולכן ההתקנה מסתכמת בחבילה ובנתיב לקובץ. אם שאר ה־stack שלך כבר פועל באמצעות Docker Compose באותו VPS, בצע mount של תיקיית הנתונים לתוך המכולה שזקוקה לה, במקום להוסיף שירות מסד נתונים, משום שאין שירות להוסיף.

שני כללים מונעים בעיות בתצורה הזו.

הקצה לכל מנוע תיקייה משלו: /srv/app עבור קובץ SQLite שהיישום כותב אליו, ו־/srv/data עבור קובצי Parquet ש־analytics קורא. כאשר הם משתפים תיקייה, משימת גיבוי שיוצרת snapshot של אחד מהם עלולה להתנגש בפעילות של השני.

אל תפנה שני תהליכים לאותו קובץ מסד נתונים של DuckDB במצב קריאה-כתיבה. רק תהליך אחד יכול להחזיק קובץ DuckDB לכתיבה, והתהליך השני ייכשל כבר בניסיון לפתוח אותו. קוראים רבים יכולים לפעול במקביל, אם כל אחד מהם מגדיר את access_mode = 'READ_ONLY'. הדבר מפתיע משתמשים שמגיעים מ־SQLite, שבו כמה תהליכים משתפים קובץ באופן שגרתי. אם שכבת ה־analytics שלך רק קוראת קובצי Parquet, השאלה אינה מתעוררת כלל. זו סיבה נוספת לשמור את המצב העמיד ב־SQLite.

גיבויים שונים זה מזה, וההבדל עלול לגרום נזק

מסד נתונים פעיל של SQLite מורכב משלושה קבצים. העתקה שלהם באמצעות cp במהלך כתיבה עלולה ליצור קובץ שנפתח, אך תוכנו שגוי. השתמשו בפקודת הגיבוי של המנוע עצמו. הפקודה יוצרת snapshot עקבי בזמן שהיישום ממשיך לכתוב:

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 כאשר ההעתקה תקינה. כל פלט אחר מצביע על snapshot שיש להשליך וליצור מחדש.

קובצי Parquet אינם משתנים לאחר כתיבתם, ולכן אינם דורשים טיפול מיוחד: גבו את התיקייה. שלחו את שני הנתיבים אל מחוץ לשרת באמצעות גיבויי restic מה־VPS שלכם, ושכבת הנתונים כולה תיכלל בשתי תיקיות במסגרת משימת גיבוי אחת.

מצבי כשל והודעות השגיאה המדויקות שתראו

database is locked מ-SQLite, בין אם הוא מגיע דרך ה-driver של היישום שלכם או דרך ה-shell של sqlite3, מציין שחיבור אחר החזיק ב-write lock זמן רב יותר ממה שה-timeout שלכם אפשר. זו אינה שחיתות נתונים. הגדירו 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 מציין שתהליך שני כבר מחזיק את מסד הנתונים פתוח לכתיבה. סגרו את ה-shell האחר, או פתחו את שלכם במצב קריאה בלבד (read only).

Out of Memory Error מ-DuckDB ב-VPS קטן מציין ששאילתה דרשה יותר זיכרון עבודה ממה שהיה זמין. DuckDB מבצע spill לתוצאות ביניים לדיסק כברירת מחדל, וזה כולל סשן :memory:, לכן פתיחת קובץ מסד נתונים אינה מה שגורם ל-spill לעבוד. הוא כותב את הקבצים הזמניים הללו לתיקיית .tmp לצד קובץ מסד הנתונים, או, עבור סשן בזיכרון, לתיקיית .tmp יחסית למיקום שבו התחלתם, לכן התחילו במקום שניתן לכתיבה עם דיסק פנוי או הצביעו על מיקום אחר באמצעות SET temp_directory = '/var/tmp/duckdb';. ההגדרה החשובה היא המכסה: SET memory_limit = '2GB';. ברירת המחדל היא 80% מה-RAM של המכונה, לכן בשרת שמריץ שירותים אחרים, הגבלה זו היא מה שמונע משאילתה אד-הוק לדחוק את היישום שלכם מחוץ ל-RAM. חלק מהפונקציות האגרגטיביות, כגון string_agg(), אינן יכולות לבצע spill, וכך גם חלק מהאגרגציות המבוססות על מיון, לכן שאילתה המבוססת עליהן עדיין עלולה להיתקל בחוסר זיכרון.

Binder Error: Referenced column "amount" not found בעת שאילתת Parquet מציין כמעט תמיד שה-schema של הקובץ אינו כפי שזכרתם. הריצו DESCRIBE SELECT * FROM '/srv/data/orders.parquet'; וקראו את שמות העמודות האמיתיים.

איך לבחור בפועל

שאלו מהו דפוס הכתיבה. כתיבות קטנות רבות שחייבות לשרוד הפסקת חשמל מצביעות על SQLite. שאלו מהו דפוס הקריאה. סריקות מלאות עם חישובי צבירה על היסטוריה ארוכה מצביעות על DuckDB. ברוב המערכות האמיתיות התשובה לשתי השאלות היא חיובית. במקרה כזה, הפתרון הנכון הוא לתת לכל מנוע לטפל בחלק שבו הוא מצטיין, במקום לאלץ אחד מהם לכסות את תחום הפעולה של האחר.

ההעברה שכדאי להימנע ממנה היא העברת מצב חי של היישום אל DuckDB משום שדוח מסוים היה איטי. הדוח היה איטי בגלל פריסת האחסון. לכן התיקון הוא ייצוא, ולא כתיבה מחדש של נתיב הכתיבה.

FAQ

האם DuckDB יכול להחליף את SQLite עבור מסד הנתונים של היישום שלי?

לא אם היישום מבצע כתיבות תכופות. DuckDB נועל את כל קובץ מסד הנתונים לכתיבה, מאפשר תהליך קריאה-כתיבה יחיד בכל רגע נתון, ומותאם לשינויים מרוכזים (bulk) ולא להכנסת שורות בודדות. שמרו את מצב הטרנזקציות ב-SQLite ואפשרו ל-DuckDB לקרוא אותו באמצעות ATTACH '/srv/app/app.db' AS app (TYPE sqlite, READ_ONLY); כאשר נדרש דוח.

האם DuckDB באמת מהיר יותר מ-SQLite עבור אנליטיקה?

עבור סריקות ופעולות אגרגציה על טבלה גדולה, כן. הסיבה לכך היא מבנה האחסון ולא טריק כזה או אחר. DuckDB קורא רק את העמודות המצוינות בשאילתה ומעבד ערכים במקבצים, בעוד ש-SQLite חייב לעבור על שורות שלמות כדי להגיע לשדה אחד. עבור שליפת שורה בודדת לפי מפתח ראשי, המצב מתהפך: SQLite ניגש לשני דפים בלבד, בעוד DuckDB ניגש לאחסון של כל עמודה.

האם אני זקוק להרבה RAM כדי להריץ DuckDB על שרת VPS?

לא, אך הגדירו לו מגבלה ואפשרו לו מקום ל-spill. DuckDB כותב תוצאות ביניים לדיסק כברירת מחדל, גם בסשן :memory:, כל עוד ספריית הזמניים שלו ניתנת לכתיבה ויש בה שטח פנוי. הגדירו את SET memory_limit = '2GB'; לערך שה-VPS שלכם יכול להרשות לעצמו. אם תושאר ברירת המחדל של 80% מה-RAM, שאילתת 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, היכן שהיישום עדיין כותב אותן.

את איזה מהם עלי לגבות, ואיך?

את שניהם, בדרכים שונות. בצעו Snapshot ל-SQLite באמצעות sqlite3 app.db ".backup '/srv/backup/app.db'" במקום cp, כיוון שמסד נתונים פעיל הוא גם -wal וגם קובץ -shm, והעתקה פשוטה עלולה להוביל לקובץ פגום. קובצי Parquet אינם משתנים לעולם לאחר כתיבתם, לכן העתקת הספרייה מספיקה.

האם המאמר הזה היה מועיל?