马浩琨
2 min read
Available in LaTeX and PDF
对象存储上的持久流式传输
对象存储分段索引:低成本 EB 级实时流式读写

对象存储长期被视为「存文件」的地方,但随着日志、监控、视频以及物联网数据的爆发式增长,人们开始追问一个问题:是否能在保留对象存储低成本、无限扩容特性的同时,让它像消息队列一样提供低延迟的顺序读写?本文尝试回答这一问题,并给出可落地的技术路径。

对象存储与流式传输的语义差异

对象存储的核心操作是 PUT 与 GET,写操作以整个对象为单位,读操作同样面向完整对象或固定范围。这种模型天然支持最终一致性,但缺乏对「偏移量」和「提交点」的原生表达。流式传输则要求把连续字节流切分为可编号的分片,并支持在任意偏移处继续读写。把两者结合的思路在于:把对象内部继续切分为逻辑分片,再用独立的元数据对象把分片串成一条「流」。

三层模型的职责划分

应用层负责把业务数据包装成「流」的语义,向下提供 append、log、read 等接口。协议层把这些调用翻译成 S3 的 Multipart Upload 原语,同时维护一个轻量级索引,记录每个分片的 PartNumber、ETag 以及字节范围。存储层仍然是标准的对象存储桶,只需开启跨区域复制与生命周期策略即可。

分段写入的实现细节

最常见的做法是利用 Multipart Upload 的 PartNumber 充当逻辑偏移。假设我们把 PartSize 固定为 16 MiB,那么第 n 个分片对应的字节范围就是 16 MiB * n16 MiB * (n + 1)。当生产者需要追加数据时,只需发起一个新的 Part,并把 PartNumber 设为当前最大值加一即可。需要注意的是,S3 对 PartSize 的限制是 5 MiB 到 5 GiB 之间,过小的 Part 会导致 Part 数量膨胀,过大的 Part 则会增加重传成本。因此实际部署时往往会根据实时带宽动态调整 PartSize。

流式索引的结构与版本控制

索引本身也是一个对象,通常命名为 stream-name/manifest/latest.json。文件内容采用 JSON Lines 格式,每一行记录一个 Part 的元数据:

{"part": 42, "etag": "\"abc123\"", "start": 688128000, "size": 16777216}

当索引体积超过阈值后,可以按天或按小时切分,并用一个「热索引」放在 Redis 中做缓存。写时复制策略要求每次更新索引时,先把旧索引拷贝一份,再在副本上追加新行,最后用原子 PUT 覆盖主索引。这样即使写入过程中发生崩溃,系统也能回退到最近一个完整版本。

一致性与持久化保证

一旦某个 Part 的 PUT 请求返回 200,对象存储就承诺该 Part 已在至少三个可用区持久化。此时生产者可以安全地向消费者 ACK。跨可用区复制策略(CRR)可以把同一个 Part 异步复制到另一个区域,实现真正的多活。断点续传则依赖 ListParts API:消费者启动时先列出所有未完成的分片,找到最大的 PartNumber,之后即可从下一个 Part 继续写入。

读取模式的匹配策略

顺序读取时,消费者先从索引里拿到 Part 列表,再依次发起 Range-GET 请求。为了降低延迟,可以在后台预取未来若干个 Part,并把它们缓存在本地内存。随机读取时,消费者先在索引里做二分查找,定位目标字节所在的 Part,再发起单次 Range 请求即可。零拷贝传输可以在服务端开启 SSE-Customer-Key 加密后仍然保持:对象存储直接把加密后的字节流返回给客户端,客户端在内核态完成解密,避免用户态内存拷贝。

元数据与生命周期管理

在对象上打标签 topic=logspartition=0offset=10086,可以让后续的垃圾回收脚本快速定位过期分片。生命周期策略可配置为:标准存储保留 7 天后转低频访问,再过 23 天转归档。归档后的 Part 不会被主动删除,而是由独立的 GC 进程在索引里做标记,等所有消费者确认消费完毕后再真正发出 Delete 请求。

性能调优的关键参数

PartSize 与并发度的关系可近似表示为:

吞吐=min(带宽,并发数×PartSize/往返延迟)\text{吞吐} = \min(\text{带宽}, \text{并发数} \times \text{PartSize} / \text{往返延迟})

实际测试表明,在 1 Gbps 带宽、平均 RTT 30 ms 的环境下,把 PartSize 设为 16 MiB、并发度设为 8,追加延迟的 P99 可稳定在 180 ms 左右。读写分离的另一个技巧是把索引和数据放在不同桶,避免 List 操作扫描海量数据对象。

真实场景的落地效果

某日志平台每秒产生 10 GB 文本,先在边缘节点做 Snappy 行式压缩,再以 16 MiB 为单位切分上传到 S3。索引存放在 ElastiCache 中,查询引擎 Athena 可以直接引用清单文件做即席分析,端到端延迟从原来的 5 分钟缩短到 30 秒。视频直播场景则把 HLS 切片直接映射为 Part,PartNumber 即为媒体序列号,播放器用 Range 请求即可实现任意倍速回放。

方案对比与选型建议

与 Kafka 相比,对象存储流式方案的追加延迟从 10 ms 上升到 50-200 ms,但容量可达 EB 级且成本降低一个数量级。MinIO 在局域网环境下可把延迟压到 20 ms,但仍然受限于单集群容量。HDFS 适合批处理,对小文件追加并不友好。因此在「先持久化、后回放」或「先存后算」的场景下,对象存储流式方案往往成为最优解。

未来演进与开放问题

S3 Express One Zone 把数据放在单可用区的高性能存储上,理论上可以将追加延迟再降一个数量级。Apache Arrow 把 Parquet 文件直接放在 S3 上,结合 Object Lambda 可以在服务端完成列裁剪和过滤,进一步降低分析延迟。另一个尚未标准化的方向是 Conditional Write:如果能让 PUT 在「当前 PartNumber 不存在」时才成功,就能用一条请求同时完成追加与冲突检测。

对象存储通过「分段 + 索引」即可在保留对象语义的同时实现持久流式传输。实施时只需四步:选定 PartSize 与索引介质、封装支持 append/log/read 的 SDK、配置生命周期与跨区复制、监控 P99 延迟与 GC 频率。如此即可把「大文件」变成「永不丢失的数据流」。