分布式服务的复制测试、端口复用及客户端负载均衡
1. 复制测试
在分布式系统中,需要对Raft的复制功能进行测试。通过向领导者服务器追加一些记录,然后检查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作为第一个字节来区分它们。
更新代理以复用其Raft和gRPC连接并创建分布式日志的步骤如下:
1.
更新导入
:
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
- 设置复用器 :
setup := []func() error {
a.setupLogger,
a.setupMux,
a.setupLog,
a.setupServer,
a.setupMembership,
}
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
}
-
更新
setupLog()方法 :
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
}
-
更新
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
}
-
更新
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
}
-
删除不必要的代码
:删除
a.replicator.Close行和internal/log/replicator.go文件。 - 启动复用器 :
go a.serve()
func (a *Agent) serve() error {
if err := a.mux.Serve(); err != nil {
_ = a.Shutdown()
return err
}
return nil
}
3. 客户端负载均衡
3.1 三种负载均衡策略
- 服务器代理 :客户端将请求发送到负载均衡器,负载均衡器知道服务器(通过查询服务注册表或本身就是服务注册表)并将请求代理到后端服务。
- 外部负载均衡 :客户端查询外部负载均衡服务,该服务知道服务器并告诉客户端将RPC发送到哪个服务器。
- 客户端侧负载均衡 :客户端查询服务注册表以了解服务器,选择要发送RPC的服务器,并直接将RPC发送到该服务器。
| 负载均衡策略 | 优点 | 缺点 |
|---|---|---|
| 服务器代理 | 可作为信任边界,控制请求摄入,常用于外部流量负载均衡 | 增加了中间环节,可能会增加延迟 |
| 外部负载均衡 | 能进行复杂和准确的负载均衡 | 运营负担大 |
| 客户端侧负载均衡 | 减少延迟,提高效率,具有弹性 | 需要处理网络和安全问题,让客户端直接访问服务器 |
3.2 gRPC中的客户端负载均衡
gRPC将服务器发现、负载均衡以及客户端请求和响应处理分开。在gRPC中,解析器负责发现服务器,选择器负责负载均衡,即选择哪个服务器处理当前请求。gRPC还具有管理子连接的平衡器,但将负载均衡工作交给选择器。
gRPC默认使用轮询负载均衡算法,该算法按顺序将请求依次发送到各个服务器,适用于每个请求需要服务器进行相同工作量的情况。但轮询负载均衡不考虑请求、客户端和服务器的具体情况,例如:
- 对于具有单个写入器和多个读取器的复制分布式服务,希望从副本读取数据,让写入器专注于写入,这需要知道请求是读还是写,以及服务器是主服务器还是副本。
- 对于全球分布式服务,希望客户端优先与本地服务器进行网络通信,这需要知道客户端和服务器的位置。
- 对于对延迟敏感的系统,可以跟踪服务器的飞行中或排队请求数量等延迟指标,并让客户端请求具有最小数量的服务器。
为了解决这些问题,可以编写自己的解析器和选择器。解析器负责发现服务器并确定哪个服务器是领导者,选择器负责将生产调用定向到领导者,并在追随者之间平衡消费调用。
4. 让服务器可发现
解析器需要一种方法来发现集群中的服务器,包括每个服务器的地址以及是否为领导者。可以通过gRPC服务的端点将这些信息暴露给解析器。
-
更新
api/v1/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;
}
-
在
internal/log/distributed.go文件中添加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)
-
更新
internal/server/server.go文件 :
type Config struct {
CommitLog CommitLog
Authorizer Authorizer
GetServerer GetServerer
}
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)
}
通过以上步骤,可以实现分布式服务的复制测试、端口复用以及客户端负载均衡,提高服务的可用性、可扩展性和用户体验。
分布式服务的复制测试、端口复用及客户端负载均衡
5. 实现解析器和选择器
为了让客户端能够自动发现服务器、将追加调用定向到领导者并在追随者之间平衡消费调用,我们需要实现自定义的解析器和选择器。
5.1 解析器的实现思路
解析器的主要任务是发现集群中的服务器,并确定哪个是领导者。可以利用前面提到的
GetServers
端点来获取服务器信息。以下是一个简单的解析器实现示例:
package resolver
import (
"context"
"google.golang.org/grpc/resolver"
"yourproject/api/v1"
"yourproject/internal/client"
)
type CustomResolver struct {
target resolver.Target
cc resolver.ClientConn
client api.LogClient
}
func NewCustomResolver(target resolver.Target, cc resolver.ClientConn) resolver.Resolver {
conn, err := client.Dial(target.Endpoint())
if err != nil {
// 处理错误
}
r := &CustomResolver{
target: target,
cc: cc,
client: api.NewLogClient(conn),
}
r.ResolveNow(resolver.ResolveNowOptions{})
return r
}
func (r *CustomResolver) ResolveNow(options resolver.ResolveNowOptions) {
ctx := context.Background()
resp, err := r.client.GetServers(ctx, &api.GetServersRequest{})
if err != nil {
// 处理错误
}
var addresses []resolver.Address
for _, server := range resp.Servers {
addresses = append(addresses, resolver.Address{
Addr: server.RpcAddr,
ServerName: server.Id,
Attributes: map[string]interface{}{
"is_leader": server.IsLeader,
},
})
}
r.cc.UpdateState(resolver.State{
Addresses: addresses,
})
}
func (r *CustomResolver) Close() {
// 关闭连接等操作
}
func init() {
resolver.Register(&resolver.Builder{
Scheme: "custom",
Build: func(target resolver.Target, cc resolver.ClientConn, opts resolver.BuildOptions) (resolver.Resolver, error) {
return NewCustomResolver(target, cc), nil
},
})
}
这个解析器会定期调用
GetServers
端点,获取服务器信息,并更新gRPC的连接状态。
5.2 选择器的实现思路
选择器负责根据解析器提供的服务器信息,将请求定向到合适的服务器。对于追加调用,应该定向到领导者;对于消费调用,应该在追随者之间进行负载均衡。以下是一个简单的选择器实现示例:
package picker
import (
"google.golang.org/grpc/balancer"
"google.golang.org/grpc/balancer/base"
"google.golang.org/grpc/resolver"
)
type CustomPicker struct {
leaderAddr resolver.Address
followerAddrs []resolver.Address
index int
}
func NewCustomPicker(buildInfo base.PickerBuildInfo) balancer.Picker {
var leaderAddr resolver.Address
var followerAddrs []resolver.Address
for _, subConnInfo := range buildInfo.ReadySCs {
isLeader, ok := subConnInfo.Address.Attributes.Value("is_leader").(bool)
if ok && isLeader {
leaderAddr = subConnInfo.Address
} else {
followerAddrs = append(followerAddrs, subConnInfo.Address)
}
}
return &CustomPicker{
leaderAddr: leaderAddr,
followerAddrs: followerAddrs,
index: 0,
}
}
func (p *CustomPicker) Pick(info balancer.PickInfo) (balancer.PickResult, error) {
if info.FullMethodName == "/api.Log/Produce" {
if p.leaderAddr.Addr != "" {
return balancer.PickResult{
SubConn: info.SubConnMap[p.leaderAddr],
}, nil
}
return balancer.PickResult{}, balancer.ErrNoSubConnAvailable
}
if len(p.followerAddrs) == 0 {
return balancer.PickResult{}, balancer.ErrNoSubConnAvailable
}
addr := p.followerAddrs[p.index]
p.index = (p.index + 1) % len(p.followerAddrs)
return balancer.PickResult{
SubConn: info.SubConnMap[addr],
}, nil
}
func init() {
balancer.Register(base.NewBalancerBuilderV2("custom", NewCustomPicker, base.Config{}))
}
这个选择器会根据请求的方法名(
Produce
或
Consume
)来选择合适的服务器。
6. 测试与验证
在实现了解析器和选择器之后,需要进行测试和验证,确保客户端能够正确地发现服务器、定向请求和进行负载均衡。
6.1 测试环境搭建
可以使用测试框架(如
go test
)来搭建测试环境。启动多个服务器实例,模拟集群环境,并使用客户端进行请求。
6.2 测试用例编写
以下是一些测试用例的示例:
-
追加记录到领导者
:
func TestAppendToLeader(t *testing.T) {
ctx := context.Background()
conn, err := grpc.Dial("custom:///your-target", grpc.WithResolvers(&resolver.CustomResolverBuilder{}), grpc.WithBalancerName("custom"))
if err != nil {
t.Fatalf("Failed to dial: %v", err)
}
defer conn.Close()
client := api.NewLogClient(conn)
resp, err := client.Produce(ctx, &api.ProduceRequest{
Record: &api.Record{
Value: []byte("test record"),
},
})
if err != nil {
t.Fatalf("Failed to produce: %v", err)
}
// 验证响应
}
- 从追随者消费记录 :
func TestConsumeFromFollowers(t *testing.T) {
ctx := context.Background()
conn, err := grpc.Dial("custom:///your-target", grpc.WithResolvers(&resolver.CustomResolverBuilder{}), grpc.WithBalancerName("custom"))
if err != nil {
t.Fatalf("Failed to dial: %v", err)
}
defer conn.Close()
client := api.NewLogClient(conn)
resp, err := client.Consume(ctx, &api.ConsumeRequest{
Offset: 0,
})
if err != nil {
t.Fatalf("Failed to consume: %v", err)
}
// 验证响应
}
7. 总结
通过实现分布式服务的复制测试、端口复用、客户端负载均衡以及服务器发现功能,我们可以提高服务的可用性、可扩展性和用户体验。具体步骤如下:
1. 进行复制测试,确保Raft能够正确地将记录复制到追随者。
2. 实现端口复用,让Raft和gRPC服务可以在同一端口上运行。
3. 选择合适的负载均衡策略,如客户端侧负载均衡,并在gRPC中实现自定义的解析器和选择器。
4. 让服务器可发现,通过gRPC端点暴露服务器信息。
5. 编写测试用例,验证系统的正确性。
以下是整个过程的流程图:
graph TD
A[复制测试] --> B[端口复用]
B --> C[选择负载均衡策略]
C --> D[实现解析器和选择器]
D --> E[让服务器可发现]
E --> F[测试与验证]
通过以上步骤的实施,可以构建一个高效、稳定的分布式服务系统。
超级会员免费看
1173

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



