给对象存储做字节范围缓存,是我处理大文件随机读取时最先考虑的一个优化点。对象存储本身支持 Range 请求,但每次请求都要走网络、解析响应,反复读取同一段数据时成本很高。字节范围缓存要做的,就是把按字节区间读取过的对象片段缓存在本地,后续请求同区间时直接从缓存返回,少一次远端访问,就少一次延迟和流量。
下面按我实际落地时拆解的顺序来写:先搞清楚它解决什么问题,再讲缓存块怎么组织,然后用一个最小实现跑通基本流程,最后说清楚对接对象存储时要注意的坑和验证方法。适合正在做数据准备、媒体处理、训练样本读取、日志分析这类任务的开发,也适合被大文件随机读拖慢的人参考。
1. 为什么对象存储的大文件读取需要“字节范围缓存”
1.1 先描述一个最常见的现场
假设对象存储里有一个 10 GB 的模型文件,或者一部 MP4 视频、一个 Parquet 数据集。你的业务并不会从头到尾顺序读一遍,而是需要读取其中某一段,比如视频第 30 秒对应的时间段,模型某个 checkpoint 的尾部,或者数据集中某些行的分片。对象存储支持 Range 请求,可以只拉取指定字节区间,这看起来已经很高效。
但问题在于,如果同样一段数据被多个任务、多次请求反复读取,它仍然需要每次回到对象存储拉取。即便有服务器端的缓存或 CDN,内网环境下常见问题也是连接重复建立、带宽占用和对象存储的访问请求计费。字节范围缓存就是在客户端这一侧,把已经获取过的字节区间暂存到本地磁盘或内存,让相同区间的后续读取不再产生网络请求。
这类需求在分布式训练前的数据打散、视频抽帧、大型二进制文件的索引读取里很常见。对象存储的优势是便宜、容量大、按量计费,但代价是每一次随机读都有网络往返。本地缓存的意义不是在所有场景里替代对象存储,而是在“读多写少、固定区间重复访问”的场景里,把热点数据留在离任务更近的地方。
1.2 Range 请求与字节范围缓存的关系
HTTP Range 语义很简单:客户端发送GET /bucket/key,带上Range: bytes=start-end,服务端返回206 Partial Content,配合Content-Range说明实际返回的区间。对象存储 SDK 大多把这段逻辑封装成了下载时指定起始字节和长度的方法。
字节范围缓存做的事情,不是改变 Range 请求本身,而是把响应体按照 key + 区间缓存下来。它和普通对象缓存最大的区别在于:普通缓存通常按“整个文件”作为粒度,读 1 KB 也要先把整个文件下下来;字节范围缓存按“字节区间”作为粒度,只缓存被请求的部分。对于大文件,这价值非常明显:你不需要为了一次 1 MB 的读取,把 10 GB 对象拉到本地。
另一个容易混的概念是 CDN 或 HTTP 代理缓存。它们也能处理 Range 请求,有些还会把完整文件回源后再切分。但自己做字节范围缓存,能更细地控制块大小、预取策略、存储位置,也能避免过度依赖外部组件。它更像一个位于对象存储 SDK 和业务代码之间的本地缓存层。
1.3 值得用和不一定值得用的判断标准
先给一个我常用的判断标准:
- 值得用:对象体积大(超过几百 MB)、只访问其中的部分区间、同一区间有重复访问、对延迟敏感或对回源带宽敏感。
- 不值得用:文件很小(几 KB 到几 MB)、访问模式接近全量顺序读、缓存命中率低、本地磁盘空间有限。
- 可能要换方案:业务需要频繁修改对象内容、多机并发同步写入同一个对象、对强一致性要求极高,比如同一份数据刚写入立刻要读到最新版本。
缓存不是越复杂越好。如果你只是偶尔读一两个对象,默认 SDK 的流式读取就够用。真正适合动手写字节范围缓存的,是大文件多段随机读且重复率不低的场景。先识别出这个“是否值得”的边界,再谈实现。
2. 缓存系统的核心设计决策:从一块数据到一把字节区间
2.1 按固定分块切分,还是按请求区间切分
第一个要决定的,是缓存块的划分方式。
常见做法有两种:一种是固定分块(fixed chunk),把对象按固定大小切,比如 4 MB、8 MB、16 MB;另一种是按请求区间原样缓存,来一个 Range 就存一段,不强制对齐。
我建议优先考虑固定分块。原因有三点:
- 命中判断简单。请求只要落在某个已缓存块范围内,就能直接判断命中。
- 空间管理容易。固定大小的缓存文件可以预分配目录、清理过期块、做容量统计。
- 预取方便。缺了某一块,可以直接按块回源,不会出现缓存文件碎片越攒越多。
按请求区间缓存的问题在于:请求范围可能反复变化,比如第一次请求 0-100,第二次请求 50-200,如果按原区间保存,会发现有重叠区域无法复用,缓存文件也变得越来越碎,命中率反而不好。
固定分块也有一个代价:如果业务请求非常稀疏,每一块只读了几 KB,按整块缓存会浪费空间和回源带宽。这时可以把块大小调小,或者引入“小请求合并”逻辑,先不缓存整块,等重复访问达到阈值再落盘。初次实现不用做这个优化,先把固定分块跑通。
2.2 块大小怎么选
块大小是整个缓存系统最重要的参数之一,我一般会在实现前先做一次小样本统计,看业务请求的区间长度和重复规律。选择标准大致如下:
- 1 MB 以下:适合小对象或极稀疏随机读,内存占用低,但可能产生较多的小文件。
- 4 MB 到 16 MB:适合大多数大文件随机读,回源次数和本地文件数量都能平衡。
- 32 MB 以上:适合顺序读较多或网络带宽很高的环境,回源次数更少,但浪费空间的风险更大。
块大小和对象存储的请求成本要放在一起看。对象存储按请求次数计费时,块太小会产生大量 GET 请求;块太大又会缓存很多业务用不到的数据。一个折中是:先按 8 MB 起步,观察命中率和本地磁盘占用,再根据日志调整。
注意:块大小不是越大约好。缓存系统最常见的性能陷阱,就是大块预取导致回源带宽飙高,而业务实际只用了其中一小段。
2.3 缓存命中的判断:区间覆盖与合并
固定分块后,一个请求可能覆盖多个块。比如请求bytes=0-10485759,块大小 4 MB,那么需要块 0、1、2 共三个块。每一个块可能已缓存,也可能未缓存。命中判断需要区分三种情况:
- 完整命中:请求范围内所有需要的块都已缓存,直接读取本地,不需要回源。
- 部分命中:部分块已缓存,缺的块需要回源。
- 完全未命中:所有块都未缓存,按请求区间回源。
实现时,我通常不会直接以“对象 + 块号”并存整个区间,而是把块号映射成一段物理范围。例如对象key、块大小 8 MB,块号n对应字节区间[n*8MB, (n+1)*8MB)。缓存索引使用(bucket, key, block_id) -> local_path。
因为使用固定分块,请求合并的核心逻辑其实是把业务 Range 拆成若干完整块,然后逐块检查缓存状态。缺失块可以按原始请求区间一次性回源,也可以单独补拉缺失块。顺序上建议先缺失块回源,再拼接返回,避免重复拉取同一块。
2.4 替换策略:LRU 与容量控制
本地磁盘不是无限的。缓存块需要有上限,满了之后要能淘汰旧块。
最简单有效的是 LRU:每个缓存块维护一个最近访问时间,容量达到上限后,删除最久没被访问的块。实现时不需要一开始就用复杂的跳表,一个access_time字段加一个按照时间排序的清理任务就够。初级版本可以每次写入后扫描一遍目录,统计总大小,超过阈值按最旧删除;压力大了再改成索引维护。
更进一档的做法是引入访问频次。比如一个块虽然最近访问过,但只访问过一次,另一个块访问了二十次,当容量紧张时,保留高频块更合理。可以用类似 ARC 或 W-TinyLFU 的思路,但没必要自己从头写。第一次实现用 LRU 已经能满足大部分场景。
3. 一个最小可运行的本地字节范围缓存实现
3.1 定义目录结构和元数据
这里直接给一个最小实现的设计,语言用 Python 描述思路,迁移到 Go、Java 或 Rust 都非常直接。
缓存根目录下面按对象维度建子目录,避免所有块堆在一个目录里:
cache_root/ {bucket}/ {key_hash}/ {block_id}.bin不直接使用原始 key 建目录,是因为 key 里可能包含斜杠、特殊字符或不适合作为文件名的字符串。简单做法是取 key 的 SHA-256 前 16 位作为目录名,同时用一个meta.json记录bucket、key、chunk_size、total_size和最近访问时间。
{ "bucket": "default", "key": "datasets/sample.bin", "chunk_size": 8388608, "total_size": 10737418240, "last_access_at": 1700000000 }这里meta.json不是为了每次读都解析,而是在扫描清理和恢复索引时用。每次读写操作主要靠文件名里的block_id定位。
3.2 核心读写流程
我一般会先把流程拆成三个函数:get_range(bucket, key, start, length)、read_from_cache(bucket, key, block_id)、write_to_cache(bucket, key, block_id, data)。
伪代码:
def get_range(bucket, key, start, length): end = start + length first_block = start // CHUNK_SIZE last_block = (end - 1) // CHUNK_SIZE result = bytearray() for block_id in range(first_block, last_block + 1): block_start = block_id * CHUNK_SIZE block_end = min(block_start + CHUNK_SIZE, total_size) data = read_block(bucket, key, block_id) result.extend(data) return bytes(result[max(0, start - first_block * CHUNK_SIZE):])这个写法把返回结果简化成“读完整块再截取”。实际上更稳妥的流程是:
- 先根据
start和length算出需要的块范围。 - 对每个块,检查本地缓存文件是否存在,且文件长度等于块应有长度。
- 缺失的块统一回源,回源成功后写入缓存。
- 最后从完整块数据里切出业务实际请求的区间。
需要注意末尾块。对象总大小不是块大小的整数倍时,最后一块长度小于CHUNK_SIZE,判断缓存是否命中时不能只查文件是否存在,还要校验长度。否则一旦缓存文件只有一部分,就会把残缺数据当作完整块返回。
3.3 请求合并与缺失块回源
在最小版本里,缺失块直接逐块调用对象存储 SDK 的get_object,指定Range。这样实现简单,但会有一个明显问题:一个跨三个块的请求,如果三块都缺失,会发三次回源请求,增加延迟。
更好的做法是把缺失但相邻的块合并成一个 Range 回源,然后按块切分后分别落盘。比如请求覆盖块 1、2、3,块 2 有缓存,块 1 和块 3 缺失,就不需要发三次请求,可以分别对块 1 和块 3 各发一次请求;如果块 1、2、3 都缺失,则直接请求块 1 的起点到块 3 的终点,一次拉取,再拆成三块写入。
合并回源的收益在带宽和请求数上都很明显。尤其是请求频繁且块大小较小的时候,一次合并可以把几十次回源变成一次。注意回源完成后,要按块边界切分,不要把相邻块的数据写混。
3.4 并发的写入与锁
当多个线程或进程同时读取同一个对象时,同一个缺失块可能被多个请求一起回源,然后重复写入缓存文件。重复写入本身问题不大,最坏是浪费一次回源带宽,但需要注意写文件时的原子性。
我建议采用“临时文件 + rename”的方式:先把回源数据写到block_id.bin.tmp,写入完成后通过os.replace改成最终文件名。这样读线程不会读到半个文件,也不会出现文件内容互相覆盖。并发控制第一阶段不用加锁,让多读少写自然竞争;如果发现同一个块的并发回源次数很多,再引入块级锁。
多块并发回源时,先把块文件写到
.tmp,再os.replace到正式文件,能避免读线程看见半截文件。
4. 对接对象存储:回源、重试、并发与一致性处理
4.1 封装一个按范围读取的客户端
对象存储 SDK 的实现各不相同,但核心能力都一样:传入 bucket、key、start、length,返回该范围的数据。
我一般会封装一层,让后面的缓存逻辑不依赖具体 SDK:
class RangeReader: def __init__(self, client): self._client = client def get_object_range(self, bucket, key, start, end): body = self._client.get_object( Bucket=bucket, Key=key, Range=f"bytes={start}-{end}" ) return body这层封装的好处是可以随时替换成模拟客户端或者别的对象存储服务。测试时尤其有用:先不用真实对象存储,用一个本地文件系统伪装成远端对象,验证缓存逻辑再切到真实环境。
注意 end 参数的语义。有的 SDK 是闭区间,有的接口是“偏移 + 长度”,有的地方需要传end而不是length。封装时最好统一成闭区间[start, end],在内部做转换,减少调用方写错。
4.2 回源失败的重试与限流
对象存储请求会有偶发超时、网络抖动、限流。回源失败时,不能直接给业务报错,也不能无限重试。
常用的重试策略:
- 首次失败后等 200 ms 重试。
- 第二次失败后等 500 ms。
- 第三次失败后再等 1 秒,如果仍然失败,向业务返回错误。
- 对同一条请求做最多 3 到 4 次尝试。
这里有一个容易被忽略的点:如果回源失败发生在部分块已经写入缓存之后,下一次请求会跳过已缓存块,继续只拉缺失块,所以不需要额外做事务回滚。缓存系统天然支持“部分成功”的继续推进,这一点比整文件缓存要灵活。
另外要控制并发回源数量。比如设置了 16 个并发读,如果每个读都同时缺块回源,对象存储可能因为并发 QPS 上去之后出现限流。给回源操作增加一个信号量,限制最大并发请求数,通常比依赖 SDK 内部重试更有效。
4.3 缓存失效与数据一致性
字节范围缓存和普通缓存一样,最怕数据变了,缓存还在。
对于只读对象、不可变对象、版本化对象,缓存逻辑最简单:只要 key 不变,内容就不会变,缓存可以长期保存。对于可写对象,必须考虑失效策略。常见处理方式有这几种:
- 业务侧在写入对象后调用缓存清理接口,删除该对象的所有缓存块。
- 缓存 key 里带上版本号,读取时每次先获取最新版本号,版本变化则重新回源。
- 设置过期时间,超过 TTL 后不再认为缓存生效。
具体选哪种,取决于你用的对象存储是否提供 ETag、版本 ID 或 Last-Modified 等元数据。如果业务写入频率很低,可以只在写入时主动清理;如果写入频繁,最好在缓存索引里保存对象的 ETag,回源时先做一次 HEAD 请求检查 ETag 是否变化。注意不要每次读都 HEAD,否则成本会明显增加。
常见的坑:写对象时直接用覆盖写,如果缓存客户端已经在本地缓存了旧块,业务读到的还是旧内容。遇到这类场景,先把清理接口做出来,再考虑复杂一致性方案。
4.4 并发任务访问多个对象时的目录容量
上一节的缓存根目录设计,在多对象大规模场景下会变成一个容易忽视的问题。比如有 1000 个对象,每个对象 100 个缓存块,本地目录就会有 10 万个文件。目录扫描会越来越慢,文件系统 inode 也容易吃满。
更好的做法是进一步分层,或者直接把块文件放到按日期、按对象哈希分组的目录里,避免单个目录文件过多。清理任务也不要每次全盘扫描,可以维护一个“最近写入时间”索引,定期清理。
如果本地磁盘容量有限,还需要在写入前先做容量检查。可以设定缓存目录最大 200 GB,达到 90% 时先触发旧块清理,再允许新块写入。这样能防止对象存储缓存把业务磁盘打满。
5. 如何验证缓存系统:命中率、延迟、资源占用与排查顺序
5.1 测试样例与成功标准
第一次验证,不要直接上生产流量。我一般会构造一个模拟对象,比如 1 GB 的随机文件,放在对象存储里。然后做一组请求序列,覆盖以下模式:
- 单块范围内的随机读。
- 跨越多个块的连续读。
- 请求同一区间多次,观察第二次是否命中缓存。
- 请求区间随机且稀疏,观察块大小和空间浪费。
- 并发 8 到 16 个请求同时读取相同或不同区间。
成功标准不只看能不能读到正确数据,还要看几个指标:
| 指标 | 判断标准 |
|---|---|
| 数据正确性 | 返回的字节与直接请求对象存储所得完全一致 |
| 二次读取延迟 | 第二次读取同一区间,耗时明显低于第一次 |
| 缓存命中率 | 按请求字节数或块数统计,能达到预期水平 |
| 回源次数 | 相比无缓存时明显下降 |
| 本地磁盘占用 | 不会无限增长,达到阈值后能清理旧块 |
| 并发稳定性 | 多任务同时跑不出现锁冲突、文件损坏或进程崩溃 |
如果以上指标都正常,再考虑接入真实业务。
5.2 慢请求和命中率低的排查链路
遇到问题先看日志,再改参数。我常用的排查顺序是:
- 先看请求是否命中缓存。可以在日志里打印每个请求的命中块数和缺失块数,不命中时要看是第一次请求,还是缓存被清理,还是缓存 key 不一致。
- 再看回源耗时。如果命中率很高但整体延迟还是高,问题可能在本地文件读取慢、目录过大、磁盘 IO 占用高,而不是对象存储。
- 然后看块大小。命中率低但每个请求都缺很多块,往往是把块调太小了;磁盘暴涨且预取浪费多,往往是把块调太大了。
- 再检查路径和权限。缓存文件写入失败时,很多实现会静默忽略,导致每次请求都回源,日志里却看不到报错。
- 最后检查并发。如果并发高时延迟陡增,优先看回源信号量、CPU 和文件句柄数。
有一个很常见的坑:把缓存 key 拼成了包含 token 或时间戳的临时 URL,导致同一个对象每次 key 都不同,缓存永远不命中。对象存储的缓存 key 应该用稳定的 bucket + key 标识,不要用签名 URL 作为缓存的标识。
5.3 不应该用字节范围缓存的场景
前面提过,这里再展开一下。
如果你面对的是大量小文件,比如几十 KB 的 JSON、图片缩略图,直接做完整文件缓存更简单。字节范围缓存的块元信息、多块拼接逻辑反而是负担。
如果访问模式接近全量顺序读,比如整个文件作为训练样本从头到尾读一遍,用整文件下载或流式读取,一次网络请求就完成了,不需要拆块。这时候做了字节范围缓存,反而会让磁盘缓存多写一遍数据,拖慢速度。
另外,如果对象存储本身就提供了高性能随机读能力,比如一些兼容 S3 协议的内存存储或专门的数据库存储,也不一定要在客户端再套一层缓存。缓存层应该用在“收益明确”的位置,不是为了看起来高级而加一层。
5.4 从单机缓存到分布式缓存的演进思路
单机缓存跑通之后,会遇到多机场景。最常见的是多台计算节点同时读取同一个对象,每台机器都维护自己的本地缓存,可能产生重复回源。
一种思路是引入共享缓存服务器,把缓存块放到多个节点都能读取的分布式文件系统或 Redis/NFS 上,但这会引入新的网络访问,可能失去本地延迟优势。另一种思路是维持本地缓存,同时把回源请求做节点间协同,例如根据对象和块号做一致性哈希,让相同块的回源尽量落到同一台节点,其他节点从该节点获取。
这种演进不一定要在第一个版本实现。我比较建议先在单机模式里把“块的命中、回源、清理、正确性”都跑稳,再考虑多机间的块共享。否则分布式缓存踩的坑会混在一起,很难定位是缓存逻辑的问题还是网络同步的问题。
6. 接入生产前的优化和避坑清单
6.1 分阶段上线
接入生产时,不要一把梭全部流量走缓存。可以先让缓存以“旁路”模式运行:业务读取仍然直接请求对象存储,同时把读取过的区间写入缓存;比对缓存结果和远端结果是否一致,确认无误后再切换直读逻辑。
或者用开关控制:先开启只写不读,确认缓存写入不破坏业务;再开启读命中,但保留直读路径;最后再全面使用缓存。这样可以明显降低上线风险,尤其适合对象内容可能被覆盖的系统。
6.2 指标收集和日志规范
缓存系统必须能观测。至少要记录这些字段:bucket、key、request_start、request_end、hit_blocks、missing_blocks、source(cache/backend)、duration_ms、error。
有了这些日志,才能事后统计命中率、平均回源耗时、耗时分布和最热对象。没有日志的缓存系统,出了问题基本只能靠猜。日志级别建议把命中情况放到 DEBUG,把回源失败和缓存写失败放到 ERROR。
6.3 常见的几个“看起来缓存没生效”的原因
我踩过的坑里,最典型的几个:
- 块缓存写入失败但被吞掉,没有返回错误,导致下次请求继续回源。
- 对象大小变化后,末尾块长度判断错误,每次请求都把末尾块当作缺失块。
- 缓存目录权限不够,临时文件写不进去。
- 清理任务把正在被读取的缓存文件删掉了,读线程拿到一个不完整文件。
- 多个进程使用同一个缓存目录,没有做进程级锁或原子改名。
这些问题都不难修,但很容易被忽略。建议在关键路径上多打日志,不要为了让日志干净而隐藏异常。
6.4 如果不想自己实现,可以看哪些组件
其实很多开源工具已经封装了类似能力。
常见的有:
- 用支持 Range 的 HTTP 缓存代理,比如 Nginx 的 proxy_cache,或者专门的 range cache 模块。
- 对象存储读取框架,比如 JuiceFS、MinIO 网关这类文件系统层缓存,它们会把对象存储映射成文件系统,内部自带分块和本地缓存。
- 数据湖/数据仓库引擎自带的文件缓存,比如 Spark、Presto/Trino 的本地缓存。
如果你只是想解决“大量 Parquet 文件随机读慢”的问题,用 Presto/Trino 的 file cache 可能比自己写缓存更省事。自己实现字节范围缓存,更适合那些有特殊需求、需要深度定制或者无法引入重依赖的场景。
写到这里,我最后的建议是:不要为了写缓存而写缓存。先量化你的请求模式,确认重复读确实存在,再决定是写一个最小缓存层,还是直接使用现成组件。字节范围缓存的本质,是用本地磁盘和复杂逻辑,换取更低的回源成本和更低的读取延迟。只要你能把命中率、块大小和清理策略控制好,它是很值得做的一层优化。