KnowFlow 项目学习笔记(一):上传流水线深度解析
写在前面:本学习笔记基于 KnowFlow 项目源码逐行解析,深度讲解文件上传流水线的实现细节(分片上传、断点续传、MinIO 存储、Redis Bitmap 去重)。并指出源码里的 4 处严重问题(❗❗ 标记),给出修复方案。适合面试前深度学习,确保”傻子都能懂”。
一、什么是上传流水线?
傻子都能懂的解释:
想象你要上传一个 100MB 的文件到服务器:
- 传统方式:直接上传整个文件,如果网络断了,得重新上传(从头开始)
- 分片上传:把文件切成 20 个 5MB 的小块,分别上传,哪个块失败了就重传哪个块(不用重新上传整个文件)
KnowFlow 的上传流水线:
- 前端:把文件切成 5MB 的小块(分片),逐个上传
- 后端:每个分片上传到 MinIO(对象存储),并在 Redis 里记录”这个分片已经上传了”
- 合并:所有分片都上传完后,后端把分片合并成完整文件
- 断点续传:如果网络断了,前端可以问后端”哪些分片已经上传了”,只上传没上传的分片
二、源码解析:UploadService.java
文件位置:PaiSmart-zuzhi/src/main/java/com/yizhaoqi/smartpai/service/UploadService.java
2.1 分片上传:uploadChunk() 方法
源码(第 68-236 行):
1 | |
逐行解析(傻子都能懂版):
第 69-75 行:方法参数
fileMd5:整个文件的 MD5 值(用来唯一标识一个文件,比如你上传了简历.pdf,MD5 是abc123,下次再上传同一个文件,MD5 还是abc123,就可以秒传)chunkIndex:这是第几个分片(比如第 0 个、第 1 个…)totalSize:文件总大小(用来计算要切成多少片)fileName:文件名(比如简历.pdf)file:分片文件(Spring 的MultipartFile对象)orgTag:组织标签(用来做权限控制,比如”技术部”的人只能看技术部的文件)isPublic:是否公开(true = 所有人都能看,false = 只有自己能看)userId:谁上传的
第 78-101 行:检查文件记录是否存在
- 去数据库查:
SELECT * FROM file_upload WHERE file_md5 = ? AND user_id = ? - 如果不存在,插入一条新记录:
INSERT INTO file_upload (file_md5, file_name, total_size, status, user_id, org_tag, is_public) VALUES (...) status = 0表示”上传中”
- 去数据库查:
第 103-104 行:检查这个分片是否已经上传了
- 调用
isChunkUploaded()方法(后面会讲)
- 调用
为什么要在 Redis 里记录分片状态?
- 如果每次都去数据库查”这个分片是否已经上传”,性能很差(数据库磁盘 I/O)
- Redis 是内存数据库,查询速度极快(毫秒级)
- 用 Redis 的 Bitmap(位图)数据结构:每个分片用一个 bit 表示(0 = 未上传,1 = 已上传),非常节省空间
2.2 Redis Bitmap 记录分片状态
源码(第 344-361 行):
1 | |
傻子都能懂的解释:
什么是 Bitmap?
- Bitmap 就是”位图”,用一串 0 和 1 表示状态
- 比如一个文件有 10 个分片,Redis 里存的就是:
0000000000(10 个 0) - 如果第 3 个分片上传成功了,就变成:
0001000000(第 3 位是 1) - 如果第 5 个分片也上传成功了,就变成:
0001001000
为什么用 Bitmap?
- 节省空间:每个分片只占用 1 bit(1/8 字节),1000 个分片才占用 125 字节
- 查询快:
GETBIT命令是 O(1) 时间复杂度(瞬间返回) - 批量查询快:可以一次性取出所有 bit(后面
getUploadedChunks()方法会讲)
代码解析:
第 351 行:
String redisKey = "upload:" + userId + ":" + fileMd5;- Redis key 格式:
upload:用户ID:文件MD5 - 比如:
upload:1001:abc123 - 这样每个用户、每个文件都有独立的 Bitmap
- Redis key 格式:
第 352 行:
boolean isUploaded = redisTemplate.opsForValue().getBit(redisKey, chunkIndex);- 调用 Redis 的
GETBIT命令:GETBIT upload:1001:abc123 3 - 返回第 3 位的值(0 或 1)
- 调用 Redis 的
2.3 标记分片为已上传
源码(第 370-385 行):
1 | |
代码解析:
- 第 378 行:
redisTemplate.opsForValue().setBit(redisKey, chunkIndex, true);- 调用 Redis 的
SETBIT命令:SETBIT upload:1001:abc123 3 1 - 把第 3 位设为 1(表示已上传)
- 调用 Redis 的
2.4 上传分片到 MinIO
源码(第 162-202 行):
1 | |
傻子都能懂的解释:
什么是 MinIO?
- MinIO 是一个开源的对象存储服务(类似阿里云 OSS、腾讯云 COS)
- 用来存储文件(比如 PDF、图片、视频)
- 好处:可以部署在本地(不用花钱买云服务)
代码解析:
第 164-169 行:计算分片的 MD5 值
DigestUtils.md5Hex(fileBytes):计算分片文件的 MD5(用来校验文件完整性)- 比如分片文件内容是
hello,MD5 是abc123 - 上传到 MinIO 后,再计算一次 MD5,如果还是
abc123,说明文件没损坏
第 171-172 行:构建存储路径
storagePath = "chunks/" + fileMd5 + "/" + chunkIndex;- 比如:
chunks/abc123/0(第 0 个分片)、chunks/abc123/1(第 1 个分片)
第 180-187 行:上传到 MinIO
PutObjectArgs.builder():构建上传参数.bucket("uploads"):上传到uploads桶(类似文件夹).object(storagePath):对象名(存储路径).stream(file.getInputStream(), file.getSize(), -1):文件流minioClient.putObject(putObjectArgs):执行上传
第 206-213 行:标记分片为已上传
- 调用
markChunkUploaded()方法(前面讲过了) - 注意:这里如果标记失败,不会抛出异常(因为分片已经上传成功了,只是 Redis 没标记,下次还会重新上传这个分片,浪费带宽但不影响功能)
- 调用
2.5 合并分片:mergeChunks() 方法
源码(第 541-676 行):
1 | |
傻子都能懂的解释:
合并分片的过程:
- 从数据库查询所有分片信息(
SELECT * FROM chunk_info WHERE file_md5 = ? ORDER BY chunk_index ASC) - 检查分片数量是否完整(比如文件有 10 个分片,数据库里必须有 10 条记录)
- 检查每个分片文件是否存在于 MinIO(调用
statObject()方法) - 调用 MinIO 的
composeObject()方法,把所有分片合并成一个文件 - 删除分片文件(节省存储空间)
- 删除 Redis 里的分片状态记录(节省内存)
- 更新数据库里的文件状态(
status = 1表示”已完成”) - 生成预签名 URL(用来下载文件)
什么是预签名 URL?
- 预签名 URL 是一个临时下载链接(比如有效期 1 小时)
- 用户点击这个链接,可以直接下载文件(不需要登录)
- 好处:不用把文件存在服务器本地,直接从 MinIO 下载(节省服务器带宽)
三、❗❗ 源码里的问题
问题 1:分片上传没有加锁,导致并发问题!
看代码(第 78-101 行):
1 | |
场景重现:
- 线程 A 检查文件记录是否存在:
fileExists = false - 线程 B 检查文件记录是否存在:
fileExists = false(因为线程 A 还没插入) - 线程 A 插入文件记录:
INSERT INTO file_upload ... - 线程 B 插入文件记录:
INSERT INTO file_upload ... - 结果:数据库里有两条相同的文件记录(MD5 一样)!
修复方案:加分布式锁
1 | |
问题 2:Redis Bitmap 的 key 没有设置过期时间!
看代码(第 377-378 行):
1 | |
问题:
- Redis key 没有设置过期时间(TTL)
- 如果文件上传完成后,没有调用
deleteFileMark()方法,这个 key 会一直存在 Redis 里(占用内存)
场景重现:
- 用户上传了 1000 个文件,每个文件的 Bitmap 占用 125 字节(1000 个分片)
- 如果上传完成后没有清理,1000 个 key 占用 125KB(不多)
- 但如果有 100 万个用户,每个用户上传 1000 个文件,就会占用 125GB(爆炸)!
修复方案:设置过期时间
1 | |
问题 3:合并分片时没有事务,导致数据不一致!
看代码(第 614-647 行):
1 | |
问题:
- 如果”清理分片文件”成功,但”更新文件状态”失败,会怎么样?
- 数据库里
status还是 0(上传中),但分片文件已经被删除了 - 用户再想上传这个文件,会认为”分片已经上传了”(因为 Redis 里有记录),但实际上 MinIO 里已经没有分片文件了
修复方案:加事务
1 | |
更好的方案:先更新数据库,再删除文件(即使删除文件失败,数据库状态是对的)
问题 4:没有限制文件大小,容易被攻击!
看代码(第 68-69 行):
1 | |
问题:
totalSize是前端传过来的,没有校验- 如果前端传一个
totalSize = 999999999999999(超大文件),后端会接受 - 如果有人恶意上传超大文件,会占满磁盘空间(DDoS 攻击)
修复方案:限制文件大小
1 | |
四、面试八股文
4.1 什么是分片上传?为什么要用分片上传?
答:
- 分片上传是把大文件切成小块,分别上传
- 好处:
- 断点续传:如果网络断了,只需要重传失败的分片(不用重新上传整个文件)
- 并行上传:多个分片可以同时上传(提高上传速度)
- 失败重试:某个分片上传失败,只需要重传这个分片
4.2 什么是 Redis Bitmap?为什么要用 Bitmap 记录分片状态?
答:
- Bitmap 是 Redis 的一种数据结构,用一串 0 和 1 表示状态
- 好处:
- 节省空间:每个分片只占用 1 bit(1/8 字节)
- 查询快:
GETBIT命令是 O(1) 时间复杂度 - 批量查询快:可以一次性取出所有 bit
4.3 什么是 MinIO?为什么要用 MinIO?
答:
- MinIO 是一个开源的对象存储服务(类似阿里云 OSS)
- 好处:
- 免费:可以部署在本地(不用花钱买云服务)
- 高性能:支持分布式部署(可以横向扩展)
- 兼容 S3 协议:可以用 AWS S3 的 SDK 访问
4.4 什么是预签名 URL?为什么要用预签名 URL?
答:
- 预签名 URL 是一个临时下载链接(比如有效期 1 小时)
- 好处:
- 安全性:不需要把文件存在服务器本地(减少服务器被攻击的风险)
- 节省带宽:用户直接从 MinIO 下载(不用经过服务器)
- 权限控制:可以设置有效期(过期后链接失效)
五、总结
KnowFlow 的上传流水线:
- 分片上传:把文件切成 5MB 的小块,逐个上传
- Redis Bitmap 记录分片状态:每个分片用一个 bit 表示(0 = 未上传,1 = 已上传)
- MinIO 存储分片:每个分片上传到 MinIO(对象存储)
- 合并分片:所有分片都上传完后,调用 MinIO 的
composeObject()方法合并 - 预签名 URL:生成临时下载链接(有效期 1 小时)
源码里的问题(❗❗):
- 分片上传没有加锁,导致并发问题
- Redis Bitmap 的 key 没有设置过期时间
- 合并分片时没有事务,导致数据不一致
- 没有限制文件大小,容易被攻击
修复方案:
- 加分布式锁(Redisson)
- 设置过期时间(
redisTemplate.expire()) - 加事务(
@Transactional) - 限制文件大小(后端校验)
下一篇预告:《KnowFlow 项目学习笔记(二):混合检索深度解析》
参考资料:
- KnowFlow 项目源码:https://github.com/your-repo/KnowFlow
- MinIO 官方文档:https://min.io/docs/
- Redis Bitmap 官方文档:https://redis.io/docs/data-types/bitmaps/
最后更新:2026-06-08 21:45:00