RocksDB确实很适合这种中等规模的配置数据存储场景,它比文件存储更高效,又比独立数据库更轻量。除此之外,它还具有下面这些优点:
- 支持原子写入操作,避免文件存储可能出现的写入中断问题
- 读操作支持无锁并发,效率非常高
- 支持列式存储,带来了更加丰富的数据管理和查询能力
- 内置压缩功能,可以节省存储空间
- 支持快照功能,方便配置回滚
当然,我选择RocksDB的原因是我不希望因为存储配置相关的数据而依赖传统意义上的数据库,同时我也想拥有低延迟的数据处理能力。但RocksDB作为嵌入式数据库,它的天生素质是直接运行在应用程序进程内,无需独立数据库服务器,以库形式提供数据管理能力。嵌入式数据库与传统数据库相比,其特征如下:
RocksDB的定位
- RocksDB 本身不是完整的数据库系统(如 MySQL、PostgreSQL),而是一个嵌入式的键值存储引擎,需通过上层封装(如 MyRocks、TiKV)提供完整数据库功能。
- 基于 LevelDB 改进,采用日志结构合并树(LSM-Tree)结构,优化写密集型场景(如高频插入、更新)。
- 通过布隆过滤器和内存表(MemTable)加速点查询。
RocksDB的相关原理
在深入应用RocksDB之前,我们有必要简单了解一下RocksDB背后的相关原理
- LSM树原理及其优势
- 什么是MemTable、SSTable、WAL
- 如何理解写放大、读放大、空间放大
LSM树原理及其优势
LSM树的原理
-
写入优化:
- LSM树将写入操作分为两步:首先将数据快速写入内存中的“内存表”(MemTable),这是一个有序的数据结构(比如跳表)。
- 当内存表达到一定大小后,会被冻结并转换为不可变的“SSTable”(Sorted String Table),然后写入磁盘。这个过程是顺序写入,速度非常快。
-
分层合并:
- 磁盘上的SSTable会被分成多层(Level),每一层的数据量逐渐增大。
- 当某一层的SSTable数量达到阈值时,系统会触发“合并”(Compaction)操作,将多个SSTable合并为一个更大的SSTable,并推送到下一层。合并过程中会去重和排序,保证数据的有序性。
-
读取优化:
- 读取时,系统会先查内存表,再逐层查询磁盘上的SSTable。为了加速查询,通常会使用布隆过滤器(Bloom Filter)来快速判断某个键是否存在于某个SSTable中。
LSM树的优势
-
高写入吞吐:
- 由于写入操作主要是顺序写入(内存表和SSTable的生成),避免了传统B树的随机写入问题,因此特别适合高写入负载的场景。
-
空间效率:
- 通过合并操作,LSM树可以有效地减少磁盘空间的浪费,并且合并过程中会清理过期或重复的数据。
-
适合现代硬件:
- 顺序写入和分层存储的设计,充分利用了SSD的顺序写入性能,同时减少了随机IO的开销。
-
灵活可调:
- RocksDB等实现允许用户调整合并策略、内存表大小等参数,以适应不同的应用场景。
什么是MemTable, SSTable和WAL
MemTable、SSTable和WAL是LSM树(Log-Structured Merge Tree)架构中的三个核心组件,它们在数据库系统(如RocksDB)中扮演着重要角色。下面用简单的语言解释它们的作用和关系:
MemTable(内存表)**
MemTable是内存中的数据结构,用于临时存储最新的写入数据。它通常是一个有序的数据结构,比如跳表(Skip List)或平衡树。
作用:
- 写入数据时,首先会被快速写入MemTable,因为内存操作比磁盘操作快得多。
- 当MemTable的大小达到一定阈值后,会被冻结(变为不可变),并准备转换为SSTable写入磁盘。
- 特点:
- 高性能:内存操作避免了磁盘IO的延迟。
- 易失性:如果系统崩溃,MemTable中的数据可能会丢失(因此需要WAL来保证持久性)。
SSTable(Sorted String Table)
SSTable是磁盘上的有序键值存储文件,由MemTable转换而来。
作用:
- 存储持久化的数据,按键排序,便于快速查找。
- 多个SSTable会分层存储(比如Level 0、Level 1等),并通过合并(Compaction)操作优化存储和查询效率。
特点:
- 有序性:数据按键排序,支持高效的区间查询。
- 不可变性:一旦生成,SSTable不会被修改,只能通过合并操作生成新的SSTable。
WAL(Write-Ahead Log,预写日志)
WAL是一种追加写入的日志文件,记录所有写入操作。
作用:
- 在数据写入MemTable之前,先写入WAL。这样即使系统崩溃,也可以通过重放WAL恢复MemTable中的数据。
- 保证数据的持久性和一致性。
特点:
- 顺序写入:WAL是追加写入的,性能很高。
- 可靠性:是防止数据丢失的关键机制。
三者的协作流程
- 写入数据:
- 数据首先被写入WAL(确保持久性)。
- 然后写入MemTable(内存中快速处理)。
- MemTable转SSTable:
- 当MemTable满了,会被冻结并转换为SSTable写入磁盘。
- 新的MemTable会被创建,继续接收写入。
- 读取数据:
- 先查MemTable,再查磁盘上的SSTable(可能需要多层查询)。
- 崩溃恢复:
- 系统重启时,通过重放WAL恢复MemTable中的数据。
如何理解写放大、读放大、空间放大
在数据库和存储系统中(尤其是基于LSM树的系统,如RocksDB),**写放大(Write Amplification)、读放大(Read Amplification)和空间放大(Space Amplification)**是三个关键的性能指标。它们描述了系统在不同操作中的效率问题。
写放大(Write Amplification)
写放大是指实际写入磁盘的数据量远大于用户实际写入的数据量。
例如,用户写入1KB数据,但系统可能因为合并(Compaction)、日志(WAL)或其他操作,实际写入磁盘的数据可能是10KB。
原因:
- LSM树的合并操作:当多个SSTable合并时,需要读取旧文件、合并数据、写入新文件,导致多次磁盘IO。
- 日志和冗余:WAL、数据复制等机制也会增加写入量。
影响:
- 降低写入性能(尤其是SSD,频繁写入会缩短寿命)。
- 增加磁盘带宽和IO压力。
优化方法:
- 调整合并策略(如减少合并频率)。
- 使用更大的SSTable或分层合并。
读放大(Read Amplification)
读放大是指读取一个数据时,实际需要访问的磁盘数据量远大于用户请求的数据量。
例如,用户想读1KB的数据,但系统可能需要检查多个SSTable或索引,实际读取了10KB的数据。
原因:
- 分层查询:LSM树中,数据可能分布在多个层级的SSTable中,读取时需要逐层查找。
- 索引和布隆过滤器:虽然索引能加速查询,但额外的元数据也会增加读取量。
影响:
- 增加查询延迟。
- 占用更多内存和磁盘带宽。
优化方法:
- 使用布隆过滤器减少无效查询。
- 优化SSTable的分层和合并策略。
空间放大(Space Amplification)
空间放大是指存储的数据量远大于用户实际写入的数据量。
例如,用户存储了1GB数据,但系统可能因为冗余、未清理的旧数据或索引占用了2GB空间。
原因:
- 数据冗余:LSM树中,合并操作会生成新的SSTable,旧文件可能不会立即删除。
- 未清理的过期数据:删除操作可能只是标记,直到合并时才真正清理。
- 索引和元数据:额外的索引结构占用空间。
影响:
- 浪费存储资源。
- 增加成本(尤其是云存储场景)。
优化方法:
- 定期触发合并以清理旧数据。
- 压缩数据以减少占用空间。
三者的关系
互相制约:优化一个指标可能会恶化另一个指标。
例如:
- 减少写放大(减少合并频率)可能导致空间放大(更多旧数据堆积)。
- 减少读放大(增加索引)可能导致空间放大(索引占用更多空间)。
设计权衡:
数据库系统需要根据场景(如写入密集、读取密集或存储敏感)调整策略,平衡三者。
- MemTable:内存中的临时存储,高性能但易失。
- SSTable:磁盘上的有序存储,持久化且高效。
- WAL:日志文件,保证数据不丢失。
它们共同构成了LSM树的高效存储机制,适合需要高吞吐写入的场景(比如RocksDB)。
RocksDB键排序机制
-
字典序存储:
- RocksDB内部所有数据都按键的字节序(lexicographical order) 严格排序
- 比较规则:从第一个字节开始逐字节比较(类似字符串排序)
- 示例:
# 字节序比较示例 b"apple" < b"banana" # 因为 'a'(0x61) < 'b'(0x62) b"100" > b"0999" # 因为 '1'(0x31) > '0'(0x30)
-
物理存储位置:
- 键值越小 → 物理位置越靠前
- 键值越大 → 物理位置越靠后
- 在SST文件(磁盘存储单元)中,记录严格按此顺序排列
-
LSM树结构体现:
- 所有SST文件内部按键排序
- 不同层级SST文件的键范围有序(Level1的键范围在Level0之后)
时序场景的关键验证
我们通常用这种方式来设计时序key:
// 键结构示意
[object_id][分隔符][反转时间戳]
// 示例:object_001_18446744073709551615 (u64::MAX)
-
时间戳反转的作用:
- 新时间戳(值大)→
u64::MAX - timestamp
值小 → 键值小 - 旧时间戳(值小)→
u64::MAX - timestamp
值大 → 键值大
- 新时间戳(值大)→
-
物理存储效果:
实际时间 存储键值 物理位置 新数据(时间戳大) 键值小 靠前 旧数据(时间戳小) 键值大 靠后
通过RocksDB API验证
use rocksdb::{DB, IteratorMode, Options};
fn main() -> Result<(), Box<dyn std::error::Error>> {
// 创建测试数据库
let mut opts = Options::default();
opts.create_if_missing(true);
let db = DB::open(&opts, "test.db")?;
// 写入时序键(反转时间戳)
db.put(b"sensor1_18446744073709551615", b"new")?; // 新时间
db.put(b"sensor1_18446744073709551610", b"old")?; // 旧时间
// 全扫描验证存储顺序
let iter = db.iterator(IteratorMode::Start);
for (i, (key, _)) in iter.enumerate() {
println!("物理位置 {}: {}",
i,
String::from_utf8_lossy(&key));
}
Ok(())
}
输出结果将显示:
物理位置 0: sensor1_18446744073709551615 # 新数据在前
物理位置 1: sensor1_18446744073709551610 # 旧数据在后
重要补充说明
-
比较规则陷阱:
- 数字字符串
"10" < "2"
(因为'1' < '2'
) - 解决方案:使用固定宽度二进制表示
// Rust推荐方案:二进制大端序 let mut key = Vec::new(); key.extend_from_slice(sensor_id.as_bytes()); key.extend_from_slice(&(u64::MAX - timestamp).to_be_bytes());
- 数字字符串
-
列族(Column Family)特性:
- 每个列族有独立的键空间
- 排序规则在列族内部独立生效
- 不同列族之间没有排序关系
-
性能影响:
- 顺序键设计可使范围查询速度提升 100-1000倍
- 最新数据在LSM树的较高层级(Level0/Level1),访问延迟更低
结语
RocksDB就像一把精密的瑞士军刀——它不会替代你的工具箱,但在特定场景下能优雅解决棘手问题。当你在配置存储、时序数据或高吞吐写入的领域探索时,这个嵌入式引擎用LSM树的智慧将磁盘的物理特性转化为系统的超能力。
记住它的本质:不是全能数据库,而是专注键值操作的加速器。那些MemTable的闪电写入、SSTable的冷热分层、键排序的巧妙设计,都在默默优化你的IO路径。
下次当你面对"中等规模数据+低延迟"的挑战时,不妨让RocksDB在进程内悄然运转。用SSD友好的顺序操作告诉你:轻量级的选择,也能承载重负载的野心。