
PB 级数据存储一旦进入生产环境最先被反复追问的问题往往不是“怎么写入更快”而是“这么多数据放哪、怎么才能不被存储成本压垮”。我过去一年主要在带一个日志与时序数据的存储链路把架构从单集群混存改成了基于 LSM-Tree 引擎的冷热分离架构存储成本压下来一大截热查询的 P99 也稳住了。这篇文章想把冷热分离如何定义、LSM-Tree 引擎下的压缩怎么做、查询加速怎么落地以及我实际踩过的坑一次说清楚。适合正在做存储选型、或者正被大数据量和查询延迟一起折磨的工程师参考。1. 为什么 PB 级数据存储必须先想清楚冷热分离1.1 数据访问的“温度分布”是客观规律到 PB 量级后我基本没见过哪份数据是全程均匀热读的。绝大多数场景都符合二八甚至一九规律一周内写入的数据贡献了 80% 以上的查询一个月以前的数据可能一周才被扫一次。这不是业务设计失误而是数据本身的时效性决定的。拿日志系统来说故障排查基本聚焦在最近几小时到几天老日志只是合规留存。用户行为轨迹也是同理分析任务跑最近一段时间的窗口历史明细只在做长周期回溯时偶尔拉一把。如果你的存储层不区分热度所有 SSTable 都会被同等对待——热数据要和冷数据挤在同一个 Compaction 队列里冷数据的老版本也会反复被合并。结果就是热查询延迟被无关任务拖累冷数据又在持续消耗 CPU 和磁盘空间。想明白温度分布这件事冷热分离就不是一个可选项而是 PB 级存储的必选项。1.2 冷热混存带来的三个典型问题结合我自己的排查经验冷热混存的问题一般会以三种方式暴露出来。第一个是成本失控。全 SSD 集群跑 PB 级数据成本非常夸张如果你为了省成本把热数据也放到 HDD那 P99 查询延迟基本不可接受。成本与性能的矛盾不靠分层几乎无解。第二个是 Compaction 放大。LSM-Tree 引擎的写放大和空间放大直接和总数据量挂钩。冷数据并不会因为没人读就不参与合并你每写一次热数据Compaction 都可能把旁边历史悠久的数据再翻出来一遍。曾有一次我查线上 CPU 异常定位到是有个大分区的深层文件反复被卷入底层合并而那批数据明明三个月都没人读过。第三个是查询互相干扰。一次全表扫描的冷查询会把热数据的内存缓存、磁盘 IO 带宽全部吃掉热查询毛刺就是这么来的。冷热分离之后这类问题能避免一大半。1.3 为什么底座要选 LSM-Tree 引擎冷热分离可以建在任何存储引擎之上但在 PB 级写入密集场景下LSM-Tree 几乎是绕不开的底座。原因主要有三点。第一写入路径是顺序追加。LSM-Tree 先把数据写进内存中的 MemTable同时记录 WAL落盘时生成不可变的 SSTable整个写入以顺序 IO 为主吞吐上限很高。第二它有天然的层级结构。L0 到 Ln 数据一层层合并下沉这本身就是按“新旧”在分层非常契合“新数据热、老数据冷”的规律。第三引擎的 Compaction 和压缩器都是开放配置的你可以针对不同层级、不同列族设置不同压缩策略这给冷热分离提供了非常灵活的落地手段。后面讲的所有方案我都会默认存储引擎是 LSM-Tree具体到 RocksDB、HBase、Cassandra、ScyllaDB 都适用只是 API 和参数名称略有差异。2. 冷热分离架构的整体设计与数据分层2.1 热度判定规则怎么定做冷热分离第一步不是搭集群而是定义“什么是热数据”。规则要满足两个要求业务上解释得通工程上执行起来简单。我最常用的规则是时间窗口加访问频率双条件。时间窗口一般设定为“最近 7 天写入的数据默认热数据超过 30 天的默认冷数据”。中间 7 到 30 天视为温数据可以单独设一档也可以直接归入热。这个窗口不是拍脑袋定的要看业务回溯周期。像日志、监控指标这类业务7 天窗口基本够用交易流水可能要放宽到 30 天甚至 90 天。访问频率用访问次数除以数据年龄低于阈值的直接标记冷。阈值的计算我建议做成动态的统计每个分区的最近 24 小时查询次数和 IO 字节数低于整体平均值 10% 的分区下一轮迁移时优先转冷。线上可以用这个思路做一个小时级的扫描任务提前生成待迁移分区列表。流量计算上可以给一个简单参考如果一个数据分区的日查询次数低于该表整体日查询次数的 5%且数据年龄超过 7 天我就把它标记为可转冷。规则跑起来之后要定期回看误判率——如果经常发现刚转冷的数据又被高频访问说明阈值定得太激进了。2.2 物理分层与存储介质选型分层最终要落到不同的物理存储介质上。我这边采用的是三层设计热层、温层、冷层。热层放在 NVMe SSD 上跑的是最近几天的活跃数据使用 LSM-Tree 引擎的高频写入路径和内存索引。温层可以放在 SATA SSD 或高性能 HDD 上存中间状态的数据索引可以稀疏一些。冷层放在大容量 HDD 或对象存储上数据做高压缩比编码基本不建二级索引。选介质时不用迷信单点性能要算综合成本。我当时的估算方式是PB 级数据如果 90% 都转冷冷层的存储成本大概是热层的 1/6 到 1/10这一下就能把硬件预算拉回一个可控范围。对象存储的读写延迟在秒级只能承担离线分析不要让它出现在在线查询路径上。2.3 数据迁移的整体流程设计数据从热到冷不能直接“删了重建”。我用的流程是三个环节。标记阶段按热度规则批量生成迁移任务每个任务对应一个分区或一组 SSTable 文件。迁移阶段先把冷数据文件复制到目标存储复制完成后做校验和对比确认数据条数和 checksum 一致再删源。这里最关键的是“先复制再删除”避免任何单点故障导致数据丢失。切读阶段迁移完成后在线查询路径需要感知到数据位置变化。一般有两种做法一种是查询引擎里维护一层“数据路由表”记录每个分区的真实位置另一种是把冷数据转成独立的列式文件查询时通过统一的查询入口去扫。后者在我们场景里更常用因为后文要说的压缩和加速手段都依赖重新组织文件格式。3. LSM-Tree 引擎下的存储压缩实践3.1 LSM-Tree 的压缩机制与写放大问题很多人把 LSM-Tree 的压缩等同于“列式压缩”或者“通用压缩算法”这是误解。LSM-Tree 里的压缩有两层含义一是 Compaction 的合并压缩二是数据块级别的编码压缩。Compaction 是把多个 SSTable 合并成一个更大、更有序的 SSTable清理掉重复和删除的数据。它的好处是减少文件数量、保持有序性代价是写放大。如果引擎配置激进一次写入可能要反复参与合并最坏达到 20 倍以上的写放大。降低写放大最直接的手段是调整层级大小倍数和触发阈值让大多数合并发生在浅层。数据块压缩则是在 SSTable 的每个 block 上做的。RocksDB 默认支持 Snappy、LZ4、ZSTD 等压缩算法每个 block 在写入时压缩读取时解压。热数据在读多写少的路径上要选解压开销小的算法冷数据要选压缩率高的算法。这里面每个算法的性价比差异很大后面单独给一组实测数据。3.2 冷数据压缩策略的工程调优这里给一组我实测比较常用的压缩算法参考同样一份日志类型数据在相同 block 大小下测得算法CPU 开销压缩率适合场景LZ4很低2-3x热数据、在线查询路径Snappy低2-3x与 LZ4 接近兼容性更好ZSTD默认级别中等3-5x温数据、冷数据ZSTD最高级别高5-10x几乎不再在线访问的冷数据注意压缩率受数据内容影响极大。文本日志的重复模式多ZSTD 能压到 10x 以上已经经过编码的二进制数据可能只有 2x。另外RocksDB 支持按层配置压缩算法我现在的做法是L0 到 L2 用 LZ4保证新写入数据查询不卡L3 及以下用 ZSTD因为沉到这里的文件基本就是温冷数据了。配合 block_size 的调整冷层把 block_size 从默认 4KB 改大到 64KB可以让 ZSTD 获得更大的上下文窗口压缩率能再提升 15%-20%代价是随机读变差——冷数据本来就不需要高频随机读这个取舍非常划算。3.3 邻接表与 CSR 压缩存储的内存空间消耗对比有段时间我在弄图关系类的冷数据存储社区里有个问题问得很多“邻接表和 CSR 压缩存储内存空间消耗是同一个量级吗”结论很明确不是。规模一大二者能差出一个数量级。邻接表是每个顶点挂一个邻居列表。实现上要么是 vector 套 list要么是 map 嵌套 set。每条边除了存对端顶点 ID4 字节或 8 字节还要付对象头、指针、容器扩容预留空间。实测下来一亿条边在 Java/C 的邻接表实现里内存轻松超过 3GB算下来每条边 30-80 字节都正常。CSRCompressed Sparse Row是图计算里非常经典的压缩布局。它用两个数组一个 offset 数组长度是顶点数加一记录每个顶点的邻居在第二个数组里的起止位置一个 neighbor 数组长度是边数顺序存储所有邻居 ID。没有对象头、没有指针、也没有扩容预留。每条边只占 4 字节int32或 8 字节int64每个顶点额外 4 字节 offset。一亿条边用 int32 大概就是 400MB 出头如果用 int64 也就 700MB 左右。所以量级上CSR 比邻接表通常能省 50%-80% 甚至更多。代价是 CSR 是静态结构增删边不方便。这正好适合冷数据老数据不更新、只读取用 CSR 做压缩存储收益很高热数据保留邻接表方便频繁更新和遍历。冷热分离的架构里这两种结构完全可以共存各管一段。4. 查询加速从热索引到冷扫描的协同优化4.1 热数据的索引与缓存设计压缩省了空间但查询加速才是冷热分离的另一半收益。热数据查询的目标是低延迟手段主要是索引和内存缓存。LSM-Tree 引擎本身有两层索引SSTable 的稀疏索引每个 block 起始 key和内存中的 Bloom Filter。Bloom Filter 的核心价值是快速判断一个 key 一定不在某个 SSTable 里避免无谓的磁盘 IO。误判率默认 1% 左右但对读路径的加速非常明显。调 Bloom Filter 时要注意不是说越大越好过大的过滤器和内存成本会不成比例后面参数部分我会给一个具体的 bits/key 计算。缓存方面热数据分区的 block cache 一定要给足并且要考虑读路径和 Compaction 之间的隔离。RocksDB 里 block cache 默认是统一 LRU热数据频繁被扫描时Compaction 读到的冷 block 会把热 block 挤出去这种问题线上很常见。解决方式是给不同 CF 单独分配 cache或者用 cache 的优先级策略让热数据 block 不被淘汰。4.2 冷数据的查询扫描加速冷数据分析类查询走索引反而不一定划算因为扫描比例高、单次读取量大。这时候更有效的是列式化、谓词下推和并行扫描。列式化是指把冷数据从行式 SSTable 重排为列式布局比如 Parquet 或 ORC 风格。同一列的数据连续存储压缩率更高扫描时也可以只读取查询涉及的列。比如日志冷数据很多查询只需要查 level 和 message 两个字段不需要把整行读出来再过滤列式存储能省大量 IO。这块如果做成定时任务在数据转冷时自动格式转换对查询侧是透明的。谓词下推则是把 where 条件尽量下沉到存储层让每一行在读出后立刻淘汰减少数据传输。对对象存储上的冷数据尤其重要因为对象存储不适合海量随机读。我见过不少团队把冷数据放上对象存储后查询接口还是按行拉全量结果一个简单过滤条件要跑半小时问题就出在过滤逻辑放在了最上层。并行扫描是把冷分区按文件或 offset 切成多个分片用多线程或分布式任务并发扫描。PB 级冷数据的单次全量扫描不可能在秒级完成但要能通过扩大并发把时间从小时级降到分钟级。这个过程中注意合理控制并发度避免把冷数据所在的 HDD 磁盘 IO 打满尤其是对象存储的请求配额也要一并考虑。4.3 冷热联动与预聚合计算查询往往不会只查热或只查冷而是需要冷热拼接。比如“最近 30 天的趋势分析”近 7 天在热层7 到 30 天在温冷层。直接跨层 join 会产生大量网络开销和中间数据。我常用的解法是预聚合和物化视图。预聚合是按业务维度预先计算出小时级、天级聚合结果查询时优先命中聚合数据明细数据回退到冷层扫描。以日志统计为例提前把每分钟的 error 数量、P99 延迟计算好用户查趋势时直接读聚合结果完全不用碰明细。这个思路说来简单真正落地时要注意聚合数据的更新延迟别让用户看到的趋势和明细对不上。物化视图则是针对固定查询模式直接把结果表落下来更新策略是增量合并。数据从热转冷时同步触发物化视图的增量更新这样查询端永远读的是一个相对小的结果集。如果预聚合和物化视图都不适合最后的手段才是实时扫描冷数据但要给这类查询单独设置超时和并发上限防止拖垮热查询。5. 实操过程与核心环节实现5.1 引擎选型与架构落地具体落地时引擎选型决定了后面所有参数的写法。我以最常用的 RocksDB 和 HBase 为例说明。RocksDB 是嵌入式引擎适合作为自研存储服务或数据湖/图存储的底层。它原生支持多 Column Family我们可以把热数据和冷数据放在不同 CF甚至通过文件系统插件的不同目录直接指向不同介质。HBase 是分布式 LSM-Tree 系统RegionServer 承担读写和 Compaction适合已经有多机房、多副本需求的团队。对 HBase 做冷热分离通常做法是按表或按 Region 的时间戳去分集群再配合 HDFS 或者对象存储做归档。如果是从零搭建我更推荐 RocksDB 或 ScyllaDB 这类节点级容灾做得好的引擎。下面参数配置部分基于 RocksDB 给出其他引擎的配置思想可以平滑迁移。5.2 关键参数配置与计算先给一组我线上使用的 RocksDB 相关配置场景是日志型写入、7 天热数据、30 天冷数据。write_buffer_size 256MB max_write_buffer_number 4 level0_file_num_compaction_trigger 4 level0_slowdown_writes_trigger 20 level0_stop_writes_trigger 32 max_bytes_for_level_base 512MB max_bytes_for_level_multiplier 10 block_size 64KB bloom_locality 1几个参数的选择逻辑write_buffer_size 决定每个 MemTable 的大小。256MB 在日志场景下既保证了批量写的吞吐又不会让 WAL 恢复时间过长。如果机器内存小可以降到 128MB否则写放大和内存占用不平衡。level0_file_num_compaction_trigger 设成 4意思是 L0 文件数量到 4 就开始往 L1 合并。值太小会导致 Compaction 频繁值太大又会让 L0 的读路径退化。这个值建议和 write_buffer_size 联动调整。max_bytes_for_level_base 与 max_bytes_for_level_multiplier 配合控制分层大小。按 512MB 基准、10 倍增速L1 约 512MBL2 约 5GBL3 约 50GB。冷数据沉到深层后单个 SSTable 可以非常大这样文件数量稳定后台合并压力也小。Bloom Filter 的内存预算可以按公式计算m -n * ln(p) / (ln2)^2。m 是需要的 bit 数n 是预估 key 数p 是允许的误判率。我常用 p1%10 亿 key 的情况下m 大约只需要 12GB 左右平均每个 key 不到 10 bit。如果 p 放宽到 5%内存可以降到 6-7GB。实际配置时可以按这个公式反推先定误判率再看内存能不能接受。5.3 冷热迁移的实操步骤最后是迁移怎么做。步骤拆开如下。第一步双写。新架构正式上线前先把在线写入同时打到热集群和新链路保证新链路的数据追平。这一步要持续观察两边的延迟和写入成功率差异别急着往下走。第二步存量迁移。按分区生成迁移任务每个分区关联一段时间范围。我是用小时级任务批处理比如一次迁移某天全部分区。任务执行时先导出 SSTable 或底层数据文件再做列式转换和压缩推送到冷存储。建议每批任务跑完后输出一份清单标明成功、失败和重跑状态。第三步校验。对比源和目标的数据条数、默认字段 checksum不一致的自动重跑。这一步一定要做成自动化靠人工看数据根本没法搞。尤其是对象存储场景网络抖动可能导致文件上传不完整漏掉校验就是埋雷。第四步切读。热数据查询先切到双读模式对比返回结果确认一致后把查询流量完全切到新链路老链路保留若干天用于兜底回滚。第五步降本。确认稳定后老集群的数据按保留策略清理然后开始调整容量规划。清理也要按批次来先删最早的、最后删最近备用的避免回滚时没有退路。这个流程里最容易出问题的是第二步存量迁移时会有新数据持续写入同一个分区。解决方式是迁移任务处理时先拿到该分区当时的版本号迁移完成后再次校验如果有新写入就标记该分区为“迁移中”下一轮重新执行保证最终一致。6. 常见问题与排查技巧实录6.1 高频问题速查表我把过去一年线上遇到的典型问题整理成一个速查表方便你遇到相似现象时快速定位。现象可能原因排查方法P99 延迟突增冷数据 Compaction 与热查询抢 IO查看 Compaction 线程池和磁盘 IO 排队压缩率不如预期block_size 太小或数据自重复低增大 block_size检查数据分布迁移后数据不一致迁移期间仍有写入引入版本号重跑机制内存持续上涨Bloom Filter 占比过大用公式反算误判率适当调低 bits/key冷查询拖垮热查询扫描并发无限制给冷查询单独设置并发上限与优先级排查时先抓现象再反向找资源瓶颈。延迟突增第一件事是看磁盘 IO 和 CPU 的等待线程而不是先怀疑代码逻辑。存储层的性能问题大概率出在 IO 竞争和 Compaction 策略上。6.2 实战排查案例与避坑经验分享一个实际案例。有一次线上反馈热数据查询偶发超时持续时间几秒到几十秒不等。我一开始怀疑是索引失效后来发现热分区的 SSTable 数量在某个时间窗口暴涨而 L0 到 L1 的 Compaction 被积压任务的 CPU 抢占。定位过程是这样的先看监控发现磁盘 IO 的读写比例异常write 比例高得不像业务写入的量级再看 Compaction 日志发现有不少历史冷文件被卷入深层合并。最后处理方案是给小分区设置独立的 Compaction 优先级把冷数据文件先手动触发了一次 full compaction 合并成大文件再调整了层级倍数参数让它们不再频繁参与合并。从那以后我不再把所有数据放在同一套参数下管理冷热数据一定分 CF、分参数。关于避坑经验我想特别提三点。一是不要一上来就追求最极致压缩。ZSTD 最高级别虽然压缩率高但写路径 CPU 开销极大用在热数据上会把系统拖慢。先默认档跑起来观察 CPU 再往上调。二是冷数据的放置要规划好再动手。对象存储的读延迟高如果查询模式其实还会频繁访问那说明你的“冷”定义错了要回炉调整热度阈值。三是迁移过程中的校验比迁移本身更重要。没有 checksum 自动对比的迁移等于在给自己埋雷。特别是 PB 级规模下靠人工抽查基本查不出问题。最后说点我个人的体会。做冷热分离最花时间的部分其实不是压缩和查询加速而是怎么把“冷热”这件事定义清楚、让全链路的人都认可。技术方案再漂亮如果热度阈值定得不贴合业务迁移回去的概率很高。我的习惯是上线前先拉三个月的数据访问频率分布让数字说话上线后每两周复盘一次热度阈值动态修正。等整套机制跑顺了存储成本、查询延迟、运维精力都会明显改善。这也是我在 PB 级存储项目里做的最有价值的一件事。