把单体换到分布式系统时踩过的坑

拆成五个微服务之后,下单接口偶发「扣了库存没生成订单」——链路一查,超时重试把同一笔请求打了两次。单体时代四十分钟部署的痛,换了一种更隐蔽的形式回来。

我们有一个 Spring Boot 单体,订单、库存、支付、用户、促销全塞在一个 WAR 里,MySQL 一张库走天下。部署一次四十分钟,回滚更痛苦——回滚不是 git revert 就完事,得等镜像重新拉、数据库迁移脚本能不能逆、缓存要不要清。团队人数上到二十多人之后,改一行促销规则经常和别人的支付改动撞在同一个 PR 里。

讨论拆微服务时,大家想的大多是独立发布技术选型自由。真正动手拆完五个服务、上线跑了一阵,日常运维的主战场变成了:服务发现配错、链路追不到、跨库事务补不齐、超时重试搞出重复单。这篇不复述 SOA 史和康威定律全文,只记这次迁移里真正疼过的点,并把几个总问题说清楚:分布式拆分是什么、当初为什么要拆、拆完得到什么、什么业务值得拆、会带回什么坑、我们最后怎么定边界。

分布式拆分是什么

一句话:把原来一个进程里用本地方法调用的模块,拆成多个独立部署的服务,靠网络通信协作。

和「模块化单体」不是一回事——模块还在同一个进程里,崩溃、部署、扩缩容仍然绑在一起。拆到分布式之后,网络、时钟、部分失败都变成日常假设,不能再假装「调用 = 本地函数」。

单体(我们拆之前)拆成微服务之后
调用@Autowired 本地 BeanHTTP / gRPC / MQ
事务@Transactional 一把梭跨服务只有最终一致,得自己设计补偿
部署全量四十分鐘单服务三五分钟,但集成验证变长
排障一个堆栈请求穿五六个 Pod,得靠 traceId

微服务是分布式拆分里粒度偏细、强调独立部署的一种做法;不是唯一做法,也不是默认最优。

它解决什么问题

回到我们拆之前的四类摩擦:

1. 部署耦合

改促销文案和改支付回调绑在同一次发布里。一次上线动到三十多个模块,测试回归全跑,发布窗口只能挤在晚上。

2. 扩展粒度粗

大促只有库存和下单接口 CPU 飙高,但得把整个 WAR 横向扩,DB 连接池、内存跟着一起涨,钱花在不会热的路径上。

3. 团队协作冲突

二十多人共用一个仓库,Git 冲突、互相等合并、集成环境被谁占着测,成了日常消耗。

4. 技术栈锁定

支付想试 gRPC + 独立运行时,库存想 Stay on Java 8——在单体里都是幻想,全库 lombok 版本都得对齐。

拆服务的直接目标不是「架构先进」,而是把变更面、扩缩面、责任面切开。如果单体里这四类问题都不突出,拆完只会更痛。

拆完有什么优势

对我们这次有效、且能感知的:

独立发布。 促销规则改完,只发 promotion-service,回归范围从「全站」缩到「下单链路 + 促销计算」。

按热点扩缩。 大促前只把 order-serviceinventory-service 的副本数拉上去,用户中心和后台报表不动。

故障隔离(有限)。 报表服务 OOM 不会拖垮下单——前提是链路里没有同步硬依赖;我们后面踩过这个坑。

技术异构有空间。 支付对接渠道 SDK 单独一个服务,升级 JDK 不用等全站。

# 拆之前:改一行也要等整个 WAR
mvn clean package && kubectl rollout status deployment/monolith   # ~40min

# 拆之后:库存 hotfix
kubectl rollout status deployment/inventory-service              # ~4min

代价后面单列;这里只记确实兑现了的部分。

哪些业务场景值得拆

我们事后画了一张「拆不拆」分档,比「要不要上微服务」的二元问法好用:

很适合拆

  • 多团队长期共维护一个大单体,发布窗口和冲突成本已经量化得出来
  • 流量热点模块明确(下单、库存、网关),整体扩容浪费明显
  • 部分子域变更频率远高于其他(促销、支付渠道),需要不同发布节奏
  • 合规或隔离要求硬边界(支付 PCI、用户隐私)——物理拆分有时比逻辑拆分省事

可以拆,但别一口气拆完

  • 业务还在快速试错,边界每周变——先模块化单体 + 清晰包边界,比拆错服务便宜
  • 日订单量万级以下、团队十人以内——我们当时有点急,事后看再晚半年拆也来得及

谨慎拆

  • 强一致事务是核心卖点(账务、库存金融级对账)——Saga / 最终一致的设计和测试成本会反超单体事务
  • 团队没有容器编排和可观测性基础——先补 K8s + 日志 + 追踪,再动刀

别硬拆

  • CRUD 后台、内部工具、生命周期不到一年的项目
  • 「微服务是趋势」——没有具体痛点指标,拆完就是分布式单体 + 更贵的运维

它会带来什么问题

这次迁移里真实付过学费的,比文档里的「CAP 定理」具体:

部分失败。 库存扣成功了,订单服务超时——用户看到失败,货已经少了。单体里同一个事务里回滚;分布式里没有免费午餐。

调试难度跳级。 没有 traceId 之前,五个服务的日志各说各话,对不上是不是同一笔请求。

运维面爆炸。 五个服务 × 三个环境 × 健康检查、配置、证书、限流——原来一个 WAR 的配置文件,变成五个仓库加一套 Consul。

数据一致性。 共享库还能假装是单体;一库一服务之后,JOIN 没了,报表要么冗余要么异步同步。

重试与幂等。 客户端、网关、服务间三层都可能重试,不做幂等就会重复下单、重复扣款——这是我们线上第一个 P1。

组织成本。 康威定律不是 PPT 装饰:服务边界和团队边界不对齐,跨服务需求比单体里改接口还慢。

我们怎么权衡

定了几条能执行的规则,不是「视情况而定」:

graph TD A[要不要拆出一个新服务?] --> B{有没有独立发布/扩缩/隔离的硬需求?} B -->|没有| C[留在单体模块里<br/>先把包边界划清] B -->|有| D{数据能否独立成库<br/>且团队能全职维护?} D -->|不能| E[先绞杀者模式抽逻辑<br/>库可以暂时共享只读] D -->|能| F[拆服务 + 独立库<br/>同步改监控和 SLA] F --> G{跨服务是否必须强一致?} G -->|是| H[重新评估:也许不该拆<br/>或缩小事务边界] G -->|否| I[Saga / 事件驱动 + 幂等键<br/>接受最终一致]

1. 先绞杀者,再动库。 新能力走新服务,老数据通过 Facade 路由;数据库拆分比代码拆分晚一步,避免「服务拆了库还缠在一起」的假分布式。

2. 一服务一主库,禁止跨服务写同一张表。 共享库阶段可以只读调用,写必须归一个 owner。

3. 同步链路过三层就警惕。 下单 → 库存 → 促销 → 支付,任何一环超时都要想熔断和降级,不能默认「总会成功」。

4. 幂等键先行。 所有写接口带 Idempotency-Key,重试策略写进规范,比事后补工单便宜。

5. 可观测性和拆分同步上线。 traceId、统一 access log 格式、RED 指标——缺这套不批准新服务进生产。

6. 服务数量设上限。 我们暂定「核心域不超过八个可独立部署单元」,再细的先合在 BFF 或域服务里。

这次真正踩过的坑

坑 1:分布式单体——服务拆了,库没拆

第一版拆完五个服务,还在写同一张 orders。团队各自发版,DDL 迁移脚本的执行顺序打架;库存服务直接 UPDATE orders 补状态,订单服务的领域逻辑被绕过。

症状:发布独立了,耦合一点没少,还多了网络调用。排查时既要看服务间 HTTP,又要猜谁最后写了库。

改法: 按限界上下文拆库——inventory 只拥有 stock / stock_log,订单侧冗余 sku_id + qty 快照;跨域只走 API 或事件,禁止跨库写。

坑 2:跨服务「伪事务」——以为 2PC 能救

支付要同时改订单状态和调渠道,第一版在订单服务里开本地事务,再同步调支付服务,支付失败就回滚订单——看起来对,支付超时的时候订单已经 COMMIT 了

试过 Seata AT 模式,锁持有时间和网络抖动一对线就拖垮 TPS;而且运维要多维护一个 TC 集群。

改法: 下单链路改成 Saga——创建订单(待支付)→ 调支付 → 成功则确认 / 失败则关单并释放库存。每一步可补偿,接受短窗口内的中间态。补偿逻辑写进代码 review 清单,不能「理论上会自动回滚」。

sequenceDiagram participant O as order-service participant I as inventory-service participant P as payment-service O->>O: CREATE order PENDING O->>I: reserve stock I-->>O: OK O->>P: charge P--xO: timeout Note over O: 客户端重试 → 若无幂等键<br/>可能重复 reserve O->>I: release stock (compensate)

坑 3:超时重试 → 重复下单(第一个 P1)

网关对 POST /orders 配了 3 秒超时 + 2 次重试。订单服务其实收到了请求、库存也扣了,只是回包慢。重试打进来第二遍,没有幂等键,又建一单。

改法:

  • 客户端和网关对写操作不重试,或只对明确幂等的 GET 重试
  • 订单创建带 Idempotency-Key,DB 唯一索引 (user_id, idempotency_key)
  • 服务间重试必须带同一 key,库存 reserve 做成幂等

坑 4:Consul 健康检查配错——实例活着,流量不进

inventory-service 注册到 Consul,健康检查 URL 写成了 http://localhost:8080/actuator/health,在 K8s 里探针偶尔失败,Consul 把全部实例标成 unhealthy,网关侧列表为空,下单全 503。

更阴间的是:Pod 其实还在跑,只是注册中心认为它死了,和 K8s 自己的 readiness 两套标准打架。

改法: 健康检查跟 K8s readiness 对齐,用 Pod IP + 正确端口;Consul 只做服务发现时,考虑直接用 K8s Service + Ingress,少一层状态源。

坑 5:没有分布式追踪——五个服务五个故事

早期日志只有 requestId 在网关生成,下游没传。用户报「支付成功订单仍待支付」,五个服务各查一段,对是不是同一笔请求靠猜

改法: 统一 traceId(W3C traceparent),网关注入,Feign / RestTemplate 拦截器透传;Jaeger 里按 trace 看完整链路。这条上线后,排查时间从「半天」降到「十几二十分钟」——是我们觉得最值的基础设施投入之一。

坑 6:拆太细——五个变九个,运维先崩

中间又拆出 notificationauditsearch 三个小服务,每个日调用量不大,但 CI/CD、证书、告警规则各一套。On-call 同学记不住谁依赖谁。

改法: 把 notification 和 audit 合回订单域里的异步 worker(同仓库不同进程),search 走只读从库 + ES 同步,不单独占一个「微服务名额」。

我们当时的栈和顺序(供对照)

环境不复杂,贴出来方便你对照自家栈:

  • 单体遗留:Spring Boot 2.x + MySQL 5.7,WAR 丢 Tomcat,后来迁 K8s
  • 拆分后通信:域内 REST(Feign),支付回调和库存变更走 Kafka
  • 注册发现:Consul(后期部分域改 K8s Service)
  • 追踪:Jaeger + 统一 JSON 日志进 Loki
  • 迁移顺序:订单读模型 → 库存 → 支付 → 促销 → 最后动用户中心;每步用绞杀者 Facade 切流量,灰度按租户

收个尾

分布式拆分是什么?独立部署的服务通过网络协作,接受部分失败和网络不确定。 我们拆,是因为单体发布、扩缩和协作成本已经量化了;拆完确实换来了独立发布和热点扩容,但也换回了一致性、幂等、追踪和运维面一整套新功课。

什么业务值得拆?多团队、高变更频率、热点明确、边界能划清时值得;边界还在晃、团队小、强一致是命根子的,先别跟风。

怎么权衡?我们的底线是:绞杀者先行、一库一 owner、写接口幂等、同步链路过深就砍、可观测性和拆分一起上、服务数量设上限。

如果你现在正站在「要不要拆」的路口,可以先回答三个数:发布一次要多久、有多少次发布其实只动一个模块、线上故障有多少次需要跨模块日志才能定位。三个数都不难看,拆微服务可能还排不上优先级——把单体里的模块边界和事务边界理顺,往往比先买一张「微服务架构图」便宜。

我们那个四十分钟部署的单体,拆完没有变成「五个四分钟」就万事大吉;只是把 pain 从发布窗口,挪到了链路、数据和人的协作上。认清这一点再动手,坑会少一半。

版权声明: 本文首发于 指尖魔法屋-把单体换到分布式系统时踩过的坑https://blog.thinkmoon.cn/post/6-monolith-to-distributed-system-practice/) 转载或引用必须申明原指尖魔法屋来源及源地址!