8 Layer 合并为 3 进程
coomia-dip 的初始架构将系统划分为 8 个 Layer,每个 Layer 独立部署、独立演进。这个设计在理论上完美遵循了"关注点分离"原则,但在一个 4 人团队的实际开发中,8 个独立进程意味着 8 份配置、8 个启动脚本、28 条潜在的 gRPC 连接——复杂度远超团队的承受能力。本文记录了我们如何在保持逻辑架构清晰的前提下,将 8 个 Layer 合并为 3 个部署进程:Control(B+G)、Compute(C+F)、Intelligence(D+E),以及合并过程中的技术挑战和经验教训。
“系列:S14 工程实录 · 第 3 篇 | 难度:中级 | 阅读时间:15 分钟
8 Layer 合并为 3 进程
#TL;DR
coomia-dip 的初始架构将系统划分为 8 个 Layer,每个 Layer 独立部署、独立演进。这个设计在理论上完美遵循了"关注点分离"原则,但在一个 4 人团队的实际开发中,8 个独立进程意味着 8 份配置、8 个启动脚本、28 条潜在的 gRPC 连接——复杂度远超团队的承受能力。本文记录了我们如何在保持逻辑架构清晰的前提下,将 8 个 Layer 合并为 3 个部署进程:Control(B+G)、Compute(C+F)、Intelligence(D+E),以及合并过程中的技术挑战和经验教训。
#1. 问题:8 Layer 的代价
#1.1 理想与现实的差距
分层架构在白板上画出来时非常优雅:
A (Deployment) → B (Control) → C (Data)
↓ ↓
G (Metadata) F (Pipeline)
↓
D (Reasoning) → E (Agent)
↓
H (SDK)
每个 Layer 有清晰的职责边界,技术栈可以独立选择,团队可以并行开发。这是微服务架构的教科书案例。
但现实是:
问题 1:启动一个完整的开发环境需要 12 分钟
开发者每天早上到办公室的第一件事是 docker compose up,然后等 12 分钟。这 12 分钟包括:
- 8 个服务的镜像拉取和启动
- 3 个基础设施(Doris、MinIO、Redis)的初始化
- 服务间的健康检查和连接建立
如果中间有任何一个服务启动失败(在 WSL2 环境下,这种情况大约每周发生 2-3 次),就需要排查问题、清理状态、重新启动,可能又要 20 分钟。
问题 2:调试跨 Layer 的问题极其困难
一个典型的 Action 执行流程需要经过 4 个 Layer:
SDK (H) → Control Layer (B) → Data Layer (C) → Intelligence Layer (D)
当 Action 执行失败时,开发者需要在 4 个服务的日志中来回跳转,拼凑出完整的调用链。我们虽然集成了分布式追踪(Jaeger),但在开发环境中,额外启动一个 Jaeger 服务意味着内存消耗又增加 500MB。
问题 3:gRPC 连接管理的复杂度
8 个 Layer 之间最多可能有 C(8,2) = 28 条双向连接。实际使用中,我们有 14 条活跃的 gRPC 连接。每条连接都需要:
- 配置目标地址和端口
- 处理连接超时和重试
- 管理连接池
- 处理优雅关闭
14 条连接的配置和管理代码加起来超过 2000 行。
问题 4:测试环境的资源消耗
CI/CD 流水线中,跑一次完整的集成测试需要启动全部 8 个服务 + 3 个基础设施 = 11 个容器。在我们的 CI 服务器上(32GB 内存),这占用了约 20GB 内存,几乎没有余量。
#1.2 量化痛苦
我们用一周时间收集了以下数据:
| 指标 | 数值 |
|---|---|
| 每日启动开发环境的时间 | 12-30 分钟 |
| 每周因启动失败浪费的时间 | 2-3 小时 |
| 跨 Layer 调试的平均时间 | 45 分钟/次 |
| gRPC 连接管理代码行数 | 2,100 行 |
| Docker Compose 配置行数 | 380 行 |
| CI 完整测试运行时间 | 35 分钟 |
| CI 内存消耗 | 20GB |
这些数字告诉我们:架构的优雅不能以开发效率为代价。
#2. 设计约束:什么不能变
在讨论合并方案之前,我们明确了不可妥协的约束:
#2.1 逻辑边界必须保留
合并的是部署单元(进程),不是代码组织。每个 Layer 的代码仍然在独立的 package/module 中,有清晰的 API 边界。这意味着未来如果团队扩大,可以随时拆回独立部署。
#2.2 gRPC 接口不变
所有 Layer 之间的通信仍然通过 gRPC。合并后,同一进程内的 Layer 之间的 gRPC 调用走进程内通道(in-process channel),避免网络开销,但接口定义不变。
#2.3 技术栈约束
- B(Control)和 G(Metadata)都是 Java/Spring Boot,可以合并
- C(Data)和 F(Pipeline)都是 Java/Quarkus,可以合并
- D(Reasoning)和 E(Agent)都是 Python/FastAPI,可以合并
- A(Deployment)保持独立(它是部署工具,不是运行时服务)
- H(SDK)保持独立(它是客户端库,不是服务)
#2.4 可逆性
合并必须是可逆的。如果未来需要,任何被合并的 Layer 都能在一周内拆回独立部署。
#3. 合并方案
#3.1 最终的 3 进程架构
经过评估,我们确定了以下合并方案:
进程 1: Control Service (Java/Spring Boot)
├── Control Layer: Control Layer(Ontology Runtime, Schema Registry, Action Engine)
└── Metadata & Governance Layer: Metadata & Governance(元数据管理, 数据血缘, 审计)
进程 2: Compute Service (Java/Quarkus)
├── Data Layer: Data Layer(存储引擎, 查询引擎, OQL)
└── Pipeline & Orchestration Layer: Pipeline & Orchestration(数据管道, ETL, 调度)
进程 3: Intelligence Service (Python/FastAPI)
├── Reasoning & Decision Layer: Reasoning & Decision(规则引擎, 推理引擎)
└── Agent Runtime Layer: Agent Runtime(Agent 框架, Temporal 工作流)
Deployment & Operations Layer(Deployment)和 SDK & Developer Experience Layer(SDK)不参与合并——它们本来就不是运行时服务。
#3.2 为什么是这样的组合
B + G → Control Service
B(Control)负责 Ontology 的核心运行时,G(Metadata)负责元数据治理。它们共享大量的领域模型(ObjectType、LinkType、PropertyType),合并后可以消除大量的跨进程数据传输。
在合并前,G 需要通过 gRPC 从 B 拉取 ObjectType 定义来构建数据血缘;合并后,G 可以直接调用 B 的内部 API,延迟从 5-10ms 降到 < 0.1ms。
C + F → Compute Service
C(Data)负责数据存储和查询,F(Pipeline)负责数据管道。它们共享存储层——管道的输出直接写入 Data Layer 的存储。
在合并前,F 执行完 ETL 后需要通过 gRPC 将数据推送到 C;合并后,F 可以直接调用 C 的存储 API,批量导入性能提升 3-5 倍。
D + E → Intelligence Service
D(Reasoning)负责规则和推理,E(Agent)负责 Agent 运行时。它们共享 Python 运行时和 AI 模型加载器。
在合并前,E 的 Agent 需要通过 gRPC 调用 D 的推理接口进行决策;合并后,Agent 可以直接调用推理引擎的 Python 函数,消除了序列化/反序列化开销。
#4. 技术实现
#4.1 进程内 gRPC 通道
合并的核心技术挑战是:如何在同一进程内的两个 Layer 之间保留 gRPC 通信,但避免网络开销。
Java 侧(Control Service / Compute Service)
我们使用 gRPC 的 InProcessServer 和 InProcessChannel:
// 创建进程内服务器
Server inProcessServer = InProcessServerBuilder
.forName("control-internal")
.addService(new MetadataGrpcService(metadataService))
.build()
.start();
// 创建进程内通道
ManagedChannel channel = InProcessChannelBuilder
.forName("control-internal")
.directExecutor()
.build();
// 使用通道创建 Stub(与远程调用完全一样)
MetadataServiceGrpc.MetadataServiceBlockingStub stub =
MetadataServiceGrpc.newBlockingStub(channel);
这种方式的好处是:
- Stub 的使用方式与远程调用完全一致
- 如果需要拆回独立部署,只需要将
InProcessChannel改为ManagedChannel - 进程内调用的延迟从 5-10ms 降到 < 0.1ms
Python 侧(Intelligence Service)
Python 的 grpcio 库也支持进程内通道,但我们采用了更简单的方案——直接函数调用 + 接口适配器:
class ReasoningClient:
"""统一接口,支持远程和本地两种模式"""
def __init__(self, mode: str = "local"):
if mode == "remote":
self._client = GrpcReasoningClient(address="reasoning:50051")
else:
self._client = LocalReasoningClient(reasoning_engine)
def evaluate_rules(self, context: RuleContext) -> RuleResult:
return self._client.evaluate_rules(context)
#4.2 配置统一
合并前,8 个服务各有自己的配置文件。合并后,我们使用分层配置:
# control-service.yml
server:
port: 8080
grpc-port: 50051
# Control Layer (B) 配置
ontology:
runtime:
cache-size: 10000
schema-version-limit: 100
# Metadata Layer (G) 配置
metadata:
lineage:
enabled: true
storage: doris
audit:
retention-days: 90
每个 Layer 的配置前缀不同,互不干扰。
#4.3 健康检查合并
合并前,每个服务有自己的 /health 端点。合并后,健康检查需要聚合多个 Layer 的状态:
@GetMapping("/health")
public HealthResponse health() {
Map<String, PlaneHealth> Layers = new LinkedHashMap<>();
Layers.put("control-Layer", controlPlaneHealth.check());
Layers.put("metadata-Layer", metadataPlaneHealth.check());
boolean allHealthy = Layers.values().stream()
.allMatch(h -> h.getStatus() == Status.UP);
return new HealthResponse(
allHealthy ? Status.UP : Status.DEGRADED,
Layers
);
}
返回示例:
{
"status": "UP",
"Layers": {
"control-Layer": {"status": "UP", "details": {"objects": 15230}},
"metadata-Layer": {"status": "UP", "details": {"lineageEdges": 8940}}
}
}
#4.4 日志隔离
合并后的一个重要问题是日志混杂。我们通过 MDC(Mapped Diagnostic Context)为每个 Layer 的日志添加前缀:
[2024-08-15 10:23:45] [CONTROL-B] Creating ObjectType: Device
[2024-08-15 10:23:46] [METADATA-G] Recording lineage for ObjectType: Device
[2024-08-15 10:23:46] [CONTROL-B] ObjectType created: Device (v1)
这样在查看合并后的日志时,仍然可以清晰地区分每个 Layer 的日志。
#5. 合并过程
#5.1 时间线
整个合并过程历时 3 周:
Week 1:准备
- 梳理所有跨 Layer 的 gRPC 接口
- 确认哪些可以改为进程内调用
- 编写进程内通道的适配器
- 更新 Docker Compose 配置
Week 2:合并
- Day 1-2:合并 B + G → Control Service
- Day 3-4:合并 C + F → Compute Service
- Day 5:合并 D + E → Intelligence Service
Week 3:验证
- 运行全部 3500+ 测试
- 性能基准测试对比
- 修复合并引入的 Bug(共 7 个)
- 更新文档
#5.2 合并中遇到的问题
问题 1:Spring Boot 和 Bean 冲突
B 和 G 都是 Spring Boot 应用,合并后出现了 Bean 名称冲突。例如,两个 Layer 都定义了 ObjectTypeRepository 的 Bean。
解决方案:使用 @Qualifier 注解区分,并统一命名规范为 {Layer}{BeanName}:
@Bean
@Qualifier("controlObjectTypeRepository")
public ObjectTypeRepository controlObjectTypeRepository() { ... }
@Bean
@Qualifier("metadataObjectTypeRepository")
public ObjectTypeRepository metadataObjectTypeRepository() { ... }
问题 2:Quarkus 的类加载问题
C 和 F 都是 Quarkus 应用,合并后发现 Quarkus 的 CDI(Context and Dependency Injection)在处理两个模块的相同接口时出现了歧义。
解决方案:使用 @Alternative 和 @Priority 注解明确优先级。
问题 3:Python 的模块命名冲突
D 和 E 都有 models 包,合并后 import models 出现歧义。
解决方案:所有导入改为绝对路径导入:
# 之前
from models import RuleContext
# 之后
from intelligence_plane.reasoning.models import RuleContext
from intelligence_plane.agent.models import AgentContext
问题 4:端口冲突
合并前每个服务监听不同端口(8080-8087)。合并后只需要 3 个 HTTP 端口 + 3 个 gRPC 端口。
解决方案:统一端口分配策略。
| 服务 | HTTP | gRPC |
|---|---|---|
| Control Service | 8080 | 50051 |
| Compute Service | 8081 | 50052 |
| Intelligence Service | 8082 | 50053 |
问题 5:数据库连接池调优
合并前,每个服务维护自己的数据库连接池(每个 10 个连接 × 8 = 80 个连接)。合并后,3 个服务共享更少的连接就够了(每个 20 个连接 × 3 = 60 个连接)。
#6. 合并效果
#6.1 开发效率提升
| 指标 | 合并前 | 合并后 | 提升 |
|---|---|---|---|
| 启动开发环境 | 12 分钟 | 4 分钟 | 3x |
| CI 完整测试 | 35 分钟 | 18 分钟 | 1.9x |
| Docker Compose 行数 | 380 行 | 120 行 | -68% |
| gRPC 连接数 | 14 条 | 3 条 | -79% |
| 连接管理代码 | 2,100 行 | 600 行 | -71% |
| CI 内存消耗 | 20GB | 10GB | -50% |
#6.2 性能提升
进程内 gRPC 调用消除了网络开销:
| 调用路径 | 合并前延迟 | 合并后延迟 | 提升 |
|---|---|---|---|
| B → G(元数据查询) | 8ms | 0.05ms | 160x |
| C → F(管道触发) | 6ms | 0.03ms | 200x |
| D → E(Agent 推理) | 12ms | 0.08ms | 150x |
| B → C(跨进程) | 8ms | 8ms | 无变化 |
注意:跨进程的调用(如 B → C)延迟不变,因为它们仍然走网络 gRPC。
#6.3 代码组织
合并后的代码目录结构:
control-Layer/
├── control-control/ # Control Layer 代码
│ ├── src/main/java/com/onto/control/
│ └── src/test/java/
├── governance-metadata/ # Metadata & Governance Layer 代码
│ ├── src/main/java/com/onto/metadata/
│ └── src/test/java/
├── control-service/ # 合并后的启动入口
│ ├── src/main/java/com/onto/control/ControlServiceApplication.java
│ └── src/main/resources/application.yml
└── build.gradle # 多模块构建
每个 Layer 的代码仍然在独立的子模块中,保持了代码层面的清晰隔离。
#7. 可逆性验证
为了验证"可逆性"约束,我们在合并完成后花了半天时间做了一次"拆分演练":
- 将
governance-metadata从control-Layer中拆出 - 创建独立的
metadata-service启动入口 - 将进程内通道改为网络 gRPC 通道
- 运行 Metadata & Governance Layer 的全部测试
结果:约 3 小时完成拆分,所有测试通过。这验证了我们的合并方案确实是可逆的。
#8. 经验教训
#8.1 架构应该匹配团队规模
Conway 定律说"系统的架构反映组织的沟通结构"。我们的经验补充了一条:系统的部署单元数量不应超过团队人数的 2 倍。
4 人团队维护 8 个部署单元 = 每人 2 个。考虑到每个部署单元都有配置、监控、故障排查的开销,这已经超出了合理范围。合并为 3 个部署单元后(每人 < 1 个),开发效率显著提升。
#8.2 逻辑架构和部署架构可以解耦
这是这次合并最重要的认识。逻辑上的 8 个 Layer 和部署上的 3 个进程完全可以共存。代码组织遵循逻辑架构(清晰的边界、独立的模块),部署策略遵循实际需求(开发期合并,扩展期拆分)。
#8.3 进程内 gRPC 是一个好模式
进程内 gRPC 通道让我们在合并部署单元的同时保留了接口契约。这意味着:
- 接口测试不需要修改
- 拆分时只需要改通道配置
- 性能有数量级的提升
#8.4 合并要趁早
如果我们在第三个月就做这个合并,而不是等到第九个月,可以省下约 6 个月的"运维税"。延迟合并的原因是"担心破坏现有功能"——但事实证明,有了完善的测试套件,合并是安全的。
#9. 什么时候应该拆回去
我们预设了三个"拆分触发条件":
- 团队扩大到 8 人以上:每个进程至少 2 人负责,有足够的人力维护独立部署
- 单进程的资源消耗达到瓶颈:CPU 或内存无法通过垂直扩展满足
- 不同 Layer 需要不同的扩缩策略:如 Intelligence Layer 需要 GPU 而 Control Layer 不需要
目前(Day 420),这三个条件都未触发。
#10. 总结
8 Layer 合并为 3 进程,是 coomia-dip 项目中对"务实工程"的一次重要实践。它告诉我们:好的架构不是追求理论上的完美分离,而是在清晰性和实用性之间找到平衡。
逻辑上,我们仍然有 8 个 Layer。代码上,每个 Layer 仍然是独立的模块。但在部署上,我们选择了对 4 人团队最高效的方式——3 个进程。
如果未来团队扩大、业务增长,我们随时可以拆回去。而且因为我们保留了 gRPC 接口和模块边界,拆分的成本是可控的。
#Key Takeaways
- 部署单元数 <= 团队人数 x 2:超过这个比例,运维开销会吞噬开发效率
- 逻辑架构和部署架构可以解耦:代码组织遵循逻辑边界,部署策略遵循实际需求
- 进程内 gRPC 通道是合并部署但保留接口契约的好模式
- 合并要趁早:有完善的测试套件支撑,合并的风险远低于预期
- 预设拆分条件:明确什么时候需要拆回去,避免合并后的惰性
#Next Article
下一篇:S14-04 存储统一之路 — 从 PostgreSQL 到 ClickHouse 到 Doris,存储架构三次变迁的完整故事。
Tags: #coomia-dip #架构合并 #微服务 #部署架构 #gRPC #Conway定律 #工程效率