先声明一句:我在实际把这类项目从零搭起来又反复拆掉重做的过程里,最深的体会是——“企业级”三个字在源码贩卖和简历里已经被用滥了。很多人拿到的所谓完整版,打开数据库脚本一看只有一张用户表,前端就三个页面,根本没有管理后台,这也能叫企业级?
所以这篇博客我不打算只把标题翻译一遍,而是围绕源码里真正值得讲的东西展开:业务功能边界怎么定、数据库表结构怎么设计、前后端联调会踩哪些坑、部署上线之后会遇到什么稀奇古怪的问题。参考的是我基于SpringBoot+Vue+MyBatis+MySQL这套经典组合做音乐网站管理系统的完整过程,用的是业内最常见的那套工程结构和开发节奏。
1. 先聊清楚什么叫“企业级”音乐网站:需求边界与技术选型的取舍
1.1 看起来唬人的需求,其实拆开就四块
任何一个音乐网站管理系统,本质上逃不出“内容管理”和“用户消费”两个大方向。用户能在前台听的歌、搜的歌、收藏的歌单,都是从后台录入进去的。所以哪怕标题吹得再天花乱坠,你需要落地的核心模块无非是:
- 前台门户:歌手展示、专辑列表、歌曲列表、歌单广场、排行榜、搜索、播放器、用户登录注册、评论与收藏。
- 管理后台:歌手管理、歌曲管理、专辑管理、歌单管理、用户管理、评论审核、数据统计看板。
- 基础支撑:文件上传(歌曲文件和封面图)、统一的权限拦截、统一的返回值格式、统一的异常处理。
- 非功能性需求:日志记录、数据库索引优化、图片与文件的安全校验、部署脚本。
这个边界一划清楚,你就会发现——它根本不需要微服务。很多学员上来就问我“要不要上Spring Cloud”,我的答案非常统一:不要。单体应用拆成微服务是有代价的,分布式事务、服务治理、链路追踪,每一个都需要额外的人力去维护,而音乐站的真实并发量在早期根本打不到需要微服务的程度。先用一个SpringBoot单体应用把业务跑通,比什么都实在。
1.2 为什么技术栈会落在SpringBoot+Vue+MyBatis+MySQL上
这套组合在今天看来不算新潮,但它的最大优势是“生态成熟、问题可查、招人容易”。
- SpringBoot解决了Spring配置地狱的问题,内嵌Tomcat,打成一个jar包就能跑,开发效率极高。2.7.x版本比较稳,SpringBoot 3对JDK版本、Java配置方式都有新要求,如果服务器还是JDK8,老老实实用2.7系列。
- Vue做单页应用非常舒服,组件化开发管理后台的效率很高,配合Element UI(Vue2)或Element Plus(Vue3)能快速搭出后台界面。管理端和门户端可以拆成两个前端工程,也可以放一个工程里用路由区分。
- MyBatis是持久层的稳妥选择。它不像JPA那样有“自动建表”的魔法,SQL都攥在自己手里,写复杂查询(比如排行榜、多表关联统计)的时候心智负担很小。配合MyBatis自带的二级缓存和第三方分页插件PageHelper,日常开发足够用了。
- MySQL免费、部署简单、文档多,存储结构化业务数据是它的强项。但注意,歌曲文件本身不要存MySQL,存文件路径或对象存储地址就行。
1.3 我用的工程目录结构
后端是标准的Maven多模块或单模块多包结构。前端我习惯拆成两个部分:music-portal(面向用户的门户,Vue3+Vite)和music-admin(后台管理,Vue3+Element Plus)。后端按功能分包:
com.example.music ├── controller # 接口层 ├── service # 业务逻辑层 ├── mapper # MyBatis的Mapper接口 ├── entity # 数据库实体类 ├── dto # 前后端交互对象 ├── vo # 视图返回对象 ├── config # 配置类(跨域、拦截器、文件上传大小等) ├── common # 统一返回结果、异常处理、工具类 └── MusicApplication.java这个分包方式没什么玄学,但是边界清晰:Controller只做参数接收和结果包装,Service只做业务判断,Mapper只做SQL交互。很多人喜欢在Controller里写一堆if/else,最后Service层形同虚设,那后面维护起来会想哭。
2. 音乐资源的“命根子”:数据库表结构与文件上传链路设计
2.1 核心表设计:不该省的字段一步到位
从源码的角度来看,表结构设计直接决定了你能往上叠多少功能。我见过太多半吊子项目把歌曲表建得跟流水账一样,歌手和歌曲都不分表,最后做歌手专辑关联查询的时候只能用字符串拼接。一套相对完整的表结构至少要有这些:
- user(用户表):主键、用户名、密码(加盐)、昵称、头像、邮箱、手机号、注册时间、状态。
- admin(管理员表):管理员账号、密码、角色标识、最后登录时间。把管理员和普通用户分开,权限体系才清晰。
- singer(歌手表):姓名、性别、头像、简介、地区、创建时间。
- album(专辑表):专辑名、歌手ID(外键)、封面图、发行时间、简介。
- song(歌曲表):歌名、歌手ID、专辑ID、时长、歌词、音频文件路径、播放次数、状态。
- song_list(歌单表):歌单名、创建者ID、封面、标签、简介、播放次数。
- song_list_song(歌单与歌曲关联表):歌单ID、歌曲ID。
- comment(评论表):用户ID、歌曲ID或歌单ID(冗余一个类型字段)、评论内容、评论时间、状态。
- collect(收藏表):用户ID、歌曲/歌单ID、类型、收藏时间。
你注意看,凡是多对多关系(用户收藏歌曲、歌单包含歌曲)都抽了关联表,而不是在一张表里堆逗号分隔的ID。这样后面统计用户收藏了多少歌、歌单里有哪些歌都是顺手的事。
建表时我会额外加上一句:
CREATE TABLE `song` ( `id` int NOT NULL AUTO_INCREMENT, `name` varchar(100) NOT NULL, `singer_id` int DEFAULT NULL, ... PRIMARY KEY (`id`), KEY `idx_singer_id` (`singer_id`), KEY `idx_name` (`name`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_general_ci;2.2 为什么歌曲文件、封面图不直接存MySQL
这一步很多人会走弯路。老有人在论坛问:歌曲文件能不能直接以Blob类型存进数据库?技术上当然能,生产环境千万别这么干。
数据库本身就是稀缺资源,它的强项是事务处理和索引查询,不是当文件服务器。几十G的音乐文件全部灌进MySQL的话,备份会变成噩梦,数据库连接会被大文件读写拖死,而且前端加载一个音频接口还要先查出二进制流再响应,性能极差。
正确的做法是:
- 文件(mp3、jpg、png)走本地磁盘目录或云对象存储。
- 数据库里只保存文件的URL路径或相对路径。
部署时我把上传目录统一放在某个固定路径下,再用Nginx做一个静态资源映射:
location /upload/ { alias /data/music/upload/; expires 7d; add_header Cache-Control "public"; }这样前端直接用https://你的域名/upload/song/xxxx.mp3就能访问到音频文件,同时还能蹭一下Nginx的静态文件处理能力,不用让Tomcat去读文件流,性能好得多。
2.3 文件上传链路的完整设计
前端上传组件选中mp3文件后,通过FormData提交到后端/api/admin/song/upload接口。后端要做的事不止是接住文件:
- 格式校验:只允许mp3、flac、wav、jpg、png等白名单后缀,不要用黑名单,因为新出的恶意扩展名你永远堵不完。
- 大小限制:音频文件最大20MB,图片最大5MB,在SpringBoot配置里同时限制
spring.servlet.multipart.max-file-size和max-request-size。 - 重命名:用UUID或时间戳+随机数重新生成文件名,避免用户上传的原始文件名包含中文、空格、特殊字符导致服务器路径解析异常。
- 分目录存储:按日期或类型分目录,比如
/upload/song/202506/、/upload/img/avatar/,避免单目录文件数量过多导致磁盘寻址变慢。 - 保存记录:文件落盘成功后,才向数据库插入歌曲记录,文件路径存相对路径。
核心代码大致长这样:
@PostMapping("/upload") public Result upload(@RequestParam("file") MultipartFile file, @RequestParam("songName") String songName) { // 校验后缀 String originalFilename = file.getOriginalFilename(); String ext = StringUtils.getFilenameExtension(originalFilename); if (!allowedExtSet.contains(ext.toLowerCase())) { return Result.error("不支持的文件格式"); } // 生成新文件名并保存 String newName = UUID.randomUUID().toString().replace("-", "") + "." + ext; String dateDir = new SimpleDateFormat("yyyyMM").format(new Date()); File dir = new File(uploadDir + "/song/" + dateDir); if (!dir.exists()) { dir.mkdirs(); } file.transferTo(new File(dir.getAbsolutePath(), newName)); // 保存数据库记录 songService.addSong(songName, "/upload/song/" + dateDir + "/" + newName); return Result.success(); }这里最容易踩的坑是file.transferTo()在不同系统下对目标路径的要求不一样,Windows下OK的路径到Linux上可能因为目录不存在直接抛IOException,所以mkdirs()一定要有,而且要放在transferTo之前。
2.4 数据库索引与查询优化
音乐网站最常见的两个查询场景:搜索歌曲和排行榜列表。
搜索接口一般用WHERE name LIKE CONCAT('%', #{keyword}, '%')去模糊匹配。如果表里的数据量到几万条,这个查询在没索引的情况下走全表扫描是能感觉出卡顿的。给歌曲名的字段加普通索引的同时,考虑用前缀索引或引入全文检索框架,但在数据量不大时普通LIKE配合索引覆盖已经够用。不过要注意,LIKE前置百分号会导致索引失效,数据量再大就要换Elasticsearch了。
排行榜可以用一个play_count字段存播放次数,然后:
SELECT id, name, singer_id, play_count FROM song ORDER BY play_count DESC LIMIT 10;这种高频统计接口,我建议在Redis里做缓存,每小时同步一次数据库即可,没必要每次请求都去执行聚合查询。如果不想引入Redis,至少在项目里加一个内存缓存,避免页面每刷新一次就查一次MySQL。
3. 后端从0到1:那些最容易被忽略的模块实现细节
3.1 用户密码处理:不要拿MD5裸奔
标题里的“管理系统”只要涉及到用户登录,密码存储就是个绕不开的话题。很多教学项目用的还是MD5(password)这种操作——这在今天的攻防环境下完全不够看,彩虹表一查一个准。
我按照业界标准做法做了一层加盐哈希:
// 注册时生成随机盐 String salt = UUID.randomUUID().toString().replace("-", ""); String hashed = DigestUtils.md5DigestAsHex((password + salt).getBytes()); // 数据库中存salt和hashed两个字段登录时取出用户对应的盐,再对输入的密码做同样的哈希,比对结果。严格说,生产环境更应该用BCrypt,它每次生成的哈希值不同,安全性更高。但接在MyBatis项目里时要注意,BCrypt需要额外的spring-security-crypto依赖,很多抱着“轻量管理后台”心态的项目不一定愿意引入Spring Security全家桶。所以我的建议是:如果项目已经用了Spring Security,直接用BCrypt;如果没引入,就用加盐MD5+登录令牌方案,至少比裸MD5强一个档次。
登录令牌我用的是一个UUID Token:登录成功后生成token,存Redis或内存Map里,前端后续请求在请求头里带token,后端写一个拦截器统一校验。简单、直观、对单体应用来说完全够用。
3.2 歌单与收藏的多对多关系:MyBatis的映射处理
歌单和歌曲是多对多,用户和歌曲的收藏也是多对多。用MyBatis来做这些关联查询,需要理清resultMap的写法。
举个例子,查询某个歌单的详情时,我希望返回歌单基本信息加上歌曲列表。sql大致是:
<select id="selectSongListDetail" resultMap="SongListDetailMap"> SELECT sl.id, sl.name, sl.cover, sl.description, s.id AS song_id, s.name AS song_name, s.singer_id, s.url FROM song_list sl LEFT JOIN song_list_song sls ON sl.id = sls.song_list_id LEFT JOIN song s ON sls.song_id = s.id WHERE sl.id = #{id} </select>然后定义一个连表映射的resultMap:
<resultMap id="SongListDetailMap" type="SongListDetailVO"> <id property="id" column="id"/> <result property="name" column="name"/> <result property="cover" column="cover"/> <collection property="songList" ofType="SongVO"> <id property="id" column="song_id"/> <result property="songName" column="song_name"/> ... </collection> </resultMap>这里的collection标签是MyBatis连表查询的核心。刚接触MyBatis的人最容易在这里踩坑——如果查询出来有重复的歌手ID或歌曲ID,collection里的子列表会出现重复元素。解决方式是确保外层主表的id映射正确,配合数据库查询排序让关联记录相邻,基本能避免重复问题。
我在这个项目里还碰到过一个让人印象深刻的细节:MyBatis的二级缓存默认是关闭的,但很多教学项目都会在配置里顺手打开cacheEnabled。这个开关开了之后,如果你在这张表上做增删改,却没有清掉缓存,用户查到的就是脏数据。音乐网站后台修改歌曲信息后前台迟迟不变,十有八九是这里的问题。我的建议是,项目里如果依赖实时性,别开二级缓存;非要优化性能,不如把热点数据放Redis里自己做失效策略。
3.3 排行榜和热度权重:一个容易被业务逻辑坑到的点
排行榜如果只按播放次数排,大概率会出现“老歌霸榜、新歌永远上不去”的局面,因为老歌积累的播放量是新歌没法追的。一个相对科学的做法是引入时间衰减因子,只统计最近30天或7天的播放量。
我在最初的实现里直接ORDER BY play_count DESC,结果榜单三个月没换过,产品和运营直接找上门。后来改成每天定时任务汇总当日播放量,再用热度公式:
热度 = 7天内播放量 * 1.0 + 30天内播放量 * 0.5 + 总播放量 * 0.1数据库结构上新增了一张hot_rank表,每天凌晨用定时任务跑一次统计,统计结果缓存起来。这样避免了实时计算对数据库的压力,排行榜也能保持一定的活跃度。这个改动不复杂,但对业务效果的提升非常明显——如果一个音乐管理系统连榜单都是死的,很难说服别人它是“能用”的。
3.4 MyBatis使用心得:日志打印和SQL排查
开发阶段一定要打印SQL日志,否则很难定位问题。在application.yml里配置:
logging: level: com.example.music.mapper: debug这个配置会把Mapper接口里每条SQL的执行参数和结果集大小都打印出来。排查MyBatis 单个数字字符比较这类问题的时候,日志能帮你快速确认是不是SQL里拼接出来的参数类型不对。比如一个状态字段在数据库里是char类型,你传0进去,SQL变成了WHERE status = 0,MySQL可能触发隐式转换导致索引失效——日志里看实际SQL一眼就明白了。
MyBatis-Plus的Wrapper用起来确实省事,但从源码学习角度我一直建议先把原生MyBatis本身跑明白,动态SQL、resultMap、手动实现多表关联,这些理解透之后再去用MP会觉得很多东西是透明的。项目里如果既有简单CRUD又有复杂查询,混合使用也没问题,但一定要在代码规范里约定好:简单操作可以用MP,复杂统计必须走XML里的手写SQL。
4. 前端开发的真实耗时点:Vue组件封装与播放器状态管理
4.1 前端工程的搭建与代码组织
前端这块很多人以为把Element Plus的表格和表单拼一拼就结束了,实际上最花时间的部分是播放器体验和状态同步。
我用的是Vue3+Vite+Pinia。工程结构:
music-portal ├── src │ ├── api # 接口请求封装 │ ├── assets │ ├── components # 通用组件 │ ├── router # 路由 │ ├── store # Pinia状态管理 │ ├── views # 页面组件 │ └── utils这里要特别注意vue.config.js或vite.config.js里的开发代理配置。前后端分离开发时,前端跑在5173端口,后端跑在8080端口,跨域是绕不开的。我用的是Vite的代理:
server: { proxy: { '/api': { target: 'http://localhost:8080', changeOrigin: true } } }好处是前端代码里写的/api/xxx请求路径在开发环境会自动转发到后端,同时规避了跨域。生产环境则由Nginx统一处理转发,这样一来前端代码里的API前缀可以写死成/api,不用区分环境。
4.2 播放器组件的封装与全局播放状态
门户端的核心体验就是播放器,不能每跳转一个页面播放器就重置。我的方案是:
- 做一个全局播放器组件挂在Layout的底部。
- 用Pinia维护播放状态:
playList(播放列表)、currentIndex(当前播放的歌曲索引)、isPlaying(播放状态)、isMuted(是否静音)。 - 所有触发播放的地方(歌曲列表、歌单详情、排行榜)都往store里
playSong(),而不是自己创建audio元素。
播放器组件内部的audio元素绑定好ended事件,一首歌放完自动播放列表里下一首。如果列表里最后一首放完了,自动停止或循环播放,按产品需求调。
要真正把播放器做成一个好用的全局组件,不能只做一个简单的<audio>标签。我封装时把进度条、音量、播放模式(顺序/循环/单曲)都做了。你有兴趣可以看看那些开源音乐播放器的实现,核心逻辑就是监听timeupdate事件更新当前播放进度,再通过currentTime设置跳转进度。
还有一个实用细节:音频预加载。列表页不要给每一首歌都写<audio>,浏览器会直接卡死。只创建一个audio实例,切换到不同歌曲时动态修改src并调用play()就能满足需求。
4.3 m3u8格式播放:一张音视频处理中绕不开的牌
最近在线技术社区里关于Vue播放m3u8格式的讨论很多,这也确实是音乐站和视频站都绕不开的场景。HLS流媒体协议使用的.m3u8是索引文件,里面记录了一串.ts分片文件的地址,所以浏览器原生video是不能直接播放m3u8的(Safari除外)。
我在做后台上传视频预览和门户端MV播放时,用的是hls.js这个库。在Vue组件里:
import Hls from 'hls.js' if (Hls.isSupported()) { const hls = new Hls() hls.loadSource(videoSrc) hls.attachMedia(videoElement) hls.on(Hls.Events.MANIFEST_PARSED, () => { videoElement.play() }) }这段逻辑必须放在onMounted钩子里,否则video元素还没挂载到DOM上,attachMedia会报错。我在正式环境里踩过这个坑——明明本地开发环境跑得好好的,打包部署到Nginx之后视频播放一片黑,最后查出来是hls.js文件太大导致加载慢,且onMounted时序混乱。后来调整了引入时机,把hls.js的加载延后到真正打开MV播放页时才动态import,问题就解决了。
4.4 后台管理页面的搭建心得:表格+表单+搜索条件
后台管理系统是整个“管理系统”的门面,所有歌曲、歌手、歌单、用户的增删改查都在这边完成。我用Element Plus的el-table、el-form、el-dialog组件组装了几套通用页面:
- 搜索条件区(关键词、时间范围、状态下拉框)
- 表格区(数据展示、分页)
- 操作列(编辑、删除)
- 新增/编辑弹窗表单
实际开发时最大的问题是重复代码多。每个管理模块都要写一遍搜索、分页、弹窗逻辑,容易枯燥还容易出错。我的做法是抽出几个通用组件:SearchForm、DataTable、Pagination,传不同的配置项进去。这套代码在多个管理页面里复用,后续新增一个“专辑管理”模块,前后端一共不到两个小时就能搞定。
关于vue 打包后布局异常这个经典问题,可能是历史最好的坑。根源通常有两个:一是打包后静态资源路径找不到,导致CSS和JS加载失败,界面全乱;二是路由使用了history模式但Nginx没有做try_files回退,刷新页面就直接404。这两个问题我都会在打包部署阶段提前处理,Nginx配置里加上:
location / { try_files $uri $uri/ /index.html; }这样不管用户刷新什么路径,都能正确回到前端入口,再由前端路由接管。
5. 权限、安全与性能优化:影响项目下限的“隐形工程”
5.1 基于拦截器的登录校验和基于角色的权限控制
管理系统不能没有后台权限控制,否则随便一个人访问/admin/user/list就能把用户数据拉走。我在项目里做了两套拦截器:
- 门户端拦截器:拦截
/api/user/**下的受保护接口(收藏、评论、获取自己的歌单),校验请求头里的token是否存在且有效。 - 管理端拦截器:拦截
/api/admin/**下的所有接口,校验管理员身份,同时根据角色判断是否有访问权限。
拦截器的实现核心是重写HandlerInterceptor的preHandle方法:
@Override public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception { String token = request.getHeader("Authorization"); if (!StringUtils.hasText(token)) { response.setStatus(401); return false; } // 解析token并检查有效期... return true; }SpringBoot里通过WebMvcConfigurer.addInterceptors注册拦截器,并指定排除路径,比如登录接口、歌曲列表接口、排行榜接口等不需要登录就能访问。这块有一个容易被忽略的地方:CORS预检请求(OPTIONS)不能被拦截,否则前端跨域请求会莫名其妙失败。
5.2 文件上传的安全检查:不止是后缀白名单
文件上传漏洞是管理后台的高危风险点。比如,攻击者上传一个内容为脚本的“图片”,通过某些解析漏洞让服务器执行它,整个后台就可能被拿下。
我的建议是:
- 检查Content-Type:接收文件时校验
file.getContentType(),虽然它有伪造可能,但能挡住大部分随手改后缀的低级攻击。 - 检查文件真实类型:对图片可以用ImageIO读取,读不出来就拒绝;对音频可以用JAudioTagger或FFmpeg识别文件头。
- 存储目录和可执行目录分离:上传目录放在Nginx静态资源配置的独立路径下,不作为任何脚本执行目录。如果用的是Tomcat部署,不要把上传目录放在
webapps下。 - 重命名文件:不使用用户原始文件名,避免路径穿越(
../../../)。
这些规则看起来简单,但每一条都对应着真实的安全事故。做管理系统的人如果不理解什么是任意文件上传漏洞,等于把自己的服务器门钥匙挂在大门口。
5.3 接口层面的统一返回与参数校验
一个“能用的系统”和“好用且好维护的系统”,分水岭就在这里。我在项目里定义了一个通用的Result<T>返回类:
{ "code": 200, "message": "操作成功", "data": {...} }所有Controller接口都返回这个对象,前端axios响应拦截器统一判断code,不是200就直接弹错误提示。这样好处是前端不需要每个请求都做容错,后端异常处理也统一了。
参数校验方面,登录名不能为空、密码长度不能低于6位、上传文件不能为空……这些校验我建议放在Controller层入口,而不是等Service层跑了一大堆逻辑之后才发现参数有问题。可以用JSR303注解,也可以手写判断,但手写判断一定要记得在第一个地方就Return,不要一路穿透到数据库层。
5.4 SQL注入与XSS:MyBatis天然防御之外的事
MyBatis使用#{}预编译能有效防止SQL注入,但它的XML里如果用了${}拼接,又没人管你输入什么,SQL注入漏洞就会重新出现。我给自己定的规矩是:除了动态传入表名或排序列名(比如ORDER BY ${sortColumn})时可以用${},其余一律不得出现。
XSS攻击方面,管理后台用户输入的评价、歌单简介等字段都可能在页面上被渲染为HTML。用户在评论里写个<script>标签,如果不做过滤,别的用户一打开页面就被执行了。我在后端写了一个简单的过滤工具,对提交的文本做转义处理,前端用Vue的插值表达式{{}}渲染时本来就有一定的转义防御,但后端的过滤不能省——因为前端校验可以被绕过,后端的防线才是真正的防线。
6. 从“能跑”到“跑得稳”:本地部署、数据库参数与线上故障
6.1 SpringBoot打包与部署的几种方式
项目正常开发完成后,本地能用还只算第一步,真正考验人的是把源码部署到服务器上还能稳定跑。
SpringBoot项目我一般打jar包:
mvn clean package -DskipTests java -jar music-server-1.0.0.jar --spring.profiles.active=prod生产环境的配置文件我单独放一个application-prod.yml,里面配置的是服务器的数据库连接地址、上传目录的绝对路径、日志级别等。这样开发环境和生产环境互不干扰,打包时也能用同一套代码。
Java服务在Linux上跑,我用systemd做守护进程:
[Unit] Description=Music Server After=network.target [Service] ExecStart=/usr/bin/java -jar /data/app/music-server.jar Restart=always User=root [Install] WantedBy=multi-user.targetRestart=always这个配置能保证进程挂掉之后自动拉起,避免半夜服务器被压垮网站上不去了,还得爬起来手动启动。前端打包后的dist目录直接丢到Nginx的静态目录,配合前面说的try_files配置,就可以正式对外服务了。
6.2 MySQL生产环境参数调整
本地开发环境MySQL配置越随意,上线之后踩的坑越深。有段时间我的本地项目运行得好好的,一到服务器上就频繁报Too many connections,查看日志才发现连接数默认151,而应用里配置的连接池是20,还没算上后台手动连接的session。于是我在生产库中把连接数调大:
max_connections = 500还有一个极其容易出现的问题是时区。MySQL 8.x默认时区是SYSTEM,SpringBoot连接字符串里如果没加serverTimezone=Asia/Shanghai,插入的时间就会比北京时间慢8小时。这个bug因为前后端展示的是东八区时间,一开始根本发现不了,直到有用户反馈评论时间显示错了才排查出来。
字符集也是一个大坑。数据库建库时如果用的是utf8而不是utf8mb4,存不了Emoji表情,用户昵称带个表情就报错。我建库时统一用utf8mb4,并在连接字符串上加上:
jdbc:mysql://localhost:3306/music?useUnicode=true&characterEncoding=utf8mb4&serverTimezone=Asia/Shanghai&useSSL=false&allowPublicKeyRetrieval=trueallowPublicKeyRetrieval=true是MySQL 8.0.3版本以后必须要加的,否则连接时会报Public Key Retrieval is not allowed。这个报错太经典了,排查过一圈的人看一眼就懂。
6.3 上线后真实遭遇的故障与排查过程
我这里分享两个真实发生在项目上线稳定期的问题,排查过程值得完整记录一下。
第一个是首页排行榜刷新很慢。一开始以为是SQL写得不对,EXPLAIN一看索引都命中了,单条查询只要几十毫秒。后来发现是页面里有个轮询接口每5秒调一次,长时间开着导致Tomcat线程池占满,用户访问其他接口全部排队。最后把轮询改成WebSocket推送或者延长间隔到30秒,一切恢复正常。问题本身不算难,但排查链路让我再次重视一件事:性能问题不一定是SQL问题,也可能是线程池和接口设计问题。
第二个是管理后台图片偶尔加载不出来。排查下来发现因为Nginx对静态资源的expires 7d配置,浏览器会把图片缓存7天,而后台编辑封面图后文件名没变(覆盖式上传),前端缓存里拿到的还是旧图。解决方案是上传新图时文件名带时间戳或随机数,让URL改变,彻底绕开浏览器缓存。做管理系统的人要理解HTTP缓存的脾气,不然这种问题能做半年。
结合nginx部署多个web项目这个话题,我只说一句:多个前端项目在同一个Nginx下,最好的隔离方式是通过不同的location前缀或不同server_name去分流。把门户站和后台管理站分别配置两个server块,静态资源的维护互不相干,回退也不用担心串台。
6.4 后续扩展方向:缓存、搜索、对象存储
如果这套源码真正跑到了成百上千的日活用户量级,有几个升级方向是明确的:
- 引入Redis:会话token、验证码、排行榜、热点歌曲列表全部可以缓存,能大大降低MySQL压力。
- 引入Elasticsearch:歌曲搜索超过5万条数据后,LIKE模糊查询已经有点吃力,ES的倒排索引才能撑起站内搜索的体验。
- 文件上云:本地磁盘总有满的一天,转到对象存储后上传下载都能扛流量,还能白嫖CDN加速。
这几个升级方向不是说现在就必须做,而是要让做项目的人心里有数——技术选型要有演进路径,不是一锤子买卖。
7. 关于源码本身和研发排期的一点实在话
最后分享一点关于源码实践和排期的经验,这是我带过好几轮项目后最想说的话。
很多初学者拿这套源码去求职面试的时候,会被问“这个项目是你自己从零写的吗?”如果你的回答含糊其辞,面试官立马就会追问各种细节。我建议拿到任何源码之后,不管是谁写的,都自己动手新建一个SpringBoot工程,把核心模块手敲一遍,尤其是用户登录认证、文件上传、权限拦截器、歌曲列表分页查询这四个部分。手敲一遍和用现成源码跑通一遍,掌握程度天差地别。这也是我认为任何一份完整版源码最重要的使用方式——它是地图,不是代步工具。
排期上,这类系统从需求分析到联调完成,两个人配合(一个前端一个后端)大概需要3到4周,其中后端表结构和接口设计大概花5天,前端门户和后台页面各花5天,前后端联调是最费时间的,预留至少一周比较合理。如果只有一个人全栈开发,时间翻倍不算夸张。别信那些培训机构说的“七天做出企业级项目”,七天的项目里藏着无数根悬浮的稻草,后续维护都会变成惊悚片。
做项目管理系统的过程里,我个人最大的体会是:这个系统真正难的从来不是某个单独的技术点,而是把上传、权限、缓存、部署这些琐碎的细节全部串起来后还能平稳运行。每一个模块看起来都不复杂,但它们之间的边界和交互才是坑最多的地方。希望这篇博客能让你在拿到或正在开发类似系统时,少走一段弯路。