《MMO 服务器开发》12.3 掉线处理与队长迁移

队长掉线了,谁带队进副本?本节把「掉线」与「退队」拆成两件事:掉线只灭在线标志、保留座位等重连,超时未回才回收;队长一掉线立刻按加入顺序把队长位交给下一位在线成员,且重连后不归还。全部用 go test -race 真跑,并讲清它与卷一顶号、重连的衔接。

12.3 掉线处理与队长迁移

队伍最脆弱的一刻,是队长掉线的那一刻。副本还在等一个人点「开始」,而那个能点的人不见了。如果这时队伍没有队长,或者队长位空着没人能接,整队就卡死在那里——玩家唯一能做的就是解散重组,体验直接崩掉。

但「队长掉线」只是表象,底下是两个必须分开处理的问题:座位要不要留?队长位要不要换? 这两件事的答案完全不同。座位应该留——掉线的人通常几秒后就回来了,把他的位置删掉、再让他重新申请入队,是一段毫无必要的折腾。队长位则应该立刻换——不能让整队等着一个可能永远不回来的人。

本节把这两个问题分别落成代码:掉线只灭在线标志、保留座位、开一个宽限窗口;队长一掉线就按稳定的加入顺序把队长交给下一位在线成员,而且重连后不归还。全部用 go test -race 真跑。

需要提前说明的是,本节写的是队伍服务内部怎么反应,不涉及「怎么发现掉线」。发现掉线是会话层的职责(卷一已经写完了心跳看门狗与重连票据),队伍服务只接收一个干净的事件。把这条边界守住,队伍服务才能在没有网络的情况下被完整单测——这正是本节所有代码都能 go test 跑起来的原因。

掉线不等于退队:座位与宽限期

先定义语义。一次 socket 断开在队伍服务看来有三种完全不同的含义:

现象语义座位队长
短暂掉线可能马上重连保留若掉线者是队长则迁移
主动退队明确不想待了立即释放若退队者是队长则迁移
超时未回认定为掉线离场宽限期满后释放已迁移

关键区别是掉线和退队必须走不同的代码路径。如果 socket 一断就调用 RemoveMember,那么玩家地铁里断 8 秒,回来时会被提示「你已不在队伍中」——而队友那边其实什么都没变。正确的做法是:socket 断开触发 MarkOffline,只改 Online 标志,座位原封不动。

座位留着,但也不能无限期留着——否则队伍永远凑不齐人数、永远进不了副本。所以配一个宽限期(《晨曦大陆》取 60 秒):期内重连就恢复,超期未回才真正回收座位。

宽限期的取值不是拍脑袋定的,它有两个硬约束。下界是重连票据的有效期:如果票据能活 120 秒、座位只留 60 秒,玩家拿着还没过期的票据回来,座位却已经没了——他会被当成新人重新申请入队。所以宽限期必须 不小于 票据 TTL。上界是玩法能忍受的等待:副本门口最多等一两分钟,超过就该换人。两个约束一夹,60 秒是一个合理的落点。这条约束关系值得写进配置校验:队伍宽限期 < 重连票据 TTL 的配置应当直接拒绝加载,而不是等到线上出事故才发现。

另一个容易忽略的点是:掉线判定不能由队伍服务自己轮询心跳来做。队伍服务如果去监听每个成员的心跳,就又把网络依赖引回来了。正确分工是会话层(卷一 6.2 的心跳看门狗)判定「这个连接超时了」,然后把一个干净的离线事件推给队伍服务。队伍服务只处理事件,不处理 socket。

队长迁移:顺序、一次性、不归还

队长迁移有三个必须钉死的规则,缺一个都会出体验问题。

第一,顺序必须是稳定的。 新队长要按「加入顺序」挑,而不是按 map 迭代顺序。Go 的 map 迭代是随机的,如果让 range p.members 决定谁当队长,同一支队伍在两次掉线里可能选出不同的人,队友看到的队长头像会莫名其妙地跳。

第二,迁移是一次性的,不归还。 队长掉线后,队友 A 接任;队长重连回来,队长位不还给他。这条规则听起来有点不近人情,但它是刻意的:如果每次重连都触发一次队长往返,队友的界面会来回抖动,而且会出现「两个人都以为自己是队长」的短暂窗口。谁先接任,谁就当到队伍解散或主动退位。

第三,迁移必须和掉线在同一个临界区里完成。 队长掉线的瞬间,如果先标记离线、再另开一次调用去选新队长,中间可能有人退队、有人掉线,选出来的「下一位」已经不在队里了。

下面这段是 L1,go test 可以直接跑。MarkOffline 在一个临界区里同时完成「灭在线标志」和「必要时迁移队长」:

// file: internal/party/session.go
// tier: L1
// verify: go test ./internal/party/ -run TestDisconnect -race

package party

import "time"

// DisconnectResult 描述一次掉线对队伍造成的后果,交给上层决定要不要
// 发 CmdPartyInfoSync 0x0702 给剩下的人。
type DisconnectResult struct {
	// LeaderChanged 为真时 leader 字段已经换成新队长。
	LeaderChanged bool
	NewLeader     PlayerID
	// Dissolved 为真时队伍已无人在线,应当解散并回收 PartyID。
	Dissolved bool
}

// MarkOffline 处理一次成员掉线。它只改 Online 标志、不删座位——座位是留给
// 重连的(对应卷一 6.3 的重连票据:重连成功后拿同一 PlayerID 回到原座位)。
// 若掉线的是队长,立即把队长迁移给下一位在线成员;若无人可继,标记解散。
func (p *Party) MarkOffline(pid PlayerID) DisconnectResult {
	p.mu.Lock()
	defer p.mu.Unlock()
	m, ok := p.members[pid]
	if !ok {
		return DisconnectResult{}
	}
	m.Online = false

	var res DisconnectResult
	if p.leader == pid {
		if next, ok := p.nextOnlineLocked(); ok {
			p.members[next].Role = RoleLeader
			p.leader = next
			res.LeaderChanged = true
			res.NewLeader = next
		}
	}
	if !p.anyOnlineLocked() {
		res.Dissolved = true
	}
	return res
}

// MarkOnline 是重连成功的回调。它把座位点亮;但队长不会自动还回去——
// 顶号式地来回换队长会让队友看到的头像一直跳,迁移是一次性的。
func (p *Party) MarkOnline(pid PlayerID) bool {
	p.mu.Lock()
	defer p.mu.Unlock()
	m, ok := p.members[pid]
	if !ok {
		return false
	}
	m.Online = true
	return true
}

// MigrateLeader 是队长主动退位时的显式迁移:挑下一位在线成员当队长。
// 与 MarkOffline 的自动迁移共用同一个挑选函数,保证两处规则一致。
func (p *Party) MigrateLeader(actor PlayerID) (PlayerID, error) {
	p.mu.Lock()
	defer p.mu.Unlock()
	if actor != p.leader {
		return 0, ErrNotLeader
	}
	next, ok := p.nextOnlineLocked()
	if !ok {
		return 0, ErrNoLeader
	}
	p.members[next].Role = RoleLeader
	p.leader = next
	return next, nil
}

// nextOnlineLocked 按加入顺序找第一个在线的非队长成员。顺序稳定是关键:
// 队友看到的新队长是可预期的,而不是 map 迭代的随机结果。
func (p *Party) nextOnlineLocked() (PlayerID, bool) {
	for _, id := range p.order {
		if id == p.leader {
			continue
		}
		if m, ok := p.members[id]; ok && m.Online {
			return id, true
		}
	}
	return 0, false
}

func (p *Party) anyOnlineLocked() bool {
	for _, m := range p.members {
		if m.Online {
			return true
		}
	}
	return false
}

// ExpireOffline 是宽限期到期后的真正清理:把仍离线的成员从队伍里删掉。
// 宽限期内他们能靠重连票据回来;超时未回,座位才被回收。
func (p *Party) ExpireOffline(now time.Time, offlineSince map[PlayerID]time.Time, grace time.Duration) []PlayerID {
	p.mu.Lock()
	defer p.mu.Unlock()
	var dropped []PlayerID
	for _, id := range append([]PlayerID(nil), p.order...) {
		m, ok := p.members[id]
		if !ok || m.Online {
			continue
		}
		since, seen := offlineSince[id]
		if !seen || now.Sub(since) < grace {
			continue
		}
		if id == p.leader {
			continue // 队长位必须先迁移,不能直接删
		}
		p.removeLocked(id)
		dropped = append(dropped, id)
	}
	return dropped
}

ExpireOffline 里有一处细节值得单独说:队长的座位不能被 ExpireOffline 直接删。因为队长位是「必须有人」的,如果宽限期清理顺手把离线队长删了,队伍会瞬间变成无主状态。正确顺序是:队长掉线时 MarkOffline 已经迁移过了,所以到 ExpireOffline 这一步,那个离线的人早已不是队长,可以安全回收。这个 if id == p.leader { continue } 是一道兜底断言——真跑到它,说明迁移漏了。

与卷一的顶号、重连如何衔接

掉线处理不是孤立的,它接在卷一的会话层之后。两个衔接点:

重连。 玩家重连走的是卷一 6.3 断线重连与重连票据 的票据流程。票据校验通过、会话恢复后,会话层调用本节队伍的 MarkOnline,把座位点亮。这里有个隐含约束:票据里的 PlayerID 必须与座位上的 PlayerID 一致——否则就是「用别人的重连票据占我的座位」,属于会话层要拦的问题,队伍层只认 PlayerID。

顶号。 同一账号在别处登录时,卷一 7.2 顶号、踢人与连接迁移 会把旧连接踢掉。旧连接被踢会触发一次 socket 断开事件,进而调用 MarkOffline。所以「顶号」在队伍服务看来就是一次普通的掉线——它不需要为顶号写特例,只要保证 MarkOffline 是幂等的:同一个人被连续标记离线两次,第二次是无害的。

这两个衔接点合起来说明一件事:队伍服务不该去监听 socket,它只应该接收「某某离线了 / 某某上线了」这两个高层事件。谁把这些事件翻译出来(会话层、网关、还是账号服务),是卷一的问题;队伍服务只关心事件本身的语义。这条边界画清楚了,队伍服务才可能被单测——它不依赖任何网络。

L1 实现与并发验证

下面这段测试节选覆盖迁移的两条核心路径:非队长掉线不触发迁移,队长掉线立刻迁移给下一位在线成员,且重连不归还:

// snippet: internal/party/party_test.go(节选)

// TestDisconnectLeaderMigration 钉死 12.3:队长掉线后队长位立刻给下一位
// 在线成员,且顺序稳定(按加入顺序,不是 map 随机)。
func TestDisconnectLeaderMigration(t *testing.T) {
	l := ids(3) // 100 队长, 101, 102
	p := NewParty(PartyID(5), l[0])
	for _, id := range l[1:] {
		if _, err := p.TryJoin(l[0], id); err != nil {
			t.Fatalf("join: %v", err)
		}
	}
	// 101 先掉线,队长交给 102。
	if r := p.MarkOffline(l[1]); r.LeaderChanged {
		t.Fatalf("non-leader offline should not migrate leader")
	}
	res := p.MarkOffline(l[0])
	if !res.LeaderChanged || res.NewLeader != l[2] {
		t.Fatalf("leader migration: got %+v, want NewLeader=%d", res, l[2])
	}
	// 重连不会把队长还回去。
	if !p.MarkOnline(l[0]) {
		t.Fatalf("reconnect should find the seat")
	}
	if p.Leader() != l[2] {
		t.Fatalf("leader should stay %d after reconnect, got %d", l[2], p.Leader())
	}
}

验证命令是 go test ./internal/party/ -race,六个用例全绿,包括这个迁移用例。-race 在这里不是走过场:MarkOffline 同时改 Online 和 leader,如果漏了锁,race detector 会在并发掉线的压测里直接把它标出来。

本节小结

事件座位队长是否通知客户端
成员掉线(非队长)保留不变是(0x0702 更新在线状态)
队长掉线保留迁移给下一位在线成员是(0x0702 更新队长)
重连点亮不归还是
宽限期满未回回收已迁移是
全员离线待解散—解散包

把「掉线」和「退队」分成两条路径,把队长迁移的规则钉成「按加入顺序、一次性、不归还」,队伍在掉线潮里就不会卡死。到这里,组队服务的三个核心问题——状态归属、并发写、掉线治理——都落了地。下一章我们把同样的方法用到规模更大、关系更复杂的公会系统上。

阅读导航:上一节:12.2 满员判断与匹配锁定 · 下一节:13.1 入会、职位与权限 。

继续阅读

探索更多技术文章

浏览归档,发现更多关于系统设计、工具链和工程实践的内容。

全部文章 返回首页

「mmo」更多文章

  1. 《MMO 服务器开发》工程化与运营 · 目录
  2. 《MMO 服务器开发》9.3 上线检查清单
  3. 《MMO 服务器开发》9.2 性能预算达标