尧图精选

RAG大文件并发处理实践:异步队列、分片上传与限流压测

🕒 发布时间:2026/10/1 23:56:48 📁 来源:尧图网络
最近在折腾本地 RAG 知识库小文件跑得特别顺结果一上大文件就原形毕露解析卡死、内存爆掉、并发一多整个服务直接不响应。相信不少做 RAG 落地的人都有同感——传统的 RAG 流程在演示和中小规模文档上很能打但一旦面对几十 MB 甚至上百 MB 的 PDF、Word、扫描件以及多人同时上传、检索、导出的场景就会暴露出大量工程问题。这篇文章我准备把从“单机文本 RAG”改成“支持大文件并发 RAG”的完整实践过程写出来。核心会覆盖大文件解析分块的工程化改造、异步任务队列的设计、16C32G 这类常见配置下并发容量到底怎么估算、数据库并发锁到令牌桶限流的取舍、前端 Worker 分片上传的实现以及用 JMeter 做多参数并发压测的方法。适合正在做 RAG 知识库、AI Agent 后端或者被“并发一高就崩”折磨的朋友可以直接参考里面的方案和代码思路。1. 为什么 RAG 项目会卡在大文件上1.1 RAG 全流程拆解瓶颈到底在哪一环RAG检索增强生成看起来就是“检索 生成”两个词但工程落地上它是一条完整的流水线文件解析 → 文本清洗 → 分块 → 向量化Embedding→ 写入向量库 → 检索召回 → 拼装 Prompt → LLM 生成。任何一个环节在“大文件 高并发”下都可能成为瓶颈但很多人一上来就怀疑模型服务能力其实不对。我自己的实测结果是在纯 CPU 环境下跑一个 100MB 的 PDF解析加分块可能只需要几十秒但向量化阶段如果用中等规模的 Embedding 模型把所有 chunk 都过一遍可能要数分钟。如果每上传一个大文件就同步等着这几分钟前后端全被拖死。更糟糕的是很多人直接把文件读进内存做 split一个大文件就把进程内存拉升好几个 GB几并发下来 JVM 或者 Python 进程直接 OOM。1.2 小文件跑得通不代表架构是对的你可能会说小文件明明可以同步处理为什么要搞异步改造因为同步处理方案在并发场景下会互相挤占资源。假设一个 5MB 的小文件同步处理要 5 秒那 10 个并发上传就是 10 个 5 秒的任务同时跑如果每个任务还要加载模型做推理CPU 和内存马上被打满。而大文件同步处理时间更长可能 3 到 5 分钟等于一个请求把服务线程池占死后续所有检索请求都在排队。这是典型的“一条同步链路挡住全局”的问题。所以我在改造时先做了一个决定文件处理和检索解耦所有耗时操作进队列异步完成。这个思路跟高并发 IM 里的消息异步推送、AI Agent 里的任务编排类似先削峰再考虑具体怎么处理。1.3 RAG 知识库能不能存图片也算第一个分岔路很多做知识库的人会问“RAG 知识库能存储图片吗”这就是对大文件的困惑之一。早期的 RAG 项目确实只处理文本图片只能丢。现在如果要支持 PDF 里的插图、扫描件、表格截图就不能简单丢解析文本而要考虑多模态 Embedding 或者单独走 OCR。这块我后面会单独说但核心原则是先把“大文件”从传输层到存储层都用工程手段接住再考虑内容层面的模态问题。传输和存储没搞定后面一切都是空谈。2. 大文件处理的工程化改造路线2.1 不再整体读内存用分片和块级处理处理大文件的第一个原则是永远不要把整个文件一次性装进内存。我见过不少开源项目直接写open(file).read()这种代码在小 demo 里没问题但大数据量下几乎是必炸的。改造思路是参照文件下载、传输工具里成熟的“分片”思路。前端上传时把大文件切成多个分片比如每片 5MB后端收到分片之后先落临时存储全部接收完再触发合并和解析。这样有几个好处内存占用可控、失败可以重试、可以断点续传而且天然支持并发上传多个分片。后端接收分片的接口我建议设计成幂等的核心是给每个分片一个唯一标识比如{文件ID}:{分片序号}:{分片hash}这样客户端重试时不会产生重复数据。合并阶段在服务端做流式合并边读边写不要一次性加载整个合并文件。2.2 分块策略直接影响检索命中率RAG Hit Rate大文件解析完成后会得到很长的纯文本接下来要做分块。这里有个容易被忽略的认知分块策略直接决定 RAG 的 Hit Rate命中率。很多人随便按固定长度 512 切结果一句话被切成两半召回时语义中断命中率惨不忍睹。我实际对比过几种分块策略分块策略优点缺点适用场景固定长度切分如 512 字符实现简单、索引均匀容易切断句子和语义日志类、格式规整的文本递归字符切分按段落-句子-字符逐级减少切断句子概率大段落时块仍可能过大常见文档、标准 RAG语义切分按段落语义边界召回质量最高计算成本较高、耗时更长知识库、企业文档我的建议是优先用递归字符切分做默认值然后针对特殊文档类型做语义切分。分块大小也要考虑检索时拼接 Prompt 的长度比如 chunk 设为 800 到 1500 字符比较适合中文场景太长会导致检索结果超出上下文窗口太短又会导致局部信息不足连带 Hit Rate 下降。2.3 解析和向量化放进异步队列改造最关键的一步是把“文件解析 分块 向量化 写入向量库”从同步调用改成异步任务。这里我用了一个最常见的方案Redis 列表作为任务队列多个 Worker 进程消费任务。大致流程是前端上传分片完成后端生成一个process_file任务推到队列。Worker 从队列取出任务执行解析、分块、向量化。每一步更新任务状态pending / processing / done / failed前端轮询进度。这样做最大的价值是削峰。比如 100 个大文件同时进入系统不会同时跑 100 个解析任务而是队列里排队Worker 并发数可控比如 4 到 8 个。上传接口可以立刻返回“文件上传成功处理中”用户体验也好得多。2.4 存储层别只用本地磁盘元数据和文件分开管处理大文件时文件本身和文件的元数据应该分开管理。文件本体建议放到对象存储或者支持流式读写的分布式文件系统本地磁盘只做缓存和临时存储。元数据文件名、大小、状态、分块信息、向量化进度放到数据库方便查询和统计。我在项目里用的是一张file_record表字段大致是file_id、file_name、file_size、status、chunk_total、chunk_done、create_time、process_progress。每次分片上传成功就更新chunk_done全部传完自动触发合并和处理任务。这样用户刷新页面也能看到实时进度而不是一个“干等”的接口。3. 并发场景下的资源建模与容量估算3.1 16C32G 服务器到底能扛多少并发“16C32G 服务器支持多少并发”这类问题其实没有标准答案因为 RAG 服务不是单一负载而是混合负载。你需要把链路拆开算Web/API 层这个层主要是 I/O 等待连接数和线程池配置是瓶颈。Tomcat 默认线程池 200一般同时在线几百人没问题。文件处理 Worker每任务消耗的主要是 CPU解析、向量化和内存文本缓存建议并发数不超过 CPU 核数的一倍到两倍。Embedding 推理这是最重的部分。如果是 CPU 推理一个模型推理请求可能占 1~2 核并发十几路 CPU 就跑满了如果走 GPU则要看显存和 batch size。向量数据库比如 Milvus、Elasticsearch、pgvector查询和写入都会占资源容量取决于索引类型和内存。我简单给出一个估算公式并发瓶颈数 min(API线程池可用数, 推理服务并发上限, 数据库连接池上限, 队列消费速率)。16C32G 纯 CPU 部署小型 RAG 系统合理的并发能力大概是文件上传接口支持高并发几十到上百并发检索接口支持中等并发十几个 QPS大文件处理任务同时跑 6 到 8 个比较稳。3.2 先算算 Embedding 的吞吐上限Embedding 是整个 RAG 链路里最容易被低估的资源消耗点。举个例子一份 100MB 的文档分块后可能有 3000 到 6000 个 chunk。如果一个 Embedding 模型每秒能处理 50 个文本片段那么这 6000 个 chunk 光是向量化就要 120 秒而且这还只是单任务。所以在改造时我给 Embedding 模块加了三层优化使用 Batch 推理不要一个 chunk 一个请求而是攒一批如 32 或 64 条统一推理吞吐能提升好几倍。增加缓存相同或相似的 chunk 文本直接命中缓存不重复计算。控制并发给推理服务设置最大并发限制防止大文件任务把算力打满导致正常检索请求也变慢。3.3 数据库并发连接数别让连接池先崩了很多 RAG 系统的数据库并发问题不是查询本身慢而是连接池被打爆。默认的连接池配置在低并发下没问题但并发多任务各自持有连接长时间做事务很容易撑爆上限。比如你给 HikariCP 配了 10 个连接而 8 个 Worker 同时在写向量数据和文件记录连接就被占满检索接口也开始等连接整个系统进入雪崩状态。我的做法是给不同用途分配不同数据源文件元数据操作用一个连接池向量数据操作单独用另一个连接池并且给“批量写入”和“实时查询”设置不同的超时和排队策略。这样大文件任务再重也不会把实时检索的连接资源抢光。4. 并发控制数据库锁、令牌桶与信号量的实际取舍4.1 数据库并发锁什么时候用怎么用大文件并发合并和任务状态更新时很容易出现多个请求同时操作同一行数据。比如同一个文件的 20 个分片几乎同时上传完成触发了 20 次“检查分片完整性并启动合并”的逻辑如果没有锁就会重复启动合并任务。数据库层面的方案有三种悲观锁SELECT ... FOR UPDATE事务期间锁住记录适合写冲突概率高的场景但并发高时容易造成锁等待。乐观锁通过version字段判断更新时SET version version 1 WHERE version 旧值失败则重试适合读多写少。分布式锁用 Redis 的SET NX EX实现适合多实例部署锁粒度可以精确到某个文件 ID。我的建议是分场景选型分片上传状态更新用乐观锁就够了启动合并任务这种“只能执行一次”的关键动作用 Redis 分布式锁更稳高频小事务尽量别用悲观锁否则并发一高数据库连接和锁等待时间都会成为新的瓶颈。4.2 用令牌桶保护推理服务而不是用线程池硬扛RAG 并发控制的另一个重点是保护模型推理服务。很多人一想到并发控制就调整线程池大小但线程池只能限制本进程的线程数控制不了外部请求打进来的速率和突发流量。更好的办法是令牌桶限流。令牌桶的思路很简单一个桶里以固定速率放入令牌请求到来时要先拿一个令牌才能继续执行桶满则直接拒绝或排队。在 RAG 系统里我给 Embedding 服务和 LLM 生成服务各自配了一个令牌桶速率根据机器配置实测得出。这样即使文件处理任务再多推理服务的并发也被牢牢锁在安全范围内。补充一点如果同一瞬间涌入 10 个大文件处理任务每个任务都要调 LLM 做摘要生成你不加限流的话LLM 服务很可能会返回超时或 429然后引发大量重试最终把服务打挂。令牌桶在这里的作用不是简单拒绝而是让流量变得平滑。4.3 信号量控制本地资源多进程场景下要注意除了令牌桶我还用信号量控制“本地文件合并”这类资源敏感操作。信号量和令牌桶的区别是令牌桶管速率信号量管并发数。比如本地磁盘同一时刻最多允许 2 个文件合并任务我用Semaphore(2)就能做到。但注意在 Python 多进程或多实例部署下单个进程内的信号量是管不住其他实例的。真要全局控制并发数还是要借助分布式限流组件或者任务队列本身限制 Worker 数量。这也是为什么我把大文件处理都收敛到任务队列的原因——队列天然就是全局并发控制器。4.4 并发更新任务状态别把数据库写崩任务状态更新看起来简单实际并发写的时候问题很多。比如分片上传完成回调每个分片完成都会执行一条UPDATE file_record SET chunk_done chunk_done 1 WHERE file_id ?这种语句在 20 个分片并发执行时没什么问题。但如果每个分片完成时都去额外嵌套一个事务检查状态并触发合并冲突率就大幅上升。我最终的处理方式是把“状态更新”和“判断是否启动合并”拆成两个阶段分片完成回调只做原子更新判断是否启动合并则由一个专门的 Worker 去扫描chunk_done chunk_total的记录再配合分布式锁防止重复触发。简单说能异步判断的不要同步做能被幂等的不要加锁。5. 前端并发上传与 Worker 的配合5.1 为什么选择前端 Worker 上传分片大文件上传时如果整个文件用普通表单提交网络稍有抖动就要重传整个文件体验很差。前端使用 Worker 上传大文件的思路是在单独的线程里把大文件切成多个分片每个分片独立并发上传。这样做的好处有三点避免主线程卡顿、支持断点续传、提升整体上传速度。Worker 是浏览器提供的 Web Worker 能力文件切片、哈希计算这类 CPU 密集操作放在 Worker 里主线程不会因为计算大文件 hash 而冻结。上传请求本身可以用XMLHttpRequest或fetch从 Worker 发起也可以在主线程用并发控制发送但切片和 hash 尽量放 Worker。分片上传的基本流程用户选择文件获取文件的size、type、lastModified。确定分片大小如 5MB计算总分片数。用 Worker 逐个读取分片计算每个分片的md5或sha1。先调用后端接口注册文件拿到file_id。并发上传多个分片每个分片带file_id、index、hash。全部成功后调用合并接口触发服务端处理。5.2 并发数怎么设不是越大越快分片并发上传的并发数并不是越大越好我实测下来在普通网络环境下同时发 3 到 5 个分片就接近上限了。并发太高会导致浏览器连接数占满、服务端连接池压力变大反而降低整体吞吐。这里可以用信号量在小程序或者浏览器端做控制也可以用简单的固定并发池维护一个待上传数组同时只有 N 个请求在跑完成一个再从队列里取下一个。断点续传的核心是后端支持查询已上传的分片列表。前端每次启动上传前先调GET /file/{fileId}/chunks拿到已收到的分片索引只上传缺失的部分。配合分片 hash还能实现整分片的秒传和去重。5.3 一张上传前后端配合的时序我把整个改造后的上传链路说明如下前端发送POST /file/register携带文件名、大小、分片数后端写入文件记录状态uploading返回file_id。前端逐个上传分片到POST /file/{fileId}/chunk请求体会包含index和hash后端写入临时文件目录更新chunk_done。所有分片完成后前端调用POST /file/{fileId}/merge后端校验分片数量和 hash合并成完整文件。合并完成后后端推送process_file到任务队列状态变更为processing。前端轮询GET /file/{fileId}/status获取处理进度直到done或failed。这套链路里最需要注意的点是超时控制。上传分片的接口本身应该很快但合并和处理可能很慢所以合并接口和上传接口的超时设置要分开。我们实际生产里上传接口超时设 60 秒合并接口直接设到 10 分钟否则同步等待合并结果时经常会超时重试造成重复合并。6. 压测方法用 JMeter 模拟不同参数的并发请求6.1 JMeter 参数化十个参数不同的 POST 请求怎么做完成改造后必须用压测数据说话。很多人在 JMeter 里只做单接口固定参数的压测这跟真实业务差距很大。真实场景是10 个用户同时上传不同文件、检索不同关键词、提交不同参数。这时候就需要参数化。JMeter 里做多参数 POST 请求我推荐用 CSV 数据集配置CSV Data Set Config。预先准备一个 CSV 文件里面放十行数据比如文件名、大小、标签、模拟内容等线程组每个线程读取一行。如果线程数大于 CSV 行数可以设置循环或共享模式这样同一份数据可以被重复使用但请求参数会变化。实际步骤是新建线程组设置线程数如 50、Ramp-Up如 10 秒、循环次数如 100。添加 CSV Data Set Config配置 CSV 文件路径、变量名例如file_name, file_size, tag, keywords。HTTP 请求中使用变量如${file_name}、${keywords}POST Body 用 JSON 模板变量位置填进去。添加查看结果树、聚合报告、用表格查看结果等监听器。6.2 压测三个关键指标吞吐量、响应时间、错误率压测不是为了跑一个好看的吞吐量而是为了找到系统的瓶颈和合理的并发上限。我主要看三个指标吞吐量TPS 或 QPS单位时间完成的请求数体现系统最大处理能力。响应时间重点关注 P95、P99而不是平均值。平均值很容易被几个慢请求拉得很低尾延迟才是用户体验的真正指标。错误率超过一定阈值比如大于 1%就说明系统已经进入过载状态。压测时我一般从低并发开始阶梯加压10 并发、50 并发、100 并发、200 并发每档跑 5 到 10 分钟观察。随着并发升高如果吞吐量不再线性增长、P99 响应时间突然飙升、错误率开始上升就找到了系统拐点。这时候把并发数回调到拐点的 70% 左右通常能获得最优的稳定性。6.3 JMeter 压测后的瓶颈定位方法压测得出“系统拐点”后还需要定位瓶颈在哪个环节。我会同时采集以下指标指标采集方式说明API 线程池活跃数Tomcat/Spring 监控确认是否线程池打满数据库连接池活跃数HikariCP 监控确认连接是否耗尽队列长度RedisLLEN确认任务积压程度推理服务延迟日志统计确认模型服务是否成为瓶颈机器负载top、vmstat确认 CPU、内存是否饱和比如我压测时发现数据库连接池活跃数涨到 40 但机器 CPU 才 60%那就说明瓶颈在连接池配置而不在机器算力反过来如果 CPU 一直是 100%连接数还在排队说明算力不够需要限制并发或者提升硬件。6.4 压测里最容易忽略的坑压测最容易被忽略的是“压测工具本身变成瓶颈”。JMeter 默认跑在 GUI 模式如果并发线程配得很大JMeter 所在的机器自己就会先卡死测试结果毫无意义。我建议用非 GUI 模式jmeter -n -t test.jmx -l result.jtl -e -o report_dir生成 HTML 报告后再分析。另一个坑是压测请求如果包含真实 Embedding 或 LLM 调用很容易直接把模型服务打崩甚至产生大量计费。我在压测时会先用 Mock 接口替换推理服务先将链路压测跑通再针对推理服务单独做专项压测。这样既能定位问题也不会污染真实数据。7. 真实项目里的踩坑记录与性能观测7.1 内存溢出的元凶同步解析大文件第一次跑大文件并发压测时系统不到 10 分钟就 OOM。排查后发现某个解析代码把整个 PDF 的内容一次性读进了字符串然后又做了多次切片复制内存峰值远超预期。修复方式是改用流式解析和迭代器分页处理每处理完一页就释放引用同时限制单个文件的解析任务并发数。这里我补充一个经验Python 里的大字符串切片会复制对象操作大文本时不要无脑重复切片。尽量用io.StringIO、生成器或者直接按行/按页处理避免在内存里保留多份大文件内容。7.2 任务重复执行没有幂等设计的代价有次线上一个小文件被重复处理了 4 次查下来是客户端超时重试导致合并接口被调了多次而合并逻辑没有加锁。修复方式是给每个任务的执行入口都加了 Redis 分布式锁以file_id为锁键加锁成功后检查任务状态如果已经done就直接返回。再加上队列任务本身的唯一 ID重试就不会造成重复处理。这个教训对“高并发 异步任务”的系统尤其重要。超时重试、消息重投、用户连点提交任何一环不做幂等都会造成脏数据和计算浪费。RAG 项目里向量库重复写入比数据库重复更新更隐蔽因为很多向量库没有强唯一约束重复数据会悄然污染检索结果。7.3 性能观测从黑盒到白盒改造前我对系统几乎是黑盒出问题只能重启。后来我加了三层观测队列观测Redis 队列长度、Worker 消费速率、任务失败率。数据库观测慢 SQL、连接池等待时间、锁等待事件。应用观测接口 P99 延迟、异常堆栈、GC 时长。加了观测之后排查效率明显提升。比如有一次检索变慢慢 SQL 日志显示某个向量相似度查询没有走索引反而是全表扫描比对了一遍。这个在测试环境数据量小看不出来数据上了百万级就直接暴露。7.4 改造后的效果对比最后放一组我在 16C32G 机器上的实测数据场景为混合负载上传 解析 检索 问答场景改造前改造后100MB PDF 单文件处理同步阻塞接口超时异步队列后台完成约 2 分钟50 并发上传 10MB 文件并发 20 时接口开始超时稳定处理错误率 0.2%检索接口 P99受文件处理影响飙到 8 秒稳定在 300ms 左右数据库连接池频繁打满连接等待分离数据源后无等待数字不算惊艳但对一台纯 CPU 的小机器来说已经达到“大文件能传、并发不崩”的目标。如果你的机器配置更好或者用上了 GPU 推理容量上限还会更高但架构思路是一样的异步削峰、限流保护、资源隔离、幂等防重。7.5 个人实际操作中的体会最后分享一点我从这次改造里沉淀下的心得RAG 项目从“能跑”到“扛得住”差的往往不是模型精度而是工程韧性。大文件并发这件事本质上就是在问几个问题——你的系统能不能接受异步能不能限制流量能不能防止重复能不能把资源隔离开把这些问题一个个解决掉RAG 就算真正具备上线能力了。如果后续还想继续优化可以往两个方向走一是文件解析环节接入 OCR 和多模态模型让图片、扫描件也能进知识库二是把任务队列从 Redis 换成专业的消息队列比如 RocketMQ 或 Kafka支持任务优先级、延迟重试和更细粒度的监控。这些在架构上都不会推翻现有设计属于平滑演进。
上一篇/下一篇内容由系统自动关联 返回资讯列表 →