Skip to content
掌握度

文件上传与解析模块实现了大文件的分片上传、断点续传、文件合并以及文档解析功能。

通过 Redis 和 MinIO 的结合,确保大文件上传的可靠性;并通过 Kafka 实现异步处理。模块支持多种文档格式(PDF、Word、Excel)的解析,并提取文本内容用于后续向量化处理。文本向量化通过调用阿里云百炼的 API 实现,生成的向量数据目前存储在 Elasticsearch 中,未来将同时支持 FAISS 存储。

分片上传流程 ​

分片上传是前端和后端配合做的。用户选完文件,前端先不急着传——先用 SparkMD5 分块计算整个文件的 MD5,作为文件的唯一标识。这一步是在上传之前完成的,所以‘这个文件传没传过、传到哪了’在发第一个请求之前就能问服务端。计算也是分块喂给哈希器的,任何时刻内存里只有一个 5MB,传大文件不会撑爆浏览器。

然后按 5MB 一片切片,开四个并发上传。并发数是刻意的:太高会给服务端和带宽造成压力,太低又吃不满带宽。

每个分片到后端,核心逻辑是幂等去重。我用三个存储共同判断这个分片传过没有:Redis 里一个位图,每个分片占一个 bit,记进度;MySQL 里存分片元数据;MinIO 里存分片内容本身。查询按开销从低到高——先查 Redis 位,再查数据库,最后才做 MinIO 的对象存在性检查,前两关没过就不用发网络请求去对象存储。

三种结果:三方都齐,说明传过,直接返回成功,不重复存储;只有 Redis 缺失、另外两方都在,说明只是缓存丢了,从数据库回填位图,照样幂等返回——所以 Redis 宕机断点进度也不丢,位图随时能从事实层重建;剩下所有不一致的情况,先把脏状态清掉,再当新分片走完整的写入流程。

写入是有顺序的:先写 MinIO 存内容,再写 MySQL 存元数据,最后才标 Redis 位图。这个顺序保证了任何一步失败,遗留的都是可恢复的状态——内容写失败就干净中断让客户端重试;元数据写失败会留一个孤儿对象,但分片存储路径是由 MD5 和索引确定的,重传时正好复写覆盖;位图标记失败就直接吞掉,因为数据库才是事实来源,下次查询自动回填。所以整个分片上传没有一个地方依赖分布式事务,靠写入顺序加幂等就能保证最终一致。

分片的 MD5 是服务端重算的,不信任前端声明,这是校验那一层;文件级的 MD5 用来做身份标识和断点定位。

为什么切片是 5MB? ​

合并用的 composeObject 走的是 S3 协议的分片拷贝能力,协议规定每个源分片最小 5MiB,切片大小对齐的就是这个下限——选小了服务端直接不受理。同时 5MB 也让前端哈希计算和单请求体都不至于太大,是一个兼顾协议约束和传输效率的选择。

为什么去重要查三个地方,一个 Redis 位不够吗? ​

不够。位图只是进度缓存,它可能脏也可能丢——比如 Redis 重启、或者对象存储里文件被删了。三个存储各代表一层事实:位图说‘我说传过了’、数据库说‘我记过账’、MinIO 说‘字节真的在’。三方对齐才算数,不一致时以事实层为准修缓存。只信任何一方,都会有对不上的场景。

四个并发如果同一个分片重传了两次,会不会存两份? ​

不会。一是重传前会走三方校验,已存在就幂等返回;二是就算两个请求同时穿过校验,分片存储路径由 MD5 加索引唯一确定,写的是同一个对象;三是数据库层有唯一约束兜底,重复插入会冲突,代码里冲突按幂等成功处理。所以并发窗口最多造成一次重复写,不会产生脏数据。

断点续传流程 ​

断点续传的闭环是:前端续传前先调状态接口,服务端一次性 GET 整个位图——不是逐位查,一次网络往返拿全部进度——解析出已传分片列表返回。前端拿全集减已传,得出待传队列,开四个并发只补缺失的分片。位图缺失或为空时服务端从数据库元数据重建位图再返回,所以 Redis 怎么挂,断点进度都不丢。

查询状态接口又是怎么实现的? ​

这个接口是断点续传的入口——它不是上传过程中轮询用的,而是在用户重新选择文件续传时调用一次。前端会先算一遍新选文件的 MD5,和任务行里记录的指纹比对,确认是同一个文件,才来查这个接口。

服务端做三件事。第一,补齐文件名、类型这些元信息,这步查不到就用默认值兜底——读接口不能因为次要信息挂掉。第二,算已传分片列表,这是核心。第三,总分片数由服务端自己根据文件大小重算,进度就是已传数除以总数,前端拿到的直接是百分比和已传索引数组。

核心在已传列表的三级回源。第一级,用一次网络请求把整个位图拉回来,在本地解析出哪些位是 1——位图极小,1GB 的文件也就 25 个字节,所以批量读进度退化成一次 GET,这是当初选位图而不是普通集合存进度的主要原因。第二级,位图不存在,说明 Redis 重启过或者 key 被清了,这时落到数据库的分片元数据表查事实,查完顺手逐位回填位图——数据库是事实层,位图随时能从它重建,所以 Redis 怎么挂,断点进度都不丢。第三级最细:位图存在但解析出来全是零,这种罕见状态也回源数据库再回填。设计意图一句话:位图是缓存,元数据是事实,任何一级拿不到都向事实收敛。

前端拿到已传列表后,用它减掉全集得到待传队列,开四个并发只补缺失的分片——这就是续传的全部逻辑,服务端负责如实报告进度,前端负责差量补传。

分片合并流程 ​

合并是由前端主动发起的——最后一个分片上传成功后,前端请求合并接口,只带文件 MD5 和文件名。

服务端收到后不会直接合,先做四道复核:文件登记记录存在、请求者是文件属主、文件状态允许合并、再用文件大小重新推算期望分片数和已传数量做比对。这四道里服务端不信任前端带来的任何声明,数量是服务端自己重算的。

复核过了之后,关键是抢合并权。我用一条带状态条件的 UPDATE 做乐观锁:只有当前状态是‘上传中’才能改成‘合并中’,改到的那个请求才有资格执行合并。这样两个请求同时点合并,只有一个真正执行,另一个发现影响行数是零,重查状态后按幂等成功或者冲突退出——没有锁对象,也没有分布式锁,一条 SQL 解决并发双合并。

真正的合并用的是 MinIO 的 composeObject,让对象存储在服务端把分片按索引顺序拼成完整文件。选它而不是在 Java 里读流拼接,核心原因是数据不过应用内存:1GB 的文件如果在应用层拼,堆内存和网络带宽都是双倍开销,composeObject 只是发一条指令。

合并成功后做清理,顺序是:删分片对象、清分片元数据、删 Redis 位图、最后把状态置为已完成。容错只给第一步:分片对象是垃圾,完整文件已经在了,没人再读它,删失败只是占点磁盘,所以单独 try-catch 记日志继续走,不为删垃圾让一个成功的合并报错。后面三步失败会让合并报错、状态回拨——原因不是残留可怕,元数据和位图的残留其实能被去重逻辑自愈——而是这三步失败说明数据库或缓存此刻在异常,后面的步骤大概率也写不进去,与其带着不一致硬把状态置成完成,不如快速失败停在一个可排查的点上;而且报错的代价很低,客户端重新上传就能收敛。

真正会主动回滚的只有一处:合并本体失败,比如某个分片丢了或者对象存储不可用,这时把状态从“合并中”回拨到“上传中”,客户端可以重试。而“合并对象已生成、清理链中途断掉”这个死角,我补了清理补偿分支:合并入口先检查 MinIO 里完整文件对象是否已存在,存在就说明合并本体已成功,跳过分片重组直接补做清理——所以这种场景下重试合并就能自愈,不再依赖重新上传整份文件。如果进程是崩掉的、连报错都没有,状态会停在“合并中”,它天然是互斥闸门,拒绝所有新分片和新合并请求;人工把状态复位后再触发合并,会走补偿分支正常收敛。宁可卡住保护数据,也不允许在半清理状态下继续写。

合并完成后,文件就算交付了,后面通过 Kafka 消息交给异步流水线去解析和向量化。

为什么用乐观锁不用分布式锁? ​

这个场景里互斥的本质只是‘同一时刻只能有一个合并在执行’,一条带状态条件的 UPDATE 就能表达,数据库行级锁天然保证原子性。引入 Redis 或 Zookeeper 分布式锁,等于为一次性判断增加一个外部依赖和故障面。锁的粒度也只需要到文件这一行,行级条件更新是最贴合的。

合并到一半服务挂了怎么办? ​

composeObject 是 MinIO 服务端的原子操作,不存在合并一半的对象,所以“一半”只会出现在清理链上。最棘手的是断在“合并对象已生成、分片元数据已删”之后——重试合并救不回来,因为数量校验永远过不了。这个场景我实际处理过:给合并入口加了清理补偿分支,进合并先检查 MinIO 里完整文件对象是否存在,存在就说明上次合并本体已成功,跳过分片重组直接补做清理。有两个实施细节:一是这个检查要放在分片数量复核之前,否则会被“分片未全部上传”的闸拦住;二是补偿不能重放副作用,文件如果已完成向量化就跳过预估和 Kafka 分发,否则 ES 分块翻倍、token 重复计费。至于进程崩溃导致状态卡在“合并中”的情况,它天然是互斥闸门,挡住所有新写入;人工把状态复位后再触发合并,现在会走补偿分支正常收敛——以前复位了也救不回来,这正是补偿分支补上的洞。

分片删失败留了垃圾,没人管吗? ​

当前实现确实只记了警告日志,没有对账任务。这是我知道的简化——更完整的做法是定时任务扫描‘已合并但仍有分片对象’的文件做二次清理,或者干脆用 MinIO 的生命周期规则给临时分片目录设过期时间。量小的时候日志足够,量大之后对账是必须补的。

文件处理流程 ​

整个流程分三段:合并交付、异步处理、结果入库,由一条 Kafka 任务消息串起来。

第一段,合并交付。 前端在最后一个分片上传成功后主动请求合并,服务端做四道复核——文件登记存在、属主校验、状态机检查、用文件大小重算期望分片数做数量比对,全程不信任前端声明。然后一条带状态条件的 UPDATE 做乐观锁抢合并权,防止并发双合并。合并本体用对象存储的 composeObject 在服务端完成,数据不过应用内存。成功后清理分片对象、分片元数据、Redis 位图,状态置为已完成,顺手预估一下这个文档的 embedding 消耗给用户做提示。

第二段,异步处理。 合并接口把任务发进 Kafka——消息里带文件指纹、存储路径和权限三元组——用事务生产者保证状态变更和消息投递不出现单边。消费端五步:先置“处理中”状态让用户可见;然后从对象存储拉文件流;第三步按类型解析——PDF 走本地解析引擎的子进程,产出带页码和锚点的页级文本,非 PDF 走 Tika 流式解析,内容攒够父块就触发切片;第四步语义切块,每个切块带页码、锚点、权限三元组写入 MySQL;第五步从 MySQL 把块取回来做向量化。

向量化这步有三个设计点。一是两阶段配额:调用 embedding 之前先按文本量预估 token,和用户 Redis 里的实时余额比对,不够就拦在 HTTP 之前;调用后按 API 返回的真实用量结算差额,所以系统能对每个用户精确记账。二是落 ES 的文档同时带文本、向量、页码锚点和权限三元组——检索时的权限过滤、引用定位全靠这份文档自包含。三是写入幂等:ES 文档 ID 用文件指纹加块序号的确定性 ID 而不是随机 ID,同一分块重复写入是覆盖而不是追加——配合前面的重跑前清理,消息重复消费、处理中断重试都不会产生重复数据。

第三段,状态收敛。 全部成功后把真实 token 消耗和切片数回填文件记录,状态置完成,前端能看到本次处理花了多少 token。失败路径是统一的:任何一环异常,状态置失败、原因入库,然后重抛给 Kafka 错误处理器做退避重试四次,耗尽进死信。这条失败链我实际验证过——解析引擎缺失报的进程启动错误、密钥为空的 401、ES 僵死的写超时、余额不足的预检拦截,四种不同性质的故障最终都收敛到同一个状态字段,能从库里的错误信息一路剥到根因。

解析产物为什么 MySQL 和 ES 各存一份? ​

职责不同。MySQL 那份是工作数据:向量化失败的补偿、重建索引、引用元数据都从它来——重试向量化不需要重新解析文件,这是用数据库在解析和向量化之间做的解耦。ES 那份是检索数据:文本和向量在一个文档里自包含,检索一次拿齐内容加定位信息。MySQL 管账和续跑,ES 管查询,谁也替代不了谁。

同一条消息被重复消费,或者处理到一半失败重试,ES 里会不会出现重复数据? ​

不会,幂等是这条流水线的设计前提,两层机制配合保证的。写入层,ES 文档 ID 不是随机生成的,而是文件指纹加块序号的确定性 ID——同一个文件的同一个分块,无论写多少次,落到的都是同一个文档 ID,重复写入变成覆盖而不是追加,这是天然的幂等。流程层,消费端在每次重新解析前,会先清掉上一次运行的部分产物——旧的文本块和 ES 文档——所以处理到一半失败触发 Kafka 重试时,整条链是在干净的地面上重跑,不会和上次残留叠加。这两层还互为兜底:就算清理那一刻对象存储或 ES 抖动导致清理失败,写入层的覆盖语义也能保证最终不重复。这条设计和重建索引入口是同一套语义——管理员触发重建和管理消息重放,走的是同一种“清干净再写、写了就覆盖”的路径。

文件更新了怎么办?知识库内容会过期吗? ​

当前的模型是指纹即身份:文件内容变了 MD5 就变,那就是一个新文件、新记录,旧文件需要用户显式删除——删除会同时清 ES、对象存储、MySQL 三层,所以不存在“同名文件内容悄悄过期”的问题。经过幂等改造之后,流水线其实已经“更新友好”了——对同一文件重新解析向量化是安全的覆盖语义,管理端的重建索引就是证明。真正缺的是产品级的就地更新:用户想替换文档内容但保留文档身份。这需要引入业务文档 ID 作为一层新身份,让文件指纹降级为版本概念——涉及一张新表和若干服务的身份解析改造,是明确的演进方向但当前没有做,现阶段“删旧传新”两个操作就能覆盖更新诉求,成本收益上我选择先不做。

文件删除流程 ​

删除入口有两道权限门:先过一个组织标签过滤器做资源级校验,控制器里再做属主判断——不是文件主人也没有管理员角色,返回 403;文件记录不存在返回 404。

权限过了之后是五层顺序删除,这个顺序是有讲究的。第一层先删 ES 里的向量文档——按文件指纹一个删除查询清掉全部,让检索立刻下线,这是用户感知最强的一层,必须最先处理;第二层删对象存储里的合并文件,删失败会降级试一个旧路径,再失败也继续;第三层清理 PDF 单页预览的缓存,这块是本地缓存加 Redis 两层,都要失效掉;然后是 MySQL 的两张表——文本块和分片元数据,分片元数据必须删,否则同一个文件重新上传时,分片去重逻辑会误以为‘这分片传过了’而实际内容已经没了;最后才删文件登记记录,因为它是‘这个文档存在’的权威依据,先删它、后面任何一步失败,这个文档就彻底失联了。

事务语义分两层:MySQL 这几张表包在一个事务里,原子提交;但 ES 和对象存储不在事务保护范围内,是尽力而为——所以失败场景下可能出现‘MySQL 记录还在、但检索和文件已经没了’的中间态。这个状态不需要补偿任务去修,因为删除操作本身是幂等的:用户重试一次删除,对已经不存在的数据是空操作,MySQL 侧重新走一遍就能对齐,靠重试收敛而不是靠分布式事务。

删除之后还有一条联动:这个文件的断点续传位图不在这里清——正常文件合并成功时已经清了,就算有残留,上传侧的分片三方校验也会把它当脏数据清掉。删除、重建、重试三个场景共用同一套清理原语,语义是一致的。

为什么 ES 和对象存储不放进分布式事务里? ​

为这个场景引 Seata 或 TCC 不值得。删除是幂等操作,重跑无害,所以‘事务失败后人工或用户重试’就能收敛,最终一致就够了;分布式事务的代价是引入协调者和故障面,把一个可重试问题变成一个不可重试问题。我做的是把顺序排对——检索最先下线、权威记录最后删——再把每一步做成可重入的,这比事务框架更贴合这个场景。

删除到一半失败了,用户会看到什么?系统处于什么状态? ​

分两种。如果只是某一层存储抖动,那层失败被容忍继续走,MySQL 事务正常提交,用户看到删除成功,残留是少量垃圾数据,无害。如果是事务回滚级别的失败——比如数据库本身挂了——用户看到删除失败,但此时 ES 和文件可能已经删了,出现‘记录在、搜不到’的状态。这个状态是稳定的、可重试的:用户再点一次删除就能对齐。最坏情况就是需要一个‘记录存在但内容已缺失’的文档被重新删除一次,没有不可恢复的死角。

删除之后,同一个文件马上重新上传,会发生什么? ​

完全干净地走一遍新流程。分片元数据在删除时清掉了,所以分片去重不会误判;文件登记记录没了,会新建登记行;Redis 位图即使有残留,也会被三方校验当脏状态清掉;ES 是按文件指纹删的,也没有旧文档。这里能看出删除设计和上传设计是咬合的——chunk_info 那一步删除,注释里写明的原因就是防止重传误判,两个模块是互为前提的。

上传性能:1GB 从 15s 到 3s 的实测 ​

同环境对比测试:同一台机器、同一个 1GB 文件,分别用单流整文件上传和分片四并发上传,各跑多次取均值。单流基线约 15 秒,瓶颈在单条 TCP 连接的吞吐;四并发分片约 3 秒。提升两个来源:一是并行连接数把带宽吃满(4 倍吞吐,15÷4≈3.75,和实测吻合);二是失败重试粒度变了——弱网下单流失败要整文件重来,分片只补缺失块,重传成本从 O(文件) 降到 O(分片),网络越差收益越大。

本站仅供个人学习使用,请勿外传