
简介基于JAVASpringBootMySQL的音乐网站与分享平台设计与实现是一份面向本科毕业设计场景的完整项目设计文档适合计算机、软件工程等专业学生以及需要快速上手同类型Web项目的开发者。选题围绕在线音乐资讯、音乐翻唱、在线听歌、留言反馈等核心业务采用B/S架构与面向对象思想将系统划分为管理员与普通用户两类角色功能边界清晰后台管理覆盖用户管理、音乐资讯管理、在线听歌管理、留言板管理等模块。文档按照软件开发流程组织从需求分析、总体设计、数据库设计到详细实现均有说明重点展示了SpringBoot简化项目搭建、MySQL存储业务数据的具体做法可帮助读者理解音乐分享平台的模块划分与前后台交互流程。资源包仅含1个docx文件大小约5.28MB文件为完整论文正文包含中英文摘要、目录、系统设计图表与关键代码章节结构规范便于按章节查阅或作为毕设初稿修改。目前已有65人学习尤其适合正在开题、撰写设计文档或准备答辩参考的同学可根据自身课题需求在此基础上调整功能模块与页面设计。1. 音乐网站与分享平台先划清内容、关系与行为三类数据边界一个音乐播放页面用户每点一次播放、收藏一首歌、把歌单分享给朋友后端都会产生一串离散的动作更新播放计数、写入行为日志、校验分享关系、刷新排行榜。很多人拿到“基于JAVASpringBootMySQL的音乐网站与分享平台”这类需求时第一反应是先把页面做出来再去补表结构结果往往是接口越写越别扭连“这首歌被哪些人收藏过”都查不出来。原因很简单这个系统的复杂度不在页面而在数据建模。它至少包含三块彼此独立又互相引用的数据——歌曲、专辑、歌手构成的内容域用户、关注、歌单、分享构成的关系域播放、收藏、评论构成的行为域。MySQL承担的是这三类数据的最终一致性存储SpringBoot则负责把领域规则翻译成事务和接口。这篇文章就按数据建模、接口实现、查询优化、慢SQL诊断这条线把一套能落地的方案完整走一遍。2. 核心表结构歌曲、歌单与用户行为的MySQL建模2.1 为什么不能把所有字段塞进一张宽松的大表音乐网站最容易犯的第一个设计错误是把歌曲信息和歌手、专辑、歌词、播放地址全部放一张表理由是“查询方便”。等到要上线搜索功能、按歌手聚合统计、做歌单推荐时这种宽表会同时踩中三个问题一是更新放大改一个歌手名要UPDATE几千行歌曲记录二是索引膨胀为覆盖各种查询条件不得不建大量联合索引写入性能直线下降三是语义混乱歌单和歌曲是多对多关系宽表根本无法表达。常见的做法是遵循内容域、关系域、行为域分治的原则每个域独立建表域之间通过外键或逻辑外键关联。外键约束在业务量上来之后通常会去掉但建表初期保留外键有助于保证数据完整性等分库分表时再考虑迁移。与内容域相关的表还包括歌手表、专辑表和歌曲-歌手关联表。一位歌手可能有多首歌曲一首歌也可能有多个歌手合唱、Feat这种多对多关系必须用中间表表达不能靠逗号分隔的artistIds字段。中间表除了两个外键外通常会额外冗余一个is_main字段标记主唱便于列表页直接展示。从实践上看歌曲表应该尽量精简把歌词、音频URL、封面图这些大字段拆分到单独表或对象存储MySQL里只保留访问路径和文件大小避免大字段拖慢InnoDB的行存储性能。2.2 可运行的建表SQL从用户到播放记录的最小闭环下面是这套系统里最关键的五张表的建表语句。它覆盖了“用户注册-浏览歌曲-创建歌单-播放歌曲-分享歌单”这条核心路径所有字段都经过精简去掉了与主题无关的冗余字段。CREATE TABLE user ( id bigint NOT NULL AUTO_INCREMENT, username varchar(50) NOT NULL, password_hash varchar(255) NOT NULL, nickname varchar(50) DEFAULT NULL, avatar_url varchar(255) DEFAULT NULL, created_at datetime NOT NULL DEFAULT CURRENT_TIMESTAMP, updated_at datetime NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, PRIMARY KEY (id), UNIQUE KEY uk_username (username) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4; CREATE TABLE song ( id bigint NOT NULL AUTO_INCREMENT, title varchar(128) NOT NULL, duration int NOT NULL DEFAULT 0 COMMENT 时长(秒), play_count bigint NOT NULL DEFAULT 0 COMMENT 总播放次数, audio_url varchar(255) NOT NULL, cover_url varchar(255) DEFAULT NULL, status tinyint NOT NULL DEFAULT 1 COMMENT 1-上架 0-下架, created_at datetime NOT NULL DEFAULT CURRENT_TIMESTAMP, PRIMARY KEY (id), KEY idx_status_playcount (status, play_count) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4; CREATE TABLE playlist ( id bigint NOT NULL AUTO_INCREMENT, user_id bigint NOT NULL, title varchar(128) NOT NULL, description varchar(512) DEFAULT NULL, is_public tinyint NOT NULL DEFAULT 1, created_at datetime NOT NULL DEFAULT CURRENT_TIMESTAMP, PRIMARY KEY (id), KEY idx_user_id (user_id), CONSTRAINT fk_playlist_user FOREIGN KEY (user_id) REFERENCES user (id) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4; CREATE TABLE playlist_song ( id bigint NOT NULL 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, PRIMARY KEY (id), UNIQUE KEY uk_playlist_song (playlist_id, song_id), KEY idx_song_id (song_id) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4; CREATE TABLE play_history ( id bigint NOT NULL AUTO_INCREMENT, user_id bigint NOT NULL, song_id bigint NOT NULL, play_time datetime NOT NULL DEFAULT CURRENT_TIMESTAMP, source varchar(20) DEFAULT NULL COMMENT 播放来源: song/playlist/search, PRIMARY KEY (id), KEY idx_user_time (user_id, play_time) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4;这套建表语句里有几个值得展开的细节。第一user表单独建了uk_username唯一索引而不是把 username 直接设为主键这样做的目的是降低聚簇索引的写放大——业务上用户名允许修改而主键一旦变更代价极高。第二play_hist表是典型的行为域数据它只做追加写入几乎不更新所以索引设计上直接按照“查某个用户最近播放了哪些歌”这个高频查询建(user_id, play_time)联合索引查询时WHERE user_id ? ORDER BY play_time DESC LIMIT 20可以直接走索引避免排序。第三playlist_song表用了唯一约束uk_playlist_song(playlist_id, song_id)这一步非常关键。业务上一个歌单不能重复添加同一首歌这个约束在应用层做判断会有并发漏洞——两个请求同时提交时可能都通过检查导致重复数据落库。数据库唯一约束是幂等性的最后一道防线后面讲分享接口时还会用到同一思路。值得注意的是sort_order字段它专门用来记录歌曲在歌单中的排序用户拖拽调整顺序时更新这一个字段即可不需要重排列。2.3 多对多关系表里的联合索引顺序取决于查询方向在playlist_song这类关系表中索引设计最忌讳平均用力。每次只能有一个查询方向使用联合索引最左前缀另一个方向必须靠辅助索引回表。上面建表语句中已经有了两个索引uk_playlist_song(playlist_id, song_id)和idx_song_id(song_id)。这两个索引用处的分工是第一个索引服务“查某个歌单里的所有歌曲”where条件只带playlist_id就能走最左前缀第二个索引服务逆向查询“这首歌被加进了哪些歌单”。这里有一个常见误用就是给(playlist_id, song_id)和(song_id, playlist_id)各建一个联合索引认为这样两个方向都优化了。实际效果是两个索引都只能命中一个查询场景还白白增加写入开销。正确的做法是存一个联合索引另一个场景用单列索引就够了因为歌曲侧查询通常是分页拉取单列索引扫描后回表取playlist_id完全可接受。提示中间表的联合索引列顺序应该把区分度高、查询条件固定的列放前面。比如这里 playlitst_id 总是参与等值查询song_id 参与范围或排序所以 playlitst_id 在前符合最左前缀原则。3. SpringBoot实现层播放与分享接口的事务、并发与幂等3.1 播放接口的Service写法和事务边界播放接口是整个系统中读写最频繁的操作它同时涉及行为域写入和内容域更新。一个朴素版本的实现是先给song表的play_count加1再往play_history插一条记录最后返回歌曲的播放地址。这三个动作放在一个事务里看起来没问题但实际上有一个隐藏性能风险play_count 所在的行是热门歌曲的“热点行”所有用户的播放都会去更新同一行InnoDB 的行锁竞争会非常激烈。常见的应对思路是把计数器更新与历史记录写入拆开历史记录是纯追加不需要和计数强一致可以放到事务外的异步队列里。Service public class PlayServiceImpl implements PlayService { Autowired private SongMapper songMapper; Autowired private PlayHistoryMapper playHistoryMapper; Autowired private RedisTemplateString, String redisTemplate; Override Transactional(rollbackFor Exception.class) public String play(Long userId, Long songId, String source) { // 1. 更新歌曲播放次数使用SQL层面的原子自增 songMapper.incrementPlayCount(songId); // 2. 写入播放历史属于行为域与计数更新在同一个事务 PlayHistory history new PlayHistory(); history.setUserId(userId); history.setSongId(songId); history.setSource(source); playHistoryMapper.insert(history); // 3. 同步到Redis热榜分值这里利用了Redis自增的原子性 redisTemplate.opsForZSet().incrementScore(hot_song_rank, songId.toString(), 1); // 4. 返回播放地址 Song song songMapper.selectById(songId); return song.getAudioUrl(); } }Update(UPDATE song SET play_count play_count 1 WHERE id #{songId}) int incrementPlayCount(Param(songId) Long songId);这段代码的事务边界值得仔细推敲。Transactional注解包裹了计数更新和历史写入保证这两个动作要么都成功要么都失败。Redis的ZSet计数没有放在事务里原因是Redis不支持与MySQL的分布式事务放进事务内反而占用数据库连接时间。如果Redis写入失败热榜数据会暂时缺失可以由定时任务比对play_history重新统计补偿而播放次数和历史的准确性由数据库兜底。使用SQL原子自增而不是先select后update是为了避免并发下丢失更新问题play_count play_count 1在InnoDB的行锁保护下天然线程安全。提示Transactional(rollbackFor Exception.class)必须显式声明rollbackFor否则Spring只对RuntimeException回滚受检异常不会触发事务回滚这是生产事故最常见的来源之一。3.2 分享接口用唯一约束保证幂等分享功能表面上只是insert一条share记录但真正实现时要考虑一个场景用户反复点击分享按钮或者前端重试导致同一内容被分享多次。如果分享成功后每次点击都创建新记录用户的时间线里会出现同一条分享刷屏。解决这个问题有两条路应用层做请求去重或者数据库层面做唯一约束。实际项目中两者都会做但数据库约束是最后防线。CREATE TABLE share_record ( id bigint NOT NULL AUTO_INCREMENT, user_id bigint NOT NULL COMMENT 分享者, song_id bigint NOT NULL COMMENT 被分享的歌曲, target_user_id bigint NOT NULL COMMENT 接收分享的用户, message varchar(255) DEFAULT NULL COMMENT 附言, created_at datetime NOT NULL DEFAULT CURRENT_TIMESTAMP, PRIMARY KEY (id), UNIQUE KEY uk_share_onece (user_id, song_id, target_user_id) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4;Override public boolean shareSong(Long userId, Long songId, Long targetUserId, String message) { ShareRecord record new ShareRecord(); record.setUserId(userId); record.setSongId(songId); record.setTargetUserId(targetUserId); record.setMessage(message); try { shareRecordMapper.insert(record); return true; } catch (DuplicateKeyException e) { // 唯一键冲突说明之前已经分享过幂等返回成功 log.info(duplicate share record, userId{}, songId{}, targetUserId{}, userId, songId, targetUserId); return true; } }uk_share_onece这个联合唯一索引在语义上表达了“同一个人对同一首歌只能分享给同一个人一次”的业务规则。捕捉DuplicateKeyException后返回true而不是抛异常是因为重复提交这个动作在业务上视为成功用户不需要感知到后端拒绝了第二次点击。这种用数据库约束做幂等最终防线的做法比在应用层先select再insert要可靠得多它不受并发时序影响。配合Spring的Transactional使用时要特别注意如果插入发生在事务内捕获DuplicateKeyException后事务状态可能被标记为rollback-only这时即使方法返回事务提交时仍会抛出UnexpectedRollbackException。修复手段是把幂等插入的mapper方法声明为REQUIRES_NEW传播级别让它在独立事务中执行。3.3 SpringBoot事务自调用失效与回滚失效排查事务是SpringBoot开发里的老生常谈但面试八股文里的“自调用失效”在真实项目里照样层出不穷。自调用失效指的是同一个类中方法A调用方法BB上面标着Transactional但B的事务不会生效。原因是Spring的事务基于动态代理只有外部调用会经过代理对象内部this调用直接执行原始方法。常见触发场景是播放接口里把play()方法拆成updatePlayCount()和insertHistory()两个带事务注解的私有方法互相调用。解决办法有两个把需要事务边界的方法放到另一个Service类里或者注入ApplicationContext后通过代理对象调用。另外一类回滚失效的原因是数据库表引擎不是InnoDB。MySQL的MyISAM引擎不支持事务Transactional注解在它上面没有任何效果。排查时可以执行SHOW TABLE STATUS WHERE Name song查看Engine字段如果显示MyISAM则需要ALTER TABLE song ENGINE InnoDB迁移。这类问题在生产环境往往表现为“数据写进去了但没回滚”比事务报错更难发现。4. 热搜榜单与深度分页MySQL查询优化的两个典型战场4.1 热歌榜单为什么不能直接ORDER BY play_count排行榜功能在数据量小的时候看起来很简单SELECT * FROM song ORDER BY play_count DESC LIMIT 50就够了。但在这个系统里这个查询有三个问题一是冷热不均热门歌曲的行被高频更新查询与更新互相竞争InnoDB的行锁和Buffer Pool二是排序代价高当歌曲表达到几十万行后ORDER BY play_count需要走filesort回表读取全量行再排序耗时可能到秒级三是无法表达时间窗口比如“本周热歌榜”需要额外的统计字段或复杂where条件。常见的做法是用Redis的ZSet维护实时榜单MySQL的song表只作为持久化存储。// 写入侧每次播放时已经执行过 incrementScore // 读取侧取热度前N名 public ListSong getHotSongs(int topN) { // 1. 从Redis取TopN的歌曲ID SetString songIds redisTemplate.opsForZSet() .reverseRange(hot_song_rank, 0, topN - 1); if (songIds null || songIds.isEmpty()) { return Collections.emptyList(); } // 2. 按ID批量回MySQL查歌曲详情 ListLong ids songIds.stream().map(Long::valueOf).collect(Collectors.toList()); return songMapper.selectBatchIds(ids); }// 兜底策略每半小时从play_history重新统计一次防止Redis数据丢失 Scheduled(fixedDelay 30 * 60 * 1000) public void rebuildRankFromDB() { ListSongRankDO rankList playHistoryMapper.selectHotSongsSince(LocalDateTime.now().minusDays(7)); String cacheKey hot_song_rank; redisTemplate.delete(cacheKey); rankList.forEach(item - redisTemplate.opsForZSet().add(cacheKey, item.getSongId().toString(), item.getPlayCount())); }这个方案的成功之处在于把“实时计数”和“历史统计”的两个诉求区分开了。Redis负责实时热榜的高并发读写MySQL负责最终一致性和历史回溯。rebuildRankFromDB定时任务的意义不只是Redis宕机恢复还有一个实用功能是让榜单维度灵活改成minusHours(24)就可以出24小时热榜不需要额外建表。实际运维时要注意Redis的内存开销ZSet的score使用Double类型播放次数超过2的53次方才有精度问题音乐网站根本不需要担心。4.2 深度分页优化从LIMIT 100000到游标后台管理页或用户“我的歌单”里经常需要分页查询。MySQL最典型的分页写法是LIMIT offset, size但数据量上去后会有明显的深翻页问题偏移量越大MySQL需要扫描并丢弃越多行。执行SELECT * FROM playlist_song ORDER BY id DESC LIMIT 100000, 20时InnoDB会先扫描100020行再只返回最后20行前10万行全部白读。性能特征是查询耗时随页码递增到后面几页直接卡死。针对这个场景业界最常见做法是从偏移量分页改成游标分页也叫Keyset Pagination。-- 改进前深翻页慢 SELECT id, song_id, sort_order FROM playlist_song WHERE playlist_id ? ORDER BY id DESC LIMIT 100000, 20; -- 改进后游标分页传入上一页最后一条的id SELECT id, song_id, sort_order FROM playlist_song WHERE playlist_id ? AND id #{lastId} ORDER BY id DESC LIMIT 20;游标分页的SQL理解起来非常直白每次查询只找比上一页最后一条记录id更小的数据因为id主键上有聚簇索引WHERE id ?直接走索引定位不需要扫描任何被跳过的行。这样无论翻到第10页还是第10000页查询耗时都保持恒定。代价是无法直接跳转到第10页因为必须知道第9页最后一条记录的id才能请求第10页。社交App的信息流、评论列表、播放历史这类场景都天然适合游标分页因为用户习惯是向下滑动而不是跳页。实际落地时通常把lastId放在接口响应体里返回给前端前端下次请求时带上。注意如果排序字段不是唯一主键比如ORDER BY play_count DESC游标分页需要额外加上id作为第二排序条件否则会出现同一分数跨页时数据重复或丢失的问题。排序条件写成ORDER BY play_count DESC, id DESC游标条件写成(play_count ?) OR (play_count ? AND id ?)。4.3 用EXPLAIN验证索引是否真正生效索引优化不能靠猜EXPLAIN是必须掌握的验证工具。下面用两个典型SQL对比说明。第一个是查询歌单歌曲数量EXPLAIN SELECT COUNT(*) FROM playlist_song WHERE playlist_id 123;执行计划里type字段通常显示refkey字段显示uk_playlist_songrows估算值应该远小于全表行数。注意extra字段如果是Using index说明查询只扫描了索引而没有回表这是最高效的状态。如果看到type为ALL说明走了全表扫描立刻检查where条件里的列是否被索引覆盖。第二个例子是最近播放列表的关联查询。很多人习惯用IN子查询查最近播过的歌但MySQL优化器对IN子查询的支持在不同版本里行为差异较大一个常见实践是拆成两条独立SQL在Java里做内存拼接。如果一定要用JOIN要注意驱动表的顺序用小表驱动大表EXPLAIN SELECT s.id, s.title, h.play_time FROM ( SELECT id, song_id, play_time FROM play_history WHERE user_id 1001 ORDER BY play_time DESC LIMIT 20 ) h JOIN song s ON h.song_id s.id ORDER BY h.play_time DESC;play_history的(user_id, play_time)联合索引直接支撑了where和order by两项操作子查询先取20条记录再关联song表回表查详情规避了大范围JOIN。如果担心MySQL5.7对派生表的优化策略导致临时表开销也可以直接在Java层面执行两条独立查询业务逻辑同样清晰。对于5年以上经验的开发者看EXPLAIN时除了关注type、rows、Extra这老三样建议额外关注filtered字段——它表示存储引擎层返回的数据经过where条件过滤后还剩百分之多少filtered过低说明索引下推或联合索引的设计有问题。5. 从一条慢查询日志开始调整复合索引并验证效果5.1 复现慢查询与定位瓶颈假设线上监控发现一条慢查询SELECT * FROM song WHERE status 1 ORDER BY play_count DESC LIMIT 10平均耗时1.8秒。这条SQL的意图很明确就是取上架歌曲中播放量最高的10首歌。先执行EXPLAIN看执行计划EXPLAIN SELECT * FROM song WHERE status 1 ORDER BY play_count DESC LIMIT 10; -- 关键结果: -- type: ref -- key: idx_status_playcount -- rows: 52341 -- Extra: Using index condition; Using filesort执行计划显示命中了idx_status_playcount索引但rows高达5万行且Extra中出现了Using filesort。这说明一个很典型的问题当status 1匹配的行数很多时MySQL需要把这5万多行的play_count提取出来做排序然后只取前10条。idx_status_playcount(status, play_count)这个索引虽然在物理上已经把数据按status和play_count排序了但由于ORDER BY用在了索引的第二列且第一列是等值条件此时利用索引排序的原理是通过索引顺序直接扫描不需要filesort。为什么这里没有利用上关键点在于索引设计之初是面向播放列表管理的不是面向排行榜的。如果确认“查上架歌曲的播放榜”属于高频场景可以直接修改联合索引为更精确的形态。把(status, play_count)调整成(status, play_count, id)其中id列作为第三排序键这样排序时如果play_count相同可以按id稳定排序。更重要的是在status的等值匹配下索引的叶子节点已经按play_count有序排列执行器只需要从第一个满足status条件的叶子节点向后顺序扫描10条记录直接返回彻底消除filesort。这个优化思路在处理“普通筛选排行榜”类需求时非常通用本质是用索引的有序性抵消排序操作。5.2 验证调整后的效果与观察指标ALTER TABLE song DROP INDEX idx_status_playcount, ADD INDEX idx_status_playcount_sort (status, play_count DESC, id DESC);修改索引后重新执行EXPLAIN重点观察两处变化Extra字段从Using filesort变为空说明排序被下推到索引扫描过程中完成rows从5万降到10左右因为SQL只需要扫描10行就能返回结果。从业务视角看这个改动最直接的价值是解决了play_count分布不均时冷热数据查询性能的极端抖动。执行计划里rows的估值变化比单次查询耗时更有参考价值因为MySQL优化器会根据估算行数决定是否走索引、选择哪条索引rows下降意味着成本模型做出正确选择。5.3 用覆盖索引写出“免回表”的列表接口最后一个实战技巧是关于列表接口的。歌单详情页需要展示歌曲标题、时长、封面封面URL涉及song表的多个字段且必须按歌单排序。这类需求如果每次都SELECT *即使主键走聚簇索引也需要读取完整行数据。一个直接有效的优化是设计一个覆盖索引让查询的所有字段都在索引里-- 歌单页高频查询的覆盖索引设计 ALTER TABLE song ADD INDEX idx_playlist_view (id, title, duration, audio_url);这样执行计划中Extra出现Using indexInnoDB直接从索引叶子节点读取所需列不做回表。需要注意的是覆盖索引不是越多越好每多一个索引就是多一份写入成本建议只对明确的超高频率查询场景做这个优化。用SHOW INDEX FROM song查看现有索引列表删除与新建索引重复的冗余项别让优化变成另一个性能问题。本文还有配套的精品资源点击获取