把单体换到分布式系统时踩过的坑
拆成五个微服务之后,下单接口偶发「扣了库存没生成订单」——链路一查,超时重试把同一笔请求打了两次。单体时代四十分钟部署的痛,换了一种更隐蔽的形式回来。
我们有一个 Spring Boot 单体,订单、库存、支付、用户、促销全塞在一个 WAR 里,MySQL 一张库走天下。部署一次四十分钟,回滚更痛苦——回滚不是 git revert 就完事,得等镜像重新拉、数据库迁移脚本能不能逆、缓存要不要清。团队人数上到二十多人之后,改一行促销规则经常和别人的支付改动撞在同一个 PR 里。
讨论拆微服务时,大家想的大多是独立发布和技术选型自由。真正动手拆完五个服务、上线跑了一阵,日常运维的主战场变成了:服务发现配错、链路追不到、跨库事务补不齐、超时重试搞出重复单。这篇不复述 SOA 史和康威定律全文,只记这次迁移里真正疼过的点,并把几个总问题说清楚:分布式拆分是什么、当初为什么要拆、拆完得到什么、什么业务值得拆、会带回什么坑、我们最后怎么定边界。
分布式拆分是什么
一句话:把原来一个进程里用本地方法调用的模块,拆成多个独立部署的服务,靠网络通信协作。
和「模块化单体」不是一回事——模块还在同一个进程里,崩溃、部署、扩缩容仍然绑在一起。拆到分布式之后,网络、时钟、部分失败都变成日常假设,不能再假装「调用 = 本地函数」。
| 单体(我们拆之前) | 拆成微服务之后 | |
|---|---|---|
| 调用 | @Autowired 本地 Bean | HTTP / gRPC / MQ |
| 事务 | @Transactional 一把梭 | 跨服务只有最终一致,得自己设计补偿 |
| 部署 | 全量四十分鐘 | 单服务三五分钟,但集成验证变长 |
| 排障 | 一个堆栈 | 请求穿五六个 Pod,得靠 traceId |
微服务是分布式拆分里粒度偏细、强调独立部署的一种做法;不是唯一做法,也不是默认最优。
它解决什么问题
回到我们拆之前的四类摩擦:
1. 部署耦合
改促销文案和改支付回调绑在同一次发布里。一次上线动到三十多个模块,测试回归全跑,发布窗口只能挤在晚上。
2. 扩展粒度粗
大促只有库存和下单接口 CPU 飙高,但得把整个 WAR 横向扩,DB 连接池、内存跟着一起涨,钱花在不会热的路径上。
3. 团队协作冲突
二十多人共用一个仓库,Git 冲突、互相等合并、集成环境被谁占着测,成了日常消耗。
4. 技术栈锁定
支付想试 gRPC + 独立运行时,库存想 Stay on Java 8——在单体里都是幻想,全库 lombok 版本都得对齐。
拆服务的直接目标不是「架构先进」,而是把变更面、扩缩面、责任面切开。如果单体里这四类问题都不突出,拆完只会更痛。
拆完有什么优势
对我们这次有效、且能感知的:
独立发布。 促销规则改完,只发 promotion-service,回归范围从「全站」缩到「下单链路 + 促销计算」。
按热点扩缩。 大促前只把 order-service 和 inventory-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 装饰:服务边界和团队边界不对齐,跨服务需求比单体里改接口还慢。
我们怎么权衡
定了几条能执行的规则,不是「视情况而定」:
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 清单,不能「理论上会自动回滚」。
坑 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:拆太细——五个变九个,运维先崩
中间又拆出 notification、audit、search 三个小服务,每个日调用量不大,但 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/) 转载或引用必须申明原指尖魔法屋来源及源地址!
评论
使用 GitHub 账号登录后即可留言,支持 Markdown。