Appearance
19 微服务拆分与治理:怎么拆、怎么切流量、拆完多了什么
详细程度:深入 · 约 55 分钟 材料:W10-1、W10-2、W10-3、W14-1、W13-2、W14-2 · 旧长版:
notes/p10-grpc.md(gRPC 基础与拦截器) 前置:04 接口与组合、16 稳定性、17 迁移
先看一笔算不平的账
两个系统各自复杂度是 Sa 和 Sb。把它们合并成一个系统,复杂度 S 是多少?直觉答案是 Sa 加 Sb,资料给的推导却是不等式:S > Sa + Sb。多出来的部分就是两个系统之间的交互成本:模块越多,模块之间的交互越多,一个研发人员需要弄清楚的东西就越多。〔W10-1 p5-6〕
把这个推导反过来用,就是微服务的全部动机。下面这段代码把"拆与不拆"的差别摆在台面上:同一个入口请求,单体里是一串函数调用;拆成微服务后,每一跳都变成跨网络的 RPC,链路上任何一跳出问题,整条请求就失败。
go
package main
import "fmt"
// 一条简化链路:网关 -> post -> counter。
// 单体里这是三个函数调用;拆开后是两次跨网络 RPC,交互成本就长在这里。
type counterSvc struct{ calls int }
func (c *counterSvc) IncrReadCnt() { c.calls++ }
type postSvc struct {
counter *counterSvc
calls int
retries int // 每次调用最多重试次数(拆分后必须配的重试)
}
func (p *postSvc) Get() {
p.calls++
for i := 0; i < p.retries; i++ {
p.counter.IncrReadCnt() // 失败就再试:counter 感受到的调用量被放大
}
}
func main() {
cnt := &counterSvc{}
post := &postSvc{counter: cnt, retries: 2}
for i := 0; i < 1000; i++ {
post.Get()
}
fmt.Printf("入口 1000 次 -> counter 收到 %d 次调用(每跳重试 2 次,放大 2 倍)\n", cnt.calls)
}text
入口 1000 次 -> counter 收到 2000 次调用(每跳重试 2 次,放大 2 倍)入口 1000 次请求,counter 实际被打了 2000 次。这就是拆分后系统的真实形态:一个入口请求在链路上产生多次服务调用,流量放大随链路长度和重试次数一起增长。入口流量翻倍,系统的实际负载增长不止一倍,治理(限流、熔断)要防的就是这个放大器把一次抖动变成雪崩。〔W14-1 p10〕
所以"微服务"不是一个崇拜对象,它只是分而治之的一种实现:把系统拆到一个人能理解的地步,模块之间只通过 API 耦合,不必知道彼此内部细节。〔W10-1 p5〕这一节按拆分的真实顺序走:先回答该不该拆(一),再回答怎么拆(二、三、四),最后回答拆完之后多出来的治理与日常开销(五、六)。本节材料四个单元的总览页(W10-1 p2、W10-2 p2、W10-3 p2、W14-1 p4)在这里一并给引用:正文一、二、四、五节逐条展开过它们。
和你已知语言的差异
| 问题 | Java / Kotlin | Rust | TS / Node | Go |
|---|---|---|---|---|
| 服务间怎么通信 | Spring Cloud 全家桶,HTTP 加注册中心 | 内部服务多直接进程内,跨服务用 HTTP/gRPC | HTTP/JSON 为主 | gRPC 生态最好,HTTP 也常见 |
| 接口契约 | OpenAPI 生成或手写 | serde 类型共享 | 共享 TS 类型 | Protobuf IDL 编译生成,编译期锁定 |
| 装配方式 | @Autowired 运行期反射 | trait 对象注入 | 依赖注入库 | 构造函数显式装配,wire 生成样板(见 10) |
| 服务边界依据 | 与语言无关 | 与语言无关 | 与语言无关 | 材料的标准:DDD 一个领域一个服务,再看团队能维护多少〔W10-2 p4〕 |
表里第二行值得展开:用 gRPC 时的做法是契约先行,.proto 先写、代码后生成,客户端和服务端在编译期就被同一份 IDL 锁住。这也决定了本章的拆分步骤里,"定义 Protobuf"是独立的一步而不是实现细节。
一、该不该拆:先算收益,再算代价
微服务是一种架构,架构有很多种,微服务只是一种。它不是目标,拆分本身不产生价值。〔W10-1 p4〕
收益从四个方向来:一是上面说的降复杂度,拆到一个人能理解单个模块;二是独立演进,保持 API 向后兼容的前提下各服务可以独立开发、测试、部署、扩容;三是运维粒度变细,运维的基本单位是实例,单体部署可能要 16G 内存,拆出来的服务可能 4G 就够,资源按需分配;四是结构贴近组织架构,减少组织之间的交流损耗(这一条的理论名字是康威定律,材料没提,是这里补的说法)。〔W10-1 p5-7〕
代价列出来是一整张清单:RPC、注册发现、负载均衡、路由、故障处理(检测、处理、恢复,外加熔断限流降级)、超时控制、优雅退出,这一整套在单体里要么不存在、要么是函数调用天然解决的,拆开后全都要自己面对。〔W10-1 p8〕这正是 16 章(稳定性)、20 章(发现与负载均衡)各自整章展开的内容,在拆分的语境下它们不是可选项。
既然收益和代价都真实存在,判断标准就应该是"收益能不能覆盖代价"。资料给的标准非常具体:微服务应该拆分到一个人能够掌握其中全部细节的程度。目标是分而治之,那就拆到一个人能完全掌握为止。现实中更常见的是三五个人维护两三个服务。〔W10-2 p4〕粒度也要考虑团队组织:敏捷理论里的两个披萨团队大约 9 人,团队成员能维护住多少业务,就划定了边界。〔W10-2 p4〕
还有一个常见的追问:模块化也是分而治之,只搞模块化不拆进程行不行?答案是不行,因为模块化解决不了运维粒度:模块化单体的资源需求是整个应用的需求,微服务化的资源需求是单个服务的需求;独立演进和组织架构对齐这两条也依赖进程边界。〔W10-1 p7〕模块化是拆分的必经阶段,但不是终点,下一节展开。
二、拆分路线:先模块化,再进程化
不要一上来就拆进程。资料给的路线分四个阶段,每个阶段都在为下一阶段补地基:〔W10-2 p5〕
- 混沌:完善单元测试;先抽公共部分(utils、helper);引入聚合层解除模块间循环依赖;按业务对象把模块分到不同的包。
- 模块化:创建不同的代码仓库,公共部分和业务模块逐个挪过去;准备微服务环境,搭建 CI 和集成测试环境。
- 模块依赖化:业务模块逐个服务化,解决微服务开发、测试、部署中的问题;搭自动部署和回滚平台;引入消息队列、分布式事务、分布式任务调度;搭可观测性平台(logging、tracing、metrics 加告警)。
- 服务化:引入服务治理、网关、回归测试,按业务分库。
路线也有分岔:只先拆一个模块,可以提前验证整个拆分流程,但"开弓没有回头箭",拆了一个之后很难停下来;全部模块按"模块化、模块依赖化、微服务化"推进,处处可停,但流程问题会积累到最后集中爆发。资料选的是第一条路线,拆一部分、验证流程。〔W10-2 p6〕
八步:单个模块怎么拆出去
选定模块之后,资料列了八步。注意第六步是整套流程的核心,后面第四节专门展开:〔W10-2 p7〕
- 选定模块,作为拆分对象。
- 检查并补充测试,覆盖率到 80%,业务场景和异常场景都要覆盖。
- 代码拆分,作为独立模块(先留在本地)。
- 确定技术选型(微服务框架)。
- 把模块改造成微服务,对外提供服务。
- 原应用同时使用本地调用和微服务调用,用开关控制流量,允许回滚。
- 逐步调整流量,直到微服务调用 100%。
- 删除本地调用,纯粹依赖微服务。
选哪个模块先拆
先易后难,三个条件按序筛:业务影响力小(崩了无所谓)、最独立(依赖它的和它依赖的都少)、QPS 低。资料里选了点赞收藏:用户和帖子不能选(核心依赖),短信是核心依赖(登录和关键业务都用它),热榜 QPS 高。〔W10-2 p12〕
配套的心态调整也在这里:重构必然引入 bug,要问的是"出 bug 了怎么办",而不是"怎么不出 bug"。〔W10-2 p12〕这句话决定了后面两件事的设计:回滚方案(第六步的开关)和测试策略(覆盖率 80% 只是降低概率,不是消灭)。
回滚设计与测试保障
回滚方案围绕"出问题大概率在哪"设计。出问题几率最大的是"拆代码"和"转微服务调用"两步:拆代码那一步仍然是本地调用,风险小;转微服务调用引入了中间态,所以八步里专门安排了第六步作为缓冲,微服务调用出问题立刻把流量切回本地调用。〔W10-2 p8〕
测试保障是整个流程的安全网,三句话值得记住。第一,补测试是重构的第一步:接手历史代码时不可能知道全部业务场景,只能追求测试覆盖所有代码,考虑到有些异常分支几乎不会触发,覆盖率 80% 以上就比较稳妥。第二,集成测试覆盖率比单元测试更重要,因为集成测试真实地和数据库、Redis 打交道。第三,代码全覆盖不等于业务全覆盖,补完测试还要人工检查核心路径、梳理业务场景(缓存命中与未命中、并发更新、点赞取消点赞反复进行这类),重构团队里要有一个非常了解业务的人。〔W10-2 p13-16〕
动手前的最后一件事是列出全部重构点,忌讳边拆边发现漏了。资料列的清单是:Web 层、Service 层、Domain 层、Repository 层、Cache 层、DAO 层(含初始化表结构的 init 文件)、wire.go、事件消费代码。〔W10-2 p19〕其中事件消费者要复制一份而不是共享事件定义,因为模块化之后不允许再依赖原主应用的内部包,名义上拆了、实际上互相编译依赖是最糟的中间态。〔W10-2 p22〕
三、契约与外壳:gRPC 服务端怎么写
gRPC 和普通 HTTP 接口差在哪
材料介绍 gRPC 时说它"基于 QUIC 协议,还利用了 HTTP2 的双向流特性"。〔W10-1 p26〕这里要纠正一处:gRPC 的传输层是 HTTP/2,不是 QUIC。QUIC 是 HTTP/3 的传输层,gRPC 本体并不跑在上面;grpc-go 的 internal/transport 包里实现的是 http2 的客户端和服务端(我在本机模块缓存里确认过文件名 http2_client.go)。 所以 gRPC 和你熟悉的 HTTP/JSON 接口,差别不在"一个走 HTTP 一个不走",而在三件事:契约用 Protobuf IDL 描述并生成代码(本节后面展开);消息体是二进制编码而不是文本 JSON〔W10-1 p32〕;HTTP/2 的多路复用和双向流让一条连接上能并发多个调用。Java 的 Spring MVC、TS 的 Express 默认是 HTTP/1.1 加 JSON 的请求响应形态,Rust 的 tonic 和 Java 的 grpc-java 同样基于 HTTP/2。
Protobuf 的兼容性规则
gRPC 规定用 IDL 描述接口,落地选 Protobuf:跨语言、平台无关,编译生成各语言的客户端和服务端代码。〔W10-1 p27-28〕IDL 的核心是 message:每个字段有唯一的编号,Protobuf 靠编号而不是字段名序列化和反序列化。〔W10-1 p32〕〔W10-1 p35〕这个设计直接派生出几条必须遵守的兼容性规则:
- 字段编号不能冲突,删除后不复用。编号是线上数据的"地址",两个版本对同一个编号有不同理解,数据就错位。字段下线时用
reserved把编号占住,防止未来误用。("删除后不复用"是 Protobuf 的通行规则,资料在示例注释里写了,展开解释是这里补的。)〔W10-1 p35〕 enum的 0 值留给 Unknown。材料的说法是"0 就不要用了,定义成 Unknown 之类的"。〔W10-1 p39〕原因是下面这段推理:proto3 里字段没有出现在消息里时,反序列化出来的是零值,枚举就是 0;0 如果是任何一个有意义的值,老数据就会被误判。(零值的解释是这里补的。)
go
package main
import "fmt"
// 枚举 0 值坑:老数据没有 gender 字段,反序列化出来就是 0。
// 0 必须是 Unknown,不能是任何一个有意义的枚举值,否则老数据会被误判。
type Gender int32
const (
GenderUnknown Gender = 0
GenderFemale Gender = 1
GenderMale Gender = 2
)
type User struct {
Name string
Gender Gender
}
func main() {
var old User = User{Name: "老数据"}
fmt.Printf("老数据 gender=%d(Unknown,不是女也不是男)\n", old.Gender)
if old.Gender == GenderUnknown {
fmt.Println("必须按未知处理:老版本的消息没有这个字段")
}
}text
老数据 gender=0(Unknown,不是女也不是男)
必须按未知处理:老版本的消息没有这个字段- 生成的服务端接口强制组合一个空实现(
mustEmbedUnimplemented...Server)。目的同样是向后兼容:接口以后加方法时,老实现因为组合了空实现而仍然编译通过,不会让所有存量服务一夜之间编译失败。〔W10-1 p49〕
API 定义放哪也是拆分期的决定,三种方案:公司级统一 API 仓库(团队 30 人以内推荐,课程采用)、各服务内部维护 API 目录、实现仓库与 API 仓库分离(巨型应用)。〔W10-3 p7〕protoc 插件难管理、命令行参数长,用 buf 配 buf.gen.yaml 统一生成,go_package_prefix 免去每个文件写长路径。〔W10-3 p10-11〕
gRPC、HTTP、定时任务是同一层
服务端实现里有一个值得记住的结构原则:gRPC、定时任务(Job)、Web 是同一个级别的东西,都是"服务对外的表现形式":Job 以定时任务的形式暴露,Web 以 HTTP 接口的形式暴露,gRPC 以 RPC 接口的形式暴露。〔W10-3 p13〕
这个原则的价值要用代码看。只要本地的 Service 抽象得好,gRPC 服务端只是"组合 Unimplemented...Server 加持有原来的 Service",翻译一下请求参数再调 Service,Service 本身零修改。〔W10-3 p12〕
go
package main
import "fmt"
// 同一个业务接口,三个外壳:HTTP handler、定时任务、gRPC server。
// Service 抽象得好,gRPC 服务端只是"组合 Unimplemented...Server + 持有原 Service",
// Service 本身零修改。这是拆分能顺利进行的代码结构前提。
type InteractiveService struct{}
func (s *InteractiveService) IncrReadCnt(biz string, id int64) error {
fmt.Printf("service: %s/%d +1\n", biz, id)
return nil
}
// gRPC 外壳:翻译 protobuf 请求再调 Service
type GrpcServer struct {
svc *InteractiveService
}
func (g *GrpcServer) IncrReadCnt(req map[string]any) error {
biz, _ := req["biz"].(string)
id, _ := req["id"].(int64)
return g.svc.IncrReadCnt(biz, id)
}
// 定时任务外壳:同层的另一种"服务对外表现形式"
type Job struct {
svc *InteractiveService
}
func (j *Job) Run() { _ = j.svc.IncrReadCnt("article", 8) }
func main() {
svc := &InteractiveService{}
server := &GrpcServer{svc: svc}
_ = server.IncrReadCnt(map[string]any{"biz": "article", "id": int64(7)})
(&Job{svc: svc}).Run()
}text
service: article/7 +1
service: article/8 +1如果你的抽象没做好,这一步就会要求修改 Service 的实现,那是危险信号:先回头把本地接口整理干净,再拆进程。〔W10-3 p12〕04 章的"接口写在使用方""包装器、适配器"在这一步全部派上用场。
框架选型顺便带过:功能性上各框架差距不大,成熟度上 gRPC 最好(生态、搜索答案、招人),非功能性(性能)中小公司基本不用考虑;go-zero 集成 Web 加微服务(底层也是 gRPC),Dubbo-go 能和 Java 的 Dubbo 互通,go-micro 简单易用。遇事不决选 gRPC。〔W10-1 p26〕〔W10-3 p4-5〕
四、拆分的关键一步:灰度切流量
八步里最危险的是第六步。做法综合了两个 04 章讲过的模式:适配器把本地 Service 适配成 gRPC 客户端的形态,让两种实现类型一致(这些代码是临时的,拆完就删);流量调度装饰器把两个实现包在一起,用阈值决定这次调用走哪条路径。〔W10-3 p22-23〕
调度算法本身简单得可以写在一张便签上:产生一个随机数,随机数小于阈值就走远程,否则走本地。〔W10-3 p24〕下面的程序把阈值从 0 一路推到 100 再拉回 0,每个阈值打一万次请求,看远程占比如何跟随:
go
package main
import (
"context"
"fmt"
"math/rand"
"sync/atomic"
)
// Interaction 两条路径共同满足的接口。本地实现和"伪装成 gRPC 客户端的适配器"
// 都实现它,调用方 Switch 只认接口。
type Interaction interface {
Like(ctx context.Context, biz string, id int64) (string, error)
}
// Local 本地调用路径(原单体实现)
type Local struct{ n *int32 }
func (l *Local) Like(ctx context.Context, biz string, id int64) (string, error) {
atomic.AddInt32(l.n, 1)
return "local", nil
}
// Remote 远程调用路径(真实场景是 gRPC 客户端)
type Remote struct{ n *int32 }
func (r *Remote) Like(ctx context.Context, biz string, id int64) (string, error) {
atomic.AddInt32(r.n, 1)
return "remote", nil
}
// Switch 流量调度装饰器:随机数小于阈值走远程,否则走本地。
// 阈值放原子变量里,配置中心热更它就等于远程灰度旋钮;归零等于一键回滚。
type Switch struct {
remote, local Interaction
threshold atomic.Int32 // [0,100)
rnd func(int) int
}
func (s *Switch) Like(ctx context.Context, biz string, id int64) (string, error) {
if int32(s.rnd(100)) < s.threshold.Load() {
return s.remote.Like(ctx, biz, id)
}
return s.local.Like(ctx, biz, id)
}
func main() {
var ln, rn int32
// 演示用固定种子让输出可复现;线上用 math/rand 或业务 ID 哈希
rr := rand.New(rand.NewSource(7))
sw := &Switch{
remote: &Remote{n: &rn},
local: &Local{n: &ln},
rnd: rr.Intn,
}
// 灰度节奏:0 起步 -> 放 10% -> 放一半 -> 全量 -> 回零(一键回滚)
for _, th := range []int32{0, 10, 50, 100, 0} {
sw.threshold.Store(th)
ln, rn = 0, 0
for i := 0; i < 10000; i++ {
_, _ = sw.Like(context.Background(), "article", 1)
}
fmt.Printf("threshold=%3d remote=%4.1f%%\n", th, float64(rn)/100)
}
}text
threshold= 0 remote= 0.0%
threshold= 10 remote=10.0%
threshold= 50 remote=49.7%
threshold=100 remote=100.0%
threshold= 0 remote= 0.0%remote 列紧紧咬住 threshold 列。最后一行 threshold=0 是回滚的现场演示:不管远程出了什么事,阈值归零,流量立刻全部回到老路径。这就是"出 bug 了怎么办"的答案在代码里的样子。〔W10-3 p24〕
配套的三件事:
- 阈值热更:阈值放在原子变量里,初始化客户端的地方监听配置变更,变更时更新阈值。配置解析失败只记日志、不中断执行,用老阈值继续跑。〔W10-3 p25〕
- 随机数换成业务 ID 哈希:材料只说可以考虑用业务 ID 的哈希取代随机数。〔W10-3 p28〕
hash(ID) % 100让同一个用户永远命中同一个桶,稳定走同一路径;缓存友好、排查可复现,是这里补的推理。 - 发布新功能同理:这套"阈值加随机数(或哈希)、默认全走老路径、逐步放大、出问题立刻归零、配置中心同步到所有节点"的机制,就是最通用的灰度方案,发布和重构都能用。〔W10-3 p28〕
go
package main
import "fmt"
// 用业务 ID 的哈希代替随机数做灰度:同一用户永远命中同一个桶。
// 好处:同一用户稳定走同一路径(缓存友好、可复现、排查有依据)。
// 这里手写 FNV-1a,不引入依赖。
func bucketOf(id string) uint32 {
const fnvOffset32 = 2166136261
const fnvPrime32 = 16777619
h := uint32(fnvOffset32)
for _, c := range []byte(id) {
h ^= uint32(c)
h *= fnvPrime32
}
return h % 100
}
func main() {
for _, id := range []string{"u1", "u2", "u3", "u4"} {
b := bucketOf(id)
path := "local"
if b < 30 {
path = "remote"
}
fmt.Printf("%s -> bucket=%2d -> %s\n", id, b, path)
}
fmt.Printf("u3 再算一遍: bucket=%2d, 与上次一致\n", bucketOf("u3"))
}text
u1 -> bucket=35 -> local
u2 -> bucket=54 -> local
u3 -> bucket=73 -> local
u4 -> bucket=92 -> local
u3 再算一遍: bucket=73, 与上次一致"怎么保证拆分没引入 bug"的诚实答案是:保证不了。完善的测试能显著降低概率,线上靠灰度兜底,两边是配合关系而不是替代关系。〔W10-3 p30〕
写路径有一个双调特有的坑要单独说:读路径两边同时跑没有一致性问题(各读各的),写路径两边同时写就会产生两份各自增长的计数。所以要么只对读路径灰度,要么写路径以一边为权威、另一边只做影子校验,幂等键去重保住"业务上只算一次"。(这一段是本节补的推导,材料没讲双写问题;判断题 1 会回到这里。)
五、拆完之后:治理三件事怎么协同
拆开之后,调用方面对的第一个问题就是"这次调哪个实例"。发现、负载、容错三件事是联动的,不是孤立的:发现(注册中心里有哪些实例,见 20)、负载(在实例里选一个,见 20)、容错(选的实例坏了怎么办,见 16)。(三分法是本节的归纳,联动的证据是下面材料里的两个做法。)
联动的典型场景是熔断反馈到负载均衡:客户端从响应里发现服务端触发了熔断或限流,一段时间内不再选中这个节点,等于把 failover 做进了负载均衡。资料作业里的做法是收到这类响应直接把节点权重调到极低,后续 Pick 就不会再选它。〔W13-2 p35〕更主动的做法是客户端的 failover:重试加负载均衡,调用不通就换节点再试,round_robin 加 retryPolicy 是最简单的落地。〔W13-2 p29-30〕
故障处理的理论框架在拆分后要重新落地一遍:预防(限流,不管有没有故障,到阈值就限)、检测(静态阈值或动态指标)、处理(同步转异步、执行特殊代码、请求转发,熔断限流降级本质上都是这三种)、恢复(试探加逐步放量,一把放开会抖动)。〔W14-1 p5-9〕16 章已经有完整实现,这里只补一个拆分视角:这些机制在单体里是"一个进程内部的事",拆分后每一跳的调用方都要各自配一遍,漏配一跳,那一跳就是放大器(本节开头演示过)。
链路超时:预算随请求递减
链路超时是拆分后典型的新问题。A 调 B 调 C,如果每一跳各自设 1 秒超时,最坏情况整条链路要等 3 秒才失败,上游早就没耐心了。正确模型是整条链路共用一个总预算:A 的预算 1 秒,A 自己干了 300ms 的活,B 拿到的就是剩余的不到 700ms,B 再传给 C 的又是 B 剩下的。〔W14-2 p19〕
gRPC 自动做这件事:服务端收到请求时 ctx 里已经是剩余时间,零配置。〔W14-2 p20〕(deadline 随请求元数据传到服务端,是 gRPC 的实现方式,材料没展开。)下面的程序用 ctx.Deadline 手动演示这个语义:
go
package main
import (
"context"
"fmt"
"time"
)
// 链路超时:A 调 B,共享一个 1s 总预算。A 干了 300ms 的活,B 拿到的是剩余的约 0.6s。
// gRPC 自动把 deadline 编进请求元数据传给下游;这里用 ctx.Deadline 手动演示这个语义。
func callB(ctx context.Context, cost time.Duration) error {
ddl, ok := ctx.Deadline()
if !ok {
return fmt.Errorf("B: 没有 deadline")
}
rest := ddl.Sub(time.Now()).Milliseconds()
fmt.Printf("B 拿到的剩余预算: %.1fs\n", float64(rest/100)/10)
select {
case <-time.After(cost):
return nil
case <-ctx.Done():
return ctx.Err()
}
}
func main() {
ctx, cancel := context.WithTimeout(context.Background(), 1000*time.Millisecond)
defer cancel()
time.Sleep(300 * time.Millisecond) // A 自己的耗时
if err := callB(ctx, 800*time.Millisecond); err != nil {
fmt.Println("B 失败:", err)
}
}text
B 拿到的剩余预算: 0.6s
B 失败: context deadline exceededB 的剩余预算不到 0.7s(程序按 100ms 取整并截断,所以打印 0.6s),要 0.8s 才能干完活,于是超时:链路上没有任何一段单独超时,是总预算不够了。16 章讲过 context 的 deadline 取父子中较小的,想放宽不可能;gRPC 的贡献只是把这个语义跨进程延续下去。
可观测性:拦截器是统一入口
单体里可观测性接在 Web 中间件上;拆分后每一次 RPC 都是跨进程边界,接入点统一换成 gRPC 拦截器。三件套都从拦截器进:日志(客户端信息、调用方法、错误码、耗时)、链路追踪(每个 RPC 建 span,错误记录进去)、Prometheus 指标(按服务、方法、对端、错误码打标签的 SummaryVec)。〔W14-2 p24-26〕15 章的中间件骨架原样平移,只是签名从 func(http.Handler) http.Handler 换成了 grpc.UnaryServerInterceptor。
六、日常工作多了什么
拆分完成不等于事情变少,开发者的日常多了这些:
- 每个服务一套 main、装配、配置。原来一个
main函数管全部,现在每个服务有自己的main、wire 文件和配置文件,启动时要注意工作目录与配置路径,配错了服务起不来或连错库。〔W10-3 p16-18〕 - 集成测试跟着搬:测试从统一目录挪进模块,测试目标从本地实现改为 gRPC 服务端,断言改成 gRPC 返回的错误和 protobuf 响应。测试也要有自己的 IoC 装配(比主应用简单,模块少)。〔W10-3 p14-15〕
- 开发环境变复杂:要求每个开发把所有依赖的服务都起起来不现实,常见形态是单一测试环境(测试开发共用)、测试环境加一个开发环境、测试环境加多个开发环境(按功能分)。〔W10-3 p6〕
- 跨服务调用需要一个聚合层:
ArticleHandler这类直接持有另一个服务客户端的结构,把帖子数据和互动数据聚合后再返回。材料的说法是把 ArticleHandler 看作聚合服务本身,勉强可以算 DDD 里的 Application Service。〔W10-3 p21〕 - 测试覆盖率的新含义:现在"用了点赞收藏服务的代码"也要覆盖到 80%,因为那是你的调用链。〔W10-2 p14〕
小结
- 微服务是分而治之的一种实现:合并系统复杂度大于两者之和,多出来的是交互成本;拆分把交互成本换成明确的 API 边界。〔W10-1 p5-6〕
- 拆分标准不是技术崇拜,是一个人能不能掌握服务的全部细节;粒度考虑团队(两个披萨约 9 人)。〔W10-2 p4〕
- 路线是先模块化再进程化,四个阶段步步为营;单个模块八步,第六步(双调切流量)是核心。〔W10-2 p5〕〔W10-2 p7〕
- 先拆业务影响小、依赖少、QPS 低的模块;重构必然引入 bug,问"出了怎么办",用回滚设计兜底。〔W10-2 p12〕〔W10-2 p8〕
- 补测试是第一步:覆盖 80% 起步,集成测试比单元测试重要,代码全覆盖不等于业务全覆盖。〔W10-2 p13-14〕
- Protobuf 兼容三规则:编号不冲突不复用、
enum0 值留 Unknown、服务端强制组合Unimplemented...Server。〔W10-1 p35〕〔W10-1 p39〕〔W10-1 p49〕 - gRPC、HTTP、定时任务是同一层外壳;本地 Service 抽象好,gRPC 服务端只是翻译参数。〔W10-3 p12-13〕
- 灰度切流量:适配器统一类型,装饰器加阈值加随机数(或业务哈希),默认走老路径,出问题归零即回滚〔W10-3 p24〕〔W10-3 p28〕;写路径只能有一个权威(本节补充的推导)。
- 拆完后治理三件事(发现、负载、容错)联动,熔断限流响应要反馈到负载均衡;链路超时是总预算递减,gRPC 自动传播 deadline。〔W13-2 p35〕〔W14-2 p19-20〕
常见坑
- 一上来就拆进程:没有模块化、没有测试覆盖,拆完无法回滚。先模块化再进程化。〔W10-2 p5〕
- 先拆最核心的模块:用户、短信这类核心依赖、热榜这类高 QPS 应该最后拆;先拆崩了无所谓的。〔W10-2 p12〕
- 只想"不出 bug"没有回滚设计:双调开关和可归零的阈值是底线,不是可选项。〔W10-2 p8〕
- 模块化后仍共享内部包:事件定义不复制、消费者引用主应用内部包,名义上拆了、实际互相编译依赖。〔W10-2 p22〕
- protobuf 字段编号复用:新老版本混跑时按编号错位读数据。下线字段用
reserved占住编号。〔W10-1 p35〕 - 把
enum的 0 当有意义的值:老数据反序列化出来全是 0,会被误判成那个"有意义"的值。〔W10-1 p39〕 - 所有治理靠框架默认值:gRPC 默认负载均衡是
pick_first,永远只打第一个节点(见 20);限流熔断不配就是裸奔。〔W13-2 p4〕 - 写路径双调两边都写:两边各自增长的不一致计数,写路径的权威只能有一个。
判断题与答案
先自己答,再看后面。
- 双调阶段的写操作(如
Like)如果同时落到本地和远程两边会怎样?灰度切流量时怎样保证写路径只有一个权威? - 灰度阈值用业务 ID 哈希代替随机数,对缓存命中率和问题排查各有什么影响?
- 拆分后调用链上每一跳都有重试,一次失败最多会放大成多少次实际调用?(对照 16 节)
- 服务端实现里,gRPC、HTTP、定时任务为什么是"同一层"?
答案
- 两边各写一份,出现两份各自增长、互不一致的计数,切到 100% 远程时本地那份作废,期间的增量丢不丢看迁移方案。保证唯一权威的做法:写路径不灰度(要么全本地要么全远程),或者以一边为权威、另一边影子跑再用幂等键去重。
- 哈希让同一用户稳定命中同一个桶,用户维度的缓存(如"该用户点赞过哪些帖子")不会被灰度切换打穿;排查问题时同一用户行为可复现,不用反复碰随机数。代价是切比例时流量按用户整体迁移,瞬时分布不如下一跳平滑,但可接受。
- 每一跳最多重试 N 次就是 N 倍,两跳各重试 2 次,最坏放大 4 倍(
2×2),链路越长乘积越大;这正是 16 章要求重试配退避、用户体验类错误不重试的原因,也是限流阈值要按放大后的真实负载定的原因。 - 因为它们都是"服务对外的表现形式",只是暴露协议不同:定时任务暴露给调度器,HTTP 暴露给浏览器,gRPC 暴露给其他服务。Service 层不感知外壳,所以同一份业务逻辑可以被三种方式复用,gRPC 服务端只是多一层参数翻译。
在项目里
- 同层多形态的微缩版:
dnsproxi-go/tcp/server.go里 UDP 路径和 TCP 路径(Serve)共用同一个handleQuery,TCP 侧靠streamConn把连接适配成net.PacketConn。换传输协议不用改业务,就是"本地抽象好,对外形式只是外壳"的缩影。 - 聚合层:minifeed 的
server.go里server结构体同时持有post.PostStore、counter.Counter、rank.Rank,handleGetPost聚合多源数据返回,对应第六节"Handler 层聚合"的单机形态;rankSource组装计数与帖子标题对应资料里"在 Repository 聚合"的作业形态。 - 灰度与治理:真正的服务拆分与灰度演练在 minifeed 的项目线里(25 节任务表第 8 项:把 feed 拆成独立进程,开关切流量可回滚)。限流熔断装饰器的完整可运行版本在 16 章。
想深入:正文之外的干货
原材料里还有一批正文没展开、但真拆的时候会撞上的知识,按单元收在这里。负载均衡的完整展开在 20 节,熔断限流降级的完整实现在 16 节,这里只收拆分视角下要用的部分。
W10-1 拆分边界:HTTP 还是 RPC,DDD 怎么用
- 微服务不绑死 RPC。HTTP 微服务运维简单、组件少、门槛低、兼容性好、适合异构;RPC 高效但组件多、运维复杂,协议不合适就没法异构通信。外企和历史系统走 HTTP 的不少,先看团队和异构需求,再谈框架选型。〔W10-1 p11〕
- REST 和微服务是两回事:REST 是接口风格,微服务是架构,两者没特别大的关系。RESTful 可以直接当通信机制用,也能再包一层封成 RPC。〔W10-1 p12〕
- DDD 八个概念里,拆分最该吃透的是四个。限界上下文:同一业务模型在不同场景特征不同(销售眼中的产品和售后眼中的产品不是一个模型),它就是微服务边界的描述,常常与组织架构重合,黑白地带是难点。〔W10-1 p16〕实体:唯一性靠 ID 不靠属性(邮箱唯一但能改,不算),实体必有表,表不一定都是实体。〔W10-1 p17〕值对象:没有 ID,由属性集合标识(价格、金额),一个领域的实体到另一个领域常降级成值对象。〔W10-1 p18〕聚合体:聚合根实体加 N 个值对象,聚合体之间只能 ID 引用,聚合根是修改的唯一入口(order.SetXXX 这种写法)。〔W10-1 p19〕
- 剩下四个一句话带过:工厂把构造与业务逻辑解耦,作者观点用 Builder 也行;仓库是聚合体的增删改查,屏蔽缓存与 DB;领域事件是系统发生的事(多为聚合体数据变化),可以套 MQ 的皮,发出去的消息就是事件;业务逻辑尽量放实体和值对象,跨聚合逻辑才放 Domain Service,CRUD 项目里它几乎没代码。〔W10-1 p20-23〕
- proto 落地两件小事:
service加rpc关键字定义服务,方法入参出参都是 message;go_package必须带.或/,只写 "grpc" 编不过。〔W10-1 p33-35〕〔W10-1 p40〕 - 工具链是一条命令:protoc 同时带
--go_out与--go-grpc_out两组 out 和 opt(都用 source_relative 相对 proto 文件输出),插件 protoc-gen-go 与 protoc-gen-go-grpc 放进 GOPATH/bin;产物固定两个文件,纯结构体的pb.go和带 gRPC 存根的_grpc.pb.go。〔W10-1 p41-45〕 - 生成代码的三个细节:optional 字段生成指针;oneof 生成 isXxx 接口加两个实现,用的时候类型断言,Go 没有联合类型所以丑,课程作者基本不用;state/sizeCache/unknownFields 是内部字段,不用管。〔W10-1 p46-47〕
- 两端代码的样子:服务端 grpc.NewServer、Register、net.Listen、Serve 四步;客户端接口每个方法都是 (ctx, in, ...grpc.CallOption) 返回 (resp, error);grpc.Dial 是惰性连接,Dial 不建连、第一次 RPC 才连,测试里 ctx 超时要覆盖调用全程。〔W10-1 p48〕〔W10-1 p50-52〕
- Protobuf 的性能优势有数:材料给的对比是序列化 0.7ms/0.4MB 对 JSON 1.7 到 4.3ms/1.9 到 2.8MB,反序列化 0.8ms 对 2.6 到 6.5ms;同量级数据快几倍、体积小几倍。〔W10-1 p29-31〕
W10-2 模块化实操的三个翻车点
- 只拆代码不迁数据时的验证方案:过全部测试即认为没问题,数据库拆分时再设计迁移验证;前提是覆盖率、核心逻辑、主要异常都有覆盖。〔W10-2 p9〕
- 治理范畴里还有两项正文没点名的:隔离(核心业务与边缘业务的资源隔离,出事不连坐)和分组路由(按调用方或业务分组调度流量),它们是 20 章负载均衡的延伸话题。〔W14-1 p4〕
- 限流的对象要先想清楚再写代码:单机还是集群、整个应用还是某服务某接口、按用户(VIP 不限流)还是按 IP;对象选错,阈值和算法都白配。〔W14-1 p17〕
- dao 的 InitTables(AutoMigrate 建表)必须随模块挪走,wire 与集成测试的 startup 同步调整;init 表结构挪走后启动 panic 是最典型的翻车点。〔W10-2 p21〕
- wire_gen 有两份:main 启动一份、集成测试一份,都要重新生成,漏一份要到启动期才暴露。〔W10-2 p23〕
- 模块化挪目录的实操要点:用 IDE 的重构功能挪代码,每挪一步就跑一次测试,不要攒到最后一起验;配合本节正文的重构点清单,漏项能当场暴露。〔W10-2 p20〕
W10-3 契约与聚合的补充
- Interactive 的 proto 是"参考本地 service 接口直接翻译成 rpc 定义"来的(IncrReadCnt、Like、CancelLike、Collect、Get、GetByIds),package 可以不配;先有好的本地抽象再写 IDL,顺序不能反。〔W10-3 p9〕
- 聚合有两种落点:Handler 直接持有别的服务客户端(实用主义,勉强算 Application Service),或 Repository 聚合(把 Interactive 的 gRPC 客户端组装进 ArticleRepository,更符合 DDD);权衡是组装复杂度对领域纯度,材料作业让两种都做。〔W10-3 p21〕〔W10-3 p32〕
- 灰度生效的验证手段:改配置里的 Threshold 直接观察,或用 wrk 长压测看流量分布。〔W10-3 p26〕
W13-2 治理联动的底层:负载均衡与 failover
- gRPC 默认没有负载均衡:pick_first 永远打 Resolver 解析出的第一个节点,双端口实验里只有 8090 挨打;单节点加 K8s Service 场景下它反而是正确行为,多实例必须显式指定 round_robin。〔W13-2 p4〕
- round_robin 靠 WithDefaultServiceConfig 传一段 JSON 指定;JSON 手写易错,写错一个引号配置静默失效、悄悄退回 pick_first,建议放进配置文件加单测校验。〔W13-2 p5〕
- 加权算法的接入方式是匿名引入 balancer/weightedroundrobin 触发 init 注册(名字小写进 map),这是 Go 生态常见的注册模式;官方 WRR 不是服务端配权重,客户端按 QPS 与错误率自算,用 EDF 挑选。〔W13-2 p7-9〕
- 自定义负载均衡只需两个接口:PickerBuilder 的 Build 造 Picker,Picker 的 Pick 返回 PickResult;顶层的 Balancer 接口要自己管连接池状态,逼不得已才碰。〔W13-2 p11-12〕
- 权重传递链:服务端注册时把 weight 写进注册中心 Metadata,客户端服务发现时拿到;Metadata 是弃用字段,有的实现放 Address.Attributes,经序列化类型还可能变 string,写 PickerBuilder 必须确认取到的类型。〔W13-2 p12-13〕〔W13-2 p15〕
- 平滑 WRR 的骨架:每个连接存 currentWeight,每轮全体加上自身权重、选最大者、选中者减去总权重;PickResult.Done 回调拿到错误与延迟,是动态升/降权的挂载点。〔W13-2 p14〕
- failover 的两个齿轮缺一不可:只有重试会反复撞死节点,只有 LB 剔除则单次失败就丢请求;retryPolicy 要配 maxAttempts、initialBackoff/maxBackoff/backoffMultiplier(指数退避)和 retryableStatusCodes(必须含 UNAVAILABLE),才会真正换节点重试。〔W13-2 p29-30〕
- 哈希负载均衡和本地缓存是配套设计:本地缓存多实例部署时,命中无缓存节点就要重复加载、内存翻倍;按业务主键哈希保证同一个 key 必命中同一个节点。〔W13-2 p34〕
- 客户端判定对端负载的三个信息来源:服务端把指标上报注册中心、响应携带负载信息、Prometheus 轮询采集。〔W13-2 p36〕
- 框架现状:Kratos 默认平滑 WRR,负载均衡封装成 Selector(传 filter 过滤节点,分组路由统一抽象);go-zero 写死 P2C(随机选 2 个节点挑负载低的);内置算法里除 round_robin 外没必要用。〔W13-2 p10〕〔W13-2 p21-24〕
W14-1 故障处理框架的落地细节
- 治理范畴里还有两项正文没点名的:隔离(核心业务与边缘业务的资源隔离,出事不连坐)和分组路由(按调用方或业务分组调度流量),它们是 20 章负载均衡的延伸话题。〔W14-1 p4〕
- 熔断限流降级是同一个"故障处理"模型的三个参数化:触发条件不同、处理动作不同(返回 error、返回默认值或快路径、转发);理解这一点才讲得清三者边界。〔W14-1 p7-8〕
- 熔断三态:开放(全拒)、闭合(全正常)、半开放(一部分试探);半开就是恢复中。熔断破坏性强(全拒),使用频率是限流大于降级大于熔断。〔W14-1 p12-13〕〔W14-1 p28〕
- 降级是"尽力给响应":预配默认响应,或快慢路径分离(快路径资源少计算快),降级后只走快路径;缓存降级的标准方案是降级期只查 Redis 不碰 DB,负载骤降还能撑高并发,代价是恢复时 DB 要承接积压回写,得配合放量。〔W14-1 p14-15〕
- 被限流的请求三个去处:同步转异步、返回默认值、通知客户端换节点重试;限流熔断降级的界限本来就不分明。〔W14-1 p18〕
- 限流先定对象再定算法:单机还是集群、整个应用还是某服务某接口、业务维度(VIP 不限、按 IP);算法清单(计数器、固定窗口、滑动窗口、令牌桶、漏桶)效果差不多,随便选。触发条件上限流和熔断不同:它是"判定系统无法处理更多请求"就执行,不依赖故障发生,平时就在生效,多为静态阈值。〔W14-1 p17〕〔W14-1 p19-20〕
- 计数器和窗口限的不是同一维度:计数器限在途并发数,窗口类限 QPS;固定窗口在边界两侧各满额时瞬间放行 2 倍流量,Redis INCR 加 EXPIRE 默认就是固定窗口,要警惕。〔W14-1 p21〕〔W14-1 p24〕
- 令牌桶允许积压(攒令牌扛突发),漏桶绝对均匀;突发流量是常态,生产多用令牌桶,漏桶只在下游要求严格匀速(如第三方配额接口)时必要。〔W14-1 p25-27〕
- 恢复比触发难:立即全量放开会抖动反复;通用解是间隔试探一个请求,正常就加大流量,失败就减回去,直到 100%,可直接复用第四节的随机数加阈值方案。〔W14-1 p8-9〕
W14-2 gRPC 接入治理的细节
- 拦截器就是 gRPC 版 middleware:UnaryServerInterceptor 四参数 (ctx, req, info, handler),info 里拿 FullMethod 和服务实现;多个拦截器用 grpc.ChainUnaryInterceptor 链式注册。〔W14-2 p5-7〕
- 限流分三级:应用级 key 固定(普通和 VIP 一起限)、服务级用 HasPrefix 匹配 FullMethod、业务级类型断言 req 取业务 key(缺陷是所有请求都进断言但只有部分被限,白费 CPU);单方法专有限流用装饰器组合 Server 只重写一个方法,不污染别的业务。〔W14-2 p8-11〕
- 被限流必须返回语义化错误码 ResourceExhausted:客户端 LB 靠它区分"被限流"与"真失败",这是降权的依据;统一返回 Internal 会误导容错逻辑。〔W14-2 p8〕〔W14-2 p32〕
- 限流器自己出错时是 fail-close 还是 fail-open:下单类保守直接拒,展示类放行;课程选 fail-close。〔W14-2 p8〕
- 熔断不用手搓:go-kratos/aegis 子项目独立可用(限流 BBR 加熔断),封装成拦截器;Allow 返回 error 时也要 MarkFailed。〔W14-2 p12〕
- 降级没法做通用:降不降、怎么降、降到哪都业务强相关;可行方案是限流器或熔断器判定后打标记位,业务看标记走特殊路径(限流触发后缓存未命中直接返回,快速清空在途请求)。〔W14-2 p13〕
- 限流阈值从哪来:压测找 A(响应稳定、性能最好)、B(将崩临界)、C(吞吐最高)三点,默认选 A,B、C 重合时无脑 C;没条件压测就线上观测、参考类似接口或手算(单请求 DB 查询 20ms、4 核,1000/20×4=200 QPS),估值上调两到五成留余量。〔W14-2 p14-15〕
- 单次调用的超时值:理论由产品按用户体验定,实操取 999 线(1s 是用户明显卡顿的节点)或手动计算;首页要压进 100ms。〔W14-2 p21〕
以下有意省略:各单元的面试话术与职场心得(W10-2 p27、W13-2 p40、W14-2 p30),W14-1 的跨服务降级分层与动态综合判定(宣讲向),W13-2 的 rls/grpclb 内置算法盘点(实验或已弃用),以及各单元的练习题与 Rust/Java 对照。各单元的总览页(W10-1 p2、W10-2 p2、W10-3 p2、W14-1 p2)没有独立干货,内容已逐条落进正文一至五节,引用见正文;本节材料单元 W14-1 p4、p17 与 W10-2 p20 的补条在上面。
想深入去哪查
- 原材料:W10-1(微服务概念、DDD、gRPC 与 Protobuf)、W10-2(拆分路线、八步、补测试、模块化)、W10-3(框架选型、API 管理、灰度调度装饰器)、W14-1(故障处理理论)、W14-2(链路超时、拦截器可观测)、W13-2(failover 与熔断联动)。
- 旧章:
notes/p10-grpc.md(gRPC 四种流和拦截器基础)。 - 灰度调度装饰器与断路器示例在
/tmp的独立模块里跑过go vet和go run;真实场景里Remote对应 gRPC 客户端适配,threshold由配置中心或 viper 热更。