logo
Public
0
0
WeChat Login
feat: 实现后台并行预取功能

rangefs

基于 HTTP Range 的用户态虚拟文件读取库。将支持 HTTP Range 的远端对象暴露为进程内可 Read / Seek / Close 的虚拟文件句柄,无需 FUSE,无需整文件下载。

核心特性

特性说明
按需读取仅在 Read() 时发起远端 Range 请求,不预取全量数据
双层块缓存默认 64 MiB 内存 LRU,可选启用 1 GiB 磁盘缓存,同一 FS 实例下多文件共享热点块
连接复用小范围前向 Seek 在阈值内通过 io.Discard 跳过,避免重连
ReadAt 解耦ReadAt 走独立直取窗口,不扰动顺序读取游标与活动连接
一致性校验读取期间若 ETag / Size / Last-Modified 发生漂移,立即报错
指数退避对 timeout、5xx、429、短读等可恢复错误自动重试
协议兼容HEAD 不可用时自动 fallback 为 GET Range: bytes=0-0;兼容非标准 200 + Content-Range 响应
可选并行预取默认关闭;开启后按文件级后台并行 Range 预热后续块,默认仅对 >= 8 MiB 的顺序读取生效

快速开始

package main import ( "context" "io" "log" "cnb.cool/svn/s3vfs/rangefs" ) func main() { // 1. 创建 HTTP Source source, err := rangefs.NewHTTPSource("http://127.0.0.1:9000") if err != nil { log.Fatal(err) } // 2. 创建文件系统实例 fs, err := rangefs.New( source, rangefs.WithBlockSize(1<<20), // 1 MiB 块 rangefs.WithPrefetchBlocks(2), // 每次请求额外预取 2 块 rangefs.WithSmallSeekThreshold(128<<10), // 128 KiB 内小跳复用连接 rangefs.WithMaxCacheBytes(64<<20), // 默认内存缓存上限 64 MiB rangefs.WithDiskCache("/var/cache/rangefs"), // 可选:启用磁盘缓存 rangefs.WithMaxDiskCacheBytes(1<<30), // 可选:磁盘缓存上限 1 GiB rangefs.WithParallelStreams(4), // 可选:单文件后台并行预取 4 路 rangefs.WithParallelAheadBlocks(8), // 可选:后台额外预取 8 个后续块 ) if err != nil { log.Fatal(err) } // 3. 打开远端对象 file, err := fs.Open(context.Background(), "assets", "video/demo.mp4") if err != nil { log.Fatal(err) } defer file.Close() // 4. 像本地文件一样读取 buf := make([]byte, 4096) _, _ = file.Read(buf) // 从偏移 0 读取 _, _ = file.Seek(8<<20, io.SeekStart) // 跳到 8 MiB 处 _, _ = file.Read(buf) // 继续读取 }

架构概览

┌─────────────┐ │ Application │ └──────┬───────┘ │ Open / Read / Seek / Close ▼ ┌─────────────┐ ┌──────────────┐ │ rangefs │────▶│ BlockCache │ 内存 LRU 块缓存 │ .FS │ └──────────────┘ └──────┬───────┘ ┌──────────────┐ │ │ DiskCache │ 可选磁盘块缓存 │ └──────────────┘ │ ┌──────────────┐ │ │ PrefetchPool │ 可选后台并行 Range 调度 │ └──────────────┘ │ ▼ ┌─────────────┐ ┌──────────────────────┐ │ rangefs │────▶│ Range Stream Controller│ 连接复用 + 预取窗口 │ .File │ └──────────────────────┘ └──────┬───────┘ │ Stat / GetRange ▼ ┌─────────────┐ │ Source │ 抽象接口(可自定义实现) └──────┬───────┘ │ ▼ ┌─────────────┐ │ HTTPSource │ 内置实现:HEAD/GET + Range └─────────────┘

关键组件

Source — 对象源接口

Sourcerangefs 对远端存储的唯一依赖,定义了两个方法:

type Source interface { Stat(ctx context.Context, bucket, key string) (ObjectInfo, error) GetRange(ctx context.Context, bucket, key string, offset, length int64) (*RangeResult, error) }
  • Stat:返回对象元信息(Size、ETag、LastModified)
  • GetRange:打开 [offset, offset+length) 范围的数据流,length < 0 表示读到文件尾

可通过实现此接口对接任意存储后端,无需依赖 HTTP。

HTTPSource — 内置 HTTP 实现

HTTPSource 通过标准 HTTP HEAD/GET + Range 头与 S3 兼容端点交互:

操作实现方式
Stat优先 HEAD;若返回 405/501 则自动 fallback 为 GET Range: bytes=0-0
GetRangeGET + Range: bytes=start-end 头;兼容 206 和 200 + Content-Range

配置选项:

  • WithHTTPClient(client) — 复用自定义 *http.Client
  • WithRequestMutator(fn) — 为每个请求追加 header/签名(如 AWS SigV4)
  • WithRequestBuilder(fn) — 完全接管请求构造逻辑

认证接入示例:

// 方式一:每个请求走 Header SigV4 签名 source, err := rangefs.NewHTTPSource( "https://s3.example.com", rangefs.WithRequestMutator(func(req *http.Request) error { // 在这里为 req 注入 Authorization / X-Amz-Date / X-Amz-Content-Sha256 return signV4Header(req, accessKey, secretKey, "us-east-1") }), ) // 方式二:完全接管请求构造,返回 presigned URL source, err := rangefs.NewHTTPSource( "https://s3.example.com", rangefs.WithRequestBuilder(func(ctx context.Context, spec rangefs.RequestSpec) (*http.Request, error) { return buildPresignedRequest(ctx, spec) }), )

最佳实践:

  • Header 签名适合固定 AK/SK、长连接复用和统一中间件注入场景
  • Presigned URL 适合上游只下发短时授权、客户端不直接持有 Secret 的场景
  • 若服务端启用了 SigV4,至少应对 HEADGET + Range 两条链路都做回归验证

FS — 文件系统实例

FS 管理共享缓存和全局配置。通过 New(source, ...Option) 创建,所有从同一 FS 打开的 File 共享底层缓存层:默认只有 BlockCache,启用磁盘缓存后会形成“内存优先、磁盘补位”的双层结构。

配置选项:

选项默认值说明
WithBlockSize(n)1 MiB缓存块大小,也是 Range 请求的基础窗口
WithPrefetchBlocks(n)2每次新建流时额外预取的后续块数
WithSmallSeekThreshold(n)128 KiB前向小跳复用连接的最大跳过距离
WithMaxCacheBytes(n)64 MiB内存块缓存字节上限
WithBlockCache(cache)自动创建复用外部共享内存缓存实例
WithDiskCache(dir)关闭启用磁盘块缓存并指定缓存目录
WithMaxDiskCacheBytes(n)1 GiB磁盘块缓存字节上限
WithParallelStreams(n)0单文件后台并行 Range 预取并发数;0 表示关闭
WithParallelAheadBlocks(n)parallelStreams 相同顺序读取时后台额外预取的块数
WithParallelMinObjectSize(n)8 MiB启用后台并行预取的最小对象大小
WithParallelTotalLimit(n)32单个 FS 内全部文件共享的后台并行请求上限
WithRetryPolicy(policy)4 次 / 100ms / 2s指数退避策略

File — 虚拟文件句柄

通过 fs.Open(ctx, bucket, key) 获得,提供标准文件操作:

方法说明
Read(p)从当前偏移读取,自动推进游标
ReadAt(p, off)从指定偏移读取,不改变当前游标,走独立直取窗口
Seek(offset, whence)支持 SeekStart / SeekCurrent / SeekEnd,允许跳到 EOF 之后
Stat()返回打开时锁定的对象元信息快照
Close()关闭句柄,释放底层网络流

BlockCache — LRU 块缓存

  • blockSize 为粒度缓存数据块
  • 缓存 key 由 bucket + key + ETag + size + lastModified 组成,对象变更时自动失效
  • 按字节预算做 LRU 淘汰,超限后淘汰最久未访问的块
  • 多个 File 共享同一缓存实例,热点块自动复用

DiskCache — 可选磁盘块缓存

  • 默认关闭;通过 WithDiskCache(dir) 显式启用
  • 启用后采用“内存优先、磁盘补位”策略:内存未命中时查磁盘,磁盘命中后自动回灌内存
  • 默认磁盘上限为 1 GiB,可通过 WithMaxDiskCacheBytes(n) 调整
  • 缓存文件名使用块 key 的 SHA-256 哈希,可跨 FS 实例复用同一缓存目录
  • 适合大文件、长时读取或容器内存预算较紧的场景

Parallel Prefetch — 可选单文件并行预取

  • 默认关闭;仅当 WithParallelStreams(n)n > 0 时启用
  • 只对顺序 Read() 生效,ReadAt() 不会主动触发后台并行预取
  • 进入顺序态后,前台仍只保障当前块可读;后台并发预热后续块到 cache
  • 默认仅对 >= 8 MiB 的对象生效,避免小文件引入额外噪声
  • Seek()、回退跳读或 Close() 会取消旧一代预取任务,防止陈旧结果继续消耗带宽
  • 多个文件共享 WithParallelTotalLimit(n) 设定的全局后台请求预算
  • 它不是“永远更快”的总开关:对于纯顺序冷读,若 prefetchBlocks 已能有效合并 Range,请先保留窗口预取,再决定是否打开并行

RetryPolicy — 指数退避

type RetryPolicy struct { MaxAttempts int // 最大重试次数(默认 4) BaseDelay time.Duration // 首次退避间隔(默认 100ms) MaxDelay time.Duration // 退避上限(默认 2s) }

可重试的错误类型:网络超时、429/408/5xx、io.ErrUnexpectedEOF、短读。 不可重试:context.CanceledErrObjectModifiedErrInvalidRangeErrObjectNotFound

Benchmark 与调优

仓库内置了可重复的冷缓存顺序读 benchmark,位于 rangefs/benchmark_test.go。可用下面的命令直接复演:

go test ./rangefs -run '^$' -bench '^BenchmarkSequentialReadColdCache$' -benchmem -benchtime=3x

若要继续验证 Seek 抖动、混合 ReadAt 和多文件并发场景,可执行:

go test ./rangefs -run '^$' -bench '^(BenchmarkSeekJitterColdCache|BenchmarkMixedReadAtColdCache|BenchmarkConcurrentSequentialReadColdCache)$' -benchmem -benchtime=3x

当前仓库环境的一次样例结果如下:

  • 机器:AMD EPYC 9K65
  • 场景:8 MiB 对象、模拟单次 Range RTT 4 ms、冷缓存顺序读
  • 结论重点:看相对趋势,不要把绝对数值当成不同机器上的硬门槛
配置结果结论
256 KiB + prefetch 0 + parallel 0134.5 ms/op, 32 ranges/op小块且无窗口预取时,RTT 成本最重
256 KiB + prefetch 2 + parallel 047.8 ms/op, 11 ranges/op仅靠窗口预取就能显著减少 Range 次数
256 KiB + prefetch 0 + parallel 4/ahead 843.3 ms/op, 32 ranges/op当必须维持小块且不想扩大单请求窗口时,parallel 可把耗时压到接近甚至略优于窗口预取
1 MiB + prefetch 0 + parallel 036.6 ms/op, 8 ranges/op增大块大小本身就能明显降低 RTT 放大
1 MiB + prefetch 2 + parallel 015.4 ms/op, 3 ranges/op该组是本轮纯顺序冷读 benchmark 的最快配置
1 MiB + prefetch 0 + parallel 4/ahead 819.4 ms/op, 8 ranges/op比无窗口串行更快,但仍慢于 1 MiB + prefetch 2 的窗口预取

从这组数据可以得到三条直接结论:

  1. 对纯顺序大文件读取,优先调 blockSizeprefetchBlocks,不要默认认为 parallel 一定更快。
  2. parallelStreams=4 在“小块、禁用窗口预取”的场景比 parallelStreams=2 更容易体现收益;两路并行更适合保守灰度,不适合追求极限吞吐。
  3. 若当前对象是连续顺序读,且允许适度 over-fetch,1 MiB + prefetch 2 仍是最稳的起点。

更多工作负载样例

同一台机器上,吾继续补跑了三组更接近真实业务的样例:

  • Seek 抖动:8 MiB 对象,每读取 256 KiB 左右就插入一次 SeekCurrent(+256 KiB)
  • 混合 ReadAt8 MiB 对象,顺序读过程中每两次 Read 插入一次远端 ReadAt
  • 多文件并发:4 个文件并发顺序读,每个文件 4 MiB
场景配置结果结论
Seek 抖动1 MiB + prefetch 2 + parallel 035.5 ms/op, 8 ranges/op作为基线最稳
Seek 抖动1 MiB + parallel 4/ahead 835.4 ms/op, 8 ranges/op与基线几乎持平,说明频繁 seek 会稀释 parallel 收益
Seek 抖动256 KiB + parallel 4/ahead 867.1 ms/op, 16 ranges/op小块 + 抖动下请求数翻倍,明显更慢
混合 ReadAt1 MiB + prefetch 2 + parallel 021.6 ms/op, 4 ranges/op当前实现下表现最好
混合 ReadAt1 MiB + parallel 4/ahead 823.9 ms/op, 8 ranges/opReadAt 不主动触发并行预取,因此收益有限
混合 ReadAt256 KiB + parallel 4/ahead 877.7 ms/op, 32 ranges/op在随机扰动下,小块并行最容易放大 RTT 成本
多文件并发1 MiB + prefetch 2 + parallel 012.7 ms/op, 8 ranges/op该组仍是本轮最佳
多文件并发256 KiB + parallel 4/ahead 8 + total 840.7 ms/op, 64 ranges/op全局限流能兜底,但请求数仍然偏大
多文件并发256 KiB + parallel 4/ahead 8 + total 3228.2 ms/op, 64 ranges/op放宽总并发后吞吐更好,但仍慢于 1 MiB + prefetch 2

这些结果再补了一层判断:

  1. Seek 很频繁时,parallel prefetch 更像“保底”,不是加速器。
  2. 混合 ReadAt 场景下,大块 + 窗口预取依旧最稳,因为 ReadAt 本身走独立窗口,不吃后台并行的直接红利。
  3. 多文件并发时,WithParallelTotalLimit 必须保守设置;但在当前模型下,它更多是“防打爆上游”的保险丝,而不是替代 blockSize/prefetchBlocks 的主调参手段。

生产参数起点

建议先按访问模式选一组起点,再做灰度:

场景建议参数说明
纯顺序大文件读取WithBlockSize(1<<20) + WithPrefetchBlocks(2) + parallel off默认推荐起点,吞吐和请求数平衡最好
Seek 抖动明显WithBlockSize(1<<20) + WithPrefetchBlocks(2) + parallel off 或最多 parallel 2~4 小流量灰度先稳住请求数,不要急着缩块
混合 ReadAt + 顺序读WithBlockSize(1<<20) + WithPrefetchBlocks(2) + parallel off当前实现下最稳,先让 ReadAt 吃窗口缓存
顺序读为主,但希望减少 seek 后冷块等待WithBlockSize(1<<20) + WithPrefetchBlocks(0~1) + WithParallelStreams(4) + WithParallelAheadBlocks(8)只在确认 RTT 成本高、窗口预取不够用时打开
小块读取、需要压低单次 over-fetchWithBlockSize(256<<10)WithBlockSize(512<<10) + WithPrefetchBlocks(0~1) + WithParallelStreams(4) + WithParallelAheadBlocks(8)parallel 在这类场景更有意义
多文件高并发优先 WithBlockSize(1<<20) + WithPrefetchBlocks(2),若必须启用并行,再加 WithParallelTotalLimit(16~32)先靠更少的 Range 取胜,再用总限流兜底

若不确定该选哪组,先从下面这条安全配置起步:

fs, err := rangefs.New( source, rangefs.WithBlockSize(1<<20), rangefs.WithPrefetchBlocks(2), rangefs.WithSmallSeekThreshold(128<<10), rangefs.WithMaxCacheBytes(64<<20), )

只有当你确认“顺序读很长、RTT 明显、窗口预取不够或 over-fetch 不可接受”时,再切到:

fs, err := rangefs.New( source, rangefs.WithBlockSize(256<<10), rangefs.WithPrefetchBlocks(0), rangefs.WithSmallSeekThreshold(128<<10), rangefs.WithMaxCacheBytes(64<<20), rangefs.WithParallelStreams(4), rangefs.WithParallelAheadBlocks(8), rangefs.WithParallelTotalLimit(32), )

读取流程

顺序读取(Read)

Read(p) │ ├─ 计算当前偏移对应的 blockIndex ├─ 检查缓存:先查内存,再查磁盘;命中则直接返回 └─ 未命中: ├─ positionStreamLocked: 复用或新建 Range 流 │ ├─ 已有流且偏移在窗口内 + 小跳阈值内 → io.Discard 跳过 │ └─ 否则关闭旧流,发起新 Range 请求(含预取窗口) ├─ readBlockLocked: 从流中读取一个完整块 └─ 将块写入共享缓存

若开启并行预取且检测到顺序读取模式,后续块会由后台 worker 并发写入 cache;前台读取到这些块时会优先命中 cache 或等待同块 inflight 任务完成,而不会重复发起相同 Range。

随机读取(ReadAt)

ReadAt(p, off) │ ├─ 计算目标 blockIndex ├─ 检查缓存:先查内存,再查磁盘;命中则直接返回 └─ 未命中: └─ fetchWindowDirectLocked: 发起独立 Range 请求 ├─ 不复用当前顺序流 ├─ 读取整个窗口数据(含预取块) └─ 按 blockSize 切分后批量写入缓存

一致性校验

打开文件时通过 Stat 锁定对象元信息。后续每次 Range 响应都会校验:

  • ETag — 不匹配则对象内容已变
  • Size — 不匹配则对象被截断或追加
  • Last-Modified — 不匹配则对象被覆盖

任一校验失败立即返回 ErrObjectModified,防止将不同版本的数据块拼接到同一文件视图。

错误定义

错误含义
ErrClosed文件句柄已关闭
ErrInvalidSeekSeek 目标非法(负偏移或非法 whence)
ErrInvalidRange数据源拒绝给定的范围请求
ErrObjectNotFound目标对象不存在
ErrShortRange范围响应在期望长度前提前结束
ErrObjectModified对象在读取过程中发生变化

适用场景

  • 容器内无法使用 FUSE,但业务需要文件式读取远端大对象
  • 音视频、模型文件、归档数据的顺序读与小范围随机读
  • 需要在内存与性能之间做可控权衡,而不是整文件一次性拉取

限制

  • ReadAt 与顺序 Read/Seek 共用同一文件句柄状态,不建议多 goroutine 并发混用同一 File
  • 内置 HTTPSource 依赖 HEAD 与 GET,复杂鉴权需通过 RequestMutator 或自定义 RequestBuilder 注入
  • 不提供完整 POSIX 文件系统、目录树浏览、写入或对象生命周期管理