sqlite-multiwriter 发布:16 线程写入达 49,277 tx/s,为 SQLite 的 5.7 倍
50k tx/SEC with SQLite with multiple writers
sqlite-multiwriter 是一个以 VFS 扩展形式加载的引擎,在不改动 SQLite、SQL 和数据库文件的前提下让多个连接同时写入同一数据库。
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