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