ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

基于Spring Boot的私人云网盘实现:分片上传、断点续传与安全部署实战

基于Spring Boot的私人云网盘实现:分片上传、断点续传与安全部署实战

先交代下背景:我手头有一台不关机的小主机,平时要存的东西越来越多——手机照片、工作文档、影视资源,散落在电脑、移动硬盘和各个网盘里。公有网盘要么限速要么担心隐私,折腾一圈下来,还是决定自己搭一个私人云网盘。技术栈很自然地选了Spring Boot,生态成熟、上手快、资料多,前后端一个人搞也扛得住。这篇文章把我从零开始实现这套系统的过程、踩过的坑、以及生产部署后的实测体验完整记录下来,给同样想自己搭网盘的人一个参考。

1. 私有网盘的定位差异:先想清楚它和公有网盘的区别

动手写代码之前,我花了不少时间想一个问题:私人云网盘和公有网盘到底差在哪?如果只是把文件存到服务器上、提供一个下载链接,那这项目没什么意思。真正的差异在于使用对象和使用场景。

公有网盘面向海量用户,功能堆得很多:同步盘、在线编辑、多端实时同步、共享空间。私人网盘面向的是自己或者三五个人,核心需求其实就几条:文件能安全存进去、能方便取出来、分享给别人的时候不泄露隐私。功能一定要克制,做得越多,维护成本越高,出问题的面也越大。

所以我给这个项目划了一条清晰的功能边界:

  • 用户登录与注册(其实注册也只给自己人用,没必要做复杂的审批流)
  • 目录管理(创建、重命名、移动)
  • 文件上传(支持分片、秒传、断点续传算加分项,但一开始就必须设计好)
  • 文件下载与在线预览(图片、视频、音频、常见文档)
  • 分享链接(可选有效期和提取码)

暂缓的功能包括:多人在线协同编辑、增量同步客户端、移动端App。这些后续有精力了再加,不影响主体框架。

这个定位影响了我后续所有的技术决策。比如存储层,我最终选的是本地磁盘+Mysql元数据,而不是引入MinIO或者公有云对象存储。下面详细说说。

2. 存储方案选型:为什么我坚持用本地磁盘 + MySQL 而不上对象存储

项目开始前,我在存储层面纠结了很久。网上搜Spring Boot网盘项目,大半推荐接MinIO,理由是对象存储适合海量文件、支持桶策略、有现成的S3 API。这些都对,但对于"私人云网盘"这个场景,我认真算了一笔账。

2.1 三种存储方案的真实对比

方案优点缺点适合场景
本地磁盘 + DB存元数据实现简单、零额外依赖、读写快、便于直接备份横向扩展难、磁盘坏了数据可能丢单机自用、小团队内网
MinIO 对象存储有S3接口、支持桶策略、可集群多一个中间件要维护、配置项多、小规模反而绕多节点、需要统一对象接口
公有云OSS无限容量、CDN加持要花钱、数据出海、依赖外网有预算、追求稳定托管

我这个场景,数据量撑死在几TB以内,用户数两位数封顶。MinIO引入后确实能学到东西,但也会带来新问题:部署要占内存,要管理accessKey,还要处理桶和对象的生命周期。用本地磁盘,目录结构一目了然,出问题可以直接用du、ls这些基础命令排查,对于一个以实用为主的私人项目,这是最大的优势。

2.2 元数据与文件实体分离:为什么数据库只存索引

选完存储介质,紧接着是架构上的关键决策:用户看到的"目录树"和"文件",并不直接对应服务器上的真实路径。我在MySQL里维护了一张file_info表,记录了文件的逻辑路径、文件名、大小、MD5等信息,而文件实体则以"对象ID.扩展名"的形式躺在磁盘存储目录里。

这样做有三个直接好处:

  • 解决重名问题:用户上传两个都叫"报告.pdf"的文件,物理文件名会是a1b2c3d4.pdf和e5f6a7b8.pdf,互不干扰。
  • 方便移动和重命名:用户在前端把文件从A目录拖到B目录,后台只需要改数据库里的一行path字段,磁盘上的文件动都不用动,秒完成。
  • 天然防止路径穿越:物理路径完全由服务端生成,用户输入的原始文件名只作为展示名保存在数据库里,不会直接拼进文件系统路径。

2.3 Spring Boot 版本选型与依赖清单

我用的是Spring Boot 2.7.x。为什么不直接上3.x?因为2.7还在维护期内,javax包体系对老资料兼容性最好,网上踩坑记录也多。考虑到核心是文件上传下载,依赖尽量精简:

<dependencies> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-web</artifactId> </dependency> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-validation</artifactId> </dependency> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-security</artifactId> </dependency> <dependency> <groupId>mysql</groupId> <artifactId>mysql-connector-java</artifactId> <scope>runtime</scope> </dependency> <dependency> <groupId>com.baomidou</groupId> <artifactId>mybatis-plus-boot-starter</artifactId> <version>3.5.3</version> </dependency> <dependency> <groupId>io.jsonwebtoken</groupId> <artifactId>jjwt</artifactId> <version>0.9.1</version> </dependency> <dependency> <groupId>org.projectlombok</groupId> <artifactId>lombok</artifactId> </dependency> </dependencies>

Spring Security这里不是用来做复杂权限模型的,主要是配合JWT做登录认证和接口拦截,后面章节会展开说。MyBatis-Plus纯粹图它写CRUD快,反正表结构就那几张,不需要上JPA全家桶。

3. 数据库表设计:3张核心表和1张辅助表

私人网盘的表结构不需要很复杂,但字段一定要设计对。我最终沉淀下来4张表:用户表、文件信息表、分享链接表、分片上传临时表。下面逐个拆解。

3.1 用户表:认证与配额

CREATE TABLE `user` ( `id` bigint NOT NULL AUTO_INCREMENT, `username` varchar(50) NOT NULL, `password` varchar(100) NOT NULL COMMENT 'BCrypt加密后的密码', `nickname` varchar(50) DEFAULT NULL, `total_quota` bigint DEFAULT 107374182400 COMMENT '总配额,默认100GB', `used_quota` bigint DEFAULT 0 COMMENT '已用空间', `create_time` datetime DEFAULT NULL, PRIMARY KEY (`id`), UNIQUE KEY `uk_username` (`username`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;

很多网盘项目把配额忽略掉了,实际用起来迟早要补。我一开始没做,结果有次导照片把磁盘塞满,整个服务挂掉,排查半天才发现是没配额控制。后来老老实实加上,文件上传前先检查used_quota + 文件大小是否超过total_quota,不够就直接拒绝。

3.2 文件信息表:逻辑目录的落点

CREATE TABLE `file_info` ( `id` bigint NOT NULL AUTO_INCREMENT, `user_id` bigint NOT NULL, `parent_id` bigint DEFAULT 0 COMMENT '父目录ID,0表示根目录', `file_name` varchar(255) NOT NULL, `file_path` varchar(500) DEFAULT NULL COMMENT '当前目录路径,用于列表查询', `file_type` tinyint DEFAULT 0 COMMENT '0-目录 1-文件', `file_size` bigint DEFAULT 0, `file_md5` varchar(32) DEFAULT NULL, `storage_path` varchar(255) DEFAULT NULL COMMENT '物理存储路径', `create_time` datetime DEFAULT NULL, `update_time` datetime DEFAULT NULL, PRIMARY KEY (`id`), KEY `idx_user_parent` (`user_id`, `parent_id`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;

这里有个细节:parent_id表示目录结构,file_path我当时图省事存了一份冗余路径,后来发现这个字段其实可以不要。列表查询时用user_id + parent_id足够定位了,而且更灵活,重命名目录时不用连带更新所有子文件。如果非要做面包屑导航,通过 parent_id 递归向上查或者在前端维护路径映射即可。这是我的一个教训:冗余字段看着省事,实际引入了一致性问题,改目录名时要同步更新所有子记录的字段,很容易漏。

3.3 分享链接表:提权访问的桥梁

CREATE TABLE `share_link` ( `id` bigint NOT NULL AUTO_INCREMENT, `share_token` varchar(32) NOT NULL COMMENT '分享token', `extract_code` varchar(6) DEFAULT NULL COMMENT '提取码', `user_id` bigint NOT NULL, `file_id` bigint NOT NULL COMMENT '被分享的文件/目录ID', `expire_time` datetime DEFAULT NULL, `create_time` datetime DEFAULT NULL, PRIMARY KEY (`id`), UNIQUE KEY `uk_token` (`share_token`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;

分享链接的思路是:不直接开放文件ID访问,而是生成一个随机的share_token作为入口。这样即使别人拿到链接,也只能访问被分享的那一个文件,不能通过修改ID来遍历别人的文件。提取码是可选的,用于私密分享。

3.4 分片上传临时表:承担断点续传的元数据

CREATE TABLE `upload_task` ( `id` bigint NOT NULL AUTO_INCREMENT, `task_id` varchar(64) NOT NULL COMMENT '前端生成的上传任务ID', `user_id` bigint NOT NULL, `file_name` varchar(255) NOT NULL, `file_md5` varchar(32) NOT NULL, `file_size` bigint NOT NULL, `chunk_size` int NOT NULL, `chunk_total` int NOT NULL, `chunk_uploaded` int DEFAULT 0 COMMENT '已上传分片数', `storage_path` varchar(255) DEFAULT NULL, `status` tinyint DEFAULT 0 COMMENT '0-上传中 1-合并完成 2-已失效', PRIMARY KEY (`id`), UNIQUE KEY `uk_task` (`task_id`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;

这张表是整个断点续传功能的数据基础。上传分片会在临时目录落盘,所有分片传完后触发合并。如果中途断了,下次带着同一个task_id回来,服务端能查出已上传了哪些分片,前端自动跳过。

表的数量不多,但每张的字段我都磨了几轮。尤其是file_info表,后期加配额字段、加MD5字段的时候,数据迁移是真的麻烦。建议动手前就把用户配额、秒传校验这些考虑进去,别等上线了再改表。

4. 上传链路:分片、秒传、断点续传的完整实现

网盘系统最核心的部分不是列表、不是下载,而是上传。小文件还好,几GB的大文件如果用普通POST上传,要么超时,要么断线全部重来。所以上传模块我重点做了三件事:分片上传、MD5秒传、断点续传。

4.1 前端为什么要切片,以及怎么切

浏览器XMLHttpRequest不支持直接暂停恢复大文件上传,但通过File.slice()可以把文件切成一个个Blob,然后按顺序上传每个分片。前端逻辑不复杂:

const CHUNK_SIZE = 5 * 1024 * 1024; // 5MB一片 function createChunks(file) { const chunks = []; let start = 0; while (start < file.size) { const end = Math.min(start + CHUNK_SIZE, file.size); chunks.push({ file: file.slice(start, end), index: chunks.length, total: Math.ceil(file.size / CHUNK_SIZE) }); start = end; } return chunks; }

切片大小我试过1MB、2MB、5MB、10MB。在小主机环境(网络上行一般10-20Mbps),5MB一片最平衡:一片的传输时间大概2-5秒,失败重传的成本可控,分片数量又不会太多导致请求太频繁。

4.2 后端接口三连:初始化、上传分片、合并

后端设计成三个接口配合使用。

第一步,前端拿到文件后先算MD5(Web Worker里算,避免卡UI),然后请求初始化接口:

@PostMapping("/upload/init") public Result<UploadInitVO> init(@RequestBody UploadInitDTO dto) { // 1. 先查秒传:如果文件MD5已经存在,且校验大小一致,直接返回秒传成功 // 2. 校验配额是否足够 // 3. 生成taskId和临时存储路径,在upload_task表插入记录 // 4. 返回 { taskId, chunkSize, chunkTotal, uploadedChunks: [] } }

uploadedChunks这个返回值很关键。如果之前传过一部分片,这里返回已上传的分片序号列表,前端直接跳过,这就是断点续传的核心。

第二步,循环上传每个分片:

@PostMapping("/upload/chunk") public Result<Boolean> uploadChunk( @RequestParam("taskId") String taskId, @RequestParam("chunkIndex") Integer chunkIndex, @RequestPart("file") MultipartFile chunk) { // 1. 从upload_task表查出任务,校验状态 // 2. 写临时文件:{存储根目录}/tmp/{taskId}/{chunkIndex}.part // 3. 更新upload_task表的chunk_uploaded计数(用乐观锁,防止重复提交) }

第三步,当前端收到所有分片上传成功的响应后,调用合并接口:

@PostMapping("/upload/merge") public Result<FileVO> merge(@RequestBody MergeDTO dto) { // 1. 校验分片数量是否齐全 // 2. 按分片序号从小到大,逐个写入最终文件 // 3. 计算最终文件的MD5,与前端传来的MD5比对,不一致说明分片传错了 // 4. 清理临时目录和upload_task记录 // 5. 在file_info表插入正式记录,更新用户已用空间 }

4.3 合并时我踩过的最深的坑:并发覆盖

这个坑几乎让我怀疑人生。现象是:两个完全不同的文件,各自在前端正常上传,合并的时候,后合并的那个文件把先合并的那个覆盖了。查了很久发现,问题出在最终文件的命名上。

我当时偷懒,直接用原始文件名_时间戳.ext作为存储文件名。理论上不会重,但合并代码里,我在写入最终文件之前先创建了File对象,然后两个线程同时走到创建这一步,都拿到了同一个路径的File对象引用。由于时间戳精度只到秒,两个几乎同时到达的合并请求生成了完全相同的文件名,于是后一个覆盖了前一个。

解决办法很粗暴也很有效:存储文件名直接用UUID或雪花ID生成,拼上扩展名,完全摆脱时间戳依赖。后来我干脆用MD5前12位 + 随机4位 + .ext作为存储名,既能保留一定的可读性,又保证唯一。

4.4 秒传的实现细节

秒传的逻辑其实很简单:用户上传文件时,先传MD5给后端,后端去file_info表里查,如果存在相同MD5且文件大小一致的文件,就直接把用户的file_info记录指向这个已存在的文件实体,不再做分片上传。因为这个系统用户量很小,甚至可以跨用户秒传:同一个局域网里大家一起用的网盘,某个人上传过的资源,其他人再传就是秒完,非常爽。

但这里有个容易被忽略的问题:MD5碰撞虽然概率极低,但不是零。所以光比较MD5不够,我增加了文件大小比较,两个条件都命中才触发秒传。另外,秒传只对完整的文件用户有意义,如果服务器上出现过相同MD5但文件损坏的情况,秒传会把坏文件关联给下一个用户。为了稳妥,我在合并完成后会重新计算整个文件的MD5并和任务记录的MD5比对,不一致就标记合并失败,宁可让用户重传,也不能保留一个不可信的秒传结果。

5. 下载与在线预览:Range请求是绕不开的坎

上传搞定后,最爽的下载功能反而暴露了一个问题:直接用ResponseEntity<byte[]>或者输入流拷贝下载,几GB的文件浏览器会一直转圈,而且拖动进度条完全失效。这是因为没有实现HTTP Range请求。

5.1 理解Range请求

HTTP协议里,客户端可以通过请求头Range: bytes=0-1023告诉服务器只返回文件的某一段字节。服务器返回206 Partial Content,并带上Content-Range响应头。这个能力是所有在线视频播放、音频拖动的基石。

Spring Boot里实现Range并不复杂,但框架默认不带这个逻辑,需要自己处理请求头。我的实现思路:

@GetMapping("/file/download") public ResponseEntity<Resource> download( @RequestParam Long fileId, @RequestHeader(value = "Range", required = false) String rangeHeader) throws IOException { File file = new File(storagePath); long fileLength = file.length(); long start = 0; long end = fileLength - 1; if (rangeHeader != null) { String[] range = rangeHeader.replace("bytes=", "").split("-"); start = Long.parseLong(range[0]); if (range.length > 1 && !range[1].isEmpty()) { end = Long.parseLong(range[1]); } long length = end - start + 1; InputStream inputStream = new FileInputStream(file); inputStream.skip(start); return ResponseEntity.status(HttpStatus.PARTIAL_CONTENT) .header("Content-Range", "bytes " + start + "-" + end + "/" + fileLength) .header("Accept-Ranges", "bytes") .header("Content-Length", String.valueOf(length)) .header("Content-Disposition", "attachment; filename=\"" + URLEncoder.encode(fileName, "UTF-8") + "\"") .contentType(MediaType.APPLICATION_OCTET_STREAM) .body(new InputStreamResource(inputStream)); } // 无Range头时返回完整文件 }

这里用InputStreamResource而不是把文件读进内存,是为了避免大文件引起OOM。InputStreamResource基于流读取,边读边写,内存占用几乎是恒定的。

5.2 预览功能的取舍

在线预览我分了三个等级来实现:

  • 图片和音频:直接通过流式接口返回,配合Content-Type,浏览器自己就能展示。
  • 视频:只要实现了上面的Range请求,HTML5 Video标签就能拖动播放,不需要任何前端插件。
  • 文档类(Word/Excel/PDF):这块最麻烦。PDF可以用浏览器内置预览,Word和Excel原生不能直接预览。

我的做法是:PDF直接返回application/pdf让浏览器加载;Word和Excel先用LibreOffice无头转换一次,转成PDF后再预览。受小主机性能限制,这个转换对超大文档会很慢,所以我做了一层文件缓存,转换过的PDF存在临时目录,下次直接读缓存。如果文件超过5MB,直接让它下载,不强行预览。

5.3 下载限速:防止一个人占满带宽

局域网里有人用网盘下大文件,带宽很容易被吃光,其他人的视频预览就卡住了。这个在私人网盘场景下非常真实。我的解决方案是:在下载响应流外面包一层限速流,每读一个chunk就sleep一下,控制读取速率。

public class ThrottledInputStream extends InputStream { private final InputStream original; private final long maxBytesPerSecond; private long lastRefreshTime; private long bytesSinceLastRefresh; @Override public int read(byte[] b, int off, int len) throws IOException { long now = System.currentTimeMillis(); long elapsed = now - lastRefreshTime; if (elapsed >= 1000) { bytesSinceLastRefresh = 0; lastRefreshTime = now; } long budget = maxBytesPerSecond * elapsed / 1000 - bytesSinceLastRefresh; if (budget <= 0) { try { Thread.sleep(100); } catch (InterruptedException e) { Thread.currentThread().interrupt(); } return 0; } int toRead = (int) Math.min(len, budget); int bytesRead = original.read(b, off, toRead); bytesSinceLastRefresh += bytesRead; return bytesRead; } }

限速值我默认设为单线程下载5MB/s,基本满足个人远程取文件的需求,又不至于拖垮其他服务。这种妥协在私人网盘里很合理,但如果是团队共享使用,最好做成可配置项,放在管理页面里而不是写死在代码中。

6. 权限与安全:登录校验、路径穿越和分享链接那点事

网盘系统最怕的是什么?不是硬盘坏,是隐私泄露。自己用的系统反而容易忽略安全,因为觉得没人会来攻击。但实际上,只要服务暴露在公网上,扫描器半小时就能发现你。

6.1 用JWT作为无状态认证

我选择JWT而不是Session,主要因为:Spring Security + JWT可以做到完全无状态,服务重启后不用重新登录,也有利于以后拆成更多服务时共用同一套认证逻辑。

实现时在Security配置里注册一个JWT过滤器,每次请求从Header取token,验签通过后把用户信息放进SecurityContext。文件上传下载接口需要登录态,分享链接接口则单独放行。

@Override protected void configure(HttpSecurity http) throws Exception { http.csrf().disable() .sessionManagement().sessionCreationPolicy(SessionCreationPolicy.STATELESS) .and() .authorizeRequests() .antMatchers("/api/login", "/api/register", "/s/**", "/file/preview/**").permitAll() .anyRequest().authenticated() .and() .addFilterBefore(jwtAuthenticationFilter, UsernamePasswordAuthenticationFilter.class); }

这里s/**路径下的分享链接是公开访问的,后面会讲它内部如何校验。

6.2 路径穿越攻击与防护:最容易被忽略的高危漏洞

路径穿越漏洞在文件网盘里属于经典必测漏洞。攻击手法一般是:请求下载某个文件时,构造"../../etc/passwd"之类的参数,试图读服务器上的敏感文件。

我的防护分了三层。第一层,所有文件操作的接口只接收fileId(数据库主键),不接收任何路径参数。物理路径在服务端存储路径基础上拼出来的,用户根本碰不到路径。第二层,即使传了路径参数,也要做规范化检查:

String basePath = new File(StorageProperties.getRootPath()).getCanonicalPath(); String targetPath = new File(basePath, userInputPath).getCanonicalPath(); if (!targetPath.startsWith(basePath)) { throw new BusinessException("非法路径"); }

用getCanonicalPath()会把..和符号链接解析掉,再比对前缀,这一招能挡住绝大多数路径穿越变体。第三层,上传的文件名做了白名单校验,强制过滤掉/、\、..等字符,并且最终落盘文件名永远走UUID逻辑,和用户文件名无关。

6.3 分享链接的访问控制细节

分享链接的公开接口必须格外小心。我的处理逻辑是:

  • 前端分享时生成 share_token 和 extract_code,expire_time 可为空表示永久。
  • 访问公开预览接口时,先按 token 查出 share_link,如果设置了提取码且不是空字符串,要求请求参数里带上 code,比对失败拒绝访问。
  • 查到期时间,过期的直接返回"链接已失效"。
  • 由 file_id 定位到 file_info,校验文件所有者的id是否与share_link.user_id一致,防止用户A把用户B的文件ID拼进自己的分享链接绕过权限。

之后,公开访问时在response header里加一句Content-Disposition,同时给不带提取码的链接附上短有效期(默认7天),这样即使拿到了分享链接但忘了提取码,过期链接也自动作废。

6.4 密码存储与登录防爆破

密码不能用MD5直接存,这是常识。我用的BCrypt加密,Spring Security自带该实现,加盐成本是自动处理的。登录接口做了简单的失败计数限制:同一个IP 5分钟内失败超过10次就锁定15分钟。暴力破解这个漏洞在私网盘上同样不能掉以轻心,因为一旦登录被突破,整个文件库就暴露了。

7. 大文件合并、磁盘IO与内存占用实测

开发时大家都用小文件测试,看着都跑得好好的,一到真实使用就翻车。我这边翻车翻在:往网盘里导入一个40GB的文件夹备份时(里面是大量小文件加几个几GB的虚拟磁盘镜像),IO直接卡死,服务监控告警,SSH都几乎敲不动命令。

后来查下来,合并逻辑里有三个问题:

一是用FileOutputStream按单个分片循环写入,每次写完没flush,导致文件描述符和缓冲一直积压。改成每片写完后flush()再关闭流。

二是合并大文件时,代码里有把文件读进byte数组做校验的操作,虽然用的是Files.readAllBytes(),但一个5GB的文件直接OOM,JVM垃圾回收频繁,整个服务响应变慢。改成边合并边计算MD5,用DigestInputStream包住输入流,避免同时加载全文件到内存。

三是临时分片文件全放在同一个目录,文件数量达到几千上万后,文件系统访问性能明显下降。Linux ext4单目录文件数量超过几万后,寻址耗时显著增加。我把临时分片按照taskId前两位做了二级目录划分,每个目录下的文件数量最多几百个,性能问题解除。

以下是合并模块最终形态的核心代码结构:

public File mergeChunks(UploadTask task) throws IOException { File target = new File(storagePath); try (FileOutputStream fos = new FileOutputStream(target)) { for (int i = 0; i < task.getChunkTotal(); i++) { File chunkFile = new File(chunkDir, i + ".part"); try (FileInputStream fis = new FileInputStream(chunkFile)) { byte[] buffer = new byte[1024 * 1024]; int len; while ((len = fis.read(buffer)) != -1) { fos.write(buffer, 0, len); } } chunkFile.delete(); } } // 计算合并后的MD5做一致性校验 return target; }

这个过程中没有涉及任何花哨操作,全都是基础API,但正因为是基础API,一不留神就会性能翻车。我的建议是:大文件合并这种IO密集型的代码,写完一定要用真实的大文件压测一遍,别信"看起来没问题"。

8. 生产环境部署:Nginx反向代理、HTTPS与断点续传的兼容

本机跑通只是第一步,要真正替代公有网盘,必须放到一台公网服务器上。部署阶段我踩的坑也不少,挑两个最影响体验的说。

8.1 Nginx配置中的Range请求转发问题

本地直连Spring Boot测试视频拖动没问题,一旦上了Nginx反向代理,视频进度条拖动就失效。排查下来,Nginx默认配置会把Range请求头透传给后端,但后端响应中的Accept-Ranges如果没设置,代理层就会把响应缓存或转成整个文件返回。

解决的要点有两个:

server { listen 443 ssl http2; server_name your-domain.com; client_max_body_size 0; # 不限制上传大小 location / { proxy_pass http://127.0.0.1:8080; proxy_set_header Host $host; proxy_set_header X-Real-IP $remote_addr; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; proxy_set_header X-Forwarded-Proto $scheme; proxy_request_buffering off; # 关闭请求缓冲,让分片上传即时转发 proxy_buffering off; # 关闭响应缓冲,避免Range响应被代理层缓存 } }

关键的坑就是proxy_request_buffering off。Nginx默认会先把整个请求体缓冲完再转给后端,一个大分片上传会等全部接收完成才转发,前端看着一直卡在发送中,体验极差;对大文件断点续传影响更大,已经传完的分片数据会被Nginx缓冲导致重复转发。

client_max_body_size 0也很关键,默认1MB限制一旦碰到大分片直接413,纯粹的分片上传不受单个分片大小影响,但如果以后要支持直传整个小文件,这里不关掉就会炸。

8.2 HTTPS 证书配置:用免费的证书就好

私人网盘没有预算买商业证书,用免费的Let's Encrypt完全够用。我的操作思路是:先用certbot申请证书,生成证书文件后,在Nginx中配置ssl_certificate和ssl_certificate_key,每年到期前自动续签。

要注意的是,证书申请过程会短暂占用80端口做验证,如果Nginx已经在运行,certbot默认方式会失败。建议先把Nginx停掉,申请完成后再启动,或者用standalone插件手工验证,不要图省事跳过这一环。

8.3 文件备份策略

最后还想提一句备份。网盘的命根子是文件实体,数据库丢了可以重新建,文件丢了就全完了。我的备份方案很简单:服务器上的存储目录每天凌晨通过rsync增量同步到另一块独立硬盘,数据库用mysqldump每天导出一份SQL备份。整个备份放到cron里自动执行,不依赖人力。

# 每天凌晨2点执行 0 2 * * * rsync -av --delete /data/netdisk-storage/ /backup/disk/ 0 3 * * * mysqldump -u netdisk -pYOUR_PASSWORD netdisk > /backup/db/netdisk_$(date +\%Y\%m\%d).sql

我个人的体会是,私人网盘这个项目最迷人的地方不在于功能有多花哨,而在于所有逻辑都在自己的掌控范围内。上传链路自己写的,下载限速自己配的,安全策略自己定的,用起来踏实。如果你也打算动手做一个,建议先把上传和下载这两条主链路跑顺,再逐步加分享、预览、配额这些外围能力。先把核心链路搞稳,后面每一步扩展都会轻松很多。

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进