Naixxfl
← 全部笔记

服务拆错了长什么样

微服务架构服务拆分模块化

微服务拆得对不对,有一个特别简单的判断标准:改一个需求,要动几个服务?

如果经常是三个以上,那多半拆错了。

这个数据其实不用凭印象估,跑一下就有:

# 在每个仓库里统计最近三个月的提交,按需求号(分支名/commit 前缀)归并
git log --since=3.months --pretty='%s' | grep -oE '[A-Z]+-[0-9]+' | sort -u

把各个仓库的结果取交集,看有多少需求号同时出现在三个以上的仓库里。占比过半的话,你的服务边界和业务边界是错位的。

下面几种是见得最多的错位方式。

按技术分层拆

拆出来的服务叫 xxx-api、xxx-service、xxx-dao。

这么拆听起来很规整,实际上是把原来的三层架构按进程边界切了一刀。任何一个业务改动都要从上到下动三个服务,发布还得讲顺序。

这种拆法把单体的所有缺点都留下了,又额外送了你网络调用、序列化开销和分布式调试。唯一的收获是部署变复杂了。

共享一个数据库

这个最隐蔽,因为架构图上看起来完全没问题,几个服务各司其职,画出来干干净净。

但它们连的是同一个库,甚至直接读对方的表。

于是改一个字段要通知五个团队;想加索引得先确认没人在用另一种查询方式;某个服务写了个慢查询把整个库拖垮,所有服务一起完蛋。

服务之间没有真正的边界,只是把方法调用换成了 HTTP 调用。业内管这个叫分布式单体,一个既有单体的耦合、又有分布式的复杂度的东西。

判断方法很直接:能不能在不通知别人的情况下改自己的表结构? 不能,就说明边界不存在。

共享库是同一件事的编译期版本

还有一种更容易被放过去的共享:一个 common-dto 或者 common-model 包,所有服务都依赖它。

改一个字段,所有人升版本、重新编译、重新发布。耦合没消失,只是从运行时挪到了编译期,而且因为它长得像"复用",通常还能收获几句夸奖。

拆干净的做法是每个服务定义自己的入参出参,跨服务那一层做一次显式转换。这层转换写起来确实像重复劳动,它买到的是"对方改字段不关我事"。共享库真正适合放的是无业务语义的东西,日志埋点、追踪上下文、通用工具方法这些。

一条长长的调用链

A 调 B,B 调 C,C 调 D。每一跳都很合理,串起来是一条脆弱的链子。

结果是:D 慢 200 毫秒,A 那边的响应时间涨 200 毫秒;D 挂了,整条链全挂。

可用性是乘起来的。四个 99.9% 串联,得到的是 99.6%,一年的不可用时间从 8.8 小时涨到 35 小时。而这四个服务各自的 SLA 报表都是达标的绿色。

见过一个下单接口,同步串了五个服务,其中包括发短信和写积分。这两件事成不成功,跟用户能不能下单成功一点关系都没有。

这类地方就该改成事件驱动:下单成功发个事件出去,积分和短信自己订阅。主链路上只留"不做就不算成功"的那几步。

问自己一句:这一步失败了,用户的操作到底算不算成功?不算,才留在同步链路上。

拆得太细

一个需求要改六个仓库、提六个 PR、发六次版,还得排好顺序。

每个服务几百行代码,一半是 DTO 转换。开个会讨论"这个字段该放哪个服务"要花四十分钟。

拆得太细和拆得太粗,代价是不对称的:太粗,你改起来费劲但还能改;太细,你光是协调就把时间花光了。

我判断边界用的几条

看变化频率。 总是一起改的东西就该在一起。每次改 A 都要顺手改 B,说明 A 和 B 本来是一个东西,被人为切开了。

看数据归属。 一张表只能有一个服务能写。别人要改,走接口或者发事件。做不到这一点,边界就是假的。

看团队。 康威定律那句话是反过来用的:既然系统结构最终会长成组织结构的样子,那不如一开始就让服务边界对齐团队边界。一个服务被三个团队改,冲突是必然的。

看事务。 必须在一个事务里完成的操作,尽量别跨服务。跨了就得上分布式事务,那是另一笔账(我写过一篇关于 Outbox 的)。很多拆分的痛苦,根源就是把一个事务劈成了两半。

边界要靠结构成立,不能靠人记得

上面这几条都是判断标准,但判断标准是会被忘掉的。我自己的项目是个模块化单体,在这上面吃过的亏让我现在更看重另一件事:边界能不能不依赖人的自觉。

举个我做过的例子。项目里好几个模块的数据都要按账号隔离,最早的做法是每张业务表存一个 owner_username 列。看起来很直观,代价是每加一个模块,数据迁移功能就要跟着加一行,漏了就是这个模块的数据迁不走 —— 而且不报错,用户换账号之后只是发现有些数据不见了。

后来改成一张 data_owner 中间表,业务表只存 owner_id 指过去。迁移变成了改中间表的一行,全站数据一起走。

这个改动真正的收益在于新模块什么都不用做。只要建表时带上 owner_id BIGINT NOT NULL 外键指过去,迁移自动覆盖它,不需要注册、不需要实现接口、不需要在任何清单里加行。记录数统计也是扫 information_schema 里带 owner_id 的表自动发现的,模块只负责配个中文名,漏配了也只是显示成表名,不影响迁移。

同一个思路我在错误日志上又用了一次。采集探针挂在 Logback 的 root appender 上,而不是挂在业务代码里。新增任何 Controller、Service、异步任务,只要写了 log.error 就自动被采集,零维护。

判据我总结成一句:漏了会不会丢数据。 会,就必须让它靠结构自动成立;不会,靠文档和约定就够了。中间表那个例子里,漏一行的后果是用户数据迁移时静默丢失,这种代价不能押在"下个人记得看文档"上。

这条对微服务同样成立,而且更重要。单体里漏了一处还能靠 IDE 全局搜出来,跨了进程边界之后,"谁该维护这个约定"本身就成了一个需要开会的问题。

所以设计服务接口的时候值得多想一层:这个约定是靠调用方自觉遵守,还是靠类型、靠编译、靠契约测试强制成立的?前者迟早会被某次赶工绕过去。

拆错了要敢合回去

这条其实最难,因为它跟"进步"的感觉相反。

拆分总被当成架构演进,合并则容易被看成倒退、承认失败。于是明明两个服务永远一起改、一起发、共享数据,也没人提合并,就这么各自维护着两份 CI、两套监控、两个仓库。

但如果两个服务从来没有独立发布过,那它们本来就是一个服务,只是被拆开摆着而已。

合并是完全正当的架构决策。判断依据也简单:它们各自独立发布过吗?没有,就该合。

最后

微服务解决的是人的问题,让多个团队能并行干活、各自发布、互不打扰。

三个人的团队拆成八个服务,是在用一个协作方案解决一个根本不存在的协作问题。我自己的项目就是三个人以下的规模,所以它到今天还是个 Maven 多模块的单体,边界靠模块依赖方向强制(sdk 不许依赖 core,core 不许依赖 web),构建期就能拦住越界,比进程隔离便宜太多。

真正需要运行时隔离的那部分,我用了插件机制:每个插件一个独立 ClassLoader,可以热加载热卸载,互相之间类不可见。目标是"改一个小工具不用重启主程序",这个目标不需要拆服务也能达到。

拆之前先问:现在真正卡住我们的,是代码耦合,还是人手不够?这两个问题的答案指向完全不同的方向。