三年前我们把一个单体 Python 应用按"领域驱动设计"拆成了三十个微服务,以为能解决团队协作问题、独立部署、技术栈自由,结果两年后整个研发效率不升反降,故障率翻了三倍,新人入职两周还搞不清楚一个完整请求要经过哪些服务。半年前下定决心做反向重构,把三十个服务合并回了八个,故障率降到原来的四分之一,需求交付速度提升一倍。这篇把这次"反微服务"重构的完整决策过程、合并方法论、踩过的坑,完整写一遍。看完之后,你应该能对"什么时候该拆、什么时候该合"有更现实的判断,而不是盲目追逐架构潮流。
背景和初衷
三年前的项目是一个 SaaS 营销自动化平台,后端 Python + Django,前端 Vue,数据库 PostgreSQL,业务复杂度中等,日活用户大约五万。当时团队大约二十人,正好赶上"微服务"概念火爆,公司技术评审会上一致决定按 DDD 拆分。我们花了大半年时间,把单体应用拆成了用户服务、订单服务、活动服务、模板服务、推送服务、统计服务等等一共三十个微服务,每个服务一个 git repo、一个数据库、一套独立的 CI/CD。
| 阶段 | 服务数 | 开发人数 | 故障率(月) | 需求交付周期 |
|---|---|---|---|---|
| 单体时代 | 1 | 20 | 2 次 | 3 天 |
| 拆分初期 | 15 | 25 | 5 次 | 5 天 |
| 拆分巅峰 | 30 | 35 | 12 次 | 10 天 |
| 合并中 | 18 | 30 | 6 次 | 5 天 |
| 合并后 | 8 | 22 | 3 次 | 4 天 |
过度拆分的代价
拆分的初衷是"独立部署、降低耦合",但实际跑了两年之后,发现这些好处大多没兑现,反而引入了大量新问题。第一个问题是跨服务调用爆炸。一个完整的"发送营销邮件"请求,需要经过用户服务查权限、订单服务查套餐、活动服务取活动信息、模板服务渲染、推送服务发送、统计服务记录,六个服务的串行调用,任何一个慢都会拖垮整体。链路追踪图复杂得像蜘蛛网,debug 时基本靠猜。
第二个问题是数据一致性失控。拆分前一个事务能搞定的事,拆分后变成了跨服务的分布式事务。我们尝试过 Saga 模式、TCC、消息事件最终一致性,每种方案都引入新的复杂度,而且实际跑起来问题不断。"为什么订单已经创建了但活动统计没加一"这种问题排查起来巨痛苦,经常涉及好几个团队。
第三个问题是团队认知负担。新人入职后,光是搞清楚三十个服务各自负责什么、怎么交互,就要两三周。文档永远跟不上代码,review 一个跨服务的需求需要协调三四个团队,沟通成本爆炸。资深员工要花大量时间给新人讲架构,实际产出反而降低。
第四个问题是基础设施成本。三十个服务意味着三十套 CI/CD 管道、三十套监控、三十套日志收集、三十个数据库实例。光是 K8s 集群的 pod 数量就上千,基础设施成本翻了三倍。还有大量的同步问题:某个公共依赖升级,要在三十个 repo 里改三十次,稍有不慎就漏掉一两个。
反思:拆分应该解决什么问题
反思这两年,我们发现当初拆分的"理由"几乎都站不住脚。独立部署?实际上很多需求都跨服务,一次发布需要协调多个服务同时上线,根本不"独立"。技术栈自由?团队就那么几个人,根本没必要每个服务用不同语言,统一 Python 反而效率最高。容错隔离?调用链上任何一个服务挂了整条链路都挂,根本"隔离不了"。扩展灵活?实际瓶颈在数据库,服务拆得再细,数据库还是那个数据库,扩展能力没增强。
真正应该拆分的场景是这样的:不同部分的负载差异巨大(比如图片处理服务比业务逻辑服务的资源消耗大十倍)、不同部分的发布节奏完全不同(比如核心交易和实验性新功能)、不同部分需要不同的技术栈(比如机器学习模型和业务逻辑)、团队规模大到沟通成本超过 API 协调成本(比如几百人的大团队)。我们当时是二十人的小团队,业务负载均匀,技术栈统一,根本不符合拆分的前提。
合并方法论:逆向重构
下定决心合并之后,核心挑战是怎么在不停业务的情况下,把三十个服务合并回八个。我们的方法论是"逆向 strangler pattern":在新的合并服务里逐步实现各个旧服务的功能,通过 API 网关切流,旧服务等到流量归零再下线。这是个慢功夫,我们花了五个月,但整个过程业务零中断。
# 合并阶段的网关路由
# 第一阶段: 旧服务接收 100% 流量
# 第二阶段: 新合并服务接收 10%, 比对结果
# 第三阶段: 50/50
# 第四阶段: 新服务 100%, 旧服务保留观察
# 第五阶段: 旧服务下线
from fastapi import FastAPI, Request
import httpx
app = FastAPI()
NEW_SERVICE_RATIO = 0.1 # 灰度比例
@app.api_route("/api/user/{path:path}", methods=["GET","POST","PUT","DELETE"])
async def user_route(path: str, req: Request):
import random
body = await req.body()
headers = dict(req.headers)
if random.random() < NEW_SERVICE_RATIO:
target = f"http://new-merged-service/user/{path}"
else:
target = f"http://old-user-service/{path}"
async with httpx.AsyncClient() as client:
resp = await client.request(req.method, target, content=body, headers=headers)
return resp.content
灰度过程中最关键的是结果比对。我们写了一个 shadow traffic 工具,把请求同时发给新旧服务,异步比对返回结果,任何不一致都告警。这样可以在大流量切过去之前,先把潜在 bug 暴露出来。比对工具帮我们发现了几十个迁移过程中的细节问题,避免了上线事故。
合并后的服务结构
| 合并后服务 | 整合的旧服务 | 定位 |
|---|---|---|
| core-api | 用户/权限/订单/账单 | 核心业务,事务密集 |
| marketing | 活动/模板/受众/投放 | 营销主流程 |
| delivery | 邮件推送/短信推送/微信推送 | 消息发送通道 |
| analytics | 统计/报表/导出/dashboard | 数据分析 |
| integration | 第三方对接(CRM/广告平台) | 对外集成 |
| worker | 异步任务/定时任务 | 后台计算 |
| admin | 后台管理/运营工具 | 内部管理 |
| gateway | API 网关/认证/限流 | 流量入口 |
合并的依据是业务边界而不是"DDD 子域"。我们发现 DDD 在大型企业系统里很有用,但在中小型 SaaS 里经常拆得过细。合并后的八个服务每个都对应一个清晰的业务领域,内部的代码再用模块化组织,既保持了领域边界,又避免了跨服务调用的开销。
shadow traffic 比对工具的实现细节
shadow traffic 是合并过程中最关键的安全网,它把同一个请求同时发给新旧服务,异步比对返回结果,任何不一致都告警。这种"双发比对"的好处是在大流量切过去之前,先把潜在 bug 暴露出来。我们写的比对工具花了一周时间打磨,核心是处理"看起来不一样但语义相同"的场景,比如时间戳精度差异、字段顺序差异、空字符串和 null 的差异等等。
# shadow traffic 比对工具的核心逻辑
import asyncio
import httpx
import json
from deepdiff import DeepDiff
async def shadow_compare(request_payload, old_url, new_url):
"""同时调用新旧服务并比对结果"""
async with httpx.AsyncClient(timeout=30) as client:
old_task = client.post(old_url, json=request_payload)
new_task = client.post(new_url, json=request_payload)
old_resp, new_resp = await asyncio.gather(
old_task, new_task, return_exceptions=True
)
if isinstance(old_resp, Exception) or isinstance(new_resp, Exception):
log_error("shadow_call_failed", old=str(old_resp), new=str(new_resp))
return
old_data = normalize_response(old_resp.json())
new_data = normalize_response(new_resp.json())
diff = DeepDiff(old_data, new_data, ignore_order=True,
exclude_paths=["root['timestamp']", "root['request_id']"])
if diff:
log_diff("shadow_diff_detected", path=request_payload.get("path"),
diff=diff.to_dict())
def normalize_response(data):
"""规范化响应, 去除已知的合理差异"""
if isinstance(data, dict):
return {k: normalize_response(v) for k, v in data.items()
if k not in ("server_time", "trace_id")}
if isinstance(data, list):
return [normalize_response(x) for x in data]
if data == "":
return None
return data
比对工具最棘手的部分是定义"什么算不一致"。我们最初的版本太严格,把所有细微差异都报告,告警噪音爆炸,工程师根本看不过来。后来加了"规范化"和"忽略路径"两层过滤,告警量降到原来的十分之一,真正有问题的告警才能被注意到。这种"信号噪声比"的优化对告警系统至关重要,告警太多等于没有告警。
合并过程中踩的坑
第一个坑是数据库合并的难度被低估。三十个服务有三十个数据库,合并的时候要决定哪些表合到同一个库、表结构如何统一、数据怎么迁移。我们最初打算合成一个大库,后来发现性能扛不住,改成按服务分库,但同服务内的表合到一起。这种"中庸"的方案最终落地,虽然不是理论最优,但实操可行。
-- 数据迁移示例: 合并用户服务和权限服务的库
-- 步骤 1: 在新库里建表结构
CREATE TABLE users (...);
CREATE TABLE permissions (...);
CREATE TABLE user_permissions (...);
-- 步骤 2: 用 logical replication 同步旧数据
-- (PostgreSQL 的 pglogical 或 native logical replication)
CREATE PUBLICATION old_user_pub FOR TABLE users;
CREATE SUBSCRIPTION new_db_sub CONNECTION '...' PUBLICATION old_user_pub;
-- 步骤 3: 校验数据一致性
SELECT COUNT(*) FROM users; -- 新旧库比对
-- 步骤 4: 切换应用读写到新库
-- 步骤 5: 旧库保留一周作为回滚保险, 然后下线
第二个坑是API 兼容性。旧服务的 API 路径、参数、返回格式各不相同,合并后要保持向后兼容,否则所有调用方都要改。我们的方法是新服务暴露旧 API 的兼容层,内部转发到统一的新 API。这样调用方可以慢慢迁移,不会因为合并而被迫一起改。这种"两套 API 并存"的过渡期持续了大约三个月,期间没出现因 API 不兼容引发的事故。
第三个坑是团队组织调整。原来三十个服务各有 owner,合并后变成八个,团队组织也要跟着调整。这一步比技术合并还难,涉及人的归属、KPI、晋升通道等敏感问题。我们的做法是先把团队按八大业务域重新分组,每组有明确的服务 ownership,然后用半年时间让大家适应新的协作模式。过程中有几个资深员工不适应离开了,但留下的人整体战斗力反而提升了。
第四个坑是监控告警的迁移。三十个服务的监控规则、告警阈值都是基于原有架构的,合并后这些规则全部失效。我们花了一个月时间重建监控体系,按八个新服务重新定义 SLO,设置告警阈值。这一步如果省掉,会出现"合并后没人盯着新服务"的盲区,风险很大。
API 兼容层的具体实现
合并过程中最容易被低估的工作量就是API 兼容层。旧服务积累了几十种 API 路径和参数风格,调用方代码遍布前端、移动端、内部服务、第三方集成,任何不兼容都会引发连锁故障。我们的策略是在新服务里写一层"翻译适配器",对外保持旧 API 的形状,对内转发到统一的新 API。这种适配器代码量不小,但它让迁移彻底解耦,调用方可以按自己的节奏切换。
# API 兼容层示例: 旧 user 服务 -> 新 core-api 的 user 模块
from fastapi import APIRouter, HTTPException
from modules.users import service as user_service
legacy_router = APIRouter(prefix="/api/legacy/user", tags=["legacy"])
@legacy_router.get("/{user_id}")
async def legacy_get_user(user_id: str):
"""兼容旧版 user-service GET /user/{id}"""
user = await user_service.get_by_id(user_id)
if not user:
raise HTTPException(404, detail={"code": "USER_NOT_FOUND"})
# 旧 API 的返回结构与新 API 不同, 这里做字段映射
return {
"uid": user.id, # 新: id, 旧: uid
"name": user.display_name, # 新: display_name, 旧: name
"email": user.email,
"is_active": user.status == "active", # 新用枚举, 旧用布尔
"created_at": int(user.created_at.timestamp()), # 旧用 unix 时间戳
}
@legacy_router.post("/login")
async def legacy_login(payload: dict):
"""兼容旧版 login 接口的参数风格"""
username = payload.get("username") or payload.get("name")
password = payload.get("password") or payload.get("pwd")
if not username or not password:
return {"code": -1, "msg": "missing params"}
token = await user_service.login(username, password)
return {"code": 0, "msg": "ok", "data": {"token": token}}
兼容层有一个关键设计原则:新 API 是真相之源,兼容层只做翻译。这种单向依赖让兼容层可以随时删除,不会影响新 API 的演进。我们给每个兼容接口都标记了 deprecation 头,告诉调用方"这个接口将在某个时间后下线",并定期发邮件提醒,推动迁移。三个月后大部分调用方都迁移完了,我们才正式下线兼容层,整个过程业务零中断。
合并后的真实收益
合并完成上线半年之后,我们做了一次量化复盘。故障率从月均十二次降到三次,降幅七成五,主要是因为跨服务调用减少,链路简单了,出错概率自然下降。需求交付周期从十天降到四天,降幅六成,主要是因为大部分需求不再需要协调多个团队,一个团队就能完成。基础设施成本降了百分之四十,K8s pod 数量从一千多降到三百,数据库实例从三十个降到八个。
新人上手时间从三周降到一周,新人能更快理解整个系统的全貌,因为服务少了,关系简单了。跨团队沟通会议每周从十几个降到两三个,腾出来的时间都用在了实际编码上。这些数字让团队真切感受到"合并对了",当初的反对声音也都消失了。
| 指标 | 合并前(30 服务) | 合并后(8 服务) | 改善 |
|---|---|---|---|
| 月故障次数 | 12 | 3 | 降 75% |
| 需求交付周期 | 10 天 | 4 天 | 降 60% |
| K8s pod 数 | 1200 | 320 | 降 73% |
| 基础设施月成本 | 8 万 | 4.8 万 | 降 40% |
| 新人上手时间 | 3 周 | 1 周 | 降 67% |
| 跨团队会议/周 | 14 | 3 | 降 79% |
| 研发团队人数 | 35 | 22 | 降 37% |
什么时候真的需要微服务
不是所有项目都不该用微服务,而是大多数中小型项目用不上。真正适合微服务的场景有几个特征。团队规模超过 50 人,沟通成本已经成为瓶颈,需要通过架构隔离来降低协调成本。不同模块的扩展需求差异巨大,某些模块需要十倍于其他模块的资源,合在一起会浪费。不同模块的发布节奏完全不同,核心稳定模块和实验性模块的发布频率差几个数量级。不同模块需要不同的技术栈,比如机器学习模型用 Python,实时通信用 Go,业务逻辑用 Java。
反过来,以下情况不应该上微服务。团队规模小于 30 人,合在一起协作效率更高。业务变化频繁、边界不清,过早拆分会带来反复合并的痛苦。团队没有足够的运维和分布式系统经验,微服务的复杂度会压垮团队。性能瓶颈在数据库,服务拆得再细也解决不了数据库问题。判断这些条件不是凭直觉,而是看团队的真实数据和能力。
"模块化单体"的中间方案
合并之后我们的架构其实不是"单体",而是模块化单体(Modular Monolith)。每个合并后的服务内部按业务模块清晰组织,模块之间通过定义良好的接口交互,但都在同一个进程内运行,部署是一个二进制。这种架构既保持了模块化的开发组织,又避免了微服务的运行时复杂度。
# 模块化单体的代码组织
# core-api/
# ├── app.py # FastAPI 应用入口
# ├── modules/
# │ ├── users/ # 用户模块
# │ │ ├── api.py # 暴露给外部的 API
# │ │ ├── service.py # 业务逻辑
# │ │ ├── repo.py # 数据访问
# │ │ └── models.py # 数据模型
# │ ├── permissions/ # 权限模块
# │ ├── orders/ # 订单模块
# │ └── billing/ # 账单模块
# ├── shared/ # 跨模块共享
# │ ├── db.py
# │ ├── cache.py
# │ └── events.py # 内部事件总线
# └── config.py
# 模块间通过事件解耦
from shared.events import event_bus
# 订单模块发布事件
@event_bus.subscribe("order.created")
async def on_order_created(event):
# 账单模块订阅, 创建账单
await billing_service.create_invoice(event.order_id)
# 用户模块发布事件
async def create_order(user_id, items):
order = await order_repo.create(user_id, items)
await event_bus.publish("order.created", {"order_id": order.id})
return order
模块化单体的关键是模块边界清晰。每个模块有明确的对外接口,内部实现细节不暴露给其他模块。模块之间的调用要么走 API(直接调用对方的 service 层),要么走事件总线(松耦合)。这种约束让代码组织保持清晰,即使在同一个进程内,也能保持微服务级别的内聚和解耦。如果将来某个模块真的需要拆出去,这种边界清晰的代码改造起来不难。
团队协作模式的调整
合并后我们的团队协作模式也做了调整。原来每个服务一个 owner 团队,合并后变成每个合并服务一个团队,团队内部按模块再分小组。这种"大团队+小组"的结构,既保留了模块级别的快速迭代能力,又能在大需求面前形成合力,不需要跨团队协调。
具体的协作机制是,每个团队有自己的迭代节奏,周会同步本团队进展;跨团队的需求由产品 owner 协调,通过定义良好的 API 契约对接;季度规划时所有团队一起开,确保大方向一致。这套机制运行半年,团队协作效率明显提升,几乎没有因为"找不到对接人"而 block 的情况。
关于"康威定律"的体悟
康威定律说"系统架构反映组织结构",这次合并让我深刻理解了这句话的含义。当初拆三十个服务,本质上是想用架构来推动组织变得更"扁平、独立、敏捷",但实际上组织没那么容易改变,反而被复杂架构所拖累。合并回八个之后,组织结构也跟着精简,大家的注意力更集中,沟通成本更低,反而更敏捷了。
所以做架构决策时,要先看团队的真实状态:多少人、什么能力、什么文化、什么管理水平。架构应该跟随团队、服务团队,而不是"塑造团队"。"想用先进架构倒逼组织升级"在大多数情况下都是一厢情愿,组织变革需要时间和领导力,不是改几个 repo 就能完成的。
合并过程的几个反直觉发现
第一个反直觉发现是"独立部署"在大多数场景下被高估了。理论上微服务可以独立部署,实际上业务需求经常跨服务,一次发布需要协调多个服务,根本不"独立"。合并后虽然是一次部署所有功能,但因为不需要协调,反而更快更稳。统计下来,我们合并后的发布频率反而比拆分时高了,因为发布不再需要排队。
第二个反直觉发现是性能反而提升。理论上微服务通过水平扩展能获得更好的性能,实际上网络调用的开销远大于本地调用,合并后大量请求从跨服务的 RPC 变成进程内函数调用,延迟降了一半以上。数据库事务也从分布式事务回归到本地事务,可靠性和性能都提升了。
第三个反直觉发现是团队成员的满意度大幅提升。原来很多工程师抱怨"天天在追故障、协调跨团队、debug 分布式问题",合并后这些工作量大幅减少,大家有更多时间做"真正的开发"。员工满意度调研显示,合并后技术团队的满意度从六十分涨到八十分,离职率也明显下降。
给同行的几条建议
第一条建议是不要为了微服务而微服务。如果你的团队规模不大、业务变化频繁、运维能力有限,先做模块化单体,等真的有拆分需求再拆。微服务不是先进架构,只是一种适合特定场景的架构。盲目追潮流的代价非常大,我们这次合并就是补当年的课。
第二条建议是架构决策要可逆。任何架构决策都可能错,关键是错了能不能调整。拆分容易合并难,所以倾向于保守,先合后拆比先拆后合容易得多。如果实在拿不准,先做模块化单体保留拆分的可能性,需要的时候再拆出去,代价更小。
第三条建议是关注真实数据而不是理论优雅。架构是否好,看的是实际运行的数据:故障率、交付速度、成本、团队满意度。理论上再优雅的架构,如果实际运行数据糟糕,就是错的架构。我们当初拆分是被理论说服的,合并是被数据说服的,后者更可靠。
第四条建议是合并比拆分更需要勇气。在"微服务就是先进"的氛围下,提出合并会被认为是"开倒车"。我们当时在公司内部承受了不小压力,有人说"这是技术倒退"。但数据出来之后,反对声音消失了。能解决问题的方案就是好方案,这种朴素的工程观,比任何架构潮流都重要。
对比:其他公司的类似经历
其实不止我们一家做过这种"反微服务"重构。Amazon Prime Video 几年前发表过一篇文章,讲他们把一个微服务架构的视频质量分析系统合并回单体,延迟降了百分之九十,成本降了百分之九十。Uber 也曾经把一些过度拆分的小服务合并回去,简化运维。这些大厂的案例说明,合并不是"低端",而是对症下药的工程决策。
这些案例的共同点是:原本的拆分是出于"理论正确"而非"实际需要",合并之后实际指标全面改善。这印证了一个观点:架构的价值是解决问题,不是炫技。任何架构在合适的场景下都是好架构,在不合适的场景下就是坏架构。理解这一点,可以避免很多盲目的架构决策。
事故复盘的几点反思
第一点反思是技术决策容易被潮流绑架。当年决定拆三十个微服务时,几乎没有人质疑,因为"大家都在做微服务"。这种集体无意识非常危险,会让团队走上不适合自己的道路。后来我们在公司内部建了"架构决策记录(ADR)"机制,每个重大架构决策必须写下理由、权衡、预期收益,定期回顾,这样能减少潮流绑架的风险。
第二点反思是架构决策需要长期观察才能评估。一个架构是否合适,不是上线一两个月就能看出来的,需要一两年甚至更长的真实运行才能验证。我们这次拆分用了三年才看清楚问题,合并又用了半年。任何重大架构变更都要做好长期监控的准备,不要急着下结论。
第三点反思是不要害怕承认错误。拆分错了,合回去就是。在工程界"承认错误"是个不容易做到的事,因为牵涉到面子、绩效、晋升等等。但工程的本质是解决问题,不是证明谁对,能及时纠错的团队反而更受尊重。我们这次能成功合并,跟管理层支持"承认错误"的文化有很大关系,这是技术之外的重要因素。
未来的架构演进思路
合并完成之后,我们也在思考未来的架构演进。短期内不会再做大的拆分,继续打磨模块化单体的内部组织。中期看,如果某个模块真的出现了拆分的强信号(比如负载剧增、技术栈分化),会单独拆出去,但保持其他模块不动。长期看,架构演进应该是渐进的、按需的、可逆的,不再做"一刀切"的大改造。
另一个思路是引入function-as-a-service。某些低频、独立的功能(比如定时任务、Webhook 回调),可以用 serverless 函数来实现,既保持了主服务的简洁,又获得了按需扩展的能力。这种"混合架构"可能是未来更主流的方向,不是非黑即白的"全单体"或"全微服务",而是根据每个场景选最合适的形态。
总结
微服务不是先进,单体不是落后,合适的才是最好的。我们用三年时间把单体拆成三十个微服务,又用五个月合并回八个,过程中走了不少弯路,也学到了很多。最大的收获是架构决策要基于团队和业务的真实状态,不要被理论或潮流绑架。模块化单体在大多数中小型项目里都是合理选择,等真的有拆分需求再拆,代价更小,风险更低。
希望这篇能给正在考虑"拆不拆"的团队一些参考。如果你的项目还没拆,先想清楚拆分的真实理由是什么,以及拆完之后能不能维护好。如果你已经拆了但发现问题不少,不要怕"开倒车",合回去也是一种选择。技术工作的本质是解决问题,任何能解决问题的方案都值得考虑,不要让"先进"或"落后"的标签限制你的选择。架构是工具,服务的是业务和团队,而不是反过来。
最后想说的是,做了几年架构工作,越来越觉得谦逊是最重要的品质。承认自己当下的判断可能是错的,愿意根据新的数据调整方向,这种心态比任何技术能力都更宝贵。希望我们这次的经验能给同行一些启发,在面对架构决策时多一份审慎,少一份盲目。
—— 别看了 · 2026