
写这篇东西之前我先说个背景。很多人在学校、公司内部或者接私活的时候碰到过类似的需求——做一个“基于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) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4; 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) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4;这里有两个容易被忽略的设计点。第一song 表里单独存了singer_id为什么不直接在 song 表里冗余一个歌手名字段因为歌手信息以后会扩展地区、简介、头像如果冗余字段以后同步更新会非常麻烦。歌手表稳定下来之后查询时 JOIN 一下成本很低。第二list_song 表没有用复合主键而是单独弄了一个自增 id同时加了联合唯一索引uk_list_song。这样做的目的是避免以后出现“同一首歌在同一个歌单里被重复添加”的情况程序里做判断容易漏数据库层面直接挡住更稳。3.3 duration 字段的重要性很多初学者做歌曲管理的时候根本不存音频时长。这在前端展示时问题不大但做播放列表进度条和“最近播放”功能时会发现没有时长就很难展示。获取时长并不是 Java 代码直接能读出来的我当时的方案是上传完音频之后调用 FFmpeg 的命令行工具去探测文件时长。命令大概是ffprobe -v error -show_entries formatduration -of defaultnoprint_wrappers1:nokey1 /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 作废。所以我做了一套小方案用户登录信息会缓存到 Rediskey 是用户 IDvalue 是当前 token。修改密码或者被封禁时直接删掉 Redis 中对应 key。这个设计简便宜实现也把 JWT 最大的痛点解决掉了。登录请求的核心逻辑大致是public ResultString login(LoginDTO dto) { LambdaQueryWrapperUser 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 ResponseEntityResource 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?tokenxxxexpire1720000000过期就失效防住那种把播放链接抓出去外传的场景。对于学生项目或者内部系统做到判断登录这一层已足够签名 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 没配启动时会报时间相关的异常直接serverTimezoneAsia/Shanghai即可。8. 最后再分享几个小经验这节算是我的一点点总结吧想到哪写到哪。第一树形结构不要硬造。歌单和歌曲本来就是个标准的多对多模型中间表是绕不开的。早期我图省事用逗号分隔歌曲 id后来加排序功能时被自己坑惨了只好重构。数据库的规范性是在为未来的扩展能力买单不要图当前几行代码简单。第二文件路径能相对就不要绝对。凡是所有用户数据相关的资源路径都存相对路径部署时统一拼接 base path换服务器不至于把数据库里的几百条数据改一遍。第三如果你只有一周时间最优先要做的是把“播放”这一条链路跑通而不是在歌单管理上纠结太多。所谓“跑通”就是从数据库查到歌曲、流式接口输出 Range 响应、前端点一下能正常播放、能拖进度条。这一条链路通了这个项目的核心价值已经交付了大半剩下的是锦上添花。第四遇到问题先看浏览器 Network 面板再看后端日志。很多音频播放不了的问题其实就是 Response Header 里少了某个字段或者 MP3 文件本身是坏的。先前后端各自定位能少走很多弯路。这些经验是我在实际项目里一个个踩出来的希望能让准备做类似系统的人少折腾几个晚上。如果你也在做 Java 音乐系统欢迎按着这篇文章的思路先把数据库和流接口搭起来再继续往细节深挖。