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 天)内的 SSTable,用 STCS 的方式互相合并。
- 跨窗口的 SSTable 永不合并。
- 当窗口内所有数据都超过 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(写优化) | T4 | STCS |
| 纯 leveled(读优化) | L10 | LCS |
| 先 tiered 后 leveled | T4, 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 策略的本质是决定"数据在被读取之前,允许以多乱的形态存在多久"。 你允许它乱,写入就便宜;你要求它整齐,写入就昂贵。生产调优的全部工作,就是找到你的业务在这个光谱上的那个点。

发表评论 取消回复