第 3 章 从单体项目到 Spring Cloud 微服务
本章目标
前两章我们已经完成了两件事。
第一,理解 KnowHub 为什么不是一个普通聊天机器人,而是一个企业级 AI 知识库 / RAG 平台。
第二,梳理项目需要的技术栈和环境,包括 Java 17、Spring Cloud、Spring AI、MySQL、pgvector、Redis、RabbitMQ、MinIO、Nacos 和 Vue 前端。
从这一章开始,我们进入后端工程结构。
很多读者第一次看到本书项目时,会发现目录里有两套后端项目:在这里插入代码片
text
D:\rag\rag-demo-monolith`在这里插入代码片`
D:\rag\rag-platform
一个是单体版,一个是微服务版。
这不是重复建设,而是一条非常重要的学习路径:先用单体项目跑通业务闭环,再把边界清晰的能力拆成 Spring Cloud 多服务。
本章要解决的问题是:
- 为什么一开始不直接写微服务。
- 单体版
rag-demo-monolith解决了什么问题。 - 单体项目继续扩展会遇到哪些边界。
- 微服务版
rag-platform为什么拆成 Gateway、Auth、Knowledge、Task 和 Common。 - 请求在单体版和微服务版中分别怎么流转。
学完本章,你应该能看懂整个项目的后端结构,而不是只知道"这里有很多服务"。
3.1 为什么先做单体版
初学者做企业级项目时,很容易想一步到位:
text
Spring Cloud
Gateway
Nacos
Feign
RabbitMQ
Redis
MinIO
pgvector
Vue 管理端
这些技术看起来很完整,也很像企业项目。但如果一开始就全部上,问题会很快出现:你可能还没理解 RAG 是什么,就已经卡在 Nacos 注册失败、Gateway 路由不通、Feign 调用超时、Docker 端口冲突上。
这就是为什么 KnowHub 先有 rag-demo-monolith。
单体版的目标不是最终交付,而是验证核心业务闭环。
RAG 主链路本身已经包含很多内容:

如果这条链路还没跑通,就急着拆服务,系统复杂度会迅速上升。
所以单体版的作用可以概括成一句话:
用最少的工程复杂度,先证明业务链路能跑通。
这和真实企业开发也很像。很多复杂系统并不是一开始就是微服务,而是先把核心业务跑通,再随着团队规模、业务边界和性能要求逐步拆分。
3.2 rag-demo-monolith 承担的职责
rag-demo-monolith 是一个 Spring Boot 单体项目。
在这个项目里,知识库、文档、任务、向量检索、问答都放在同一个应用中。
它大致包含这些模块:
text
common 通用返回、异常、配置、枚举
kb 知识库管理
document 文档上传、解析、切片、存储
task 索引任务状态和本地异步执行
vector pgvector 写入和检索
qa RAG 问答和问答日志
ai Chat / Embedding 测试接口
rag 简单 RAG 示例接口
对于学习来说,这种结构有几个优点。
第一,调试简单。所有代码都在一个进程里,打断点、看日志、查数据库都比较直接。
第二,链路短。Controller 调 Service,Service 调 Mapper 或向量服务,不需要先理解网关、注册中心和服务间调用。
第三,适合验证 RAG 思路。你可以专注于文档怎么切片、向量怎么写入、检索结果怎么拼 Prompt,而不是被微服务基础设施干扰。
第四,适合沉淀公共能力。比如 TextChunker、DocumentParser、PgVectorUtils、KnowledgeChatPromptBuilder 这些能力,在单体版里先验证,再迁移到微服务版更稳。
但是单体版也有明显边界。
3.3 单体项目的边界
单体项目不是不好。对于小项目、内部工具、快速验证,它非常高效。
但 Knowhub 的目标不是只做一个 Demo,而是做一个接近企业实践的 AI 知识库平台。随着功能增加,单体项目会遇到几个问题。
3.3.1 职责边界变模糊
在单体项目里,认证、知识库、文档、索引任务、问答都在一个应用中。
一开始这没问题,但功能越来越多后,代码边界容易变模糊。比如:
- 用户登录逻辑和知识库逻辑放在同一个应用中。
- 文档上传和索引任务执行耦合在一起。
- 问答接口和向量写入共用大量服务。
- 管理端接口和用户端接口混在同一个项目里。
当你想改某个模块时,容易影响其他模块。
3.3.2 用户身份不够工程化
单体版可以通过请求参数传 userId 来模拟用户隔离。
比如:
text
GET /kb/list?userId=1
这适合学习阶段,但不适合真实系统。
真实系统应该由登录产生 Token,由 Gateway 统一校验,再把用户身份透传给下游服务。前端不能随便传一个 userId 就访问数据,否则很容易越权。
这也是微服务版必须引入 Auth 和 Gateway 的原因。
3.3.3 耗时任务需要独立治理
文档索引是典型耗时任务。
解析 PDF、文本切片、调用 Embedding、写入向量库,这些步骤都可能失败,也可能耗时很长。
如果这些逻辑一直和知识库接口放在一个应用里,后续很难独立扩展、独立排查和独立重试。
企业级系统更合理的做法是:
text
knowledge-service 负责上传和创建任务
task-service 负责执行索引任务
RabbitMQ 负责异步消息传递
这样上传接口不会被索引耗时拖住,任务执行失败也可以单独追踪。
3.3.4 文件存储需要适配多服务部署
单体项目里,文件保存在本地目录通常可以正常运行。
但拆成微服务后,情况变了。
如果 knowledge-service 在一台机器上保存文件,而 task-service 在另一台机器上执行索引,task-service 就无法直接读取 knowledge-service 的本地路径。
所以完整架构中需要 MinIO。
MinIO 把文件变成对象存储资源,服务之间只传对象路径,而不是依赖某台机器上的本地目录。
3.4 为什么演进到 Spring Cloud
从单体版演进到 Spring Cloud,不是为了炫技,而是为了让系统边界更清晰。
rag-platform 将后端拆成几个核心模块:
text
rag-common
rag-gateway-service
rag-auth-service
rag-knowledge-service
rag-task-service
每个模块都有明确职责。
3.4.1 rag-common:公共能力
rag-common 存放多个服务都会用到的内容。
比如:
ApiResponse:统一返回结构。BusinessException:业务异常。ErrorCode:错误码。BaseEntity:基础实体字段。JwtTokenService:JWT 生成和解析。UserClaims:Token 中的用户信息。UserRole:USER/ADMIN角色。- 文档状态、任务状态、知识库状态枚举。
这些代码如果每个服务都复制一份,会很难维护。放到 common 后,所有服务都可以复用同一套语义。
3.4.2 rag-gateway-service:统一入口
Gateway 是所有后端请求的入口。
它负责:
text
请求路由
JWT 鉴权
白名单放行
用户信息透传
管理员路径校验
CORS 处理
比如用户请求知识库接口时,不直接访问 knowledge-service,而是访问 Gateway:
text
http://localhost:9000/kb/list
Gateway 校验 Token 后,把请求转发给 knowledge-service,并加上用户信息请求头。
这样下游服务不用重复解析 Token,只需要信任 Gateway 透传的用户上下文。
3.4.3 rag-auth-service:认证服务
Auth 服务专门处理用户身份。
它负责:
- 注册。
- 登录。
- BCrypt 密码加密。
- JWT 签发。
- 当前用户查询。
- 用户状态。
- 用户角色。
认证服务独立出来后,其他服务不需要关心密码怎么校验、Token 怎么签发,只需要根据 Gateway 透传的身份做业务判断。
3.4.4 rag-knowledge-service:RAG 主业务服务
Knowledge 服务是 KnowHub 的核心业务服务。
它负责:
- 知识库管理。
- 文档上传入口。
- 文档元数据。
- 文档切片查询。
- 向量检索。
- RAG 问答。
- qa_log 和 qa_reference。
- Redis owner 缓存。
- Sentinel 限流。
- AI 调用降级。
它不应该承担所有后台耗时任务。文档索引执行应该交给 task-service,这样职责更清楚。
3.4.5 rag-task-service:异步任务服务
Task 服务负责索引任务治理。
在完整链路中,它通过 RabbitMQ 接收索引消息,执行解析、切片、向量化和入库。
它关注的问题不是"用户怎么提问",而是:
- 任务从 WAITING 到 RUNNING 是否正确。
- 执行成功后是否更新 SUCCESS。
- 失败后是否记录原因。
- 是否允许手动重试。
- RUNNING 超时是否能标记 TIMEOUT。
- RabbitMQ 重复投递时是否能幂等处理。
这就是服务拆分的价值:每个服务只专注自己的核心职责。
3.5 单体链路与微服务链路对比
为了更直观地理解拆分,我们以"上传文档"为例。
3.5.1 单体版上传链路
在 rag-demo-monolith 中,链路大致是:

所有步骤都在一个 Spring Boot 应用内完成。
好处是简单,坏处是边界不够清晰。
3.5.2 微服务版上传链路
在 rag-platform 中,完整链路会变成:
`
这条链路看起来更长,但职责更清楚。
- Gateway 负责入口和鉴权。
- knowledge-service 负责文档入口和业务校验。
- MinIO 负责文件存储。
- RabbitMQ 负责异步解耦。
- task-service 负责索引执行。
- Redis 负责幂等和缓存。
- MySQL 和 pgvector 分别负责业务数据和向量数据。
3.5.3 问答链路对比
单体版问答链路:

微服务版问答链路:

微服务版多了 Gateway 和用户隔离,这正是企业系统必须补齐的部分。
3.6 服务之间如何协作
拆成多个服务后,一个关键问题是:服务之间怎么找到彼此,怎么调用彼此?
Knowhub 中主要有三种协作方式。
3.6.1 Gateway 路由
前端只需要记住 Gateway 地址。
比如:
text
http://localhost:9000
用户访问:
text
/auth/login
/kb/list
/kb/{kbId}/chat
/admin/index-tasks
Gateway 根据路径把请求转发到对应服务。
这种方式的好处是前端不需要知道每个后端服务的端口,也方便统一鉴权。
3.6.2 Nacos 服务注册与发现
每个服务启动后,会把自己注册到 Nacos。
比如:
text
rag-auth-service
rag-knowledge-service
rag-task-service
rag-gateway-service
Gateway 或 Feign 调用下游服务时,可以通过服务名找到真实实例。
这比写死 IP 和端口更灵活。以后服务部署到不同机器,或者一个服务启动多个实例,只要注册到 Nacos,调用方就能发现它。
3.6.3 OpenFeign 同步调用
同步调用适合"当前请求需要立即拿到结果"的场景。
比如 Knowledge-Service 需要查询 Task-Service 中某个文档的最新索引任务,就可以通过 OpenFeign 调用。
同步调用的特点是:
text
调用方等待结果
下游失败会影响当前请求
适合查询类、状态类、轻量操作
3.6.4 RabbitMQ 异步消息
异步消息适合耗时任务。
比如文档索引不需要在上传接口里同步完成。knowledge-service 只需要创建任务并发送消息,task-service 后台消费即可。
异步调用的特点是:
text
调用方不等待任务完成
任务可以后台执行
失败可以重试
削峰能力更好
需要处理重复消费和消息可靠性
所以,OpenFeign 和 RabbitMQ 不是谁替代谁,而是分工不同。
一句话总结:
需要立即返回结果的,用 Feign;耗时、可后台处理的,用 RabbitMQ。
3.7 Maven 多模块结构
rag-platform 是 Maven 多模块工程。
父工程 pom.xml 负责管理子模块和公共版本。
结构类似:
text
rag-platform
├── pom.xml
├── rag-common
├── rag-gateway-service
├── rag-auth-service
├── rag-knowledge-service
└── rag-task-service
父工程本身通常不写业务代码,它负责:
- 统一 Java 版本。
- 统一 Spring Boot / Spring Cloud 版本。
- 声明子模块。
- 管理依赖版本。
子模块负责具体业务。
初学者打开项目时,要从父工程打开,而不是只打开某个子模块。否则 IDEA 可能识别不到模块之间的依赖关系。
3.8 拆分后的收益和代价
微服务不是只有好处,也有代价。
3.8.1 收益
第一,职责更清晰。
认证、网关、知识库、任务分别拆开后,每个服务关注的问题更聚焦。
第二,权限边界更明确。
gateway 统一鉴权,业务服务做资源归属校验,比前端传 userId 更可靠。
第三,任务更容易治理。
文档索引变成后台任务后,可以记录状态、失败原因、重试次数、执行耗时,也可以通过管理端查看。
第四,部署和扩展更灵活。
如果将来问答请求多,可以扩展 knowledge service;如果索引任务多,可以扩展 task service 消费者。
第五,更接近企业项目。
对学习和简历来说,多服务架构能体现更多后端工程能力,比如服务注册、网关、鉴权、异步任务、缓存、降级和运维排查。
3.8.2 代价
第一,启动更复杂。
单体项目启动一个应用就够,微服务至少要启动 Gateway、auth、knowledge、task,还要启动 Nacos、MySQL、Redis、RabbitMQ、MinIO、PostgreSQL。
第二,配置更多。
每个服务都有自己的 application.yml,数据库、Redis、Nacos、模型 API、端口都要配置正确。
第三,排查链路更长。
一个请求失败,可能是 Gateway 拦截、Token 过期、Nacos 未注册、Feign 调用失败、数据库异常、模型接口超时。
第四,数据一致性更难。
文档上传、任务创建、消息投递、索引执行分布在多个组件中,必须通过状态机、日志和重试机制保证最终可追踪。
所以,微服务不是为了简单,而是为了在复杂业务下保持边界清晰。
3.9 常见问题排查
3.9.1 服务没有注册到 Nacos
现象:Gateway 找不到下游服务,Feign 调用失败。
排查顺序:
- Nacos 是否启动。
- 服务配置中的 Nacos 地址是否正确。
- 服务名是否和 Gateway 路由配置一致。
- 服务启动日志中是否出现注册成功信息。
3.9.2 Gateway 路由不生效
现象:访问 Gateway 返回 404 或 503。
排查顺序:
- 请求路径是否匹配路由规则。
- 下游服务是否注册。
- Gateway 是否引入负载均衡依赖。
- 是否被鉴权过滤器提前拦截。
3.9.3 Token 透传失败
现象:下游服务拿不到用户 ID,或者提示未登录。
排查顺序:
- 前端是否携带
Authorization: Bearer xxx。 - Gateway 是否正确解析 Token。
- Gateway 是否写入
X-User-Id、X-Username、X-User-Role。 - 下游服务拦截器是否读取这些请求头。
3.9.4 Feign 调用失败
现象:knowledge-service 调用 task-service 报错。
排查顺序:
- task-service 是否启动。
- task-service 是否注册到 Nacos。
- FeignClient 的服务名是否正确。
- 接口路径、请求方法、参数是否一致。
- 超时时间是否太短。
3.9.5 RabbitMQ 消息没有被消费
现象:上传文档后任务一直 WAITING。
排查顺序:
- RabbitMQ 是否启动。
- exchange、queue、routing key 是否一致。
- task-service 消费者是否启动。
- 消息是否堆积在队列中。
- 消费者是否因为异常一直重试或被关闭。
3.9.6 跨服务文件访问失败
现象:文档上传成功,但索引任务找不到文件。
排查顺序:
- 文件是否上传到 MinIO。
- 数据库中的 storage_path 是否正确。
- task-service 是否能访问 MinIO。
- bucket 是否存在。
- accessKey、secretKey 是否正确。
如果系统仍依赖本地路径,要特别注意服务是否部署在同一台机器。不同机器之间不能直接读取对方本地磁盘。
本章小结
这一章我们完成了从单体项目到 Spring Cloud 微服务的整体理解。
rag-demo-monolith 的作用是降低复杂度,先跑通 RAG 核心业务链路。它适合学习文档上传、解析、切片、向量检索和问答闭环。
rag-platform 的作用是把已经验证过的核心能力拆成更清晰的企业级结构。Gateway 负责统一入口,auth-service 负责认证,knowledge-service 负责 RAG 主业务,task-service 负责异步索引任务,rag-common 负责公共能力。
微服务带来了职责清晰、权限边界明确、任务治理能力增强等收益,也带来了启动复杂、配置更多、排查链路更长等代价。
从下一章开始,我们将进入认证和权限部分,先讲 JWT 登录认证与 Gateway 统一鉴权。因为在企业知识库中,只有先知道"谁在访问",后面才能谈"他能访问哪些知识库"。
思考题
- 为什么 KnowHub 不建议一开始就直接写 Spring Cloud 多服务?
rag-demo-monolith在整个学习路线中承担什么作用?- 单体项目中通过请求参数传
userId有什么风险? - Gateway 为什么适合作为统一鉴权入口?
- OpenFeign 和 RabbitMQ 的使用场景有什么区别?
- 为什么文档索引任务适合拆到 task-service?
- 微服务拆分后,排查问题为什么会比单体项目更复杂?