资讯动态

PB级数据存储下的LSM-Tree冷热分离与压缩实践

发布时间:2026/10/5 13:28:13 来源:尧图企业网站定制
干存储这行的人基本都有一个默契数据量一旦过了 PB 级成本和延迟会变成一对“冤家”。去年我接手内部一个基于 LSM-Tree 引擎的分布式存储集群实际数据量已经摸到 PB 级的边表面上一路顺风账单却越来越难看——热数据挤在 SSD 上冷数据也占着同样的位置压缩开不起来查询动不动就扫全量 SSTable。这篇文章就是我当时做冷热分离架构改造的记录核心围绕 PB 级数据存储下的存储压缩与查询加速实践展开里面所有方案都是在生产环境里跑过并反复调过的如果你也在折腾 RocksDB、LevelDB 这类 LSM-Tree 引擎或者正在为海量数据的存储成本发愁这篇可以直接收藏当参考。1. 冷热分离架构为什么必须拆拆之前要想清楚什么1.1 冷热分离的本质是“用延迟换成本”冷热分离不是什么新鲜概念但很多人对它有一个误解以为只是把老数据挪个位置。实际上冷热分离的本质是把“访问概率”和“存储成本”做一次重新匹配。热数据访问频繁需要低延迟、高吞吐所以放在 SSD 甚至内存里成本高昂也能接受冷数据一个月可能都读不了一次放在机械盘或者对象存储上完全够用牺牲几十毫秒的延迟换取一个数量级的成本下降这笔账非常划算。我在这个项目里做了个简单测算如果 1PB 数据全部放 SSD 高性能存储按 3 副本算就是 3PB 物理空间成本很难接受。但如果做过冷热分离真正活跃的热数据可能只有 5% 到 10%剩余 90% 的冷数据放到低成本的冷存储层整体硬件成本直接下降 60% 以上。这个数字足以推动架构改造立项。不过这里要泼一盆冷水冷热分离不是“拆开就完事”它牵扯到数据迁移、元数据管理、查询路由、压缩策略、合并策略等一整套链路。如果没有想清楚怎么判冷热、怎么迁移、怎么保证迁移过程中数据一致性和可用性仓促上马只会给自己挖坑。1.2 冷热判定的两类核心策略基于时间和基于访问频率冷热判定是所有工作的起点。我在实践中主要用到两类策略工程上通常组合使用。基于时间的策略最简单直观按数据写入时间划分比如 3 天内写入的数据为热数据3 到 30 天为温数据30 天以上为冷数据。这种策略适合日志、监控指标、时序数据这类场景因为新写入的数据访问概率天然更高。基于访问频率的策略则更精细维护每个分区的访问计数器定期统计读写次数访问频率高的分区标记为热长期无人问津的标记为冷。这种策略适合用户数据、订单数据这类访问模式难以预测的业务。我在项目里采用的是“时间为主、频率为辅”的双维度策略默认按时间划冷热但某些分区即使时间很老、访问频率却很高会被强制拉回热层反之某些新写入但访问量极低的分区也会被降级为冷数据。这个策略最大的好处是能应对突发的“回访热”比如用户突然查询两年前的某笔订单。1.3 冷热分层后的数据迁移链路判定完冷热之后数据迁移是下一个关键环节。LSM-Tree 引擎里数据最终都落在 SSTable 文件上所以冷热迁移本质上是在不同存储层之间搬运 SSTable。我的做法是在引擎外面加了一层分区管理器它定期扫描所有分区的元数据根据冷热判定结果把冷分区的 SSTable 从热存储目录迁移到冷存储目录。迁移过程中先复制、后校验、再删除原文件保证任何时刻数据都有完整副本。迁移完成之后元数据中心更新分区的存储位置信息查询请求就自动走冷存储路径。这里有一个容易被忽视的问题迁移本身会产生大量 IO 和带宽消耗。如果一次性迁移几百 TB 的数据业务高峰期的查询延迟会被明显拖累。所以我把迁移做成了限速的异步任务设置在凌晨低峰期执行并且每次迁移的数据量有上限宁可多跑几个晚上也不能影响在线业务。2. LSM-Tree 引擎下的压缩体系从 MemTable 到 SSTable 每一层都值得压2.1 再认识 LSM-Tree写入、合并与三类放大效应先说一个基础但关键的前提理解 LSM-Tree 的写入路径才能明白压缩和查询加速应该在哪一层做文章。LSM-Tree 的写入流程是这样的数据先写 WAL 保证持久性然后进入内存中的 MemTable通常是一个跳表结构MemTable 满了之后变成不可变 MemTable后台线程将它刷盘成为 SSTable 文件。SSTable 文件会随着刷盘不断增多达到一定阈值后触发 Compaction也就是多路归并把小文件合并成大文件同时清理冗余数据。在这个模型里存在三类放大效应写放大、读放大和空间放大。写放大是 Compaction 过程中同一份数据被反复重写读放大是一次查询要检查多个 SSTable空间放大是删除和更新的旧数据没有及时清理暂时占用空间。冷热分离和压缩策略本质上都是在平衡这三类放大效应。我特别想强调一点压缩不是只发生在数据块层面Compaction 策略本身就决定了压缩能有多大的发挥空间。如果 SSTable 文件普遍很小粒度碎压缩率一定上不去只有通过合理的合并策略生成大文件之后块压缩和数据编码才能真正发挥威力。2.2 数据块压缩Snappy、LZ4 与 ZSTD 的取舍SSTable 内部一般按固定大小切分成数据块常见 4KB 到 64KB每个数据块独立压缩。压缩算法的选择直接影响两个指标压缩率和 CPU 消耗。我在这个项目里做了完整的压测对比结论比较典型压缩算法压缩率相对CPU 压缩吞吐MB/s/核适用场景Snappy2.1x450写入密集需要低延迟LZ42.0x500写入密集追求最大吞吐ZSTD level 33.4x180冷数据追求高压缩率ZSTD level 94.1x70冷数据极致压缩热数据层我选了 LZ4因为写入路径上压缩不能拖后腿SSD 带宽是有限的压缩太快没用压缩太慢反而会成为瓶颈。冷数据层则用了 ZSTD 中高压缩级别因为这个层的数据几乎不参与实时写入CPU 多烧一点完全值得省下的存储空间是实打实的成本。还有一个容易忽略的参数数据块大小。块越小读取粒度越细查询时解压的数据越少但块太小压缩率会下降而且索引项变多、内存占用增加。我实测下来热层用 16KB 块冷层用 32KB 块是比较均衡的选择。2.3 编码优化Varint、Delta Encoding 与前缀压缩块压缩解决的是字节层面的压缩问题而编码优化解决的是“数据本身怎么表示更省空间”的问题。两者叠加才能把压缩率做到极致。最常见的编码优化是 Varint把固定 4 字节或者 8 字节的整型按大小动态编码为 1 到 5 个字节。对于 ID、时间戳这类数值大部分情况下值都比较小Varint 能省下一半以上的空间。另一个是 Delta Encoding或者叫差值编码。比如时间戳序列 [1000, 1001, 1003, 1010]直接存这四个整数需要 16 字节存差值 [1000, 1, 2, 7] 只需要 8 字节如果差值继续用 Varint 编码还能更小。时序数据、监控指标这类数据集特别适合做差值编码。前缀压缩在字符串场景非常有用一组相邻 key 如果共享公共前缀比如 /user/10001/orders 和 /user/10002/orders前缀部分只存一次后面的差异部分完整保留。在图存储或者键值型数据里这种压缩往往能带来 30% 以上的额外收益。2.4 列簇拆分和列式编码压缩率的二次提升如果数据结构允许我强烈建议在业务建模阶段就把冷热属性映射到列簇层面。HBase 就是典型的列簇模型同一行数据的多个列簇存储到不同的 SSTable 中各自独立压缩、独立合并。举个例子一个用户信息表包含基本资料访问频繁和扩展画像基本不读。如果放在同一个列簇里查询基本资料时也要把那堆画像数据读出来解压Cassandra 的场景下尤其浪费。拆成两个列簇之后热列簇用 LZ4冷列簇用 ZSTD冷数据压缩率高且不影响热数据读取。类似的思路存在于 ClickHouse 这类列式存储里同一列的数据放在一起类型一致压缩率和编码效率远高于行式存储。所以如果你的查询模式以列维度的统计分析为主可以优先考虑列式存储引擎或者在某一些宽表场景中引入列式索引。3. 查询加速布隆过滤器、索引与缓存的三板斧3.1 布隆过滤器把无效 SSTable 挡在查询路径之外LSM-Tree 引擎查询慢的头号原因是点查时不知道 key 到底在哪个 SSTable于是只能逐个检查。布隆过滤器就是为了解决这个问题而生的每个 SSTable 维护一个位数组通过多个哈希函数计算 key能快速判断“这个 key 肯定不在表中”但不能保证“一定在”。布隆过滤器的核心参数是每个 key 的位数和哈希函数个数。位数太少误判率飙升过滤器形同虚设位数太多内存占用高得吓人冷数据上的布隆过滤器就成了纯浪费。我在热数据层把布隆过滤器设置得比较激进每个 key 分配 10 位误判率控制在 1% 左右冷数据层我直接不加载布隆过滤器到内存查询时走稀疏索引加块级扫描因为冷数据本来就很少被查没必要为了极低概率的访问承担高昂的内存成本。这样下来布隆过滤器的内存占用大幅下降而热查询的延迟反而更快了。3.2 稀疏索引用二分查找把扫描范围缩小一个数量级SSTable 内部的数据块是按 key 有序排列的。引擎为每个 SSTable 维护一个索引块记录每个数据块最后一个 key 的偏移量这就是稀疏索引。查询时先在索引块里做二分查找定位到可能包含目标 key 的数据块然后只加载这个数据块解压扫描而不是把整个 SSTable 扫一遍。这个机制看起来简单实际效果非常显著一个 256MB 的 SSTable 如果分成 4096 个数据块点查只需要加载 1/4096 的数据。我在冷数据层做了一项额外优化冷 SSTable 的索引块不再常驻内存而是在需要时冷加载。冷数据查询本来就少这个索引块加载的代价摊到每一次查询上可以接受但省下的内存却非常可观相当于用微小的查询延迟换了几 GB 的内存空间。3.3 Block Cache 与预取策略热数据走缓存冷数据走顺序读Block Cache 是 LSM-Tree 引擎里最重要的查询加速组件。它把最近访问过的数据块放在内存里下一次访问直接命中不再走磁盘 IO。RocksDB 默认的 LRU Cache、以及 HBase 的 BlockCache本质都一样。冷热分离架构下Block Cache 的分层管理尤为关键。热数据块的命中率应该尽量高所以我在分配缓存空间时给了热分区更高的权重冷数据块即使命中缓存也不应该长期占用内存所以冷分区使用单独的容量较小的缓存池且淘汰策略更激进。另外有一个容易被忽略的查询加速手段预取。顺序扫描场景下与其等当前数据块读完再发下一个 IO不如提前把下一块甚至下几块加载出来。我实测在宽表扫描场景里预取 2 个数据块能让扫描吞吐翻倍但预取太多会浪费 IO 带宽和缓存空间需要根据实际查询模式做调优。3.4 查询路径拆分热查走内存冷查走磁盘冷热分离架构下最理想的状态是对上层业务而言查询入口没有任何变化但内部查询路径已经按冷热拆分。热数据的查询路径通常是缓存 - MemTable - 热 SSTable 的布隆过滤器 Block Cache整个过程大部分在内存里完成磁盘 IO 极少。冷数据的查询路径则是跳过布隆过滤器 - 直接加载冷索引块 - 顺序读取目标数据块 - 解压返回延迟明显更高但吞吐和并发表现平稳。我把这个差异化查询路径做成了一个可观测的指标热查询 P99 稳定在 5ms 以内冷查询 P99 在 50ms 以内。对于业务方来说绝大多数交互式请求走热路径体验几乎不受影响而成本上90% 的数据都跑在便宜存储上。4. 邻接表与 CSR 压缩存储内存空间消耗真不在一个量级4.1 邻接表的真实内存账本最近圈里讨论热度很高的问题是邻接表和 CSR 压缩存储内存空间消耗是不是同一个量级我的结论非常明确不是而且差距可以是好几倍。这个问题的答案在做图存储、图引擎或者基于 LSM-Tree 的图数据落盘时直接决定了架构选型。先看邻接表的常规实现。假设有一个有向图顶点数 V 1 亿边数 E 10 亿这是一个相当稀疏的图。用 C 里最常见的 vectorvector 实现每个顶点对应一个 vector而每个 vector 对象本身就有 3 个指针的空间开销大约是 24 字节。光 1 亿个 vector 的开头就是 2.4GB。每条边存一个目标顶点 ID用 int32 是 4 字节10 亿条边就是 4GB。但这还没算完。vector 扩容时有 capacity 大于 size 的浪费以及内存碎片化实际内存占用通常要达到理论值的 1.5 倍以上。所以这种实现的总内存轻松超过 10GB而你还只是存了一个最简单的图结构。如果换成链式邻接表每个边节点至少包含目标顶点 ID4 字节和 next 指针8 字节受内存对齐影响这个节点实际要占 16 字节。10 亿条边的边节点就是 16GB这还没把顶点数组算进去。可以这么说链式邻接表是内存杀手。4.2 CSR把指针扔了空间逼近理论下界再看 CSR全称 Compressed Sparse Row中文叫压缩稀疏行。CSR 的核心思想不复杂把所有顶点的邻居连续放在一个数组里再用一个偏移数组记录每个顶点的邻居在哪个区间。具体到上面的例子CSR 需要两个数组偏移数组的长度是 V1用 int32 表示每个顶点的邻居起点占用约 0.4GB邻居数组的长度是 E存目标顶点 ID用 int32 是 4 字节总占 4GB。两者相加大约 4.4GB就已经完整表达了整个图结构。如果不需要动态更新还可以进一步压缩邻居数组按局部性排序后相邻节点 ID 的差值通常很小用 Varint 编码可以再压缩 30% 到 40%。也就是说CSR 方案能做到图数据内存占用接近理论下界。4GB 和 10GB 甚至 20GB 的差距你说是不是一个量级早年我在图引擎项目里把邻接表改成 CSR单机单图的内存占用直接下降 60% 多当时团队都不敢相信没有引入任何外部依赖只是换了一种数据表示方式。4.3 差距从哪来指针、对齐和容器开销邻接表和 CSR 的差距根源在哪里答案是指针和容器壳子。链式邻接表每条边带一个 next 指针下一条边的位置不保证在内存里连续必须额外存一个地址vector 虽然内存连续但每个顶点带一个 24 字节的对象头。CSR 把这些全部去掉用“偏移量”代替“指针”用“连续数组”代替“分散节点”于是空间利用率和缓存友好性都大幅提升。这也是我认为 CSR 尤其适合与 LSM-Tree 结合的原因MemTable 写入时为了支持高频更新用一个哈希索引的邻接表或类似结构是合理的数据还在内存里多花一点空间换来写入性能但一旦数据刷入 SSTable转为不可变状态就应该做一次格式重排转成 CSR 类似的连续布局再配合 Varint 压缩。热数据在内存享受邻接表的访问便利冷数据在磁盘享受 CSR 的空间优势和顺序扫描优势这本身就是一种冷热分离思想在数据结构层面的实践。冷 SStable 上的 CSR 布局还有一个额外好处当业务需要做图分析类查询时比如遍历某个节点的所有邻居只需要读取一段连续内存顺序 IO 效率远远高于链式邻接表的随机访问。实测下来遍历性能可以提升 5 倍以上。这也侧面说明冷数据存储并不是简单地“丢到便宜盘上”压缩和存储格式的优化同样重要。5. 容量规划与压测实录架构落地的关键数据5.1 从容量需求反推压缩目标冷热分离方案落地之前我先做的不是写代码而是算账。算清楚现有数据的组成、增长趋势和压缩后的物理空间需求才能确定硬件采购和架构方案。已知条件集群当前总数据量 900TB月增长约 30TB。按目前保留策略预计 12 个月后达到 1.2PB 左右。业务方反馈约 8% 的数据属于高频访问15% 的数据偶尔访问剩余 77% 的数据基本无人问津。方案设计如下热层保留最近 30 天数据约占总量 8% 到 10%放 SSD采用 LZ4 压缩和 3 副本温层保留 30 到 180 天的数据约占 20%放高性能机械盘或 SATA SSD采用 ZSTD level 32 副本冷层保留 180 天以上及全量归档数据约占 70% 以上放大容量机械盘或对象存储采用 ZSTD level 91 副本加纠删码。压缩率预估热层数据多为近期写入重复度一般LZ4 大概 2 倍冷层数据经过多轮合并且业务上有大量相似记录ZSTD level 9 保守估计 3.5 倍。这样算下来1.2PB 逻辑数据最终物理占用大约 400TB比原来的 3 副本 3.6PB 下降了 88% 以上这就是冷热分离和压缩叠加的效果。5.2 压测方案和关键指标压测这一步绝对不能省。我当时的压测主要关注四个指标写入吞吐、点查延迟、范围扫描吞吐、合并耗时。写入吞吐的压测方法是用与生产一致的负载模型写入 500GB 数据观察 P99 写入延迟和每节点吞吐。热层启用 LZ4 压缩后写入吞吐从原来的 320MB/s 提升到 410MB/s主要原因是压缩后的数据量变小刷盘 IO 变少。点查延迟的压测方法是构造一批均匀分布的 key分别查询热分区和冷分区。热分区 P99 为 4.8ms冷分区 P99 为 42ms。差异完全符合预期业务侧可以接受。范围扫描吞吐的压测方法按时间范围扫描冷数据用 CSR 布局加上顺序预取之后扫描吞吐达到 280MB/s比改造前高了 1.8 倍。合并耗时这个指标容易被忽略但影响很大。冷数据如果频繁参与合并ZSTD 压缩和解压会消耗大量 CPU导致合并进度拖慢。所以我调整了 Compaction 策略冷分区的 SSTable 合并频率大幅降低只有当文件数超过阈值时才触发一次大合并。5.3 调参过程中的几个关键取舍压测期间踩了一些坑也沉淀了一些调参经验。第一个取舍热层压缩算法。最初我图省事统一用 Snappy压测下来热层写入吞吐没到预期。换成 LZ4 后吞吐明显提升压缩率差距不大所以热层用 LZ4 是更优选择。第二个取舍冷层块大小。冷层我最初沿用热层的 16KB 块压缩率偏低。把块大小调到 32KB 后压缩率从 3.1 提升到 3.6查询延迟只增加了不到 5ms完全可接受。第三个取舍布隆过滤器的加载策略。冷层布隆过滤器经常命中规则是“不加载”效果确实明显但操作上要小心如果业务突然出现大规模冷数据回访比如历史报表下载所有冷查询都会落盘IO 压力会瞬间打满。因此我加了一个熔断兜底当冷查询失败率超过阈值时自动为最近访问的冷 SSTable 重建布隆过滤器并加载到内存同时在监控页面上发出告警。6. 常见问题与排查技巧真实环境里的血泪教训6.1 Compaction 期间读放大飙升怎么办现象Major Compaction 运行时业务查询延迟明显上涨RT 曲线出现周期性尖刺。排查过程我先看了 Compaction 线程数量和 IO 队列发现 Compaction 期间大量磁盘带宽被占用导致读路径的 IO 排队时间拉长。根本原因是 Compaction 和查询共用同一批磁盘 IO 资源没有任何隔离。解决方案分两步走。第一配置 IO 优先级让 Compaction 的读写请求走低优先级队列查询走的请求走正常优先级。大多数存储引擎都支持类似的 IO 限流配置效果非常直接。第二限制 Compaction 线程数我把 Compaction 线程数从默认的 4 降到 2合并耗时会变长但查询延迟恢复了稳定。高峰时段甚至可以让 Compaction 休眠等到低峰期再唤醒。6.2 数据量增长后 Bloom Filter 误判率上升现象随着数据量增长一些点查请求开始逐个 SSTable 扫描延迟涨了好几倍。排查过程检查日志会发现布隆过滤器的误判率从 1% 涨到了 5% 左右。原因是新版本中 SStable 数量增长过快每个 SSTable 分配的 key 位数被稀释。同时冷数据迁移后某些热层 SSTable 的布隆过滤器在重建过程中采用了更小的 size 参数。解决方案针对热数据层单独调大布隆过滤器内存预算保证每个 key 稳定分配到 10 个位冷数据层保持不加载。同时优化了 SSTable 合并时机减少小文件数量从根源上降低布隆过滤器的数量碎片化。6.3 冷数据回查导致节点 IO 瞬时打满现象某天凌晨报表任务触发大量冷数据被扫描节点磁盘 IO 瞬间饱和部分在线查询超时。排查过程查了监控发现冷数据存储节点没做 IO 隔离和限流大规模的离线扫描请求直接压垮了磁盘。解决方案第一给冷数据查询单独设置并发限制和速率限制超出的请求排队等待。第二离线扫描任务改用批量游标分片读取避免一个流量尖峰打满全部 IO。第三冷数据存储节点与热数据节点物理隔离从根源上保障热查询不受冷查询干扰。6.4 稀疏索引加载慢导致冷查询首包延迟高现象冷查询第一次访问某个 SSTable 时延迟高达几百毫秒远高于后续查询。排查过程原因是冷 SStable 的索引块不常驻内存首次访问需要读磁盘加载索引再加上冷数据跨机械盘寻址延迟自然高。解决方案我把热点冷数据的索引块做了一个小型 LRU 缓存容量只有 128MB但能缓存大量常用索引块首包延迟从几百毫秒降到了 30ms 左右。这个技巧性价比非常高强烈建议冷数据查询场景都加上。6.5 调完参以后一定要留的观测指标最后聊一个经验层面的东西。冷热分离和压缩调优最忌讳黑盒操作调完参数看不到效果就继续乱调。我建议每一个做存储优化的团队至少保证以下指标完整覆盖监控热查询 P99、冷查询 P99、各存储层空间利用率、压缩率实时统计、Compaction 耗时与频率、IO 队列深度、布隆过滤器误判率、缓存命中率。有了这些指标任何一次调优都能回到数据上做判断而不是靠感觉。以我个人实际操作的体会来说冷热分离不是一次性改完就结束的事情而是一个需要持续观察数据访问模式变化的长期调优过程。不同业务的冷热边界会漂移存储引擎版本升级也可能会改变压缩和合并行为定期复盘监控数据、微调阈值参数才能让这套架构始终在成本与性能的平衡点上。

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价 →
↑