Files
zhuangang/topics/backend/grpc-microservices/scenarios/monolith-split.md
T

114 lines
5.3 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
---
id: backend.grpc-microservices.scenario.monolith-split
title: 单体拆分:从单体到“订单 + 用户”两个服务
type: scenario
domain: backend
stack: [grpc-microservices, go, mq]
tags: [单体拆分, 绞杀者模式, 数据拆分, gRPC, 灰度]
level: advanced
prerequisites: [backend.grpc-microservices.governance]
est_hours: 2
related: [backend.grpc-microservices.interview, backend.mq.reliability]
week: [14]
---
# 单体拆分:从单体到“订单 + 用户”两个服务
## 业务背景与问题
一个 Go 单体后台(用户、商品、订单、报表都在一个进程、一个数据库),团队从 3 人增加到 10 人后:多人改同一个仓库冲突多;报表的重查询拖慢下单;一个模块的 bug 导致整个服务重启。团队讨论“要不要全面改微服务”。
## 约束
- 业务不能停,拆分过程中要能随时回退。
- 运维人力有限,没有专职的平台团队。
- 用户数据被订单、报表等多处直接 JOIN 查询。
## 方案设计
1. **先判断是否真的需要拆**:很多问题不需要微服务就能解决——报表查询拖慢下单 → 报表走只读从库;代码冲突 → 按模块划分目录和负责人(模块化单体)。只有“独立部署、独立扩容、故障隔离”的需求明确的部分才拆出来。
2. **选第一个拆分对象**:边界清晰、被依赖多、变化少的模块——用户服务(登录、资料)。
3. **绞杀者模式(Strangler Fig)逐步迁移**:
- 第一步:在单体内部把用户模块整理成接口(`UserService`),其他模块只通过接口访问,不再直接 JOIN 用户表;
- 第二步:新建用户服务(gRPC),实现同样的接口;单体里的接口实现改为调用 gRPC 客户端,用开关控制走本地还是远程,灰度切换;
- 第三步:数据拆分——用户表迁到用户服务的独立数据库;迁移期间双写或用 CDC 同步,校验一致后切读、再切写;
- 第四步:订单需要的用户字段(如昵称)在订单里冗余一份或通过事件同步,避免每次查订单都远程调用。
4. **配套治理**:调用超时和熔断、统一 trace ID、错误码映射、接口契约(proto)放在独立仓库管理。
```mermaid
flowchart LR
subgraph 单体
O[订单模块] --> I{UserService 接口}
I -->|开关=本地| L[本地实现]
I -->|开关=远程| G[gRPC 客户端]
end
G -->|超时 + 熔断| U[用户服务] --> UDB[(用户库)]
L --> MDB[(原单体库)]
```
## 关键代码
接口先行,单体里的调用方不关心实现在本地还是远程:
```go
type UserService interface {
GetUser(ctx context.Context, id int64) (User, error)
}
// 远程实现:gRPC 客户端 + 超时 + 错误码映射
type remoteUsers struct{ cli userv1.UserServiceClient }
func (r remoteUsers) GetUser(ctx context.Context, id int64) (User, error) {
ctx, cancel := context.WithTimeout(ctx, 300*time.Millisecond)
defer cancel()
u, err := r.cli.GetUser(ctx, &userv1.GetUserRequest{Id: id})
switch status.Code(err) {
case codes.OK:
return User{ID: u.GetId(), Name: u.GetName()}, nil
case codes.NotFound:
return User{}, ErrUserNotFound
default:
return User{}, fmt.Errorf("用户服务调用失败:%w", err)
}
}
// 灰度开关:按比例把流量切到远程实现,出问题可以立即切回
type switchUsers struct {
local, remote UserService
percent func() int // 从配置中心读取 0~100
}
func (s switchUsers) GetUser(ctx context.Context, id int64) (User, error) {
if int(id%100) < s.percent() {
return s.remote.GetUser(ctx, id)
}
return s.local.GetUser(ctx, id)
}
```
gRPC 调用、错误码和超时的可运行演示见 [examples/grpc-user](../examples/grpc-user/);超时 / 重试 / 熔断见 [examples/resilience](../examples/resilience/)。
## 取舍
| 方案 | 适合 | 代价 |
| --- | --- | --- |
| 保持单体 + 模块化 | 团队小、业务变化快 | 部署和扩容不能独立 |
| **按需拆出少数服务(本方案)** | 有明确的独立部署 / 扩容 / 隔离需求 | 引入网络调用、分布式数据一致性、更多运维 |
| 一次性全面微服务 | 很少适合 | 风险高、周期长、运维成本陡增 |
## 易错点
- 拆了服务但还共用一个数据库:耦合没有解决,反而多了网络调用。
- 一次性切换、没有开关:出问题无法快速回退。
- 订单列表每行都远程查用户:N+1 远程调用。用批量接口或冗余字段。
- 拆分后跨服务事务直接用分布式锁硬凑:应该用可靠消息 / Saga 做最终一致。
- 拆得太细(每张表一个服务):调用链变长,问题更多。
## 面试怎么讲
> “我们没有一上来就全面微服务。先在单体里把用户模块抽成接口,让其他模块只通过接口访问;再把用户服务独立出来用 gRPC 提供,单体里加了按比例切流的开关,出问题可以立即切回;数据用双写加校验迁到独立库,订单里需要的用户昵称通过事件同步冗余。调用都加了超时、熔断和 trace ID。”
转岗求职时如果没有真实的拆分经历,可以讲“我在项目里做过模块化设计,并且了解拆分的步骤和风险”,不要编造经历。
追问准备:怎么判断一个模块该不该拆?数据迁移期间怎么保证一致?服务间调用失败怎么处理?