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, який повідомляє про режим, на який виконано перемикання. Це найважливіше налаштування на сервері. У стандартному режимі rollback journal операція запису блокує всіх читачів. У режимі 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 станом на July 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);"Тепер виконайте аналітичний запит. Відкрийте 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, визначив, які column chunks потрібні запиту, і прочитав лише їх. Цілий каталог працює так само з 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 має послідовно їх обробити. Використовуйте цей підхід для експорту, а не для dashboard, який оновлюється кожні тридцять секунд:
COPY (SELECT * FROM app.orders)
TO '/srv/data/orders-2026-07.parquet' (FORMAT parquet, COMPRESSION zstd);Цей один оператор містить увесь шаблон. SQLite зберігає нещодавні робочі рядки. Запланований експорт перетворює закриті періоди на Parquet. DuckDB відповідає на всі запити за кілька місяців, а база даних застосунку залишається малою, тому операції запису виконуються швидко.
Запускайте експорт за розкладом, а не вручну. Пара service і timer systemd має відповідний масштаб: один unit запускає COPY, а один timer запускає його щоночі.
Запуск обох рушіїв на одному VPS
Для цього не потрібен контейнер і не потрібен порт. Обидва рушії є бібліотеками, тому встановлення зводиться до пакета та шляху до файлу. Якщо решта вашого стека вже працює в Docker Compose на тому самому VPS, змонтуйте каталог із даними в контейнер, якому він потрібен, замість додавання сервісу бази даних. Додавати нічого, оскільки окремого сервісу немає.
Дотримуйтеся двох правил, щоб ця схема працювала без проблем.
Використовуйте окремий каталог для кожного рушія: /srv/app для файла SQLite, у який записує застосунок, і /srv/data для файлів Parquet, які читає аналітика. Якщо вони використовують спільний каталог, завдання резервного копіювання, яке створює snapshot одного рушія, починає конфліктувати з іншим.
Не вказуйте одному файлу бази даних 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, і весь рівень даних складатиметься з двох каталогів в одному завданні резервного копіювання.
Режими відмов і точні рядки, які ви побачите
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 означає, що інший процес уже відкрив цю базу даних для запису. Закрийте іншу оболонку або відкрийте свою в режимі лише для читання.
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 після запису не змінюються, тому достатньо скопіювати каталог.