☰
BadgerDB vs sfsDb:嵌入式KV事务性能与选型实战对比
2026/10/13 13:43:49 网站建设 项目流程

最近在压一批边缘设备的数据暂存模块,遇到了一个选型问题:嵌入式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 隔离级别和冲突处理机制对比

对比维度BadgerDBsfsDb
底层结构LSM树 + WAL + ValueLogappend-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 TPS2400 TPS8200 TPS4800 TPS
S2 单线程5KV小事务4200 TPS5100 TPS21000 TPS7500 TPS
S3 多线程高冲突写450 TPS1900 TPS2600 TPS2000 TPS
S4 多线程低冲突写3100 TPS2100 TPS9500 TPS2300 TPS
S5 多线程读写混合2900 TPS1700 TPS9100 TPS1900 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。没有第三个答案。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询