简介:在数据隐私日益受重视的今天,个人网盘成为许多开发者和企业构建私有文件存储的首选方案。Django与MySQL的组合凭借成熟生态、完善的ORM和丰富的社区支持,成为实现这一场景的高效技术栈。其核心原理是将文件元数据与物理存储分离,通过数据库记录文件树结构和属性,结合分片上传与秒传机制解决大文件传输可靠性问题。该方案不仅提升了开发效率,还具备良好的可扩展性,适用于内部资料管理、团队协作共享及个人数据备份等典型应用场景。本文从工程实践出发,完整呈现基于Django+MySQL的个人网盘系统搭建过程,涵盖数据库建模、核心接口实现、生产部署以及常见问题排查,为需要构建私有云盘的开发者提供可参考的落地路径。 我最近完整跑了一遍基于 Django + MySQL 的个人网盘项目,从数据库设计到文件上传下载,再到部署上线,折腾了不少时间,也踩了不少坑。这类“个人云盘源码”其实网上能搜到很多版本,但大部分要么缺关键功能,要么代码质量堪忧,真正拿过来能用、能二次开发的不多。这篇文章我就把自己复现这个项目的过程、设计思路、核心代码和排查经验完整记录下来,包含完整的源码级讲解和可直接套用的配置,不管你是刚学 Django 的初学者,还是想找一份能改造成私有网盘的参考工程,这篇内容应该都能帮上忙,尤其是那些网上文档不会写清楚的细节。
这个网盘系统到底解决了什么问题?说白了就是自己掌握数据。现在的公共网盘动辄限速、审核、容量限制,很多做独立开发或者有数据隐私需求的人,都在考虑搭一个内部的私有文件存储服务。用 Django + MySQL 来做这件事,优势非常明显:一是 Django 自带 Admin 后台和完整的 ORM,开发效率高,文件管理逻辑不用从零造轮子;二是 MySQL 生态成熟,部署、备份、扩容都有大量现成方案;三是这个组合的中文资料和社区支持非常丰富,遇到问题基本都能搜到答案。接下来我按从设计到实现再到部署的完整链路来写。
1. 项目整体设计与功能拆解
1.1 核心需求定位:不是简单“传文件”而是“管文件”
在动手写代码之前,我先把需求做了个拆解。如果只是把文件存到服务器,那用现成的 FTP 或者 Nginx 的 autoindex 就够了,根本不需要自己写一套系统。既然要做“个人网盘”,核心需求应该包含这么几个层次:
- 文件上传:支持单个大文件上传,尽量支持断点续传和秒传,不然传几个 GB 的文件会崩溃。
- 文件下载:支持直接下载、打包下载目录,最好能生成临时分享链接。
- 文件管理:目录树结构、重命名、移动、复制、删除、创建文件夹。
- 用户体系:个人网盘往往不止一个人用,需要有用户隔离,至少要有登录注册和简单的权限控制。
- 文件信息展示:文件大小、类型图标、上传时间、下载次数等。
这套系统我把功能划分为“存储层、逻辑层、展示层”三层。存储层就是 MySQL 中的文件元数据表,加服务器的物理存储目录;逻辑层是 Django 的 views 和 services,负责处理上传下载、目录操作等业务;展示层是 Bootstrap + 原生 JavaScript 编写的网页端,保持轻量,不引入复杂的前端框架,对个人项目来说更容易维护。
1.2 为什么选择 Django + MySQL 而不是其他方案
很多朋友会问:现在 FastAPI 那么火,Go 写并发上传也不差,为什么选 Django?我的判断标准很朴素:个人网盘这种系统,业务核心是文件管理那一套 CRUD,Django 的 ORM、Admin、表单、认证系统都是现成的,开发速度比从零搭 Flask 或 FastAPI 快得多;另外 Django 的模板和静态文件处理机制很成熟,做一个纯后端渲染的页面不需要单独搭建前后端分离工程,维护成本低。MySQL 则是因为它太普及了,几乎每台服务器都有现成的环境,而且 Django 对 MySQL 的支持非常完善,迁移、事务、锁机制都有成熟的解决方案。
还有一个考虑是团队协作和后续交接。Django + MySQL 的组合在很多中小团队里是事实标准,以后如果这个个人网盘要加功能,比如对接对象存储或者做分享链接的鉴权,找资料、找人手都会容易很多。从这个角度来说,“技术新潮”不是第一位的,“可靠、可维护、资料多”才是个人项目的真正刚需。
1.3 整体架构和目录结构预览
我采用的是经典的单应用多模块结构,Django 主项目叫 cloud_drive,核心应用叫 drive,静态页面和通用工具单独放。最终的项目目录大概是这样的:
cloud_drive/ ├── manage.py ├── requirements.txt ├── cloud_drive/ │ ├── settings.py │ ├── urls.py │ ├── wsgi.py │ └── asgi.py ├── drive/ │ ├── models.py # 数据模型:文件和目录 │ ├── views.py # 所有业务视图 │ ├── urls.py # 应用路由 │ ├── services.py # 存储服务封装,比如秒传/分片判断 │ ├── admin.py │ ├── migrations/ │ └── templates/drive/ │ ├── base.html │ ├── index.html │ └── login.html ├── media/ # 文件实际存储目录 │ └── files/ ├── static/ │ ├── css/ │ └── js/ └── scripts/ └── start.sh # 一键启动脚本这段结构里,media 是物理文件目录,Django 通过 settings 里配置的 MEDIA_ROOT 来管理;数据库里只存文件的元数据和路径关系。一定要把“数据库记录”和“物理文件”分开看待,后续排查问题时思路会清晰很多。
2. 核心功能设计与数据库建模
2.1 文件表设计:把目录和文件放到同一张表
这是我这个项目里最重要的设计决策。常见的做法有两种:一种是把“文件”和“目录”分成两张表,结构清晰但查询时要 join;另一种是把文件和目录都抽象为“节点”(Node)放进同一张表,用 type 字段区分,用 parent 字段维护层级关系。我选择的是第二种,因为网盘的目录树操作(移动、重命名、删除子目录)如果拆成两张表,逻辑复杂度会成倍增加。同一张表只需要改 parent_id 就能完成移动操作,非常方便。
from django.db import models from django.contrib.auth.models import User import uuid class FileNode(models.Model): TYPE_FILE = 'file' TYPE_FOLDER = 'folder' TYPE_CHOICES = [ (TYPE_FILE, '文件'), (TYPE_FOLDER, '目录'), ] id = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False) name = models.CharField(max_length=255) parent = models.ForeignKey( 'self', null=True, blank=True, related_name='children', on_delete=models.CASCADE ) owner = models.ForeignKey(User, related_name='files', on_delete=models.CASCADE) node_type = models.CharField(max_length=10, choices=TYPE_CHOICES) file_size = models.BigIntegerField(default=0) storage_path = models.CharField(max_length=512, blank=True) sha1 = models.CharField(max_length=64, blank=True, db_index=True) created_at = models.DateTimeField(auto_now_add=True) updated_at = models.DateTimeField(auto_now=True) class Meta: db_table = 'drive_file_node' indexes = [ models.Index(fields=['owner', 'parent']), models.Index(fields=['sha1']), ] def __str__(self): return f"{self.owner.username}/{self.name}"这里有几个关键点:
- 使用 UUID 作为主键:不用自增 id。原因很简单:如果你要做分享链接或者对外暴露文件 ID,UUID 不容易被遍历;而且分布式场景下自增 id 容易冲突。
- sha1 字段:做秒传功能的关键。上传文件前先计算文件的 SHA1,如果数据库里已经存在相同 SHA1 的记录,就不需要真正上传文件内容,直接创建一条新记录指向同一物理文件即可。
- owner + parent 联合索引:因为“列出某个目录下的内容”是最频繁的查询,where 条件通常就是
owner=? AND parent=?,这个索引能显著提升查询速度。 - parent 外键指向自己:自引用外键是树形结构在关系型数据库里的经典做法,配合
on_delete=models.CASCADE,删除父目录时自动删除子节点。但这里有一个坑:如果目录层级太深,递归删除可能会很慢,实际使用时我会在服务层限制最大递归深度,防止误操作把整个存储目录删掉。
2.2 分片上传与秒传:文件表之上再加一个分片表
虽然上面已经用 FileNode 存储文件基本信息,但大文件上传如果不做分片处理,一个 2GB 的视频文件在上传过程中只要网络抖动一下,整个文件就要重新传。为了改善这个体验,我增加了一个分片表:
class UploadChunk(models.Model): id = models.AutoField(primary_key=True) upload_id = models.CharField(max_length=64, db_index=True) # 前端生成的临时上传会话ID chunk_index = models.IntegerField() # 分片序号 chunk_file = models.FileField(upload_to='chunks/') # 分片临时存储 uploaded_at = models.DateTimeField(auto_now_add=True) class Meta: db_table = 'drive_upload_chunk' unique_together = ('upload_id', 'chunk_index')前端把大文件切分成固定大小(比如 5MB 一块)的多个分片,逐个发送到后端接口。后端接收到分片后,把分片临时保存到chunks/目录,并记录upload_id和chunk_index。所有分片传完后,前端再调用“合并接口”,后端按顺序读取所有分片,拼接成完整文件,然后写入 FileNode 表。
这里选择的策略是“前端切分 + 后端临时存储 + 最后合并”,好处是后端逻辑简单,不需要用 Redis 维护复杂的状态。分片表的唯一约束(upload_id, chunk_index)保证了同一个分片不会被重复记录,客户端重试上传时接口能稳定幂等。
秒传功能的实现则是在创建 FileNode 之前先检查sha1字段:如果同一用户(甚至所有用户)已经上传过相同 SHA1 的文件,就跳过物理文件写入,直接新建一条 FileNode 记录引用已有文件。这里要说明一下,Django 的 FileField 其实封装了文件路径,但我没有直接用 FileField 来存储最终文件,而是用 CharField 存储storage_path,再通过自定义文件句柄来处理下载响应,这样对文件名的控制更灵活,也方便做文件去重。
2.3 目录操作与文件操作的服务层封装
Django 中一个常见的代码坏味道是:视图函数里堆了一大堆业务逻辑。个人网盘尤其容易这样,因为目录操作太琐碎了——重命名要校验同名冲突,移动要防止移动到自己的子目录,删除要递归清理。我把这些逻辑全部抽到了services.py,视图只负责参数解析和响应封层。
以“移动文件/目录”为例,核心逻辑是这样的:
def move_node(node, target_parent, user): if target_parent is not None: # 不能把目录移动到自己或自己的子目录中 cur = target_parent while cur is not None: if cur.id == node.id: raise ValueError("不能移动到自身或其子目录") cur = cur.parent # 校验目标目录所有者 if target_parent.owner != user: raise PermissionError("无权操作目标目录") # 校验同名冲突 siblings = FileNode.objects.filter(parent=target_parent, owner=user, name=node.name) if siblings.exclude(id=node.id).exists(): raise ValueError("目标目录已存在同名文件或目录") node.parent = target_parent node.save()这段代码里最重要的就是循环向上查找祖先节点,防止出现把文件夹移动到自己子目录下的逻辑错误。这个 bug 如果不处理好,会导致目录树出现环,之后所有递归查询都会死循环。
对于“删除目录”,我采用标记 + 异步清理的策略:先把要删除的节点标记为deleted,然后用 Celery 或者后台线程去递归清理物理文件,而不是在请求线程里一次性干掉。刚开始我用的是同步递归遍历,结果一旦目录里有几万个文件,前端请求直接超时,后来改成异步才好。
2.4 URL 路由设计与视图函数逻辑
Django 的 URL 设计在这个项目里非常关键。我参考了现在很多对象存储的风格,把核心接口设计成一眼就能看出意图的形式:
from django.urls import path from . import views urlpatterns = [ path('', views.index, name='index'), path('login/', views.login_view, name='login'), path('register/', views.register_view, name='register'), path('logout/', views.logout_view, name='logout'), path('api/list/', views.list_nodes, name='list_nodes'), path('api/upload/', views.upload_file, name='upload_file'), path('api/upload/chunk/', views.upload_chunk, name='upload_chunk'), path('api/upload/merge/', views.merge_chunk, name='merge_chunk'), path('api/download/<uuid:node_id>/', views.download_file, name='download_file'), path('api/delete/', views.delete_node, name='delete_node'), path('api/rename/', views.rename_node, name='rename_node'), path('api/move/', views.move_node_view, name='move_node'), path('api/mkdir/', views.create_folder, name='create_folder'), path('api/share/<uuid:node_id>/', views.create_share_link, name='create_share_link'), path('share/<str:token>/', views.shared_file, name='shared_file'), ]列表接口的核心实现我直接复用 Django 的 ORM 查询,并按节点类型进行排序,目录排前面,文件排后面,文件和目录内部按名称排序:
def list_nodes(request): parent_id = request.GET.get('parent_id') if parent_id in (None, '', 'root'): parent = None else: parent = FileNode.objects.get(id=parent_id, owner=request.user) nodes = FileNode.objects.filter(owner=request.user, parent=parent).order_by( '-node_type', 'name' ) data = [{ 'id': str(n.id), 'name': n.name, 'type': n.node_type, 'size': n.file_size, 'created': n.created_at.strftime('%Y-%m-%d %H:%M'), } for n in nodes] return JsonResponse({'code': 0, 'data': data})这里要注意的是parent_id的前端传参。我没有用 0 代表根目录,而是统一用root字符串代表根节点,这样前端处理起来更直白,也避免把字符串和整型混淆。
3. 前端界面与交互流程实现
3.1 以文件管理器为核心的页面设计
前端我用的是 Bootstrap 5 + 原生 JavaScript,没有引入 Vue 或 React。做个人项目时,引入重型前端框架反而会让工程结构变得复杂,服务端渲染 + 局部 AJAX 已经够用。页面核心是一个文件列表,单击跳转目录,勾选后显示操作按钮。
由于不要过度设计,我只做了几个核心交互:
- 路径导航栏:显示当前目录的层级路径,点击任意层级可回退。
- 文件列表:包含文件名、大小、上传时间、操作列。目录可以点击进入,文件点击触发下载。
- 上传区域:支持拖拽上传和点击选择文件,支持多选。上传时展示进度条。
- 右键菜单:在文件或目录上右键弹出操作菜单,包括重命名、移动、删除、下载、复制分享链接。
- 新建文件夹:顶部按钮,点击后创建默认目录。
<!-- templates/drive/index.html 核心部分 --> <div class="container-fluid"> <div class="row"> <div class="col-12"> <nav class="navbar navbar-expand-lg navbar-light bg-light"> <a class="navbar-brand" href="#">我的云盘</a> <div class="ms-auto"> <span>{{ request.user.username }}</span> <a href="{% url 'logout' %}" class="btn btn-outline-secondary btn-sm">退出</a> </div> </nav> </div> </div> <div class="row mt-3"> <div class="col-12" id="path-container"></div> </div> <div class="row mt-2"> <div class="col-12"> <div id="upload-btn-area"> <button id="upload-btn" class="btn btn-primary">上传文件</button> <button id="mkdir-btn" class="btn btn-secondary">新建文件夹</button> <button id="refresh-btn" class="btn btn-outline-secondary">刷新</button> </div> </div> </div> <div class="row mt-2"> <div class="col-12"> <div id="file-list" class="table-responsive"></div> </div> </div> </div> <input type="file" id="file-input" multiple style="display:none;">这个页面没有采用复杂的组件库,表格部分完全由 JavaScript 动态渲染。这样做的好处是后端模板不用做条件判断,前端逻辑也更统一。
3.2 上传流程的完整实现(含分片逻辑)
前端上传是整个项目中代码量最大的部分。我把 HTML5 的文件 API、XMLHttpRequest 和 FormData 结合起来实现分片上传。核心逻辑如下:
// 分片上传核心逻辑 const CHUNK_SIZE = 5 * 1024 * 1024; // 5MB async function uploadFile(file, parentId) { const totalChunks = Math.ceil(file.size / CHUNK_SIZE); const uploadId = generateUUID(); // 生成上传会话ID for (let chunkIndex = 0; chunkIndex < totalChunks; chunkIndex++) { const start = chunkIndex * CHUNK_SIZE; const end = Math.min(file.size, start + CHUNK_SIZE); const blob = file.slice(start, end); const formData = new FormData(); formData.append('upload_id', uploadId); formData.append('chunk_index', chunkIndex); formData.append('total_chunks', totalChunks); formData.append('file_name', file.name); formData.append('file_size', file.size); formData.append('parent_id', parentId || ''); formData.append('file', blob); // 上传当前分片 await new Promise((resolve, reject) => { const xhr = new XMLHttpRequest(); xhr.open('POST', '/api/upload/chunk/'); xhr.onload = () => { if (xhr.status === 200) resolve(); else reject(new Error(`分片${chunkIndex}上传失败`)); }; xhr.onerror = () => reject(new Error('网络错误')); xhr.send(formData); }); updateProgress(chunkIndex + 1, totalChunks); } // 分片全部传完,请求合并 await fetch('/api/upload/merge/', { method: 'POST', headers: {'Content-Type': 'application/json'}, body: JSON.stringify({ upload_id: uploadId, file_name: file.name, file_size: file.size, parent_id: parentId || '' }) }); }这里我要特别说明file.size的传递方式:每个分片请求都带文件的总大小,后端拿到后可以判断所有分片大小之和是否和声明的一致,这是个很基础但非常重要的完整性校验。另外,file.slice是获得文件分片的可靠方法,要注意在低版本浏览器里的兼容性,现代浏览器已经全部支持。
上传后端接口upload_chunk的实现,需要接收分片并保存到临时目录:
@login_required def upload_chunk(request): if request.method != 'POST': return JsonResponse({'code': 1, 'msg': '仅支持POST请求'}) upload_id = request.POST.get('upload_id') chunk_index = int(request.POST.get('chunk_index')) total_chunks = int(request.POST.get('total_chunks')) file_name = request.POST.get('file_name') parent_id = request.POST.get('parent_id') chunk_file = request.FILES.get('file') if not all([upload_id, chunk_file, file_name]): return JsonResponse({'code': 1, 'msg': '参数不完整'}) chunk_dir = os.path.join(settings.MEDIA_ROOT, 'chunks', str(request.user.id), upload_id) os.makedirs(chunk_dir, exist_ok=True) chunk_path = os.path.join(chunk_dir, f"{chunk_index}.part") # 保存分片 with open(chunk_path, 'wb+') as destination: for chunk in chunk_file.chunks(): destination.write(chunk) # 记录分片信息(如果不存在) UploadChunk.objects.get_or_create( upload_id=upload_id, chunk_index=chunk_index, defaults={'chunk_file': f'chunks/{request.user.id}/{upload_id}/{chunk_index}.part'} ) return JsonResponse({'code': 0, 'msg': 'ok'})合并接口merge_chunk的实现主要做三件事:验证分片完整性、把分片拼接为完整文件、创建 FileNode 记录。拼接的时候要注意文件打开模式,分片写入目标文件时要用二进制模式,否则 Windows 环境下会有换行符转换导致文件损坏的问题。
3.3 下载与分享链接:两种访问方式的实现
下载功能我采用 Django 的FileResponse实现。它的好处是底层用迭代器读取文件,不会把大文件一次性加载进内存,可以处理几个 GB 的文件。代码实现如下:
from django.http import FileResponse, Http404 import os @login_required def download_file(request, node_id): node = get_object_or_404(FileNode, id=node_id, owner=request.user) if node.node_type != 'file': return JsonResponse({'code': 1, 'msg': '不能下载目录'}) storage_path = os.path.join(settings.MEDIA_ROOT, node.storage_path) if not os.path.exists(storage_path): raise Http404("文件不存在,可能已被清理") response = FileResponse(open(storage_path, 'rb'), as_attachment=True) # Content-Disposition 需要处理中文文件名 filename = quote(node.name.encode('utf-8')) response['Content-Disposition'] = f"attachment; filename*=UTF-8''{filename}" return response分享链接的功能需要单独讲。因为下载接口是要登录的,但分享链接往往希望不登录就能访问。我的实现方式是:创建一个 ShareLink 模型,存一个随机 token 和对应的文件节点 ID,分享链接的访问接口只校验 token 而不校验用户登录态。
class ShareLink(models.Model): token = models.CharField(max_length=32, unique=True, db_index=True) node = models.ForeignKey(FileNode, related_name='share_links', on_delete=models.CASCADE) created_by = models.ForeignKey(User, on_delete=models.CASCADE) created_at = models.DateTimeField(auto_now_add=True) expire_time = models.DateTimeField(null=True, blank=True) # 过期时间,可空生成链接的时候,用secrets.token_hex(16)生成随机 token。访问公开分享链接时,只需要校验 token 是否存在、是否过期,不校验用户身份,实现了“公开链接”的效果。这里有一个安全细节要注意:如果分享的是目录,我限制只能下载目录下的所有文件打包 zip,否则共享目录里的所有层级都暴露会带来信息安全隐患。
4. 环境准备、部署上线与性能优化
4.1 完整的环境搭建步骤(从零开始)
环境这块我重新从零搭了一遍,确保下面这些步骤是完整的。操作系统我用的是 Ubuntu 22.04 服务器,Python 版本是 3.10,Django 版本是 4.2 LTS,MySQL 是 8.0。
第一步,安装 Python 和虚拟环境工具:
sudo apt update sudo apt install -y python3 python3-pip python3-venv nginx python3 --version第二步,创建项目目录和虚拟环境:
mkdir -p /opt/cloud_drive cd /opt/cloud_drive python3 -m venv venv source venv/bin/activate第三步,安装 Python 依赖。这里我把所有依赖写进requirements.txt,方便后续直接一键安装:
Django==4.2.10 mysqlclient==2.2.0 django-cors-headers==4.3.1 gunicorn==21.2.0 python-dotenv==1.0.0安装命令:
pip install -r requirements.txt第四步,安装 MySQL 服务器并创建数据库。这里我踩过一个坑:MySQL 8.0 默认的认证插件是 caching_sha2_password,而 mysqlclient 在某些版本下连接会报错。解决办法是在创建用户时明确指定认证插件:
CREATE DATABASE cloud_drive DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci; CREATE USER 'cloud'@'localhost' IDENTIFIED WITH mysql_native_password BY '你的强密码'; GRANT ALL PRIVILEGES ON cloud_drive.* TO 'cloud'@'localhost'; FLUSH PRIVILEGES;第五步,修改 Django 的settings.py,数据库配置如下:
DATABASES = { 'default': { 'ENGINE': 'django.db.backends.mysql', 'NAME': 'cloud_drive', 'USER': 'cloud', 'PASSWORD': '你的强密码', 'HOST': '127.0.0.1', 'PORT': '3306', 'OPTIONS': { 'charset': 'utf8mb4', 'init_command': "SET sql_mode='STRICT_TRANS_TABLES'", }, 'CONN_MAX_AGE': 60, } }socket_timeout和CONN_MAX_AGE的配置很关键。Django 默认每次请求都新建数据库连接,高频访问下连接开销非常大。设置CONN_MAX_AGE=60后,连接会复用 60 秒,实测 QPS 提升明显。但要注意,如果 MySQL 服务端设置了较短的wait_timeout,长连接可能会断掉,这时可以适当调大 MySQL 的wait_timeout参数。
最后执行数据库迁移,并创建超级用户:
python manage.py makemigrations drive python manage.py migrate python manage.py createsuperuser启动开发环境测试:
python manage.py runserver 0.0.0.0:8000访问http://服务器IP:8000/后,能正常看到登录页,说明环境配置成功。
4.2 生产环境部署:Gunicorn + Nginx + MySQL
本地开发没有问题之后,生产环境我选择了 Gunicorn + Nginx 的组合,没有用 uWSGI,因为 Gunicorn 用起来更省心,配置简单,性能也能满足个人网盘的需求。
先启动 Gunicorn:
cd /opt/cloud_drive source venv/bin/activate gunicorn cloud_drive.wsgi:application --bind 127.0.0.1:8000 --workers 3 --timeout 120这里workers数量一般设置为 CPU 核心数 × 2 + 1,对于小项目 3 个 worker 完全够用。timeout设置为 120 秒,是因为大文件下载时如果传输时间太长,worker 可能被 Gunicorn 超时杀死。实际生产我建议再加一层--max-requests参数,防止内存泄漏:
gunicorn cloud_drive.wsgi:application --bind 127.0.0.1:8000 --workers 3 --timeout 120 --max-requests 1000 --max-requests-jitter 100Nginx 配置的核心是处理好静态文件、媒体文件上传大小和反向代理。下面是一份我实测可用的配置:
server { listen 80; server_name your_domain.com; client_max_body_size 10g; # 允许上传最大10GB文件 location /static/ { alias /opt/cloud_drive/static/; } location /media/ { alias /opt/cloud_drive/media/; internal; # 禁止直接访问,必须走 Django 鉴权 } location / { proxy_pass http://127.0.0.1:8000; 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_read_timeout 300s; proxy_send_timeout 300s; proxy_request_buffering off; # 大文件上传时关闭缓冲,直接转发到后端 } }我这里要重点提两个配置:client_max_body_size 10g是必须的,否则 Nginx 默认只允许 1MB 的上传大小;proxy_request_buffering off也很重要,它让 Nginx 不缓冲整个请求体,而是直接把数据流式转发给 Gunicorn,这样大文件上传时不会占满 Nginx 的临时磁盘空间。
4.3 性能优化:文件存储、缓存与并发上传
个人网盘的性能瓶颈通常不在 Django 本身,而在文件 I/O 和数据库查询。我做了几次压测之后,总结了三个最有效的优化点:
第一,物理文件存储目录采用两级哈希结构。不要把所有文件直接丢进media/files/目录下,否则文件多了以后操作系统查找文件会变慢。我按照hash(storage_path) % 128分成 128 个子目录,再按用户 ID 分一层,最后才是文件本身。这样即使有十万个文件,也能保证每个目录下文件数量可控。
第二,文件列表缓存。很多操作只是查看列表,不涉及文件内容。这个场景下,我使用 Django 的cache框架,把某个目录下的文件列表缓存起来,键名是user_id:parent_id。用户执行上传、删除、重命名等操作时主动失效缓存,这样列表查询的响应时间从平均 50ms 降到了 10ms 左右。如果进一步追求性能,可以把缓存放到 Redis 里,不过个人项目用本地内存缓存已经足够。
第三,上传文件的磁盘缓冲。DjangoUploadedFile默认使用TemporaryFileUploadHandler,文件超过一定阈值后会写临时文件。这个处理方式本身没问题,但要注意FILE_UPLOAD_MAX_MEMORY_SIZE的默认值是 2.5MB,超过这个大小就开始落盘。个人云盘场景里,上传带宽往往大于磁盘写入速度,瓶颈通常在磁盘。我实测在机械硬盘服务器上,分片大小设置为 5MB 左右时磁盘写入最稳定,小于 1MB 会造成过多的随机写,大于 20MB 则网络中断重传代价太高。
4.4 数据备份与迁移:不能忽略的日常维护
个人网盘最重要的是数据安全,代码写好了如果数据丢了,那就真是灾难。我的备份策略并不复杂,但很有效:每天凌晨对 MySQL 做一次全量备份,同时对 media 目录做增量同步。
MySQL 备份命令:
mysqldump -u cloud -p cloud_drive --single-transaction --quick --lock-tables=false > /backup/cloud_drive_$(date +%F).sql--single-transaction参数很关键,它让备份在事务中执行,不会锁表,避免备份期间用户无法访问。
media 目录的增量备份我用了 rsync:
rsync -av --delete /opt/cloud_drive/media/ /backup/media_bak/如果你的服务器有多个磁盘,建议把数据库备份和媒体文件备份放到不同的物理磁盘上。数据库在系统盘,媒体备份在数据盘,这样即使系统盘损坏,数据盘上的媒体备份仍然完好。
数据迁移时,只需要把数据库备份文件和 media 目录一起拷到新服务器,先在目标服务器恢复 MySQL 转储文件,再把 media 目录恢复到对应位置,最后执行python manage.py migrate确保数据库结构一致即可。
5. 常见问题排查与避坑指南
5.1 中文文件名乱码与 Content-Disposition 问题
下载文件时,中文文件名在 HTTP 响应头里需要特殊处理。直接用filename=中文名会乱码,因为 HTTP 头默认只支持 ASCII 字符。正确做法是使用 RFC 5987 标准,把文件名 URL 编码后放在filename*=UTF-8''后面:
from urllib.parse import quote filename = quote(node.name.encode('utf-8')) response['Content-Disposition'] = f"attachment; filename*=UTF-8''{filename}"这个坑在 Chrome 下表现不明显,但在 Safari 和部分国产浏览器里会直接显示乱码。另外,如果文件名超过 100 个字节,部分浏览器也会截断,这时可以在filename和filename*两个字段都写上,前者写英文回退名,后者写编码后的中文名,做到最大兼容。
5.2 MySQL 连接数耗尽与线程安全问题
使用 Gunicorn 多 worker 后,MySQL 默认的最大连接数(151)可能不够用。每个 worker 维持一个数据库连接,如果一次请求里开启了多个并发子请求,连接池就会被占满。
我遇到的现象是:网站突然打不开,后端日志报Too many connections。排查后确认是 Gunicorn 3 个 worker × Django 每个请求创建的新连接数量超过了 MySQL 限制。解决办法是在settings.py中设置CONN_MAX_AGE,同时调大 MySQL 的max_connections:
[mysqld] max_connections = 500如果你使用的是腾讯云或阿里云的托管数据库,管理控制台里可以直接调整这个参数。单独的 MySQL 服务器需要改配置文件后重启服务。
5.3 大文件上传超时与磁盘空间满掉
大文件上传最容易出的问题就是“传了一半就断了”。除了网络本身的抖动,还有一个经常被忽略的原因是:临时分片文件占满了/tmp目录。Django 默认的FILE_UPLOAD_TEMP_DIR指向系统临时目录,系统盘通常只有几十 GB,传几个大文件就满了。
解决办法是在settings.py里显式指定临时目录到数据盘:
FILE_UPLOAD_TEMP_DIR = '/opt/cloud_drive/tmp/'同时写一个定时任务,每小时清理超过 24 小时未完成的分片文件:
find /opt/cloud_drive/tmp/ -type f -mtime +1 -delete5.4 常见问题速查表
为了节省大家排查时间,我把项目里遇到的高频问题整理成了一张表:
| 问题现象 | 可能原因 | 解决办法 |
|---|---|---|
登录报OperationalError: (1045, Access denied) | MySQL 用户名/密码错误 | 用命令行连接测试一遍,确认密码无误 |
上传文件提示Csrf verification failed | AJAX 请求未携带 CSRF Token | 在 JS 中获取 Cookie 里的 csrftoken 并加入请求头 |
| 上传大文件后页面加载空白 | Nginxclient_max_body_size太小 | 设置为10g并重启 Nginx |
| 文件下载时浏览器显示乱码文件名 | Content-Disposition 未使用 RFC 5987 格式 | 使用filename*=UTF-8''...编码 |
| 列表页打开很慢 | 数据库缺少索引 | 给FileNode.owner和parent加联合索引 |
| 批量删除时前端请求超时 | 递归删除节点太慢 | 改为异步删除,先标记后清理 |
| 上传分片全部成功但合并失败 | 分片临时目录没权限 | 检查media/chunks目录的写权限 |
| 分享链接打开后 404 | ShareLink 过期时间已过 | 在创建链接时增加有效期提示,前端显示过期状态 |
5.5 一些我认为非常有价值的实战经验
最后分享几个个人经验,这些在官方文档里基本找不到。
第一个是关于文件去重的取舍。很多网盘系统做了全站去重,即不同用户上传相同文件共享同一份物理存储。这样做确实节省空间,但实现时要注意“硬链接计数”。如果你不做引用计数,用户 A 删除文件时把物理文件删了,用户 B 的文件就变成死链接了。我的建议是个人网盘初期先做“同用户去重”就够了,等用户量上来再考虑全站去重,否则排查问题会非常痛苦。
第二个是关于数据库表的字段设计。file_size字段一定要用BigIntegerField,不要用IntegerField。一个 4GB 的视频文件,int 类型最大只能存约 21 亿字节(约 2GB),超过后就会出现负数或者报错。这是网上很多开源网盘源码里最常见的 bug,我见过不止一次有人问“为什么上传 3GB 的文件显示大小是负数”。
第三个是关于安全方面的经验。个人网盘的/media/目录千万不要直接暴露给 Nginx,否则任何人都可以绕过登录直接猜 URL 下载文件。我上面的 Nginx 配置里就把/media/设为了internal;,这样只有 Django 内部重定向才能访问,外部请求一律 404。如果你用的是 PythonAnywhere 或国内一些 PaaS 平台,也要注意确认媒体文件的访问是否经过了 Django 鉴权。
第四个是关于后续扩展的方向。当前这个版本虽然完整,但离“好用”还有一段距离。我计划在下一版里加入“文件夹打包下载”功能,后端用 ZipFile 流式打包,不用先把整个 zip 生成到磁盘再传回浏览器;还会加入“文件回收站”机制,删除文件后先进回收站保留 30 天,30 天后真正清理,避免误删重要数据;再加上“视频在线预览”,对接 HTML5 的 video 标签的 Range 请求,这个如果后面实现了我再写一篇专门的文章。
个人网盘这种项目,看着功能简单,真正做起来涉及到的细节非常多——文件上传的断点续传、数据库的索引设计、生产环境的并发配置、备份策略的制定,每一项都够琢磨一阵子。我写这篇文章的初衷,就是把我踩过的坑、验证过的方案都记录下来,希望后来的人能少走一些弯路。如果你准备上手这个项目,我的建议是:先把基础的目录管理和文件上传下载跑通,再加分片断点续传和秒传,不要一上来就把所有功能都堆进去,否则出了问题很难排查。等整个链路稳定了,再逐步加分享链接、回收站这些锦上添花的功能,这样项目进度会更可控,你也不会在排错过程中失去耐心。
本文还有配套的精品资源,点击获取