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: один unit запускає COPY, а один timer запускає його щоночі.
Запуск обох на одному 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. Увесь рівень даних можна охопити одним завданням резервного копіювання, що містить два каталоги.
Типові помилки та точні рядки, які ви побачите
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';. На сервері, де працюють інші служби, це обмеження не дає нерегламентованому запиту вичерпати оперативну пам’ять, потрібну вашому застосунку.
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 після запису не змінюються, тому достатньо скопіювати каталог.