简介:基于JAVA+SpringBoot+MySQL的音乐网站与分享平台设计与实现,是一份面向本科毕业设计场景的完整项目设计文档,适合计算机、软件工程等专业学生以及需要快速上手同类型Web项目的开发者。选题围绕在线音乐资讯、音乐翻唱、在线听歌、留言反馈等核心业务,采用B/S架构与面向对象思想,将系统划分为管理员与普通用户两类角色,功能边界清晰,后台管理覆盖用户管理、音乐资讯管理、在线听歌管理、留言板管理等模块。文档按照软件开发流程组织,从需求分析、总体设计、数据库设计到详细实现均有说明,重点展示了SpringBoot简化项目搭建、MySQL存储业务数据的具体做法,可帮助读者理解音乐分享平台的模块划分与前后台交互流程。资源包仅含1个docx文件,大小约5.28MB,文件为完整论文正文,包含中英文摘要、目录、系统设计图表与关键代码章节,结构规范,便于按章节查阅或作为毕设初稿修改。目前已有65人学习,尤其适合正在开题、撰写设计文档或准备答辩参考的同学,可根据自身课题需求在此基础上调整功能模块与页面设计。
1. 音乐网站与分享平台:先划清内容、关系与行为三类数据边界
一个音乐播放页面,用户每点一次播放、收藏一首歌、把歌单分享给朋友,后端都会产生一串离散的动作:更新播放计数、写入行为日志、校验分享关系、刷新排行榜。很多人拿到“基于JAVA+SpringBoot+MySQL的音乐网站与分享平台”这类需求时,第一反应是先把页面做出来,再去补表结构,结果往往是接口越写越别扭,连“这首歌被哪些人收藏过”都查不出来。原因很简单:这个系统的复杂度不在页面,而在数据建模。它至少包含三块彼此独立又互相引用的数据——歌曲、专辑、歌手构成的内容域;用户、关注、歌单、分享构成的关系域;播放、收藏、评论构成的行为域。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`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; 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`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; 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`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; 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`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; 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`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;这套建表语句里有几个值得展开的细节。第一,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 RedisTemplate<String, 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`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;@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调用方法B,B上面标着@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 List<Song> getHotSongs(int topN) { // 1. 从Redis取TopN的歌曲ID Set<String> songIds = redisTemplate.opsForZSet() .reverseRange("hot_song_rank", 0, topN - 1); if (songIds == null || songIds.isEmpty()) { return Collections.emptyList(); } // 2. 按ID批量回MySQL查歌曲详情 List<Long> ids = songIds.stream().map(Long::valueOf).collect(Collectors.toList()); return songMapper.selectBatchIds(ids); }// 兜底策略:每半小时从play_history重新统计一次,防止Redis数据丢失 @Scheduled(fixedDelay = 30 * 60 * 1000) public void rebuildRankFromDB() { List<SongRankDO> 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字段通常显示ref,key字段显示uk_playlist_song,rows估算值应该远小于全表行数。注意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 index,InnoDB直接从索引叶子节点读取所需列,不做回表。需要注意的是覆盖索引不是越多越好,每多一个索引就是多一份写入成本,建议只对明确的超高频率查询场景做这个优化。用SHOW INDEX FROM song查看现有索引列表,删除与新建索引重复的冗余项,别让优化变成另一个性能问题。
本文还有配套的精品资源,点击获取