分布式服务的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"
)
-
更新
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
}
-
添加
Bootstrap字段到Config结构体 :
Bootstrap bool
-
设置多路复用器
:在
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;
}
-
在
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
}
-
更新
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)
-
更新服务器配置和实现
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使用率、内存使用率、网络带宽等,以确保服务的稳定运行。
- 集群变更处理 :当集群中的服务器发生变更(如加入、离开)时,解析器和选择器需要及时更新服务器信息,以保证负载均衡的正确性。
通过以上的配置和实现,我们构建了一个具有高可用性、可扩展性和良好用户体验的分布式服务。客户端可以自动发现服务器,将追加请求发送到领导者,将消费请求在追随者之间进行负载均衡,从而充分利用集群资源,提高服务性能。
超级会员免费看
27

被折叠的 条评论
为什么被折叠?



