做 AI 训练的人应该都遇到过类似情况几千个样本用 WebDataset 打包成 tar放在对象存储上。训练脚本每次只读 tar 里的一个文件也就是读取对象中间很小一段字节。第一次跑通也许很顺但跑到第二个 epoch 或者开多卡并行时问题就来了GPU 经常在等数据对象存储的往返延迟被放大流量费用在涨最后发现瓶颈不在模型而在读数据。很多人第一反应是加一个本地缓存。但把几千 GB 的整个对象下载到本地不现实只依赖 HTTP Range 请求每次还是要回到对象存储。字节范围缓存byte-range cache解决的就是这个夹缝里的问题在对象存储之上加一层缓存按字节区间缓存热门数据让重复的随机读落在本地而不是反复访问远端。这篇文章就聊聊它到底解决什么问题、核心设计有哪些取舍、以及实际落地时要注意什么。我会尽量把设计思路、关键参数和排查路径都讲清楚不写成一篇只能收藏不能用的概念稿。1. 先搞清楚问题Range 请求解决了带宽但没解决回源1.1 对象存储适合整体存储但不适合随机读对象存储的设计初衷是大规模、低成本、非结构化数据的持久化。它有几乎无限的空间有跨可用区的耐久性但它的访问模型是“一次写入多次读取按 Key 访问”。单对象读请求的延迟通常远高于本地磁盘IOPS 也有限。这个特点决定了对象存储非常适合归档、备份、静态资源托管但不适合高频随机访问。如果你曾经在训练任务里直接读对象存储上的 tar 文件应该能感受到读一两个文件还好一旦遍历几百个文件或者开多卡并行等待时间会被迅速拉长。瓶颈往往不是网络带宽而是单次请求的延迟、连接开销和对象存储侧对请求数的限制。数据量到了 TB 级以后传统“下载到本地再用”的思路也不成立。本地磁盘不可能放得下全部数据集而且训练任务往往只需要访问其中一小部分样本。这里的核心矛盾是数据集整体很大但单次真正用到的数据很小且访问位置不固定。1.2 Range 请求提供了“只读一段”的能力但每一次都得走网络对象存储普遍支持 HTTP Range 请求也就是用一个Range: bytesstart-end头只取资源的一段字节服务器返回 206 Partial Content。这让“读大文件的一小段”成为可能不再需要下载整个对象。但它仍然是一次网络请求意味着每次都要经历连接、鉴权、网络往返。每次请求都有固定的 RTT不可能只看传输的那几 KB。重复读相同的范围时对象存储不会因为你刚才读过就变快。请求次数和回源流量都会计入成本。说得更直白一点Range 请求只是让你少下载了不用的字节但它没有减少“请求次数”和“远端 IO 次数”。对象存储不是低延迟存储它不会为一个小范围读保持热状态。1.3 哪些场景会反复读同一个范围如果每个对象只读一次Range 请求已经够了不需要缓存。真正需要 byte-range cache 的场景都有一个共同特征相同的字节范围被反复读取或者一组对象被多次随机索引。典型场景AI 训练的数据集读取WebDataset、FastForward、生 tar 格式数据集中每个 sample 是 tar 内部的一个 member。训练脚本会随机访问甚至同一个样本在多个 epoch 里重复读每次只读 tar 文件的中间一个小范围。数据湖列式扫描Parquet、ORC 这类列式存储查询引擎并不总是读整个文件而是根据谓词下推只读某些 row group 或 column chunk这些范围在不同查询里可能被复用。视频处理转码、抽帧、剪辑预览时经常按时间偏移读视频容器文件的一段例如读取 MP4 的 moov 元数据或者某个 GOP。日志归档分析把大日志文件放在对象存储分析引擎按 offset 读取一批行如果多个任务处理同一个文件不同 offset 的块会被重复访问。这些场景还有一个共性数据量可能达到 TB不能整体缓存但热点字节范围相对集中值得用一层缓存去接住。注意byte-range cache 不是替代对象存储也不是解决所有读取性能问题的银弹。它只对“重复访问少数字节范围”的随机读有明显收益。如果读取是均匀分布且一次性扫描缓存命中率不会高收益也就非常有限。2. byte-range cache 到底在缓存什么2.1 缓存对象是一段字节区间而不是一个文件这个名字的重点在 byte-range 而不是 cache。传统文件缓存的粒度是“文件”或“数据块”文件系统页缓存按 4KB 页面缓存CDN 按对象缓存。而 byte-range cache 的粒度是“对象的某个字节偏移区间”。举一个小例子。对象dataset-0000.tar大小 4GB。训练脚本请求Range: bytes1024-2048缓存层发现本地没有这个区间于是回源拉取1024-2048的字节写入本地缓存第二次同样的范围请求直接命中本地无需再访问对象存储。核心变化是把“对象存储的一次读请求”拆成了“缓存未命中时才回源”的按需过程。读出来的数据在本地被复用而不是每次都通过网络。2.2 固定块大小是最务实的选择一种直观做法是把每次 Range 请求作为缓存项保存键就是(object_key, start, end)。这样实现最简单但有几个问题如果范围重叠但不完全一致比如第一次读0-4096第二次读2048-6144两次缓存项没有完全重叠无法直接复用。缓存项数量会随访问模式碎片化很难做容量回收。元数据索引会越来越大。更常见的做法是固定块大小chunk/block例如 64KB 或 256KB。每次读取范围都被对齐到块边界缓存的最小单元是一个块block_no offset // block_size block_start block_no * block_size block_end min(block_start block_size, object_size) - 1请求1024-2048如果块大小是 1024就可能需要两个块0-1023和1024-2047分别查缓存、回源然后组装返回给调用方。固定块的好处缓存项数量可预估容量回收策略简单。任意范围的读请求最终都能由一组固定块拼出来。不同范围请求之间可以共享块避免碎片化。2.3 内容标识必须是 object 版本不只是 key缓存命中之后所有请求不会再经过对象存储所以必须保证“缓存里的块仍然匹配远端对象当前的内容”。在对象存储里同一个 key 可能被覆盖写也可能被删除后重新上传。如果缓存层只以 object key 来标识内容那么对象新版和旧版共用同一批缓存块就会返回旧数据——这是缓存一致性毒化。更稳妥的标识方式是(bucket, key, etag/version, block_no)。对象存储的 ETag 通常由服务端生成当对象内容发生变化时ETag 也会变化。缓存层每次回源时记录 ETag后续读请求可以先确认当前对象的 ETag 没有变化再用 ETag 作为缓存键的一部分。如果每次都查 ETag 会增加成本更现实的做法是写入缓存时记录 ETag后续命中不额外验证由外部写入动作显式触发缓存失效。比如对象上传完成后调用一个invalidate(key)接口或者依赖短 TTL 让旧块过期。2.4 驱逐策略不只看 LRU也要看块大小缓存容量有限块多到一定程度就必须驱逐。简单的 LRU 通常够用但字节范围缓存有一个特殊点块的大小不一定完全相同而且不同对象的访问频率差异很大。建议在驱逐时综合考虑块最后访问时间最基础的时间维。块在所有请求中被命中的次数把“低频但大块”优先驱逐。块所属的对象是否是当前活跃访问对象训练任务切换后旧对象的缓存块往往应该批量清理。如果整个缓存是本地目录驱逐逻辑可以做成“遍历索引选择目标删除本地文件删除索引项”。这里要注意从盘上删除文件和更新索引的一致性如果中途崩溃再次扫描时可能发现孤儿文件或索引引用不存在文件。经验先控制住块数量和总容量再谈算法。很多场景下默认 LRU 加容量上限已经能解决 80% 的问题复杂的权重策略反而会增加维护难度。3. 核心设计取舍从分块、元数据到一致性3.1 存储后端内存、NVMe、还是分布式缓存数据最终要落在某个本地存储。选项无非三种内存 / tmpfs读延迟最低带宽最高但内存贵、容量小掉电就丢。适合热点小、命中后收益极高的场景比如元数据索引本身放内存、数据块临时也放内存。本地 NVMe SSD容量更大持续读带宽高延迟比内存高一个数量级但远低于对象存储。适合绝大多数工作负载。一个训练节点挂 1 至 2 TB 的 NVMe 作为缓存盘性价比很可观。分布式缓存跨节点共享多个节点共享同一份块缓存提高了全局命中率但要处理节点间一致性、数据同步、网络开销。这其实已经把问题从“单机缓存”放大成“分布式缓存系统”复杂度会明显上升。如果只是做一个“对象存储范围读取的加速层”我建议先做单机本地缓存。原因很直接AI 训练的许多任务与节点绑定每个节点读相同数据集的概率很高单机缓存已经能吃住大部分局部性。跨节点共享缓存属于后续优化不应该写进第一版。3.2 元数据索引数据块的组织形式字节范围缓存的元数据设计决定了它能支撑多少缓存块、能多快查到命中和淘汰块。基本元数据至少包含bucket、key、etag标识远端对象及版本。block_no块序号。local_path本地缓存文件路径。size块实际大小。last_access_time最近访问时间用于驱逐。hit_count累计命中次数用于统计和驱逐参考。数据块在磁盘上的布局有两种常见方式一是每个块一个独立小文件直接以key_blkno命名二是所有块写进一个大文件用索引记录偏移量。前者实现简单、回收容易但小文件过多会造成目录系统压力后者文件数少但碎片和索引更复杂。从工程经验看块大小如果选在 64KB 到 1MB 之间独立小文件数量可以控制在一万到几十万级别普通文件系统能扛住但要注意目录条数过多时的ls和删除性能。设计时可以按 hash 分桶子目录避免单个目录里文件过多。3.3 并发回源的惊群问题一个很隐蔽但很常见的坑是多个请求同时拿到了“同一个块未命中”的判定然后同时发起对象存储回源请求。假设有 40 个进程同时训练读取同一个 tar 文件某个块缺失可能瞬间产生 40 个相同的远端请求。解决方式是在缓存层内部做一个“单块合并回源”机制也就是请求级 singleflight块未命中时不直接回源先尝试向一个块级锁注册请求。如果某个回源请求已经进行中其他请求等待同一个结果。回源完成后再广播给所有等待者。这个设计不仅减少了对象存储的请求压力还能避免冷启动时一上来就打爆远端配额。建议把“单个块的并发回源上限”做成可配置项。3.4 一致性对象更新后缓存怎么不毒化对象存储的一致性模型在逐步演进但“对象内容可能变化”这件事始终存在。实用的处理策略有几种ETag 存在内存或索引中读时再校验每次命中前向对象存储发一个 HEAD 请求确认 ETag简单但违背了缓存减少网络请求的初衷。写入时记录 ETag后续命中不校验依赖外部失效通过消息队列、对象事件通知或调用方显式invalidate(key)使缓存失效。TTL 过期给每个缓存块设置有效期过期后再回源校验。逻辑简单代价是短时间内可能返回旧数据。最实用的做法往往是组合正常读取时缓存命中直接返回外部接入方如果有写操作主动调用缓存层的失效接口同时提供一个短 TTL 作为兜底。如果对象是一旦写入就不再更新的数据集比如训练数据集归档那甚至可以完全不做 ETag 校验只做 key 维度失效性能最好。关键点缓存层永远不应该是唯一的数据源。缓存可以丢、可以失效、可以过期这些情况只影响速度不影响正确性。如果缓存写坏了导致返回错误数据那才真正伤到系统。4. 落地实践一个最小可用设计4.1 最简流程拆块、查索引、回源、组装返回一个最小可用的 byte-range cache其实不需要多少行代码。核心流程是固定的接收上层传下来的读取请求(bucket, key, start, end)。按固定块大小把[start, end]拆成多个 block 请求。遍历 block检查索引未命中则回源拉取对应 block。把所有 block 的字节按原范围截取、拼接后返回。异步或同步把拉取到的 block 写入本地缓存并更新索引。这里的难点不在“读”而在“缓存层对调用方要像读本地文件一样简单”。设计时最好把接口收敛成一个read(bucket, key, start, end) - bytes上层不需要关心块在哪、缓存有没有命中。4.2 一段概念代码下面这段代码只表示设计思路不是某一家生产代码你可以根据自己的对象存储 SDK 调整def read_range(client, bucket, key, etag, start, end): blocks [] first_block start // block_size last_block (end - 1) // block_size for block_no in range(first_block, last_block 1): block get_block(client, bucket, key, etag, block_no) blocks.append(block) data b.join(blocks) offset start - first_block * block_size length end - start return data[offset:offset length] def get_block(client, bucket, key, etag, block_no): cache_key (bucket, key, etag, block_no) if cache_key in index: return load_local_block(cache_key) block_start block_no * block_size block_end min(block_start block_size - 1, object_size - 1) resp client.get_object( Bucketbucket, Keykey, Rangefbytes{block_start}-{block_end}, ) data resp[Body].read() store_local_block(cache_key, data) index[cache_key] (data, time.time()) return data几个值得注意的细节etag作为缓存键的一部分天然避免版本冲突。block_end必须对object_size做 min避免请求超出对象末尾。回源失败时要做重试和降级如果缓存不可写至少应该把数据直接返回给调用方。index用内存字典还是持久化索引取决于缓存规模。4.3 关键参数怎么选参数默认建议说明block_size64KB - 1MB太小元数据多太大浪费带宽并使命中粒度变粗cache_capacity取决于磁盘容量与目标命中率建议先按工作集大小的 10%-30% 估算max_concurrent_fetch8 - 16防止冷启动回源风暴ttl0不失效到数小时数据只读或很少更新时设 0 最合适cleanup_interval60s定时清理过期块和孤儿文件块大小的选择对命中率影响非常直接。如果读请求集中在某几个 4KB 对齐的区间block_size选 64KB 可能让每个请求多拉了 60KB 无用数据而选 1MB 则会放更大。实际工程里可以先用日志统计真实 Range 请求的分布再决定block_size。常见对象存储的最小读取和对齐要求也值得确认避免请求一个不允许的偏移。4.4 什么时候不应该用 byte-range cache这个判断很关键。不是所有对象存储读取慢的问题都适合套一层缓存。读一次且不再重读比如日志备份恢复按顺序拉全量数据缓存命中率趋近于零不如直接做全量下载或流式读取。对象非常小对象本身只有几 KB固定块很容易把整个对象包进去。与其走缓存层不如把对象完整读入后由上层自己复用。数据写入频繁且要求立刻一致ETag 失效策略很难做到实时写后立即读容易拿到旧缓存。这种情况下优先考虑对象存储的强一致读能力不要自建缓存层。多节点并发写入同一对象缓存层的失效广播会变成一个分布式一致性问题复杂度已经超出了一层缓存所能承载的范围。这句话可以作为选型判断标准是否有大量“相同字节范围”被重复读取。如果没有byte-range cache 再先进也没有收益。5. 长期维护与工程化不要只做一次性轮子5.1 命中率是最重要的健康指标上线缓存后第一件要做的事不是继续优化而是把指标接好。至少需要监控字节命中率命中缓存的字节数 / 总读取字节数。这个指标直接决定回源流量和费用。请求命中率命中缓存的读请求次数 / 总请求次数。它反映的是“少发了多少次网络请求”。回源字节数与回源耗时回源流量大通常说明冷启动或缓存失效。驱逐率单位时间内被淘汰的块数。驱逐率过高说明缓存容量不够需要扩容或调整块大小。元数据内存占用如果为每个块都存一个对象几十万块的索引会吃掉可观的内存要注意内存模型。一旦命中率低于预期先不要急着调参数要回到工作负载本身。命中率低通常意味着读请求本身的重复性不够而不是缓存实现有问题。5.2 排查链路当命中率上不去时从哪查起一个实用的排查顺序看真实 Range 请求分布把上层发下来的(start, end)记录下来观察是否集中在少数热区。如果范围几乎不重复字节范围缓存先天帮不上忙。看block_size是否合理大量请求横跨很多个块但只读取每个块的一小段说明块太大需要调小如果索引里块数量暴涨且命中率低可能是块太小。看 ETag/版本变化如果对象更新频繁每次更新都会让旧块失效命中率会大幅下降此时要考虑外部失效事件是否及时触达。看驱逐与容量崩溃后重启缓存为空冷启动阶段命中率自然会低如果容量不够也会频繁驱逐导致“刚刚写入的块马上被淘汰”。看并发回源如果回源请求数量异常高检查 singleflight 是否生效是否有多个进程各自维护了一份索引而没有共享。一个常见误区是只看“缓存 read 的次数占比”而不看“回源是否真的被减少”。比如一个请求拆成 100 个块99 个命中1 个未命中请求命中率可能有 99%但回源的那 1 个块依然会带来一次完整的网络请求和延迟。所以在读延迟敏感的系统中更应该关注“单次上层请求有多少比例在缓存内完成”。5.3 缓存只是加速层不是正确性层设计这套系统时我一直坚持一个原则缓存可以丢但不能让调用方拿到错误数据。所以缓存层的每个块都最好带上(bucket, key, etag, block_no)的完整定位信息如果某个块读取失败应该保证上层能通过“缓存未命中 → 回源读远端”的方式兜底本地盘损坏、目录被清空、索引丢失都不能影响数据的最终正确性。做工程时还可以把“缓存写失败”当成平常事处理而不是致命错误。例如当磁盘空间不足时store_local_block可以返回失败但read_range仍然应该把刚从远端拉到的数据直接返回给调用方。这样缓存退化后系统只是变慢而不是直接不可用。另外缓存预热也值得考虑。如果训练前已知某些对象会被大量读取可以提前把高频块拉取到本地避免训练开始时并发回源撞在一起。预热任务应该限制并发和在线回源流量分开避免预热打满对象存储配额。5.4 我的整体判断先解决局部性再追求复杂架构回到最开始的问题对象存储的随机读性能瓶颈很多团队会先想到换存储、换服务商、或者上分布式缓存集群。但从工程投入和收益来看byte-range cache 的第一步价值在于“把局部性接住”。原因很简单大量实际负载的读请求并不均匀而是高度集中在少数字节范围。无论你是训练 WebDataset、阅读 Parquet 的 row group还是做视频按需抽帧热点范围重复出现的概率都很高。一层简单的本地固定块缓存就能省掉大量回源请求和流量费用。真正需要警惕的是把方案做复杂。一开始就用分布式缓存、多级缓存、复杂的权重驱逐算法只会让问题从“读数据慢”变成“缓存的元数据、一致性和运维也让人头疼”。更务实的路径是先做单机、固定块、带 ETag 失效的 byte-range cache跑通业务再根据监控决定要不要加分布式能力。如果你也恰好有对象存储大量随机读的困扰我建议先统计一下真实读请求里重复范围的占比。如果重复范围足够多做一个最小可用的 byte-range cache性价比会比盲目升级存储高很多。