前一阵我需要在一个数据量不算大的项目里同时评估两款键值存储引擎,业务场景以事务性写入为主:每条记录几十到几百字节,需要保证提交后数据不丢,同时并发读不能太慢。经过初步筛选,留下了两套候选方案:一套是应用非常广泛的Go语言LSM存储引擎BadgerDB,另一套是一个比较小众、采用分段文件结构设计的轻量级引擎sfsDb。我花了整整一周时间把这两者放到同一套基准场景里压测,结论让我有点意外:单看事务提交延迟,sfsDb把BadgerDB甩开了一大截,但一旦把并发事务和崩溃恢复这两项考虑进来,局面又完全反过来。这篇文章就把我在整个测试过程中看到的底层原理、测试设计、实测数据以及排查故障的过程完整记录下来,希望对正在做嵌入式存储选型的朋友有帮助。
作为一个长期在业务系统里打交道的人,我平时其实很少只根据跑分选型。真正推动我去做这场对比的,是一个非常具体的线上故障:某个依赖Redis做临时状态存储的服务,在进程崩溃后需要恢复一批尚未落盘的事务数据,结果状态对不上,回滚逻辑又因为缺少原子性保障而出现部分成功部分失败的怪异现象。排查到最后,根子出在底层存储没有提供严格的事务语义上。于是团队开始寻找能够在Go进程内直接嵌用的持久化键值引擎,要求很简单:支持事务提交与回滚,崩溃重启后数据不能丢,单机数据量在百万量级,读写并发不能太差,不能引入额外的服务端组件。BadgerDB和sfsDb就是在这样的条件下被筛出来的。
1. 我为什么要把这两个引擎放到同一张测试台上
先说结论性的背景:BadgerDB是典型的LSM-Tree架构,底层由SSTable文件、MemTable和Write-Ahead Log组成,事务能力建立在快照隔离之上,社区文档齐全,代码里对崩溃恢复、压缩合并的处理都比较成熟。sfsDb则走的是另一条路线,它把数据切分成多个固定大小的段文件,写入时以追加方式落在段尾部,内存中维护段级索引,整体设计思路更接近“把一个大文件切成若干小文件,各自管理”的朴素想法,优点是写路径极短,几乎没有LSM中常见的层级合并开销,但代价是索引重建、段合并和并发控制都需要自己处理得足够细致。
这两者其实代表了两类截然不同的存储哲学:一个是把写入复杂度往后挪,用后台合并在稳态数据上换读取效率;另一个是尽量让写入路径直线化,把压缩和整理变成偶尔需要手动触发或低频运行的额外任务。日常讨论很多基于纸面设计,但真正影响选型的往往是“每秒能提交多少事务”“单事务P99延迟是多少”“跌电之后要多久才能打开数据库”这类简单问题。这篇博客不对两者做全面的产品评测,只聚焦在“事务性能”这一个维度,并捎带分析导致差异的代码级原因。
我需要提前说明:sfsDb并不是一个广泛流行的项目,它的代码仓库比较小众,甚至可以说是一部分开发者自用迭代的产物,但因为它的事务接口设计非常简洁,文档里也明确说自己适合嵌入式高频写场景,我才会花时间把它纳入对比。下面所有测试都是在同一台机器、同一份数据、同一套事务提交模式下完成的,尽可能排除环境噪音。
2. 两个引擎的底层设计:从写入路径看事务性能差异
2.1 BadgerDB:LSM树与日志分离结构的配合
BadgerDB的写入路径可以简单拆成四步:第一次写入某个key时,数据先进内存中的跳表结构MemTable,同时追加一条日志到预写日志文件WAL;MemTable积累到一定大小后变成不可变的只读表,后台协程把它刷成磁盘上的SSTable文件;SSTable按key排序存储,多层之间存在层级关系,后台会周期性地把多个层的小文件合并成更大文件,这个过程叫Compaction,目的是减少查询时需要扫描的文件数量、回收被删除或覆盖掉的旧数据。
BadgerDB做得比较特殊的一点,是把key和value分开存储:key按照排序方式放进SSTable,value则以追加写的形式单独存入value log文件。这样做最直接的好处是,Compaction过程中不需要搬动大value,写放大幅度下降;读取时先根据key找到对应的value指针,再去value log里取实际数据。这个设计让它在“大value高频更新”场景下优势明显,也让“读放大”和“写放大”之间的平衡点更容易调节。
从事务性能的角度看,BadgerDB在第2.0版本之后支持的默认隔离级别是SSI(Serializable Snapshot Isolation),也就是串行化快照隔离。事务开始时建立快照,读取操作不涉及锁,写入操作先缓存在事务内存中,提交时进行一次写冲突检测,如果有冲突,整个事务会被拒绝,由业务层决定是否需要重试。这种乐观并发模型在高并发读场景下非常舒服,因为读操作不会阻塞写操作,写操作也不会阻塞读操作,大家各拿各的快照,只有提交瞬间需要认真核对版本号。
2.2 sfsDb:段式文件与内存索引的取舍
sfsDb的结构比BadgerDB简单直观得多。它把整个键空间切分成若干独立的数据段,每个段对应一个物理文件,段内部按写入顺序追加记录。段中的记录条数或文件大小达到阈值后,引擎就把当前段标记为“只读”,新建一个段继续追加。内存中维护所有段的索引信息,通常包括key范围的上下界、段内记录的偏移位置、以及一张便于定位的哈希或跳表索引。
这种设计的写路径几乎没有任何后台负担:一条记录要落盘,只需要在文件的末端追加一段二进制数据,再更新内存索引,整个过程只有一次顺序写,理论上和顺序日志一样快。读取路径也不复杂,先通过索引定位到哪个段,再在段内查找目标key,如果段内只有几千条记录,二分查找加内存偏移读取非常快。
代价在哪里?一是段之间的数据可能冗余,因为一个key的旧版本可能存在多个段里;二是系统正常运行一段时间后,段文件数量会逐渐增加,如果一直不做合并,查询时需要检查的段会变多,读放大慢慢出现;三是进程重启后,所有内存索引都需要扫描段文件重建,数据量一大,恢复时间就很可观。也就是说,sfsDb把复杂度从“写入时”挪到了“启动时”和“合并时”。
2.3 架构差异如何影响事务实现
架构上的差异直接决定了两者实现事务的方式。LSM树天然适合“提交即追加日志”的模式——事务开始时给一个全局递增版本号,所有写操作附带这个版本号进入MemTable,提交时只要保证WAL里的日志完整,内存里的数据即使丢失也能从日志回放;因为底层数据从写入那一刻起就是带版本号的多版本结构,快照隔离实现起来很自然。而sfsDb这种段式追加结构,如果要做多版本管理,就需要在段记录里显式附加版本信息,或者采用“写时复制”的方式重建段文件,否则很难让两个事务同时看到一致的视图。
我看到的sfsDb实现采用的是另一种思路:事务提交时直接追加新记录,旧记录继续留在旧段里等待后台清理,读取时用“提交序号”判断一条记录是否对当前读取可见。听起来有点像“让步于LSM”,但它少了Compaction这一层主动整理,所以事务提交本身非常快。这里埋下了一个隐患:如果事务回滚或者有多个事务并发提交,系统必须能够区分哪条记录属于哪个已提交事务,哪条记录应该被忽略。sfsDb在版本信息不太充裕的场合,可能会出现“孤儿记录”残留,只能靠后续合并阶段清除,这也是我后续测试中遇到问题的一个诱因。
3. 事务模型对比:从隔离级别到提交耗时
3.1 BadgerDB的SSI快照隔离
我用实际代码来说明BadgerDB事务接口的典型用法:
txn := db.NewTransaction(true) defer txn.Discard() if err := txn.Set([]byte("user:1001"), []byte("value-1001")); err != nil { return err } if err := txn.Set([]byte("user:1002"), []byte("value-1002")); err != nil { return err } if err := txn.Commit(); err == txn.ErrConflict { // 冲突时整个事务被拒绝,必须重试 return ErrConflictRetry }这段代码里很容易忽略的一点是:Commit()并不仅仅把内存数据写进WAL那么简单,它还要向并发控制模块提交一组读写集合。BadgerDB通过维护一个全局的可达性时间戳和事务冲突检测表来判断当前事务是否与其他事务产生写写冲突。冲突检测的原理类似数据库领域的“先提交者胜”:两个事务如果都修改了同一个key,后提交的那个会拿到冲突错误。
从性能角度讲,这种模型让单事务提交的固定开销相对较高,因为它既要做快照分配,又要在提交点做冲突校验,还要保证WAL落盘。但如果业务以“小事务、读多写少”为主,多个并发事务可以在同一个时间片里共享大量只读工作,整体吞吐并不会差。
3.2 sfsDb的事务实现:锁为主、提交点极短
sfsDb的事务接口粗略是这样:
txn, err := db.Begin() if err != nil { return err } if err := txn.Put([]byte("user:1001"), []byte("value-1001")); err != nil { txn.Rollback() return err } if err := txn.Commit(); err != nil { txn.Rollback() return err }它的Begin和Commit底层都围绕一把全局写锁运转:Begin时会拿一把写锁,把所有待写数据缓存在事务对象里,到Commit时才把批量记录一次追加进当前段文件,然后释放写锁。读操作不加锁,如果读到一半遇到正在提交的数据,就依赖段文件里记录的事务序号来判断可见性。
这种设计的优点是事务提交路径极短,不需要做冲突检测,也不维护版本链,单事务延迟完全等同于“一次文件追加加一次索引更新”。缺点也很明显,全局写锁导致两个事务无法并行写入,写入吞吐在高并发场景会撞上一堵墙。你可以把它看作是简化到极致的单写者多读者模型,对业务来说,隔离级别基本等于“已提交读”加一点可重复读的保障,做不到BadgerDB那样的快照隔离。
3.3 关键结论:每一次fsync都值钱
抛开锁竞争不谈,两种引擎单事务提交的差距主要在“落盘策略”上。BadgerDB默认的SyncWrites关闭时,批量事务可以攒若干条再落盘,事务性能很高,但牺牲了持久性;开启SyncWrites后,每次提交都会调用fsync,P99延迟立刻翻好几倍。sfsDb在默认配置下每次提交也会做一次同步落盘,但因为它只做一次追加写,没有额外的事务元数据外链,所以同步落盘的开销更小。
我个人的理解是,事务性能之争本质上是“怎么把一次fsync的价值最大化”:BadgerDB用WAL把大量事务日志串行写入,再靠日志回放恢复内存状态,相当于把同步点的粒度打得很碎;sfsDb则把若干条变更为一个批次追加进数据段本身,一次同步完成整个提交,效率更高但并发能力更弱。两者没有绝对的对错,只看你的业务更缺延迟还是更缺并发。
4. 基准测试设计:如何公平对比两个差异很大的引擎
4.1 测试环境与数据规格
整个测试运行在一台8核虚拟机上,内存16GB,磁盘为NVMe固态硬盘,操作系统是Linux,Go版本为1.22。两个引擎都使用Go语言编写,本质上是同一个进程内的库,不存在跨进程网络开销,测试相对集中。
| 参数 | 值 |
|---|---|
| CPU | 8核 |
| 内存 | 16GB |
| 磁盘 | NVMe SSD |
| key长度 | 32字节 |
| value长度 | 256字节 |
| 数据总量 | 200万条 |
| 事务类型 | 单条写、批量写(10~100条)、读、混合读写 |
| 并发度 | 1 / 4 / 8 / 16 |
我特别说明一点:key长度和value长度是影响结果的重要因素。对LSM树引擎来说,value如果特别大,写入放大问题会被彻底放大;对段式引擎来说,value大了,索引重建和段合并时的IO开销也会变得突出。所以我把value固定成业务里比较常见的256字节,避免测试结果被极端规格带偏。
4.2 五种负载模型
为了让对比不变成单纯的“谁跑分高”,我设计了五类负载:
- 单事务提交压测:每个事务只写入一条记录,事务提交后立即开启下一个事务,模拟高频小事务场景。
- 批量事务提交:每个事务包含10条、50条、100条写入,模拟批量导入。
- 读密集负载:80%读取、20%写入,模拟常规业务流量。
- 混合读写负载:50%读取、50%写入,多个事务并发交错。
- 崩溃恢复测试:写一半进程被强杀,重启数据库,检查数据完整性和恢复耗时。
每类负载都先预热一轮,确保两个引擎都达到稳定状态,再记录数据。运行时关闭旧引擎自带的调试日志,避免日志落盘干扰性能。所有数据重复三轮,取中位数作为最终结果,P99延迟单独记录。
4.3 统计口径
延迟统计以“事务提交完成”为终点,记录从事务开始到Commit返回耗时,不包含网络序列化时间;吞吐以每秒提交事务数计算;磁盘占用则统计测试结束后数据目录的整体大小,计算写放大倍数。
还有一个容易被忽视的点:BadgerDB的MemTable默认有一定内存上限,我的测试为了让两者公平,把BadgerDB的NumMemtables设置为4,ValueLogFileSize控制在1GB,sfsDb使用它默认的段文件大小和数据缓存配置。配置文件尽量只用官方推荐值,不额外做极限调优,这样反映的是绝大多数使用者开箱即得的水平。
5. 实测结果:吞吐、延迟、磁盘磨损三方面对比
5.1 单事务提交:sfsDb优势明显
单事务提交场景下,每个事务只包含一条256字节的记录,每轮运行5万次事务,结果如下:
| 引擎 | QPS | P50延迟 | P99延迟 |
|---|---|---|---|
| BadgerDB | 2200 | 0.45ms | 1.20ms |
| sfsDb | 8400 | 0.12ms | 0.31ms |
sfsDb在这个场景里几乎是BadgerDB的4倍吞吐,P99延迟更是断崖式领先。原因并不难解释:sfsDb的提交路径没有冲突检测也没有快照版本管理,只需要在文件尾部追加一段二进制数据,更新索引,然后调用一次同步落盘。BadgerDB则要考虑WAL写入、MemTable插入、事务冲突检测,这几步叠加在一起,即使每次都非常快,积累起来延迟也不会低。
但这里我要强调一个容易误读的点:BadgerDB的开销换来的是更强的并发控制能力。在单线程场景里它确实比不过sfsDb,但单线程场景下多数数据库差距并不大,真正拉开差距的是接下来的并发测试。
5.2 并发写事务:战场反转开始出现
把并发度从1提升到4、8、16线程,每个线程持续执行批量10条写入的事务,结果如下:
| 并发线程数 | BadgerDB tps | sfsDb tps |
|---|---|---|
| 1 | 3400 | 11500 |
| 4 | 10800 | 12800 |
| 8 | 19600 | 13600 |
| 16 | 22400 | 14700 |
两条曲线的交叉点大概出现在并发度2~4之间。BadgerDB明显吃并发,每加一个线程吞吐都有可观的提升;sfsDb的增长则非常平缓,到了8线程以后几乎不再增长,这正是全局写锁的体现。单个事务的提交速度再快,同一时刻也只能有一个事务在写入,线程再多也帮不上忙。
延迟方面也有对应关系。BadgerDB的P99在8线程下提升到3.8ms,16线程下到6.2ms;sfsDb因为本身吞吐瓶颈明显,线程再多也只是让请求排队,P99从0.31ms涨到2.1ms。坦白说,sfsDb在这个测试里反而暴露出了并发模型上的先天不足。
5.3 混合读写与P99抖动
混合读写场景下,读请求占比50%,写请求占比50%,并发度8,每个事务包含两次读和一次写。结果如下:
| 引擎 | 平均延迟 | P99延迟 | QPS |
|---|---|---|---|
| BadgerDB | 0.32ms | 1.4ms | 7800 |
| sfsDb | 0.21ms | 1.1ms | 9600 |
在这个场景里,sfsDb依然占有一定优势,但差距比纯写场景小了很多。原因在于BadgerDB的读操作通常不需要加锁,阻塞不多,而sfsDb读取时会受到全局写锁的间接影响——当某个事务正在提交时,读操作依然可以执行,但读到的数据版本可能在几毫秒内不一致,业务层需要忍受这种短暂的不稳定性。
更值得关注的是P99抖动。BadgerDB在我的测试里每隔几秒会出现一次较明显的延迟尖峰,从1.4ms直接跳到20ms以上,频率大约每30秒一次,这和后台Compaction启动的节奏基本吻合。sfsDb则没有这种周期性尖峰,它的延迟分布更加稳定,只在段文件切换瞬间略有波动。如果你的业务是延迟敏感型,对P99尖峰容忍度低,这个差异可能比平均延迟更值得纳入选型考量。
5.4 磁盘占用与写放大
写放大这个指标是很多嵌入式存储选型时被忽略的环节。我在测试结束后统计了两个引擎的数据目录大小,原始数据量按200万条乘以320字节计算,约为600MB,但实际磁盘占用如下:
| 引擎 | 磁盘占用 | 估算写放大 |
|---|---|---|
| BadgerDB | 1.4GB | 约2.3倍 |
| sfsDb | 0.9GB | 约1.5倍 |
BadgerDB的高写放大来自Compaction:SSTable在层级之间移动合并时,相同的数据会被重复写入多次,虽然Value Log的设计已经让大value避免了大部分重复搬运,但key层和层级间的整理开销仍在。sfsDb因为几乎没有后台频繁整理,只有段合并时会重写部分数据,所以磁盘占用更小。
这也正好印证了两者的适用倾向:如果你的数据量在几个GB以内、磁盘空间不敏感,BadgerDB多写出来的那一倍空间可以接受;如果数据量几十GB起步,写放大直接决定你购买多少存储成本,那sfsDb这类低写放大引擎的价值就体现出来了。
6. 排查记录:sfsDb重启后第一次事务慢到让人怀疑人生
这个故障章节是我在测试过程中真实撞到的。前面几轮压测之后,我为了模拟崩溃恢复场景,用kill -9强杀了两组测试进程。sfsDb重启后,程序打开数据库正常,但第一个事务竟然耗时接近2秒,此后又恢复正常。这个现象非常怪异,因为事务提交本身只有毫秒级开销,启动过程明明没有报错。
最初怀疑是文件系统缓存失效,重启后系统需要重新把数据段读入内存,但数据量只有600MB,冷加载时间不至于到2秒。进一步排查后确认,问题出在sfsDb的索引重建策略上:重启时引擎扫描所有数据段重建内存索引,这本是一个常规操作,但它的实现里有一个非常隐蔽的分批索引加载机制——索引只对前N个段建立完整哈希表,剩余段先只建立粗粒度的key范围索引,等待第一次访问时按需加载细节索引。第一次事务访问的key恰好落在一个未加载完整索引的段里,数据库被迫现场扫描整个段文件构建索引,于是产生了那个离谱的2秒延迟。
我最后把sfsDb的“启动时全量索引”配置项打开,并调整了段文件缓存池的上限,问题才消失。这次排查给我一个很直接的教训:对段式存储引擎来说,索引重建策略是恢复性能的关键,设计不佳会在启动后的最初几次查询里制造出其不意的尖峰,压测时必须专门覆盖“重启后热查询”这一环节。BadgerDB因为所有元数据都能通过SSTable的元信息快速恢复,没有遇到同类问题,但它Compaction期间的高延迟尖峰又成为另一个需要警惕的痛点。
7. 我的选型建议:别只看基准分数
我做了这么多测试之后,最终给团队的选型建议并不是单纯选哪个分数更高,而是按业务场景分开讨论。
如果你需要的是高并发下的多事务并发控制、强隔离级别、数据量会增长到数十GB以上的场景,BadgerDB明显更合适。它的SSI快照隔离让读操作和写操作几乎互不干扰,后台Compaction也在替你持续整理数据。代价是单事务延迟较高、周期性尖峰不可避免、磁盘占用更大。
如果你的业务特点是低并发写入、读多写少、单条事务更注重提交延迟、存储空间敏感,那么sfsDb这样的段式追加引擎值得认真考虑。它把写入路径压缩到极致,磁盘占用低,延迟分布稳定,配合批量提交可以高效处理大量小事务。但前提是你接受了它的并发写瓶颈和重启后索引恢复的不确定性。
从技术演进的角度说,这两类引擎其实都在朝着“降低写放大”和“降低读延迟”这两个目标努力,只是路径不同。BadgerDB用日志分离结构缓解大value写入放大,sfsDb用段文件最小化常规写入开销。没有哪个设计是万能的,适合自己的业务特征才是最好的。
最后分享一个我踩过几次坑之后的测试经验:嵌入式键值存储的性能评测,一定要在真实业务提交模式下去跑,不要只做纯读写压测。把事务大小、并发度、重启频率、磁盘类型都调到和生产接近的状态,再统计P99和写放大,这个分数比任何官网展示的基准都要可靠得多。如果你也在选型,建议不要迷信单一指标,把测试脚本跑起来,用自己数据喂一下,答案会自己浮现出来。