写这篇东西之前,我先说个背景。很多人在学校、公司内部或者接私活的时候碰到过类似的需求——做一个“基于Java的在线音乐播放系统”。看起来是个老掉牙的题目,网上相关的项目源码也一抓一大把,但真正自己动手做一遍,或者在原有代码上改一改就会发现,这里面坑不少:音频文件怎么存、播放进度怎么续传、歌单和歌曲的关系怎么不写成一团乱麻、多端访问时接口怎么设计才不打架。这篇文章不打算讲那些网上抄来抄去的理论,而是拆一个我实际带过的项目:基于 Java 的在线音乐播放系统,从需求分析、表结构设计、后端接口实现,到前端播放器对接,再到上线前容易踩的坑,一条线捋下来。适合的人群就是两类:一是马上要做相关课程设计或者毕业设计的同学,二是在公司里要做内部音乐点播、语音素材管理等相似系统的后端工程师。废话不多说,直接进入正题。
1. 动手前先想清楚:在线音乐系统到底在解决什么问题
很多人在写这种系统的时候,第一步就是建 Spring Boot 项目,然后开始写增删改查。这在我看来是顺序错了。开发这种业务系统,最先应该花时间想清楚的是三个前提问题,而不是先写代码。
1.1 “在线播放”和“文件下载”是两码事
我见过很多半成品系统,所谓的在线播放,其实是把音频文件直接塞到前端<audio>标签的 src 里,然后让浏览器自己下载完再播。这样实现极其简单,但体验和后期问题都很大:大文件加载慢、刷新就从头开始、带宽浪费、无法做权限控制。
真正的在线播放,本质上是一个“流式(Streaming)读取”过程。客户端向服务端发一个请求,服务端从磁盘里读文件,按字节一点一点吐给客户端。这时候 HTTP 协议里有一个很关键的东西叫Range 请求头(HTTP Range Header)。简单说就是浏览器会告诉服务器:“我不需要整个文件,我只要从第 N 个字节到第 M 个字节的这段数据”。服务端如果能正确处理这种请求,就能实现拖动进度条播放、快进快退、断点续传,而不是傻傻地每次从零开始传。
所以刚开始做的时候,我就把系统的核心定位成:能正确处理 Range 请求的音频流接口。这一条确定下来,后面整个接口设计都会不一样。
1.2 管理端和播放端要分离开
这个系统的使用方看起来很简单:用户登录、搜歌、听歌。但实际落地的时候,一定会有“谁把歌传上来的”这个问题。我参与的这个项目里,最初没人管上传这回事,结果导致所有歌曲都得靠程序员往数据库里手工插数据,一次插几十条,效率低不说,还很容易出现封面路径写错、时长写错这些问题。
所以在设计上从一开始就要明确两个角色:管理员负责维护曲库(上传音频、填写歌曲信息、管理歌手和专辑),普通用户只负责听(浏览、搜索、创建自己的歌单)。这个区分不是界面上的花架子,它直接决定了接口的权限分配,比如上传接口必须鉴权,删除操作必须限定角色,普通用户的接口则最多做登录校验,不应该动不动就 403。
1.3 版权和存储策略不能抄网上的思路
网上下载的一些源码,很多直接把歌曲文件塞到项目根目录的 static 文件夹里,然后数据库存一个 /static/music/xxx.mp3 了事。这在演示阶段没问题,但对稍微正式一点的项目就是隐患:
- 项目重新打包部署时,static 目录可能被覆盖,文件说没就没;
- 静态资源如果被 Nginx 直接映射出去,任何人都能拼 URL 下载,完全绕过了权限校验;
- 几百首歌塞进 jar/war 包里,包变得巨大,启动都慢。
正确的做法是把音频文件和代码彻底分开,文件放到独立的本地目录或者对象存储里,数据库只保存逻辑路径,通过接口动态输出。这一点我会在第 5 节专门展开讲。
2. 技术选型:不是越新越好,而是越顺手越好
做 Java 后端的人对技术栈通常都有一些偏好,但在这个项目上,我最终采用的是非常“务实”的一套组合,没有刻意追新:Spring Boot 2.7 + MyBatis-Plus + MySQL 8.0 + Redis(可选)+ Vue 3 + Element Plus。
2.1 后端为什么用 Spring Boot 而不是 Spring Cloud
音乐播放系统本质上是一个单体的中小型应用,没必要上微服务那一套。Spring Boot 自带内嵌 Tomcat,打包成一个 jar 就能跑,开发调试都简单。用 Spring Cloud 反而会把 Nacos 注册中心、配置中心等概念引进来,徒增部署复杂度。
持久层框架我用的是 MyBatis-Plus。很多人纠结到底用 MyBatis 还是 Spring Data JPA,我的看法是:这个系统涉及比较多的多表关联查询(歌单里查歌曲、歌曲关联歌手),MyBatis 的 SQL 写起来可控性更强,而 MyBatis-Plus 又把单表 CRUD 给封装掉了,不用写一堆重复的 BaseMapper 方法。查歌单的时候稍微加一点注解或 XML 就能完成关联查询,效率很高。
2.2 前端是 SPA 还是服务端渲染
前端我试过两条路:一条是 Thymeleaf 服务端渲染,另一条是前后端分离的 Vue 单页应用。第一条路实现起来很快,但播放器做交互的时候很难受,页面一刷新状态就没了。第二条路一开始要多写一点接口对接代码,但播放列表、音量、播放模式这些状态都放在浏览器内存里,体验好得多。
如果你要在一个月内交项目,我不建议前后端完全分离,因为还要配 Nginx 或者处理跨域。折中一点的做法是后端只提供 RESTful API,前端用 Vue + Vite 单独开发,调试的时候用开发代理(比如 Vite 的 server.proxy)把 /api 转发到后端 8080 端口,部署时再统一放到同一个域的路径下,这样既不难受又能讲清楚架构。
2.3 项目包结构设计
我实际开发的包结构大概长这样:
src/main/java/com/example/music ├── MusicApplication.java ├── config │ ├── WebConfig.java // 跨域、拦截器配置 │ └── MybatisPlusConfig.java // 分页插件 ├── controller │ ├── UserController.java │ ├── SongController.java │ ├── SongListController.java │ └── StreamController.java // 音频流播放接口 ├── service │ ├── UserService.java │ ├── SongService.java │ ├── SongListService.java │ └── OssFileService.java // 本地文件存储服务 ├── mapper ├── entity ├── dto └── common ├── Result.java └── BizException.java这套结构不算复杂,但把配置、控制层、业务层、持久层、通用类都分开了。项目到后期加了定时统计功能,也只是在 service 下面加一个 StatisticService,不影响原有代码。
3. 数据库设计:表结构决定后面少改多少需求
这个系统的表设计是我花时间最多的地方。我见过很多烂设计,比如“歌单里直接存一串歌曲 ID”,查一次歌单要把字符串拆开来再查几十遍。这里把逻辑完整理一遍。
3.1 核心表与关系
整个系统一共有 6 张核心表:
user 用户表 singer 歌手表 song 歌曲表 song_list 歌单表 list_song 歌单-歌曲关联表 upload_record 上传记录表(可选)关系是这样的:
- 一个歌手可以有多首歌,一对多;
- 一首歌可以出现在多个歌单里,一个歌单也能包含多首歌,所以歌单和歌曲是多对多,必须用中间表 list_song 连接;
- 用户可以创建多个歌单,用户和歌单是一对多。
3.2 关键表结构脚本
下面给出一段我实际使用的核心 SQL,虽然不能覆盖所有字段,但最能说明设计意图的是 song 表和 list_song 表。
CREATE TABLE `song` ( `id` bigint NOT NULL AUTO_INCREMENT, `singer_id` bigint DEFAULT NULL COMMENT '歌手ID', `name` varchar(128) NOT NULL COMMENT '歌曲名称', `cover_url` varchar(512) DEFAULT NULL COMMENT '封面图相对路径', `file_url` varchar(512) NOT NULL COMMENT '音频文件相对路径', `duration` int DEFAULT NULL COMMENT '音频时长(秒)', `play_count` int DEFAULT 0 COMMENT '播放次数', `status` tinyint DEFAULT 1 COMMENT '1-上架 0-下架', `create_time` datetime DEFAULT CURRENT_TIMESTAMP, PRIMARY KEY (`id`), KEY `idx_singer_id` (`singer_id`), KEY `idx_name` (`name`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; CREATE TABLE `list_song` ( `id` bigint NOT NULL AUTO_INCREMENT, `song_list_id` bigint NOT NULL, `song_id` bigint NOT NULL, `sort_order` int DEFAULT 0, `create_time` datetime DEFAULT CURRENT_TIMESTAMP, PRIMARY KEY (`id`), UNIQUE KEY `uk_list_song` (`song_list_id`, `song_id`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;这里有两个容易被忽略的设计点。
第一,song 表里单独存了singer_id,为什么不直接在 song 表里冗余一个歌手名字段?因为歌手信息以后会扩展(地区、简介、头像),如果冗余字段,以后同步更新会非常麻烦。歌手表稳定下来之后,查询时 JOIN 一下成本很低。
第二,list_song 表没有用复合主键,而是单独弄了一个自增 id,同时加了联合唯一索引uk_list_song。这样做的目的是避免以后出现“同一首歌在同一个歌单里被重复添加”的情况,程序里做判断容易漏,数据库层面直接挡住更稳。
3.3 duration 字段的重要性
很多初学者做歌曲管理的时候根本不存音频时长。这在前端展示时问题不大,但做播放列表进度条和“最近播放”功能时会发现没有时长就很难展示。获取时长并不是 Java 代码直接能读出来的,我当时的方案是:上传完音频之后,调用 FFmpeg 的命令行工具去探测文件时长。命令大概是:
ffprobe -v error -show_entries format=duration -of default=noprint_wrappers=1:nokey=1 /path/to/audio.mp3Java 这边用 ProcessBuilder 去执行这个命令,把输出解析成 int 秒数存到 duration 字段里。你也可以用现成的库比如 jaudiotagger,但碰到某些特殊编码的音频,FFmpeg 明显更稳。这条经验在后来处理一大批用户上传的录音文件时帮了大忙。
3.4 搜索查询的索引设计
用户搜歌都是按歌名模糊搜,SQL 基本长这样:
SELECT * FROM song WHERE name LIKE CONCAT('%', #{keyword}, '%') LIMIT 20;这种写法在数据量小的时候没问题,但数据库里如果有了几万首歌,全表扫描就会变慢。我们当时的处理是把 name 字段加了索引,并且配合 MySQL 的覆盖索引优化查询;再往后数据量大了,更专业的做法是接入 Elasticsearch,但对大多数场景来说,加索引已经足够。不要一上来就整个 ES,那是过度设计。
4. 后端核心接口:登录、播放、歌单管理的实现逻辑
这部分是系统的主干。我会挑几个最关键的接口讲,重点不是说把所有代码贴出来,而是讲接口的设计逻辑和边界处理。
4.1 登录用 JWT 还是 Session
考虑到这个系统可能有 H5 端和管理端两个前端同时访问,用的技术栈还不一样,我选了 JWT 而不是传统 Session。原因很简单:Session 依赖浏览器 Cookie,在 App 端、小程序端对接时要处理 Cookie 同步,很麻烦;JWT 就是一个字符串,前端登录后存到 localStorage 里,每次请求放在请求头 Authorization 里就行。
JWT 的问题也同样明显:服务端不好主动把某个 token 作废。所以我做了一套小方案,用户登录信息会缓存到 Redis,key 是用户 ID,value 是当前 token。修改密码或者被封禁时,直接删掉 Redis 中对应 key。这个设计简便宜实现,也把 JWT 最大的痛点解决掉了。
登录请求的核心逻辑大致是:
public Result<String> login(LoginDTO dto) { LambdaQueryWrapper<User> wrapper = new LambdaQueryWrapper<>(); wrapper.eq(User::getUsername, dto.getUsername()); User user = userMapper.selectOne(wrapper); if (user == null || !PasswordUtil.matches(dto.getPassword(), user.getPassword())) { throw new BizException("用户名或密码错误"); } String token = JwtUtil.createToken(user.getId(), user.getRole()); redisTemplate.opsForValue().set("login:token:" + user.getId(), token, Duration.ofDays(7)); return Result.success(token); }密码不是明文存储的,而是用 BCrypt 加密。审核代码的时候要认真检查有没有人为了省事把密码明文落库,这是红线。
4.2 流播放接口的实现
这是整个系统最核心的接口,我建议单独用一个 Controller 来管。实现流程是这样的:
- 前端把歌曲 id 传给后端,或者直接传一个带签名 token 的 URL;
- 后端根据 id 查库,拿到 fileUrl;
- 后端把本地文件包装成
org.springframework.core.io.Resource,通过ResponseEntity返回; - 关键是处理 Range 请求头。
如果你只返回文件,不处理 Range,浏览器确实也能播,但进度条拖动会非常迟钝。处理 Range 的代码我直接给出要点:
public ResponseEntity<Resource> stream(@PathVariable Long id, @RequestHeader(value = "Range", required = false) String range) { Song song = songService.getById(id); File file = ossFileService.getFile(song.getFileUrl()); long fileLength = file.length(); long start = 0; long end = fileLength - 1; if (range != null && range.startsWith("bytes=")) { String[] parts = range.replace("bytes=", "").split("-"); start = Long.parseLong(parts[0]); if (parts.length > 1 && !parts[1].isEmpty()) { end = Long.parseLong(parts[1]); } } long contentLength = end - start + 1; InputStreamResource resource = new InputStreamResource(new FileInputStream(file), start, contentLength); return ResponseEntity.status(HttpStatus.PARTIAL_CONTENT) .header(HttpHeaders.CONTENT_TYPE, "audio/mpeg") .header(HttpHeaders.CONTENT_LENGTH, String.valueOf(contentLength)) .header(HttpHeaders.CONTENT_RANGE, "bytes " + start + "-" + end + "/" + fileLength) .body(resource); }这段逻辑其实并不复杂,关键是你知道有这个机制、去实现它。不处理 Range,音频播放“在线感”会差一大截。
4.3 歌单和歌曲的关联操作
歌单接口最容易出问题的是添加歌曲时的双重校验。
场景是这样的:用户把一首歌添加到歌单 A,如果歌单里已经有了这首歌,应该提示“已存在”,而不是再插一条重复数据。程序上当然可以先查一次 list_song 表再决定插不插,但如果两个请求并发过来,还是会插进去两条。所以我除了加数据库唯一索引之外,插入时还用了一条 SQL 直接挡:
INSERT INTO list_song(song_list_id, song_id, sort_order) SELECT #{songListId}, #{songId}, #{sortOrder} FROM dual WHERE NOT EXISTS ( SELECT 1 FROM list_song WHERE song_list_id = #{songListId} AND song_id = #{songId} );这条语句成功插入返回 1,重复插入返回 0,代码里判断一下返回结果就知道有没有插成功。这种做法比“先查再插”要稳得多,也省一次数据库往返。
4.4 播放次数的统计
播放次数不能每一次用户点击都去 UPDATE 一次,会有并发写问题。我当时的做法是:前端每次点击播放时调一个异步接口,后端只把“歌曲ID + 时间戳”扔进一个内存队列,再写一个定时任务,每隔 30 秒把队列里的数据聚合一次,批量 UPDATE song 表的 play_count 字段。这样数据库的压力很小,代码也只多了一个类而已。如果想要更精确,这个数据还可以落到 Redis 里定期持久化,但对校内部系统来说,批处理足够。
5. 音频文件的存储与安全设计
这节单独拿出来讲,是因为我觉得很多网上的项目在文件这一块处理得太随意了。
5.1 存储目录规划
我在服务器上规划了这样的目录:
/data/music-platform/ ├── uploads/ │ ├── covers/ # 封面图片 │ └── audio/ # 音频文件 └── backup/数据库里的 file_url 字段只存相对路径,比如/uploads/audio/20240715_163000_4821.mp3。为什么不存绝对路径?因为部署环境的 IP、域名、端口都可能变,服务端只拿相对路径去拼接,移动环境时只改一个配置项就够了。
文件名生成用“日期_时间戳_随机数”的方式。为什么不直接用原文件名?用户上传的文件名很容易出现中文、空格、特殊字符,直接落盘后 URL 编码各种问题都会冒出来,改成系统生成的名字一劳永逸。原始名称可以存到数据库的 remark 字段里。
5.2 不能直接暴露静态目录
静态资源控制器是这么配的:
@Override public void addResourceHandlers(ResourceHandlerRegistry registry) { registry.addResourceHandler("/static/**") .addResourceLocations("file:/data/music-platform/uploads/"); }这样配置以后,前端用<img src="/static/covers/xxx.jpg">就能直接展示封面图,开发时很方便。但是注意,音频文件千万不要这么暴露。原因我在开头就说了,一旦暴露,任何人都可以绕过鉴权直接下载整个音频,系统的登录机制等于形同虚设。
音频必须走第 4.2 节的 stream 接口,并且接口里要判断用户是否登录。更细一步,可以生成一个带过期时间的签名 URL,比如/api/stream/123?token=xxx&expire=1720000000,过期就失效,防住那种把播放链接抓出去外传的场景。对于学生项目或者内部系统,做到判断登录这一层已足够,签名 URL 属于加分项。
5.3 上传文件的大小限制
Spring Boot 默认的上传文件大小是 1MB,一个音频动辄几十 MB,不配置就直接被拒了。这个坑我见过好几个人踩。配置在 application.yml 里:
spring: servlet: multipart: max-file-size: 50MB max-request-size: 60MB另外单次上传一个大文件,如果体积超过 100MB,还需要考虑分片上传,这已经超出本文范围。一般处理 50MB 以下的音频,直接 MultipartFile 上传完全够用。
6. 前端播放器的对接细节
前端我用的是 Vue 3。播放器本身是 HTML5 的<audio>标签,但要做成一个能正常工作的在线播放器,需要处理很多细节。
6.1 播放列表是什么数据结构
播放器内部维护一个播放队列,这个队列其实就是一个歌曲数组。用户的歌单可能包含几十首歌,切歌的时候不能每次都去找歌单接口,所以前端是先把整个歌单的歌曲拉下来,存到播放器 store 里,再根据当前歌曲 id 计算下一首。具体逻辑是这样的:
- store 里的 playList 存放所有歌曲对象;
- playIndex 记录当前下标;
- 点击列表中的歌曲时,重新设置 playList 和 playIndex;
- 播放结束后,根据播放模式(列表循环、单曲循环、随机播放)计算下一个 index。
function playSong(song, index) { store.playList = [...currentSongList]; store.playIndex = index; audioElement.src = getStreamUrl(song.id); audioElement.play(); }6.2 获取歌词和封面
歌词和封面这种信息如果每次都从接口拉,会拖慢页面。我的建议是歌单列表接口里就冗余返回歌曲的基础信息(封面 URL、歌手名、时长),点击播放时再去拉音频流地址。音频流地址确实每次都要重新拼,因为带了时间戳参数,可以防止浏览器和 Nginx 缓存住旧的链接。
一个很有意思的细节是:音频文件的流播放接口不能用普通的 axios 去请求然后拿 blob 播放,那样内存会爆。应该直接赋给audio.src,让浏览器原生去处理流式加载。很多第一次做播放器的人会在这一步卡半天,实际上就是没搞明白audio.src和 axios 的区别。
6.3 播放进度条需要 source 支持 range
如果后端的流接口没有实现 Range 处理,前端拖动进度条后会听到声音,但是进度条会拉回原位置,点击暂停再播放又从头开始,体验非常差。这不是前端代码的问题,而是后端流接口不支持随机读。我调试的时候踩过这个坑,后来补上 Range 处理后一切正常。所以前后端联调时,要在浏览器 Network 面板里检查 Response Headers 里有没有Content-Range,有就说明后端处理对了。
7. 部署与性能调优:从能跑到能扛
一个系统从能在本地跑,到部署在服务器上让别人用,中间还隔着不少事。
7.1 用 Nginx 做动静分离
虽然系统是一个 Spring Boot 的 jar 包,但在生产环境我建议前面再挂一个 Nginx。Nginx 负责三件事:
- 监听 80/443 端口;把 /api 下的请求反向代理到内网的 8080;
- 直接服务 /static 下的封面图片,减轻 Tomcat 的压力;
- 配置 Gzip 压缩,让 JSON 响应更小。
Nginx 里音频的流播放请求不建议开太大的 proxy_buffer,否则边下边播时候内存占用会比较诡异。实测下来保持默认值即可。
7.2 大并发下的热点缓存
“热歌排行榜”对应的 SQL 很简单:
SELECT * FROM song ORDER BY play_count DESC LIMIT 10;真到几十万播放量的时候,每次首页打开都去执行这条 SQL,数据库压力会很大。我把它做了 Redis 缓存,key 是 hot_songs,缓存时间 5 分钟。播放次数异步更新后,缓存最多落后 5 分钟,对排行榜这种非实时数据来说完全能接受。实现上用@Cacheable或者手动 redisTemplate 都行,关键是想清楚这个数据允许“最终一致”,不需要强一致。
7.3 音频资源防泄露
部署完成后我还做了一件事:给音频流接口加了一层 referer 校验。不是严格的安全手段,但对于内部系统足够:当请求的 Referer 域不是自己系统的域名时,直接返回 403。这个在 Nginx 里一个配置就能实现:
location /api/stream/ { valid_referers example.com; if ($invalid_referer) { return 403; } proxy_pass http://127.0.0.1:8080; }加上这个以后,别人把播放链接贴在其它网站上,基本是打开就 403。这招成本极低,效果却很好。
7.4 常见运行期报错
我在部署阶段遇到过几个高频报错,直接列出来:
- 上传文件时 413:通常不是 Java 的问题,而是 Nginx 的
client_max_body_size默认只有 1M,需要在 http 或 server 块里加client_max_body_size 60m;。 - 跨域报错:开发环境前端在 5173 端口,后端在 8080,必须配置 CORS。生产环境用 Nginx 同域代理后,跨域问题自然消失。
- 中文文件名导致 404:如果把前端传入的中文路径直接拼 URL,容易有问题,最好统一格式化为 URL 编码,或者干脆按前面说的避免存中文文件名。
- 数据库时区报错:连接 MySQL 时如果 serverTimezone 没配,启动时会报时间相关的异常,直接
serverTimezone=Asia/Shanghai即可。
8. 最后再分享几个小经验
这节算是我的一点点总结吧,想到哪写到哪。
第一,树形结构不要硬造。歌单和歌曲本来就是个标准的多对多模型,中间表是绕不开的。早期我图省事用逗号分隔歌曲 id,后来加排序功能时被自己坑惨了,只好重构。数据库的规范性是在为未来的扩展能力买单,不要图当前几行代码简单。
第二,文件路径能相对就不要绝对。凡是所有用户数据相关的资源路径,都存相对路径,部署时统一拼接 base path,换服务器不至于把数据库里的几百条数据改一遍。
第三,如果你只有一周时间,最优先要做的是把“播放”这一条链路跑通,而不是在歌单管理上纠结太多。所谓“跑通”,就是从数据库查到歌曲、流式接口输出 Range 响应、前端点一下能正常播放、能拖进度条。这一条链路通了,这个项目的核心价值已经交付了大半,剩下的是锦上添花。
第四,遇到问题先看浏览器 Network 面板,再看后端日志。很多音频播放不了的问题,其实就是 Response Header 里少了某个字段,或者 MP3 文件本身是坏的。先前后端各自定位,能少走很多弯路。
这些经验是我在实际项目里一个个踩出来的,希望能让准备做类似系统的人少折腾几个晚上。如果你也在做 Java 音乐系统,欢迎按着这篇文章的思路先把数据库和流接口搭起来,再继续往细节深挖。