基于SpringBoot的资源分享系统完整落地记录
编号 83534223,基于SpringBoot的资源分享系统。上周合作方发来一句话需求:“搞一个系统,能传资料、能找资料。”就这么九个字,我断断续续做了三周。别嫌标题朴实,这类系统做完之后,几乎所有模块——登录、上传、下载、搜索、权限、部署——都被串起来过了一遍,特别适合想搞懂SpringBoot后台实战的人参考。
先交代一下这个系统实际解决了什么问题:某内部团队需要把散落在微信群、邮件附件和个人电脑里的课件、论文、软件安装包统一起来,让成员能按分类浏览、关键词搜索、在线下载,同时管理员能审核资源、封禁违规账号、看下载数据。听起来像是“网盘平替”,但真做起来,坑几乎全部在后端设计上。
如果你正准备从纯CRUD项目往一个“能上线、能扛日常使用”的Web系统过渡,这篇能帮你把数据库模型、文件存储、并发下载、鉴权拦截这些思路一次性理顺。下面按我的实际开发顺序,把每个细节和踩坑都摊开写。
1. 先把需求拆干净:这不是一个网盘,而是一个带审核的内容平台
1.1 一句话需求背后的三个角色
“做一个资源分享系统,方便传资料、找资料”——这句话我拿到心里是发毛的,因为“方便”的定义因人而异。
于是第一周我干的事情不是写代码,是蹲着问了三个人:使用者、管理员、以及给钱拍板的那个人。问下来的结果,系统要服务的不是单一人群,而是三类角色,功能边界完全不同。
- 普通成员:注册、登录、按分类浏览资源列表,用关键词搜标题和简介,上传自己的文件,下载别人的文件,可以对资源打评分和留评论。
- 管理员:审核每一个新上传的资源,不合格的直接驳回;对已发布资源进行下架或删除;封禁恶意用户;查看每日下载量、上传量、分类热度。
- 游客:只能看到资源列表和介绍,不能下载,不能试看。
这个划分直接影响后面所有表结构和权限设计。比如“游客能看到列表但不能下载”,就要求资源查询接口和下载接口必须有独立的鉴权逻辑,而不能只靠一个全局登录拦截器。很多新手在这里偷懒,把列表和下载放在同一个Controller里,最后只能到处加if,代码越改越丑。
1.2 技术选型:为什么就是SpringBoot
这个项目定技术栈的时候,我列过三个候选:SpringBoot、Node.js、PHP。最终选SpringBoot,有三个决定性因素。第一,生态太成熟了,从权限框架到分页插件到文档生成,几乎每一个需求都能找到稳定的轮子,团队后续找人维护也容易上手。第二,团队里主要成员熟悉Java,后端出问题能自己排查,不必依赖外包。第三,Spring Boot的自动配置和约定大于配置,在按两周工期估算的情况下,把代码量压在可接受的范围内。
最终版本组合是:SpringBoot 2.7.14 + JDK 1.8 + MySQL 8.0 + Redis 6.x + MinIO文件存储 + Elasticsearch 7.x做检索 + P6spy做SQL打印。为什么不是SpringBoot 3.x?因为部分依赖插件当时还没完全适配,我不想花时间去解决插件兼容问题。这个组合在2023年是非常保守却稳妥的搭配。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 数据库和文件存储:决定系统上限的是这两张设计图
2.1 核心表结构是怎么定出来的
资源库这种系统,表结构说复杂不复杂,但有几个点必须想清楚。我最终设计了五张核心表:用户表、资源表、分类表、评论表、下载记录表。加上一个管理员操作日志表,用来追溯审核和删除操作。
用户表不赘述,密码字段用BCrypt加密,长度留60。这里有个新手容易踩的坑:用MD5存密码。对于内部系统,MD5一旦碰上稍微有点耐心的攻击者,撞库速度比你想象的快得多。
资源表是最需要仔细想的。字段设计如下:
- id,主键
- category_id,分类外键
- user_id,上传者
- title,资源标题
- description,资源简介
- file_name,原始文件名
- file_url,存储对象的访问路径
- file_size,文件大小,单位字节
- file_type,文件扩展名
- download_count,下载量
- score,平均评分(冗余字段,避免每次实时计算)
- status,状态:0待审核、1已发布、2已驳回、3已下架
- create_time / update_time,时间字段
- deleted,逻辑删除标记
这里重点说两个设计取舍。第一是status字段,它把整个流程串起来了:用户上传后进入待审核,管理员审核通过变成已发布,驳回后用户可以在前端看到被拒原因。第二是download_count,这个字段看起来是冗余,但非常必要,因为资源列表页、排行榜、管理员统计都要用它,如果每次请求都去count下载记录表,数据库压力会成倍增加。
下载记录表单独建,记录哪个用户、下载了哪个资源、什么时间下载、当时用的IP。这张表除了做行为审计,还能防止同行恶意扒资源——一旦同一个账号短时间下载频率异常,管理员能直接定位。我在代码里做了一个简单限制:单个账号每分钟最多下载10次,超过就返回太频繁,实际效果不错。
2.2 文件存储方案:MinIO而不是直接塞数据库
文件怎么存,是这类系统最关键的决策。有三种常见选择:直接存服务器本地磁盘、存OSS、自建MinIO。
直接存本地磁盘是最简单的,但问题很多:第一,磁盘空间有限,扩展要重新挂盘;第二,如果后续系统改成多实例部署,文件分散在不同机器,下载时不知道去哪个实例找文件;第三,备份困难。所以最初我直接否掉了本地存储,尽管它开发最快。
OSS云存储内部评估后放弃了,原因是涉及流量费用和云端凭证管理,对内部系统来说运维复杂度不划算。最后选了MinIO,用Docker部署,理由很朴素:它是开源方案,接口兼容S3协议,以后真要迁OSS,改动成本极低。
MinIO落地时先建一个桶,把桶访问权限设为私有,所有文件通过后端签名URL临时授权下载。上传的时候,由后端计算出唯一的对象名,规则是“yyyy-MM-dd/当天日期+随机字符串+原始文件名”,这样既避免文件名重复,也能在对象存储里按时间归档。上传完成后,数据库里只存对象路径,不存完整HTTP地址。这样以后换域名、换存储,数据库完全不用动。
2.3 分页查询和统计的一点点优化
列表页设计时,我用了MyBatis-Plus自带的分页插件,配合一个分类筛选条件。这里有一个性能上的细节:当数据量刚起步时,直接查资源表没问题,但一旦资源数上千,列表里关联很多字段会导致查询变慢。真正上线前的优化手段是只查询列表需要的字段:标题、封面、文件大小、下载量、评分,而把完整的description放到详情接口去查。
统计方面,我做了三张临时统计入口:今日上传数、今日下载数、分类下载排行。没有单独建统计表,而是用Redis的HyperLogLog来估算日活下载UV,用计数器记录PV。理由是这个内部系统一个月的数据量几千条,单表直接count也没啥问题。
3. 核心功能实现:登录、上传、搜索、下载这四个硬骨头
3.1 登录鉴权:JWT不如想象中省心
鉴权我采用了Spring Security + JWT的组合。用户登录后签发一个有效期两小时的AccessToken,再给一个有效期七天的RefreshToken。Redis里存一份当前用户的有效Token并进行校验,这样可以在管理员封禁用户时,直接把Redis里的记录删除,让token立刻失效。这个能力很关键,因为纯JWT无状态设计一旦签发,很难在封号时主动踢人。
密码这里再强调一次,用BCrypt,千万别用MD5。BCrypt的每次哈希都会自动加盐,同样的密码每次加密结果不同,即使数据库被拖走,撞库的难度完全不是一个量级。
拦截器逻辑比较标准:对所有/api/**路径放行登录接口、注册接口和资源列表接口,其余接口一律校验Header里的Authorization。校验失败返回401,前端拿到后跳转登录页。这里我踩过一个不太容易察觉的坑——跨域配置。前端项目跑在Vue的8080端口,后端跑在9090端口,必须配置CorsFilter允许指定来源和Authorization请求头,否则前端发请求时浏览器会直接拦截,后端日志什么都看不到,特别容易误判成后端挂掉。
3.2 资源上传:分片上传和秒传的生产级实现
上传模块是花时间最多的部分。
用户上传的文件,小到几百KB的文档,大到几个G的视频安装包。直接用普通的multipart上传,只要超过100MB就容易断线,尤其是校园网络环境,请求一中断整个文件作废,用户心态直接爆炸。所以上传模块我实现了分片上传。
分片上传的思路是一句话:前端把文件按固定大小切块,逐个上传,后端收齐所有分片后再合并成完整文件。我在这里定了每片5MB的分片大小:文件10GB以内都不会触发单请求体超限,同时分片数量控制在合理范围,不会因为片数太多导致合并时性能下降。前端先计算整个文件的MD5,拿这个值到后端查询,如果这个文件已经有人传过,直接返回“秒传”成功。服务端保存了一份分片记录表,记录了每个分片的序号、上传状态、合并状态。
核心的合并逻辑:
bash复制# 所有分片上传完成后,通过Java调用系统命令按序号拼接
cat /data/shards/{md5}-01 /data/shards/{md5}-02 ... > /data/upload/xxx.zip
注意:生产环境不建议用Runtime.exec拼接这种命令,因为文件名是用户可控的,容易造成命令注入。更稳妥的做法是使用Java NIO的FileChannel逐块写入,既安全又能向下兼容。我的实现是读取分片目录下全部文件,按序号排序后循环写入目标文件,代码量不大但很稳妥。
然后是SpringBoot的上传参数配置,这里有一个非常常见的坑:
yaml复制spring:
servlet:
multipart:
max-file-size: 100MB
max-request-size: 200MB
前端一旦单次请求超过这个值,SpringBoot会直接抛异常。分片上传时,单片请求只有5MB,所以这个配置不会成为瓶颈。要注意的是Nginx层的限制,服务器上Nginx默认的client_max_body_size是1MB,没有改的话,前端上传请求会在Nginx层就被拦截,后端日志里一个报错都看不到。这个点我在后文部署章节再展开。
3.3 全文搜索:别用MySQL的LIKE硬扛
资源分享系统的核心使用路径就是搜索,如果搜索结果不准确,系统基本白做。一开始我图省事,用MySQL的LIKE去匹配标题,结果被测出一个根本性问题:搜“分布式”搜不出“Distributed”,搜“教程”搜不出“tutorial”。用户经常用中文输入法却要匹配英文标题,需求的多样性远超预期。
最终引入Elasticsearch。建了一个独立索引,字段包含标题、简介、文件类型、上传者昵称。中文检索用IK分词器,同时开启了同义词过滤。数据同步方案考虑到项目规模没有上Canal,而是在上传审核通过后,调一次搜索服务的同步接口把数据推送到ES。如果同步失败,消息会进Redis队列,定时任务每分钟重试一次。
搜索接口返回的不只是命中列表,还做了两件体验优化:高亮匹配关键词,以及根据文件类型做过滤。比如用户选了“视频”类型,ES查询时加一个term过滤条件,这样前端就不需要把全量结果拉到内存里再过滤。
3.4 下载计数与积分并发:一个容易被忽略的稳定风险
下载接口看起来简单,但每一次下载要同时做三件事:下载次数加一、写下载记录、扣除用户积分。这里面有一个典型的并发问题。
沿用常规实现直接更新数据库:
sql复制UPDATE resource SET download_count = download_count + 1 WHERE id = ?
没有用先查后改的读改写逻辑,而是让数据库原子自增,这个是安全的。积分扣减也一样,使用条件更新来保证余额不为负数:
sql复制UPDATE user SET points = points - 2 WHERE id = ? AND points >= 2
受影响行数为0说明积分不足,需要提示用户充值。这种写法在SQL层面解决了并发问题,不需要Java代码里加锁,简单稳定。
日元栓积分的规则我定的是:上传一个资源得20积分,下载消耗2积分,系统初始给100积分。内部系统没必要搞复杂的积分商城,这个规则既能制止恶意下载,又不影响正常使用者。
4. 部署上线:Docker Compose一键拉起整个环境
4.1 容器化编排的一些细节
整个环境最终跑在Docker Compose里,共六个服务:后端jar包、前端静态资源Nginx、MySQL、Redis、Elasticsearch、MinIO。使用Docker Compose,是因为它把依赖关系声明好之后,新机器clone下来一条命令就能启动,后续加机器也能复制一套。
Compose文件里几个关键点:
yaml复制mysql:
image: mysql:8.0
environment:
MYSQL_ROOT_PASSWORD: xxx
MYSQL_DATABASE: resource_share
volumes:
- ./mysql/data:/var/lib/mysql
- ./mysql/conf:/etc/mysql/conf.d
command: --character-set-server=utf8mb4 --collation-server=utf8mb4_unicode_ci
字符集必须明确指定utf8mb4,字符集如果保持默认的utf8mb3,则emoji和生僻字无法写入。这个坑我遇到过,上线第二周有用户评论里带了一个emoji,接口直接报Data too long,排查了十分钟才想到是字符集问题。
Elasticsearch内存调整也要在编排文件里设置。默认堆内存会占机器一半内存,开发机只有8G,很容易触发OOM。所以Elasticsearch容器里我设置了环境变量ES_JAVA_OPTS为-Xms512m -Xmx512m,直接限制住。索引不多,实际常用的就一个,512M足够支撑常规搜索。
4.2 Nginx的两个隐藏配置
Nginx容器同时承担了静态资源托管和反向代理职责,这个配置里有两次占坑经历。
第一个是刚才提到的大文件上传限制。这个必须显式加上:
bash复制client_max_body_size 100m;
默认的1MB限制不调,上传100MB以上的视频请求直接413。
第二个是文件下载时的缓冲区。大文件下载时后端走MinIO签名URL重定向,用户浏览器拿到302后直接访问MinIO。为了避免Nginx在转发请求时读取整个响应进内存,加了以下几行:
bash复制proxy_max_temp_file_size 1024m;
proxy_request_buffering off;
proxy_buffering off;
如果不加,20个并发下载大文件时,Nginx的内存占用会飙升到好几个G,物理机直接卡死。
我记得某天下午压测的时候,一下载系统就CPU 100%,排查到最后就是proxy_buffering的问题。把缓冲关上之后,文件下载变成流式透传,内存占用立即掉了80%。
4.3 热数据缓存:Redis怎么用才算恰当
缓存最先想到的是存资源详情和热门列表。用Redis缓存首页推荐资源,key是recommend:list,缓存10分钟,用户发布新资源时主动删除,下次请求再回源。这块逻辑不复杂,但有几个边界条件要处理:缓存空值防止穿透、设置随机过期时间防止同时过期造成缓存雪崩、用互斥锁防止高并发下同时回源数据库。
这个项目里没有引入Caffeine做本地缓存,因为单机应用加本地缓存收益不大,反而要考虑一致性。Redis缓存加数据库兜底,对这个量级的系统已经足够。
5. 上线之后踩过的坑:常见问题排查与规避方法
5.1 问题速查表
| 现象 | 根因 | 解决办法 |
|---|---|---|
| 上传大文件报413 | Nginx的client_max_body_size未调 | 加到100M以上,重新加载Nginx |
| 接口返回乱码 | 数据库连接未指定utf8mb4 | 在JDBC URL加characterEncoding=utf8mb4 |
| 审核通过的资源搜不到 | ES数据同步失败 | 重跑同步任务,确认Redis队列没有堆积 |
| 用户突然全部提示登录过期 | JWT密钥配置变了或Redis被刷 | 检查yml中的jwt.secret,禁止动态生成密钥 |
| 下载大文件时Nginx内存暴涨 | proxy_buffering未关闭 | 在下载的location块加proxy_buffering off |
| 连接池报Connection is not available | 连接数超过maxPoolSize | 压测时把HikariCP最大连接数调到20 |
| MinIO文件删除后下载仍成功 | 私有桶签名URL过期时间太长 | 下载地址有效期设置为5分钟 |
| 管理员搜索乱码或搜索不到同义词 | IK分词器词典未加载 | 确认IK的远程词典更新,重启ES节点 |
5.2 结构之外的三个提醒
第一,定时清理任务必须有。分片上传经常会留下残缺文件:用户传了30个分片,第15个之后放弃了,那前15个分片就一直躺在磁盘上。我写了一个定时任务,每天凌晨三点扫描分片目录,超过24小时未合并的文件直接删除。这个任务上线以后,存储空间被回收了几个G。
第二,操作日志别省。管理员审核资源、驳回资源、下架资源,这些操作全部落日志表,记录操作人、操作时间、操作类型和备注。内部系统审计是保护开发者的,出了问题能查到责任人,很关键。
第三,做一个健康检查接口。暴露一个/api/heartbeat,返回当前时间、数据库连接状态、Redis连接状态、磁盘剩余空间。运维监控系统可以定期调用,发现异常及时微信告警。这个接口花十分钟,但省下来的排查时间是按小时计的。
5.3 上线前后的事前准备
上线前我做了两轮测试。第一轮是功能测试,把上传、下载、搜索、审核、封禁这些主流程走一遍,尤其验证了不同大小文件的下载完整性——用md5sum命令对比上传前和下载后的文件哈希,这个笨办法能检查是否出现传输丢字节的情况。第二轮是接口压测,用脚本模拟30个并发同时下载一个5GB的大文件,观察Nginx内存和后端CPU。
备份策略也要提前定。数据库每天凌晨全量备份到MinIO,保留最近7天;文件存储本身就是MinIO,定期用mc mirror把桶数据同步到另一台机器的独立目录里。内部系统数据量不大,用这种简单粗暴的备份方式完全可行。
6. 把这次实战沉淀下来的核心体会
系统上线稳定运行了一个多月,陆续又有一些小需求进来,像是给资源列表增加热度标记、给管理员增加按日期范围导出统计报表、给用户增加个人收藏夹。这些都是原有框架上扩展的活,因为当初把表结构和模块边界留得比较清晰,新增功能基本不伤筋骨。
回到开头的那个问题,“传资料、找资料”背后究竟有多少事?现在回头看,真正让系统不翻车的,不是某个高深算法,而是上传的超时处理、拦截器的细节、数据库字段的取舍、并发操作的原子性这些基础功夫。我最想跟你说的是:这种资源分享系统,别一开始就奔着什么高并发架构去,先把核心链路跑通、把文件搞不丢、把权限分清楚,上线之后再根据日志和数据去优化。前端展示可以后调整,但数据库设计和文件存储方案要在动手前就定死,中途改这两个地方,成本实在太高。
如果让我再做一遍,我会在一开始就给用户表加上头衔字段,给资源表加上封面地址字段。看似无关紧要的小扩展,可以省去后期为了页面美观而做的两张额外表。没有更好的设计,只有更早遇到需求的设计,这就是接项目的常态。
