基于 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 是 rangefs 对远端存储的唯一依赖,定义了两个方法:
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 HEAD/GET + Range 头与 S3 兼容端点交互:
| 操作 | 实现方式 |
|---|---|
| Stat | 优先 HEAD;若返回 405/501 则自动 fallback 为 GET Range: bytes=0-0 |
| GetRange | GET + Range: bytes=start-end 头;兼容 206 和 200 + Content-Range |
配置选项:
WithHTTPClient(client) — 复用自定义 *http.ClientWithRequestMutator(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)
}),
)
最佳实践:
HEAD 与 GET + Range 两条链路都做回归验证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 | 指数退避策略 |
通过 fs.Open(ctx, bucket, key) 获得,提供标准文件操作:
| 方法 | 说明 |
|---|---|
Read(p) | 从当前偏移读取,自动推进游标 |
ReadAt(p, off) | 从指定偏移读取,不改变当前游标,走独立直取窗口 |
Seek(offset, whence) | 支持 SeekStart / SeekCurrent / SeekEnd,允许跳到 EOF 之后 |
Stat() | 返回打开时锁定的对象元信息快照 |
Close() | 关闭句柄,释放底层网络流 |
blockSize 为粒度缓存数据块bucket + key + ETag + size + lastModified 组成,对象变更时自动失效File 共享同一缓存实例,热点块自动复用WithDiskCache(dir) 显式启用1 GiB,可通过 WithMaxDiskCacheBytes(n) 调整FS 实例复用同一缓存目录WithParallelStreams(n) 且 n > 0 时启用Read() 生效,ReadAt() 不会主动触发后台并行预取>= 8 MiB 的对象生效,避免小文件引入额外噪声Seek()、回退跳读或 Close() 会取消旧一代预取任务,防止陈旧结果继续消耗带宽WithParallelTotalLimit(n) 设定的全局后台请求预算prefetchBlocks 已能有效合并 Range,请先保留窗口预取,再决定是否打开并行type RetryPolicy struct {
MaxAttempts int // 最大重试次数(默认 4)
BaseDelay time.Duration // 首次退避间隔(默认 100ms)
MaxDelay time.Duration // 退避上限(默认 2s)
}
可重试的错误类型:网络超时、429/408/5xx、io.ErrUnexpectedEOF、短读。
不可重试:context.Canceled、ErrObjectModified、ErrInvalidRange、ErrObjectNotFound。
仓库内置了可重复的冷缓存顺序读 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 9K658 MiB 对象、模拟单次 Range RTT 4 ms、冷缓存顺序读| 配置 | 结果 | 结论 |
|---|---|---|
256 KiB + prefetch 0 + parallel 0 | 134.5 ms/op, 32 ranges/op | 小块且无窗口预取时,RTT 成本最重 |
256 KiB + prefetch 2 + parallel 0 | 47.8 ms/op, 11 ranges/op | 仅靠窗口预取就能显著减少 Range 次数 |
256 KiB + prefetch 0 + parallel 4/ahead 8 | 43.3 ms/op, 32 ranges/op | 当必须维持小块且不想扩大单请求窗口时,parallel 可把耗时压到接近甚至略优于窗口预取 |
1 MiB + prefetch 0 + parallel 0 | 36.6 ms/op, 8 ranges/op | 增大块大小本身就能明显降低 RTT 放大 |
1 MiB + prefetch 2 + parallel 0 | 15.4 ms/op, 3 ranges/op | 该组是本轮纯顺序冷读 benchmark 的最快配置 |
1 MiB + prefetch 0 + parallel 4/ahead 8 | 19.4 ms/op, 8 ranges/op | 比无窗口串行更快,但仍慢于 1 MiB + prefetch 2 的窗口预取 |
从这组数据可以得到三条直接结论:
blockSize 和 prefetchBlocks,不要默认认为 parallel 一定更快。parallelStreams=4 在“小块、禁用窗口预取”的场景比 parallelStreams=2 更容易体现收益;两路并行更适合保守灰度,不适合追求极限吞吐。1 MiB + prefetch 2 仍是最稳的起点。同一台机器上,吾继续补跑了三组更接近真实业务的样例:
Seek 抖动:8 MiB 对象,每读取 256 KiB 左右就插入一次 SeekCurrent(+256 KiB)ReadAt:8 MiB 对象,顺序读过程中每两次 Read 插入一次远端 ReadAt4 个文件并发顺序读,每个文件 4 MiB| 场景 | 配置 | 结果 | 结论 |
|---|---|---|---|
Seek 抖动 | 1 MiB + prefetch 2 + parallel 0 | 35.5 ms/op, 8 ranges/op | 作为基线最稳 |
Seek 抖动 | 1 MiB + parallel 4/ahead 8 | 35.4 ms/op, 8 ranges/op | 与基线几乎持平,说明频繁 seek 会稀释 parallel 收益 |
Seek 抖动 | 256 KiB + parallel 4/ahead 8 | 67.1 ms/op, 16 ranges/op | 小块 + 抖动下请求数翻倍,明显更慢 |
混合 ReadAt | 1 MiB + prefetch 2 + parallel 0 | 21.6 ms/op, 4 ranges/op | 当前实现下表现最好 |
混合 ReadAt | 1 MiB + parallel 4/ahead 8 | 23.9 ms/op, 8 ranges/op | ReadAt 不主动触发并行预取,因此收益有限 |
混合 ReadAt | 256 KiB + parallel 4/ahead 8 | 77.7 ms/op, 32 ranges/op | 在随机扰动下,小块并行最容易放大 RTT 成本 |
| 多文件并发 | 1 MiB + prefetch 2 + parallel 0 | 12.7 ms/op, 8 ranges/op | 该组仍是本轮最佳 |
| 多文件并发 | 256 KiB + parallel 4/ahead 8 + total 8 | 40.7 ms/op, 64 ranges/op | 全局限流能兜底,但请求数仍然偏大 |
| 多文件并发 | 256 KiB + parallel 4/ahead 8 + total 32 | 28.2 ms/op, 64 ranges/op | 放宽总并发后吞吐更好,但仍慢于 1 MiB + prefetch 2 |
这些结果再补了一层判断:
Seek 很频繁时,parallel prefetch 更像“保底”,不是加速器。ReadAt 场景下,大块 + 窗口预取依旧最稳,因为 ReadAt 本身走独立窗口,不吃后台并行的直接红利。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-fetch | WithBlockSize(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(p) │ ├─ 计算当前偏移对应的 blockIndex ├─ 检查缓存:先查内存,再查磁盘;命中则直接返回 └─ 未命中: ├─ positionStreamLocked: 复用或新建 Range 流 │ ├─ 已有流且偏移在窗口内 + 小跳阈值内 → io.Discard 跳过 │ └─ 否则关闭旧流,发起新 Range 请求(含预取窗口) ├─ readBlockLocked: 从流中读取一个完整块 └─ 将块写入共享缓存
若开启并行预取且检测到顺序读取模式,后续块会由后台 worker 并发写入 cache;前台读取到这些块时会优先命中 cache 或等待同块 inflight 任务完成,而不会重复发起相同 Range。
ReadAt(p, off) │ ├─ 计算目标 blockIndex ├─ 检查缓存:先查内存,再查磁盘;命中则直接返回 └─ 未命中: └─ fetchWindowDirectLocked: 发起独立 Range 请求 ├─ 不复用当前顺序流 ├─ 读取整个窗口数据(含预取块) └─ 按 blockSize 切分后批量写入缓存
打开文件时通过 Stat 锁定对象元信息。后续每次 Range 响应都会校验:
任一校验失败立即返回 ErrObjectModified,防止将不同版本的数据块拼接到同一文件视图。
| 错误 | 含义 |
|---|---|
ErrClosed | 文件句柄已关闭 |
ErrInvalidSeek | Seek 目标非法(负偏移或非法 whence) |
ErrInvalidRange | 数据源拒绝给定的范围请求 |
ErrObjectNotFound | 目标对象不存在 |
ErrShortRange | 范围响应在期望长度前提前结束 |
ErrObjectModified | 对象在读取过程中发生变化 |
ReadAt 与顺序 Read/Seek 共用同一文件句柄状态,不建议多 goroutine 并发混用同一 FileHTTPSource 依赖 HEAD 与 GET,复杂鉴权需通过 RequestMutator 或自定义 RequestBuilder 注入