来源:https://tracewayapp.com/blog/sqlite-vs-duckdb
SQLite 与 DuckDB 在相同的 16 美元服务器上对决:每一个性能悬崖都移动了 100 倍
Jovan Stojiljkovic
· 2026年7月21日
同一台 16.49 美元/月的服务器,相同的 Traceway 二进制文件,两个嵌入式数据库。DuckDB 的写入速度是 SQLite 的 4 到 15 倍,在数据行数多 100 倍时仍能提供服务仪表盘,并将 10 亿个指标点存储在 10.8 GB 的磁盘空间中。完整数据和测试方法见内文。
TL;DR
我花了六天时间对 DuckDB 上的可观测性数据进行基准测试并运行。我在上一篇博文中对 SQLite 做了同样的工作,我非常想看看 DuckDB 在便宜的 Hetzner CCX13 实例上的表现。我测量的方法是将 DuckDB 实现为 Traceway 的有效存储引擎,然后运行其基准测试套件。基准测试表明,DuckDB 的列式引擎能够在没有重大后端更改的情况下查询多 100 倍的数据点。写入吞吐量也提高了 3 到 15 倍。结果是,你现在可以在一个相当小的服务器上自行托管完整的 OTel 栈,处理大量数据。请继续阅读,了解我进行测量的方法!
以下是结果对比:
| 指标写入 | 61,712 点/秒 | 254,242 点/秒 | 4 倍 |
| Span 写入 | 30,508 spans/秒 | 95,737 spans/秒 | 3 倍 |
| 日志写入 | 4,877 条记录/秒 | 75,225 条记录/秒 | 15 倍 |
| 指标读取性能悬崖 | 100 万行(中位数 3.85 秒) | 1 亿行(中位数 3.0 秒) | 100 倍 |
| Span 读取性能悬崖 | 10 万行(中位数 2.27 秒) | 1000 万行(中位数 902 毫秒) | 100 倍 |
| 日志读取性能悬崖 | 10 万行(中位数 114 毫秒) | 1000 万行(中位数 85 毫秒) | 100 倍 |
在本系列中,“读取性能悬崖”指的是真实仪表盘页面仍能加载的最大表大小:三个端点探测的中位数时间在 5 秒或以内,且没有超时。之所以这样命名,是因为一旦超过这个规模,情况就会发生剧变:查询不会变慢,而是直接超时无响应。
在相同的硬件上,DuckDB 上每种信号的读取性能悬崖精确地比 SQLite 远了 100 倍,且延迟相当或更低。我在相信这个结果之前,对照原始 JSON 数据检查了两次这种对称性。该服务器还在不到一小时内向 10.8 GB 的磁盘中摄入了 10 亿个指标点,并且保持稳定运行,这个规模是 SQLite 基准测试从未达到过的(甚至差着 100 倍)。日志,那个博文 2 建议完全不要存储在 SQLite 上的信号,正是 DuckDB 最大的赢点。
实际在比较什么
博文 2 的结尾承诺了与 ClickHouse 的对比,那篇博文仍在准备中。但每当我坐下来构建它时,同样的问题总会浮现:在求助于一个客户端-服务器模式的 OLAP 数据库(它有自己的容器、自己的内存需求、自己的故障模式)之前,一个嵌入式数据库能走多远?Traceway 将 DuckDB 遥测后端作为一个可选构建提供,同一个二进制文件,与 SQLite 构建相同的部署方式,只是在遥测表下使用了不同的存储引擎。如果你在自己的一台廉价服务器上自托管,这两种构建方式才是你真正要面对的选择,而我找不到任何人发布过相关的数据。因此,我针对 DuckDB 构建运行了博文 2 的完整方法论,并将结果并列展示。
具体计划如下。首先是方法论,包括自博文 2 以来测试工具的更改,以及后端本身的一个修复(这些数据依赖于它)。然后是写入测试,三种信号与 SQLite 基线对比。接着是读取测试,格式相同。然后是十亿行运行测试、超过性能悬崖后的查询成本、两种构建方式的集群规模计算,以及我会实际运行哪一种构建。
我的测量方法
设置与博文 2 相同,所以我长话短说:一个运行 Traceway 二进制文件的被测系统(SUT)和一个在私有网络链路上的独立负载生成器,两者都在 Hetzner 纽伦堡数据中心,使用 OTLP(OpenTelemetry 有线协议),通过 gzip 压缩的 protobuf,数据为均匀随机分布且有界范围。Traceway 基于提交 14b4aa6e 构建,Ubuntu 24.04,基准测试期间关闭了数据保留策略。
每个信号有两种测试场景。吞吐量爬坡测试:在固定速率下进行批量大小爬坡,然后在最优批量大小下进行请求速率爬坡(这次使用更细的速率梯度,从 1 到 25 请求/秒,因为有趣的性能悬崖恰好位于博文 2 较粗梯度之间),然后进行模拟 SDK 集群的小批量高速率爬坡。一个步骤通过的条件是错误率低于 5% 且至少达到目标速率的 70%。读取探测测试:将表填充到 100 万、1000 万、1 亿、10 亿行,然后在每个级别为每种信号加载三个真实的仪表盘端点,当一个级别的中位数时间在 5 秒或以内且没有命中 6 秒超时,则该级别通过。5 秒的限值是有意放宽的,博文 2 中对这一点的讨论仍然有效。
与博文 2 的测试工具有三处不同,均已披露:
注意事项。这些是单次运行结果,每种信号只进行了一次吞吐量运行和一次读取梯级测试。博文 2 曾承诺从本文开始提供三次重复的中位数,但我需要再次收回这个承诺:下面的修复和重新运行循环已经耗尽了预算,我宁愿带着这个说明发布单次运行的数据,也不愿让数据继续闲置。关键数据点在多次调试运行中是可复现的(100M 指标读取在不同日期的四次运行中介于 2.7 秒到 3.2 秒),但协议本身是单次运行的。“通过”仍然意味着在空闲服务器上新写入数据上的三个指定端点;在数据摄取过程中同时进行读取仍是未来工作。
还有一项披露,这次是在后端而非测试工具中。我最初的 DuckDB 运行产生了无法信任的数据:服务器在基准测试过程中不断崩溃,排查后发现是我自己的数据摄取路径中的一个真实 bug——它没有准入控制,允许持续的胖批次突发流量导致进程内存耗尽。我通过一个摄取门控修复了它,该门控限制并发处理并在过载时返回 503 及 Retry-After 头(链接如上提交),然后重新运行了所有测试。本文所有数据均来自修复后的后端;修复前的结果仍可见于仓库历史,但它们低估了 DuckDB 的性能(Span 低 23%,日志低近一半),因为崩溃导致爬坡中断。SQLite 从未暴露这个 bug,其较慢的插入路径在压力下会提前报错,这恰恰是博文 2 中日志 5 请求/秒的限制在起作用。
写入性能:4 倍、3 倍、15 倍
| 指标 | 61,712/秒 | 254,242/秒 | 批次 16384 × 20 请求/秒,p50 3.2 秒 | 22.5 请求/秒,12.2% 错误 |
| Span | 30,508/秒 | 95,737/秒 | 批次 16384 × 7.5 请求/秒,p50 1.2 秒 | 8.75 请求/秒,9.7% 错误 |
| 日志 | 4,877/秒 | 75,225/秒 | 批次 16384 × 5 请求/秒,p50 3.0 秒 | 5.625 请求/秒,11.4% 错误 |
在开始之前,我内心对这张表的期望值仅仅是“达到 SQLite 的两倍就算胜利”,而我最担心的指标是日志。博文 2 中最惊人的结果就是日志写入速度被限制在 4,877 条/秒,并在 5 请求/秒处遭遇一个非常陡峭的瓶颈,以至于 1.5 倍的请求量就将错误率从 0% 推至 98%。而这个瓶颈正是变化最大的地方:DuckDB 构建的日志写入速度是 SQLite 的十五倍,那个陡峭的瓶颈变成了一条平滑的拒绝斜坡。SQLite 上限最低、现实世界中数据量最大的信号,恰恰是列式引擎帮助最大的,这要么是后见之明,要么与博文 2 让我预期的一切背道而驰——在这张表出现之前,我一直持有后一种观点。
“最低失败步骤”列与标题列同样重要,这里的“最低”是字面意思:梯级测试在最后一个通过和第一个失败速率之间二分,因此这些是通过失败的最小速率,而非墙上时钟顺序中的第一次失败。该列中的所有失败都是后端主动拒绝工作,而非崩溃:运行日志显示来自准入门控的 503 错误,归档的 JSON 显示其后没有插入失败和丢弃的行,并且容器在所有三次运行结束后都没有重启。这与博文 2 中 SQLite 展示的优雅悬崖行为相同,而 DuckDB 只有在应用了我上面披露的修复后才表现出这一点;在此之前,这张表根本无法产生。
每次爬坡的底部还有一个本系列的首次:模拟 SDK 集群的形态,即每秒数百个小批次而非少量大批次,博文 2 只在 SQLite 上能测量到。DuckDB 在批次大小为 100 时能维持 53,091 指标点/秒、36,577 spans/秒 和 30,442 条日志记录/秒。我早期的尝试从未产生过这些数字——修复前的后端在爬坡的这一阶段开始之前就崩溃了——因此看到它们出现是首次表明重新运行在测量被崩溃掩盖掉的数据。
读取性能:每个性能悬崖,都延后了 100 倍
写入只是前菜;读取才是人们选择列式引擎的原因,也是我最可能失败的地方。博文 2 中被引用最多的是关于“读取性能悬崖”的表述,即仪表盘仍能加载的最后行数。如果 DuckDB 在这方面只是与 SQLite 持平,那么整个构建就只是个噱头。在运行之前,我告诉自己,如果每种信号的悬崖能移动 10 倍,我就满意了。
| 100 万 | 146 毫秒 | 139 毫秒 | 27 毫秒 |
| 1000 万 | 382 毫秒 | 902 毫秒 | 85 毫秒 |
| 1 亿 | 3.0 秒,通过 | 超过 60 秒,失败 | 失败不均匀:正文搜索 3.5 秒,trace-id 2 毫秒,严重性过滤器超过 60 秒 |
| 10 亿 | 填充成功,查询超过 60 秒 | 未达到 | 未达到 |
每个单元格是填充和“消化”后三个真实端点探测的中位数;粗体标记了每种信号最后一个通过的级别。关于此表有两点注释。这些级别是填充目标,且填充会超过目标:100 万级别对于 Span 和日志实际包含约 170 万行,对于指标约 260 万行,而更高级别都在目标的 16% 以内,确切行数在 JSON 中。超过 60 秒的耗时来自第二次梯级运行,其中探测超时被提高到 60 秒,这将在下面单独讨论;标准探测在 6 秒时放弃。我得到了每种信号 100 倍的提升,而非 10 倍:SQLite 最后通过的级别分别是 100 万、10 万和 10 万行,而这三个指标都精确地移动了 100 倍。相同的端点,相同的阈值。
指标读取扩展平缓:100 万行时 146 毫秒,1000 万行时 382 毫秒,1 亿行时 3.0 秒。行数增加一百倍,延迟只增加了二十倍,随着表增长,每行的扫描成本在降低。SQLite 勉强通过的级别,DuckDB 以相同的余量通过了其一百倍大小的级别。
每个端点读取延迟 vs metric_points 表大小,双对数坐标轴。所有三个探测在 100 万行时低于 250 毫秒,在 1000 万行时低于 700 毫秒,在 1 亿行时介于 2.4 到 5 秒之间,仍在阈值之下。
Span 为其百分位查询付出了代价,与在 SQLite 上相同,只是延后了。以下是正在为此付出代价的页面,展示在我本地实例的负载生成器 Span 数据上:
Traceway 在负载生成器 Span 上的端点页面:十三个合成路由,每个约 21.9 万次调用,P50 约 505 毫秒,慢速桶 950 毫秒,这些是负载生成器发出的形态,而非 Traceway 的开销。
该表中的每一行都是其背后 Span 数据的 P50/P95/P99 聚合,其上方的堆叠延迟图是第二个聚合。这两个查询在 1000 万行时耗时 902 毫秒,在 1 亿行时超过一分钟。博文 2 中观察到的相同两个页面在 10 万到 100 万行之间死亡。
每个端点读取延迟 vs spans 表大小,双对数坐标轴。两个百分位聚合探测从 100 万行时的不到 200 毫秒,到 1000 万行时的约 900 毫秒,然后在 1 亿行时超过超时。
日志是我最不信任且检查得最仔细的数字,一次早期的错误运行让我一整天都确信 DuckDB 根本无法读取日志,而事实结果是博文 2 中最有趣的模式在十倍行数上重演了。以下是相关页面,展示在我本地实例的负载生成器日志记录上:
Traceway 在负载生成器数据上的日志页面:包含真实正文如“检测到慢查询”和“通过 stripe 授权付款”的 INFO、WARN 和 ERROR 记录,一个服务列,以及截断的 trace ID。
在它们通过的级别上,日志是三种信号中最快的仪表盘(1000 万行时中位数 85 毫秒),而它们在 1 亿行时的失败与 SQLite 在 100 万行时的失败方式相同:trace-ID 查找仍在 2 毫秒内响应,正文搜索在 3.5 秒内,只有严重性过滤器(匹配并排序 1 亿行中的很大一部分)失效。在事件发生时,值班人员实际打开的页面——查找此 trace、搜索此错误——在 1 亿行时仍然可用;全表扫描则不行。保留策略应该仍然遵循这种区别。只是这个区别延后了 100 倍。
每个端点读取延迟 vs log_records 表大小,双对数坐标轴。trace-id 查找在所有级别都保持在 2 毫秒,正文搜索在 1000 万行时低于 100 毫秒,在 1 亿行时达到 3.5 秒,严重性过滤器在 1 亿行时越过阈值。
在 80 GB 磁盘上的 10 亿行数据
我将 10 亿级别放在梯级测试中,原本期望写一段它如何失败的文字,因为之前的每次尝试都以相同方式结束:容器消失,级别不可读。这次,仅最后一步填充就运行了 52 分钟,以持续 287k 点/秒的速度添加了最后 9 亿个点(网关在削减去超出部分),使表达到 1,001,472,000 行,占磁盘 10.8 GB,列式压缩后约为每点 10.8 字节,整个梯级测试的总摄取时间不到一小时。服务器保持稳定。健康检查在整个过程中都响应,填充后的“消化”耗时 5 秒。博文 1 或 2 中的任何内容都无法达到这个级别的 100 分之一;在修复后的后端上,10 亿行只是一张大表。
一张你无法查看的大表:所有三个仪表盘查询在 10 亿行时都运行超过 60 秒,因此该级别失败,而 50 亿行从未尝试,因为梯级测试在第一次失败时停止。我故意保持这样。一个 50 亿行的填充可以证明磁盘能容纳 540 亿个点,而我已经知道没有任何东西能读取它们。对此梯级测试顶部更实际的总结是:这台服务器可以存储 10 亿个指标点,并且它可以向你展示其中的 1 亿个。
越过性能悬崖后有多慢
博文 2 对于越过悬崖的任何情况只能说“它超时了”。这次,我将整个梯级测试的探测超时提高到 60 秒,以获取失败单元格的真实耗时,但结果是没有耗时可言:每个失败的查询在达到新的上限时仍在运行。1 亿行时的指标,在 61 秒时仍在运行。1000 万行时的 Span 聚合,在 61 秒时仍在运行。1 亿行时的日志严重性过滤器,在 61 秒时仍在运行,而旁边的正文搜索在 3.2 秒内完成。
因此,在两种数据库上,性能悬崖都不是缓坡。查询从一两秒变成超过一分钟,仅仅跨越了行数的一个 10 倍步长,这是聚合超过 4 GB 内存预算并开始溢出的标志。博文 2 在 SQLite 上百分之一规模处发现了相同的现象,这也是我坚持发布“悬崖”而非“曲线”的原因:在悬崖之上不存在一个“缓慢但可用”的区域供你规划。
60 秒运行还暴露了一个需要修复的小缺陷:一个被取消的仪表盘查询不会立即释放其读取池连接,因此在这两个 Span 聚合超时后,一个 3 毫秒的异常查询在它们后面排队等待了 52 秒。它没有影响任何通过的数字,它被列入了与准入门控相同的待修复清单。
在每种构建方式上,什么算“舒适地运行”
性能悬崖是上限;日常重要的是真实集群距离上限还有多远。博文 2 中的小型集群形态——十个后端,每个每秒发出 50 个 Span、200 条日志记录和 10 个指标点——在两种构建方式上都成立:
| Span(500/秒) | 1.7% | 0.5% |
| 日志(2,000/秒) | 40% | 2.7% |
| 指标(100/秒) | 0.2% | 0.04% |
在 SQLite 上,写入除了日志之外已经不是问题;在 DuckDB 上,写入根本无需讨论。真正的调节旋钮仍然是数据保留策略,只是单位变了。在此集群速率下,日志达到其最后通过读取级别(1000 万行)大约需要 85 分钟,达到失败级别需要约 14 小时;Span 达到 1000 万行需要 5.5 小时;指标达到 1 亿行需要 11 天。在 SQLite 上,博文 2 测得日志窗口为 50 秒。一个事件规模的工作集(几小时的 Span 和日志,几天的指标)在 DuckDB 的每个性能悬崖的正确一侧,配合常规的保留任务即可容纳,这是 SQLite 构建无法提供的。
我故意没有测量的内容
- 并发写入下的读取。填充、消化、稳定、探测。比博文 2 更严格的隔离,但仍然不是下午 3 点仪表盘实际经历的情况。这是未来工作的首要任务。
- 混合信号负载。每种信号独立占用服务器;实际部署中三种信号会同时写入,而这些上限不能简单相加。
- 结果正确性。我计时的是响应,没有比较它们的内容。
- 方差。单次运行,如方法学中所述,三中位数的协议仍有待履行。
- 调优。使用的是默认配置,即基准测试 compose 文件默认提供的:4 GB DuckDB 内存上限,256 MB 检查点阈值,无模式或查询更改。10 亿行读取失败看起来像是需要内存预算实验,这是一个有意留下的悬念。
- ClickHouse。博文 2 承诺的对比文章仍会发布,它将基于相同的梯级测试单独成篇,而不是在此处挤占一节。
- 长期持久性。一小时的 10 亿行填充说明不了同一磁盘在第三个月的情况。
让我惊讶的地方
我预期读取会胜出,毕竟这是列式存储的用途,我把标准定为每种信号提升 10 倍。结果三种都实现了 100 倍。但这周让我印象最深的是,数据库从来都不是难点:六天基准测试中每一个看起来不对劲的数字,最终都回溯到了我自己的数据摄取路径,而非引擎。DuckDB,只要我的代码不碍事,就以数据库能做到的最好的方式保持着“无聊”的稳定。
另一个惊喜又是日志。博文 2 的结论是“不要让日志接触 SQLite”,而日志结果却成了支持 DuckDB 构建的最强论据:写入提升 15 倍,读取性能悬崖延后 100 倍,并且在 1 亿行时事件工作流页面仍然响应。
结论
博文 1 声称一台 16 美元的服务器可以运行你的可观测性栈。博文 2 测试了这个说法并附带一个例外返回了结果:日志不行,并且要留意读取性能悬崖。本文收回了那个例外。在同一台服务器上,DuckDB 构建以高于小型集群发送速率的性能写入每种信号,在 SQLite 构建无法企及的行数上提供仪表盘,并将日志——这个博文 2 告诉你不要放在服务器上的信号——变成了本数据集中最佳的结果。十亿个指标点占用了 10.8 GB 磁盘。我这周开始时希望能将博文 2 的数据翻倍;而表中最小改进也是 3 倍。
因此,如果你在单台机器上自托管 Traceway,请运行 DuckDB 构建(仓库中的 docker-compose.duckdb.yml 可以启动它)。在运行之前有一项配置说明:该 compose 文件设置了保守的 2 GB DuckDB 内存上限,并将检查点阈值保持在 DuckDB 默认的 16 MB,而基准测试运行在 4 GB 和 256 MB 下,因此如果你想要这些数字,请相应设置 DUCKDB_MEMORY_LIMIT 和 DUCKDB_CHECKPOINT_THRESHOLD。我只在两种情况下会考虑使用 SQLite 构建,而这两种情况都是真实的。如果你需要一个能在任何 Go 能编译的地方运行的二进制文件,SQLite 胜出:DuckDB 构建需要 CGO、Go 的 C 桥接和 glibc,因此其容器镜像是 Debian 而非 Alpine。如果你的数据量舒适地处于博文 2 的数字之下,SQLite 的存储引擎(所有工作内联执行,无延迟检查点,无需等待“消化”窗口)仍然是最简单有效的方式。但一旦日志变得重要,或需要超过一小时的保留期,这个比较就不再接近了。
整个表上有一个星号:这些数字之所以存在,是因为基准测试首先在我的数据摄取路径中发现了一个崩溃 bug,而我在重新运行所有内容之前修复了它。如果你运行 DuckDB 构建,请运行带有准入门控的版本。引擎从来都不是问题所在。它前面的代码才是,而发现这一点是这次比较产生的最有用的事情。
原始数据 + 工作流程
本文所有内容均可从仓库运行:
- 原始 JSON 位于 benchmarks/blog/post-3-data/:吞吐量、读取探测和 60 秒诊断 JSON,每种信号一个。
- 工作流程:benchmark-hardware.yml。吞吐量:29828430297。读取探测:29838394873。60 秒诊断:29848976962。方法学披露中提到的修复前运行:29734966806 和 29815312404。
- 修复:提交 14b4aa6e,即数据摄取准入门控。
- 尝试 Traceway:五分钟内自托管。
- 问题、反驳或“你的数字因为 X 而错误”:jstojiljkovic941@gmail.com,或在 GitHub 上找我。
下一篇博文:博文 2 承诺的那一篇。在同一台服务器上运行 ClickHouse,相同的负载生成器,相同的梯级测试,看看完整的客户端-服务器栈相对于嵌入式引擎能给你带来什么,以及获得它需要付出什么代价。



