跳到正文
Hacker News· marcobambini·· 9 天前AI 评分62

sqlite-multiwriter 发布:16 线程写入达 49,277 tx/s,为 SQLite 的 5.7 倍

50k tx/SEC with SQLite with multiple writers

AI 导读

sqlite-multiwriter 是一个以 VFS 扩展形式加载的引擎,在不改动 SQLite、SQL 和数据库文件的前提下让多个连接同时写入同一数据库。

正文 · AI 翻译

16 个线程写入一个数据库时,每秒 49,277 次事务,而 SQLite 为 8,630 次(5.7 倍),最慢的千分之一提交耗时 2.08 毫秒,而非 157 毫秒。 16 个进程时,25,987 对 8,636(3.0 倍),1.54 毫秒对 233 毫秒。

SQLite 是世界上使用最广泛的数据库,它有一个众所周知的限制:一个数据库一次只接受一个写入者。当有许多线程或许多进程(例如多个代理在同一个数据库上工作)时,写入者会在一个锁后面排队,或者因 SQLITE_BUSY 而失败并不得不重试。

sqlite-multiwriter 在不改动 SQLite 的情况下移除了这一限制。它是一个 VFS,你将其作为扩展加载(或链接进去):你的 SQLite、你的 SQL 和你的数据库文件保持不变,而多个连接可以同时写入同一个数据库。每次提交都是持久的,文件仍然是普通的 SQLite 数据库。

  • 每秒更多写入。操作不同行(或不同页)的写入者不会互相等待。
  • 几乎没有 SQLITE_BUSY。当写入者操作各自的行时,应用程序大约每 1000 次事务重试 2 次。
  • 线程或进程。一个进程有多个连接,或多个进程操作同一个文件。
  • 无需更改 schema 或 SQL。没有触发器,没有新语法,没有特殊表。

数字对比

16 个写入者在同一个数据库上,对比 WAL 模式的原版 SQLite,synchronous=FULL(每次提交返回时都已落盘)。延迟是从事务开始到其提交的时间,包含重试。最佳结果以粗体显示;测量方法见下方表格。

16 个写入者 SQLite 事务/秒 multiwriter 事务/秒 提升 每 100 次事务的重试次数 最慢的千分之一提交
线程,各自插入自己的行 8,630 49,277 5.7 倍 189 → 0.2 157 → 2.08 毫秒
线程,各自的行位于共享页上(使用 rebase) 13,562 55,098 4.1 倍 35 → 0 584 → 0.93 毫秒
进程,各自插入自己的行 8,636 25,987 3.0 倍 189 → 0.1 233 → 1.54 毫秒
进程,各自的行位于共享页上(使用 rebase) 13,225 51,472 3.9 倍 30 → 0 664 → 16.6 毫秒
线程,全部操作相同的 4 行 13,277 29,976 2.3 倍 24 → 17 609 → 122 毫秒

单个写入者并不比 SQLite 慢(15,959 对 13,746 事务/秒)。当写入者更改相同的行时,它们确实会冲突:提升较小,重试依然存在。

工作原理

每个写入者都有自己的 WAL。它的事务在那里基于数据库的快照运行,提交时会被检查、排序并发布;日志在后台合并到数据库文件中:

writer 1 --> private WAL --+
writer 2 --> private WAL --+--> check and publish --> commit log --> database file
writer 3 --> private WAL --+    (first committer wins)               (compaction)

每个事务都基于数据库的一个快照运行。提交时,其页面会与期间已发布的提交进行核对。如果没有人更改过它写入或读取的内容,它就提交:写入者不会互相等待。如果另一个提交先到达同一页面,该事务会被拒绝并返回 SQLITE_BUSY_SNAPSHOT,应用程序重新运行它(先提交者胜)。提交通过带组提交的日志进行(具有 synchronous=FULL 的 WAL 持久性),并在后台压缩到数据库文件中。

rebase(mw_rebase=1,可选)。两个更改不同行的写入者仍可能落在同一页上,这是提交被拒绝的常见原因。使用 rebase 时,引擎不会拒绝失败者:它取其事务的行更改,将其重新应用到最新状态之上,然后提交。应用程序看不到错误。真正冲突的事务(同一行被两者更改)仍会被拒绝。rebase 仅限于简单事务:INSERT 以及按 key 对行进行 UPDATE/DELETE。参见限制。

隔离是快照隔离加上对事务读取过的页面进行验证:如果另一个提交在快照之后重写了其中某个页面,该提交就会被拒绝,这排除了常见的写偏斜(docs/design.md,“What is guaranteed”)。它是经过测试的,而非被证明的可串行化。应用程序必须重试因SQLITE_BUSY_SNAPSHOT而失败的事务,即整个事务。

两种使用方式

线程 进程(mw_mp=1)
谁写入 一个进程中的多个连接 同一文件上的多个进程(代理、工作进程、一个 CLI 和一个服务器)
共享状态 进程的内存 共享内存以及数据库旁边分段中的日志
平台 全部 全部(在 iOS 应用中用处不大:只有一个进程)

向两者添加mw_rebase=1。只需vfs=multiwriter即可开启引擎(SQLite 连接必须以SQLITE_OPEN_URI打开)。

用sqlite3_open_v2的zVfs参数和普通文件名选择 VFS 并不会开启引擎:数据库会以原版 SQLite 打开,因为 SQLite 也会通过 VFS 打开ATTACH和VACUUM INTO的数据库,而这些必须保持原样。将名称写成带有 VFS 或mw=1的 URI:file:app.db?vfs=multiwriter或file:app.db?mw=1(带SQLITE_OPEN_URI)。

安装

每个版本都有适用于各平台的扩展(releases;make extension会为你所在的机器构建它)。将其加载到允许扩展的 SQLite 中,然后用该 VFS 打开数据库:

.load ./multiwriter                              -- the sqlite3 shell (multiwriter.so, .dylib or .dll)
SELECT mw_version();                             -- 0.5.2

从 C 语言:

sqlite3_enable_load_extension(db, 1);
sqlite3_load_extension(db, "./multiwriter", "sqlite3_multiwriter_init", &err);

以sqlite3_multiwriter_default_init作为入口点也会使其成为默认 VFS。该扩展通过宿主(SQLite 3.14 或更高版本)的例程调用 SQLite。要将引擎和 SQLite 构建为一个库,请运行make:这样 VFS 会在 SQLite 启动时自行注册,URI 就不需要vfs=了。

多线程

用相同的 URI 打开每个连接,并在SQLITE_BUSY_SNAPSHOT时重试:

#define URI "file:app.db?vfs=multiwriter&mw_rebase=1"

/* load the extension once (see Install), then in each thread: */
sqlite3 *db;
sqlite3_open_v2(URI, &db, SQLITE_OPEN_READWRITE | SQLITE_OPEN_CREATE | SQLITE_OPEN_URI, NULL);

for (;;) {
    sqlite3_exec(db, "BEGIN", 0, 0, 0);
    sqlite3_exec(db, "UPDATE account SET balance = balance + 10 WHERE id = 7", 0, 0, 0);
    int rc = sqlite3_exec(db, "COMMIT", 0, 0, 0);
    if (rc == SQLITE_OK) break;
    sqlite3_exec(db, "ROLLBACK", 0, 0, 0);
    if (rc != SQLITE_BUSY_SNAPSHOT && rc != SQLITE_BUSY) break;       /* a real error */
    /* otherwise run the whole transaction again */
}

选项(URI 参数,在打开数据库时读取):

参数 含义
mw 1:引擎,也是 URI 带有vfs=multiwriter时的默认值;0:对该数据库关闭;任何其他值都会导致打开失败
mw_rebase=1 重放仅在共享页面上失败的提交,而不是拒绝它
mw_profile=small 更小的缓存(8 MB 页面,压缩前 16 MB 日志),适用于手机或小型服务器
mw_log_max_mb 日志在被压缩进数据库之前的大小
mw_fullfsync=1 在 macOS 上对日志和压缩使用F_FULLFSYNC
mw_readcheck=0 不验证事务读取过的页面:重试更少,但写偏斜成为可能

完整列表在docs/design.md中。

与原版 SQLite 对比,线程

Apple M5 Pro(18 核,64 GB,SSD,APFS),SQLite 3.53.4 处于 WAL 模式,synchronous=FULL,在全新数据库上运行 8 秒。“重试”是指每 100 个已提交事务中,应用程序在SQLITE_BUSY后不得不再次运行事务的次数;引擎会拒绝冲突的提交,应用程序会再次运行它,最多 1000 次。每行最佳结果以粗体显示。更多场景及所写入的行:docs/benchmarks.md。要重复这些运行:make bench,然后VARIANTS=mw,mwr,sqlite WORKLOAD=bulk python3 bench/compare_sqlite.py threads out.jsonl 8 1 4 16 64(WORKLOAD为bulk、groups或hot)。

每个线程插入自己的行(每个事务 100 行)

线程 SQLite 事务/秒 重试 multiwriter 事务/秒 重试 with rebase 事务/秒 重试
1 13,746 0 15,959 0 15,687 0
4 9,307 128 33,258 0.0 32,898 0.0
16 8,630 189 49,277 0.2 48,275 0.2
64 8,128 398 45,906 1.1 45,689 1.0

每个线程更新自己的行,行共享页面(每个事务一行)

线程 SQLite 事务/秒 重试 multiwriter 事务/秒 重试 with rebase 事务/秒 重试
1 13,401 0 34,911 0 35,885 0
4 13,632 4.6 49,173 0.6 36,628 0
16 13,562 35 37,789 * 8.2 55,098 0
64 13,623 132 40,012 64 39,500 0

* 少数事务在 1000 次重试后放弃。

每个线程更新相同的 4 行(a = a + 1):真正的冲突,任何引擎都无法合并

线程 SQLite 事务/秒 重试 multiwriter 事务/秒 重试 with rebase 事务/秒 重试
1 13,770 0 37,196 0 32,939 0
4 14,248 6.7 36,895 2.5 27,225 7.6
16 13,277 24 29,976 17 28,014 34
64 14,057 101 32,186 81 26,782 142

提交延迟(毫秒,每个线程插入自己的行):从事务开始到其提交的时间,包括重试

线程 SQLite p50 p99 p99.9 multiwriter p50 p99 p99.9
1 0.07 0.09 1.26 0.06 0.08 0.13
4 0.21 1.54 55.8 0.11 0.24 0.69
16 0.23 53.9 157 0.28 1.15 2.08
64 0.26 146 271 1.18 3.97 31.4

SQLite 的中位数在有大量写入者时可能更低:写入者发现数据库繁忙会立即失败,应用程序重试,代价体现在 99.9 百分位上。当写入者争抢同一行时,收益约为 2 倍,而 rebase 无济于事。

多进程

当多个代理写入同一个数据库时使用它。每个进程用 mw_mp=1 打开同一个文件;代码与上面相同,只是 URI 不同:

#define URI "file:agents.db?vfs=multiwriter&mw_mp=1&mw_rebase=1"

这些进程共享一个页面版本索引和一个日志(数据库旁边的 agents.db-mw* 文件)。被终止的进程不会阻塞其他进程:其未完成的事务被丢弃,已提交的事务被保留。最后一个关闭的进程留下一个普通的 SQLite 文件。数据库必须位于本地文件系统上。

对比原生 SQLite,进程

与上述相同的机器和设置;每个写入者是一个单独的进程(WORKLOAD=bulk python3 bench/compare_sqlite.py procs out.jsonl 8 1 4 16)。

每个进程插入自己的行(每个事务 100 行)

进程数 SQLite 事务/秒 重试 multiwriter 事务/秒 重试 带 rebase 事务/秒 重试
1 13,570 0 15,005 0 14,560 0
4 9,176 125 28,489 0.0 27,918 0.0
16 8,636 189 25,987 0.1 26,744 0.1

每个进程更新自己的行,行共享页面(每个事务一行)

进程数 SQLite 事务/秒 重试 multiwriter 事务/秒 重试 带 rebase 事务/秒 重试
1 13,392 0 37,130 0 36,376 0
4 13,865 5.2 49,527 20 49,854 0
16 13,225 30 50,389 102 51,472 0

每个进程更新相同的 4 行(a = a + 1)

进程数 SQLite 事务/秒 重试 multiwriter 事务/秒 重试 带 rebase 事务/秒 重试
1 13,136 0 39,550 0 39,592 0
4 13,385 4.4 43,638 68 43,940 49
16 13,818 24 46,352 110 46,327 100

提交延迟(毫秒,每个进程插入自己的行)

进程数 SQLite p50 p99 p99.9 multiwriter p50 p99 p99.9
1 0.07 0.09 1.27 0.06 0.21 0.28
4 0.22 1.69 69.3 0.15 0.30 0.41
16 0.23 77.9 233 0.60 0.95 1.54

rebase 正是消除了中间表格的重试:进程从不更改同一行,只更改同一页面。

平台

归档和软件包位于 当前发布版。

平台 发布版文件 备注
Linux glibc,x86_64 和 arm64 multiwriter-linux-<arch>-<version>.tar.gz 线程和进程
Linux musl(Alpine),x86_64 和 arm64 multiwriter-linux-musl-<arch>-... 线程和进程
macOS,x86_64 和 arm64 multiwriter-macos-<version>(通用),multiwriter-macos-x86_64-...,multiwriter-macos-arm64-... 线程和进程
iOS、iOS 模拟器、Mac Catalyst multiwriter-ios-...,-ios-sim-...,-mac-catalyst-... 线程
Apple XCFramework multiwriter-apple-xcframework-<version>.zip,以及 Package.swift(Swift Package Manager) iOS、模拟器、Catalyst、macOS
Android arm64-v8a、armeabi-v7a、x86_64、x86 multiwriter-android-<abi>-...,以及 multiwriter-android-aar-<version>.aar API 26 或更高版本,16 KB 页面
Windows x86_64 multiwriter-windows-x86_64-<version>.zip 线程和进程;Windows 10 1709 或更高版本(docs/windows.md)

自行构建:make extension [PLATFORM=macos|ios|ios-sim|mac-catalyst|android|linux|linux-musl|windows] [ARCH=...]、make xcframework、make aar、make package (mk/extension.mk、mk/package.mk)。版本为 MW_VERSION,位于 src/multiwriter.h;推送到 main 会构建并测试每个平台,如果该版本尚未发布,则发布它 (.github/workflows/main.yml)。

构建和测试

git clone https://github.com/sqliteai/sqlite-multiwriter && cd sqlite-multiwriter
make test           # the test suite (about 50 programs, a few minutes)
make test-mp        # the transaction tests with processes (mw_mp=1)
make test-io        # errors of the file system and a full disk (minutes)
make bench          # dist/mw_bench;  bench/compare_sqlite.py: against stock SQLite
test/sanitize.sh asan|ubsan|tsan [tests]

SQLite 3.53.4 已内置在 third_party/sqlite 中;不需要其他东西。设计、保证和限制:docs/design.md。本项目不进行数据库同步;如需同步,请参见 sqlite-sync。

限制

在依赖它之前需要了解的内容(详细信息和测量数据见 docs/design.md)。

数据库和平台

  • 仅支持 WAL:不支持 WAL 以外的 journal_mode,不支持除 none 以外的 locking_mode=EXCLUSIVE 和 auto_vacuum。在新数据库上 PRAGMA page_size 会被忽略。不支持网络文件系统。
  • 数据库文件在打开期间只能通过引擎使用(日志 <db>-mw 及其段保存了尚未写入文件的提交);<db>-mw* 文件是数据库的一部分。它们有版本控制:其他版本的格式会被拒绝,绝不会被错误读取。
  • 堆叠在此之上的 VFS 必须转发共享内存方法(xShm*);如果它保留自己的共享内存,则提交会失败并返回 SQLITE_IOERR。
  • PRAGMA data_version 随每个事务而变化(也包括连接自身的事务)。

隔离和重试

  • 基于页面的首次提交者获胜的快照隔离,加上对读取页面的验证。未证明可串行化:对第 1 页(文件头)的读取未被验证,mw_readcheck=0 会关闭验证(可能产生写偏斜),并且附加数据库上的事务跨文件不是原子的。应用程序必须重试因 SQLITE_BUSY_SNAPSHOT 而失败的事务(整个事务)。
  • 单个数据库的吞吐量受提交发布(在进程模式下约 40 微秒)和真实冲突的限制:同一行上的多个写入者会串行化,而 rebase 对它们没有帮助。

rebase(mw_rebase=1,需选择启用) 会逐行重放仅在与其他事务共享的页面上失败的提交,而不是拒绝它。它永远不会出错,但常常不适用,此时提交会被拒绝并像没有它一样重试:

  • 事务读取了行(SELECT、WITH、VALUES)或运行了不是 点语句 的语句:通过 rowid 或唯一索引对一行执行的 INSERT ... VALUES 或 UPDATE/DELETE,且没有子查询、没有扫描、没有连接;未找到行的 UPDATE/DELETE 算作一次读取。因此 UPDATE t SET n = n + 1 WHERE id = ? 会被重放,而 SELECT 后跟 UPDATE 则不会;
  • DDL、数据库中的触发器或虚拟表、表指向自身的外键或外键环、被其他表引用的键发生更改、键不是 BINARY 或为降序的 WITHOUT ROWID 表、AUTOINCREMENT(每次插入都写入 sqlite_sequence 的同一行)、不是 UTF-8 的数据库、不是其快照首个提交的提交;
  • 在连接上安装自己的 sqlite3_trace_v2 的应用程序会替换引擎的语句钩子:此后 rebase 在该连接上永不适用(引擎会检测到);
  • 计数行更改(sqlite3_total_changes)与页面中实际发现的行更改不完全一致的事务不会被重放:这就是发现空操作 UPDATE(它设置行已有的值:被计数,但未写入页面)的方式,也是发现事务中一行被更改两次、REPLACE、回滚的保存点的方式。

资源

  • 大事务需要其大小两到三倍的内存。永不结束的读取者会阻碍旧页面版本的垃圾回收(目前没有超时,也没有警告)。小步长的 sqlite3_backup_step 只会在不写入的连接上完成。
  • 共享模式下的版本索引大小固定(MW_IDX_ENTRIES);当压缩跟不上时,提交会因 SQLITE_FULL 而失败。
  • 索引的内部页面(因此也包括 WITHOUT ROWID 表的内部页面)被重写后,会使通过它们读取的事务重试:为表 b-tree 省去该重试的捷径对索引不安全。

验证(make test、make test-mp、make test-io、test/sanitize.sh、test/hunt.sh;通过 docs/sqlite-test-suite.md 中的 VFS 运行 SQLite 自带的 Tcl 测试套件):使用线程和被杀进程的随机化可串行化检查、ext4 上的断电测试(仅 Docker)、数千次 hunt 运行,而非数天。未验证:其他文件系统、带进程的 TSan、在硬件上断电的机器。

License

Apache License 2.0:参见 LICENSE 和 NOTICE。内置的 SQLite(third_party/sqlite)属于公有领域。

来源:Hacker News · github.com