服务器上 DuckDB 和 SQLite 怎么选?
SQLite 保存订单和会话等事务状态,DuckDB 分析 Parquet 与 CSV。本文解释两者为何适合在同一台 VPS 上共存,并提供可运行示例。
服务器上 DuckDB 与 SQLite 的区别:一句话答案
SQLite 是 OLTP 引擎(在线事务处理):它按行存储数据,适合安全、快速地同时读写少量数据。DuckDB 是 OLAP 引擎(在线分析处理):它按列存储数据,适合扫描数百万行并返回一个聚合结果。两者都是嵌入式库,都可以打开普通文件,也都不需要运行必须专门维护的服务器进程。
因此,对于“应该选哪个”这个问题,最实际的答案通常是“在同一台 VPS 上同时使用两者”。应用程序将实时状态保存在 SQLite 中。报表使用 DuckDB 读取 Parquet 和 CSV 文件。两者并不冲突,因为它们负责的任务不同。
行存储与列存储为何会改变结论
SQLite 会将一行作为页面中的一个连续数据块写入。按主键获取一条订单时,需要访问一个索引页和一个数据页,也就是执行两次读取。这正是应用每秒执行数千次的操作:读取某个用户、更新某个会话、插入某个订单。
DuckDB 会分别写入每一列,并对其进行压缩。对 500 万行的 amount_cents 求和时,只需读取 amount_cents 列,跳过文件中的其他所有字节,再通过向量化代码批量处理这些值并完成求和。其他列完全不需要从磁盘读取,速度优势就来自这里。
现在让每个引擎处理对方擅长的工作负载。SQLite 对一列求和时,必须遍历每一行,并从页面中读取整行才能访问其中一个字段,因此读取的磁盘数据远多于实际需要的数据。DuckDB 插入一条订单时,必须为一个值访问每一列的存储区域,还要为此获取整个数据库文件的写锁。两个引擎都没有问题。只是它们各自处理的任务并不是自身设计的目标。
SQLite 的优势场景:事务性应用状态
如果写入数据量小、频率高且不能丢失,请选择 SQLite。会话、订单、队列行、设置,以及 Web 请求创建的任何数据都适用。
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 模式下,一个写入操作追加数据时,读取操作仍可读取最近一次提交的状态。因此,缓慢的报表查询不再阻塞后续的 Web 请求。
检查该行是否已返回:
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; 来延长等待时间。在正常的 Web 工作负载下,等待五秒通常可以消除大多数此类错误。
DuckDB 的优势:分析现有文件中的数据
当问题以“多少”“有多少”或“排名前十的是哪些”开头,输入又是一批 CSV 或 Parquet 文件时,应选择 DuckDB。安装命令行客户端。截至 2026 年 7 月,版本为 1.5.5:
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 读取实际耗时,不要直接相信已发布的数值,因为结果取决于磁盘和 CPU 核心数。重要的是性能表现的趋势。这里没有 CREATE TABLE、没有 INSERT,也没有加载步骤:DuckDB 读取 Parquet 文件尾部,确定查询所需的列块,然后只读取这些列块。使用 glob FROM '/srv/data/orders-*.parquet' 后,整个目录的处理方式也相同;因此,一个月的每日导出文件可以通过一次查询完成分析。
磁盘速度决定了整体性能下限,而列扫描属于长距离顺序读取。因此,在这里,VPS 上的 NVMe 存储与较旧 SATA 存储之间的差距比 SQLite 的小规模随机读取场景更加明显。
从 DuckDB 读取 SQLite 数据库
两个引擎通过 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 服务和计时器 正合适:一个单元运行 COPY,一个计时器每晚触发它。
在同一台 VPS 上运行两者
这里不需要容器,也不需要端口。两个引擎都是库,因此安装内容只是软件包和文件路径。如果堆栈的其余部分已经通过同一台 VPS 上的 Docker Compose运行,请将数据目录挂载到需要使用它的容器中,而不是添加数据库服务,因为这里没有需要添加的服务。
以下两条规则可以避免这种部署方式出现问题。
为每个引擎使用独立目录:/srv/app存放应用写入的 SQLite 文件,/srv/data存放分析程序读取的 Parquet 文件。如果它们共用一个目录,备份任务为其中一个创建快照时,可能会与另一个引擎发生竞争。
不要让两个进程以读写模式打开同一个 DuckDB 数据库文件。一个 DuckDB 文件同时只能由一个进程写入,第二个进程会直接打开失败。多个读取进程可以同时访问,但每个进程都必须设置access_mode = 'READ_ONLY'。这与 SQLite 的行为不同,因此从 SQLite 迁移过来的用户可能会感到意外:SQLite 通常允许多个进程共享同一个文件。如果分析程序只读取 Parquet 文件,就不会遇到这个问题。这也是将持久化状态保存在 SQLite 中的另一个原因。
备份存在差异,而差异可能造成严重问题
正在运行的 SQLite 数据库由 3 个文件组成。在写入过程中使用 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 文件写入后不会改变,因此无需特殊处理:直接备份目录即可。使用 从 VPS 执行 restic 备份 将这两个路径发送到服务器外部。这样,整个数据层只需在一个备份任务中备份两个目录。
故障模式与将看到的确切字符串
Error: database is locked 表示另一个连接持有写锁的时间超过了您设置的超时。这不表示数据损坏。为每个连接设置 PRAGMA busy_timeout,然后查找本应拆分为多个短事务、却持续时间过长的事务。
权限变更后出现 Error: unable to open database file,通常表示进程可以写入文件,但无法写入其所在目录。SQLite 会在数据库旁创建 app.db-wal 和 app.db-shm,因此必须确保目录本身可写,而不仅是 .db 文件可写。
DuckDB 出现 IO Error: Could not set lock on file 表示已有另一个进程以写入模式打开该数据库。关闭另一个 shell,或以只读模式打开当前连接。
在小型 VPS 上,DuckDB 出现 Out of Memory Error 表示查询所需的工作内存超过了可用内存。DuckDB 在条件允许时会将数据溢出到磁盘,因此应将数据库文件放在磁盘上,而不是使用 :memory:,并通过 SET memory_limit = '2GB'; 限制内存使用量。在运行其他服务的服务器上,该限制可以防止临时查询耗尽应用的 RAM。
查询 Parquet 时出现 Binder Error: Referenced column "amount" not found,几乎总是表示文件的 schema 与您记忆中的不一致。运行 DESCRIBE SELECT * FROM '/srv/data/orders.parquet';,读取实际的列名。
实际如何选择
先确认写入模式。大量必须在断电后保留的小规模写入,适合 SQLite。再确认读取模式。需要对长期历史数据执行聚合的全表扫描,适合 DuckDB。大多数实际系统对这两个问题的答案都是“是”。正确做法是让每个引擎负责自己擅长的部分,而不是强行让其中一个引擎替代另一个。
应避免的迁移方式,是因为报表运行缓慢,就把实时应用状态迁移到 DuckDB。报表运行缓慢的原因是存储布局,因此解决方案是导出数据,而不是重写写入路径。
FAQ
DuckDB 可以替代应用数据库中的 SQLite 吗?
如果应用频繁写入,则不能。DuckDB 会锁定整个数据库文件,同时只允许一个读写进程,并且针对批量变更而非单行插入进行了优化。请将事务状态保存在 SQLite 中,需要生成报告时,再通过 ATTACH '/srv/app/app.db' AS app (TYPE sqlite, READ_ONLY); 让 DuckDB 读取这些数据。
DuckDB 在分析方面真的比 SQLite 快吗?
对于大型表的扫描和聚合,是的。原因在于存储布局,而不是某种调优技巧。DuckDB 只读取查询指定的列,并按批次处理数据;SQLite 则必须遍历完整行才能访问某个字段。按主键获取单行时,情况正好相反,因为 SQLite 只需访问两个页面,而 DuckDB 会访问每一列的存储区域。
在 VPS 上运行 DuckDB 需要很多 RAM 吗?
不需要,但应为它设置限制并提供磁盘空间。打开数据库文件,而不是 :memory:,这样 DuckDB 可以将中间结果溢写到磁盘;然后将 SET memory_limit = '2GB'; 设置为 VPS 可以提供的值。如果不设置限制,一次大型 GROUP BY 可能会导致 Out of Memory Error,或将其他服务挤出 RAM。
如何将 SQLite 数据导入 Parquet?
从 DuckDB 附加 SQLite 文件,然后使用 COPY (SELECT * FROM app.orders) TO '/srv/data/orders.parquet' (FORMAT parquet, COMPRESSION zstd); 直接导出查询结果。可以按计划对已关闭的时间段执行此操作,例如导出上个月的行;近期行仍保留在 SQLite 中,因为应用仍会向其中写入数据。
应该备份哪一个,如何备份?
两者都应备份,但方式不同。使用 sqlite3 app.db ".backup '/srv/backup/app.db'" 创建 SQLite 快照,而不是使用 cp,因为运行中的数据库同时也是一个 -wal 和 -shm 文件,直接复制可能得到不完整的文件。Parquet 文件写入后不会改变,因此只需复制所在目录即可。