MMO游戏中的“跨服团队副本”匹配与状态同步系统

背景

在一个大型多人在线(MMO)游戏中,策划希望推出一个高难度的"世界级团队副本"。该副本需要20名玩家共同挑战。由于单服玩家数量有限,系统需要支持跨服匹配。副本内有复杂的机制,如多个阶段BOSS、团队资源(如团队怒气值)、可被玩家交互的环境物件。玩家共同挑战后,根据各自的贡献给予奖励。

核心需求:

  1. 匹配系统:玩家可以从各自服务器报名,系统需要将来自不同服务器的20名玩家匹配到一个副本队伍中。匹配时应考虑玩家的职业、装等、等待时间。

  2. 状态同步:副本内有许多需要全队实时感知的状态,例如:

    • BOSS的当前血量、当前阶段。

    • 团队共享的"怒气值"(由玩家输出积累,可供团队释放大招)。

    • 场景中关键机关的状态(如"是否已被激活")。

  3. 数据一致性:副本奖励的掉落和分配,必须保证绝对准确,不丢失、不重复。

性能要求

假设峰值时有数万玩家同时参与匹配。副本平均时长为30分钟。

  • 延迟:状态同步要求高,理想在50ms内。

  • 一致性:匹配结果、奖励发放需强一致;部分战斗状态可最终一致。

  • 可用性:匹配服务、副本逻辑服务需高可用。

目标

设计支撑该"跨服团队副本"玩法的后端系统。从系统架构、数据流、关键技术选型与挑战等方面进行阐述,最终形成一篇结构化的技术短文。

服务设计

  • 全局匹配服务:根据不同副本类型,匹配相应玩家。支持自定义匹配规则。
  • 副本服务:副本管理器,负责创建、销毁、监控副本实例。
  • 副本逻辑服务:副本逻辑服务器,承载具体的战斗逻辑和状态。这是一个有状态服务。
  • 跨服路由网关:负责跨服网络数据传输

服务拆分思想学习

为何副本要弄一个管理器和一个逻辑?

是分布式系统中"有状态"与"无状态"服务分离、资源调度与业务逻辑解耦准则的具体体现

  • 副本管理器:

    职责:负责元数据管理、生命周期管理和资源调度。它知道"应该有多少个副本"、"每个副本的状态(待机、运行中、已结束)"、"哪个逻辑服在运行哪个副本"。它本身不处理任何游戏战斗逻辑。

    设计目标:轻量、高可用、可扩展。它维护的是映射关系,数据量不大,可以做成无状态或容易共享状态。

  • 副本逻辑服务:

    职责:承载具体的游戏战斗逻辑和实时状态。它负责计算伤害、技能释放、同步20个玩家的位置和状态。它是一个有状态的重型进程。

    设计目标:高性能、低延迟、强一致性。它需要消耗大量CPU和内存来处理游戏Tick,其内部状态(如BOSS血量、玩家位置)是临时的、私有的,且非常重要。

为什么要分开?

  • 避免单点故障:如果两者合并,一个服务的崩溃会同时导致"副本调度"和"副本内战斗"两个核心功能全部失效。分离后,副本管理器即使短暂重启,只要副本逻辑服不重启,玩家战斗体验就不受影响。

  • 独立扩缩容:匹配排队玩家多时,需要扩容副本管理器来创建更多副本;战斗计算复杂时,需要扩容副本逻辑服的计算能力。两者资源需求(CPU密集型 vs 调度密集型)和扩缩容策略完全不同,分离使得资源利用更高效。

  • 提升可维护性:战斗逻辑的迭代发布非常频繁,而副本调度逻辑相对稳定。分离后,可以独立部署、更新副本逻辑服,而无需重启副本管理器,影响全局的副本创建。

服务间数据流

  1. 玩家报名 -> 本服-> 跨服网关 -> 全局匹配服务。
  2. 匹配成功 -> 匹配服务通知副本管理器创建副本 -> 副本管理器分配/拉起一个副本逻辑服
  3. 玩家连接副本逻辑服 -> 开始战斗,状态通过逻辑服同步给所有玩家。
  4. 战斗结束 -> 副本逻辑服上报结果给副本管理器和奖励服务。

各服务关键数据结构设计

匹配系统

mmo游戏中一般都有不同职业的玩家,如何让玩家快速匹配且队伍职业均衡很关键。还需要考虑玩家可能匹配但不响应、或者玩家取消匹配或取消报名。

简易内存实现版本

让当前的我实现的话,首先可能在内存中实现一个匹配机制更快看到效果,且暂时不考虑扩展、容灾等情况。

  • 先设定玩家匹配会发送以下数据:(玩家ID, 服务器ID, 职业, 装等分数, 加入时间)

    为何需要这些数据

    • 方便匹配的职业搭配相对合理(如至少需要2个坦克,4个治疗)。

    • 根据装等分数将玩家进行分类匹配(避免新手和顶尖玩家同场,体验差)。

  • 详细的规则约束

    • 人数满足要求(如20人)。
    • 匹配过程必须是原子性的(一个玩家不能同时被匹配进两个队伍),且需高效。
    • 等待时间可控
lua 复制代码
local MatchPool = {
    -- 按"装等分数段"划分多个队列,这是匹配"实力相近"的关键
    -- Key: 分数段 (如 1000-1099, 1100-1199 ...)
    -- Value: 一个按"加入时间"排序的玩家队列
    by_score_bucket = {
        [1000] = {  -- 假设分数段是1000
            {player_id=101, class="warrior", join_time=123456},
            {player_id=102, class="mage", join_time=123457},
            -- ...
        },
        [1100] = {...},
    },

    -- 辅助索引:用于快速查找玩家在哪个队列中,防止重复入队
    -- Key: player_id
    -- Value: {score_bucket, index_in_queue}
    player_index = { [101] = {bucket=1000, index=1} }
}

以上数据结构的设计考量:

  • by_score_bucket:将海量玩家分而治之。匹配时,我们优先在同一个分数段内找,找不到再扩大范围。这大大缩小了每次搜索的数据量,是匹配算法的性能基础。
  • 每个桶内用数组/列表:因为需要按"加入时间"先后出队(公平性),数组便于实现FIFO(先进先出)队列。
  • player_index:这是实现"原子操作"和防止玩家重复的关键。当玩家取消匹配或匹配成功时,我们需要快速定位并移除他,如果没有这个索引,就需要遍历整个池子,效率极低。
核心操作
  1. 入队:
    • 计算玩家装等所属的score_bucket。
    • 将玩家信息插入by_score_bucket[score_bucket]队列的末尾。
    • 在player_index中记录位置。
  2. 匹配:
    • 定时(如每秒)执行。
    • 遍历by_score_bucket,从某个桶的队头开始,尝试"捞出"20个职业搭配合理的玩家。
  3. 出队:
    • 通过player_index快速找到玩家在池中的位置,从队列中删除,并清理索引。
局限性
  • 并发问题:如果匹配线程在"捞人"的过程中,另一个线程在"移除玩家",会导致数据错乱。需要加锁,锁的粒度大会成为性能瓶颈。
  • 单点与状态丢失:进程崩溃,匹配池全丢。
  • 无法扩展:无法部署第二个匹配服务进程。因为匹配服务是有状态的服务,扩展时无法同步数据给另一个匹配服务。
  • 所有匹配机制都要自行实现,匹配规则扩展复杂。
redis(分布式,高可用版本)

目标:解决内存版的所有痛点,构建一个可水平扩展、高可用的匹配服务。

核心思想:将匹配服务变为无状态的,把"匹配池"这个有状态的数据,下沉到Redis这个高可用的共享存储中。匹配服务进程可以有多个,它们都从同一个Redis读写数据。

要使用redis实现内存版中by_score_bucket和player_index的功能。

那么by_score_bucket中,我们存储的那些数据,目的是为了能根据不同分段,进行匹配,并且还希望根据玩家加入匹配队列的时间优先对早加入的玩家完成匹配。

那么这个就需要排序,所以redis中拥有较好排序效率的数据结构就是sorted set,那么我们可以这样设计他的key和存储的数据:

  • Key: match:pool:{battle_type}:{score_bucket}(例如 match:pool:world_boss:1000)

  • Member: player_id

  • Score: join_time(玩家的加入时间戳)

  • 移除我们可以使用ZREM命令原子性地将这些玩家从集合中移除

  • 自动排序:ZSET按score(这里是join_time)自动排序,完美实现了"按加入时间排序的队列",且查询效率是O(log N)。

  • 范围查询:我们可以用ZRANGE ... BYSCORE命令,轻松取出最早加入的一批玩家。

而player_index本质上是一个映射表,那么redis中的HASH结构就很合适了。

  • Key: match:player:{player_id}(例如 match:player:101)
  • Field-Value:
  • score_bucket: 1000
  • class: warrior
  • server_id: 1
关键操作
  1. 入队 (enqueue):

    1. 计算score_bucket。

    2. 写入玩家属性:HSET match:player:101 score_bucket 1000 class warrior ...。

    3. 加入排序队列:ZADD match:pool:world_boss:1000 <current_timestamp> 101。

    这里需要保证1和2的原子性吗?​ 如果2成功3失败,会出现脏数据。一个更健壮的做法是用Lua脚本将这两步打包成一个原子操作。

  2. 匹配算法 (match_tick):

    这是最复杂的一步,但Redis极大地简化了它。

    1. 确定搜索范围:从一个score_bucket开始(如1000),准备ZRANGE match:pool:world_boss:1000 0 19取出前20个玩家ID。

    2. 检查职业:用HMGET批量获取这20个玩家的职业信息。在内存中计算职业组合是否满足要求。

    3. 原子性"捞出":如果满足,我们必须原子性地将这20人从ZSET中移除,并标记他们为"已匹配",防止被其他匹配进程重复捞走。

    实现:使用Redis Lua脚本。脚本内执行:检查这些玩家是否仍在集合中 -> 计算职业 -> 如果满足,则ZREM移除他们 -> 返回成功列表。Lua脚本在Redis中是原子执行的,完美解决了并发竞争问题。

lua 复制代码
-- 伪代码:匹配主循环 (在生产Redis版基础上)
function matchmaking_tick(battle_type, target_team_size)
    local start_bucket = calculate_start_bucket() -- 从某个基准分数段开始
    local max_wait_time = 300 -- 最长等待时间(秒)
    
    -- 尝试多轮匹配,每轮放宽条件
    for _, strategy in ipairs(expansion_strategies) do
        -- strategy 示例:
        -- { score_range = 100,  -- 分数范围放宽100分
        --   class_tolerance = {tank=1, healer=1}, -- 职业容忍度(可少1个坦克)
        --   wait_time_threshold = 60 } -- 等待超过60秒的玩家才用此策略
        
        -- 1. 根据策略,计算本次搜索的分数范围
        local search_range = {start_bucket - strategy.score_range, start_bucket + strategy.score_range}
        
        -- 2. 从Redis ZSET中,取出这个分数范围内,等待时间最长的候选玩家
        -- 使用ZRANGEBYSCORE命令,按加入时间(score)排序
        local candidate_player_ids = redis.call('ZRANGEBYSCORE', 
                                                'match:pool:'..battle_type, 
                                                search_range[1], search_range[2], 
                                                'WITHSCORES', 'LIMIT', 0, target_team_size*2) -- 多取一些备选
        
        -- 3. 获取这些玩家的详细信息(职业、等待时间)
        local candidates = {}
        for i=1, #candidate_player_ids, 2 do
            local player_id = candidate_player_ids[i]
            local join_time = tonumber(candidate_player_ids[i+1])
            local player_info = redis.call('HMGET', 'match:player:'..player_id, 'class', 'server_id')
            table.insert(candidates, {
                id = player_id,
                class = player_info[1],
                wait_time = os.time() - join_time,
                -- ... 其他信息
            })
        end
        
        -- 4. 应用职业平衡算法
        local matched_team = try_form_team_with_class_balance(candidates, target_team_size, strategy.class_tolerance)
        
        if matched_team then
            -- 5. 匹配成功!原子性地从池中移除这些玩家
            remove_players_from_pool(matched_team)
            return matched_team
        end
        -- 如果这轮不成功,继续下一轮(放宽更多条件)
    end
    
    -- 所有策略都尝试后仍失败
    return nil
end

职业平衡算法:

lua 复制代码
-- 伪代码:尝试组建一个职业平衡的队伍
function try_form_team_with_class_balance(candidates, team_size, class_tolerance)
    -- 目标职业配置
    local target_composition = {tank=2, healer=4, dps=14}
    -- 根据容忍度调整目标
    for class, tolerance in pairs(class_tolerance or {}) do
        target_composition[class] = math.max(0, target_composition[class] - tolerance)
    end
    
    -- 按职业分类候选人,并考虑等待时间(等待越久优先级越高)
    local candidates_by_class = {tank={}, healer={}, dps={}}
    for _, candidate in ipairs(candidates) do
        if candidates_by_class[candidate.class] then
            -- 插入时按等待时间排序,优先选等得久的
            table.insert(candidates_by_class[candidate.class], candidate)
        end
    end
    
    -- 尝试填充队伍
    local team = {}
    local current_comp = {tank=0, healer=0, dps=0}
    
    -- 策略:优先满足最稀缺的职业(通常是坦克和治疗)
    local fill_order = {'tank', 'healer', 'dps'}
    
    for _, class in ipairs(fill_order) do
        local needed = target_composition[class] - current_comp[class]
        local available = candidates_by_class[class]
        
        -- 如果可用人数不足,尝试用容忍度内的其他职业替代?(高级策略,此处简化)
        if #available < needed then
            return nil -- 本轮匹配失败
        end
        
        -- 选取等待时间最长的前needed个玩家
        for i=1, needed do
            table.insert(team, available[i].id)
            current_comp[class] = current_comp[class] + 1
        end
    end
    
    -- 检查是否凑满队伍
    if #team == team_size then
        return team
    end
    return nil
end
  1. 出队/移除 (remove):
    • 通过HGET获取玩家的score_bucket。
    • 执行ZREM match:pool:world_boss:{score_bucket} {player_id}。
    • 删除DEL match:player:{player_id}。
如何支撑海量玩家匹配?

问题:匹配算法在大量玩家时可能成为CPU热点。

解决方案:分片、批处理、算法优化。

lua 复制代码
-- 1. 分片:按战斗类型或分数段分片,不同的匹配服务处理不同的片
local shard_key = "world_boss:" .. math.floor(score_bucket / 100) -- 每100分一个分片
redis.call('ZADD', 'match:pool:shard:'..shard_key, join_time, player_id)

-- 2. 批处理:使用Redis Pipeline减少网络往返
local pipeline = redis.pipeline()
for _, player_id in ipairs(player_ids) do
    pipeline('HGETALL', 'match:player:'..player_id)
end
local player_infos = pipeline.execute()

-- 3. 算法优化:使用布隆过滤器快速排除不可能匹配的玩家
-- (伪代码)在Lua中实现一个简单的布隆过滤器检查
local function might_have_required_class(candidate_classes, target_comp)
    -- 快速检查这个玩家组合是否可能满足职业需求
    -- 这是一个启发式检查,避免深入计算
    -- ...
    return true
end
水平拓展

问题:单匹配服务成为瓶颈。

解决方案:无状态服务 + 分布式锁协调。

lua 复制代码
-- 多个匹配服务实例同时工作,需要协调避免重复匹配
function distributed_match_tick()
    -- 使用Redis分布式锁,确保同一时间只有一个实例处理某个分片
    local lock_key = "match:lock:shard:world_boss:1000"
    local lock_acquired = redis.call('SET', lock_key, 'locked', 'NX', 'EX', 5) -- 锁5秒
    
    if not lock_acquired then
        return -- 其他实例正在处理这个分片
    end
    
    -- 获取锁成功,执行匹配逻辑
    local success, matched_team = pcall(do_actual_matchmaking, 'world_boss', 20)
    
    -- 无论如何都要释放锁
    redis.call('DEL', lock_key)
    
    if success and matched_team then
        notify_players(matched_team)
    end
end
应对故障

问题:匹配服务或Redis故障怎么办?

解决方案:多活部署、故障转移、健康检查。

lua 复制代码
-- 1. 匹配服务自身高可用:多个实例 + 负载均衡
-- 每个实例定时上报心跳
redis.call('HSET', 'match:service:heartbeat', instance_id, os.time())
redis.call('EXPIRE', 'match:service:heartbeat', 30) -- 30秒过期

-- 监控服务检查心跳,剔除故障实例
local all_instances = redis.call('HGETALL', 'match:service:heartbeat')
for i=1, #all_instances, 2 do
    local instance_id = all_instances[i]
    local last_beat = tonumber(all_instances[i+1])
    if os.time() - last_beat > 20 then
        -- 实例失活,触发告警,负载均衡器将其摘除
        trigger_alert('match_service_down', instance_id)
    end
end

-- 2. Redis高可用:使用Redis Sentinel或Cluster
-- 客户端配置自动故障转移
local redis_client = require "redis"
local client = redis_client.connect({
    host = 'redis-sentinel',
    sentinels = {{host='sentinel1', port=26379}, {host='sentinel2', port=26379}},
    service_name = 'mymaster'
})
容错处理

问题:匹配过程中玩家掉线、服务崩溃、网络分区等。

解决方案:状态机、超时、补偿机制。

lua 复制代码
-- 1. 玩家状态管理:为每个匹配中的玩家设置状态和超时
function enqueue_player(player_id, battle_type)
    local now = os.time()
    -- 设置玩家状态为"匹配中",10分钟超时
    redis.call('HSET', 'match:player:'..player_id, 
               'status', 'matching',
               'enqueue_time', now)
    redis.call('EXPIRE', 'match:player:'..player_id, 600)
    
    -- 加入匹配池
    redis.call('ZADD', 'match:pool:'..battle_type, now, player_id)
end

-- 2. 后台清理任务:清理超时和孤立的玩家
function cleanup_stale_players()
    -- 查找所有状态为"匹配中"但已超时的玩家
    local cursor = 0
    repeat
        local result = redis.call('SCAN', cursor, 'MATCH', 'match:player:*', 'COUNT', 100)
        cursor = tonumber(result[1])
        local keys = result[2]
        
        for _, key in ipairs(keys) do
            local player_info = redis.call('HMGET', key, 'status', 'enqueue_time')
            if player_info[1] == 'matching' then
                local enqueue_time = tonumber(player_info[2])
                if os.time() - enqueue_time > 600 then
                    -- 超时,清理玩家
                    cleanup_player(key)
                end
            end
        end
    until cursor == 0
end

-- 3. 匹配成功后的确认机制
function on_match_success(matched_team)
    -- 为每个玩家设置"待确认"状态,30秒内需确认
    for _, player_id in ipairs(matched_team) do
        redis.call('HSET', 'match:player:'..player_id, 
                   'status', 'pending_confirm',
                   'match_time', os.time(),
                   'team_id', generate_team_id())
        redis.call('EXPIRE', 'match:player:'..player_id, 30)
    end
    
    -- 通知玩家,启动确认倒计时
    notify_players_for_confirmation(matched_team)
    
    -- 30秒后检查,如果有玩家未确认,解散队伍,将其他人重新加入匹配池
    skynet.timeout(300, function() check_confirmation(matched_team) end)
end

function check_confirmation(matched_team)
    local team_id = get_team_id(matched_team[1])
    local all_confirmed = true
    
    for _, player_id in ipairs(matched_team) do
        local status = redis.call('HGET', 'match:player:'..player_id, 'status')
        if status ~= 'confirmed' then
            all_confirmed = false
            break
        end
    end
    
    if not all_confirmed then
        -- 有玩家未确认,解散队伍
        disband_team_and_re_enqueue(matched_team)
    else
        -- 全部确认,创建副本
        create_instance_for_team(matched_team)
    end
end
完整工作量流示例
lua 复制代码
-- 主匹配服务入口
function matchmaking_service_loop()
    while true do
        -- 1. 健康检查
        if not check_health() then
            skynet.sleep(100)  -- 等待恢复
            goto continue
        end
        
        -- 2. 尝试获取分布式锁处理某个分片
        local shard = select_shard_to_process()
        if not acquire_lock(shard) then
            goto continue
        end
        
        -- 3. 执行匹配算法
        local matched_team
        local success, err = pcall(function()
            matched_team = matchmaking_tick('world_boss', 20)
        end)
        
        -- 4. 处理匹配结果
        if success and matched_team then
            on_match_success(matched_team)
        elseif not success then
            log_error('matchmaking_error', err)
            -- 触发告警,可能需要人工干预
            trigger_alert('matchmaking_logic_error', err)
        end
        
        -- 5. 释放锁
        release_lock(shard)
        
        ::continue::
        skynet.sleep(10)  -- 10毫秒后继续下一轮
    end
end
相关推荐
Zephyr_02 小时前
Unity2D游戏制作
游戏·unity
雨落在了我的手上2 小时前
如何学习java?
java·开发语言·学习
吃好睡好便好3 小时前
汽车基本组成
学习·汽车
拾忆丶夜4 小时前
unity 热力图学习
学习·unity·游戏引擎
red_redemption4 小时前
自由学习记录(183)
学习·ue项目改名字的学问
lizhihai_994 小时前
股市学习心得-智能体顶层设计文件收益供应链
大数据·人工智能·学习
中草药z5 小时前
【测试基础】Python 核心语法,一篇搞定测试脚本开发基础
开发语言·笔记·python·学习·测试·语法
一口吃俩胖子5 小时前
【脉宽调制DCDC功率变换学习笔记020】频域性能准则
笔记·学习
pottichu5 小时前
claud code 学习记录
学习