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 入会、职位与权限 。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。