1. 拆分容易收拢难合并服务的动机与边界微服务合并这个话题最近两年在技术圈被反复提起。我所在的团队在去年做过一次规模不小的服务合并把原本跑在容器里的九个服务收敛成三个其中最大的一次是把订单、库存、履约这三个高频互相调用的服务并成了一个进程。做完之后接口平均延迟从 180ms 降到 45ms发布窗口从必须三个组同时在线变成一个组点一下按钮。但也有代价比如某次库存模块的一个慢查询把整个进程的线程池拖垮连带订单接口一起超时。所以这篇东西不是要劝你合并也不是要劝你拆分而是把一次真实的合并过程、踩过的坑、判断标准摊开讲清楚让正在纠结要不要合的人能有个参照。先把话说直白多微服务合并为一个服务指的是把若干个原本独立部署、独立进程、通过网络调用的服务收敛到同一个部署单元里服务之间的远程调用退化为进程内的方法调用。它解决的核心问题是服务粒度过细带来的协作成本和调用成本超过收益。适合谁看一是系统已经拆了不少服务发现运维、联调、排障越来越吃力的团队二是正在做架构评审需要判断某个服务该不该继续独立的人三是接手了一个微服务数量比开发人数还多的遗留系统想知道从哪下手收敛的同行。1.1 拆过细的六个典型症状判断要不要合并别凭感觉先看症状。我把见过的、也亲历过的信号列出来命中三条以上基本就该认真评估合并了。第一跨服务调用占了单次请求的大头。一个下单请求要串行调用用户、商品、库存、优惠、风控五个服务每个服务 RTT 20ms光网络往返就 100ms加上超时重试和序列化反序列化实际耗时翻倍。这种情况下业务逻辑本身可能只占 20% 的时间。第二一个字段的改动要发五个服务。产品要加个预计送达时间需要改订单、改履约、改网关、改前端聚合层还得协调四个仓库的合并顺序谁先发谁后发要开会定。这种耦合说明服务边界切错了切在了业务实体内部而不是业务边界上。第三联调必须起五个进程。本地开发想跑通一条完整链路得靠脚本一键拉起一堆服务加上注册中心、配置中心、消息队列一台开发机的内存直接见底。开发效率的损失是隐性的但非常真实。第四分布式事务的补偿代码比业务代码还长。为了一个跨服务的扣减一致性写了对账任务、补偿队列、人工干预界面最后发现这个场景本来就是强一致的压根不该拆开。这是最典型的为了微服务而微服务。第五注册中心的实例数、监控的告警规则、日志的索引数量随服务数量线性膨胀。九个服务意味着九套告警阈值、九个日志索引、九份容量规划运维同学的时间被大量消耗在这些重复劳动上。第六团队规模撑不起服务数量。这是最根本的一条。业内常说的经验值是一个服务对应一个能独立负责它的团队如果团队只有六七个人却维护着十几二十个服务那每个人都是全栈救火队员服务的独立部署和独立演进这两个好处根本兑现不了剩下的只有开销。提示症状判断只是起点真正决定合并的是边界是否可拆。即使症状全中如果两个服务的数据所有权、生命周期、伸缩特征完全不同硬合也会出问题后面会细讲。1.2 收益与代价的对照账合并不是免费的做之前要能把账算清楚否则很容易被单点故障变多了这种声音否掉。我一般会把下面这张表拉出来让团队一起填填完结论自然就清楚了。维度合并带来的变化需要注意的代价部署单元从 N 个降到 1 个发布流程显著简化任一模块的缺陷都会导致整体回滚调用延迟远程调用变本地调用省去网络与序列化开销进程内长耗时调用会占用公共线程池事务一致性分布式事务退化为本地事务补偿逻辑可以删掉事务范围变大长事务风险上升排障效率一个请求一份日志链路一目了然单进程内多个模块的日志需要做区分资源占用省掉多份 JVM 开销、多份基础镜像单实例内存需求变大堆内存要重算弹性伸缩按整体伸缩简单可控无法针对热点模块单独扩容故障隔离减弱一个模块的线程池打满会扩散需要用舱壁模式做进程内隔离这张表里最容易被低估的是资源占用那一行。很多人以为合并能省钱实际上单实例内存反而要往上调。我们那次合并五个服务各 2G 堆合计 10G合并后给了 6GGC 压力反而比原来单服务时更大因为对象存活集变大了。所以合并省的是调度复杂度和镜像管理成本不一定是内存。1.3 不该合并的三种情形第一种两个服务的数据隔离是合规或安全要求。这种情况不要动合并会破坏隔离边界后续很难补回来。第二种两个服务的伸缩特征差异极大。比如一个服务每天高峰期 QPS 十万另一个服务平时每分钟几次。合在一起后前者需要几十个副本后者被顺带扩容了成本浪费严重而且后者的资源争抢会影响到前者。这种应该保持独立。第三种两个服务属于完全不同的业务域只是恰好由同一个团队维护。这时候该做的是把团队拆开或者把服务托管出去而不是合到一起。合了之后会变成一个什么都干的胖服务过一年又得重新拆来回折腾的成本更高。注意判断是否同一业务域有个特别好用的土办法看两个服务是否共享核心表的主键。如果 A 服务需要拿着 B 服务的主键去查 B 的数据并且这种调用是高频同步的那它们大概率属于同一个边界。2. 合并路径怎么选物理合并、逻辑合并还是模块化单进程确定要合并之后下一步是选路径。这一步选错后面返工的成本会很大。我见过三种主流做法各有适用场景。2.1 三条路径的能力对比路径做法适合场景主要风险物理合并多个服务代码并进一个仓库、一个进程、一套配置边界清晰、共享数据多、调用频繁代码耦合失控变成大泥球模块化单进程一个进程但内部严格分模块模块间只允许通过接口通信团队不大但希望保留清晰的边界约束约束靠工具和纪律容易被绕过逻辑聚合保留独立进程前面加一层聚合服务或网关做编排只想减少前端调用次数不想动内部调用链更长聚合层成为新的瓶颈我在实际项目里用的是第二种也就是模块化单进程。理由很务实一方面物理合并后代码全混在一起包结构很快就会乱另一方面如果保留多个进程只做 BFF 聚合调用链没变短收益太薄。模块化单进程相当于进程合并、边界保留既能拿到本地调用的性能又能靠构建工具和静态检查守住依赖方向。2.2 用依赖频次和共享数据来定边界选路径之前得先摸清服务之间的关系。我的做法是拉三份数据静态依赖从代码仓库里扫看服务之间有没有直接的客户端调用、有没有共享的公共库、有没有共用的数据表。动态数据从链路追踪里统计统计周期取两周看三个指标跨服务调用的 QPS、P99 延迟占比、调用链平均跨度数。业务归属问产品或者业务方这两个服务是否由同一条业务线的同一个需求驱动改动。把结果整理成表合并优先级自然就排出来了服务日均被调用P99 延迟是否共享表是否同业务线合并优先级订单基准120ms是是高库存12 万80ms是是高履约9 万90ms否是中优惠3 万40ms否否低风控1 万200ms否否不合并判断原则很朴素两个服务如果共享同一批核心表并且调用是高频同步的那基本可以判定边界切错了优先合并如果只是链路上下游、数据不共享那就往后放如果连业务归属都不同就别碰。2.3 边界划分的实操判据真正难的是合并之后怎么划模块。我的经验是三个判据一是数据所有权。每张核心表只能有一个模块负责写其他模块即使同进程也不能直接访问它的 Mapper。这条最有效因为数据是最不容易造假的边界。二是变更频率。频繁一起变更的代码放一个模块一年都不动一次的放另一个。放在一起的好处是改动集中测试范围清晰。三是失败影响面。如果一个模块的失败不能影响另一个模块那它们之间必须有明确的隔离手段比如独立线程池、独立连接池、独立超时。这种情况下物理上在一个进程逻辑上要有舱壁。3. 核心难点拆解数据、事务、接口与配置合并过程中真正卡住进度的几乎都不是代码搬迁本身而是数据、事务、接口兼容和基础设施配置这四块。下面一个个说。3.1 数据库合并的三种粒度与选择数据库怎么处理是合并方案里最先要定的事情因为它决定了后续所有代码改造的形态。我总结成三档。第一档同实例不同 schema。多个库放在同一个数据库实例上各自保留 schema 名代码里通过不同的数据源访问。改造量最小只需要调整连接配置表结构和 SQL 几乎不用动。缺点是跨 schema 的本地事务在部分数据库上不能直接生效还是得靠应用层协调。这一档适合合并初期快速见效。第二档同 schema 不同表前缀。把所有表并到一个 schema用前缀区分归属比如order_、inventory_。这样才能真正用上本地事务。改造量中等主要是改表名和对应的实体映射。我当时做的是这一档改了两百多张表靠脚本批量替换加上人工核对。第三档彻底整合同语义的表合并成一张。改造量最大业务含义要重新梳理。除非两个服务确实在维护同一份数据否则不建议。具体操作上我踩过几个坑值得说。自增主键冲突是最常见的两个服务的表都是BIGINT AUTO_INCREMENT合并到一个库后如果存在跨表关联主键会撞车。解决办法是给不同模块分配不同的起始值和步长比如订单从 1000 万起、步长 1库存从 2000 万起、步长 1虽然土但对存量系统最安全。第二个坑是字段同名不同型。订单表里的status是TINYINT履约表里的status是VARCHAR(16)迁移工具会静默截断或者报错。这种必须逐表比对写个脚本把两边information_schema拉出来做 diff人工过一遍。第三个坑是索引爆炸。原来分散在多个库的索引合到一个库写放大的问题会集中爆发。合并前统计一遍每张表的索引数量和写入 QPS超过五个二级索引的写热点表先砍掉冗余索引再合并。3.2 分布式事务退化成本地事务这一块的收益最大但也是风险最集中的地方。远程调用变本地调用之后原本的扣库存 建订单可以用一个Transactional包起来一致性从最终一致变成强一致。听起来很美但有几个必须注意的点。第一原来的补偿逻辑要删干净。我们合并后第一次上线就出了重复扣减的问题原因是一个旧的对账补偿任务还在跑它按历史逻辑去查未确认的扣减记录然后回滚但合并后这些记录已经在同一个事务里提交了补偿任务读到的是正常数据误判后做了反向操作。上线前一定要把散落在各处的定时补偿任务、消息重试消费者、人工干预脚本全部梳理一遍确认哪些该停。第二事务范围会变大。原来一个事务只碰一张表现在可能碰五张表锁的持有时间变长死锁概率上升。我的做法是把事务边界收紧到最短路径上把非必要的远程调用、消息发送、日志落库挪到事务外用事务同步回调或者事件机制处理。Transactional(rollbackFor Exception.class) public void placeOrder(OrderCmd cmd) { // 事务内只做数据库写入顺序固定减少死锁 orderRepository.insert(cmd.toOrder()); inventoryRepository.deduct(cmd.getSkuId(), cmd.getQty()); } // 事务提交后再发消息用 TransactionSynchronization 保证顺序 TransactionSynchronizationManager.registerSynchronization( new TransactionSynchronization() { Override public void afterCommit() { eventPublisher.publish(new OrderPlacedEvent(orderId)); } });第三幂等键要保留。虽然事务变成本地了但外部重试、消息重复投递仍然存在幂等设计不能因为现在有本地事务了就拆掉。3.3 接口兼容与灰度切换策略合并对外的 API 不能变这是硬要求。我的做法是保留原来的 HTTP 契约一层不动只在内部把远程客户端换成同进程的服务注入。// 合并前order 模块通过客户端调用库存 FeignClient(name inventory-service, path /internal/inventory) public interface InventoryClient { PostMapping(/deduct) DeductResult deduct(RequestBody DeductCmd cmd); } // 合并后保留接口定义但改成同进程实现业务代码零改动 Service public class InventoryClientImpl implements InventoryClient { private final InventoryAppService inventoryAppService; Override public DeductResult deduct(DeductCmd cmd) { return inventoryAppService.deduct(cmd); } }这样做的价值在于业务代码一行不用改回滚的时候只要把实现类换成FeignClient版本就能退回去。上线节奏上我建议分三步走第一步双跑。合并后的新进程和原有的多个旧服务同时在线网关按流量比例切先切 1%观察半天再切 10%。第二步指标对齐。对比新旧两侧的核心指标接口 P99、错误率、数据库连接数、GC 时间、线程池活跃数。差异超过 5% 就停下来查。第三步下线旧服务。这里顺序很关键必须是先摘流量再停应用最后清理注册中心实例和网关路由最后删 K8s 里的 Deployment 和 Service。反过来做会出现注册中心里还有实例但进程已经没了的局面调用方会持续报错。整个观察期我给的建议是最少两周覆盖一个完整的业务波动周期。3.4 配置、注册中心与网关的收尾清理这块是纯体力活但漏掉任何一项都会在半夜炸出来。配置合并时最常见的冲突是 key 重名。两个服务都有spring.datasource.url合并后只能留一个必须改成多数据源配置。还有日志路径、上传目录、缓存前缀都可能撞。我的做法是把合并后进程的所有配置 key 拉出来做一次全量 diff冲突项逐个手工处理处理完打标签记录。注册中心这块合并后只需要注册一个服务名但要注意健康检查路径变了。原来每个服务有独立的/actuator/health合并后是一个总端点如果探针配置里的initialDelaySeconds还是 30 秒而合并后的进程启动要 90 秒K8s 会一直在重启循环里打转。我们把启动探针改成startupProbe阈值放到 180 秒之后才交给存活探针接管判断。还有限流熔断规则。原来每个服务一套规则合并后要重新按接口维度配不能简单相加否则总阈值会超过进程的实际承载能力。4. 一次完整的合并落地过程讲完原理下面把实操过程完整走一遍。以我做过的那次为例九个服务收敛成三个其中订单、库存、履约三个并成一个。整个过程分四周节奏是每周一个里程碑。4.1 第一周仓库结构与模块划分先建一个新的空仓库不直接在老仓库上改这点很重要。老仓库保持原样作为回滚的兜底。目录结构用 Maven 多模块核心原则是一个业务模块一个子模块公共能力下沉到 commonmerged-app/ ├── pom.xml 聚合 POM ├── app-bootstrap/ 启动模块只放 Application 类和全局配置 ├── app-common/ 公共工具、统一返回、异常定义 ├── module-order/ 订单域 ├── module-inventory/ 库存域 ├── module-fulfillment/ 履约域 └── module-contract/ 跨模块调用的接口契约聚合 POM 的关键配置modules moduleapp-common/module modulemodule-contract/module modulemodule-order/module modulemodule-inventory/module modulemodule-fulfillment/module moduleapp-bootstrap/module /modules依赖方向必须是单向的业务模块依赖 contract 和 commonbootstrap 依赖所有业务模块contract 不依赖任何业务模块业务模块之间不互相依赖。这一条靠maven-enforcer-plugin的banned-dependencies规则守住谁违反了构建就红。module-contract里放的是接口定义和数据传输对象订单需要调库存就依赖 contract 里的InventoryFacade接口具体实现在module-inventory里由 Spring 在运行时注入。这样业务模块之间没有编译期依赖边界是硬的。启动类合并到一个注意包扫描范围要覆盖所有模块SpringBootApplication(scanBasePackages com.example.merged) EnableScheduling EnableAsync public class MergedApplication { public static void main(String[] args) { SpringApplication.run(MergedApplication.class, args); } }这里有个坑原来每个服务都有自己的EnableFeignClients、MapperScan、EnableTransactionManagement合并后注释要全部集中到这一个类上。MapperScan尤其要注意多个服务的 Mapper 包路径不同要么写多个注解要么统一到同一个根包下。4.2 第二周代码搬迁与远程调用改造搬迁的顺序我建议从被调用方开始先搬库存再搬履约最后搬订单。因为调用方要等被调用方的本地实现就位之后才能改。改造成本最高的是远程调用的替换。原来订单调库存用的是 HTTP 客户端返回结构、异常处理、超时重试都跟远程语义绑定。改成进程内调用后有几处必须同步调整超时和重试的逻辑要删掉。远程调用失败会抛FeignException本地调用失败会抛业务异常如果保留原来的try-catch和重试循环本地调用一旦出问题会被重试 N 次直接把线程池占满。我们上线第一天就遇到了一个库存扣减的业务校验失败被重试了三次日志刷屏。降级逻辑要重新审视。原来远程调用挂了会走 fallback 返回兜底数据现在同进程了fallback 还有没有必要我的判断是同进程调用的失败通常是业务失败或者数据库失败兜底返回假数据反而会掩盖问题建议全部去掉改成快速失败。序列化开销可以省但也别过度乐观。远程调用时对象会被反序列化成新实例本地调用传的是同一个引用如果被调用方修改了入参对象调用方会看到变化。这种意外的副作用在合并后最容易出我的做法是所有跨模块的入参统一做不可变处理或者强制深拷贝。4.3 第三周连接池、线程池与内存参数重算参数重算这一步是我认为整个合并里最需要算而不是猜的部分单独拿出来讲。数据库连接池。原来五个服务各配 20 个连接加起来 100 个。合并后如果还是 20 个就等于总容量砍到五分之一高峰期必然排队。但也不能直接配 100因为数据库的总连接数是有限的。合理的算法是先算出单进程的理论需求所需连接数 ≈ 核心线程数 × 平均每请求占用连接时长 / 平均请求处理总时长举例Tomcat 最大线程 200一次请求平均耗时 50ms其中占数据库连接 10ms那并发占用连接的期望值是 200 × 10 / 50 40。考虑到波动配 50 到 60 比较合适。HikariCP 官方给的经验公式是CPU 核数 × 2 有效磁盘数8 核配 16 到 20但这个公式针对的是纯数据库密集型场景Web 应用要按上面的并发占比来算。spring: datasource: hikari: maximum-pool-size: 50 minimum-idle: 10 connection-timeout: 3000 max-lifetime: 1200000线程池隔离。合并后所有请求共用一个 Tomcat 线程池一个模块的慢接口会拖垮全部。我的做法是对非核心链路比如导出、报表、消息消费单独开线程池核心链路才用主线程池Bean(exportExecutor) public ThreadPoolTaskExecutor exportExecutor() { ThreadPoolTaskExecutor executor new ThreadPoolTaskExecutor(); executor.setCorePoolSize(4); executor.setMaxPoolSize(8); executor.setQueueCapacity(200); executor.setThreadNamePrefix(export-); executor.setRejectedExecutionHandler(new ThreadPoolExecutor.CallerRunsPolicy()); return executor; }注意拒绝策略用CallerRunsPolicy是有意为之的导出任务堆积时让调用线程自己去跑形成天然的反压比直接丢弃更安全也不会把内存撑爆。JVM 内存重算。原来每个服务 2G 堆合计 10G合并后我给的是 6G。这个数不是拍脑袋的思路是合并后省掉了重复的框架类加载和连接池对象但业务对象的存活集会叠加。上线后看 Full GC 后的老年代占用如果稳定在堆的 40% 以下就是健康的。GC 参数建议用 G1并显式设置停顿目标-XX:UseG1GC -Xms6g -Xmx6g -XX:MaxGCPauseMillis200 \ -XX:MetaspaceSize512m -XX:MaxMetaspaceSize512m启动时间也要重算。合并后的进程启动时间从 30 秒左右涨到 90 秒以上因为要初始化所有模块的 Bean。这直接影响 K8s 探针配置和发布时的滚动更新策略maxSurge和maxUnavailable都要调否则滚动发布会卡住。4.4 第四周灰度、观测与旧服务下线灰度期最重要的不是看业务指标而是看隔离是否有效。我列了几个必看项进程级指标CPU、内存、GC 次数和耗时、线程数、文件句柄数。数据库指标活跃连接数、慢查询数、锁等待时间。接口指标按模块维度拆分的 P99、错误率、超时数。这里要求日志和监控都能按模块打标签否则合并后所有指标混成一坨出问题根本不知道是谁的锅。日志打标签的做法是在入口处塞 MDCComponent public class ModuleMdcFilter extends OncePerRequestFilter { Override protected void doFilterInternal(HttpServletRequest req, HttpServletResponse resp, FilterChain chain) throws ServletException, IOException { String module resolveModule(req.getRequestURI()); MDC.put(module, module); try { chain.doFilter(req, resp); } finally { MDC.clear(); // 必须清理线程复用会串味 } } }日志格式里加上%X{module}采集端就能按模块拆分索引了。MDC.clear()那行千万别漏Tomcat 线程池复用线程不清理会导致日志串到别的模块上去排查时会被误导得很惨。旧服务下线的顺序我在前面提过这里再强调一次摘网关流量 → 观察 30 分钟无调用 → 摘注册中心实例 → 停应用 → 删部署单元 → 删配置和告警规则。每一步之间留出观察窗口别图快。5. 常见问题与排查实录合并之后出问题的形态和拆分时完全不一样因为原来的网络故障变成了进程内资源争抢表现出来更隐蔽。下面几个是我印象最深的。5.1 连接池被打满导致全站雪崩现象是合并上线第三天下午两点开始订单接口的 P99 从 50ms 涨到 3 秒然后库存接口也开始超时最后连查询接口都挂了。看监控数据库连接池的活跃连接数一直卡在 50配置的上限等待队列有几百个线程。排查过程先看慢查询日志发现履约模块有一个按时间范围扫全表的查询平时 QPS 很低但那天有个批量任务在跑单个查询耗时 800ms并发 20 个就把连接池占满了。合并前这个问题也存在但它只影响履约服务自己合并后所有模块共用一个连接池就被放大了。解决办法有三层短期是给那个查询加索引并限制批量任务并发中期是拆成两个连接池核心链路一个、批量任务一个长期是在代码层面禁止没有索引条件的范围查询加静态检查规则。问题现象可能原因排查手段处理方式P99 突然上涨连接池打满某模块慢查询占用连接慢查询日志、连接池监控加索引、拆分连接池线程数飙升但 CPU 不高某模块在等锁或等 IOjstack 抓线程栈看阻塞点缩短事务、加超时Full GC 频繁停顿超过 1 秒堆内存不足或大对象堆积GC 日志、堆 dump调堆、查大对象来源日志里模块标签错乱MDC 未清理看日志上下文filter 里加 finally 清理定时任务重复执行多副本都跑同一个任务看任务执行日志条数加分布式锁或单副本调度5.2 定时任务和异步线程的重复执行这个坑非常典型。原来每个服务只有一个副本时Scheduled任务天然不会重复。合并后如果部署三个副本同一个任务就被执行三次。我们有个对账任务就因此重复跑导致金额被重复统计。处理方式有几种最简单的是把定时任务从业务进程里剥出去做成独立的调度任务如果不方便剥离就在任务上加分布式锁抢到锁的实例才执行还有一种是用调度框架的统一调度能力只在一个实例上触发。我倾向第一种物理隔离最彻底也避免了任务跑在业务进程里抢占资源。5.3 类冲突与依赖版本打架两个服务依赖同一个公共库的不同版本合并到一个 POM 后Maven 的依赖仲裁机制会选一个版本另一个服务的代码可能因为 API 不兼容直接编译失败或运行时抛NoSuchMethodError。排查手段是跑mvn dependency:tree把冲突列出来重点看omitted for conflict的条目。处理原则是先统一到高版本跑一遍全量回归如果高版本有破坏性变更就在业务模块里做适配层不要试图用exclusion硬排除那样埋的雷更深。提示合并前跑一次全量依赖冲突扫描把结果作为合并 checklist 的一行能省掉上线后 80% 的诡异报错。5.4 链路追踪的跨度变化合并后原来跨服务的几个 span 变成了一个进程内的调用链路图上看起来变短了但这不代表问题变少了。原来能通过 span 的耗时定位到具体服务现在都在一个进程里需要靠方法级埋点或者模块标签来区分。我的做法是在跨模块的 contract 接口实现上加一层统一的埋点切面方法名带上模块前缀这样链路里依然能看到订单调库存这一段只是父子关系从跨进程变成了同进程。6. 合并之后怎么守住边界什么时候再拆回去合并完成不等于结束最怕的是一年后又变成一团乱麻。防守的手段有两个工具约束和定期评审。6.1 用架构测试守住模块边界光靠代码评审守不住边界人总会有就这一次的侥幸。我用的办法是架构测试把规则写成可执行的断言构建时自动跑AnalyzeClasses(packages com.example.merged) public class ModuleBoundaryTest { ArchTest static final ArchRule order_must_not_touch_inventory_repo noClasses() .that().resideInAPackage(..module.order..) .should().dependOnClassesThat() .resideInAPackage(..module.inventory.repository..); ArchTest static final ArchRule contract_must_not_depend_on_modules noClasses() .that().resideInAPackage(..module.contract..) .should().dependOnClassesThat() .resideInAnyPackage(..module.order.., ..module.inventory..); }这类测试的价值在于说清楚什么是不允许的新来的同学跑一次构建就知道边界在哪比看文档有效得多。除了架构测试还有几条团队约定我觉得必须写下来跨模块只能通过 contract 里的接口通信不能直接注入对方的 Service每个模块的数据表只能由本模块的 Repository 访问其他模块要用数据必须走接口跨模块调用必须有明确的超时和降级策略不能无限等待。6.2 重新拆分的触发条件合并不是终局。业务长大了该拆还得拆。我给自己定的触发条件是这几个单个模块的资源消耗占到进程总消耗的 60% 以上说明它需要独立伸缩了。单个模块的发布频率远高于其他模块比如一天发五次而其他模块一周一次这时候合并反而拖慢了它。某个模块出现了需要独立的技术栈比如要跑在 GPU 上或者需要特定运行时。某个模块的故障已经三次以上影响到其他模块且舱壁隔离没能解决。这几种情况下拆回去的成本比继续硬扛低。但拆之前一定要确认一件事当初合并时删掉的那些分布式基础设施比如服务发现、远程调用框架、分布式事务现在是不是还能快速补回来。如果当时的合并做得比较克制保留了 contract 接口和独立的模块边界拆的时候只需要把本地实现换回远程客户端工作量是可控的。我在实际操作中的体会是架构的粒度和团队的协作方式必须匹配服务拆得再漂亮如果团队没有对应的沟通结构支撑最后都会退化成分布式的单体。合并这个动作本质上不是在否定微服务而是在给过度拆分做一次纠偏。做完之后最明显的感受是发布从要协调三个组排期变成了谁改谁发排障从翻四个日志索引拼时间线变成了看一份日志往下翻这两件事省下来的时间比任何性能优化都值钱。最后分享一个我一直在用的小技巧合并之后给每个模块在配置文件里加一个module.enabled开关默认全开出问题时可以单独关掉某个模块的自动装配。这在灰度期特别有用某个模块有个隐藏 bug 时关掉它比回滚整个进程快得多影响面也小得多。这个开关本身只有几行代码但它把整体回滚这个重操作变成了局部降级这个轻操作。