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