SSD Nodes Learn 8GB RAM — $66/năm
Hướng dẫn Matt ConnorBởi Matt Connor · Cập nhật ngày 2026-08-01

DuckDB vs SQLite: Khi nào dùng cái nào trên VPS?

SQLite tối ưu cho giao dịch OLTP, còn DuckDB xử lý phân tích OLAP trên file Parquet. Bài viết giải thích lý do bạn nên chạy cả hai trên cùng một VPS và ví dụ triển khai thực tế.

DuckDB so với 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 trữ dữ liệu theo hàng và được xây dựng để đọc/ghi một vài hàng tại một thời điểm, đảm bảo an toàn và tốc độ. DuckDB là một engine OLAP (xử lý phân tích trực tuyến): nó lưu trữ dữ liệu theo cột và được xây dựng để quét hàng triệu hàng rồi trả về một kết quả tổng hợp. Cả hai đều là thư viện nhúng, cả hai đều mở một file thông thường và không cái nào chạy tiến trình server mà bạn phải quản lý.

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ữ trạng thái trực tiếp trong SQLite. Hệ thống báo cáo của bạn đọc các file Parquet và CSV bằng DuckDB. Chúng không cạnh tranh vì chúng không thực hiện cùng một công việc.

Tại sao lưu trữ theo hàng và lưu trữ theo cột làm thay đổi câu trả lời

SQLite ghi một hàng dưới dạng một khối dữ liệu liên tục trên một trang. Việc truy xuất một đơn hàng theo khóa chính (primary key) chỉ cần truy cập một trang chỉ mục (index page) và một trang dữ liệu, tức là hai lần đọc. Đây chính xác là những gì một ứng dụng thực hiện hàng nghìn lần mỗi giây: đọc thông tin người dùng này, cập nhật phiên làm việc này, chèn đơn hàng này.

DuckDB ghi riêng biệt từng cột và nén chúng lại. Việc tính tổng amount_cents trên năm triệu hàng chỉ cần đọc cột amount_cents, bỏ qua mọi byte khác trong tệp và chạy phép tính tổng thông qua mã vector hóa trên các lô giá trị. Các cột khác không bao giờ bị đọc từ ổ cứng, đó là lý do tại sao nó đạt tốc độ cao.

Bây giờ hãy thử chạy mỗi engine với khối lượng công việc của engine kia. Khi SQLite tính tổng một cột, nó phải duyệt qua từng hàng và lấy toàn bộ hàng từ trang dữ liệu chỉ để truy cập một trường, vì vậy nó đọc từ ổ cứng nhiều hơn mức cần thiết. Khi DuckDB chèn một đơn hàng, nó phải truy cập vào bộ lưu trữ của mọi cột cho một giá trị duy nhất và nó phải thực hiện khóa ghi (write lock) trên toàn bộ tệp cơ sở dữ liệu để làm việc đó. Không engine nào bị lỗi cả. Mỗi engine đang giải quyết một bài toán mà nó không được thiết kế để xử lý.

Khi nào SQLite chiếm ưu thế: trạng thái ứng dụng giao dịch

Hãy chọn SQLite khi các thao tác ghi dữ liệu nhỏ, diễn ra thường xuyên và không được phép mất mát. Các phiên làm việc, đơn hàng, hàng đợi, cài đặt, bất cứ thứ gì mà một yêu cầu web tạo ra đều phù hợp.

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

Hãy tạo bảng và bật tính năng ghi nhật ký trước (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 kết quả đầu ra là wal. Đó là PRAGMA báo cáo chế độ mà nó vừa chuyển sang, và đây là thiết lập hữu ích nhất trên một server. Ở chế độ rollback journal mặc định, một tiến trình ghi sẽ chặn mọi tiến trình đọc. Trong chế độ WAL, các tiến trình đọc vẫn tiếp tục đọc trạng thái đã commit gần nhất trong khi một tiến trình ghi thực hiện ghi nối tiếp, vì vậy một báo cáo chậm sẽ không còn làm đình trệ yêu cầu web phía sau nó nữa.

Kiểm tra xem hàng dữ liệu đã được trả về chưa:

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 tệp tin nữa đã xuất hiện bên cạnh cơ sở dữ liệu, app.db-walapp.db-shm, và cả hai đều thuộc về nó. Việc chỉ sao chép app.db trong khi ứng dụng đang chạy sẽ tạo ra một bản sao lưu bị lỗi (torn backup), vấn đề này sẽ được đề cập chi tiết hơn ở phần dưới.

SQLite vẫn chỉ cho phép một tiến trình ghi tại một thời điểm. Giới hạn đó là một khóa (lock), không phải là hàng đợi, vì vậy tiến trình ghi thứ hai nếu chờ quá lâu sẽ thất bại với lỗi database is locked thay vì bị chặn vĩnh viễn. Hãy tăng thời gian chờ bằng PRAGMA busy_timeout = 5000; trên mọi kết nối mà ứng dụng của bạn mở. Năm giây kiên nhẫn sẽ loại bỏ hầu hết các lỗi này trong khối lượng công việc web thông thường.

Khi nào DuckDB chiếm ưu thế: phân tích trên các tệp tin bạn đang có

Hãy chọn DuckDB khi câu hỏi bắt đầu bằng "bao nhiêu", "tổng cộng là bao nhiêu" hoặc "top mười là gì", và dữ liệu đầu vào là một tập hợp các tệp CSV hoặc Parquet. Hãy cài đặt client dòng lệnh, phiên bản 1.5.5 tính đến tháng 7 năm 2026:

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

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

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

Tạo một tệp tin thực tế để truy vấn. Lệnh này ghi năm triệu dòng đơn hàng vào định dạng Parquet, được 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ờ hãy đặt câu hỏi phân tích. Mở shell, bật bộ đếm thời gian và truy vấn trực tiếp tệp tin 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;

Hãy tự đọc con số của bạn từ .timer thay vì tin vào con số đã được công bố, vì kết quả phụ thuộc vào ổ đĩa và số lượng core của bạn. Hình thái của nó mới là điều quan trọng. Không hề có CREATE TABLE, không có INSERT và không có bước load: DuckDB đã đọc phần footer của Parquet, xác định các chunk cột nào mà truy vấn cần, và chỉ đọc những phần đó. Toàn bộ thư mục cũng hoạt động theo cách tương tự với glob, FROM '/srv/data/orders-*.parquet', đây là cách mà một tháng dữ liệu xuất ra hàng ngày trở thành một truy vấn duy nhất.

Tốc độ ổ đĩa là nền tảng cho tất cả những điều này, và việc quét cột là một quá trình đọc tuần tự kéo dài, vì vậy khoảng cách giữa NVMe và lưu trữ SATA cũ trên VPS sẽ thể hiện rõ ràng hơn ở đây so với các thao tác đọc ngẫu nhiên nhỏ của SQLite.

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

Hai engine này kết nối với nhau thông qua extension sqlite của DuckDB. Hãy attach cơ sở dữ liệu của ứng dụng ở chế độ chỉ đọc (read only) để đảm bảo truy vấn phân tích không bao giờ ghi đè lên trạng thái thực tế:

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;

Thao tác này đọc các dòng 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 lợi nhưng không nhanh, vì dữ liệu trên đĩa vẫn là định dạng lưu trữ theo dòng (row storage) và DuckDB phải duyệt qua nó. Hãy dùng cách này để xuất dữ liệu, không dùng cho dashboard tải lại mỗi 30 giây:

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

Chỉ một câu lệnh đó là toàn bộ mô hình. SQLite quản lý các dòng dữ liệu thực tế gần nhất. Một tiến trình xuất dữ liệu theo lịch trình sẽ chuyển đổi các khoảng thời gian đã đóng thành định dạng Parquet. DuckDB trả lời mọi câu hỏi kéo dài hàng tháng, còn cơ sở dữ liệu của ứng dụng vẫn giữ được kích thước nhỏ, giúp các thao tác ghi luôn nhanh chóng.

Hãy chạy tiến trình xuất dữ liệu theo lịch trình thay vì chạy thủ công. Một cặp systemd service và timer là giải pháp phù hợp cho việc này: một unit để chạy COPY, một timer để kích hoạt nó hàng đêm.

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

Không có thành phần nào ở đây cần container hay cổng kết nối. Cả hai engine đều là các thư viện, vì vậy việc cài đặt chỉ là một gói phần mềm và một đường dẫn tệp. Nếu phần còn lại trong stack của bạn đã chạy dưới Docker Compose trên cùng một VPS, hãy mount thư mục dữ liệu vào container cần nó thay vì thêm một dịch vụ cơ sở dữ liệu, vì không có dịch vụ nào để thêm cả.

Hai quy tắc giúp cấu hình này tránh được các sự cố.

Hãy cấp cho mỗi engine thư mục riêng: /srv/app cho tệp SQLite mà ứng dụng ghi vào, /srv/data cho các tệp Parquet mà trình phân tích dữ liệu đọc. Khi chúng dùng chung một thư mục, một tác vụ sao lưu thực hiện snapshot tệp này sẽ gây ra xung đột với tệp kia.

Đừng trỏ hai tiến trình vào cùng một tệp cơ sở dữ liệu DuckDB ở chế độ đọc-ghi. Chỉ một tiến trình được phép giữ tệp DuckDB để ghi, tiến trình thứ hai sẽ không thể mở được tệp đó. Nhiều trình đọc có thể hoạt động cùng lúc khi tất cả đều thiết lập access_mode = 'READ_ONLY'. Điều này gây bất ngờ cho những người chuyển từ SQLite sang, nơi nhiều tiến trình thường xuyên chia sẻ một tệp. Nếu trình phân tích của bạn chỉ đọc các tệp Parquet, vấn đề này sẽ không xảy ra, đó là một lý do nữa để lưu trữ trạng thái bền vững trong SQLite.

Sao lưu không giống nhau, và sự khác biệt đó gây ra lỗi

Một cơ sở dữ liệu SQLite đang chạy bao gồm ba tệp, và việc sao chép chúng bằng cp khi đang ghi dữ liệu sẽ tạo ra một tệp có thể mở được nhưng bị lỗi. Hãy sử dụng lệnh sao lưu của chính engine này, lệnh này sẽ tạo ra một bản snapshot nhất quán trong khi ứng dụng vẫn tiếp tục ghi dữ liệu:

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 sẽ in ra ok nếu bản sao hợp lệ. Bất kỳ kết quả nào khác đều có nghĩa là bạn phải loại bỏ bản snapshot đó và thực hiện lại.

Các tệp Parquet không bao giờ thay đổi sau khi đã được ghi, vì vậy chúng không cần xử lý đặc biệt: hãy sao lưu thư mục chứa chúng. Hãy gửi cả hai đường dẫn ra khỏi máy chủ bằng sao lưu restic từ VPS của bạn, và toàn bộ lớp dữ liệu sẽ nằm gọn trong hai thư mục trong một tác vụ sao lưu.

Các chế độ lỗi và chuỗi thông báo chính xác bạn sẽ thấy

Error: database is locked từ SQLite nghĩa là một kết nối khác đã giữ khóa ghi lâu hơn thời gian chờ cho phép của bạn. Đây không phải là lỗi hỏng dữ liệu. Hãy thiết lập PRAGMA busy_timeout trên mọi kết nối, sau đó tìm kiếm các transaction dài lẽ ra nên được chia thành nhiều transaction ngắn.

Error: unable to open database file sau khi thay đổi quyền truy cập thường nghĩa là tiến trình có thể ghi vào tệp nhưng không thể ghi vào thư mục chứa nó. SQLite tạo ra app.db-walapp.db-shm ngay cạnh cơ sở dữ liệu, vì vậy bản thân thư mục phải có quyền ghi, không chỉ riêng tệp .db.

IO Error: Could not set lock on file từ DuckDB nghĩa là một tiến trình khác đã mở cơ sở dữ liệu đó để ghi. Hãy đóng shell kia lại, hoặc mở shell của bạn ở chế độ chỉ đọc.

Out of Memory Error từ DuckDB trên một VPS nhỏ nghĩa là truy vấn cần nhiều bộ nhớ làm việc hơn mức hiện có. DuckDB sẽ ghi dữ liệu tạm ra đĩa khi có thể, vì vậy hãy cung cấp cho nó một nơi để ghi bằng cách mở tệp cơ sở dữ liệu trên đĩa thay vì :memory:, và giới hạn mức tiêu thụ bộ nhớ bằng SET memory_limit = '2GB';. Trên một máy chủ đang chạy các dịch vụ khác, giới hạn đó là thứ ngăn chặn một truy vấn ad hoc đẩy ứng dụng của bạn ra khỏi RAM.

Binder Error: Referenced column "amount" not found khi truy vấn Parquet gần như luôn luôn nghĩa là schema của tệp không giống như bạn nhớ. Hãy chạy DESCRIBE SELECT * FROM '/srv/data/orders.parquet'; và đọc lại tên các cột thực tế.

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

Hãy tự hỏi kiểu ghi dữ liệu là gì. Nhiều thao tác ghi nhỏ cần được bảo toàn khi mất điện đồng nghĩa với việc dùng SQLite. Hãy tự hỏi kiểu đọc dữ liệu là gì. Việc quét toàn bộ dữ liệu kèm các hàm tổng hợp trên lịch sử lâu dài đồng nghĩa với việc dùng DuckDB. Hầu hết các hệ thống thực tế đều trả lời có cho cả hai câu hỏi này, và phản hồi đúng là hãy để mỗi engine xử lý phần việc mà nó làm tốt nhất thay vì ép một trong hai phải đảm nhận toàn bộ.

Việc di chuyển cần tránh là đưa trạng thái ứng dụng đang chạy (live application state) vào DuckDB chỉ vì một báo cáo bị chậm. Báo cáo chậm là do cách bố trí lưu trữ, vì vậy giải pháp là xuất dữ liệu (export), không phải là viết lại đường dẫn ghi (write path) của bạn.

FAQ

DuckDB có thể thay thế SQLite cho cơ sở dữ liệu ứng dụng của tôi không?

Không, nếu ứng dụng của bạn ghi dữ liệu thường xuyên. DuckDB khóa toàn bộ tệp cơ sở dữ liệu khi ghi, chỉ cho phép một tiến trình đọc-ghi tại một thời điểm và được tối ưu hóa cho các thay đổi hàng loạt thay vì chèn từng dòng đơn lẻ. Hãy giữ trạng thái giao dịch trong SQLite và để DuckDB đọc bằng ATTACH '/srv/app/app.db' AS app (TYPE sqlite, READ_ONLY); khi cần xuất báo cáo.

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

Đối với các tác vụ quét và tổng hợp trên bảng lớn thì có, lý do nằm ở cấu trúc lưu trữ thay vì các thủ thuật tinh chỉnh. DuckDB chỉ đọc các cột được truy vấn và xử lý giá trị theo lô, trong khi SQLite phải duyệt qua toàn bộ dòng để lấy một trường dữ liệu. Đối với việc truy xuất một dòng đơn lẻ theo khóa chính thì ngược lại, vì SQLite chỉ truy cập hai trang dữ liệu còn DuckDB phải truy cập vào bộ lưu trữ của mọi cột.

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à sử dụng ổ đĩa. Hãy mở tệp cơ sở dữ liệu thay vì dùng :memory: để DuckDB có thể đẩy các kết quả trung gian xuống đĩa, sau đó đặt SET memory_limit = '2GB'; ở mức mà VPS của bạn có thể đáp ứng. Nếu không giới hạn, một lệnh GROUP BY lớn có thể gây ra Out of Memory Error hoặc đẩy các dịch vụ khác ra khỏi RAM.

Làm thế nào để đưa dữ liệu SQLite vào Parquet?

Gắn tệp SQLite từ DuckDB và sao chép trực tiếp kết quả truy vấn bằng COPY (SELECT * FROM app.orders) TO '/srv/data/orders.parquet' (FORMAT parquet, COMPRESSION zstd);. Hãy chạy lệnh này theo lịch trình cho các giai đoạn đã đóng, ví dụ như các dòng của tháng trước, và giữ các dòng gần đây trong SQLite nơi ứng dụng vẫn đang ghi dữ liệu.

Tôi nên sao lưu cái nào và bằng cách nào?

Cả hai, theo những cách khác nhau. Hãy tạo bản chụp nhanh (snapshot) cho SQLite bằng sqlite3 app.db ".backup '/srv/backup/app.db'" thay vì cp, vì cơ sở dữ liệu đang chạy cũng là một tệp -wal-shm, nên việc sao chép thông thường có thể gây lỗi dữ liệu. Các tệp Parquet không bao giờ thay đổi sau khi đã ghi, vì vậy chỉ cần sao chép thư mục là đủ.

#duckdb#sqlite#database#analytics#parquet#tự lưu trữ