☰
在线云音乐系统实战:Range流式播放、转码与播放器状态机
2026/10/27 16:36:38 网站建设 项目流程

1. 把“云音乐”这三个字拆开看:真正的难点不在增删改查

很多人看到“在线云音乐系统”这个题目,第一反应是又一个练习增删改查的项目:用户注册登录、上传歌曲、列表展示、点击播放,四个接口加几个页面就能交付。我最早也是这么想的,结果真正动手做到第三天就卡住了,因为进度条一拖就黑屏、封面一会上有一会没有、切歌切到一半声音还在继续。这些问题跟数据库设计毫无关系,全部出在“字节流”和“播放状态”这两件事上。

所以我把这个项目的核心定义成一句话:它是一个把音频字节高效、稳定、可定位地送到浏览器,并让播放器在任何异常下都能恢复到正确状态的内容分发小系统。增删改查只是外壳,真正吃时间的是流式传输、音频元数据解析、播放器状态机这三块。搞明白这三块,你写出来的东西才敢给别人用;搞不明白,做出来的就只是个能播放的列表页。

这篇文章面向两类读者:一类是刚学完后端框架,想找个有分量的项目练手的人;另一类是想在自己站内挂一套音频点播能力的人。我会按照我自己实际搭建的顺序来讲:先定选型和数据模型,再搞定音频流,然后是上传转码流水线,接着是播放器状态机,最后是检索、歌单、部署与压测。每一步我都会说清楚为什么这么选,以及我在实测里踩到了什么。

阅读建议:如果你只想先跑起来看到效果,可以只看第 3 章的 Range 请求部分和第 5 章的播放队列实现,这两块是“能不能用”的分水岭;数据模型和转码流水线决定的是“能不能长大”。

还有一点必须先说清楚:这个系统涉及音频文件的存储与分发,你自己练手用本地文件没问题,但如果要挂在公开网络上,请务必确认你手上的音频素材是可合法分发的内容,比如自己录的、明确授权的、或公版素材。这是个技术之外但绕不过去的前提,别等到流量起来才想起来补这一课。

2. 选型与数据模型:为什么我最后选了单体架构加分层

2.1 一上来就拆微服务,是我见过最多人犯的错误

我见过不少人做这类项目,第一步就把“歌曲服务”“用户服务”“文件服务”拆成三个独立进程,中间还加了个注册中心。结果是本地调试要开五个终端,一行日志要在三个窗口里翻,项目做到一半人先崩溃了。对于一个日活几十到几千的在线音乐系统来说,单体架构加清晰分层是性价比最高的方案,没有之一。

我的分层是这样的:controller只做参数校验和响应包装,service承载业务编排,repository/mapper只碰数据库,storage这一层单独抽出来封装文件的读写(后面换成对象存储时只改这一层),media层专门处理音频解析和转码调用。这个分层的关键点在于把“文件”和“数据”彻底切开:数据库里只存路径和元信息,音频本体永远走存储层。这样做的好处是,将来你从本地磁盘换到对象存储,业务代码几乎不用动。

为什么不拆微服务?因为拆分的收益来自“不同模块的伸缩需求差异巨大”,而音乐系统里最吃资源的是流式传输和转码,这两个恰好都可以通过“静态资源交给 Nginx、转码做成异步进程”来解决,不需要引入分布式那一整套复杂度。你得先有被压垮的经验,才知道该往哪拆;没被压过就拆,纯粹是给自己加戏。

2.2 音频文件放本地磁盘还是对象存储:一张表说清取舍

这是我在动手前纠结最久的决策,最后我两种都实现了一遍,把差异整理成了下面这张表。注意这里的对比前提是“单机或少量节点”的规模,跨度太大的场景要另算。

维度本地磁盘对象存储
实现成本低,一个路径拼接就完事中,要处理签名、分片上传、回调
容量弹性差,受限于单机磁盘好,理论上无上限
读取性能极好,几乎无网络开销好,但多一跳网络
Range 支持Nginx 自带,零配置需要确认服务端支持,一般默认支持
备份迁移麻烦,得自己写脚本相对简单,同步工具较多
成本磁盘一次性投入按量计费,流量费要留意
适合阶段练手、内网、小规模公网、有增长预期

我的实际做法是:开发阶段用本地磁盘,上线前把存储层抽象成接口,生产环境切对象存储。抽象接口只需要五个方法——put、getStream、getRangeStream、delete、exists,本地实现和对象存储实现各写一份。这样你在本地调试时享受零延迟,上线后享受弹性,两边的好处都不用放弃。这里特别提醒一句:getRangeStream一定要单独定义,不能只写一个getStream然后在业务层自己截取,否则大文件会把内存吃干净。

2.3 六张表撑起整个业务:字段和索引的实际写法

数据模型这块,我最初的版本有十几张表,做到后面砍到六张,反而更清晰。核心是:用户、歌曲、专辑、歌手、歌单、歌单歌曲关联。歌词和封面我都用字段挂在歌曲表上,因为它们跟歌曲是一对一且总是一起读的,拆表只会增加一次 join。

歌曲表是整个系统的核心,我把关键字段列一下,重点看我标注的索引:

CREATE TABLE song ( id BIGINT PRIMARY KEY AUTO_INCREMENT, title VARCHAR(200) NOT NULL, artist_id BIGINT NULL, album_id BIGINT NULL, duration_ms INT NOT NULL DEFAULT 0, file_key VARCHAR(255) NOT NULL COMMENT '存储层路径,不含域名', file_size BIGINT NOT NULL DEFAULT 0, file_hash CHAR(64) NOT NULL COMMENT 'sha256,用于去重', bitrate INT NULL, format VARCHAR(10) NOT NULL DEFAULT 'mp3', cover_key VARCHAR(255) NULL, lyric_text MEDIUMTEXT NULL, play_count BIGINT NOT NULL DEFAULT 0, status TINYINT NOT NULL DEFAULT 0 COMMENT '0待审核 1正常 2下架', created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, UNIQUE KEY uk_hash (file_hash), KEY idx_status_created (status, created_at), KEY idx_title (title) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;

这里有三个设计点值得展开。第一,file_hash加了唯一索引,上传时先算哈希再查库,能直接干掉重复文件,这个在真实使用中命中率很高,因为同一个音频被不同人上传的情况远比想象中普遍。第二,file_key存的是相对路径而不是完整 URL,因为将来换域名或加 CDN 时,完整 URL 存库会让你想哭。第三,duration_ms用毫秒存整数,不用字符串,因为播放器进度条和歌词同步都要拿它算偏移。

歌单歌曲关联表看起来简单,但有个坑:手动排序。我一开始用的是sort_order INT,每次拖动都全表重排,二十首歌还行,两百首就开始卡。后来改成留空隙的整数(每次 +100),只在空隙耗尽时整体重排一次,这个策略下面第 6 章还会细说。

CREATE TABLE playlist_song ( id BIGINT PRIMARY KEY AUTO_INCREMENT, playlist_id BIGINT NOT NULL, song_id BIGINT NOT NULL, sort_order INT NOT NULL DEFAULT 0, added_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, UNIQUE KEY uk_pl_song (playlist_id, song_id), KEY idx_pl_sort (playlist_id, sort_order) );

uk_pl_song这个唯一键很关键,它保证同一首歌不会在同一个歌单里出现两次。前端加歌时如果收到唯一键冲突,直接返回“已在歌单中”而不是报错,用户体验会好很多。

3. 音频流式播放:Range 请求才是这套系统的地基

3.1 浏览器点下播放键的那一刻,到底发生了什么

绝大多数人写播放功能时,以为流程是“后端返回整个文件,浏览器拿到后开始播”。实际完全不是。浏览器内置的音频解码器非常聪明,它发起请求时会带一个Range: bytes=0-的头,意思是“先给我前面一段”。服务端如果返回206 Partial Content并带上Content-Range头,浏览器就知道“哦,这个资源支持分段”,于是它会按需请求后续字节,同时用户拖动进度条时只需要请求对应位置的片段。

反过来,如果服务端返回的是200 OK加完整文件,会发生什么?浏览器会退化成“全部下载完再播”,进度条拖不动,或者拖了之后音频直接断开。这就是我在第 1 章说的“一拖就黑屏”的根因。Range 支持不是优化项,而是可播放的前提。

关键响应头就三个:Accept-Ranges: bytes告诉浏览器支持分段,Content-Range: bytes 0-1023/1048576告诉它这一段的范围和总长度,状态码必须是 206。少一个,行为就可能不一致。

3.2 静态托管与后端转发:两条路选哪条

有两条路能实现流式播放,我两条都走过,结论很明确。

第一条是把音频文件交给 Nginx 直接服务。Nginx 对静态文件的 Range 支持是内建的,你只要把目录挂出去,基本零配置就能支持拖动。这条路的好处是性能极好、内存占用极低,Nginx 用 sendfile 直接走内核态,不经过应用进程。缺点是没有权限控制——谁猜到路径谁就能下。

第二条是后端转发字节流。由应用进程打开文件,按 Range 切片读出来写进响应。好处是能在读取前做权限校验、播放计数、防盗链;缺点是占用应用线程和内存,并发一高就顶不住。

我的方案是“Nginx 做主力,后端只发临时票据”:用户点播放时,后端校验权限并生成一个带过期时间的签名,前端拿签名去请求 Nginx 的受保护路径(用secure_link模块校验)。这样既保住了 Nginx 的性能,又有了权限控制。练手阶段可以先只做后端转发,把逻辑跑通再说,等压测发现瓶颈了再换。

后端转发如果用 Java 生态,注意不要用一次性读全文件的写法,要用框架提供的分段资源类型:

@GetMapping("/stream/{id}") public ResponseEntity<ResourceRegion> stream(@PathVariable Long id, @RequestHeader HttpHeaders headers) { Song song = songService.getPlayable(id); Path path = storage.resolve(song.getFileKey()); long len = Files.size(path); ResourceRegion region = headers.getRange().stream().findFirst() .map(r -> { long start = r.getRangeStart(len); long end = Math.min(r.getRangeEnd(len), len - 1); long count = end - start + 1; return new ResourceRegion(new FileSystemResource(path), start, count); }) .orElseGet(() -> new ResourceRegion( new FileSystemResource(path), 0, Math.min(len, 2 * 1024 * 1024))); return ResponseEntity.status(HttpStatus.PARTIAL_CONTENT) .contentType(MediaType.parseMediaType("audio/mpeg")) .header(HttpHeaders.ACCEPT_RANGES, "bytes") .body(region); }

Node.js 那边更直白,用流加start、end选项就行:

app.get('/stream/:id', async (req, res) => { const meta = await songService.getPlayable(req.params.id); const filePath = storage.resolve(meta.fileKey); const stat = await fs.promises.stat(filePath); const total = stat.size; const range = req.headers.range; if (!range) { res.writeHead(200, { 'Content-Length': total, 'Accept-Ranges': 'bytes', 'Content-Type': 'audio/mpeg' }); return fs.createReadStream(filePath).pipe(res); } const match = /bytes=(\d*)-(\d*)/.exec(range); let start = match[1] ? parseInt(match[1], 10) : 0; let end = match[2] ? parseInt(match[2], 10) : total - 1; if (isNaN(start) || isNaN(end) || start > end || start >= total) { res.writeHead(416, { 'Content-Range': `bytes */${total}` }); return res.end(); } end = Math.min(end, total - 1); res.writeHead(206, { 'Content-Range': `bytes ${start}-${end}/${total}`, 'Accept-Ranges': 'bytes', 'Content-Length': end - start + 1, 'Content-Type': 'audio/mpeg' }); fs.createReadStream(filePath, { start, end }).pipe(res); });

注意 416 那个分支别省,当浏览器请求了一个越界的范围时,正确返回 416 比悄悄返回 200 更利于播放器做错误恢复。

3.3 拖拽进度条、静音、后台播放:三个我真实踩到的坑

第一个坑:拖拽后出现静默空白。我一开始返回 206 但忘了带Content-Length,浏览器无法确定这一段到底多长,于是拖到某个位置后既不报错也不出声。加上Content-Length之后立刻正常。这个坑排查了很久,因为控制台一点错都没有。

第二个坑:某些浏览器首次加载不出声。部分移动端浏览器对自动播放有严格策略,audio.play()返回的 Promise 会被拒绝。正确姿势是必须由用户手势触发,并且要捕获 rejection:

async function safePlay(audio) { try { await audio.play(); } catch (err) { // 自动播放被拦截,切换为"点击播放"提示态 playerStore.setState({ needUserGesture: true }); } }

第三个坑:切到后台就断。移动端切后台一段时间后音频会被系统回收,回来时currentTime已经错乱。我的处理是监听visibilitychange,页面重新可见时比对一次实际currentTime和记录的期望值,偏差超过 2 秒就 seek 回去。

一个很实用的小设置:响应头加上Cache-Control: public, max-age=86400并让 URL 里带上文件哈希(如/audio/abc123.mp3),浏览器就能命中缓存,第二次播放几乎零请求。但注意带签名的临时链接不要设长缓存,否则过期后用户会看到一堆 403。

4. 上传与转码流水线:MP3 不是唯一答案,也不该是唯一格式

4.1 上传接口真正要做的四件事

新手写的上传接口长这样:收文件、存磁盘、插一条记录、返回成功。这条链路在真实场景里会出至少四种问题,所以我把上传拆成了四个明确步骤。

第一步是限制与校验。文件大小上限要卡死(我设的是单文件 50MB),扩展名白名单只放行常见音频格式,同时读文件头的魔数确认真是音频而不是改了后缀的其他文件。这一步不做,你的存储会被莫名其妙的东西填满。

第二步是算哈希去重。分块读文件算 SHA-256,算完先查库。命中就返回已有记录并提示“该音频已存在”,直接省掉一次存储。实测这一步的命中率能到 20% 以上,非常划算。

第三步是抽取元数据。用 FFprobe 读时长、码率、采样率、声道数,用标签解析库读标题、歌手、专辑、封面。这两件事必须做,因为时长决定进度条,封面决定列表好看不好看。

第四步才是落库并写入异步转码队列。注意顺序,先落库拿到 ID,再入队,否则转码回调找不到记录。

4.2 用 FFmpeg 抽封面、抽时长、生成多码率

命令行调用 FFmpeg 是这套系统里最“外挂”的部分,但也是最省事的。抽取封面这一条:

ffmpeg -i input.mp3 -an -vcodec copy -y cover.jpg

抽时长和基础参数,用 FFprobe 更方便,输出是 JSON,解析即可:

ffprobe -v quiet -print_format json -show_format -show_streams input.mp3

如果要生成多码率,方便弱网用户,我会额外产出一份 96kbps 的版本:

ffmpeg -i input.mp3 -codec:a libmp3lame -b:a 96k -y output_96.mp3

这里有个成本判断要说清楚:多码率是否值得做,取决于你的用户网络分布。如果只是站内小范围使用,单一码率完全够;如果确实有明显的弱网用户,那 96k 和 192k 两档就够了,别搞五六档,转码时间和存储都会翻倍,收益却是边际递减的。我实测过三档,第三档带来的实际体验提升几乎测不出来。

4.3 ID3 元数据乱码:一个几乎百分百会遇到的问题

只要用户上传的是自己收集的音频,你几乎一定会遇到中文标题变乱码。原因是 ID3v2 早期版本对非拉丁字符的编码声明混乱,很多文件实际用本地编码写入但标记成 ISO-8859-1。解析出来就是一堆问号或者奇怪的字符。

解决办法是在解析层做一次兜底探测:先按声明编码解,如果结果里出现大量替换字符(\uFFFD),就用 GBK 再解一次,取“看起来更像中文”的那个结果。这是一段不太优雅但极其有效的代码:

def decode_tag(raw: bytes, declared: str) -> str: for enc in (declared, "utf-8", "gb18030", "big5"): try: text = raw.decode(enc) if "\ufffd" not in text: return text.strip("\x00").strip() except (UnicodeDecodeError, LookupError): continue return raw.decode("utf-8", errors="ignore").strip("\x00")

另一个更彻底的做法是:解析出来的元数据只作为默认值填进表单,让用户上传后自己确认或修正,一旦被用户改过就标记为“已人工确认”,后续不再用文件内标签覆盖。这个策略我强烈推荐,因为文件里的标签质量真的不可控。

4.4 异步任务队列:单机阶段别急着上重量级组件

转码是典型的耗 CPU、耗时长、允许延迟的任务,绝对不能同步跑在上传请求里,否则一个 50MB 文件能把请求挂十几秒。

单机阶段我用的是最土但最稳的方案:一张任务表加一个扫描进程。任务表里有status(待处理、处理中、成功、失败)、retry_count、next_retry_at。扫描进程每隔几秒捞一批待处理任务,用乐观锁抢占(UPDATE ... WHERE status=0),处理完更新状态。失败的任务指数退避重试,超过三次就标记为永久失败并记录原因。

UPDATE transcode_task SET status = 1, worker_id = ?, started_at = NOW() WHERE id = ? AND status = 0;

这个方案的好处是:状态完全可见,出问题查库就知道卡在哪;不需要额外组件,重启不丢任务;并发控制就是一个数字。等哪天单机扛不住了,你把这套逻辑搬到消息队列上,代码结构几乎不用改,因为你已经在用“抢占、确认、重试”这套语义了。先学会用数据库做队列,再理解为什么需要专业的队列,这个顺序别倒过来。

5. 播放器前端:状态机的复杂度是 UI 的十倍

5.1 HTML5 Audio 的事件顺序,比你想的复杂

一个音频元素身上能监听的事件有十几个,但真正需要你关心的只有几个关键节点,而且它们的触发顺序并不总是符合直觉。我整理了一张表,是我反复测试后得出的结论:

事件触发时机我用来做什么
loadedmetadata拿到时长和基础信息初始化进度条总长度
canplay可以开始播放但可能还会缓冲解除加载态,允许点击播放
waiting缓冲中,播放暂停显示缓冲指示,禁用进度条拖动
playing真正开始播放启动进度更新定时器
timeupdate播放位置变化,频率约 4Hz更新进度、驱动歌词高亮
ended播放自然结束触发下一首
error加载或解码失败跳到下一首并记录失败原因

最容易出问题的是error事件。它可能因为网络中断、格式不支持、链接过期等原因触发,而如果不处理,播放器就静默停在那里,用户以为卡了。我的做法是监听它,把当前歌曲标记为“本轮跳过”,然后自动切下一首,同时上报一个错误日志。用户几乎无感知,但你在后台能看到哪些歌有问题。

还有一个反直觉的点:timeupdate的频率大约是每 250 毫秒一次,如果你在这个回调里做重渲染(比如整页状态更新),会明显掉帧。正确做法是把它写进一个高频更新的独立组件,只更新进度条和歌词这两处,别碰全局状态。

5.2 播放队列、随机播放、单曲循环的正确实现

播放队列听起来简单,实际上有四种模式:顺序、列表循环、单曲循环、随机。前三种都好办,随机是个坑。

很多人实现随机的做法是每次切歌时从列表里随机挑一首。问题在于:这样会有重复(同一首歌可能连续播两次),也无法“回到上一首”。正确的做法是洗牌一次,记住顺序。用户第一次开启随机时,用 Fisher-Yates 打乱一份索引数组,之后按这个数组走,上一首就是数组里的前一个。

function shuffle(arr) { const a = arr.slice(); for (let i = a.length - 1; i > 0; i--) { const j = Math.floor(Math.random() * (i + 1)); [a[i], a[j]] = [a[j], a[i]]; } return a; } // 开启随机时保留原顺序,关闭时一键还原 const state = { queue: [...songs], order: null, cursor: 0 }; function enableShuffle() { state.order = shuffle(state.queue.map((_, i) => i)); state.cursor = 0; } function disableShuffle() { state.order = null; }

单曲循环的实现也有个细节:不要靠audio.loop = true,因为它会让ended事件不触发,你的播放计数就统计不到了。正确做法是监听ended,在单曲循环模式下把currentTime归零并重新play()。多写三行,换来的是统计数据准确。

切歌时还有一个必须做的动作:在src赋值前先调用audio.pause()和清空事件。否则快速连续切歌会出现“两首歌同时在播”的诡异现象——其实是旧的加载还没结束,新的已经开始。

5.3 歌词同步:LRC 解析加二分查找

歌词是这类系统提升质感最明显的功能,成本却很低。LRC 格式本质就是一行行带时间戳的文本,解析规则很简单:

function parseLrc(text) { const lines = []; const re = /\[(\d{2}):(\d{2})(?:\.(\d{1,3}))?\](.*)/; for (const raw of text.split(/\r?\n/)) { const m = re.exec(raw); if (!m) continue; const ms = (+m[1]) * 60000 + (+m[2]) * 1000 + (m[3] ? +m[3].padEnd(3, '0') : 0); const content = m[4].trim(); if (content) lines.push({ time: ms, content }); } return lines.sort((a, b) => a.time - b.time); }

一行歌词前面可能有多个时间戳(副歌重复),我的简化做法是先按第一个时间戳插入,够用了;如果要做完整,就在循环里把每个时间戳都展开成一条。

高亮当前行用二分查找找最后一个time <= currentTime的索引,比线性扫描稳得多。歌词几百行时差异不明显,但配合timeupdate每秒四次调用,二分能省下不少计算。另外要处理currentTime往前跳(用户拖回去)的情况,二分查找天然支持,线性的“从上一次位置往下找”就会失效。

5.4 记住上次播放位置:一个防抖没做好的典型事故

“打开网站自动续播上次的位置”是个很讨喜的小功能,实现也简单:定时把songId和currentTime写进localStorage。但我第一版写完之后,用户反馈页面很卡,排查发现我在timeupdate里直接写localStorage,每秒四次同步写磁盘,确实会卡。

修法是加节流,两秒写一次,并且在pause和beforeunload时补一次最终写入:

let lastWrite = 0; audio.addEventListener('timeupdate', () => { const now = Date.now(); if (now - lastWrite < 2000) return; lastWrite = now; localStorage.setItem('lastPos', JSON.stringify({ songId: current.id, time: audio.currentTime })); }); audio.addEventListener('pause', () => { localStorage.setItem('lastPos', JSON.stringify({ songId: current.id, time: audio.currentTime })); });

恢复时要判断一下时间戳的合理性:如果存的currentTime已经接近时长末尾(比如剩不到 3 秒),就应该从头播,而不是一进来就结束。

6. 检索、歌单与审核:业务层的长尾都在这里

6.1 搜索用 LIKE 还是全文索引,取决于你的歌曲量级

搜索这个功能,我在三个不同阶段用过三种方案,结论是按量级选,别提前优化。

方案适用量级优点缺点
LIKE '%关键字%'万级以下零配置,中文友好无法走索引,全表扫
MySQL 全文索引(ngram)十万级走索引,配置简单分词质量一般,短词不准
独立搜索引擎百万级以上分词、权重、高亮齐全部署和维护成本高

我最终停在中间那档。用 ngram 分词器建全文索引,同时保留一个“前缀匹配走普通索引”的分支,因为用户输入一两个字符时 ngram 的效果不好。搜索的目标字段是标题、歌手名、专辑名三个,我会给标题更高权重——这是一个纯业务判断,因为用户搜歌九成是在搜标题。

还有一个体验细节:搜索接口要支持拼音首字母。这个不用改数据库,在写入歌曲时额外存一列title_pinyin_abbr,查询时对关键词也做同样转换再匹配。用户习惯用 “qgxy” 搜“千千阙歌”,加上这个之后搜索命中率提升非常明显。

6.2 歌单排序:留空隙的整数比全表重排划算得多

前面提到过这个设计,这里展开说。歌单里拖动排序,最朴素的做法是每次拖动后把整张关联表按新顺序重写一遍sort_order。二十首歌问题不大,但歌单是可以有几百首的,全表更新一次数据库压力不小,而且并发下容易出现顺序错乱。

我的做法是留空隙:新增时sort_order取当前最大值加 100。拖到两个元素之间时,取前后两个值的平均数。比如 A 是 100,B 是 200,把 X 拖到中间就是 150;再拖一个到 A 和 X 之间就是 125。这样每次拖动只更新一行。

空隙总会被耗尽——连续在同一个位置插入十几次之后,两个值就差 1 了。这时触发一次局部重排:只把当前歌单的sort_order重新按 100 递增分配一遍。重排是低频事件,可以接受。

-- 拖到 prev 和 next 之间 UPDATE playlist_song SET sort_order = (? + ?) / 2 WHERE playlist_id = ? AND song_id = ?;

用整数存的话,(100 + 101) / 2 = 100,会退化。所以要么把字段改成DECIMAL(20,6),要么在发现间距小于 2 时立刻触发重排。我选的是后者,字段保持整数,逻辑里加个判断,实现更简单也不会有精度问题。

6.3 上传审核与内容自查:这个流程不能省

如果这个系统不只是你自己用,那上传就必须有一个审核环节。我最初没做,结果很快就发现问题:有人上传了命名混乱的文件、有人重复上传同一个音频、有人传了明显不是音频的东西。

我的审核流程是三段式:机器校验、人工抽检、上线后可撤回。机器校验包括格式白名单、时长下限(低于 15 秒的当作音效而非歌曲)、哈希去重、元数据完整性检查。通过机器校验的进入“待审核”状态,在后台列表里排队,管理员看一眼标题和封面就能决定放行或拒绝。上线之后如果发现问题,还能一键下架,status改成 2 即可,播放接口会拒绝返回。

审核列表我加了一个小功能很有用:直接内嵌一个迷你播放器,管理员不用下载就能听。这个功能实现成本极低(复用现成的播放组件),但把审核效率提升了好几倍。

7. 部署与压测:这套系统跑起来之后的真实数字

7.1 Nginx 那几行真正重要的配置

静态音频托管的配置里,真正影响体验的就那么几行,其余都是常规设置:

location /audio/ { alias /data/music/; add_header Accept-Ranges bytes; add_header Cache-Control "public, max-age=86400"; gzip off; sendfile on; tcp_nopush on; aio on; } location /api/ { proxy_pass http://127.0.0.1:8080; proxy_set_header Host $host; proxy_set_header X-Real-IP $remote_addr; }

三个点要说明。gzip off是必须的,音频本身已经是压缩格式,再压一遍纯属浪费 CPU 而且可能破坏 Range 语义。sendfile on让文件传输走内核态,是静态大文件性能的关键。aio on在支持的系统上进一步提升读取效率,如果你的环境不支持可以去掉,不影响正确性。

另外,音频路径不要用可预测的自增 ID。我用的是file_key,形如2024/06/{hash前两位}/{hash}.mp3,一方面分散目录避免单目录文件过多,另一方面让路径难以枚举。

7.2 缓存策略:哪些能缓存,哪些一秒都不能

缓存这事我分了三档,写成了明确的规则:

资源类型缓存策略理由
音频文件(带哈希路径)一年,immutable内容不变,路径随内容变
封面图七天可能被替换,但不需要实时
歌曲详情接口60 秒播放量等字段允许短暂滞后
歌单列表接口不缓存用户随时增删,必须实时
播放上报接口不缓存写操作
登录态相关不缓存安全底线

这里最容易被搞错的是歌单列表。很多人图省事把它也缓存了,结果用户加完歌刷新看不到,以为没成功,反复点。凡是“用户刚刚操作过”的数据,一律不缓存,这条经验值很多钱。

7.3 用压测工具跑一遍,才知道瓶颈在哪

我上线前用压测工具跑了一轮,测试对象是流式播放接口,单文件约 8MB,请求都带 Range 头。结果很有意思,整理成下面这组数据(都是我本地环境实测,绝对值不重要,看趋势):

场景并发QPS平均延迟观察
后端转发字节流50约 120380msCPU 明显上升
后端转发字节流200约 952100ms出现超时
Nginx 静态托管200约 900210ms稳定
Nginx 静态托管500约 1100450ms仍然稳定

结论非常清楚:后端起转发作用时,并发是硬瓶颈;交给 Nginx 之后,压力几乎不体现在应用层。这也印证了第 3 章的架构选择。我最终把音频全部交给 Nginx,后端只负责发签名和记录播放事件,同样的机器能撑住的并发翻了七八倍。

压测时还有个细节值得提醒:压测工具默认不带 Range 头,你得手动加上,否则测出来的是整文件下载,跟真实播放行为完全不符,结论会误导人。

8. 几个我反复验证过的小经验

说说我在这个项目里踩过或验证过的几件小事,散但实用。

并发切歌一定要做请求取消。用户快速点五首歌,会发出五个加载请求。如果没有取消机制,后到的响应可能覆盖先到的,最终播放的和界面上显示的对不上。前端用AbortController取消上一次的搜索和元数据请求,音频元素则在切换前先pause()并清空src。这个问题在小规模测试里几乎发现不了,人一多就冒出来。

播放计数不要在请求流时直接自增。有人一拖进度条就触发好几次请求,播放量会虚高。我改成前端在播放满 30 秒后单独发一次上报,后端按用户和歌曲做去重(同一用户同一天只计一次)。数据一下子干净了。

文件哈希用流式计算,别读进内存。大文件一次性读到内存算哈希,几个并发就能把内存打满。用流的方式边读边算,内存占用是常数级的。

给数据库的lyric_text设个大小限制。我遇到过一份几兆的歌词文件,明显是格式有问题,直接拒掉比存进去再处理更省事。

开发阶段一定要准备一批“坏样本”。我专门留了一个目录放各种异常文件:没有标签的、标签乱码的、时长为 0 的、码率极低的、后缀是 mp3 实际是别的格式的。每次改完上传逻辑,先拿这批样本跑一遍,能省掉大量线上的尴尬。

播放列表状态尽量放在一个地方。我一开始把当前歌曲存在组件里、队列存在 store 里、模式存在另一个地方,结果切歌时经常出现某处没同步。后来统一收敛到一个播放状态对象,所有操作都通过它暴露的方法走,问题基本消失。这个重构花的半天时间是整个项目里回报最高的半天。

最后分享一个我觉得挺有意思的扩展方向:等你把基础播放跑通之后,可以试试加一个“按播放历史自动生成推荐歌单”的小功能。不需要什么复杂算法,就按最近听过的歌的歌手做简单统计,把同歌手但没听过的歌排前面,生成一个“你可能想听”的歌单。实现不到一百行,但用起来的感觉完全不一样,而且这是那种“自己用着会觉得这系统真的活了”的功能。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询