3.4 滚动归档:按时间与大小切

日志不能堆成无底洞。存储成本、查询效率、合规保留期三条线共同要求把日志分段切走:热数据留在本地 RocksDB 里支撑秒级查询,过了 freshness 窗口就压缩打包转储到对象存储,三个月以上的进低频存储或直接删除。这套机制通常叫滚动归档,是日志系统的「新陈代谢」。

三个切分维度

滚动切分既要有时间维度,也要有大小维度,单靠任何一个都会翻车:

维度 触发条件 解决什么
时间 固时分小时,固定每日 查询对齐自然时间边界
大小 段达到上限(如 256 MB) 防止单段过大、内存压力
条数 段内日志条数上限 均衡段内行级偏移

实战中时间与大小取或触发:谁先到谁切。凌晨流量低时按时间切,高峰期大小先到就按大小切。只按时间切会遇到高峰段飙到几个 GB,只按大小切又会切出几百个碎段,查询时筛段筛到眼花。

切分粒度还影响压缩:段越大,段内重复模式越完整,压缩率越好;段越小,查询定位越快。两头不可兼得时优先保查询,因为压缩率还能靠归档合并补回来,查询体验差了就是天天难受。

切分器的骨架

切分逻辑放在写入路径上:每条日志写段前先问一句「当前段满了吗」,满了就关闭当前段、开新段,并触发异步入档。判断逻辑要轻——单条开销控制在微秒级,通常只比对计数器,时间维度则比对缓存的「当前段起始小时」。

// 段切分判定:时间或大小任一触发即切
fn should_roll(seg: &Segment, now: u64, max_bytes: u64, span_secs: u64) -> bool {
    seg.size >= max_bytes || now - seg.started_at >= span_secs
}
// 切分时先冻结旧段、原子切换,再异步入档
参数 常见值 说明
段大小上限 128~512 MB 兼顾压缩率与查询粒度
时间跨度 1 小时或 1 天 与常见查询范围对齐
段内锚点间隔 每 1024 行 供 3.3 的行级定位用

生命周期分层

段切出来之后去哪,由生命周期策略决定。经典的四层结构(常见值,供参考):

层 介质 保留时长 查诟能力
热 本地 RocksDB 最近 1~2 天 秒级、全文索引
温 对象存储标准存储 7~30 天 按段下载后查
冷 对象存储低频/归档存储 90 天 分钟级取回
删除 无 超期 无

顺带说清「删除」这一层:合规保留期到了,删除必须是不可绕过的默认动作,而不是「再看看要不要留」。给个别段加保留标记可以,但标记要有审批链、有上限,否则几个月后冷存储里全是「舍不得删」的段,成本曲线悄悄抬头。定期清理与配额告警的组合拳可对照 K8s 流水线实战 的存储配额管理。

热转温是压缩段加索引元数据一起上传,冷层把存储类型降档即可。对象存储 COS 的存储类型切换按规则自动完成,这条链路和 Go 云盘实战 的冷热分层是同一套打法。

分层之后还要回答一个反直觉的问题:热层多宽合适。直觉是热层越宽查询越爽,但热层放在本地磁盘上,费用远高于对象存储,而且机器重启、扩容都要带着它搬家。更实用的口径是按「查询覆盖度」反推——统计最近一周的查询里有百分之多少落在最近两天,多数业务能到七成以上,那就用两天热层兜住七成查询,剩下三成走温层下载,慢一点但便宜。容量规划与查询统计的做法可对照 Node.js 看板实战 的数据看板思路。

热层宽度 本地盘占用 一周查询覆盖率
1 天 低 约 50%
2 天 中 约 70%
7 天 高 约 90%

覆盖率本质是查询的时间局部性,日志如此,业务数据也一样,可对照 PHP SaaS 实战 的分层报表设计。

转储的原子性与幂等

归档最常见的故障是「传一半挂了」。网络抖动、实例重启都会让上传中断,下次重传就面临幂等问题。稳妥做法:先传临时对象名(带段 ID 与版本),传完校验大小与校验和,再 rename 成正式对象名;重传时如果正式对象已存在且校验通过,直接跳过。归档完成后在元数据表里把段标记为已转储,删除本地段前再确认一次,防止「元数据说转了、对象不存在」的致命不一致。

// 幂等转储:临时名上传、校验后改名
let tmp = format!("seg/{}.tmp", seg_id);
cos_upload(&tmp, &data).await?;
if cos_size(&tmp).await? == data.len() as u64 {
    cos_rename(&tmp, &format!("seg/{}.zst", seg_id)).await?;
    mark_archived(seg_id).await?;        // 元数据置位
}
故障 症状 兜底
上传中断 段残缺 临时名 + 校验和
重复上传 存储双份 同名覆盖 + 幂等标记
元数据丢失 转了但没记录 对账任务定期比对

转储并发与带宽预算

归档把日志推向对象存储,用的却是业务机房同一根出口带宽。高峰期段切得勤,上传并发一开就几百路,把业务接口的延迟挤到天上去。克制做法:给转储任务单独设并发上限与令牌桶限速,比如并发 8 路、总速 50 MB/s,夜间空闲再放宽;转储任务整体优先级低于实时管道,积压了慢慢追,不与热路径抢资源。

参数 常见值 考量
转储并发 4~8 太多抢连接
上传限速 业务时段 50 MB/s 保护业务延迟
重试退避 指数退避,上限 10 分钟 防对象存储限流

重试退避有讲究:第一次失败等 1 秒,之后每次翻倍、封顶 10 分钟,超过六次告警人工介入。死磕式的立即重试只会把对象存储的限流越顶越紧,让所有在途请求一起超时。

顺带一提,对象存储本身有按请求数计费的一面:小段上传得多,请求费比流量费还贵,所以段大小下限别低于 16 MB,太小就先在本地合并(归档段间的 compaction)再传。这个合并动作还能顺带压一遍旧段——刚切出来的段往往是 LZ4 快速压缩,合并归档时换成 Zstd 高级别压一遍,节省的存储费按月可见。

对账:转储的最后一道保险

即便做了幂等与校验,仍要有一个对账任务:每天扫描本地与远端的段清单,比对元数据表里的状态,补传遗漏、修错状态、删双方都不认的孤儿。对账不是多余的洁癖——线上跑久了,什么重启、限流、人工误操作都会发生,小幅不一致积累到一定量级,查询就会开始莫名其妙地缺行。这个「用账目对齐实物」的思路是通用的,可对照 PHP 秒杀实战 的库存对账设计。

踩坑提示

第一个坑是段开着的时候就被上传:写入还在追加,上传的是半截内容,元数据里记的行数与文件对不上。切段必须先冻结(关写入句柄)再入档队列。第二个坑是本地段删早了:转储任务积压,本地 RocksDB 还没腾出空间,磁盘先被撑爆——删除策略要以「转储成功」为前提,而不是「转储任务已提交」。第三个坑是时区与边界:按天切用 UTC 还是本地时区必须写死在配置里,多实例各自为政会让「昨天 10 点到今天 10 点」变成两笔糊涂账。多实例对齐时区的做法可对照 k8s 流水线实战 的时区治理。

小结

滚动归档=时间加大小双触发切段,热温冷四层按生命周期流转,转储走临时名加校验和的幂等路径。归档的本质是让热数据保持小巧、让冷数据安静躺着、让超期数据体面退场——管道才不会有一天被自己的历史压垮。一句话总结:双触发切段、四层流转、幂等转储、对账兜底,四个动作配齐,归档这层就稳了。

延伸阅读

  • 段的组织与 RocksDB 落盘,见 4.1。
  • 索引如何随段归档,见 3.3。
  • 冷热分层与对象存储打法,见 Go 云盘实战。
  • 归档流水线的部署形态,见 k8s 流水线实战。