
1. 为什么自建个人云盘以及我为什么锁定了Spring Boot先交代一下背景。我一直有囤资料的习惯论文、电子书、工作文档、各种安装包加起来快2个T先后试过几家主流的免费网盘体验都不太满意免费容量越给越小下载速度一到高峰期就“战术性”变慢偶尔还会遇到文件被“审核”然后莫名消失的情况。折腾了一圈我决定自己搭一套个人云盘系统核心诉求就三个数据在自己手里、速度不受限、想怎么扩展就怎么扩展。市面上其实有现成的开源云盘像NextCloud、Seafile、ownCloud这类功能确实很全部署也算方便。但我的情况比较特殊手头有几台性能一般的云服务器内存只有2G到4G跑NextCloud全家桶有点吃力更关键的是我需要一个能完全按自己需求改、代码逻辑一目了然的系统方便后续往里面加“按年月自动归档”、“重复文件清理”、“定时备份”这些个性化功能。所以最终决定自己写技术栈锁定Spring Boot。为什么是Spring Boot而不是PHP或Node说句实在话在这个场景下Spring Boot有几个不可替代的优势。第一生态足够成熟文件上传下载、权限管理、定时任务、消息队列这些都有现成方案遇到问题搜一下基本都能解决第二Java内存管理和并发处理更稳我实测过用Spring Boot处理5G以上的大文件下载内存抖动明显比Node方案小第三后期如果想把单机版升级成带分布式存储的版本Spring Cloud那一套可以直接接上迁移成本低。这里多说一句关于“源码数据库文档”的价值。很多人拿到一个开源项目习惯性先看代码但我建议先读文档和数据库脚本。数据库表结构能直接反映设计者的思路——哪些信息被独立成表哪些字段被冗余出来表与表之间怎么关联这些都搞清楚了代码看起来会顺畅得多改起来也有的放矢。这篇博文我不打算只贴代码而是把设计思路、核心实现、部署过程和踩坑记录都串起来讲一遍希望拿到源码的同学能真正理解它而不是只会启动一下跑个demo。2. 系统的技术架构与存储策略先定方案再写代码2.1 整体技术栈选择与分工搭建这个项目之前我先把技术选型定了下来避免写代码过程中反复改。后端Spring Boot 2.7.x MyBatis Plus MySQL 8.0 Redis前端Vue 3 Element Plus做管理界面打包后放进Spring Boot静态目录存储本地磁盘直接存储小规模场景性价比最高后面细说鉴权JWT Redis缓存Session构建Maven前端单独用Vite构建Spring Boot版本这里要提醒一下不要一上来就用最新版最好在2.5到2.7之间选一个稳定的。因为很多辅助库的兼容性还没跟上新版本我身边就有同事因为Spring Boot 3.x和某些MyBatis Plus版本不兼容折腾了大半天。我这个项目用的是2.7.6实测很稳。数据库层面我选了MySQL 8.0主要看中它的窗口函数和JSON类型后面做“目录树查询”和“文件标签扩展”会方便很多。Redis在这里不是必需的但它干两件事很值一是存JWT的黑名单用户退出登录后token能立刻失效二是做文件秒传的MD5索引缓存这个后面细讲。2.2 存储策略本地磁盘 vs 对象存储 vs MinIO这是很多自建云盘的人最纠结的地方。我当时列了个对比表方案部署复杂度成本扩展性适用场景本地磁盘零部署仅硬盘成本差受单机容量限制个人使用、家庭NAS场景云厂商对象存储阿里云OSS等低按API接入按容量和流量计费极好无限扩展有公网对外服务需求、需要高可用MinIO自建对象存储中等需单独部署服务服务器成本好可集群化数据量大且想保留私有化优势我的建议是初期就用本地磁盘。理由很直接个人云盘的文件量级通常在几个T以内一块3T的硬盘就够了没必要引额外的中间件。用对象存储的话每次上传下载都要走公网API延迟和流量费用都上来了而且数据放在别人机房和“数据自己掌握”的初衷就背道而驰了。本地存储的目录结构我按“用户ID 年月”两级分目录/storage/user/1/2025-01/ /storage/user/1/2025-02/ /storage/user/2/2025-01/为什么按年月再分一层因为单目录下文件过多时Linux文件系统的索引性能会下降。我做过测试目录下超过1万个文件后ls操作就能感觉到延迟超过5万就很明显了。按年月拆开一个用户一个月通常不会超过几千个文件访问性能稳定很多。2.3 数据流设计一次完整的上传下载要经过哪些环节这里把核心数据流画一下不画时序图了用文字描述。一次文件上传经过的环节是前端将文件分片或整体POST到后端 → Spring Boot的MultipartFile接收 → 校验文件大小、后缀白名单 → 生成文件唯一标识MD5 → 判断MD5是否已存在秒传逻辑 → 不存在则写入磁盘 → 将文件元数据文件名、大小、路径、MD5、所属用户、父目录ID等写入MySQL → 同步更新Redis中的用户已用空间 → 返回上传成功响应。下载流程则反过来用户请求下载文件ID → 后端校验文件归属权限防止越权访问他人文件 → 拼接磁盘完整路径 → 用StreamingResponseBody流式写出 → 完成后回写下载次数日志。这套流程里最容易被忽略的是权限校验。我见过不少人做云盘demo下载接口直接拿着文件ID查路径就返回了压根不判断这个文件属于哪个用户。别人猜出你的文件ID就能拿走文件这在自己用的时候还好一旦对外开放就是安全事故。这个问题我在第四部分还会提。2.4 前后端分离还是单体打包我的取舍这个项目原本是打算前后端分离部署的后端跑在8080端口前端跑在Vite的5173端口联调时通过Vite代理转发请求。但考虑到最终要给用户一个“拿到源码就能一键跑起来”的体验我选择了前端构建后放入Spring Boot的static目录打成单个Jar包。好处是部署极其简单不用单独装Nginx、不用配置跨域、不用维护两个进程。坏处是前端代码更新后需要重新打包整个Jar但个人项目无所谓更新频率本来就不高。如果你打算用前后端分离模式一定要把跨域配置提前写好。我在本地联调时踩过坑前端请求后端接口时浏览器报CORS错误一开始以为是跨域配置没写好后来发现是Vite代理配置的target地址写错了端口。建议前后端分离调试时统一用代理方式不要直接在后端代码里放开所有跨域否则上线后接口相当于对全网开放任何人都能调用。3. 数据库设计云盘项目的表结构到底怎么规划3.1 五张核心表从用户到文件再到分享一个云盘系统的数据库设计核心就是五张表用户表、文件表、文件夹表、分享表、回收站记录表。下面把关键字段列出来讲讲设计原因。用户表t_user字段名类型说明idbigint主键自增accountvarchar(32)登录账号唯一索引passwordvarchar(64)密码MD5加盐存储nicknamevarchar(32)昵称展示用total_spacebigint总空间单位字节默认10Gused_spacebigint已用空间单位字节create_timedatetime注册时间密码用MD5加盐很多人问为什么不用BCrypt。BCrypt加密强度高但每次登录验证都挺耗CPU在小规模个人系统里两者差距不大。但如果你有对外开放的打算我建议换成BCrypt毕竟MD5撞库风险是实打实的。这个字段将来可以通过Spring Security的PasswordEncoder无缝切换不影响表结构。文件表t_file字段名类型说明idbigint主键user_idbigint所属用户ID建普通索引parent_idbigint父目录ID文件夹和根目录为0file_namevarchar(255)文件名file_pathvarchar(500)存储相对路径file_sizebigint文件大小字节file_md5varchar(64)文件MD5用于秒传和去重file_typevarchar(10)类型标识file或folderis_deletetinyint软删除标志1为已删除delete_timedatetime删除时间用于回收站自动清理create_timedatetime上传/创建时间很多云盘的文件夹和文件是分两张表存的但我把文件夹也当成一种特殊的“文件记录”用file_type字段区分。好处是目录树的查询逻辑变得非常统一——查子项就是查parent_id等于某个值的所有记录不管它是文件还是文件夹一条SQL搞定坏处是文件夹本身没有file_size和file_md5需要允许这两个字段为空。这个取舍我认为对个人项目是划算的代码精简很多。分享表t_share字段名类型说明idbigint主键user_idbigint分享发起人file_idsvarchar(500)分享的文件或文件夹ID多个用逗号分隔share_codevarchar(10)提取码expire_timedatetime过期时间空为永久visit_countint访问次数create_timedatetime分享创建时间注意file_ids是逗号分隔的字符串这在正常范式设计里是不推荐的但云盘分享有个特点用户可能一次分享多个文件而非固定数量的关系。单独建关联表会增加复杂度对个人项目来说一个varchar字段完全够用。查询时再用FIND_IN_SET或IN语句拆一下就好。回收站记录表t_recycle这张表的字段和文件表几乎一样多一个origin_路径字段用于还原。也可以不建独立表直接在文件表上做is_delete软删除我为了后续加“回收站列表独立展示”“到期自动清除”的定时任务选择了独立表。从文件表删除记录时把它插入回收站表形成一次事务。3.2 建表语句中的几个关键细节建表时我建议给三个位置特别加索引查询性能差距很大ALTER TABLE t_file ADD INDEX idx_user_parent (user_id, parent_id); ALTER TABLE t_file ADD INDEX idx_user_md5 (user_id, file_md5); ALTER TABLE t_share ADD INDEX idx_share_code (share_code);idx_user_parent支撑目录列表查询这是最高频的查询没有这个索引数据量一大就很慢idx_user_md5支撑秒传功能的MD5查重上传前先查一次idx_share_code支撑分享链接打开时的提取码校验另外文件表的file_name建议用utf8mb4别用utf8。utf8在MySQL里实际上只能存基本多语言平面字符遇到特殊字符比如emoji命名文件会直接报错或者乱码。现在文件名叫“测试.pdf”的越来越多用utf8mb4就没这个问题。3.3 数据库脚本中的初始化数据源码附带的数据库脚本里除了表结构我还塞了一条管理员账号admin / 123456和几个测试目录。有同学问为什么不全删掉让用户自己注册。我的想法是第一次部署的人能立刻登录看到效果确认系统没问题再改密码体验会顺畅很多。但正式使用时记得第一时间改掉默认密码这个不用我说了吧。4. 核心功能拆解上传、下载、分享与用户模块的实现路径4.1 文件上传秒传、分片、进度反馈背后的逻辑文件上传是一个云盘系统的核心也是最容易出问题的地方。这个项目实现了三个能力秒传、分片上传、断点续传。先说秒传。原理很简单用户上传文件时前端先计算整个文件的MD5用浏览器端的SparkMD5库把MD5传给后端。后端去t_file表查这个MD5是否已存在于当前用户的文件列表中如果存在且用户已用空间足够就不再真正上传文件内容直接返回“上传成功”并把用户已用空间增加对应大小。这样一来同一份文件不管传几次只有第一次会真的占用磁盘体检视频、安装包这种重复转存的场景体验极好。MD5计算在浏览器端对大文件比较耗时所以我对超过100MB的文件做了一个降级策略不做全量秒传而是直接走分片上传。分片上传就是前端把文件切成2MB一片逐片POST到后端后端暂存在临时目录全部传完后调用一个合并接口把分片拼接成完整文件再写入正式存储路径。分片大小我调过几次2MB比较均衡。太小的分片HTTP请求次数太多网络开销大太大的话单片传输时间变长失败重传成本高。2MB片大小5G的文件大约2560片实测在网络稳定的内网场景下传输效率接近整传外网弱网环境下的失败重传成本也能接受。断点续传的逻辑建立在分片基础上前端把每个分片的序号和MD5都传给后端后端记录哪些分片已经传到临时目录前端重新上传时先调一个接口查哪些分片缺失只重传缺失的就可以。这个功能对50MB以上文件的体验提升非常明显值得优先实现。涉及上传的Spring Boot配置这里给出关键部分spring: servlet: multipart: max-file-size: 1024MB max-request-size: 2048MBmax-file-size控制单个文件大小max-request-size控制单次请求总大小。很多人只配置了前者忘了后者结果单文件不超过限制但分片连传时请求体超限报错之后一脸懵。这两个配置大小要根据分片策略来定比如分片大小2MB一次请求传10片那max-request-size至少得20MB以上。4.2 文件下载单文件、多文件打包下载与权限校验单文件下载逻辑很简单但有一个初学者容易犯的错误直接用FileInputStream读取整个文件再返回给前端。小文件没事大文件会把内存撑爆。正确做法是用StreamingResponseBody流式输出让Response的OutputStream直接从磁盘搬数据不经过JVM内存的完整拷贝。GetMapping(/download/{fileId}) public ResponseEntityStreamingResponseBody download(PathVariable Long fileId, RequestParam Long userId) { FileInfo file fileService.getFileById(fileId); // 关键校验文件必须属于当前用户 if (!file.getUserId().equals(userId)) { throw new RuntimeException(无权访问该文件); } File diskFile new File(file.getFilePath()); StreamingResponseBody body outputStream - { try (InputStream input new FileInputStream(diskFile)) { byte[] buffer new byte[4096]; int bytesRead; while ((bytesRead input.read(buffer)) ! -1) { outputStream.write(buffer, 0, bytesRead); } outputStream.flush(); } }; return ResponseEntity.ok() .header(HttpHeaders.CONTENT_DISPOSITION, attachment; filename\ URLEncoder.encode(file.getFileName(), UTF-8) \) .contentType(MediaType.APPLICATION_OCTET_STREAM) .contentLength(diskFile.length()) .body(body); }上面的代码里第一个if就是权限校验看似多写了几行但这是云盘系统最不能省的防线。另外注意响应头里的filename一定要做URL编码否则包含中文或特殊字符的文件名会直接乱码这个我踩过客户端下下来文件名全是“%E6%B5%8B%E8%AF%95.pdf”这种。多文件下载我实现的是打包成zip流式输出。用Java自带的ZipOutputStream注意不能把整个zip文件先写到临时磁盘再返回那样延迟高还浪费磁盘。正确做法是边遍历文件列表边写入ZipOutputStream输出流直接指向HTTP响应。如果是视频、图片这种希望在线预览的文件可以走独立的预览接口读取字节输出配置正确的Content-Type就行。4.3 分享机制提取码、有效期设计分享功能是我个人很喜欢的一个模块。用户勾选若干文件或文件夹点击“创建分享”后端生成一个随机字符串我用的UUID前8位作为链接标识再生成一个4位数字提取码设置有效期可选1天、7天、30天、永久。访问者拿到链接和提取码后输入提取码验证通过才能查看和下载文件。提取码的生成有个细节值得说一下。4位数字一共只有10000种组合绝对安全谈不上但作为分享场景的身份验证够用了。我见过有人在提取码校验逻辑里不做次数限制这等于给了暴力破解的机会。我在实现时加了限制同一个分享链接提取码错误超过5次锁定10分钟这个功能用Redis的increxpire实现代码量很小但安全性提升明显。失效处理也很重要。在查询分享信息时同步检查过期时间过期后接口返回“链接已失效”。同时我写了一个定时任务每天凌晨扫一次分享表把过期的记录直接清除并把分享状态置为失效避免无效数据越积越多。这个定时任务用Spring Boot自带的Scheduled注解就能实现没必要引入额外的调度框架。4.4 用户体系注册、登录、JWT鉴权用户模块是其他所有功能的基础我用JWT做无状态鉴权。用户登录成功后后端生成一个带过期时间的token返回给前端前端存到localStorage每次请求在Authorization头带上。后端写一个拦截器统一校验token解析用户ID放入ThreadLocal后续处理请求的任何地方都能取到当前用户。JWT的secret一定要放在配置中心或环境变量里别硬编码在代码中。这个属于老生常谈了但GitHub上泄露secret的事确实不少见提一下没坏处。用户“已用空间”的统计我是在文件上传成功、删除文件、回收站清理三个时机单独维护的没有用SQL统计再更新。原因很简单单用户文件数量过万后每次进首页都COUNT一次大表接口延迟能从30ms飙升到300ms以上。用单独字段维护换来的是首页加载的稳定快速。代价是偶尔会出现空间数值不同步所以我另加了一个“空间校准”接口扫描该用户所有未删除文件累加file_size然后回写used_space字段。每月手动执行一次就行方便得很。5. 从源码到上线开发环境配置与部署要点5.1 本地跑起来的完整步骤拿到源码之后我按下面这个顺序配置环境目前来看是最省事的路径安装JDK 1.8或11配置JAVA_HOME安装Maven 3.6配置文件仓库地址建议设置成阿里云镜像不然依赖下载能等到怀疑人生安装MySQL 8.0执行项目根目录下的schema.sql初始化所有表结构安装Redis 6.x默认端口即可打开application.yml修改数据库地址、账号、密码以及Redis地址在项目根目录执行mvn spring-boot:run或者导入Idea后直接运行主类如果数据库连接报错关于公钥检索问题记得在MySQL连接串后面加上allowPublicKeyRetrievaltrue这属于MySQL 8.0的认证机制变化导致的不加这个参数直接连不上。前端部分如果我已经把构建后的dist目录打进了Jar包那你跑后端起来打开http://localhost:8080就能看到界面不需要单独启前端。如果你想改前端的界面样式就需要在frontend目录下执行npm install装依赖然后npm run build生成新的dist把它拷贝到Spring Boot的src/main/resources/static目录下再重新打包运行。5.2 真机部署时要注意的坑服务器部署和我本地跑通是两回事这里有三个我说什么也要叮嘱一下的坑。第一个是磁盘路径问题。Windows上文件路径分隔符是反斜杠\Linux上是正斜杠/。我在本地Windows开发时上传的文件路径存的是D:\storage\user\1\2025-01\abc.pdf把Jar包扔到Linux服务器上跑就全乱了。解决方案是存储路径不要写绝对路径用相对路径或配置文件指定存库时统一把路径分隔符替换成/读取时用Java 7的Path类或File.separator去拼路径。我在代码里统一用/拼接字符串再转成File对象这样跨平台没问题。第二个是静态资源映射。Spring Boot默认只映射static目录我自定义的存储路径比如/storage不在这个范围内。需要在配置里加一段资源映射spring: resources: static-locations: classpath:/static/,file:/storage/如果不加这段直接按URL访问上传的图片会404很多人排查半天找不到原因。其实Spring Boot的资源映射很简单就是把HTTP请求路径映射到磁盘路径上。第三个是端口和防火墙。服务器上用java -jar cloud-disk.jar --server.port8080启动后记得在安全组和系统防火墙里同时放行8080端口。我上线时只在云控制台配了规则忘了服务器本机的firewalld也要放行结果折腾了半小时才确认是这个问题。5.3 上线前的自检清单我每次部署新版本都会过一遍这个清单省了很多事默认密码是否已修改admin账号是否绑定了自己的邮箱或手机号application.yml里是否开启了生产环境配置关闭Swagger文档暴露、关闭DevTools热部署存储路径是否存在、是否有写权限执行chmod授权的时候别把用户主目录权限也改了MySQL连接是否使用独立的云盘数据库账号而不是root直接跑定时任务的执行日志是否输出到独立文件方便排查回收站清理情况Jar包备份是否保留上一个稳定版本方便快速回滚6. 开发中遇到的真坑与避坑经验写代码的时候踩了不少坑这里挑几个最有代表性的记下来都是百度搜不太到的经验。坑一大文件上传时的内存溢出第一次跑通上传功能的时候我直接用的MultipartFile接收整个文件然后转存本地测的时候200MB的文件没问题一部署到服务器传1.2GB的文件就报OutOfMemoryError。排查发现是Tomcat默认把上传文件先写进内存文件太大直接撑爆。解决办法有两个一是配置spring.servlet.multipart.file-size-threshold2MB让超过2MB的文件直接写入临时文件而不是内存二是前端做分片把单片控制在较小体积。我把两个方案都用了效果很理想传10GB大文件后端内存占用一直很平稳。坑二中文文件名下载时乱码这个前面提过一嘴再展开说下。最初我下载接口的Content-Disposition头是直接拼文件名的浏览器打开后发现中文全部变成下划线或者乱码。后来查RFC文档才知道HTTP头部默认不支持非ASCII字符需要对文件名做RFC 5987编码。正确写法是String encodedFileName URLEncoder.encode(fileName, UTF-8).replaceAll(\\, %20); header(Content-Disposition, attachment; filename*UTF-8 encodedFileName);注意URLEncoder会把空格编码成但URL里有特殊含义所以要再替换成%20。这个细节我在网上搜了好几个帖子才定位到实际效果立竿见影。坑三秒传功能误判重复文件最开始实现秒传时我只按文件MD5判断结果发现不同用户上传同一个文件时A用户上传成功后B用户也能秒传但B用户文件列表里确实有这个文件没问题。真正的坑在于同一个文件两个用户都上传了但其中一个用户把文件修改后又上传MD5变了新文件正常创建但原始版本一直存在造成存储空间浪费。这个问题要彻底解决需要做文件引用计数——同一MD5同时被多个用户引用时才真正保留一份物理文件。我一开始没做这个只是做了“同一用户重复上传”的秒传后来数据量大了才意识到这是个可优化项。如果你要扩展这个系统可以从文件引用计数入手这是比较高级的优化方向。坑四MyBatis Plus的Wrapper查询把parentId写成字符串文件夹的parentId在数据库里是bigint但我在前端路由里拿到的参数是字符串传到后端没转型就拼接进了QueryWrapperMyBatis Plus居然也能执行成功直接查出空列表。这种类型不匹配导致的隐式转换问题非常隐蔽需要靠日志才能发现。建议所有从前端拿到的ID类型在后端接口里统一用Long.parseLong或RequestParam(required true) Long强制类型转换尽早暴露问题。坑五回收站删除后用户空间没减这个属于业务逻辑遗漏。删除文件时我只把文件记录从t_file挪到了t_recycle没有同步把用户used_space减掉。结果用户删了一堆大文件空间却一点没释放首页显示还是满的。后来我在删除方法里补了事务操作插入回收站记录、删除原文件记录、更新用户空间统计三步必须同时成功。空间统计这类数据更新时机要尽量紧跟操作时机不然很容易出现“看到的数据”和“实际数据”不一致。结语说说这个项目还能怎么玩如果你拿到源码只是把它跑起来看了两眼那体验到的价值大概只发挥了三分之一。我建议你按下面这几条路径继续折腾它的扩展空间非常大第一给文件表加一个“标签”字段用MySQL 8.0的JSON类型存标签列表配合全文索引实现全文搜索。云盘最重要的体验之一就是找文件找得快光靠文件名模糊查询不够标签能大幅提升检索速度。第二加一个在线预览模块。图片预览用浏览器原生能力就够了PDF预览也容易麻烦的是Office文档和视频。视频可以用FFmpeg做转码转成HLS分片流输出体验能追上主流网盘。第三把本地存储替换成MinIO。MinIO兼容S3协议更换存储层之后上传下载的逻辑改动其实不大但能换来对象存储的很多高级特性比如版本管理、生命周期规则、冷热数据分层。个人云盘这种项目技术栈不算前沿但它把Web开发里最常见的那些问题——文件IO、权限校验、并发控制、缓存设计、部署运维——全都串在了一起。认认真真把它做到能用的程度你收获的不仅是几份代码而是从设计到落地的完整思维方式这是啃多少篇教程都换不来的。希望这篇记录对你有点用处如果你在部署或扩展的过程中遇到问题欢迎留言交流。