gRPC 专题:从接口定义到生产实践¶
gRPC 适合服务之间的结构化通信。它用 Protobuf 描述数据与服务,通常运行在 HTTP/2 之上,并通过代码生成把协议变成强类型接口。本章以 Go 为主线,从一个 UserService 开始,逐步讲清 Protobuf、四类 RPC、grpc-gateway、错误模型、超时、重试、并发治理、高可用和可观测性。
学完本章,你应该能独立完成三件事:
- 设计一份兼容演进的
.proto文件。 - 实现 Go 版 gRPC 服务端、客户端和 HTTP JSON 网关。
- 解释 Deadline、状态码、幂等重试、流式调用、过载保护与线上排查。
1. 先理解一次 gRPC 调用¶
客户端调用 CreateUser(ctx, req) 时,看起来像调用本地函数,实际经历了完整的网络通信:
这里有四个核心角色:
| 角色 | 作用 |
|---|---|
.proto 文件 |
描述消息结构和服务方法,是通信契约 |
| Protobuf | 将结构化消息编码成紧凑的二进制数据 |
| 生成代码 | 提供消息类型、客户端 Stub、服务端接口和注册函数 |
| gRPC 运行时 | 管理连接、HTTP/2 传输、状态码、Deadline、流和拦截器 |
1.1 gRPC 与 REST 的差异¶
| 维度 | gRPC | 常见 REST API |
|---|---|---|
| 接口描述 | .proto,契约明确 |
OpenAPI 或文档,也可能只靠约定 |
| 数据编码 | Protobuf 二进制 | JSON 文本最常见 |
| 传输 | 通常使用 HTTP/2 | HTTP/1.1、HTTP/2 均可 |
| 调用方式 | 方法调用,如 GetUser |
资源路径与 HTTP Method |
| 流式通信 | 原生支持四类 RPC | 常借助 SSE、WebSocket 等方案 |
| 浏览器调试 | 需要 gRPC-Web 或网关 | 直接使用浏览器、curl 即可 |
| 典型场景 | 内部服务通信、低延迟、强契约 | 对外开放 API、浏览器和第三方接入 |
工程里经常同时使用两者:内部服务走 gRPC,对外通过 grpc-gateway 提供 HTTP JSON。
1.2 为什么常说 gRPC 基于 HTTP/2¶
HTTP/2 为 gRPC 提供了几项关键能力:
- 多路复用:一条 TCP 连接可以承载多个并发 Stream,减少连接数量。
- 二进制分帧:请求头和消息体会拆成不同类型的帧传输。
- 头部压缩:HPACK 可降低重复元数据的传输开销。
- 双向流:客户端和服务端都能持续发送消息。
多路复用不能消除 TCP 层的队头阻塞。如果底层 TCP 丢包,同一连接上的多个 HTTP/2 Stream 仍可能一起等待重传。
2. Protobuf:先把通信契约写清楚¶
下面是一份最小但完整的用户服务定义:
syntax = "proto3";
package user.v1;
option go_package = "example.com/project/api/user/v1;userv1";
service UserService {
rpc CreateUser(CreateUserRequest) returns (CreateUserReply);
rpc GetUser(GetUserRequest) returns (GetUserReply);
}
message CreateUserRequest {
string email = 1;
string display_name = 2;
}
message CreateUserReply {
User user = 1;
}
message GetUserRequest {
int64 id = 1;
}
message GetUserReply {
User user = 1;
}
message User {
int64 id = 1;
string email = 2;
string display_name = 3;
}
2.1 package 与 go_package¶
package user.v1 是 Protobuf 命名空间,防止不同模块出现同名消息。把版本放进包名后,破坏性变更可以进入 user.v2,旧客户端仍能继续使用 v1。
go_package 决定生成的 Go 包路径与包名:
分号前是 import path,分号后是 Go package name。两者职责不同,不应省略 package,也不应依赖工具猜测 go_package。
2.2 字段编号才是线上的身份¶
这一行包含字段名、类型和字段编号:
Protobuf 的二进制数据主要记录字段编号和 wire type,字段名不会随每条消息发送。字段编号一旦上线,就应视为协议身份:
- 可以在保持语义一致的前提下修改字段名。
- 不要修改已经使用的字段编号。
- 删除字段后,使用
reserved保留旧编号和旧名称。 1到15的字段编号编码通常更短,可留给高频字段。19000到19999是 Protobuf 实现保留范围,不要使用。
正确删除字段:
message User {
reserved 4;
reserved "nickname";
int64 id = 1;
string email = 2;
string display_name = 3;
}
如果把旧编号 4 分配给新字段,旧客户端可能把新数据解释成已删除字段,产生静默的数据错误。
这里的
User只是一个独立的语法示例(假设nickname字段被删除后用reserved封存编号4),与 12.2 节案例中User消息把编号4用于department_id是两条不同的演进线,并不是同一个消息复用了编号4。
2.3 常用字段类型¶
| 需求 | 推荐类型 | 说明 |
|---|---|---|
| 整数编号 | int64 |
Go 中生成 int64 |
| 普通文本 | string |
必须是合法 UTF-8 |
| 原始字节 | bytes |
图片内容、摘要等二进制数据 |
| 列表 | repeated T |
默认是空列表语义 |
| 键值结构 | map<K, V> |
key 类型受限,顺序没有协议保证 |
| 多选一 | oneof |
同一时刻最多设置一个成员 |
| 时间点 | google.protobuf.Timestamp |
避免自定义不统一的时间字符串 |
| 时长 | google.protobuf.Duration |
表达超时、间隔等持续时间 |
oneof 适合表达互斥条件:
客户端只能按编号或邮箱选择一种查询方式,生成代码会提供明确的分支类型。
2.4 为什么 Protobuf 更紧凑:varint 与 wire type¶
Protobuf 的二进制编码不传输字段名,只传输"字段编号 + wire type + 值"。每个字段的 tag 由 (字段编号 << 3) | wire_type 组成,常见 wire type 有:
| wire type | 编号 | 对应类型 |
|---|---|---|
| Varint | 0 | int32/int64、uint32/uint64、sint32/sint64(zigzag)、bool、枚举 |
| 64-bit | 1 | fixed64、sfixed64、double |
| Length-delimited | 2 | string、bytes、嵌套消息、packed repeated |
| 32-bit | 5 | fixed32、sfixed32、float |
Varint 是一种变长编码:每字节用 7 位存数据,最高位标记"后面是否还有字节"。数值越小,占用字节越少,小整数通常只需 1~2 字节。这是 Protobuf 通常比 JSON 更紧凑的原因之一:JSON 数字以文本形式存储,Protobuf 用 varint 压缩了绝大多数业务中常见的小整数。
Varint 对负数不友好:int32 的负数会按补码解释成一个很大的无符号数,固定占用 10 字节。sint32/sint64 通过 zigzag 编码把有符号数映射成无符号数(0→0、-1→1、1→2、-2→3 ...),让绝对值小的负数也能用少量字节表示。字段经常出现负数时应选 sint32/sint64,而不是默认的 int32/int64。
fixed32/fixed64 放弃变长编码,固定占用 4/8 字节。当数值经常大于 2^28(varint 不再省字节)时,fixed32 反而比 int32 更紧凑,浮点数、哈希值等场景常用固定长度类型。
面试可以这样回答:Protobuf 省空间主要靠两点——不传字段名只传编号,以及用 varint 变长编码压缩小整数;wire type 决定了值按什么格式解析,这也解释了为什么修改字段类型可能破坏兼容性——wire type 变了,接收方会按错误的格式解析后续字节。
2.5 默认值与字段存在性¶
proto3 标量字段有默认值:数字为 0,布尔值为 false,字符串为空字符串。普通标量字段在部分语言和生成模式下无法直接区分“没有传”与“传了默认值”。
更新接口尤其需要区分这两种情况。例如把昵称更新为空字符串,与保持昵称不变,含义不同。可以使用 optional:
复杂的部分更新也常使用 google.protobuf.FieldMask,明确告诉服务端哪些字段需要更新。
2.6 枚举要保留零值¶
零值命名为 UNSPECIFIED,便于识别调用方没有明确设置状态。上线后的枚举值同样不能随意复用;删除枚举项后应保留其编号和名称。
2.7 兼容性速查¶
| 变更 | 一般结论 | 原因 |
|---|---|---|
| 新增字段 | 兼容 | 旧端会忽略未知字段 |
| 修改字段名 | 二进制层通常兼容 | 线上编码主要依赖编号 |
| 修改字段编号 | 不兼容 | 接收方会按另一个字段解析 |
| 复用已删除编号 | 高风险 | 新旧消息可能互相误读 |
int32 改为 int64 |
需谨慎评估 | wire type 可相同,语言范围和业务语义可能变化 |
string 改为 bytes |
不建议 | 校验规则和生成类型变化 |
| 新增枚举值 | 需验证客户端 | 旧代码必须能处理未知枚举值 |
兼容性不只看能否反序列化,还要看业务语义、JSON 映射、校验规则和各语言客户端行为。
3. 工具链与代码生成¶
Go 项目常用三个工具:
| 工具 | 作用 |
|---|---|
protoc |
Protobuf 编译器,读取 .proto 文件 |
protoc-gen-go |
生成消息类型与 Protobuf 编解码代码 |
protoc-gen-go-grpc |
生成 gRPC 客户端和服务端接口代码 |
安装 Go 插件:
go install google.golang.org/protobuf/cmd/protoc-gen-go@latest
go install google.golang.org/grpc/cmd/protoc-gen-go-grpc@latest
生成代码:
protoc \
--go_out=. --go_opt=paths=source_relative \
--go-grpc_out=. --go-grpc_opt=paths=source_relative \
api/user/v1/user.proto
输出通常包括:
生成文件不应手工修改。协议调整写回 .proto,然后重新生成。团队项目还应固定 protoc 和插件版本,并在 CI 中检查生成结果是否最新。Buf 可以进一步处理格式化、Lint、依赖和破坏性变更检测。
4. 写出第一个 Unary RPC¶
Unary RPC 是一请求、一响应,也是最常用的调用形式。
4.1 服务端实现¶
package service
import (
"context"
"strings"
userv1 "example.com/project/api/user/v1"
"google.golang.org/grpc/codes"
"google.golang.org/grpc/status"
)
type UserService struct {
userv1.UnimplementedUserServiceServer
repo UserRepository
}
func (s *UserService) CreateUser(
ctx context.Context,
req *userv1.CreateUserRequest,
) (*userv1.CreateUserReply, error) {
email := strings.TrimSpace(req.GetEmail())
if email == "" {
return nil, status.Error(codes.InvalidArgument, "email is required")
}
user, err := s.repo.Create(ctx, email, req.GetDisplayName())
if err != nil {
return nil, mapRepositoryError(err)
}
return &userv1.CreateUserReply{
User: &userv1.User{
Id: user.ID,
Email: user.Email,
DisplayName: user.DisplayName,
},
}, nil
}
嵌入 UnimplementedUserServiceServer 可以帮助服务在协议新增方法后保持前向兼容,也是新版生成代码推荐的写法。
4.2 启动服务器¶
lis, err := net.Listen("tcp", ":9000")
if err != nil {
log.Fatal(err)
}
grpcServer := grpc.NewServer()
userv1.RegisterUserServiceServer(grpcServer, userService)
if err := grpcServer.Serve(lis); err != nil {
log.Fatal(err)
}
服务实现完成后还必须注册到 grpc.Server。忘记注册时,客户端会收到 Unimplemented。
4.3 客户端调用¶
conn, err := grpc.NewClient(
"localhost:9000",
grpc.WithTransportCredentials(insecure.NewCredentials()),
)
if err != nil {
log.Fatal(err)
}
defer conn.Close()
client := userv1.NewUserServiceClient(conn)
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
defer cancel()
reply, err := client.CreateUser(ctx, &userv1.CreateUserRequest{
Email: "alice@example.com",
DisplayName: "Alice",
})
if err != nil {
st, _ := status.FromError(err)
log.Fatalf("CreateUser failed: code=%s message=%s", st.Code(), st.Message())
}
log.Printf("created user: %d", reply.GetUser().GetId())
grpc.NewClient 是懒连接语义:调用它只做参数校验和内部初始化,不会立即建立 TCP/TLS 连接;真正的拨号发生在第一次 RPC 调用时(也可以主动调用 conn.Connect() 提前触发)。这与旧版 grpc.Dial 不同,理解这一点才能正确解读下一段"复用连接"的建议——复用的是 ClientConn 这个长期对象本身,而不是指望 NewClient 帮你提前把连接建好。
示例使用明文连接,只适合本地开发。生产环境应配置 TLS,并根据部署环境决定服务发现、负载均衡和认证方式。
4.4 分层边界¶
推荐让 gRPC Handler 保持轻量:
Handler 负责把 Protobuf 请求转换为业务输入,把领域错误转换为 gRPC 状态。业务层不应到处依赖生成的 Protobuf 类型,否则更换入口协议、写单元测试和复用业务逻辑都会变难。
5. 四类 RPC 与适用场景¶
5.1 Unary:一请求,一响应¶
适合查询详情、创建订单、更新用户等普通接口。
5.2 Server Streaming:一请求,多响应¶
服务端持续返回用户事件:
func (s *UserService) WatchUserEvents(
req *userv1.WatchUserEventsRequest,
stream userv1.UserService_WatchUserEventsServer,
) error {
for event := range s.events.Subscribe(stream.Context(), req.GetUserId()) {
if err := stream.Send(event); err != nil {
return err
}
}
return nil
}
适合事件订阅、实时进度和批量结果逐步返回。客户端取消请求后,服务端应及时检查 stream.Context() 并停止工作。
5.3 Client Streaming:多请求,一响应¶
客户端持续上传分片,服务端收完后返回汇总结果。适合大文件分片上传、批量采集数据。
func (s *UserService) UploadChunks(
stream userv1.UserService_UploadChunksServer,
) error {
var total int64
for {
chunk, err := stream.Recv()
if err == io.EOF {
return stream.SendAndClose(&userv1.UploadReply{
TotalBytes: total,
})
}
if err != nil {
return err
}
total += int64(len(chunk.GetData()))
}
}
服务端在收到 io.EOF 前持续 Recv,io.EOF 代表客户端已经调用 CloseAndRecv;此时用 SendAndClose 一次性返回汇总结果。
5.4 Bidirectional Streaming:多请求,多响应¶
双方独立读写,同一个 Stream 中的单向消息顺序会被保留。双向流适合聊天、协同控制和长连接事件交换,但状态管理、背压、重连和半关闭处理更复杂。
func (s *UserService) Chat(stream userv1.UserService_ChatServer) error {
for {
msg, err := stream.Recv()
if err == io.EOF {
return nil
}
if err != nil {
return err
}
reply, err := s.buildChatReply(stream.Context(), msg)
if err != nil {
return err
}
if err := stream.Send(reply); err != nil {
return err
}
}
}
收发是两个独立方向:这里的收发循环串行处理,真实场景常把 Recv 放在单独的 goroutine 里,通过 channel 把收到的消息交给发送逻辑,避免慢速发送阻塞接收,或者反过来让接收堆积失控——这正是背压需要设计的地方(见 5.5)。客户端一侧结构类似,用同一个 Stream 对象分别调用 Send 和 Recv。
面试可以这样回答:四类 RPC 的选型本质是"谁需要持续发送/接收"——普通请求响应用 Unary,服务端持续推送用 Server Streaming,客户端持续上传用 Client Streaming,双方都要长期交互用 Bidirectional。选流式通常意味着要同时设计背压、断线恢复和连接生命周期,不是简单的"支持流所以更强"。
5.5 流不等于无限吞吐¶
流式 RPC 仍受 HTTP/2 流控、网络带宽、内存和消费速度限制。工程上要明确:
- 单条消息大小和单个 Stream 的生命周期。
- 消费者变慢时如何施加背压。
- 断线后从哪里恢复,是否需要序列号或游标。
- 心跳与空闲超时如何设置。
- 长连接期间凭证过期如何处理。
6. grpc-gateway:同时提供 HTTP JSON¶
移动端、浏览器和第三方系统通常更容易调用 HTTP JSON。grpc-gateway 根据 .proto 中的 HTTP 注解生成反向代理代码,将 HTTP 请求转成 gRPC 请求。
6.1 添加 HTTP 注解¶
syntax = "proto3";
package user.v1;
import "google/api/annotations.proto";
service UserService {
rpc CreateUser(CreateUserRequest) returns (CreateUserReply) {
option (google.api.http) = {
post: "/v1/users"
body: "*"
};
}
rpc GetUser(GetUserRequest) returns (GetUserReply) {
option (google.api.http) = {
get: "/v1/users/{id}"
};
}
}
body: "*" 表示将请求 JSON Body 映射到整个 Protobuf 请求消息。路径参数 {id} 会映射到 GetUserRequest.id。
6.2 生成网关代码¶
除前面的插件外,还需要 protoc-gen-grpc-gateway:
常见坑:上面这条命令常会报 google/api/annotations.proto: File not found——protoc 不知道去哪里找 googleapis 定义的 HTTP 注解文件。需要把 googleapis 仓库中的 google/api/annotations.proto、google/api/http.proto 拉到 -I(--proto_path)能找到的目录(常见做法是 vendor 进 third_party/),或者直接改用 buf——它能按依赖声明自动拉取远程 proto,省去手工 vendor 和路径维护。
随后注册生成的 Handler:
mux := runtime.NewServeMux()
err := userv1.RegisterUserServiceHandlerFromEndpoint(
ctx,
mux,
"localhost:9000",
[]grpc.DialOption{
grpc.WithTransportCredentials(insecure.NewCredentials()),
},
)
if err != nil {
log.Fatal(err)
}
http.ListenAndServe(":8080", mux)
HTTP 调用示例:
curl -X POST http://localhost:8080/v1/users \
-H 'Content-Type: application/json' \
-d '{"email":"alice@example.com","displayName":"Alice"}'
6.3 JSON 映射中的细节¶
- Protobuf 的
display_name默认映射为 JSON 的displayName。 int64在标准 ProtoJSON 中通常编码为 JSON 字符串,避免 JavaScript 精度丢失。- 未知字段默认可能导致解析失败,具体行为取决于网关配置。
Timestamp通常映射为 RFC 3339 字符串。- ProtoJSON 的兼容规则与二进制 Protobuf 不完全相同,修改字段名可能影响 JSON 客户端。
网关负责协议转换,认证、限流、日志和跨域等能力可以放在网关中间件或外层 API Gateway;核心业务规则仍应由服务层统一执行。
7. 错误处理:状态大类与业务原因¶
gRPC 错误由状态码、错误消息和可选详情组成。推荐让状态码表达通用类别,让结构化详情表达业务原因。
7.1 常用状态码¶
| 状态码 | 典型场景 | 常见 HTTP 映射 |
|---|---|---|
InvalidArgument |
参数格式或取值错误 | 400 |
Unauthenticated |
未登录、凭证无效 | 401 |
PermissionDenied |
已识别身份,但无权限 | 403 |
NotFound |
资源不存在 | 404 |
AlreadyExists |
邮箱、业务编号等唯一冲突 | 409 |
FailedPrecondition |
当前业务状态不允许操作 | 400 / 409 |
ResourceExhausted |
配额耗尽、限流、资源不足 | 429 |
Canceled |
调用方主动取消 | 499 或自定义处理 |
DeadlineExceeded |
超过调用截止时间 | 504 |
Unavailable |
服务暂时不可用、连接失败 | 503 |
Internal |
未预期的服务端内部错误 | 500 |
Unimplemented |
方法未实现或服务未注册 | 501 |
HTTP 映射应以项目网关规则为准。业务失败全部返回 Internal 会让客户端无法正确决定提示、重试或降级策略。
面试可以这样回答:状态码只表达通用错误大类,真正的业务原因(比如"邮箱已存在")应该放进结构化的
ErrorInfo.Reason,客户端按 Code 决定通用处理(要不要重试、要不要提示登录),再按 Reason 做具体的业务分支或文案。
7.2 创建结构化错误详情¶
st := status.New(codes.AlreadyExists, "email already exists")
withDetails, err := st.WithDetails(&errdetails.ErrorInfo{
Reason: "USER_EMAIL_ALREADY_EXISTS",
Domain: "user.example.com",
Metadata: map[string]string{
"field": "email",
},
})
if err != nil {
return nil, st.Err()
}
return nil, withDetails.Err()
客户端可以按 Code 做通用处理,再读取 ErrorInfo.Reason 区分具体业务错误。不要把数据库错误、堆栈、SQL 或内部地址直接放进面向客户端的消息。
7.3 领域错误的统一映射¶
Repository 可以返回领域层可识别的错误:
func mapRepositoryError(err error) error {
switch {
case errors.Is(err, domain.ErrUserNotFound):
return status.Error(codes.NotFound, "user not found")
case errors.Is(err, domain.ErrEmailExists):
return status.Error(codes.AlreadyExists, "email already exists")
default:
return status.Error(codes.Internal, "internal server error")
}
}
日志中记录原始错误,客户端只收到稳定、可理解、不会泄密的协议错误。
7.4 grpc-gateway 怎样生成 HTTP 错误响应¶
grpc-gateway 收到 gRPC Status 后,会根据 Code 选择 HTTP 状态码,再把错误内容编码为 JSON。默认映射大致遵循下面的关系:
InvalidArgument → 400 Bad Request
Unauthenticated → 401 Unauthorized
PermissionDenied → 403 Forbidden
NotFound → 404 Not Found
AlreadyExists → 409 Conflict
ResourceExhausted → 429 Too Many Requests
Unavailable → 503 Service Unavailable
DeadlineExceeded → 504 Gateway Timeout
项目通常还需要稳定的业务错误结构,例如:
可以通过 runtime.WithErrorHandler 统一完成转换:
func gatewayErrorHandler(
ctx context.Context,
mux *runtime.ServeMux,
marshaler runtime.Marshaler,
w http.ResponseWriter,
r *http.Request,
err error,
) {
st := status.Convert(err)
reason := reasonFromDetails(st.Details())
body := struct {
Code string `json:"code"`
Message string `json:"message"`
RequestID string `json:"requestId"`
}{
Code: reason,
Message: publicMessage(st),
RequestID: requestIDFromContext(ctx),
}
w.Header().Set("Content-Type", marshaler.ContentType(body))
w.WriteHeader(runtime.HTTPStatusFromCode(st.Code()))
_ = json.NewEncoder(w).Encode(body)
}
mux := runtime.NewServeMux(
runtime.WithErrorHandler(gatewayErrorHandler),
)
自定义错误处理器仍要遵守几个边界:HTTP 状态表达通用失败类别,业务 Code 保持稳定,Message 面向用户,原始数据库错误与堆栈只进入内部日志。错误详情解析失败时也要有兜底 Code,避免返回空字符串。
8. Deadline、Timeout 与取消传播¶
8.1 三个概念¶
- Timeout:允许一次操作持续多久,例如 2 秒。
- Deadline:操作最晚应在哪个时间点结束。
- Cancellation:调用方主动终止,或 Deadline 到期后由 Context 触发取消。
gRPC 默认不会替业务自动选择 Deadline。客户端若不设置,上游请求可能长时间占用连接、Goroutine 和数据库资源。
面试可以这样回答:gRPC 不会替业务自动设置 Deadline,必须显式传递,并在跨服务调用中层层收窄预算(见 8.2);否则一次慢请求会顺着调用链一直占用上游的连接、Goroutine 和数据库资源,直到客户端自己放弃等待。
8.2 调用链上的时间预算¶
服务 A 调用服务 B 时,应传播现有 ctx,让下游感知剩余时间:
不要把 context.Background() 塞进下游调用,否则上游取消无法传播。若某个下游需要更紧的预算,可以从当前 ctx 派生更短的超时。
8.3 服务端主动响应取消¶
for {
select {
case <-ctx.Done():
return nil, status.FromContextError(ctx.Err()).Err()
case item := <-work:
// 处理当前结果
}
}
数据库驱动、HTTP 客户端和下游 gRPC 调用也要使用同一个 Context。只在 Handler 检查一次取消,随后继续执行不受控的查询,仍会浪费后端资源。
8.4 客户端超时不代表服务端回滚¶
客户端看到 DeadlineExceeded 时,无法仅凭状态码判断服务端是否已经完成写入——写请求可能已经提交成功,只是响应没能及时返回。这个不确定性从何而来、如何靠幂等键应对,第 9 节展开讲。
9. 重试、幂等与退避¶
一次调用失败可能发生在不同位置:
客户端看到的都可能是 Unavailable 或超时,却无法知道服务端是否已经完成写入。
9.1 哪些调用更适合重试¶
- 只读查询通常更容易安全重试。
- 使用幂等键保护的创建请求可以按规则重试。
- 状态设置型操作,如“将状态设为 disabled”,比“余额增加 10”更容易做到幂等。
- 非幂等写操作不能只看
Unavailable就立即重试。
面试可以这样回答:能不能重试取决于操作是否幂等,而不是取决于收到的错误码本身;即使是看起来"安全"的
Unavailable,也要结合幂等键、总 Deadline 和退避策略,否则重试本身就可能造成故障放大。
9.2 幂等键示例¶
继续用 UserService 场景:给 CreateUser 请求加一个客户端生成的 request_id 字段作为幂等键,完整字段设计见 12.2(email、display_name 保留第 2 节定义的编号不变,request_id 作为新增字段追加编号)。数据库对 request_id 建唯一索引,重复请求命中同一条幂等记录时,返回第一次创建的用户结果,避免重复创建。
9.3 指数退避¶
重试间隔可以近似为:
jitter 用于打散大量客户端的重试时间,降低服务恢复瞬间的流量尖峰。重试必须受到最大次数、总 Deadline 和重试节流限制。
9.4 不要在多层同时盲目重试¶
如果网关、服务 A、服务 B 都重试 3 次,一次用户请求可能放大为多次下游调用。通常在最了解幂等语义的一层配置重试,并把总时间限制在上游 Deadline 内。
10. Metadata、认证与拦截器¶
10.1 Metadata¶
Metadata 是随 RPC 传递的键值信息,底层对应 HTTP/2 Header。常见内容包括:
authorizationx-request-idx-tenant-id- 链路追踪上下文
客户端发送:
md := metadata.Pairs(
"authorization", "Bearer "+token,
"x-request-id", requestID,
)
ctx := metadata.NewOutgoingContext(parent, md)
reply, err := client.GetUser(ctx, req)
业务数据应放进 Request Message。Metadata 更适合认证、追踪和传输层上下文,且要限制大小、避免写入敏感明文。
10.2 Unary Interceptor¶
拦截器类似 HTTP 中间件,可以统一处理认证、日志、指标、恢复和追踪:
func loggingUnaryInterceptor(
ctx context.Context,
req any,
info *grpc.UnaryServerInfo,
handler grpc.UnaryHandler,
) (any, error) {
started := time.Now()
resp, err := handler(ctx, req)
code := status.Code(err)
slog.Info("grpc request",
"method", info.FullMethod,
"code", code.String(),
"duration_ms", time.Since(started).Milliseconds(),
)
return resp, err
}
注册多个拦截器时,顺序会影响行为。常见顺序是恢复、请求标识、追踪、认证、日志/指标、业务 Handler。恢复拦截器应把 panic 转为 Internal,同时完整记录内部堆栈。
流式 RPC 使用 Stream Interceptor。它拦截的是整个 Stream;若需要统计每条消息,通常还要包装 ServerStream 的 RecvMsg 和 SendMsg。
10.3 TLS 与身份认证¶
- TLS 保护传输内容并验证服务端身份。
- mTLS 让服务端也验证客户端证书,适合严格的服务间身份认证。
- Token、JWT 或 API Key 常通过 Metadata 传递,由拦截器校验。
- 认证回答“调用者是谁”,授权回答“调用者能做什么”,两者需要分开设计。
11. 连接管理、服务发现与负载均衡¶
gRPC 的 ClientConn 代表一组长期维护的底层连接,应尽量复用。每次请求都新建连接会重复付出 DNS、TCP、TLS 和 HTTP/2 建连成本。
典型过程:
- Resolver 将服务名解析为多个后端地址。
- gRPC 维护 SubConn 和连接状态。
- 负载均衡策略选择本次 RPC 使用的后端。
- 健康检查、连接失败和地址更新会改变可选节点。
常见策略有 pick_first 和 round_robin。具体默认值和服务配置取决于语言、版本与部署环境。
11.1 Kubernetes 下为什么普通 Service 会导致负载不均¶
这是一个经典面试点。Kubernetes 的 ClusterIP Service 是 L4(TCP 层)负载均衡:kube-proxy 通过 iptables/IPVS 规则,在新建 TCP 连接时随机或轮询选择一个后端 Pod,之后同一条连接的所有流量都固定发往这个 Pod,直到连接断开。
问题在于 gRPC 基于 HTTP/2,而 HTTP/2 的核心设计就是一条 TCP 连接长期复用、承载大量并发 Stream(见 1.2)。客户端 ClientConn 建立一次连接后,后续成百上千次 RPC 都会复用这同一条连接、同一个 TCP 五元组。对 kube-proxy 而言,这只是"一条连接",负载均衡只在建连那一刻发生过一次——结果就是所有请求实际上都打到了最初选中的那一个 Pod,其余 Pod 处于空闲状态,L4 负载均衡在连接层面之上完全失效。这个现象在连接长期存活、Pod 数量较多、或者发布后新 Pod 迟迟分不到流量时尤其明显。
常见解法有三类:
- 客户端负载均衡:用 headless Service(
clusterIP: None)让 DNS 直接返回所有 Pod IP,客户端 Resolver 拿到完整地址列表后,通过round_robin等策略自己在多条连接间分发 RPC,而不是依赖 kube-proxy。 - L7 感知的代理/Mesh:引入 Envoy、Linkerd 等 Sidecar 或网关,它们理解 HTTP/2 Stream,可以按请求粒度而不是连接粒度做负载均衡。
- xDS / 服务网格的原生 gRPC 负载均衡:让 gRPC 客户端直接对接支持 xDS 协议的控制面,动态获取后端列表并做客户端侧负载均衡,避免额外的代理跳数。
面试可以这样回答:普通 K8s Service 是 L4 负载均衡,只在新建 TCP 连接时选一次后端;而 gRPC 基于 HTTP/2 长连接复用,一条连接可能承载成千上万次 RPC,结果是负载均衡实际只发生过一次,其余副本被晾在一边。解法要么让客户端自己做负载均衡(headless Service +
round_robin),要么换成理解 HTTP/2 的 L7 代理或 xDS 服务网格,按请求粒度而不是连接粒度分发流量。
Keepalive 用于探测连接状态和维持必要的长连接,参数过于激进会产生额外流量,甚至被服务端以 GOAWAY 拒绝。生产配置应结合代理、负载均衡器的空闲超时统一设计。
12. 工程案例:创建用户前校验部门¶
本节使用“用户属于某个部门”作为教学案例,用来串联服务边界、跨服务调用和数据一致性。示例中的目录、字段和超时值用于解释设计方法,不代表读者真实项目中的实现。
12.1 先判断模块边界¶
用户和部门可以放在同一个服务的两个模块,也可以拆成两个服务。判断依据来自业务边界和运行需求:
| 判断维度 | 更适合同一服务 | 更适合拆成两个服务 |
|---|---|---|
| 业务归属 | 用户与组织强绑定,经常一起修改 | 部门是独立组织域,被多个系统复用 |
| 数据一致性 | 创建用户必须与部门变更强一致 | 可以接受跨服务校验和最终一致性 |
| 部署扩缩容 | 流量和发布节奏接近 | 两者流量、容量和发布节奏差异明显 |
| 团队 ownership | 同一团队维护 | 不同团队独立负责 |
| 故障影响 | 拆分收益有限 | 需要隔离故障和独立降级 |
小型系统优先采用一个服务内的两个模块,事务和调试都更简单。只有业务边界、数据所有权或独立部署需求已经明确时,拆分才有足够收益。
12.2 一份贯穿案例的 Proto¶
下面在第 2 节 CreateUserRequest 的基础上继续演进:email = 1、display_name = 2 两个已上线的编号保持不变,department_id、request_id 是本节新增的字段,按 2.2 节的规则追加新编号,而不是复用或打乱原有编号。把请求、响应、服务方法、HTTP 注解和幂等键放在同一份定义中:
syntax = "proto3";
package user.v1;
import "google/api/annotations.proto";
import "google/protobuf/timestamp.proto";
option go_package = "example.com/project/api/user/v1;userv1";
service UserService {
rpc CreateUser(CreateUserRequest) returns (CreateUserReply) {
option (google.api.http) = {
post: "/v1/users"
body: "*"
};
}
}
message CreateUserRequest {
string email = 1;
string display_name = 2;
int64 department_id = 3;
string request_id = 4;
}
message CreateUserReply {
User user = 1;
}
message User {
int64 id = 1;
string email = 2;
string display_name = 3;
int64 department_id = 4;
google.protobuf.Timestamp created_at = 5;
}
request_id 是客户端生成的业务幂等键。服务端需要用唯一索引保存它,重复请求返回第一次创建的用户。它和仅用于日志关联的 trace_id 职责不同。
12.3 两种校验路径¶
同一服务内的模块可以直接在本地事务前查询部门表:
拆成两个服务后,UserService 不能直接读取 DepartmentService 的数据库。它通过对方公开的 gRPC 接口检查部门:
12.4 调用顺序和一致性边界¶
创建用户只写 UserService 自己的数据时,推荐先完成部门校验,再开启短事务写用户。不要在数据库事务中等待远程 RPC,否则部门服务变慢时会长期占用数据库连接和锁。
func (s *UserService) CreateUser(
ctx context.Context,
req *userv1.CreateUserRequest,
) (*userv1.CreateUserReply, error) {
if err := validateCreateUser(req); err != nil {
return nil, status.Error(codes.InvalidArgument, err.Error())
}
departmentCtx, cancel := context.WithTimeout(ctx, 200*time.Millisecond)
defer cancel()
_, err := s.departmentClient.GetDepartment(
departmentCtx,
&departmentv1.GetDepartmentRequest{Id: req.GetDepartmentId()},
)
if err != nil {
return nil, mapDepartmentError(err)
}
user, err := s.repo.CreateIdempotently(ctx, CreateUserParams{
RequestID: req.GetRequestId(),
Email: req.GetEmail(),
DisplayName: req.GetDisplayName(),
DepartmentID: req.GetDepartmentId(),
})
if err != nil {
return nil, mapRepositoryError(err)
}
return toCreateUserReply(user), nil
}
示例中的 200ms 是子预算演示值。真实数值应根据端到端 SLO、历史 P99、网络开销和数据库预算确定,并写入配置,而非散落在业务代码中。
12.5 失败时怎样处理¶
| 失败 | 是否创建用户 | 返回建议 | 是否重试 |
|---|---|---|---|
| 部门不存在 | 否 | FailedPrecondition 或 InvalidArgument |
否 |
| 部门服务暂时不可用 | 否 | Unavailable |
仅在总预算内有限重试 |
| 部门校验超时 | 否 | DeadlineExceeded |
通常不在服务端继续重试 |
| 邮箱已存在 | 否 | AlreadyExists |
否 |
相同 request_id 重复请求 |
返回第一次结果 | 正常响应 | 无需再次写入 |
| 用户已创建,响应丢失 | 已创建 | 重试后返回第一次结果 | 依靠幂等键安全重试 |
部门在校验后仍可能被删除,这是典型的跨服务竞态。处理方式取决于业务规则:组织系统可以禁止删除仍被引用的部门;也可以发布 DepartmentDisabled 事件,让 UserService 异步冻结或迁移相关用户。跨服务强一致事务通常代价很高,只有业务确实要求原子提交时才考虑更重的协调方案。
13. 高并发:先控制资源,再追求吞吐¶
高并发能力来自整条链路的容量设计。只提高 gRPC 的并发参数,往往会把压力推向数据库或下游服务。
13.1 用并发量理解容量¶
稳定状态下可以用一个简单关系估算在途请求数:
当接口达到 2000 QPS、平均耗时 50ms 时,平均约有 100 个请求同时执行。P99 延迟升高、下游抖动或重试都会让在途请求快速增加,因此容量测试既要看 QPS,也要看 P95/P99、错误率、Goroutine、连接池等待和资源饱和度。
13.2 连接复用与连接数¶
一个 grpc.ClientConn 可以承载多个并发 RPC,应作为长期对象复用。是否需要建立多个连接,取决于服务端 MAX_CONCURRENT_STREAMS、单连接流控、TLS 开销、代理行为和压测结果。
调用方进程
├── ClientConn A → HTTP/2 connection → Pod 1
├── ClientConn B → HTTP/2 connection → Pod 2
└── ClientConn C → HTTP/2 connection → Pod 3
连接越多并不必然越快。大量连接会增加文件描述符、内存、TLS 握手和服务端状态。先复用少量长连接,再通过指标判断是否存在单连接瓶颈。
13.3 限制在途请求¶
服务容量是有上限的。请求无限进入时,会产生 Goroutine 堆积、数据库连接池等待和尾延迟扩散。可以在服务端拦截器中限制同时执行的 Unary RPC:
func concurrencyLimitInterceptor(limit int) grpc.UnaryServerInterceptor {
sem := make(chan struct{}, limit)
return func(
ctx context.Context,
req any,
info *grpc.UnaryServerInfo,
handler grpc.UnaryHandler,
) (any, error) {
select {
case sem <- struct{}{}:
defer func() { <-sem }()
return handler(ctx, req)
case <-ctx.Done():
return nil, status.FromContextError(ctx.Err()).Err()
default:
return nil, status.Error(
codes.ResourceExhausted,
"server concurrency limit reached",
)
}
}
}
实际项目通常按方法、租户或流量等级设置不同阈值。读取接口与创建用户共享一个全局小阈值,可能导致写入流量挤占所有查询;按业务优先级隔离容量更稳妥。
13.4 限流与过载保护¶
限流控制单位时间内的请求量,并发限制控制同时执行的请求数,两者解决的问题不同:
| 机制 | 主要保护对象 | 常见算法 |
|---|---|---|
| API 限流 | 租户配额、突发流量 | Token Bucket、Leaky Bucket |
| 并发限制 | CPU、Goroutine、下游容量 | Semaphore、自适应并发 |
| 数据库连接池 | 数据库连接与锁 | MaxOpenConns、等待队列 |
| 消息大小限制 | 内存和网络 | MaxRecvMsgSize、MaxSendMsgSize |
| Deadline | 单次请求资源占用时间 | 端到端时间预算 |
容量耗尽时应快速返回 ResourceExhausted,让调用方执行退避或降级。把请求全部排进无界队列,只会延后失败并放大超时。
面试可以这样回答:限流管的是"允许多少请求进来",并发限制管的是"同时能处理多少请求",连接池管的是"底层资源能撑住多少",三者要一起设计,而不是只调大 gRPC 一层的参数;容量到顶时应该让上游快速拿到
ResourceExhausted去重试或降级,而不是把请求堆进无界队列,把过载拖成一堆超时。
13.5 数据库才常是最终瓶颈¶
创建用户会消耗数据库连接、唯一索引检查、事务日志和磁盘 I/O。服务端并发上限应与数据库连接池配合:
MaxOpenConns 过大会把压力直接推给 MySQL,过小则会让请求长时间等待连接。应同时监控 WaitCount、WaitDuration、SQL P99、锁等待和数据库 CPU,再通过压测确定阈值。
13.6 流式 RPC 的背压¶
流式接口中,接收方处理速度低于发送方时,数据会在 gRPC、应用缓冲区或业务队列中积压。可靠设计需要:
- 使用有界 Channel,不创建无限缓冲。
- 发送和接收循环持续检查 Context。
- 限制单条消息大小和每个 Stream 的最大生命周期。
- 明确消费落后时选择等待、丢弃、采样还是断开。
- 用序列号或游标支持断线恢复,不依赖无限长连接保存全部状态。
13.7 压测要回答什么¶
进阶/选学:下面这份清单更接近 SRE 压测报告的完整度,超出常规面试问答的必答范围,作为加分项了解即可。
压测目标不能只写“达到多少 QPS”。完整报告至少包含:
- 请求模型:读写比例、请求大小、连接数和并发数。
- 延迟分布:P50、P95、P99 与最大值。
- 失败分布:各 gRPC Code 的数量和比例。
- 资源曲线:CPU、内存、GC、Goroutine、连接池等待。
- 饱和点:从哪一档并发开始,吞吐不再增长而延迟快速上升。
- 降载表现:超过容量后是否快速返回,恢复后是否自行回稳。
14. 高可用:让局部故障保持局部¶
高可用的目标是在节点故障、下游变慢和发布过程中保持核心能力可用。它依赖冗余、故障检测、流量切换和受控降级共同完成。
14.1 多副本与健康检查¶
多副本可以承受单个进程或节点故障,前提是请求状态没有只保存在某个进程内。登录会话、幂等记录和任务状态应放在可共享、可恢复的存储中。
gRPC Health Checking Protocol 可以暴露服务健康状态。部署平台通常还区分:
- 启动探针:服务是否完成初始化。
- 就绪探针:当前实例是否可以接收新流量。
- 存活探针:进程是否陷入无法恢复的状态,需要重启。
依赖短暂变慢时不要轻易让存活探针失败,否则多个实例可能同时重启。就绪探针用于摘除流量,存活探针处理进程级死锁等不可恢复故障。
14.2 分层时间预算¶
预算为什么要逐级收窄、以及服务端应如何响应剩余时间,见 8.2。这里补一组更具体的数值,展示同一条链路里各层预算是怎样一层层分下去的:
APP 总等待上限 1200ms
└── grpc-gateway 请求 Deadline 1000ms
└── UserService 业务预算 850ms
├── Department RPC 200ms
└── User DB 写入 300ms
数值仅为教学示例,真实项目应按 SLO、历史 P99 和数据库预算配置化管理,而非写死在代码里。
14.3 重试、熔断与故障隔离¶
三种机制承担不同职责:
| 机制 | 作用 | 使用边界 |
|---|---|---|
| 重试 | 跨过一次短暂网络或节点故障 | 仅限可重试错误、幂等请求和剩余预算 |
| 熔断 | 下游持续失败时暂时停止调用 | 需要半开探测和恢复条件 |
| 隔舱 | 为不同下游或业务分配独立容量 | 防止一个慢依赖耗尽全部资源 |
DepartmentService 大量超时时,UserService 若不断重试,会同时占用调用方 Goroutine、连接和总 Deadline。更稳的行为是有限重试、达到阈值后快速失败,并让其他不依赖部门服务的接口继续运行。
面试可以这样回答:重试、熔断、隔舱三者分工不同——重试跨过一次瞬时抖动,熔断防止下游持续故障时还在不断浪费资源等待超时,隔舱确保一个慢依赖不会把其他业务的连接、Goroutine 和 Deadline 一起拖垮。三者组合起来,才能让 DepartmentService 变慢时,UserService 里不依赖它的接口仍然正常响应。
14.4 重试配置示例¶
gRPC 可以通过 Service Config 为指定方法配置重试策略。下面只对只读方法 GetDepartment 重试:
{
"methodConfig": [{
"name": [{
"service": "department.v1.DepartmentService",
"method": "GetDepartment"
}],
"timeout": "0.200s",
"retryPolicy": {
"maxAttempts": 3,
"initialBackoff": "0.020s",
"maxBackoff": "0.080s",
"backoffMultiplier": 2,
"retryableStatusCodes": ["UNAVAILABLE"]
}
}]
}
最大尝试次数包含第一次调用。即使策略允许三次尝试,所有尝试仍受上游 Deadline 限制。创建用户方法自身只有在幂等键生效后才具备安全重试条件。
14.5 优雅下线¶
滚动发布时,实例应先停止接收新流量,再等待在途 RPC 完成:
stopped := make(chan struct{})
go func() {
grpcServer.GracefulStop()
close(stopped)
}()
select {
case <-stopped:
case <-time.After(15 * time.Second):
grpcServer.Stop()
}
完整顺序通常是:
流式 RPC 可能长期不结束,需要设置最大生命周期、服务端关闭通知或断线续传机制,否则优雅下线会一直等待。
14.6 高可用仍需要数据层支撑¶
服务部署三个副本,数据库只有一个无备份节点,整体系统仍有单点。生产设计还要考虑数据库主从或托管高可用、备份恢复、唯一约束、幂等记录、缓存故障和配置中心可用性。
高可用需要用故障演练验证:终止一个实例、让 DepartmentService 延迟升高、断开数据库连接、制造连接池耗尽,再观察错误率、P99、流量切换、告警和恢复时间。
15. 可观测性与线上排查¶
15.1 最少记录哪些字段¶
request_id
trace_id
service
method
peer
status_code
duration_ms
error_reason
attempt
deadline_ms
instance_id
不要默认记录完整 Request 和 Response,其中可能含密码、Token、手机号和大块二进制数据。
15.2 三类信号¶
| 信号 | 主要回答的问题 |
|---|---|
| Metrics | 错误率是否升高?P95/P99 延迟是否恶化? |
| Traces | 慢在网关、服务、下游 RPC,还是数据库? |
| Logs | 某一次调用的参数摘要、错误原因和运行上下文是什么? |
OpenTelemetry 可以让 HTTP 入口、grpc-gateway、gRPC 服务和数据库查询处在同一条 Trace 上:
15.3 一次 Unavailable 的排查顺序¶
- 看客户端错误码、Deadline 和目标地址。
- 检查 DNS 或服务发现是否返回有效后端。
- 检查代理、Ingress、Service 是否支持 HTTP/2 和 gRPC。
- 查看服务端进程、端口、健康状态和连接日志。
- 按
trace_id判断请求有没有抵达服务端。 - 检查连接是否被负载均衡器空闲超时关闭。
- 查看是否正在发布、扩缩容或发生证书问题。
Unavailable 只说明服务当前不可用这一大类现象,不应直接等同于”服务端宕机”。
面试可以这样回答:排查
Unavailable要按”由近到远”的顺序推进——先看客户端本地的错误码和目标地址,再查服务发现和网络层(DNS、代理是否支持 HTTP/2),然后看服务端进程和健康状态,最后用trace_id确认请求到底有没有抵达服务端;顺序错了,很容易把网络或负载均衡问题误判成服务端故障。
15.4 面向容量和可用性的指标¶
进阶/选学:15.4、15.5 的指标粒度和排障细节接近 SRE 运维手册,超出常规面试问答深度,可作为加分项了解。
只统计请求总数很难判断系统是否接近饱和。生产环境至少要建立下面几组指标:
| 类别 | 指标示例 | 用途 |
|---|---|---|
| 流量 | 各 Method QPS、请求/响应字节数 | 判断负载规模与消息成本 |
| 延迟 | P50、P95、P99、Deadline 剩余时间 | 发现尾延迟和预算不足 |
| 错误 | 各 gRPC Code、业务 Reason | 区分参数错误、过载与依赖故障 |
| 饱和度 | 在途 RPC、Goroutine、CPU、GC | 判断实例是否接近容量上限 |
| 连接 | ClientConn 状态、连接建立失败、活跃 Stream | 排查网络和服务发现问题 |
| 下游 | Department RPC 延迟、错误率、熔断状态 | 识别依赖传播故障 |
| 数据库 | 连接池等待、SQL P99、锁等待 | 判断瓶颈是否已经进入存储层 |
告警应聚焦用户影响和持续时间。例如 CreateUser 的 P99 连续五分钟超过 SLO,同时 DeadlineExceeded 比例升高,比单次请求失败更适合作为告警条件。
15.5 教学故障:部门服务变慢¶
假设 DepartmentService 的 P99 从 40ms 升到 500ms,而 UserService 为该调用分配的子预算是 200ms。排查链路可以这样展开:
- 网关看到
CreateUser的 504 比例和 P99 同时升高。 - Trace 显示时间主要消耗在
DepartmentService/GetDepartmentSpan。 - UserService 日志出现相同
trace_id,Code 为DeadlineExceeded,deadline_ms接近 0。 - DepartmentService 指标显示数据库连接池等待增加,SQL 本身耗时没有明显变化。
- 根因定位为连接池容量过小或连接泄漏,而非 gRPC 序列化性能。
- 修复后用相同流量模型回放,验证连接池等待、P99 和错误率恢复。
这个例子展示的是排查方法。真正复盘项目故障时,要把示例名称替换为实际服务、文件、日志、指标和验证数据。
16. 测试与调试¶
16.1 使用 grpcurl 手工调用¶
服务开启 Server Reflection 后,可以像使用 curl 一样检查 gRPC:
grpcurl -plaintext localhost:9000 list
grpcurl -plaintext localhost:9000 list user.v1.UserService
grpcurl -plaintext \
-d '{"email":"alice@example.com","displayName":"Alice"}' \
localhost:9000 user.v1.UserService/CreateUser
生产环境是否开放 Reflection 要按安全要求决定。即使不开放,也可以给 grpcurl 提供 proto 文件或 descriptor set。
16.2 Handler 测试¶
Go 中可使用 bufconn 创建内存 Listener,启动真实的 gRPC Server,再通过生成客户端调用。它能覆盖序列化、注册、拦截器和状态码,比直接调用 Handler 更接近真实链路,同时不需要占用网络端口。
测试至少覆盖:
- 正常请求与响应字段。
- 参数错误对应的
InvalidArgument。 - 唯一冲突对应的
AlreadyExists。 - Deadline 和取消是否传播。
- 重复幂等键是否返回同一业务结果。
- 未认证和无权限是否正确区分。
- 网关的 JSON 字段、路径参数与错误映射。
16.3 Proto 契约检查¶
团队应在 CI 中执行格式、Lint、生成代码一致性和兼容性检查。常见失败包括:复用字段编号、删除对外方法、修改字段类型、改变包名或生成代码没有更新。
17. 一个生产级请求的完整旅程¶
把前面的知识串起来,一次创建用户请求可以这样流动。这张图在 12.3 的基础上,把 grpc-gateway 和 Interceptor 泳道补全,展示同一条请求从 HTTP 入口到数据库的完整链路,而不是重复论证 12.3 已经讲过的三个分支:
这条链路中的职责边界很清楚:
| 层 | 核心职责 |
|---|---|
| grpc-gateway | HTTP 与 Protobuf 转换,对外协议接入 |
| Interceptor | 横切能力,如认证、日志、追踪、指标 |
| gRPC Handler | 参数检查、协议与领域对象转换、错误映射 |
| Usecase / Service | 业务规则和流程编排 |
| DepartmentService Client | 校验部门并传播 Deadline 与 Trace Context |
| Repository | 数据访问抽象与存储错误转换 |
| Database | 唯一约束、事务和持久化 |
18. 关键概念回顾¶
不重复展开论证,只留一张可以脱口而出的速查卡,详细推导见对应小节。
| 主线 | 一句话结论 | 详见 |
|---|---|---|
| 契约 | 字段编号是线上身份:只加、不改、不复用;删除字段用 reserved 封存(对应第 12.2 节 CreateUserRequest 追加 department_id、request_id 时的编号演进) |
§2 |
| 生成代码 | 代码由协议生成,不手改;协议变了就重新生成,别在生成文件里写业务逻辑 | §3 |
| 调用生命周期 | Deadline、取消、Metadata、Trace 都跟着 ctx 走完全程,谁丢了 ctx 谁就切断了资源控制(对应第 17 节请求旅程图中 ctx 贯穿 Interceptor → UserService → DepartmentService → Repository 全程) |
§8, §10 |
| 错误模型 | Code 管大类、Reason 管业务原因、Message 给用户看、堆栈只进日志(对应第 12.5 节的失败表与第 17 节里各分支返回的 HTTP 状态码) | §7 |
| 重试与幂等 | 网络错误只说明结果不确定,不说明写入是否发生;能不能重试看幂等设计,不看错误码(对应第 12.2 节 request_id 幂等键的设计) |
§8.4, §9 |
| 网关 | grpc-gateway 让 HTTP JSON 复用同一份 Protobuf 契约,业务规则仍然只在 Service 层写一份 | §6 |
| 工程边界 | 高并发靠限流、限并发、保护数据库、验证饱和点;高可用靠多副本、故障隔离、优雅下线;两者都靠可观测性校准阈值(对应第 13、14 节围绕 CreateUser 展开的容量与故障隔离设计) |
§13, §14, §15 |
19. 循序实验与课后练习¶
如果你是刚接手 UserService 的工程师,前面十八节讲的是它应该长成什么样,这一节就是接下来要做的事——不是一份通用课程大纲,而是延续前面案例的四步任务:先跑通最小闭环,再把它接入对外网关,然后按第 12、17 节的方式补齐生产要求,最后用第 13、14 节的方法验证它扛不扛得住压力和故障。
第一阶段:跑通¶
- 写
UserService.CreateUser和GetUser。 - 生成 Go 代码并实现服务端。
- 写一个带 2 秒 Deadline 的客户端。
- 用 grpcurl 验证成功和失败状态码。
跑通之后,这个服务该有一个对外的 HTTP 入口了。
第二阶段:接入 HTTP¶
- 添加
google.api.http注解。 - 生成 grpc-gateway 代码。
- 用 curl 调用创建和查询接口。
- 验证 JSON 字段名、
int64编码和错误映射。
HTTP 入口跑通后,把它按第 12、17 节的样子补成一个真正能上线的服务。
第三阶段:达到生产要求¶
- 增加日志、Tracing、指标、认证和恢复拦截器。
- 为创建请求增加幂等键和数据库唯一约束。
- 验证 Deadline 能传播到数据库与下游服务。
- 用
bufconn编写集成测试。 - 在 CI 中加入 Proto Lint 和兼容性检查。
生产该有的都补齐之后,再回到第 13、14 节,验证它在压力和故障下的真实表现。
第四阶段:验证并发与可用性¶
- 用固定读写比例逐级提高并发,记录 QPS、P95/P99 和错误码分布。
- 设置服务端在途请求上限,验证过载时能快速返回
ResourceExhausted。 - 人为延迟 DepartmentService,观察 Deadline、重试和熔断是否符合预期。
- 终止一个服务实例,验证流量能切换到其他健康副本。
- 执行优雅下线,确认在途 Unary RPC 完成,长连接按设计重连。
- 调小数据库连接池,识别连接等待如何推高 gRPC 尾延迟。
综合练习¶
不看资料,完整解释下面这条链路:
HTTP JSON
→ grpc-gateway
→ Protobuf Request
→ gRPC Client Stub
→ HTTP/2
→ Server Interceptor
→ UserService Handler
→ Usecase
→ Repository
→ MySQL
→ gRPC Status / Reply
→ HTTP JSON
为这条链路补充四份设计说明:
- Deadline 如何从网关传播到数据库。
- 邮箱重复时各层分别返回什么。
- 客户端超时重试时如何防止重复创建。
- 如何用同一个
trace_id串起 HTTP、gRPC 和 SQL Span。 - 超过服务容量后如何限制在途请求并保护数据库。
- DepartmentService 变慢时如何阻止故障扩散。
完成代码和这四份说明后,再主动制造超时、重复请求和服务不可用三类故障,观察客户端状态码、服务端日志与 Trace 是否一致。