学习目标
学完本章,你应该能够:
- 说清微服务架构的动机,并讲明单体应用、模块化单体、微服务三者的差异。
- 讲清 RPC、gRPC、Protobuf 的基本概念,能独立写出一个 gRPC 服务端与客户端。
- 解释 DDD 中的限界上下文、聚合体、Repository 等核心概念,并说明它们和微服务边界的关系。
- 复述单体应用拆分微服务的完整路线图:混沌一片 → 模块化 → 模块依赖化 → 微服务化。
- 设计一套上线灰度方案:本地调用与 gRPC 调用并行 + 流量阈值控制 + 回滚。
前置知识(如果下面任意一点生疏,先回看对应章):
- 第02章 Gin + GORM:知道一个服务的 Web/Service/Repository 分层。
- 第07章 Kafka:知道消息是怎么从生产者到消费者的(本章灰度切换会用到「配置中心动态下发」的思维)。
- 基本的 Go 接口、泛型与依赖注入(wire)写法。
- 一点 HTTP/gRPC 调试经验(如用 Postman 发请求)。
本章你会动手做的事:
- 写一个
interactive.proto,用 protoc 编译出.pb.go和_grpc.pb.go。 - 起一个 gRPC 服务端(监听某个端口),再用客户端连上它调一次
Like。 - 给你的客户端加一个「阈值 + 随机数」开关,把 5% 流量切到远程、其余走本地,体会灰度。
一、核心概念讲解
1.1 什么是微服务架构
微服务是一种架构风格,它把一个大型单体应用拆分为数个、数十个独立的服务,每个服务:
- 可独立开发、测试、部署、扩容;
- 自己拥有数据存储(理论上不应共享数据库);
- 通过轻量通信机制(HTTP / RPC / MQ)交互;
- 松耦合、自治、去中心化。
注意:架构有很多种,微服务只是一种。它不是银弹,而是"分而治之"思想在系统设计上的体现。
类比:单体应用像一个超大的厨房,洗菜、切菜、炒菜、装盘全在一个屋子里,厨师之间随时能喊话、随手拿对方的工具。微服务像是把厨房拆成「洗菜间」「切菜间」「炒菜间」几个独立小店,各干各的、通过传菜窗口(API)交接。好处是每个小店可以单独扩人手、单独关门维修;坏处是原来喊一嗓子就能办的事,现在要走流程窗口。
1.2 为什么要使用微服务——分而治之
直观解释:应用太复杂了,要想办法降低复杂度,降低到一个人能理解的地步。
假设两个系统的复杂度为 Sa 和 Sb,合并成一个系统后复杂度 S > Sa + Sb(多出来的部分来自模块间的相互交互)。拆分带来三方面好处:
- 总体复杂度降低;
- 单个模块复杂度变得可理解;
- 模块间仅通过 API 耦合,无需了解其他模块的内部细节。
flowchart LR
subgraph 单体[单体: 复杂度 S 很高]
M1[模块A] --- M2[模块B]
M2 --- M3[模块C]
M1 --- M3
end
subgraph 微服务[微服务: 各自可理解]
S1[服务A] -. API .- S2[服务B]
S2 -. API .- S3[服务C]
end1.3 模块化 vs 微服务化(高频面试题)
模块化本身也是分而治之,那为什么不能只做模块化?答案是不行,原因有四点:
| 维度 | 模块化单体 | 微服务化 |
|---|---|---|
| 部署 | 整个应用一起部署 | 每个服务独立部署 |
| 资源 | 整个应用需要的资源(如 16G 内存) | 单服务所需资源(如 4G) |
| 演进 | 强耦合,难以独立迭代 | 在 API 向后兼容下可独立演进 |
| 组织 | 一个团队维护一个庞大应用 | 一个团队对应一组服务(康威定律) |
运维视角的关键差异:实例才是最基本的运维单位。模块化的资源粒度是"整个单体",微服务的资源粒度是"单个服务"。
1.4 RPC、HTTP、RESTful、微服务——理清关系
很多面试者搞混这几个概念,要捋清楚:
- RPC(Remote Procedure Call):远程过程调用,是一种通信协议,让你像调用本地方法一样调用远端方法。RPC 可以基于 TCP(如 Dubbo)、HTTP(如 gRPC)、UDP、甚至消息队列。
- HTTP:一种应用层协议。可以用 HTTP 实现微服务通信,也可以在 HTTP 之上封装出 RPC 协议。
- RESTful:一种软件架构风格,符合 REST 风格的 Web 应用叫 RESTful Web。它和微服务没有直接关系,只是可以把它作为微服务的通信机制。
- 微服务:一种架构,对通信协议没有强制定义。
结论:微服务 ≠ 必须 RPC;可以 HTTP 通信,也可以 RPC 通信,还可以 MQ 通信。
基于 HTTP vs 基于 RPC 的微服务
| 方案 | 优点 | 缺点 |
|---|---|---|
| 基于 HTTP | 运维简单,组件少,对研发要求低,兼容异构系统 | 性能略低 |
| 基于 RPC(如 gRPC) | 性能高,跨语言,生态完善 | 运维复杂,对研发要求高 |
1.5 gRPC 与 Protobuf
gRPC 是 Google 开源的高性能 RPC 框架,“遇事不决选 gRPC"基本不会出错。
特点:
- 高性能:基于 HTTP/2 双向流,支持流控和压缩;
- 跨语言:主流语言都有实现,异构系统首选;
- 开源:社区强大,生态丰富。
gRPC 使用 IDL(接口描述语言) 来定义客户端和服务端之间的通信格式。Protobuf 就是 gRPC 选用的 IDL 落地语言。
Protobuf 的优势
- 高效:二进制格式,序列化/反序列化速度极快,压缩率高;
- 跨平台语言无关:支持 C/C++/Java/Python/Go 等;
- 扩展性强:可以灵活修改数据结构而不破坏兼容性;
- 工具链完善:编译器、代码生成器、调试工具齐全。
1.6 DDD 核心概念(拆分微服务的理论标准)
DDD(Domain Driven Design,领域驱动设计)是微服务拆分的理论依据。核心概念:
| 概念 | 说明 |
|---|---|
| 限界上下文 Bounded Context | 描述问题的上下文边界,对应微服务边界。同一个"产品"在销售和售后眼里特征不同。 |
| 实体 Entity | 有唯一标识符(ID),属性可变。如用户、订单。数据库的表不一定是实体。 |
| 值对象 Value Object | 没有唯一标识,由属性定义。如价格、金额。一个领域中的实体到另一个领域可能变成值对象。 |
| 聚合体 Aggregate | 实体 + N 个值对象的集合,实体是聚合根。聚合体之间只能通过 ID 引用。聚合根是修改聚合体的唯一入口。 |
| 工厂 Factory | 分离构造过程与业务逻辑。也可以用 Builder 替代。 |
| 仓库 Repository | 数据存储的抽象,屏蔽缓存/数据库差异,主要操作聚合体的增删改查。 |
| 领域事件 Domain Event | 系统中发生的事情,如订单状态变更。可发布到 MQ。 |
| 领域服务 Domain Service | 跨聚合体的业务逻辑。普通 CRUD 项目里通常没多少代码。 |
判定限界上下文的难点:有些业务确实处于黑白地带,放哪里都合适,需要业务理解。
flowchart TD
BC[限界上下文 = 一个微服务] --> AG[聚合体]
AG --> AR[聚合根 Entity]
AG --> VO1[值对象]
AG --> VO2[值对象]
AR -. 只通过 ID 引用 .-> AR2[另一个聚合根]
AR --> REP[(Repository 持久化)]二、代码实战
2.1 Protobuf 定义示例
文件 webook/api/proto/interactive/v1/interactive.proto:
// 声明使用 proto3 语法(除非维护老系统,否则都用 proto3)
syntax = "proto3";
// 生成 Go 代码时的包路径配置(必须包含 . 或 /)
option go_package = "gitee.com/curriculum/webook/api/proto/interactive/v1;interactivev1";
// 包名(可选,没什么实际作用)
package interactive.v1;
// 服务定义:相当于 Go 中的 interface
service InteractiveService {
// 每个方法:一个请求 message,一个响应 message
rpc Like(LikeRequest) returns (LikeResponse);
rpc CancelLike(CancelLikeRequest) returns (CancelLikeResponse);
rpc Collect(CollectRequest) returns (CollectResponse);
rpc Get(GetRequest) returns (GetResponse);
}
// 点赞请求
message LikeRequest {
// biz 标识业务类型,如 "article" 或 "comment"
string biz = 1;
int64 biz_id = 2;
int64 uid = 3;
}
message LikeResponse {}
message CancelLikeRequest {
string biz = 1;
int64 biz_id = 2;
int64 uid = 3;
}
message CancelLikeResponse {}
message CollectRequest {
string biz = 1;
int64 biz_id = 2;
int64 uid = 3;
int64 cid = 4; // 收藏夹 ID
}
message CollectResponse {}
// 互动数据
message Interactive {
int64 biz_id = 1;
int64 like_cnt = 2;
int64 collect_cnt = 3;
int64 view_cnt = 4;
bool liked = 5;
bool collected = 6;
}
message GetRequest {
string biz = 1;
int64 biz_id = 2;
int64 uid = 3;
}
message GetResponse {
Interactive intr = 1;
}
2.2 编译 Protobuf
直接用 protoc 命令:
# 安装 Go 和 gRPC 插件(一次性)
go install google.golang.org/protobuf/cmd/protoc-gen-go@v1.28
go install google.golang.org/grpc/cmd/protoc-gen-go-grpc@v1.2
# 务必把 $GOPATH/bin 加入 PATH
# 编译
protoc \
--go_out=. --go_opt=paths=source_relative \
--go-grpc_out=. --go-grpc_opt=paths=source_relative \
webook/api/proto/interactive/v1/interactive.proto
生成的产物:
interactive.pb.go:纯 Go 代码,主要是结构体定义;interactive_grpc.pb.go:gRPC 服务端和客户端代码。
推荐使用 buf 工具替代 protoc,能解决插件难管理、命令难记、目录定位复杂等问题。
2.3 实现 gRPC 服务端
// webook/internal/interactive/grpc/server.go
package grpc
import (
"context"
"gitee.com/curriculum/webook/api/proto/interactive/v1/interactivev1"
"gitee.com/curriculum/webook/internal/interactive/service"
)
// GRPCServer 是 gRPC 服务端实现
// 它组合了 protoc 生成的 UnimplementedInteractiveServiceServer
// 这是为了向前兼容:以后接口新增方法时不会编译失败
type GRPCServer struct {
interactivev1.UnimplementedInteractiveServiceServer
svc *service.InteractiveService
}
func NewGRPCServer(svc *service.InteractiveService) *GRPCServer {
return &GRPCServer{svc: svc}
}
// Like 实现点赞接口
// 方法签名固定:context.Context + 请求 message + error 返回值
func (s *GRPCServer) Like(ctx context.Context, req *interactivev1.LikeRequest) (
*interactivev1.LikeResponse, error) {
// 步骤 1:从请求取出参数
// 步骤 2:委托给领域服务处理业务逻辑
err := s.svc.Like(ctx, req.GetBiz(), req.GetBizId(), req.GetUid())
return &interactivev1.LikeResponse{}, err
}
func (s *GRPCServer) CancelLike(ctx context.Context, req *interactivev1.CancelLikeRequest) (
*interactivev1.CancelLikeResponse, error) {
err := s.svc.CancelLike(ctx, req.GetBiz(), req.GetBizId(), req.GetUid())
return &interactivev1.CancelLikeResponse{}, err
}
func (s *GRPCServer) Collect(ctx context.Context, req *interactivev1.CollectRequest) (
*interactivev1.CollectResponse, error) {
err := s.svc.Collect(ctx, req.GetBiz(), req.GetBizId(), req.GetUid(), req.GetCid())
return &interactivev1.CollectResponse{}, err
}
func (s *GRPCServer) Get(ctx context.Context, req *interactivev1.GetRequest) (
*interactivev1.GetResponse, error) {
intr, err := s.svc.Get(ctx, req.GetBiz(), req.GetBizId(), req.GetUid())
if err != nil {
return nil, err
}
return &interactivev1.GetResponse{
Intr: &interactivev1.Interactive{
BizId: intr.BizId,
LikeCnt: intr.LikeCnt,
CollectCnt: intr.CollectCnt,
ViewCnt: intr.ViewCnt,
Liked: intr.Liked,
Collected: intr.Collected,
},
}, nil
}
⚠️ 新手必踩的坑:没嵌入
UnimplementedInteractiveServiceServer。protoc 生成的接口里有一组「未实现」的嵌入类型。如果你不把它嵌入到自己的GRPCServer,将来 proto 里新增一个 RPC 方法,你的服务端结构体就满足不了新接口,编译直接失败。嵌入它,新增方法默认走「未实现」占位,编译照过——这就是向前兼容的关键。
2.4 启动 gRPC 服务端
// webook/internal/interactive/ioc/grpc.go
package ioc
import (
"google.golang.org/grpc"
)
func InitGRPCServer(server *grpcinterative.GRPCServer) *grpc.Server {
// 创建一个 gRPC Server
s := grpc.NewServer()
// 注册我们实现的 InteractiveServiceServer
interactivev1.RegisterInteractiveServiceServer(s, server)
return s
}
// webook/internal/interactive/main.go (节选)
package main
func main() {
// 创建监听端口
lis, err := net.Listen("tcp", ":8091")
if err != nil {
panic(err)
}
// 初始化依赖(用 wire 生成)
app := wirex.NewApp()
// 启动 gRPC Server
server := app.GRPCServer
log.Println("interactive gRPC listening on :8091")
if err = server.Serve(lis); err != nil {
panic(err)
}
}
2.5 gRPC 客户端
// webook/internal/web/client/interactive.go
package client
import (
"context"
"google.golang.org/grpc"
"google.golang.org/grpc/credentials/insecure"
"gitee.com/curriculum/webook/api/proto/interactive/v1/interactivev1"
)
type LocalInteractiveClientAdapter struct {
// 适配本地 service,伪装成 gRPC 客户端
// 微服务拆分完成后会删掉
svc *service.InteractiveService
}
type InteractiveClient struct {
remote interactivev1.InteractiveServiceClient // 真实 gRPC 客户端
local interactivev1.InteractiveServiceClient // 本地适配的 gRPC 客户端
threshold int32 // 流量阈值,0~100,表示走远程调用的概率百分比
}
// Get 根据随机数 + 阈值来决定走本地还是远程
func (c *InteractiveClient) Get(ctx context.Context, biz string, bizId, uid int64) (
*interactivev1.GetResponse, error) {
randNum := rand.Int31n(100)
if randNum < c.threshold {
// 走 gRPC 远程调用
return c.remote.Get(ctx, &interactivev1.GetRequest{
Biz: biz, BizId: bizId, Uid: uid,
})
}
// 走本地调用
return c.local.Get(ctx, &interactivev1.GetRequest{
Biz: biz, BizId: bizId, Uid: uid,
})
}
2.6 客户端初始化 + 监听配置变更
// webook/internal/web/ioc/interactive.go
func InitInteractiveClient(client *grpc.ClientConn, configClient *config.Client) *client.InteractiveClient {
// 真实 gRPC 客户端
remote := interactivev1.NewInteractiveServiceClient(client)
// 本地适配的 gRPC 客户端
local := NewLocalAdapter(svc)
res := &client.InteractiveClient{
Remote: remote,
Local: local,
}
// 监听配置变更,实时调整阈值
// 这是上线灰度的关键:通过配置中心动态调整流量比例
configClient.Subscribe("interactive.threshold", func(val string) {
threshold, _ := strconv.Atoi(val)
res.UpdateThreshold(int32(threshold))
})
return res
}
sequenceDiagram
participant C as Web 客户端
participant T as 阈值开关
participant R as 远程 gRPC
participant L as 本地 Service
C->>T: Get(biz,bizId,uid)
alt randNum < threshold
T->>R: 远程调用 interactive 服务
R-->>C: 返回
else 否则
T->>L: 本地调用
L-->>C: 返回
end⚠️ 新手必踩的坑:灰度开关只支持「随机」不支持「按业务 ID」。上面
rand.Int31n(100)是纯随机,同一个bizId这次走远程、下次走本地,排查问题时要同时看两个地方。生产进阶做法是「阈值 + 业务 ID 哈希」:同一个bizId永远走同一条路径,出问题能稳定复现。
三、单体拆分微服务路线图
3.1 四个阶段
| 阶段 | 关键动作 |
|---|---|
| 混沌一片 | 完善单元测试;抽取公共 utils/helper;引入聚合层解除模块间循环依赖;按业务对象划分模块包 |
| 模块化 | 创建不同代码仓库;准备微服务环境与框架选型;搭建 CI 和集成测试环境 |
| 模块依赖化 | 业务模块逐个服务化;搭建自动部署和回滚平台;调研服务治理、网关、MQ、分布式事务、分布式任务调度、可观测性 |
| 服务化 | 引入服务治理;引入网关;引入回归测试;按业务分库 |
flowchart LR
S1[混沌一片] --> S2[模块化]
S2 --> S3[模块依赖化]
S3 --> S4[服务化]3.2 拆分路线选型
有两条路线:
- 直接拆分某个模块为独立微服务:可提前验证全流程,但开弓没有回头箭。
- 全部模块按"模块化→模块依赖化→微服务化"分阶段推进:有后悔药,但无法提前验证。
本课程采用第一条路线,选择点赞/收藏模块(interactive)作为第一个拆分对象。
3.3 选择拆分模块的原则——先易后难
- 优先选业务影响小的:崩了也没大影响;
- 其次选最独立的:依赖少、被依赖少;
- 最后考虑 QPS 低的;
- 绝对不要选:用户、帖子、短信、热榜等核心服务。
重构心态:要考虑"出 BUG 怎么办”,而不是"怎么不出 BUG"。重构必然引入 BUG,再小心都会遇到。
3.4 详细迁移步骤
- 选定模块 A;
- 补充测试,覆盖率 ≥ 80%(业务覆盖比代码覆盖更重要);
- 代码拆分,命名为
webook-A; - 确定微服务框架技术选型;
- 将 A 改造为微服务,同时对外提供服务;
- 在原 webook 中同时使用本地调用和微服务调用,开关控制流量,允许回滚;
- 逐步调整流量,直到 100% 走微服务;
- 移除原 webook 中的本地调用,纯依赖微服务。
3.5 模块化执行——重构点梳理
以 interactive 模块为例,重构前要先列出受影响的所有文件:
- Web 层:article.go
- Service 层:article.go、interactive.go
- Domain 层:interactive.go
- Repository 层:interactive.go
- Cache 层:interactive.go
- DAO 层:interactive.go、init.go
- wire.go
- ioc:db.go
- events:article.go
最忌讳:没有分析重构点就直接动手,重构过程中才发现遗漏点,可能很难补救。
3.6 模块化步骤要点
- 把模块内的代码挪到独立目录(service/domain/repository 等);
- 解决数据库初始化(dao/init.go);
- 挪动 Kafka 消费者(注意:消息事件定义也要复制一份,不能再共享,否则模块化不彻底);
- 重新生成 wire 文件(main 启动 + 集成测试两处都要改);
- 运行集成测试验证。
模块化的目标:模块不再依赖 webook/internal 里的任何代码。
3.7 API 仓库管理方案
| 方案 | 适用场景 |
|---|---|
| 统一 API 仓库 | 小规模(≤30 人团队),所有微服务的 proto 放一处 |
| 各服务内部维护 API 目录 | 中等规模 |
| 每个服务有实现仓库 + API 仓库 | 巨型应用,团队组织合理 |
本课程采用方案一,目录结构:
webook/
├── api/
│ └── proto/
│ └── interactive/
│ └── v1/
│ ├── interactive.proto
│ └── gen/ # 编译产物
└── internal/
四、灰度上线方案(高频面试题)
4.1 为什么要"本地调用 + 微服务调用"并行
最大风险点在模块化和服务化这两个步骤。为避免出问题无法回滚:
- 引入"同时使用本地调用和微服务调用"的中间状态;
- 如果微服务调用出问题,立即把流量切回本地调用;
- 没问题就逐步加大微服务流量。
flowchart TD
Q[一次请求] --> T{randNum
小于阈值?}
T -- 是 走远程 --> R[调用 interactive 微服务]
T -- 否 走本地 --> L[调用本地 Service]
R --> OK[返回]
L --> OK
R -- 出错且阈值>0 --> RB[调低阈值回滚到本地]4.2 流量调度算法
最简单的方案:阈值 + 随机数
// 产生 0~99 的随机数
randNum := rand.Int31n(100)
if randNum < threshold {
// 走远程 gRPC 调用
} else {
// 走本地调用
}
进阶方案:阈值 + 业务 ID 哈希(保证同一业务数据走同一条路径,便于排查问题)。
4.3 流量调度完整流程
- 最开始阈值 = 0,所有流量走老路径;
- 调大阈值到 5%,放少量流量到新路径;
- 没问题就继续加大:10% → 30% → 50% → 100%;
- 有问题立即把阈值调回 0;
- 借助配置中心,动态修改阈值,立即同步到所有节点。
五、工程实践要点
- 重构必先补测试:覆盖率 ≥ 80%,且要覆盖核心业务场景和主要异常场景。代码全覆盖 ≠ 业务全覆盖。
- 小步前进:每完成一个关键步骤就跑测试,不要憋大招。
- 充分利用 IDE 重构功能:移包、改名、提取接口。
- wire 文件别漏:main 函数和集成测试各有自己的 wire 文件。
- API 设计向后兼容:gRPC 接口一旦上线就难以大改,新增字段用新编号,不要删除老字段。
- 配置中心是灰度方案的基础:阈值要能动态调整并实时生效。
- 重构是升职加薪机会:能加深对业务和架构的理解,重构成功的系统你就是专家,影响力扩大。
六、面试要点
本章涉及的常见面试题:
- 什么是微服务架构?为什么使用微服务架构?
- 模块化之后为什么要微服务化?
- RESTful 和微服务架构是什么关系?
- 可以用 HTTP 协议实现微服务架构吗?
- 什么是 RPC?RPC 和 HTTP 是什么关系?
- 什么是 DDD?介绍 DDD 的各个概念。
- 微服务拆分怎么拆?具体步骤是什么?
- 微服务拆分有哪些难点?
- 怎么保证微服务拆分没有引入 BUG?(强调测试)
- 怎么在线上做灰度方案?(阈值 + 随机数 / 业务哈希)
核心话术:在简历里写"主导微服务拆分",主动引导面试官问上述问题,强调"完善的测试可以显著减少引入 BUG 的可能性"。
自测题与动手练习
自测题(合上书能答出来,才算懂):
- 模块化单体已经「分而治之」了,为什么还非要微服务化?从「运维视角」说一个最关键差异。
- RPC、HTTP、RESTful、微服务四者是什么关系?微服务架构是否必须用 RPC?
- DDD 里「限界上下文」对应微服务的什么?聚合体之间为什么只能靠 ID 引用?
- 为什么 gRPC 服务端要嵌入
UnimplementedInteractiveServiceServer?不嵌入会怎样? - 灰度上线为什么必须「本地调用 + 远程调用并行 + 阈值控制」?只做二选一(切过去就不管)有什么风险?
动手练习(建议真做一遍):
- 写 proto 并编译:按 2.1 写一份
interactive.proto,用 protoc 编译,确认生成.pb.go和_grpc.pb.go两份文件。 - 起服务 + 调一次:用 2.3/2.4 起一个 gRPC 服务端监听
:8091,再用 2.5 的客户端连上它成功调一次Like,观察返回。 - 加灰度开关:在客户端里加一个
threshold字段,用rand.Int31n(100) < threshold决定走远程,先用threshold=5观察大多数请求仍走本地,再逐步调到 100 体会全量切换。
七、本章小结
本章内容是微服务工程师入门必修:
- 微服务本质是"分而治之",目标是把复杂度降到一个人能理解的程度。
- 微服务 ≠ RPC,通信可以用 HTTP 也可以用 RPC;RESTful 只是风格,不是架构。
- gRPC + Protobuf 是当前 Go 生态最主流的微服务通信方案,“遇事不决选 gRPC”。
- DDD 是拆分微服务的理论标准,限界上下文 ≈ 微服务边界。
- 拆分四阶段:混沌一片 → 模块化 → 模块依赖化 → 服务化。
- 重构前先补测试,覆盖率 ≥ 80%;重构先易后难,从最独立的模块入手。
- 灰度上线方案:本地调用 + gRPC 调用并行 + 阈值控制流量 + 配置中心动态调整 + 随时可回滚。
下章将进入不停机数据迁移——微服务化之后必须把数据库也拆开,这是生产环境最高频的难题之一。