DuckDB czy SQLite na serwerze: kiedy wybrać który?
Porównanie zastosowań SQLite i DuckDB w architekturze serwerowej. Dowiedz się, dlaczego SQLite obsługuje transakcje, a DuckDB analizy plików Parquet i CSV na jednym VPS.
DuckDB a SQLite na serwerze: odpowiedź w jednym zdaniu
SQLite to silnik OLTP (online transaction processing): przechowuje dane w wierszach i jest zoptymalizowany pod kątem szybkiego oraz bezpiecznego odczytu i zapisu pojedynczych rekordów. DuckDB to silnik OLAP (online analytical processing): przechowuje dane w kolumnach i jest zoptymalizowany pod kątem skanowania milionów wierszy w celu wygenerowania pojedynczej wartości zagregowanej. Oba rozwiązania to biblioteki osadzone, oba otwierają zwykły plik i żadne z nich nie wymaga uruchamiania procesu serwera, którym trzeba zarządzać.
Szczera odpowiedź na pytanie „który wybrać” brzmi niemal zawsze: „oba, na tym samym VPS”. Aplikacja przechowuje swój bieżący stan w SQLite. Moduł raportowania odczytuje pliki Parquet i CSV za pomocą DuckDB. Narzędzia te nie konkurują ze sobą, ponieważ realizują odmienne zadania.
Dlaczego przechowywanie wierszowe i kolumnowe zmienia odpowiedź
SQLite zapisuje wiersz jako jeden ciągły fragment strony. Pobranie jednego zamówienia według klucza głównego wymaga odczytu jednej strony indeksu i jednej strony danych, co daje łącznie dwa odczyty. Jest to dokładnie to, co aplikacja wykonuje tysiące razy na sekundę: odczyt użytkownika, aktualizacja sesji, wstawienie zamówienia.
DuckDB zapisuje każdą kolumnę oddzielnie i poddaje ją kompresji. Sumowanie amount_cents dla pięciu milionów wierszy wymaga odczytu tylko kolumny amount_cents, pominięcia wszystkich pozostałych bajtów w pliku oraz przetworzenia sumy za pomocą wektoryzowanego kodu dla partii wartości. Pozostałe kolumny nigdy nie są odczytywane z dysku, co stanowi źródło wysokiej wydajności.
Teraz należy uruchomić każdy z silników w zadaniach przeznaczonych dla drugiego z nich. SQLite podczas sumowania kolumny musi przejść przez każdy wiersz i pobrać cały wiersz ze strony, aby dotrzeć do jednego pola, przez co odczytuje znacznie więcej danych z dysku niż to konieczne. DuckDB podczas wstawiania jednego zamówienia musi uzyskać dostęp do pamięci każdej kolumny dla pojedynczej wartości i zakłada blokadę zapisu na całym pliku bazy danych. Żaden z silników nie jest uszkodzony. Każdy z nich odpowiada na pytanie, do którego nie został zaprojektowany.
Kiedy SQLite wygrywa: transakcyjny stan aplikacji
Wybierz SQLite, gdy operacje zapisu są małe, częste i nie mogą zostać utracone. Sesje, zamówienia, wiersze kolejek, ustawienia – wszystko, co tworzy żądanie internetowe.
sudo apt update && sudo apt install -y sqlite3
sudo install -d -o "$USER" -g "$USER" /srv/appUtwórz tabelę i włącz tryb write-ahead logging w tym samym kroku.
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);
SQLPierwsza linia wyjścia to wal. Jest to PRAGMA informujący o trybie, na który przełączono bazę, co stanowi najprzydatniejsze ustawienie na serwerze. W domyślnym trybie rollback journal proces zapisu blokuje wszystkich czytających. W trybie WAL czytający odczytują ostatni zatwierdzony stan, podczas gdy jeden proces zapisu dopisuje dane, więc wolny raport nie wstrzymuje już żądania internetowego.
Sprawdź, czy wiersz został zwrócony:
sqlite3 /srv/app/app.db "SELECT * FROM orders WHERE customer = 'ana';"Otrzymasz 1|ana|2026-07-30T09:14:00Z|4200. Obok bazy danych pojawiły się dwa dodatkowe pliki, app.db-wal oraz app.db-shm, które należą do niej. Kopiowanie samego pliku app.db podczas pracy aplikacji prowadzi do powstania niespójnej kopii zapasowej, co zostało omówione w dalszej części.
SQLite nadal zezwala tylko na jednego piszącego w danym momencie. To ograniczenie jest blokadą, a nie kolejką, więc drugi proces zapisu, który czeka zbyt długo, kończy się błędem database is locked zamiast blokować się na stałe. Zwiększ czas oczekiwania za pomocą PRAGMA busy_timeout = 5000; dla każdego połączenia otwieranego przez aplikację. Pięć sekund cierpliwości eliminuje większość tych błędów przy typowym obciążeniu internetowym.
Gdzie DuckDB zyskuje przewagę: analityka na posiadanych plikach
Wybierz DuckDB, gdy pytanie zaczyna się od „ile”, „jaka wartość” lub „które dziesięć najlepszych”, a danymi wejściowymi jest zbiór plików CSV lub Parquet. Zainstaluj klienta wiersza poleceń, wersja 1.5.5 na lipiec 2026:
curl https://install.duckdb.org | shSkrypt instaluje plik binarny w ~/.duckdb/cli/latest/duckdb i wyświetla linię dodającą go do PATH. Potwierdź działanie:
~/.duckdb/cli/latest/duckdb :memory: "SELECT version();"Przygotuj realistyczny plik do zapytania. Poniższa operacja zapisuje pięć milionów wierszy zamówień do formatu Parquet, kompresując je za pomocą 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);"Teraz zadaj pytanie analityczne. Otwórz powłokę, włącz licznik czasu i odpytaj plik bezpośrednio, bez etapu importu:
.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;Odczytaj własny wynik z .timer zamiast polegać na opublikowanych danych, ponieważ rezultat zależy od wydajności dysku i liczby rdzeni procesora. Istotny jest sam charakter operacji. Nie było żadnego CREATE TABLE, żadnego INSERT ani etapu ładowania: DuckDB odczytało stopkę pliku Parquet, określiło, które fragmenty kolumn są potrzebne do zapytania i odczytało tylko je. Cały katalog działa w ten sam sposób przy użyciu wzorca glob, FROM '/srv/data/orders-*.parquet', co pozwala na wykonanie jednego zapytania dla miesiąca codziennych eksportów.
Szybkość dysku stanowi fundament tych operacji, a skanowanie kolumnowe to długi odczyt sekwencyjny, więc różnica między pamięcią NVMe a starszymi nośnikami SATA na VPS jest tutaj wyraźniejsza niż w przypadku małych, losowych odczytów SQLite.
Odczyt bazy danych SQLite z poziomu DuckDB
Oba silniki współpracują ze sobą dzięki rozszerzeniu sqlite w DuckDB. Należy dołączyć bazę danych aplikacji w trybie tylko do odczytu, aby zapytania analityczne nie mogły modyfikować bieżącego stanu:
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;Operacja ta odczytuje wiersze z pliku SQLite w czasie wykonywania zapytania bez tworzenia kopii. Jest to rozwiązanie wygodne, lecz niezbyt wydajne, ponieważ dane na dysku są przechowywane w formacie wierszowym, który DuckDB musi przetworzyć. Należy stosować tę metodę do eksportu danych, a nie w pulpitach nawigacyjnych odświeżanych co trzydzieści sekund:
COPY (SELECT * FROM app.orders)
TO '/srv/data/orders-2026-07.parquet' (FORMAT parquet, COMPRESSION zstd);To jedno polecenie stanowi kompletny wzorzec. SQLite przechowuje bieżące wiersze. Zaplanowany eksport przekształca zamknięte okresy do formatu Parquet. DuckDB odpowiada na wszystkie zapytania obejmujące wiele miesięcy, a baza danych aplikacji pozostaje niewielka, co zapewnia wysoką wydajność operacji zapisu.
Eksport należy uruchamiać zgodnie z harmonogramem, a nie ręcznie. Odpowiednim rozwiązaniem jest para jednostki typu service i timera w systemd: jedna jednostka wykonująca COPY oraz jeden timer uruchamiający ją każdej nocy.
Uruchamianie obu na jednym VPS
W tym przypadku nie jest wymagany kontener ani port. Oba silniki są bibliotekami, więc instalacja sprowadza się do pakietu i ścieżki pliku. Jeśli reszta stosu technologicznego działa już w ramach Docker Compose na tym samym VPS, należy zamontować katalog z danymi w kontenerze, który go potrzebuje, zamiast dodawać usługę bazy danych, ponieważ nie ma żadnej usługi do dodania.
Dwie zasady pozwalają uniknąć problemów w tej konfiguracji.
Należy przydzielić każdemu silnikowi osobny katalog: /srv/app dla pliku SQLite, do którego zapisuje aplikacja, oraz /srv/data dla plików Parquet, z których korzysta analityka. Gdy współdzielą one katalog, zadanie kopii zapasowej wykonujące migawkę jednego z nich wejdzie w konflikt z drugim.
Nie należy wskazywać dwóch procesów na jeden plik bazy danych DuckDB w trybie odczytu i zapisu. Tylko jeden proces może otwierać plik DuckDB w celu zapisu, a drugi nie będzie w stanie go otworzyć. Wielu czytelników może korzystać z pliku, o ile każdy z nich ustawi access_mode = 'READ_ONLY'. Jest to zaskakujące dla osób przechodzących z SQLite, gdzie wiele procesów rutynowo współdzieli plik. Jeśli analityka korzysta wyłącznie z odczytu plików Parquet, problem ten nie występuje, co stanowi kolejny powód, aby przechowywać trwały stan w SQLite.
Kopie zapasowe różnią się między sobą, a te różnice bywają kosztowne
Działająca baza danych SQLite składa się z trzech plików, a ich kopiowanie za pomocą cp w trakcie zapisu prowadzi do powstania pliku, który otwiera się, ale zawiera błędne dane. Należy użyć wbudowanego polecenia kopii zapasowej silnika, które tworzy spójną migawkę w czasie, gdy aplikacja kontynuuje zapis:
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 wyświetla ok w przypadku poprawnej kopii. Każdy inny wynik oznacza, że migawkę należy odrzucić i wykonać kolejną.
Pliki Parquet nigdy nie zmieniają się po zapisaniu, więc nie wymagają specjalnej obsługi: należy wykonać kopię zapasową całego katalogu. Obie ścieżki należy wysłać poza serwer za pomocą kopii zapasowych restic z VPS, dzięki czemu cała warstwa danych będzie sprowadzała się do dwóch katalogów w jednym zadaniu kopii zapasowej.
Tryby awarii i dokładne komunikaty, które zobaczysz
Error: database is locked z SQLite oznacza, że inne połączenie przetrzymywało blokadę zapisu dłużej, niż pozwala na to limit czasu. Nie jest to uszkodzenie bazy. Ustaw PRAGMA busy_timeout dla każdego połączenia, a następnie poszukaj długiej transakcji, którą należało podzielić na kilka krótszych.
Error: unable to open database file po zmianie uprawnień zazwyczaj oznacza, że proces może zapisać plik, ale nie ma uprawnień do jego katalogu. SQLite tworzy app.db-wal oraz app.db-shm obok bazy danych, więc katalog musi być zapisywalny, a nie tylko sam plik .db.
IO Error: Could not set lock on file z DuckDB oznacza, że inny proces ma już otwartą tę bazę danych w trybie zapisu. Zamknij drugą powłokę lub otwórz swoją w trybie tylko do odczytu.
Out of Memory Error z DuckDB na małym VPS oznacza, że zapytanie wymagało więcej pamięci operacyjnej, niż było dostępne. DuckDB zrzuca dane na dysk, gdy jest to możliwe, więc zapewnij mu miejsce na pliki tymczasowe, otwierając bazę danych na dysku zamiast :memory: i ogranicz jego zapotrzebowanie za pomocą SET memory_limit = '2GB';. Na serwerze z innymi usługami ten limit zapobiega sytuacji, w której doraźne zapytanie wypiera aplikację z pamięci RAM.
Binder Error: Referenced column "amount" not found podczas odpytywania Parquet prawie zawsze oznacza, że schemat pliku jest inny, niż zakładasz. Uruchom DESCRIBE SELECT * FROM '/srv/data/orders.parquet'; i odczytaj rzeczywiste nazwy kolumn.
Jak dokonać wyboru w praktyce
Należy ustalić wzorzec zapisu. Wiele małych operacji zapisu, które muszą przetrwać zanik zasilania, wskazuje na SQLite. Należy ustalić wzorzec odczytu. Pełne skanowanie z agregacją w długiej historii wskazuje na DuckDB. Większość rzeczywistych systemów wymaga obu tych rozwiązań, dlatego właściwym podejściem jest przydzielenie każdemu silnikowi zadań, w których się specjalizuje, zamiast wymuszania obsługi całości przez jeden z nich.
Należy unikać migracji aktywnego stanu aplikacji do DuckDB tylko dlatego, że raport działał zbyt wolno. Przyczyną powolnego działania raportu był układ danych, dlatego rozwiązaniem jest eksport, a nie przebudowa ścieżki zapisu.
FAQ
Czy DuckDB może zastąpić SQLite jako baza danych dla mojej aplikacji?
Nie w przypadku aplikacji z częstymi zapisami. DuckDB nakłada blokadę zapisu na cały plik bazy danych, zezwala tylko na jeden proces odczytu-zapisu jednocześnie i jest zoptymalizowana pod kątem operacji masowych, a nie wstawiania pojedynczych wierszy. Stan transakcyjny należy przechowywać w SQLite, a DuckDB wykorzystywać do odczytu za pomocą ATTACH '/srv/app/app.db' AS app (TYPE sqlite, READ_ONLY); w momencie generowania raportu.
Czy DuckDB jest rzeczywiście szybsza od SQLite w analityce?
W przypadku skanowania i agregacji dużych tabel – tak. Wynika to z układu przechowywania danych, a nie z optymalizacji parametrów. DuckDB odczytuje tylko kolumny wskazane w zapytaniu i przetwarza wartości w partiach, podczas gdy SQLite musi przeszukać całe wiersze, aby dotrzeć do jednego pola. Przy pobieraniu pojedynczego wiersza po kluczu głównym sytuacja jest odwrotna, ponieważ SQLite odwołuje się do dwóch stron, a DuckDB musi odczytać pamięć każdej kolumny.
Czy do uruchomienia DuckDB na VPS potrzeba dużo pamięci RAM?
Nie, należy jednak ustawić limit pamięci i wskazać ścieżkę do dysku. Należy otwierać plik bazy danych zamiast używać :memory:, aby DuckDB mogła zapisywać wyniki pośrednie na dysku, a następnie ustawić SET memory_limit = '2GB'; na wartość dostępną dla VPS. Bez limitu jedno duże zapytanie GROUP BY może wywołać Out of Memory Error lub spowodować usunięcie innych usług z pamięci RAM.
Jak przenieść dane z SQLite do formatu Parquet?
Należy podłączyć plik SQLite z poziomu DuckDB i skopiować wynik zapytania bezpośrednio za pomocą COPY (SELECT * FROM app.orders) TO '/srv/data/orders.parquet' (FORMAT parquet, COMPRESSION zstd);. Proces ten warto uruchamiać według harmonogramu dla zamkniętych okresów, na przykład dla wierszy z poprzedniego miesiąca, pozostawiając bieżące dane w SQLite, gdzie aplikacja nadal dokonuje zapisów.
Którą bazę należy kopiować i w jaki sposób?
Obie, ale w różny sposób. Migawki SQLite należy wykonywać za pomocą sqlite3 app.db ".backup '/srv/backup/app.db'" zamiast cp, ponieważ działająca baza danych to również plik -wal oraz -shm, a zwykłe kopiowanie może doprowadzić do uszkodzenia spójności danych. Pliki Parquet nie zmieniają się po zapisaniu, więc wystarczy skopiowanie całego katalogu.