BoltDB是相当出名的纯Go实现的KV读写引擎, 用户有etcd, consul等. 我近阅读了它的源代码, 以下是我的一点看法.
先说优点,
BoltDB源码相当清晰, 没有黑魔法, 就是经典的B+Tree实现, 在工程中是非常可控的. etcd和consul都用了它, 未必是因为性能有多高而是简单可靠. Raft就是为了稳定, 如果底层的读写引擎出错, 那就失去了全部意义.
此外B+Tree天然优势, 随机查询比LSMT快, 就不赘述了.
现在来说说缺点,
1 - 数据库单文件且完全使用mmap交互(有误!)
还记得在天国的MongoDB mmap v3引擎吗? mmap不是银弹! mmap看似少了一次内存拷贝, 但这背后是有成本的. 在内存充沛的情况下, 我认为无论读写, mmap都没有理由比write和read之类的system call来得差. 可是一旦内存不够产生频繁换页的话, 对于写入操作, mmap就是灾难性的.
我做过一个测试, 在8GB内存的机器上, 用write来写一个24GB的文件, 花了1分多钟. mmap 2分钟了还没写完... 这是为什么呢? 要写的文件比物理内存大, 必然有频繁换页, 首先就会带来TLB失效, 其次换页换哪里去? 还是得换硬盘上, 这就会导致系统stall. 因为操作系统并不准确知道将来会有多少IO请求, 也不知道dirty page是否应该立即写回, 硬盘并没有火力全开.
解决方法很简单, 每8MB或者更大的chunk写完了, 立马msync让操作系统异步写回, 同时madvise表示这段虚拟内存不再使用了, 实测性能一下子就和write差不多了, 算是打满硬盘了.
BoltDB索引和数据mmap一把梭, 不可取.
---
更新勘误:
感谢 @我叫尤加利 指出!
BoltDB为了规避上述问题, 只有在读取的时候走mmap, 写回是在tx.go的func (tx *Tx) Commit() error然后跳到tx.db.ops.writeAt. 我因为遇到过mmap写入性能下降的问题, 所以就想当然了...
原理可以搜索unified buffer cache, 以后用mmap写大文件时注意.
---
2 - 没有WAL/redo log, 变动直接打入B+Tree
一方面看这确实是优点, 另一方面也导致了很多更好的优化做不了. 因为没有log保护中间状态, B+Tree自身必须具有原子性. BoltDB通过COW(copy on write)来达成, 带来了可观的开销. 即使只对某个page改了1 byte也必须重写整页, 连带parent到root都需要重写, 直至meta page, 有写放大的问题. meta page更新成功是操作成功的依据.
COW使得BoltDB几乎没有成本地支持一写多读的事务, 但也做不了并发写事务了, 因为双meta page只能确保至少一份是有效的. BoltDB提供了batch write可以自我安慰一下.
数据与终的数据结构绑定也会带来系统的stall. LSMT的memtable可以快速地吸收写入压力, 然后一次性做compaction. 按BoltDB这样, B+Tree的基于COW的复杂操作将会有delay. 举个例子, 为什么我们需要kafka? 因为我们希望用户开心, 能记录的东西先赶快记下来, 然后让具体的业务和索引层再慢慢消费. 在读写引擎就是log+memtable先消化了写请求, 然后再慢慢迁移, 有pipeline的效果.
3 - 使用freelist进行空间回收, 加大随机写入(修改)量
我们都知道无论HDD/SSD对于随机修改都是弃疗. BoltDB不进行compact而是将释放掉的page存入freelist, 久而久之就会有空洞, 来回跳转补洞的时候性能骤降.
如果内存 > 数据库 && 读 > 写 && 稳定 > 性能, 可以选择BoltDB.