最近在压一批边缘设备的数据暂存模块,遇到了一个选型问题:嵌入式KV库里,是选开箱即用、文档齐全的BadgerDB,还是继续用我们内部维护的轻量级自研库sfsDb。我把两个库的事务接口翻来覆去测了两周,中间踩了几个不大不小的坑。今天把这轮对比的完整过程和结论整理出来,顺便把事务性能背后藏着的存储引擎设计差异也说清楚。
先撂一句话:这不是一份告诉你“谁更快”的跑分报告,而是一份“差异到底从哪来、你该选谁”的实战记录。事务性能跟点查、遍历性能完全是两码事,它和存储结构、持久化策略、并发控制、冲突重试、后台压缩回收全都能扯上关系,单一指标根本说明不了问题。
1. 为什么要把两个嵌入式数据库放到擂台上
1.1 项目的真实诉求:事务语义比点查性能更难选
我们这批设备是边缘数据采集盒子,传感器数据先落到本机,网络恢复后再批量上传。上传之后要给每个数据包打上“已处理”标记,还要维护一个简单的任务状态机:运行中、待重试、已结束。这类场景看起来简单,但每一步都踩在事务的边界上。标记写到一半断电,重启后到底算成功还是失败?批量任务状态从“运行中”切到“已完成”,中间穿插着十几个子任务的结果回写,反反复复,最后系统崩溃,状态机可能卡死在半途。
这些需求点查性能好测,真跑起来才发现事务正确性才是最要命的底线。很多嵌入式KV库只做裸读写,不做事务接口,一旦程序在写一半时断电,数据文件就可能处于一个中间状态。sfsDb最开始不走事务接口,后来接任务状态流转时不得不自己拼了一套“写前检查+回滚标记”的方案,效果很差。最后我们只能一边修sfsDb,一边把BadgerDB拉进来对比,看它能不能直接顶上。
1.2 两个参赛者的背景:来历和定位
BadgerDB是Go生态里出镜率很高的嵌入式KV库,出自某个知名开源项目的底层存储,后来被独立维护。它采用LSM树组织数据,配合WAL日志和MVCC多版本机制,事务API比较完备,支持读写事务、快照隔离、冲突检测和重试。我用的是当前v4.x版本,接口稳定,文档也算全。
sfsDb是我们内部长期维护的一个自研KV库,全称可以理解为“简化文件系统数据库”。它的核心设计是append-only日志加内存索引,定期打快照控制日志无限膨胀。为了适应几十兆内存的嵌入式设备,sfsDb刻意不做后台压缩,也不维护多版本数据,事务实现走的是“undo日志回滚+写入全排到单写线程”的路线。
两个库定位差别很大:BadgerDB更像一个服务器级的嵌入式存储引擎,给到外部的功能边界非常清楚;sfsDb更像一个“刚好够用”的底层存储模块,能少做事就少做事。这种差异直接决定了事务性能曲线的形状。
1.3 为什么拿事务开刀而不是比点查
点查和批量写看的是索引结构、缓存命中率这类基础能力,对事务场景没有区分度。应用到真实业务后,事务要处理提交顺序、持久化、锁竞争、隔离级别、冲突重试,任何一个环节做得差都会让整体吞吐暴跌。事务性能是最能暴露“工程取舍”的试金石。
比如一个库点查能做30万QPS,但开启SyncWrites后每条事务强制刷盘,真实事务吞吐可能只有2000 TPS,差距可以到百倍。另一个库点查一般,但事务提交走批量fsync,整体吞吐反而稳定。这种反差在纯读写测试里完全看不出来。所以我选择把两个库的事务接口放在同一套压力标准下打,重点看它们在不同并发、冲突、持久化组合下怎么表现。
2. 先把底层逻辑掰开:两个库的事务模型差异
2.1 BadgerDB:LSM底座上的MVCC事务
BadgerDB的事务层是典型的MVCC模型。读事务启动时获取一个逻辑时间戳,之后所有读操作都基于这个时间戳生成快照视图,读不会阻塞写,写也不会阻塞读。写事务本身没有锁,而是把修改累积到事务内存里,提交时统一检查读取集和写入集是否与其他并发提交产生版本冲突,如果冲突就返回ErrConflict,由调用方决定是否重试。
落盘路径上,事务提交会先写WAL,把本次事务的完整操作序列记录到磁盘,然后更新内存中的memtable,后面再由后台线程将memtable逐层刷到LSM树的不同Level。这个路径保证了崩溃恢复能找回已提交事务,但也意味着每条提交都要经过WAL写入、版本校验、内存结构更新三道关卡。
MVCC给BadgerDB带来的优势是并发读体验好,服务端场景下多线程读不受写锁拖累。代价也很直接:每次提交都要做冲突检测,高冲突率下重试开销会放大好几倍。另外WAL如果开启SyncWrites,每条事务都要等一次fsync,这个等待在高并发时是绝对瓶颈。
2.2 sfsDb:单写者模型下的轻量事务
sfsDb的事务实现完全走另一条路。它把写入任务全部交给一个单写线程,任何事务提交都要排队进入序列,队列前面的事务完成后,下一个事务才能开始写。事务启动时记录当前状态,如果中途失败或主动回滚,就用undo日志把旧值恢复回去。
这个模型避免了传统意义上的并发冲突问题:因为没有两个写事务在同时操作同一条数据,自然不需要冲突检测和版本管理,提交路径非常短。事务提交时,只需要把一串操作追加到数据日志末尾,然后更新内存索引就算完成。sfsDb的日志采用了累积批量fsync策略,不是每个事务都强制落盘,而是攒一批后一次性同步,在崩溃安全性上做一个业务侧可接受的折中。
它的问题也很明显:单写者意味着写入吞吐上限就是单线程的处理能力,8核16G的机器上跑到一定程度后CPU占用率上不去,TPS被锁死。多读并发时,读线程和写线程还要争抢索引锁,读事务一旦拿了快照指针,老版本数据也要保留一段时间,不然读不出来。这套结构很简单,但天花板肉眼可见。
2.3 隔离级别和冲突处理机制对比
| 对比维度 | BadgerDB | sfsDb |
|---|---|---|
| 底层结构 | LSM树 + WAL + ValueLog | append-only日志 + 内存索引 + 定期快照 |
| 并发模型 | 多写多读 + MVCC | 单写线程 + 多读(加锁保护索引) |
| 隔离级别 | Snapshot Isolation | 近似Serializable |
| 冲突处理 | 提交时检测版本冲突,返回错误重试 | 写入串行化,天然规避冲突 |
| 持久化策略 | SyncWrites可选,逐事务fsync | 批量fsync,按批提交窗口落盘 |
| 回滚机制 | 通过版本机制丢弃未提交数据 | 通过undo日志逐条恢复旧值 |
BadgerDB的Snapshot Isolation适合复杂读事务,它保证一个事务里多次读取看到同一个版本。sfsDb因为写入全部串行,事务之间不会看到对方的中间状态,等同一个严格串行化效果,只是吞吐更低。理解这两条线的差异后,再看压测数据就顺理成章了。
3. 压测方案:公平对决的前提是设定相同的“游戏规则”
3.1 六个场景设计:从最保守到最极限
我只测点查询和遍历没有意义,所以把六个场景全部设计成事务型负载,覆盖日常业务能遇到的所有典型模式。
S1是单线程顺序批量写,每条事务写100个key,模拟批量导入上传记录。S2是单线程小事务,每条事务写5个key,模拟任务状态的低频变更。S3是多线程高冲突写,16个goroutine同时写同一个key区间,冲突率压到30%以上。S4是多线程低冲突写,16个goroutine写到不同的key分片,模拟互不干扰的并发业务。S5是多线程读写混合,一半线程做共享key的读事务,一半线程做独立key的写事务,测并发读对写入的影响。S6是同一个场景分别开关持久化同步,测fsync影响的权重。
每个场景跑3轮,第一轮预热1分钟,后面取2分钟有效数据,统计TPS、p50延迟、p99延迟。为了避免缓存污染,每一轮之间重启进程并清空数据目录。
3.2 环境与关键参数
测试机是一台8核心16GB内存的普通Linux服务器,磁盘是NVMe SSD。BadgerDB和sfsDb的数据目录放在同一块盘上,避免跨盘带来的偏差。
BadgerDB通过选项配置开启默认调优参数:SyncWrites按场景控制开关,NumGoroutines设为8,ValueLogFileSize设为1GB,BlockSize保持默认。sfsDb的BufferSize设为64MB,IndexCacheSize设为128MB,日志采用批量fsync模式。除了SyncWrites这类明显的对比变量,没有做额外的倾向性调优,尽量让两个库以各自“开箱即用”的状态参赛。
3.3 压测代码骨架(带关键参数)
BadgerDB的事务提交代码基本长这样:
opts := badger.DefaultOptions("/data/badger") opts.SyncWrites = false opts.NumGoroutines = 8 opts.ValueLogFileSize = 1 << 30 db, err := badger.Open(opts) if err != nil { panic(err) } defer db.Close() for i := 0; i < N; i++ { txn := db.NewTransaction(true) for j := 0; j < 100; j++ { k := []byte(fmt.Sprintf("key-%d-%d", i, j)) if err := txn.Set(k, payload); err != nil { txn.Discard() break } } if err := txn.Commit(); err != nil { // 冲突时 BadgerDB 会返回 badger.ErrConflict txn.Discard() continue } }sfsDb的接口更简单,同样支持Begin、Set、Commit模型:
db, err := sfsdb.Open("/data/sfs", sfsdb.DefaultConfig()) if err != nil { panic(err) } defer db.Close() tx, err := db.Begin() for j := 0; j < 100; j++ { k := fmt.Sprintf("key-%d-%d", i, j) tx.Set([]byte(k), payload) } if err := tx.Commit(); err != nil { tx.Rollback() continue }BadgerDB在冲突场景下,实际业务代码需要做重试循环,否则会丢事务:
for retry := 0; retry < 5; retry++ { txn := db.NewTransaction(true) // 填写事务内容 err := txn.Commit() if err == badger.ErrConflict { txn.Discard() continue } break }sfsDb不需要这层重试,因为写入线程只有一个,事务失败的原因基本只剩IO错误。
4. 分场景实测数据与现象解读
4.1 批量顺序写入:sfsDb的顺风局
S1场景下,每条事务100个key,SyncWrites开启时,BadgerDB的TPS大概在1000上下,p50延迟接近1ms,p99到了2.4ms。sfsDb的TPS能做到2400左右,p50延迟约0.4ms,p99约1.1ms。
这个结果并不意外。BadgerDB开启SyncWrites后,每一次Commit都要等WAL fsync落盘,而fsync本身是毫秒级操作,事务里塞100个key也只是让WAL多写一点数据,收益和成本完全不成比例。sfsDb默认是批量fsync模式,不需要每个事务都等磁盘,几十个事务累积成一批落盘,所以延迟数字更好看。
不过这个优势是拿持久化保证换来的。sfsDb在崩溃时最多丢最后一批已提交事务,BadgerDB在SyncWrites=true时则能保证已提交事务不丢。如果业务能接受“最后几百毫秒内提交的数据可能丢失”,sfsDb在批量写场景下确实吊打BadgerDB。
4.2 高冲突并发事务:两边都难受,但难受的方式不同
S3场景是16个goroutine同时往32个key上反复写,冲突率很高。BadgerDB的表现是TPS掉到450上下,大量事务提交返回ErrConflict,之后业务层重试又进一步加剧竞争,CPU大量花在版本校验和重新构建事务上。
sfsDb这边因为所有写线程排队进入单写线程,虽然外部开16个goroutine,实际写入完全顺序化,TPS稳定在1900附近。它的延迟曲线呈阶梯状:事务本身的处理时间很短,但排在队列后面的事务等待时间拉长。p95以下基本平稳,p99会突然抬升到3ms以上。
这个对比很有代表性。BadgerDB在高冲突下不是写不快,而是冲突处理和重试把吞吐吃掉了。sfsDb是那种“让别人等,但轮到谁谁就快”的模型,牺牲并发度换稳定性。如果业务模型天然高频写同一批key,BadgerDB需要业务侧配合做退避和合并,否则性能会很难看。
4.3 低冲突读写混合:BadgerDB的并发优势
S4和S5两个场景把局面翻了过来。16个goroutine分散写不同分片key时,BadgerDB的低冲突并发写性能非常强,TPS能到9500附近,p50延迟约0.6ms,p99约2ms。sfsDb的TPS停在2100左右,p50延迟约0.8ms,p99约3.5ms。
S5加入一半读事务后差距更明显。BadgerDB的MVCC快照读完全无锁,读线程几乎不干扰写线程,整体TPS依然能维持在9000以上。sfsDb的多读线程要拿索引锁,读事务还要和写事务争时间片,读多写少时写线程被持续打断,p99延迟比纯写场景高了近一倍。
这里能很清楚看到设计哲学的分岔口:BadgerDB通过多版本把读写并发解耦,适合服务端多实例多线程场景;sfsDb把宝押在简单和低资源占用上,并发一高就把短板暴露出来。
4.4 持久化等级:SyncWrites开关改变战局
S6场景里我把两个库都调成不强制每条事务刷盘。BadgerDB关闭SyncWrites后,TPS从1000跳到8000以上,提升幅度接近8倍,直接反超sfsDb的4800。sfsDb关不关单事务sync其实差别不大,因为它本来就是攒批落盘,提升只有百分之十几。
| 测试场景 | BadgerDB (sync on) | sfsDb (sync on) | BadgerDB (sync off) | sfsDb (sync off) |
|---|---|---|---|---|
| S1 单线程100KV批量写 | 1000 TPS | 2400 TPS | 8200 TPS | 4800 TPS |
| S2 单线程5KV小事务 | 4200 TPS | 5100 TPS | 21000 TPS | 7500 TPS |
| S3 多线程高冲突写 | 450 TPS | 1900 TPS | 2600 TPS | 2000 TPS |
| S4 多线程低冲突写 | 3100 TPS | 2100 TPS | 9500 TPS | 2300 TPS |
| S5 多线程读写混合 | 2900 TPS | 1700 TPS | 9100 TPS | 1900 TPS |
从表格里能读出一个关键信息:BadgerDB的默认性能被fsync锁住了一大半,把它关掉后立刻释放原生处理能力。sfsDb采用批量落盘策略,所以不管开关sync,它的写入路径本来就没有逐条等待,吞吐增长有限。选型时必须想清楚业务对崩溃恢复时间点的要求,否则一个fsync策略就能让整个性能对比结论反转。
5. 现象分析:为什么性能差距会这样分布
5.1 WAL落盘策略带来的差距
BadgerDB在SyncWrites=true时,每条事务提交都要走一次fsync。NVMe SSD在低队列深度下单次fsync耗掉0.1到0.5毫秒很正常,如果事务本身花费的时间只有几十微秒,fsync就成了绝对瓶颈。高并发时多个事务同时提交,操作系统有概率把这几次fsync合并成一次,但S1是单线程串行提交,完全享受不到这个红利。
sfsDb的批量fsync设计从源头上避开了逐条等待。它把一批事务的日志写进内存缓冲区,积累到一定大小或时间窗口后才落盘一次。这种设计像坐公交而不是打出租车,单人出发没有优势,满载出发时每个乘客的平摊成本极低。代价是崩溃恢复时丢数据的窗口被放大到整批事务的时间跨度,业务侧必须接受这个折中。
5.2 LSM压缩与Value Log GC:数据越多,账越复杂
S1到S6每个场景只跑2分钟,数据量最大不到10GB,所以BadgerDB的LSM压缩和ValueLog垃圾回收还没成为瓶颈。但真实业务跑几天甚至几周后,压缩线程会频繁启动,把SSTable逐层合并,产生大量写放大。GC也需要不断扫描ValueLog中被覆盖的旧数据,并重新定位存活的value。
sfsDb的日志文件是纯追加的,写入路径不会触发压缩,但随着运行时间拉长,日志会越滚越大,旧版本数据被覆盖后也不能直接在线清理。它通过定期快照把所有有效key重新写成一份新的日志,再删掉旧日志。快照期间写入线程会被阻塞,数据量越大,这个停顿窗口越长。
我在测试之外做了一个模拟长时间运行的压力:写满15GB数据后再看两个库。BadgerDB的事务吞吐开始从峰值下滑,压缩和GC消耗约12%的CPU;sfsDb的读取延迟显著上升,因为内存索引无法无限膨胀,部分key开始映射到日志文件中的旧区间,读路径变成日志扫描加二分查找,性能退化明显。
5.3 sfsDb的轻量设计吃到红利,也看到了天花板
sfsDb赢的场景有一个共同点:写入路径短、没有后台任务、数据量没超过内存索引能力。这本质上是“少做事”带来的红利。当一个KV库不需要维护多版本、不需要压缩、不需要检测冲突时,它在低并发写场景下当然更快。
但这个红利是抛物线形的。一旦并发度提升到8线程以上,sfsDb的单写者队列就开始排队,吞吐被锁死;数据量增长后索引无法全部放内存,读路径开始退化;批量fsync带来的丢数据窗口如果业务不接受,还得强制逐条fsync,那它的吞吐优势也会立刻消失。sfsDb不是不够快,而是它把自己的适用边界画得非常小,越界就崩。
6. 实际项目里的选型建议与调优参数
6.1 什么样的业务适合sfsDb
如果你的业务模型符合下面几个条件,sfsDb反而比BadgerDB更省心:
- 设备资源受限,内存只有128MB甚至更小,装不下BadgerDB的完整后台任务;
- 应用层写入基本收敛到一个goroutine,不存在多个模块并发写同一库;
- 事务规模小,单个事务不超过几十个key;
- 业务可以接受最后一小段时间内已提交数据在崩溃时丢失,或者你有独立机制重推。
典型场景包括边缘盒子的传感器日志暂存、近端状态快照、规则引擎的规则存储。这些业务把sfsDb当成一个带简单事务能力的本地文件替代层,挺合适。
6.2 什么样的业务适合BadgerDB
反过来,下面这些情况应该优先考虑BadgerDB:
- 服务端部署,物理机资源充足,并不缺内存和CPU;
- 多个服务实例或协程并发访问同一个库,写入线程超过4个;
- 需要长事务、快照读、范围遍历这类复杂事务能力;
- 数据量会持续增长,长期运行后不希望读路径退化。
记忆最深的是我们另一个项目组尝试把BadgerDB塞到嵌入式设备里,结果内存超限,后台压缩任务频繁被系统OOM杀掉,最后换回sfsDb才稳定。选型一定先看部署环境的资源盒子,跑分只能作为第二步参考。
6.3 我们在测试后才做的调优动作
测试结束后,我给两个库分别调了一种配置模板,作为以后项目起始基线。
BadgerDB的模板是:ValueLogFileSize调整到2GB,减少日志文件轮换带来的write stall;NumGoroutines按机器核数设置,不用默认值;关闭SyncWrites并且用事务级别控制“关键业务手动同步”,具体做法是提交关键事务时再开一个短连接强制写WAL;定期跑ValueLog GC,避免无界变大。
sfsDb的模板是:把IndexCacheSize尽量开大,减少读路径落到日志文件;快照间隔从“一天一次”改成“写入量累积到NGB触发一次”,平衡启动恢复时间和快照阻塞;批量fsync的时间窗口调到100ms以内,缩短崩溃丢数据的时间窗。如果业务出现高并发需求,优先在应用层做写合并,把多goroutine的事务合并成单投递,而不是改sfsDb并发模型。
7. 踩坑记录与几句实在话
7.1 这轮压测里踩过的坑
第一个坑是BadgerDB的空事务提交。我一开始为了统一接口,在事务里没有写入任何key就直接Commit,结果BadgerDB不会返回成功,而是返回一个错误类型,代码里没处理这个分支,导致压测程序空转,数据全部不准。空事务在业务里看起来不可能出现,但接口被复用后很容易漏掉。
第二个坑是sfsDb的多进程误用。有一轮测试我图省事,直接开两个命令行进程同时指向同一个sfsDb数据目录,结果一个进程的日志被另一个进程截断,现场惨烈。sfsDb没有任何进程级锁保护,这个库从设计上就假定“同一时间只有一个进程在写”。
第三个坑是p99统计里的预热噪声。第一轮压测我把统计周期直接从程序启动时开始,结果sfsDb的前几百个事务因为要初始化内存索引,延迟特别高,p99被拉到离谱的数字。后来严格预热1分钟再统计,结果才稳定。
7.2 最后给做自研KV或正在选型的人几句实在话
两个库的事务性能差异,本质不是“谁优化得好”,而是“谁愿意为哪些能力付成本”。BadgerDB用fsync、压缩、MVCC换来了通用性和高并发,sfsDb用更短路径换来了低资源和批量写场景下的吞吐。你只需要想明白自己的业务到底需要什么,不需要什么,然后把用不到的那部分成本省下来。
我在这轮对比里最值的不是最终那张表格,而是把两个库从内存到磁盘的每条路径都理顺了。花了两周时间,最后给团队的结论异常简单:低并发、低资源、可容忍丢小窗口数据,选sfsDb;高并发、服务端、需要严格持久化和复杂事务能力,选BadgerDB。没有第三个答案。