11、分布式服务的Raft协调、多路复用与客户端负载均衡

分布式服务的Raft协调、多路复用与客户端负载均衡

1. 分布式日志复制测试

在分布式系统中,确保日志在各个节点之间正确复制是至关重要的。我们通过向领导者服务器追加一些记录,并检查Raft是否将这些记录复制到了其追随者节点来进行复制测试。由于Raft追随者在短延迟后才会应用追加消息,所以使用 testify Eventually() 方法给予Raft足够的时间完成复制。以下是测试代码:

require.Eventually(t, func() bool {
    for j := 0; j < nodeCount; j++ {
        got, err := logs[j].Read(off)
        if err != nil {
            return false
        }
        record.Offset = off
        if !reflect.DeepEqual(got.Value, record.Value) {
            return false
        }
    }
    return true
}, 500*time.Millisecond, 50*time.Millisecond)

接着,我们添加以下代码完成测试,检查领导者是否停止向离开集群的服务器复制,同时继续向现有服务器复制:

err := logs[0].Leave("1")
require.NoError(t, err)
time.Sleep(50 * time.Millisecond)
off, err := logs[0].Append(&api.Record{
    Value: []byte("third"),
})
require.NoError(t, err)
time.Sleep(50 * time.Millisecond)
record, err = logs[1].Read(off)
require.IsType(t, api.ErrOffsetOutOfRange{}, err)
require.Nil(t, record)
record, err = logs[2].Read(off)
require.NoError(t, err)
require.Equal(t, []byte("third"), record.Value)
require.Equal(t, off, record.Offset)

2. 单端口多路复用运行多个服务

多路复用允许在同一端口上提供不同的服务,具有诸多优点,如减少文档、配置和连接管理,即使在防火墙限制为一个端口的情况下也能提供多个服务。不过,每个新连接会有轻微的性能损失,因为多路复用器需要读取前几个字节来识别连接,但对于长连接来说,这种性能损失可以忽略不计。同时,要注意避免意外暴露服务。

许多使用Raft的分布式服务会将Raft与其他服务(如RPC服务)进行多路复用。在运行带有相互TLS的gRPC时,多路复用变得棘手,因为我们需要在TLS握手后进行连接多路复用。在握手之前,无法区分连接,只能知道它们都是TLS连接,需要握手并查看解密后的数据包才能了解更多信息。握手后,可以读取连接的数据包来确定连接是gRPC还是Raft连接。

为了区分Raft连接和gRPC连接,我们让Raft连接写入一个字节来标识。将数字1作为Raft连接的第一个字节,以将其与gRPC连接分开。如果有其他服务,可以通过向gRPC客户端传递自定义拨号器来发送数字2作为第一个字节来区分。

2.1 更新代理以进行多路复用

以下是更新代理以多路复用Raft和gRPC连接并创建分布式日志的步骤:
1. 更新导入 :在 internal/agent/agent.go 中更新导入:

import (
    "bytes"
    "crypto/tls"
    "fmt"
    "io"
    "net"
    "sync"
    "time"
    "go.uber.org/zap"
    "github.com/hashicorp/raft"
    "github.com/soheilhy/cmux"
    "google.golang.org/grpc"
    "google.golang.org/grpc/credentials"
    "github.com/travisjeffery/proglog/internal/auth"
    "github.com/travisjeffery/proglog/internal/discovery"
    "github.com/travisjeffery/proglog/internal/log"
    "github.com/travisjeffery/proglog/internal/server"
)
  1. 更新 Agent 类型
type Agent struct {
    Config Config
    mux    cmux.CMux
    log    *log.DistributedLog
    server *grpc.Server
    membership *discovery.Membership
    shutdown bool
    shutdowns chan struct{}
    shutdownLock sync.Mutex
}
  1. 添加 Bootstrap 字段到 Config 结构体
Bootstrap bool
  1. 设置多路复用器 :在 New() 函数中添加设置多路复用器的代码:
setup := []func() error {
    a.setupLogger,
    a.setupMux,
    a.setupLog,
    a.setupServer,
    a.setupMembership,
}

并在 New() 函数后添加 setupMux() 方法:

func (a *Agent) setupMux() error {
    rpcAddr := fmt.Sprintf(
        ":%d",
        a.Config.RPCPort,
    )
    ln, err := net.Listen("tcp", rpcAddr)
    if err != nil {
        return err
    }
    a.mux = cmux.New(ln)
    return nil
}

2.2 配置匹配Raft的规则并创建分布式日志

更新 setupLog() 方法来配置匹配Raft连接的规则并创建分布式日志:

func (a *Agent) setupLog() error {
    raftLn := a.mux.Match(func(reader io.Reader) bool {
        b := make([]byte, 1)
        if _, err := reader.Read(b); err != nil {
            return false
        }
        return bytes.Compare(b, []byte{byte(log.RaftRPC)}) == 0
    })
    logConfig := log.Config{}
    logConfig.Raft.StreamLayer = log.NewStreamLayer(
        raftLn,
        a.Config.ServerTLSConfig,
        a.Config.PeerTLSConfig,
    )
    logConfig.Raft.LocalID = raft.ServerID(a.Config.NodeName)
    logConfig.Raft.Bootstrap = a.Config.Bootstrap
    var err error
    a.log, err = log.NewDistributedLog(
        a.Config.DataDir,
        logConfig,
    )
    if err != nil {
        return err
    }
    if a.Config.Bootstrap {
        err = a.log.WaitForLeader(3 * time.Second)
    }
    return err
}

2.3 更新gRPC服务器以使用多路复用器的监听器

更新 setupServer() 方法:

func (a *Agent) setupServer() error {
    authorizer := auth.New(
        a.Config.ACLModelFile,
        a.Config.ACLPolicyFile,
    )
    serverConfig := &server.Config{
        CommitLog:  a.log,
        Authorizer: authorizer,
    }
    var opts []grpc.ServerOption
    if a.Config.ServerTLSConfig != nil {
        creds := credentials.NewTLS(a.Config.ServerTLSConfig)
        opts = append(opts, grpc.Creds(creds))
    }
    var err error
    a.server, err = server.NewGRPCServer(serverConfig, opts...)
    if err != nil {
        return err
    }
    grpcLn := a.mux.Match(cmux.Any())
    go func() {
        if err := a.server.Serve(grpcLn); err != nil {
            _ = a.Shutdown()
        }
    }()
    return err
}

2.4 更新成员资格设置

替换 setupMembership() 方法:

func (a *Agent) setupMembership() error {
    rpcAddr, err := a.Config.RPCAddr()
    if err != nil {
        return err
    }
    a.membership, err = discovery.New(a.log, discovery.Config{
        NodeName: a.Config.NodeName,
        BindAddr: a.Config.BindAddr,
        Tags: map[string]string{
            "rpc_addr": rpcAddr,
        },
        StartJoinAddrs: a.Config.StartJoinAddrs,
    })
    return err
}

2.5 启动多路复用器

New() 函数的返回语句之前添加:

go a.serve()

并在文件底部添加 serve() 方法:

func (a *Agent) serve() error {
    if err := a.mux.Serve(); err != nil {
        _ = a.Shutdown()
        return err
    }
    return nil
}

2.6 更新代理测试

internal/agent/agent_test.go 中添加以下代码:

Bootstrap: i == 0,

在测试底部添加:

consumeResponse, err = leaderClient.Consume(
    context.Background(),
    &api.ConsumeRequest{
        Offset: produceResponse.Offset + 1,
    },
)
require.Nil(t, consumeResponse)
require.Error(t, err)
got := grpc.Code(err)
want := grpc.Code(api.ErrOffsetOutOfRange{}.GRPCStatus().Err())
require.Equal(t, got, want)

运行测试: $ make test ,此时分布式服务使用Raft进行共识和复制。

3. 客户端发现服务器和负载均衡

3.1 三种负载均衡策略

解决发现和负载均衡问题有三种策略:
| 策略 | 描述 | 优点 | 缺点 |
| ---- | ---- | ---- | ---- |
| 服务器代理 | 客户端将请求发送到负载均衡器,负载均衡器知道服务器信息并将请求代理到后端服务 | 可作为信任边界,控制请求摄入 | |
| 外部负载均衡 | 客户端查询外部负载均衡服务,该服务知道服务器信息并告知客户端发送RPC的服务器 | 能做出更优的服务器选择决策 | 运营负担大 |
| 客户端负载均衡 | 客户端查询服务注册表了解服务器信息,选择服务器并直接发送RPC | 减少延迟,提高效率,无单点故障 | 需要处理网络和安全问题,让客户端直接访问服务器 |

我们选择客户端负载均衡,因为我们控制客户端和服务器,并且服务设计用于低延迟、高吞吐量的应用程序。

3.2 gRPC中的客户端负载均衡

gRPC将服务器发现、负载均衡和客户端请求与响应处理分开。在gRPC中,解析器发现服务器,选择器通过选择处理当前请求的服务器进行负载均衡。gRPC还有管理子连接的平衡器,但将负载均衡工作交给选择器。gRPC提供了创建基础平衡器的API,但通常不需要自己编写平衡器。

当调用 grpc.Dial 时,gRPC将地址传递给解析器,解析器发现服务器。gRPC的默认解析器是DNS解析器,如果提供的地址有多个DNS记录,gRPC将在这些记录的服务器之间平衡请求。可以编写自己的解析器或使用社区编写的解析器,如 Kuberesolver

gRPC默认使用轮询负载均衡算法,该算法按顺序将请求发送到服务器,每个服务器接收相同数量的请求。轮询适用于每个请求需要服务器进行相同工作量的情况,但它不考虑请求、客户端和服务器的具体信息。

3.3 使服务器可发现

为了让解析器发现集群中的服务器,需要在gRPC服务中添加一个端点来暴露服务器信息。
1. 更新 log.proto 文件

service Log {
    rpc Produce(ProduceRequest) returns (ProduceResponse) {}
    rpc Consume(ConsumeRequest) returns (ConsumeResponse) {}
    rpc ConsumeStream(ConsumeRequest) returns (stream ConsumeResponse) {}
    rpc ProduceStream(stream ProduceRequest) returns (stream ProduceResponse) {}
    rpc GetServers(GetServersRequest) returns (GetServersResponse) {}
}
message GetServersRequest {}
message GetServersResponse {
    repeated Server servers = 1;
}
message Server {
    string id = 1;
    string rpc_addr = 2;
    bool is_leader = 3;
}
  1. DistributedLog 中添加 GetServers() 方法
func (l *DistributedLog) GetServers() ([]*api.Server, error) {
    future := l.raft.GetConfiguration()
    if err := future.Error(); err != nil {
        return nil, err
    }
    var servers []*api.Server
    for _, server := range future.Configuration().Servers {
        servers = append(servers, &api.Server{
            Id:       string(server.ID),
            RpcAddr:  string(server.Address),
            IsLeader: l.raft.Leader() == server.Address,
        })
    }
    return servers, nil
}
  1. 更新 DistributedLog 测试
servers, err := logs[0].GetServers()
require.NoError(t, err)
require.Equal(t, 3, len(servers))
require.True(t, servers[0].IsLeader)
require.False(t, servers[1].IsLeader)
require.False(t, servers[2].IsLeader)
err = logs[0].Leave("1")
require.NoError(t, err)
time.Sleep(50 * time.Millisecond)
servers, err = logs[0].GetServers()
require.NoError(t, err)
require.Equal(t, 2, len(servers))
require.True(t, servers[0].IsLeader)
require.False(t, servers[1].IsLeader)
  1. 更新服务器配置和实现 GetServers 端点
    internal/server/server.go 中更新 Config 结构体:
type Config struct {
    CommitLog    CommitLog
    Authorizer   Authorizer
    GetServerer  GetServerer
}

ConsumeStream() 方法之后添加:

func (s *grpcServer) GetServers(
    ctx context.Context, req *api.GetServersRequest,
) (
    *api.GetServersResponse, error) {
    servers, err := s.GetServerer.GetServers()
    if err != nil {
        return nil, err
    }
    return &api.GetServersResponse{Servers: servers}, nil
}
type GetServerer interface {
    GetServers() ([]*api.Server, error)
}

通过以上步骤,我们实现了分布式服务的Raft协调、单端口多路复用以及客户端的发现和负载均衡,提高了服务的可用性、可扩展性和用户体验。

3.4 自定义解析器和选择器

为了解决当前客户端连接单一服务器的问题,我们可以编写自定义的解析器和选择器。解析器负责发现服务器并确定哪个是领导者,选择器则管理将追加(produce)调用定向到领导者,并在追随者之间平衡消费(consume)调用。

以下是实现自定义解析器和选择器的大致思路:
1. 解析器
- 调用 GetServers 端点获取服务器信息。
- 定期更新服务器列表,以适应集群变化。
2. 选择器
- 对于追加调用,将请求发送到领导者服务器。
- 对于消费调用,在追随者服务器之间进行负载均衡。

3.5 整体流程总结

以下是整个分布式服务从配置到实现客户端负载均衡的流程图:

graph LR
    classDef startend fill:#F5EBFF,stroke:#BE8FED,stroke-width:2px
    classDef process fill:#E5F6FF,stroke:#73A6FF,stroke-width:2px
    classDef decision fill:#FFF6CC,stroke:#FFBC52,stroke-width:2px

    A([开始]):::startend --> B(分布式日志复制测试):::process
    B --> C(单端口多路复用):::process
    C --> C1(更新代理导入):::process
    C --> C2(更新Agent类型):::process
    C --> C3(添加Bootstrap字段):::process
    C --> C4(设置多路复用器):::process
    C --> C5(配置匹配Raft规则):::process
    C --> C6(更新gRPC服务器):::process
    C --> C7(更新成员资格设置):::process
    C --> C8(启动多路复用器):::process
    C --> C9(更新代理测试):::process
    C9 --> D(客户端发现和负载均衡):::process
    D --> D1(选择负载均衡策略):::process
    D1 --> |客户端负载均衡| D2(gRPC客户端负载均衡基础):::process
    D2 --> D3(使服务器可发现):::process
    D3 --> D31(更新log.proto文件):::process
    D3 --> D32(添加GetServers方法):::process
    D3 --> D33(更新DistributedLog测试):::process
    D3 --> D34(更新服务器配置和端点):::process
    D34 --> D4(自定义解析器和选择器):::process
    D4 --> E([结束]):::startend

3.6 代码示例总结

以下是关键代码示例的总结表格:
| 功能 | 代码文件 | 代码片段 |
| ---- | ---- | ---- |
| 分布式日志复制测试 | - | go<br>require.Eventually(t, func() bool {<br> for j := 0; j < nodeCount; j++ {<br> got, err := logs[j].Read(off)<br> if err != nil {<br> return false<br> }<br> record.Offset = off<br> if !reflect.DeepEqual(got.Value, record.Value) {<br> return false<br> }<br> }<br> return true<br>}, 500*time.Millisecond, 50*time.Millisecond)<br> |
| 多路复用更新导入 | internal/agent/agent.go | go<br>import (<br> "bytes"<br> "crypto/tls"<br> "fmt"<br> "io"<br> "net"<br> "sync"<br> "time"<br> "go.uber.org/zap"<br> "github.com/hashicorp/raft"<br> "github.com/soheilhy/cmux"<br> "google.golang.org/grpc"<br> "google.golang.org/grpc/credentials"<br> "github.com/travisjeffery/proglog/internal/auth"<br> "github.com/travisjeffery/proglog/internal/discovery"<br> "github.com/travisjeffery/proglog/internal/log"<br> "github.com/travisjeffery/proglog/internal/server"<br>)<br> |
| 使服务器可发现 - 更新 log.proto | api/v1/log.proto | protobuf<br>service Log {<br> rpc Produce(ProduceRequest) returns (ProduceResponse) {}<br> rpc Consume(ConsumeRequest) returns (ConsumeResponse) {}<br> rpc ConsumeStream(ConsumeRequest) returns (stream ConsumeResponse) {}<br> rpc ProduceStream(stream ProduceRequest) returns (stream ProduceResponse) {}<br> rpc GetServers(GetServersRequest) returns (GetServersResponse) {}<br>}<br>message GetServersRequest {}<br>message GetServersResponse {<br> repeated Server servers = 1;<br>}<br>message Server {<br> string id = 1;<br> string rpc_addr = 2;<br> bool is_leader = 3;<br>}<br> |

3.7 注意事项

  • 安全问题 :在使用客户端负载均衡时,需要确保客户端有足够的权限访问服务器,同时要注意网络安全,避免数据泄露。
  • 性能监控 :定期监控服务器的性能指标,如CPU使用率、内存使用率、网络带宽等,以确保服务的稳定运行。
  • 集群变更处理 :当集群中的服务器发生变更(如加入、离开)时,解析器和选择器需要及时更新服务器信息,以保证负载均衡的正确性。

通过以上的配置和实现,我们构建了一个具有高可用性、可扩展性和良好用户体验的分布式服务。客户端可以自动发现服务器,将追加请求发送到领导者,将消费请求在追随者之间进行负载均衡,从而充分利用集群资源,提高服务性能。

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值