GreptimeDB Global GC Worker 设计解析:基于 Compaction 驱动的文件垃圾回收机制
【免费下载链接】greptimedbThe open-source observability database. One columnar engine for metrics, logs, and traces, on object storage.项目地址: https://gitcode.com/GitHub_Trending/gr/greptimedb
本文依据 docs/rfcs/2025-07-23-global-gc-worker.md 展开,并结合 GreptimeDB 当前仓库中 mito2 存储引擎的
LocalGcWorker实现(src/mito2/src/gc.rs)、meta-srv 的 GC 调度器(src/meta-srv/src/gc/scheduler.rs)及对应配置项,深入讲解该 GC 机制的设计动机、工作流程、源码实现与配置方式。读者读完后可以理解 GreptimeDB 如何在列式存储引擎中安全回收废弃 Parquet/Puffin 文件、如何借助临时 Manifest 与"心跳"机制保护长查询所需文件,以及如何通过配置项调优 GC 行为。
背景与动机:为什么存储层需要全局 GC
GreptimeDB 是基于对象存储的时序/可观测性数据库,数据最终以 SST(Parquet 数据文件与 Puffin 索引文件)的形式落在对象存储上。随着功能的演进,存储目录中会不断积累"不再被任何组件使用"的陈旧文件,典型来源包括:
- 表重新分区(repartition):分区规则变更后,旧分区对应的整批 SST 文件会被新文件替换,产生大量废弃文件;
- Manifest 更新失败:新 SST 文件已成功写入对象存储,但随后的 manifest 更新操作失败,导致文件从未被任何 manifest 记录,成为无人引用的孤儿文件;
- Compaction / Truncate / Delete:数据合并、清空与删除操作都会将旧文件标记为移除。
如果不做回收,这些文件会持续占用对象存储空间。因此,RFC 提出在 Compaction 流程内集成一个垃圾回收(GC)机制:当一个 region 的 Compaction 完成后,自动触发 GC worker,识别并删除超过保留期限的废弃文件。这一设计把 GC 的执行绑定在一个明确、可定义的操作边界——"一次 compaction 周期的成功完成"上,以正确性和安全性为优先。
术语定义
RFC 将待回收文件划分为两类核心概念:
| 术语 | 含义 | 典型场景 |
|---|---|---|
| Unused File(未使用文件) | 存在于存储目录中、但从未被任何 manifest 正式记录的文件 | 新 SST 成功写入存储,但随后的 manifest 更新失败,导致文件未被引用 |
| Obsolete File(废弃文件) | 曾经被 manifest 记录、但现已明确标记为移除的文件 | 数据 repartition 或 compaction 后产生的旧文件 |
仓库实现中进一步引入了两个与时间相关的精确概念(见 src/mito2/src/gc.rs 的模块注释):
- expel time(驱逐时间):文件被视为"已移除"的时刻,即它从 manifest 中被移除的时间;
- lingering time(滞留时间):文件从 manifest 移除后、到真正从对象存储删除之间的时间窗口。
此外,代码中还将"未在 manifest 中记录"的文件细分为unknown files(因为 checkpoint 可能已经推进、移除了 checkpoint 之前的移除记录,导致文件的 expel time 无法确定),并为此单独设计了滞留策略。
GC Worker 工作流程
触发与执行位置
RFC 明确:GC worker 作为 Compaction 流程的组成部分运行。某个 region 的 Compaction 完成后,GC worker 自动被触发;执行位置优先放在datanode上,从而避免 metasrv 侧需要额外配置对象存储(object storage)访问权限的负担。
在实际仓库实现中,GC 由两个层面协同驱动:
- datanode 侧(执行层):LocalGcWorker 是真正执行文件扫描与删除的 worker,它持有一个或多个待 GC 的 region(同一张表),并携带
GcConfig、FileRefsManifest等上下文; - metasrv 侧(调度层):GcScheduler 周期性(默认每 5 分钟一个 tick,见 options.rs)向 datanode 下发 GC 请求,并汇总各 datanode 返回的
GcReport。它还支持通过Event::Manually手动触发指定 region 的 GC。
完整处理流程
RFC 给出的流程如下:
- 触发:region 的 Compaction 成功完成后,调用 GC worker;
- 读取 Manifest:读取 region 的主 manifest,获得所有被标记为废弃的文件列表;同时读取长查询产生的临时 manifest(temporary manifests),识别当前正在使用的文件,防止误删;
- 检查滞留时间(Obsolete Files):对每个废弃文件计算"滞留时间"(即从 manifest 移除后经过的时间);
- 标记删除(Obsolete Files):超过可配置的最大滞留时间、且未被任何活跃临时 manifest 引用的文件,标记为待删除;
- 滞留时间(Unused Files):从未被任何 manifest 记录的文件,同样需要等待可配置的最大滞留时间之后才可删除。
对应流程图(RFC 原文):
源码级实现:LocalGcWorker 的执行细节
对照 src/mito2/src/gc.rs,LocalGcWorker::run()按顺序执行以下步骤(与 RFC 流程一一对应):
- 读取临时引用:
read_tmp_ref_files()从FileRefsManifest中收集所有 region 当前被引用的文件集合(gc.rs); - 逐 region 执行:
do_region_gc()内先获取 region 的主 manifest,并校验临时 manifest 记录的 manifest version 与 region 当前 manifest version 是否一致。不一致(例如 leader 刚更新了 manifest version)时直接跳过该 region 的 GC,避免误删仍在使用的文件(gc.rs); - 获取已移除文件及驱逐时间:
get_removed_files_expel_times()从 manifest 的removed_files记录中还原每个文件的 expel time(即包含移除动作的 delta manifest 的最后修改时间,见 gc.rs); - 并发列出 region 目录文件:
list_from_object_store()将 region 目录按字典序边界分区,使用多个 lister 并发列出 Parquet 文件,并单独平铺列出region_dir/index/下的 Puffin 索引文件(gc.rs); - 过滤可删除文件:
filter_deletable_files()结合"在 manifest 中""在临时引用中""处于滞留期""已达可删除条件"四类状态,逐文件判定是否可删; - 物理删除与状态更新:
delete_files()调用delete_files/delete_indexes真正删除对象存储上的数据与索引;随后update_manifest_removed_files()调用clear_deleted_files清理 manifest 中对应的移除记录(gc.rs)。
需要特别说明的是,仓库实现中 GC 并不总是做"全量文件列表扫描",而是支持两种模式(由LocalGcWorker.full_file_listing字段控制,见 gc.rs):
- 快速模式(fast):仅删除 manifest 的
removed_files中记录的文件,避免昂贵的列表操作,性能最佳,作为常规 GC 使用; - 全量列表模式(full_file_listing):完整扫描 region 目录,额外发现并清理未被任何 manifest 跟踪的孤儿文件,代价高,周期性执行即可(metasrv 侧默认每 24 小时执行一次全量列表,见下文配置节)。
废弃文件(Obsolete Files)的回收条件
RFC 明确规定,一个废弃文件只有当以下两个条件同时满足时才会被永久删除:
- 从 manifest 中被移除(其 obsolescence timestamp)所经过的时间超过可配置阈值;
- 当前未被任何活跃的临时 manifest 引用。
在源码中,这一逻辑浓缩在should_delete_file()函数中(gc.rs):
- 文件若在 manifest 中(
is_in_manifest)或在临时引用中(is_in_tmp_ref),一律不删除; - 文件若处于滞留期(
is_linger)但未达删除条件(is_eligible_for_delete),不删除; - 仅当
is_linger且is_eligible_for_delete同时为真时才删除; - 对于无法解析为 SST/索引文件名的条目,直接跳过并累加
GC_SKIPPED_UNPARSABLE_FILES指标,绝不删除未知类型文件(gc.rs)。
对应地,src/mito2/src/gc/worker_test.rs 中提供了丰富的测试验证这些边界条件:
test_gc_worker_basic_compact:写入多批数据并 compaction 后,GC 删除的文件数量与 manifest 中记录的被移除文件数量一致;test_gc_worker_compact_with_ref:当文件被临时引用(FileRefsManifest)保护时,GC 结果为空(0 个文件被删);test_known_file_still_lingering_not_deleted/test_known_file_eligible_for_delete_deleted:分别验证滞留期未满不删、期满即删;test_file_in_manifest_not_deleted/test_file_in_tmp_ref_not_deleted:验证 manifest 与临时引用对文件的强保护。
未使用文件(Unused Files)的处理
RFC 指出,由于 GC worker 与 Compaction 深度集成,"Unused Files"这类最容易被误删的文件类别风险已被大幅化解——新写入的 SST 文件几乎总是紧接着伴随 manifest 更新,因此"写完文件但尚未记入 manifest"的时间窗口极短。任何真正"未使用"(未被任何 manifest 引用)的文件,在超过可配置的最大滞留时间后即可安全删除。
不过,源码实现比 RFC 更为保守,将"unknown files"(无法确定 expel time 的文件)单独对待:
- 对于活跃/打开的 region:仅当对象文件的
last_modified时间超过unknown_file_lingering_time(默认 1 天)阈值时才删除,且采用严格小于(strict less-than)比较;如果对象存储不提供last_modified元数据,则保守地保留该文件(gc.rs); - 对于已删除的 region(dropped):unknown 文件可以立即删除,因为这类 region 已无活跃读写,且 meta 侧在发起 dropped-region GC 前会先收集相关活跃 region 的
FileRefsManifest以做交叉保护。
对应测试见 worker_test.rs 中的test_unknown_file_within_ttl_not_deleted、test_unknown_file_exceeded_ttl_deleted、test_unknown_file_dropped_region_deleted、test_missing_last_modified_unknown_kept与test_unknown_file_at_cutoff_not_deleted等用例。
同时,为便于调试与审计,GC 会维护近期已删除文件的完整列表(GcReport)。源码中GcReport结构体记录了每个 region 删除的数据文件与索引文件、需要下轮重试的 region、以及成功处理的 region 集合(src/store-api/src/storage/file.rs)。
读一致性保障:临时 Manifest 与"心跳"机制
为什么需要保护机制
GC 最危险的场景是误删仍在被长查询使用的文件。为此,RFC 设计了一套依赖临时 manifest 的保护机制:
- 创建临时 manifest:当检测到长查询(例如通过 slow query recorder)时,查询进程向 region 的 manifest 目录写入一个临时 manifest,其中列出该查询所需的全部文件;
- 周期性心跳:仅仅创建文件不够——如果查询进程崩溃,临时 manifest 会变成孤儿文件,导致 GC 永远无法回收相关文件。因此执行长查询的进程必须周期性更新临时 manifest 的修改时间戳(touch 文件),作为"查询仍存活"的心跳信号;
- GC 校验:GC worker 运行时扫描临时 manifest,检查其最后修改时间;若超过可配置阈值,则判定该临时 manifest 已陈旧(来自崩溃或已终止的查询),将其删除,受其保护的文件随即不再受屏蔽;
- 动态生命周期:临时 manifest 在查询启动时创建、由查询周期性保活、查询正常结束时主动删除、异常终止时由 GC worker 自动清理。
仓库中的落地方案:FileRefsManifest
在 GreptimeDB 当前实现中,这一概念落地为FileRefsManifest(临时文件引用清单,见 src/store-api/src/storage/file.rs),包含三部分:
file_refs:每个 region 当前被引用(正在被查询使用)的文件集合FileRef;manifest_version:生成这些临时引用时对应 region 的 manifest 版本,用于判断临时引用是否过期;cross_region_refs:跨 region 文件归属映射,记录 repartition 后新 region 持有的、原属于源 region 的文件,防止 GC 误删迁移中的文件。
GC worker 在do_region_gc()中会做版本一致性校验:如果FileRefsManifest中记录的 manifest version 与 region 当前 manifest version 不一致,则跳过该 region 的 GC(gc.rs),并累加GC_ERRORS_TOTAL{manifest_mismatch}指标。这对应 RFC 中"读副本的 manifest 版本落后时间不能超过废弃文件的 lingering time,否则可能引用已被 GC 删除的文件"的约束。
两阶段实施建议
RFC 同时指出,临时 manifest + 心跳机制一次性实现复杂度较高,建议分两阶段推进:
- Phase 1(基于时间的简单删除):先实现只依赖可配置 lingering time 的简单 GC 策略,为空间回收提供基线能力,暂不引入临时 manifest;
- Phase 2(一致性感知 GC):根据 Phase 1 的实际效果与观测到的问题,再决定是否实现完整的临时 manifest 与心跳机制,以处理长查询场景。
配置参数详解
datanode 侧:region_engine.mito.gc
GC worker 的滞留时间等参数在 datanode 的 mito2 引擎配置中设置,完整示例见 config/datanode.example.toml,对应GcConfig结构体(src/mito2/src/gc.rs):
| 配置项 | 类型 | 默认值 | 说明 |
|---|---|---|---|
region_engine.mito.gc.enable | Bool | false | 是否启用 GC。必须与 metasrv 的gc.enable保持一致,否则会产生意外行为 |
region_engine.mito.gc.lingering_time | String | 1h | 删除文件前的滞留时间。应足够长以让长查询完成;设为None时未使用文件会被立即删除 |
region_engine.mito.gc.unknown_file_lingering_time | String | 1d | 删除无法确定 expel time 的 unknown 文件前的滞留时间。仅在全量列表 GC 时生效;基于对象last_modified时间戳做严格小于比较。生产环境不宜配得过小,避免误删 compaction/flush 进行中的 pre-manifest 文件;对象存储不提供last_modified时保守保留;dropped region 的 unknown 文件立即删除 |
此外,源码中GcConfig还包含两个并发控制参数(未在示例配置文件中直接暴露,使用默认值):
max_concurrent_lister_per_gc_job(默认 32):单个 GC job 内并发列表操作的上限;max_concurrent_gc_job(默认 4):datanode 上并发 GC job 的上限,防止过多 GC 任务压垮 datanode。
这两个参数由GcLimiter通过 tokio 信号量实施:每次新建LocalGcWorker时需获取一个信号量许可(permit()),无可用许可时返回TooManyGcJobs错误(gc.rs)。
metasrv 侧:gc
GC 调度器(何时、对哪些 region 触发 GC)由 metasrv 配置控制,完整示例见 config/metasrv.example.toml,对应GcSchedulerOptions(src/meta-srv/src/gc/options.rs):
| 配置项 | 默认值 | 说明 |
|---|---|---|
gc.enable | false | 是否启用 GC。必须与 datanode 的mito.gc.enable保持一致;关闭时 datanode 上的部分文件可能永远不会被删除 |
gc.gc_cooldown_period | 5m | 同一 region 两次 GC 之间的冷却时间 |
max_concurrent_tables | 10 | 并发处理的表数量上限 |
max_retries_per_region | 3 | 单个 region GC 失败时的最大重试次数 |
region_gc_concurrency | 16 | 表内 region GC 的并发度 |
retry_backoff_duration | 5s | 重试之间的退避时间 |
min_region_size_threshold | 100MB | GC 候选 region 的最小尺寸阈值 |
sst_count_weight | 0.5 | GC 打分中 SST 文件数量的权重(SST 越多潜在可删文件越多,中等优先级) |
file_removed_count_weight | 1.0 | GC 打分中待删文件数量的权重(可删文件越多优先级越高) |
regions_per_table_threshold | 20 | 每张表最多选出参与 GC 的 region 数(按打分取前 20) |
mailbox_timeout | 60s | 与 datanode 的 mailbox 通信超时 |
full_file_listing_interval | 24h | 全量文件列表 GC 的执行间隔。全量列表开销大但能清理孤儿文件,每 N 个 GC 周期执行一次(N = 该值 / ticker 间隔) |
tracker_cleanup_interval | 6h | 清理 GC tracker 中陈旧 region 条目的间隔(仅用于内存清理) |
metasrv 侧 ticker 默认每 5 分钟触发一次 GC tick(options.rs),且所有参数在GcSchedulerOptions::validate()中做了合法性校验(例如max_concurrent_tables、retry_backoff_duration等必须大于 0,见 options.rs)。另外注意gc.experimental_soft_drop(软删除表的自动清理)为Enterprise Edition 专属功能,社区版配置为启用会直接校验失败。
已知缺陷与竞态条件
RFC 坦诚列出了该设计的固有缺陷,仓库实现与调度策略也都围绕这些风险做了针对性设计:
- 依赖 Compaction 频率:GC 周期与 Compaction 频率直接绑定。Compaction 不频繁的环境中,废弃文件会累积较长时间才被回收,存储消耗可能增加。为此 metasrv 侧引入了独立的周期性 ticker 调度(而非仅靠 compaction 触发),缓解了这一依赖;
- 与长查询的竞态:若长查询已开始但尚未写入临时 manifest,而 Compaction 恰好同时开始并将该查询所需文件标记为废弃,就可能造成误删。缓解手段是:临时 manifest 的写入阈值时间应显著小于废弃文件的 lingering time,从而保证下一次 GC 运行时查询已注册引用;同时,读副本的 manifest 版本落后时间不能超过废弃文件的 lingering time;
- 与 region 迁移(region-migration)的竞态:RFC 用一张时序图说明了风险——GC 读取 Region 1 的 manifest 后、Region 迁移将 Leader 切换到 Region 2、Region 2 写入新文件,此时 GC 继续扫描目录会发现"不在 Region 1 manifest 中"的新文件并将其误标为孤儿并删除。根本解法是对同一 region 的 GC 与 region 迁移/重分区操作加互斥锁,避免二者并发执行:
源码层面,LocalGcWorker对 region 的 Follower 状态有明确防护:run()中若发现 region 处于RegionRoleState::Follower,会直接报错拒绝执行 GC(gc.rs),且 GC 对 manifest version 的一致性校验同样降低了此类风险;
- 临时 manifest 上传到对象存储的额外开销:会增加复杂度与潜在性能开销,但长查询通常不频繁,影响预期很小。实现中还通过
cross_region_refs处理 repartition 带来的跨 region 文件归属问题,进一步降低误删风险; - 与 repartition 的竞态:RFC 建议在 GC 期间同时对 region-migration 与 repartition 加锁,作为当前阶段的简单可靠方案。
结论与权衡
RFC 汇总了该集成式 GC 方案的核心权衡:
| 方面 | 当前方案(集成式 GC) |
|---|---|
| 实现复杂度 | 中等。需要与 Compaction 流程及 slow query recorder(用于临时 manifest 管理)做细致集成 |
| 可靠性 | 高。与 Compaction 集成 + 利用长查询的临时 manifest,显著降低误删风险;精确管理废弃文件滞留时间、避免误删新建 SST,增强了数据安全性 |
| 性能开销 | 低到中。GC 在 compaction 之后运行,对写入路径的直接冲击最小;slow query recorder 管理临时 manifest 的开销对长查询可接受 |
| 对其他组件的影响 | 中等。需要修改 Compaction 流程以触发 GC、修改 slow query recorder 以管理临时 manifest,引入一定耦合,但整体提升了数据安全性 |
| 删除策略 | 基于状态与时间。废弃文件按可配置滞留时间删除,若被临时 manifest 引用则暂停;未使用文件(从未进入 manifest)同样受滞留时间约束 |
未决问题与未来工作
RFC 留下两个需要进一步讨论与落地的方向:
- Slow Query Recorder 实现细节:需要给出 slow query recorder 的具体修改规范,以及它与临时 manifest 之间精确的交互机制;
- 可配置滞留时间:需要为废弃文件与未使用文件分别确立并开放具体的滞留时间配置,以在存储回收与数据可用性之间取得最优平衡。
替代方案对比
方案一:独立 GC 服务(Standalone GC Service)
不把 GC worker 集成进 Compaction,而是实现一个独立服务,周期性基于 manifest 信息与预定义保留策略扫描并清理废弃/未使用文件。
- 优点:与 Compaction 解耦,可独立扩缩容部署;GC 频率与策略可独立于 Compaction 配置;
- 缺点:需要单独的服务进行管理、监控与组件协调;可能与 Compaction 已有的文件扫描逻辑重复;保证读一致性需要更复杂的协调机制(如分布式锁管理器或更精细的临时 manifest 系统)。
若集成式 GC worker 被证明不够用,或未来需要更高级的 GC 策略,可考虑在将来实施此方案。
方案二:Manifest 驱动的立即删除(无滞留时间)
文件一旦从 manifest 移除就立即删除,不设置任何滞留时间。
- 优点:GC 逻辑最简单,空间即时回收;
- 缺点:若未做到完全同步,极易删除仍被长查询或其他进程使用的文件;需要极其健壮且即时的机制来保证无活跃查询引用待删文件,可能导致性能瓶颈或复杂的错误处理;由于删除的即时性,误删问题极难排查调试。
总结
Global GC Worker 是 GreptimeDB 存储引擎中保障对象存储空间可持续回收的关键设计。它以"Compaction 完成"为执行边界,通过"manifest 追踪 + 滞留时间窗口 + 临时文件引用清单"三层防护,在回收 repartition、compaction、manifest 更新失败等场景产生的废弃文件的同时,最大程度避免误删活跃查询正在使用的数据。当前仓库中 LocalGcWorker 与 GcScheduler 的协同实现、FileRefsManifest 的跨 region 引用追踪,以及 datanode/metasrv 两侧的 GC 配置项,共同构成了这一机制的完整工程落地,为后续更细粒度的滞留时间调优与 slow query recorder 集成预留了清晰的演进路径。
【免费下载链接】greptimedbThe open-source observability database. One columnar engine for metrics, logs, and traces, on object storage.项目地址: https://gitcode.com/GitHub_Trending/gr/greptimedb
创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考