Minimatch 轻量级匹配服务使用体验
前言
最近在一个项目中,了解到了minimatch这个轻量级匹配服务,并借助minimatch实现了简单的match-maker服务,进行了学习。
在游戏服务器匹配场景中,Open Match 是一个成熟的解决方案,但它的架构相对复杂,需要部署多个 Kubernetes 组件。在本地开发、或者不希望额外管理open-match的复杂性,只想实现 match 核心逻辑,minimatch 是一个很好的选择。
核心概念
1. Frontend 服务
Frontend 负责接收玩家的匹配请求,创建和管理 Ticket。每个 Ticket 包含玩家的搜索条件,如地区、等级、角色等。
// 创建 Ticket
ticket := &pb.Ticket{
SearchFields: &pb.SearchFields{
Tags: []string{
fmt.Sprintf("region:%s", user.Region),
fmt.Sprintf("role:%s", user.Role),
},
DoubleArgs: map[string]float64{
"level": float64(user.Level),
},
},
}
2. Backend 服务
Backend 是匹配的核心,它定期(每个 tick)从 Redis 获取活跃的 Tickets,执行匹配逻辑,并将匹配结果分配给 GameServer。
Backend 包含三个关键组件:
- MatchProfile:定义匹配规则,包括多个 Pool(匹配池),每个 Pool 可以设置过滤条件(如地区、等级范围)
- MatchFunction:实现具体的匹配算法,根据 Pool 中的 Tickets 生成 Match
- Assigner:将匹配结果分配给 GameServer
3. 匹配流程
玩家请求 → Frontend 创建 Ticket → Redis 存储
↓
Backend 定时轮询(通过提前设置的tick) → 获取 Tickets → MatchFunction 匹配 → Assigner 分配 GameServer → 更新 Ticket 状态
实践:与 Agones 集成实现一个简单的match-maker
架构设计
我们将 minimatch 的 Frontend 和 Backend 集成到同一个服务中,通过 HTTP API 对外提供服务:
- Frontend 服务:内部运行 minimatch 的 gRPC Frontend 服务,同时提供 HTTP 接口
- Backend 服务:内部运行 minimatch 的 Backend,执行匹配逻辑
- Agones 集成:在 Assigner 中调用 Agones Allocation API 或者 Allocator Service(grpc over tls) 分配 GameServer
Frontend 初始化
Frontend 需要先创建 Redis 连接和 StateStore,然后启动 gRPC 服务:
// 创建 Redis 客户端
redisClient, err := rueidis.NewClient(
rueidis.ClientOption{
InitAddress: []string{"localhost:6379"},
})
if err != nil {
log.Fatalf("Failed to create Redis client: %v", err)
}
// 创建 Locker(用于分布式锁)
lockerOpt := rueidislock.LockerOption{
ClientOption: rueidis.ClientOption{
InitAddress: []string{"localhost:6379"},
},
ExtendInterval: 200 * time.Millisecond,
}
locker, err := rueidislock.NewLocker(lockerOpt)
if err != nil {
log.Fatalf("Failed to create locker: %v", err)
}
// 创建 Redis Store
redisstore := statestore.NewRedisStore(
redisClient,
locker,
statestore.WithRedisKeyPrefix("minimatch"),
)
// 创建 Frontend gRPC 服务
frontendService := minimatch.NewFrontendGPRCService(redisstore)
lis, err := net.Listen("tcp", ":50504")
if err != nil {
log.Fatalf("Failed to listen: %v", err)
}
grpcServer := grpc.NewServer()
pb.RegisterFrontendServiceServer(grpcServer, frontendService)
go func() {
if err := grpcServer.Serve(lis); err != nil {
log.Fatalf("Failed to serve: %v", err)
}
}()
// 创建 Frontend 客户端(用于创建 Ticket)
conn, err := grpc.NewClient("localhost:50504", grpc.WithTransportCredentials(insecure.NewCredentials()))
if err != nil {
log.Fatalf("Failed to connect to minimatch Frontend: %v", err)
}
frontendClient := pb.NewFrontendServiceClient(conn)
Backend 初始化
Backend 需要创建 Redis 连接、Assigner 和 MatchFunction,然后启动匹配循环:
// 创建 Redis 客户端和 Store(与 Frontend 类似)
redisClient, err := rueidis.NewClient(
rueidis.ClientOption{
InitAddress: []string{"localhost:6379"},
})
if err != nil {
log.Fatalf("Failed to create Redis client: %v", err)
}
lockerOpt := rueidislock.LockerOption{
ClientOption: rueidis.ClientOption{
InitAddress: []string{"localhost:6379"},
},
ExtendInterval: 200 * time.Millisecond,
}
locker, err := rueidislock.NewLocker(lockerOpt)
if err != nil {
log.Fatalf("Failed to create locker: %v", err)
}
redisstore := statestore.NewRedisStore(
redisClient,
locker,
statestore.WithRedisKeyPrefix("minimatch"),
)
// 创建 Assigner(分配 GameServer)
assigner := minimatch.AssignerFunc(func(ctx context.Context, matches []*pb.Match) ([]*pb.AssignmentGroup, error) {
return allocateGameServer(ctx, matches)
})
// 创建 Backend
backend, err := minimatch.NewBackend(redisstore, assigner)
if err != nil {
log.Fatalf("Failed to create backend: %v", err)
}
// 添加 MatchFunction 和 MatchProfile
backend.AddMatchFunction(&openmatch.MatchProfile{
Name: "test",
Pools: []*openmatch.Pool{
// ... Pool 配置
},
}, &testMatchFunction{})
// 启动 Backend(每个 tick 执行一次匹配)
if err := backend.Start(context.Background(), 1*time.Second); err != nil {
log.Fatalf("Failed to start backend: %v", err)
}
MatchProfile 配置
backend.AddMatchFunction(&openmatch.MatchProfile{
Name: "test",
Pools: []*openmatch.Pool{
{
Name: "中国地区-相似等级的玩家",
DoubleRangeFilters: []*openmatch.DoubleRangeFilter{
{
DoubleArg: "level",
Max: 10,
Min: 5,
},
},
TagPresentFilters: []*openmatch.TagPresentFilter{
{
Tag: "region:cn",
},
},
},
},
}, &testMatchFunction{})
MatchFunction 实现
MatchFunction 负责根据 Tickets 生成 Match。当前实现是简化版本,每个 Ticket 单独成 Match(比如 pve,玩家1v1对战ai):
func (m *testMatchFunction) MakeMatches(ctx context.Context, profile *pb.MatchProfile, poolTickets map[string][]*pb.Ticket) ([]*pb.Match, error) {
matches := make([]*pb.Match, 0)
for _, pool := range profile.Pools {
tickets := poolTickets[pool.Name]
for _, ticket := range tickets {
// 重要:Match 必须包含 Tickets 字段
matches = append(matches, &pb.Match{
MatchId: ticket.Id,
Tickets: []*pb.Ticket{ticket},
MatchProfile: profile.Name,
})
}
}
return matches, nil
}
Assigner 实现
Assigner 在找到匹配后调用,负责为每个 Match 分配 GameServer。我们通过 Agones Allocation API 获取可用的 GameServer:
func allocateGameServer(ctx context.Context, matches []*pb.Match) ([]*pb.AssignmentGroup, error) {
assignmentGroups := make([]*pb.AssignmentGroup, 0, len(matches))
for _, match := range matches {
// 调用 Agones Allocation API
allocation, err := createGameServerAllocation(ctx)
if err != nil {
continue
}
// 获取 GameServer 信息(包括自定义 annotation)
gameServerName := allocation.Status.GameServerName
address := allocation.Status.Address
port := allocation.Status.Ports[0].Port
// 从 GameServer 获取自定义 annotation(如 public_address)
publicAddress := address
gs, err := agonesClient.AgonesV1().GameServers(namespace).Get(ctx, gameServerName, metav1.GetOptions{})
if err == nil {
if pa, ok := gs.Annotations["agones.dev/sdk-public_address"]; ok {
publicAddress = pa
}
}
connection := fmt.Sprintf("%s:%d", publicAddress, port)
// 重要:必须设置 TicketIds,minimatch 才能知道这个 Assignment 分配给哪些 Ticket
ticketIds := make([]string, 0, len(match.Tickets))
for _, ticket := range match.Tickets {
ticketIds = append(ticketIds, ticket.Id)
}
assignmentGroups = append(assignmentGroups, &pb.AssignmentGroup{
TicketIds: ticketIds,
Assignment: &pb.Assignment{
Connection: connection,
},
})
}
return assignmentGroups, nil
}
关键点
1. Match对象的Tickets字段必须包含对应的Ticket
MakeMatches 返回的 pb.Match 必须包含 Tickets 字段,否则 minimatch 无法正确分配 Assignment。
2. AssignmentGroup 必须包含 TicketIds
Assigner 返回的 pb.AssignmentGroup 必须包含 TicketIds 字段,告诉 minimatch 这个 Assignment 分配给哪些 Ticket。
3. 时间过滤器的理解
MatchProfile 中的 CreatedBefore 和 CreatedAfter 是在程序启动时计算的固定时间,可以用于处理 match-maker 更新后,一段时间之前的 ticket 不再匹配的场景。
4. 错误处理
如果 GameServer 分配失败,应该返回错误,这样 minimatch 会保留 ticket 等待下次匹配。如果返回 nil 或空的 AssignmentGroup,ticket 会被释放,下次 tick 会重新匹配(可能导致重复处理)。
5. 调用时机
- MakeMatches:每个 tick(如 1 秒)都会被调用(如果有活跃的 tickets)
- Assigner:只在找到匹配时被调用(不是每个 tick 都调用)
使用体验
优点
- 轻量级:单进程运行,无需复杂的 Kubernetes 部署
- API 兼容:与 Open Match 协议完全兼容,可以配合open-match一个用于生产一个用于开发测试,方便切换
- 易于集成:可以轻松集成到现有服务中
- 可扩展:通过 Redis 存储状态,frontend和backend都天然支持水平扩展
适用场景
- 测试匹配逻辑
- 需要快速验证匹配算法的场景
- 不希望额外管理open-match的复杂性,只想实现 match 核心逻辑
总结
minimatch 是一个很好的轻量级匹配服务选择,特别适合中小型项目或开发环境。通过将 minimatch 集成到服务内部,我们可以快速搭建一个完整的匹配系统,并与 Agones 无缝集成。
对于生产环境,如果匹配量很大,可以考虑使用 Open Match 或对 minimatch 进行优化(如增加缓存、优化匹配算法等)。