去年年底的一次版本发布,让我彻底改掉了"能拆就拆"的毛病。那天晚上一个跑了三年的电商单体应用要上线一个新促销规则,代码评审过了、测试也过了,结果打包发布时发现一个不起眼的依赖冲突,整个 WAR 起不来,回滚花了四十分钟——而这四十分钟里,下单接口全挂。事后复盘,团队里有人提了一句:"要是拆成微服务就好了,只挂促销那一个服务。"这话听着特别有道理,但我们花了两周把它拆完之后,问题反而更多了:链路变长、日志分散、一次跨服务写操作对不上账。
这段经历是我写这篇东西的直接原因。Java 微服务架构这个话题被讲得太多了,多到很多人以为"从单体到分布式"是一条单向的、越走越正确的路。实际干下来你会发现,它更像一次有明确前置条件、有明确代价、也有明确退路的搬迁工程。单体不是落后的代名词,分布式也不是免费的午餐。这篇内容会把这条路拆成几个真实的决策点:什么信号说明你该动了、第一刀切在哪里、骨架怎么搭、跨服务调用怎么不被拖垮、数据一致性怎么兜住、出了问题怎么在一堆服务里定位到那一行日志。适合已经有一定 Spring Boot 开发经验、正在犹豫要不要拆、或者已经拆了一半正在填坑的同学。
1. 单体为什么会被卡住:先分清症状再谈架构
我见过太多团队拆分微服务的理由只有一句"业务涨了,单体撑不住"。这句话在技术上几乎等于没说,因为它掩盖了真正的问题。一个 Spring Boot 单体能不能撑住业务,取决于并发量、代码组织度、发布频率、团队规模这四个变量,而不是取决于它是不是单体。所以在动刀之前,第一件事是把"疼"定位到具体器官上。
1.1 三类真实症状,只有一类必须靠拆分解决
第一类是发布耦合。十几个人的团队共用一个代码仓库、一个构建产物,任何人改一行代码都要走完整的回归测试,一个月只能发两次版。这种情况的根因是发布单元和业务边界不匹配,拆服务确实能治,但先做 Maven 多模块拆分、明确模块间依赖方向,往往能缓解七八成。我待过的一个团队先做了多模块治理,把发布频率从月级提到周级,拆分的事就往后推了半年。
第二类是资源与故障隔离。一个导出报表的慢查询把整个应用的线程池占满,导致主链路超时;或者图片处理模块吃光了堆内存,把订单服务一起带崩。这类问题的特征是"局部故障全局放大",是拆分收益最明确的一类。但要先排除一个更便宜的方案:把重活扔到独立部署的异步 worker 里,用消息队列解耦,成本可能是拆服务的十分之一。
第三类是组织规模。当开发人数超过二十,代码的认知负担和合并冲突本身就变成瓶颈,这时候需要的是"每个小组拥有独立的部署单元",也就是康威定律在起作用。这类情况下拆分的驱动因素不是技术,而是协作效率。
把症状对上号之后你会发现,真正"必须拆"的场景比想象中少。我一般会给团队画一张对照表,把决策摆到明面上讨论,而不是凭感觉。
| 症状 | 优先尝试的低成本方案 | 何时才升级到拆分 |
|---|---|---|
| 发布频繁冲突、回归成本高 | 多模块拆分 + 依赖约束 + 契约测试 | 模块边界稳定但仍无法独立发布 |
| 单个功能拖垮全局 | 异步化、独立 worker 部署、线程池隔离 | 该功能有独立的伸缩需求和 SLA |
| 数据库表被多业务乱改 | 表归属划分 + 只读视图 | 写入事务边界确实跨业务 |
| 团队人数膨胀、沟通成本高 | 代码所有权 + 模块负责人制 | 小组需要独立的发布节奏和技术栈 |
| 想"技术升级"才拆 | 不要拆 | 没有业务驱动就不拆 |
1.2 拆分成本要提前算清楚,别只算收益
拆分的成本不是"多写几个 pom",而是长期的运维复杂度。我在项目里通常会先把这几笔账列出来,让业务方也看见:
- 基础设施成本:注册中心、配置中心、网关、链路追踪、日志聚合、监控告警,这些组件至少要占掉一到两个人力的长期维护。
- 调试成本:一个请求跨五个服务,本地复现一个线上问题的难度是单体的数倍。没有链路追踪的话,排障时间会从十分钟变成两小时。
- 数据一致性成本:原来一个本地事务搞定的事,现在要考虑消息可靠投递、消费幂等、补偿任务。这部分是微服务里最容易出线上事故的地方。
- 测试成本:端到端测试需要一套能跑起来的完整环境,契约测试、消费者驱动的测试变成必需品。
我个人的经验阈值是:如果团队里没有人能专职负责基础设施和可观测性,那么拆分到超过五个服务就会开始失控。这不是理论,是踩出来的。
2. 第一刀切在哪里:按业务能力而不是技术层
新手最容易犯的错,是按照技术分层来拆:controller 一个服务、service 一个服务、dao 一个服务。这种拆法在纸面上很整齐,实际结果是每一次业务请求都要穿过三层网络,延迟翻三倍,任何一个字段改动都要三边联调。这本质上是把代码里的分层原封不动搬到了网络上,是最典型的"分布式单体"前兆。
2.1 从数据库事务边界反推服务边界
找边界的实操方法,我的习惯是打开数据库 ER 图,把"经常在同一个事务里被一起修改的表"圈成一组。这一组表基本就是一个服务的天然候选。因为在微服务里,一个服务应该独占自己的库,跨库事务是最贵的操作,所以"事务内聚"就是最好的边界信号。举个例子:订单表、订单明细表、订单状态流水表,永远在同一个事务里写,那它们就该属于订单服务;而库存表、库存流水表属于库存服务,两个服务之间通过"下单成功事件"来驱动扣减,而不是让订单服务直接 update 库存表。
这套方法有个前置动作必须先做:把现有的跨模块直接 join 全部梳理出来。我一般会写一段脚本扫一遍慢查询日志和代码里的 join 语句,列出所有的跨域关联,然后逐个判断——是改成服务内冗余字段,还是改成接口调用,还是改成事件同步。这一步做不细,拆完之后就会变成"每个接口都在调五个服务拼数据"。
2.2 服务粒度:宁可粗一点,也不要细碎
关于粒度,我的判断标准只有一条:一个服务应该对应一个能独立演进、独立发布、独立扩容的业务能力。如果两个模块永远一起改、一起发、一起扩容,那它们就不该拆开。反过来,如果一块逻辑有独立的伸缩曲线(比如秒杀校验的 QPS 是普通接口的五十倍),那即使业务上不大,也值得单独拎出来。
实践中我会用三个问题来校验粒度是否合适:
- 这个服务能不能在不改其他服务的前提下完成一次完整的业务功能上线?
- 这个服务的数据库表,是不是只被它自己写?
- 这个服务挂掉一小时,业务影响面是不是可控且可描述?
三个都答"是",粒度基本合适。如果第一个答"否",说明切得太碎;如果第三个答"影响全站",说明这个服务承担了过多职责,还要继续拆或者做降级设计。
2.3 拆分顺序:先剥外围,最后动核心交易
大拆分最忌讳一次性重构。我见过的成功案例都是渐进式的:第一步先把非核心的、只读的、旁路的模块剥出去,比如消息推送、文件上传、报表统计、字典配置。这类模块的特点是失败影响小、事务简单、调用方少,用它来跑通整套基础设施最合适——注册中心、配置中心、网关路由、链路追踪全部在这个阶段验证一遍,出问题的代价很低。
第二步剥业务关联度中等但写操作独立的模块,比如用户中心、商品中心。这一步要重点处理数据迁移和双写过渡。第三步才动核心交易链路,因为这部分的跨服务一致性问题最难。整个过程我建议按季度规划,而不是按周,因为每个阶段都需要一段并行运行期来验证。
有个细节很容易被忽略:拆出去的第一个服务,不要选最难的,也不要选最边缘到没人用的。选一个有真实流量、但故障影响可控的,这样基础设施的问题能暴露出来,团队也能积累信心。
3. 骨架三件套:注册发现、配置中心、网关
服务拆开之后,最先撞上的问题不是业务,而是"服务 A 怎么找到服务 B"。硬编码 IP 在容器化环境里必然失效,因为每次发布 IP 都变。这时候需要的是一套服务发现机制:每个服务启动时把自己注册到注册中心,调用方从注册中心拿到实时的实例列表。这一层还有第二个价值——它天然支持按实例权重做灰度、按健康状态自动摘除故障节点。
3.1 注册发现:客户端发现与服务端发现的取舍
主流方案分两派。一派是客户端发现,调用方自己拉取实例列表并在本地做负载均衡,Nacos、Consul 这类注册中心配 OpenFeign 就是这种模式,好处是少一跳、性能好,坏处是每个服务都要引入客户端依赖并维护缓存逻辑。另一派是服务端发现,由负载均衡器或网关统一转发,好处是客户端无感,坏处是多一跳网络。
我在 Java 技术栈里的默认选择是客户端发现,配置大致是这样:
spring: application: name: order-service cloud: nacos: discovery: server-addr: 127.0.0.1:8848 namespace: prod group: ORDER_GROUP ephemeral: true这里有几个参数值得展开说。namespace用来做环境隔离,测试和生产的实例绝不能注册到同一个命名空间,否则测试流量打到生产实例上的事故我见过不止一次。group用来做同环境内的业务分组隔离。ephemeral决定是临时实例还是持久实例,临时实例靠心跳保活,进程挂掉后会被自动摘除,绝大多数业务服务都该用临时实例;只有那种需要人工介入才下线的场景才用持久实例。
服务端还要有健康检查的兜底。注册中心的心跳只能证明进程活着,不能证明接口能用。所以我会额外暴露一个轻量健康检查接口,只检查关键依赖(数据库连接、缓存连接),不做复杂逻辑,避免健康检查本身成为性能瓶颈。
3.2 配置中心:把开关从代码里赶出去
单体时代的配置文件放在 jar 包里,改一个超时时间要重新打包发布,这个痛点在小团队里不明显,服务一多就变成了灾难:二十个服务,改一个公共参数要改二十次。配置中心解决的就是这个问题——配置外置、集中管理、支持热更新、支持按环境按集群差异化。
落地时最容易出问题的是热更新的作用域。不是所有配置都能热更新,比如数据库连接池的核心参数、线程池大小,改了之后需要重建对象才生效,直接刷新配置值是没用的。我的做法是在代码里给配置项分两类:一类是纯值型(超时时间、限流阈值、开关),用@RefreshScope支持热更新;另一类是结构性配置,明确标注"重启生效",避免运维误以为改完立刻有效。
@Data @Component @RefreshScope public class GatewaySwitchConfig { // 灰度开关,支持配置中心热更新 @Value("${biz.gray.enabled:false}") private boolean grayEnabled; // 调用下游的读超时,单位毫秒 @Value("${biz.timeout.read:800}") private int readTimeout; }另外一个必须养成的习惯:配置要有默认值。配置中心挂掉或者网络抖动时,服务启动不能因为拿不到配置直接崩。所有@Value都写上:默认值,这是最便宜的高可用手段。
3.3 网关的职责边界,别把它做成万能胶
网关的定位应该是"南北向流量的统一入口",职责包括路由转发、鉴权、限流、协议转换、灰度分流。它不该承担的是:业务逻辑编排、多服务聚合返回值、复杂的字段裁剪。我见过把网关写成"超级 BFF"的项目,最后网关的代码量超过所有业务服务之和,任何一个业务改动都要动网关,网关成了新的单体。
路由配置上有个细节值得说:默认情况下网关的线程模型是异步非阻塞的,但如果你在过滤器里写了阻塞调用(比如同步查数据库做鉴权),整个网关的吞吐会断崖式下跌。所以鉴权信息要么放缓存、要么走响应式客户端。这一点在压测时特别容易被发现:单机 QPS 从两万掉到八百,通常就是过滤器里藏了一个同步 IO。
4. 跨服务调用:超时、重试、熔断的次序不能乱
服务拆开之后,一次用户请求会串起三到五个服务,任何一个环节变慢,用户感知到的延迟就是所有环节之和。更麻烦的是,某个下游变慢会占住上游的线程,上游的线程被占满后又会影响它的上游,这就是雪崩。我处理这类问题的顺序是固定的:先把超时设对,再考虑重试,最后才是熔断和隔离。顺序反了,效果会互相抵消。
4.1 超时是底线,而且必须逐层递减
超时的基本原则是下游超时必须小于上游超时。假设网关超时 3000ms,订单服务调用库存服务的超时是 2000ms,库存服务调用计价服务是 1000ms,这样任何一层出问题,调用方都能在自己的超时窗口内拿到明确的失败结果,而不是无限等待。如果反过来了——下游超时比上游还长——上游早就断开了,下游还在傻等,线程被白白占用。
feign: client: config: default: connectTimeout: 300 readTimeout: 800 loggerLevel: basic连接超时设得比读超时短很多,是因为连接失败通常是网络或实例已经挂了,快速失败比等待更有价值;读超时则要覆盖正常业务的 P99 耗时。这两个值的设定不能拍脑袋,得看监控里的实际分位数。我的习惯是取 P99 的 1.5 倍作为读超时,同时保证这个值小于上游超时的二分之一。
4.2 重试是把双刃剑,只对幂等操作开
很多人把重试当成提可靠的万能药,结果在大促时制造了重试风暴:下游已经过载响应变慢,上游还在不断重试,请求量翻倍,下游彻底压垮。我的规则很明确:只对幂等的读接口开启重试,写接口一律不重试,重试次数最多一次,并且必须配合退避和抖动。
幂等读的重试配置可以这样写:
@Bean public Retryer feignRetryer() { // 初始间隔 100ms,最大间隔 500ms,最多重试 1 次 return new Retryer.Default(100, 500, 2); }这里第三个参数是"最大尝试次数",设为 2 表示首次失败后重试一次。写接口如果需要最终可靠,正确的做法不是重试,而是本地消息表加补偿任务,这个话题在第 5 节展开。
4.3 一次雪崩的完整时间线复盘
我把去年遇到的一次典型故障按时间线记下来,因为它几乎踩全了所有坑:
| 时间点 | 发生了什么 | 根因 |
|---|---|---|
| T+0s | 商品详情接口 P99 从 80ms 涨到 1200ms | 数据库慢查询,索引失效 |
| T+15s | 订单服务调用商品服务超时,线程池开始堆积 | 订单服务读超时设的是 5000ms,太宽松 |
| T+40s | 订单服务线程池打满,下单接口全线超时 | 没有线程隔离,慢调用占满共享池 |
| T+60s | 网关重试配置生效,下单请求量翻倍 | 对非幂等写接口开了自动重试 |
| T+180s | 数据库连接数打满,商品服务彻底不可用 | 缺少熔断,流量持续压入 |
事后改了三件事:把订单服务调用商品服务的读超时从 5000ms 降到 600ms;给商品调用单独配一个线程池做舱壁隔离;去掉网关层对所有接口的自动重试,只保留 GET 的幂等重试。改完之后再压测,商品服务人为注入 5 秒延迟,下单接口的成功率仍然保持在 95% 以上,只是商品区块降级成了兜底文案。
熔断参数上我一般这样设:统计窗口 10 秒,最小请求数 20,慢调用比例阈值 50%,熔断时长 5 秒。最小请求数是关键,设成 1 的话,一个偶发慢请求就会触发熔断,误伤率极高。
5. 跨服务写操作:分布式事务里最容易踩的坑
这是微服务里事故密度最高的地方。原来在一个本地事务里搞定的"下单扣库存写流水",拆开之后变成了三个服务的三次写操作,中间任何一步失败都会导致数据对不上。我见过最严重的一次,订单创建成功但库存没扣,超卖了三百多件,全靠人工补单。
5.1 强一致两阶段提交为什么在多数业务里不划算
两阶段提交能保证强一致,代价是同步阻塞和性能急剧下降。在跨服务的场景里,它意味着所有参与者在一个事务期间都持有资源锁,只要有任何一个服务变慢,整个链路就被拖住。对电商、内容这类高并发业务来说,这个代价基本不可接受。
我的选型原则是:只有账户余额、资金这类绝对不能错的场景,才考虑 TCC 或者强一致方案;其余绝大多数业务用最终一致就够了。订单扣库存、积分发放、消息通知这类场景,允许几百毫秒甚至几秒的最终一致,用本地消息表或事务消息实现,性能和可靠性都更可控。
5.2 本地消息表加幂等消费的落地写法
这个方案的核心思路很朴素:把"业务写库"和"消息发送"放进同一个本地事务,靠数据库的原子性保证不丢消息,再靠定时任务把没发出去的消息补发,最后靠消费端的幂等保证不重复处理。
CREATE TABLE local_message ( id BIGINT PRIMARY KEY AUTO_INCREMENT, biz_type VARCHAR(32) NOT NULL COMMENT '业务类型', biz_id VARCHAR(64) NOT NULL COMMENT '业务主键,用于幂等', payload TEXT NOT NULL COMMENT '消息体', status TINYINT NOT NULL DEFAULT 0 COMMENT '0待发送 1已发送 2已消费', retry_count INT NOT NULL DEFAULT 0, next_retry_at DATETIME NOT NULL, created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, UNIQUE KEY uk_biz (biz_type, biz_id) ) ENGINE = InnoDB;写业务数据的同一个事务里插一条消息记录,事务提交后由定时任务扫描status = 0的记录投递出去。消费端收到消息后,先查自己的防重表,如果这个biz_id已经处理过就直接确认,没处理过就在本地事务里同时写业务数据和防重记录。
@Transactional(rollbackFor = Exception.class) public void consumeStockDeduct(StockDeductMessage msg) { // 1. 幂等校验,防重表唯一索引兜底 int inserted = dedupMapper.insertIgnore(msg.getBizId()); if (inserted == 0) { log.info("重复消息,直接忽略,bizId={}", msg.getBizId()); return; } // 2. 业务处理 int rows = stockMapper.deduct(msg.getSkuId(), msg.getCount()); if (rows == 0) { throw new BizException("库存不足,skuId=" + msg.getSkuId()); } }这里有个细节必须强调:幂等校验和业务写入必须在同一个本地事务里。如果先查防重表、再开另一个事务写业务,中间进程挂掉就会出现"防重记录有了但业务没做"的情况,消息被误判为重复而丢弃,这是最隐蔽的数据丢失。
5.3 补偿任务与监控指标怎么设计
本地消息表方案一定要配套补偿任务。扫描频率我一般设成 5 秒一次,只捞next_retry_at <= now()的记录,重试次数超过 8 次就转入死信状态并触发告警,由人工介入。为什么要分级?因为中间某几次可能是下游临时抖动,无限重试没有意义,但直接丢弃又可能丢真实数据。
监控上有三个指标必须盯着:待发送消息的堆积量、死信消息数量、消费失败率。堆积量持续上涨说明投递链路有问题;死信数量非零说明有业务逻辑异常,需要人看。这三个指标不上监控的话,一致性问题是静默的,往往等到财务对账才发现。
6. 可观测性:拆完之后如何定位一行日志
微服务带来的调试体验下降是实打实的。用户反馈"下单卡了一下",在单体时代你加个日志断点就能定位,拆开之后你需要在五个服务的上千条日志里找出属于这次请求的那几条。没有链路追踪,这个过程基本靠猜。
6.1 TraceId 贯穿全链路的三个断点
链路追踪的核心是给每个请求分配一个唯一标识,并在所有跨进程调用中透传。Java 技术栈里实现的关键是抓住三个容易断链的位置:
- HTTP 调用:通过 Feign 的
RequestInterceptor把 TraceId 塞进 header,下游用 Filter 读出来放进 MDC。 - 消息队列:发送时放到消息头,消费时取出并放回 MDC,否则消费链路和上游彻底断开。
- 线程池:这是最隐蔽的断点。MDC 是基于 ThreadLocal 的,任务提交到线程池后就丢了上下文,必须在提交任务时手动捕获并传递。
public class TraceMdcInterceptor implements RequestInterceptor { @Override public void apply(RequestTemplate template) { String traceId = MDC.get("traceId"); if (traceId != null) { template.header("X-Trace-Id", traceId); } } }线程池那一段,我的做法是包装一层TaskDecorator,在提交任务时把当前 MDC 快照存下来,执行时再恢复,执行完清理掉。这段代码很短,但不写的话,异步链路排查基本无解。
6.2 采样率与存储成本的平衡
全量采集链路数据的成本是很吓人的,高 QPS 场景下存储费用能超过服务器费用。所以采样率必须调。我的策略是分层采样:错误请求和慢请求 100% 采集,正常请求按 1% 到 5% 采样。这样既保证了问题请求一定能看到完整链路,又控制了存储量。同时给慢请求单独设阈值(比如超过 500ms 就强制采集),这比盲目全量采集实用得多。
日志聚合同样需要治理。服务一多,日志量会翻十倍,我一般要求:业务服务只打关键节点日志(请求入口、关键分支、异常、外部调用结果),中间过程用 debug 级别,生产环境默认关闭。日志格式统一成 JSON,把 traceId、服务名、接口名都作为字段,方便检索。
6.3 告警阈值怎么定才不吵
告警疲劳是微服务运维最大的隐形杀手。规则一堆,每天几百条通知,最后没人看。我的做法是只对四类情况告警:核心接口错误率超过 1%、核心接口 P99 超过阈值持续三分钟、消息堆积超过阈值、熔断器打开。其余的都做成看板,人主动去看,不推消息。
这里有个小技巧:阈值不要用绝对值,用相对基线。比如错误率的告警基线设为过去七天的同时段均值加上三个标准差,这样大促期间的整体水位上涨不会造成误报,而真正的异常尖峰仍然能触发。这一条改完之后,我们团队的无效告警下降了大概七成。
7. 上线方式与团队边界
服务拆完之后,发布节奏会变得频繁,一天发十几次是常态。这时候如果还是"全量替换",每次发布都是一次风险事件。灰度发布和蓝绿部署就变成了必需品。同时,服务的边界最终是由团队边界塑形的,这一点如果不提前想清楚,架构会随着组织变动反复摇摆。
7.1 灰度发布绕不开的数据库兼容
灰度发布的本质是同一个服务的两个版本同时在线,这就对数据库变更提出了约束:任何一次表结构变更,都必须保证新旧两个版本的代码都能正常工作。我总结出一套三步走的规矩。
第一步是扩展阶段:只加字段、加表、加索引,不改不删。新字段必须允许 NULL 或者有默认值,老代码不认识这个字段,写入时不受影响。
第二步是双写阶段:新代码写新字段,同时兼容读老字段,老代码继续写老字段。这个阶段要跑够时间,确保数据回填完成,并且有监控能对比两个字段的差异。
第三步才是清理阶段:等所有实例都升级完、灰度完全铺开、观察期过了之后,再删掉老字段。急不得,我见过灰度期间直接删字段导致未升级实例全部报错的事故,回滚都回不了。
| 阶段 | 数据库动作 | 代码状态 | 观察指标 |
|---|---|---|---|
| 扩展 | 加字段,允许 NULL | 旧版本无感知 | 写入延迟、锁等待 |
| 双写 | 新旧字段并存 | 新版本双写、读新兜底读旧 | 两字段差异率、回填进度 |
| 收敛 | 老字段置为废弃 | 全部读新字段 | 错误率、慢查询 |
| 清理 | 删除老字段 | 无引用 | 无 |
灰度分流的维度也有讲究。按用户 ID 取模分流最稳妥,同一个用户体验一致;按 header 分流适合内部测试;按比例随机分流最简单但体验会跳变。我在核心交易链路上一般用用户 ID 分流,比例从 1% 起,每十五分钟观察一次错误率和延迟,没问题再加倍。
7.2 团队边界决定服务边界
康威定律说系统的结构会趋同于组织的沟通结构,这在微服务里体现得极为直接。如果两个服务由同一个小组维护,它们迟早会因为"顺手"而互相调用、共享数据表,边界慢慢就模糊了。反过来,如果两个模块分属不同小组,即使技术上耦合,也会被强行拆开。
所以划定服务边界的时候,我会先问一句:这个服务未来由谁负责?如果找不到明确的负责人,说明要么这个服务太边缘(考虑合并),要么组织还没准备好(考虑推迟拆分)。一个服务有明确的所有者,才有明确的 SLA、明确的发布节奏、明确的故障责任人,架构才稳得住。
配套的还有接口契约的管理。我要求所有跨服务接口必须定义独立的 API 包,消费方只能依赖这个包,不能直接依赖提供方的实现模块。这个约束看起来很形式化,但它能在编译期就拦住大量不该有的耦合。接口变更走版本号,新增字段允许,删除和改语义必须升版本。
8. 分布式单体的识别与回退到模块化单体
这是我特别想讲的一节,因为几乎所有团队都会在拆分路上经历一次"拆完之后更糟"的阶段。如果这个阶段处理不好,就会一路加组件、加中间件,最后得到一个既没有单体效率、也没有微服务弹性的四不像。
8.1 分布式单体的五个典型特征
判断标准很具体,对上三个以上基本就能确诊:
- 服务之间同步强耦合:一次请求串行调用五个以上服务,任意一个慢全链路就慢,没有一个是可选的。
- 数据库混用:服务 A 直接读服务 B 的表,或者多个服务连同一个库。这说明边界根本没切开,只是把代码分到了不同进程。
- 发布必须成套:改一个功能要同时发布三四个服务,版本必须严格对齐,本质还是一体发布。
- 共享实体类:所有服务依赖同一个 domain 包,一个字段改动引发全量编译失败。
- 没有独立的数据所有权:说不清哪张表归谁,出问题无法定位责任人。
如果诊断出这些问题,方向不是继续拆,而是往回收敛。我经历过一次这样的收敛:把五个服务合并成两个,反而让发布频率和故障率都改善了。
8.2 什么时候应该收手做模块化单体
模块化单体是一个被严重低估的选项。它的形态是:单个部署单元,但内部用严格的模块划分和依赖约束,模块之间只通过明确的接口通信,每个模块有自己的表所有权。它的好处是把微服务的边界纪律保留下来,同时避免了分布式的一切开销。
在 Java 里落地模块化单体,最实用的手段是 Maven 多模块加依赖检查:把每个业务模块做成独立 module,用构建工具限制模块间只能依赖对方暴露的 api 模块,实现模块不可见。再加一层架构测试,比如用 ArchUnit 写规则禁止跨模块直接访问 dao 包,构建时自动检查,违规就失败。
@ArchTest public static final ArchRule order_should_not_access_stock_internal = noClasses().that().resideInAPackage("..order..") .should().accessClassesThat() .resideInAPackage("..stock.internal..");这段规则写在测试里,每次构建都会跑。它带来的约束效果,某种程度上比拆成两个服务更强,因为它是编译期强制的,不依赖人的自觉。
我的判断是:团队规模在十人以内、业务没有明确的独立伸缩需求、团队里没有专职的基础设施人力,优先做模块化单体。等这三个条件中有两个被打破了,再启动真正的服务化。这条建议我给过好几个团队,回过头看,采纳的那些项目在半年内的线上事故数量都明显更低。
补充一个现实观察:从微服务回退到模块化单体的团队,往往比从单体走向微服务的团队更容易成功。因为他们已经经历过分布式的痛,对边界纪律有真实的敬畏,合并之后不会退化成大泥球。
最后分享两个我个人的习惯。第一个是每次拆分之前,先花半天时间把"这次拆分要解决的三个具体问题"写下来,贴在项目看板上,三个月后回头看,如果三个问题里解决了不到两个,就说明这次拆分方向有问题,该停下来复盘而不是继续推进。第二个是给每个新拆出来的服务写一份"故障影响说明书",明确写清它挂了会影响哪些功能、有没有降级方案、降级后的用户表现是什么。这份文档在真实故障发生时的价值,远超任何架构图。踩过几次坑之后我越来越确信,架构决策的质量不取决于用了多少组件,而取决于你是否清楚每个选择的代价,并且提前为这个代价做好了准备。