Apache Cassandra LSM 压缩策略深度实战:从 STCS/LCS/TWCS 到统一压缩 UCS 的工程全解

如果你运维过 TB 级别的 Cassandra 集群,一定经历过这样的夜晚:nodetool compactionstats 里 pending tasks 一路飙到几百,磁盘水位线从 60% 爬到 85%,读延迟 P99 从 5ms 涨到 200ms,而你手上的解法只有"调大 compaction throughput"这一张牌。

这篇文章试图把 Compaction 这件"玄学"拆成可计算的工程问题:先讲清楚 SSTable 的物理布局决定了哪些事必须做、哪些事不可能避免,再逐个剖析 STCS / LCS / TWCS 三代的权衡曲面,最后落到 Cassandra 5.0 引入的 UnifiedCompactionStrategy(UCS)——它把前三者统一成一棵可调参的树。

一、SSTable 的物理布局:所有 Compaction 决策的起点

Cassandra 的一个 SSTable 在磁盘上不是一个文件,而是一组组件文件:

md-1234-big-Data.db          # 实际数据,按 partition token 顺序排列
md-1234-big-Index.db         # partition key -> Data.db offset 的索引
md-1234-big-Summary.db       # Index.db 的抽样索引(默认 128:1),常驻内存
md-1234-big-Filter.db        # Bloom filter,判断 partition 是否可能存在
md-1234-big-Statistics.db    # 列的最小值/最大值、直方图、tombstone 时间戳下界
md-1234-big-CompressionInfo.db
md-1234-big-Digest.crc32 / TOC.txt

理解这张清单有两个关键推论:

推论 1:SSTable 是不可变的(immutable)。 这是 LSM 的根本约束。所有"修改"都必须重写一份新文件,再原子性地替换旧文件。因此 Compaction 本质上是"读 N 个有序文件 → 归并排序 → 写 1 个新文件 → 删除旧文件"的过程,它天然产生写放大。

推论 2:单个 SSTable 内部是按 token 有序的,但多个 SSTable 之间完全无序。 一次读请求在某个节点上,最坏情况要检查所有未参与 compaction 的 SSTable。这就是为什么"减少 SSTable 数量"是 Compaction 的核心目标之一。

于是我们得到 LSM 经典的三角约束:

  • 读放大(Read Amplification):一次逻辑读需要触碰的物理 SSTable 数量。
  • 写放大(Write Amplification):每写入 1 字节用户数据,实际落盘的字节数。
  • 空间放大(Space Amplification):磁盘占用 / 有效数据量,主要来自未回收的旧版本与 tombstone。

三者不可兼得。所谓"选择 Compaction 策略",就是在这个三角上选一个你认为最不痛的角。

二、STCS:为写密集场景优化的桶归并

SizeTieredCompactionStrategy 是默认策略,逻辑朴素:把大小相近的 SSTable 归为一组(bucket),组内的若干个合并成一个更大的。

Cassandra 的 bucket 划分逻辑(SizeTieredCompactionStrategy.getBuckets)可以简化成这样:

def get_buckets(sstables, bucket_high=1.5, bucket_low=0.5):
    """sstables: [(size_bytes, sstable_obj)], 已按 size 升序"""
    buckets = []
    for size, sst in sstables:
        placed = False
        for b in buckets:
            avg = sum(x[0] for x in b) / len(b)
            # bucket_low * avg <= size <= bucket_high * avg 才归为同一组
            if bucket_low * avg <= size <= bucket_high * avg:
                b.append((size, sst))
                placed = True
                break
        if not placed:
            buckets.append([(size, sst)])
    return buckets

bucket_high=1.5 意味着一个桶里最大文件和平均值的比值不能超过 1.5 倍。默认当某个桶里累积到 min_threshold=4 个 SSTable 时,触发一次合并。

STCS 的优势:每次合并只在 4 个左右文件之间进行,单次 IO 量小,写放大相对可控(典型 4~10x)。写多读少的场景(日志收集、事件流)非常合适。

STCS 的致命弱点:空间放大。 假设你反复更新同一个 partition,每次更新都产生一个新 SSTable。在最坏情况下,一个 partition 的 20 个历史版本分散在 20 个 SSTable 里,只有新一轮 compaction 才会把它们归并。Cassandra 官方文档给出的经验值是:STCS 需要预留 50% 以上的空闲磁盘,否则 compaction 会在写满磁盘前无法完成——因为合并过程中新旧文件同时存在,峰值占用接近 2 倍。

这是一个经常被忽略的生产事故源:磁盘到 80% 时还能正常写入,但 compaction 需要的临时空间已经不够了,然后集群进入"写入被拒 → 运维介入 → 手动 nodetool compact → 雪上加霜"的循环。

三、LCS:用写放大换读放大

LeveledCompactionStrategy 借鉴 LevelDB/RocksDB 的分层思想:

  • 新写入的 SSTable 进入 L0(由 memtable flush 产生)。
  • L0 的 SSTable 之间允许 token 范围重叠。
  • L1 及以上的每一层,SSTable 之间保证不重叠,每层总大小是上一层的 fanout_size 倍(默认 10)。
L0:  [S1][S2][S3]              # 可重叠,flush 直接产出
L1:  [----][----][----]        # 不重叠,总量 ~160MB
L2:  [--][--][--][--][--]      # ~1.6GB
L3:  ...                       # ~16GB

当 L_n 超过容量上限时,选出 L_n 中若干 SSTable,与 L_{n+1} 中 token 范围重叠的所有 SSTable 合并,产出写回 L_{n+1}。

这样做的好处是读放大被严格约束:因为 L1+ 每层内部不重叠,一次 partition 读最多检查每层的 1 个 SSTable。对 6 层、100GB 数据量的表,读放大约等于层数(个位数),而 STCS 在最坏情况下是 SSTable 总数(可能几十甚至上百)。

代价是写放大。每次 L_n → L_{n+1} 的合并,扇出比是 10,意味着 1 字节数据在跨过一层时被重写约 10 次。Cassandra 社区的实测经验是:LCS 的写放大可达 20x 甚至更高,对 HDD 是灾难,对 NVMe 才勉强可接受。

CQL 定义:

CREATE TABLE events (
    user_id   uuid,
    ts        timestamp,
    payload   text,
    PRIMARY KEY ((user_id), ts)
) WITH compaction = {
    'class': 'LeveledCompactionStrategy',
    'sstable_size_in_mb': '160',
    'fanout_size': '10'
};

fanout_size 是个常被低估的旋钮。把 10 降到 4,写放大显著下降(约 4x/层),但层数增加、读放大上升。我的建议是:如果是 NVMe + 读多写少的业务,fanout 保持 10;如果是 SATA SSD 且写入量不小,降到 5~6,并密切观察 pending compactions。

判断 LCS 是否健康只有一个硬指标:

nodetool compactionstats
# pending tasks: 0 表示健康
# 长期 > 100 说明 compaction 吞吐跟不上写入速度

吞吐可调,但这是有代价的:

# 默认 16 MB/s(对 NVMe 极其保守)
nodetool setcompactionthroughput 64   # 单位 MB/s,节点级别
nodetool setconcurrentcompactors 4    # 通常设为 min(磁盘数, CPU核数/2)

需要警惕的是:调大 throughput 会让 compaction 抢占读请求的 IO,读延迟会变差。这是一场零和博弈,没有免费午餐。

四、TWCS:时间序列表的正确解法

TimeWindowCompactionStrategy 专门为时间序列数据设计。核心洞察是:如果数据带 TTL 且写入时间单调,那么按时间窗口切分的 SSTable 可以被整体丢弃,而不必 compaction。

CREATE TABLE metrics (
    metric_id  text,
    ts         timestamp,
    value      double,
    PRIMARY KEY ((metric_id), ts)
) WITH default_time_to_live = 604800     -- 7 天
  AND compaction = {
    'class': 'TimeWindowCompactionStrategy',
    'compaction_window_unit': 'DAYS',
    'compaction_window_size': '1'
};

工作机制:

  1. 每个时间窗口(这里 1 天)内的 SSTable,用 STCS 的方式互相合并。
  2. 跨窗口的 SSTable 永不合并。
  3. 当窗口内所有数据都超过 TTL,整个窗口的 SSTable 被一次性删除。

这带来两个巨大收益:空间放大接近 1(旧数据整块消失,不需要重写),写放大接近 1(数据写入后基本不再被移动)。对比 STCS 面对大量 TTL 数据时的窘境——STCS 会不停地在合并中搬运那些"即将过期但仍占据空间"的数据,产生大量无意义的 IO。

TWCS 有两个必须遵守的前提,违反任意一个都会导致灾难:

前提 1:写入必须时间单调。 如果你回填历史数据(backfill),一个 2024 年的数据点会被写进"今天"的窗口,导致该窗口的 minTimestamp/maxTimestamp 跨度极大。后果是这个窗口的 SSTable 无法在 TTL 到期时被整体删除(因为它还包含"新"数据),磁盘持续膨胀。这也是 TWCS 表在 Cassandra 里被明确标记为"不支持删除与覆盖写"的原因。

前提 2:不要在同一个 partition 里混入冷热差异极大的数据。 例如 PRIMARY KEY ((metric_id), ts) 中,如果某个 metric 突然停更,其所在窗口的 SSTable 仍然要等到最晚时间戳过期才能释放。

五、UCS:把三种策略统一成一棵可调参的树

Cassandra 5.0 引入的 UnifiedCompactionStrategy 是一次真正的抽象升级。它观察到:STCS 和 LCS 其实是同一个结构的两个极端。

UCS 用一棵"密度递增"的树来描述数据布局:

  • 树的每一层 L_i,SSTable 的密度(density,近似理解为"该层单个 SSTable 覆盖范围与大小的比值")按固定倍率 W 递增。
  • 层内 SSTable 不重叠(leveled 语义),层内 SSTable 数量达到阈值时向下合并。

关键参数:

ALTER TABLE events WITH compaction = {
    'class': 'UnifiedCompactionStrategy',
    'scaling_parameters': 'T4,L10'     -- 见下
};

scaling_parameters 是 UCS 的灵魂,形如 L10, L12, T4:

  • L<W>:leveled 模式,density 按 W 倍递增,等价于 LCS 的 fanout。
  • T<n>:tiered 模式,允许 n 个重叠的 SSTable 共存后再合并,等价于 STCS 的 bucket。

于是:

想要的行为scaling_parameters等价策略
纯 tiered(写优化)T4STCS
纯 leveled(读优化)L10LCS
先 tiered 后 leveledT4, L10混合:热数据 tiered 承接写入,冷数据 leveled 优化读取
多层混合T4, L10, L12逐层收紧读放大

这是一个真正有价值的设计,因为它把"读写权衡"从离散的三选一变成了连续可调的曲线,而且支持在线迁移:你可以从 T4 起步(写密集),随着业务转为读密集,在线改成 T4, L10,UCS 会在后续 compaction 中逐步把布局演进过去,不需要重写全表。

# 在线演进(Cassandra 5.0+)
nodetool setcompactionparameters keyspace1 table1 '{"class":"UnifiedCompactionStrategy","scaling_parameters":"T4,L10"}'

六、生产实战观点

观点 1:先量化再选型。 不要凭感觉。用下面这个简化模型估算:

def estimate(sstables_per_read, write_amp, disk_gb, write_mb_per_s):
    """粗略判断当前瓶颈在哪"""
    io = write_mb_per_s * write_amp
    print(f"实际落盘带宽需求: {io} MB/s")
    print(f"读放大(最坏 SSTable 数): {sstables_per_read}")
    if io > 200:
        print(">> 写放大瓶颈:考虑降低 fanout 或改用 tiered 前缀")
    if sstables_per_read > 20:
        print(">> 读放大瓶颈:考虑 leveled 或降低 L0 积压")

观点 2:tombstone 比 compaction 策略更容易打死集群。 删除产生的 tombstone 要等 gc_grace_seconds(默认 10 天)后才在 compaction 中被真正回收。如果 compaction 跟不上,tombstone 会长期驻留,读取时还要被扫描并过滤,造成"幽灵读延迟"。排查命令:

nodetool tablestats ks.tbl | grep -i "tombstone\|sstable count"
# 单表 SSTable 数 > 100 且 tombstone 比例高 -> 优先治理

观点 3:不要把 nodetool compact 当常规手段。 它会强制做一次 major compaction(把所有 SSTable 合成一个),期间磁盘峰值接近 2 倍,且合并后的巨大 SSTable 在 STCS 下再也不会参与后续 compaction(因为找不到大小相近的伙伴)。这是典型的"饮鸩止渴"——一次手动 major compaction 之后,这张表的 compaction 可能永久失效。

观点 4:容量规划必须算上 compaction 峰值。 一条务实的经验线:

  • STCS:安全水位 ≤ 50%
  • LCS:安全水位 ≤ 70%(无 tiered 积压,峰值更可控)
  • TWCS:安全水位 ≤ 80%(整窗口删除,无需搬运)

超过这条线,compaction 的输入空间就不够了。

七、结语

Compaction 没有银弹,只有权衡曲面上的位置选择。STCS 用空间放大换写放大,LCS 用写放大换读放大,TWCS 用"时间单调"这个前提同时把三者压到 1,UCS 则把这条曲线交给了使用者。

真正值得记住的其实只有一句话:Compaction 策略的本质是决定"数据在被读取之前,允许以多乱的形态存在多久"。 你允许它乱,写入就便宜;你要求它整齐,写入就昂贵。生产调优的全部工作,就是找到你的业务在这个光谱上的那个点。

点赞(0) 打赏

评论列表 共有 0 条评论

暂无评论
立即
投稿

微信公众账号

微信扫一扫加关注

发表
评论
返回
顶部