go-zero RPC 通信详解

zrpc = gRPC + 服务发现 + 负载均衡 + 超时/重试/熔断。本文把一次 RPC 调用的全链路拆开讲,配代码、配时序图。

zrpc 是什么 定义 proto 生成代码 客户端调用 调用全链路 拦截器 超时/重试/熔断

zrpc 到底是什么

zrpc 是对 gRPC 的封装:你写 proto,它帮你生成服务端骨架与「自带服务发现」的客户端,调用时像调本地函数一样简单。

比裸 gRPC 多什么

自动 etcd 注册发现、内置负载均衡、超时控制、熔断、限流、链路追踪——这些裸 gRPC 都要自己拼,zrpc 默认接好。

两种角色

Server:实现 proto 里定义的 service 方法;Client:通过生成的 client 调用对端。一个服务可以同时是两者。

定义 proto 契约

proto 是服务之间的「接口文档」,双方按它生成代码,强类型、不会调错字段。

user.proto 示例

syntax = "proto3";
package user;

// go_package 决定生成代码的位置
option go_package = "./user;user";

service User {
  rpc GetUser(GetUserRequest) returns (GetUserResponse);
  rpc Login(LoginRequest) returns (LoginResponse);
}

message GetUserRequest { int64 id = 1; }
message GetUserResponse {
  int64 id = 1;
  string name = 2;
}
message LoginRequest {
  string username = 1;
  string password = 2;
}
message LoginResponse { string token = 1; }

一条命令生成全套代码

goctl 会生成 pb.go(消息)、grpc.pb.go(gRPC 桩)、以及 zrpc 的 server/client 封装。

生成命令

goctl rpc protoc user.proto \
  --go_out=. \
  --go-grpc_out=. \
  --zrpc_out=. \
  -m        # -m 顺带生成 yaml 配置与 main

产物结构:

user/
  user.pb.go          # 消息结构体
  user_grpc.pb.go     # gRPC 接口 + 客户端
internal/server/
  userserver.go       # 服务端注册
internal/logic/
  getuserlogic.go     # 你的实现
  loginlogic.go
user.go               # main
etc/user.yaml

在 logic 里实现方法

func (l *GetUserLogic) GetUser(in *user.GetUserRequest) (*user.GetUserResponse, error) {
    u, err := l.svcCtx.UserModel.FindOne(l.ctx, in.Id)
    if err != nil {
        return nil, err
    }
    return &user.GetUserResponse{Id: u.Id, Name: u.Name}, nil
}

客户端怎么调用

调用方不写连接、不写拨号。在 config 配 etcd,svc 建 client,logic 直接方法调用。

1)config 声明下游 RPC

type Config struct {
    rest.RestConf
    UserRpc zrpc.RpcClientConf   // 关键:下游 RPC 配置
}

2)svc 注入 client

type ServiceContext struct {
    Config  config.Config
    UserRpc user.User
}
func NewServiceContext(c config.Config) *ServiceContext {
    return &ServiceContext{
        Config:  c,
        UserRpc: user.NewUser(zrpc.MustNewClient(c.UserRpc)),
    }
}

3)logic 里一行调用

resp, err := l.svcCtx.UserRpc.GetUser(l.ctx, &user.GetUserRequest{Id: 1})
if err != nil {
    return nil, err
}
// resp.Name 即可使用

一次 RPC 调用发生了什么

从「发起调用」到「拿到响应」,中间经过服务发现、负载均衡、超时控制等。时序图如下。

调用方 zrpc 客户端 etcd 被调服务 ① 方法调用 ② 查节点列表 ③ 返回节点 ④ 负载均衡选节点+发请求 ⑤ 响应 ⑥ 返回结果
# 代码视角(客户端)等价于: client.GetUser(ctx, req) 从 etcd 缓存拿到 user 服务可用节点列表 负载均衡(默认 p2c + 一致性哈希)选一个节点 套用超时 / 熔断 / 重试策略发起 gRPC 调用 拿到响应反序列化返回

拦截器:RPC 的「中间件」

想在每次调用前后统一做事(日志、鉴权、追踪)?用拦截器,不用改业务。

服务端拦截器(记录耗时)

srv := zrpc.MustNewServer(c.RpcServerConf, func(s *grpc.Server) {
    s.AddUnaryInterceptor(func(ctx context.Context, req interface{},
        info *grpc.UnaryServerInfo, handler grpc.UnaryHandler) (interface{}, error) {
        start := time.Now()
        resp, err := handler(ctx, req)
        logx.Infof("%s cost=%v err=%v", info.FullMethod, time.Since(start), err)
        return resp, err
    })
})
defer srv.Stop()
if err := srv.Add(user.RegisterUserServer, userServer.NewUserServer(ctx)); err != nil {
    logx.Fatal(err)
}
srv.Start()

客户端拦截器(透传 header)

client := zrpc.MustNewClient(c.UserRpc, zrpc.WithUnaryClientInterceptor(
    func(ctx context.Context, method string, req, reply interface{},
        cc *grpc.ClientConn, invoker grpc.UnaryInvoker, opts ...grpc.CallOption) error {
        ctx = metadata.AppendToOutgoingContext(ctx, "x-trace-id", traceId())
        return invoker(ctx, method, req, reply, cc, opts...)
    }))

超时 / 重试 / 熔断

RPC 调用一定要设防护,否则一个慢下游能把整条链路拖死。

超时:在 yaml 配

UserRpc:
  Etcd:
    Hosts: [127.0.0.1:2379]
    Key: user.rpc
  Timeout: 3000        # 单此调用超时(ms)

熔断:包裹调用

err := breaker.Do("user-rpc:GetUser", func() error {
    _, e := l.svcCtx.UserRpc.GetUser(l.ctx, req)
    return e
}, nil)

重试:客户端配置

UserRpc:
  Timeout: 3000
  Retries: 1           # 失败重试次数(谨慎,需幂等)
重试的前提是接口幂等(重复调用不产生副作用)。下单、扣款这类写操作不要盲目重试,否则会重复扣钱。
防护优先级建议:超时必配(防止无限等待)→ 熔断保护下游(防止雪崩)→ 重试只给只读/幂等接口(提升可用性)。