
关于微服务和单体的一些思考
一个系统有 30 个服务,它就比一个单体先进吗?
未必。它也可能只是把原来一次函数调用能完成的事情,改成了 30 个进程互相发请求,再配上一套 Kubernetes、一条消息队列、三种数据库和一群半夜被告警叫醒的人。服务数量确实上去了,业务价值却不会因此自动上涨。
微服务最容易被误解成一种代码组织方式:文件多了就拆仓库,类多了就拆服务,团队大了就上容器。其实它首先是部署边界、数据所有权和团队责任的重新划分。一刀切下去,切开的不只是代码,还包括事务、故障、版本、监控与值班责任。边界画对了,团队能够独立交付;边界画错了,原本藏在进程里的耦合就搬到网络上,顺便获得超时、重试和部分失败等新技能。
单体也不等于一锅粥。一个应用可以作为单个单元部署,同时在内部保持清晰模块、严格依赖方向和明确数据边界。反过来,一组服务也可能共享数据库、同步发布、互相翻表,组成一个披着微服务外衣的分布式单体。
因此,我的立场很明确:大多数新系统应该从模块化单体开始。只有当独立部署、独立扩缩容或组织自治带来的收益,已经大到足以覆盖分布式系统的长期成本时,才拆出微服务。 微服务不是单体长大后的成人礼,更不是简历上的建筑面积。它是一笔昂贵的交易,先看自己要买什么,再决定是否付款。
一、先别争名字,看看边界
单体到底「单」在哪里?
通常说的单体 (Monolith),核心特征是作为一个单元构建和部署。一次发布会把应用的多个功能一起送进生产环境,它们往往运行在同一进程,访问同一个数据库。
这个定义没有说代码必须混乱,也没有说整个应用只能由一个团队维护。把用户、订单、库存、结算分别放进独立模块,限制跨模块依赖,只通过公开接口协作,最后仍然打成一个制品发布,这就是模块化单体 (Modular Monolith)。模块边界是逻辑的,部署边界则暂时保持粗粒度。
微服务把某些逻辑边界进一步升级为进程和部署边界。每个服务围绕一项业务能力工作,拥有自己的发布节奏,理想情况下也拥有自己的数据。所谓「微」不是要求代码必须少于多少行、容器内存必须低于多少 MB,而是服务能否由一个团队理解、维护并独立演进。
两者并不是正邪对决,而是边界强度不同:
| 维度 | 模块化单体 | 微服务 |
|---|---|---|
| 代码边界 | 通过模块、包和架构测试约束 | 由仓库、API 和进程共同约束 |
| 部署边界 | 多个模块一起部署 | 服务可以独立部署 |
| 调用方式 | 进程内调用为主 | 网络调用与消息为主 |
| 数据边界 | 可共享数据库实例,但应限制表的所有权 | 服务应拥有并封装自己的数据 |
| 事务范围 | 本地事务容易覆盖多个模块 | 通常只能在单个服务内完成本地事务 |
| 故障形态 | 进程级故障,状态相对集中 | 部分失败、超时、乱序与重复成为常态 |
关键不在于哪一列看起来更高级,而在于系统需要哪一种边界。如果所有功能必须一起修改、一起验收、一起上线,把它们拆成服务并不会得到独立性,只会多出网络成本。不能独立变化的东西,通常也不值得独立部署。
边界应该按什么来画?
按技术层拆最直观:用户服务、数据库服务、消息服务,或者前端服务、业务服务、数据服务。可这种切法常把一次业务变更分散给多个团队。新增一个退款规则,要同时修改 API、业务层和数据层,所谓独立服务最后还是排队联调。
更稳妥的方向是围绕业务能力与限界上下文 (Bounded Context) 划分。订单里的「客户」可能是收货人与联系方式,风控里的「客户」则包含风险等级和授信状态。两个领域碰巧使用同一个名词,不代表必须共享同一份模型。Microsoft 的微服务领域分析指南也强调,应围绕业务能力而不是数据访问、消息等水平技术层设计服务;边界不是由机械公式算出来的,还要结合业务和非功能需求持续修正。
这件事难就难在,项目开始时我们往往并不了解领域。需求会变,术语会变,原来以为紧密相关的能力逐渐分开,原来以为独立的流程却共享大量规则。进程内移动一段代码,通常只是重构;跨服务移动数据和职责,则可能牵涉 API 兼容、数据回填、事件订阅、权限和灰度切流。
Martin Fowler 在 Monolith First 中给出的理由至今仍然有效:微服务要运转良好,依赖相对稳定的边界,而新项目恰恰最缺少边界知识。先用单体学习领域,可以把早期频繁修正留在成本较低的进程内部。
这并不意味着先随便糊一个巨石应用,以后再念咒拆开。没有模块边界的单体会不断长出跨层调用、共享状态和循环依赖,最后当然难拆。「单体优先」的重点不是单体,而是优先验证业务;「模块化」则是在验证过程中保留未来的选择权。
二、微服务真正解决的是组织问题
部署独立,才有服务独立
微服务最有价值的能力不是使用不同语言,也不是把系统画成一排漂亮方框,而是独立交付。库存团队改库存规则,不必等待结算团队的发布窗口;推荐服务突然变热,可以单独增加实例;一个低频后台服务不需要跟着核心接口一起扩容。
然而,把代码放进不同仓库并不等于独立部署。下面这些情况都值得警惕:
- 服务
A升级时,B和C必须同时升级 - 一项需求需要在七个仓库提交 PR,最后约定周三晚一起上线
- 所有服务共享一组数据库表,任何字段变更都要召集全员
- 测试环境只能整套启动,单个服务无法用契约或替身验证
- 某个「公共包」封装了大量业务对象,升级它等于全家桶发布
这就是分布式单体。部署单元看似很多,发布仍然是一件集体活动。你支付了服务发现、网络通信和多仓库管理的成本,却没有买到独立交付。
真正的独立部署需要兼容的 API 演进、消费者契约测试、自动化流水线、渐进发布和快速回滚。服务提供者不能一改字段就通知所有消费者「今晚配合一下」,消费者也不能偷偷查询对方数据库。否则边界只存在于架构图里。
Martin Fowler 在微服务的前置条件中把快速部署、基础监控和服务间协作机制列为重要准备。道理不复杂:服务越多,发布与排错的频率越高;如果部署仍靠手工登录服务器,微服务只会把一份手工劳动复制几十次。
服务边界往往也是团队边界
软件结构会逐渐映照沟通结构。五个团队共同修改一个大应用,边界不清时,代码审查和发布协调很容易变成会议驱动开发。微服务用更硬的边界把责任交给长期稳定的小团队,让团队对一项业务能力从需求、开发到运行负责。
但不要把这句话理解成「一个人一个服务」。服务不是分封给团队的领地,更不能因为组织架构图上有六个部门,就切出六套互相甩锅的 API。业务能力的边界与团队的认知容量需要互相校准:一个团队拥有太多服务,会被告警、依赖升级和平台琐事淹没;一个服务被多个团队共同拥有,又会回到协调瓶颈。
因此,判断是否需要微服务时,我会先问组织问题:
- 是否真的存在多个能够端到端负责业务能力的团队?
- 它们是否经常被统一发布列车阻塞?
- 不同领域的变化速度、可靠性目标或扩容需求是否明显不同?
- 团队是否愿意承担上线后的运行责任,而不只是把容器扔给运维?
- 是否有平台能力把部署、观测、密钥和网络策略做成可靠的默认项?
若答案大多是否定的,提前拆服务只会让本来坐在一张桌旁的人通过网络假装不认识。五个人维护 40 个服务,不叫自治,叫轮流值班。
三、把函数调用换成网络调用之后
网络不是透明的胶水
单体里的函数调用要么返回,要么抛错,延迟通常稳定在微秒或更低的尺度。远程调用则有一长串暧昧状态:请求可能没发出去,可能已经到达但响应丢了,也可能服务端处理成功后在返回途中超时。调用方看到一个 timeout,并不知道能不能重试。
假设支付接口超时。直接重试可能扣款两次,不重试又可能让已扣款订单停在「待支付」。因此,服务间调用必须考虑幂等键、超时预算、有限重试、指数退避、熔断、隔离和补偿。注意,重试不是免费的成功按钮。下游已经过载时,上游整齐划一地重试,只会给火堆再添一车柴。
调用链越长,尾延迟与可用性越难看。一条用户请求同步经过 A → B → C → D,任何一环变慢都会拖住整条链路。服务各自宣称「四个九」并不代表组合后仍有四个九。更麻烦的是,某些失败只影响部分请求、部分机房或某一版本,系统没有完全宕机,却一直处在薛定谔的健康状态。
所以故障隔离并不是把程序切开后自动获得的赠品。边界合理、依赖方向清楚、资源隔离有效时,一个服务失败可以被限制在局部;边界错误、同步链路过长时,一个小服务也可以通过线程池耗尽、连接池堆积和重试风暴拖垮全站。
可观测性不是多装一个日志平台
单体排错时,一段堆栈经常能指出请求在哪里失败。微服务中,同一次请求的证据散落在多个实例、队列与数据库里。机器时间可能有偏差,异步消息又可能晚几分钟才被消费。你若只有几十份没有关联标识的日志,排错就像把一部侦探小说撕碎后撒进不同垃圾桶。
至少要把下面几类信号串起来:
- 指标回答「系统是否异常」,并围绕延迟、流量、错误和饱和度设置告警
- 日志解释离散事件,携带服务、版本、环境、请求或业务关联标识
- 分布式追踪还原请求跨服务传播的路径与耗时
- 变更记录告诉你故障前后部署了什么、调整了什么配置
OpenTelemetry 对分布式追踪的说明很直白:Trace 记录一次请求在分布式系统多个服务中的传播路径。可只有 SDK 还不够。团队还要统一上下文传播、采样、字段规范、敏感信息处理和数据保留策略。高基数字段可能让监控账单猛涨,日志中随手记录令牌和个人信息则会把排错系统变成新的数据泄露面。
单体同样需要可观测性,但微服务把它从「最好有」变成了「没有就别上线」。因为当边界穿过网络,你失去了一段本来由进程和调试器天然提供的上下文,只能用工程能力把它补回来。
四、最难拆的从来不是代码,而是数据
数据库共享,会把边界重新粘回去
如果订单服务和库存服务都可以直接读写同一组表,它们就没有真正的数据边界。一方改字段含义,另一方可能悄悄坏掉;一方为了性能加锁,另一方的延迟突然上涨。代码仓库分开了,最深的耦合却留在数据库里。
较完整的微服务边界要求服务拥有数据,其他服务通过 API 或事件获取。这并不一定意味着每个服务都要买一台数据库服务器。可以共享数据库集群,通过独立 schema、账户权限和迁移流程隔离;关键是数据的写入权与语义由谁负责,而不是账单上有几套实例。
数据私有化能让 schema 独立演进,却会立刻带来两个问题:跨服务事务怎么办,跨服务查询怎么办?过去一个 SQL JOIN 加一次 ACID 事务能完成的事,现在可能需要事件、物化视图、补偿和对账。
一致性不是只有「强」和「最终」两个按钮
以创建订单为例:订单要落库、库存要预留、优惠券要核销。单体可以在一个数据库事务里完成,失败则整体回滚。拆成三个拥有独立数据的服务后,本地事务无法覆盖整条业务流程。
常见做法是 Saga:把全局流程拆成一串本地事务,某一步失败时执行补偿动作。补偿不是数据库的时间倒流。「取消库存预留」可以补偿,「撤回已经发送的短信」却做不到;退款也不是抹掉扣款,而是新增一笔方向相反、可审计的业务事实。Saga 模式因此要求我们明确每一步的补偿语义,以及采用事件编排还是中心协调。
事件发布本身还有经典的双写问题:数据库提交成功但消息没发出去,或消息发出后数据库回滚。事务发件箱 (Transactional Outbox) 的做法,是在同一个本地事务中同时写业务数据与待发送事件,再由独立进程投递。它缩小了不一致窗口,但接收方仍要处理重复消息,投递顺序也要围绕业务实体认真设计。Transactional Outbox 模式说明对此有完整描述。
「最终一致」也不能作为所有异常的万能解释。产品需要定义:
- 最迟多久一致,超过时间后由谁告警?
- 用户在窗口期会看到什么状态?
- 消息重复、乱序、丢失或死信时如何恢复?
- 是否有周期性对账发现沉默的不一致?
- 哪些不变量绝不能放松,必须留在同一个事务边界?
若一条核心流程有大量必须同时成立的不变量,拆开它可能本身就是错误。不要为了服务纯洁度,把一个天然的事务边界劈成几段,再用半本分布式系统教材把它缝回去。
跨服务查询也一样。后台报表可以接受异步物化视图;用户提交订单前的关键校验可能需要同步 API;离线分析可以进入数仓。选择取决于新鲜度、正确性和可用性要求。数据不是拆库时顺手搬走的家具,而是决定服务边界是否成立的硬约束。
五、成本不会消失,只会换一份账单
机器成本只是最容易看到的一层
一个单体通常可以在少量实例上充分共享 CPU、内存和连接池。拆成几十个服务后,每个服务都要保留基础资源与冗余副本,还会增加入口、服务发现、证书、消息系统、遥测采集和持续部署的开销。低流量系统尤其容易出现一种荒诞景象:业务 QPS 不高,维持平台运转的组件倒是坐满一桌。
云和容器能够降低资源申请的摩擦,却不会让资源免费。更大的成本往往来自人:
- 开发者需要理解远程通信、兼容性与故障恢复
- 平台团队需要维护流水线、运行环境、权限和可观测性
- 测试从进程内集成变成契约、端到端与异步流程验证
- 值班人员要面对跨服务关联、告警降噪和复杂回滚
- 安全边界扩大,服务身份、密钥轮换和东西向流量都要治理
技术多样性同样是一把双刃剑。让图计算服务使用合适的数据库很合理;因为某位开发者喜欢新语言就再养一套编译、扫描、监控和招聘体系,通常不划算。自治不是各自为政。成熟的平台应该提供一条铺好的路:统一构建、发布、遥测、身份和安全基线,同时允许确有收益的例外。
什么时候成本值得付?
微服务的收益主要在复杂系统与多团队环境中显现:不同业务能力需要独立而频繁地发布;局部负载差异足够大,独立扩缩容能产生明确收益;不同领域的可靠性与合规边界确实不同;一个大制品的构建、测试和协调已经成为持续瓶颈。
注意,这些都应该有证据。发布频率、交付前置时间、变更失败率、恢复时间、构建耗时、热点资源使用率和跨团队等待时间,至少能告诉我们痛点究竟在哪里。若问题只是测试太慢,先并行测试和整理测试边界;若问题只是某个查询吃 CPU,先优化查询或拆出只读工作负载;若问题是模块耦合,先在单体内修复依赖方向。
不要用最昂贵的架构,替代本该完成的基本工程治理。 一个没有自动化测试、发布和监控的团队,不会因为上了 Kubernetes 就突然成熟。它只会把旧问题容器化。
六、我的默认选择:模块化单体
模块化不是换个目录名
模块化单体的价值,是先用较低成本建立业务边界,再保留未来升级为部署边界的可能。要做到这一点,至少需要几条硬约束:
- 模块围绕业务能力组织,而不是把 Controller、Service、Repository 各放一个大目录。
- 模块只暴露少量公开接口,内部类型与实现不向外泄漏。
- 依赖方向明确,禁止循环依赖;可以用架构测试或静态检查持续验证。
- 数据表有清晰所有者,跨模块不直接写表;需要的数据通过接口或事件获取。
- 外部系统、时间和消息等副作用通过适配层隔离,便于替换与测试。
- 模块间交互记录得足够清楚,未来才能判断哪些调用适合跨网络。
它仍然是单个部署单元,意味着一次发布可能影响整个应用,热点模块也常要跟随整体扩容。但在许多系统里,这个代价远小于分布式事务、服务治理和跨进程排错。部署粗粒度并不可耻,无法控制耦合才危险。
模块化单体也不是永不拆分的承诺。它更像铅笔画出的边界:规则明确,但仍允许在领域知识增长后擦掉重画。网络边界则更像用切割机开墙,当然也能改,只是工钱和灰尘都多得多。
哪些信号说明可以拆了?
我更相信重复出现、可以测量的痛点,而不是「以后用户可能很多」。下面这些是值得认真评估的信号:
- 某个模块有独立、稳定的业务边界和明确数据所有者
- 它的发布频率明显不同,却长期被整体发布节奏阻塞
- 它的负载形态特殊,整体扩容造成了可观的资源浪费
- 它需要不同的可用性、隔离、合规或安全等级
- 一个长期稳定的团队能够端到端负责其开发与运行
- 模块间调用已经收敛为少量接口,跨边界事务可被合理处理
- 拆分收益能通过交付、成本或可靠性指标验证
反过来,下面这些理由不够:代码行数多、类太多、想使用新框架、架构图不好看、别人都在用,或者猜测未来会有一亿用户。未来真来了一亿用户,再根据真实热点演进也不迟;多数项目先要解决的问题,是找到一万个愿意留下来的用户。
七、真要拆时,慢一点反而更快
不要进行大爆炸重写
把旧系统冻结一年,另起炉灶重写全部功能,听起来干净利落。现实往往是旧系统仍要修漏洞、应对法规和支持业务,重写项目则持续追逐移动的目标。到切换那天,新系统既缺少旧系统十年积累的边界行为,又没有经历真实流量检验。
更可控的方式是绞杀者模式 (Strangler Fig Pattern):在单体前建立稳定入口,逐步把某项能力路由到新服务,完成验证后再删除旧路径。AWS 的绞杀者模式指南也强调增量替换,以降低一次性迁移带来的业务中断风险。
一个相对稳妥的迁移顺序是:
- 先测量。 找到交付、扩容或可靠性的真实瓶颈,写清拆分目标与成功指标。
- 先在单体内划边界。 收拢调用入口和数据访问,消除随意跨模块读写。
- 选择合适切口。 优先考虑边界清晰、依赖较少、失败可恢复,同时又能产生实际收益的模块。
- 建立兼容层。 让调用方暂时不知道能力位于进程内还是远端,避免业务迁移与调用方改造绑成一次发布。
- 明确数据所有权。 选择源头,设计增量同步、校验、回填、切读与切写顺序,避免长期双向写入。
- 先补运行能力。 在承接流量前完成指标、日志、追踪、告警、容量评估、灰度和回滚。
- 逐步切流。 从内部或低风险流量开始,对比结果与指标,扩大流量前保留可验证的退路。
- 删除旧路径。 迁移完成后清理旧代码、同步任务和兼容层,否则系统会永久保留两套真相。
最后一步尤其容易被忽略。新服务上线只是迁移中点,旧路径退役才算完成。若团队不断开新拆分项目,却没有预算清除临时桥接,系统的熵只会越来越高。
数据迁移期间也要明确「谁是真相」。双写看似方便,实际上很容易在局部失败后产生分叉。若不得不并行运行,应尽量让单一来源产生变更,再通过可重放事件或变更数据捕获同步,并用对账验证,而不是让两个系统都自由修改同一事实。
拆到哪里停?
不是所有单体最终都要消失。核心区域若内部一致性强、变化相互关联,继续作为一个较大的模块或服务完全合理。微服务架构的终点也不是每张表一个服务,更不是函数即服务的文字游戏。
每拆出一个服务,都应该重新算账:独立部署是否真的发生了,团队等待是否减少了,可靠性是否改善了,成本是否在预期内?如果收益已经见顶,就停。架构演进不是拆迁竞赛,留下一个健康的核心单体,比得到 80 个互相纠缠的「微服务」体面得多。
八、结语:先把边界想明白
回到开头:30 个服务会比一个单体先进吗?
只有当这 30 个边界对应真实的业务与团队边界,能够独立开发、部署、扩缩容和恢复,并且组织愿意长期支付数据一致性、可观测性与运行平台的成本时,答案才可能是「会」。否则,服务数量只是复杂度计数器。
我的选择仍然是:新系统默认采用模块化单体,先把产品与领域搞明白;已有系统从可测量的瓶颈出发,逐个提取真正需要自治的能力。 对边界不确定时,让它先留在进程里;对独立性已经有充分证据时,再把边界推到网络上。
单体的问题,通常不是它只有一个部署单元,而是内部没有边界。微服务的价值,也不在于它有很多部署单元,而在于边界终于能够承载团队自治与独立变化。
说到底,好架构不是方框最多的那张图,而是在变化到来时,让正确的人只改该改的部分,并且知道哪里会坏、坏了怎么救。能用一个部署单元做到,就别急着折腾 30 个。网络从不免费,凌晨的告警更不会替架构情怀买单。
