12、深入理解 gRPC 客户端服务发现与负载均衡

深入理解 gRPC 客户端服务发现与负载均衡

1. 服务发现基础与准备

非分布式日志(如 Log 类型)对服务器信息并不了解。为了解决这个问题,我们创建了一个新的接口,其唯一方法 GetServers() DistributedLog.GetServers 相匹配。在更新 agent 包中的端到端测试时,我们会将 DistributedLog 同时设置为 CommitLog GetServerer ,新的服务器端点会对其进行错误处理。

agent.go 中,更新 setupServer() 方法,让服务器从 DistributedLog 获取集群的服务器信息:

ClientSideServiceDiscovery/internal/agent/agent.go
serverConfig := &server.Config{
    CommitLog:    a.log,
    Authorizer:   authorizer,
    GetServerer:  a.log,
}

这样,我们就有了一个客户端可以调用的服务器端点,用于获取集群的服务器信息。接下来,我们将构建解析器。

2. 构建解析器

我们将编写的 gRPC 解析器会调用 GetServers() 端点,并将获取的信息传递给 gRPC,以便选择器知道可以将请求路由到哪些服务器。

操作步骤如下:
1. 创建一个新的包用于解析器和选择器代码:

$ mkdir internal/loadbalance
  1. internal/loadbalance 中创建 resolver.go 文件,代码如下:
ClientSideServiceDiscovery/internal/loadbalance/resolver.go
package loadbalance
import (
    "context"
    "fmt"
    "sync"
    "go.uber.org/zap"
    "google.golang.org/grpc"
    "google.golang.org/grpc/attributes"
    "google.golang.org/grpc/resolver"
    "google.golang.org/grpc/serviceconfig"
    api "github.com/travisjeffery/proglog/api/v1"
)

type Resolver struct {
    mu            sync.Mutex
    clientConn    resolver.ClientConn
    resolverConn  *grpc.ClientConn
    serviceConfig *serviceconfig.ParseResult
    logger        *zap.Logger
}

Resolver 类型将实现 gRPC 的 resolver.Builder resolver.Resolver 接口。 clientConn 是用户的客户端连接,gRPC 会将其传递给解析器,以便解析器用发现的服务器信息更新它。 resolverConn 是解析器自己到服务器的客户端连接,用于调用 GetServers() 并获取服务器信息。

  1. 实现 resolver.Builder 接口:
ClientSideServiceDiscovery/internal/loadbalance/resolver.go
var _ resolver.Builder = (*Resolver)(nil)
func (r *Resolver) Build(
    target resolver.Target,
    cc resolver.ClientConn,
    opts resolver.BuildOptions,
) (resolver.Resolver, error) {
    r.logger = zap.L().Named("resolver")
    r.clientConn = cc
    var dialOpts []grpc.DialOption
    if opts.DialCreds != nil {
        dialOpts = append(
            dialOpts,
            grpc.WithTransportCredentials(opts.DialCreds),
        )
    }
    r.serviceConfig = r.clientConn.ParseServiceConfig(
        fmt.Sprintf(`{"loadBalancingConfig":[{"%s":{}}]}`, Name),
    )
    var err error
    r.resolverConn, err = grpc.Dial(target.Endpoint, dialOpts...)
    if err != nil {
        return nil, err
    }
    r.ResolveNow(resolver.ResolveNowOptions{})
    return r, nil
}
const Name = "proglog"
func (r *Resolver) Scheme() string {
    return Name
}
func init() {
    resolver.Register(&Resolver{})
}

resolver.Builder 包含两个方法:
- Build() :接收构建解析器所需的数据(如目标地址)和解析器将用发现的服务器信息更新的客户端连接。它会设置一个到服务器的客户端连接,以便解析器可以调用 GetServers() API。
- Scheme() :返回解析器的方案标识符。当调用 grpc.Dial 时,gRPC 会从你提供的目标地址中解析出方案,并尝试找到匹配的解析器,默认使用其 DNS 解析器。对于我们的解析器,目标地址的格式应为: proglog://your-service-address

我们在 init() 中向 gRPC 注册这个解析器,以便 gRPC 在查找与目标方案匹配的解析器时知道它。

  1. 实现 resolver.Resolver 接口:
ClientSideServiceDiscovery/internal/loadbalance/resolver.go
var _ resolver.Resolver = (*Resolver)(nil)
func (r *Resolver) ResolveNow(resolver.ResolveNowOptions) {
    r.mu.Lock()
    defer r.mu.Unlock()
    client := api.NewLogClient(r.resolverConn)
    ctx := context.Background()
    res, err := client.GetServers(ctx, &api.GetServersRequest{})
    if err != nil {
        r.logger.Error(
            "failed to resolve server",
            zap.Error(err),
        )
        return
    }
    var addrs []resolver.Address
    for _, server := range res.Servers {
        addrs = append(addrs, resolver.Address{
            Addr: server.RpcAddr,
            Attributes: attributes.New(
                "is_leader",
                server.IsLeader,
            ),
        })
    }
    r.clientConn.UpdateState(resolver.State{
        Addresses:     addrs,
        ServiceConfig: r.serviceConfig,
    })
}
func (r *Resolver) Close() {
    if err := r.resolverConn.Close(); err != nil {
        r.logger.Error(
            "failed to close conn",
            zap.Error(err),
        )
    }
}

resolver.Resolver 包含两个方法:
- ResolveNow() :gRPC 调用此方法来解析目标、发现服务器,并使用服务器信息更新客户端连接。解析器发现服务器的方式取决于解析器和所使用的服务。我们创建一个 gRPC 客户端来调用 GetServers() API 获取集群的服务器信息。
- Close() :关闭解析器。在我们的解析器中,关闭在 Build() 中创建的到服务器的连接。

3. 测试解析器

internal/loadbalance 中创建 resolver_test.go 文件,代码如下:

ClientSideServiceDiscovery/internal/loadbalance/resolver_test.go
package loadbalance_test
import (
    "net"
    "testing"
    "github.com/stretchr/testify/require"
    "google.golang.org/grpc"
    "google.golang.org/grpc/attributes"
    "google.golang.org/grpc/credentials"
    "google.golang.org/grpc/resolver"
    "google.golang.org/grpc/serviceconfig"
    api "github.com/travisjeffery/proglog/api/v1"
    "github.com/travisjeffery/proglog/internal/loadbalance"
    "github.com/travisjeffery/proglog/internal/config"
    "github.com/travisjeffery/proglog/internal/server"
)
func TestResolver(t *testing.T) {
    l, err := net.Listen("tcp", "127.0.0.1:0")
    require.NoError(t, err)
    tlsConfig, err := config.SetupTLSConfig(config.TLSConfig{
        CertFile:      config.ServerCertFile,
        KeyFile:       config.ServerKeyFile,
        CAFile:        config.CAFile,
        Server:        true,
        ServerAddress: "127.0.0.1",
    })
    require.NoError(t, err)
    serverCreds := credentials.NewTLS(tlsConfig)
    srv, err := server.NewGRPCServer(&server.Config{
        GetServerer: &getServers{},
    }, grpc.Creds(serverCreds))
    require.NoError(t, err)
    go srv.Serve(l)
    conn := &clientConn{}
    tlsConfig, err = config.SetupTLSConfig(config.TLSConfig{
        CertFile:      config.RootClientCertFile,
        KeyFile:       config.RootClientKeyFile,
        CAFile:        config.CAFile,
        Server:        false,
        ServerAddress: "127.0.0.1",
    })
    require.NoError(t, err)
    clientCreds := credentials.NewTLS(tlsConfig)
    opts := resolver.BuildOptions{
        DialCreds: clientCreds,
    }
    r := &loadbalance.Resolver{}
    _, err = r.Build(
        resolver.Target{
            Endpoint: l.Addr().String(),
        },
        conn,
        opts,
    )
    require.NoError(t, err)
    wantState := resolver.State{
        Addresses: []resolver.Address{{
            Addr:       "localhost:9001",
            Attributes: attributes.New("is_leader", true),
        }, {
            Addr:       "localhost:9002",
            Attributes: attributes.New("is_leader", false),
        }},
    }
    require.Equal(t, wantState, conn.state)
    conn.state.Addresses = nil
    r.ResolveNow(resolver.ResolveNowOptions{})
    require.Equal(t, wantState, conn.state)
}
type getServers struct{}
func (s *getServers) GetServers() ([]*api.Server, error) {
    return []*api.Server{{
        Id:       "leader",
        RpcAddr:  "localhost:9001",
        IsLeader: true,
    }, {
        Id:       "follower",
        RpcAddr:  "localhost:9002",
    }}, nil
}
type clientConn struct {
    resolver.ClientConn
    state resolver.State
}
func (c *clientConn) UpdateState(state resolver.State) {
    c.state = state
}
func (c *clientConn) ReportError(err error) {}
func (c *clientConn) NewAddress(addrs []resolver.Address) {}
func (c *clientConn) NewServiceConfig(config string) {}
func (c *clientConn) ParseServiceConfig(
    config string,
) *serviceconfig.ParseResult {
    return nil
}

这个测试代码设置了一个服务器,让测试解析器尝试从中发现一些服务器。我们传入一个模拟的 GetServerers ,以便设置解析器应该找到的服务器。

4. 解析器工作流程
graph TD;
    A[开始] --> B[创建解析器实例];
    B --> C[调用Build方法];
    C --> D[设置客户端连接和日志记录器];
    D --> E[处理传输凭证];
    E --> F[解析服务配置];
    F --> G[建立到服务器的连接];
    G --> H[调用ResolveNow方法];
    H --> I[调用GetServers API获取服务器信息];
    I --> J[更新客户端连接状态];
    J --> K[结束];
5. 解析器相关信息总结
接口 方法 功能
resolver.Builder Build() 接收构建解析器所需的数据,设置到服务器的客户端连接
resolver.Builder Scheme() 返回解析器的方案标识符
resolver.Resolver ResolveNow() 解析目标、发现服务器并更新客户端连接
resolver.Resolver Close() 关闭解析器连接
6. 实现选择器

在 gRPC 架构中,选择器负责处理 RPC 负载均衡逻辑。它们从解析器发现的服务器中选择一个服务器来处理每个 RPC。选择器可以根据 RPC、客户端和服务器的信息进行请求路由,其功能不仅仅局限于负载均衡。

操作步骤如下:
1. 在 internal/loadbalance 中创建 picker.go 文件,代码如下:

ClientSideServiceDiscovery/internal/loadbalance/picker.go
package loadbalance
import (
    "strings"
    "sync"
    "sync/atomic"
    "google.golang.org/grpc/balancer"
    "google.golang.org/grpc/balancer/base"
)
var _ base.PickerBuilder = (*Picker)(nil)
type Picker struct {
    mu        sync.RWMutex
    leader    balancer.SubConn
    followers []balancer.SubConn
    current   uint64
}
func (p *Picker) Build(buildInfo base.PickerBuildInfo) balancer.Picker {
    p.mu.Lock()
    defer p.mu.Unlock()
    var followers []balancer.SubConn
    for sc, scInfo := range buildInfo.ReadySCs {
        isLeader := scInfo.Address.Attributes.Value("is_leader").(bool)
        if isLeader {
            p.leader = sc
            continue
        }
        followers = append(followers, sc)
    }
    p.followers = followers
    return p
}

选择器和解析器一样使用构建器模式。gRPC 将子连接及其信息的映射传递给 Build() 方法来设置选择器。我们的选择器将把消费 RPC 路由到跟随者服务器,将生产 RPC 路由到领导者服务器。

  1. 实现 balancer.Picker 接口:
ClientSideServiceDiscovery/internal/loadbalance/picker.go
var _ balancer.Picker = (*Picker)(nil)
func (p *Picker) Pick(info balancer.PickInfo) (
    balancer.PickResult, error) {
    p.mu.RLock()
    defer p.mu.RUnlock()
    var result balancer.PickResult
    if strings.Contains(info.FullMethodName, "Produce") ||
        len(p.followers) == 0 {
        result.SubConn = p.leader
    } else if strings.Contains(info.FullMethodName, "Consume") {
        result.SubConn = p.nextFollower()
    }
    if result.SubConn == nil {
        return result, balancer.ErrNoSubConnAvailable
    }
    return result, nil
}
func (p *Picker) nextFollower() balancer.SubConn {
    cur := atomic.AddUint64(&p.current, uint64(1))
    len := uint64(len(p.followers))
    idx := int(cur % len)
    return p.followers[idx]
}

选择器有一个方法 Pick(balancer.PickInfo) 。gRPC 会给 Pick() 方法一个包含 RPC 名称和上下文的 balancer.PickInfo ,帮助选择器选择子连接。我们根据 RPC 的方法名来决定选择领导者还是跟随者子连接,并使用轮询算法在跟随者之间平衡消费调用。

  1. 注册选择器:
ClientSideServiceDiscovery/internal/loadbalance/picker.go
func init() {
    balancer.Register(
        base.NewBalancerBuilder(Name, &Picker{}, base.Config{}),
    )
}
7. 测试选择器

internal/loadbalance 中创建 picker_test.go 文件,代码如下:

ClientSideServiceDiscovery/internal/loadbalance/picker_test.go
package loadbalance_test
import (
    "testing"
    "google.golang.org/grpc/attributes"
    "google.golang.org/grpc/balancer"
    "google.golang.org/grpc/balancer/base"
    "google.golang.org/grpc/resolver"
    "github.com/stretchr/testify/require"
    "github.com/travisjeffery/proglog/internal/loadbalance"
)
func TestPickerNoSubConnAvailable(t *testing.T) {
    picker := &loadbalance.Picker{}
    for _, method := range []string{
        "/log.vX.Log/Produce",
        "/log.vX.Log/Consume",
    } {
        info := balancer.PickInfo{
            FullMethodName: method,
        }
        result, err := picker.Pick(info)
        require.Equal(t, balancer.ErrNoSubConnAvailable, err)
        require.Nil(t, result.SubConn)
    }
}
func TestPickerProducesToLeader(t *testing.T) {
    picker, subConns := setupTest()
    info := balancer.PickInfo{
        FullMethodName: "/log.vX.Log/Produce",
    }
    for i := 0; i < 5; i++ {
        gotPick, err := picker.Pick(info)
        require.NoError(t, err)
        require.Equal(t, subConns[0], gotPick.SubConn)
    }
}
func TestPickerConsumesFromFollowers(t *testing.T) {
    picker, subConns := setupTest()
    info := balancer.PickInfo{
        FullMethodName: "/log.vX.Log/Consume",
    }
    for i := 0; i < 5; i++ {
        pick, err := picker.Pick(info)
        require.NoError(t, err)
        require.Equal(t, subConns[i%2+1], pick.SubConn)
    }
}
func setupTest() (*loadbalance.Picker, []*subConn) {
    var subConns []*subConn
    buildInfo := base.PickerBuildInfo{
        ReadySCs: make(map[balancer.SubConn]base.SubConnInfo),
    }
    for i := 0; i < 3; i++ {
        sc := &subConn{}
        addr := resolver.Address{
            Attributes: attributes.New("is_leader", i == 0),
        }
        sc.UpdateAddresses([]resolver.Address{addr})
        buildInfo.ReadySCs[sc] = base.SubConnInfo{Address: addr}
        subConns = append(subConns, sc)
    }
    picker := &loadbalance.Picker{}
    picker.Build(buildInfo)
    return picker, subConns
}
type subConn struct {
    addrs []resolver.Address
}
func (s *subConn) UpdateAddresses(addrs []resolver.Address) {
    s.addrs = addrs
}
func (s *subConn) Connect() {}

TestPickerNoSubConnAvailable() 测试在解析器发现服务器并更新选择器状态之前,选择器是否返回 balancer.ErrNoSubConnAvailable TestPickerProducesToLeader() 测试选择器是否为生产调用选择领导者子连接。 TestPickerConsumesFromFollowers() 测试选择器是否使用轮询算法为消费调用选择跟随者子连接。

8. 选择器工作流程
graph TD;
    A[开始] --> B[创建选择器实例];
    B --> C[调用Build方法];
    C --> D[区分领导者和跟随者子连接];
    D --> E[等待Pick请求];
    E --> F[根据RPC方法名选择子连接];
    F --> G[若为Produce或无跟随者选领导者];
    F --> H[若为Consume选跟随者];
    G --> I[返回所选子连接];
    H --> J[使用轮询算法选跟随者];
    J --> I;
    I --> K[结束];
9. 选择器相关信息总结
接口 方法 功能
base.PickerBuilder Build() 接收子连接信息,区分领导者和跟随者子连接
balancer.Picker Pick() 根据 RPC 方法名选择子连接处理请求
10. 端到端测试

现在我们准备更新代理测试,进行端到端测试,包括客户端配置解析器和选择器、解析器发现服务器以及选择器为每个 RPC 选择子连接。

操作步骤如下:
1. 打开 internal/agent/agent_test.go 文件,添加以下导入:

ClientSideServiceDiscovery/internal/agent/agent_test.go
"github.com/travisjeffery/proglog/internal/loadbalance"
  1. 更新 client() 函数以使用解析器和选择器:
ClientSideServiceDiscovery/internal/agent/agent_test.go
func client(
    t *testing.T,
    agent *agent.Agent,
    tlsConfig *tls.Config,
) api.LogClient {
    tlsCreds := credentials.NewTLS(tlsConfig)
    opts := []grpc.DialOption{
        grpc.WithTransportCredentials(tlsCreds),
    }
    rpcAddr, err := agent.Config.RPCAddr()
    require.NoError(t, err)
    conn, err := grpc.Dial(fmt.Sprintf(
        "%s:///%s",
        loadbalance.Name,
        rpcAddr,
    ), opts...)
    require.NoError(t, err)
    client := api.NewLogClient(conn)
    return client
}
  1. 调整测试代码,等待服务器复制记录后再进行消费:
ClientSideServiceDiscovery/internal/agent/agent_test.go
// wait until replication has finished
time.Sleep(3 * time.Second)
consumeResponse, err := leaderClient.Consume(
    context.Background(),
    &api.ConsumeRequest{
        Offset: produceResponse.Offset,
    },
)
require.NoError(t, err)
require.Equal(t, consumeResponse.Record.Value, []byte("foo"))
followerClient := client(t, agents[1], peerTLSConfig)
consumeResponse, err = followerClient.Consume(
    context.Background(),
    &api.ConsumeRequest{
        Offset: produceResponse.Offset,
    },
)
require.NoError(t, err)
require.Equal(t, consumeResponse.Record.Value, []byte("foo"))

运行测试: $ make test ,确保测试通过。

通过以上步骤,我们深入了解了 gRPC 如何解析服务并在它们之间平衡调用,以及如何构建自己的解析器和选择器。解析器可以让客户端动态发现服务器,选择器不仅可用于负载均衡,还能实现自定义的路由逻辑。

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值