音乐播放功能的实现:约束驱动的缓存分层与限流
这个网站的音乐播放,服务端不存音频、不解码、不转码,也不碰任何音频字节——它只做一件事: 向第三方音乐接口要一个播放地址,然后交给浏览器去播。真正的工作量在于绕不开的三条约束: 接口限流 60 次 / 5 分钟、JSON 接口没有 CORS 头、播放地址是带时间戳的短时签名。 本文记录在这三条约束下做的工程取舍:两级缓存怎么分层、为什么要自己给自己限流、 签名地址过期后播放器怎么恢复、浏览器拖动进度条为什么能生效,以及几个在验证阶段才暴露的错误。
👤 站长:lankHub 博客站长
目录
一、问题:三条绕不开的约束
这个功能依赖第三方免费接口 music-api.gdstudio.xyz,它提供搜索、取播放地址、取封面、取歌词四类查询。
实测下来有三条硬约束,整个架构都是被它们逼出来的。
第一条:限流 60 次 / 5 分钟。而播一首歌要用掉什么?一次取播放地址。如果封面和歌词也在渲染时现取,一个访客点十首歌就是 30 次调用,两个访客就能把配额打光。
第二条:JSON 接口没有 Access-Control-Allow-Origin。 浏览器直接 fetch 会被 CORS 拦下,所以 JSON 必须走自己的服务器代理。但音频文件不受同源策略约束——<audio src> 是浏览器的原生请求,不受 CORS 限制。
第三条:播放地址是短时签名的。地址形如
https://m701.music.126.net/20261009220919/<签名>/jdymusic/obj/.../xxx.mp3
路径里那段时间戳每次请求都不同。它不能存进数据库,否则很快就失效。
这三条决定了整个设计:什么时候必须调接口、什么可以永久缓存、什么只能活几分钟。
二、原理与数据流:JSON 走自己,音频直连 CDN
浏览器点击播放
→ GET /api/music/play?id=<歌曲id> (本站接口,带访客限流)
→ 查库取出该歌的 apiId
→ 查内存缓存:命中则直接返回
→ 未命中:向第三方请求 types=url,拿短时签名地址
→ 地址写入内存缓存,TTL 5 分钟
→ 浏览器拿着这个地址,直接向网易云 CDN 取音频流(不经过本站服务器)这个分叉是有意的:JSON 走代理,音频直连。如果音频也由服务器中转,320 kbps 的码率意味着每分钟约 2.4 MB 的流量,全部落在服务器上;直连 CDN 则把这部分成本转移给了浏览器与对方的边缘节点。
代价是 CSP 必须放行外部域名:
img-src 'self' data: blob: https://*.music.126.net
media-src 'self' https://*.music.126.net
connect-src 'self' ← JSON 仍走自己的代理,所以这一项保持收紧一个通配符同时覆盖音频(m701/m801)与封面(p1/p2)。
三、方案:两级缓存,按「能不能永久保存」分层
关键在于区分哪些 ID 不会过期、哪些地址会过期。
Song 表里存的是歌曲 ID(apiId),不是地址。ID 是稳定的,所以它可以永久入库。
而地址是签名的,只能活在内存里。于是分成两层:
永久层(数据库):封面地址 coverUrl 与歌词 lyric 在绑定那一刻就抓取入库。
前台渲染卡片墙与歌词因此是零次接口调用——这一步直接把 60 次配额的压力削掉大半。
短层(进程内存):播放地址只进一个 Map,TTL 5 分钟,上限 500 条,超限丢最早插入的一条。
const PLAY_URL_TTL_MS = 5 60 1000
const PLAY_URL_CACHE_MAX = 500
const playUrlCache = new Map<string, { url: string; expiresAt: number }>()
export async function getPlayUrl(apiId: string, options: { force?: boolean } = {}) {
const cached = playUrlCache.get(apiId)
// force 用于「播到一半地址失效」的场景:跳过缓存重取,
// 否则重试拿回来的还是那条已经坏掉的地址
if (!options.force && cached && cached.expiresAt > Date.now()) {
return { ok: true, data: cached.url }
}
const result = await callApi({ types: "url", id: apiId, br: "320" })
if (!result.ok) return result
const url = (result.data as { url?: unknown })?.url
// 空字符串也是失败:某些音源会返回 {"url":"","br":-1},
// 不判空的话前端会拿到一个空 src,静默播不出声音,极难排查
if (typeof url !== "string" || url.length === 0) {
return { ok: false, error: "这首歌暂时拿不到播放地址" }
}
if (playUrlCache.size >= PLAY_URL_CACHE_MAX) {
const oldest = playUrlCache.keys().next().value
if (oldest !== undefined) playUrlCache.delete(oldest)
}
playUrlCache.set(apiId, { url, expiresAt: Date.now() + PLAY_URL_TTL_MS })
return { ok: true, data: url }
}四、方案:给自己加一道限额,比撞上去更划算
既然官方是 60 次 / 5 分钟,我在自己这边设了一道**更紧**的闸门:**最多 45 次**,剩下 15 次留给访客的实际播放请求。用满之后明确拒绝并提示稍后再试,而不是硬撞官方限流。
const RATE_WINDOW_MS = 5 60 1000
const RATE_BUDGET = 45
const recentCalls: number[] = []
function pruneRecentCalls(now: number) {
while (recentCalls.length > 0 && now - recentCalls[0] > RATE_WINDOW_MS) {
recentCalls.shift()
}
}这是最朴素的滑动窗口:一个时间戳数组,每次调用前先剔除窗口外的记录,再看长度是否触顶。
它同时被两处使用——一处是真正的拦截,另一处是把「剩余次数」回报给后台界面,让批量任务知道这一批能处理几首。
另外两条细节:两次调用之间强制 400ms 最小间隔(实测连续快速搜索会拿到 503),以及对 503 与超时各重试一次,其余情况不重试——因为重试是要花配额的,不能无脑重试。
五、坑:签名地址会过期,浏览器不会自己好
这是唯一一个只能在真实使用中才暴露的问题。播放地址有效期有限(URL 里的时间戳可以推算),如果用户暂停很久再继续,浏览器会去请求一个已经过期的地址,然后直接失败——<audio> 不会自动重取。
做法是在 onError 里做一次自动恢复:跳过服务端缓存重取地址,并从断点续播。
const retriedSongId = useRef<number | null>(null)
const retrying = useRef(false)
const retryCurrent = useCallback(() => {
if (!current || retrying.current) return
if (retriedSongId.current === current.id) return // 每首只自动重试一次
retriedSongId.current = current.id
retrying.current = true
const resumeAt = audioRef.current?.currentTime ?? 0 // 断点
void start(current, { force: true, resumeAt }).finally(() => {
retrying.current = false
})
}, [current, start])三个必须处理的细节:
1. 只重试一次。地址过期可以重取救回来,但网络不通、源站没有版权这两种情况重取也没用, 无限重试会变成死循环。
2. 续播要在拿到新地址之后设置。audio.src 一换,currentTime 就归零了, 所以要在 loadedmetadata 事件里再 seek。
3. 屏蔽中间态的 error 事件。重试过程中会换 src,中间可能再抛一次 onError, 用 retrying ref 挡掉它,否则界面会被一个已经过时的错误覆盖。
六、Range 请求:进度条为什么能拖
<audio> 的进度条能拖动,前提是服务端支持**范围请求**。实测对播放地址发不同形式的 Range:
| 请求 | 状态码 | 响应 |
| --- | --- | --- |
| 无 Range | 200 | Accept-Ranges: bytes,Content-Length: 10452158 |
| Range: bytes=0-0 | 206 | Content-Range: bytes 0-0/10452158 |
| Range: bytes=1000000-1001023 | 206 | 返回恰好 1024 字节 |
| Range: bytes=-1024 | 206 | bytes 10451134-10452157/10452158(支持后缀范围) |顺手可以确认几件事:文件头是 49 44 33 04,即 ASCII 的 ID3——确实是 MP3 容器;
一个中段切片的响应头里有 content-disposition: inline; filename="obj/.../xxx.mp3",说明它取的是同一个文件的片段,而不是把整个文件返回。
这里有一个我自己犯过的判定错误值得记下来。我此前一直用「响应头里有 Accept-Ranges: bytes」来判断是否支持范围请求——但复测发现,这个头只出现在无 Range 的 200 响应里,206 响应里没有。
所以正确的判据是状态码 206 加 Content-Range,而不是 Accept-Ranges。
另外记一个与音频无关、但同样值得记住的坑:这个 CDN 的 Cache-Control 是 max-age=31536000,而签名 URL 每次不同——看上去像是每次回源。但对比响应头里的真实对象路径可以发现,签名路径与真实对象路径是分开的两段:签名段每次变,obj/... 那段不变。
也就是说签名只是授权层,真实对象是同一个,边缘缓存依然有效。
七、坑:接口的相关度排序不可信
「哪首歌是原版」这个问题,接口的返回顺序靠不住。实测搜「大鱼 周深」,可信的原版排在候选第 8 位,前面 7 条全是翻唱。如果按顺序取第一条同名结果,就会绑到翻唱上。
所以匹配不是「取第一条」,而是本地重排:在所有候选里找第一条歌名精确相同、且歌手可信的。歌名只剥掉《》这类外围装饰,内部括号一律保留——因为「稻香(治愈版)」这种内部括号恰恰是翻唱标记。歌手必须是精确相等,或者「歌手名后面直接跟括号」(合法别名,如「冯沁苑(买辣椒也用券)」);「周杰伦.」「HF薛之谦」这类冒充原唱的写法一律不算。
结论分三级:high(有可信候选)、low(歌名对得上但歌手全对不上,多半是翻唱)、none(连歌名都没收录)。只有 high 才自动绑定,其余一律留给人判断。
同一个坑的另一面:SQL 的三值逻辑
「跳过已经判定无原版的歌」看起来是一句很自然的过滤:
where: { apiId: null, matchStatus: { not: "nomatch" } } // ✗ 错这条是错的,而且错得很隐蔽。在 SQL 里 NULL <> 'nomatch' 的结果是 NULL 而不是 true,而「从来没搜过」的歌 matchStatus 正是 NULL——于是所有没搜过的新歌会被一起过滤掉,自动匹配一首也匹配不到。正确写法是显式地把两种情况并列:
{ OR: [{ matchStatus: null }, { matchStatus: { not: "nomatch" } }] }八、原理与坑:歌词滚动与二分查找
歌词是 LRC 格式的纯文本,解析有两个容易漏的点:一行可能有多个时间标签(副歌复用同一句时会写成 [00:12.00][01:30.00]同一句词),要展开成多条时间点;毫秒位数不定(.5 / .50 / .500),统一按千分位补齐后再排序。
播放时要把「当前秒数」映射到「第几行」。因为歌词行是按时间有序的,这里用的是二分查找而不是线性扫描——滚动歌词时会高频触发这个查询。
九、坑:React 的渲染边界
播放进度每秒会触发好几次 timeupdate。如果把它放进 React Context,整面歌手卡片墙都会跟着重渲染。
所以进度不进 Context,播放条直接读 audio 元素的 ref,只有播放条自己重渲染:
// Context 里只放「哪首歌在播」这类低频状态;
// 时间/时长属于高频状态,留在播放条内部,直接操作 audio 元素的 ref
const [progress, setProgress] = useState({ songId: -1, time: 0, duration: 0 })进度状态里特意带上 songId,用它来推导「换歌时归零」的显示,而不是在 effect 里 setState 重置——后者会触发级联渲染,也会被 lint 拦下。
十、结论:真正的工作量在「不做什么」
回头看,这个功能里没有一行代码在处理音频:服务端不存、不转、不解码,只负责拿到一个地址。
真正的工作量集中在三个地方:
1. 分清什么能永久保存、什么只能活几分钟——歌曲 ID 入库,签名地址进内存,封面歌词在绑定时抓一次。
2. 在别人的配额里留出自己的余量——自我限额 45 次而不是 60 次,加最小调用间隔,只在必要时重试。
3. 把失败当成正常路径来处理——地址会过期、接口会 503、源站可能压根没有版权, 这些都不是异常,而是设计的一部分。
十一、验证与几个判定错误
功能上线后运行正常,但在开发过程中我有三处自己犯的判定错误,都是靠实测才发现的:
1. 把封面地址当作可以拼出来的。我一度按 p2.music.126.net/{pic_id}.jpg 拼封面地址, 并且用「这个字符串出现在页面里」作为断言——测试通过了,但那个地址实测是 404。
真实地址里有一段无法由 pic_id 推导的加密哈希,只能由接口返回。
教训是断言必须用真实数据,而不是自己拼出来的数据。
2. 断言了一个需要交互才存在的元素。前台卡片墙在服务端渲染时只有折叠状态的歌手卡片,歌曲列表要点击才展开,所以行内的标记在首屏 HTML 里根本不存在。
这类断言应该改成检查数据层(组件拿到的属性),而不是首屏 DOM。
3. 拿 Accept-Ranges 判断范围请求支持,见第六节。
另外还有一条方法论上的教训:一次采样不能代表整体。
我最初只用一首歌验证了接口「可用」,就以为整个库都能播——结果那个平台上,某些歌手的作品根本没有版权,搜出来的全是翻唱。验证第三方能力必须抽样多个样本,并明确边界。
评论 (0)
暂无评论,来发表第一条评论吧