1.我们来聊聊文件上传的功能。当用户想要上传一个大文件(比如1GB)时,你的系统是如何接收它的?
对于大文件,派聪明采用的是‘分片上传 + 断点续传’的方式。我们会在前端先把大文件切成小的分片,比如 5MB 一块,然后并发地上传到后端。后端每收到一个分片,就存到 MinIO 中,同时会用 Redis 的 bitmap 去记录哪些分片已经上传成功。这样的好处就是,即使上传过程中断了,前端可以根据 Redis 状态判断哪些分片已经上传,不用从头开始,用户体验会比较好。

这里还有一个关键细节,就是首次上传分片时,我们会把这个文件的元信息,比如文件名、文件大小、上传者、所属组织标签等,保存到 MySQL 中,用来跟踪整个文件的上传状态。这也是为了方便后续的状态管理和权限控制。

当所有分片上传完成后,前端会调用后端的合并接口。这里我们用的是 MinIO 提供的 composeObject 功能,直接在存储端完成分片的合并,完全不占用服务器的内存和 CPU 资源。合并完成后,系统会把文件状态在 MySQL 里更新为‘已完成’,并且清理掉对应的分片文件和 Redis 记录。

最后,文件合并后我们还会发送一条 Kafka 消息,通知后台的异步服务去做后续的文件解析、文本切片、向量化等工作,保证上传接口本身是快速响应的,不会因为后端的耗时任务拖慢用户体验。

2.分片上传…那你是如何知道哪个分片属于哪个文件的?
前端在上传文件前,会通过 MD5 算法计算出该文件内容的唯一哈希值,也就是 fileMd5,然后前端在分片上传文件时,请求不仅会包含分片本身的数据,还会附带两个关键的元信息,一个是 fileMd5,一个是 chunkIndex,用于记录当前分片在原始文件中的顺序。

后端接收到分片后,除了存储分片本身之外,还会根据这个 fileMd5 和 chunkIndex 把分片放到对应的位置上。比如我们会在 MinIO 里以 chunks/{fileMd5}/{chunkIndex} 这样的结构来存储,确保所有分片归属于正确的文件,同时用 Redis 去记录每个分片的上传状态。

等前端把所有分片都传完了,后端再根据这个 fileMd5 把所有分片拿出来,按 chunkIndex 顺序拼接在一起,通过 MinIO 的 composeObject 方法直接在存储端完成合并,效率非常高。

3.如果上传中网络断了,如何实现‘断点续传’?
后端在收到每个分片之后,一方面会把分片存储到 MinIO,另一方面也会用 Redis 的 bitmap 去记录这个分片的上传状态。这样后端就能实时知道这个文件的哪些分片上传成功了,哪些还没传。

等到网络恢复后,前端会带着这个文件的 MD5 去后端的 Redis 里查所有分片的状态,前端拿到分片状态后,在重新上传的时候,就会跳过那些已经上传成功的分片,只上传那些还没传的。这样就避免了重复上传。
当然了,后端在重新上传的时候,也会进行核验。
4.你用什么来存储这个临时的分片上传状态?数据库还是缓存?为什么?
我是用 Redis 来管理分片上传的临时状态的。因为分片上传属于高频写入,比如一个 1GB 的大文件可能会被切割成上百个甚至上千个分片,每上传一个分片,后端都要记录一下“这个分片的状态”。如果是用 MySQL 的话,MySQL 的压力会特别大,而且这些数据都是临时的,合并完之后就没用了,不值得进库。

Redis 刚好适合这种场景。它是内存型的键值对存储,读写速度特别快,而且我们用的是 Redis 的 Bitmap。简单来说,我们会用文件的 MD5 作为 Redis 的 key,然后用一串“0”和“1”的位图来记录每个分片的状态,比如第 0 个位代表第 0 个分片,第 1 个位代表第 1 个分片……上传一个分片就把对应的 bit 位标记为 1。
这样记录状态特别省内存,例如,要跟踪一个 100 万个分片的文件,只需要大约 122KB 的内存(1,000,000 bits / 8 / 1024 ≈ 122 KB),而且查询和更新都很快,基本就是 O(1) 的时间复杂度。
5.这些上传的临时分片,存在哪?
存在 MinIO 里。
因为分片上传场景下,文件往往比较大,而且一旦上传中断或者失败,之前已经上传的分片是需要持久化的。
MinIO 还是一个遵循 S3 协议的对象存储系统,天然适合这种大文件、多分片的场景,而且支持高并发读写,性能也不错。
并且所有分片上传成功后,还需要一个合并操作,MinIO 恰好就提供了这么一个 API——composeObject。

6.详细描述分片上传与断点续传的实现机制。在这个过程中,Redis和MinIO分别承担了什么核心角色?
我先说分片上传,每个分片在上传成功之后,后端是直接把它存在 MinIO 里的。等所有分片都上传完成后,我们会调用 MinIO 的 compose 接口,在服务端把这些分片直接拼成一个完整的文件。

再说一下断点续传。
光有 MinIO 还不够,因为我们还需要知道当前这个文件上传到第几块了,哪些分片已经传过了。所以在上传的过程中,每当一个分片上传成功,后端会在 Redis 里记录这个分片的上传状态。

具体实现上,我们是用 Redis 的 Bitmap 来做的,把文件的 MD5 值作为 Redis 的 Key,每个分片对应 Bitmap 里的一个 bit 位,上传成功就把那个 bit 设置成 1。
这样后端只需要再给前端提供一个查询分片状态的接口:告诉前端哪些分片已经上传了,哪些还没上传,这样前端就可以进行断点续传了。后端也会在合并前做一个完整性校验,看是否所有分片都到齐了。
总结来说,MinIO 主要负责存实际的分片数据和最终的完整文件,Redis 主要负责存上传过程中的状态。
7.如何处理上传过程中的各种异常情况?例如,如果用户的网络突然中断,或者某个分片上传失败了,你设计了什么样的恢复机制?
假如说用户的网络突然断了,或者浏览器崩了,派聪明的断点续传机制仍然能够保证用户重新将自己的文件上传成功。

首先,每个成功上传的分片状态都已经记录在了 Redis 的位图当中,因此上传进度不会因为用户网络的中断而丢失。
其次,针对单个分片的上传失败,比如说因为网络抖动或者服务端临时出错导致的上传失败,前端是有内置重试机制的。也就是说,如果某个分片上传失败了,前端不会直接放弃,而是会自动重试几次,当然了,我们设置了最多三次,每次间隔逐渐拉长。

而且我们的后端接口做了幂等设计,哪怕同一个分片被重复上传多次,后端也能识别出来,不会存重复数据或者状态混乱。

再有一点,我们对数据的完整性也比较重视。每一个分片上传的时候,前端会将这个分片的 MD5 值一块发给后端。后端在收到分片数据之后,会重新算一遍 MD5,对比一下,如果发现数据被破坏了(比如有比特翻转),我们就会直接拒绝这个分片,告诉前端“这个分片坏了,请重传”,保证最终存下来的数据是没问题的。
如果是服务端自身的问题,比如说 MinIO 挂了,或者 Redis 短暂不可用,我们目前的做法是让前端去兜底。服务端会把错误返回给前端,前端那边的重试机制会自动拉起,等服务恢复后用户这边就能继续上传,不用重新选择文件什么的。
8.文件合并过程中,如何保证原子性?如果合并过程中失败了,你有什么回滚机制?
在我们的项目中,文件合并涉及应用服务器、MinIO 对象存储、数据库和 Redis 缓存的协同,所以它本质上是一个分布式操作。
关于这个问题,我分析了我们的核心代码 UploadService.java 中的 mergeChunks 方法,可以将它分为两层来看:
核心操作的原子性
我们项目中,最关键的合并步骤是调用了 MinIO 的 composeObject 接口。这个操作是在 MinIO 服务端内部完成的,它本身是一个原子操作。这意味着,对于 MinIO 来说,分片合并要么就彻底成功,生成一个完整的最终文件;要么就失败,不产生任何文件。不会生成一个“合并了一半”的损坏文件。这一点为我们的数据一致性提供了最基础的保障。
整体业务流程的一致性与挑战
但是,整个业务流程(mergeChunks 方法)包含了多个步骤:1. 调用 MinIO 合并 -> 2. 清理 MinIO 中的分片 -> 3. 删除 Redis 缓存 -> 4. 更新数据库状态为“已完成”。
这个体流程,在当前的实现中并不是原子的,并且缺乏一个明确的回滚机制。会存在一些潜在的风险:
①、场景一:合并操作失败。
这是最简单的情况。MinIO 没有生成新文件,代码会抛出异常并终止。此时,分片、数据库记录、Redis 缓存都还保留在合并之前的状态,用户只需要重新触发合并即可。这种情况风险较小。
②、场景二:合并成功,但后续步骤失败。(这是最危险的)
这是当前实现的主要弱点。比如,文件在 MinIO 上已经合并成功了,但在更新数据库状态时,数据库突然宕机或网络中断。这会导致系统进入一个数据不一致的状态:
- MinIO 中:文件实际上已经准备好了。
- 数据库中:文件的状态却依然是“上传中”。
- 后果:这会导致用户看到文件一直在“合并中”,但永远无法访问,同时系统也无法自动修复这个错误状态。
当前规划的改进方案
针对这个潜在的数据不一致问题,已经有了解决方案,我会引入补偿机制和后台校准任务的思想,来保证系统的“最终一致性”。
具体来说,我会分两步走:
①、引入“合并中”状态:首先,我会在数据库的文件上传记录表中增加一个中间状态,比如 status=2,代表“合并中”。整个流程就从 上传中(0) -> 合并中(2) -> 已完成(1)。在调用 MinIO 的合并接口之前,就先把状态置为2。
②、建立后台校准任务:我会创建一个定时的后台任务(例如每小时执行一次),这个任务专门做“兜底”和“校准”的工作。
- 它会扫描数据库里所有状态为“合并中”并且“卡住”超过一定时间(比如 1 小时)的记录。
- 对于每一条记录,它会去 MinIO 查询对应的最终文件是否存在。
- 如果文件存在,说明当时只是后续步骤失败了。校准任务就会主动完成剩下的工作:将数据库状态更新为“已完成”,并清理分片和 Redis 缓存。
- 如果文件不存在,说明当时是核心合并步骤就失败了。校准任务就会把数据库状态重置为“上传中”,以便用户可以重新尝试。
- 同时也可以用来清理那些因为各种异常而残留的孤儿分片数据,避免存储资源浪费。
通过这种方式,即使在执行过程中发生任何单点故障,系统也具备了自我修复和自动达到最终一致性的能力,从而大大提升了文件上传功能的健壮性。
9.当一个文件最终合并成功,并准备在数据库中创建它的元数据记录时(在`file_upload`表中),你是如何确定并记录这个文件的权限归属的?比如,系统如何知道这个文件是‘王二’上传的?除了记录所有者,有没有机制允许上传者为这个文件指定一些‘组织标签’(比如,‘仅自己可见’或‘研发部可见’)?”
先说所有权的确定。整个上传流程是基于 JWT 做身份认证的,所有接口,比如分片上传、合并操作,前端都必须带上 token。这个 token 在后端被拦截器解析之后,会提取出当前用户的 userId。然后我们在用户第一次上传分片时,就把这个 userId 和文件的 MD5、文件名等信息一起写进数据库,保存到 file_upload 表里。这个过程其实就明确了:这个文件是属于谁的。

接下来,比如合并文件这种敏感操作,系统会再做一次权限校验。我们会通过 fileMd5 + userId 去数据库查这个文件记录,确认这个操作确实是这个用户发起的,防止用户去合并别人的文件。
然后是访问权限这一块,我们用了一种叫做组织标签的机制。简单理解就是:文件上传时,用户可以指定这个文件是“哪些组织标签”下可见的。比如说“研发部可见”或者“仅自己可见”。如果前端不传这个字段,我们会给它加上一个默认的组织标签——就是这个用户的主组织,避免权限空白。

这些权限标签会一起保存到 file_upload 表里,下游的检索服务也会根据组织标签去做权限过滤,保证“有权限”的人才能看到对应的文件。
10.为什么最终选择了Kafka来实现异步处理?相比于其他消息队列(如RabbitMQ),它有什么优势?
第一,比如说我们在文件上传完成之后,会有很多后续的异步任务,比如提取元数据、生成全文索引、做 AI 向量化之类的处理。这些处理不仅数据量大,而且任务本身也比较消耗资源,属于典型的流式数据处理场景。

Kafka 本身就非常适合这种高吞吐的数据流。它的架构决定了它可以处理每秒上百万条消息,而且是基于磁盘顺序写入,非常稳定,这跟 RabbitMQ 那种内存优先、消息读完即删的机制不太一样。
另外一个很关键的点是 Kafka 的数据持久化能力。它的消息是保存在磁盘上的,而且可以保留很久,比如 7 天。消费者通过 offset 去控制消费进度。这个机制带来了一个特别大的好处:我们可以随时进行数据回溯。

比如某个向量化模型升级了,或者数据处理逻辑修复了 bug,我们只需要新建一个消费组从历史 offset 开始消费,就能批量重跑所有老的文件数据,不用动数据库。
最后一点是生态。Kafka 现在的生态越来越强,比如 Kafka Connect、Kafka Streams、ksqlDB 这些都很成熟了。虽然现在我们主要是用它做异步任务解耦,但未来如果要做实时计算、流式分析,其实也可以直接在 Kafka 的基础上扩展。
11.在使用Kafka时,你是如何配置来确保消息的可靠传递的?
首先是生产者这块,我们设置了 acks=all,也就是生产者发送一条消息,必须等到所有同步副本都写成功,才算这条消息真正发出。这是 Kafka 提供的最高级别可靠性设置,哪怕 leader 写完之后宕机了,只要 follower 写成功了,就不影响消息落盘,避免了“刚写完就丢”的风险。

服务端这块,我们把 topic 的副本数设成了 3,也就是一个 leader + 两个 follower,再加上配置了 min.insync.replicas=2,意思是如果只有一个副本存活了(比如只有 leader 没挂),Broker 就会拒绝写入请求。这个机制相当于在服务端也加了一道“健康检查”,防止我们写入到一个马上就挂掉的分区上,避免数据丢失。
第三是消费者这边,我们没有用 Kafka 默认的自动提交,而是设置了 enable.auto.commit=false 转为手动提交。这样能确保只有当我们的业务逻辑真正执行成功,比如向量写入 ES 成功,数据库状态更新成功,才会调用 commitSync() 提交 offset。这样即使系统崩溃,重启后还能从失败点重新拉取任务,保证至少处理一次,不会丢任务。
12.kakfa怎么保证消息重试?
生产者在发送消息到 Kafka Broker 的过程中,可能会因为网络抖动、Broker 临时故障等原因失败。为了应对这种情况,Kafka 生产者内置了简单的重试机制。在派聪明项目中,我们为 Kafka 的 retries 参数设置了 3。

这意味着,当生产者发送消息失败时,它会自动尝试重新发送,最多 3 次。同时, enable.idempotence: true 的配置也能确保即使在重试过程中,消息也只会被写入一次,避免了因重试导致的数据重复问题。
消费者的重试比生产者稍微复杂一些,因为它处理的是业务逻辑的失败。比如,在派聪明中,消费者拿到文件处理任务后,可能因为数据库连接超时、embedding 模型暂时不可用等原因处理失败。如果此时直接确认消息,这条任务就丢失了;如果不确认,消息会一直被重复消费。
派聪明通过 DefaultErrorHandler 实现了一套优雅的消费者重试与死信队列机制。FixedBackOff 会阻塞当前消费线程 ,等待 1 秒后进行第一次重试。如果再次失败,它会再等 1 秒,进行第二次重试,总共最多重试 4 次。如果 4 次后仍然失败, DeadLetterPublishingRecoverer 会接管这个消息,将它发送到死信队列中 (在项目中是 file-processing-dlt )。

13.在文档解析过程中,你是如何处理不同格式(PDF、Word、txt)的文档的?遇到的最棘手的技术难点是什么?”
我们采用了开源工具 Apache Tika 来完成文档的解析工作。其核心组件 AutoDetectParser 能够自动识别上传文件的类型(如 PDF、Word、txt 等),并统一提取纯文本内容。

尽管 Tika 功能强大,但仍然存在不足。
比如无法处理扫描件类型的 PDF,这类文档不包含可提取的文本信息,下个版本我们打算集成 Tesseract OCR 引擎来完成。
14.文本分块的策略是什么?你是如何去确定一个最优的分块大小的?
一开始我们实现了最基础的固定大小分块策略,直接把整个文本按照设定好的 chunkSize 切段。这种方式简单粗暴,但存在的问题是:容易在句子中间或者段落中间截断,导致语义残缺,对检索的精度会有影响。
后来我们引入了语义感知分块策略。这个版本会优先尝试按段落进行切割,如果某个段落太长,我们再细化到句子级别。整体上来说,它更尊重语义结构,能让一个 chunk 更自然地保留上下文,对于检索来说准确性提升非常明显。

当然了,这种策略仍然会遇到边界切断的问题。比如一个知识点刚好在两个分块的交界处被拆开了,于是我们又引入了重叠分块机制。我们让每个分块的前后都有一部分“上下文冗余”,这样, embedding 模型在处理时就不会遗漏关键信息,召回率提高了很多。
15.当处理一个超大文件(>10GB)时,你是如何设计内存管理策略来避免OOM问题的?
为了解决超大文件的解析问题,我们采用了流式处理,尽可能在任何时候都只处理一小段数据,避免全量加载。

我们自定义了一个 StreamingContentHandler,它在每次处理字符的回调函数中,会判断当前内容是否达到了分块阈值,如果是就马上进行切片、入库。这样整个过程就不再需要等待解析完成之后一次性处理,而是边读边处理。
为了进一步优化内存使用,我们还增加了一个实时检测内存使用率的处理,超过阈值就会触发自动 GC。

16.与豆包API的集成过程中遇到了哪些挑战?你是如何处理API的限流、超时、或者返回错误码这些问题的?
豆包 embedding API 是整个 RAG 流程中非常关键的一环,主要负责文本的向量化。
针对豆包 embedding API 有时候会出现的网络抖动或者服务自身压力过大的情况。我们在 EmbeddingClient 中接入了一个简单的重试机制,基于 WebClient 的 retryWhen 操作符。具体来说,只要是 WebClientResponseException 类的错误,我们就自动重试 3 次,每次间隔 1 秒。这个方案在面对短暂性的波动时效果很好。

对于每次的 API 请求,我们设置了一个 30 秒的超时时间。这个设置非常重要,因为如果不做限制,万一外部服务响应很慢,我们的线程可能会一直阻塞在那儿,不仅影响用户体验,还可能拖垮整个服务。
还有一个就是频率控制。虽然我们没有显式使用 Guava 的 RateLimiter 做限流,但是通过批量调用的方式做了“变相的限流”——我们会把文本分批,每批 100 条,合并成一个请求发送出去。这样做一方面减少了总的 HTTP 请求数量,另一方面也能尽量降低每秒调用次数,算是一种“成本很低但有效”的优化策略。
17.你预留了FAISS接口,为什么不直接用FAISS,而是选择ES?
首先,ES 并不只是一个简单的全文搜索引擎,它已经内置了向量检索的能力。而且 ES 特别适合做混合搜索,像我们现在的需求,是既要支持语义相似度这种向量搜索,又要支持关键词的精准匹配。这些能力,ES 都能一体化搞定。具体实现上,我们用了 knn 做初步召回,再通过 rescore 用 BM25 做精排,效果上也能兼顾相关性和语义相似度。
其次是运维和开发的复杂度。ES 本身就是一个成熟的搜索服务,很多底层问题它都已经帮你解决了,比如服务注册、集群分片、副本、高可用、监控等等,我们不需要重新造轮子。
如果换成 FAISS,开发成本会急剧上升。但下一个版本,我们是打算升级到 FAISS 的。
18.在写入链路中,如何保证MySQL、Redis、MinIO和Elasticsearch之间的数据最终同步?(待完善)
用户在上传文件的时候,我们会先把文件存到 MinIO,然后给 Kafka 的 file-processing 主题发一条消息。这个消息里会带上文件路径、MD5、用户 ID 这些关键信息。这样前端请求就能秒回,后续的重任务就交给后台慢慢处理。
Kafka 的消费者需要做三件事:
第一件是从 MinIO 下载这个文件,拿到文件的输入流;
第二件是解析,我们会用 Apache Tika 把文件内容解析成文本,然后做分块,再把每个文本块存进 MySQL。这里有一个关键点:每次上传的文件,分片数据会先落库 MySQL;
第三件是做向量化和存入 ES。它从 MySQL 把刚存进去的文本块拉出来,调用外部大模型(比如豆包 embedding)生成向量,然后通过再一口气写入 ES。这时候我们才算真正完成了文档的向量化和可检索化。
要保证最终的一致性,需要:
首先是重试机制。对于 MinIO 下载失败、向量服务超时、ES 写入异常这些场景,我们可以用 Spring Retry 的 @Retryable的注解,设定 3 次重试、每次间隔 1 秒,最多耗时不超过 5 秒。
其次是死信队列。我们可以在 Kafka 的配置里加个 dead-letter 主题,比如 file-processing.DLQ,当超过最大重试次数后,把“没处理好的消息”扔进去。再配一个告警或者定时任务来分析 DLQ 的原因,避免长期积压。
最后是任务状态追踪。我们在 MySQL 建一张表,每个文件处理一次就记录一条,标记它当前的处理状态,比如 PENDING → PARSING → VECTORIZING → COMPLETED 或者 FAILED。这样我们可以清楚知道哪些文档同步失败了,甚至还能支持后台补偿重跑。
kafak 生产端


已实现「生产端可靠投递」核心逻辑,新增逻辑总结:
①、application.yml / application-dev.yml
- 在
spring.kafka.producer节点新增acks: all、retries: 3、enable-idempotence: true、transactional-id-prefix: file-upload-tx-—— Broker 全部 ISR 落盘确认 + 幂等生产者 + 自动重试 + 事务前缀。
②、KafkaConfig
- producerFactory 里同步写入
ACKS_CONFIG / ENABLE_IDEMPOTENCE_CONFIG / RETRIES_CONFIG。 - 创建
DefaultKafkaProducerFactory后setTransactionIdPrefix("file-upload-tx-"),开启事务能力。
③、UploadController
mergeFile()方法加@Transactional,保证 MySQL 更新与 Kafka 发送同一数据库事务。- 使用
kafkaTemplate.executeInTransaction(...)发送消息,确保与生产端事务绑定。
作用:
• MySQL file_upload.status 更新成功 ⇒ Kafka 消息一定写入;若发送失败事务整体回滚,避免数据不一致。
• 发送端启用幂等 + 重试,确保网络抖动时消息至多写入一次。
• 系统级别实现“写库 + 发消息”原子性,为后续消费端重试/死信配合提供可靠前提。
kafka 消费端

修改后的消费端在可靠性与可观测性上获得了显著提升,主要体现在以下 5 个方面:
①、自动重试机制
DefaultErrorHandler + FixedBackOff(3s, 4)- 每条消息在业务抛异常后会等待 3 秒再重放,最多重试 4 次,连同第一次共 5 次机会。
- 效果:解决瞬时故障(网络抖动、外部接口超时等)导致的“偶发失败”。
②、死信队列(DLT)
DeadLetterPublishingRecoverer将重试仍失败的消息转发到file-processing-dlt主题。- 分区号保持与原消息一致,方便按源分区并行补偿。
- 效果:把“持续失败”的脏数据与正常流量隔离,防止阻塞主消费;同时为后续人工/自动补偿提供集中入口。
③、至少一次投递保证
- 业务方法不再自行
try/catch,而是抛异常交给框架; - Spring-Kafka 仅在处理成功后提交 offset,失败则不提交 → 消息一定会重新消费或进入 DLT。
- 效果:避免“处理失败但 offset 已提交”导致的数据丢失。
④、配置驱动的 DLT 主题
@Value("${spring.kafka.topic.dlt}")注入fileProcessingDltTopic,摆脱硬编码。- 可在不同环境通过 yml 轻松指定 DLT 名称、分区数、保留策略。
- 效果:运维、变更更加灵活统一。
⑤、代码职责更清晰
- 业务代码(FileProcessingConsumer)只关心核心逻辑,不写重试/告警/投递逻辑。
- 错误处理策略集中到 KafkaConfig,符合“横切关注点”分离原则。
- 效果:降低业务代码复杂度,可单独调优重试和 DLT 策略。
整体收益:
- 对用户接口:上传后的后台处理不再“悄悄失败”;失败任务要么被自动恢复,要么沉淀到 DLT。
- 对运维:可监控 DLT 累积量触发告警,并通过单独的 Listener 或脚本批量重放。
- 对系统:实现“至少一次 + 可补偿”的可靠消费链路,为跨 MySQL / MinIO / ES 的最终一致性奠定基础。
19. 当需要删除一个文件时,如何确保跨多个存储系统的一致性删除?(待完善)
当前代码中“删除文件”走的是同步流程,直接在 DocumentService.deleteDocument 内按顺序调用各数据源,未使用 Kafka:
- ElasticsearchService.deleteByFileMd5 → 删除向量索引
- MinIO.removeObject → 删除对象存储中的合并文件
- DocumentVectorRepository → 删除 MySQL document_vectors 记录
- FileUploadRepository → 删除 MySQL file_upload 记录
整个方法被 @Transactional 包裹;若中途抛异常会回滚数据库层面的操作,但不会触发异步消息,也就没有 Kafka 介入。
只有“上传合并完后触发后台解析/向量化”的场景才用 Kafka;文件删除暂时是不经消息队列的。
@Transactional 只能保证 受同一事务管理器管辖的资源(本项目是 Spring JPA → MySQL)的原子提交与回滚;MinIO、Elasticsearch 等外部系统并不会加入到这段数据库事务里,因而存在两类不一致风险:
①、数据库回滚无法“撤销”外部动作
- 代码先删 ES / MinIO,再删 MySQL 并提交事务。
- 若提交阶段 MySQL 失败并触发回滚,ES、MinIO 的删除已生效 → 数据丢失与元数据仍在的错位。
②、外部删除失败导致数据库回滚,但外部已部分成功
- 先删 ES 成功 → 接着删 MinIO 抛异常 → 方法抛 RuntimeException → JPA 回滚;
- 结果:ES 已删,MinIO / MySQL 仍在,同样不一致。
要真正保障跨存储一致性,一般有三种思路:
①、分布式事务 / XA(理论可行,实践代价高)
MinIO、Elasticsearch 并不天然支持 XA,两段式提交在云原生场景也不推荐。
②、事件驱动 + 最终一致(常用做法)
- 只在事务内修改数据库并写一条“删除事件”到 Kafka(用 Outbox / 事务消息保证原子)。
- 独立消费者监听事件,再调用 ES、MinIO 删除;幂等设计 + 重试 / DLT 可收敛到最终一致。
③、补偿 / 对账
若坚持同步删除:
- 将“删数据库”放最后,且捕获外部删除异常记录待补偿;
- 每晚对账:若 DB 无记录但 ES/MinIO 仍有文件,则补删;反之则补回 DB。
综上:
@Transactional 只能保证 MySQL 层的一致性;若要跨 MySQL、MinIO、Elasticsearch 保证一致,需要引入“事件 + 补偿”机制或专用的分布式事务框架,单靠注解无法做到。
我们把删除操作拆成了两个阶段。第一阶段是软删除,也就是用户发起删除请求后,我们只是在数据库中把这条文件的状态标记为 DELETING,并且记录下 fileMd5、userId 等关键元数据。

等状态更新完后,我们会往 Kafka 发一条 file-deletion 的事件消息。这个事件就是我们删除操作的“触发器”,它由多个独立的消费者去异步处理:
- 有一个消费者专门处理 MinIO 文件删除;
- 一个负责从 Elasticsearch 删除向量数据;
- 还有一个消费者负责最终的数据库清理(比如把 document_vectors 和 file_uploads 表中的记录标记为 DELETED)。
每个消费者我们都加上幂等处理。比如 MinIO 文件已经被删了,再收到消息也不会报错;如果 MinIO 出现临时问题,还能通过 Spring Retry 做自动重试,最多重试 3 次。同时我们也配了 Kafka 的死信队列,一旦某条消息重试失败,系统会自动把它转到 DLQ,方便我们做告警和人工处理。








