Turso 事务延迟基准测试指南:从零复现 SQLite 与 Turso 的写入延迟对比实验
【免费下载链接】tursoA SQL database in Rust: SQLite-compatible, now also speaking Postgres (experimental). The LLVM of databases.项目地址: https://gitcode.com/GitHub_Trending/tu/turso
本文是 Turso 仓库中事务延迟基准测试(transaction latency benchmark)的完整技术指南。该基准位于 perf/latency 目录,用于测量一个小型写事务从"应该开始执行"到"提交完成"在 SQLite 与 Turso 中的耗时分布,并以 eCDF(经验累积分布函数)图形对比两个引擎在 1、8、16、32 个并发连接下的尾延迟表现。读完本文,你将掌握该基准的完整设计思想(开环负载、无协调遗漏、分阶段采样)、全部环境变量与命令行参数、底层引擎调用链(含 io_uring、MVCC、被动检查点等关键机制),并能直接复现实验、读懂输出文件与绘图结果。
基准测试要回答什么问题
数据库性能对比中最常见的陷阱是"协调遗漏"(coordinated omission):如果测试程序在数据库变慢时停止向其发送新请求,那么数据库"忙于阻塞"的时间段就不会被任何样本覆盖,测得的结果会虚低——一个被写锁卡住的数据库看起来反而"很快",因为它只是暂时没人问它要活干。本基准的核心设计目标,正是把等待写锁、排队阻塞的时间真实地计入延迟样本。
具体做法是:事务按照一个固定的到达时刻表运行,这个时刻表不会因为数据库变慢而调整。turso-txn-latency的 main.rs 模块注释对此有明确的说明:事务的延迟从它"应当开始"的时刻算起,排在前面的事务造成的等待会被计入样本;否则一个阻塞中的数据库会显得很快,因为它忙的时候干脆没被派活,只有它准备好的事务才会被计时——这恰好掩盖了本基准要测量的停顿。
工作量、加载方式、持久化语义、检查点策略和运行规则五大部分构成了基准的方法论骨架,接下来逐一展开。
快速开始
一键运行(完整实验)
在perf/latency目录下执行:
./scripts/run.shrun.sh的工作分为两阶段(见 run.sh):
- 调用
scripts/bench.sh构建基准程序并以默认参数跑完全部测试; - 检查系统是否装有
uv(绘图所需的 Python 包管理器,缺失时会报错退出),然后调用 plot-latency-ecdf.py 绘制 eCDF 图形。
完整实验的行为如下:
- 在两个引擎上分别以 1、8、16、32 个连接各跑 3 次,每次负载为 1000 事务/秒;
- 总耗时约 1 小时,其中大部分时间花在每次运行前让磁盘空闲 1 分钟;
- 每次运行前都会请求
sudo权限,用于对磁盘执行fstrim(告诉驱动哪些块空闲)并清空页面缓存; - 运行环境要求:Rust 工具链、用于绘图的
uv、以及 Linux(Turso 端依赖 io_uring 后端)。
快速试跑
只想快速看效果时,可以用环境变量压缩规模(详见下文参数表):
CONNECTIONS="1 8" REPEATS=1 DURATION=20 ./scripts/run.sh这条命令只跑 2 个连接数、每个引擎各 1 次、每次测量 20 秒。
基准程序本身的帮助
直接运行turso-txn-latency --help可查看 harness 自己的全部参数,其中有两个值得关注的选项:
--mode immediate:让 Turso 使用 WAL 日志 +BEGIN IMMEDIATE,与 SQLite 的配置方式对齐;--io syscall:让 Turso 使用系统调用 I/O 后端而非 io_uring。
此外,当一次运行未能跟上提供速率(offered rate)时,摘要中会给出 WARNING——此时该次运行画出的曲线展示的是"落后了多少",而不是"延迟是多少"(详见 main.rs 的告警逻辑)。
产物:一次完整实验会得到什么
运行结束后,plot/与db/目录下会生成以下文件:
| 产物 | 说明 |
|---|---|
plot/latency-ecdf.png、.pdf、.tikz | 每个连接数一个 eCDF 面板,SQLite 对 Turso,曲线上标出 p50、p90,另有一条 p99.9 竖线。.tikz是 pgfplots 图片,可在论文中通过\input引入 |
plot/<engine>-c<N>-r<i>.csv | 每次运行一个文件,每个事务一行:应开始时刻、是否处于预热期、重启次数、以及 queue/begin/work/commit 各阶段耗时 |
plot/<engine>-c<N>-r<i>-checkpoints.csv | 该次运行中每次检查点的开始时刻与耗时 |
plot/bench.log | 全部运行的摘要日志,同样输出到终端:分位数、进程自身 CPU 占用、数据库所在磁盘的活动、检查点计时 |
db/<timestamp>/ | 每次运行的数据库文件,永不删除 |
从 CSV 的列结构(write_samples)可以看到每个样本携带的完整信息:
engine,mode,connections,run,thread_id,scheduled_ns,warmup,restarts,queue_ns,begin_ns,work_ns,commit_ns,total_ns其中total_ns即从计划到达时刻到成功 COMMIT 的总延迟(Sample结构体的字段注释见 main.rs);检查点 CSV 的列为engine,mode,connections,run,at_ns,took_ns。
方法论:基准是如何设计的
工作量(Workload)
单张表:test_table(id INTEGER PRIMARY KEY, data TEXT)。每个事务插入BATCH_SIZE(默认 10)行,主键 id 从所有连接共享的一个计数器取号(两个引擎分别用NEXT_ID原子计数器实现,见 sqlite_engine.rs 与 turso_engine.rs),因此:
- 事务之间绝不会触碰同一行,唯一竞争点是引擎自身的写路径;
BEGIN、INSERT、COMMIT三条语句在每个连接上只 prepare 一次,解析 SQL 的开销不计入事务延迟(SQLite 端见 sqlite_engine.rs,Turso 端见 turso_engine.rs,注释明确说明这是为了避免把每次Connection::execute的重复解析计费给事务)。
加载方式(Load):开环 + 抗协调遗漏
- 开环:到达时刻在运行开始前一次性确定(
Pacer持有预生成的due: Vec<Duration>时刻表,见 main.rs),事务在其到达时刻"到期",无论当时是否有空闲连接;哪个连接空闲就领取下一个到期事务。 - 延迟口径:延迟从到期时刻算起,因此排在停滞写者后面的事务,其排队时间计入样本。
- 到达过程:默认为
RATE速率下的泊松过程(指数间隔来自一个带种子的生成器,保证每个引擎、每次重复看到的到达时刻表完全相同);ARRIVALS=fixed则把间隔固定为精确的1/RATE。 - 速率语义:
RATE是所有连接的总量——更多连接意味着同时在途的事务更多,而不是负载更大。这正是 SQLite "同一时刻只能有一个写者"体现得最明显的地方。 - 预热:前
WARMUP秒的事务会被记录但打上 warmup 标记,在所有摘要与图形中排除(绘图脚本会跳过warmup == 1的行,见 plot-latency-ecdf.py)。 - 分阶段采样:每个样本携带到期时刻、重启次数、以及 queue(等待连接)、begin、work(插入)、commit 四段时间。
值得注意的实现细节:Pacer的时钟在第一个连接请求工作时才启动(started_at()使用OnceLock,见 main.rs),因此打开连接与 prepare 语句的开销不会挤占第一批事务的时隙。为了不把线程调度的误差算成数据库的延迟,等待到期采用"睡眠 + 最后 300 微秒自旋"的方式(wait_until);Turso 端在 tokio 计时器之上又叠加了一层处理——tokio 计时器每毫秒才 tick 一次,可能晚醒最多 1 毫秒,因此 tokio 睡眠提前 1.5 毫秒结束,最后一段交给线程睡眠加自旋(turso_engine.rs)。
持久化(Durability)
两个引擎都以PRAGMA synchronous = FULL运行,即每次提交都以 fsync 收尾。这意味着对比的是两条持久提交路径之间的差异,而不是两种持久化设置之间的差异。
SQLite 端的配置
- WAL 模式,通过
rusqlite每个 OS 线程一个连接; - 使用
BEGIN IMMEDIATE。延迟的BEGIN会在第一条INSERT时才获取写锁,若期间其他连接已提交,锁升级会立刻以SQLITE_BUSY_SNAPSHOT失败,事务必须回滚后由应用重试;提前取锁则把等待放进 begin 阶段,由 SQLite 的 busy handler 消化,这正是 SQLite 官方文档为多写者应用推荐的做法(实现见 sqlite_engine.rs)。 - busy 超时 60 秒,使用默认 busy handler(
--timeout默认值 60000 毫秒,见 main.rs)。
Turso 端的配置
- MVCC(
PRAGMA journal_mode = mvcc),每个 OS 线程一个连接、各自持有独立的单线程 tokio runtime,使用BEGIN CONCURRENT,Linux 上默认 io_uring 后端(DEFAULT_IO在 Linux 上为io_uring,见 main.rs)。 - 并发事务互不阻塞,只在提交时串行化——提交即追加日志记录并 fsync(见 README 描述)。
- 发现快照过期的事务会重启(restart),重启次数计入其延迟;由于行不相交,正常情况下不应发生重启,摘要中会报告实际发生的次数。Turso 写入线程捕获
turso::Error::BusySnapshot后执行ROLLBACK并重新进入事务循环(turso_engine.rs),每笔样本的restarts字段记录该事务的重启次数。
这里有一个值得留意的工程细节(turso_engine.rs 的注释):Turso 端刻意不使用共享的多线程 tokio runtime,而是每个连接一个 current-thread runtime。原因是在 syscall I/O 后端下,事务执行过程中没有任何 yield 点,占住 worker 的任务会让 runtime 的计时器无人驱动,其他连接可能错过时隙甚至睡到运行结束。
检查点(Checkpointing)
两个引擎都按照"生产服务器"的方式做检查点:
- 写者自身的自动检查点关闭:SQLite 设
wal_autocheckpoint = 0,Turso 设mvcc_checkpoint_threshold = -1; - 由一条独立连接每隔
CHECKPOINTER毫秒(默认 1000)执行PRAGMA wal_checkpoint(PASSIVE); - Turso 使用被动检查点模式(passive checkpoint),把已提交的版本排入 B-tree 而不阻塞写者;
- 每次检查点的耗时都会被上报,因此可以把写者停顿与造成停顿的检查点一一对应。
实现层面,两个引擎各自有一个检查点线程/异步任务:SQLite 端在独立连接上周期执行PRAGMA wal_checkpoint(PASSIVE)(sqlite_engine.rs);Turso 端在独立连接上执行相同 pragma 并排空结果行,错误(通常是 Busy)意味着本轮未完成,下一轮会重试(turso_engine.rs)。Turso 的setup还根据模式组合启用被动检查点:仅在Concurrent + Passive组合下调用Builder::experimental_mvcc_passive_checkpoint(true)(turso_engine.rs),而--mvcc-checkpoint-threshold可覆盖默认的 MVCC 自动检查点阈值(默认约 4.12 MB,见 main.rs)。若--checkpointer 0,则各写者自行在提交路径上自动检查点,即引擎开箱即用的行为。
运行规则(Runs)
- 每次运行从自己的全新空数据库文件开始,测量 60 秒(
DURATION),前 5 秒为预热(WARMUP); - 每次运行前:先
sync,再对数据库所在挂载点执行sudo fstrim,随后让磁盘空闲IDLE(默认 60)秒,最后清空页面缓存(echo 3 | sudo tee /proc/sys/vm/drop_caches,见 bench.sh)。原因是消费级 SSD 会在空闲时排空写缓存、做垃圾回收;不清洗的话,本次运行会继承前几次运行的写积压; - 每个引擎 × 连接数组合重复
REPEATS(默认 3)次; - 每次运行写出独立的样本文件;每次运行的摘要(包括进程自身 CPU 占用与数据库所在磁盘的活动)输出到 stderr 与
plot/bench.log。
磁盘与 CPU 统计来自内核:磁盘统计通过解析/proc/diskstats按设备主次设备号匹配(main.rs),CPU 统计来自getrusage(RUSAGE_SELF)(main.rs)。摘要会打印 p50/p99/p99.9/max 分位数、实际达到的吞吐、进程 CPU 占用占单核与全部硬件线程的百分比、磁盘写入量/每写耗时/繁忙占比、以及检查点 p50/max/总耗时(report)。
配置参数全表
所有设置都是环境变量,由scripts/bench.sh读取,run.sh会调用它,因此两个脚本都接受这些变量:
| 变量 | 默认值 | 含义 |
|---|---|---|
RATE | 1000 | 每秒提供的事务数,为所有连接的总额 |
CONNECTIONS | "1 8 16 32" | 要运行的连接数列表,每个对应一个图形面板 |
REPEATS | 3 | 每个引擎和连接数的运行次数;每次运行写出独立 CSV |
IDLE | 60 | 每次运行前(fstrim之后)让磁盘空闲的秒数,避免本次运行继承之前运行的写积压 |
DURATION | 60 | 每次运行测量的秒数 |
WARMUP | 5 | 测量开始前运行的秒数 |
BATCH_SIZE | 10 | 每个事务插入的行数 |
CHECKPOINTER | 1000 | 独立连接两次检查点之间的毫秒数;0表示让各写者自行检查点 |
ARRIVALS | poisson | poisson用指数间隔围绕1/RATE分布到达时刻;fixed则精确间隔1/RATE |
SEED | 1 | 泊松时刻表的种子;相同种子保证每次运行到达时刻一致 |
OUT | plot/ | CSV 与图形输出目录;运行拒绝覆盖已存在的 CSV |
DB_DIR | db/<timestamp>/ | 数据库文件目录;每次运行在那里获得独立文件 |
bench.sh通过cargo build --release -p turso-txn-latency构建基准程序,并通过仓库根目录的scripts/cargo-target-dir脚本定位构建产物(尊重CARGO_TARGET_DIR设置),随后按连接数 × 引擎 × 重复次数三层循环逐个运行(bench.sh)。每次运行前会检查输出文件是否已存在——存在即报错退出,避免覆盖旧数据(这是"Nothing is ever deleted"原则的一部分)。日志第一行会记录本次会话的完整配置:rate connections repeats idle arrivals seed db。
绘制 eCDF 图形
绘图脚本 plot-latency-ecdf.py 通过uv run执行(run.sh会把所有非 checkpoints 的 CSV 文件作为参数传入):
uv run plot-latency-ecdf.py sqlite-c1.csv sqlite-c8.csv turso-c1.csv turso-c8.csv \ -o latency-ecdf.png -o latency-ecdf.pdf -o latency-ecdf.tikz脚本要点:
- 图形语义:x 轴为延迟(对数刻度,毫秒),y 轴为"延迟不超过该值的事务占比";每条曲线在 p50、p90 处有标记,p99.9 用虚线竖线标出并标注数值,曲线右端即该引擎最慢的事务。
- 面板布局:每个连接数一个面板横向排列、共享坐标轴,在同一面板内对比两个引擎,跨面板观察并发数的影响。
- 同配置多次运行合并:同一引擎、同一连接数的多次运行样本会被合并为一条曲线(
read_series按(engine, mode, connections)分组并np.concatenate)。 - 颜色方案:SQLite 与 Turso 使用 Okabe-Ito 色盲安全配色(橙
#E69F00与蓝#0072B2),这也是系统论文常用的配色;遇到未知引擎时回退到备选色板。 - LaTeX 输出:
.tikz/.tex输出生成 pgfplots 的groupplots图片,按\linewidth自适应尺寸,论文中可直接\input(要求 pgfplots 的groupplots库与\pgfplotsset{compat=1.18})。 - 可选参数:
--column可选择绘制total_ns(默认)、queue_ns、begin_ns、work_ns、commit_ns中的任意一列,用于分解延迟构成;--name ENGINE=NAME可自定义图例名称(如turso=Limbo)。 - 曲线点采样采用"均匀网格 + 从最慢样本反向的 log 网格"双网格抽稀,确保最末尾的几个事务也能被单独画出来(
Curve.points)。
读取摘要输出
每次运行的摘要(写入终端与bench.log)格式如下,各字段含义与告警条件都可从 report 对应:
[turso/concurrent] N transactions in 60.0s, 1000/s achieved [turso/concurrent] p50 0.23ms p99 1.05ms p99.9 2.10ms max 4.87ms [turso/concurrent] cpu: user 3.2s sys 1.1s 7% of one core over 60.0s, 0.2% of 32 hardware threads [turso/concurrent] disk nvme0n1: 4500 writes, 22.5 MB written, 0.50ms per write, busy 1% of the run [turso/concurrent] checkpointer: 60 checkpoints, p50 0.4ms max 1.2ms total 30ms(以上数值仅为示意格式,非实测数据。)需要特别留意两种告警:
WARNING: gave up after running Nx past the planned time. This database cannot sustain RATE transactions/s, so the tail here is cut short rather than measured.—— 运行超时被截断,尾部数据缺失;WARNING: only reached X/s of the Y/s offered. The latencies are real, but this database is past saturation.—— 实际吞吐低于提供速率的 95%,数据库已过饱和,此时曲线代表的是"落后程度"而非真实延迟。
Turso 端还会额外打印一行(turso_engine.rs):
[turso] io backend io_uring, checkpoint mode Passive, 0 transaction restarts用于核对 I/O 后端、检查点模式与重启次数。
基准的工程结构一览
该基准是工作区成员perf/latency(见根目录 Cargo.toml),包名为turso-txn-latency,二进制入口为main.rs(Cargo.toml),依赖rusqlite、tokio、turso(工作区路径bindings/rust)、clap与libc。核心模块划分:
- main.rs:参数解析、
Pacer到达时刻表、Sample/Checkpoint/Run数据结构、CPU/磁盘统计、摘要报告与 CSV 写出; - sqlite_engine.rs:SQLite 端——WAL、
BEGIN IMMEDIATE、busy handler、独立检查点连接; - turso_engine.rs:Turso 端——MVCC、
BEGIN CONCURRENT、快照过期重启、tokio current-thread runtime、被动检查点; - plot-latency-ecdf.py:eCDF 绘图与 pgfplots 生成。
适用前提与限制
- 平台:需要 Linux(Turso 端默认 io_uring 后端,
DEFAULT_IO在非 Linux 平台回退为syscall,磁盘统计在非 Linux 上不可用)。 - 权限:完整流程需要
sudo(fstrim与清空页面缓存);无 sudo 环境可跳过这两个步骤,但要注意测量结果会受磁盘写积压影响。 - 工具链:Rust(
cargo)、uv(绘图)。 - 时长:默认完整实验约 1 小时,其中大部分是每次运行前的 60 秒磁盘空闲期;快速试跑请用
CONNECTIONS="1 8" REPEATS=1 DURATION=20这类参数组合。 - 结果解读:只有能跟上提供速率(achieved ≥ 95% × RATE)的运行,其曲线才反映真实延迟分布;过饱和运行的曲线应视为掉队程度而非延迟。
【免费下载链接】tursoA SQL database in Rust: SQLite-compatible, now also speaking Postgres (experimental). The LLVM of databases.项目地址: https://gitcode.com/GitHub_Trending/tu/turso
创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考