做音视频平台的工程师几乎都遇到过这类问题:一个热门视频上线后接口响应从50ms飙升到2秒,带宽费用一个月翻了几倍,数据库CPU被播放页请求打到90%以上。这些问题的根源往往不是计算能力不足,而是缓存体系没有设计好。音视频场景的数据量巨大,一个完整的视频文件可能是几百MB,即使是一条弹幕接口,QPS也可能轻松过万。单靠数据库或者单层缓存很难扛住,实践中通常需要CDN、Redis、本地缓存三层协同,各司其职。本文围绕音视频API的缓存策略,把三层缓存的分工、实现细节和常见坑逐一讲清楚。

一、CDN层:视频资源的边缘缓存设计
CDN是音视频场景最省钱也最有效的一层缓存。视频文件体量大、读取频次高,天然适合放在离用户最近的边缘节点。核心思路是:视频本体、封面图、转码后的分片文件走CDN,API只负责返回这些资源的URL和元数据,两者彻底解耦。
首先要处理的是缓存头。很多团队把视频直接扔到对象存储上就不管了,结果CDN回源率居高不下。正确的做法是为静态资源明确设置Cache-Control。下面是一个典型的Nginx配置示例:
location ~* \.(mp4|m3u8|ts|jpg|png)$ {
root /data/media;
# 浏览器和CDN共享缓存,边缘节点缓存7天
expires 7d;
add_header Cache-Control "public, max-age=604800";
}
# API接口不缓存,避免数据不一致
location /api/ {
add_header Cache-Control "no-store";
proxy_pass http://127.0.0.1:8080;
}
其次是鉴权问题。视频往往有版权或会员限制,不能裸奔在CDN上。常见方案是URL签名:服务端生成带过期时间戳和哈希的临时链接,CDN边缘节点校验签名后才放行。这样既享受了CDN加速,又保证了资源不被盗链。签名链接的有效期建议设置为1到2小时,太短会导致用户播放中途断流,太长则失去防盗意义。
还有一个容易被忽视的点是预热。大型活动或热门内容上线前,主动把资源推送到CDN节点,可以避免上线瞬间集中回源把源站打挂。大多数云厂商的CDN都提供预热API,配合内容发布流程自动化调用即可。命中率的监控也要跟上,一般CDN命中率低于85%就应该排查是URL参数污染(比如链接里带了会变化的query参数导致缓存key不匹配)还是缓存时间设置过短。
二、Redis层:接口元数据与热点数据缓存
CDN解决了大文件分发,但API本身返回的数据——视频标题、时长、点赞数、播放列表、推荐结果——还是需要应用层缓存。这类数据体积小、读写频繁、更新相对低频,是Redis的典型用武之地。
缓存设计的第一步是选好数据结构。以视频详情接口为例,推荐把整个响应体序列化后按视频ID存成一个String类型的key,而不是用多个Hash分别存标题、封面、作者。一次网络往返拿到全量数据,比多次HGET效率高得多。示例代码:
public VideoDetail getVideoDetail(long videoId) {
String key = "video:detail:" + videoId;
// 先查缓存
String cached = redis.get(key);
if (cached != null) {
return JSON.parseObject(cached, VideoDetail.class);
}
// 缓存未命中,查数据库并回填
VideoDetail detail = videoDao.queryById(videoId);
if (detail != null) {
// 过期时间加随机值,防止同一时刻大量key失效击穿数据库
int ttl = 3600 + ThreadLocalRandom.current().nextInt(600);
redis.setex(key, ttl, JSON.toJSONString(detail));
}
return detail;
}
注意代码里过期时间加随机偏移这一行,这不是可有可无的细节。如果所有视频详情key都是固定的1小时过期,某个热门合集的视频会在几乎同一时刻失效,下一次请求全部打到数据库,这就是典型的缓存雪崩。同理,对于极端热点的key(比如首页推荐池),可以用互斥锁或者逻辑过期方案防止缓存击穿:缓存失效时只放一个请求去重建数据,其他请求先返回旧值。
播放量、点赞数这类高频写数据要单独处理。如果每次点赞都直接写Redis再同步数据库,数据库压力依然不小。更好的做法是Redis计数器先累加,用定时任务每隔几秒批量刷回数据库;客户端展示时读Redis即可,允许秒级误差。弹幕、评论列表则适合用Redis的ZSet按时间戳排序,只缓存最新的几百条,更早的历史数据直接走数据库分页查询。
三、本地缓存:进程内的最后一道防线
当某些数据的访问频率高到连Redis的网络开销都成为瓶颈时,就需要本地缓存了。比如视频分类字典、转码模板配置、平台开关这类几乎不变的数据,每个请求都去Redis取一次是浪费。放进进程内存,读取耗时从毫秒级降到纳秒级,还不占用网络带宽。
Java生态里主流方案是Caffeine,它提供基于W-TinyLFU的淘汰算法,命中率优于传统的LRU。配置示例如下:
Cache<Long, VideoDetail> localCache = Caffeine.newBuilder()
.maximumSize(10_000) // 最多缓存1万条
.expireAfterWrite(5, TimeUnit.MINUTES) // 写入后5分钟过期
.recordStats() // 开启命中率统计
.build();
// 读取时走Cache-Aside模式
VideoDetail detail = localCache.get(videoId,
id -> loadFromRedisOrDb(id));
本地缓存的致命弱点是多实例一致性问题。应用部署了十个节点,每个节点的缓存内容各自独立,一旦数据变更,无法像Redis那样删一个key就全局生效。解决思路有两条:一是给本地缓存设置足够短的过期时间(比如5分钟内),接受短暂不一致;二是引入消息队列,数据变更时广播失效消息,各节点收到后主动驱逐对应的本地缓存。对于配置类数据,还可以结合版本号机制,本地缓存带上版本,定时比对Redis中的全局版本号,不一致就整体刷新。
另外要控制本地缓存的容量上限,警惕大对象。如果把一个包含几百条视频的推荐列表整体塞进本地缓存,单条几KB,一万个key就是几十MB,多实例部署时内存浪费非常可观。原则上本地缓存只放小而热的数据,比如详情页里嵌套的作者信息、标签信息这类高频复用的小对象。
四、三级缓存协同架构与落地建议
三层缓存不是各自为战,而是一个漏斗式的请求链路:请求先查本地缓存,未命中再查Redis,仍未命中才访问数据库,同时视频文件类请求直接由CDN承接,不进入应用层。数据更新时按相反顺序失效:先写数据库,再删Redis,再广播本地缓存失效。顺序千万不能反,先删缓存再写库会出现旧数据被并发请求回填的经典问题。
命中率是检验缓存体系唯一靠谱的标准。建议对每一层都建立监控:CDN看边缘命中率,Redis看keyspace_hits与keyspace_misses的比值,本地缓存看Caffeine的stats统计。一个健康的音视频API,理想状态下数据库QPS应该只占总请求量的1%以下,如果发现数据库压力依然高,先别急着加机器,用慢日志和缓存miss日志定位是哪一层失守了。
最后提醒几个实践中反复出现的坑:不要缓存包含用户态数据的响应(比如已点赞状态),否则会出现A用户看到B用户数据的严重bug,用户态数据必须在拼装响应时实时注入;热key要提前识别,某个视频突然上热搜时,单key的Redis访问可能达到每秒几十万次,这时需要做key分片,把一个key拆成N个子key随机读;灰度发布新版本接口时记得兼容旧缓存格式,序列化结构变更最好带上版本号,避免反序列化失败导致大面积报错。把这三层缓存的角色边界划清楚,再配合完善的命中率监控,音视频API的性能和成本问题就能得到系统性解决。