ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

分布式KV存储 之(三)为啥要要RockDB而不是其他

分布式KV存储 之(三)为啥要要RockDB而不是其他 声明这篇文章来自NeoKV的技术文档一起学一下。目录声明这篇文章来自NeoKV的技术文档一起学一下。RocksDB 基础与特性概览为什么选 RocksDBLSM-Tree写优化的核心Column FamilyCompaction 策略Bloom FilterTransactionDB 与 WriteBatchCompaction FilterSST IngestionBlock Cache、压缩与 Write Stall检验你的理解延伸阅读RocksDB 基础与特性概览LSM-Tree 的基本原理为什么 RocksDB 适合写密集型场景Column Family、Compaction 策略、Bloom FilterTransactionDB、WriteBatch、Compaction Filter、SST IngestionBlock Cache、压缩策略、Write Stall这一章是 RocksDB 的速查手册聚焦于 NeoKV 实际使用的特性。如果你已经熟悉 RocksDB可以直接跳到 06-存储架构设计。为什么选 RocksDB在第一章我们讲过NeoKV 用 Raft RocksDB 替代了 Redis 的内存存储 异步复制。Raft 解决了一致性问题RocksDB 则解决了持久性问题。但为什么偏偏选 RocksDB而不是 LevelDB、BoltDB、或者直接用 B-Tree 引擎答案藏在 NeoKV 的工作负载特征里。NeoKV 的写入路径是 Raft 日志驱动的——每条 Redis 写命令都会变成一条 Raft 日志committed 后 apply 到存储引擎。这意味着写入量远大于读取量尤其在 Follower 上几乎全是写入而且写入模式是批量顺序追加Raft 日志按 index 递增。RocksDB 的底层数据结构 LSM-Tree 恰好就是为这种写密集场景设计的。LSM-Tree写优化的核心LSM-TreeLog-Structured Merge-Tree与传统的 B-Tree 有一个根本性的不同B-Tree 直接在原地修改数据in-place update每次写入可能触发随机磁盘 I/OLSM-Tree 则将所有写入转化为顺序追加append-only代价是读取时可能需要查找多个层级。一次写入的完整旅程是这样的数据首先被追加到 WALWrite-Ahead Log这是一个只追加的日志文件保证持久性——即使进程崩溃重启后可以从 WAL 恢复。然后数据写入内存中的 MemTable一个基于 SkipList 的有序结构此时就可以返回成功了。当 MemTable 积累到一定大小NeoKV 中默认 128MB它被冻结为 Immutable MemTable然后由后台线程 Flush 为一个 SST 文件Sorted String Table写入磁盘的 L0 层。L0 层的 SST 文件之间可能有 key 范围重叠后台的 Compaction 过程会将它们逐层合并——L1 及以上每层内的 key 范围互不重叠越往下层数据越旧、文件越大。Put(key, value) │ ▼ ┌──────────────┐ │ WAL │ 1. 顺序追加保证持久性 └──────┬───────┘ ▼ ┌──────────────┐ │ MemTable │ 2. 写入内存 SkipList即可返回 └──────┬───────┘ │ 达到 128MB ▼ ┌──────────────┐ │ Immutable │ 3. 冻结后台 Flush │ MemTable │ └──────┬───────┘ ▼ ┌──────────────┐ │ L0 SST │ 4. SST 文件key 范围可能重叠 └──────┬───────┘ │ Compaction ▼ ┌──────────────┐ │ L1 → L6 │ 5. 逐层合并每层内 key 不重叠 └──────────────┘这个设计的关键特点是写入只涉及 WAL顺序写和 MemTable内存写非常快SST 文件一旦写入就不可变immutable简化了并发控制Compaction 在后台异步执行不阻塞前台读写。读取一个 key 时需要按新到旧的顺序查找先查 MemTable再查 Immutable MemTable再查 L0可能需要查多个文件再逐层往下查 L1、L2……直到找到或确认不存在。这就是 LSM-Tree 的读放大问题。RocksDB 通过 Bloom Filter快速判断 key 是否可能存在于某个 SST 文件中和 Block Cache缓存热数据块来缓解。LSM-Tree 的设计本质上是在三种放大之间做权衡。写放大是指实际写入磁盘的数据量与用户写入量的比值——Compaction 会导致数据被多次重写写放大通常在 10-30 倍。读放大是指一次读取需要检查多少层——最坏情况下需要查每一层。空间放大是指旧版本数据在 Compaction 之前占用的额外空间。不同的 Compaction 策略在这三者之间做不同的取舍。Column FamilyColumn FamilyCF是 RocksDB 中逻辑隔离的 KV 命名空间。每个 CF 有独立的 MemTable、SST 文件、Compaction 配置和压缩策略但所有 CF 共享同一个 WAL。共享 WAL 带来了一个重要的好处跨 CF 的 WriteBatch 是原子的——要么全部成功要么全部失败。NeoKV 使用 8 个 Column Family每个 CF 针对其数据特点做了专门的调优。为什么不用一个 CF 装所有数据因为不同数据有截然不同的访问模式。Raft 日志是追加写入、很少读取的Redis 数据是随机读写的Binlog 是只关心最近数据、旧数据可以丢弃的。把它们放在同一个 CF 里Compaction 策略无法同时满足所有模式。独立的 CF 让每种数据都能获得最适合自己的配置。Compaction 策略Compaction 是 LSM-Tree 的核心后台操作将多层 SST 文件合并清理旧版本数据。RocksDB 提供三种主要策略NeoKV 使用了其中两种。Level Compaction是最常用的策略。每层的容量是上一层的 10 倍默认当某层的数据量超过限制时RocksDB 选择一个 SST 文件与下一层重叠的文件合并。这种策略的写放大较高数据可能被多次合并但读放大和空间放大较低。NeoKV 的 DATA_CF、REDIS_METADATA_CF、REDIS_ZSET_SCORE_CF 都使用这种策略。FIFO Compaction是最简单的策略——只保留最近的数据当总大小超过限制时直接删除最旧的 SST 文件。这非常适合日志类数据NeoKV 的 BIN_LOG_CF 使用这种策略配置了 100GB 的上限。Bloom FilterBloom Filter 是 RocksDB 缓解读放大的关键武器。它是一种概率性数据结构可以快速判断一个 key 是否可能存在于某个 SST 文件中。如果 Bloom Filter 说不存在那一定不存在无假阴性如果说可能存在那不一定存在有假阳性。通过 Bloom FilterRocksDB 可以跳过大量不包含目标 key 的 SST 文件大幅减少不必要的磁盘读取。RocksDB 还支持基于 key 前缀的 Bloom Filter。通过FixedPrefixTransform(n)配置只用 key 的前 n 个字节构建 Bloom Filter。这在 NeoKV 中非常有用——NeoKV 所有 key 都以[region_id:8][index_id:8]作为前缀配置 16 字节的前缀 Bloom Filter 后查询某个 Region 的数据时可以快速排除不相关的 SST 文件。RocksDB 提供两种 Filter 实现传统的 Bloom Filter10 bits/key和更新的 Ribbon Filter约 9.9 bits/key更省空间但构建和查询略慢。NeoKV 默认使用 Bloom Filter可通过配置切换到 Ribbon Filter。TransactionDB 与 WriteBatchNeoKV 使用rocksdb::TransactionDB而非普通的rocksdb::DB。TransactionDB 在 DB 之上提供了悲观事务支持——先获取行锁再执行操作最后原子提交。但在 NeoKV 的 Redis 场景中我们更多使用的是 WriteBatchrocksdb::WriteBatch batch; batch.Put(cf1, key1, value1); batch.Put(cf2, key2, value2); batch.Delete(cf1, key3); db-Write(write_options, batch);WriteBatch 保证一组操作要么全部成功要么全部失败。这对 Redis 的复合操作至关重要——比如 HSET 需要同时更新 metadata CFsize 1和 data CF写入 field这两个操作必须原子执行。如果只有一个成功了另一个失败了数据就不一致了。Compaction FilterCompaction Filter 允许在 compaction 过程中自定义数据清理逻辑。当 RocksDB 合并 SST 文件时会对每个 KV 对调用你注册的 Filter 函数——返回 true 表示删除这个 KV返回 false 表示保留。NeoKV 使用了两个 Compaction Filter。SplitCompactionFilter作用于 DATA_CF在 Region Split 后清理超出新范围的数据。RaftLogCompactionFilter作用于 RAFT_LOG_CF通过自定义 Raft 日志存储注册清理已截断的 Raft 日志——这就是我们在第三章讨论过的惰性删除机制。Compaction Filter 的优势在于零额外 I/O——数据清理与 compaction 合并执行不需要单独发起删除操作。SST IngestionIngestExternalFile允许将预排序的 SST 文件直接导入 RocksDB跳过 MemTable 和 WAL。导入的 SST 文件被直接放入 LSM-Tree 的合适层级避免了逐条写入的写放大而且整个操作是原子的。NeoKV 在 Raft 快照加载中使用了这个特性——第三章中我们讨论的SstWriterAdaptorFollower 收到 Leader 的快照数据后写入临时 SST 文件再通过IngestExternalFile导入 RocksDB。这比逐条 Put 快了一个数量级。Block Cache、压缩与 Write StallBlock Cache是 RocksDB 读性能的关键。RocksDB 从 SST 文件中读取数据时以 Block 为单位NeoKV 配置为 64KB读出后缓存在内存中。NeoKV 配置了 8GB 的 LRU Cache8 个 shard 以减少锁竞争热数据命中缓存后不需要访问磁盘。压缩方面NeoKV 对不同层级使用不同策略。L0 不使用压缩——L0 文件生命周期短很快会被 compaction压缩/解压的 CPU 开销得不偿失。L1 到 L6 使用 LZ4 压缩——快速压缩/解压CPU 开销低。最底层可选启用 ZSTD 字典压缩——压缩率更高适合冷数据。Write Stall是 RocksDB 的自我保护机制。当 L0 文件数量或 pending compaction 数据量超过阈值时RocksDB 会主动减慢甚至暂停写入防止 Compaction 跟不上写入速度导致系统雪崩。在 NeoKV 中默认配置是 L0 文件数达到 10 时减慢写入slowdown达到 40 时暂停写入stoppending compaction 达到 64GB 时减慢256GB 时暂停。Write Stall 会直接影响 Raft apply 的速度进而影响写入延迟是运维监控的重要指标。检验你的理解为什么 RocksDB 适合写密集型场景它的读性能瓶颈在哪里Column Family 共享 WAL 有什么好处如果某个 CF 的写入量远大于其他 CF会有什么影响Prefix Bloom Filter 的前缀长度如何影响过滤效果前缀太短或太长分别有什么问题WriteBatch 和 Transaction 的区别是什么NeoKV 为什么主要使用 WriteBatch 而不是 Transaction为什么 L0 不使用压缩如果 L0 也使用 LZ4 压缩会有什么问题延伸阅读RocksDB Wiki — 官方文档非常详尽RocksDB Tuning Guide — 性能调优指南LSM-Tree 论文 — 原始论文1996
返回列表