简介一款基于Java的在线音乐网站系统项目采用SSM框架与JSP、HTML5、MySQL技术栈开发面向需要完成课程设计或毕业设计的计算机专业学生也适合对音乐社交平台感兴趣的开发者参考。系统模拟QQ音乐、酷狗音乐等平台提供音乐搜索、在线收听、MV播放、用户留言与评价推荐等核心功能能够帮助学习者掌握SSM整合、数据库设计及Web前端交互的完整流程。压缩包共937个文件包含JSP页面、Java源码、class编译文件、jar依赖库、SQL脚本以及大量图片与JS/CSS资源另有若干gif演示图与mp4示例视频整体大小约32.94MB。目前已有45人学习下载。资源内项目结构完整附带数据库初始化脚本和配置文件可用IDE直接导入运行适合以此为基础进行二次功能扩展或论文撰写。1. 在线音乐网站用 Java 做最稳的路子还是前后端分离把「基于 Java 的在线音乐网站系统」拆开看本质是三个问题音频文件怎么存、怎么播业务数据怎么管以及多人同时在线时谁先卡死。很多人一上来就想着做推荐算法、做歌单广场结果卡在「歌都放不出来」这一步。我做过同类系统的经验是最核心的链路永远是「上传 → 转码 / 校验 → 存储 → 流式播放」推荐和评论只是锦上添花。适合谁想系统掌握 Java Web 全栈、准备做课程设计或毕业项目的开发者以及小团队要快速搭一套可用的音乐站点做内部验证的人。技术不复杂复杂的是把文件服务和数据库服务分开想清楚。2. 技术选型与目录结构Spring Boot 3 MyBatis-Plus 的组合逻辑2.1 为什么跳过 JSP直接用前后端分离传统 SSM 项目里用 JSP 渲染页面放在今天不是说不能用而是音频播放这种强交互场景会让 JSP 写起来极其别扭。播放进度、列表循环、音量记忆这些状态全要靠 JS 维护JSP 的模板渲染能力在这里几乎帮不上忙。所以常见做法是后端只出 JSON 接口前端用 Vue 或 React 单独跑。这样做的第二个好处是部署时后端可以独立扩容音频接口压力大了就多挂一台实例不会因为页面渲染抢占 Tomcat 线程。我做这个选型时最直接的理由是调试效率。前后端分离后后端开发可以用 Swagger 或 Postman 单独测接口不需要每次改动都重启整个 Web 应用去看页面效果。音频文件的上传和播放流程也能单独抽出来测试不会因前端一个 JS 报错阻塞后端联调。2.2 项目骨架与依赖坐标后端骨架用 Spring Boot 起步版本选稳定的 3.x 系列即可注意 3.x 要求 JDK 17 以上。ORM 层用 MyBatis-Plus原因是单表 CRUD 不需要手写 XML分页和条件查询也内置了适合这种以单表操作为主的业务。音频文件存储不塞进数据库放本地磁盘目录或 MinIO 之类的对象存储数据库只存路径和元数据。以下是一个典型的pom.xml核心依赖只保留与本系统直接相关的部分parent groupIdorg.springframework.boot/groupId artifactIdspring-boot-starter-parent/artifactId version3.2.4/version relativePath/ /parent dependencies dependency groupIdorg.springframework.boot/groupId artifactIdspring-boot-starter-web/artifactId /dependency dependency groupIdcom.baomidou/groupId artifactIdmybatis-plus-spring-boot3-starter/artifactId version3.5.5/version /dependency dependency groupIdcom.mysql/groupId artifactIdmysql-connector-j/artifactId scoperuntime/scope /dependency dependency groupIdorg.springframework.boot/groupId artifactIdspring-boot-starter-validation/artifactId /dependency dependency groupIdorg.projectlombok/groupId artifactIdlombok/artifactId optionaltrue/optional /dependency /dependencies逻辑说明mybatis-plus-spring-boot3-starter是专门适配 Spring Boot 3 的版本老项目的mybatis-plus-boot-starter在 Boot 3 下会出现自动装配失败。spring-boot-starter-validation用来做上传参数和注册参数的 Bean Validation能省掉大量手写 if 判断。Lombok 在这类项目里几乎是标配尤其实体类字段多时Data能减少大量样板代码。参数说明MySQL 8.x 配mysql-connector-j5.x 则要换成mysql-connector-java。2.3 播放与上传分开走一个走静态服务一个走上行接口音频读取和音频写入的流量特征完全相反。播放时是高频读同一个热门歌曲可能同时被几十个人拉取上传时是低频写但单文件体积大。把两者混在一个接口里会让 Tomcat 的线程池被长连接占满热歌一响其他接口全部超时。常见的做法是把播放行为直接交给 Nginx 或对象存储的静态文件服务Java 进程只管理音频的元数据。我一般会把上传目录独立在应用目录之外比如 Linux 下的/data/music/cover和/data/music/audio而不是放在项目的src/main/resources下。这样应用重新部署时音频文件不会丢也方便 Nginx 直接alias这个目录提供静态访问。Java 接口只负责接收文件流、落盘、记录路径到数据库以及后续的元数据修改。3. 数据库与存储设计歌曲表、用户表、播放记录的落库姿势3.1 歌曲表、歌手表、专辑表的字段取舍音乐网站的核心表逃不开歌曲、歌手、专辑三张表但字段怎么取舍直接决定后期开发体验。歌曲表不要塞过多冗余字段歌手和专辑用外键关联即可。以下是我常用的建表结构重点在于路径字段的设计和索引选择。CREATE TABLE song ( id BIGINT PRIMARY KEY AUTO_INCREMENT, title VARCHAR(120) NOT NULL, artist_id BIGINT NOT NULL, album_id BIGINT DEFAULT NULL, duration INT NOT NULL DEFAULT 0 COMMENT 单位秒, audio_url VARCHAR(255) NOT NULL COMMENT 相对路径如 /audio/2024/07/xxx.mp3, cover_url VARCHAR(255) DEFAULT NULL, lyric TEXT, play_count INT NOT NULL DEFAULT 0, status TINYINT NOT NULL DEFAULT 1 COMMENT 1-上架 0-下架, created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, KEY idx_artist (artist_id), KEY idx_album (album_id), KEY idx_status_play (status, play_count) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4;逻辑说明audio_url存的是相对路径而不是完整 URL这样未来把音频迁到 CDN 或对象存储时只需要在返回接口里拼不同域名不需要回改数据库。duration这个字段在展示端很常用列表页不想每个歌曲都拉播放器去探测时长上传时就该算好存进去。status字段用于下架歌曲不要用物理删除。参数说明idx_status_play是给热门榜单查询用的联合索引WHERE status1 ORDER BY play_count DESC可以直接走索引用户表、歌单表同理。歌手表建议独立字段为id、name、avatar、intro专辑表建议带上release_date和cover_url。很多同学会问「能不能把歌手名直接写死在歌曲表里」短期的确可以但后期要做歌手页、歌手歌曲聚合时就会写一堆GROUP BY还很别扭。3.2 用户表与播放记录表冗余字段的边界用户表不需要太复杂核心是账号、密码、昵称、头像、注册时间。密码用 BCrypt 加密存储别用 MD5。播放记录表是另一个容易被忽视的点用户每播一首歌就插一条记录时间长了表会非常大所以不要急着加唯一索引以外的约束而是用它做「最近播放」和「播放次数」两个统计来源。CREATE TABLE play_record ( id BIGINT PRIMARY KEY AUTO_INCREMENT, user_id BIGINT NOT NULL, song_id BIGINT NOT NULL, played_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, KEY idx_user_time (user_id, played_at) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4;注意这里没有存播放时长进度。如果产品上不需要「从上次听到的位置继续播放」就没必要为这个需求提前设计避免表字段膨胀。需要做续播功能时再在表里增加position_seconds字段成本很低。逻辑说明idx_user_time支撑「最近播放」列表的查询是WHERE user_id? ORDER BY played_at DESC LIMIT 20这个索引刚刚好。参数说明play_record 表可以考虑按月份做分区或干脆定期归档否则一年后这张表可能就是几千万行。3.3 写入路径与 MyBatis-Plus 的 BaseMapper 工作流表结构确定后实体类和 Mapper 层用 MyBatis-Plus 可以压缩到很短。Data TableName(song) public class Song { TableId(type IdType.AUTO) private Long id; private String title; private Long artistId; private Long albumId; private Integer duration; private String audioUrl; private String coverUrl; private String lyric; private Integer playCount; private Integer status; private LocalDateTime createdAt; private LocalDateTime updatedAt; }public interface SongMapper extends BaseMapperSong { }逻辑说明TableName把实体映射到song表TableId(type IdType.AUTO)对应数据库自增主键。继承BaseMapperSong后单表 CRUD 方法全部自带比如songMapper.selectById(id)、songMapper.updateById(song)。参数说明如果数据库主键是雪花 ID要把IdType改成ASSIGN_ID否则 MyBatis-Plus 默认按自增处理插入时会报主键冲突。写接口时的常见写法是 Service 层注入 Mapper直接调用内置方法完成业务只有多表查询才需要自己写Select注解或 XML 文件。这套组合能让整个项目的持久层代码量减少 60% 左右而且是经过大量生产项目验证过的成熟路线。4. 播放与搜索的核心实现流式返回、分词检索、封面处理的代码实操4.1 音频流式播放接口支持 Range 的分段返回浏览器的audio标签播放 MP3 时会发一个带Range头的请求要求服务器返回文件的一部分而不是全量内容。如果后端忽略这个头直接OutputStream把整个文件写完播放器虽然能出声但进度条拉不动切歌也会重新加载整个文件。因此接口必须正确处理 Range。RestController RequestMapping(/api/song) public class SongPlayController { private final SongMapper songMapper; public SongPlayController(SongMapper songMapper) { this.songMapper songMapper; } GetMapping(/{id}/stream) public ResponseEntityResource stream(PathVariable Long id, RequestHeader(value Range, required false) String range) throws IOException { Song song songMapper.selectById(id); if (song null) { return ResponseEntity.notFound().build(); } Path filePath Path.of(/data/music, song.getAudioUrl()); if (!Files.exists(filePath)) { return ResponseEntity.notFound().build(); } long fileSize Files.size(filePath); long start 0; long end fileSize - 1; if (range ! null range.startsWith(bytes)) { String[] parts range.substring(6).split(-); start Long.parseLong(parts[0]); if (parts.length 1 !parts[1].isEmpty()) { end Long.parseLong(parts[1]); } } long length end - start 1; InputStream inputStream Files.newInputStream(filePath); inputStream.skip(start); return ResponseEntity.status(HttpStatus.PARTIAL_CONTENT) .header(HttpHeaders.CONTENT_TYPE, audio/mpeg) .header(HttpHeaders.ACCEPT_RANGES, bytes) .header(HttpHeaders.CONTENT_LENGTH, String.valueOf(length)) .header(HttpHeaders.CONTENT_RANGE, bytes start - end / fileSize) .body(new InputStreamResource(inputStream)); } }逻辑说明Range头的标准格式是bytesstart-end解析后返回206 Partial Content状态码同时设置Content-Range告诉播放器本次返回的区间。InputStream.skip(start)直接从起点开始读避免把整个文件读进内存再截取。参数说明/data/music是音频根目录song.getAudioUrl()存储的是相对路径如/audio/2024/07/xxx.mp3两者拼接后就是磁盘上的完整路径。注意这里没有做play_count 1的更新播放计数最好放到前端拿到播放事件后再调独立接口避免流式接口里做非必要操作。没有处理好 Range 的典型表现是歌曲能播放但无法拖动进度条切歌要等 3 秒以上。调试时用curl -H Range: bytes0-1023看响应码是否为 206一目了然。4.2 搜索接口MySQL 全文索引 ngram 分词搜索是音乐网站的常用入口但大部分系统的数据量根本到不了必须上 Elasticsearch 的程度几千首歌用 MySQL 的全文索引完全够用。常见的做法是给song表的title字段加全文索引并配置 ngram 分词器支持中文。ALTER TABLE song ADD FULLTEXT INDEX ft_title (title) WITH PARSER ngram;GetMapping(/search) public ListSearchResultVO search(RequestParam String keyword) { QueryWrapperSong wrapper new QueryWrapper(); wrapper.apply(MATCH(title) AGAINST ({0} IN NATURAL LANGUAGE MODE), keyword); wrapper.eq(status, 1); wrapper.last(LIMIT 20); ListSong songs songMapper.selectList(wrapper); return songs.stream().map(s - { SearchResultVO vo new SearchResultVO(); vo.setId(s.getId()); vo.setTitle(s.getTitle()); return vo; }).collect(Collectors.toList()); }逻辑说明MATCH(title) AGAINST走全文索引IN NATURAL LANGUAGE MODE适合这种普通关键词搜索场景。ngram 默认把中文切成 2 字符的 token输入「周杰伦」会切成「周杰」「杰伦」能覆盖大多数搜索习惯。参数说明{0}是 MyBatis-Plus 的条件占位符最后拼出的 SQL 是MATCH(title) AGAINST(周杰伦 IN NATURAL LANGUAGE MODE)。注意wrapper.last(LIMIT 20)是直接拼接 SQL 尾部只适用于固定值不要在这里拼用户输入。搜索翻车最多的情况是搜“晴天”却把“晴天”“晴雨天”都搜出来这是自然语言模式的相似度打分问题。解决办法是改用IN BOOLEAN MODE加引号做精确匹配或者干脆先按LIKE %keyword%配合LOCATE()排序数据量小时性能并不差。搜索属于典型的功能对结果质量敏感但对性能不敏感的场景不要过早引入重型方案。4.3 封面与上传接口大小校验和存储路径设计封面图片和音频文件的上传逻辑不同封面要做尺寸压缩音频要做格式校验。上传接口常见的做法是单接口接收 MultipartFile按类型分支处理。PostMapping(/upload) public ResponseEntity? upload(RequestParam(file) MultipartFile file, RequestParam(type) String type) { if (file.isEmpty()) { return ResponseEntity.badRequest().body(文件为空); } String ext StringUtils.getFilenameExtension(file.getOriginalFilename()); if (audio.equals(type)) { if (!mp3.equalsIgnoreCase(ext) !flac.equalsIgnoreCase(ext)) { return ResponseEntity.badRequest().body(仅支持 mp3/flac); } } else if (cover.equals(type)) { if (!jpg.equalsIgnoreCase(ext) !png.equalsIgnoreCase(ext)) { return ResponseEntity.badRequest().body(仅支持 jpg/png); } } else { return ResponseEntity.badRequest().body(未知类型); } String datePath LocalDate.now().format(DateTimeFormatter.ofPattern(yyyy/MM)); String filename UUID.randomUUID().toString().replace(-, ) . ext; Path targetPath Path.of(/data/music, type, datePath, filename); try { Files.createDirectories(targetPath.getParent()); file.transferTo(targetPath); } catch (IOException e) { return ResponseEntity.internalServerError().body(保存失败); } MapString, String result new HashMap(); result.put(url, / type / datePath / filename); return ResponseEntity.ok(result); }逻辑说明transferTo是 Spring 封装好的落盘方法不需要手动关闭输入流。路径按「类型 / 年月 / UUID.扩展名」组织这样一年的文件都分区存放不会出现单个目录几万文件的极端情况。参数说明用 UUID 做文件名是为了避免中文文件名在 URL 编码上出问题以及重名覆盖的风险。Files.createDirectories会一次性创建多级目录不要用Files.createDirectory去逐级建。上传接口还必须限制文件大小Spring Boot 的默认限制是单文件 1MB音频显然不够。在application.yml里设置spring: servlet: multipart: max-file-size: 100MB max-request-size: 110MB参数说明max-file-size是单个文件上限max-request-size是整次请求的上限多文件上传时必须比单文件大。封面图片建议额外限制 5MB否则用户传一张 20MB 的 PNG 会让加载变慢。前端展示封面时再做一次懒加载体验会好很多。5. 避坑指南文件路径、音频格式、并发播放的三个经典翻车现场5.1 文件路径写进代码里换个环境就崩现象本地 Windows 开发测试一切正常部署到 Linux 服务器后所有上传和播放接口都报NoSuchFileException或者文件能传上去但播放 404。原因本地用的是D:/music/xxx这种绝对路径Linux 不认盘符更多人是在代码里直接写了System.getProperty(user.dir) /audio应用重启目录变化后就找不到文件了。解决把存储根目录抽成配置项放到application.yml里music: storage-root: /data/music代码里统一通过Value(${music.storage-root})读取并且Path.of(root, relativeUrl)的写法天然跨平台。换环境时只需要改配置不需要动代码。判断一个路径是否该做成配置的标准很简单换台机器部署要不要改代码要改就做成配置。5.2 MP3 比特率参差播放器卡顿其实是编码问题现象部分歌曲文件能播放但拖动进度条时卡顿明显或者播放到一半浏览器直接停止报MEDIA_ERR_SRC_NOT_SUPPORTED。原因不是服务器带宽问题也不是代码问题是音频文件本身编码格式不合规。很多从网上下载的 MP3 实际上是 VBR 可变比特率甚至有些文件是 MP3 容器里装 AAC 编码数据播放器的兼容性处理不好就会出现解码断流。解决上传时加一道转码流程常见做法是调用 FFmpeg 把上传文件统一转成 CBR 128kbps 的 MP3。转码命令大致如下ffmpeg -i input.mp3 -codec:a libmp3lame -b:a 128k -ar 44100 output.mp3参数说明-b:a 128k固定比特率-ar 44100统一采样率。这样处理后所有文件都是标准 MP3播放器兼容性最好代价是转码需要时间上传接口不再同步返回。常见做法是上传后给一个「处理中」状态转完再回写数据库和文件路径。如果不想引入 FFmpeg就在上传时校验文件头但效果远不如转码彻底。CBR 的价值还在于服务端可以精确计算播放进度对时间轴支持更友好。5.3 热歌并发播放Tomcat 线程被音频流占满现象某个热门歌曲同时几十人播放后首页接口、登录接口全部超时重启后恢复一会儿又卡死。原因代码里直接让 Controller 返回InputStreamResource音频流的发送过程占用了 Tomcat 的工作线程而随后的InputStream.skip()在transferTo期间线程会一直挂着。几十个音频流就能占满默认的 200 个线程池其余接口全部排队等线程。解决把音频文件转交给 Nginx 处理Java 接口只产出文件的 URL。Nginx 的静态文件服务对并发读非常友好而且支持sendfile不占用用户态内存。server { listen 80; server_name music.example.com; location /audio/ { alias /data/music/audio/; expires 7d; add_header Cache-Control public; add_header Accept-Ranges bytes; } }配置说明alias把/audio/前缀映射到磁盘目录accept_ranges开启分段支持expires 7d让播放器对同一首歌只加载一次。这样 Java 进程只负责下载播放接口返回歌曲元数据音频本体由 Nginx 用零拷贝机制发送占用的资源可以忽略不计。参数说明如果以后接入 CDN 或对象存储只需要把audio_url拼上对应域名不用改动业务代码。这是我推荐所有小型音乐系统都做的架构优化改动成本低但效果立竿见影。5.4 播放计数接口每次播放都 UPDATE热门歌一天写几十万次现象play_count字段更新频繁MySQL 慢查询日志里出现大量UPDATE song SET play_count play_count 1 WHERE id ?的全表扫描如果没有走主键则更严重。原因常见错误做法是播放接口里同步执行更新且没有缓存。每次播放都写一次数据库数据量大后行锁竞争激烈。解决播放计数走异步前端先调播放事件接口落一条play_record然后在内存或 Redis 里累加计数定期批量回写数据库。量级不大时不引入 Redis 也可以用一个ConcurrentHashMapLong, Integer做每首歌的计数缓冲每 60 秒 flush 一次到数据库。关键是把高频写和业务读路径剥离开让play_record表承担原始数据song.play_count只承担展示。很多项目翻车不是因为功能做不出来而是这种看似不起眼的高频写把数据库拖垮了。5.5 歌曲下架后音频文件还留在服务器上存储被拖垮现象运营把歌曲状态改成下架数据库查不到了但对应 MP3 文件还在磁盘上时间久了磁盘占用越来越高。原因下架操作只改了status没有联动处理音频文件。如果做物理删除又要小心用户歌单里收藏的歌一旦删除音频文件歌单里的条目就变成死链。解决设计两阶段策略。下架时只隐藏入口保留文件超过 30 天再执行一次物理清理任务清理条件为「该地址没有对应play_record且在回收站列表中的歌曲」。清理任务用定时调度在凌晨执行避免影响在线用户。很多刚做这类系统的人会忽略「软删除 延迟清理」的组合顺手把文件删了后来歌单功能一上线就后悔。6. 上线前必做的并发验证与存储策略先算峰值再选架构很多项目做完功能就急着上线结果第一次被多人使用就暴露问题。上生产前我习惯用并发测试跑一遍播放接口和上传接口。用wrk做一次简单压测wrk -t4 -c50 -d30s --timeout 2s http://localhost:8080/api/song/1/stream观察两个值Requests/sec和Latency。如果Latency在 50 并发下超过 500ms说明 Tomcat 线程处理音频流已经吃力该走 Nginx 静态分发方案了。这个压测的动作本身也是验证 Nginx 配置是否生效的好机会。存储这块如果单机磁盘不够用最平滑的升级路径是引入 MinIO 做对象存储Java 后端改用MinioClient上传播放地址直接走 MinIO 的预签名 URL 或通过 Nginx 反代 MinIO 的桶。改动范围只限于上传和播放两个接口业务层不用动。参数上特别注意 MinIO 的presignedGetObject默认 7 天过期做播放地址时要给足有效期。最后说一个我的习惯每次给播放器换文件路径策略都会先清空浏览器缓存再验证因为播放器对206 Partial Content响应有缓存容易出现「代码改了但还是旧文件」的错觉。尤其是拖动进度条、切歌这两个场景必须模拟弱网环境测一遍用 Chrome 的 Network 面板把网络调成 Slow 3G这样能最快暴露 Range 支持不到位的问题。系统上线后记得留一条监控定时扫描上传目录里「存在文件但数据库没有记录」的孤儿文件这是磁盘增长的主要来源。清理脚本要跑到凌晨业务低谷扫描时用文件的lastModified判断是否超过 30 天避免误删刚上传的文件。这个习惯帮我挡掉过多次磁盘写满的线上事故分享给你希望帮到你。本文还有配套的精品资源点击获取