yafeiaa Blogs

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 中的 CreatedBeforeCreatedAfter 是在程序启动时计算的固定时间,可以用于处理 match-maker 更新后,一段时间之前的 ticket 不再匹配的场景。

4. 错误处理

如果 GameServer 分配失败,应该返回错误,这样 minimatch 会保留 ticket 等待下次匹配。如果返回 nil 或空的 AssignmentGroup,ticket 会被释放,下次 tick 会重新匹配(可能导致重复处理)。

5. 调用时机

  • MakeMatches:每个 tick(如 1 秒)都会被调用(如果有活跃的 tickets)
  • Assigner:只在找到匹配时被调用(不是每个 tick 都调用)

使用体验

优点

  1. 轻量级:单进程运行,无需复杂的 Kubernetes 部署
  2. API 兼容:与 Open Match 协议完全兼容,可以配合open-match一个用于生产一个用于开发测试,方便切换
  3. 易于集成:可以轻松集成到现有服务中
  4. 可扩展:通过 Redis 存储状态,frontend和backend都天然支持水平扩展

适用场景

  • 测试匹配逻辑
  • 需要快速验证匹配算法的场景
  • 不希望额外管理open-match的复杂性,只想实现 match 核心逻辑

总结

minimatch 是一个很好的轻量级匹配服务选择,特别适合中小型项目或开发环境。通过将 minimatch 集成到服务内部,我们可以快速搭建一个完整的匹配系统,并与 Agones 无缝集成。

对于生产环境,如果匹配量很大,可以考虑使用 Open Match 或对 minimatch 进行优化(如增加缓存、优化匹配算法等)。

参考