gRPC 的多路复用与负载均衡


多路复用


简介

关于 gRPC 服务和客户端应用程序,除了在给定的 gRPC 服务器端上注册唯一的 gRPC 服务,并且由单个客户端存根使用 gRPC 客户端进行连接。gRPC 还允许在同一个 gRPC 服务器端上运行多个 gRPC 服务,也允许多个客户端存根使用同一个 gRPC 客户端连接,这种功能叫作多路复用(multiplexing),如下图所示:

例如,在以下关键的程序代码 OrderManagement 服务中,假设为了满足订单管理需求,希望在同一个 gRPC 服务器端运行另一个服务,这样客户端就能重用同一个连接,从而按需调用这两个服务。通过对应的服务器端注册函数(即 ordermgt_pb.RegisterOrderManagementServer() 和 hello_pb.RegisterGreeterServer() )可以在同一个服务器端注册这两个服务。

func main() {
    
    
		lis, err := net.Listen("tcp", port) if err != nil {
    
    
				log.Fatalf("failed to listen: %v", err)
		}
		grpcServer := grpc.NewServer() 

		// 在 gRPC orderMgtServer上注册订单管理服务
		ordermgt_pb.RegisterOrderManagementServer(grpcServer, &orderMgtServer{
    
    }) 

		// 在 gRPC orderMgtServer上注册问候服务
		hello_pb.RegisterGreeterServer(grpcServer, &helloServer{
    
    }) 
		...
}

同理,通过客户端可以在两个 gRPC 客户端存根间共享相同的 gRPC 连接,因为两个 gRPC 服务在同一个 gRPC 服务器端运行,所以可以创建一个 gRPC 连接,并在为两个服务创建 gRPC 客户端实例时使用该连接,例如以下关键的程序代码:

// 建立到服务器端的连接
conn, err := grpc.Dial(address, grpc.WithInsecure()) 
...
orderManagementClient := pb.NewOrderManagementClient(conn) 
...
// 添加订单的 RPC
...
res, addErr := orderManagementClient.AddOrder(ctx, &order1)
...
helloClient := hwpb.NewGreeterClient(conn) 
...
// 打招呼的RPC
helloResponse, err := helloClient.SayHello(hwcCtx, &hwpb.HelloRequest{
    
    Name: "gRPC Up and Running!"})
...

对于多个服务或者多个存根使用相同的连接,这只涉及设计形式,与 gRPC 理念无关。在微服务等大多数日常使用场景中,通常并不会在两个服务间共享同一个 gRPC 服务器端。

在微服务架构中,gRPC 多路复用的一个强大的用途就是在同一个服务器端进程中托管同一个服务的多个主版本。这样做能够保证 API 在发生破坏性变更之后,依然能够适应遗留的客户端,一旦服务契约的旧版本不再有效,就可以在服务器端将其移除了。


程序示例

(1)在任意目录下,创建 serverclient 目录存放服务端和客户端文件,创建 echo 目录用于编写 IDL 的 echo.proto 文件,创建 helloword 目录用于编写 IDL 的 helloword.proto 文件,具体的目录结构如下所示:

Multiplex
├── client
│   ├── echo
│   │   └── echo.proto
│   └──helloword
│       └── helloword.proto
└── server
    ├── echo
    │   └── echo.proto
    └── helloword
        └── helloword.proto

(2)在 echo 文件夹下的 echo.proto 文件中,写入如下内容:

syntax = "proto3";

option go_package = "../echo";

package echo;

// EchoRequest is the request for echo.
message EchoRequest {
  		string message = 1;
}

// EchoResponse is the response for echo.
message EchoResponse {
  		string message = 1;
}

// Echo is the echo service.
service Echo {
  		// UnaryEcho is unary echo.
  		rpc UnaryEcho(EchoRequest) returns (EchoResponse) {}
}

(3)在 helloword 文件夹下的 helloword.proto 文件中,写入如下内容:

syntax = "proto3";

option go_package = "../helloword";

package helloworld;

// The greeting service definition.
service Greeter {
		// Sends a greeting
  		rpc SayHello (HelloRequest) returns (HelloReply) {}
}

// The request message containing the user's name.
message HelloRequest {
  		string name = 1;
}

// The response message containing the greetings
message HelloReply {
  		string message = 1;
}

(4)为服务端和客户端生成 gRPC 源代码程序,在 proto 目录下执行以下的命令:

protoc --go_out=. --go_opt=paths=source_relative --go-grpc_out=. --go-grpc_opt=paths=source_relative *.proto

正确生成后的目录结构如下所示:

Multiplex
├── client
│   ├── echo
│   │   ├── echo_grpc.pb.go
│   │   ├── echo.pb.go
│   │   └── echo.proto
│   ├── helloword
│   │   ├── helloword_grpc.pb.go
│   │   ├── helloword.pb.go
│   │   └── helloword.proto
└── server
    ├── echo
    │   ├── echo_grpc.pb.go
    │   ├── echo.pb.go
    │   └── echo.proto
    └── helloword
           ├── helloword_grpc.pb.go
           ├── helloword.pb.go
           └── helloword.proto

(5)在 server 目录下初始化项目( go mod init server ),编写 Server 端程序,该程序的具体代码如下:

package main

import (
        "context"
        "flag"
        "fmt"
        "log"
        "net"

        "google.golang.org/grpc"

        ecpb "server/echo"
        hwpb "server/helloword"
)

var port = flag.Int("port", 50051, "the port to serve on")

// hwServer is used to implement helloworld.GreeterServer.
type hwServer struct {
    
    
        hwpb.UnimplementedGreeterServer
}

// SayHello implements helloworld.GreeterServer
func (s *hwServer) SayHello(ctx context.Context, in *hwpb.HelloRequest) (*hwpb.HelloReply, error) {
    
    
        return &hwpb.HelloReply{
    
    Message: "Hello " + in.Name}, nil
}

type ecServer struct {
    
    
        ecpb.UnimplementedEchoServer
}

func (s *ecServer) UnaryEcho(ctx context.Context, req *ecpb.EchoRequest) (*ecpb.EchoResponse, error) {
    
    
        return &ecpb.EchoResponse{
    
    Message: req.Message}, nil
}

func main() {
    
    
        flag.Parse()
        lis, err := net.Listen("tcp", fmt.Sprintf(":%d", *port))
        if err != nil {
    
    
                log.Fatalf("failed to listen: %v", err)
        }
        fmt.Printf("server listening at %v\n", lis.Addr())

        s := grpc.NewServer()

        // Register Greeter on the server.
        hwpb.RegisterGreeterServer(s, &hwServer{
    
    })

        // Register RouteGuide on the same server.
        ecpb.RegisterEchoServer(s, &ecServer{
    
    })

        if err := s.Serve(lis); err != nil {
    
    
                log.Fatalf("failed to serve: %v", err)
        }
}

(6)在 client 目录下初始化项目( go mod init client ),编写 Client 端程序,该程序的具体代码如下:

package main

import (
        "context"
        "flag"
        "fmt"
        "log"
        "time"

        "google.golang.org/grpc"
        "google.golang.org/grpc/credentials/insecure"
        ecpb "client/echo"
        hwpb "client/helloword"
)

var addr = flag.String("addr", "localhost:50051", "the address to connect to")

// callSayHello calls SayHello on c with the given name, and prints the
// response.
func callSayHello(c hwpb.GreeterClient, name string) {
    
    
        ctx, cancel := context.WithTimeout(context.Background(), time.Second)
        defer cancel()
        r, err := c.SayHello(ctx, &hwpb.HelloRequest{
    
    Name: name})
        if err != nil {
    
    
                log.Fatalf("client.SayHello(_) = _, %v", err)
        }
        fmt.Println("Greeting: ", r.Message)
}

func callUnaryEcho(client ecpb.EchoClient, message string) {
    
    
        ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
        defer cancel()
        resp, err := client.UnaryEcho(ctx, &ecpb.EchoRequest{
    
    Message: message})
        if err != nil {
    
    
                log.Fatalf("client.UnaryEcho(_) = _, %v: ", err)
        }
        fmt.Println("UnaryEcho: ", resp.Message)
}

func main() {
    
    
        flag.Parse()
        // Set up a connection to the server.
        conn, err := grpc.Dial(*addr, grpc.WithTransportCredentials(insecure.NewCredentials()))
        if err != nil {
    
    
                log.Fatalf("did not connect: %v", err)
        }
        defer conn.Close()

        fmt.Println("--- calling helloworld.Greeter/SayHello ---")
        // Make a greeter client and send an RPC.
        hwc := hwpb.NewGreeterClient(conn)
        callSayHello(hwc, "multiplex")

        fmt.Println()
        fmt.Println("--- calling routeguide.RouteGuide/GetFeature ---")
        // Make a routeguild client with the same ClientConn.
        rgc := ecpb.NewEchoClient(conn)
        callUnaryEcho(rgc, "this is examples/multiplex")
}

(7)分别执行 Server 端和 Client 端的程序,输出如下的结果:

// Client
--- calling helloworld.Greeter/SayHello ---
Greeting:  Hello multiplex

--- calling routeguide.RouteGuide/GetFeature ---
UnaryEcho:  this is examples/multiplex

均衡负载


简介

在开发生产级 gRPC 应用程序时,通常需要确保该应用程序能够满足高可用性和高扩展性的需求。因此,在生产环境中,始终需要多个 gRPC 服务器端,在这些服务之间分发 RPC 需要由某个实体来处理,这就需要使用负载均衡器。

gRPC 的负载均衡是基于一次请求而不是一次连接的,即假如所有的请求都来自同一个客户端的连接,这些请求还是会被均衡到所有服务器,整个 gRPC 负载均衡流程如下图:

  • 启动时,grpc client 通过服名字解析服务得到一个 address list,每个 address 将指示它是服务器地址还是负载平衡器地址,以及指示要哪个客户端负载平衡策略的服务配置(例如 round_robin 或 grpclb )。

  • 客户端实例化负载均衡策略 如果解析程序返回的任何一个地址是负载均衡器地址,则无论 service config 中定义了什么负载均衡策略,客户端都将使用grpclb策略;否则,客户端将使用 service config 中定义的负载均衡策略。如果服务配置未请求负载均衡策略,则客户端将默认使用选择第一个可用服务器地址的策略。

  • 负载平衡策略为每个服务器地址创建一个 subchannel,假如是 grpclb 策略,客户端会根据名字解析服务返回的地址列表,请求负载均衡器,由负载均衡器决定请求哪个 subConn,然后打开一个数据流,对这个 subConn 中的所有服务器 adress 都建立连接,从而实现 client stream 的效果。

  • 当有 RPC 请求时,负载均衡策略决定哪个子通道即 gRPC 服务器将接收请求,当可用服务器为空时客户端的请求将被阻塞。

gRPC 中的负载均衡是基于每次调用的,而不是基于每个连接的,gRPC 通常使用两种主要的负载均衡机制:负载均衡器代理和客户端负载均衡。

(1)负载均衡器代理

在代理负载均衡场景中,客户端向负载均衡器代理发起 RPC ,随后负载均衡器代理将 RPC 分发给一台可用的后端 gRPC 服务器,该后端 gRPC 服务器实现了满足服务调用的逻辑。负载均衡器代理会跟踪每台后端服务器的负载,并为后端服务分配负载提供不同的负载均衡算法,如下图所示:

后端服务的拓扑结构对 gRPC 客户端是不透明的,它们只知道负载均衡器的端点就可以了。因此,为了满足负载均衡的使用场景,除了使用负载均衡器作为 gRPC 连接的目的地外,在客户端无须任何变更,后端服务可以将负载情况报告给负载均衡器,这样它就能使用该信息确定负载均衡的逻辑。

(2)客户端负载均衡

这个方案不再借助负载均衡的中间代理层,而是在 gRPC 客户端层实现负载均衡的逻辑。在这种方法中,客户端要知道多台后端 gRPC 服务器,并为每个 RPC 选择一台后端 gRPC 服务器。

负载均衡逻辑可以完全作为客户端应用程序(也被称为厚客户端)的一部分来进行开发,也可以实现为一个专用的服务器端,叫作后备负载均衡器。客户端可以查询它,从而选择最优的 gRPC 服务器来进行连接,客户端直接连接到选定的 gRPC 服务器,其地址从后备负载均衡器获取。

gRPC 的客户端负载均衡主要分为两个部分:

  • Name Resolver

gRPC 中的默认 name-system 是 DNS ,同时在客户端以插件形式提供了自定义 name-system 的机制。

gRPC NameResolver 会根据 name-system 选择对应的解析器,用以解析用户提供的服务器名,最后返回具体地址列表(IP + 端口号)。

例如:默认使用 DNS name-system ,只需要提供服务器的域名(即端口号),NameResolver 就会使用 DNS 解析出域名对应的IP列表并返回。

具体可以参考 官方文档-Name Resolver

  • Load Balancing Policy

常见的 gRPC 库都内置了几个负载均衡算法,比如
gRPC-go 内置支持有 pick_first (默认值)和 round_robin 两种策略。

  • pick_first 是 gRPC 负载均衡的默认值,因此不需要设置。pick_first 会尝试连接取到的第一个服务端地址,如果连接成功,则将其用于所有 RPC,如果连接失败,则尝试下一个地址(持续以上过程,直到一个连接成功)。因此,所有的 RPC 将被发送到同一个后端。所有接收到的响应都显示相同的后端地址。

  • round_robin 连接到它所看到的所有地址并按顺序一次向每个 server 发送一个 RPC。例如,现在注册有两个 server,第一个 RPC 将被发送到 server-1 ,第二个 RPC 将被发送到 server-2 ,第三个 RPC 将再次被发送到 server-1 。

gRPC 客户端通过 grpc.WithDefaultServiceConfig() 函数来配置要使用的负载均衡策略,例如以下的程序代码:

conn, err := grpc.Dial(
		"cqupt:///resolver.cqupthao.com",
		grpc.WithDefaultServiceConfig(`{"loadBalancingPolicy":"round_robin"}`), // 这里设置初始策略
		grpc.WithTransportCredentials(insecure.NewCredentials()),
)

程序示例

(1)在任意目录下,创建 serverclient 目录存放服务端和客户端文件,创建 cert 目录存放证书文件,创建 proto 目录用于编写 IDL 的 loadbalance.proto 文件,具体的目录结构如下所示:

LoadBalance
├── client
│   └── proto
│       └── loadbalance.proto
└── server
    └── proto
        └── loadbalance.proto

(2)在 proto 文件夹下的 loadbalance.proto 文件中,写入如下内容:

syntax = "proto3";

option go_package = "../proto";

package loadbalance;

// EchoRequest is the request for echo.
message EchoRequest {
  		string message = 1;
}

// EchoResponse is the response for echo.
message EchoResponse {
  		string message = 1;
}

// Echo is the echo service.
service Echo {
 		// UnaryEcho is unary echo.
  		rpc UnaryEcho(EchoRequest) returns (EchoResponse) {}
  		// ServerStreamingEcho is server side streaming.
  		rpc ServerStreamingEcho(EchoRequest) returns (stream EchoResponse) {}
 		// ClientStreamingEcho is client side streaming.
  		rpc ClientStreamingEcho(stream EchoRequest) returns (EchoResponse) {}
  		// BidirectionalStreamingEcho is bidi streaming.
  		rpc BidirectionalStreamingEcho(stream EchoRequest) returns (stream EchoResponse) {}
}

(3)为服务端和客户端生成 gRPC 源代码程序,在 proto 目录下执行以下的命令:

protoc --go_out=. --go_opt=paths=source_relative --go-grpc_out=. --go-grpc_opt=paths=source_relative *.proto

正确生成后的目录结构如下所示:

LoadBalance
├── client
│   └── proto
│       ├── loadbalance_grpc.pb.go
│       ├── loadbalance.pb.go
│       └── loadbalance.proto
└── server
    └── proto
        ├── loadbalance_grpc.pb.go
        ├── loadbalance.pb.go
        └── loadbalance.proto

(4)在 server 目录下初始化项目( go mod init server ),编写 Server 端程序,该程序的具体代码如下:

package main

import (
        "context"
        "fmt"
        "log"
        "net"
        "sync"

        "google.golang.org/grpc"
        "google.golang.org/grpc/codes"
        ecpb "server/proto"
        "google.golang.org/grpc/status"
)

var (
        addrs = []string{
    
    ":50051", ":50052"}
)

type ecServer struct {
    
    
        addr string
        ecpb.UnimplementedEchoServer
}

func (s *ecServer) UnaryEcho(ctx context.Context, req *ecpb.EchoRequest) (*ecpb.EchoResponse, error) {
    
    
        return &ecpb.EchoResponse{
    
    Message: fmt.Sprintf("%s (from %s)", req.Message, s.addr)}, nil
}
func (s *ecServer) ServerStreamingEcho(*ecpb.EchoRequest, ecpb.Echo_ServerStreamingEchoServer) error {
    
    
        return status.Errorf(codes.Unimplemented, "not implemented")
}
func (s *ecServer) ClientStreamingEcho(ecpb.Echo_ClientStreamingEchoServer) error {
    
    
        return status.Errorf(codes.Unimplemented, "not implemented")
}
func (s *ecServer) BidirectionalStreamingEcho(ecpb.Echo_BidirectionalStreamingEchoServer) error {
    
    
        return status.Errorf(codes.Unimplemented, "not implemented")
}

func startServer(addr string) {
    
    
        lis, err := net.Listen("tcp", addr)
        if err != nil {
    
    
                log.Fatalf("failed to listen: %v", err)
        }
        s := grpc.NewServer()
        ecpb.RegisterEchoServer(s, &ecServer{
    
    addr: addr})
        log.Printf("serving on %s\n", addr)
        if err := s.Serve(lis); err != nil {
    
    
                log.Fatalf("failed to serve: %v", err)
        }
}

func main() {
    
    
        var wg sync.WaitGroup
        for _, addr := range addrs {
    
    
                wg.Add(1)
                go func(addr string) {
    
    
                        defer wg.Done()
                        startServer(addr)
                }(addr)
        }
        wg.Wait()
}

(5)在 client 目录下初始化项目( go mod init client ),编写 Client 端程序实现客户端负载均衡功能,该程序的具体代码如下:

package main

import (
        "context"
        "fmt"
        "log"
        "time"

        pb "client/proto"
        "google.golang.org/grpc"
        "google.golang.org/grpc/resolver"
)

const (
        exampleScheme      = "example"
        exampleServiceName = "lb.example.grpc.lixueduan.com"
)

var addrs = []string{
    
    "localhost:50051", "localhost:50052"}

func callUnaryEcho(c pb.EchoClient, message string) {
    
    
        ctx, cancel := context.WithTimeout(context.Background(), time.Second)
        defer cancel()
        r, err := c.UnaryEcho(ctx, &pb.EchoRequest{
    
    Message: message})
        if err != nil {
    
    
                log.Fatalf("could not greet: %v", err)
        }
        fmt.Println(r.Message)
}

func makeRPCs(cc *grpc.ClientConn, n int) {
    
    
        hwc := pb.NewEchoClient(cc)
        for i := 0; i < n; i++ {
    
    
                callUnaryEcho(hwc, "this is examples/load_balancing")
        }
}

func main() {
    
    
        // "pick_first" is the default, so there's no need to set the load balancer.
        pickfirstConn, err := grpc.Dial(
                fmt.Sprintf("%s:///%s", exampleScheme, exampleServiceName),
                grpc.WithInsecure(),
                grpc.WithBlock(),
        )
        if err != nil {
    
    
                log.Fatalf("did not connect: %v", err)
        }
        defer pickfirstConn.Close()

        fmt.Println("--- calling helloworld.Greeter/SayHello with pick_first ---")
        makeRPCs(pickfirstConn, 3)

        fmt.Println()

        // Make another ClientConn with round_robin policy.
        roundrobinConn, err := grpc.Dial(
                fmt.Sprintf("%s:///%s", exampleScheme, exampleServiceName),
                grpc.WithDefaultServiceConfig(`{"loadBalancingPolicy":"round_robin"}`), // This sets the initial balancing policy.
                grpc.WithInsecure(),
                grpc.WithBlock(),
        )
        if err != nil {
    
    
                log.Fatalf("did not connect: %v", err)
        }
        defer roundrobinConn.Close()

        fmt.Println("--- calling helloworld.Greeter/SayHello with round_robin ---")
        makeRPCs(roundrobinConn, 3)
}

// Following is an example name resolver implementation. Read the name
// resolution example to learn more about it.

type exampleResolverBuilder struct{
    
    }

func (*exampleResolverBuilder) Build(target resolver.Target, cc resolver.ClientConn, opts resolver.BuildOptions) (resolver.Resolver, error) {
    
    
        r := &exampleResolver{
    
    
                target: target,
                cc:     cc,
                addrsStore: map[string][]string{
    
    
                        exampleServiceName: addrs,
                },
        }
        r.start()
        return r, nil
}
func (*exampleResolverBuilder) Scheme() string {
    
     return exampleScheme }

type exampleResolver struct {
    
    
        target     resolver.Target
        cc         resolver.ClientConn
        addrsStore map[string][]string
}

func (r *exampleResolver) start() {
    
    
        addrStrs := r.addrsStore[r.target.Endpoint()]
        addrs := make([]resolver.Address, len(addrStrs))
        for i, s := range addrStrs {
    
    
                addrs[i] = resolver.Address{
    
    Addr: s}
        }
        r.cc.UpdateState(resolver.State{
    
    Addresses: addrs})
}
func (*exampleResolver) ResolveNow(o resolver.ResolveNowOptions) {
    
    }
func (*exampleResolver) Close()                                  {
    
    }
func init() {
    
    
        // Register the example ResolverBuilder. This is usually done in a package's
        // init() function.
        resolver.Register(&exampleResolverBuilder{
    
    })
}

(6)分别执行 Server 端和 Client 端的程序,分别输出如下的结果:

// Client
--- calling helloworld.Greeter/SayHello with pick_first ---
this is examples/load_balancing (from :50051)
this is examples/load_balancing (from :50051)
this is examples/load_balancing (from :50051)

--- calling helloworld.Greeter/SayHello with round_robin ---
this is examples/load_balancing (from :50052)
this is examples/load_balancing (from :50051)
this is examples/load_balancing (from :50052)

  • 参考链接:gRPC 教程

  • 参考链接:gRPC 官网

  • 参考书籍:《gRPC与云原生应用开发:以Go和Java为例》([斯里兰卡] 卡山 • 因德拉西里 丹尼什 • 库鲁普 著)

猜你喜欢

转载自blog.csdn.net/qq_46457076/article/details/129250000