自建CDN听起来是个很酷的方案,但真正落地之后你会发现,节点之间的缓存命中率、回源带宽成本、大文件的分发效率,每一个环节都藏着坑。其中回源优化是投入产出比最高的一块,而回源优化里最值得深挖的,就是Range请求的处理和分片回源策略。这两件事做好了,大文件分发的带宽成本能明显下降,用户拖动视频进度条时的响应速度也会有质的提升。

一、Range请求的底层机制与CDN处理的常见坑
HTTP Range请求指的是客户端在请求头中携带Range字段,要求服务器只返回资源的某一段字节,比如Range: bytes=0-1023表示只要前1024个字节。服务器如果支持该请求,会返回206 Partial Content状态码,并在Content-Range头中说明本次返回的字节范围。如果服务器不支持Range,则返回200和完整内容。
这个机制本身不复杂,但在CDN场景下有几个容易踩的坑。第一个坑是缓存键的问题:如果边缘节点把带Range的请求和完整请求当成不同的缓存对象,缓存目录会被大量零散分片撑爆。正确做法是缓存完整文件或固定大小的分片,收到Range请求后从缓存中切片返回。第二个坑是多Range请求,比如Range: bytes=0-99,200-299,这种请求的响应是multipart格式,很多自建系统为了省事直接回200给完整文件,虽然协议上勉强说得过去,但会造成带宽浪费,正确的做法是降级成多个单Range处理或者干脆告知客户端不支持。
还有一个隐蔽的坑是Accept-Ranges头的处理。有些源站在响应中没有声明Accept-Ranges: bytes,CDN节点就直接判定源站不支持Range,导致大文件回源时只能整文件拉取。实际上可以先对源站做一次探测,对常见的对象存储源站(大部分都支持Range),在节点侧强制启用分片回源。
二、Nginx处理Range请求的配置实践
如果用Nginx做CDN节点的核心组件,那么slice模块是处理Range请求的关键。它的原理是把大文件切成固定大小的分片,每个分片独立缓存和回源,收到用户的Range请求后,Nginx会计算出该Range覆盖了哪些分片,只回源缺失的分片,再拼接成206响应返回。这样即使缓存里只有文件的一部分,也能直接服务用户,不必等整个文件回源完成。
一个典型配置如下:
location /video/ {
# 开启分片,每个分片1MB
slice 1m;
# 回源时带上Range头,按分片拉取
proxy_set_header Range $slice_range;
proxy_http_version 1.1;
# 响应头透传206状态码
proxy_set_header Accept-Encoding "";
# 缓存key包含分片范围,保证不同分片独立缓存
proxy_cache_key $uri$slice_range;
proxy_cache cdn_cache;
proxy_cache_valid 200 206 12h;
proxy_cache_lock on;
}
配置中有几个细节值得注意。proxy_cache_key里必须包含$slice_range,否则所有分片会互相覆盖缓存。proxy_cache_lock开启后,同一个分片在被回源填充期间,后续请求会等待而不是重复回源,这在突发流量场景下能显著降低源站压力。分片大小的选择也有讲究:分片太小会导致缓存元数据膨胀、小文件请求要拼接大量分片;分片太大则粒度不够细,用户只要一小段数据也可能触发大块回源。一般视频类业务用1MB到4MB比较合适,安装包分发可以适当放大到8MB。
另外要提醒一点,开启slice后,响应给客户端的Content-Length语义会变化,部分客户端如果对206响应头校验严格,需要确认回源链路上没有中间层把206改成200。可以在节点上显式配置proxy_force_ranges on;来确保Range语义在整条链路上生效。
三、分片回源策略的设计与并发拉取
Nginx的slice模块解决了边缘缓存的问题,但如果你是自研回源逻辑,分片回源策略需要自己设计。核心思路是:把一个大文件按固定大小切分,回源时多个分片并发拉取,拉到的分片可以直接推给等待中的用户请求,同时写入本地缓存。这样首字节时间不再是下载整个文件的时间,而是第一个分片下载完成的时间。
伪代码示例如下:
// 计算请求范围覆盖的所有分片
func planSegments(fileSize int64, start, end int64, segSize int64) []Segment {
var segs []Segment
first := start / segSize
last := end / segSize
for i := first; i <= last; i++ {
segStart := i * segSize
segEnd := min(segStart+segSize-1, fileSize-1)
segs = append(segs, Segment{Index: i, Start: segStart, End: segEnd})
}
return segs
}
// 本地缓存未命中的分片进入并发回源队列,限制并发数
func fetchSegments(segs []Segment, concurrency int) {
sem := make(chan struct{}, concurrency)
for _, s := range segs {
sem <- struct{}{}
go func(seg Segment) {
defer func() { <-sem }()
data := originFetchRange(seg.Start, seg.End)
cacheStore(seg, data) // 写入本地分片缓存
pushToWaiting(seg, data) // 推送给等待分片的请求
}(s)
}
}
并发数不是越大越好。源站的连接数上限、回源带宽、TCP拥塞控制都会制约实际效果。经验值是单节点对同一源站的并发回源连接控制在8到16个,配合分片大小一起调优。如果回源走的是公网,还要考虑运营商之间的链路质量,可以在分片请求上加上重试和超时控制,单个分片失败只重试该分片,不影响其他已经成功的部分,这正是分片回源相对整文件回源最大的容错优势。
四、缓存分片的复用与命中率提升
分片回源的价值要在缓存层充分体现出来。缓存分片的好处是细粒度复用:一个2GB的文件,用户A看了前10分钟,用户B从第5分钟开始看,两人请求的范围有重叠,重叠部分的分片直接命中缓存,不需要二次回源。如果缓存的是整个文件,这种场景下的复用就很难做到。
提升分片命中率还有两个实用技巧。一是热点分片预取:通过日志分析统计用户请求最集中的字节区间,比如视频网站的开头几分钟几乎人人都要拉,可以后台定时任务把这些热点分片提前回源填充到各节点。二是缓存淘汰策略按分片热度执行,LRU淘汰时优先清理冷门分片,让热门分片长期驻留。要注意分片缓存需要额外记录文件级元数据,比如文件总长度、ETag、Last-Modified,分片命中前先校验这些元数据,源站文件更新后旧的分片必须作废,否则会出现用户拉到新旧混杂数据的严重问题,这是分片缓存方案里最不能出的错。
综合来看,Range请求处理和分片回源是自建CDN回源优化的一体两面:前者解决用户侧按需取数据的问题,后者解决回源侧按需拉数据的问题,两头都按需了,带宽成本和响应速度自然就优化了。落地时建议先用Nginx的slice模块快速验证效果,再根据业务特点逐步演进到自研的分片回源调度,每一步都用回源带宽、首字节时间、缓存命中率这三个指标去衡量收益。