☰
C++网盘服务端实战:epoll分片秒传与引用计数删除
2026/10/26 11:36:36 网站建设 项目流程

简介:这份基于C++的网盘系统项目源代码面向具备一定C++基础、希望深入理解云存储服务实现的学生与开发者,提供一套可直接编译运行的完整工程,用于学习服务器端请求处理、文件管理与客户端交互等核心机制。压缩包共114个文件、约7.16MB,以cpp源文件和h头文件为主体,辅以ui界面、qrc资源、pro工程配置、config运行参数、makefile编译脚本及db数据库文件,并附docx、pptx说明材料,覆盖从编码到构建运行的完整环节。已有830人学习,说明其在C++项目实践群体中具有一定参考价值。读者可从中梳理客户端与服务器分层结构、TCP套接字通信、数据库读写、好友与文件业务逻辑以及图形界面组织方式,并借助配置文件和构建脚本完成本地调试,适合课程设计、毕业设计选题参考或二次开发实践。

1. 很多人以为网盘就是一个上传下载页,真正难的是文件删不干净

搜「基于C++的网盘系统项目源代码」的人,八成想找的是一个能传能下的网页 demo。真把源码跑起来就会发现,难的部分在于把一个文件拆成元数据、分片、引用计数三份状态之后,怎么保证删掉一份不误伤另一份。这份 C++ 网盘服务端就是围绕这条主线写的:epoll 事件循环接 HTTP,Token 做鉴权,分片上传配合 SHA256 实现秒传和断点续传,MySQL 存目录树,本地磁盘存物理块,前端是纯静态页面。它不适合想找现成商业产品的人,适合学过 C++11、想找一个规模刚好、能一口气读完的服务端项目练手的人,也适合给小组内网搭一个文件中心。

2. 骨架先摸清:技术栈选型、目录结构和第一次编译

拿到一份 C++ 服务端源码,我的习惯是先别看 main,先把目录扫一遍,再去找构建脚本。目录结构能告诉你作者是怎么分层的,构建脚本能告诉你它依赖了什么。这两件事搞明白,后面读任何一条业务链路都不会迷路。

2.1 技术选型:为什么是 epoll + 线程池 + MySQL,而不是上现成框架

这个项目的依赖清单很短:Linux、CMake、pthread、MySQL 客户端库,外加一个自己写的 HTTP 解析器。没有 muduo,没有 Boost.Asio,也没有 protobuf。这个选择对学习者其实是有利的——框架会把手写 IO 的细节藏起来,而网盘这类项目真正值得看的就是 IO 和状态管理。

网络模型是单 Reactor 加固定大小线程池:主线程只跑epoll_wait,把就绪事件分发给工作线程,工作线程负责解析、查库、读写文件。这套模型扛不住十万并发,但对一个内网文件中心绰绰有余,而且代码量控制在两三千行,能完整读完。

存储侧走的是「物理文件 + 逻辑文件」两层的设计。物理层按内容的 SHA256 命名落盘,逻辑层是用户在目录树里看到的文件名和路径。两层之间用引用计数连着,这是秒传、也是安全删除的基础。元数据全放 MySQL,用 InnoDB 事务兜住「建逻辑文件」和「物理文件计数 +1」这两步的原子性。

鉴权用的是 JWT,服务端不存 session,Token 里塞 uid 和过期时间,签名密钥放配置文件。这样做的好处是水平扩容时不用共享会话状态,坏处是注销只能靠客户端丢 Token,或者自己再维护一张黑名单表。项目里选的是前者,够用。

2.2 目录结构逐层看:每个目录对应哪条业务链路

目录职责关键文件
src/net事件循环、连接生命周期、监听套接字EventLoop.cpp、Connection.cpp、Acceptor.cpp
src/http报文解析、路由表、响应封装HttpParser.cpp、Router.cpp
src/authJWT 签发验签、接口级鉴权Jwt.cpp、AuthGuard.cpp
src/service业务编排:上传、文件、分享、用户UploadService.cpp、FileService.cpp
src/daoSQL 封装,一个 DAO 对应一张主表FileDao.cpp、UploadTaskDao.cpp
src/storage存储后端抽象,默认本地磁盘实现IStorage.h、LocalStorage.cpp
src/common日志、配置解析、字符串与哈希工具Config.cpp、Log.cpp
www前端静态页,登录、列表、上传index.html、upload.js
sql建表脚本schema.sql

分层是严格的单向依赖:net不知道service存在,service只通过dao和storage碰数据,http夹在中间做协议转换。想加一个新接口,改动范围是「路由表注册一行 + service 加一个方法」,不会牵一发动全身。

2.3 从零编译:依赖、CMake 和第一次启动

先把系统依赖装上,不同发行版的包名略有差别,MySQL 客户端开发包一般叫libmysqlclient-dev或mariadb-devel。然后走标准 CMake 流程:

# 安装编译依赖(Debian/Ubuntu 系) sudo apt install -y build-essential cmake libmysqlclient-dev # 生成构建目录,Release 模式,关掉调试符号体积 cmake -S . -B build -DCMAKE_BUILD_TYPE=Release cmake --build build -j$(nproc) # 初始化数据库,把 schema.sql 灌进去 mysql -uroot -p netdisk < sql/schema.sql # 前台启动,日志直接打到终端,方便第一次排查 ./build/netdisk_server -c conf/server.conf

CMakeLists.txt里最关键的是这两段:

cmake_minimum_required(VERSION 3.16) project(netdisk_server CXX) set(CMAKE_CXX_STANDARD 17) set(CMAKE_CXX_STANDARD_REQUIRED ON) # 依赖用 find_package 找系统库,找不到就在配置阶段直接报错,别拖到链接期 find_package(Threads REQUIRED) find_package(MySQL REQUIRED) include_directories(${PROJECT_SOURCE_DIR}/include ${MYSQL_INCLUDE_DIR}) file(GLOB_RECURSE SRC_LIST ${PROJECT_SOURCE_DIR}/src/*.cpp) add_executable(netdisk_server ${SRC_LIST}) target_link_libraries(netdisk_server PRIVATE Threads::Threads ${MYSQL_LIBRARIES})

CMAKE_CXX_STANDARD 17是必须的,源码里用了std::filesystem和结构化绑定。GLOB_RECURSE递归收集源文件,好处是加文件不用改构建脚本,坏处是新增文件后必须重新跑一次cmake,不然编译产物里没有它——这一点第一次踩坑的人特别多。

配置文件的字段不多,主要就这几项:

# conf/server.conf listen_port = 8080 thread_num = 8 mysql_host = 127.0.0.1 mysql_db = netdisk mysql_user = netdisk mysql_pass = change_me storage_root = /data/netdisk/files jwt_secret = 换成你自己的长随机串 chunk_size = 4194304

thread_num建议等于 CPU 核数,再大就是线程切换在浪费时间。chunk_size默认 4MB,是上传分片和断点续传的对齐单位,改它意味着前后端要同时改,因为前端切片的字节数要跟服务端一致。

3. 请求链路:一个上传请求从 epoll 事件到落盘的完整路径

这条链路是整个项目里最长的一条,也是最值得逐段读的。从内核把一个可读事件放进就绪队列开始,到最终write把字节刷进磁盘,中间要过事件分发、HTTP 解析、路由匹配、鉴权、业务编排、DAO、存储七层。下面拆开看。

3.1 网络层:Reactor 事件循环和线程池怎么分工

事件循环本身很短,但几个细节决定了它跑不跑得动:

// src/net/EventLoop.cpp void EventLoop::run() { constexpr int kMaxEvents = 1024; std::vector<epoll_event> events(kMaxEvents); while (!quit_.load(std::memory_order_relaxed)) { // -1 表示没有事件时阻塞,避免空转把 CPU 打满 int n = epoll_wait(epoll_fd_, events.data(), kMaxEvents, -1); if (n < 0) { if (errno == EINTR) continue; // 被信号打断,重试即可 log_error("epoll_wait failed: %s", strerror(errno)); break; } for (int i = 0; i < n; ++i) { // ptr 里挂的是 Connection 的裸指针,生命周期由 ConnectionPool 保证 auto* conn = static_cast<Connection*>(events[i].data.ptr); if (events[i].events & (EPOLLIN | EPOLLRDHUP)) { pool_.submit([conn] { conn->onReadable(); }); } if (events[i].events & EPOLLOUT) { pool_.submit([conn] { conn->onWritable(); }); } } } }

kMaxEvents是一次epoll_wait最多取回的事件数,1024 对中等负载够用,业务量再大就调高,但每个事件占 12 字节左右,别拍脑袋写个十万。EPOLLRDHUP必须监听,否则对端半关闭连接时你不会收到通知,连接会一直挂在池子里漏掉。

这里有个容易忽略的点:同一个 fd 不能被两个工作线程同时处理。源码里给每个 fd 注册时用了边缘触发加EPOLLONESHOT,事件被取走后该 fd 就不再上报,处理完由工作线程显式重新epoll_ctl注册。这套组合把一个连接的所有事件串行化了,省掉了业务层加锁。

还有一点血泪经验:accept出来的新 fd 一定要设成非阻塞,accept4带上SOCK_NONBLOCK最省事。如果忘了这一步,边缘触发模式下必须一次性把缓冲区读空,而阻塞 fd 上的read会卡住工作线程,整个线程池迟早被拖死。

3.2 协议层:HTTP 解析、路由表和一层薄鉴权

HTTP 解析器是手写的状态机,逐字节喂入,遇到\r\n\r\n结束头部,然后按Content-Length或 chunked 决定要不要继续收 body。写得不复杂,但边界处理要小心:头字段名大小写不敏感、URL 里的百分号编码要解码、请求行里的路径要单独存一份不带 query 的。

路由表是个unordered_map,key 是「方法 + 归一化路径」,value 是处理函数加一个是否需要登录的布尔:

// src/http/Router.cpp void Router::dispatch(const HttpRequest& req, HttpResponse& resp, Connection* conn) { // 例如 "POST /api/upload/chunk",动态段会先被 matchPath 归一成占位符 std::string key = req.method + " " + matchPath(req.path); auto it = table_.find(key); if (it == table_.end()) { resp.setStatus(404); resp.setJson(R"({"code":404,"msg":"no route"})"); return; } // 需要登录的接口先过鉴权,失败直接 401,不往下走 if (it->second.need_auth && !AuthGuard::check(req, resp)) return; it->second.handler(req, resp, conn); }

matchPath负责把/api/file/12345/download归一成/api/file/:id/download,这样一条路由能覆盖一整个资源族。归一化过程只做一次字符串扫描,性能上没有负担。

鉴权放在路由之后、业务之前,是个很自然的位置——粒度是「接口级」,不关心具体资源归属。资源归属的检查(这个文件是不是你的)必须放在 service 里,两件事不能混:

// src/auth/AuthGuard.cpp bool AuthGuard::check(const HttpRequest& req, HttpResponse& resp) { auto auth = req.header("Authorization"); // 形如 "Bearer eyJhbGci..." if (auth.rfind("Bearer ", 0) != 0) { resp.setStatus(401); return false; } // verify 内部做 HMAC 验签 + exp 过期时间比对 auto payload = Jwt::verify(auth.substr(7), secret_); if (!payload) { resp.setStatus(401); return false; } // uid 挂在 request 上,下游 handler 直接取,不用再查一次库 req.setUserId(payload->uid); return true; }

Jwt::verify里最容易被写漏的是过期校验和时间基准。exp是 Unix 秒,服务端时钟必须跟客户端同步,容器里跑的话记得校时,否则会出现「Token 刚发就过期」这种玄学问题。

3.3 存储层:分片上传、秒传和断点续传怎么串起来

上传的第一步不是传字节,是先问服务端「这个文件你有了吗」。前端算完整个文件的 SHA256,把哈希和大小发过去,服务端判断:

// src/service/UploadService.cpp UploadInitResult UploadService::init(uint64_t uid, const std::string& sha256, uint64_t size) { // 物理层命中:内容已经在盘上,直接建一条逻辑记录并给计数加一 auto exist = fileDao_.findBySha256(sha256); if (exist) { fileDao_.incRef(exist->id); auto logicId = userFileDao_.create(uid, exist->id, size); return UploadInitResult::fast(logicId); } // 没命中:开一个上传任务,把还缺的分片号告诉前端 auto task = uploadTaskDao_.create(uid, sha256, size, chunkSize_); return UploadInitResult::normal(task.uploadId, uploadTaskDao_.missingChunks(task)); }

sha256是全局去重的唯一依据,表上有唯一索引,所以并发上传同一个文件也不会产生两份物理文件——第二个请求会在findBySha256或唯一键冲突时落到秒传分支。missingChunks返回的是位图里所有为 0 的位置,前端拿着这个列表只传缺的片,断点续传就是这一句话的事,不需要额外的续传协议。

分片落盘的顺序是先写临时文件再原子替换:每片写到{storage_root}/tmp/{upload_id}/{index}.part,全部齐了以后按索引顺序拼成完整文件,算一遍 SHA256 跟声明值比对,一致才rename到{storage_root}/{sha256前两位}/{sha256}。多一级两级目录是为了避免单目录下文件过多导致readdir变慢,这个坑在文件数过十万以后会非常明显。

rename在同一文件系统内是原子的,所以这个流程即使中途断电,也不会出现「半截文件被当成完整文件」的情况。这是个很便宜但收益很高的设计,值得抄。

4. 元数据与鉴权:MySQL 表结构、Token 校验和文件去重

服务端跑不跑得稳,八成看表设计和事务边界。网盘这个业务的特点是「写少读多、删除敏感」,一张表设计歪了,后面补多少业务代码都救不回来。

4.1 表设计:物理文件、逻辑文件、上传任务、分享四张表的关系

核心就三张表加一张任务表:

-- 物理文件:一份内容只有一行,ref_count 为 0 时才允许删盘 CREATE TABLE t_file ( id BIGINT UNSIGNED PRIMARY KEY AUTO_INCREMENT, sha256 CHAR(64) NOT NULL, size BIGINT UNSIGNED NOT NULL, storage_path VARCHAR(255) NOT NULL COMMENT '相对 storage_root 的路径', ref_count INT UNSIGNED NOT NULL DEFAULT 0, created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, UNIQUE KEY uk_sha256 (sha256) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; -- 逻辑文件:用户在目录树里看到的东西,多条记录可以指向同一个 t_file CREATE TABLE t_user_file ( id BIGINT UNSIGNED PRIMARY KEY AUTO_INCREMENT, uid BIGINT UNSIGNED NOT NULL, file_id BIGINT UNSIGNED NOT NULL, parent_id BIGINT UNSIGNED NOT NULL DEFAULT 0 COMMENT '0 表示根目录', name VARCHAR(255) NOT NULL, is_dir TINYINT(1) NOT NULL DEFAULT 0, deleted TINYINT(1) NOT NULL DEFAULT 0, KEY idx_uid_parent (uid, parent_id, deleted), KEY idx_file (file_id) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;

t_file上的uk_sha256是秒传的地基。t_user_file上的idx_uid_parent是列目录的性能关键,少了它,用户文件一多就是全表扫。deleted做软删除是为了回收站,真实删除是异步任务扫的——同步删盘会让接口响应时间被磁盘 IO 绑架。

上传任务表用位图记进度:

CREATE TABLE t_upload_task ( upload_id CHAR(32) PRIMARY KEY, uid BIGINT UNSIGNED NOT NULL, sha256 CHAR(64) NOT NULL, total_size BIGINT UNSIGNED NOT NULL, chunk_size INT UNSIGNED NOT NULL, done_bits VARBINARY(1024) NOT NULL COMMENT '第 n 位为 1 表示第 n 片已落盘', updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, KEY idx_uid_time (uid, updated_at) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;

done_bits用位图而不是一张分片明细表,省的是行数:一个 10GB 文件按 4MB 切片是 2560 片,明细表就是 2560 行,位图只要 320 字节。代价是不能记每片的校验值,不过分片本身在拼装后会整体验一次 SHA256,够用。

4.2 登录与 Token:无状态会话怎么落地

登录接口做三件事:查用户、验密码、签 Token。密码存的是加盐哈希,别用裸 MD5:

// src/service/UserService.cpp std::string UserService::login(const std::string& account, const std::string& password) { auto user = userDao_.findByAccount(account); if (!user) throw AuthError("account or password wrong"); // salt 每用户独立,存在 t_user 里;比对用固定时间比较,避免时序侧信道 auto hash = sha256_hex(password + user->salt); if (!constant_time_equal(hash, user->pass_hash)) { throw AuthError("account or password wrong"); } // 有效期 7 天,载荷里只放 uid 和 exp,别塞用户信息,Token 会越签越大 return Jwt::sign({{"uid", user->id}, {"exp", now() + 7 * 86400}}, secret_); }

错误信息统一成一句话,不区分「用户不存在」和「密码错误」,这是基本操作,能挡掉一批账号枚举。constant_time_equal是自己写的逐字节比较,别用strcmp,编译器可能把它优化成提前返回。

Token 有效期给 7 天是权衡的结果:太短用户天天重登,太长泄露后窗口太大。真要提高安全性就上双 Token(access + refresh),项目里为了压缩复杂度没做,二次开发时可以补。

4.3 去重与引用计数:秒传命中之后,谁负责删文件

这是整个项目里最容易写错的一段。删除逻辑必须满足两个约束:引用计数减到 0 才删盘;删盘动作要在数据库事务提交之后。

// src/service/FileService.cpp void FileService::remove(uint64_t uid, uint64_t userFileId) { auto uf = userFileDao_.get(userFileId); if (!uf || uf->uid != uid) throw PermissionDenied(); // 归属校验必须在 service 层 uint64_t fileId = uf->fileId; { // 事务只包住两次数据库写,磁盘操作放到提交之后 Transaction tx(db_); userFileDao_.erase(userFileId); fileDao_.decRef(fileId); tx.commit(); } // 事务提交后再查一次计数,为 0 说明没人引用了,可以真正删盘 if (fileDao_.get(fileId)->refCount == 0) { storage_->remove(fileDao_.get(fileId)->storagePath); fileDao_.erase(fileId); } }

顺序反过来的话,会出两种事故:先删盘再提交,事务回滚后数据库里还有记录,但文件没了,用户点下载就是 404;或者计数减到 0 后没删盘,磁盘上留下一堆孤儿文件,跑三个月磁盘告警。前者是数据不一致,后者是资源泄漏,都得防。

并发删除同一个文件也要考虑。两个请求同时把ref_count从 1 减到 0,两次都触发删盘。解法是decRef用UPDATE t_file SET ref_count = ref_count - 1 WHERE id = ? AND ref_count > 0,再判断影响行数,只有拿到行锁并成功减一的那一方才继续走删除流程。

物理文件按sha256前两位分目录,路径在t_file.storage_path里存相对路径,换存储根目录时不用改数据。这个细节看着小,迁移的时候能救命。

5. 避坑与排查:编译、运行、联调阶段最容易翻车的五处

源码能编译通过只是第一步。下面这五条是我在不同机器上复现过的真实问题,按出现频率排。

5.1 编译和依赖阶段的两个坑

现象一:cmake配置阶段报Could NOT find MySQL,但系统里明明装了 MySQL。

原因:FindMySQL.cmake找的是mysql_config这个可执行文件,只装服务端不装开发包时没有它;或者装了 MariaDB,头文件路径叫mariadb,模块找不到。

解决:装libmysqlclient-dev,然后手动指路径cmake -S . -B build -DMYSQL_INCLUDE_DIR=/usr/include/mysql -DMYSQL_LIBRARIES=/usr/lib/x86_64-linux-gnu/libmysqlclient.so。嫌麻烦就在CMakeLists.txt里加一段回落逻辑,find_package失败时用pkg-config再找一次。

现象二:新增了一个.cpp,make成功但链接时报undefined reference。

原因:file(GLOB_RECURSE ...)的结果在cmake配置阶段就固化了,新文件没进列表,编译器压根没编它。

解决:新增源文件后重跑一次cmake -S . -B build。或者干脆改成显式列出源文件,虽然啰嗦但不会再有这类问题——项目文件数上到五十个以后,我一般会换回显式列表。

5.2 运行和网络层的坑

现象三:服务起来了,curl打得通,但top里 CPU 一直贴着 100%。

原因:epoll_wait的超时参数写成了 0,变成忙轮询;或者用了边缘触发但accept后没设非阻塞,read没读干净,事件反复触发。

解决:超时给 -1(阻塞)或一个较大的毫秒数;accept4带SOCK_NONBLOCK;read循环读到返回EAGAIN为止。排查的时候strace -c -p <pid>看一眼epoll_wait的调用次数,每秒几万次就基本能确认是空转。

现象四:传大文件时进程被 OOM killer 干掉,或者报Too many open files。

原因:前者是 HTTP 解析器把整个 body 一次性读进内存,几个 GB 的文件直接把内存打满;后者是前端分片并发数开太大,每片一个连接,fd 耗尽。

解决:body 必须流式处理,超过阈值(比如 1MB)就边读边写临时文件,别攒在内存里。fd 上限用ulimit -n 65535抬高,同时在 Nginx 侧限制单 IP 并发连接数,前端把并发切片数压到 3~5 个,实测这个区间吞吐已经接近带宽上限了。

5.3 前后端联调与数据一致性的坑

现象五:秒传一个文件,然后删掉其中一个副本,另一个副本下载变成 404。

原因:删除逻辑没走引用计数,直接按file_id删了物理文件;或者ref_count在秒传分支里忘了+1,计数从 0 开始,第一次删就归零。

解决:秒传分支必须和普通上传分支走同一套「建逻辑记录 + 计数加一」的代码路径,别为了快写两份。验收方法是造一个用例:同一个文件上传三次,删两次,第三次下载必须成功;再删一次,磁盘上对应文件才消失。这个用例我每次改动删除相关代码都会跑一遍,比看代码靠谱。

另外提一句跨域:前端静态页和后端不同端口时,OPTIONS预检请求会先到,路由表里如果没有OPTIONS的兜底处理,浏览器直接报跨域失败,而curl一切正常,很容易误判成后端问题。在Router最前面拦一下OPTIONS直接返回 204 加几个头就完事。

6. 二次开发:把存储后端抽成可插拔,让单机网盘有扩容的余地

这个项目默认只能往本地磁盘写,单机跑没问题,一旦要接对象存储或者多机共享就卡住了。改造点其实只有一个:把LocalStorage里那些std::filesystem调用收进接口后面。

接口定义成这样,五个方法覆盖了网盘的全部存储动作:

// include/storage/IStorage.h class IStorage { public: virtual ~IStorage() = default; // key 是 sha256,localPath 是本地临时文件;返回 false 表示这次写入失败可重试 virtual bool put(const std::string& key, const std::string& localPath) = 0; virtual bool get(const std::string& key, const std::string& localPath) = 0; virtual bool remove(const std::string& key) = 0; // 本地实现返回静态文件路径,对象存储实现返回带签名的临时 URL virtual std::string url(const std::string& key, int expireSec) = 0; };

配套一个注册表,用配置文件里的字符串选实现:

// src/storage/StorageFactory.cpp IStorage* StorageFactory::create(const Config& cfg) { const auto& kind = cfg.get("storage_kind", "local"); if (kind == "local") return new LocalStorage(cfg.get("storage_root")); // 想接别的后端,在下面加分支并注册进去,业务层一行都不用改 if (kind == "s3") return new S3Storage(cfg); throw ConfigError("unknown storage_kind: " + kind); }

改造顺序建议是:先把所有storage_调用点换成接口调用,跑通现有功能;再实现第二个后端;最后才动业务层。验证方法很土但有效——准备一个 100MB 的文件,跑一遍「上传 → 秒传 → 断点续传(中途 kill 掉重来)→ 下载比对 SHA256 → 删除」,两套后端各跑一遍。这个脚本我一般会写成 shell,改一次存储层就跑一次,比点网页快得多。

还有两个值得动手的方向。一是把t_upload_task的过期清理做成定时任务,updated_at超过 24 小时的任务直接删掉临时目录,不然tmp会慢慢撑爆磁盘。二是给decRef加一个「延迟删除队列」,把删盘动作丢给后台线程,接口响应时间能砍掉一大截,代价是要处理删除失败的重试。

从那以后我每次接一个陌生的 C++ 服务端项目,都先把事件循环和存储抽象这两块翻出来看一遍,再决定要不要动手改——这两处定了,剩下的都是体力活。希望帮到你。

本文还有配套的精品资源,点击获取

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询