lankHub
首页博客合集简历
首页博客合集简历
首页博客简历免责声明

© 2026 lankHub · Made with· 记录成长,分享生活

湘ICP备2026026981号-1

返回
技术开发

音乐播放功能的实现:约束驱动的缓存分层与限流

站长2026-10-09·22 分钟阅读·3 次阅读

这个网站的音乐播放,服务端不存音频、不解码、不转码,也不碰任何音频字节——它只做一件事: 向第三方音乐接口要一个播放地址,然后交给浏览器去播。真正的工作量在于绕不开的三条约束: 接口限流 60 次 / 5 分钟、JSON 接口没有 CORS 头、播放地址是带时间戳的短时签名。 本文记录在这三条约束下做的工程取舍:两级缓存怎么分层、为什么要自己给自己限流、 签名地址过期后播放器怎么恢复、浏览器拖动进度条为什么能生效,以及几个在验证阶段才暴露的错误。

👤 站长:lankHub 博客站长

目录

目录

  • 一、问题:三条绕不开的约束
  • 二、原理与数据流:JSON 走自己,音频直连 CDN
  • 三、方案:两级缓存,按「能不能永久保存」分层
  • 四、方案:给自己加一道限额,比撞上去更划算
  • 五、坑:签名地址会过期,浏览器不会自己好
  • 六、Range 请求:进度条为什么能拖
  • 七、坑:接口的相关度排序不可信
  • 同一个坑的另一面:SQL 的三值逻辑
  • 八、原理与坑:歌词滚动与二分查找
  • 九、坑:React 的渲染边界
  • 十、结论:真正的工作量在「不做什么」
  • 十一、验证与几个判定错误

一、问题:三条绕不开的约束

这个功能依赖第三方免费接口 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

markdown
浏览器点击播放
  → GET /api/music/play?id=<歌曲id>      (本站接口,带访客限流)
      → 查库取出该歌的 apiId
      → 查内存缓存:命中则直接返回
      → 未命中:向第三方请求 types=url,拿短时签名地址
      → 地址写入内存缓存,TTL 5 分钟
  → 浏览器拿着这个地址,直接向网易云 CDN 取音频流(不经过本站服务器)

这个分叉是有意的:JSON 走代理,音频直连。如果音频也由服务器中转,320 kbps 的码率意味着每分钟约 2.4 MB 的流量,全部落在服务器上;直连 CDN 则把这部分成本转移给了浏览器与对方的边缘节点。

代价是 CSP 必须放行外部域名:

markdown
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 条,超限丢最早插入的一条。

typescript
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 次留给访客的实际播放请求。用满之后明确拒绝并提示稍后再试,而不是硬撞官方限流。

typescript
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 里做一次自动恢复:跳过服务端缓存重取地址,并从断点续播。

typescript
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:

markdown
| 请求 | 状态码 | 响应 |
| --- | --- | --- |
| 无 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 的三值逻辑

「跳过已经判定无原版的歌」看起来是一句很自然的过滤:

typescript
where: { apiId: null, matchStatus: { not: "nomatch" } }   // ✗ 错

这条是错的,而且错得很隐蔽。在 SQL 里 NULL <> 'nomatch' 的结果是 NULL 而不是 true,而「从来没搜过」的歌 matchStatus 正是 NULL——于是所有没搜过的新歌会被一起过滤掉,自动匹配一首也匹配不到。正确写法是显式地把两种情况并列:

typescript
{ OR: [{ matchStatus: null }, { matchStatus: { not: "nomatch" } }] }

八、原理与坑:歌词滚动与二分查找

歌词是 LRC 格式的纯文本,解析有两个容易漏的点:一行可能有多个时间标签(副歌复用同一句时会写成 [00:12.00][01:30.00]同一句词),要展开成多条时间点;毫秒位数不定(.5 / .50 / .500),统一按千分位补齐后再排序。

播放时要把「当前秒数」映射到「第几行」。因为歌词行是按时间有序的,这里用的是二分查找而不是线性扫描——滚动歌词时会高频触发这个查询。

九、坑:React 的渲染边界

播放进度每秒会触发好几次 timeupdate。如果把它放进 React Context,整面歌手卡片墙都会跟着重渲染。

所以进度不进 Context,播放条直接读 audio 元素的 ref,只有播放条自己重渲染:

typescript
// 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)

请先 登录 后发表评论

暂无评论,来发表第一条评论吧

目录

  • 一、问题:三条绕不开的约束
  • 二、原理与数据流:JSON 走自己,音频直连 CDN
  • 三、方案:两级缓存,按「能不能永久保存」分层
  • 四、方案:给自己加一道限额,比撞上去更划算
  • 五、坑:签名地址会过期,浏览器不会自己好
  • 六、Range 请求:进度条为什么能拖
  • 七、坑:接口的相关度排序不可信
  • 同一个坑的另一面:SQL 的三值逻辑
  • 八、原理与坑:歌词滚动与二分查找
  • 九、坑:React 的渲染边界
  • 十、结论:真正的工作量在「不做什么」
  • 十一、验证与几个判定错误