2.1 服务边界:怎么拆才不碎

拆分最大的坑不是拆不动,而是拆太碎。我们最早按表拆过一版:17 张核心表就拆出 21 个服务,结果服务间一次下单要串 7 跳,P99 从 60ms 涨到 220ms,后来又把 21 个收拢到 17 个。这一篇把定边界的判断标准写成可执行的清单,并把两次收拢的过程摊开:哪些服务该合、合完怎么验,是比「怎么拆」更常遇到的问题。

场景:拆碎的代价

拆法 服务数 下单链跳数 下单 P99 维护感受
按表拆 21 7 220ms 改一个字段跨 4 个仓库
按业务域拆 17 3 68ms 各域内聚,跨域走契约
按团队拆 12 4 95ms 两个域揉进一个服务,发布互相等

结论:业务域优先,团队规模校准。跨域调用超过 3 跳的链路一律打回重审,这个数字成为我们拆分评审的红线。服务拆分与调用链的治理细节在 go-micro-cps-guide 里也有对照,两篇的边界判断标准一致。

三组数字都来自同一套压测脚本,压测口径在 5.1 篇给全:21 个服务时链路展开 7 跳,每跳 gRPC 往返加排队约 20ms,P99 自然顶到 220ms;按域 17 个时链路 3 跳,P99 68ms。这里要强调的其实是第二个数字:拆得对,延迟不降反升是假象,真正升的是维护面。按域拆的优势不在单请求速度,而在改动半径小、故障域清晰。

拆碎的另一重代价在治理侧:21 个服务时,熔断配置要维护 21 份、灰度放量要排 21 条流水线、注册中心的实例数翻倍,机器账单比按域拆高 43%(见 1.3 的拓扑对照)。边界一旦定碎,后面所有治理组件的成本都按服务数线性放大,这就是为什么本篇放在注册发现之前讲。

还有一个隐性代价:21 个服务时 oncall 轮值表要 21 个负责人,值班响应被稀释,一次跨 3 域的故障 40 分钟没人认领。收拢到 17 个后轮值表回到 17 人,响应时长回到 5 分钟以内。服务数不只是技术账,也是组织账。

方案:三条画线规则

规则 判定方法 反例
数据所有权唯一 每个实体只归一个服务写,其余只读或调用 用户余额两个服务都能写
独立发布性 改这个域不逼上下游改代码 订单加字段要支付同步发版
容量与故障独立 峰值 QPS 与资源需求可单独扩缩 秒杀与查单同服务抢资源

执行顺序:先用数据所有权画域,再用独立发布性验证,最后用容量校验。顺序反了会先被业务话术带跑。

规则 1 最常被打脸的写法是「共享表」:两个服务直读同一张订单表。按所有权规则,订单表只能归订单服务,用户服务要看订单就走 api 或订阅事件。我们清过 5 处共享表直读,改完跨库查询的慢日志直接消失一半——共享表带来的不仅是边界问题,还有连接池与索引互相踩的性能问题。

规则 3 要量化:每个域给出峰值 QPS 与内存占用,能单独扩到 2 倍不逼他人就是容量独立。秒杀与查单同服务那次,秒杀把 4C8G 的机子打满 90%,查单 P99 跟着涨到 400ms,拆开后两边各自 30% 水位。

// 域内聚检查:一次下单的调用链(目标 ≤ 3 跳)
// gateway -> orders -> inventory -> 写库(订单持有库存读,扣减走 inventory 契约)

怎么判断「业务域」而不是「功能模块」:看数据的生命周期。商品、库存、订单、支付各自的数据从写入到废弃由同一套规则驱动,这就是四个域;而「用户中心」里登录、资料、积分的数据生命周期完全不同,它其实是一个门面加三个域,门面只负责路由。按这个标准我们砍掉了 2 个伪域,服务数从 19 收到 17。

画线之外还要留一张「域日历」:每个域标注最近一次边界变更的时间与理由。边界腐化不会一次性发生,而是 6 个月里 10 次小让步累积出来的。双周评审时先扫这张日历,连续 4 个月没动的域重点看它是不是被绕过了(有人用事件替代 api、有人共享了表),日历加共享表扫描是边界体检的两张单子,扫完把结果写回日历,下一轮评审直接看 diff。

配置与代码:边界的固化

边界定完不是写在 wiki 里就完了,要固化到代码结构:每个域一个 Go module,跨域只能 import 对方的 api 包(生成的 gRPC client),禁止 import 内部包。CI 加一条 go list -deps 检查,跨域引用内部包直接红。我们用它拦下过 3 次「顺手调了人家内部方法」的 PR。

# CI 卡点:跨域只能依赖 api 包
go list -deps ./... | grep -E 'internal/' | grep -v "$(go list -m)" \
  && echo "跨域引用了 internal 包" && exit 1

依赖关系还要留一份机器可读的清单:gov-deps 命令扫全仓 api 包引用,生成服务依赖图到 CLS 可检索的 JSON,每次发版自动更新。3 跳红线不再是人工评审的记忆,而是图上量出来的。依赖图后来还给 5.1 的压测提供了链路展开的依据。

收拢实操:21 到 17 怎么做到的

合并比拆分难,因为要处理存量调用方。我们的合并流程固定四步:

步骤 动作 验收点
1 定目标 选定要合入的域,声明数据所有权迁移 双周评审通过
2 双跑 新旧服务并行 2 周,请求进新服务、影子比对 比对差异 0
3 切流 调用方 api 包升级,旧服务只读 1 周 旧服务流量归零
4 下线 旧服务撤注册、撤机器、归档仓库 3 个迭代无回滚

第 2 步影子比对是收拢里最容易偷懒的一步:我们把新服务的响应与旧服务逐字段 diff,2 周内累计发现 17 处行为差异,其中 2 处是金额计算精度问题。跳过比对直接切流的话,这两处就是线上事故。21 到 17 全程 6 周,4 次合并全部走完四步,没有一次回滚。影子比对的脚本与 k8s-pipeline-guide 里的部署验证套路同源,改的是校验对象。

踩坑:三条实战教训

第一,把「查询服务」单独拆是个伪需求。按报表维度读的多服务(OLAP 查询)一度单拆,结果它要同步 6 个域的数据,一致性维护成本超过它带来的收益,合并回各域后延迟反而下降。第二,「未来可能要拆」不能作为拆的理由,当时拆出去的 2 个投机性服务一年没加过第二个人,纯维护成本。第三,边界要留缝:库存域最初连读都不对外开放,后来查单要展示库存,又改契约发一轮——不确定就先用事件(只发不读)过渡,稳定后再开放读接口。

四条教训各对应一张表:

教训 触发场景 修正动作 现在的纪律
伪查询服务 报表读多服务单拆 合并回各域 读多场景先建索引再谈拆
投机性拆分 为假想需求建服务 归档 2 个服务 无真实流量的服务不进注册
边界不留缝 库存读接口后补 事件过渡再开放读 不确定用事件,稳定再开 api
事件乱序 库存事件显示 0 实为 3 单调序号加幂等 跨域事件必须带序

事件过渡的代价也要算:订单域与库存域之间用库存变更事件衔接时,事件乱序导致一次查单显示库存 0 但实际 3,客诉 2 起后才改成带单调序号的事件加幂等消费。跨域通信不是免费的,事件顺序、幂等、补偿这些账在拆的那一刻就该列出来,别等线上再补。

还有一条流程教训:边界调整必须走双周评审而不是 PR 随手改。有一次有人把支付状态机的两个状态挪进了订单域,PR 理由写「方便」,合进去后才被发现破坏数据所有权规则,回滚花了 4 小时。规则进了评审会,这条没再犯。

组织层面的校准也要定期做:每半年对照一次团队规模与域数量。我们 17 个域对应 5 个组,平均 1 组 3.4 个域;一旦某组背到 5 个域,评审时强制检查它是不是被合并的机会漏掉了。团队与域的对照表同时放进 SRE 看板(见 4.3),域数量变动时告警,避免组织漂移悄悄改写拓扑。拆分的边界纪律与团队协作的更多实践,可以在 tencent-cloud-practice 的团队分工章节做交叉对照。

小结

拆分判断就三句:数据所有权画线、独立发布验证、容量校验收尾;跨域 3 跳红线,投机性服务不拆。收拢走四步:定目标、双跑比对、切流、下线,比对一步不能省。边界写完进 module 与 CI 固化,依赖图进 CLS 机器可读化,域日历半年一校,不然三个月就烂掉。下一篇 2.2 把契约层的 gRPC 设计规则定死,服务数 17 个的契约面也在那里收口。

延伸阅读