SSD Nodes Learn Hosting plans →
Hướng dẫn Matt ConnorBởi Matt Connor · Cập nhật ngày 2026-08-07

DuckDB vs SQLite trên server: nên dùng cả hai

SQLite giữ state giao dịch, DuckDB phân tích Parquet và CSV. Xem ví dụ chạy cả hai trên cùng một VPS và biết khi nào nên chọn từng engine.

DuckDB và SQLite trên server: câu trả lời trong một câu

SQLite là một engine OLTP (xử lý giao dịch trực tuyến): nó lưu dữ liệu theo hàng và được thiết kế để đọc, ghi một vài hàng mỗi lần, an toàn và nhanh. DuckDB là một engine OLAP (xử lý phân tích trực tuyến): nó lưu dữ liệu theo cột và được thiết kế để quét hàng triệu hàng rồi trả về một giá trị tổng hợp. Cả hai đều là thư viện embedded, đều mở một file thông thường và không chạy server process mà bạn phải thường xuyên giám sát.

Vì vậy, câu trả lời trung thực cho câu hỏi "chọn cái nào" gần như luôn là "cả hai, trên cùng một VPS". Ứng dụng của bạn giữ state đang hoạt động trong SQLite. Công cụ reporting đọc các file Parquet và CSV bằng DuckDB. Chúng không cạnh tranh vì chúng không làm cùng một việc.

Vì sao lưu trữ theo hàng và lưu trữ theo cột làm thay đổi lựa chọn

SQLite ghi một hàng thành một phần liên tục trong một page. Khi lấy một order theo primary key, hệ thống chạm vào một index page và một data page, tức là thực hiện hai lần đọc. Đây chính xác là việc một ứng dụng thực hiện hàng nghìn lần mỗi giây: đọc user này, cập nhật session này, chèn order này.

DuckDB ghi riêng từng column và nén dữ liệu. Khi tính tổng amount_cents trên năm triệu hàng, hệ thống chỉ đọc column amount_cents, bỏ qua mọi byte khác trong file và chạy phép tính tổng bằng code vector hoá trên các batch giá trị. Các column khác không bao giờ được đọc từ disk. Đây là lý do DuckDB nhanh.

Bây giờ hãy chạy mỗi engine với workload của engine còn lại. Khi tính tổng một column, SQLite phải duyệt qua mọi hàng và lấy toàn bộ hàng ra khỏi page để truy cập một field, nên đọc nhiều dữ liệu từ disk hơn mức cần thiết. Khi chèn một order, DuckDB phải chạm vào phần lưu trữ của mọi column để ghi một giá trị và phải lấy write lock trên toàn bộ database file. Không engine nào bị lỗi. Mỗi engine đang xử lý một loại truy vấn mà nó không được thiết kế cho.

SQLite phát huy hiệu quả ở đâu: state giao dịch của ứng dụng

Chọn SQLite khi các lần ghi có kích thước nhỏ, xảy ra thường xuyên và không được phép mất dữ liệu. Session, đơn hàng, các dòng trong queue, setting và mọi dữ liệu mà một web request tạo ra.

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

Tạo table và bật write-ahead logging trong cùng một bước.

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

Dòng đầu tiên của output là wal. Đây là PRAGMA cho biết mode mà SQLite đã chuyển sang, và đây là setting hữu ích nhất trên server. Trong mode rollback journal mặc định, một writer sẽ chặn mọi reader. Trong mode WAL, reader tiếp tục đọc state đã commit gần nhất trong khi một writer append dữ liệu. Vì vậy, một report chạy chậm không còn làm request web phía sau bị treo.

Kiểm tra row đã được trả về:

sqlite3 /srv/app/app.db "SELECT * FROM orders WHERE customer = 'ana';"

Bạn nhận được 1|ana|2026-07-30T09:14:00Z|4200. Hai file khác xuất hiện bên cạnh database là app.db-walapp.db-shm, và cả hai đều thuộc về database đó. Nếu chỉ copy app.db trong khi ứng dụng đang chạy, bạn sẽ có một backup không nhất quán. Phần dưới sẽ giải thích vấn đề này.

SQLite vẫn chỉ cho phép một writer tại một thời điểm. Giới hạn này là một lock, không phải một queue. Vì vậy, writer thứ hai nếu chờ quá lâu sẽ fail với database is locked thay vì block vô thời hạn. Tăng thời gian chờ bằng PRAGMA busy_timeout = 5000; trên mọi connection mà ứng dụng mở. Chờ 5 giây thường loại bỏ phần lớn các lỗi này với workload web thông thường.

DuckDB phát huy thế mạnh ở đâu: phân tích các file bạn đã có

Chọn DuckDB khi câu hỏi bắt đầu bằng “bao nhiêu”, “tổng cộng bao nhiêu” hoặc “top ten nào”, và dữ liệu đầu vào là một tập các file CSV hoặc Parquet. Cài CLI, phiên bản 1.5.5 tính đến July 2026:

curl https://install.duckdb.org | sh

Script cài binary vào ~/.duckdb/cli/latest/duckdb và in ra dòng lệnh để thêm nó vào PATH. Xác nhận binary chạy được:

~/.duckdb/cli/latest/duckdb :memory: "SELECT version();"

Tạo một file thực tế để truy vấn. Lệnh này ghi năm triệu dòng order vào Parquet và nén bằng 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);"

Bây giờ đặt câu hỏi phân tích. Mở shell, bật timer và truy vấn trực tiếp file mà không cần bước import:

.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;

Tự đọc kết quả của bạn từ .timer thay vì tin một con số được công bố, vì kết quả phụ thuộc vào ổ đĩa và số core của máy. Điều quan trọng là dạng kết quả. Không có CREATE TABLE, không có INSERT và không có bước load: DuckDB đọc footer của Parquet, xác định những column chunk mà query cần, rồi chỉ đọc các chunk đó. Với cả một thư mục, cách làm cũng tương tự khi dùng glob, FROM '/srv/data/orders-*.parquet'. Nhờ vậy, dữ liệu export hằng ngày trong một tháng có thể trở thành một query duy nhất.

Tốc độ ổ đĩa là giới hạn nền của toàn bộ quy trình này. Column scan là một lần đọc tuần tự dài, nên khác biệt giữa NVMe và storage SATA đời cũ trên VPS thể hiện rõ ở đây hơn so với các lần đọc ngẫu nhiên nhỏ của SQLite.

Đọc cơ sở dữ liệu SQLite từ DuckDB

Hai engine kết nối với nhau thông qua extension sqlite của DuckDB. Attach cơ sở dữ liệu của ứng dụng ở chế độ chỉ đọc để truy vấn analytics không bao giờ ghi vào trạng thái đang chạy:

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;

Lệnh này đọc các row từ file SQLite tại thời điểm truy vấn mà không cần sao chép. Cách này tiện nhưng không nhanh, vì dữ liệu trên disk vẫn được lưu theo dạng row và DuckDB phải duyệt qua dữ liệu. Hãy dùng cách này để export, không dùng cho dashboard reload mỗi ba mươi giây:

COPY (SELECT * FROM app.orders)
  TO '/srv/data/orders-2026-07.parquet' (FORMAT parquet, COMPRESSION zstd);

Một câu lệnh đó là toàn bộ pattern. SQLite quản lý các row mới nhất đang chạy. Một scheduled export chuyển các kỳ đã kết thúc thành Parquet. DuckDB trả lời mọi truy vấn bao quát nhiều tháng, còn cơ sở dữ liệu của ứng dụng vẫn nhỏ nên các thao tác ghi vẫn nhanh.

Hãy chạy export theo lịch thay vì chạy thủ công. Một cặp systemd service và timer là phù hợp cho việc này: một unit chạy COPY, một timer kích hoạt unit đó mỗi đêm.

Chạy cả hai trên cùng một VPS

Phần này không cần container và cũng không cần mở port. Cả hai engine đều là thư viện, nên việc cài đặt chỉ gồm package và đường dẫn file. Nếu phần còn lại của stack đã chạy bằng Docker Compose trên cùng VPS, hãy mount thư mục dữ liệu vào container cần dùng thay vì thêm một database service, vì không có service nào cần thêm.

Hai quy tắc sau giúp cấu hình này hoạt động ổn định.

Dành cho mỗi engine một thư mục riêng: /srv/app cho file SQLite mà ứng dụng ghi dữ liệu, /srv/data cho các file Parquet mà hệ thống analytics đọc. Nếu dùng chung một thư mục, backup job snapshot một bên có thể chạy đồng thời và tranh chấp với bên còn lại.

Không trỏ hai process vào cùng một file database DuckDB ở chế độ read-write. Chỉ một process được phép ghi vào file DuckDB; process thứ hai sẽ không thể mở file. Có thể có nhiều reader nếu tất cả đều đặt access_mode = 'READ_ONLY'. Điều này thường gây bất ngờ cho những người quen với SQLite, nơi nhiều process thường cùng chia sẻ một file. Nếu hệ thống analytics của bạn chỉ đọc các file Parquet, vấn đề này không phát sinh. Đây là thêm một lý do để giữ durable state trong SQLite.

Bản sao lưu khác nhau, và sự khác biệt này gây lỗi

Một cơ sở dữ liệu SQLite đang chạy gồm 3 file. Nếu dùng cp để sao chép chúng giữa lúc đang ghi, bạn sẽ có một file vẫn mở được nhưng dữ liệu không đúng. Hãy dùng lệnh backup riêng của engine. Lệnh này tạo snapshot nhất quán trong khi ứng dụng vẫn tiếp tục ghi:

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 in ok khi bản sao hợp lệ. Nếu nhận được kết quả khác, hãy xóa snapshot đó và tạo bản sao khác.

Parquet không thay đổi sau khi được ghi, nên không cần xử lý đặc biệt: chỉ cần backup thư mục. Gửi cả hai đường dẫn ra khỏi server bằng restic backup từ VPS, khi đó toàn bộ lớp dữ liệu chỉ là 2 thư mục trong một backup job.

Các lỗi thường gặp và chuỗi chính xác bạn sẽ thấy

Error: database is locked từ SQLite có nghĩa là một connection khác đã giữ write lock lâu hơn thời gian timeout cho phép. Đây không phải lỗi hỏng dữ liệu. Đặt PRAGMA busy_timeout trên mọi connection, sau đó tìm transaction chạy lâu trong khi lẽ ra phải tách thành nhiều transaction ngắn.

Error: unable to open database file sau khi thay đổi quyền thường có nghĩa là process có thể ghi vào file nhưng không thể ghi vào thư mục chứa file. SQLite tạo app.db-walapp.db-shm bên cạnh database, vì vậy bản thân thư mục phải có quyền ghi, không chỉ file .db.

IO Error: Could not set lock on file từ DuckDB có nghĩa là một process khác đang mở database đó để ghi. Đóng shell kia hoặc mở database ở chế độ chỉ đọc.

Out of Memory Error từ DuckDB trên VPS nhỏ có nghĩa là query cần nhiều working memory hơn lượng memory hiện có. DuckDB sẽ spill dữ liệu ra disk khi có thể, vì vậy hãy cho nó nơi để spill bằng cách mở database file trên disk thay vì :memory:, đồng thời giới hạn mức sử dụng bằng SET memory_limit = '2GB';. Trên máy đang chạy các service khác, giới hạn này ngăn query ad hoc đẩy ứng dụng của bạn ra khỏi RAM.

Binder Error: Referenced column "amount" not found khi query Parquet gần như luôn có nghĩa là schema của file không giống như bạn nhớ. Chạy DESCRIBE SELECT * FROM '/srv/data/orders.parquet'; rồi đọc lại tên các column thực tế.

Cách lựa chọn trong thực tế

Hãy xác định kiểu ghi dữ liệu. Nếu có nhiều thao tác ghi nhỏ và dữ liệu phải được bảo toàn khi mất điện, hãy dùng SQLite. Hãy xác định kiểu đọc dữ liệu. Nếu cần quét toàn bộ dữ liệu và tính toán tổng hợp trên lịch sử dài, hãy dùng DuckDB. Hầu hết hệ thống thực tế đều có cả hai nhu cầu này. Cách phù hợp là giao cho mỗi engine phần việc mà nó xử lý tốt, thay vì buộc một engine làm thay phần việc của engine kia.

Không nên chuyển state đang được ứng dụng sử dụng sang DuckDB chỉ vì một report chạy chậm. Report chạy chậm vì cách bố trí dữ liệu chưa phù hợp, nên cách sửa là export dữ liệu, không phải viết lại write path.

FAQ

DuckDB có thể thay thế SQLite cho database của ứng dụng không?

Không phù hợp với ứng dụng ghi dữ liệu thường xuyên. DuckDB khóa ghi trên toàn bộ file database, chỉ cho phép một process đọc-ghi tại một thời điểm và được tối ưu cho các thay đổi hàng loạt thay vì insert từng row. Hãy giữ state giao dịch trong SQLite và để DuckDB đọc dữ liệu bằng ATTACH '/srv/app/app.db' AS app (TYPE sqlite, READ_ONLY); khi cần tạo report.

DuckDB có thực sự nhanh hơn SQLite khi phân tích dữ liệu không?

Có, khi quét và tính aggregate trên một table lớn. Lý do nằm ở layout lưu trữ, không phải một mẹo tuning. DuckDB chỉ đọc các column được query yêu cầu và xử lý dữ liệu theo batch, trong khi SQLite phải duyệt toàn bộ row để lấy một field. Khi lấy một row theo primary key thì kết quả ngược lại, vì SQLite chỉ chạm vào hai page còn DuckDB phải chạm vào vùng lưu trữ của mọi column.

Tôi có cần nhiều RAM để chạy DuckDB trên VPS không?

Không, nhưng hãy đặt giới hạn và cấp disk cho nó. Mở một database file thay vì :memory: để DuckDB có thể spill các kết quả trung gian ra disk, sau đó đặt SET memory_limit = '2GB'; thành một giá trị mà VPS của bạn có thể dành ra. Nếu không đặt giới hạn, một GROUP BY lớn có thể làm tăng Out of Memory Error hoặc đẩy các service khác ra khỏi RAM.

Làm cách nào để đưa dữ liệu SQLite vào Parquet?

Attach file SQLite từ DuckDB rồi copy trực tiếp kết quả query bằng COPY (SELECT * FROM app.orders) TO '/srv/data/orders.parquet' (FORMAT parquet, COMPRESSION zstd);. Chạy thao tác này theo lịch cho các kỳ đã đóng, chẳng hạn các row của tháng trước, và giữ các row gần đây trong SQLite vì ứng dụng vẫn ghi dữ liệu vào đó.

Nên backup database nào và backup như thế nào?

Cả hai, theo hai cách khác nhau. Tạo snapshot SQLite bằng sqlite3 app.db ".backup '/srv/backup/app.db'" thay vì cp, vì database đang chạy cũng là một -wal và là một -shm file nên bản copy thông thường có thể bị rách. Parquet file không thay đổi sau khi được ghi, vì vậy chỉ cần copy directory là đủ.