《MMO 服务器开发》7.2 顶号、踢人与连接迁移

同账号第二次登录,该不该踢掉旧连接?踢了之后,旧连接迟到的断开事件又会不会把新会话一起删掉?本节给 aegis 写 Registrar,把「先摘旧、后关旧、再通知」的顺序钉死,用 go test -race 复现那个最经典的顶号竞态,并讲清「踢人」与「迁移」两种语义的区别。

7.2 顶号、踢人与连接迁移

上一节把会话表写对了,但只解决了「一个账号一条活连接」这个不变量的一半:当第二条连接带着同一个账号进来时,第一条该怎么办? 这就是顶号。它是 MMO 里最高频的会话事件之一——玩家换设备、断线重连超时后重登、被顶下去的老客户端又自动重试——几乎每个玩家每天都会触发。

顶号本身不难,难的是它牵动的时序。你踢掉旧连接,旧连接的读循环不会立刻停:它可能还要几百毫秒才发现 socket 已死,然后跑一遍清理逻辑。如果这个迟到的清理把新会话当成旧会话删了,玩家就会「刚登进去就掉线」。这是所有会话系统里最经典、也最容易在压测里漏掉的竞态。

本节给 aegis 写一个 Registrar,把顶号的顺序固定下来:先摘旧、后关旧、再通知。同时区分两种容易混为一谈的语义——踢人(旧连接必须死)和迁移(旧连接优雅交接,不能丢包)。全部用 go test -race 真跑。

顶号的语义:先决定「谁赢」

在写代码之前先定语义。同账号二次登录,行业里三种策略:

策略行为适用
踢旧新连接获胜,旧连接被主动断开绝大多数 MMO 的默认
拒新保留旧连接,告诉新连接「账号已在别处登录」防盗号、强 PK 场景
让玩家选新连接进入「确认顶号」态,玩家点了才踢端游的常见交互

本节实现最普遍的「踢旧」,但把策略抽成一个可替换的决策点,拒新只是返回不同的错误码。无论哪种策略,底层动作都一样:改会话表 + 处理旧连接。区别只在谁被关掉。

顶号还必须通知客户端,否则玩家只看到「连接断开」这种无信息量的现象。aegis 在关旧连接前先发一帧 CmdErrorNotify 0x0003,payload 里带一个业务错误码(例如 1002 = 账号在别处登录),客户端据此弹「您的账号已在其他设备登录」。顺序仍然是「先摘、后关」,但「发通知」要排在「关 socket」之前。

这一帧直接复用卷一 §3.5 定死的固定头,不另造格式:

固定头 11 字节:length(4, BE) | cmd(2, BE) | seq(4, BE) | flags(1)
踢号通知:cmd = CmdErrorNotify = 0x0003
payload = reason_code(2, BE) + msg_len(2, BE) + utf8_msg
length = 7 + len(payload),帧总长 = 11 + len(payload),MaxPayload = 1 << 20

reason_code 是给程序判断的,utf8_msg 是给玩家看的兜底文案——两者都要,因为客户端版本可能不认识新错误码,这时至少能显示服务端给的中文。

从零实现:Registrar

Registrar 把顶号策略和会话表拼起来。它唯一要保证的事是:旧连接在表里被摘除,必须发生在它被关闭之前。下面这段是 L1,go test 可以直接跑:

// file: internal/session/kick.go
// tier: L1
// verify: go test ./internal/session/ -run TestKick

package session

import "sync"

// Registrar enforces the "one live session per account" invariant on top of a
// Table. Login evicts and closes a previous session; Migrate hands it off
// without closing it.
type Registrar struct {
	table *Table

	mu     sync.Mutex
	kicked map[string]int
}

// NewRegistrar returns a registrar bound to table.
func NewRegistrar(table *Table) *Registrar {
	return &Registrar{table: table, kicked: make(map[string]int)}
}

// Login binds c to account. If another live connection already holds the
// account it is evicted from the table first, then closed, so the old
// connection's disconnect handler (Table.Remove) can never delete the new
// binding: by the time it runs, its connID is already gone.
func (r *Registrar) Login(account string, c *Conn) {
	old := r.table.Bind(account, c)
	if old == nil {
		return
	}
	r.mu.Lock()
	r.kicked[account]++
	r.mu.Unlock()
	_ = old.Close()
}

// Migrate rebinds an existing account to a new connection (for example after
// the player is moved to another gateway). Unlike Login it does NOT close the
// old conn: the caller drains it gracefully so in-flight frames are not lost.
func (r *Registrar) Migrate(account string, c *Conn) (old *Conn) {
	return r.table.Bind(account, c)
}

// Kick force-closes whatever session currently holds account, without a new
// login taking its place. Admin tools and risk control use it. It removes the
// conn from the table before closing, for the same reason Login does. It
// returns ErrUnknownConn when the account is already offline.
func (r *Registrar) Kick(account string) error {
	c, ok := r.table.Lookup(account)
	if !ok {
		return ErrUnknownConn
	}
	r.mu.Lock()
	r.kicked[account]++
	r.mu.Unlock()
	r.table.Remove(c.ID)
	return c.Close()
}

// Kicked returns how many times account has been force-evicted by a later
// login. A non-zero value is a healthy signal, not an error.
func (r *Registrar) Kicked(account string) int {
	r.mu.Lock()
	defer r.mu.Unlock()
	return r.kicked[account]
}

关键只有一行:old := r.table.Bind(account, c) 在 old.Close() 之前。Bind 在锁内已经把旧连接从 byConn 和 byAcc 里都摘干净了,所以哪怕旧连接的清理回调紧接着就跑,它在表里也已经查无此 conn——Remove 直接返回,不会误伤新会话。

反过来写会怎样?如果先 old.Close() 再 Bind,旧连接的 Remove 可能在 Bind 之前执行,此时账号索引还指向旧的,于是被正确清空——看起来没事;但如果 Remove 晚于 Bind 执行,Bind 刚把账号指向新连接,Remove 又会把它清掉,新会话就成了孤儿。「先摘、后关」把这段窗口彻底消除,因为它不依赖旧连接的清理跑得快还是慢。

最经典的竞态:旧连接断开不得误删新会话

把上面的话变成一条能跑的测试。场景:alice 登录两次,第二次顶掉第一次,然后第一次那条连接的 socket 才「死」,跑它的清理回调。断言是第二次的会话必须还在。下面这段测试就是为这个竞态写的:

// snippet: internal/session/kick_test.go(节选)

func TestKickSecondLoginEvictsFirst(t *testing.T) {
	tab := NewTable()
	reg := NewRegistrar(tab)

	first := NewConn(nil)
	first.onClose = func(c *Conn) { tab.Remove(c.ID) } // 旧连接的清理
	reg.Login("alice", first)

	second := NewConn(nil)
	second.onClose = func(c *Conn) { tab.Remove(c.ID) }
	reg.Login("alice", second) // 顶号:先摘 first,再关 first

	if !first.Closed() {
		t.Fatal("first session was not closed on takeover")
	}
	if second.Closed() {
		t.Fatal("second session must stay open")
	}
	if got, ok := tab.Lookup("alice"); !ok || got != second {
		t.Fatalf("Lookup(alice) = %v, %v; want the second session", got, ok)
	}
	if n := tab.Len(); n != 1 {
		t.Fatalf("Len = %d, want 1", n)
	}
}

时序画出来是这样:

Login(alice, first)          Login(alice, second)         first 的 socket 终于死
     |                              |                              |
  Bind: byAcc[alice]=first      Bind: 摘掉 first               first.Close() 已跑过
                                     byAcc[alice]=second          或回调 Remove(first.ID)
                                     first.Close() -> Remove         -> byConn 里没有 -> 空操作
                                          (表里已无 first)          -> 新会话安然无恙

注意测试里 first.Close() 是通过 Registrar.Login 间接触发的,回调 tab.Remove(first.ID) 也真的跑了——它不是被跳过,而是因为 first 早已不在表里,Remove 安全地空转。这正是「先摘、后关」的收益:迟到的清理变成无害的空操作,而不是需要靠时序运气去躲的炸弹。

谁在踢:三种触发

踢人不只发生在顶号。运营后台踢掉卡死的玩家、风控封禁一个外挂号,都要在没有新登录的情况下关掉一条活连接。它们和顶号共用同一张会话表,但入口不同:

触发入口旧连接的处理
顶号Registrar.Login先摘后关,先发 CmdErrorNotify
管理员/运营踢人Registrar.Kick直接摘除并关闭
风控封禁Kick + 黑名单摘除、关闭,并写入禁止登录名单

Kick 是「无继任者的关停」:它按账号查到活连接,先把它从表里摘掉,再关 socket。为什么还是先摘后关?因为关 socket 会触发读循环的清理回调,那个回调同样要 Remove。如果先关,回调可能在 Kick 自己的 Remove 之前跑完,随后 Kick 又对一条已删的连接重复操作——虽然幂等,但会让「踢人成功」和「连接自然断开」两件事在日志里无法区分。先摘后关则让 Kick 成为唯一改表的人。下面这段测试覆盖在线与离线两种情况:

// snippet: internal/session/kick_test.go(节选)

func TestAdminKickRemovesSession(t *testing.T) {
	tab := NewTable()
	reg := NewRegistrar(tab)

	c := NewConn(nil)
	c.onClose = func(x *Conn) { tab.Remove(x.ID) }
	reg.Login("carol", c)

	if err := reg.Kick("carol"); err != nil {
		t.Fatalf("Kick(carol) err = %v", err)
	}
	if !c.Closed() {
		t.Fatal("kicked session is still open")
	}
	if _, ok := tab.Lookup("carol"); ok {
		t.Fatal("kicked session is still indexed")
	}
	if err := reg.Kick("carol"); err != ErrUnknownConn {
		t.Fatalf("Kick(offline) = %v, want ErrUnknownConn", err)
	}
}

注意最后一行断言:对已经离线的账号再 Kick 必须返回 ErrUnknownConn,而不是静默成功。运营后台点「踢人」时,如果玩家其实早就掉线了,操作员需要知道这一点——「踢了但没人在线」和「踢掉了」是两条不同的审计记录。

连接迁移:不是踢,是交接

顶号和迁移看着像,语义却相反。顶号要旧连接立刻死;迁移要旧连接优雅退场。典型场景是玩家从网关 A 被移到网关 B(A 过载、或玩家跨到另一个区),这时旧连接上可能还有没发完的推送,直接 Close 会丢包。

所以 Migrate 复用了 Bind,但不关旧连接——它只把账号索引挪到新连接,然后把旧连接返回给调用方,由调用方自己决定怎么排空(等 in-flight 帧发完、超时兜底、再关)。测试断言迁移之后旧连接仍然是打开的:

// snippet: internal/session/kick_test.go(节选)

func TestMigrateKeepsOldOpen(t *testing.T) {
	tab := NewTable()
	reg := NewRegistrar(tab)

	old := NewConn(nil)
	reg.Migrate("bob", old)

	drained := NewConn(nil)
	if ev := reg.Migrate("bob", drained); ev != old {
		t.Fatalf("Migrate returned %v, want the old conn", ev)
	}
	if old.Closed() {
		t.Fatal("Migrate must not close the old conn")
	}
	if n := reg.Kicked("bob"); n != 0 {
		t.Fatalf("Migrate must not count as a kick, got %d", n)
	}
}

Migrate 不计入 Kicked,因为「迁移」和「被顶」对玩家的含义完全不同:前者不该弹「账号在别处登录」。迁移时新连接通常还会继承旧连接的会话序号(第 6 章的 seq)和幂等窗口(第 11 章),保证玩家跨越网关时不会重复执行一条已经生效的命令——这部分留到讲幂等时再展开。

本节小结

顶号和迁移是同一个 Bind 的两种用法,区别只在关不关旧连接。真正容易写错的从来不是「踢」这个动作,而是「踢完之后,旧连接的余波会不会打到新会话」。答案是:只要坚持先摘、后关,旧连接的清理在表里永远查无此人,余波自动变成空操作。

go test ./internal/session/ -run TestKick -race 本节 3 个测试全绿(顶号、迁移、管理员踢人),加上上一节的会话表测试,整个 internal/session 包共 10 个测试在 -race 下通过。下一节换个视角:前面写的这些都在一个网关内部,那么网关整体该负责到哪一步、又绝不该碰什么?

阅读导航:上一节:7.1 会话表与连接管理 · 下一节:7.3 网关的职责边界 。

继续阅读

探索更多技术文章

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

全部文章 返回首页

「mmo」更多文章

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