Riak 的存储引擎通过插件式设计运行在 riak_kv 层之下,Bitcask 和 LevelDB 是两种最常用的后端。Bitcask 作为默认选项,通常能在小规模键空间下带来非常低的读取延迟;LevelDB 则适合键数量大、需要范围扫描或写入压力高的集群。许多集群最初使用 Bitcask,在键数量增长后遇到内存告警,才被迫评估迁移方案。理解两者在索引结构、写入路径和压缩策略上的差异,是做出合理选型的前提。

一、Bitcask与LevelDB的底层机制差异
Bitcask 的设计来自 Riak 早期对低延迟的极致追求。写入数据时,Riak 将键值对追加到活跃日志文件末尾,随后更新内存中的哈希索引。索引项记录了键所在的文件编号、偏移量和值长度,因此单次点查只需要一次内存哈希查找和一次磁盘读取。日志文件达到阈值后变为不可变文件,后台合并进程负责清理重复键和删除标记。这种机制的好处是写入路径非常短,顺序 IO 也较容易打满磁盘带宽,但所有键的索引必须常驻内存。如果一个键平均占用二十字节索引元数据,一亿个键仅索引就需要约两GB内存,而且随着删除和更新,旧版本仍然占据日志空间,必须等待合并回收。
LevelDB 采用的是 LSM-Tree 结构。写入先进入内存中的 memtable 和预写日志,memtable 写满后被冻结并转换为磁盘上的 SSTable。SSTable 中的键按字典序排列,分层组织,层级越深数据量越大。后台 compaction 持续合并不同层级的文件,删除过期值和冗余版本。LevelDB 内存中只保存当前 memtable、一部分索引缓存和布隆过滤器,键数量远大于内存时仍能正常工作。但点查询可能要从 memtable 查起,再逐层访问磁盘文件,直到找到目标键或确认不存在,读放大比 Bitcask 更明显。范围查询则是 LSM-Tree 的强项,因为键天然有序,可以顺序扫描多个 SSTable 文件。
可以这样总结:Bitcask 用内存换延迟,键数量是硬约束;LevelDB 用少量读放大和后台合并开销换键容量,适合数据规模更大、查询模式更复杂的场景。
二、从负载特征选择存储后端
判断键数量是否超出节点内存预算,是选择后端的第一条标准。Riak 集群中每个节点的可用内存不仅要给存储引擎,还要分配给操作系统的文件缓存和 Erlang 虚拟机本身。如果使用 Bitcask,可用内存中至少应预留一半给键索引和日志缓冲区。可以粗略估算:总索引内存约等于键数量乘以平均键长度与固定元数据之和。如果键数量只有几百万,Bitcask 几乎没有压力;如果键数量达到五千万或更多,而单节点内存只有16GB,继续使用 Bitcask 就会触发大量内存回收,甚至影响 Riak 的节点健康。
值大小也是一个重要变量。Bitcask 的索引大小与值无关,因此存储较大的值,例如几KB到几十KB的图片或文档摘要,单位键的索引开销占比反而较低,整体效率较好。LevelDB 在值很小时写入放大问题可能更突出,因为 compaction 需要对键值对反复重写,值越大单次写放大代价越高。另一个维度是读写比例:如果业务以高频点查为主,且延迟要求稳定在个位数毫秒,Bitcask 是更直接的选择;如果写入流量大、批量导入多,LevelDB 的批量写入和 compaction 合并策略能提供更平滑的写入曲线。
范围扫描支持是 LevelDB 的明显优势。Bitcask 不支持按范围遍历,只能通过全量扫描所有键实现范围查询,代价很高。如果业务中存在按时间范围或字典序范围拉取数据的场景,例如提取某类 ID 前缀下的所有对象,选择 LevelDB 可以避免在应用层自行维护二级索引。此外,数据保留周期和删除频率也会影响决策:Bitcask 大量的删除标记依赖合并进程清理,如果合并窗口设置不合理,磁盘空间可能长时间得不到释放;LevelDB 的 compaction 对删除也有处理,但删除标记会随着层级合并逐步下沉,最终被清理。
三、配置切换与调优实践
在 Riak 中切换存储后端需要修改节点的 app.config 配置文件。以 LevelDB 为例,配置示例如下:
{riak_kv, [
{storage_backend, riak_kv_eleveldb_backend},
{eleveldb, [
{total_leveldb_mem_percent, 70},
{leveldb_compression, true}
]}
]}.
如果坚持使用 Bitcask,可以保持默认配置,或调整合并参数:
{riak_kv, [
{storage_backend, riak_kv_bitcask_backend},
{bitcask, [
{merge_window, always},
{frag_threshold, 40}
]}
]}.
修改配置后不能直接对已有数据生效。Riak 不会自动将 Bitcask 格式的数据转换为 LevelDB 格式。如果直接修改后端并重启,新数据会写入新引擎,而旧数据仍然留在原引擎中,节点可能无法正确读取历史键。正确做法是滚动替换节点或使用 Riak 的数据迁移工具。可以先在集群中加入使用新后端的节点,通过 hint 或手工转移分区,让旧节点逐步退出;也可以关闭旧节点后重新初始化其数据目录,让其以新后端加入并接收副本数据。迁移时要注意数据倾斜和网络带宽,避免一次性转移大量分区导致集群负载升高。
LevelDB 调优时重点关注内存占比和 compaction 并发。参数 total_leveldb_mem_percent 控制 LevelDB 可用于 memtable 和缓存的总内存比例,过高会挤压 Riak 其他部分,过低会导致频繁写入停顿。write_buffer_size 和 max_open_files 也会影响写入性能和文件句柄占用。Bitcask 的 merge_window 决定合并窗口,frag_threshold 控制碎片率触发合并的阈值,需要根据磁盘空闲空间和写入压力调整。无论如何选择,都应该在测试环境用接近生产的数据规模和访问模式做基准测试,再决定最终配置。